Compare commits

...
Author SHA1 Message Date
james.maes 03e09c3c54 Merge remote-tracking branch 'origin/main' into feat/print-to-file 2026-11-01 13:30:41 -06:00
james.maesandClaude Fable 5.1 1d7e0af3cc fix(tui): screen saver counts signed-in sessions; Query art from the real logo (#18)
The Online tile reads the session store's storedSession table first (active sessions and distinct users), falls back to you plus collaboration presence, never says 'nobody around' while signed in, and the saver and lock keep the presence lease. The Query mascot is regenerated at three sizes from the qrun.io/query hero SVG in the logo's indigo (packages/tui/scripts/gen-query-art.mjs), with landmark tests per size.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:30:14 -06:00
james.maesandClaude Fable 5.1 cccadc9a71 docs(printing): document save as PDF, content prints and the page header
print-agent.sh now prints to ~/Downloads unless QQQ_PRINT_OUT says
otherwise; --dir reports the folder in use. CHANGELOG under Unreleased.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:24:02 -06:00
james.maesandClaude Fable 5.1 51337c35ed feat(tui): Save as PDF, print the content of a screen, app header and footer
The printer picker starts with Save as PDF..., which renders the same
document on the server and saves it the way downloads and exports are
saved (save dialog or download folder, stamped name, no silent overwrite);
:print pdf saves straight away. Ctrl+P on screens that are not records or
grids asks Print what?: Content (the screen's panels as titled sections,
without the sidebar, title bar, key lines or borders) or Screen (as drawn).
Every page carries the app header and footer: instance name, breadcrumb,
server, user, time in the profile's date format, qqq ui <version>.
Breadcrumbs moved to breadcrumbs.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:22:00 -06:00
james.maesandClaude Fable 5.1 76bbf44144 feat(cli): --to-file on qqq print, print list and print text
--to-file saves the rendered PDF instead of printing: into a folder with
the stamped names, as a named file, or into the download folder by default,
never overwriting a default name. Every print and save carries the app's
header and footer: the instance name, the server, the user, the time in the
profile's date format and qqq <version> · <server>.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:22:00 -06:00
james.maesandClaude Fable 5.1 f0b1f38bdc feat(core): save as PDF, the app header and footer, content sections
renderToPdf renders a record sheet, table list or text page through the
printing qbit's printingRenderToPdf and downloads it with the stamped
download name. PrintChrome carries the app's header and footer (app,
breadcrumb, server, user, printed at; footer and filter note) on every
print and save request, and text prints take a screen's content as
sections.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:22:00 -06:00
james.maesandClaude Fable 5.1 650f1d33db fix(tui): render assistant answers; F12 toggles the panel (#17)
A shared markdown renderer in core handles entities, headings, lists, bold/code and pipe tables for assistant answers; links render as 'label ↗' numbered once per target (Tab, Enter, u); the CLI prints the same blocks as sub-tables. F12 toggles the Assistant panel on every screen, keeping the conversation, context and draft; Esc hides; Ctrl+G, :ask and a click on 12Ask do the same; the F-key bar gains 11/12Ask at 120+ columns.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:19:36 -06:00
34 changed files with 2097 additions and 271 deletions

No files matched your search

+43
View File
@@ -3,6 +3,49 @@
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).
- **Save as PDF…** is the first entry of the UI's printer picker (record `p`, grid Ctrl+P, `:print`; `:print pdf`
saves straight away): the server renders the same record sheet, table list or page (printing
`printingRenderToPdf`) and the PDF is saved the way downloads and exports are — the save dialog or, with
silent downloads, the download folder, the stamped name (`Customer-Ada-Lovelace_2026-11-01_120301.pdf`), no
silent overwrite. The CLI's `qqq print`, `qqq print list` and `qqq print text` take `--to-file [path]` (a
folder, a file, or the download folder by default).
- Ctrl+P on screens that are not records or grids asks **Print what?**: **Content** (the default: the screen's
panels as a document with their titles, without navigation or borders) or **Screen** (the terminal as drawn).
- Every printed or saved page carries the app's header (instance name, breadcrumb, server, user, time in the
profile's date format) and footer (`qqq ui <version> · <server>` or `qqq <version> · <server>`, a grid's filter
and sort, page n of m); the CLI sends the same.
- `scripts/print-agent.sh` prints to `~/Downloads` unless `QQQ_PRINT_OUT` says otherwise (`--dir` reports it).
- The kernel adds `renderToPdf`, `renderValues`, `canSaveAsPdf`, `chromeValues` and content sections on text
prints; the TUI's breadcrumbs moved to `breadcrumbs.ts`.
### Fixed
- **The assistant's answers render** instead of showing raw markdown, in the TUI panel and in
`qqq assistant ask`: HTML entities decoded (`healthy&#58;` was shown as is), headings, wrapped
paragraphs, lists, bold and code, and pipe tables laid out as a grid (CLI: sub-tables) instead of `| --- |`
rows. Links show as `label ↗` (CLI: `label (qqq table get …)` or `label [n]` with the command under
Links), never their URL-encoded `/app/...?filter=` targets; the tool results' links are listed only
when the text does not already show the same target, so `db_performance` no longer appears three times.
Exact duplicate paragraphs are shown once; the timing and token line stays, dimmed.
## [1.0.0] - 2026-11-01
`qqq` 1.0: the command-line client and the full-screen terminal UI (`qqq ui`), shipped together as one
+7 -3
View File
@@ -190,10 +190,14 @@ returns at once; `qqq print job status <id> --watch` follows later; `qqq print j
job still queued). The printer given with `--printer` (or `qqq printer use`) is remembered per profile.
`qqq print list person -w "lastName = Odd" --order -id --columns id,firstName,lastName` prints a table list
of the whole result (up to 10,000 rows; `--page 2` for one page), and `some-command | qqq print text --title Log`
prints text. In `qqq ui`, **Ctrl+P prints what you see** on every screen: a record sheet, a table list of
the grid (all rows or this page), or the screen as text. Tables print only where the server enabled Print
prints text. Add `--to-file [folder|file]` to any of them to save the PDF instead of printing (no path: the
download folder, with the stamped name exports use). In `qqq ui`, **Ctrl+P prints what you see** on every
screen: a record sheet, a table list of the grid (all rows or this page), or a screen's content (or the
screen as drawn), each page with the app's header and footer; **Save as PDF…** in the printer picker saves
it like a download. Tables print only where the server enabled Print
for them (qbit-printing 2.1 `registerAll` enables every table). For local testing without hardware,
`scripts/print-agent.sh` runs qqq-print-agent's virtual file printer, which writes each job to a directory.
`scripts/print-agent.sh` runs qqq-print-agent's virtual file printer, which writes each job to `~/Downloads`
(or `QQQ_PRINT_OUT`; `--dir` shows the folder).
[TRY-IT.md](TRY-IT.md) walks through every command against the sample server.
+1 -1
View File
@@ -69,7 +69,7 @@ the options.
| `report`, `saved-report`, `saved-view`, `share`, `bulk-profile`, `audit`, `widget`, `script`, `job`, `prefs`, `help-search` | reports, saved reports, saved views, sharing, bulk-load mappings, audits, widgets as text, scripts, jobs, preferences, help |
| `api <operationId>` | any UI API operation by id |
| `extensions`, `esb`, `collab`, `health`, `alerts`, ... | one noun per extension, run when the server has it installed |
| `assistant ask`, `assistant config` | ask the in-app assistant (qbit-assistant) a question about your data: the answer streams, then its numbers and the records and tables it refers to (`--table`/`--record` send the page as context, `-c` continues a conversation, `--json` the whole answer); `config` says whether it answers and with which model, or why not |
| `assistant ask`, `assistant config` | ask the in-app assistant (qbit-assistant) a question about your data: the answer streams block by block, rendered (tables as sub-tables, links as `label (qqq table get …)` or `label [n]` with the command under Links, never a URL), then its numbers (`--table`/`--record` send the page as context, `-c` continues a conversation, `--json` the whole answer); `config` says whether it answers and with which model, or why not |
| `print`, `printer` | print records, table lists and text through the printing extension, follow print jobs; printers and print agents |
| `history` | everything you did with the CLI and the TUI, like a shell history (see below) |
| `login`, `logout`, `profile`, `config`, `cache`, `completion`, `doctor` | sign-in, saved server profiles (`profile list|add|edit|use|remove`), settings, the metadata cache, shell completion (bash, zsh, fish, PowerShell), checks |
+86 -25
View File
@@ -13,7 +13,7 @@
import type { Command } from 'commander'
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
import { assistantContextFor, describeAssistantConfig, type AssistantAnswer } from '@qrun-io/qqq-core'
import { answerDocument, assistantContextFor, describeAssistantConfig, linkTargetKey, markdownText, parseAppHref, parseMarkdown, type AssistantAnswer, type MdInline } from '@qrun-io/qqq-core'
import { io, type Context, UsageError } from '../context.js'
import { positiveInt, type Json } from './shared.js'
@@ -22,16 +22,73 @@ export function plainAnswer(text: string): string {
return text.replace(/\*\*(.+?)\*\*/g, '$1').replace(/__(.+?)__/g, '$1').replace(/`([^`]+)`/g, '$1').replace(/^#{1,6}\s+/gm, '')
}
/** Rows for the links table: kind, label, table, id, the app path and the qqq command that opens it. */
export function linkRows(answer: AssistantAnswer): Json[] {
return answer.links.map((link) => ({
kind: link.kind,
label: link.label,
table: link.tableName ?? '',
id: link.recordId ?? '',
href: link.href,
open: link.kind === 'record' ? `qqq table get ${link.tableName} ${link.recordId}` : link.kind === 'table' ? `qqq table query ${link.tableName}` : '',
}))
/** The qqq command that opens a link's target (`qqq table get t 4`, `qqq table query t --filter-json '…'`), or '' for other pages. */
export function linkCommand(href: string): string {
const target = parseAppHref(href)
if (!target?.tableName) return ''
const key = linkTargetKey(href)
const record = /^record:[^:]+:(.*)$/.exec(key)
if (record) return `qqq table get ${target.tableName} ${shellQuote(record[1]!)}`
if (key === `table:${target.tableName}:` || !target.filter) return `qqq table query ${target.tableName}`
return `qqq table query ${target.tableName} --filter-json ${shellQuote(JSON.stringify(target.filter))}`
}
function shellQuote(text: string): string {
return /^[A-Za-z0-9_.:@%+=,/-]+$/.test(text) ? text : `'${text.replace(/'/g, `'\\''`)}'`
}
/**
* Renders an answer's markdown for the terminal, block by block (so it can print while the model
* writes): entities decoded, tables as sub-tables, each link as `label (qqq table get …)` when that
* is short, else `label [n]` with the command listed under Links (numbered once per target).
*/
export class AnswerRenderer {
readonly footnotes: Array<{ n: number; label: string; open: string }> = []
private readonly noted = new Map<string, number>()
private printed = 0
constructor(private readonly width = 100) {}
private note(label: string, href: string): number {
const key = linkTargetKey(href)
let n = this.noted.get(key)
if (n === undefined) {
n = this.noted.size + 1
this.noted.set(key, n)
this.footnotes.push({ n, label, open: linkCommand(href) || href })
}
return n
}
/** Text for the blocks completed since the last call (all of them when `done`), '' when none. */
next(text: string, done: boolean): string {
const doc = parseMarkdown(text)
const complete = done ? doc.blocks.length : Math.max(0, doc.blocks.length - 1)
const out: string[] = []
for (; this.printed < complete; this.printed++) {
const block = doc.blocks[this.printed]!
const inTable = block.kind === 'table'
out.push(markdownText({ blocks: [block], links: doc.links }, this.width, (part: MdInline) => {
const open = linkCommand(part.href ?? '')
if (!inTable && open && !open.includes('--filter-json') && open.length <= 48) return `${part.text} (${open})`
return `${part.text} [${this.note(part.text, part.href ?? '')}]`
}))
}
return out.join('\n\n')
}
/** Numbers the answer's other links (tool results) the text did not show, once per target. */
extra(answer: { text: string; links: Array<{ label: string; href: string }> }): void {
for (const link of answerDocument(answer).extraLinks) this.note(link.label, link.href)
}
}
/** The whole answer rendered at once (tests, and output that is not streamed). */
export function renderAnswer(answer: { text: string; links: Array<{ label: string; href: string }> }, width = 100): { text: string; links: Array<{ n: number; label: string; open: string }> } {
const renderer = new AnswerRenderer(width)
const text = renderer.next(answer.text, true)
renderer.extra(answer)
return { text, links: renderer.footnotes }
}
export function registerAssistant(noun: Command, ctx: () => Context): void {
@@ -66,38 +123,42 @@ export function registerAssistant(noun: Command, ctx: () => Context): void {
const conversationId = options.conversation ? positiveInt(options.conversation, '--conversation') : undefined
const assistant = await c.assistant()
const human = c.format === 'table' || c.format === 'text'
let streamed = false
const renderer = new AnswerRenderer(Math.max(40, io.width() ?? 100))
let printedAny = false
const emit = (text: string) => {
if (!text) return
io.out(`${printedAny ? '\n\n' : ''}${text}`)
printedAny = true
}
const answer = await assistant.ask(question, {
conversationId,
context: assistantContextFor({ tableName: options.table, recordId: options.record }),
onEvent: (event) => {
onEvent: (event, partial) => {
if (!human) return
if (event.type === 'text.delta' && typeof event.text === 'string') {
io.out(plainAnswer(event.text))
streamed = true
} else if (event.type === 'tool.start' && typeof event.summary === 'string' && event.summary) {
c.note(`… ${event.summary}`)
}
// whole blocks as the model completes them, rendered (a table prints once it is complete)
if (event.type === 'text.delta') emit(renderer.next(partial.text, false))
else if (event.type === 'tool.start' && typeof event.summary === 'string' && event.summary) c.note(`… ${event.summary}`)
},
})
if (answer.status === 'ERROR') {
if (streamed) io.out('\n')
if (printedAny) io.out('\n')
throw new UiApiError(502, { code: answer.error?.code ?? 'INTERNAL', message: answer.error?.message ?? 'The assistant could not answer.' })
}
if (!human) {
c.print({ data: answer })
return
}
if (!streamed) io.out(plainAnswer(answer.text))
emit(renderer.next(answer.text, true))
io.out('\n')
for (const notice of answer.notices) c.note(`${notice.level}: ${notice.text}`)
if (answer.numbers.length) {
io.out('\n')
c.print({ data: answer.numbers, rows: answer.numbers.map((n) => ({ number: n.label, value: n.value, href: n.href ?? '' })), columns: ['number', 'value', 'href'] })
c.print({ data: answer.numbers, rows: answer.numbers.map((n) => ({ number: n.label, value: n.value })), columns: ['number', 'value'] })
}
if (answer.links.length) {
io.out('\n')
c.print({ data: answer.links, rows: linkRows(answer), columns: ['kind', 'label', 'table', 'id', 'open'] })
renderer.extra(answer)
if (renderer.footnotes.length) {
io.out('\nLinks\n')
c.print({ data: renderer.footnotes, rows: renderer.footnotes.map((f) => ({ n: `[${f.n}]`, label: f.label, open: f.open })), columns: ['n', 'label', 'open'] })
}
const seconds = answer.elapsedMs === undefined ? '' : `${(answer.elapsedMs / 1000).toFixed(1)} s, `
c.note(`(${seconds}conversation ${answer.conversationId}; continue with: qqq assistant ask -c ${answer.conversationId} "...")`)
+76 -9
View File
@@ -22,10 +22,13 @@
*/
import type { Command } from 'commander'
import { readFileSync } from 'node:fs'
import { FAILED_JOB_STATES, MAX_LIST_ROWS, printFilter, isFinalJobState, jobStateSummary, printProcessName, PRINT_PROCESS_PREFIX, readRememberedPrinter, resolvePrinter, whyNotPrintable, writeRememberedPrinter, type Printer, type PrintJob, type Printing } from '@qrun-io/qqq-core'
import { existsSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { profileNameForServer, formatDateValue, localeFromEnv, readDateSettings, resolveDateSettings, saveDownload, type PrintChrome, type RenderedPdf, type RenderRequest, FAILED_JOB_STATES, MAX_LIST_ROWS, printFilter, isFinalJobState, jobStateSummary, printProcessName, PRINT_PROCESS_PREFIX, readRememberedPrinter, resolvePrinter, whyNotPrintable, writeRememberedPrinter, type Printer, type PrintJob, type Printing } from '@qrun-io/qqq-core'
import { EXIT, UsageError, type Context } from '../context.js'
import { ORDER_OPTION, countFooter, instantText, positiveInt, type Json } from './shared.js'
import { getLogin } from '../session.js'
import { CLI_VERSION } from '../version.js'
import { ORDER_OPTION, countFooter, instantText, positiveInt, writeDownload, type Json } from './shared.js'
import { resolvedFilterFrom, type QueryOptions } from './table.js'
const JOB_COLUMNS = ['id', 'title', 'status', 'printer', 'copies', 'created', 'message']
@@ -45,6 +48,61 @@ function printerRows(printers: Printer[], remembered: string | undefined): Json[
return printers.map((p) => ({ default: p.id === remembered ? '*' : '', name: p.name, device: p.device ?? '', status: p.status ?? '', approved: p.approved ? 'yes' : 'no', exposed: p.exposed ? 'yes' : 'no', formats: p.formats.join(' '), id: p.id }))
}
/**
* The app's header and footer for what the CLI prints: the instance's branding name (else the
* profile), the title, the server (profile and host), the signed-in user, the time in the
* profile's date format; the footer `qqq <version> · <server>`.
*/
async function chromeFor(c: Context, title?: string): Promise<PrintChrome> {
const t = c.target
let host = t.server
try {
host = new URL(t.server).host
} catch {
host = t.server
}
const server = t.name && t.name !== host && t.name !== profileNameForServer(t.server) ? `${t.name} (${host})` : host
let app: string | undefined
try {
app = ((await c.metaData()) as unknown as { branding?: { appName?: string } }).branding?.appName
} catch {
app = undefined
}
const login = await getLogin(t).catch(() => undefined)
const user = login && login.kind === 'session' ? String((login.user as { name?: string } | undefined)?.name ?? '') : ''
let printedAt = new Date().toISOString()
try {
printedAt = formatDateValue(printedAt, 'DATE_TIME', resolveDateSettings(readDateSettings(t.name), localeFromEnv()), { withSeconds: false })
} catch {
// the ISO instant stays
}
return { app: app ?? t.name, title, server, user: user || undefined, printedAt, footerLeft: `qqq ${CLI_VERSION} · ${server}` }
}
/**
* --to-file: renders each document on the server and saves it as downloads are saved: a folder
* given gets the stamped default names, a file name given is written as given (one document
* only), no path saves to the download folder (`qqq config get download-dir`) with the stamped
* name, never overwriting.
*/
async function saveAsPdf(c: Context, printing: Printing, requests: RenderRequest[], out: string | true, title?: string): Promise<void> {
const chrome = await chromeFor(c, title)
const path = out === true ? undefined : out
const folder = path !== undefined && path !== '-' && existsSync(path) && statSync(path).isDirectory()
if (path !== undefined && !folder && requests.length > 1) throw new UsageError('--to-file with several documents takes a folder (or no path: the download folder).')
const rows: Json[] = []
for (const request of requests) {
const pdf: RenderedPdf = await printing.renderToPdf(request, new Date(), chrome)
if (pdf.message) c.note(pdf.message)
const written = folder ? saveDownload(join(path!, pdf.fileName), pdf.bytes, { explicit: false }) : writeDownload(path, pdf.fileName, pdf.bytes)
if (written) {
c.note(`Saved ${written} (${pdf.bytes.length.toLocaleString('en-US')} bytes)`)
rows.push({ saved: written, bytes: pdf.bytes.length })
}
}
if (path !== '-') c.print({ data: rows, rows, columns: ['saved', 'bytes'] })
}
/** Tables the server offers Print on, from the metadata. */
async function printableTables(c: Context): Promise<string[]> {
const processes = ((await c.metaData() as unknown as Json).processes ?? {}) as Record<string, unknown>
@@ -102,16 +160,18 @@ export function registerPrint(program: Command, ctx: () => Context): void {
.option('-c, --copies <n>', 'copies of each record (1-100)', '1')
.option('--no-wait', 'return once the jobs are queued')
.option('--timeout <seconds>', 'how long to wait for the jobs', '120')
.action(async (table: string, ids: string[], options: { printer?: string; copies: string; wait: boolean; timeout: string }) => {
.option('--to-file [path]', 'save each record sheet as a PDF instead of printing: a folder, a file (one record), or the download folder')
.action(async (table: string, ids: string[], options: { printer?: string; copies: string; wait: boolean; timeout: string; toFile?: string | true }) => {
const c = ctx()
const printing = await printingOf(c)
const tables = await printableTables(c)
if (!tables.includes(table)) throw new UsageError(`Print is not enabled for table "${table}" on this server${tables.length ? ` (tables with Print: ${tables.join(', ')})` : ''}.`)
if (options.toFile !== undefined) return saveAsPdf(c, printing, ids.map((id) => ({ kind: 'record' as const, table, id })), options.toFile)
const copies = positiveInt(options.copies, '--copies')
const printer = await choosePrinter(c, printing, options.printer)
if (options.printer) writeRememberedPrinter(c.target.name, { id: printer.id, name: printer.name })
c.note(`Printing ${ids.length} ${table} record${ids.length === 1 ? '' : 's'} on "${printer.name}"${copies > 1 ? `, ${copies} copies each` : ''} (${printProcessName(table)})`)
const jobIds = await printing.print({ table, ids, printer: printer.id, copies })
const jobIds = await printing.print({ table, ids, printer: printer.id, copies, chrome: await chromeFor(c) })
if (!jobIds.length) throw new Error(`Nothing was printed: no ${table} record ${ids.join(', ')} was found (or you may not see ${ids.length === 1 ? 'it' : 'them'}).`)
const jobs = options.wait ? await follow(c, printing, jobIds, positiveInt(options.timeout, '--timeout')) : await printing.jobs({ ids: jobIds })
c.print({ data: { printer: { id: printer.id, name: printer.name }, jobs }, rows: jobRows(jobs, c), columns: JOB_COLUMNS, footer: countFooter(jobs.length, 'job', [jobStateSummary(jobs), !options.wait && `follow: qqq print job status ${jobIds.join(' ')} --watch`]) })
@@ -134,7 +194,8 @@ export function registerPrint(program: Command, ctx: () => Context): void {
.option('--copies <n>', 'copies (1-100)', '1')
.option('--no-wait', 'return once the job is queued')
.option('--timeout <seconds>', 'how long to wait for the job', '300')
.action(async (table: string, options: QueryOptions & { columns?: string; page?: string; pageSize: string; title?: string; orientation: string; paper: string; printer?: string; copies: string; wait: boolean; timeout: string }) => {
.option('--to-file [path]', 'save the table list as a PDF instead of printing: a folder, a file, - for stdout, or the download folder')
.action(async (table: string, options: QueryOptions & { columns?: string; page?: string; pageSize: string; title?: string; orientation: string; paper: string; printer?: string; copies: string; wait: boolean; timeout: string; toFile?: string | true }) => {
const c = ctx()
const printing = await printingOf(c)
const meta = await c.tableMeta<Json>(table)
@@ -148,11 +209,15 @@ export function registerPrint(program: Command, ctx: () => Context): void {
const filter = await resolvedFilterFrom(c, { ...options, pageSize: '500' }, meta, columns)
const pageSize = positiveInt(options.pageSize, '--page-size')
const scope = options.page ? { kind: 'page' as const, skip: (positiveInt(options.page, '--page') - 1) * pageSize, limit: pageSize } : { kind: 'all' as const }
if (options.toFile !== undefined) {
const described = [printFilter(filter as never), options.search ? `quick "${options.search}"` : ''].filter(Boolean).join(' · ')
return saveAsPdf(c, printing, [{ kind: 'list', list: { table, columns, filter: filter as unknown as Record<string, unknown>, scope, title: options.title ?? String(meta.label ?? table), filterDescription: described || undefined, orientation: orientation as 'AUTO', pageSize: paper as 'LETTER', primaryKeyField: String(meta.primaryKeyField ?? 'id') } }], options.toFile, String(meta.label ?? table))
}
const printer = await choosePrinter(c, printing, options.printer)
if (options.printer) writeRememberedPrinter(c.target.name, { id: printer.id, name: printer.name })
const described = [printFilter(filter as never), options.search ? `quick "${options.search}"` : ''].filter(Boolean).join(' · ')
c.note(`Printing ${scope.kind === 'all' ? 'all rows of' : `page ${options.page} of`} ${meta.label ?? table}${described ? ` where ${described}` : ''} on "${printer.name}"`)
const outcome = await printing.printTableList({ table, columns, filter: filter as unknown as Record<string, unknown>, scope, title: options.title ?? String(meta.label ?? table), filterDescription: described || undefined, printer: printer.id, copies: positiveInt(options.copies, '--copies'), orientation: orientation as 'AUTO', pageSize: paper as 'LETTER', primaryKeyField: String(meta.primaryKeyField ?? 'id') })
const outcome = await printing.printTableList({ table, columns, filter: filter as unknown as Record<string, unknown>, scope, title: options.title ?? String(meta.label ?? table), filterDescription: described || undefined, printer: printer.id, copies: positiveInt(options.copies, '--copies'), orientation: orientation as 'AUTO', pageSize: paper as 'LETTER', primaryKeyField: String(meta.primaryKeyField ?? 'id'), chrome: await chromeFor(c, String(meta.label ?? table)) })
if (outcome.message) c.note(outcome.message)
if (!outcome.jobIds.length) throw new Error('The server queued no print job.')
const jobs = options.wait ? await follow(c, printing, outcome.jobIds, positiveInt(options.timeout, '--timeout')) : await printing.jobs({ ids: outcome.jobIds })
@@ -168,14 +233,16 @@ export function registerPrint(program: Command, ctx: () => Context): void {
.option('--copies <n>', 'copies (1-100)', '1')
.option('--no-wait', 'return once the job is queued')
.option('--timeout <seconds>', 'how long to wait for the job', '120')
.action(async (file: string | undefined, options: { title: string; subtitle?: string; landscape?: boolean; printer?: string; copies: string; wait: boolean; timeout: string }) => {
.option('--to-file [path]', 'save the text page as a PDF instead of printing: a folder, a file, or the download folder')
.action(async (file: string | undefined, options: { title: string; subtitle?: string; landscape?: boolean; printer?: string; copies: string; wait: boolean; timeout: string; toFile?: string | true }) => {
const c = ctx()
const printing = await printingOf(c)
const text = readFileSync(file === undefined || file === '-' ? 0 : file, 'utf8')
if (!text.trim()) throw new UsageError('Nothing to print: the text is empty.')
if (options.toFile !== undefined) return saveAsPdf(c, printing, [{ kind: 'text', title: options.title, subtitle: options.subtitle, text, orientation: options.landscape ? 'LANDSCAPE' : 'PORTRAIT' }], options.toFile, options.title)
const printer = await choosePrinter(c, printing, options.printer)
if (options.printer) writeRememberedPrinter(c.target.name, { id: printer.id, name: printer.name })
const outcome = await printing.printText({ title: options.title, subtitle: options.subtitle, text, printer: printer.id, copies: positiveInt(options.copies, '--copies'), orientation: options.landscape ? 'LANDSCAPE' : 'PORTRAIT' })
const outcome = await printing.printText({ title: options.title, subtitle: options.subtitle, text, printer: printer.id, copies: positiveInt(options.copies, '--copies'), orientation: options.landscape ? 'LANDSCAPE' : 'PORTRAIT', chrome: await chromeFor(c, options.title) })
const jobs = options.wait ? await follow(c, printing, outcome.jobIds, positiveInt(options.timeout, '--timeout')) : await printing.jobs({ ids: outcome.jobIds })
c.print({ data: { printer: { id: printer.id, name: printer.name }, jobs }, rows: jobRows(jobs, c), columns: JOB_COLUMNS, footer: countFooter(jobs.length, 'job', [jobStateSummary(jobs)]) })
})
+2 -1
View File
@@ -10,7 +10,7 @@
import type { Command } from 'commander'
import { UiApiError, unwrap, type UiApiClient } from '@qrun-io/qqq-ui-api-client'
import { Assistant, Collaboration, Printing, missingQBitMessage, runProcess, type ProcessMeta, ExtensionNotInstalledError, OperationInputError, ExtensionRegistry, KNOWN_EXTENSIONS, FilterSyntaxError, OUTPUT_FORMATS, ProcessError, UnsafeFileNameError, defaultTimeZone, type DisplayOptions, applyQuery, callOperation, defaultFormat, extensionRegistry, getMetaData, getTableMetaData, render, type CallInput, type CallResult, type MetaDataDocument, type OutputFormat, type Printable } from '@qrun-io/qqq-core'
import { Assistant, Collaboration, Printing, downloadProcessFile, missingQBitMessage, runProcess, type ProcessMeta, ExtensionNotInstalledError, OperationInputError, ExtensionRegistry, KNOWN_EXTENSIONS, FilterSyntaxError, OUTPUT_FORMATS, ProcessError, UnsafeFileNameError, defaultTimeZone, type DisplayOptions, applyQuery, callOperation, defaultFormat, extensionRegistry, getMetaData, getTableMetaData, render, type CallInput, type CallResult, type MetaDataDocument, type OutputFormat, type Printable } from '@qrun-io/qqq-core'
import { openRealtimeStream } from '@qrun-io/qqq-ui-api-client'
import { CLIENT_CHANNEL, authenticatedFetch, clientFor, currentToken, selectTarget, sessionCookieHeaderFor, target, type Target } from './session.js'
@@ -190,6 +190,7 @@ export class Context {
const outcome = await runProcess({ baseUrl: t.server, fetch: authenticatedFetch(t), meta, values, records: recordIds.length ? { ids: recordIds } : undefined, stepTimeoutMillis: 60000 })
return outcome.values
},
download: (reference) => downloadProcessFile(t.server, authenticatedFetch(t), reference),
})
}
+1 -1
View File
@@ -14,7 +14,7 @@ import { Command, CommanderError } from 'commander'
import { Context, EXIT, describeError, io } from './context.js'
import { versionText } from './version.js'
export { buildDescription } from './version.js'
export { buildDescription, CLI_VERSION } from './version.js'
import { registerApi } from './commands/api.js'
import { registerAudit } from './commands/audit.js'
import { registerAuth } from './commands/auth.js'
+29 -4
View File
@@ -5,7 +5,10 @@
import assert from 'node:assert/strict'
import { after, before, describe, test } from 'node:test'
import { plainAnswer } from '../commands/assistant.js'
import { linkCommand, plainAnswer, renderAnswer } from '../commands/assistant.js'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { TEST_SOURCE } from './harness.js'
import { runCli, sandbox } from './harness.js'
import { StubResponse, startStub, type J, type Stub } from './stub.js'
@@ -59,9 +62,11 @@ describe('qqq assistant against a stub', () => {
const r = await qqq(['-O', 'table', 'assistant', 'ask', '--table', 'customer', '--record', '5', 'how', 'many', 'customers?'])
assert.equal(r.code, 0, r.stderr)
assert.match(r.stdout, /^There are 42 customers\.\n/)
assert.match(r.stdout, /Customer\s+42\s+\/app\/customer/)
assert.match(r.stdout, /record\s+Acme \(Customer\)\s+customer\s+5\s+qqq table get customer 5/)
assert.match(r.stdout, /table\s+Customer \(42\)\s+customer\s+qqq table query customer/)
assert.match(r.stdout, /Customer\s+42\n/)
assert.match(r.stdout, /Links\n/)
assert.match(r.stdout, /\[1\]\s+Acme \(Customer\)\s+qqq table get customer 5/)
assert.match(r.stdout, /\[2\]\s+Customer \(42\)\s+qqq table query customer/)
assert.doesNotMatch(r.stdout, /\/app\//)
assert.match(r.stderr, /Counting Customer/)
assert.match(r.stderr, /conversation 12; continue with: qqq assistant ask -c 12/)
const created = stub.requests.find((q) => q.path === '/assistant/conversations')!
@@ -94,4 +99,24 @@ describe('qqq assistant against a stub', () => {
test('plain answer text drops markdown markers', () => {
assert.equal(plainAnswer('## Total\nThere are **42** `customers` and __3__ orders.'), 'Total\nThere are 42 customers and 3 orders.')
})
test('the 8078 answer about unhealthy services renders as text: no entities, no URLs, a sub-table, links once', () => {
const fixture = JSON.parse(readFileSync(join(TEST_SOURCE, '..', '..', '..', 'core', 'src', 'test', 'fixtures', 'assistant-unhealthy.json'), 'utf8')) as { text: string; links: Array<{ label: string; href: string }> }
const { text, links } = renderAnswer(fixture, 100)
assert.doesNotMatch(text, /&#|\/app\/|%7B|\| ---/)
assert.match(text, /Service\s+Status\s+Since/)
assert.match(text, /email \[1\]\s+DEGRADED/)
assert.match(text, /- Open unhealthy components \[4\]/)
assert.ok(text.split('\n').every((l) => [...l].length <= 100))
assert.deepEqual(links.map((l) => [l.n, l.label, l.open.split(' --')[0]]), [
[1, 'email', 'qqq table get health_component 4'],
[2, 'db_performance', 'qqq table get health_component 3'],
[3, 'esb', 'qqq table get health_component 10'],
[4, 'Open unhealthy components', 'qqq table query health_component'],
])
assert.match(links[3]!.open, /--filter-json '\{"criteria":\[\{"fieldName":"status","operator":"NOT_EQUALS","values":\["UP"\]\}\],"booleanOperator":"AND"\}'/)
assert.equal(linkCommand('/app/person/2'), 'qqq table get person 2')
assert.equal(linkCommand('https://x'), '')
})
})
+58 -2
View File
@@ -14,7 +14,8 @@ import assert from 'node:assert/strict'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import type { AddressInfo } from 'node:net'
import { after, before, describe, test } from 'node:test'
import { readFileSync } from 'node:fs'
import { mkdtempSync, readFileSync, readdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runCli, sandbox, startFakeServer, type FakeServer } from './harness.js'
@@ -24,6 +25,7 @@ interface PrintServer {
url: string
runs: { process: string; values: J; recordIds?: string }[]
lists: { process: string; values: J; recordsParam?: string }[]
renders: J[]
jobs: Map<string, { status: string[]; title: string; reads: number; message?: string }>
close(): Promise<void>
}
@@ -50,6 +52,7 @@ const CANCEL_META = { name: 'printingCancelSelectedJobs', label: 'Cancel queued
async function startPrintServer(): Promise<PrintServer> {
const runs: PrintServer['runs'] = []
const lists: PrintServer['lists'] = []
const renders: PrintServer['renders'] = []
const jobs: PrintServer['jobs'] = new Map()
const state = new Map<string, { process: string; values: J; recordIds?: string }>()
let next = 1
@@ -69,10 +72,22 @@ async function startPrintServer(): Promise<PrintServer> {
let m: RegExpExecArray | null
if (path === '/version') return send(res, 200, { apiVersion: '1.9.0', features: ['metaData', 'tables', 'processes', 'version', 'extensions'], extensions: [{ name: 'printing', version: '2.1.0', pathPrefix: '/qbit/printing' }] })
if (path === '/metaData/authentication') return send(res, 200, { type: 'MOCK' })
if (path === '/metaData') return send(res, 200, { tables: { person: { name: 'person', label: 'Person' } }, processes: { printingPrintRecords_person: { name: 'printingPrintRecords_person', label: 'Print', tableName: 'person' }, printingCancelSelectedJobs: { name: 'printingCancelSelectedJobs', tableName: 'printing_job' }, printingPrintTableList: { name: 'printingPrintTableList' }, printingPrintText: { name: 'printingPrintText' } }, apps: {} })
if (path === '/metaData') return send(res, 200, { tables: { person: { name: 'person', label: 'Person' } }, processes: { printingPrintRecords_person: { name: 'printingPrintRecords_person', label: 'Print', tableName: 'person' }, printingCancelSelectedJobs: { name: 'printingCancelSelectedJobs', tableName: 'printing_job' }, printingPrintTableList: { name: 'printingPrintTableList' }, printingPrintText: { name: 'printingPrintText' }, printingRenderToPdf: { name: 'printingRenderToPdf' } }, apps: {} })
if (path === '/metaData/process/printingPrintRecords_person') return send(res, 200, PRINT_META)
if (path === '/metaData/process/printingCancelSelectedJobs') return send(res, 200, CANCEL_META)
if (path === '/metaData/table/person') return send(res, 200, PERSON_META)
if (path === '/metaData/process/printingRenderToPdf') return send(res, 200, { name: 'printingRenderToPdf', label: 'Save as PDF', frontendSteps: [{ name: 'download', label: 'Download', components: [{ type: 'DOWNLOAD_FORM' }], viewFields: [{ name: 'downloadFileName', type: 'STRING', label: 'File' }] }] })
if (path === '/processes/printingRenderToPdf/init') {
const v = JSON.parse(field(body, 'values') ?? '{}') as J
renders.push(v)
const name = v.kind === 'RECORD' ? `Person-${v.recordId}` : v.kind === 'LIST' ? 'Person' : String(v.title).replace(/[^A-Za-z0-9]+/g, '-')
return send(res, 200, { processUUID: 'render', nextStep: 'download', type: 'COMPLETE', values: { downloadFileName: `${name}.pdf`, serverFilePath: `/tmp/r-${renders.length}.pdf`, ...(v.kind === 'LIST' ? { printingMessage: 'Saved the first 10000 of 12000 rows: the limit is 10000 rows. Narrow the filter to save the rest.' } : {}) } })
}
if (path.startsWith('/download/')) {
res.writeHead(200, { 'Content-Type': 'application/pdf' })
res.end(`%PDF-1.7 ${path}`)
return
}
if ((m = /^\/metaData\/process\/(printingPrintTableList|printingPrintText)$/.exec(path))) return send(res, 200, LIST_META(m[1]!))
if ((m = /^\/processes\/(printingPrintTableList|printingPrintText)\/init$/.exec(path))) {
lists.push({ process: m[1]!, values: JSON.parse(field(body, 'values') ?? '{}') as J, recordsParam: field(body, 'recordsParam') })
@@ -132,6 +147,7 @@ async function startPrintServer(): Promise<PrintServer> {
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
runs,
lists,
renders,
jobs,
close: () => new Promise<void>((resolve) => { server.closeAllConnections(); server.close(() => resolve()) }),
}
@@ -249,6 +265,11 @@ describe('qqq print and qqq printer', () => {
assert.deepEqual(filter.criteria, [{ fieldName: 'lastName', operator: 'EQUALS', values: ['Odd'] }])
assert.deepEqual(filter.orderBys, [{ fieldName: 'id', isAscending: false }])
assert.equal(run.values.filterDescription, 'lastName EQUALS Odd')
// the app header and footer
assert.match(String(run.values.footerLeft), /^qqq \d+\.\d+\.\d+ · 127\.0\.0\.1:\d+$/)
assert.match(String(run.values.headerServer), /^127\.0\.0\.1:\d+$/)
assert.equal(run.values.headerTitle, 'Person')
assert.match(String(run.values.headerPrintedAt), /\d/)
})
test('print list --page prints one page; default columns skip hidden and binary fields; bad options are usage errors', async () => {
@@ -271,5 +292,40 @@ describe('qqq print and qqq printer', () => {
const empty = await qqq(['print', 'text', '-', '--title', 'Nothing'], printServer.url, ' \n')
assert.equal(empty.code, 2)
})
test('--to-file saves record sheets instead of printing: a folder gets stamped names; no job is queued', async () => {
const folder = mkdtempSync(join(tmpdir(), 'qqq-cli-pdf-'))
const before = printServer.runs.length
const result = await qqq(['print', 'person', '1', '2', '--to-file', folder])
assert.equal(result.code, 0, result.stderr)
const files = readdirSync(folder).sort()
assert.equal(files.length, 2)
assert.match(files[0]!, /^Person-1_\d{4}-\d{2}-\d{2}_\d{6}\.pdf$/)
assert.match(readFileSync(join(folder, files[0]!), 'utf8'), /^%PDF-1\.7 \/download\/Person-1\.pdf/)
assert.match(result.stderr, /Saved .*Person-2_\d{4}/)
assert.equal(printServer.runs.length, before, 'no print job')
assert.deepEqual(printServer.renders.slice(-2).map((r) => [r.kind, r.tableName, r.recordId]), [['RECORD', 'person', '1'], ['RECORD', 'person', '2']])
assert.match(String(printServer.renders.at(-1)!.footerLeft), /^qqq \d/)
const two = await qqq(['print', 'person', '1', '2', '--to-file', join(folder, 'one.pdf')])
assert.equal(two.code, 2)
assert.match(two.stderr, /takes a folder/)
})
test('print list --to-file writes the named file as given and shows the limit note; without a path it goes to the download folder', async () => {
const folder = mkdtempSync(join(tmpdir(), 'qqq-cli-pdf-'))
const named = join(folder, 'people.pdf')
const result = await qqq(['print', 'list', 'person', '-w', 'lastName = Odd', '--to-file', named])
assert.equal(result.code, 0, result.stderr)
assert.match(readFileSync(named, 'utf8'), /^%PDF/)
assert.match(result.stderr, /Saved the first 10000 of 12000 rows/)
const render = printServer.renders.at(-1)!
assert.deepEqual([render.kind, render.scope, render.printingPrinterId], ['LIST', 'ALL', undefined])
const downloads = mkdtempSync(join(tmpdir(), 'qqq-cli-dl-'))
assert.equal((await qqq(['config', 'set', 'download-dir', downloads])).code, 0)
const text = await qqq(['print', 'text', '-', '--title', 'Build log', '--to-file'], printServer.url, 'hello\n')
assert.equal(text.code, 0, text.stderr)
assert.match(readdirSync(downloads)[0]!, /^Build-log_\d{4}-\d{2}-\d{2}_\d{6}\.pdf$/)
assert.equal((await qqq(['config', 'set', 'download-dir', ''])).code, 0)
})
})
+99 -2
View File
@@ -23,6 +23,8 @@
import type { CallInput, CallResult } from '../api/call.js'
import { readConfig, writeConfig } from '../session/profiles.js'
import { downloadReference } from '../process/screen.js'
import { exportFileName } from '../render/files.js'
import type { ExtensionRegistry } from './registry.js'
export const PRINTING = 'printing'
@@ -33,6 +35,8 @@ export const COPIES_FIELD = 'printingCopies'
export const JOB_IDS_VALUE = 'printingJobIds'
export const TABLE_LIST_PROCESS = 'printingPrintTableList'
export const TEXT_PROCESS = 'printingPrintText'
/** Renders a record sheet, table list or text page to a PDF download (Save as PDF). */
export const RENDER_PROCESS = 'printingRenderToPdf'
/** The most rows the server prints in one table list. */
export const MAX_LIST_ROWS = 10_000
/** "All rows" is the default list choice up to this many rows (or whenever the grid is filtered). */
@@ -95,6 +99,8 @@ export interface PrintingTransport {
call(id: string, input: CallInput): Promise<CallResult>
/** Runs a process to its end with these values and selected record ids; returns its final values. */
runProcess(processName: string, options: { values: Record<string, unknown>; recordIds: string[] }): Promise<Record<string, unknown>>
/** Fetches a process download (GET /download/{file}); needed for Save as PDF. */
download?(reference: { fileName: string; query: Record<string, string> }): Promise<{ bytes: Uint8Array; contentType: string }>
}
interface QueryRecord {
@@ -203,6 +209,75 @@ export function canPrintLists(meta: { processes?: Record<string, unknown> } | un
return Boolean(meta?.processes?.[TABLE_LIST_PROCESS]) && Boolean(meta?.processes?.[TEXT_PROCESS])
}
/**
* The header and footer every printed page carries (printing 0.4.1+ renderers): the app or
* instance name, the screen title or breadcrumb, the server, the user and the printed-at time in
* the user's date format on top; the client's footer (`qqq ui 1.0.1 · server`), a grid's filter and
* sort, and the page number at the bottom.
*/
export interface PrintChrome {
app?: string
title?: string
server?: string
user?: string
printedAt?: string
footerLeft?: string
footerNote?: string
}
/** The process values of a chrome (blank fields left out: the server's defaults apply). */
export function chromeValues(chrome: PrintChrome | undefined): Record<string, string> {
if (!chrome) return {}
const out: Record<string, string> = {}
const put = (key: string, value: string | undefined) => {
if (value !== undefined && value.trim()) out[key] = value.trim()
}
put('headerApp', chrome.app)
put('headerTitle', chrome.title)
put('headerServer', chrome.server)
put('headerUser', chrome.user)
put('headerPrintedAt', chrome.printedAt)
put('footerLeft', chrome.footerLeft)
put('footerNote', chrome.footerNote)
return out
}
/** One section of a screen's content: a panel's title (blank for none) and its lines. */
export interface ContentSection {
title: string
lines: string[]
}
/** Whether the server can render documents to a PDF download ("Save as PDF"). */
export function canSaveAsPdf(meta: { processes?: Record<string, unknown> } | undefined): boolean {
return Boolean(meta?.processes?.[RENDER_PROCESS])
}
/** What Save as PDF renders: a record sheet, a table list, or a text page. */
export type RenderRequest =
| { kind: 'record'; table: string; id: string }
| { kind: 'list'; list: Omit<TableListRequest, 'printer' | 'copies'> }
| { kind: 'text'; title: string; subtitle?: string; text: string; sections?: ContentSection[]; orientation?: 'PORTRAIT' | 'LANDSCAPE' }
/** A PDF rendered for the client: the default file name (stamped), the server's name, the bytes, and a note. */
export interface RenderedPdf {
fileName: string
serverName: string
bytes: Uint8Array
message?: string
}
/** The process values of a render request. */
export function renderValues(request: RenderRequest, chrome?: PrintChrome): Record<string, unknown> {
const header = chromeValues(chrome)
if (request.kind === 'record') return { kind: 'RECORD', tableName: request.table, recordId: request.id, ...header }
if (request.kind === 'text') return { kind: 'TEXT', title: request.title, ...(request.subtitle ? { subtitle: request.subtitle } : {}), text: request.text, ...(request.sections?.length ? { sections: JSON.stringify(request.sections) } : {}), orientation: request.orientation ?? 'PORTRAIT', pageSize: 'LETTER', ...header }
const values = tableListValues({ ...request.list, printer: '' })
delete values[PRINTER_FIELD]
delete values[COPIES_FIELD]
return { kind: 'LIST', ...values, ...header }
}
/** What a grid print covers. */
export type ListScope = { kind: 'all' } | { kind: 'page'; skip: number; limit: number } | { kind: 'selected'; ids: string[] } | { kind: 'sheets'; ids: string[] }
@@ -308,6 +383,7 @@ export interface PrintRequest {
/** Printer row id. */
printer: string
copies?: number
chrome?: PrintChrome
}
export interface TableListRequest {
@@ -329,12 +405,16 @@ export interface TableListRequest {
pageSize?: 'LETTER' | 'A4'
/** The primary key field, for a selection. */
primaryKeyField?: string
chrome?: PrintChrome
}
export interface TextRequest {
title: string
subtitle?: string
text: string
/** A screen's content as sections (printed instead of the raw text when given). */
sections?: ContentSection[]
chrome?: PrintChrome
printer: string
copies?: number
orientation?: 'PORTRAIT' | 'LANDSCAPE'
@@ -376,6 +456,7 @@ export function tableListValues(request: TableListRequest): Record<string, unkno
pageSize: request.pageSize ?? 'LETTER',
[PRINTER_FIELD]: request.printer,
[COPIES_FIELD]: request.copies ?? 1,
...chromeValues(request.chrome),
}
}
@@ -438,7 +519,7 @@ export class Printing {
if (!request.ids.length) throw new Error('nothing to print: no record ids')
const copies = request.copies ?? 1
if (!Number.isInteger(copies) || copies < 1 || copies > 100) throw new Error('copies must be 1 to 100')
const values = await this.transport.runProcess(printProcessName(request.table), { values: { [PRINTER_FIELD]: request.printer, [COPIES_FIELD]: copies }, recordIds: request.ids })
const values = await this.transport.runProcess(printProcessName(request.table), { values: { [PRINTER_FIELD]: request.printer, [COPIES_FIELD]: copies, ...chromeValues(request.chrome) }, recordIds: request.ids })
return jobIdsOf(values)
}
@@ -453,10 +534,26 @@ export class Printing {
async printText(request: TextRequest): Promise<PrintOutcome> {
this.require()
if (!request.text.trim()) throw new Error('nothing to print: the text is empty')
const values = await this.transport.runProcess(TEXT_PROCESS, { values: { title: request.title, ...(request.subtitle ? { subtitle: request.subtitle } : {}), text: request.text, orientation: request.orientation ?? 'PORTRAIT', pageSize: 'LETTER', [PRINTER_FIELD]: request.printer, [COPIES_FIELD]: request.copies ?? 1 }, recordIds: [] })
const values = await this.transport.runProcess(TEXT_PROCESS, { values: { ...chromeValues(request.chrome), title: request.title, ...(request.subtitle ? { subtitle: request.subtitle } : {}), text: request.text, ...(request.sections?.length ? { sections: JSON.stringify(request.sections) } : {}), orientation: request.orientation ?? 'PORTRAIT', pageSize: 'LETTER', [PRINTER_FIELD]: request.printer, [COPIES_FIELD]: request.copies ?? 1 }, recordIds: [] })
return { jobIds: jobIdsOf(values) }
}
/**
* Renders a record sheet, table list or text page to a PDF on the server (no printer, no job)
* and downloads it; the default name is the server's (the document title) with the date and
* time stamp downloads and exports use: `Customer-Ada-Lovelace_2026-11-01_120301.pdf`.
*/
async renderToPdf(request: RenderRequest, now: Date = new Date(), chrome?: PrintChrome): Promise<RenderedPdf> {
this.require()
if (!this.transport.download) throw new Error('this client cannot download process files')
if (request.kind === 'text' && !request.text.trim()) throw new Error('nothing to save: the text is empty')
const values = await this.transport.runProcess(RENDER_PROCESS, { values: renderValues(request, chrome), recordIds: [] })
const reference = downloadReference(values)
if (!reference) throw new Error('the server rendered no PDF (no download in its answer)')
const { bytes } = await this.transport.download(reference)
return { fileName: exportFileName(reference.fileName, 'pdf', now), serverName: reference.fileName, bytes, message: values.printingMessage === undefined ? undefined : String(values.printingMessage) }
}
/** Cancels jobs still queued in QQQ (jobs a device has taken must be handled at the device). */
async cancel(ids: string[]): Promise<void> {
this.require()
+1
View File
@@ -48,6 +48,7 @@ export * from './render/text.js'
export * from './extensions/registry.js'
export * from './extensions/collaboration.js'
export * from './extensions/assistant.js'
export * from './render/markdown.js'
export * from './extensions/list-columns.js'
export * from './extensions/esb.js'
export * from './extensions/health.js'
+261
View File
@@ -0,0 +1,261 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The small markdown the assistant writes, parsed into blocks both heads draw: headings, paragraphs,
* bullet and numbered lists, pipe tables, and inline **bold**, `code` and [links](/app/...). HTML
* entities are decoded (`&#58;` is `:`). Links are numbered in reading order, one number per target
* (two links to the same record share it), so a client can make them Tab targets and never show a
* URL. Exact duplicate paragraphs are collapsed (models sometimes restate a sentence word for word).
*/
import { parseAppHref } from '../extensions/assistant.js'
import { layoutTable } from './output.js'
import { decodeEntities } from './text.js'
/** A run of text with one style; a link carries its number. */
export interface MdInline {
text: string
style?: 'bold' | 'code' | 'link'
href?: string
/** For links: the index into {@link MdDocument.links}. */
link?: number
}
export type MdAlign = 'left' | 'right' | 'center'
export type MdBlock =
| { kind: 'heading'; level: number; inlines: MdInline[] }
| { kind: 'paragraph'; inlines: MdInline[] }
| { kind: 'list'; ordered: boolean; items: MdInline[][] }
| { kind: 'table'; headers: MdInline[][]; aligns: MdAlign[]; rows: MdInline[][][] }
export interface MdLink {
label: string
href: string
/** What the link points at (see {@link linkTargetKey}): links with the same key are one link. */
key: string
}
export interface MdDocument {
blocks: MdBlock[]
/** Every link target, in reading order, once. */
links: MdLink[]
}
/** The primary key field of a table, for telling a filter on the key from a record link. */
export type PrimaryKeyOf = (table: string) => string | undefined
/**
* What a link points at, so equal targets compare equal: `record:{table}:{id}` (also for a filter
* that is exactly "primary key EQUALS one value"), `table:{table}:{canonical filter}`, or the href.
* Filters in JSON or base64 JSON compare by content (keys sorted, empty criteria dropped).
*/
export function linkTargetKey(href: string, primaryKeyOf: PrimaryKeyOf = () => 'id'): string {
const target = parseAppHref(href)
if (!target?.tableName) return `href:${href}`
if (target.recordId !== undefined) return `record:${target.tableName}:${target.recordId}`
const filter = target.filter as { criteria?: Array<{ fieldName?: string; operator?: string; values?: unknown[] }>; subFilters?: unknown[] } | undefined
const criteria = filter?.criteria ?? []
const pk = primaryKeyOf(target.tableName) ?? 'id'
if (criteria.length === 1 && !filter?.subFilters?.length && criteria[0]!.fieldName === pk && criteria[0]!.operator === 'EQUALS' && criteria[0]!.values?.length === 1) {
return `record:${target.tableName}:${String(criteria[0]!.values[0])}`
}
if (!criteria.length && !filter?.subFilters?.length) return `table:${target.tableName}:`
return `table:${target.tableName}:${canonical({ criteria, subFilters: filter?.subFilters ?? [], booleanOperator: (filter as { booleanOperator?: string }).booleanOperator ?? 'AND' })}`
}
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
if (value && typeof value === 'object') return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical((value as Record<string, unknown>)[k])}`).join(',')}}`
return JSON.stringify(value)
}
/** Parses inline markdown: links, bold, code; entities decoded in text and labels. */
function inlines(source: string, register: (label: string, href: string) => number): MdInline[] {
const out: MdInline[] = []
const push = (text: string, style?: MdInline['style']) => {
if (!text) return
const last = out[out.length - 1]
if (!style && last && !last.style) last.text += text
else out.push(style ? { text, style } : { text })
}
const pattern = /\[([^\]]+)\]\(([^)\s]+)\)|\*\*(.+?)\*\*|__(.+?)__|`([^`]+)`/g
let at = 0
for (const m of source.matchAll(pattern)) {
push(decodeEntities(source.slice(at, m.index)))
if (m[1] !== undefined) {
const label = decodeEntities(m[1]).replace(/\*\*|__|`/g, '')
const href = decodeEntities(m[2]!)
out.push({ text: label, style: 'link', href, link: register(label, href) })
} else if (m[3] !== undefined || m[4] !== undefined) push(decodeEntities(m[3] ?? m[4]!), 'bold')
else push(decodeEntities(m[5]!), 'code')
at = m.index + m[0].length
}
push(decodeEntities(source.slice(at)))
return out
}
const TABLE_RULE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/
function cells(line: string): string[] {
let text = line.trim()
if (text.startsWith('|')) text = text.slice(1)
if (text.endsWith('|') && !text.endsWith('\\|')) text = text.slice(0, -1)
// split on pipes that are not inside a link target or escaped
const out: string[] = []
let cell = ''
let depth = 0
for (let i = 0; i < text.length; i++) {
const ch = text[i]!
if (ch === '\\' && text[i + 1] === '|') { cell += '|'; i++; continue }
if (ch === '(' && text[i - 1] === ']') depth++
else if (ch === ')' && depth > 0) depth--
if (ch === '|' && depth === 0) { out.push(cell.trim()); cell = '' } else cell += ch
}
out.push(cell.trim())
return out
}
/** Parses the assistant's markdown into blocks and numbered links. */
export function parseMarkdown(text: string, primaryKeyOf?: PrimaryKeyOf): MdDocument {
const links: MdLink[] = []
const register = (label: string, href: string): number => {
const key = linkTargetKey(href, primaryKeyOf)
const found = links.findIndex((l) => l.key === key)
if (found >= 0) return found
links.push({ label, href, key })
return links.length - 1
}
const blocks: MdBlock[] = []
const seenParagraphs = new Set<string>()
const lines = text.replace(/\r\n?/g, '\n').split('\n')
let i = 0
const isList = (l: string) => /^\s*([-*+]|\d+[.)])\s+/.test(l)
const isHeading = (l: string) => /^\s{0,3}#{1,6}\s+/.test(l)
const isTableStart = (n: number) => lines[n]?.includes('|') && TABLE_RULE.test(lines[n + 1] ?? '')
while (i < lines.length) {
const line = lines[i]!
if (!line.trim()) { i++; continue }
if (isHeading(line)) {
const m = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line)!
blocks.push({ kind: 'heading', level: m[1]!.length, inlines: inlines(m[2]!, register) })
i++
continue
}
if (isTableStart(i)) {
const headers = cells(line).map((c) => inlines(c, register))
const aligns: MdAlign[] = cells(lines[i + 1]!).map((c) => c.endsWith(':') ? (c.startsWith(':') ? 'center' : 'right') : 'left')
const rows: MdInline[][][] = []
i += 2
while (i < lines.length && lines[i]!.trim() && lines[i]!.includes('|')) {
rows.push(cells(lines[i]!).map((c) => inlines(c, register)))
i++
}
blocks.push({ kind: 'table', headers, aligns, rows })
continue
}
if (isList(line)) {
const ordered = /^\s*\d+[.)]\s+/.test(line)
const items: MdInline[][] = []
while (i < lines.length && isList(lines[i]!)) {
let item = lines[i]!.replace(/^\s*([-*+]|\d+[.)])\s+/, '')
i++
// continuation lines indented under the item
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!) && !isList(lines[i]!)) item += ' ' + lines[i++]!.trim()
items.push(inlines(item, register))
}
blocks.push({ kind: 'list', ordered, items })
continue
}
const paragraph: string[] = []
while (i < lines.length && lines[i]!.trim() && !isHeading(lines[i]!) && !isList(lines[i]!) && !isTableStart(i)) paragraph.push(lines[i++]!.trim())
const joined = paragraph.join(' ')
const norm = decodeEntities(joined).replace(/\s+/g, ' ').trim()
if (seenParagraphs.has(norm)) continue
seenParagraphs.add(norm)
blocks.push({ kind: 'paragraph', inlines: inlines(joined, register) })
}
return { blocks, links }
}
/** The plain text of inlines; links as `label ↗` (or what `link` returns). */
export function inlineText(parts: MdInline[], link: (part: MdInline) => string = (p) => `${p.text} ↗`): string {
return parts.map((p) => (p.style === 'link' ? link(p) : p.text)).join('')
}
/** Word-wraps plain text to a width (long words are cut); `indent` goes before continuation lines. */
export function wrapPlain(text: string, width: number, first = '', rest = first): string[] {
const out: string[] = []
const w = Math.max(4, width)
let line = first
let empty = true
for (const word of text.split(/\s+/).filter(Boolean)) {
const prefixLen = (out.length ? rest : first).length
if (!empty && line.length + 1 + word.length > w) {
out.push(line)
line = rest
empty = true
}
let piece = word
while ((empty ? line.length : line.length + 1) + piece.length > w && piece.length > w - prefixLen) {
const room = w - line.length - (empty ? 0 : 1)
line += (empty ? '' : ' ') + piece.slice(0, room)
out.push(line)
line = rest
piece = piece.slice(room)
empty = true
}
line += (empty ? '' : ' ') + piece
empty = false
}
if (!empty || !out.length) out.push(line)
return out
}
/**
* The document as terminal text for a width: headings and paragraphs wrapped, lists with `-` or
* `1.`, tables laid out like the CLI's sub-tables (columns that do not fit are left out, cells cut
* with `…`). `link` renders a link (default `label ↗`).
*/
export function markdownText(doc: MdDocument, width = 100, link?: (part: MdInline) => string): string {
const out: string[] = []
for (const block of doc.blocks) {
if (out.length) out.push('')
if (block.kind === 'heading') out.push(...wrapPlain(inlineText(block.inlines, link), width))
else if (block.kind === 'paragraph') out.push(...wrapPlain(inlineText(block.inlines, link), width))
else if (block.kind === 'list') {
block.items.forEach((item, n) => {
const bullet = block.ordered ? `${n + 1}. ` : '- '
out.push(...wrapPlain(inlineText(item, link), width, bullet, ' '.repeat(bullet.length)))
})
} else {
const columns = block.headers.map((_, i) => `c${i}`)
const rows = block.rows.map((r) => Object.fromEntries(columns.map((c, i) => [c, inlineText(r[i] ?? [], link)])))
const { text, hidden } = layoutTable(rows, columns, block.headers.map((h) => inlineText(h, link)), { width, maxCell: Math.max(8, width) })
out.push(...text.split('\n'))
if (hidden.length) out.push(`(${hidden.length} more column${hidden.length === 1 ? '' : 's'} do not fit)`)
}
}
return out.join('\n')
}
/**
* An assistant answer as a document, plus the answer's links that the text does not already show
* (records and tables from tool results), once per target: the "→" list a client draws under the text.
* The document's links come first, then these, so numbering them in that order is reading order.
*/
export function answerDocument(answer: { text: string; links: Array<{ label: string; href: string }> }, primaryKeyOf?: PrimaryKeyOf): { doc: MdDocument; extraLinks: MdLink[] } {
const doc = parseMarkdown(answer.text, primaryKeyOf)
const keys = new Set(doc.links.map((l) => l.key))
const extraLinks: MdLink[] = []
for (const link of answer.links) {
const key = linkTargetKey(link.href, primaryKeyOf)
if (keys.has(key)) continue
keys.add(key)
extraLinks.push({ label: link.label, href: link.href, key })
}
return { doc, extraLinks }
}
@@ -0,0 +1,85 @@
{
"conversationId": 4,
"text": "3 services are not fully healthy&#58; one degraded &#40;email&#41; and two with unknown status&#46; None are down&#46;\n\nUnhealthy services\n\n| Service | Status | Since | Checks up | Degraded | Unknown |\n| --- | --- | --- | ---: | ---: | ---: |\n| [email](/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B4%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D) | DEGRADED | 2026-11-01 11&#58;25 UTC | 0 | 1 | 0 |\n| [db&#95;performance](/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B3%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D) | UNKNOWN | 2026-11-01 17&#58;25 UTC | 2 | 0 | 1 |\n| [esb](/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B10%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D) | UNKNOWN | 2026-11-01 11&#58;23 UTC | 1 | 0 | 1 |\n\n- [Open unhealthy components](/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22status%22%2C%22operator%22%3A%22NOT_EQUALS%22%2C%22values%22%3A%5B%22UP%22%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D)\nThree services aren't fully healthy right now:\n\n- **email** is degraded. Its only check is degraded.\n- **db_performance** is unknown. One of its three checks has no status.\n- **esb** is unknown. One of its two checks has no status.\n\nNo service is down.",
"status": "COMPLETE",
"notices": [],
"numbers": [],
"links": [
{
"label": "db_performance (Health Components)",
"href": "/app/health_component/3",
"kind": "record",
"tableName": "health_component",
"recordId": "3"
},
{
"label": "email (Health Components)",
"href": "/app/health_component/4",
"kind": "record",
"tableName": "health_component",
"recordId": "4"
},
{
"label": "esb (Health Components)",
"href": "/app/health_component/10",
"kind": "record",
"tableName": "health_component",
"recordId": "10"
},
{
"label": "Health Components (3)",
"href": "/app/health_component?filter=eyJjcml0ZXJpYSI6W3siZmllbGROYW1lIjoic3RhdHVzIiwib3BlcmF0b3IiOiJOT1RfRVFVQUxTIiwidmFsdWVzIjpbIlVQIl19XSwiYm9vbGVhbk9wZXJhdG9yIjoiQU5EIn0%3D",
"kind": "table",
"tableName": "health_component"
},
{
"label": "email · DEGRADED",
"href": "/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B4%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D",
"kind": "table",
"tableName": "health_component"
},
{
"label": "db_performance · UNKNOWN",
"href": "/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B3%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D",
"kind": "table",
"tableName": "health_component"
},
{
"label": "esb · UNKNOWN",
"href": "/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B10%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D",
"kind": "table",
"tableName": "health_component"
},
{
"label": "Open unhealthy components",
"href": "/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22status%22%2C%22operator%22%3A%22NOT_EQUALS%22%2C%22values%22%3A%5B%22UP%22%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D",
"kind": "table",
"tableName": "health_component"
}
],
"tools": [
{
"name": "describe_table",
"summary": "Described Health Components",
"outcome": "OK"
},
{
"name": "query_records",
"summary": "Queried Health Components · 3 rows",
"outcome": "OK"
},
{
"name": "present_results",
"summary": "Presented results",
"outcome": "OK"
}
],
"turnId": 7,
"messageId": 14,
"usage": {
"inputTokens": 10,
"outputTokens": 729,
"costMicros": 34021
},
"elapsedMs": 7140
}
+63
View File
@@ -0,0 +1,63 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
import { answerDocument, linkTargetKey, markdownText, parseMarkdown, wrapPlain } from '../render/markdown.js'
/** The answer to "what services are not healthy right now?" as 8078 gave it (qqq assistant ask --json). */
const fixture = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'src', 'test', 'fixtures', 'assistant-unhealthy.json'), 'utf8')) as { text: string; links: Array<{ label: string; href: string }> }
test('before: the raw answer has entities, a pipe table and URL-encoded link targets', () => {
assert.match(fixture.text, /healthy&#58;/)
assert.match(fixture.text, /\| --- \| --- \|/)
assert.match(fixture.text, /\(\/app\/health_component\?filter=%7B/)
assert.equal(fixture.links.length, 8)
})
test('after: entities decoded, the table laid out, links as label ↗ and never their URL', () => {
const { doc, extraLinks } = answerDocument(fixture)
const text = markdownText(doc, 100)
assert.doesNotMatch(text, /&#|\/app\/|%7B|\| ---/)
assert.match(text, /^3 services are not fully healthy: one degraded \(email\) and two with unknown status\. None are down\./)
assert.match(text, /Service\s+Status\s+Since\s+Checks up\s+Degraded\s+Unknown/)
assert.match(text, /db_performance ↗\s+UNKNOWN\s+2026-11-01 17:25 UTC/)
assert.match(text, /- Open unhealthy components ↗/)
assert.match(text, /- email is degraded\. Its only check is degraded\./)
assert.ok(text.split('\n').every((l) => [...l].length <= 100))
// four targets in reading order; the tool results' eight links are all the same four (records = id filters)
assert.deepEqual(doc.links.map((l) => l.label), ['email', 'db_performance', 'esb', 'Open unhealthy components'])
assert.deepEqual(extraLinks, [])
})
test('narrow: the table drops what does not fit and says so', () => {
const text = markdownText(answerDocument(fixture).doc, 40)
assert.ok(text.split('\n').every((l) => [...l].length <= 40))
assert.match(text, /more columns? do not fit/)
})
test('duplicate paragraphs collapse; lists, headings, bold and code', () => {
const doc = parseMarkdown('## Totals\n\nThere are **42** `customers`.\n\nThere are **42** `customers`.\n\n1. one\n2. two\n')
assert.deepEqual(doc.blocks.map((b) => b.kind), ['heading', 'paragraph', 'list'])
assert.deepEqual((doc.blocks[1] as { inlines: Array<{ style?: string }> }).inlines.map((i) => i.style ?? ''), ['', 'bold', '', 'code', ''])
assert.equal(markdownText(doc, 80), 'Totals\n\nThere are 42 customers.\n\n1. one\n2. two')
})
test('link targets: records, id filters, JSON and base64 filters compare by content', () => {
const json = '/app/t?filter=' + encodeURIComponent('{"booleanOperator":"AND","criteria":[{"fieldName":"s","operator":"EQUALS","values":["x"]}]}')
const b64 = '/app/t?filter=' + encodeURIComponent(Buffer.from('{"criteria":[{"fieldName":"s","operator":"EQUALS","values":["x"]}],"booleanOperator":"AND"}').toString('base64'))
assert.equal(linkTargetKey(json), linkTargetKey(b64))
assert.equal(linkTargetKey('/app/t/4'), linkTargetKey('/app/t?filter=' + encodeURIComponent('{"criteria":[{"fieldName":"id","operator":"EQUALS","values":[4]}]}')))
assert.notEqual(linkTargetKey('/app/t/4', () => 'code'), linkTargetKey('/app/t?filter=' + encodeURIComponent('{"criteria":[{"fieldName":"id","operator":"EQUALS","values":[4]}]}'), () => 'code'))
assert.equal(linkTargetKey('/app/t'), 'table:t:')
})
test('wrapping keeps the prefix and cuts long words', () => {
assert.deepEqual(wrapPlain('alpha beta gamma', 11, '- ', ' '), ['- alpha', ' beta', ' gamma'])
assert.deepEqual(wrapPlain('abcdefghij', 6), ['abcdef', 'ghij'])
})
+56 -1
View File
@@ -10,7 +10,7 @@ import { join } from 'node:path'
import { test } from 'node:test'
import type { CallInput, CallResult } from '../api/call.js'
import { ExtensionNotInstalledError, ExtensionRegistry, knownExtension } from '../extensions/registry.js'
import { MAX_LIST_ROWS, Printing, canPrintLists, canPrintTo, listScopeChoices, tableListValues, isFinalJobState, jobIdsOf, jobOf, jobStateSummary, printProcessName, printerOf, readRememberedPrinter, resolvePrinter, tableHasPrint, whyNotPrintable, writeRememberedPrinter, type PrintingTransport } from '../extensions/printing.js'
import { MAX_LIST_ROWS, Printing, canSaveAsPdf, chromeValues, renderValues, canPrintLists, canPrintTo, listScopeChoices, tableListValues, isFinalJobState, jobIdsOf, jobOf, jobStateSummary, printProcessName, printerOf, readRememberedPrinter, resolvePrinter, tableHasPrint, whyNotPrintable, writeRememberedPrinter, type PrintingTransport } from '../extensions/printing.js'
import { readConfig, upsertProfile } from '../session/profiles.js'
const withPrinting = ExtensionRegistry.fromVersion({ extensions: [{ name: 'printing', version: '2.0.0' }] })
@@ -201,3 +201,58 @@ test('grid print choices: all rows first when filtered or small, this page first
assert.equal(canPrintLists({ processes: { printingPrintTableList: {}, printingPrintText: {} } }), true)
assert.equal(canPrintLists({ processes: { printingPrintTableList: {} } }), false)
})
test('save as PDF: render values for a record, a list (no printer) and a text page', () => {
assert.deepEqual(renderValues({ kind: 'record', table: 'customer', id: '1' }), { kind: 'RECORD', tableName: 'customer', recordId: '1' })
const list = renderValues({ kind: 'list', list: { table: 'person', columns: ['id'], filter: { criteria: [], orderBys: [{ fieldName: 'id', isAscending: true }] }, scope: { kind: 'all' }, title: 'Person' } })
assert.equal(list.kind, 'LIST')
assert.equal(list.scope, 'ALL')
assert.equal(list.printingPrinterId, undefined)
assert.equal(list.printingCopies, undefined)
assert.deepEqual(renderValues({ kind: 'text', title: 'Home', text: 'x' }), { kind: 'TEXT', title: 'Home', text: 'x', orientation: 'PORTRAIT', pageSize: 'LETTER' })
assert.equal(canSaveAsPdf({ processes: { printingRenderToPdf: {} } }), true)
assert.equal(canSaveAsPdf({ processes: {} }), false)
})
test('save as PDF: renders on the server, downloads the file and names it with the download stamp', async () => {
const downloads: unknown[] = []
const { runs, transport } = fake(() => ({}), async () => ({ downloadFileName: 'Customer-Ada-Lovelace.pdf', serverFilePath: '/tmp/qqq-print-1.pdf', printingMessage: 'Saved the first 10000 of 12000 rows' }))
transport.download = async (reference) => {
downloads.push(reference)
return { bytes: new TextEncoder().encode('%PDF-1.7'), contentType: 'application/pdf' }
}
const printing = new Printing(withPrinting, transport)
const pdf = await printing.renderToPdf({ kind: 'record', table: 'customer', id: '1' }, new Date(2026, 10, 1, 12, 3, 1))
assert.equal(pdf.fileName, 'Customer-Ada-Lovelace_2026-11-01_120301.pdf')
assert.equal(pdf.serverName, 'Customer-Ada-Lovelace.pdf')
assert.equal(new TextDecoder().decode(pdf.bytes), '%PDF-1.7')
assert.equal(pdf.message, 'Saved the first 10000 of 12000 rows')
assert.deepEqual(runs[0], { name: 'printingRenderToPdf', values: { kind: 'RECORD', tableName: 'customer', recordId: '1' }, recordIds: [] })
assert.deepEqual(downloads, [{ fileName: 'Customer-Ada-Lovelace.pdf', query: { filePath: '/tmp/qqq-print-1.pdf' } }])
await assert.rejects(printing.renderToPdf({ kind: 'text', title: 't', text: ' ' }), /empty/)
delete transport.download
await assert.rejects(printing.renderToPdf({ kind: 'record', table: 'customer', id: '1' }), /cannot download/)
await assert.rejects(new Printing(without, transport).renderToPdf({ kind: 'record', table: 'customer', id: '1' }), ExtensionNotInstalledError)
const { transport: none } = fake(() => ({}), async () => ({}))
none.download = async () => ({ bytes: new Uint8Array(), contentType: '' })
await assert.rejects(new Printing(withPrinting, none).renderToPdf({ kind: 'record', table: 'customer', id: '1' }), /no PDF/)
})
test('the app header and footer travel with every request; blank fields are left to the server', async () => {
const chrome = { app: 'QQQ All', title: 'Sales › Customer › Ada Lovelace', server: 'local (localhost:8078)', user: 'Admin', printedAt: '2026-11-01 12:03', footerLeft: 'qqq ui 1.0.1 · local', footerNote: ' ' }
assert.deepEqual(chromeValues(chrome), { headerApp: 'QQQ All', headerTitle: 'Sales › Customer › Ada Lovelace', headerServer: 'local (localhost:8078)', headerUser: 'Admin', headerPrintedAt: '2026-11-01 12:03', footerLeft: 'qqq ui 1.0.1 · local' })
assert.deepEqual(chromeValues(undefined), {})
const sections = [{ title: 'Recently viewed', lines: ['Ada Lovelace'] }]
const text = renderValues({ kind: 'text', title: 'Home', text: 'raw', sections }, chrome)
assert.equal(text.sections, JSON.stringify(sections))
assert.equal(text.headerApp, 'QQQ All')
assert.equal(renderValues({ kind: 'record', table: 'customer', id: '1' }, chrome).headerTitle, 'Sales › Customer › Ada Lovelace')
const { runs, transport } = fake(() => ({}), async () => ({ printingJobIds: 'j1' }))
const printing = new Printing(withPrinting, transport)
await printing.print({ table: 'customer', ids: ['1'], printer: 'p1', chrome })
await printing.printText({ title: 'Home', text: 'raw', sections, printer: 'p1', chrome })
await printing.printTableList({ table: 'person', columns: ['id'], filter: {}, scope: { kind: 'all' }, printer: 'p1', chrome: { ...chrome, footerNote: 'Filter: x' } })
assert.equal(runs[0]!.values.headerUser, 'Admin')
assert.equal(runs[1]!.values.sections, JSON.stringify(sections))
assert.equal(runs[2]!.values.footerNote, 'Filter: x')
})
+48 -20
View File
@@ -84,11 +84,19 @@ passes it on — most do not, so Ctrl+P is the key to rely on):
selected row**. The server prints up to 10,000 rows (more prints the first 10,000 and says so), repeats
the header on every page, turns the page landscape when the columns need it and puts the table, filter,
sort, row count, user and time in the header and footer;
- any other screen (home, dashboards, the developer view, SQL results, process results, output) prints the
**screen as text**: a monospace PDF of what is drawn, without the status line and F-key bar, sized so a
wide terminal keeps its lines.
- any other screen (home, dashboards, the developer view, SQL results, process results, Search, History,
output) first asks **Print what?**: **Content** (the default) prints the screen's panels as a document —
each panel a section under its title, no Apps sidebar, title bar, key lines or borders, wrapped to the
page — and **Screen** prints the terminal as drawn (monospace, sized so a wide terminal keeps its lines).
Then the printer picker: printers that can print now (approved, exposed by a connected print agent, PDF)
Every page carries the app's **header** (the instance's name, the screen's breadcrumb such as `People ›
Person › Ada Lovelace`, the server, you, and the time in your date format) and **footer** (`qqq ui 1.0.1 ·
<server>`, a grid's filter and sort, page n of m).
Then the printer picker, with **Save as PDF…** first: it renders the same document on the server and saves
it the way downloads and exports are saved — the save dialog (or, with `download-mode silent`, straight to
the download folder), the stamped name (`Customer-Ada-Lovelace_2026-11-01_120301.pdf`), never overwriting
without asking. `:print pdf` saves without the picker. Then printers that can print now (approved, exposed by a connected print agent, PDF)
come first, the others are listed with the reason and cannot be picked; the printer chosen is remembered
per profile and preselected next time, marked "(last used)". `:print <printer>` skips the picker (name,
unique prefix or id). The title bar and status line show `print 1/3 RECEIVED` while jobs run, and a flash
@@ -350,31 +358,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 +485,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 +505,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
+71
View File
@@ -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')}`)
+5 -29
View File
@@ -5,11 +5,13 @@
import { useKeyboard, usePaste, useTerminalDimensions } from '@opentui/react'
import { Component, useEffect, useReducer, type ReactNode } from 'react'
import type { AppTreeNode } from './client'
import { FKeyZone, PaneZone, StatusZone, useMouseDispatcher } from './mouse/app-zones'
import { MouseCapture, MouseLayer, MouseRoot } from './mouse/zone'
import { presenceText, usePresence } from './presence'
import type { AppStore } from './store'
import { breadcrumbs } from './breadcrumbs'
export { appPath, breadcrumbs } from './breadcrumbs'
import { glyphs, theme } from './theme'
import { pad, trunc } from './util/text'
@@ -39,32 +41,6 @@ export class ErrorBoundary extends Component<{ children: ReactNode; resetKey: st
}
}
/** The apps above a table, process or app in the app tree (labels, outermost first). */
export function appPath(nodes: AppTreeNode[], name: string, type: string, path: string[] = []): string[] | undefined {
for (const n of nodes) {
if (n.name === name && n.type === type) return path
if (n.children) {
const found = appPath(n.children, name, type, [...path, n.label])
if (found) return found
}
}
return undefined
}
/** The title-bar path of the top screen: its apps, the table, then the record or form. */
export function breadcrumbs(store: AppStore): string[] {
const top = store.screen as (import('./types').Screen & { tableName?: string; processName?: string; appName?: string }) | undefined
if (!top) return []
const tree = (store.meta?.appTree ?? []) as AppTreeNode[]
const tableLabel = top.tableName ? store.meta?.tables?.[top.tableName]?.label ?? top.tableName : undefined
if (top.kind === 'table') return [...(appPath(tree, top.tableName!, 'TABLE') ?? []), top.title()]
if (top.kind === 'record') return [...(appPath(tree, top.tableName!, 'TABLE') ?? []), tableLabel!, top.title()]
if (top.kind === 'form') return [...(appPath(tree, top.tableName!, 'TABLE') ?? []), top.title()]
if (top.kind === 'process') return [...(appPath(tree, top.processName!, 'PROCESS') ?? []), top.title()]
if (top.kind === 'app') return [...(appPath(tree, top.appName!, 'APP') ?? []), top.title()]
return [top.title()]
}
function TitleBar({ store, width }: { store: AppStore; width: number }) {
const t = theme()
const presence = usePresence(store.screen?.presenceScope?.())
@@ -112,7 +88,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 +100,7 @@ function FKeyBar({ store, width }: { store: AppStore; width: number }) {
<FKeyZone store={store} width={width} keys={keys.map((k) => k.n)}>
<text flexShrink={0} wrapMode="none">
{keys.map((k) => {
const w = k.n === 10 ? Math.max(0, width - used) : cell
const w = k === keys[keys.length - 1] ? Math.max(0, width - used) : cell
used += w
const num = String(k.n).padStart(2)
// a key that does nothing here still shows its global label, dimmed, so the bar teaches the keymap
+158 -16
View File
@@ -4,13 +4,16 @@
*/
/**
* The assistant panel's conversation as lines for a width: each question, the answer (wrapped,
* markdown markers dropped), its numbers and its links. Links are numbered across the whole
* conversation so the panel can move a cursor over them; every line fits the width it was built for.
* The assistant panel's conversation as lines for a width: each question, the answer's markdown
* rendered (entities decoded, headings, wrapped paragraphs, lists, **bold**, `code`, pipe tables laid
* out like the grid and clipped with `…`), its numbers, and the links. A link shows as `label ↗`,
* never its URL; links are numbered across the conversation in reading order, once per target (the
* text's links, then the tool results' links the text does not show), so the panel can move a cursor
* over them. Every line fits the width it was built for.
*/
import type { AssistantAnswer, AssistantPageContext } from '@qrun-io/qqq-core'
import { cellWidth } from '../util/text'
import { answerDocument, fitColumns, type AssistantAnswer, type AssistantPageContext, type MdBlock, type MdInline, type MdLink, type PrimaryKeyOf } from '@qrun-io/qqq-core'
import { cellWidth, trunc } from '../util/text'
/** One question and its answer (streaming until `answer.status` leaves RUNNING). */
export interface Exchange {
@@ -26,13 +29,22 @@ export interface Exchange {
startedAt: number
}
export type LineRole = 'question' | 'context' | 'text' | 'number' | 'link' | 'activity' | 'error' | 'meta' | 'blank'
export type LineRole = 'question' | 'context' | 'text' | 'heading' | 'header' | 'rule' | 'number' | 'link' | 'activity' | 'error' | 'meta' | 'blank'
/** A run of a line with one look; `link` is the link number it opens. */
export interface Seg {
text: string
style?: 'bold' | 'code' | 'link' | 'dim'
link?: number
}
export interface ChatLine {
/** The line as plain text (what the segments add up to). */
text: string
role: LineRole
/** Index into {@link chatLinks} for link lines. */
/** The first link on the line (a click opens it). */
link?: number
segs?: Seg[]
}
/** The answer's markdown as plain text (bold, code and heading markers dropped). */
@@ -74,15 +86,140 @@ export function wrap(text: string, width: number): string[] {
return out
}
/** Every link of the conversation, in order (what the link cursor moves over). */
export function chatLinks(exchanges: Exchange[]): Array<{ label: string; href: string }> {
return exchanges.flatMap((e) => e.answer?.links ?? [])
/** An answer's links in reading order with their offset into the conversation's numbering. */
function answerLinks(answer: AssistantAnswer, primaryKeyOf?: PrimaryKeyOf): { blocks: MdBlock[]; text: MdLink[]; extra: MdLink[] } {
const { doc, extraLinks } = answerDocument(answer, primaryKeyOf)
return { blocks: doc.blocks, text: doc.links, extra: extraLinks }
}
/** Every link of the conversation, in reading order, once per target per answer (what the link cursor moves over). */
export function chatLinks(exchanges: Exchange[], primaryKeyOf?: PrimaryKeyOf): Array<{ label: string; href: string }> {
return exchanges.flatMap((e) => {
if (!e.answer) return []
const { text, extra } = answerLinks(e.answer, primaryKeyOf)
return [...text, ...extra]
})
}
function segsFrom(inlines: MdInline[], offset: number): Seg[] {
return inlines.map((p) => p.style === 'link' ? { text: `${p.text} ↗`, style: 'link' as const, link: offset + p.link! } : { text: p.text, ...(p.style ? { style: p.style } : {}) })
}
/** Wraps styled runs to a width, word by word; `first`/`rest` go before the first and later lines. */
export function wrapSegs(segs: Seg[], width: number, role: LineRole, first = '', rest = first): ChatLine[] {
const words: Seg[] = []
for (const s of segs) {
for (const part of s.text.split(/(\s+)/)) if (part) words.push({ ...s, text: part })
}
const lines: ChatLine[] = []
let cur: Seg[] = [{ text: first }]
let used = cellWidth(first)
let content = false
let pendingSpace: Seg | undefined
const flush = () => {
lines.push(line(cur, role))
cur = [{ text: rest }]
used = cellWidth(rest)
content = false
pendingSpace = undefined
}
for (const word of words) {
if (/^\s+$/.test(word.text)) {
if (content) pendingSpace = { ...word, text: ' ' }
continue
}
let text = word.text
let w = cellWidth(text)
if (content && used + (pendingSpace ? 1 : 0) + w > width) flush()
if (pendingSpace && content) {
cur.push(pendingSpace)
used += 1
}
pendingSpace = undefined
while (used + w > width) {
let cut = ''
for (const ch of text) {
if (used + cellWidth(cut + ch) > width) break
cut += ch
}
if (!cut) break
cur.push({ ...word, text: cut })
flush()
text = text.slice(cut.length)
w = cellWidth(text)
}
if (text) {
cur.push({ ...word, text })
used += w
content = true
}
}
if (content) flush()
return lines
}
function line(segs: Seg[], role: LineRole): ChatLine {
const merged: Seg[] = []
for (const s of segs) {
if (!s.text) continue
const last = merged[merged.length - 1]
if (last && last.style === s.style && last.link === s.link) last.text += s.text
else merged.push({ ...s })
}
const link = merged.find((s) => s.link !== undefined)?.link
return { text: merged.map((s) => s.text).join(''), role, segs: merged, ...(link !== undefined ? { link } : {}) }
}
/** A pipe table laid out like the grid: columns fitted to the width (the rest left out), cells cut with `…`. */
function tableLines(block: Extract<MdBlock, { kind: 'table' }>, width: number, offset: number): ChatLine[] {
const cellSegs = (inl: MdInline[]) => segsFrom(inl, offset)
const plain = (segs: Seg[]) => segs.map((s) => s.text).join('').replace(/\s+/g, ' ')
const headers = block.headers.map((h) => plain(cellSegs(h)))
const rows = block.rows.map((r) => block.headers.map((_, i) => cellSegs(r[i] ?? [])))
const natural = headers.map((h, i) => Math.max(cellWidth(h), ...rows.map((r) => cellWidth(plain(r[i]!)))))
const { widths, shown } = fitColumns(natural, width)
const fit = (text: string, w: number, align: string) => {
const t = trunc(text, w)
const room = Math.max(0, w - cellWidth(t))
return align === 'right' ? ' '.repeat(room) + t : t + ' '.repeat(room)
}
const out: ChatLine[] = []
out.push(line(headers.slice(0, shown).flatMap((h, i) => [{ text: fit(h, widths[i]!, block.aligns[i] ?? 'left'), style: 'bold' as const }, { text: i < shown - 1 ? ' ' : '' }]), 'header'))
out.push(line([{ text: widths.map((w) => '─'.repeat(w)).join(' '), style: 'dim' }], 'rule'))
for (const r of rows) {
const segs: Seg[] = []
r.slice(0, shown).forEach((cell, i) => {
const text = fit(plain(cell), widths[i]!, block.aligns[i] ?? 'left')
const linked = cell.find((s) => s.link !== undefined)
segs.push(linked ? { text, style: 'link', link: linked.link } : { text })
if (i < shown - 1) segs.push({ text: ' ' })
})
out.push(line(segs, 'text'))
}
if (shown < headers.length) out.push(line([{ text: trunc(`(${headers.length - shown} more column${headers.length - shown === 1 ? '' : 's'}: ${headers.slice(shown).join(', ')})`, width), style: 'dim' }], 'meta'))
return out.map((l) => (cellWidth(l.text) > width ? { ...l, text: trunc(l.text, width), segs: [{ text: trunc(l.text, width), style: l.segs?.[0]?.style }] } : l))
}
/** An answer's markdown as lines (links numbered from `offset`). */
export function answerLines(blocks: MdBlock[], width: number, offset: number): ChatLine[] {
const out: ChatLine[] = []
for (const block of blocks) {
if (out.length) out.push({ text: '', role: 'blank' })
if (block.kind === 'heading') out.push(...wrapSegs(segsFrom(block.inlines, offset).map((s) => ({ ...s, style: s.style ?? 'bold' })), width, 'heading'))
else if (block.kind === 'paragraph') out.push(...wrapSegs(segsFrom(block.inlines, offset), width, 'text'))
else if (block.kind === 'list') block.items.forEach((item, n) => {
const bullet = block.ordered ? `${n + 1}. ` : '• '
out.push(...wrapSegs(segsFrom(item, offset), width, 'text', bullet, ' '.repeat(cellWidth(bullet))))
})
else out.push(...tableLines(block, width, offset))
}
return out
}
/** The conversation as lines for a width (the panel's inner width). */
export function chatLines(exchanges: Exchange[], width: number, now = Date.now()): ChatLine[] {
export function chatLines(exchanges: Exchange[], width: number, now = Date.now(), primaryKeyOf?: PrimaryKeyOf): ChatLine[] {
const lines: ChatLine[] = []
let linkIndex = 0
let offset = 0
const add = (text: string, role: LineRole, prefix = '', link?: number) => {
const rest = ' '.repeat(cellWidth(prefix))
wrap(text, width - cellWidth(prefix)).forEach((l, i) => lines.push({ text: (i === 0 ? prefix : rest) + l, role, ...(link !== undefined ? { link } : {}) }))
@@ -93,11 +230,16 @@ export function chatLines(exchanges: Exchange[], width: number, now = Date.now()
if (e.contextLabel) add(`on ${e.contextLabel}`, 'context', ' ')
const answer = e.answer
const running = !e.error && (!answer || answer.status === 'RUNNING')
const text = plainText(answer?.text ?? '').trim()
if (text) add(text, 'text')
if (running && !text) add(`${e.activity ?? 'thinking'}… ${Math.max(0, Math.round((now - e.startedAt) / 1000))} s`, 'activity')
const parsed = answer ? answerLinks(answer, primaryKeyOf) : { blocks: [], text: [], extra: [] }
if (parsed.blocks.length) lines.push(...answerLines(parsed.blocks, width, offset))
if (running && !parsed.blocks.length) add(`${e.activity ?? 'thinking'}… ${Math.max(0, Math.round((now - e.startedAt) / 1000))} s`, 'activity')
for (const num of answer?.numbers ?? []) add(`${num.label}: ${num.value}`, 'number', '# ')
for (const link of answer?.links ?? []) add(link.label, 'link', '→ ', linkIndex++)
// the tool results' links the text does not show, once per target
parsed.extra.forEach((link, i) => {
const index = offset + parsed.text.length + i
wrapSegs([{ text: `${link.label} ↗`, style: 'link', link: index }], width, 'link', '→ ', ' ').forEach((l) => lines.push(l))
})
offset += parsed.text.length + parsed.extra.length
for (const notice of answer?.notices ?? []) add(notice.text, notice.level === 'error' ? 'error' : 'meta', `${notice.level}: `)
if (e.error) add(e.error, 'error')
else if (answer?.error) add(`${answer.error.message} (${answer.error.code})`, 'error')
+66 -18
View File
@@ -24,8 +24,8 @@ import { wheelAsKeys } from '../mouse/keys'
import type { AppStore } from '../store'
import { borderProps, theme } from '../theme'
import type { FKeyLabels } from '../types'
import { errorText, hintLine, pad, trunc } from '../util/text'
import { chatLines, chatLinks, contextLabel, wrap, type ChatLine, type Exchange } from './model'
import { cellWidth, errorText, hintLine, pad, trunc } from '../util/text'
import { chatLines, chatLinks, contextLabel, wrap, type ChatLine, type Exchange, type Seg } from './model'
/** Below this terminal width the panel takes the whole body instead of a right-hand pane. */
export const SIDE_MIN_WIDTH = 110
@@ -92,9 +92,26 @@ export class AssistantPanel {
void this.loadConfig().then(() => this.ask(question))
}
/** The page under the panel as context: a record, or a grid with its filter. */
/** The context questions carry: the screen the panel was shown over (kept while hidden). */
private pinned?: AssistantPageContext
/**
* Takes the screen under the panel as the context: when the panel is shown, and while it is shown
* beside the screen (a link opened from it becomes the context). A screen without one (Home)
* keeps the context the panel had.
*/
capturePageContext(): void {
const context = this.screenContext()
if (context) this.pinned = context
}
/** The context the next question carries (none after Ctrl+D). */
pageContext(): AssistantPageContext | undefined {
if (this.dropContext) return undefined
return this.dropContext ? undefined : this.pinned
}
/** The screen's context: a record, or a grid with its filter. */
screenContext(): AssistantPageContext | undefined {
const screen = this.store.screen as unknown as { kind?: string; tableName?: string; primaryKey?: string; filter?: Record<string, unknown> } | undefined
if (screen?.kind === 'record' && screen.tableName && screen.primaryKey !== undefined) return assistantContextFor({ tableName: screen.tableName, recordId: screen.primaryKey })
if (screen?.kind === 'table' && screen.tableName) return assistantContextFor({ tableName: screen.tableName, filter: screen.filter })
@@ -170,9 +187,23 @@ export class AssistantPanel {
this.store.bump()
}
/** A table's primary key field from the metadata (links that filter on it are that record). */
readonly primaryKeyOf = (table: string): string | undefined => (this.store.meta?.tables?.[table] as { primaryKeyField?: string } | undefined)?.primaryKeyField
/** Every link of the conversation, in reading order, once per target. */
links(): Array<{ label: string; href: string }> {
return chatLinks(this.exchanges, this.primaryKeyOf)
}
/** `u` on a link: its address, shown (never opened). */
showUrl(index = this.linkCursor): void {
const link = this.links()[index]
if (link) this.store.message(`${link.label}\n${link.href}`, 'info', 'Link address')
}
/** Opens the link under the cursor in the screen beside (or, on a narrow terminal, under) the panel. */
openLink(index = this.linkCursor): void {
const link = chatLinks(this.exchanges)[index]
const link = this.links()[index]
if (!link) return
this.linkCursor = -1
this.store.focus = 'content'
@@ -198,13 +229,14 @@ export class AssistantPanel {
['Ctrl+D', 'send without / with the page context'],
['Ctrl+L', 'new conversation'],
['Ctrl+X', 'stop the answer'],
['Ctrl+G', 'back to the screen (the panel stays)'],
['Esc', 'close the panel (the conversation is kept)'],
['u', 'on a link: show its address'],
['F12 Ctrl+G', 'hide the panel (the conversation, context and draft are kept); again shows it'],
['Esc', 'hide the panel (on a link: back to the question line)'],
]
}
onKey(key: Key): boolean {
const links = chatLinks(this.exchanges).length
const links = this.links().length
if (key.ctrl && key.name === 'x') return this.stop(), true
if (key.ctrl && key.name === 'l') return this.clear(), true
if (key.ctrl && key.name === 'd') {
@@ -214,6 +246,7 @@ export class AssistantPanel {
}
switch (key.name) {
case 'escape':
// hides the panel; the draft on the question line stays for next time
if (this.linkCursor >= 0) this.linkCursor = -1
else this.store.closeAssistant()
return true
@@ -247,6 +280,7 @@ export class AssistantPanel {
break
}
if (key.name.startsWith('f') && /^f\d+$/.test(key.name)) return false
if (this.linkCursor >= 0 && !key.ctrl && !key.meta && key.sequence === 'u') return this.showUrl(), true
if (this.editor.handle(key)) {
this.linkCursor = -1
return true
@@ -285,15 +319,16 @@ export class AssistantPanel {
render(width: number, height: number, focused: boolean) {
const t = theme()
if (this.store.assistantLayout()?.mode === 'side') this.capturePageContext()
const inner = Math.max(10, width - 2)
const context = this.pageContext()
const label = this.label(context)
const ctxLine = this.dropContext ? 'no page context (Ctrl+D adds it)' : label ? `on: ${label} (Ctrl+D drops)` : 'no page context'
const bodyHeight = Math.max(1, height - 2 - 3)
this.lastHeight = bodyHeight
const all: ChatLine[] = this.exchanges.length ? chatLines(this.exchanges, inner) : this.intro().flatMap((x) => wrap(x.text, inner).map((text) => ({ ...x, text })))
const all: ChatLine[] = this.exchanges.length ? chatLines(this.exchanges, inner, Date.now(), this.primaryKeyOf) : this.intro().flatMap((x) => wrap(x.text, inner).map((text) => ({ ...x, text })))
if (this.pendingReveal && this.linkCursor >= 0) {
const at = all.findIndex((l) => l.link === this.linkCursor)
const at = all.findIndex((l) => l.segs?.some((x) => x.link === this.linkCursor) || l.link === this.linkCursor)
if (at >= 0) {
const bottom = all.length - this.scrollBack
if (at < bottom - bodyHeight) this.scrollBack = all.length - at - bodyHeight
@@ -306,27 +341,40 @@ export class AssistantPanel {
const shown = all.slice(Math.max(0, end - bodyHeight), end)
const color = (l: ChatLine): string => {
switch (l.role) {
case 'question': return t.acc
case 'context': case 'meta': case 'activity': return t.dim
case 'question': case 'heading': case 'header': return t.acc
case 'context': case 'meta': case 'activity': case 'rule': return t.dim
case 'number': return t.acc2
case 'link': return t.ok
case 'error': return t.err
default: return t.fg
}
}
const segColor = (l: ChatLine, seg: Seg): string => seg.style === 'link' ? t.ok : seg.style === 'code' ? t.acc2 : seg.style === 'dim' ? t.dim : color(l)
const rows = shown.map((l, i) => {
const selected = l.link !== undefined && l.link === this.linkCursor
const text = <text flexShrink={0} wrapMode="none" fg={selected ? t.selFg : color(l)} bg={selected ? t.selBg : undefined}>{pad(l.text, inner)}</text>
const used = l.segs ? l.segs.reduce((n, x) => n + cellWidth(x.text), 0) : 0
const body = l.segs
? (
<text flexShrink={0} wrapMode="none">
{l.segs.map((seg, k) => {
const selected = seg.link !== undefined && seg.link === this.linkCursor
const fg = selected ? t.selFg : segColor(l, seg)
const bg = selected ? t.selBg : undefined
return <span key={k} fg={fg} bg={bg}>{seg.style === 'bold' ? <strong>{seg.text}</strong> : seg.text}</span>
})}
<span>{' '.repeat(Math.max(0, inner - used))}</span>
</text>
)
: <text flexShrink={0} wrapMode="none" fg={color(l)}>{pad(l.text, inner)}</text>
return l.link === undefined
? <box key={i} height={1} width={inner}>{text}</box>
: <Zone key={i} onClick={() => { this.store.focus = 'assistant'; this.linkCursor = l.link!; this.openLink(l.link) }}><box height={1} width={inner}>{text}</box></Zone>
? <box key={i} height={1} width={inner}>{body}</box>
: <Zone key={i} onClick={() => { this.store.focus = 'assistant'; this.linkCursor = l.link!; this.openLink(l.link) }}><box height={1} width={inner}>{body}</box></Zone>
})
while (rows.length < bodyHeight) rows.push(<box key={`p${rows.length}`} height={1} width={inner}><text flexShrink={0} wrapMode="none">{pad('', inner)}</text></box>)
const view = this.editor.view(Math.max(1, inner - 2))
const ready = !this.config || this.config.enabled
const hint = this.linkCursor >= 0
? 'Enter opens · ↑↓ Tab links · Esc back to the question'
: this.streaming ? 'answering… Ctrl+X stops · PgUp PgDn scroll · Esc closes' : 'Enter asks · Tab links · ↑↓ history · Ctrl+L new · Esc closes'
? 'Enter opens · u address · ↑↓ Tab links · Esc back to the question'
: this.streaming ? 'answering… Ctrl+X stops · PgUp PgDn scroll · F12/Esc hides' : 'Enter asks · Tab links · ↑↓ history · Ctrl+L new · F12/Esc hides'
const title = `Assistant${this.config?.model && ready ? ` · ${this.config.model}` : ''}${this.scrollBack ? ` · ↑${this.scrollBack}` : ''}`
return (
<Zone
+35
View File
@@ -0,0 +1,35 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/** The title-bar path of a screen (apps, table, record), shared by the title bar and printed headers. */
import type { AppTreeNode } from './client'
import type { AppStore } from './store'
/** The apps above a table, process or app in the app tree (labels, outermost first). */
export function appPath(nodes: AppTreeNode[], name: string, type: string, path: string[] = []): string[] | undefined {
for (const n of nodes) {
if (n.name === name && n.type === type) return path
if (n.children) {
const found = appPath(n.children, name, type, [...path, n.label])
if (found) return found
}
}
return undefined
}
/** The title-bar path of the top screen: its apps, the table, then the record or form. */
export function breadcrumbs(store: AppStore): string[] {
const top = store.screen as (import('./types').Screen & { tableName?: string; processName?: string; appName?: string }) | undefined
if (!top) return []
const tree = (store.meta?.appTree ?? []) as AppTreeNode[]
const tableLabel = top.tableName ? store.meta?.tables?.[top.tableName]?.label ?? top.tableName : undefined
if (top.kind === 'table') return [...(appPath(tree, top.tableName!, 'TABLE') ?? []), top.title()]
if (top.kind === 'record') return [...(appPath(tree, top.tableName!, 'TABLE') ?? []), tableLabel!, top.title()]
if (top.kind === 'form') return [...(appPath(tree, top.tableName!, 'TABLE') ?? []), top.title()]
if (top.kind === 'process') return [...(appPath(tree, top.processName!, 'PROCESS') ?? []), top.title()]
if (top.kind === 'app') return [...(appPath(tree, top.appName!, 'APP') ?? []), top.title()]
return [top.title()]
}
+6 -3
View File
@@ -63,7 +63,7 @@ export const TUI_COMMAND_HELP: Record<string, string> = {
shortcuts: 'single-key shortcuts on or off', lock: 'lock the screen', pin: 'set or remove the lock PIN', idle: 'screen saver after idle minutes', refresh: 'reload this screen',
filter: 'filter the grid (grammar)', export: 'export the grid or result', jobs: 'your jobs', printjobs: 'print jobs', print: 'print what you see (or :print <printer>)', profile: 'switch profile', servers: 'saved servers (Ctrl+O)',
history: 'everything you did (CLI and TUI)', server: 'connect to a server URL', signout: 'sign out', connection: 'connection details', open: 'open a table', record: 'open a record by key', app: 'open an app',
sql: 'SQL Runner (statement to run, open <notebook id>)', ask: 'the Assistant panel (Ctrl+G); with a question, asks it about this screen', saver: 'the screen saver', doctor: 'check the terminal', views: 'saved views of this table', share: 'share this record', helpkeys: 'help-authoring mode',
sql: 'SQL Runner (statement to run, open <notebook id>)', ask: 'show or hide the Assistant panel (F12, Ctrl+G); with a question, asks it about this screen', saver: 'the screen saver', doctor: 'check the terminal', views: 'saved views of this table', share: 'share this record', helpkeys: 'help-authoring mode',
}
/** CLI commands the `:` line does not offer (they make no sense inside the TUI). */
@@ -426,7 +426,10 @@ export class Commands implements CommandsLike {
case 'open': return rest[0] ? openTable(s, rest[0]) : fail('open <table>')
case 'app': return rest[0] ? openApp(s, rest[0]) : fail('app <name>')
case 'sql': return sqlCommand(s, line.replace(/^sql\b/, ''))
case 'ask': return s.openAssistant(line.replace(/^ask\b/, ''))
case 'ask': {
const question = line.replace(/^ask\b/, '').trim()
return question ? s.openAssistant(question) : s.toggleAssistant()
}
case 'report':
if (rest[0] === 'run' && rest[1] && !rest.includes('--format') && !rest.includes('--out')) return openReport(s, rest[1])
break
@@ -728,7 +731,7 @@ export class Commands implements CommandsLike {
out.push({ section: 'Work', label: 'My work', disabled: s.extensions.has('collaboration') ? undefined : 'needs the collaboration extension' })
out.push({ section: 'Work', label: 'History', command: 'history', detail: 'everything you did, CLI and TUI', run: () => openHistory(s) })
out.push(...sqlMenuEntries(s))
out.push({ id: 'work:assistant', section: 'Work', label: 'Assistant', key: 'Ctrl+G', command: 'ask <question>', detail: 'ask about your data, with this screen as context', disabled: s.extensions.has('assistant') ? undefined : 'needs the assistant extension (qbit-assistant)', run: () => s.openAssistant() })
out.push({ id: 'work:assistant', section: 'Work', label: 'Assistant', key: 'F12', command: 'ask <question>', detail: 'F12 assistant (Ctrl+G too): ask about your data, with this screen as context', disabled: s.extensions.has('assistant') ? undefined : 'needs the assistant extension (qbit-assistant)', run: () => s.openAssistant() })
for (const e of s.extensions.all()) out.push({ section: 'Work', label: `Extension: ${e.name} ${e.version}`, detail: e.description, command: `api --list`, run: () => s.message(`${e.name} ${e.version}\n${e.description ?? ''}\nPath: ${e.pathPrefix ?? ''}\nIts panels are not drawn by this TUI yet; its operations are reachable with :api.`, 'info', 'Extension') })
// Connection
out.push({ section: 'Connection', label: `Server ${s.status.server}`, detail: `UI API ${s.status.apiVersion}`, command: 'connection', run: () => this.connection() })
+1 -1
View File
@@ -26,7 +26,7 @@ const ANYWHERE: KeyRow[] = [
['F9', 'menu: areas, then their actions'],
['Ctrl+P PrtSc', 'print what you see: a record sheet, a table list (all rows or this page), or the screen; Ctrl+P always works, Print Screen only where the terminal passes it on'],
['Ctrl+O', 'servers: switch, add, edit'],
['Ctrl+G', 'Assistant panel: ask about your data (this screen is the context); :ask <question>'],
['F12 Ctrl+G', 'F12 assistant: show or hide the Assistant panel (this screen is the context); :ask <question>'],
[':', 'command line; Tab completes, ↑↓ history, Ctrl+R searches it; :history lists all you did'],
['F10', 'quit, reset or cancel'],
['Ctrl+C', 'quit now'],
+5
View File
@@ -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,
+238 -20
View File
@@ -17,12 +17,16 @@
*/
import { writeFileSync } from 'node:fs'
import { exportFileName, FAILED_JOB_STATES, MAX_LIST_ROWS, Printing, canPrintLists, isFinalJobState, listScopeChoices, readRememberedPrinter, resolvePrinter, runProcess, tableHasPrint, whyNotPrintable, writeRememberedPrinter, type ListScope, type Printer, type PrintJob, type PrintOutcome, type ProcessMeta } from '@qrun-io/qqq-core'
import { dirname, join } from 'node:path'
import { canSaveAsPdf, downloadProcessFile, formatDateValue, profileNameForServer, readConfig, saveDownload, type ContentSection, type PrintChrome, type RenderRequest, exportFileName, FAILED_JOB_STATES, MAX_LIST_ROWS, Printing, canPrintLists, isFinalJobState, listScopeChoices, readRememberedPrinter, resolvePrinter, runProcess, tableHasPrint, whyNotPrintable, writeRememberedPrinter, type ListScope, type Printer, type PrintJob, type PrintOutcome, type ProcessMeta } from '@qrun-io/qqq-core'
import { ChoiceOverlay, type Choice } from './overlays/basic'
import type { AppStore } from './store'
import type { Screen } from './types'
import { errorText } from './util/text'
import { saveFile } from './overlays/files-helpers'
import { breadcrumbs } from './breadcrumbs'
import { dateSettingsFor } from './controls/date-settings'
import { CLI_VERSION } from '@qrun-io/qqq-cli/program'
import { history } from './history/record'
/** The printing module of the store's server, or undefined without a client or the extension. */
@@ -35,6 +39,7 @@ export function printingOf(store: AppStore): Printing | undefined {
const meta = await client.processMeta(processName) as unknown as ProcessMeta
return (await runProcess({ baseUrl: client.server, fetch: client.fetch, meta, values, records: recordIds.length ? { ids: recordIds } : undefined, stepTimeoutMillis: 60000 })).values
},
download: (reference) => downloadProcessFile(client.server, client.fetch, reference),
})
}
@@ -66,17 +71,26 @@ export function printerChoices(printers: Printer[], rememberedId: string | undef
return { choices, initial: remembered >= 0 ? remembered : Math.max(0, firstPrintable) }
}
/** The first entry of the printer picker when the server can render to a PDF download. */
export const SAVE_AS_PDF = 'Save as PDF…'
/**
* The printer picker: printable printers first, the last used one on this profile preselected.
* With `hint` (`:print <printer>`) the printer is chosen by name, id or unique prefix without the
* picker. The choice is remembered for the profile, then `send` queues the print.
* The printer picker: "Save as PDF…" first (when the server can render to a download and the
* caller says what to render), then the printers, printable ones first, the last used one on this
* profile preselected. With `hint` (`:print <printer>`) the printer is chosen by name, id or unique
* prefix without the picker; `:print pdf` saves. A printer choice is remembered for the profile,
* then `send` queues the print.
*/
export function pickPrinter(store: AppStore, title: string, send: (printing: Printing, printer: Printer) => void, hint?: string): void {
export function pickPrinter(store: AppStore, title: string, send: (printing: Printing, printer: Printer) => void, hint?: string, renders?: RenderRequest[]): void {
const printing = printingOf(store)
if (!printing) return store.flash(`Printing needs ${store.client ? 'the printing extension' : 'a server connection'}.`, 'error')
const canSave = Boolean(renders?.length) && canSaveAsPdf(store.meta as never)
if (hint && /^(pdf|file)$/i.test(hint.trim())) {
return canSave ? savePdfs(store, printing, renders!) : store.flash('This server cannot render to PDF (it needs the printingRenderToPdf process of qbit-printing).', 'error')
}
const profile = store.status.profile
store.run(printing.printers(), (printers) => {
if (!printers.length) return store.message('No printers: no print agent has exposed a printer to this server yet.', 'info', 'Print')
if (!printers.length && !canSave) return store.message('No printers: no print agent has exposed a printer to this server yet.', 'info', 'Print')
const go = (printer: Printer) => {
writeRememberedPrinter(profile, { id: printer.id, name: printer.name })
send(printing, printer)
@@ -92,7 +106,44 @@ export function pickPrinter(store: AppStore, title: string, send: (printing: Pri
return why ? store.flash(`"${printer.name}" cannot print now: ${why}.`, 'error') : go(printer)
}
const { choices, initial } = printerChoices(printers, readRememberedPrinter(profile)?.id)
store.open(new ChoiceOverlay<Printer>(store, title, choices, go, 'printers that cannot print now are shown with the reason; the choice is remembered for this profile', initial))
const anyPrintable = choices.some((c) => !c.disabled)
const all: Choice<Printer | 'pdf'>[] = [
...(canSave ? [{ label: SAVE_AS_PDF, value: 'pdf' as const, detail: 'render it and save it like a download: download folder, file dialog' }] : []),
...choices,
]
const first = canSave && !anyPrintable ? 0 : initial + (canSave ? 1 : 0)
store.open(new ChoiceOverlay<Printer | 'pdf'>(store, title, all, (choice) => (choice === 'pdf' ? savePdfs(store, printing, renders!) : go(choice)),
'Save as PDF saves where downloads go; printers that cannot print now are shown with the reason; the printer is remembered for this profile', first))
})
}
/**
* Save as PDF: renders each document on the server and saves it as downloads and exports are
* saved: the save dialog (or, with silent downloads, the download folder), the stamped default
* name (`Customer-Ada-Lovelace_2026-11-01_120301.pdf`), no silent overwrite. More than one
* document go next to the first, each with its own stamped name.
*/
export function savePdfs(store: AppStore, printing: Printing, renders: RenderRequest[]): void {
store.flash(`Rendering ${renders.length === 1 ? 'the PDF' : `${renders.length} PDFs`}…`)
const chrome = chromeOf(store)
store.run(printing.renderToPdf(renders[0]!, new Date(), chrome), (first) => {
saveFile(store, renders.length === 1 ? 'Save as PDF' : `Save ${renders.length} PDFs (the others go next to this one)`, first.fileName, (path) => {
const saved = [saveDownload(path, first.bytes, { explicit: true })]
const notes = first.message ? [first.message] : []
const done = () => {
history.record(store, { kind: 'print', summary: `Saved ${saved.length === 1 ? saved[0] : `${saved.length} PDFs to ${dirname(saved[0]!)}`}` })
store.message([`Saved ${saved.length === 1 ? saved[0] : `${saved.length} PDFs to ${dirname(saved[0]!)}`} (${first.bytes.length.toLocaleString('en-US')} bytes${saved.length > 1 ? ' for the first' : ''}).`, ...notes].join('\n'), 'info', 'Save as PDF')
}
const rest = renders.slice(1)
if (!rest.length) return done()
store.run((async () => {
for (const request of rest) {
const pdf = await printing.renderToPdf(request, new Date(), chrome)
if (pdf.message) notes.push(pdf.message)
saved.push(saveDownload(join(dirname(path), pdf.fileName), pdf.bytes, { explicit: false }))
}
})(), done)
})
})
}
@@ -114,7 +165,7 @@ export function openPrintDialog(store: AppStore, table: string, ids: string[], w
const unavailable = printUnavailable(store, table)
if (!printingOf(store) || unavailable) return store.flash(`Print ${unavailable ?? 'needs a server connection'}.`, 'error')
if (!ids.length) return store.flash('Nothing to print: select records first (Space).', 'error')
pickPrinter(store, `Print ${what} on…`, (printing, printer) => queued(store, printing, printer, what, printing.print({ table, ids, printer: printer.id }).then((jobIds) => ({ jobIds }))), hint)
pickPrinter(store, `Print ${what} on…`, (printing, printer) => queued(store, printing, printer, what, printing.print({ table, ids, printer: printer.id, chrome: chromeOf(store) }).then((jobIds) => ({ jobIds }))), hint, ids.map((id) => ({ kind: 'record' as const, table, id })))
}
// ---------------------------------------------------------------------------------------------
@@ -161,8 +212,8 @@ export function printTargetOf(screen: Screen | undefined): PrintTarget {
* The text of the screen as drawn (the renderer's character frame), without the status line and
* the function-key bar, trailing spaces and trailing blank lines removed.
*/
export function captureScreenText(store: AppStore): string {
const frame = store.captureScreen?.() ?? ''
export function captureScreenText(store: AppStore, captured?: string): string {
const frame = captured ?? store.captureScreen?.() ?? ''
const lines = frame.replace(/\r/g, '').split('\n')
while (lines.length && !lines[lines.length - 1]!.trim()) lines.pop()
const body = lines.slice(0, Math.max(0, lines.length - 2)).map((l) => l.replace(/\s+$/, ''))
@@ -182,31 +233,198 @@ export function printScreen(store: AppStore, hint?: string): void {
if (target.kind === 'record') {
if (tableHasPrint(store.meta as never, target.table)) return openPrintDialog(store, target.table, target.ids, target.what, hint)
if (!canPrintLists(store.meta as never)) return saveInstead(store, target)
return printText(store, target.what, hint)
return askWhat(store, target.what, hint)
}
if (target.kind === 'text') return printText(store, target.title, hint)
if (target.kind === 'text') return askWhat(store, target.title, hint)
const grid = target.grid
const choices = listScopeChoices({ total: grid.total, pageIndex: grid.pageIndex, pageSize: grid.pageSize, pageRows: grid.pageRows, selected: grid.selected, filtered: Boolean(grid.filterText), recordSheets: tableHasPrint(store.meta as never, grid.table) })
const scoped = (scope: ListScope) => {
if (scope.kind === 'sheets') return openPrintDialog(store, grid.table, scope.ids, `${scope.ids.length} ${grid.label} records`, hint)
const what = scope.kind === 'all' ? `${grid.label}: ${grid.total === undefined ? 'all' : Math.min(grid.total, MAX_LIST_ROWS).toLocaleString('en-US')} rows` : scope.kind === 'page' ? `${grid.label}: page ${grid.pageIndex + 1}` : `${grid.label}: ${scope.ids.length} selected rows`
pickPrinter(store, `Print ${what} on…`, (p, printer) => queued(store, p, printer, what, p.printTableList({
const list = {
table: grid.table, columns: grid.columns.map((c) => c.name), widths: grid.columns.every((c) => c.width) ? grid.columns.map((c) => c.width!) : undefined,
filter: grid.filter, scope, title: grid.label, filterDescription: grid.filterText || undefined, printer: printer.id, primaryKeyField: grid.primaryKeyField,
})), hint)
filter: grid.filter, scope, title: grid.label, filterDescription: grid.filterText || undefined, primaryKeyField: grid.primaryKeyField,
}
pickPrinter(store, `Print ${what} on…`, (p, printer) => queued(store, p, printer, what, p.printTableList({ ...list, printer: printer.id, chrome: chromeOf(store) })), hint, [{ kind: 'list', list }])
}
if (grid.selectAll) return scoped({ kind: 'all' })
store.open(new ChoiceOverlay<ListScope>(store, `Print ${grid.label}: what?`, choices.map((c) => ({ label: c.label, value: c.value, detail: c.detail })), scoped, 'the table list uses the columns, filter and sort you see; the header repeats on every page'))
}
/** Prints the screen's text (monospace PDF). */
function printText(store: AppStore, title: string, hint?: string): void {
const text = captureScreenText(store)
if (!text.trim()) return store.flash('There is nothing on the screen to print.', 'error')
const wide = text.split('\n').some((l) => l.length > 110)
pickPrinter(store, `Print this screen (${title}) on…`, (p, printer) => queued(store, p, printer, `screen ${title}`, p.printText({ title, subtitle: `qqq ui · ${store.status.server.replace(/^https?:\/\//, '')}`, text, printer: printer.id, orientation: wide ? 'LANDSCAPE' : 'PORTRAIT' })), hint)
function printText(store: AppStore, title: string, text: string, sections?: ContentSection[], hint?: string): void {
const wide = !sections && text.split('\n').some((l) => l.length > 110)
const page = { title, text, sections, orientation: wide ? 'LANDSCAPE' as const : 'PORTRAIT' as const }
const chrome = chromeOf(store)
const what = sections ? `${title} (content)` : `screen ${title}`
pickPrinter(store, `Print ${sections ? 'the content of' : 'this screen'} (${title}) on…`, (p, printer) => queued(store, p, printer, what, p.printText({ ...page, printer: printer.id, chrome })), hint, [{ kind: 'text', ...page }])
}
// ---------------------------------------------------------------------------------------------
// the app's header and footer, and a screen's content as sections
// ---------------------------------------------------------------------------------------------
/**
* The header and footer every printed page carries: the app (instance branding, else the
* profile), the breadcrumb of the screen, the server (profile and host), the user, the time in
* the profile's date format; the footer `qqq ui <version> · <server>` and, for grids, the filter.
*/
export function chromeOf(store: AppStore, footerNote?: string, now: Date = new Date()): PrintChrome {
const branding = (store.meta as { branding?: { appName?: string } } | undefined)?.branding
let host = store.status.server
try {
host = new URL(store.status.server).host
} catch {
host = store.status.server.replace(/^https?:\/\//, '')
}
const profile = store.status.profile
const server = profile && profile !== host && profile !== profileNameForServer(store.status.server) ? `${profile} (${host})` : host
let printedAt = now.toISOString()
try {
printedAt = formatDateValue(now.toISOString(), 'DATE_TIME', dateSettingsFor(store), { withSeconds: false })
} catch {
printedAt = now.toISOString()
}
return {
app: branding?.appName ?? profileNote(profile) ?? 'QQQ',
title: breadcrumbs(store).join(' › '),
server,
user: store.status.user,
printedAt,
footerLeft: `qqq ui ${CLI_VERSION} · ${server}`,
footerNote,
}
}
/** The profile's description (its note in config.json), if it has one. */
function profileNote(profile: string): string | undefined {
try {
return readConfig().profiles[profile]?.note || undefined
} catch {
return undefined
}
}
const TOP_LEFT = new Set(['┌', '╭'])
const TOP_RIGHT = new Set(['┐', '╮'])
const BOTTOM_LEFT = new Set(['└', '╰'])
const BOX = /[─│┌┐└┘├┤┬┴┼╭╮╰╯═║╔╗╚╝╟╢╠╣▏▕]/g
interface Box { top: number; left: number; bottom: number; right: number; title: string }
/** Boxes drawn with single lines in a character grid (their title from the top edge). */
function boxesOf(grid: string[][]): Box[] {
const out: Box[] = []
for (let r = 0; r < grid.length; r++) {
const row = grid[r]!
for (let c = 0; c < row.length; c++) {
if (!TOP_LEFT.has(row[c]!)) continue
let right = -1
for (let x = c + 1; x < row.length; x++) {
if (TOP_RIGHT.has(row[x]!)) { right = x; break }
if (TOP_LEFT.has(row[x]!)) break
}
if (right < 0) continue
let bottom = -1
for (let y = r + 1; y <= grid.length; y++) {
// a panel cut off by the bottom of its region runs to the end
if (y === grid.length) { bottom = y; break }
const ch = grid[y]![c]
if (ch !== undefined && BOTTOM_LEFT.has(ch)) { bottom = y; break }
if (ch !== '│' && ch !== '║') { bottom = y; break }
}
if (bottom < 0) continue
const title = row.slice(c + 1, right).join('').replace(/^[─\s]+|[─\s]+$/g, '').trim()
out.push({ top: r, left: c, bottom, right, title })
}
}
return out
}
/** Text lines without box characters, trimmed; blank and border-only lines collapse. */
function cleanLines(lines: string[]): string[] {
const out: string[] = []
for (const raw of lines) {
const line = raw.replace(BOX, ' ').replace(/\s+$/, '')
if (!line.trim()) {
if (out.length && out[out.length - 1] !== '') out.push('')
continue
}
out.push(line)
}
while (out.length && out[out.length - 1] === '') out.pop()
const indent = Math.min(...out.filter((l) => l.trim()).map((l) => l.length - l.trimStart().length))
return Number.isFinite(indent) && indent > 0 ? out.map((l) => l.slice(indent)) : out
}
/**
* A screen's content as sections, read from the drawn frame: the content panel only (not the
* Apps sidebar, the title bar, the status line or the key bar), each inner panel as a section
* with its title and lines, the text around them as untitled sections, in reading order, borders
* removed and the panel's key hint dropped.
*/
export function contentSections(frame: string): ContentSection[] {
const lines = frame.replace(/\r/g, '').split('\n')
while (lines.length && !lines[lines.length - 1]!.trim()) lines.pop()
const body = lines.slice(1, Math.max(1, lines.length - 2))
const grid = body.map((l) => Array.from(l))
// the Apps sidebar ("Apps (Tab)", "Apps · type to find") is navigation, not content
const boxes = boxesOf(grid).filter((b) => !/^Apps(\s+\(|\s+·|$)/.test(b.title))
if (!boxes.length) {
const text = cleanLines(body)
return text.length ? [{ title: '', lines: text }] : []
}
const area = (b: Box) => (b.bottom - b.top) * (b.right - b.left)
const panel = boxes.reduce((a, b) => (area(b) > area(a) ? b : a))
const inside = boxes.filter((b) => b !== panel && b.top > panel.top && b.bottom <= panel.bottom && b.left >= panel.left && b.right <= panel.right)
const children = inside.filter((b) => !inside.some((o) => o !== b && b.top > o.top && b.bottom < o.bottom && b.left > o.left && b.right < o.right))
const sections: ContentSection[] = []
let free: string[] = []
const flush = () => {
const text = cleanLines(free)
if (text.length) sections.push({ title: '', lines: text })
free = []
}
const freeRows: Array<{ row: number; text: string }> = []
for (let r = panel.top + 1; r < panel.bottom; r++) {
const cells = grid[r]!.slice(panel.left + 1, panel.right).map((ch, i) => (children.some((b) => r >= b.top && r <= b.bottom && panel.left + 1 + i >= b.left && panel.left + 1 + i <= b.right) ? ' ' : ch))
freeRows.push({ row: r, text: cells.join('') })
}
// the panel's last line is its key hint ("a actions · … · Esc back"): not content
const lastText = [...freeRows].reverse().find((f) => f.text.trim())
if (lastText && /(Esc|a actions| · )/.test(lastText.text) && /(Esc|actions|Enter)/.test(lastText.text)) lastText.text = ''
const starts = new Map<number, Box[]>()
for (const b of children) starts.set(b.top, [...(starts.get(b.top) ?? []), b].sort((x, y) => x.left - y.left))
for (const f of freeRows) {
const opening = starts.get(f.row)
if (opening) {
flush()
for (const b of opening) {
const text = cleanLines(grid.slice(b.top + 1, b.bottom).map((row) => row.slice(b.left + 1, b.right).join('')))
if (b.title || text.length) sections.push({ title: b.title, lines: text })
}
}
free.push(f.text)
}
flush()
return sections
}
/** Ctrl+P on a screen that is not a record or a grid: its content as a document, or the screen as drawn. */
function askWhat(store: AppStore, title: string, hint?: string): void {
const frame = store.captureScreen?.() ?? ''
const sections = contentSections(frame)
const screen = captureScreenText(store, frame)
if (!screen.trim()) return store.flash('There is nothing on the screen to print.', 'error')
if (hint || !sections.length) return printText(store, title, screen, sections.length ? sections : undefined, hint)
store.open(new ChoiceOverlay<'content' | 'screen'>(store, 'Print what?', [
{ label: 'Content', value: 'content', detail: "the screen's panels as a document: no navigation, no borders" },
{ label: 'Screen', value: 'screen', detail: 'the terminal as you see it, line for line' },
], (what) => printText(store, title, screen, what === 'content' ? sections : undefined), 'both carry the app header and footer'))
}
/** Without printing: the grid's export, else the screen text saved as a file. */
function saveInstead(store: AppStore, target: PrintTarget): void {
const screen = store.screen as { exportDialog?: () => void } | undefined
+26 -16
View File
@@ -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 })
}
}
+65 -54
View File
@@ -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
+73 -17
View File
@@ -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
View File
@@ -25,6 +25,9 @@ import { errorText } from './util/text'
import { SQL_EXTENSION, hideRunnerApps } from './sql/apps'
import { AssistantPanel, SIDE_MIN_WIDTH, sideWidth } from './assistant/panel'
/** From this width the F-key bar also has the 11 and 12 slots (12 is the Assistant). */
export const FKEY_WIDE = 120
export interface Settings {
theme: string
ascii: boolean
@@ -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() {
+132 -3
View File
@@ -6,7 +6,10 @@
import { afterEach, beforeAll, expect, test } from 'bun:test'
import { testRender } from '@opentui/react/test-utils'
import { App } from '../src/app'
import { chatLines, contextLabel, plainText, wrap } from '../src/assistant/model'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { chatLines, chatLinks, contextLabel, plainText, wrap } from '../src/assistant/model'
import { fkeyAt } from '../src/mouse/hit'
import { boot, parseArgs } from '../src/boot'
import { linkFilter } from '../src/links'
import { fakeFetch, type FakeLog } from './fake-server'
@@ -26,7 +29,7 @@ const TURN = sse([
{ type: 'message.end', messageId: 2, status: 'COMPLETE' },
])
interface AssistantFake { installed: boolean; config: J; turns: J[]; conversations: J[] }
interface AssistantFake { installed: boolean; config: J; turns: J[]; conversations: J[]; stream?: string }
function assistantFetch(log: FakeLog, state: AssistantFake): typeof fetch {
const base = fakeFetch(log)
@@ -44,7 +47,7 @@ function assistantFetch(log: FakeLog, state: AssistantFake): typeof fetch {
}
if (/^\/assistant\/conversations\/\d+\/turns$/.test(path)) {
state.turns.push(body ?? {})
return new Response(TURN, { status: 200, headers: { 'content-type': 'text/event-stream' } })
return new Response(state.stream ?? TURN, { status: 200, headers: { 'content-type': 'text/event-stream' } })
}
return json({ code: 'NOT_FOUND', message: path }, 404)
}) as typeof fetch
@@ -182,3 +185,129 @@ test('chat lines wrap to the width, number links across exchanges and drop markd
expect(contextLabel({ tableName: 'order', recordId: 5 }, 'Order')).toBe('Order #5')
expect(contextLabel({ tableName: 'order', filter: {} }, 'Order')).toBe('Order (filtered)')
})
/** The answer to "what services are not healthy right now?" as 8078 gave it (qqq assistant ask --json). */
const UNHEALTHY = JSON.parse(readFileSync(join(import.meta.dir, '..', '..', 'core', 'src', 'test', 'fixtures', 'assistant-unhealthy.json'), 'utf8')) as J & { text: string; links: Array<{ label: string; href: string }> }
const unhealthyAnswer = () => ({ ...(UNHEALTHY as never as import('@qrun-io/qqq-core').AssistantAnswer), status: 'COMPLETE' as const })
test('before: the 8078 answer drawn as plain text had entities, a pipe table, URLs and a duplicate link list', () => {
const before = plainText(UNHEALTHY.text)
expect(before).toContain('healthy&#58;')
expect(before).toContain('| --- |')
expect(before).toContain('/app/health_component?filter=%7B')
expect(UNHEALTHY.links.length).toBe(8)
})
for (const width of [98, 158]) {
test(`after (${width} wide): entities decoded, a table, links as label ↗ once each, no URLs, no → list`, () => {
const lines = chatLines([{ question: 'what services are not healthy right now?', startedAt: 0, answer: unhealthyAnswer() }], width)
const text = lines.map((l) => l.text).join('\n')
expect(text).not.toMatch(/&#|\/app\/|%7B|\| ---|^→/m)
expect(text).toContain('3 services are not fully healthy: one degraded (email) and two with unknown status.')
expect(text).toMatch(/Service\s+Status\s+Since/)
expect(text).toMatch(/db_performance ↗\s+UNKNOWN/)
expect(text).toContain('• Open unhealthy components ↗')
expect(text).toContain('• email is degraded. Its only check is degraded.')
expect(text).toContain('7.1 s · 10 in / 729 out tokens')
expect(lines.every((l) => [...l.text].length <= width)).toBe(true)
expect(lines.find((l) => l.role === 'meta' && l.text.includes('tokens'))).toBeTruthy()
// four Tab targets in reading order: the three services, then the open-all link
expect(chatLinks([{ question: 'q', startedAt: 0, answer: unhealthyAnswer() }]).map((l) => l.label)).toEqual(['email', 'db_performance', 'esb', 'Open unhealthy components'])
const linked = lines.flatMap((l) => (l.segs ?? []).filter((x) => x.link !== undefined).map((x) => x.link))
expect([...new Set(linked)]).toEqual([0, 1, 2, 3])
})
}
test('narrow: the table keeps the columns that fit and names the rest', () => {
const lines = chatLines([{ question: 'q', startedAt: 0, answer: unhealthyAnswer() }], 38)
expect(lines.every((l) => [...l.text].length <= 38)).toBe(true)
expect(lines.map((l) => l.text).join('\n')).toMatch(/more columns?:/)
})
test('duplicate paragraphs collapse; a link the text does not show is listed once under →', () => {
const answer = { conversationId: 1, status: 'COMPLETE' as const, notices: [], numbers: [], tools: [], text: 'All good.\n\nAll good.',
links: [{ label: 'Person (3)', href: '/app/person', kind: 'table' as const }, { label: 'People', href: '/app/person', kind: 'table' as const }] }
const lines = chatLines([{ question: 'q', startedAt: 0, answer }], 60)
expect(lines.filter((l) => l.text === 'All good.').length).toBe(1)
expect(lines.filter((l) => l.text.startsWith('→ ')).map((l) => l.text)).toEqual(['→ Person (3) ↗'])
})
test('F12 shows and hides the panel; hiding keeps the conversation, context and draft; Esc hides without asking', async () => {
const state = fresh()
h = await start(state, 120, 36)
await command(h, 'record person 1')
await waitFor(h, () => h!.store.screen?.kind === 'record')
await key('F12')
expect(h.store.assistantOpen).toBe(true)
expect(h.store.focus).toBe('assistant')
await waitFor(h, (x) => x.includes('on: Person #1'))
await type('draft question')
await key('F12')
expect(h.store.assistantOpen).toBe(false)
expect(h.store.focus).toBe('content')
// back on Home the panel keeps the record's context and the draft
await key('ESCAPE')
await key('F12')
expect(h.store.assistantOpen).toBe(true)
expect(h.store.assistant!.editor.value).toBe('draft question')
expect(await waitFor(h, (x) => x.includes('on: Person #1'))).toBeTruthy()
// Esc with text on the question line hides; nothing is asked; the draft stays
await key('ESCAPE')
expect(h.store.assistantOpen).toBe(false)
expect(state.turns.length).toBe(0)
expect(h.store.assistant!.editor.value).toBe('draft question')
// Ctrl+G and :ask do the same
await key('g', { ctrl: true })
expect(h.store.assistantOpen).toBe(true)
await key('g', { ctrl: true })
expect(h.store.assistantOpen).toBe(false)
await command(h, 'ask')
expect(h.store.assistantOpen).toBe(true)
})
test('F-key bar: 11 and 12 from 120 columns, 12Ask lit with the extension, a click on it toggles', async () => {
h = await start(fresh(), 120, 36)
let keys = h.store.fkeys()
expect(keys.map((k) => k.n)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
expect(keys.find((k) => k.n === 12)?.label).toBe('Ask')
expect(keys.find((k) => k.n === 11)?.label).toBeNull()
const bar = h.frame().split('\n').filter((l) => l.trim()).pop()!
expect(bar).toContain('12Ask')
expect(bar).toMatch(/11\s+12Ask/)
expect([...bar].length).toBeLessThanOrEqual(120)
const x = bar.indexOf('12Ask') + 2
expect(fkeyAt(x, 120, keys.map((k) => k.n))).toBe(12)
const y = h.frame().split('\n').findIndex((l) => l.includes('12Ask'))
await press(h, () => h!.setup.mockMouse.click(x, y))
expect(h.store.assistantOpen).toBe(true)
await press(h, () => h!.setup.mockMouse.click(x, y))
expect(h.store.assistantOpen).toBe(false)
h.store.resize(100, 36)
keys = h.store.fkeys()
expect(keys.map((k) => k.n)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
})
test('F-key bar without the extension: 12Ask is dimmed (no label), the press says what is missing', async () => {
h = await start({ ...fresh(), installed: false }, 120, 36)
expect(h.store.fkeys().find((k) => k.n === 12)?.label).toBeNull()
await key('F12')
await waitFor(h, (f) => f.includes('needs the assistant extension'))
expect(h.store.assistantOpen).toBe(false)
})
test('a markdown answer in the panel: a table and links as label ↗, Tab to a link, u shows its address', async () => {
const state = { ...fresh(), stream: sse([
{ type: 'message.start', turnId: 1, conversationId: 7, userMessageId: 1, messageId: 2 },
{ type: 'text.delta', messageId: 2, text: 'Two people&#58;\n\n| Person | Last |\n| --- | --- |\n| [Blair](/app/person/2) | Odd |\n| [Casey](/app/person/3) | Even |\n' },
{ type: 'message.end', messageId: 2, status: 'COMPLETE' },
]) }
h = await start(state, 100, 30)
await command(h, 'ask who are they')
const f = await waitFor(h, (x) => x.includes('Blair ↗'))
expect(f).toContain('Two people:')
expect(f).toMatch(/Person\s+Last/)
expect(f).not.toContain('/app/person')
await key('TAB')
await key('u')
expect(await waitFor(h, (x) => x.includes('/app/person/2') && x.includes('Link address'))).toBeTruthy()
})
+17
View File
@@ -16,6 +16,10 @@ export const printing = {
enabled: false,
/** The 2.1 table-list and text prints (on with the extension unless a test turns them off). */
lists: true,
/** Save as PDF (printingRenderToPdf). */
render: true,
/** Render runs: the values each started with. */
renders: [] as J[],
/** Table-list and text runs: the process and the values it finished with. */
listRuns: [] as Array<{ process: string; values: J }>,
/** Print runs: the values and record ids each one finished with. */
@@ -24,6 +28,8 @@ export const printing = {
reset(): void {
this.enabled = false
this.lists = true
this.render = true
this.renders = []
this.listRuns = []
this.runs = []
this.jobs.clear()
@@ -41,6 +47,7 @@ export function printProcessesMeta(): J {
if (!printing.enabled) return {}
return {
printingPrintRecords_person: { name: 'printingPrintRecords_person', label: 'Print', tableName: 'person' },
...(printing.render ? { printingRenderToPdf: { name: 'printingRenderToPdf', label: 'Save as PDF' } } : {}),
...(printing.lists ? { printingPrintTableList: { name: 'printingPrintTableList', label: 'Print a table list' }, printingPrintText: { name: 'printingPrintText', label: 'Print text' } } : {}),
}
}
@@ -76,6 +83,16 @@ export async function printRoute(path: string, method: string, body: unknown): P
})
return json({ type: 'COMPLETE', processUUID: 'pr', nextStep: 'result', values: { printingJobIds: ids.join(',') } })
}
if (printing.render && path === '/metaData/process/printingRenderToPdf') {
return json({ name: 'printingRenderToPdf', label: 'Save as PDF', frontendSteps: [{ name: 'download', label: 'Download', components: [{ type: 'DOWNLOAD_FORM' }], viewFields: [{ name: 'downloadFileName', label: 'File', type: 'STRING' }] }] })
}
if (printing.render && path === '/processes/printingRenderToPdf/init') {
const v = values()
printing.renders.push(v)
const name = v.kind === 'RECORD' ? `Person-${v.recordId}` : v.kind === 'LIST' ? 'Person' : String(v.title).replace(/[^A-Za-z0-9]+/g, '-')
return json({ type: 'COMPLETE', processUUID: 'rd', nextStep: 'download', values: { downloadFileName: `${name}.pdf`, serverFilePath: `/tmp/qqq-print-${printing.renders.length}.pdf` } })
}
if (printing.render && path.startsWith('/download/')) return new Response(`%PDF-1.7 ${path}`, { status: 200, headers: { 'content-type': 'application/pdf' } })
const list = /^\/(?:metaData\/process|processes)\/(printingPrintTableList|printingPrintText)(\/.*)?$/.exec(path)
if (list && printing.lists) {
const name = list[1]!
+142 -6
View File
@@ -5,8 +5,11 @@
import { afterEach, beforeAll, beforeEach, expect, test } from 'bun:test'
import { KeyCodes } from '@opentui/core/testing'
import { printerOf, readRememberedPrinter, writeRememberedPrinter } from '@qrun-io/qqq-core'
import { captureScreenText, printTargetOf, printerChoices } from '../src/printing'
import { printerOf, readRememberedPrinter, writeFilePreferences, writeRememberedPrinter } from '@qrun-io/qqq-core'
import { mkdtempSync, readFileSync, readdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { captureScreenText, contentSections, printTargetOf, printerChoices } from '../src/printing'
import { printing } from './fake-print'
import { command, isolate, press, startTui, waitFor, type Harness } from './helpers'
@@ -14,6 +17,7 @@ let h: Harness | undefined
beforeAll(() => isolate())
beforeEach(() => {
printing.reset()
writeFilePreferences({ downloadMode: undefined, downloadDir: undefined })
writeRememberedPrinter('fake', undefined)
})
afterEach(() => {
@@ -72,7 +76,7 @@ test('record: p opens the printer picker, Enter prints, the job is followed in t
await press(h, () => h!.setup.mockInput.typeText('Virtual PDF'))
await enter()
await waitFor(h, () => printing.runs.length === 1)
expect(printing.runs[0]).toEqual({ values: { printingPrinterId: 'p1', printingCopies: 1 }, recordIds: '2' })
expect(printing.runs[0]).toMatchObject({ values: { printingPrinterId: 'p1', printingCopies: 1 }, recordIds: '2' })
f = await waitFor(h, (x) => x.includes('Queued a print job for Person First2 Odd on Virtual PDF printer'))
expect(h.store.status.print).toBe('print 0/1 QUEUED')
await poll()
@@ -102,7 +106,7 @@ test('grid: Space selects records, p, "a record sheet for each selected row" pri
expect(f).toContain('Back office laser (last used)')
await enter()
await waitFor(h, () => printing.runs.length === 1)
expect(printing.runs[0]).toEqual({ values: { printingPrinterId: 'p3', printingCopies: 1 }, recordIds: '1,2' })
expect(printing.runs[0]).toMatchObject({ values: { printingPrinterId: 'p3', printingCopies: 1 }, recordIds: '1,2' })
await waitFor(h, () => h!.store.status.print === 'print 0/2 QUEUED')
await poll()
await poll()
@@ -206,20 +210,88 @@ test('Ctrl+P on a record prints its record sheet; F8 says Print where there is n
expect(f).toContain('8Print')
expect(printTargetOf(h.store.screen)).toEqual({ kind: 'text', title: h.store.screen!.title() })
await press(h, () => h!.setup.mockInput.pressKey(KeyCodes.F8))
f = await waitFor(h, (x) => x.includes('Print this screen'))
f = await waitFor(h, (x) => x.includes('Print what?'))
// Content first (the default), Screen second
expect(f.indexOf('Content')).toBeLessThan(f.indexOf('Screen '))
await enter()
f = await waitFor(h, (x) => x.includes('Print the content of'))
await enter()
await waitFor(h, () => printing.listRuns.at(-1)?.values.printingPrinterId !== undefined)
const run = printing.listRuns.at(-1)!
expect(run.process).toBe('printingPrintText')
const sections = JSON.parse(String(run.values.sections)) as Array<{ title: string; lines: string[] }>
expect(sections.length).toBeGreaterThan(0)
const content = JSON.stringify(sections)
expect(content).not.toContain('Apps (Tab)')
expect(content).not.toContain('│')
expect(content).not.toContain('1Help')
// the app header and footer
expect(run.values.headerApp).toBe('Fake')
expect(run.values.headerTitle).toBe(h.store.screen!.title())
expect(String(run.values.headerServer)).toContain('fake.test')
expect(run.values.headerUser).toBe(h.store.status.user)
expect(String(run.values.headerPrintedAt)).toMatch(/\d/)
expect(String(run.values.footerLeft)).toMatch(/^qqq ui \d+\.\d+\.\d+ · .*fake\.test/)
})
test('Print what? → Screen prints the terminal as drawn; a record print carries the breadcrumb', async () => {
printing.enabled = true
h = await startTui(120, 36)
await ctrlP()
await waitFor(h, (x) => x.includes('Print what?'))
await arrow('down')
await enter()
await waitFor(h, (x) => x.includes('Print this screen'))
await enter()
await waitFor(h, () => printing.listRuns.at(-1)?.values.printingPrinterId !== undefined)
const run = printing.listRuns.at(-1)!
expect(run.values.sections).toBeUndefined()
expect(String(run.values.text)).toContain('Apps')
expect(String(run.values.text)).not.toContain('1Help')
await command(h, 'table get person 2')
await waitFor(h, (f) => f.includes('First2 Odd'))
await ctrlP()
await waitFor(h, (x) => x.includes('Print Person First2 Odd on…'))
await press(h, () => h!.setup.mockInput.typeText('Virtual PDF'))
await enter()
await waitFor(h, () => printing.runs.length === 1)
expect(printing.runs[0]!.values.headerTitle).toBe('People › Person › First2 Odd')
})
test('content sections: the content panel only, inner panels as titled sections in reading order, no borders, no key hint', () => {
const frame = [
' Fake ─ Home Tester ',
'┌─ Apps (Tab) ─┐┌─ Home ───────────────────────────────────────────┐',
'│▸ People ││Good morning, Tester │',
'│ ││┌─ Recently viewed ─┐┌─ Quick actions ───────────┐ │',
'│ │││ Ada Lovelace ││ ▷ Simple Sleep run │ │',
'│ │││ Blair Sample ││ + New Person create │ │',
'│ ││└───────────────────┘└───────────────────────────┘ │',
'│ ││ Applications (2) │',
'│ ││ a actions · Tab panels · Esc back │',
'└──────────────┘└───────────────────────────────────────────────────┘',
' fake.test · fake · mock session · jobs n/a ',
' 1Help 2Search 8Print ',
].join('\n')
const sections = contentSections(frame)
expect(sections).toEqual([
{ title: '', lines: ['Good morning, Tester'] },
{ title: 'Recently viewed', lines: ['Ada Lovelace', 'Blair Sample'] },
{ title: 'Quick actions', lines: ['▷ Simple Sleep run', '+ New Person create'] },
{ title: '', lines: ['Applications (2)'] },
])
expect(contentSections('')).toEqual([])
// a panel cut off by the bottom of the content panel still becomes a section
const clipped = [' t', '┌─ Main ──────────────┐', '│┌─ Apps grid ───────┐│', '││ Alerts Cluster ││', '││ 3 tables ││', ' status', ' keys'].join('\n')
expect(contentSections(clipped)).toEqual([{ title: 'Apps grid', lines: ['Alerts Cluster', '3 tables'] }])
})
test('Print Screen (when the terminal passes it on) and :print <printer> print without the picker', async () => {
printing.enabled = true
h = await startTui(120, 36)
await press(h, () => h!.store.handleKey({ name: 'printscreen', sequence: '' } as never))
await waitFor(h, (x) => x.includes('Print this screen'))
await waitFor(h, (x) => x.includes('Print what?'))
await esc()
await command(h, 'print back office')
await waitFor(h, () => printing.listRuns.length === 1 && printing.listRuns[0]!.values.printingPrinterId === 'p3')
@@ -232,3 +304,67 @@ test('the screen capture drops the status line and the function-key bar and trim
expect(captureScreenText(store)).toBe(' Title bar\n body line')
expect(captureScreenText({} as never)).toBe('')
})
test('Save as PDF is the first picker entry: a record renders on the server and saves like a download (silent mode: the download folder, stamped name)', async () => {
printing.enabled = true
const folder = mkdtempSync(join(tmpdir(), 'qqq-tui-pdf-'))
writeFilePreferences({ downloadMode: 'silent', downloadDir: folder })
h = await startTui(120, 36)
await command(h, 'table get person 2')
await waitFor(h, (f) => f.includes('First2 Odd'))
await ctrlP()
const f = await waitFor(h, (x) => x.includes('Print Person First2 Odd on…'))
const picker = f.split('\n').filter((l) => l.includes('║'))
expect(picker.findIndex((l) => l.includes('Save as PDF…'))).toBeLessThan(picker.findIndex((l) => l.includes('Back office laser')))
await press(h, () => h!.setup.mockInput.typeText('Save as PDF'))
await enter()
await waitFor(h, (x) => x.includes('Saved '))
expect(printing.renders).toMatchObject([{ kind: 'RECORD', tableName: 'person', recordId: '2' }])
expect(printing.runs).toEqual([])
const files = readdirSync(folder)
expect(files.length).toBe(1)
expect(files[0]).toMatch(/^Person-2_\d{4}-\d{2}-\d{2}_\d{6}\.pdf$/)
expect(readFileSync(join(folder, files[0]!), 'utf8')).toStartWith('%PDF-1.7 /download/Person-2.pdf')
})
test('Save as PDF on a grid saves the table list; with the dialog (default mode) the save dialog offers the stamped name', async () => {
printing.enabled = true
h = await startTui(140, 40)
await command(h, 'table query person -w "lastName = Odd"')
await waitFor(h, (f) => f.includes('First2 '))
await ctrlP()
await waitFor(h, (x) => x.includes('Print Person: what?'))
await enter()
await waitFor(h, (x) => x.includes('Print Person: 15 rows on…'))
await press(h, () => h!.setup.mockInput.typeText('Save as PDF'))
await enter()
const f = await waitFor(h, (x) => x.includes('Save as PDF') && /Person_\d{4}-\d{2}-\d{2}_\d{6}\.pdf/.test(x))
expect(f).toContain('Name')
const render = printing.renders[0]!
expect(render.kind).toBe('LIST')
expect(render.scope).toBe('ALL')
expect(JSON.parse(String(render.filterJSON)).criteria[0]).toMatchObject({ fieldName: 'lastName', values: ['Odd'] })
expect(render.printingPrinterId).toBeUndefined()
})
test(':print pdf saves the screen as a PDF without the picker; a server without the render process shows no Save as PDF entry', async () => {
printing.enabled = true
const folder = mkdtempSync(join(tmpdir(), 'qqq-tui-pdf-'))
writeFilePreferences({ downloadMode: 'silent', downloadDir: folder })
h = await startTui(120, 36)
await command(h, 'print pdf')
await waitFor(h, (x) => x.includes('Saved '))
expect(printing.renders[0]!.kind).toBe('TEXT')
expect(String(printing.renders[0]!.text)).toContain('Apps')
expect(readdirSync(folder)[0]).toMatch(/_\d{4}-\d{2}-\d{2}_\d{6}\.pdf$/)
await esc()
printing.render = false
// F10 › Reset refreshes the metadata: the render process is gone
await command(h, 'reset')
await waitFor(h, () => h!.store.screen?.kind === 'home')
await command(h, 'table get person 2')
await waitFor(h, (x) => x.includes('First2 Odd'))
await ctrlP()
const f = await waitFor(h, (x) => x.includes('Print Person First2 Odd on…'))
expect(f).not.toContain('Save as PDF…')
})
+112 -7
View File
@@ -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)
+3 -2
View File
@@ -14,7 +14,8 @@
# QQQ_PRINT_AGENT_DIR qqq-print-agent checkout with tools/virtual-agent
# (default <QRun-IO>/qqq-print-agent next to this repo)
# QQQ_PRINT_STATE pid, log and device identity (default ${TMPDIR:-/tmp}/qqq-print-agent-<port>)
# QQQ_PRINT_OUT where printed documents go (default <state>/printed)
# QQQ_PRINT_OUT where printed documents go (default ~/Downloads, as downloads and exports;
# the agent's own default too); --dir prints the folder in use
# QQQ_PRINT_PRINTER_NAME, QQQ_PRINT_DEVICE_NAME names shown in QQQ
#
# Then, from the CLI: qqq printer list; qqq print person 1 --printer "Virtual PDF printer"
@@ -31,7 +32,7 @@ agent="$agent_dir/tools/virtual-agent/virtual-agent.mjs"
port="$(node -e 'const u=new URL(process.argv[1]); console.log(u.port || (u.protocol === "https:" ? 443 : 80))' "$server")"
tmp_root="${TMPDIR:-/tmp}"; state_dir="${QQQ_PRINT_STATE:-${tmp_root%/}/qqq-print-agent-$port}"
state_dir="${state_dir%/}"
out_dir="${QQQ_PRINT_OUT:-$state_dir/printed}"
out_dir="${QQQ_PRINT_OUT:-$HOME/Downloads}"
pid_file="$state_dir/agent.pid"
log_file="$state_dir/agent.log"
identity="$state_dir/device.json"