Compare commits

..
Author SHA1 Message Date
james.maesandClaude Fable 5.1 4f41f41a05 fix(release): update-packaging refuses to write missing sums; real 1.0.0 sums
scripts/update-packaging.mjs could not be shown to write zeros: run against the real
cli-v1.0.0 SHA256SUMS from the all-zero placeholders at 1bc4ddd it fills correct values
(reproduced in a clean worktree), and branch head 8fc630d already carried them. The old
script was fragile, though: it read lines with split/reverse (a `*file` binary-mode name
or a stray column would silently mis-key), never checked that a sum was 64 hex, accepted
all-zero sums, did not check that any placeholder had actually matched, and wrote the
formula before it knew the manifest would succeed.

Now it parses `shasum -a 256` lines strictly, requires every target to be 64 lowercase
hex and not all zeros, requires at least one url/sha256 pair and no unfilled sha256 left in
the formula, computes both files first and writes only if everything passed, otherwise
exits 1 with "nothing was written". Tested in scripts/update-packaging.test.mjs (fixture
SHA256SUMS; run by `npm test` via `test:scripts`). Docs (packaging README, release runbook
step 7, tap README) say to compare the diff with SHA256SUMS and that tap/bucket CI catches
a wrong or zero sum only when its PR is opened and read.

Ran against /Volumes/HD-2/Git.Local/QRun-IO/.worktrees/qqq-cli-release/dist/SHA256SUMS;
the packaging files are unchanged from 8fc630d, and each sum was checked by hand against it:
  darwin-arm64  5017a32ac5031d9146d541381099a959e5c19bbf04c48f725045d89d6007b211
  darwin-x64    eb9b1d43d24b639118d94b175c343080a39ae9ba64c24645c1792ff3b16a37f1
  linux-arm64   25f513bdda13bbe40cf536844eebc1e3a7e21cf7804024f25ebe7c42793d7ebc
  linux-x64     804d06cb7c0012c2f185ae5d94c6bb8f9e2482a440bb3c165361f1a14c941e41
  windows-x64   cf418d7842a227cee412171878228004f9bc5098a272c64b89512d1e2d06a176
(linux musl sums are in SHA256SUMS but not used by the formula or manifest.)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 12:57:49 -06:00
james.maesandClaude Fable 5.1 8fc630df42 packaging: qqq 1.0.0 checksums for the Homebrew formula and the Scoop manifest
Written by scripts/update-packaging.mjs from the SHA256SUMS published on the cli-v1.0.0 release.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 12:50:53 -06:00
19 changed files with 110 additions and 1021 deletions

No files matched your search

