Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
952492f5d3 | ||
|
|
650f1d33db | ||
|
|
708ac66251 | ||
|
|
f180a12750 |
No files matched your search
@@ -3,6 +3,34 @@
|
||||
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]
|
||||
|
||||
### Fixed
|
||||
|
||||
- The screen saver's Online tile said "nobody around" with two terminals signed in. It now counts signed-in sessions first (the session store's table: sessions active in the last 10 minutes and their users, your own included, "2 sessions · 1 user (you ×2)"), else you plus the people in collaboration presence; it never says nobody is around while you are signed in. The saver also keeps its presence lease on the screen under it (the extension has no idle state), so other clients see you still viewing it instead of gone. A server that stores no sessions and merges presence per user (mock auth) shows both terminals as "1 online · you".
|
||||
- Query in the screen saver looked like a different dog. All sizes are now generated from the drawing on https://www.qrun.io/query/ (`scripts/gen-query-art.mjs`) in its indigo, with a new medium size (25×13 cells, used where there is room) between the large (42×22) and the small (17×9).
|
||||
### 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} "...")`)
|
||||
|
||||
@@ -14,31 +14,55 @@ import { Assistant, Collaboration, Printing, missingQBitMessage, runProcess, typ
|
||||
import { openRealtimeStream } from '@qrun-io/qqq-ui-api-client'
|
||||
import { CLIENT_CHANNEL, authenticatedFetch, clientFor, currentToken, selectTarget, sessionCookieHeaderFor, target, type Target } from './session.js'
|
||||
|
||||
/** A write that ends the process quietly (status 0) when it throws because the reader has gone. */
|
||||
function safeWrite(stream: NodeJS.WriteStream, text: string): void {
|
||||
try {
|
||||
stream.write(text)
|
||||
} catch (error) {
|
||||
if (isBrokenPipe(error)) process.exit(0)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where output goes. The CLI writes to the process streams; `runCli` (the TUI's `:` command line)
|
||||
* points these at a pane for the duration of one command.
|
||||
*/
|
||||
export const io = {
|
||||
out: (text: string): void => { process.stdout.write(text) },
|
||||
err: (text: string): void => { process.stderr.write(text) },
|
||||
out: (text: string): void => { safeWrite(process.stdout, text) },
|
||||
err: (text: string): void => { safeWrite(process.stderr, text) },
|
||||
isTTY: (): boolean => Boolean(process.stdout.isTTY),
|
||||
/** The terminal width table output fits nested results in; undefined when not on a terminal. */
|
||||
width: (): number | undefined => (process.stdout.isTTY ? process.stdout.columns : undefined),
|
||||
}
|
||||
|
||||
/** What a write to a pipe whose reader has gone ends with: EPIPE, or (macOS, a stream already torn down) the errors that follow it. */
|
||||
const BROKEN_PIPE_CODES = new Set(['EPIPE', 'ECONNRESET', 'ERR_STREAM_DESTROYED', 'ERR_STREAM_WRITE_AFTER_END'])
|
||||
|
||||
/** Whether an error is a write to a pipe whose reader has gone (`qqq table query | head -1`). */
|
||||
export function isBrokenPipe(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | undefined)?.code === 'EPIPE'
|
||||
return BROKEN_PIPE_CODES.has(String((error as NodeJS.ErrnoException | undefined)?.code))
|
||||
}
|
||||
|
||||
/** Exits quietly with status 0 when stdout or stderr is a pipe nobody reads any more, as `yes | head` does. */
|
||||
export function exitOnBrokenPipe(streams: Array<NodeJS.WriteStream> = [process.stdout, process.stderr], exit: (code: number) => void = (code) => process.exit(code)): void {
|
||||
/**
|
||||
* A pipe nobody reads any more ends the command at once with status 0, as `yes | head` does, whatever
|
||||
* else was going on: the process exits from the stream's error event, from a write that throws, and
|
||||
* from an uncaught error that is one, so no later code can turn it into a failure.
|
||||
*/
|
||||
export function exitOnBrokenPipe(streams: Array<NodeJS.WriteStream> = [process.stdout, process.stderr], exit: (code: number) => void = (code) => process.exit(code), on: (event: 'uncaughtException', listener: (error: Error) => void) => unknown = (event, listener) => process.on(event, listener)): void {
|
||||
for (const stream of streams) {
|
||||
stream.on('error', (error) => {
|
||||
if (isBrokenPipe(error)) exit(0)
|
||||
else throw error
|
||||
})
|
||||
}
|
||||
on('uncaughtException', (error) => {
|
||||
if (isBrokenPipe(error)) exit(0)
|
||||
else {
|
||||
process.stderr.write(`${error.stack ?? error.message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Exit codes, as in the design doc (KofTwentyTwo ADR-0031 shape). */
|
||||
|
||||
@@ -33,10 +33,7 @@ importLegacyCredentials().then(() => program.parseAsync()).catch((error: unknown
|
||||
return
|
||||
}
|
||||
// a stream that failed with EPIPE (an export piped into head): the reader left, nothing to report
|
||||
if (isBrokenPipe(error)) {
|
||||
process.exitCode = 0
|
||||
return
|
||||
}
|
||||
if (isBrokenPipe(error)) process.exit(0)
|
||||
const { code, text } = describeError(error, Boolean(program.opts().trace))
|
||||
process.stderr.write(text + '\n')
|
||||
process.exitCode = code
|
||||
|
||||
@@ -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'), '')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('broken pipes', () => {
|
||||
stub = await startStub([...BASE_ROUTES,
|
||||
[/^GET \/metaData$/, () => ({ tables: { big: { name: 'big', label: 'Big' } }, processes: {}, apps: {} })],
|
||||
[/^GET \/metaData\/table\/big$/, () => ({ name: 'big', label: 'Big', primaryKeyField: 'id', capabilities: ['TABLE_QUERY'], fields: { id: { name: 'id', label: 'Id', type: 'INTEGER' }, note: { name: 'note', label: 'Note', type: 'STRING' } } })],
|
||||
[/^POST \/table\/big\/query$/, () => ({ records: Array.from({ length: 2000 }, (_, i) => ({ values: { id: i, note: 'x'.repeat(200) } })) })],
|
||||
[/^POST \/table\/big\/query$/, () => ({ records: Array.from({ length: 5000 }, (_, i) => ({ values: { id: i, note: 'x'.repeat(200) } })) })],
|
||||
])
|
||||
})
|
||||
after(async () => {
|
||||
@@ -30,23 +30,40 @@ describe('broken pipes', () => {
|
||||
|
||||
test('the helper exits 0 on EPIPE and rethrows anything else', () => {
|
||||
assert.ok(isBrokenPipe(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' })))
|
||||
assert.ok(isBrokenPipe(Object.assign(new Error('destroyed'), { code: 'ERR_STREAM_DESTROYED' })))
|
||||
assert.ok(!isBrokenPipe(new Error('other')))
|
||||
const listeners: Array<(e: Error) => void> = []
|
||||
const exits: number[] = []
|
||||
exitOnBrokenPipe([{ on: (_: string, f: (e: Error) => void) => listeners.push(f) } as never], (code) => exits.push(code))
|
||||
let uncaught: ((e: Error) => void) | undefined
|
||||
exitOnBrokenPipe([{ on: (_: string, f: (e: Error) => void) => listeners.push(f) } as never], (code) => exits.push(code), (_, f) => { uncaught = f })
|
||||
listeners[0]!(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
|
||||
assert.deepEqual(exits, [0])
|
||||
uncaught!(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
|
||||
assert.deepEqual(exits, [0, 0])
|
||||
assert.throws(() => listeners[0]!(new Error('disk full')), /disk full/)
|
||||
})
|
||||
|
||||
test('a closed stdout ends the command quietly with status 0 and no stack trace', async () => {
|
||||
/** Runs a query whose output is far larger than a pipe buffer, closing the read end when `closeAt` says so. */
|
||||
const query = async (closeAt: 'before-any-write' | 'first-chunk') => {
|
||||
const env = { ...process.env, ...box.env, QQQ_TOKEN: 't', NO_COLOR: '1' }
|
||||
const child = spawn(process.execPath, [CLI, '--server', stub.url, 'table', 'query', 'big', '-O', 'json', '-l', '2000'], { env, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
const child = spawn(process.execPath, [CLI, '--server', stub.url, 'table', 'query', 'big', '-O', 'json', '-l', '5000'], { env, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let stderr = ''
|
||||
child.stderr.on('data', (d: Buffer) => (stderr += d.toString('utf8')))
|
||||
child.stdout.once('data', () => child.stdout.destroy())
|
||||
// before-any-write: the reader is gone before the child has even started, so its first write meets a closed pipe
|
||||
if (closeAt === 'before-any-write') child.stdout.destroy()
|
||||
else child.stdout.once('data', () => child.stdout.destroy())
|
||||
const code = await new Promise<number | null>((resolve) => child.on('close', resolve))
|
||||
assert.equal(code, 0)
|
||||
return { code, stderr }
|
||||
}
|
||||
|
||||
test('a reader that is gone before the first write ends the command quietly with status 0', { timeout: 60_000 }, async () => {
|
||||
const { code, stderr } = await query('before-any-write')
|
||||
assert.equal(code, 0, stderr)
|
||||
assert.doesNotMatch(stderr, /EPIPE|at .*\.js/)
|
||||
})
|
||||
|
||||
test('a reader that leaves after the first chunk ends the command quietly with status 0', { timeout: 60_000 }, async () => {
|
||||
const { code, stderr } = await query('first-chunk')
|
||||
assert.equal(code, 0, stderr)
|
||||
assert.doesNotMatch(stderr, /EPIPE|at .*\.js/)
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
+36
-16
@@ -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
|
||||
|
||||
@@ -457,7 +477,7 @@ the plain sample server; one that fails shows `…`, then its last value dimmed
|
||||
|
||||
| Tile | Needs | Shows |
|
||||
|------|-------|-------|
|
||||
| Online | collaboration | people in presence across a few tables per refresh (favourites and recents first), who and on what; the signed-in user's other sessions as "you elsewhere" |
|
||||
| Online | the session store, or collaboration | who is signed in: with the session store qbit, the sessions active in the last 10 minutes and their users ("3 sessions · 2 users (you ×2, bob)"; your own sessions count); otherwise you plus the people in presence across a few tables per refresh (favourites and recents first), who and on what. It never says "nobody around" while you are signed in. A server that stores no sessions (mock auth) or merges presence per user cannot tell two terminals of one user apart: both show "1 online · you". The saver keeps the presence lease of the screen under it, so others see you still viewing it, not gone |
|
||||
| Jobs | `durableJobs` | running, queued and recently failed jobs (all users with jobs_admin, else yours) |
|
||||
| Health | health | the summary status and up / degraded / down / unknown counts |
|
||||
| Database | db-performance | live sessions, connections of the maximum, cache hit ratio, blocking chains |
|
||||
@@ -477,7 +497,7 @@ the other tiles under it as one centred stack, rotating every 8 s). In a tall na
|
||||
the tile panel under Query. The block letters say the profile's
|
||||
short name when it has 6 characters or fewer (else the profile name), with the server host in plain text
|
||||
beneath; letters, digits, `-`, `_` and `.` are drawn in block letters, anything else falls back to plain
|
||||
text. Query blinks, wags and hops every ten seconds and faces the way he drifts; the art (braille, with a
|
||||
text. Query blinks, wags and hops every ten seconds and faces the way he drifts. His art is Query's own drawing from https://www.qrun.io/query/ (one colour, indigo `#818cf8`) rasterised into braille at three sizes (42×22, 25×13 and 17×9 cells; the medium is used where there is room, from 100 columns) by `scripts/gen-query-art.mjs`; the art (braille, with a
|
||||
line-art version for `--ascii`) is in `src/saver/query-art.ts`.
|
||||
|
||||
Burn-in guard (F9 › Settings › Screen saver burn-in guard, `:saver burnin on|off`, default on): nothing
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
// Generates the screen saver's Query art (src/saver/query-art.ts: LARGE, MEDIUM, SMALL and the dot
|
||||
// boxes of the eye and the tail) from Query's own drawing: the inline SVG on https://www.qrun.io/query/
|
||||
// (viewBox 0 0 331.41 348.44; the first path is the sitting dog, the second his eye). Needs `inkscape`
|
||||
// and ImageMagick (`magick`) on the PATH; run by hand when the drawing changes:
|
||||
// node scripts/gen-query-art.mjs query-hero.svg [outdir]
|
||||
// Each size is the drawing rasterised at 8× the dot grid, averaged down to it (a box filter, so a thin
|
||||
// stroke keeps its weight as coverage) and thresholded per size (thinner strokes need a lower cut).
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const [svgPath, outDir = '.'] = process.argv.slice(2)
|
||||
if (!svgPath) throw new Error('usage: gen-query-art.mjs query-hero.svg [outdir]')
|
||||
const svg = readFileSync(svgPath, 'utf8').replaceAll('currentColor', '#ffffff')
|
||||
const paths = [...svg.matchAll(/<path[^>]*>/g)].map((m) => m[0])
|
||||
const wrap = (body) => `<svg viewBox="0 0 331.41 348.44" xmlns="http://www.w3.org/2000/svg"><rect width="331.41" height="348.44" fill="#000"/>${body}</svg>`
|
||||
const work = mkdtempSync(join(tmpdir(), 'query-art-'))
|
||||
|
||||
/** Coverage 0…1 per dot of an SVG body, on a grid of w×h dots. */
|
||||
function raster(body, w, h) {
|
||||
const file = join(work, 'in.svg')
|
||||
writeFileSync(file, wrap(body))
|
||||
const png = join(work, 'out.png')
|
||||
execFileSync('inkscape', [file, '-o', png, '-w', String(w * 8), '-h', String(h * 8)], { stdio: 'ignore' })
|
||||
const raw = execFileSync('magick', [png, '-colorspace', 'Gray', '-filter', 'Box', '-resize', `${w}x${h}!`, '-depth', '8', 'gray:-'], { maxBuffer: 1 << 24 })
|
||||
return Array.from({ length: h }, (_, y) => Array.from({ length: w }, (_, x) => raw[y * w + x] / 255))
|
||||
}
|
||||
|
||||
const DOT = [[0x01, 0x02, 0x04, 0x40], [0x08, 0x10, 0x20, 0x80]]
|
||||
function braille(bits) {
|
||||
const rows = []
|
||||
for (let cy = 0; cy < bits.length / 4; cy++) {
|
||||
let line = ''
|
||||
for (let cx = 0; cx < bits[0].length / 2; cx++) {
|
||||
let code = 0
|
||||
for (let dx = 0; dx < 2; dx++) for (let dy = 0; dy < 4; dy++) if (bits[cy * 4 + dy]?.[cx * 2 + dx]) code |= DOT[dx][dy]
|
||||
line += code ? String.fromCodePoint(0x2800 + code) : ' '
|
||||
}
|
||||
rows.push(line)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const SIZES = { large: { w: 42, h: 22, cut: 0.42 }, medium: { w: 25, h: 13, cut: 0.28 }, small: { w: 17, h: 9, cut: 0.2 } }
|
||||
const out = {}
|
||||
for (const [name, { w, h, cut }] of Object.entries(SIZES)) {
|
||||
const dw = w * 2
|
||||
const dh = h * 4
|
||||
const bits = raster(paths[0], dw, dh).map((r) => r.map((v) => v >= cut))
|
||||
// his eye: the second path alone, any coverage at all
|
||||
const eyeCov = raster(paths[1], dw, dh)
|
||||
const eyeDots = []
|
||||
eyeCov.forEach((r, y) => r.forEach((v, x) => { if (v >= 0.12) eyeDots.push([x, y]) }))
|
||||
const eye = eyeDots.length ? [Math.min(...eyeDots.map((d) => d[0])), Math.min(...eyeDots.map((d) => d[1])), Math.max(...eyeDots.map((d) => d[0])), Math.max(...eyeDots.map((d) => d[1]))] : [0, 0, 0, 0]
|
||||
// the eye is part of the dog path's raster too: keep the eye dots drawn
|
||||
for (const [x, y] of eyeDots) bits[y][x] = true
|
||||
// the tail: the low left corner of the drawing
|
||||
const tail = [0, Math.round(dh * 0.62), Math.round(dw * 0.2), dh - 1]
|
||||
out[name] = { rows: braille(bits), eye, tail, w, h }
|
||||
}
|
||||
const ts = Object.entries(out).map(([name, o]) => `/** ${name} Query, ${o.w}×${o.h} cells, facing right (generated by scripts/gen-query-art.mjs). */\nconst ${name.toUpperCase()} = [\n${o.rows.map((r) => ` ${JSON.stringify(r)},`).join('\n')}\n]\n`).join('\n')
|
||||
const parts = `const PARTS: Record<QuerySize, Parts> = {\n${Object.entries(out).map(([n, o]) => ` ${n}: { eye: [${o.eye}], tail: [${o.tail}] },`).join('\n')}\n}\n`
|
||||
writeFileSync(join(outDir, 'query-art.generated.txt'), `${ts}\n${parts}`)
|
||||
for (const [n, o] of Object.entries(out)) console.log(`--- ${n} ${o.w}x${o.h} eye ${o.eye} tail ${o.tail}\n${o.rows.join('\n')}`)
|
||||
@@ -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'],
|
||||
|
||||
@@ -72,6 +72,11 @@ export class SaverOverlay implements Overlay {
|
||||
features: s.version?.features ?? [],
|
||||
status: () => s.status,
|
||||
events: realtimeEvents,
|
||||
sessionTable: () => {
|
||||
// the session store qbit's table (storedSession)
|
||||
const t = Object.values(tables).find((x) => /^(stored_?)?sessions?$/i.test(x.name))
|
||||
return t && t.readPermission !== false ? t.name : undefined
|
||||
},
|
||||
favouriteTables: favourites,
|
||||
tableLabel: (name) => tables[name]?.label ?? name,
|
||||
intervalMs: () => this.model.intervalMs,
|
||||
|
||||
@@ -24,7 +24,7 @@ import { pad, trunc } from '../util/text'
|
||||
import { Canvas, type Style } from './canvas'
|
||||
import { FONT_HEIGHT, blockText, blockWidth, canDraw } from './font'
|
||||
import { sparkline, type TileState } from './model'
|
||||
import { queryFrame, queryPoseAt, querySize, type QueryPose } from './query-art'
|
||||
import { QUERY_COLOR, queryFrame, queryPoseAt, querySize, type QueryPose, type QuerySize } from './query-art'
|
||||
import { SAVER_STYLES, type SaverStyle, type SaverStyleSetting } from './settings'
|
||||
|
||||
export interface SaverTile {
|
||||
@@ -226,7 +226,7 @@ function drawPin(c: Canvas, input: SaverInput): void {
|
||||
}
|
||||
|
||||
function palette(t: ThemeTokens): string[] {
|
||||
return [t.acc, t.acc2, t.ok, t.warn, t.markFg, t.err]
|
||||
return [QUERY_COLOR, t.acc2, t.ok, t.warn, t.markFg, t.err]
|
||||
}
|
||||
|
||||
/** The block-letter logo under Query: the profile's name, with the server host beneath. */
|
||||
@@ -262,16 +262,18 @@ export interface Box {
|
||||
interface BounceSprite {
|
||||
w: number
|
||||
h: number
|
||||
size: 'large' | 'small'
|
||||
size: QuerySize
|
||||
logo: boolean
|
||||
}
|
||||
|
||||
/** Query with the name logo under him when there is room (large Query only when it all fits). */
|
||||
function bounceSprite(bodyH: number, width: number, logo: NameLogo): BounceSprite {
|
||||
const large = querySize('large')
|
||||
const medium = querySize('medium')
|
||||
const small = querySize('small')
|
||||
const extra = 2 + logo.rows.length
|
||||
if (bodyH >= large.height + extra && width >= Math.max(large.width, logo.w) + 4) return { w: Math.max(large.width, logo.w), h: large.height + extra, size: 'large', logo: true }
|
||||
if (bodyH >= medium.height + extra && width >= Math.max(medium.width, logo.w) + 4) return { w: Math.max(medium.width, logo.w), h: medium.height + extra, size: 'medium', logo: true }
|
||||
if (bodyH >= small.height + extra) return { w: Math.min(width, Math.max(small.width, logo.w)), h: small.height + extra, size: 'small', logo: true }
|
||||
return { w: small.width, h: small.height, size: 'small', logo: false }
|
||||
}
|
||||
@@ -301,7 +303,7 @@ export interface BounceLayout {
|
||||
/** The sprite is drawn (false in the panel half of the alternation). */
|
||||
showSprite: boolean
|
||||
panel?: Box
|
||||
size: 'large' | 'small'
|
||||
size: QuerySize
|
||||
showLogo: boolean
|
||||
dx: 1 | -1
|
||||
dy: 1 | -1
|
||||
@@ -354,7 +356,7 @@ export function bounceLayout(input: SaverInput): BounceLayout {
|
||||
return { ...base, sprite: box, showSprite: true }
|
||||
}
|
||||
|
||||
function drawQuery(c: Canvas, x: number, y: number, size: 'large' | 'small', pose: QueryPose, input: SaverInput, color: string): void {
|
||||
function drawQuery(c: Canvas, x: number, y: number, size: QuerySize, pose: QueryPose, input: SaverInput, color: string): void {
|
||||
c.block(x, y, queryFrame(size, pose, input.ascii), { fg: color, bold: true })
|
||||
}
|
||||
|
||||
@@ -420,13 +422,14 @@ function paintStarfield(c: Canvas, input: SaverInput): Box {
|
||||
c.put(sx, sy, chars[level]!, { fg: level >= 3 ? t.acc2 : level >= 2 ? t.fg : t.dim })
|
||||
}
|
||||
// Query drifts through left to right, bobbing
|
||||
const q = querySize('small')
|
||||
const gsize = guestSize(input.width, bodyH)
|
||||
const q = querySize(gsize)
|
||||
const step = Math.floor(input.now / FRAME_MILLIS)
|
||||
const span = input.width + q.width * 2
|
||||
const qx = (step % span) - q.width
|
||||
const qy = Math.round(1 + bodyH * 0.25 + Math.sin(input.now / 1500) * 2)
|
||||
const pose = queryPoseAt(input.now, { dx: 1, dy: Math.cos(input.now / 1500) > 0 ? 1 : -1 })
|
||||
drawQuery(c, qx, Math.max(1, qy + pose.dy), 'small', pose, input, t.acc2)
|
||||
drawQuery(c, qx, Math.max(1, qy + pose.dy), gsize, pose, input, QUERY_COLOR)
|
||||
// the tiles float in the middle
|
||||
const hud = hudSize(input, input.width - 4, bodyH - 2)
|
||||
const ox = Math.round(Math.sin(input.now / 4000) * 2)
|
||||
@@ -437,6 +440,11 @@ function paintStarfield(c: Canvas, input: SaverInput): Box {
|
||||
return { x: hx, y: hy, w: hud.w, h: hud.h }
|
||||
}
|
||||
|
||||
/** Query's size where he is a guest (not the sprite): medium when the screen is wide and tall enough for him, else small. */
|
||||
function guestSize(width: number, bodyH: number): QuerySize {
|
||||
return width >= 100 && bodyH >= 34 ? 'medium' : 'small'
|
||||
}
|
||||
|
||||
/** Whether the flip clock is mid-flip (the first 500 ms of a minute). */
|
||||
function flipping(now: number): boolean {
|
||||
return now % 60_000 < 500
|
||||
@@ -471,7 +479,8 @@ function paintFlip(c: Canvas, input: SaverInput): Box {
|
||||
const now = hhmm(input.now)
|
||||
const before = hhmm(input.now - (input.now % 60_000) - 1)
|
||||
const half = flipping(input.now) && input.now % 60_000 < 250
|
||||
const q = querySize('small')
|
||||
const qsize = guestSize(input.width, bodyH)
|
||||
const q = querySize(qsize)
|
||||
const columns = input.tiles.length > 1 && input.width >= 100 ? 2 : 1
|
||||
const wantRows = Math.ceil(input.tiles.length / columns)
|
||||
// double-size digits when the screen has room for them and the tiles
|
||||
@@ -488,7 +497,7 @@ function paintFlip(c: Canvas, input: SaverInput): Box {
|
||||
const date = trunc(new Date(input.now).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }), input.width - 4)
|
||||
const host = trunc(`${input.server} · ${input.profile}`, input.width - 4)
|
||||
// the tiles with Query beside them, bottom-aligned
|
||||
const colW = Math.max(0, Math.min(columns === 2 ? 43 : 56, Math.floor((input.width - 6 - q.width - 3 - (columns - 1) * 3) / columns)))
|
||||
const colW = Math.max(0, Math.min(columns === 2 ? 43 : 56, Math.floor((input.width - 6 - (queryBeside ? q.width + 3 : 0) - (columns - 1) * 3) / columns)))
|
||||
const tilesW = input.tiles.length ? columns * colW + (columns - 1) * 3 : 0
|
||||
const blockW = input.tiles.length ? (queryBeside ? tilesW + 3 + q.width : Math.max(tilesW, q.width)) : q.width
|
||||
const rowsCap = Math.max(1, bodyH - (cardH + 4) - (queryBeside ? 0 : q.height + 1))
|
||||
@@ -537,12 +546,12 @@ function paintFlip(c: Canvas, input: SaverInput): Box {
|
||||
if (input.tiles.length) tileBlock(c, bx, y, input.tiles, columns, colW, tileRows, input)
|
||||
// Query peeks up beside the tiles: ears and eyes, all of him when he hops
|
||||
const pose = queryPoseAt(input.now, { dx: -1 })
|
||||
const shown = Math.min(blockH, pose.action ? q.height : 3)
|
||||
const frame = queryFrame('small', pose, input.ascii).slice(0, shown)
|
||||
const shown = Math.min(blockH, pose.action ? q.height : qsize === 'medium' ? 5 : 3)
|
||||
const frame = queryFrame(qsize, pose, input.ascii).slice(0, shown)
|
||||
const qx = input.tiles.length && queryBeside ? bx + tilesW + 3 : bx + Math.floor((blockW - q.width) / 2)
|
||||
const qy = y + blockH - shown
|
||||
c.fill(qx, qy, q.width, shown, {})
|
||||
c.block(qx, qy, frame, { fg: t.acc2, bold: true })
|
||||
c.block(qx, qy, frame, { fg: QUERY_COLOR, bold: true })
|
||||
return { x: gx, y: gy, w: groupW, h: groupH }
|
||||
}
|
||||
|
||||
@@ -562,7 +571,8 @@ function paintBig(c: Canvas, input: SaverInput): Box {
|
||||
.map((x, i) => ({ x, i }))
|
||||
.sort((a, b) => (b.x.state.value!.interest ?? 0) - (a.x.state.value!.interest ?? 0) || a.i - b.i)
|
||||
.map((e) => e.x)
|
||||
const q = querySize('small')
|
||||
const qsize = guestSize(input.width, bodyH)
|
||||
const q = querySize(qsize)
|
||||
const rotation = Math.floor(input.now / 8000)
|
||||
// compact: the stack is at most about 90 columns wide, however wide the screen
|
||||
const avail = Math.min(input.width - 4, 90)
|
||||
@@ -614,7 +624,7 @@ function paintBig(c: Canvas, input: SaverInput): Box {
|
||||
const color = tile ? (levelColor(t, tile) === t.fg ? t.acc2 : levelColor(t, tile)) : t.acc2
|
||||
const heroX = centred(heroW)
|
||||
if (!tile) {
|
||||
drawQuery(c, heroX, y, 'small', queryPoseAt(input.now), input, t.acc2)
|
||||
drawQuery(c, heroX, y, qsize, queryPoseAt(input.now), input, QUERY_COLOR)
|
||||
} else {
|
||||
const pose: QueryPose = { ...pose0, ...(asleep ? { blink: true, wag: false } : {}) }
|
||||
const numberAt = (x: number, ny: number) => {
|
||||
@@ -622,12 +632,12 @@ function paintBig(c: Canvas, input: SaverInput): Box {
|
||||
else c.put(x, ny, big, { fg: color, bold: true })
|
||||
}
|
||||
if (beside) {
|
||||
drawQuery(c, heroX, y, 'small', pose, input, t.acc2)
|
||||
drawQuery(c, heroX, y, qsize, pose, input, QUERY_COLOR)
|
||||
if (asleep) c.put(heroX + q.width - 1, y - 1, input.now % 2000 < 1000 ? 'z' : 'zZ', { fg: t.dim })
|
||||
numberAt(heroX + q.width + 3, y + Math.floor((heroH - numH) / 2))
|
||||
} else {
|
||||
numberAt(heroX + Math.floor((heroW - numW) / 2), y)
|
||||
drawQuery(c, heroX + Math.floor((heroW - q.width) / 2), y + numH + 1, 'small', pose, input, t.acc2)
|
||||
drawQuery(c, heroX + Math.floor((heroW - q.width) / 2), y + numH + 1, qsize, pose, input, QUERY_COLOR)
|
||||
if (asleep) c.put(heroX + Math.floor((heroW + q.width) / 2) - 1, y + numH, input.now % 2000 < 1000 ? 'z' : 'zZ', { fg: t.dim })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,57 +7,73 @@
|
||||
* Query, the qrun.io mascot, for the screen saver.
|
||||
*
|
||||
* Source: the sitting-dog line drawing on Query's page, https://www.qrun.io/query/ (the inline SVG,
|
||||
* viewBox 0 0 331.41 348.44, that the page's hero bounces). The large art is that drawing traced
|
||||
* into braille dots (2×4 dots per cell, 66×76 dots); the small art is hand-drawn on a 24×24 dot
|
||||
* grid after it. Each braille cell is a bitmap, so the frames are made from the art itself: blink
|
||||
* (the eye's dots cleared), tail wag (the tail's dots moved), mirrored to face the way he drifts,
|
||||
* eyes glancing up or down. The ASCII glyph set gets a plain line-art version of the same bitmap
|
||||
* (hand-drawn for the small size).
|
||||
* viewBox 0 0 331.41 348.44, in one colour: indigo-400, #818cf8). All three sizes are that drawing
|
||||
* rasterised and thresholded into braille dots (2×4 dots per cell) by scripts/gen-query-art.mjs, so
|
||||
* they share its proportions: large 42×22 cells, medium 25×13, small 17×9. Each braille cell is a
|
||||
* bitmap, so the frames are made from the art itself: blink (the eye's dots cleared), tail wag (the
|
||||
* tail's dots moved), mirrored to face the way he drifts, eyes glancing up or down. The ASCII glyph
|
||||
* set gets a plain line-art version of the same bitmap.
|
||||
*/
|
||||
|
||||
export type QuerySize = 'small' | 'large'
|
||||
/** The drawing's colour (the logo's own fill). */
|
||||
export const QUERY_COLOR = '#818cf8'
|
||||
|
||||
/** Large Query, 33×19 cells, facing right. */
|
||||
export type QuerySize = 'small' | 'medium' | 'large'
|
||||
|
||||
/** large Query, 42×22 cells, facing right (generated by scripts/gen-query-art.mjs). */
|
||||
const LARGE = [
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⡴⠞⠛⠛⠛⠛⠳⢦⣄⠀⠀⠀⠀⠀",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⡾⠋⠀⠀⠀⠀⠀⠀⢀⡀⠹⣇⠀⠀⠀⠀",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⡟⠀⠀⢠⡆⠀⠀⠀⠀⠸⠗⠀⠻⣤⣤⣤⣀",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠁⠀⠀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⠀⠈⢿⡀⠀⠀⠀⠀⠀⠀⠀⢀⣀⡼⠋",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣧⠀⠀⠀⠀⢸⡇⠀⠀⠀⠘⣿⠛⠛⠋⠉⠀⠀",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⢷⣤⣀⣤⡾⠁⠀⠀⠀⠀⣿⠀⠀⠀⠀⠀⠀",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⡟⠁⠉⠀⠀⠀⠀⠀⠀⠀⣿⡀⠀⠀⠀⠀⠀",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⡾⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⠀⠀⠀",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⠞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠀⠀⠀⠀⠀",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⡆⠀⠀⠀⠀",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⢀⡾⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠁⠀⠀⠀⠀",
|
||||
"⠀⣀⣀⠀⠀⠀⢀⡾⠁⠀⠀⠀⠀⣤⡴⠶⠶⠶⠶⣤⡀⠀⠀⠀⠀⠀⣰⠏⠀⠀⠀⠀⠀",
|
||||
"⠀⠙⣿⣷⠀⠀⣼⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⣆⠀⠀⠀⢰⡏⠀⠀⠀⠀⠀⠀",
|
||||
"⠀⠀⣿⣿⠃⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⡏⠀⠀⠀⠈⣷⠀⠀⠀⠀⠀⠀",
|
||||
"⠀⢠⡿⣿⠀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⠟⠀⠀⡀⠀⠀⣿⡀⠀⠀⠀⠀⠀",
|
||||
"⠀⣼⢻⡇⠀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⠟⠁⠀⠀⠀⢿⡀⠀⢸⡇⠀⠀⠀⠀⠀",
|
||||
"⠀⣿⡈⠷⠶⠟⢁⠀⠀⠀⠀⠀⠀⠀⠀⠘⠛⠛⠛⠛⠛⠳⣦⡸⣧⠀⠈⠛⠳⣦⡀⠀⠀",
|
||||
"⠀⠈⠳⣦⣤⡴⠛⢧⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣼⡇⠹⣦⣤⣤⣤⣬⡗⠀⠀",
|
||||
" ",
|
||||
" ⣀⣀⣄⣀⣀ ",
|
||||
" ⢀⡴⠋⠁ ⠈⠙⢢⡀ ",
|
||||
" ⣰⠋ ⢠⣦ ⣧ ",
|
||||
" ⢰⠇ ⢸⠁ ⠉ ⠈⠓⠒⠲⡆ ",
|
||||
" ⡟ ⢸⡄ ⢠⠇ ",
|
||||
" ⡇ ⢷ ⢀⣀⣀⣤⡤⠴⠋ ",
|
||||
" ⢻⡀ ⣼ ⢸ ",
|
||||
" ⣹⠓⠒⠚⠁ ⢸ ",
|
||||
" ⢀⡴⠃ ⠸⡆ ",
|
||||
" ⢀⡴⠋ ⢧ ",
|
||||
" ⢀⡴⠉ ⢸ ",
|
||||
" ⣰⠋ ⢸⠃ ",
|
||||
" ⡼⠁ ⢀⣀⣀⣀⡀ ⡼ ",
|
||||
" ⠰⣶⣄ ⢰⠃ ⠘⠉⠉ ⠈⠉⠓⣆ ⡼⠁ ",
|
||||
" ⢸⢹ ⡟ ⢸⠆ ⣷ ",
|
||||
" ⣼⣸ ⢠⡇ ⢀⡞ ⢸ ",
|
||||
" ⢠⡇⡏ ⢸⡇ ⢀⡴⠋ ⢰⡀ ⠸⡇ ",
|
||||
" ⢸ ⣇⣀⡼⠁ ⠠⣴⣯⣤⣤⣤⣄ ⣧ ⢧⣤⡀ ",
|
||||
" ⠘⢧⡀⠁⣀⢶⡀ ⠈⢳⠸⣄ ⠙⡆ ",
|
||||
" ⠉⠉⠁ ⠉⠉⠉⠉⠙⠛⠛⠛⠛⠛⠛⠛⠛⠋⠉ ⠈⠉⠉⠉⠉⠁ ",
|
||||
" ",
|
||||
]
|
||||
|
||||
/** Small Query, 12×6 cells, facing right (hand-drawn on 24×24 dots). */
|
||||
/** medium Query, 25×13 cells, facing right (generated by scripts/gen-query-art.mjs). */
|
||||
const MEDIUM = [
|
||||
" ",
|
||||
" ⢀⠔⠋⠉⠉⠑⢆ ",
|
||||
" ⢠⠃ ⡆ ⠛⠘⠤⠤⡀ ",
|
||||
" ⣾ ⢧ ⢀⣀⡼⠁ ",
|
||||
" ⠹⣄⣀⡼ ⢹⠉ ",
|
||||
" ⡰⠃⠁ ⢸⡀ ",
|
||||
" ⢀⡴⠊ ⡇ ",
|
||||
" ⢠⠎ ⡇ ",
|
||||
" ⣦⡀⢠⠇ ⠐⠚⠙⠒⢦ ⣰⠁ ",
|
||||
" ⣸⡇⢸ ⡸ ⢹ ",
|
||||
" ⣿⠁⣸ ⣠⠞⠁⠐⡆⢸⡄ ",
|
||||
" ⢧⣉⡵⣄⣀⣀⣀⣈⣉⣉⣉⣱⢱⣀⣉⣳ ",
|
||||
" ",
|
||||
]
|
||||
|
||||
/** small Query, 17×9 cells, facing right (generated by scripts/gen-query-art.mjs). */
|
||||
const SMALL = [
|
||||
'⠀⠀⠀⠀⢀⠤⠒⠒⠢⢄⠀⠀',
|
||||
'⠀⠀⠀⠀⡇⢸⠀⠁⣀⣀⡩⠕',
|
||||
'⠀⠀⠀⠀⡨⠃⠀⠀⢇⠀⠀⠀',
|
||||
'⠀⡀⢠⠊⢀⠤⢄⠀⢸⠀⠀⠀',
|
||||
'⠀⢇⡇⠀⠀⠀⡠⠃⢸⠀⠀⠀',
|
||||
'⠀⠀⠣⠤⠤⠼⠶⠶⠮⠵⠀⠀',
|
||||
]
|
||||
|
||||
/** Small Query for the ASCII glyph set, 12×6 cells, facing right. */
|
||||
const SMALL_ASCII = [
|
||||
' .--. ',
|
||||
' /( o`-.',
|
||||
" \\_) .-'",
|
||||
' _ / | ',
|
||||
' ( | _/ | ',
|
||||
" `-'--'-' ",
|
||||
" ⣀⣤⣀⡀ ",
|
||||
" ⡞⢡ ⠰⠹⣀⡀ ",
|
||||
" ⢸ ⢸⡀⢀⣀⠤⠃ ",
|
||||
" ⢈⡗⠚ ⠘⡇ ",
|
||||
" ⢀⡴⠋ ⣇ ",
|
||||
" ⢀⡀⢀⠎ ⡠⠤⣀ ⡏ ",
|
||||
" ⡷⡼ ⢨⠃⠸⡄ ",
|
||||
" ⢸⢧⢇ ⠴⠥⢜⡄⠧⡀ ",
|
||||
" ⠉⠉⠓⠒⠒⠒⠒⠊⠙⠒⠃ ",
|
||||
]
|
||||
|
||||
interface Parts {
|
||||
@@ -67,8 +83,9 @@ interface Parts {
|
||||
}
|
||||
|
||||
const PARTS: Record<QuerySize, Parts> = {
|
||||
large: { eye: [51, 7, 53, 10], tail: [2, 51, 8, 68] },
|
||||
small: { eye: [14, 4, 14, 4], tail: [2, 15, 3, 18] },
|
||||
large: { eye: [57,13,59,16], tail: [0,55,17,87] },
|
||||
medium: { eye: [34,8,35,9], tail: [0,32,10,51] },
|
||||
small: { eye: [23,5,23,6], tail: [0,22,7,35] },
|
||||
}
|
||||
|
||||
// braille dot bits by (column, row) inside a cell
|
||||
@@ -158,11 +175,11 @@ export function mirrorAscii(row: string): string {
|
||||
return [...row].reverse().map((c) => MIRROR[c] ?? c).join('')
|
||||
}
|
||||
|
||||
const BASE: Record<QuerySize, Bitmap> = { large: decodeBraille(LARGE), small: decodeBraille(SMALL) }
|
||||
const BASE: Record<QuerySize, Bitmap> = { large: decodeBraille(LARGE), medium: decodeBraille(MEDIUM), small: decodeBraille(SMALL) }
|
||||
|
||||
/** Width and height in cells of Query at a size. */
|
||||
export function querySize(size: QuerySize): { width: number; height: number } {
|
||||
return size === 'large' ? { width: 33, height: 19 } : { width: 12, height: 6 }
|
||||
return size === 'large' ? { width: 42, height: 22 } : size === 'medium' ? { width: 25, height: 13 } : { width: 17, height: 9 }
|
||||
}
|
||||
|
||||
export interface QueryPose {
|
||||
@@ -178,12 +195,6 @@ export interface QueryPose {
|
||||
|
||||
/** One frame of Query: rows of text (braille, or ASCII line art), all the same width. */
|
||||
export function queryFrame(size: QuerySize, pose: QueryPose = {}, ascii = false): string[] {
|
||||
if (ascii && size === 'small') {
|
||||
let rows = SMALL_ASCII.map((r) => (pose.blink ? r.replace('o', '-') : r))
|
||||
if (pose.wag) rows = rows.map((r, i) => (i === 3 ? ` _${r.slice(2)}` : i === 4 ? `( ${r.slice(3)}` : r))
|
||||
if (pose.facing === 'left') rows = rows.map(mirrorAscii)
|
||||
return rows
|
||||
}
|
||||
const parts = PARTS[size]
|
||||
const bits = BASE[size].map((r) => [...r])
|
||||
const [ex0, ey0, ex1, ey1] = parts.eye
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*
|
||||
* | tile | needs | reads |
|
||||
* |---------|----------------------|----------------------------------------------------------|
|
||||
* | people | collaboration | GET presence per table, a few tables per refresh |
|
||||
* | people | session store, or collaboration | the session table (sessions active in the last 10 minutes), else GET presence per table, a few tables per refresh |
|
||||
* | jobs | durableJobs feature | GET /jobs?state=recent (scope all, else mine) |
|
||||
* | health | health | GET /health/summary |
|
||||
* | db | db-performance | GET overview + activity |
|
||||
@@ -34,6 +34,8 @@ export interface SaverContext {
|
||||
events: EventRate
|
||||
/** Tables to sample for presence, most likely first (favourites, recents, then the rest). */
|
||||
presenceTables(): string[]
|
||||
/** The server's session table (the session store qbit's), when it has one the user can read. */
|
||||
sessionTable?(): string | undefined
|
||||
/** The profile's favourite tables (count tiles). */
|
||||
favouriteTables: string[]
|
||||
tableLabel(name: string): string
|
||||
@@ -51,22 +53,75 @@ interface Viewer {
|
||||
mode?: string
|
||||
}
|
||||
|
||||
/** Everyone seen in presence lately across the sampled tables (this terminal is not: it left). */
|
||||
/** Sessions that count as online: active in the last 10 minutes. */
|
||||
export const SESSION_ACTIVE_MILLIS = 10 * 60_000
|
||||
|
||||
function userLabel(userId: string): string {
|
||||
return userId.includes(':') ? userId.slice(userId.indexOf(':') + 1) : userId
|
||||
}
|
||||
|
||||
/**
|
||||
* Who is online: signed-in sessions from the session store's table when the server has one and it
|
||||
* holds sessions active in the last 10 minutes (own sessions count: "2 sessions · 1 user (you ×2)"),
|
||||
* else this terminal plus everyone seen in collaboration presence across a few tables per refresh
|
||||
* (the saver keeps its lease, so the signed-in viewer is always one of them). Never "nobody around"
|
||||
* while the viewer is signed in.
|
||||
*/
|
||||
export function presenceSource(ctx: SaverContext): TileSource {
|
||||
const seen = new Map<string, { name: string; table: string; mode?: string; at: number }>()
|
||||
const skip = new Set<string>()
|
||||
let cursor = 0
|
||||
let sessionsGone = false
|
||||
const now = ctx.now ?? Date.now
|
||||
const names = new Map<string, string>()
|
||||
|
||||
async function sessions(): Promise<TileValue | undefined> {
|
||||
const table = ctx.sessionTable?.()
|
||||
if (!table || sessionsGone) return undefined
|
||||
const cutoff = new Date(now() - SESSION_ACTIVE_MILLIS).toISOString()
|
||||
let rows: Array<{ values: Record<string, unknown> }>
|
||||
try {
|
||||
rows = await ctx.client.query(table, {
|
||||
criteria: [{ fieldName: 'modifyDate', operator: 'GREATER_THAN', values: [cutoff] }, { fieldName: 'expiresAt', operator: 'GREATER_THAN', values: [new Date(now()).toISOString()] }],
|
||||
booleanOperator: 'AND', limit: 500,
|
||||
})
|
||||
} catch (error) {
|
||||
const status = (error as { status?: number }).status
|
||||
if (status === 403 || status === 404) sessionsGone = true
|
||||
return undefined
|
||||
}
|
||||
if (!rows.length) return undefined
|
||||
const self = ctx.status()
|
||||
const perUser = new Map<string, number>()
|
||||
for (const r of rows) {
|
||||
const id = String(r.values.userId ?? '')
|
||||
if (id) perUser.set(id, (perUser.get(id) ?? 0) + 1)
|
||||
}
|
||||
const isSelf = (id: string) => id === self.userId || (Boolean(self.user) && (names.get(id) === self.user || userLabel(id).toLowerCase() === self.user.toLowerCase()))
|
||||
const list = [...perUser.entries()].sort((a, b) => Number(isSelf(b[0])) - Number(isSelf(a[0])) || a[0].localeCompare(b[0]))
|
||||
const who = list.map(([id, n]) => `${isSelf(id) ? 'you' : names.get(id) ?? userLabel(id)}${n > 1 ? ` ×${n}` : ''}`)
|
||||
const total = rows.length
|
||||
const users = perUser.size
|
||||
return {
|
||||
value: `${total} online`,
|
||||
detail: `${total} session${total === 1 ? '' : 's'} · ${users} user${users === 1 ? '' : 's'} (${who.join(', ')})`,
|
||||
big: String(total), interest: 50 + total, level: 'ok',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'people', label: 'Online', needs: 'collaboration',
|
||||
present: () => ctx.has('collaboration'),
|
||||
id: 'people', label: 'Online', needs: 'collaboration or the session store',
|
||||
present: () => ctx.has('collaboration') || Boolean(ctx.sessionTable?.()),
|
||||
async load(): Promise<TileValue> {
|
||||
const all = ctx.presenceTables().filter((t) => !skip.has(t))
|
||||
if (!all.length) throw new SourceAbsent('no tables to watch')
|
||||
const bySession = await sessions()
|
||||
if (bySession) return bySession
|
||||
const self = ctx.status()
|
||||
const signedIn = Boolean(self.userId || self.user)
|
||||
const all = ctx.has('collaboration') ? ctx.presenceTables().filter((t) => !skip.has(t)) : []
|
||||
if (!all.length && !signedIn) throw new SourceAbsent('no tables to watch')
|
||||
const batch: string[] = []
|
||||
for (let i = 0; i < Math.min(PRESENCE_TABLES_PER_REFRESH, all.length); i++) batch.push(all[(cursor + i) % all.length]!)
|
||||
cursor = (cursor + batch.length) % Math.max(1, all.length)
|
||||
const self = ctx.status()
|
||||
let failures = 0
|
||||
await Promise.all(batch.map(async (table) => {
|
||||
try {
|
||||
@@ -74,10 +129,11 @@ export function presenceSource(ctx: SaverContext): TileSource {
|
||||
const t = now()
|
||||
for (const [id, v] of seen) if (v.table === table) seen.delete(id)
|
||||
for (const v of snap.viewers ?? []) {
|
||||
// this terminal left presence when the saver came up: the signed-in user seen here is
|
||||
// another session of theirs (a browser tab, another terminal)
|
||||
const you = v.userId === self.userId || (Boolean(self.user) && v.displayName === self.user)
|
||||
seen.set(v.userId, { name: you ? 'you elsewhere' : v.displayName || v.userId, table, mode: v.mode, at: t })
|
||||
// this terminal is you (it keeps its lease while the saver shows): not a second person
|
||||
if (you) continue
|
||||
names.set(v.userId, v.displayName || v.userId)
|
||||
seen.set(v.userId, { name: v.displayName || v.userId, table, mode: v.mode, at: t })
|
||||
}
|
||||
} catch (error) {
|
||||
const status = (error as { status?: number }).status
|
||||
@@ -85,16 +141,16 @@ export function presenceSource(ctx: SaverContext): TileSource {
|
||||
else failures++
|
||||
}
|
||||
}))
|
||||
if (failures === batch.length) throw new Error('presence did not answer')
|
||||
if (batch.length && failures === batch.length && !signedIn) throw new Error('presence did not answer')
|
||||
// a person not seen again within one sweep of the list (plus a lease) has left
|
||||
const ttl = Math.ceil(all.length / PRESENCE_TABLES_PER_REFRESH) * ctx.intervalMs() + 45_000
|
||||
const ttl = Math.ceil(Math.max(1, all.length) / PRESENCE_TABLES_PER_REFRESH) * ctx.intervalMs() + 45_000
|
||||
const t = now()
|
||||
for (const [id, v] of seen) if (t - v.at > ttl) seen.delete(id)
|
||||
const people = [...seen.values()].sort((a, b) => a.name.localeCompare(b.name))
|
||||
const detail = people.length
|
||||
? people.sort((a, b) => Number(a.name === 'you elsewhere') - Number(b.name === 'you elsewhere')).map((p) => `${p.name}${p.mode === 'EDITING' ? ' editing' : ''} · ${ctx.tableLabel(p.table)}`).join(', ')
|
||||
: 'nobody around'
|
||||
return { value: String(people.length), detail, big: String(people.length), interest: people.length ? 50 + people.length : 5 }
|
||||
const others = [...seen.values()].sort((a, b) => a.name.localeCompare(b.name))
|
||||
const total = others.length + (signedIn ? 1 : 0)
|
||||
const parts = [...(signedIn ? ['you'] : []), ...others.map((p) => `${p.name}${p.mode === 'EDITING' ? ' editing' : ''} · ${ctx.tableLabel(p.table)}`)]
|
||||
const detail = parts.length ? `${total} online · ${parts.join(', ')}` : 'nobody around'
|
||||
return { value: String(total), detail, big: String(total), interest: total ? 50 + total : 5 }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+26
-10
@@ -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
|
||||
@@ -198,10 +201,15 @@ export class AppStore {
|
||||
this.bump()
|
||||
}
|
||||
|
||||
/** Tells the presence source which scope is on top (called on every repaint; cheap when unchanged). */
|
||||
/**
|
||||
* Tells the presence source which scope is on top (called on every repaint; cheap when unchanged).
|
||||
* The screen saver or lock keeps the lease on the scope that was on top (the extension has no idle
|
||||
* state: the user stays as a viewer rather than vanishing from the others' screens, as a person
|
||||
* who stepped away from a record) and waking re-syncs.
|
||||
*/
|
||||
private syncPresence(): void {
|
||||
const scope = this.lock ? undefined : this.screen?.presenceScope?.()
|
||||
presenceSource().enter?.(scope)
|
||||
if (this.lock) return
|
||||
presenceSource().enter?.(this.screen?.presenceScope?.())
|
||||
}
|
||||
|
||||
/** Replaces the top screen (a form that saved becomes the record). */
|
||||
@@ -253,6 +261,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 +274,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 +409,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 +659,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()
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
||||
import { removePin, setPin } from '@qrun-io/qqq-core'
|
||||
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
|
||||
import { SaverOverlay } from '../src/overlays/saver'
|
||||
import { setPresenceSource } from '../src/presence'
|
||||
import { FONT_HEIGHT, blockText, blockWidth, canDraw } from '../src/saver/font'
|
||||
import { ALTERNATE_MILLIS, AUTO_STYLE_MILLIS, FRAME_MILLIS, SHIFT_MILLIS, bounce, bounceLayout, isDimmed, logoName, nextFrameDelay, paintSaver, saverFrame, styleAt, type SaverInput, type SaverTile } from '../src/saver/layout'
|
||||
import { EventRate, MAX_BACKOFF_MS, TileModel, isAbsent, sparkline, type TileSource, type TileValue } from '../src/saver/model'
|
||||
@@ -126,17 +127,53 @@ describe('sources', () => {
|
||||
const s = presenceSource(ctx({ now: () => t, data: (_id, input) => ({ viewers: viewers[(input as { query: { table: string } }).query.table] ?? [] }) }))
|
||||
const v = await s.load()
|
||||
expect(v.value).toBe('3')
|
||||
expect(v.detail).toMatch(/^Bob · PERSON, Carol editing · ORDER, you elsewhere · PERSON$/)
|
||||
expect(v.detail).toBe('3 online · you, Bob · PERSON, Carol editing · ORDER')
|
||||
viewers.person = []
|
||||
viewers.order = []
|
||||
t = 120_000
|
||||
expect((await s.load()).value).toBe('0')
|
||||
expect((await s.load()).value).toBe('1')
|
||||
})
|
||||
|
||||
test('presence on a table that refuses it is skipped; the source hides when nothing is left', async () => {
|
||||
const s = presenceSource(ctx({ data: () => { throw new UiApiError(404, {}) } }))
|
||||
await s.load()
|
||||
await expect(s.load()).rejects.toThrow('no tables')
|
||||
const refuse = () => { throw new UiApiError(404, {}) }
|
||||
// signed out: nothing to show once every table refuses
|
||||
const out = presenceSource(ctx({ status: () => ({ apiVersion: '1', pingMs: 1, stream: 'connected', lastEvent: '', user: '' }), data: refuse }))
|
||||
await out.load()
|
||||
await expect(out.load()).rejects.toThrow('no tables')
|
||||
// signed in: never "nobody around", this terminal is online
|
||||
const me = presenceSource(ctx({ data: refuse }))
|
||||
await me.load()
|
||||
const v = await me.load()
|
||||
expect(v.value).toBe('1')
|
||||
expect(v.detail).toBe('1 online · you')
|
||||
})
|
||||
|
||||
test('online by session: sessions active in the last 10 minutes, own sessions counted, others by user', async () => {
|
||||
const queries: unknown[] = []
|
||||
const rows = [{ userId: 'core:me' }, { userId: 'core:me' }, { userId: 'core:bob' }]
|
||||
const client = { query: async (_t: string, f: unknown) => { queries.push(f); return rows.map((values) => ({ values })) }, data: async () => ({}), count: async () => 0 }
|
||||
const mk = (over: Partial<SaverContext> & { data?: (id: string, input: unknown) => unknown } = {}) => ctx({ client: client as never, sessionTable: () => 'storedSession', now: () => 1_000_000_000_000, ...over })
|
||||
const v = await presenceSource(mk()).load()
|
||||
expect(v.value).toBe('3 online')
|
||||
expect(v.detail).toBe('3 sessions · 2 users (you ×2, bob)')
|
||||
expect(v.big).toBe('3')
|
||||
const f = queries[0] as { criteria: Array<{ fieldName: string; operator: string; values: string[] }> }
|
||||
expect(f.criteria[0]).toMatchObject({ fieldName: 'modifyDate', operator: 'GREATER_THAN', values: [new Date(1_000_000_000_000 - 600_000).toISOString()] })
|
||||
// the owner's case: the same user twice
|
||||
rows.splice(2, 1)
|
||||
expect((await presenceSource(mk()).load()).detail).toBe('2 sessions · 1 user (you ×2)')
|
||||
// an empty session table (a mock-auth server stores none) falls back to presence and still counts you
|
||||
rows.length = 0
|
||||
const fb = await presenceSource(mk({ data: () => ({ viewers: [] }) })).load()
|
||||
expect(fb.value).toBe('1')
|
||||
expect(fb.detail).toBe('1 online · you')
|
||||
// no session table, no collaboration: the tile hides
|
||||
expect(presenceSource(ctx({ has: () => false })).present()).toBe(false)
|
||||
expect(presenceSource(ctx({ has: () => false, sessionTable: () => 'storedSession' })).present()).toBe(true)
|
||||
// a session table the user may not read is skipped for good
|
||||
const denied = { ...client, query: async () => { throw new UiApiError(403, {}) } }
|
||||
const d = presenceSource(mk({ client: denied as never, data: () => ({ viewers: [] }) }))
|
||||
expect((await d.load()).value).toBe('1')
|
||||
})
|
||||
|
||||
test('jobs: counts by status, falls back to my jobs on 403', async () => {
|
||||
@@ -170,7 +207,7 @@ describe('sources', () => {
|
||||
|
||||
describe('Query', () => {
|
||||
test('both sizes have their documented size; braille round-trips; mirrored and blinking frames differ', () => {
|
||||
for (const size of ['small', 'large'] as const) {
|
||||
for (const size of ['small', 'medium', 'large'] as const) {
|
||||
const { width, height } = querySize(size)
|
||||
const frame = queryFrame(size)
|
||||
expect(frame.length).toBe(height)
|
||||
@@ -187,6 +224,49 @@ describe('Query', () => {
|
||||
expect(mirrorAscii("(/`")).toBe("'\\)")
|
||||
})
|
||||
|
||||
test('every size keeps the logo\'s silhouette: floppy ear left of the head and snout, tail curled at the low left, sitting body, front legs and the ground line at the bottom', () => {
|
||||
expect(querySize('large')).toEqual({ width: 42, height: 22 })
|
||||
expect(querySize('medium')).toEqual({ width: 25, height: 13 })
|
||||
expect(querySize('small')).toEqual({ width: 17, height: 9 })
|
||||
for (const size of ['large', 'medium', 'small'] as const) {
|
||||
const rows = queryFrame(size)
|
||||
const bits = decodeBraille(rows)
|
||||
const H = bits.length
|
||||
const W = bits[0]!.length
|
||||
const dots = (y0: number, y1: number, x0 = 0, x1 = W) => {
|
||||
const out: Array<[number, number]> = []
|
||||
for (let y = Math.floor(y0 * H); y < Math.ceil(y1 * H); y++) for (let x = Math.floor(x0 * W); x < Math.ceil(x1 * W); x++) if (bits[y]?.[x]) out.push([x, y])
|
||||
return out
|
||||
}
|
||||
const minX = (d: Array<[number, number]>) => Math.min(...d.map((p) => p[0]))
|
||||
const maxX = (d: Array<[number, number]>) => Math.max(...d.map((p) => p[0]))
|
||||
// the picture is the logo's: dots across the whole height and most of the width, nothing past the cells
|
||||
expect(dots(0, 1).length).toBeGreaterThan(H)
|
||||
expect(rows.length).toBe(querySize(size).height)
|
||||
// head and ear: the top third; the ear (its left edge) is well left of the snout (the right edge), the snout is the rightmost thing up there
|
||||
const head = dots(0, 0.34)
|
||||
expect(head.length).toBeGreaterThan(5)
|
||||
expect(maxX(head)).toBeGreaterThan(W * 0.7)
|
||||
expect(minX(head)).toBeLessThan(W * 0.55)
|
||||
expect(maxX(head) - minX(head)).toBeGreaterThan(W * 0.3)
|
||||
// the top of the head sits above the middle of the back, right of the tail
|
||||
expect(minX(head)).toBeGreaterThan(W * 0.2)
|
||||
// the tail: ink in the low-left corner, left of where the body's back reaches the ground
|
||||
const tail = dots(0.6, 1, 0, 0.22)
|
||||
expect(tail.length).toBeGreaterThan(4)
|
||||
// the sitting body: the chest edge at the right in the middle rows and the haunch at the left
|
||||
const middle = dots(0.4, 0.75)
|
||||
expect(maxX(middle)).toBeGreaterThan(W * 0.6)
|
||||
expect(minX(middle)).toBeLessThan(W * 0.5)
|
||||
// front legs and the ground line: the bottom row of cells spans most of the width
|
||||
const ground = dots(0.84, 1)
|
||||
expect(maxX(ground) - minX(ground)).toBeGreaterThan(W * 0.6)
|
||||
expect(maxX(ground)).toBeGreaterThan(W * 0.7)
|
||||
// the eye is inside the head, right of the ear's left edge
|
||||
expect(queryFrame(size, { blink: true })).not.toEqual(rows)
|
||||
}
|
||||
})
|
||||
|
||||
test('poses: blinks, acts every ten seconds, faces the way he drifts', () => {
|
||||
expect(queryPoseAt(0).blink).toBe(true)
|
||||
expect(queryPoseAt(1000).blink).toBe(false)
|
||||
@@ -520,7 +600,7 @@ describe('burn-in guard', () => {
|
||||
const cy = (minY + maxY) / 2
|
||||
for (const r of spans) {
|
||||
expect(Math.abs((r.from + r.to) / 2 - cx)).toBeLessThanOrEqual(width * 0.4)
|
||||
expect(Math.abs(r.y - cy)).toBeLessThanOrEqual(height * 0.4 + 1)
|
||||
expect(Math.abs(r.y - cy)).toBeLessThanOrEqual(height * 0.4 + 2)
|
||||
}
|
||||
{
|
||||
// the group as a whole stays near the middle of the screen, fully on it
|
||||
@@ -688,6 +768,31 @@ describe('in the TUI', () => {
|
||||
await command(h, 'saver dim 10')
|
||||
})
|
||||
|
||||
test('the saver keeps the presence lease of the screen under it (others see an idle viewer, not a gone one); waking resyncs', async () => {
|
||||
const calls: Array<string | undefined> = []
|
||||
const none = { count: 0, people: [] }
|
||||
setPresenceSource({ get: () => none, subscribe: () => () => undefined, enter: (scope) => { calls.push(scope) } })
|
||||
try {
|
||||
await command(h, 'table query person')
|
||||
await waitFor(h, (f) => f.includes('30 records'))
|
||||
expect(calls.at(-1)).toBe('table:person')
|
||||
calls.length = 0
|
||||
await command(h, 'saver flip')
|
||||
await waitFor(h, (f) => f.includes('preview: flip'))
|
||||
// never left (the same scope may be announced again; the source ignores a repeat)
|
||||
expect(calls.every((c) => c === 'table:person')).toBe(true)
|
||||
await press(h, () => h.setup.mockInput.typeText('x'))
|
||||
expect(h.store.lock).toBeUndefined()
|
||||
h.store.bump()
|
||||
await waitFor(h, (f) => f.includes('30 records'))
|
||||
expect(calls.includes(undefined)).toBe(false)
|
||||
expect(calls.every((c) => c === 'table:person')).toBe(true)
|
||||
} finally {
|
||||
setPresenceSource(undefined)
|
||||
h.store.back()
|
||||
}
|
||||
})
|
||||
|
||||
test('F9 → Settings lists the saver style, refresh and preview', () => {
|
||||
const labels = (h.store.commands() as unknown as { entries(f: string): Array<{ label: string }> }).entries('').map((e) => e.label)
|
||||
expect(labels.some((l) => l.startsWith('Screen saver style:'))).toBe(true)
|
||||
|
||||
Reference in new issue
Block a user