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>
This commit is contained in:
james.maesandClaude Opus 5.5 committed 2026-10-31 18:37:51 -05:00
commit 47287020b4
77 files changed
+9250 -366

No files matched your search

+31 -42
View File
@@ -28,51 +28,37 @@ qqq-sample-server status | log | restart | stop
Data resets on every restart. Mock auth means no sign-in; `POST /qqq/v1/manageSession` gives a session.
## M0 shell (2026-10-31): classic TUI against the sample server
## TUI (`qqq ui`, M3)
```sh
qqq-tui-spike # the shell: app tree, tables, records, processes, live footer
qqq-tui-spike test # in-memory renderer tests
qqq ui # default profile; qqq --profile sample ui
qqq ui table query person -w "lastName STARTS_WITH S" # straight to a screen
qqq ui --check # terminal and tmux check with fixes (also runs quickly at startup)
npm run test:tui # in-memory renderer tests; packages/tui/scripts/smoke.sh drives it in tmux
```
Keys (the function-key bar at the bottom is the truth):
Keys (the function-key bar is the truth; F1 or `?` lists them; every action is also in F9 and on `:`):
| Key | Does | Key | Does |
|---|---|---|---|
| F1 | help | Esc | back a screen, or back to the Apps tree |
| F2 | go to any table or process by name | Tab | switch between the Apps tree and the content |
| F3 | view the record under the cursor | Enter | open (tree item, table row) |
| F4 | edit (M3) | ↑ ↓ PgUp PgDn Home End | move and page, server-side paging on tables |
| F5 | refresh | ← → | scroll columns; expand or collapse tree nodes |
| F6 | filter a table: `field OPERATOR value[,value]` | `s` | sort by the bracketed column |
| F7 | new record (M3) | `:` | command line: `table person lastName STARTS_WITH S`, `record pet 3`, `process sleepInteractive`, `home`, `quit` |
| F8 | delete the record under the cursor, with confirm | F9 | menu |
| F10 | quit | Ctrl+C | quit immediately |
| F1 `?` | help, what you can do here, help search | Esc | back; the grid keeps its row, filter and page |
| F2 `/` `.` Ctrl+K | go to anything (apps, tables, processes, recents, commands) | Tab | Apps pane / content |
| F3 Enter | view / open | PgUp PgDn, Ctrl+F Ctrl+B | page (Ctrl+F/B work even when tmux takes PgUp) |
| F4 `e` | edit | `]` `[` (PgDn PgUp in a record) | next / previous record of the list |
| F5 `r` | refresh | `s` | sort by the column under the cursor |
| F6 `f` | filter: guided builder or the grammar with completion | Space / `b` | select rows / run a process on them |
| F7 `n` | new (a child on a child list) | `x` `g` `v` | export, go to by key, view settings |
| F8 `d` | delete, asks | Ctrl+S | save a form or continue a process step |
| F9 | menu: every command, searchable | `:` | command line, history and completion |
| F10 | Quit, Reset to home, or Cancel (`:reset`) | Ctrl+C | quit now |
Footer status line, live: server, auth and session, UI API version, ping (every 5 s), realtime stream state
(reconnects on its own), job count (`n/a` when the server has no `/jobs`), last stream event, clock.
Things to try: `:table query person`, Enter on a row, PgDn through records, Esc (same row); F6, add a
condition, Tab to the grammar and type `AND (fi` then Tab; `:table create placeAddress` (country, state, city
narrow); Space on two people, `b`, Person Bulk Edit; `:app SampleWidgetsDashboard`; F9 Settings for themes,
download folder, PIN and the screen lock; `:theme light`. See `packages/tui/README.md` for settings and for the
keys your terminal or tmux may take (`qqq ui --check` finds them).
Things to try: open Person, PgDn through Pets or Cities, F6 with `birthDate GREATER_THAN 1990-01-01`,
delete a pet with F8 and watch the count, restart the server with `qqq-sample-server restart` and watch
the footer go to `reconnecting` and back.
## M0 legacy screens: generated grid and form
```sh
QQQ_SPIKE_SCREEN=legacy qqq-tui-spike # Ctrl+G grid, Ctrl+F form, Ctrl+P process, Ctrl+Q quit
qqq-tui-spike binary # the compiled macOS binary (legacy screens)
```
What to look at:
- Grid: 10,000 generated rows x 40 columns. Arrows, PageUp/Down, Home/End, Left/Right scroll columns,
`s` sorts the cursor column. The status line shows key-to-frame latency. Resize the window.
- Form: Tab between every field type, type into the Home State field to filter the picker, Enter to
pick, Ctrl+S validates and shows errors beside fields.
- Process: runs the sample's Sleep Interactive process on the server with an async job and polling.
Change the milliseconds, press Enter. Watch the log lines at the bottom.
Known: typing in the same instant as Tab (paste, automation) lands in the previous field; a human does
not hit this. Windows binary is cross-compiled and unverified.
The M0 spike (`qqq-tui-spike`, `packages/tui-spike`) stays in the repo for reference.
## CLI (`qqq`, M1 in progress)
@@ -182,22 +168,25 @@ unreachable, rate limited or a 502/503/504, 5 not found, 6 validation, 70 server
Downloads (`table download`, `table export`, `report run`, `widget get --csv`, process files) go where `--out` or
`--out-dir` says (`-` is stdout), else into the download directory when one is set, else the current directory;
a default name never replaces a file (`name-1.ext`). The setting lives in the `ui` section of
`~/.config/qqq/config.json`, which the TUI reads too:
a default name never replaces a file (`name-1.ext`). The settings live in the `ui` section of
`~/.config/qqq/config.json` and are the same ones the TUI's F9 Settings change:
```sh
qqq config set download-dir ~/Downloads/qqq # created when missing; an empty value removes it
qqq config get ; qqq config get download-dir # also upload-dir and download-prompt
qqq config get ; qqq config get download-dir # also upload-dir, download-mode (ask|silent, the TUI) and theme
qqq config set theme classic ; qqq -p sample config set theme light # globally, or for one profile
qqq table export person # wrote ~/Downloads/qqq/person.csv
```
Is everything in order? `qqq doctor` checks the profile, the server (reachable, UI API version within 1.6.0 to
1.8.0, authentication type, the sign-in accepted, extensions), the keyring and the metadata cache, and exits 1 with
what failed. `--conformance` adds the read-only conformance checks. The terminal checks (tmux keys, colour, size,
`--fix`) arrive with the TUI's kernel module; until then the report says `skip` for them.
what failed. `--conformance` adds the read-only conformance checks. The terminal rows come from the same check as
`qqq ui --check` (tmux bindings that take PageUp/PageDown and F-keys, alternate screen, colour, unicode, size);
`--fix` offers each live tmux fix (`--yes` answers those) and appends them to `~/.tmux.conf` only after a y typed at
the terminal.
```sh
qqq doctor ; qqq doctor --conformance
qqq doctor ; qqq doctor --conformance ; qqq doctor --fix
```
Contingent fields (field 1 narrows field 2) are handled by the kernel's form session, for tables and process
+164 -147
View File
@@ -3,7 +3,7 @@
Generated by `scripts/parity-matrix.py` from the Next UI acceptance matrix (`qqq-frontend-next/docs/acceptance/feature-matrix.md`,
401 rows). Same ids; two columns added. **pass** = verified against the local sample server in this repo's
development; **partial** = works with the noted gap; **todo** = planned; **n/a** = browser-only, with the reason.
The TUI column fills in during M3.
The TUI column is `qqq ui` (M3); its notes start with "TUI:".
## Summary (CLI column)
@@ -22,88 +22,105 @@ The TUI column fills in during M3.
| WID | 69 | 29 | 27 | 0 | 13 |
| **all** | 401 | 173 | 82 | 40 | 106 |
## Summary (TUI column)
| Family | rows | pass | todo | partial | n/a |
|---|---|---|---|---|---|
| HLP | 6 | 1 | 4 | 1 | 0 |
| INT | 14 | 9 | 0 | 1 | 4 |
| NAV | 40 | 15 | 1 | 7 | 17 |
| PRC | 60 | 21 | 30 | 3 | 6 |
| PRF | 5 | 3 | 2 | 0 | 0 |
| QRY | 70 | 23 | 38 | 4 | 5 |
| REC | 65 | 22 | 39 | 4 | 0 |
| REL | 9 | 4 | 3 | 2 | 0 |
| RPT | 20 | 0 | 20 | 0 | 0 |
| SEC | 43 | 7 | 23 | 1 | 12 |
| WID | 69 | 13 | 43 | 2 | 11 |
| **all** | 401 | 118 | 203 | 25 | 55 |
## Rows
| ID | Feature | CLI | TUI | Note |
|---|---|---|---|---|
| NAV-001 | Direct link to a table list renders seeded rows | n/a | n/a | browser shell |
| NAV-002 | Sidebar lists every permitted top-level app once, by label, in backend order, with nested apps only under thei | n/a | n/a | browser shell |
| NAV-003 | Apps nested deeper than two levels render as nested sidebar groups; deep links expand every enclosing group | n/a | n/a | browser shell |
| NAV-004 | Sidebar click navigation to app homes, tables and processes with active-entry highlighting and collapse/expand | n/a | n/a | browser shell |
| NAV-005 | Declared icons (QIcon name, color, path) render on navigation surfaces | n/a | n/a | browser shell |
| NAV-006 | Hidden tables and processes never appear in navigation; direct links to hidden objects the user may not access | pass | todo | hidden tables and processes are not listed; direct use is refused by the server |
| NAV-007 | A hidden table the user may access opens by direct link (hidden is not denied) | pass | todo | qqq table get on a hidden table the user may read |
| NAV-008 | App home sections list processes (Actions), reports (Reports) and tables (Data) by label with icons, and each | pass | todo | qqq apps show <app>: sections of processes, reports, tables and child apps, then widgets |
| NAV-009 | App home table entries show the backend record count | pass | todo | qqq apps show counts each table with TABLE_COUNT (--no-counts skips) |
| NAV-010 | App homes of apps with child apps, only processes, or nothing render without errors | n/a | todo | app home rendering |
| NAV-011 | A dashboard app (widgets only) opens from navigation and shows one labelled container per permitted declared w | todo | todo | widgets per app |
| NAV-012 | Branding: sidebar logo (Material parity), app name, favicon from the icon, accent color, and partial branding | n/a | n/a | browser shell |
| NAV-001 | Direct link to a table list renders seeded rows | n/a | pass | browser shell; TUI: qqq ui table query <t>; :table query <t> |
| NAV-002 | Sidebar lists every permitted top-level app once, by label, in backend order, with nested apps only under thei | n/a | pass | browser shell; TUI: Apps tree, backend order |
| NAV-003 | Apps nested deeper than two levels render as nested sidebar groups; deep links expand every enclosing group | n/a | pass | browser shell; TUI: nested apps in the tree |
| NAV-004 | Sidebar click navigation to app homes, tables and processes with active-entry highlighting and collapse/expand | n/a | pass | browser shell; TUI: Enter opens; current entry marked; arrows expand and collapse |
| NAV-005 | Declared icons (QIcon name, color, path) render on navigation surfaces | n/a | partial | browser shell; TUI: markers for processes and reports; icons not drawn |
| NAV-006 | Hidden tables and processes never appear in navigation; direct links to hidden objects the user may not access | pass | pass | hidden tables and processes are not listed; direct use is refused by the server; TUI: hidden tables and processes are not listed |
| NAV-007 | A hidden table the user may access opens by direct link (hidden is not denied) | pass | pass | qqq table get on a hidden table the user may read; TUI: :table query and :record open hidden tables the user may read |
| NAV-008 | App home sections list processes (Actions), reports (Reports) and tables (Data) by label with icons, and each | pass | pass | qqq apps show <app>: sections of processes, reports, tables and child apps, then widgets; TUI: app home: sections with tables, processes, reports |
| NAV-009 | App home table entries show the backend record count | pass | pass | qqq apps show counts each table with TABLE_COUNT (--no-counts skips); TUI: record counts beside tables |
| NAV-010 | App homes of apps with child apps, only processes, or nothing render without errors | n/a | pass | app home rendering |
| NAV-011 | A dashboard app (widgets only) opens from navigation and shows one labelled container per permitted declared w | todo | pass | widgets per app; TUI: dashboard apps draw their widgets |
| NAV-012 | Branding: sidebar logo (Material parity), app name, favicon from the icon, accent color, and partial branding | n/a | partial | browser shell; TUI: app name in the title bar |
| NAV-013 | Branding banners render in their slots (top of site, top of body, side nav under logo) with text or sanitized | n/a | n/a | browser shell |
| NAV-014 | Breadcrumbs show the full app hierarchy for tables, processes, records, create and edit pages, and each crumb | n/a | n/a | browser shell |
| NAV-014 | Breadcrumbs show the full app hierarchy for tables, processes, records, create and edit pages, and each crumb | n/a | pass | browser shell; TUI: title-bar path: apps, table, record or form |
| NAV-015 | Document title names the page, its enclosing apps and the application (Material parity) | n/a | n/a | browser shell |
| NAV-016 | The site root redirects to the dashboard | n/a | n/a | browser shell |
| NAV-017 | Dashboard landing page: counts, quick actions and application cards derived from navigable, permitted metadata | todo | todo | whoami shows counts; dashboard landing is the TUI |
| NAV-018 | Direct (hard-loaded) links open every route shape with the shell, sidebar state and breadcrumbs | n/a | n/a | browser shell |
| NAV-017 | Dashboard landing page: counts, quick actions and application cards derived from navigable, permitted metadata | todo | partial | whoami shows counts; dashboard landing is the TUI; TUI: home: counts and app list; no quick actions |
| NAV-018 | Direct (hard-loaded) links open every route shape with the shell, sidebar state and breadcrumbs | n/a | pass | browser shell; TUI: qqq ui <route in the CLI grammar> |
| NAV-019 | Browser refresh keeps the route, its query string and the rendered content | n/a | n/a | browser shell |
| NAV-020 | Browser back and forward traverse navigation history with the right content | n/a | n/a | browser shell |
| NAV-021 | An unknown /app/{name} shows an in-shell not-found state with a link to the dashboard | n/a | n/a | browser shell |
| NAV-022 | Unknown deeper or top-level paths return the 404 page with a working home link | n/a | n/a | browser shell |
| NAV-023 | Record lookup by key: /app/{table}/key?{field}={value} opens the single matching record | pass | todo | qqq table get <t> --key field=value: one match shows the record, none exits 5, several exit 2 |
| NAV-024 | Command palette (Ctrl+K and '.') lists navigable apps, tables and processes by label with type and app context | todo | todo | shell completion covers names; a palette is the TUI |
| NAV-025 | Header search, '/' search dialog and search page jump to navigation targets and recently viewed records by lab | pass | todo | qqq search |
| NAV-023 | Record lookup by key: /app/{table}/key?{field}={value} opens the single matching record | pass | pass | qqq table get <t> --key field=value: one match shows the record, none exits 5, several exit 2; TUI: g: primary key or a configured unique key (kernel gotoKeys) |
| NAV-024 | Command palette (Ctrl+K and '.') lists navigable apps, tables and processes by label with type and app context | todo | pass | shell completion covers names; a palette is the TUI; TUI: F2, /, ., Ctrl+K palette with type and app context |
| NAV-025 | Header search, '/' search dialog and search page jump to navigation targets and recently viewed records by lab | pass | partial | qqq search; TUI: palette lists recents; no search page |
| NAV-026 | Mobile navigation drawer opens from the header, navigates and closes | n/a | n/a | browser shell |
| NAV-027 | Developer page reports the backend metadata counts and the running framework versions | pass | todo | qqq whoami: apiVersion, features, counts |
| NAV-028 | Reports in the app tree are listed under their app (sidebar and app home Reports group) and open the report pa | partial | todo | qqq report list (reports with their app); the sample app has no reports to verify |
| NAV-027 | Developer page reports the backend metadata counts and the running framework versions | pass | partial | qqq whoami: apiVersion, features, counts; TUI: F9 Connection: versions, features, extensions, metadata generation |
| NAV-028 | Reports in the app tree are listed under their app (sidebar and app home Reports group) and open the report pa | partial | partial | qqq report list (reports with their app); the sample app has no reports to verify; TUI: reports listed; run through :report run |
| NAV-029 | A user who may access no apps sees the Dashboard entry and the no-apps message (Material NoApps parity) | n/a | n/a | browser shell |
| NAV-030 | Global search (header box, '/' dialog, search page) lists the records a backend record search (POST /qqq/v1/se | pass | todo | qqq search over POST /search |
| NAV-030 | Global search (header box, '/' dialog, search page) lists the records a backend record search (POST /qqq/v1/se | pass | partial | qqq search over POST /search; TUI: palette calls POST /search; the sample has no provider |
| NAV-031 | Record search respects table read permissions and record security locks, in the UI and the API | pass | todo | server-enforced; refusals shown |
| NAV-032 | Without searchable tables (the backend declares no searchFields) global search stays local and never calls a s | n/a | n/a | browser shell |
| NAV-033 | Login branding before sign-in: logo, app name, accent color, favicon and title from GET /qqq/v1/metaData/authe | n/a | n/a | browser shell |
| NAV-034 | Material table-scoped URLs: /app/{table}/{process} opens the table process (or an instance process); /app/{tab | n/a | n/a | browser shell |
| NAV-035 | Process runs launched from a record view or a filtered query return there (Return and Cancel honor returnTo) | n/a | n/a | browser shell |
| NAV-035 | Process runs launched from a record view or a filtered query return there (Return and Cancel honor returnTo) | n/a | pass | browser shell; TUI: Esc returns to the record or query, cursor kept |
| NAV-036 | Material app settings (MaterialDashboardAppMetaData): showAppLabelOnHomeScreen and includeTableCountsOnHomeScr | n/a | n/a | browser shell |
| NAV-037 | Mobile navigation drawer is a keyboard-usable modal dialog: focus moves in on open, Tab stays inside, and Esca | n/a | n/a | browser shell |
| NAV-038 | Shell on phones and tablets: skip link, header controls, breadcrumbs, navigation entries, user menu, app homes | n/a | n/a | browser shell |
| NAV-055 | Material Dashboard CSS and test hooks remain available on navigation, banners, app homes, forms, record views | n/a | n/a | browser shell |
| NAV-056 | The Material Dashboard theme from versioned QQQ metadata changes Next UI tokens, application CSS and the brand | n/a | n/a | browser shell |
| PRF-001 | large table: a 10,000-row, 40-column table opens with every column and the exact first page within budget | pass | todo | 10,000 rows with --all and --page-size |
| PRF-002 | large table: 250-row pages of 40 columns render, page and reach the last page within budget | pass | todo | --limit/--skip paging |
| PRF-003 | large table: sorting and filtering 10,000 rows return exact results within budget | pass | todo | server sort and filter |
| PRF-002 | large table: 250-row pages of 40 columns render, page and reach the last page within budget | pass | pass | --limit/--skip paging; TUI: server-paged grid, page = visible rows |
| PRF-003 | large table: sorting and filtering 10,000 rows return exact results within budget | pass | pass | server sort and filter; TUI: server sort and filter |
| PRF-004 | heavy dashboard: 24 widgets (statistics, bar, line and pie charts, 100-row tables) load once each within budge | todo | todo | one widget per call today |
| PRF-005 | long process: a 10-second job over 10,000 rows reports progress, polls at a bounded rate and shows its result | pass | todo | process run polls every 500 ms and prints progress |
| PRC-001 | Selected-record run: record ids reach the backend, screen values are submitted, mixed view and record-list res | pass | todo | process run -r ids |
| PRC-002 | All-records run from a filter selection with no criteria | pass | todo | --filter-json |
| PRF-005 | long process: a 10-second job over 10,000 rows reports progress, polls at a bounded rate and shows its result | pass | pass | process run polls every 500 ms and prints progress; TUI: progress N/M, elapsed, 250 ms polling |
| PRC-001 | Selected-record run: record ids reach the backend, screen values are submitted, mixed view and record-list res | pass | pass | process run -r ids; TUI: grid selection or record, ids sent |
| PRC-002 | All-records run from a filter selection with no criteria | pass | pass | --filter-json; TUI: without a selection: the grid filter |
| PRC-003 | Zero-record runs from a filter that matches nothing | pass | todo | |
| PRC-004 | Processes launched from an app (no records) | pass | todo | |
| PRC-005 | Process launched from selected rows of a table query | n/a | todo | grid selection; use -r |
| PRC-004 | Processes launched from an app (no records) | pass | pass | |
| PRC-005 | Process launched from selected rows of a table query | n/a | pass | grid selection; use -r; TUI: Space selects, b runs a table process |
| PRC-006 | Minimum and maximum input records | pass | todo | checked before init (filters counted); the sample declares no limits, verified by tests |
| PRC-007 | Every declared component renders on one screen, in declared order, with one action bar | pass | todo | every component in order as text (kernel screen model); bulk load forms belong to qqq bulk |
| PRC-008 | HELP_TEXT with line breaks and previewText toggle | pass | todo | text with line breaks; previewText printed above it |
| PRC-009 | EDIT_FORM field subsets (includeFieldNames), section labels and persisted input values | pass | todo | includeFieldNames and values kept across steps |
| PRC-010 | Required input validation on screens and in the backend | pass | todo | required checked locally and by the server |
| PRC-011 | Process possible-value fields | pass | todo | labels or ids, prompts list choices |
| PRC-012 | VIEW_FORM labels and backend values | pass | todo | label: value on every step |
| PRC-007 | Every declared component renders on one screen, in declared order, with one action bar | pass | partial | every component in order as text (kernel screen model); bulk load forms belong to qqq bulk; TUI: EDIT_FORM, BULK_EDIT_FORM, VIEW_FORM, HELP_TEXT, VALIDATION_REVIEW_SCREEN, PROCESS_SUMMARY_RESULTS |
| PRC-008 | HELP_TEXT with line breaks and previewText toggle | pass | partial | text with line breaks; previewText printed above it; TUI: HELP_TEXT shown above the form |
| PRC-009 | EDIT_FORM field subsets (includeFieldNames), section labels and persisted input values | pass | pass | includeFieldNames and values kept across steps; TUI: includeFieldNames via the kernel runner |
| PRC-010 | Required input validation on screens and in the backend | pass | pass | required checked locally and by the server; TUI: required checked by field before submit |
| PRC-011 | Process possible-value fields | pass | pass | labels or ids, prompts list choices; TUI: picker |
| PRC-012 | VIEW_FORM labels and backend values | pass | pass | label: value on every step; TUI: summary shows view fields |
| PRC-013 | HTML component renders the step html value, sanitized | pass | todo | {step}.html as text (tags stripped) |
| PRC-014 | DOWNLOAD_FORM delivers the generated file | pass | todo | GET /download/{file} saved to --out-dir, else the download directory; no sample process offers one, verified by tests |
| PRC-015 | Step help content for process screens | pass | todo | step helpContents shown on the screen, process help at start and in describe; none in the sample |
| PRC-016 | Back to the backend back step with entered values | partial | todo | b at the prompt when the step reports backStep (isStepBack=true); no sample process declares one, verified by tests |
| PRC-017 | Dynamic step list from processMetaDataAdjustment.updatedFrontendStepList | pass | todo | updatedFrontendStepList followed |
| PRC-018 | Updated field metadata from processMetaDataAdjustment.updatedFields | pass | todo | updatedFields applied |
| PRC-019 | Async job progress in the live job view (message, N / M, determinate progress bar, elapsed) then continue | pass | todo | message, N/M, elapsed on stderr |
| PRC-020 | Cancel from a screen and mid-job runs the cancel step and leaves | pass | todo | Ctrl-C during a job or a prompt calls POST …/cancel, exit 130 |
| PRC-021 | Step errors: user-facing messages directly, internal errors behind a detail toggle, Close | pass | todo | userFacingError shown |
| PRC-022 | Retry after an error restarts with the original record selection | n/a | todo | retry is re-running the command |
| PRC-017 | Dynamic step list from processMetaDataAdjustment.updatedFrontendStepList | pass | pass | updatedFrontendStepList followed; TUI: kernel runner |
| PRC-018 | Updated field metadata from processMetaDataAdjustment.updatedFields | pass | pass | updatedFields applied; TUI: kernel runner |
| PRC-019 | Async job progress in the live job view (message, N / M, determinate progress bar, elapsed) then continue | pass | pass | message, N/M, elapsed on stderr; TUI: bar, N/M, message, elapsed |
| PRC-020 | Cancel from a screen and mid-job runs the cancel step and leaves | pass | partial | Ctrl-C during a job or a prompt calls POST …/cancel, exit 130; TUI: Esc asks, then POST cancel; the sample keeps the run |
| PRC-021 | Step errors: user-facing messages directly, internal errors behind a detail toggle, Close | pass | pass | userFacingError shown; TUI: failure message shown; F5 re-runs |
| PRC-022 | Retry after an error restarts with the original record selection | n/a | pass | retry is re-running the command; TUI: F5 or r runs again with the same records |
| PRC-023 | Completion: Next/Submit labels, Return on the last screen or when noMoreSteps | n/a | todo | screen labels |
| PRC-024 | Linear stepper and step position | n/a | todo | stepper; step names printed |
| PRC-024 | Linear stepper and step position | n/a | pass | stepper; step names printed; TUI: step list with marks |
| PRC-025 | RECORD_LIST paging and page size over process records | pass | todo | GET …/records paged into a table, --record-limit |
| PRC-026 | Validation review: input count, validate-or-skip choice, Next/Submit label, record preview | pass | todo | count and preview; asks validate or skip on a terminal, --validate / --skip-validation; Submit? after validation |
| PRC-027 | Full validation summary lines (OK, WARNING, ERROR) with record links | pass | todo | status, count, message lines |
| PRC-028 | Process summary results after execution and persisted effects | pass | todo | |
| PRC-029 | Bulk edit: switched-on fields only, review and result | todo | todo | bulk edit |
| PRC-026 | Validation review: input count, validate-or-skip choice, Next/Submit label, record preview | pass | pass | count and preview; asks validate or skip on a terminal, --validate / --skip-validation; Submit? after validation; TUI: validate or skip, then run |
| PRC-027 | Full validation summary lines (OK, WARNING, ERROR) with record links | pass | pass | status, count, message lines; TUI: summary lines colored by status |
| PRC-028 | Process summary results after execution and persisted effects | pass | pass | |
| PRC-029 | Bulk edit: switched-on fields only, review and result | todo | pass | bulk edit; TUI: Ctrl+T marks fields; bulkEditEnabledFields |
| PRC-030 | Bulk delete: review, validation and result | todo | todo | bulk delete |
| PRC-031 | Bulk insert: upload, column mapping, value mapping, profile, review preview and result | todo | todo | bulk insert |
| PRC-032 | Bulk load mapping validation and Back through mapping and upload | todo | todo | |
| PRC-033 | Bulk edit with a file matched by key fields | todo | todo | |
| PRC-031 | Bulk insert: upload, column mapping, value mapping, profile, review preview and result | todo | n/a | bulk insert; TUI: bulk load parked for the TUI |
| PRC-032 | Bulk load mapping validation and Back through mapping and upload | todo | n/a | TUI: bulk load parked for the TUI |
| PRC-033 | Bulk edit with a file matched by key fields | todo | n/a | TUI: bulk load parked for the TUI |
| PRC-034 | Upload instructions HTML with downloadable template | todo | todo | |
| PRC-035 | Named WIDGET fetched with the process UUID and values | pass | todo | named widget with the process UUID and values, kernel render model; none in the sample, verified by tests |
| PRC-036 | Named composite WIDGET seeded from process values | pass | todo | seeded from the process value of the widget name |
@@ -117,11 +134,11 @@ The TUI column fills in during M3.
| PRC-044 | Scanner-format screens: no heading or action bar, input blocks submit | n/a | todo | scanner screens are interactive |
| PRC-045 | State-machine step flow: repeated screens, no linear stepper | pass | todo | the runner follows nextStep whatever the flow |
| PRC-046 | Saved bulk load profiles: save, choose for a later load, delete | todo | todo | saved bulk load profiles |
| PRC-047 | A run with no frontend screens completes with a completion screen | pass | todo | completes on init |
| PRC-048 | Process-field possible values load and search through the v1 API | pass | todo | searchProcessPossibleValues |
| PRC-047 | A run with no frontend screens completes with a completion screen | pass | pass | completes on init |
| PRC-048 | Process-field possible values load and search through the v1 API | pass | pass | searchProcessPossibleValues |
| PRC-049 | Processes run on the v1 process routes (upload, step, back step, records, metadata) | pass | todo | v1 routes only |
| PRC-050 | Processes the instance adds to every query and record screen (Material processNamesToAddToAllQueryAndViewScree | n/a | todo | Material-specific |
| PRC-051 | Dependent possible values on a process screen: the choices follow the screen's other values | pass | todo | dependent choices narrow after each answer |
| PRC-051 | Dependent possible values on a process screen: the choices follow the screen's other values | pass | pass | dependent choices narrow after each answer; TUI: contingent picker narrows |
| PRC-058 | Saved bulk load profile menu (Material SavedBulkLoadProfiles): new profile, update after the confirm, rename, | todo | todo | |
| PRC-059 | Saved bulk load profile ownership: yours vs shared with you, and only the owner may save, rename or delete a p | todo | todo | |
| PRC-060 | Bulk load file mapping form fidelity: field help, column tooltips, repeated-header warning, header-row toggle | todo | todo | |
@@ -138,61 +155,61 @@ The TUI column fills in during M3.
| QRY-094 | Counted quick views render and open their saved-view route | partial | todo | saved-view list counts quick views with doCount (--counts counts all) |
| QRY-095 | A new query defaults to fifty rows per page | n/a | todo | saved views and column UI |
| QRY-096 | A saved view reconciles removed fields and warns the user | partial | todo | opening a view drops removed fields with Material's warnings on stderr |
| QRY-001 | Record list renders metadata labels and all records in the default primary-key-descending order | pass | todo | default order by primary key |
| QRY-002 | Pagination with Material page sizes and URL state | pass | todo | --limit, --skip, --all |
| QRY-003 | Column header sort cycles ascending, descending and default; sort kept in the URL | pass | todo | --order, -field for descending |
| QRY-004 | Column visibility and order persist per table | todo | todo | --fields per call; no persistence |
| QRY-005 | Column widths, density and card view | n/a | todo | grid presentation |
| QRY-006 | Opening a record and returning keeps the query; refresh shows backend changes | n/a | todo | |
| QRY-007 | A failed query reports the backend error with a retry | pass | todo | error with code, status and trace id |
| QRY-010 | Text operators match Material labels and backend semantics | pass | todo | all operators and shorthand |
| QRY-011 | Number operators including ranges and lists | pass | todo | BETWEEN, IN lists |
| QRY-012 | Date operators and relative date expressions | pass | todo | now(), now(-7d), thisPeriod(), startOf() |
| QRY-001 | Record list renders metadata labels and all records in the default primary-key-descending order | pass | pass | default order by primary key |
| QRY-002 | Pagination with Material page sizes and URL state | pass | pass | --limit, --skip, --all; TUI: PgUp/PgDn, Ctrl+F/B, Home/End across pages |
| QRY-003 | Column header sort cycles ascending, descending and default; sort kept in the URL | pass | pass | --order, -field for descending; TUI: s cycles asc, desc, default |
| QRY-004 | Column visibility and order persist per table | todo | pass | --fields per call; no persistence; TUI: v: columns on/off and order, kept per table in the config file |
| QRY-005 | Column widths, density and card view | n/a | pass | grid presentation; TUI: v: widths, fit or fill, zebra, density |
| QRY-006 | Opening a record and returning keeps the query; refresh shows backend changes | n/a | pass | TUI: Esc lands on the row last viewed, filter and page kept |
| QRY-007 | A failed query reports the backend error with a retry | pass | pass | error with code, status and trace id; TUI: error shown in the grid; F5 retries |
| QRY-010 | Text operators match Material labels and backend semantics | pass | pass | all operators and shorthand; TUI: operators by field type in the filter dialog |
| QRY-011 | Number operators including ranges and lists | pass | pass | BETWEEN, IN lists |
| QRY-012 | Date operators and relative date expressions | pass | pass | now(), now(-7d), thisPeriod(), startOf(); TUI: date presets: now(-7d), thisPeriod(month) … |
| QRY-013 | Date-time operators convert local input to UTC and support relative values | pass | todo | --where/--group DATE_TIME values local to UTC (a date is local midnight); expressions as before |
| QRY-014 | Boolean, blob and long-text operators | pass | todo | IS_BLANK, TRUE, FALSE |
| QRY-015 | Possible-value selectors for table and enum sources show labels and send ids | todo | todo | filters take ids; labels in filters todo |
| QRY-016 | AND/OR and nested groups combine as declared | pass | todo | --group with parentheses |
| QRY-017 | Filters live in the URL: reload, back/forward and Material-style JSON links | pass | todo | --print-filter gives the JSON the web links accept |
| QRY-015 | Possible-value selectors for table and enum sources show labels and send ids | todo | pass | filters take ids; labels in filters todo; TUI: labels in the picker, ids sent; typed labels resolved |
| QRY-016 | AND/OR and nested groups combine as declared | pass | pass | --group with parentheses; TUI: AND/OR and groups, guided and advanced |
| QRY-017 | Filters live in the URL: reload, back/forward and Material-style JSON links | pass | partial | --print-filter gives the JSON the web links accept; TUI: the grammar and the CLI command are shown; no URL |
| QRY-018 | Backend-only operators from links render and filter correctly | pass | todo | any operator in --where |
| QRY-019 | Quick search combines with the advanced filter | pass | todo | --search on query, count and export: CONTAINS over the shown text fields, ORed, ANDed with the filter |
| QRY-020 | One-side exposed join: labelled columns, filters and sorts match SQL | partial | todo | --join and table.field in --fields/--where/--order, labelled "Join: Field"; the sample exposes no joins, verified through an association join (person to pet) |
| QRY-021 | Many-side exposed join repeats rows and reports the distinct count | pass | todo | a many-side join repeats rows; count and --count add the distinct count (person --join pet: 21 rows, 16 distinct) |
| QRY-022 | Column statistics aggregate the filtered rows | partial | todo | qqq table stats <t> <field> [-w …] through the columnStats process; the sample has neither QUERY_STATS nor the process, so --local (same figures from the matching rows) is what was verified |
| QRY-023 | Tables without QUERY_STATS offer no column statistics | pass | todo | without QUERY_STATS (or the process) table stats exits 2 with the reason; --local is an explicit opt-in |
| QRY-030 | Selection menu: this page, full query result, first N, clear | pass | todo | -r ids, --filter-json, --limit |
| QRY-031 | Bulk Edit launches with the checked record ids | todo | todo | bulk edit |
| QRY-030 | Selection menu: this page, full query result, first N, clear | pass | partial | -r ids, --filter-json, --limit; TUI: Space and Ctrl+A (page) |
| QRY-031 | Bulk Edit launches with the checked record ids | todo | pass | bulk edit |
| QRY-032 | Bulk Delete of the full query result sends the whole filter | todo | todo | bulk delete |
| QRY-033 | First-N selection under a filter launches with that limit | pass | todo | process run --filter-json with limit |
| QRY-034 | Actions menu offers bulk load, bulk processes and table processes | todo | todo | process list --table |
| QRY-034 | Actions menu offers bulk load, bulk processes and table processes | todo | partial | process list --table; TUI: F9 lists table processes; bulk load parked |
| QRY-035 | Viewers get no create, bulk or process actions and the backend refuses them | pass | todo | server refuses; exit 3 |
| QRY-040 | CSV export has visible columns in order and exactly the filtered, sorted rows | pass | todo | table export: filtered (--where, --group, --search), sorted (--order), --fields in column order, --limit, --out - |
| QRY-041 | XLSX and JSON exports carry the same rows | pass | todo | csv, xlsx, json, tsv; format from --out extension; --no-header; bad format or field exit 2 before the request |
| QRY-040 | CSV export has visible columns in order and exactly the filtered, sorted rows | pass | pass | table export: filtered (--where, --group, --search), sorted (--order), --fields in column order, --limit, --out -; TUI: x: visible columns, filter and sort, file dialog |
| QRY-041 | XLSX and JSON exports carry the same rows | pass | pass | csv, xlsx, json, tsv; format from --out extension; --no-header; bad format or field exit 2 before the request |
| QRY-042 | Nothing to export disables the formats | n/a | todo | nothing to disable |
| QRY-043 | Backend refuses exports of unreadable tables | pass | todo | server refuses |
| QRY-050 | Your saved views are listed and open on their own route | partial | todo | qqq saved-view list <t>, open <t> <view>, table query --view; the sample has no saved views; verified with node tests and a local stub server |
| QRY-050 | Your saved views are listed and open on their own route | partial | partial | qqq saved-view list <t>, open <t> <view>, table query --view; the sample has no saved views; verified with node tests and a local stub server; TUI: F9 runs saved-view list in a pane |
| QRY-051 | Saving a new view stores filter, sort, columns and page size | partial | todo | saved-view save --name: filter, sort, columns and page size as the Material RecordQueryView |
| QRY-052 | Update, rename and delete your view | partial | todo | saved-view save --replace, rename, delete (asks; --yes) |
| QRY-053 | Duplicate names and unknown views are reported without changes | partial | todo | a name in use is refused before any change; an unknown view exits 2 with the list |
| QRY-054 | A view shared with you opens read-only and the backend refuses changes | partial | todo | views shared with you are listed as shared by their owner; changes to them are the server's to refuse, its message shown |
| QRY-060 | Variant table asks for a variant, queries with it and remembers it | partial | todo | table variants; --variant on query, count, get, copy, export, writes and process run; a terminal asks; not remembered between commands; no variant table in the sample |
| QRY-061 | Dismissing the variant prompt queries nothing; backend requires a variant | partial | todo | no --variant and no terminal (or no answer): exit 2, nothing queried; no variant table in the sample |
| QRY-062 | A table without count, export or write capabilities offers none of them | pass | todo | describe shows capabilities; server refuses |
| QRY-062 | A table without count, export or write capabilities offers none of them | pass | pass | describe shows capabilities; server refuses; TUI: F-keys hidden without permission |
| QRY-063 | Enum-backed Pet Species table is read-only | pass | todo | server refuses writes |
| QRY-064 | Backend refuses count and export for a table without those capabilities | pass | todo | |
| QRY-065 | Export streams from the v1 export route | pass | todo | POST /table/{t}/export |
| QRY-065 | Export streams from the v1 export route | pass | pass | POST /table/{t}/export |
| QRY-066 | Variant options come from the v1 variants route | partial | todo | GET /table/{t}/variants (called live; the sample lists none) |
| QRY-067 | Go To opens a record by its primary key or a configured unique key (Material gotoFieldNames) | pass | todo | table get --key; the primary key and Material gotoFieldNames are the declared keys, other fields looked up with a note |
| QRY-068 | Default grid columns follow the table sections (section order, each field once), not the metadata field order | pass | todo | default columns follow sections by tier |
| QRY-067 | Go To opens a record by its primary key or a configured unique key (Material gotoFieldNames) | pass | pass | table get --key; the primary key and Material gotoFieldNames are the declared keys, other fields looked up with a note; TUI: g |
| QRY-068 | Default grid columns follow the table sections (section order, each field once), not the metadata field order | pass | pass | default columns follow sections by tier |
| QRY-069 | Pagination numbers are locale formatted in the shown range and the total | n/a | todo | locale formatting |
| QRY-070 | Query screen keyboard shortcuts n (new record), r (refresh the query), f (open the filter builder) | n/a | todo | shortcuts |
| QRY-070 | Query screen keyboard shortcuts n (new record), r (refresh the query), f (open the filter builder) | n/a | pass | shortcuts; TUI: n r f s and the F-keys; off with keyboard.singleKeyShortcuts |
| QRY-071 | Processes launched from a variant table run with the chosen variant (tableVariant on init and every step) and | partial | todo | process run --variant: tableVariant on init, every step and status; sent live, no variant table to observe |
| QRY-080 | Basic mode displays default quick filters from table metadata and applies their criteria | n/a | todo | quick filters UI |
| QRY-081 | Add quick filter accepts metadata fields and remembers a chosen field | n/a | todo | |
| QRY-082 | Basic mode cannot simplify repeated or nested advanced conditions and explains why | n/a | todo | |
| QRY-083 | Advanced query shows and removes individual conditions (Next inline editor) | n/a | todo | |
| QRY-084 | Sort picker changes field and direction; clearing filters requires confirmation and keeps the sort | n/a | todo | |
| QRY-083 | Advanced query shows and removes individual conditions (Next inline editor) | n/a | pass | TUI: guided builder edits and removes conditions |
| QRY-084 | Sort picker changes field and direction; clearing filters requires confirmation and keeps the sort | n/a | pass | TUI: sort in the builder; c clears |
| QRY-085 | Weekday criteria use the metadata-enabled date function and ISO weekday values | todo | todo | weekday criteria |
| QRY-086 | Relative date presets expose their evaluated date and filter records | pass | todo | expressions |
| QRY-086 | Relative date presets expose their evaluated date and filter records | pass | pass | expressions |
| QRY-087 | Bulk pasted values become a multi-value filter | pass | todo | IN lists; quoted values |
| QRY-088 | Instance query help accompanies the bulk paste dialog | n/a | todo | |
| QRY-089 | String filter input honors field case behavior from v1 metadata | pass | todo | behaviors |
@@ -201,49 +218,49 @@ The TUI column fills in during M3.
| QRY-024 | Column configuration is reachable and usable by touch on phones and tablets | n/a | n/a | phone layout |
| QRY-036 | Bulk actions launch from a phone card selection | n/a | n/a | phone layout |
| QRY-055 | Saved views and export menus fit the phone and are touch-sized | n/a | n/a | phone layout |
| REL-001 | Possible-value cells show the related record label and link to it | pass | todo | display values; table get shows the target (Avery Sample → person 1), --links lists them, --follow opens one |
| REL-002 | Parent lists exactly its own children; View All opens the filtered child list | pass | todo | table get --associations lists exactly the record's children; table children pages a list with its count (View All) |
| REL-003 | Adding a child from its parent links it and shows it immediately | pass | todo | table create <child> --parent table=pk; table get --associations shows it at once |
| REL-004 | Three-level associations: each pet shows only its own notes | pass | todo | person > pets > notes: each pet lists only its own notes |
| REL-005 | Editing and deleting a child leaves no stale parent, list or unexpected rows | pass | todo | records are never cached: update or delete a child, the next get/children shows the current rows |
| REL-006 | Denied child reads show an unavailable panel without data or actions | partial | todo | unreadable child tables shown as unavailable without data; table children shows the server message; the sample has no denied child table (unit-tested) |
| REL-001 | Possible-value cells show the related record label and link to it | pass | partial | display values; table get shows the target (Avery Sample → person 1), --links lists them, --follow opens one; TUI: labels with links in the record view |
| REL-002 | Parent lists exactly its own children; View All opens the filtered child list | pass | pass | table get --associations lists exactly the record's children; table children pages a list with its count (View All); TUI: child lists; F9 Query all opens the filtered list |
| REL-003 | Adding a child from its parent links it and shows it immediately | pass | pass | table create <child> --parent table=pk; table get --associations shows it at once; TUI: F7 on a child list prefills the parent |
| REL-004 | Three-level associations: each pet shows only its own notes | pass | partial | person > pets > notes: each pet lists only its own notes; TUI: one level shown; open a child for its own |
| REL-005 | Editing and deleting a child leaves no stale parent, list or unexpected rows | pass | pass | records are never cached: update or delete a child, the next get/children shows the current rows; TUI: the parent reloads on return |
| REL-006 | Denied child reads show an unavailable panel without data or actions | partial | pass | unreadable child tables shown as unavailable without data; table children shows the server message; the sample has no denied child table (unit-tested); TUI: unavailable panel (kernel associationGroups) |
| REL-007 | Aliased and composite associations bind to their widgets and list the right members | partial | todo | composite joins and widget-section labels in the kernel, unit-tested; the sample declares none |
| REL-008 | Adding through a composite association stores every join value | partial | todo | --parent sets every join value of a composite association, unit-tested; the sample declares none |
| REL-009 | Deleting a parent removes associated children and leaves other parents intact | pass | todo | table delete of a parent: its pets are gone, other parents keep theirs |
| REC-001 | Record view shows the record label, tiered sections and backend-formatted values | pass | todo | table get by section and tier |
| REC-001 | Record view shows the record label, tiered sections and backend-formatted values | pass | pass | table get by section and tier; TUI: sections by tier, two columns when they fit |
| REC-002 | Empty values render a placeholder; hidden fields and hidden sections are never shown | partial | todo | empty values show --; hidden sections and fields are dropped by the kernel record view (unit-tested; the sample has none) |
| REC-003 | Record view of a missing record shows Record Not Found and a way back | pass | todo | exit 5 |
| REC-003 | Record view of a missing record shows Record Not Found and a way back | pass | pass | exit 5; TUI: error shown in the record pane |
| REC-004 | Record view tabs and list view expose every visible section | n/a | todo | tabs |
| REC-005 | Create form validates required fields in the browser before any request | pass | todo | validated locally before any request |
| REC-006 | Create persists the entered values and opens the new record | pass | todo | |
| REC-005 | Create form validates required fields in the browser before any request | pass | pass | validated locally before any request; TUI: local validation by field |
| REC-006 | Create persists the entered values and opens the new record | pass | pass | TUI: new record replaces the form |
| REC-007 | Backend enforces required fields when the UI is bypassed | pass | todo | |
| REC-008 | Cancel from create and edit, with the unsaved-changes guard | n/a | todo | guard |
| REC-009 | Edit loads stored values, saves only what changed, and shows the updated record | pass | todo | update sends only changed fields |
| REC-010 | Edit shows non-editable fields read-only and never submits them | pass | todo | non-editable fields excluded |
| REC-008 | Cancel from create and edit, with the unsaved-changes guard | n/a | pass | guard; TUI: Esc asks when there are unsaved changes; F10 warns too |
| REC-009 | Edit loads stored values, saves only what changed, and shows the updated record | pass | pass | update sends only changed fields; TUI: only changed fields sent |
| REC-010 | Edit shows non-editable fields read-only and never submits them | pass | pass | non-editable fields excluded; TUI: non-editable fields read-only |
| REC-011 | Edit of a missing record shows the load failure instead of a form | pass | todo | |
| REC-012 | Delete asks for confirmation, removes the record and returns to the list without errors | pass | todo | confirm unless --yes |
| REC-013 | Copy prefills a new record from the source and saves it as a separate record | pass | todo | qqq table copy <t> <pk> [--set f=v] [-i] [--dry-run]: primary key, non-editable and file fields dropped |
| REC-014 | STRING and TEXT editors round-trip single and multi-line text | pass | todo | |
| REC-015 | INTEGER, LONG and DECIMAL editors keep exact values | pass | todo | |
| REC-016 | BOOLEAN editor applies the metadata default and saves true/false | pass | todo | defaults applied |
| REC-017 | DATE and TIME editors round-trip values and clearing | pass | todo | |
| REC-018 | DATE_TIME is entered and shown in the viewer's time zone and stored in UTC | pass | todo | typed and shown in TZ, stored UTC, --utc for raw; DST edges tested |
| REC-012 | Delete asks for confirmation, removes the record and returns to the list without errors | pass | pass | confirm unless --yes; TUI: F8 asks; the grid moves on to the next record |
| REC-013 | Copy prefills a new record from the source and saves it as a separate record | pass | pass | qqq table copy <t> <pk> [--set f=v] [-i] [--dry-run]: primary key, non-editable and file fields dropped; TUI: c: copyRecordValues |
| REC-014 | STRING and TEXT editors round-trip single and multi-line text | pass | partial | TUI: TEXT single line with $EDITOR on Ctrl+E |
| REC-015 | INTEGER, LONG and DECIMAL editors keep exact values | pass | pass | |
| REC-016 | BOOLEAN editor applies the metadata default and saves true/false | pass | pass | defaults applied; TUI: Space toggles |
| REC-017 | DATE and TIME editors round-trip values and clearing | pass | pass | |
| REC-018 | DATE_TIME is entered and shown in the viewer's time zone and stored in UTC | pass | pass | typed and shown in TZ, stored UTC, --utc for raw; DST edges tested; TUI: local zone in and out (kernel datetime) |
| REC-019 | Zoned DATE_TIME display behaviors (fixed zone and per-record zone) | n/a | todo | display zones |
| REC-020 | HTML field edits in the rich-text editor and renders sanitized | todo | todo | raw HTML; $EDITOR todo |
| REC-021 | PASSWORD is masked on read and never overwritten by the mask | pass | todo | server masks |
| REC-021 | PASSWORD is masked on read and never overwritten by the mask | pass | pass | server masks; TUI: masked |
| REC-022 | BLOB upload and download without a download adornment | pass | todo | table create/update --upload and table download (base64 BLOB value) verified on fieldLab; default name into the download directory |
| REC-023 | Dynamic default values (user id, create day, modify day, explicit NONE) | pass | todo | server |
| REC-024 | Max-length policies: truncate, ellipsis, pass-through and reject | pass | todo | maxLength checked locally |
| REC-025 | Case and whitespace normalization on save | pass | todo | behaviors |
| REC-026 | Numeric range policies: reject, exclusive reject, inclusive clip and exclusive clip | pass | todo | ranges checked locally |
| REC-027 | Unique keys are enforced with the backend message on create and edit | pass | todo | server message |
| REC-028 | LINK adornment: record links and URL links | pass | todo | record links name table and key, --follow opens them; URL links show the URL |
| REC-028 | LINK adornment: record links and URL links | pass | pass | record links name table and key, --follow opens them; URL links show the URL; TUI: links marked, Enter follows (kernel record-view) |
| REC-029 | CHIP adornment colors and icons by value | partial | todo | chip as [value]; colors and icons not shown (unit-tested; none in the sample) |
| REC-030 | SIZE adornment sets the query grid column width | n/a | todo | |
| REC-031 | CODE_EDITOR adornment: code view with formatting and a code editor on forms | partial | todo | raw code on view; no code editor (unit-tested) |
| REC-032 | RENDER_HTML adornment renders sanitized HTML | partial | todo | HTML shown as text (unit-tested) |
| REC-033 | REVEAL adornment masks, reveals and copies the value | partial | todo | masked until --reveal in the record view and in query and child-list cells; no copy (unit-tested; none in the sample) |
| REC-034 | FILE_DOWNLOAD adornment links to the stored file with its name | partial | todo | name plus qqq table download via the field route (unit-tested); live only for BLOB values, server finding 16 |
| REC-033 | REVEAL adornment masks, reveals and copies the value | partial | partial | masked until --reveal in the record view and in query and child-list cells; no copy (unit-tested; none in the sample); TUI: masked; Enter reveals |
| REC-034 | FILE_DOWNLOAD adornment links to the stored file with its name | partial | partial | name plus qqq table download via the field route (unit-tested); live only for BLOB values, server finding 16; TUI: Enter saves through the file dialog; no sample field |
| REC-035 | FILE_UPLOAD adornment formats, replacing and removing a stored file | partial | todo | create/update --upload field=path as multipart, replace and remove verified on a BLOB; FILE_UPLOAD formats not in the sample |
| REC-036 | TOOLTIP adornment shows its text on the value | partial | todo | tooltip text in parentheses after the value (unit-tested) |
| REC-037 | WIDGET adornment renders the field value as the named widget | partial | todo | value rendered through the widget render model as text (unit-tested) |
@@ -251,22 +268,22 @@ The TUI column fills in during M3.
| REC-039 | Field help content follows screen roles and formats | partial | todo | table get --help-content: view-screen roles, HTML/MARKDOWN as text (unit-tested; the sample has no help); qqq help show lists help alone |
| REC-040 | Section help content on the record view | partial | todo | section help with --help-content (unit-tested) |
| REC-041 | Table help content slots are delivered in table metadata | partial | todo | table helpContent and helpContents slots with --help-content (unit-tested) |
| REC-042 | Audit history lists who changed what, newest first | partial | todo | qqq audit list <t> <pk> [--asc]: entries newest first with field changes; the sample audits no table, verified with node tests and a local stub server |
| REC-042 | Audit history lists who changed what, newest first | partial | partial | qqq audit list <t> <pk> [--asc]: entries newest first with field changes; the sample audits no table, verified with node tests and a local stub server; TUI: F9 runs audit list in a pane |
| REC-043 | Audit history without process permission reads the audit table | partial | todo | without the audit process the audit table is queried with its auditTable and auditDetail joins (node tests) |
| REC-044 | Record label format is used for the record's title and dialogs | pass | todo | recordLabel |
| REC-045 | Possible-value editor shows stored labels, looks up defaults and selects by keyboard | pass | todo | labels resolved, defaults looked up |
| REC-044 | Record label format is used for the record's title and dialogs | pass | pass | recordLabel |
| REC-045 | Possible-value editor shows stored labels, looks up defaults and selects by keyboard | pass | pass | labels resolved, defaults looked up; TUI: picker by keyboard |
| REC-046 | Read-only persona sees no write actions and the backend refuses writes | pass | todo | server |
| REC-047 | Forms are labelled and keyboard operable | n/a | todo | |
| REC-048 | Server validation errors show the backend message once and keep the form | pass | todo | fieldErrors by field |
| REC-048 | Server validation errors show the backend message once and keep the form | pass | pass | fieldErrors by field; TUI: fieldErrors by field |
| REC-049 | pages of a table outside the app tree use the table label in the document title and breadcrumbs | n/a | todo | |
| REC-051 | Table-field possible values search and resolve through the v1 API | pass | todo | |
| REC-050 | Record create, view, edit and delete run on the v1 record routes | pass | todo | |
| REC-051 | Table-field possible values search and resolve through the v1 API | pass | pass | |
| REC-050 | Record create, view, edit and delete run on the v1 record routes | pass | pass | |
| REC-056 | Record view hash links: #audit, #/launchProcess={process} and #{section} anchors | n/a | todo | |
| REC-057 | Create presets from links: #/createChild= opens a child create dialog over the record; /create#/defaultValues= | n/a | todo | |
| REC-052 | Dependent possible values: a field whose filter uses ${input.otherField} offers only the choices matching the | pass | todo | contingent fields |
| REC-052 | Dependent possible values: a field whose filter uses ${input.otherField} offers only the choices matching the | pass | pass | contingent fields; TUI: contingent fields wait and narrow |
| REC-053 | Table developer view shows the API Docs & Playground section for the table's application APIs | todo | todo | developer view |
| REC-054 | Record developer view manages associated scripts: versions, new versions, test runs, logs, docs and script cre | todo | todo | |
| REC-055 | Record view keyboard shortcuts n, e, c, d, a (Material) and their help text | n/a | todo | |
| REC-055 | Record view keyboard shortcuts n, e, c, d, a (Material) and their help text | n/a | pass | TUI: n e c d; F-keys |
| REC-058 | Record view on phones and tablets: the title stays readable, the header controls wrap onto their own row, and | n/a | todo | |
| REC-059 | Record forms on phones and tablets: every editor type fits without sideways scrolling, controls are touch-size | n/a | todo | |
| REC-061 | An editable dynamicForm widget on a record form validates its fields and persists them in the declared JSON re | todo | todo | |
@@ -277,9 +294,9 @@ The TUI column fills in during M3.
| REC-065 | Unchanged DATE_TIME instants and precision survive edit and copy | pass | todo | an unchanged wall-clock time keeps the original instant and its precision |
| SEC-001 | Table hidden from a user (DenyBehavior.HIDDEN): absent from navigation, direct link loads nothing, backend ref | pass | todo | |
| SEC-002 | Table disabled for a user (DenyBehavior.DISABLED): listed, but list/record/create/edit links explain the denia | todo | todo | disabled tables listed with a reason |
| SEC-003 | Insert permission denied: create not offered, create link refused, backend writes nothing | pass | todo | |
| SEC-004 | Edit permission denied: edit not offered, edit link refused, backend changes nothing | pass | todo | |
| SEC-005 | Delete permission denied: delete not offered on record or bulk bar, bulk delete refused, backend deletes nothi | pass | todo | |
| SEC-003 | Insert permission denied: create not offered, create link refused, backend writes nothing | pass | pass | TUI: F7 hidden without insert permission |
| SEC-004 | Edit permission denied: edit not offered, edit link refused, backend changes nothing | pass | pass | |
| SEC-005 | Delete permission denied: delete not offered on record or bulk bar, bulk delete refused, backend deletes nothi | pass | pass | |
| SEC-006 | Copy requires insert permission: copy not offered and copy link refused | todo | todo | copy |
| SEC-007 | Process permission denied (hidden): processes not offered in navigation or launcher, links do not start them, | pass | todo | |
| SEC-008 | Process disabled for a user (DenyBehavior.DISABLED): shown disabled, link explains the denial, backend refuses | todo | todo | |
@@ -290,19 +307,19 @@ The TUI column fills in during M3.
| SEC-013 | Field protection: hidden fields never shown or returned, password fields masked and never overwritten by the m | pass | todo | server |
| SEC-014 | Capability restrictions: tables without insert/update/delete capabilities offer none of them and refuse direct | pass | todo | |
| SEC-015 | Permission revoked mid-session: stale UI actions are refused by the backend with a clear message, nothing is w | pass | todo | |
| SEC-020 | MOCK sign-in: a deep link establishes the session and shows the session user from manageSession values | pass | todo | qqq login on a MOCK server |
| SEC-021 | Logout: POST /qqq/v1/logout ends the session, protected pages stay on login, Sign in returns to the page, per- | pass | todo | qqq logout calls POST /logout |
| SEC-020 | MOCK sign-in: a deep link establishes the session and shows the session user from manageSession values | pass | pass | qqq login on a MOCK server; TUI: MOCK session, shared with the CLI keyring |
| SEC-021 | Logout: POST /qqq/v1/logout ends the session, protected pages stay on login, Sign in returns to the page, per- | pass | pass | qqq logout calls POST /logout; TUI: F9 Sign out |
| SEC-022 | Session expiry: 401 redirects to login with returnTo and re-authenticates back to the page (initial load and m | pass | todo | exit 3 with the sign-in hint |
| SEC-023 | Denied sign-in: a 401 from manageSession keeps the user out with an explanation (no silent anonymous fallback) | pass | todo | |
| SEC-024 | Post-login returnTo is limited to in-app paths (open-redirect guard) | n/a | n/a | |
| SEC-025 | OAUTH2 authorization code + PKCE sign-in with an owned OIDC provider (discovery, authorize, callback, backend | pass | todo | device flow and --browser (code + PKCE, loopback) verified against a sample on OAUTH2 with qqq-dev-idp |
| SEC-025 | OAUTH2 authorization code + PKCE sign-in with an owned OIDC provider (discovery, authorize, callback, backend | pass | partial | device flow and --browser (code + PKCE, loopback) verified against a sample on OAUTH2 with qqq-dev-idp; TUI: device flow screen; not tried against a provider |
| SEC-026 | OAUTH2 sign-in failures: provider denial, tampered state and failed code exchange are reported and create no s | pass | todo | denial, forged state and a refused code exchange exit 3 and save nothing; the token is checked against the server first |
| SEC-027 | OAUTH2 logout: backend session deleted, provider end-session called, next visit requires provider sign-in | pass | todo | logout --provider-logout: refresh token revoked (RFC 7009), end-session URL opened; bearer sign-ins have no server session |
| SEC-028 | OAUTH2 session revoked server-side mid-use: 401 sends the user through the provider and back to the page | pass | todo | a refresh the provider refuses exits 3 with sign in again; refresh before expiry verified |
| SEC-029 | AUTH_0 authorization code + PKCE with an owned Auth0-compatible provider (browser token exchange, backend JWT | todo | todo | |
| SEC-030 | AUTH_0 against a real Auth0 tenant | todo | todo | |
| SEC-031 | FULLY_ANONYMOUS authentication: session without an identity provider, anonymous identity, logout and sign-in a | pass | todo | |
| SEC-032 | Unsupported authentication type (a type this UI does not know, as a newer backend could declare) is reported, | pass | todo | unknown type refused |
| SEC-031 | FULLY_ANONYMOUS authentication: session without an identity provider, anonymous identity, logout and sign-in a | pass | pass | |
| SEC-032 | Unsupported authentication type (a type this UI does not know, as a newer backend could declare) is reported, | pass | pass | unknown type refused; TUI: unknown type refused with a message |
| SEC-033 | Server-side logout invalidation: v1 logout expires every session cookie it issued and an ended Auth0 session c | n/a | n/a | |
| SEC-034 | TABLE_BASED sign-in: a deep link asks for username and password under the app branding, POST /qqq/v1/manageSes | todo | todo | TABLE_BASED sign-in (no API recipe yet) |
| SEC-035 | TABLE_BASED sign-in failure: a wrong password or unknown user is refused with one message, stores no session, | todo | todo | |
@@ -317,44 +334,44 @@ The TUI column fills in during M3.
| SEC-044 | Session cookies (sessionUUID, sessionId) are SameSite=Lax for the whole site, and Secure when the request arri | n/a | n/a | browser security |
| SEC-048 | Session cookies (sessionUUID, sessionId) are HttpOnly: script in the page cannot read them, and resuming a ses | n/a | n/a | browser security |
| SEC-049 | With HttpOnly session cookies the backend ends and replaces sessions: logout expires both cookies, an idle-exp | n/a | n/a | browser security |
| INT-001 | Keyboard: navigate from the sidebar to a list, open a record, edit and save without a pointer | n/a | n/a | browser accessibility and layout |
| INT-002 | Keyboard: run a process through its steps without a pointer | n/a | n/a | browser accessibility and layout |
| INT-003 | Dialogs trap focus, close on Escape and restore focus to the trigger; confirm by keyboard | n/a | n/a | browser accessibility and layout |
| INT-004 | Command menu, search and shortcut dialogs: keyboard open, focus inside, Escape restores focus | n/a | n/a | browser accessibility and layout |
| INT-001 | Keyboard: navigate from the sidebar to a list, open a record, edit and save without a pointer | n/a | pass | browser accessibility and layout; TUI: keyboard only by design |
| INT-002 | Keyboard: run a process through its steps without a pointer | n/a | pass | browser accessibility and layout |
| INT-003 | Dialogs trap focus, close on Escape and restore focus to the trigger; confirm by keyboard | n/a | pass | browser accessibility and layout; TUI: modal dialogs, Esc closes |
| INT-004 | Command menu, search and shortcut dialogs: keyboard open, focus inside, Escape restores focus | n/a | pass | browser accessibility and layout |
| INT-005 | Automated accessibility scan (axe WCAG 2.1 AA) of login, dashboard, list, record, edit form and process step | n/a | n/a | browser accessibility and layout |
| INT-006 | Form labels and ARIA: every field has an accessible label, required fields are marked, invalid fields announce | n/a | n/a | browser accessibility and layout |
| INT-007 | Validation and error messages: client and backend validation name the field and nothing is saved | pass | todo | field named in every message |
| INT-007 | Validation and error messages: client and backend validation name the field and nothing is saved | pass | pass | field named in every message |
| INT-008 | Responsive layout at phone width (Pixel 7): navigation drawer, list, record and form usable without horizontal | n/a | n/a | browser accessibility and layout |
| INT-009 | Loading states: a delayed list or record request shows a busy state, then the real data | n/a | n/a | browser accessibility and layout |
| INT-010 | Failed request and recovery: a failed list request shows an error with retry; retry loads the data | pass | todo | exit 4 and a message; rerun |
| INT-009 | Loading states: a delayed list or record request shows a busy state, then the real data | n/a | pass | browser accessibility and layout; TUI: loading lines |
| INT-010 | Failed request and recovery: a failed list request shows an error with retry; retry loads the data | pass | pass | exit 4 and a message; rerun; TUI: F5 retries |
| INT-011 | Delayed save: the submit control is disabled while saving and double submission creates one record | n/a | n/a | browser accessibility and layout |
| INT-012 | Stale state: changes and deletions made elsewhere are shown after refresh/navigation, not stale cache | pass | todo | no client cache of records |
| INT-013 | Empty state: a query with no results explains it and offers the create action | pass | todo | (no records) |
| INT-014 | Backend unavailable at sign-in: the login page explains the failure and recovers with Try again | pass | todo | cannot reach the server |
| INT-012 | Stale state: changes and deletions made elsewhere are shown after refresh/navigation, not stale cache | pass | pass | no client cache of records; TUI: F5 and on return |
| INT-013 | Empty state: a query with no results explains it and offers the create action | pass | partial | (no records); TUI: no records message; F7 offered |
| INT-014 | Backend unavailable at sign-in: the login page explains the failure and recovers with Try again | pass | pass | cannot reach the server; TUI: sign-in screen shows the failure |
| SEC-050 | Optional QA capture (Jam, qbit-qa-capture qa-capture extension): Jam loads only for enabled users; Report a bu | n/a | n/a | browser security |
| WID-001 | alert widget: severity from alertType, sanitized HTML and bullet list | pass | todo | rendered as text |
| WID-002 | barChart widget: one bar per label in backend colors | pass | todo | rendered as text |
| WID-001 | alert widget: severity from alertType, sanitized HTML and bullet list | pass | pass | rendered as text |
| WID-002 | barChart widget: one bar per label in backend colors | pass | pass | rendered as text; TUI: bars in block characters |
| WID-003 | chart output from an untyped widget renders as a bar chart | pass | todo | rendered as text |
| WID-004 | divider widget renders a rule without card chrome | pass | todo | rendered as text |
| WID-004 | divider widget renders a rule without card chrome | pass | pass | rendered as text |
| WID-005 | fieldValueList widget: labels, display values, prefix icons, indentation | pass | todo | rendered as text |
| WID-006 | generic widget renders its common payload fields (sublabel, footer) | todo | todo | generic |
| WID-007 | horizontalBarChart widget: horizontal bars with negative values left of zero | pass | todo | rendered as text |
| WID-008 | html widget (no-code) renders sanitized user HTML | pass | todo | rendered as text |
| WID-009 | lineChart widget: line with a point per label and description | pass | todo | rendered as text |
| WID-009 | lineChart widget: line with a point per label and description | pass | pass | rendered as text; TUI: sparkline and table |
| WID-010 | smallLineChart widget: compact line with title and HTML description | pass | todo | rendered as text |
| WID-011 | location widget: image, title, description, address and footer | todo | todo | location |
| WID-012 | multiStatistics widget: groups with icon, header, subheader and statistics | todo | todo | multiStatistics untested |
| WID-012 | multiStatistics widget: groups with icon, header, subheader and statistics | todo | pass | multiStatistics untested |
| WID-013 | multiTable widget renders each table with its label and rows | pass | todo | rendered as text |
| WID-014 | pieChart widget: colored slices, legend values and subheader | pass | todo | rendered as text |
| WID-014 | pieChart widget: colored slices, legend values and subheader | pass | pass | rendered as text; TUI: percentage bars |
| WID-015 | quickSightChart widget embeds the provider URL (real renderer, owned local provider endpoint) | pass | todo | prints the URL |
| WID-016 | statistics widget: count, context, colored percentage change and links | pass | todo | rendered as text |
| WID-017 | stackedBarChart widget stacks datasets with colors and legend | pass | todo | rendered as text |
| WID-018 | stepper widget: completed, current (with link) and upcoming steps | pass | todo | rendered as text |
| WID-019 | table widget: headers, rows, alignment and fixed totals row | pass | todo | rendered as text |
| WID-016 | statistics widget: count, context, colored percentage change and links | pass | pass | rendered as text |
| WID-017 | stackedBarChart widget stacks datasets with colors and legend | pass | pass | rendered as text; TUI: segments per dataset |
| WID-018 | stepper widget: completed, current (with link) and upcoming steps | pass | pass | rendered as text |
| WID-019 | table widget: headers, rows, alignment and fixed totals row | pass | pass | rendered as text |
| WID-020 | usaMap widget plots each marker with name and coordinates | todo | todo | usaMap markers |
| WID-021 | process widget runs the sample interactive process inline | todo | todo | process widget |
| WID-022 | parentWidget renders its children in a 12-column grid | todo | todo | parent widget children |
| WID-023 | composite widget renders QQQ blocks (big numbers, up/down, links, tooltips) | pass | todo | rendered as text |
| WID-023 | composite widget renders QQQ blocks (big numbers, up/down, links, tooltips) | pass | pass | rendered as text; TUI: big numbers, up/down |
| WID-024 | childRecordList widget without association binding lists joined child records | pass | todo | rendered as text |
| WID-025 | customComponent widget loads its bundle and renders the named component | n/a | n/a | customComponent is web-only |
| WID-026 | cronUI widget shows the record's schedule expression, description and time zone | todo | todo | developer and editor widgets |
@@ -369,7 +386,7 @@ The TUI column fills in during M3.
| WID-031 | rowBuilder widget without association binding shows its rows read-only | todo | todo | developer and editor widgets |
| WID-032 | scriptViewer widget lists script revisions and shows the selected revision's files | todo | todo | developer and editor widgets |
| WID-033 | quickSightChart against a real AWS QuickSight account and dashboard | n/a | todo | QuickSight |
| WID-040 | dashboard renders app widgets in declared order sized by gridColumns (twelfths) | todo | todo | widgets per app |
| WID-040 | dashboard renders app widgets in declared order sized by gridColumns (twelfths) | todo | partial | widgets per app; TUI: declared order, v reorders; one column |
| WID-041 | widget chrome: card vs plain (isCard=false), sublabel, metadata and payload footer HTML | todo | todo | footer text |
| WID-042 | header icons (topLeftInsideCard/topRightInsideCard) with metadata colors | n/a | n/a | card chrome |
| WID-043 | widget label tooltip from metadata on hover and keyboard focus | n/a | n/a | card chrome |
@@ -380,16 +397,16 @@ The TUI column fills in during M3.
| WID-048 | DATE_PICKER dropdown sends the chosen date | pass | todo | --input |
| WID-049 | required dropdowns: the parent waits with the backend message until selections are made | todo | todo | required dropdowns |
| WID-050 | storeDropdownSelections persists selections across reload; stale stored options are dropped | n/a | todo | |
| WID-051 | failing widget renderer shows a contained error with the backend message and retry | pass | todo | rendered as text |
| WID-051 | failing widget renderer shows a contained error with the backend message and retry | pass | pass | rendered as text; TUI: error contained to the widget |
| WID-052 | empty payloads show descriptive empty states for every display type | todo | todo | empty states |
| WID-053 | malformed payloads are contained to their own widget | pass | todo | unknown shapes shown raw |
| WID-053 | malformed payloads are contained to their own widget | pass | pass | unknown shapes shown raw |
| WID-054 | widget permissions: a denied widget is absent from metadata, not rendered and not requested; the API denies it | pass | todo | rendered as text |
| WID-055 | parent widget TABS layout switches tabs and remembers the selected tab | n/a | n/a | card chrome |
| WID-056 | parent widget skips denied and unknown children without requests | todo | todo | |
| WID-057 | composite renders every block type with its values and styles | todo | todo | every block type |
| WID-058 | composite layouts and slot links/tooltips | n/a | n/a | card chrome |
| WID-059 | unsupported block type shows a contained warning | pass | todo | rendered as text |
| WID-060 | record-view section widgets receive the record context (id, tableName) | pass | todo | --input id=… |
| WID-060 | record-view section widgets receive the record context (id, tableName) | pass | partial | --input id=…; TUI: record section widgets get the id |
| WID-061 | process widget for a persona without process permission is contained | pass | todo | rendered as text |
| WID-062 | custom component whose bundle cannot load shows a contained error | n/a | n/a | card chrome |
| WID-063 | alerts with hideWidget or without HTML render nothing | pass | todo | |
@@ -422,8 +439,8 @@ The TUI column fills in during M3.
| WID-074 | Standalone composite scalar controls and metadata editors | todo | todo | developer and editor widgets |
| WID-075 | Standalone DATE_TIME blocks localize seeded instants without losing seconds | todo | todo | developer and editor widgets |
| HLP-001 | App and widget help | partial | todo | qqq help show app/widget/instance; the sample carries no help content |
| HLP-002 | Table, field and section help | partial | todo | qqq help show table <t> [field]: table, section and field help; no sample content |
| HLP-002 | Table, field and section help | partial | partial | qqq help show table <t> [field]: table, section and field help; no sample content; TUI: F1 shows table and field help; the sample has none |
| HLP-003 | Query columns and table action help | partial | todo | field help is the column help; table action help slots print with their slot name; no sample content |
| HLP-004 | Process, step and record action help | partial | todo | qqq help show process <p>: process, step, HELP_TEXT and step field help; no sample content |
| HLP-005 | Help search | pass | todo | qqq help-search |
| HLP-005 | Help search | pass | pass | qqq help-search; TUI: type in F1 |
| HLP-006 | Help author affordance | n/a | todo | author affordance |
+19 -8
View File
@@ -35,16 +35,28 @@ code, hidden error or an inconsistency a user meets; **low** = polish.
## Requested additions (coordinator)
- **Download directory.** `qqq config set|get download-dir`, `upload-dir`, `download-prompt`, kept in the `ui`
section of `~/.config/qqq/config.json` through `readUiSettings`/`writeUiSettings` in `session/profiles.ts` (the
section and key names the TUI uses: `defaultDownloadDir`, `defaultUploadDir`, `downloadPrompt`). `table download`,
- **Download directory.** `qqq config set|get download-dir`, `upload-dir`, `download-mode`, `theme`, kept in the
`ui` section of `~/.config/qqq/config.json` through the TUI's kernel helpers (see "Merge with the TUI stream").
`table download`,
`table export`, `report run` and process downloads default to it (created when missing) when no `--out` /
`--out-dir` is given. An explicit `widget get --csv <file>` is written where named (a named path is never moved).
- **`qqq doctor`.** Profile, reachability, UI API version range (1.6.0 to 1.8.0, major from `supportedMajorVersions`),
authentication type, sign-in accepted, extensions and minimums, keyring, cache age; `--conformance` runs the
conformance package's read-only checks. Exit 1 lists the failures. The terminal half calls the kernel's
`checkTerminalEnvironment()` by name (reported as `skip` until the TUI branch's module lands); `--fix` applies its
fixes after two confirmations.
conformance package's read-only checks. Exit 1 lists the failures. The terminal half is the kernel's
`checkEnvironment`/`formatReport` (see "Merge with the TUI stream").
## Merge with the TUI stream (feat/m1-kernel-cli at 165c002)
One line per decision taken while merging the base into this branch:
- `main.ts` is the base's thin entry over `program.ts`; it still awaits `importLegacyCredentials()` before parsing (finding 4).
- `program.ts` (`createProgram`) carries the option fixes of finding 1 (`--version` only, `--server` without `-s`) and registers `qqq doctor`.
- `config.json` `ui` section: the TUI's names win, one set of helpers. My `readUiSettings`/`writeUiSettings` and their `defaultDownloadDir`/`defaultUploadDir`/`downloadPrompt` keys are gone; the CLI reads and writes `ui.files` through `readFilePreferences`/`writeFilePreferences` (files/navigator.ts) and the theme through `readTheme`/`writeTheme`. `download-prompt` became `download-mode ask|silent`, the TUI's setting; `qqq config set|get theme` was added (`--profile` sets one profile's theme).
- `readConfig` keeps the base's shape (profiles, `ui` with theme, views, files) plus finding 3: a corrupt file is refused (`ConfigFileError`) and unknown sections are kept. The profiles test now exercises files, theme and an unknown section together.
- `qqq doctor`'s terminal rows come from `checkEnvironment`, with `formatReport` printed under the table; `--fix` asks before each `applyLiveFix` (`--yes` answers those) and calls `appendTmuxConf` only after a y typed at a terminal (`--yes` never does).
- Prompts and `runCli` coexist: `runCli` sets `stdin.isTTY` false, so `prompt.ask`, `confirm` and the variant chooser refuse with a usage error in the TUI's `:` pane instead of opening readline; output written straight to `process.stdout` (`--out -`) is captured by `runCli`. A test runs `runCli` in a child process to prove both.
- `@qrun-io/qqq-ui-api-conformance` is a dependency of the CLI package (for `doctor --conformance`); the release agent sets the published version.
- `docs/parity/feature-matrix.md` was regenerated from the merged `scripts/parity-matrix.py` (the base's TUI column plus this branch's REC-022, REC-033 and PRC-014 notes).
## Found and left
@@ -56,11 +68,10 @@ code, hidden error or an inconsistency a user meets; **low** = polish.
| low | `apps show` and `saved-view list` show `?` for a count that failed, without the reason. | Display-only; acceptable, but could print the error on stderr. |
| low | Remaining short-option overlaps without a collision: `-o` is `--order` on queries and `--out` on download/report/api; `-f` is `--fields` and `job logs --follow`; `-i` is `--interactive` and `widget get --input`. | No command has both meanings; renaming would break documented usage. |
| low | `collab presence --watch` exits 0 on Ctrl-C while `tail` exits 130. | Presence leaves its lease cleanly, so 0 is defensible; align if `tail`'s 130 is the house rule. |
| info | `qqq doctor --conformance` loads `@qrun-io/qqq-ui-api-conformance` dynamically; it is not a dependency of the CLI package. | Release engineering: add it as a dependency (or optional dependency) of `@qrun-io/qqq-cli`. |
## Verified
- `npm run build`, `npm test` (ui-api-client 17, core 96, conformance 3, cli 63 incl. the golden suite in about 4 s) and `npm run lint`.
- `npm run build`, `npm test` after the merge (ui-api-client 17, core 109, conformance 3, cli 68 incl. the golden suite in about 5 s) and `npm run test:tui` (9) and `npm run lint`.
- Every `qqq` command block in `TRY-IT.md` run against the sample server (UI API 1.8.0, MOCK) from this worktree,
except the sign-in lines and the OAuth (8078) and collaboration (8098) servers, which need servers this review did
not start. Commands that need a terminal (`-i`, delete without `--yes`, the validation choice) refuse with exit 2
+2
View File
@@ -25,3 +25,5 @@ the sample app and MOCK authentication. Each is a candidate issue for `QRun-IO/q
| 18 | qbit-collaboration 1.1.0: when the last viewer leaves, `presence.changed` (and the table snapshot) is `{"table":"person"}` with no `viewers`, although `CollaborationPresenceSnapshotV1.viewers` is a non-nullable array. A client that waits for `viewers` keeps showing the last person. Seen on the qbit's `CollaborationDevServer` (QQQ 4.1.0-RC.15). | Two sessions (`?user=bob` and alice); bob POSTs then DELETEs `/qbit/collaboration/presence` while alice is subscribed to `table:person`. | The kernel treats a payload with `table` as the snapshot and missing `viewers` as none. The qbit should send `"viewers": []`. |
| 19 | The v1 insert, update and delete routes do not read a table variant: `TableInsertExecutor`, `TableUpdateExecutor` and `TableDeleteExecutor` never call `ExecutorSessionUtils.setTableVariantInSession`, and the spec gives them no `tableVariant` (query, count, get, export and the process routes have it). A client cannot write to a variant-backed table over v1. | Read `executors/Table{Insert,Update,Delete}Executor.java`; the legacy `QJavalinImplementation.setupSession` read a `tableVariant` form or query parameter for every route. | `qqq table create/update/delete/copy --variant` send `tableVariant` as a query parameter (as the get route takes it) and say the server may ignore it. Add the parameter to the three routes. |
| 20 | Joins do not reach the export route: `exportTable` has no `joins` member and a joined name in `fieldNames` (`pet.name` on `person`, a join the query route accepts) answers 500 `Field name pet.name was not found on the Person table.` The web UI exports joined columns by name; unclear whether exposed joins work there. | `qqq api exportTable --path tableName=person --body-json '{"format":"csv","fieldNames":["id","pet.name"]}'` | `qqq table export` accepts joined columns of exposed joins only and checks them before sending. |
| 21 | `GET /version` lists the `recordSearch` feature, but `POST /search` on the sample answers `{"results":[],"tables":[],"provider":{"status":"NONE"}}` for any text: the feature is advertised with no provider behind it, so a client cannot tell "nothing matched" from "this server cannot search" without reading `provider.status`. | `qqq api searchRecords --body-json '{"query":"avery"}'` | The TUI palette reads `provider.status` and says the server has no record search. Either list `recordSearch` only when a provider is configured, or document `provider.status` as the signal. |
| 22 | The `multiStatistics` widget payload names its list `statisticList` (singular) inside `statisticsGroupData`, while other statistics payloads and the kernel's first reading used `statisticsList`; the OpenAPI document does not type the group, so a client can only guess. | `qqq widget get SampleMultiStatisticsWidget --output json` | The kernel render model accepts both names. Type `MultiStatisticsData` group items in the spec. |
+1
View File
@@ -2959,6 +2959,7 @@
"dependencies": {
"@qrun-io/qqq-core": "0.1.0-dev",
"@qrun-io/qqq-ui-api-client": "1.8.0-rc.1",
"@qrun-io/qqq-ui-api-conformance": "1.6.0-rc.1",
"commander": "15.0.0"
},
"bin": {
+3 -1
View File
@@ -21,7 +21,9 @@
"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"
"lint": "node scripts/lint.mjs",
"test:tui": "cd packages/tui && bun install --frozen-lockfile && bun test",
"typecheck:tui": "cd packages/tui && bun install --frozen-lockfile && npx tsc -p tsconfig.json"
},
"devDependencies": {
"@stoplight/prism-cli": "5.16.0",
+11
View File
@@ -25,7 +25,18 @@
},
"dependencies": {
"@qrun-io/qqq-ui-api-client": "1.8.0-rc.1",
"@qrun-io/qqq-ui-api-conformance": "1.6.0-rc.1",
"commander": "15.0.0",
"@qrun-io/qqq-core": "0.1.0-dev"
},
"exports": {
".": {
"types": "./dist/main.d.ts",
"default": "./dist/main.js"
},
"./program": {
"types": "./dist/program.d.ts",
"default": "./dist/program.js"
}
}
}
+31 -18
View File
@@ -6,7 +6,7 @@
import type { Command } from 'commander'
import { homedir } from 'node:os'
import { resolve } from 'node:path'
import { configDir, readConfig, readUiSettings, removeProfile, upsertProfile, writeUiSettings, type UiSettings } from '@qrun-io/qqq-core'
import { configDir, readConfig, readFilePreferences, readTheme, removeProfile, upsertProfile, writeFilePreferences, writeTheme } from '@qrun-io/qqq-core'
import { UsageError, type Context } from '../context.js'
import { getLogin, removeLogin, secretStoreKind } from '../session.js'
@@ -21,12 +21,32 @@ export function registerConfig(program: Command, ctx: () => Context): void {
c.print({ data: { configDir: configDir(), secretStore: secretStoreKind(), profile: t.name, chosenBy: t.source, server: t.server, signedIn: login ? (login.kind === 'session' ? `${login.authType.toLowerCase()} session` : 'oauth2') : 'no', defaultProfile: cfg.defaultProfile ?? '', profiles: Object.keys(cfg.profiles).length } })
})
/** Local settings: the CLI name and the key in the `ui` section the TUI shares. */
const SETTINGS: Record<string, { key: keyof UiSettings & string; kind: 'dir' | 'boolean'; help: string }> = {
'download-dir': { key: 'defaultDownloadDir', kind: 'dir', help: 'where downloads go without --out / --out-dir (created when missing)' },
'upload-dir': { key: 'defaultUploadDir', kind: 'dir', help: 'where file pickers for uploads start' },
'download-prompt': { key: 'downloadPrompt', kind: 'boolean', help: 'whether the TUI asks before saving a download' },
/**
* Local settings, kept in the `ui` section of config.json through the kernel helpers the TUI
* uses: the file preferences (files/navigator.ts) and the theme (session/profiles.ts).
*/
const SETTINGS: Record<string, { help: string; get: () => unknown; set: (value: string | undefined, profile?: string) => unknown }> = {
'download-dir': { help: 'where downloads go without --out / --out-dir (created when missing)', get: () => readFilePreferences().downloadDir, set: (v) => writeFilePreferences({ downloadDir: v === undefined ? undefined : absolute(v) }).downloadDir },
'upload-dir': { help: 'where the TUI\'s upload chooser starts', get: () => readFilePreferences().uploadDir, set: (v) => writeFilePreferences({ uploadDir: v === undefined ? undefined : absolute(v) }).uploadDir },
'download-mode': {
help: 'the TUI: ask (a save dialog each time) or silent (straight to the download directory)',
get: () => readFilePreferences().downloadMode,
set: (v) => {
if (v !== undefined && v !== 'ask' && v !== 'silent') throw new UsageError('download-mode takes ask or silent')
return writeFilePreferences({ downloadMode: v }).downloadMode
},
},
theme: {
help: 'the TUI theme (--profile to set it for one profile; qqq ui lists the themes)',
get: () => readTheme(selectedProfile()),
set: (v, profile) => {
writeTheme(v, { profile })
return v
},
},
}
const absolute = (path: string) => resolve(path.replace(/^~(?=$|[\\/])/, homedir()))
const selectedProfile = () => (program.opts() as { profile?: string }).profile ?? process.env.QQQ_PROFILE
const setting = (name: string) => {
const s = SETTINGS[name]
if (!s) throw new UsageError(`No setting "${name}"; settings: ${Object.keys(SETTINGS).join(', ')}`)
@@ -37,27 +57,20 @@ export function registerConfig(program: Command, ctx: () => Context): void {
.description(`A local setting (${Object.keys(SETTINGS).join(', ')}), or all of them.`)
.action((name: string | undefined) => {
const c = ctx()
const ui = readUiSettings()
if (name) {
const value = ui[setting(name).key]
const value = setting(name).get()
c.print({ data: value ?? null, rows: [{ setting: name, value: value ?? '' }], columns: ['setting', 'value'] })
return
}
c.print({ data: ui, rows: Object.entries(SETTINGS).map(([n, s]) => ({ setting: n, value: ui[s.key] ?? '', meaning: s.help })), columns: ['setting', 'value', 'meaning'] })
const all = Object.fromEntries(Object.entries(SETTINGS).map(([n, s]) => [n, s.get() ?? null]))
c.print({ data: all, rows: Object.entries(SETTINGS).map(([n, s]) => ({ setting: n, value: s.get() ?? '', meaning: s.help })), columns: ['setting', 'value', 'meaning'] })
})
config.command('set <setting> <value>')
.description('Change a local setting (kept in the ui section of config.json, shared with the TUI); an empty value removes it.')
.description('Change a local setting (the ui section of config.json, shared with the TUI); an empty value removes it.')
.action((name: string, raw: string) => {
const c = ctx()
const s = setting(name)
let value: string | boolean | undefined
if (raw === '') value = undefined
else if (s.kind === 'boolean') {
if (!/^(true|false|on|off|yes|no)$/i.test(raw)) throw new UsageError(`${name} takes true or false`)
value = /^(true|on|yes)$/i.test(raw)
} else value = resolve(raw.replace(/^~(?=$|[\\/])/, homedir()))
writeUiSettings({ [s.key]: value })
const value = setting(name).set(raw === '' ? undefined : raw, name === 'theme' ? (program.opts() as { profile?: string }).profile : undefined)
c.note(value === undefined ? `Removed ${name}.` : `${name}: ${String(value)}`)
})
+40 -22
View File
@@ -7,14 +7,14 @@
* `qqq doctor`: is this client ready to use? The connectivity side (profile, server reachable, UI
* API version within the supported range, authentication type, sign-in accepted, extensions,
* keyring, metadata cache age, and with --conformance the read-only conformance checks) and the
* terminal side (the kernel's checkTerminalEnvironment: tmux key bindings, alternate screen,
* colour, unicode, size). Exit 0 when every check passes, 1 with the failing ones listed.
* terminal side (the kernel's checkEnvironment: tmux key bindings, alternate screen, colour,
* unicode, size; --fix applies its live tmux fixes and, after an explicit y, appends them to
* ~/.tmux.conf). Exit 0 when every check passes, 1 with the failing ones listed.
*/
import type { Command } from 'commander'
import { UI_API_VERSION, getAuthenticationMetaData } from '@qrun-io/qqq-ui-api-client'
import * as core from '@qrun-io/qqq-core'
import { callOperation, readCachedMetaData, type ServerVersion } from '@qrun-io/qqq-core'
import { appendTmuxConf, applyLiveFix, callOperation, checkEnvironment, formatReport, readCachedMetaData, type Finding, type Report, type ServerVersion } from '@qrun-io/qqq-core'
import { EXIT, type Context } from '../context.js'
import { CLI_VERSION, authenticatedFetch, currentToken, getLogin, secretStoreKind } from '../session.js'
import type { Json } from './shared.js'
@@ -109,17 +109,40 @@ export async function connectivityChecks(c: Context, options: { conformance?: bo
return out
}
/** The terminal report of the kernel, when the kernel has it (the TUI branch adds it). */
interface TerminalReport { checks: Array<{ check: string; status: CheckStatus; detail: string; fix?: unknown }>; applyFixes?: (options: { permanent?: boolean }) => Promise<string[]> }
/** The terminal side: the kernel's checkEnvironment (tmux bindings, alternate screen, colour, unicode, size). */
function terminalChecks(): { checks: DoctorCheck[]; report: Report } {
const report = checkEnvironment({ columns: process.stdout.columns, rows: process.stdout.rows })
const checks = report.findings.map((f): DoctorCheck => ({ area: 'terminal', check: f.id, status: f.level === 'warn' ? 'warn' : 'ok', detail: `${f.message}${f.advice ? ` (${f.advice})` : ''}${f.live ? ' [--fix]' : ''}` }))
if (!checks.length) checks.push({ area: 'terminal', check: report.terminal || 'terminal', status: 'ok', detail: `nothing to report${report.inTmux ? ' (in tmux)' : ''}` })
return { checks, report }
}
async function terminalChecks(): Promise<{ checks: DoctorCheck[]; report?: TerminalReport }> {
// TODO(merge with the TUI branch): checkTerminalEnvironment() is the kernel's terminal check
// (tmux root bindings that take PageUp/PageDown/F-keys, alternate screen, truecolor, unicode,
// size, terminal notes, tmux fixes). Looked up by name so this compiles before it lands.
const check = (core as Record<string, unknown>).checkTerminalEnvironment as undefined | (() => Promise<TerminalReport> | TerminalReport)
if (!check) return { checks: [{ area: 'terminal', check: 'environment', status: 'skip', detail: 'this build has no terminal checks yet (the kernel\'s checkTerminalEnvironment)' }] }
const report = await check()
return { report, checks: report.checks.map((x) => ({ area: 'terminal', check: x.check, status: x.status, detail: x.detail })) }
/**
* --fix: each live tmux fix after its own question (--yes answers them), then the ~/.tmux.conf
* append only after an explicit y typed at the terminal (--yes does not answer that one).
*/
async function applyFixes(c: Context, report: Report): Promise<void> {
const fixable = report.findings.filter((f) => f.live?.length)
if (!fixable.length) {
c.note('No terminal fixes to apply.')
return
}
const applied: Finding[] = []
for (const finding of fixable) {
if (!await c.confirm(`${finding.message} Fix it for this tmux server now?`)) continue
applyLiveFix(finding)
applied.push(finding)
c.note(` fixed for this tmux server: ${finding.id}`)
}
const permanent = applied.filter((f) => f.permanent)
if (!permanent.length) return
if (!process.stdin.isTTY) {
c.note('Not made permanent: that needs an explicit y at a terminal (run qqq doctor --fix there).')
return
}
const { ask } = await import('../prompt.js')
const answer = await ask(`Make ${permanent.length === 1 ? 'it' : 'them'} permanent (append to ~/.tmux.conf)? [y/N] `)
if (/^y(es)?$/i.test(answer)) c.note(` appended to ${appendTmuxConf(permanent)}`)
}
export function registerDoctor(program: Command, ctx: () => Context): void {
@@ -129,7 +152,7 @@ export function registerDoctor(program: Command, ctx: () => Context): void {
.option('--fix', 'apply the terminal fixes the report offers (asks first)')
.action(async (options: { conformance?: boolean; fix?: boolean }) => {
const c = ctx()
const terminal = await terminalChecks()
const terminal = terminalChecks()
const checks = [...await connectivityChecks(c, options), ...terminal.checks]
const failed = checks.filter((x) => x.status === 'fail')
c.print({
@@ -138,13 +161,8 @@ export function registerDoctor(program: Command, ctx: () => Context): void {
columns: ['area', 'check', 'status', 'detail'],
footer: failed.length ? `(${failed.length} of ${checks.length} checks failed: ${failed.map((x) => x.check).join(', ')})` : `(all ${checks.length} checks passed${checks.some((x) => x.status === 'warn') ? '; see the warnings' : ''})`,
})
if (options.fix) {
if (!terminal.report?.applyFixes) c.note('No terminal fixes to apply.')
else if (await c.confirm('Apply the terminal fixes now (this tmux session)?')) {
for (const line of await terminal.report.applyFixes({})) c.note(line)
if (await c.confirm('Make them permanent (append to ~/.tmux.conf)?')) for (const line of await terminal.report.applyFixes({ permanent: true })) c.note(line)
}
}
if (c.format === 'table' && terminal.report.findings.length) c.note(`\n${formatReport(terminal.report)}`)
if (options.fix) await applyFixes(c, terminal.report)
if (failed.length) process.exitCode = EXIT.error
})
}
+5 -4
View File
@@ -5,7 +5,7 @@
import { mkdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { displayDateTime, readUiSettings, saveDownload, valueText as recordValueText, type DisplayOptions, type FieldMeta, type RecordLike } from '@qrun-io/qqq-core'
import { displayDateTime, readFilePreferences, saveDownload, valueText as recordValueText, type DisplayOptions, type FieldMeta, type RecordLike } from '@qrun-io/qqq-core'
import { UsageError } from '../context.js'
export type Json = Record<string, unknown>
@@ -63,11 +63,12 @@ export function readFileArg(path: string | number, flag: string): Uint8Array {
}
/**
* Where a download goes when no --out / --out-dir names a place: the `download-dir` setting
* (`qqq config set download-dir`), created when missing, else the current directory.
* Where a download goes when no --out / --out-dir names a place: the download directory of the
* file preferences the TUI shares (`qqq config set download-dir`), created when missing, else the
* current directory.
*/
export function downloadDir(): string {
const dir = readUiSettings().defaultDownloadDir
const dir = readFilePreferences().downloadDir
if (!dir) return '.'
mkdirSync(dir, { recursive: true })
return dir
+47
View File
@@ -0,0 +1,47 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Command } from 'commander'
import { UsageError, type Context } from '../context.js'
/** The TUI entry in this checkout (dev); a packaged binary replaces this in M5. */
export function tuiEntry(): string {
const here = dirname(fileURLToPath(import.meta.url))
return resolve(join(here, '..', '..', '..', 'tui', 'src', 'main.tsx'))
}
export function registerUi(program: Command, _ctx: () => Context): void {
program.command('ui [route...]')
.description('Open the terminal UI (OpenTUI on Bun). A route opens a screen directly, in the CLI grammar: `qqq ui table query person -w "lastName STARTS_WITH S"`, `qqq ui table get person 3`, `qqq ui process run sleepInteractive`.')
.option('--theme <name>', 'high-contrast (default), classic or light; saved for next time')
.option('--ascii', 'ASCII glyphs instead of box drawing and block characters')
.option('--linear', 'one region at a time, no side pane (as QQQ_UI_LINEAR=1)')
.option('--mouse', 'enable the mouse (off by default)')
.option('--check', 'check the terminal and tmux setup, offer fixes, and exit')
.option('--no-check', 'skip the quick terminal check at startup')
.allowUnknownOption(true)
.action((route: string[], options: { theme?: string; ascii?: boolean; linear?: boolean; mouse?: boolean; check?: boolean }) => {
const entry = tuiEntry()
if (!existsSync(entry)) throw new UsageError(`The TUI is not in this installation (looked for ${entry}).`)
const globals = program.opts<{ profile?: string; server?: string }>()
const args = ['run', entry]
if (globals.profile) args.push('--profile', globals.profile)
if (globals.server) args.push('--server', globals.server)
if (options.theme) args.push('--theme', options.theme)
if (options.ascii) args.push('--ascii')
if (options.linear) args.push('--linear')
if (options.mouse) args.push('--mouse')
if (options.check === true) args.push('--check')
if (options.check === false) args.push('--no-check')
if (route.length) args.push('--', ...route)
const result = spawnSync(process.env.QQQ_BUN ?? 'bun', args, { stdio: 'inherit' })
if (result.error) throw new UsageError(`Cannot start Bun (${result.error.message}); install it (https://bun.sh) or set QQQ_BUN.`)
process.exitCode = result.status ?? 1
})
}
+13 -3
View File
@@ -14,6 +14,16 @@ import { Collaboration, ExtensionNotInstalledError, OperationInputError, Extensi
import { openRealtimeStream } from '@qrun-io/qqq-ui-api-client'
import { CLIENT_CHANNEL, authenticatedFetch, clientFor, currentToken, selectTarget, sessionCookieFor, target, type Target } from './session.js'
/**
* Where output goes. The CLI writes to the process streams; `runCli` (the TUI's `:` command line)
* points these at a pane for the duration of one command.
*/
export const io = {
out: (text: string): void => { process.stdout.write(text) },
err: (text: string): void => { process.stderr.write(text) },
isTTY: (): boolean => Boolean(process.stdout.isTTY),
}
/** Exit codes, as in the design doc (KofTwentyTwo ADR-0031 shape). */
export const EXIT = { ok: 0, error: 1, usage: 2, auth: 3, transient: 4, notFound: 5, validation: 6, unexpected: 70 } as const
@@ -63,7 +73,7 @@ export class Context {
get format(): OutputFormat {
if (this.options.output) return this.options.output
if (this.options.json) return 'json'
return defaultFormat(Boolean(process.stdout.isTTY))
return defaultFormat(io.isTTY())
}
/** How DATE_TIME values are shown: in the TZ zone, or raw UTC instants with --utc. */
@@ -78,7 +88,7 @@ export class Context {
/** Prints a result in the chosen format, after --query. */
print(printable: Printable): void {
process.stdout.write(render(printable, this.format, this.options.query) + '\n')
io.out(render(printable, this.format, this.options.query) + '\n')
}
/** Reshapes data with --query without printing (for callers that print themselves). */
@@ -146,7 +156,7 @@ export class Context {
/** A note for people, on stderr so pipes stay clean. */
note(text: string): void {
process.stderr.write(text + '\n')
io.err(text + '\n')
}
}
+5 -78
View File
@@ -12,85 +12,12 @@
* commands of 0.1 (apps, tables, query, get, run, jobs, tail) as aliases.
*/
import { Command, CommanderError } from 'commander'
import { UI_API_VERSION } from '@qrun-io/qqq-ui-api-client'
import { Context, EXIT, describeError } from './context.js'
import { CLI_VERSION, importLegacyCredentials } from './session.js'
import { registerApi } from './commands/api.js'
import { registerAudit } from './commands/audit.js'
import { registerAuth } from './commands/auth.js'
import { registerCompletion } from './commands/completion.js'
import { registerConfig } from './commands/config.js'
import { registerDoctor } from './commands/doctor.js'
import { registerExtensions } from './commands/extensions.js'
import { registerHelp } from './commands/help.js'
import { registerJob } from './commands/job.js'
import { registerMisc } from './commands/misc.js'
import { registerProcess } from './commands/process.js'
import { registerSavedView } from './commands/saved-view.js'
import { registerRelated } from './commands/related.js'
import { registerTable } from './commands/table.js'
import { registerWidget } from './commands/widget.js'
const program = new Command()
program
.name('qqq')
.description('Command-line client for QQQ applications, built only on the QQQ UI API (/qqq/v1).')
.version(`${CLI_VERSION} (UI API client ${UI_API_VERSION})`, '--version', 'print the version')
.option('--server <url>', 'the QQQ server (default: QQQ_SERVER, or the default profile)')
.option('-p, --profile <name>', 'the profile to use (default: QQQ_PROFILE, or the default profile)')
.option('-O, --output <format>', 'json, table, text, csv or yaml (default: table on a terminal, json in a pipe; QQQ_OUTPUT)')
.option('--json', 'same as --output json')
.option('-Q, --query <jmespath>', 'reshape the output with a JMESPath expression, as `aws --query`')
.option('--fields <names>', 'comma-separated columns to show in table, text and csv output')
.option('-y, --yes', 'answer yes to confirmations (delete, cancel)')
.option('--utc', 'show DATE_TIME values as raw UTC instants instead of in the local zone (TZ)')
.option('--trace', 'print stack traces for unexpected errors')
.exitOverride()
.configureOutput({ writeErr: (text) => process.stderr.write(text) })
let context: Context | undefined
const ctx = () => (context ??= new Context(program))
registerAuth(program, ctx)
registerMisc(program, ctx)
registerTable(program, ctx)
registerAudit(program, ctx)
registerRelated(program, ctx)
registerProcess(program, ctx)
registerSavedView(program, ctx)
registerJob(program, ctx)
registerWidget(program, ctx)
registerApi(program, ctx)
registerExtensions(program, ctx)
registerCompletion(program, ctx)
registerConfig(program, ctx)
registerDoctor(program, ctx)
registerHelp(program, ctx)
/** Flat aliases from 0.1: forward to the noun-verb commands with the same options. */
function alias(flat: string, noun: string, verb: string, description: string): void {
const target = program.commands.find((c) => c.name() === noun)!.commands.find((c) => c.name() === verb)!
const cmd = program.command(`${flat} ${target.registeredArguments.map((a) => (a.required ? `<${a.name()}>` : `[${a.name()}]`)).join(' ')}`.trim(), { hidden: true }).description(`${description} (alias of \`qqq ${noun} ${verb}\`)`)
for (const option of target.options) cmd.addOption(option)
cmd.action(async (...args: unknown[]) => {
const commandArgs = args.slice(0, -2) as string[]
const options = args[args.length - 2] as Record<string, unknown>
const argv: string[] = [...commandArgs]
for (const option of target.options) {
const value = options[option.attributeName()]
if (value === undefined || value === false) continue
if (value === true) argv.push(option.long!)
else if (Array.isArray(value)) for (const v of value) argv.push(option.long!, String(v))
else argv.push(option.long!, String(value))
}
await target.parseAsync(argv, { from: 'user' })
})
}
alias('query', 'table', 'query', 'Query a table')
alias('get', 'table', 'get', 'View one record')
alias('run', 'process', 'run', 'Run a process')
import { CommanderError } from 'commander'
import { EXIT, describeError } from './context.js'
import { createProgram } from './program.js'
import { importLegacyCredentials } from './session.js'
const program = createProgram()
importLegacyCredentials().then(() => program.parseAsync()).catch((error: unknown) => {
if (error instanceof CommanderError) {
process.exitCode = error.exitCode === 0 ? 0 : EXIT.usage
+139
View File
@@ -0,0 +1,139 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The qqq program as a library: `createProgram()` builds the commander program with every command,
* and `runCli(argv, { stdout, stderr })` runs one command line in-process with its output sent to
* the given writers. The TUI's `:` command line uses runCli, so the CLI grammar is the TUI's URL
* scheme and both heads share one dispatcher.
*/
import { Command, CommanderError } from 'commander'
import { UI_API_VERSION } from '@qrun-io/qqq-ui-api-client'
import { Context, EXIT, describeError, io } from './context.js'
import { CLI_VERSION } from './session.js'
import { registerApi } from './commands/api.js'
import { registerAudit } from './commands/audit.js'
import { registerAuth } from './commands/auth.js'
import { registerCompletion } from './commands/completion.js'
import { registerConfig } from './commands/config.js'
import { registerDoctor } from './commands/doctor.js'
import { registerExtensions } from './commands/extensions.js'
import { registerHelp } from './commands/help.js'
import { registerJob } from './commands/job.js'
import { registerMisc } from './commands/misc.js'
import { registerProcess } from './commands/process.js'
import { registerSavedView } from './commands/saved-view.js'
import { registerRelated } from './commands/related.js'
import { registerTable } from './commands/table.js'
import { registerUi } from './commands/ui.js'
import { registerWidget } from './commands/widget.js'
/** Builds a fresh program (a new Context per run). */
export function createProgram(): Command {
const program = new Command()
program
.name('qqq')
.description('Command-line client for QQQ applications, built only on the QQQ UI API (/qqq/v1).')
.version(`${CLI_VERSION} (UI API client ${UI_API_VERSION})`, '--version', 'print the version')
.option('--server <url>', 'the QQQ server (default: QQQ_SERVER, or the default profile)')
.option('-p, --profile <name>', 'the profile to use (default: QQQ_PROFILE, or the default profile)')
.option('-O, --output <format>', 'json, table, text, csv or yaml (default: table on a terminal, json in a pipe; QQQ_OUTPUT)')
.option('--json', 'same as --output json')
.option('-Q, --query <jmespath>', 'reshape the output with a JMESPath expression, as `aws --query`')
.option('--fields <names>', 'comma-separated columns to show in table, text and csv output')
.option('-y, --yes', 'answer yes to confirmations (delete, cancel)')
.option('--utc', 'show DATE_TIME values as raw UTC instants instead of in the local zone (TZ)')
.option('--trace', 'print stack traces for unexpected errors')
.exitOverride()
.configureOutput({ writeOut: (text) => io.out(text), writeErr: (text) => io.err(text) })
let context: Context | undefined
const ctx = () => (context ??= new Context(program))
registerAuth(program, ctx)
registerMisc(program, ctx)
registerTable(program, ctx)
registerAudit(program, ctx)
registerRelated(program, ctx)
registerProcess(program, ctx)
registerSavedView(program, ctx)
registerJob(program, ctx)
registerWidget(program, ctx)
registerApi(program, ctx)
registerExtensions(program, ctx)
registerCompletion(program, ctx)
registerConfig(program, ctx)
registerDoctor(program, ctx)
registerHelp(program, ctx)
registerUi(program, ctx)
/** Flat aliases from 0.1: forward to the noun-verb commands with the same options. */
function alias(flat: string, noun: string, verb: string, description: string): void {
const target = program.commands.find((c) => c.name() === noun)!.commands.find((c) => c.name() === verb)!
const cmd = program.command(`${flat} ${target.registeredArguments.map((a) => (a.required ? `<${a.name()}>` : `[${a.name()}]`)).join(' ')}`.trim(), { hidden: true }).description(`${description} (alias of \`qqq ${noun} ${verb}\`)`)
for (const option of target.options) cmd.addOption(option)
cmd.action(async (...args: unknown[]) => {
const commandArgs = args.slice(0, -2) as string[]
const options = args[args.length - 2] as Record<string, unknown>
const argv: string[] = [...commandArgs]
for (const option of target.options) {
const value = options[option.attributeName()]
if (value === undefined || value === false) continue
if (value === true) argv.push(option.long!)
else if (Array.isArray(value)) for (const v of value) argv.push(option.long!, String(v))
else argv.push(option.long!, String(value))
}
await target.parseAsync(argv, { from: 'user' })
})
}
alias('query', 'table', 'query', 'Query a table')
alias('get', 'table', 'get', 'View one record')
alias('run', 'process', 'run', 'Run a process')
return program
}
export interface RunCliIo {
stdout: (text: string) => void
stderr: (text: string) => void
/** Whether the output may use terminal formatting (default false: table output is still chosen by --output). */
isTTY?: boolean
}
/**
* Runs one command line in-process (argv without the program name) and returns its exit code.
* Output written by commands goes to the writers; prompts are off (no terminal), so commands that
* would ask need their values or --yes. One run at a time.
*/
export async function runCli(argv: string[], options: RunCliIo): Promise<number> {
const saved = { out: io.out, err: io.err, isTTY: io.isTTY, stdoutWrite: process.stdout.write, stderrWrite: process.stderr.write, stdinTTY: Object.getOwnPropertyDescriptor(process.stdin, 'isTTY') }
io.out = options.stdout
io.err = options.stderr
io.isTTY = () => options.isTTY ?? false
// a few commands write to the process streams directly; capture those too while the command runs
process.stdout.write = ((chunk: string | Uint8Array) => { options.stdout(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)); return true }) as typeof process.stdout.write
process.stderr.write = ((chunk: string | Uint8Array) => { options.stderr(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)); return true }) as typeof process.stderr.write
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true, writable: true })
const program = createProgram()
try {
await program.parseAsync(argv, { from: 'user' })
return typeof process.exitCode === 'number' ? process.exitCode : 0
} catch (error) {
if (error instanceof CommanderError) return error.exitCode === 0 ? 0 : EXIT.usage
const { code, text } = describeError(error, Boolean(program.opts().trace))
options.stderr(text + '\n')
return code
} finally {
process.exitCode = undefined
io.out = saved.out
io.err = saved.err
io.isTTY = saved.isTTY
process.stdout.write = saved.stdoutWrite
process.stderr.write = saved.stderrWrite
if (saved.stdinTTY) Object.defineProperty(process.stdin, 'isTTY', saved.stdinTTY)
else delete (process.stdin as { isTTY?: boolean }).isTTY
}
}
+48
View File
@@ -10,6 +10,7 @@
*/
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { after, before, describe, test } from 'node:test'
@@ -191,6 +192,53 @@ describe('doctor', () => {
})
})
describe('merged with the TUI stream', () => {
test('config set|get theme and download-mode use the TUI\'s ui helpers', async () => {
const local = sandbox()
try {
assert.equal((await runCli(['config', 'set', 'theme', 'classic'], local.env)).code, 0)
assert.equal(JSON.parse((await runCli(['config', 'get', 'theme', '-O', 'json'], local.env)).stdout), 'classic')
assert.equal((await runCli(['config', 'set', 'download-mode', 'loud'], local.env)).code, 2)
assert.equal((await runCli(['config', 'set', 'download-mode', 'silent'], local.env)).code, 0)
assert.equal((await runCli(['config', 'set', 'download-dir', join(local.env.HOME!, 'dl')], local.env)).code, 0)
const ui = (JSON.parse(readFileSync(join(local.env.QQQ_CONFIG_DIR!, 'config.json'), 'utf8')) as { ui: unknown }).ui
assert.deepEqual(ui, { theme: 'classic', files: { downloadMode: 'silent', downloadDir: join(local.env.HOME!, 'dl') } })
} finally {
local.dispose()
}
})
test('doctor reports the terminal through the kernel check', async () => {
const result = await qqq(['doctor', '-O', 'json'])
const checks = (JSON.parse(result.stdout) as { checks: Array<{ area: string; status: string }> }).checks.filter((x) => x.area === 'terminal')
assert.ok(checks.length > 0)
assert.ok(checks.every((x) => x.status !== 'skip'))
})
test('runCli captures what commands write straight to stdout (export --out -) for the TUI pane', async () => {
// in a child process: capturing process.stdout inside the test runner would eat its own reports
const script = `
import { runCli } from ${JSON.stringify(new URL('../program.js', import.meta.url).href)}
let out = '', err = ''
const io = { stdout: (t) => (out += t), stderr: (t) => (err += t) }
const exported = await runCli(['--server', process.argv[1], 'table', 'export', 'person', '--out', '-'], io)
const exportOut = out
out = ''
const refused = await runCli(['--server', process.argv[1], 'table', 'delete', 'pet', '7'], io)
process.stdout.write(JSON.stringify({ exported, exportOut, refused, err }))`
const result = await new Promise<string>((resolve, reject) => {
const child = spawn(process.execPath, ['--input-type=module', '-e', script, server.url], { env: { ...process.env, ...box.env, QQQ_TOKEN: 'test-token' } })
let text = ''
child.stdout.on('data', (d: Buffer) => (text += d.toString('utf8')))
child.on('error', reject)
child.on('close', () => resolve(text))
})
const r = JSON.parse(result) as { exported: number; exportOut: string; refused: number; err: string }
assert.equal(r.exported, 0, r.err)
assert.equal(r.exportOut, 'id,firstName\n1,Avery\n2,Blair\n')
assert.equal(r.refused, 2)
assert.match(r.err, /Pass --yes/)
})
})
describe('local state', () => {
test('a corrupt config.json is reported and left alone', async () => {
const local = sandbox()
+24
View File
@@ -0,0 +1,24 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { createProgram, runCli } from '../program.js'
test('createProgram builds every noun, including ui', () => {
const names = createProgram().commands.map((c) => c.name())
for (const n of ['table', 'process', 'widget', 'login', 'ui']) assert.ok(names.includes(n), n)
})
test('runCli sends output to the writers and returns the exit code', async () => {
const out: string[] = []
const err: string[] = []
const io = { stdout: (t: string) => { out.push(t) }, stderr: (t: string) => { err.push(t) } }
assert.equal(await runCli(['nope'], io), 2)
assert.match(err.join(''), /unknown command 'nope'/)
assert.equal(await runCli(['completion', 'bash'], io), 0)
assert.match(out.join(''), /complete/, 'a command that writes to process.stdout directly is captured')
assert.equal(process.exitCode, undefined)
})
+197
View File
@@ -0,0 +1,197 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The terminal check behind `qqq ui` (at startup, `--check`) and `qqq doctor`: tmux root-table
* bindings that take keys the TUI needs (PgUp/PgDn, F1 to F10, Home/End, Esc, `/`, `?`, `:`),
* tmux options (alternate screen, TERM, truecolor), the terminal program, colors, size. Each
* finding can carry a live fix (tmux commands for the running server, never a file edit) and the
* lines for a permanent fix (appended to ~/.tmux.conf only when the person says yes). No terminal
* UI code here; tmux is run through an injectable function so tests use fake output.
*/
import { spawnSync } from 'node:child_process'
import { appendFileSync, existsSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { readConfig, writeConfig } from '../session/profiles.js'
export type Level = 'ok' | 'info' | 'warn'
export interface Finding {
id: string
level: Level
message: string
/** tmux commands (argv) that fix it for the running server. */
live?: string[][]
/** Lines to add to ~/.tmux.conf (or another file) for good. */
permanent?: string
/** A note when the fix is in the terminal's own settings. */
advice?: string
}
export interface Report {
terminal: string
inTmux: boolean
findings: Finding[]
}
/** Runs tmux and returns stdout ('' on failure); quick, never more than a second. */
export type TmuxRunner = (args: string[]) => string
export const defaultTmux: TmuxRunner = (args) => {
const r = spawnSync('tmux', args, { encoding: 'utf8', timeout: 1000 })
return r.status === 0 ? String(r.stdout ?? '') : ''
}
/** Keys the TUI needs, as tmux names them, with the key tmux should pass on. */
const NEEDED: Record<string, string> = {
PPage: 'PPage', NPage: 'NPage', PageUp: 'PPage', PageDown: 'NPage', Home: 'Home', End: 'End', Escape: 'Escape',
F1: 'F1', F2: 'F2', F3: 'F3', F4: 'F4', F5: 'F5', F6: 'F6', F7: 'F7', F8: 'F8', F9: 'F9', F10: 'F10',
'/': '/', '?': '?', ':': ':',
}
export interface Binding {
key: string
command: string
}
/** Parses `tmux list-keys -T root` lines into key and command. */
export function parseRootBindings(output: string): Binding[] {
const out: Binding[] = []
for (const line of output.split('\n')) {
const m = /^bind-key\s+(?:-r\s+)?-T\s+root\s+(\S+)\s+(.*)$/.exec(line.trim())
if (m) out.push({ key: m[1]!.replace(/^\\/, ''), command: m[2]!.trim() })
}
return out
}
/** Whether a binding already hands the key to full-screen programs (`#{alternate_on}` then `send-keys` of the key). */
export function passesToFullScreen(binding: Binding): boolean {
return /#\{alternate_on\}/.test(binding.command) && /send-keys\s+(\S+)/.test(binding.command)
}
/** The live fix: rebind so full-screen programs get the key and the shell keeps the old behaviour. */
export function tmuxFixCommand(binding: Binding): string[] {
const pass = NEEDED[binding.key] ?? binding.key
return ['bind-key', '-n', binding.key, 'if-shell', '-F', '#{alternate_on}', `send-keys ${pass}`, binding.command || '']
}
/** The same fix as a ~/.tmux.conf line. */
export function tmuxConfLine(binding: Binding): string {
const pass = NEEDED[binding.key] ?? binding.key
const original = binding.command ? `{ ${binding.command} }` : "''"
return `bind-key -n ${binding.key} if-shell -F "#{alternate_on}" "send-keys ${pass}" ${original}`
}
export const CONF_MARK_BEGIN = '# >>> qqq ui: let full-screen programs have their keys >>>'
export const CONF_MARK_END = '# <<< qqq ui <<<'
/** Detects the terminal program from the environment. */
export function detectTerminal(env: NodeJS.ProcessEnv = process.env): string {
if (env.WT_SESSION) return 'Windows Terminal'
if (env.KITTY_WINDOW_ID || /kitty/.test(env.TERM ?? '')) return 'kitty'
if (env.WEZTERM_PANE || env.TERM_PROGRAM === 'WezTerm') return 'WezTerm'
if (env.TERM_PROGRAM === 'iTerm.app' || env.LC_TERMINAL === 'iTerm2') return 'iTerm2'
if (env.TERM_PROGRAM === 'Apple_Terminal') return 'Terminal.app'
if (env.TERM_PROGRAM === 'vscode') return 'VS Code'
return env.TERM_PROGRAM || env.TERM || 'unknown'
}
export interface CheckOptions {
env?: NodeJS.ProcessEnv
columns?: number
rows?: number
tmux?: TmuxRunner
}
/** Runs every check. */
export function checkEnvironment(options: CheckOptions = {}): Report {
const env = options.env ?? process.env
const tmux = options.tmux ?? defaultTmux
const findings: Finding[] = []
const inTmux = Boolean(env.TMUX)
const terminal = detectTerminal(env)
if (inTmux) {
const outer = tmux(['show-environment', '-g', 'TERM_PROGRAM']).replace(/^TERM_PROGRAM=/, '').trim()
const bindings = parseRootBindings(tmux(['list-keys', '-T', 'root']))
for (const b of bindings) {
if (!(b.key in NEEDED) || passesToFullScreen(b)) continue
findings.push({
id: `tmux-key-${b.key}`,
level: 'warn',
message: `tmux takes ${b.key} before any program sees it (root binding: ${b.command.slice(0, 80)}${b.command.length > 80 ? '…' : ''}).`,
live: [tmuxFixCommand(b)],
permanent: tmuxConfLine(b),
})
}
const alt = tmux(['show', '-gv', 'alternate-screen']).trim()
if (alt === 'off') findings.push({ id: 'tmux-alternate-screen', level: 'warn', message: 'tmux alternate-screen is off: full-screen programs draw into the scrollback.', live: [['set', '-g', 'alternate-screen', 'on']], permanent: 'set -g alternate-screen on' })
const term = env.TERM ?? ''
if (!/^(tmux|screen)-256color$/.test(term)) findings.push({ id: 'tmux-term', level: 'info', message: `TERM is ${term || 'unset'} inside tmux; tmux-256color (or screen-256color) gives the right keys and colors.`, permanent: 'set -g default-terminal "tmux-256color"' })
const overrides = tmux(['show', '-gv', 'terminal-overrides']) + tmux(['show', '-gv', 'terminal-features'])
if (!/Tc|RGB/.test(overrides)) findings.push({ id: 'tmux-truecolor', level: 'info', message: 'tmux does not pass true color (no Tc or RGB in terminal-overrides/terminal-features); themes fall back to 256 colors.', live: [['set', '-as', 'terminal-overrides', ',*:Tc']], permanent: "set -as terminal-overrides ',*:Tc'" })
if (outer) findings.push({ id: 'outer-terminal', level: 'ok', message: `tmux runs in ${outer}.` })
}
if (!/truecolor|24bit/i.test(env.COLORTERM ?? '')) findings.push({ id: 'truecolor', level: 'info', message: 'COLORTERM does not announce true color; colors are approximated.' })
if (!/UTF-?8/i.test(`${env.LC_ALL ?? ''}${env.LC_CTYPE ?? ''}${env.LANG ?? ''}`)) findings.push({ id: 'unicode', level: 'warn', message: 'The locale is not UTF-8; box drawing and block characters may not show. qqq ui --ascii uses plain characters.', advice: 'export LANG=en_US.UTF-8 (or your locale with .UTF-8), or run qqq ui --ascii' })
if (options.columns !== undefined && options.rows !== undefined && (options.columns < 80 || options.rows < 24)) findings.push({ id: 'size', level: 'warn', message: `The terminal is ${options.columns}x${options.rows}; qqq ui needs at least 80x24.` })
if (env.TERM === 'dumb') findings.push({ id: 'dumb', level: 'warn', message: 'TERM is dumb: no alternate screen or cursor control. Use the qqq CLI.' })
if (terminal === 'Terminal.app') findings.push({ id: 'terminal-app-fkeys', level: 'info', message: 'Terminal.app: F1 to F10 go to macOS unless "Use F1, F2, etc. keys as standard function keys" is on (System Settings › Keyboard), or hold fn.', advice: 'Every F-key action is also in the F9 menu (Esc then 9 in some setups) and on the : command line.' })
if (terminal === 'iTerm2') findings.push({ id: 'iterm-page-keys', level: 'info', message: 'iTerm2: if PgUp/PgDn scroll the window instead of reaching qqq ui, set Profiles › Keys › Key Mappings for Page Up/Down to "Send Escape Sequence" ([5~ and [6~), and turn off "Scroll wheel sends arrow keys in alternate screen mode" only if you prefer wheel scrolling.' })
return { terminal, inTmux, findings }
}
/** Runs the live fix of a finding against the running tmux server. */
export function applyLiveFix(finding: Finding, tmux: TmuxRunner = defaultTmux): void {
for (const args of finding.live ?? []) tmux(args)
}
/** Appends the permanent fixes in one marked block to a tmux config file (default ~/.tmux.conf); returns the file. */
export function appendTmuxConf(findings: Finding[], file = join(homedir(), '.tmux.conf')): string {
const lines = findings.map((f) => f.permanent).filter((l): l is string => Boolean(l))
if (!lines.length) return file
const existing = existsSync(file) ? readFileSync(file, 'utf8') : ''
const fresh = lines.filter((l) => !existing.includes(l))
if (!fresh.length) return file
appendFileSync(file, `${existing && !existing.endsWith('\n') ? '\n' : ''}\n${CONF_MARK_BEGIN}\n${fresh.join('\n')}\n${CONF_MARK_END}\n`)
return file
}
/** The report as text lines. */
export function formatReport(report: Report): string {
const out = [`terminal: ${report.terminal}${report.inTmux ? ' (inside tmux)' : ''}`]
if (!report.findings.filter((f) => f.level !== 'ok').length) out.push('everything qqq ui needs is in place')
for (const f of report.findings) {
out.push(`${f.level === 'warn' ? '!' : f.level === 'info' ? '-' : '✓'} ${f.message}`)
if (f.permanent) out.push(` fix: ${f.permanent}`)
if (f.advice) out.push(` ${f.advice}`)
}
return out.join('\n')
}
export interface DoctorState {
lastCheck?: string
applied?: string[]
/** Findings the person asked not to be asked about again. */
dontAsk?: string[]
}
export function readDoctorState(env: NodeJS.ProcessEnv = process.env): DoctorState {
return { ...(((readConfig(env).ui as { doctor?: DoctorState } | undefined)?.doctor) ?? {}) }
}
export function writeDoctorState(patch: Partial<DoctorState>, env: NodeJS.ProcessEnv = process.env): DoctorState {
const config = readConfig(env)
const doctor = { ...readDoctorState(env), ...patch }
config.ui = { ...config.ui, doctor } as never
writeConfig(config, env)
return doctor
}
/** The findings worth asking about at startup: warnings with a fix that were not dismissed. */
export function startupFindings(report: Report, state: DoctorState = readDoctorState()): Finding[] {
return report.findings.filter((f) => f.level === 'warn' && (f.live || f.permanent) && !(state.dontAsk ?? []).includes(f.id))
}
+141
View File
@@ -0,0 +1,141 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* What a file chooser needs, without any terminal code: directory listings (directories first,
* sizes and dates), path expansion and Tab completion, and the local file preferences (default
* download and upload directories, the last ones used, ask or save silently) kept in the config
* file's ui section.
*/
import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
import { readConfig, writeConfig } from '../session/profiles.js'
export interface DirEntry {
name: string
path: string
isDir: boolean
size: number
modified: Date
hidden: boolean
}
/** `~` and `~/x` become the home directory; relative paths resolve against `cwd`. */
export function expandPath(text: string, cwd: string = process.cwd()): string {
const t = text.trim()
if (t === '~') return homedir()
if (t.startsWith(`~${sep}`) || t.startsWith('~/')) return join(homedir(), t.slice(2))
return isAbsolute(t) ? resolve(t) : resolve(cwd, t)
}
/** The entries of a directory: directories first, then files, each by name (case-insensitive). Unreadable entries are skipped. */
export function listDirectory(dir: string, options: { hidden?: boolean } = {}): DirEntry[] {
const out: DirEntry[] = []
for (const name of readdirSync(dir)) {
const hidden = name.startsWith('.')
if (hidden && !options.hidden) continue
const path = join(dir, name)
try {
const st = statSync(path)
out.push({ name, path, isDir: st.isDirectory(), size: st.isDirectory() ? 0 : st.size, modified: st.mtime, hidden })
} catch {
// a broken link or no permission
}
}
return out.sort((a, b) => Number(b.isDir) - Number(a.isDir) || a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
}
/** A size for people: 512 B, 1.4 KB, 3.2 MB. */
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB', 'TB']
let n = bytes / 1024
let i = 0
while (n >= 1024 && i < units.length - 1) {
n /= 1024
i++
}
return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`
}
/**
* Tab completion of a typed path: the longest common extension of the matching entries, and the
* candidates. A directory that is the only match gets a trailing separator.
*/
export function completePath(text: string, options: { cwd?: string; hidden?: boolean; directoriesOnly?: boolean } = {}): { text: string; candidates: string[] } {
const cwd = options.cwd ?? process.cwd()
const typedDir = text.endsWith('/') || text.endsWith(sep) ? text : text.includes('/') || text.includes(sep) ? text.slice(0, Math.max(text.lastIndexOf('/'), text.lastIndexOf(sep)) + 1) : ''
const prefix = text.slice(typedDir.length)
const dir = typedDir ? expandPath(typedDir, cwd) : cwd
let entries: DirEntry[] = []
try {
entries = listDirectory(dir, { hidden: options.hidden || prefix.startsWith('.') })
} catch {
return { text, candidates: [] }
}
const matches = entries.filter((e) => e.name.startsWith(prefix) && (!options.directoriesOnly || e.isDir))
if (!matches.length) return { text, candidates: [] }
let common = matches[0]!.name
for (const m of matches) while (!m.name.startsWith(common)) common = common.slice(0, -1)
const only = matches.length === 1 ? matches[0]! : undefined
return { text: typedDir + common + (only?.isDir ? '/' : ''), candidates: matches.map((m) => m.name + (m.isDir ? '/' : '')) }
}
/** Creates a folder (and its parents); returns its path. */
export function createFolder(parent: string, name: string): string {
if (!name.trim() || /[/\\]/.test(name)) throw new Error('A folder name cannot be empty or contain a slash')
const path = join(parent, name.trim())
mkdirSync(path, { recursive: true })
return path
}
/** Local file preferences of the clients. */
export interface FilePreferences {
/** Where downloads, exports and reports are saved by default. */
downloadDir?: string
/** Where the upload chooser starts by default. */
uploadDir?: string
/** `ask` (a save dialog each time) or `silent` (straight to the download directory). */
downloadMode?: 'ask' | 'silent'
lastDownloadDir?: string
lastUploadDir?: string
}
export function readFilePreferences(env: NodeJS.ProcessEnv = process.env): FilePreferences {
return { ...(((readConfig(env).ui as { files?: FilePreferences } | undefined)?.files) ?? {}) }
}
export function writeFilePreferences(patch: Partial<FilePreferences>, env: NodeJS.ProcessEnv = process.env): FilePreferences {
const config = readConfig(env)
const files = { ...(((config.ui as { files?: FilePreferences } | undefined)?.files) ?? {}), ...patch }
for (const k of Object.keys(files) as Array<keyof FilePreferences>) if (files[k] === undefined || files[k] === '') delete files[k]
config.ui = { ...config.ui, files } as never
writeConfig(config, env)
return files
}
function firstExisting(...dirs: Array<string | undefined>): string {
for (const d of dirs) if (d && existsSync(d)) return d
return process.cwd()
}
/** The directory a save dialog starts in: last used, the default download directory, ~/Downloads, the current directory. */
export function startDirForSave(prefs: FilePreferences = readFilePreferences()): string {
return firstExisting(prefs.lastDownloadDir, prefs.downloadDir, join(homedir(), 'Downloads'))
}
/** The directory an upload chooser starts in: last used, the default upload directory, the current directory. */
export function startDirForUpload(prefs: FilePreferences = readFilePreferences()): string {
return firstExisting(prefs.lastUploadDir, prefs.uploadDir, process.cwd())
}
/** Where a download goes without asking: the default download directory, else the current directory. */
export function silentDownloadPath(fileName: string, prefs: FilePreferences = readFilePreferences()): string {
return join(firstExisting(prefs.downloadDir), fileName)
}
export { dirname }
+250
View File
@@ -0,0 +1,250 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Completion for the filter grammar: given the text, the cursor and the table's fields, what can
* come next (a field, an operator valid for that field, a value or expression, AND / OR / a
* parenthesis) and which span of the text a candidate replaces. The TUI filter box uses it as the
* person types; the CLI's shell completion can use it for --where and --group values.
*/
import { FilterSyntaxError, LIST_OPERATORS, OPERATORS, UNARY_OPERATORS, type Operator, type QueryFilter } from './grammar.js'
/** The field metadata completion needs (a structural subset of FieldMetaData). */
export interface CompletionField {
name: string
label?: string
type?: string
possibleValueSourceName?: string | null
/** Operators the application allows on this field, when it restricts them. */
allowedFilterOperators?: string[] | null
[key: string]: unknown
}
export interface Completion {
/** The text that replaces the span `from..to`. */
value: string
/** What a list shows. */
label: string
kind: 'field' | 'operator' | 'value' | 'expression' | 'keyword'
detail?: string
}
export interface CompletionResult {
/** The span the candidate replaces (the partial word under the cursor). */
from: number
to: number
/** What the grammar expects at the cursor. */
expecting: 'field' | 'operator' | 'value' | 'connector'
/** The field of the criterion being written, when expecting an operator or a value. */
field?: string
operator?: Operator
candidates: Completion[]
}
const SHORTHAND = new Set(['=', '==', '!=', '<>', '<', '<=', '>', '>=', '~', '!~'])
const SHORTHAND_OP: Record<string, Operator> = { '=': 'EQUALS', '==': 'EQUALS', '!=': 'NOT_EQUALS', '<>': 'NOT_EQUALS', '<': 'LESS_THAN', '<=': 'LESS_THAN_OR_EQUALS', '>': 'GREATER_THAN', '>=': 'GREATER_THAN_OR_EQUALS', '~': 'CONTAINS', '!~': 'NOT_CONTAINS' }
const TEXT_OPS: Operator[] = ['EQUALS', 'NOT_EQUALS', 'CONTAINS', 'NOT_CONTAINS', 'STARTS_WITH', 'NOT_STARTS_WITH', 'ENDS_WITH', 'NOT_ENDS_WITH', 'IN', 'NOT_IN', 'LIKE', 'NOT_LIKE', 'IS_BLANK', 'IS_NOT_BLANK']
const NUMBER_OPS: Operator[] = ['EQUALS', 'NOT_EQUALS', 'LESS_THAN', 'LESS_THAN_OR_EQUALS', 'GREATER_THAN', 'GREATER_THAN_OR_EQUALS', 'BETWEEN', 'NOT_BETWEEN', 'IN', 'NOT_IN', 'IS_BLANK', 'IS_NOT_BLANK']
const DATE_OPS: Operator[] = ['EQUALS', 'NOT_EQUALS', 'LESS_THAN', 'LESS_THAN_OR_EQUALS', 'GREATER_THAN', 'GREATER_THAN_OR_EQUALS', 'BETWEEN', 'NOT_BETWEEN', 'IS_BLANK', 'IS_NOT_BLANK']
const BOOLEAN_OPS: Operator[] = ['TRUE', 'FALSE', 'IS_BLANK', 'IS_NOT_BLANK']
const CHOICE_OPS: Operator[] = ['EQUALS', 'NOT_EQUALS', 'IN', 'NOT_IN', 'IS_NULL_OR_IN', 'IS_BLANK', 'IS_NOT_BLANK']
/** The operators that make sense for a field, honouring `allowedFilterOperators`. */
export function operatorsForField(field: Pick<CompletionField, 'type' | 'possibleValueSourceName' | 'allowedFilterOperators'> | undefined): Operator[] {
if (!field) return [...OPERATORS]
const type = String(field.type ?? 'STRING').toUpperCase()
let ops = field.possibleValueSourceName ? CHOICE_OPS
: type === 'INTEGER' || type === 'LONG' || type === 'DECIMAL' ? NUMBER_OPS
: type === 'DATE' || type === 'DATE_TIME' || type === 'TIME' ? DATE_OPS
: type === 'BOOLEAN' ? BOOLEAN_OPS : TEXT_OPS
if (field.allowedFilterOperators?.length) {
const allowed = new Set(field.allowedFilterOperators.map((o) => o.toUpperCase()))
const narrowed = ops.filter((o) => allowed.has(o))
ops = narrowed.length ? narrowed : OPERATORS.filter((o) => allowed.has(o))
}
return [...ops]
}
/** Expression values for date and time fields (and me() for others), as a person would pick them. */
export function expressionCandidates(type: string | undefined): Completion[] {
const t = String(type ?? '').toUpperCase()
if (t === 'DATE' || t === 'DATE_TIME' || t === 'TIME') {
return [
['now()', 'now'], ['now(-1d)', '1 day ago'], ['now(-7d)', '7 days ago'], ['now(-30d)', '30 days ago'], ['now(-1y)', '1 year ago'],
['thisPeriod(day)', 'today'], ['thisPeriod(week)', 'this week'], ['thisPeriod(month)', 'this month'], ['thisPeriod(year)', 'this year'],
['lastPeriod(week)', 'last week'], ['lastPeriod(month)', 'last month'], ['lastPeriod(year)', 'last year'],
['startOf(month)', 'start of this month'], ['startOf(quarter)', 'start of this quarter'], ['startOf(year)', 'start of this year'], ['startOf(month, -1)', 'start of last month'],
].map(([value, detail]) => ({ value: value!, label: value!, kind: 'expression' as const, detail }))
}
return [{ value: 'me()', label: 'me()', kind: 'expression', detail: 'the signed-in user' }]
}
interface Word { text: string; start: number; end: number; kind: 'word' | '(' | ')' }
/** Splits text into words, group parentheses and function calls (now(-7d) is one word), keeping quotes. */
function words(text: string): Word[] {
const out: Word[] = []
let i = 0
while (i < text.length) {
const ch = text[i]!
if (/\s/.test(ch)) {
i++
continue
}
if (ch === '(' || ch === ')') {
out.push({ text: ch, start: i, end: i + 1, kind: ch })
i++
continue
}
const start = i
let quoted = false
let depth = 0
while (i < text.length) {
const c = text[i]!
if (c === '"' && text[i - 1] !== '\\') quoted = !quoted
else if (!quoted && c === '(') {
if (/[A-Za-z0-9_}]/.test(text[i - 1] ?? ' ')) depth++
else break
} else if (!quoted && c === ')') {
if (depth > 0) depth--
else break
} else if (!quoted && depth === 0 && /\s/.test(c)) break
i++
}
out.push({ text: text.slice(start, i), start, end: i, kind: 'word' })
}
return out
}
export interface CompleteOptions {
/** Possible-value candidates for a field (labels the caller has looked up for this prefix). */
values?: (field: CompletionField, prefix: string) => Completion[]
limit?: number
}
/**
* What can be typed at the cursor. Words before the cursor drive a small state machine
* (field → operator → value → connector → field …); the word under the cursor is the prefix.
*/
export function completeFilter(text: string, cursor: number, fields: CompletionField[], options: CompleteOptions = {}): CompletionResult {
const before = text.slice(0, cursor)
const all = words(before)
const endsInSpace = before.length > 0 && /[\s(]$/.test(before) || before.length === 0
const partial = !endsInSpace && all.length && all[all.length - 1]!.kind === 'word' ? all.pop()! : undefined
const byName = new Map(fields.map((f) => [f.name, f]))
let state: CompletionResult['expecting'] = 'field'
let field: CompletionField | undefined
let operator: Operator | undefined
let depth = 0
for (const w of all) {
if (w.kind === '(') {
if (state === 'field') depth++
continue
}
if (w.kind === ')') {
depth = Math.max(0, depth - 1)
state = 'connector'
continue
}
const upper = w.text.toUpperCase()
switch (state) {
case 'field':
field = byName.get(w.text) ?? { name: w.text }
state = 'operator'
break
case 'operator':
operator = SHORTHAND.has(w.text) ? SHORTHAND_OP[w.text] : upper as Operator
state = operator && UNARY_OPERATORS.has(operator) ? 'connector' : 'value'
break
case 'value':
state = operator && LIST_OPERATORS.has(operator) && w.text.endsWith(',') ? 'value' : 'connector'
break
case 'connector':
if (upper === 'AND' || upper === 'OR') {
state = 'field'
field = undefined
operator = undefined
} else if (operator && LIST_OPERATORS.has(operator) && w.text.startsWith(',')) {
state = w.text.endsWith(',') ? 'value' : 'connector'
}
// any other word continues an unquoted value with spaces
break
}
}
let prefix = partial?.text ?? ''
let from = partial?.start ?? cursor
// a list value: complete the item after the last comma
if (state === 'value' && operator && LIST_OPERATORS.has(operator) && prefix.includes(',')) {
const at = prefix.lastIndexOf(',')
from += at + 1
prefix = prefix.slice(at + 1)
}
const needle = prefix.replace(/^"/, '').toLowerCase()
const limit = options.limit ?? 50
let candidates: Completion[] = []
if (state === 'field') {
candidates = fields.filter((f) => !needle || f.name.toLowerCase().startsWith(needle) || String(f.label ?? '').toLowerCase().includes(needle))
.sort((a, b) => Number(!a.name.toLowerCase().startsWith(needle)) - Number(!b.name.toLowerCase().startsWith(needle)))
.map((f) => ({ value: f.name, label: f.name, kind: 'field' as const, detail: `${f.label ?? f.name}${f.type ? ` · ${String(f.type).toLowerCase()}` : ''}` }))
if (!needle) candidates.push({ value: '(', label: '(', kind: 'keyword', detail: 'start a group' })
} else if (state === 'operator') {
candidates = operatorsForField(field?.type || field?.possibleValueSourceName ? field : byName.get(field?.name ?? ''))
.filter((o) => !needle || o.toLowerCase().startsWith(needle) || o.toLowerCase().includes(needle))
.map((o) => ({ value: o, label: o, kind: 'operator' as const, detail: UNARY_OPERATORS.has(o) ? 'no value' : LIST_OPERATORS.has(o) ? (o.includes('BETWEEN') ? 'two values: a,b' : 'a list: a,b,c') : undefined }))
} else if (state === 'value') {
const f = field && byName.get(field.name)
if (f?.possibleValueSourceName && options.values) candidates.push(...options.values(f, prefix.replace(/^"/, '')))
if (String(f?.type ?? '').toUpperCase() === 'BOOLEAN') candidates.push(...['true', 'false'].map((v) => ({ value: v, label: v, kind: 'value' as const })))
candidates.push(...expressionCandidates(f?.type).filter((c) => !needle || c.value.toLowerCase().startsWith(needle) || String(c.detail ?? '').toLowerCase().includes(needle)))
} else {
candidates = ['AND', 'OR', ...(depth > 0 ? [')'] : [])].filter((k) => !needle || k.toLowerCase().startsWith(needle))
.map((k) => ({ value: k, label: k, kind: 'keyword' as const, detail: k === ')' ? 'close the group' : undefined }))
}
return { from, to: cursor, expecting: state, field: field?.name, operator, candidates: candidates.slice(0, limit) }
}
/** Applies a completion: the new text and the cursor after it (a space follows fields, operators and keywords). */
export function applyCompletion(text: string, result: Pick<CompletionResult, 'from' | 'to'>, completion: Completion): { text: string; cursor: number } {
const tail = text.slice(result.to)
const value = /[\s,]/.test(completion.value) && completion.kind === 'value' && !completion.value.startsWith('"') ? `"${completion.value.replace(/"/g, '\\"')}"` : completion.value
const space = completion.value === '(' ? '' : tail.startsWith(' ') ? '' : ' '
const insert = value + space
return { text: text.slice(0, result.from) + insert + tail, cursor: result.from + insert.length }
}
/** Where in the text a parse error points, from the quoted fragment in its message (best effort). */
export function locateFilterError(text: string, error: unknown): number | undefined {
if (!(error instanceof FilterSyntaxError)) return undefined
const quoted = /"([^"]+)"/.exec(error.message)?.[1]
if (quoted) {
const at = text.indexOf(quoted)
if (at >= 0) return at
}
if (/Missing "\)"/.test(error.message)) return text.length
const tail = /: (.+)$/.exec(error.message)?.[1]
if (tail) {
const at = text.indexOf(tail)
if (at >= 0) return at
}
return undefined
}
/** A one-line description of a parsed filter: criteria and groups, for a preview. */
export function describeFilter(filter: QueryFilter): string {
let criteria = 0
let groups = 0
const visit = (f: QueryFilter) => {
criteria += f.criteria.length
for (const sub of f.subFilters ?? []) {
groups++
visit(sub)
}
}
visit(filter)
if (criteria === 0) return 'no criteria (every record)'
return `${criteria} criteri${criteria === 1 ? 'on' : 'a'}${groups ? `, ${groups} group${groups === 1 ? '' : 's'}` : ''}, joined by ${filter.booleanOperator}`
}
+6
View File
@@ -16,6 +16,12 @@ export * from './widgets/render-model.js'
export * from './metadata/cache.js'
export * from './session/profiles.js'
export * from './session/keyring.js'
export * from './session/login.js'
export * from './session/local-state.js'
export * from './filter/complete.js'
export * from './records/result-cursor.js'
export * from './files/navigator.js'
export * from './env/doctor.js'
export * from './query/quick-search.js'
export * from './records/goto.js'
export * from './records/copy.js'
+161
View File
@@ -0,0 +1,161 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* A position in a query result: the table, its filter and sort, a page size, the index of the
* current record and a cache of loaded pages. A grid pages through it; a record view opened from
* the grid steps next and previous through the same result across page boundaries, and the grid
* lands on the same row when the person goes back. Works for any query: a table, a saved view, a
* filtered child list.
*/
import { callOperation } from '../api/call.js'
import type { QueryFilter } from '../filter/grammar.js'
/** A record as the query endpoint returns it. */
export interface QueryRecord {
values: Record<string, unknown>
displayValues?: Record<string, string>
recordLabel?: string
[key: string]: unknown
}
export interface ResultCursorOptions {
baseUrl: string
fetch: typeof fetch
table: string
/** Criteria, groups and order; skip and limit are managed by the cursor. */
filter: QueryFilter
pageSize: number
primaryKeyField?: string
index?: number
total?: number
/** Extra query parameters (tableVariant). */
query?: Record<string, string | undefined>
}
export class ResultCursor {
readonly table: string
readonly filter: QueryFilter
readonly primaryKeyField: string
pageSize: number
index: number
total?: number
private readonly baseUrl: string
private readonly fetchImpl: typeof fetch
private readonly query?: Record<string, string | undefined>
private pages = new Map<number, QueryRecord[]>()
constructor(options: ResultCursorOptions) {
this.baseUrl = options.baseUrl
this.fetchImpl = options.fetch
this.table = options.table
this.filter = { ...options.filter, skip: undefined, limit: undefined }
this.pageSize = Math.max(1, options.pageSize)
this.primaryKeyField = options.primaryKeyField ?? 'id'
this.index = options.index ?? 0
this.total = options.total
this.query = options.query
}
/** The page number holding an index. */
pageOf(index: number): number {
return Math.floor(index / this.pageSize)
}
get pageCount(): number {
return this.total === undefined ? 1 : Math.max(1, Math.ceil(this.total / this.pageSize))
}
/** Counts the matching records (also done by the first page load when unknown). */
async count(): Promise<number> {
const result = await callOperation(this.baseUrl, 'countTable', { path: { tableName: this.table }, query: this.query, json: { filter: this.wireFilter() } }, this.fetchImpl)
this.total = Number((result.data as { count?: number }).count ?? 0)
return this.total
}
/** One page of records, from the cache or the server. */
async page(page: number, options: { refresh?: boolean } = {}): Promise<QueryRecord[]> {
if (!options.refresh && this.pages.has(page)) return this.pages.get(page)!
const filter = { ...this.wireFilter(), skip: page * this.pageSize, limit: this.pageSize }
const [result] = await Promise.all([
callOperation(this.baseUrl, 'queryTable', { path: { tableName: this.table }, query: this.query, json: { filter } }, this.fetchImpl),
this.total === undefined || options.refresh ? this.count() : Promise.resolve(this.total),
])
const records = ((result.data as { records?: QueryRecord[] }).records ?? [])
this.pages.set(page, records)
return records
}
/** Hands the cursor a page another component loaded with the same filter. */
seed(page: number, records: QueryRecord[], total?: number): void {
this.pages.set(page, records)
if (total !== undefined) this.total = total
}
/** The record at an index (loading its page), or undefined past the end. */
async at(index: number): Promise<QueryRecord | undefined> {
if (index < 0 || (this.total !== undefined && index >= this.total)) return undefined
const records = await this.page(this.pageOf(index))
return records[index % this.pageSize]
}
async current(): Promise<QueryRecord | undefined> {
return this.at(this.index)
}
/** Moves to the next record; stays and returns undefined at the end. */
async next(): Promise<QueryRecord | undefined> {
const record = await this.at(this.index + 1)
if (record) this.index++
return record
}
async previous(): Promise<QueryRecord | undefined> {
if (this.index <= 0) return undefined
const record = await this.at(this.index - 1)
if (record) this.index--
return record
}
/** The primary key of a record. */
keyOf(record: QueryRecord | undefined): unknown {
return record?.values?.[this.primaryKeyField]
}
/** The index of a primary key among the loaded pages, if loaded. */
indexOfKey(key: unknown): number | undefined {
for (const [page, records] of this.pages) {
const at = records.findIndex((r) => String(this.keyOf(r)) === String(key))
if (at >= 0) return page * this.pageSize + at
}
return undefined
}
/** After the record at `index` was deleted: one fewer, pages from there on reloaded; the index stays (now the next record). */
removed(index: number): void {
if (this.total !== undefined) this.total = Math.max(0, this.total - 1)
const from = this.pageOf(index)
for (const page of [...this.pages.keys()]) if (page >= from) this.pages.delete(page)
if (this.total !== undefined && this.index >= this.total) this.index = Math.max(0, this.total - 1)
}
/** Drops every cached page (after a refresh or an edit that may reorder). */
invalidate(): void {
this.pages.clear()
this.total = undefined
}
/** "record 4 of 1,234" (1-based). */
describe(): string {
return `record ${(this.index + 1).toLocaleString('en-US')} of ${this.total === undefined ? '…' : this.total.toLocaleString('en-US')}`
}
private wireFilter(): QueryFilter {
const orderBys = this.filter.orderBys?.length ? this.filter.orderBys : [{ fieldName: this.primaryKeyField, isAscending: true }]
const { skip: _skip, limit: _limit, ...rest } = this.filter
return { ...rest, orderBys }
}
}
+142
View File
@@ -0,0 +1,142 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Local client state that is not configuration: command histories (~/.local/state/qqq/history-*),
* recently viewed records, and the screen-lock PIN (a salted scrypt hash in the config file, never
* the PIN). QQQ_STATE_DIR overrides the state directory; XDG_STATE_HOME is honoured.
*/
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { readConfig, writeConfig } from './profiles.js'
export function stateDir(env: NodeJS.ProcessEnv = process.env): string {
return env.QQQ_STATE_DIR ?? join(env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'qqq')
}
/** Commands that carried a secret are never written to a history. */
export function isSecretCommand(line: string): boolean {
return /\b(pass(word)?|pin|secret|token|api[-_]?key)\b/i.test(line)
}
/** A persistent, capped command history (newest last on disk, newest first from `entries`). */
export class History {
private lines: string[]
private readonly file: string
constructor(readonly name: string, private readonly cap = 3000, env: NodeJS.ProcessEnv = process.env) {
this.file = join(stateDir(env), `history-${name.replace(/[^A-Za-z0-9_-]+/g, '_')}`)
this.lines = existsSync(this.file) ? readFileSync(this.file, 'utf8').split('\n').filter(Boolean) : []
}
/** Newest first. */
entries(): string[] {
return [...this.lines].reverse()
}
/** Adds a line (not blank, not a repeat of the newest, not a secret) and saves. */
add(line: string): void {
const text = line.trim()
if (!text || isSecretCommand(text) || this.lines[this.lines.length - 1] === text) return
this.lines.push(text)
if (this.lines.length > this.cap) this.lines = this.lines.slice(-this.cap)
try {
mkdirSync(stateDir(), { recursive: true, mode: 0o700 })
writeFileSync(this.file, this.lines.join('\n') + '\n', { mode: 0o600 })
} catch {
// a read-only home must not break the client
}
}
/** Newest-first entries containing the text (Ctrl+R). */
search(text: string): string[] {
const needle = text.toLowerCase()
return this.entries().filter((l) => l.toLowerCase().includes(needle))
}
}
export interface RecentItem {
kind: 'record' | 'table' | 'process'
table?: string
key?: string
name?: string
label: string
at: string
}
/** Recently viewed records and screens of a server, newest first (kept in the config file's ui section). */
export function readRecents(server: string, env: NodeJS.ProcessEnv = process.env): RecentItem[] {
const ui = readConfig(env).ui as { recents?: Record<string, RecentItem[]> } | undefined
return ui?.recents?.[server] ?? []
}
export function addRecent(server: string, item: Omit<RecentItem, 'at'>, cap = 30, env: NodeJS.ProcessEnv = process.env): void {
const config = readConfig(env)
const ui = (config.ui ?? {}) as { recents?: Record<string, RecentItem[]> }
const list = (ui.recents?.[server] ?? []).filter((r) => !(r.kind === item.kind && r.table === item.table && r.key === item.key && r.name === item.name))
list.unshift({ ...item, at: new Date().toISOString() })
config.ui = { ...config.ui, recents: { ...(ui.recents ?? {}), [server]: list.slice(0, cap) } } as never
writeConfig(config, env)
}
interface LockSettings { pinHash?: string; pinSalt?: string; idleMinutes?: number }
function lockSettings(env: NodeJS.ProcessEnv): LockSettings {
return ((readConfig(env).ui as { lock?: LockSettings } | undefined)?.lock) ?? {}
}
function writeLock(lock: LockSettings, env: NodeJS.ProcessEnv): void {
const config = readConfig(env)
config.ui = { ...config.ui, lock } as never
writeConfig(config, env)
}
/** Hashes a PIN with a salt (scrypt); the same PIN and salt give the same hash. */
export function hashPin(pin: string, salt: string): string {
return scryptSync(pin, salt, 32).toString('hex')
}
export function hasPin(env: NodeJS.ProcessEnv = process.env): boolean {
return Boolean(lockSettings(env).pinHash)
}
/** Sets the screen-lock PIN (4 or more characters); only a salted hash is stored. */
export function setPin(pin: string, env: NodeJS.ProcessEnv = process.env): void {
if (pin.length < 4) throw new Error('A PIN needs at least 4 characters')
const salt = randomBytes(16).toString('hex')
writeLock({ ...lockSettings(env), pinSalt: salt, pinHash: hashPin(pin, salt) }, env)
}
export function verifyPin(pin: string, env: NodeJS.ProcessEnv = process.env): boolean {
const lock = lockSettings(env)
if (!lock.pinHash || !lock.pinSalt) return true
const a = Buffer.from(hashPin(pin, lock.pinSalt), 'hex')
const b = Buffer.from(lock.pinHash, 'hex')
return a.length === b.length && timingSafeEqual(a, b)
}
/** Removes the PIN after checking the current one; false when it did not match. */
export function removePin(current: string, env: NodeJS.ProcessEnv = process.env): boolean {
if (!verifyPin(current, env)) return false
const { pinHash: _h, pinSalt: _s, ...rest } = lockSettings(env)
writeLock(rest, env)
return true
}
/** Minutes of no input before the screen saver starts (0 or undefined: never). */
export function readIdleMinutes(env: NodeJS.ProcessEnv = process.env): number {
return Number(lockSettings(env).idleMinutes ?? 0)
}
export function writeIdleMinutes(minutes: number, env: NodeJS.ProcessEnv = process.env): void {
writeLock({ ...lockSettings(env), idleMinutes: Math.max(0, Math.floor(minutes)) }, env)
}
/** How long to wait after the n-th wrong PIN (grows: 0.5 s, 1 s, 2 s … capped at 30 s). */
export function pinDelayMillis(failures: number): number {
return failures <= 0 ? 0 : Math.min(30_000, 500 * 2 ** (failures - 1))
}
+106
View File
@@ -0,0 +1,106 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* A profile's stored sign-in and the fetch that authenticates with it, shared by both heads so a
* sign-in made by `qqq login` works in `qqq ui` and the other way round. The keyring account is
* `{profile}@{server}`, as the CLI has always used.
*
* The rule for sending it: a server session (MOCK, FULLY_ANONYMOUS, TABLE_BASED via manageSession)
* goes as the `sessionUUID` cookie, because the sample server cannot continue an asynchronous
* process step with the same UUID as a bearer token (docs/server-findings.md); OAuth2 tokens go
* as `Authorization: Bearer`, refreshed at the provider when they are about to expire.
* QQQ_TOKEN overrides everything with a bearer token.
*/
import { createSession, isExpiring, refreshTokens, type NativeSignInConfig, type TokenSet } from '@qrun-io/qqq-ui-api-client'
import { upsertProfile, type ResolvedProfile } from './profiles.js'
import type { SecretStore } from './keyring.js'
/** What is stored per profile: OAuth2 tokens from the device flow, or a server session from manageSession. */
export type StoredLogin =
| { kind?: 'oauth'; config: NativeSignInConfig; tokens: TokenSet }
| { kind: 'session'; authType: string; sessionUUID: string; user?: Record<string, unknown>; createdAt: string }
/** The keyring account of a profile's sign-in. */
export function loginAccount(target: Pick<ResolvedProfile, 'name' | 'server'>): string {
return `${target.name}@${target.server}`
}
export async function readLogin(store: SecretStore, target: Pick<ResolvedProfile, 'name' | 'server'>): Promise<StoredLogin | undefined> {
const raw = await store.get(loginAccount(target))
if (!raw) return undefined
try {
return JSON.parse(raw) as StoredLogin
} catch {
return undefined
}
}
/** Keeps a sign-in and names the profile (the first profile becomes the default). */
export async function writeLogin(store: SecretStore, target: Pick<ResolvedProfile, 'name' | 'server'>, login: StoredLogin, env: NodeJS.ProcessEnv = process.env): Promise<void> {
upsertProfile(target.name, { server: target.server }, {}, env)
await store.set(loginAccount(target), JSON.stringify(login))
}
export async function deleteLogin(store: SecretStore, target: Pick<ResolvedProfile, 'name' | 'server'>): Promise<boolean> {
return store.delete(loginAccount(target))
}
/** Signs in to a MOCK or FULLY_ANONYMOUS server with manageSession and keeps the session. */
export async function signInWithSession(store: SecretStore, target: Pick<ResolvedProfile, 'name' | 'server'>, authType: string, clientChannel: string, fetchImpl: typeof fetch = fetch): Promise<StoredLogin & { kind: 'session' }> {
const session = await createSession(target.server, {}, clientChannel, fetchImpl)
const login = { kind: 'session' as const, authType, sessionUUID: session.sessionUUID, user: (session.values.user ?? {}) as Record<string, unknown>, createdAt: new Date().toISOString() }
await writeLogin(store, target, login)
return login
}
export interface LoginFetchOptions {
store: SecretStore
target: Pick<ResolvedProfile, 'name' | 'server'>
clientChannel: string
env?: NodeJS.ProcessEnv
fetch?: typeof fetch
}
/** The credential to send now: a session cookie, a bearer token (refreshed when expiring), or nothing. */
export async function currentCredential(options: LoginFetchOptions, force = false): Promise<{ cookie?: string; bearer?: string }> {
const env = options.env ?? process.env
if (env.QQQ_TOKEN) return { bearer: env.QQQ_TOKEN }
const login = await readLogin(options.store, options.target)
if (!login) return {}
if (login.kind === 'session') return { cookie: `sessionUUID=${login.sessionUUID}` }
if ((force || isExpiring(login.tokens)) && login.tokens.refreshToken) {
const tokens = await refreshTokens(login.config, login.tokens.refreshToken, options.fetch ?? fetch)
await writeLogin(options.store, options.target, { kind: 'oauth', config: login.config, tokens }, env)
return { bearer: tokens.accessToken }
}
return { bearer: login.tokens.accessToken }
}
/** A fetch that adds the profile's credential and the client channel; retries once after a refresh on 401. */
export function loginFetch(options: LoginFetchOptions): typeof fetch {
const base = options.fetch ?? fetch
const send = async (input: RequestInfo | URL, init: RequestInit | undefined, force: boolean): Promise<Response> => {
const request = new Request(input as RequestInfo, init)
request.headers.set('X-QQQ-Client', options.clientChannel)
const credential = await currentCredential(options, force)
if (credential.cookie) request.headers.set('Cookie', credential.cookie)
else if (credential.bearer) request.headers.set('Authorization', `Bearer ${credential.bearer}`)
return base(request)
}
return (async (input: RequestInfo | URL, init?: RequestInit) => {
const retryable = !init?.body || typeof init.body === 'string'
const response = await send(input, init, false)
if (response.status !== 401 || !retryable) return response
const login = await readLogin(options.store, options.target)
if (!login || login.kind === 'session' || !login.tokens.refreshToken) return response
try {
return await send(input, init, true)
} catch {
return response
}
}) as typeof fetch
}
+61 -31
View File
@@ -18,28 +18,45 @@ export interface Profile {
server: string
note?: string
createdAt?: string
/** Display settings of the terminal UI for this profile only (override the global `ui`). */
ui?: Pick<UiSettings, 'theme'>
}
/** Local display settings of the terminal UI (not server preferences): theme and per-screen view settings. */
export interface UiSettings {
theme?: string
/** Keyed by screen, e.g. `table:person`, `record:person`, `app:peopleApp`. */
views?: Record<string, ViewSettings>
}
/** How one screen is shown: grid columns and widths, stripes, density; record columns; widget order. */
export interface ViewSettings {
/** Visible grid columns, in order. */
columns?: string[]
/** Column widths in cells, by field name. */
widths?: Record<string, number>
/** How free width is used: fit columns to content, or fill the terminal width. */
fit?: 'content' | 'fill'
zebra?: boolean
headerRule?: boolean
density?: 'compact' | 'comfortable'
/** Record view: one or two columns of fields, and the label width. */
recordColumns?: 1 | 2
labelWidth?: number
/** App home: widget names in display order. */
widgetOrder?: string[]
[key: string]: unknown
}
export interface Config {
defaultProfile?: string
profiles: Record<string, Profile>
/** Local client settings shared by the CLI and the TUI (download and upload directories, …). */
/** Local client settings shared by the CLI and the TUI: theme, views, files (files/navigator.ts). */
ui?: UiSettings
/** Sections this version does not know are kept as they are. */
[section: string]: unknown
}
/** The `ui` section of config.json. */
export interface UiSettings {
/** Where downloaded files go when no --out / --out-dir is given (created when missing). */
defaultDownloadDir?: string
/** Where file pickers for uploads start. */
defaultUploadDir?: string
/** Whether a head asks before writing a download. */
downloadPrompt?: boolean
[key: string]: unknown
}
/** config.json is not valid JSON: refused, so a write never replaces the profiles with nothing. */
export class ConfigFileError extends Error {}
@@ -60,25 +77,6 @@ export function readConfig(env: NodeJS.ProcessEnv = process.env): Config {
return { ...parsed, profiles: parsed.profiles ?? {} }
}
/** The local client settings (`ui` section). */
export function readUiSettings(env: NodeJS.ProcessEnv = process.env): UiSettings {
const ui = readConfig(env).ui
return ui && typeof ui === 'object' ? { ...ui } : {}
}
/** Merges settings into the `ui` section; an undefined value removes the key. */
export function writeUiSettings(settings: Partial<UiSettings>, env: NodeJS.ProcessEnv = process.env): UiSettings {
const config = readConfig(env)
const ui: UiSettings = { ...(config.ui ?? {}) }
for (const [key, value] of Object.entries(settings)) {
if (value === undefined) delete ui[key]
else ui[key] = value
}
config.ui = ui
writeConfig(config, env)
return ui
}
export function writeConfig(config: Config, env: NodeJS.ProcessEnv = process.env): void {
const dir = configDir(env)
mkdirSync(dir, { recursive: true, mode: 0o700 })
@@ -141,3 +139,35 @@ export function removeProfile(name: string, env: NodeJS.ProcessEnv = process.env
writeConfig(config, env)
return had
}
/** The terminal UI theme in effect: the profile's, else the global one, else undefined (the UI's default). */
export function readTheme(profile?: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
const config = readConfig(env)
return (profile ? config.profiles[profile]?.ui?.theme : undefined) ?? config.ui?.theme
}
/** Saves the theme globally, or for one profile when `profile` is given. */
export function writeTheme(theme: string | undefined, options: { profile?: string } = {}, env: NodeJS.ProcessEnv = process.env): void {
const config = readConfig(env)
if (options.profile && config.profiles[options.profile]) {
config.profiles[options.profile]!.ui = { ...config.profiles[options.profile]!.ui, theme }
} else {
config.ui = { ...config.ui, theme }
}
writeConfig(config, env)
}
/** The saved view settings of one screen (`table:person`), or an empty object. */
export function readViewSettings(key: string, env: NodeJS.ProcessEnv = process.env): ViewSettings {
return { ...(readConfig(env).ui?.views?.[key] ?? {}) }
}
/** Saves the view settings of one screen, replacing what was saved; undefined removes them. */
export function writeViewSettings(key: string, view: ViewSettings | undefined, env: NodeJS.ProcessEnv = process.env): void {
const config = readConfig(env)
const views = { ...(config.ui?.views ?? {}) }
if (view === undefined) delete views[key]
else views[key] = view
config.ui = { ...config.ui, views }
writeConfig(config, env)
}
+70
View File
@@ -0,0 +1,70 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { applyCompletion, completeFilter, describeFilter, locateFilterError, operatorsForField } from '../filter/complete.js'
import { parseFilter } from '../filter/grammar.js'
const fields = [
{ name: 'id', label: 'Id', type: 'INTEGER' },
{ name: 'firstName', label: 'First Name', type: 'STRING' },
{ name: 'lastName', label: 'Last Name', type: 'STRING' },
{ name: 'birthDate', label: 'Birth Date', type: 'DATE' },
{ name: 'isEmployed', label: 'Is Employed', type: 'BOOLEAN' },
{ name: 'personId', label: 'Person', type: 'INTEGER', possibleValueSourceName: 'person' },
]
test('operators follow the field type and allowedFilterOperators', () => {
assert.ok(operatorsForField(fields[1]).includes('STARTS_WITH'))
assert.ok(!operatorsForField(fields[3]).includes('STARTS_WITH'))
assert.deepEqual(operatorsForField(fields[4]), ['TRUE', 'FALSE', 'IS_BLANK', 'IS_NOT_BLANK'])
assert.ok(operatorsForField(fields[5]).includes('IN') && !operatorsForField(fields[5]).includes('GREATER_THAN'))
assert.deepEqual(operatorsForField({ type: 'STRING', allowedFilterOperators: ['EQUALS', 'IN'] }), ['EQUALS', 'IN'])
})
test('completion walks field, operator, value and connector', () => {
let r = completeFilter('', 0, fields)
assert.equal(r.expecting, 'field')
assert.ok(r.candidates.some((c) => c.value === '('))
r = completeFilter('la', 2, fields)
assert.deepEqual([r.from, r.to, r.candidates[0]!.value], [0, 2, 'lastName'])
r = completeFilter('last', 4, fields)
assert.equal(r.candidates[0]!.value, 'lastName', 'label match: Last Name')
r = completeFilter('lastName ST', 11, fields)
assert.equal(r.expecting, 'operator')
assert.deepEqual(r.candidates.map((c) => c.value), ['STARTS_WITH', 'NOT_STARTS_WITH'])
r = completeFilter('birthDate > ', 12, fields)
assert.equal(r.expecting, 'value')
assert.ok(r.candidates.some((c) => c.value === 'now(-7d)'))
r = completeFilter('birthDate > now(-7d) ', 21, fields)
assert.equal(r.expecting, 'connector')
assert.deepEqual(r.candidates.map((c) => c.value), ['AND', 'OR'])
r = completeFilter('(isEmployed TRUE ', 17, fields)
assert.deepEqual(r.candidates.map((c) => c.value), ['AND', 'OR', ')'])
r = completeFilter('a = 1 AND (fi', 13, fields)
assert.deepEqual([r.expecting, r.candidates[0]!.value], ['field', 'firstName'])
})
test('possible values come from the caller and list items complete after the comma', () => {
const values = (_f: unknown, prefix: string) => ['Avery Sample', 'Blair Sample'].filter((l) => l.toLowerCase().startsWith(prefix.toLowerCase())).map((l) => ({ value: l, label: l, kind: 'value' as const }))
const r = completeFilter('personId IN Avery,Bl', 20, fields, { values })
assert.equal(r.from, 18)
assert.deepEqual(r.candidates.filter((c) => c.kind === 'value').map((c) => c.value), ['Blair Sample'])
const applied = applyCompletion('personId IN Avery,Bl', r, r.candidates[0]!)
assert.equal(applied.text, 'personId IN Avery,"Blair Sample" ')
})
test('errors are located and filters described', () => {
const text = 'lastName BOGUS x'
let error: unknown
try {
parseFilter(text)
} catch (e) {
error = e
}
assert.equal(locateFilterError(text, error), 9)
assert.equal(describeFilter(parseFilter('a = 1 AND (b = 2 OR c = 3)')), '3 criteria, 1 group, joined by AND')
})
+66
View File
@@ -0,0 +1,66 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { CONF_MARK_BEGIN, appendTmuxConf, applyLiveFix, checkEnvironment, detectTerminal, parseRootBindings, readDoctorState, startupFindings, writeDoctorState } from '../env/doctor.js'
const LIST_KEYS = [
'bind-key -T root F12 set-option prefix None',
'bind-key -T root NPage if-shell -F "#{pane_in_mode}" "send-keys PageDown" \'\'',
'bind-key -T root PPage if-shell -F "#{pane_in_mode}" "send-keys PageUp" "copy-mode -eu"',
'bind-key -T root Home if-shell -F "#{alternate_on}" "send-keys Home" "copy-mode"',
'bind-key -T root MouseDown1Pane select-pane -t = \; send-keys -M',
].join('\n')
function fakeTmux(calls: string[][], overrides = 'xterm-256color:Tc') {
return (args: string[]) => {
calls.push(args)
if (args[0] === 'list-keys') return LIST_KEYS
if (args.join(' ') === 'show -gv alternate-screen') return 'on\n'
if (args.join(' ') === 'show -gv terminal-overrides') return overrides
return ''
}
}
test('root bindings that take PgUp/PgDn are found; ones that pass to full-screen programs are not', () => {
assert.deepEqual(parseRootBindings(LIST_KEYS).map((b) => b.key), ['F12', 'NPage', 'PPage', 'Home', 'MouseDown1Pane'])
const calls: string[][] = []
const report = checkEnvironment({ env: { TMUX: '/tmp/tmux-1/default,1,0', TERM: 'tmux-256color', COLORTERM: 'truecolor', LANG: 'en_US.UTF-8' }, columns: 120, rows: 40, tmux: fakeTmux(calls) })
const keys = report.findings.filter((f) => f.id.startsWith('tmux-key-')).map((f) => f.id)
assert.deepEqual(keys, ['tmux-key-NPage', 'tmux-key-PPage'])
const ppage = report.findings.find((f) => f.id === 'tmux-key-PPage')!
assert.deepEqual(ppage.live, [['bind-key', '-n', 'PPage', 'if-shell', '-F', '#{alternate_on}', 'send-keys PPage', 'if-shell -F "#{pane_in_mode}" "send-keys PageUp" "copy-mode -eu"']])
assert.equal(ppage.permanent, 'bind-key -n PPage if-shell -F "#{alternate_on}" "send-keys PPage" { if-shell -F "#{pane_in_mode}" "send-keys PageUp" "copy-mode -eu" }')
assert.ok(!report.findings.some((f) => f.id === 'tmux-truecolor'))
applyLiveFix(ppage, fakeTmux(calls))
assert.deepEqual(calls.at(-1), ppage.live![0])
})
test('the permanent fix is appended once in a marked block; choices are remembered', () => {
const dir = mkdtempSync(join(tmpdir(), 'qqq-doc-'))
try {
const conf = join(dir, 'tmux.conf')
writeFileSync(conf, 'set -g mouse on')
const report = checkEnvironment({ env: { TMUX: 'x', TERM: 'xterm', COLORTERM: '', LANG: 'C' }, columns: 70, rows: 20, tmux: fakeTmux([], '') })
for (const id of ['tmux-term', 'tmux-truecolor', 'truecolor', 'unicode', 'size']) assert.ok(report.findings.some((f) => f.id === id), id)
appendTmuxConf(report.findings, conf)
appendTmuxConf(report.findings, conf)
const text = readFileSync(conf, 'utf8')
assert.equal(text.split(CONF_MARK_BEGIN).length, 2)
assert.ok(text.startsWith('set -g mouse on\n'))
const env = { QQQ_CONFIG_DIR: dir }
writeDoctorState({ dontAsk: ['tmux-key-PPage'] }, env)
assert.deepEqual(readDoctorState(env).dontAsk, ['tmux-key-PPage'])
assert.ok(!startupFindings(report, readDoctorState(env)).some((f) => f.id === 'tmux-key-PPage'))
assert.equal(detectTerminal({ TERM_PROGRAM: 'Apple_Terminal' }), 'Terminal.app')
assert.equal(detectTerminal({ WT_SESSION: '1' }), 'Windows Terminal')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
@@ -0,0 +1,56 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { History, addRecent, hasPin, hashPin, pinDelayMillis, readRecents, removePin, setPin, verifyPin } from '../session/local-state.js'
import { readConfig } from '../session/profiles.js'
test('the PIN is stored as a salted hash and verified', () => {
const dir = mkdtempSync(join(tmpdir(), 'qqq-pin-'))
const env = { QQQ_CONFIG_DIR: dir }
try {
assert.equal(hasPin(env), false)
assert.equal(verifyPin('anything', env), true, 'no PIN: nothing to check')
assert.throws(() => setPin('12', env), /at least 4/)
setPin('2468', env)
const text = readFileSync(join(dir, 'config.json'), 'utf8')
assert.ok(!text.includes('2468'), 'the PIN itself is never written')
const lock = (readConfig(env).ui as { lock: { pinSalt: string; pinHash: string } }).lock
assert.equal(lock.pinHash, hashPin('2468', lock.pinSalt))
assert.equal(verifyPin('2468', env), true)
assert.equal(verifyPin('1357', env), false)
assert.equal(removePin('0000', env), false)
assert.equal(removePin('2468', env), true)
assert.equal(hasPin(env), false)
assert.deepEqual([1, 2, 3, 10].map(pinDelayMillis), [500, 1000, 2000, 30000])
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
test('histories persist, skip repeats and secrets, and search newest first', () => {
const dir = mkdtempSync(join(tmpdir(), 'qqq-hist-'))
const prev = process.env.QQQ_STATE_DIR
process.env.QQQ_STATE_DIR = dir
try {
const h = new History('cmd', 3)
for (const l of ['table query person', 'table query person', 'login --password x', 'table get pet 3', 'home', 'quit']) h.add(l)
assert.deepEqual(new History('cmd', 3).entries(), ['quit', 'home', 'table get pet 3'])
assert.deepEqual(h.search('e'), ['home', 'table get pet 3'])
const env = { QQQ_CONFIG_DIR: dir }
addRecent('http://h', { kind: 'record', table: 'person', key: '1', label: 'Avery' }, 30, env)
addRecent('http://h', { kind: 'record', table: 'person', key: '2', label: 'Blair' }, 30, env)
addRecent('http://h', { kind: 'record', table: 'person', key: '1', label: 'Avery' }, 30, env)
assert.deepEqual(readRecents('http://h', env).map((r) => r.key), ['1', '2'])
} finally {
if (prev === undefined) delete process.env.QQQ_STATE_DIR
else process.env.QQQ_STATE_DIR = prev
rmSync(dir, { recursive: true, force: true })
}
})
+50
View File
@@ -0,0 +1,50 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { completePath, createFolder, expandPath, formatSize, listDirectory, readFilePreferences, startDirForSave, writeFilePreferences } from '../files/navigator.js'
test('listings put directories first and hide dot files unless asked', () => {
const dir = mkdtempSync(join(tmpdir(), 'qqq-nav-'))
try {
mkdirSync(join(dir, 'zeta'))
writeFileSync(join(dir, 'Alpha.csv'), 'a,b\n')
writeFileSync(join(dir, '.secret'), 'x')
mkdirSync(join(dir, 'beta'))
assert.deepEqual(listDirectory(dir).map((e) => e.name), ['beta', 'zeta', 'Alpha.csv'])
assert.ok(listDirectory(dir, { hidden: true }).some((e) => e.name === '.secret'))
assert.equal(listDirectory(dir).find((e) => e.name === 'Alpha.csv')!.size, 4)
assert.deepEqual(completePath('be', { cwd: dir }), { text: 'beta/', candidates: ['beta/'] })
writeFileSync(join(dir, 'beta', 'report-1.xlsx'), '')
writeFileSync(join(dir, 'beta', 'report-2.xlsx'), '')
assert.deepEqual(completePath('beta/re', { cwd: dir }), { text: 'beta/report-', candidates: ['report-1.xlsx', 'report-2.xlsx'] })
assert.deepEqual(completePath('nothing', { cwd: dir }).candidates, [])
assert.equal(createFolder(dir, 'new'), join(dir, 'new'))
assert.throws(() => createFolder(dir, 'a/b'), /slash/)
assert.equal(expandPath('~'), homedir())
assert.equal(expandPath('x', dir), join(dir, 'x'))
assert.deepEqual([formatSize(512), formatSize(1536), formatSize(5 * 1024 * 1024)], ['512 B', '1.5 KB', '5.0 MB'])
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
test('file preferences persist in the config file', () => {
const dir = mkdtempSync(join(tmpdir(), 'qqq-fp-'))
const env = { QQQ_CONFIG_DIR: dir }
try {
writeFilePreferences({ downloadDir: dir, downloadMode: 'silent' }, env)
assert.deepEqual(readFilePreferences(env), { downloadDir: dir, downloadMode: 'silent' })
writeFilePreferences({ downloadMode: undefined }, env)
assert.deepEqual(readFilePreferences(env), { downloadDir: dir })
assert.equal(startDirForSave({ downloadDir: dir }), dir)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
+11 -7
View File
@@ -9,7 +9,8 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { FileStore, SecretFileError, defaultSecretStore } from '../session/keyring.js'
import { ConfigFileError, profileNameForServer, readConfig, readUiSettings, removeProfile, resolveProfile, upsertProfile, writeUiSettings } from '../session/profiles.js'
import { readFilePreferences, writeFilePreferences } from '../files/navigator.js'
import { ConfigFileError, profileNameForServer, readConfig, readTheme, removeProfile, resolveProfile, upsertProfile, writeTheme } from '../session/profiles.js'
test('profiles resolve from --profile, QQQ_PROFILE, --server, then the default', () => {
const dir = mkdtempSync(join(tmpdir(), 'qqq-cfg-'))
@@ -64,17 +65,20 @@ test('a corrupt config.json is refused, never overwritten with an empty profile
}
})
test('the ui section keeps local settings and survives profile writes', () => {
test('the ui section (files, theme) survives profile writes, and unknown sections are kept', () => {
const dir = mkdtempSync(join(tmpdir(), 'qqq-cfg-'))
const env = { QQQ_CONFIG_DIR: dir }
try {
assert.deepEqual(readUiSettings(env), {})
writeUiSettings({ defaultDownloadDir: '/tmp/dl' }, env)
writeFileSync(join(dir, 'config.json'), JSON.stringify({ profiles: {}, future: { keep: true } }))
writeFilePreferences({ downloadDir: '/tmp/dl' }, env)
writeTheme('midnight', {}, env)
upsertProfile('sample', { server: 'http://localhost:8077' }, {}, env)
assert.deepEqual(readUiSettings(env), { defaultDownloadDir: '/tmp/dl' })
writeUiSettings({ defaultDownloadDir: undefined, defaultUploadDir: '/tmp/up' }, env)
assert.deepEqual(readUiSettings(env), { defaultUploadDir: '/tmp/up' })
assert.deepEqual(readFilePreferences(env), { downloadDir: '/tmp/dl' })
assert.equal(readTheme('sample', env), 'midnight')
writeFilePreferences({ downloadDir: undefined, uploadDir: '/tmp/up' }, env)
assert.deepEqual(readFilePreferences(env), { uploadDir: '/tmp/up' })
assert.equal(readConfig(env).defaultProfile, 'sample')
assert.deepEqual(readConfig(env).future, { keep: true })
} finally {
rmSync(dir, { recursive: true, force: true })
}
@@ -0,0 +1,51 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { ResultCursor } from '../records/result-cursor.js'
function fakeServer(total: number) {
const calls: Array<{ path: string; body: { filter: { skip?: number; limit?: number; orderBys?: unknown } } }> = []
const fetchImpl: typeof fetch = async (input, init) => {
const request = new Request(input as RequestInfo, init)
const body = await request.json() as { filter: { skip?: number; limit?: number } }
const path = new URL(request.url).pathname
calls.push({ path, body: body as never })
const json = (data: unknown) => new Response(JSON.stringify(data), { status: 200, headers: { 'content-type': 'application/json' } })
if (path.endsWith('/count')) return json({ count: total })
const skip = body.filter.skip ?? 0
const ids = Array.from({ length: Math.max(0, Math.min(body.filter.limit ?? 10, total - skip)) }, (_, i) => skip + i + 1)
return json({ records: ids.map((id) => ({ values: { id }, recordLabel: `R${id}` })) })
}
return { calls, fetchImpl }
}
test('the cursor steps across pages and stops at the ends', async () => {
const { calls, fetchImpl } = fakeServer(7)
const cursor = new ResultCursor({ baseUrl: 'http://h', fetch: fetchImpl, table: 't', filter: { criteria: [], booleanOperator: 'AND' }, pageSize: 3, index: 2 })
assert.equal((await cursor.current())!.recordLabel, 'R3')
assert.equal(cursor.describe(), 'record 3 of 7')
assert.equal((await cursor.next())!.recordLabel, 'R4')
assert.equal(calls.filter((c) => c.path.endsWith('/query')).at(-1)!.body.filter.skip, 3)
assert.deepEqual(calls[0]!.body.filter.orderBys, [{ fieldName: 'id', isAscending: true }])
cursor.index = 6
assert.equal(await cursor.next(), undefined)
assert.equal(cursor.index, 6)
cursor.index = 0
assert.equal(await cursor.previous(), undefined)
assert.equal(cursor.indexOfKey(4), 3)
})
test('after a delete the cursor shows the next record and reloads pages', async () => {
const { calls, fetchImpl } = fakeServer(5)
const cursor = new ResultCursor({ baseUrl: 'http://h', fetch: fetchImpl, table: 't', filter: { criteria: [], booleanOperator: 'AND' }, pageSize: 2, index: 4 })
cursor.seed(2, [{ values: { id: 5 } }], 5)
assert.equal(cursor.keyOf(await cursor.current()), 5)
assert.equal(calls.length, 0, 'a seeded page needs no request')
cursor.removed(4)
assert.equal(cursor.total, 4)
assert.equal(cursor.index, 3)
})
+5
View File
@@ -34,3 +34,8 @@ test('tables drop hidden columns and strip html cells; composites nest', () => {
assert.equal(blocksToText(c), 'Open: 123 orders\n\nhi')
assert.equal(blocksToText(widgetToBlocks({ type: 'statistics', hasPermission: false, label: 'Secret' })), 'Secret\n[WARNING] You do not have permission to view this data.')
})
test('multiStatistics reads statisticList (the sample server spelling) as well as statisticsList', () => {
const blocks = widgetToBlocks({ type: 'multiStatistics', statisticsGroupData: [{ header: 'This Week', statisticList: [{ label: 'Red', value: 15 }] }] })
assert.deepEqual(blocks.filter((b) => b.kind === 'stat'), [{ kind: 'stat', label: 'Red', value: '15', context: undefined }])
})
+1 -1
View File
@@ -56,7 +56,7 @@ export function widgetToBlocks(data: unknown): RenderBlock[] {
case 'multiStatistics':
for (const group of ((w.statisticsGroupData ?? w.groups ?? []) as J[])) {
if (group.header ?? group.label) blocks.push({ kind: 'text', text: String(group.header ?? group.label) })
for (const s of ((group.statisticsList ?? group.statistics ?? []) as J[])) blocks.push({ kind: 'stat', label: str(s.label), value: str(s.count ?? s.value), context: s.context ? String(s.context) : undefined })
for (const s of ((group.statisticsList ?? group.statisticList ?? group.statistics ?? []) as J[])) blocks.push({ kind: 'stat', label: str(s.label), value: str(s.count ?? s.value), context: s.context ? String(s.context) : undefined })
}
break
case 'table':
+108
View File
@@ -0,0 +1,108 @@
# qqq ui: the QQQ terminal UI
A classic full-screen terminal UI for QQQ applications: function-key bar, PgUp/PgDn everywhere, Esc back,
a `:` command line that speaks the CLI grammar, and a live footer. OpenTUI 0.8.1 (React 19) on Bun 1.4.4.
Everything that is not drawing comes from the client kernel (`@qrun-io/qqq-core`): sign-ins and profiles,
the metadata cache, the filter grammar and its completion, FormSession, runProcess, the widget render
model, the record-view and association helpers, the result cursor, file and terminal checks.
## Run it
```sh
npm install && npm run build # once, at the repo root (the kernel and CLI are used built)
(cd packages/tui && bun install) # once
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 | --mouse | --check | --no-check
bun run packages/tui/src/main.tsx --profile sample # the same without the CLI
```
`QQQ_UI_LINEAR=1` gives the one-region layout (no Apps pane), `QQQ_UI_ASCII=1` the ASCII glyphs.
## Keys
| Key | Does | Key | Does |
|---|---|---|---|
| F1, `?` | help: keys, what you can do here, help search | Esc | back (the screen below keeps its cursor and filter) |
| F2, `/`, `.`, Ctrl+K | go to anything; record search when the server has a provider | Tab | Apps pane / content |
| F3, Enter | view, open | ↑ ↓ PgUp PgDn Home End | move and page |
| F4, `e` | edit | Ctrl+F, Ctrl+B | page forward and back (always works) |
| F5, `r` | refresh | Space, Shift+Space | page lists (on a grid Space selects) |
| F6, `f` | filter: guided builder or the grammar with completion | ← → | columns, tree |
| F7, `n` | new record (on a child list: a child of this record) | `s` | sort by the column under the cursor |
| F8, `d` | delete, after a question | `]` `[`, Ctrl+N Ctrl+P, PgDn PgUp in a record | next and previous record of the list it came from |
| F9 | the command center: every action, searchable | `b` | run a table process on the selection |
| F10, `:quit` | Quit, Reset (home screen, metadata refreshed, session kept) or Cancel; `:reset` and `:home` reset | `x`, `g`, `v` | export, go to by key, view settings |
| `:` | command line (history ↑↓, Ctrl+R, Tab completes) | Ctrl+S | save a form, continue a process step |
| Ctrl+C | quit at once (the terminal is restored) | `c` | copy a record |
Single-key shortcuts follow the server preference `keyboard.singleKeyShortcuts` and can be switched in F9
Settings; every F-key action is also in the F9 menu and on the `:` line.
## Screens
Sign-in (profiles; MOCK and FULLY_ANONYMOUS get a server session, OAUTH2 and AUTH_0 the device flow with the
code on screen; the sign-in is shared with the CLI), home (apps with counts, recently viewed), app home
(widgets drawn from render blocks: stats, bar charts in block characters, sparklines, percentage bars,
tables, steppers, then sections with record counts), table grid (server paging, sort, columns that use the
width, horizontal scroll, selection with Space and a bulk bar, export, go to by key), record view (sections
by tier, links, child lists, next and previous through the list it came from), create/edit/copy forms
(editors per type, a picker that narrows for contingent fields, local and server validation by field,
DATE_TIME in the local zone), the process wizard (each step's form, validation review, progress with N/M
and elapsed, summary, cancel), and the output pane of `:` commands run through the CLI in-process.
## The `:` command line
TUI commands: `home`, `back`, `quit`, `help`, `menu`, `goto`, `view`, `theme <name>`, `ascii on|off`,
`linear on|off`, `shortcuts on|off`, `lock`, `pin`, `pin remove`, `idle <minutes>`, `filter [clear|<grammar>]`,
`export <format>`, `jobs`, `profile [name]`, `server <url>`, `signout`, `connection`, `doctor`, `open <table>`,
`app <name>`, `record <table> <key>`. `table query|get|create|update|delete` and `process run` open screens;
any other CLI command runs in-process (`runCli` from `@qrun-io/qqq-cli/program`) and shows its output.
## Settings kept locally
In `~/.config/qqq/config.json` (the kernel owns the file), section `ui`: `theme` (also per profile),
`views["table:person"]` (columns, widths, fit, zebra, header rule, density), `views["record:…"]`
(columns, label width), `views["app:…"]` (widget order), `files` (download and upload folders, ask or save
silently), `lock` (screen-lock PIN as a salted hash, idle minutes), `doctor` (terminal check choices),
`recents`. Command histories live in `~/.local/state/qqq/history-commands` and `history-filters`.
Themes: `high-contrast` (default), `classic`, `light`; tokens in `src/theme.ts`, no color elsewhere.
## Keys that your terminal may intercept
The TUI runs in the alternate screen and reads PgUp/PgDn itself, but a terminal or tmux can take a key
before any program sees it. `qqq ui --check` (and the quick check at startup) finds these and offers fixes.
- **tmux.** A root binding such as `bind -n PageUp if-shell -F "#{pane_in_mode}" "send-keys PageUp" "copy-mode -eu"`
enters copy-mode on every PgUp, full-screen program or not. Let full-screen programs have the keys and keep
scrollback in the shell:
```tmux
bind -n PageUp if-shell -F "#{alternate_on}" "send-keys PageUp" "copy-mode -eu"
bind -n PageDown if-shell -F "#{alternate_on}" "send-keys PageDown" ""
```
Keep `set -g alternate-screen on`, `set -g default-terminal "tmux-256color"` and true color
(`set -as terminal-overrides ',*:Tc'`).
- **iTerm2.** If PgUp/PgDn scroll the window, map Page Up/Down in Profiles › Keys › Key Mappings to "Send Escape
Sequence" `[5~` and `[6~`; "Scroll wheel sends arrow keys in alternate screen mode" affects the wheel only.
- **Terminal.app.** F1 to F10 go to macOS unless "Use F1, F2, etc. keys as standard function keys" is on
(System Settings › Keyboard), or hold fn.
- Whatever the terminal does, Ctrl+F / Ctrl+B page and the F9 menu and `:` line reach every action.
## 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
```
## Layout of the code
`src/store.ts` holds all state and dispatches every key (lock, overlay, global F-keys, focused pane, fallbacks),
so handlers read live state, never a stale closure. Screens (`src/screens/*`) are route entries that keep their
own state (grid cursor, filter, selection, scroll), which is why Esc finds them as they were. Overlays
(`src/overlays/*`) are modal. Text fields are `LineEditor` objects driven by keys, not OpenTUI's focused
input, so a burst of keys lands in the field that had focus. `src/app.tsx` draws the frame with an error
boundary around every screen and overlay; `src/main.tsx` restores the terminal on any crash.
+86
View File
@@ -0,0 +1,86 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "@qrun-io/qqq-tui-spike",
"dependencies": {
"@opentui/core": "0.8.1",
"@opentui/react": "0.8.1",
"react": "19.3.1",
},
"devDependencies": {
"@types/bun": "1.4.4",
"@types/react": "19.3.0",
"typescript": "5.8.3",
},
},
},
"packages": {
"@opentui/core": ["@opentui/core@0.8.1", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.8.1", "@opentui/core-darwin-x64": "0.8.1", "@opentui/core-linux-arm64": "0.8.1", "@opentui/core-linux-arm64-musl": "0.8.1", "@opentui/core-linux-x64": "0.8.1", "@opentui/core-linux-x64-musl": "0.8.1", "@opentui/core-win32-arm64": "0.8.1", "@opentui/core-win32-x64": "0.8.1" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-WmnpnVZn2MdyYNEahQNn4k3OrIkQKfcwPVrX/DjguLtl/S2g5fPeXCqHZjCg2GLMf1N4HJap0LEkTPJEvfrDXw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.8.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-22jdbN55W9hCraw6k1y3TklzCvF90UJG7d1wvtUYwsn9LOEvOZn+i3fUyqkpP+kBgL31RBt+sB4RKsUmNzjWaw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.8.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-rlqzXisgUl3U7WmrWySFLLZq6x9ZQiSRSGLyyULBFWTZLJY4qrs2a7y/8B0NpIFY7Ngku17qVIKcVwQZZBoLbw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.8.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-UtJzpwvAy67RU60T1IFNBYHH3tUrmcIHsLrNI+8WFFxoOk6btwRp67NKiZd3qoDcztTz2q/yKf9KQI0yr+P6MQ=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.8.1", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-BzVCOuG5Vw/nB9ffLwZq82uEg0f0BE4wkd5EvrqOET9qK5D5emT4Zo8tZJFY8NG0/xBuWj6S9xVpazQWjZR17A=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.8.1", "", { "os": "linux", "cpu": "x64" }, "sha512-du4V6YlGvCWs/ggc6Tj7Oc7qaoYdPpzwAeJMKUbjrg+Zv5zweNHFMN4YLbzIutTi6Ukz4KsW/hwEaQIjXHlUcA=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.8.1", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-RB/u2G5X4KpW1/rT5iabCLYbmfMWKF3OASj2Z29+RAj38I7caRU3/TTntpX3CpHzjQrDTVWyMYzcnu+WFF8thg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.8.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-wF5AZRWDCjkfRHT1cRfyvHkf+RI/Pxjb0oMFVg2h2ayndBu8CSgyQDhtNgZd0ON+xrP4xceikCkHDhDLe5bv1w=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.8.1", "", { "os": "win32", "cpu": "x64" }, "sha512-+rIrkvwBJMGWdXn4GGQAmM4y+RH1Xvb9nveNyJ58SOsTOBqyWyi+63qMlxe58WVl+sVAJodEjAILcWdwrzMJ3Q=="],
"@opentui/react": ["@opentui/react@0.8.1", "", { "dependencies": { "@opentui/core": "0.8.1", "react-reconciler": "^0.33.0" }, "peerDependencies": { "react": ">=19.2.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-2Suc127MY7iLkgzFjyKJVsML4PZHhtc+lOXgQhP4bP6zxgNCBlGm/FxX0fDbp5RpuogKyIix9s57YnpxQB54nw=="],
"@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="],
"@types/node": ["@types/node@26.6.6", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog=="],
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
"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=="],
"bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"get-east-asian-width": ["get-east-asian-width@1.7.0", "", {}, "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q=="],
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"react": ["react@19.3.1", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="],
"react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="],
"react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"shell-quote": ["shell-quote@1.12.0", "", {}, "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"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=="],
"web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
"ws": ["ws@8.22.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g=="],
"react-devtools-core/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="],
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@qrun-io/qqq-tui",
"version": "0.1.0-dev",
"private": true,
"description": "The QQQ terminal UI (qqq ui): OpenTUI (React) on Bun, built on the client kernel (@qrun-io/qqq-core).",
"license": "Apache-2.0",
"type": "module",
"scripts": {
"start": "bun run src/main.tsx",
"test": "bun test",
"typecheck": "tsc -p tsconfig.json"
},
"dependencies": {
"@opentui/core": "0.8.1",
"@opentui/react": "0.8.1",
"react": "19.3.1"
},
"devDependencies": {
"@types/bun": "1.4.4",
"@types/react": "19.3.0",
"typescript": "5.8.3"
}
}
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Copyright 2026 QRun.IO, Inc.
# Licensed under the Apache License, Version 2.0 (see LICENSE).
#
# tmux smoke test of qqq ui against a running server (default: the sample server, profile "sample").
# Drives the real TUI with per-key pauses and checks each screen's text; captures go to $OUT.
# packages/tui/scripts/smoke.sh [profile] OUT=dir WIDTH=160 HEIGHT=45
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
pkg="$(cd "$here/.." && pwd)"
profile="${1:-sample}"
out="${OUT:-$(mktemp -d)}"
w="${WIDTH:-160}"; h="${HEIGHT:-45}"
s="qqq-smoke-$$"
fails=0
mkdir -p "$out"
cleanup() { tmux kill-session -t "$s" 2>/dev/null || true; }
trap cleanup EXIT
keys() { for k in "$@"; do if [[ $k == T:* ]]; then tmux send-keys -t "$s" -l -- "${k#T:}"; else tmux send-keys -t "$s" "$k"; fi; sleep 0.25; done; }
cap() { tmux capture-pane -t "$s" -p > "$out/$1.txt"; }
expect() { # name text...
local name=$1; shift
for _ in $(seq 1 40); do
cap "$name"
local ok=1
for t in "$@"; do grep -qF -- "$t" "$out/$name.txt" || ok=0; done
[[ $ok == 1 ]] && { echo "ok $name"; return 0; }
sleep 0.25
done
echo "FAIL $name (wanted: $*)"; fails=$((fails + 1))
}
tmux new-session -d -s "$s" -x "$w" -y "$h" "cd '$pkg' && bun run src/main.tsx --no-check --profile '$profile'; sleep 5"
expect 01-home 'Apps' 'UI API'
keys T:: 'T:table query person' Enter
expect 02-grid 'records ·' 'First Name'
keys Down Down Down Enter
expect 03-record 'record 4 of'
keys PageDown
expect 04-record-next 'record 5 of'
keys Escape
expect 05-grid-row 'row 5 '
keys F6
expect 06-filter 'Filter Person' 'Guided'
keys Tab 'T:lastName STARTS_WITH S' Enter
expect 07-filtered 'lastName STARTS_WITH S'
keys T:: 'T:table create placeAddress' Enter
expect 08-form 'New Address book' 'set Country first'
keys Escape
keys T:: 'T:process run sleepInteractive' Enter
expect 09-process 'Sleep duration'
keys C-u T:500 C-s
expect 10-process-done 'Finished in' 'Slept for 500'
keys Escape F9
expect 11-menu 'Menu (F9)' 'Context' 'Go to'
keys Escape 'T:?'
expect 12-help 'Help (F1' 'Keys'
keys Escape T:: 'T:app SampleWidgetsDashboard' Enter
expect 13-dashboard 'Sample Widgets Dashboard'
keys F10
expect 14-quit-confirm 'Quit qqq?' 'Reset: go to the home screen'
keys Enter
sleep 1
echo "captures in $out"
[[ $fails == 0 ]] && echo "smoke: ok" || { echo "smoke: $fails failed"; exit 1; }
+162
View File
@@ -0,0 +1,162 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { useKeyboard, useTerminalDimensions } from '@opentui/react'
import { Component, useEffect, useReducer, type ReactNode } from 'react'
import type { AppTreeNode } from './client'
import { presenceText, usePresence } from './presence'
import type { AppStore } from './store'
import { glyphs, theme } from './theme'
import { pad, trunc } from './util/text'
/** Catches a failing screen or overlay so it shows its error in place; the rest of the TUI keeps working. */
export class ErrorBoundary extends Component<{ children: ReactNode; resetKey: string; width: number; label: string }, { error: Error | null }> {
override state = { error: null as Error | null }
static getDerivedStateFromError(error: Error) {
return { error }
}
override componentDidUpdate(prev: { resetKey: string }) {
if (prev.resetKey !== this.props.resetKey && this.state.error) this.setState({ error: null })
}
override render() {
if (this.state.error) {
const t = theme()
return (
<box flexDirection="column" border borderColor={t.err} width={this.props.width} title={` ${this.props.label} error `}>
<text fg={t.err}>{trunc(` ${this.state.error.message}`, this.props.width - 2)}</text>
<text fg={t.dim}>{trunc(' Esc goes back · F5 retries · this is a bug in the screen; please report the steps', this.props.width - 2)}</text>
</box>
)
}
return this.props.children
}
}
/** 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?.())
const brand = (store.meta?.branding as { appName?: string } | undefined)?.appName ?? 'QQQ'
const crumbs = breadcrumbs(store)
const shown = crumbs.length > 5 ? ['…', ...crumbs.slice(-5)] : crumbs
const ascii = glyphs().border !== undefined
const left = ` ${brand} ${ascii ? '-' : '─'} ${shown.join(ascii ? ' > ' : ' › ')}`
// collaboration presence slot: "N others viewing" (empty without the collaboration extension)
const others = presenceText(presence)
const right = [others, store.status.user, store.status.alerts ? `alerts ${store.status.alerts}` : '', store.status.jobs !== 'n/a' && store.status.jobs !== '0' ? `jobs ${store.status.jobs}` : ''].filter(Boolean).join(' ') + ' '
const room = Math.max(0, width - right.length)
return <text fg={t.titleFg} bg={t.titleBg}>{pad(trunc(left, room), room) + trunc(right, width - room)}</text>
}
function StatusLine({ store, width }: { store: AppStore; width: number }) {
const t = theme()
const s = store.status
const presence = usePresence(store.screen?.presenceScope?.())
if (store.flashText) {
return <text fg={store.flashText.kind === 'error' ? t.err : t.acc2} bg={t.statusBg}>{pad(` ${store.flashText.text}`, width)}</text>
}
const ping = s.pingMs === null ? 'ping --' : `ping ${s.pingMs.toFixed(0)} ms`
const streamColor = s.stream === 'connected' ? t.ok : s.stream === 'reconnecting' ? t.warn : t.statusFg
const left = ` ${s.server.replace(/^https?:\/\//, '')} · ${s.profile}${s.auth ? ` · ${s.auth}` : ''} · UI API ${s.apiVersion} · ${ping} · `
const stream = `stream ${s.stream}`
const right = ` · jobs ${s.jobs}${presence.count ? ` · ${presence.count} viewing` : ''} · ${s.lastEvent} · ${s.build} · ${s.clock} `
if (left.length + stream.length + right.length > width) {
const short = ` ${s.profile} · ${ping} · ${stream} · jobs ${s.jobs} · ${s.clock} `
return <text fg={t.statusFg} bg={t.statusBg}>{pad(short, width)}</text>
}
return (
<text bg={t.statusBg}>
<span fg={t.statusFg} bg={t.statusBg}>{left}</span>
<span fg={streamColor} bg={t.statusBg}>{stream}</span>
<span fg={t.statusFg} bg={t.statusBg}>{pad(right, width - left.length - stream.length)}</span>
</text>
)
}
function FKeyBar({ store, width }: { store: AppStore; width: number }) {
const t = theme()
const keys = store.fkeys()
const cell = Math.max(7, Math.floor(width / keys.length))
let used = 0
return (
<text>
{keys.map((k) => {
const w = k.n === 10 ? Math.max(0, width - used) : cell
used += w
const num = String(k.n).padStart(2)
const label = k.label && k.label.length > w - 2 ? ({ Refresh: 'Reload', NewChild: 'Child', 'Re-run': 'Again' } as Record<string, string>)[k.label] ?? k.label : k.label
return (
<span key={k.n}>
<span fg={t.keyNum}>{num}</span>
<span fg={t.keyLabelFg} bg={k.label === null ? undefined : t.keyLabelBg}>{pad(label ?? '', Math.max(0, w - 2))}</span>
</span>
)
})}
</text>
)
}
export function App({ store }: { store: AppStore }) {
const [, force] = useReducer((n: number) => n + 1, 0)
const { width, height } = useTerminalDimensions()
useEffect(() => store.subscribe(force), [store])
useEffect(() => {
if (width !== store.width || height !== store.height) store.resize(width, height)
}, [width, height, store])
useKeyboard((key) => store.handleKey(key))
const t = theme()
const w = Math.max(20, width)
const h = Math.max(8, height)
const bodyHeight = h - 3
const sidebarWidth = store.showSidebar ? (w >= 160 ? 32 : 28) : 0
const contentWidth = w - sidebarWidth
const screen = store.screen
const top = store.overlays[store.overlays.length - 1]
return (
<box flexDirection="column" width={w} height={h}>
<TitleBar store={store} width={w} />
<box flexDirection="row" width={w} height={bodyHeight}>
{sidebarWidth > 0 && store.sidebar && (
<ErrorBoundary resetKey="sidebar" width={sidebarWidth} label="Apps">{store.sidebar.render(sidebarWidth, bodyHeight, store.focus === 'sidebar' && !top)}</ErrorBoundary>
)}
{screen
? <ErrorBoundary key={screen.id} resetKey={screen.id} width={contentWidth} label="Screen">{screen.render(contentWidth, bodyHeight, store.focus === 'content' && !top)}</ErrorBoundary>
: <box width={contentWidth} height={bodyHeight}><text fg={t.dim}>{' starting…'}</text></box>}
</box>
<StatusLine store={store} width={w} />
<FKeyBar store={store} width={w} />
{store.overlays.map((o) => <ErrorBoundary key={o.id} resetKey={o.id} width={Math.min(60, w)} label="Dialog">{o.render(w, h)}</ErrorBoundary>)}
{store.lock && <ErrorBoundary key="lock" resetKey="lock" width={w} label="Lock">{store.lock.render(w, h)}</ErrorBoundary>}
</box>
)
}
+115
View File
@@ -0,0 +1,115 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/** Builds the store for a run: settings from options, config and environment; sign-in; the first route. */
import { readTheme, resolveProfile, writeTheme, defaultSecretStore, type SecretStore } from '@qrun-io/qqq-core'
import { installCommands } from './commands'
import { SignInScreen, type SignInDeps } from './screens/signin'
import { Sidebar } from './screens/sidebar'
import { AppStore, setSecretStoreFactory, type Target } from './store'
import { DEFAULT_THEME, THEMES } from './theme'
export interface CliArgs {
profile?: string
server?: string
theme?: string
ascii: boolean
linear: boolean
mouse: boolean
check: 'startup' | 'full' | 'off'
route: string[]
}
export function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env): CliArgs {
const out: CliArgs = { ascii: env.QQQ_UI_ASCII === '1', linear: env.QQQ_UI_LINEAR === '1', mouse: false, check: 'startup', route: [] }
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!
if (a === '--') {
out.route = argv.slice(i + 1)
break
}
if (a === '--profile' || a === '-p') out.profile = argv[++i]
else if (a === '--server' || a === '-s') out.server = argv[++i]
else if (a === '--theme') out.theme = argv[++i]
else if (a === '--ascii') out.ascii = true
else if (a === '--linear') out.linear = true
else if (a === '--mouse') out.mouse = true
else if (a === '--check') out.check = 'full'
else if (a === '--no-check') out.check = 'off'
else out.route.push(a)
}
return out
}
export interface BootOptions {
args: CliArgs
quit: () => void
build?: string
secrets?: () => SecretStore
fetch?: typeof fetch
timers?: boolean
}
/** 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
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, singleKeys: true }, quit: options.quit, build: options.build, target, timers: options.timers })
let secrets: SecretStore | undefined
const secretsFn = options.secrets ?? (() => (secrets ??= defaultSecretStore()))
setSecretStoreFactory(secretsFn)
installCommands(store)
store.sidebarFactory = (s) => new Sidebar(s)
const deps: SignInDeps = {
secrets: secretsFn,
fetch: options.fetch,
timers: options.timers,
afterConnect: (s) => {
loadKeyboardPreference(s)
if (args.route.length) {
const route = args.route
args.route = []
void s.commands().run(route.join(' ').replace(/^(\S+ \S+ \S+ )(.+)$/, (m) => m) && routeLine(route))
}
},
}
store.signInDeps = deps
const screen = new SignInScreen(store, deps, target, note)
store.push(screen)
if (target) screen.choose(target)
return store
}
/** Re-quotes route arguments into one command line. */
function routeLine(route: string[]): string {
return route.map((a) => (/^[A-Za-z0-9_./:=,@+-]+$/.test(a) ? a : `"${a.replace(/(["\\])/g, '\\$1')}"`)).join(' ')
}
/** The server's keyboard.singleKeyShortcuts preference turns single-key shortcuts off. */
function loadKeyboardPreference(store: AppStore): void {
if (!(store.version?.features ?? []).includes('preferences')) return
store.client?.data<{ values?: Record<string, unknown> }>('getPreferenceSection', { path: { sectionName: 'keyboard' } })
.then((d) => {
const v = d.values?.singleKeyShortcuts
if (v === false || v === 'false') {
store.settings.singleKeys = false
store.bump()
}
})
.catch(() => undefined)
}
+171
View File
@@ -0,0 +1,171 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The TUI's view of one server, entirely through the kernel: every call is a UI API operation by
* id (callOperation), metadata comes through the kernel's on-disk cache, and authentication is the
* kernel's login fetch. Tests construct it with a fake fetch.
*/
import { callOperation, getMetaData, getTableMetaData, type CallInput, type CallResult, type MetaDataDocument, type ProcessMeta, type QueryFilter, type QueryRecord } from '@qrun-io/qqq-core'
export const TUI_VERSION = '0.1.0-dev'
export const TUI_CHANNEL = `tui/qqq ${TUI_VERSION}`
export type Json = Record<string, unknown>
export interface FieldMetaData {
name: string
label: string
type: string
isRequired?: boolean
isEditable?: boolean | null
isHidden?: boolean
isHeavy?: boolean
possibleValueSourceName?: string | null
adornments?: Array<{ type: string; values?: Json }> | null
helpContents?: Array<{ contentAsText?: string; content?: string; roles?: string[] | null }> | null
[key: string]: unknown
}
export interface SectionMetaData {
name: string
label: string
tier?: string
fieldNames?: string[]
hidden?: boolean
widgetName?: string
[key: string]: unknown
}
export interface Association {
name: string
associatedTableName: string
join?: { name?: string; type?: string; leftTable?: string; rightTable?: string; joinOns?: Array<{ leftField: string; rightField: string }> }
label?: string
}
export interface TableMetaData {
name: string
label: string
primaryKeyField: string
fields: Record<string, FieldMetaData>
sections?: SectionMetaData[]
associations?: Association[] | null
capabilities?: string[]
insertPermission?: boolean
editPermission?: boolean
deletePermission?: boolean
helpContents?: Array<{ contentAsText?: string }> | null
[key: string]: unknown
}
export interface AppTreeNode {
type: string
name: string
label: string
children?: AppTreeNode[]
}
export interface VersionInfo {
apiVersion: string
features?: string[]
extensions?: Array<{ name: string; version: string; pathPrefix?: string; description?: string }>
}
export class TuiClient {
readonly server: string
readonly fetch: typeof fetch
private readonly useCache: boolean
private tableCache = new Map<string, TableMetaData>()
private processCache = new Map<string, ProcessMeta>()
metaDataFetchedAt?: number
constructor(options: { server: string; fetch: typeof fetch; useCache?: boolean }) {
this.server = options.server
this.fetch = options.fetch
this.useCache = options.useCache ?? true
}
call(id: string, input: CallInput = {}): Promise<CallResult> {
return callOperation(this.server, id, input, this.fetch)
}
async data<T = Json>(id: string, input: CallInput = {}): Promise<T> {
return (await this.call(id, input)).data as T
}
version(): Promise<VersionInfo> {
return this.data<VersionInfo>('getVersion')
}
async metaData(refresh = false): Promise<MetaDataDocument> {
const doc = this.useCache ? await getMetaData(this.server, this.fetch, { refresh }) : await this.data<MetaDataDocument>('getMetaData')
this.metaDataFetchedAt = Date.now()
return doc
}
async tableMeta(table: string, refresh = false): Promise<TableMetaData> {
if (!refresh && this.tableCache.has(table)) return this.tableCache.get(table)!
const meta = this.useCache ? await getTableMetaData<TableMetaData>(this.server, table, this.fetch, { refresh }) : await this.data<TableMetaData>('getTableMetaData', { path: { tableName: table } })
this.tableCache.set(table, meta)
return meta
}
async processMeta(name: string): Promise<ProcessMeta> {
if (this.processCache.has(name)) return this.processCache.get(name)!
const meta = await this.data<ProcessMeta>('getProcessMetaData', { path: { processName: name } })
this.processCache.set(name, meta)
return meta
}
clearCaches(): void {
this.tableCache.clear()
this.processCache.clear()
}
async query(table: string, filter: QueryFilter): Promise<QueryRecord[]> {
return ((await this.data<{ records?: QueryRecord[] }>('queryTable', { path: { tableName: table }, json: { filter } })).records ?? [])
}
async count(table: string, filter: QueryFilter): Promise<number> {
return Number((await this.data<{ count?: number }>('countTable', { path: { tableName: table }, json: { filter } })).count ?? 0)
}
async record(table: string, primaryKey: unknown, associations = false): Promise<QueryRecord & { associatedRecords?: Record<string, QueryRecord[]> }> {
const data = await this.data<Json>('getRecord', { path: { tableName: table, primaryKey: String(primaryKey) }, query: associations ? { includeAssociations: 'true' } : undefined })
return (data.record ?? data) as QueryRecord
}
async insert(table: string, values: Json): Promise<QueryRecord> {
const data = await this.data<Json>('insertRecord', { path: { tableName: table }, json: values })
return (data.record ?? data) as QueryRecord
}
async update(table: string, primaryKey: unknown, values: Json): Promise<QueryRecord> {
const data = await this.data<Json>('updateRecord', { path: { tableName: table, primaryKey: String(primaryKey) }, json: values })
return (data.record ?? data) as QueryRecord
}
async remove(table: string, primaryKey: unknown): Promise<void> {
await this.call('deleteRecord', { path: { tableName: table, primaryKey: String(primaryKey) } })
}
async export(table: string, format: string, filter: QueryFilter, fieldNames?: string[]): Promise<{ bytes: Uint8Array; contentType: string }> {
const result = await this.call('exportTable', { path: { tableName: table }, json: { format, filter, fieldNames } })
const bytes = result.bytes ?? new TextEncoder().encode(typeof result.data === 'string' ? result.data : JSON.stringify(result.data))
return { bytes, contentType: result.contentType }
}
widget(name: string, input: Json = {}): Promise<Json> {
return this.data<Json>('getWidgetData', { path: { widgetName: name }, json: input })
}
async ping(): Promise<number> {
const t = performance.now()
await this.call('getVersion')
return performance.now() - t
}
}
+534
View File
@@ -0,0 +1,534 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The command layer: the `:` line (TUI commands, CLI commands that open screens, everything else
* run in-process through the CLI's runCli), the F9 command center, the palette and the help.
* Menu entries and `:` commands are the same actions.
*/
import { runCli } from '@qrun-io/qqq-cli/program'
import { THEMES } from './theme'
import { applyLiveFix, checkEnvironment, formatReport, buildFilter, readFilePreferences, startDirForSave, startDirForUpload, writeFilePreferences, hasPin, readConfig, readRecents, removePin, setPin, writeIdleMinutes, writeTheme, readIdleMinutes, type QueryFilter } from '@qrun-io/qqq-core'
import type { AppTreeNode } from './client'
import { openApp, openForm, openProcess, openRecord, openTable } from './nav'
import { ChoiceOverlay, PromptOverlay } from './overlays/basic'
import { CommandLineOverlay, HelpOverlay, LockOverlay, MenuOverlay, PaletteOverlay, type PaletteItem } from './overlays/center'
import { chooseFolder } from './overlays/files-helpers'
import { OutputScreen } from './screens/output'
import { SignInScreen } from './screens/signin'
import type { AppStore, CommandsLike } from './store'
import type { MenuEntry } from './types'
import { errorText, shellSplit } from './util/text'
const NOUNS: Record<string, string[]> = {
table: ['list', 'describe', 'query', 'count', 'get', 'create', 'update', 'delete', 'export'],
process: ['list', 'describe', 'run'],
widget: ['list', 'get'],
report: ['run'],
prefs: ['list', 'get', 'set', 'action'],
job: ['list', 'get', 'cancel', 'retry'],
config: ['show', 'profile'],
cache: ['show', 'refresh', 'clear'],
api: [],
apps: [], whoami: [], search: [], 'help-search': [],
}
const TUI_COMMANDS = ['quit', 'home', 'reset', 'back', 'help', 'menu', 'goto', 'view', 'theme', 'ascii', 'linear', 'shortcuts', 'lock', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'profile', 'server', 'signout', 'connection', 'open', 'record', 'app']
/** Global keys for the help overlay. */
export const GLOBAL_KEYS: Array<[string, string]> = [
['F1 ?', 'help (this)'], ['F2 / .', 'go to anything'], ['F3 Enter', 'view / open'], ['F4 e', 'edit'], ['F5 r', 'refresh'],
['F6 f', 'filter (grid)'], ['F7 n', 'new record'], ['F8 d', 'delete (asks)'], ['F9', 'menu: every command'], ['F10', 'quit, reset or cancel'],
['Esc', 'back, cancel'], ['Tab', 'Apps tree / content'], ['↑↓ PgUp PgDn', 'move, page'], ['Ctrl+F Ctrl+B', 'page (always works)'], ['Space S+Space', 'page lists'], ['Home End', 'first, last'], ['← →', 'columns, tree'],
['s', 'sort by column'], ['Space (grid)', 'select row'], ['b', 'run a process on selection'], ['x', 'export'], ['g', 'go to by key'], ['v', 'view settings'],
['] [', 'next / previous record'], ['Ctrl+S', 'save a form'], [':', 'command line'], ['Ctrl+K', 'palette'], ['Ctrl+C', 'quit now'],
]
export class Commands implements CommandsLike {
constructor(private store: AppStore) {}
// ---- overlays ----
help(): void {
const s = this.store
const screen = s.screen
const topic = screen?.helpTopic?.()
const topicHelp: string[] = []
if (topic?.kind === 'table') {
const meta = s.client && (s.client as unknown as { tableCache?: Map<string, unknown> })
void meta
}
const actions = screen?.menu?.() ?? []
const keys = GLOBAL_KEYS.filter(([k]) => s.singleKeys || !/^[a-z?/.\]]/.test(k.split(/\s+/).pop() ?? ''))
const overlay = new HelpOverlay(s, keys, actions, topicHelp, async (q) => {
const data = await s.client!.data<{ results?: Array<{ kind: string; label?: string; name: string; table?: string; key?: string }> }>('searchHelp', { query: { q, limit: '30' } })
return (data.results ?? []).map((r) => ({
label: r.label ?? r.name,
detail: `${r.kind}${r.table ? ` · ${r.table}` : ''}`,
run: r.kind === 'table' ? () => openTable(s, r.name) : r.table ? () => openTable(s, r.table!) : undefined,
}))
})
s.open(overlay)
if (topic?.kind === 'table' && s.client) {
s.client.tableMeta(topic.name).then((m) => {
for (const h of m.helpContents ?? []) if (h.contentAsText) topicHelp.push(h.contentAsText)
for (const f of Object.values(m.fields)) for (const h of f.helpContents ?? []) if (h.contentAsText) topicHelp.push(`${f.label}: ${h.contentAsText}`)
if (!topicHelp.length) topicHelp.push(`${m.label}: no help content on this server. Fields: ${Object.values(m.fields).map((f) => f.label).join(', ')}.`)
s.bump()
}).catch(() => undefined)
}
}
palette(): void {
const s = this.store
const meta = s.meta
const items: PaletteItem[] = []
const walk = (nodes: AppTreeNode[], path: string[]) => {
for (const n of nodes) {
const context = path.join(' › ')
if (n.type === 'TABLE') items.push({ label: n.label, kind: 'table', context, run: () => openTable(s, n.name) })
else if (n.type === 'PROCESS') items.push({ label: n.label, kind: 'process', context, run: () => openProcess(s, n.name) })
else if (n.type === 'APP') items.push({ label: n.label, kind: 'app', context, run: () => openApp(s, n.name) })
else if (n.type === 'REPORT') items.push({ label: n.label, kind: 'report', context, run: () => this.commandLine(`report run ${n.name} --format csv`) })
if (n.children) walk(n.children, [...path, n.label])
}
}
walk((meta?.appTree ?? []) as AppTreeNode[], [])
const listed = new Set(items.map((i) => `${i.kind}:${i.label}`))
for (const t of Object.values(meta?.tables ?? {})) if (!t.isHidden && !listed.has(`table:${t.label}`)) items.push({ label: t.label, kind: 'table', context: t.name, run: () => openTable(s, t.name) })
try {
for (const r of s.client ? readRecents(s.client.server) : []) {
if (r.kind === 'record' && r.table) items.push({ label: r.label, kind: 'recent', context: meta?.tables?.[r.table]?.label ?? r.table, run: () => openRecord(s, r.table!, r.key) })
}
} catch {
// no recents
}
for (const e of this.entries()) if (e.run && !e.disabled && e.section !== 'Context' && !e.label.startsWith('Recent:') && !/^(Table|Process|App): /.test(e.label)) items.push({ label: e.label, kind: 'command', context: e.command ? `:${e.command}` : e.section, run: e.run })
const features = s.version?.features ?? []
const quick = s.extensions.has('qbit-quick-search') || s.extensions.has('quick-search')
const search = quick || features.includes('recordSearch') ? async (text: string): Promise<PaletteItem[]> => {
const data = await s.client!.data<{ results?: Array<{ tableName?: string; table?: string; primaryKey?: unknown; recordLabel?: string; label?: string }>; provider?: { status?: string; message?: string | null } }>('searchRecords', { json: { query: text, searchTerm: text, limit: 20 } })
if (data.provider?.status === 'NONE') return [{ label: 'No record search on this server (search provider: none)', kind: 'note', context: 'navigation only', run: () => undefined }]
return (data.results ?? []).map((r) => {
const table = r.tableName ?? r.table ?? ''
return { label: r.recordLabel ?? r.label ?? String(r.primaryKey), kind: 'record', context: meta?.tables?.[table]?.label ?? table, run: () => openRecord(s, table, String(r.primaryKey ?? '')) }
})
} : undefined
s.open(new PaletteOverlay(s, items, search, quick ? 'quick-search extension' : search ? 'POST /search' : ''))
}
menu(): void {
this.store.open(new MenuOverlay(this.store, this.entries()))
}
commandLine(initial?: string): void {
const s = this.store
s.open(new CommandLineOverlay(s, s.history, (line, cursor) => this.completeLine(line, cursor), (line) => void this.run(line), initial ?? this.lastFailed ?? ''))
this.lastFailed = undefined
}
lastFailed?: string
viewSettings(): void {
const o = this.store.screen?.viewSettings?.()
if (o) this.store.open(o)
else this.store.flash('this screen has no view settings')
}
lock(): void {
this.store.lock = new LockOverlay(this.store)
this.store.bump()
}
// ---- completion for the : line ----
completeLine(line: string, cursor: number): { from: number; candidates: string[] } {
const before = line.slice(0, cursor)
const words = shellSplit(before)
const endsSpace = /\s$/.test(before) || before === ''
const current = endsSpace ? '' : words.pop() ?? ''
const from = cursor - current.length
const pos = words.length
const meta = this.store.meta
const pick = (list: string[]) => ({ from, candidates: [...new Set(list)].filter((c) => c.toLowerCase().startsWith(current.toLowerCase())).sort() })
if (pos === 0) return pick([...Object.keys(NOUNS), ...TUI_COMMANDS])
const noun = words[0]!
if (pos === 1 && NOUNS[noun]?.length) return pick(NOUNS[noun]!)
if (noun === 'theme' && pos === 1) return pick(Object.keys(THEMES))
if ((noun === 'table' && pos === 2) || (noun === 'open' && pos === 1) || (noun === 'record' && pos === 1)) return pick(Object.keys(meta?.tables ?? {}))
if (noun === 'process' && pos === 2) return pick(Object.keys(meta?.processes ?? {}))
if (noun === 'widget' && pos === 2) return pick(Object.keys(meta?.widgets ?? {}))
if (noun === 'app' && pos === 1) return pick(Object.keys(meta?.apps ?? {}))
if (noun === 'export' && pos === 1) return pick(['csv', 'xlsx', 'json', 'tsv'])
if (noun === 'profile' && pos === 1) return pick(Object.keys(readConfig().profiles))
if (noun === 'table' && words[1] === 'query') return pick(['-w', '-g', '--or', '-o', '--limit', '--fields', '--print-filter', '--count'])
return { from, candidates: [] }
}
// ---- the : dispatcher ----
async run(input: string): Promise<void> {
const s = this.store
const line = input.trim().replace(/^:/, '').replace(/^qqq\s+/, '')
const argv = shellSplit(line)
const [cmd, ...rest] = argv
if (!cmd) return
const fail = (text: string) => {
this.lastFailed = line
s.flash(text, 'error')
}
try {
switch (cmd) {
case 'quit': case 'q': case 'exit': return s.requestQuit()
case 'home': case 'reset': return s.reset()
case 'back': return s.back()
case 'help': return this.help()
case 'menu': return this.menu()
case 'goto': case 'palette': return this.palette()
case 'view': return this.viewSettings()
case 'lock': return this.lock()
case 'refresh': return s.screen?.refresh?.()
case 'theme': {
const name = rest[0]
if (!name || !THEMES[name]) return fail(`themes: ${Object.keys(THEMES).join(', ')}`)
s.setTheme(name)
writeTheme(name)
return s.flash(`theme ${name} (saved)`)
}
case 'ascii': s.setAscii(rest[0] !== 'off'); return s.flash(`ASCII glyphs ${s.settings.ascii ? 'on' : 'off'}`)
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 'idle': {
const m = Number(rest[0])
if (!Number.isFinite(m)) return fail('idle <minutes>: 0 turns the screen saver timeout off')
writeIdleMinutes(m)
return s.flash(m ? `screen saver after ${m} idle minutes` : 'idle screen saver off')
}
case 'pin': return rest[0] === 'remove' ? this.removePin() : this.setPin()
case 'filter': {
const screen = s.screen as { kind?: string; setFilter?: (f: QueryFilter) => void; filterDialog?: () => void; meta?: { fields: Record<string, unknown> } } | undefined
if (screen?.kind !== 'table') return fail(':filter works on a table grid')
if (!rest.length) return screen.filterDialog?.()
if (rest[0] === 'clear') return screen.setFilter?.({ criteria: [], booleanOperator: 'AND' })
return screen.setFilter?.(buildFilter({ group: [rest.join(' ')], knownFields: Object.keys(screen.meta?.fields ?? {}) }))
}
case 'export': {
const screen = s.screen as { kind?: string; exportAs?: (f: string) => void } | undefined
if (screen?.kind !== 'table') return fail(':export works on a table grid')
return screen.exportAs?.(rest[0] ?? 'csv')
}
case 'jobs': return this.jobs()
case 'doctor': case 'check': return this.doctor()
case 'profile': return rest[0] ? this.switchProfile(rest[0]) : this.chooseProfile()
case 'server': return rest[0] ? this.switchServer(rest[0]) : fail('server <url>')
case 'signout': return this.signOut()
case 'connection': return this.connection()
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 'record': return rest[0] && rest[1] ? openRecord(s, rest[0], rest[1]) : fail('record <table> <primaryKey>')
case 'table':
if (this.tableCommand(rest)) return
break
case 'process':
if (rest[0] === 'run' && rest[1] && !rest.includes('--help')) {
const opts = parseOptions(rest.slice(2))
const values = Object.fromEntries((opts.v ?? []).map((p) => [p.split('=')[0]!, p.slice(p.indexOf('=') + 1)]))
return openProcess(s, rest[1], { recordIds: opts.r?.[0]?.split(',').map((x) => x.trim()), values })
}
break
case 'login': case 'logout': case 'ui':
return fail(`${cmd} is not available inside the TUI; use the menu (Connection) instead`)
default:
break
}
await this.runCli(line, argv)
} catch (e) {
fail(errorText(e))
}
}
/** `table query/get/create/update/delete` open screens; returns false for the rest (run by the CLI). */
tableCommand(rest: string[]): boolean {
const s = this.store
const [verb, table, ...args] = rest
if (!verb || !table || args.includes('--help') || args.includes('-h')) return false
const opts = parseOptions(args)
switch (verb) {
case 'query': {
if (opts['print-filter'] || opts.count || opts.output || opts.O || opts.json) return false
const fields = Object.keys((s.meta?.tables?.[table] ? {} : {}))
void fields
const filter = buildFilter({ where: opts.w ?? opts.where, group: opts.g ?? opts.group, or: Boolean(opts.or), order: opts.o ?? opts.order })
openTable(s, table, filter)
return true
}
case 'get':
if (!args[0]) return false
openRecord(s, table, args[0])
return true
case 'create': {
const initial = Object.fromEntries((opts.s ?? opts.set ?? []).map((p) => [p.split('=')[0]!, p.slice(p.indexOf('=') + 1)]))
openForm(s, table, { mode: 'create', initial })
return true
}
case 'update':
if (!args[0]) return false
openForm(s, table, { mode: 'edit', primaryKey: args[0] })
return true
case 'delete':
if (!args[0]) return false
s.confirm(`Delete ${s.meta?.tables?.[table]?.label ?? table} ${args[0]}?`, () => s.run(s.client!.remove(table, args[0]), () => s.flash(`deleted ${table} ${args[0]}`)))
return true
default:
return false
}
}
/** Runs a CLI command in-process and shows its output in a pane. */
async runCli(line: string, argv: string[]): Promise<void> {
const s = this.store
const screen = new OutputScreen(s, line)
s.push(screen)
const globals = s.target ? ['--profile', s.target.name] : []
const code = await runCli([...globals, ...argv], { stdout: (t) => screen.append(t), stderr: (t) => screen.append(t, true), isTTY: true })
screen.done(code)
if (code !== 0) this.lastFailed = line
}
// ---- settings and connection ----
setShortcuts(on: boolean): void {
const s = this.store
s.settings.singleKeys = on
s.bump()
s.flash(`single-key shortcuts ${on ? 'on' : 'off'}`)
if ((s.version?.features ?? []).includes('preferences')) {
s.client?.call('updatePreferenceSection', { path: { sectionName: 'keyboard' }, json: { values: { singleKeyShortcuts: on } } }).catch(() => undefined)
}
}
setPin(): void {
const s = this.store
const ask = () => s.open(new PromptOverlay(s, 'Set a screen-lock PIN', 'At least 4 characters. Only a salted hash is kept in ~/.config/qqq/config.json.', (pin) => {
s.open(new PromptOverlay(s, 'Repeat the PIN', '', (again) => {
if (pin !== again) return s.flash('the PINs differ; not set', 'error')
try {
setPin(pin)
s.flash('PIN set; the screen lock asks for it')
} catch (e) {
s.flash(errorText(e), 'error')
}
}, '', { masked: true }))
}, '', { masked: true }))
if (hasPin()) s.confirm('A PIN is set. Replace it?', () => s.open(new PromptOverlay(s, 'Current PIN', '', (cur) => (removePin(cur) ? ask() : s.flash('wrong PIN', 'error')), '', { masked: true })))
else ask()
}
removePin(): void {
const s = this.store
if (!hasPin()) return s.flash('no PIN is set')
s.open(new PromptOverlay(s, 'Remove the PIN: current PIN', '', (cur) => s.flash(removePin(cur) ? 'PIN removed' : 'wrong PIN', removePin.length ? 'info' : 'info'), '', { masked: true }))
}
jobs(): void {
const s = this.store
if (!(s.version?.features ?? []).includes('durableJobs')) return s.message('This server lists no jobs operations (no durableJobs feature).', 'info', 'Jobs')
s.run(s.client!.data<{ jobs?: Array<{ jobId?: string; id?: string; label?: string; state?: string; processName?: string }> }>('listJobs', { query: { scope: 'mine' } }), (data) => {
const jobs = data.jobs ?? []
if (!jobs.length) return s.message('No jobs.', 'info', 'Jobs')
s.open(new ChoiceOverlay(s, 'Jobs', jobs.map((j) => ({ label: `${j.label ?? j.processName ?? j.jobId ?? j.id}`, value: String(j.jobId ?? j.id), detail: j.state })), (id) => this.run(`job get ${id}`)))
})
}
chooseProfile(): void {
const s = this.store
const config = readConfig()
s.open(new ChoiceOverlay(s, 'Switch profile', Object.entries(config.profiles).map(([n, p]) => ({ label: n, value: n, detail: p.server + (n === s.target?.name ? ' (current)' : '') })), (n) => this.switchProfile(n)))
}
switchProfile(name: string): void {
const s = this.store
const p = readConfig().profiles[name]
if (!p) return s.flash(`no profile ${name}`, 'error')
this.reconnect({ name, server: p.server })
}
switchServer(url: string): void {
try {
const origin = new URL(url).origin
const existing = Object.entries(readConfig().profiles).find(([, p]) => p.server === origin)
this.reconnect({ name: existing?.[0] ?? new URL(origin).host.replace(/[^A-Za-z0-9.-]+/g, '-'), server: origin })
} catch {
this.store.flash(`not a URL: ${url}`, 'error')
}
}
reconnect(target: { name: string; server: string }): void {
const s = this.store
const deps = s.signInDeps
if (!deps) return s.flash('cannot switch here', 'error')
s.dispose()
s.client = undefined
s.meta = undefined
s.sidebar = undefined
while (s.routes.length) s.routes.pop()
const screen = new SignInScreen(s, deps, target)
s.push(screen)
screen.choose(target)
}
signOut(): void {
const s = this.store
s.confirm(`Sign out of ${s.target?.server}?`, () => {
const deps = s.signInDeps
const target = s.target
if (!deps || !target) return
s.client?.call('logout').catch(() => undefined)
import('@qrun-io/qqq-core').then(({ deleteLogin }) => deleteLogin(deps.secrets(), target)).finally(() => {
s.dispose()
s.client = undefined
s.meta = undefined
s.sidebar = undefined
while (s.routes.length) s.routes.pop()
s.push(new SignInScreen(s, deps, target, 'signed out'))
})
})
}
connectionText(): string {
const s = this.store
const v = s.version
const age = s.client?.metaDataFetchedAt ? `${Math.round((Date.now() - s.client.metaDataFetchedAt) / 1000)} s ago` : 'unknown'
return [
`server ${s.status.server}`,
`profile ${s.status.profile}`,
`user ${s.status.user || '(unknown)'} (${s.status.auth})`,
`UI API ${v?.apiVersion ?? '?'}`,
`features ${(v?.features ?? []).join(', ')}`,
`extensions ${s.extensions.all().map((e) => `${e.name} ${e.version}`).join(', ') || 'none'}`,
`ping ${s.status.pingMs === null ? '--' : `${s.status.pingMs.toFixed(0)} ms`}`,
`stream ${s.status.stream} · last event: ${s.status.lastEvent}`,
`metadata generation ${String(s.meta?.metaDataGeneration ?? '?')} · fetched ${age}`,
`client qqq ui ${s.status.build}`,
].join('\n')
}
connection(): void {
this.store.message(this.connectionText(), 'info', 'Connection')
}
/** Every command-center entry for the current context. */
entries(): MenuEntry[] {
const s = this.store
const meta = s.meta
const out: MenuEntry[] = [...(s.screen?.menu?.() ?? [])]
out.push({ section: 'Context', label: 'Back', key: 'Esc', command: 'back', run: () => s.back() })
// Go to
out.push({ section: 'Go to', label: 'Reset: go to the home screen', key: 'F10', command: 'reset', detail: 'refreshes metadata, keeps the session', run: () => s.reset() })
out.push({ section: 'Go to', label: 'Go to anything…', key: 'F2 / .', command: 'goto', run: () => this.palette() })
for (const n of (meta?.appTree ?? []) as AppTreeNode[]) if (n.type === 'APP') out.push({ section: 'Go to', label: `App: ${n.label}`, command: `app ${n.name}`, run: () => openApp(s, n.name) })
for (const t of Object.values(meta?.tables ?? {}).filter((x) => !x.isHidden).sort((a, b) => a.label.localeCompare(b.label))) out.push({ section: 'Go to', label: `Table: ${t.label}`, command: `table query ${t.name}`, run: () => openTable(s, t.name) })
for (const p of Object.values(meta?.processes ?? {}).filter((x) => !x.isHidden).sort((a, b) => a.label.localeCompare(b.label))) out.push({ section: 'Go to', label: `Process: ${p.label}`, command: `process run ${p.name}`, run: () => openProcess(s, p.name) })
for (const r of Object.values((meta?.reports ?? {}) as Record<string, { name: string; label: string }>)) out.push({ section: 'Go to', label: `Report: ${r.label}`, command: `report run ${r.name}`, run: () => this.commandLine(`report run ${r.name} --format csv`) })
for (const a of Object.values((meta?.apps ?? {}) as Record<string, { name: string; label: string; widgets?: string[] }>)) if (a.widgets?.length) out.push({ section: 'Go to', label: `Dashboard: ${a.label}`, detail: `${a.widgets.length} widgets`, run: () => openApp(s, a.name) })
try {
for (const r of s.client ? readRecents(s.client.server).slice(0, 10) : []) if (r.kind === 'record' && r.table) out.push({ section: 'Go to', label: `Recent: ${meta?.tables?.[r.table]?.label ?? r.table} › ${r.label}`, run: () => openRecord(s, r.table!, r.key) })
} catch {
// none
}
out.push({ section: 'Go to', label: 'Favorites', disabled: s.extensions.has('collaboration') ? undefined : 'needs the collaboration extension' })
// Work
out.push({ section: 'Work', label: 'Jobs', detail: `active ${s.status.jobs}`, command: 'jobs', disabled: (s.version?.features ?? []).includes('durableJobs') ? undefined : 'n/a: the server lists no jobs', run: () => this.jobs() })
out.push({ section: 'Work', label: 'Notifications', disabled: s.extensions.has('alerts') ? undefined : 'needs the alerts extension' })
out.push({ section: 'Work', label: 'My work', disabled: s.extensions.has('collaboration') ? undefined : 'needs the collaboration extension' })
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() })
out.push({ section: 'Connection', label: `Profile ${s.status.profile}`, detail: s.status.user, run: () => this.connection() })
out.push({ section: 'Connection', label: `Stream ${s.status.stream}`, detail: `ping ${s.status.pingMs === null ? '--' : s.status.pingMs.toFixed(0) + ' ms'}`, run: () => this.connection() })
out.push({ section: 'Connection', label: 'Switch profile…', command: 'profile', run: () => this.chooseProfile() })
out.push({ section: 'Connection', label: 'Connect to another server…', command: 'server <url>', run: () => s.open(new PromptOverlay(s, 'Connect to a server', 'The server URL.', (u) => this.switchServer(u.trim()), 'http://localhost:8077')) })
out.push({ section: 'Connection', label: 'Refresh metadata', run: () => s.run(s.client!.metaData(true), (m) => { s.meta = m; s.client!.clearCaches(); s.flash('metadata refreshed') }) })
out.push({ section: 'Connection', label: 'Copy connection details', run: () => s.message(this.connectionText(), 'info', 'Connection (select with the mouse to copy)') })
out.push({ section: 'Connection', label: 'Sign out', command: 'signout', run: () => this.signOut() })
// Settings
for (const t of Object.values(THEMES)) out.push({ section: 'Settings', label: `Theme: ${t.label}`, detail: t.name === s.settings.theme ? 'current' : undefined, command: `theme ${t.name}`, run: () => { s.setTheme(t.name); writeTheme(t.name) } })
out.push({ section: 'Settings', label: 'View settings for this screen', key: 'v', command: 'view', run: () => this.viewSettings() })
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({ 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
try { idle = readIdleMinutes() } catch { idle = 0 }
out.push({ section: 'Settings', label: `Idle timeout: ${idle ? `${idle} min` : 'off'}`, command: 'idle <minutes>', run: () => s.open(new PromptOverlay(s, 'Screen saver after idle minutes', '0 turns it off.', (v) => void this.run(`idle ${v}`), String(idle))) })
out.push({ section: 'Settings', label: hasPin() ? 'Change PIN' : 'Set PIN', command: 'pin', run: () => this.setPin() })
if (hasPin()) out.push({ section: 'Settings', label: 'Remove PIN', command: 'pin remove', run: () => this.removePin() })
const fp = readFilePreferences()
out.push({ section: 'Settings', label: `Download folder: ${fp.downloadDir ?? '(last used, else ~/Downloads)'}`, run: () => chooseFolder(s, 'Default download folder', startDirForSave(fp), (dir) => { writeFilePreferences({ downloadDir: dir }); s.flash(`downloads go to ${dir}`) }) })
out.push({ section: 'Settings', label: `Downloads: ${fp.downloadMode === 'silent' ? 'save silently to the download folder' : 'ask every time'}`, run: () => { writeFilePreferences({ downloadMode: fp.downloadMode === 'silent' ? 'ask' : 'silent' }); s.flash('download mode changed') } })
out.push({ section: 'Settings', label: `Upload folder: ${fp.uploadDir ?? '(last used, else the current folder)'}`, run: () => chooseFolder(s, 'Default upload folder', startDirForUpload(fp), (dir) => { writeFilePreferences({ uploadDir: dir }); s.flash(`uploads start in ${dir}`) }) })
out.push({ section: 'Settings', label: 'Server preferences…', disabled: (s.version?.features ?? []).includes('preferences') ? undefined : 'the server has no preferences', run: () => this.prefs() })
// Help
out.push({ section: 'Help', label: 'Keyboard reference and help', key: 'F1 ?', command: 'help', run: () => this.help() })
out.push({ section: 'Help', label: 'Search help…', run: () => this.help() })
out.push({ section: 'Help', label: 'Check my terminal', command: 'doctor', run: () => this.doctor() })
out.push({ section: 'Help', label: 'About', run: () => s.message(`qqq ui ${s.status.build}\nOpenTUI 0.8.1 (React) on Bun ${typeof Bun !== 'undefined' ? Bun.version : '?'}\nUI API ${s.status.apiVersion} at ${s.status.server}`, 'info', 'About') })
out.push({ section: 'Help', label: 'Quit', key: 'F10', command: 'quit', run: () => s.requestQuit() })
return out
}
/** The terminal check in a dialog; live tmux fixes asked one by one. */
doctor(): void {
const s = this.store
const report = checkEnvironment({ columns: s.width, rows: s.height })
s.message(formatReport(report), 'info', 'Terminal check')
const fixes = report.findings.filter((f) => f.live)
const next = (i: number) => {
const f = fixes[i]
if (!f) return
s.confirm(`${f.message} Fix for this tmux server now?`, () => { applyLiveFix(f); s.flash('fixed for this tmux server'); next(i + 1) }, () => next(i + 1))
}
next(0)
}
prefs(): void {
const s = this.store
s.run(s.client!.data<{ sections?: Array<{ name: string; label: string; description?: string }> }>('listPreferenceSections'), (data) => {
s.open(new ChoiceOverlay(s, 'Preferences', (data.sections ?? []).map((x) => ({ label: x.label, value: x.name, detail: x.description })), (name) => void this.run(`prefs get ${name}`)))
})
}
}
/** Parses `-w a -w b --or -o x` style options into lists (flags become ['true']). */
export function parseOptions(args: string[]): Record<string, string[] | undefined> {
const out: Record<string, string[]> = {}
const flags = new Set(['or', 'print-filter', 'count', 'all', 'json', 'yes', 'y'])
const takesValue = new Set(['o', 'order', 'l', 'limit', 'skip', 'fields', 'f', 'r', 'records', 'output', 'O'])
for (let i = 0; i < args.length; i++) {
const a = args[i]!
const m = /^--?([A-Za-z][\w-]*)(?:=(.*))?$/.exec(a)
if (!m) continue
const name = m[1]!
if (m[2] !== undefined) (out[name] ??= []).push(m[2])
else if (takesValue.has(name) && i + 1 < args.length) {
// -o -id: a sort value may itself start with a dash; repeated values follow while they are not options
;(out[name] ??= []).push(args[++i]!)
while (name.startsWith('o') && i + 1 < args.length && /^-?[A-Za-z_][\w.]*$/.test(args[i + 1]!) && !/^-[a-zA-Z]$/.test(args[i + 1]!) && !/^--/.test(args[i + 1]!)) out[name]!.push(args[++i]!)
} else if (flags.has(name) || i + 1 >= args.length || /^-/.test(args[i + 1]!)) (out[name] ??= []).push('true')
else {
// multi-value options (-w a b) take every following non-option argument
const values: string[] = []
while (i + 1 < args.length && !/^-[A-Za-z-]/.test(args[i + 1]!)) values.push(args[++i]!)
;(out[name] ??= []).push(...values)
}
}
return out
}
export function installCommands(store: AppStore): void {
const commands = new Commands(store)
store.commandsImpl = () => commands
}
+64
View File
@@ -0,0 +1,64 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The terminal check before the TUI starts (plain prompts on the terminal, nothing changes without
* a y) and `qqq ui --check`. Detection and fixes are the kernel's (env/doctor).
*/
import { createInterface } from 'node:readline/promises'
import { appendTmuxConf, applyLiveFix, checkEnvironment, formatReport, readDoctorState, startupFindings, writeDoctorState, type Report } from '@qrun-io/qqq-core'
export async function runDoctor(options: { full: boolean }): Promise<{ report: Report }> {
const started = Date.now()
const report = checkEnvironment({ columns: process.stdout.columns, rows: process.stdout.rows })
const state = readDoctorState()
const ask = options.full ? report.findings.filter((f) => f.live || f.permanent) : startupFindings(report, state)
if (options.full) process.stdout.write(formatReport(report) + '\n\n')
if (!ask.length || !process.stdin.isTTY) {
try {
writeDoctorState({ lastCheck: new Date().toISOString() })
} catch {
// read-only config
}
return { report }
}
const rl = createInterface({ input: process.stdin, output: process.stdout })
const applied = new Set(state.applied ?? [])
const dontAsk = new Set(state.dontAsk ?? [])
const permanent = []
try {
if (!options.full) process.stdout.write(`qqq ui terminal check (${Date.now() - started} ms; qqq ui --no-check skips it):\n`)
for (const f of ask) {
process.stdout.write(`\n! ${f.message}\n`)
if (f.live) {
const a = (await rl.question(' Fix for this tmux server now? [y/N/never] ')).trim().toLowerCase()
if (a === 'never') {
dontAsk.add(f.id)
continue
}
if (a === 'y' || a === 'yes') {
applyLiveFix(f)
applied.add(f.id)
process.stdout.write(' fixed for this tmux server.\n')
}
}
if (f.permanent) permanent.push(f)
}
if (permanent.length) {
const a = (await rl.question(`\nMake ${permanent.length === 1 ? 'it' : 'them'} permanent in ~/.tmux.conf? [y/N] `)).trim().toLowerCase()
if (a === 'y' || a === 'yes') process.stdout.write(` appended to ${appendTmuxConf(permanent)}\n`)
else process.stdout.write(` To keep them, add to ~/.tmux.conf:\n${permanent.map((f) => ` ${f.permanent}`).join('\n')}\n`)
}
} finally {
rl.close()
try {
writeDoctorState({ lastCheck: new Date().toISOString(), applied: [...applied], dontAsk: [...dontAsk] })
} catch {
// read-only config
}
}
return { report }
}
+42
View File
@@ -0,0 +1,42 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The installed server extensions, through the kernel's ExtensionRegistry (from GET /version).
* Panels and `:` commands of an extension appear only when it is installed; otherwise the
* command says it is not.
*/
import { ExtensionRegistry, type InstalledExtension } from '@qrun-io/qqq-core'
import type { VersionInfo } from './client'
export class Extensions {
registry = new ExtensionRegistry([])
load(version: VersionInfo | undefined): void {
this.registry = ExtensionRegistry.fromVersion((version ?? { apiVersion: '' }) as never)
}
has(name: string): boolean {
return this.registry.has(name)
}
get(name: string): InstalledExtension | undefined {
return this.registry.installed.find((e) => e.name === name)
}
all(): InstalledExtension[] {
return [...this.registry.installed]
}
describe(): string {
return this.registry.describe()
}
/** The message for a command of an extension that is not installed. */
missing(name: string): string {
return `The ${name} extension is not installed on this server (installed: ${this.registry.describe()}).`
}
}
+29
View File
@@ -0,0 +1,29 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/** The parts of an OpenTUI KeyEvent the TUI reads (tests build these directly). */
export interface Key {
name: string
sequence: string
ctrl?: boolean
shift?: boolean
meta?: boolean
}
/** A key that types a character. */
export function isPrintable(key: Key): boolean {
if (key.ctrl || key.meta) return false
const s = key.sequence ?? ''
return s.length >= 1 && !/[\x00-\x1f\x7f]/.test(s) && !s.startsWith('\x1b')
}
/** The character a key types ('' for none). */
export function charOf(key: Key): string {
return isPrintable(key) ? key.sequence : ''
}
export function isFKey(key: Key, n: number): boolean {
return key.name === `f${n}`
}
+100
View File
@@ -0,0 +1,100 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { Key } from './keys'
import { isPrintable } from './keys'
/**
* A one-line text editor driven by key events, not by OpenTUI's focused input: the value and the
* cursor live in this object, so keys that arrive in one burst (paste, automation, a fast typist)
* all land in the field that had focus when they were read, never in a stale one.
*/
export class LineEditor {
value: string
cursor: number
masked: boolean
constructor(value = '', options: { masked?: boolean } = {}) {
this.value = value
this.cursor = value.length
this.masked = Boolean(options.masked)
}
set(value: string): void {
this.value = value
this.cursor = value.length
}
insert(text: string): void {
const clean = text.replace(/[\r\n]+/g, ' ')
this.value = this.value.slice(0, this.cursor) + clean + this.value.slice(this.cursor)
this.cursor += clean.length
}
/** Handles an editing key; returns whether it was one. */
handle(key: Key): boolean {
if (key.ctrl && key.name === 'u') {
this.value = this.value.slice(this.cursor)
this.cursor = 0
return true
}
if (key.ctrl && key.name === 'k') {
this.value = this.value.slice(0, this.cursor)
return true
}
if (key.ctrl && key.name === 'a') {
this.cursor = 0
return true
}
if (key.ctrl && key.name === 'e') {
this.cursor = this.value.length
return true
}
if (key.ctrl && key.name === 'w') {
const before = this.value.slice(0, this.cursor).replace(/\S+\s*$/, '')
this.value = before + this.value.slice(this.cursor)
this.cursor = before.length
return true
}
switch (key.name) {
case 'backspace':
if (this.cursor > 0) {
this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor)
this.cursor--
}
return true
case 'delete':
this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1)
return true
case 'left':
this.cursor = Math.max(0, this.cursor - 1)
return true
case 'right':
this.cursor = Math.min(this.value.length, this.cursor + 1)
return true
case 'home':
this.cursor = 0
return true
case 'end':
this.cursor = this.value.length
return true
default:
if (isPrintable(key)) {
this.insert(key.sequence)
return true
}
return false
}
}
/** The visible window of the text for a width, with the cursor position inside it. */
view(width: number): { before: string; at: string; after: string } {
const shown = this.masked ? '•'.repeat(this.value.length) : this.value
const start = Math.max(0, this.cursor - width + 1)
const visible = shown.slice(start, start + width)
const c = this.cursor - start
return { before: visible.slice(0, c), at: visible[c] ?? ' ', after: visible.slice(c + 1) }
}
}
+83
View File
@@ -0,0 +1,83 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* qqq ui: the terminal UI. Runs in the alternate screen with the mouse off; any crash restores the
* terminal (renderer.destroy) before the stack trace is printed.
*/
import { spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createCliRenderer } from '@opentui/core'
import { createRoot } from '@opentui/react'
import { App } from './app'
import { boot, parseArgs } from './boot'
import { runDoctor } from './doctor'
const args = parseArgs(process.argv.slice(2))
if (args.check === 'full') {
await runDoctor({ full: true })
process.exit(0)
}
if (!process.stdout.isTTY || process.env.TERM === 'dumb') {
process.stderr.write('qqq ui needs a terminal with an alternate screen (TERM is dumb or stdout is not a terminal); use the qqq CLI instead.\n')
process.exit(2)
}
if (args.check === 'startup') {
const { report } = await runDoctor({ full: false })
// no UTF-8 locale: plain glyphs rather than boxes of question marks
if (report.findings.some((f) => f.id === 'unicode') && !args.ascii) args.ascii = true
}
const renderer = await createCliRenderer({
exitOnCtrlC: true,
useMouse: args.mouse,
screenMode: 'alternate-screen',
openConsoleOnError: false,
targetFps: 30,
})
let destroyed = false
function restore(): void {
if (destroyed) return
destroyed = true
try {
renderer.destroy()
} catch {
// already gone
}
}
process.on('uncaughtException', (error) => {
restore()
process.stderr.write(`qqq ui crashed: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`)
process.exit(1)
})
renderer.on('destroy', () => {
destroyed = true
setTimeout(() => process.exit(0), 10)
})
const store = boot({ args, quit: restore, build: process.env.QQQ_UI_BUILD ?? 'dev', timers: true })
process.on('unhandledRejection', (reason) => store.error(reason))
store.editExternal = async (text: string) => {
const dir = mkdtempSync(join(tmpdir(), 'qqq-edit-'))
const file = join(dir, 'value.txt')
writeFileSync(file, text)
await renderer.suspend()
try {
const editor = process.env.VISUAL ?? process.env.EDITOR ?? (process.platform === 'win32' ? 'notepad' : 'vi')
spawnSync(editor, [file], { stdio: 'inherit', shell: process.platform === 'win32' })
return readFileSync(file, 'utf8')
} finally {
await renderer.resume()
rmSync(dir, { recursive: true, force: true })
}
}
createRoot(renderer).render(<App store={store} />)
+63
View File
@@ -0,0 +1,63 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/** Opening screens: the one place that knows which screen shows what. */
import type { FieldValues, QueryFilter, ResultCursor } from '@qrun-io/qqq-core'
import type { AppTreeNode } from './client'
import { AppHomeScreen } from './screens/app-home'
import { FormScreen, type FormOptions } from './screens/form'
import { ProcessScreen, type ProcessOptions } from './screens/process'
import { RecordScreen } from './screens/record'
import { TableScreen } from './screens/table'
import type { AppStore } from './store'
function known(store: AppStore, kind: 'tables' | 'processes' | 'apps', name: string): boolean {
const map = store.meta?.[kind] as Record<string, unknown> | undefined
if (map?.[name]) return true
store.flash(`no ${kind === 'tables' ? 'table' : kind === 'processes' ? 'process' : 'app'} named "${name}"`, 'error')
return false
}
export function openTable(store: AppStore, table: string, filter?: QueryFilter): void {
if (!known(store, 'tables', table)) return
store.push(new TableScreen(store, table, filter))
}
export function openRecord(store: AppStore, table: string, primaryKey: string | undefined, options: { cursor?: ResultCursor; label?: string } = {}): void {
if (!known(store, 'tables', table)) return
store.push(new RecordScreen(store, table, primaryKey, options.cursor, options.label))
}
/** After a create: the new record replaces the form. */
export function replaceWithRecord(store: AppStore, table: string, primaryKey: string): void {
store.replace(new RecordScreen(store, table, primaryKey))
}
export function openForm(store: AppStore, table: string, options: FormOptions & { initial?: FieldValues }): void {
if (!known(store, 'tables', table)) return
const meta = store.meta?.tables?.[table] as { insertPermission?: boolean; editPermission?: boolean } | undefined
if (options.mode !== 'edit' && meta?.insertPermission === false) return store.flash('you may not create records in this table', 'error')
if (options.mode === 'edit' && meta?.editPermission === false) return store.flash('you may not edit records of this table', 'error')
store.push(new FormScreen(store, table, options))
}
export function openProcess(store: AppStore, name: string, options: ProcessOptions = {}): void {
if (!known(store, 'processes', name)) return
store.push(new ProcessScreen(store, name, options))
}
export function openApp(store: AppStore, name: string): void {
if (!known(store, 'apps', name)) return
store.push(new AppHomeScreen(store, name))
}
export function openNode(store: AppStore, node: AppTreeNode): void {
if (node.type === 'TABLE') openTable(store, node.name)
else if (node.type === 'PROCESS') openProcess(store, node.name)
else if (node.type === 'APP') openApp(store, node.name)
else if (node.type === 'REPORT') store.commands().commandLine(`report run ${node.name} --format csv`)
else store.flash(`${node.type} ${node.label} has no terminal screen yet`)
}
+186
View File
@@ -0,0 +1,186 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { Key } from '../keys'
import { charOf } from '../keys'
import { LineEditor } from '../line-editor'
import type { AppStore } from '../store'
import { theme } from '../theme'
import type { Overlay } from '../types'
import { pad, trunc } from '../util/text'
import { Frame, Line } from './frame'
let ids = 0
export function overlayId(kind: string): string {
return `${kind}:${++ids}`
}
/** Wraps text into lines of a width. */
export function wrap(text: string, width: number): string[] {
const out: string[] = []
for (const para of text.split('\n')) {
let line = ''
for (const word of para.split(/\s+/)) {
if (!word) continue
if ((line + ' ' + word).trim().length > width) {
if (line) out.push(line)
line = word.length > width ? word.slice(0, width) : word
} else {
line = (line + ' ' + word).trim()
}
}
out.push(line)
}
return out
}
export class MessageOverlay implements Overlay {
readonly id = overlayId('message')
constructor(private store: AppStore, readonly text: string, readonly kind: 'info' | 'error' = 'info', readonly title?: string) {}
onKey(): void {
this.store.closeOverlay(this)
}
render(sw: number, sh: number) {
const width = Math.min(sw - 4, Math.max(36, Math.min(100, this.text.length + 4)))
const lines = wrap(this.text, width - 4).slice(0, sh - 8)
return (
<Frame title={this.title ?? (this.kind === 'error' ? 'Error' : 'Message')} width={width} height={lines.length + 4} screenWidth={sw} screenHeight={sh}>
{lines.map((l, i) => <Line key={i} text={` ${l}`} width={width - 2} fg={this.kind === 'error' ? theme().err : theme().fg} />)}
<Line text="" width={width - 2} />
<Line text=" any key closes" width={width - 2} fg={theme().dim} />
</Frame>
)
}
}
export class ConfirmOverlay implements Overlay {
readonly id = overlayId('confirm')
constructor(private store: AppStore, readonly text: string, private onYes: () => void, private onNo?: () => void, readonly defaultYes = false) {}
onKey(key: Key): void {
const ch = charOf(key).toLowerCase()
if (ch === 'y' || (key.name === 'return' && this.defaultYes)) {
this.store.closeOverlay(this)
this.onYes()
} else if (ch === 'n' || key.name === 'escape' || key.name === 'return') {
this.store.closeOverlay(this)
this.onNo?.()
}
}
render(sw: number, sh: number) {
const width = Math.min(sw - 4, Math.max(40, Math.min(96, this.text.length + 6)))
const lines = wrap(this.text, width - 4)
return (
<Frame title="Confirm" width={width} height={lines.length + 4} screenWidth={sw} screenHeight={sh}>
{lines.map((l, i) => <Line key={i} text={` ${l}`} width={width - 2} fg={theme().warn} />)}
<Line text="" width={width - 2} />
<Line text={this.defaultYes ? ' Y/n y or Enter = yes, n or Esc = no' : ' y/N y = yes, n, Esc or Enter = no'} width={width - 2} fg={theme().dim} />
</Frame>
)
}
}
/** Draws a LineEditor with its cursor. */
export function EditorLine(props: { editor: LineEditor; width: number; focused?: boolean; placeholder?: string }) {
const t = theme()
const w = Math.max(1, props.width)
if (!props.editor.value && props.placeholder && props.focused !== false) {
return <text bg={t.inputBg}><span fg={t.inputBg} bg={t.inputFg}>{' '}</span><span fg={t.dim} bg={t.inputBg}>{pad(props.placeholder, w - 1)}</span></text>
}
const v = props.editor.view(w - 1)
if (props.focused === false) return <text fg={t.inputFg} bg={t.inputBg}>{pad(props.editor.masked ? '•'.repeat(props.editor.value.length) : props.editor.value, w)}</text>
const rest = w - v.before.length - 1 - v.after.length
return (
<text bg={t.inputBg}>
<span fg={t.inputFg} bg={t.inputBg}>{v.before}</span>
<span fg={t.inputBg} bg={t.inputFg}>{v.at}</span>
<span fg={t.inputFg} bg={t.inputBg}>{v.after + ' '.repeat(Math.max(0, rest))}</span>
</text>
)
}
export class PromptOverlay implements Overlay {
readonly id = overlayId('prompt')
readonly editor: LineEditor
constructor(private store: AppStore, readonly title: string, readonly hint: string, private onSubmit: (value: string) => void, initial = '', options: { masked?: boolean } = {}) {
this.editor = new LineEditor(initial, options)
}
onKey(key: Key): void {
if (key.name === 'escape') this.store.closeOverlay(this)
else if (key.name === 'return') {
this.store.closeOverlay(this)
this.onSubmit(this.editor.value)
} else this.editor.handle(key)
}
render(sw: number, sh: number) {
const width = Math.min(sw - 4, 90)
const hint = wrap(this.hint, width - 4)
return (
<Frame title={this.title} width={width} height={hint.length + 4} screenWidth={sw} screenHeight={sh}>
<box paddingLeft={1}><EditorLine editor={this.editor} width={width - 4} /></box>
<Line text="" width={width - 2} />
{hint.map((l, i) => <Line key={i} text={` ${l}`} width={width - 2} fg={theme().dim} />)}
</Frame>
)
}
}
export interface Choice<T> {
label: string
value: T
detail?: string
disabled?: string
}
/** A list to pick from, filtered as you type. */
export class ChoiceOverlay<T> implements Overlay {
readonly id = overlayId('choice')
readonly filter = new LineEditor('')
index = 0
constructor(private store: AppStore, readonly title: string, readonly choices: Choice<T>[], private onPick: (value: T) => void, readonly hint = '', initialIndex = 0, private onCancel?: () => void) {
this.index = Math.max(0, initialIndex)
}
matches(): Choice<T>[] {
const needle = this.filter.value.toLowerCase()
return needle ? this.choices.filter((c) => c.label.toLowerCase().includes(needle) || String(c.detail ?? '').toLowerCase().includes(needle)) : this.choices
}
onKey(key: Key): void {
const list = this.matches()
if (key.name === 'escape') {
this.store.closeOverlay(this)
this.onCancel?.()
} else if (key.name === 'down') this.index = Math.min(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(list.length - 1, this.index + 10)
else if (key.name === 'pageup') this.index = Math.max(0, this.index - 10)
else if (key.name === 'return') {
const c = list[this.index]
if (c && !c.disabled) {
this.store.closeOverlay(this)
this.onPick(c.value)
} else if (c?.disabled) this.store.flash(c.disabled)
} else if (this.filter.handle(key)) this.index = 0
}
render(sw: number, sh: number) {
const t = theme()
const list = this.matches()
const width = Math.min(sw - 4, Math.max(40, Math.min(100, Math.max(...this.choices.map((c) => c.label.length + (c.detail ? c.detail.length + 4 : 0) + 6), this.title.length + 6))))
const rows = Math.max(3, Math.min(list.length, sh - 10))
const top = Math.max(0, Math.min(this.index - Math.floor(rows / 2), list.length - rows))
const shown = list.slice(top, top + rows)
return (
<Frame title={this.title} width={width} height={rows + 5} screenWidth={sw} screenHeight={sh}>
<box paddingLeft={1}><EditorLine editor={this.filter} width={width - 4} placeholder="type to filter" /></box>
{shown.map((c, i) => {
const sel = top + i === this.index
const detail = c.disabled ?? c.detail ?? ''
const labelWidth = width - 4 - (detail ? Math.min(detail.length, Math.floor(width / 2)) + 1 : 0)
return <text key={i} fg={sel ? t.selFg : c.disabled ? t.dim : t.fg} bg={sel ? t.selBg : undefined}>{pad(` ${pad(c.label, labelWidth)}${detail ? ' ' + trunc(detail, width - 5 - labelWidth) : ''}`, width - 2)}</text>
})}
{Array.from({ length: rows - shown.length }, (_, i) => <Line key={`e${i}`} text={i === 0 && !shown.length ? ' (nothing matches)' : ''} width={width - 2} fg={t.dim} />)}
<Line text={` ${list.length} of ${this.choices.length} · ↑↓ Enter · Esc${this.hint ? ' · ' + this.hint : ''}`} width={width - 2} fg={t.dim} />
</Frame>
)
}
}
+458
View File
@@ -0,0 +1,458 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/** The F9 command center, the F2 palette, the F1 help, the `:` command line and the screen lock. */
import { hasPin, pinDelayMillis, verifyPin, type History } from '@qrun-io/qqq-core'
import type { Key } from '../keys'
import { charOf } from '../keys'
import { LineEditor } from '../line-editor'
import type { AppStore } from '../store'
import { glyphs, theme } from '../theme'
import type { MenuEntry, Overlay } from '../types'
import { center, errorText, pad, trunc } from '../util/text'
import { EditorLine, overlayId, wrap } from './basic'
import { Frame, Line } from './frame'
const SECTIONS: MenuEntry['section'][] = ['Context', 'Go to', 'Work', 'Connection', 'Settings', 'Help']
function matches(e: MenuEntry, needle: string): boolean {
return !needle || `${e.section} ${e.label} ${e.detail ?? ''} ${e.command ?? ''} ${e.key ?? ''}`.toLowerCase().includes(needle)
}
/** The F9 command center: every action by section, searchable, shortcuts beside entries. */
export class MenuOverlay implements Overlay {
readonly id = overlayId('menu')
readonly filter = new LineEditor('')
index = 0
entries: MenuEntry[]
constructor(private store: AppStore, entries: MenuEntry[]) {
this.entries = entries
const section = SECTIONS[store.lastMenuSection] ?? 'Context'
const first = this.visible().findIndex((e) => e.section === section)
this.index = Math.max(0, first)
}
visible(): MenuEntry[] {
const needle = this.filter.value.toLowerCase()
const list = this.entries.filter((e) => matches(e, needle))
return SECTIONS.flatMap((s) => list.filter((e) => e.section === s))
}
onKey(key: Key): void {
const list = this.visible()
if (key.name === 'escape' || key.name === 'f9') return this.store.closeOverlay(this)
if (key.name === 'down') this.index = Math.min(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(list.length - 1, this.index + 10)
else if (key.name === 'pageup') this.index = Math.max(0, this.index - 10)
else if (key.name === 'right' || key.name === 'tab' || key.name === 'left') {
const cur = list[this.index]?.section
const si = SECTIONS.indexOf(cur ?? 'Context')
const dir = key.name === 'left' || key.shift ? -1 : 1
for (let k = 1; k <= SECTIONS.length; k++) {
const s = SECTIONS[(si + dir * k + SECTIONS.length * 2) % SECTIONS.length]
const at = list.findIndex((e) => e.section === s)
if (at >= 0) {
this.index = at
break
}
}
} else if (key.name === 'return') {
const e = list[this.index]
if (!e) return
if (e.disabled) return this.store.flash(`${e.label}: ${e.disabled}`)
if (!e.run) return
this.store.lastMenuSection = SECTIONS.indexOf(e.section)
this.store.closeOverlay(this)
e.run()
} else if (this.filter.handle(key)) this.index = 0
}
render(sw: number, sh: number) {
const t = theme()
const width = Math.min(sw - 2, 180)
const height = Math.min(sh - 2, 40)
const inner = width - 2
const list = this.visible()
const columns = this.filter.value ? 1 : width >= 160 ? 3 : width >= 110 ? 2 : 1
const colWidth = Math.floor(inner / columns)
const rows = height - 6
const lines: Array<{ text: string; fg: string; bg?: string }[]> = Array.from({ length: columns }, () => [])
// lay sections out in columns, splitting the flat list into chunks that fit
type Item = { kind: 'h'; text: string } | { kind: 'e'; e: MenuEntry; i: number }
const items: Item[] = []
let last: string | undefined
list.forEach((e, i) => {
if (e.section !== last) {
items.push({ kind: 'h', text: e.section })
last = e.section
}
items.push({ kind: 'e', e, i })
})
// items flow down columns of `rows`; a window of `columns` columns follows the selection
const selItem = Math.max(0, items.findIndex((it) => it.kind === 'e' && it.i === this.index))
const selCol = Math.floor(selItem / rows)
const firstCol = Math.max(0, selCol - columns + 1)
items.forEach((it, k) => {
const col = Math.floor(k / rows) - firstCol
if (col < 0 || col >= columns) return
if (it.kind === 'h') lines[col]!.push({ text: ` ${it.text}`, fg: t.acc })
else {
const sel = it.i === this.index
const keyText = it.e.key ? ` ${it.e.key} ` : ''
const detail = it.e.disabled ? ` (${it.e.disabled})` : it.e.detail ? ` ${it.e.detail}` : ''
const room = colWidth - 3 - keyText.length
const text = ` ${trunc(it.e.label + detail, room)}`
lines[col]!.push({ text: pad(text, colWidth - keyText.length) + keyText, fg: sel ? t.selFg : it.e.disabled ? t.dim : t.fg, bg: sel ? t.selBg : undefined })
}
})
const out: React.ReactNode[] = []
for (let r = 0; r < rows; r++) {
out.push(
<text key={r}>
{lines.map((col, c) => {
const cellLine = col[r]
return <span key={c} fg={cellLine?.fg ?? t.fg} bg={cellLine?.bg}>{pad(cellLine?.text ?? '', colWidth)}</span>
})}
</text>,
)
}
return (
<Frame title="Menu (F9)" width={width} height={height} screenWidth={sw} screenHeight={sh} top={1}>
<box paddingLeft={1}><EditorLine editor={this.filter} width={inner - 2} placeholder="type to search every command" /></box>
<Line text="" width={inner} />
{out}
<Line text={` ${list.length} entries · ↑↓ move · ←→ Tab sections · Enter runs · Esc or F9 closes`} width={inner} fg={t.dim} />
</Frame>
)
}
}
export interface PaletteItem {
label: string
kind: string
context?: string
run: () => void
}
/** The palette (F2, /, ., Ctrl+K): jump to anything by label; searches records when the server can. */
export class PaletteOverlay implements Overlay {
readonly id = overlayId('palette')
readonly input = new LineEditor('')
index = 0
records: PaletteItem[] = []
searching = false
private timer?: ReturnType<typeof setTimeout>
private generation = 0
constructor(private store: AppStore, readonly items: PaletteItem[], readonly searchRecords?: (text: string) => Promise<PaletteItem[]>, readonly searchNote = '') {}
list(): PaletteItem[] {
const needle = this.input.value.toLowerCase().trim()
const words = needle.split(/\s+/).filter(Boolean)
const hits = this.items.filter((it) => words.every((w) => `${it.label} ${it.kind} ${it.context ?? ''}`.toLowerCase().includes(w)))
hits.sort((a, b) => Number(!a.label.toLowerCase().startsWith(needle)) - Number(!b.label.toLowerCase().startsWith(needle)))
return [...hits.slice(0, 200), ...this.records]
}
onKey(key: Key): void {
const list = this.list()
if (key.name === 'escape') return this.store.closeOverlay(this)
if (key.name === 'down') this.index = Math.min(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(list.length - 1, this.index + 10)
else if (key.name === 'pageup') this.index = Math.max(0, this.index - 10)
else if (key.name === 'return') {
const it = list[this.index]
if (!it) return
this.store.closeOverlay(this)
it.run()
} else if (this.input.handle(key)) {
this.index = 0
this.records = []
clearTimeout(this.timer)
const text = this.input.value.trim()
if (this.searchRecords && text.length >= 2) {
this.timer = setTimeout(() => {
const gen = ++this.generation
this.searching = true
this.searchRecords!(text).then((r) => { if (gen === this.generation) this.records = r }).catch(() => undefined).finally(() => { this.searching = false; this.store.bump() })
}, 250)
}
}
}
render(sw: number, sh: number) {
const t = theme()
const width = Math.min(sw - 4, 110)
const inner = width - 2
const list = this.list()
const rows = Math.max(5, Math.min(20, sh - 10))
const top = Math.max(0, Math.min(this.index - Math.floor(rows / 2), list.length - rows))
const footer = this.searchRecords ? `${this.searching ? 'searching records… · ' : ''}${list.length} matches · records searched by the server${this.searchNote ? ` (${this.searchNote})` : ''}` : `${list.length} matches · no record search on this server: navigation only`
return (
<Frame title="Go to (F2 / . Ctrl+K)" width={width} height={rows + 5} screenWidth={sw} screenHeight={sh} top={2}>
<box paddingLeft={1}><EditorLine editor={this.input} width={inner - 2} placeholder="a table, process, app, report, recent record, command, or text to find records" /></box>
{list.slice(top, top + rows).map((it, i) => {
const sel = top + i === this.index
return <text key={i} fg={sel ? t.selFg : t.fg} bg={sel ? t.selBg : undefined}>{pad(` ${pad(it.label, 38)} ${pad(it.kind, 10)} ${it.context ?? ''}`, inner)}</text>
})}
{Array.from({ length: Math.max(0, rows - Math.min(rows, list.length - top)) }, (_, i) => <Line key={`e${i}`} text="" width={inner} />)}
<Line text="" width={inner} />
<Line text={` ${footer}`} width={inner} fg={t.dim} />
</Frame>
)
}
}
/** The F1 / ? help: keys for this screen, what can be done here, the topic's help text, and help search. */
export class HelpOverlay implements Overlay {
readonly id = overlayId('help')
readonly input = new LineEditor('')
top = 0
results: Array<{ label: string; detail: string; run?: () => void }> = []
index = 0
private timer?: ReturnType<typeof setTimeout>
constructor(private store: AppStore, readonly keys: Array<[string, string]>, readonly actions: MenuEntry[], readonly topicHelp: string[], readonly search: (q: string) => Promise<Array<{ label: string; detail: string; run?: () => void }>>) {}
onKey(key: Key): void {
if (key.name === 'escape' || key.name === 'f1') return this.store.closeOverlay(this)
if (this.input.value && this.results.length) {
if (key.name === 'down') return void (this.index = Math.min(this.results.length - 1, this.index + 1))
if (key.name === 'up') return void (this.index = Math.max(0, this.index - 1))
if (key.name === 'return') {
const r = this.results[this.index]
if (r?.run) {
this.store.closeOverlay(this)
r.run()
}
return
}
}
if (key.name === 'down') this.top++
else if (key.name === 'up') this.top = Math.max(0, this.top - 1)
else if (key.name === 'pagedown') this.top += 10
else if (key.name === 'pageup') this.top = Math.max(0, this.top - 10)
else if (key.name === 'return' && !this.input.value) {
const a = this.actions.find((e) => e.run && !e.disabled)
void a
} else if (this.input.handle(key)) {
this.index = 0
clearTimeout(this.timer)
const q = this.input.value.trim()
if (!q) {
this.results = []
return
}
this.timer = setTimeout(() => {
this.search(q).then((r) => { this.results = r }).catch((e) => { this.results = [{ label: errorText(e), detail: '' }] }).finally(() => this.store.bump())
}, 250)
}
}
render(sw: number, sh: number) {
const t = theme()
const width = Math.min(sw - 4, 110)
const inner = width - 2
const height = Math.min(sh - 2, 40)
const body: Array<{ text: string; fg?: string; bg?: string }> = []
if (this.input.value) {
body.push({ text: ` Help search: ${this.results.length} results (Enter opens)`, fg: t.acc })
this.results.forEach((r, i) => body.push({ text: ` ${pad(r.label, 40)} ${r.detail}`, fg: i === this.index ? t.selFg : t.fg, bg: i === this.index ? t.selBg : undefined }))
} else {
body.push({ text: ' Keys', fg: t.acc })
const half = Math.ceil(this.keys.length / 2)
for (let i = 0; i < half; i++) {
const a = this.keys[i]!
const b = this.keys[i + half]
body.push({ text: ` ${pad(a[0], 14)}${pad(a[1], Math.floor(inner / 2) - 17)}${b ? `${pad(b[0], 14)}${b[1]}` : ''}` })
}
if (this.topicHelp.length) {
body.push({ text: '' })
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.actions.length) {
body.push({ text: '' })
body.push({ text: ' What you can do here (also in F9)', fg: t.acc })
for (const a of this.actions) body.push({ text: ` ${pad(a.label, inner - 22)} ${a.key ?? ''}`, fg: a.disabled ? t.dim : t.fg })
}
}
const rows = height - 5
this.top = Math.min(this.top, Math.max(0, body.length - rows))
const shown = body.slice(this.input.value ? 0 : this.top, (this.input.value ? 0 : this.top) + rows)
return (
<Frame title="Help (F1 ?)" width={width} height={height} screenWidth={sw} screenHeight={sh} top={1}>
<box paddingLeft={1}><EditorLine editor={this.input} width={inner - 2} placeholder="type to search the application's help" /></box>
{shown.map((l, i) => <Line key={i} text={l.text} width={inner} fg={l.fg} bg={l.bg} />)}
{Array.from({ length: Math.max(0, rows - shown.length) }, (_, i) => <Line key={`e${i}`} text="" width={inner} />)}
<Line text="" width={inner} />
<Line text=" ↑↓ PgUp PgDn scroll · type to search help · Esc or F1 closes" width={inner} fg={t.dim} />
</Frame>
)
}
}
/** The `:` command line: the CLI grammar plus TUI commands, history (↑↓, Ctrl+R), Tab completion. */
export class CommandLineOverlay implements Overlay {
readonly id = overlayId('cmdline')
readonly editor: LineEditor
historyIndex = -1
candidates: string[] = []
candidate = 0
search?: LineEditor
constructor(private store: AppStore, private history: History, private complete: (line: string, cursor: number) => { from: number; candidates: string[] }, private onRun: (line: string) => void, initial = '') {
this.editor = new LineEditor(initial)
}
onKey(key: Key): void {
if (this.search) {
if (key.name === 'escape') {
this.search = undefined
return
}
if (key.name === 'return' || (key.ctrl && key.name === 'r')) {
const hit = this.history.search(this.search.value)[0]
if (hit) this.editor.set(hit)
this.search = undefined
return
}
this.search.handle(key)
return
}
if (key.name === 'escape') return this.store.closeOverlay(this)
if (key.ctrl && key.name === 'r') {
this.search = new LineEditor('')
return
}
if (key.name === 'return') {
const line = this.editor.value.trim()
this.store.closeOverlay(this)
if (line) {
this.history.add(line)
this.onRun(line)
}
return
}
if (key.name === 'tab') {
const r = this.complete(this.editor.value, this.editor.cursor)
if (!r.candidates.length) return
if (this.candidates.join('\u0000') === r.candidates.join('\u0000') && r.candidates.length > 1) this.candidate = (this.candidate + 1) % r.candidates.length
else {
this.candidates = r.candidates
this.candidate = 0
}
const pick = r.candidates[this.candidate]!
const before = this.editor.value.slice(0, r.from)
const after = this.editor.value.slice(this.editor.cursor)
this.editor.value = before + pick + (after.startsWith(' ') ? '' : ' ') + after
this.editor.cursor = before.length + pick.length + 1
if (r.candidates.length === 1) this.candidates = []
return
}
if (key.name === 'up' || key.name === 'down') {
const hist = this.history.entries()
this.historyIndex = Math.max(-1, Math.min(hist.length - 1, this.historyIndex + (key.name === 'up' ? 1 : -1)))
this.editor.set(this.historyIndex >= 0 ? hist[this.historyIndex]! : '')
return
}
if (this.editor.handle(key)) this.candidates = []
}
render(sw: number, sh: number) {
const t = theme()
const width = Math.min(sw - 2, 140)
const inner = width - 2
const hint = this.search
? ` history search: ${this.search.value}_ → ${this.history.search(this.search.value)[0] ?? '(no match)'}`
: this.candidates.length > 1 ? ` ${trunc(this.candidates.map((c, i) => (i === this.candidate ? `[${c}]` : c)).join(' '), inner - 2)}` : ' ↑↓ history · Ctrl+R search · Tab completes · Enter runs · Esc · e.g. table query person -w "lastName STARTS_WITH S" · theme light · lock · quit'
return (
<box position="absolute" left={Math.max(0, Math.floor((sw - width) / 2))} top={Math.max(0, sh - 6)} width={width} height={4} flexDirection="column" border borderStyle="single" borderColor={t.focusBorder} {...(glyphs().border ? { customBorderChars: glyphs().border } : {})} backgroundColor={t.overlayBg} title=" : command " zIndex={30}>
<text><span fg={t.acc2}>{':'}</span></text>
<box position="absolute" left={2} top={0}><EditorLine editor={this.editor} width={inner - 3} /></box>
<Line text={hint} width={inner} fg={this.search ? t.acc2 : t.dim} />
</box>
)
}
}
/** The screen saver and lock: a slowly moving clock on the theme's colors; a PIN when one is set. */
export class LockOverlay implements Overlay {
readonly id = overlayId('lock')
asking = false
readonly pin = new LineEditor('', { masked: true })
failures = 0
waitUntil = 0
message = ''
started = Date.now()
constructor(private store: AppStore) {}
onKey(key: Key): void {
if (!hasPin()) {
this.store.lock = undefined
return
}
if (!this.asking) {
this.asking = true
return
}
if (key.name === 'escape') {
this.asking = false
this.pin.set('')
return
}
if (key.name === 'return') {
if (Date.now() < this.waitUntil) {
this.message = `wait ${Math.ceil((this.waitUntil - Date.now()) / 1000)} s`
return
}
if (verifyPin(this.pin.value)) {
this.store.lock = undefined
return
}
this.failures++
this.waitUntil = Date.now() + pinDelayMillis(this.failures)
this.message = `wrong PIN (attempt ${this.failures}); wait ${(pinDelayMillis(this.failures) / 1000).toFixed(1)} s`
this.pin.set('')
return
}
this.pin.handle(key)
}
render(sw: number, sh: number) {
const t = theme()
const elapsed = Math.floor((Date.now() - this.started) / 4000)
const brand = (this.store.meta?.branding as { appName?: string } | undefined)?.appName ?? 'QQQ'
const lines = [brand, new Date().toLocaleTimeString('en-GB'), `${this.store.status.profile} · ${this.store.status.server.replace(/^https?:\/\//, '')}`, this.asking ? 'PIN:' : hasPin() ? 'locked · any key to unlock' : 'any key returns']
const boxW = 44
const maxX = Math.max(0, sw - boxW)
const maxY = Math.max(0, sh - lines.length - 4)
const bounce = (n: number, max: number) => (max === 0 ? 0 : Math.abs(((n % (2 * max)) + 2 * max) % (2 * max) - max))
const x = bounce(elapsed * 3, maxX)
const y = bounce(elapsed, maxY)
const rows: React.ReactNode[] = []
for (let r = 0; r < sh; r++) rows.push(<text key={r} bg={t.overlayBg}>{' '.repeat(sw)}</text>)
return (
<box position="absolute" left={0} top={0} width={sw} height={sh} zIndex={100} backgroundColor={t.overlayBg}>
{rows}
<box position="absolute" left={x} top={y} width={boxW} flexDirection="column">
<text fg={t.acc}><strong>{center(lines[0]!, boxW)}</strong></text>
<text fg={t.acc2}><strong>{center(lines[1]!, boxW)}</strong></text>
<text fg={t.dim}>{center(lines[2]!, boxW)}</text>
<text fg={t.fg}>{center(lines[3]!, boxW)}</text>
{this.asking ? <box paddingLeft={12}><EditorLine editor={this.pin} width={20} /></box> : null}
{this.message ? <text fg={t.err}>{center(this.message, boxW)}</text> : null}
</box>
</box>
)
}
}
export { charOf }
@@ -0,0 +1,35 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { existsSync } from 'node:fs'
import { readFilePreferences, safeFileName, silentDownloadPath, startDirForSave, startDirForUpload } from '@qrun-io/qqq-core'
import type { AppStore } from '../store'
import { FileDialog } from './files'
/**
* Where to save a download: with "save silently" set, straight to the default download directory
* (a free name, never overwriting); otherwise the save dialog.
*/
export function saveFile(store: AppStore, title: string, suggested: string, write: (path: string) => void): void {
const prefs = readFilePreferences()
if (prefs.downloadMode === 'silent') {
const base = silentDownloadPath(safeFileName(suggested), prefs)
let path = base
for (let i = 1; existsSync(path); i++) path = base.replace(/(\.[^./]*)?$/, (ext) => `-${i}${ext}`)
write(path)
return
}
store.open(new FileDialog(store, { mode: 'save', title, startDir: startDirForSave(prefs), fileName: suggested, onPick: write }))
}
/** Chooses an existing file to upload. */
export function openFile(store: AppStore, title: string, pick: (path: string) => void): void {
store.open(new FileDialog(store, { mode: 'open', title, startDir: startDirForUpload(), onPick: pick }))
}
/** Chooses a folder. */
export function chooseFolder(store: AppStore, title: string, start: string, pick: (path: string) => void): void {
store.open(new FileDialog(store, { mode: 'folder', title, startDir: start, onPick: pick }))
}
+237
View File
@@ -0,0 +1,237 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { existsSync, statSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { completePath, createFolder, expandPath, formatSize, listDirectory, safeFileName, writeFilePreferences, type DirEntry } from '@qrun-io/qqq-core'
import type { Key } from '../keys'
import { charOf } from '../keys'
import { LineEditor } from '../line-editor'
import type { AppStore } from '../store'
import { glyphs, theme } from '../theme'
import type { Overlay } from '../types'
import { errorText, pad, trunc } from '../util/text'
import { ConfirmOverlay, EditorLine, PromptOverlay, overlayId } from './basic'
import { Frame, Line } from './frame'
export interface FileDialogOptions {
/** open: pick an existing file; save: choose a folder and a file name; folder: pick a directory. */
mode: 'open' | 'save' | 'folder'
title: string
startDir: string
/** Save: the suggested name (sanitized with the kernel's safeFileName). */
fileName?: string
onPick: (path: string) => void
}
/**
* A keyboard file navigator: directories first with sizes and dates, typing filters, Backspace or
* `..` goes up, a path line with Tab completion, `h` hidden files, `~` home, Ctrl+N new folder,
* and for saves a file name line that warns before overwriting. The listing and completion come
* from the kernel (files/navigator), so this is only drawing and keys.
*/
export class FileDialog implements Overlay {
readonly id = overlayId('files')
dir: string
entries: DirEntry[] = []
index = 0
hidden = false
focus: 'list' | 'path' | 'name' = 'list'
readonly filter = new LineEditor('')
readonly path: LineEditor
readonly name: LineEditor
error?: string
candidates: string[] = []
constructor(private store: AppStore, readonly options: FileDialogOptions) {
this.dir = existsSync(options.startDir) ? options.startDir : process.cwd()
this.path = new LineEditor(this.dir)
this.name = new LineEditor(options.fileName ? safeFileName(options.fileName) : '')
if (options.mode === 'save') this.focus = 'name'
this.load()
}
load(): void {
try {
this.entries = listDirectory(this.dir, { hidden: this.hidden })
this.error = undefined
} catch (e) {
this.entries = []
this.error = errorText(e)
}
this.path.set(this.dir)
this.index = 0
}
rows(): Array<DirEntry | { name: '..'; up: true } | { name: string; select: true }> {
const needle = this.filter.value.toLowerCase()
const list = this.entries.filter((e) => (this.options.mode !== 'folder' || e.isDir) && (!needle || e.name.toLowerCase().includes(needle)))
const head: Array<{ name: '..'; up: true } | { name: string; select: true }> = []
if (this.options.mode === 'folder' && !needle) head.push({ name: '[ select this folder ]', select: true })
if (dirname(this.dir) !== this.dir && !needle) head.push({ name: '..', up: true })
return [...head, ...list]
}
go(dir: string): void {
this.dir = dir
this.filter.set('')
this.load()
}
pick(path: string): void {
const remember = this.options.mode === 'open' ? { lastUploadDir: dirname(path) } : { lastDownloadDir: this.options.mode === 'folder' ? path : dirname(path) }
try {
writeFilePreferences(remember)
} catch {
// not critical
}
this.store.closeOverlay(this)
this.options.onPick(path)
}
save(): void {
const name = this.name.value.trim()
if (!name) {
this.error = 'type a file name'
return
}
let target: string
try {
target = join(this.dir, safeFileName(name))
} catch (e) {
this.error = errorText(e)
return
}
if (existsSync(target)) this.store.open(new ConfirmOverlay(this.store, `${target} exists. Overwrite it?`, () => this.pick(target)))
else this.pick(target)
}
onKey(key: Key): void {
if (key.name === 'escape') return this.store.closeOverlay(this)
if (key.name === 'tab') {
if (this.focus === 'path' && !key.shift) {
const r = completePath(this.path.value, { hidden: this.hidden, directoriesOnly: this.options.mode === 'folder' })
this.path.set(r.text)
this.candidates = r.candidates.length > 1 ? r.candidates : []
if (r.candidates.length === 1 && r.text.endsWith('/') && existsSync(expandPath(r.text))) this.go(expandPath(r.text))
return
}
const order: Array<'list' | 'path' | 'name'> = this.options.mode === 'save' ? ['name', 'list', 'path'] : ['list', 'path']
this.focus = order[(order.indexOf(this.focus) + (key.shift ? order.length - 1 : 1)) % order.length]!
return
}
if (key.ctrl && key.name === 'n' && this.options.mode !== 'open') {
this.store.open(new PromptOverlay(this.store, 'New folder', `In ${this.dir}`, (name) => {
try {
this.go(createFolder(this.dir, name))
} catch (e) {
this.error = errorText(e)
}
}))
return
}
if (this.focus === 'path') {
if (key.name === 'return') {
const p = expandPath(this.path.value)
if (existsSync(p) && statSync(p).isDirectory()) {
this.go(p)
this.focus = this.options.mode === 'save' ? 'name' : 'list'
} else if (existsSync(p) && this.options.mode === 'open') this.pick(p)
else if (this.options.mode === 'save' && existsSync(dirname(p))) {
this.go(dirname(p))
this.name.set(p.slice(dirname(p).length + 1))
this.focus = 'name'
} else this.error = `no such file or folder: ${p}`
return
}
this.path.handle(key)
return
}
if (this.focus === 'name') {
if (key.name === 'return') return this.save()
this.name.handle(key)
return
}
const rows = this.rows()
const ch = charOf(key)
switch (key.name) {
case 'down': this.index = Math.min(rows.length - 1, this.index + 1); return
case 'up': this.index = Math.max(0, this.index - 1); return
case 'pagedown': this.index = Math.min(rows.length - 1, this.index + 10); return
case 'pageup': this.index = Math.max(0, this.index - 10); return
case 'home': this.index = 0; return
case 'end': this.index = rows.length - 1; return
case 'return': {
const r = rows[this.index]
if (!r) return
if ('up' in r) return this.go(dirname(this.dir))
if ('select' in r) return this.pick(this.dir)
if (r.isDir) return this.go(r.path)
if (this.options.mode === 'open') return this.pick(r.path)
if (this.options.mode === 'save') {
this.name.set(r.name)
this.focus = 'name'
}
return
}
case 'backspace':
if (!this.filter.value) return this.go(dirname(this.dir))
break
default:
break
}
if (!this.filter.value && ch === 'h') {
this.hidden = !this.hidden
return this.load()
}
if (!this.filter.value && ch === '~') return this.go(homedir())
if (this.filter.handle(key)) this.index = 0
}
render(sw: number, sh: number) {
const t = theme()
const g = glyphs()
const width = Math.min(sw - 2, 110)
const inner = width - 2
const rows = this.rows()
const listRows = Math.max(4, Math.min(sh - 12, 24))
const top = Math.max(0, Math.min(this.index - Math.floor(listRows / 2), rows.length - listRows))
const dateWidth = inner >= 70 ? 17 : 0
const sizeWidth = 9
const nameWidth = inner - 3 - sizeWidth - dateWidth
const lines = rows.slice(top, top + listRows).map((r, i) => {
const sel = top + i === this.index && this.focus === 'list'
let text: string
let fg = t.fg
if ('up' in r) {
text = ` ${pad('../', nameWidth)}`
fg = t.acc
} else if ('select' in r) {
text = ` ${pad(r.name, nameWidth)}`
fg = t.acc2
} else {
const date = dateWidth ? ' ' + r.modified.toISOString().slice(0, 16).replace('T', ' ') : ''
text = ` ${pad(r.name + (r.isDir ? '/' : ''), nameWidth)} ${r.isDir ? pad('', sizeWidth - 1) : pad(formatSize(r.size), sizeWidth - 1)}${date}`
fg = r.isDir ? t.acc : r.hidden ? t.dim : t.fg
}
return <text key={i} fg={sel ? t.selFg : fg} bg={sel ? t.selBg : undefined}>{pad(text, inner)}</text>
})
while (lines.length < listRows) lines.push(<text key={`e${lines.length}`}>{pad(lines.length === 0 && this.filter.value ? ' (nothing matches)' : '', inner)}</text>)
const label = (f: 'list' | 'path' | 'name', text: string) => <span fg={this.focus === f ? t.acc2 : t.dim}>{text}</span>
return (
<Frame title={this.options.title} width={width} height={listRows + (this.options.mode === 'save' ? 9 : 8)} screenWidth={sw} screenHeight={sh} top={1}>
<box flexDirection="row" height={1}><text>{label('path', ' Path ')}</text><EditorLine editor={this.path} width={inner - 7} focused={this.focus === 'path'} /></box>
{this.options.mode === 'save' ? <box flexDirection="row" height={1}><text>{label('name', ' Name ')}</text><EditorLine editor={this.name} width={inner - 7} focused={this.focus === 'name'} /></box> : null}
<box flexDirection="row" height={1}><text>{label('list', ' Find ')}</text><EditorLine editor={this.filter} width={inner - 7} focused={this.focus === 'list'} placeholder="type to filter this folder" /></box>
<Line text={this.candidates.length ? ` ${trunc(this.candidates.join(' '), inner - 2)}` : ''} width={inner} fg={t.dim} />
{lines}
<Line text={this.error ? ` ${this.error}` : ` ${rows.filter((r) => 'isDir' in r).length} entries${this.hidden ? ' (hidden shown)' : ''}`} width={inner} fg={this.error ? t.err : t.dim} />
<Line text={` Enter open/pick · Backspace up · Tab ${this.options.mode === 'save' ? 'name/list/path' : 'list/path'} · h hidden · ~ home${this.options.mode !== 'open' ? ' · Ctrl+N folder' : ''} · Esc`} width={inner} fg={t.dim} />
<Line text={` ${g.sep} ${trunc(this.dir, inner - 4)}`} width={inner} fg={t.dim} />
</Frame>
)
}
}
+499
View File
@@ -0,0 +1,499 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The F6 filter dialog, two modes over one QueryFilter (through the kernel grammar):
* - guided: a builder over the table's fields (field, then an operator valid for its type, then a
* value editor that fits: possible-value picker, date presets, list values), AND/OR, one level of
* groups, and the sort; the grammar text is shown live;
* - advanced: the grammar typed directly with completion from the kernel (fields, operators,
* values and expressions, AND/OR/parentheses), inline parse errors and a preview.
* Tab switches modes (guided prints the grammar; advanced parses back when it parses).
*/
import {
FilterSyntaxError, LIST_OPERATORS, UNARY_OPERATORS, applyCompletion, completeFilter, describeFilter, expressionCandidates, locateFilterError,
operatorsForField, parseFilter, parseValue, printCriterion, printFilter, printValue, resolvePossibleValue, searchPossibleValues,
type Completion, type CompletionResult, type Criterion, type FilterValue, type Operator, type QueryFilter,
} from '@qrun-io/qqq-core'
import type { FieldMetaData, TableMetaData } from '../client'
import type { Key } from '../keys'
import { charOf } from '../keys'
import { LineEditor } from '../line-editor'
import type { AppStore } from '../store'
import { glyphs, theme } from '../theme'
import type { Overlay } from '../types'
import { errorText, pad, shellQuote, trunc } from '../util/text'
import { ChoiceOverlay, EditorLine, PromptOverlay, overlayId, wrap } from './basic'
import { Frame, Line } from './frame'
type Sort = { field: string; asc: boolean } | undefined
type GuidedRow =
| { kind: 'crit'; group: number; index: number }
| { kind: 'group'; group: number }
| { kind: 'add'; group: number }
| { kind: 'addGroup' }
| { kind: 'join' }
| { kind: 'sort' }
function clone(f: QueryFilter): QueryFilter {
return JSON.parse(JSON.stringify(f)) as QueryFilter
}
export class FilterOverlay implements Overlay {
readonly id = overlayId('filter')
mode: 'guided' | 'advanced' = 'guided'
filter: QueryFilter
sort: Sort
row = 0
/** Labels of possible-value ids, for display: `${field}\u0000${id}` -> label. */
labels = new Map<string, string>()
readonly text = new LineEditor('')
completion?: CompletionResult
candidate = 0
parseError?: { message: string; at?: number }
historyIndex = -1
historySearch?: LineEditor
private valueCache = new Map<string, Completion[]>()
constructor(private store: AppStore, readonly meta: TableMetaData, filter: QueryFilter, sort: Sort, private onApply: (filter: QueryFilter, sort: Sort | null) => void) {
this.filter = clone({ ...filter, criteria: filter.criteria ?? [], booleanOperator: filter.booleanOperator ?? 'AND' })
this.sort = sort
this.collectLabels()
}
fields(): FieldMetaData[] {
return Object.values(this.meta.fields).filter((f) => !f.isHidden)
}
field(name: string): FieldMetaData | undefined {
return this.meta.fields[name]
}
/** Looks up labels for possible-value ids already in the filter. */
collectLabels(): void {
const all = [...this.filter.criteria, ...(this.filter.subFilters ?? []).flatMap((s) => s.criteria)]
for (const c of all) {
const f = this.field(c.fieldName)
const ids = c.values.filter((v): v is string => typeof v === 'string')
if (!f?.possibleValueSourceName || !ids.length) continue
searchPossibleValues(this.store.client!.server, this.store.client!.fetch, { table: this.meta.name }, f.name, { ids, useCase: 'FILTER' })
.then((opts) => { for (const o of opts) this.labels.set(`${f.name}\u0000${String(o.id)}`, o.label); this.store.bump() })
.catch(() => undefined)
}
}
// ---- guided ----
groups(): QueryFilter[] {
return this.filter.subFilters ?? []
}
rows(): GuidedRow[] {
const out: GuidedRow[] = []
this.filter.criteria.forEach((_, i) => out.push({ kind: 'crit', group: -1, index: i }))
out.push({ kind: 'add', group: -1 })
this.groups().forEach((g, gi) => {
out.push({ kind: 'group', group: gi })
g.criteria.forEach((_, i) => out.push({ kind: 'crit', group: gi, index: i }))
out.push({ kind: 'add', group: gi })
})
out.push({ kind: 'addGroup' }, { kind: 'join' }, { kind: 'sort' })
return out
}
groupOf(gi: number): QueryFilter {
return gi < 0 ? this.filter : this.groups()[gi]!
}
valueText(c: Criterion): string {
return c.values.map((v) => (typeof v === 'string' ? this.labels.get(`${c.fieldName}\u0000${v}`) ?? v : printValue(v))).join(', ')
}
critText(c: Criterion): string {
const f = this.field(c.fieldName)
const op = c.operator.toLowerCase().replace(/_/g, ' ')
return `${f?.label ?? c.fieldName} ${op}${c.values.length ? ` ${this.valueText(c)}` : ''}`
}
/** Field, then operator, then value; calls back with the criterion. */
buildCriterion(start: Partial<Criterion>, done: (c: Criterion) => void): void {
const pickField = (next: (f: FieldMetaData) => void) => {
if (start.fieldName && this.field(start.fieldName)) return next(this.field(start.fieldName)!)
this.store.open(new ChoiceOverlay(this.store, 'Filter on which field?', this.fields().map((f) => ({ label: f.label, value: f, detail: `${f.name} · ${f.possibleValueSourceName ? 'choice' : f.type.toLowerCase()}` })), next))
}
pickField((f) => {
const pickOp = (next: (op: Operator) => void) => {
if (start.operator && operatorsForField(f).includes(start.operator)) return next(start.operator)
this.store.open(new ChoiceOverlay(this.store, `${f.label}: which comparison?`, operatorsForField(f).map((o) => ({ label: o.toLowerCase().replace(/_/g, ' '), value: o, detail: o })), next))
}
pickOp((op) => {
if (UNARY_OPERATORS.has(op)) return done({ fieldName: f.name, operator: op, values: [] })
this.editValues(f, op, start.values ?? [], (values) => done({ fieldName: f.name, operator: op, values }))
})
})
}
/** The value editor that fits the field and operator. */
editValues(f: FieldMetaData, op: Operator, current: FilterValue[], done: (values: FilterValue[]) => void): void {
const list = LIST_OPERATORS.has(op)
const between = op === 'BETWEEN' || op === 'NOT_BETWEEN'
if (f.possibleValueSourceName && !between) {
const pick = (acc: FilterValue[]) => {
const term = new LineEditor('')
const overlay = new ChoiceOverlay<string | null>(this.store, `${f.label}${list ? ` (${acc.length} chosen; Esc when done)` : ''}`, [], (id) => {
if (id === null) return done(acc)
const next = list ? [...acc, id] : [id]
if (list) pick(next)
else done(next)
}, 'type to search', 0, () => { if (list && acc.length) done(acc) })
void term
const load = (search: string) => {
searchPossibleValues(this.store.client!.server, this.store.client!.fetch, { table: this.meta.name }, f.name, { searchTerm: search, useCase: 'FILTER', limit: 100 }).then((opts) => {
overlay.choices.splice(0, overlay.choices.length, ...(list && acc.length ? [{ label: `Done (${acc.length} chosen)`, value: null }] : []), ...opts.map((o) => {
this.labels.set(`${f.name}\u0000${String(o.id)}`, o.label)
return { label: o.label, value: String(o.id), detail: String(o.id) }
}))
this.store.bump()
}).catch((e) => this.store.flash(errorText(e), 'error'))
}
const original = overlay.onKey.bind(overlay)
overlay.onKey = (key: Key) => {
const before = overlay.filter.value
original(key)
if (overlay.filter.value !== before) load(overlay.filter.value)
}
// the choice list filters locally too; server search narrows big sources
overlay.matches = () => overlay.choices
load('')
this.store.open(overlay)
}
return pick(list ? current : [])
}
const type = f.type.toUpperCase()
const isDate = type === 'DATE' || type === 'DATE_TIME' || type === 'TIME'
const prompt = (initial: string) => this.store.open(new PromptOverlay(this.store, `${f.label} ${op.toLowerCase().replace(/_/g, ' ')}`, between ? 'Two values separated by a comma: from,to. Expressions work: now(-7d), startOf(month).' : list ? 'Values separated by commas; quote values that contain commas.' : isDate ? 'A date (YYYY-MM-DD) or an expression: now(-7d), thisPeriod(month), startOf(year).' : 'The value.', (text) => {
try {
const c = parseFilter(`${f.name} ${op} ${text}`).criteria[0]!
done(c.values)
} catch (e) {
this.store.flash(errorText(e), 'error')
}
}, initial))
const initial = current.map(printValue).join(',')
if (isDate && !list) {
const presets = expressionCandidates(type).map((c) => ({ label: c.detail ?? c.value, value: c.value, detail: c.value }))
this.store.open(new ChoiceOverlay(this.store, `${f.label}: when?`, [{ label: 'Type a date or an expression…', value: '', detail: 'YYYY-MM-DD' }, ...presets], (v) => (v ? done([parseValue(v)]) : prompt(initial))))
return
}
prompt(initial)
}
editRow(r: GuidedRow): void {
if (r.kind === 'crit') {
const g = this.groupOf(r.group)
const c = g.criteria[r.index]!
const f = this.field(c.fieldName)
this.store.open(new ChoiceOverlay(this.store, this.critText(c), [
{ label: 'Change the value', value: 'value', disabled: UNARY_OPERATORS.has(c.operator) ? 'this comparison has no value' : undefined },
{ label: 'Change the comparison', value: 'op' },
{ label: 'Change the field', value: 'field' },
{ label: 'Remove this condition', value: 'remove' },
], (what) => {
if (what === 'remove') g.criteria.splice(r.index, 1)
else if (what === 'value' && f) this.editValues(f, c.operator, c.values, (values) => { c.values = values; this.store.bump() })
else if (what === 'op') this.buildCriterion({ fieldName: c.fieldName }, (n) => { g.criteria[r.index] = n; this.store.bump() })
else this.buildCriterion({}, (n) => { g.criteria[r.index] = n; this.store.bump() })
}))
} else if (r.kind === 'add') {
this.buildCriterion({}, (c) => { this.groupOf(r.group).criteria.push(c); this.store.bump() })
} else if (r.kind === 'addGroup') {
this.filter.subFilters = [...this.groups(), { criteria: [], booleanOperator: this.filter.booleanOperator === 'AND' ? 'OR' : 'AND' }]
this.buildCriterion({}, (c) => { this.groups()[this.groups().length - 1]!.criteria.push(c); this.store.bump() })
} else if (r.kind === 'group' || r.kind === 'join') {
const g = r.kind === 'group' ? this.groupOf(r.group) : this.filter
g.booleanOperator = g.booleanOperator === 'AND' ? 'OR' : 'AND'
} else if (r.kind === 'sort') {
this.store.open(new ChoiceOverlay(this.store, 'Sort by', [{ label: '(default order)', value: '' }, ...this.fields().map((f) => ({ label: f.label, value: f.name, detail: f.name }))], (field) => {
if (!field) {
this.sort = undefined
return
}
this.store.open(new ChoiceOverlay(this.store, 'Direction', [{ label: 'Ascending', value: true }, { label: 'Descending', value: false }], (asc) => { this.sort = { field, asc }; this.store.bump() }))
}))
}
}
removeRow(r: GuidedRow): void {
if (r.kind === 'crit') this.groupOf(r.group).criteria.splice(r.index, 1)
else if (r.kind === 'group') this.filter.subFilters = this.groups().filter((_, i) => i !== r.group)
else if (r.kind === 'sort') this.sort = undefined
}
/** Empty groups are dropped before printing or applying. */
normalized(): QueryFilter {
const f = clone(this.filter)
f.subFilters = (f.subFilters ?? []).filter((g) => g.criteria.length || g.subFilters?.length)
if (!f.subFilters.length) delete f.subFilters
return f
}
// ---- advanced ----
toAdvanced(): void {
this.text.set(printFilter(this.normalized()))
this.mode = 'advanced'
this.complete()
}
toGuided(): void {
const parsed = this.parse()
if (!parsed) return this.store.flash('fix the filter text first (or Ctrl+U clears it)', 'error')
this.filter = parsed
this.collectLabels()
this.mode = 'guided'
this.row = 0
}
parse(): QueryFilter | undefined {
const text = this.text.value.trim()
if (!text) {
this.parseError = undefined
return { criteria: [], booleanOperator: 'AND' }
}
try {
const f = parseFilter(text, Object.keys(this.meta.fields))
this.parseError = undefined
return f
} catch (e) {
this.parseError = { message: e instanceof Error ? e.message : String(e), at: locateFilterError(text, e) }
return undefined
}
}
complete(): void {
const fields = this.fields().map((f) => ({ name: f.name, label: f.label, type: f.type, possibleValueSourceName: f.possibleValueSourceName, allowedFilterOperators: f.allowedFilterOperators as string[] | undefined }))
this.completion = completeFilter(this.text.value, this.text.cursor, fields, {
values: (f, prefix) => {
const key = `${f.name}\u0000${prefix.toLowerCase()}`
if (!this.valueCache.has(key)) {
this.valueCache.set(key, [])
searchPossibleValues(this.store.client!.server, this.store.client!.fetch, { table: this.meta.name }, f.name, { searchTerm: prefix, useCase: 'FILTER', limit: 20 }).then((opts) => {
this.valueCache.set(key, opts.map((o) => ({ value: o.label, label: o.label, kind: 'value' as const, detail: String(o.id) })))
if (this.mode === 'advanced') {
this.complete()
this.store.bump()
}
}).catch(() => undefined)
}
return this.valueCache.get(key) ?? []
},
})
this.candidate = 0
this.parse()
}
/** Turns possible-value labels typed in the text into ids (the server filters by id). */
async resolveLabels(filter: QueryFilter): Promise<QueryFilter> {
const visit = async (f: QueryFilter) => {
for (const c of f.criteria) {
const field = this.field(c.fieldName)
if (!field?.possibleValueSourceName) continue
const out: FilterValue[] = []
for (const v of c.values) {
if (typeof v !== 'string') {
out.push(v)
continue
}
const { option } = await resolvePossibleValue(this.store.client!.server, this.store.client!.fetch, { table: this.meta.name }, field.name, v, { fieldType: field.type })
out.push(option ? String(option.id) : v)
}
c.values = out
}
for (const s of f.subFilters ?? []) await visit(s)
}
const copy = clone(filter)
await visit(copy)
return copy
}
apply(): void {
if (this.mode === 'advanced') {
const parsed = this.parse()
if (!parsed) return
this.store.filterHistory.add(this.text.value)
this.store.run(this.resolveLabels(parsed), (resolved) => {
this.store.closeOverlay(this)
this.onApply(resolved, this.sort ?? null)
})
return
}
const f = this.normalized()
if (printFilter(f)) this.store.filterHistory.add(printFilter(f))
this.store.closeOverlay(this)
this.onApply(f, this.sort ?? null)
}
onKey(key: Key): void {
if (key.name === 'escape') {
if (this.historySearch) {
this.historySearch = undefined
return
}
return this.store.closeOverlay(this)
}
if (key.ctrl && key.name === 's') return this.apply()
if (this.mode === 'guided') return this.guidedKey(key)
return this.advancedKey(key)
}
guidedKey(key: Key): void {
const rows = this.rows()
const r = rows[this.row]
const ch = charOf(key)
if (key.name === 'tab') return this.toAdvanced()
if (key.name === 'down') this.row = Math.min(rows.length - 1, this.row + 1)
else if (key.name === 'up') this.row = Math.max(0, this.row - 1)
else if (key.name === 'home') this.row = 0
else if (key.name === 'end') this.row = rows.length - 1
else if (key.name === 'return') {
if (r && (r.kind === 'add' || r.kind === 'addGroup' || r.kind === 'sort' || r.kind === 'join' || r.kind === 'group')) this.editRow(r)
else this.apply()
} else if (ch === 'e' || key.name === 'right' || key.name === 'space') {
if (r) this.editRow(r)
} else if (ch === 'a' || key.name === 'insert') this.editRow({ kind: 'add', group: r && 'group' in r ? r.group : -1 })
else if (ch === 'd' || key.name === 'delete' || key.name === 'backspace') {
if (r) this.removeRow(r)
this.row = Math.min(this.row, this.rows().length - 1)
} else if (ch === 'o') {
const g = r && 'group' in r && r.group >= 0 ? this.groupOf(r.group) : this.filter
g.booleanOperator = g.booleanOperator === 'AND' ? 'OR' : 'AND'
} else if (ch === 'g') this.editRow({ kind: 'addGroup' })
else if (ch === 's') this.editRow({ kind: 'sort' })
else if (ch === 'c' || (key.ctrl && key.name === 'u')) {
this.filter = { criteria: [], booleanOperator: 'AND' }
this.row = 0
}
}
advancedKey(key: Key): void {
if (this.historySearch) {
if (key.name === 'return' || (key.ctrl && key.name === 'r')) {
const hit = this.store.filterHistory.search(this.historySearch.value)[0]
if (hit) this.text.set(hit)
this.historySearch = undefined
this.complete()
return
}
this.historySearch.handle(key)
return
}
const cands = this.completion?.candidates ?? []
if (key.ctrl && key.name === 'r') {
this.historySearch = new LineEditor('')
return
}
if (key.name === 'tab') {
if (key.shift) return this.toGuided()
const c = cands[this.candidate]
if (c && this.completion) {
const next = applyCompletion(this.text.value, this.completion, c)
this.text.value = next.text
this.text.cursor = next.cursor
this.complete()
}
return
}
if (key.name === 'down' || key.name === 'up') {
if (cands.length && this.text.value.trim()) {
this.candidate = (this.candidate + (key.name === 'down' ? 1 : cands.length - 1)) % cands.length
} else {
const hist = this.store.filterHistory.entries()
this.historyIndex = Math.max(-1, Math.min(hist.length - 1, this.historyIndex + (key.name === 'up' ? 1 : -1)))
this.text.set(this.historyIndex >= 0 ? hist[this.historyIndex]! : '')
this.complete()
}
return
}
if (key.name === 'return') return this.apply()
if (key.ctrl && key.name === 'g') return this.toGuided()
if (this.text.handle(key)) this.complete()
}
render(sw: number, sh: number) {
const t = theme()
const g = glyphs()
const width = Math.min(sw - 2, 110)
const inner = width - 2
const height = Math.min(sh - 2, 30)
const lines: React.ReactNode[] = []
const tabs = <text key="tabs"><span fg={this.mode === 'guided' ? t.selFg : t.fg} bg={this.mode === 'guided' ? t.selBg : undefined}>{' Guided '}</span><span fg={t.dim}>{' │ '}</span><span fg={this.mode === 'advanced' ? t.selFg : t.fg} bg={this.mode === 'advanced' ? t.selBg : undefined}>{' Advanced '}</span><span fg={t.dim}>{pad(' Tab switches', inner - 23)}</span></text>
lines.push(tabs)
lines.push(<Line key="sp" text="" width={inner} />)
let grammar = ''
let cli = ''
if (this.mode === 'guided') {
const rows = this.rows()
const body = height - 9
const top = Math.max(0, Math.min(this.row - Math.floor(body / 2), rows.length - body))
rows.slice(top, top + body).forEach((r, i) => {
const sel = top + i === this.row
let text = ''
let fg = t.fg
if (r.kind === 'crit') {
const c = this.groupOf(r.group).criteria[r.index]!
const join = r.index > 0 ? this.groupOf(r.group).booleanOperator.padEnd(4) : ' '
text = `${r.group >= 0 ? ' ' : ''}${join}${this.critText(c)}`
} else if (r.kind === 'add') {
text = `${r.group >= 0 ? ' ' : ''} + add a condition${r.group >= 0 ? ' to this group' : ''}`
fg = t.acc
} else if (r.kind === 'group') {
text = `${r.group > 0 || this.filter.criteria.length ? this.filter.booleanOperator.padEnd(4) : ' '}( group, joined by ${this.groupOf(r.group).booleanOperator} ) d removes the group`
fg = t.acc2
} else if (r.kind === 'addGroup') {
text = ' + add a group (conditions in parentheses)'
fg = t.acc
} else if (r.kind === 'join') {
text = ` conditions are joined by ${this.filter.booleanOperator} (Enter or o switches to ${this.filter.booleanOperator === 'AND' ? 'OR' : 'AND'})`
fg = t.dim
} else {
text = ` sort: ${this.sort ? `${this.field(this.sort.field)?.label ?? this.sort.field} ${this.sort.asc ? 'ascending' : 'descending'}` : 'default order'}`
fg = t.dim
}
lines.push(<text key={`r${i}`} fg={sel ? t.selFg : fg} bg={sel ? t.selBg : undefined}>{pad(`${sel ? g.pointer : ' '}${text}`, inner)}</text>)
})
for (let i = lines.length; i < body + 2; i++) lines.push(<Line key={`p${i}`} text="" width={inner} />)
grammar = printFilter(this.normalized())
lines.push(<Line key="help" text=" a add · e edit · d remove · o AND/OR · g group · s sort · c clear · Enter applies · Esc cancels" width={inner} fg={t.dim} />)
} else {
lines.push(<box key="ed" paddingLeft={1}><EditorLine editor={this.text} width={inner - 2} placeholder="lastName STARTS_WITH S AND (birthDate > now(-40y) OR email IS_BLANK)" /></box>)
if (this.historySearch) lines.push(<text key="hs" fg={t.acc2}>{pad(` history search: ${this.historySearch.value}_ → ${this.store.filterHistory.search(this.historySearch.value)[0] ?? '(no match)'}`, inner)}</text>)
else if (this.parseError) {
const at = this.parseError.at
lines.push(<text key="caret" fg={t.err}>{pad(at !== undefined ? ' ' + ' '.repeat(at) + '^' : '', inner)}</text>)
} else lines.push(<Line key="caret" text="" width={inner} />)
const cands = this.completion?.candidates ?? []
const body = height - 11
const ctop = Math.max(0, Math.min(this.candidate - Math.floor(body / 2), cands.length - body))
lines.push(<Line key="ex" text={` expecting ${this.completion?.expecting ?? 'field'}${this.completion?.field ? ` (${this.completion.field})` : ''} · Tab accepts · ↑↓ choose`} width={inner} fg={t.dim} />)
cands.slice(ctop, ctop + body).forEach((c, i) => {
const sel = ctop + i === this.candidate
lines.push(<text key={`c${i}`} fg={sel ? t.selFg : c.kind === 'keyword' ? t.acc2 : t.fg} bg={sel ? t.selBg : undefined}>{pad(` ${pad(c.label, 28)} ${c.detail ?? ''}`, inner)}</text>)
})
for (let i = Math.min(body, cands.length - ctop); i < body; i++) lines.push(<Line key={`q${i}`} text="" width={inner} />)
const parsed = this.parseError ? undefined : this.parse()
grammar = this.text.value.trim()
lines.push(<text key="pv" fg={this.parseError ? t.err : t.ok}>{pad(this.parseError ? ` ${trunc(this.parseError.message, inner - 2)}` : ` ${parsed ? describeFilter(parsed) : ''}`, inner)}</text>)
lines.push(<Line key="help" text=" Enter applies · Shift+Tab or Ctrl+G guided · Ctrl+R history · ↑↓ history when empty · Esc cancels" width={inner} fg={t.dim} />)
}
cli = `qqq table query ${this.meta.name}${grammar ? ` -g ${shellQuote(grammar)}` : ''}${this.sort ? ` -o ${this.sort.asc ? '' : '-'}${this.sort.field}` : ''}`
lines.push(<text key="gram" fg={t.acc}>{pad(` ${trunc(grammar || '(no criteria: every record)', inner - 2)}`, inner)}</text>)
lines.push(<text key="cli" fg={t.dim}>{pad(` ${trunc(cli, inner - 2)}`, inner)}</text>)
return <Frame title={`Filter ${this.meta.label}`} width={width} height={lines.length + 2} screenWidth={sw} screenHeight={sh} top={1}>{lines}</Frame>
}
}
export { printCriterion, wrap, FilterSyntaxError }
+27
View File
@@ -0,0 +1,27 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { ReactNode } from 'react'
import { glyphs, theme } from '../theme'
/** A centred modal frame drawn over the screen. */
export function Frame(props: { title: string; width: number; height?: number; screenWidth: number; screenHeight: number; children: ReactNode; top?: number }) {
const t = theme()
const left = Math.max(0, Math.floor((props.screenWidth - props.width) / 2))
const top = props.top ?? Math.max(1, Math.floor((props.screenHeight - (props.height ?? 10)) / 3))
return (
<box position="absolute" left={left} top={top} width={props.width} {...(props.height ? { height: props.height } : {})} flexDirection="column"
border borderStyle="double" borderColor={t.focusBorder} {...(glyphs().border ? { customBorderChars: glyphs().border } : {})}
backgroundColor={t.overlayBg} title={` ${props.title} `} zIndex={20}>
{props.children}
</box>
)
}
/** One line of text with a background, padded to the width. */
export function Line(props: { text: string; width: number; fg?: string; bg?: string; bold?: boolean }) {
const text = props.text.length >= props.width ? props.text.slice(0, props.width) : props.text + ' '.repeat(props.width - props.text.length)
return <text fg={props.fg ?? theme().fg} bg={props.bg}>{props.bold ? <strong>{text}</strong> : text}</text>
}
+96
View File
@@ -0,0 +1,96 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { getDependencies, type FormSession, type PossibleValue } from '@qrun-io/qqq-core'
import type { Key } from '../keys'
import { LineEditor } from '../line-editor'
import type { AppStore } from '../store'
import { theme } from '../theme'
import type { Overlay } from '../types'
import { errorText, pad } from '../util/text'
import { EditorLine, overlayId } from './basic'
import { Frame, Line } from './frame'
/**
* A possible-value picker: searches the field's choices on the server as you type, honouring the
* values of the fields it depends on (contingent fields narrow: country, then state, then city).
*/
export class PickerOverlay implements Overlay {
readonly id = overlayId('picker')
readonly search: LineEditor
options: PossibleValue[] = []
index = 0
loading = false
error?: string
private timer?: ReturnType<typeof setTimeout>
private generation = 0
constructor(private store: AppStore, private session: FormSession, readonly field: string, private onPick: (option: PossibleValue | null) => void, initial = '') {
this.search = new LineEditor(initial)
this.fetch()
}
fetch(): void {
const gen = ++this.generation
this.loading = true
this.session.options(this.field, this.search.value, 100).then((options) => {
if (gen !== this.generation) return
this.options = options
this.index = Math.min(this.index, Math.max(0, options.length - 1))
this.loading = false
this.error = undefined
this.store.bump()
}).catch((e) => {
if (gen !== this.generation) return
this.loading = false
this.error = errorText(e)
this.store.bump()
})
}
onKey(key: Key): void {
if (key.name === 'escape') return this.store.closeOverlay(this)
if (key.name === 'down') this.index = Math.min(this.options.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(this.options.length - 1, this.index + 10)
else if (key.name === 'pageup') this.index = Math.max(0, this.index - 10)
else if (key.name === 'return') {
const o = this.options[this.index]
if (!o) return
this.store.closeOverlay(this)
this.onPick(o)
} else if (key.ctrl && key.name === 'd') {
this.store.closeOverlay(this)
this.onPick(null)
} else if (this.search.handle(key)) {
this.index = 0
clearTimeout(this.timer)
this.timer = setTimeout(() => this.fetch(), 150)
}
}
render(sw: number, sh: number) {
const t = theme()
const f = this.session.fields[this.field]!
const deps = getDependencies(f).filter((d) => this.session.values[d] !== undefined && this.session.values[d] !== null && this.session.values[d] !== '')
const narrowed = deps.map((d) => `${this.session.fields[d]?.label ?? d} = ${this.session.displayValues[d] ?? String(this.session.values[d])}`).join(', ')
const width = Math.min(sw - 4, 70)
const rows = Math.max(4, Math.min(16, sh - 12))
const top = Math.max(0, Math.min(this.index - Math.floor(rows / 2), this.options.length - rows))
const shown = this.options.slice(top, top + rows)
return (
<Frame title={`Choose ${f.label}`} width={width} height={rows + 6} screenWidth={sw} screenHeight={sh}>
<box paddingLeft={1}><EditorLine editor={this.search} width={width - 4} placeholder="type to search" /></box>
<Line text={narrowed ? ` narrowed by ${narrowed}` : ''} width={width - 2} fg={t.dim} />
{shown.map((o, i) => {
const sel = top + i === this.index
return <text key={String(o.id)} fg={sel ? t.selFg : t.fg} bg={sel ? t.selBg : undefined}>{pad(` ${o.label}`, width - 2)}</text>
})}
{Array.from({ length: rows - shown.length }, (_, i) => <Line key={`e${i}`} text={i === 0 && !shown.length ? (this.error ? ` ${this.error}` : this.loading ? ' searching…' : ' (no choices)') : ''} width={width - 2} fg={this.error ? t.err : t.dim} />)}
<Line text={` ${this.options.length} shown · ↑↓ Enter picks · Ctrl+D clears · Esc`} width={width - 2} fg={t.dim} />
</Frame>
)
}
}
+131
View File
@@ -0,0 +1,131 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { readViewSettings, writeViewSettings, type ViewSettings } from '@qrun-io/qqq-core'
import type { TableMetaData } from '../client'
import type { Key } from '../keys'
import { charOf } from '../keys'
import type { AppStore } from '../store'
import { glyphs, theme } from '../theme'
import type { Overlay } from '../types'
import { pad } from '../util/text'
import { PromptOverlay, overlayId } from './basic'
import { Frame, Line } from './frame'
/**
* How a grid is shown, saved per table in the local config file: which columns and in what order,
* column widths, fit to content or fill the width, zebra stripes, the header rule, density.
*/
export class GridViewSettingsOverlay implements Overlay {
readonly id = overlayId('view')
view: ViewSettings
order: string[]
visible: Set<string>
row = 0
constructor(private store: AppStore, readonly meta: TableMetaData, readonly key: string, current: string[], private onDone: () => void) {
this.view = readViewSettings(key)
const all = Object.values(meta.fields).filter((f) => !f.isHidden).map((f) => f.name)
this.order = [...current, ...all.filter((n) => !current.includes(n))]
this.visible = new Set(current)
}
options(): Array<{ label: string; value: string; toggle: () => void }> {
const t = theme()
return [
{ label: 'Width use', value: this.view.fit === 'fill' ? 'fill the terminal width' : 'fit to content', toggle: () => { this.view.fit = this.view.fit === 'fill' ? 'content' : 'fill' } },
{ label: 'Zebra stripes', value: (this.view.zebra ?? t.zebraByDefault) ? 'on' : 'off', toggle: () => { this.view.zebra = !(this.view.zebra ?? t.zebraByDefault) } },
{ label: 'Header rule', value: (this.view.headerRule ?? true) ? 'on' : 'off', toggle: () => { this.view.headerRule = !(this.view.headerRule ?? true) } },
{ label: 'Density', value: this.view.density === 'comfortable' ? 'comfortable (blank line between rows)' : 'compact', toggle: () => { this.view.density = this.view.density === 'comfortable' ? 'compact' : 'comfortable' } },
]
}
save(): void {
const columns = this.order.filter((n) => this.visible.has(n))
writeViewSettings(this.key, { ...this.view, columns: columns.length ? columns : undefined })
this.store.closeOverlay(this)
this.onDone()
}
onKey(key: Key): void {
const opts = this.options()
const total = this.order.length + opts.length
const ch = charOf(key)
const onColumn = this.row < this.order.length
const name = this.order[this.row]
const move = (dir: number) => {
const j = this.row + dir
if (!onColumn || j < 0 || j >= this.order.length) return
;[this.order[this.row], this.order[j]] = [this.order[j]!, this.order[this.row]!]
this.row = j
}
const width = (delta: number) => {
if (!name) return
const w = this.view.widths?.[name] ?? Math.max(this.meta.fields[name]!.label.length + 1, 8)
this.view.widths = { ...(this.view.widths ?? {}), [name]: Math.max(3, Math.min(120, w + delta)) }
}
if (key.name === 'escape') return this.store.closeOverlay(this)
if (key.name === 'return' || (key.ctrl && key.name === 's')) return this.save()
if (key.shift && key.name === 'up') return move(-1)
if (key.shift && key.name === 'down') return move(1)
if (key.name === 'down') this.row = Math.min(total - 1, this.row + 1)
else if (key.name === 'up') this.row = Math.max(0, this.row - 1)
else if (key.name === 'pagedown') this.row = Math.min(total - 1, this.row + 10)
else if (key.name === 'pageup') this.row = Math.max(0, this.row - 10)
else if (key.name === 'space') {
if (onColumn && name) {
if (this.visible.has(name)) this.visible.delete(name)
else this.visible.add(name)
} else opts[this.row - this.order.length]?.toggle()
} else if (ch === '+' || ch === ']') move(1)
else if (ch === '-' || ch === '[') move(-1)
else if (ch === '>' || key.name === 'right') width(1)
else if (ch === '<' || key.name === 'left') width(-1)
else if (ch === 'a' && name) {
const widths = { ...(this.view.widths ?? {}) }
delete widths[name]
this.view.widths = widths
} else if (ch === 'w' && name) {
this.store.open(new PromptOverlay(this.store, `Width of ${this.meta.fields[name]!.label}`, 'Cells (3 to 120); empty fits the column to its content.', (v) => {
const widths = { ...(this.view.widths ?? {}) }
if (!v.trim()) delete widths[name]
else if (/^\d+$/.test(v.trim())) widths[name] = Math.max(3, Math.min(120, Number(v.trim())))
this.view.widths = widths
}, String(this.view.widths?.[name] ?? '')))
} else if (ch === 'r') {
this.view = {}
this.visible = new Set(this.order.slice(0, 0))
writeViewSettings(this.key, undefined)
this.store.closeOverlay(this)
this.onDone()
}
}
render(sw: number, sh: number) {
const t = theme()
const g = glyphs()
const width = Math.min(sw - 4, 80)
const inner = width - 2
const opts = this.options()
const items = [
...this.order.map((n, i) => ({ text: `${this.visible.has(n) ? g.check : g.uncheck} ${pad(this.meta.fields[n]!.label, 28)} ${this.view.widths?.[n] ? `width ${this.view.widths[n]}` : 'auto width'}`, idx: i })),
...opts.map((o, i) => ({ text: ` ${pad(o.label, 28)} ${o.value}`, idx: this.order.length + i })),
]
const rows = Math.max(5, Math.min(items.length, sh - 10))
const top = Math.max(0, Math.min(this.row - Math.floor(rows / 2), items.length - rows))
return (
<Frame title={`View settings · ${this.meta.label}`} width={width} height={rows + 5} screenWidth={sw} screenHeight={sh}>
{items.slice(top, top + rows).map((it) => {
const sel = it.idx === this.row
const isOpt = it.idx >= this.order.length
return <text key={it.idx} fg={sel ? t.selFg : isOpt ? t.acc : t.fg} bg={sel ? t.selBg : undefined}>{pad(` ${it.text}`, inner)}</text>
})}
<Line text="" width={inner} />
<Line text=" Space on/off · Shift+↑↓ or +/- move · ←→ width · w type width · a auto width" width={inner} fg={t.dim} />
<Line text=" r reset to defaults · Enter saves (kept for this table) · Esc cancels" width={inner} fg={t.dim} />
</Frame>
)
}
}
+99
View File
@@ -0,0 +1,99 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Collaboration presence: "N others viewing" for a screen scope (`table:person`, `record:person:3`).
* The interface the TUI draws from; the collaboration extension's kernel hook (presence over the
* realtime stream, being built on another branch) plugs in through `setPresenceSource`. Without
* the extension the source is a no-op and the title bar slot and footer segment stay empty.
*/
import { useSyncExternalStore } from 'react'
import { Collaboration, type ExtensionRegistry, type PresenceHandle } from '@qrun-io/qqq-core'
import type { TuiClient } from './client'
export interface PresencePerson {
name: string
since?: string
}
export interface Presence {
count: number
people: PresencePerson[]
}
export interface PresenceSource {
/** Current presence of a scope. */
get(scope: string): Presence
/** Subscribes to changes; returns the unsubscribe function. */
subscribe(listener: () => void): () => void
/** Announces that this client views a scope (and stops for the previous one). */
enter?(scope: string | undefined): void
}
const NONE: Presence = { count: 0, people: [] }
const noop: PresenceSource = {
get: () => NONE,
subscribe: () => () => undefined,
}
let source: PresenceSource = noop
export function setPresenceSource(next: PresenceSource | undefined): void {
source = next ?? noop
}
export function presenceSource(): PresenceSource {
return source
}
/** React hook: presence of a scope, updated live (empty without the collaboration extension). */
export function usePresence(scope: string | undefined): Presence {
return useSyncExternalStore(source.subscribe, () => (scope ? source.get(scope) : NONE), () => NONE)
}
/** Title-bar text for presence, empty when nobody else is there. */
export function presenceText(p: Presence): string {
if (!p.count) return ''
const names = p.people.slice(0, 3).map((x) => x.name).join(', ')
return `${p.count} other${p.count === 1 ? '' : 's'} viewing${names ? `: ${names}${p.count > 3 ? ', …' : ''}` : ''}`
}
/**
* Presence from the kernel's collaboration module: joins the scope of the screen on top (a lease
* renewed by the kernel), leaves the previous one. Others only: this client is not counted.
*/
export function collaborationPresence(registry: ExtensionRegistry, client: TuiClient, selfName?: string): PresenceSource | undefined {
const collab = new Collaboration(registry, { call: (id, input) => client.call(id, input) })
if (!collab.available) return undefined
const listeners = new Set<() => void>()
let current: { scope: string; handle?: PresenceHandle; value: Presence } | undefined
const notify = () => { for (const l of listeners) l() }
return {
get: (scope) => (current?.scope === scope ? current.value : NONE),
subscribe: (l) => {
listeners.add(l)
return () => listeners.delete(l)
},
enter: (scope) => {
if (current?.scope === scope) return
void current?.handle?.stop()
current = undefined
if (!scope) return notify()
const [kind, table, ...pk] = scope.split(':')
if (!table || (kind !== 'table' && kind !== 'record')) return
const entry: { scope: string; handle?: PresenceHandle; value: Presence } = { scope, value: NONE }
current = entry
collab.joinPresence(kind === 'record' ? { table, pk: pk.join(':') } : { table }, {
onChange: (state) => {
const people = state.people.filter((p) => p.name !== selfName).map((p) => ({ name: p.name, since: p.since.toISOString() }))
entry.value = { count: people.length, people }
notify()
},
}).then((h) => { entry.handle = h }).catch(() => undefined)
},
}
}
+189
View File
@@ -0,0 +1,189 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { readViewSettings, widgetToBlocks, writeViewSettings, type RenderBlock } from '@qrun-io/qqq-core'
import type { AppTreeNode } from '../client'
import type { Key } from '../keys'
import { openApp, openProcess, openTable } from '../nav'
import { ChoiceOverlay } from '../overlays/basic'
import type { AppStore } from '../store'
import { glyphs, theme } from '../theme'
import type { FKeyLabels, MenuEntry, Overlay, Screen } from '../types'
import { errorText, num, pad } from '../util/text'
import { blockLines } from '../widgets'
import { ListModel, Panel, type Row } from './list'
interface AppMeta {
name: string
label: string
widgets?: string[]
sections?: Array<{ name: string; label: string; role?: string; tables?: string[]; processes?: string[]; reports?: string[]; widgets?: string[] }>
children?: AppTreeNode[]
}
/** An app's home: its widgets drawn from render blocks, then its sections (tables with counts, processes, reports, sub-apps). */
export class AppHomeScreen implements Screen {
readonly kind = 'app'
readonly id: string
readonly list = new ListModel()
widgets = new Map<string, RenderBlock[] | { error: string }>()
counts = new Map<string, number | string>()
width = 100
constructor(private store: AppStore, readonly appName: string) {
this.id = `app:${appName}`
this.load()
}
get app(): AppMeta | undefined {
return (this.store.meta?.apps as Record<string, AppMeta> | undefined)?.[this.appName]
}
title(): string {
return this.app?.label ?? this.appName
}
helpTopic() {
return { kind: 'app' as const, name: this.appName }
}
fkeys(): FKeyLabels {
return { 3: 'Open', 4: null, 6: null, 7: null, 8: null }
}
widgetOrder(): string[] {
const declared = this.app?.widgets ?? []
const saved = readViewSettings(this.id).widgetOrder ?? []
return [...saved.filter((w) => declared.includes(w)), ...declared.filter((w) => !saved.includes(w))]
}
load(): void {
const client = this.store.client
if (!client) return
for (const w of this.app?.widgets ?? []) {
client.widget(w).then((data) => this.widgets.set(w, widgetToBlocks(data))).catch((e) => this.widgets.set(w, { error: errorText(e) })).finally(() => { this.build(); this.store.bump() })
}
for (const t of this.tableNames()) {
client.count(t, { criteria: [], booleanOperator: 'AND' }).then((n) => this.counts.set(t, n)).catch(() => this.counts.set(t, '?')).finally(() => { this.build(); this.store.bump() })
}
this.build()
}
tableNames(): string[] {
const names = new Set<string>()
for (const s of this.app?.sections ?? []) for (const t of s.tables ?? []) names.add(t)
for (const c of this.app?.children ?? []) if (c.type === 'TABLE') names.add(c.name)
return [...names]
}
build(): void {
const t = theme()
const g = glyphs()
const meta = this.store.meta
const rows: Row[] = []
const width = this.width
for (const w of this.widgetOrder()) {
const data = this.widgets.get(w)
const label = (meta?.widgets as Record<string, { label?: string }> | undefined)?.[w]?.label ?? w
if (!data) {
rows.push({ text: label, fg: t.acc, bold: true })
rows.push({ text: ' loading…', fg: t.dim })
} else if ('error' in data) {
rows.push({ text: label, fg: t.acc, bold: true })
rows.push({ text: ` ${data.error}`, fg: t.err })
} else {
const heading = data[0]?.kind === 'heading' ? data[0] : undefined
rows.push({ text: heading ? heading.text : label, fg: t.acc, bold: true })
if (heading?.sub) rows.push({ text: ` ${heading.sub}`, fg: t.dim })
rows.push(...blockLines(heading ? data.slice(1) : data, width - 4).map((l) => ({ ...l, text: ' ' + l.text })))
}
rows.push({ text: g.rule.repeat(Math.min(width - 2, 80)), fg: t.border })
}
const sections = this.app?.sections?.length ? this.app.sections : [{ name: this.appName, label: this.title(), tables: this.app?.children?.filter((c) => c.type === 'TABLE').map((c) => c.name), processes: this.app?.children?.filter((c) => c.type === 'PROCESS').map((c) => c.name) }]
for (const s of sections) {
rows.push({ text: s.label, fg: t.acc, bold: true })
for (const name of s.tables ?? []) {
const label = meta?.tables?.[name]?.label ?? name
const c = this.counts.get(name)
rows.push({ text: ` ${pad(label, 34)} ${typeof c === 'number' ? num(c) + ' records' : c ?? '…'}`, key: `t:${name}`, action: () => openTable(this.store, name) })
}
for (const name of s.processes ?? []) rows.push({ text: ` ${g.process} ${meta?.processes?.[name]?.label ?? name}`, key: `p:${name}`, action: () => openProcess(this.store, name) })
for (const name of s.reports ?? []) rows.push({ text: ` ${g.report} ${(meta?.reports as Record<string, { label?: string }> | undefined)?.[name]?.label ?? name} (run with :report run ${name})`, key: `r:${name}`, action: () => this.store.commands().commandLine(`report run ${name} --format csv`) })
}
const listed = new Set(sections.flatMap((s) => [...(s.tables ?? []), ...(s.processes ?? []), ...(s.reports ?? [])]))
const others = (this.app?.children ?? []).filter((c) => !listed.has(c.name))
if (others.length) {
rows.push({ text: 'More', fg: t.acc, bold: true })
for (const c of others) {
if (c.type === 'APP') rows.push({ text: ` ${g.app} ${c.label}`, key: `a:${c.name}`, action: () => openApp(this.store, c.name) })
else if (c.type === 'TABLE') rows.push({ text: ` ${c.label}`, key: `t:${c.name}`, action: () => openTable(this.store, c.name) })
else if (c.type === 'PROCESS') rows.push({ text: ` ${g.process} ${c.label}`, key: `p:${c.name}`, action: () => openProcess(this.store, c.name) })
}
}
if (!rows.length) rows.push({ text: '(this app has no widgets or sections)', fg: t.dim })
this.list.setRows(rows)
}
onKey(key: Key): boolean {
if (key.name === 'f3') {
this.list.current?.action?.()
return true
}
return this.list.onKey(key)
}
refresh(): void {
this.widgets.clear()
this.counts.clear()
this.load()
}
menu(): MenuEntry[] {
const out: MenuEntry[] = []
for (const r of this.list.rows) if (r.action && r.key) out.push({ section: 'Context', label: `Open ${r.text.trim().replace(/\s{2,}.*/, '')}`, detail: r.key.startsWith('t:') ? String(this.counts.get(r.key.slice(2)) ?? '') : undefined, run: r.action })
out.push({ section: 'Context', label: 'Refresh widgets and counts', key: 'F5', run: () => this.refresh() })
return out
}
viewSettings(): Overlay | undefined {
const order = this.widgetOrder()
if (!order.length) return undefined
return new WidgetOrderOverlay(this.store, this, order)
}
render(width: number, height: number, focused: boolean) {
if (width !== this.width) {
this.width = width
this.build()
}
return <Panel title={this.title()} width={width} height={height} focused={focused}>{this.list.view(width - 2, height - 2, focused)}</Panel>
}
}
/** View settings of an app home: widget order (Shift+↑↓ or +/- moves the selected widget). */
class WidgetOrderOverlay extends ChoiceOverlay<string> {
constructor(private s: AppStore, private screen: AppHomeScreen, private order: string[]) {
super(s, 'Widget order', order.map((w) => ({ label: w, value: w })), () => undefined, '+/- move · Enter saves')
}
override onKey(key: Key): void {
const move = (dir: number) => {
const i = this.index
const j = i + dir
if (j < 0 || j >= this.order.length) return
;[this.order[i], this.order[j]] = [this.order[j]!, this.order[i]!]
this.choices.splice(0, this.choices.length, ...this.order.map((w) => ({ label: w, value: w })))
this.index = j
}
if (key.sequence === '+' || (key.shift && key.name === 'down')) return move(1)
if (key.sequence === '-' || (key.shift && key.name === 'up')) return move(-1)
if (key.name === 'return') {
writeViewSettings(this.screen.id, { ...readViewSettings(this.screen.id), widgetOrder: this.order })
this.s.closeOverlay(this)
this.screen.build()
return
}
super.onKey(key)
}
}
+413
View File
@@ -0,0 +1,413 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { FormInputError, FormSession, ResultCursor, copyRecordValues, dateTimesToUtc, defaultTimeZone, isInstant, isMissingValue, utcToLocalInput, validateValue, type FieldMeta, type FieldValues } from '@qrun-io/qqq-core'
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
import type { TableMetaData } from '../client'
import type { Key } from '../keys'
import { charOf, isPrintable } from '../keys'
import { LineEditor } from '../line-editor'
import { replaceWithRecord } from '../nav'
import { EditorLine } from '../overlays/basic'
import { PickerOverlay } from '../overlays/picker'
import type { AppStore } from '../store'
import { glyphs, theme } from '../theme'
import type { FKeyLabels, Screen } from '../types'
import { errorText, pad, trunc } from '../util/text'
import { Panel } from './list'
import { orderedSections } from './record'
const TEXT_TYPES = new Set(['STRING', 'TEXT', 'HTML', 'PASSWORD', 'INTEGER', 'LONG', 'DECIMAL', 'DATE', 'DATE_TIME', 'TIME', 'BLOB'])
function hintFor(f: FieldMeta): string {
switch (f.type) {
case 'DATE': return 'YYYY-MM-DD'
case 'DATE_TIME': return `YYYY-MM-DD HH:MM (${defaultTimeZone()})`
case 'TIME': return 'HH:MM'
case 'INTEGER': case 'LONG': return 'whole number'
case 'DECIMAL': return 'number'
case 'BOOLEAN': return 'Space toggles'
case 'TEXT': case 'HTML': return 'Ctrl+E opens $EDITOR'
case 'BLOB': return 'file path'
default: return ''
}
}
function textOf(value: unknown): string {
return value === null || value === undefined ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value)
}
/**
* A form over a kernel FormSession: one editor per field type, a possible-value picker that narrows
* for contingent fields, local validation shown by the field, server fieldErrors mapped back to
* fields. Keys are read from the object's state (no React closures), so a burst of keys lands in
* the field that had focus when each key was read. Used by table forms and process steps.
*/
export class FormEditor {
index = 0
top = 0
editors = new Map<string, LineEditor>()
/** Bulk edit: the fields that will be applied. */
enabled = new Set<string>()
formError?: string
saving = false
readonly initial: FieldValues
constructor(private store: AppStore, readonly session: FormSession, readonly order: string[], readonly options: { bulkEdit?: boolean; readOnly?: string[] } = {}) {
this.initial = { ...session.values }
for (const name of order) {
const f = session.fields[name]
if (f && TEXT_TYPES.has(f.type) && !session.hasChoices(name)) this.editors.set(name, new LineEditor(textOf(session.values[name]), { masked: f.type === 'PASSWORD' }))
}
}
get names(): string[] {
return this.order.filter((n) => this.session.fields[n] && !this.session.fields[n]!.isHidden)
}
get current(): string | undefined {
return this.names[this.index]
}
dirty(): boolean {
return this.names.some((n) => textOf(this.valueFor(n)) !== textOf(this.initial[n]))
}
/** The value a field has now (the editor text for text fields not yet committed). */
valueFor(name: string): unknown {
const ed = this.editors.get(name)
return ed ? ed.value : this.session.values[name]
}
/** Writes a text editor's value into the session (behaviors, adjusters, local check). */
async commit(name: string | undefined): Promise<void> {
if (!name) return
const ed = this.editors.get(name)
if (!ed) return
const f = this.session.fields[name]!
if (textOf(this.session.values[name]) === ed.value && !(name in this.session.errors)) return
if (this.session.state(name).disabledBecause) return
try {
const result = await this.session.set(name, ed.value === '' ? null : ed.value)
for (const n of result.cleared) this.editors.get(n)?.set('')
if (this.options.bulkEdit && ed.value !== textOf(this.initial[name])) this.enabled.add(name)
const problem = validateValue(f, ed.value)
if (problem) this.session.errors[name] = `${problem}`
for (const [n, v] of Object.entries(this.session.values)) {
const other = this.editors.get(n)
if (other && n !== name && other.value !== textOf(v)) other.set(textOf(v))
}
} catch (e) {
this.session.errors[name] = e instanceof FormInputError ? e.message.replace(`${f.label}: `, '') : errorText(e)
}
}
move(delta: number): void {
const from = this.current
void this.commit(from).then(() => this.store.bump())
this.index = Math.max(0, Math.min(this.names.length - 1, this.index + delta))
}
/** Commits everything and validates; returns the problems (empty when the form can be sent). */
async check(): Promise<Record<string, string>> {
for (const n of this.names) await this.commit(n)
const only = this.options.bulkEdit ? [...this.enabled] : undefined
const problems = this.session.validate(only)
return problems
}
/** Maps a server validation error onto fields. */
absorbServerError(error: unknown): void {
if (error instanceof UiApiError && error.fieldErrors.length) {
for (const fe of error.fieldErrors) if (fe.field && this.session.fields[fe.field]) this.session.errors[fe.field] = fe.message
this.formError = error.message
const first = this.names.findIndex((n) => this.session.errors[n])
if (first >= 0) this.index = first
} else {
this.formError = errorText(error)
}
}
openPicker(name: string, initial = ''): void {
if (this.session.state(name).disabledBecause) return this.store.flash(`${this.session.fields[name]!.label}: ${this.session.state(name).disabledBecause}`)
this.store.open(new PickerOverlay(this.store, this.session, name, (option) => {
this.store.run(this.session.set(name, option === null ? null : String(option.id)).then((r) => {
if (option) this.session.displayValues[name] = option.label
for (const n of r.cleared) this.editors.get(n)?.set('')
delete this.session.errors[name]
if (this.options.bulkEdit) this.enabled.add(name)
if (option && this.current === name) this.index = Math.min(this.names.length - 1, this.index + 1)
}))
}, initial))
}
/** Handles a key; returns true when the form used it. Ctrl+S and Esc are left to the owner. */
onKey(key: Key): boolean {
const name = this.current
if (!name) return false
const f = this.session.fields[name]!
const readOnly = this.options.readOnly?.includes(name) || f.isEditable === false
if (key.name === 'tab') {
this.move(key.shift ? -1 : 1)
return true
}
switch (key.name) {
case 'down': this.move(1); return true
case 'up': this.move(-1); return true
case 'pagedown': this.move(8); return true
case 'pageup': this.move(-8); return true
default: break
}
if (key.ctrl && key.name === 't' && this.options.bulkEdit) {
if (this.enabled.has(name)) this.enabled.delete(name)
else this.enabled.add(name)
return true
}
if (readOnly) return key.name === 'return' ? (this.move(1), true) : false
if (this.session.hasChoices(name)) {
if (key.name === 'return' || key.name === 'space') {
this.openPicker(name)
return true
}
if (key.name === 'backspace' || key.name === 'delete') {
this.store.run(this.session.set(name, null).then((r) => { for (const n of r.cleared) this.editors.get(n)?.set('') }))
return true
}
if (isPrintable(key)) {
this.openPicker(name, charOf(key))
return true
}
return false
}
if (f.type === 'BOOLEAN') {
if (key.name === 'space' || key.name === 'return' || /^[yntf01]$/i.test(charOf(key))) {
const ch = charOf(key).toLowerCase()
const next = ch === 'y' || ch === 't' || ch === '1' ? true : ch === 'n' || ch === 'f' || ch === '0' ? false : !(this.session.values[name] === true)
this.store.run(this.session.set(name, next).then(() => { if (this.options.bulkEdit) this.enabled.add(name) }))
if (key.name === 'return') this.move(1)
return true
}
return false
}
const ed = this.editors.get(name)
if (!ed) return false
if (key.ctrl && key.name === 'e' && (f.type === 'TEXT' || f.type === 'HTML')) {
this.store.run(this.store.editExternal(ed.value), (text) => { ed.set(text.replace(/\n$/, '')); void this.commit(name) })
return true
}
if (key.name === 'return') {
this.move(1)
return true
}
if (ed.handle(key)) {
delete this.session.errors[name]
return true
}
return false
}
render(width: number, height: number, focused: boolean) {
const t = theme()
const g = glyphs()
const names = this.names
const labelWidth = Math.min(28, Math.max(8, ...names.map((n) => this.session.fields[n]!.label.length + 2)))
const editorWidth = Math.max(10, Math.min(60, width - labelWidth - 6 - (this.options.bulkEdit ? 4 : 0) - Math.min(36, Math.floor(width / 3))))
const msgWidth = Math.max(0, width - labelWidth - editorWidth - 6 - (this.options.bulkEdit ? 4 : 0))
const rows = Math.max(1, height - (this.formError ? 1 : 0))
if (this.index < this.top) this.top = this.index
if (this.index >= this.top + rows) this.top = this.index - rows + 1
const lines = names.slice(this.top, this.top + rows).map((name, i) => {
const f = this.session.fields[name]!
const idx = this.top + i
const sel = idx === this.index && focused
const state = this.session.state(name)
const error = this.session.errors[name]
const required = f.isRequired || f.validation?.required ? '*' : ' '
const disabled = Boolean(state.disabledBecause) || f.isEditable === false || this.options.readOnly?.includes(name)
const bulk = this.options.bulkEdit ? (this.enabled.has(name) ? g.check : g.uncheck) + ' ' : ''
const ed = this.editors.get(name)
let editor: React.ReactNode
if (ed && !disabled) editor = <EditorLine editor={ed} width={editorWidth} focused={sel} />
else {
let shown: string
if (f.type === 'BOOLEAN') shown = this.session.values[name] === true ? `${g.check} yes` : this.session.values[name] === false ? `${g.uncheck} no` : `${g.uncheck} (not set)`
else if (this.session.hasChoices(name)) shown = isMissingValue(this.session.values[name]) ? (state.disabledBecause ? '' : '(choose…)') : `${this.session.displayValues[name] ?? textOf(this.session.values[name])}`
else shown = ed ? ed.value : textOf(this.session.values[name])
const pickGlyph = this.session.hasChoices(name) && !disabled ? ' ▾' : ''
editor = <text fg={disabled ? t.dim : sel ? t.selFg : t.inputFg} bg={disabled ? undefined : sel ? t.selBg : t.inputBg}>{pad(trunc(shown, editorWidth - pickGlyph.length) + pickGlyph, editorWidth)}</text>
}
const msg = error ? `! ${error}` : state.disabledBecause ? state.disabledBecause : idx === this.index ? (this.session.hasChoices(name) ? 'Enter or type to choose · Backspace clears' : hintFor(f)) : ''
return (
<box key={name} flexDirection="row" height={1}>
<text fg={sel ? t.acc2 : disabled ? t.dim : t.fg}>{`${sel ? g.pointer : ' '}${bulk}${pad(f.label, labelWidth - 1)}${required} `}</text>
{editor}
<text fg={error ? t.err : t.dim}>{pad(msgWidth > 2 ? ' ' + trunc(msg, msgWidth - 1) : '', msgWidth + 1)}</text>
</box>
)
})
return (
<>
{lines}
{Array.from({ length: Math.max(0, rows - lines.length) }, (_, i) => <text key={`e${i}`}>{pad('', width)}</text>)}
{this.formError ? <text fg={t.err}>{pad(` ${this.formError}`, width)}</text> : null}
</>
)
}
}
export interface FormOptions {
mode: 'create' | 'edit' | 'copy'
primaryKey?: string
initial?: FieldValues
/** The result the record came from (edit from a grid): saving returns there. */
cursor?: ResultCursor
}
/** Create, edit or copy a table record. Ctrl+S saves; Esc cancels and asks when there are unsaved changes. */
export class FormScreen implements Screen {
readonly kind = 'form'
readonly id: string
meta?: TableMetaData
editor?: FormEditor
error?: string
originals: FieldValues = {}
constructor(private store: AppStore, readonly tableName: string, readonly options: FormOptions) {
this.id = `form:${tableName}:${options.mode}:${Date.now()}`
store.run(this.init())
}
async init(): Promise<void> {
const client = this.store.client!
try {
const meta = await client.tableMeta(this.tableName)
this.meta = meta
let initial: FieldValues = { ...(this.options.initial ?? {}) }
const display: Record<string, string> = {}
if (this.options.mode !== 'create' && this.options.primaryKey !== undefined) {
const rec = await client.record(this.tableName, this.options.primaryKey)
initial = this.options.mode === 'copy' ? copyRecordValues(meta, rec) as FieldValues : { ...rec.values }
Object.assign(display, rec.displayValues ?? {})
}
// DATE_TIME values are edited in the local zone and sent back as UTC
this.originals = { ...initial }
for (const f of Object.values(meta.fields)) if (f.type === 'DATE_TIME' && isInstant(initial[f.name])) initial[f.name] = utcToLocalInput(String(initial[f.name]), defaultTimeZone())
const fields = Object.values(meta.fields) as FieldMeta[]
const session = new FormSession({ baseUrl: client.server, fetch: client.fetch, scope: { table: this.tableName }, fields, initialValues: initial, tableMeta: meta as never, exclude: this.options.mode === 'edit' ? [] : [meta.primaryKeyField] })
for (const [k, v] of Object.entries(display)) if (session.hasChoices(k) && !isMissingValue(initial[k])) session.displayValues[k] = v
// labels for prefilled possible values (create child: the parent's id)
for (const [k, v] of Object.entries(this.options.initial ?? {})) {
if (session.hasChoices(k) && !isMissingValue(v) && !session.displayValues[k]) {
try {
session.displayValues[k] = (await session.resolveChoice(k, String(v))).label
} catch {
// keep the id
}
}
}
await session.load()
const editable = new Set(session.editable.map((f) => f.name))
const order: string[] = []
for (const s of orderedSections(meta)) for (const n of s.fieldNames ?? []) if (editable.has(n) && !order.includes(n)) order.push(n)
for (const n of session.order) if (editable.has(n) && !order.includes(n)) order.push(n)
this.editor = new FormEditor(this.store, session, order)
} catch (e) {
this.error = errorText(e)
}
}
title(): string {
const verb = this.options.mode === 'create' ? 'New' : this.options.mode === 'edit' ? 'Edit' : 'Copy'
return `${verb} ${this.meta?.label ?? this.tableName}${this.options.mode === 'edit' ? ` ${this.options.primaryKey}` : ''}`
}
presenceScope(): string | undefined {
return this.options.mode === 'edit' ? `record:${this.tableName}:${this.options.primaryKey}` : undefined
}
capturesText(): boolean {
return true
}
dirty(): boolean {
return this.editor?.dirty() ?? false
}
fkeys(): FKeyLabels {
return { 3: null, 4: null, 6: null, 7: null, 8: null, 5: null }
}
cancel(): void {
if (this.dirty()) this.store.confirm('Discard your changes?', () => this.store.back())
else this.store.back()
}
save(): void {
const editor = this.editor
const meta = this.meta
if (!editor || !meta || editor.saving) return
editor.saving = true
editor.formError = undefined
const client = this.store.client!
const work = async () => {
const problems = await editor.check()
if (Object.keys(problems).length) {
editor.formError = `${Object.keys(problems).length} field${Object.keys(problems).length === 1 ? '' : 's'} need attention`
editor.index = Math.max(0, editor.names.findIndex((n) => problems[n]))
return
}
try {
if (this.options.mode === 'edit') {
const changed = editor.names.filter((n) => textOf(editor.session.values[n]) !== textOf(editor.initial[n]))
if (!changed.length) {
this.store.flash('nothing changed')
this.store.back()
return
}
const values = dateTimesToUtc(Object.values(editor.session.fields), Object.fromEntries(changed.map((n) => [n, editor.session.values[n] ?? null])), { originals: this.originals })
await client.update(this.tableName, this.options.primaryKey, values)
this.store.flash(`saved ${meta.label} ${this.options.primaryKey}`)
this.store.back()
} else {
const record = await client.insert(this.tableName, dateTimesToUtc(Object.values(editor.session.fields), editor.session.submission()))
const pk = record.values?.[meta.primaryKeyField]
this.store.flash(`created ${meta.label} ${String(pk ?? '')}`)
replaceWithRecord(this.store, this.tableName, String(pk))
}
} catch (e) {
editor.absorbServerError(e)
}
}
work().catch((e) => editor.absorbServerError(e)).finally(() => {
editor.saving = false
this.store.bump()
})
}
onKey(key: Key): boolean {
if (key.ctrl && key.name === 's') {
this.save()
return true
}
if (key.name === 'escape') {
this.cancel()
return true
}
if (key.name === 'f5') return true
return this.editor?.onKey(key) ?? true
}
render(width: number, height: number, focused: boolean) {
const t = theme()
const footer = this.editor?.saving ? ' saving…' : ' Ctrl+S save · Esc cancel · Tab ↓ next · ↑ previous · Enter or typing opens a picker · * required'
return (
<Panel title={this.title()} width={width} height={height} focused={focused}>
{this.error ? <text fg={t.err}>{pad(` ${this.error}`, width - 2)}</text> : this.editor ? this.editor.render(width - 2, height - 3, focused) : <text fg={t.dim}>{pad(' loading…', width - 2)}</text>}
{this.error || !this.editor ? Array.from({ length: Math.max(0, height - 4) }, (_, i) => <text key={i}>{''}</text>) : null}
<text fg={t.dim}>{pad(footer, width - 2)}</text>
</Panel>
)
}
}
+120
View File
@@ -0,0 +1,120 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { readRecents } from '@qrun-io/qqq-core'
import type { AppTreeNode } from '../client'
import type { Key } from '../keys'
import { openApp, openRecord, openTable, openProcess } from '../nav'
import type { AppStore } from '../store'
import { glyphs, theme } from '../theme'
import type { FKeyLabels, MenuEntry, Screen } from '../types'
import { pad } from '../util/text'
import { ListModel, Panel, type Row } from './list'
/** Counts the tables and processes under an app node, recursively. */
export function countUnder(node: AppTreeNode): { tables: number; processes: number; reports: number; apps: number } {
const c = { tables: 0, processes: 0, reports: 0, apps: 0 }
for (const ch of node.children ?? []) {
if (ch.type === 'TABLE') c.tables++
else if (ch.type === 'PROCESS') c.processes++
else if (ch.type === 'REPORT') c.reports++
else if (ch.type === 'APP') {
c.apps++
const sub = countUnder(ch)
c.tables += sub.tables
c.processes += sub.processes
c.reports += sub.reports
}
}
return c
}
/** The landing screen: apps with counts, the server's totals, and recently viewed records. */
export class HomeScreen implements Screen {
readonly kind = 'home'
readonly id = 'home'
readonly list = new ListModel()
constructor(private store: AppStore) {
this.build()
}
title(): string {
return 'Home'
}
fkeys(): FKeyLabels {
return { 3: 'Open', 4: null, 6: null, 7: null, 8: null }
}
build(): void {
const t = theme()
const g = glyphs()
const meta = this.store.meta
const rows: Row[] = []
const tables = Object.values(meta?.tables ?? {}).filter((x) => !x.isHidden).length
const processes = Object.values(meta?.processes ?? {}).filter((x) => !x.isHidden).length
const apps = (meta?.appTree ?? []) as AppTreeNode[]
rows.push({ text: `${apps.length} apps · ${tables} tables · ${processes} processes · ${Object.keys(meta?.widgets ?? {}).length} widgets · UI API ${this.store.status.apiVersion}`, fg: t.dim })
rows.push({ text: '' })
rows.push({ text: 'Apps', fg: t.acc, bold: true })
for (const app of apps) {
const c = countUnder(app)
const parts = [c.tables && `${c.tables} tables`, c.processes && `${c.processes} processes`, c.reports && `${c.reports} reports`, c.apps && `${c.apps} apps`].filter(Boolean).join(' · ')
if (app.type === 'APP') rows.push({ text: ` ${g.app} ${pad(app.label, 30)} ${parts}`, key: `app:${app.name}`, action: () => openApp(this.store, app.name) })
else if (app.type === 'TABLE') rows.push({ text: ` ${pad(app.label, 30)} table`, key: `t:${app.name}`, action: () => openTable(this.store, app.name) })
else if (app.type === 'PROCESS') rows.push({ text: ` ${g.process} ${pad(app.label, 30)} process`, key: `p:${app.name}`, action: () => openProcess(this.store, app.name) })
}
let recents: ReturnType<typeof readRecents> = []
try {
recents = this.store.client ? readRecents(this.store.client.server).slice(0, 8) : []
} catch {
recents = []
}
if (recents.length) {
rows.push({ text: '' })
rows.push({ text: 'Recently viewed', fg: t.acc, bold: true })
for (const r of recents) {
const label = r.kind === 'record' ? `${meta?.tables?.[r.table ?? '']?.label ?? r.table} › ${r.label}` : r.label
rows.push({ text: ` ${pad(label, 50)} ${r.kind}`, key: `r:${r.kind}:${r.table}:${r.key}:${r.name}`, action: () => {
if (r.kind === 'record' && r.table) openRecord(this.store, r.table, r.key)
else if (r.kind === 'table' && r.table) openTable(this.store, r.table)
else if (r.kind === 'process' && r.name) openProcess(this.store, r.name)
} })
}
}
rows.push({ text: '' })
rows.push({ text: 'Enter opens · F2 or / goes anywhere by name · F9 menu · : command line (try :table query person -w "lastName STARTS_WITH S")', fg: t.dim })
this.list.setRows(rows)
}
onKey(key: Key): boolean {
if (key.name === 'f3') {
this.list.current?.action?.()
return true
}
return this.list.onKey(key)
}
resume(): void {
this.build()
}
refresh(): void {
this.store.run(this.store.client!.metaData(true).then((m) => {
this.store.meta = m
this.build()
}))
}
menu(): MenuEntry[] {
return ((this.store.meta?.appTree ?? []) as AppTreeNode[]).filter((a) => a.type === 'APP').map((a) => ({ section: 'Context' as const, label: `Open app ${a.label}`, run: () => openApp(this.store, a.name) }))
}
render(width: number, height: number, focused: boolean) {
const brand = (this.store.meta?.branding as { appName?: string } | undefined)?.appName ?? 'QQQ'
return <Panel title={brand} width={width} height={height} focused={focused}>{this.list.view(width - 2, height - 2, focused)}</Panel>
}
}
+89
View File
@@ -0,0 +1,89 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { ReactNode } from 'react'
import type { Key } from '../keys'
import { borderProps, theme } from '../theme'
import type { StyledLine } from '../widgets'
import { pad } from '../util/text'
/** A row of a scrolling list: a line, optionally selectable with an action. */
export interface Row extends StyledLine {
action?: () => void
/** Stable identity of a selectable row (kept across reloads). */
key?: string
}
/**
* A scrolling list of lines where some rows are selectable. The cursor moves between selectable
* rows; PgUp/PgDn/Home/End page; the viewport follows the cursor. State lives in the object.
*/
export class ListModel {
rows: Row[] = []
cursor = 0
top = 0
height = 10
setRows(rows: Row[]): void {
const key = this.rows[this.cursor]?.key
this.rows = rows
const again = key ? rows.findIndex((r) => r.key === key) : -1
if (again >= 0) this.cursor = again
else if (!rows[this.cursor]?.action) this.cursor = this.firstSelectable(0, 1) ?? 0
}
get current(): Row | undefined {
return this.rows[this.cursor]
}
private firstSelectable(from: number, dir: 1 | -1): number | undefined {
for (let i = from; i >= 0 && i < this.rows.length; i += dir) if (this.rows[i]!.action) return i
return undefined
}
/** Arrow, page and enter keys; returns whether handled. */
onKey(raw: Key): boolean {
const page = Math.max(1, this.height - 1)
// Space and Shift+Space page too (PgUp/PgDn may be taken by the terminal or tmux)
const key: Key = raw.name === 'space' ? { name: raw.shift ? 'pageup' : 'pagedown', sequence: '' } : raw
switch (key.name) {
case 'down': this.cursor = this.firstSelectable(this.cursor + 1, 1) ?? this.cursor; break
case 'up': this.cursor = this.firstSelectable(this.cursor - 1, -1) ?? this.cursor; break
case 'pagedown': this.cursor = this.firstSelectable(Math.min(this.rows.length - 1, this.cursor + page), 1) ?? this.firstSelectable(this.rows.length - 1, -1) ?? this.cursor; this.top = Math.min(Math.max(0, this.rows.length - this.height), this.top + page); break
case 'pageup': this.cursor = this.firstSelectable(Math.max(0, this.cursor - page), -1) ?? this.firstSelectable(0, 1) ?? this.cursor; this.top = Math.max(0, this.top - page); break
case 'home': this.cursor = this.firstSelectable(0, 1) ?? 0; this.top = 0; break
case 'end': this.cursor = this.firstSelectable(this.rows.length - 1, -1) ?? this.cursor; this.top = Math.max(0, this.rows.length - this.height); break
case 'return': this.current?.action?.(); break
default: return false
}
return true
}
/** Draws the visible rows. */
view(width: number, height: number, focused: boolean): ReactNode {
const t = theme()
this.height = height
if (this.cursor < this.top) this.top = this.cursor
if (this.cursor >= this.top + height) this.top = this.cursor - height + 1
this.top = Math.max(0, Math.min(this.top, Math.max(0, this.rows.length - height)))
const shown = this.rows.slice(this.top, this.top + height)
const lines = shown.map((r, i) => {
const sel = this.top + i === this.cursor && Boolean(r.action)
const text = pad(r.text, width)
return <text key={i} fg={sel ? (focused ? t.selFg : t.fg) : r.fg ?? t.fg} bg={sel ? (focused ? t.selBg : t.selInactiveBg) : r.bg}>{r.bold ? <strong>{text}</strong> : text}</text>
})
while (lines.length < height) lines.push(<text key={`pad${lines.length}`}>{pad('', width)}</text>)
return <>{lines}</>
}
}
/** A bordered panel with a title, sized to the screen area. */
export function Panel(props: { title: string; width: number; height: number; focused: boolean; children: ReactNode; bottomTitle?: string }) {
return (
<box flexDirection="column" width={props.width} height={props.height} {...borderProps(props.focused)} title={` ${props.title} `} {...(props.bottomTitle ? { bottomTitle: ` ${props.bottomTitle} ` } : {})}>
{props.children}
</box>
)
}
+90
View File
@@ -0,0 +1,90 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { Key } from '../keys'
import type { AppStore } from '../store'
import { theme } from '../theme'
import type { FKeyLabels, MenuEntry, Screen } from '../types'
import { pad } from '../util/text'
import { Panel } from './list'
/** The output of a `:` command run through the CLI in-process (runCli), scrollable. */
export class OutputScreen implements Screen {
readonly kind = 'output'
readonly id: string
lines: Array<{ text: string; err?: boolean }> = []
top = 0
height = 10
code?: number
running = true
constructor(private store: AppStore, readonly command: string) {
this.id = `output:${Date.now()}`
}
title(): string {
return `: ${this.command}`
}
append(text: string, err = false): void {
const parts = text.replace(/\r/g, '').split('\n')
if (parts[parts.length - 1] === '') parts.pop()
for (const p of parts) this.lines.push({ text: p, err })
this.store.bump()
}
done(code: number): void {
this.code = code
this.running = false
this.store.bump()
}
fkeys(): FKeyLabels {
return { 3: null, 4: null, 6: null, 7: null, 8: null, 5: 'Re-run' }
}
refresh(): void {
this.store.back()
void this.store.commands().run(this.command)
}
onKey(raw: Key): boolean {
const key: Key = raw.name === 'space' ? { name: raw.shift ? 'pageup' : 'pagedown', sequence: '' } : raw
const max = Math.max(0, this.lines.length - this.height)
switch (key.name) {
case 'down': this.top = Math.min(max, this.top + 1); return true
case 'up': this.top = Math.max(0, this.top - 1); return true
case 'pagedown': this.top = Math.min(max, this.top + this.height - 1); return true
case 'pageup': this.top = Math.max(0, this.top - this.height + 1); return true
case 'home': this.top = 0; return true
case 'end': this.top = max; return true
case 'right': case 'left': return true
case 'return': this.store.back(); return true
default: return false
}
}
menu(): MenuEntry[] {
return [
{ section: 'Context', label: 'Run the command again', key: 'F5', run: () => this.refresh() },
{ section: 'Context', label: 'Edit the command', run: () => this.store.commands().commandLine(this.command) },
]
}
render(width: number, height: number, focused: boolean) {
const t = theme()
this.height = height - 3
const shown = this.lines.slice(this.top, this.top + this.height)
const rows = shown.map((l, i) => <text key={i} fg={l.err ? t.err : t.fg}>{pad(l.text, width - 2)}</text>)
while (rows.length < this.height) rows.push(<text key={`p${rows.length}`}>{pad('', width - 2)}</text>)
const status = this.running ? 'running…' : `exit ${this.code}${this.code === 0 ? '' : ' (see the message above)'} · ${this.lines.length} lines · ↑↓ PgUp PgDn scroll · F5 again · Enter or Esc closes`
return (
<Panel title={`qqq ${this.command}`} width={width} height={height} focused={focused}>
{rows}
<text fg={this.code ? t.err : t.dim}>{pad(` ${status}`, width - 2)}</text>
</Panel>
)
}
}
+364
View File
@@ -0,0 +1,364 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { FormSession, ProcessError, runProcess, type FieldMeta, type FieldValues, type ProcessMeta, type ProcessStep, type QueryFilter } from '@qrun-io/qqq-core'
import type { Key } from '../keys'
import { charOf } from '../keys'
import type { AppStore } from '../store'
import { glyphs, theme } from '../theme'
import type { FKeyLabels, MenuEntry, Screen } from '../types'
import { wrap } from '../overlays/basic'
import { cell, errorText, pad, trunc } from '../util/text'
import { FormEditor } from './form'
import { Panel } from './list'
class Cancelled extends Error {
constructor() {
super('cancelled')
}
}
export interface ProcessOptions {
recordIds?: string[]
filter?: QueryFilter
values?: FieldValues
}
type Phase = 'starting' | 'form' | 'review' | 'running' | 'done' | 'error' | 'cancelled'
/**
* A process wizard over the kernel's runProcess: each step's form through a FormSession (the
* provider waits for Ctrl+S), the validation review choice, progress with N/M and elapsed time,
* summary results, and Esc to cancel (POST cancel on the server).
*/
export class ProcessScreen implements Screen {
readonly kind = 'process'
readonly id: string
meta?: ProcessMeta
phase: Phase = 'starting'
step?: ProcessStep
stepValues: FieldValues = {}
editor?: FormEditor
reviewChoice = 0
progress: { message?: string; current?: number; total?: number } = {}
stepStarted = Date.now()
started = Date.now()
finished?: number
result?: { values: FieldValues; lastStep?: ProcessStep }
error?: string
log: string[] = []
processUUID?: string
private cancelled = false
private pending?: { resolve: (v: FieldValues) => void; reject: (e: Error) => void }
constructor(private store: AppStore, readonly processName: string, readonly options: ProcessOptions = {}) {
this.id = `process:${processName}:${Date.now()}`
this.start()
}
title(): string {
return this.meta?.label ?? (this.store.meta?.processes?.[this.processName]?.label ?? this.processName)
}
helpTopic() {
return { kind: 'process' as const, name: this.processName }
}
cliEquivalent(): string {
return `process run ${this.processName}${this.options.recordIds?.length ? ` -r ${this.options.recordIds.join(',')}` : ''}`
}
busy(): boolean {
return this.phase === 'running' || this.phase === 'starting'
}
dirty(): boolean {
return this.phase === 'form' && (this.editor?.dirty() ?? false)
}
capturesText(): boolean {
return this.phase === 'form'
}
/** A fetch that stops when cancelled and notes the processUUID for the cancel call. */
private fetchFor(): typeof fetch {
const base = this.store.client!.fetch
return (async (input: RequestInfo | URL, init?: RequestInit) => {
if (this.cancelled) throw new Cancelled()
const response = await base(input, init)
if (!this.processUUID && /\/processes\//.test(String(input instanceof Request ? input.url : input))) {
try {
const body = await response.clone().json() as { processUUID?: string }
if (body.processUUID) this.processUUID = body.processUUID
} catch {
// not JSON
}
}
return response
}) as typeof fetch
}
start(): void {
this.phase = 'starting'
this.cancelled = false
this.processUUID = undefined
this.result = undefined
this.error = undefined
this.log = []
this.started = Date.now()
this.finished = undefined
const client = this.store.client!
const work = async () => {
this.meta = await client.processMeta(this.processName)
this.store.remember({ kind: 'process', name: this.processName, label: this.meta.label ?? this.processName })
return runProcess({
baseUrl: client.server,
fetch: this.fetchFor(),
meta: this.meta,
values: this.options.values,
records: this.options.recordIds?.length ? { ids: this.options.recordIds } : this.options.filter ? { filter: this.options.filter } : undefined,
stepTimeoutMillis: 0,
pollMillis: 250,
always: true,
onProgress: (e) => {
if (e.phase === 'init') this.note('starting')
else if (e.phase === 'step') {
this.phase = 'running'
this.stepStarted = Date.now()
this.progress = {}
this.note(`step ${e.step}`)
} else if (e.phase === 'running') {
this.phase = 'running'
this.progress = { message: e.message, current: e.current, total: e.total }
}
this.store.bump()
},
provide: (context) => this.provide(context.step, context.fields, context.values, context.response.values ?? {}),
})
}
work().then((outcome) => {
this.result = { values: outcome.values, lastStep: outcome.lastStep }
this.phase = 'done'
this.finished = Date.now()
this.note('complete')
this.store.bump()
}).catch((e) => {
if (e instanceof Cancelled || this.cancelled) {
this.phase = 'cancelled'
} else {
this.phase = 'error'
this.error = e instanceof ProcessError ? e.message : errorText(e)
}
this.finished = Date.now()
this.store.bump()
})
}
note(text: string): void {
this.log.push(`${new Date().toLocaleTimeString('en-GB')} ${text}`)
if (this.log.length > 50) this.log.shift()
}
/** The runner's provider: shows the step and resolves when the person submits it. */
private provide(step: ProcessStep, fields: FieldMeta[], values: FieldValues, serverValues: FieldValues): Promise<FieldValues> {
this.step = step
this.stepValues = { ...serverValues, ...values }
const types = (step.components ?? []).map((c) => c.type)
if (types.includes('VALIDATION_REVIEW_SCREEN') && !fields.length) {
this.phase = 'review'
this.reviewChoice = this.stepValues.validationSummary ? 1 : 0
} else if (fields.length) {
const client = this.store.client!
const session = new FormSession({ baseUrl: client.server, fetch: this.fetchFor(), scope: { process: this.processName }, fields, initialValues: values })
this.editor = new FormEditor(this.store, session, session.promptOrder(fields.map((f) => f.name)), { bulkEdit: types.includes('BULK_EDIT_FORM') })
this.phase = 'form'
} else {
// nothing to ask (a view step that is not the last): continue
return Promise.resolve({})
}
this.store.bump()
return new Promise<FieldValues>((resolve, reject) => {
this.pending = { resolve, reject }
})
}
submit(): void {
const pending = this.pending
if (!pending) return
if (this.phase === 'review') {
this.pending = undefined
this.phase = 'running'
pending.resolve({ doFullValidation: this.reviewChoice === 0 ? 'true' : 'false' })
return
}
const editor = this.editor
if (!editor || editor.saving) return
editor.saving = true
editor.check().then((problems) => {
editor.saving = false
if (Object.keys(problems).length) {
editor.formError = `${Object.keys(problems).length} field(s) need attention`
editor.index = Math.max(0, editor.names.findIndex((n) => problems[n]))
return
}
const out: FieldValues = {}
const enabled = editor.options.bulkEdit ? editor.enabled : undefined
for (const n of editor.names) if (editor.session.values[n] !== undefined && (!enabled || enabled.has(n))) out[n] = editor.session.values[n]
if (enabled) out.bulkEditEnabledFields = [...enabled].join(',')
this.pending = undefined
this.phase = 'running'
this.stepStarted = Date.now()
pending.resolve(out)
}).finally(() => this.store.bump())
}
cancel(): void {
if (this.phase === 'done' || this.phase === 'error' || this.phase === 'cancelled') return this.store.back()
this.store.confirm('Cancel this process?', () => {
this.cancelled = true
this.pending?.reject(new Cancelled())
this.pending = undefined
this.phase = 'cancelled'
const uuid = this.processUUID
if (uuid) this.store.client!.call('cancelProcess', { path: { processName: this.processName, processUUID: uuid } }).then(() => this.note('cancelled on the server')).catch((e) => this.note(`cancel: ${errorText(e)}`)).finally(() => this.store.bump())
})
}
fkeys(): FKeyLabels {
return { 3: null, 4: null, 6: null, 7: null, 8: null, 5: this.phase === 'done' || this.phase === 'error' || this.phase === 'cancelled' ? 'Re-run' : null }
}
onKey(key: Key): boolean {
if (key.name === 'escape') {
this.cancel()
return true
}
if (this.phase === 'form') {
if ((key.ctrl && key.name === 's')) {
this.submit()
return true
}
return this.editor?.onKey(key) ?? true
}
if (this.phase === 'review') {
if (key.name === 'up' || key.name === 'down' || key.name === 'tab') this.reviewChoice = 1 - this.reviewChoice
else if (key.name === 'return' || (key.ctrl && key.name === 's')) this.submit()
return true
}
if ((this.phase === 'done' || this.phase === 'error' || this.phase === 'cancelled') && (key.name === 'f5' || charOf(key) === 'r')) {
this.start()
return true
}
if (this.phase === 'done' && key.name === 'return') {
this.store.back()
return true
}
return false
}
menu(): MenuEntry[] {
const finished = this.phase === 'done' || this.phase === 'error' || this.phase === 'cancelled'
return [
{ section: 'Context', label: 'Cancel the process', key: 'Esc', disabled: finished ? 'finished' : undefined, run: () => this.cancel() },
{ section: 'Context', label: 'Run it again', key: 'F5 r', disabled: finished ? undefined : 'still running', run: () => this.start() },
{ section: 'Context', label: 'Copy as a CLI command', detail: `qqq ${this.cliEquivalent()}`, run: () => this.store.message(`qqq ${this.cliEquivalent()}`, 'info', 'CLI equivalent') },
]
}
private elapsed(from: number): string {
const s = Math.max(0, Math.round(((this.finished ?? Date.now()) - from) / 1000))
return s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`
}
private summaryLines(width: number): Array<{ text: string; fg?: string }> {
const t = theme()
const out: Array<{ text: string; fg?: string }> = []
const values = this.result?.values ?? {}
const lines = (values.processResults ?? values.recordListSummary) as Array<{ status?: string; count?: number; message?: string }> | undefined
if (Array.isArray(lines) && lines.length) {
for (const l of lines) {
const status = String(l.status ?? 'INFO')
out.push({ text: trunc(`${pad(status, 8)} ${l.count !== undefined && l.count !== null ? String(l.count).padStart(6) : ' '} ${String(l.message ?? '').replace(/\s+/g, ' ').trim()}`, width), fg: status === 'OK' ? t.ok : status === 'ERROR' ? t.err : status === 'WARNING' ? t.warn : t.fg })
}
return out
}
const viewFields = (this.result?.lastStep?.viewFields ?? []) as FieldMeta[]
const names = viewFields.length ? viewFields.map((f) => f.name) : Object.keys(values).filter((k) => values[k] !== null && typeof values[k] !== 'object').slice(0, 30)
const labelWidth = Math.min(30, Math.max(6, ...names.map((n) => (viewFields.find((f) => f.name === n)?.label ?? n).length)))
for (const n of names) out.push({ text: trunc(`${pad(viewFields.find((f) => f.name === n)?.label ?? n, labelWidth)} ${cell({ values }, n)}`, width) })
if (!out.length) out.push({ text: 'Done.' })
return out
}
render(width: number, height: number, focused: boolean) {
const t = theme()
const g = glyphs()
const inner = width - 2
const steps = (this.meta?.frontendSteps ?? []) as ProcessStep[]
const currentIdx = steps.findIndex((s) => s.name === this.step?.name)
const stepper = steps.map((s, i) => `${i < currentIdx || this.phase === 'done' ? g.check : i === currentIdx ? g.pointer + ' ' : g.uncheck} ${s.label ?? s.name}`).join(' ')
const body: React.ReactNode[] = []
const lines: Array<{ text: string; fg?: string; bold?: boolean }> = []
const help = (this.step?.components ?? []).filter((c) => c.type === 'HELP_TEXT' || c.type === 'HTML').map((c) => String(c.values?.text ?? c.values?.html ?? '').replace(/<[^>]+>/g, ''))
if (this.phase === 'starting') lines.push({ text: 'starting…', fg: t.dim })
if (this.phase === 'running') {
const p = this.progress
const determinate = p.total !== undefined && p.total > 0 && p.current !== undefined
const barWidth = Math.max(10, Math.min(60, inner - 30))
const filled = determinate ? Math.round((p.current! / p.total!) * barWidth) : (Math.floor(Date.now() / 250) % barWidth)
const bar = determinate ? g.bar.repeat(filled).padEnd(barWidth, '·') : '·'.repeat(filled) + g.bar.repeat(3) + '·'.repeat(Math.max(0, barWidth - filled - 3))
lines.push({ text: `Running ${this.step?.label ?? this.step?.name ?? ''}`, fg: t.acc, bold: true })
lines.push({ text: `${bar.slice(0, barWidth)} ${determinate ? `${p.current}/${p.total}` : 'working'} · ${this.elapsed(this.stepStarted)} elapsed` })
if (p.message) lines.push({ text: p.message, fg: t.dim })
lines.push({ text: 'Esc cancels', fg: t.dim })
}
if (this.phase === 'review') {
const v = this.stepValues
lines.push({ text: this.step?.label ?? 'Review', fg: t.acc, bold: true })
if (v.recordCount !== undefined) lines.push({ text: `${String(v.recordCount)} record(s) will be processed.` })
const summary = v.validationSummary as Array<{ status?: string; count?: number; message?: string }> | undefined
for (const l of summary ?? []) lines.push({ text: `${pad(String(l.status ?? ''), 8)} ${String(l.count ?? '').padStart(5)} ${String(l.message ?? '')}`, fg: l.status === 'ERROR' ? t.err : l.status === 'OK' ? t.ok : t.fg })
lines.push({ text: '' })
const opts = v.validationSummary ? ['Validate again', 'Run with these results'] : ['Validate first (check every record before running)', 'Skip validation and run']
opts.forEach((o, i) => lines.push({ text: `${i === this.reviewChoice ? g.radioOn : g.radioOff} ${o}`, fg: i === this.reviewChoice ? t.acc2 : t.fg }))
lines.push({ text: '' })
lines.push({ text: '↑↓ choose · Enter continues · Esc cancels', fg: t.dim })
}
if (this.phase === 'done') {
lines.push({ text: `Finished in ${this.elapsed(this.started)}`, fg: t.ok, bold: true })
lines.push(...this.summaryLines(inner))
lines.push({ text: '' })
lines.push({ text: 'Enter or Esc closes · F5 or r runs it again', fg: t.dim })
}
if (this.phase === 'error') {
lines.push({ text: 'The process failed', fg: t.err, bold: true })
for (const l of wrap(this.error ?? '', inner)) lines.push({ text: l, fg: t.err })
lines.push({ text: '' })
lines.push({ text: 'Esc closes · F5 or r runs it again', fg: t.dim })
}
if (this.phase === 'cancelled') {
lines.push({ text: 'Cancelled', fg: t.warn, bold: true })
lines.push({ text: 'Esc closes · F5 or r runs it again', fg: t.dim })
}
body.push(<text key="stepper" fg={t.dim}>{pad(` ${trunc(stepper, inner - 1)}`, inner)}</text>)
body.push(<text key="sp">{pad('', inner)}</text>)
for (const h of help) for (const l of wrap(h, inner - 2)) body.push(<text key={`h${body.length}`} fg={t.dim}>{pad(` ${l}`, inner)}</text>)
let used = body.length
if (this.phase === 'form' && this.editor) {
body.push(<text key="title" fg={t.acc}><strong>{pad(` ${this.step?.label ?? this.step?.name ?? ''}${this.editor.options.bulkEdit ? ' (Ctrl+T marks a field to apply)' : ''}`, inner)}</strong></text>)
used++
const formHeight = Math.max(3, height - 2 - used - 1)
body.push(<box key="form" flexDirection="column" height={formHeight}>{this.editor.render(inner, formHeight, focused)}</box>)
body.push(<text key="ft" fg={t.dim}>{pad(this.editor.saving ? ' sending…' : ' Ctrl+S continues · Esc cancels · Tab ↓ next · Enter or typing opens a picker', inner)}</text>)
} else {
for (const l of lines.slice(0, Math.max(0, height - 2 - used - 4))) body.push(<text key={`l${body.length}`} fg={l.fg ?? t.fg}>{l.bold ? <strong>{pad(` ${l.text}`, inner)}</strong> : pad(` ${l.text}`, inner)}</text>)
const logRows = Math.max(0, Math.min(this.log.length, height - 2 - body.length - 1))
const pad2 = height - 2 - body.length - logRows
for (let i = 0; i < pad2; i++) body.push(<text key={`p${i}`}>{pad('', inner)}</text>)
for (const l of this.log.slice(-logRows)) body.push(<text key={`g${body.length}`} fg={t.dim}>{pad(` ${l}`, inner)}</text>)
}
return <Panel title={this.title()} width={width} height={height} focused={focused}>{body}</Panel>
}
}
+415
View File
@@ -0,0 +1,415 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { ResultCursor, associationGroups, childFilter, childListFields, readViewSettings, relatedRecordTarget, saveDownload, valueText, widgetToBlocks, writeViewSettings, type AssociationGroup, type FieldMeta, type QueryRecord, type RenderBlock, type TableMetaLike } from '@qrun-io/qqq-core'
import { saveFile } from '../overlays/files-helpers'
import type { FieldMetaData, TableMetaData } from '../client'
import type { Key } from '../keys'
import { charOf } from '../keys'
import { openForm, openProcess, openRecord } from '../nav'
import { ChoiceOverlay } from '../overlays/basic'
import type { AppStore } from '../store'
import { glyphs, theme } from '../theme'
import type { FKeyLabels, MenuEntry, Overlay, Screen } from '../types'
import { cell, errorText, fieldCell, pad, trunc } from '../util/text'
import { blockLines } from '../widgets'
import { ListModel, Panel, type Row } from './list'
import { defaultColumns } from './table'
type FullRecord = QueryRecord & { associatedRecords?: Record<string, QueryRecord[]> }
/** The record a field links to (LINK adornment, or a possible-value source that is a table), through the kernel. */
export function linkTarget(store: AppStore, field: FieldMetaData, record?: QueryRecord): string | undefined {
const tables = Object.keys(store.meta?.tables ?? {})
if (record) return relatedRecordTarget(field as FieldMeta, record as never, tables)?.tableName
const pvs = field.possibleValueSourceName
return pvs && tables.includes(pvs) ? pvs : undefined
}
/** Sections in tier order (T1, then T2, then T3, then the rest), hidden ones left out. */
export function orderedSections(meta: TableMetaData) {
const rank = (tier?: string) => (tier === 'T1' ? 0 : tier === 'T2' ? 1 : tier === 'T3' ? 2 : 3)
return [...(meta.sections ?? [])].filter((s) => !s.hidden).sort((a, b) => rank(a.tier) - rank(b.tier))
}
/**
* A record: sections by tier, child lists from associations, widget sections. Opened from a grid it
* shares the grid's ResultCursor, so PgDn/PgUp step through the same result and the grid lands on
* the last record viewed when the person goes back.
*/
export class RecordScreen implements Screen {
readonly kind = 'record'
readonly id: string
meta?: TableMetaData
record?: FullRecord
error?: string
childMeta = new Map<string, TableMetaData>()
widgets = new Map<string, RenderBlock[] | { error: string }>()
readonly list = new ListModel()
width = 100
private generation = 0
constructor(private store: AppStore, readonly tableName: string, public primaryKey: string | undefined, readonly cursor?: ResultCursor, private label?: string) {
this.id = `record:${tableName}:${Date.now()}`
this.load()
}
title(): string {
return String(this.record?.recordLabel ?? this.label ?? this.primaryKey ?? '')
}
presenceScope(): string {
return `record:${this.tableName}:${this.primaryKey}`
}
helpTopic() {
return { kind: 'table' as const, name: this.tableName }
}
cliEquivalent(): string {
return `table get ${this.tableName} ${this.primaryKey}`
}
load(): void {
const client = this.store.client!
const gen = ++this.generation
const work = async () => {
this.meta ??= await client.tableMeta(this.tableName)
if (this.cursor && this.primaryKey === undefined) this.primaryKey = String(this.cursor.keyOf(await this.cursor.current()) ?? '')
if (this.primaryKey === undefined || this.primaryKey === '') throw new Error('no record')
const record = await client.record(this.tableName, this.primaryKey, Boolean(this.meta.associations?.length))
for (const a of this.meta.associations ?? []) if (!this.childMeta.has(a.associatedTableName)) this.childMeta.set(a.associatedTableName, await client.tableMeta(a.associatedTableName))
return record
}
work().then((record) => {
if (gen !== this.generation) return
this.record = record
this.error = undefined
this.label = record.recordLabel
this.store.remember({ kind: 'record', table: this.tableName, key: this.primaryKey, label: record.recordLabel ?? String(this.primaryKey) })
this.loadWidgets()
this.build()
this.store.bump()
}).catch((e) => {
if (gen !== this.generation) return
this.error = errorText(e)
this.build()
this.store.bump()
})
}
loadWidgets(): void {
for (const s of this.meta?.sections ?? []) {
if (!s.widgetName) continue
const name = s.widgetName
this.store.client!.widget(name, { id: this.primaryKey }).then((d) => this.widgets.set(name, widgetToBlocks(d))).catch((e) => this.widgets.set(name, { error: errorText(e) })).finally(() => { this.build(); this.store.bump() })
}
}
/** Steps through the result the record came from. */
step(direction: 1 | -1): void {
const c = this.cursor
if (!c) return this.store.flash('this record was not opened from a list; there is nothing to step through')
this.store.run((direction === 1 ? c.next() : c.previous()).then((rec) => {
if (!rec) return this.store.flash(direction === 1 ? 'last record of the result' : 'first record of the result')
this.primaryKey = String(c.keyOf(rec))
this.label = rec.recordLabel
this.record = undefined
this.list.cursor = 0
this.list.top = 0
this.widgets.clear()
this.load()
}))
}
resume(): void {
this.load()
}
refresh(): void {
this.widgets.clear()
this.load()
}
view() {
return readViewSettings(`record:${this.tableName}`)
}
build(): void {
const t = theme()
const g = glyphs()
const rows: Row[] = []
const meta = this.meta
const rec = this.record
if (this.error && !rec) {
rows.push({ text: this.error, fg: t.err })
this.list.setRows(rows)
return
}
if (!meta || !rec) {
rows.push({ text: 'loading…', fg: t.dim })
this.list.setRows(rows)
return
}
const inner = this.width - 2
const view = this.view()
const labelWidth = view.labelWidth ?? Math.min(28, Math.max(8, ...Object.values(meta.fields).map((f) => f.label.length + 1)))
rows.push({ text: `${rec.recordLabel ?? this.primaryKey}${this.cursor ? ` (${this.cursor.describe()})` : ''}`, fg: t.fg, bold: true })
for (const s of orderedSections(meta)) {
if (s.widgetName) {
rows.push({ text: '' })
rows.push({ text: `${s.label} ${g.rule.repeat(Math.max(0, Math.min(60, inner - s.label.length - 2)))}`, fg: t.acc, bold: true })
const w = this.widgets.get(s.widgetName)
if (!w) rows.push({ text: ' loading…', fg: t.dim })
else if ('error' in w) rows.push({ text: ` ${w.error}`, fg: t.err })
else rows.push(...blockLines(w.filter((b) => b.kind !== 'heading'), inner - 2).map((l) => ({ ...l, text: ' ' + l.text })))
continue
}
const fields = (s.fieldNames ?? []).map((n) => meta.fields[n]).filter((f): f is FieldMetaData => Boolean(f) && !f!.isHidden)
if (!fields.length) continue
rows.push({ text: '' })
rows.push({ text: `${s.label}${s.tier ? ` (${s.tier})` : ''} ${g.rule.repeat(Math.max(0, Math.min(60, inner - s.label.length - 8)))}`, fg: t.acc, bold: true })
const tableNames = Object.keys(this.store.meta?.tables ?? {})
const valueOf = (f: FieldMetaData) => valueText(f as FieldMeta, rec as never, { reveal: this.revealed.has(f.name), tableNames, tableName: this.tableName, primaryKey: this.primaryKey, widgets: this.store.meta?.widgets as never }).text
const longest = Math.max(...fields.map((f) => valueOf(f).length + (linkTarget(this.store, f) ? 2 : 0)), 4)
const colWidth = Math.min(60, labelWidth + 2 + longest)
const two = (view.recordColumns ?? (inner >= 2 * colWidth + 6 ? 2 : 1)) === 2 && inner >= 2 * Math.min(colWidth, 30) + 6
const fieldText = (f: FieldMetaData) => {
const v = valueText(f as FieldMeta, rec as never, { tableNames, widgets: this.store.meta?.widgets as never })
const link = v.link ? ` ${g.link}` : v.file ? ' [file: Enter saves]' : v.masked ? ' [Enter reveals]' : ''
return `${pad(f.label, labelWidth)} ${trunc(valueOf(f) + link, (two ? colWidth : Math.min(inner - 4, 100)) - labelWidth - 2)}`
}
for (let i = 0; i < fields.length; i += two ? 2 : 1) {
const pair = two ? fields.slice(i, i + 2) : [fields[i]!]
const links = pair.filter((f) => { const v = valueText(f as FieldMeta, rec as never, { tableNames }); return Boolean(v.link || v.file || v.masked) })
const text = ' ' + (two ? pad(fieldText(pair[0]!), colWidth) + ' ' + (pair[1] ? fieldText(pair[1]) : '') : fieldText(pair[0]!))
rows.push({ text, key: `f:${pair[0]!.name}`, action: () => this.follow(links) })
}
}
const tables: Record<string, TableMetaLike | undefined> = Object.fromEntries([...this.childMeta].map(([k, v]) => [k, v as unknown as TableMetaLike]))
this.groups = associationGroups(rec as never, meta as unknown as TableMetaLike, tables, { widgets: this.store.meta?.widgets as never, maxDepth: 1 })
this.groups.forEach((group, gi) => this.childRows(group, gi, rows, inner))
this.list.setRows(rows)
// back from a child record: land on the child that was viewed last (it may have stepped)
if (this.openedChild) {
const key = `c:${this.openedChild.gi}:${this.openedChild.cursor.index}`
const at = rows.findIndex((r) => r.key === key)
if (at >= 0) this.list.cursor = at
}
}
/** The child list and cursor of the child record opened last. */
openedChild?: { gi: number; cursor: ResultCursor }
groups: AssociationGroup[] = []
revealed = new Set<string>()
childRows(group: AssociationGroup, gi: number, rows: Row[], inner: number): void {
const t = theme()
const g = glyphs()
const child = this.childMeta.get(group.table)
const records = group.records as QueryRecord[]
rows.push({ text: '' })
rows.push({ text: `${group.label} (${records.length}) ${g.rule.repeat(Math.max(0, Math.min(60, inner - group.label.length - 8)))}`, fg: t.acc, bold: true, key: `c:${gi}`, action: () => this.newChild(group) })
if (group.unavailable || !child) {
rows.push({ text: ` ${group.unavailable ?? 'no metadata'}`, fg: t.dim })
return
}
const binding = (group.association.join?.joinOns ?? []).map((j) => j.rightField)
const names = childListFields(child as unknown as TableMetaLike, binding)
const widths = names.map((n) => Math.min(30, Math.max(child.fields[n]!.label.length, ...records.map((r) => fieldCell(r, child.fields[n]!).length))))
rows.push({ text: ' ' + names.map((n, i) => pad(child.fields[n]!.label, widths[i]!)).join(' '), fg: t.dim })
records.forEach((r, idx) => {
rows.push({ text: ' ' + names.map((n, i) => pad(fieldCell(r, child.fields[n]!), widths[i]!)).join(' '), key: `c:${gi}:${idx}`, action: () => this.openChild(group, idx) })
})
if (!records.length) rows.push({ text: ' (none) · Enter on the heading or F7 adds one', fg: t.dim })
}
/** The filter selecting this record's children of a group (kernel childFilter). */
childFilterOf(group: AssociationGroup) {
const child = this.childMeta.get(group.table)!
const f = childFilter(group.association, this.meta as unknown as TableMetaLike, this.record as never, child as unknown as TableMetaLike)
return { criteria: f.criteria, booleanOperator: f.booleanOperator, ...(f.subFilters ? { subFilters: f.subFilters } : {}) }
}
/** Opens a child record with a cursor over its siblings, so PgDn steps through them. */
openChild(group: AssociationGroup, index: number): void {
const child = this.childMeta.get(group.table)
if (!child) return
const records = group.records as QueryRecord[]
const cursor = new ResultCursor({ baseUrl: this.store.client!.server, fetch: this.store.client!.fetch, table: group.table, filter: this.childFilterOf(group), pageSize: 50, primaryKeyField: child.primaryKeyField, index })
cursor.seed(0, records.slice(0, 50), records.length)
this.openedChild = { gi: this.groups.indexOf(group), cursor }
openRecord(this.store, group.table, String(records[index]?.values?.[child.primaryKeyField] ?? ''), { cursor, label: records[index]?.recordLabel })
}
newChild(group: AssociationGroup): void {
const values = Object.fromEntries((group.association.join?.joinOns ?? []).map((j) => [j.rightField, this.record?.values?.[j.leftField]]))
openForm(this.store, group.table, { mode: 'create', initial: values })
}
/** Saves a FILE_DOWNLOAD field to a file (kernel fileDownload, safe names, no overwrite unless named). */
download(f: FieldMetaData): void {
const v = valueText(f as FieldMeta, this.record as never, {})
const file = v.file
if (!file) return
if (!file.route) return this.store.message(`This file is outside the server: ${file.url ?? ''}`, 'info', 'Download')
const route = file.route
saveFile(this.store, `Save ${file.fileName}`, file.fileName, (out) => {
this.store.run(this.store.client!.call('downloadRecordFieldFile', { path: { tableName: route.tableName, primaryKey: route.primaryKey, fieldName: route.fieldName, filename: route.filename } }), (r) => {
const bytes = r.bytes ?? new TextEncoder().encode(typeof r.data === 'string' ? r.data : JSON.stringify(r.data))
const saved = saveDownload(out, bytes, { explicit: true })
this.store.message(`Saved ${saved} (${bytes.length} bytes).`)
})
})
}
follow(fields: FieldMetaData[]): void {
const go = (f: FieldMetaData) => {
const v = valueText(f as FieldMeta, this.record as never, { tableNames: Object.keys(this.store.meta?.tables ?? {}) })
if (v.file) return this.download(f)
if (v.masked) {
this.revealed.add(f.name)
return this.build()
}
if (v.link && 'tableName' in v.link) return openRecord(this.store, v.link.tableName, v.link.primaryKey)
if (v.link && 'url' in v.link) return this.store.message(v.link.url, 'info', 'Link (open it in a browser)')
}
if (!fields.length) return this.store.flash('no link on this line (links are marked ' + glyphs().link + ')')
if (fields.length === 1) return go(fields[0]!)
this.store.open(new ChoiceOverlay(this.store, 'Follow which link?', fields.map((f) => ({ label: f.label, value: f, detail: cell(this.record, f.name) })), go))
}
currentAssociation(): AssociationGroup | undefined {
const key = this.list.current?.key ?? ''
if (!key.startsWith('c:')) return undefined
return this.groups[Number(key.split(':')[1])]
}
deleteRecord(): void {
if (this.meta?.deletePermission === false) return this.store.flash('you may not delete records of this table', 'error')
this.store.confirm(`Delete ${this.meta?.label ?? this.tableName} ${this.primaryKey}${this.record?.recordLabel ? ` "${this.record.recordLabel}"` : ''}?`, () => {
this.store.run(this.store.client!.remove(this.tableName, this.primaryKey), () => {
this.store.flash(`deleted ${this.meta?.label ?? this.tableName} ${this.primaryKey}`)
const c = this.cursor
if (!c) return this.store.back()
c.removed(c.index)
if (c.total === 0) return this.store.back()
this.primaryKey = undefined
this.record = undefined
this.load()
})
})
}
recordProcesses() {
return Object.values(this.store.meta?.processes ?? {}).filter((p) => p.tableName === this.tableName && !/bulkInsert|bulkEditWithFile|bulkLoad/i.test(p.name))
}
fkeys(): FKeyLabels {
return { 3: null, 4: this.meta?.editPermission === false ? null : 'Edit', 6: null, 7: this.currentAssociation() ? 'NewChild' : this.meta?.insertPermission === false ? null : 'New', 8: this.meta?.deletePermission === false ? null : 'Delete' }
}
onKey(key: Key): boolean {
const ch = charOf(key)
if (key.name === 'pagedown' || ch === ']' || (key.ctrl && key.name === 'n')) {
if (this.cursor) this.step(1)
else this.list.onKey({ name: 'pagedown', sequence: '' })
return true
}
if (key.name === 'pageup' || ch === '[' || (key.ctrl && key.name === 'p')) {
if (this.cursor) this.step(-1)
else this.list.onKey({ name: 'pageup', sequence: '' })
return true
}
switch (key.name) {
case 'f4': if (this.primaryKey !== undefined) openForm(this.store, this.tableName, { mode: 'edit', primaryKey: this.primaryKey }); return true
case 'f7': {
const a = this.currentAssociation()
if (a) this.newChild(a)
else openForm(this.store, this.tableName, { mode: 'create' })
return true
}
case 'f8': this.deleteRecord(); return true
default: break
}
if (this.list.onKey(key)) return true
if (!this.store.singleKeys) return false
switch (ch) {
case 'e': return this.onKey({ name: 'f4', sequence: '' })
case 'c': if (this.primaryKey !== undefined) openForm(this.store, this.tableName, { mode: 'copy', primaryKey: this.primaryKey }); return true
case 'd': this.deleteRecord(); return true
case 'n': openForm(this.store, this.tableName, { mode: 'create' }); return true
case 'r': this.refresh(); return true
case 'l': this.list.current?.action?.(); return true
default: return false
}
}
menu(): MenuEntry[] {
const out: MenuEntry[] = [
{ section: 'Context', label: 'Edit this record', key: 'F4 e', run: () => this.onKey({ name: 'f4', sequence: '' }) },
{ section: 'Context', label: 'Copy into a new record', key: 'c', run: () => openForm(this.store, this.tableName, { mode: 'copy', primaryKey: this.primaryKey }) },
{ section: 'Context', label: 'Delete this record', key: 'F8 d', run: () => this.deleteRecord() },
{ section: 'Context', label: `New ${this.meta?.label ?? this.tableName}`, key: 'F7 n', run: () => openForm(this.store, this.tableName, { mode: 'create' }) },
{ section: 'Context', label: 'Next record', key: 'PgDn ] Ctrl+N', disabled: this.cursor ? undefined : 'not opened from a list', run: () => this.step(1) },
{ section: 'Context', label: 'Previous record', key: 'PgUp [ Ctrl+P', disabled: this.cursor ? undefined : 'not opened from a list', run: () => this.step(-1) },
{ section: 'Context', label: 'Refresh', key: 'F5 r', run: () => this.refresh() },
{ section: 'Context', label: 'View settings (columns, label width)', key: 'v', run: () => { const o = this.viewSettings(); if (o) this.store.open(o) } },
{ section: 'Context', label: 'Copy as a CLI command', detail: `qqq ${this.cliEquivalent()}`, run: () => this.store.message(`qqq ${this.cliEquivalent()}`, 'info', 'CLI equivalent') },
]
for (const group of this.groups) {
out.push({ section: 'Context', label: `New ${group.label} for this record`, key: 'F7', run: () => this.newChild(group) })
out.push({ section: 'Context', label: `Query all ${group.label} of this record`, run: () => import('../nav').then((n) => n.openTable(this.store, group.table, this.childFilterOf(group))) })
}
const tableNames = Object.keys(this.store.meta?.tables ?? {})
for (const f of Object.values(this.meta?.fields ?? {})) {
if (!this.record) break
const v = valueText(f as FieldMeta, this.record as never, { tableNames })
if (v.link) out.push({ section: 'Context', label: `Follow ${f.label}`, detail: cell(this.record, f.name), run: () => this.follow([f]) })
if (v.file) out.push({ section: 'Context', label: `Download ${f.label}`, detail: v.file.fileName, run: () => this.download(f) })
if (v.masked) out.push({ section: 'Context', label: `Reveal ${f.label}`, run: () => this.follow([f]) })
}
for (const p of this.recordProcesses()) out.push({ section: 'Context', label: `Run ${p.label} on this record`, run: () => openProcess(this.store, p.name, { recordIds: [String(this.primaryKey)] }) })
out.push({ section: 'Context', label: 'Audit history', command: `audit list ${this.tableName} ${this.primaryKey}`, run: () => void this.store.commands().run(`audit list ${this.tableName} ${this.primaryKey}`) })
return out
}
viewSettings(): Overlay | undefined {
const key = `record:${this.tableName}`
const v = readViewSettings(key)
const choices = [
{ label: 'Automatic columns (two when there is room)', value: 'auto' },
{ label: 'One column', value: '1' },
{ label: 'Two columns', value: '2' },
{ label: 'Label width: narrower (-4)', value: 'narrow' },
{ label: 'Label width: wider (+4)', value: 'wide' },
{ label: 'Reset record view settings', value: 'reset' },
]
return new ChoiceOverlay(this.store, `Record view settings · ${this.meta?.label ?? this.tableName}`, choices, (value) => {
const next = { ...v }
if (value === 'auto') delete next.recordColumns
else if (value === '1' || value === '2') next.recordColumns = Number(value) as 1 | 2
else if (value === 'narrow' || value === 'wide') next.labelWidth = Math.max(6, Math.min(40, (v.labelWidth ?? 20) + (value === 'wide' ? 4 : -4)))
writeViewSettings(key, value === 'reset' ? undefined : next)
this.build()
})
}
render(width: number, height: number, focused: boolean) {
if (width !== this.width) {
this.width = width
this.build()
}
const t = theme()
const hint = this.cursor ? `${this.cursor.describe()} · PgDn/PgUp next/previous · Enter follows ${glyphs().link} or opens a child · Esc back` : `Enter follows ${glyphs().link} or opens a child · Esc back`
return (
<Panel title={`${this.meta?.label ?? this.tableName} › ${this.title()}`} width={width} height={height} focused={focused}>
{this.list.view(width - 2, height - 3, focused)}
<text fg={t.dim}>{pad(` ${hint}`, width - 2)}</text>
</Panel>
)
}
}
+99
View File
@@ -0,0 +1,99 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { AppTreeNode } from '../client'
import type { Key } from '../keys'
import { openNode } from '../nav'
import type { AppStore, SidebarLike } from '../store'
import { glyphs, theme } from '../theme'
import { pad } from '../util/text'
import { Panel } from './list'
export interface FlatNode {
node: AppTreeNode
depth: number
expanded: boolean
path: string
}
/** The visible nodes of the app tree, descending only into expanded nodes; order as the server gives it. */
export function flatten(nodes: AppTreeNode[], expanded: Set<string>, depth = 0, prefix = ''): FlatNode[] {
const out: FlatNode[] = []
for (const n of nodes) {
const path = `${prefix}/${n.name}`
const open = expanded.has(path)
out.push({ node: n, depth, expanded: open, path })
if (open && n.children?.length) out.push(...flatten(n.children, expanded, depth + 1, path))
}
return out
}
/** The Apps tree pane (left), from metadata appTree. */
export class Sidebar implements SidebarLike {
expanded = new Set<string>()
cursor = 0
top = 0
constructor(private store: AppStore) {
for (const n of this.tree()) this.expanded.add(`/${n.name}`)
}
tree(): AppTreeNode[] {
return (this.store.meta?.appTree ?? []) as AppTreeNode[]
}
onKey(key: Key): boolean {
const flat = flatten(this.tree(), this.expanded)
const cur = flat[this.cursor]
switch (key.name) {
case 'down': this.cursor = Math.min(flat.length - 1, this.cursor + 1); return true
case 'up': this.cursor = Math.max(0, this.cursor - 1); return true
case 'pagedown': this.cursor = Math.min(flat.length - 1, this.cursor + 10); return true
case 'pageup': this.cursor = Math.max(0, this.cursor - 10); return true
case 'home': this.cursor = 0; return true
case 'end': this.cursor = flat.length - 1; return true
case 'right':
if (cur?.node.children?.length) this.expanded.add(cur.path)
return true
case 'left':
if (cur?.expanded) this.expanded.delete(cur.path)
else if (cur && cur.depth > 0) this.cursor = Math.max(0, flat.findIndex((f) => f.path === cur.path.slice(0, cur.path.lastIndexOf('/'))))
return true
case 'space':
if (cur?.node.children?.length) {
if (cur.expanded) this.expanded.delete(cur.path)
else this.expanded.add(cur.path)
}
return true
case 'return':
if (!cur) return true
openNode(this.store, cur.node)
return true
default:
return false
}
}
render(width: number, height: number, focused: boolean) {
const t = theme()
const g = glyphs()
const flat = flatten(this.tree(), this.expanded)
const inner = height - 2
if (this.cursor < this.top) this.top = this.cursor
if (this.cursor >= this.top + inner) this.top = this.cursor - inner + 1
const current = (this.store.screen as { tableName?: string; processName?: string; appName?: string } | undefined)
const here = current?.tableName ?? current?.processName ?? current?.appName
const lines = flat.slice(this.top, this.top + inner).map((f, i) => {
const idx = this.top + i
const glyph = f.node.children?.length ? (f.expanded ? g.open : g.closed) + ' ' : ' '
const marker = f.node.type === 'PROCESS' ? g.process + ' ' : f.node.type === 'REPORT' ? g.report + ' ' : ''
const text = pad(' '.repeat(f.depth) + glyph + marker + f.node.label, width - 3) + (f.node.name === here ? '◂' : ' ')
const sel = idx === this.cursor
return <text key={f.path} fg={sel && focused ? t.selFg : f.node.type === 'APP' ? t.fg : t.dim} bg={sel ? (focused ? t.selBg : t.selInactiveBg) : undefined}>{text}</text>
})
while (lines.length < inner) lines.push(<text key={`p${lines.length}`}>{pad('', width - 2)}</text>)
return <Panel title={focused ? 'Apps' : 'Apps (Tab)'} width={width} height={height} focused={focused}>{lines}</Panel>
}
}
+186
View File
@@ -0,0 +1,186 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { UiApiError, decodeJwtClaims, deviceSignIn, getAuthenticationMetaData, getNativeSignInConfig } from '@qrun-io/qqq-ui-api-client'
import { loginFetch, profileNameForServer, readConfig, readLogin, signInWithSession, writeLogin, type SecretStore } from '@qrun-io/qqq-core'
import { TUI_CHANNEL, TuiClient } from '../client'
import type { Key } from '../keys'
import { PromptOverlay } from '../overlays/basic'
import type { AppStore, Target } from '../store'
import { glyphs, theme } from '../theme'
import type { FKeyLabels, Screen } from '../types'
import { errorText, pad } from '../util/text'
import { HomeScreen } from './home'
import { ListModel, Panel, type Row } from './list'
export interface SignInDeps {
secrets: () => SecretStore
fetch?: typeof fetch
/** Runs after the home screen is shown (the route given on the command line). */
afterConnect?: (store: AppStore) => void
timers?: boolean
}
/** Connects the store to a profile: the stored sign-in, a client, metadata; then the home screen. */
export async function connect(store: AppStore, target: Target, deps: SignInDeps): Promise<void> {
const store_ = deps.secrets()
const login = await readLogin(store_, target)
if (!login && !process.env.QQQ_TOKEN) throw new NeedsSignIn()
const fetchImpl = loginFetch({ store: store_, target, clientChannel: TUI_CHANNEL, fetch: deps.fetch })
const client = new TuiClient({ server: target.server, fetch: fetchImpl })
store.target = target
store.status.profile = target.name
store.status.server = target.server
if (login?.kind === 'session') {
const u = login.user ?? {}
store.status.user = String(u.displayName ?? u.name ?? u.email ?? u.username ?? '')
store.status.auth = `${login.authType.toLowerCase()} session`
} else if (login) {
const claims = decodeJwtClaims(login.tokens.accessToken)
store.status.user = String(claims.email ?? claims.preferred_username ?? claims.sub ?? '')
store.status.auth = 'oauth2'
} else {
store.status.auth = 'QQQ_TOKEN'
}
try {
await store.connect(client, { timers: deps.timers })
} catch (e) {
if (e instanceof UiApiError && (e.status === 401 || e.status === 403)) throw new NeedsSignIn('your sign-in expired; sign in again')
throw e
}
while (store.routes.length) store.routes.pop()?.dispose?.()
store.push(new HomeScreen(store))
deps.afterConnect?.(store)
}
export class NeedsSignIn extends Error {
constructor(message = 'not signed in') {
super(message)
}
}
/** The sign-in screen: pick a profile (or a new server); MOCK/anonymous get a session, OAuth2 uses the device flow. */
export class SignInScreen implements Screen {
readonly kind = 'signin'
readonly id = 'signin'
readonly list = new ListModel()
status = ''
device?: { url: string; code: string; expires: number }
connecting = false
constructor(private store: AppStore, private deps: SignInDeps, private initial?: Target, note?: string) {
this.status = note ?? ''
this.build()
}
title(): string {
return 'Sign in'
}
fkeys(): FKeyLabels {
return { 3: null, 4: null, 5: null, 6: null, 7: null, 8: null }
}
build(): void {
const t = theme()
const g = glyphs()
const config = readConfig()
const rows: Row[] = [{ text: 'Choose a profile (a server and its sign-in), or connect to another server.', fg: t.dim }, { text: '' }]
const names = Object.keys(config.profiles)
if (this.initial && !config.profiles[this.initial.name]) rows.push({ text: ` ${g.pointer} ${pad(this.initial.name, 24)} ${this.initial.server} (new)`, key: `p:${this.initial.name}`, action: () => this.choose(this.initial!) })
for (const n of names) {
const p = config.profiles[n]!
rows.push({ text: ` ${n === config.defaultProfile ? '*' : ' '} ${pad(n, 24)} ${p.server}`, key: `p:${n}`, action: () => this.choose({ name: n, server: p.server }) })
}
rows.push({ text: ` ${pad('Connect to another server…', 24)}`, key: 'new', action: () => this.newServer() })
rows.push({ text: '' })
rows.push({ text: '* is the default profile · Enter signs in · F10 quits', fg: t.dim })
this.list.setRows(rows)
if (this.initial) {
const i = this.list.rows.findIndex((r) => r.key === `p:${this.initial!.name}`)
if (i >= 0) this.list.cursor = i
}
}
newServer(): void {
this.store.open(new PromptOverlay(this.store, 'Connect to a server', 'The server URL, e.g. http://localhost:8077. A profile named after the host is created.', (url) => {
try {
const origin = new URL(url.trim()).origin
this.choose({ name: profileNameForServer(origin), server: origin })
} catch {
this.status = `not a URL: ${url}`
}
}, 'http://localhost:8077'))
}
/** Uses the stored sign-in, or signs in now. */
choose(target: Target): void {
if (this.connecting) return
this.connecting = true
this.status = `connecting to ${target.server}…`
this.store.bump()
connect(this.store, target, this.deps).catch(async (e) => {
if (!(e instanceof NeedsSignIn)) throw e
await this.signIn(target)
await connect(this.store, target, this.deps)
}).catch((e) => {
this.status = errorText(e)
}).finally(() => {
this.connecting = false
this.device = undefined
this.store.bump()
})
}
async signIn(target: Target): Promise<void> {
const secrets = this.deps.secrets()
const auth = await getAuthenticationMetaData(target.server, this.deps.fetch)
const type = String(auth.type ?? '').toUpperCase()
if (type === 'MOCK' || type === 'FULLY_ANONYMOUS') {
this.status = `${type.toLowerCase()} authentication: creating a session…`
this.store.bump()
await signInWithSession(secrets, target, type, TUI_CHANNEL, this.deps.fetch)
return
}
if (type === 'OAUTH2' || type === 'AUTH_0') {
const config = await getNativeSignInConfig(target.server, this.deps.fetch)
const tokens = await deviceSignIn(config, (prompt) => {
this.device = { url: prompt.verificationUriComplete ?? prompt.verificationUri, code: prompt.userCode, expires: Date.now() + prompt.expiresIn * 1000 }
this.status = 'waiting for approval in the browser…'
this.store.bump()
}, { fetch: this.deps.fetch })
await writeLogin(secrets, target, { kind: 'oauth', config, tokens })
return
}
if (type === 'TABLE_BASED') throw new Error('TABLE_BASED sign-in (username and password) is not supported yet; set QQQ_TOKEN to a session UUID.')
throw new Error(`unknown authentication type "${auth.type}"`)
}
onKey(key: Key): boolean {
if (key.name === 'escape') return true
return this.list.onKey(key)
}
render(width: number, height: number, focused: boolean) {
const t = theme()
const extra: React.ReactNode[] = []
if (this.device) {
const left = Math.max(0, Math.round((this.device.expires - Date.now()) / 1000))
extra.push(<text key="d1" fg={t.acc}>{pad(' Open this address in a browser:', width - 2)}</text>)
extra.push(<text key="d2" fg={t.fg}><strong>{pad(` ${this.device.url}`, width - 2)}</strong></text>)
extra.push(<text key="d3" fg={t.acc}>{pad(' and confirm the code:', width - 2)}</text>)
extra.push(<text key="d4" fg={t.acc2}><strong>{pad(` ${this.device.code}`, width - 2)}</strong></text>)
extra.push(<text key="d5" fg={t.dim}>{pad(` the code expires in ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`, width - 2)}</text>)
}
const listHeight = Math.max(3, height - 3 - extra.length - 1)
return (
<Panel title="Sign in" width={width} height={height} focused={focused}>
{this.list.view(width - 2, listHeight, focused)}
{extra}
<text fg={/fail|error|cannot|not /i.test(this.status) ? t.err : t.dim}>{pad(` ${this.status}`, width - 2)}</text>
</Panel>
)
}
}
+479
View File
@@ -0,0 +1,479 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { writeFileSync } from 'node:fs'
import { ResultCursor, findRecordByKey, gotoKeys, printFilter, readViewSettings, type QueryFilter, type QueryRecord, type ViewSettings } from '@qrun-io/qqq-core'
import type { FieldMetaData, TableMetaData } from '../client'
import type { Key } from '../keys'
import { charOf } from '../keys'
import { openForm, openProcess, openRecord } from '../nav'
import { ChoiceOverlay, PromptOverlay } from '../overlays/basic'
import { FilterOverlay } from '../overlays/filter'
import { saveFile } from '../overlays/files-helpers'
import { GridViewSettingsOverlay } from '../overlays/view-settings'
import type { AppStore } from '../store'
import { glyphs, theme } from '../theme'
import type { FKeyLabels, MenuEntry, Overlay, Screen } from '../types'
import { errorText, fieldCell, num, pad, padLeft, shellQuote, trunc } from '../util/text'
import { Panel } from './list'
export interface GridColumn {
name: string
label: string
type: string
width: number
numeric: boolean
}
/** The default column order: primary key, then section fields by tier, then the rest (hidden and heavy left out). */
export function defaultColumns(meta: TableMetaData): string[] {
const order: string[] = [meta.primaryKeyField]
for (const tier of ['T1', 'T2', 'T3']) {
for (const s of (meta.sections ?? []).filter((x) => x.tier === tier && !x.hidden)) {
for (const f of s.fieldNames ?? []) if (!order.includes(f) && meta.fields[f] && !meta.fields[f]!.isHidden) order.push(f)
}
}
for (const [name, f] of Object.entries(meta.fields)) if (!order.includes(name) && !f.isHidden && !f.isHeavy) order.push(name)
return order.filter((n) => meta.fields[n])
}
/**
* Column widths that use the available width: each column starts at the longest of its header and
* the visible values (capped at 40, or its saved width), truncated columns then get the spare width,
* and with "fill" what remains is spread over every column. Only when the natural widths do not fit
* does the grid scroll horizontally.
*/
export function layoutColumns(fields: FieldMetaData[], rows: QueryRecord[], available: number, view: ViewSettings): GridColumn[] {
const gap = 1
const cols = fields.map((f) => {
const numeric = ['INTEGER', 'LONG', 'DECIMAL'].includes(f.type) && !f.possibleValueSourceName
const longest = Math.max(f.label.length + 1, ...rows.map((r) => fieldCell(r, f).length), 3)
const saved = view.widths?.[f.name]
return { name: f.name, label: f.label, type: f.type, numeric, longest, width: saved ?? Math.min(40, longest) }
})
const total = () => cols.reduce((a, c) => a + c.width + gap, 0)
let spare = available - total()
if (spare > 0) {
// first give truncated columns what they need, the shortest need first
for (const c of [...cols].filter((x) => !view.widths?.[x.name] && x.longest > x.width).sort((a, b) => (a.longest - a.width) - (b.longest - b.width))) {
const add = Math.min(spare, c.longest - c.width)
c.width += add
spare -= add
}
if (spare > 0 && view.fit === 'fill' && cols.length) {
const each = Math.floor(spare / cols.length)
cols.forEach((c, i) => { c.width += each + (i < spare - each * cols.length ? 1 : 0) })
}
}
return cols.map(({ longest: _l, ...c }) => c)
}
/** The columns from `left` that fit in a width (at least one). */
export function visibleFrom(cols: GridColumn[], left: number, width: number): GridColumn[] {
const out: GridColumn[] = []
let used = 0
for (let i = left; i < cols.length; i++) {
if (used + cols[i]!.width + 1 > width && out.length) break
out.push(cols[i]!)
used += cols[i]!.width + 1
}
return out
}
/** A table query screen: a server-paged grid over a ResultCursor, with filter, sort, selection and export. */
export class TableScreen implements Screen {
readonly kind = 'table'
readonly id: string
meta?: TableMetaData
cursor?: ResultCursor
filter: QueryFilter
sort?: { field: string; asc: boolean }
colCursor = 0
leftCol = 0
selection = new Set<string>()
rows: QueryRecord[] = []
rowsPage = -1
loading = false
error?: string
fetchMs?: number
pageSize = 20
view: ViewSettings = {}
private generation = 0
constructor(private store: AppStore, readonly tableName: string, filter?: QueryFilter, options: { index?: number } = {}) {
this.id = `table:${tableName}:${Date.now()}`
this.filter = { criteria: [], booleanOperator: 'AND', ...(filter ?? {}) }
if (this.filter.orderBys?.[0]) this.sort = { field: this.filter.orderBys[0].fieldName, asc: this.filter.orderBys[0].isAscending }
delete this.filter.orderBys
this.view = readViewSettings(`table:${tableName}`)
store.run(store.client!.tableMeta(tableName), (meta) => {
this.meta = meta
this.newCursor(options.index ?? 0)
this.store.remember({ kind: 'table', table: tableName, label: meta.label })
})
}
title(): string {
return this.meta?.label ?? this.tableName
}
presenceScope(): string {
return `table:${this.tableName}`
}
helpTopic() {
return { kind: 'table' as const, name: this.tableName }
}
/** The query as the CLI says it. */
cliEquivalent(): string {
const parts = ['table', 'query', this.tableName]
const text = printFilter(this.filter)
if (text) parts.push('-g', shellQuote(text))
if (this.sort) parts.push('-o', (this.sort.asc ? '' : '-') + this.sort.field)
return parts.join(' ')
}
fullFilter(): QueryFilter {
return { ...this.filter, ...(this.sort ? { orderBys: [{ fieldName: this.sort.field, isAscending: this.sort.asc }] } : {}) }
}
newCursor(index = 0): void {
if (!this.meta) return
this.cursor = new ResultCursor({ baseUrl: this.store.client!.server, fetch: this.store.client!.fetch, table: this.tableName, filter: this.fullFilter(), pageSize: this.pageSize, primaryKeyField: this.meta.primaryKeyField, index })
this.rowsPage = -1
this.load()
}
/** Loads the page holding the cursor's index (ignores stale answers). */
load(refresh = false): void {
const cursor = this.cursor
if (!cursor) return
const page = cursor.pageOf(cursor.index)
const gen = ++this.generation
this.loading = true
const started = performance.now()
cursor.page(page, { refresh }).then((records) => {
if (gen !== this.generation) return
this.rows = records
this.rowsPage = page
this.loading = false
this.error = undefined
this.fetchMs = performance.now() - started
if (cursor.total !== undefined && cursor.index >= cursor.total) cursor.index = Math.max(0, cursor.total - 1)
this.store.bump()
}).catch((e) => {
if (gen !== this.generation) return
this.loading = false
this.error = errorText(e)
this.store.bump()
})
}
get rowIndex(): number {
return this.cursor ? this.cursor.index - this.rowsPage * this.cursor.pageSize : 0
}
get currentRecord(): QueryRecord | undefined {
return this.rows[this.rowIndex]
}
keyOf(record: QueryRecord | undefined): string | undefined {
const v = record?.values?.[this.meta?.primaryKeyField ?? 'id']
return v === undefined || v === null ? undefined : String(v)
}
fields(): FieldMetaData[] {
if (!this.meta) return []
const names = (this.view.columns?.length ? this.view.columns : defaultColumns(this.meta)).filter((n) => this.meta!.fields[n])
return names.map((n) => this.meta!.fields[n]!)
}
moveTo(index: number): void {
const c = this.cursor
if (!c) return
const max = Math.max(0, (c.total ?? 1) - 1)
c.index = Math.max(0, Math.min(max, index))
if (c.pageOf(c.index) !== this.rowsPage) this.load()
}
resume(): void {
// back from a record or form: the cursor may have moved (next/previous) or records changed
this.view = readViewSettings(`table:${this.tableName}`)
this.cursor?.invalidate()
this.load(true)
}
refresh(): void {
this.cursor?.invalidate()
this.load(true)
}
setFilter(filter: QueryFilter, sort?: { field: string; asc: boolean } | null): void {
this.filter = { criteria: filter.criteria, booleanOperator: filter.booleanOperator, ...(filter.subFilters?.length ? { subFilters: filter.subFilters } : {}) }
if (sort !== undefined) this.sort = sort ?? undefined
else if (filter.orderBys?.[0]) this.sort = { field: filter.orderBys[0].fieldName, asc: filter.orderBys[0].isAscending }
this.selection.clear()
this.newCursor(0)
}
openCurrent(): void {
const rec = this.currentRecord
const key = this.keyOf(rec)
if (key === undefined || !this.cursor) return this.store.flash('no record under the cursor')
openRecord(this.store, this.tableName, key, { cursor: this.cursor, label: rec?.recordLabel })
}
deleteCurrent(): void {
const rec = this.currentRecord
const key = this.keyOf(rec)
if (key === undefined) return this.store.flash('no record under the cursor')
if (this.meta?.deletePermission === false) return this.store.flash('you may not delete records of this table', 'error')
this.store.confirm(`Delete ${this.title()} ${key}${rec?.recordLabel ? ` "${rec.recordLabel}"` : ''}?`, () => {
this.store.run(this.store.client!.remove(this.tableName, key), () => {
this.cursor?.removed(this.cursor.index)
this.selection.delete(key)
this.load(true)
this.store.flash(`deleted ${this.title()} ${key}`)
})
})
}
/** Table processes for the bulk bar and the menu (bulk-load file processes are parked). */
tableProcesses(): Array<{ name: string; label: string; parked?: string }> {
const procs = Object.values(this.store.meta?.processes ?? {}).filter((p) => p.tableName === this.tableName)
return procs.map((p) => ({ name: p.name, label: p.label, parked: /bulkInsert|bulkEditWithFile|bulkLoad/i.test(p.name) ? 'parked: bulk load needs file uploads' : undefined }))
.sort((a, b) => Number(Boolean(a.parked)) - Number(Boolean(b.parked)) || a.label.localeCompare(b.label))
}
runProcess(name: string): void {
const ids = [...this.selection]
if (ids.length) openProcess(this.store, name, { recordIds: ids })
else {
const key = this.keyOf(this.currentRecord)
openProcess(this.store, name, key !== undefined ? { recordIds: [key] } : { filter: this.fullFilter() })
}
}
bulkActions(): void {
const procs = this.tableProcesses()
if (!procs.length) return this.store.flash('this table has no processes')
const what = this.selection.size ? `${this.selection.size} selected` : 'the record under the cursor'
this.store.open(new ChoiceOverlay(this.store, `Run on ${what}`, procs.map((p) => ({ label: p.label, value: p.name, detail: p.name, disabled: p.parked })), (name) => this.runProcess(name)))
}
exportDialog(): void {
const formats = ['csv', 'xlsx', 'json', 'tsv']
this.store.open(new ChoiceOverlay(this.store, 'Export to a file', formats.map((f) => ({ label: f, value: f, detail: f === 'xlsx' ? 'Excel' : undefined })), (format) => this.exportAs(format)))
}
/** Go to a record by its primary key or a configured unique key (kernel gotoKeys / findRecordByKey). */
gotoKey(): void {
const meta = this.meta
if (!meta) return
const keys = gotoKeys(meta as never)
const ask = (key: string[]) => {
const labels = key.map((n) => meta.fields[n]?.label ?? n)
this.store.open(new PromptOverlay(this.store, `Go to ${this.title()} by ${labels.join(' + ')}`, key.length > 1 ? `Values for ${labels.join(', ')}, separated by commas.` : 'Opens the record with this value.', (text) => {
const parts = key.length > 1 ? text.split(',').map((x) => x.trim()) : [text.trim()]
if (!parts.some(Boolean)) return
const values = Object.fromEntries(key.map((n, i) => [n, parts[i] ?? '']))
if (key.length === 1 && key[0] === meta.primaryKeyField) return openRecord(this.store, this.tableName, parts[0])
this.store.run(findRecordByKey(this.store.client!.server, this.store.client!.fetch, meta as never, values), (r) => {
if (r.found === 'one') openRecord(this.store, this.tableName, r.primaryKey)
else this.store.flash(r.found === 'none' ? 'no record has that key' : 'more than one record has that key', 'error')
})
}))
}
if (keys.length === 1) return ask(keys[0]!)
this.store.open(new ChoiceOverlay(this.store, 'Go to by which key?', keys.map((k) => ({ label: k.map((n) => meta.fields[n]?.label ?? n).join(' + '), value: k })), ask))
}
filterDialog(): void {
if (!this.meta) return
this.store.open(new FilterOverlay(this.store, this.meta, this.filter, this.sort, (filter, sort) => this.setFilter(filter, sort)))
}
viewSettings(): Overlay | undefined {
if (!this.meta) return undefined
return new GridViewSettingsOverlay(this.store, this.meta, `table:${this.tableName}`, this.fields().map((f) => f.name), () => {
this.view = readViewSettings(`table:${this.tableName}`)
this.store.bump()
})
}
fkeys(): FKeyLabels {
const m = this.meta
return { 3: 'View', 4: m?.editPermission === false ? null : 'Edit', 6: 'Filter', 7: m?.insertPermission === false ? null : 'New', 8: m?.deletePermission === false ? null : 'Delete' }
}
onKey(key: Key): boolean {
const c = this.cursor
if (!this.meta || !c) return key.name !== 'escape' && key.name !== 'tab' && !key.name.startsWith('f')
const ch = charOf(key)
const single = this.store.singleKeys
switch (key.name) {
case 'down': this.moveTo(c.index + 1); return true
case 'up': this.moveTo(c.index - 1); return true
case 'pagedown': this.moveTo(this.rowIndex === this.rows.length - 1 || this.rows.length < c.pageSize ? c.index + c.pageSize : (this.rowsPage + 1) * c.pageSize - 1); return true
case 'pageup': this.moveTo(this.rowIndex === 0 ? c.index - c.pageSize : this.rowsPage * c.pageSize); return true
case 'home': this.moveTo(0); return true
case 'end': this.moveTo((c.total ?? 1) - 1); return true
case 'right': this.colCursor = Math.min(this.fields().length - 1, this.colCursor + 1); return true
case 'left': this.colCursor = Math.max(0, this.colCursor - 1); return true
case 'return': case 'f3': this.openCurrent(); return true
case 'f4': {
const key2 = this.keyOf(this.currentRecord)
if (key2 !== undefined) openForm(this.store, this.tableName, { mode: 'edit', primaryKey: key2, cursor: c })
return true
}
case 'f6': this.filterDialog(); return true
case 'f7': openForm(this.store, this.tableName, { mode: 'create' }); return true
case 'f8': this.deleteCurrent(); return true
case 'space': {
const k = this.keyOf(this.currentRecord)
if (k !== undefined) {
if (this.selection.has(k)) this.selection.delete(k)
else this.selection.add(k)
this.moveTo(c.index + 1)
}
return true
}
case 'escape':
if (this.selection.size) {
this.selection.clear()
return true
}
return false
default: break
}
if (key.ctrl && key.name === 'a') {
for (const r of this.rows) { const k = this.keyOf(r); if (k !== undefined) this.selection.add(k) }
return true
}
if (!single) return false
switch (ch) {
case 's': {
const field = this.fields()[this.colCursor]?.name
if (!field) return true
this.sort = !this.sort || this.sort.field !== field ? { field, asc: true } : this.sort.asc ? { field, asc: false } : undefined
this.newCursor(0)
return true
}
case 'f': this.filterDialog(); return true
case 'n': openForm(this.store, this.tableName, { mode: 'create' }); return true
case 'r': this.refresh(); return true
case 'g': this.gotoKey(); return true
case 'x': this.exportDialog(); return true
case 'b': this.bulkActions(); return true
default: return false
}
}
menu(): MenuEntry[] {
const out: MenuEntry[] = [
{ section: 'Context', label: 'View the record under the cursor', key: 'F3 Enter', run: () => this.openCurrent() },
{ section: 'Context', label: 'Edit the record under the cursor', key: 'F4', run: () => this.onKey({ name: 'f4', sequence: '' }) },
{ section: 'Context', label: `New ${this.title()}`, key: 'F7 n', run: () => openForm(this.store, this.tableName, { mode: 'create' }) },
{ section: 'Context', label: 'Delete the record under the cursor', key: 'F8', run: () => this.deleteCurrent() },
{ section: 'Context', label: 'Filter and sort', key: 'F6 f', command: 'filter', run: () => this.filterDialog() },
{ section: 'Context', label: 'Clear the filter', command: 'filter clear', run: () => this.setFilter({ criteria: [], booleanOperator: 'AND' }) },
{ section: 'Context', label: 'Go to a record by key', key: 'g', run: () => this.gotoKey() },
{ section: 'Context', label: 'Refresh', key: 'F5 r', run: () => this.refresh() },
{ section: 'Context', label: 'View settings (columns, widths, stripes, density)', key: 'v', command: 'view', run: () => { const o = this.viewSettings(); if (o) this.store.open(o) } },
{ section: 'Context', label: 'Copy as a CLI command', detail: this.cliEquivalent(), run: () => this.store.message(`qqq ${this.cliEquivalent()}`, 'info', 'CLI equivalent') },
]
for (const f of ['csv', 'xlsx', 'json', 'tsv']) out.push({ section: 'Context', label: `Export as ${f}`, key: f === 'csv' ? 'x' : undefined, command: `export ${f}`, run: () => this.exportAs(f) })
for (const p of this.tableProcesses()) out.push({ section: 'Context', label: `Run ${p.label}`, detail: this.selection.size ? `${this.selection.size} selected` : 'cursor record', disabled: p.parked, run: () => this.runProcess(p.name) })
out.push({ section: 'Context', label: 'Saved views of this table', command: `saved-view list ${this.tableName}`, run: () => void this.store.commands().run(`saved-view list ${this.tableName}`) })
out.push({ section: 'Context', label: `Column statistics: ${this.fields()[this.colCursor]?.label ?? ''}`, command: `table stats ${this.tableName} ${this.fields()[this.colCursor]?.name ?? ''}`, run: () => void this.store.commands().run(`table stats ${this.tableName} ${this.fields()[this.colCursor]?.name ?? ''}`) })
return out
}
/** Exports with the current filter, sort and visible columns, to a file chosen in the file dialog (or silently to the download directory). */
exportAs(format: string): void {
const write = (out: string) => {
this.store.run(this.store.client!.export(this.tableName, format, this.fullFilter(), this.fields().map((f) => f.name)), ({ bytes }) => {
writeFileSync(out, bytes)
this.store.message(`Wrote ${out} (${num(bytes.length)} bytes).`)
})
}
saveFile(this.store, `Export ${this.title()} as ${format}`, `${this.tableName}.${format}`, write)
}
render(width: number, height: number, focused: boolean) {
const t = theme()
const g = glyphs()
const inner = width - 2
const comfortable = this.view.density === 'comfortable'
const headerRule = this.view.headerRule ?? true
const chrome = 1 + (headerRule ? 1 : 0) + 2 + (this.selection.size ? 1 : 0)
const bodyLines = Math.max(1, height - 2 - chrome)
const pageSize = Math.max(1, comfortable ? Math.ceil(bodyLines / 2) : bodyLines)
if (this.cursor && pageSize !== this.cursor.pageSize) {
const index = this.cursor.index
this.pageSize = pageSize
this.cursor.pageSize = pageSize
this.cursor.invalidate()
this.cursor.index = index
this.rowsPage = -1
queueMicrotask(() => this.load())
}
const fields = this.fields()
const cols = layoutColumns(fields, this.rows, inner - 2, this.view)
this.colCursor = Math.min(this.colCursor, Math.max(0, cols.length - 1))
if (this.colCursor < this.leftCol) this.leftCol = this.colCursor
while (this.leftCol < this.colCursor && !visibleFrom(cols, this.leftCol, inner - 2).some((c) => c.name === cols[this.colCursor]!.name)) this.leftCol++
const shown = visibleFrom(cols, this.leftCol, inner - 2).map((c) => ({ ...c }))
if (this.view.fit === 'fill' && shown.length) {
// fill: spread what is left of the width over the visible columns
const spare = inner - 2 - shown.reduce((a, c) => a + c.width + 1, 0)
if (spare > 0) shown.forEach((c, i) => { c.width += Math.floor(spare / shown.length) + (i < spare % shown.length ? 1 : 0) })
}
const zebra = this.view.zebra ?? t.zebraByDefault
const header = ' ' + shown.map((c) => {
const mark = this.sort?.field === c.name ? (this.sort.asc ? g.up : g.down) : ''
const label = trunc(c.label, c.width - mark.length) + mark
return c.numeric ? padLeft(label, c.width) : pad(label, c.width)
}).join(' ')
const lines: React.ReactNode[] = []
lines.push(
<text key="h">
{shown.map((c, i) => {
const hot = cols.findIndex((x) => x.name === c.name) === this.colCursor
const mark = this.sort?.field === c.name ? (this.sort.asc ? g.up : g.down) : ''
const label = trunc(c.label, c.width - mark.length) + mark
return <span key={c.name} fg={hot && focused ? t.acc2 : t.acc}>{(i === 0 ? ' ' : ' ') + (c.numeric ? padLeft(label, c.width) : pad(label, c.width))}</span>
})}
<span>{' '.repeat(Math.max(0, inner - header.length))}</span>
</text>,
)
if (headerRule) lines.push(<text key="r" fg={t.border}>{pad(g.rule.repeat(Math.min(inner, header.length)), inner)}</text>)
const pk = this.meta?.primaryKeyField ?? 'id'
this.rows.forEach((r, i) => {
const sel = i === this.rowIndex
const key = String(r.values?.[pk] ?? '')
const marked = this.selection.has(key)
const text = (marked ? g.mark + ' ' : ' ') + shown.map((c) => (c.numeric ? padLeft(fieldCell(r, c), c.width) : pad(fieldCell(r, c), c.width))).join(' ')
const bg = sel ? (focused ? t.selBg : t.selInactiveBg) : zebra && i % 2 === 1 ? t.zebraBg : undefined
lines.push(<text key={`r${i}`} fg={sel ? (focused ? t.selFg : t.fg) : marked ? t.markFg : t.fg} bg={bg}>{pad(text, inner)}</text>)
if (comfortable) lines.push(<text key={`s${i}`}>{pad('', inner)}</text>)
})
const used = lines.length - 1 - (headerRule ? 1 : 0)
for (let i = used; i < bodyLines; i++) {
lines.push(<text key={`e${i}`} fg={this.error ? t.err : t.dim}>{pad(i === 0 && !this.rows.length ? (this.error ? ` ${this.error}` : this.loading || !this.meta ? ' loading…' : ' (no records match)') : '', inner)}</text>)
}
if (this.selection.size) {
lines.push(<text key="bulk" fg={t.selFg} bg={t.acc}>{pad(` ${this.selection.size} selected · b run a process on them · Ctrl+A select page · Space toggle · Esc clear`, inner)}</text>)
}
const c = this.cursor
const filterText = printFilter(this.filter) || 'no filter'
lines.push(<text key="f" fg={t.dim}>{pad(` ${filterText}`, inner)}</text>)
const status = c ? `${c.total === undefined ? '…' : num(c.total)} records · row ${num(c.index + 1)} · page ${this.rowsPage + 1}/${c.pageCount}${this.sort ? ` · sort ${this.sort.field} ${this.sort.asc ? 'asc' : 'desc'}` : ''} · col ${this.colCursor + 1}/${cols.length}${this.fetchMs !== undefined ? ` · ${this.fetchMs.toFixed(0)} ms` : ''}${this.loading ? ' · loading' : ''}` : 'loading…'
const hint = this.store.singleKeys ? ' · s sort · f filter · v view · x export · Space select' : ''
lines.push(<text key="s" fg={t.dim}>{pad(` ${status}${hint}`, inner)}</text>)
return <Panel title={this.title()} width={width} height={height} focused={focused}>{lines}</Panel>
}
}
+540
View File
@@ -0,0 +1,540 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The application state and the one place keys are dispatched. React components only draw what is
* here; key handlers read and change it directly (no closures over render-time state), then ask for
* a repaint. Order of dispatch: the lock screen, the top overlay, the global function keys, the
* focused pane (sidebar or screen), then the global fallbacks (Esc back, Tab, `:`).
*/
import { openRealtimeStream } from '@qrun-io/qqq-ui-api-client'
import { History, 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 { ChoiceOverlay, ConfirmOverlay, MessageOverlay } from './overlays/basic'
import { Extensions } from './extensions'
import { collaborationPresence, presenceSource, setPresenceSource } from './presence'
import { setAscii, setTheme, theme } from './theme'
import type { FKeyLabels, Overlay, Screen } from './types'
import { errorText } from './util/text'
export interface Settings {
theme: string
ascii: boolean
linear: boolean
mouse: boolean
/** Single-key shortcuts (s, v, n, e …); the server's keyboard.singleKeyShortcuts preference turns them off. */
singleKeys: boolean
}
export interface Status {
server: string
profile: string
user: string
auth: string
apiVersion: string
pingMs: number | null
stream: 'off' | 'connecting' | 'connected' | 'reconnecting' | 'n/a'
lastEvent: string
jobs: string
build: string
clock: string
alerts?: number
}
export interface Target {
name: string
server: string
}
export interface StoreOptions {
settings: Settings
/** Ends the program (renderer.destroy). */
quit: () => void
build?: string
target?: Target
/** Starts the ping, clock and realtime timers (off in tests). */
timers?: boolean
}
type Listener = () => void
export class AppStore {
routes: Screen[] = []
overlays: Overlay[] = []
focus: 'content' | 'sidebar' = 'content'
width = 120
height = 40
client?: TuiClient
meta?: MetaDataDocument
version?: VersionInfo
extensions = new Extensions()
target?: Target
settings: Settings
status: Status
flashText?: { text: string; kind: 'info' | 'error'; at: number }
/** The screen saver or lock, when shown. */
lock?: Overlay
readonly history = new History('commands')
readonly filterHistory = new History('filters')
lastMenuSection = 0
lastInput = Date.now()
sidebarFactory?: (store: AppStore) => SidebarLike
/** How to sign in again (profile switch, sign out); set by main. */
signInDeps?: import('./screens/signin').SignInDeps
/** Opens text in $EDITOR and returns the edited text (set by main; suspends the renderer). */
editExternal: (text: string) => Promise<string> = async () => { throw new Error('no external editor in this session') }
sidebar?: SidebarLike
private listeners = new Set<Listener>()
private timers: Array<ReturnType<typeof setInterval>> = []
private streamAbort?: AbortController
private readonly quitImpl: () => void
private paintQueued = false
constructor(options: StoreOptions) {
this.settings = options.settings
this.quitImpl = options.quit
this.target = options.target
setTheme(options.settings.theme)
setAscii(options.settings.ascii)
this.status = {
server: options.target?.server ?? '', profile: options.target?.name ?? '', user: '', auth: '', apiVersion: '…', pingMs: null,
stream: 'off', lastEvent: 'no events yet', jobs: 'n/a', build: options.build ?? 'dev', clock: clock(),
}
if (options.timers) {
this.timers.push(setInterval(() => {
this.status.clock = clock()
this.checkIdle()
this.bump()
}, 1000))
}
}
// ---- rendering ----
subscribe(listener: Listener): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
/** Asks for a repaint (coalesced). */
bump(): void {
if (this.paintQueued) return
this.paintQueued = true
queueMicrotask(() => {
this.paintQueued = false
this.syncPresence()
for (const l of this.listeners) l()
})
}
get screen(): Screen | undefined {
return this.routes[this.routes.length - 1]
}
get singleKeys(): boolean {
return this.settings.singleKeys
}
get showSidebar(): boolean {
return !this.settings.linear && this.width >= 100 && Boolean(this.meta) && Boolean(this.sidebar)
}
// ---- navigation ----
push(screen: Screen): void {
this.routes.push(screen)
this.focus = 'content'
this.bump()
}
/** Tells the presence source which scope is on top (called on every repaint; cheap when unchanged). */
private syncPresence(): void {
const scope = this.lock ? undefined : this.screen?.presenceScope?.()
presenceSource().enter?.(scope)
}
/** Replaces the top screen (a form that saved becomes the record). */
replace(screen: Screen): void {
const old = this.routes.pop()
old?.dispose?.()
this.routes.push(screen)
this.bump()
}
/** Back one screen; the screen below resumes with its state intact. */
back(): void {
if (this.routes.length <= 1) {
if (!this.settings.linear && this.sidebar && this.showSidebar) this.focus = 'sidebar'
this.bump()
return
}
const old = this.routes.pop()
old?.dispose?.()
this.screen?.resume?.()
this.bump()
}
/** Back to the first screen (home). */
home(): void {
while (this.routes.length > 1) this.routes.pop()?.dispose?.()
this.screen?.resume?.()
this.bump()
}
// ---- overlays and messages ----
open(overlay: Overlay): void {
this.overlays.push(overlay)
this.bump()
}
closeOverlay(overlay?: Overlay): void {
if (!overlay) this.overlays.pop()
else this.overlays = this.overlays.filter((o) => o !== overlay)
this.bump()
}
message(text: string, kind: 'info' | 'error' = 'info', title?: string): void {
this.open(new MessageOverlay(this, text, kind, title))
}
/** A short note in the status area that fades on its own. */
flash(text: string, kind: 'info' | 'error' = 'info'): void {
this.flashText = { text, kind, at: Date.now() }
this.bump()
setTimeout(() => {
if (this.flashText && Date.now() - this.flashText.at >= 3900) {
this.flashText = undefined
this.bump()
}
}, 4000)
}
confirm(text: string, onYes: () => void, onNo?: () => void): void {
this.open(new ConfirmOverlay(this, text, onYes, onNo))
}
error(error: unknown): void {
this.message(errorText(error), 'error')
}
/** Runs async work; failures become an error message instead of an unhandled rejection. */
run<T>(work: Promise<T>, onDone?: (value: T) => void): void {
work.then((value) => {
onDone?.(value)
this.bump()
}).catch((e) => this.error(e))
}
// ---- keys ----
handleKey(raw: Key): void {
this.lastInput = Date.now()
// Ctrl+F / Ctrl+B page forward and back everywhere: no terminal or tmux intercepts them
const key: Key = raw.ctrl && (raw.name === 'f' || raw.name === 'b') ? { name: raw.name === 'f' ? 'pagedown' : 'pageup', sequence: '' } : raw
try {
this.dispatch(key)
} catch (e) {
this.error(e)
}
this.bump()
}
private dispatch(key: Key): void {
if (this.lock) {
this.lock.onKey(key)
return
}
const top = this.overlays[this.overlays.length - 1]
if (top) {
top.onKey(key)
return
}
const screen = this.screen
if (key.name === 'f1') return this.commands().help()
if (this.backslash && charOf(key) === '?') {
this.backslash = false
return this.commands().help()
}
if (key.name === 'f2' || (key.ctrl && key.name === 'k')) return this.commands().palette()
if (key.name === 'f9') return this.commands().menu()
if (key.name === 'f10') return this.requestQuit()
if (this.focus === 'sidebar' && this.sidebar && this.showSidebar) {
if (this.sidebar.onKey(key)) return
} else if (screen?.onKey(key)) {
return
}
const typing = screen?.capturesText?.() ?? false
switch (key.name) {
case 'escape':
if (this.focus === 'sidebar') this.focus = 'content'
else this.back()
return
case 'tab':
if (this.showSidebar) this.focus = this.focus === 'sidebar' ? 'content' : 'sidebar'
return
case 'f5':
screen?.refresh?.()
return
case 'f3': case 'f4': case 'f6': case 'f7': case 'f8':
this.flash(`${key.name.toUpperCase()} does nothing on this screen`)
return
default:
break
}
if (typing) return
const ch = charOf(key)
if (ch === ':') return this.commands().commandLine()
if (ch === '?') return this.commands().help()
if (ch === '/' || ch === '.' || ch === '\\') return this.pendingBackslash(ch)
this.backslash = false
if (this.singleKeys && ch === 'v') return this.commands().viewSettings()
}
private backslash = false
/** `/` and `.` open the palette; a backslash followed by `?` is an alias of `?` (help). */
private pendingBackslash(ch: string): void {
if (ch === '\\') {
this.backslash = true
return
}
this.backslash = false
this.commands().palette()
}
/** The command layer (menu, palette, `:` line); set by commands.ts to avoid an import cycle. */
commandsImpl?: (store: AppStore) => CommandsLike
commands(): CommandsLike {
return this.commandsImpl!(this)
}
// ---- quitting ----
/** F10: a small dialog with Quit, Reset (home screen) and Cancel. */
requestQuit(): void {
this.open(new ChoiceOverlay<'quit' | 'reset' | 'cancel'>(this, 'Quit qqq?', [
{ label: 'Quit', value: 'quit', detail: 'end the program' },
{ label: 'Reset: go to the home screen', value: 'reset', detail: 'keeps the session' },
{ label: 'Cancel', value: 'cancel', detail: 'Esc' },
], (choice) => {
if (choice === 'quit') this.guard('quit', () => this.quit())
else if (choice === 'reset') this.reset()
}, 'Enter chooses'))
}
/** Warns about unsaved forms or running processes before `then`. */
private guard(what: 'quit' | 'reset', then: () => void): void {
const dirty = this.routes.some((s) => s.dirty?.())
const busy = this.routes.some((s) => s.busy?.())
if (!dirty && !busy) return then()
const warn = dirty ? 'A form has unsaved changes that will be lost.' : 'A process is still running; it continues on the server.'
this.confirm(`${warn} ${what === 'quit' ? 'Quit' : 'Reset'} anyway?`, then)
}
/**
* Reset: closes dialogs and every screen above Home (after the same warning), refreshes the
* metadata, and returns to Home with the Apps tree focused. The session and profile stay.
*/
reset(): void {
this.guard('reset', () => {
this.overlays = []
while (this.routes.length > 1) this.routes.pop()?.dispose?.()
const done = () => {
this.sidebar = this.sidebarFactory?.(this) ?? this.sidebar
this.screen?.resume?.()
this.focus = this.showSidebar ? 'sidebar' : 'content'
this.flash('reset: home screen, metadata refreshed')
this.bump()
}
if (!this.client) return done()
this.client.clearCaches()
this.client.metaData(true).then((m) => { this.meta = m }).catch((e) => this.error(e)).finally(done)
})
}
quit(): void {
this.dispose()
this.quitImpl()
}
dispose(): void {
for (const t of this.timers) clearInterval(t)
this.timers = []
this.streamAbort?.abort()
for (const s of this.routes) s.dispose?.()
}
// ---- connection ----
/** Called after a sign-in: loads version and metadata, starts ping, jobs and the realtime stream. */
async connect(client: TuiClient, options: { timers?: boolean } = {}): Promise<void> {
this.client = client
this.status.server = client.server
const [version, meta] = await Promise.all([client.version(), client.metaData()])
this.version = version
this.meta = meta
this.extensions.load(version)
setPresenceSource(collaborationPresence(this.extensions.registry, client, this.status.user))
this.status.apiVersion = version.apiVersion
this.sidebar = this.sidebarFactory?.(this)
if (options.timers) {
const ping = () => client.ping().then((ms) => { this.status.pingMs = ms }).catch(() => { this.status.pingMs = null }).finally(() => this.bump())
ping()
this.timers.push(setInterval(ping, 5000))
this.loadJobs()
this.timers.push(setInterval(() => this.loadJobs(), 15000))
this.startStream()
}
this.bump()
}
private loadJobs(): void {
const client = this.client
if (!client) return
if (!(this.version?.features ?? []).includes('durableJobs')) {
this.status.jobs = 'n/a'
return
}
client.data<{ activeCount?: number; jobs?: unknown[] }>('listJobs', { query: { scope: 'mine', state: 'active' } })
.then((r) => { this.status.jobs = String(r.activeCount ?? r.jobs?.length ?? 0) })
.catch(() => { this.status.jobs = 'n/a' })
.finally(() => this.bump())
}
/** The realtime stream: state in the footer, last event, reconnect with backoff, heartbeat timeout. */
private startStream(): void {
if (!(this.version?.features ?? []).includes('realtime') || !this.client || !this.target) {
this.status.stream = 'n/a'
return
}
const abort = new AbortController()
this.streamAbort = abort
const target = this.target
let delay = 1000
const loop = async () => {
while (!abort.signal.aborted) {
this.status.stream = this.status.stream === 'off' ? 'connecting' : 'reconnecting'
this.bump()
try {
const credential = await currentCredential({ store: storeFor(), target, clientChannel: TUI_CHANNEL })
let heartbeat = 30_000
let last = Date.now()
const watchdog = setInterval(() => { if (Date.now() - last > heartbeat * 2) inner.abort() }, 5000)
const inner = new AbortController()
abort.signal.addEventListener('abort', () => inner.abort())
try {
for await (const message of openRealtimeStream({ baseUrl: target.server, clientChannel: TUI_CHANNEL, signal: inner.signal, headers: credential.cookie ? { Cookie: credential.cookie } : undefined, token: () => credential.bearer })) {
last = Date.now()
delay = 1000
this.status.stream = 'connected'
if (message.event === 'hello') heartbeat = Number((message.data as { heartbeatSeconds?: number })?.heartbeatSeconds ?? 30) * 1000
else this.status.lastEvent = `${message.event} @ ${clock()}`
this.onRealtime?.(message.event, message.data)
this.bump()
}
} finally {
clearInterval(watchdog)
}
} catch {
// reconnect below
}
if (abort.signal.aborted) return
this.status.stream = 'reconnecting'
this.bump()
await new Promise((r) => setTimeout(r, delay))
delay = Math.min(30_000, delay * 2)
}
}
void loop()
}
/** Realtime events for the presence and extension hooks. */
onRealtime?: (event: string, data: unknown) => void
// ---- misc ----
remember(item: Omit<RecentItem, 'at'>): void {
if (!this.client) return
try {
addRecent(this.client.server, item)
} catch {
// not critical
}
}
setTheme(name: string): void {
this.settings.theme = setTheme(name).name
this.bump()
}
setAscii(ascii: boolean): void {
this.settings.ascii = ascii
setAscii(ascii)
this.bump()
}
resize(width: number, height: number): void {
this.width = width
this.height = height
if (!this.showSidebar && this.focus === 'sidebar') this.focus = 'content'
this.bump()
}
/** The function-key bar for the current context. */
fkeys(): Array<{ n: number; label: string | null }> {
const defaults: Record<number, string> = { 1: 'Help', 2: 'Go to', 3: 'View', 4: 'Edit', 5: 'Refresh', 6: 'Filter', 7: 'New', 8: 'Delete', 9: 'Menu', 10: 'Quit' }
const overrides: FKeyLabels = this.screen?.fkeys?.() ?? { 3: null, 4: null, 6: null, 7: null, 8: null }
return Object.entries(defaults).map(([n, label]) => {
const k = Number(n)
return { n: k, label: k in overrides ? overrides[k]! : label }
})
}
themeTokens() {
return theme()
}
private checkIdle(): void {
if (this.lock || !this.client) return
let minutes = 0
try {
minutes = readIdleMinutes()
} catch {
minutes = 0
}
if (minutes > 0 && Date.now() - this.lastInput > minutes * 60_000) this.commands().lock()
}
}
/** What the store needs from the sidebar (screens/sidebar.tsx). */
export interface SidebarLike {
onKey(key: Key): boolean
render(width: number, height: number, focused: boolean): import('react').ReactNode
}
/** What the store needs from the command layer (commands.ts). */
export interface CommandsLike {
help(): void
palette(): void
menu(): void
commandLine(initial?: string): void
viewSettings(): void
lock(): void
run(line: string): Promise<void>
}
let secretStoreFactory: () => import('@qrun-io/qqq-core').SecretStore = () => { throw new Error('no secret store') }
export function setSecretStoreFactory(factory: () => import('@qrun-io/qqq-core').SecretStore): void {
secretStoreFactory = factory
}
function storeFor() {
return secretStoreFactory()
}
export function clock(): string {
return new Date().toLocaleTimeString('en-GB')
}
+144
View File
@@ -0,0 +1,144 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Themes as token sets, and glyph sets (Unicode or ASCII). Every color the TUI draws comes from the
* current token set; nothing else names a color. The choice is saved in ~/.config/qqq/config.json
* through the kernel (readTheme / writeTheme).
*/
import type { BorderCharacters } from '@opentui/core'
export interface ThemeTokens {
name: string
label: string
/** Normal text. */
fg: string
/** Inactive text (unfocused sidebar entries, hints): readable on the background, never dark grey on black. */
dim: string
/** Accent: headings, column headers. */
acc: string
/** Second accent: keys, highlights. */
acc2: string
ok: string
warn: string
err: string
titleBg: string
titleFg: string
selBg: string
selFg: string
/** The selected row when its pane does not have focus. */
selInactiveBg: string
focusBorder: string
border: string
keyNum: string
keyLabelBg: string
keyLabelFg: string
overlayBg: string
statusBg: string
statusFg: string
zebraBg: string
inputBg: string
inputFg: string
/** Marked (Space-selected) rows. */
markFg: string
/** Whether alternating rows are shaded unless the view settings say otherwise. */
zebraByDefault: boolean
}
export const THEMES: Record<string, ThemeTokens> = {
'high-contrast': {
name: 'high-contrast', label: 'High contrast (bright text, strong accents)',
fg: '#ffffff', dim: '#c6cbd1', acc: '#5fd7ff', acc2: '#ffd75f', ok: '#5fff87', warn: '#ffd75f', err: '#ff6b6b',
titleBg: '#005f87', titleFg: '#ffffff', selBg: '#ffd75f', selFg: '#000000', selInactiveBg: '#4e4e4e',
focusBorder: '#ffd75f', border: '#a8b0b8', keyNum: '#ffffff', keyLabelBg: '#005f87', keyLabelFg: '#ffff87',
overlayBg: '#121212', statusBg: '#262626', statusFg: '#eeeeee', zebraBg: '#1c1c1c', inputBg: '#3a3a3a', inputFg: '#ffffff',
markFg: '#5fff87', zebraByDefault: false,
},
classic: {
name: 'classic', label: 'Classic (cyan title, yellow keys)',
fg: '#d5dce5', dim: '#a0a8b0', acc: '#7cc2e8', acc2: '#ffd98a', ok: '#8bd49b', warn: '#f1c86e', err: '#ff9088',
titleBg: '#1b6587', titleFg: '#ffffff', selBg: '#2d5f7c', selFg: '#ffffff', selInactiveBg: '#2a3540',
focusBorder: '#7cc2e8', border: '#5a6a7a', keyNum: '#ffffff', keyLabelBg: '#1b6587', keyLabelFg: '#ffd98a',
overlayBg: '#161d26', statusBg: '#10151b', statusFg: '#c0c8d0', zebraBg: '#18202a', inputBg: '#24313d', inputFg: '#ffffff',
markFg: '#8bd49b', zebraByDefault: true,
},
light: {
name: 'light', label: 'Light (for light terminal backgrounds)',
fg: '#1a1a1a', dim: '#4e4e4e', acc: '#005f87', acc2: '#875f00', ok: '#1e7b34', warn: '#875f00', err: '#b00020',
titleBg: '#005f87', titleFg: '#ffffff', selBg: '#005f87', selFg: '#ffffff', selInactiveBg: '#c6c6c6',
focusBorder: '#005f87', border: '#6c6c6c', keyNum: '#000000', keyLabelBg: '#005f87', keyLabelFg: '#ffffff',
overlayBg: '#f4f4f4', statusBg: '#dadada', statusFg: '#1a1a1a', zebraBg: '#ececec', inputBg: '#e0e0e0', inputFg: '#000000',
markFg: '#1e7b34', zebraByDefault: true,
},
}
export const DEFAULT_THEME = 'high-contrast'
export interface Glyphs {
ellipsis: string
up: string
down: string
bar: string
spark: string
pointer: string
closed: string
open: string
mark: string
unmark: string
check: string
uncheck: string
radioOn: string
radioOff: string
sep: string
rule: string
process: string
report: string
app: string
link: string
border?: BorderCharacters
}
const UNICODE: Glyphs = {
ellipsis: '…', up: '▲', down: '▼', bar: '█', spark: '▁▂▃▄▅▆▇█', pointer: '▶', closed: '▸', open: '▾', mark: '●', unmark: ' ',
check: '[x]', uncheck: '[ ]', radioOn: '(•)', radioOff: '( )', sep: '│', rule: '─', process: '▷', report: '≡', app: '◆', link: '↗',
}
const ASCII_BORDER: BorderCharacters = { topLeft: '+', topRight: '+', bottomLeft: '+', bottomRight: '+', horizontal: '-', vertical: '|', topT: '+', bottomT: '+', leftT: '+', rightT: '+', cross: '+' }
const ASCII: Glyphs = {
ellipsis: '~', up: '^', down: 'v', bar: '#', spark: ' .:-=+*#', pointer: '>', closed: '+', open: '-', mark: '*', unmark: ' ',
check: '[x]', uncheck: '[ ]', radioOn: '(*)', radioOff: '( )', sep: '|', rule: '-', process: '>', report: '=', app: '*', link: '->', border: ASCII_BORDER,
}
let currentTheme: ThemeTokens = THEMES[DEFAULT_THEME]!
let currentGlyphs: Glyphs = UNICODE
export function theme(): ThemeTokens {
return currentTheme
}
export function glyphs(): Glyphs {
return currentGlyphs
}
/** Switches the theme; unknown names fall back to the default. Returns the theme in effect. */
export function setTheme(name: string | undefined): ThemeTokens {
currentTheme = (name && THEMES[name]) || THEMES[DEFAULT_THEME]!
return currentTheme
}
export function setAscii(ascii: boolean): void {
currentGlyphs = ascii ? ASCII : UNICODE
}
export function isAscii(): boolean {
return currentGlyphs === ASCII
}
/** Box border props for the current glyph set. */
export function borderProps(focused: boolean): { border: true; borderColor: string; customBorderChars?: BorderCharacters } {
return { border: true, borderColor: focused ? currentTheme.focusBorder : currentTheme.border, ...(currentGlyphs.border ? { customBorderChars: currentGlyphs.border } : {}) }
}
+68
View File
@@ -0,0 +1,68 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { ReactNode } from 'react'
import type { Key } from './keys'
/** A function-key label for the bar: a string, or null when the key does nothing here (the label is hidden). */
export type FKeyLabels = Partial<Record<number, string | null>>
/** One entry of the F9 command center (and of the palette): runnable, searchable, with its shortcut. */
export interface MenuEntry {
section: 'Context' | 'Go to' | 'Work' | 'Connection' | 'Settings' | 'Help'
label: string
/** Shortcut shown beside the entry. */
key?: string
/** The `:` command that does the same, when there is one. */
command?: string
/** Shown but not runnable, with the reason (parked, not installed). */
disabled?: string
/** Extra text (a count, a state). */
detail?: string
run?: () => void
}
/**
* A screen on the navigation stack. The object is the route entry: its state (cursor, page,
* filter, selection, scroll) lives here, not in React component state, so going back finds it
* exactly as it was. Views read from it; key handlers mutate it and ask the store to repaint.
*/
export interface Screen {
readonly kind: string
/** Identity for the error boundary (a new identity resets a failed view). */
readonly id: string
title(): string
onKey(key: Key): boolean
render(width: number, height: number, focused: boolean): ReactNode
/** Function-key labels that differ from the defaults (null hides a key that does nothing here). */
fkeys?(): FKeyLabels
/** Entries for the Context section of the menu. */
menu?(): MenuEntry[]
/** Unsaved changes (forms): leaving or quitting asks first. */
dirty?(): boolean
/** Work in progress (a running process): quitting warns. */
busy?(): boolean
/** Called when this screen is on top again after a screen above it closed. */
resume?(): void
refresh?(): void
dispose?(): void
/** While true, printable keys belong to the screen (a text field has focus): single-key shortcuts are off. */
capturesText?(): boolean
/** The presence scope of the screen (`table:person`, `record:person:3`), for the collaboration slot. */
presenceScope?(): string | undefined
/** The CLI command that shows the same thing, for copying. */
cliEquivalent?(): string | undefined
/** The view-settings dialog for this screen, when it has one. */
viewSettings?(): Overlay | undefined
/** The help topic: table, process or app name, for F1. */
helpTopic?(): { kind: 'table' | 'process' | 'app'; name: string } | undefined
}
/** A modal overlay: takes every key while it is on top. */
export interface Overlay {
readonly id: string
onKey(key: Key): void
render(width: number, height: number): ReactNode
}
+104
View File
@@ -0,0 +1,104 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { displayDateTime } from '@qrun-io/qqq-core'
import { glyphs } from '../theme'
/** Cuts text to a width, with an ellipsis when it was longer. */
export function trunc(text: string, width: number): string {
if (width <= 0) return ''
const flat = text.replace(/[\r\n\t]+/g, ' ')
return flat.length <= width ? flat : width === 1 ? glyphs().ellipsis : flat.slice(0, width - 1) + glyphs().ellipsis
}
/** Pads (or cuts) text to exactly `width` cells. */
export function pad(text: string, width: number): string {
const t = trunc(text, width)
return t + ' '.repeat(Math.max(0, width - t.length))
}
/** Right-aligns text in `width` cells. */
export function padLeft(text: string, width: number): string {
const t = trunc(text, width)
return ' '.repeat(Math.max(0, width - t.length)) + t
}
/** Centres text in `width` cells. */
export function center(text: string, width: number): string {
const t = trunc(text, width)
const left = Math.floor((width - t.length) / 2)
return ' '.repeat(Math.max(0, left)) + t + ' '.repeat(Math.max(0, width - t.length - left))
}
export function num(n: number | undefined | null): string {
return n === undefined || n === null ? '…' : n.toLocaleString('en-US')
}
/** A value as text: display value when present. */
export function cell(record: { values?: Record<string, unknown>; displayValues?: Record<string, string> } | undefined, field: string): string {
if (!record) return ''
const display = record.displayValues?.[field]
const raw = record.values?.[field]
const v = display ?? raw
if (v === null || v === undefined) return ''
return typeof v === 'object' ? JSON.stringify(v) : String(v)
}
/** A value as text for a field: DATE_TIME in the viewer's zone (kernel displayDateTime), else the display value. */
export function fieldCell(record: { values?: Record<string, unknown>; displayValues?: Record<string, string> } | undefined, field: { name: string; type: string }): string {
if (record && field.type === 'DATE_TIME') {
const raw = record.values?.[field.name]
if (raw !== null && raw !== undefined && raw !== '') return displayDateTime(raw, record.displayValues?.[field.name])
}
return cell(record, field.name)
}
/** Splits a command line into arguments as a shell would (quotes, backslash escapes; no expansion). */
export function shellSplit(line: string): string[] {
const out: string[] = []
let current = ''
let quote: '"' | "'" | null = null
let has = false
for (let i = 0; i < line.length; i++) {
const ch = line[i]!
if (quote) {
if (ch === quote) quote = null
else if (ch === '\\' && quote === '"' && i + 1 < line.length) current += line[++i]
else current += ch
} else if (ch === '"' || ch === "'") {
quote = ch
has = true
} else if (ch === '\\' && i + 1 < line.length) {
current += line[++i]
has = true
} else if (/\s/.test(ch)) {
if (has || current) out.push(current)
current = ''
has = false
} else {
current += ch
}
}
if (has || current) out.push(current)
return out
}
/** Quotes an argument for a command line when it needs it. */
export function shellQuote(arg: string): string {
return /^[A-Za-z0-9_./:=,@+-]+$/.test(arg) ? arg : `"${arg.replace(/(["\\$`])/g, '\\$1')}"`
}
export function errorText(error: unknown): string {
if (error && typeof error === 'object' && 'fieldErrors' in error && Array.isArray((error as { fieldErrors: unknown[] }).fieldErrors) && (error as { fieldErrors: unknown[] }).fieldErrors.length) {
const e = error as { message: string; fieldErrors: Array<{ field: string; message: string }> }
return `${e.message}: ${e.fieldErrors.map((f) => `${f.field} ${f.message}`).join('; ')}`
}
if (error instanceof Error) {
const cause = (error as Error & { cause?: { message?: string } }).cause
if (/fetch failed|Unable to connect|ECONNREFUSED/i.test(error.message + String(cause?.message ?? ''))) return `cannot reach the server (${cause?.message ?? error.message})`
return error.message
}
return String(error)
}
+142
View File
@@ -0,0 +1,142 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Draws the kernel's widget render model (RenderBlock) as styled terminal lines: stat boxes, bar
* charts in block characters, sparklines for lines, percentage bars for pies, aligned tables,
* steppers, alerts. New widget types are handled once in the kernel; this only draws blocks.
*/
import type { RenderBlock } from '@qrun-io/qqq-core'
import { wrap } from './overlays/basic'
import { glyphs, theme } from './theme'
import { pad, padLeft, trunc } from './util/text'
export interface StyledLine {
text: string
fg?: string
bg?: string
bold?: boolean
}
function fmt(n: number): string {
return Number.isFinite(n) ? n.toLocaleString('en-US', { maximumFractionDigits: 2 }) : String(n)
}
/** Aligned table lines that fit a width (columns shrink from the widest). */
export function tableLines(headers: string[], rows: string[][], width: number): StyledLine[] {
const t = theme()
if (!headers.length) return []
const widths = headers.map((h, i) => Math.min(40, Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length))))
const total = () => widths.reduce((a, b) => a + b + 2, 0)
while (total() > width && Math.max(...widths) > 4) widths[widths.indexOf(Math.max(...widths))]!--
const line = (cells: string[]) => cells.map((c, i) => pad(c, widths[i]!)).join(' ')
return [
{ text: line(headers), fg: t.acc, bold: true },
{ text: widths.map((w) => glyphs().rule.repeat(w)).join(' '), fg: t.dim },
...rows.map((r) => ({ text: line(r) })),
]
}
/** Turns blocks into lines for a width. */
export function blockLines(blocks: RenderBlock[], width: number): StyledLine[] {
const t = theme()
const g = glyphs()
const out: StyledLine[] = []
for (const b of blocks) {
switch (b.kind) {
case 'heading':
out.push({ text: trunc(b.text, width), fg: t.acc, bold: true })
if (b.sub) out.push(...wrap(b.sub, width).map((text) => ({ text, fg: t.dim })))
break
case 'text':
out.push(...wrap(b.text, width).map((text) => ({ text })))
break
case 'stat': {
const delta = b.delta ? ` ${b.delta.amount >= 0 ? '+' : ''}${b.delta.amount}%${b.delta.label ? ` ${b.delta.label}` : ''}` : ''
out.push({ text: trunc(`${b.label ? `${b.label}: ` : ''}${b.value}${b.context ? ` ${b.context}` : ''}`, width), fg: t.fg, bold: true })
if (delta) out.push({ text: trunc(delta.trim(), width), fg: b.delta?.good === true ? t.ok : b.delta?.good === false ? t.err : t.dim })
break
}
case 'table':
out.push(...tableLines(b.headers, b.rows.map((r) => b.columns.map((c) => String(r[c] ?? ''))), width))
if (!b.rows.length) out.push({ text: '(no rows)', fg: t.dim })
break
case 'series': {
const labelWidth = Math.min(18, Math.max(4, ...b.labels.map((l) => l.length)))
if (b.chart === 'line' && b.datasets.length) {
for (const d of b.datasets) {
const max = Math.max(...d.data, 0)
const min = Math.min(...d.data, 0)
const spark = d.data.map((n) => g.spark[Math.round(((n - min) / ((max - min) || 1)) * (g.spark.length - 1))]).join('')
out.push({ text: trunc(`${pad(d.label || 'value', labelWidth)} ${spark} ${fmt(min)} … ${fmt(max)}`, width), fg: t.acc2 })
}
out.push(...tableLines(['', ...b.datasets.map((d) => d.label || 'value')], b.labels.map((l, i) => [l, ...b.datasets.map((d) => fmt(d.data[i] ?? 0))]), width))
} else if (b.chart === 'pie' && b.datasets[0]) {
const d = b.datasets[0]
const sum = d.data.reduce((a, n) => a + Math.abs(n), 0) || 1
const barWidth = Math.max(10, width - labelWidth - 14)
b.labels.forEach((l, i) => {
const pct = (Math.abs(d.data[i] ?? 0) / sum) * 100
out.push({ text: `${pad(l, labelWidth)} ${pad(g.bar.repeat(Math.round((pct / 100) * barWidth)), barWidth)} ${padLeft(pct.toFixed(1) + '%', 6)}`, fg: i % 2 ? t.acc : t.acc2 })
})
} else {
const values = b.labels.map((_, i) => b.datasets.reduce((a, d) => a + (d.data[i] ?? 0), 0))
const max = Math.max(...values.map(Math.abs), 1)
const numWidth = Math.max(...values.map((v) => fmt(v).length), 1)
const barWidth = Math.max(6, width - labelWidth - numWidth - 4)
b.labels.forEach((l, i) => {
if (b.datasets.length > 1 && b.chart === 'stackedBar') {
// one segment per dataset, alternating accents
let line = ''
b.datasets.forEach((d, k) => { line += (k % 2 ? '▓' : g.bar).repeat(Math.round((Math.abs(d.data[i] ?? 0) / max) * barWidth)) })
out.push({ text: `${pad(l, labelWidth)} ${pad(line, barWidth)} ${padLeft(fmt(values[i]!), numWidth)}`, fg: t.acc })
} else {
out.push({ text: `${pad(l, labelWidth)} ${pad(g.bar.repeat(Math.round((Math.abs(values[i]!) / max) * barWidth)), barWidth)} ${padLeft(fmt(values[i]!), numWidth)}`, fg: t.acc })
}
})
if (b.datasets.length > 1) out.push({ text: trunc(b.datasets.map((d, k) => `${k % 2 ? '▓' : g.bar} ${d.label}`).join(' '), width), fg: t.dim })
}
break
}
case 'pairs': {
const w = Math.min(28, Math.max(...b.pairs.map((p) => p.label.length), 1))
for (const p of b.pairs) out.push({ text: trunc(`${pad(p.label, w)} ${p.value}`, width) })
break
}
case 'steps':
for (const s of b.steps) out.push({ text: trunc(`${s.state === 'done' ? g.check : s.state === 'active' ? g.pointer + ' ' : g.uncheck} ${s.label}`, width), fg: s.state === 'active' ? t.acc2 : s.state === 'done' ? t.ok : t.dim })
break
case 'alert':
out.push(...wrap(`[${b.level.toUpperCase()}] ${b.text}`, width).map((text) => ({ text, fg: b.level === 'error' ? t.err : b.level === 'warning' ? t.warn : b.level === 'success' ? t.ok : t.acc })))
break
case 'link':
out.push({ text: trunc(`${g.link} ${b.label}: ${b.href}`, width), fg: t.acc })
break
case 'rule':
out.push({ text: g.rule.repeat(Math.min(width, 60)), fg: t.dim })
break
case 'children':
b.blocks.forEach((child, i) => {
if (i) out.push({ text: '' })
out.push(...blockLines(child, width))
})
break
case 'raw':
out.push({ text: trunc(`(${b.type}: no terminal rendering yet)`, width), fg: t.dim })
break
}
}
return out
}
/** Draws lines as <text> rows, padded to the width. */
export function Lines({ lines, width }: { lines: StyledLine[]; width: number }) {
return (
<>
{lines.map((l, i) => <text key={i} fg={l.fg ?? theme().fg} bg={l.bg}>{l.bold ? <strong>{pad(l.text, width)}</strong> : pad(l.text, width)}</text>)}
</>
)
}
+111
View File
@@ -0,0 +1,111 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/** A fake UI API server for in-memory TUI tests: a person table with pets, a contingent address form, a process. */
type J = Record<string, unknown>
const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } })
export const PEOPLE = Array.from({ length: 30 }, (_, i) => ({ id: i + 1, firstName: `First${i + 1}`, lastName: i % 2 ? 'Odd' : 'Even', birthDate: `19${70 + (i % 30)}-01-01` }))
const personMeta = {
name: 'person', label: 'Person', primaryKeyField: 'id', insertPermission: true, editPermission: true, deletePermission: true, capabilities: ['TABLE_QUERY'],
fields: {
id: { name: 'id', label: 'Id', type: 'INTEGER', isEditable: false },
firstName: { name: 'firstName', label: 'First Name', type: 'STRING', isRequired: true },
lastName: { name: 'lastName', label: 'Last Name', type: 'STRING' },
birthDate: { name: 'birthDate', label: 'Birth Date', type: 'DATE' },
},
sections: [{ name: 'identity', label: 'Identity', tier: 'T1', fieldNames: ['id', 'firstName', 'lastName'] }, { name: 'more', label: 'More', tier: 'T2', fieldNames: ['birthDate'] }],
associations: [],
}
const addressMeta = {
name: 'address', label: 'Address', primaryKeyField: 'id', insertPermission: true, editPermission: true, deletePermission: true,
fields: {
id: { name: 'id', label: 'Id', type: 'INTEGER', isEditable: false },
label: { name: 'label', label: 'Label', type: 'STRING' },
countryId: { name: 'countryId', label: 'Country', type: 'STRING', possibleValueSourceName: 'country' },
stateId: { name: 'stateId', label: 'State', type: 'STRING', possibleValueSourceName: 'state', possibleValueSourceDependsOn: ['countryId'], possibleValueSourceDependencyBehavior: 'DISABLE_UNTIL_SET' },
},
sections: [{ name: 's', label: 'Address', tier: 'T1', fieldNames: ['id', 'label', 'countryId', 'stateId'] }],
}
const STATES: Record<string, Array<{ id: string; label: string }>> = { USA: [{ id: 'MO', label: 'Missouri' }, { id: 'IL', label: 'Illinois' }], CAN: [{ id: 'ON', label: 'Ontario' }] }
const processMeta = {
name: 'sleeper', label: 'Sleeper', frontendSteps: [
{ name: 'input', label: 'Input', components: [{ type: 'EDIT_FORM' }], formFields: [{ name: 'millis', label: 'Millis', type: 'INTEGER', isRequired: true }] },
{ name: 'result', label: 'Result', components: [{ type: 'VIEW_FORM' }], viewFields: [{ name: 'message', label: 'Message', type: 'STRING' }] },
],
}
export interface FakeLog { requests: Array<{ method: string; path: string; body?: unknown }> }
/** A fetch answering the operations the TUI uses. */
export function fakeFetch(log: FakeLog = { requests: [] }): typeof fetch {
let statusPolls = 0
return (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input as RequestInfo, init)
const url = new URL(request.url)
const path = url.pathname.replace('/qqq/v1', '')
let body: unknown
const type = request.headers.get('content-type') ?? ''
if (request.method !== 'GET') {
if (type.includes('json')) body = await request.json()
else if (type.includes('form')) body = Object.fromEntries((await request.formData()).entries())
}
log.requests.push({ method: request.method, path, body })
if (path === '/version') return json({ apiVersion: '1.8.0', features: ['metaData', 'tables', 'processes'], extensions: [] })
if (path === '/metaData') {
return json({
apps: { people: { name: 'people', label: 'People', children: [{ type: 'TABLE', name: 'person', label: 'Person' }, { type: 'TABLE', name: 'address', label: 'Address' }, { type: 'PROCESS', name: 'sleeper', label: 'Sleeper' }] } },
appTree: [{ type: 'APP', name: 'people', label: 'People', children: [{ type: 'TABLE', name: 'person', label: 'Person' }, { type: 'TABLE', name: 'address', label: 'Address' }, { type: 'PROCESS', name: 'sleeper', label: 'Sleeper' }] }],
tables: { person: { name: 'person', label: 'Person', insertPermission: true }, address: { name: 'address', label: 'Address', insertPermission: true } },
processes: { sleeper: { name: 'sleeper', label: 'Sleeper' } },
widgets: {}, branding: { appName: 'Fake' },
})
}
if (path === '/metaData/table/person') return json(personMeta)
if (path === '/metaData/table/address') return json(addressMeta)
if (path === '/metaData/process/sleeper') return json(processMeta)
const filter = ((body as J | undefined)?.filter ?? {}) as { skip?: number; limit?: number; orderBys?: Array<{ fieldName: string; isAscending: boolean }>; criteria?: Array<{ fieldName: string; values: string[] }> }
let people = [...PEOPLE]
for (const c of filter.criteria ?? []) people = people.filter((p) => String((p as J)[c.fieldName]) === String(c.values[0]))
const order = filter.orderBys?.[0]
const key = (o: J) => (typeof o[order?.fieldName ?? 'id'] === 'number' ? (o[order?.fieldName ?? 'id'] as number) : String(o[order?.fieldName ?? 'id']))
if (order) people.sort((a, b) => (key(a) < key(b) ? -1 : 1) * (order.isAscending ? 1 : -1))
if (path === '/table/person/count') return json({ count: people.length })
if (path === '/table/person/query') {
const page = people.slice(filter.skip ?? 0, (filter.skip ?? 0) + (filter.limit ?? 50))
return json({ records: page.map((p) => ({ values: p, displayValues: {}, recordLabel: `${p.firstName} ${p.lastName}` })) })
}
const rec = /^\/table\/person\/(\d+)$/.exec(path)
if (rec && request.method === 'GET') {
const p = PEOPLE.find((x) => x.id === Number(rec[1]))
return p ? json({ record: { values: p, displayValues: {}, recordLabel: `${p.firstName} ${p.lastName}`, associatedRecords: {} } }) : json({ code: 'NOT_FOUND', message: 'no' }, 404)
}
const pv = /^\/table\/address\/possibleValues\/(\w+)$/.exec(path)
if (pv) {
const b = body as { searchTerm?: string; ids?: string[]; labels?: string[]; values?: J }
let options = pv[1] === 'countryId' ? [{ id: 'USA', label: 'USA' }, { id: 'CAN', label: 'Canada' }] : STATES[String(b.values?.countryId ?? '')] ?? []
if (b.ids?.length) options = options.filter((o) => b.ids!.includes(o.id))
if (b.labels?.length) options = options.filter((o) => b.labels!.some((l) => l.toLowerCase() === o.label.toLowerCase()))
if (b.searchTerm) options = options.filter((o) => o.label.toLowerCase().includes(b.searchTerm!.toLowerCase()))
return json({ options })
}
if (path === '/table/address' && request.method === 'POST') return json({ record: { values: { id: 9, ...(body as J) }, recordLabel: 'new' } })
if (path === '/processes/sleeper/init') return json({ type: 'COMPLETE', processUUID: 'p1', nextStep: 'input', values: {} })
if (path === '/processes/sleeper/p1/step/input') return json({ type: 'JOB_STARTED', processUUID: 'p1', jobUUID: 'j1' })
if (path === '/processes/sleeper/p1/status/j1') {
statusPolls++
if (statusPolls < 2) return json({ type: 'RUNNING', processUUID: 'p1', message: 'sleeping', current: 1, total: 2 })
const millis = (log.requests.find((r) => r.path === '/processes/sleeper/p1/step/input')?.body as J | undefined)?.values
return json({ type: 'COMPLETE', processUUID: 'p1', nextStep: 'result', values: { message: `slept ${JSON.parse(String(millis ?? '{}')).millis}` } })
}
return json({ code: 'NOT_FOUND', message: `fake server has no ${request.method} ${path}` }, 404)
}) as typeof fetch
}
+86
View File
@@ -0,0 +1,86 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { act } from 'react'
import { testRender } from '@opentui/react/test-utils'
import type { TestRendererSetup } from '@opentui/core/testing'
import { upsertProfile, type SecretStore } from '@qrun-io/qqq-core'
import { App } from '../src/app'
import { boot, parseArgs } from '../src/boot'
import type { AppStore } from '../src/store'
import { fakeFetch, type FakeLog } from './fake-server'
// async loads repaint outside act(); the frames are awaited explicitly, so the warning is noise here
const consoleError = console.error
console.error = (...args: unknown[]) => {
if (typeof args[0] === 'string' && args[0].includes('not wrapped in act')) return
consoleError(...args)
}
/** Isolated config, cache and state directories for one test file. */
export function isolate(): void {
const dir = mkdtempSync(join(tmpdir(), 'qqq-tui-test-'))
process.env.QQQ_CONFIG_DIR = join(dir, 'config')
process.env.QQQ_CACHE_DIR = join(dir, 'cache')
process.env.QQQ_STATE_DIR = join(dir, 'state')
delete process.env.QQQ_TOKEN
delete process.env.QQQ_PROFILE
delete process.env.QQQ_SERVER
upsertProfile('fake', { server: 'http://fake.test' })
}
/** A secret store holding a session sign-in for every account. */
export function sessionSecrets(): SecretStore {
const login = JSON.stringify({ kind: 'session', authType: 'MOCK', sessionUUID: 'test-session', user: { name: 'Tester' }, createdAt: new Date().toISOString() })
return { kind: 'memory', get: async () => login, set: async () => undefined, delete: async () => true }
}
export interface Harness {
setup: TestRendererSetup
store: AppStore
log: FakeLog
frame(): string
}
/** Renders the whole TUI against the fake server and waits for the home screen. */
export async function startTui(width = 120, height = 36): Promise<Harness> {
const log: FakeLog = { requests: [] }
const store = boot({ args: parseArgs(['--profile', 'fake']), quit: () => undefined, build: 'test', secrets: sessionSecrets, fetch: fakeFetch(log) })
const setup = await testRender(<App store={store} />, { width, height })
const h: Harness = { setup, store, log, frame: () => setup.captureCharFrame() }
await waitFor(h, (f) => f.includes('Apps'))
return h
}
/** Fires input inside act(), lets data events settle, paints one frame. */
export async function press(h: Harness, fire: () => void | Promise<void>, settleMs = 30): Promise<string> {
await act(async () => {
await fire()
await new Promise((resolve) => setTimeout(resolve, settleMs))
})
await h.setup.renderOnce()
return h.frame()
}
/** Waits until the frame satisfies a predicate (async loads), or fails with the last frame. */
export async function waitFor(h: Harness, ok: (frame: string) => boolean, timeoutMs = 3000): Promise<string> {
const end = Date.now() + timeoutMs
let frame = ''
while (Date.now() < end) {
frame = await press(h, () => undefined, 25)
if (ok(frame)) return frame
}
throw new Error(`timed out; last frame:\n${frame}`)
}
/** Types a `:` command and runs it. */
export async function command(h: Harness, line: string): Promise<void> {
await press(h, () => h.setup.mockInput.typeText(':'))
await press(h, () => h.setup.mockInput.typeText(line))
await press(h, () => h.setup.mockInput.pressEnter())
}
+119
View File
@@ -0,0 +1,119 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { afterEach, beforeAll, expect, test } from 'bun:test'
import { KeyCodes } from '@opentui/core/testing'
import type { TableScreen } from '../src/screens/table'
import { command, isolate, press, startTui, waitFor, type Harness } from './helpers'
let h: Harness | undefined
beforeAll(() => isolate())
afterEach(() => {
h?.store.dispose()
h?.setup.renderer.destroy()
h = undefined
})
test('home, then the grid; Esc from a record lands on the same row, also after stepping', async () => {
h = await startTui()
expect(h.frame()).toContain('People')
await command(h, 'table query person')
await waitFor(h, (f) => f.includes('First1 ') && f.includes('30 records'))
const grid = h.store.screen as TableScreen
for (let i = 0; i < 3; i++) await press(h, () => h!.setup.mockInput.pressArrow('down'))
await press(h, () => h!.setup.mockInput.pressEnter())
await waitFor(h, (f) => f.includes('record 4 of 30'))
await press(h, () => h!.setup.mockInput.pressEscape())
await waitFor(h, (f) => f.includes('row 4 '))
expect(h.store.screen).toBe(grid)
expect(grid.cursor!.index).toBe(3)
await press(h, () => h!.setup.mockInput.pressEnter())
await waitFor(h, (f) => f.includes('record 4 of 30'))
for (let i = 0; i < 3; i++) {
await press(h, () => h!.setup.mockInput.pressKey(']'))
await waitFor(h, (f) => f.includes(`record ${5 + i} of 30`))
}
await press(h, () => h!.setup.mockInput.pressEscape())
const back = await waitFor(h, (f) => f.includes('row 7 '))
expect(grid.cursor!.index).toBe(6)
expect(back).toContain('First7')
})
test('the grid pages on the server, sorts with s and filters through the kernel grammar', async () => {
h = await startTui(120, 20)
await command(h, 'table query person')
await waitFor(h, (f) => f.includes('30 records'))
const grid = h.store.screen as TableScreen
const pageSize = grid.cursor!.pageSize
expect(pageSize).toBeLessThan(30)
await press(h, () => h!.setup.mockInput.pressKey(KeyCodes.END))
await waitFor(h, (f) => f.includes('First30'))
const lastQuery = h.log.requests.filter((r) => r.path === '/table/person/query').at(-1)!
expect((lastQuery.body as { filter: { skip: number } }).filter.skip).toBe(Math.floor(29 / pageSize) * pageSize)
await press(h, () => h!.setup.mockInput.pressKey(KeyCodes.HOME))
await press(h, () => h!.setup.mockInput.pressArrow('right'))
await press(h, () => h!.setup.mockInput.pressKey('s'))
await waitFor(h, (f) => f.includes('sort firstName asc'))
const sorted = h.log.requests.filter((r) => r.path === '/table/person/query').at(-1)!
expect((sorted.body as { filter: { orderBys: unknown } }).filter.orderBys).toEqual([{ fieldName: 'firstName', isAscending: true }])
await command(h, 'filter lastName = Odd')
const filtered = await waitFor(h, (f) => f.includes('15 records'))
expect(filtered).toContain('lastName EQUALS Odd')
})
test('a form with a contingent field: State waits for Country, then narrows to its states', async () => {
h = await startTui()
await command(h, 'table create address')
let f = await waitFor(h, (x) => x.includes('New Address'))
expect(f).toContain('set Country first')
await press(h, () => h!.setup.mockInput.typeText('Home'))
await press(h, () => h!.setup.mockInput.pressTab())
await press(h, () => h!.setup.mockInput.pressEnter())
await waitFor(h, (x) => x.includes('Choose Country') && x.includes('Canada'))
await press(h, () => h!.setup.mockInput.pressEnter())
f = await waitFor(h, (x) => !x.includes('Choose Country') && !x.includes('set Country first'))
expect(f).toContain('USA')
await press(h, () => h!.setup.mockInput.pressEnter())
f = await waitFor(h, (x) => x.includes('Choose State') && x.includes('Missouri'))
expect(f).toContain('narrowed by Country = USA')
expect(f).not.toContain('Ontario')
await press(h, () => h!.setup.mockInput.pressEnter())
await waitFor(h, (x) => x.includes('Missouri') && !x.includes('Choose State'))
await press(h, () => h!.setup.mockInput.pressKey('s', { ctrl: true }))
await waitFor(h, (x) => x.includes('created Address 9'))
const insert = h.log.requests.find((r) => r.path === '/table/address' && r.method === 'POST')!
expect(insert.body).toEqual({ label: 'Home', countryId: 'USA', stateId: 'MO' })
})
test('the process wizard: step form, asynchronous job with progress, summary', async () => {
h = await startTui()
await command(h, 'process run sleeper')
await waitFor(h, (x) => x.includes('Millis'))
await press(h, () => h!.setup.mockInput.typeText('250'))
await press(h, () => h!.setup.mockInput.pressKey('s', { ctrl: true }))
const done = await waitFor(h, (x) => x.includes('Finished in'), 6000)
expect(done).toContain('slept 250')
const step = h.log.requests.find((r) => r.path === '/processes/sleeper/p1/step/input')!
expect(JSON.parse(String((step.body as { values: string }).values))).toEqual({ millis: '250' })
})
test('F10 offers Quit, Reset and Cancel; Reset returns to Home with the Apps tree focused', async () => {
h = await startTui()
await command(h, 'table query person')
await waitFor(h, (x) => x.includes('30 records'))
let f = await press(h, () => h!.setup.mockInput.pressKey(KeyCodes.F10))
expect(f).toContain('Quit qqq?')
expect(f).toContain('Reset: go to the home screen')
f = await press(h, () => h!.setup.mockInput.pressEscape())
expect(f).not.toContain('Quit qqq?')
await press(h, () => h!.setup.mockInput.pressKey(KeyCodes.F10))
await press(h, () => h!.setup.mockInput.pressArrow('down'))
await press(h, () => h!.setup.mockInput.pressEnter())
await waitFor(h, (x) => x.includes('reset: home screen'))
expect(h.store.routes.length).toBe(1)
expect(h.store.screen?.kind).toBe('home')
expect(h.store.focus).toBe('sidebar')
})
+52
View File
@@ -0,0 +1,52 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { expect, test } from 'bun:test'
import { parseOptions } from '../src/commands'
import { LineEditor } from '../src/line-editor'
import { layoutColumns, visibleFrom } from '../src/screens/table'
import { THEMES } from '../src/theme'
import { shellSplit } from '../src/util/text'
const fields = [
{ name: 'id', label: 'Id', type: 'INTEGER' },
{ name: 'email', label: 'Email', type: 'STRING' },
{ name: 'note', label: 'Note', type: 'TEXT' },
]
const rows = [{ values: { id: 1, email: 'a-rather-long-address@example.invalid', note: 'x'.repeat(80) } }]
test('columns start at content width (capped at 40) and truncated ones get the spare width', () => {
const tight = layoutColumns(fields, rows, 50, {})
expect(tight.map((c) => c.width)).toEqual([3, 37, 40])
const wide = layoutColumns(fields, rows, 200, {})
expect(wide.find((c) => c.name === 'note')!.width).toBe(80)
expect(layoutColumns(fields, rows, 200, { widths: { note: 12 } }).find((c) => c.name === 'note')!.width).toBe(12)
expect(visibleFrom(tight, 0, 50).map((c) => c.name)).toEqual(['id', 'email'])
expect(visibleFrom(tight, 2, 10).map((c) => c.name)).toEqual(['note'])
})
test('the line editor applies a burst of keys in order, from its own state', () => {
const ed = new LineEditor('')
for (const ch of 'Morgan') ed.handle({ name: ch, sequence: ch })
ed.handle({ name: 'left', sequence: '' })
ed.handle({ name: 'backspace', sequence: '' })
expect(ed.value).toBe('Morgn')
ed.handle({ name: 'u', sequence: '', ctrl: true })
expect([ed.value, ed.cursor]).toEqual(['n', 0])
})
test('command lines split like a shell and options collect repeated values', () => {
expect(shellSplit('table query person -w "lastName = O\'Neil" -o -id')).toEqual(['table', 'query', 'person', '-w', "lastName = O'Neil", '-o', '-id'])
expect(parseOptions(['-w', 'a = 1', '-w', 'b = 2', '--or', '-o', 'x'])).toEqual({ w: ['a = 1', 'b = 2'], or: ['true'], o: ['x'] })
expect(parseOptions(['-w', 'lastName = Sample', '-o', '-id', 'firstName'])).toEqual({ w: ['lastName = Sample'], o: ['-id', 'firstName'] })
})
test('every theme defines every token, and high-contrast has no dark grey text', () => {
const keys = Object.keys(THEMES['high-contrast']!)
for (const t of Object.values(THEMES)) expect(Object.keys(t).sort()).toEqual([...keys].sort())
const lum = (hex: string) => { const n = parseInt(hex.slice(1), 16); return (0.2126 * (n >> 16) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255 }
expect(lum(THEMES['high-contrast']!.dim)).toBeGreaterThan(0.6)
expect(lum(THEMES.classic!.dim)).toBeGreaterThan(0.55)
})
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["bun-types"]
},
"include": ["src", "test", "scripts"]
}
+1 -1
View File
@@ -14,7 +14,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, relative } from 'node:path'
const HEADER = 'Copyright 2026 QRun.IO, Inc.'
const roots = ['packages/ui-api-client/src', 'packages/core/src', 'packages/conformance/src', 'packages/cli/src', 'scripts']
const roots = ['packages/ui-api-client/src', 'packages/core/src', 'packages/conformance/src', 'packages/cli/src', 'packages/tui/src', 'packages/tui/test', 'packages/tui/scripts', 'scripts']
const problems = []
function walk(dir) {
+55 -3
View File
@@ -86,6 +86,45 @@ STATUS = {
'QRY-093': (X, 'no screen state to keep: views are opened by name'), 'QRY-094': (N, 'saved-view list counts quick views with doCount (--counts counts all)'), 'QRY-096': (N, 'opening a view drops removed fields with Material\'s warnings on stderr'), 'HLP-006': (X, 'author affordance'),
}
# TUI column (M3, qqq ui): rows verified in tmux against the sample server or by the in-memory tests in
# packages/tui/test. Rows not listed fall back to n/a for browser-only rows and todo for the rest.
TUI = {
'NAV-001': (P, 'qqq ui table query <t>; :table query <t>'), 'NAV-002': (P, 'Apps tree, backend order'), 'NAV-003': (P, 'nested apps in the tree'),
'NAV-004': (P, 'Enter opens; current entry marked; arrows expand and collapse'), 'NAV-005': (N, 'markers for processes and reports; icons not drawn'),
'NAV-006': (P, 'hidden tables and processes are not listed'), 'NAV-007': (P, ':table query and :record open hidden tables the user may read'),
'NAV-008': (P, 'app home: sections with tables, processes, reports'), 'NAV-009': (P, 'record counts beside tables'), 'NAV-010': (P, ''),
'NAV-011': (P, 'dashboard apps draw their widgets'), 'NAV-012': (N, 'app name in the title bar'), 'NAV-014': (P, 'title-bar path: apps, table, record or form'),
'NAV-017': (N, 'home: counts and app list; no quick actions'), 'NAV-018': (P, 'qqq ui <route in the CLI grammar>'), 'NAV-023': (P, 'g: primary key or a configured unique key (kernel gotoKeys)'),
'NAV-024': (P, 'F2, /, ., Ctrl+K palette with type and app context'), 'NAV-025': (N, 'palette lists recents; no search page'), 'NAV-027': (N, 'F9 Connection: versions, features, extensions, metadata generation'),
'NAV-028': (N, 'reports listed; run through :report run'), 'NAV-030': (N, 'palette calls POST /search; the sample has no provider'), 'NAV-035': (P, 'Esc returns to the record or query, cursor kept'),
'PRF-002': (P, 'server-paged grid, page = visible rows'), 'PRF-003': (P, 'server sort and filter'), 'PRF-005': (P, 'progress N/M, elapsed, 250 ms polling'),
'PRC-001': (P, 'grid selection or record, ids sent'), 'PRC-002': (P, 'without a selection: the grid filter'), 'PRC-004': (P, ''), 'PRC-005': (P, 'Space selects, b runs a table process'),
'PRC-007': (N, 'EDIT_FORM, BULK_EDIT_FORM, VIEW_FORM, HELP_TEXT, VALIDATION_REVIEW_SCREEN, PROCESS_SUMMARY_RESULTS'), 'PRC-008': (N, 'HELP_TEXT shown above the form'),
'PRC-009': (P, 'includeFieldNames via the kernel runner'), 'PRC-010': (P, 'required checked by field before submit'), 'PRC-011': (P, 'picker'), 'PRC-012': (P, 'summary shows view fields'),
'PRC-017': (P, 'kernel runner'), 'PRC-018': (P, 'kernel runner'), 'PRC-019': (P, 'bar, N/M, message, elapsed'), 'PRC-020': (N, 'Esc asks, then POST cancel; the sample keeps the run'),
'PRC-021': (P, 'failure message shown; F5 re-runs'), 'PRC-022': (P, 'F5 or r runs again with the same records'), 'PRC-024': (P, 'step list with marks'),
'PRC-026': (P, 'validate or skip, then run'), 'PRC-027': (P, 'summary lines colored by status'), 'PRC-028': (P, ''), 'PRC-029': (P, 'Ctrl+T marks fields; bulkEditEnabledFields'),
'PRC-031': (X, 'bulk load parked for the TUI'), 'PRC-032': (X, 'bulk load parked for the TUI'), 'PRC-033': (X, 'bulk load parked for the TUI'), 'PRC-047': (P, ''), 'PRC-048': (P, ''), 'PRC-051': (P, 'contingent picker narrows'),
'QRY-001': (P, ''), 'QRY-002': (P, 'PgUp/PgDn, Ctrl+F/B, Home/End across pages'), 'QRY-003': (P, 's cycles asc, desc, default'), 'QRY-004': (P, 'v: columns on/off and order, kept per table in the config file'),
'QRY-005': (P, 'v: widths, fit or fill, zebra, density'), 'QRY-006': (P, 'Esc lands on the row last viewed, filter and page kept'), 'QRY-007': (P, 'error shown in the grid; F5 retries'),
'QRY-010': (P, 'operators by field type in the filter dialog'), 'QRY-011': (P, ''), 'QRY-012': (P, 'date presets: now(-7d), thisPeriod(month) …'), 'QRY-015': (P, 'labels in the picker, ids sent; typed labels resolved'),
'QRY-016': (P, 'AND/OR and groups, guided and advanced'), 'QRY-017': (N, 'the grammar and the CLI command are shown; no URL'), 'QRY-030': (N, 'Space and Ctrl+A (page)'), 'QRY-031': (P, ''), 'QRY-034': (N, 'F9 lists table processes; bulk load parked'),
'QRY-040': (P, 'x: visible columns, filter and sort, file dialog'), 'QRY-041': (P, ''), 'QRY-050': (N, 'F9 runs saved-view list in a pane'), 'QRY-062': (P, 'F-keys hidden without permission'),
'QRY-065': (P, ''), 'QRY-067': (P, 'g'), 'QRY-068': (P, ''), 'QRY-070': (P, 'n r f s and the F-keys; off with keyboard.singleKeyShortcuts'), 'QRY-083': (P, 'guided builder edits and removes conditions'), 'QRY-084': (P, 'sort in the builder; c clears'), 'QRY-086': (P, ''),
'REC-001': (P, 'sections by tier, two columns when they fit'), 'REC-003': (P, 'error shown in the record pane'), 'REC-005': (P, 'local validation by field'), 'REC-006': (P, 'new record replaces the form'),
'REC-008': (P, 'Esc asks when there are unsaved changes; F10 warns too'), 'REC-009': (P, 'only changed fields sent'), 'REC-010': (P, 'non-editable fields read-only'), 'REC-012': (P, 'F8 asks; the grid moves on to the next record'),
'REC-013': (P, 'c: copyRecordValues'), 'REC-014': (N, 'TEXT single line with $EDITOR on Ctrl+E'), 'REC-015': (P, ''), 'REC-016': (P, 'Space toggles'), 'REC-017': (P, ''), 'REC-018': (P, 'local zone in and out (kernel datetime)'),
'REC-021': (P, 'masked'), 'REC-028': (P, 'links marked, Enter follows (kernel record-view)'), 'REC-033': (N, 'masked; Enter reveals'), 'REC-034': (N, 'Enter saves through the file dialog; no sample field'),
'REC-042': (N, 'F9 runs audit list in a pane'), 'REC-044': (P, ''), 'REC-045': (P, 'picker by keyboard'), 'REC-048': (P, 'fieldErrors by field'), 'REC-050': (P, ''), 'REC-051': (P, ''), 'REC-052': (P, 'contingent fields wait and narrow'), 'REC-055': (P, 'n e c d; F-keys'),
'REL-001': (N, 'labels with links in the record view'), 'REL-002': (P, 'child lists; F9 Query all opens the filtered list'), 'REL-003': (P, 'F7 on a child list prefills the parent'), 'REL-004': (N, 'one level shown; open a child for its own'),
'REL-005': (P, 'the parent reloads on return'), 'REL-006': (P, 'unavailable panel (kernel associationGroups)'),
'SEC-003': (P, 'F7 hidden without insert permission'), 'SEC-004': (P, ''), 'SEC-005': (P, ''), 'SEC-020': (P, 'MOCK session, shared with the CLI keyring'), 'SEC-021': (P, 'F9 Sign out'), 'SEC-025': (N, 'device flow screen; not tried against a provider'), 'SEC-031': (P, ''), 'SEC-032': (P, 'unknown type refused with a message'),
'INT-001': (P, 'keyboard only by design'), 'INT-002': (P, ''), 'INT-003': (P, 'modal dialogs, Esc closes'), 'INT-004': (P, ''), 'INT-007': (P, ''), 'INT-009': (P, 'loading lines'), 'INT-010': (P, 'F5 retries'), 'INT-012': (P, 'F5 and on return'), 'INT-013': (N, 'no records message; F7 offered'), 'INT-014': (P, 'sign-in screen shows the failure'),
'WID-001': (P, ''), 'WID-002': (P, 'bars in block characters'), 'WID-004': (P, ''), 'WID-009': (P, 'sparkline and table'), 'WID-012': (P, ''), 'WID-014': (P, 'percentage bars'), 'WID-016': (P, ''), 'WID-017': (P, 'segments per dataset'),
'WID-018': (P, ''), 'WID-019': (P, ''), 'WID-023': (P, 'big numbers, up/down'), 'WID-040': (N, 'declared order, v reorders; one column'), 'WID-051': (P, 'error contained to the widget'), 'WID-053': (P, ''), 'WID-060': (N, 'record section widgets get the id'),
'HLP-002': (N, 'F1 shows table and field help; the sample has none'), 'HLP-005': (P, 'type in F1'),
}
rows = []
for line in open(SRC, encoding='utf-8', errors='replace'):
m = re.match(r'^\|\s*([A-Z]{3}-\d{3})\s*\|\s*(.*?)\s*\|', line)
@@ -93,19 +132,25 @@ for line in open(SRC, encoding='utf-8', errors='replace'):
rows.append((m.group(1), m.group(2)))
fam_counts = collections.defaultdict(collections.Counter)
tui_counts = collections.defaultdict(collections.Counter)
out = ['# Terminal clients parity matrix', '',
'Generated by `scripts/parity-matrix.py` from the Next UI acceptance matrix (`qqq-frontend-next/docs/acceptance/feature-matrix.md`,',
f'{len(rows)} rows). Same ids; two columns added. **pass** = verified against the local sample server in this repo\'s',
'development; **partial** = works with the noted gap; **todo** = planned; **n/a** = browser-only, with the reason.',
'The TUI column fills in during M3.', '']
'The TUI column is `qqq ui` (M3); its notes start with "TUI:".', '']
lines = []
for rid, feature in rows:
fam = rid[:3]
cli, note = STATUS.get(rid, (T, ''))
# the TUI column: browser-only rows stay n/a; everything else is M3 work
tui = X if cli == X and (fam in ('INT', 'SEC') or 'phone' in note or 'browser' in note or 'chrome' in note or 'theme' in note or 'Google' in note or 'web-only' in note) else T
tnote = ''
if rid in TUI:
tui, tnote = TUI[rid]
fam_counts[fam][cli] += 1
lines.append(f'| {rid} | {feature[:110].replace("|", "/")} | {cli} | {tui} | {note.replace("|", "/")} |')
tui_counts[fam][tui] += 1
both = note if not tnote else (f'{note}; TUI: {tnote}' if note else f'TUI: {tnote}')
lines.append(f'| {rid} | {feature[:110].replace("|", "/")} | {cli} | {tui} | {both.replace("|", "/")} |')
out.append('## Summary (CLI column)'); out.append('')
out.append('| Family | rows | pass | todo | partial | n/a |'); out.append('|---|---|---|---|---|---|')
total = collections.Counter()
@@ -113,7 +158,14 @@ for fam in sorted(fam_counts):
c = fam_counts[fam]; total.update(c)
out.append(f'| {fam} | {sum(c.values())} | {c[P]} | {c[T]} | {c[N]} | {c[X]} |')
out.append(f'| **all** | {sum(total.values())} | {total[P]} | {total[T]} | {total[N]} | {total[X]} |'); out.append('')
out.append('## Summary (TUI column)'); out.append('')
out.append('| Family | rows | pass | todo | partial | n/a |'); out.append('|---|---|---|---|---|---|')
ttotal = collections.Counter()
for fam in sorted(tui_counts):
c = tui_counts[fam]; ttotal.update(c)
out.append(f'| {fam} | {sum(c.values())} | {c[P]} | {c[T]} | {c[N]} | {c[X]} |')
out.append(f'| **all** | {sum(ttotal.values())} | {ttotal[P]} | {ttotal[T]} | {ttotal[N]} | {ttotal[X]} |'); out.append('')
out.append('## Rows'); out.append(''); out.append('| ID | Feature | CLI | TUI | Note |'); out.append('|---|---|---|---|---|')
out.extend(lines); out.append('')
open(OUT, 'w', encoding='utf-8').write('\n'.join(out))
print(f'{len(rows)} rows -> {OUT}; CLI: ' + ', '.join(f'{k} {v}' for k, v in sorted(total.items())))
print(f'{len(rows)} rows -> {OUT}; CLI: ' + ', '.join(f'{k} {v}' for k, v in sorted(total.items())) + '; TUI: ' + ', '.join(f'{k} {v}' for k, v in sorted(ttotal.items())))