-24
View File
@@ -3,30 +3,6 @@
All notable changes to the qqq CLI and its packages. Versions follow [semver](https://semver.org);
`@qrun-io/qqq-ui-api-client` follows the UI API version of the document it is generated from.
## [Unreleased]
### Added
- **F12 shows and hides the Assistant panel** on every screen (Ctrl+G, `:ask` and a click on `12Ask` do
the same). Hiding keeps the conversation, its context and the draft; Esc hides too (with a draft,
nothing is asked). The F-key bar has `11` (reserved, blank) and `12Ask` from 120 columns (dimmed
without the extension); narrower, the `?` help and F9 › Work say "F12 assistant". F12 is read as the
legacy `ESC [24~` and as the kitty keyboard protocol key; README notes macOS's F12 mapping.
- Assistant panel: `u` on a link shows its address.
- Kernel: `parseMarkdown`, `markdownText`, `answerDocument` and `linkTargetKey` (render/markdown.ts): the
assistant's markdown as blocks with numbered links, one per target (a filter on the primary key is
that record; JSON and base64 filters compare by content).
### Fixed
- **The assistant's answers render** instead of showing raw markdown, in the TUI panel and in
`qqq assistant ask`: HTML entities decoded (`healthy&#58;` was shown as is), headings, wrapped
paragraphs, lists, bold and code, and pipe tables laid out as a grid (CLI: sub-tables) instead of `| --- |`
rows. Links show as `label ↗` (CLI: `label (qqq table get …)` or `label [n]` with the command under
Links), never their URL-encoded `/app/...?filter=` targets; the tool results' links are listed only
when the text does not already show the same target, so `db_performance` no longer appears three times.
Exact duplicate paragraphs are shown once; the timing and token line stays, dimmed.
## [1.0.0] - 2026-11-01
`qqq` 1.0: the command-line client and the full-screen terminal UI (`qqq ui`), shipped together as one
+1 -1
View File
@@ -69,7 +69,7 @@ the options.
| `report`, `saved-report`, `saved-view`, `share`, `bulk-profile`, `audit`, `widget`, `script`, `job`, `prefs`, `help-search` | reports, saved reports, saved views, sharing, bulk-load mappings, audits, widgets as text, scripts, jobs, preferences, help |
| `api <operationId>` | any UI API operation by id |
| `extensions`, `esb`, `collab`, `health`, `alerts`, ... | one noun per extension, run when the server has it installed |
| `assistant ask`, `assistant config` | ask the in-app assistant (qbit-assistant) a question about your data: the answer streams block by block, rendered (tables as sub-tables, links as `label (qqq table get …)` or `label [n]` with the command under Links, never a URL), then its numbers (`--table`/`--record` send the page as context, `-c` continues a conversation, `--json` the whole answer); `config` says whether it answers and with which model, or why not |
| `assistant ask`, `assistant config` | ask the in-app assistant (qbit-assistant) a question about your data: the answer streams, then its numbers and the records and tables it refers to (`--table`/`--record` send the page as context, `-c` continues a conversation, `--json` the whole answer); `config` says whether it answers and with which model, or why not |
| `print`, `printer` | print records, table lists and text through the printing extension, follow print jobs; printers and print agents |
| `history` | everything you did with the CLI and the TUI, like a shell history (see below) |
| `login`, `logout`, `profile`, `config`, `cache`, `completion`, `doctor` | sign-in, saved server profiles (`profile list|add|edit|use|remove`), settings, the metadata cache, shell completion (bash, zsh, fish, PowerShell), checks |
+25 -86
View File
@@ -13,7 +13,7 @@
import type { Command } from 'commander'
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
import { answerDocument, assistantContextFor, describeAssistantConfig, linkTargetKey, markdownText, parseAppHref, parseMarkdown, type AssistantAnswer, type MdInline } from '@qrun-io/qqq-core'
import { assistantContextFor, describeAssistantConfig, type AssistantAnswer } from '@qrun-io/qqq-core'
import { io, type Context, UsageError } from '../context.js'
import { positiveInt, type Json } from './shared.js'
@@ -22,73 +22,16 @@ export function plainAnswer(text: string): string {
return text.replace(/\*\*(.+?)\*\*/g, '$1').replace(/__(.+?)__/g, '$1').replace(/`([^`]+)`/g, '$1').replace(/^#{1,6}\s+/gm, '')
}
/** The qqq command that opens a link's target (`qqq table get t 4`, `qqq table query t --filter-json '…'`), or '' for other pages. */
export function linkCommand(href: string): string {
const target = parseAppHref(href)
if (!target?.tableName) return ''
const key = linkTargetKey(href)
const record = /^record:[^:]+:(.*)$/.exec(key)
if (record) return `qqq table get ${target.tableName} ${shellQuote(record[1]!)}`
if (key === `table:${target.tableName}:` || !target.filter) return `qqq table query ${target.tableName}`
return `qqq table query ${target.tableName} --filter-json ${shellQuote(JSON.stringify(target.filter))}`
}
function shellQuote(text: string): string {
return /^[A-Za-z0-9_.:@%+=,/-]+$/.test(text) ? text : `'${text.replace(/'/g, `'\\''`)}'`
}
/**
* Renders an answer's markdown for the terminal, block by block (so it can print while the model
* writes): entities decoded, tables as sub-tables, each link as `label (qqq table get …)` when that
* is short, else `label [n]` with the command listed under Links (numbered once per target).
*/
export class AnswerRenderer {
readonly footnotes: Array<{ n: number; label: string; open: string }> = []
private readonly noted = new Map<string, number>()
private printed = 0
constructor(private readonly width = 100) {}
private note(label: string, href: string): number {
const key = linkTargetKey(href)
let n = this.noted.get(key)
if (n === undefined) {
n = this.noted.size + 1
this.noted.set(key, n)
this.footnotes.push({ n, label, open: linkCommand(href) || href })
}
return n
}
/** Text for the blocks completed since the last call (all of them when `done`), '' when none. */
next(text: string, done: boolean): string {
const doc = parseMarkdown(text)
const complete = done ? doc.blocks.length : Math.max(0, doc.blocks.length - 1)
const out: string[] = []
for (; this.printed < complete; this.printed++) {
const block = doc.blocks[this.printed]!
const inTable = block.kind === 'table'
out.push(markdownText({ blocks: [block], links: doc.links }, this.width, (part: MdInline) => {
const open = linkCommand(part.href ?? '')
if (!inTable && open && !open.includes('--filter-json') && open.length <= 48) return `${part.text} (${open})`
return `${part.text} [${this.note(part.text, part.href ?? '')}]`
}))
}
return out.join('\n\n')
}
/** Numbers the answer's other links (tool results) the text did not show, once per target. */
extra(answer: { text: string; links: Array<{ label: string; href: string }> }): void {
for (const link of answerDocument(answer).extraLinks) this.note(link.label, link.href)
}
}
/** The whole answer rendered at once (tests, and output that is not streamed). */
export function renderAnswer(answer: { text: string; links: Array<{ label: string; href: string }> }, width = 100): { text: string; links: Array<{ n: number; label: string; open: string }> } {
const renderer = new AnswerRenderer(width)
const text = renderer.next(answer.text, true)
renderer.extra(answer)
return { text, links: renderer.footnotes }
/** Rows for the links table: kind, label, table, id, the app path and the qqq command that opens it. */
export function linkRows(answer: AssistantAnswer): Json[] {
return answer.links.map((link) => ({
kind: link.kind,
label: link.label,
table: link.tableName ?? '',
id: link.recordId ?? '',
href: link.href,
open: link.kind === 'record' ? `qqq table get ${link.tableName} ${link.recordId}` : link.kind === 'table' ? `qqq table query ${link.tableName}` : '',
}))
}
export function registerAssistant(noun: Command, ctx: () => Context): void {
@@ -123,42 +66,38 @@ export function registerAssistant(noun: Command, ctx: () => Context): void {
const conversationId = options.conversation ? positiveInt(options.conversation, '--conversation') : undefined
const assistant = await c.assistant()
const human = c.format === 'table' || c.format === 'text'
const renderer = new AnswerRenderer(Math.max(40, io.width() ?? 100))
let printedAny = false
const emit = (text: string) => {
if (!text) return
io.out(`${printedAny ? '\n\n' : ''}${text}`)
printedAny = true
}
let streamed = false
const answer = await assistant.ask(question, {
conversationId,
context: assistantContextFor({ tableName: options.table, recordId: options.record }),
onEvent: (event, partial) => {
onEvent: (event) => {
if (!human) return
// whole blocks as the model completes them, rendered (a table prints once it is complete)
if (event.type === 'text.delta') emit(renderer.next(partial.text, false))
else if (event.type === 'tool.start' && typeof event.summary === 'string' && event.summary) c.note(`… ${event.summary}`)
if (event.type === 'text.delta' && typeof event.text === 'string') {
io.out(plainAnswer(event.text))
streamed = true
} else if (event.type === 'tool.start' && typeof event.summary === 'string' && event.summary) {
c.note(`… ${event.summary}`)
}
},
})
if (answer.status === 'ERROR') {
if (printedAny) io.out('\n')
if (streamed) io.out('\n')
throw new UiApiError(502, { code: answer.error?.code ?? 'INTERNAL', message: answer.error?.message ?? 'The assistant could not answer.' })
}
if (!human) {
c.print({ data: answer })
return
}
emit(renderer.next(answer.text, true))
if (!streamed) io.out(plainAnswer(answer.text))
io.out('\n')
for (const notice of answer.notices) c.note(`${notice.level}: ${notice.text}`)
if (answer.numbers.length) {
io.out('\n')
c.print({ data: answer.numbers, rows: answer.numbers.map((n) => ({ number: n.label, value: n.value })), columns: ['number', 'value'] })
c.print({ data: answer.numbers, rows: answer.numbers.map((n) => ({ number: n.label, value: n.value, href: n.href ?? '' })), columns: ['number', 'value', 'href'] })
}
renderer.extra(answer)
if (renderer.footnotes.length) {
io.out('\nLinks\n')
c.print({ data: renderer.footnotes, rows: renderer.footnotes.map((f) => ({ n: `[${f.n}]`, label: f.label, open: f.open })), columns: ['n', 'label', 'open'] })
if (answer.links.length) {
io.out('\n')
c.print({ data: answer.links, rows: linkRows(answer), columns: ['kind', 'label', 'table', 'id', 'open'] })
}
const seconds = answer.elapsedMs === undefined ? '' : `${(answer.elapsedMs / 1000).toFixed(1)} s, `
c.note(`(${seconds}conversation ${answer.conversationId}; continue with: qqq assistant ask -c ${answer.conversationId} "...")`)
+5 -29
View File
@@ -14,55 +14,31 @@ import { Assistant, Collaboration, Printing, missingQBitMessage, runProcess, typ
import { openRealtimeStream } from '@qrun-io/qqq-ui-api-client'
import { CLIENT_CHANNEL, authenticatedFetch, clientFor, currentToken, selectTarget, sessionCookieHeaderFor, target, type Target } from './session.js'
/** A write that ends the process quietly (status 0) when it throws because the reader has gone. */
function safeWrite(stream: NodeJS.WriteStream, text: string): void {
try {
stream.write(text)
} catch (error) {
if (isBrokenPipe(error)) process.exit(0)
throw error
}
}
/**
* Where output goes. The CLI writes to the process streams; `runCli` (the TUI's `:` command line)
* points these at a pane for the duration of one command.
*/
export const io = {
out: (text: string): void => { safeWrite(process.stdout, text) },
err: (text: string): void => { safeWrite(process.stderr, text) },
out: (text: string): void => { process.stdout.write(text) },
err: (text: string): void => { process.stderr.write(text) },
isTTY: (): boolean => Boolean(process.stdout.isTTY),
/** The terminal width table output fits nested results in; undefined when not on a terminal. */
width: (): number | undefined => (process.stdout.isTTY ? process.stdout.columns : undefined),
}
/** What a write to a pipe whose reader has gone ends with: EPIPE, or (macOS, a stream already torn down) the errors that follow it. */
const BROKEN_PIPE_CODES = new Set(['EPIPE', 'ECONNRESET', 'ERR_STREAM_DESTROYED', 'ERR_STREAM_WRITE_AFTER_END'])
/** Whether an error is a write to a pipe whose reader has gone (`qqq table query | head -1`). */
export function isBrokenPipe(error: unknown): boolean {
return BROKEN_PIPE_CODES.has(String((error as NodeJS.ErrnoException | undefined)?.code))
return (error as NodeJS.ErrnoException | undefined)?.code === 'EPIPE'
}
/**
* A pipe nobody reads any more ends the command at once with status 0, as `yes | head` does, whatever
* else was going on: the process exits from the stream's error event, from a write that throws, and
* from an uncaught error that is one, so no later code can turn it into a failure.
*/
export function exitOnBrokenPipe(streams: Array<NodeJS.WriteStream> = [process.stdout, process.stderr], exit: (code: number) => void = (code) => process.exit(code), on: (event: 'uncaughtException', listener: (error: Error) => void) => unknown = (event, listener) => process.on(event, listener)): void {
/** Exits quietly with status 0 when stdout or stderr is a pipe nobody reads any more, as `yes | head` does. */
export function exitOnBrokenPipe(streams: Array<NodeJS.WriteStream> = [process.stdout, process.stderr], exit: (code: number) => void = (code) => process.exit(code)): void {
for (const stream of streams) {
stream.on('error', (error) => {
if (isBrokenPipe(error)) exit(0)
else throw error
})
}
on('uncaughtException', (error) => {
if (isBrokenPipe(error)) exit(0)
else {
process.stderr.write(`${error.stack ?? error.message}\n`)
process.exit(1)
}
})
}
/** Exit codes, as in the design doc (KofTwentyTwo ADR-0031 shape). */
+4 -1
View File
@@ -33,7 +33,10 @@ importLegacyCredentials().then(() => program.parseAsync()).catch((error: unknown
return
}
// a stream that failed with EPIPE (an export piped into head): the reader left, nothing to report
if (isBrokenPipe(error)) process.exit(0)
if (isBrokenPipe(error)) {
process.exitCode = 0
return
}
const { code, text } = describeError(error, Boolean(program.opts().trace))
process.stderr.write(text + '\n')
process.exitCode = code
+4 -29
View File
@@ -5,10 +5,7 @@
import assert from 'node:assert/strict'
import { after, before, describe, test } from 'node:test'
import { linkCommand, plainAnswer, renderAnswer } from '../commands/assistant.js'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { TEST_SOURCE } from './harness.js'
import { plainAnswer } from '../commands/assistant.js'
import { runCli, sandbox } from './harness.js'
import { StubResponse, startStub, type J, type Stub } from './stub.js'
@@ -62,11 +59,9 @@ describe('qqq assistant against a stub', () => {
const r = await qqq(['-O', 'table', 'assistant', 'ask', '--table', 'customer', '--record', '5', 'how', 'many', 'customers?'])
assert.equal(r.code, 0, r.stderr)
assert.match(r.stdout, /^There are 42 customers\.\n/)
assert.match(r.stdout, /Customer\s+42\n/)
assert.match(r.stdout, /Links\n/)
assert.match(r.stdout, /\[1\]\s+Acme \(Customer\)\s+qqq table get customer 5/)
assert.match(r.stdout, /\[2\]\s+Customer \(42\)\s+qqq table query customer/)
assert.doesNotMatch(r.stdout, /\/app\//)
assert.match(r.stdout, /Customer\s+42\s+\/app\/customer/)
assert.match(r.stdout, /record\s+Acme \(Customer\)\s+customer\s+5\s+qqq table get customer 5/)
assert.match(r.stdout, /table\s+Customer \(42\)\s+customer\s+qqq table query customer/)
assert.match(r.stderr, /Counting Customer/)
assert.match(r.stderr, /conversation 12; continue with: qqq assistant ask -c 12/)
const created = stub.requests.find((q) => q.path === '/assistant/conversations')!
@@ -99,24 +94,4 @@ describe('qqq assistant against a stub', () => {
test('plain answer text drops markdown markers', () => {
assert.equal(plainAnswer('## Total\nThere are **42** `customers` and __3__ orders.'), 'Total\nThere are 42 customers and 3 orders.')
})
test('the 8078 answer about unhealthy services renders as text: no entities, no URLs, a sub-table, links once', () => {
const fixture = JSON.parse(readFileSync(join(TEST_SOURCE, '..', '..', '..', 'core', 'src', 'test', 'fixtures', 'assistant-unhealthy.json'), 'utf8')) as { text: string; links: Array<{ label: string; href: string }> }
const { text, links } = renderAnswer(fixture, 100)
assert.doesNotMatch(text, /&#|\/app\/|%7B|\| ---/)
assert.match(text, /Service\s+Status\s+Since/)
assert.match(text, /email \[1\]\s+DEGRADED/)
assert.match(text, /- Open unhealthy components \[4\]/)
assert.ok(text.split('\n').every((l) => [...l].length <= 100))
assert.deepEqual(links.map((l) => [l.n, l.label, l.open.split(' --')[0]]), [
[1, 'email', 'qqq table get health_component 4'],
[2, 'db_performance', 'qqq table get health_component 3'],
[3, 'esb', 'qqq table get health_component 10'],
[4, 'Open unhealthy components', 'qqq table query health_component'],
])
assert.match(links[3]!.open, /--filter-json '\{"criteria":\[\{"fieldName":"status","operator":"NOT_EQUALS","values":\["UP"\]\}\],"booleanOperator":"AND"\}'/)
assert.equal(linkCommand('/app/person/2'), 'qqq table get person 2')
assert.equal(linkCommand('https://x'), '')
})
})
+7 -24
View File
@@ -20,7 +20,7 @@ describe('broken pipes', () => {
stub = await startStub([...BASE_ROUTES,
[/^GET \/metaData$/, () => ({ tables: { big: { name: 'big', label: 'Big' } }, processes: {}, apps: {} })],
[/^GET \/metaData\/table\/big$/, () => ({ name: 'big', label: 'Big', primaryKeyField: 'id', capabilities: ['TABLE_QUERY'], fields: { id: { name: 'id', label: 'Id', type: 'INTEGER' }, note: { name: 'note', label: 'Note', type: 'STRING' } } })],
[/^POST \/table\/big\/query$/, () => ({ records: Array.from({ length: 5000 }, (_, i) => ({ values: { id: i, note: 'x'.repeat(200) } })) })],
[/^POST \/table\/big\/query$/, () => ({ records: Array.from({ length: 2000 }, (_, i) => ({ values: { id: i, note: 'x'.repeat(200) } })) })],
])
})
after(async () => {
@@ -30,40 +30,23 @@ describe('broken pipes', () => {
test('the helper exits 0 on EPIPE and rethrows anything else', () => {
assert.ok(isBrokenPipe(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' })))
assert.ok(isBrokenPipe(Object.assign(new Error('destroyed'), { code: 'ERR_STREAM_DESTROYED' })))
assert.ok(!isBrokenPipe(new Error('other')))
const listeners: Array<(e: Error) => void> = []
const exits: number[] = []
let uncaught: ((e: Error) => void) | undefined
exitOnBrokenPipe([{ on: (_: string, f: (e: Error) => void) => listeners.push(f) } as never], (code) => exits.push(code), (_, f) => { uncaught = f })
exitOnBrokenPipe([{ on: (_: string, f: (e: Error) => void) => listeners.push(f) } as never], (code) => exits.push(code))
listeners[0]!(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
uncaught!(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
assert.deepEqual(exits, [0, 0])
assert.deepEqual(exits, [0])
assert.throws(() => listeners[0]!(new Error('disk full')), /disk full/)
})
/** Runs a query whose output is far larger than a pipe buffer, closing the read end when `closeAt` says so. */
const query = async (closeAt: 'before-any-write' | 'first-chunk') => {
test('a closed stdout ends the command quietly with status 0 and no stack trace', async () => {
const env = { ...process.env, ...box.env, QQQ_TOKEN: 't', NO_COLOR: '1' }
const child = spawn(process.execPath, [CLI, '--server', stub.url, 'table', 'query', 'big', '-O', 'json', '-l', '5000'], { env, stdio: ['ignore', 'pipe', 'pipe'] })
const child = spawn(process.execPath, [CLI, '--server', stub.url, 'table', 'query', 'big', '-O', 'json', '-l', '2000'], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let stderr = ''
child.stderr.on('data', (d: Buffer) => (stderr += d.toString('utf8')))
// before-any-write: the reader is gone before the child has even started, so its first write meets a closed pipe
if (closeAt === 'before-any-write') child.stdout.destroy()
else child.stdout.once('data', () => child.stdout.destroy())
child.stdout.once('data', () => child.stdout.destroy())
const code = await new Promise<number | null>((resolve) => child.on('close', resolve))
return { code, stderr }
}
test('a reader that is gone before the first write ends the command quietly with status 0', { timeout: 60_000 }, async () => {
const { code, stderr } = await query('before-any-write')
assert.equal(code, 0, stderr)
assert.doesNotMatch(stderr, /EPIPE|at .*\.js/)
})
test('a reader that leaves after the first chunk ends the command quietly with status 0', { timeout: 60_000 }, async () => {
const { code, stderr } = await query('first-chunk')
assert.equal(code, 0, stderr)
assert.equal(code, 0)
assert.doesNotMatch(stderr, /EPIPE|at .*\.js/)
})
})
-1
View File
@@ -48,7 +48,6 @@ export * from './render/text.js'
export * from './extensions/registry.js'
export * from './extensions/collaboration.js'
export * from './extensions/assistant.js'
export * from './render/markdown.js'
export * from './extensions/list-columns.js'
export * from './extensions/esb.js'
export * from './extensions/health.js'
-261
View File
@@ -1,261 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The small markdown the assistant writes, parsed into blocks both heads draw: headings, paragraphs,
* bullet and numbered lists, pipe tables, and inline **bold**, `code` and [links](/app/...). HTML
* entities are decoded (`&#58;` is `:`). Links are numbered in reading order, one number per target
* (two links to the same record share it), so a client can make them Tab targets and never show a
* URL. Exact duplicate paragraphs are collapsed (models sometimes restate a sentence word for word).
*/
import { parseAppHref } from '../extensions/assistant.js'
import { layoutTable } from './output.js'
import { decodeEntities } from './text.js'
/** A run of text with one style; a link carries its number. */
export interface MdInline {
text: string
style?: 'bold' | 'code' | 'link'
href?: string
/** For links: the index into {@link MdDocument.links}. */
link?: number
}
export type MdAlign = 'left' | 'right' | 'center'
export type MdBlock =
| { kind: 'heading'; level: number; inlines: MdInline[] }
| { kind: 'paragraph'; inlines: MdInline[] }
| { kind: 'list'; ordered: boolean; items: MdInline[][] }
| { kind: 'table'; headers: MdInline[][]; aligns: MdAlign[]; rows: MdInline[][][] }
export interface MdLink {
label: string
href: string
/** What the link points at (see {@link linkTargetKey}): links with the same key are one link. */
key: string
}
export interface MdDocument {
blocks: MdBlock[]
/** Every link target, in reading order, once. */
links: MdLink[]
}
/** The primary key field of a table, for telling a filter on the key from a record link. */
export type PrimaryKeyOf = (table: string) => string | undefined
/**
* What a link points at, so equal targets compare equal: `record:{table}:{id}` (also for a filter
* that is exactly "primary key EQUALS one value"), `table:{table}:{canonical filter}`, or the href.
* Filters in JSON or base64 JSON compare by content (keys sorted, empty criteria dropped).
*/
export function linkTargetKey(href: string, primaryKeyOf: PrimaryKeyOf = () => 'id'): string {
const target = parseAppHref(href)
if (!target?.tableName) return `href:${href}`
if (target.recordId !== undefined) return `record:${target.tableName}:${target.recordId}`
const filter = target.filter as { criteria?: Array<{ fieldName?: string; operator?: string; values?: unknown[] }>; subFilters?: unknown[] } | undefined
const criteria = filter?.criteria ?? []
const pk = primaryKeyOf(target.tableName) ?? 'id'
if (criteria.length === 1 && !filter?.subFilters?.length && criteria[0]!.fieldName === pk && criteria[0]!.operator === 'EQUALS' && criteria[0]!.values?.length === 1) {
return `record:${target.tableName}:${String(criteria[0]!.values[0])}`
}
if (!criteria.length && !filter?.subFilters?.length) return `table:${target.tableName}:`
return `table:${target.tableName}:${canonical({ criteria, subFilters: filter?.subFilters ?? [], booleanOperator: (filter as { booleanOperator?: string }).booleanOperator ?? 'AND' })}`
}
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
if (value && typeof value === 'object') return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical((value as Record<string, unknown>)[k])}`).join(',')}}`
return JSON.stringify(value)
}
/** Parses inline markdown: links, bold, code; entities decoded in text and labels. */
function inlines(source: string, register: (label: string, href: string) => number): MdInline[] {
const out: MdInline[] = []
const push = (text: string, style?: MdInline['style']) => {
if (!text) return
const last = out[out.length - 1]
if (!style && last && !last.style) last.text += text
else out.push(style ? { text, style } : { text })
}
const pattern = /\[([^\]]+)\]\(([^)\s]+)\)|\*\*(.+?)\*\*|__(.+?)__|`([^`]+)`/g
let at = 0
for (const m of source.matchAll(pattern)) {
push(decodeEntities(source.slice(at, m.index)))
if (m[1] !== undefined) {
const label = decodeEntities(m[1]).replace(/\*\*|__|`/g, '')
const href = decodeEntities(m[2]!)
out.push({ text: label, style: 'link', href, link: register(label, href) })
} else if (m[3] !== undefined || m[4] !== undefined) push(decodeEntities(m[3] ?? m[4]!), 'bold')
else push(decodeEntities(m[5]!), 'code')
at = m.index + m[0].length
}
push(decodeEntities(source.slice(at)))
return out
}
const TABLE_RULE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/
function cells(line: string): string[] {
let text = line.trim()
if (text.startsWith('|')) text = text.slice(1)
if (text.endsWith('|') && !text.endsWith('\\|')) text = text.slice(0, -1)
// split on pipes that are not inside a link target or escaped
const out: string[] = []
let cell = ''
let depth = 0
for (let i = 0; i < text.length; i++) {
const ch = text[i]!
if (ch === '\\' && text[i + 1] === '|') { cell += '|'; i++; continue }
if (ch === '(' && text[i - 1] === ']') depth++
else if (ch === ')' && depth > 0) depth--
if (ch === '|' && depth === 0) { out.push(cell.trim()); cell = '' } else cell += ch
}
out.push(cell.trim())
return out
}
/** Parses the assistant's markdown into blocks and numbered links. */
export function parseMarkdown(text: string, primaryKeyOf?: PrimaryKeyOf): MdDocument {
const links: MdLink[] = []
const register = (label: string, href: string): number => {
const key = linkTargetKey(href, primaryKeyOf)
const found = links.findIndex((l) => l.key === key)
if (found >= 0) return found
links.push({ label, href, key })
return links.length - 1
}
const blocks: MdBlock[] = []
const seenParagraphs = new Set<string>()
const lines = text.replace(/\r\n?/g, '\n').split('\n')
let i = 0
const isList = (l: string) => /^\s*([-*+]|\d+[.)])\s+/.test(l)
const isHeading = (l: string) => /^\s{0,3}#{1,6}\s+/.test(l)
const isTableStart = (n: number) => lines[n]?.includes('|') && TABLE_RULE.test(lines[n + 1] ?? '')
while (i < lines.length) {
const line = lines[i]!
if (!line.trim()) { i++; continue }
if (isHeading(line)) {
const m = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line)!
blocks.push({ kind: 'heading', level: m[1]!.length, inlines: inlines(m[2]!, register) })
i++
continue
}
if (isTableStart(i)) {
const headers = cells(line).map((c) => inlines(c, register))
const aligns: MdAlign[] = cells(lines[i + 1]!).map((c) => c.endsWith(':') ? (c.startsWith(':') ? 'center' : 'right') : 'left')
const rows: MdInline[][][] = []
i += 2
while (i < lines.length && lines[i]!.trim() && lines[i]!.includes('|')) {
rows.push(cells(lines[i]!).map((c) => inlines(c, register)))
i++
}
blocks.push({ kind: 'table', headers, aligns, rows })
continue
}
if (isList(line)) {
const ordered = /^\s*\d+[.)]\s+/.test(line)
const items: MdInline[][] = []
while (i < lines.length && isList(lines[i]!)) {
let item = lines[i]!.replace(/^\s*([-*+]|\d+[.)])\s+/, '')
i++
// continuation lines indented under the item
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!) && !isList(lines[i]!)) item += ' ' + lines[i++]!.trim()
items.push(inlines(item, register))
}
blocks.push({ kind: 'list', ordered, items })
continue
}
const paragraph: string[] = []
while (i < lines.length && lines[i]!.trim() && !isHeading(lines[i]!) && !isList(lines[i]!) && !isTableStart(i)) paragraph.push(lines[i++]!.trim())
const joined = paragraph.join(' ')
const norm = decodeEntities(joined).replace(/\s+/g, ' ').trim()
if (seenParagraphs.has(norm)) continue
seenParagraphs.add(norm)
blocks.push({ kind: 'paragraph', inlines: inlines(joined, register) })
}
return { blocks, links }
}
/** The plain text of inlines; links as `label ↗` (or what `link` returns). */
export function inlineText(parts: MdInline[], link: (part: MdInline) => string = (p) => `${p.text} ↗`): string {
return parts.map((p) => (p.style === 'link' ? link(p) : p.text)).join('')
}
/** Word-wraps plain text to a width (long words are cut); `indent` goes before continuation lines. */
export function wrapPlain(text: string, width: number, first = '', rest = first): string[] {
const out: string[] = []
const w = Math.max(4, width)
let line = first
let empty = true
for (const word of text.split(/\s+/).filter(Boolean)) {
const prefixLen = (out.length ? rest : first).length
if (!empty && line.length + 1 + word.length > w) {
out.push(line)
line = rest
empty = true
}
let piece = word
while ((empty ? line.length : line.length + 1) + piece.length > w && piece.length > w - prefixLen) {
const room = w - line.length - (empty ? 0 : 1)
line += (empty ? '' : ' ') + piece.slice(0, room)
out.push(line)
line = rest
piece = piece.slice(room)
empty = true
}
line += (empty ? '' : ' ') + piece
empty = false
}
if (!empty || !out.length) out.push(line)
return out
}
/**
* The document as terminal text for a width: headings and paragraphs wrapped, lists with `-` or
* `1.`, tables laid out like the CLI's sub-tables (columns that do not fit are left out, cells cut
* with `…`). `link` renders a link (default `label ↗`).
*/
export function markdownText(doc: MdDocument, width = 100, link?: (part: MdInline) => string): string {
const out: string[] = []
for (const block of doc.blocks) {
if (out.length) out.push('')
if (block.kind === 'heading') out.push(...wrapPlain(inlineText(block.inlines, link), width))
else if (block.kind === 'paragraph') out.push(...wrapPlain(inlineText(block.inlines, link), width))
else if (block.kind === 'list') {
block.items.forEach((item, n) => {
const bullet = block.ordered ? `${n + 1}. ` : '- '
out.push(...wrapPlain(inlineText(item, link), width, bullet, ' '.repeat(bullet.length)))
})
} else {
const columns = block.headers.map((_, i) => `c${i}`)
const rows = block.rows.map((r) => Object.fromEntries(columns.map((c, i) => [c, inlineText(r[i] ?? [], link)])))
const { text, hidden } = layoutTable(rows, columns, block.headers.map((h) => inlineText(h, link)), { width, maxCell: Math.max(8, width) })
out.push(...text.split('\n'))
if (hidden.length) out.push(`(${hidden.length} more column${hidden.length === 1 ? '' : 's'} do not fit)`)
}
}
return out.join('\n')
}
/**
* An assistant answer as a document, plus the answer's links that the text does not already show
* (records and tables from tool results), once per target: the "→" list a client draws under the text.
* The document's links come first, then these, so numbering them in that order is reading order.
*/
export function answerDocument(answer: { text: string; links: Array<{ label: string; href: string }> }, primaryKeyOf?: PrimaryKeyOf): { doc: MdDocument; extraLinks: MdLink[] } {
const doc = parseMarkdown(answer.text, primaryKeyOf)
const keys = new Set(doc.links.map((l) => l.key))
const extraLinks: MdLink[] = []
for (const link of answer.links) {
const key = linkTargetKey(link.href, primaryKeyOf)
if (keys.has(key)) continue
keys.add(key)
extraLinks.push({ label: link.label, href: link.href, key })
}
return { doc, extraLinks }
}
@@ -1,85 +0,0 @@
{
"conversationId": 4,
"text": "3 services are not fully healthy&#58; one degraded &#40;email&#41; and two with unknown status&#46; None are down&#46;\n\nUnhealthy services\n\n| Service | Status | Since | Checks up | Degraded | Unknown |\n| --- | --- | --- | ---: | ---: | ---: |\n| [email](/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B4%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D) | DEGRADED | 2026-11-01 11&#58;25 UTC | 0 | 1 | 0 |\n| [db&#95;performance](/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B3%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D) | UNKNOWN | 2026-11-01 17&#58;25 UTC | 2 | 0 | 1 |\n| [esb](/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B10%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D) | UNKNOWN | 2026-11-01 11&#58;23 UTC | 1 | 0 | 1 |\n\n- [Open unhealthy components](/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22status%22%2C%22operator%22%3A%22NOT_EQUALS%22%2C%22values%22%3A%5B%22UP%22%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D)\nThree services aren't fully healthy right now:\n\n- **email** is degraded. Its only check is degraded.\n- **db_performance** is unknown. One of its three checks has no status.\n- **esb** is unknown. One of its two checks has no status.\n\nNo service is down.",
"status": "COMPLETE",
"notices": [],
"numbers": [],
"links": [
{
"label": "db_performance (Health Components)",
"href": "/app/health_component/3",
"kind": "record",
"tableName": "health_component",
"recordId": "3"
},
{
"label": "email (Health Components)",
"href": "/app/health_component/4",
"kind": "record",
"tableName": "health_component",
"recordId": "4"
},
{
"label": "esb (Health Components)",
"href": "/app/health_component/10",
"kind": "record",
"tableName": "health_component",
"recordId": "10"
},
{
"label": "Health Components (3)",
"href": "/app/health_component?filter=eyJjcml0ZXJpYSI6W3siZmllbGROYW1lIjoic3RhdHVzIiwib3BlcmF0b3IiOiJOT1RfRVFVQUxTIiwidmFsdWVzIjpbIlVQIl19XSwiYm9vbGVhbk9wZXJhdG9yIjoiQU5EIn0%3D",
"kind": "table",
"tableName": "health_component"
},
{
"label": "email · DEGRADED",
"href": "/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B4%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D",
"kind": "table",
"tableName": "health_component"
},
{
"label": "db_performance · UNKNOWN",
"href": "/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B3%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D",
"kind": "table",
"tableName": "health_component"
},
{
"label": "esb · UNKNOWN",
"href": "/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B10%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D",
"kind": "table",
"tableName": "health_component"
},
{
"label": "Open unhealthy components",
"href": "/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22status%22%2C%22operator%22%3A%22NOT_EQUALS%22%2C%22values%22%3A%5B%22UP%22%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D",
"kind": "table",
"tableName": "health_component"
}
],
"tools": [
{
"name": "describe_table",
"summary": "Described Health Components",
"outcome": "OK"
},
{
"name": "query_records",
"summary": "Queried Health Components · 3 rows",
"outcome": "OK"
},
{
"name": "present_results",
"summary": "Presented results",
"outcome": "OK"
}
],
"turnId": 7,
"messageId": 14,
"usage": {
"inputTokens": 10,
"outputTokens": 729,
"costMicros": 34021
},
"elapsedMs": 7140
}
-63
View File
@@ -1,63 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
import { answerDocument, linkTargetKey, markdownText, parseMarkdown, wrapPlain } from '../render/markdown.js'
/** The answer to "what services are not healthy right now?" as 8078 gave it (qqq assistant ask --json). */
const fixture = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'src', 'test', 'fixtures', 'assistant-unhealthy.json'), 'utf8')) as { text: string; links: Array<{ label: string; href: string }> }
test('before: the raw answer has entities, a pipe table and URL-encoded link targets', () => {
assert.match(fixture.text, /healthy&#58;/)
assert.match(fixture.text, /\| --- \| --- \|/)
assert.match(fixture.text, /\(\/app\/health_component\?filter=%7B/)
assert.equal(fixture.links.length, 8)
})
test('after: entities decoded, the table laid out, links as label ↗ and never their URL', () => {
const { doc, extraLinks } = answerDocument(fixture)
const text = markdownText(doc, 100)
assert.doesNotMatch(text, /&#|\/app\/|%7B|\| ---/)
assert.match(text, /^3 services are not fully healthy: one degraded \(email\) and two with unknown status\. None are down\./)
assert.match(text, /Service\s+Status\s+Since\s+Checks up\s+Degraded\s+Unknown/)
assert.match(text, /db_performance ↗\s+UNKNOWN\s+2026-11-01 17:25 UTC/)
assert.match(text, /- Open unhealthy components ↗/)
assert.match(text, /- email is degraded\. Its only check is degraded\./)
assert.ok(text.split('\n').every((l) => [...l].length <= 100))
// four targets in reading order; the tool results' eight links are all the same four (records = id filters)
assert.deepEqual(doc.links.map((l) => l.label), ['email', 'db_performance', 'esb', 'Open unhealthy components'])
assert.deepEqual(extraLinks, [])
})
test('narrow: the table drops what does not fit and says so', () => {
const text = markdownText(answerDocument(fixture).doc, 40)
assert.ok(text.split('\n').every((l) => [...l].length <= 40))
assert.match(text, /more columns? do not fit/)
})
test('duplicate paragraphs collapse; lists, headings, bold and code', () => {
const doc = parseMarkdown('## Totals\n\nThere are **42** `customers`.\n\nThere are **42** `customers`.\n\n1. one\n2. two\n')
assert.deepEqual(doc.blocks.map((b) => b.kind), ['heading', 'paragraph', 'list'])
assert.deepEqual((doc.blocks[1] as { inlines: Array<{ style?: string }> }).inlines.map((i) => i.style ?? ''), ['', 'bold', '', 'code', ''])
assert.equal(markdownText(doc, 80), 'Totals\n\nThere are 42 customers.\n\n1. one\n2. two')
})
test('link targets: records, id filters, JSON and base64 filters compare by content', () => {
const json = '/app/t?filter=' + encodeURIComponent('{"booleanOperator":"AND","criteria":[{"fieldName":"s","operator":"EQUALS","values":["x"]}]}')
const b64 = '/app/t?filter=' + encodeURIComponent(Buffer.from('{"criteria":[{"fieldName":"s","operator":"EQUALS","values":["x"]}],"booleanOperator":"AND"}').toString('base64'))
assert.equal(linkTargetKey(json), linkTargetKey(b64))
assert.equal(linkTargetKey('/app/t/4'), linkTargetKey('/app/t?filter=' + encodeURIComponent('{"criteria":[{"fieldName":"id","operator":"EQUALS","values":[4]}]}')))
assert.notEqual(linkTargetKey('/app/t/4', () => 'code'), linkTargetKey('/app/t?filter=' + encodeURIComponent('{"criteria":[{"fieldName":"id","operator":"EQUALS","values":[4]}]}'), () => 'code'))
assert.equal(linkTargetKey('/app/t'), 'table:t:')
})
test('wrapping keeps the prefix and cuts long words', () => {
assert.deepEqual(wrapPlain('alpha beta gamma', 11, '- ', ' '), ['- alpha', ' beta', ' gamma'])
assert.deepEqual(wrapPlain('abcdefghij', 6), ['abcdef', 'ghij'])
})
+14 -34
View File
@@ -350,51 +350,31 @@ help-authoring mode (F1 shows every help slot key).
## Assistant
With the `assistant` UI API extension (qbit-assistant), **F12** shows and hides the Assistant panel on
every screen (also `Ctrl+G`, `:ask [question]`, F9 › Work › Assistant, or a click on `12Ask`). It is a
chat pane on the right of the screen (40 to 64 columns), or the whole body on terminals narrower than 110
columns. The question line is at the bottom and the conversation above it. Answers are read-only and use
your permissions.
With the `assistant` UI API extension (qbit-assistant), `Ctrl+G`, `:ask [question]` or F9 › Work ›
Assistant opens the Assistant panel: a chat pane on the right of the screen (40 to 64 columns), or the
whole body on terminals narrower than 110 columns. The question line is at the bottom and the
conversation above it: each question, the answer as it is written, its numbers (counts and metrics) and
its links. Answers are read-only and use your permissions.
Hiding the panel keeps the conversation, its context and the draft on the question line; the next F12
shows them again. When the panel is shown but the screen beside it has the focus (after opening a link),
F12 goes back to the question line. F11 is reserved.
Answers are drawn from their markdown: entities decoded, headings in the accent colour, paragraphs
wrapped to the pane, bullet and numbered lists, **bold** and `code`, and pipe tables laid out like the
grid (columns that do not fit are named underneath, cells cut with `…`). A link shows as `label ↗`, never
its address; each is a Tab target in reading order, once per target. Records and tables the tool results
refer to that the text does not show are listed under `→`. Exact duplicate paragraphs are shown once;
the timing and token line stays, dimmed.
Questions carry the screen the panel was shown over as context, the way the Next UI's panel sends it: a
record (`/app/{table}/{id}`) or a grid with its filter; the first line of the panel says which
(`on: Person #3`). While the panel is beside the screen, a record or grid you open becomes the context.
Questions carry the screen under the panel as context, the way the Next UI's panel sends it: a record
(`/app/{table}/{id}`) or a grid with its filter; the first line of the panel says which (`on: Person #3`).
| Key | Does |
|---|---|
| Enter | ask the question; on a link, open it (a record or a grid, beside the panel; a full-width panel hides so the screen shows) |
| Enter | ask the question; on a link, open it (a record or a grid, beside the panel; a full-width panel closes so the screen shows) |
| Tab, Shift+Tab | move over the answer's links; typing returns to the question line |
| u | on a link: show its address |
| ↑ ↓ | the questions you asked on this profile (on the links: previous and next link) |
| PgUp PgDn, wheel | scroll the conversation |
| Ctrl+D | send without (or again with) the page context |
| Ctrl+L | a new conversation |
| Ctrl+X | stop the answer being written |
| F12, Ctrl+G | hide the panel (again: show it) |
| Esc | hide the panel (with text on the question line too: nothing is asked, the draft stays); on a link, back to the question line |
| Ctrl+G | back to the screen (the panel stays); again to return |
| Esc | close the panel (the conversation is kept) |
**F12 in your terminal.** The F-key bar shows `11` (blank, reserved) and `12Ask` from 120 columns;
narrower, the `?` help and F9 › Work say "F12 assistant". The TUI reads F12 both as the legacy sequence
(`ESC [24~`, also with modifiers, as tmux and iTerm2 send it by default) and as the kitty keyboard
protocol's key (57375). macOS may take F12 for Mission Control or Dashboard, or send it only with `fn`
(System Settings › Keyboard › Keyboard Shortcuts, or "Use F1, F2, etc. keys as standard function keys");
Ctrl+G is the fallback everywhere.
Without the extension the menu entry is disabled, `12Ask` is dimmed and F12 says what is missing. When
the assistant is off for you (no model provider on the server, no permission, the kill switch) the panel
shows the server's reason and what to configure; F5 checks again. The command line has the same:
`qqq assistant ask`, `qqq assistant config`.
Without the extension the menu entry is disabled and Ctrl+G says what is missing. When the assistant is
off for you (no model provider on the server, no permission, the kill switch) the panel shows the server's
reason and what to configure; F5 checks again. The command line has the same: `qqq assistant ask`,
`qqq assistant config`.
## SQL Runner
+2 -2
View File
@@ -112,7 +112,7 @@ function StatusLine({ store, width }: { store: AppStore; width: number }) {
}
/** Short labels for narrow terminals (below ~100 columns), so nothing clips at 80. */
const SHORT: Record<string, string> = { Help: 'Help', Search: 'Search', 'Go to': 'Go', View: 'View', Edit: 'Edit', Refresh: 'Refr', Filter: 'Filt', New: 'New', Delete: 'Del', Menu: 'Menu', Quit: 'Quit', NewChild: 'Child', 'Re-run': 'Again', Open: 'Open', Ask: 'Ask' }
const SHORT: Record<string, string> = { Help: 'Help', Search: 'Search', 'Go to': 'Go', View: 'View', Edit: 'Edit', Refresh: 'Refr', Filter: 'Filt', New: 'New', Delete: 'Del', Menu: 'Menu', Quit: 'Quit', NewChild: 'Child', 'Re-run': 'Again', Open: 'Open' }
function FKeyBar({ store, width }: { store: AppStore; width: number }) {
const t = theme()
@@ -124,7 +124,7 @@ function FKeyBar({ store, width }: { store: AppStore; width: number }) {
<FKeyZone store={store} width={width} keys={keys.map((k) => k.n)}>
<text flexShrink={0} wrapMode="none">
{keys.map((k) => {
const w = k === keys[keys.length - 1] ? Math.max(0, width - used) : cell
const w = k.n === 10 ? Math.max(0, width - used) : cell
used += w
const num = String(k.n).padStart(2)
// a key that does nothing here still shows its global label, dimmed, so the bar teaches the keymap
+16 -158
View File
@@ -4,16 +4,13 @@
*/
/**
* The assistant panel's conversation as lines for a width: each question, the answer's markdown
* rendered (entities decoded, headings, wrapped paragraphs, lists, **bold**, `code`, pipe tables laid
* out like the grid and clipped with `…`), its numbers, and the links. A link shows as `label ↗`,
* never its URL; links are numbered across the conversation in reading order, once per target (the
* text's links, then the tool results' links the text does not show), so the panel can move a cursor
* over them. Every line fits the width it was built for.
* The assistant panel's conversation as lines for a width: each question, the answer (wrapped,
* markdown markers dropped), its numbers and its links. Links are numbered across the whole
* conversation so the panel can move a cursor over them; every line fits the width it was built for.
*/
import { answerDocument, fitColumns, type AssistantAnswer, type AssistantPageContext, type MdBlock, type MdInline, type MdLink, type PrimaryKeyOf } from '@qrun-io/qqq-core'
import { cellWidth, trunc } from '../util/text'
import type { AssistantAnswer, AssistantPageContext } from '@qrun-io/qqq-core'
import { cellWidth } from '../util/text'
/** One question and its answer (streaming until `answer.status` leaves RUNNING). */
export interface Exchange {
@@ -29,22 +26,13 @@ export interface Exchange {
startedAt: number
}
export type LineRole = 'question' | 'context' | 'text' | 'heading' | 'header' | 'rule' | 'number' | 'link' | 'activity' | 'error' | 'meta' | 'blank'
/** A run of a line with one look; `link` is the link number it opens. */
export interface Seg {
text: string
style?: 'bold' | 'code' | 'link' | 'dim'
link?: number
}
export type LineRole = 'question' | 'context' | 'text' | 'number' | 'link' | 'activity' | 'error' | 'meta' | 'blank'
export interface ChatLine {
/** The line as plain text (what the segments add up to). */
text: string
role: LineRole
/** The first link on the line (a click opens it). */
/** Index into {@link chatLinks} for link lines. */
link?: number
segs?: Seg[]
}
/** The answer's markdown as plain text (bold, code and heading markers dropped). */
@@ -86,140 +74,15 @@ export function wrap(text: string, width: number): string[] {
return out
}
/** An answer's links in reading order with their offset into the conversation's numbering. */
function answerLinks(answer: AssistantAnswer, primaryKeyOf?: PrimaryKeyOf): { blocks: MdBlock[]; text: MdLink[]; extra: MdLink[] } {
const { doc, extraLinks } = answerDocument(answer, primaryKeyOf)
return { blocks: doc.blocks, text: doc.links, extra: extraLinks }
}
/** Every link of the conversation, in reading order, once per target per answer (what the link cursor moves over). */
export function chatLinks(exchanges: Exchange[], primaryKeyOf?: PrimaryKeyOf): Array<{ label: string; href: string }> {
return exchanges.flatMap((e) => {
if (!e.answer) return []
const { text, extra } = answerLinks(e.answer, primaryKeyOf)
return [...text, ...extra]
})
}
function segsFrom(inlines: MdInline[], offset: number): Seg[] {
return inlines.map((p) => p.style === 'link' ? { text: `${p.text} ↗`, style: 'link' as const, link: offset + p.link! } : { text: p.text, ...(p.style ? { style: p.style } : {}) })
}
/** Wraps styled runs to a width, word by word; `first`/`rest` go before the first and later lines. */
export function wrapSegs(segs: Seg[], width: number, role: LineRole, first = '', rest = first): ChatLine[] {
const words: Seg[] = []
for (const s of segs) {
for (const part of s.text.split(/(\s+)/)) if (part) words.push({ ...s, text: part })
}
const lines: ChatLine[] = []
let cur: Seg[] = [{ text: first }]
let used = cellWidth(first)
let content = false
let pendingSpace: Seg | undefined
const flush = () => {
lines.push(line(cur, role))
cur = [{ text: rest }]
used = cellWidth(rest)
content = false
pendingSpace = undefined
}
for (const word of words) {
if (/^\s+$/.test(word.text)) {
if (content) pendingSpace = { ...word, text: ' ' }
continue
}
let text = word.text
let w = cellWidth(text)
if (content && used + (pendingSpace ? 1 : 0) + w > width) flush()
if (pendingSpace && content) {
cur.push(pendingSpace)
used += 1
}
pendingSpace = undefined
while (used + w > width) {
let cut = ''
for (const ch of text) {
if (used + cellWidth(cut + ch) > width) break
cut += ch
}
if (!cut) break
cur.push({ ...word, text: cut })
flush()
text = text.slice(cut.length)
w = cellWidth(text)
}
if (text) {
cur.push({ ...word, text })
used += w
content = true
}
}
if (content) flush()
return lines
}
function line(segs: Seg[], role: LineRole): ChatLine {
const merged: Seg[] = []
for (const s of segs) {
if (!s.text) continue
const last = merged[merged.length - 1]
if (last && last.style === s.style && last.link === s.link) last.text += s.text
else merged.push({ ...s })
}
const link = merged.find((s) => s.link !== undefined)?.link
return { text: merged.map((s) => s.text).join(''), role, segs: merged, ...(link !== undefined ? { link } : {}) }
}
/** A pipe table laid out like the grid: columns fitted to the width (the rest left out), cells cut with `…`. */
function tableLines(block: Extract<MdBlock, { kind: 'table' }>, width: number, offset: number): ChatLine[] {
const cellSegs = (inl: MdInline[]) => segsFrom(inl, offset)
const plain = (segs: Seg[]) => segs.map((s) => s.text).join('').replace(/\s+/g, ' ')
const headers = block.headers.map((h) => plain(cellSegs(h)))
const rows = block.rows.map((r) => block.headers.map((_, i) => cellSegs(r[i] ?? [])))
const natural = headers.map((h, i) => Math.max(cellWidth(h), ...rows.map((r) => cellWidth(plain(r[i]!)))))
const { widths, shown } = fitColumns(natural, width)
const fit = (text: string, w: number, align: string) => {
const t = trunc(text, w)
const room = Math.max(0, w - cellWidth(t))
return align === 'right' ? ' '.repeat(room) + t : t + ' '.repeat(room)
}
const out: ChatLine[] = []
out.push(line(headers.slice(0, shown).flatMap((h, i) => [{ text: fit(h, widths[i]!, block.aligns[i] ?? 'left'), style: 'bold' as const }, { text: i < shown - 1 ? ' ' : '' }]), 'header'))
out.push(line([{ text: widths.map((w) => '─'.repeat(w)).join(' '), style: 'dim' }], 'rule'))
for (const r of rows) {
const segs: Seg[] = []
r.slice(0, shown).forEach((cell, i) => {
const text = fit(plain(cell), widths[i]!, block.aligns[i] ?? 'left')
const linked = cell.find((s) => s.link !== undefined)
segs.push(linked ? { text, style: 'link', link: linked.link } : { text })
if (i < shown - 1) segs.push({ text: ' ' })
})
out.push(line(segs, 'text'))
}
if (shown < headers.length) out.push(line([{ text: trunc(`(${headers.length - shown} more column${headers.length - shown === 1 ? '' : 's'}: ${headers.slice(shown).join(', ')})`, width), style: 'dim' }], 'meta'))
return out.map((l) => (cellWidth(l.text) > width ? { ...l, text: trunc(l.text, width), segs: [{ text: trunc(l.text, width), style: l.segs?.[0]?.style }] } : l))
}
/** An answer's markdown as lines (links numbered from `offset`). */
export function answerLines(blocks: MdBlock[], width: number, offset: number): ChatLine[] {
const out: ChatLine[] = []
for (const block of blocks) {
if (out.length) out.push({ text: '', role: 'blank' })
if (block.kind === 'heading') out.push(...wrapSegs(segsFrom(block.inlines, offset).map((s) => ({ ...s, style: s.style ?? 'bold' })), width, 'heading'))
else if (block.kind === 'paragraph') out.push(...wrapSegs(segsFrom(block.inlines, offset), width, 'text'))
else if (block.kind === 'list') block.items.forEach((item, n) => {
const bullet = block.ordered ? `${n + 1}. ` : '• '
out.push(...wrapSegs(segsFrom(item, offset), width, 'text', bullet, ' '.repeat(cellWidth(bullet))))
})
else out.push(...tableLines(block, width, offset))
}
return out
/** Every link of the conversation, in order (what the link cursor moves over). */
export function chatLinks(exchanges: Exchange[]): Array<{ label: string; href: string }> {
return exchanges.flatMap((e) => e.answer?.links ?? [])
}
/** The conversation as lines for a width (the panel's inner width). */
export function chatLines(exchanges: Exchange[], width: number, now = Date.now(), primaryKeyOf?: PrimaryKeyOf): ChatLine[] {
export function chatLines(exchanges: Exchange[], width: number, now = Date.now()): ChatLine[] {
const lines: ChatLine[] = []
let offset = 0
let linkIndex = 0
const add = (text: string, role: LineRole, prefix = '', link?: number) => {
const rest = ' '.repeat(cellWidth(prefix))
wrap(text, width - cellWidth(prefix)).forEach((l, i) => lines.push({ text: (i === 0 ? prefix : rest) + l, role, ...(link !== undefined ? { link } : {}) }))
@@ -230,16 +93,11 @@ export function chatLines(exchanges: Exchange[], width: number, now = Date.now()
if (e.contextLabel) add(`on ${e.contextLabel}`, 'context', ' ')
const answer = e.answer
const running = !e.error && (!answer || answer.status === 'RUNNING')
const parsed = answer ? answerLinks(answer, primaryKeyOf) : { blocks: [], text: [], extra: [] }
if (parsed.blocks.length) lines.push(...answerLines(parsed.blocks, width, offset))
if (running && !parsed.blocks.length) add(`${e.activity ?? 'thinking'}… ${Math.max(0, Math.round((now - e.startedAt) / 1000))} s`, 'activity')
const text = plainText(answer?.text ?? '').trim()
if (text) add(text, 'text')
if (running && !text) add(`${e.activity ?? 'thinking'}… ${Math.max(0, Math.round((now - e.startedAt) / 1000))} s`, 'activity')
for (const num of answer?.numbers ?? []) add(`${num.label}: ${num.value}`, 'number', '# ')
// the tool results' links the text does not show, once per target
parsed.extra.forEach((link, i) => {
const index = offset + parsed.text.length + i
wrapSegs([{ text: `${link.label} ↗`, style: 'link', link: index }], width, 'link', '→ ', ' ').forEach((l) => lines.push(l))
})
offset += parsed.text.length + parsed.extra.length
for (const link of answer?.links ?? []) add(link.label, 'link', '→ ', linkIndex++)
for (const notice of answer?.notices ?? []) add(notice.text, notice.level === 'error' ? 'error' : 'meta', `${notice.level}: `)
if (e.error) add(e.error, 'error')
else if (answer?.error) add(`${answer.error.message} (${answer.error.code})`, 'error')
+18 -66
View File
@@ -24,8 +24,8 @@ import { wheelAsKeys } from '../mouse/keys'
import type { AppStore } from '../store'
import { borderProps, theme } from '../theme'
import type { FKeyLabels } from '../types'
import { cellWidth, errorText, hintLine, pad, trunc } from '../util/text'
import { chatLines, chatLinks, contextLabel, wrap, type ChatLine, type Exchange, type Seg } from './model'
import { errorText, hintLine, pad, trunc } from '../util/text'
import { chatLines, chatLinks, contextLabel, wrap, type ChatLine, type Exchange } from './model'
/** Below this terminal width the panel takes the whole body instead of a right-hand pane. */
export const SIDE_MIN_WIDTH = 110
@@ -92,26 +92,9 @@ export class AssistantPanel {
void this.loadConfig().then(() => this.ask(question))
}
/** The context questions carry: the screen the panel was shown over (kept while hidden). */
private pinned?: AssistantPageContext
/**
* Takes the screen under the panel as the context: when the panel is shown, and while it is shown
* beside the screen (a link opened from it becomes the context). A screen without one (Home)
* keeps the context the panel had.
*/
capturePageContext(): void {
const context = this.screenContext()
if (context) this.pinned = context
}
/** The context the next question carries (none after Ctrl+D). */
/** The page under the panel as context: a record, or a grid with its filter. */
pageContext(): AssistantPageContext | undefined {
return this.dropContext ? undefined : this.pinned
}
/** The screen's context: a record, or a grid with its filter. */
screenContext(): AssistantPageContext | undefined {
if (this.dropContext) return undefined
const screen = this.store.screen as unknown as { kind?: string; tableName?: string; primaryKey?: string; filter?: Record<string, unknown> } | undefined
if (screen?.kind === 'record' && screen.tableName && screen.primaryKey !== undefined) return assistantContextFor({ tableName: screen.tableName, recordId: screen.primaryKey })
if (screen?.kind === 'table' && screen.tableName) return assistantContextFor({ tableName: screen.tableName, filter: screen.filter })
@@ -187,23 +170,9 @@ export class AssistantPanel {
this.store.bump()
}
/** A table's primary key field from the metadata (links that filter on it are that record). */
readonly primaryKeyOf = (table: string): string | undefined => (this.store.meta?.tables?.[table] as { primaryKeyField?: string } | undefined)?.primaryKeyField
/** Every link of the conversation, in reading order, once per target. */
links(): Array<{ label: string; href: string }> {
return chatLinks(this.exchanges, this.primaryKeyOf)
}
/** `u` on a link: its address, shown (never opened). */
showUrl(index = this.linkCursor): void {
const link = this.links()[index]
if (link) this.store.message(`${link.label}\n${link.href}`, 'info', 'Link address')
}
/** Opens the link under the cursor in the screen beside (or, on a narrow terminal, under) the panel. */
openLink(index = this.linkCursor): void {
const link = this.links()[index]
const link = chatLinks(this.exchanges)[index]
if (!link) return
this.linkCursor = -1
this.store.focus = 'content'
@@ -229,14 +198,13 @@ export class AssistantPanel {
['Ctrl+D', 'send without / with the page context'],
['Ctrl+L', 'new conversation'],
['Ctrl+X', 'stop the answer'],
['u', 'on a link: show its address'],
['F12 Ctrl+G', 'hide the panel (the conversation, context and draft are kept); again shows it'],
['Esc', 'hide the panel (on a link: back to the question line)'],
['Ctrl+G', 'back to the screen (the panel stays)'],
['Esc', 'close the panel (the conversation is kept)'],
]
}
onKey(key: Key): boolean {
const links = this.links().length
const links = chatLinks(this.exchanges).length
if (key.ctrl && key.name === 'x') return this.stop(), true
if (key.ctrl && key.name === 'l') return this.clear(), true
if (key.ctrl && key.name === 'd') {
@@ -246,7 +214,6 @@ export class AssistantPanel {
}
switch (key.name) {
case 'escape':
// hides the panel; the draft on the question line stays for next time
if (this.linkCursor >= 0) this.linkCursor = -1
else this.store.closeAssistant()
return true
@@ -280,7 +247,6 @@ export class AssistantPanel {
break
}
if (key.name.startsWith('f') && /^f\d+$/.test(key.name)) return false
if (this.linkCursor >= 0 && !key.ctrl && !key.meta && key.sequence === 'u') return this.showUrl(), true
if (this.editor.handle(key)) {
this.linkCursor = -1
return true
@@ -319,16 +285,15 @@ export class AssistantPanel {
render(width: number, height: number, focused: boolean) {
const t = theme()
if (this.store.assistantLayout()?.mode === 'side') this.capturePageContext()
const inner = Math.max(10, width - 2)
const context = this.pageContext()
const label = this.label(context)
const ctxLine = this.dropContext ? 'no page context (Ctrl+D adds it)' : label ? `on: ${label} (Ctrl+D drops)` : 'no page context'
const bodyHeight = Math.max(1, height - 2 - 3)
this.lastHeight = bodyHeight
const all: ChatLine[] = this.exchanges.length ? chatLines(this.exchanges, inner, Date.now(), this.primaryKeyOf) : this.intro().flatMap((x) => wrap(x.text, inner).map((text) => ({ ...x, text })))
const all: ChatLine[] = this.exchanges.length ? chatLines(this.exchanges, inner) : this.intro().flatMap((x) => wrap(x.text, inner).map((text) => ({ ...x, text })))
if (this.pendingReveal && this.linkCursor >= 0) {
const at = all.findIndex((l) => l.segs?.some((x) => x.link === this.linkCursor) || l.link === this.linkCursor)
const at = all.findIndex((l) => l.link === this.linkCursor)
if (at >= 0) {
const bottom = all.length - this.scrollBack
if (at < bottom - bodyHeight) this.scrollBack = all.length - at - bodyHeight
@@ -341,40 +306,27 @@ export class AssistantPanel {
const shown = all.slice(Math.max(0, end - bodyHeight), end)
const color = (l: ChatLine): string => {
switch (l.role) {
case 'question': case 'heading': case 'header': return t.acc
case 'context': case 'meta': case 'activity': case 'rule': return t.dim
case 'question': return t.acc
case 'context': case 'meta': case 'activity': return t.dim
case 'number': return t.acc2
case 'link': return t.ok
case 'error': return t.err
default: return t.fg
}
}
const segColor = (l: ChatLine, seg: Seg): string => seg.style === 'link' ? t.ok : seg.style === 'code' ? t.acc2 : seg.style === 'dim' ? t.dim : color(l)
const rows = shown.map((l, i) => {
const used = l.segs ? l.segs.reduce((n, x) => n + cellWidth(x.text), 0) : 0
const body = l.segs
? (
<text flexShrink={0} wrapMode="none">
{l.segs.map((seg, k) => {
const selected = seg.link !== undefined && seg.link === this.linkCursor
const fg = selected ? t.selFg : segColor(l, seg)
const bg = selected ? t.selBg : undefined
return <span key={k} fg={fg} bg={bg}>{seg.style === 'bold' ? <strong>{seg.text}</strong> : seg.text}</span>
})}
<span>{' '.repeat(Math.max(0, inner - used))}</span>
</text>
)
: <text flexShrink={0} wrapMode="none" fg={color(l)}>{pad(l.text, inner)}</text>
const selected = l.link !== undefined && l.link === this.linkCursor
const text = <text flexShrink={0} wrapMode="none" fg={selected ? t.selFg : color(l)} bg={selected ? t.selBg : undefined}>{pad(l.text, inner)}</text>
return l.link === undefined
? <box key={i} height={1} width={inner}>{body}</box>
: <Zone key={i} onClick={() => { this.store.focus = 'assistant'; this.linkCursor = l.link!; this.openLink(l.link) }}><box height={1} width={inner}>{body}</box></Zone>
? <box key={i} height={1} width={inner}>{text}</box>
: <Zone key={i} onClick={() => { this.store.focus = 'assistant'; this.linkCursor = l.link!; this.openLink(l.link) }}><box height={1} width={inner}>{text}</box></Zone>
})
while (rows.length < bodyHeight) rows.push(<box key={`p${rows.length}`} height={1} width={inner}><text flexShrink={0} wrapMode="none">{pad('', inner)}</text></box>)
const view = this.editor.view(Math.max(1, inner - 2))
const ready = !this.config || this.config.enabled
const hint = this.linkCursor >= 0
? 'Enter opens · u address · ↑↓ Tab links · Esc back to the question'
: this.streaming ? 'answering… Ctrl+X stops · PgUp PgDn scroll · F12/Esc hides' : 'Enter asks · Tab links · ↑↓ history · Ctrl+L new · F12/Esc hides'
? 'Enter opens · ↑↓ Tab links · Esc back to the question'
: this.streaming ? 'answering… Ctrl+X stops · PgUp PgDn scroll · Esc closes' : 'Enter asks · Tab links · ↑↓ history · Ctrl+L new · Esc closes'
const title = `Assistant${this.config?.model && ready ? ` · ${this.config.model}` : ''}${this.scrollBack ? ` · ↑${this.scrollBack}` : ''}`
return (
<Zone
+3 -6
View File
@@ -63,7 +63,7 @@ export const TUI_COMMAND_HELP: Record<string, string> = {
shortcuts: 'single-key shortcuts on or off', lock: 'lock the screen', pin: 'set or remove the lock PIN', idle: 'screen saver after idle minutes', refresh: 'reload this screen',
filter: 'filter the grid (grammar)', export: 'export the grid or result', jobs: 'your jobs', printjobs: 'print jobs', print: 'print what you see (or :print <printer>)', profile: 'switch profile', servers: 'saved servers (Ctrl+O)',
history: 'everything you did (CLI and TUI)', server: 'connect to a server URL', signout: 'sign out', connection: 'connection details', open: 'open a table', record: 'open a record by key', app: 'open an app',
sql: 'SQL Runner (statement to run, open <notebook id>)', ask: 'show or hide the Assistant panel (F12, Ctrl+G); with a question, asks it about this screen', saver: 'the screen saver', doctor: 'check the terminal', views: 'saved views of this table', share: 'share this record', helpkeys: 'help-authoring mode',
sql: 'SQL Runner (statement to run, open <notebook id>)', ask: 'the Assistant panel (Ctrl+G); with a question, asks it about this screen', saver: 'the screen saver', doctor: 'check the terminal', views: 'saved views of this table', share: 'share this record', helpkeys: 'help-authoring mode',
}
/** CLI commands the `:` line does not offer (they make no sense inside the TUI). */
@@ -426,10 +426,7 @@ export class Commands implements CommandsLike {
case 'open': return rest[0] ? openTable(s, rest[0]) : fail('open <table>')
case 'app': return rest[0] ? openApp(s, rest[0]) : fail('app <name>')
case 'sql': return sqlCommand(s, line.replace(/^sql\b/, ''))
case 'ask': {
const question = line.replace(/^ask\b/, '').trim()
return question ? s.openAssistant(question) : s.toggleAssistant()
}
case 'ask': return s.openAssistant(line.replace(/^ask\b/, ''))
case 'report':
if (rest[0] === 'run' && rest[1] && !rest.includes('--format') && !rest.includes('--out')) return openReport(s, rest[1])
break
@@ -731,7 +728,7 @@ export class Commands implements CommandsLike {
out.push({ section: 'Work', label: 'My work', disabled: s.extensions.has('collaboration') ? undefined : 'needs the collaboration extension' })
out.push({ section: 'Work', label: 'History', command: 'history', detail: 'everything you did, CLI and TUI', run: () => openHistory(s) })
out.push(...sqlMenuEntries(s))
out.push({ id: 'work:assistant', section: 'Work', label: 'Assistant', key: 'F12', command: 'ask <question>', detail: 'F12 assistant (Ctrl+G too): ask about your data, with this screen as context', disabled: s.extensions.has('assistant') ? undefined : 'needs the assistant extension (qbit-assistant)', run: () => s.openAssistant() })
out.push({ id: 'work:assistant', section: 'Work', label: 'Assistant', key: 'Ctrl+G', command: 'ask <question>', detail: 'ask about your data, with this screen as context', disabled: s.extensions.has('assistant') ? undefined : 'needs the assistant extension (qbit-assistant)', run: () => s.openAssistant() })
for (const e of s.extensions.all()) out.push({ section: 'Work', label: `Extension: ${e.name} ${e.version}`, detail: e.description, command: `api --list`, run: () => s.message(`${e.name} ${e.version}\n${e.description ?? ''}\nPath: ${e.pathPrefix ?? ''}\nIts panels are not drawn by this TUI yet; its operations are reachable with :api.`, 'info', 'Extension') })
// Connection
out.push({ section: 'Connection', label: `Server ${s.status.server}`, detail: `UI API ${s.status.apiVersion}`, command: 'connection', run: () => this.connection() })
+1 -1
View File
@@ -26,7 +26,7 @@ const ANYWHERE: KeyRow[] = [
['F9', 'menu: areas, then their actions'],
['Ctrl+P PrtSc', 'print what you see: a record sheet, a table list (all rows or this page), or the screen; Ctrl+P always works, Print Screen only where the terminal passes it on'],
['Ctrl+O', 'servers: switch, add, edit'],
['F12 Ctrl+G', 'F12 assistant: show or hide the Assistant panel (this screen is the context); :ask <question>'],
['Ctrl+G', 'Assistant panel: ask about your data (this screen is the context); :ask <question>'],
[':', 'command line; Tab completes, ↑↓ history, Ctrl+R searches it; :history lists all you did'],
['F10', 'quit, reset or cancel'],
['Ctrl+C', 'quit now'],
+7 -18
View File
@@ -25,9 +25,6 @@ import { errorText } from './util/text'
import { SQL_EXTENSION, hideRunnerApps } from './sql/apps'
import { AssistantPanel, SIDE_MIN_WIDTH, sideWidth } from './assistant/panel'
/** From this width the F-key bar also has the 11 and 12 slots (12 is the Assistant). */
export const FKEY_WIDE = 120
export interface Settings {
theme: string
ascii: boolean
@@ -256,7 +253,6 @@ export class AppStore {
this.assistant ??= new AssistantPanel(this)
this.assistantOpen = true
this.focus = 'assistant'
this.assistant.capturePageContext()
void this.assistant.loadConfig()
if (question?.trim()) this.assistant.askWhenReady(question)
this.bump()
@@ -269,20 +265,16 @@ export class AppStore {
this.bump()
}
/**
* F12 (and Ctrl+G, and `:ask` without a question): shows the panel (focus on the question line,
* the current screen as context) or hides it. Hiding keeps the conversation, the context and the
* draft; the next press shows them again. When the panel is shown but the screen beside it has the
* focus, the press goes back to the question line instead.
*/
/** Ctrl+G: opens the panel, moves between the panel and the screen beside it, or (full width) closes it. */
toggleAssistant(): void {
if (!this.assistantOpen) return this.openAssistant()
// shown beside a screen that has the focus (a link was opened from it): back to the question line
if (this.focus !== 'assistant') {
this.focus = 'assistant'
return this.bump()
}
this.closeAssistant()
if (this.assistantLayout()?.mode === 'full') return this.closeAssistant()
this.focus = 'content'
this.bump()
}
// ---- overlays and messages ----
@@ -404,8 +396,7 @@ export class AppStore {
if (key.name === 'f9') return this.commands().menu()
if (key.ctrl && key.name === 'o') return this.commands().servers()
if (key.name === 'f10') return this.requestQuit()
// F12 shows and hides the Assistant panel (Ctrl+G where the terminal or the OS takes F12); F11 is reserved
if (key.name === 'f12' || (key.ctrl && key.name === 'g')) return this.toggleAssistant()
if (key.ctrl && key.name === 'g') return this.toggleAssistant()
if (this.focus === 'assistant' && this.assistantOpen && this.assistant) {
// the panel has every other key while it has focus (text goes to the question line)
this.assistant.onKey(key)
@@ -654,18 +645,16 @@ export class AppStore {
this.bump()
}
/** The function-key bar for the current context; from 120 columns also 11 (reserved, blank) and 12 (the Assistant). */
/** The function-key bar for the current context. */
fkeys(): Array<{ n: number; label: string | null; global: string }> {
const defaults: Record<number, string> = { 1: 'Help', 2: 'Search', 3: 'View', 4: 'Edit', 5: 'Refresh', 6: 'Filter', 7: 'New', 8: 'Delete', 9: 'Menu', 10: 'Quit' }
const overrides: FKeyLabels = this.focus === 'assistant' && this.assistantOpen && this.assistant ? this.assistant.fkeys() : this.screen?.fkeys?.() ?? { 3: null, 4: null, 6: null, 7: null, 8: null }
const keys = Object.entries(defaults).map(([n, label]): { n: number; label: string | null; global: string } => {
return Object.entries(defaults).map(([n, label]) => {
const k = Number(n)
const shown = k in overrides ? overrides[k]! : label
// F8 prints on screens that have no Delete for it
return { n: k, label: k === 8 && shown === null ? 'Print' : shown, global: label }
})
if (this.width >= FKEY_WIDE) keys.push({ n: 11, label: null, global: '' }, { n: 12, label: this.extensions.has('assistant') ? 'Ask' : null, global: 'Ask' })
return keys
}
themeTokens() {
+3 -132
View File
@@ -6,10 +6,7 @@
import { afterEach, beforeAll, expect, test } from 'bun:test'
import { testRender } from '@opentui/react/test-utils'
import { App } from '../src/app'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { chatLines, chatLinks, contextLabel, plainText, wrap } from '../src/assistant/model'
import { fkeyAt } from '../src/mouse/hit'
import { chatLines, contextLabel, plainText, wrap } from '../src/assistant/model'
import { boot, parseArgs } from '../src/boot'
import { linkFilter } from '../src/links'
import { fakeFetch, type FakeLog } from './fake-server'
@@ -29,7 +26,7 @@ const TURN = sse([
{ type: 'message.end', messageId: 2, status: 'COMPLETE' },
])
interface AssistantFake { installed: boolean; config: J; turns: J[]; conversations: J[]; stream?: string }
interface AssistantFake { installed: boolean; config: J; turns: J[]; conversations: J[] }
function assistantFetch(log: FakeLog, state: AssistantFake): typeof fetch {
const base = fakeFetch(log)
@@ -47,7 +44,7 @@ function assistantFetch(log: FakeLog, state: AssistantFake): typeof fetch {
}
if (/^\/assistant\/conversations\/\d+\/turns$/.test(path)) {
state.turns.push(body ?? {})
return new Response(state.stream ?? TURN, { status: 200, headers: { 'content-type': 'text/event-stream' } })
return new Response(TURN, { status: 200, headers: { 'content-type': 'text/event-stream' } })
}
return json({ code: 'NOT_FOUND', message: path }, 404)
}) as typeof fetch
@@ -185,129 +182,3 @@ test('chat lines wrap to the width, number links across exchanges and drop markd
expect(contextLabel({ tableName: 'order', recordId: 5 }, 'Order')).toBe('Order #5')
expect(contextLabel({ tableName: 'order', filter: {} }, 'Order')).toBe('Order (filtered)')
})
/** The answer to "what services are not healthy right now?" as 8078 gave it (qqq assistant ask --json). */
const UNHEALTHY = JSON.parse(readFileSync(join(import.meta.dir, '..', '..', 'core', 'src', 'test', 'fixtures', 'assistant-unhealthy.json'), 'utf8')) as J & { text: string; links: Array<{ label: string; href: string }> }
const unhealthyAnswer = () => ({ ...(UNHEALTHY as never as import('@qrun-io/qqq-core').AssistantAnswer), status: 'COMPLETE' as const })
test('before: the 8078 answer drawn as plain text had entities, a pipe table, URLs and a duplicate link list', () => {
const before = plainText(UNHEALTHY.text)
expect(before).toContain('healthy&#58;')
expect(before).toContain('| --- |')
expect(before).toContain('/app/health_component?filter=%7B')
expect(UNHEALTHY.links.length).toBe(8)
})
for (const width of [98, 158]) {
test(`after (${width} wide): entities decoded, a table, links as label ↗ once each, no URLs, no → list`, () => {
const lines = chatLines([{ question: 'what services are not healthy right now?', startedAt: 0, answer: unhealthyAnswer() }], width)
const text = lines.map((l) => l.text).join('\n')
expect(text).not.toMatch(/&#|\/app\/|%7B|\| ---|^→/m)
expect(text).toContain('3 services are not fully healthy: one degraded (email) and two with unknown status.')
expect(text).toMatch(/Service\s+Status\s+Since/)
expect(text).toMatch(/db_performance ↗\s+UNKNOWN/)
expect(text).toContain('• Open unhealthy components ↗')
expect(text).toContain('• email is degraded. Its only check is degraded.')
expect(text).toContain('7.1 s · 10 in / 729 out tokens')
expect(lines.every((l) => [...l.text].length <= width)).toBe(true)
expect(lines.find((l) => l.role === 'meta' && l.text.includes('tokens'))).toBeTruthy()
// four Tab targets in reading order: the three services, then the open-all link
expect(chatLinks([{ question: 'q', startedAt: 0, answer: unhealthyAnswer() }]).map((l) => l.label)).toEqual(['email', 'db_performance', 'esb', 'Open unhealthy components'])
const linked = lines.flatMap((l) => (l.segs ?? []).filter((x) => x.link !== undefined).map((x) => x.link))
expect([...new Set(linked)]).toEqual([0, 1, 2, 3])
})
}
test('narrow: the table keeps the columns that fit and names the rest', () => {
const lines = chatLines([{ question: 'q', startedAt: 0, answer: unhealthyAnswer() }], 38)
expect(lines.every((l) => [...l.text].length <= 38)).toBe(true)
expect(lines.map((l) => l.text).join('\n')).toMatch(/more columns?:/)
})
test('duplicate paragraphs collapse; a link the text does not show is listed once under →', () => {
const answer = { conversationId: 1, status: 'COMPLETE' as const, notices: [], numbers: [], tools: [], text: 'All good.\n\nAll good.',
links: [{ label: 'Person (3)', href: '/app/person', kind: 'table' as const }, { label: 'People', href: '/app/person', kind: 'table' as const }] }
const lines = chatLines([{ question: 'q', startedAt: 0, answer }], 60)
expect(lines.filter((l) => l.text === 'All good.').length).toBe(1)
expect(lines.filter((l) => l.text.startsWith('→ ')).map((l) => l.text)).toEqual(['→ Person (3) ↗'])
})
test('F12 shows and hides the panel; hiding keeps the conversation, context and draft; Esc hides without asking', async () => {
const state = fresh()
h = await start(state, 120, 36)
await command(h, 'record person 1')
await waitFor(h, () => h!.store.screen?.kind === 'record')
await key('F12')
expect(h.store.assistantOpen).toBe(true)
expect(h.store.focus).toBe('assistant')
await waitFor(h, (x) => x.includes('on: Person #1'))
await type('draft question')
await key('F12')
expect(h.store.assistantOpen).toBe(false)
expect(h.store.focus).toBe('content')
// back on Home the panel keeps the record's context and the draft
await key('ESCAPE')
await key('F12')
expect(h.store.assistantOpen).toBe(true)
expect(h.store.assistant!.editor.value).toBe('draft question')
expect(await waitFor(h, (x) => x.includes('on: Person #1'))).toBeTruthy()
// Esc with text on the question line hides; nothing is asked; the draft stays
await key('ESCAPE')
expect(h.store.assistantOpen).toBe(false)
expect(state.turns.length).toBe(0)
expect(h.store.assistant!.editor.value).toBe('draft question')
// Ctrl+G and :ask do the same
await key('g', { ctrl: true })
expect(h.store.assistantOpen).toBe(true)
await key('g', { ctrl: true })
expect(h.store.assistantOpen).toBe(false)
await command(h, 'ask')
expect(h.store.assistantOpen).toBe(true)
})
test('F-key bar: 11 and 12 from 120 columns, 12Ask lit with the extension, a click on it toggles', async () => {
h = await start(fresh(), 120, 36)
let keys = h.store.fkeys()
expect(keys.map((k) => k.n)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
expect(keys.find((k) => k.n === 12)?.label).toBe('Ask')
expect(keys.find((k) => k.n === 11)?.label).toBeNull()
const bar = h.frame().split('\n').filter((l) => l.trim()).pop()!
expect(bar).toContain('12Ask')
expect(bar).toMatch(/11\s+12Ask/)
expect([...bar].length).toBeLessThanOrEqual(120)
const x = bar.indexOf('12Ask') + 2
expect(fkeyAt(x, 120, keys.map((k) => k.n))).toBe(12)
const y = h.frame().split('\n').findIndex((l) => l.includes('12Ask'))
await press(h, () => h!.setup.mockMouse.click(x, y))
expect(h.store.assistantOpen).toBe(true)
await press(h, () => h!.setup.mockMouse.click(x, y))
expect(h.store.assistantOpen).toBe(false)
h.store.resize(100, 36)
keys = h.store.fkeys()
expect(keys.map((k) => k.n)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
})
test('F-key bar without the extension: 12Ask is dimmed (no label), the press says what is missing', async () => {
h = await start({ ...fresh(), installed: false }, 120, 36)
expect(h.store.fkeys().find((k) => k.n === 12)?.label).toBeNull()
await key('F12')
await waitFor(h, (f) => f.includes('needs the assistant extension'))
expect(h.store.assistantOpen).toBe(false)
})
test('a markdown answer in the panel: a table and links as label ↗, Tab to a link, u shows its address', async () => {
const state = { ...fresh(), stream: sse([
{ type: 'message.start', turnId: 1, conversationId: 7, userMessageId: 1, messageId: 2 },
{ type: 'text.delta', messageId: 2, text: 'Two people&#58;\n\n| Person | Last |\n| --- | --- |\n| [Blair](/app/person/2) | Odd |\n| [Casey](/app/person/3) | Even |\n' },
{ type: 'message.end', messageId: 2, status: 'COMPLETE' },
]) }
h = await start(state, 100, 30)
await command(h, 'ask who are they')
const f = await waitFor(h, (x) => x.includes('Blair ↗'))
expect(f).toContain('Two people:')
expect(f).toMatch(/Person\s+Last/)
expect(f).not.toContain('/app/person')
await key('TAB')
await key('u')
expect(await waitFor(h, (x) => x.includes('/app/person/2') && x.includes('Link address'))).toBeTruthy()
})