From 7d99a38509ac7851780d834b7438910e91a3ffe8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 2 Jun 2026 21:31:41 +0200 Subject: [PATCH] feat: add read-only Session Dashboard (agent-tty dashboard) A master-detail Ink TUI that lists Sessions and shows a continuously-updated, read-only Live View of the selected one, reconstructed by Event Log Follow (file-tail of events.jsonl -> libghostty-vt replayTo/snapshot) per ADR 0006. It reads the Event Log as the source of truth, never queries the live host, and never resizes the Session. - Deep modules (unit-tested): EventLogTailSource/SessionEventSource seam, LiveViewProjection (pure clip/pan/letterbox/overview), LiveViewFollower (coalescing, pin-on-exit, collected, bounded replay), and RendererReadiness plus a `dashboard` doctor capability. - Tab-toggled focus (list select vs Live View pan), `a` scope toggle (active default; all excludes destroyed), `z` block-glyph overview, `--all`/`--session`. - Requires the optional libghostty-vt renderer (no browser fallback); interactive-only (no --json), fails fast on a non-interactive terminal. - Moves react+ink to dependencies, adds @types/react (dev) and jsx to tsconfig; removes the throwaway proto/. Change-Id: I2b15108a1e7968f9eeeade63d672974111681a59 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- CHANGELOG.md | 1 + aube-lock.yaml | 318 ++++++++++ package.json | 3 + src/cli/commands/dashboard.ts | 62 ++ src/cli/commands/doctor.ts | 17 + src/cli/main.ts | 30 + src/dashboard/app.tsx | 557 ++++++++++++++++++ src/dashboard/eventSource.ts | 124 ++++ src/dashboard/liveViewFollower.ts | 143 +++++ src/dashboard/liveViewProjection.ts | 230 ++++++++ src/dashboard/sessionScope.ts | 62 ++ src/renderer/capabilities.ts | 40 ++ src/renderer/readiness.ts | 97 +++ test/unit/commands/dashboard.test.ts | 90 +++ test/unit/commands/doctor.test.ts | 5 +- test/unit/commands/version.test.ts | 3 +- test/unit/dashboard/eventSource.test.ts | 197 +++++++ test/unit/dashboard/liveViewFollower.test.ts | 290 +++++++++ .../unit/dashboard/liveViewProjection.test.ts | 198 +++++++ test/unit/dashboard/sessionScope.test.ts | 116 ++++ test/unit/protocol/messages.test.ts | 1 + test/unit/renderer/capabilities.test.ts | 66 ++- test/unit/renderer/readiness.test.ts | 109 ++++ tsconfig.build.json | 10 +- tsconfig.json | 4 + 25 files changed, 2766 insertions(+), 7 deletions(-) create mode 100644 src/cli/commands/dashboard.ts create mode 100644 src/dashboard/app.tsx create mode 100644 src/dashboard/eventSource.ts create mode 100644 src/dashboard/liveViewFollower.ts create mode 100644 src/dashboard/liveViewProjection.ts create mode 100644 src/dashboard/sessionScope.ts create mode 100644 src/renderer/readiness.ts create mode 100644 test/unit/commands/dashboard.test.ts create mode 100644 test/unit/dashboard/eventSource.test.ts create mode 100644 test/unit/dashboard/liveViewFollower.test.ts create mode 100644 test/unit/dashboard/liveViewProjection.test.ts create mode 100644 test/unit/dashboard/sessionScope.test.ts create mode 100644 test/unit/renderer/readiness.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 792c0d22..cb4d767e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Added +- `agent-tty dashboard`: a read-only, interactive **Session Dashboard** that lists your sessions and shows a live **Live View** of the selected one — watch what your agents are doing in their shells, e.g. in a tmux split. The Live View is produced by **Event Log Follow** (file-tail of `events.jsonl` → `libghostty-vt` `replayTo`/`snapshot`), so it reads the append-only **Event Log** as the source of truth and never queries the live host (ADR 0006). Master-detail UI with Tab-toggled focus (list select vs. Live View pan), 1:1 clip-top-left/letterbox plus a lossy block-glyph overview (`z`), an active/all scope toggle (`a`), and pin-on-exit (the watched session stays and freezes on its final screen with an exit badge). Requires the optional `libghostty-vt` renderer with no browser fallback, so `doctor` now reports a `dashboard` readiness capability. Interactive-only (no `--json`; fails fast on a non-interactive terminal); machine-readable session listing remains via `list --json`. See `docs/prd/session-dashboard/PRD.md` and ADR 0006 ([#109](https://github.com/coder/agent-tty/issues/109)). - `inspect --json` now reports `host.cliVersion`, `host.rpcSocketPath`, `rendererRuntime.profile`, `rendererRuntime.booted`, `rendererRuntime.bootInFlight` (live mode), and `eventLogBytes` (both live and offline replay). All fields are optional schema additions; existing consumers are unaffected ([#104](https://github.com/coder/agent-tty/pull/104)). - Canonical proof-bundle lock-down: a new `CanonicalBundleManifestSchema` requires `sha256` and `bytes` on every artifact, `npm run validate-bundle:canonical` (also wired through `mise run validate-bundles`) runs eight drift-detection rules plus catalog parity across the four canonical bundles, and the `linux-static` CI job now fails on bundle drift ([#104](https://github.com/coder/agent-tty/pull/104)). - Hero Demo bundle (`dogfood/agent-uses-agent-tty/`) replaced with an external Outer Camera flow: VHS records real Codex (`gpt-5.5`) and Claude (`claude-opus-4-7`) TUIs while `agent-tty` produces the inner Neovim proof artifacts. A new `mise run demo:agent-uses-agent-tty` task regenerates and promotes the demo with pinned `vhs`/`ttyd`/`ffmpeg` ([#105](https://github.com/coder/agent-tty/pull/105)). diff --git a/aube-lock.yaml b/aube-lock.yaml index 534e50e7..0510e8f9 100644 --- a/aube-lock.yaml +++ b/aube-lock.yaml @@ -6,6 +6,7 @@ settings: time: '@ai-hero/sandcastle@0.5.10': 2026-05-08T08:11:01.507Z + '@alcalzone/ansi-tokenize@0.3.0': 2026-02-20T13:12:49.245Z '@clack/core@1.3.0': 2026-04-29T18:10:52.964Z '@clack/prompts@1.3.0': 2026-04-29T18:10:53.358Z '@coder/libghostty-vt-node@0.1.0-beta.0': 2026-04-24T16:17:20.231Z @@ -102,6 +103,7 @@ time: '@types/estree@1.0.9': 2026-05-06T21:01:00.975Z '@types/node@25.5.0': 2026-03-12T15:48:00.014Z '@types/parse-path@7.1.0': 2025-05-05T20:03:45.729Z + '@types/react@19.2.16': 2026-06-01T18:00:27.791Z '@vitest/expect@4.1.2': 2026-03-26T14:36:39.253Z '@vitest/mocker@4.1.2': 2026-03-26T14:36:29.326Z '@vitest/pretty-format@4.1.2': 2026-03-26T14:36:18.500Z @@ -110,10 +112,13 @@ time: '@vitest/spy@4.1.2': 2026-03-26T14:36:22.490Z '@vitest/utils@4.1.2': 2026-03-26T14:36:25.794Z agent-base@8.0.0: 2026-03-11T16:58:24.478Z + ansi-escapes@7.3.0: 2026-02-04T16:21:10.230Z ansi-regex@6.2.2: 2025-09-08T14:48:14.264Z + ansi-styles@6.2.3: 2025-09-08T14:52:15.705Z assertion-error@2.0.1: 2023-10-18T18:04:13.507Z ast-types@0.13.4: 2020-08-23T22:36:07.885Z async-retry@1.3.3: 2021-08-17T02:10:33.288Z + auto-bind@5.0.1: 2021-10-18T05:54:17.480Z balanced-match@4.0.4: 2026-02-22T11:38:25.951Z basic-ftp@5.3.1: 2026-04-28T03:37:53.895Z before-after-hook@4.0.0: 2025-05-12T22:37:33.860Z @@ -127,13 +132,19 @@ time: ci-info@4.4.0: 2026-01-29T16:23:16.342Z citty@0.1.6: 2024-02-14T13:09:33.620Z citty@0.2.2: 2026-04-01T18:24:39.653Z + cli-boxes@4.0.1: 2024-08-04T17:57:54.988Z + cli-cursor@4.0.0: 2021-08-23T19:35:19.940Z cli-cursor@5.0.0: 2024-07-26T14:02:26.233Z cli-spinners@3.4.0: 2026-01-13T15:09:46.171Z + cli-truncate@6.0.0: 2026-04-04T13:38:05.476Z cli-width@4.1.0: 2023-08-05T16:14:01.241Z + code-excerpt@4.0.0: 2022-02-06T10:11:50.660Z commander@14.0.3: 2026-01-31T01:47:17.592Z confbox@0.2.4: 2026-02-06T12:11:42.581Z consola@3.4.2: 2025-03-18T10:17:43.596Z convert-source-map@2.0.0: 2022-10-17T22:06:48.628Z + convert-to-spaces@2.0.1: 2022-02-06T10:05:09.499Z + csstype@3.2.3: 2025-11-17T11:42:27.045Z data-uri-to-buffer@7.0.0: 2026-03-11T16:58:27.424Z debug@4.4.3: 2025-09-13T17:25:19.732Z default-browser-id@5.0.1: 2025-11-14T07:11:37.981Z @@ -145,8 +156,11 @@ time: detect-libc@2.1.2: 2025-10-05T12:46:33.077Z dotenv@17.4.2: 2026-04-12T16:41:11.574Z effect@3.21.2: 2026-04-22T23:24:05.782Z + environment@1.1.0: 2024-05-14T07:02:20.386Z es-module-lexer@2.1.0: 2026-04-25T22:50:31.041Z + es-toolkit@1.47.0: 2026-05-25T08:00:38.148Z esbuild@0.27.7: 2026-04-02T16:43:44.831Z + escape-string-regexp@2.0.0: 2019-04-17T07:49:09.559Z escodegen@2.1.0: 2023-06-29T20:18:37.341Z esprima@4.0.1: 2018-07-13T08:39:14.711Z estraverse@5.3.0: 2021-10-25T12:18:17.722Z @@ -175,11 +189,15 @@ time: http-proxy-agent@8.0.0: 2026-03-11T16:58:43.756Z https-proxy-agent@8.0.0: 2026-03-11T16:58:46.683Z iconv-lite@0.7.2: 2026-01-08T16:49:11.167Z + indent-string@5.0.0: 2021-04-17T17:10:20.469Z ini@4.1.3: 2024-05-22T15:59:34.831Z + ink@7.0.5: 2026-05-29T21:15:43.919Z ip-address@10.2.0: 2026-05-01T06:34:05.804Z is-docker@3.0.0: 2021-08-31T23:02:35.148Z is-extglob@2.1.1: 2016-12-11T04:04:24.390Z + is-fullwidth-code-point@5.1.0: 2025-08-31T07:37:04.805Z is-glob@4.0.3: 2021-09-29T16:52:47.977Z + is-in-ci@2.0.0: 2025-08-17T11:37:36.590Z is-in-ssh@1.0.0: 2025-11-12T15:31:49.173Z is-inside-container@1.0.0: 2023-02-15T06:50:49.428Z is-interactive@2.0.0: 2021-05-03T20:19:09.981Z @@ -212,6 +230,7 @@ time: mime-db@1.54.0: 2025-03-18T15:06:44.354Z mime-types@3.0.2: 2025-11-20T11:12:29.693Z mime@3.0.0: 2021-11-03T00:50:48.131Z + mimic-fn@2.1.0: 2019-03-31T17:53:34.125Z mimic-function@5.0.1: 2024-03-14T08:37:42.297Z minimatch@10.2.5: 2026-03-30T18:08:07.247Z minipass@7.1.3: 2026-02-19T00:34:33.886Z @@ -232,6 +251,7 @@ time: nypm@0.6.6: 2026-04-24T17:28:57.939Z obug@2.1.1: 2025-11-22T09:31:55.146Z ohash@2.0.11: 2025-03-04T17:39:01.858Z + onetime@5.1.2: 2020-08-09T20:29:04.963Z onetime@7.0.0: 2023-11-05T20:42:03.405Z open@11.0.0: 2025-11-15T08:22:54.225Z ora@9.3.0: 2026-02-05T04:33:37.616Z @@ -244,6 +264,7 @@ time: package-json-from-dist@1.0.1: 2024-09-26T18:59:08.941Z parse-path@7.1.0: 2025-04-15T07:02:16.169Z parse-url@9.2.0: 2024-04-03T05:25:19.419Z + patch-console@2.0.0: 2022-02-04T09:24:24.525Z path-scurry@2.0.2: 2026-02-19T17:20:00.211Z pathe@2.0.3: 2025-02-11T19:47:35.862Z perfect-debounce@2.1.0: 2026-01-21T23:47:33.457Z @@ -261,29 +282,38 @@ time: pure-rand@6.1.0: 2024-03-20T21:29:49.243Z quickjs-wasi@0.0.1: 2026-03-06T21:20:04.435Z rc9@2.1.2: 2024-04-09T17:15:22.489Z + react-reconciler@0.33.0: 2025-10-01T21:39:00.081Z + react@19.2.7: 2026-06-01T18:00:48.323Z readdirp@5.0.0: 2025-11-25T23:11:27.476Z release-it@20.0.1: 2026-04-24T09:06:54.177Z resolve-pkg-maps@1.0.0: 2022-12-14T15:37:46.218Z + restore-cursor@4.0.0: 2021-08-23T19:27:21.792Z restore-cursor@5.1.0: 2024-07-26T13:56:42.169Z retry@0.13.1: 2021-06-21T07:45:32.286Z rimraf@6.1.3: 2026-02-16T00:59:39.538Z rolldown@1.0.0-rc.18: 2026-04-29T13:44:09.738Z run-applescript@7.1.0: 2025-09-09T13:42:36.477Z safer-buffer@2.1.2: 2018-04-08T10:42:42.130Z + scheduler@0.27.0: 2025-10-01T21:39:15.208Z semver@7.7.4: 2026-02-05T17:23:11.131Z siginfo@2.0.0: 2020-06-16T20:30:23.515Z + signal-exit@3.0.7: 2022-02-03T21:05:34.544Z signal-exit@4.1.0: 2023-07-29T05:44:56.721Z sisteransi@1.0.5: 2020-03-18T12:53:26.602Z + slice-ansi@9.0.0: 2026-04-04T13:19:33.198Z smart-buffer@4.2.0: 2021-08-07T06:18:54.397Z socks-proxy-agent@9.0.0: 2026-03-11T16:58:53.061Z socks@2.8.8: 2026-04-28T06:19:04.302Z source-map-js@1.2.1: 2024-09-08T16:22:55.645Z source-map@0.6.1: 2017-09-29T14:42:30.948Z + stack-utils@2.0.6: 2022-11-08T15:39:54.449Z stackback@0.0.2: 2012-10-20T00:56:54.591Z std-env@4.1.0: 2026-04-15T08:02:30.338Z stdin-discarder@0.3.2: 2026-04-14T07:35:35.513Z string-width@8.2.1: 2026-04-27T05:27:21.188Z strip-ansi@7.2.0: 2026-02-26T13:51:11.099Z + tagged-tag@1.0.0: 2025-05-12T05:23:55.484Z + terminal-size@4.0.1: 2026-02-02T05:23:26.211Z tinybench@2.9.0: 2024-08-02T15:09:44.961Z tinyexec@1.1.2: 2026-04-29T07:40:28.138Z tinyglobby@0.2.15: 2025-09-06T18:52:04.151Z @@ -294,6 +324,7 @@ time: tslib@2.8.1: 2024-10-31T22:42:48.624Z tsx@4.21.0: 2025-11-30T15:56:09.488Z type-fest@2.19.0: 2022-08-22T17:20:40.104Z + type-fest@5.7.0: 2026-05-31T19:28:39.290Z typescript@5.9.3: 2025-09-30T21:19:38.784Z ulid@3.0.2: 2025-11-30T20:02:50.707Z undici-types@7.18.2: 2026-01-06T15:57:40.133Z @@ -304,13 +335,16 @@ time: vite@8.0.11: 2026-05-07T06:05:59.982Z vitest@4.1.2: 2026-03-26T14:36:51.447Z why-is-node-running@2.3.0: 2024-07-08T12:57:23.951Z + widest-line@6.0.0: 2026-01-24T03:25:03.834Z wildcard-match@5.1.4: 2024-12-17T17:21:21.445Z windows-release@7.1.1: 2026-02-24T15:25:50.430Z + wrap-ansi@10.0.0: 2026-02-20T10:03:54.703Z ws@8.20.0: 2026-03-21T17:31:08.578Z wsl-utils@0.3.1: 2026-01-02T13:12:04.090Z yaml@2.8.4: 2026-05-02T09:09:40.093Z yargs-parser@22.0.0: 2025-05-26T20:12:00.864Z yoctocolors@2.1.2: 2025-08-19T15:19:23.680Z + yoga-layout@3.2.1: 2024-12-13T01:45:49.754Z zod@4.3.6: 2026-01-22T19:14:35.382Z importers: @@ -335,12 +369,18 @@ importers: ghostty-web: specifier: 0.4.0 version: 0.4.0 + ink: + specifier: ^7.0.5 + version: 7.0.5(@types/react@19.2.16)(react@19.2.7) node-pty: specifier: 1.1.0 version: 1.1.0 playwright: specifier: 1.60.0 version: 1.60.0 + react: + specifier: ^19.2.7 + version: 19.2.7 ulid: specifier: 3.0.2 version: 3.0.2 @@ -366,6 +406,9 @@ importers: '@types/node': specifier: 25.5.0 version: 25.5.0 + '@types/react': + specifier: ^19.2.16 + version: 19.2.16 oxfmt: specifier: 0.47.0 version: 0.47.0 @@ -409,6 +452,10 @@ packages: '@vercel/sandbox': optional: true + '@alcalzone/ansi-tokenize@0.3.0': + resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} + engines: {node: '>=18'} + '@clack/core@1.3.0': resolution: {integrity: sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA==} engines: {node: '>= 20.12.0'} @@ -1092,6 +1139,9 @@ packages: '@types/parse-path@7.1.0': resolution: {integrity: sha512-EULJ8LApcVEPbrfND0cRQqutIOdiIgJ1Mgrhpy755r14xMohPTEpkV/k28SJvuOs9bHRFW8x+KeDAEPiGQPB9Q==} + '@types/react@19.2.16': + resolution: {integrity: sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==} + '@vitest/expect@4.1.2': resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} @@ -1125,10 +1175,18 @@ packages: resolution: {integrity: sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg==} engines: {node: '>= 14'} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1140,6 +1198,10 @@ packages: async-retry@1.3.3: resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -1192,6 +1254,14 @@ packages: citty@0.2.2: resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + cli-boxes@4.0.1: + resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} + engines: {node: '>=18.20 <19 || >=20.10'} + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -1200,10 +1270,18 @@ packages: resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} engines: {node: '>=18.20'} + cli-truncate@6.0.0: + resolution: {integrity: sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==} + engines: {node: '>=22'} + cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -1218,6 +1296,13 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-uri-to-buffer@7.0.0: resolution: {integrity: sha512-CuRUx0TXGSbbWdEci3VK/XOZGP3n0P4pIKpsqpVtBqaIIuj3GKK8H45oAqA4Rg8FHipc+CzRdUzmD4YQXxv66Q==} engines: {node: '>= 14'} @@ -1264,14 +1349,25 @@ packages: effect@3.21.2: resolution: {integrity: sha512-rXd2FGDM8KdjSIrc+mqEELo7ScW7xTVxEf1iInmPSpIde9/nyGuFM710cjTo7/EreGXiUX2MOonPpprbz2XHCg==} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-toolkit@1.47.0: + resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==} + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} hasBin: true + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + escodegen@2.1.0: resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} engines: {node: '>=6.0'} @@ -1384,10 +1480,27 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + ini@4.1.3: resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ink@7.0.5: + resolution: {integrity: sha512-zWNjGHQPxSeiSAmDUOq+QPQ6CfmMhmNi85vrJIuy4prafKKUSoZlXEy4wbM7LuLuF1pDURk7qvF4fxrQlLxv3w==} + engines: {node: '>=22'} + peerDependencies: + '@types/react': '>=19.2.0' + react: '>=19.2.0' + react-devtools-core: '>=6.1.2' + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -1401,10 +1514,19 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-in-ci@2.0.0: + resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} + engines: {node: '>=20'} + hasBin: true + is-in-ssh@1.0.0: resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} engines: {node: '>=20'} @@ -1561,6 +1683,10 @@ packages: engines: {node: '>=10.0.0'} hasBin: true + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -1635,6 +1761,10 @@ packages: ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -1690,6 +1820,10 @@ packages: resolution: {integrity: sha512-bCgsFI+GeGWPAvAiUv63ZorMeif3/U0zaXABGJbOWt5OH2KCaPHF6S+0ok4aqM9RuIPGyZdx9tR9l13PsW4AYQ==} engines: {node: '>=14.13.0'} + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -1751,6 +1885,16 @@ packages: rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -1763,6 +1907,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -1788,6 +1936,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -1796,6 +1947,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -1803,6 +1957,10 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slice-ansi@9.0.0: + resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==} + engines: {node: '>=22'} + smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -1823,6 +1981,10 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1841,6 +2003,14 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + terminal-size@4.0.1: + resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1879,6 +2049,10 @@ packages: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} + type-fest@5.7.0: + resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} + engines: {node: '>=20'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1990,6 +2164,10 @@ packages: engines: {node: '>=8'} hasBin: true + widest-line@6.0.0: + resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} + engines: {node: '>=20'} + wildcard-match@5.1.4: resolution: {integrity: sha512-wldeCaczs8XXq7hj+5d/F38JE2r7EXgb6WQDM84RVwxy81T/sxB5e9+uZLK9Q9oNz1mlvjut+QtvgaOQFPVq/g==} @@ -1997,6 +2175,10 @@ packages: resolution: {integrity: sha512-0GBwC9WmR8Bm3WYiz3FC391054BsFHZ2gzBVdYj9uj5eIVYzbn/YPYCYW9SWdh9vwnLuzpn1UGwJKiMG4F236w==} engines: {node: '>=20'} + wrap-ansi@10.0.0: + resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} + engines: {node: '>=20'} + ws@8.20.0: resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} engines: {node: '>=10.0.0'} @@ -2026,6 +2208,9 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -2041,6 +2226,11 @@ snapshots: '@effect/printer-ansi': 0.48.0(@effect/typeclass@0.39.0(effect@3.21.2))(effect@3.21.2) effect: 3.21.2 + '@alcalzone/ansi-tokenize@0.3.0': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + '@clack/core@1.3.0': dependencies: fast-wrap-ansi: 0.2.0 @@ -2443,6 +2633,10 @@ snapshots: dependencies: parse-path: 7.1.0 + '@types/react@19.2.16': + dependencies: + csstype: 3.2.3 + '@vitest/expect@4.1.2': dependencies: '@standard-schema/spec': 1.1.0 @@ -2485,8 +2679,14 @@ snapshots: agent-base@8.0.0: {} + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-regex@6.2.2: {} + ansi-styles@6.2.3: {} + assertion-error@2.0.1: {} ast-types@0.13.4: @@ -2497,6 +2697,8 @@ snapshots: dependencies: retry: 0.13.1 + auto-bind@5.0.1: {} + balanced-match@4.0.4: {} basic-ftp@5.3.1: {} @@ -2544,14 +2746,29 @@ snapshots: citty@0.2.2: {} + cli-boxes@4.0.1: {} + + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 cli-spinners@3.4.0: {} + cli-truncate@6.0.0: + dependencies: + slice-ansi: 9.0.0 + string-width: 8.2.1 + cli-width@4.1.0: {} + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + commander@14.0.3: {} confbox@0.2.4: {} @@ -2560,6 +2777,10 @@ snapshots: convert-source-map@2.0.0: {} + convert-to-spaces@2.0.1: {} + + csstype@3.2.3: {} + data-uri-to-buffer@7.0.0: {} debug@4.4.3: @@ -2595,8 +2816,12 @@ snapshots: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 + environment@1.1.0: {} + es-module-lexer@2.1.0: {} + es-toolkit@1.47.0: {} + esbuild@0.27.7: optionalDependencies: '@esbuild/darwin-arm64': 0.27.7 @@ -2605,6 +2830,8 @@ snapshots: '@esbuild/win32-arm64': 0.27.7 '@esbuild/win32-x64': 0.27.7 + escape-string-regexp@2.0.0: {} + escodegen@2.1.0: dependencies: esprima: 4.0.1 @@ -2707,18 +2934,56 @@ snapshots: dependencies: safer-buffer: 2.1.2 + indent-string@5.0.0: {} + ini@4.1.3: {} + ink@7.0.5(@types/react@19.2.16)(react@19.2.7): + dependencies: + '@alcalzone/ansi-tokenize': 0.3.0 + '@types/react': 19.2.16 + ansi-escapes: 7.3.0 + ansi-styles: 6.2.3 + auto-bind: 5.0.1 + chalk: 5.6.2 + cli-boxes: 4.0.1 + cli-cursor: 4.0.0 + cli-truncate: 6.0.0 + code-excerpt: 4.0.0 + es-toolkit: 1.47.0 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + react: 19.2.7 + react-reconciler: 0.33.0(react@19.2.7) + scheduler: 0.27.0 + signal-exit: 3.0.7 + slice-ansi: 9.0.0 + stack-utils: 2.0.6 + string-width: 8.2.1 + terminal-size: 4.0.1 + type-fest: 5.7.0 + widest-line: 6.0.0 + wrap-ansi: 10.0.0 + ws: 8.20.0 + yoga-layout: 3.2.1 + ip-address@10.2.0: {} is-docker@3.0.0: {} is-extglob@2.1.1: {} + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.5.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-in-ci@2.0.0: {} + is-in-ssh@1.0.0: {} is-inside-container@1.0.0: @@ -2812,6 +3077,8 @@ snapshots: mime@3.0.0: {} + mimic-fn@2.1.0: {} + mimic-function@5.0.1: {} minimatch@10.2.5: @@ -2873,6 +3140,10 @@ snapshots: ohash@2.0.11: {} + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -2962,6 +3233,8 @@ snapshots: '@types/parse-path': 7.1.0 parse-path: 7.1.0 + patch-console@2.0.0: {} + path-scurry@2.0.2: dependencies: lru-cache: 11.3.6 @@ -3023,6 +3296,13 @@ snapshots: defu: 6.1.7 destr: 2.0.5 + react-reconciler@0.33.0(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react@19.2.7: {} + readdirp@5.0.0: {} release-it@20.0.1(@octokit/core@7.0.6)(@types/node@25.5.0)(picomatch@4.0.4)(quickjs-wasi@0.0.1): @@ -3053,6 +3333,11 @@ snapshots: resolve-pkg-maps@1.0.0: {} + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -3082,14 +3367,23 @@ snapshots: safer-buffer@2.1.2: {} + scheduler@0.27.0: {} + semver@7.7.4: {} siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} sisteransi@1.0.5: {} + slice-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + smart-buffer@4.2.0: {} socks-proxy-agent@9.0.0: @@ -3107,6 +3401,10 @@ snapshots: source-map@0.6.1: {} + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackback@0.0.2: {} std-env@4.1.0: {} @@ -3122,6 +3420,10 @@ snapshots: dependencies: ansi-regex: 6.2.2 + tagged-tag@1.0.0: {} + + terminal-size@4.0.1: {} + tinybench@2.9.0: {} tinyexec@1.1.2: {} @@ -3153,6 +3455,10 @@ snapshots: type-fest@2.19.0: {} + type-fest@5.7.0: + dependencies: + tagged-tag: 1.0.0 + typescript@5.9.3: {} ulid@3.0.2: {} @@ -3211,12 +3517,22 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@6.0.0: + dependencies: + string-width: 8.2.1 + wildcard-match@5.1.4: {} windows-release@7.1.1: dependencies: powershell-utils: 0.2.0 + wrap-ansi@10.0.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.2.1 + strip-ansi: 7.2.0 + ws@8.20.0: {} wsl-utils@0.3.1: @@ -3230,4 +3546,6 @@ snapshots: yoctocolors@2.1.2: {} + yoga-layout@3.2.1: {} + zod@4.3.6: {} diff --git a/package.json b/package.json index 056ce1be..b0508765 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@effect/rpc": "^0.74.0", "@effect/sql": "^0.50.0", "@types/node": "25.5.0", + "@types/react": "^19.2.16", "oxfmt": "0.47.0", "oxlint": "1.62.0", "oxlint-tsgolint": "0.22.1", @@ -84,8 +85,10 @@ "dependencies": { "commander": "14.0.3", "ghostty-web": "0.4.0", + "ink": "^7.0.5", "node-pty": "1.1.0", "playwright": "1.60.0", + "react": "^19.2.7", "ulid": "3.0.2", "zod": "4.3.6" }, diff --git a/src/cli/commands/dashboard.ts b/src/cli/commands/dashboard.ts new file mode 100644 index 00000000..c9215d31 --- /dev/null +++ b/src/cli/commands/dashboard.ts @@ -0,0 +1,62 @@ +import process from 'node:process'; + +import type { CommandContext } from '../context.js'; + +import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { + assertDashboardRendererAvailable, + probeLibghosttyVt, + type LibghosttyVtProbe, +} from '../../renderer/readiness.js'; +import type { DashboardScope } from '../../dashboard/sessionScope.js'; + +export interface DashboardAppOptions { + home: string; + scope: DashboardScope; + sessionId?: string; +} + +export interface DashboardCommandOptions { + context: CommandContext; + all: boolean; + session?: string; +} + +export interface DashboardCommandDependencies { + isInteractive?: () => boolean; + probeRenderer?: () => Promise; + runApp?: (options: DashboardAppOptions) => Promise; +} + +function defaultIsInteractive(): boolean { + return process.stdout.isTTY && process.stdin.isTTY; +} + +async function defaultRunApp(options: DashboardAppOptions): Promise { + // Imported lazily so non-dashboard CLI paths never load the Ink/React runtime. + const { runDashboardApp } = await import('../../dashboard/app.js'); + await runDashboardApp(options); +} + +export async function runDashboardCommand( + options: DashboardCommandOptions, + dependencies: DashboardCommandDependencies = {}, +): Promise { + const isInteractive = dependencies.isInteractive ?? defaultIsInteractive; + if (!isInteractive()) { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: + 'agent-tty dashboard requires an interactive terminal: stdin and stdout must both be a TTY. It is interactive-only and does not support --json or piped/CI use.', + }); + } + + const probe = await (dependencies.probeRenderer ?? probeLibghosttyVt)(); + assertDashboardRendererAvailable(probe); + + const runApp = dependencies.runApp ?? defaultRunApp; + await runApp({ + home: options.context.home, + scope: options.all ? 'all' : 'active', + ...(options.session === undefined ? {} : { sessionId: options.session }), + }); +} diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index d2358ac8..8c081fcc 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -30,6 +30,7 @@ import type { CapabilityEntry } from '../../renderer/capabilities.js'; import { createPty } from '../../pty/createPty.js'; import { resolveDefaultPlaywrightBrowsersPath } from '../../renderer/browserPath.js'; import { discoverCapabilities } from '../../renderer/capabilities.js'; +import { probeLibghosttyVt } from '../../renderer/readiness.js'; import { artifactPath, ensureArtifactsDir, @@ -65,6 +66,7 @@ const DOCTOR_CHECK_LABELS: Readonly> = Object.freeze({ browser_launch: 'browser', ghostty_web_available: 'ghostty-web', screenshot_viable: 'screenshot', + libghostty_vt_available: 'libghostty-vt', }); let doctorResourceSequence = 0; @@ -760,6 +762,20 @@ async function runGhosttyWebAvailableCheck(): Promise { return 'WASM available'; } +async function runLibghosttyVtAvailableCheck(): Promise { + const probe = await probeLibghosttyVt(); + if (!probe.available) { + // libghostty-vt is an optional dependency; its absence makes the dashboard + // unavailable but does not break the rest of agent-tty, so skip (not fail). + return skipDoctorCheck( + probe.detail ?? + probe.reason ?? + 'libghostty-vt optional renderer not installed', + ); + } + return probe.detail ?? 'libghostty-vt available'; +} + async function runScreenshotViabilityCheck(): Promise { const chromium = await getPlaywrightChromium(); const temporaryDirectory = await mkdtemp(join(tmpdir(), 'agent-tty-doctor-')); @@ -831,6 +847,7 @@ export async function runDoctorChecks(): Promise { ['browser_launch', () => runBrowserLaunchCheck()], ['ghostty_web_available', () => runGhosttyWebAvailableCheck()], ['screenshot_viable', () => runScreenshotViabilityCheck()], + ['libghostty_vt_available', () => runLibghosttyVtAvailableCheck()], ]); const allChecks = [...environment, ...renderer]; const uniqueCheckNames = new Set(allChecks.map((check) => check.name)); diff --git a/src/cli/main.ts b/src/cli/main.ts index 72cae9bb..217d8ef2 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -7,6 +7,7 @@ import { Command, CommanderError } from 'commander'; import type { CommandContext } from './context.js'; import { runCreateCommand } from './commands/create.js'; +import { runDashboardCommand } from './commands/dashboard.js'; import { runDestroyCommand } from './commands/destroy.js'; import { runDoctorCommand } from './commands/doctor.js'; import { runGcCommand } from './commands/gc.js'; @@ -674,6 +675,35 @@ async function main(): Promise { ), ); + program + .command('dashboard') + .description( + 'Watch what your agents are doing in their shells: a read-only, live dashboard of your sessions', + ) + .option( + '--all', + 'Start showing all sessions (active and terminal), not just active ones', + false, + ) + .option('--session ', 'Preselect a session to watch on launch') + .action( + wrapAction( + 'dashboard', + async ( + options: { all: boolean; session?: string }, + context: CommandContext, + ) => { + await runDashboardCommand({ + context, + all: options.all, + ...(options.session === undefined + ? {} + : { session: options.session }), + }); + }, + ), + ); + const recordCommand = program .command('record') .description('Manage recorded session artifacts'); diff --git a/src/dashboard/app.tsx b/src/dashboard/app.tsx new file mode 100644 index 00000000..ea57b01a --- /dev/null +++ b/src/dashboard/app.tsx @@ -0,0 +1,557 @@ +import { useEffect, useRef, useState } from 'react'; +import { Box, Text, render, useApp, useInput, useStdout } from 'ink'; + +import type { DashboardAppOptions } from '../cli/commands/dashboard.js'; +import { createRendererBackend } from '../renderer/registry.js'; +import { resolveProfile } from '../renderer/profiles.js'; +import { EventLogTailSource } from './eventSource.js'; +import { LiveViewFollower, type LiveViewFrame } from './liveViewFollower.js'; +import { + projectLiveView, + type LiveViewMode, + type PanOffset, + type ProjectedCell, + type ProjectedView, +} from './liveViewProjection.js'; +import { + listDashboardSessions, + type DashboardScope, + type DashboardSession, +} from './sessionScope.js'; + +// reference-dark profile defaults; cells matching these are left unstyled so the +// terminal's own theme shows through instead of repainting every cell. +const PROFILE_BG = '#1e1e2e'; +const PROFILE_FG = '#cdd6f4'; +const LIST_WIDTH = 28; +const FRAME_INTERVAL_MS = 33; +const LIST_REFRESH_MS = 1500; +const PAN_STEP = 1; + +type Focus = 'list' | 'live'; + +// ── cell-grid painting ──────────────────────────────────────────────────────── + +function styleKey(cell: ProjectedCell): string { + return [ + cell.fg ?? '', + cell.bg ?? '', + cell.bold === true ? '1' : '0', + cell.italic === true ? '1' : '0', + cell.underline === true ? '1' : '0', + cell.strikethrough === true ? '1' : '0', + cell.cursor === true ? '1' : '0', + ].join('|'); +} + +/** Coalesce a row of cells into styled runs to keep the Ink tree small. */ +function paintRow(cells: ProjectedCell[], rowKey: number): React.ReactNode { + const runs: React.ReactNode[] = []; + let index = 0; + let runIndex = 0; + while (index < cells.length) { + const cell = cells[index]; + if (cell === undefined) { + break; + } + const key = styleKey(cell); + let text = cell.char === '' ? ' ' : cell.char; + let next = index + 1; + while (next < cells.length) { + const candidate = cells[next]; + if (candidate === undefined || styleKey(candidate) !== key) { + break; + } + text += candidate.char === '' ? ' ' : candidate.char; + next += 1; + } + const fg = + cell.fg !== undefined && cell.fg !== PROFILE_FG ? cell.fg : undefined; + const bg = + cell.bg !== undefined && cell.bg !== PROFILE_BG ? cell.bg : undefined; + runs.push( + + {text} + , + ); + runIndex += 1; + index = next; + } + return ( + + {runs} + + ); +} + +function truncationIndicator(view: ProjectedView): string { + const marks: string[] = []; + if (view.truncated.top) marks.push('↑'); + if (view.truncated.bottom) marks.push('↓'); + if (view.truncated.left) marks.push('←'); + if (view.truncated.right) marks.push('→'); + return marks.length > 0 ? ` clip ${marks.join('')}` : ''; +} + +// ── live view pane ────────────────────────────────────────────────────────── + +function exitBadge(frame: LiveViewFrame): string { + if (frame.exit === undefined) { + return 'exited'; + } + if (frame.exit.exitSignal !== null) { + return `exited (signal ${frame.exit.exitSignal})`; + } + if (frame.exit.exitCode === 0) { + return 'exited (code 0)'; + } + return `failed (code ${String(frame.exit.exitCode ?? '?')})`; +} + +function LiveView({ + frame, + error, + pane, + mode, + pan, + focused, +}: { + frame: LiveViewFrame | null; + error: string | null; + pane: { cols: number; rows: number }; + mode: LiveViewMode; + pan: PanOffset; + focused: boolean; +}): React.ReactNode { + const snapshot = frame?.snapshot ?? null; + const status = frame?.status ?? 'pending'; + + let body: React.ReactNode; + let header = ''; + if (error !== null) { + body = Live View error: {error}; + } else if (status === 'collected') { + body = ( + + Event Log collected — session no longer available. + + ); + } else if (snapshot === null) { + body = Waiting for output…; + } else { + const view = projectLiveView({ snapshot, pane, mode, pan }); + header = + `screen ${String(snapshot.cols)}×${String(snapshot.rows)}` + + (snapshot.isAltScreen ? ' [alt]' : '') + + (mode === 'overview' ? ' [overview]' : '') + + truncationIndicator(view); + body = ( + + {view.cells.map((row, rowIndex) => paintRow(row, rowIndex))} + + ); + } + + return ( + + + {header.length > 0 ? header : 'live view'} + {status === 'exited' && frame !== null + ? ` · ${exitBadge(frame)}` + : ''} + + {body} + + ); +} + +// ── session list pane ───────────────────────────────────────────────────────── + +function statusDot(status: string): React.ReactNode { + if (status === 'running') return ; + if (status === 'exiting' || status === 'destroying') + return ; + if (status === 'failed') return ; + return ; +} + +function shortId(sessionId: string): string { + return sessionId.length > 10 ? `…${sessionId.slice(-9)}` : sessionId; +} + +function SessionList({ + sessions, + selectedIndex, + scope, + focused, + height, +}: { + sessions: DashboardSession[]; + selectedIndex: number; + scope: DashboardScope; + focused: boolean; + height: number; +}): React.ReactNode { + // Scroll a window that keeps the selected row visible (centered when possible) + // so navigating past the fold never hides the selection. + const visible = Math.max(1, height - 1); + const start = + sessions.length <= visible + ? 0 + : Math.min( + Math.max(0, selectedIndex - Math.floor(visible / 2)), + sessions.length - visible, + ); + const windowed = sessions.slice(start, start + visible); + + return ( + + + Sessions · {scope} ({sessions.length}){start > 0 ? ' ↑' : ''} + {start + visible < sessions.length ? ' ↓' : ''} + + {windowed.map((session, index) => { + const selected = start + index === selectedIndex; + const label = + session.name ?? session.command[0]?.split('/').pop() ?? ''; + return ( + + {selected ? '▸ ' : ' '} + {statusDot(session.status)} {shortId(session.sessionId)} {label} + + ); + })} + {sessions.length === 0 && no sessions} + + ); +} + +// ── follower wiring (one selected session at a time) ────────────────────────── + +interface FollowerState { + frame: LiveViewFrame | null; + error: string | null; +} + +function useFollower(session: DashboardSession | undefined): FollowerState { + const [frame, setFrame] = useState(null); + const [error, setError] = useState(null); + const sessionId = session?.sessionId; + + useEffect(() => { + setFrame(null); + setError(null); + if (session === undefined) { + return; + } + + const controller = new AbortController(); + const { signal } = controller; + let busy = false; + let timer: NodeJS.Timeout | undefined; + let follower: LiveViewFollower | null = null; + let lastStatus = ''; + + // Surface a failure to the UI, but stay silent for errors caused by our own + // teardown (a render in flight when the backend is disposed on switch/quit). + const fail = (caught: unknown): void => { + if (!signal.aborted) { + setError(caught instanceof Error ? caught.message : String(caught)); + } + }; + + void (async () => { + try { + const backend = await createRendererBackend( + 'libghostty-vt', + session.sessionId, + resolveProfile('reference-dark'), + ); + if (signal.aborted) { + await backend.dispose(); + return; + } + follower = new LiveViewFollower({ + source: new EventLogTailSource(session.eventLog), + backend, + sessionId: session.sessionId, + initialCols: session.initialCols, + initialRows: session.initialRows, + }); + + timer = setInterval(() => { + const active = follower; + if (busy || signal.aborted || active === null) { + return; + } + busy = true; + void (async () => { + try { + await active.ingest(); + const changed = await active.render(); + if ( + !signal.aborted && + (changed || active.frame.status !== lastStatus) + ) { + lastStatus = active.frame.status; + setFrame(active.frame); + } + } catch (caught) { + fail(caught); + } finally { + busy = false; + } + })(); + }, FRAME_INTERVAL_MS); + } catch (caught) { + fail(caught); + } + })(); + + return () => { + controller.abort(); + if (timer !== undefined) { + clearInterval(timer); + } + void follower?.dispose(); + }; + }, [sessionId]); // re-follow only when the selected Session changes + + return { frame, error }; +} + +// ── app ─────────────────────────────────────────────────────────────────────── + +function App({ options }: { options: DashboardAppOptions }): React.ReactNode { + const { exit } = useApp(); + const { stdout } = useStdout(); + + const [sessions, setSessions] = useState([]); + const [selectedId, setSelectedId] = useState( + options.sessionId ?? null, + ); + const [scope, setScope] = useState(options.scope); + const [focus, setFocus] = useState('list'); + const [mode, setMode] = useState('one-to-one'); + const [pan, setPan] = useState({ row: 0, col: 0 }); + const [error, setError] = useState(null); + + const lastKnown = useRef>(new Map()); + const frameRef = useRef(null); + const selectedIdRef = useRef(selectedId); + selectedIdRef.current = selectedId; + + // Refresh the Session list on an interval, pinning the selected Session + // through its transition to Terminal (dropping it only once collected). + useEffect(() => { + let alive = true; + let refreshing = false; + const refresh = async (): Promise => { + if (refreshing) { + return; // a slower scan is still in flight; skip this tick + } + refreshing = true; + try { + const next = await listDashboardSessions(options.home, scope); + if (!alive) { + return; + } + for (const session of next) { + lastKnown.current.set(session.sessionId, session); + } + const pinnedId = selectedIdRef.current; + const collected = frameRef.current?.status === 'collected'; + const displayed = [...next]; + if ( + pinnedId !== null && + !collected && + !next.some((session) => session.sessionId === pinnedId) + ) { + const pinned = lastKnown.current.get(pinnedId); + if (pinned !== undefined) { + displayed.push(pinned); + } + } + setSessions(displayed); + setSelectedId((current) => { + if ( + current !== null && + displayed.some((s) => s.sessionId === current) + ) { + return current; + } + // Prefix-match a requested --session id, else fall back to newest. + const requested = + options.sessionId !== undefined + ? displayed.find((s) => + s.sessionId.startsWith(options.sessionId ?? ''), + ) + : undefined; + return requested?.sessionId ?? displayed[0]?.sessionId ?? null; + }); + } catch (caught) { + if (alive) { + setError(caught instanceof Error ? caught.message : String(caught)); + } + } finally { + refreshing = false; + } + }; + void refresh(); + const timer = setInterval(() => void refresh(), LIST_REFRESH_MS); + return () => { + alive = false; + clearInterval(timer); + }; + }, [scope, options.home, options.sessionId]); + + const selectedIndex = Math.max( + 0, + sessions.findIndex((session) => session.sessionId === selectedId), + ); + const selectedSession = sessions[selectedIndex]; + const { frame, error: liveError } = useFollower(selectedSession); + frameRef.current = frame; + + const termCols = stdout.columns; + const termRows = stdout.rows; + const paneCols = Math.max(10, termCols - LIST_WIDTH - 5); + const paneRows = Math.max(4, termRows - 5); + + // Clamp a candidate pan to the current screen so stored pan never drifts past + // the edges (which would otherwise make pan keys feel dead after overshooting). + const clampPan = (next: PanOffset): PanOffset => { + const snapshot = frame?.snapshot; + if (snapshot === null || snapshot === undefined) { + return { row: 0, col: 0 }; + } + return { + row: Math.min( + Math.max(0, next.row), + Math.max(0, snapshot.rows - paneRows), + ), + col: Math.min( + Math.max(0, next.col), + Math.max(0, snapshot.cols - paneCols), + ), + }; + }; + + const moveSelection = (delta: number): void => { + if (sessions.length === 0) { + return; + } + const nextIndex = Math.min( + sessions.length - 1, + Math.max(0, selectedIndex + delta), + ); + const next = sessions[nextIndex]; + if (next !== undefined) { + setSelectedId(next.sessionId); + setPan({ row: 0, col: 0 }); + } + }; + + useInput((input, key) => { + if (input === 'q' || (key.ctrl && input === 'c')) { + exit(); + return; + } + if (key.tab) { + setFocus((current) => (current === 'list' ? 'live' : 'list')); + return; + } + if (input === 'a') { + setScope((current) => (current === 'active' ? 'all' : 'active')); + return; + } + if (input === 'z') { + setMode((current) => + current === 'one-to-one' ? 'overview' : 'one-to-one', + ); + setPan({ row: 0, col: 0 }); + return; + } + + if (focus === 'list') { + if (key.upArrow || input === 'k') moveSelection(-1); + if (key.downArrow || input === 'j') moveSelection(1); + return; + } + + // focus === 'live': pan the clipped screen (no-op in overview). + if (key.upArrow || input === 'k') + setPan((p) => clampPan({ row: p.row - PAN_STEP, col: p.col })); + if (key.downArrow || input === 'j') + setPan((p) => clampPan({ row: p.row + PAN_STEP, col: p.col })); + if (key.leftArrow || input === 'h') + setPan((p) => clampPan({ row: p.row, col: p.col - PAN_STEP })); + if (key.rightArrow || input === 'l') + setPan((p) => clampPan({ row: p.row, col: p.col + PAN_STEP })); + }); + + return ( + + + + {' agent-tty dashboard '} + + + {' read-only · '} + {selectedSession + ? `${shortId(selectedSession.sessionId)} ${selectedSession.status}` + : 'no session selected'} + + + + + + + + + + + {`focus:${focus} · Tab switch · `} + {focus === 'list' ? '↑/↓ j/k select' : '↑/↓ h/j/k/l pan'} + {' · a scope · z overview · q quit'} + {error !== null ? ` · ERR: ${error}` : ''} + + + + ); +} + +export async function runDashboardApp( + options: DashboardAppOptions, +): Promise { + const instance = render(); + await instance.waitUntilExit(); +} diff --git a/src/dashboard/eventSource.ts b/src/dashboard/eventSource.ts new file mode 100644 index 00000000..b75fcbaf --- /dev/null +++ b/src/dashboard/eventSource.ts @@ -0,0 +1,124 @@ +import { open, stat } from 'node:fs/promises'; + +import { EventRecordSchema, type EventRecord } from '../protocol/schemas.js'; +import { hasErrorCode } from '../util/hasErrorCode.js'; + +const LINE_FEED = 0x0a; + +/** + * Whether a Session's Event Log is present and being followed. + * + * - `pending`: the Event Log has never been observed (the Session may be + * starting up and has not written its first entry yet). + * - `active`: the Event Log is present; entries are being read. + * - `collected`: the Event Log was present and has since been removed (the + * Session was garbage-collected / its **Collectable Session** directory was + * reclaimed). The **Live View** should freeze and the **Session** drops out + * of the list on the next refresh. + */ +export type SessionEventSourceState = 'pending' | 'active' | 'collected'; + +/** One batch of newly-appended Event Log entries plus the source's state. */ +export interface SessionEventBatch { + records: EventRecord[]; + state: SessionEventSourceState; +} + +/** + * A source of **Event Log** entries for a single **Session**, consumed by + * **Event Log Follow**. The v1 implementation tails `events.jsonl` from disk + * ({@link EventLogTailSource}); a future streaming subscribe transport can + * implement this same interface without touching the **Live View** (ADR 0006). + */ +export interface SessionEventSource { + /** Pull any Event Log entries appended since the previous poll. */ + poll(): Promise; +} + +export class EventLogTailSource implements SessionEventSource { + private offset = 0; + private partial = Buffer.alloc(0); + private everPresent = false; + + constructor(private readonly path: string) {} + + async poll(): Promise { + let size: number; + try { + size = (await stat(this.path)).size; + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + // A log we have read before and is now gone was collected; one we have + // never seen is simply not created yet. Reset the read position so a log + // later (re)created at this path is read from the start rather than + // resuming at a stale byte offset. + this.offset = 0; + this.partial = Buffer.alloc(0); + return { + records: [], + state: this.everPresent ? 'collected' : 'pending', + }; + } + throw error; + } + this.everPresent = true; + + if (size < this.offset) { + // The log was truncated or rewritten; start over from the beginning. + this.offset = 0; + this.partial = Buffer.alloc(0); + } + if (size === this.offset) { + return { records: [], state: 'active' }; + } + + const length = size - this.offset; + const chunk = Buffer.alloc(length); + const handle = await open(this.path, 'r'); + try { + const { bytesRead } = await handle.read(chunk, 0, length, this.offset); + this.offset += bytesRead; + this.partial = Buffer.concat([ + this.partial, + chunk.subarray(0, bytesRead), + ]); + } finally { + await handle.close(); + } + + return { records: this.drainCompleteLines(), state: 'active' }; + } + + /** + * Decode and parse only newline-terminated lines, keeping any trailing + * partial line buffered as raw bytes (so a multibyte sequence split across + * reads is never decoded mid-character). + */ + private drainCompleteLines(): EventRecord[] { + const records: EventRecord[] = []; + let newlineIndex: number; + while ((newlineIndex = this.partial.indexOf(LINE_FEED)) !== -1) { + const line = this.partial + .subarray(0, newlineIndex) + .toString('utf8') + .trim(); + this.partial = this.partial.subarray(newlineIndex + 1); + if (line.length === 0) { + continue; + } + // A complete line that is not a valid Event Log entry is corruption, not + // a torn write. Skip it rather than crashing the read-only Live View. + let value: unknown; + try { + value = JSON.parse(line); + } catch { + continue; + } + const parsed = EventRecordSchema.safeParse(value); + if (parsed.success) { + records.push(parsed.data); + } + } + return records; + } +} diff --git a/src/dashboard/liveViewFollower.ts b/src/dashboard/liveViewFollower.ts new file mode 100644 index 00000000..c371e5c1 --- /dev/null +++ b/src/dashboard/liveViewFollower.ts @@ -0,0 +1,143 @@ +import type { EventRecord } from '../protocol/schemas.js'; +import type { SnapshotOptions } from '../renderer/backend.js'; +import type { + ReplayInput, + ReplayState, + SemanticSnapshot, +} from '../renderer/types.js'; +import type { SessionEventSource } from './eventSource.js'; + +/** + * The subset of a renderer backend that **Event Log Follow** drives. The real + * `libghostty-vt` backend satisfies this; tests inject a fake. + */ +export interface FollowRendererBackend { + boot(): Promise; + replayTo(input: ReplayInput): Promise; + snapshot(options?: SnapshotOptions): Promise; + dispose(): Promise; +} + +/** + * The lifecycle of a followed **Live View**: + * - `pending`: no screen yet (Event Log not produced its first entry). + * - `following`: actively reconstructing the live screen. + * - `exited`: the **Session** process exited; the final screen is frozen. + * - `collected`: the **Event Log** was removed; the frozen screen is the last + * thing seen and the **Session** drops out on the next list refresh. + */ +export type LiveViewStatus = 'pending' | 'following' | 'exited' | 'collected'; + +export interface LiveViewExit { + exitCode: number | null; + exitSignal: string | null; +} + +export interface LiveViewFrame { + status: LiveViewStatus; + snapshot: SemanticSnapshot | null; + exit?: LiveViewExit; +} + +export interface LiveViewFollowerOptions { + source: SessionEventSource; + backend: FollowRendererBackend; + sessionId: string; + initialCols: number; + initialRows: number; +} + +export class LiveViewFollower { + private readonly events: EventRecord[] = []; + private pendingSeq = -1; + private renderedSeq = -1; + private booted = false; + private collected = false; + private exit: LiveViewExit | null = null; + private lastSnapshot: SemanticSnapshot | null = null; + + constructor(private readonly options: LiveViewFollowerOptions) {} + + /** Pull one batch from the source and accumulate it (no rendering). */ + async ingest(): Promise { + const batch = await this.options.source.poll(); + + if (batch.state === 'collected') { + // The Event Log is gone; freeze on the last screen we reconstructed. + this.collected = true; + return; + } + + for (const record of batch.records) { + this.events.push(record); + if (record.seq > this.pendingSeq) { + this.pendingSeq = record.seq; + } + if (record.type === 'exit') { + this.exit = { + exitCode: record.payload.exitCode, + exitSignal: record.payload.exitSignal, + }; + } + } + } + + /** + * Advance the screen to the latest ingested sequence in a single replay + * (coalescing any backlog into one frame) and snapshot it. Returns whether a + * new frame was produced. A frozen (collected) follower never re-renders. + */ + async render(): Promise { + if (this.collected) { + return false; + } + if (this.pendingSeq <= this.renderedSeq || this.events.length === 0) { + return false; + } + + if (!this.booted) { + await this.options.backend.boot(); + this.booted = true; + } + + await this.options.backend.replayTo({ + sessionId: this.options.sessionId, + initialCols: this.options.initialCols, + initialRows: this.options.initialRows, + events: this.events, + targetSeq: this.pendingSeq, + }); + this.lastSnapshot = await this.options.backend.snapshot({ + includeCells: true, + }); + this.renderedSeq = this.pendingSeq; + // The backend is stateful (it tracks lastAppliedSeq and rejects rewinds), so + // every accumulated event up to pendingSeq is now applied and can be + // dropped. The next render passes only freshly-ingested events, keeping + // memory and per-frame replay work bounded over a long session. + this.events.length = 0; + return true; + } + + get frame(): LiveViewFrame { + return { + status: this.status(), + snapshot: this.lastSnapshot, + ...(this.exit === null ? {} : { exit: this.exit }), + }; + } + + private status(): LiveViewStatus { + if (this.collected) { + return 'collected'; + } + if (this.exit !== null) { + return 'exited'; + } + return this.lastSnapshot === null ? 'pending' : 'following'; + } + + async dispose(): Promise { + await this.options.backend.dispose(); + } +} diff --git a/src/dashboard/liveViewProjection.ts b/src/dashboard/liveViewProjection.ts new file mode 100644 index 00000000..5976f5d5 --- /dev/null +++ b/src/dashboard/liveViewProjection.ts @@ -0,0 +1,230 @@ +import type { SnapshotCell } from '../protocol/schemas.js'; +import type { SemanticSnapshot } from '../renderer/types.js'; + +/** + * Pure projection of a **Semantic Snapshot** into the grid a **Live View** + * paints. It clips, pans, or downsamples the **Session**'s own screen to fit + * the dashboard pane; it never reflows or stretches (ADR-aligned: the + * dashboard never resizes the Session). + */ +export type LiveViewMode = 'one-to-one' | 'overview'; + +export interface PaneSize { + cols: number; + rows: number; +} + +export interface PanOffset { + row: number; + col: number; +} + +export interface ProjectedCell { + char: string; + fg?: string; + bg?: string; + bold?: boolean; + italic?: boolean; + underline?: boolean; + strikethrough?: boolean; + cursor?: boolean; +} + +/** Which edges hide off-screen content (so the shell can show indicators). */ +export interface TruncationFlags { + top: boolean; + bottom: boolean; + left: boolean; + right: boolean; +} + +export interface ProjectedView { + mode: LiveViewMode; + /** Painted grid width; may be smaller than the pane (letterbox / overview). */ + cols: number; + /** Painted grid height; may be smaller than the pane (letterbox / overview). */ + rows: number; + cells: ProjectedCell[][]; + /** The pan offset actually applied after clamping to the content. */ + pan: PanOffset; + truncated: TruncationFlags; +} + +export interface ProjectLiveViewInput { + snapshot: SemanticSnapshot; + pane: PaneSize; + mode: LiveViewMode; + pan?: PanOffset; +} + +/** Reads styled characters from a snapshot's `cells` (preferred) or text. */ +class SnapshotGrid { + private readonly cellRows: Map>; + private readonly textRows: Map; + + constructor(private readonly snapshot: SemanticSnapshot) { + this.cellRows = new Map( + (snapshot.cells ?? []).map((line) => [ + line.lineNumber, + line.cells.map((cell) => toProjectedCell(cell)), + ]), + ); + this.textRows = new Map( + snapshot.visibleLines.map((line) => [line.row, line.text]), + ); + } + + cellAt(row: number, col: number): ProjectedCell { + // Known limitation: `SnapshotCell[]` is densely packed without a column key + // (the renderer drops native `col`/`width`), so we treat array index as the + // terminal column. A wide glyph (CJK/emoji) spans two columns but is one + // entry, shifting everything after it left. Shared with `snapshot`; fixing + // it needs `col`/`width` on the schema. See coder/agent-tty#112. + const styled = this.cellRows.get(row)?.[col]; + if (styled !== undefined) { + return styled.char === '' ? { ...styled, char: ' ' } : styled; + } + const char = Array.from(this.textRows.get(row) ?? '')[col] ?? ' '; + return { char: char === '' ? ' ' : char }; + } +} + +function toProjectedCell(cell: SnapshotCell): ProjectedCell { + return { + char: cell.char, + ...(cell.fg === undefined ? {} : { fg: cell.fg }), + ...(cell.bg === undefined ? {} : { bg: cell.bg }), + ...(cell.bold === undefined ? {} : { bold: cell.bold }), + ...(cell.italic === undefined ? {} : { italic: cell.italic }), + ...(cell.underline === undefined ? {} : { underline: cell.underline }), + ...(cell.strikethrough === undefined + ? {} + : { strikethrough: cell.strikethrough }), + }; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function projectOneToOne(input: ProjectLiveViewInput): ProjectedView { + const { snapshot, pane } = input; + const grid = new SnapshotGrid(snapshot); + + const panRow = clamp( + input.pan?.row ?? 0, + 0, + Math.max(0, snapshot.rows - pane.rows), + ); + const panCol = clamp( + input.pan?.col ?? 0, + 0, + Math.max(0, snapshot.cols - pane.cols), + ); + + const visibleRows = Math.min(pane.rows, snapshot.rows - panRow); + const visibleCols = Math.min(pane.cols, snapshot.cols - panCol); + + const cells: ProjectedCell[][] = []; + for (let row = 0; row < visibleRows; row += 1) { + const sourceRow = panRow + row; + const out: ProjectedCell[] = []; + for (let col = 0; col < visibleCols; col += 1) { + const sourceCol = panCol + col; + const cell = grid.cellAt(sourceRow, sourceCol); + const isCursor = + sourceRow === snapshot.cursorRow && sourceCol === snapshot.cursorCol; + out.push(isCursor ? { ...cell, cursor: true } : cell); + } + cells.push(out); + } + + return { + mode: 'one-to-one', + cols: visibleCols, + rows: visibleRows, + cells, + pan: { row: panRow, col: panCol }, + truncated: { + top: panRow > 0, + bottom: panRow + visibleRows < snapshot.rows, + left: panCol > 0, + right: panCol + visibleCols < snapshot.cols, + }, + }; +} + +const SHADE_RAMP = ['░', '▒', '▓', '█'] as const; + +/** Map block fill density (0..1) to a block glyph; blank when empty. */ +function shadeForDensity(density: number): string { + if (density <= 0) { + return ' '; + } + const index = Math.min( + SHADE_RAMP.length - 1, + Math.ceil(density * SHADE_RAMP.length) - 1, + ); + return SHADE_RAMP[index] ?? '█'; +} + +function projectOverview(input: ProjectLiveViewInput): ProjectedView { + const { snapshot, pane } = input; + const grid = new SnapshotGrid(snapshot); + + const scaleX = Math.max(1, Math.ceil(snapshot.cols / pane.cols)); + const scaleY = Math.max(1, Math.ceil(snapshot.rows / pane.rows)); + const outCols = Math.ceil(snapshot.cols / scaleX); + const outRows = Math.ceil(snapshot.rows / scaleY); + + const cells: ProjectedCell[][] = []; + for (let row = 0; row < outRows; row += 1) { + const out: ProjectedCell[] = []; + for (let col = 0; col < outCols; col += 1) { + let filled = 0; + let total = 0; + let fg: string | undefined; + for (let dy = 0; dy < scaleY; dy += 1) { + const sourceRow = row * scaleY + dy; + if (sourceRow >= snapshot.rows) { + break; + } + for (let dx = 0; dx < scaleX; dx += 1) { + const sourceCol = col * scaleX + dx; + if (sourceCol >= snapshot.cols) { + break; + } + total += 1; + const cell = grid.cellAt(sourceRow, sourceCol); + if (cell.char !== ' ' && cell.char !== '') { + filled += 1; + if (fg === undefined && cell.fg !== undefined) { + fg = cell.fg; + } + } + } + } + const density = total === 0 ? 0 : filled / total; + out.push({ + char: shadeForDensity(density), + ...(fg === undefined ? {} : { fg }), + }); + } + cells.push(out); + } + + return { + mode: 'overview', + cols: outCols, + rows: outRows, + cells, + pan: { row: 0, col: 0 }, + truncated: { top: false, bottom: false, left: false, right: false }, + }; +} + +export function projectLiveView(input: ProjectLiveViewInput): ProjectedView { + return input.mode === 'overview' + ? projectOverview(input) + : projectOneToOne(input); +} diff --git a/src/dashboard/sessionScope.ts b/src/dashboard/sessionScope.ts new file mode 100644 index 00000000..12b5ff54 --- /dev/null +++ b/src/dashboard/sessionScope.ts @@ -0,0 +1,62 @@ +import { listSessions } from '../host/lifecycle.js'; +import { readManifestIfExists } from '../storage/manifests.js'; +import { + eventLogPath, + manifestPath, + sessionDir, +} from '../storage/sessionPaths.js'; + +/** + * The Session Dashboard list scope. + * + * - `active`: **Active Sessions** only (mirrors `list`). + * - `all`: **Active** plus **Terminal** Sessions, excluding `destroyed` (a + * **Collectable Session** whose **Event Log** may already be removed). + */ +export type DashboardScope = 'active' | 'all'; + +export interface DashboardSession { + sessionId: string; + status: string; + command: string[]; + createdAt: string; + name?: string; + /** Replay dimensions used to seed Event Log Follow (creation size). */ + initialCols: number; + initialRows: number; + eventLog: string; +} + +const DEFAULT_COLS = 80; +const DEFAULT_ROWS = 24; + +/** List the Sessions a Dashboard should show for a scope, newest-first. */ +export async function listDashboardSessions( + home: string, + scope: DashboardScope, +): Promise { + const summaries = await listSessions(home, scope === 'all'); + const visible = + scope === 'all' + ? summaries.filter((summary) => summary.status !== 'destroyed') + : summaries; + + const sessions: DashboardSession[] = []; + for (const summary of visible) { + const dir = sessionDir(home, summary.sessionId); + const manifest = await readManifestIfExists(manifestPath(dir)); + sessions.push({ + sessionId: summary.sessionId, + status: summary.status, + command: summary.command, + createdAt: summary.createdAt, + ...(summary.name === undefined ? {} : { name: summary.name }), + initialCols: manifest?.creationCols ?? manifest?.cols ?? DEFAULT_COLS, + initialRows: manifest?.creationRows ?? manifest?.rows ?? DEFAULT_ROWS, + eventLog: eventLogPath(dir), + }); + } + + sessions.sort((left, right) => right.createdAt.localeCompare(left.createdAt)); + return sessions; +} diff --git a/src/renderer/capabilities.ts b/src/renderer/capabilities.ts index b155ae93..98c3363b 100644 --- a/src/renderer/capabilities.ts +++ b/src/renderer/capabilities.ts @@ -3,6 +3,11 @@ import assert from 'node:assert/strict'; import { z } from 'zod'; import { ensurePlaywrightBrowsersPath } from './browserPath.js'; +import { + buildDashboardCapability, + probeLibghosttyVt, + type LibghosttyVtProbe, +} from './readiness.js'; // --- Capability vocabulary --- @@ -12,6 +17,7 @@ export const CapabilityNameSchema = z.enum([ 'screenshot', 'record-export-asciicast', 'record-export-webm', + 'dashboard', ]); export type CapabilityName = z.infer; @@ -76,6 +82,7 @@ const CAPABILITY_NAMES: ReadonlyArray = Object.freeze([ 'screenshot', 'record-export-asciicast', 'record-export-webm', + 'dashboard', ]); const BUILTIN_CAPABILITY_NAMES: ReadonlyArray = Object.freeze([ 'snapshot', @@ -99,6 +106,7 @@ interface PlaywrightProbeResult { export interface CapabilityDiscoveryDependencies { probePlaywright?: (mode: DiscoveryMode) => Promise; + probeLibghosttyVt?: () => Promise; rendererChecks?: ReadonlyArray; } @@ -362,6 +370,37 @@ async function buildPlaywrightCapability( }; } +async function discoverDashboardCapability( + mode: DiscoveryMode, + deps: CapabilityDiscoveryDependencies, +): Promise { + if (mode === 'full' && deps.rendererChecks !== undefined) { + const check = findRendererCheck( + deps.rendererChecks, + 'libghostty_vt_available', + ); + if (check === undefined) { + return buildUnknownCapability('dashboard'); + } + const probe: LibghosttyVtProbe = + check.status === 'pass' + ? { + available: true, + reason: 'libghostty-vt native module available', + detail: check.message, + } + : { + available: false, + reason: 'libghostty-vt unavailable', + detail: check.message, + }; + return buildDashboardCapability(probe, mode); + } + + const probe = await (deps.probeLibghosttyVt ?? probeLibghosttyVt)(); + return buildDashboardCapability(probe, mode); +} + function validateDiscoveredCapabilities( capabilities: ReadonlyArray, ): CapabilityEntry[] { @@ -407,6 +446,7 @@ export async function discoverCapabilities( capabilities.push( await buildPlaywrightCapability('record-export-webm', mode, deps), ); + capabilities.push(await discoverDashboardCapability(mode, deps)); const sortedCapabilities: CapabilityEntry[] = []; for (const name of CAPABILITY_NAMES) { diff --git a/src/renderer/readiness.ts b/src/renderer/readiness.ts new file mode 100644 index 00000000..5b56f3fe --- /dev/null +++ b/src/renderer/readiness.ts @@ -0,0 +1,97 @@ +import { ERROR_CODES, makeCliError } from '../protocol/errors.js'; +import type { CliError } from '../cli/errors.js'; +import type { CapabilityEntry, DiscoveryMode } from './capabilities.js'; + +/** + * Readiness of the in-process `libghostty-vt` renderer that the + * **Session Dashboard** requires. The dashboard always uses this backend and + * never falls back to the browser-backed renderer (ADR 0006), so it must fail + * fast with an actionable message when the optional native dependency is + * absent, and `doctor` must report the resulting `dashboard` capability. + */ +export const LIBGHOSTTY_VT_PACKAGE = '@coder/libghostty-vt-node'; + +export const DASHBOARD_RENDERER_UNAVAILABLE_MESSAGE = + `The dashboard requires the in-process libghostty-vt renderer, provided by the optional dependency ${LIBGHOSTTY_VT_PACKAGE}. ` + + 'Reinstall agent-tty on a supported platform so the optional native package is fetched, then run `agent-tty doctor` to confirm readiness.'; + +export interface LibghosttyVtProbe { + available: boolean; + reason?: string; + detail?: string; +} + +export type LibghosttyVtLoader = () => Promise; + +function defaultLoader(): Promise { + return import('@coder/libghostty-vt-node'); +} + +/** + * Probe whether `libghostty-vt` can render: the optional native package must + * load and expose `createTerminal()` (the same shape the backend boots from). + */ +export async function probeLibghosttyVt( + loader: LibghosttyVtLoader = defaultLoader, +): Promise { + try { + const module = (await loader()) as { createTerminal?: unknown }; + if (typeof module.createTerminal !== 'function') { + return { + available: false, + reason: 'libghostty-vt module is incomplete', + detail: `${LIBGHOSTTY_VT_PACKAGE} loaded but did not expose createTerminal()`, + }; + } + return { + available: true, + reason: 'libghostty-vt native module available', + detail: `${LIBGHOSTTY_VT_PACKAGE} exposes createTerminal()`, + }; + } catch (error) { + return { + available: false, + reason: 'libghostty-vt not installed', + detail: error instanceof Error ? error.message : String(error), + }; + } +} + +/** Throw an actionable CLI error when the dashboard renderer is unavailable. */ +export function assertDashboardRendererAvailable( + probe: LibghosttyVtProbe, +): void { + if (probe.available) { + return; + } + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: DASHBOARD_RENDERER_UNAVAILABLE_MESSAGE, + details: { + renderer: 'libghostty-vt', + ...(probe.detail === undefined ? {} : { detail: probe.detail }), + }, + }) satisfies CliError; +} + +/** Build the `dashboard` capability entry that `doctor`/`version` report. */ +export function buildDashboardCapability( + probe: LibghosttyVtProbe, + mode: DiscoveryMode, +): CapabilityEntry { + if (!probe.available) { + return { + name: 'dashboard', + status: 'unavailable', + ...(probe.reason === undefined ? {} : { reason: probe.reason }), + ...(probe.detail === undefined ? {} : { detail: probe.detail }), + }; + } + return mode === 'full' + ? { + name: 'dashboard', + status: 'available', + ...(probe.reason === undefined ? {} : { reason: probe.reason }), + ...(probe.detail === undefined ? {} : { detail: probe.detail }), + } + : { name: 'dashboard', status: 'available' }; +} diff --git a/test/unit/commands/dashboard.test.ts b/test/unit/commands/dashboard.test.ts new file mode 100644 index 00000000..72a50897 --- /dev/null +++ b/test/unit/commands/dashboard.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CliError } from '../../../src/cli/errors.js'; +import { + runDashboardCommand, + type DashboardAppOptions, +} from '../../../src/cli/commands/dashboard.js'; +import type { CommandContext } from '../../../src/cli/context.js'; + +function fakeContext(home = '/tmp/home'): CommandContext { + // The dashboard command only reads `context.home`. + return { home } as CommandContext; +} + +describe('runDashboardCommand', () => { + it('fails fast with an actionable error on a non-interactive terminal', async () => { + const runApp = vi.fn(() => Promise.resolve()); + const probeRenderer = vi.fn(() => Promise.resolve({ available: true })); + + const promise = runDashboardCommand( + { context: fakeContext(), all: false }, + { isInteractive: () => false, probeRenderer, runApp }, + ); + + await expect(promise).rejects.toBeInstanceOf(CliError); + await expect(promise).rejects.toThrow(/interactive terminal/); + expect(probeRenderer).not.toHaveBeenCalled(); + expect(runApp).not.toHaveBeenCalled(); + }); + + it('fails fast when the libghostty-vt renderer is unavailable', async () => { + const runApp = vi.fn(() => Promise.resolve()); + + const promise = runDashboardCommand( + { context: fakeContext(), all: false }, + { + isInteractive: () => true, + probeRenderer: () => + Promise.resolve({ + available: false, + reason: 'libghostty-vt not installed', + detail: 'Cannot find package @coder/libghostty-vt-node', + }), + runApp, + }, + ); + + await expect(promise).rejects.toThrow(/libghostty-vt-node/); + expect(runApp).not.toHaveBeenCalled(); + }); + + it('runs the app with the resolved scope and preselected session when ready', async () => { + const calls: DashboardAppOptions[] = []; + const runApp = vi.fn((options: DashboardAppOptions) => { + calls.push(options); + return Promise.resolve(); + }); + + await runDashboardCommand( + { context: fakeContext('/home/agent'), all: true, session: '01J' }, + { + isInteractive: () => true, + probeRenderer: () => Promise.resolve({ available: true }), + runApp, + }, + ); + + expect(calls).toEqual([ + { home: '/home/agent', scope: 'all', sessionId: '01J' }, + ]); + }); + + it('defaults to the active scope without a preselected session', async () => { + const runApp = vi.fn(() => Promise.resolve()); + + await runDashboardCommand( + { context: fakeContext('/home/agent'), all: false }, + { + isInteractive: () => true, + probeRenderer: () => Promise.resolve({ available: true }), + runApp, + }, + ); + + expect(runApp).toHaveBeenCalledWith({ + home: '/home/agent', + scope: 'active', + }); + }); +}); diff --git a/test/unit/commands/doctor.test.ts b/test/unit/commands/doctor.test.ts index 22bb334b..3a79647f 100644 --- a/test/unit/commands/doctor.test.ts +++ b/test/unit/commands/doctor.test.ts @@ -128,14 +128,15 @@ describe('doctor command', () => { expect(result.ok).toBe(true); expect(result.checks.environment).toHaveLength(9); - expect(result.checks.renderer).toHaveLength(5); - expect(result.capabilities).toHaveLength(5); + expect(result.checks.renderer).toHaveLength(6); + expect(result.capabilities).toHaveLength(6); expect(result.capabilities.map((capability) => capability.name)).toEqual([ 'snapshot', 'wait', 'screenshot', 'record-export-asciicast', 'record-export-webm', + 'dashboard', ]); expect(result.capabilities.find(({ name }) => name === 'snapshot')).toEqual( { diff --git a/test/unit/commands/version.test.ts b/test/unit/commands/version.test.ts index e0bdb25f..422a1c3d 100644 --- a/test/unit/commands/version.test.ts +++ b/test/unit/commands/version.test.ts @@ -27,13 +27,14 @@ describe('version command', () => { it('builds the version result with runtime capabilities when requested', async () => { const result = await buildVersionResult({ includeCapabilities: true }); - expect(result.capabilities).toHaveLength(5); + expect(result.capabilities).toHaveLength(6); expect(result.capabilities?.map((capability) => capability.name)).toEqual([ 'snapshot', 'wait', 'screenshot', 'record-export-asciicast', 'record-export-webm', + 'dashboard', ]); expect( result.capabilities?.find(({ name }) => name === 'snapshot'), diff --git a/test/unit/dashboard/eventSource.test.ts b/test/unit/dashboard/eventSource.test.ts new file mode 100644 index 00000000..ddd0e179 --- /dev/null +++ b/test/unit/dashboard/eventSource.test.ts @@ -0,0 +1,197 @@ +import { appendFile, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { EventRecord } from '../../../src/protocol/schemas.js'; +import { EventLogTailSource } from '../../../src/dashboard/eventSource.js'; + +function outputEvent(seq: number, data: string): EventRecord { + return { + seq, + ts: '2026-06-02T12:00:00.000Z', + type: 'output', + payload: { data }, + }; +} + +function jsonl(records: readonly EventRecord[]): string { + return records.map((record) => `${JSON.stringify(record)}\n`).join(''); +} + +let tempDir = ''; +let logPath = ''; + +describe('EventLogTailSource', () => { + beforeEach(async () => { + // oxfmt-ignore + tempDir = await realpath(await mkdtemp(join(tmpdir(), 'agent-tty-event-source-'))); + logPath = join(tempDir, 'events.jsonl'); + }); + + afterEach(async () => { + if (tempDir.length > 0) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('returns only newly-appended entries on each poll', async () => { + const source = new EventLogTailSource(logPath); + + await writeFile(logPath, jsonl([outputEvent(0, 'first')]), 'utf8'); + await expect(source.poll()).resolves.toEqual({ + records: [outputEvent(0, 'first')], + state: 'active', + }); + + await writeFile( + logPath, + jsonl([outputEvent(0, 'first'), outputEvent(1, 'second')]), + 'utf8', + ); + await expect(source.poll()).resolves.toEqual({ + records: [outputEvent(1, 'second')], + state: 'active', + }); + + await expect(source.poll()).resolves.toEqual({ + records: [], + state: 'active', + }); + }); + + it('buffers a partial trailing line until its terminating newline arrives', async () => { + const source = new EventLogTailSource(logPath); + + const firstLine = `${JSON.stringify(outputEvent(0, 'first'))}\n`; + const secondLine = `${JSON.stringify(outputEvent(1, 'second'))}\n`; + const splitAt = Math.floor(secondLine.length / 2); + + // First record complete, second record only half-written (no newline yet). + await writeFile(logPath, firstLine + secondLine.slice(0, splitAt), 'utf8'); + await expect(source.poll()).resolves.toEqual({ + records: [outputEvent(0, 'first')], + state: 'active', + }); + + // The rest of the second line, including its newline, is flushed. + await appendFile(logPath, secondLine.slice(splitAt), 'utf8'); + await expect(source.poll()).resolves.toEqual({ + records: [outputEvent(1, 'second')], + state: 'active', + }); + }); + + it('decodes a multibyte sequence split across two reads without corruption', async () => { + const source = new EventLogTailSource(logPath); + + const record = outputEvent(0, 'rocket 🚀 done'); + const lineBytes = Buffer.from(`${JSON.stringify(record)}\n`, 'utf8'); + // Split one byte into the first multibyte (>= 0x80) sequence so a complete + // character straddles the two reads. + const splitIndex = lineBytes.findIndex((byte) => byte >= 0x80) + 1; + expect(splitIndex).toBeGreaterThan(1); + + await writeFile(logPath, lineBytes.subarray(0, splitIndex)); + await expect(source.poll()).resolves.toEqual({ + records: [], + state: 'active', + }); + + await appendFile(logPath, lineBytes.subarray(splitIndex)); + await expect(source.poll()).resolves.toEqual({ + records: [record], + state: 'active', + }); + }); + + it('reports a never-created Event Log as pending', async () => { + const source = new EventLogTailSource(logPath); + + await expect(source.poll()).resolves.toEqual({ + records: [], + state: 'pending', + }); + }); + + it('reports a removed Event Log as collected after it was active', async () => { + const source = new EventLogTailSource(logPath); + + await writeFile(logPath, jsonl([outputEvent(0, 'first')]), 'utf8'); + await expect(source.poll()).resolves.toEqual({ + records: [outputEvent(0, 'first')], + state: 'active', + }); + + await rm(logPath); + await expect(source.poll()).resolves.toEqual({ + records: [], + state: 'collected', + }); + }); + + it('resets and re-reads from the start when the log is truncated or rewritten', async () => { + const source = new EventLogTailSource(logPath); + + await writeFile( + logPath, + jsonl([outputEvent(0, 'first'), outputEvent(1, 'second')]), + 'utf8', + ); + await expect(source.poll()).resolves.toEqual({ + records: [outputEvent(0, 'first'), outputEvent(1, 'second')], + state: 'active', + }); + + // Rewritten to a smaller file (size < last read offset). + await writeFile(logPath, jsonl([outputEvent(0, 'restarted')]), 'utf8'); + await expect(source.poll()).resolves.toEqual({ + records: [outputEvent(0, 'restarted')], + state: 'active', + }); + }); + + it('re-reads a recreated log from the start after it was collected', async () => { + const source = new EventLogTailSource(logPath); + + await writeFile(logPath, jsonl([outputEvent(0, 'old')]), 'utf8'); + await expect(source.poll()).resolves.toEqual({ + records: [outputEvent(0, 'old')], + state: 'active', + }); + + await rm(logPath); + await expect(source.poll()).resolves.toEqual({ + records: [], + state: 'collected', + }); + + // A brand-new (larger) log appears at the same path; it must be read from + // the start, not resumed at the previous file's byte offset. + await writeFile( + logPath, + jsonl([outputEvent(0, 'new-a'), outputEvent(1, 'new-b')]), + 'utf8', + ); + await expect(source.poll()).resolves.toEqual({ + records: [outputEvent(0, 'new-a'), outputEvent(1, 'new-b')], + state: 'active', + }); + }); + + it('skips a malformed complete line without dropping the valid records around it', async () => { + const source = new EventLogTailSource(logPath); + + const content = + jsonl([outputEvent(0, 'before')]) + + 'this is not json\n' + + jsonl([outputEvent(1, 'after')]); + await writeFile(logPath, content, 'utf8'); + + await expect(source.poll()).resolves.toEqual({ + records: [outputEvent(0, 'before'), outputEvent(1, 'after')], + state: 'active', + }); + }); +}); diff --git a/test/unit/dashboard/liveViewFollower.test.ts b/test/unit/dashboard/liveViewFollower.test.ts new file mode 100644 index 00000000..48bfd642 --- /dev/null +++ b/test/unit/dashboard/liveViewFollower.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from 'vitest'; + +import type { EventRecord } from '../../../src/protocol/schemas.js'; +import type { SnapshotOptions } from '../../../src/renderer/backend.js'; +import type { + ReplayInput, + ReplayState, + SemanticSnapshot, +} from '../../../src/renderer/types.js'; +import type { + SessionEventBatch, + SessionEventSource, +} from '../../../src/dashboard/eventSource.js'; +import { + LiveViewFollower, + type FollowRendererBackend, +} from '../../../src/dashboard/liveViewFollower.js'; + +function outputEvent(seq: number): EventRecord { + return { + seq, + ts: '2026-06-02T12:00:00.000Z', + type: 'output', + payload: { data: `o${seq}` }, + }; +} + +function exitEvent( + seq: number, + exitCode: number | null, + exitSignal: string | null = null, +): EventRecord { + return { + seq, + ts: '2026-06-02T12:00:00.000Z', + type: 'exit', + payload: { exitCode, exitSignal }, + }; +} + +/** A SessionEventSource that yields pre-programmed batches, then idles active. */ +class FakeSource implements SessionEventSource { + private readonly queue: SessionEventBatch[]; + + constructor(batches: SessionEventBatch[]) { + this.queue = [...batches]; + } + + poll(): Promise { + const next = this.queue.shift(); + return Promise.resolve(next ?? { records: [], state: 'active' }); + } +} + +interface ReplayCall { + targetSeq: number; + eventCount: number; + sessionId: string; + initialCols: number; + initialRows: number; +} + +/** A renderer backend that records calls and snapshots the latest target seq. */ +class FakeBackend implements FollowRendererBackend { + bootCount = 0; + readonly replayCalls: ReplayCall[] = []; + readonly snapshotOptions: (SnapshotOptions | undefined)[] = []; + disposed = false; + private lastSeq = 0; + + boot(): Promise { + this.bootCount += 1; + return Promise.resolve(); + } + + replayTo(input: ReplayInput): Promise { + this.replayCalls.push({ + targetSeq: input.targetSeq, + eventCount: input.events.length, + sessionId: input.sessionId, + initialCols: input.initialCols, + initialRows: input.initialRows, + }); + this.lastSeq = input.targetSeq; + return Promise.resolve({ + lastSeq: input.targetSeq, + cols: 80, + rows: 24, + cursorRow: 0, + cursorCol: 0, + }); + } + + snapshot(options?: SnapshotOptions): Promise { + this.snapshotOptions.push(options); + return Promise.resolve({ + sessionId: 'session', + capturedAtSeq: this.lastSeq, + cols: 80, + rows: 24, + cursorRow: 0, + cursorCol: 0, + isAltScreen: false, + visibleLines: [{ row: 0, text: `seq ${this.lastSeq}` }], + }); + } + + dispose(): Promise { + this.disposed = true; + return Promise.resolve(); + } +} + +function makeFollower( + source: SessionEventSource, + backend: FollowRendererBackend, +): LiveViewFollower { + return new LiveViewFollower({ + source, + backend, + sessionId: 'session', + initialCols: 80, + initialRows: 24, + }); +} + +describe('LiveViewFollower', () => { + it('boots the backend and reconstructs the latest screen from ingested events', async () => { + const source = new FakeSource([ + { records: [outputEvent(0), outputEvent(1)], state: 'active' }, + ]); + const backend = new FakeBackend(); + const follower = makeFollower(source, backend); + + await follower.ingest(); + const rendered = await follower.render(); + + expect(rendered).toBe(true); + expect(backend.bootCount).toBe(1); + expect(backend.replayCalls).toEqual([ + { + targetSeq: 1, + eventCount: 2, + sessionId: 'session', + initialCols: 80, + initialRows: 24, + }, + ]); + expect(backend.snapshotOptions).toEqual([{ includeCells: true }]); + expect(follower.frame.status).toBe('following'); + expect(follower.frame.snapshot?.capturedAtSeq).toBe(1); + }); + + it('coalesces a backlog ingested over several polls into a single frame', async () => { + const source = new FakeSource([ + { + records: [outputEvent(0), outputEvent(1), outputEvent(2)], + state: 'active', + }, + { + records: [outputEvent(3), outputEvent(4), outputEvent(5)], + state: 'active', + }, + ]); + const backend = new FakeBackend(); + const follower = makeFollower(source, backend); + + await follower.ingest(); + await follower.ingest(); + const rendered = await follower.render(); + + expect(rendered).toBe(true); + expect(backend.replayCalls).toHaveLength(1); + expect(backend.replayCalls[0]).toMatchObject({ + targetSeq: 5, + eventCount: 6, + }); + expect(backend.snapshotOptions).toHaveLength(1); + expect(follower.frame.snapshot?.capturedAtSeq).toBe(5); + }); + + it('passes only newly-ingested events to the backend after the first render', async () => { + const source = new FakeSource([ + { + records: [outputEvent(0), outputEvent(1), outputEvent(2)], + state: 'active', + }, + { + records: [outputEvent(3), outputEvent(4), outputEvent(5)], + state: 'active', + }, + ]); + const backend = new FakeBackend(); + const follower = makeFollower(source, backend); + + await follower.ingest(); + expect(await follower.render()).toBe(true); + await follower.ingest(); + expect(await follower.render()).toBe(true); + + // The stateful backend already holds seq 0-2, so the second replay carries + // only the new delta (seq 3-5), not the whole accumulated history. + expect(backend.replayCalls).toEqual([ + expect.objectContaining({ targetSeq: 2, eventCount: 3 }), + expect.objectContaining({ targetSeq: 5, eventCount: 3 }), + ]); + }); + + it('does not re-render when no new events have arrived', async () => { + const source = new FakeSource([ + { records: [outputEvent(0)], state: 'active' }, + ]); + const backend = new FakeBackend(); + const follower = makeFollower(source, backend); + + await follower.ingest(); + expect(await follower.render()).toBe(true); + + await follower.ingest(); // idle: active, no records + expect(await follower.render()).toBe(false); + expect(backend.replayCalls).toHaveLength(1); + }); + + it('freezes the final screen and reports the exit code when the Session exits', async () => { + const source = new FakeSource([ + { records: [outputEvent(0), exitEvent(1, 0)], state: 'active' }, + ]); + const backend = new FakeBackend(); + const follower = makeFollower(source, backend); + + await follower.ingest(); + await follower.render(); + + expect(follower.frame.status).toBe('exited'); + expect(follower.frame.exit).toEqual({ exitCode: 0, exitSignal: null }); + expect(follower.frame.snapshot?.capturedAtSeq).toBe(1); + + // Subsequent idle polls keep the screen pinned and do not re-render. + await follower.ingest(); + expect(await follower.render()).toBe(false); + expect(follower.frame.status).toBe('exited'); + expect(backend.replayCalls).toHaveLength(1); + }); + + it('surfaces the collected state and freezes the last screen when the log is removed', async () => { + const source = new FakeSource([ + { records: [outputEvent(0)], state: 'active' }, + { records: [], state: 'collected' }, + ]); + const backend = new FakeBackend(); + const follower = makeFollower(source, backend); + + await follower.ingest(); + await follower.render(); + const frozen = follower.frame.snapshot; + + await follower.ingest(); // collected + expect(follower.frame.status).toBe('collected'); + expect(follower.frame.snapshot).toBe(frozen); + expect(await follower.render()).toBe(false); + }); + + it('reports pending before the Event Log produces any entry', async () => { + const source = new FakeSource([{ records: [], state: 'pending' }]); + const backend = new FakeBackend(); + const follower = makeFollower(source, backend); + + await follower.ingest(); + expect(await follower.render()).toBe(false); + expect(follower.frame.status).toBe('pending'); + expect(follower.frame.snapshot).toBeNull(); + expect(backend.bootCount).toBe(0); + }); + + it('reports collected even after an observed exit once the log is removed', async () => { + const source = new FakeSource([ + { records: [outputEvent(0), exitEvent(1, 0)], state: 'active' }, + { records: [], state: 'collected' }, + ]); + const backend = new FakeBackend(); + const follower = makeFollower(source, backend); + + await follower.ingest(); + await follower.render(); + expect(follower.frame.status).toBe('exited'); + + await follower.ingest(); + expect(follower.frame.status).toBe('collected'); + }); +}); diff --git a/test/unit/dashboard/liveViewProjection.test.ts b/test/unit/dashboard/liveViewProjection.test.ts new file mode 100644 index 00000000..fbde268f --- /dev/null +++ b/test/unit/dashboard/liveViewProjection.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest'; + +import type { SnapshotCell } from '../../../src/protocol/schemas.js'; +import type { SemanticSnapshot } from '../../../src/renderer/types.js'; +import { projectLiveView } from '../../../src/dashboard/liveViewProjection.js'; + +interface SnapshotOptions { + cursorRow?: number; + cursorCol?: number; + isAltScreen?: boolean; + style?: (row: number, col: number, char: string) => Partial; + includeCells?: boolean; +} + +/** Build a SemanticSnapshot from rows of text, with optional per-cell styling. */ +function snapshotFromRows( + rows: string[], + options: SnapshotOptions = {}, +): SemanticSnapshot { + const cols = Math.max(1, ...rows.map((row) => row.length)); + const includeCells = options.includeCells ?? true; + const snapshot: SemanticSnapshot = { + sessionId: 'session', + capturedAtSeq: 0, + cols, + rows: rows.length, + cursorRow: options.cursorRow ?? 0, + cursorCol: options.cursorCol ?? 0, + isAltScreen: options.isAltScreen ?? false, + visibleLines: rows.map((text, row) => ({ row, text })), + ...(includeCells + ? { + cells: rows.map((text, row) => ({ + lineNumber: row, + cells: Array.from(text).map((char, col) => ({ + char, + ...options.style?.(row, col, char), + })), + })), + } + : {}), + }; + return snapshot; +} + +function chars(view: { cells: { char: string }[][] }): string[] { + return view.cells.map((row) => row.map((cell) => cell.char).join('')); +} + +describe('projectLiveView', () => { + it('mirrors a screen that exactly fills the pane and flags the cursor cell', () => { + const snapshot = snapshotFromRows(['abc', 'def'], { + cursorRow: 0, + cursorCol: 1, + style: (_row, col) => (col === 0 ? { fg: '#ff0000' } : {}), + }); + + const view = projectLiveView({ + snapshot, + pane: { cols: 3, rows: 2 }, + mode: 'one-to-one', + }); + + expect(view.mode).toBe('one-to-one'); + expect(view.cols).toBe(3); + expect(view.rows).toBe(2); + expect(chars(view)).toEqual(['abc', 'def']); + expect(view.pan).toEqual({ row: 0, col: 0 }); + expect(view.truncated).toEqual({ + top: false, + bottom: false, + left: false, + right: false, + }); + expect(view.cells[0]?.[1]?.cursor).toBe(true); + expect(view.cells[0]?.[0]?.cursor).toBeUndefined(); + expect(view.cells[0]?.[0]?.fg).toBe('#ff0000'); + }); + + it('clips to the top-left and flags right/bottom truncation when larger than the pane', () => { + const snapshot = snapshotFromRows(['abcd', 'efgh', 'ijkl', 'mnop']); + + const view = projectLiveView({ + snapshot, + pane: { cols: 2, rows: 2 }, + mode: 'one-to-one', + }); + + expect(view.cols).toBe(2); + expect(view.rows).toBe(2); + expect(chars(view)).toEqual(['ab', 'ef']); + expect(view.truncated).toEqual({ + top: false, + bottom: true, + left: false, + right: true, + }); + }); + + it('pans the clipped window and flags top/left truncation', () => { + const snapshot = snapshotFromRows(['abcd', 'efgh', 'ijkl', 'mnop']); + + const view = projectLiveView({ + snapshot, + pane: { cols: 2, rows: 2 }, + mode: 'one-to-one', + pan: { row: 1, col: 1 }, + }); + + expect(chars(view)).toEqual(['fg', 'jk']); + expect(view.pan).toEqual({ row: 1, col: 1 }); + expect(view.truncated).toEqual({ + top: true, + bottom: true, + left: true, + right: true, + }); + }); + + it('clamps a pan offset that would scroll past the content', () => { + const snapshot = snapshotFromRows(['abcd', 'efgh', 'ijkl', 'mnop']); + + const view = projectLiveView({ + snapshot, + pane: { cols: 2, rows: 2 }, + mode: 'one-to-one', + pan: { row: 99, col: 99 }, + }); + + // Clamped to the bottom-right window: max pan = (rows-pane, cols-pane) = (2,2). + expect(view.pan).toEqual({ row: 2, col: 2 }); + expect(chars(view)).toEqual(['kl', 'op']); + expect(view.truncated).toEqual({ + top: true, + bottom: false, + left: true, + right: false, + }); + }); + + it('letterboxes a screen smaller than the pane (own size, no truncation, no stretch)', () => { + const snapshot = snapshotFromRows(['hi', 'yo']); + + const view = projectLiveView({ + snapshot, + pane: { cols: 10, rows: 6 }, + mode: 'one-to-one', + }); + + expect(view.cols).toBe(2); + expect(view.rows).toBe(2); + expect(chars(view)).toEqual(['hi', 'yo']); + expect(view.truncated).toEqual({ + top: false, + bottom: false, + left: false, + right: false, + }); + }); + + it('downsamples to fit the pane with density block glyphs in overview mode', () => { + // 4x4 screen, each output cell aggregates a 2x2 block at pane 2x2. + const snapshot = snapshotFromRows(['XX ', 'X ', ' ', ' X']); + + const view = projectLiveView({ + snapshot, + pane: { cols: 2, rows: 2 }, + mode: 'overview', + }); + + expect(view.mode).toBe('overview'); + expect(view.cols).toBe(2); + expect(view.rows).toBe(2); + // top-left block 3/4 filled -> '▓'; bottom-right block 1/4 filled -> '░'. + expect(chars(view)).toEqual(['▓ ', ' ░']); + expect(view.pan).toEqual({ row: 0, col: 0 }); + expect(view.truncated).toEqual({ + top: false, + bottom: false, + left: false, + right: false, + }); + }); + + it('falls back to visibleLines text when the snapshot carries no cells', () => { + const snapshot = snapshotFromRows(['ab', 'cd'], { includeCells: false }); + expect(snapshot.cells).toBeUndefined(); + + const view = projectLiveView({ + snapshot, + pane: { cols: 2, rows: 2 }, + mode: 'one-to-one', + }); + + expect(chars(view)).toEqual(['ab', 'cd']); + expect(view.cells[0]?.[0]?.fg).toBeUndefined(); + }); +}); diff --git a/test/unit/dashboard/sessionScope.test.ts b/test/unit/dashboard/sessionScope.test.ts new file mode 100644 index 00000000..3285490a --- /dev/null +++ b/test/unit/dashboard/sessionScope.test.ts @@ -0,0 +1,116 @@ +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import process from 'node:process'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { SessionRecord } from '../../../src/protocol/schemas.js'; +import { listDashboardSessions } from '../../../src/dashboard/sessionScope.js'; +import { writeManifest } from '../../../src/storage/manifests.js'; +import { + eventLogPath, + manifestPath, + sessionDir, +} from '../../../src/storage/sessionPaths.js'; + +let home = ''; + +async function seedSession( + record: Partial & { sessionId: string }, +): Promise { + const dir = sessionDir(home, record.sessionId); + await mkdir(dir, { recursive: true }); + const full: SessionRecord = { + version: 1, + createdAt: '2026-06-02T12:00:00.000Z', + updatedAt: '2026-06-02T12:00:00.000Z', + status: 'running', + command: ['/bin/sh'], + cwd: '/tmp/workspace', + cols: 80, + rows: 24, + hostPid: null, + childPid: null, + exitCode: null, + exitSignal: null, + ...record, + }; + await writeManifest(manifestPath(dir), full); + await writeFile(eventLogPath(dir), '', 'utf8'); +} + +describe('listDashboardSessions', () => { + beforeEach(async () => { + home = await realpath(await mkdtemp(join(tmpdir(), 'agent-tty-scope-'))); + }); + + afterEach(async () => { + if (home.length > 0) { + await rm(home, { recursive: true, force: true }); + } + }); + + it('returns only active sessions in active scope', async () => { + // A live hostPid keeps a `running` session from reconciling to `failed`. + await seedSession({ + sessionId: 'running-1', + status: 'running', + hostPid: process.pid, + createdAt: '2026-06-02T12:00:02.000Z', + }); + await seedSession({ + sessionId: 'exited-1', + status: 'exited', + createdAt: '2026-06-02T12:00:01.000Z', + }); + + const sessions = await listDashboardSessions(home, 'active'); + + expect(sessions.map((session) => session.sessionId)).toEqual(['running-1']); + }); + + it('includes terminal sessions but excludes destroyed in all scope, newest-first', async () => { + await seedSession({ + sessionId: 'running-1', + status: 'running', + hostPid: process.pid, + createdAt: '2026-06-02T12:00:02.000Z', + }); + await seedSession({ + sessionId: 'exited-1', + status: 'exited', + createdAt: '2026-06-02T12:00:01.000Z', + }); + await seedSession({ + sessionId: 'destroyed-1', + status: 'destroyed', + createdAt: '2026-06-02T12:00:03.000Z', + }); + + const sessions = await listDashboardSessions(home, 'all'); + + expect(sessions.map((session) => session.sessionId)).toEqual([ + 'running-1', + 'exited-1', + ]); + }); + + it('enriches each session with its replay dimensions and event-log path', async () => { + await seedSession({ + sessionId: 'running-1', + status: 'running', + hostPid: process.pid, + cols: 80, + rows: 24, + creationCols: 120, + creationRows: 40, + }); + + const [session] = await listDashboardSessions(home, 'active'); + + expect(session?.initialCols).toBe(120); + expect(session?.initialRows).toBe(40); + expect(session?.eventLog).toBe(eventLogPath(sessionDir(home, 'running-1'))); + }); +}); diff --git a/test/unit/protocol/messages.test.ts b/test/unit/protocol/messages.test.ts index beb6048e..f665effe 100644 --- a/test/unit/protocol/messages.test.ts +++ b/test/unit/protocol/messages.test.ts @@ -220,6 +220,7 @@ describe('CapabilityEntrySchema', () => { 'screenshot', 'record-export-asciicast', 'record-export-webm', + 'dashboard', ] as const; for (const name of names) { diff --git a/test/unit/renderer/capabilities.test.ts b/test/unit/renderer/capabilities.test.ts index 7385c2fa..6a2c5d83 100644 --- a/test/unit/renderer/capabilities.test.ts +++ b/test/unit/renderer/capabilities.test.ts @@ -10,21 +10,25 @@ function getCapability( } describe('discoverCapabilities', () => { - it('returns five quick capabilities without browser-launch details', async () => { + it('returns six quick capabilities without browser-launch details', async () => { const probePlaywright = vi.fn(() => Promise.resolve({ available: true })); + const probeLibghosttyVt = vi.fn(() => Promise.resolve({ available: true })); const capabilities = await discoverCapabilities('quick', { probePlaywright, + probeLibghosttyVt, }); expect(probePlaywright).toHaveBeenCalledTimes(2); - expect(capabilities).toHaveLength(5); + expect(probeLibghosttyVt).toHaveBeenCalledTimes(1); + expect(capabilities).toHaveLength(6); expect(capabilities.map((capability) => capability.name)).toEqual([ 'snapshot', 'wait', 'screenshot', 'record-export-asciicast', 'record-export-webm', + 'dashboard', ]); expect(getCapability(capabilities, 'snapshot')).toEqual({ name: 'snapshot', @@ -46,10 +50,34 @@ describe('discoverCapabilities', () => { name: 'record-export-webm', status: 'available', }); + expect(getCapability(capabilities, 'dashboard')).toEqual({ + name: 'dashboard', + status: 'available', + }); + }); + + it('marks the quick dashboard capability unavailable when libghostty-vt is missing', async () => { + const capabilities = await discoverCapabilities('quick', { + probePlaywright: () => Promise.resolve({ available: true }), + probeLibghosttyVt: () => + Promise.resolve({ + available: false, + reason: 'libghostty-vt not installed', + detail: 'Cannot find package @coder/libghostty-vt-node', + }), + }); + + expect(getCapability(capabilities, 'dashboard')).toEqual({ + name: 'dashboard', + status: 'unavailable', + reason: 'libghostty-vt not installed', + detail: 'Cannot find package @coder/libghostty-vt-node', + }); }); it('marks quick browser-backed capabilities unavailable when playwright is missing', async () => { const capabilities = await discoverCapabilities('quick', { + probeLibghosttyVt: () => Promise.resolve({ available: true }), probePlaywright: () => Promise.resolve({ available: false, @@ -190,5 +218,39 @@ describe('discoverCapabilities', () => { reason: 'renderer checks incomplete', detail: 'doctor did not provide the full renderer check set', }); + expect(getCapability(capabilities, 'dashboard')).toEqual({ + name: 'dashboard', + status: 'unknown', + reason: 'renderer checks incomplete', + detail: 'doctor did not provide the full renderer check set', + }); + }); + + it('derives the full dashboard capability from the libghostty_vt_available check', async () => { + const available = await discoverCapabilities('full', { + rendererChecks: [ + { name: 'libghostty_vt_available', status: 'pass', message: 'ok' }, + ], + }); + expect(getCapability(available, 'dashboard')).toMatchObject({ + name: 'dashboard', + status: 'available', + }); + + const missing = await discoverCapabilities('full', { + rendererChecks: [ + { + name: 'libghostty_vt_available', + status: 'skip', + message: 'libghostty-vt optional renderer not installed', + }, + ], + }); + expect(getCapability(missing, 'dashboard')).toEqual({ + name: 'dashboard', + status: 'unavailable', + reason: 'libghostty-vt unavailable', + detail: 'libghostty-vt optional renderer not installed', + }); }); }); diff --git a/test/unit/renderer/readiness.test.ts b/test/unit/renderer/readiness.test.ts new file mode 100644 index 00000000..705afb53 --- /dev/null +++ b/test/unit/renderer/readiness.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; + +import { CliError } from '../../../src/cli/errors.js'; +import { + assertDashboardRendererAvailable, + buildDashboardCapability, + probeLibghosttyVt, + type LibghosttyVtProbe, +} from '../../../src/renderer/readiness.js'; + +describe('probeLibghosttyVt', () => { + it('reports available when the native module exposes createTerminal', async () => { + const probe = await probeLibghosttyVt(() => + Promise.resolve({ createTerminal: () => ({}) }), + ); + + expect(probe.available).toBe(true); + }); + + it('reports unavailable when the loaded module is missing createTerminal', async () => { + const probe = await probeLibghosttyVt(() => Promise.resolve({})); + + expect(probe.available).toBe(false); + expect(probe.reason).toBe('libghostty-vt module is incomplete'); + }); + + it('reports unavailable with the import error detail when the module cannot load', async () => { + const probe = await probeLibghosttyVt(() => + Promise.reject( + new Error('Cannot find package @coder/libghostty-vt-node'), + ), + ); + + expect(probe.available).toBe(false); + expect(probe.reason).toBe('libghostty-vt not installed'); + expect(probe.detail).toContain( + 'Cannot find package @coder/libghostty-vt-node', + ); + }); +}); + +describe('assertDashboardRendererAvailable', () => { + it('does nothing when the renderer is available', () => { + expect(() => + assertDashboardRendererAvailable({ available: true }), + ).not.toThrow(); + }); + + it('throws an actionable INVALID_INPUT error naming the optional package when absent', () => { + let caught: unknown; + try { + assertDashboardRendererAvailable({ + available: false, + reason: 'libghostty-vt not installed', + detail: 'Cannot find package @coder/libghostty-vt-node', + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(CliError); + const error = caught as CliError; + expect(error.code).toBe('INVALID_INPUT'); + expect(error.message).toContain('@coder/libghostty-vt-node'); + expect(error.message).toContain('doctor'); + }); +}); + +describe('buildDashboardCapability', () => { + const available: LibghosttyVtProbe = { + available: true, + reason: 'libghostty-vt native module available', + detail: 'exposes createTerminal()', + }; + + it('returns a bare available entry in quick mode', () => { + expect(buildDashboardCapability(available, 'quick')).toEqual({ + name: 'dashboard', + status: 'available', + }); + }); + + it('includes reason and detail in full mode', () => { + expect(buildDashboardCapability(available, 'full')).toEqual({ + name: 'dashboard', + status: 'available', + reason: 'libghostty-vt native module available', + detail: 'exposes createTerminal()', + }); + }); + + it('reports unavailable with reason and detail when the probe failed', () => { + expect( + buildDashboardCapability( + { + available: false, + reason: 'libghostty-vt not installed', + detail: 'nope', + }, + 'full', + ), + ).toEqual({ + name: 'dashboard', + status: 'unavailable', + reason: 'libghostty-vt not installed', + detail: 'nope', + }); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json index 84ae5b33..69ae14d4 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -5,6 +5,12 @@ "outDir": "./dist", "tsBuildInfoFile": ".tsconfig.build.tsbuildinfo" }, - "include": ["src/**/*.ts"], - "exclude": ["test/**/*.ts", "dist", "coverage", "node_modules"] + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": [ + "test/**/*.ts", + "test/**/*.tsx", + "dist", + "coverage", + "node_modules" + ] } diff --git a/tsconfig.json b/tsconfig.json index 5d54eed3..0c850b64 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,8 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2024"], + "jsx": "react-jsx", + "jsxImportSource": "react", "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, @@ -22,7 +24,9 @@ }, "include": [ "src/**/*.ts", + "src/**/*.tsx", "test/**/*.ts", + "test/**/*.tsx", "evals/**/*.ts", ".sandcastle/**/*.ts", "vitest.config.ts"