Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b79487dad7 | ||
|
|
fc7a90ce40 | ||
|
|
9f8d652028 |
No files matched your search
@@ -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:` 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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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} "...")`)
|
||||
|
||||
@@ -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'), '')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 (`:` 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: one degraded (email) and two with unknown status. None are down.\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:25 UTC | 0 | 1 | 0 |\n| [db_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: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: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
|
||||
}
|
||||
@@ -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:/)
|
||||
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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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:')
|
||||
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:\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()
|
||||
})
|
||||
Reference in new issue
Block a user