Compare commits

..
Author SHA1 Message Date
james.maesandClaude Fable 5.1 79ba68eea3 fix(tui): record view settings take typed values
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / gradle-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-2 (push) Skipped
QRun-IO/ci-policy: ci / node-3 (push) Skipped
QRun-IO/ci-policy: ci / source-config-5 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared Node CI / verify (push) Successful in 3m48s
QRun-IO/ci-policy: Shared Node CI / secrets (push) Successful in 29s
QRun-IO/ci-policy: Shared Node CI / gate (push) Successful in 10s
QRun-IO/ci-policy: ci / node-4 (push) Successful in 24m15s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
The record view's v dialog was a flat filterable list of 15 pre-baked
choices (label width +-4, five child row counts, the collaboration panel
listed three times). It is now a settings form like the grid's View
settings: Columns cycles auto/1/2, Label width (8-60) and Child list rows
(1-50) take typed digits or +/- with Backspace back to the default, one
Collaboration panel row, and Reset to defaults with a confirm. Persistence
keys are unchanged, so saved settings still load.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 12:42:11 -06:00
james.maesandClaude Fable 5.1 c670509b09 chore: drop an accidentally tracked empty log file and ignore log/
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 11:24:31 -06:00
james.maesandClaude Fable 5.1 969f3c139a docs: add the assistant to the 1.0.0 entry, release notes, README and TRY-IT
CLI qqq assistant ask/config, the TUI Assistant panel (Ctrl+G, :ask), the kernel Assistant module, the
base64 link filter fix and the enterprise server's ANTHROPIC_API_KEY handling are folded into the
structured entry with commit ids; the release notes get an Assistant section saying what the server needs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 11:24:09 -06:00
james.maesandClaude Fable 5.1 e5327b245d fix(links): read base64 filter parameters, as the web UI and the assistant write them
The assistant's links (and the web UI's) carry `filter` as base64 JSON,
which the TUI's link reader and the kernel's parseAppHref dropped, so a
filtered link opened the whole table. Both now decode it (JSON still
works), and a filter without criteria counts as an empty one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 11:18:19 -06:00
james.maesandClaude Fable 5.1 29f35595d1 feat(scripts): enterprise server passes ANTHROPIC_API_KEY and reports the assistant
The enterprise profile now picks Claude when ANTHROPIC_API_KEY is set,
else a local Ollama. The key is read by name from the private assistant
env file (~/.local/state/qqq-enterprise/assistant.env: owned by you, not a
symlink, mode 0600; never sourced, never printed) and exported only into
the server's environment, not onto a command line. --status gains an
assistant line (and --assistant-status prints just that): the model that
answers, or the reason and what to configure.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 11:16:12 -06:00
james.maesandClaude Fable 5.1 2c70c3c2d4 feat(tui): Assistant panel (Ctrl+G, :ask, F9 > Work > Assistant)
A chat pane on the right (the whole body below 110 columns): question
line at the bottom, the conversation above with each answer's numbers and
links; Tab and Enter open a link as a record or grid through the existing
server-link checks. Questions carry the record or grid under the panel as
context like the Next UI (Ctrl+D drops it), history is kept per profile.
Ctrl+A was not taken because the grid uses it for its selection menu.
Without the extension the entry is disabled and Ctrl+G says what is
missing; when the assistant is off the panel shows the server's reason.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 11:16:12 -06:00
james.maesandClaude Fable 5.1 a5e0cf4268 feat(assistant): qqq assistant ask and config, on a kernel Assistant module
The assistant qbit was reachable only through its generated operation
subcommands, which cannot read a streamed turn. The kernel now has an
Assistant module (feature probe, ask with the page context the way the
Next UI panel sends it, an incremental SSE parser and an answer fold with
numbers and links), and the CLI builds two first-class verbs on it:
`assistant ask` streams the answer and prints its numbers and links with
the command that opens each; `assistant config` says whether it answers
and with which model, or the server's reason (NOT_CONFIGURED names what
to configure).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 11:16:01 -06:00
james.maesandClaude Fable 5.1 981c0ca858 fix(core): linear-time history redaction; activity log reads on Windows
CodeQL flagged js/polynomial-redos (high) in the activity log's
redaction. Two regexes could backtrack polynomially on long arguments:
the header pattern (`Name:\s*(.+)`, slow on "A:" plus many spaces) and
the filter-criterion pattern (slow on long runs of name characters).
Redaction now uses linear scans:
- flags are parsed with indexOf at the first `=`
- headers and name=value pairs check the name before the first `:` or
  `=`
- filter text goes through a one-pass tokenizer (spaces, names, symbol
  operators, quoted strings, other runs), and the value after
  `<secret name> <operator>` is replaced
The remaining regexes each have a single, unambiguous quantifier.

Behaviour is unchanged on every existing redaction test. A differential
fuzz against the old code (300k random inputs, not committed) shows
three kinds of difference, none of which exposes a secret:
- a value now stops at a `)` that closes a group
- a quoted value is hidden whole
- a header whose value is only whitespace is left as it is
The rest of the differences are malformed mixed quotes.
New tests run 18 adversarial 100k-character inputs, each well under
500 ms.

The Windows lane failed "events are appended one JSON line each". The
test asserted POSIX mode 0600, and Windows has no mode bits. That check
now runs only off Windows. The reader splits lines on /\r?\n/, and a
new test reads back a CRLF file. The offset test now checks the parsed
time and the offset that getTimezoneOffset gives for that instant,
whatever the zone's name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 11:11:47 -06:00
james.maesandClaude Fable 5.1 981e442e29 docs: fold history, saver, export names, widgets and collaboration into the 1.0.0 entry
The CHANGELOG bullets added by accretion since bedbf69 are merged into the structured entry (CLI history
and table stats, TUI history screen, one saver bullet instead of three fixes, Search coverage hint,
collaboration at the bottom, popups and widgets) with commit ids. Release notes, README and TRY-IT mention
history, stamped export names and the new keys.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 11:05:12 -06:00
james.maesandClaude Fable 5.1 3e80043bd9 fix(tui): collaboration lives at the bottom of the record view
The record view opened on three collaboration rows (tags, the counts line,
"Viewing now") between the title and the record's sections, and the cursor
landed on the tags row. The owner wants collaboration to sit with the
controls at the bottom and take no space at the top.

The record now starts right under its title, the cursor on its first field.
A dim strip directly above the key line carries tags, comment/file/task
counts, timeline, follow state and who is viewing; others viewing or editing
replace "(you)" (with their state, from presence's mode and field) and give
the strip an accent. Segments drop whole when the width is short, the
viewers and tags last. From 140 terminal columns a 36-column Collaboration
panel on the right shows tags, the latest two comments, open tasks with the
next due date, files, followers and each viewer's state, and the strip
shortens to the keys; a record view setting (collaboration panel
auto/on/off) controls it. The keys are unchanged; strip segments and panel
rows are click zones that press them. The summary now also fetches the
followers and keeps the latest comments and next due date for the panel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 11:00:17 -06:00
james.maesandClaude Fable 5.1 98d2076d14 fix(tui): keep each saver group compact and the header and lock line beside it
Big numbers and Flip clock are one group at most about 90 columns wide
(tiles in one or two adjacent columns of at most 43 cells, the caption
capped), however wide the terminal; the guard drifts it a tenth of the
width at most. The clock line and the lock line now sit just above and
below the group (swapping every 30 s, a new offset each time) instead of
at the opposite screen edges. Tests cover six sizes including 200x50.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 10:57:00 -06:00
james.maesandClaude Fable 5.1 aff2b00529 fix(tui): lay the screen saver styles out as one group each
On a tall narrow terminal Big numbers squashed the number and Query into
a corner and left its only tile floating alone at the far left. Now the
metric name, number, Query (beside it, or below on a narrow screen),
caption, dots and the other tiles are one centred stack, and the guard
moves that stack as a unit between spots near the centre, always fully on
screen. A single tile sits centred under the number. Flip clock keeps its
tiles and Query with the clock as one group; Bounce stacks the tile panel
under Query when the terminal is too narrow for side by side. The drifting
header and lock line keep a one-cell left margin.

Layout tests cover five sizes (75x95, 200x30, 80x24, 120x40, 160x50) and
check every style stays within 40% of the group's centre.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 10:57:00 -06:00
james.maesandClaude Fable 5.1 7d754bfbbf test(core): de-flake the PIN storage test
The test asserted that the digits "2468" never appear in config.json,
but the stored salt and scrypt hash are random hex and can contain that
substring by chance, so the test failed now and then. It now checks the
stored values: the lock holds only pinSalt (32 hex) and pinHash (64 hex),
neither is the PIN, and no JSON value equals the PIN.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 10:51:35 -06:00
james.maesandClaude Fable 5.1 0bcc735ee9 feat: history of everything done in the CLI and the TUI
The owner asked for a "history" command that shows everything they have
done, like a bash history. Both heads now write to one activity log, an
append-only JSONL file per profile in the state directory
(~/.local/state/qqq/activity/<profile>.jsonl). Each event records the
time with the local offset, profile, server, client, kind, a one-line
summary and the command that does it again. The file is rotated once to
.1 at 5 MB. Secrets are written as ***: secret flags, name=value pairs,
filter criteria on secret or PASSWORD fields, Authorization headers and
bearer tokens. QQQ_HISTORY=off turns the log off.

- core: session/activity-log.ts (append, rotate, read, filter, redact,
  CLI kinds, mutating-command check).
- cli: every invocation is recorded on exit with its exit code and
  duration (shell completion is skipped). `qqq history` lists entries
  with --limit, --since, --grep, --kind, --all-profiles, --absolute,
  --commands and --format. `history rerun <n>` asks first for commands
  that change data and records the command it ran. `history clear`
  asks first.
- tui: one history.record(...) call at each site: `:` lines, opened
  apps, tables, records and processes, queries with filters, record
  create, edit and delete (no values), process outcomes, exports,
  prints, server switches, sign-in and sign-out. The History screen
  (`:history`, F9 > Work > History) filters as you type, Enter does an
  entry again, y copies and c or F8 clears. Ctrl+R on the `:` line is
  now a bash-style reverse search over the log.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 10:50:58 -06:00
james.maesandClaude Fable 5.1 7c66b16b6f fix(cli): table stats computes from matching records when the server offers none; quiet exit on a closed pipe
table stats refused tables without QUERY_STATS and pointed at --local. It now falls back
to the matching records itself (also when the columnStats process answers 403 or 404),
using the kernel computeLoadedColumnStats, says where the numbers come from, and exits 0.
--local forces it, --no-local keeps the old error, --max-records (default 10,000) caps it.

A reader that leaves early (qqq table query person | head -1) crashed the CLI with an
unhandled write EPIPE. stdout and stderr errors with EPIPE now exit 0 quietly, and a
stream pipeline rejected with EPIPE is not reported, so exports, process logs and
assistant ask behave the same.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 10:50:31 -06:00
james.maesandClaude Fable 5.1 70b6fa1908 fix(tui): completion popups readable: the width they need, two columns, wrapped descriptions
The owner on the `:` popup: "this needs to be easier to read, text cut
off?" On a 160-column terminal the popup was about 50 columns wide, the
descriptions were right-aligned and cut with an ellipsis, and nothing
separated the name from the description.

The popup now takes the width its matches need, at least 60 and at most
120 columns, never past the screen's inner width. Rows are two
left-aligned columns, `name  ·  description`; the name column fits the
longest name and gives way (down to 40%) only to descriptions that need the
room. A description that still does not fit wraps onto a second line under
the description column instead of being cut. The selected row is
highlighted across the whole popup. A dim header line carries the column
titles (command/argument/name and description or type/columns) and the
match count (on the border when the titles fill the line). The window
counts lines, so two-line rows keep the cursor visible, and the `:` list
stays above the command line with its height capped so the line and its
key help stay visible. The SQL editor popup gets the same layout (name ·
type / columns), below the cursor line or above it when there is more room
there.

Tests: the layout (width bounds, name column, wrapping, line-counting
window) and the `:` line end to end (header, separator, a wrapped
description).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 10:47:07 -06:00
james.maesandClaude Fable 5.1 81a044f68d fix(tui): explain quick search coverage when Search finds nothing
A search with no matches said "no records match", which reads as "the
record does not exist" when the table is simply not indexed. The empty
state now says what quick search covers (N of M tables, up to five names
then "and N more", records, last reindex), that other tables cannot be
found there, where to add them (Quick Search Admin › Quick Search
Index), and that `o` searches the table Search came from by name (its
text fields, through the query API). The status line adds the indexed
table count ("index 57 records in 17 tables"). The kernel's
searchCoverageText builds the sentence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 10:45:28 -06:00
james.maesandClaude Fable 5.1 d7c6e6575a fix(tui): give text and HTML widgets headings, badges, aligned lists and paragraphs
The owner on Quick Search Admin's Search Diagnostics: "the header text
needs to have more layout or spacing". The widget's HTML went through
htmlToText into one block: sub-headings looked like body text, status
words were inline, label/value lines unaligned, paragraphs ran the full
pane with no blank lines, ISO timestamps had microseconds, and the Tools
paragraph was a run-on list.

Text and HTML widgets are now parsed into a small document
(src/text-doc.ts) and drawn with the hierarchy the web gives the same
HTML: headings (h1-h4, markdown `#`, or a short line before a block) in
the accent colour with a blank line before and their block indented;
a leading OK/WARN/FAIL/DEGRADED/DOWN as a fixed-width coloured badge so
lines align; `Label: value` items as an aligned two-column list, with
none/0 under a failure label in green; instants in the profile's date
format; body text at a ~100-cell measure with blank lines between
paragraphs; a sentence enumerating three or more capitalised names as
bullets; stat cards kept together; rows of button links as items Enter
follows (through the safe link parser). Lines carry coloured segments,
kept by the clipping and the list cursor; ASCII uses [OK] and *.
Row-shaped HTML (the alerts setup checklist) keeps its aligned columns.

Tests use the Search Diagnostics and SQL Workbench payloads captured
from the enterprise server, before and after.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 10:42:34 -06:00
james.maesandClaude Fable 5.1 c201f92f2b feat: stamp default export names with the local date and time
Why: two exports of the same table overwrote nothing but piled up as person.csv, person-1.csv,
person-2.csv, which say nothing about when they were taken. A default name now carries the local
date and time (person_2026-11-01_102316.csv), so exports are unique and sort by age.

One kernel helper, exportFileName(base, ext, now), is used by every surface that chooses a name:
table export, widget --csv and report route downloads, field saves from a record, the OpenAPI
document, and in the TUI the grid export, SQL Runner results, saved reports, widget exports and the
screen-text print fallback. A name the user gives (-o, a typed name in the file dialog) is left as
given; the dialog's name field is pre-filled with the stamped name. A name that comes from the
server keeps it unless it is taken, when the stamp goes before the extension (then -1, -2).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 10:34:13 -06:00
james.maesandClaude Fable 5.1 899326a1c7 fix(tui): screen saver moves every element to prevent burn-in
The Bounce tiles panel, the clock/server/profile row and the lock line sat
still on an OLED/CRT. Now (burnin on, the default): the panel rides beside
the drifting logo on the side with room (alternating 20 s panel / 20 s
Query when both do not fit), the header and lock line change place every
30 s, the flip clock and its tiles drift, Big numbers changes corners, and
everything dims after 10 minutes (:saver burnin on|off, :saver dim
<minutes|off>, F9 Settings). The block-letter logo says the connected
profile's name (short name when 6 characters or fewer) with the server
host beneath, and the block font covers letters, digits - _ . with a
plain-text fallback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 10:26:44 -06:00
james.maesandClaude Fable 5.1 bedbf69452 docs: say which printing operations are qqq printing subcommands
The client document's 17 printing 2.0.0 operations are the generated qqq printing subcommands (print
agent side). printTableList and printText are processes the kernel runs, reached as qqq print list and
qqq print text, not as qqq printing subcommands. Every operation count in the docs is 219, the length of
OPERATIONS in @qrun-io/qqq-core (generated from the merged document); no 202 remains outside the rc.1 notes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 07:42:39 -06:00
james.maesandClaude Fable 5.1 4f95a76d50 docs: restructure the 1.0.0 changelog and rewrite the release notes
The 1.0.0 entry had grown by accretion (Added/Changed/Fixed since rc.1 beside older sections). It is now
one entry with Added, Changed, Fixed, Security and Known limitations, grouped by area (CLI, terminal UI,
kernel and client, packaging and CI), deduplicated, with the commit of each post-rc.1 change. The release
notes describe what qqq is, the UI keys, the CLI highlights, compatibility, install, known limitations and
the enterprise test server, and the command sequence matches the real tags, orb 0.8.33 and the tap and
bucket repositories. The client README and runbook counts and checklist are brought up to date.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 07:40:15 -06:00
james.maesandClaude Fable 5.1 5d55f40127 docs: fix stale keys in the READMEs and let the readiness gate see default subcommands
F2 is Search, / is the find-anything palette, Ctrl+O switches servers and Ctrl+P prints; Ctrl+P no
longer steps to the previous record. The CLI README lists print, printer and profile. The readiness
gate reported qqq print person 1 2 as a missing verb although record is print's default command.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 07:40:04 -06:00
james.maesandClaude Fable 5.1 3bc2179928 fix(tui): message dialog footer drops whole keys; sweep F2 Search
The mouse footer of the message dialog ("any key or a click here closes ·
...") was cut mid-token in narrow dialogs; it now goes through hintLine. The
sweep's / scenario follows the new Search dialog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 07:34:56 -06:00
james.maesandClaude Fable 5.1 c2a9e98496 fix(tui): date picker keys use fitKeyLine; sweep the date picker
At most three lines of keys, broken between keys and dropping the least
important first, like the other dialogs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 07:34:56 -06:00
james.maesandClaude Fable 5.1 da76a35d30 fix(tui): F9, Servers and SQL Runner key lines use the shared fitting; sweep them
The F9 menu, the Servers dialog and the SQL Runner dialogs had their own key
line helpers measuring string length; they now call util/text fitKeyLine, and
the SQL Runner's segment fitting uses fitSegments (terminal cells). The overflow
sweep covers F9 levels 1 and 2, the Servers dialog and its add form, and, where
the sql-workbench extension is installed, the SQL Runner (SELECT, a table,
an error, a plan, the schema and the history).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 07:34:56 -06:00
james.maesandClaude Fable 5.1 6937a652d0 docs(changelog): overflow entry matches the rebased help and record view
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 07:34:55 -06:00
james.maesandClaude Fable 5.1 0e4716a85e fix(tui): sweep the redesigned developer screens; drop the old screen's wrap hook
The developer view now sizes itself at render time (f236c7b), so the LinesScreen
wrap-width hook added for the old screen is removed again. The overflow sweep
covers the record and table developer views, their sections, the JSON section
and a value opened from it. CHANGELOG follows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 07:34:55 -06:00
james.maesandClaude Fable 5.1 c94499c336 fix(tui): record view key line drops whole keys; V shows a field's whole value
The record view's key line now goes through hintLine with drop priorities
(A and D go first, Esc back is kept), so it never cuts a key at 80 columns.
V on a field row of the record view shows the whole value of its field(s) in
a dialog, as V does on the grid. The help's key table keeps two columns when
all but a couple of descriptions fit. The sweep adds a 30+ column grid
(db_perf_snapshot on the enterprise server, else fieldLab), the F9 menu with a
search, and waits longer for a large instance to start; the checker accepts a
box cut by the bottom of its scrolling pane even when a dialog covers that
pane's corner.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 07:34:55 -06:00
james.maesandClaude Fable 5.1 4abfd8d4f1 fix(tui): no text outside its container; view settings help line and key wrapping
The owner saw the View settings dialog run text and its highlight bar past the
right border, and the key help cut in the middle of a token. Make clipping the
default at the container level instead of trusting each screen:

- util/text: trunc/pad/padLeft/center measure terminal cells (Bun.stringWidth)
  so wide characters cannot push a border; fitSegments for styled lines;
  fitKeyLine/hintLine lay key help out between tokens (" · "), dropping the
  least important first (explicit `drop` priority; Esc/Enter/Ctrl+S kept).
- overlays/frame: Frame is held inside the screen and gives Line/KeyLines its
  inner width through context; Line cuts with an ellipsis instead of slicing.
- View settings: the value cell holds only the value; the selected row is
  explained on a help line ("columns that stay left while scrolling · +/-
  changes"); keys in at most three lines; +/- really change Pinned columns.
- Sweep fixes: footers drop whole keys, help key table goes to one column
  when two would cut it, developer panels and process errors wrap to the
  pane, home header lines end in an ellipsis, saved column widths are held to
  the grid, bulk edit intro fits 80 columns, wrap() keeps long words.
- V on the grid shows a cell's whole value.
- scripts/overflow-sweep.sh drives every screen and dialog at 80/100/120/200
  x 24/50 against a running server; scripts/overflow-check.ts fails on a line
  wider than the terminal or a broken box border. smoke.sh runs the check on
  its captures.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 07:34:55 -06:00
james.maesandClaude Fable 5.1 16be5c2a6f test: make presence tests wait on state, stop handles in finally, add test deadlines
Windows timers fire in ~15 ms steps, so the fixed tick(30) in the joinPresence
stream test ended before the three stub events (tick(2) each) were applied, and
the failed assertion skipped handle.stop(). The live handle's heartbeat interval
and endless stub stream then kept the test process alive, which CI saw as ten
silent minutes. Tests now poll for the state they assert and stop every handle
in finally. run-tests.mjs gives each test a 2 minute deadline and kills a run
that outlives 8 minutes with a message.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 07:30:10 -06:00
james.maesandClaude Fable 5.1 9535e3a972 fix: linear scanning for the search snippet and date parser regexes
Two more CodeQL js/polynomial-redos alerts on the release pull request: the
search screen's status-line clock (/^.* (\d\d:\d\d).*$/) and the date parser's
date/time split (/^(\S+?)(?:[T ](.+))?$/i). Both backtracked quadratically on
long repeated input. lastClockTime and splitDateAndTime in core's text helpers
return the same results by plain scanning; each has a 100k-character test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 07:09:02 -06:00
james.maesandClaude Fable 5.1 3fa81bf428 fix(dev): keep the enterprise server's secrets in a private state dir and never source them
The enterprise script generated its MinIO credentials in the shared temp dir and sourced them, so
another local user could pre-create or symlink the file to run code or read the credentials. State
now lives in a 0700 per-user directory; existing files are used only if owned by us, not symlinks
(credentials also 0600); the credentials and pid files are parsed and validated, never sourced.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 07:04:26 -06:00
james.maesandClaude Fable 5.1 6018cdf706 feat(dev): start a MinIO container for the enterprise server's object storage
The enterprise profile installs qbit-object-storage when S3_ENDPOINT is
set, so collaboration attachments work. enterprise-server.sh now starts
qqq-enterprise-minio on 127.0.0.1:9310 alongside Redis and OpenSearch,
creates the attachments bucket, and passes S3_* to the server. The
credentials are generated once into $TMPDIR/qqq-enterprise-minio.env (0600;
QQQ_ENTERPRISE_MINIO_ENV overrides) and never written to the repository.
container_up takes a container command after "--".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 07:01:16 -06:00
james.maesandClaude Fable 5.1 cf0e610bc1 feat(dev): add a local enterprise test server and a CLI sweep of its qbits
scripts/enterprise-server.sh builds and runs qqq-all's enterprise profile
(branch feat/enterprise-terminal-test in .worktrees/qqq-all-enterprise)
on port 8078 against the UI API 1.9.0 fix-branch build: MOCK auth, every
locally runnable qbit, the qqq sample app, Redis and OpenSearch in Docker
for redis and quick search. --status and --stop only touch the pid it
started and the two containers it names; it never touches the 8077 sample
server.

scripts/enterprise-smoke.sh runs the CLI through each qbit surface (ESB,
alerts, assistant, collaboration, data analysis, DB performance, QA
capture, quick search, release notes, secured data, SQL workbench, jobs,
audits, saved views and reports, exports, webhooks, processes) and records
exit codes, so client and server gaps show as non-zero lines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 07:01:16 -06:00
james.maesandClaude Fable 5.1 3ccb9731b2 docs(printing): document print anything and Ctrl+P
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:53:46 -06:00
james.maesandClaude Fable 5.1 555c4346be feat(tui): Ctrl+P prints what you see on every screen
A record view prints its record sheet, a grid a table list with its
columns, widths, filter and sort (all rows, this page, or the selection),
and any other screen its text as a monospace PDF, then the printer picker
and the job progress line. Also p on records and grids, F8 where a screen
has no Delete, :print [printer], a and F9, and Print Screen when the
terminal passes it on. Without printing Ctrl+P saves instead: the grid's
export, else the screen text. Ctrl+P no longer steps to the previous
record; PgUp and [ still do.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:53:37 -06:00
james.maesandClaude Fable 5.1 4bd62b7c1f feat(cli): add qqq print list and qqq print text
print list prints a table list of the whole result of the criteria (up to
10,000 rows) or one page, in the column order given, with paper and
orientation options; print text prints a file or stdin as a monospace
document. Both pick and remember the printer as print does and follow the
job.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:53:28 -06:00
james.maesandClaude Fable 5.1 d81c05352b feat(core): print table lists and text through the printing qbit
printTableList sends a grid's table, columns, widths, filter and sort with
the scope (all rows, this page, or selected rows) to printingPrintTableList
and passes the server's row-limit note on; printText prints text such as a
screen capture. listScopeChoices orders a grid's print choices: all rows
first when the grid is filtered or small, this page first otherwise, the
10,000-row limit named, and the selection as a list or as record sheets.
The client document's printing entry moves to 2.1.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:53:28 -06:00
james.maesandClaude Fable 5.1 d76fb03a27 docs(tui): Search on F2, qqq search and the attachments message
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:41:35 -06:00
james.maesandClaude Fable 5.1 437ed3c258 fix(tui): say plainly when the server cannot store attachments
Uploading to a server without the Object Storage QBit showed the raw
"IllegalStateException (The Object Storage qbit is not installed)". A
server failure (5xx) naming a missing QBit or extension now reads as one
line, for attachments "This server cannot store attachments: the Object
Storage QBit is not installed." (the kernel's missingQBitMessage, used by
the TUI's errors and the CLI's error output, so `collab
post-records-attachments` says it too). Once known for a server, the
UI's "+ Upload a file" row and the record's upload action are dimmed
with that reason instead of starting another upload.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:41:35 -06:00
james.maesandClaude Fable 5.1 ef2b554e73 feat(tui): F2 is Search, a full screen for finding records with quick search
The owner: quick search is the main way to find a record and get into
the data, so it gets a function key and a screen of its own. F2 (it was
"Go to", which `/` covers) opens Search everywhere: a search line with
"search records across N tables (quick search)", results as you type
grouped by table with label and count, each hit's label, key and
highlight snippet with the field. ←→/Tab cycle the tables, Enter opens
the record and Esc returns to the hit, `o` opens the table's grid
filtered to the hits, Space marks hits for `a` (grid, export, print,
bulk, processes), `f` narrows to one table, `h` recent searches kept per
profile, `?` the syntax quick search really has. The status line shows
matches, tables, time, index size and the last reindex.

Home starts with the search line: typing there starts a search. Without
the extension F2 still opens Search and finds tables, apps and processes
by name and the records of the table it came from, saying "quick search
not installed: searching names only". `:search <text>` opens Search;
`:find <text>` and `/` stay the find-anything palette.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:41:35 -06:00
james.maesandClaude Fable 5.1 0e07f8aefa feat(cli): qqq search shows the matched field, takes several tables and explains the syntax
The kernel gains record search (query/record-search): POST /search
grouped by table with counts, each hit's label, key and highlighted
snippet with the field, what the quick search query language supports
(AND of words matched from their start; no phrases, field:value,
wildcards or OR), notes for text that uses those, and the quick search
status line. The CLI's `qqq search` uses it: a `match` column, `--table`
repeated or comma-separated, `--limit` per table, a footer with the
tables and the total, `field:value` searching the value with a note, the
syntax in --help; `--json` prints the response.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:41:34 -06:00
james.maesandClaude Fable 5.1 e5d6c929d5 fix(tui): column statistics from loaded rows when the server offers none
The S dialog was a dead end on tables without QUERY_STATS: it only said so. It now
computes count, nulls, distinct, min and max from the rows the grid has loaded, plus
sum, mean and median for numbers, shortest, longest and the top 5 values for text, and
earliest, latest and the top 5 days for dates, and says where the numbers come from.
A server that offers the process but answers 403 or 404 gets the same fallback. 'a'
pages through the existing query API (current filter and sort, up to 10,000 rows) with
a progress line; Esc stops and keeps the rows so far. The computation is a pure kernel
function (loaded-stats.ts) with unit tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:36:05 -06:00
james.maesandClaude Fable 5.1 cbabc31b5f feat(tui): mouse support across the whole TUI
The owner asked why the mouse did not work everywhere. Mouse tracking is now
on by default (button-event tracking with SGR 1006, no motion, so hovering
does nothing), switched by F9 Settings "Mouse: on/off" and :mouse (saved in
ui.mouse) or --no-mouse / --mouse for one run.

One transparent box over the terminal takes every event, so OpenTUI's own
text selection and focus never start; a registry of zones (rectangles
measured from the drawn elements, a layer per dialog) says what is under the
pointer. Clicks select, double-clicks activate, right clicks open the
actions at that row, the wheel scrolls 3 lines (Shift sideways in the grid),
header drags resize or move grid columns (saved as view settings), the
F-key bar presses keys and the footer opens the Servers dialog. Clicks
beside a dialog do nothing. Help and copyable-text dialogs say Shift+drag
selects text. The two-level F9 menu, the Servers dialog and the SQL Runner
panes are wired too.

The layer lives in src/mouse/*; screens add a few Zone wrappers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:31:45 -06:00
james.maesandClaude Fable 5.1 efc202a0d0 feat(tui): SQL Runner results like the main grid, export and copy, completion popups
Owner feedback on SELECT * FROM QQQTABLE: the results need to show better,
the export should be complete and discoverable, and Tab should list what
fits even before anything is typed.

Results: columns stay in the server's column list order (for QQQTABLE that
is the table's own H2 order, LABEL, CREATEDATE, MODIFYDATE, NAME, ID, as the
execute response sends it). Cells use type-aware formatting: dates and date-times in the profile's
display format (the kernel's formatDateValue and the TUI's date settings:
day order, 12/24 h clock), seconds only when the value has them, never the
fraction, zoned instants in the local zone; numbers
right-aligned with thousands separators except key-like columns, booleans,
NULL as a dim dash. The grid gets a row-number gutter, a header rule, zebra
per the sql:results view setting, a selection bar that ends at the last
column, horizontal scroll with Shift+Left/Right and edge markers. The status
line sits directly under the results ("1 row · 7 ms · h2 · read-only ·
limit 10,000 · sorted by —") with the key line under it, and F8 Export
joins the F-key bar.

Export and copy: F8 and x open the main grid's export dialog with csv, tsv,
xlsx, json (every row: the server writes csv/xlsx/json from its cached
result, tsv is read page by page) and copy to the clipboard for small
results, through the file navigator and the download-folder preference.
y copies the cell, Y the row as TSV, Ctrl+Y the whole result. Export and
copy show in the a actions menu, F9 Context and F1.

Completion: Tab in the SQL editor opens a popup also with nothing typed
(after FROM/JOIN every table with its column count, after SELECT/WHERE/ON
or a dot the statement's columns with types, at the start the statement
keywords and recent statements), filtered as you type. The : command line
uses the same popup: every command with a one-line description on an empty
line (TUI commands plus the CLI's own descriptions), then a command's
arguments. The popup clips to its width and follows the ASCII setting.

Execution UUIDs and cell keys now always come from crypto.randomUUID()
(the old Math.random fallback was flagged by CodeQL, and the id goes to the
server).

Tests: column order, cell formatting, delimited output, export choices,
reading every row, the popup model, empty-prefix completion, and the screen
and : line end to end. smoke-sql.sh runs the owner's query, both popups and
a real csv export whose row count must match the status line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:28:21 -06:00
james.maesandClaude Fable 5.1 c22ae75d0e feat(tui): a live screen saver starring Query, the qrun.io mascot
The saver was a drifting clock. It now shows what is happening on the
server: people online (collaboration presence), jobs, health, database
sessions, ESB depth and dead letters, unread alerts, realtime events per
minute, ping, and the profile's favourite table counts. Each tile hides
when its extension is absent (or answers 403/404) so the plain sample
server works too; failures keep the last value dimmed as stale and back
off. Four styles (Bounce, Starfield, Flip clock, Big numbers, or auto)
animate Query, traced from the line drawing on qrun.io/query, at no more
than 4 fps, and only the saver repaints. The lock's key handling is
reused unchanged: with a PIN the prompt opens over the animating saver.
`:saver [style]` previews; F9 > Settings holds style and refresh.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:22:18 -06:00
james.maesandClaude Fable 5.1 ad4001dba4 feat(tui): one date control: typed entry in the display format and a calendar picker
Dates and date-times were typed as bare ISO text with no help; the owner asked
that they can be entered by hand or picked. One control now serves every place a
DATE or DATE_TIME is edited: create/edit forms, process forms, bulk edit and the
filter dialog's value cell (its row model unchanged).

Kernel (forms/dates.ts): parseDateInput reads ISO, the display format, partial
dates, t/today, n/now and the filter grammar's now(...)/startOf(...), refusing
with the exact reason; slash dates only when the profile's date order says which
part is the month. formatDateValue/formatHint, monthGrid and week numbers, and
ui.dates settings (order, clock, week start, week numbers, picker on Enter).
Values go out as the kernel already sends them.

TUI (controls/): DateEditor (a LineEditor showing the display format, Backspace
clears a whole value when nullable), DatePickerOverlay anchored to the field
(F2/Ctrl+Space; time row with zone, UTC sent and server zone), the bulk prompt
(now sending DATE_TIME as the UTC instant like forms), the filter wiring, and
:dates. Screens claim F2 through Screen.claimsKey so it is not the palette there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:11:29 -06:00
james.maesandClaude Fable 5.1 cc7839639f feat(tui): SQL Runner over the sql-workbench extension
The owner asked whether anyone was getting the SQL Runner working in the
terminal. The Next UI's Runner (a SQL_WORKBENCH widget on its own app page)
had no TUI screen: its app opened an empty app home.

Add a classic database-TUI screen over the sql-workbench UI API extension:
- editor on top: multi-line buffer, syntax tint (server keywords included),
  F5 / Ctrl+Enter (Ctrl+J) runs the statement under the cursor, Tab completes
  tables and columns (alias.columns) from the schema endpoint, Ctrl+Up/Down
  steps through the on-disk run history; :name parameters are asked for,
  bound as values and remembered
- result grid below: server-paged and server-sorted over the cached result,
  column widths from the column kinds, NULL dimmed, V whole value, x export
  (csv/xlsx/json through the file flow), status line with rows, elapsed,
  backend, read-only and the row limit (cursor tokens dropped first)
- errors inline under the editor with the SQL position marked (H2 [*],
  PostgreSQL Position, line/column forms)
- F6 EXPLAIN as an indented, foldable plan tree with costs and a relative
  cost bar (raw text with r); quick text bar charts (c)
- F3 schema browser (backends, tables, columns; filter; Enter inserts)
- F2 notebooks (mine/shared, open, save as a new version, save as,
  duplicate, delete, publish when allowed), F4 cells (SQL and markdown,
  markdown rendered as text, move, title, backend, run all), F7 audited
  history with open and re-run
- reachable from the Runner app in the tree, `/` (the SQL Workbench widget
  hit opens the Runner instead of `widget get`), F9 > Work and :sql
  (:sql <statement>, :sql open <id>); without the extension the Runner apps
  and their SQL_WORKBENCH widgets leave the metadata and :sql says the
  extension is missing

Registration is kept to a few lines: store.setMeta hides the Runner apps
(extensions now load before the metadata), a screen may claim a global key
(F2 here; its keys reach F1 through the existing helpKeys), openApp routes
Runner apps, and commands gain :sql, the Work entry and the widget route. Write mode is not offered (the Next UI keeps
the gated write flow).

Tests: editor model, completion, statements, parameters, error positions,
history, notebooks, absent-extension metadata, result paging and sort,
column widths, plan tree, bars, and the screen end to end against a fake
server (80x24 clipping checked). scripts/smoke-sql.sh drives it in tmux at
80x24 and 120x36 against a server with the extension.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:00:35 -06:00
james.maesandClaude Opus 5.5 3c45c8ee2f feat(tui): Servers dialog (Ctrl+O) for saved profiles and one-key switching
Switching servers took the profile chooser and gave no sign of which servers
were up. The dialog lists every profile with short name, server, background
probe status, last use, signed-in user and description; Enter switches
(reusing a valid stored sign-in), n/e/d/* add, edit, remove and set the
default, typing filters. Home and the footer show the profile's short name,
the switch fetches fresh metadata, and the footer clock and status are reset
for the new server instead of freezing on the old one.

With the flat app and recent entries gone from F9 (places are found with /),
Go to lists the apps and the recent records as groups (five and more…), so
those lists stay one level down in the menu; Ctrl+O joins the key reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 05:55:32 -06:00
james.maesandClaude Opus 5.5 13c020f62e feat(tui): F9 menu in two levels, areas then entries
The flat three-column list of every entry did not fit and was hard to scan.
Level 1 lists the areas with a count and a summary; Enter or the right arrow
opens one, a letter jumps to it, left or Esc goes back, Tab steps areas. Long
groups (Recent, Apps, Favorites, or any entry's group) show five and more….
Two or more typed characters still search every entry, now listed by area.
Only the structure and rendering changed: the entry catalog in commands.ts
is untouched apart from the optional group field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 05:54:17 -06:00
james.maesandClaude Opus 5.5 d9317f5f59 feat(core,cli): profile short name, description and sign-in mode; qqq profile verbs
Profiles gain short (2-6 chars, for compact places), description, authMode
(auto, browser, device for OAuth2 servers), and lastUsedAt/user recorded by
the TUI. Every field is additive: a 1.0.0-rc.1 config.json loads unchanged and
its note shows where a description is missing. probeServer asks /version and
the authentication metadata with a short timeout so the CLI and the TUI's
Servers dialog report reachability the same way.

qqq profile list|add|edit|use|remove (also under qqq config profile) matches
the dialog field for field: --short, --description, --auth-mode, --check,
edit --server/--rename, list --probe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 05:54:16 -06:00
james.maesandClaude Fable 5.1 fc47cb8d47 docs(printing): document printing and add a virtual print agent script
scripts/print-agent.sh runs qqq-print-agent's virtual file printer against
a local server with the printing qbit (the enterprise server on 8078 by
default): it enrolls and approves itself the first time and writes each
job to a directory. README and CHANGELOG (1.0.0, Added) describe qqq print,
qqq printer and the TUI's Print action.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:49:16 -06:00
james.maesandClaude Fable 5.1 05f467c605 feat(conformance): check the printing extension when a server lists it
GET /qbit/printing/session must answer 200 with principalId and tenantId
(or 403 with the error schema when the user lacks printing access), and the
printing printer, device and job tables must query and validate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:49:16 -06:00
james.maesandClaude Fable 5.1 acfb090024 feat(tui): print records and selections, and follow print jobs
p (Print...) on a record, or on a grid's Space selection, opens a printer
picker: printable printers first, the others listed with the reason, the
last used one preselected and remembered per profile. The jobs are followed
in the title bar and status line (print 1/3 RECEIVED) with a flash when they
end, and :printjobs (F9 > Work) lists recent jobs. Without the printing
extension no Print action is offered and :printjobs says what is missing.
The qbit's own Print process is not listed again under Run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:49:16 -06:00
james.maesandClaude Fable 5.1 c1e7bf9cfa feat(cli): add qqq print and qqq printer
qqq print <table> <id...> prints records as record sheets and follows the
jobs until they have printed (--printer, --copies, --no-wait, --timeout;
exit 1 when a job failed, 4 when still running at the timeout). qqq print
job list|status [--watch]|cancel follows and cancels jobs; qqq printer
list|use|show|forget|devices shows printers and print agents and manages
the printer remembered for the profile. Without the printing extension the
commands exit 5 with the installed list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:49:03 -06:00
james.maesandClaude Fable 5.1 d632987e43 feat(core): add the Printing module for printers, jobs and Print
Printing wraps what a person does with the printing qbit: printers (with
approval, exposure and formats), print devices and their health, jobs by id
or state, Print on a table's records through its printingPrintRecords_{table}
process (returning the queued job ids), cancel, and watch until every job
is final. resolvePrinter and whyNotPrintable serve both heads, and the
printer chosen last is remembered per profile in config.json. Every call
needs the extension and throws ExtensionNotInstalledError before any
request otherwise.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:49:03 -06:00
james.maesandClaude Fable 5.1 5cf62430aa feat(client): carry the printing 2.0.0 extension document
Appends qbit-printing's committed extension document (printing 2.0.0,
17 operations under /qbit/printing) to the client document with
merge-spec.mjs, keeping the other documents untouched, and regenerates the
schema and operation index (219 operations). The registry knows printing
with minimum 2.0.0: the realtime channel became the agent's prerequisite
there, so a 1.x server counts as not installed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:49:03 -06:00
james.maesandClaude Fable 5.1 f149a4753a fix(tui): smoke.sh skips the sample-only record steps against another server
Steps 13b-13d open person 12 and its ESB events, which only the sample
server has; with --server they are skipped (SAMPLE=1 keeps them), so the
smoke test passes against the enterprise server too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:47:20 -06:00
james.maesandClaude Fable 5.1 c35def57ff fix(tui): server links are content, never commands
Review of the widget links: parseLink turned any `:…` link into a command
that Enter ran, and links come from server content (widget payloads,
HTML, table rows with __href), so a buggy or malicious server could make
Enter run `:process run …` or `:table delete …`.

- Server links are read against the metadata: `/app/{table}[/{id}]` and
  `/app/{process}` through the developer view's linkTarget, the web's
  app paths only when every app on the way is an app; record keys must be
  plain segments, filters must have a QueryFilter's shape. `:` text,
  unknown names and other sites are inert notices that show the address
  (no browser).
- Only TUI-built links are trusted: ESB destination rows carry a
  symbol-keyed link that JSON cannot contain. Commands they run are shown
  in the status line first, server names are quoted as single arguments,
  and mutating commands confirm.
- Extension output is now laid out by the kernel's formatDocument (the
  CLI's layout since 5e34433) instead of a second renderer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:43:50 -06:00
james.maesandClaude Fable 5.1 e8577daf3b docs(tui): search and help, collaboration, widgets and smoke against any server
CHANGELOG 1.0.0 (changed, added and fixed since rc.1), the TUI README keys
and sections, and TRY-IT: `?` is the keyboard reference, `/` the one
search (with :search), the record's collaboration keys, J for raw JSON.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:40:41 -06:00
james.maesandClaude Fable 5.1 6950e9d811 fix(tui): smoke.sh takes --server URL and --profile NAME
Against another server the smoke test launched the TUI without a
profile for it and landed on the profile picker. With --server it writes
an isolated config (config, cache, state, file keyring) with one profile
for the server, so a MOCK or anonymous server signs in straight away and
the user's own config is untouched. The positional profile still works.
The help step checks the new keyboard reference.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:40:20 -06:00
james.maesandClaude Fable 5.1 b782812108 feat(tui): record collaboration: comments, attachments, tasks, timeline, follow, share preview, presence
The record view offered only tags from the collaboration extension. It
now shows a line with the record's comments, attachments and open tasks
and the follow state (keys C P K T W), and below it who is viewing the
record now: everyone the server reports, this user marked "(you)" (the
MOCK and same-user case merges into one person, so others alone could
never show). `a` and F9 list comments (list, add, reply), follow and stop
following, attachments (list, download through the file navigator,
upload, delete), the timeline, tasks (list, add, done) and the share
preview (find people, or type user ids when the directory is empty; see
who could read the record, then send). Without the extension the line
is not drawn and F9 says "needs the collaboration extension".

Comments, following and people go through the kernel's Collaboration;
attachments, timeline, tasks and shares are the extension's operations.
An attachment's temporary URL gets credentials only when it is on the
server's own origin.

Also: a choice list sizes itself for disabled reasons too (they were cut).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:40:20 -06:00
james.maesandClaude Fable 5.1 e13e7bf0ff fix(tui): leave quietly when the terminal is already gone
Closing the tmux pane or window before quitting printed "setRawMode
failed with errno: 2" and a native session OutputFailed error, each with
a stack trace. The renderer's restore steps cannot succeed then and the
messages say nothing to the person who closed the window; they are
dropped from console.error/warn and from the crash handler. Other errors
still print.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:40:19 -06:00
james.maesandClaude Fable 5.1 0691e6fd13 fix(tui): app cards show the app's icon as a glyph, not its Material name
Cards printed "[notifications_active]". Common Material icon names map to
single-width glyphs (bell, chart, database, people, settings, search,
star, folder, terminal, shield, hub, clock, ...), with ASCII stand-ins
under the ASCII-glyphs setting; unknown names fall back to the app mark.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:40:19 -06:00
james.maesandClaude Fable 5.1 70c064eabe feat(tui): one search on /, a keyboard-only ?, and :search
Help and go-to overlapped: `?` listed actions and searched help, `/`
listed recents and commands, F9 listed every app and metadata match.
Each thing now has one home.

- `/` (F2, `.`, Ctrl+K, `:search <text>`) searches apps, tables,
  processes, widgets, reports, records, commands and help, grouped under
  headers with the best match first. Records come from quick search (or
  the server's search), debounced; the group reads "quick search: N
  matches" with the table and record label per hit, and says once,
  dimmed, "records: install the quick search extension" without it.
  Hits open by `recordId` (they opened with an empty key) and the label
  falls back to the highlighted text when it is only the key.
- `?` is the keyboard reference of the screen: keys grouped by what they
  do (the screen's own shortcuts from its actions, keys only), the
  screen's help text, and "/ search anything · F9 menu · : command line".
- F9 keeps its sections and drops the per-app, recent and index entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:40:19 -06:00
james.maesandClaude Fable 5.1 19cc3ec16b fix(tui): draw the ESB, jobs and alerts widgets and extension JSON
The enterprise sweep found widgets the terminal could not draw: the ESB
app's overview printed "(ESB_OVERVIEW: no terminal rendering yet)", the
jobs statistics printed their web addresses, the alerts setup checklist
ran label, status and detail together, and extension output dumped
nested arrays as one-line JSON.

- ESB_OVERVIEW is drawn from the esb extension as `:esb overview` does;
  Enter on a destination offers the ESB of its table and processes, its
  messages and dead letters.
- Widget lines carry links (a statistic's url, a table row, an HTML
  row's first link); Enter reads the table, filter, record, process or
  app out of the web path and opens the TUI's own screen.
- Row-shaped HTML becomes aligned columns, the last wrapping under
  itself; the kernel decodes the common named and numeric entities.
- Extension operations on `:` and payloads without a block draw as
  key/value rows and clipped sub-tables; J shows the raw JSON.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:39:52 -06:00
james.maesandClaude Fable 5.1 e8f075addf feat(tui): copy CLI equivalents to the clipboard
The CLI equivalent dialog could only be read. y, c or Enter now copies the command
(Esc closes without copying), the filter dialog gets Ctrl+Y for its command line, and
F9 > Settings has "Copy CLI commands to the clipboard automatically" (ui.copyCliCommands,
default off). The footer points at Shift+drag for terminal selection; mouse tracking is
left to the TUI-wide mouse setting.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:38:53 -06:00
james.maesandClaude Fable 5.1 536d0b37cd docs: record the enterprise sweep fixes and the -o convention for 1.0.0
CHANGELOG 1.0.0 gains the -o/--order convention with its migration note
and the extension table output under "Changed since 1.0.0-rc.1", and the
ESB trigger, login profile, health fallback and conformance fixes under a
new "Fixed since 1.0.0-rc.1". README, the cli and conformance package
READMEs, TRY-IT, the 1.0.0 release notes and the parity matrix (and its
generator) use --order for the sort and -o/--out for files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:27:39 -06:00
james.maesandClaude Fable 5.1 248f30f458 fix(cli): name ESB triggers by process and fall back to an unadvertised /health
esb messages on a topic without --trigger only passed on the server's
"name one of the triggers", and trigger names look like
syncOrder.orderEvents, so the process name syncOrder answered 404. On a
400 or 404 the command now reads the ESB overview: a process name (or
label) with one trigger on the topic reads that trigger, several are
listed, and otherwise the error lists the valid trigger names
(resolveEsbTrigger in the kernel).

health get-summary said "not installed" on servers that serve /health
without listing the health extension in GET /version (qqq-all). The
kernel's probeHealth reads /health, shapes it like the extension's summary
and marks it unadvertised; the CLI shows it with a note when the
extension is not listed and the endpoint answers.

Tests cover the sub-table golden output, --columns, the -o convention,
ESB trigger resolution, the login warning and the health fallback against
a fake enterprise server.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:27:39 -06:00
james.maesandClaude Fable 5.1 5e34433a86 feat(cli): print extension results as key/value rows and sub-tables
The generated QBit commands (alerts get-inbox, sql-workbench
get-capabilities, collab get-..., api) printed lists as one line of JSON
in a key/value table (`items [{"id":5,...`). The kernel's render model
gains a document layout (formatDocument): scalars as aligned key/value
rows with nested objects as dotted keys, each list of objects as a
sub-table under its key with columns from the union of the row keys in
first-seen order, nested objects flattened one level, lists in a cell
joined, fitted to the terminal width (cells cut with an ellipsis, columns
that do not fit named under the table). render() uses it for nested
results and for any result given a layout; json, yaml, csv and text do not
change, and a --query reshape drops the column choice.

The inbox, notebooks, SQL history, comments, tasks, favorites and recents
get default columns (DEFAULT_LIST_COLUMNS); --columns (or the global
--fields) chooses, on the generated commands and on the first-class collab
comments list, favorites list, recents, tags list and people.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:27:39 -06:00
james.maesandClaude Fable 5.1 0687083c56 fix(cli): make -o the output file everywhere and the sort --order
table export and table query used -o for --order while the extension and
download commands used it for the output file, so `table export customer
-o file.csv` sorted by a field named after the path (and the server
answered 403). One convention now: -o/--out is always the output file, the
sort is --order (no short form; -s is --set). table export takes
-o/--out. On the commands that write no file (table query, saved-view
save, saved-report preview and set) -o still sorts for one release, hidden
from help, with a deprecation note. A sort field that looks like a file
path is warned about. Completion offers fields after --order only and
skips hidden options; the flat query alias forwards short-only options.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:27:38 -06:00
james.maesandClaude Fable 5.1 a10c8344d4 fix(cli): ask before login --server points an existing profile elsewhere
With QQQ_PROFILE set, `qqq login --server URL` silently moved that profile
to another server. resolveProfile now reports movedFrom when an existing
profile is given a different server; login warns, needs --yes (or a yes at
the question on a terminal) and says --profile <new-name> adds a profile
instead. saveLogin only moves a profile when login asked for it, so a token
refresh under a one-off --server no longer moves it either.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:27:38 -06:00
james.maesandClaude Fable 5.1 e7108ad319 fix(conformance): authenticate every check the same way, as the CLI does
GET /jobs and the realtime check built their own credentials while the
other checks used the token as a bearer only, so a token could report "no
jobs permission" where a session worked. One Authentication now serves
every authenticated check: a JWT as a bearer token, a session UUID also as
the sessionUUID cookie, and on MOCK and FULLY_ANONYMOUS servers without a
token a server session from manageSession (what qqq login does there). The
metaData check names the authentication used, and a jobs 403 says it was
the same one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:27:38 -06:00
james.maesandClaude Fable 5.1 f236c7b833 feat(tui): developer view in sections, with a scrollable pane and real JSON
The record and table developer views were one wall of text: panel blocks
flattened to prose, tables with ad-hoc headers, web paths as links, and the
ESB sample event broken into fragments that looked duplicated. That came
from the prose word-wrap: the minified event was split at the spaces inside
its strings ("Clone of: Blair", in both record and oldRecord) and every
longer piece was cut at the width.

Both views are now a section bar (left/right or 1-9) over a scrollable pane
with a scroll bar. Record: Security, Events, Scripts (revisions, code, logs,
test runs, store from $EDITOR), JSON (endpoints, display and raw values),
Metadata, Audits, APIs, then any further panel. Table: Events (ESB counters),
JSON, Metadata, APIs. Grids use the main grid's column layout with a dash
for no value and a note on why a column is empty; JSON is pretty-printed,
tinted and cut per line, with v for the full value viewer and y to copy.
Web links map to TUI screens or are shown as browser only. F3 goes to the
record or the grid; the help overlay lists the developer keys.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 05:24:09 -06:00
james.maesandClaude Fable 5.1 213c605d95 fix(tui): read escaped quotes in the filter dialog like core's grammar
Core now prints quoted filter values with \" and \\ escapes and its
tokenizers skip an escaped quote. mixedJoinAt, which points at the first
mixed AND/OR in the Advanced text, toggled on every quote, so a value such
as "x \" AND y" was read as ending early and its AND flagged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:51:16 -06:00
james.maesandClaude Fable 5.1 c52220a413 docs(changelog): record the CodeQL security fixes for 1.0.0
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:47:57 -06:00
james.maesandClaude Fable 5.1 d749f144a8 fix(release): escape every sed metacharacter in the notes command
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:47:56 -06:00
james.maesandClaude Fable 5.1 c5b4b47888 fix(tui): strip process help markup with the core stripTags
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:47:56 -06:00
james.maesandClaude Fable 5.1 9609acfc08 fix(conformance): dev IdP end-session redirects only to loopback URIs
post_logout_redirect_uri is rebuilt from a constant loopback host, a numeric
port and its path, and any other value is answered with 400 (it showed the
signed-out page before), matching the authorization redirect_uri rule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:47:56 -06:00
james.maesandClaude Fable 5.1 4d13ba98d2 fix(cli): keep the edit file path out of the editor's shell command
editText ran $EDITOR through a shell with a file name built from the table,
primary key and field, so a primary key such as $(...) executed. The path is
now the shell's $1 on POSIX (as git runs its editor) or a quoted variable on
Windows, and the name keeps only letters, digits and ._-.

Also: --api-path trimming and bulk help text use the linear core helpers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:47:56 -06:00
james.maesandClaude Fable 5.1 e1694d4fe7 fix(core): linear text scanning and correct escaping for CodeQL alerts
Server metadata and user input reached regular expressions that backtrack
quadratically (slash and = trimming, ${name} and ${input.x} references,
filter error tails, tmux list-keys lines). They are replaced by linear scans
in render/text.ts, with the same matches for well-formed input.

htmlToText and the upload-template names decoded entities in sequence, so
&amp;lt; became <; they now strip tags in one pass and decode once. Filter
values escape backslashes as well as quotes, and the tokenizers count
preceding backslashes, so a value ending in a backslash keeps its closing
quote.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:47:56 -06:00
james.maesandClaude Fable 5.1 067d97ccb3 fix(tui): rework the F6 filter dialog into rows of field, operator, value
The guided dialog mixed three models: quick rows that reused one
editor call (a true/false quick field became the unary TRUE and its row
edit re-applied TRUE, so it could not be changed there), condition lines
edited through a menu, and letter shortcuts next to cells that took
typing. People could not tell which row did what or how to change a
value.

Now every condition is one row of three cells, edited the way its type
asks: text and numbers in place, choices from a searchable list that
toggles values, Space cycles true/false (t/f set it), dates take presets
or typed values, and the operator cell lists the operators that fit the
field. A row without a value shows (any) and is not applied; Backspace
clears any cell. Quick fields are the same rows. AND/OR and groups are
drawn as structure, the help and key lines follow the focus, and the
footer shows a live, debounced match count. Commands moved to Ctrl and
F keys so letters always type; Esc asks before discarding changes.
Advanced gains a cheat sheet, a caret that follows the scrolled text
(and points at the joiner when AND and OR are mixed), and says why it
cannot switch back to Guided.

Design: packages/tui/docs/filter-dialog.md. The smoke script switches
modes with F2 (Tab now moves between cells) and the soak's reset accepts
discarding a half-edited filter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:44:39 -06:00
james.maesandClaude Fable 5.1 5dd5e2d7f1 test: run the package tests without shell globs, for the Windows lane
The cli-windows-x64 lane failed in every package with "Could not find
...\dist\test\*.test.js": npm runs scripts with cmd.exe there, which
does not expand globs, and Node 20 does not glob its own arguments.
Plain "node --test" is no fix on Node 20 either, because its default
patterns run every file under a test directory, helpers such as
harness.js and stub.js included.

scripts/run-tests.mjs lists the *.test.js files directly in dist/test and
spawns node --test with them, so all four packages run the same files on
every platform. Counts on macOS are unchanged: 18, 167, 3 and 100.

Two cheap Windows fixes in the CLI test harness: the sandbox sets
USERPROFILE as well as HOME (os.homedir() reads USERPROFILE on Windows,
so defaults would have escaped into the real profile) and QQQ_STATE_DIR,
and the golden-file normaliser turns backslashes in sandbox paths into /
so export-download-dir.txt matches there too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:39:55 -06:00
james.maesandClaude Fable 5.1 508ddc017a test(cli): assert dashboard widgets load concurrently, not within 3 s
The heavy-dashboard test proved concurrent loading by requiring the
whole command, CLI start-up included, to finish under 3000 ms against a
stub that answered each widget after 150 ms. A serial load would take
3.6 s, so the margin was 600 ms of start-up on whatever machine runs it:
the same kind of wall-clock guard that failed the release pipeline in the
core index test.

The stub now counts widget requests in flight and holds each answer until
every widget request has arrived (or 300 ms after its own arrival). The
test asserts the peak is above 1, which a serial loader cannot reach;
loadDashboard has no concurrency cap, so local runs also assert all 24
overlap and keep a generous 6 s bound, both skipped when CI is set.
Checked by making loadDashboard serial in the built kernel: the test then
fails with a peak of 1, CI set or not. Three npm test runs under
nice -n 19 with competing busy loops, and one with CI=1, all passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:39:55 -06:00
james.maesandClaude Fable 5.1 0f7f536b5b fix(tui): keep the record view layout stable and show each association once
Paging record to record on a table with child lists (the sample's sales
order) moved every section below the lists, and the same association was
drawn twice.

Cause of the movement: child lists grew and shrank with the record's row
count, empty grandchild lists vanished, and field and child-list column
widths were measured from the values of the first record shown. Now the
section plan comes from the metadata only; each child list is a box of
fixed height (heading, column header, N data rows, a count line) that pads
short lists, keeps its box when empty, and scrolls long ones inside the
box ("rows 1-5 of 12"; PgDn/PgUp a box, Down/Up or j/k a row at the edges,
] and [ still step records, Enter opens the child). Column positions come
from field metadata. Widget sections keep the tallest height they reached,
the collaboration tags line is always there, and a child's own child lists
(whose number varies) come last. N is the per-table view setting
childRows (v), defaulting to ui.childRows of the profile, then the global
ui.childRows, then 5 (kernel readChildRows).

Cause of the duplication: two code paths drew the association. The
section loop rendered the childRecordList widget section from its widget
payload ("Lines" with the "1 Sales Order Line" caption), and the
associationGroups loop after the sections rendered the record's
associatedRecords for the same association ("Lines (1)"). The Next UI
treats the widget section as the place of the association: a section
whose childRecordList binds an association renders that association there
under the section label, and the association is dropped from the related
list. The kernel's widgetAssociation binds as Next does
(manageAssociationName, rowBuilder associationName) plus the join name the
sample's widgets carry; the TUI draws the bound list from associatedRecords
with the widget's column set (kernel childWidgetColumns, Next's
childColumns without the parent key) and no longer loads its widget
payload. Widgets that list no association of the table stay as they are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:38:06 -06:00
james.maesandClaude Fable 5.1 0d62416f08 ci: turn on the macOS arm64 and Windows x64 lanes of qqq-orb 0.8.33
The CLI ships for macOS and Windows and has Keychain and DPAPI keyring
paths, but CI only ran Linux. qqq-orb 0.8.33 adds opt-in platform lanes to
the cli profile, and the setup config's 0.8 track picks the release up by
itself, so the manifest only needs platforms: [macos-arm64, windows-x64].

Rendered with the orb's generate_pipeline.py from v0.8.33 against this
manifest: a push to main yields cli-macos-arm64, cli-windows-x64 and
cli_verify_publish; the tag v1.8.0 yields the same in publish_release with
the publish job requiring both lanes and bound to the registry context.

ci-lanes.md now describes what runs (and that the lanes cover the Node
packages' build, tests and lint, not the binaries or the TUI), keeping the
0.8.32 gap list and proposal as history. The runbook marks the orb item
done, says a missing macOS or Windows resource class now holds a publish,
and no longer claims the Windows lane runs verify-binary.ps1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:34:29 -06:00
james.maesandClaude Fable 5.1 e3822d2169 test(core): guard the metadata index by scaling, not wall-clock bounds
The search-index test asserted that every search took under 50 ms and
the build under 200 ms. On the orb's cimg/node:22 medium container a
search took 51.5 ms and failed the release pipeline, though nothing was
wrong: the bound measured the machine, not the algorithm.

The test now runs the same queries (and the same index build) on an
instance with a tenth of the entries and asserts the large one costs less
than 40x as much. The index is a flat scan, so the observed ratio is
about 10x; a per-keystroke tree walk or a quadratic step would land near
100x and fail, so the guard still catches what it is for. Each side is the
least of several timed rounds, which filters scheduler and GC noise, and
machine speed cancels out of the ratio. A 500 ms absolute backstop remains
for local runs only (skipped when CI is set). Three full npm test runs
under nice -n 19 with four busy loops competing all passed (ratios
7.6x to 10.1x).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:32:04 -06:00
james.maesandClaude Fable 5.1 1fd06db2e3 fix(cli): word doctor's UI API support from the same range as --version
qqq --version said "servers UI API >=1.6.0 <2.0.0" while qqq doctor said
"supports 1.6.0 to 1.8.0", which reads as if a 1.9.0 server were out of
range. The range is now built from UI_API_MIN_VERSION and a new
UI_API_MAX_EXCLUSIVE in version.ts, and doctor's ok, warn and fail details
all quote that UI_API_RANGE string. A newer-minor server (1.9.0) is
explicitly called supported while the warning about its new operations
stays. The comparison logic is unchanged: 1.6.0 and 1.8.0 are ok, 1.9.0
warn, 1.5.2 and a server without major 1 fail.

A test pins the range text, checks that --version and every doctor detail
use it, and keeps the old statuses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:25:23 -06:00
james.maesandClaude Fable 5.1 cddbea7eee docs(try-it): present TRY-IT as the 1.0.0 walkthrough, not a milestone log
TRY-IT.md still introduced itself as "updated with every milestone" with
sections marked "M1 in progress" and "M3", which reads as unfinished on a
1.0.0 release page. The intro now says what it is (a walkthrough from a
source checkout against the sample server) and points to the README's
install section; the milestone markers are dropped. Commands and content
are unchanged, and the readiness gate still finds every one of them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:22:01 -06:00
james.maesandClaude Fable 5.1 35061395f2 docs(changelog): date 1.0.0 and list what changed since 1.0.0-rc.1
1.0.0 ships on 2026-11-01, so the YYYY-MM-DD placeholder the runbook says
to fill in is filled. The entry claimed to be the release candidate's
"unchanged in content", which stopped being true with the installer's
stable-channel default and the fast-uri bump; those and the new
documentation are listed in a "Changed since 1.0.0-rc.1" section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:21:43 -06:00
james.maesandClaude Fable 5.1 7fed5590bf docs: index docs/ and bring release docs to the final 1.0.0 wording
docs/ had no index, so a reader could not tell the runbook from the
historical plans. docs/README.md now describes every file under docs/.

The runbook, ci-lanes and 1.0.0 release notes still spoke as if rc.1
were pending and left the Scoop bucket undecided. They now name
QRun-IO/homebrew-tap and QRun-IO/scoop-bucket, state that the 1.0.0
version step is done on release/1.0.0, note what qqq-orb PR #165 changes
about publishing, describe the installer's stable default, and the
release notes list the Homebrew and Scoop commands. The generic rc-to-final
recipe keeps its -rc.N placeholders on purpose.

Two links in parity/next-feature-matrix.md pointed at files that exist
only in qqq-frontend-next; they now use absolute GitHub URLs. Its content
is otherwise untouched. Every relative Markdown link now resolves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:21:43 -06:00
james.maesandClaude Fable 5.1 a14cd5c87c fix(install): default the curl installer to the newest stable release
The installer took the newest cli-v* release including prereleases, so
after 1.0.0 the next release candidate would have reached curl users
while Homebrew and Scoop stayed on the stable channel. It now picks the
newest release whose "prerelease" is false; --pre (or QQQ_PRE=1) opts
into prereleases and --version still pins one.

--help printed nothing under curl | sh because it read the script back
with sed from "$0"; the help is now a heredoc. The Windows hint says to
add the QRun-IO scoop bucket before scoop install qqq. A --dry-run option
prints the resolved version and URLs, which made the change testable:
sh -n and shellcheck are clean, and dry and real runs against compact
and pretty-printed release lists and a file:// mirror pick 1.0.0 over a
newer rc, the rc with --pre, and ignore tag_name/prerelease text inside
release notes. The README install section documents the stable default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:20:22 -06:00
james.maesandClaude Fable 5.1 5563f2f393 build(packaging): make the tap and bucket drafts pass their CI and lint
The homebrew-tap profile's validator rejects a setup caller that runs on
tags, so the tap's .circleci/config.yml is now byte-for-byte the one in
QRun-IO/homebrew-qctl (tags: ignore). brew style flags a formula without
the frozen_string_literal magic comment, so it is added; the 64-hex zero
placeholders stay so scripts/update-packaging.mjs still fills them.

The formula and Scoop manifest placeholders now name 1.0.0 instead of
1.0.0-rc.1, and the packaging notes name QRun-IO/scoop-bucket, say that
the tap's CI executes only the Linux x64 binary (macOS downloads are
fetched and checksummed, not run), and that the bucket uses the
ScoopInstaller BucketTemplate Actions with Excavator auto-updating on new
cli-v* releases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:20:07 -06:00
james.maesandClaude Fable 5.1 ac86860422 chore(repo): add contributing, security, conduct and GitHub templates
The repository had no contributor or security guidance and no .github
directory, so a 1.0.0 public release would give reporters no private
channel and contributors no statement of the checks, the signed-commit
rule on main or the license their work falls under.

Modeled on QRun-IO/qqq but adapted: npm workspaces plus Bun for the TUI,
the qqq-orb cli profile, Apache-2.0 (no AGPL text copied), conventional
signed commits. CODEOWNERS requests @KofTwentyTwo on every change.
Dependabot groups weekly minor and patch updates for the npm workspaces
and, through its bun ecosystem, for packages/tui's bun.lock; the
throwaway tui-spike is left out on purpose. Issue templates ask for the
qqq doctor build line, and route server and security problems elsewhere.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:18:03 -06:00
james.maesandClaude Fable 5.1 1b0d54768c docs(packages): write npm READMEs for cli, client, core and conformance
The CLI and client READMEs were about a hundred words each, and the
conformance package had none, so their GitHub Packages pages said almost
nothing. Each now covers purpose, install (including the read:packages
token GitHub Packages needs), a quick start whose code was run against the
local sample server, the API surface, auth modes, compatibility and the
versioning policy, with absolute links back to the repository docs since
relative links do not resolve on a package page.

The core README gains install and quick-start sections and rows for the
26 modules the index exports but the table did not list. The TUI README is
corrected, not rewritten: qqq ui is in the release binaries only, and a
checkout runs it with node packages/cli/dist/main.js ui after npm ci.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:16:49 -06:00
james.maesandClaude Fable 5.1 d3d09622f9 build(packages): complete npm metadata on the four published packages
@qrun-io/qqq-ui-api-conformance had no homepage, bugs or keywords, so its
npm page would link nowhere, and the client was tagged "cli". All four
publishable packages now carry the same repository, homepage, bugs,
license and GitHub Packages publishConfig shape, with keywords that
describe each package. Versions are untouched; npm pack --dry-run shows
each tarball with dist, README.md and LICENSE and no tests or source maps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:16:49 -06:00
james.maesandClaude Fable 5.1 e46bc6b4b1 docs(readme): rewrite the top-level README for the 1.0.0 release
The rc-era README led with a placeholder OAuth sign-in and mentioned
Homebrew and Scoop only as "prepared". For 1.0.0 the first screen has to
answer what qqq is, how to install it on every platform (curl installer,
qrun-io/tap, the QRun-IO scoop bucket, direct download, npm for the CLI
only) and what using it looks like.

The CLI tour now uses real output captured from the local sample server
(login, whoami, query with a filter, get, process run, export), the TUI
tour explains Home, the function-key bar, the / palette, F9 and the :
line, and new sections state compatibility, the versioning policy and an
index of every document. The Gatekeeper and SmartScreen notes are kept.
scripts/readiness.mjs still finds every documented command in the program.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:14:34 -06:00
james.maesandClaude Fable 5.1 0845036f56 chore(deps): bump transitive fast-uri 3.1.8 to 3.1.9
fast-uri 3.1.9 fixes four URI parsing advisories (GHSA-w74x-v6f9-j3xg,
GHSA-wjr9-j795-jxcr, GHSA-xhq7-xmqj-769h, GHSA-333x-w4r6-6crp). It reaches
us only through ajv in @qrun-io/qqq-ui-api-conformance, so this is a
lockfile-only change, byte-identical to Dependabot PR #9. Landing it on the
release docs branch lets that PR be closed as superseded; npm ci is clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:11:39 -06:00
james.maesandClaude Fable 5.1 34bbd2a317 release: 1.0.0 (client 1.8.0, core 1.0.0, conformance 1.6.0, cli 1.0.0)
All four packages go final together, with the exact workspace pins, so
the orb publishes them as latest. The CHANGELOG's rc.1 entry becomes
"## [1.0.0] - YYYY-MM-DD" (date to fill in on release day),
docs/release-notes/1.0.0.md is the release body with the final sequence
(tags v1.8.0 and cli-v1.0.0, gh release --latest, dist-tag latest), the
README, the CLI README and TRY-IT drop the rc version and @rc, and the
runbook says this branch is fast-forwarded onto main the day rc.1
feedback is accepted. node scripts/release.mjs --check is READY for
1.0.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 21:56:39 -05:00
james.maesandClaude Fable 5.1 6517373b93 docs: CHANGELOG for the UI's adorned grid cells and text primary keys on create
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 21:52:11 -05:00
james.maesandClaude Fable 5.1 7ca3ca28e7 Merge feat/m1-kernel-cli (ebe700b) into feat/m5-cli-release
TUI: adorned grid cells, text primary keys on create, theme per profile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 21:52:00 -05:00
james.maesandClaude Opus 5.5 ebe700bcba tui: adorned grid cells, text primary keys on create; verified live on the qqq fix branch
Found on the fix-branch sample (8bf6a5a66, private server on 8091): grid
cells showed REVEAL secrets and raw RENDER_HTML; they are now drawn like the
record view (masked, text, CHIP brackets). A WIDGET-adorned field with a
plain value shows the value. Create forms ask for a text primary key (the
archive table's file name) and leave out generated numeric keys. Parity
notes record what was verified live there: weekday criteria (QRY-085
pass), case-insensitive matching, AdornmentLab adornments, the composite
association, the locker refusal, uploads and downloads, sharing, saved
report setup, help. NAV-030 and PRC-020 stay blocked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:49:37 -05:00
james.maesandClaude Fable 5.1 ad09a469f3 release: draft notes for the GitHub release of 1.0.0-rc.1
docs/release-notes/1.0.0-rc.1.md is the user-facing release body (install
table, the Gatekeeper and SmartScreen notes, getting started, what is in
it, known limitations), with the exact rc.1 command sequence for James
in an HTML comment that GitHub does not render. release.mjs prints
--notes-file with that file when one exists for the version, else the
CHANGELOG section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 21:44:18 -05:00
james.maesandClaude Fable 5.1 5b2977b877 docs: CHANGELOG for the UI's case-insensitive search, themes per profile and the soak test
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 21:42:42 -05:00
james.maesandClaude Fable 5.1 537eee5658 Merge feat/m1-kernel-cli (5352a12) into feat/m5-cli-release
TUI case-insensitive search, soak script, theme per profile, docs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 21:42:21 -05:00
james.maes 5352a127f4 Merge branch 'feat/m1-kernel-cli' into feat/m3-tui 2026-10-31 21:39:02 -05:00
james.maesandClaude Opus 5.5 b8846ca8e7 tui: text matches ignore case (CASE_INSENSITIVE), with exact case on request
Quick search sends CASE_INSENSITIVE; a term starting with = matches exact
case. Guided filter text conditions (contains, starts, ends, like) get the
option; the condition menu offers Match exact case, shown as (exact case)
and printed as OPERATOR:CS in the grammar so it survives the round trip.
The palette ignores case; =Text asks for exact case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:39:02 -05:00
james.maesandClaude Fable 5.1 a8c691eba3 docs: CHANGELOG and README for home, find, reports, sharing and the rest of parity
The draft 1.0.0 entry adds, for the CLI, home, find, apps show --widgets,
widget get --record/--run, report run, saved-report, share, login
--username for TABLE_BASED, table columns, api-call, widget-form, script
test, table get --copy, table update -e, labels in filters, case-
insensitive text matching by default (and that the server does not read
the option yet, finding 27) and refusals on DISABLED tables and
processes; for the UI, the bulk wizard, reports and saved-report editors,
sharing, the TABLE_BASED, PKCE and Auth0 sign-ins, widget actions,
dynamicForm and composite inputs, permissions, F1 help slots and the
large-table timings. Known limitations now name only jobs and Auth0, and
parity stands at 0 open CLI rows and 1 UI row. The README grammar table
and sign-in notes follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 20:44:23 -05:00
james.maesandClaude Fable 5.1 ed294ff47f Merge feat/m1-kernel-cli (f16b61f) into feat/m5-cli-release
CLI parity complete; TUI reports, sharing, bulk wizard and sign-ins.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 20:43:29 -05:00
james.maes e6f70b2a81 Merge branch 'feat/m1-kernel-cli' into feat/m3-tui
# Conflicts:
#	docs/parity/feature-matrix.md
2026-10-31 20:43:25 -05:00
james.maesandClaude Opus 5.5 f7ff7b8516 tui: soak test, theme per profile, 1.0 docs read-through; SEC-030 blocked
scripts/soak.sh drives the real TUI in tmux with random keys from the keymap
(F-keys, arrows, paging, Tab, Esc, Enter, single keys, : commands) and fails
on an exit or an error boundary, keeping the last 20 frames; nothing that
quits, saves, confirms a delete or exports is sent (QQQ_UI_NO_QUIT=1 ignores
a Quit chosen from a menu). The theme picker keeps a theme for every profile
or this one. README and TRY-IT: options, environment, per-screen keys, the
settings file, themes. SEC-030 is blocked on qqq-cli#10 and qqq#1359.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:41:22 -05:00
james.maesandClaude Fable 5.1 f16b61f496 merge follow-up: keep the TUI's setup-widget pairs and sharing module, finding 28 linked
The base brought its own sharing module, a manageSession password sign-in and render-model
additions. The CLI now uses records/sharing.ts for qqq share, keeps its sessionId-cookie sign-in
as signInWithPasswordCookie next to the TUI's signInWithPassword, and the render model keeps both
sides: the web's wording and empty states from this branch, the TUI's pairs for the saved report
setup widgets (saved-report show still words them as the web does), its bar-chart default,
fieldValueList indents and block markers. Finding 28 is qqq#1359; SEC-030 also cites qqq-cli#10.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 20:40:22 -05:00
james.maes 6f52f6283d Merge branch 'feat/m1-kernel-cli' into feat/m1-cli-parity
# Conflicts:
#	docs/parity/feature-matrix.md
#	packages/core/src/widgets/render-model.ts
2026-10-31 20:36:49 -05:00
james.maesandClaude Fable 5.1 7bdf116010 core+cli: dashboards, widgets as the web renders them, home and find, reports, saved reports, sharing
The navigation, widget and report rows were the last open CLI rows. The kernel render model now
follows the web's widget contracts (multiStatistics values, location, usaMap, generic, cronUI,
dynamicForm, pivot and filter setups, rowBuilder, every composite block, table extras, chrome,
empty states, the dropdown message), and a loader fetches widgets as ConnectedWidget does:
metadata first (unknown or denied widgets cost no request), parent widgets load their children,
data bag and script viewers read their records, dashboards load every widget once at once.

On top: qqq home, qqq find, apps show --widgets, widget get --record/--run; report run through
the report's process (input fields, GET /download) or the report route; qqq saved-report
(show, preview incl. through an application API, run, set filter/sort/columns, pivot, schedule)
and qqq share for any shareable record. A WIDGET-adorned filter field in a process takes the
filter grammar. Printed filters no longer mark case-sensitive matches (the TUI builds criteria
without options, and its footers must not change).

The sample has no reports, saved reports or editor widgets; stub-server CLI tests cover them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 20:31:14 -05:00
james.maesandClaude Opus 5.5 d0dab57f73 tui: AUTH_0 sign-in, help slots and authoring keys, column help, PRF-001 timings; parity re-scored
AUTH_0 signs in in the browser (authorization code + PKCE with the API
audience, code exchanged at the tenant, the token handed to manageSession)
and can end the Auth0 session on sign-out. F1 uses the kernel's help
entries (app, widget, instance, table, process, step) with readable places;
:helpkeys shows every help slot key. The grid footer shows the column's
help. scripts/perf-large-table.sh times a 10,000 x 40 table on the fake
large-instance server: first page ~100 ms, record 10,000 ~40 ms, column 40
~350 ms. Parity: TUI 330 pass, 67 n/a, 3 blocked, 1 todo (SEC-030: needs a
real Auth0 tenant).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:30:18 -05:00
james.maesandClaude Opus 5.5 d982d50d07 tui: widget controls, saved report setup, dynamicForm editing, ad hoc buttons, permissions
core: widgets/controls.ts (dropdowns, selections reconciled, params, date
picker values, export CSV); the render model draws dynamicForm, cronUI with
the record, usaMap, location, process, rowBuilder, setup widgets, image/
audio/icon/unsupported blocks, typed table cells, empty states, untyped
charts, single-block payloads and hides hidden alerts; PASSWORD values masked.
tui: WidgetView (v1 widget route, stored dropdown selections, denied
widgets not requested, parent widget children in grid or tabs, export,
process widgets, data bag versions, script revisions) on home, app homes and
record sections; saved report filter/columns/pivot setup editors (and the
API-versioned setup through the application API); WIDGET-adorned filter
fields in forms; dynamicForm editing; ad hoc widget buttons and Enter-
submit inputs; negative horizontal bars; disabled tables, processes and
apps explain the denial; capabilities gate new/edit/delete/copy; tables
without sections show their fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:19:59 -05:00
james.maesandClaude Fable 5.1 d62b6cd9d3 client+core+cli: TABLE_BASED sign-in, AUTH_0 audience, DISABLED tables and processes
TABLE_BASED servers could not be signed in to at all. The server's TableBasedAuthenticationModule
accepts HTTP Basic credentials on any route, inserts a session and answers with a sessionId
cookie; qqq login --username now does that once (password asked, or QQQ_PASSWORD), keeps only
the session and sends it as the sessionId cookie, which the typed client could not do, so the
CLI wraps its fetch. A refused password stores nothing; logout ends the session.

AUTH_0 servers were refused by the native sign-in discovery. The client now builds the sign-in
from the tenant, the native (or SPA) client id and the audience, which Auth0 needs to issue a JWT
the server can verify. No AUTH_0 server exists to run it against (finding 28).

DISABLED tables and processes come in the metadata with their permissions false; the CLI lists
them with the reason and refuses query, writes, copy (insert needed), bulk commands and process
run before any request, as the web explains the denial.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 20:15:00 -05:00
james.maesandClaude Fable 5.1 61c3a5694e core+cli: text matches send CASE_INSENSITIVE by default; findings 26 and 27
qqq ruled that CONTAINS, STARTS_WITH and ENDS_WITH are case-sensitive by design (finding 11,
qqq#1344) and that clients opt in per criterion with CriteriaOption.CASE_INSENSITIVE. The
grammar and quick search now add the option to every text match; ~= , OPERATOR:CS and
--case-sensitive ask for a case-sensitive match, and printed filters mark those with :CS.

The option does not reach the backend over v1 yet: QFilterCriteriaDeserializer ignores
options and the spec does not declare them (finding 27), so matching stays case-sensitive
until the server reads it. Finding 26 records the bulk mapping suggester problem the kernel
works around (it was cited as finding 11 by mistake).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 20:07:56 -05:00
james.maes 1bc1e4d122 Merge branch 'feat/m1-kernel-cli' into feat/m3-tui 2026-10-31 20:03:51 -05:00
james.maesandClaude Opus 5.5 cafbef1898 tui: record sharing, TABLE_BASED and PKCE sign-in, provider sign-out, session expiry
core: records/sharing.ts (share processes, audience, owner rule) and
signInWithPassword (TABLE_BASED via manageSession with Basic credentials,
nothing kept on refusal). cli: exports browser-login for the TUI.
tui: share dialog on shareable records and saved views (Z, :share);
TABLE_BASED username and password prompts; OAUTH2 sign-in in the browser
(authorization code + PKCE) with the device flow as the alternative;
sign-out ends the server session or the provider session; a 401 mid-use
opens sign-in on top and returns to the refreshed screen; :views.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:03:51 -05:00
james.maes 529d9ef612 Merge branch 'feat/m1-kernel-cli' into feat/m1-cli-parity 2026-10-31 19:58:52 -05:00
james.maesandClaude Fable 5.1 21a1fa94ca docs: record/query/process parity rows, TRY-IT commands, plan checkpoint 2
The CLI column moves to 232 pass, 2 blocked (finding 19 for variant writes, finding 24 for
weekday criteria on H2) and 1 partial (NAV-028, navigation group next). Rows the sample cannot
exercise say unit-tested or stub-server tested in their note.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:58:52 -05:00
james.maesandClaude Fable 5.1 decd6cd39f core+cli: record, query and process parity: columns, filter labels, variant memory, editors, copy, ad hoc widgets
Closes the record, query and process gaps the parity matrix listed. Each capability lands in
the kernel with tests and a thin command on top:

- possible-value labels in --where are resolved to ids, as the web filter sends them
- table columns keeps the query columns per table in the config the TUI column chooser uses
- the variant chosen for a table is remembered per server (--variant - forgets it)
- ad hoc composite widgets: conditional blocks, INPUT_FIELD blocks asked as step fields,
  BUTTON actionCodes offered as choices
- HTML fields show as text; table update -e edits HTML and code in $EDITOR
- CHIP values carry their color and icon (colored on a terminal; tables measure ANSI-free width)
- table get --copy, table api-call (the API playground), script test, table widget-form
  (a dynamicForm on a record form, saved in its merged JSON field)

Stub-server CLI tests cover what the sample cannot exercise (variants, saved views, audits,
application APIs, script tests, dynamicForm).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:58:52 -05:00
james.maesandClaude Opus 5.5 9e71723184 tui: bulk wizard on the kernel's runBulkProcess
Bulk insert and bulk edit with a file (file navigator upload, column mapping
grid with typed defaults and field help, value mapping with the picker,
review with optional validation, result), bulk edit (switched-on fields) and
bulk delete; saved bulk load profiles (yours and shared, owner-only update
after a confirm listing the changes, delete); the upload template; table
actions list the bulk actions with their CLI commands. Verified live on the
sample: 2 person rows loaded from CSV, bulk edited, bulk deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:54:50 -05:00
james.maesandClaude Fable 5.1 b501bcda81 docs: bulk load and bulk changes in the CHANGELOG and the README overview
table bulk-insert, bulk-edit, bulk-delete, table actions and
bulk-profile join the draft 1.0.0 entry and the grammar table; the
known limitation "bulk load is not in the CLI" is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:42:19 -05:00
james.maesandClaude Fable 5.1 68bbf48d59 Merge feat/m1-kernel-cli (69006be) into feat/m5-cli-release
The TUI reports runner and the CLI bulk-load commands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:41:50 -05:00
james.maes 5be588a79e Merge branch 'feat/m1-kernel-cli' into feat/m3-tui 2026-10-31 19:41:44 -05:00
james.maesandClaude Opus 5.5 11e0aacb7e tui: report runner tests (streaming and process reports)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:41:44 -05:00
james.maes 69006beb5f Merge branch 'feat/m1-kernel-cli' into feat/m1-cli-parity
# Conflicts:
#	docs/parity/feature-matrix.md
#	docs/server-findings.md
#	scripts/parity-matrix.py
2026-10-31 19:40:12 -05:00
james.maesandClaude Fable 5.1 c798a0e0c4 docs+tests: bulk load parity rows, TRY-IT bulk section, stub-server CLI tests
The bulk rows (PRC-016, 029-034, 046, 058-061, QRY-031/032/034) are verified live against the
sample, except saved profiles, which the sample does not offer: those are covered by a stub-server
CLI test. The matrix script gains a blocked status (a note must cite a server finding) so the
release gate can tell a server limitation from unfinished work.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:37:47 -05:00
james.maesandClaude Opus 5.5 cf18f9a9c9 tui: run reports from the app home, tree, palette and F9
A report with a process runs through its screens and saves its file on
completion; otherwise the format is chosen and the streaming report is saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:37:32 -05:00
james.maesandClaude Opus 5.5 6d649741a5 docs: finding 24 links qqq#1355; QRY-085 blocked on it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:37:32 -05:00
james.maes 14b1c8a351 Merge branch 'feat/m1-kernel-cli' into feat/m1-cli-parity 2026-10-31 19:35:55 -05:00
james.maes 41219523db Merge branch 'feat/m1-kernel-cli' into feat/m3-tui 2026-10-31 19:34:53 -05:00
james.maesandClaude Fable 5.1 2f6d9cdb41 tui: record value widths from the field types; parity: the partials re-scored
Record view value columns start from the field set (DATE_TIME 30, dates,
numbers, text length) as well as the first record, so an empty first
record no longer cuts later values; still fixed while stepping.

Parity TUI column: the partials resolved: passes (live or unit-tested, said
in each note), browser-only n/a with the reason (icon fonts, logos, URL
state, rich-text and code editors), and blocked on server findings 13
(cancel keeps the run) and 21 (no search provider). Remaining partial: report
runs, the 10,000 x 40 budget, ad hoc widget buttons, bulk load (parked),
OAuth authorization code + PKCE.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:34:05 -05:00
james.maesandClaude Fable 5.1 7e8be15c1c docs: CHANGELOG 1.0.0-rc.1 covers the terminal UI and the work merged since
The entry now describes qqq ui (Home, F9 command center, the grid,
records, uploads and downloads, processes, developer mode, tags,
dashboards, terminal checks) and the CLI's developer-mode and tag
commands, in user-facing terms, and says final waits for parity. It is
the draft of the 1.0.0 entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:32:48 -05:00
james.maesandClaude Fable 5.1 4824f2d613 release: the readiness doc check ignores shell comments in code blocks
A comment such as '# qqq 1.0.0-rc.1 (...)' after a command was read as a
qqq command line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:32:16 -05:00
james.maesandClaude Fable 5.1 1f4760cd54 docs: TRY-IT section for the release binary (qqq-bin) and the quarantine note
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:31:59 -05:00
james.maes 2723792a86 Merge branch 'feat/m1-kernel-cli' into feat/m3-tui
# Conflicts:
#	docs/server-findings.md
2026-10-31 19:31:42 -05:00
james.maesandClaude Fable 5.1 3a315bfe7d release: a final CLI ships with final client, core and conformance
The orb (PR #165) publishes all four packages as rc when any of them is
a prerelease and as latest only when all are final. The readiness gate
gains a package-versions line that refuses a final CLI version while
client, core or conformance is still a prerelease; release.mjs derives
the dist-tag from all four. The runbook's version table, the rc-to-final
step and the publish step follow the rule, and its signing step now
uses sign-macos.sh and sign-windows.sh.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:31:41 -05:00
james.maesandClaude Fable 5.1 725cdfb68a tui: the priority partials: selection menu, pinned columns, column chooser search and groups, cell links, nested child lists, help by role, CHIP and WIDGET fields, widget grid, record paging in steps, saved-view rename
- Grid: Ctrl+A opens the selection menu (this page, the full result, the
  first N, clear) and the bulk bar runs processes on that selection; pinned
  columns stay left while scrolling; DATE_TIME criteria typed in the local
  zone go out in UTC (kernel filterDateTimesToUtc); l opens the record a
  possible-value cell links to; the empty state offers F7.
- View settings: / searches the column list, columns grouped by table and
  join with shown counts, * marks what differs from the default.
- Saved views: rename and update with the current query.
- Record view: child lists three levels deep (a pet's notes under the pet),
  section help, CHIP values in their color, WIDGET-adorned fields drawn as
  widgets; F1 shows table, section and field help for the screen's roles
  (view, edit, query) and process and step help; forms show field help.
- App dashboards lay widgets out by gridColumns side by side on wide
  terminals.
- Process steps page their RECORD_LIST (n / p) through getProcessRecords.

Tests: a fake acceptance table and process cover CHIP, WIDGET, help by role,
a form adjuster, audits, column statistics, developer scripts and logs,
table APIs, a section widget with the record context, a composite
association, a named step widget, record paging, back step and a
DOWNLOAD_FORM; and the grid selection, pins, UTC filter and cell link.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:31:30 -05:00
james.maesandClaude Fable 5.1 5656952e22 scripts: sign-macos.sh and sign-windows.sh, one command each
sign-macos.sh signs the darwin binaries with a Developer ID under the
hardened runtime with Bun's documented entitlements, notarizes them
(keychain profile or App Store Connect key) and recomputes SHA256SUMS;
sign-windows.sh Authenticode-signs the .exe with osslsigncode. Both read
everything from environment variables, exit 0 with what is missing when
it is not set, and have --dry-run. Checked here: both no-op messages and
dry runs; an ad-hoc hardened-runtime signature with the entitlements,
after which verify-binary.sh against the sample server and the qqq ui
tmux smoke still pass; and a real osslsigncode signature with a
throwaway self-signed certificate in Docker, which verifies against
that certificate and changes the recorded checksum. Notarization and a
real Developer ID or Authenticode certificate were not available.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:30:55 -05:00
james.maes 12181ca183 Merge branch 'feat/m1-kernel-cli' into feat/m1-cli-parity
# Conflicts:
#	docs/server-findings.md
2026-10-31 19:30:54 -05:00
james.maesandClaude Fable 5.1 80deeb266f core+cli: bulk insert, bulk edit (with and without a file), bulk delete and saved bulk load profiles
Bulk load was the largest parity gap: the CLI could not load a file or change many records at
once. The kernel gains the bulk load model (table structure, v1 profile, mapping documents, the
server's suggestion cleaned per finding 11, mapping checks, diffs, upload templates) and
runBulkProcess, which drives the bulk processes through the runner with one callback per screen
(edit form, column mapping, value mapping, review), so the TUI can reuse it. Profile values go
as flat strings (fieldListJSON), cleared bulk-edit fields as "" (finding 23), and the file is
sent again after Back to the upload. BULK_LOAD_PROFILE_FORM no longer counts as an input, so the
result step is the last screen.

The CLI adds table bulk-insert, bulk-edit (--set/--clear, or --file with --key), bulk-delete,
table actions and bulk-profile list|show|save|rename|delete. Mapping comes from the server's
suggestion, a --mapping document (YAML/JSON), --column/--default, or -i prompts; the review is
shown, validation runs by default, and loading asks unless --yes (exit 2 without a terminal).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:30:35 -05:00
james.maesandClaude Fable 5.1 f241e29383 release: readiness gate for a final release, and npm run release -- --check
scripts/readiness.mjs reports what a final release still lacks: parity
rows that are partial or todo in the CLI or TUI column (a blocked row
must cite a finding or issue), server findings without an issue link, a
missing CHANGELOG entry for the version, and qqq commands quoted in
README.md and TRY-IT.md that the program (createProgram from the built
CLI) does not have. release.mjs refuses a non-prerelease version until
the report is clean, prints it as information for a prerelease, and
--check prints only the report. Today: 122 CLI and 152 TUI rows open,
everything else clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:28:32 -05:00
james.maesandClaude Fable 5.1 e4a9117942 Merge feat/m1-kernel-cli into feat/m5-cli-release
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:24:00 -05:00
james.maesandClaude Fable 5.1 36739f7787 docs: release runbook for 1.0.0, and the with-UI binary in README and CHANGELOG
docs/release-runbook.md goes from a green branch to 1.0.0 published:
James's checklist first (orb release, CircleCI plan, Developer ID and
notarization, Authenticode, the tap and bucket repositories, the publish
token, the go decision, each with what happens without it), what is
not verified, then version bumps, release.mjs, signing, tags and the
orb publish, the GitHub release, tap and bucket updates, the smoke after
publishing and recovery. The README and CHANGELOG describe the one
binary with qqq ui, the Gatekeeper prompt and the xattr workaround. The
orb proposal record gains the rc-versus-latest dist-tag change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:23:55 -05:00
james.maesandClaude Fable 5.1 4778298f3b release: final versions print dist-tag latest and a latest GitHub release
A prerelease keeps rc and --prerelease; 1.0.0 prints --tag latest,
--latest, and the dist-tag commands that move latest while the orb
still publishes everything as rc. The printed packaging step names the
tap's formula path and the signing step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:23:55 -05:00
james.maesandClaude Fable 5.1 05846459dd scripts: verify-binary.sh and .ps1, the binary smoke for CI lanes and releases
Version with "binary with ui", completion for four shells, qqq ui
--check naming the build, and with a server: login, doctor, whoami, a
query and a process run, in a temporary profile (file keyring, or the OS
keyring removed again). Run here on darwin-arm64 (both keyrings), in
Debian amd64, and the .ps1 under PowerShell 7 in a Linux container, all
against the sample server; never on Windows. The proposed lane YAML now
calls them and still validates; ci-lanes.md says Windows x64 and macOS
x64 stay unverified until the lanes run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:21:50 -05:00
james.maesandClaude Fable 5.1 73bc9cf1e0 docs: parity TUI statuses for the priority batch; blocked status; finding 23
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:19:44 -05:00
james.maesandClaude Fable 5.1 654fb08c83 packaging: a ready-to-copy QRun-IO/homebrew-tap and the with-UI artifact
docs/packaging/homebrew-tap/ is the whole tap repository (formula,
README, LICENSE, qqq-orb homebrew-tap profile); the formula moves there
as the single source, installs only bin/qqq with completions, says qqq ui
is bundled, and its test fails on a binary without the UI. The binary is
chmod 0755 before install: Homebrew downloads arrive without the
execute bit and the completion step runs the binary (found by installing
the formula in the homebrew/brew container, where it then installed,
passed brew test and wrote the three completion files). The Scoop
manifest describes the with-UI binary and notes the ConPTY requirement.
The packaging README covers Gatekeeper without notarization and the
xattr workaround, SmartScreen, and what is still unverified.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:19:13 -05:00
james.maesandClaude Fable 5.1 e65c8a4495 core+tui: the 13 priority todos: joins, quick filters, weekday, bulk paste, first N, scanner, counted views, variants on records
- Exposed joins (QRY-020/021, REC-064): grid columns and filters take
  joinTable.field (labels "Join: Field"); the kernel ResultCursor sends
  the planned joins with query and count and reads the distinct count for
  many-side joins ("3 rows (2 distinct)"); record sections show fields of
  readable exposed joins through a joined query for the record.
- Quick filters (QRY-080/081): the filter dialog starts with the table's
  quick filter fields (defaultQuickFilterFieldNames, else T1) and an add
  row whose fields are remembered per table.
- Weekday criteria (QRY-085): the kernel grammar reads weekday(field) IN
  1-7 or day names into a WeekdayOf fieldFunction (DATE_TIME fields get
  WeekdayOfDateTime with the instance's arguments); the builder offers day
  of the week is any of / none of. The sample rejects it: server finding 23.
- Bulk paste (QRY-088): list values accept pasted text split on commas,
  tabs, semicolons and new lines, choice labels resolved to ids, with the
  instance's bulkAddFilterValues help; bracketed paste reaches the focused
  editor everywhere.
- First N (QRY-033): table processes run on the selection, the cursor
  record, the whole result, or its first N records.
- Scanner screens (PRC-044): no heading or step list; Enter submits.
- Counted quick views (QRY-094): doCount views show their counts.
- Variant (REC-063): records and forms opened from a variant grid read
  with tableVariant.
- The grid shows a failed query instead of keeping the previous rows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:18:44 -05:00
james.maesandClaude Fable 5.1 8ce8f96838 docs: finding 23, SSE writer drops empty arrays (qqq#1354)
The qbit-collaboration fix for #52 showed the presence qbit already sent
an empty viewers list; qqq core's SSE serializer removes it. Recorded so
the TUI's presence handling and the core fix point at the same cause.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:13:32 -05:00
james.maesandClaude Fable 5.1 04d8259434 Merge feat/m1-kernel-cli (98e426b) into feat/m5-cli-release
The TUI batch (fixed-height rows, uploads and downloads, sort, process
screens, saved views, variants), so the with-UI binary carries it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:10:50 -05:00
james.maesandClaude Fable 5.1 95b8d3a83c cli+tui: qqq doctor and qqq ui --check say which build is running
A first 'build' line names the version, the install (which binary, with
or without the UI, commit; or the npm package or a checkout), the
runtime (the Bun embedded in a binary, or Node) and the platform, so
support can read it from one paste. qqq --version adds the runtime too.
doctor's minimum UI API now comes from version.ts instead of a second
constant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:09:53 -05:00
james.maesandClaude Fable 5.1 690a7ef533 build: the qqq binary carries the terminal UI by default
James's decision: one qqq binary per platform with the UI is the release
artifact. npm run binaries and release.mjs bundle the TUI unless
--cli-only. Both entries now start the built CLI (packages/cli/dist),
the module the TUI imports as @qrun-io/qqq-cli/program, so the CLI
program is in the bundle once (darwin-arm64: 100.8 -> 100.5 MB; the rest
of the 16 MB over the CLI-only build is React, OpenTUI and its native
library).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:09:53 -05:00
james.maes 98e426b69d Merge branch 'feat/m1-kernel-cli' into feat/m3-tui 2026-10-31 19:08:45 -05:00
james.maesandClaude Fable 5.1 dcf136d5ff tui: process DATE_TIME in the local zone and variants on processes; parity TUI column re-scored
Process step forms show DATE_TIME inputs in the local zone and send UTC
instants (kernel datetime); processes launched from a variant grid carry
tableVariant through the runner.

scripts/parity-matrix.py: the TUI column re-scored against what is built
now. Priority families (NAV, PRF, PRC, QRY, REC, REL): 150 pass, 44
partial with a reason, 13 named todo, 42 n/a. All rows: 180 pass, 49
partial, 103 todo, 69 n/a.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:06:46 -05:00
james.maes 10e8ca2620 Merge branch 'feat/m1-kernel-cli' into feat/m3-tui 2026-10-31 19:04:54 -05:00
james.maesandClaude Fable 5.1 abf4a49c20 tui: quick search, column statistics, saved views and table variants in the grid
q quick-searches the visible text columns (kernel quickSearchFilter, ANDed
onto the filter with combineWithQuickSearch; Esc clears). S shows column
statistics of the column under the cursor with the current filter (kernel
runColumnStats; the server's refusal when the table has no QUERY_STATS).
F9 › Saved views lists, opens (filter, sort, columns via viewToQuery),
saves the current query and deletes views (kernel saved-views). A table
with variants asks for one before the first query, sends tableVariant on
query and count, and dismissing the prompt queries nothing.

Tests: quick search criteria on the wire and Esc, the variant prompt and
the tableVariant parameter. The sample has no QUERY_STATS, saved-view or
variant table, so those were seen live only as their refusal messages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:04:54 -05:00
james.maesandClaude Fable 5.1 496528ae6a Merge feat/m1-kernel-cli (6c56b89) into feat/m5-cli-release
The server findings now link each finding to its filed issue.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:03:53 -05:00
james.maesandClaude Fable 5.1 6c56b8955a docs: link each server finding to its filed qqq issue
James approved filing the 22 findings; one issue per finding except the
archive-table one (already open as #784), the two record-search ones
(one root cause, #1343) and the presence one (qbit-collaboration#52).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:03:15 -05:00
james.maesandClaude Fable 5.1 202703135d tui: process steps draw the kernel screen (help, HTML, view values, records, widgets, downloads); back; record limits
The wizard renders each step's screen blocks from the kernel runner (help
content, HELP_TEXT, HTML, VIEW_FORM values, RECORD_LIST records, named and
ad hoc widgets, DOWNLOAD_FORM files) above the form or result; steps
without input are shown through the runner's present callback. F4 goes
back to the backend back step when the step has one; w saves a DOWNLOAD_FORM
file through the save dialog; min and max input records are checked before
init (kernel checkInputRecords).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:02:53 -05:00
james.maesandClaude Fable 5.1 a8a105a3d5 tui+core: file uploads and BLOB downloads, sort in view settings, more filter operators, width smoke
Forms: BLOB and FILE_UPLOAD fields choose a file in the file navigator and
the record is sent as multipart (create and edit). Process steps hand the
chosen files to the kernel runner through its new stepFiles option, sent
with that step. Record view: a BLOB stored in the record saves through the
save dialog (Enter on the field, or the actions list); child lists offer
Save entries for their BLOB and FILE_DOWNLOAD fields. Verified live on the
sample's Field Lab: a create with blobValue uploaded from a file, then the
value saved back byte for byte.

Grid view settings choose the sort (field and direction), kept per table and
applied when the grid opens. The filter builder offers the operators of the
field type, now with NOT_EQUALS_OR_IS_NULL, IS_NULL_OR_IN, IN on dates and
EQUALS on booleans, and More operators… for the rest. When the column layout
is frozen, text columns take spare width up to 40 cells so later pages have
room before values are cut.

Tests: filtered export (filter and visible columns sent, file written), a
file field uploaded as a multipart part and a BLOB saved from the record
view, runner stepFiles. scripts/smoke-widths.sh checks page 1 against page 2
in tmux on the fake large instance.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:01:16 -05:00
james.maesandClaude Fable 5.1 6c200a6030 docs: ready-to-send proposal for the two orb changes
Publishing @qrun-io/qqq-core (blocking a usable CLI package) and opt-in
macOS and Windows lanes, written as a message for James with the
cli_publish.sh diff; the Node 22.12 row is green after the prompt fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:00:32 -05:00
james.maesandClaude Fable 5.1 69e7ad669a release: --with-tui, and the TUI distribution findings
release.mjs passes --with-tui to the binary build. docs/packaging
records the three ways tried to ship qqq ui (one binary with the UI, a
second qqq-ui binary, npm with Bun), the sizes, what was smoke-tested
and what was not, and recommends one qqq binary with the UI bundled.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:59:58 -05:00
james.maesandClaude Fable 5.1 5ec288185b build: --with-tui bundles the terminal UI into the qqq binary
scripts/binary-with-tui.ts is a Bun-only entry that registers the
bundled TUI and starts the CLI, so the Node build is untouched. The
build installs OpenTUI's native package for every target (bun install
--os='*' --cpu='*' in packages/tui), pins OPENTUI_LIBC per Linux libc
and defines QQQ_BUILD_UI, which qqq --version prints as "with ui".
Source maps that bun writes beside each binary now go to
dist/sourcemaps/, so the release glob dist/qqq-* no longer uploads them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:59:21 -05:00
james.maesandClaude Fable 5.1 588caf6203 cli: qqq ui says how to get the terminal UI instead of an internal path
In a binary or an npm install, qqq ui printed "looked for
/tui/src/main.tsx". It now says the TUI is not in this installation
and names the two ways to get it: a qqq binary built with the UI, or a
source checkout with Bun. A binary that bundles the TUI registers
globalThis.__qqqRunTui, and qqq ui hands over to it in-process with the
same arguments it would pass to bun run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:59:21 -05:00
james.maesandClaude Fable 5.1 3d29c1390d tui: quick actions like the web dashboard (4 processes, 6 tables); smoke no longer relies on vanished rows
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:55:03 -05:00
james.maesandClaude Fable 5.1 141b6513a6 cli: a question closed by Ctrl-D fails on Node 22.12 too
On Node 22.12, readline/promises leaves a pending question unsettled
when Ctrl-D closes the interface, so the prompt tests (and a real
prompt) hung until the event loop drained; that is the orb image. The
question now also settles when the interface closes: a usage error for
end of input, the caller's abort when it aborted. Ctrl-C still reaches
SIGINT (process run cancels, exit 130); checked on Node 20.20, 22.12
and 24.22 and with a real Ctrl-C and Ctrl-D at the clonePeople review.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:54:23 -05:00
james.maesandClaude Fable 5.1 d81f4a490d tui: rows no longer vanish when a screen overflows (cause of the missing card line)
Cause: OpenTUI text rows default to flex-shrink 1 and word wrap. When any
row is wider than its box it wraps, the column overflows, and yoga shrinks
other rows to zero height, so lines in the middle of a screen disappeared
(the Home application card's counts line in the in-memory test, and any
screen in a real terminal whose content overflowed). Every text row now has
flexShrink 0 and wrapMode none, and panels, dialogs and the frame clip with
overflow hidden, so an overlong line is cut at the edge and extra rows are
cut at the bottom. A layout test pins it; the Home test asserts the frame
again instead of the card data.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:54:03 -05:00
james.maesandClaude Fable 5.1 29d848f822 Merge feat/m1-kernel-cli (bf859a5) into feat/m5-cli-release
Developer mode and the TUI batch (Home screen, search-first F9, the
metadata index, audits, tags and developer screens).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:52:33 -05:00
james.maesandClaude Fable 5.1 d4bbccaf42 release: include the musl binaries in a release build
scripts/install.sh picks qqq-linux-<arch>-musl on Alpine, so a release
without them would leave those hosts with a 404.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:50:11 -05:00
james.maesandClaude Fable 5.1 bf859a5bb8 tui: smoke script checks the Home panels
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:50:05 -05:00
james.maesandClaude Fable 5.1 9c7efe606b core+cli+tui: collaboration tags; docs for Home, large instances, new keys
core: Collaboration.tags (vocabulary search), createTag, recordTags and
applyTags (add/remove in one request), with fake-transport tests.
cli: qqq collab tags list|add|remove|create (ids or labels).
tui: with the collaboration extension, a record shows its tags under the
header; t (F9 Tags…, :tags) opens the tag editor: remove with Delete, add
from the vocabulary searched as you type, create a new tag inline (a
suggestion on approved-only tables). Without the extension nothing shows
and :tags says it is not installed. The UI API has no rename or delete of
tags and no tag filter criterion, so those are not offered.

Verified live on the qbit-collaboration dev server (port 8098, separate
config dir, stopped afterwards): CLI list/add/create/remove, the TUI tags
line, the editor search, adding tui-test from the TUI.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:49:41 -05:00
james.maesandClaude Fable 5.1 01fdf25562 docs: README for the 1.0 release candidate and a CHANGELOG
The README now leads with what qqq is, how to install it (binary,
installer, npm), the first sign-in and the noun-verb grammar, then
points at TRY-IT and the docs. The CHANGELOG's 1.0.0-rc.1 entry sums
up the M1 work since 603c090 and the hardening review.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:49:29 -05:00
james.maesandClaude Fable 5.1 70c4fae565 docs: CI lanes, what the orb cli profile really runs and macOS/Windows lanes
Records the orb profile as verified in Docker (Node 22.12 image, no lint,
core never published, one platform), the Node 22.12-only prompt test
failure, and a proposal for macOS arm64 and Windows x64 lanes: an orb
change with a .qqq-ci.yml opt-in, or a second CircleCI pipeline whose
YAML validates with the CircleCI CLI. .circleci/config.yml is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:48:21 -05:00
james.maesandClaude Fable 5.1 8e26c71d59 tui: audits, developer mode, actions list; F-key bar teaches the keymap; core: macOS F-key note
Record view: A opens the audit history (kernel records/audits; newest
first, who, when in the local zone, what, and the changes; the refusal text
when the table has no audit source), D the developer screen (kernel
records/developer: the server's developer panels drawn from blocks, the
associated scripts with revisions, show code, run logs, store a new version
from $EDITOR with a commit message), a the actions list (the F9 Context
entries). On a grid, a lists actions and D shows the table's application
APIs with their OpenAPI documents (view or save through the file dialog).
:audits, :dev and :actions do the same; F9 lists them.

The F-key bar shows the global label of a key that does nothing on the
current screen, dimmed, and switches to short labels below 100 columns.
Esc then a digit (Alt+digit) and Ctrl+digit act as F1..F10 for terminals
that take the F-keys. The terminal check adds the macOS function-key note
(and iTerm2's own F-key bindings).

Verified live on the sample: developer panels (Record Security, ESB
Events), no associated scripts, no table APIs, no audit source (refusal
shown).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:46:06 -05:00
james.maes 13e3d4d73b Merge branch 'feat/m1-kernel-cli' into feat/m3-tui 2026-10-31 18:42:46 -05:00
james.maesandClaude Fable 5.1 fd4d6d3652 tui: Home landing page, search-first F9 and palette, F9 as a stack, lazy dashboards, fixed column widths
Home is the counterpart of the web dashboard: greeting, connection and
counts, recently viewed records, quick actions (runnable processes, new
records, favorites with collaboration), application cards in a grid, work
(jobs, alerts, my work, release notes, extensions) when present, and the
instance home app's widgets when the metadata declares homeAppName; Tab
moves between panels, arrows inside, Enter opens, v orders the panels.

Large instances: the store builds the kernel metadata index once per
document; the palette and F9 show recents, commands and counts first and
search the index as you type (a page at a time, more on scrolling); the Apps
tree opens collapsed and filters as you type; app dashboards load widgets
and counts only as they come into view, four requests at a time, cancelled
when the screen is left. scripts/fake-big-server.ts serves a generated
1000-table, 500-process, 3000-widget instance.

F9 is a stack: entries that open dialogs leave it underneath and come back
to the same entry with refreshed states (the theme entry opens a picker);
navigating entries close it.

Grid column widths are computed once (metadata, view settings, first page)
and kept while paging, sorting and filtering; record value columns and child
list widths are kept while stepping records.

Tests: fixed widths across pages with longer values, the F9 stack with the
theme picker, Home panels, and F9 and palette timings on the generated
large instance (about 40-60 ms including the 30 ms settle).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:42:42 -05:00
james.maesandClaude Fable 5.1 bef8762aa0 release: run the TUI's Bun tests and type check before a release
The TUI is neither published nor bundled into the qqq binaries yet, but
it builds on the same kernel, so a release that breaks it should stop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:41:12 -05:00
james.maesandClaude Fable 5.1 7893dd465e Merge feat/m1-kernel-cli (4728702) into feat/m5-cli-release
Brings in the TUI package and the hardening review (createProgram in
program.ts, qqq doctor, the golden-output suite). Resolutions: main.ts
from the base; program.ts prints versionText(), whose second line now
starts with "UI API client <version>" as the golden test expects and
adds the supported server range; the CLI package keeps the release
metadata with the base's exports and its new conformance dependency,
and conformance goes to 1.6.0-rc.2 because the CLI now ships against it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:40:33 -05:00
james.maesandClaude Fable 5.1 fc6984661e Merge feat/m1-kernel-cli (hardening) into feat/m1-developer-mode
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:39:04 -05:00
james.maesandClaude Fable 5.1 47f44a3146 docs: developer mode in TRY-IT and parity rows
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:38:35 -05:00
james.maesandClaude Fable 5.1 bd219da46f cli: table apis, table api-doc, table developer; qqq script list, show, store, logs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:38:35 -05:00
james.maesandClaude Fable 5.1 47c3e9480a core: developer mode model (associated scripts, revisions, logs, developer panels, table APIs)
Exports getRecordDeveloper, listDeveloperPanels, listTableApis,
getTableApiDocument, storeScript and getScriptLogs for both heads;
panels render to the neutral render blocks. Revision code is read from
scriptRevisionFile, as the web UI does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:38:34 -05:00
james.maesandClaude Opus 5.5 47287020b4 Merge feat/m1-kernel-cli (165c002) into feat/m1-hardening
Brings in the TUI stream (program.ts with createProgram and runCli, the io
object, qqq ui, session/login, local-state, filter completion, the result
cursor, the file navigator and env/doctor). Resolutions:

- main.ts is the base's thin entry; it still awaits the 0.1 credentials import.
- program.ts carries the option fixes (--version only, --server without -s)
  and registers qqq doctor.
- One set of ui helpers: the TUI's. readUiSettings/writeUiSettings are gone;
  qqq config set|get download-dir, upload-dir and download-mode use
  readFilePreferences/writeFilePreferences (ui.files), and the new
  qqq config set|get theme uses readTheme/writeTheme. readConfig keeps the
  corrupt-file refusal and unknown sections; the profiles test covers files,
  theme and an unknown section together.
- qqq doctor's terminal rows come from checkEnvironment, with formatReport
  under the table; --fix asks before each applyLiveFix and calls
  appendTmuxConf only after a y typed at a terminal.
- runCli and the prompts coexist (stdin is not a terminal in runCli, so
  prompts refuse; direct stdout writes are captured), tested in a child process.
- @qrun-io/qqq-ui-api-conformance is a dependency of the CLI package.
- The parity matrix is regenerated; docs/review-2026-10-31.md records each
  merge decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:37:51 -05:00
james.maesandClaude Fable 5.1 672c90a68c core: metadata search index for large instances
metadata/index.ts builds one index from the cached GET /metaData (apps,
tables, processes, reports, widgets with label, name, app path, hidden
flag) and searches it by exact, prefix, word prefix, substring and
subsequence match, ranked then by label, paged with a total for more.
generateMetaData makes a document of any size; the test indexes 1000
tables, 500 processes, 2000 widgets and 300 reports (0.57 MB: parse 3 ms,
index 4 ms, slowest search 9 ms on this Mac).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:33:51 -05:00
james.maesandClaude Opus 5.5 4af42bb51f docs: review of the merged CLI and kernel; TRY-IT checked against the sample server
docs/review-2026-10-31.md lists each finding of the fresh-eyes review, its
severity and the fix (or why it was left). TRY-IT.md was run block by block
against the sample server and corrected: placeholders written as commands,
missing upload files, record ids the shared data no longer matches; it now
documents the option names shared by every noun, the footer, the exit codes
for a no, Ctrl-C and gateways, the download directory and qqq doctor. The
parity matrix notes REC-022 (BLOB upload and download) as verified and the
REVEAL masking in lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:32:58 -05:00
james.maesandClaude Opus 5.5 0bfdb06afe cli: table stats footer says how many values there are in all
"(3 values; of 5; --limit for more)" read badly; it is now
"(3 values; 5 in all; --limit for more)".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:32:58 -05:00
james.maesandClaude Fable 5.1 8b2ee7946c test: let the shell expand the test glob so npm test runs on Node 20
node --test only expands glob patterns itself from Node 21. With the
pattern quoted, Node 20 looked for a file literally named
dist/test/*.test.js and every workspace's npm test failed, although
the packages declare engines node >=20. The orb's Node 22 image hid it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:32:36 -05:00
james.maesandClaude Fable 5.1 19569d8273 packaging: curl installer, Homebrew formula and Scoop manifest drafts
scripts/install.sh picks the binary for the machine (musl on Alpine,
native arm64 under Rosetta), verifies it against the release's
SHA256SUMS and installs to ~/.local/bin. qqq.rb and qqq.json follow the
qctl tap and bucket shapes; scripts/update-packaging.mjs fills their
version and checksums from the uploaded release so nobody pastes sums
by hand. The formula writes its own #compdef zsh file because the
output of qqq completion zsh is an eval script, not an fpath file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:31:41 -05:00
james.maesandClaude Fable 5.1 165c002d3b tui: F10 offers Quit, Reset (home screen) and Cancel; :reset and :home
Quit ends the program after the unsaved-form or running-process warning.
Reset closes every dialog and screen above Home after the same warning,
refreshes the metadata, and returns to Home with the Apps tree focused,
keeping the session and profile; also :reset, :home and the F9 command
center. Esc cancels. Test, smoke script and docs updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:31:28 -05:00
james.maesandClaude Fable 5.1 99befbb2ef Merge feat/m1-kernel-cli (f364a3c) into feat/m3-tui
Parity matrix regenerated with both the CLI and the TUI statuses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:29:18 -05:00
james.maesandClaude Fable 5.1 dc6513bc8f docs: qqq ui README, TRY-IT TUI section, TUI parity column, server findings 21-22
packages/tui/README.md: running it, keys, screens, the : commands, the local
settings, the keys terminals and tmux may take (with the alternate_on
rebinding), tests and the code layout. TRY-IT.md replaces the spike section
with the qqq ui section. scripts/parity-matrix.py gets a TUI status table
and a TUI summary; feature-matrix.md regenerated (TUI: 118 pass, 25
partial). Server findings: recordSearch advertised with no provider, and
the untyped multiStatistics group (statisticList).

Also: -o -field in : commands and qqq ui routes, ASCII title separators,
short F-key labels at 80 columns.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:28:40 -05:00
james.maesandClaude Opus 5.5 f97ea3283c cli: qqq doctor, the connectivity half (terminal checks wired for the kernel module)
qqq doctor checks the profile, that the server answers GET /version, that its
UI API version is in the supported range (1.6.0 to this client's 1.8.0, major
from supportedMajorVersions), the authentication type, that the stored sign-in
or QQQ_TOKEN is accepted, the installed extensions and their minimums, the
keyring kind and the metadata cache age; --conformance adds the read-only
conformance checks (the conformance package, loaded when installed). Exit 0 when
everything passes, 1 with the failing checks in the footer.

The terminal half calls the kernel's checkTerminalEnvironment() by name, so it
reports "skip" on this base and the report (tmux bindings, colour, size, and
--fix with its two confirmations) once the TUI branch's module lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:28:18 -05:00
james.maesandClaude Fable 5.1 2bc76f6dc4 release: scripts/release.mjs checks a release and prints the commands
Clean tree, free tags, npm ci, the orb's generate-drift, build and test
steps, lint, npm pack dry runs and the binaries; then it prints the
signed tag, npm publish and gh release commands. It never tags,
pushes or publishes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:28:04 -05:00
james.maesandClaude Fable 5.1 e1135b958f build: single-file qqq binaries with bun build --compile
scripts/build-binaries.mjs (npm run binaries) writes
dist/qqq-<os>-<arch>[.exe] for macOS arm64 and x64, Linux x64 and arm64
(glibc), Windows x64 and, with --musl, Linux musl, plus SHA256SUMS. It
refuses to build when the compiled-in operations index no longer
matches the OpenAPI document or the CLI version constant disagrees with
package.json, stamps the commit and target into qqq --version, and
smoke-runs the host binary. x64 targets use the baseline runtime so they
run on pre-AVX2 CPUs and under Rosetta or QEMU.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:28:04 -05:00
james.maesandClaude Fable 5.1 e488df6d70 lint: fail when release versions disagree
Checks the CLI's compiled-in version against its package.json, the
client's UI_API_VERSION against the document's info.version, and every
workspace dependency against the workspace version, so a coordinated
bump cannot leave one behind.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:28:04 -05:00
james.maesandClaude Fable 5.1 0588e902fe release: 1.0.0-rc.1 metadata for the CLI, core and client packages
CLI and core go to 1.0.0-rc.1 and the client to 1.8.0-rc.2, with the
workspace dependencies pinned to them. Every published package now has
repository, homepage, bugs, engines (Node 20+), publishConfig, explicit
exports and a files list that leaves out compiled tests and source
maps. The repository LICENSE is copied in on prepack because npm only
packs a LICENSE from the package root. core had no publishConfig, so a
publish would have gone to the public registry; it now targets GitHub
Packages like the others. The CLI and client packages get the README
their files lists already named.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:28:04 -05:00
james.maesandClaude Fable 5.1 be3aabbee4 cli: qqq --version prints the build and the supported UI API range
The version moves to packages/cli/src/version.ts so the single-file
binaries can stamp the git commit and target (QQQ_BUILD_SHA and
QQQ_BUILD_TARGET defines) without touching the npm build, and so the
supported server range (UI API >=1.6.0 <2.0.0) is stated next to the
version a user reports.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:28:03 -05:00
james.maesandClaude Opus 5.5 db3232f488 cli: golden-output tests for the CLI as a whole
A node:test suite that spawns the built CLI against a fake QQQ server
(node:http) answering with responses recorded from the sample server, in a
throwaway config, cache and file keyring with TZ=UTC and no terminal. It covers
table list, describe, query (table, json, csv, text, yaml, --query, --utc),
count, get, create, update (contingent fields), delete with and without --yes,
export to the download directory, process describe and run (synchronous and a
polled job), widget get and --csv, api, extensions, an installed and a missing
extension noun, exit codes for 400, 401, 403, 404, 500, 503, a refused token and
an unreachable server, bad numeric options, the no-terminal refusals, a corrupt
config.json and the 0.1 credentials import. About 4 s; UPDATE_GOLDEN=1
rewrites the golden files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:26:42 -05:00
james.maesandClaude Opus 5.5 a9c22a7b54 cli: consistent options, record rendering, DATE_TIME display, footers and downloads
Short options that collided: the global -s (--server) took every -s meant as
--set (table create, copy, update, prefs set: "Error: Invalid URL"), the
global -p (--profile) took -p meant as --path (qqq api and every generated
extension verb), and commander's -V (--version) took table query -V (--view),
printing the version and exiting 0. --server and --path have no short form now,
the version is --version only; collab presence --watch lost -w (it is --where
everywhere else) and saved-view save takes -l, --limit like table query.

table update read nothing of the record without -i, so a contingent field
could not be changed ("City: set State first" although the record has one)
and possible values were not narrowed; it now starts from the record, and a
changed dependency also sends the dependents it cleared.

One renderer for record values: query rows, child lists and joined fields go
through the record view's cell mode (REVEAL masked, with --reveal on query and
children), and the duplicate valueOf helper is gone. One DATE_TIME path:
audits, collab comments and people, ESB counters and messages, job logs and
tail now show instants in the zone (or raw with --utc) instead of slicing UTC
text. One help path: table get --help-content and process describe print what
help show prints. One footer: (N records; ...) through countFooter, with
plurals (no more "(1 results)").

One download path (writeDownload): table download and export, report run,
widget --csv and api --out write through saveDownload, take - for stdout, and
default to the download directory (qqq config set|get download-dir, upload-dir,
download-prompt, kept in the ui section the TUI shares); process --out-dir
defaults to it too.

Also: numeric options (--limit, --skip, --page-size, --timeout, ...) are checked
(exit 2) instead of sending NaN; missing files named by --file, --form @file,
--bytes or a JSON file argument are usage errors; table children and download
take --variant; association metadata errors other than 403/404 are no longer
shown as "unavailable"; tail ends quietly with 130 on Ctrl-C; answering n to
Submit? is "Not submitted" with exit 0, as "Not deleted." is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:26:42 -05:00
james.maesandClaude Fable 5.1 e3e0d83d37 core+tui: terminal check (qqq ui --check, at startup, F9 Help)
core/env/doctor.ts finds tmux root bindings that take keys the TUI needs
(PgUp/PgDn, F1-F10, Home/End, Esc, / ? :), checks alternate-screen, TERM,
true color, the locale and the size, and names the terminal program with
its known traps (Terminal.app F-keys, iTerm2 page keys). Each tmux finding
carries a live fix (bind-key -n KEY if-shell -F "#{alternate_on}" "send-keys
KEY" <the old binding>, so full-screen programs get the key and the shell
keeps its behaviour) and a ~/.tmux.conf line appended in a marked block only
after a y. Choices are kept in the config file's ui.doctor; tests run on
fake tmux output.

qqq ui runs the quick check before the renderer starts and asks only about
warnings not dismissed (never), --check prints the full report, --no-check
skips it; a non-UTF-8 locale switches to ASCII glyphs. The F9 Help section
and :doctor show the report in a dialog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:25:02 -05:00
james.maesandClaude Opus 5.5 1d61c7bb33 core: one help text rule, a cell mode for record values, variants on child lists
Help content had three extractors (helpContentText for help show, helpEntryText
in the record view, helpText on process screens) that disagreed: an empty
contentAsText hid the help on process screens, MARKDOWN without rendered HTML
came out differently. helpContentText is now the rule and the other two use it;
the role filter takes several roles (the view screen's).

valueText gets cell mode: the record view's adornment rules (REVEAL masked,
CHIP, ERROR, DATE_TIME in the zone) without link targets, download hints or
tooltips, so record lists can use the same renderer as the record view instead
of showing REVEAL values in the clear.

fetchChildren takes the table variant for child tables on a variant backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:23:48 -05:00
james.maesandClaude Opus 5.5 7b0e932ca9 core: a process that never stops stepping is an error, not a completed run
runProcess looped at most 200 times and then fell through to the normal return,
so a server that kept answering with a nextStep looked like a successful run.
It now throws a ProcessError naming the step (MAX_STEPS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:23:26 -05:00
james.maesandClaude Opus 5.5 22547021a2 cli: one prompt path with Ctrl-C and Ctrl-D handled; exit codes for gateways and bad inputs
Context.confirm (delete, saved-view delete) and the variant chooser opened their
own readline without a SIGINT listener, so Ctrl-C at "Delete? [y/N]" failed
the question with a bare AbortError ("Error: The operation was aborted",
exit 1) instead of exiting 130. Both now go through prompt.ask, which passes
Ctrl-C on as SIGINT, turns Ctrl-D (end of input) into a usage error, and takes
an input stream so it can be tested without a terminal.

Exit codes: 502, 503 and 504 are transient (4), like a refused connection,
rather than 70; any fetch TypeError naming a network failure is 4; a missing or
unknown path parameter of qqq api is a usage error (2, OperationInputError)
instead of 1. callOperation reads an empty JSON body as no data instead of
throwing a SyntaxError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:23:26 -05:00
james.maesandClaude Opus 5.5 9e77c5cdd9 cli: import 0.1 sign-ins only when the keyring took them; report unreadable stored sign-ins
importLegacyCredentials fired the keyring writes without awaiting them
(void secrets().set(...)) and renamed credentials.json straight away: a
keychain that refused the write lost the 0.1 sign-in, and the rejection went
unhandled. The import is now awaited before any command runs, each write is
awaited, and the file is renamed only when all of them succeeded (otherwise it
stays and a warning says why).

getLogin turned a stored sign-in it could not parse into "not signed in",
which surfaced later as a puzzling 401. It now says the sign-in is unreadable
and how to replace it; logout still removes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:23:15 -05:00
james.maesandClaude Opus 5.5 f8554e93f5 core: refuse corrupt config and secrets files instead of overwriting them; ui settings section
readConfig treated a config.json it could not parse as empty, and the next
upsertProfile wrote that back: one stray comma lost every profile. FileStore and
DpapiFileStore did the same to secrets.json, so saving one sign-in wiped the
others. Both now throw (ConfigFileError, SecretFileError) and change nothing.

readConfig also dropped every section it did not know; it now keeps them, and
the ui section (defaultDownloadDir, defaultUploadDir, downloadPrompt) gets
readUiSettings/writeUiSettings for the CLI and TUI to share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:23:06 -05:00
james.maesandClaude Fable 5.1 913e2a22ce tui: in-memory renderer tests, tmux smoke script, dashboard headings; core: multiStatistics
bun test in packages/tui (npm run test:tui at the root): the whole TUI
against a fake server in the test renderer: Esc from record 4 lands on row
4 and after stepping to record 7 lands on row 7; server paging, sort and a
:filter; a contingent form where State waits for Country and narrows;
the process wizard through an asynchronous job to its summary; F10 asks.
Plus unit tests for column layout, the line editor under a key burst,
command-line splitting and theme tokens.

packages/tui/scripts/smoke.sh drives the real TUI in tmux against the
sample server through fourteen screens and keeps the captures.

The kernel's multiStatistics reading accepts statisticList, the spelling
the sample server sends. Lint covers packages/tui.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:22:51 -05:00
james.maesandClaude Fable 5.1 7d79a56111 tui+core: file navigator, file preferences, paging keys that terminals cannot take
core/files/navigator.ts: directory listings (directories first, sizes,
dates), path expansion and Tab completion, new folders, and the local file
preferences (default download and upload folders, last used, ask or save
silently) in the config file's ui section; tests on a temp directory.

The TUI's file dialog draws them: typing filters, Backspace goes up, a path
line with completion, h hidden, ~ home, Ctrl+N new folder, and a save
variant with a sanitized name that asks before overwriting. Exports and
record file downloads use it, or save silently to the download folder when
that is the preference; the F9 Settings section sets the folders and mode.
Ctrl+F / Ctrl+B page everywhere and Space / Shift+Space page lists, for
terminals and tmux setups that take PgUp/PgDn. Fill-the-width spreads over
the visible columns.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:19:29 -05:00
james.maesandClaude Fable 5.1 f519a93b34 tui: record view, forms, presence and extensions on the kernel modules
The record view renders values through the kernel's record-view module
(adornments, REVEAL masking, links, FILE_DOWNLOAD saves via saveDownload),
child lists through associationGroups, childListFields and childFilter, and
Esc from a child record lands on the child last viewed. Forms copy with
copyRecordValues and edit DATE_TIME in the local zone (utcToLocalInput,
dateTimesToUtc); grids show DATE_TIME in the local zone. Extensions use the
kernel ExtensionRegistry; the presence slot is fed by the kernel
Collaboration module when the extension is installed. Go to by key uses
gotoKeys and findRecordByKey. Breadcrumbs follow the app path. The F9 menu
scrolls its columns; saved views, column statistics and audits run their
CLI commands in the output pane. Palette says when the server has no record
search provider.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:15:32 -05:00
james.maesandClaude Fable 5.1 7a1b1c6068 tui: picker advances to the next field; record columns leave room for link marks
Verified in tmux against the sample server: sign-in with the stored
profile, home, the person grid, record 4 with PgDn to record 7 and Esc back
to row 7, the guided and advanced filter (completion, live grammar, CLI
equivalent), and a placeAddress create with country, state and city
narrowing, saved and shown as the new record.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:09:28 -05:00
james.maesandClaude Fable 5.1 be9d78f1f9 Merge feat/m1-kernel-cli into feat/m3-tui
Keeps every kernel export; the CLI entry stays a thin main over program.ts,
which now registers the audit, related, saved-view, extensions and help
commands and the --utc option from the kernel branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:07:04 -05:00
james.maesandClaude Fable 5.1 63ed3eca78 tui: the qqq ui package, first cut (type-checks; not yet verified end to end)
packages/tui on OpenTUI 0.8.1 (React) and Bun, built on the kernel: a store
that holds all state and dispatches every key (lock, overlay, F-keys,
focused pane, fallbacks) so handlers never read stale closures; screens as
route entries that keep their own state (grid cursor, filter, selection) so
Esc returns to the same row; themes as token sets with high-contrast as the
default; sign-in (profiles, MOCK sessions, OAuth device flow), home, app
home with widgets, table grid over the kernel ResultCursor, record view with
child lists and next/previous, forms on FormSession with a narrowing picker,
the process wizard on runProcess, the two-mode filter dialog, view settings,
the F9 command center, palette, help, the : line with history, and the
screen lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:06:31 -05:00
james.maesandClaude Fable 5.1 f364a3c8de Merge feat/m1-kernel-cli (ee855eb) into feat/m1-process-components
Brings in the extensions, related-records and query-gaps streams. The parity
matrix is regenerated from the merged STATUS table; process downloads already
use the shared safeFileName/saveDownload helpers from the base.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:06:15 -05:00
james.maesandClaude Fable 5.1 2bf68e9667 client+cli+dev-idp: OAuth2 browser sign-in with PKCE, provider logout, revoked sign-ins
qqq login --browser signs in with the authorization code and S256 PKCE on a
loopback redirect (RFC 8252): a one-shot listener on 127.0.0.1 takes the
callback, the state is checked, and the code is exchanged at the provider with
the verifier (no nonce, as UI-API-CLIENTS.md section 7 asks). --port,
--redirect-host and --no-open cover exact registered URIs and headless hosts.
A denied request, a forged state or a refused exchange exits 3 and saves
nothing; the new token is checked against the server before it is kept.

qqq logout --provider-logout revokes the refresh token (RFC 7009) and opens
the provider end-session URL with the ID token, which refreshes now keep. A
refresh the provider refuses becomes UNAUTHENTICATED (exit 3, sign in again).

qqq-dev-idp gains the authorize endpoint (Approve/Deny page or auto-approve,
loopback redirects and S256 only), the authorization_code grant, ID tokens,
revocation and end-session. The device flow was run end to end for the first
time against a sample on OAUTH2 (8078) and worked unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:02:40 -05:00
james.maesandClaude Fable 5.1 ee855eb146 Merge feat/m1-kernel-cli (extensions) into feat/m1-query-record-gaps
My server findings move to 19 and 20 after the merged 18.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:01:43 -05:00
james.maesandClaude Fable 5.1 3c75c22d41 Merge feat/m1-kernel-cli into feat/m1-query-record-gaps
Conflicts resolved by keeping both sides: table get keeps the record view
(sections, links, reveal, help) and gains --join and --variant; create and
update keep uploads and parents and send the variant; filters keep the
DATE_TIME to UTC conversion and accept joined names; export keeps safe
file names. My server findings are renumbered 18 and 19 after the merged
13 to 17. Joined DATE_TIME values are now shown in the local zone too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 18:01:06 -05:00
james.maesandClaude Fable 5.1 6f822f3baa Merge feat/m1-kernel-cli (dd33657) into feat/m1-extensions
Conflicts: core index exports (records, render/files and extensions all kept), CLI context
(imports from both sides; UnsafeFileNameError exits 2 and ExtensionNotInstalledError exits 5),
server-findings (the presence finding is now #18, after related records' #16 and #17). The parity
matrix regenerates unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:58:25 -05:00
james.maesandClaude Fable 5.1 275abe90ca docs: parity rows and TRY-IT line for audits
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:58:06 -05:00
james.maesandClaude Fable 5.1 1aadc3cb8a cli: qqq audit list <table> <primaryKey>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:58:06 -05:00
james.maesandClaude Fable 5.1 57d0a1e9f8 core: record audit history through the audit process or the audit table
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:58:06 -05:00
james.maesandClaude Fable 5.1 9cc07008fb docs: parity rows and TRY-IT lines for saved views
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:57:08 -05:00
james.maesandClaude Fable 5.1 f81700e985 cli: qqq saved-view list, open, save, rename, delete; table query --view
Opening a view runs its filter, sort, columns and page size; --where and
--search are ANDed on, --order and --fields replace. Duplicate names are
refused before any change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:57:08 -05:00
james.maesandClaude Fable 5.1 e946536ef4 core: saved views through the saved-view processes, Material view documents and reconcile
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:57:08 -05:00
james.maesandClaude Fable 5.1 dd33657f4f Merge feat/m1-kernel-cli (e058942) into feat/m1-related-records
Brings in the DATE_TIME work (local-zone display and input, --utc, process input exit codes).
The record view now formats DATE_TIME through displayDateTime (the viewer's zone, --utc for
the raw instant, server-zoned text kept), and so do the child lists of table get
--associations and table children; create/update keep the base's conversion through the
form session, including the --parent and --upload paths. process.ts keeps the base's value
formatting with the unified safeFileName/saveDownload. Exports, TRY-IT sections and parity
STATUS merged; this branch's findings renumbered to 16 and 17; matrix regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:57:07 -05:00
james.maesandClaude Fable 5.1 23d625ef9a Merge feat/m1-kernel-cli (e058942) into feat/m1-extensions
Conflicts: core index exports (both kept), CLI context imports (both), main.ts registrations
(extensions and help), server-findings (the presence finding renumbered to #16 after #11-#15).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:56:53 -05:00
james.maesandClaude Fable 5.1 95dbbe7b2c docs: extensions (qbits) in TRY-IT, the kernel extension API, merged versions, finding #11
- TRY-IT: "Extensions (qbits)": what happens when an extension is installed, absent, too old or
  unknown; the noun table (esb, collab and the generated nouns); running qbit-collaboration's demo
  server on 8098 beside the sample server.
- packages/core/src/extensions/README.md: registry.has()/require() and joinPresence for the TUI.
- docs/extensions.md: every merged extension document with its version and repository commit.
- server-findings #11: an emptied presence scope is sent without `viewers`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:55:42 -05:00
james.maesandClaude Fable 5.1 5c244f7ebb core+cli: one download-name sanitizer and saver for every download
The process screen had its own safeDownloadName and the CLI process command its own wx
writer, next to render/files' safeFileName and saveDownload. Keep one: safeFileName (last
path segment of either separator; empty, dot, dot-dot and control characters refused) and
saveDownload, which now takes the process writer's friendlier rule for default names (flag wx,
then name-1.ext, name-2.ext when taken) and returns the path written; an explicit --out still
replaces the file. process run DOWNLOAD_FORM, table download, table export and report run all
use it; the duplicates and their test move into files.test.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:54:56 -05:00
james.maesandClaude Fable 5.1 e058942257 core+cli: DATE_TIME typed and shown in the local zone, sent as UTC instants
forms/datetime converts typed wall-clock times (2026-10-31T14:30) to UTC in
the TZ zone with Intl offsets: a time in a spring-forward gap moves forward,
a repeated fall-back time means its first occurrence, and an unchanged
wall-clock time keeps the original instant with its precision. Instants are
shown as 2026-10-31 14:30:00 CDT; display values the server formats in a
declared zone are kept; --utc shows raw instants.

The form session converts DATE_TIME on set (table create, update, copy and
process values), DATE_TIME validation now requires a date and time, --where
and --group criteria on DATE_TIME fields are converted (a date alone is local
midnight), and table get/query and process screens format in the zone.
Tests cover Chicago, London, Sydney and Kolkata DST edges; verified live on
fieldLab (create, get, --utc, TZ=Asia/Tokyo, filters, update across the gap,
fieldLab.bulkEdit values).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:54:29 -05:00
james.maesandClaude Fable 5.1 0b17f85c9f cli: a noun per extension, gated by the server's extensions; qqq collab and qqq esb
- `qqq <extension> ...` for every extension the client document carries (collab for
  collaboration, alias collaboration): each operation is a generated subcommand (method plus the
  literal path segments, `get-records-comments <table> <pk>`), with path parameters as arguments
  or --path, plus --param, --body-json, --form and --out.
- First-class `qqq esb overview|table|process|dead-letters|messages` and `qqq collab presence
  [--watch|--observe] | comments list|add | favorites list|add|remove | recents | follow
  [--status] | unfollow | my-work | people`.
- `qqq extensions`: known and installed extensions, versions, minimums, operations, channels;
  extensions the server lists but the client does not know are listed, never dropped.
- `qqq api <op>` of an absent extension stops before the request; `qqq api --list` has an
  extension column and `--extension <name|core>`. `qqq whoami` describes extensions through the
  registry (unknown ones marked).
- ExtensionNotInstalledError maps to exit 5 with the installed list. QQQ_EXTENSION_CHECK=off
  treats every known extension as installed, for Prism mocks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:54:28 -05:00
james.maesandClaude Fable 5.1 0b6ff79ac4 core: an emptied presence scope arrives without viewers; apply it
Live against the collaboration qbit's dev server (QQQ 4.1.0-RC.15, collaboration 1.1.0): when the
last person leaves, presence.changed carries {"table": "person"} with no `viewers` at all, so the
handler treated it as no snapshot and kept showing the last viewer. A payload with `table` is now
the snapshot, viewers defaulting to none. The too-old error reads "this client needs extension X
1.1.0 or later".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:54:28 -05:00
james.maesandClaude Fable 5.1 416790169d Merge feat/m1-kernel-cli (ffedcbb) into feat/m1-related-records
Conflicts resolved: core index keeps every export; table get keeps --key (Go To) and gains
the record view, --associations, --links, --follow, --reveal, --help-content; table export
keeps the new formats, --search and stdout with the kernel's safe default name; README module
table and TRY-IT keep both; server findings keep 11-14 from the base and renumber this
branch's to 15 and 16; parity STATUS merged row by row and the matrix regenerated. The record
view's help helpers are renamed helpEntryText and tableViewHelp, since the base's help and
process screen modules export helpText and tableHelp.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:54:17 -05:00
james.maesandClaude Fable 5.1 8dbd434d8f docs: parity rows and TRY-IT lines for column statistics
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:53:12 -05:00
james.maesandClaude Fable 5.1 35679d48aa cli: table stats <table> <field>, gated on QUERY_STATS and the columnStats process; --local opt-in
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:53:11 -05:00
james.maesandClaude Fable 5.1 033dcb2327 core: column statistics through columnStats, and computed from records
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:53:11 -05:00
james.maesandClaude Fable 5.1 65de37c76e cli: validation review needs a choice without a terminal; input failures exit 6
Without a terminal, a validation review step now stops with a usage error
(exit 2) unless --validate or --skip-validation is given, instead of letting
the server default. A step ERROR that is user-facing, or that reports missing
required input (placeAddressPick without values), exits 6 rather than 1.
Finding 15 records the missing userFacingError.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:51:59 -05:00
james.maesandClaude Fable 5.1 e3703a34a1 docs: parity rows, TRY-IT lines and server findings for joins and variants
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:51:25 -05:00
james.maesandClaude Fable 5.1 ff9a560c46 cli: --join and table.field names on query, count and get; table variants and --variant everywhere
A table that uses variants without --variant asks on a terminal and
otherwise exits 2 without querying. process run sends the variant on init,
every step and status. Writes send it too, with a note that UI API 1.8.0
ignores it there (server finding #13).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:51:25 -05:00
james.maesandClaude Fable 5.1 2f5e47d46f core: exposed joins and table variants models
Joins follow the web UI: only the joins a query uses (named, or referenced
by columns, criteria or sorts), LEFT with the join name on one-hop paths,
and a distinct count for many-side joins. Association joins cover tables
that expose none. Variants list from the v1 route and resolve by id, name
or type:id.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:51:25 -05:00
james.maesandClaude Fable 5.1 093b5377fe docs: related records and record view in TRY-IT, parity and findings
TRY-IT gains the related-record and record-view commands as verified against the sample.
Parity: REL-001..005 and 009, REC-028 pass live; REL-006..008, REC-002, REC-029..041 are
partial because the sample declares no such metadata (kernel unit tests cover them).
Findings: downloadRecordFieldFile 500s on a BLOB without adornments, and the sample lacks the
adornment, help and composite-association metadata needed to check those rows live.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:51:00 -05:00
james.maesandClaude Fable 5.1 856199345e cli: downloads use the kernel's safe file names, never overwrite by default
table download, table export and report run now name a default output file through
safeFileName and write it exclusively (an existing file is an error naming --out); an
explicit --out may still overwrite. An unsafe name from the server exits 2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:50:35 -05:00
james.maesandClaude Fable 5.1 07c8927557 core: safe names and exclusive writes for downloaded files
A file name from the server (a record's file, a content disposition, a process download)
was used as a path by table download, so a name like ../../etc/passwd could write outside
the working directory. safeFileName keeps only the final path component (either slash
style) and refuses empty, dot, dot-dot and control-character names; saveDownload creates a
default-named file exclusively, so only a path the user named is ever overwritten. Kernel
code so every download path (table download, export, reports, process DOWNLOAD_FORM) uses
the same rule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:50:35 -05:00
james.maesandClaude Fable 5.1 ffedcbb2ff core+cli: never write a server-named download outside --out-dir or over a file
DOWNLOAD_FORM names come from the server. safeDownloadName keeps the last path
segment only (either separator), strips control characters and refuses empty,
. and ..; qqq process run writes with flag wx and takes name-1.ext, name-2.ext
when the name is taken. Tested with traversal names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:50:20 -05:00
james.maesandClaude Fable 5.1 0ef9bfb364 Merge feat/m1-kernel-cli into feat/m1-process-components
Keeps both export sets in the kernel index, renumbers the process findings to
13 and 14 after the query-gaps findings, and regenerates the parity matrix from
the merged STATUS table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:49:50 -05:00
james.maesandClaude Fable 5.1 d5ed1595ca cli: related records and a complete record view
table get now shows the record by section through the kernel's record view: hidden
sections and fields never appear, empty values show --, adornments render as text (record
links name their table and key, masked values with --reveal, files with the download
command), --help-content adds table, section and field help, --links lists the related
records and --follow opens one. --associations prints every association as tables, all
levels (person > pets > notes), honouring the table metadata, with unreadable child tables
marked unavailable.

New: table children pages one child list with its count (or summarises every association);
table create --parent table=pk [--association] links a new child to its parent; create and
update take --upload field=path as multipart/form-data; table download saves a stored file
(FILE_DOWNLOAD route or BLOB value).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:48:58 -05:00
james.maesandClaude Fable 5.1 e65cec7715 core: child lists ordered by sections, count-only child reads
Child-list columns followed the server's field map order (Modify Date before Name); order
them as the record view does (key, then sections by tier, never fields only in hidden
sections). fetchChildren with limit 0 now only counts, for association summaries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:48:57 -05:00
james.maesandClaude Fable 5.1 46457e981a core: local state: command histories, recent records, screen-lock PIN
session/local-state.ts keeps client state that is not configuration:
persistent capped command histories under ~/.local/state/qqq (never a line
that carried a password, PIN or token), recently viewed records per server
in the config file's ui section, and the screen-lock PIN as a salted scrypt
hash (never the PIN) with an idle timeout and a growing retry delay.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:48:27 -05:00
james.maesandClaude Fable 5.1 e4916c2971 core+cli: every process step component, review prompt, cancel, back, min/max records
The kernel gains a screen model (process/screen): each step's components in
declared order become neutral blocks both heads render - HELP_TEXT and HTML as
text, VIEW_FORM pairs on every step, RECORD_LIST paged through GET .../records,
the validation review with its preview and summary, PROCESS_SUMMARY_RESULTS
lines, DOWNLOAD_FORM references (GET /download), WIDGET through the widget
render model, and step help content.

The runner presents each screen, asks the provider on every input step
(validation review included), restarts at backStep with isStepBack=true when
the provider returns BACK, cancels on an abort signal with POST .../cancel,
and checks min/max input records before init. A step whose components are
only a VIEW_FORM no longer counts its formFields as inputs.

qqq process run prints intermediate screens to stderr and the last as its
output, asks validate-or-skip and Submit? on a terminal, saves downloads to
--out-dir, and turns Ctrl-C into a server cancel (exit 130). Verified live on
sleepInteractive, greetInteractive, clonePeople, simpleThrow, placeAddressPick
and person.bulkEdit's review screen; findings 11 and 12 recorded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:47:38 -05:00
james.maesandClaude Fable 5.1 06bcd91e80 core: extension registry and the collaboration module (presence, comments, favorites, follow)
extensions/registry: every extension the client document carries (name, path prefix, document
version, documented minimum version, realtime channels it publishes on, its operations), joined
with the server's GET /version `extensions`. `has`, `version`, `require` (typed
ExtensionNotInstalledError: "extension X is not installed on this server (installed: esb 1.1.0)")
and `requireOperation`. Extensions the server lists but the client does not know are kept and
described, never dropped. GET /version is cached next to the metadata (same TTL, offline copy).

extensions/collaboration: presence as the qbit specifies it (POST lease every 20 s, DELETE on
leave, GET snapshot, presence.changed on record:{t}:{pk} / table:{t} after subscribing on hello,
polling when there is no stream, reconnect with backoff), with `since` tracked per person by the
client; a no-op handle when the extension is absent. Favorites, recents, comments, follow, people
and my-work calls throw the typed error before any request when it is absent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:47:29 -05:00
james.maesandClaude Fable 5.1 c31a426ea8 docs: parity rows, TRY-IT lines and server findings for query and record gaps (batch 1)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:46:29 -05:00
james.maesandClaude Fable 5.1 b68ca47c62 cli: table get --key, table copy, --search, apps show, report list, help show; export hardening
Export checks the format and field names before the request (the server
answers 500 for both), takes the format from --out, writes to stdout with
--out -, and can drop the header row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:46:29 -05:00
james.maesandClaude Fable 5.1 4eb0e9ba83 core: quick search, go-to by key, record copy, app home, report listing, help content
Kernel models the TUI can reuse: the web UI's quick search (CONTAINS over
shown text fields, ORed, ANDed onto the filter), Go To lookups by the
primary key or Material gotoFieldNames, base-copy values, app home sections
and report listings from metadata, and helpContents as text. Table variant
helpers come along because lookups take an optional variant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:46:29 -05:00
james.maesandClaude Fable 5.1 4620b4d0da core: association model and record view with adornments and help
Related records and the record view belong in the kernel so the TUI reuses them. The
association model binds children to parents through the join (composite joins too),
builds the child-list filter, walks a record read with includeAssociations into labelled
child lists (person > pets > notes), marks unreadable child tables unavailable, and
computes the link values for creating a child under a parent. The record view drops
hidden sections and fields and renders each adornment as text (LINK, CHIP, REVEAL,
RENDER_HTML, CODE_EDITOR, ERROR, FILE_DOWNLOAD, WIDGET, TOOLTIP), following the Next UI,
with view-screen help selection by role.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:45:37 -05:00
james.maesandClaude Fable 5.1 4660d50a8f cli: the program as a library (runCli) and the qqq ui command
main.ts becomes a thin entry over program.ts, which exports createProgram()
and runCli(argv, { stdout, stderr }): one command line run in-process with
its output captured, prompts off. The TUI's : command line uses it, so the
CLI grammar is the TUI's command language and both heads share one
dispatcher. Context output goes through an io object that runCli points at
the writers; the few commands that write to process.stdout directly are
captured for the duration of the run.

qqq ui [route...] runs packages/tui/src/main.tsx with Bun, passing the
profile, server, theme, --ascii, --linear and --mouse options and an
optional route in the CLI grammar.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:44:43 -05:00
james.maesandClaude Fable 5.1 2a2325bc6f client: merge every extension document and tag each operation with its extension
The client document is now the sample server's live document (core plus esb, UI API 1.8.0) merged
with the extension documents of every qbit checked out beside this repository: health (qqq), alerts,
assistant, collaboration, data-analysis, db-performance, edi, qa-capture, quick-search,
release-notes, secured-data and sql-workbench. 202 operations, 136 of them from extensions.

- scripts/merge-spec.mjs records each extension's path prefix and operationIds in
  info.x-qqq-extensions, and accepts an extension the core input already carries when its
  operations are identical (the live document's esb), instead of failing on the duplicate.
- scripts/merge-extensions.mjs (npm run spec:merge-extensions) finds the documents, merges them and
  writes openapi/extension-sources.json with each document's version, branch and commit.
- scripts/gen-operations.mjs adds `extension` to OperationInfo (from x-qqq-extensions, else the
  /qbit/{name} or established prefix) and exports DOCUMENT_EXTENSIONS.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:43:58 -05:00
james.maesandClaude Fable 5.1 ff24989a80 core: shared login fetch, UI settings in the config file, filter completion, result cursor
Additive kernel helpers the TUI head needs and the CLI can adopt:
- session/login.ts: the profile's stored sign-in and a fetch that sends a
  server session as the sessionUUID cookie and OAuth2 tokens as bearer
  (refreshed when expiring), same keyring account as the CLI.
- session/profiles.ts: a ui section (theme, per-screen view settings) in
  ~/.config/qqq/config.json, preserved by readConfig, with read/write helpers.
- filter/complete.ts: what can be typed at the cursor in the filter grammar
  (fields, operators valid for the type, values and expressions, AND/OR and
  parentheses), operatorsForField, error location and a filter description.
- records/result-cursor.ts: a position in a query result with a page cache,
  next/previous across pages, and delete bookkeeping.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:43:23 -05:00
james.maesandClaude Fable 5.1 06a78d87b5 core+cli: files for BLOB process fields (qqq process run --file field=path)
The runner sends a file as a multipart part with the first step whose form has that BLOB
field, and treats a pending file as a provided value. Verified against the sample's
person.bulkInsert upload step once the sample accepts uploads (finding 10).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:34:29 -05:00
james.maesandClaude Fable 5.1 3917a278d4 docs: kernel package README and the M1 status in TRY-IT
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:31:57 -05:00
james.maesandClaude Fable 5.1 7f1ad5e120 ci: prepublishOnly and lint scripts the qqq-orb node profile runs
The orb's node_install_dependencies runs npm run prepublishOnly (now the workspace build) and
node_run_tests runs npm test then npm run lint. lint checks license headers and that no
package imports another package's src. Also records finding 10: the sample app has no
uploaded-file archive table, so bulk load cannot be exercised against it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:31:32 -05:00
james.maesandClaude Fable 5.1 455e62ff2f docs: terminal-clients parity matrix generated from the Next UI acceptance matrix
docs/parity/feature-matrix.md keeps the 401 Next UI row ids and adds a CLI and a TUI column
(pass, todo, partial, n/a with reasons), computed by scripts/parity-matrix.py from the copied
Next matrix (docs/parity/next-feature-matrix.md) and the status table in the script. First fill
of the CLI column after the M1 work so far.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:28:49 -05:00
james.maesandClaude Fable 5.1 bbbcbff6e9 core+cli: profiles and an OS keyring for sign-ins
session/profiles.ts keeps named profiles (server, note, default) in ~/.config/qqq/config.json
and resolves --profile, QQQ_PROFILE, --server, QQQ_SERVER or the default. session/keyring.ts
stores secrets without native modules: macOS Keychain through security, Linux Secret Service
through secret-tool, Windows DPAPI through PowerShell, and a 0600 file otherwise or with
QQQ_KEYRING=file. The CLI's session store moves onto them, imports the 0.1 credentials file
once, and adds --profile plus qqq config show and config profile list|add|use|remove.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:24:56 -05:00
james.maesandClaude Fable 5.1 b29903c177 core+cli: metadata cache and shell completion
metadata/cache.ts keeps GET /metaData and table definitions on disk per server (five-minute
TTL, offline reads, refresh and clear). Listings and table commands read through it, so a
cached query round-trips in tens of milliseconds. qqq completion bash|zsh|fish|pwsh prints a
small script that calls the hidden qqq __complete, which answers from the command tree and
the cache: nouns, verbs, options, tables, processes, widgets, reports, fields after --where,
--order, --fields and --set, output formats, and operation ids for qqq api.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:21:15 -05:00
james.maesandClaude Fable 5.1 c228ec1abc core+cli: widget render model; widget, report, prefs and help-search commands
widgets/render-model.ts turns any WidgetData shape into neutral blocks (heading, stat, table,
series, pairs, steps, alert, link, rule, children) and prints them for a terminal: one-dataset
charts as bars, several datasets as a labels-by-datasets table, html through its text sibling or
stripped, composites nested, permission refusals as a warning. qqq widget get renders or
exports --csv; report run saves a report; prefs list/get/set/action and help-search wrap the
preferences and help operations. docs/server-findings.md lists the candidate qqq issues found
so far.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:18:23 -05:00
james.maesandClaude Fable 5.1 4383c87e42 core+cli: form engine with contingent fields, possible-value labels, local validation, prompts
The kernel gains forms/: field dependencies (possibleValueSourceDependsOn or ${input.x} in a
filter), DISABLE_UNTIL_SET semantics, dependents cleared transitively when a dependency
changes, possible-value search with the dependency values, resolution of typed text by id,
exact label or unique partial (with a client-side fallback when the source matches
case-sensitively), server-mirroring validation (required, type, length, range, pattern),
behaviors, form adjusters (onLoad and onChange, 404 means none), and a FormSession that
both heads drive. process/runner.ts runs init and every step through a provider callback,
polling async jobs. The CLI's table create/update and process run go through the session:
--set and -v accept labels, -i prompts on a terminal with choices that narrow after each
answer, and invalid values fail with exit 6 before any request.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:14:25 -05:00
james.maesandClaude Fable 5.1 8ed694cd2f cli: noun-verb grammar on the kernel, output formats, JMESPath, CRUD, api escape hatch
qqq <noun> <verb> for table (list, describe, query, count, get, create, update, delete, export),
process (list, describe, run) and job (list, get, logs, cancel, retry), with the 0.1 flat
commands kept as hidden aliases. Global --output json|table|text|csv|yaml, --json, --query
(JMESPath), --fields, --yes and --trace work before or after the subcommand. Filters take
--where, --group (AND/OR with parentheses), --or, --order, --filter-json, --print-filter and
--count; queries page with --limit, --skip and --all. qqq api <operationId> calls any
operation from the generated index (--path, --param, --body-json, --form, --bytes, --out).
Exit codes: 2 usage and bad filters, 3 auth, 4 unreachable or rate limited, 5 not found,
6 validation, 70 server error. The realtime stream can carry a session cookie for tail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 17:03:18 -05:00
james.maesandClaude Fable 5.1 a0cc4d8be2 core: kernel package with output formats and the filter grammar
packages/core is the client kernel both heads build on; no terminal code. This first cut has
render/output (json, table, text, csv, yaml, JMESPath --query, tty-aware default) and
filter/grammar: criteria with shorthand operators, quoted values, AND/OR groups with
parentheses, and the backend's expression values (now(), now(-7d), thisPeriod(month),
startOf(quarter,-1), me(), ${variable.x}), parsed and printed back identically.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 16:56:16 -05:00
james.maesandClaude Fable 5.1 b64734fd08 chore: lockfile for the client 1.8.0-rc.1 and cli 0.2.0-dev versions
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 16:21:10 -05:00
james.maesandClaude Fable 5.1 ff27af2f9e cli: session sign-in for MOCK and anonymous servers, sent as a cookie; qqq launcher
The client gains getAuthenticationMetaData, createSession (reads the HttpOnly sessionUUID
cookie) and a sessionCookie option; UI_API_VERSION is 1.8.0. qqq login branches on the
server's authentication type and stores a server session for MOCK and FULLY_ANONYMOUS; the
device flow stays for OAUTH2 and AUTH_0. The session goes as a cookie because a bearer
session cannot continue asynchronous process steps on the 4.1.0-SNAPSHOT sample server.
scripts/dev/qqq.sh runs the CLI from source so every commit is testable without a build.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 16:21:02 -05:00
james.maesandClaude Fable 5.1 af62700f81 client: regenerate from the UI API 1.8.0 document
Taken from a running QQQ 4.1.0-SNAPSHOT (develop 6ac64fc66) sample server, so it carries core
plus the esb extension only; the other extension documents are merged back from their qbit
repositories when those are next built. The CLI and conformance packages compile unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 16:18:21 -05:00
james.maesandClaude Fable 5.1 45db6bbc7e spike(m0): show the running build in the launcher and status line
So the person testing always knows which commit the shell is, and whether it carries
uncommitted changes. TRY-IT.md explains that there is no install step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 16:17:03 -05:00
james.maesandClaude Fable 5.1 6d68084816 spike(m0): classic TUI shell against the live sample server
James wants the feel of a great old TUI: function-key bar, Page Up/Down everywhere, a
real-time footer. The shell adds a title bar with breadcrumbs, the app tree, a server-paged
table grid, record view by section tier, the process screen, help/go-to/menu/confirm/prompt
overlays, a ':' command line, and a status line with ping, stream state, session, API
version, jobs and clock. Per-screen error boundary so a throwing screen cannot take the
terminal down. TRY-IT.md documents the keymap; SPIKE-RESULTS.md records what it taught us.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 16:03:51 -05:00
james.maesandClaude Fable 5.1 c11495643e spike(m0): OpenTUI feasibility spike with results, dev launchers and TRY-IT guide
Throwaway OpenTUI (React) spike on Bun: 10k x 40 grid, every-field-type form, and a real
UI API process run with an async job against the qqq sample server. Records the go
decision, the measured latencies, binary sizes, and two server findings (bearer session
cannot continue an async process step under MOCK auth; url-encoded process values are
silently ignored). Adds scripts/dev launchers so the client can be tried after every step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 15:48:34 -05:00
72 changed files with 378 additions and 4974 deletions

No files matched your search

-80
View File
@@ -3,86 +3,6 @@
All notable changes to the qqq CLI and its packages. Versions follow [semver](https://semver.org);
`@qrun-io/qqq-ui-api-client` follows the UI API version of the document it is generated from.
## [Unreleased]
### Changed
- TypeScript 5.8.3 to 7.0.2 (the Go-based compiler) and `@types/node` 22.15.3 to 26.6.6 across the workspace, `packages/tui` and `packages/tui-spike`. TypeScript 7 no longer loads every `@types/*` package implicitly, so `tsconfig.base.json` now lists `"types": ["node"]`; `@types/node` 26 no longer accepts an `undefined` assertion message, so one conformance test passes a real fallback message. No behaviour change.
### 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).
- **`qqq ui`: the Shift bank.** Shift+F1…F10 are a second row of function keys: Keys (the `?`
reference), Servers, SQL (SQL Runner), Bulk, Metadata (refresh), Views (saved views), Copy (into a new
record), Export, History and Assistant (the same toggle as F12 and Ctrl+G). Like the primary bar they follow the
screen: keys that do not apply are dimmed and say why when pressed. The assignments are one table bound to
named actions (`assistant.toggle` and the rest), so other keys can bind the same actions.
- **The F-key bar in two rows** (primary, then the Shift row marked `⇧`) on terminals of 30 lines or more;
`:fkeys one|two|auto` and F9 › Settings › F-key bar choose (saved as `ui.fkeyBar`). In one-row mode the
Shift row replaces the primary row for 2 seconds after a Shift+F press. Clicks on the Shift row run its
keys; `?` lists the bank.
- **One decoder for modified function keys** across terminals: xterm modifier forms (tmux, iTerm2, Windows
Terminal), the kitty keyboard protocol, the legacy `ESC [25~`…`ESC [34~` numbering (VT220, rxvt and
the Linux console, macOS Terminal.app; `QQQ_FKEYS` overrides the detection) and rxvt's `$ ^ @`
suffixes. Ctrl+F-keys are decoded too (no bindings yet).
- **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`.
- **`qqq ui` starts on a splash**: Query (the qrun.io mascot) blinking and wagging while it boots, the `qqq`
wordmark, the version and a status line that follows the boot (reading profiles, connecting, signing in,
loading metadata with the table count). Large Query from about 34 rows, the small one beside the
wordmark at 80×24, plain characters under `--ascii`; any key stops the animation, not the boot; shown at
least 600 ms. `--no-splash`, `QQQ_UI_NO_SPLASH=1` or F9 › Settings › Splash on start (`:splash on|off`,
saved as `ui.splash`) skip it.
- **A profile picker on start**: with several profiles and none named (`--profile`, `QQQ_PROFILE`,
`--server`, `QQQ_SERVER`), the Servers picker opens over the splash with the last used profile selected;
Enter connects, `n` adds, the server status fills in as the probes answer, Esc quits with a one-line hint
about `--profile`. With no profile at all the add form opens directly; with exactly one, it connects.
- **Connection failures stay on the splash** with the reason: Enter retries, `c` chooses another profile, `n`
adds one, Esc quits (instead of the bare error on the sign-in screen).
### Changed
- With several profiles and none named, `qqq ui` asks which to connect to instead of using the default
profile (`--profile` or `QQQ_PROFILE` keeps the old behaviour).
### 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.
- **Shift+F3 and the other modified F3s** (`ESC [1;2R`) no longer vanish: OpenTUI took them for cursor
position reports. They are rewritten on the input stream into `ESC [13;2~` outside the startup
cursor-query window.
## [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
+3 -9
View File
@@ -190,14 +190,10 @@ 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. 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
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
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 `~/Downloads`
(or `QQQ_PRINT_OUT`; `--dir` shows the folder).
`scripts/print-agent.sh` runs qqq-print-agent's virtual file printer, which writes each job to a directory.
[TRY-IT.md](TRY-IT.md) walks through every command against the sample server.
@@ -217,8 +213,6 @@ qqq ui --theme light # high-contrast (defau
- **The function-key bar** at the bottom is always right for the current screen: F1 Help, F2 Search,
F3 View, F4 Edit, F5 Refresh, F6 Filter, F7 New, F8 Delete, F9 Menu, F10 Quit. When the terminal takes
the F-keys, Esc then a digit (or Ctrl+digit) stands for F1 to F10.
**Shift+F1…F10** are a second bank (Keys, Servers, SQL, Bulk, Metadata, Views, Copy, Export, History,
Assistant), shown as a second bar row from 30 lines (`:fkeys one|two|auto`).
- **F2** is Search: records across tables (quick search), the main way into the data. **`/`** (or `.`, Ctrl+K)
is the find-anything palette: any app, table, process, widget, report, record or command, searched as you
type, fast on instances with thousands of tables. **Ctrl+O** switches servers, **Ctrl+P** prints what you see.
+1 -6
View File
@@ -26,12 +26,7 @@ See [`docs/release-runbook.md`](../release-runbook.md). In short: `node scripts/
release with `dist/qqq-*` and `dist/SHA256SUMS`, `node scripts/update-packaging.mjs <that SHA256SUMS>`,
copy `homebrew-tap/Formula/qqq.rb` to the tap and `qqq.json` to the bucket (the bucket's Excavator
also picks up new releases by itself). Use the uploaded sums, not a
local rebuild: each build stamps its commit, so checksums differ between builds. The script exits 1 and writes
nothing when a sum is missing or malformed, and never writes zeros; check the result anyway with
`git diff` and `grep -c 0000000000000000 docs/packaging/qqq.json docs/packaging/homebrew-tap/Formula/qqq.rb`
(both must print 0) before copying the files out. The tap's CI and the bucket's manifest tests would catch
zeros (a download that does not match) only if their pull requests are opened and their checks are read;
checkpoint the sums here first, not there.
local rebuild: each build stamps its commit, so checksums differ between builds.
## Where the formula and manifest live
+4 -4
View File
@@ -12,22 +12,22 @@ class Qqq < Formula
on_macos do
on_arm do
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-darwin-arm64"
sha256 "5017a32ac5031d9146d541381099a959e5c19bbf04c48f725045d89d6007b211"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
end
on_intel do
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-darwin-x64"
sha256 "eb9b1d43d24b639118d94b175c343080a39ae9ba64c24645c1792ff3b16a37f1"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
end
end
on_linux do
on_arm do
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-linux-arm64"
sha256 "25f513bdda13bbe40cf536844eebc1e3a7e21cf7804024f25ebe7c42793d7ebc"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
end
on_intel do
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-linux-x64"
sha256 "804d06cb7c0012c2f185ae5d94c6bb8f9e2482a440bb3c165361f1a14c941e41"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
end
end
+1 -3
View File
@@ -21,8 +21,6 @@ here (`Formula/qctl.rb`) once this tap exists, with `homebrew-qctl` kept as a po
The formula is generated in qqq-cli: after a `cli-v<version>` GitHub release, run
`node scripts/update-packaging.mjs <release SHA256SUMS>` there and copy
`docs/packaging/homebrew-tap/Formula/qqq.rb` here. CI (qqq-orb `homebrew-tap` profile) fetches every
platform's download, installs the formula and runs its test, which fails on a wrong or all-zero sum, but
only once the pull request here is opened and its checks are read. The script refuses to write missing or
malformed sums; still compare the sums in the diff with the release's `SHA256SUMS` before opening the PR.
platform's download, installs the formula and runs its test.
Apache-2.0
+1 -1
View File
@@ -10,7 +10,7 @@
"architecture": {
"64bit": {
"url": "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v1.0.0/qqq-windows-x64.exe",
"hash": "cf418d7842a227cee412171878228004f9bc5098a272c64b89512d1e2d06a176"
"hash": "0000000000000000000000000000000000000000000000000000000000000000"
}
},
"bin": [
-6
View File
@@ -180,15 +180,9 @@ release (the channel Homebrew and Scoop follow); a release candidate is reached
```sh
gh release download cli-v1.0.0 --repo QRun-IO/qqq-cli -p SHA256SUMS -D /tmp/qqq-1.0.0
node scripts/update-packaging.mjs /tmp/qqq-1.0.0/SHA256SUMS
git diff docs/packaging # every sha256 differs from the placeholder, none is all zeros
git commit -S -am "packaging: qqq 1.0.0" && git push # in qqq-cli, through a pull request
```
The script exits 1 and writes nothing if `SHA256SUMS` lacks a target or has a malformed sum. Compare each sum
in the diff with the release's `SHA256SUMS` by eye before committing: the tap's CI and the bucket's manifest
tests catch a wrong sum (the download will not match) only when someone opens those pull requests and reads
their checks.
Then copy `docs/packaging/homebrew-tap/Formula/qqq.rb` to `Formula/qqq.rb` in `QRun-IO/homebrew-tap`
and `docs/packaging/qqq.json` to `bucket/qqq.json` in `QRun-IO/scoop-bucket`, a pull request on each. The tap's
CI fetches every platform's download, installs the formula and runs its test (which insists on
+15 -376
View File
@@ -14,8 +14,8 @@
],
"devDependencies": {
"@stoplight/prism-cli": "5.16.0",
"@types/node": "26.6.6",
"typescript": "7.0.2",
"@types/node": "22.15.3",
"typescript": "5.8.3",
"yaml": "2.9.1"
},
"engines": {
@@ -564,13 +564,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.6.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
"version": "22.15.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz",
"integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
"undici-types": "~6.21.0"
}
},
"node_modules/@types/swagger-schema-official": {
@@ -590,346 +590,6 @@
"@types/node": "*"
}
},
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@@ -3048,44 +2708,23 @@
}
},
"node_modules/typescript": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc"
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
+3 -4
View File
@@ -18,8 +18,7 @@
"spec:merge-extensions": "node scripts/merge-extensions.mjs",
"generate": "npm run generate -w @qrun-io/qqq-ui-api-client && node scripts/gen-operations.mjs",
"build": "npm run build -w @qrun-io/qqq-ui-api-client && npm run build -w @qrun-io/qqq-core && npm run build -w @qrun-io/qqq-ui-api-conformance && npm run build -w @qrun-io/qqq-cli",
"test": "npm test --workspaces --if-present && npm run test:scripts",
"test:scripts": "node --test scripts/update-packaging.test.mjs",
"test": "npm test --workspaces --if-present",
"mock": "prism mock --dynamic --port 4010 packages/ui-api-client/openapi/qqq-v1.json",
"prepublishOnly": "npm run build",
"lint": "node scripts/lint.mjs",
@@ -30,8 +29,8 @@
},
"devDependencies": {
"@stoplight/prism-cli": "5.16.0",
"@types/node": "26.6.6",
"typescript": "7.0.2",
"@types/node": "22.15.3",
"typescript": "5.8.3",
"yaml": "2.9.1"
}
}
+1 -1
View File
@@ -69,7 +69,7 @@ the options.
| `report`, `saved-report`, `saved-view`, `share`, `bulk-profile`, `audit`, `widget`, `script`, `job`, `prefs`, `help-search` | reports, saved reports, saved views, sharing, bulk-load mappings, audits, widgets as text, scripts, jobs, preferences, help |
| `api <operationId>` | any UI API operation by id |
| `extensions`, `esb`, `collab`, `health`, `alerts`, ... | one noun per extension, run when the server has it installed |
| `assistant ask`, `assistant config` | ask the in-app assistant (qbit-assistant) a question about your data: the answer streams block by block, rendered (tables as sub-tables, links as `label (qqq table get …)` or `label [n]` with the command under Links, never a URL), then its numbers (`--table`/`--record` send the page as context, `-c` continues a conversation, `--json` the whole answer); `config` says whether it answers and with which model, or why not |
| `assistant ask`, `assistant config` | ask the in-app assistant (qbit-assistant) a question about your data: the answer streams, then its numbers and the records and tables it refers to (`--table`/`--record` send the page as context, `-c` continues a conversation, `--json` the whole answer); `config` says whether it answers and with which model, or why not |
| `print`, `printer` | print records, table lists and text through the printing extension, follow print jobs; printers and print agents |
| `history` | everything you did with the CLI and the TUI, like a shell history (see below) |
| `login`, `logout`, `profile`, `config`, `cache`, `completion`, `doctor` | sign-in, saved server profiles (`profile list|add|edit|use|remove`), settings, the metadata cache, shell completion (bash, zsh, fish, PowerShell), checks |
+25 -86
View File
@@ -13,7 +13,7 @@
import type { Command } from 'commander'
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
import { answerDocument, assistantContextFor, describeAssistantConfig, linkTargetKey, markdownText, parseAppHref, parseMarkdown, type AssistantAnswer, type MdInline } from '@qrun-io/qqq-core'
import { assistantContextFor, describeAssistantConfig, type AssistantAnswer } from '@qrun-io/qqq-core'
import { io, type Context, UsageError } from '../context.js'
import { positiveInt, type Json } from './shared.js'
@@ -22,73 +22,16 @@ export function plainAnswer(text: string): string {
return text.replace(/\*\*(.+?)\*\*/g, '$1').replace(/__(.+?)__/g, '$1').replace(/`([^`]+)`/g, '$1').replace(/^#{1,6}\s+/gm, '')
}
/** The qqq command that opens a link's target (`qqq table get t 4`, `qqq table query t --filter-json '…'`), or '' for other pages. */
export function linkCommand(href: string): string {
const target = parseAppHref(href)
if (!target?.tableName) return ''
const key = linkTargetKey(href)
const record = /^record:[^:]+:(.*)$/.exec(key)
if (record) return `qqq table get ${target.tableName} ${shellQuote(record[1]!)}`
if (key === `table:${target.tableName}:` || !target.filter) return `qqq table query ${target.tableName}`
return `qqq table query ${target.tableName} --filter-json ${shellQuote(JSON.stringify(target.filter))}`
}
function shellQuote(text: string): string {
return /^[A-Za-z0-9_.:@%+=,/-]+$/.test(text) ? text : `'${text.replace(/'/g, `'\\''`)}'`
}
/**
* Renders an answer's markdown for the terminal, block by block (so it can print while the model
* writes): entities decoded, tables as sub-tables, each link as `label (qqq table get …)` when that
* is short, else `label [n]` with the command listed under Links (numbered once per target).
*/
export class AnswerRenderer {
readonly footnotes: Array<{ n: number; label: string; open: string }> = []
private readonly noted = new Map<string, number>()
private printed = 0
constructor(private readonly width = 100) {}
private note(label: string, href: string): number {
const key = linkTargetKey(href)
let n = this.noted.get(key)
if (n === undefined) {
n = this.noted.size + 1
this.noted.set(key, n)
this.footnotes.push({ n, label, open: linkCommand(href) || href })
}
return n
}
/** Text for the blocks completed since the last call (all of them when `done`), '' when none. */
next(text: string, done: boolean): string {
const doc = parseMarkdown(text)
const complete = done ? doc.blocks.length : Math.max(0, doc.blocks.length - 1)
const out: string[] = []
for (; this.printed < complete; this.printed++) {
const block = doc.blocks[this.printed]!
const inTable = block.kind === 'table'
out.push(markdownText({ blocks: [block], links: doc.links }, this.width, (part: MdInline) => {
const open = linkCommand(part.href ?? '')
if (!inTable && open && !open.includes('--filter-json') && open.length <= 48) return `${part.text} (${open})`
return `${part.text} [${this.note(part.text, part.href ?? '')}]`
}))
}
return out.join('\n\n')
}
/** Numbers the answer's other links (tool results) the text did not show, once per target. */
extra(answer: { text: string; links: Array<{ label: string; href: string }> }): void {
for (const link of answerDocument(answer).extraLinks) this.note(link.label, link.href)
}
}
/** The whole answer rendered at once (tests, and output that is not streamed). */
export function renderAnswer(answer: { text: string; links: Array<{ label: string; href: string }> }, width = 100): { text: string; links: Array<{ n: number; label: string; open: string }> } {
const renderer = new AnswerRenderer(width)
const text = renderer.next(answer.text, true)
renderer.extra(answer)
return { text, links: renderer.footnotes }
/** Rows for the links table: kind, label, table, id, the app path and the qqq command that opens it. */
export function linkRows(answer: AssistantAnswer): Json[] {
return answer.links.map((link) => ({
kind: link.kind,
label: link.label,
table: link.tableName ?? '',
id: link.recordId ?? '',
href: link.href,
open: link.kind === 'record' ? `qqq table get ${link.tableName} ${link.recordId}` : link.kind === 'table' ? `qqq table query ${link.tableName}` : '',
}))
}
export function registerAssistant(noun: Command, ctx: () => Context): void {
@@ -123,42 +66,38 @@ export function registerAssistant(noun: Command, ctx: () => Context): void {
const conversationId = options.conversation ? positiveInt(options.conversation, '--conversation') : undefined
const assistant = await c.assistant()
const human = c.format === 'table' || c.format === 'text'
const renderer = new AnswerRenderer(Math.max(40, io.width() ?? 100))
let printedAny = false
const emit = (text: string) => {
if (!text) return
io.out(`${printedAny ? '\n\n' : ''}${text}`)
printedAny = true
}
let streamed = false
const answer = await assistant.ask(question, {
conversationId,
context: assistantContextFor({ tableName: options.table, recordId: options.record }),
onEvent: (event, partial) => {
onEvent: (event) => {
if (!human) return
// whole blocks as the model completes them, rendered (a table prints once it is complete)
if (event.type === 'text.delta') emit(renderer.next(partial.text, false))
else if (event.type === 'tool.start' && typeof event.summary === 'string' && event.summary) c.note(`… ${event.summary}`)
if (event.type === 'text.delta' && typeof event.text === 'string') {
io.out(plainAnswer(event.text))
streamed = true
} else if (event.type === 'tool.start' && typeof event.summary === 'string' && event.summary) {
c.note(`… ${event.summary}`)
}
},
})
if (answer.status === 'ERROR') {
if (printedAny) io.out('\n')
if (streamed) io.out('\n')
throw new UiApiError(502, { code: answer.error?.code ?? 'INTERNAL', message: answer.error?.message ?? 'The assistant could not answer.' })
}
if (!human) {
c.print({ data: answer })
return
}
emit(renderer.next(answer.text, true))
if (!streamed) io.out(plainAnswer(answer.text))
io.out('\n')
for (const notice of answer.notices) c.note(`${notice.level}: ${notice.text}`)
if (answer.numbers.length) {
io.out('\n')
c.print({ data: answer.numbers, rows: answer.numbers.map((n) => ({ number: n.label, value: n.value })), columns: ['number', 'value'] })
c.print({ data: answer.numbers, rows: answer.numbers.map((n) => ({ number: n.label, value: n.value, href: n.href ?? '' })), columns: ['number', 'value', 'href'] })
}
renderer.extra(answer)
if (renderer.footnotes.length) {
io.out('\nLinks\n')
c.print({ data: renderer.footnotes, rows: renderer.footnotes.map((f) => ({ n: `[${f.n}]`, label: f.label, open: f.open })), columns: ['n', 'label', 'open'] })
if (answer.links.length) {
io.out('\n')
c.print({ data: answer.links, rows: linkRows(answer), columns: ['kind', 'label', 'table', 'id', 'open'] })
}
const seconds = answer.elapsedMs === undefined ? '' : `${(answer.elapsedMs / 1000).toFixed(1)} s, `
c.note(`(${seconds}conversation ${answer.conversationId}; continue with: qqq assistant ask -c ${answer.conversationId} "...")`)
+9 -76
View File
@@ -22,13 +22,10 @@
*/
import type { Command } from 'commander'
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 { 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 { EXIT, UsageError, type Context } from '../context.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 { ORDER_OPTION, countFooter, instantText, positiveInt, type Json } from './shared.js'
import { resolvedFilterFrom, type QueryOptions } from './table.js'
const JOB_COLUMNS = ['id', 'title', 'status', 'printer', 'copies', 'created', 'message']
@@ -48,61 +45,6 @@ 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>
@@ -160,18 +102,16 @@ 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')
.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 }) => {
.action(async (table: string, ids: string[], options: { printer?: string; copies: string; wait: boolean; timeout: string }) => {
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, chrome: await chromeFor(c) })
const jobIds = await printing.print({ table, ids, printer: printer.id, copies })
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`]) })
@@ -194,8 +134,7 @@ 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')
.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 }) => {
.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 }) => {
const c = ctx()
const printing = await printingOf(c)
const meta = await c.tableMeta<Json>(table)
@@ -209,15 +148,11 @@ 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'), chrome: await chromeFor(c, String(meta.label ?? table)) })
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') })
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 })
@@ -233,16 +168,14 @@ 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')
.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 }) => {
.action(async (file: string | undefined, options: { title: string; subtitle?: string; landscape?: boolean; printer?: string; copies: string; wait: boolean; timeout: string }) => {
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', chrome: await chromeFor(c, options.title) })
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 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)]) })
})
+6 -31
View File
@@ -10,59 +10,35 @@
import type { Command } from 'commander'
import { UiApiError, unwrap, type UiApiClient } from '@qrun-io/qqq-ui-api-client'
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 { 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 { openRealtimeStream } from '@qrun-io/qqq-ui-api-client'
import { CLIENT_CHANNEL, authenticatedFetch, clientFor, currentToken, selectTarget, sessionCookieHeaderFor, target, type Target } from './session.js'
/** A write that ends the process quietly (status 0) when it throws because the reader has gone. */
function safeWrite(stream: NodeJS.WriteStream, text: string): void {
try {
stream.write(text)
} catch (error) {
if (isBrokenPipe(error)) process.exit(0)
throw error
}
}
/**
* Where output goes. The CLI writes to the process streams; `runCli` (the TUI's `:` command line)
* points these at a pane for the duration of one command.
*/
export const io = {
out: (text: string): void => { safeWrite(process.stdout, text) },
err: (text: string): void => { safeWrite(process.stderr, text) },
out: (text: string): void => { process.stdout.write(text) },
err: (text: string): void => { process.stderr.write(text) },
isTTY: (): boolean => Boolean(process.stdout.isTTY),
/** The terminal width table output fits nested results in; undefined when not on a terminal. */
width: (): number | undefined => (process.stdout.isTTY ? process.stdout.columns : undefined),
}
/** What a write to a pipe whose reader has gone ends with: EPIPE, or (macOS, a stream already torn down) the errors that follow it. */
const BROKEN_PIPE_CODES = new Set(['EPIPE', 'ECONNRESET', 'ERR_STREAM_DESTROYED', 'ERR_STREAM_WRITE_AFTER_END'])
/** Whether an error is a write to a pipe whose reader has gone (`qqq table query | head -1`). */
export function isBrokenPipe(error: unknown): boolean {
return BROKEN_PIPE_CODES.has(String((error as NodeJS.ErrnoException | undefined)?.code))
return (error as NodeJS.ErrnoException | undefined)?.code === 'EPIPE'
}
/**
* A pipe nobody reads any more ends the command at once with status 0, as `yes | head` does, whatever
* else was going on: the process exits from the stream's error event, from a write that throws, and
* from an uncaught error that is one, so no later code can turn it into a failure.
*/
export function exitOnBrokenPipe(streams: Array<NodeJS.WriteStream> = [process.stdout, process.stderr], exit: (code: number) => void = (code) => process.exit(code), on: (event: 'uncaughtException', listener: (error: Error) => void) => unknown = (event, listener) => process.on(event, listener)): void {
/** Exits quietly with status 0 when stdout or stderr is a pipe nobody reads any more, as `yes | head` does. */
export function exitOnBrokenPipe(streams: Array<NodeJS.WriteStream> = [process.stdout, process.stderr], exit: (code: number) => void = (code) => process.exit(code)): void {
for (const stream of streams) {
stream.on('error', (error) => {
if (isBrokenPipe(error)) exit(0)
else throw error
})
}
on('uncaughtException', (error) => {
if (isBrokenPipe(error)) exit(0)
else {
process.stderr.write(`${error.stack ?? error.message}\n`)
process.exit(1)
}
})
}
/** Exit codes, as in the design doc (KofTwentyTwo ADR-0031 shape). */
@@ -190,7 +166,6 @@ 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),
})
}
+4 -1
View File
@@ -33,7 +33,10 @@ importLegacyCredentials().then(() => program.parseAsync()).catch((error: unknown
return
}
// a stream that failed with EPIPE (an export piped into head): the reader left, nothing to report
if (isBrokenPipe(error)) process.exit(0)
if (isBrokenPipe(error)) {
process.exitCode = 0
return
}
const { code, text } = describeError(error, Boolean(program.opts().trace))
process.stderr.write(text + '\n')
process.exitCode = code
+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, CLI_VERSION } from './version.js'
export { buildDescription } from './version.js'
import { registerApi } from './commands/api.js'
import { registerAudit } from './commands/audit.js'
import { registerAuth } from './commands/auth.js'
+4 -29
View File
@@ -5,10 +5,7 @@
import assert from 'node:assert/strict'
import { after, before, describe, test } from 'node:test'
import { linkCommand, plainAnswer, renderAnswer } from '../commands/assistant.js'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { TEST_SOURCE } from './harness.js'
import { plainAnswer } from '../commands/assistant.js'
import { runCli, sandbox } from './harness.js'
import { StubResponse, startStub, type J, type Stub } from './stub.js'
@@ -62,11 +59,9 @@ describe('qqq assistant against a stub', () => {
const r = await qqq(['-O', 'table', 'assistant', 'ask', '--table', 'customer', '--record', '5', 'how', 'many', 'customers?'])
assert.equal(r.code, 0, r.stderr)
assert.match(r.stdout, /^There are 42 customers\.\n/)
assert.match(r.stdout, /Customer\s+42\n/)
assert.match(r.stdout, /Links\n/)
assert.match(r.stdout, /\[1\]\s+Acme \(Customer\)\s+qqq table get customer 5/)
assert.match(r.stdout, /\[2\]\s+Customer \(42\)\s+qqq table query customer/)
assert.doesNotMatch(r.stdout, /\/app\//)
assert.match(r.stdout, /Customer\s+42\s+\/app\/customer/)
assert.match(r.stdout, /record\s+Acme \(Customer\)\s+customer\s+5\s+qqq table get customer 5/)
assert.match(r.stdout, /table\s+Customer \(42\)\s+customer\s+qqq table query customer/)
assert.match(r.stderr, /Counting Customer/)
assert.match(r.stderr, /conversation 12; continue with: qqq assistant ask -c 12/)
const created = stub.requests.find((q) => q.path === '/assistant/conversations')!
@@ -99,24 +94,4 @@ describe('qqq assistant against a stub', () => {
test('plain answer text drops markdown markers', () => {
assert.equal(plainAnswer('## Total\nThere are **42** `customers` and __3__ orders.'), 'Total\nThere are 42 customers and 3 orders.')
})
test('the 8078 answer about unhealthy services renders as text: no entities, no URLs, a sub-table, links once', () => {
const fixture = JSON.parse(readFileSync(join(TEST_SOURCE, '..', '..', '..', 'core', 'src', 'test', 'fixtures', 'assistant-unhealthy.json'), 'utf8')) as { text: string; links: Array<{ label: string; href: string }> }
const { text, links } = renderAnswer(fixture, 100)
assert.doesNotMatch(text, /&#|\/app\/|%7B|\| ---/)
assert.match(text, /Service\s+Status\s+Since/)
assert.match(text, /email \[1\]\s+DEGRADED/)
assert.match(text, /- Open unhealthy components \[4\]/)
assert.ok(text.split('\n').every((l) => [...l].length <= 100))
assert.deepEqual(links.map((l) => [l.n, l.label, l.open.split(' --')[0]]), [
[1, 'email', 'qqq table get health_component 4'],
[2, 'db_performance', 'qqq table get health_component 3'],
[3, 'esb', 'qqq table get health_component 10'],
[4, 'Open unhealthy components', 'qqq table query health_component'],
])
assert.match(links[3]!.open, /--filter-json '\{"criteria":\[\{"fieldName":"status","operator":"NOT_EQUALS","values":\["UP"\]\}\],"booleanOperator":"AND"\}'/)
assert.equal(linkCommand('/app/person/2'), 'qqq table get person 2')
assert.equal(linkCommand('https://x'), '')
})
})
+7 -24
View File
@@ -20,7 +20,7 @@ describe('broken pipes', () => {
stub = await startStub([...BASE_ROUTES,
[/^GET \/metaData$/, () => ({ tables: { big: { name: 'big', label: 'Big' } }, processes: {}, apps: {} })],
[/^GET \/metaData\/table\/big$/, () => ({ name: 'big', label: 'Big', primaryKeyField: 'id', capabilities: ['TABLE_QUERY'], fields: { id: { name: 'id', label: 'Id', type: 'INTEGER' }, note: { name: 'note', label: 'Note', type: 'STRING' } } })],
[/^POST \/table\/big\/query$/, () => ({ records: Array.from({ length: 5000 }, (_, i) => ({ values: { id: i, note: 'x'.repeat(200) } })) })],
[/^POST \/table\/big\/query$/, () => ({ records: Array.from({ length: 2000 }, (_, i) => ({ values: { id: i, note: 'x'.repeat(200) } })) })],
])
})
after(async () => {
@@ -30,40 +30,23 @@ describe('broken pipes', () => {
test('the helper exits 0 on EPIPE and rethrows anything else', () => {
assert.ok(isBrokenPipe(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' })))
assert.ok(isBrokenPipe(Object.assign(new Error('destroyed'), { code: 'ERR_STREAM_DESTROYED' })))
assert.ok(!isBrokenPipe(new Error('other')))
const listeners: Array<(e: Error) => void> = []
const exits: number[] = []
let uncaught: ((e: Error) => void) | undefined
exitOnBrokenPipe([{ on: (_: string, f: (e: Error) => void) => listeners.push(f) } as never], (code) => exits.push(code), (_, f) => { uncaught = f })
exitOnBrokenPipe([{ on: (_: string, f: (e: Error) => void) => listeners.push(f) } as never], (code) => exits.push(code))
listeners[0]!(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
uncaught!(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
assert.deepEqual(exits, [0, 0])
assert.deepEqual(exits, [0])
assert.throws(() => listeners[0]!(new Error('disk full')), /disk full/)
})
/** Runs a query whose output is far larger than a pipe buffer, closing the read end when `closeAt` says so. */
const query = async (closeAt: 'before-any-write' | 'first-chunk') => {
test('a closed stdout ends the command quietly with status 0 and no stack trace', async () => {
const env = { ...process.env, ...box.env, QQQ_TOKEN: 't', NO_COLOR: '1' }
const child = spawn(process.execPath, [CLI, '--server', stub.url, 'table', 'query', 'big', '-O', 'json', '-l', '5000'], { env, stdio: ['ignore', 'pipe', 'pipe'] })
const child = spawn(process.execPath, [CLI, '--server', stub.url, 'table', 'query', 'big', '-O', 'json', '-l', '2000'], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let stderr = ''
child.stderr.on('data', (d: Buffer) => (stderr += d.toString('utf8')))
// before-any-write: the reader is gone before the child has even started, so its first write meets a closed pipe
if (closeAt === 'before-any-write') child.stdout.destroy()
else child.stdout.once('data', () => child.stdout.destroy())
child.stdout.once('data', () => child.stdout.destroy())
const code = await new Promise<number | null>((resolve) => child.on('close', resolve))
return { code, stderr }
}
test('a reader that is gone before the first write ends the command quietly with status 0', { timeout: 60_000 }, async () => {
const { code, stderr } = await query('before-any-write')
assert.equal(code, 0, stderr)
assert.doesNotMatch(stderr, /EPIPE|at .*\.js/)
})
test('a reader that leaves after the first chunk ends the command quietly with status 0', { timeout: 60_000 }, async () => {
const { code, stderr } = await query('first-chunk')
assert.equal(code, 0, stderr)
assert.equal(code, 0)
assert.doesNotMatch(stderr, /EPIPE|at .*\.js/)
})
})
+2 -58
View File
@@ -14,8 +14,7 @@ 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 { mkdtempSync, readFileSync, readdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { runCli, sandbox, startFakeServer, type FakeServer } from './harness.js'
@@ -25,7 +24,6 @@ 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>
}
@@ -52,7 +50,6 @@ 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
@@ -72,22 +69,10 @@ 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' }, printingRenderToPdf: { name: 'printingRenderToPdf' } }, 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' } }, 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') })
@@ -147,7 +132,6 @@ 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()) }),
}
@@ -265,11 +249,6 @@ 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 () => {
@@ -292,40 +271,5 @@ 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)
})
})
+1 -1
View File
@@ -30,7 +30,7 @@ test('on a MOCK server without a token, every authenticated check (jobs too) use
const seen: Array<{ path: string; authorization?: string; cookie?: string }> = []
const results = await runConformance({ baseUrl: 'http://fake', fetch: fakeServer(seen) })
const jobs = results.find((r) => r.name === 'GET /qqq/v1/jobs validates')!
assert.equal(jobs.ok && !jobs.skipped, true, jobs.detail ?? 'jobs check did not pass')
assert.equal(jobs.ok && !jobs.skipped, true, jobs.detail)
assert.equal(seen.find((s) => s.path === '/qqq/v1/metaData')?.cookie, 'sessionUUID=s-1')
assert.equal(seen.find((s) => s.path === '/qqq/v1/jobs')?.cookie, 'sessionUUID=s-1')
assert.match(results.find((r) => r.name === 'GET /qqq/v1/metaData validates')!.detail!, /authenticated with a server session/)
+2 -99
View File
@@ -23,8 +23,6 @@
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'
@@ -35,8 +33,6 @@ 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). */
@@ -99,8 +95,6 @@ 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 {
@@ -209,75 +203,6 @@ 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[] }
@@ -383,7 +308,6 @@ export interface PrintRequest {
/** Printer row id. */
printer: string
copies?: number
chrome?: PrintChrome
}
export interface TableListRequest {
@@ -405,16 +329,12 @@ 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'
@@ -456,7 +376,6 @@ export function tableListValues(request: TableListRequest): Record<string, unkno
pageSize: request.pageSize ?? 'LETTER',
[PRINTER_FIELD]: request.printer,
[COPIES_FIELD]: request.copies ?? 1,
...chromeValues(request.chrome),
}
}
@@ -519,7 +438,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, ...chromeValues(request.chrome) }, recordIds: request.ids })
const values = await this.transport.runProcess(printProcessName(request.table), { values: { [PRINTER_FIELD]: request.printer, [COPIES_FIELD]: copies }, recordIds: request.ids })
return jobIdsOf(values)
}
@@ -534,26 +453,10 @@ 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: { ...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: [] })
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: [] })
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,7 +48,6 @@ export * from './render/text.js'
export * from './extensions/registry.js'
export * from './extensions/collaboration.js'
export * from './extensions/assistant.js'
export * from './render/markdown.js'
export * from './extensions/list-columns.js'
export * from './extensions/esb.js'
export * from './extensions/health.js'
-261
View File
@@ -1,261 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The small markdown the assistant writes, parsed into blocks both heads draw: headings, paragraphs,
* bullet and numbered lists, pipe tables, and inline **bold**, `code` and [links](/app/...). HTML
* entities are decoded (`&#58;` is `:`). Links are numbered in reading order, one number per target
* (two links to the same record share it), so a client can make them Tab targets and never show a
* URL. Exact duplicate paragraphs are collapsed (models sometimes restate a sentence word for word).
*/
import { parseAppHref } from '../extensions/assistant.js'
import { layoutTable } from './output.js'
import { decodeEntities } from './text.js'
/** A run of text with one style; a link carries its number. */
export interface MdInline {
text: string
style?: 'bold' | 'code' | 'link'
href?: string
/** For links: the index into {@link MdDocument.links}. */
link?: number
}
export type MdAlign = 'left' | 'right' | 'center'
export type MdBlock =
| { kind: 'heading'; level: number; inlines: MdInline[] }
| { kind: 'paragraph'; inlines: MdInline[] }
| { kind: 'list'; ordered: boolean; items: MdInline[][] }
| { kind: 'table'; headers: MdInline[][]; aligns: MdAlign[]; rows: MdInline[][][] }
export interface MdLink {
label: string
href: string
/** What the link points at (see {@link linkTargetKey}): links with the same key are one link. */
key: string
}
export interface MdDocument {
blocks: MdBlock[]
/** Every link target, in reading order, once. */
links: MdLink[]
}
/** The primary key field of a table, for telling a filter on the key from a record link. */
export type PrimaryKeyOf = (table: string) => string | undefined
/**
* What a link points at, so equal targets compare equal: `record:{table}:{id}` (also for a filter
* that is exactly "primary key EQUALS one value"), `table:{table}:{canonical filter}`, or the href.
* Filters in JSON or base64 JSON compare by content (keys sorted, empty criteria dropped).
*/
export function linkTargetKey(href: string, primaryKeyOf: PrimaryKeyOf = () => 'id'): string {
const target = parseAppHref(href)
if (!target?.tableName) return `href:${href}`
if (target.recordId !== undefined) return `record:${target.tableName}:${target.recordId}`
const filter = target.filter as { criteria?: Array<{ fieldName?: string; operator?: string; values?: unknown[] }>; subFilters?: unknown[] } | undefined
const criteria = filter?.criteria ?? []
const pk = primaryKeyOf(target.tableName) ?? 'id'
if (criteria.length === 1 && !filter?.subFilters?.length && criteria[0]!.fieldName === pk && criteria[0]!.operator === 'EQUALS' && criteria[0]!.values?.length === 1) {
return `record:${target.tableName}:${String(criteria[0]!.values[0])}`
}
if (!criteria.length && !filter?.subFilters?.length) return `table:${target.tableName}:`
return `table:${target.tableName}:${canonical({ criteria, subFilters: filter?.subFilters ?? [], booleanOperator: (filter as { booleanOperator?: string }).booleanOperator ?? 'AND' })}`
}
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
if (value && typeof value === 'object') return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical((value as Record<string, unknown>)[k])}`).join(',')}}`
return JSON.stringify(value)
}
/** Parses inline markdown: links, bold, code; entities decoded in text and labels. */
function inlines(source: string, register: (label: string, href: string) => number): MdInline[] {
const out: MdInline[] = []
const push = (text: string, style?: MdInline['style']) => {
if (!text) return
const last = out[out.length - 1]
if (!style && last && !last.style) last.text += text
else out.push(style ? { text, style } : { text })
}
const pattern = /\[([^\]]+)\]\(([^)\s]+)\)|\*\*(.+?)\*\*|__(.+?)__|`([^`]+)`/g
let at = 0
for (const m of source.matchAll(pattern)) {
push(decodeEntities(source.slice(at, m.index)))
if (m[1] !== undefined) {
const label = decodeEntities(m[1]).replace(/\*\*|__|`/g, '')
const href = decodeEntities(m[2]!)
out.push({ text: label, style: 'link', href, link: register(label, href) })
} else if (m[3] !== undefined || m[4] !== undefined) push(decodeEntities(m[3] ?? m[4]!), 'bold')
else push(decodeEntities(m[5]!), 'code')
at = m.index + m[0].length
}
push(decodeEntities(source.slice(at)))
return out
}
const TABLE_RULE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/
function cells(line: string): string[] {
let text = line.trim()
if (text.startsWith('|')) text = text.slice(1)
if (text.endsWith('|') && !text.endsWith('\\|')) text = text.slice(0, -1)
// split on pipes that are not inside a link target or escaped
const out: string[] = []
let cell = ''
let depth = 0
for (let i = 0; i < text.length; i++) {
const ch = text[i]!
if (ch === '\\' && text[i + 1] === '|') { cell += '|'; i++; continue }
if (ch === '(' && text[i - 1] === ']') depth++
else if (ch === ')' && depth > 0) depth--
if (ch === '|' && depth === 0) { out.push(cell.trim()); cell = '' } else cell += ch
}
out.push(cell.trim())
return out
}
/** Parses the assistant's markdown into blocks and numbered links. */
export function parseMarkdown(text: string, primaryKeyOf?: PrimaryKeyOf): MdDocument {
const links: MdLink[] = []
const register = (label: string, href: string): number => {
const key = linkTargetKey(href, primaryKeyOf)
const found = links.findIndex((l) => l.key === key)
if (found >= 0) return found
links.push({ label, href, key })
return links.length - 1
}
const blocks: MdBlock[] = []
const seenParagraphs = new Set<string>()
const lines = text.replace(/\r\n?/g, '\n').split('\n')
let i = 0
const isList = (l: string) => /^\s*([-*+]|\d+[.)])\s+/.test(l)
const isHeading = (l: string) => /^\s{0,3}#{1,6}\s+/.test(l)
const isTableStart = (n: number) => lines[n]?.includes('|') && TABLE_RULE.test(lines[n + 1] ?? '')
while (i < lines.length) {
const line = lines[i]!
if (!line.trim()) { i++; continue }
if (isHeading(line)) {
const m = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line)!
blocks.push({ kind: 'heading', level: m[1]!.length, inlines: inlines(m[2]!, register) })
i++
continue
}
if (isTableStart(i)) {
const headers = cells(line).map((c) => inlines(c, register))
const aligns: MdAlign[] = cells(lines[i + 1]!).map((c) => c.endsWith(':') ? (c.startsWith(':') ? 'center' : 'right') : 'left')
const rows: MdInline[][][] = []
i += 2
while (i < lines.length && lines[i]!.trim() && lines[i]!.includes('|')) {
rows.push(cells(lines[i]!).map((c) => inlines(c, register)))
i++
}
blocks.push({ kind: 'table', headers, aligns, rows })
continue
}
if (isList(line)) {
const ordered = /^\s*\d+[.)]\s+/.test(line)
const items: MdInline[][] = []
while (i < lines.length && isList(lines[i]!)) {
let item = lines[i]!.replace(/^\s*([-*+]|\d+[.)])\s+/, '')
i++
// continuation lines indented under the item
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!) && !isList(lines[i]!)) item += ' ' + lines[i++]!.trim()
items.push(inlines(item, register))
}
blocks.push({ kind: 'list', ordered, items })
continue
}
const paragraph: string[] = []
while (i < lines.length && lines[i]!.trim() && !isHeading(lines[i]!) && !isList(lines[i]!) && !isTableStart(i)) paragraph.push(lines[i++]!.trim())
const joined = paragraph.join(' ')
const norm = decodeEntities(joined).replace(/\s+/g, ' ').trim()
if (seenParagraphs.has(norm)) continue
seenParagraphs.add(norm)
blocks.push({ kind: 'paragraph', inlines: inlines(joined, register) })
}
return { blocks, links }
}
/** The plain text of inlines; links as `label ↗` (or what `link` returns). */
export function inlineText(parts: MdInline[], link: (part: MdInline) => string = (p) => `${p.text} ↗`): string {
return parts.map((p) => (p.style === 'link' ? link(p) : p.text)).join('')
}
/** Word-wraps plain text to a width (long words are cut); `indent` goes before continuation lines. */
export function wrapPlain(text: string, width: number, first = '', rest = first): string[] {
const out: string[] = []
const w = Math.max(4, width)
let line = first
let empty = true
for (const word of text.split(/\s+/).filter(Boolean)) {
const prefixLen = (out.length ? rest : first).length
if (!empty && line.length + 1 + word.length > w) {
out.push(line)
line = rest
empty = true
}
let piece = word
while ((empty ? line.length : line.length + 1) + piece.length > w && piece.length > w - prefixLen) {
const room = w - line.length - (empty ? 0 : 1)
line += (empty ? '' : ' ') + piece.slice(0, room)
out.push(line)
line = rest
piece = piece.slice(room)
empty = true
}
line += (empty ? '' : ' ') + piece
empty = false
}
if (!empty || !out.length) out.push(line)
return out
}
/**
* The document as terminal text for a width: headings and paragraphs wrapped, lists with `-` or
* `1.`, tables laid out like the CLI's sub-tables (columns that do not fit are left out, cells cut
* with `…`). `link` renders a link (default `label ↗`).
*/
export function markdownText(doc: MdDocument, width = 100, link?: (part: MdInline) => string): string {
const out: string[] = []
for (const block of doc.blocks) {
if (out.length) out.push('')
if (block.kind === 'heading') out.push(...wrapPlain(inlineText(block.inlines, link), width))
else if (block.kind === 'paragraph') out.push(...wrapPlain(inlineText(block.inlines, link), width))
else if (block.kind === 'list') {
block.items.forEach((item, n) => {
const bullet = block.ordered ? `${n + 1}. ` : '- '
out.push(...wrapPlain(inlineText(item, link), width, bullet, ' '.repeat(bullet.length)))
})
} else {
const columns = block.headers.map((_, i) => `c${i}`)
const rows = block.rows.map((r) => Object.fromEntries(columns.map((c, i) => [c, inlineText(r[i] ?? [], link)])))
const { text, hidden } = layoutTable(rows, columns, block.headers.map((h) => inlineText(h, link)), { width, maxCell: Math.max(8, width) })
out.push(...text.split('\n'))
if (hidden.length) out.push(`(${hidden.length} more column${hidden.length === 1 ? '' : 's'} do not fit)`)
}
}
return out.join('\n')
}
/**
* An assistant answer as a document, plus the answer's links that the text does not already show
* (records and tables from tool results), once per target: the "→" list a client draws under the text.
* The document's links come first, then these, so numbering them in that order is reading order.
*/
export function answerDocument(answer: { text: string; links: Array<{ label: string; href: string }> }, primaryKeyOf?: PrimaryKeyOf): { doc: MdDocument; extraLinks: MdLink[] } {
const doc = parseMarkdown(answer.text, primaryKeyOf)
const keys = new Set(doc.links.map((l) => l.key))
const extraLinks: MdLink[] = []
for (const link of answer.links) {
const key = linkTargetKey(link.href, primaryKeyOf)
if (keys.has(key)) continue
keys.add(key)
extraLinks.push({ label: link.label, href: link.href, key })
}
return { doc, extraLinks }
}
-2
View File
@@ -46,8 +46,6 @@ export interface UiSettings {
childRows?: number
/** The terminal UI copies a CLI equivalent to the clipboard as soon as it shows it (default off). */
copyCliCommands?: boolean
/** The terminal UI's function-key bar: one row, two rows (with the Shift bank), or auto (two from 30 lines). */
fkeyBar?: 'one' | 'two' | 'auto'
/** Date entry: display order, clock, week start, picker behaviour (forms/dates.ts). */
dates?: import('../forms/dates.js').DateSettings
/** Keyed by screen, e.g. `table:person`, `record:person`, `app:peopleApp`. */
@@ -1,85 +0,0 @@
{
"conversationId": 4,
"text": "3 services are not fully healthy&#58; one degraded &#40;email&#41; and two with unknown status&#46; None are down&#46;\n\nUnhealthy services\n\n| Service | Status | Since | Checks up | Degraded | Unknown |\n| --- | --- | --- | ---: | ---: | ---: |\n| [email](/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B4%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D) | DEGRADED | 2026-11-01 11&#58;25 UTC | 0 | 1 | 0 |\n| [db&#95;performance](/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B3%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D) | UNKNOWN | 2026-11-01 17&#58;25 UTC | 2 | 0 | 1 |\n| [esb](/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B10%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D) | UNKNOWN | 2026-11-01 11&#58;23 UTC | 1 | 0 | 1 |\n\n- [Open unhealthy components](/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22status%22%2C%22operator%22%3A%22NOT_EQUALS%22%2C%22values%22%3A%5B%22UP%22%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D)\nThree services aren't fully healthy right now:\n\n- **email** is degraded. Its only check is degraded.\n- **db_performance** is unknown. One of its three checks has no status.\n- **esb** is unknown. One of its two checks has no status.\n\nNo service is down.",
"status": "COMPLETE",
"notices": [],
"numbers": [],
"links": [
{
"label": "db_performance (Health Components)",
"href": "/app/health_component/3",
"kind": "record",
"tableName": "health_component",
"recordId": "3"
},
{
"label": "email (Health Components)",
"href": "/app/health_component/4",
"kind": "record",
"tableName": "health_component",
"recordId": "4"
},
{
"label": "esb (Health Components)",
"href": "/app/health_component/10",
"kind": "record",
"tableName": "health_component",
"recordId": "10"
},
{
"label": "Health Components (3)",
"href": "/app/health_component?filter=eyJjcml0ZXJpYSI6W3siZmllbGROYW1lIjoic3RhdHVzIiwib3BlcmF0b3IiOiJOT1RfRVFVQUxTIiwidmFsdWVzIjpbIlVQIl19XSwiYm9vbGVhbk9wZXJhdG9yIjoiQU5EIn0%3D",
"kind": "table",
"tableName": "health_component"
},
{
"label": "email · DEGRADED",
"href": "/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B4%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D",
"kind": "table",
"tableName": "health_component"
},
{
"label": "db_performance · UNKNOWN",
"href": "/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B3%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D",
"kind": "table",
"tableName": "health_component"
},
{
"label": "esb · UNKNOWN",
"href": "/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B10%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D",
"kind": "table",
"tableName": "health_component"
},
{
"label": "Open unhealthy components",
"href": "/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22status%22%2C%22operator%22%3A%22NOT_EQUALS%22%2C%22values%22%3A%5B%22UP%22%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D",
"kind": "table",
"tableName": "health_component"
}
],
"tools": [
{
"name": "describe_table",
"summary": "Described Health Components",
"outcome": "OK"
},
{
"name": "query_records",
"summary": "Queried Health Components · 3 rows",
"outcome": "OK"
},
{
"name": "present_results",
"summary": "Presented results",
"outcome": "OK"
}
],
"turnId": 7,
"messageId": 14,
"usage": {
"inputTokens": 10,
"outputTokens": 729,
"costMicros": 34021
},
"elapsedMs": 7140
}
-63
View File
@@ -1,63 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
import { answerDocument, linkTargetKey, markdownText, parseMarkdown, wrapPlain } from '../render/markdown.js'
/** The answer to "what services are not healthy right now?" as 8078 gave it (qqq assistant ask --json). */
const fixture = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'src', 'test', 'fixtures', 'assistant-unhealthy.json'), 'utf8')) as { text: string; links: Array<{ label: string; href: string }> }
test('before: the raw answer has entities, a pipe table and URL-encoded link targets', () => {
assert.match(fixture.text, /healthy&#58;/)
assert.match(fixture.text, /\| --- \| --- \|/)
assert.match(fixture.text, /\(\/app\/health_component\?filter=%7B/)
assert.equal(fixture.links.length, 8)
})
test('after: entities decoded, the table laid out, links as label ↗ and never their URL', () => {
const { doc, extraLinks } = answerDocument(fixture)
const text = markdownText(doc, 100)
assert.doesNotMatch(text, /&#|\/app\/|%7B|\| ---/)
assert.match(text, /^3 services are not fully healthy: one degraded \(email\) and two with unknown status\. None are down\./)
assert.match(text, /Service\s+Status\s+Since\s+Checks up\s+Degraded\s+Unknown/)
assert.match(text, /db_performance ↗\s+UNKNOWN\s+2026-11-01 17:25 UTC/)
assert.match(text, /- Open unhealthy components ↗/)
assert.match(text, /- email is degraded\. Its only check is degraded\./)
assert.ok(text.split('\n').every((l) => [...l].length <= 100))
// four targets in reading order; the tool results' eight links are all the same four (records = id filters)
assert.deepEqual(doc.links.map((l) => l.label), ['email', 'db_performance', 'esb', 'Open unhealthy components'])
assert.deepEqual(extraLinks, [])
})
test('narrow: the table drops what does not fit and says so', () => {
const text = markdownText(answerDocument(fixture).doc, 40)
assert.ok(text.split('\n').every((l) => [...l].length <= 40))
assert.match(text, /more columns? do not fit/)
})
test('duplicate paragraphs collapse; lists, headings, bold and code', () => {
const doc = parseMarkdown('## Totals\n\nThere are **42** `customers`.\n\nThere are **42** `customers`.\n\n1. one\n2. two\n')
assert.deepEqual(doc.blocks.map((b) => b.kind), ['heading', 'paragraph', 'list'])
assert.deepEqual((doc.blocks[1] as { inlines: Array<{ style?: string }> }).inlines.map((i) => i.style ?? ''), ['', 'bold', '', 'code', ''])
assert.equal(markdownText(doc, 80), 'Totals\n\nThere are 42 customers.\n\n1. one\n2. two')
})
test('link targets: records, id filters, JSON and base64 filters compare by content', () => {
const json = '/app/t?filter=' + encodeURIComponent('{"booleanOperator":"AND","criteria":[{"fieldName":"s","operator":"EQUALS","values":["x"]}]}')
const b64 = '/app/t?filter=' + encodeURIComponent(Buffer.from('{"criteria":[{"fieldName":"s","operator":"EQUALS","values":["x"]}],"booleanOperator":"AND"}').toString('base64'))
assert.equal(linkTargetKey(json), linkTargetKey(b64))
assert.equal(linkTargetKey('/app/t/4'), linkTargetKey('/app/t?filter=' + encodeURIComponent('{"criteria":[{"fieldName":"id","operator":"EQUALS","values":[4]}]}')))
assert.notEqual(linkTargetKey('/app/t/4', () => 'code'), linkTargetKey('/app/t?filter=' + encodeURIComponent('{"criteria":[{"fieldName":"id","operator":"EQUALS","values":[4]}]}'), () => 'code'))
assert.equal(linkTargetKey('/app/t'), 'table:t:')
})
test('wrapping keeps the prefix and cuts long words', () => {
assert.deepEqual(wrapPlain('alpha beta gamma', 11, '- ', ' '), ['- alpha', ' beta', ' gamma'])
assert.deepEqual(wrapPlain('abcdefghij', 6), ['abcdef', 'ghij'])
})
+1 -56
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, 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 { 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 { readConfig, upsertProfile } from '../session/profiles.js'
const withPrinting = ExtensionRegistry.fromVersion({ extensions: [{ name: 'printing', version: '2.0.0' }] })
@@ -201,58 +201,3 @@ 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')
})
+1 -1
View File
@@ -18,6 +18,6 @@
"devDependencies": {
"@types/bun": "1.4.4",
"@types/react": "19.3.0",
"typescript": "7.0.2"
"typescript": "5.8.3"
}
}
+27 -130
View File
@@ -15,16 +15,15 @@ says `binary with ui`. The npm package `@qrun-io/qqq-cli` does not carry it. Fro
```sh
npm ci && npm run build # once, at the repo root (the kernel and CLI are used built)
(cd packages/tui && bun install --frozen-lockfile) # once
qqq ui # the splash, then the only profile (or a picker); or: qqq --profile sample ui
qqq ui # the default profile; or: qqq --profile sample ui
qqq ui table query person -w "lastName STARTS_WITH S" # open a screen directly (CLI grammar)
qqq ui --theme light | --ascii | --linear | --no-mouse | --no-splash | --check | --no-check
qqq ui --theme light | --ascii | --linear | --no-mouse | --check | --no-check
bun run packages/tui/src/main.tsx --profile sample # the same without the CLI
```
Options: `--profile|-p <name>`, `--server|-s <url>` (a profile named after the host is made), `--theme
<name>`, `--ascii` (ASCII glyphs), `--linear` (one region, no Apps pane), `--no-mouse` / `--mouse` (the
mouse for this run; the saved Mouse setting is on by default, see [Mouse](#mouse)), `--no-splash` /
`--splash` (the start-up splash for this run; see [Starting](#starting)),
mouse for this run; the saved Mouse setting is on by default, see [Mouse](#mouse)),
`--check` (the full terminal check, then exit), `--no-check` (skip the quick check at startup). Anything
after the options is a route in the CLI grammar.
@@ -32,36 +31,7 @@ Environment: `QQQ_UI_LINEAR=1`, `QQQ_UI_ASCII=1` (as the options); `QQQ_UI_SIGN_
OAUTH2 choice); `QQQ_UI_NO_BROWSER=1` (show the sign-in address without opening a browser);
`QQQ_PROFILE`, `QQQ_SERVER`, `QQQ_TOKEN` (a bearer token instead of the stored sign-in), `QQQ_CONFIG_DIR`,
`QQQ_STATE_DIR`, `QQQ_CACHE_DIR`, `QQQ_KEYRING=file` (keep sign-ins in a file instead of the OS keychain),
shared with the CLI. `QQQ_UI_NO_SPLASH=1` (as `--no-splash`). `QQQ_UI_NO_QUIT=1` is for the soak test only.
## Starting
`qqq ui` opens on a **splash**: Query (the qrun.io mascot) blinking, his tail wagging while it works, the
`qqq` wordmark, "Terminal client for QQQ", the version (`qqq 1.0.0 · UI API client 1.8.0`) and one status
line that follows the boot: reading profiles, connecting to the server, signing in, loading metadata (with
the table count). The large Query needs about 34 rows; smaller terminals (80×24) get the small Query beside
the wordmark, very small ones the text alone; `--ascii` draws it all in plain characters. Any key stops the
animation (not the boot). The splash stays at least 600 ms so it never just flashes, then Home opens as
before. `--no-splash`, `QQQ_UI_NO_SPLASH=1`, or F9 › Settings › **Splash on start: off** (`:splash off`,
saved as `ui.splash: off`) skip it.
Which server:
- `--profile`, `QQQ_PROFILE`, `--server` or `QQQ_SERVER` names one: it connects straight away. So does a
config with exactly one profile.
- Several profiles and none named: the **Servers picker** opens over the splash (the rows of the Servers
dialog, [Servers](#servers): name, short name, server, status, last used; who is signed in, the sign-in,
the description), with the last used profile selected. Enter connects; `n` adds a profile, `e` edits, typing
filters; the status column fills in as the servers answer (the picker never waits for them). Esc quits and
prints how to skip the picker (`--profile NAME` or `QQQ_PROFILE`).
- No profile at all (the first run): the add form opens directly; saving connects to the new profile. Esc
closes it; then `n` opens it again and Esc quits.
The sign-in runs as before (a MOCK or anonymous session, the OAuth2 browser or device flow with the address
or code on the splash, the username and password dialog). A failure stays on the splash with the reason:
Enter (or `r`) retries, `c` opens the picker to choose another profile (Esc there returns), `n` adds one,
Esc quits. Ctrl+O opens the picker too. With `--no-splash` the sign-in screen comes first, as in 1.0.0,
and the picker or the add form opens over it in the same cases.
shared with the CLI. `QQQ_UI_NO_QUIT=1` is for the soak test only.
## Keys
@@ -85,47 +55,6 @@ and the picker or the add form opens over it in the same cases.
| `t` | tags of a record (collaboration extension) | Esc then 1..0, Ctrl+1..0 | F1..F10 when the terminal takes the F-keys |
| Ctrl+P, `p`, PrtSc | print what you see: record sheet, table list (all rows or this page), or the screen (printing extension; else save as a file) | | |
### Shift bank (Shift+F1…F10)
A second row of ten function keys, like Norton Commander's Shift bar. Keys that do not apply on the screen
are dimmed; pressing one says why.
| Key | Label | Does |
|---|---|---|
| Shift+F1 | Keys | the keyboard reference (as `?`) |
| Shift+F2 | Servers | the Servers dialog (as Ctrl+O) |
| Shift+F3 | SQL | the SQL Runner (sql-workbench extension) |
| Shift+F4 | Bulk | bulk edit, bulk delete and processes on the grid selection (as `b`) |
| Shift+F5 | Metadata | refresh the metadata from the server |
| Shift+F6 | Views | saved views of the grid |
| Shift+F7 | Copy | copy the record, or the grid row under the cursor, into a new record (as `c`) |
| Shift+F8 | Export | export the grid to a file (as `x`) |
| Shift+F9 | History | the History screen (as `:history`) |
| Shift+F10 | Assistant | show or hide the Assistant panel (the same action as F12 and Ctrl+G; assistant extension) |
The bar shows the Shift row under the primary one (prefixed `⇧`) when the terminal has 30 lines or more.
`:fkeys one|two|auto` or F9 › Settings › F-key bar chooses: **auto** (two rows from 30 lines), **two** rows
always, or **one** row, where the Shift row takes the primary row's place for 2 seconds after any Shift+F
press (a terminal cannot report Shift being pressed and released on its own). A click on the Shift row runs
that key. The assignments are one table (`src/keys/shift-bank.ts`), each naming an action in
`src/keys/actions.ts`.
What terminals send for Shift+F1…F12 is decoded in one place (`src/keys/fkeys.ts`):
| Terminal | Shift+F1…F12 | Notes |
|---|---|---|
| tmux (any outer terminal) | `ESC [1;2P` … `ESC [1;2S`, `ESC [15;2~` … `ESC [24;2~` | verified live; tmux re-encodes the keys it knows |
| iTerm2, Windows Terminal, xterm, VTE, Konsole, Alacritty | the same xterm forms | kitty-protocol terminals (kitty, WezTerm, Ghostty, iTerm2 with CSI u) send `ESC [13;2~` for F3 and may send `ESC [57364;2u`…; both read |
| macOS Terminal.app | Shift+F5…F12 are `ESC [25~` … `ESC [34~` | Shift+F1…F4 have no code of their own by default: add them in Settings › Profiles › Keyboard (`\033[1;2P` …) or use the F9 menu |
| rxvt, urxvt, Linux console | Shift+F3…F10 are `ESC [25~` … `ESC [34~`, Shift+F11/F12 `ESC [23$` / `ESC [24$` | Shift+F1/F2 send the same as F11/F12 there |
Shift+F3 in the xterm form (`ESC [1;2R`) looks like a cursor position report, which OpenTUI drops; it and
the legacy forms are rewritten on the input stream into the canonical `ESC [n;2~` (never in the first
1.5 s after start or a resume, while the terminal answers OpenTUI's cursor queries). The legacy numbering is
picked from `TERM_PROGRAM` and `TERM`; `QQQ_FKEYS=vt220|rxvt|apple|modern` overrides it (vt220: `ESC [25~`
is Shift+F1; modern: legacy numbers are ignored, the default in tmux, iTerm2, Windows Terminal and kitty).
Ctrl+F1…F12 are decoded too but have no bindings yet: Ctrl+F5 acts as F5.
Keys on one screen:
| Screen | Keys |
@@ -155,19 +84,11 @@ 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, 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).
- 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.
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)
Then the printer picker: 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
@@ -429,51 +350,31 @@ help-authoring mode (F1 shows every help slot key).
## Assistant
With the `assistant` UI API extension (qbit-assistant), **F12** shows and hides the Assistant panel on
every screen (also `Ctrl+G`, `:ask [question]`, F9 › Work › Assistant, or a click on `12Ask`). It is a
chat pane on the right of the screen (40 to 64 columns), or the whole body on terminals narrower than 110
columns. The question line is at the bottom and the conversation above it. Answers are read-only and use
your permissions.
With the `assistant` UI API extension (qbit-assistant), `Ctrl+G`, `:ask [question]` or F9 › Work ›
Assistant opens the Assistant panel: a chat pane on the right of the screen (40 to 64 columns), or the
whole body on terminals narrower than 110 columns. The question line is at the bottom and the
conversation above it: each question, the answer as it is written, its numbers (counts and metrics) and
its links. Answers are read-only and use your permissions.
Hiding the panel keeps the conversation, its context and the draft on the question line; the next F12
shows them again. When the panel is shown but the screen beside it has the focus (after opening a link),
F12 goes back to the question line. F11 is reserved.
Answers are drawn from their markdown: entities decoded, headings in the accent colour, paragraphs
wrapped to the pane, bullet and numbered lists, **bold** and `code`, and pipe tables laid out like the
grid (columns that do not fit are named underneath, cells cut with `…`). A link shows as `label ↗`, never
its address; each is a Tab target in reading order, once per target. Records and tables the tool results
refer to that the text does not show are listed under `→`. Exact duplicate paragraphs are shown once;
the timing and token line stays, dimmed.
Questions carry the screen the panel was shown over as context, the way the Next UI's panel sends it: a
record (`/app/{table}/{id}`) or a grid with its filter; the first line of the panel says which
(`on: Person #3`). While the panel is beside the screen, a record or grid you open becomes the context.
Questions carry the screen under the panel as context, the way the Next UI's panel sends it: a record
(`/app/{table}/{id}`) or a grid with its filter; the first line of the panel says which (`on: Person #3`).
| Key | Does |
|---|---|
| Enter | ask the question; on a link, open it (a record or a grid, beside the panel; a full-width panel hides so the screen shows) |
| Enter | ask the question; on a link, open it (a record or a grid, beside the panel; a full-width panel closes so the screen shows) |
| Tab, Shift+Tab | move over the answer's links; typing returns to the question line |
| u | on a link: show its address |
| ↑ ↓ | the questions you asked on this profile (on the links: previous and next link) |
| PgUp PgDn, wheel | scroll the conversation |
| Ctrl+D | send without (or again with) the page context |
| Ctrl+L | a new conversation |
| Ctrl+X | stop the answer being written |
| F12, Ctrl+G | hide the panel (again: show it) |
| Esc | hide the panel (with text on the question line too: nothing is asked, the draft stays); on a link, back to the question line |
| Ctrl+G | back to the screen (the panel stays); again to return |
| Esc | close the panel (the conversation is kept) |
**F12 in your terminal.** The F-key bar shows `11` (blank, reserved) and `12Ask` from 120 columns;
narrower, the `?` help and F9 › Work say "F12 assistant". The TUI reads F12 both as the legacy sequence
(`ESC [24~`, also with modifiers, as tmux and iTerm2 send it by default) and as the kitty keyboard
protocol's key (57375). macOS may take F12 for Mission Control or Dashboard, or send it only with `fn`
(System Settings › Keyboard › Keyboard Shortcuts, or "Use F1, F2, etc. keys as standard function keys");
Ctrl+G is the fallback everywhere.
Without the extension the menu entry is disabled, `12Ask` is dimmed and F12 says what is missing. When
the assistant is off for you (no model provider on the server, no permission, the kill switch) the panel
shows the server's reason and what to configure; F5 checks again. The command line has the same:
`qqq assistant ask`, `qqq assistant config`.
Without the extension the menu entry is disabled and Ctrl+G says what is missing. When the assistant is
off for you (no model provider on the server, no permission, the kill switch) the panel shows the server's
reason and what to configure; F5 checks again. The command line has the same: `qqq assistant ask`,
`qqq assistant config`.
## SQL Runner
@@ -556,7 +457,7 @@ the plain sample server; one that fails shows `…`, then its last value dimmed
| Tile | Needs | Shows |
|------|-------|-------|
| 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 |
| 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" |
| 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 |
@@ -576,7 +477,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. 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
text. Query blinks, wags and hops every ten seconds and faces the way he drifts; 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
@@ -611,8 +512,7 @@ Everything below is local to the machine and shared with the CLI; nothing is sen
1.0.0-rc.1 `note`, `lastUsedAt` and `user` recorded by the UI, and `ui.theme` for that profile only)
and `defaultProfile`. Fields are only ever added: a 1.0.0-rc.1 file loads unchanged.
Sign-ins themselves are in the OS keychain (or the file keyring), one per profile.
- `ui.theme`: the theme for every profile without its own; `ui.mouse`: `false` when the mouse is off;
`ui.splash`: `off` when `qqq ui` starts without the splash.
- `ui.theme`: the theme for every profile without its own; `ui.mouse`: `false` when the mouse is off.
- `ui.dates` (global, or in a profile's `ui`): date order (`YMD`, `MDY`, `DMY`), `clock` (`24h`, `12h`),
`weekStart`, `weekNumbers`, `pickerOnEnter`, set with `:dates`. Without them the clock and the first day
of the week follow the locale (`LC_ALL`, `LC_TIME`, `LANG`), else 24-hour and Monday; the order is ISO.
@@ -637,7 +537,7 @@ global choice. `--ascii` replaces box drawing and symbols. Colours come only fro
The CLI equivalent dialog copies its command with `y` (or Enter); F9 › Settings can copy it automatically, and Shift+drag selects it with the terminal.
F9 › Settings also holds the mouse on/off, the splash on start on/off, single-key shortcuts on/off (default: the server preference
F9 › Settings also holds the mouse on/off, single-key shortcuts on/off (default: the server preference
`keyboard.singleKeyShortcuts`), the download and upload folders, the PIN and idle lock, and the terminal check.
## Keys that your terminal may intercept
@@ -663,19 +563,16 @@ before any program sees it. `qqq ui --check` (and the quick check at startup) fi
(System Settings › Keyboard), or hold fn.
- Whatever the terminal does, Ctrl+F / Ctrl+B page and the F9 menu and `:` line reach every action; Esc then
a digit (or Ctrl+digit) stands for F1..F10.
- **Shift+F-keys** (the Shift bank): see [Shift bank](#shift-bank-shiftf1f10) for what each terminal sends;
`QQQ_FKEYS` overrides the legacy numbering.
## Tests
```sh
cd packages/tui && bun test # or npm run test:tui at the root: the whole TUI against a fake server in memory
packages/tui/scripts/smoke.sh # tmux against a running server (profile "sample"); captures kept
# --server URL: an isolated config with two profiles, through the start-up picker
packages/tui/scripts/mouse-smoke.sh # the mouse in tmux: SGR 1006 clicks, drags and wheel written into the pane
packages/tui/scripts/smoke-widths.sh # fixed grid widths across pages (fake large-instance server)
packages/tui/scripts/smoke-sql.sh [server] # SQL Runner at 80x24 and 120x36 (default the enterprise server on 8078)
packages/tui/scripts/overflow-sweep.sh [server] # every screen and dialog (the splash, its picker, failure and add form too) at 80/100/120/200 x 24/50: no text past a border
packages/tui/scripts/overflow-sweep.sh [server] # every screen and dialog at 80/100/120/200 x 24/50: no text past a border
packages/tui/scripts/perf-large-table.sh # PRF-001: 10,000 rows x 40 columns, timings against a 2 s budget
packages/tui/scripts/soak.sh [server] [minutes] # random keys for 10 min; fails on an exit or an error boundary
```
+2 -42
View File
@@ -12,7 +12,7 @@
"devDependencies": {
"@types/bun": "1.4.4",
"@types/react": "19.3.0",
"typescript": "7.0.2",
"typescript": "5.8.3",
},
},
},
@@ -43,46 +43,6 @@
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
"@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
"@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
"@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
"@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
"@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
"@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
"@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
"@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
"@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
"@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
"@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
"@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
"@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
"@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
"@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
"@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
"@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
"ansi-regex": ["ansi-regex@6.5.0", "", {}, "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ=="],
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
@@ -113,7 +73,7 @@
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
+1 -1
View File
@@ -18,6 +18,6 @@
"devDependencies": {
"@types/bun": "1.4.4",
"@types/react": "19.3.0",
"typescript": "7.0.2"
"typescript": "5.8.3"
}
}
-71
View File
@@ -1,71 +0,0 @@
#!/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')}`)
+2 -9
View File
@@ -74,10 +74,9 @@ expect 07-record 'record 2 of'
keys Escape
expect 08-back 'row 2 '
right "$((hx + 1))" "$((hy + 4))"
expect 09-actions 'Actions'
expect 09-actions 'row 3 ' 'Actions'
keys Escape
# the grid's status line can sit under the dialog (the F-key bar takes two rows from 30 lines): checked after it closes
expect 09b-before-drag 'row 3 '
cap 09b-before-drag
read -r x y < <(at 09b-before-drag 'First Name')
read -r lx _ < <(at 09b-before-drag 'Last Name')
drag "$((lx - 1))" "$y" "$((lx + 6))"
@@ -90,12 +89,6 @@ expect 11b-footer ' · ping ' # the flash from the resize has faded
read -r x y < <(at 11b-footer ' · ping ')
click 2 "$y"
expect 12-footer-servers 'Servers'
# the Shift row of the F-key bar (two rows from 30 lines): a click on Shift+F2 opens the same dialog
keys Escape
cap 12c-bar
read -r x y < <(at 12c-bar '2Servers')
click "$((x + 1))" "$y"
expect 12d-shift-servers 'Servers (Ctrl+O)'
keys Escape F9 T:mouse
expect 12b-menu 'Mouse: on'
read -r x y < <(at 12b-menu 'Mouse: on')
-13
View File
@@ -65,22 +65,9 @@ shot() {
if [[ $bad == 0 ]]; then echo "ok $name"; else fails=$((fails + 1)); fi
}
cmd() { keys T:: "T:$1" Enter; }
# start_splash NAME CONFIG-JSON: a fresh TUI on its own config, no --server (the start-up splash, picker or add form)
start_splash() {
tmux kill-session -t "$s" 2>/dev/null || true
mkdir -p "$cfg/$1/config"
printf '%s\n' "$2" > "$cfg/$1/config/config.json"
tmux new-session -d -s "$s" -x 120 -y 36 "cd '$pkg' && QQQ_CONFIG_DIR='$cfg/$1/config' QQQ_STATE_DIR='$cfg/$1/state' QQQ_KEYRING=file QQQ_CACHE_DIR='$cfg/$1/cache' bun run src/main.tsx --no-check; sleep 30"
tmux set-option -t "$s" window-size manual > /dev/null
}
long="$(printf 'Long value %.0s' $(seq 1 30))"
shots=0
# the start-up splash: the picker (two profiles), a failed connection (retry / choose another), the add form (no profile)
start_splash picker "{\"profiles\":{\"sweep\":{\"server\":\"$server\",\"description\":\"the sweep server\"},\"other\":{\"server\":\"$server\"}}}"; shot 00a-splash-picker 'Connect to a server' 'Terminal client for QQQ'
start_splash failed '{"profiles":{"down":{"server":"http://127.0.0.1:9"}}}'; shot 00b-splash-failed 'Enter retries' 'Terminal client for QQQ'
start_splash add '{"profiles":{}}'; shot 00c-splash-add 'Add a server profile'
keys Escape; shot 00d-splash-no-profile 'n adds a server profile'
start; shot 01-home 'Quick actions' 'Recently viewed'
keys T:?; shot 02-help 'Keys'
start; keys T:/; shot 03-search 'Search'
+4 -13
View File
@@ -7,9 +7,7 @@
# packages/tui/scripts/smoke.sh [profile] a profile of your own config
# packages/tui/scripts/smoke.sh --server URL [--profile NAME] any server, in an isolated config
# (MOCK or anonymous sign-in; your
# config, keyring and cache untouched;
# two profiles, so it starts at the
# start-up picker and connects with Enter)
# config, keyring and cache untouched)
# OUT=dir WIDTH=160 HEIGHT=45
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -28,12 +26,11 @@ done
out="${OUT:-$(mktemp -d)}"
env_prefix=""
if [[ -n "$server" ]]; then
# an isolated config with two profiles: the start-up picker opens with this one (the last used) preselected
# an isolated config with one profile for the server, so the TUI signs in there instead of showing the picker
profile="${profile:-smoke}"
cfg="$(mktemp -d)"
mkdir -p "$cfg/config"
now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf '{"profiles":{"%s":{"server":"%s","lastUsedAt":"%s"},"smoke-other":{"server":"%s","lastUsedAt":"2020-01-01T00:00:00Z"}},"defaultProfile":"smoke-other"}\n' "$profile" "${server%/}" "$now" "${server%/}" > "$cfg/config/config.json"
printf '{"profiles":{"%s":{"server":"%s"}},"defaultProfile":"%s"}\n' "$profile" "${server%/}" "$profile" > "$cfg/config/config.json"
env_prefix="QQQ_CONFIG_DIR='$cfg/config' QQQ_CACHE_DIR='$cfg/cache' QQQ_STATE_DIR='$cfg/state' QQQ_KEYRING=file"
fi
profile="${profile:-sample}"
@@ -57,13 +54,7 @@ expect() { # name text...
echo "FAIL $name (wanted: $*)"; fails=$((fails + 1))
}
echo "smoke: profile $profile${server:+ at $server (isolated config)}; captures in $out"
if [[ -n "$server" ]]; then
tmux new-session -d -s "$s" -x "$w" -y "$h" "cd '$pkg' && $env_prefix bun run src/main.tsx --no-check; sleep 5"
expect 00-picker 'Connect to a server' 'Enter connects' 'Esc quits' 'Terminal client for QQQ' "$profile"
keys Enter
else
tmux new-session -d -s "$s" -x "$w" -y "$h" "cd '$pkg' && $env_prefix bun run src/main.tsx --no-check --profile '$profile'; sleep 5"
fi
tmux new-session -d -s "$s" -x "$w" -y "$h" "cd '$pkg' && $env_prefix bun run src/main.tsx --no-check --profile '$profile'; sleep 5"
expect 01-home 'Apps' 'UI API' 'Recently viewed' 'Quick actions'
keys T:: 'T:table query person' Enter
expect 02-grid 'records ·' 'First Name'
+32 -25
View File
@@ -5,14 +5,11 @@
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 { ShiftRow, barRows } from './keys/shift-row'
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'
@@ -42,6 +39,32 @@ 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?.())
@@ -89,7 +112,7 @@ function StatusLine({ store, width }: { store: AppStore; width: number }) {
}
/** Short labels for narrow terminals (below ~100 columns), so nothing clips at 80. */
const SHORT: Record<string, string> = { Help: 'Help', Search: 'Search', 'Go to': 'Go', View: 'View', Edit: 'Edit', Refresh: 'Refr', Filter: 'Filt', New: 'New', Delete: 'Del', Menu: 'Menu', Quit: 'Quit', NewChild: 'Child', 'Re-run': 'Again', Open: 'Open', Ask: 'Ask' }
const SHORT: Record<string, string> = { Help: 'Help', Search: 'Search', 'Go to': 'Go', View: 'View', Edit: 'Edit', Refresh: 'Refr', Filter: 'Filt', New: 'New', Delete: 'Del', Menu: 'Menu', Quit: 'Quit', NewChild: 'Child', 'Re-run': 'Again', Open: 'Open' }
function FKeyBar({ store, width }: { store: AppStore; width: number }) {
const t = theme()
@@ -101,7 +124,7 @@ function FKeyBar({ store, width }: { store: AppStore; width: number }) {
<FKeyZone store={store} width={width} keys={keys.map((k) => k.n)}>
<text flexShrink={0} wrapMode="none">
{keys.map((k) => {
const w = k === keys[keys.length - 1] ? Math.max(0, width - used) : cell
const w = k.n === 10 ? Math.max(0, width - used) : cell
used += w
const num = String(k.n).padStart(2)
// a key that does nothing here still shows its global label, dimmed, so the bar teaches the keymap
@@ -133,9 +156,7 @@ export function App({ store }: { store: AppStore }) {
const t = theme()
const w = Math.max(20, width)
const h = Math.max(8, height)
// the F-key bar: the primary row, and the Shift row under it with two rows (keys/shift-row.tsx)
const rows = barRows(store, h)
const bodyHeight = h - 2 - rows
const bodyHeight = h - 3
const assistant = store.assistantLayout()
const full = assistant?.mode === 'full'
const sidebarWidth = store.showSidebar && !full ? (w >= 160 ? 32 : 28) : 0
@@ -143,19 +164,6 @@ export function App({ store }: { store: AppStore }) {
const contentWidth = w - sidebarWidth - assistantWidth
const screen = store.screen
const top = store.overlays[store.overlays.length - 1]
const overlays = store.overlays.map((o, i) => <MouseLayer key={o.id} layer={i + 1}><ErrorBoundary resetKey={o.id} width={Math.min(60, w)} label="Dialog">{o.render(w, h)}</ErrorBoundary></MouseLayer>)
if (screen?.kind === 'splash') {
// the start-up splash has the whole terminal: no title bar, status line or key bar yet
return (
<MouseRoot dispatcher={mouse} press={store.pressKey}>
<box flexDirection="column" overflow="hidden" width={w} height={h}>
<ErrorBoundary resetKey={screen.id} width={w} label="Splash">{screen.render(w, h, !top)}</ErrorBoundary>
{overlays}
<MouseCapture width={w} height={h} />
</box>
</MouseRoot>
)
}
return (
<MouseRoot dispatcher={mouse} press={store.pressKey}>
<box flexDirection="column" overflow="hidden" width={w} height={h}>
@@ -172,9 +180,8 @@ export function App({ store }: { store: AppStore }) {
{assistantWidth > 0 && store.assistant && <ErrorBoundary resetKey={store.assistant.id} width={assistantWidth} label="Assistant">{store.assistant.render(assistantWidth, bodyHeight, store.focus === 'assistant' && !top)}</ErrorBoundary>}
</box>
<StatusLine store={store} width={w} />
{rows === 1 && store.shiftRowShown() ? <ShiftRow store={store} width={w} slots={store.fkeys().length} /> : <FKeyBar store={store} width={w} />}
{rows === 2 && <ShiftRow store={store} width={w} slots={store.fkeys().length} />}
{overlays}
<FKeyBar store={store} width={w} />
{store.overlays.map((o, i) => <MouseLayer key={o.id} layer={i + 1}><ErrorBoundary resetKey={o.id} width={Math.min(60, w)} label="Dialog">{o.render(w, h)}</ErrorBoundary></MouseLayer>)}
{store.lock && <ErrorBoundary key="lock" resetKey="lock" width={w} label="Lock">{store.lock.render(w, h)}</ErrorBoundary>}
<MouseCapture width={w} height={h} />
</box>
+16 -158
View File
@@ -4,16 +4,13 @@
*/
/**
* The assistant panel's conversation as lines for a width: each question, the answer's markdown
* rendered (entities decoded, headings, wrapped paragraphs, lists, **bold**, `code`, pipe tables laid
* out like the grid and clipped with `…`), its numbers, and the links. A link shows as `label ↗`,
* never its URL; links are numbered across the conversation in reading order, once per target (the
* text's links, then the tool results' links the text does not show), so the panel can move a cursor
* over them. Every line fits the width it was built for.
* The assistant panel's conversation as lines for a width: each question, the answer (wrapped,
* markdown markers dropped), its numbers and its links. Links are numbered across the whole
* conversation so the panel can move a cursor over them; every line fits the width it was built for.
*/
import { answerDocument, fitColumns, type AssistantAnswer, type AssistantPageContext, type MdBlock, type MdInline, type MdLink, type PrimaryKeyOf } from '@qrun-io/qqq-core'
import { cellWidth, trunc } from '../util/text'
import type { AssistantAnswer, AssistantPageContext } from '@qrun-io/qqq-core'
import { cellWidth } from '../util/text'
/** One question and its answer (streaming until `answer.status` leaves RUNNING). */
export interface Exchange {
@@ -29,22 +26,13 @@ export interface Exchange {
startedAt: number
}
export type LineRole = 'question' | 'context' | 'text' | 'heading' | 'header' | 'rule' | 'number' | 'link' | 'activity' | 'error' | 'meta' | 'blank'
/** A run of a line with one look; `link` is the link number it opens. */
export interface Seg {
text: string
style?: 'bold' | 'code' | 'link' | 'dim'
link?: number
}
export type LineRole = 'question' | 'context' | 'text' | 'number' | 'link' | 'activity' | 'error' | 'meta' | 'blank'
export interface ChatLine {
/** The line as plain text (what the segments add up to). */
text: string
role: LineRole
/** The first link on the line (a click opens it). */
/** Index into {@link chatLinks} for link lines. */
link?: number
segs?: Seg[]
}
/** The answer's markdown as plain text (bold, code and heading markers dropped). */
@@ -86,140 +74,15 @@ export function wrap(text: string, width: number): string[] {
return out
}
/** An answer's links in reading order with their offset into the conversation's numbering. */
function answerLinks(answer: AssistantAnswer, primaryKeyOf?: PrimaryKeyOf): { blocks: MdBlock[]; text: MdLink[]; extra: MdLink[] } {
const { doc, extraLinks } = answerDocument(answer, primaryKeyOf)
return { blocks: doc.blocks, text: doc.links, extra: extraLinks }
}
/** Every link of the conversation, in reading order, once per target per answer (what the link cursor moves over). */
export function chatLinks(exchanges: Exchange[], primaryKeyOf?: PrimaryKeyOf): Array<{ label: string; href: string }> {
return exchanges.flatMap((e) => {
if (!e.answer) return []
const { text, extra } = answerLinks(e.answer, primaryKeyOf)
return [...text, ...extra]
})
}
function segsFrom(inlines: MdInline[], offset: number): Seg[] {
return inlines.map((p) => p.style === 'link' ? { text: `${p.text} ↗`, style: 'link' as const, link: offset + p.link! } : { text: p.text, ...(p.style ? { style: p.style } : {}) })
}
/** Wraps styled runs to a width, word by word; `first`/`rest` go before the first and later lines. */
export function wrapSegs(segs: Seg[], width: number, role: LineRole, first = '', rest = first): ChatLine[] {
const words: Seg[] = []
for (const s of segs) {
for (const part of s.text.split(/(\s+)/)) if (part) words.push({ ...s, text: part })
}
const lines: ChatLine[] = []
let cur: Seg[] = [{ text: first }]
let used = cellWidth(first)
let content = false
let pendingSpace: Seg | undefined
const flush = () => {
lines.push(line(cur, role))
cur = [{ text: rest }]
used = cellWidth(rest)
content = false
pendingSpace = undefined
}
for (const word of words) {
if (/^\s+$/.test(word.text)) {
if (content) pendingSpace = { ...word, text: ' ' }
continue
}
let text = word.text
let w = cellWidth(text)
if (content && used + (pendingSpace ? 1 : 0) + w > width) flush()
if (pendingSpace && content) {
cur.push(pendingSpace)
used += 1
}
pendingSpace = undefined
while (used + w > width) {
let cut = ''
for (const ch of text) {
if (used + cellWidth(cut + ch) > width) break
cut += ch
}
if (!cut) break
cur.push({ ...word, text: cut })
flush()
text = text.slice(cut.length)
w = cellWidth(text)
}
if (text) {
cur.push({ ...word, text })
used += w
content = true
}
}
if (content) flush()
return lines
}
function line(segs: Seg[], role: LineRole): ChatLine {
const merged: Seg[] = []
for (const s of segs) {
if (!s.text) continue
const last = merged[merged.length - 1]
if (last && last.style === s.style && last.link === s.link) last.text += s.text
else merged.push({ ...s })
}
const link = merged.find((s) => s.link !== undefined)?.link
return { text: merged.map((s) => s.text).join(''), role, segs: merged, ...(link !== undefined ? { link } : {}) }
}
/** A pipe table laid out like the grid: columns fitted to the width (the rest left out), cells cut with `…`. */
function tableLines(block: Extract<MdBlock, { kind: 'table' }>, width: number, offset: number): ChatLine[] {
const cellSegs = (inl: MdInline[]) => segsFrom(inl, offset)
const plain = (segs: Seg[]) => segs.map((s) => s.text).join('').replace(/\s+/g, ' ')
const headers = block.headers.map((h) => plain(cellSegs(h)))
const rows = block.rows.map((r) => block.headers.map((_, i) => cellSegs(r[i] ?? [])))
const natural = headers.map((h, i) => Math.max(cellWidth(h), ...rows.map((r) => cellWidth(plain(r[i]!)))))
const { widths, shown } = fitColumns(natural, width)
const fit = (text: string, w: number, align: string) => {
const t = trunc(text, w)
const room = Math.max(0, w - cellWidth(t))
return align === 'right' ? ' '.repeat(room) + t : t + ' '.repeat(room)
}
const out: ChatLine[] = []
out.push(line(headers.slice(0, shown).flatMap((h, i) => [{ text: fit(h, widths[i]!, block.aligns[i] ?? 'left'), style: 'bold' as const }, { text: i < shown - 1 ? ' ' : '' }]), 'header'))
out.push(line([{ text: widths.map((w) => '─'.repeat(w)).join(' '), style: 'dim' }], 'rule'))
for (const r of rows) {
const segs: Seg[] = []
r.slice(0, shown).forEach((cell, i) => {
const text = fit(plain(cell), widths[i]!, block.aligns[i] ?? 'left')
const linked = cell.find((s) => s.link !== undefined)
segs.push(linked ? { text, style: 'link', link: linked.link } : { text })
if (i < shown - 1) segs.push({ text: ' ' })
})
out.push(line(segs, 'text'))
}
if (shown < headers.length) out.push(line([{ text: trunc(`(${headers.length - shown} more column${headers.length - shown === 1 ? '' : 's'}: ${headers.slice(shown).join(', ')})`, width), style: 'dim' }], 'meta'))
return out.map((l) => (cellWidth(l.text) > width ? { ...l, text: trunc(l.text, width), segs: [{ text: trunc(l.text, width), style: l.segs?.[0]?.style }] } : l))
}
/** An answer's markdown as lines (links numbered from `offset`). */
export function answerLines(blocks: MdBlock[], width: number, offset: number): ChatLine[] {
const out: ChatLine[] = []
for (const block of blocks) {
if (out.length) out.push({ text: '', role: 'blank' })
if (block.kind === 'heading') out.push(...wrapSegs(segsFrom(block.inlines, offset).map((s) => ({ ...s, style: s.style ?? 'bold' })), width, 'heading'))
else if (block.kind === 'paragraph') out.push(...wrapSegs(segsFrom(block.inlines, offset), width, 'text'))
else if (block.kind === 'list') block.items.forEach((item, n) => {
const bullet = block.ordered ? `${n + 1}. ` : '• '
out.push(...wrapSegs(segsFrom(item, offset), width, 'text', bullet, ' '.repeat(cellWidth(bullet))))
})
else out.push(...tableLines(block, width, offset))
}
return out
/** Every link of the conversation, in order (what the link cursor moves over). */
export function chatLinks(exchanges: Exchange[]): Array<{ label: string; href: string }> {
return exchanges.flatMap((e) => e.answer?.links ?? [])
}
/** The conversation as lines for a width (the panel's inner width). */
export function chatLines(exchanges: Exchange[], width: number, now = Date.now(), primaryKeyOf?: PrimaryKeyOf): ChatLine[] {
export function chatLines(exchanges: Exchange[], width: number, now = Date.now()): ChatLine[] {
const lines: ChatLine[] = []
let offset = 0
let linkIndex = 0
const add = (text: string, role: LineRole, prefix = '', link?: number) => {
const rest = ' '.repeat(cellWidth(prefix))
wrap(text, width - cellWidth(prefix)).forEach((l, i) => lines.push({ text: (i === 0 ? prefix : rest) + l, role, ...(link !== undefined ? { link } : {}) }))
@@ -230,16 +93,11 @@ export function chatLines(exchanges: Exchange[], width: number, now = Date.now()
if (e.contextLabel) add(`on ${e.contextLabel}`, 'context', ' ')
const answer = e.answer
const running = !e.error && (!answer || answer.status === 'RUNNING')
const parsed = answer ? answerLinks(answer, primaryKeyOf) : { blocks: [], text: [], extra: [] }
if (parsed.blocks.length) lines.push(...answerLines(parsed.blocks, width, offset))
if (running && !parsed.blocks.length) add(`${e.activity ?? 'thinking'}… ${Math.max(0, Math.round((now - e.startedAt) / 1000))} s`, 'activity')
const text = plainText(answer?.text ?? '').trim()
if (text) add(text, 'text')
if (running && !text) add(`${e.activity ?? 'thinking'}… ${Math.max(0, Math.round((now - e.startedAt) / 1000))} s`, 'activity')
for (const num of answer?.numbers ?? []) add(`${num.label}: ${num.value}`, 'number', '# ')
// the tool results' links the text does not show, once per target
parsed.extra.forEach((link, i) => {
const index = offset + parsed.text.length + i
wrapSegs([{ text: `${link.label} ↗`, style: 'link', link: index }], width, 'link', '→ ', ' ').forEach((l) => lines.push(l))
})
offset += parsed.text.length + parsed.extra.length
for (const link of answer?.links ?? []) add(link.label, 'link', '→ ', linkIndex++)
for (const notice of answer?.notices ?? []) add(notice.text, notice.level === 'error' ? 'error' : 'meta', `${notice.level}: `)
if (e.error) add(e.error, 'error')
else if (answer?.error) add(`${answer.error.message} (${answer.error.code})`, 'error')
+18 -66
View File
@@ -24,8 +24,8 @@ import { wheelAsKeys } from '../mouse/keys'
import type { AppStore } from '../store'
import { borderProps, theme } from '../theme'
import type { FKeyLabels } from '../types'
import { cellWidth, errorText, hintLine, pad, trunc } from '../util/text'
import { chatLines, chatLinks, contextLabel, wrap, type ChatLine, type Exchange, type Seg } from './model'
import { errorText, hintLine, pad, trunc } from '../util/text'
import { chatLines, chatLinks, contextLabel, wrap, type ChatLine, type Exchange } from './model'
/** Below this terminal width the panel takes the whole body instead of a right-hand pane. */
export const SIDE_MIN_WIDTH = 110
@@ -92,26 +92,9 @@ export class AssistantPanel {
void this.loadConfig().then(() => this.ask(question))
}
/** The context questions carry: the screen the panel was shown over (kept while hidden). */
private pinned?: AssistantPageContext
/**
* Takes the screen under the panel as the context: when the panel is shown, and while it is shown
* beside the screen (a link opened from it becomes the context). A screen without one (Home)
* keeps the context the panel had.
*/
capturePageContext(): void {
const context = this.screenContext()
if (context) this.pinned = context
}
/** The context the next question carries (none after Ctrl+D). */
/** The page under the panel as context: a record, or a grid with its filter. */
pageContext(): AssistantPageContext | undefined {
return this.dropContext ? undefined : this.pinned
}
/** The screen's context: a record, or a grid with its filter. */
screenContext(): AssistantPageContext | undefined {
if (this.dropContext) return undefined
const screen = this.store.screen as unknown as { kind?: string; tableName?: string; primaryKey?: string; filter?: Record<string, unknown> } | undefined
if (screen?.kind === 'record' && screen.tableName && screen.primaryKey !== undefined) return assistantContextFor({ tableName: screen.tableName, recordId: screen.primaryKey })
if (screen?.kind === 'table' && screen.tableName) return assistantContextFor({ tableName: screen.tableName, filter: screen.filter })
@@ -187,23 +170,9 @@ export class AssistantPanel {
this.store.bump()
}
/** A table's primary key field from the metadata (links that filter on it are that record). */
readonly primaryKeyOf = (table: string): string | undefined => (this.store.meta?.tables?.[table] as { primaryKeyField?: string } | undefined)?.primaryKeyField
/** Every link of the conversation, in reading order, once per target. */
links(): Array<{ label: string; href: string }> {
return chatLinks(this.exchanges, this.primaryKeyOf)
}
/** `u` on a link: its address, shown (never opened). */
showUrl(index = this.linkCursor): void {
const link = this.links()[index]
if (link) this.store.message(`${link.label}\n${link.href}`, 'info', 'Link address')
}
/** Opens the link under the cursor in the screen beside (or, on a narrow terminal, under) the panel. */
openLink(index = this.linkCursor): void {
const link = this.links()[index]
const link = chatLinks(this.exchanges)[index]
if (!link) return
this.linkCursor = -1
this.store.focus = 'content'
@@ -229,14 +198,13 @@ export class AssistantPanel {
['Ctrl+D', 'send without / with the page context'],
['Ctrl+L', 'new conversation'],
['Ctrl+X', 'stop the answer'],
['u', 'on a link: show its address'],
['F12 Ctrl+G', 'hide the panel (the conversation, context and draft are kept); again shows it'],
['Esc', 'hide the panel (on a link: back to the question line)'],
['Ctrl+G', 'back to the screen (the panel stays)'],
['Esc', 'close the panel (the conversation is kept)'],
]
}
onKey(key: Key): boolean {
const links = this.links().length
const links = chatLinks(this.exchanges).length
if (key.ctrl && key.name === 'x') return this.stop(), true
if (key.ctrl && key.name === 'l') return this.clear(), true
if (key.ctrl && key.name === 'd') {
@@ -246,7 +214,6 @@ export class AssistantPanel {
}
switch (key.name) {
case 'escape':
// hides the panel; the draft on the question line stays for next time
if (this.linkCursor >= 0) this.linkCursor = -1
else this.store.closeAssistant()
return true
@@ -280,7 +247,6 @@ export class AssistantPanel {
break
}
if (key.name.startsWith('f') && /^f\d+$/.test(key.name)) return false
if (this.linkCursor >= 0 && !key.ctrl && !key.meta && key.sequence === 'u') return this.showUrl(), true
if (this.editor.handle(key)) {
this.linkCursor = -1
return true
@@ -319,16 +285,15 @@ export class AssistantPanel {
render(width: number, height: number, focused: boolean) {
const t = theme()
if (this.store.assistantLayout()?.mode === 'side') this.capturePageContext()
const inner = Math.max(10, width - 2)
const context = this.pageContext()
const label = this.label(context)
const ctxLine = this.dropContext ? 'no page context (Ctrl+D adds it)' : label ? `on: ${label} (Ctrl+D drops)` : 'no page context'
const bodyHeight = Math.max(1, height - 2 - 3)
this.lastHeight = bodyHeight
const all: ChatLine[] = this.exchanges.length ? chatLines(this.exchanges, inner, Date.now(), this.primaryKeyOf) : this.intro().flatMap((x) => wrap(x.text, inner).map((text) => ({ ...x, text })))
const all: ChatLine[] = this.exchanges.length ? chatLines(this.exchanges, inner) : this.intro().flatMap((x) => wrap(x.text, inner).map((text) => ({ ...x, text })))
if (this.pendingReveal && this.linkCursor >= 0) {
const at = all.findIndex((l) => l.segs?.some((x) => x.link === this.linkCursor) || l.link === this.linkCursor)
const at = all.findIndex((l) => l.link === this.linkCursor)
if (at >= 0) {
const bottom = all.length - this.scrollBack
if (at < bottom - bodyHeight) this.scrollBack = all.length - at - bodyHeight
@@ -341,40 +306,27 @@ export class AssistantPanel {
const shown = all.slice(Math.max(0, end - bodyHeight), end)
const color = (l: ChatLine): string => {
switch (l.role) {
case 'question': case 'heading': case 'header': return t.acc
case 'context': case 'meta': case 'activity': case 'rule': return t.dim
case 'question': return t.acc
case 'context': case 'meta': case 'activity': return t.dim
case 'number': return t.acc2
case 'link': return t.ok
case 'error': return t.err
default: return t.fg
}
}
const segColor = (l: ChatLine, seg: Seg): string => seg.style === 'link' ? t.ok : seg.style === 'code' ? t.acc2 : seg.style === 'dim' ? t.dim : color(l)
const rows = shown.map((l, i) => {
const used = l.segs ? l.segs.reduce((n, x) => n + cellWidth(x.text), 0) : 0
const body = l.segs
? (
<text flexShrink={0} wrapMode="none">
{l.segs.map((seg, k) => {
const selected = seg.link !== undefined && seg.link === this.linkCursor
const fg = selected ? t.selFg : segColor(l, seg)
const bg = selected ? t.selBg : undefined
return <span key={k} fg={fg} bg={bg}>{seg.style === 'bold' ? <strong>{seg.text}</strong> : seg.text}</span>
})}
<span>{' '.repeat(Math.max(0, inner - used))}</span>
</text>
)
: <text flexShrink={0} wrapMode="none" fg={color(l)}>{pad(l.text, inner)}</text>
const selected = l.link !== undefined && l.link === this.linkCursor
const text = <text flexShrink={0} wrapMode="none" fg={selected ? t.selFg : color(l)} bg={selected ? t.selBg : undefined}>{pad(l.text, inner)}</text>
return l.link === undefined
? <box key={i} height={1} width={inner}>{body}</box>
: <Zone key={i} onClick={() => { this.store.focus = 'assistant'; this.linkCursor = l.link!; this.openLink(l.link) }}><box height={1} width={inner}>{body}</box></Zone>
? <box key={i} height={1} width={inner}>{text}</box>
: <Zone key={i} onClick={() => { this.store.focus = 'assistant'; this.linkCursor = l.link!; this.openLink(l.link) }}><box height={1} width={inner}>{text}</box></Zone>
})
while (rows.length < bodyHeight) rows.push(<box key={`p${rows.length}`} height={1} width={inner}><text flexShrink={0} wrapMode="none">{pad('', inner)}</text></box>)
const view = this.editor.view(Math.max(1, inner - 2))
const ready = !this.config || this.config.enabled
const hint = this.linkCursor >= 0
? 'Enter opens · u address · ↑↓ Tab links · Esc back to the question'
: this.streaming ? 'answering… Ctrl+X stops · PgUp PgDn scroll · F12/Esc hides' : 'Enter asks · Tab links · ↑↓ history · Ctrl+L new · F12/Esc hides'
? 'Enter opens · ↑↓ Tab links · Esc back to the question'
: this.streaming ? 'answering… Ctrl+X stops · PgUp PgDn scroll · Esc closes' : 'Enter asks · Tab links · ↑↓ history · Ctrl+L new · Esc closes'
const title = `Assistant${this.config?.model && ready ? ` · ${this.config.model}` : ''}${this.scrollBack ? ` · ↑${this.scrollBack}` : ''}`
return (
<Zone
-104
View File
@@ -1,104 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* What `qqq ui` does first, decided from the options, the environment and config.json before
* anything is drawn: connect to one profile, ask which profile (several saved, none named), or add
* the first one (none saved). Also the Splash setting (`ui.splash`: on unless off) and its timing.
*/
import { readConfig, resolveProfile, writeConfig, type Config } from '@qrun-io/qqq-core'
import type { Target } from './store'
/** The splash stays up at least this long (ms) so it never just flashes. */
export const SPLASH_MIN_MS = 600
export type BootPlan =
/** A profile was named (--profile, QQQ_PROFILE, --server, QQQ_SERVER) or only one exists. */
| { kind: 'connect'; target: Target }
/** Several profiles and none named: the Servers picker, `select` preselected (the last used). */
| { kind: 'pick'; select?: string; note?: string }
/** No profile at all: the add form. */
| { kind: 'add'; note?: string }
/** Whether a profile was named for this run (an option or the environment). */
export function namedProfile(args: { profile?: string; server?: string }, env: NodeJS.ProcessEnv = process.env): boolean {
return Boolean(args.profile || args.server || env.QQQ_PROFILE || env.QQQ_SERVER)
}
/** The most recently used profile (config lastUsedAt), else the default, else the first. */
export function lastUsedProfile(config: Config): string | undefined {
const names = Object.keys(config.profiles)
let best: string | undefined
let bestAt = -Infinity
for (const n of names) {
const at = Date.parse(config.profiles[n]!.lastUsedAt ?? '')
if (!Number.isNaN(at) && at > bestAt) {
best = n
bestAt = at
}
}
return best ?? (config.defaultProfile && config.profiles[config.defaultProfile] ? config.defaultProfile : names[0])
}
/** Decides the first step of a run (pure: config and environment are passed in by tests). */
export function planBoot(args: { profile?: string; server?: string }, env: NodeJS.ProcessEnv = process.env, config: Config = readConfig(env)): BootPlan {
const names = Object.keys(config.profiles)
if (!namedProfile(args, env) && names.length === 1) return { kind: 'connect', target: { name: names[0]!, server: config.profiles[names[0]!]!.server } }
if (namedProfile(args, env)) {
try {
const r = resolveProfile({ profile: args.profile, server: args.server }, env, config)
return { kind: 'connect', target: { name: r.name, server: r.server } }
} catch (e) {
// a name that does not exist: choose from what there is (or add the first)
const note = e instanceof Error ? e.message : String(e)
return names.length ? { kind: 'pick', select: lastUsedProfile(config), note } : { kind: 'add', note }
}
}
if (!names.length) return { kind: 'add' }
return { kind: 'pick', select: lastUsedProfile(config) }
}
/** The Splash setting: on unless `ui.splash` is off (or false). */
export function readSplash(env: NodeJS.ProcessEnv = process.env): boolean {
try {
const v = (readConfig(env).ui as { splash?: unknown } | undefined)?.splash
return !(v === false || v === 'off')
} catch {
return true
}
}
export function writeSplash(on: boolean, env: NodeJS.ProcessEnv = process.env): void {
const config = readConfig(env)
config.ui = { ...config.ui, splash: on ? 'on' : 'off' } as never
writeConfig(config, env)
}
/** Whether this run shows the splash: --no-splash (or QQQ_UI_NO_SPLASH=1) and the setting turn it off. */
export function splashEnabled(args: { splash?: boolean }, env: NodeJS.ProcessEnv = process.env): boolean {
if (args.splash !== undefined) return args.splash
if (env.QQQ_UI_NO_SPLASH === '1') return false
return readSplash(env)
}
/** How long to wait before leaving the splash, given when it was shown. */
export function splashWait(shownAt: number, now: number, minMs = SPLASH_MIN_MS): number {
return Math.max(0, shownAt + minMs - now)
}
/** The one line printed when the picker is closed without choosing. */
export const PICK_HINT = 'qqq ui: no profile chosen; --profile NAME (or QQQ_PROFILE) skips the picker'
/** Settings > Splash on start and `:splash on|off`: saved for the next start. */
export function setSplashSetting(store: { flash: (text: string) => void; error: (e: unknown) => void }, on: boolean): void {
try {
writeSplash(on)
} catch (e) {
store.error(e)
return
}
store.flash(on ? 'splash on (saved): the next qqq ui starts on the splash' : 'splash off (saved): the next qqq ui starts without it (--splash shows it once)')
}
+11 -44
View File
@@ -5,15 +5,10 @@
/** Builds the store for a run: settings from options, config and environment; sign-in; the first route. */
import { probeServer, readConfig, readTheme, writeTheme, defaultSecretStore, type SecretStore } from '@qrun-io/qqq-core'
import { PICK_HINT, SPLASH_MIN_MS, planBoot, splashEnabled, type BootPlan } from './boot-plan'
import { readConfig, readTheme, resolveProfile, writeTheme, defaultSecretStore, type SecretStore } from '@qrun-io/qqq-core'
import { installCommands } from './commands'
import { readMouse } from './mouse/settings'
import { readFKeyBar } from './keys/shift-bank'
import { ProfileFormOverlay, ServersOverlay } from './overlays/servers'
import { ServersModel } from './servers'
import { SignInScreen, type SignInDeps } from './screens/signin'
import { SplashScreen } from './screens/splash'
import { Sidebar } from './screens/sidebar'
import { AppStore, setSecretStoreFactory, type Target } from './store'
import { DEFAULT_THEME, THEMES } from './theme'
@@ -26,8 +21,6 @@ export interface CliArgs {
linear: boolean
/** --mouse / --no-mouse for this run; unset: the saved Mouse setting (on by default). */
mouse?: boolean
/** --splash / --no-splash for this run; unset: the Splash setting (ui.splash, on by default). */
splash?: boolean
check: 'startup' | 'full' | 'off'
route: string[]
}
@@ -47,8 +40,6 @@ export function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env):
else if (a === '--linear') out.linear = true
else if (a === '--mouse') out.mouse = true
else if (a === '--no-mouse') out.mouse = false
else if (a === '--splash') out.splash = true
else if (a === '--no-splash') out.splash = false
else if (a === '--check') out.check = 'full'
else if (a === '--no-check') out.check = 'off'
else out.route.push(a)
@@ -63,29 +54,25 @@ export interface BootOptions {
secrets?: () => SecretStore
fetch?: typeof fetch
timers?: boolean
/** Shows the splash; unset: --no-splash, QQQ_UI_NO_SPLASH=1 and the Splash setting decide. */
splash?: boolean
/** The splash's minimum display time; unset: 600 ms with timers (a real run), else none. */
splashMinMs?: number
/** Prints a line once the terminal is restored (the picker closed without a choice). */
hint?: (text: string) => void
}
/**
* Creates the store and starts the boot: the splash (unless switched off), then the profile named
* or the only one; with several the Servers picker, with none the add form (boot-plan.ts).
*/
/** Creates the store and starts sign-in (or connects straight away with a stored sign-in). */
export function boot(options: BootOptions): AppStore {
const { args } = options
const plan: BootPlan = planBoot(args)
const target: Target | undefined = plan.kind === 'connect' ? plan.target : undefined
let note = plan.kind === 'connect' ? undefined : plan.note
let target: Target | undefined
let note: string | undefined
try {
const r = resolveProfile({ profile: args.profile, server: args.server })
target = { name: r.name, server: r.server }
} catch (e) {
note = e instanceof Error ? e.message : String(e)
}
if (args.theme) {
if (THEMES[args.theme]) writeTheme(args.theme)
else note = `unknown theme "${args.theme}"; themes: ${Object.keys(THEMES).join(', ')}`
}
const theme = (args.theme && THEMES[args.theme] ? args.theme : undefined) ?? readTheme(target?.name) ?? DEFAULT_THEME
const store = new AppStore({ settings: { theme, ascii: args.ascii, linear: args.linear, mouse: args.mouse ?? readMouse(), singleKeys: true, copyCli: readConfig().ui?.copyCliCommands === true, fkeyBar: readFKeyBar() }, quit: options.quit, build: options.build, target, timers: options.timers })
const store = new AppStore({ settings: { theme, ascii: args.ascii, linear: args.linear, mouse: args.mouse ?? readMouse(), singleKeys: true, copyCli: readConfig().ui?.copyCliCommands === true }, quit: options.quit, build: options.build, target, timers: options.timers })
let secrets: SecretStore | undefined
const secretsFn = options.secrets ?? (() => (secrets ??= defaultSecretStore()))
setSecretStoreFactory(secretsFn)
@@ -105,29 +92,9 @@ export function boot(options: BootOptions): AppStore {
},
}
store.signInDeps = deps
const exit = (hint: string) => {
store.quit()
if (hint) options.hint?.(hint)
}
if (options.splash ?? splashEnabled(args)) {
const splash = new SplashScreen(store, { plan, deps, exit, minMs: options.splashMinMs ?? (options.timers ? SPLASH_MIN_MS : 0), animate: Boolean(options.timers) })
store.push(splash)
splash.start()
return store
}
// --no-splash: the sign-in screen, with the same first step
const screen = new SignInScreen(store, deps, target, note)
store.push(screen)
if (target) screen.choose(target)
else {
const model = new ServersModel({ secrets: secretsFn, probe: (server) => probeServer(server, { fetch: options.fetch }), onChange: () => store.bump() })
if (plan.kind === 'pick') store.open(new ServersOverlay(store, (row) => screen.choose({ name: row.name, server: row.server }), model, { title: 'Connect to a server', enterLabel: 'Enter connects', escLabel: 'Esc quits', select: plan.select, onEscape: () => exit(PICK_HINT) }))
else store.open(new ProfileFormOverlay(store, model, undefined, (name) => {
const p = readConfig().profiles[name]
screen.build()
if (p) screen.choose({ name, server: p.server })
}))
}
return store
}
-35
View File
@@ -1,35 +0,0 @@
/*
* 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()]
}
+5 -27
View File
@@ -11,7 +11,6 @@
import { createProgram, runCli } from '@qrun-io/qqq-cli/program'
import { setMouseSetting } from './mouse/settings'
import { readSplash, setSplashSetting } from './boot-plan'
import { THEMES } from './theme'
import { metaHelp, processHelp, tableHelp, applyLiveFix, checkEnvironment, formatReport, buildFilter, readFilePreferences, startDirForSave, startDirForUpload, writeFilePreferences, hasPin, readConfig, readRecents, removePin, setPin, writeConfig, writeIdleMinutes, writeTheme, readIdleMinutes, type QueryFilter } from '@qrun-io/qqq-core'
import { openApp, openForm, openProcess, openRecord, openTable } from './nav'
@@ -40,7 +39,6 @@ import { withPrintEntry } from './overlays/actions'
import { openHistory } from './history/screen'
import { searchLines } from './history/reverse-search'
import { changeEvent, commandEvent, history } from './history/record'
import { FKEY_BAR_MODES, describeFKeyBar, runShiftKey, setFKeyBar, shiftBankHelp, type FKeyBarMode } from './keys/shift-bank'
const NOUNS: Record<string, string[]> = {
table: ['list', 'describe', 'query', 'count', 'get', 'create', 'update', 'delete', 'export'],
@@ -55,7 +53,7 @@ const NOUNS: Record<string, string[]> = {
apps: [], whoami: [], search: [], 'help-search': [],
}
const TUI_COMMANDS = ['quit', 'home', 'reset', 'tags', 'audits', 'dev', 'actions', 'back', 'help', 'menu', 'goto', 'view', 'theme', 'ascii', 'linear', 'mouse', 'splash', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'ask', 'dates', 'history', 'fkeys']
const TUI_COMMANDS = ['quit', 'home', 'reset', 'tags', 'audits', 'dev', 'actions', 'back', 'help', 'menu', 'goto', 'view', 'theme', 'ascii', 'linear', 'mouse', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'ask', 'dates', 'history']
/** One line on each TUI command, for the `:` line's completion popup. */
export const TUI_COMMAND_HELP: Record<string, string> = {
@@ -65,7 +63,7 @@ export const TUI_COMMAND_HELP: Record<string, string> = {
shortcuts: 'single-key shortcuts on or off', lock: 'lock the screen', pin: 'set or remove the lock PIN', idle: 'screen saver after idle minutes', refresh: 'reload this screen',
filter: 'filter the grid (grammar)', export: 'export the grid or result', jobs: 'your jobs', printjobs: 'print jobs', print: 'print what you see (or :print <printer>)', profile: 'switch profile', servers: 'saved servers (Ctrl+O)',
history: 'everything you did (CLI and TUI)', server: 'connect to a server URL', signout: 'sign out', connection: 'connection details', open: 'open a table', record: 'open a record by key', app: 'open an app',
sql: 'SQL Runner (statement to run, open <notebook id>)', ask: 'show or hide the Assistant panel (F12, Ctrl+G); with a question, asks it about this screen', saver: 'the screen saver', doctor: 'check the terminal', views: 'saved views of this table', share: 'share this record', helpkeys: 'help-authoring mode', fkeys: 'F-key bar: one row, two rows (with the Shift bank) or auto',
sql: 'SQL Runner (statement to run, open <notebook id>)', ask: 'the Assistant panel (Ctrl+G); with a question, asks it about this screen', saver: 'the screen saver', doctor: 'check the terminal', views: 'saved views of this table', share: 'share this record', helpkeys: 'help-authoring mode',
}
/** CLI commands the `:` line does not offer (they make no sense inside the TUI). */
@@ -97,10 +95,7 @@ export class Commands implements CommandsLike {
// keys only: the screen's own key rows and shortcuts from its actions (label and key), never the actions themselves
const panelKeys = s.focus === 'assistant' && s.assistantOpen && s.assistant ? s.assistant.helpKeys() : []
const screenKeys: KeyRow[] = [...panelKeys, ...(screen?.helpKeys?.() ?? []), ...(screen?.menu?.() ?? []).filter((e) => e.key && !e.disabled).map((e): KeyRow => [e.key!, e.label.replace(/…$/, '').replace(/\s*\([^)]*\d[^)]*\)$/, '')])]
const help = new HelpOverlay(s, helpKeyGroups(screen?.kind, s.singleKeys, screenKeys), topicHelp)
// the Shift bank (Shift+F1…F10) after the screen's help
help.after = [{ title: 'Shift bank (Shift+F1…F10; the second row of the F-key bar)', keys: shiftBankHelp(s) }]
s.open(help)
s.open(new HelpOverlay(s, helpKeyGroups(screen?.kind, s.singleKeys, screenKeys), topicHelp))
// help for this screen: the kernel's help entries for the screen's roles; in help-authoring mode
// (:helpkeys) every slot shows its key, with or without content, so authors see where help goes
const authoring = s.helpKeys
@@ -235,10 +230,6 @@ export class Commands implements CommandsLike {
}
/** The Servers dialog: saved profiles, probed in the background; Enter switches. */
shiftKey(n: number): void {
runShiftKey(this.store, n)
}
servers(): void {
const s = this.store
if (s.overlays.some((o) => o instanceof ServersOverlay)) return
@@ -358,7 +349,6 @@ export class Commands implements CommandsLike {
case 'linear': s.settings.linear = rest[0] !== 'off'; s.bump(); return s.flash(`linear layout ${s.settings.linear ? 'on' : 'off'}`)
case 'shortcuts': return this.setShortcuts(rest[0] !== 'off')
case 'mouse': return setMouseSetting(s, rest[0] === undefined ? !s.settings.mouse : rest[0] !== 'off')
case 'splash': return setSplashSetting(s, rest[0] === undefined ? !readSplash() : rest[0] !== 'off')
case 'idle': {
const m = Number(rest[0])
if (!Number.isFinite(m)) return fail('idle <minutes>: 0 turns the screen saver timeout off')
@@ -428,12 +418,6 @@ export class Commands implements CommandsLike {
case 'doctor': case 'check': return this.doctor()
case 'profile': return rest[0] ? this.switchProfile(rest[0]) : this.chooseProfile()
case 'servers': return this.servers()
case 'fkeys': {
const mode = rest[0] as FKeyBarMode | undefined
if (!mode) return s.flash(`F-key bar: ${describeFKeyBar(s.settings.fkeyBar ?? 'auto')} · :fkeys one|two|auto`)
if (!FKEY_BAR_MODES.includes(mode)) return fail('fkeys one|two|auto')
return setFKeyBar(s, mode)
}
case 'server': return rest[0] ? this.switchServer(rest[0]) : fail('server <url>')
case 'signout': return this.signOut()
case 'connection': return this.connection()
@@ -442,10 +426,7 @@ export class Commands implements CommandsLike {
case 'open': return rest[0] ? openTable(s, rest[0]) : fail('open <table>')
case 'app': return rest[0] ? openApp(s, rest[0]) : fail('app <name>')
case 'sql': return sqlCommand(s, line.replace(/^sql\b/, ''))
case 'ask': {
const question = line.replace(/^ask\b/, '').trim()
return question ? s.openAssistant(question) : s.toggleAssistant()
}
case 'ask': return s.openAssistant(line.replace(/^ask\b/, ''))
case 'report':
if (rest[0] === 'run' && rest[1] && !rest.includes('--format') && !rest.includes('--out')) return openReport(s, rest[1])
break
@@ -747,7 +728,7 @@ export class Commands implements CommandsLike {
out.push({ section: 'Work', label: 'My work', disabled: s.extensions.has('collaboration') ? undefined : 'needs the collaboration extension' })
out.push({ section: 'Work', label: 'History', command: 'history', detail: 'everything you did, CLI and TUI', run: () => openHistory(s) })
out.push(...sqlMenuEntries(s))
out.push({ id: 'work:assistant', section: 'Work', label: 'Assistant', key: 'F12', command: 'ask <question>', detail: 'F12 assistant (Ctrl+G too): ask about your data, with this screen as context', disabled: s.extensions.has('assistant') ? undefined : 'needs the assistant extension (qbit-assistant)', run: () => s.openAssistant() })
out.push({ id: 'work:assistant', section: 'Work', label: 'Assistant', key: 'Ctrl+G', command: 'ask <question>', detail: 'ask about your data, with this screen as context', disabled: s.extensions.has('assistant') ? undefined : 'needs the assistant extension (qbit-assistant)', run: () => s.openAssistant() })
for (const e of s.extensions.all()) out.push({ section: 'Work', label: `Extension: ${e.name} ${e.version}`, detail: e.description, command: `api --list`, run: () => s.message(`${e.name} ${e.version}\n${e.description ?? ''}\nPath: ${e.pathPrefix ?? ''}\nIts panels are not drawn by this TUI yet; its operations are reachable with :api.`, 'info', 'Extension') })
// Connection
out.push({ section: 'Connection', label: `Server ${s.status.server}`, detail: `UI API ${s.status.apiVersion}`, command: 'connection', run: () => this.connection() })
@@ -765,9 +746,6 @@ export class Commands implements CommandsLike {
out.push({ section: 'Settings', label: `Single-key shortcuts: ${s.settings.singleKeys ? 'on' : 'off'}`, command: `shortcuts ${s.settings.singleKeys ? 'off' : 'on'}`, run: () => this.setShortcuts(!s.settings.singleKeys) })
out.push({ section: 'Settings', label: `Linear layout: ${s.settings.linear ? 'on' : 'off'}`, command: `linear ${s.settings.linear ? 'off' : 'on'}`, run: () => { s.settings.linear = !s.settings.linear; s.bump() } })
out.push({ id: 'settings:mouse', section: 'Settings', label: `Mouse: ${s.settings.mouse ? 'on' : 'off'}`, command: `mouse ${s.settings.mouse ? 'off' : 'on'}`, detail: s.settings.mouse ? 'off restores plain terminal text selection' : undefined, run: () => setMouseSetting(s, !s.settings.mouse) })
out.push({ id: 'settings:fkeys', section: 'Settings', label: `F-key bar: ${s.settings.fkeyBar ?? 'auto'}`, command: 'fkeys one|two|auto', detail: describeFKeyBar(s.settings.fkeyBar ?? 'auto'), run: () => s.open(new ChoiceOverlay<FKeyBarMode>(s, 'F-key bar', FKEY_BAR_MODES.map((m) => ({ label: m, value: m, detail: describeFKeyBar(m) })), (m) => setFKeyBar(s, m), 'Enter chooses')) })
const splash = readSplash()
out.push({ id: 'settings:splash', section: 'Settings', label: `Splash on start: ${splash ? 'on' : 'off'}`, command: `splash ${splash ? 'off' : 'on'}`, detail: 'Query and the boot steps while qqq ui starts', run: () => setSplashSetting(s, !splash) })
out.push({ section: 'Settings', label: `ASCII glyphs: ${s.settings.ascii ? 'on' : 'off'}`, command: `ascii ${s.settings.ascii ? 'off' : 'on'}`, run: () => s.setAscii(!s.settings.ascii) })
out.push({ section: 'Settings', label: 'Screen saver / lock', command: 'lock', run: () => this.lock() })
let idle = 0
+1 -1
View File
@@ -26,7 +26,7 @@ const ANYWHERE: KeyRow[] = [
['F9', 'menu: areas, then their actions'],
['Ctrl+P PrtSc', 'print what you see: a record sheet, a table list (all rows or this page), or the screen; Ctrl+P always works, Print Screen only where the terminal passes it on'],
['Ctrl+O', 'servers: switch, add, edit'],
['F12 Ctrl+G', 'F12 assistant: show or hide the Assistant panel (this screen is the context); :ask <question>'],
['Ctrl+G', 'Assistant panel: ask about your data (this screen is the context); :ask <question>'],
[':', 'command line; Tab completes, ↑↓ history, Ctrl+R searches it; :history lists all you did'],
['F10', 'quit, reset or cancel'],
['Ctrl+C', 'quit now'],
+1 -1
View File
@@ -30,7 +30,7 @@ function sameAction(command: string | undefined): string | undefined {
* `:` commands the TUI does itself (theme, lock, the Servers dialog …): logged as `:…`, re-run on
* the `:` line only. Everything else on the `:` line is a CLI command (`qqq …`).
*/
const TUI_ONLY = new Set(['quit', 'q', 'exit', 'home', 'reset', 'tags', 'audits', 'audit', 'dev', 'developer', 'actions', 'back', 'help', 'menu', 'goto', 'palette', 'view', 'theme', 'ascii', 'linear', 'mouse', 'splash', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'dates', 'helpkeys', 'views', 'share', 'doctor', 'check', 'history'])
const TUI_ONLY = new Set(['quit', 'q', 'exit', 'home', 'reset', 'tags', 'audits', 'audit', 'dev', 'developer', 'actions', 'back', 'help', 'menu', 'goto', 'palette', 'view', 'theme', 'ascii', 'linear', 'mouse', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'dates', 'helpkeys', 'views', 'share', 'doctor', 'check', 'history'])
let last: { action?: string; at: number; words?: string[] } = { at: 0 }
-133
View File
@@ -1,133 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Named actions that keys bind to: the Shift bank (shift-bank.ts) binds by name, and so can any other
* key (F12 and Ctrl+G bind `assistant.toggle`). Each action says whether it applies on the current
* screen; one that does not says why, and the bar dims its key.
*/
import { openForm, permits } from '../nav'
import { openHistory } from '../history/screen'
import { openSqlRunner, sqlWorkbenchInstalled } from '../sql/entry'
import type { AppStore } from '../store'
import type { MenuEntry } from '../types'
export interface NamedAction {
/** Why it does not apply here; undefined when it does. */
unavailable(store: AppStore): string | undefined
run(store: AppStore): void
}
/** The parts of the table grid the actions use (screens/table.tsx). */
interface GridLike {
kind: string
tableName: string
meta?: unknown
currentRecord?: unknown
keyOf?(record: unknown): string | undefined
bulkActions?(): void
bulkEntries?(): MenuEntry[]
tableProcesses?(): unknown[]
savedViews?(): void
exportDialog?(): void
variant?: unknown
}
/** The parts of the record view the actions use (screens/record.tsx). */
interface RecordLike {
kind: string
tableName: string
primaryKey?: string
meta?: unknown
tableVariant?: string
}
function grid(store: AppStore): GridLike | undefined {
const s = store.screen as unknown as GridLike | undefined
return s?.kind === 'table' ? s : undefined
}
function record(store: AppStore): RecordLike | undefined {
const s = store.screen as unknown as RecordLike | undefined
return s?.kind === 'record' ? s : undefined
}
/** The record to copy: the record on screen, or the grid row under the cursor. */
function copySource(store: AppStore): { table: string; key: string; meta: unknown; tableVariant?: string } | undefined {
const r = record(store)
if (r?.primaryKey !== undefined) return { table: r.tableName, key: r.primaryKey, meta: r.meta, tableVariant: r.tableVariant }
const g = grid(store)
const key = g?.keyOf?.(g.currentRecord)
return g && key !== undefined ? { table: g.tableName, key, meta: g.meta } : undefined
}
export const ACTIONS = {
'help.keys': {
unavailable: () => undefined,
run: (s) => s.commands().help(),
},
servers: {
unavailable: () => undefined,
run: (s) => s.commands().servers(),
},
'sql.open': {
unavailable: (s) => sqlWorkbenchInstalled(s) ? undefined : 'needs the sql-workbench extension',
run: (s) => openSqlRunner(s),
},
'grid.bulk': {
unavailable: (s) => {
const g = grid(s)
if (!g) return 'bulk actions work on a table grid'
return (g.bulkEntries?.().length ?? 0) + (g.tableProcesses?.().length ?? 0) ? undefined : 'this table has no bulk actions or processes'
},
run: (s) => grid(s)?.bulkActions?.(),
},
'meta.refresh': {
unavailable: (s) => s.client ? undefined : 'not connected',
run: (s) => s.run(s.client!.metaData(true), (m) => {
s.setMeta(m)
s.client!.clearCaches()
s.flash('metadata refreshed')
}),
},
'grid.views': {
unavailable: (s) => grid(s) ? undefined : 'saved views work on a table grid',
run: (s) => grid(s)?.savedViews?.(),
},
'record.copy': {
unavailable: (s) => {
const src = copySource(s)
if (!src) return 'copy works on a record or a grid row'
return permits(src.meta as never, 'insert') ? undefined : 'copying needs permission to create records'
},
run: (s) => {
const src = copySource(s)
if (src) openForm(s, src.table, { mode: 'copy', primaryKey: src.key, ...(src.tableVariant ? { tableVariant: src.tableVariant } : {}) })
},
},
export: {
unavailable: (s) => grid(s) ? undefined : 'export works on a table grid',
run: (s) => grid(s)?.exportDialog?.(),
},
history: {
unavailable: () => undefined,
run: (s) => openHistory(s),
},
'assistant.toggle': {
unavailable: (s) => s.extensions.has('assistant') ? undefined : 'needs the assistant extension',
run: (s) => s.toggleAssistant(),
},
} satisfies Record<string, NamedAction>
export type ActionName = keyof typeof ACTIONS
/** Runs a named action, or says why it does not apply here. */
export function runAction(store: AppStore, name: ActionName, label: string = name): void {
const action: NamedAction = ACTIONS[name]
const why = action.unavailable(store)
if (why) return store.flash(`${label}: ${why}`)
action.run(store)
}
-214
View File
@@ -1,214 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The one decoder for function keys with modifiers (Shift+F1…F12, Ctrl+F-keys, Alt), across the
* forms terminals send:
*
* - xterm modifier forms: `CSI 1;m P|Q|R|S` (F1–F4), `SS3 m P…S`, `CSI n;m ~` (F5–F12; also
* `CSI 11..14;m ~` and kitty's `CSI 13;m ~` for F3), m = 1 + shift + 2·alt + 4·ctrl
* (tmux, iTerm2, Windows Terminal, xterm, VTE, Konsole, Alacritty);
* - the kitty keyboard protocol: the same forms with `:event` sub-parameters, and `CSI 57364..57375;m u`;
* - legacy numbered forms with no modifier parameter, which mean different keys per terminal:
* `CSI 25~ … CSI 34~` is Shift+F1…F8 with the VT220 numbering (F13 = Shift+F1), Shift+F3…F10 on
* rxvt and the Linux console (F11 = Shift+F1), Shift+F5…F12 on macOS Terminal.app;
* - rxvt's suffixes: `CSI n $` Shift, `CSI n ^` Ctrl, `CSI n @` Ctrl+Shift;
* - the Linux console's `CSI [ A…E` (F1–F5).
*
* OpenTUI 0.8 reads the xterm and kitty forms itself, except Shift+F3 (`CSI 1;2R`) and the other
* modified F3s: they look like a cursor position report, so OpenTUI takes them as one and drops them.
* It reads none of the legacy or rxvt forms. `rewriteFKeyInput` turns exactly those into the
* canonical `CSI n;m ~` form before OpenTUI sees the bytes (stdin-rewrite.ts), so every terminal
* ends up as one OpenTUI key event (name `f3`, shift true), which `bankKey` names `shift+f3`.
*/
import type { Key } from '../keys'
/** A function key with its modifiers. */
export interface FKeyChord {
/** 1..12 */
n: number
shift: boolean
ctrl: boolean
alt: boolean
/** A kitty key-release event (nothing to do). */
release?: boolean
}
/** Which sequence family a chord was read from. */
export type FKeyFamily = 'ss3' | 'csi-letter' | 'csi-f3-cpr' | 'csi-tilde' | 'kitty-u' | 'legacy-number' | 'rxvt-suffix' | 'linux-console'
/**
* How a terminal numbers its legacy shifted F-keys (`CSI 25~` and up): `vt220` (F13 = Shift+F1),
* `rxvt` (rxvt, urxvt, the Linux console: F11 = Shift+F1), `apple` (macOS Terminal.app: Shift+F5 is
* `CSI 25~`), `modern` (the terminal only sends the xterm forms; legacy numbers mean nothing).
*/
export type LegacyProfile = 'vt220' | 'rxvt' | 'apple' | 'modern'
export const LEGACY_PROFILES: LegacyProfile[] = ['vt220', 'rxvt', 'apple', 'modern']
/** The `CSI n ~` number of F1..F12. */
const TILDE_CODE = [0, 11, 12, 13, 14, 15, 17, 18, 19, 20, 21, 23, 24]
const TILDE_TO_N: Record<number, number> = Object.fromEntries(TILDE_CODE.map((code, n) => [code, n]).filter(([, n]) => n > 0))
const LETTER_TO_N: Record<string, number> = { P: 1, Q: 2, R: 3, S: 4 }
const LINUX_TO_N: Record<string, number> = { A: 1, B: 2, C: 3, D: 4, E: 5 }
/** kitty's private-use code points for F1..F12 (F13 and up are real keys of their own). */
const KITTY_F1 = 57364
/** Legacy numbers (no modifier) that stand for Shift+F-keys, per profile. */
const LEGACY_SHIFT: Record<LegacyProfile, Record<number, number>> = {
vt220: { 25: 1, 26: 2, 28: 3, 29: 4, 31: 5, 32: 6, 33: 7, 34: 8 },
rxvt: { 25: 3, 26: 4, 28: 5, 29: 6, 31: 7, 32: 8, 33: 9, 34: 10 },
apple: { 25: 5, 26: 6, 28: 7, 29: 8, 31: 9, 32: 10, 33: 11, 34: 12 },
modern: {},
}
/** The profile for this terminal: QQQ_FKEYS overrides; otherwise from TERM_PROGRAM, TERM and friends. */
export function legacyProfile(env: NodeJS.ProcessEnv = process.env): LegacyProfile {
const forced = env.QQQ_FKEYS?.trim().toLowerCase()
if (forced && (LEGACY_PROFILES as string[]).includes(forced)) return forced as LegacyProfile
const program = env.TERM_PROGRAM ?? ''
const term = env.TERM ?? ''
if (program === 'Apple_Terminal') return 'apple'
// tmux re-encodes the keys it knows in the xterm forms; Windows Terminal sends CSI 29~ for the Menu key
if (env.TMUX || env.WT_SESSION || /^(iTerm\.app|WezTerm|ghostty|vscode|tmux)$/.test(program) || /kitty|ghostty|alacritty|foot/.test(term)) return 'modern'
if (/^(rxvt|linux)/.test(term)) return 'rxvt'
return 'vt220'
}
function modifiers(m: number): Pick<FKeyChord, 'shift' | 'ctrl' | 'alt'> {
const bits = Math.max(0, m - 1)
return { shift: Boolean(bits & 1), alt: Boolean(bits & 2), ctrl: Boolean(bits & 4) }
}
/** `m` or `m:event` → modifier number and whether it is a kitty release. */
function modParam(p: string | undefined): { m: number; release: boolean } {
if (!p) return { m: 1, release: false }
const [m, event] = p.split(':')
return { m: Number(m) || 1, release: event === '3' }
}
function chord(n: number, m: number, release = false, alt = false): FKeyChord | undefined {
if (!(n >= 1 && n <= 12)) return undefined
const mods = modifiers(m)
const out: FKeyChord = { n, ...mods, alt: mods.alt || alt }
if (release) out.release = true
return out
}
/** Reads one whole sequence; undefined when it is not a function key. */
export function decodeFKey(seq: string, profile: LegacyProfile = 'vt220'): { chord: FKeyChord; family: FKeyFamily } | undefined {
// an extra ESC in front is Alt (meta) on terminals that send it that way
let s = seq
let alt = false
if (s.startsWith('\x1b\x1b')) {
alt = true
s = s.slice(1)
}
let m: RegExpExecArray | null
if ((m = /^\x1bO(\d*)([PQRS])$/.exec(s))) {
const c = chord(LETTER_TO_N[m[2]!]!, Number(m[1]) || 1, false, alt)
return c && { chord: c, family: 'ss3' }
}
if ((m = /^\x1b\[(?:1;)?(\d+(?::\d+)?)?([PQRS])$/.exec(s))) {
const { m: mod, release } = modParam(m[1])
const c = chord(LETTER_TO_N[m[2]!]!, mod, release, alt)
return c && { chord: c, family: m[2] === 'R' ? 'csi-f3-cpr' : 'csi-letter' }
}
if ((m = /^\x1b\[(\d+)(?:;(\d+(?::\d+)?))?~$/.exec(s))) {
const code = Number(m[1])
const { m: mod, release } = modParam(m[2])
if (TILDE_TO_N[code]) {
const c = chord(TILDE_TO_N[code]!, mod, release, alt)
return c && { chord: c, family: 'csi-tilde' }
}
const shifted = LEGACY_SHIFT[profile][code]
if (shifted && !m[2]) {
const c = chord(shifted, 2, false, alt)
return c && { chord: c, family: 'legacy-number' }
}
return undefined
}
if ((m = /^\x1b\[(\d+)([$^@])$/.exec(s))) {
// rxvt: $ Shift, ^ Ctrl, @ Ctrl+Shift; 25..34 are the shifted F3..F10 there
const code = Number(m[1])
const suffix = m[2]!
const base = TILDE_TO_N[code] ?? LEGACY_SHIFT.rxvt[code]
if (!base) return undefined
const shift = suffix !== '^' || LEGACY_SHIFT.rxvt[code] !== undefined
const ctrl = suffix !== '$'
const c = chord(base, 1 + (shift ? 1 : 0) + (ctrl ? 4 : 0), false, alt)
return c && { chord: c, family: 'rxvt-suffix' }
}
if ((m = /^\x1b\[(\d+)(?:;(\d+(?::\d+)?))?u$/.exec(s))) {
const { m: mod, release } = modParam(m[2])
const c = chord(Number(m[1]) - KITTY_F1 + 1, mod, release, alt)
return c && { chord: c, family: 'kitty-u' }
}
if ((m = /^\x1b\[\[([A-E])$/.exec(s))) {
const c = chord(LINUX_TO_N[m[1]!]!, 1, false, alt)
return c && { chord: c, family: 'linux-console' }
}
return undefined
}
/** `shift+f2`, `ctrl+f5`, `ctrl+shift+f1`, `alt+f3`, `f4`. */
export function chordName(c: FKeyChord): string {
return `${c.ctrl ? 'ctrl+' : ''}${c.alt ? 'alt+' : ''}${c.shift ? 'shift+' : ''}f${c.n}`
}
/** The canonical xterm form OpenTUI reads for every modifier: `CSI n;m ~`. */
export function canonicalSequence(c: FKeyChord): string {
const m = 1 + (c.shift ? 1 : 0) + (c.alt ? 2 : 0) + (c.ctrl ? 4 : 0)
return `\x1b[${TILDE_CODE[c.n]}${m > 1 ? `;${m}` : ''}~`
}
/** Families OpenTUI 0.8 does not read (or reads as something else); these are rewritten. */
const NEEDS_REWRITE = new Set<FKeyFamily>(['csi-f3-cpr', 'legacy-number', 'rxvt-suffix'])
/**
* Rewrites the function-key sequences OpenTUI cannot read into the canonical form, leaving every
* other byte alone. `cpr` false leaves `CSI 1;m R` alone: during startup (and after a resume) those
* bytes are real cursor position reports that OpenTUI's capability probe is waiting for. A chunk
* inside a bracketed paste is never touched.
*/
export function rewriteFKeyInput(chunk: string, profile: LegacyProfile, options: { cpr: boolean }): string {
if (!chunk.includes('\x1b') || chunk.includes('\x1b[200~')) return chunk
return chunk.replace(/\x1b{1,2}\[(?:1;\d+R|\d+[~$^@])/g, (seq) => {
if (seq.endsWith('R') && !options.cpr) return seq
const found = decodeFKey(seq, profile)
if (!found || !NEEDS_REWRITE.has(found.family)) return seq
// Shift+F3 and friends only with a modifier: CSI 1;1R is always a cursor report
if (found.family === 'csi-f3-cpr' && !found.chord.shift && !found.chord.ctrl && !found.chord.alt) return seq
return canonicalSequence(found.chord)
})
}
/** The chord of a key event from OpenTUI (or a test), when it is a function key. */
export function fkeyChord(key: Key, profile: LegacyProfile = 'vt220'): FKeyChord | undefined {
const named = /^f(\d{1,2})$/.exec(key.name)
if (named) {
const n = Number(named[1])
return n >= 1 && n <= 12 ? { n, shift: Boolean(key.shift), ctrl: Boolean(key.ctrl), alt: Boolean(key.meta) } : undefined
}
if (key.name && key.name !== 'menu') return undefined
return key.sequence?.startsWith('\x1b') ? decodeFKey(key.sequence, profile)?.chord : undefined
}
/**
* The key the store dispatches: Shift+F1…F12 (no Ctrl, no Alt) become `shift+f1`…`shift+f12` (the
* Shift bank); everything else is left as it is (Ctrl+F5 still acts as F5: no Ctrl bank yet).
*/
export function bankKey(key: Key, profile: LegacyProfile = 'vt220'): Key {
const c = fkeyChord(key, profile)
if (!c || c.release || !c.shift || c.ctrl || c.alt) return key
return { name: `shift+f${c.n}`, sequence: key.sequence ?? '', shift: true }
}
/** The Shift bank number of a dispatched key (`shift+f2` → 2), or undefined. */
export function shiftNumber(key: Key): number | undefined {
const m = /^shift\+f(\d{1,2})$/.exec(key.name)
return m ? Number(m[1]) : undefined
}
-140
View File
@@ -1,140 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The Shift bank: Shift+F1…F10, a second row of ten function keys (like Norton Commander's Shift
* bar). The assignments are this one table; each key names an action (actions.ts), so changing a key
* is changing a row here. Like the primary bar, a key that does not apply on the screen is dimmed
* and pressing it says why.
*/
import { readConfig, writeConfig } from '@qrun-io/qqq-core'
import type { AppStore } from '../store'
import { ACTIONS, runAction, type ActionName, type NamedAction } from './actions'
export interface ShiftKey {
n: number
/** The bar label (and the help's). */
label: string
/** At most 5 characters, for narrow terminals. */
short: string
action: ActionName
/** What it does, for the `?` help. */
help: string
}
export const SHIFT_BANK: ShiftKey[] = [
{ n: 1, label: 'Keys', short: 'Keys', action: 'help.keys', help: 'Keys: this reference (as ?)' },
{ n: 2, label: 'Servers', short: 'Servr', action: 'servers', help: 'Servers dialog (as Ctrl+O)' },
{ n: 3, label: 'SQL', short: 'SQL', action: 'sql.open', help: 'SQL Runner' },
{ n: 4, label: 'Bulk', short: 'Bulk', action: 'grid.bulk', help: 'Bulk actions on the grid (as b)' },
{ n: 5, label: 'Metadata', short: 'Meta', action: 'meta.refresh', help: 'Refresh the metadata' },
{ n: 6, label: 'Views', short: 'Views', action: 'grid.views', help: 'Saved views of the grid' },
{ n: 7, label: 'Copy', short: 'Copy', action: 'record.copy', help: 'Copy into a new record (as c)' },
{ n: 8, label: 'Export', short: 'Exprt', action: 'export', help: 'Export the grid (as x)' },
{ n: 9, label: 'History', short: 'Hist', action: 'history', help: 'History screen' },
{ n: 10, label: 'Assistant', short: 'Asst', action: 'assistant.toggle', help: 'Assistant panel (as F12, Ctrl+G)' },
]
/** The bank key for a number, if bound. */
export function shiftKey(n: number): ShiftKey | undefined {
return SHIFT_BANK.find((k) => k.n === n)
}
/** One slot of the Shift row as drawn: a label, or null where the key does nothing here (dimmed). */
export interface ShiftSlot {
n: number
label: string | null
global: string
short: string
why?: string
}
/**
* The Shift row for the current screen, `slots` keys wide (the primary row's count, so both rows line
* up; unbound keys are blank).
*/
export function shiftSlots(store: AppStore, slots = 10): ShiftSlot[] {
const out: ShiftSlot[] = []
for (let n = 1; n <= Math.max(10, slots); n++) {
const k = shiftKey(n)
if (!k) {
out.push({ n, label: null, global: '', short: '' })
continue
}
const action: NamedAction = ACTIONS[k.action]
let why: string | undefined
try {
why = action.unavailable(store)
} catch {
why = 'not available'
}
out.push({ n, label: why ? null : k.label, global: k.label, short: k.short, why })
}
return out
}
/** Shift+Fn pressed: runs its action (or says why not) and, in one-row mode, shows the Shift row for a moment. */
export function runShiftKey(store: AppStore, n: number): void {
store.showShiftRow(SHIFT_ROW_MS)
const k = shiftKey(n)
if (!k) return store.flash(`Shift+F${n} does nothing`)
runAction(store, k.action, `Shift+F${n} ${k.label}`)
}
/** The keys for the `?` help's Shift bank group. */
export function shiftBankHelp(store: AppStore): Array<[string, string]> {
return shiftSlots(store).filter((s) => shiftKey(s.n)).map((s) => {
const k = shiftKey(s.n)!
return [`Shift+F${s.n}`, `${k.help}${s.why ? ' · not here' : ''}`]
})
}
// ---- the F-key bar setting ----
export type FKeyBarMode = 'one' | 'two' | 'auto'
export const FKEY_BAR_MODES: FKeyBarMode[] = ['auto', 'one', 'two']
/** From this height `auto` draws both rows. */
export const TWO_ROW_MIN_HEIGHT = 30
/** How long one-row mode shows the Shift row after a Shift+F press. */
export const SHIFT_ROW_MS = 2000
export function readFKeyBar(env: NodeJS.ProcessEnv = process.env): FKeyBarMode {
try {
const v = readConfig(env).ui?.fkeyBar
return v === 'one' || v === 'two' ? v : 'auto'
} catch {
return 'auto'
}
}
export function writeFKeyBar(mode: FKeyBarMode, env: NodeJS.ProcessEnv = process.env): void {
const config = readConfig(env)
config.ui = { ...config.ui, fkeyBar: mode }
writeConfig(config, env)
}
/** Settings › F-key bar and `:fkeys one|two|auto`: applies now and saves it. */
export function setFKeyBar(store: AppStore, mode: FKeyBarMode): void {
store.settings.fkeyBar = mode
store.bump()
try {
writeFKeyBar(mode)
} catch (e) {
return store.error(e)
}
store.flash(`F-key bar: ${describeFKeyBar(mode)} (saved)`)
}
export function describeFKeyBar(mode: FKeyBarMode): string {
return mode === 'one' ? 'one row (Shift row for 2 s after Shift+F)' : mode === 'two' ? 'two rows' : `auto (two rows from ${TWO_ROW_MIN_HEIGHT} lines)`
}
/** How many rows the bar takes at this terminal height. */
export function fkeyBarRows(mode: FKeyBarMode | undefined, height: number): 1 | 2 {
if (mode === 'two') return 2
if (mode === 'one') return 1
return height >= TWO_ROW_MIN_HEIGHT ? 2 : 1
}
-70
View File
@@ -1,70 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The Shift row of the F-key bar: under the primary row when the bar has two rows, or in its place
* for a moment after a Shift+F press in one-row mode. It uses the primary row's arithmetic (mouse/hit.ts
* fkeyAt), so the keys line up and a click hits the key drawn there; the first number carries the ⇧.
*/
import type { AppStore } from '../store'
import { fkeyAt } from '../mouse/hit'
import { key } from '../mouse/keys'
import { Zone } from '../mouse/zone'
import { glyphs, theme } from '../theme'
import { pad } from '../util/text'
import { fkeyBarRows, shiftSlots, type ShiftSlot } from './shift-bank'
/** The cells of each slot, as the primary row lays them out: max(7, width / count), the last takes the rest. */
export function slotWidths(width: number, count: number): number[] {
const cell = Math.max(7, Math.floor(width / count))
const out: number[] = []
let used = 0
for (let i = 0; i < count; i++) {
const w = i === count - 1 ? Math.max(0, width - used) : cell
out.push(w)
used += w
}
return out
}
/** The text of each slot (number, then the label padded to the slot), clipped to the width. */
export function shiftRowCells(slots: ShiftSlot[], width: number, ascii = false): Array<{ n: number; num: string; label: string; active: boolean }> {
const widths = slotWidths(width, slots.length)
const narrow = width < 100
return slots.map((s, i) => {
const w = widths[i]!
const num = i === 0 ? `${ascii ? 'S' : '⇧'}${s.n}` : String(s.n).padStart(2)
const base = s.label ?? s.global
const label = narrow || base.length > w - 2 ? s.short : base
return { n: s.n, num: num.slice(0, w), label: pad(label, Math.max(0, w - 2)).slice(0, Math.max(0, w - 2)), active: s.label !== null }
})
}
export function ShiftRow({ store, width, slots }: { store: AppStore; width: number; slots: number }) {
const t = theme()
const shift = shiftSlots(store, slots)
const cells = shiftRowCells(shift, width, glyphs().border !== undefined)
return (
<Zone global onClick={(e) => {
const n = fkeyAt(e.x, width, shift.map((s) => s.n))
if (n !== undefined) store.handleKey(key(`shift+f${n}`, { shift: true }))
}}>
<text flexShrink={0} wrapMode="none">
{cells.map((c) => (
<span key={c.n}>
<span fg={t.keyNum}>{c.num}</span>
<span fg={c.active ? t.keyLabelFg : t.dim} bg={c.active ? t.keyLabelBg : undefined}>{c.label}</span>
</span>
))}
</text>
</Zone>
)
}
/** How many rows the bar takes now (the body is that much shorter). */
export function barRows(store: AppStore, height: number): 1 | 2 {
return fkeyBarRows(store.settings.fkeyBar, height)
}
-44
View File
@@ -1,44 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Feeds the renderer canonical function-key sequences: every `data` chunk of the input stream goes
* through rewriteFKeyInput before any listener (OpenTUI's stdin parser) sees it. OpenTUI's own input
* handlers run after its parser, which already took Shift+F3 for a cursor report, so the bytes are
* rewritten on the stream itself.
*/
import { rewriteFKeyInput, type LegacyProfile } from './fkeys'
/** Cursor reports are expected for this long after start and after a resume (the capability probe). */
export const CPR_QUIET_MS = 1500
export interface FKeyRewrite {
/** Call after renderer.resume(): the probe runs again, so CSI 1;m R is a cursor report for a while. */
quiet(): void
/** Restores the stream. */
dispose(): void
}
export function installFKeyRewrite(stream: NodeJS.EventEmitter, profile: LegacyProfile, clock: () => number = Date.now): FKeyRewrite {
const original = stream.emit
let quietUntil = clock() + CPR_QUIET_MS
const patched = function (this: NodeJS.EventEmitter, event: string | symbol, ...args: unknown[]): boolean {
if (event === 'data' && args.length) {
const chunk = args[0]
const text = typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? chunk.toString('latin1') : undefined
if (text !== undefined && text.includes('\x1b')) {
const out = rewriteFKeyInput(text, profile, { cpr: clock() >= quietUntil })
if (out !== text) args[0] = typeof chunk === 'string' ? out : Buffer.from(out, 'latin1')
}
}
return original.call(this, event, ...args)
}
stream.emit = patched as typeof stream.emit
return {
quiet: () => { quietUntil = clock() + CPR_QUIET_MS },
dispose: () => { if (stream.emit === patched) stream.emit = original },
}
}
+1 -16
View File
@@ -19,8 +19,6 @@ import { App } from './app'
import { boot, parseArgs } from './boot'
import { runDoctor } from './doctor'
import { isShutdownNoise, quietShutdownNoise } from './exit'
import { legacyProfile } from './keys/fkeys'
import { installFKeyRewrite } from './keys/stdin-rewrite'
const args = parseArgs(process.argv.slice(2))
if (args.check === 'full') {
@@ -51,10 +49,6 @@ const renderer = await createCliRenderer({
targetFps: 30,
})
// Shift+F3 (CSI 1;2R, which OpenTUI takes for a cursor report) and the legacy and rxvt Shift/Ctrl+F
// forms reach OpenTUI as the canonical CSI n;m ~ (keys/fkeys.ts)
const fkeyRewrite = installFKeyRewrite(process.stdin, legacyProfile())
let destroyed = false
function restore(): void {
if (destroyed) return
@@ -77,14 +71,8 @@ renderer.on('destroy', () => {
setTimeout(() => process.exit(0), 10)
})
// a line for the normal screen once the alternate screen is gone (the picker closed without a choice)
let exitHint: string | undefined
process.on('exit', () => {
if (exitHint) process.stderr.write(`${exitHint}\n`)
})
// QQQ_UI_NO_QUIT=1 (the soak test): quitting from a menu is ignored, so only a crash ends the program
const store = boot({ args, quit: process.env.QQQ_UI_NO_QUIT === '1' ? () => store.flash('quit is off (QQQ_UI_NO_QUIT)') : restore, build: process.env.QQQ_UI_BUILD ?? 'dev', timers: true, hint: (text) => { exitHint = text } })
const store = boot({ args, quit: process.env.QQQ_UI_NO_QUIT === '1' ? () => store.flash('quit is off (QQQ_UI_NO_QUIT)') : restore, build: process.env.QQQ_UI_BUILD ?? 'dev', timers: true })
process.on('unhandledRejection', (reason) => {
if (destroyed && isShutdownNoise([reason])) return
store.error(reason)
@@ -105,10 +93,7 @@ store.editExternal = async (text: string) => {
spawnSync(editor, [file], { stdio: 'inherit', shell: process.platform === 'win32' })
return readFileSync(file, 'utf8')
} finally {
// the resume probes the terminal again: its cursor reports are not Shift+F3
fkeyRewrite.quiet()
await renderer.resume()
fkeyRewrite.quiet()
rmSync(dir, { recursive: true, force: true })
}
}
-4
View File
@@ -169,9 +169,6 @@ export class HelpOverlay implements Overlay {
readonly id = overlayId('help')
top = 0
/** Key groups shown after the screen's help (the Shift bank), before the mouse. */
after: KeyGroup[] = []
constructor(private store: AppStore, readonly groups: KeyGroup[], readonly topicHelp: string[], readonly pointer = HELP_POINTER) {}
onKey(key: Key): void {
@@ -193,7 +190,6 @@ export class HelpOverlay implements Overlay {
body.push({ text: ' About this screen', fg: t.acc })
for (const p of this.topicHelp) for (const l of wrap(p, inner - 4)) body.push({ text: ` ${l}` })
}
if (this.after.length) this.keyLines(this.after, body, half)
if (this.store.settings.mouse) this.keyLines([MOUSE_KEYS], body, half)
return body
}
-5
View File
@@ -72,11 +72,6 @@ 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,
+7 -26
View File
@@ -33,21 +33,6 @@ function hint(tokens: KeyToken[], width: number): string {
/** Command keys while the filter is empty; any other character starts the filter (/ starts it too). */
const COMMAND_KEYS = new Set(['n', 'e', 'd', '*', 'r', '/'])
/** The dialog as the start-up profile picker (over the splash): its title, Enter and Esc, the preselected row. */
export interface ServersOverlayOptions {
title?: string
/** The Enter hint (`Enter connects`). */
enterLabel?: string
/** The Esc hint (`Esc quits`). */
escLabel?: string
/** Esc with an empty filter: instead of closing. */
onEscape?: () => void
/** The profile the cursor starts on (else the one in use). */
select?: string
/** The frame's first row for a screen height (below the splash's header), and only as tall as its rows need. */
top?: (screenHeight: number) => number
}
export class ServersOverlay implements Overlay {
readonly id = overlayId('servers')
readonly filter = new LineEditor('')
@@ -60,9 +45,9 @@ export class ServersOverlay implements Overlay {
/** `/` was pressed: every character goes to the filter. */
private filtering = false
constructor(private store: AppStore, private switchTo: (row: ServerRow) => void, model?: ServersModel, readonly options: ServersOverlayOptions = {}) {
constructor(private store: AppStore, private switchTo: (row: ServerRow) => void, model?: ServersModel) {
this.model = model ?? new ServersModel({ current: store.target?.name, secrets: store.signInDeps?.secrets, probe: (server) => probeServer(server, { fetch: store.signInDeps?.fetch }), onChange: () => store.bump() })
const at = options.select !== undefined && this.model.rows.some((r) => r.name === options.select) ? this.model.rows.findIndex((r) => r.name === options.select) : this.model.rows.findIndex((r) => r.isCurrent)
const at = this.model.rows.findIndex((r) => r.isCurrent)
this.index = Math.max(0, at)
void this.model.probeAll()
void this.model.readSignIns()
@@ -96,8 +81,7 @@ export class ServersOverlay implements Overlay {
this.filter.set('')
this.filtering = false
this.index = 0
} else if (this.options.onEscape) this.options.onEscape()
else this.store.closeOverlay(this)
} else this.store.closeOverlay(this)
} else if (key.name === 'down') this.index = Math.min(Math.max(0, list.length - 1), this.index + 1)
else if (key.name === 'up') this.index = Math.max(0, this.index - 1)
else if (key.name === 'pagedown') this.index = Math.min(Math.max(0, list.length - 1), this.index + 5)
@@ -168,9 +152,7 @@ export class ServersOverlay implements Overlay {
const t = theme()
const g = glyphs()
const width = Math.max(30, Math.min(sw - 2, 120))
const top = this.options.top ? this.options.top(sh) : 1
// under the splash header: as tall as the profiles need, else up to the screen's foot
const height = this.options.top ? Math.max(10, Math.min(sh - top - 1, 30, this.model.rows.length * 2 + 6)) : Math.max(10, Math.min(sh - 2, 30))
const height = Math.max(10, Math.min(sh - 2, 30))
const inner = width - 2
const list = this.list()
if (this.index >= list.length) this.index = Math.max(0, list.length - 1)
@@ -204,13 +186,12 @@ export class ServersOverlay implements Overlay {
const more = list.length > visibleRows ? ` ${this.scroll > 0 ? g.up : ' '}${this.scroll + visibleRows < list.length ? g.down : ' '}` : ''
const header = ` ${pad('Name', nameW)}${pad('Short', shortW)}${pad('Server', serverW)}${pad('Status', statusW)}${pad('Last used', lastW)}`
const commands = !this.filter.value && !this.filtering
const enter = this.options.enterLabel ?? 'Enter switches'
const keys = commands
? [{ text: enter }, { text: 'n add' }, { text: 'e edit' }, { text: 'd remove', drop: 1 }, { text: '* default', drop: 1 }, { text: 'r probe', drop: 3 }, { text: 'type to filter', drop: 2 }, { text: this.options.escLabel ?? 'Esc closes' }]
: [{ text: `${list.length} of ${this.model.rows.length}` }, { text: enter }, { text: 'F7 add', drop: 2 }, { text: 'F4 edit', drop: 2 }, { text: 'F8 remove', drop: 3 }, { text: 'Esc clears' }]
? [{ text: 'Enter switches' }, { text: 'n add' }, { text: 'e edit' }, { text: 'd remove', drop: 1 }, { text: '* default', drop: 1 }, { text: 'r probe', drop: 3 }, { text: 'type to filter', drop: 2 }, { text: 'Esc closes' }]
: [{ text: `${list.length} of ${this.model.rows.length}` }, { text: 'Enter switches' }, { text: 'F7 add', drop: 2 }, { text: 'F4 edit', drop: 2 }, { text: 'F8 remove', drop: 3 }, { text: 'Esc clears' }]
const legend = `* default ${g.mark} in use`
return (
<Frame title={this.options.title ?? 'Servers (Ctrl+O)'} width={width} height={height} screenWidth={sw} screenHeight={sh} top={top}>
<Frame title="Servers (Ctrl+O)" width={width} height={height} screenWidth={sw} screenHeight={sh} top={1}>
<box paddingLeft={1}><EditorLine editor={this.filter} width={inner - 2} focused={!commands || undefined} placeholder="type to filter by name, short name, description or server · / then any letter" /></box>
<Line text={pad(header, inner - more.length) + more} width={inner} fg={t.acc} />
{lines}
+20 -238
View File
@@ -17,16 +17,12 @@
*/
import { writeFileSync } from 'node:fs'
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 { 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. */
@@ -39,7 +35,6 @@ 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),
})
}
@@ -71,26 +66,17 @@ 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: "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.
* 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.
*/
export function pickPrinter(store: AppStore, title: string, send: (printing: Printing, printer: Printer) => void, hint?: string, renders?: RenderRequest[]): void {
export function pickPrinter(store: AppStore, title: string, send: (printing: Printing, printer: Printer) => void, hint?: string): 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 && !canSave) return store.message('No printers: no print agent has exposed a printer to this server yet.', 'info', 'Print')
if (!printers.length) 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)
@@ -106,44 +92,7 @@ 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)
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)
})
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))
})
}
@@ -165,7 +114,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, chrome: chromeOf(store) }).then((jobIds) => ({ jobIds }))), hint, ids.map((id) => ({ kind: 'record' as const, table, id })))
pickPrinter(store, `Print ${what} on…`, (printing, printer) => queued(store, printing, printer, what, printing.print({ table, ids, printer: printer.id }).then((jobIds) => ({ jobIds }))), hint)
}
// ---------------------------------------------------------------------------------------------
@@ -212,8 +161,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, captured?: string): string {
const frame = captured ?? store.captureScreen?.() ?? ''
export function captureScreenText(store: AppStore): string {
const frame = 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+$/, ''))
@@ -233,198 +182,31 @@ 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 askWhat(store, target.what, hint)
return printText(store, target.what, hint)
}
if (target.kind === 'text') return askWhat(store, target.title, hint)
if (target.kind === 'text') return printText(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`
const list = {
pickPrinter(store, `Print ${what} on…`, (p, printer) => queued(store, p, printer, what, p.printTableList({
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, 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 }])
filter: grid.filter, scope, title: grid.label, filterDescription: grid.filterText || undefined, printer: printer.id, primaryKeyField: grid.primaryKeyField,
})), hint)
}
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, 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 }])
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)
}
// ---------------------------------------------------------------------------------------------
// 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
+16 -26
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 { QUERY_COLOR, queryFrame, queryPoseAt, querySize, type QueryPose, type QuerySize } from './query-art'
import { queryFrame, queryPoseAt, querySize, type QueryPose } 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 [QUERY_COLOR, t.acc2, t.ok, t.warn, t.markFg, t.err]
return [t.acc, 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,18 +262,16 @@ export interface Box {
interface BounceSprite {
w: number
h: number
size: QuerySize
size: 'large' | 'small'
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 }
}
@@ -303,7 +301,7 @@ export interface BounceLayout {
/** The sprite is drawn (false in the panel half of the alternation). */
showSprite: boolean
panel?: Box
size: QuerySize
size: 'large' | 'small'
showLogo: boolean
dx: 1 | -1
dy: 1 | -1
@@ -356,7 +354,7 @@ export function bounceLayout(input: SaverInput): BounceLayout {
return { ...base, sprite: box, showSprite: true }
}
function drawQuery(c: Canvas, x: number, y: number, size: QuerySize, pose: QueryPose, input: SaverInput, color: string): void {
function drawQuery(c: Canvas, x: number, y: number, size: 'large' | 'small', pose: QueryPose, input: SaverInput, color: string): void {
c.block(x, y, queryFrame(size, pose, input.ascii), { fg: color, bold: true })
}
@@ -422,14 +420,13 @@ 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 gsize = guestSize(input.width, bodyH)
const q = querySize(gsize)
const q = querySize('small')
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), gsize, pose, input, QUERY_COLOR)
drawQuery(c, qx, Math.max(1, qy + pose.dy), 'small', pose, input, t.acc2)
// 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)
@@ -440,11 +437,6 @@ 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
@@ -479,8 +471,7 @@ 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 qsize = guestSize(input.width, bodyH)
const q = querySize(qsize)
const q = querySize('small')
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
@@ -497,7 +488,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 - (queryBeside ? q.width + 3 : 0) - (columns - 1) * 3) / columns)))
const colW = Math.max(0, Math.min(columns === 2 ? 43 : 56, Math.floor((input.width - 6 - q.width - 3 - (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))
@@ -546,12 +537,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 : qsize === 'medium' ? 5 : 3)
const frame = queryFrame(qsize, pose, input.ascii).slice(0, shown)
const shown = Math.min(blockH, pose.action ? q.height : 3)
const frame = queryFrame('small', 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: QUERY_COLOR, bold: true })
c.block(qx, qy, frame, { fg: t.acc2, bold: true })
return { x: gx, y: gy, w: groupW, h: groupH }
}
@@ -571,8 +562,7 @@ 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 qsize = guestSize(input.width, bodyH)
const q = querySize(qsize)
const q = querySize('small')
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)
@@ -624,7 +614,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, qsize, queryPoseAt(input.now), input, QUERY_COLOR)
drawQuery(c, heroX, y, 'small', queryPoseAt(input.now), input, t.acc2)
} else {
const pose: QueryPose = { ...pose0, ...(asleep ? { blink: true, wag: false } : {}) }
const numberAt = (x: number, ny: number) => {
@@ -632,12 +622,12 @@ function paintBig(c: Canvas, input: SaverInput): Box {
else c.put(x, ny, big, { fg: color, bold: true })
}
if (beside) {
drawQuery(c, heroX, y, qsize, pose, input, QUERY_COLOR)
drawQuery(c, heroX, y, 'small', pose, input, t.acc2)
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, qsize, pose, input, QUERY_COLOR)
drawQuery(c, heroX + Math.floor((heroW - q.width) / 2), y + numH + 1, 'small', pose, input, t.acc2)
if (asleep) c.put(heroX + Math.floor((heroW + q.width) / 2) - 1, y + numH, input.now % 2000 < 1000 ? 'z' : 'zZ', { fg: t.dim })
}
}
+54 -65
View File
@@ -7,73 +7,57 @@
* 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, 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.
* 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).
*/
/** The drawing's colour (the logo's own fill). */
export const QUERY_COLOR = '#818cf8'
export type QuerySize = 'small' | 'large'
export type QuerySize = 'small' | 'medium' | 'large'
/** large Query, 42×22 cells, facing right (generated by scripts/gen-query-art.mjs). */
/** Large Query, 33×19 cells, facing right. */
const LARGE = [
" ",
" ⣀⣀⣄⣀⣀ ",
" ⢀⡴⠋⠁ ⠈⠙⢢⡀ ",
" ⣰⠋ ⢠⣦ ⣧ ",
" ⢰⠇ ⢸⠁ ⠉ ⠈⠓⠒⠲⡆ ",
" ⡟ ⢸⡄ ⢠⠇ ",
" ⡇ ⢷ ⢀⣀⣀⣤⡤⠴⠋ ",
" ⢻⡀ ⣼ ⢸ ",
" ⣹⠓⠒⠚⠁ ⢸ ",
" ⢀⡴⠃ ⠸⡆ ",
" ⢀⡴⠋ ⢧ ",
" ⢀⡴⠉ ⢸ ",
" ⣰⠋ ⢸⠃ ",
" ⡼⠁ ⢀⣀⣀⣀⡀ ⡼ ",
" ⠰⣶⣄ ⢰⠃ ⠘⠉⠉ ⠈⠉⠓⣆ ⡼⠁ ",
" ⢸⢹ ⡟ ⢸⠆ ⣷ ",
" ⣼⣸ ⢠⡇ ⢀⡞ ⢸ ",
" ⢠⡇⡏ ⢸⡇ ⢀⡴⠋ ⢰⡀ ⠸⡇ ",
" ⢸ ⣇⣀⡼⠁ ⠠⣴⣯⣤⣤⣤⣄ ⣧ ⢧⣤⡀ ",
" ⠘⢧⡀⠁⣀⢶⡀ ⠈⢳⠸⣄ ⠙⡆ ",
" ⠉⠉⠁ ⠉⠉⠉⠉⠙⠛⠛⠛⠛⠛⠛⠛⠛⠋⠉ ⠈⠉⠉⠉⠉⠁ ",
" ",
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⡴⠞⠛⠛⠛⠛⠳⢦⣄⠀⠀⠀⠀⠀",
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⡾⠋⠀⠀⠀⠀⠀⠀⢀⡀⠹⣇⠀⠀⠀⠀",
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⡟⠀⠀⢠⡆⠀⠀⠀⠀⠸⠗⠀⠻⣤⣤⣤⣀",
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠁⠀⠀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿",
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⠀⠈⢿⡀⠀⠀⠀⠀⠀⠀⠀⢀⣀⡼⠋",
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣧⠀⠀⠀⠀⢸⡇⠀⠀⠀⠘⣿⠛⠛⠋⠉⠀⠀",
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⢷⣤⣀⣤⡾⠁⠀⠀⠀⠀⣿⠀⠀⠀⠀⠀⠀",
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⡟⠁⠉⠀⠀⠀⠀⠀⠀⠀⣿⡀⠀⠀⠀⠀⠀",
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⡾⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⠀⠀⠀",
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⠞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠀⠀⠀⠀⠀",
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⡆⠀⠀⠀⠀",
"⠀⠀⠀⠀⠀⠀⠀⢀⡾⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠁⠀⠀⠀⠀",
"⠀⣀⣀⠀⠀⠀⢀⡾⠁⠀⠀⠀⠀⣤⡴⠶⠶⠶⠶⣤⡀⠀⠀⠀⠀⠀⣰⠏⠀⠀⠀⠀⠀",
"⠀⠙⣿⣷⠀⠀⣼⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⣆⠀⠀⠀⢰⡏⠀⠀⠀⠀⠀⠀",
"⠀⠀⣿⣿⠃⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⡏⠀⠀⠀⠈⣷⠀⠀⠀⠀⠀⠀",
"⠀⢠⡿⣿⠀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⠟⠀⠀⡀⠀⠀⣿⡀⠀⠀⠀⠀⠀",
"⠀⣼⢻⡇⠀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⠟⠁⠀⠀⠀⢿⡀⠀⢸⡇⠀⠀⠀⠀⠀",
"⠀⣿⡈⠷⠶⠟⢁⠀⠀⠀⠀⠀⠀⠀⠀⠘⠛⠛⠛⠛⠛⠳⣦⡸⣧⠀⠈⠛⠳⣦⡀⠀⠀",
"⠀⠈⠳⣦⣤⡴⠛⢧⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣼⡇⠹⣦⣤⣤⣤⣬⡗⠀⠀",
]
/** 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). */
/** Small Query, 12×6 cells, facing right (hand-drawn on 24×24 dots). */
const SMALL = [
" ⣀⣤⣀⡀ ",
" ⡞⢡ ⠰⠹⣀⡀ ",
" ⢸ ⢸⡀⢀⣀⠤⠃ ",
" ⢈⡗⠚ ⠘⡇ ",
" ⢀⡴⠋ ⣇ ",
" ⢀⡀⢀⠎ ⡠⠤⣀ ⡏ ",
" ⡷⡼ ⢨⠃⠸⡄ ",
" ⢸⢧⢇ ⠴⠥⢜⡄⠧⡀ ",
" ⠉⠉⠓⠒⠒⠒⠒⠊⠙⠒⠃ ",
'⠀⠀⠀⠀⢀⠤⠒⠒⠢⢄⠀⠀',
'⠀⠀⠀⠀⡇⢸⠀⠁⣀⣀⡩⠕',
'⠀⠀⠀⠀⡨⠃⠀⠀⢇⠀⠀⠀',
'⠀⡀⢠⠊⢀⠤⢄⠀⢸⠀⠀⠀',
'⠀⢇⡇⠀⠀⠀⡠⠃⢸⠀⠀⠀',
'⠀⠀⠣⠤⠤⠼⠶⠶⠮⠵⠀⠀',
]
/** Small Query for the ASCII glyph set, 12×6 cells, facing right. */
const SMALL_ASCII = [
' .--. ',
' /( o`-.',
" \\_) .-'",
' _ / | ',
' ( | _/ | ',
" `-'--'-' ",
]
interface Parts {
@@ -83,9 +67,8 @@ interface Parts {
}
const PARTS: Record<QuerySize, Parts> = {
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] },
large: { eye: [51, 7, 53, 10], tail: [2, 51, 8, 68] },
small: { eye: [14, 4, 14, 4], tail: [2, 15, 3, 18] },
}
// braille dot bits by (column, row) inside a cell
@@ -175,11 +158,11 @@ export function mirrorAscii(row: string): string {
return [...row].reverse().map((c) => MIRROR[c] ?? c).join('')
}
const BASE: Record<QuerySize, Bitmap> = { large: decodeBraille(LARGE), medium: decodeBraille(MEDIUM), small: decodeBraille(SMALL) }
const BASE: Record<QuerySize, Bitmap> = { large: decodeBraille(LARGE), 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: 42, height: 22 } : size === 'medium' ? { width: 25, height: 13 } : { width: 17, height: 9 }
return size === 'large' ? { width: 33, height: 19 } : { width: 12, height: 6 }
}
export interface QueryPose {
@@ -195,6 +178,12 @@ 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
+17 -73
View File
@@ -8,7 +8,7 @@
*
* | tile | needs | reads |
* |---------|----------------------|----------------------------------------------------------|
* | 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 |
* | people | collaboration | 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,8 +34,6 @@ 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
@@ -53,75 +51,22 @@ interface Viewer {
mode?: string
}
/** 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.
*/
/** Everyone seen in presence lately across the sampled tables (this terminal is not: it left). */
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 or the session store',
present: () => ctx.has('collaboration') || Boolean(ctx.sessionTable?.()),
id: 'people', label: 'Online', needs: 'collaboration',
present: () => ctx.has('collaboration'),
async load(): Promise<TileValue> {
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 all = ctx.presenceTables().filter((t) => !skip.has(t))
if (!all.length) 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 {
@@ -129,11 +74,10 @@ 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)
// 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 })
seen.set(v.userId, { name: you ? 'you elsewhere' : v.displayName || v.userId, table, mode: v.mode, at: t })
}
} catch (error) {
const status = (error as { status?: number }).status
@@ -141,16 +85,16 @@ export function presenceSource(ctx: SaverContext): TileSource {
else failures++
}
}))
if (batch.length && failures === batch.length && !signedIn) throw new Error('presence did not answer')
if (failures === batch.length) 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(Math.max(1, all.length) / PRESENCE_TABLES_PER_REFRESH) * ctx.intervalMs() + 45_000
const ttl = Math.ceil(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 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 }
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 }
},
}
}
-7
View File
@@ -22,10 +22,6 @@ export interface SignInDeps {
/** Runs after the home screen is shown (the route given on the command line). */
afterConnect?: (store: AppStore) => void
timers?: boolean
/** Boot steps as they happen (the splash's status line): connecting, loading metadata. */
progress?: (text: string) => void
/** Awaited once connected, before Home replaces the screens (the splash's minimum display time). */
beforeHome?: () => Promise<void>
}
/** Connects the store to a profile: the stored sign-in, a client, metadata; then the home screen. */
@@ -61,9 +57,7 @@ export async function connect(store: AppStore, target: Target, deps: SignInDeps)
store.status.auth = 'QQQ_TOKEN'
}
try {
deps.progress?.('loading metadata…')
await store.connect(client, { timers: deps.timers })
deps.progress?.(`loaded metadata (${Object.keys(store.meta?.tables ?? {}).length} tables)`)
} catch (e) {
if (e instanceof UiApiError && (e.status === 401 || e.status === 403)) throw new NeedsSignIn('your sign-in expired; sign in again')
throw e
@@ -75,7 +69,6 @@ export async function connect(store: AppStore, target: Target, deps: SignInDeps)
// config.json unreadable: not critical here
}
history.record(store, { kind: 'auth', summary: `Signed in to ${target.server}${store.status.user ? ` as ${store.status.user}` : ''} (${store.status.auth})` })
await deps.beforeHome?.()
while (store.routes.length) store.routes.pop()?.dispose?.()
store.push(new HomeScreen(store))
deps.afterConnect?.(store)
-413
View File
@@ -1,413 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The start-up splash of `qqq ui`: Query (the qrun.io mascot) blinking and wagging while the TUI
* boots, the `qqq` wordmark, the version, and one status line that follows the boot steps (reading
* profiles, connecting, signing in, loading metadata). It drives the boot (boot-plan.ts decides the
* first step): with several profiles and none named the Servers picker opens over it; with none the
* add form; a failure stays on the splash with Enter retrying and c choosing another profile.
* Any key stops the animation (not the boot); --no-splash and `ui.splash: off` skip the splash.
*/
import { useEffect, useReducer } from 'react'
import { UI_API_VERSION } from '@qrun-io/qqq-ui-api-client'
import { CLI_VERSION } from '@qrun-io/qqq-cli/program'
import { probeServer, readConfig } from '@qrun-io/qqq-core'
import { PICK_HINT, splashWait, type BootPlan } from '../boot-plan'
import type { Key } from '../keys'
import { charOf } from '../keys'
import { ProfileFormOverlay, ServersOverlay } from '../overlays/servers'
import { blockText, blockWidth } from '../saver/font'
import { queryFrame, querySize } from '../saver/query-art'
import { ServersModel } from '../servers'
import type { AppStore, Target } from '../store'
import { isAscii, theme, type ThemeTokens } from '../theme'
import type { FKeyLabels, Screen } from '../types'
import { cellWidth, errorText, trunc } from '../util/text'
import { NeedsSignIn, SignInScreen, connect, type SignInDeps } from './signin'
export type SplashPhase = 'starting' | 'picking' | 'adding' | 'connecting' | 'failed' | 'done'
/** The colour a piece of the splash is drawn in (a theme token). */
export type SplashTone = 'art' | 'mark' | 'fg' | 'dim' | 'status' | 'err' | 'key'
export interface SplashSeg {
text: string
tone: SplashTone
}
export const SPLASH_TAGLINE = 'Terminal client for QQQ'
/** `qqq 1.0.0 · UI API client 1.8.0` */
export function versionLine(): string {
return `qqq ${CLI_VERSION} · UI API client ${UI_API_VERSION}`
}
/** Query's pose at a moment: a blink every 3 s; while the boot works, the tail wags (his wave). */
export function splashPose(now: number, busy: boolean): { blink: boolean; wag: boolean } {
return { blink: now % 3000 < 200, wag: busy && Math.floor(now / 300) % 2 === 1 }
}
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
const SPINNER_ASCII = ['|', '/', '-', '\\']
export interface SplashLayoutInput {
ascii: boolean
pose: { blink: boolean; wag: boolean }
status: SplashSeg
/** Lines under the status (a sign-in address, a device code). */
extra: SplashSeg[]
/** The key hint line, or ''. */
keys: string
/** `top`: the header only (the picker is drawn below it), from `headerTop`, keeping `reserve` rows under it. */
anchor: 'center' | 'top'
reserve?: number
headerTop?: number
}
/** Rows of the large header (Query, gap, wordmark, gap, tagline, version) and of the compact one. */
const LARGE_HEADER = querySize('large').height + 9
const COMPACT_HEADER = querySize('small').height + 1
export type SplashLine = SplashSeg[]
/** Which composition fits with `rowsBelow` rows under it: the large Query over the wordmark, the small one beside it, or text. */
export function splashTier(width: number, height: number, rowsBelow: number): 'large' | 'compact' | 'text' {
// a free row to spare, so a header and the picker under it never meet the screen's foot
if (width >= 40 && height > LARGE_HEADER + rowsBelow) return 'large'
if (width >= 40 && height > COMPACT_HEADER + rowsBelow) return 'compact'
return 'text'
}
/**
* The splash as lines of coloured pieces, centred in `width` and clipped to `width` × `height`
* (pure: tests read it at 80×24 and in ASCII). Blank lines are empty arrays.
*/
export function splashLayout(width: number, height: number, input: SplashLayoutInput): SplashLine[] {
const below: SplashLine[] = [[{ ...input.status }], ...input.extra.map((s) => [{ ...s }]), ...(input.keys ? [[{ text: input.keys, tone: 'key' as const }]] : [])]
const header = input.anchor === 'top'
const rowsBelow = header ? input.reserve ?? 0 : below.length + 1
const tier = splashTier(width, height, rowsBelow)
const mark = blockText('qqq', input.ascii)
const lines: SplashLine[] = []
if (tier === 'large') {
for (const row of queryFrame('large', input.pose, input.ascii)) lines.push([{ text: row, tone: 'art' }])
lines.push([])
for (const row of mark) lines.push([{ text: row, tone: 'mark' }])
lines.push([])
lines.push([{ text: SPLASH_TAGLINE, tone: 'fg' }])
lines.push([{ text: versionLine(), tone: 'dim' }])
} else if (tier === 'compact') {
// the small Query beside the wordmark, the tagline under the wordmark
const art = queryFrame('small', input.pose, input.ascii)
const right = Math.max(blockWidth('qqq'), SPLASH_TAGLINE.length)
// the wordmark and the tagline (a row apart) centred against Query's height
const from = Math.max(0, Math.floor((art.length - mark.length - 2) / 2))
const taglineRow = Math.min(art.length - 1, from + mark.length + 1)
art.forEach((row, i) => {
const inMark = i >= from && i < from + mark.length
const text = inMark ? mark[i - from]! : i === taglineRow ? SPLASH_TAGLINE : ''
lines.push([{ text: row, tone: 'art' }, { text: ' ', tone: 'fg' }, { text: text.padEnd(right), tone: inMark ? 'mark' : 'fg' }])
})
lines.push([{ text: versionLine(), tone: 'dim' }])
} else {
if (width >= blockWidth('qqq') && height >= 7 + rowsBelow) for (const row of mark) lines.push([{ text: row, tone: 'mark' }])
else lines.push([{ text: 'qqq', tone: 'mark' }])
lines.push([{ text: SPLASH_TAGLINE, tone: 'fg' }])
lines.push([{ text: versionLine(), tone: 'dim' }])
}
if (!header) {
lines.push([])
lines.push(...below)
}
// the ASCII glyph set (no UTF-8 locale): the middle dot and the ellipsis too
if (input.ascii) for (const line of lines) for (const seg of line) seg.text = seg.text.replace(/·/g, '-').replace(/…/g, '...')
const shown = lines.slice(0, Math.max(0, height))
const top = header ? input.headerTop ?? 1 : Math.max(0, Math.floor((height - shown.length) / 2))
const out: SplashLine[] = Array.from({ length: Math.min(top, height) }, () => [])
for (const line of shown) {
if (out.length >= height) break
out.push(centerSegs(line, width))
}
return out
}
/** Centres a line of pieces in `width` cells, cutting the last pieces with an ellipsis when it is wider. */
export function centerSegs(line: SplashLine, width: number): SplashLine {
const total = line.reduce((w, s) => w + cellWidth(s.text), 0)
if (total <= width) {
const left = Math.floor((width - total) / 2)
return left > 0 ? [{ text: ' '.repeat(left), tone: 'fg' }, ...line] : line
}
const out: SplashLine = []
let room = width
for (const s of line) {
if (room <= 0) break
const w = cellWidth(s.text)
if (w <= room) {
out.push(s)
room -= w
} else {
out.push({ text: trunc(s.text, room), tone: s.tone })
room = 0
}
}
return out
}
/** The plain text of a laid-out splash (tests, the overflow sweep). */
export function splashText(lines: SplashLine[]): string {
return lines.map((l) => l.map((s) => s.text).join('')).join('\n')
}
function toneColor(t: ThemeTokens, tone: SplashTone): string {
return tone === 'art' ? t.acc : tone === 'mark' ? t.acc2 : tone === 'dim' || tone === 'key' ? t.dim : tone === 'err' ? t.err : tone === 'status' ? t.acc : t.fg
}
export interface SplashOptions {
plan: BootPlan
/** How to connect and sign in (the store's signInDeps). */
deps: SignInDeps
/** Ends the program and prints a line (the picker closed without a choice). */
exit: (hint: string) => void
/** The splash stays at least this long (0 in tests). */
minMs: number
/** Animate Query (off in tests: one still frame). */
animate: boolean
}
export class SplashScreen implements Screen {
readonly kind = 'splash'
readonly id = 'splash'
phase: SplashPhase = 'starting'
status = 'reading profiles…'
statusKind: 'info' | 'error' = 'info'
/** The profile being connected to (Enter retries it after a failure). */
target?: Target
/** Query moves until a key is pressed. */
animate: boolean
readonly shownAt = Date.now()
/** The sign-in itself (prompts, device code, browser address) is the sign-in screen's, unshown. */
readonly signIn: SignInScreen
private signingIn = false
/** The picker's profiles (its height follows them). */
private pickerModel?: ServersModel
constructor(readonly store: AppStore, readonly options: SplashOptions) {
this.animate = options.animate
this.signIn = new SignInScreen(store, options.deps)
}
title(): string {
return 'qqq'
}
fkeys(): FKeyLabels {
return {}
}
/** Starts the boot plan. */
start(): void {
const plan = this.options.plan
if (plan.kind === 'connect') this.connectTo(plan.target)
else if (plan.kind === 'pick') this.pick(plan.note, false)
else this.add(plan.note)
}
private say(text: string, kind: 'info' | 'error' = 'info'): void {
this.status = text
this.statusKind = kind
this.store.bump()
}
private model(): ServersModel {
const deps = this.options.deps
return new ServersModel({ secrets: deps.secrets, probe: (server) => probeServer(server, { fetch: deps.fetch }), onChange: () => this.store.bump() })
}
/** The Servers picker over the splash; Esc quits (or, after a failure, returns to it). */
pick(note?: string, fromFailure = this.phase === 'failed'): void {
const model = this.model()
if (!model.rows.length) return this.add(note)
this.pickerModel = model
const before = { phase: this.phase, status: this.status, kind: this.statusKind }
this.phase = 'picking'
this.say(note ?? 'choose a server to connect to', note ? 'error' : 'info')
const select = this.target?.name ?? (this.options.plan.kind === 'pick' ? this.options.plan.select : undefined)
const overlay: ServersOverlay = new ServersOverlay(this.store, (row) => this.connectTo({ name: row.name, server: row.server }), model, {
title: 'Connect to a server',
enterLabel: 'Enter connects',
escLabel: fromFailure ? 'Esc goes back' : 'Esc quits',
select,
top: (sh) => this.pickerPlace(sh).pickerTop,
onEscape: () => {
this.store.closeOverlay(overlay)
if (!fromFailure) return this.options.exit(PICK_HINT)
this.phase = before.phase
this.say(before.status, before.kind)
},
})
this.store.open(overlay)
}
/** The picker's rows: two per profile and six of frame, filter, header and hints (the Servers dialog's own sum). */
private pickerHeight(): number {
return Math.max(10, Math.min(30, (this.pickerModel?.rows.length ?? 2) * 2 + 6))
}
/**
* The header and the picker under it, centred together; a terminal too short for both gets the
* picker near the top, over the header.
*/
pickerPlace(screenHeight: number): { headerTop: number; pickerTop: number } {
const pickerHeight = this.pickerHeight()
const header = splashLayout(this.store.width, screenHeight, { ...this.layoutInput('top'), reserve: pickerHeight + 1, headerTop: 0 }).length
const total = header + 1 + pickerHeight
if (total > screenHeight - 1) return { headerTop: 1, pickerTop: 1 }
const top = Math.max(0, Math.floor((screenHeight - total) / 2))
return { headerTop: top, pickerTop: top + header + 1 }
}
/** What to lay out at a height: the centred splash, or the header above the picker. */
inputFor(height: number, now = Date.now()): SplashLayoutInput {
if (!(this.phase === 'picking' && this.store.overlays.length)) return this.layoutInput('center', now)
const place = this.pickerPlace(height)
return { ...this.layoutInput('top', now), reserve: this.pickerHeight() + 1, headerTop: place.headerTop }
}
/** The add form (no profile yet, or `n`); a saved profile is connected to at once. */
add(note?: string): void {
this.phase = 'adding'
this.say(note ?? 'no server profile yet: add one to connect', note ? 'error' : 'info')
this.store.open(new ProfileFormOverlay(this.store, this.model(), undefined, (name) => {
const p = readConfig().profiles[name]
if (p) this.connectTo({ name, server: p.server })
}))
}
/** Connects to a profile (signing in when there is no stored sign-in); Home replaces the splash. */
connectTo(target: Target): void {
if (this.phase === 'connecting') return
this.phase = 'connecting'
this.target = target
const where = target.server.replace(/^https?:\/\//, '')
this.say(`connecting to ${where}…`)
const deps: SignInDeps = {
...this.options.deps,
progress: (text) => this.say(text),
beforeHome: () => new Promise((resolve) => setTimeout(resolve, splashWait(this.shownAt, Date.now(), this.options.minMs))),
}
const run = async () => {
try {
await connect(this.store, target, deps)
} catch (e) {
if (!(e instanceof NeedsSignIn)) throw e
this.signingIn = true
this.signIn.status = ''
this.say(`signing in to ${where}…`)
await this.signIn.signIn(target)
this.signingIn = false
this.say(`connecting to ${where}…`)
await connect(this.store, target, deps)
}
}
run().then(() => {
this.phase = 'done'
}).catch((e) => {
this.phase = 'failed'
this.say(errorText(e), 'error')
}).finally(() => {
this.signingIn = false
this.signIn.device = undefined
this.signIn.browserUrl = undefined
this.store.bump()
})
}
/** Every key is the splash's (no F-keys, menu or palette before a server is connected). */
claimsKey(): boolean {
return true
}
onKey(key: Key): boolean {
this.animate = false
const ch = charOf(key).toLowerCase()
if (key.name === 'escape') {
if (this.phase === 'connecting') this.signIn.onKey(key)
else if (this.phase === 'failed' || this.phase === 'adding' || this.phase === 'starting') this.options.exit(this.phase === 'failed' ? `qqq ui: ${this.status}` : PICK_HINT)
} else if (key.name === 'f10' || (key.ctrl && key.name === 'q')) this.options.exit('')
else if (this.phase === 'failed' && (key.name === 'return' || ch === 'r') && this.target) this.connectTo(this.target)
else if (this.phase !== 'connecting' && (ch === 'c' || ch === 'p' || (key.ctrl && key.name === 'o'))) this.pick()
else if (this.phase !== 'connecting' && ch === 'n') this.add()
this.store.bump()
return true
}
/** The status line: the sign-in's own words while it runs (creating a session, waiting for the browser). */
private statusSeg(now: number): SplashSeg {
const text = this.signingIn && this.signIn.status ? this.signIn.status : this.status
if (this.statusKind === 'error') return { text, tone: 'err' }
const busy = this.phase === 'connecting' || this.phase === 'starting'
if (!busy) return { text, tone: 'status' }
const frames = isAscii() ? SPINNER_ASCII : SPINNER
const spin = this.animate ? frames[Math.floor(now / 100) % frames.length]! : isAscii() ? '*' : '·'
return { text: `${spin} ${text}`, tone: 'status' }
}
private extra(): SplashSeg[] {
const s = this.signIn
if (s.browserUrl) return [{ text: 'Sign in in your browser (opened); if it did not open, visit:', tone: 'fg' }, { text: s.browserUrl, tone: 'fg' }]
if (s.device) {
const left = Math.max(0, Math.round((s.device.expires - Date.now()) / 1000))
return [{ text: `Open ${s.device.url}`, tone: 'fg' }, { text: `and confirm the code ${s.device.code} (expires in ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')})`, tone: 'fg' }]
}
return []
}
private keys(): string {
if (this.store.overlays.length) return ''
if (this.phase === 'failed') return 'Enter retries · c chooses another profile · n adds one · Esc quits'
if (this.phase === 'adding') return 'n adds a server profile · Esc quits'
if (this.phase === 'connecting' && (this.signIn.browserUrl || this.signIn.device)) return 'Esc cancels the sign-in'
return ''
}
layoutInput(anchor: 'center' | 'top', now = Date.now()): SplashLayoutInput {
const busy = this.phase === 'connecting' || this.phase === 'starting'
return { ascii: isAscii(), pose: this.animate ? splashPose(now, busy) : { blink: false, wag: false }, status: this.statusSeg(now), extra: this.extra(), keys: this.keys(), anchor }
}
/** The text as drawn now (tests). */
text(width: number, height: number): string {
return splashText(splashLayout(width, height, this.inputFor(height)))
}
render(width: number, height: number) {
return <SplashView splash={this} width={width} height={height} />
}
}
/** Repaints itself at about 10 fps while Query moves (only this component). */
function SplashView({ splash, width, height }: { splash: SplashScreen; width: number; height: number }) {
const [, tick] = useReducer((n: number) => n + 1, 0)
const moving = splash.animate && splash.phase !== 'done'
useEffect(() => {
if (!moving) return
const timer = setInterval(tick, 100)
return () => clearInterval(timer)
}, [moving])
const t = theme()
const lines = splashLayout(width, height, splash.inputFor(height))
return (
<box flexDirection="column" overflow="hidden" width={width} height={height}>
{lines.map((line, i) => (
<text key={i} flexShrink={0} wrapMode="none" height={1}>
{line.length ? line.map((s, k) => <span key={k} fg={toneColor(t, s.tone)}>{s.text}</span>) : ' '}
</text>
))}
</box>
)
}
+10 -58
View File
@@ -14,7 +14,6 @@ import { openRealtimeStream } from '@qrun-io/qqq-ui-api-client'
import { History, MetaDataIndex, addRecent, currentCredential, readIdleMinutes, type MetaDataDocument, type RecentItem } from '@qrun-io/qqq-core'
import { TUI_CHANNEL, TuiClient, type VersionInfo } from './client'
import { charOf, isPrintable, type Key } from './keys'
import { bankKey, legacyProfile, shiftNumber, type LegacyProfile } from './keys/fkeys'
import { ChoiceOverlay, ConfirmOverlay, CliCommandOverlay, MessageOverlay } from './overlays/basic'
import { Extensions } from './extensions'
import { PrintTracker, printScreen } from './printing'
@@ -26,9 +25,6 @@ import { errorText } from './util/text'
import { SQL_EXTENSION, hideRunnerApps } from './sql/apps'
import { AssistantPanel, SIDE_MIN_WIDTH, sideWidth } from './assistant/panel'
/** From this width the F-key bar also has the 11 and 12 slots (12 is the Assistant). */
export const FKEY_WIDE = 120
export interface Settings {
theme: string
ascii: boolean
@@ -38,8 +34,6 @@ export interface Settings {
singleKeys: boolean
/** Copy CLI equivalents to the clipboard automatically (ui.copyCliCommands; default off). */
copyCli?: boolean
/** The F-key bar: one row, two rows (primary and Shift), or auto (two from 30 lines); keys/shift-bank.ts. */
fkeyBar?: 'one' | 'two' | 'auto'
}
export interface Status {
@@ -121,11 +115,6 @@ export class AppStore {
/** Copies text to the clipboard (set by main); returns how it was copied, or undefined when there is no clipboard. */
copyText: (text: string) => string | undefined = () => undefined
sidebar?: SidebarLike
/** How this terminal numbers its legacy shifted F-keys (keys/fkeys.ts; QQQ_FKEYS overrides). */
fkeyProfile: LegacyProfile = legacyProfile()
/** One-row bar: the Shift row is shown instead of the primary row until this time (after a Shift+F press). */
shiftRowUntil = 0
private shiftRowTimer?: ReturnType<typeof setTimeout>
private listeners = new Set<Listener>()
private timers: Array<ReturnType<typeof setInterval>> = []
private streamAbort?: AbortController
@@ -209,15 +198,10 @@ export class AppStore {
this.bump()
}
/**
* 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.
*/
/** Tells the presence source which scope is on top (called on every repaint; cheap when unchanged). */
private syncPresence(): void {
if (this.lock) return
presenceSource().enter?.(this.screen?.presenceScope?.())
const scope = this.lock ? undefined : this.screen?.presenceScope?.()
presenceSource().enter?.(scope)
}
/** Replaces the top screen (a form that saved becomes the record). */
@@ -269,7 +253,6 @@ export class AppStore {
this.assistant ??= new AssistantPanel(this)
this.assistantOpen = true
this.focus = 'assistant'
this.assistant.capturePageContext()
void this.assistant.loadConfig()
if (question?.trim()) this.assistant.askWhenReady(question)
this.bump()
@@ -282,20 +265,16 @@ export class AppStore {
this.bump()
}
/**
* F12 (and Ctrl+G, and `:ask` without a question): shows the panel (focus on the question line,
* the current screen as context) or hides it. Hiding keeps the conversation, the context and the
* draft; the next press shows them again. When the panel is shown but the screen beside it has the
* focus, the press goes back to the question line instead.
*/
/** Ctrl+G: opens the panel, moves between the panel and the screen beside it, or (full width) closes it. */
toggleAssistant(): void {
if (!this.assistantOpen) return this.openAssistant()
// shown beside a screen that has the focus (a link was opened from it): back to the question line
if (this.focus !== 'assistant') {
this.focus = 'assistant'
return this.bump()
}
this.closeAssistant()
if (this.assistantLayout()?.mode === 'full') return this.closeAssistant()
this.focus = 'content'
this.bump()
}
// ---- overlays and messages ----
@@ -371,8 +350,6 @@ export class AppStore {
// F-key fallbacks for terminals that take the F-keys: Esc then a digit (arrives as Alt+digit) or Ctrl+digit; 0 is F10
const digit = /^[0-9]$/.test(raw.name) ? raw.name : /^[0-9]$/.test(raw.sequence?.slice(-1) ?? '') && raw.meta ? raw.sequence.slice(-1) : undefined
if (digit !== undefined && (raw.meta || raw.ctrl)) key = { name: `f${digit === '0' ? 10 : digit}`, sequence: '' }
// Shift+F1…F12 (any terminal's form, keys/fkeys.ts) are the Shift bank: shift+f1…shift+f12
key = bankKey(key, this.fkeyProfile)
try {
this.dispatch(key)
} catch (e) {
@@ -409,8 +386,6 @@ export class AppStore {
// Ctrl+P prints what you see on every screen; Print Screen too when the terminal passes it on
// (kitty keyboard protocol); F8 prints where the screen has no Delete for it
if ((key.ctrl && key.name === 'p') || key.name === 'printscreen' || (key.name === 'f8' && this.fkeys().find((k) => k.n === 8)?.label === 'Print')) return printScreen(this)
const shifted = shiftNumber(key)
if (shifted !== undefined) return this.commands().shiftKey(shifted)
if (key.name === 'f1') return this.commands().help()
if (this.backslash && charOf(key) === '?') {
this.backslash = false
@@ -421,8 +396,7 @@ export class AppStore {
if (key.name === 'f9') return this.commands().menu()
if (key.ctrl && key.name === 'o') return this.commands().servers()
if (key.name === 'f10') return this.requestQuit()
// F12 shows and hides the Assistant panel (Ctrl+G where the terminal or the OS takes F12); F11 is reserved
if (key.name === 'f12' || (key.ctrl && key.name === 'g')) return this.toggleAssistant()
if (key.ctrl && key.name === 'g') return this.toggleAssistant()
if (this.focus === 'assistant' && this.assistantOpen && this.assistant) {
// the panel has every other key while it has focus (text goes to the question line)
this.assistant.onKey(key)
@@ -529,8 +503,6 @@ export class AppStore {
dispose(): void {
for (const t of this.timers) clearInterval(t)
this.timers = []
if (this.shiftRowTimer) clearTimeout(this.shiftRowTimer)
this.shiftRowTimer = undefined
this.clockTimer = undefined
this.streamAbort?.abort()
this.printTracker.dispose()
@@ -673,34 +645,16 @@ export class AppStore {
this.bump()
}
/** One-row bar: shows the Shift row for `ms` (keys/shift-bank.ts SHIFT_ROW_MS), then the primary row again. */
showShiftRow(ms = 2000): void {
this.shiftRowUntil = Date.now() + ms
if (this.shiftRowTimer) clearTimeout(this.shiftRowTimer)
this.shiftRowTimer = setTimeout(() => {
this.shiftRowTimer = undefined
this.bump()
}, ms + 10)
;(this.shiftRowTimer as { unref?: () => void }).unref?.()
}
/** Whether one-row mode shows the Shift row now. */
shiftRowShown(): boolean {
return Date.now() < this.shiftRowUntil
}
/** The function-key bar for the current context; from 120 columns also 11 (reserved, blank) and 12 (the Assistant). */
/** The function-key bar for the current context. */
fkeys(): Array<{ n: number; label: string | null; global: string }> {
const defaults: Record<number, string> = { 1: 'Help', 2: 'Search', 3: 'View', 4: 'Edit', 5: 'Refresh', 6: 'Filter', 7: 'New', 8: 'Delete', 9: 'Menu', 10: 'Quit' }
const overrides: FKeyLabels = this.focus === 'assistant' && this.assistantOpen && this.assistant ? this.assistant.fkeys() : this.screen?.fkeys?.() ?? { 3: null, 4: null, 6: null, 7: null, 8: null }
const keys = Object.entries(defaults).map(([n, label]): { n: number; label: string | null; global: string } => {
return Object.entries(defaults).map(([n, label]) => {
const k = Number(n)
const shown = k in overrides ? overrides[k]! : label
// F8 prints on screens that have no Delete for it
return { n: k, label: k === 8 && shown === null ? 'Print' : shown, global: label }
})
if (this.width >= FKEY_WIDE) keys.push({ n: 11, label: null, global: '' }, { n: 12, label: this.extensions.has('assistant') ? 'Ask' : null, global: 'Ask' })
return keys
}
themeTokens() {
@@ -734,8 +688,6 @@ export interface CommandsLike {
menu(): void
/** The Servers dialog (Ctrl+O). */
servers(): void
/** Shift+Fn: the Shift bank's key n (keys/shift-bank.ts). */
shiftKey(n: number): void
commandLine(initial?: string): void
viewSettings(): void
lock(): void
+3 -133
View File
@@ -6,10 +6,7 @@
import { afterEach, beforeAll, expect, test } from 'bun:test'
import { testRender } from '@opentui/react/test-utils'
import { App } from '../src/app'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { chatLines, chatLinks, contextLabel, plainText, wrap } from '../src/assistant/model'
import { fkeyAt } from '../src/mouse/hit'
import { chatLines, contextLabel, plainText, wrap } from '../src/assistant/model'
import { boot, parseArgs } from '../src/boot'
import { linkFilter } from '../src/links'
import { fakeFetch, type FakeLog } from './fake-server'
@@ -29,7 +26,7 @@ const TURN = sse([
{ type: 'message.end', messageId: 2, status: 'COMPLETE' },
])
interface AssistantFake { installed: boolean; config: J; turns: J[]; conversations: J[]; stream?: string }
interface AssistantFake { installed: boolean; config: J; turns: J[]; conversations: J[] }
function assistantFetch(log: FakeLog, state: AssistantFake): typeof fetch {
const base = fakeFetch(log)
@@ -47,7 +44,7 @@ function assistantFetch(log: FakeLog, state: AssistantFake): typeof fetch {
}
if (/^\/assistant\/conversations\/\d+\/turns$/.test(path)) {
state.turns.push(body ?? {})
return new Response(state.stream ?? TURN, { status: 200, headers: { 'content-type': 'text/event-stream' } })
return new Response(TURN, { status: 200, headers: { 'content-type': 'text/event-stream' } })
}
return json({ code: 'NOT_FOUND', message: path }, 404)
}) as typeof fetch
@@ -185,130 +182,3 @@ test('chat lines wrap to the width, number links across exchanges and drop markd
expect(contextLabel({ tableName: 'order', recordId: 5 }, 'Order')).toBe('Order #5')
expect(contextLabel({ tableName: 'order', filter: {} }, 'Order')).toBe('Order (filtered)')
})
/** The answer to "what services are not healthy right now?" as 8078 gave it (qqq assistant ask --json). */
const UNHEALTHY = JSON.parse(readFileSync(join(import.meta.dir, '..', '..', 'core', 'src', 'test', 'fixtures', 'assistant-unhealthy.json'), 'utf8')) as J & { text: string; links: Array<{ label: string; href: string }> }
const unhealthyAnswer = () => ({ ...(UNHEALTHY as never as import('@qrun-io/qqq-core').AssistantAnswer), status: 'COMPLETE' as const })
test('before: the 8078 answer drawn as plain text had entities, a pipe table, URLs and a duplicate link list', () => {
const before = plainText(UNHEALTHY.text)
expect(before).toContain('healthy&#58;')
expect(before).toContain('| --- |')
expect(before).toContain('/app/health_component?filter=%7B')
expect(UNHEALTHY.links.length).toBe(8)
})
for (const width of [98, 158]) {
test(`after (${width} wide): entities decoded, a table, links as label ↗ once each, no URLs, no → list`, () => {
const lines = chatLines([{ question: 'what services are not healthy right now?', startedAt: 0, answer: unhealthyAnswer() }], width)
const text = lines.map((l) => l.text).join('\n')
expect(text).not.toMatch(/&#|\/app\/|%7B|\| ---|^→/m)
expect(text).toContain('3 services are not fully healthy: one degraded (email) and two with unknown status.')
expect(text).toMatch(/Service\s+Status\s+Since/)
expect(text).toMatch(/db_performance ↗\s+UNKNOWN/)
expect(text).toContain('• Open unhealthy components ↗')
expect(text).toContain('• email is degraded. Its only check is degraded.')
expect(text).toContain('7.1 s · 10 in / 729 out tokens')
expect(lines.every((l) => [...l.text].length <= width)).toBe(true)
expect(lines.find((l) => l.role === 'meta' && l.text.includes('tokens'))).toBeTruthy()
// four Tab targets in reading order: the three services, then the open-all link
expect(chatLinks([{ question: 'q', startedAt: 0, answer: unhealthyAnswer() }]).map((l) => l.label)).toEqual(['email', 'db_performance', 'esb', 'Open unhealthy components'])
const linked = lines.flatMap((l) => (l.segs ?? []).filter((x) => x.link !== undefined).map((x) => x.link))
expect([...new Set(linked)]).toEqual([0, 1, 2, 3])
})
}
test('narrow: the table keeps the columns that fit and names the rest', () => {
const lines = chatLines([{ question: 'q', startedAt: 0, answer: unhealthyAnswer() }], 38)
expect(lines.every((l) => [...l.text].length <= 38)).toBe(true)
expect(lines.map((l) => l.text).join('\n')).toMatch(/more columns?:/)
})
test('duplicate paragraphs collapse; a link the text does not show is listed once under →', () => {
const answer = { conversationId: 1, status: 'COMPLETE' as const, notices: [], numbers: [], tools: [], text: 'All good.\n\nAll good.',
links: [{ label: 'Person (3)', href: '/app/person', kind: 'table' as const }, { label: 'People', href: '/app/person', kind: 'table' as const }] }
const lines = chatLines([{ question: 'q', startedAt: 0, answer }], 60)
expect(lines.filter((l) => l.text === 'All good.').length).toBe(1)
expect(lines.filter((l) => l.text.startsWith('→ ')).map((l) => l.text)).toEqual(['→ Person (3) ↗'])
})
test('F12 shows and hides the panel; hiding keeps the conversation, context and draft; Esc hides without asking', async () => {
const state = fresh()
h = await start(state, 120, 36)
await command(h, 'record person 1')
await waitFor(h, () => h!.store.screen?.kind === 'record')
await key('F12')
expect(h.store.assistantOpen).toBe(true)
expect(h.store.focus).toBe('assistant')
await waitFor(h, (x) => x.includes('on: Person #1'))
await type('draft question')
await key('F12')
expect(h.store.assistantOpen).toBe(false)
expect(h.store.focus).toBe('content')
// back on Home the panel keeps the record's context and the draft
await key('ESCAPE')
await key('F12')
expect(h.store.assistantOpen).toBe(true)
expect(h.store.assistant!.editor.value).toBe('draft question')
expect(await waitFor(h, (x) => x.includes('on: Person #1'))).toBeTruthy()
// Esc with text on the question line hides; nothing is asked; the draft stays
await key('ESCAPE')
expect(h.store.assistantOpen).toBe(false)
expect(state.turns.length).toBe(0)
expect(h.store.assistant!.editor.value).toBe('draft question')
// Ctrl+G and :ask do the same
await key('g', { ctrl: true })
expect(h.store.assistantOpen).toBe(true)
await key('g', { ctrl: true })
expect(h.store.assistantOpen).toBe(false)
await command(h, 'ask')
expect(h.store.assistantOpen).toBe(true)
})
test('F-key bar: 11 and 12 from 120 columns, 12Ask lit with the extension, a click on it toggles', async () => {
h = await start(fresh(), 120, 36)
let keys = h.store.fkeys()
expect(keys.map((k) => k.n)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
expect(keys.find((k) => k.n === 12)?.label).toBe('Ask')
expect(keys.find((k) => k.n === 11)?.label).toBeNull()
// the primary row (from 30 lines the Shift row is drawn under it)
const bar = h.frame().split('\n').find((l) => l.includes(' 1Help'))!
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,10 +16,6 @@ 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. */
@@ -28,8 +24,6 @@ export const printing = {
reset(): void {
this.enabled = false
this.lists = true
this.render = true
this.renders = []
this.listRuns = []
this.runs = []
this.jobs.clear()
@@ -47,7 +41,6 @@ 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' } } : {}),
}
}
@@ -83,16 +76,6 @@ 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]!
-195
View File
@@ -1,195 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/** The function-key decoder (keys/fkeys.ts), per sequence family, and the stdin rewrite into what OpenTUI reads. */
import { EventEmitter } from 'node:events'
import { describe, expect, test } from 'bun:test'
import { parseKeypress } from '@opentui/core'
import { bankKey, canonicalSequence, chordName, decodeFKey, fkeyChord, legacyProfile, rewriteFKeyInput, shiftNumber, type FKeyChord, type LegacyProfile } from '../src/keys/fkeys'
import { CPR_QUIET_MS, installFKeyRewrite } from '../src/keys/stdin-rewrite'
const E = '\x1b'
const name = (seq: string, profile: LegacyProfile = 'vt220') => {
const d = decodeFKey(seq, profile)
return d ? chordName(d.chord) : undefined
}
const family = (seq: string, profile: LegacyProfile = 'vt220') => decodeFKey(seq, profile)?.family
/** What OpenTUI makes of a sequence after the rewrite (kitty parsing on, as the renderer runs). */
function opentui(seq: string, profile: LegacyProfile = 'vt220'): string | undefined {
const k = parseKeypress(rewriteFKeyInput(seq, profile, { cpr: true }), { useKittyKeyboard: true })
if (!k?.name) return undefined
return `${k.ctrl ? 'ctrl+' : ''}${k.meta ? 'alt+' : ''}${k.shift ? 'shift+' : ''}${k.name}`
}
describe('xterm modifier forms (tmux, iTerm2, Windows Terminal, xterm, VTE)', () => {
const shift = ['1;2P', '1;2Q', '1;2R', '1;2S', '15;2~', '17;2~', '18;2~', '19;2~', '20;2~', '21;2~', '23;2~', '24;2~']
test('Shift+F1…F12', () => {
shift.forEach((s, i) => expect(name(`${E}[${s}`)).toBe(`shift+f${i + 1}`))
expect(family(`${E}[1;2P`)).toBe('csi-letter')
expect(family(`${E}[1;2R`)).toBe('csi-f3-cpr')
expect(family(`${E}[15;2~`)).toBe('csi-tilde')
})
test('SS3 with a modifier and the plain F1–F4', () => {
expect(name(`${E}O2P`)).toBe('shift+f1')
expect(name(`${E}O2S`)).toBe('shift+f4')
expect(name(`${E}OQ`)).toBe('f2')
expect(family(`${E}O2R`)).toBe('ss3')
})
test('CSI 11..14 forms (VT220, PuTTY) with and without modifiers', () => {
expect(name(`${E}[11~`)).toBe('f1')
expect(name(`${E}[12;2~`)).toBe('shift+f2')
expect(name(`${E}[14;2~`)).toBe('shift+f4')
})
test('Ctrl, Alt and combinations come for free', () => {
expect(name(`${E}[1;5P`)).toBe('ctrl+f1')
expect(name(`${E}[15;5~`)).toBe('ctrl+f5')
expect(name(`${E}[24;5~`)).toBe('ctrl+f12')
expect(name(`${E}[1;6P`)).toBe('ctrl+shift+f1')
expect(name(`${E}[20;3~`)).toBe('alt+f9')
expect(name(`${E}${E}[1;2P`)).toBe('alt+shift+f1')
expect(name(`${E}[1;5R`)).toBe('ctrl+f3')
})
test('OpenTUI reads all of them after the rewrite, Shift+F3 included', () => {
shift.forEach((s, i) => expect(opentui(`${E}[${s}`)).toBe(`shift+f${i + 1}`))
expect(opentui(`${E}[1;5R`)).toBe('ctrl+f3')
// without the rewrite OpenTUI drops Shift+F3: it looks like a cursor position report
expect(parseKeypress(`${E}[1;2R`, { useKittyKeyboard: true })).toBeNull()
})
})
describe('kitty keyboard protocol', () => {
test('F3 is CSI 13 ~; event sub-parameters; the CSI u code points', () => {
expect(name(`${E}[13;2~`)).toBe('shift+f3')
expect(name(`${E}[15;2:2~`)).toBe('shift+f5')
expect(decodeFKey(`${E}[1;2:3Q`)!.chord).toEqual({ n: 2, shift: true, ctrl: false, alt: false, release: true })
expect(name(`${E}[57364;2u`)).toBe('shift+f1')
expect(name(`${E}[57375;2u`)).toBe('shift+f12')
expect(family(`${E}[57365;2u`)).toBe('kitty-u')
// F13 and up are keys of their own (macOS keyboards have them)
expect(decodeFKey(`${E}[57376;2u`)).toBeUndefined()
})
test('OpenTUI reads the kitty forms itself (no rewrite)', () => {
expect(rewriteFKeyInput(`${E}[13;2~`, 'vt220', { cpr: true })).toBe(`${E}[13;2~`)
expect(opentui(`${E}[13;2~`)).toBe('shift+f3')
expect(opentui(`${E}[57364;2u`)).toBe('shift+f1')
})
})
describe('legacy numbered forms, per terminal profile', () => {
const codes = ['25~', '26~', '28~', '29~', '31~', '32~', '33~', '34~']
test('vt220 (F13 = Shift+F1): CSI 25~…34~ are Shift+F1…F8', () => {
codes.forEach((c, i) => expect(name(`${E}[${c}`, 'vt220')).toBe(`shift+f${i + 1}`))
expect(family(`${E}[25~`)).toBe('legacy-number')
})
test('rxvt and the Linux console (F11 = Shift+F1): Shift+F3…F10', () => {
codes.forEach((c, i) => expect(name(`${E}[${c}`, 'rxvt')).toBe(`shift+f${i + 3}`))
// CSI 23~ / 24~ stay F11 / F12 everywhere (rxvt's Shift+F1 / F2 cannot be told apart from them)
expect(name(`${E}[23~`, 'rxvt')).toBe('f11')
})
test('macOS Terminal.app: Shift+F5…F12', () => {
codes.forEach((c, i) => expect(name(`${E}[${c}`, 'apple')).toBe(`shift+f${i + 5}`))
})
test('modern terminals: legacy numbers mean nothing (Windows Terminal sends CSI 29~ for the Menu key)', () => {
for (const c of codes) expect(decodeFKey(`${E}[${c}`, 'modern')).toBeUndefined()
expect(rewriteFKeyInput(`${E}[29~`, 'modern', { cpr: true })).toBe(`${E}[29~`)
})
test('the rewrite gives OpenTUI the canonical form', () => {
expect(opentui(`${E}[25~`, 'vt220')).toBe('shift+f1')
expect(opentui(`${E}[26~`, 'vt220')).toBe('shift+f2')
expect(opentui(`${E}[33~`, 'apple')).toBe('shift+f11')
expect(opentui(`${E}[34~`, 'rxvt')).toBe('shift+f10')
})
})
describe('rxvt suffixes and the Linux console', () => {
test('$ Shift, ^ Ctrl, @ Ctrl+Shift', () => {
expect(name(`${E}[23$`)).toBe('shift+f11')
expect(name(`${E}[24$`)).toBe('shift+f12')
expect(name(`${E}[11^`)).toBe('ctrl+f1')
expect(name(`${E}[15^`)).toBe('ctrl+f5')
expect(name(`${E}[24^`)).toBe('ctrl+f12')
expect(name(`${E}[25^`)).toBe('ctrl+shift+f3')
expect(name(`${E}[23@`)).toBe('ctrl+shift+f11')
expect(family(`${E}[15^`)).toBe('rxvt-suffix')
expect(opentui(`${E}[23$`)).toBe('shift+f11')
expect(opentui(`${E}[15^`)).toBe('ctrl+f5')
})
test('editing keys with the same suffixes are not function keys', () => {
expect(decodeFKey(`${E}[2$`)).toBeUndefined()
expect(decodeFKey(`${E}[5^`)).toBeUndefined()
expect(rewriteFKeyInput(`${E}[3^`, 'rxvt', { cpr: true })).toBe(`${E}[3^`)
})
test('Linux console CSI [ A…E are F1…F5', () => {
expect(name(`${E}[[A`)).toBe('f1')
expect(name(`${E}[[E`)).toBe('f5')
})
})
describe('the rewrite', () => {
test('leaves cursor reports alone during the probe, and CSI 1;1R always', () => {
expect(rewriteFKeyInput(`${E}[1;2R`, 'vt220', { cpr: false })).toBe(`${E}[1;2R`)
expect(rewriteFKeyInput(`${E}[1;1R`, 'vt220', { cpr: true })).toBe(`${E}[1;1R`)
expect(rewriteFKeyInput(`${E}[12;40R`, 'vt220', { cpr: true })).toBe(`${E}[12;40R`)
expect(rewriteFKeyInput(`${E}[1;2R`, 'vt220', { cpr: true })).toBe(`${E}[13;2~`)
})
test('rewrites inside a chunk, keeps text and other sequences, never touches a paste', () => {
expect(rewriteFKeyInput(`ab${E}[25~${E}[A${E}[1;2R`, 'vt220', { cpr: true })).toBe(`ab${E}[11;2~${E}[A${E}[13;2~`)
expect(rewriteFKeyInput(`${E}[200~${E}[25~${E}[201~`, 'vt220', { cpr: true })).toBe(`${E}[200~${E}[25~${E}[201~`)
expect(rewriteFKeyInput('plain', 'vt220', { cpr: true })).toBe('plain')
})
test('canonical sequences', () => {
const c = (n: number, mods: Partial<FKeyChord> = {}): FKeyChord => ({ n, shift: false, ctrl: false, alt: false, ...mods })
expect(canonicalSequence(c(1, { shift: true }))).toBe(`${E}[11;2~`)
expect(canonicalSequence(c(3, { ctrl: true }))).toBe(`${E}[13;5~`)
expect(canonicalSequence(c(12))).toBe(`${E}[24~`)
})
test('on the stream: Buffer chunks before any listener; quiet after a resume; dispose restores', () => {
const stream = new EventEmitter()
const seen: string[] = []
stream.on('data', (d: Buffer | string) => seen.push(Buffer.isBuffer(d) ? d.toString('latin1') : d))
let now = 1000
const r = installFKeyRewrite(stream, 'vt220', () => now)
stream.emit('data', Buffer.from(`${E}[1;2R`))
stream.emit('data', Buffer.from(`${E}[25~`))
now += CPR_QUIET_MS
stream.emit('data', Buffer.from(`${E}[1;2R`))
r.quiet()
stream.emit('data', `${E}[1;2R`)
stream.emit('data', Buffer.from('é', 'utf8'))
r.dispose()
stream.emit('data', `${E}[25~`)
expect(seen).toEqual([`${E}[1;2R`, `${E}[11;2~`, `${E}[13;2~`, `${E}[1;2R`, Buffer.from('é', 'utf8').toString('latin1'), `${E}[25~`])
})
})
describe('key events and the profile', () => {
test('fkeyChord and bankKey: Shift (only) becomes the Shift bank; Ctrl+F keeps acting as F', () => {
expect(bankKey({ name: 'f2', sequence: `${E}[1;2Q`, shift: true }).name).toBe('shift+f2')
expect(bankKey({ name: 'f2', sequence: `${E}OQ` }).name).toBe('f2')
expect(bankKey({ name: 'f5', sequence: '', ctrl: true }).name).toBe('f5')
expect(bankKey({ name: 'f1', sequence: '', ctrl: true, shift: true }).name).toBe('f1')
expect(bankKey({ name: 'f9', sequence: '', meta: true, shift: true }).name).toBe('f9')
// a sequence OpenTUI left unnamed is decoded from its bytes
expect(bankKey({ name: '', sequence: `${E}[26~` }, 'vt220').name).toBe('shift+f2')
expect(bankKey({ name: 'menu', sequence: `${E}[29~` }, 'modern').name).toBe('menu')
expect(fkeyChord({ name: 'f13', sequence: '' })).toBeUndefined()
expect(fkeyChord({ name: 'x', sequence: 'x' })).toBeUndefined()
expect(shiftNumber({ name: 'shift+f10', sequence: '' })).toBe(10)
expect(shiftNumber({ name: 'f10', sequence: '' })).toBeUndefined()
})
test('legacyProfile from the environment', () => {
expect(legacyProfile({ TERM_PROGRAM: 'Apple_Terminal', TERM: 'xterm-256color' })).toBe('apple')
expect(legacyProfile({ TERM_PROGRAM: 'iTerm.app', TERM: 'xterm-256color' })).toBe('modern')
expect(legacyProfile({ WT_SESSION: 'x', TERM: 'xterm-256color' })).toBe('modern')
expect(legacyProfile({ TMUX: '/tmp/x', TERM: 'tmux-256color', TERM_PROGRAM: 'tmux' })).toBe('modern')
expect(legacyProfile({ TERM: 'rxvt-unicode-256color' })).toBe('rxvt')
expect(legacyProfile({ TERM: 'linux' })).toBe('rxvt')
expect(legacyProfile({ TERM: 'xterm' })).toBe('vt220')
expect(legacyProfile({ TERM: 'xterm', QQQ_FKEYS: 'apple' })).toBe('apple')
expect(legacyProfile({ TERM: 'xterm', QQQ_FKEYS: 'bogus' })).toBe('vt220')
})
})
+1 -2
View File
@@ -50,8 +50,7 @@ export interface Harness {
/** Renders the whole TUI against the fake server and waits for the home screen. */
export async function startTui(width = 120, height = 36, options: { metaData?: unknown; auth?: FakeAuth; secrets?: () => SecretStore; until?: 'home' | 'signin' } = {}): Promise<Harness> {
const log: FakeLog = { requests: [] }
// the sign-in tests drive the sign-in screen (--no-splash); the rest go through the splash
const store = boot({ args: parseArgs(['--profile', 'fake', ...(options.until === 'signin' ? ['--no-splash'] : [])]), quit: () => undefined, build: 'test', secrets: options.secrets ?? sessionSecrets, fetch: fakeFetch(log, options.metaData, options.auth) })
const store = boot({ args: parseArgs(['--profile', 'fake']), quit: () => undefined, build: 'test', secrets: options.secrets ?? sessionSecrets, fetch: fakeFetch(log, options.metaData, options.auth) })
const setup = await testRender(<App store={store} />, { width, height })
const h: Harness = { setup, store, log, frame: () => setup.captureCharFrame() }
store.captureScreen = () => setup.captureCharFrame()
+2 -3
View File
@@ -101,9 +101,8 @@ test('F-key bar: a click presses the key; the footer connection opens the menu a
expect(h.store.overlays.length).toBe(1)
await press(h, () => h!.setup.mockInput.pressEscape())
expect(h.store.overlays.length).toBe(0)
// the footer is the row above the F-key bar (two rows from 30 lines: primary, then Shift); its
// first cells are the server and profile: the Servers dialog
await press(h, () => mouse().click(2, 30 - 3))
// the footer is the row above the F-key bar; its first cells are the server and profile: the Servers dialog
await press(h, () => mouse().click(2, 30 - 2))
await waitFor(h, () => h!.store.overlays.length === 1)
expect(h.store.overlays[0]!.constructor.name).toBe('ServersOverlay')
})
+6 -142
View File
@@ -5,11 +5,8 @@
import { afterEach, beforeAll, beforeEach, expect, test } from 'bun:test'
import { KeyCodes } from '@opentui/core/testing'
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 { printerOf, readRememberedPrinter, writeRememberedPrinter } from '@qrun-io/qqq-core'
import { captureScreenText, printTargetOf, printerChoices } from '../src/printing'
import { printing } from './fake-print'
import { command, isolate, press, startTui, waitFor, type Harness } from './helpers'
@@ -17,7 +14,6 @@ let h: Harness | undefined
beforeAll(() => isolate())
beforeEach(() => {
printing.reset()
writeFilePreferences({ downloadMode: undefined, downloadDir: undefined })
writeRememberedPrinter('fake', undefined)
})
afterEach(() => {
@@ -76,7 +72,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]).toMatchObject({ values: { printingPrinterId: 'p1', printingCopies: 1 }, recordIds: '2' })
expect(printing.runs[0]).toEqual({ 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()
@@ -106,7 +102,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]).toMatchObject({ values: { printingPrinterId: 'p3', printingCopies: 1 }, recordIds: '1,2' })
expect(printing.runs[0]).toEqual({ values: { printingPrinterId: 'p3', printingCopies: 1 }, recordIds: '1,2' })
await waitFor(h, () => h!.store.status.print === 'print 0/2 QUEUED')
await poll()
await poll()
@@ -210,88 +206,20 @@ 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 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'))
f = 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.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 what?'))
await waitFor(h, (x) => x.includes('Print this screen'))
await esc()
await command(h, 'print back office')
await waitFor(h, () => printing.listRuns.length === 1 && printing.listRuns[0]!.values.printingPrinterId === 'p3')
@@ -304,67 +232,3 @@ 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…')
})
+7 -112
View File
@@ -7,7 +7,6 @@ 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'
@@ -127,53 +126,17 @@ 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).toBe('3 online · you, Bob · PERSON, Carol editing · ORDER')
expect(v.detail).toMatch(/^Bob · PERSON, Carol editing · ORDER, you elsewhere · PERSON$/)
viewers.person = []
viewers.order = []
t = 120_000
expect((await s.load()).value).toBe('1')
expect((await s.load()).value).toBe('0')
})
test('presence on a table that refuses it is skipped; the source hides when nothing is left', async () => {
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')
const s = presenceSource(ctx({ data: () => { throw new UiApiError(404, {}) } }))
await s.load()
await expect(s.load()).rejects.toThrow('no tables')
})
test('jobs: counts by status, falls back to my jobs on 403', async () => {
@@ -207,7 +170,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', 'medium', 'large'] as const) {
for (const size of ['small', 'large'] as const) {
const { width, height } = querySize(size)
const frame = queryFrame(size)
expect(frame.length).toBe(height)
@@ -224,49 +187,6 @@ 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)
@@ -600,7 +520,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 + 2)
expect(Math.abs(r.y - cy)).toBeLessThanOrEqual(height * 0.4 + 1)
}
{
// the group as a whole stays near the middle of the screen, fully on it
@@ -768,31 +688,6 @@ 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)
-319
View File
@@ -1,319 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/** The Shift bank (Shift+F1…F10): the table, the bar in one and two rows, dispatch, the mouse, help. */
import { afterEach, beforeAll, expect, test } from 'bun:test'
import { testRender } from '@opentui/react/test-utils'
import { readConfig } from '@qrun-io/qqq-core'
import { App } from '../src/app'
import { boot, parseArgs } from '../src/boot'
import { ACTIONS } from '../src/keys/actions'
import { SHIFT_BANK, fkeyBarRows, shiftSlots } from '../src/keys/shift-bank'
import { shiftRowCells, slotWidths } from '../src/keys/shift-row'
import { CPR_QUIET_MS, installFKeyRewrite } from '../src/keys/stdin-rewrite'
import { fkeyAt } from '../src/mouse/hit'
import { SqlRunnerScreen } from '../src/sql/screen'
import { fakeFetch, type FakeLog } from './fake-server'
import { sqlFakeFetch } from './fake-sql'
import { command, isolate, press, sessionSecrets, startTui, waitFor, type Harness } from './helpers'
let h: Harness | undefined
beforeAll(() => isolate())
afterEach(() => {
h?.store.dispose()
h?.setup.renderer.destroy()
h = undefined
})
const E = '\x1b'
/** Raw bytes into the terminal, as a terminal would send them. */
const raw = (seq: string) => press(h!, () => h!.setup.mockInput.pressKeys([seq]))
const lines = (frame: string) => frame.replace(/\n$/, '').split('\n')
const fits = (frame: string, width: number) => lines(frame).every((l) => [...l].length <= width)
const last = (frame: string, n = 1) => lines(frame).slice(-n)
async function start(width: number, height: number, fkeyBar?: 'one' | 'two' | 'auto', fetch?: typeof globalThis.fetch): Promise<Harness> {
const log: FakeLog = { requests: [] }
const store = boot({ args: parseArgs(['--profile', 'fake']), quit: () => undefined, build: 'test', secrets: sessionSecrets, fetch: fetch ?? fakeFetch(log) })
if (fkeyBar) store.settings.fkeyBar = fkeyBar
const setup = await testRender(<App store={store} />, { width, height })
const harness: Harness = { setup, store, log, frame: () => setup.captureCharFrame() }
store.captureScreen = () => setup.captureCharFrame()
await waitFor(harness, () => harness.store.screen?.kind === 'home' && Boolean(harness.store.meta))
return harness
}
/** Changes the bar mode and repaints. */
async function mode(m: 'one' | 'two' | 'auto'): Promise<string> {
h!.store.settings.fkeyBar = m
h!.store.bump()
return press(h!, () => undefined)
}
/** The fake server with the assistant extension (enough for the panel to open). */
function assistantFetch(): typeof fetch {
const base = fakeFetch({ requests: [] })
const json = (d: unknown) => new Response(JSON.stringify(d), { headers: { 'content-type': 'application/json' } })
return (async (input: RequestInfo | URL, init?: RequestInit) => {
const path = new URL(new Request(input as RequestInfo, init).url).pathname.replace('/qqq/v1', '')
if (path === '/version') return json({ apiVersion: '1.9.0', features: ['metaData', 'tables', 'processes'], extensions: [{ name: 'assistant', version: '1.7.0', pathPrefix: '/assistant' }] })
if (path === '/assistant/config') return json({ enabled: true, model: 'm', profile: { displayName: 'Assistant' } })
return base(input, init)
}) as typeof fetch
}
// ---- the table ----
test('the bank is one table of ten keys, each bound to a named action, with short labels for 80 columns', () => {
expect(SHIFT_BANK.map((k) => k.n)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
expect(SHIFT_BANK.map((k) => k.label)).toEqual(['Keys', 'Servers', 'SQL', 'Bulk', 'Metadata', 'Views', 'Copy', 'Export', 'History', 'Assistant'])
for (const k of SHIFT_BANK) {
expect(ACTIONS[k.action]).toBeDefined()
expect(k.short.length).toBeLessThanOrEqual(5)
}
// the assistant toggle is the named action F12 and Ctrl+G bind too
expect(SHIFT_BANK.find((k) => k.n === 10)!.action).toBe('assistant.toggle')
})
test('rows: auto is two from 30 lines; one and two are fixed', () => {
expect(fkeyBarRows('auto', 29)).toBe(1)
expect(fkeyBarRows('auto', 30)).toBe(2)
expect(fkeyBarRows(undefined, 40)).toBe(2)
expect(fkeyBarRows('one', 40)).toBe(1)
expect(fkeyBarRows('two', 24)).toBe(2)
})
test('row cells: the primary arithmetic, ⇧ on the first number, short labels below 100 columns, clipped', () => {
expect(slotWidths(80, 10)).toEqual([8, 8, 8, 8, 8, 8, 8, 8, 8, 8])
expect(slotWidths(120, 12).reduce((a, b) => a + b, 0)).toBe(120)
const slots = SHIFT_BANK.map((k) => ({ n: k.n, label: k.label, global: k.label, short: k.short }))
const narrow = shiftRowCells(slots, 80)
expect(narrow[0]!.num).toBe('⇧1')
expect(narrow[1]!.label.trim()).toBe('Servr')
expect(narrow.map((c) => c.num + c.label).join('').length).toBe(80)
expect(shiftRowCells(slots, 120)[4]!.label.trim()).toBe('Metadata')
expect(shiftRowCells(slots, 80, true)[0]!.num).toBe('S1')
// twelve slots (when the primary row has 11 and 12) line up; 11 and 12 are blank
expect(shiftRowCells([...slots, { n: 11, label: null, global: '', short: '' }, { n: 12, label: null, global: '', short: '' }], 120).length).toBe(12)
})
// ---- the bar ----
test('80×24 auto: one row; a Shift+F press shows the Shift row for 2 s, then the primary row again', async () => {
h = await start(80, 24)
let f = h.frame()
expect(fits(f, 80)).toBe(true)
expect(last(f)[0]).toContain(' 1Help')
expect(f).not.toContain('⇧1')
// Shift+F2 (xterm form): the Servers dialog; the bar swaps to the Shift row
await raw(`${E}[1;2Q`)
expect(h.store.overlays[0]!.constructor.name).toBe('ServersOverlay')
f = h.frame()
expect(last(f)[0]).toStartWith('⇧1Keys')
expect(last(f)[0]).toContain('2Servr')
expect(last(f)[0]).toContain('9Hist')
expect(fits(f, 80)).toBe(true)
// after the 2 s the primary row is back
h.store.shiftRowUntil = Date.now() - 1
h.store.bump()
f = await press(h, () => undefined)
expect(last(f)[0]).toContain(' 1Help')
})
test('80×24 two rows: primary then Shift, the footer above them, nothing wider than 80', async () => {
h = await start(80, 24, 'two')
const f = h.frame()
expect(fits(f, 80)).toBe(true)
const [primary, shift] = last(f, 2)
expect(primary).toContain(' 1Help')
expect(primary).toContain('10Quit')
expect(shift).toStartWith('⇧1Keys')
expect(shift).toContain('10Asst')
expect(lines(f).length).toBe(24)
})
test('120×40 auto: two rows with full labels; one-row mode keeps the primary row', async () => {
h = await start(120, 40)
let f = h.frame()
expect(fits(f, 120)).toBe(true)
const [primary, shift] = last(f, 2)
expect(primary).toContain(' 1Help')
expect(shift).toStartWith('⇧1Keys')
for (const label of ['2Servers', '3SQL', '4Bulk', '5Metadata', '6Views', '7Copy', '8Export', '9History', '10Asst']) expect(shift).toContain(label)
// the primary row has 11 and 12 from 120 columns: the Shift row lines up with it (11 and 12 unbound)
expect(shift).toMatch(/11\s+12\s*$/)
expect(shift.indexOf(' 9History')).toBe(primary.indexOf(' 9Menu'))
expect(lines(f).length).toBe(40)
f = await mode('one')
expect(last(f)[0]).toContain(' 1Help')
expect(f).not.toContain('⇧1Keys')
expect(lines(f).length).toBe(40)
await press(h, () => h!.setup.mockInput.pressKey('F9', { shift: true }))
f = await waitFor(h, () => h!.store.screen?.kind === 'history')
expect(last(f)[0]).toStartWith('⇧1Keys')
})
test('context: keys that do not apply are dimmed and say why; on a grid Bulk, Views, Copy and Export apply', async () => {
h = await start(120, 40)
const home = Object.fromEntries(shiftSlots(h.store).map((s) => [s.global, s.why]))
expect(home.Keys).toBeUndefined()
expect(home.Servers).toBeUndefined()
expect(home.SQL).toContain('sql-workbench')
expect(home.Bulk).toContain('table grid')
expect(home.Views).toContain('table grid')
expect(home.Copy).toBeDefined()
expect(home.Export).toContain('table grid')
expect(home.History).toBeUndefined()
expect(home.Assistant).toContain('assistant extension')
// Shift+F3 in the xterm form without the stdin rewrite (main.tsx installs it): OpenTUI takes it for a cursor report
await raw(`${E}[1;2R`)
expect(h.store.overlays.length).toBe(0)
await press(h, () => h!.setup.mockInput.pressKey('F10', { shift: true }))
expect(h.store.flashText?.text).toContain('Shift+F10 Assistant: needs the assistant extension')
expect(h.store.assistantOpen).toBe(false)
await command(h, 'table query person')
await waitFor(h, (f) => f.includes('First1 '))
const grid = Object.fromEntries(shiftSlots(h.store).map((s) => [s.global, s.why]))
expect(grid.Views).toBeUndefined()
expect(grid.Export).toBeUndefined()
// person has no TABLE_INSERT capability in the fake: Copy is dimmed and says so
expect(grid.Copy).toBe('copying needs permission to create records')
await press(h, () => h!.setup.mockInput.pressKey('F7', { shift: true }))
expect(h.store.flashText?.text).toBe('Shift+F7 Copy: copying needs permission to create records')
expect(h.store.screen?.kind).toBe('table')
})
// ---- dispatch ----
test('dispatch on a grid: Shift+F8 export, Shift+F6 saved views, Shift+F4 bulk, Shift+F7 copy, Shift+F5 metadata, Shift+F1 keys', async () => {
h = await start(120, 40)
await command(h, 'table query person')
await waitFor(h, (f) => f.includes('First1 '))
await press(h, () => h!.setup.mockInput.pressKey('F8', { shift: true }))
expect((h.store.overlays[0] as unknown as { title: string }).title).toBe('Export to a file')
await press(h, () => h!.setup.mockInput.pressEscape())
await press(h, () => h!.setup.mockInput.pressKey('F6', { shift: true }))
await waitFor(h, (f) => f.includes('Saved views'))
await press(h, () => h!.setup.mockInput.pressEscape())
h.store.overlays = []
await raw(`${E}[1;2S`) // Shift+F4
const bulk = h.store.overlays[0] as unknown as { title?: string } | undefined
if (bulk) expect(bulk.title).toContain('Run on')
else expect(h.store.flashText?.text).toContain('Shift+F4 Bulk')
h.store.overlays = []
// with permission to create records, Shift+F7 copies the row under the cursor into a new-record form
const meta = (h.store.screen as unknown as { meta: { capabilities: string[] } }).meta
meta.capabilities = [...meta.capabilities, 'TABLE_INSERT']
await press(h, () => h!.setup.mockInput.pressKey('F7', { shift: true }))
await waitFor(h, () => h!.store.screen?.kind === 'form')
await press(h, () => h!.setup.mockInput.pressEscape())
await waitFor(h, () => h!.store.screen?.kind === 'table')
const before = h.log.requests.filter((r) => r.path === '/metaData').length
await press(h, () => h!.setup.mockInput.pressKey('F5', { shift: true }))
await waitFor(h, () => h!.store.flashText?.text === 'metadata refreshed')
expect(h.log.requests.filter((r) => r.path === '/metaData').length).toBe(before + 1)
await raw(`${E}[1;2P`) // Shift+F1
expect(h.store.overlays[0]!.constructor.name).toBe('HelpOverlay')
})
test('plain F-keys are unchanged; Ctrl+F5 still refreshes like F5; Shift+F11 does nothing', async () => {
h = await start(120, 40)
await raw(`${E}OQ`) // F2
expect(h.store.screen?.kind).toBe('search')
await press(h, () => h!.setup.mockInput.pressEscape())
await press(h, () => h!.setup.mockInput.pressKey('F11', { shift: true }))
expect(h.store.flashText?.text).toContain('Shift+F11 does nothing')
await press(h, () => h!.setup.mockInput.pressKey('F5', { ctrl: true }))
expect(h.store.overlays.length).toBe(0)
})
test('the rewrite on the terminal input: Shift+F3 (CSI 1;2R) opens the SQL Runner; legacy CSI 26~ is Shift+F2', async () => {
const log: FakeLog = { requests: [] }
h = await start(120, 40, undefined, sqlFakeFetch(log, true, { executions: [], notebooks: [], rowsCalls: [] }))
let now = 0
const rewrite = installFKeyRewrite(h.setup.renderer.stdin, 'vt220', () => now)
// during the startup probe CSI 1;2R is a cursor report: nothing happens
await raw(`${E}[1;2R`)
expect(h.store.screen?.kind).toBe('home')
// past it, the same bytes are Shift+F3
now += CPR_QUIET_MS
try {
expect(shiftSlots(h.store).find((s) => s.n === 3)!.why).toBeUndefined()
await raw(`${E}[1;2R`)
await waitFor(h, () => h!.store.screen instanceof SqlRunnerScreen)
await press(h, () => h!.setup.mockInput.pressEscape())
h.store.overlays = []
await raw(`${E}[26~`)
expect(h.store.overlays[0]!.constructor.name).toBe('ServersOverlay')
} finally {
rewrite.dispose()
}
})
test('Shift+F10 toggles the Assistant (the same named action as Ctrl+G)', async () => {
h = await start(120, 40, undefined, assistantFetch())
await press(h, () => h!.setup.mockInput.pressKey('F10', { shift: true }))
expect(h.store.assistantOpen).toBe(true)
expect(h.store.focus).toBe('assistant')
// the panel has the focus: the same key goes on toggling (Shift+F keys are global)
await press(h, () => h!.setup.mockInput.pressKey('F10', { shift: true }))
expect(h.store.focus === 'content' || !h.store.assistantOpen).toBe(true)
})
// ---- the mouse ----
test('mouse: a click on the Shift row runs that key; in one-row mode while the Shift row shows, too', async () => {
h = await start(120, 40)
const y = 39
const shiftLine = last(h.frame())[0]!
const x = shiftLine.indexOf('2Servers') + 2
// the Shift row has as many slots as the primary row (12 from 120 columns)
expect(fkeyAt(x, 120, h.store.fkeys().map((k) => k.n))).toBe(2)
await press(h, () => h!.setup.mockMouse.click(x, y))
expect(h.store.overlays[0]!.constructor.name).toBe('ServersOverlay')
await press(h, () => h!.setup.mockInput.pressEscape())
h.store.overlays = []
// the primary row above still presses F1
await press(h, () => h!.setup.mockMouse.click(3, y - 1))
expect(h.store.overlays[0]!.constructor.name).toBe('HelpOverlay')
await press(h, () => h!.setup.mockInput.pressEscape())
h.store.overlays = []
h.store.showShiftRow()
const f = await mode('one')
const row = last(f)[0]!
expect(row).toStartWith('⇧1Keys')
await press(h, () => h!.setup.mockMouse.click(row.indexOf('9History') + 2, 39))
await waitFor(h, () => h!.store.screen?.kind === 'history')
})
// ---- help and settings ----
test('? lists the Shift bank; :fkeys and Settings › F-key bar switch and save the mode', async () => {
h = await start(120, 40)
h.store.commands().help()
const help = h.store.overlays[0] as unknown as { after: Array<{ title: string; keys: Array<[string, string]> }> }
expect(help.after[0]!.title).toStartWith('Shift bank')
expect(help.after[0]!.keys.map(([k]) => k)).toEqual(SHIFT_BANK.map((k) => `Shift+F${k.n}`))
expect(help.after[0]!.keys[2]![1]).toContain('not here')
h.store.overlays = []
await command(h, 'fkeys one')
expect(h.store.settings.fkeyBar).toBe('one')
expect(readConfig().ui?.fkeyBar).toBe('one')
const f = await press(h, () => undefined)
expect(f).not.toContain('⇧1Keys')
await command(h, 'fkeys sideways')
expect(h.store.flashText?.text).toContain('fkeys one|two|auto')
const entry = (h.store.commandsImpl!(h.store) as unknown as { entries(t: string): Array<{ id?: string; label: string; run?: () => void }> }).entries('').find((e) => e.id === 'settings:fkeys')
expect(entry?.label).toBe('F-key bar: one')
entry!.run!()
expect((h.store.overlays[0] as unknown as { title: string }).title).toBe('F-key bar')
await press(h, () => h!.setup.mockInput.pressEscape())
expect(h.store.overlays.length).toBe(0)
// (the : line keeps a failed command for editing, so this one runs directly)
await press(h, () => h!.store.commands().run('fkeys auto'))
expect(readConfig().ui?.fkeyBar).toBe('auto')
})
-274
View File
@@ -1,274 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/** The start-up splash and profile picker: the boot plan, the picker (preselect, Enter, Esc), the add form, timing, ASCII, failures. */
import { afterEach, beforeAll, expect, test } from 'bun:test'
import { testRender } from '@opentui/react/test-utils'
import { readConfig, writeConfig, type Config } from '@qrun-io/qqq-core'
import { App } from '../src/app'
import { boot, parseArgs } from '../src/boot'
import { PICK_HINT, SPLASH_MIN_MS, lastUsedProfile, planBoot, readSplash, splashEnabled, splashWait, writeSplash } from '../src/boot-plan'
import { ProfileFormOverlay, ServersOverlay } from '../src/overlays/servers'
import { SplashScreen, splashLayout, splashText, versionLine, type SplashLayoutInput } from '../src/screens/splash'
import { fakeFetch, type FakeLog } from './fake-server'
import { command, isolate, press, sessionSecrets, waitFor, type Harness } from './helpers'
import { querySize } from '../src/saver/query-art'
import { setAscii } from '../src/theme'
import { cellWidth } from '../src/util/text'
let h: Harness | undefined
let exits: string[] = []
let quits = 0
beforeAll(() => isolate())
afterEach(() => {
h?.store.dispose()
h?.setup.renderer.destroy()
h = undefined
exits = []
quits = 0
setAscii(false)
})
const hour = 3_600_000
function profiles(map: Record<string, { server: string; lastUsedAt?: string }>, defaultProfile?: string): void {
writeConfig({ profiles: map, ...(defaultProfile ? { defaultProfile } : {}) } as Config)
}
function two(): void {
profiles({
alpha: { server: 'http://alpha.test', lastUsedAt: new Date(Date.now() - 5 * hour).toISOString() },
beta: { server: 'http://beta.test', lastUsedAt: new Date(Date.now() - hour).toISOString() },
}, 'alpha')
}
async function start(argv: string[], options: { width?: number; height?: number; minMs?: number; fetch?: typeof fetch; log?: FakeLog } = {}): Promise<Harness> {
const log = options.log ?? { requests: [] }
const store = boot({ args: parseArgs(argv, {}), quit: () => { quits++ }, build: 'test', secrets: sessionSecrets, fetch: options.fetch ?? fakeFetch(log), splashMinMs: options.minMs, hint: (t) => exits.push(t) })
const setup = await testRender(<App store={store} />, { width: options.width ?? 120, height: options.height ?? 40 })
const harness: Harness = { setup, store, log, frame: () => setup.captureCharFrame() }
store.captureScreen = () => setup.captureCharFrame()
return harness
}
test('the boot plan: a named profile or the only one connects; several ask (last used first); none adds', () => {
const at = (n: number) => new Date(Date.now() - n * hour).toISOString()
const several: Config = { profiles: { alpha: { server: 'http://a.test', lastUsedAt: at(5) }, beta: { server: 'http://b.test', lastUsedAt: at(1) }, gamma: { server: 'http://c.test' } }, defaultProfile: 'alpha' } as Config
expect(planBoot({}, {}, several)).toEqual({ kind: 'pick', select: 'beta' })
expect(planBoot({ profile: 'gamma' }, {}, several)).toEqual({ kind: 'connect', target: { name: 'gamma', server: 'http://c.test' } })
expect(planBoot({}, { QQQ_PROFILE: 'alpha' }, several)).toEqual({ kind: 'connect', target: { name: 'alpha', server: 'http://a.test' } })
expect(planBoot({ server: 'http://b.test/x' }, {}, several)).toEqual({ kind: 'connect', target: { name: 'beta', server: 'http://b.test' } })
expect(planBoot({}, { QQQ_SERVER: 'http://new.test' }, several).kind).toBe('connect')
const one: Config = { profiles: { solo: { server: 'http://solo.test' } } } as Config
expect(planBoot({}, {}, one)).toEqual({ kind: 'connect', target: { name: 'solo', server: 'http://solo.test' } })
expect(planBoot({}, {}, { profiles: {} } as Config)).toEqual({ kind: 'add' })
// a name that does not exist: the picker (or the add form) with why
const typo = planBoot({ profile: 'nope' }, {}, several)
expect(typo.kind).toBe('pick')
expect(typo.kind === 'pick' && typo.note).toMatch(/No profile "nope"/)
expect(planBoot({ profile: 'nope' }, {}, { profiles: {} } as Config).kind).toBe('add')
// never used: the default, else the first
expect(lastUsedProfile({ profiles: { a: { server: 'x' }, b: { server: 'y' } }, defaultProfile: 'b' } as Config)).toBe('b')
expect(lastUsedProfile({ profiles: { a: { server: 'x' }, b: { server: 'y' } } } as Config)).toBe('a')
})
test('the splash switch: --no-splash, --splash, QQQ_UI_NO_SPLASH and the ui.splash setting', () => {
expect(parseArgs(['--no-splash']).splash).toBe(false)
expect(parseArgs(['--splash']).splash).toBe(true)
expect(parseArgs([]).splash).toBeUndefined()
expect(splashEnabled({ splash: false })).toBe(false)
expect(splashEnabled({}, { ...process.env, QQQ_UI_NO_SPLASH: '1' })).toBe(false)
expect(readSplash()).toBe(true)
writeSplash(false)
expect(readConfig().ui).toMatchObject({ splash: 'off' })
expect(splashEnabled({})).toBe(false)
expect(splashEnabled({ splash: true })).toBe(true)
writeSplash(true)
expect(splashEnabled({})).toBe(true)
})
test('the minimum display time: never under 600 ms, nothing more once it has been up that long', async () => {
expect(SPLASH_MIN_MS).toBe(600)
expect(splashWait(1000, 1100)).toBe(500)
expect(splashWait(1000, 1700)).toBe(0)
profiles({ fake: { server: 'http://fake.test' } })
const began = Date.now()
h = await start(['--profile', 'fake'], { minMs: 600 })
await waitFor(h, () => h!.store.screen?.kind === 'splash')
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
expect(Date.now() - began).toBeGreaterThanOrEqual(600)
})
test('one profile: the splash shows Query, the wordmark, the version and the boot steps, then Home', async () => {
profiles({ fake: { server: 'http://fake.test' } })
h = await start([], { minMs: 400 })
const f = await waitFor(h, (x) => x.includes('Terminal client for QQQ'))
expect(h.store.screen?.kind).toBe('splash')
expect(f).toContain(versionLine())
expect(f).toMatch(/qqq \d+\.\d+\.\d+ · UI API client \d+\.\d+\.\d+/)
expect(f).toContain('█')
// no title bar, status line or key bar under the splash
expect(f).not.toMatch(/10\s*Quit/)
const steps = h.store.screen as SplashScreen
await waitFor(h, () => /loaded metadata \(\d+ tables\)/.test(steps.status))
await waitFor(h, (x) => h!.store.screen?.kind === 'home' && x.includes('Apps'), 4000)
expect(h.store.overlays.length).toBe(0)
})
test('several profiles: the picker over the splash, the last used preselected; Enter connects to it', async () => {
two()
h = await start([], { width: 120, height: 40 })
const f = await waitFor(h, (x) => x.includes('Connect to a server'))
const picker = h.store.overlays.at(-1) as ServersOverlay
expect(picker).toBeInstanceOf(ServersOverlay)
expect(picker.selected()?.name).toBe('beta')
expect(f).toContain('Enter connects')
expect(f).toContain('Esc quits')
// the splash header stays visible above the picker
expect(f).toContain('Terminal client for QQQ')
// probes fill in on their own (the picker does not wait for them)
await waitFor(h, (x) => x.includes('UI API'))
await press(h, () => h!.setup.mockInput.pressEnter())
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
expect(h.store.target?.name).toBe('beta')
})
test('several profiles: Esc on the picker quits with the --profile hint', async () => {
two()
h = await start([])
await waitFor(h, (x) => x.includes('Connect to a server'))
await press(h, () => h!.setup.mockInput.pressEscape())
expect(quits).toBe(1)
expect(exits).toEqual([PICK_HINT])
expect(PICK_HINT).toContain('--profile')
})
test('--profile skips the picker even with several profiles', async () => {
two()
h = await start(['--profile', 'alpha'])
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
expect(h.store.target?.name).toBe('alpha')
})
test('no profiles: the add form opens straight away; saving connects to the new profile', async () => {
writeConfig({ profiles: {} } as Config)
h = await start([])
await waitFor(h, (x) => x.includes('Add a server profile'))
const form = h.store.overlays.at(-1) as ProfileFormOverlay
expect(form).toBeInstanceOf(ProfileFormOverlay)
form.editors.name.set('local')
form.editors.server.set('http://fake.test')
await press(h, () => form.save(), 50)
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
expect(h.store.target).toEqual({ name: 'local', server: 'http://fake.test' })
})
test('no profiles, add form closed: the splash says n adds one and Esc quits', async () => {
writeConfig({ profiles: {} } as Config)
h = await start([], { width: 80, height: 24 })
await waitFor(h, (x) => x.includes('Add a server profile'))
const f = await press(h, () => h!.setup.mockInput.pressEscape())
expect(f).toContain('n adds a server profile')
await press(h, () => h!.setup.mockInput.pressKey('n'))
expect(h.store.overlays.at(-1)).toBeInstanceOf(ProfileFormOverlay)
await press(h, () => h!.setup.mockInput.pressEscape())
await press(h, () => h!.setup.mockInput.pressEscape())
expect(quits).toBe(1)
})
test('--no-splash: the sign-in screen as before, then Home; with several profiles the picker opens over it', async () => {
profiles({ fake: { server: 'http://fake.test' } })
h = await start(['--no-splash'])
expect(h.store.routes.some((r) => r.kind === 'splash')).toBe(false)
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
h.store.dispose()
h.setup.renderer.destroy()
two()
h = await start(['--no-splash'])
await waitFor(h, (x) => x.includes('Connect to a server'))
expect(h.store.screen?.kind).toBe('signin')
expect((h.store.overlays.at(-1) as ServersOverlay).selected()?.name).toBe('beta')
})
test('a failure stays on the splash with retry and choose-another; c opens the picker, Esc goes back to it', async () => {
profiles({ fake: { server: 'http://fake.test' }, other: { server: 'http://other.test' } })
let down = true
const ok = fakeFetch()
const flaky = (async (input: RequestInfo | URL, init?: RequestInit) => {
if (down) throw new Error('connection refused')
return ok(input, init)
}) as typeof fetch
h = await start(['--profile', 'fake'], { width: 80, height: 24, fetch: flaky })
let f = await waitFor(h, (x) => x.includes('Enter retries'))
expect(f).toContain('connection refused')
expect(f).toContain('c chooses another profile')
await press(h, () => h!.setup.mockInput.pressKey('c'))
f = await waitFor(h, (x) => x.includes('Connect to a server'))
expect(f).toContain('Esc goes back')
await press(h, () => h!.setup.mockInput.pressEscape())
expect(quits).toBe(0)
f = await waitFor(h, (x) => x.includes('Enter retries'))
down = false
await press(h, () => h!.setup.mockInput.pressEnter())
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
})
test('any key stops the animation, not the boot', async () => {
profiles({ fake: { server: 'http://fake.test' } })
h = await start([], { minMs: 300 })
const splash = h.store.screen as SplashScreen
splash.animate = true
await press(h, () => h!.setup.mockInput.pressKey('x'))
expect(splash.animate).toBe(false)
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
})
function input(ascii: boolean, anchor: 'center' | 'top' = 'center'): SplashLayoutInput {
return { ascii, pose: { blink: false, wag: false }, status: { text: 'connecting to localhost:8078…', tone: 'status' }, extra: [], keys: '', anchor }
}
test('the layout fits 80×24 (small Query beside the wordmark) and 120×40 (large Query); every line clipped', () => {
for (const [w, hgt] of [[80, 24], [120, 40], [40, 12], [30, 8], [20, 5]] as const) {
for (const ascii of [false, true]) {
setAscii(ascii)
const lines = splashLayout(w, hgt, input(ascii))
expect(lines.length).toBeLessThanOrEqual(hgt)
for (const l of lines) expect(cellWidth(l.map((s) => s.text).join(''))).toBeLessThanOrEqual(w)
}
}
setAscii(false)
const small = splashText(splashLayout(80, 24, input(false)))
expect(small).toContain('Terminal client for QQQ')
expect(small).toContain('connecting to localhost:8078…')
const large = splashLayout(120, 40, input(false))
expect(large.filter((l) => l.some((s) => s.tone === 'art')).length).toBe(querySize('large').height)
const header = splashLayout(80, 24, input(false, 'top'))
expect(splashText(header)).not.toContain('connecting')
expect(header.length).toBeLessThanOrEqual(querySize('small').height + 2)
})
test('ASCII: no braille or block characters, the wordmark in #', () => {
setAscii(true)
for (const [w, hgt] of [[80, 24], [120, 40]] as const) {
const text = splashText(splashLayout(w, hgt, input(true)))
expect(text).not.toMatch(/[⠀-⣿█]/)
expect(text).toContain('####')
expect(text).toContain('Terminal client for QQQ')
expect(text).toMatch(/^[\x20-\x7e\n]*$/)
expect(text).toContain('qqq')
}
})
test(':splash off and on save the setting (Settings > Splash on start)', async () => {
profiles({ fake: { server: 'http://fake.test' } })
h = await start([])
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
await command(h, 'splash off')
expect(readSplash()).toBe(false)
expect(h.store.flashText?.text).toContain('splash off')
await command(h, 'splash on')
expect(readSplash()).toBe(true)
})
+2 -3
View File
@@ -14,8 +14,7 @@
# 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 ~/Downloads, as downloads and exports;
# the agent's own default too); --dir prints the folder in use
# QQQ_PRINT_OUT where printed documents go (default <state>/printed)
# 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"
@@ -32,7 +31,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:-$HOME/Downloads}"
out_dir="${QQQ_PRINT_OUT:-$state_dir/printed}"
pid_file="$state_dir/agent.pid"
log_file="$state_dir/agent.log"
identity="$state_dir/device.json"
+20 -63
View File
@@ -10,79 +10,36 @@
* Run it on the binaries that were uploaded to the GitHub release, not on a local rebuild: the
* commit is stamped into each binary, so every build has different checksums.
*
* Refuses to write anything (exit 1, both files untouched) when a sum is missing, is not 64 lowercase hex digits,
* is all zeros, or when a placeholder in the files is left unfilled.
*
* Usage: node scripts/update-packaging.mjs [dist/SHA256SUMS]
*/
import { readFileSync, realpathSync, writeFileSync } from 'node:fs'
import { readFileSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
const HEX64 = /^[0-9a-f]{64}$/
const root = resolve(import.meta.dirname, '..')
const FORMULA_SUMS = /(url "[^"]*\/(qqq-[a-z0-9-]+)"\n\s*sha256 ")([0-9a-f]{64})"/g
const sumsPath = resolve(process.argv[2] ?? join(root, 'dist/SHA256SUMS'))
const version = JSON.parse(readFileSync(join(root, 'packages/cli/package.json'), 'utf8')).version
const sums = new Map(readFileSync(sumsPath, 'utf8').trim().split('\n').map((line) => line.split(/\s+/).reverse()))
/** Parses `shasum -a 256` output (`<hash> <file>`, `*` before the name tolerated) into a Map of file to hash. */
export function parseSums(text) {
const sums = new Map()
for (const line of text.split('\n')) {
if (line.trim() === '') continue
const match = line.match(/^(\S+)\s+\*?(\S+)$/)
if (!match) throw new Error(`unparseable SHA256SUMS line: ${JSON.stringify(line)}`)
sums.set(match[2], match[1].toLowerCase())
}
return sums
}
function sumFor(sums, file) {
function sum(file) {
const hash = sums.get(file)
if (hash === undefined) throw new Error(`SHA256SUMS has no ${file}`)
if (!HEX64.test(hash)) throw new Error(`SHA256SUMS has a malformed sum for ${file}: ${JSON.stringify(hash)}`)
if (/^0+$/.test(hash)) throw new Error(`SHA256SUMS has an all-zero sum for ${file}`)
if (!hash) {
console.error(`update-packaging: ${sumsPath} has no ${file}`)
process.exit(1)
}
return hash
}
/** Returns the new text of both packaging files; throws, leaving the caller nothing to write, on any problem. */
export function updatePackaging({ version, sums, formula, manifest }) {
let formulaCount = 0
const newFormula = formula
.replace(/^(\s*version ")[^"]+"/m, `$1${version}"`)
.replace(FORMULA_SUMS, (_, head, file) => {
formulaCount++
return `${head}${sumFor(sums, file)}"`
})
if (formulaCount === 0) throw new Error('the formula has no url/sha256 pairs to fill')
const left = newFormula.match(/sha256 "(?![0-9a-f]{64}")[^"]*"/g)
if (left || /sha256 "0{64}"/.test(newFormula)) throw new Error(`the formula still has unfilled sha256 values: ${left ?? 'zeros'}`)
const formulaPath = join(root, 'docs/packaging/homebrew-tap/Formula/qqq.rb')
let formula = readFileSync(formulaPath, 'utf8').replace(/^(\s*version ")[^"]+"/m, `$1${version}"`)
formula = formula.replace(/(url "[^"]*\/(qqq-[a-z0-9-]+)"\n\s*sha256 ")[0-9a-f]{64}"/g, (_, head, file) => `${head}${sum(file)}"`)
writeFileSync(formulaPath, formula)
const parsed = JSON.parse(manifest)
parsed.version = version
parsed.architecture['64bit'].url = `https://github.com/QRun-IO/qqq-cli/releases/download/cli-v${version}/qqq-windows-x64.exe`
parsed.architecture['64bit'].hash = sumFor(sums, 'qqq-windows-x64.exe')
return { formula: newFormula, manifest: JSON.stringify(parsed, null, 4) + '\n', formulaCount }
}
const manifestPath = join(root, 'docs/packaging/qqq.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
manifest.version = version
manifest.architecture['64bit'].url = `https://github.com/QRun-IO/qqq-cli/releases/download/cli-v${version}/qqq-windows-x64.exe`
manifest.architecture['64bit'].hash = sum('qqq-windows-x64.exe')
writeFileSync(manifestPath, JSON.stringify(manifest, null, 4) + '\n')
function main() {
const sumsPath = resolve(process.argv[2] ?? join(root, 'dist/SHA256SUMS'))
const version = JSON.parse(readFileSync(join(root, 'packages/cli/package.json'), 'utf8')).version
const formulaPath = join(root, 'docs/packaging/homebrew-tap/Formula/qqq.rb')
const manifestPath = join(root, 'docs/packaging/qqq.json')
try {
const out = updatePackaging({
version,
sums: parseSums(readFileSync(sumsPath, 'utf8')),
formula: readFileSync(formulaPath, 'utf8'),
manifest: readFileSync(manifestPath, 'utf8'),
})
writeFileSync(formulaPath, out.formula)
writeFileSync(manifestPath, out.manifest)
console.log(`docs/packaging/homebrew-tap/Formula/qqq.rb (${out.formulaCount} sums) and docs/packaging/qqq.json now name ${version} with the sums from ${sumsPath}`)
} catch (error) {
console.error(`update-packaging: ${error.message}; nothing was written`)
process.exit(1)
}
}
// Run only as a script, not when the tests import it (realpath: argv[1] may go through a symlink such as macOS /var).
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(import.meta.filename)) main()
console.log(`docs/packaging/homebrew-tap/Formula/qqq.rb and docs/packaging/qqq.json now name ${version} with the sums from ${sumsPath}`)
-73
View File
@@ -1,73 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/** Tests for scripts/update-packaging.mjs. Run: node --test scripts/update-packaging.test.mjs */
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { cpSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { parseSums, updatePackaging } from './update-packaging.mjs'
const ZERO = '0'.repeat(64)
const h = (c) => c.repeat(64)
const FILES = ['qqq-darwin-arm64', 'qqq-darwin-x64', 'qqq-linux-arm64', 'qqq-linux-x64']
const sumsText = (omit = []) =>
[...FILES, 'qqq-linux-x64-musl', 'qqq-windows-x64.exe'].filter((f) => !omit.includes(f)).map((f, i) => `${'abcdef0123456'[i].repeat(64)} ${f}`).join('\n') + '\n'
const formula = `class Qqq < Formula
version "0.0.0"
${FILES.map((f) => ` url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/${f}"\n sha256 "${ZERO}"`).join('\n')}
end
`
const manifest = JSON.stringify({ version: '0.0.0', architecture: { '64bit': { url: 'x', hash: ZERO } } }, null, 4) + '\n'
test('fills every sum from shasum output and never leaves zeros', () => {
const out = updatePackaging({ version: '1.0.0', sums: parseSums(sumsText()), formula, manifest })
assert.equal(out.formulaCount, 4)
assert.ok(!out.formula.includes(ZERO) && !out.manifest.includes(ZERO))
assert.match(out.formula, /version "1\.0\.0"/)
assert.ok(out.formula.includes(`qqq-darwin-arm64"\n sha256 "${h('a')}"`))
assert.equal(JSON.parse(out.manifest).architecture['64bit'].hash, h('f'))
})
test('a missing target throws', () => {
assert.throws(() => updatePackaging({ version: '1.0.0', sums: parseSums(sumsText(['qqq-linux-x64'])), formula, manifest }), /no qqq-linux-x64/)
assert.throws(() => updatePackaging({ version: '1.0.0', sums: parseSums(sumsText(['qqq-windows-x64.exe'])), formula, manifest }), /no qqq-windows-x64\.exe/)
})
test('malformed, short, uppercase-free or zero sums throw', () => {
for (const bad of ['abc', ZERO, 'g'.repeat(64)]) {
const sums = parseSums(sumsText().replace(h('a'), bad))
assert.throws(() => updatePackaging({ version: '1.0.0', sums, formula, manifest }), /malformed|all-zero/)
}
assert.throws(() => parseSums('only-one-column\n'), /unparseable/)
})
test('a formula with nothing to fill throws', () => {
assert.throws(() => updatePackaging({ version: '1.0.0', sums: parseSums(sumsText()), formula: 'class Qqq < Formula\nend\n', manifest }), /no url\/sha256/)
})
test('the CLI exits 1 and writes nothing when a sum is missing', () => {
const dir = mkdtempSync(join(tmpdir(), 'update-packaging-'))
mkdirSync(join(dir, 'scripts'))
cpSync(join(import.meta.dirname, 'update-packaging.mjs'), join(dir, 'scripts/update-packaging.mjs'))
mkdirSync(join(dir, 'packages/cli'), { recursive: true })
writeFileSync(join(dir, 'packages/cli/package.json'), '{"version":"1.0.0"}')
mkdirSync(join(dir, 'docs/packaging/homebrew-tap/Formula'), { recursive: true })
writeFileSync(join(dir, 'docs/packaging/homebrew-tap/Formula/qqq.rb'), formula)
writeFileSync(join(dir, 'docs/packaging/qqq.json'), manifest)
writeFileSync(join(dir, 'SHA256SUMS'), sumsText(['qqq-linux-arm64']))
const run = spawnSync(process.execPath, [join(dir, 'scripts/update-packaging.mjs'), join(dir, 'SHA256SUMS')], { encoding: 'utf8' })
assert.equal(run.status, 1)
assert.match(run.stderr, /nothing was written/)
assert.equal(readFileSync(join(dir, 'docs/packaging/homebrew-tap/Formula/qqq.rb'), 'utf8'), formula)
assert.equal(readFileSync(join(dir, 'docs/packaging/qqq.json'), 'utf8'), manifest)
writeFileSync(join(dir, 'SHA256SUMS'), sumsText())
const ok = spawnSync(process.execPath, [join(dir, 'scripts/update-packaging.mjs'), join(dir, 'SHA256SUMS')], { encoding: 'utf8' })
assert.equal(ok.status, 0, ok.stderr)
assert.ok(!readFileSync(join(dir, 'docs/packaging/qqq.json'), 'utf8').includes(ZERO))
})
-1
View File
@@ -4,7 +4,6 @@
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"types": ["node"],
"declaration": true,
"sourceMap": true,
"esModuleInterop": true,