Compare commits

...
Author SHA1 Message Date
james.maes b79487dad7 Merge remote-tracking branch 'origin/main' into fix/tui-assistant-render 2026-11-01 13:16:18 -06:00
james.maesandClaude Fable 5.1 fc7a90ce40 feat(tui): rendered assistant answers, F12 shows and hides the panel
The panel drew answers as plain text (owner: "the assistant needs some
work"). It now renders them through the kernel's markdown parser:
entities decoded, headings in the accent colour, wrapped paragraphs,
lists, bold and code, pipe tables laid out like the grid and clipped;
links as `label ↗` only, numbered Tab targets in reading order, once per
target, `u` shows a link's address, and the → list keeps only links the
text does not show. Duplicate paragraphs collapse; the timing line stays.

F12 shows and hides the panel on every screen (owner: "a function key"):
hiding keeps the conversation, context and draft; Esc hides without
asking; Ctrl+G, :ask and a click on the new 12Ask slot (from 120 columns,
dimmed without the extension; 11 stays blank and reserved) do the same.
OpenTUI reads F12 as both the legacy ESC [24~ and the kitty protocol key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:10:39 -06:00
james.maesandClaude Fable 5.1 9f8d652028 fix(assistant): render the answer's markdown in qqq assistant ask
The CLI printed the model's markdown as it came: HTML entities
(`healthy&#58;`), pipe tables with `| --- |` rows and URL-encoded
/app/...?filter= link targets, then the same links again. The kernel
gets a small markdown parser (render/markdown.ts: entities decoded,
headings, paragraphs, lists, bold, code, pipe tables, links numbered once
per target; a filter on the primary key is that record, JSON and base64
filters compare by content; exact duplicate paragraphs collapse), and
`assistant ask` prints whole blocks as they complete: tables as
sub-tables, links as `label (qqq table get ...)` or `label [n]` with the
command under Links, never a URL; tool-result links only when the text
does not show the same target. Tested with the 8078 answer about
unhealthy services as a fixture.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:10:39 -06:00
16 changed files with 967 additions and 94 deletions

No files matched your search

+24
View File
@@ -3,6 +3,30 @@
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, 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 |
| `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 |
| `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 |
+86 -25
View File
@@ -13,7 +13,7 @@
import type { Command } from 'commander'
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
import { assistantContextFor, describeAssistantConfig, type AssistantAnswer } from '@qrun-io/qqq-core'
import { answerDocument, assistantContextFor, describeAssistantConfig, linkTargetKey, markdownText, parseAppHref, parseMarkdown, type AssistantAnswer, type MdInline } from '@qrun-io/qqq-core'
import { io, type Context, UsageError } from '../context.js'
import { positiveInt, type Json } from './shared.js'
@@ -22,16 +22,73 @@ export function plainAnswer(text: string): string {
return text.replace(/\*\*(.+?)\*\*/g, '$1').replace(/__(.+?)__/g, '$1').replace(/`([^`]+)`/g, '$1').replace(/^#{1,6}\s+/gm, '')
}
/** 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}` : '',
}))
/** 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 }
}
export function registerAssistant(noun: Command, ctx: () => Context): void {
@@ -66,38 +123,42 @@ 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'
let streamed = false
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
}
const answer = await assistant.ask(question, {
conversationId,
context: assistantContextFor({ tableName: options.table, recordId: options.record }),
onEvent: (event) => {
onEvent: (event, partial) => {
if (!human) return
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}`)
}
// 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 (answer.status === 'ERROR') {
if (streamed) io.out('\n')
if (printedAny) 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
}
if (!streamed) io.out(plainAnswer(answer.text))
emit(renderer.next(answer.text, true))
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, href: n.href ?? '' })), columns: ['number', 'value', 'href'] })
c.print({ data: answer.numbers, rows: answer.numbers.map((n) => ({ number: n.label, value: n.value })), columns: ['number', 'value'] })
}
if (answer.links.length) {
io.out('\n')
c.print({ data: answer.links, rows: linkRows(answer), columns: ['kind', 'label', 'table', 'id', 'open'] })
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'] })
}
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} "...")`)
+29 -4
View File
@@ -5,7 +5,10 @@
import assert from 'node:assert/strict'
import { after, before, describe, test } from 'node:test'
import { plainAnswer } from '../commands/assistant.js'
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 { runCli, sandbox } from './harness.js'
import { StubResponse, startStub, type J, type Stub } from './stub.js'
@@ -59,9 +62,11 @@ 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\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.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.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')!
@@ -94,4 +99,24 @@ 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'), '')
})
})
+1
View File
@@ -48,6 +48,7 @@ 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
@@ -0,0 +1,261 @@
/*
* 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 }
}
@@ -0,0 +1,85 @@
{
"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
@@ -0,0 +1,63 @@
/*
* 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'])
})
+34 -14
View File
@@ -350,31 +350,51 @@ help-authoring mode (F1 shows every help slot key).
## Assistant
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.
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.
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`).
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.
| Key | Does |
|---|---|
| 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) |
| 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) |
| 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 |
| Ctrl+G | back to the screen (the panel stays); again to return |
| Esc | close the panel (the conversation is kept) |
| 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 |
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`.
**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`.
## 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' }
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' }
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.n === 10 ? Math.max(0, width - used) : cell
const w = k === keys[keys.length - 1] ? 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
+158 -16
View File
@@ -4,13 +4,16 @@
*/
/**
* 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.
* 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.
*/
import type { AssistantAnswer, AssistantPageContext } from '@qrun-io/qqq-core'
import { cellWidth } from '../util/text'
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'
/** One question and its answer (streaming until `answer.status` leaves RUNNING). */
export interface Exchange {
@@ -26,13 +29,22 @@ export interface Exchange {
startedAt: number
}
export type LineRole = 'question' | 'context' | 'text' | 'number' | 'link' | 'activity' | 'error' | 'meta' | 'blank'
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 interface ChatLine {
/** The line as plain text (what the segments add up to). */
text: string
role: LineRole
/** Index into {@link chatLinks} for link lines. */
/** The first link on the line (a click opens it). */
link?: number
segs?: Seg[]
}
/** The answer's markdown as plain text (bold, code and heading markers dropped). */
@@ -74,15 +86,140 @@ export function wrap(text: string, width: number): string[] {
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 ?? [])
/** 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
}
/** The conversation as lines for a width (the panel's inner width). */
export function chatLines(exchanges: Exchange[], width: number, now = Date.now()): ChatLine[] {
export function chatLines(exchanges: Exchange[], width: number, now = Date.now(), primaryKeyOf?: PrimaryKeyOf): ChatLine[] {
const lines: ChatLine[] = []
let linkIndex = 0
let offset = 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 } : {}) }))
@@ -93,11 +230,16 @@ 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 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')
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')
for (const num of answer?.numbers ?? []) add(`${num.label}: ${num.value}`, 'number', '# ')
for (const link of answer?.links ?? []) add(link.label, 'link', '→ ', linkIndex++)
// 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 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')
+66 -18
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 { errorText, hintLine, pad, trunc } from '../util/text'
import { chatLines, chatLinks, contextLabel, wrap, type ChatLine, type Exchange } from './model'
import { cellWidth, errorText, hintLine, pad, trunc } from '../util/text'
import { chatLines, chatLinks, contextLabel, wrap, type ChatLine, type Exchange, type Seg } 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,9 +92,26 @@ export class AssistantPanel {
void this.loadConfig().then(() => this.ask(question))
}
/** The page under the panel as context: a record, or a grid with its filter. */
/** 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). */
pageContext(): AssistantPageContext | undefined {
if (this.dropContext) return undefined
return this.dropContext ? undefined : this.pinned
}
/** The screen's context: a record, or a grid with its filter. */
screenContext(): AssistantPageContext | 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 })
@@ -170,9 +187,23 @@ 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 = chatLinks(this.exchanges)[index]
const link = this.links()[index]
if (!link) return
this.linkCursor = -1
this.store.focus = 'content'
@@ -198,13 +229,14 @@ export class AssistantPanel {
['Ctrl+D', 'send without / with the page context'],
['Ctrl+L', 'new conversation'],
['Ctrl+X', 'stop the answer'],
['Ctrl+G', 'back to the screen (the panel stays)'],
['Esc', 'close the panel (the conversation is kept)'],
['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)'],
]
}
onKey(key: Key): boolean {
const links = chatLinks(this.exchanges).length
const links = this.links().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') {
@@ -214,6 +246,7 @@ 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
@@ -247,6 +280,7 @@ 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
@@ -285,15 +319,16 @@ 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) : this.intro().flatMap((x) => wrap(x.text, inner).map((text) => ({ ...x, text })))
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 })))
if (this.pendingReveal && this.linkCursor >= 0) {
const at = all.findIndex((l) => l.link === this.linkCursor)
const at = all.findIndex((l) => l.segs?.some((x) => x.link === this.linkCursor) || l.link === this.linkCursor)
if (at >= 0) {
const bottom = all.length - this.scrollBack
if (at < bottom - bodyHeight) this.scrollBack = all.length - at - bodyHeight
@@ -306,27 +341,40 @@ export class AssistantPanel {
const shown = all.slice(Math.max(0, end - bodyHeight), end)
const color = (l: ChatLine): string => {
switch (l.role) {
case 'question': return t.acc
case 'context': case 'meta': case 'activity': return t.dim
case 'question': case 'heading': case 'header': return t.acc
case 'context': case 'meta': case 'activity': case 'rule': 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 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>
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>
return l.link === undefined
? <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>
? <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>
})
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 · ↑↓ 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'
? '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'
const title = `Assistant${this.config?.model && ready ? ` · ${this.config.model}` : ''}${this.scrollBack ? ` · ↑${this.scrollBack}` : ''}`
return (
<Zone
+6 -3
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: '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',
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',
}
/** CLI commands the `:` line does not offer (they make no sense inside the TUI). */
@@ -426,7 +426,10 @@ 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': return s.openAssistant(line.replace(/^ask\b/, ''))
case 'ask': {
const question = line.replace(/^ask\b/, '').trim()
return question ? s.openAssistant(question) : s.toggleAssistant()
}
case 'report':
if (rest[0] === 'run' && rest[1] && !rest.includes('--format') && !rest.includes('--out')) return openReport(s, rest[1])
break
@@ -728,7 +731,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: '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() })
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() })
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'],
['Ctrl+G', 'Assistant panel: ask about your data (this screen is the context); :ask <question>'],
['F12 Ctrl+G', 'F12 assistant: show or hide the Assistant panel (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'],
+18 -7
View File
@@ -25,6 +25,9 @@ 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
@@ -253,6 +256,7 @@ 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()
@@ -265,16 +269,20 @@ export class AppStore {
this.bump()
}
/** Ctrl+G: opens the panel, moves between the panel and the screen beside it, or (full width) closes it. */
/**
* 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.
*/
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()
}
if (this.assistantLayout()?.mode === 'full') return this.closeAssistant()
this.focus = 'content'
this.bump()
this.closeAssistant()
}
// ---- overlays and messages ----
@@ -396,7 +404,8 @@ 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()
if (key.ctrl && key.name === 'g') return this.toggleAssistant()
// 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 (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)
@@ -645,16 +654,18 @@ export class AppStore {
this.bump()
}
/** The function-key bar for the current context. */
/** The function-key bar for the current context; from 120 columns also 11 (reserved, blank) and 12 (the Assistant). */
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 }
return Object.entries(defaults).map(([n, label]) => {
const keys = Object.entries(defaults).map(([n, label]): { n: number; label: string | null; global: string } => {
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() {
+132 -3
View File
@@ -6,7 +6,10 @@
import { afterEach, beforeAll, expect, test } from 'bun:test'
import { testRender } from '@opentui/react/test-utils'
import { App } from '../src/app'
import { chatLines, contextLabel, plainText, wrap } from '../src/assistant/model'
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 { boot, parseArgs } from '../src/boot'
import { linkFilter } from '../src/links'
import { fakeFetch, type FakeLog } from './fake-server'
@@ -26,7 +29,7 @@ const TURN = sse([
{ type: 'message.end', messageId: 2, status: 'COMPLETE' },
])
interface AssistantFake { installed: boolean; config: J; turns: J[]; conversations: J[] }
interface AssistantFake { installed: boolean; config: J; turns: J[]; conversations: J[]; stream?: string }
function assistantFetch(log: FakeLog, state: AssistantFake): typeof fetch {
const base = fakeFetch(log)
@@ -44,7 +47,7 @@ function assistantFetch(log: FakeLog, state: AssistantFake): typeof fetch {
}
if (/^\/assistant\/conversations\/\d+\/turns$/.test(path)) {
state.turns.push(body ?? {})
return new Response(TURN, { status: 200, headers: { 'content-type': 'text/event-stream' } })
return new Response(state.stream ?? TURN, { status: 200, headers: { 'content-type': 'text/event-stream' } })
}
return json({ code: 'NOT_FOUND', message: path }, 404)
}) as typeof fetch
@@ -182,3 +185,129 @@ 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()
})