Compare commits

..
Author SHA1 Message Date
james.maes 03e09c3c54 Merge remote-tracking branch 'origin/main' into feat/print-to-file 2026-11-01 13:30:41 -06:00
james.maesandClaude Fable 5.1 cccadc9a71 docs(printing): document save as PDF, content prints and the page header
print-agent.sh now prints to ~/Downloads unless QQQ_PRINT_OUT says
otherwise; --dir reports the folder in use. CHANGELOG under Unreleased.

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

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

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

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

No files matched your search

-37
View File
@@ -5,10 +5,6 @@ All notable changes to the qqq CLI and its packages. Versions follow [semver](ht
## [Unreleased]
### Changed
- TypeScript 5.8.3 to 7.0.2 (the Go-based compiler) and `@types/node` 22.15.3 to 26.6.6 across the workspace, `packages/tui` and `packages/tui-spike`. TypeScript 7 no longer loads every `@types/*` package implicitly, so `tsconfig.base.json` now lists `"types": ["node"]`; `@types/node` 26 no longer accepts an `undefined` assertion message, so one conformance test passes a real fallback message. No behaviour change.
### Fixed
- The screen saver's Online tile said "nobody around" with two terminals signed in. It now counts signed-in sessions first (the session store's table: sessions active in the last 10 minutes and their users, your own included, "2 sessions · 1 user (you ×2)"), else you plus the people in collaboration presence; it never says nobody is around while you are signed in. The saver also keeps its presence lease on the screen under it (the extension has no idle state), so other clients see you still viewing it instead of gone. A server that stores no sessions and merges presence per user (mock auth) shows both terminals as "1 online · you".
@@ -24,19 +20,6 @@ All notable changes to the qqq CLI and its packages. Versions follow [semver](ht
- Kernel: `parseMarkdown`, `markdownText`, `answerDocument` and `linkTargetKey` (render/markdown.ts): the
assistant's markdown as blocks with numbered links, one per target (a filter on the primary key is
that record; JSON and base64 filters compare by content).
- **`qqq ui`: the Shift bank.** Shift+F1…F10 are a second row of function keys: Keys (the `?`
reference), Servers, SQL (SQL Runner), Bulk, Metadata (refresh), Views (saved views), Copy (into a new
record), Export, History and Assistant (the same toggle as F12 and Ctrl+G). Like the primary bar they follow the
screen: keys that do not apply are dimmed and say why when pressed. The assignments are one table bound to
named actions (`assistant.toggle` and the rest), so other keys can bind the same actions.
- **The F-key bar in two rows** (primary, then the Shift row marked `⇧`) on terminals of 30 lines or more;
`:fkeys one|two|auto` and F9 › Settings › F-key bar choose (saved as `ui.fkeyBar`). In one-row mode the
Shift row replaces the primary row for 2 seconds after a Shift+F press. Clicks on the Shift row run its
keys; `?` lists the bank.
- **One decoder for modified function keys** across terminals: xterm modifier forms (tmux, iTerm2, Windows
Terminal), the kitty keyboard protocol, the legacy `ESC [25~`…`ESC [34~` numbering (VT220, rxvt and
the Linux console, macOS Terminal.app; `QQQ_FKEYS` overrides the detection) and rxvt's `$ ^ @`
suffixes. Ctrl+F-keys are decoded too (no bindings yet).
- **Save as PDF…** is the first entry of the UI's printer picker (record `p`, grid Ctrl+P, `:print`; `:print pdf`
saves straight away): the server renders the same record sheet, table list or page (printing
@@ -52,23 +35,6 @@ All notable changes to the qqq CLI and its packages. Versions follow [semver](ht
- `scripts/print-agent.sh` prints to `~/Downloads` unless `QQQ_PRINT_OUT` says otherwise (`--dir` reports it).
- The kernel adds `renderToPdf`, `renderValues`, `canSaveAsPdf`, `chromeValues` and content sections on text
prints; the TUI's breadcrumbs moved to `breadcrumbs.ts`.
- **`qqq ui` starts on a splash**: Query (the qrun.io mascot) blinking and wagging while it boots, the `qqq`
wordmark, the version and a status line that follows the boot (reading profiles, connecting, signing in,
loading metadata with the table count). Large Query from about 34 rows, the small one beside the
wordmark at 80×24, plain characters under `--ascii`; any key stops the animation, not the boot; shown at
least 600 ms. `--no-splash`, `QQQ_UI_NO_SPLASH=1` or F9 › Settings › Splash on start (`:splash on|off`,
saved as `ui.splash`) skip it.
- **A profile picker on start**: with several profiles and none named (`--profile`, `QQQ_PROFILE`,
`--server`, `QQQ_SERVER`), the Servers picker opens over the splash with the last used profile selected;
Enter connects, `n` adds, the server status fills in as the probes answer, Esc quits with a one-line hint
about `--profile`. With no profile at all the add form opens directly; with exactly one, it connects.
- **Connection failures stay on the splash** with the reason: Enter retries, `c` chooses another profile, `n`
adds one, Esc quits (instead of the bare error on the sign-in screen).
### Changed
- With several profiles and none named, `qqq ui` asks which to connect to instead of using the default
profile (`--profile` or `QQQ_PROFILE` keeps the old behaviour).
### Fixed
@@ -79,9 +45,6 @@ All notable changes to the qqq CLI and its packages. Versions follow [semver](ht
Links), never their URL-encoded `/app/...?filter=` targets; the tool results' links are listed only
when the text does not already show the same target, so `db_performance` no longer appears three times.
Exact duplicate paragraphs are shown once; the timing and token line stays, dimmed.
- **Shift+F3 and the other modified F3s** (`ESC [1;2R`) no longer vanish: OpenTUI took them for cursor
position reports. They are rewritten on the input stream into `ESC [13;2~` outside the startup
cursor-query window.
## [1.0.0] - 2026-11-01
-2
View File
@@ -217,8 +217,6 @@ qqq ui --theme light # high-contrast (defau
- **The function-key bar** at the bottom is always right for the current screen: F1 Help, F2 Search,
F3 View, F4 Edit, F5 Refresh, F6 Filter, F7 New, F8 Delete, F9 Menu, F10 Quit. When the terminal takes
the F-keys, Esc then a digit (or Ctrl+digit) stands for F1 to F10.
**Shift+F1…F10** are a second bank (Keys, Servers, SQL, Bulk, Metadata, Views, Copy, Export, History,
Assistant), shown as a second bar row from 30 lines (`:fkeys one|two|auto`).
- **F2** is Search: records across tables (quick search), the main way into the data. **`/`** (or `.`, Ctrl+K)
is the find-anything palette: any app, table, process, widget, report, record or command, searched as you
type, fast on instances with thousands of tables. **Ctrl+O** switches servers, **Ctrl+P** prints what you see.
+15 -376
View File
@@ -14,8 +14,8 @@
],
"devDependencies": {
"@stoplight/prism-cli": "5.16.0",
"@types/node": "26.6.6",
"typescript": "7.0.2",
"@types/node": "22.15.3",
"typescript": "5.8.3",
"yaml": "2.9.1"
},
"engines": {
@@ -564,13 +564,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.6.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
"version": "22.15.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz",
"integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
"undici-types": "~6.21.0"
}
},
"node_modules/@types/swagger-schema-official": {
@@ -590,346 +590,6 @@
"@types/node": "*"
}
},
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@@ -3048,44 +2708,23 @@
}
},
"node_modules/typescript": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc"
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
+2 -2
View File
@@ -30,8 +30,8 @@
},
"devDependencies": {
"@stoplight/prism-cli": "5.16.0",
"@types/node": "26.6.6",
"typescript": "7.0.2",
"@types/node": "22.15.3",
"typescript": "5.8.3",
"yaml": "2.9.1"
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ test('on a MOCK server without a token, every authenticated check (jobs too) use
const seen: Array<{ path: string; authorization?: string; cookie?: string }> = []
const results = await runConformance({ baseUrl: 'http://fake', fetch: fakeServer(seen) })
const jobs = results.find((r) => r.name === 'GET /qqq/v1/jobs validates')!
assert.equal(jobs.ok && !jobs.skipped, true, jobs.detail ?? 'jobs check did not pass')
assert.equal(jobs.ok && !jobs.skipped, true, jobs.detail)
assert.equal(seen.find((s) => s.path === '/qqq/v1/metaData')?.cookie, 'sessionUUID=s-1')
assert.equal(seen.find((s) => s.path === '/qqq/v1/jobs')?.cookie, 'sessionUUID=s-1')
assert.match(results.find((r) => r.name === 'GET /qqq/v1/metaData validates')!.detail!, /authenticated with a server session/)
-2
View File
@@ -46,8 +46,6 @@ export interface UiSettings {
childRows?: number
/** The terminal UI copies a CLI equivalent to the clipboard as soon as it shows it (default off). */
copyCliCommands?: boolean
/** The terminal UI's function-key bar: one row, two rows (with the Shift bank), or auto (two from 30 lines). */
fkeyBar?: 'one' | 'two' | 'auto'
/** Date entry: display order, clock, week start, picker behaviour (forms/dates.ts). */
dates?: import('../forms/dates.js').DateSettings
/** Keyed by screen, e.g. `table:person`, `record:person`, `app:peopleApp`. */
+1 -1
View File
@@ -18,6 +18,6 @@
"devDependencies": {
"@types/bun": "1.4.4",
"@types/react": "19.3.0",
"typescript": "7.0.2"
"typescript": "5.8.3"
}
}
+7 -82
View File
@@ -15,16 +15,15 @@ says `binary with ui`. The npm package `@qrun-io/qqq-cli` does not carry it. Fro
```sh
npm ci && npm run build # once, at the repo root (the kernel and CLI are used built)
(cd packages/tui && bun install --frozen-lockfile) # once
qqq ui # the splash, then the only profile (or a picker); or: qqq --profile sample ui
qqq ui # the default profile; or: qqq --profile sample ui
qqq ui table query person -w "lastName STARTS_WITH S" # open a screen directly (CLI grammar)
qqq ui --theme light | --ascii | --linear | --no-mouse | --no-splash | --check | --no-check
qqq ui --theme light | --ascii | --linear | --no-mouse | --check | --no-check
bun run packages/tui/src/main.tsx --profile sample # the same without the CLI
```
Options: `--profile|-p <name>`, `--server|-s <url>` (a profile named after the host is made), `--theme
<name>`, `--ascii` (ASCII glyphs), `--linear` (one region, no Apps pane), `--no-mouse` / `--mouse` (the
mouse for this run; the saved Mouse setting is on by default, see [Mouse](#mouse)), `--no-splash` /
`--splash` (the start-up splash for this run; see [Starting](#starting)),
mouse for this run; the saved Mouse setting is on by default, see [Mouse](#mouse)),
`--check` (the full terminal check, then exit), `--no-check` (skip the quick check at startup). Anything
after the options is a route in the CLI grammar.
@@ -32,36 +31,7 @@ Environment: `QQQ_UI_LINEAR=1`, `QQQ_UI_ASCII=1` (as the options); `QQQ_UI_SIGN_
OAUTH2 choice); `QQQ_UI_NO_BROWSER=1` (show the sign-in address without opening a browser);
`QQQ_PROFILE`, `QQQ_SERVER`, `QQQ_TOKEN` (a bearer token instead of the stored sign-in), `QQQ_CONFIG_DIR`,
`QQQ_STATE_DIR`, `QQQ_CACHE_DIR`, `QQQ_KEYRING=file` (keep sign-ins in a file instead of the OS keychain),
shared with the CLI. `QQQ_UI_NO_SPLASH=1` (as `--no-splash`). `QQQ_UI_NO_QUIT=1` is for the soak test only.
## Starting
`qqq ui` opens on a **splash**: Query (the qrun.io mascot) blinking, his tail wagging while it works, the
`qqq` wordmark, "Terminal client for QQQ", the version (`qqq 1.0.0 · UI API client 1.8.0`) and one status
line that follows the boot: reading profiles, connecting to the server, signing in, loading metadata (with
the table count). The large Query needs about 34 rows; smaller terminals (80×24) get the small Query beside
the wordmark, very small ones the text alone; `--ascii` draws it all in plain characters. Any key stops the
animation (not the boot). The splash stays at least 600 ms so it never just flashes, then Home opens as
before. `--no-splash`, `QQQ_UI_NO_SPLASH=1`, or F9 › Settings › **Splash on start: off** (`:splash off`,
saved as `ui.splash: off`) skip it.
Which server:
- `--profile`, `QQQ_PROFILE`, `--server` or `QQQ_SERVER` names one: it connects straight away. So does a
config with exactly one profile.
- Several profiles and none named: the **Servers picker** opens over the splash (the rows of the Servers
dialog, [Servers](#servers): name, short name, server, status, last used; who is signed in, the sign-in,
the description), with the last used profile selected. Enter connects; `n` adds a profile, `e` edits, typing
filters; the status column fills in as the servers answer (the picker never waits for them). Esc quits and
prints how to skip the picker (`--profile NAME` or `QQQ_PROFILE`).
- No profile at all (the first run): the add form opens directly; saving connects to the new profile. Esc
closes it; then `n` opens it again and Esc quits.
The sign-in runs as before (a MOCK or anonymous session, the OAuth2 browser or device flow with the address
or code on the splash, the username and password dialog). A failure stays on the splash with the reason:
Enter (or `r`) retries, `c` opens the picker to choose another profile (Esc there returns), `n` adds one,
Esc quits. Ctrl+O opens the picker too. With `--no-splash` the sign-in screen comes first, as in 1.0.0,
and the picker or the add form opens over it in the same cases.
shared with the CLI. `QQQ_UI_NO_QUIT=1` is for the soak test only.
## Keys
@@ -85,47 +55,6 @@ and the picker or the add form opens over it in the same cases.
| `t` | tags of a record (collaboration extension) | Esc then 1..0, Ctrl+1..0 | F1..F10 when the terminal takes the F-keys |
| Ctrl+P, `p`, PrtSc | print what you see: record sheet, table list (all rows or this page), or the screen (printing extension; else save as a file) | | |
### Shift bank (Shift+F1…F10)
A second row of ten function keys, like Norton Commander's Shift bar. Keys that do not apply on the screen
are dimmed; pressing one says why.
| Key | Label | Does |
|---|---|---|
| Shift+F1 | Keys | the keyboard reference (as `?`) |
| Shift+F2 | Servers | the Servers dialog (as Ctrl+O) |
| Shift+F3 | SQL | the SQL Runner (sql-workbench extension) |
| Shift+F4 | Bulk | bulk edit, bulk delete and processes on the grid selection (as `b`) |
| Shift+F5 | Metadata | refresh the metadata from the server |
| Shift+F6 | Views | saved views of the grid |
| Shift+F7 | Copy | copy the record, or the grid row under the cursor, into a new record (as `c`) |
| Shift+F8 | Export | export the grid to a file (as `x`) |
| Shift+F9 | History | the History screen (as `:history`) |
| Shift+F10 | Assistant | show or hide the Assistant panel (the same action as F12 and Ctrl+G; assistant extension) |
The bar shows the Shift row under the primary one (prefixed `⇧`) when the terminal has 30 lines or more.
`:fkeys one|two|auto` or F9 › Settings › F-key bar chooses: **auto** (two rows from 30 lines), **two** rows
always, or **one** row, where the Shift row takes the primary row's place for 2 seconds after any Shift+F
press (a terminal cannot report Shift being pressed and released on its own). A click on the Shift row runs
that key. The assignments are one table (`src/keys/shift-bank.ts`), each naming an action in
`src/keys/actions.ts`.
What terminals send for Shift+F1…F12 is decoded in one place (`src/keys/fkeys.ts`):
| Terminal | Shift+F1…F12 | Notes |
|---|---|---|
| tmux (any outer terminal) | `ESC [1;2P` … `ESC [1;2S`, `ESC [15;2~` … `ESC [24;2~` | verified live; tmux re-encodes the keys it knows |
| iTerm2, Windows Terminal, xterm, VTE, Konsole, Alacritty | the same xterm forms | kitty-protocol terminals (kitty, WezTerm, Ghostty, iTerm2 with CSI u) send `ESC [13;2~` for F3 and may send `ESC [57364;2u`…; both read |
| macOS Terminal.app | Shift+F5…F12 are `ESC [25~` … `ESC [34~` | Shift+F1…F4 have no code of their own by default: add them in Settings › Profiles › Keyboard (`\033[1;2P` …) or use the F9 menu |
| rxvt, urxvt, Linux console | Shift+F3…F10 are `ESC [25~` … `ESC [34~`, Shift+F11/F12 `ESC [23$` / `ESC [24$` | Shift+F1/F2 send the same as F11/F12 there |
Shift+F3 in the xterm form (`ESC [1;2R`) looks like a cursor position report, which OpenTUI drops; it and
the legacy forms are rewritten on the input stream into the canonical `ESC [n;2~` (never in the first
1.5 s after start or a resume, while the terminal answers OpenTUI's cursor queries). The legacy numbering is
picked from `TERM_PROGRAM` and `TERM`; `QQQ_FKEYS=vt220|rxvt|apple|modern` overrides it (vt220: `ESC [25~`
is Shift+F1; modern: legacy numbers are ignored, the default in tmux, iTerm2, Windows Terminal and kitty).
Ctrl+F1…F12 are decoded too but have no bindings yet: Ctrl+F5 acts as F5.
Keys on one screen:
| Screen | Keys |
@@ -611,8 +540,7 @@ Everything below is local to the machine and shared with the CLI; nothing is sen
1.0.0-rc.1 `note`, `lastUsedAt` and `user` recorded by the UI, and `ui.theme` for that profile only)
and `defaultProfile`. Fields are only ever added: a 1.0.0-rc.1 file loads unchanged.
Sign-ins themselves are in the OS keychain (or the file keyring), one per profile.
- `ui.theme`: the theme for every profile without its own; `ui.mouse`: `false` when the mouse is off;
`ui.splash`: `off` when `qqq ui` starts without the splash.
- `ui.theme`: the theme for every profile without its own; `ui.mouse`: `false` when the mouse is off.
- `ui.dates` (global, or in a profile's `ui`): date order (`YMD`, `MDY`, `DMY`), `clock` (`24h`, `12h`),
`weekStart`, `weekNumbers`, `pickerOnEnter`, set with `:dates`. Without them the clock and the first day
of the week follow the locale (`LC_ALL`, `LC_TIME`, `LANG`), else 24-hour and Monday; the order is ISO.
@@ -637,7 +565,7 @@ global choice. `--ascii` replaces box drawing and symbols. Colours come only fro
The CLI equivalent dialog copies its command with `y` (or Enter); F9 › Settings can copy it automatically, and Shift+drag selects it with the terminal.
F9 › Settings also holds the mouse on/off, the splash on start on/off, single-key shortcuts on/off (default: the server preference
F9 › Settings also holds the mouse on/off, single-key shortcuts on/off (default: the server preference
`keyboard.singleKeyShortcuts`), the download and upload folders, the PIN and idle lock, and the terminal check.
## Keys that your terminal may intercept
@@ -663,19 +591,16 @@ before any program sees it. `qqq ui --check` (and the quick check at startup) fi
(System Settings › Keyboard), or hold fn.
- Whatever the terminal does, Ctrl+F / Ctrl+B page and the F9 menu and `:` line reach every action; Esc then
a digit (or Ctrl+digit) stands for F1..F10.
- **Shift+F-keys** (the Shift bank): see [Shift bank](#shift-bank-shiftf1f10) for what each terminal sends;
`QQQ_FKEYS` overrides the legacy numbering.
## Tests
```sh
cd packages/tui && bun test # or npm run test:tui at the root: the whole TUI against a fake server in memory
packages/tui/scripts/smoke.sh # tmux against a running server (profile "sample"); captures kept
# --server URL: an isolated config with two profiles, through the start-up picker
packages/tui/scripts/mouse-smoke.sh # the mouse in tmux: SGR 1006 clicks, drags and wheel written into the pane
packages/tui/scripts/smoke-widths.sh # fixed grid widths across pages (fake large-instance server)
packages/tui/scripts/smoke-sql.sh [server] # SQL Runner at 80x24 and 120x36 (default the enterprise server on 8078)
packages/tui/scripts/overflow-sweep.sh [server] # every screen and dialog (the splash, its picker, failure and add form too) at 80/100/120/200 x 24/50: no text past a border
packages/tui/scripts/overflow-sweep.sh [server] # every screen and dialog at 80/100/120/200 x 24/50: no text past a border
packages/tui/scripts/perf-large-table.sh # PRF-001: 10,000 rows x 40 columns, timings against a 2 s budget
packages/tui/scripts/soak.sh [server] [minutes] # random keys for 10 min; fails on an exit or an error boundary
```
+2 -42
View File
@@ -12,7 +12,7 @@
"devDependencies": {
"@types/bun": "1.4.4",
"@types/react": "19.3.0",
"typescript": "7.0.2",
"typescript": "5.8.3",
},
},
},
@@ -43,46 +43,6 @@
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
"@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
"@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
"@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
"@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
"@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
"@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
"@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
"@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
"@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
"@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
"@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
"@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
"@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
"@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
"@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
"@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
"@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
"ansi-regex": ["ansi-regex@6.5.0", "", {}, "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ=="],
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
@@ -113,7 +73,7 @@
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
+1 -1
View File
@@ -18,6 +18,6 @@
"devDependencies": {
"@types/bun": "1.4.4",
"@types/react": "19.3.0",
"typescript": "7.0.2"
"typescript": "5.8.3"
}
}
+2 -9
View File
@@ -74,10 +74,9 @@ expect 07-record 'record 2 of'
keys Escape
expect 08-back 'row 2 '
right "$((hx + 1))" "$((hy + 4))"
expect 09-actions 'Actions'
expect 09-actions 'row 3 ' 'Actions'
keys Escape
# the grid's status line can sit under the dialog (the F-key bar takes two rows from 30 lines): checked after it closes
expect 09b-before-drag 'row 3 '
cap 09b-before-drag
read -r x y < <(at 09b-before-drag 'First Name')
read -r lx _ < <(at 09b-before-drag 'Last Name')
drag "$((lx - 1))" "$y" "$((lx + 6))"
@@ -90,12 +89,6 @@ expect 11b-footer ' · ping ' # the flash from the resize has faded
read -r x y < <(at 11b-footer ' · ping ')
click 2 "$y"
expect 12-footer-servers 'Servers'
# the Shift row of the F-key bar (two rows from 30 lines): a click on Shift+F2 opens the same dialog
keys Escape
cap 12c-bar
read -r x y < <(at 12c-bar '2Servers')
click "$((x + 1))" "$y"
expect 12d-shift-servers 'Servers (Ctrl+O)'
keys Escape F9 T:mouse
expect 12b-menu 'Mouse: on'
read -r x y < <(at 12b-menu 'Mouse: on')
-13
View File
@@ -65,22 +65,9 @@ shot() {
if [[ $bad == 0 ]]; then echo "ok $name"; else fails=$((fails + 1)); fi
}
cmd() { keys T:: "T:$1" Enter; }
# start_splash NAME CONFIG-JSON: a fresh TUI on its own config, no --server (the start-up splash, picker or add form)
start_splash() {
tmux kill-session -t "$s" 2>/dev/null || true
mkdir -p "$cfg/$1/config"
printf '%s\n' "$2" > "$cfg/$1/config/config.json"
tmux new-session -d -s "$s" -x 120 -y 36 "cd '$pkg' && QQQ_CONFIG_DIR='$cfg/$1/config' QQQ_STATE_DIR='$cfg/$1/state' QQQ_KEYRING=file QQQ_CACHE_DIR='$cfg/$1/cache' bun run src/main.tsx --no-check; sleep 30"
tmux set-option -t "$s" window-size manual > /dev/null
}
long="$(printf 'Long value %.0s' $(seq 1 30))"
shots=0
# the start-up splash: the picker (two profiles), a failed connection (retry / choose another), the add form (no profile)
start_splash picker "{\"profiles\":{\"sweep\":{\"server\":\"$server\",\"description\":\"the sweep server\"},\"other\":{\"server\":\"$server\"}}}"; shot 00a-splash-picker 'Connect to a server' 'Terminal client for QQQ'
start_splash failed '{"profiles":{"down":{"server":"http://127.0.0.1:9"}}}'; shot 00b-splash-failed 'Enter retries' 'Terminal client for QQQ'
start_splash add '{"profiles":{}}'; shot 00c-splash-add 'Add a server profile'
keys Escape; shot 00d-splash-no-profile 'n adds a server profile'
start; shot 01-home 'Quick actions' 'Recently viewed'
keys T:?; shot 02-help 'Keys'
start; keys T:/; shot 03-search 'Search'
+4 -13
View File
@@ -7,9 +7,7 @@
# packages/tui/scripts/smoke.sh [profile] a profile of your own config
# packages/tui/scripts/smoke.sh --server URL [--profile NAME] any server, in an isolated config
# (MOCK or anonymous sign-in; your
# config, keyring and cache untouched;
# two profiles, so it starts at the
# start-up picker and connects with Enter)
# config, keyring and cache untouched)
# OUT=dir WIDTH=160 HEIGHT=45
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -28,12 +26,11 @@ done
out="${OUT:-$(mktemp -d)}"
env_prefix=""
if [[ -n "$server" ]]; then
# an isolated config with two profiles: the start-up picker opens with this one (the last used) preselected
# an isolated config with one profile for the server, so the TUI signs in there instead of showing the picker
profile="${profile:-smoke}"
cfg="$(mktemp -d)"
mkdir -p "$cfg/config"
now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf '{"profiles":{"%s":{"server":"%s","lastUsedAt":"%s"},"smoke-other":{"server":"%s","lastUsedAt":"2020-01-01T00:00:00Z"}},"defaultProfile":"smoke-other"}\n' "$profile" "${server%/}" "$now" "${server%/}" > "$cfg/config/config.json"
printf '{"profiles":{"%s":{"server":"%s"}},"defaultProfile":"%s"}\n' "$profile" "${server%/}" "$profile" > "$cfg/config/config.json"
env_prefix="QQQ_CONFIG_DIR='$cfg/config' QQQ_CACHE_DIR='$cfg/cache' QQQ_STATE_DIR='$cfg/state' QQQ_KEYRING=file"
fi
profile="${profile:-sample}"
@@ -57,13 +54,7 @@ expect() { # name text...
echo "FAIL $name (wanted: $*)"; fails=$((fails + 1))
}
echo "smoke: profile $profile${server:+ at $server (isolated config)}; captures in $out"
if [[ -n "$server" ]]; then
tmux new-session -d -s "$s" -x "$w" -y "$h" "cd '$pkg' && $env_prefix bun run src/main.tsx --no-check; sleep 5"
expect 00-picker 'Connect to a server' 'Enter connects' 'Esc quits' 'Terminal client for QQQ' "$profile"
keys Enter
else
tmux new-session -d -s "$s" -x "$w" -y "$h" "cd '$pkg' && $env_prefix bun run src/main.tsx --no-check --profile '$profile'; sleep 5"
fi
tmux new-session -d -s "$s" -x "$w" -y "$h" "cd '$pkg' && $env_prefix bun run src/main.tsx --no-check --profile '$profile'; sleep 5"
expect 01-home 'Apps' 'UI API' 'Recently viewed' 'Quick actions'
keys T:: 'T:table query person' Enter
expect 02-grid 'records ·' 'First Name'
+3 -20
View File
@@ -8,7 +8,6 @@ import { Component, useEffect, useReducer, type ReactNode } from 'react'
import { FKeyZone, PaneZone, StatusZone, useMouseDispatcher } from './mouse/app-zones'
import { MouseCapture, MouseLayer, MouseRoot } from './mouse/zone'
import { presenceText, usePresence } from './presence'
import { ShiftRow, barRows } from './keys/shift-row'
import type { AppStore } from './store'
import { breadcrumbs } from './breadcrumbs'
@@ -133,9 +132,7 @@ export function App({ store }: { store: AppStore }) {
const t = theme()
const w = Math.max(20, width)
const h = Math.max(8, height)
// the F-key bar: the primary row, and the Shift row under it with two rows (keys/shift-row.tsx)
const rows = barRows(store, h)
const bodyHeight = h - 2 - rows
const bodyHeight = h - 3
const assistant = store.assistantLayout()
const full = assistant?.mode === 'full'
const sidebarWidth = store.showSidebar && !full ? (w >= 160 ? 32 : 28) : 0
@@ -143,19 +140,6 @@ export function App({ store }: { store: AppStore }) {
const contentWidth = w - sidebarWidth - assistantWidth
const screen = store.screen
const top = store.overlays[store.overlays.length - 1]
const overlays = store.overlays.map((o, i) => <MouseLayer key={o.id} layer={i + 1}><ErrorBoundary resetKey={o.id} width={Math.min(60, w)} label="Dialog">{o.render(w, h)}</ErrorBoundary></MouseLayer>)
if (screen?.kind === 'splash') {
// the start-up splash has the whole terminal: no title bar, status line or key bar yet
return (
<MouseRoot dispatcher={mouse} press={store.pressKey}>
<box flexDirection="column" overflow="hidden" width={w} height={h}>
<ErrorBoundary resetKey={screen.id} width={w} label="Splash">{screen.render(w, h, !top)}</ErrorBoundary>
{overlays}
<MouseCapture width={w} height={h} />
</box>
</MouseRoot>
)
}
return (
<MouseRoot dispatcher={mouse} press={store.pressKey}>
<box flexDirection="column" overflow="hidden" width={w} height={h}>
@@ -172,9 +156,8 @@ export function App({ store }: { store: AppStore }) {
{assistantWidth > 0 && store.assistant && <ErrorBoundary resetKey={store.assistant.id} width={assistantWidth} label="Assistant">{store.assistant.render(assistantWidth, bodyHeight, store.focus === 'assistant' && !top)}</ErrorBoundary>}
</box>
<StatusLine store={store} width={w} />
{rows === 1 && store.shiftRowShown() ? <ShiftRow store={store} width={w} slots={store.fkeys().length} /> : <FKeyBar store={store} width={w} />}
{rows === 2 && <ShiftRow store={store} width={w} slots={store.fkeys().length} />}
{overlays}
<FKeyBar store={store} width={w} />
{store.overlays.map((o, i) => <MouseLayer key={o.id} layer={i + 1}><ErrorBoundary resetKey={o.id} width={Math.min(60, w)} label="Dialog">{o.render(w, h)}</ErrorBoundary></MouseLayer>)}
{store.lock && <ErrorBoundary key="lock" resetKey="lock" width={w} label="Lock">{store.lock.render(w, h)}</ErrorBoundary>}
<MouseCapture width={w} height={h} />
</box>
-104
View File
@@ -1,104 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* What `qqq ui` does first, decided from the options, the environment and config.json before
* anything is drawn: connect to one profile, ask which profile (several saved, none named), or add
* the first one (none saved). Also the Splash setting (`ui.splash`: on unless off) and its timing.
*/
import { readConfig, resolveProfile, writeConfig, type Config } from '@qrun-io/qqq-core'
import type { Target } from './store'
/** The splash stays up at least this long (ms) so it never just flashes. */
export const SPLASH_MIN_MS = 600
export type BootPlan =
/** A profile was named (--profile, QQQ_PROFILE, --server, QQQ_SERVER) or only one exists. */
| { kind: 'connect'; target: Target }
/** Several profiles and none named: the Servers picker, `select` preselected (the last used). */
| { kind: 'pick'; select?: string; note?: string }
/** No profile at all: the add form. */
| { kind: 'add'; note?: string }
/** Whether a profile was named for this run (an option or the environment). */
export function namedProfile(args: { profile?: string; server?: string }, env: NodeJS.ProcessEnv = process.env): boolean {
return Boolean(args.profile || args.server || env.QQQ_PROFILE || env.QQQ_SERVER)
}
/** The most recently used profile (config lastUsedAt), else the default, else the first. */
export function lastUsedProfile(config: Config): string | undefined {
const names = Object.keys(config.profiles)
let best: string | undefined
let bestAt = -Infinity
for (const n of names) {
const at = Date.parse(config.profiles[n]!.lastUsedAt ?? '')
if (!Number.isNaN(at) && at > bestAt) {
best = n
bestAt = at
}
}
return best ?? (config.defaultProfile && config.profiles[config.defaultProfile] ? config.defaultProfile : names[0])
}
/** Decides the first step of a run (pure: config and environment are passed in by tests). */
export function planBoot(args: { profile?: string; server?: string }, env: NodeJS.ProcessEnv = process.env, config: Config = readConfig(env)): BootPlan {
const names = Object.keys(config.profiles)
if (!namedProfile(args, env) && names.length === 1) return { kind: 'connect', target: { name: names[0]!, server: config.profiles[names[0]!]!.server } }
if (namedProfile(args, env)) {
try {
const r = resolveProfile({ profile: args.profile, server: args.server }, env, config)
return { kind: 'connect', target: { name: r.name, server: r.server } }
} catch (e) {
// a name that does not exist: choose from what there is (or add the first)
const note = e instanceof Error ? e.message : String(e)
return names.length ? { kind: 'pick', select: lastUsedProfile(config), note } : { kind: 'add', note }
}
}
if (!names.length) return { kind: 'add' }
return { kind: 'pick', select: lastUsedProfile(config) }
}
/** The Splash setting: on unless `ui.splash` is off (or false). */
export function readSplash(env: NodeJS.ProcessEnv = process.env): boolean {
try {
const v = (readConfig(env).ui as { splash?: unknown } | undefined)?.splash
return !(v === false || v === 'off')
} catch {
return true
}
}
export function writeSplash(on: boolean, env: NodeJS.ProcessEnv = process.env): void {
const config = readConfig(env)
config.ui = { ...config.ui, splash: on ? 'on' : 'off' } as never
writeConfig(config, env)
}
/** Whether this run shows the splash: --no-splash (or QQQ_UI_NO_SPLASH=1) and the setting turn it off. */
export function splashEnabled(args: { splash?: boolean }, env: NodeJS.ProcessEnv = process.env): boolean {
if (args.splash !== undefined) return args.splash
if (env.QQQ_UI_NO_SPLASH === '1') return false
return readSplash(env)
}
/** How long to wait before leaving the splash, given when it was shown. */
export function splashWait(shownAt: number, now: number, minMs = SPLASH_MIN_MS): number {
return Math.max(0, shownAt + minMs - now)
}
/** The one line printed when the picker is closed without choosing. */
export const PICK_HINT = 'qqq ui: no profile chosen; --profile NAME (or QQQ_PROFILE) skips the picker'
/** Settings > Splash on start and `:splash on|off`: saved for the next start. */
export function setSplashSetting(store: { flash: (text: string) => void; error: (e: unknown) => void }, on: boolean): void {
try {
writeSplash(on)
} catch (e) {
store.error(e)
return
}
store.flash(on ? 'splash on (saved): the next qqq ui starts on the splash' : 'splash off (saved): the next qqq ui starts without it (--splash shows it once)')
}
+11 -44
View File
@@ -5,15 +5,10 @@
/** Builds the store for a run: settings from options, config and environment; sign-in; the first route. */
import { probeServer, readConfig, readTheme, writeTheme, defaultSecretStore, type SecretStore } from '@qrun-io/qqq-core'
import { PICK_HINT, SPLASH_MIN_MS, planBoot, splashEnabled, type BootPlan } from './boot-plan'
import { readConfig, readTheme, resolveProfile, writeTheme, defaultSecretStore, type SecretStore } from '@qrun-io/qqq-core'
import { installCommands } from './commands'
import { readMouse } from './mouse/settings'
import { readFKeyBar } from './keys/shift-bank'
import { ProfileFormOverlay, ServersOverlay } from './overlays/servers'
import { ServersModel } from './servers'
import { SignInScreen, type SignInDeps } from './screens/signin'
import { SplashScreen } from './screens/splash'
import { Sidebar } from './screens/sidebar'
import { AppStore, setSecretStoreFactory, type Target } from './store'
import { DEFAULT_THEME, THEMES } from './theme'
@@ -26,8 +21,6 @@ export interface CliArgs {
linear: boolean
/** --mouse / --no-mouse for this run; unset: the saved Mouse setting (on by default). */
mouse?: boolean
/** --splash / --no-splash for this run; unset: the Splash setting (ui.splash, on by default). */
splash?: boolean
check: 'startup' | 'full' | 'off'
route: string[]
}
@@ -47,8 +40,6 @@ export function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env):
else if (a === '--linear') out.linear = true
else if (a === '--mouse') out.mouse = true
else if (a === '--no-mouse') out.mouse = false
else if (a === '--splash') out.splash = true
else if (a === '--no-splash') out.splash = false
else if (a === '--check') out.check = 'full'
else if (a === '--no-check') out.check = 'off'
else out.route.push(a)
@@ -63,29 +54,25 @@ export interface BootOptions {
secrets?: () => SecretStore
fetch?: typeof fetch
timers?: boolean
/** Shows the splash; unset: --no-splash, QQQ_UI_NO_SPLASH=1 and the Splash setting decide. */
splash?: boolean
/** The splash's minimum display time; unset: 600 ms with timers (a real run), else none. */
splashMinMs?: number
/** Prints a line once the terminal is restored (the picker closed without a choice). */
hint?: (text: string) => void
}
/**
* Creates the store and starts the boot: the splash (unless switched off), then the profile named
* or the only one; with several the Servers picker, with none the add form (boot-plan.ts).
*/
/** Creates the store and starts sign-in (or connects straight away with a stored sign-in). */
export function boot(options: BootOptions): AppStore {
const { args } = options
const plan: BootPlan = planBoot(args)
const target: Target | undefined = plan.kind === 'connect' ? plan.target : undefined
let note = plan.kind === 'connect' ? undefined : plan.note
let target: Target | undefined
let note: string | undefined
try {
const r = resolveProfile({ profile: args.profile, server: args.server })
target = { name: r.name, server: r.server }
} catch (e) {
note = e instanceof Error ? e.message : String(e)
}
if (args.theme) {
if (THEMES[args.theme]) writeTheme(args.theme)
else note = `unknown theme "${args.theme}"; themes: ${Object.keys(THEMES).join(', ')}`
}
const theme = (args.theme && THEMES[args.theme] ? args.theme : undefined) ?? readTheme(target?.name) ?? DEFAULT_THEME
const store = new AppStore({ settings: { theme, ascii: args.ascii, linear: args.linear, mouse: args.mouse ?? readMouse(), singleKeys: true, copyCli: readConfig().ui?.copyCliCommands === true, fkeyBar: readFKeyBar() }, quit: options.quit, build: options.build, target, timers: options.timers })
const store = new AppStore({ settings: { theme, ascii: args.ascii, linear: args.linear, mouse: args.mouse ?? readMouse(), singleKeys: true, copyCli: readConfig().ui?.copyCliCommands === true }, quit: options.quit, build: options.build, target, timers: options.timers })
let secrets: SecretStore | undefined
const secretsFn = options.secrets ?? (() => (secrets ??= defaultSecretStore()))
setSecretStoreFactory(secretsFn)
@@ -105,29 +92,9 @@ export function boot(options: BootOptions): AppStore {
},
}
store.signInDeps = deps
const exit = (hint: string) => {
store.quit()
if (hint) options.hint?.(hint)
}
if (options.splash ?? splashEnabled(args)) {
const splash = new SplashScreen(store, { plan, deps, exit, minMs: options.splashMinMs ?? (options.timers ? SPLASH_MIN_MS : 0), animate: Boolean(options.timers) })
store.push(splash)
splash.start()
return store
}
// --no-splash: the sign-in screen, with the same first step
const screen = new SignInScreen(store, deps, target, note)
store.push(screen)
if (target) screen.choose(target)
else {
const model = new ServersModel({ secrets: secretsFn, probe: (server) => probeServer(server, { fetch: options.fetch }), onChange: () => store.bump() })
if (plan.kind === 'pick') store.open(new ServersOverlay(store, (row) => screen.choose({ name: row.name, server: row.server }), model, { title: 'Connect to a server', enterLabel: 'Enter connects', escLabel: 'Esc quits', select: plan.select, onEscape: () => exit(PICK_HINT) }))
else store.open(new ProfileFormOverlay(store, model, undefined, (name) => {
const p = readConfig().profiles[name]
screen.build()
if (p) screen.choose({ name, server: p.server })
}))
}
return store
}
+3 -22
View File
@@ -11,7 +11,6 @@
import { createProgram, runCli } from '@qrun-io/qqq-cli/program'
import { setMouseSetting } from './mouse/settings'
import { readSplash, setSplashSetting } from './boot-plan'
import { THEMES } from './theme'
import { metaHelp, processHelp, tableHelp, applyLiveFix, checkEnvironment, formatReport, buildFilter, readFilePreferences, startDirForSave, startDirForUpload, writeFilePreferences, hasPin, readConfig, readRecents, removePin, setPin, writeConfig, writeIdleMinutes, writeTheme, readIdleMinutes, type QueryFilter } from '@qrun-io/qqq-core'
import { openApp, openForm, openProcess, openRecord, openTable } from './nav'
@@ -40,7 +39,6 @@ import { withPrintEntry } from './overlays/actions'
import { openHistory } from './history/screen'
import { searchLines } from './history/reverse-search'
import { changeEvent, commandEvent, history } from './history/record'
import { FKEY_BAR_MODES, describeFKeyBar, runShiftKey, setFKeyBar, shiftBankHelp, type FKeyBarMode } from './keys/shift-bank'
const NOUNS: Record<string, string[]> = {
table: ['list', 'describe', 'query', 'count', 'get', 'create', 'update', 'delete', 'export'],
@@ -55,7 +53,7 @@ const NOUNS: Record<string, string[]> = {
apps: [], whoami: [], search: [], 'help-search': [],
}
const TUI_COMMANDS = ['quit', 'home', 'reset', 'tags', 'audits', 'dev', 'actions', 'back', 'help', 'menu', 'goto', 'view', 'theme', 'ascii', 'linear', 'mouse', 'splash', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'ask', 'dates', 'history', 'fkeys']
const TUI_COMMANDS = ['quit', 'home', 'reset', 'tags', 'audits', 'dev', 'actions', 'back', 'help', 'menu', 'goto', 'view', 'theme', 'ascii', 'linear', 'mouse', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'ask', 'dates', 'history']
/** One line on each TUI command, for the `:` line's completion popup. */
export const TUI_COMMAND_HELP: Record<string, string> = {
@@ -65,7 +63,7 @@ export const TUI_COMMAND_HELP: Record<string, string> = {
shortcuts: 'single-key shortcuts on or off', lock: 'lock the screen', pin: 'set or remove the lock PIN', idle: 'screen saver after idle minutes', refresh: 'reload this screen',
filter: 'filter the grid (grammar)', export: 'export the grid or result', jobs: 'your jobs', printjobs: 'print jobs', print: 'print what you see (or :print <printer>)', profile: 'switch profile', servers: 'saved servers (Ctrl+O)',
history: 'everything you did (CLI and TUI)', server: 'connect to a server URL', signout: 'sign out', connection: 'connection details', open: 'open a table', record: 'open a record by key', app: 'open an app',
sql: 'SQL Runner (statement to run, open <notebook id>)', ask: 'show or hide the Assistant panel (F12, Ctrl+G); with a question, asks it about this screen', saver: 'the screen saver', doctor: 'check the terminal', views: 'saved views of this table', share: 'share this record', helpkeys: 'help-authoring mode', fkeys: 'F-key bar: one row, two rows (with the Shift bank) or auto',
sql: 'SQL Runner (statement to run, open <notebook id>)', ask: 'show or hide the Assistant panel (F12, Ctrl+G); with a question, asks it about this screen', saver: 'the screen saver', doctor: 'check the terminal', views: 'saved views of this table', share: 'share this record', helpkeys: 'help-authoring mode',
}
/** CLI commands the `:` line does not offer (they make no sense inside the TUI). */
@@ -97,10 +95,7 @@ export class Commands implements CommandsLike {
// keys only: the screen's own key rows and shortcuts from its actions (label and key), never the actions themselves
const panelKeys = s.focus === 'assistant' && s.assistantOpen && s.assistant ? s.assistant.helpKeys() : []
const screenKeys: KeyRow[] = [...panelKeys, ...(screen?.helpKeys?.() ?? []), ...(screen?.menu?.() ?? []).filter((e) => e.key && !e.disabled).map((e): KeyRow => [e.key!, e.label.replace(/…$/, '').replace(/\s*\([^)]*\d[^)]*\)$/, '')])]
const help = new HelpOverlay(s, helpKeyGroups(screen?.kind, s.singleKeys, screenKeys), topicHelp)
// the Shift bank (Shift+F1…F10) after the screen's help
help.after = [{ title: 'Shift bank (Shift+F1…F10; the second row of the F-key bar)', keys: shiftBankHelp(s) }]
s.open(help)
s.open(new HelpOverlay(s, helpKeyGroups(screen?.kind, s.singleKeys, screenKeys), topicHelp))
// help for this screen: the kernel's help entries for the screen's roles; in help-authoring mode
// (:helpkeys) every slot shows its key, with or without content, so authors see where help goes
const authoring = s.helpKeys
@@ -235,10 +230,6 @@ export class Commands implements CommandsLike {
}
/** The Servers dialog: saved profiles, probed in the background; Enter switches. */
shiftKey(n: number): void {
runShiftKey(this.store, n)
}
servers(): void {
const s = this.store
if (s.overlays.some((o) => o instanceof ServersOverlay)) return
@@ -358,7 +349,6 @@ export class Commands implements CommandsLike {
case 'linear': s.settings.linear = rest[0] !== 'off'; s.bump(); return s.flash(`linear layout ${s.settings.linear ? 'on' : 'off'}`)
case 'shortcuts': return this.setShortcuts(rest[0] !== 'off')
case 'mouse': return setMouseSetting(s, rest[0] === undefined ? !s.settings.mouse : rest[0] !== 'off')
case 'splash': return setSplashSetting(s, rest[0] === undefined ? !readSplash() : rest[0] !== 'off')
case 'idle': {
const m = Number(rest[0])
if (!Number.isFinite(m)) return fail('idle <minutes>: 0 turns the screen saver timeout off')
@@ -428,12 +418,6 @@ export class Commands implements CommandsLike {
case 'doctor': case 'check': return this.doctor()
case 'profile': return rest[0] ? this.switchProfile(rest[0]) : this.chooseProfile()
case 'servers': return this.servers()
case 'fkeys': {
const mode = rest[0] as FKeyBarMode | undefined
if (!mode) return s.flash(`F-key bar: ${describeFKeyBar(s.settings.fkeyBar ?? 'auto')} · :fkeys one|two|auto`)
if (!FKEY_BAR_MODES.includes(mode)) return fail('fkeys one|two|auto')
return setFKeyBar(s, mode)
}
case 'server': return rest[0] ? this.switchServer(rest[0]) : fail('server <url>')
case 'signout': return this.signOut()
case 'connection': return this.connection()
@@ -765,9 +749,6 @@ export class Commands implements CommandsLike {
out.push({ section: 'Settings', label: `Single-key shortcuts: ${s.settings.singleKeys ? 'on' : 'off'}`, command: `shortcuts ${s.settings.singleKeys ? 'off' : 'on'}`, run: () => this.setShortcuts(!s.settings.singleKeys) })
out.push({ section: 'Settings', label: `Linear layout: ${s.settings.linear ? 'on' : 'off'}`, command: `linear ${s.settings.linear ? 'off' : 'on'}`, run: () => { s.settings.linear = !s.settings.linear; s.bump() } })
out.push({ id: 'settings:mouse', section: 'Settings', label: `Mouse: ${s.settings.mouse ? 'on' : 'off'}`, command: `mouse ${s.settings.mouse ? 'off' : 'on'}`, detail: s.settings.mouse ? 'off restores plain terminal text selection' : undefined, run: () => setMouseSetting(s, !s.settings.mouse) })
out.push({ id: 'settings:fkeys', section: 'Settings', label: `F-key bar: ${s.settings.fkeyBar ?? 'auto'}`, command: 'fkeys one|two|auto', detail: describeFKeyBar(s.settings.fkeyBar ?? 'auto'), run: () => s.open(new ChoiceOverlay<FKeyBarMode>(s, 'F-key bar', FKEY_BAR_MODES.map((m) => ({ label: m, value: m, detail: describeFKeyBar(m) })), (m) => setFKeyBar(s, m), 'Enter chooses')) })
const splash = readSplash()
out.push({ id: 'settings:splash', section: 'Settings', label: `Splash on start: ${splash ? 'on' : 'off'}`, command: `splash ${splash ? 'off' : 'on'}`, detail: 'Query and the boot steps while qqq ui starts', run: () => setSplashSetting(s, !splash) })
out.push({ section: 'Settings', label: `ASCII glyphs: ${s.settings.ascii ? 'on' : 'off'}`, command: `ascii ${s.settings.ascii ? 'off' : 'on'}`, run: () => s.setAscii(!s.settings.ascii) })
out.push({ section: 'Settings', label: 'Screen saver / lock', command: 'lock', run: () => this.lock() })
let idle = 0
+1 -1
View File
@@ -30,7 +30,7 @@ function sameAction(command: string | undefined): string | undefined {
* `:` commands the TUI does itself (theme, lock, the Servers dialog …): logged as `:…`, re-run on
* the `:` line only. Everything else on the `:` line is a CLI command (`qqq …`).
*/
const TUI_ONLY = new Set(['quit', 'q', 'exit', 'home', 'reset', 'tags', 'audits', 'audit', 'dev', 'developer', 'actions', 'back', 'help', 'menu', 'goto', 'palette', 'view', 'theme', 'ascii', 'linear', 'mouse', 'splash', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'dates', 'helpkeys', 'views', 'share', 'doctor', 'check', 'history'])
const TUI_ONLY = new Set(['quit', 'q', 'exit', 'home', 'reset', 'tags', 'audits', 'audit', 'dev', 'developer', 'actions', 'back', 'help', 'menu', 'goto', 'palette', 'view', 'theme', 'ascii', 'linear', 'mouse', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'dates', 'helpkeys', 'views', 'share', 'doctor', 'check', 'history'])
let last: { action?: string; at: number; words?: string[] } = { at: 0 }
-133
View File
@@ -1,133 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Named actions that keys bind to: the Shift bank (shift-bank.ts) binds by name, and so can any other
* key (F12 and Ctrl+G bind `assistant.toggle`). Each action says whether it applies on the current
* screen; one that does not says why, and the bar dims its key.
*/
import { openForm, permits } from '../nav'
import { openHistory } from '../history/screen'
import { openSqlRunner, sqlWorkbenchInstalled } from '../sql/entry'
import type { AppStore } from '../store'
import type { MenuEntry } from '../types'
export interface NamedAction {
/** Why it does not apply here; undefined when it does. */
unavailable(store: AppStore): string | undefined
run(store: AppStore): void
}
/** The parts of the table grid the actions use (screens/table.tsx). */
interface GridLike {
kind: string
tableName: string
meta?: unknown
currentRecord?: unknown
keyOf?(record: unknown): string | undefined
bulkActions?(): void
bulkEntries?(): MenuEntry[]
tableProcesses?(): unknown[]
savedViews?(): void
exportDialog?(): void
variant?: unknown
}
/** The parts of the record view the actions use (screens/record.tsx). */
interface RecordLike {
kind: string
tableName: string
primaryKey?: string
meta?: unknown
tableVariant?: string
}
function grid(store: AppStore): GridLike | undefined {
const s = store.screen as unknown as GridLike | undefined
return s?.kind === 'table' ? s : undefined
}
function record(store: AppStore): RecordLike | undefined {
const s = store.screen as unknown as RecordLike | undefined
return s?.kind === 'record' ? s : undefined
}
/** The record to copy: the record on screen, or the grid row under the cursor. */
function copySource(store: AppStore): { table: string; key: string; meta: unknown; tableVariant?: string } | undefined {
const r = record(store)
if (r?.primaryKey !== undefined) return { table: r.tableName, key: r.primaryKey, meta: r.meta, tableVariant: r.tableVariant }
const g = grid(store)
const key = g?.keyOf?.(g.currentRecord)
return g && key !== undefined ? { table: g.tableName, key, meta: g.meta } : undefined
}
export const ACTIONS = {
'help.keys': {
unavailable: () => undefined,
run: (s) => s.commands().help(),
},
servers: {
unavailable: () => undefined,
run: (s) => s.commands().servers(),
},
'sql.open': {
unavailable: (s) => sqlWorkbenchInstalled(s) ? undefined : 'needs the sql-workbench extension',
run: (s) => openSqlRunner(s),
},
'grid.bulk': {
unavailable: (s) => {
const g = grid(s)
if (!g) return 'bulk actions work on a table grid'
return (g.bulkEntries?.().length ?? 0) + (g.tableProcesses?.().length ?? 0) ? undefined : 'this table has no bulk actions or processes'
},
run: (s) => grid(s)?.bulkActions?.(),
},
'meta.refresh': {
unavailable: (s) => s.client ? undefined : 'not connected',
run: (s) => s.run(s.client!.metaData(true), (m) => {
s.setMeta(m)
s.client!.clearCaches()
s.flash('metadata refreshed')
}),
},
'grid.views': {
unavailable: (s) => grid(s) ? undefined : 'saved views work on a table grid',
run: (s) => grid(s)?.savedViews?.(),
},
'record.copy': {
unavailable: (s) => {
const src = copySource(s)
if (!src) return 'copy works on a record or a grid row'
return permits(src.meta as never, 'insert') ? undefined : 'copying needs permission to create records'
},
run: (s) => {
const src = copySource(s)
if (src) openForm(s, src.table, { mode: 'copy', primaryKey: src.key, ...(src.tableVariant ? { tableVariant: src.tableVariant } : {}) })
},
},
export: {
unavailable: (s) => grid(s) ? undefined : 'export works on a table grid',
run: (s) => grid(s)?.exportDialog?.(),
},
history: {
unavailable: () => undefined,
run: (s) => openHistory(s),
},
'assistant.toggle': {
unavailable: (s) => s.extensions.has('assistant') ? undefined : 'needs the assistant extension',
run: (s) => s.toggleAssistant(),
},
} satisfies Record<string, NamedAction>
export type ActionName = keyof typeof ACTIONS
/** Runs a named action, or says why it does not apply here. */
export function runAction(store: AppStore, name: ActionName, label: string = name): void {
const action: NamedAction = ACTIONS[name]
const why = action.unavailable(store)
if (why) return store.flash(`${label}: ${why}`)
action.run(store)
}
-214
View File
@@ -1,214 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The one decoder for function keys with modifiers (Shift+F1…F12, Ctrl+F-keys, Alt), across the
* forms terminals send:
*
* - xterm modifier forms: `CSI 1;m P|Q|R|S` (F1–F4), `SS3 m P…S`, `CSI n;m ~` (F5–F12; also
* `CSI 11..14;m ~` and kitty's `CSI 13;m ~` for F3), m = 1 + shift + 2·alt + 4·ctrl
* (tmux, iTerm2, Windows Terminal, xterm, VTE, Konsole, Alacritty);
* - the kitty keyboard protocol: the same forms with `:event` sub-parameters, and `CSI 57364..57375;m u`;
* - legacy numbered forms with no modifier parameter, which mean different keys per terminal:
* `CSI 25~ … CSI 34~` is Shift+F1…F8 with the VT220 numbering (F13 = Shift+F1), Shift+F3…F10 on
* rxvt and the Linux console (F11 = Shift+F1), Shift+F5…F12 on macOS Terminal.app;
* - rxvt's suffixes: `CSI n $` Shift, `CSI n ^` Ctrl, `CSI n @` Ctrl+Shift;
* - the Linux console's `CSI [ A…E` (F1–F5).
*
* OpenTUI 0.8 reads the xterm and kitty forms itself, except Shift+F3 (`CSI 1;2R`) and the other
* modified F3s: they look like a cursor position report, so OpenTUI takes them as one and drops them.
* It reads none of the legacy or rxvt forms. `rewriteFKeyInput` turns exactly those into the
* canonical `CSI n;m ~` form before OpenTUI sees the bytes (stdin-rewrite.ts), so every terminal
* ends up as one OpenTUI key event (name `f3`, shift true), which `bankKey` names `shift+f3`.
*/
import type { Key } from '../keys'
/** A function key with its modifiers. */
export interface FKeyChord {
/** 1..12 */
n: number
shift: boolean
ctrl: boolean
alt: boolean
/** A kitty key-release event (nothing to do). */
release?: boolean
}
/** Which sequence family a chord was read from. */
export type FKeyFamily = 'ss3' | 'csi-letter' | 'csi-f3-cpr' | 'csi-tilde' | 'kitty-u' | 'legacy-number' | 'rxvt-suffix' | 'linux-console'
/**
* How a terminal numbers its legacy shifted F-keys (`CSI 25~` and up): `vt220` (F13 = Shift+F1),
* `rxvt` (rxvt, urxvt, the Linux console: F11 = Shift+F1), `apple` (macOS Terminal.app: Shift+F5 is
* `CSI 25~`), `modern` (the terminal only sends the xterm forms; legacy numbers mean nothing).
*/
export type LegacyProfile = 'vt220' | 'rxvt' | 'apple' | 'modern'
export const LEGACY_PROFILES: LegacyProfile[] = ['vt220', 'rxvt', 'apple', 'modern']
/** The `CSI n ~` number of F1..F12. */
const TILDE_CODE = [0, 11, 12, 13, 14, 15, 17, 18, 19, 20, 21, 23, 24]
const TILDE_TO_N: Record<number, number> = Object.fromEntries(TILDE_CODE.map((code, n) => [code, n]).filter(([, n]) => n > 0))
const LETTER_TO_N: Record<string, number> = { P: 1, Q: 2, R: 3, S: 4 }
const LINUX_TO_N: Record<string, number> = { A: 1, B: 2, C: 3, D: 4, E: 5 }
/** kitty's private-use code points for F1..F12 (F13 and up are real keys of their own). */
const KITTY_F1 = 57364
/** Legacy numbers (no modifier) that stand for Shift+F-keys, per profile. */
const LEGACY_SHIFT: Record<LegacyProfile, Record<number, number>> = {
vt220: { 25: 1, 26: 2, 28: 3, 29: 4, 31: 5, 32: 6, 33: 7, 34: 8 },
rxvt: { 25: 3, 26: 4, 28: 5, 29: 6, 31: 7, 32: 8, 33: 9, 34: 10 },
apple: { 25: 5, 26: 6, 28: 7, 29: 8, 31: 9, 32: 10, 33: 11, 34: 12 },
modern: {},
}
/** The profile for this terminal: QQQ_FKEYS overrides; otherwise from TERM_PROGRAM, TERM and friends. */
export function legacyProfile(env: NodeJS.ProcessEnv = process.env): LegacyProfile {
const forced = env.QQQ_FKEYS?.trim().toLowerCase()
if (forced && (LEGACY_PROFILES as string[]).includes(forced)) return forced as LegacyProfile
const program = env.TERM_PROGRAM ?? ''
const term = env.TERM ?? ''
if (program === 'Apple_Terminal') return 'apple'
// tmux re-encodes the keys it knows in the xterm forms; Windows Terminal sends CSI 29~ for the Menu key
if (env.TMUX || env.WT_SESSION || /^(iTerm\.app|WezTerm|ghostty|vscode|tmux)$/.test(program) || /kitty|ghostty|alacritty|foot/.test(term)) return 'modern'
if (/^(rxvt|linux)/.test(term)) return 'rxvt'
return 'vt220'
}
function modifiers(m: number): Pick<FKeyChord, 'shift' | 'ctrl' | 'alt'> {
const bits = Math.max(0, m - 1)
return { shift: Boolean(bits & 1), alt: Boolean(bits & 2), ctrl: Boolean(bits & 4) }
}
/** `m` or `m:event` → modifier number and whether it is a kitty release. */
function modParam(p: string | undefined): { m: number; release: boolean } {
if (!p) return { m: 1, release: false }
const [m, event] = p.split(':')
return { m: Number(m) || 1, release: event === '3' }
}
function chord(n: number, m: number, release = false, alt = false): FKeyChord | undefined {
if (!(n >= 1 && n <= 12)) return undefined
const mods = modifiers(m)
const out: FKeyChord = { n, ...mods, alt: mods.alt || alt }
if (release) out.release = true
return out
}
/** Reads one whole sequence; undefined when it is not a function key. */
export function decodeFKey(seq: string, profile: LegacyProfile = 'vt220'): { chord: FKeyChord; family: FKeyFamily } | undefined {
// an extra ESC in front is Alt (meta) on terminals that send it that way
let s = seq
let alt = false
if (s.startsWith('\x1b\x1b')) {
alt = true
s = s.slice(1)
}
let m: RegExpExecArray | null
if ((m = /^\x1bO(\d*)([PQRS])$/.exec(s))) {
const c = chord(LETTER_TO_N[m[2]!]!, Number(m[1]) || 1, false, alt)
return c && { chord: c, family: 'ss3' }
}
if ((m = /^\x1b\[(?:1;)?(\d+(?::\d+)?)?([PQRS])$/.exec(s))) {
const { m: mod, release } = modParam(m[1])
const c = chord(LETTER_TO_N[m[2]!]!, mod, release, alt)
return c && { chord: c, family: m[2] === 'R' ? 'csi-f3-cpr' : 'csi-letter' }
}
if ((m = /^\x1b\[(\d+)(?:;(\d+(?::\d+)?))?~$/.exec(s))) {
const code = Number(m[1])
const { m: mod, release } = modParam(m[2])
if (TILDE_TO_N[code]) {
const c = chord(TILDE_TO_N[code]!, mod, release, alt)
return c && { chord: c, family: 'csi-tilde' }
}
const shifted = LEGACY_SHIFT[profile][code]
if (shifted && !m[2]) {
const c = chord(shifted, 2, false, alt)
return c && { chord: c, family: 'legacy-number' }
}
return undefined
}
if ((m = /^\x1b\[(\d+)([$^@])$/.exec(s))) {
// rxvt: $ Shift, ^ Ctrl, @ Ctrl+Shift; 25..34 are the shifted F3..F10 there
const code = Number(m[1])
const suffix = m[2]!
const base = TILDE_TO_N[code] ?? LEGACY_SHIFT.rxvt[code]
if (!base) return undefined
const shift = suffix !== '^' || LEGACY_SHIFT.rxvt[code] !== undefined
const ctrl = suffix !== '$'
const c = chord(base, 1 + (shift ? 1 : 0) + (ctrl ? 4 : 0), false, alt)
return c && { chord: c, family: 'rxvt-suffix' }
}
if ((m = /^\x1b\[(\d+)(?:;(\d+(?::\d+)?))?u$/.exec(s))) {
const { m: mod, release } = modParam(m[2])
const c = chord(Number(m[1]) - KITTY_F1 + 1, mod, release, alt)
return c && { chord: c, family: 'kitty-u' }
}
if ((m = /^\x1b\[\[([A-E])$/.exec(s))) {
const c = chord(LINUX_TO_N[m[1]!]!, 1, false, alt)
return c && { chord: c, family: 'linux-console' }
}
return undefined
}
/** `shift+f2`, `ctrl+f5`, `ctrl+shift+f1`, `alt+f3`, `f4`. */
export function chordName(c: FKeyChord): string {
return `${c.ctrl ? 'ctrl+' : ''}${c.alt ? 'alt+' : ''}${c.shift ? 'shift+' : ''}f${c.n}`
}
/** The canonical xterm form OpenTUI reads for every modifier: `CSI n;m ~`. */
export function canonicalSequence(c: FKeyChord): string {
const m = 1 + (c.shift ? 1 : 0) + (c.alt ? 2 : 0) + (c.ctrl ? 4 : 0)
return `\x1b[${TILDE_CODE[c.n]}${m > 1 ? `;${m}` : ''}~`
}
/** Families OpenTUI 0.8 does not read (or reads as something else); these are rewritten. */
const NEEDS_REWRITE = new Set<FKeyFamily>(['csi-f3-cpr', 'legacy-number', 'rxvt-suffix'])
/**
* Rewrites the function-key sequences OpenTUI cannot read into the canonical form, leaving every
* other byte alone. `cpr` false leaves `CSI 1;m R` alone: during startup (and after a resume) those
* bytes are real cursor position reports that OpenTUI's capability probe is waiting for. A chunk
* inside a bracketed paste is never touched.
*/
export function rewriteFKeyInput(chunk: string, profile: LegacyProfile, options: { cpr: boolean }): string {
if (!chunk.includes('\x1b') || chunk.includes('\x1b[200~')) return chunk
return chunk.replace(/\x1b{1,2}\[(?:1;\d+R|\d+[~$^@])/g, (seq) => {
if (seq.endsWith('R') && !options.cpr) return seq
const found = decodeFKey(seq, profile)
if (!found || !NEEDS_REWRITE.has(found.family)) return seq
// Shift+F3 and friends only with a modifier: CSI 1;1R is always a cursor report
if (found.family === 'csi-f3-cpr' && !found.chord.shift && !found.chord.ctrl && !found.chord.alt) return seq
return canonicalSequence(found.chord)
})
}
/** The chord of a key event from OpenTUI (or a test), when it is a function key. */
export function fkeyChord(key: Key, profile: LegacyProfile = 'vt220'): FKeyChord | undefined {
const named = /^f(\d{1,2})$/.exec(key.name)
if (named) {
const n = Number(named[1])
return n >= 1 && n <= 12 ? { n, shift: Boolean(key.shift), ctrl: Boolean(key.ctrl), alt: Boolean(key.meta) } : undefined
}
if (key.name && key.name !== 'menu') return undefined
return key.sequence?.startsWith('\x1b') ? decodeFKey(key.sequence, profile)?.chord : undefined
}
/**
* The key the store dispatches: Shift+F1…F12 (no Ctrl, no Alt) become `shift+f1`…`shift+f12` (the
* Shift bank); everything else is left as it is (Ctrl+F5 still acts as F5: no Ctrl bank yet).
*/
export function bankKey(key: Key, profile: LegacyProfile = 'vt220'): Key {
const c = fkeyChord(key, profile)
if (!c || c.release || !c.shift || c.ctrl || c.alt) return key
return { name: `shift+f${c.n}`, sequence: key.sequence ?? '', shift: true }
}
/** The Shift bank number of a dispatched key (`shift+f2` → 2), or undefined. */
export function shiftNumber(key: Key): number | undefined {
const m = /^shift\+f(\d{1,2})$/.exec(key.name)
return m ? Number(m[1]) : undefined
}
-140
View File
@@ -1,140 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The Shift bank: Shift+F1…F10, a second row of ten function keys (like Norton Commander's Shift
* bar). The assignments are this one table; each key names an action (actions.ts), so changing a key
* is changing a row here. Like the primary bar, a key that does not apply on the screen is dimmed
* and pressing it says why.
*/
import { readConfig, writeConfig } from '@qrun-io/qqq-core'
import type { AppStore } from '../store'
import { ACTIONS, runAction, type ActionName, type NamedAction } from './actions'
export interface ShiftKey {
n: number
/** The bar label (and the help's). */
label: string
/** At most 5 characters, for narrow terminals. */
short: string
action: ActionName
/** What it does, for the `?` help. */
help: string
}
export const SHIFT_BANK: ShiftKey[] = [
{ n: 1, label: 'Keys', short: 'Keys', action: 'help.keys', help: 'Keys: this reference (as ?)' },
{ n: 2, label: 'Servers', short: 'Servr', action: 'servers', help: 'Servers dialog (as Ctrl+O)' },
{ n: 3, label: 'SQL', short: 'SQL', action: 'sql.open', help: 'SQL Runner' },
{ n: 4, label: 'Bulk', short: 'Bulk', action: 'grid.bulk', help: 'Bulk actions on the grid (as b)' },
{ n: 5, label: 'Metadata', short: 'Meta', action: 'meta.refresh', help: 'Refresh the metadata' },
{ n: 6, label: 'Views', short: 'Views', action: 'grid.views', help: 'Saved views of the grid' },
{ n: 7, label: 'Copy', short: 'Copy', action: 'record.copy', help: 'Copy into a new record (as c)' },
{ n: 8, label: 'Export', short: 'Exprt', action: 'export', help: 'Export the grid (as x)' },
{ n: 9, label: 'History', short: 'Hist', action: 'history', help: 'History screen' },
{ n: 10, label: 'Assistant', short: 'Asst', action: 'assistant.toggle', help: 'Assistant panel (as F12, Ctrl+G)' },
]
/** The bank key for a number, if bound. */
export function shiftKey(n: number): ShiftKey | undefined {
return SHIFT_BANK.find((k) => k.n === n)
}
/** One slot of the Shift row as drawn: a label, or null where the key does nothing here (dimmed). */
export interface ShiftSlot {
n: number
label: string | null
global: string
short: string
why?: string
}
/**
* The Shift row for the current screen, `slots` keys wide (the primary row's count, so both rows line
* up; unbound keys are blank).
*/
export function shiftSlots(store: AppStore, slots = 10): ShiftSlot[] {
const out: ShiftSlot[] = []
for (let n = 1; n <= Math.max(10, slots); n++) {
const k = shiftKey(n)
if (!k) {
out.push({ n, label: null, global: '', short: '' })
continue
}
const action: NamedAction = ACTIONS[k.action]
let why: string | undefined
try {
why = action.unavailable(store)
} catch {
why = 'not available'
}
out.push({ n, label: why ? null : k.label, global: k.label, short: k.short, why })
}
return out
}
/** Shift+Fn pressed: runs its action (or says why not) and, in one-row mode, shows the Shift row for a moment. */
export function runShiftKey(store: AppStore, n: number): void {
store.showShiftRow(SHIFT_ROW_MS)
const k = shiftKey(n)
if (!k) return store.flash(`Shift+F${n} does nothing`)
runAction(store, k.action, `Shift+F${n} ${k.label}`)
}
/** The keys for the `?` help's Shift bank group. */
export function shiftBankHelp(store: AppStore): Array<[string, string]> {
return shiftSlots(store).filter((s) => shiftKey(s.n)).map((s) => {
const k = shiftKey(s.n)!
return [`Shift+F${s.n}`, `${k.help}${s.why ? ' · not here' : ''}`]
})
}
// ---- the F-key bar setting ----
export type FKeyBarMode = 'one' | 'two' | 'auto'
export const FKEY_BAR_MODES: FKeyBarMode[] = ['auto', 'one', 'two']
/** From this height `auto` draws both rows. */
export const TWO_ROW_MIN_HEIGHT = 30
/** How long one-row mode shows the Shift row after a Shift+F press. */
export const SHIFT_ROW_MS = 2000
export function readFKeyBar(env: NodeJS.ProcessEnv = process.env): FKeyBarMode {
try {
const v = readConfig(env).ui?.fkeyBar
return v === 'one' || v === 'two' ? v : 'auto'
} catch {
return 'auto'
}
}
export function writeFKeyBar(mode: FKeyBarMode, env: NodeJS.ProcessEnv = process.env): void {
const config = readConfig(env)
config.ui = { ...config.ui, fkeyBar: mode }
writeConfig(config, env)
}
/** Settings › F-key bar and `:fkeys one|two|auto`: applies now and saves it. */
export function setFKeyBar(store: AppStore, mode: FKeyBarMode): void {
store.settings.fkeyBar = mode
store.bump()
try {
writeFKeyBar(mode)
} catch (e) {
return store.error(e)
}
store.flash(`F-key bar: ${describeFKeyBar(mode)} (saved)`)
}
export function describeFKeyBar(mode: FKeyBarMode): string {
return mode === 'one' ? 'one row (Shift row for 2 s after Shift+F)' : mode === 'two' ? 'two rows' : `auto (two rows from ${TWO_ROW_MIN_HEIGHT} lines)`
}
/** How many rows the bar takes at this terminal height. */
export function fkeyBarRows(mode: FKeyBarMode | undefined, height: number): 1 | 2 {
if (mode === 'two') return 2
if (mode === 'one') return 1
return height >= TWO_ROW_MIN_HEIGHT ? 2 : 1
}
-70
View File
@@ -1,70 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The Shift row of the F-key bar: under the primary row when the bar has two rows, or in its place
* for a moment after a Shift+F press in one-row mode. It uses the primary row's arithmetic (mouse/hit.ts
* fkeyAt), so the keys line up and a click hits the key drawn there; the first number carries the ⇧.
*/
import type { AppStore } from '../store'
import { fkeyAt } from '../mouse/hit'
import { key } from '../mouse/keys'
import { Zone } from '../mouse/zone'
import { glyphs, theme } from '../theme'
import { pad } from '../util/text'
import { fkeyBarRows, shiftSlots, type ShiftSlot } from './shift-bank'
/** The cells of each slot, as the primary row lays them out: max(7, width / count), the last takes the rest. */
export function slotWidths(width: number, count: number): number[] {
const cell = Math.max(7, Math.floor(width / count))
const out: number[] = []
let used = 0
for (let i = 0; i < count; i++) {
const w = i === count - 1 ? Math.max(0, width - used) : cell
out.push(w)
used += w
}
return out
}
/** The text of each slot (number, then the label padded to the slot), clipped to the width. */
export function shiftRowCells(slots: ShiftSlot[], width: number, ascii = false): Array<{ n: number; num: string; label: string; active: boolean }> {
const widths = slotWidths(width, slots.length)
const narrow = width < 100
return slots.map((s, i) => {
const w = widths[i]!
const num = i === 0 ? `${ascii ? 'S' : '⇧'}${s.n}` : String(s.n).padStart(2)
const base = s.label ?? s.global
const label = narrow || base.length > w - 2 ? s.short : base
return { n: s.n, num: num.slice(0, w), label: pad(label, Math.max(0, w - 2)).slice(0, Math.max(0, w - 2)), active: s.label !== null }
})
}
export function ShiftRow({ store, width, slots }: { store: AppStore; width: number; slots: number }) {
const t = theme()
const shift = shiftSlots(store, slots)
const cells = shiftRowCells(shift, width, glyphs().border !== undefined)
return (
<Zone global onClick={(e) => {
const n = fkeyAt(e.x, width, shift.map((s) => s.n))
if (n !== undefined) store.handleKey(key(`shift+f${n}`, { shift: true }))
}}>
<text flexShrink={0} wrapMode="none">
{cells.map((c) => (
<span key={c.n}>
<span fg={t.keyNum}>{c.num}</span>
<span fg={c.active ? t.keyLabelFg : t.dim} bg={c.active ? t.keyLabelBg : undefined}>{c.label}</span>
</span>
))}
</text>
</Zone>
)
}
/** How many rows the bar takes now (the body is that much shorter). */
export function barRows(store: AppStore, height: number): 1 | 2 {
return fkeyBarRows(store.settings.fkeyBar, height)
}
-44
View File
@@ -1,44 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Feeds the renderer canonical function-key sequences: every `data` chunk of the input stream goes
* through rewriteFKeyInput before any listener (OpenTUI's stdin parser) sees it. OpenTUI's own input
* handlers run after its parser, which already took Shift+F3 for a cursor report, so the bytes are
* rewritten on the stream itself.
*/
import { rewriteFKeyInput, type LegacyProfile } from './fkeys'
/** Cursor reports are expected for this long after start and after a resume (the capability probe). */
export const CPR_QUIET_MS = 1500
export interface FKeyRewrite {
/** Call after renderer.resume(): the probe runs again, so CSI 1;m R is a cursor report for a while. */
quiet(): void
/** Restores the stream. */
dispose(): void
}
export function installFKeyRewrite(stream: NodeJS.EventEmitter, profile: LegacyProfile, clock: () => number = Date.now): FKeyRewrite {
const original = stream.emit
let quietUntil = clock() + CPR_QUIET_MS
const patched = function (this: NodeJS.EventEmitter, event: string | symbol, ...args: unknown[]): boolean {
if (event === 'data' && args.length) {
const chunk = args[0]
const text = typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? chunk.toString('latin1') : undefined
if (text !== undefined && text.includes('\x1b')) {
const out = rewriteFKeyInput(text, profile, { cpr: clock() >= quietUntil })
if (out !== text) args[0] = typeof chunk === 'string' ? out : Buffer.from(out, 'latin1')
}
}
return original.call(this, event, ...args)
}
stream.emit = patched as typeof stream.emit
return {
quiet: () => { quietUntil = clock() + CPR_QUIET_MS },
dispose: () => { if (stream.emit === patched) stream.emit = original },
}
}
+1 -16
View File
@@ -19,8 +19,6 @@ import { App } from './app'
import { boot, parseArgs } from './boot'
import { runDoctor } from './doctor'
import { isShutdownNoise, quietShutdownNoise } from './exit'
import { legacyProfile } from './keys/fkeys'
import { installFKeyRewrite } from './keys/stdin-rewrite'
const args = parseArgs(process.argv.slice(2))
if (args.check === 'full') {
@@ -51,10 +49,6 @@ const renderer = await createCliRenderer({
targetFps: 30,
})
// Shift+F3 (CSI 1;2R, which OpenTUI takes for a cursor report) and the legacy and rxvt Shift/Ctrl+F
// forms reach OpenTUI as the canonical CSI n;m ~ (keys/fkeys.ts)
const fkeyRewrite = installFKeyRewrite(process.stdin, legacyProfile())
let destroyed = false
function restore(): void {
if (destroyed) return
@@ -77,14 +71,8 @@ renderer.on('destroy', () => {
setTimeout(() => process.exit(0), 10)
})
// a line for the normal screen once the alternate screen is gone (the picker closed without a choice)
let exitHint: string | undefined
process.on('exit', () => {
if (exitHint) process.stderr.write(`${exitHint}\n`)
})
// QQQ_UI_NO_QUIT=1 (the soak test): quitting from a menu is ignored, so only a crash ends the program
const store = boot({ args, quit: process.env.QQQ_UI_NO_QUIT === '1' ? () => store.flash('quit is off (QQQ_UI_NO_QUIT)') : restore, build: process.env.QQQ_UI_BUILD ?? 'dev', timers: true, hint: (text) => { exitHint = text } })
const store = boot({ args, quit: process.env.QQQ_UI_NO_QUIT === '1' ? () => store.flash('quit is off (QQQ_UI_NO_QUIT)') : restore, build: process.env.QQQ_UI_BUILD ?? 'dev', timers: true })
process.on('unhandledRejection', (reason) => {
if (destroyed && isShutdownNoise([reason])) return
store.error(reason)
@@ -105,10 +93,7 @@ store.editExternal = async (text: string) => {
spawnSync(editor, [file], { stdio: 'inherit', shell: process.platform === 'win32' })
return readFileSync(file, 'utf8')
} finally {
// the resume probes the terminal again: its cursor reports are not Shift+F3
fkeyRewrite.quiet()
await renderer.resume()
fkeyRewrite.quiet()
rmSync(dir, { recursive: true, force: true })
}
}
-4
View File
@@ -169,9 +169,6 @@ export class HelpOverlay implements Overlay {
readonly id = overlayId('help')
top = 0
/** Key groups shown after the screen's help (the Shift bank), before the mouse. */
after: KeyGroup[] = []
constructor(private store: AppStore, readonly groups: KeyGroup[], readonly topicHelp: string[], readonly pointer = HELP_POINTER) {}
onKey(key: Key): void {
@@ -193,7 +190,6 @@ export class HelpOverlay implements Overlay {
body.push({ text: ' About this screen', fg: t.acc })
for (const p of this.topicHelp) for (const l of wrap(p, inner - 4)) body.push({ text: ` ${l}` })
}
if (this.after.length) this.keyLines(this.after, body, half)
if (this.store.settings.mouse) this.keyLines([MOUSE_KEYS], body, half)
return body
}
+7 -26
View File
@@ -33,21 +33,6 @@ function hint(tokens: KeyToken[], width: number): string {
/** Command keys while the filter is empty; any other character starts the filter (/ starts it too). */
const COMMAND_KEYS = new Set(['n', 'e', 'd', '*', 'r', '/'])
/** The dialog as the start-up profile picker (over the splash): its title, Enter and Esc, the preselected row. */
export interface ServersOverlayOptions {
title?: string
/** The Enter hint (`Enter connects`). */
enterLabel?: string
/** The Esc hint (`Esc quits`). */
escLabel?: string
/** Esc with an empty filter: instead of closing. */
onEscape?: () => void
/** The profile the cursor starts on (else the one in use). */
select?: string
/** The frame's first row for a screen height (below the splash's header), and only as tall as its rows need. */
top?: (screenHeight: number) => number
}
export class ServersOverlay implements Overlay {
readonly id = overlayId('servers')
readonly filter = new LineEditor('')
@@ -60,9 +45,9 @@ export class ServersOverlay implements Overlay {
/** `/` was pressed: every character goes to the filter. */
private filtering = false
constructor(private store: AppStore, private switchTo: (row: ServerRow) => void, model?: ServersModel, readonly options: ServersOverlayOptions = {}) {
constructor(private store: AppStore, private switchTo: (row: ServerRow) => void, model?: ServersModel) {
this.model = model ?? new ServersModel({ current: store.target?.name, secrets: store.signInDeps?.secrets, probe: (server) => probeServer(server, { fetch: store.signInDeps?.fetch }), onChange: () => store.bump() })
const at = options.select !== undefined && this.model.rows.some((r) => r.name === options.select) ? this.model.rows.findIndex((r) => r.name === options.select) : this.model.rows.findIndex((r) => r.isCurrent)
const at = this.model.rows.findIndex((r) => r.isCurrent)
this.index = Math.max(0, at)
void this.model.probeAll()
void this.model.readSignIns()
@@ -96,8 +81,7 @@ export class ServersOverlay implements Overlay {
this.filter.set('')
this.filtering = false
this.index = 0
} else if (this.options.onEscape) this.options.onEscape()
else this.store.closeOverlay(this)
} else this.store.closeOverlay(this)
} else if (key.name === 'down') this.index = Math.min(Math.max(0, list.length - 1), this.index + 1)
else if (key.name === 'up') this.index = Math.max(0, this.index - 1)
else if (key.name === 'pagedown') this.index = Math.min(Math.max(0, list.length - 1), this.index + 5)
@@ -168,9 +152,7 @@ export class ServersOverlay implements Overlay {
const t = theme()
const g = glyphs()
const width = Math.max(30, Math.min(sw - 2, 120))
const top = this.options.top ? this.options.top(sh) : 1
// under the splash header: as tall as the profiles need, else up to the screen's foot
const height = this.options.top ? Math.max(10, Math.min(sh - top - 1, 30, this.model.rows.length * 2 + 6)) : Math.max(10, Math.min(sh - 2, 30))
const height = Math.max(10, Math.min(sh - 2, 30))
const inner = width - 2
const list = this.list()
if (this.index >= list.length) this.index = Math.max(0, list.length - 1)
@@ -204,13 +186,12 @@ export class ServersOverlay implements Overlay {
const more = list.length > visibleRows ? ` ${this.scroll > 0 ? g.up : ' '}${this.scroll + visibleRows < list.length ? g.down : ' '}` : ''
const header = ` ${pad('Name', nameW)}${pad('Short', shortW)}${pad('Server', serverW)}${pad('Status', statusW)}${pad('Last used', lastW)}`
const commands = !this.filter.value && !this.filtering
const enter = this.options.enterLabel ?? 'Enter switches'
const keys = commands
? [{ text: enter }, { text: 'n add' }, { text: 'e edit' }, { text: 'd remove', drop: 1 }, { text: '* default', drop: 1 }, { text: 'r probe', drop: 3 }, { text: 'type to filter', drop: 2 }, { text: this.options.escLabel ?? 'Esc closes' }]
: [{ text: `${list.length} of ${this.model.rows.length}` }, { text: enter }, { text: 'F7 add', drop: 2 }, { text: 'F4 edit', drop: 2 }, { text: 'F8 remove', drop: 3 }, { text: 'Esc clears' }]
? [{ text: 'Enter switches' }, { text: 'n add' }, { text: 'e edit' }, { text: 'd remove', drop: 1 }, { text: '* default', drop: 1 }, { text: 'r probe', drop: 3 }, { text: 'type to filter', drop: 2 }, { text: 'Esc closes' }]
: [{ text: `${list.length} of ${this.model.rows.length}` }, { text: 'Enter switches' }, { text: 'F7 add', drop: 2 }, { text: 'F4 edit', drop: 2 }, { text: 'F8 remove', drop: 3 }, { text: 'Esc clears' }]
const legend = `* default ${g.mark} in use`
return (
<Frame title={this.options.title ?? 'Servers (Ctrl+O)'} width={width} height={height} screenWidth={sw} screenHeight={sh} top={top}>
<Frame title="Servers (Ctrl+O)" width={width} height={height} screenWidth={sw} screenHeight={sh} top={1}>
<box paddingLeft={1}><EditorLine editor={this.filter} width={inner - 2} focused={!commands || undefined} placeholder="type to filter by name, short name, description or server · / then any letter" /></box>
<Line text={pad(header, inner - more.length) + more} width={inner} fg={t.acc} />
{lines}
-7
View File
@@ -22,10 +22,6 @@ export interface SignInDeps {
/** Runs after the home screen is shown (the route given on the command line). */
afterConnect?: (store: AppStore) => void
timers?: boolean
/** Boot steps as they happen (the splash's status line): connecting, loading metadata. */
progress?: (text: string) => void
/** Awaited once connected, before Home replaces the screens (the splash's minimum display time). */
beforeHome?: () => Promise<void>
}
/** Connects the store to a profile: the stored sign-in, a client, metadata; then the home screen. */
@@ -61,9 +57,7 @@ export async function connect(store: AppStore, target: Target, deps: SignInDeps)
store.status.auth = 'QQQ_TOKEN'
}
try {
deps.progress?.('loading metadata…')
await store.connect(client, { timers: deps.timers })
deps.progress?.(`loaded metadata (${Object.keys(store.meta?.tables ?? {}).length} tables)`)
} catch (e) {
if (e instanceof UiApiError && (e.status === 401 || e.status === 403)) throw new NeedsSignIn('your sign-in expired; sign in again')
throw e
@@ -75,7 +69,6 @@ export async function connect(store: AppStore, target: Target, deps: SignInDeps)
// config.json unreadable: not critical here
}
history.record(store, { kind: 'auth', summary: `Signed in to ${target.server}${store.status.user ? ` as ${store.status.user}` : ''} (${store.status.auth})` })
await deps.beforeHome?.()
while (store.routes.length) store.routes.pop()?.dispose?.()
store.push(new HomeScreen(store))
deps.afterConnect?.(store)
-413
View File
@@ -1,413 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The start-up splash of `qqq ui`: Query (the qrun.io mascot) blinking and wagging while the TUI
* boots, the `qqq` wordmark, the version, and one status line that follows the boot steps (reading
* profiles, connecting, signing in, loading metadata). It drives the boot (boot-plan.ts decides the
* first step): with several profiles and none named the Servers picker opens over it; with none the
* add form; a failure stays on the splash with Enter retrying and c choosing another profile.
* Any key stops the animation (not the boot); --no-splash and `ui.splash: off` skip the splash.
*/
import { useEffect, useReducer } from 'react'
import { UI_API_VERSION } from '@qrun-io/qqq-ui-api-client'
import { CLI_VERSION } from '@qrun-io/qqq-cli/program'
import { probeServer, readConfig } from '@qrun-io/qqq-core'
import { PICK_HINT, splashWait, type BootPlan } from '../boot-plan'
import type { Key } from '../keys'
import { charOf } from '../keys'
import { ProfileFormOverlay, ServersOverlay } from '../overlays/servers'
import { blockText, blockWidth } from '../saver/font'
import { queryFrame, querySize } from '../saver/query-art'
import { ServersModel } from '../servers'
import type { AppStore, Target } from '../store'
import { isAscii, theme, type ThemeTokens } from '../theme'
import type { FKeyLabels, Screen } from '../types'
import { cellWidth, errorText, trunc } from '../util/text'
import { NeedsSignIn, SignInScreen, connect, type SignInDeps } from './signin'
export type SplashPhase = 'starting' | 'picking' | 'adding' | 'connecting' | 'failed' | 'done'
/** The colour a piece of the splash is drawn in (a theme token). */
export type SplashTone = 'art' | 'mark' | 'fg' | 'dim' | 'status' | 'err' | 'key'
export interface SplashSeg {
text: string
tone: SplashTone
}
export const SPLASH_TAGLINE = 'Terminal client for QQQ'
/** `qqq 1.0.0 · UI API client 1.8.0` */
export function versionLine(): string {
return `qqq ${CLI_VERSION} · UI API client ${UI_API_VERSION}`
}
/** Query's pose at a moment: a blink every 3 s; while the boot works, the tail wags (his wave). */
export function splashPose(now: number, busy: boolean): { blink: boolean; wag: boolean } {
return { blink: now % 3000 < 200, wag: busy && Math.floor(now / 300) % 2 === 1 }
}
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
const SPINNER_ASCII = ['|', '/', '-', '\\']
export interface SplashLayoutInput {
ascii: boolean
pose: { blink: boolean; wag: boolean }
status: SplashSeg
/** Lines under the status (a sign-in address, a device code). */
extra: SplashSeg[]
/** The key hint line, or ''. */
keys: string
/** `top`: the header only (the picker is drawn below it), from `headerTop`, keeping `reserve` rows under it. */
anchor: 'center' | 'top'
reserve?: number
headerTop?: number
}
/** Rows of the large header (Query, gap, wordmark, gap, tagline, version) and of the compact one. */
const LARGE_HEADER = querySize('large').height + 9
const COMPACT_HEADER = querySize('small').height + 1
export type SplashLine = SplashSeg[]
/** Which composition fits with `rowsBelow` rows under it: the large Query over the wordmark, the small one beside it, or text. */
export function splashTier(width: number, height: number, rowsBelow: number): 'large' | 'compact' | 'text' {
// a free row to spare, so a header and the picker under it never meet the screen's foot
if (width >= 40 && height > LARGE_HEADER + rowsBelow) return 'large'
if (width >= 40 && height > COMPACT_HEADER + rowsBelow) return 'compact'
return 'text'
}
/**
* The splash as lines of coloured pieces, centred in `width` and clipped to `width` × `height`
* (pure: tests read it at 80×24 and in ASCII). Blank lines are empty arrays.
*/
export function splashLayout(width: number, height: number, input: SplashLayoutInput): SplashLine[] {
const below: SplashLine[] = [[{ ...input.status }], ...input.extra.map((s) => [{ ...s }]), ...(input.keys ? [[{ text: input.keys, tone: 'key' as const }]] : [])]
const header = input.anchor === 'top'
const rowsBelow = header ? input.reserve ?? 0 : below.length + 1
const tier = splashTier(width, height, rowsBelow)
const mark = blockText('qqq', input.ascii)
const lines: SplashLine[] = []
if (tier === 'large') {
for (const row of queryFrame('large', input.pose, input.ascii)) lines.push([{ text: row, tone: 'art' }])
lines.push([])
for (const row of mark) lines.push([{ text: row, tone: 'mark' }])
lines.push([])
lines.push([{ text: SPLASH_TAGLINE, tone: 'fg' }])
lines.push([{ text: versionLine(), tone: 'dim' }])
} else if (tier === 'compact') {
// the small Query beside the wordmark, the tagline under the wordmark
const art = queryFrame('small', input.pose, input.ascii)
const right = Math.max(blockWidth('qqq'), SPLASH_TAGLINE.length)
// the wordmark and the tagline (a row apart) centred against Query's height
const from = Math.max(0, Math.floor((art.length - mark.length - 2) / 2))
const taglineRow = Math.min(art.length - 1, from + mark.length + 1)
art.forEach((row, i) => {
const inMark = i >= from && i < from + mark.length
const text = inMark ? mark[i - from]! : i === taglineRow ? SPLASH_TAGLINE : ''
lines.push([{ text: row, tone: 'art' }, { text: ' ', tone: 'fg' }, { text: text.padEnd(right), tone: inMark ? 'mark' : 'fg' }])
})
lines.push([{ text: versionLine(), tone: 'dim' }])
} else {
if (width >= blockWidth('qqq') && height >= 7 + rowsBelow) for (const row of mark) lines.push([{ text: row, tone: 'mark' }])
else lines.push([{ text: 'qqq', tone: 'mark' }])
lines.push([{ text: SPLASH_TAGLINE, tone: 'fg' }])
lines.push([{ text: versionLine(), tone: 'dim' }])
}
if (!header) {
lines.push([])
lines.push(...below)
}
// the ASCII glyph set (no UTF-8 locale): the middle dot and the ellipsis too
if (input.ascii) for (const line of lines) for (const seg of line) seg.text = seg.text.replace(/·/g, '-').replace(/…/g, '...')
const shown = lines.slice(0, Math.max(0, height))
const top = header ? input.headerTop ?? 1 : Math.max(0, Math.floor((height - shown.length) / 2))
const out: SplashLine[] = Array.from({ length: Math.min(top, height) }, () => [])
for (const line of shown) {
if (out.length >= height) break
out.push(centerSegs(line, width))
}
return out
}
/** Centres a line of pieces in `width` cells, cutting the last pieces with an ellipsis when it is wider. */
export function centerSegs(line: SplashLine, width: number): SplashLine {
const total = line.reduce((w, s) => w + cellWidth(s.text), 0)
if (total <= width) {
const left = Math.floor((width - total) / 2)
return left > 0 ? [{ text: ' '.repeat(left), tone: 'fg' }, ...line] : line
}
const out: SplashLine = []
let room = width
for (const s of line) {
if (room <= 0) break
const w = cellWidth(s.text)
if (w <= room) {
out.push(s)
room -= w
} else {
out.push({ text: trunc(s.text, room), tone: s.tone })
room = 0
}
}
return out
}
/** The plain text of a laid-out splash (tests, the overflow sweep). */
export function splashText(lines: SplashLine[]): string {
return lines.map((l) => l.map((s) => s.text).join('')).join('\n')
}
function toneColor(t: ThemeTokens, tone: SplashTone): string {
return tone === 'art' ? t.acc : tone === 'mark' ? t.acc2 : tone === 'dim' || tone === 'key' ? t.dim : tone === 'err' ? t.err : tone === 'status' ? t.acc : t.fg
}
export interface SplashOptions {
plan: BootPlan
/** How to connect and sign in (the store's signInDeps). */
deps: SignInDeps
/** Ends the program and prints a line (the picker closed without a choice). */
exit: (hint: string) => void
/** The splash stays at least this long (0 in tests). */
minMs: number
/** Animate Query (off in tests: one still frame). */
animate: boolean
}
export class SplashScreen implements Screen {
readonly kind = 'splash'
readonly id = 'splash'
phase: SplashPhase = 'starting'
status = 'reading profiles…'
statusKind: 'info' | 'error' = 'info'
/** The profile being connected to (Enter retries it after a failure). */
target?: Target
/** Query moves until a key is pressed. */
animate: boolean
readonly shownAt = Date.now()
/** The sign-in itself (prompts, device code, browser address) is the sign-in screen's, unshown. */
readonly signIn: SignInScreen
private signingIn = false
/** The picker's profiles (its height follows them). */
private pickerModel?: ServersModel
constructor(readonly store: AppStore, readonly options: SplashOptions) {
this.animate = options.animate
this.signIn = new SignInScreen(store, options.deps)
}
title(): string {
return 'qqq'
}
fkeys(): FKeyLabels {
return {}
}
/** Starts the boot plan. */
start(): void {
const plan = this.options.plan
if (plan.kind === 'connect') this.connectTo(plan.target)
else if (plan.kind === 'pick') this.pick(plan.note, false)
else this.add(plan.note)
}
private say(text: string, kind: 'info' | 'error' = 'info'): void {
this.status = text
this.statusKind = kind
this.store.bump()
}
private model(): ServersModel {
const deps = this.options.deps
return new ServersModel({ secrets: deps.secrets, probe: (server) => probeServer(server, { fetch: deps.fetch }), onChange: () => this.store.bump() })
}
/** The Servers picker over the splash; Esc quits (or, after a failure, returns to it). */
pick(note?: string, fromFailure = this.phase === 'failed'): void {
const model = this.model()
if (!model.rows.length) return this.add(note)
this.pickerModel = model
const before = { phase: this.phase, status: this.status, kind: this.statusKind }
this.phase = 'picking'
this.say(note ?? 'choose a server to connect to', note ? 'error' : 'info')
const select = this.target?.name ?? (this.options.plan.kind === 'pick' ? this.options.plan.select : undefined)
const overlay: ServersOverlay = new ServersOverlay(this.store, (row) => this.connectTo({ name: row.name, server: row.server }), model, {
title: 'Connect to a server',
enterLabel: 'Enter connects',
escLabel: fromFailure ? 'Esc goes back' : 'Esc quits',
select,
top: (sh) => this.pickerPlace(sh).pickerTop,
onEscape: () => {
this.store.closeOverlay(overlay)
if (!fromFailure) return this.options.exit(PICK_HINT)
this.phase = before.phase
this.say(before.status, before.kind)
},
})
this.store.open(overlay)
}
/** The picker's rows: two per profile and six of frame, filter, header and hints (the Servers dialog's own sum). */
private pickerHeight(): number {
return Math.max(10, Math.min(30, (this.pickerModel?.rows.length ?? 2) * 2 + 6))
}
/**
* The header and the picker under it, centred together; a terminal too short for both gets the
* picker near the top, over the header.
*/
pickerPlace(screenHeight: number): { headerTop: number; pickerTop: number } {
const pickerHeight = this.pickerHeight()
const header = splashLayout(this.store.width, screenHeight, { ...this.layoutInput('top'), reserve: pickerHeight + 1, headerTop: 0 }).length
const total = header + 1 + pickerHeight
if (total > screenHeight - 1) return { headerTop: 1, pickerTop: 1 }
const top = Math.max(0, Math.floor((screenHeight - total) / 2))
return { headerTop: top, pickerTop: top + header + 1 }
}
/** What to lay out at a height: the centred splash, or the header above the picker. */
inputFor(height: number, now = Date.now()): SplashLayoutInput {
if (!(this.phase === 'picking' && this.store.overlays.length)) return this.layoutInput('center', now)
const place = this.pickerPlace(height)
return { ...this.layoutInput('top', now), reserve: this.pickerHeight() + 1, headerTop: place.headerTop }
}
/** The add form (no profile yet, or `n`); a saved profile is connected to at once. */
add(note?: string): void {
this.phase = 'adding'
this.say(note ?? 'no server profile yet: add one to connect', note ? 'error' : 'info')
this.store.open(new ProfileFormOverlay(this.store, this.model(), undefined, (name) => {
const p = readConfig().profiles[name]
if (p) this.connectTo({ name, server: p.server })
}))
}
/** Connects to a profile (signing in when there is no stored sign-in); Home replaces the splash. */
connectTo(target: Target): void {
if (this.phase === 'connecting') return
this.phase = 'connecting'
this.target = target
const where = target.server.replace(/^https?:\/\//, '')
this.say(`connecting to ${where}…`)
const deps: SignInDeps = {
...this.options.deps,
progress: (text) => this.say(text),
beforeHome: () => new Promise((resolve) => setTimeout(resolve, splashWait(this.shownAt, Date.now(), this.options.minMs))),
}
const run = async () => {
try {
await connect(this.store, target, deps)
} catch (e) {
if (!(e instanceof NeedsSignIn)) throw e
this.signingIn = true
this.signIn.status = ''
this.say(`signing in to ${where}…`)
await this.signIn.signIn(target)
this.signingIn = false
this.say(`connecting to ${where}…`)
await connect(this.store, target, deps)
}
}
run().then(() => {
this.phase = 'done'
}).catch((e) => {
this.phase = 'failed'
this.say(errorText(e), 'error')
}).finally(() => {
this.signingIn = false
this.signIn.device = undefined
this.signIn.browserUrl = undefined
this.store.bump()
})
}
/** Every key is the splash's (no F-keys, menu or palette before a server is connected). */
claimsKey(): boolean {
return true
}
onKey(key: Key): boolean {
this.animate = false
const ch = charOf(key).toLowerCase()
if (key.name === 'escape') {
if (this.phase === 'connecting') this.signIn.onKey(key)
else if (this.phase === 'failed' || this.phase === 'adding' || this.phase === 'starting') this.options.exit(this.phase === 'failed' ? `qqq ui: ${this.status}` : PICK_HINT)
} else if (key.name === 'f10' || (key.ctrl && key.name === 'q')) this.options.exit('')
else if (this.phase === 'failed' && (key.name === 'return' || ch === 'r') && this.target) this.connectTo(this.target)
else if (this.phase !== 'connecting' && (ch === 'c' || ch === 'p' || (key.ctrl && key.name === 'o'))) this.pick()
else if (this.phase !== 'connecting' && ch === 'n') this.add()
this.store.bump()
return true
}
/** The status line: the sign-in's own words while it runs (creating a session, waiting for the browser). */
private statusSeg(now: number): SplashSeg {
const text = this.signingIn && this.signIn.status ? this.signIn.status : this.status
if (this.statusKind === 'error') return { text, tone: 'err' }
const busy = this.phase === 'connecting' || this.phase === 'starting'
if (!busy) return { text, tone: 'status' }
const frames = isAscii() ? SPINNER_ASCII : SPINNER
const spin = this.animate ? frames[Math.floor(now / 100) % frames.length]! : isAscii() ? '*' : '·'
return { text: `${spin} ${text}`, tone: 'status' }
}
private extra(): SplashSeg[] {
const s = this.signIn
if (s.browserUrl) return [{ text: 'Sign in in your browser (opened); if it did not open, visit:', tone: 'fg' }, { text: s.browserUrl, tone: 'fg' }]
if (s.device) {
const left = Math.max(0, Math.round((s.device.expires - Date.now()) / 1000))
return [{ text: `Open ${s.device.url}`, tone: 'fg' }, { text: `and confirm the code ${s.device.code} (expires in ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')})`, tone: 'fg' }]
}
return []
}
private keys(): string {
if (this.store.overlays.length) return ''
if (this.phase === 'failed') return 'Enter retries · c chooses another profile · n adds one · Esc quits'
if (this.phase === 'adding') return 'n adds a server profile · Esc quits'
if (this.phase === 'connecting' && (this.signIn.browserUrl || this.signIn.device)) return 'Esc cancels the sign-in'
return ''
}
layoutInput(anchor: 'center' | 'top', now = Date.now()): SplashLayoutInput {
const busy = this.phase === 'connecting' || this.phase === 'starting'
return { ascii: isAscii(), pose: this.animate ? splashPose(now, busy) : { blink: false, wag: false }, status: this.statusSeg(now), extra: this.extra(), keys: this.keys(), anchor }
}
/** The text as drawn now (tests). */
text(width: number, height: number): string {
return splashText(splashLayout(width, height, this.inputFor(height)))
}
render(width: number, height: number) {
return <SplashView splash={this} width={width} height={height} />
}
}
/** Repaints itself at about 10 fps while Query moves (only this component). */
function SplashView({ splash, width, height }: { splash: SplashScreen; width: number; height: number }) {
const [, tick] = useReducer((n: number) => n + 1, 0)
const moving = splash.animate && splash.phase !== 'done'
useEffect(() => {
if (!moving) return
const timer = setInterval(tick, 100)
return () => clearInterval(timer)
}, [moving])
const t = theme()
const lines = splashLayout(width, height, splash.inputFor(height))
return (
<box flexDirection="column" overflow="hidden" width={width} height={height}>
{lines.map((line, i) => (
<text key={i} flexShrink={0} wrapMode="none" height={1}>
{line.length ? line.map((s, k) => <span key={k} fg={toneColor(t, s.tone)}>{s.text}</span>) : ' '}
</text>
))}
</box>
)
}
-32
View File
@@ -14,7 +14,6 @@ import { openRealtimeStream } from '@qrun-io/qqq-ui-api-client'
import { History, MetaDataIndex, addRecent, currentCredential, readIdleMinutes, type MetaDataDocument, type RecentItem } from '@qrun-io/qqq-core'
import { TUI_CHANNEL, TuiClient, type VersionInfo } from './client'
import { charOf, isPrintable, type Key } from './keys'
import { bankKey, legacyProfile, shiftNumber, type LegacyProfile } from './keys/fkeys'
import { ChoiceOverlay, ConfirmOverlay, CliCommandOverlay, MessageOverlay } from './overlays/basic'
import { Extensions } from './extensions'
import { PrintTracker, printScreen } from './printing'
@@ -38,8 +37,6 @@ export interface Settings {
singleKeys: boolean
/** Copy CLI equivalents to the clipboard automatically (ui.copyCliCommands; default off). */
copyCli?: boolean
/** The F-key bar: one row, two rows (primary and Shift), or auto (two from 30 lines); keys/shift-bank.ts. */
fkeyBar?: 'one' | 'two' | 'auto'
}
export interface Status {
@@ -121,11 +118,6 @@ export class AppStore {
/** Copies text to the clipboard (set by main); returns how it was copied, or undefined when there is no clipboard. */
copyText: (text: string) => string | undefined = () => undefined
sidebar?: SidebarLike
/** How this terminal numbers its legacy shifted F-keys (keys/fkeys.ts; QQQ_FKEYS overrides). */
fkeyProfile: LegacyProfile = legacyProfile()
/** One-row bar: the Shift row is shown instead of the primary row until this time (after a Shift+F press). */
shiftRowUntil = 0
private shiftRowTimer?: ReturnType<typeof setTimeout>
private listeners = new Set<Listener>()
private timers: Array<ReturnType<typeof setInterval>> = []
private streamAbort?: AbortController
@@ -371,8 +363,6 @@ export class AppStore {
// F-key fallbacks for terminals that take the F-keys: Esc then a digit (arrives as Alt+digit) or Ctrl+digit; 0 is F10
const digit = /^[0-9]$/.test(raw.name) ? raw.name : /^[0-9]$/.test(raw.sequence?.slice(-1) ?? '') && raw.meta ? raw.sequence.slice(-1) : undefined
if (digit !== undefined && (raw.meta || raw.ctrl)) key = { name: `f${digit === '0' ? 10 : digit}`, sequence: '' }
// Shift+F1…F12 (any terminal's form, keys/fkeys.ts) are the Shift bank: shift+f1…shift+f12
key = bankKey(key, this.fkeyProfile)
try {
this.dispatch(key)
} catch (e) {
@@ -409,8 +399,6 @@ export class AppStore {
// Ctrl+P prints what you see on every screen; Print Screen too when the terminal passes it on
// (kitty keyboard protocol); F8 prints where the screen has no Delete for it
if ((key.ctrl && key.name === 'p') || key.name === 'printscreen' || (key.name === 'f8' && this.fkeys().find((k) => k.n === 8)?.label === 'Print')) return printScreen(this)
const shifted = shiftNumber(key)
if (shifted !== undefined) return this.commands().shiftKey(shifted)
if (key.name === 'f1') return this.commands().help()
if (this.backslash && charOf(key) === '?') {
this.backslash = false
@@ -529,8 +517,6 @@ export class AppStore {
dispose(): void {
for (const t of this.timers) clearInterval(t)
this.timers = []
if (this.shiftRowTimer) clearTimeout(this.shiftRowTimer)
this.shiftRowTimer = undefined
this.clockTimer = undefined
this.streamAbort?.abort()
this.printTracker.dispose()
@@ -673,22 +659,6 @@ export class AppStore {
this.bump()
}
/** One-row bar: shows the Shift row for `ms` (keys/shift-bank.ts SHIFT_ROW_MS), then the primary row again. */
showShiftRow(ms = 2000): void {
this.shiftRowUntil = Date.now() + ms
if (this.shiftRowTimer) clearTimeout(this.shiftRowTimer)
this.shiftRowTimer = setTimeout(() => {
this.shiftRowTimer = undefined
this.bump()
}, ms + 10)
;(this.shiftRowTimer as { unref?: () => void }).unref?.()
}
/** Whether one-row mode shows the Shift row now. */
shiftRowShown(): boolean {
return Date.now() < this.shiftRowUntil
}
/** The function-key bar for the current context; from 120 columns also 11 (reserved, blank) and 12 (the Assistant). */
fkeys(): Array<{ n: number; label: string | null; global: string }> {
const defaults: Record<number, string> = { 1: 'Help', 2: 'Search', 3: 'View', 4: 'Edit', 5: 'Refresh', 6: 'Filter', 7: 'New', 8: 'Delete', 9: 'Menu', 10: 'Quit' }
@@ -734,8 +704,6 @@ export interface CommandsLike {
menu(): void
/** The Servers dialog (Ctrl+O). */
servers(): void
/** Shift+Fn: the Shift bank's key n (keys/shift-bank.ts). */
shiftKey(n: number): void
commandLine(initial?: string): void
viewSettings(): void
lock(): void
+1 -2
View File
@@ -271,8 +271,7 @@ test('F-key bar: 11 and 12 from 120 columns, 12Ask lit with the extension, a cli
expect(keys.map((k) => k.n)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
expect(keys.find((k) => k.n === 12)?.label).toBe('Ask')
expect(keys.find((k) => k.n === 11)?.label).toBeNull()
// the primary row (from 30 lines the Shift row is drawn under it)
const bar = h.frame().split('\n').find((l) => l.includes(' 1Help'))!
const bar = h.frame().split('\n').filter((l) => l.trim()).pop()!
expect(bar).toContain('12Ask')
expect(bar).toMatch(/11\s+12Ask/)
expect([...bar].length).toBeLessThanOrEqual(120)
-195
View File
@@ -1,195 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/** The function-key decoder (keys/fkeys.ts), per sequence family, and the stdin rewrite into what OpenTUI reads. */
import { EventEmitter } from 'node:events'
import { describe, expect, test } from 'bun:test'
import { parseKeypress } from '@opentui/core'
import { bankKey, canonicalSequence, chordName, decodeFKey, fkeyChord, legacyProfile, rewriteFKeyInput, shiftNumber, type FKeyChord, type LegacyProfile } from '../src/keys/fkeys'
import { CPR_QUIET_MS, installFKeyRewrite } from '../src/keys/stdin-rewrite'
const E = '\x1b'
const name = (seq: string, profile: LegacyProfile = 'vt220') => {
const d = decodeFKey(seq, profile)
return d ? chordName(d.chord) : undefined
}
const family = (seq: string, profile: LegacyProfile = 'vt220') => decodeFKey(seq, profile)?.family
/** What OpenTUI makes of a sequence after the rewrite (kitty parsing on, as the renderer runs). */
function opentui(seq: string, profile: LegacyProfile = 'vt220'): string | undefined {
const k = parseKeypress(rewriteFKeyInput(seq, profile, { cpr: true }), { useKittyKeyboard: true })
if (!k?.name) return undefined
return `${k.ctrl ? 'ctrl+' : ''}${k.meta ? 'alt+' : ''}${k.shift ? 'shift+' : ''}${k.name}`
}
describe('xterm modifier forms (tmux, iTerm2, Windows Terminal, xterm, VTE)', () => {
const shift = ['1;2P', '1;2Q', '1;2R', '1;2S', '15;2~', '17;2~', '18;2~', '19;2~', '20;2~', '21;2~', '23;2~', '24;2~']
test('Shift+F1…F12', () => {
shift.forEach((s, i) => expect(name(`${E}[${s}`)).toBe(`shift+f${i + 1}`))
expect(family(`${E}[1;2P`)).toBe('csi-letter')
expect(family(`${E}[1;2R`)).toBe('csi-f3-cpr')
expect(family(`${E}[15;2~`)).toBe('csi-tilde')
})
test('SS3 with a modifier and the plain F1–F4', () => {
expect(name(`${E}O2P`)).toBe('shift+f1')
expect(name(`${E}O2S`)).toBe('shift+f4')
expect(name(`${E}OQ`)).toBe('f2')
expect(family(`${E}O2R`)).toBe('ss3')
})
test('CSI 11..14 forms (VT220, PuTTY) with and without modifiers', () => {
expect(name(`${E}[11~`)).toBe('f1')
expect(name(`${E}[12;2~`)).toBe('shift+f2')
expect(name(`${E}[14;2~`)).toBe('shift+f4')
})
test('Ctrl, Alt and combinations come for free', () => {
expect(name(`${E}[1;5P`)).toBe('ctrl+f1')
expect(name(`${E}[15;5~`)).toBe('ctrl+f5')
expect(name(`${E}[24;5~`)).toBe('ctrl+f12')
expect(name(`${E}[1;6P`)).toBe('ctrl+shift+f1')
expect(name(`${E}[20;3~`)).toBe('alt+f9')
expect(name(`${E}${E}[1;2P`)).toBe('alt+shift+f1')
expect(name(`${E}[1;5R`)).toBe('ctrl+f3')
})
test('OpenTUI reads all of them after the rewrite, Shift+F3 included', () => {
shift.forEach((s, i) => expect(opentui(`${E}[${s}`)).toBe(`shift+f${i + 1}`))
expect(opentui(`${E}[1;5R`)).toBe('ctrl+f3')
// without the rewrite OpenTUI drops Shift+F3: it looks like a cursor position report
expect(parseKeypress(`${E}[1;2R`, { useKittyKeyboard: true })).toBeNull()
})
})
describe('kitty keyboard protocol', () => {
test('F3 is CSI 13 ~; event sub-parameters; the CSI u code points', () => {
expect(name(`${E}[13;2~`)).toBe('shift+f3')
expect(name(`${E}[15;2:2~`)).toBe('shift+f5')
expect(decodeFKey(`${E}[1;2:3Q`)!.chord).toEqual({ n: 2, shift: true, ctrl: false, alt: false, release: true })
expect(name(`${E}[57364;2u`)).toBe('shift+f1')
expect(name(`${E}[57375;2u`)).toBe('shift+f12')
expect(family(`${E}[57365;2u`)).toBe('kitty-u')
// F13 and up are keys of their own (macOS keyboards have them)
expect(decodeFKey(`${E}[57376;2u`)).toBeUndefined()
})
test('OpenTUI reads the kitty forms itself (no rewrite)', () => {
expect(rewriteFKeyInput(`${E}[13;2~`, 'vt220', { cpr: true })).toBe(`${E}[13;2~`)
expect(opentui(`${E}[13;2~`)).toBe('shift+f3')
expect(opentui(`${E}[57364;2u`)).toBe('shift+f1')
})
})
describe('legacy numbered forms, per terminal profile', () => {
const codes = ['25~', '26~', '28~', '29~', '31~', '32~', '33~', '34~']
test('vt220 (F13 = Shift+F1): CSI 25~…34~ are Shift+F1…F8', () => {
codes.forEach((c, i) => expect(name(`${E}[${c}`, 'vt220')).toBe(`shift+f${i + 1}`))
expect(family(`${E}[25~`)).toBe('legacy-number')
})
test('rxvt and the Linux console (F11 = Shift+F1): Shift+F3…F10', () => {
codes.forEach((c, i) => expect(name(`${E}[${c}`, 'rxvt')).toBe(`shift+f${i + 3}`))
// CSI 23~ / 24~ stay F11 / F12 everywhere (rxvt's Shift+F1 / F2 cannot be told apart from them)
expect(name(`${E}[23~`, 'rxvt')).toBe('f11')
})
test('macOS Terminal.app: Shift+F5…F12', () => {
codes.forEach((c, i) => expect(name(`${E}[${c}`, 'apple')).toBe(`shift+f${i + 5}`))
})
test('modern terminals: legacy numbers mean nothing (Windows Terminal sends CSI 29~ for the Menu key)', () => {
for (const c of codes) expect(decodeFKey(`${E}[${c}`, 'modern')).toBeUndefined()
expect(rewriteFKeyInput(`${E}[29~`, 'modern', { cpr: true })).toBe(`${E}[29~`)
})
test('the rewrite gives OpenTUI the canonical form', () => {
expect(opentui(`${E}[25~`, 'vt220')).toBe('shift+f1')
expect(opentui(`${E}[26~`, 'vt220')).toBe('shift+f2')
expect(opentui(`${E}[33~`, 'apple')).toBe('shift+f11')
expect(opentui(`${E}[34~`, 'rxvt')).toBe('shift+f10')
})
})
describe('rxvt suffixes and the Linux console', () => {
test('$ Shift, ^ Ctrl, @ Ctrl+Shift', () => {
expect(name(`${E}[23$`)).toBe('shift+f11')
expect(name(`${E}[24$`)).toBe('shift+f12')
expect(name(`${E}[11^`)).toBe('ctrl+f1')
expect(name(`${E}[15^`)).toBe('ctrl+f5')
expect(name(`${E}[24^`)).toBe('ctrl+f12')
expect(name(`${E}[25^`)).toBe('ctrl+shift+f3')
expect(name(`${E}[23@`)).toBe('ctrl+shift+f11')
expect(family(`${E}[15^`)).toBe('rxvt-suffix')
expect(opentui(`${E}[23$`)).toBe('shift+f11')
expect(opentui(`${E}[15^`)).toBe('ctrl+f5')
})
test('editing keys with the same suffixes are not function keys', () => {
expect(decodeFKey(`${E}[2$`)).toBeUndefined()
expect(decodeFKey(`${E}[5^`)).toBeUndefined()
expect(rewriteFKeyInput(`${E}[3^`, 'rxvt', { cpr: true })).toBe(`${E}[3^`)
})
test('Linux console CSI [ A…E are F1…F5', () => {
expect(name(`${E}[[A`)).toBe('f1')
expect(name(`${E}[[E`)).toBe('f5')
})
})
describe('the rewrite', () => {
test('leaves cursor reports alone during the probe, and CSI 1;1R always', () => {
expect(rewriteFKeyInput(`${E}[1;2R`, 'vt220', { cpr: false })).toBe(`${E}[1;2R`)
expect(rewriteFKeyInput(`${E}[1;1R`, 'vt220', { cpr: true })).toBe(`${E}[1;1R`)
expect(rewriteFKeyInput(`${E}[12;40R`, 'vt220', { cpr: true })).toBe(`${E}[12;40R`)
expect(rewriteFKeyInput(`${E}[1;2R`, 'vt220', { cpr: true })).toBe(`${E}[13;2~`)
})
test('rewrites inside a chunk, keeps text and other sequences, never touches a paste', () => {
expect(rewriteFKeyInput(`ab${E}[25~${E}[A${E}[1;2R`, 'vt220', { cpr: true })).toBe(`ab${E}[11;2~${E}[A${E}[13;2~`)
expect(rewriteFKeyInput(`${E}[200~${E}[25~${E}[201~`, 'vt220', { cpr: true })).toBe(`${E}[200~${E}[25~${E}[201~`)
expect(rewriteFKeyInput('plain', 'vt220', { cpr: true })).toBe('plain')
})
test('canonical sequences', () => {
const c = (n: number, mods: Partial<FKeyChord> = {}): FKeyChord => ({ n, shift: false, ctrl: false, alt: false, ...mods })
expect(canonicalSequence(c(1, { shift: true }))).toBe(`${E}[11;2~`)
expect(canonicalSequence(c(3, { ctrl: true }))).toBe(`${E}[13;5~`)
expect(canonicalSequence(c(12))).toBe(`${E}[24~`)
})
test('on the stream: Buffer chunks before any listener; quiet after a resume; dispose restores', () => {
const stream = new EventEmitter()
const seen: string[] = []
stream.on('data', (d: Buffer | string) => seen.push(Buffer.isBuffer(d) ? d.toString('latin1') : d))
let now = 1000
const r = installFKeyRewrite(stream, 'vt220', () => now)
stream.emit('data', Buffer.from(`${E}[1;2R`))
stream.emit('data', Buffer.from(`${E}[25~`))
now += CPR_QUIET_MS
stream.emit('data', Buffer.from(`${E}[1;2R`))
r.quiet()
stream.emit('data', `${E}[1;2R`)
stream.emit('data', Buffer.from('é', 'utf8'))
r.dispose()
stream.emit('data', `${E}[25~`)
expect(seen).toEqual([`${E}[1;2R`, `${E}[11;2~`, `${E}[13;2~`, `${E}[1;2R`, Buffer.from('é', 'utf8').toString('latin1'), `${E}[25~`])
})
})
describe('key events and the profile', () => {
test('fkeyChord and bankKey: Shift (only) becomes the Shift bank; Ctrl+F keeps acting as F', () => {
expect(bankKey({ name: 'f2', sequence: `${E}[1;2Q`, shift: true }).name).toBe('shift+f2')
expect(bankKey({ name: 'f2', sequence: `${E}OQ` }).name).toBe('f2')
expect(bankKey({ name: 'f5', sequence: '', ctrl: true }).name).toBe('f5')
expect(bankKey({ name: 'f1', sequence: '', ctrl: true, shift: true }).name).toBe('f1')
expect(bankKey({ name: 'f9', sequence: '', meta: true, shift: true }).name).toBe('f9')
// a sequence OpenTUI left unnamed is decoded from its bytes
expect(bankKey({ name: '', sequence: `${E}[26~` }, 'vt220').name).toBe('shift+f2')
expect(bankKey({ name: 'menu', sequence: `${E}[29~` }, 'modern').name).toBe('menu')
expect(fkeyChord({ name: 'f13', sequence: '' })).toBeUndefined()
expect(fkeyChord({ name: 'x', sequence: 'x' })).toBeUndefined()
expect(shiftNumber({ name: 'shift+f10', sequence: '' })).toBe(10)
expect(shiftNumber({ name: 'f10', sequence: '' })).toBeUndefined()
})
test('legacyProfile from the environment', () => {
expect(legacyProfile({ TERM_PROGRAM: 'Apple_Terminal', TERM: 'xterm-256color' })).toBe('apple')
expect(legacyProfile({ TERM_PROGRAM: 'iTerm.app', TERM: 'xterm-256color' })).toBe('modern')
expect(legacyProfile({ WT_SESSION: 'x', TERM: 'xterm-256color' })).toBe('modern')
expect(legacyProfile({ TMUX: '/tmp/x', TERM: 'tmux-256color', TERM_PROGRAM: 'tmux' })).toBe('modern')
expect(legacyProfile({ TERM: 'rxvt-unicode-256color' })).toBe('rxvt')
expect(legacyProfile({ TERM: 'linux' })).toBe('rxvt')
expect(legacyProfile({ TERM: 'xterm' })).toBe('vt220')
expect(legacyProfile({ TERM: 'xterm', QQQ_FKEYS: 'apple' })).toBe('apple')
expect(legacyProfile({ TERM: 'xterm', QQQ_FKEYS: 'bogus' })).toBe('vt220')
})
})
+1 -2
View File
@@ -50,8 +50,7 @@ export interface Harness {
/** Renders the whole TUI against the fake server and waits for the home screen. */
export async function startTui(width = 120, height = 36, options: { metaData?: unknown; auth?: FakeAuth; secrets?: () => SecretStore; until?: 'home' | 'signin' } = {}): Promise<Harness> {
const log: FakeLog = { requests: [] }
// the sign-in tests drive the sign-in screen (--no-splash); the rest go through the splash
const store = boot({ args: parseArgs(['--profile', 'fake', ...(options.until === 'signin' ? ['--no-splash'] : [])]), quit: () => undefined, build: 'test', secrets: options.secrets ?? sessionSecrets, fetch: fakeFetch(log, options.metaData, options.auth) })
const store = boot({ args: parseArgs(['--profile', 'fake']), quit: () => undefined, build: 'test', secrets: options.secrets ?? sessionSecrets, fetch: fakeFetch(log, options.metaData, options.auth) })
const setup = await testRender(<App store={store} />, { width, height })
const h: Harness = { setup, store, log, frame: () => setup.captureCharFrame() }
store.captureScreen = () => setup.captureCharFrame()
+2 -3
View File
@@ -101,9 +101,8 @@ test('F-key bar: a click presses the key; the footer connection opens the menu a
expect(h.store.overlays.length).toBe(1)
await press(h, () => h!.setup.mockInput.pressEscape())
expect(h.store.overlays.length).toBe(0)
// the footer is the row above the F-key bar (two rows from 30 lines: primary, then Shift); its
// first cells are the server and profile: the Servers dialog
await press(h, () => mouse().click(2, 30 - 3))
// the footer is the row above the F-key bar; its first cells are the server and profile: the Servers dialog
await press(h, () => mouse().click(2, 30 - 2))
await waitFor(h, () => h!.store.overlays.length === 1)
expect(h.store.overlays[0]!.constructor.name).toBe('ServersOverlay')
})
-319
View File
@@ -1,319 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/** The Shift bank (Shift+F1…F10): the table, the bar in one and two rows, dispatch, the mouse, help. */
import { afterEach, beforeAll, expect, test } from 'bun:test'
import { testRender } from '@opentui/react/test-utils'
import { readConfig } from '@qrun-io/qqq-core'
import { App } from '../src/app'
import { boot, parseArgs } from '../src/boot'
import { ACTIONS } from '../src/keys/actions'
import { SHIFT_BANK, fkeyBarRows, shiftSlots } from '../src/keys/shift-bank'
import { shiftRowCells, slotWidths } from '../src/keys/shift-row'
import { CPR_QUIET_MS, installFKeyRewrite } from '../src/keys/stdin-rewrite'
import { fkeyAt } from '../src/mouse/hit'
import { SqlRunnerScreen } from '../src/sql/screen'
import { fakeFetch, type FakeLog } from './fake-server'
import { sqlFakeFetch } from './fake-sql'
import { command, isolate, press, sessionSecrets, startTui, waitFor, type Harness } from './helpers'
let h: Harness | undefined
beforeAll(() => isolate())
afterEach(() => {
h?.store.dispose()
h?.setup.renderer.destroy()
h = undefined
})
const E = '\x1b'
/** Raw bytes into the terminal, as a terminal would send them. */
const raw = (seq: string) => press(h!, () => h!.setup.mockInput.pressKeys([seq]))
const lines = (frame: string) => frame.replace(/\n$/, '').split('\n')
const fits = (frame: string, width: number) => lines(frame).every((l) => [...l].length <= width)
const last = (frame: string, n = 1) => lines(frame).slice(-n)
async function start(width: number, height: number, fkeyBar?: 'one' | 'two' | 'auto', fetch?: typeof globalThis.fetch): Promise<Harness> {
const log: FakeLog = { requests: [] }
const store = boot({ args: parseArgs(['--profile', 'fake']), quit: () => undefined, build: 'test', secrets: sessionSecrets, fetch: fetch ?? fakeFetch(log) })
if (fkeyBar) store.settings.fkeyBar = fkeyBar
const setup = await testRender(<App store={store} />, { width, height })
const harness: Harness = { setup, store, log, frame: () => setup.captureCharFrame() }
store.captureScreen = () => setup.captureCharFrame()
await waitFor(harness, () => harness.store.screen?.kind === 'home' && Boolean(harness.store.meta))
return harness
}
/** Changes the bar mode and repaints. */
async function mode(m: 'one' | 'two' | 'auto'): Promise<string> {
h!.store.settings.fkeyBar = m
h!.store.bump()
return press(h!, () => undefined)
}
/** The fake server with the assistant extension (enough for the panel to open). */
function assistantFetch(): typeof fetch {
const base = fakeFetch({ requests: [] })
const json = (d: unknown) => new Response(JSON.stringify(d), { headers: { 'content-type': 'application/json' } })
return (async (input: RequestInfo | URL, init?: RequestInit) => {
const path = new URL(new Request(input as RequestInfo, init).url).pathname.replace('/qqq/v1', '')
if (path === '/version') return json({ apiVersion: '1.9.0', features: ['metaData', 'tables', 'processes'], extensions: [{ name: 'assistant', version: '1.7.0', pathPrefix: '/assistant' }] })
if (path === '/assistant/config') return json({ enabled: true, model: 'm', profile: { displayName: 'Assistant' } })
return base(input, init)
}) as typeof fetch
}
// ---- the table ----
test('the bank is one table of ten keys, each bound to a named action, with short labels for 80 columns', () => {
expect(SHIFT_BANK.map((k) => k.n)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
expect(SHIFT_BANK.map((k) => k.label)).toEqual(['Keys', 'Servers', 'SQL', 'Bulk', 'Metadata', 'Views', 'Copy', 'Export', 'History', 'Assistant'])
for (const k of SHIFT_BANK) {
expect(ACTIONS[k.action]).toBeDefined()
expect(k.short.length).toBeLessThanOrEqual(5)
}
// the assistant toggle is the named action F12 and Ctrl+G bind too
expect(SHIFT_BANK.find((k) => k.n === 10)!.action).toBe('assistant.toggle')
})
test('rows: auto is two from 30 lines; one and two are fixed', () => {
expect(fkeyBarRows('auto', 29)).toBe(1)
expect(fkeyBarRows('auto', 30)).toBe(2)
expect(fkeyBarRows(undefined, 40)).toBe(2)
expect(fkeyBarRows('one', 40)).toBe(1)
expect(fkeyBarRows('two', 24)).toBe(2)
})
test('row cells: the primary arithmetic, ⇧ on the first number, short labels below 100 columns, clipped', () => {
expect(slotWidths(80, 10)).toEqual([8, 8, 8, 8, 8, 8, 8, 8, 8, 8])
expect(slotWidths(120, 12).reduce((a, b) => a + b, 0)).toBe(120)
const slots = SHIFT_BANK.map((k) => ({ n: k.n, label: k.label, global: k.label, short: k.short }))
const narrow = shiftRowCells(slots, 80)
expect(narrow[0]!.num).toBe('⇧1')
expect(narrow[1]!.label.trim()).toBe('Servr')
expect(narrow.map((c) => c.num + c.label).join('').length).toBe(80)
expect(shiftRowCells(slots, 120)[4]!.label.trim()).toBe('Metadata')
expect(shiftRowCells(slots, 80, true)[0]!.num).toBe('S1')
// twelve slots (when the primary row has 11 and 12) line up; 11 and 12 are blank
expect(shiftRowCells([...slots, { n: 11, label: null, global: '', short: '' }, { n: 12, label: null, global: '', short: '' }], 120).length).toBe(12)
})
// ---- the bar ----
test('80×24 auto: one row; a Shift+F press shows the Shift row for 2 s, then the primary row again', async () => {
h = await start(80, 24)
let f = h.frame()
expect(fits(f, 80)).toBe(true)
expect(last(f)[0]).toContain(' 1Help')
expect(f).not.toContain('⇧1')
// Shift+F2 (xterm form): the Servers dialog; the bar swaps to the Shift row
await raw(`${E}[1;2Q`)
expect(h.store.overlays[0]!.constructor.name).toBe('ServersOverlay')
f = h.frame()
expect(last(f)[0]).toStartWith('⇧1Keys')
expect(last(f)[0]).toContain('2Servr')
expect(last(f)[0]).toContain('9Hist')
expect(fits(f, 80)).toBe(true)
// after the 2 s the primary row is back
h.store.shiftRowUntil = Date.now() - 1
h.store.bump()
f = await press(h, () => undefined)
expect(last(f)[0]).toContain(' 1Help')
})
test('80×24 two rows: primary then Shift, the footer above them, nothing wider than 80', async () => {
h = await start(80, 24, 'two')
const f = h.frame()
expect(fits(f, 80)).toBe(true)
const [primary, shift] = last(f, 2)
expect(primary).toContain(' 1Help')
expect(primary).toContain('10Quit')
expect(shift).toStartWith('⇧1Keys')
expect(shift).toContain('10Asst')
expect(lines(f).length).toBe(24)
})
test('120×40 auto: two rows with full labels; one-row mode keeps the primary row', async () => {
h = await start(120, 40)
let f = h.frame()
expect(fits(f, 120)).toBe(true)
const [primary, shift] = last(f, 2)
expect(primary).toContain(' 1Help')
expect(shift).toStartWith('⇧1Keys')
for (const label of ['2Servers', '3SQL', '4Bulk', '5Metadata', '6Views', '7Copy', '8Export', '9History', '10Asst']) expect(shift).toContain(label)
// the primary row has 11 and 12 from 120 columns: the Shift row lines up with it (11 and 12 unbound)
expect(shift).toMatch(/11\s+12\s*$/)
expect(shift.indexOf(' 9History')).toBe(primary.indexOf(' 9Menu'))
expect(lines(f).length).toBe(40)
f = await mode('one')
expect(last(f)[0]).toContain(' 1Help')
expect(f).not.toContain('⇧1Keys')
expect(lines(f).length).toBe(40)
await press(h, () => h!.setup.mockInput.pressKey('F9', { shift: true }))
f = await waitFor(h, () => h!.store.screen?.kind === 'history')
expect(last(f)[0]).toStartWith('⇧1Keys')
})
test('context: keys that do not apply are dimmed and say why; on a grid Bulk, Views, Copy and Export apply', async () => {
h = await start(120, 40)
const home = Object.fromEntries(shiftSlots(h.store).map((s) => [s.global, s.why]))
expect(home.Keys).toBeUndefined()
expect(home.Servers).toBeUndefined()
expect(home.SQL).toContain('sql-workbench')
expect(home.Bulk).toContain('table grid')
expect(home.Views).toContain('table grid')
expect(home.Copy).toBeDefined()
expect(home.Export).toContain('table grid')
expect(home.History).toBeUndefined()
expect(home.Assistant).toContain('assistant extension')
// Shift+F3 in the xterm form without the stdin rewrite (main.tsx installs it): OpenTUI takes it for a cursor report
await raw(`${E}[1;2R`)
expect(h.store.overlays.length).toBe(0)
await press(h, () => h!.setup.mockInput.pressKey('F10', { shift: true }))
expect(h.store.flashText?.text).toContain('Shift+F10 Assistant: needs the assistant extension')
expect(h.store.assistantOpen).toBe(false)
await command(h, 'table query person')
await waitFor(h, (f) => f.includes('First1 '))
const grid = Object.fromEntries(shiftSlots(h.store).map((s) => [s.global, s.why]))
expect(grid.Views).toBeUndefined()
expect(grid.Export).toBeUndefined()
// person has no TABLE_INSERT capability in the fake: Copy is dimmed and says so
expect(grid.Copy).toBe('copying needs permission to create records')
await press(h, () => h!.setup.mockInput.pressKey('F7', { shift: true }))
expect(h.store.flashText?.text).toBe('Shift+F7 Copy: copying needs permission to create records')
expect(h.store.screen?.kind).toBe('table')
})
// ---- dispatch ----
test('dispatch on a grid: Shift+F8 export, Shift+F6 saved views, Shift+F4 bulk, Shift+F7 copy, Shift+F5 metadata, Shift+F1 keys', async () => {
h = await start(120, 40)
await command(h, 'table query person')
await waitFor(h, (f) => f.includes('First1 '))
await press(h, () => h!.setup.mockInput.pressKey('F8', { shift: true }))
expect((h.store.overlays[0] as unknown as { title: string }).title).toBe('Export to a file')
await press(h, () => h!.setup.mockInput.pressEscape())
await press(h, () => h!.setup.mockInput.pressKey('F6', { shift: true }))
await waitFor(h, (f) => f.includes('Saved views'))
await press(h, () => h!.setup.mockInput.pressEscape())
h.store.overlays = []
await raw(`${E}[1;2S`) // Shift+F4
const bulk = h.store.overlays[0] as unknown as { title?: string } | undefined
if (bulk) expect(bulk.title).toContain('Run on')
else expect(h.store.flashText?.text).toContain('Shift+F4 Bulk')
h.store.overlays = []
// with permission to create records, Shift+F7 copies the row under the cursor into a new-record form
const meta = (h.store.screen as unknown as { meta: { capabilities: string[] } }).meta
meta.capabilities = [...meta.capabilities, 'TABLE_INSERT']
await press(h, () => h!.setup.mockInput.pressKey('F7', { shift: true }))
await waitFor(h, () => h!.store.screen?.kind === 'form')
await press(h, () => h!.setup.mockInput.pressEscape())
await waitFor(h, () => h!.store.screen?.kind === 'table')
const before = h.log.requests.filter((r) => r.path === '/metaData').length
await press(h, () => h!.setup.mockInput.pressKey('F5', { shift: true }))
await waitFor(h, () => h!.store.flashText?.text === 'metadata refreshed')
expect(h.log.requests.filter((r) => r.path === '/metaData').length).toBe(before + 1)
await raw(`${E}[1;2P`) // Shift+F1
expect(h.store.overlays[0]!.constructor.name).toBe('HelpOverlay')
})
test('plain F-keys are unchanged; Ctrl+F5 still refreshes like F5; Shift+F11 does nothing', async () => {
h = await start(120, 40)
await raw(`${E}OQ`) // F2
expect(h.store.screen?.kind).toBe('search')
await press(h, () => h!.setup.mockInput.pressEscape())
await press(h, () => h!.setup.mockInput.pressKey('F11', { shift: true }))
expect(h.store.flashText?.text).toContain('Shift+F11 does nothing')
await press(h, () => h!.setup.mockInput.pressKey('F5', { ctrl: true }))
expect(h.store.overlays.length).toBe(0)
})
test('the rewrite on the terminal input: Shift+F3 (CSI 1;2R) opens the SQL Runner; legacy CSI 26~ is Shift+F2', async () => {
const log: FakeLog = { requests: [] }
h = await start(120, 40, undefined, sqlFakeFetch(log, true, { executions: [], notebooks: [], rowsCalls: [] }))
let now = 0
const rewrite = installFKeyRewrite(h.setup.renderer.stdin, 'vt220', () => now)
// during the startup probe CSI 1;2R is a cursor report: nothing happens
await raw(`${E}[1;2R`)
expect(h.store.screen?.kind).toBe('home')
// past it, the same bytes are Shift+F3
now += CPR_QUIET_MS
try {
expect(shiftSlots(h.store).find((s) => s.n === 3)!.why).toBeUndefined()
await raw(`${E}[1;2R`)
await waitFor(h, () => h!.store.screen instanceof SqlRunnerScreen)
await press(h, () => h!.setup.mockInput.pressEscape())
h.store.overlays = []
await raw(`${E}[26~`)
expect(h.store.overlays[0]!.constructor.name).toBe('ServersOverlay')
} finally {
rewrite.dispose()
}
})
test('Shift+F10 toggles the Assistant (the same named action as Ctrl+G)', async () => {
h = await start(120, 40, undefined, assistantFetch())
await press(h, () => h!.setup.mockInput.pressKey('F10', { shift: true }))
expect(h.store.assistantOpen).toBe(true)
expect(h.store.focus).toBe('assistant')
// the panel has the focus: the same key goes on toggling (Shift+F keys are global)
await press(h, () => h!.setup.mockInput.pressKey('F10', { shift: true }))
expect(h.store.focus === 'content' || !h.store.assistantOpen).toBe(true)
})
// ---- the mouse ----
test('mouse: a click on the Shift row runs that key; in one-row mode while the Shift row shows, too', async () => {
h = await start(120, 40)
const y = 39
const shiftLine = last(h.frame())[0]!
const x = shiftLine.indexOf('2Servers') + 2
// the Shift row has as many slots as the primary row (12 from 120 columns)
expect(fkeyAt(x, 120, h.store.fkeys().map((k) => k.n))).toBe(2)
await press(h, () => h!.setup.mockMouse.click(x, y))
expect(h.store.overlays[0]!.constructor.name).toBe('ServersOverlay')
await press(h, () => h!.setup.mockInput.pressEscape())
h.store.overlays = []
// the primary row above still presses F1
await press(h, () => h!.setup.mockMouse.click(3, y - 1))
expect(h.store.overlays[0]!.constructor.name).toBe('HelpOverlay')
await press(h, () => h!.setup.mockInput.pressEscape())
h.store.overlays = []
h.store.showShiftRow()
const f = await mode('one')
const row = last(f)[0]!
expect(row).toStartWith('⇧1Keys')
await press(h, () => h!.setup.mockMouse.click(row.indexOf('9History') + 2, 39))
await waitFor(h, () => h!.store.screen?.kind === 'history')
})
// ---- help and settings ----
test('? lists the Shift bank; :fkeys and Settings › F-key bar switch and save the mode', async () => {
h = await start(120, 40)
h.store.commands().help()
const help = h.store.overlays[0] as unknown as { after: Array<{ title: string; keys: Array<[string, string]> }> }
expect(help.after[0]!.title).toStartWith('Shift bank')
expect(help.after[0]!.keys.map(([k]) => k)).toEqual(SHIFT_BANK.map((k) => `Shift+F${k.n}`))
expect(help.after[0]!.keys[2]![1]).toContain('not here')
h.store.overlays = []
await command(h, 'fkeys one')
expect(h.store.settings.fkeyBar).toBe('one')
expect(readConfig().ui?.fkeyBar).toBe('one')
const f = await press(h, () => undefined)
expect(f).not.toContain('⇧1Keys')
await command(h, 'fkeys sideways')
expect(h.store.flashText?.text).toContain('fkeys one|two|auto')
const entry = (h.store.commandsImpl!(h.store) as unknown as { entries(t: string): Array<{ id?: string; label: string; run?: () => void }> }).entries('').find((e) => e.id === 'settings:fkeys')
expect(entry?.label).toBe('F-key bar: one')
entry!.run!()
expect((h.store.overlays[0] as unknown as { title: string }).title).toBe('F-key bar')
await press(h, () => h!.setup.mockInput.pressEscape())
expect(h.store.overlays.length).toBe(0)
// (the : line keeps a failed command for editing, so this one runs directly)
await press(h, () => h!.store.commands().run('fkeys auto'))
expect(readConfig().ui?.fkeyBar).toBe('auto')
})
-274
View File
@@ -1,274 +0,0 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/** The start-up splash and profile picker: the boot plan, the picker (preselect, Enter, Esc), the add form, timing, ASCII, failures. */
import { afterEach, beforeAll, expect, test } from 'bun:test'
import { testRender } from '@opentui/react/test-utils'
import { readConfig, writeConfig, type Config } from '@qrun-io/qqq-core'
import { App } from '../src/app'
import { boot, parseArgs } from '../src/boot'
import { PICK_HINT, SPLASH_MIN_MS, lastUsedProfile, planBoot, readSplash, splashEnabled, splashWait, writeSplash } from '../src/boot-plan'
import { ProfileFormOverlay, ServersOverlay } from '../src/overlays/servers'
import { SplashScreen, splashLayout, splashText, versionLine, type SplashLayoutInput } from '../src/screens/splash'
import { fakeFetch, type FakeLog } from './fake-server'
import { command, isolate, press, sessionSecrets, waitFor, type Harness } from './helpers'
import { querySize } from '../src/saver/query-art'
import { setAscii } from '../src/theme'
import { cellWidth } from '../src/util/text'
let h: Harness | undefined
let exits: string[] = []
let quits = 0
beforeAll(() => isolate())
afterEach(() => {
h?.store.dispose()
h?.setup.renderer.destroy()
h = undefined
exits = []
quits = 0
setAscii(false)
})
const hour = 3_600_000
function profiles(map: Record<string, { server: string; lastUsedAt?: string }>, defaultProfile?: string): void {
writeConfig({ profiles: map, ...(defaultProfile ? { defaultProfile } : {}) } as Config)
}
function two(): void {
profiles({
alpha: { server: 'http://alpha.test', lastUsedAt: new Date(Date.now() - 5 * hour).toISOString() },
beta: { server: 'http://beta.test', lastUsedAt: new Date(Date.now() - hour).toISOString() },
}, 'alpha')
}
async function start(argv: string[], options: { width?: number; height?: number; minMs?: number; fetch?: typeof fetch; log?: FakeLog } = {}): Promise<Harness> {
const log = options.log ?? { requests: [] }
const store = boot({ args: parseArgs(argv, {}), quit: () => { quits++ }, build: 'test', secrets: sessionSecrets, fetch: options.fetch ?? fakeFetch(log), splashMinMs: options.minMs, hint: (t) => exits.push(t) })
const setup = await testRender(<App store={store} />, { width: options.width ?? 120, height: options.height ?? 40 })
const harness: Harness = { setup, store, log, frame: () => setup.captureCharFrame() }
store.captureScreen = () => setup.captureCharFrame()
return harness
}
test('the boot plan: a named profile or the only one connects; several ask (last used first); none adds', () => {
const at = (n: number) => new Date(Date.now() - n * hour).toISOString()
const several: Config = { profiles: { alpha: { server: 'http://a.test', lastUsedAt: at(5) }, beta: { server: 'http://b.test', lastUsedAt: at(1) }, gamma: { server: 'http://c.test' } }, defaultProfile: 'alpha' } as Config
expect(planBoot({}, {}, several)).toEqual({ kind: 'pick', select: 'beta' })
expect(planBoot({ profile: 'gamma' }, {}, several)).toEqual({ kind: 'connect', target: { name: 'gamma', server: 'http://c.test' } })
expect(planBoot({}, { QQQ_PROFILE: 'alpha' }, several)).toEqual({ kind: 'connect', target: { name: 'alpha', server: 'http://a.test' } })
expect(planBoot({ server: 'http://b.test/x' }, {}, several)).toEqual({ kind: 'connect', target: { name: 'beta', server: 'http://b.test' } })
expect(planBoot({}, { QQQ_SERVER: 'http://new.test' }, several).kind).toBe('connect')
const one: Config = { profiles: { solo: { server: 'http://solo.test' } } } as Config
expect(planBoot({}, {}, one)).toEqual({ kind: 'connect', target: { name: 'solo', server: 'http://solo.test' } })
expect(planBoot({}, {}, { profiles: {} } as Config)).toEqual({ kind: 'add' })
// a name that does not exist: the picker (or the add form) with why
const typo = planBoot({ profile: 'nope' }, {}, several)
expect(typo.kind).toBe('pick')
expect(typo.kind === 'pick' && typo.note).toMatch(/No profile "nope"/)
expect(planBoot({ profile: 'nope' }, {}, { profiles: {} } as Config).kind).toBe('add')
// never used: the default, else the first
expect(lastUsedProfile({ profiles: { a: { server: 'x' }, b: { server: 'y' } }, defaultProfile: 'b' } as Config)).toBe('b')
expect(lastUsedProfile({ profiles: { a: { server: 'x' }, b: { server: 'y' } } } as Config)).toBe('a')
})
test('the splash switch: --no-splash, --splash, QQQ_UI_NO_SPLASH and the ui.splash setting', () => {
expect(parseArgs(['--no-splash']).splash).toBe(false)
expect(parseArgs(['--splash']).splash).toBe(true)
expect(parseArgs([]).splash).toBeUndefined()
expect(splashEnabled({ splash: false })).toBe(false)
expect(splashEnabled({}, { ...process.env, QQQ_UI_NO_SPLASH: '1' })).toBe(false)
expect(readSplash()).toBe(true)
writeSplash(false)
expect(readConfig().ui).toMatchObject({ splash: 'off' })
expect(splashEnabled({})).toBe(false)
expect(splashEnabled({ splash: true })).toBe(true)
writeSplash(true)
expect(splashEnabled({})).toBe(true)
})
test('the minimum display time: never under 600 ms, nothing more once it has been up that long', async () => {
expect(SPLASH_MIN_MS).toBe(600)
expect(splashWait(1000, 1100)).toBe(500)
expect(splashWait(1000, 1700)).toBe(0)
profiles({ fake: { server: 'http://fake.test' } })
const began = Date.now()
h = await start(['--profile', 'fake'], { minMs: 600 })
await waitFor(h, () => h!.store.screen?.kind === 'splash')
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
expect(Date.now() - began).toBeGreaterThanOrEqual(600)
})
test('one profile: the splash shows Query, the wordmark, the version and the boot steps, then Home', async () => {
profiles({ fake: { server: 'http://fake.test' } })
h = await start([], { minMs: 400 })
const f = await waitFor(h, (x) => x.includes('Terminal client for QQQ'))
expect(h.store.screen?.kind).toBe('splash')
expect(f).toContain(versionLine())
expect(f).toMatch(/qqq \d+\.\d+\.\d+ · UI API client \d+\.\d+\.\d+/)
expect(f).toContain('█')
// no title bar, status line or key bar under the splash
expect(f).not.toMatch(/10\s*Quit/)
const steps = h.store.screen as SplashScreen
await waitFor(h, () => /loaded metadata \(\d+ tables\)/.test(steps.status))
await waitFor(h, (x) => h!.store.screen?.kind === 'home' && x.includes('Apps'), 4000)
expect(h.store.overlays.length).toBe(0)
})
test('several profiles: the picker over the splash, the last used preselected; Enter connects to it', async () => {
two()
h = await start([], { width: 120, height: 40 })
const f = await waitFor(h, (x) => x.includes('Connect to a server'))
const picker = h.store.overlays.at(-1) as ServersOverlay
expect(picker).toBeInstanceOf(ServersOverlay)
expect(picker.selected()?.name).toBe('beta')
expect(f).toContain('Enter connects')
expect(f).toContain('Esc quits')
// the splash header stays visible above the picker
expect(f).toContain('Terminal client for QQQ')
// probes fill in on their own (the picker does not wait for them)
await waitFor(h, (x) => x.includes('UI API'))
await press(h, () => h!.setup.mockInput.pressEnter())
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
expect(h.store.target?.name).toBe('beta')
})
test('several profiles: Esc on the picker quits with the --profile hint', async () => {
two()
h = await start([])
await waitFor(h, (x) => x.includes('Connect to a server'))
await press(h, () => h!.setup.mockInput.pressEscape())
expect(quits).toBe(1)
expect(exits).toEqual([PICK_HINT])
expect(PICK_HINT).toContain('--profile')
})
test('--profile skips the picker even with several profiles', async () => {
two()
h = await start(['--profile', 'alpha'])
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
expect(h.store.target?.name).toBe('alpha')
})
test('no profiles: the add form opens straight away; saving connects to the new profile', async () => {
writeConfig({ profiles: {} } as Config)
h = await start([])
await waitFor(h, (x) => x.includes('Add a server profile'))
const form = h.store.overlays.at(-1) as ProfileFormOverlay
expect(form).toBeInstanceOf(ProfileFormOverlay)
form.editors.name.set('local')
form.editors.server.set('http://fake.test')
await press(h, () => form.save(), 50)
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
expect(h.store.target).toEqual({ name: 'local', server: 'http://fake.test' })
})
test('no profiles, add form closed: the splash says n adds one and Esc quits', async () => {
writeConfig({ profiles: {} } as Config)
h = await start([], { width: 80, height: 24 })
await waitFor(h, (x) => x.includes('Add a server profile'))
const f = await press(h, () => h!.setup.mockInput.pressEscape())
expect(f).toContain('n adds a server profile')
await press(h, () => h!.setup.mockInput.pressKey('n'))
expect(h.store.overlays.at(-1)).toBeInstanceOf(ProfileFormOverlay)
await press(h, () => h!.setup.mockInput.pressEscape())
await press(h, () => h!.setup.mockInput.pressEscape())
expect(quits).toBe(1)
})
test('--no-splash: the sign-in screen as before, then Home; with several profiles the picker opens over it', async () => {
profiles({ fake: { server: 'http://fake.test' } })
h = await start(['--no-splash'])
expect(h.store.routes.some((r) => r.kind === 'splash')).toBe(false)
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
h.store.dispose()
h.setup.renderer.destroy()
two()
h = await start(['--no-splash'])
await waitFor(h, (x) => x.includes('Connect to a server'))
expect(h.store.screen?.kind).toBe('signin')
expect((h.store.overlays.at(-1) as ServersOverlay).selected()?.name).toBe('beta')
})
test('a failure stays on the splash with retry and choose-another; c opens the picker, Esc goes back to it', async () => {
profiles({ fake: { server: 'http://fake.test' }, other: { server: 'http://other.test' } })
let down = true
const ok = fakeFetch()
const flaky = (async (input: RequestInfo | URL, init?: RequestInit) => {
if (down) throw new Error('connection refused')
return ok(input, init)
}) as typeof fetch
h = await start(['--profile', 'fake'], { width: 80, height: 24, fetch: flaky })
let f = await waitFor(h, (x) => x.includes('Enter retries'))
expect(f).toContain('connection refused')
expect(f).toContain('c chooses another profile')
await press(h, () => h!.setup.mockInput.pressKey('c'))
f = await waitFor(h, (x) => x.includes('Connect to a server'))
expect(f).toContain('Esc goes back')
await press(h, () => h!.setup.mockInput.pressEscape())
expect(quits).toBe(0)
f = await waitFor(h, (x) => x.includes('Enter retries'))
down = false
await press(h, () => h!.setup.mockInput.pressEnter())
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
})
test('any key stops the animation, not the boot', async () => {
profiles({ fake: { server: 'http://fake.test' } })
h = await start([], { minMs: 300 })
const splash = h.store.screen as SplashScreen
splash.animate = true
await press(h, () => h!.setup.mockInput.pressKey('x'))
expect(splash.animate).toBe(false)
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
})
function input(ascii: boolean, anchor: 'center' | 'top' = 'center'): SplashLayoutInput {
return { ascii, pose: { blink: false, wag: false }, status: { text: 'connecting to localhost:8078…', tone: 'status' }, extra: [], keys: '', anchor }
}
test('the layout fits 80×24 (small Query beside the wordmark) and 120×40 (large Query); every line clipped', () => {
for (const [w, hgt] of [[80, 24], [120, 40], [40, 12], [30, 8], [20, 5]] as const) {
for (const ascii of [false, true]) {
setAscii(ascii)
const lines = splashLayout(w, hgt, input(ascii))
expect(lines.length).toBeLessThanOrEqual(hgt)
for (const l of lines) expect(cellWidth(l.map((s) => s.text).join(''))).toBeLessThanOrEqual(w)
}
}
setAscii(false)
const small = splashText(splashLayout(80, 24, input(false)))
expect(small).toContain('Terminal client for QQQ')
expect(small).toContain('connecting to localhost:8078…')
const large = splashLayout(120, 40, input(false))
expect(large.filter((l) => l.some((s) => s.tone === 'art')).length).toBe(querySize('large').height)
const header = splashLayout(80, 24, input(false, 'top'))
expect(splashText(header)).not.toContain('connecting')
expect(header.length).toBeLessThanOrEqual(querySize('small').height + 2)
})
test('ASCII: no braille or block characters, the wordmark in #', () => {
setAscii(true)
for (const [w, hgt] of [[80, 24], [120, 40]] as const) {
const text = splashText(splashLayout(w, hgt, input(true)))
expect(text).not.toMatch(/[⠀-⣿█]/)
expect(text).toContain('####')
expect(text).toContain('Terminal client for QQQ')
expect(text).toMatch(/^[\x20-\x7e\n]*$/)
expect(text).toContain('qqq')
}
})
test(':splash off and on save the setting (Settings > Splash on start)', async () => {
profiles({ fake: { server: 'http://fake.test' } })
h = await start([])
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
await command(h, 'splash off')
expect(readSplash()).toBe(false)
expect(h.store.flashText?.text).toContain('splash off')
await command(h, 'splash on')
expect(readSplash()).toBe(true)
})
-1
View File
@@ -4,7 +4,6 @@
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"types": ["node"],
"declaration": true,
"sourceMap": true,
"esModuleInterop": true,