mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc3f9f8a37 | ||
|
|
97acfc61c3 | ||
|
|
c27363459d | ||
|
|
737c2ed7f8 | ||
|
|
bb24d2c256 | ||
|
|
bc1821661f | ||
|
|
74c9879359 | ||
|
|
499d3d6e4d | ||
|
|
9b29666e03 | ||
|
|
aec6516638 | ||
|
|
c59f006bb6 | ||
|
|
b2ac6c1bd9 | ||
|
|
7b1150ca4f | ||
|
|
95a1f540b5 | ||
|
|
e7b7e90a1b | ||
|
|
84f8a8a2fe | ||
|
|
6ad9145417 | ||
|
|
ab4a868688 | ||
|
|
35b2c1baa5 | ||
|
|
bf860382a2 | ||
|
|
aa487f13ce | ||
|
|
0919f9da62 | ||
|
|
75b272ff0a | ||
|
|
1385415e53 | ||
|
|
954a9ac26a | ||
|
|
5fd6c5dd44 | ||
|
|
8b5fc974b0 | ||
|
|
a52abd9f96 | ||
|
|
008dfddc23 | ||
|
|
32549789c7 | ||
|
|
29d9a55eb6 | ||
|
|
ef812236b0 | ||
|
|
b51abe2c27 | ||
|
|
eb8958ddd0 | ||
|
|
953a560eee | ||
|
|
dc89f05b14 | ||
|
|
bb73400afa | ||
|
|
9d7dd2ab62 | ||
|
|
3f88226d50 | ||
|
|
e5ae2826c6 | ||
|
|
85b69e0923 | ||
|
|
6682231d68 | ||
|
|
e140132e45 | ||
|
|
6475c010a6 | ||
|
|
64c8048dda | ||
|
|
0566ea3453 | ||
|
|
6ef3b2ba2e | ||
|
|
74fe2cad9f | ||
|
|
aa2deea73e | ||
|
|
5d7fdb528b | ||
|
|
64cf8384f2 | ||
|
|
596c08d20c | ||
|
|
1d0c3650f9 | ||
|
|
b9afd5a57e | ||
|
|
14a911b4f6 | ||
|
|
9b1d269943 | ||
|
|
4e5d0dcbd6 | ||
|
|
f9d6c4f0c3 | ||
|
|
09d6bb9eb0 | ||
|
|
a922d301b1 | ||
|
|
abca552676 | ||
|
|
12a996b107 | ||
|
|
458e751a33 | ||
|
|
dab432b3fd | ||
|
|
79a0399552 | ||
|
|
61251c0b94 | ||
|
|
bb4ba79791 | ||
|
|
d7ad73bc9b | ||
|
|
96ee8b536d | ||
|
|
b7f3b07c79 | ||
|
|
2073a1b185 | ||
|
|
f9a8493823 | ||
|
|
d2711ef092 | ||
|
|
30a15adbd6 | ||
|
|
a35438ba34 | ||
|
|
fef903df98 | ||
|
|
02e7d3fcba | ||
|
|
f744719650 | ||
|
|
63c5ba89da | ||
|
|
7fc4784d0f | ||
|
|
fa7e700834 | ||
|
|
7936a75e28 | ||
|
|
07b9c7fd7b | ||
|
|
c00e054e0e | ||
|
|
68ae9a8c5f | ||
|
|
a49c30d173 | ||
|
|
d871d1913f | ||
|
|
ede3b05c10 | ||
|
|
c049de75db | ||
|
|
aae90599e5 | ||
|
|
2e58da7479 | ||
|
|
ba843bb272 | ||
|
|
756728e553 | ||
|
|
f2d3a7e3c1 | ||
|
|
5cc78669bd | ||
|
|
d4ccff07ca | ||
|
|
108c00e78d |
@@ -1,23 +0,0 @@
|
||||
---
|
||||
"aicodeman": patch
|
||||
---
|
||||
|
||||
Clear every production-reachable npm advisory, and fix a service-worker caching regression the upgrade exposed.
|
||||
|
||||
`npm audit` reported 20 advisories, but 16 were devDependencies-only (Remotion, Puppeteer, postcss, the eslint/tsx toolchain) and never reached anyone installing the package. Four reached production and are now resolved:
|
||||
|
||||
- **`@fastify/static` 9.1.3 to 10.1.3** — GHSA-8pvw-jcv7-9cmj, authorization bypass via non-canonical URL paths. The advisory covers `<=10.1.1`, so the entire 9.x line is affected and the fix only exists on 10.x.
|
||||
- **`find-my-way` 9.6.0 to 9.8.0** — GHSA-c96f-x56v-gq3h (HTTP/2 DDoS). Not exploitable here since Codeman does not enable HTTP/2, fixed anyway.
|
||||
- **`fast-uri` 3.1.2 to 3.1.5** — GHSA-v2hh-gcrm-f6hx, host confusion via a literal backslash authority delimiter.
|
||||
- **`brace-expansion` to 5.0.9 / 1.1.18** — GHSA-3jxr-9vmj-r5cp, exponential-time expansion DoS.
|
||||
|
||||
The last three were transitive and only needed a lockfile re-resolve; no `overrides` were added.
|
||||
|
||||
The `@fastify/static` major changes the `setHeaders` callback's first argument from a Node `ServerResponse` to a `FastifyReply`, which required two fixes:
|
||||
|
||||
- `res.setHeader()` became `reply.header()`. A v9-style body throws `TypeError: res.setHeader is not a function` from inside the plugin on every static request.
|
||||
- **That change also flips precedence, silently.** The callback used to write to the raw response and be overwritten by the route's staged reply headers; it now writes to the reply and wins instead. That handed `/sw.js` a year of `immutable` in place of the `no-cache, no-store` its route sets, which would pin a service worker on every client with no server-side way to recover. A route that already set `Cache-Control` now keeps it.
|
||||
|
||||
`ws` also appears in `npm audit` but production is already on 8.21.0, outside the vulnerable range; the only affected copy is bundled under `@remotion/renderer` and is dev-only.
|
||||
|
||||
Adds `test/static-cache-headers.test.ts`, which drives a real server and covers the caching contract that had no test at all, and moves the floors in `test/dependency-security.test.ts` up to the patched versions.
|
||||
@@ -93,8 +93,6 @@ packages/gesture-control/.vite/
|
||||
# Claude Code plan tracking
|
||||
plan.json
|
||||
|
||||
# Unfinished TUI (local development only)
|
||||
src/tui/
|
||||
.claude/
|
||||
media-assets/
|
||||
commands
|
||||
|
||||
@@ -1,5 +1,69 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.20.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Terminal input and scrollback fixes (PRs #327, #331):
|
||||
- IME punctuation preserved (#327): keyCode 229 / `Process` key events are now delegated to xterm's CompositionHelper instead of being suppressed, so an active Chinese IME committing numbers and full-width punctuation (,。!? and friends) reaches the terminal correctly. The CJK input field sends the browser's committed text instead of guessing from `KeyboardEvent.key`, and the redundant Android orphan-input fallback is removed so xterm is the single input owner.
|
||||
- Shell history replay bounded (#331): selecting a Shell session loads a bounded 1 MiB tail instead of replaying the entire multi-megabyte tmux scrollback on xterm's main thread; full history stays available via the explicit "Load full history" action. tmux history limits now apply correctly on both legacy tmux (global default set in the same command queue before pane creation) and tmux 3.7+ (per-pane targeting that never resizes or trims unrelated live panes). Also adds `Server-Timing` and `[TERMINAL-PERF]` timing stages for terminal loads, fixes `scrollToLastNonEmptyLine` double-counting scrollback rows, and keeps live output ordered behind snapshot replays.
|
||||
|
||||
### Thanks
|
||||
- @dignfei for both fixes: the IME punctuation root-cause fix (#327) and the bounded shell history replay with the tmux history-limit correctness work (#331).
|
||||
|
||||
## 1.20.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Response viewer for OpenCode, Gemini, Antigravity and Pi sessions (#326). External CLIs render their own TUIs, so the viewer used to come up empty for them; a new transcript parser (`response-viewer-transcript.ts`) reconstructs the conversation from the pane text instead, and the `?context=full` view now tags every block with a role so prompts render as "You" and agent output as the assistant. The divider normalizer was rewritten as a linear scan after review found catastrophic backtracking on agent-controlled input (minutes of stall on a long dash run), with an equivalence corpus pinning the old accept set.
|
||||
|
||||
CLIs installed via nvm or Homebrew are now found when Codeman runs as a service (#329). A shared resolver falls back to a login-shell probe when the direct PATH lookup misses, so systemd and LaunchAgent installs no longer report every CLI as missing. Review hardening on top: a failed resolution is negative-cached with doubling backoff instead of re-spawning a login shell on every request, all probes pass `killSignal: 'SIGKILL'` (interactive bash shrugs off SIGTERM, and a blocking `.bash_profile` could have hung the server indefinitely), the resolvers are inert under vitest again so test suites cannot execute binaries found on the dev box, and the improved not-found guidance is wired into both the session-create errors and the per-CLI status endpoints.
|
||||
|
||||
`GET /api/system/repo-status` reports branch, upstream, ahead/behind and remote reachability for git-clone installs (#328). Review hardening: the git network calls moved off the synchronous path onto a single-flight 45s cache (one slow remote could previously freeze the whole server for up to a minute per request), remote URLs and git stderr are credential-redacted before they leave the server, the spawns use the same non-interactive git env as the clone path, and a local-branch upstream no longer parses into garbage.
|
||||
|
||||
Auto Copy for the terminal (#325, opt-in, per-device): a finished selection (mouse drag, double or triple click, or a phone long-press) lands on the clipboard by itself, so select-then-copy becomes select. Alongside it, hand-encoded tap reports are now gated on the server-observed `cliMouseTracking` state, so a pane that has fallen back to a plain shell no longer receives `[<0;88;20M` junk on tap.
|
||||
|
||||
The Ralph loop no longer stops polling after two ticks (#330): the reschedule guard read a stale timer handle that the timer callback never cleared, so the loop silently died while its status stayed `running`. The handle is now nulled as the callback's first statement, and a regression test pins the bug.
|
||||
|
||||
The red "needs you" tab alert clears when a dialog is answered in the terminal instead of surviving until the end of the turn: the post-hook re-capture could erase the parsed dialog options that the staleness sweep relies on (`applyCapture` is now add-only for options), and a delayed staleness pass now runs while a page is open. The unreachable `copyTerminal()` was removed, closing out #322.
|
||||
|
||||
### Thanks
|
||||
- @aakhter contributed the external-CLI response viewer (#326), the repo-status endpoint (#328), the login-shell CLI resolution (#329) and the Ralph reschedule fix (#330)
|
||||
- @rounakdatta reported the mobile copy gap (#322) closed out in this release
|
||||
|
||||
## 1.19.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Mobile catches up: links open from a tap, terminal text can be selected and copied, long prompts stay visible while you type. Plus Files panel search, a bundled Nerd Font symbols fallback, and a per-device terminal font setting.
|
||||
- **Terminal and chat links work on phones** (#321): tapping a URL or file path in terminal output now opens it (new tab, file preview, or log viewer), resolved through the same provider desktop hover uses, so tap and click can never disagree about what is a link. Dialog rows and the composer keep their existing meaning. Response-viewer links open in a new tab with `rel="noopener noreferrer"` instead of navigating the dashboard away. Wrapped links open whole: the logical-line reconstruction now stitches hard wraps through the indent their continuation carries, which also fixes desktop hover-click truncating wrapped URLs.
|
||||
- **Terminal text can be copied on touch devices** (#321): long-press selects the token under the finger, drag or tap the other end to extend, and a small bar offers Copy, Line (the whole logical line, wraps included) and dismiss. Copy works on plain-HTTP installs too. Three guards keep the keyboard down and the selection alive through the browser's own long-press handling.
|
||||
- **A long prompt stays visible on phones** (#321): the local-echo overlay grows upward once it would run past the last visible row (a prompt taller than the screen keeps its tail, where the cursor is), and the keyboard-driven padding shrink can no longer reclaim the space the fixed toolbar and accessory bar stand in.
|
||||
- **Files panel search** (#324): `GET /api/sessions/:id/files?q=...` answers a flat match list (name or path substring, `*`/`?` globs), recursing past non-matching directories with its own match cap on top of the existing bounds; without `q` the response is byte-identical to before. Glob queries are matched without regex so a pathological pattern cannot stall the server.
|
||||
- **Nerd Font prompt glyphs out of the box, custom terminal font** (#320): a bundled icons-only Symbols Nerd Font Mono fallback renders powerlevel10k/starship/oh-my-posh glyphs on every device with no font install, and App Settings gains a per-device terminal font family that is prepended to the built-in stack.
|
||||
|
||||
### Thanks
|
||||
|
||||
Three contributor PRs in one release: thanks to @rounakdatta (#321), @aakhter (#324) and @comzine (#320).
|
||||
|
||||
- 8a54b33: Clear every production-reachable npm advisory, and fix a service-worker caching regression the upgrade exposed.
|
||||
|
||||
`npm audit` reported 20 advisories, but 16 were devDependencies-only (Remotion, Puppeteer, postcss, the eslint/tsx toolchain) and never reached anyone installing the package. Four reached production and are now resolved:
|
||||
- **`@fastify/static` 9.1.3 to 10.1.3** — GHSA-8pvw-jcv7-9cmj, authorization bypass via non-canonical URL paths. The advisory covers `<=10.1.1`, so the entire 9.x line is affected and the fix only exists on 10.x.
|
||||
- **`find-my-way` 9.6.0 to 9.8.0** — GHSA-c96f-x56v-gq3h (HTTP/2 DDoS). Not exploitable here since Codeman does not enable HTTP/2, fixed anyway.
|
||||
- **`fast-uri` 3.1.2 to 3.1.5** — GHSA-v2hh-gcrm-f6hx, host confusion via a literal backslash authority delimiter.
|
||||
- **`brace-expansion` to 5.0.9 / 1.1.18** — GHSA-3jxr-9vmj-r5cp, exponential-time expansion DoS.
|
||||
|
||||
The last three were transitive and only needed a lockfile re-resolve; no `overrides` were added.
|
||||
|
||||
The `@fastify/static` major changes the `setHeaders` callback's first argument from a Node `ServerResponse` to a `FastifyReply`, which required two fixes:
|
||||
- `res.setHeader()` became `reply.header()`. A v9-style body throws `TypeError: res.setHeader is not a function` from inside the plugin on every static request.
|
||||
- **That change also flips precedence, silently.** The callback used to write to the raw response and be overwritten by the route's staged reply headers; it now writes to the reply and wins instead. That handed `/sw.js` a year of `immutable` in place of the `no-cache, no-store` its route sets, which would pin a service worker on every client with no server-side way to recover. A route that already set `Cache-Control` now keeps it.
|
||||
|
||||
`ws` also appears in `npm audit` but production is already on 8.21.0, outside the vulnerable range; the only affected copy is bundled under `@remotion/renderer` and is dev-only.
|
||||
|
||||
Adds `test/static-cache-headers.test.ts`, which drives a real server and covers the caching contract that had no test at all, and moves the floors in `test/dependency-security.test.ts` up to the patched versions.
|
||||
|
||||
## 1.19.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -658,6 +658,22 @@ These run for **every** request — before auth, even on the default no-password
|
||||
|
||||
---
|
||||
|
||||
## Terminal UI (`codeman tui`)
|
||||
|
||||
A full-screen dashboard for your sessions, in the terminal. Same states as the web UI, because it is a client of the same server:
|
||||
|
||||
```bash
|
||||
codeman tui # the dashboard
|
||||
codeman tui --list # numbered session list, then exit (scriptable)
|
||||
codeman tui 2 # attach straight to session 2 of that list
|
||||
```
|
||||
|
||||
Sessions are grouped **NEEDS YOU → WORKING → IDLE → RECENT**, longest-waiting first. `↑↓`/`j`/`k` select, `1`-`9` and `[`/`]` switch between sessions, `Enter` attaches into the tmux pane (**`F1`** to come back). Inside a pane the bar across the top keeps the session strip visible and `Alt+1`-`Alt+9` switch without leaving. `y`/`n`/digit answer a pending permission dialog right from the list, `p` sends a one-line prompt, `n` starts a session and opens straight into it, `x` kills one (`y` confirms), `/` searches, `g` shows the away digest, `?` is help, `q` quits. Below 72 columns it drops the preview pane and becomes a single-column list, so it stays usable in Termius on a phone. With no server running it still starts in attach-only degraded mode.
|
||||
|
||||
The web UI remains the primary surface; see **[docs/tui.md](docs/tui.md)** for the full guide.
|
||||
|
||||
---
|
||||
|
||||
## SSH Alternative (`sc`)
|
||||
|
||||
If you prefer SSH (Termius, Blink, etc.), the `sc` command is a thumb-friendly session chooser:
|
||||
@@ -668,7 +684,7 @@ sc 2 # Quick attach to session 2
|
||||
sc -l # List sessions
|
||||
```
|
||||
|
||||
Single-digit selection (1-9), color-coded status, token counts, auto-refresh. Detach with `Ctrl+A D`.
|
||||
Single-digit selection (1-9), color-coded status, token counts, auto-refresh. `sc` hands you to a plain `tmux attach`, so you leave the pane with tmux's own detach chord: `Ctrl+B`, release, then `d`. (`codeman tui` is the one that gives you a single `F1` instead, because its attach claims that key for the duration.)
|
||||
|
||||
---
|
||||
|
||||
@@ -893,7 +909,9 @@ codeman session start -d /path/to/repo # (s) start a session
|
||||
codeman session list # list sessions
|
||||
codeman session logs <id> # tail output
|
||||
codeman task add "fix the failing test" # (t) queue a task
|
||||
codeman attach <path> # attach a Claude hook context
|
||||
codeman attach <path> # show an attachment card for a local file
|
||||
codeman tui --list # numbered session list (plain text when piped)
|
||||
codeman tui 3 # attach to session 3 of that list
|
||||
```
|
||||
|
||||
### Hooks (events flowing _back_ to Codeman)
|
||||
|
||||
@@ -14,7 +14,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
||||
|
||||
### Instance isolation and the multi-instance attach danger
|
||||
|
||||
**Instance isolation / multi-instance attach danger** — data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`). ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions** (`tmux -L codeman attach-session …`), resizing/mutating them — `$HOME` isolation is NOT enough (tmux is system-global). To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes BOTH dir+socket: `~/.codeman-<name>` + `-L codeman-<name>`), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually. **`CODEMAN_INSTANCE` defaults to empty = the production layout (`~/.codeman`, `-L codeman`, port 3000)**, so this branch is safe to ship to master without disturbing existing installs. To run THIS beta alongside prod, launch with `scripts/run-beta.sh` (`CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`) — it never collides with prod's data dir/socket/port. Any new `~/.codeman/...` path MUST go through `dataPath()`, never `join(homedir(), '.codeman', …)`.
|
||||
**Instance isolation / multi-instance attach danger** — data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`). ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions** (`tmux -L codeman attach-session …`), resizing/mutating them — `$HOME` isolation is NOT enough (tmux is system-global). To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes BOTH dir+socket: `~/.codeman-<name>` + `-L codeman-<name>`), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually. **`CODEMAN_INSTANCE` defaults to empty = the production layout (`~/.codeman`, `-L codeman`, port 3000)**, so this branch is safe to ship to master without disturbing existing installs. To run THIS beta alongside prod, launch with `scripts/run-beta.sh` (`CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`) — it never collides with prod's data dir/socket/port. Any new `~/.codeman/...` path MUST go through `dataPath()`, never `join(homedir(), '.codeman', …)`, and any new `tmux -L` caller through `resolveTmuxSocketName()` (same module): it applies the `CODEMAN_TMUX_SOCKET` override only when the name is safe and falls back to `DEFAULT_TMUX_SOCKET` otherwise. `TmuxManager` was the only such caller until `codeman tui` shelled out to tmux from a SECOND process for its degraded-mode listing and its attach handoff; a hardcoded `codeman` there would have pointed a beta instance straight at prod's panes.
|
||||
|
||||
## Session launch modes
|
||||
|
||||
@@ -92,13 +92,15 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
||||
|
||||
### Full-scrollback replay
|
||||
|
||||
**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -<lines>` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load OF EACH SESSION per page load requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other tab one frame of history); later switches keep the cheap `?tail=` visible-frame path. On top of that, scrolling up while already at the TOP of the buffer re-pulls `full=1` on demand (`_maybeRefetchFullHistory`, 4s per-session cooldown, in-flight + tab-switch guards, viewport position held across the replay). The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. ⚠️ **The re-pull must never DOWNGRADE the buffer** (#205 round 2): the same reasoning that makes it a win for a shell pane makes it destructive for a repaint-mode CLI pane, where tmux keeps no history of its own (`history_size≈0` measured for a Claude pane) and the capture is roughly ONE frame while xterm may hold hundreds of rows of replayed frames — `_resetTerminalForReplay()` + rewrite then deletes history mid-scroll ("goes back a bit, repeats blocks, gets worse the further up I go"; measured A/B on a live pane: 341 rows → 42 with the guard off). `_replayWouldShrinkBuffer()` (terminal-ui.js) estimates the capture's rendered rows — escape sequences stripped, `capture-pane -J` re-wrapping accounted for — and the pull is skipped when that is more than one screen short of `buffer.active.length`. The one-screen tolerance matters: both sides are estimates (the buffer length counts trailing blank rows), so only a clear downgrade is refused. A refused session joins `_fullHistoryRepullUseless`, raising its cooldown from 4s to 60s so a hollow pane stops re-fetching megabytes on every scroll-up. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`, `test/terminal-scroll-routing.test.ts`.
|
||||
**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -<lines>` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other TUI tab one frame of history). Shell sessions instead load a bounded 1 MiB `?tail=` window on every selection: a 100k-line shell capture can be tens of MiB, and automatically parsing it makes tab-switch latency scale with the entire session. Shell full history is therefore explicit-button-only; reaching the top during an ordinary wheel/touch gesture must not reset xterm and replay the multi-megabyte capture on its main thread. Other modes may still re-pull `full=1` at the TOP, and pressing **Load full history** forces the request for any recoverably truncated session (`_maybeRefetchFullHistory`, 4s per-session gesture cooldown, in-flight + tab-switch guards, viewport position held across the replay); Shell full pulls are not retained in the tab cache, so the next switch stays bounded. Chunked replay enqueues 32 KiB pieces across safe yields, appends an xterm parse marker, then releases the live-output gate; output arriving after that release stays ordered behind the snapshot, while the marker callback supplies accurate parse timing without extending the pre-existing queued-event discard window. The route exposes capture/prepare totals in `Server-Timing`, while `[TERMINAL-PERF]` separates TTFB, body/JSON, reset+parse and total time for both selection and on-demand full pulls; parse completion is not a browser compositor/GPU paint measurement. The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. ⚠️ **The re-pull must never DOWNGRADE the buffer** (#205 round 2): the same reasoning that makes it a win for a shell pane makes it destructive for a repaint-mode CLI pane, where tmux keeps no history of its own (`history_size≈0` measured for a Claude pane) and the capture is roughly ONE frame while xterm may hold hundreds of rows of replayed frames — `_resetTerminalForReplay()` + rewrite then deletes history mid-scroll ("goes back a bit, repeats blocks, gets worse the further up I go"; measured A/B on a live pane: 341 rows → 42 with the guard off). `_replayWouldShrinkBuffer()` (terminal-ui.js) estimates the capture's rendered rows — escape sequences stripped, `capture-pane -J` re-wrapping accounted for — and the pull is skipped when that is more than one screen short of `buffer.active.length`. The one-screen tolerance matters: both sides are estimates (the buffer length counts trailing blank rows), so only a clear downgrade is refused. A refused session joins `_fullHistoryRepullUseless`, raising its cooldown from 4s to 60s so a hollow pane stops re-fetching megabytes on every scroll-up. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`, `test/terminal-scroll-routing.test.ts`.
|
||||
|
||||
### Terminal scrollback: strip flavors and wheel/touch forwarding
|
||||
|
||||
**Two strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. Every other mode (shell/opencode/antigravity/pi) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same narrow branch; the frontend `_sessionUsesServerMouseStrip()` mirror stays claude/codex/gemini because only the FULL strip touches mouse DECSETs. The chunk-boundary carry (`_altScreenSeqCarry`) runs for both flavors. Tests: `test/claude-scrollback-strip.test.ts`.
|
||||
**Two strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. Every other mode (shell/opencode/antigravity/pi) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same narrow branch; the frontend mirror (`_shouldReportMouseToCli()`) stays claude/codex/gemini because only the FULL strip touches mouse DECSETs. The chunk-boundary carry (`_altScreenSeqCarry`) runs for both flavors. Tests: `test/claude-scrollback-strip.test.ts`.
|
||||
|
||||
**Only claude ≥ 2.1.187 forwards the wheel; every other mode scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS stay enabled for codex (`_sessionUsesServerMouseStrip`); measured, they are no-ops that insert nothing, so click-to-position is simply unavailable there rather than harmful.
|
||||
⚠️ **What the full strip removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously (so clicks keep being reported exactly as before), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach.
|
||||
|
||||
**Only claude ≥ 2.1.187 forwards the wheel; every other mode scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (strip mode AND the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk.
|
||||
|
||||
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 — version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
|
||||
|
||||
@@ -258,6 +260,22 @@ Invariants:
|
||||
|
||||
Copy goes through `_copyText()` (Clipboard API, then hidden-textarea + `execCommand`), not raw `navigator.clipboard`, because `install.sh`'s LAN option serves plain HTTP where `navigator.clipboard` is undefined; the fallback steals focus, so the terminal is refocused afterwards. Related: xterm registers its own `copy` listener on the terminal element gated on `hasSelection()`, which is why right-click → Copy has always worked. Selection itself is unavailable on touch devices by design (`user-select: none` on the terminal subtree), and in `shell`/`opencode`/`antigravity` tabs the TUI owns the mouse, so selecting there needs Shift+drag. Tests: `test/terminal-copy-selection.test.ts` (gate + wiring invariants), `test/terminal-copy-shortcut.test.ts` (browser, real key presses).
|
||||
|
||||
### Auto Copy (copy-on-select)
|
||||
|
||||
**Auto Copy** (`autoCopySelection`, per-device, default OFF) puts a finished terminal selection on the clipboard without a keystroke. It is a thin layer over the smart-copy machinery above and shares `_copyText()` with it, but the two paths differ in every decision that matters:
|
||||
|
||||
- **It fires at the END of a gesture, never on selection change.** `onSelectionChange` runs for every cell a drag crosses, so copying there would be one clipboard write per mouse move. The callback only ARMS `_autoCopyPending`; the flush is a document-level `mouseup` listener installed once in `initTerminal`, plus explicit calls from the touch selection path.
|
||||
- **The flush is synchronous inside the handler.** Both clipboard paths need user activation: Firefox gates `navigator.clipboard.writeText` on it, and `document.execCommand('copy')` (the plain-HTTP fallback that `install.sh`'s LAN option lands on) has to run in the gesture's own task. Deferring to a timer or waiting for `onSelectionChange` loses it, and the failure is browser-specific and invisible in Chrome.
|
||||
- **The listener is on `document`, not the terminal container**, because a drag that ends outside the terminal (sweeping up past the header) delivers its mouseup to the document. Unrelated mouseups elsewhere on the page are filtered by the decision helper, not by the listener's target.
|
||||
- **Touch has its own entry point.** `_endTouchSelectionGesture()` and `_selectTouchSelectionLine()` call the flush directly, because the touch path `preventDefault()`s its touchend (that is what stops the compat mouse pair from stealing the selection back), so no mouseup ever reaches the document there. Without those two calls the toggle is simply dead on a phone.
|
||||
- **It must NOT do what `copyTerminalSelection()` does.** That one clears the selection (so a second `Ctrl+C` is an interrupt) and focuses the terminal. Clearing would make text vanish from under the cursor that just highlighted it, and focusing opens the on-screen keyboard over it on a phone. Focus is instead RESTORED to whatever held it before the copy, which only matters for the `execCommand` fallback (it focuses a temp textarea on the way through); the Clipboard API path never moves focus at all.
|
||||
|
||||
`decideAutoCopy()` (constants.js, pure) holds the guards: setting off, blank or whitespace-only text (what a drag across empty cells produces), and a `AUTO_COPY_MAX_CHARS` (1M) cap. ⚠️ The cap is not decoration: a drag off the top of the viewport autoscrolls, so one gesture can sweep the whole 50k-line scrollback. Past it the copy is REFUSED rather than truncated, with a toast pointing at `Ctrl+C`, which still copies everything through the explicit path.
|
||||
|
||||
⚠️ **Two dedupe rules, and both earn their place.** A genuine selection change (`pending`) always copies, so re-selecting the same text after copying something else in between still works. Otherwise only text differing from the last auto-copy does, which is what stops an unrelated mouseup from re-copying a stale selection AND what makes the first copy of a drag work at all: xterm fires `onSelectionChange` from its own document `mouseup` handler, and listener order between the two is registration order, not something this code controls. Gating on `pending` alone silently drops that first copy.
|
||||
|
||||
Feedback is silent on success except ONCE per page load (a feature that works by doing nothing visible cannot otherwise be told from a dead toggle); failures and refusals toast, throttled to 10s so a permanently blocked clipboard cannot paint a toast on every drag. The setting is per-device on both counts required by the settings rule: it is in `displayKeys` AND absent from the `.strict()` `SettingsUpdateSchema` (clipboard access differs by device and by origin, and the plain-HTTP LAN install has no `navigator.clipboard` at all). Tests: `test/terminal-auto-copy.test.ts`.
|
||||
|
||||
### Settings surface: App Settings, Session Options, Add Case
|
||||
|
||||
**One visual language, three modals.** `#appSettingsModal`, `#sessionOptionsModal` and `#createCaseModal` share the `set-*` surface (left rail, sections of grouped row cards, label + description on the left, control pinned right) through a single `:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal)` scope in `styles.css`. An `:is()` list takes the specificity of its **most specific argument**, and all three arguments are ids, so every rule kept exactly the weight it had when the block was `#appSettingsModal`-only: nothing downstream shifted in the cascade. That property is what let the surface absorb Session Options and then Add Case in two separate commits without a cascade audit each time.
|
||||
@@ -359,7 +377,7 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se
|
||||
|
||||
### Buffers, uploads, and terminal history
|
||||
|
||||
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired live); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
|
||||
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
|
||||
|
||||
## Local packages and build artifacts
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
# Codeman TUI Rework Plan
|
||||
|
||||
Status: **phases 0-2 implemented** on `feat/tui`; phases 3-4 remain follow-ups. The user guide is [`docs/tui.md`](tui.md); this document stays the design record.
|
||||
|
||||
- Phase 0: `src/cli-style.ts` (palette, glyphs, `heading`/`kv`/`table`/`spinner`/`confirm`) plus the mechanical fixes of §5, and `test/cli-commands.test.ts` now derives its inventory from the real commander `program` instead of parsing a fixture.
|
||||
- Phases 1-2: `src/tui/`. `tui-app.ts` (main loop, attach handoff, verbs) and `tui-client.ts` (API, SSE, degraded enumeration) are the only IO; `tui-model`, `tui-layout`, `tui-render`, `tui-keys`, `tui-ansi`, `tui-composer`, `tui-approvals`, `tui-digest`, `tui-sse` and `tui-types` are pure and unit-tested, with an E2E suite driving the real binary under node-pty.
|
||||
- Deferred with the rest of phase 3: `r` (resume a RECENT row) is not wired up, so the help overlay does not advertise it.
|
||||
- Not started: phase 3 (mouse, `--pick` popup switcher, opt-in attach status line, OSC 9) and phase 4 (retiring the bash choosers).
|
||||
|
||||
The goal: replace Codeman's scattered terminal surfaces with one first-class TUI, `codeman tui`, that gives SSH/terminal users the same at-a-glance awareness the web UI gives browsers. The reference point is herdr (herdr.dev), the trending Rust "agent multiplexer" whose defining feature is a live agent-state sidebar. Codeman can match and beat that sidebar in the terminal because the states herdr infers from screen-scraping heuristics are states our server already computes from hooks, pane probing, and the approvals inbox.
|
||||
|
||||
---
|
||||
|
||||
## 1. What we have today (inventory)
|
||||
|
||||
Three disconnected surfaces, three visual idioms, two data sources:
|
||||
|
||||
| Surface | What it is | Data source | Idiom |
|
||||
| --- | --- | --- | --- |
|
||||
| `codeman` CLI (`src/cli.ts`, 1214 lines) | commander + chalk, ~20 commands | HTTP API + state files | `✓`/`✗` line-per-fact, no interactivity |
|
||||
| `sc` (`scripts/tmux-chooser.sh`, 663 lines) | bash number-menu chooser, mobile-tuned (44 cols) | `tmux -L codeman` + `state.json` via jq | 256-color, numbered, full repaint per key |
|
||||
| `scripts/tmux-manager.sh` (529 lines) | bash cursor TUI with kill/info | `mux-sessions.json` (and writes it back) | 8-color, box-drawn, arrow keys |
|
||||
|
||||
Weaknesses found in the audit (file:line refs verified 2026-08-16):
|
||||
|
||||
1. **No interactive picker in the Node CLI at all.** Every `session stop`, `task status`, `session logs` requires a pasted UUID prefix. There is no `codeman attach <session>`; `codeman attach` is actually the attachment-card command (and `README.md:895` describes it wrongly).
|
||||
2. **`sc` cannot reach sessions 10+ interactively**: entries are numbered globally (`tmux-chooser.sh:343`) but input accepts a single `[1-9]` keypress (`:487-493`). Page 2 shows items 8-14 that mostly cannot be selected.
|
||||
3. **No cursor/selection concept in `sc`** (`BG_SEL` at `:90` is dead code); arrows only page.
|
||||
4. The two bash tools can disagree about which sessions exist (different data files), and only `sc` is on PATH.
|
||||
5. **Zero live feedback anywhere**: `codeman web -d` and `service install` block silently up to 30s (`daemon-control.ts:395-412`); no spinner exists in the codebase.
|
||||
6. Styling drift: `doctor` is the only table and is deliberately monochrome with a colorize hook nobody wired up (`dependency-report.ts:5-7`); `codeman web` prints its "running at" line twice (colored `cli.ts:934`, plain `server.ts:2366`); the server's security warning is colorless `console.warn` while the CLI's version of the same warning is yellow; `tmux-manager.sh`'s header box is visibly misaligned; `padEnd(14)` overflows on "Antigravity CLI".
|
||||
7. Bash TUIs emit raw escapes unconditionally (no TTY/NO_COLOR gate); `install.sh` and `postinstall.js` do it right.
|
||||
8. Detach hint inconsistency: chooser says Ctrl+B D, `README.md:671` says Ctrl+A D.
|
||||
9. Inside an attached session there is **no chrome at all**: Codeman turns the tmux status bar off (`tmux-manager.ts:1978`), so an SSH user in a pane has no session identity, no state, no way back to a picker except detach.
|
||||
10. `test/cli-commands.test.ts` asserts against a hand-written fixture, not the real `program`, and that fixture already lists a `tui` command that does not exist (`:57-61`). The name is pre-approved by our own test file.
|
||||
|
||||
## 2. Research: how herdr does it
|
||||
|
||||
herdr (github.com/herdrdev/herdr, ~30k stars, single Rust binary, pre-1.0) is a background terminal multiplexer "your coding agents live on". What matters for us:
|
||||
|
||||
- **The agent-state sidebar is the product.** Every pane is classified live as `working` / `blocked` / `done` / `idle` and grouped in a sidebar, so you see who needs you without switching tabs. Reviews unanimously call this "the killer feature tmux can't match".
|
||||
- **Detection is heuristic-first**: process-name matching + screen-manifest TOML rules parsing the visible frame; optional per-agent "integration install" adds lifecycle hooks over JSON-RPC on a unix socket for accurate states. Claude Code there is on the heuristic path and reviewers note blocked-state lag.
|
||||
- **Model**: workspaces → tabs → panes, tmux-style prefix keys (Ctrl+B V split, arrows navigate, D detach), mouse-first (click select, drag resize, right-click menus, touch over SSH), adapts to narrow widths.
|
||||
- **Agent-shaped API**: socket API with `pane read` (visible/recent/detection), `send-text`/`send-keys`/`run`, `agent start|prompt|wait|explain`, `pane wait-output` with regex, plugins placed as overlay/split/tab/popup.
|
||||
- **Persistence**: sessions survive disconnects, reattach from any terminal / SSH.
|
||||
- Weaknesses reviewers cite: pre-1.0 churn, bus factor 1, no session resurrection, rendering lag with many panes.
|
||||
|
||||
What is striking is how much of herdr Codeman already has, server-side: our hooks give exact `permission_prompt`/`stop`/`idle_prompt` events (herdr's "integration" path, but installed by default), `_confirmIdle()` does the screen-probe fallback, the approvals inbox parses the actual dialog options, and the agent skill + wait primitives are our socket API. What we lack is purely the presentation layer in the terminal.
|
||||
|
||||
Prior art for the architecture we want: **agent-deck** (Bubble Tea + tmux) proves the "TUI list + attach into tmux" model works great: session list with live glyphs (● ◐ ○ ✕), Enter attaches into a tmux pane, status polling, groups, fuzzy search. We take the shape, not the code.
|
||||
|
||||
Licensing note: herdr is reported variously as Apache-2.0/AGPL-3.0. Irrelevant either way: we copy concepts, never code.
|
||||
|
||||
### What we take / what we skip
|
||||
|
||||
Take: the four-state sidebar as the organizing principle; grouping by "needs you first"; narrow-width adaptation; mouse support; tmux-familiar keys; the "attention at a glance" framing.
|
||||
|
||||
Skip: being a multiplexer. tmux already backs every Codeman session and is a hard dependency; herdr had to build pane management because it owns terminals, we do not. Also skip (for now): plugin marketplace, split layouts, pane drag. Our TUI is a **dashboard + switchboard over tmux**, not a tmux replacement.
|
||||
|
||||
## 3. Design: `codeman tui`
|
||||
|
||||
One command, one full-screen client of the existing HTTP/SSE API.
|
||||
|
||||
**Positioning (owner decision, 2026-08-16): the web UI remains THE primary surface.** The TUI is strictly additive, for users who want a terminal workflow (SSH, Termius, tmux die-hards). Bare `codeman` keeps printing help; nothing existing changes behavior. The `sc` bash chooser also stays untouched for now; flipping its alias to `codeman tui` is deferred to a follow-up release once the TUI has mileage.
|
||||
|
||||
### Layout (≥100 cols)
|
||||
|
||||
```
|
||||
codeman tnode · v1.19.0 · 6 sessions · 5h ▂▂▅ 32% wk 61% ? help q quit
|
||||
────────────────────────────────────────────────────────────────────────────────────────────
|
||||
NEEDS YOU ──────────────────────────┐ ┌ w4-api-refactor ── claude · ~/dev/api ────────────
|
||||
▶ 1 w4-api-refactor ⚠ approval 2m │ │ ✻ Actualizing… (2m 14s · ↓ 12.3k tokens)
|
||||
2 w6-docs ✋ waiting 11m │ │
|
||||
│ │ ⚠ Claude requests: Bash(git push origin main)
|
||||
WORKING ────────────────────────────┤ │ 1. Yes 2. Yes, don't ask again 3. No
|
||||
3 w1-codeman ✻ 17m 45.2k │ │
|
||||
4 w2-gallery ✻ 3m 8.1k │ │ [y] approve [n] deny [Enter] attach
|
||||
IDLE ───────────────────────────────┤ │
|
||||
5 w3-promo ○ 2h │ │ …live tail of the selected session's
|
||||
RECENT ─────────────────────────────┤ │ terminal (ANSI colors preserved),
|
||||
· api-hotfix ✔ done Fri │ │ updating while you browse the list…
|
||||
────────────────────────────────────────────────────────────────────────────────────────────
|
||||
↑↓ select · ⏎ attach · 1-9 jump · y/n answer · p prompt · n new · x kill · / search · g digest
|
||||
```
|
||||
|
||||
- **Header**: hostname/instance, server version, session count, plan-usage chip (same telemetry that feeds the web chip, when available). Degrades gracefully when the server is down (see §3.6).
|
||||
- **Sidebar**: sessions grouped `NEEDS YOU` → `WORKING` → `IDLE` → `RECENT` (past sessions from the unified list, resumable). Within groups, reuse the activity ordering already built for the home screens in PR #303 (blocked first, running longest, quiet newest); that logic is pure and shared.
|
||||
- **Preview pane**: live tail of the selected session, SGR colors preserved, cursor-movement stripped. When the selected session has a pending approval, the parsed dialog is rendered as a card above the tail with one-key answer bindings.
|
||||
- **Footer**: contextual keymap (changes when a dialog/confirm is active).
|
||||
|
||||
### States and vocabulary
|
||||
|
||||
Exactly the web's language so the two surfaces read the same:
|
||||
|
||||
| Group | Glyph | Color | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| NEEDS YOU (question/permission) | `⚠` | red, blinking row | approvals inbox / `permission_prompt` |
|
||||
| NEEDS YOU (waiting for input) | `✋` | yellow | `idle_prompt` / waiting classification |
|
||||
| WORKING | `✻` animating through `· ✢ ✳ ∗ ✻ ✽` at 2Hz | green | working classification (the same glyph family Claude itself draws, a deliberate nod) |
|
||||
| IDLE | `○` | muted | idle |
|
||||
| RECENT / done | `✔` | muted green | unified list history rows |
|
||||
|
||||
Nerd-font/glyph fallback exactly like `sc` does today (`[!] [w] [*] [-] [ok]` when the terminal is not known-capable), plus full NO_COLOR / `tput colors` degradation (8-color and mono renderings are designed, not accidental).
|
||||
|
||||
### Keymap
|
||||
|
||||
- `↑/↓` or `j/k` select · `Enter` attach · `1-9` jump-attach (parity with `sc`, but now the cursor covers 10+)
|
||||
- `y`/`n` (or the digit keys) answer the selected session's pending approval right from the dashboard, via `POST /api/approvals/:id/answer`. The server already re-captures the pane and 409s if the dialog is gone, so this is safe by construction.
|
||||
- `p` send a one-line prompt to the selected session without attaching (`POST /input` with `\r`, the composer opens in the footer)
|
||||
- `n` new session (case picker → mode picker, drives `POST /api/quick-start`) · `x` kill with typed confirm (never bulk; refuses the session hosting the TUI itself, like tmux-manager.sh does)
|
||||
- `/` fuzzy search across sessions/history/attachments (`GET /api/search`) · `g` away digest (`GET /api/away-digest`) rendered as a panel
|
||||
- `r` resume selected RECENT row (unified list `resume-session` flow) · `?` help overlay · `q` quit
|
||||
- Mouse (phase 3): SGR mouse reporting, click selects, wheel scrolls list/preview, click on footer keys triggers them. Works over SSH, same as herdr's touch story.
|
||||
|
||||
### Responsive behavior
|
||||
|
||||
The `sc` design constraint survives: below ~72 cols (Termius, iPhone portrait) the preview pane drops and the TUI is a single-column list with two-line rows, nearly identical to today's `sc` but with a cursor, live states, and the answer/prompt/new/kill verbs. The layout switch is width-driven at draw time, no mode flag.
|
||||
|
||||
### Attach model
|
||||
|
||||
Enter suspends the TUI (restore main screen + cooked mode), then hands the terminal to `tmux -L <socket> attach-session -t <name>` with `stdio: inherit`. On tmux exit/detach, the TUI resumes and refreshes. Full fidelity (mouse, paste, colors) is tmux's, we never proxy bytes.
|
||||
|
||||
- Inside tmux already: same socket → `switch-client -t`; different socket → warn about nesting and offer detach-first. `$TMUX` + `CODEMAN_MUX` detection.
|
||||
- **Return path**: a tmux binding installed for codeman sessions (opt-in) runs `codeman tui --pick` inside `tmux display-popup -E`, a minimal picker-only mode (list + jump, no preview) so switching sessions from inside a pane is one keystroke, fzf-style.
|
||||
- Optional per-attach chrome (opt-in setting, default off since `status off` at `tmux-manager.ts:1978` is deliberate): a minimal codeman-styled tmux status line showing `name · state · alert`, set on attach, restored on detach.
|
||||
|
||||
### Notifications
|
||||
|
||||
While the TUI is open and a session flips to NEEDS YOU: flash the row, ring BEL, and optionally emit OSC 9 (desktop notification in kitty/WezTerm/iTerm2, and it traverses SSH). This is the herdr sidebar promise delivered even when the terminal is backgrounded.
|
||||
|
||||
### Degraded mode (server down)
|
||||
|
||||
`sc` works without the server today and the TUI must too: when no server answers, enumerate `tmux -L codeman list-sessions` + read `state.json` (read-only), show a "server not running" header line, and offer attach only (no states, no approvals). This keeps the "web server crashed, get me to my sessions" path alive.
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
### A client of the server, not a second brain
|
||||
|
||||
Everything live comes from the API the web UI already uses:
|
||||
|
||||
| Need | Endpoint |
|
||||
| --- | --- |
|
||||
| Session list + history | `GET /api/sessions/unified` |
|
||||
| Live updates | SSE `GET /api/events` (heartbeat `sse:heartbeat` already exists; fall back to 2s polling) |
|
||||
| Pending approvals + parsed options | `GET /api/approvals`, answer via `POST /api/approvals/:id/answer` |
|
||||
| Preview tail | `GET /api/sessions/:id/terminal?tail=N` (throttled to the selected session only) |
|
||||
| Prompt send | `POST /api/sessions/:id/input` (single line + `\r`, per the composer contract) |
|
||||
| New session | `POST /api/quick-start` (routes remote/docker cases correctly) |
|
||||
| Search | `GET /api/search` |
|
||||
| Away digest | `GET /api/away-digest` |
|
||||
| Plan usage chip | latest status-telemetry snapshot (`plan-usage-latest`) |
|
||||
|
||||
Server discovery and auth reuse what exists: instance config from `src/config/instance.ts` (`CODEMAN_INSTANCE`, `CODEMAN_PORT`), the probe logic from `daemon-control.ts`, credentials from `~/.codeman/.env` (the established `codeman attach` pattern), self-signed HTTPS accepted for loopback probes (the hooks-on-HTTPS lesson). Multi-user scoping comes free: the API only returns what the authenticated user owns.
|
||||
|
||||
### Renderer: hand-rolled, zero new dependencies (decision)
|
||||
|
||||
Options considered:
|
||||
|
||||
- **Ink (React for CLIs)**: what Claude Code uses. Pros: layout engine, ecosystem. Cons: pulls React into a CLI that today ships only commander+chalk; rerender model fights the two things we care most about (a raw-ANSI preview region and 2Hz glyph animation without flicker); version-pins React for every `npm i -g aicodeman`.
|
||||
- **blessed/neo-blessed**: unmaintained, skip.
|
||||
- **Hand-rolled screen core** (recommended): this repo hand-rolls ANSI everywhere already and has the expertise (regex-patterns, stripAnsi, the xterm work). The core is small and boring: alt screen + raw mode + cursor-home full-frame repaint from an off-screen string buffer, throttled to state changes and the 2Hz animation tick, wrapped in DECSET 2026 (synchronized output) where supported so repaints are atomic in modern terminals (tmux, kitty, WezTerm, iTerm2). No diffing needed at these frame rates.
|
||||
|
||||
The one genuinely tricky pure function: SGR-aware line clipping for the preview (keep colors, strip cursor movement/OSC/DECSET, clip to width while carrying SGR state, reset at EOL). That is a pure module with exhaustive unit tests, and it is exactly the kind of function Ink would not have given us anyway.
|
||||
|
||||
### Module layout
|
||||
|
||||
```
|
||||
src/tui/
|
||||
tui-app.ts entry + main loop + attach handoff (IO)
|
||||
tui-client.ts API + SSE client, degraded-mode enumeration (IO)
|
||||
tui-model.ts pure: state store, grouping, ordering (reuses PR #303 helpers)
|
||||
tui-layout.ts pure: responsive layout math, row building
|
||||
tui-render.ts pure: model+layout -> frame string (palette, glyphs, fallbacks)
|
||||
tui-keys.ts pure: byte stream -> key/mouse events (incl. SGR mouse decode)
|
||||
tui-ansi.ts pure: SGR-aware clip/filter for the preview
|
||||
```
|
||||
|
||||
Pure modules unit-test with no TTY. `cli.ts` gains one thin `tui` command registration (and `--list`/`<n>` fast paths for `sc -l` / `sc 2` parity, which must stay fast: they short-circuit before any screen setup).
|
||||
|
||||
## 5. CLI-wide polish (the rest of "make it much nicer")
|
||||
|
||||
A shared style kit, `src/cli-style.ts`: one palette (mirroring the web's status colors), one glyph set with fallback, `heading()`, `kv()`, `table()` (width-aware, fixes the Antigravity overflow), `spinner()` (finally: the 30s silent daemon/service waits get a live line), `confirm()` (used by `reset --force`'s missing prompt and `x` in the TUI). Then the mechanical fixes from §1: colorize `doctor` through the hook that already exists for it, dedupe the `codeman web` startup line, colorize the server's security warning, fix the README `codeman attach` description and the Ctrl+B/Ctrl+A detach drift, TTY/NO_COLOR gates everywhere.
|
||||
|
||||
## 6. Phasing
|
||||
|
||||
| Phase | Contents | Size |
|
||||
| --- | --- | --- |
|
||||
| 0 | `cli-style.ts` + mechanical fixes (§5), real CLI tests (retire the fixture parser in `test/cli-commands.test.ts`) | S |
|
||||
| 1 | `codeman tui` core: list + states via SSE, cursor + 1-9, attach/return loop, kill w/ confirm, new session, narrow mode, degraded mode, `sc` alias flip + `--list`/`<n>` parity | M/L |
|
||||
| 2 | Preview pane (SGR clip), approvals answering, prompt composer, search, digest, resume, plan-usage header | M |
|
||||
| 3 | Mouse support, `--pick` popup switcher + tmux binding, opt-in attach status line, BEL/OSC 9 notifications | M |
|
||||
| 4 | Retire `tmux-chooser.sh`/fold `tmux-manager.sh` (keep as thin wrappers for one release), docs/README/wiki, screenshots for promo | S |
|
||||
|
||||
Phases 0-1 are the useful minimum; 2 is where it beats herdr's sidebar (answering approvals from the dashboard); 3 is delight.
|
||||
|
||||
## 7. Testing
|
||||
|
||||
- Pure modules (`tui-model/layout/render/keys/ansi`): plain vitest, frame snapshots as stripped strings plus targeted ANSI assertions.
|
||||
- Interactive E2E: spawn the built TUI under `node-pty` (already a dependency), feed keys, assert on captured frames; the vitest tmux mock (`IS_TEST_MODE`) keeps attach paths inert. Port rules per CLAUDE.md (3150+, `app.inject()` where possible by testing `tui-client` against injected routes).
|
||||
- Manual: Termius/iPhone portrait (the 44-col case), tmux nesting, server-down mode, NO_COLOR, non-nerd-font terminal.
|
||||
|
||||
## 8. Invariants this plan respects
|
||||
|
||||
- tmux socket and data dir always via instance config (`dataPath()`, `-L codeman`); a beta instance TUI sees only its own world.
|
||||
- Never bulk kill, always confirm, never touch another session implicitly, refuse killing the session the TUI runs in (w1/w2/w3 are sacred).
|
||||
- Input is single-line with `\r`, via the server (never raw tmux send-keys from the TUI while the server owns the session).
|
||||
- Approvals answering goes through the server's re-capture + 409 path, never blind keystrokes.
|
||||
- `status off` on panes stays the default; any chrome is opt-in.
|
||||
- No new runtime dependencies; the npm package stays light.
|
||||
|
||||
## 9. Decisions (resolved 2026-08-16)
|
||||
|
||||
1. **Bare `codeman` does NOT open the TUI** (owner decision): the web UI is the main thing, the TUI is additional. `codeman tui` only.
|
||||
2. **`sc` stays the bash chooser for now**; the alias flip is a follow-up once the TUI has mileage. `codeman tui --list` / `codeman tui <n>` provide the same fast paths for people who want to switch.
|
||||
3. Opt-in tmux status line: deferred to phase 3 along with the `--pick` popup switcher.
|
||||
4. Preview tail goes over the API (auth/multi-user/remote-consistent); previews are simply unavailable in degraded server-down mode.
|
||||
5. Name is `codeman tui` (the test fixture historically expected it).
|
||||
|
||||
Initial PR scope: phases 0-2. Phase 3 (mouse, popup switcher, status line, OSC 9) and phase 4 (bash chooser retirement) are follow-ups.
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
# Terminal UI (`codeman tui`)
|
||||
|
||||
`codeman tui` is a full-screen dashboard for your Codeman sessions, in the terminal.
|
||||
It shows every session grouped by whether it needs you, lets you answer a permission
|
||||
dialog or send a prompt without switching anywhere, and puts you inside a session's
|
||||
tmux pane with one keystroke.
|
||||
|
||||
It is **additional, not a replacement**: the web UI stays the primary surface and
|
||||
gets every feature first. The TUI exists for the terminal workflow (SSH, Termius,
|
||||
a tmux window you keep open all day), and it is a *client* of the running server,
|
||||
so the two surfaces can never disagree about what a session is doing. It is also
|
||||
not a multiplexer: tmux still owns every pane, and attaching hands the terminal to
|
||||
tmux rather than proxying bytes.
|
||||
|
||||
## Starting it
|
||||
|
||||
```bash
|
||||
codeman tui # the dashboard
|
||||
codeman tui --list # print the numbered session list and exit
|
||||
codeman tui 2 # attach straight to session 2 of that list
|
||||
```
|
||||
|
||||
The two fast paths are the scriptable ones (they are the `sc -l` / `sc 2` shapes).
|
||||
Neither sets up a screen, so both are as quick as the one API call they make, and
|
||||
`--list` prints plain text when piped, so it composes with `grep`/`awk`.
|
||||
|
||||
What it needs:
|
||||
|
||||
| Needs | What you get |
|
||||
| --- | --- |
|
||||
| **Full features** | A running Codeman server (states, approvals, preview, prompts, search, digest). The TUI finds it the way `codeman attach` does: `CODEMAN_API_URL`, else loopback on `CODEMAN_PORT` for this `CODEMAN_INSTANCE`. The self-signed certificate an `--https` install generates is accepted, as it is everywhere else in the CLI. |
|
||||
| **Server down** | It still starts, in **degraded mode**: sessions are enumerated straight from `tmux -L codeman` plus a read-only peek at `state.json`, and attach is the only verb. See [Troubleshooting](#troubleshooting). |
|
||||
| **A terminal** | `codeman tui` refuses to run when stdin/stdout are not a TTY, and says to use `--list` instead. A cron job or a pipe therefore fails loudly rather than emitting escape codes into a log. |
|
||||
|
||||
## What it looks like
|
||||
|
||||
A real frame at 100x30 (`NO_COLOR`, trailing blank rows trimmed). The selected
|
||||
session has a pending permission dialog, so the preview pane leads with the card:
|
||||
|
||||
```
|
||||
codeman ⚠ 2 tnode · v1.19.0 · 5 sessions · 5h 32% · wk 61% ? help q quit
|
||||
NEEDS YOU ─────────────────────────│ w4-api-refactor · claude · /home/you/dev/api · blocked
|
||||
1 w6-docs ✋ 11m│ ⚠ requests: Bash(git push origin main)
|
||||
▶ 2 w4-api-refactor ⚠ 2m│ 1. Yes
|
||||
WORKING ───────────────────────────│ 2. Yes, and do not ask again
|
||||
3 w1-codeman ∗ 1h│ 3. No, tell Claude what to do
|
||||
4 w2-gallery ∗ 15m│ y approve · n deny · digit chooses
|
||||
IDLE ──────────────────────────────│
|
||||
5 w3-promo shell ○ 2h│ > refactor the api routes onto the shared port interface
|
||||
RECENT ────────────────────────────│
|
||||
6 api-hotfix ✔ 3d│ Read src/web/ports/session-port.ts (48 lines)
|
||||
│ Read src/api/routes.ts (312 lines)
|
||||
│ Edit src/api/routes.ts
|
||||
│ 1 -import { SessionManager } from "../session-manager.js";
|
||||
│ 2 +import type { SessionPort } from "../web/ports/session-
|
||||
│
|
||||
│ Bash(npm run typecheck)
|
||||
│ └ tsc --noEmit: no errors
|
||||
│
|
||||
│ ✻ Actualizing… (2m 14s · ↓ 12.3k tokens)
|
||||
↑↓ select · ⏎ attach · y approve · n deny · 1-9 option · p prompt · x kill · / search · g digest ·
|
||||
```
|
||||
|
||||
- **Header**: the machine, the server version, how many sessions are live, and the
|
||||
plan-usage chip (the same statusLine telemetry that feeds the web chip, when the
|
||||
server has a snapshot). A `⚠ n` badge counts pending approvals.
|
||||
- **Sidebar**: every session, grouped and numbered.
|
||||
- **Preview**: a live tail of the selected session, its own colors preserved, with
|
||||
the parsed dialog card on top when that session is blocked.
|
||||
- **Footer**: only the keys that work right now. `n` reads `n new` normally and
|
||||
`n deny` when the selected session has a dialog, because it cannot be both.
|
||||
|
||||
The same world through `--list`:
|
||||
|
||||
```
|
||||
1 waiting w6-docs /home/you/dev/docs
|
||||
2 blocked w4-api-refactor /home/you/dev/api
|
||||
3 working w1-codeman /home/you/dev/codeman
|
||||
4 working w2-gallery /home/you/dev/gallery
|
||||
5 idle w3-promo /home/you/dev/promo
|
||||
6 done api-hotfix /home/you/dev/api
|
||||
```
|
||||
|
||||
The numbers are the same on both surfaces, so `codeman tui --list` then
|
||||
`codeman tui 4` is one thought.
|
||||
|
||||
## The four groups
|
||||
|
||||
Groups are always in this order, and a session is in exactly one of them:
|
||||
|
||||
| Group | Glyph | Means | Comes from |
|
||||
| --- | --- | --- | --- |
|
||||
| **NEEDS YOU** | `⚠` | A permission or question dialog is blocking the agent | The approvals inbox (`permission_prompt` hooks, with the on-screen options parsed) |
|
||||
| | `✋` | Waiting for your next instruction, or errored | `idle_prompt`, or an errored session (equally something only a human clears) |
|
||||
| **WORKING** | `✻` animating | A turn is running | The same working classification the web dashboard uses |
|
||||
| **IDLE** | `○` | Live, but sitting there | |
|
||||
| **RECENT** | `✔` | A past session from the unified list | History rows, no live pane |
|
||||
|
||||
Ordering inside a group is "the one that has waited longest, first": blocked
|
||||
sessions sort by how long the dialog has been up, working sessions by when their
|
||||
turn started (the pane's last Enter, since a working pane repaints every second
|
||||
and would otherwise always look freshly started), and quiet ones by last activity.
|
||||
That is the ordering the web home screens already use.
|
||||
|
||||
The cursor sticks to a **session**, not a row number, so a session that jumps to
|
||||
NEEDS YOU does not drag your selection with it. The number beside each row is what
|
||||
`1-9` and `codeman tui <n>` mean, and it is renumbered on every re-sort.
|
||||
|
||||
When a new dialog appears, the terminal bell rings once, for that dialog only: the
|
||||
same item announced twice does not ring twice.
|
||||
|
||||
## Keymap
|
||||
|
||||
| Key | Does |
|
||||
| --- | --- |
|
||||
| `↑` `↓` or `j` `k` | Move the cursor. PageUp/PageDown jump five rows. |
|
||||
| `Enter` | Attach to the selected session (see [Attaching](#attaching)) |
|
||||
| `1`-`9` | Jump to that row and attach. When a dialog is on screen, a digit answers it instead (see below). |
|
||||
| `y` | Approve the selected session's dialog |
|
||||
| `n` | Deny it, or **start a new session** when there is no dialog |
|
||||
| `p` | Send one line to the selected session without attaching |
|
||||
| `x` | Kill the selected session; `y` confirms, any other key cancels |
|
||||
| `/` | Search sessions, events and files |
|
||||
| `g` | Away digest: what happened while you were gone |
|
||||
| `?` | Help overlay |
|
||||
| `Esc` | Close whatever overlay is open |
|
||||
| `q` or `Ctrl+C` | Quit, restoring the screen you started with |
|
||||
|
||||
Inside the `p` composer and the `/` query: `←` `→` `Home` `End` `Delete`
|
||||
`Backspace` plus `Ctrl+A` / `Ctrl+E` / `Ctrl+U` / `Ctrl+W`, `Enter` to send or open,
|
||||
`Esc` (or `Ctrl+C`) to cancel. In the kill confirmation you retype the session name;
|
||||
anything else cancels. In the `n` pickers, type to filter, `Enter` chooses.
|
||||
|
||||
Verbs that need the server (`y`/`n`/`p`/`x`/`/`/`g`) say so in degraded mode
|
||||
instead of failing silently; `Enter` and `1-9` keep working.
|
||||
|
||||
### `p` sends exactly one line
|
||||
|
||||
The composer is a single line by design, ending in a carriage return: that is the
|
||||
input contract every Codeman path follows, because multi-line text breaks the
|
||||
agent's own composer. Pasted newlines become spaces rather than being rejected, so
|
||||
a paste cannot silently run a different command than the one you read.
|
||||
|
||||
## Answering approvals
|
||||
|
||||
This is the thing the terminal could not do before. Select a blocked session and:
|
||||
|
||||
- `y` approves.
|
||||
- `n` picks the parsed "No" option, or sends Esc when the dialog did not parse one.
|
||||
- A digit picks that numbered option, **but only a digit the dialog actually
|
||||
offers**. A digit with no matching option falls through to the list's own
|
||||
jump-and-attach binding, so it can never be typed at whatever has focus.
|
||||
|
||||
The answer goes through `POST /api/approvals/:id/answer`, which **re-captures the
|
||||
pane before it types anything**. If the dialog is no longer on screen (you answered
|
||||
it in tmux a moment ago, or the agent moved on), the server refuses with a 409 and
|
||||
the TUI says `that dialog is no longer on screen` rather than pressing a key into a
|
||||
live composer. The answer is scoped to the options the server parsed off the actual
|
||||
frame, never to a guess.
|
||||
|
||||
An idle prompt (`✋`) is not a dialog: there is nothing to approve, so `p` is the
|
||||
reply path and the footer says `p reply` instead of `p prompt`.
|
||||
|
||||
## Attaching
|
||||
|
||||
`Enter` suspends the dashboard (main screen back, cooked mode back) and hands the
|
||||
terminal to tmux with `stdio: inherit`. Colors, mouse and paste are tmux's, at full
|
||||
fidelity.
|
||||
|
||||
**Press `F1` to come back.** One key, no modifier to hold or release, nothing to
|
||||
type in a particular order. tmux's own way out is a chord — press the prefix, let
|
||||
go, then a letter — and beta testing showed that is genuinely hard to convey: the
|
||||
bar first named the wrong letter (tmux binds lowercase `d` to `detach-client` and
|
||||
capital `D` to `choose-client`), and once corrected it still failed for anyone who
|
||||
kept Ctrl held, because that sends `Ctrl+D`, which tmux leaves unbound. So the TUI
|
||||
claims `F1` in tmux's prefix-less key table for the length of the attach and gives
|
||||
it back afterwards. The chord still works; it is simply not what you are told to
|
||||
press.
|
||||
|
||||
You do not have to remember any of it. For as long as the attach lasts the pane
|
||||
wears a bar across the top:
|
||||
|
||||
```
|
||||
1 w3-codeman-… 2 w4-codeman-… 3 testcase … alt+1-9 switch · F1 back to the codeman dashboard
|
||||
```
|
||||
|
||||
That is the **session strip**: the other sessions stay visible from inside a pane,
|
||||
numbered exactly as the dashboard numbers them, with the one you are in inverted.
|
||||
`Alt+1`..`Alt+9` switch between them without going back to the dashboard first. With
|
||||
more sessions than fit, the strip shows a window around the current one and marks
|
||||
each cut end with `…`; the way-out hint is measured first and always keeps its space.
|
||||
|
||||
Codeman keeps the status bar off on its panes (the web UI carries that information
|
||||
around the terminal instead), so the TUI turns it on for the attach and puts it back
|
||||
exactly as it was on detach, along with each window's size. Every session the strip
|
||||
can switch to is dressed and sized the same way, so switching is instant and lands
|
||||
in a pane that already fills your terminal.
|
||||
|
||||
Detaching leaves the agent running; typing `exit` or pressing `Ctrl+D` would end it,
|
||||
which is the difference the bar exists to make obvious. If an agent does exit, its
|
||||
pane stays as a corpse: the TUI refuses to attach to a dead pane and offers `r` to
|
||||
resume the conversation in a fresh one instead.
|
||||
|
||||
Three cases:
|
||||
|
||||
| Where you are | What happens |
|
||||
| --- | --- |
|
||||
| Not in tmux | `tmux -L codeman attach-session` |
|
||||
| Already in tmux on Codeman's socket | `switch-client`, so you do not nest |
|
||||
| In tmux on a **different** socket | Refused, with an explanation: detach from that tmux first, then run `codeman tui` again |
|
||||
|
||||
A direct-PTY session has no pane to attach to, and says so.
|
||||
|
||||
**`Enter` on a RECENT row resumes that conversation** instead: there is no pane to
|
||||
attach to, so the TUI creates a new claude session carrying the old transcript
|
||||
(`resumeSessionId`, exactly what the web UI's "Resume Conversation" list does), in
|
||||
the directory it originally ran in and under its old name, then attaches to it. It
|
||||
is claude-only, and a row with no working directory or no conversation id says why
|
||||
rather than resuming something else.
|
||||
|
||||
`x` never bulk-kills: it kills one session, only after you retype its name, never a
|
||||
history row, and never the session the TUI itself is running in.
|
||||
|
||||
## Over SSH, and on a phone
|
||||
|
||||
The TUI is an ordinary terminal program with no local dependencies beyond tmux, so
|
||||
`ssh box` then `codeman tui` works exactly like running it locally. There is no
|
||||
separate remote mode.
|
||||
|
||||
Below 72 columns (Termius, an iPhone in portrait) the preview pane is dropped and
|
||||
rows take two lines each: the same constraint the `sc` chooser was built around,
|
||||
now with a cursor, live states and the answer/prompt/kill verbs. The switch is
|
||||
width-driven at draw time, so unfolding a foldable or resizing a window re-lays out
|
||||
immediately; there is no mode flag to set.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"The Codeman server rejected these credentials."** The server has
|
||||
`CODEMAN_PASSWORD` set. Export `CODEMAN_PASSWORD` (and `CODEMAN_USERNAME` if it is
|
||||
not `admin`), or put them in the data dir's `.env` (`~/.codeman/.env`), which is
|
||||
where `codeman attach` already reads them from.
|
||||
|
||||
**`server not running: attach only`** in a yellow banner. Nothing answered on the
|
||||
expected port, so the TUI fell back to enumerating tmux. You get names and attach;
|
||||
you do not get states, approvals or previews, because those only exist on the
|
||||
server. Start the server (`codeman web -d`, or `systemctl --user start codeman-web`)
|
||||
and the banner clears on its own: the TUI keeps re-probing.
|
||||
|
||||
**It found the wrong server, or none.** Discovery is instance-scoped. A beta
|
||||
instance (`CODEMAN_INSTANCE=beta`) has its own data dir *and* its own tmux socket,
|
||||
so its TUI sees only its own sessions. Set `CODEMAN_PORT` or `CODEMAN_API_URL`
|
||||
explicitly when you run more than one.
|
||||
|
||||
**"this terminal is already inside tmux on socket ..."** You are in a tmux session
|
||||
on a socket that is not Codeman's, so attaching would nest two multiplexers whose
|
||||
prefix keys collide. Detach from that tmux and run `codeman tui` from outside.
|
||||
|
||||
**Boxes and glyphs render as garbage.** The TUI picks a glyph tier from the
|
||||
environment: no `TERM` (or `dumb`), or a non-UTF-8 locale, gets the ASCII set
|
||||
(`[!] [w] [*] [-]`, `+`/`-`/`|` frames). Force it either way with
|
||||
`CODEMAN_TUI_GLYPHS=ascii|unicode|nerd`.
|
||||
|
||||
**Colors.** Standard `NO_COLOR` / `FORCE_COLOR` handling (chalk's, the same as the
|
||||
rest of the CLI). Under `NO_COLOR` the frame is cursor addressing and text only,
|
||||
and the preview's own colors are stripped too, so a session's output cannot repaint
|
||||
the dashboard.
|
||||
|
||||
**It refuses to open at all**, saying it needs an interactive terminal. stdout or
|
||||
stdin is not a TTY. That is the guard: use `codeman tui --list`.
|
||||
|
||||
## Related
|
||||
|
||||
- [`docs/tui-plan.md`](tui-plan.md): the design record. Why hand-rolled ANSI, why a
|
||||
client and not a second brain, and what is deliberately deferred.
|
||||
- [`docs/approvals-inbox-plan.md`](approvals-inbox-plan.md): where the parsed
|
||||
dialogs and the answer endpoint come from.
|
||||
- [`docs/remote-sessions.md`](remote-sessions.md): remote-SSH cases, which the TUI
|
||||
lists like any other session.
|
||||
@@ -88,6 +88,22 @@ looks exactly like a dead button.
|
||||
On phones this button replaces the desktop's **Run Shell** control; starting a shell moved
|
||||
into the Run dropdown.
|
||||
|
||||
## Tapping, links and copying
|
||||
|
||||
- **Tap a link** in terminal output and it opens in a new tab. Same for a link in an agent's
|
||||
answer in the response viewer — it opens a tab rather than navigating the dashboard away,
|
||||
which on a phone would unload the whole session view.
|
||||
- **Tap a file path** an agent printed and the file-preview overlay opens; a log path opens the
|
||||
log viewer. Works in scrolled-up transcript too.
|
||||
- A tap on the prose *beside* a link still places the cursor as usual, and a tap on a dialog's
|
||||
numbered choice still answers the dialog even when the row contains a path — the dialog wins,
|
||||
because on a phone it is the only interaction that matters.
|
||||
- **Long-press to select text**, then drag, or tap the other end to extend the selection — no
|
||||
hairline handles to grab. A small bar offers **Copy**, **Line** (the whole logical line,
|
||||
wrapped rows included) and dismiss. Copy works on plain-HTTP installs too, where the browser
|
||||
clipboard API is unavailable.
|
||||
- A swipe is never mistaken for a long-press, and the keyboard stays down while you select.
|
||||
|
||||
## Scrolling and the keyboard
|
||||
|
||||
- The terminal and toolbar shift up when the keyboard opens, tracked through the browser's
|
||||
@@ -97,6 +113,10 @@ into the Run dropdown.
|
||||
keeps focus so you can place the caret.
|
||||
- A scroll is never mistaken for a tap: travel is measured from the start of the gesture, and
|
||||
multi-touch never counts.
|
||||
- **A long prompt stays visible.** Once what you are typing wraps past the last visible row it
|
||||
grows upward over the transcript instead of sliding under the keyboard, so the end of the
|
||||
sentence — where the cursor is — is always on screen. A prompt taller than the visible strip
|
||||
shows its tail.
|
||||
|
||||
## Voice
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
|
||||
| CJK Input | Off | IME composition through a dedicated text field. |
|
||||
| Extended Keyboard Bar | Per device | Which accessory bar phones get. Shell sessions override it while they are active. |
|
||||
| Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. |
|
||||
| Auto Copy Selection | Off | Copies highlighted terminal text to the clipboard the moment you finish selecting it. Ctrl+C still copies on demand. |
|
||||
| WebGL Renderer | On | With a GPU-stall watchdog that falls back to DOM rendering. |
|
||||
| Gesture Control | Off | Camera hand tracking. Also needs `CODEMAN_GESTURE=1` on the server. |
|
||||
|
||||
|
||||
@@ -133,8 +133,10 @@ TUIs render correctly.
|
||||
|
||||
Worth knowing:
|
||||
|
||||
- **Scrollback.** The first time you open a session, Codeman pulls the entire tmux
|
||||
scrollback, not just the recent tail. Scrolling to the very top pulls again on demand.
|
||||
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
|
||||
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
|
||||
switching; press **Load full history** to pull the rest explicitly. Ordinary Shell scrolling
|
||||
stays within the bounded browser buffer so dragging upward remains responsive.
|
||||
- **Wheel and touch scrolling** are forwarded into Claude's own transcript on recent Claude
|
||||
versions, so the wheel scrolls the conversation rather than the terminal. `Shift+Wheel` is
|
||||
always local scrollback. Other CLIs scroll locally.
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.19.6",
|
||||
"version": "1.20.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.19.6",
|
||||
"version": "1.20.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -12361,7 +12361,7 @@
|
||||
}
|
||||
},
|
||||
"packages/xterm-zerolag-input": {
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@xterm/headless": "^6.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.19.6",
|
||||
"version": "1.20.1",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# xterm-zerolag-input
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Mobile catches up: links open from a tap, terminal text can be selected and copied, long prompts stay visible while you type. Plus Files panel search, a bundled Nerd Font symbols fallback, and a per-device terminal font setting.
|
||||
- **Terminal and chat links work on phones** (#321): tapping a URL or file path in terminal output now opens it (new tab, file preview, or log viewer), resolved through the same provider desktop hover uses, so tap and click can never disagree about what is a link. Dialog rows and the composer keep their existing meaning. Response-viewer links open in a new tab with `rel="noopener noreferrer"` instead of navigating the dashboard away. Wrapped links open whole: the logical-line reconstruction now stitches hard wraps through the indent their continuation carries, which also fixes desktop hover-click truncating wrapped URLs.
|
||||
- **Terminal text can be copied on touch devices** (#321): long-press selects the token under the finger, drag or tap the other end to extend, and a small bar offers Copy, Line (the whole logical line, wraps included) and dismiss. Copy works on plain-HTTP installs too. Three guards keep the keyboard down and the selection alive through the browser's own long-press handling.
|
||||
- **A long prompt stays visible on phones** (#321): the local-echo overlay grows upward once it would run past the last visible row (a prompt taller than the screen keeps its tail, where the cursor is), and the keyboard-driven padding shrink can no longer reclaim the space the fixed toolbar and accessory bar stand in.
|
||||
- **Files panel search** (#324): `GET /api/sessions/:id/files?q=...` answers a flat match list (name or path substring, `*`/`?` globs), recursing past non-matching directories with its own match cap on top of the existing bounds; without `q` the response is byte-identical to before. Glob queries are matched without regex so a pathological pattern cannot stall the server.
|
||||
- **Nerd Font prompt glyphs out of the box, custom terminal font** (#320): a bundled icons-only Symbols Nerd Font Mono fallback renders powerlevel10k/starship/oh-my-posh glyphs on every device with no font install, and App Settings gains a per-device terminal font family that is prepended to the built-in stack.
|
||||
|
||||
### Thanks
|
||||
|
||||
Three contributor PRs in one release: thanks to @rounakdatta (#321), @aakhter (#324) and @comzine (#320).
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xterm-zerolag-input",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections",
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
|
||||
@@ -65,38 +65,71 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
charTop,
|
||||
charHeight,
|
||||
promptRow,
|
||||
totalRows,
|
||||
font,
|
||||
showCursor,
|
||||
cursorColor,
|
||||
terminal,
|
||||
} = params;
|
||||
|
||||
// Position container at prompt row.
|
||||
// ── Keep what is being typed ON SCREEN ────────────────────────────
|
||||
//
|
||||
// The overlay lays its wrapped lines out DOWNWARD from the prompt row, and
|
||||
// nothing past the last terminal row is visible. On a phone the strip left
|
||||
// above the on-screen keyboard is only a handful of rows, so a prompt long
|
||||
// enough to wrap ran off the bottom and the user was typing blind — the tail
|
||||
// of their own sentence, the part they are actually looking at, hidden behind
|
||||
// the keyboard.
|
||||
//
|
||||
// So the composer grows UPWARD once it reaches the last row, exactly as a real
|
||||
// terminal's does: every line div is opaque (see makeLine), so the lines cover
|
||||
// transcript rows above instead of vanishing under the keyboard below, and the
|
||||
// newest text stays where the eye is. A prompt taller than the whole viewport
|
||||
// keeps its TAIL for the same reason.
|
||||
//
|
||||
// `startCol` indents only the line that begins at the prompt marker, so it is
|
||||
// dropped along with that line when the tail is all that fits.
|
||||
const rows = totalRows && totalRows > 0 ? totalRows : terminal?.rows;
|
||||
let visibleLines = lines;
|
||||
let keepsPromptLine = true;
|
||||
let topRow = promptRow;
|
||||
if (rows && rows > 0) {
|
||||
if (lines.length > rows) {
|
||||
visibleLines = lines.slice(lines.length - rows);
|
||||
keepsPromptLine = false;
|
||||
topRow = 0;
|
||||
} else if (promptRow + lines.length > rows) {
|
||||
topRow = rows - lines.length;
|
||||
}
|
||||
}
|
||||
topRow = Math.max(0, topRow);
|
||||
|
||||
container.style.left = '0px';
|
||||
container.style.top = promptRow * cellH + 'px';
|
||||
container.style.top = topRow * cellH + 'px';
|
||||
|
||||
// Clear and rebuild (typically 1-3 line divs, negligible cost)
|
||||
container.innerHTML = '';
|
||||
const fullWidthPx = totalCols * cellW;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const leftPx = i === 0 ? startCol * cellW : 0;
|
||||
const widthPx = i === 0 ? fullWidthPx - leftPx : fullWidthPx;
|
||||
for (let i = 0; i < visibleLines.length; i++) {
|
||||
const indents = i === 0 && keepsPromptLine;
|
||||
const leftPx = indents ? startCol * cellW : 0;
|
||||
const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx;
|
||||
const topPx = i * cellH;
|
||||
const lineEl = makeLine(lines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal);
|
||||
const lineEl = makeLine(visibleLines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal);
|
||||
container.appendChild(lineEl);
|
||||
}
|
||||
|
||||
// Block cursor at end of last line (use visual width for CJK support)
|
||||
if (showCursor) {
|
||||
const lastLine = lines[lines.length - 1];
|
||||
const lastLineLeft = lines.length === 1 ? startCol : 0;
|
||||
const lastLine = visibleLines[visibleLines.length - 1];
|
||||
const lastLineLeft = visibleLines.length === 1 && keepsPromptLine ? startCol : 0;
|
||||
const cursorCol = lastLineLeft + stringCellWidth(terminal, lastLine);
|
||||
if (cursorCol < totalCols) {
|
||||
const cursor = document.createElement('span');
|
||||
cursor.style.cssText = 'position:absolute;display:inline-block';
|
||||
cursor.style.left = cursorCol * cellW + 'px';
|
||||
cursor.style.top = (lines.length - 1) * cellH + 'px';
|
||||
cursor.style.top = (visibleLines.length - 1) * cellH + 'px';
|
||||
cursor.style.width = cellW + 'px';
|
||||
cursor.style.height = cellH + 'px';
|
||||
cursor.style.backgroundColor = cursorColor;
|
||||
|
||||
@@ -172,6 +172,13 @@ export interface RenderParams {
|
||||
/** Height of the character rendering area (px). */
|
||||
charHeight: number;
|
||||
promptRow: number;
|
||||
/**
|
||||
* Visible terminal rows. When given, the overlay is kept ON SCREEN: it grows
|
||||
* upward instead of running off the bottom edge, and a wrapped prompt taller
|
||||
* than the viewport keeps its tail. Omit to lay out straight down from
|
||||
* `promptRow` (the historical behaviour).
|
||||
*/
|
||||
totalRows?: number;
|
||||
font: FontStyle;
|
||||
showCursor: boolean;
|
||||
cursorColor: string;
|
||||
|
||||
@@ -565,7 +565,10 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
|
||||
// Skip redundant re-renders — include text content to detect
|
||||
// same-length changes (e.g., setFlushed with different text)
|
||||
const renderKey = `${displayText}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._flushedOffset}`;
|
||||
// `rows` is part of the key: the layout is clamped to the visible rows
|
||||
// (see renderOverlay), so a keyboard opening — which changes rows without
|
||||
// changing the text — must not be skipped as a redundant render.
|
||||
const renderKey = `${displayText}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
|
||||
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
|
||||
this._lastRenderKey = renderKey;
|
||||
|
||||
@@ -612,6 +615,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
charTop,
|
||||
charHeight,
|
||||
promptRow: activePrompt.row,
|
||||
totalRows: this._terminal.rows,
|
||||
font: this._font,
|
||||
showCursor: this._options.showCursor,
|
||||
cursorColor,
|
||||
|
||||
@@ -418,3 +418,88 @@ describe('stringCellWidth', () => {
|
||||
expect(stringCellWidth(null, '')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderOverlay — staying on screen (totalRows)', () => {
|
||||
// A phone with the keyboard up leaves only a handful of terminal rows. The
|
||||
// overlay lays its wrapped lines out downward from the prompt row, so a long
|
||||
// prompt used to run off the bottom edge and the user typed blind, with the
|
||||
// tail of their own sentence behind the keyboard. With totalRows known, the
|
||||
// composer grows UPWARD instead — the line divs are opaque, so they cover
|
||||
// transcript above rather than disappearing below.
|
||||
const linesOf = (n: number) => Array.from({ length: n }, (_, i) => `line${i}`);
|
||||
const lineDivs = (container: HTMLDivElement) =>
|
||||
Array.from(container.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[];
|
||||
|
||||
it('lifts the block so its last line lands on the last visible row', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: linesOf(5), promptRow: 10, totalRows: 12, cellH: 17 }));
|
||||
|
||||
// 10 + 5 would end on row 14 of a 12-row screen; the block starts at 7 instead.
|
||||
expect(container.style.top).toBe(7 * 17 + 'px');
|
||||
expect(lineDivs(container)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('leaves the prompt row alone when the block already fits', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: linesOf(3), promptRow: 5, totalRows: 24, cellH: 17 }));
|
||||
|
||||
expect(container.style.top).toBe(5 * 17 + 'px');
|
||||
});
|
||||
|
||||
it('keeps the TAIL when the prompt is taller than the whole viewport', () => {
|
||||
// The end is where the cursor is, and where the user is looking.
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: linesOf(6), promptRow: 2, totalRows: 3, cellH: 20 }));
|
||||
|
||||
const divs = lineDivs(container);
|
||||
expect(container.style.top).toBe('0px');
|
||||
expect(divs).toHaveLength(3);
|
||||
expect(divs.map((d) => d.textContent)).toEqual(['line3', 'line4', 'line5']);
|
||||
});
|
||||
|
||||
it('drops the prompt indent once the prompt line is no longer shown', () => {
|
||||
// startCol indents only the line that begins at the prompt marker.
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({ lines: linesOf(6), promptRow: 2, totalRows: 3, startCol: 5, cellW: 10, totalCols: 80 })
|
||||
);
|
||||
|
||||
const first = lineDivs(container)[0];
|
||||
expect(first.style.left).toBe('0px');
|
||||
expect(first.style.width).toBe(80 * 10 + 'px');
|
||||
});
|
||||
|
||||
it('rides the cursor on the last VISIBLE line', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({ lines: ['aaa', 'bbb', 'ccc', 'ddd'], promptRow: 9, totalRows: 3, cellH: 20, cellW: 10, startCol: 4 })
|
||||
);
|
||||
|
||||
const cursor = Array.from(container.children).find((el) => el.tagName === 'SPAN') as HTMLSpanElement;
|
||||
// Tail is the last 3 lines, so the cursor sits on row 2 (0-based) of the block…
|
||||
expect(cursor.style.top).toBe(2 * 20 + 'px');
|
||||
// …at column 3, NOT startCol + 3: the indented prompt line is not shown.
|
||||
expect(cursor.style.left).toBe(3 * 10 + 'px');
|
||||
});
|
||||
|
||||
it('lays out straight down when totalRows is absent (unchanged behaviour)', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: linesOf(9), promptRow: 20, cellH: 17 }));
|
||||
|
||||
expect(container.style.top).toBe(20 * 17 + 'px');
|
||||
expect(lineDivs(container)).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('falls back to the terminal row count when totalRows is not passed', () => {
|
||||
// The addon passes totalRows, but a stale bundle / third-party caller may not.
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({ lines: linesOf(4), promptRow: 8, cellH: 17, terminal: { rows: 10, cols: 80 } as never })
|
||||
);
|
||||
|
||||
expect(container.style.top).toBe(6 * 17 + 'px');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* @fileoverview One style vocabulary for everything the `codeman` CLI prints:
|
||||
* palette, glyphs, the small block helpers (heading/rule/kv), width-aware table
|
||||
* layout, a stderr spinner and a y/N confirm.
|
||||
*
|
||||
* Color detection is chalk's alone. It already honors NO_COLOR, FORCE_COLOR,
|
||||
* TERM=dumb and TTY-ness, and a second detector here would disagree with it on
|
||||
* some terminal with no way to tell which one was right.
|
||||
*
|
||||
* The layout math is pure and exported separately from anything that touches a
|
||||
* terminal, which is what lets it be unit-tested with no TTY and reused by
|
||||
* `utils/dependency-report.ts` while that file stays color-free.
|
||||
*
|
||||
* @module cli-style
|
||||
*/
|
||||
|
||||
import chalk, { type ChalkInstance } from 'chalk';
|
||||
import { createInterface } from 'node:readline';
|
||||
// Direct import, not the `utils` barrel: the barrel pulls in node-pty and every
|
||||
// CLI resolver, which a style module has no business loading.
|
||||
import { stripAnsi } from './utils/regex-patterns.js';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Palette and glyphs
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Semantic roles, mirroring the web UI's status language (green fine, yellow waiting, red blocked). */
|
||||
export const palette = {
|
||||
ok: chalk.green,
|
||||
warn: chalk.yellow,
|
||||
err: chalk.red,
|
||||
info: chalk.cyan,
|
||||
muted: chalk.gray,
|
||||
emph: chalk.bold,
|
||||
accent: chalk.magenta,
|
||||
} as const satisfies Record<string, ChalkInstance>;
|
||||
|
||||
/** The glyph vocabulary the CLI already used, in one place. */
|
||||
export const GLYPH = {
|
||||
ok: '✓',
|
||||
fail: '✗',
|
||||
warn: '⚠',
|
||||
idle: '○',
|
||||
dot: '●',
|
||||
arrow: '→',
|
||||
} as const;
|
||||
|
||||
/** Spinner frames (braille, one cell wide in every terminal we support). */
|
||||
export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const;
|
||||
|
||||
/** What a line is reporting, independent of how it is painted. */
|
||||
export type Tone = 'ok' | 'warn' | 'err' | 'idle' | 'info';
|
||||
|
||||
const TONE_GLYPH: Record<Tone, string> = {
|
||||
ok: GLYPH.ok,
|
||||
warn: GLYPH.warn,
|
||||
err: GLYPH.fail,
|
||||
idle: GLYPH.idle,
|
||||
info: GLYPH.dot,
|
||||
};
|
||||
|
||||
const TONE_STYLE: Record<Tone, ChalkInstance> = {
|
||||
ok: palette.ok,
|
||||
warn: palette.warn,
|
||||
err: palette.err,
|
||||
idle: palette.muted,
|
||||
info: palette.info,
|
||||
};
|
||||
|
||||
/** Glyph for a tone. Pure, so the mapping is testable without a terminal. */
|
||||
export function glyphFor(tone: Tone): string {
|
||||
return TONE_GLYPH[tone];
|
||||
}
|
||||
|
||||
/** Paint text in a tone's color. */
|
||||
export function tint(tone: Tone, text: string): string {
|
||||
return TONE_STYLE[tone](text);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Blocks
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Section heading. The blank line above it is part of the existing block idiom. */
|
||||
export function heading(text: string): string {
|
||||
return `\n${palette.emph(text)}`;
|
||||
}
|
||||
|
||||
/** Horizontal rule under a title. */
|
||||
export function rule(width = 40): string {
|
||||
return palette.muted('─'.repeat(Math.max(0, width)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Indented `Label: value` line. `pad` aligns the values of a block by padding
|
||||
* the label column (including its colon), for blocks whose labels differ in
|
||||
* length.
|
||||
*/
|
||||
export function kv(label: string, value: string, pad = 0): string {
|
||||
const key = pad > 0 ? padCell(`${label}:`, pad) : `${label}:`;
|
||||
return ` ${key} ${value}`;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Width-aware layout (pure)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Printed width of a cell: ANSI sequences take no columns. */
|
||||
export function displayWidth(text: string): number {
|
||||
return stripAnsi(text).length;
|
||||
}
|
||||
|
||||
export type CellAlign = 'left' | 'right';
|
||||
|
||||
/** Pad to `width` columns, measuring by display width so colored cells still align. */
|
||||
export function padCell(text: string, width: number, align: CellAlign = 'left'): string {
|
||||
const fill = ' '.repeat(Math.max(0, width - displayWidth(text)));
|
||||
return align === 'right' ? `${fill}${text}` : `${text}${fill}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad AFTER the paint, so the fill stays outside the color run and a trailing
|
||||
* empty column can be trimmed away instead of ending in a reset sequence with
|
||||
* invisible spaces before it.
|
||||
*/
|
||||
export function padStyled(text: string, width: number, paint: (t: string) => string): string {
|
||||
return `${paint(text)}${' '.repeat(Math.max(0, width - displayWidth(text)))}`;
|
||||
}
|
||||
|
||||
/** Widest cell per column. Short rows count as empty cells, never as narrower columns. */
|
||||
export function columnWidths(rows: readonly (readonly string[])[]): number[] {
|
||||
const widths: number[] = [];
|
||||
for (const row of rows) {
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
widths[i] = Math.max(widths[i] ?? 0, displayWidth(row[i] ?? ''));
|
||||
}
|
||||
}
|
||||
return widths;
|
||||
}
|
||||
|
||||
export interface TableOptions {
|
||||
/** Per-column alignment; missing entries are left-aligned. */
|
||||
align?: readonly CellAlign[];
|
||||
/** Spaces between columns. */
|
||||
gap?: number;
|
||||
/** Prefix for every row. */
|
||||
indent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay rows out in columns sized to their widest cell. The last cell of a row is
|
||||
* never padded, so no line carries trailing whitespace.
|
||||
*/
|
||||
export function layoutTable(rows: readonly (readonly string[])[], options: TableOptions = {}): string[] {
|
||||
const { align = [], gap = 1, indent = '' } = options;
|
||||
const widths = columnWidths(rows);
|
||||
const separator = ' '.repeat(Math.max(0, gap));
|
||||
return rows.map((row) => {
|
||||
const cells = row.map((cell, i) => (i === row.length - 1 ? cell : padCell(cell, widths[i], align[i] ?? 'left')));
|
||||
return `${indent}${cells.join(separator)}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** `layoutTable()` as one printable block. */
|
||||
export function table(rows: readonly (readonly string[])[], options: TableOptions = {}): string {
|
||||
return layoutTable(rows, options).join('\n');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Spinner
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const HIDE_CURSOR = '\x1b[?25l';
|
||||
const SHOW_CURSOR = '\x1b[?25h';
|
||||
const CLEAR_LINE = '\x1b[K';
|
||||
|
||||
/** The slice of a stream a spinner needs; `process.stderr` satisfies it. */
|
||||
export interface SpinnerStream {
|
||||
isTTY?: boolean;
|
||||
write(chunk: string): unknown;
|
||||
}
|
||||
|
||||
export interface Spinner {
|
||||
start(): Spinner;
|
||||
/** Change the text mid-flight. Silent on a non-TTY, which prints once and stops. */
|
||||
setText(text: string): void;
|
||||
/** Clear the line, restore the cursor and optionally print a final line. */
|
||||
stop(finalLine?: string): void;
|
||||
}
|
||||
|
||||
export interface SpinnerOptions {
|
||||
stream?: SpinnerStream;
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place progress line on stderr, for the calls that block for tens of seconds
|
||||
* (daemon start, service install). Only a TTY gets the animation: piped output
|
||||
* and journald get the text once, so a log file never fills with `\r` frames.
|
||||
*/
|
||||
export function spinner(text: string, options: SpinnerOptions = {}): Spinner {
|
||||
const stream = options.stream ?? process.stderr;
|
||||
const intervalMs = options.intervalMs ?? 90;
|
||||
const animated = Boolean(stream.isTTY);
|
||||
let label = text;
|
||||
let frame = 0;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
let started = false;
|
||||
let stopped = false;
|
||||
|
||||
const restoreCursor = () => {
|
||||
if (animated) stream.write(SHOW_CURSOR);
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
stream.write(`\r${palette.info(SPINNER_FRAMES[frame % SPINNER_FRAMES.length])} ${label}${CLEAR_LINE}`);
|
||||
frame++;
|
||||
};
|
||||
|
||||
const handle: Spinner = {
|
||||
start() {
|
||||
if (started || stopped) return handle;
|
||||
started = true;
|
||||
if (!animated) {
|
||||
stream.write(`${label}\n`);
|
||||
return handle;
|
||||
}
|
||||
stream.write(HIDE_CURSOR);
|
||||
// A hidden cursor left behind by a Ctrl+C outlives the process, so the
|
||||
// exit hook is not optional.
|
||||
process.once('exit', restoreCursor);
|
||||
render();
|
||||
// Unref'd: a spinner must never be the reason the process stays alive.
|
||||
timer = setInterval(render, intervalMs);
|
||||
timer.unref();
|
||||
return handle;
|
||||
},
|
||||
setText(next: string) {
|
||||
label = next;
|
||||
if (animated && started && !stopped) render();
|
||||
},
|
||||
stop(finalLine?: string) {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (animated && started) {
|
||||
stream.write(`\r${CLEAR_LINE}`);
|
||||
restoreCursor();
|
||||
process.off('exit', restoreCursor);
|
||||
}
|
||||
if (finalLine && animated) stream.write(`${finalLine}\n`);
|
||||
},
|
||||
};
|
||||
return handle;
|
||||
}
|
||||
|
||||
/** Run `work` with a spinner up, stopping it however `work` ends. */
|
||||
export async function withSpinner<T>(text: string, work: () => Promise<T>, options?: SpinnerOptions): Promise<T> {
|
||||
const handle = spinner(text, options).start();
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
handle.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Confirm
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Is there a human on the other end of both halves of the terminal? */
|
||||
export function isInteractive(): boolean {
|
||||
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
||||
}
|
||||
|
||||
/**
|
||||
* y/N prompt. Answers `false` immediately when stdin is not a TTY (a script
|
||||
* piping into the CLI must never hang on an invisible question), so callers
|
||||
* that support a `--force` flag can branch on `isInteractive()` to keep printing
|
||||
* their "pass --force" hint instead.
|
||||
*/
|
||||
export async function confirm(question: string): Promise<boolean> {
|
||||
if (!isInteractive()) return false;
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
rl.once('SIGINT', () => resolve(''));
|
||||
rl.question(`${question} ${palette.muted('[y/N]')} `, resolve);
|
||||
});
|
||||
return /^y(es)?$/i.test(answer.trim());
|
||||
} finally {
|
||||
rl.close();
|
||||
// readline resumes stdin; a still-flowing stdin keeps the process alive.
|
||||
process.stdin.pause();
|
||||
}
|
||||
}
|
||||
+270
-205
@@ -8,7 +8,6 @@
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { createRequire } from 'module';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
@@ -26,6 +25,9 @@ import { isSupportedAttachmentExtension } from './attachment-registry.js';
|
||||
import { daemonStatus, startDaemon, stopDaemon, type WebLaunchOptions } from './daemon-control.js';
|
||||
import { installService, serviceStatus, uninstallService } from './service-installer.js';
|
||||
import { isLoopbackBindHost, isUnauthenticatedNetworkAcknowledged } from './web/network-auth-policy.js';
|
||||
import { confirm, heading, isInteractive, kv, palette, rule, tint, withSpinner, type Tone } from './cli-style.js';
|
||||
import type { ToolResult } from './utils/dependency-checker.js';
|
||||
import type { ReportStyle } from './utils/dependency-report.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkg = require('../package.json') as { version: string };
|
||||
@@ -107,14 +109,14 @@ program
|
||||
.action(async (filePath, options) => {
|
||||
const extension = String(filePath).split('.').pop()?.toLowerCase() || '';
|
||||
if (!isAbsolute(filePath) || !isSupportedAttachmentExtension(extension)) {
|
||||
console.error(chalk.red('✗ attach requires an absolute path to a png, pdf, docx, pptx, md, or txt file'));
|
||||
console.error(palette.err('✗ attach requires an absolute path to a png, pdf, docx, pptx, md, or txt file'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sessionId = options.session || process.env.CODEMAN_SESSION_ID;
|
||||
const apiUrl = options.url || process.env.CODEMAN_API_URL || 'https://127.0.0.1:3000';
|
||||
if (sessionId && (await postAttachment(apiUrl, sessionId, filePath))) {
|
||||
console.log(chalk.green('✓ Attachment card requested'));
|
||||
console.log(palette.ok('✓ Attachment card requested'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -174,7 +176,7 @@ export function resolveSkillTargetPath(options: {
|
||||
function resolveSkillTarget(options: { case?: string }): string {
|
||||
const resolved = resolveSkillTargetPath(options);
|
||||
if (resolved.missingCase !== undefined) {
|
||||
console.error(chalk.red(`✗ Case not found: ${resolved.missingCase}`));
|
||||
console.error(palette.err(`✗ Case not found: ${resolved.missingCase}`));
|
||||
process.exit(1);
|
||||
}
|
||||
return resolved.target;
|
||||
@@ -199,9 +201,9 @@ function reportSkillResult(result: AgentSkillApplyResult, target: string): void
|
||||
};
|
||||
const message = messages[result];
|
||||
if (message.ok) {
|
||||
console.log(chalk.green(`✓ ${message.text}`));
|
||||
console.log(palette.ok(`✓ ${message.text}`));
|
||||
} else {
|
||||
console.error(chalk.red(`✗ ${message.text}`));
|
||||
console.error(palette.err(`✗ ${message.text}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -220,7 +222,7 @@ skillCmd
|
||||
const target = resolveSkillTarget(options);
|
||||
reportSkillResult(await installAgentSkillInto(target), target);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ Failed to install agent skill: ${getErrorMessage(err)}`));
|
||||
console.error(palette.err(`✗ Failed to install agent skill: ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -235,7 +237,7 @@ skillCmd
|
||||
const target = resolveSkillTarget(options);
|
||||
reportSkillResult(await removeAgentSkillFrom(target), target);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ Failed to remove agent skill: ${getErrorMessage(err)}`));
|
||||
console.error(palette.err(`✗ Failed to remove agent skill: ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -252,11 +254,11 @@ sessionCmd
|
||||
try {
|
||||
const manager = getSessionManager();
|
||||
const session = await manager.createSession(options.dir);
|
||||
console.log(chalk.green(`✓ Session started: ${session.id}`));
|
||||
console.log(palette.ok(`✓ Session started: ${session.id}`));
|
||||
console.log(` Working directory: ${session.workingDir}`);
|
||||
console.log(` PID: ${session.pid}`);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ Failed to start session: ${getErrorMessage(err)}`));
|
||||
console.error(palette.err(`✗ Failed to start session: ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -268,70 +270,81 @@ sessionCmd
|
||||
try {
|
||||
const manager = getSessionManager();
|
||||
await manager.stopSession(id);
|
||||
console.log(chalk.green(`✓ Session stopped: ${id}`));
|
||||
console.log(palette.ok(`✓ Session stopped: ${id}`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ Failed to stop session: ${getErrorMessage(err)}`));
|
||||
console.error(palette.err(`✗ Failed to stop session: ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
/** Session status in the shared vocabulary: idle is fine, busy is working, anything else is a problem. */
|
||||
function sessionStatusLabel(status: string): string {
|
||||
if (status === 'idle') return palette.ok('idle');
|
||||
if (status === 'busy') return palette.warn('busy');
|
||||
return palette.err(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one session listing. `codeman list` used to be a copy of this that had
|
||||
* drifted (it lost the stopped and web-server sections), so it now calls the
|
||||
* same renderer and only opts out of those two sections.
|
||||
*/
|
||||
function printSessionList(options: { includeStored: boolean }): void {
|
||||
const manager = getSessionManager();
|
||||
const sessions = manager.getAllSessions();
|
||||
const stored = manager.getStoredSessions();
|
||||
|
||||
if (sessions.length === 0 && Object.keys(stored).length === 0) {
|
||||
console.log(palette.warn('No sessions found'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(heading('Active Sessions:'));
|
||||
if (sessions.length === 0) {
|
||||
console.log(' (none)');
|
||||
} else {
|
||||
for (const session of sessions) {
|
||||
console.log(
|
||||
` ${palette.info(session.id.slice(0, 8))} ${sessionStatusLabel(session.status)} ${session.workingDir}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.includeStored) {
|
||||
const stoppedSessions = Object.values(stored).filter((s) => s.status === 'stopped');
|
||||
if (stoppedSessions.length > 0) {
|
||||
console.log(heading('Stopped Sessions:'));
|
||||
for (const session of stoppedSessions) {
|
||||
const name = session.name ? ` (${session.name})` : '';
|
||||
console.log(
|
||||
` ${palette.muted(session.id.slice(0, 8))} ${palette.muted('stopped')}${name} ${session.workingDir}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Sessions the web server owns: this process has no PTY for them, so they
|
||||
// only exist in the shared state file.
|
||||
const activeSessions = Object.values(stored).filter((s) => s.status !== 'stopped');
|
||||
if (sessions.length === 0 && activeSessions.length > 0) {
|
||||
console.log(heading('Active Sessions (from web server):'));
|
||||
for (const session of activeSessions) {
|
||||
const name = session.name ? ` (${session.name})` : '';
|
||||
const mode = session.mode === 'shell' ? palette.muted(' [shell]') : '';
|
||||
const cost = session.totalCost ? palette.muted(` $${session.totalCost.toFixed(4)}`) : '';
|
||||
console.log(
|
||||
` ${palette.info(session.id.slice(0, 8))} ${sessionStatusLabel(session.status)}${name}${mode}${cost} ${session.workingDir}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
sessionCmd
|
||||
.command('list')
|
||||
.alias('ls')
|
||||
.description('List all sessions')
|
||||
.action(() => {
|
||||
const manager = getSessionManager();
|
||||
const sessions = manager.getAllSessions();
|
||||
const stored = manager.getStoredSessions();
|
||||
|
||||
if (sessions.length === 0 && Object.keys(stored).length === 0) {
|
||||
console.log(chalk.yellow('No sessions found'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold('\nActive Sessions:'));
|
||||
if (sessions.length === 0) {
|
||||
console.log(' (none)');
|
||||
} else {
|
||||
for (const session of sessions) {
|
||||
const status =
|
||||
session.status === 'idle'
|
||||
? chalk.green('idle')
|
||||
: session.status === 'busy'
|
||||
? chalk.yellow('busy')
|
||||
: chalk.red(session.status);
|
||||
console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
const stoppedSessions = Object.values(stored).filter((s) => s.status === 'stopped');
|
||||
if (stoppedSessions.length > 0) {
|
||||
console.log(chalk.bold('\nStopped Sessions:'));
|
||||
for (const session of stoppedSessions) {
|
||||
const name = session.name ? ` (${session.name})` : '';
|
||||
console.log(` ${chalk.gray(session.id.slice(0, 8))} ${chalk.gray('stopped')}${name} ${session.workingDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Show active sessions from state (when web server manages them)
|
||||
const activeSessions = Object.values(stored).filter((s) => s.status !== 'stopped');
|
||||
if (sessions.length === 0 && activeSessions.length > 0) {
|
||||
console.log(chalk.bold('\nActive Sessions (from web server):'));
|
||||
for (const session of activeSessions) {
|
||||
const status =
|
||||
session.status === 'idle'
|
||||
? chalk.green('idle')
|
||||
: session.status === 'busy'
|
||||
? chalk.yellow('busy')
|
||||
: chalk.red(session.status);
|
||||
const name = session.name ? ` (${session.name})` : '';
|
||||
const mode = session.mode === 'shell' ? chalk.gray(' [shell]') : '';
|
||||
const cost = session.totalCost ? chalk.gray(` $${session.totalCost.toFixed(4)}`) : '';
|
||||
console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status}${name}${mode}${cost} ${session.workingDir}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
});
|
||||
.action(() => printSessionList({ includeStored: true }));
|
||||
|
||||
sessionCmd
|
||||
.command('logs <id>')
|
||||
@@ -342,12 +355,12 @@ sessionCmd
|
||||
const output = options.errors ? manager.getSessionError(id) : manager.getSessionOutput(id);
|
||||
|
||||
if (output === null) {
|
||||
console.log(chalk.yellow(`Session ${id} not found or not active`));
|
||||
console.log(palette.warn(`Session ${id} not found or not active`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (output === '') {
|
||||
console.log(chalk.gray('(no output)'));
|
||||
console.log(palette.muted('(no output)'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -374,7 +387,7 @@ taskCmd
|
||||
completionPhrase: options.completion,
|
||||
timeoutMs: options.timeout ? parseInt(options.timeout, 10) : undefined,
|
||||
});
|
||||
console.log(chalk.green(`✓ Task added: ${task.id}`));
|
||||
console.log(palette.ok(`✓ Task added: ${task.id}`));
|
||||
console.log(` Prompt: ${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}`);
|
||||
console.log(` Priority: ${task.priority}`);
|
||||
});
|
||||
@@ -393,26 +406,28 @@ taskCmd
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.log(chalk.yellow('No tasks found'));
|
||||
console.log(palette.warn('No tasks found'));
|
||||
return;
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
pending: chalk.gray,
|
||||
running: chalk.yellow,
|
||||
completed: chalk.green,
|
||||
failed: chalk.red,
|
||||
pending: palette.muted,
|
||||
running: palette.warn,
|
||||
completed: palette.ok,
|
||||
failed: palette.err,
|
||||
};
|
||||
|
||||
console.log(chalk.bold('\nTasks:'));
|
||||
console.log(palette.emph('\nTasks:'));
|
||||
for (const task of tasks) {
|
||||
const color = statusColors[task.status];
|
||||
const prompt = task.prompt.slice(0, 40) + (task.prompt.length > 40 ? '...' : '');
|
||||
console.log(` ${chalk.cyan(task.id.slice(0, 8))} ${color(task.status.padEnd(10))} [${task.priority}] ${prompt}`);
|
||||
console.log(
|
||||
` ${palette.info(task.id.slice(0, 8))} ${color(task.status.padEnd(10))} [${task.priority}] ${prompt}`
|
||||
);
|
||||
}
|
||||
|
||||
const counts = queue.getCount();
|
||||
console.log(chalk.bold('\nSummary:'));
|
||||
console.log(palette.emph('\nSummary:'));
|
||||
console.log(
|
||||
` Pending: ${counts.pending}, Running: ${counts.running}, Completed: ${counts.completed}, Failed: ${counts.failed}`
|
||||
);
|
||||
@@ -427,11 +442,11 @@ taskCmd
|
||||
const task = queue.getTask(id);
|
||||
|
||||
if (!task) {
|
||||
console.log(chalk.red(`Task ${id} not found`));
|
||||
console.log(palette.err(`Task ${id} not found`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold('\nTask Details:'));
|
||||
console.log(palette.emph('\nTask Details:'));
|
||||
console.log(` ID: ${task.id}`);
|
||||
console.log(` Status: ${task.status}`);
|
||||
console.log(` Priority: ${task.priority}`);
|
||||
@@ -441,10 +456,10 @@ taskCmd
|
||||
console.log(` Session: ${task.assignedSessionId}`);
|
||||
}
|
||||
if (task.error) {
|
||||
console.log(` Error: ${chalk.red(task.error)}`);
|
||||
console.log(` Error: ${palette.err(task.error)}`);
|
||||
}
|
||||
if (task.output) {
|
||||
console.log(chalk.bold('\nOutput:'));
|
||||
console.log(palette.emph('\nOutput:'));
|
||||
console.log(task.output.slice(0, 500) + (task.output.length > 500 ? '...' : ''));
|
||||
}
|
||||
console.log('');
|
||||
@@ -457,9 +472,9 @@ taskCmd
|
||||
.action((id) => {
|
||||
const queue = getTaskQueue();
|
||||
if (queue.removeTask(id)) {
|
||||
console.log(chalk.green(`✓ Task removed: ${id}`));
|
||||
console.log(palette.ok(`✓ Task removed: ${id}`));
|
||||
} else {
|
||||
console.log(chalk.red(`Task ${id} not found`));
|
||||
console.log(palette.err(`Task ${id} not found`));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -474,13 +489,13 @@ taskCmd
|
||||
|
||||
if (options.all) {
|
||||
count = queue.clearAll();
|
||||
console.log(chalk.green(`✓ Cleared ${count} tasks`));
|
||||
console.log(palette.ok(`✓ Cleared ${count} tasks`));
|
||||
} else if (options.failed) {
|
||||
count = queue.clearFailed();
|
||||
console.log(chalk.green(`✓ Cleared ${count} failed tasks`));
|
||||
console.log(palette.ok(`✓ Cleared ${count} failed tasks`));
|
||||
} else {
|
||||
count = queue.clearCompleted();
|
||||
console.log(chalk.green(`✓ Cleared ${count} completed tasks`));
|
||||
console.log(palette.ok(`✓ Cleared ${count} completed tasks`));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -503,38 +518,38 @@ ralphCmd
|
||||
}
|
||||
|
||||
if (loop.isRunning()) {
|
||||
console.log(chalk.yellow('Ralph loop is already running'));
|
||||
console.log(palette.warn('Ralph loop is already running'));
|
||||
return;
|
||||
}
|
||||
|
||||
loop.on('taskAssigned', (taskId, sessionId) => {
|
||||
console.log(chalk.cyan(`→ Task ${taskId.slice(0, 8)} assigned to session ${sessionId.slice(0, 8)}`));
|
||||
console.log(palette.info(`→ Task ${taskId.slice(0, 8)} assigned to session ${sessionId.slice(0, 8)}`));
|
||||
});
|
||||
|
||||
loop.on('taskCompleted', (taskId) => {
|
||||
console.log(chalk.green(`✓ Task ${taskId.slice(0, 8)} completed`));
|
||||
console.log(palette.ok(`✓ Task ${taskId.slice(0, 8)} completed`));
|
||||
});
|
||||
|
||||
loop.on('taskFailed', (taskId, error) => {
|
||||
console.log(chalk.red(`✗ Task ${taskId.slice(0, 8)} failed: ${error}`));
|
||||
console.log(palette.err(`✗ Task ${taskId.slice(0, 8)} failed: ${error}`));
|
||||
});
|
||||
|
||||
loop.on('stopped', () => {
|
||||
console.log(chalk.yellow('\nRalph loop stopped'));
|
||||
console.log(palette.warn('\nRalph loop stopped'));
|
||||
printStats(loop.getStats());
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
await loop.start();
|
||||
console.log(chalk.green('✓ Ralph loop started'));
|
||||
console.log(palette.ok('✓ Ralph loop started'));
|
||||
if (options.minHours) {
|
||||
console.log(` Minimum duration: ${options.minHours} hours`);
|
||||
}
|
||||
console.log(chalk.gray(' Press Ctrl+C to stop\n'));
|
||||
console.log(palette.muted(' Press Ctrl+C to stop\n'));
|
||||
|
||||
// Keep process running
|
||||
process.on('SIGINT', () => {
|
||||
console.log(chalk.yellow('\nStopping Ralph loop...'));
|
||||
console.log(palette.warn('\nStopping Ralph loop...'));
|
||||
loop.stop();
|
||||
});
|
||||
});
|
||||
@@ -545,11 +560,11 @@ ralphCmd
|
||||
.action(() => {
|
||||
const loop = getRalphLoop();
|
||||
if (!loop.isRunning()) {
|
||||
console.log(chalk.yellow('Ralph loop is not running'));
|
||||
console.log(palette.warn('Ralph loop is not running'));
|
||||
return;
|
||||
}
|
||||
loop.stop();
|
||||
console.log(chalk.green('✓ Ralph loop stopped'));
|
||||
console.log(palette.ok('✓ Ralph loop stopped'));
|
||||
});
|
||||
|
||||
ralphCmd
|
||||
@@ -562,9 +577,10 @@ ralphCmd
|
||||
});
|
||||
|
||||
function printStats(stats: ReturnType<ReturnType<typeof getRalphLoop>['getStats']>) {
|
||||
const statusColor = stats.status === 'running' ? chalk.green : stats.status === 'paused' ? chalk.yellow : chalk.gray;
|
||||
const statusColor =
|
||||
stats.status === 'running' ? palette.ok : stats.status === 'paused' ? palette.warn : palette.muted;
|
||||
|
||||
console.log(chalk.bold('\nRalph Loop Status:'));
|
||||
console.log(palette.emph('\nRalph Loop Status:'));
|
||||
console.log(` Status: ${statusColor(stats.status)}`);
|
||||
console.log(` Elapsed: ${stats.elapsedHours.toFixed(2)} hours`);
|
||||
if (stats.minDurationMs) {
|
||||
@@ -574,14 +590,14 @@ function printStats(stats: ReturnType<ReturnType<typeof getRalphLoop>['getStats'
|
||||
);
|
||||
}
|
||||
|
||||
console.log(chalk.bold('\nTasks:'));
|
||||
console.log(palette.emph('\nTasks:'));
|
||||
console.log(` Pending: ${stats.pending}`);
|
||||
console.log(` Running: ${stats.running}`);
|
||||
console.log(` Completed: ${stats.completed} (${stats.tasksCompleted} this session)`);
|
||||
console.log(` Failed: ${stats.failed}`);
|
||||
console.log(` Generated: ${stats.tasksGenerated}`);
|
||||
|
||||
console.log(chalk.bold('\nSessions:'));
|
||||
console.log(palette.emph('\nSessions:'));
|
||||
console.log(` Active: ${stats.activeSessions}`);
|
||||
console.log(` Idle: ${stats.idleSessions}`);
|
||||
console.log(` Busy: ${stats.busySessions}`);
|
||||
@@ -695,20 +711,20 @@ program
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.bold('\nCodeman Status'));
|
||||
console.log('─'.repeat(40));
|
||||
console.log(heading('Codeman Status'));
|
||||
console.log(rule(40));
|
||||
|
||||
console.log(chalk.bold('\nWeb Server:'));
|
||||
console.log(heading('Web Server:'));
|
||||
if (probe.reachable) {
|
||||
const version = probe.version ? ` (v${probe.version})` : '';
|
||||
console.log(` Status: ${chalk.green('running')}${version} at ${probe.url}`);
|
||||
console.log(kv('Status', `${palette.ok('running')}${version} at ${probe.url}`));
|
||||
if (probe.authRequired) {
|
||||
console.log(chalk.gray(' (answers 401: set CODEMAN_PASSWORD/CODEMAN_USERNAME to see session details)'));
|
||||
console.log(palette.muted(' (answers 401: set CODEMAN_PASSWORD/CODEMAN_USERNAME to see session details)'));
|
||||
}
|
||||
} else {
|
||||
console.log(` Status: ${chalk.red('not reachable')} at ${candidates.join(' or ')}`);
|
||||
console.log(kv('Status', `${palette.err('not reachable')} at ${candidates.join(' or ')}`));
|
||||
console.log(
|
||||
chalk.gray(' (start it with `codeman web`, or check your service: systemctl --user status codeman-web)')
|
||||
palette.muted(' (start it with `codeman web`, or check your service: systemctl --user status codeman-web)')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -716,26 +732,26 @@ program
|
||||
// as such, so the numbers are never silently a different thing.
|
||||
if (probe.sessions) {
|
||||
const live = probe.sessions;
|
||||
console.log(chalk.bold('\nSessions (live, from the server):'));
|
||||
console.log(` Total: ${live.length}`);
|
||||
console.log(` Idle: ${live.filter((s) => s.status === 'idle').length}`);
|
||||
console.log(` Busy: ${live.filter((s) => s.status === 'busy').length}`);
|
||||
console.log(heading('Sessions (live, from the server):'));
|
||||
console.log(kv('Total', String(live.length)));
|
||||
console.log(kv('Idle', String(live.filter((s) => s.status === 'idle').length)));
|
||||
console.log(kv('Busy', String(live.filter((s) => s.status === 'busy').length)));
|
||||
} else {
|
||||
const manager = getSessionManager();
|
||||
const storedValues = Object.values(manager.getStoredSessions());
|
||||
console.log(chalk.bold('\nSessions (from saved state):'));
|
||||
console.log(` Active: ${storedValues.filter((s) => s.status !== 'stopped').length}`);
|
||||
console.log(` Idle: ${storedValues.filter((s) => s.status === 'idle').length}`);
|
||||
console.log(` Busy: ${storedValues.filter((s) => s.status === 'busy').length}`);
|
||||
console.log(heading('Sessions (from saved state):'));
|
||||
console.log(kv('Active', String(storedValues.filter((s) => s.status !== 'stopped').length)));
|
||||
console.log(kv('Idle', String(storedValues.filter((s) => s.status === 'idle').length)));
|
||||
console.log(kv('Busy', String(storedValues.filter((s) => s.status === 'busy').length)));
|
||||
}
|
||||
|
||||
const taskCounts = getTaskQueue().getCount();
|
||||
console.log(chalk.bold('\nTasks:'));
|
||||
console.log(` Total: ${taskCounts.total}`);
|
||||
console.log(` Pending: ${taskCounts.pending}`);
|
||||
console.log(` Running: ${taskCounts.running}`);
|
||||
console.log(` Completed: ${taskCounts.completed}`);
|
||||
console.log(` Failed: ${taskCounts.failed}`);
|
||||
console.log(heading('Tasks:'));
|
||||
console.log(kv('Total', String(taskCounts.total)));
|
||||
console.log(kv('Pending', String(taskCounts.pending)));
|
||||
console.log(kv('Running', String(taskCounts.running)));
|
||||
console.log(kv('Completed', String(taskCounts.completed)));
|
||||
console.log(kv('Failed', String(taskCounts.failed)));
|
||||
console.log('');
|
||||
});
|
||||
|
||||
@@ -745,9 +761,17 @@ program
|
||||
.option('-f, --force', 'Skip confirmation')
|
||||
.action(async (options) => {
|
||||
if (!options.force) {
|
||||
console.log(chalk.yellow('This will stop all sessions and clear all state.'));
|
||||
console.log(chalk.yellow('Use --force to confirm.'));
|
||||
return;
|
||||
console.log(palette.warn('This will stop all sessions and clear all state.'));
|
||||
// Non-interactive callers keep the old refusal: a script piping into the
|
||||
// CLI must never be able to reset state by hanging on an unseen question.
|
||||
if (!isInteractive()) {
|
||||
console.log(palette.warn('Use --force to confirm.'));
|
||||
return;
|
||||
}
|
||||
if (!(await confirm('Reset all Codeman state?'))) {
|
||||
console.log(palette.muted('○ Cancelled, nothing was changed'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const manager = getSessionManager();
|
||||
@@ -756,7 +780,7 @@ program
|
||||
await manager.stopAllSessions();
|
||||
store.reset();
|
||||
|
||||
console.log(chalk.green('✓ All state reset'));
|
||||
console.log(palette.ok('✓ All state reset'));
|
||||
});
|
||||
|
||||
// Shorthand commands at root level
|
||||
@@ -767,38 +791,45 @@ program
|
||||
.action(async (options) => {
|
||||
const manager = getSessionManager();
|
||||
const session = await manager.createSession(options.dir);
|
||||
console.log(chalk.green(`✓ Session started: ${session.id}`));
|
||||
console.log(palette.ok(`✓ Session started: ${session.id}`));
|
||||
});
|
||||
|
||||
program
|
||||
.command('list')
|
||||
.alias('ls')
|
||||
.description('List all sessions (shorthand)')
|
||||
.action(() => {
|
||||
const manager = getSessionManager();
|
||||
const sessions = manager.getAllSessions();
|
||||
const stored = manager.getStoredSessions();
|
||||
.description('List active sessions (shorthand; `codeman session list` also shows stopped ones)')
|
||||
.action(() => printSessionList({ includeStored: false }));
|
||||
|
||||
if (sessions.length === 0 && Object.keys(stored).length === 0) {
|
||||
console.log(chalk.yellow('No sessions found'));
|
||||
// ============ TUI ============
|
||||
|
||||
program
|
||||
.command('tui')
|
||||
.argument('[n]', 'attach straight to the nth session of `codeman tui --list`')
|
||||
.description('Terminal dashboard for your sessions (the web UI remains the primary surface)')
|
||||
.option('-l, --list', 'Print the numbered session list and exit, instead of opening the dashboard')
|
||||
.action(async (position: string | undefined, options: { list?: boolean }) => {
|
||||
// Imported here, not at the top: the dashboard pulls in the whole TUI core,
|
||||
// and every other command would pay for it at startup.
|
||||
const { runTui, runTuiAttach, runTuiList } = await import('./tui/tui-app.js');
|
||||
|
||||
if (options.list) {
|
||||
process.exitCode = await runTuiList();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold('\nActive Sessions:'));
|
||||
if (sessions.length === 0) {
|
||||
console.log(' (none)');
|
||||
} else {
|
||||
for (const session of sessions) {
|
||||
const status =
|
||||
session.status === 'idle'
|
||||
? chalk.green('idle')
|
||||
: session.status === 'busy'
|
||||
? chalk.yellow('busy')
|
||||
: chalk.red(session.status);
|
||||
console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`);
|
||||
if (position !== undefined) {
|
||||
const n = Number.parseInt(position, 10);
|
||||
if (!Number.isSafeInteger(n) || n < 1) {
|
||||
console.error(palette.err(`"${position}" is not a session number.`));
|
||||
console.error(`Run ${palette.info('codeman tui --list')} to see them.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
process.exitCode = await runTuiAttach(n);
|
||||
return;
|
||||
}
|
||||
console.log('');
|
||||
// The dashboard owns the terminal until it quits; exiting explicitly keeps a
|
||||
// stray handle (a socket mid-close) from stranding the user's shell.
|
||||
process.exit(await runTui());
|
||||
});
|
||||
|
||||
// ============ Web / daemon / service Commands ============
|
||||
@@ -831,7 +862,7 @@ function toWebLaunchOptions(options: {
|
||||
}): WebLaunchOptions {
|
||||
const port = parseInt(options.port, 10);
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
console.error(chalk.red(`✗ Invalid port: ${options.port}`));
|
||||
console.error(palette.err(`✗ Invalid port: ${options.port}`));
|
||||
process.exit(1);
|
||||
}
|
||||
return {
|
||||
@@ -852,11 +883,11 @@ function warnIfUnauthenticatedNetwork(launch: WebLaunchOptions): void {
|
||||
if (isLoopbackBindHost(launch.host)) return;
|
||||
if (isUnauthenticatedNetworkAcknowledged(launch.allowUnauthenticatedNetwork)) return;
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
palette.warn(
|
||||
`⚠ Binding ${launch.host} without CODEMAN_PASSWORD: anyone who can reach this port gets terminal control.`
|
||||
)
|
||||
);
|
||||
console.log(chalk.yellow(' Set CODEMAN_PASSWORD, or bind 127.0.0.1 and front it with tailscale serve.'));
|
||||
console.log(palette.warn(' Set CODEMAN_PASSWORD, or bind 127.0.0.1 and front it with tailscale serve.'));
|
||||
}
|
||||
|
||||
// Web interface command
|
||||
@@ -872,17 +903,18 @@ webCmd.action(async (options) => {
|
||||
const launch = toWebLaunchOptions(options);
|
||||
|
||||
if (options.stop) {
|
||||
const result = await stopDaemon(launch);
|
||||
// stopDaemon waits for the process to actually exit (up to 15s).
|
||||
const result = await withSpinner('Stopping Codeman...', () => stopDaemon(launch));
|
||||
if (result.ok && result.reason === 'not-running') {
|
||||
console.log(chalk.gray(`○ ${result.message}`));
|
||||
console.log(palette.muted(`○ ${result.message}`));
|
||||
return;
|
||||
}
|
||||
if (result.ok) {
|
||||
console.log(chalk.green(`✓ ${result.message ?? `Stopped Codeman (pid ${result.pid})`}`));
|
||||
console.log(chalk.gray(' Your agents keep running in tmux.'));
|
||||
console.log(palette.ok(`✓ ${result.message ?? `Stopped Codeman (pid ${result.pid})`}`));
|
||||
console.log(palette.muted(' Your agents keep running in tmux.'));
|
||||
return;
|
||||
}
|
||||
console.error(chalk.red(`✗ ${result.message ?? 'Could not stop the server'}`));
|
||||
console.error(palette.err(`✗ ${result.message ?? 'Could not stop the server'}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -890,31 +922,33 @@ webCmd.action(async (options) => {
|
||||
const status = await daemonStatus(launch);
|
||||
if (status.responding) {
|
||||
const version = status.version ? ` (v${status.version})` : '';
|
||||
console.log(chalk.green(`✓ Responding at ${status.url}${version}`));
|
||||
console.log(palette.ok(`✓ Responding at ${status.url}${version}`));
|
||||
} else {
|
||||
console.log(chalk.yellow(`○ Nothing answering at ${status.url}`));
|
||||
console.log(palette.warn(`○ Nothing answering at ${status.url}`));
|
||||
}
|
||||
console.log(` Daemon pid: ${status.running ? chalk.green(String(status.pid)) : chalk.gray('not running')}`);
|
||||
console.log(chalk.gray(` Pidfile: ${status.pidFile}`));
|
||||
console.log(chalk.gray(` Log: ${status.logPath}`));
|
||||
console.log(kv('Daemon pid', status.running ? palette.ok(String(status.pid)) : palette.muted('not running'), 11));
|
||||
console.log(palette.muted(kv('Pidfile', status.pidFile, 11)));
|
||||
console.log(palette.muted(kv('Log', status.logPath, 11)));
|
||||
if (!status.running && status.responding) {
|
||||
console.log(chalk.gray(' (running, but not started with --daemon: probably a service or a foreground run)'));
|
||||
console.log(palette.muted(' (running, but not started with --daemon: probably a service or a foreground run)'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.daemon) {
|
||||
warnIfUnauthenticatedNetwork(launch);
|
||||
console.log(chalk.cyan('Starting Codeman in the background...'));
|
||||
const result = await startDaemon(launch);
|
||||
// The start polls /api/status for up to 30s; without this the shell just sits there.
|
||||
const result = await withSpinner('Starting Codeman in the background, waiting for it to answer...', () =>
|
||||
startDaemon(launch)
|
||||
);
|
||||
if (result.ok) {
|
||||
console.log(chalk.green(`\n✓ Codeman is running at ${result.url} (pid ${result.pid})`));
|
||||
console.log(chalk.gray(` Logs: ${result.logPath}`));
|
||||
console.log(chalk.gray(' Stop it with: codeman web --stop'));
|
||||
console.log(chalk.gray(' Want it back after a reboot? codeman service install'));
|
||||
console.log(palette.ok(`\n✓ Codeman is running at ${result.url} (pid ${result.pid})`));
|
||||
console.log(palette.muted(` Logs: ${result.logPath}`));
|
||||
console.log(palette.muted(' Stop it with: codeman web --stop'));
|
||||
console.log(palette.muted(' Want it back after a reboot? codeman service install'));
|
||||
return;
|
||||
}
|
||||
console.error(chalk.red(`\n✗ ${result.message ?? 'Failed to start'}`));
|
||||
console.error(palette.err(`\n✗ ${result.message ?? 'Failed to start'}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -924,29 +958,29 @@ webCmd.action(async (options) => {
|
||||
const https = launch.https;
|
||||
const titleHostname = options.titleHostname;
|
||||
const allowUnauthenticatedNetwork = launch.allowUnauthenticatedNetwork ?? false;
|
||||
const protocol = https ? 'https' : 'http';
|
||||
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
|
||||
|
||||
console.log(chalk.cyan(`Starting Codeman web interface on ${displayHost}:${port}${https ? ' (HTTPS)' : ''}...`));
|
||||
console.log(palette.info(`Starting Codeman web interface on ${displayHost}:${port}${https ? ' (HTTPS)' : ''}...`));
|
||||
|
||||
try {
|
||||
// The server prints its own "running at" line (it also covers the daemon and
|
||||
// service launch paths), so this one used to be a duplicate of it.
|
||||
const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork);
|
||||
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://${displayHost}:${port}`));
|
||||
if (https) {
|
||||
console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit'));
|
||||
console.log(palette.warn(' Note: Accept the self-signed certificate in your browser on first visit'));
|
||||
}
|
||||
console.log(chalk.gray(' Press Ctrl+C to stop\n'));
|
||||
console.log(palette.muted(' Press Ctrl+C to stop\n'));
|
||||
|
||||
// Graceful shutdown handler — flush state and clean up on SIGTERM/SIGINT
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (signal: string) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.log(chalk.yellow(`\n${signal} received, shutting down gracefully...`));
|
||||
console.log(palette.warn(`\n${signal} received, shutting down gracefully...`));
|
||||
try {
|
||||
await server.stop();
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error during shutdown: ${getErrorMessage(err)}`));
|
||||
console.error(palette.err(`Error during shutdown: ${getErrorMessage(err)}`));
|
||||
}
|
||||
process.exit(0);
|
||||
};
|
||||
@@ -954,7 +988,7 @@ webCmd.action(async (options) => {
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGHUP', () => shutdown('SIGHUP'));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ Failed to start web server: ${getErrorMessage(err)}`));
|
||||
console.error(palette.err(`✗ Failed to start web server: ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -970,20 +1004,23 @@ addWebLaunchOptions(
|
||||
).action(async (options) => {
|
||||
const launch = toWebLaunchOptions(options);
|
||||
warnIfUnauthenticatedNetwork(launch);
|
||||
console.log(chalk.cyan('Installing the Codeman service...'));
|
||||
|
||||
const result = await installService(launch);
|
||||
for (const warning of result.warnings ?? []) console.log(chalk.yellow(`⚠ ${warning}`));
|
||||
// Install polls the new unit's /api/status for up to 30s before it can honestly
|
||||
// report success, so the wait needs a visible heartbeat.
|
||||
const result = await withSpinner('Installing the Codeman service, waiting for it to answer...', () =>
|
||||
installService(launch)
|
||||
);
|
||||
for (const warning of result.warnings ?? []) console.log(palette.warn(`⚠ ${warning}`));
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(chalk.red(`✗ ${result.message}`));
|
||||
console.error(palette.err(`✗ ${result.message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(chalk.green(`✓ ${result.message}`));
|
||||
console.log(chalk.gray(` Unit: ${result.unitPath}`));
|
||||
console.log(palette.ok(`✓ ${result.message}`));
|
||||
console.log(palette.muted(` Unit: ${result.unitPath}`));
|
||||
if (process.env.CODEMAN_PASSWORD) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
palette.warn(
|
||||
' Note: CODEMAN_PASSWORD was NOT copied into the unit file. Add it there yourself if the service needs auth.'
|
||||
)
|
||||
);
|
||||
@@ -996,10 +1033,10 @@ serviceCmd
|
||||
.action(() => {
|
||||
const result = uninstallService();
|
||||
if (!result.ok) {
|
||||
console.error(chalk.red(`✗ ${result.message}`));
|
||||
console.error(palette.err(`✗ ${result.message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(chalk.green(`✓ ${result.message}`));
|
||||
console.log(palette.ok(`✓ ${result.message}`));
|
||||
});
|
||||
|
||||
addWebLaunchOptions(
|
||||
@@ -1007,15 +1044,15 @@ addWebLaunchOptions(
|
||||
).action(async (options) => {
|
||||
const status = await serviceStatus(toWebLaunchOptions(options));
|
||||
if (!status.kind) {
|
||||
console.log(chalk.yellow(`No supported supervisor on ${process.platform}. Use \`codeman web -d\` instead.`));
|
||||
console.log(palette.warn(`No supported supervisor on ${process.platform}. Use \`codeman web -d\` instead.`));
|
||||
return;
|
||||
}
|
||||
console.log(` Supervisor: ${status.kind} (${status.name})`);
|
||||
console.log(` Unit file: ${status.installed ? chalk.green(status.unitPath) : chalk.gray('not installed')}`);
|
||||
console.log(` Loaded: ${status.loaded ? chalk.green('yes') : chalk.gray('no')}`);
|
||||
console.log(` Unit file: ${status.installed ? palette.ok(status.unitPath) : palette.muted('not installed')}`);
|
||||
console.log(` Loaded: ${status.loaded ? palette.ok('yes') : palette.muted('no')}`);
|
||||
const version = status.version ? ` (v${status.version})` : '';
|
||||
console.log(
|
||||
` Responding: ${status.responding ? chalk.green(`yes at ${status.url}${version}`) : chalk.gray(`no at ${status.url}`)}`
|
||||
` Responding: ${status.responding ? palette.ok(`yes at ${status.url}${version}`) : palette.muted(`no at ${status.url}`)}`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1085,7 +1122,7 @@ usersCmd
|
||||
.action(async (name, options) => {
|
||||
const { createUser, isValidUsername } = await import('./user-store.js');
|
||||
if (!isValidUsername(name)) {
|
||||
console.error(chalk.red('✗ Username must be lowercase, start alphanumeric, 2-32 chars ([a-z0-9_-])'));
|
||||
console.error(palette.err('✗ Username must be lowercase, start alphanumeric, 2-32 chars ([a-z0-9_-])'));
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
@@ -1096,18 +1133,18 @@ usersCmd
|
||||
password = await promptHiddenPassword('New password: ');
|
||||
const confirm = await promptHiddenPassword('Confirm password: ');
|
||||
if (password !== confirm) {
|
||||
console.error(chalk.red('✗ Passwords do not match'));
|
||||
console.error(palette.err('✗ Passwords do not match'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
console.error(chalk.red('✗ Password must be at least 8 characters'));
|
||||
console.error(palette.err('✗ Password must be at least 8 characters'));
|
||||
process.exit(1);
|
||||
}
|
||||
const user = await createUser({ username: name, role: options.admin ? 'admin' : 'user', password });
|
||||
console.log(chalk.green(`✓ Created ${user.role} "${user.username}"`));
|
||||
console.log(palette.ok(`✓ Created ${user.role} "${user.username}"`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ ${getErrorMessage(err)}`));
|
||||
console.error(palette.err(`✗ ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -1126,14 +1163,14 @@ usersCmd
|
||||
password = await promptHiddenPassword('New password: ');
|
||||
const confirm = await promptHiddenPassword('Confirm password: ');
|
||||
if (password !== confirm) {
|
||||
console.error(chalk.red('✗ Passwords do not match'));
|
||||
console.error(palette.err('✗ Passwords do not match'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
await setPassword(name, password, { mustChangePassword: false });
|
||||
console.log(chalk.green(`✓ Password updated for "${name}"`));
|
||||
console.log(palette.ok(`✓ Password updated for "${name}"`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ ${getErrorMessage(err)}`));
|
||||
console.error(palette.err(`✗ ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -1146,17 +1183,17 @@ usersCmd
|
||||
const { readUsers } = await import('./user-store.js');
|
||||
const users = await readUsers(true);
|
||||
if (users.length === 0) {
|
||||
console.log(chalk.yellow('No users defined (run: codeman users add <name> --admin)'));
|
||||
console.log(palette.warn('No users defined (run: codeman users add <name> --admin)'));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.bold('\nUsers:'));
|
||||
console.log(palette.emph('\nUsers:'));
|
||||
for (const u of users) {
|
||||
const role = u.role === 'admin' ? chalk.magenta('admin') : chalk.cyan('user ');
|
||||
const state = u.disabled ? chalk.red('disabled') : chalk.green('enabled ');
|
||||
const role = u.role === 'admin' ? palette.accent('admin') : palette.info('user ');
|
||||
const state = u.disabled ? palette.err('disabled') : palette.ok('enabled ');
|
||||
const flags = [u.mustChangePassword ? 'must-change-pw' : '', u.canBypassPermissions ? 'can-bypass' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
console.log(` ${role} ${state} ${u.username}${flags ? chalk.gray(` [${flags}]`) : ''}`);
|
||||
console.log(` ${role} ${state} ${u.username}${flags ? palette.muted(` [${flags}]`) : ''}`);
|
||||
}
|
||||
console.log('');
|
||||
});
|
||||
@@ -1171,16 +1208,43 @@ usersCmd
|
||||
await deleteUser(name);
|
||||
if (options.deleteSpace) {
|
||||
await deleteUserSpace(name);
|
||||
console.log(chalk.green(`✓ Deleted user "${name}" and their space`));
|
||||
console.log(palette.ok(`✓ Deleted user "${name}" and their space`));
|
||||
} else {
|
||||
console.log(chalk.green(`✓ Deleted user "${name}" (space left on disk)`));
|
||||
console.log(palette.ok(`✓ Deleted user "${name}" (space left on disk)`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ ${getErrorMessage(err)}`));
|
||||
console.error(palette.err(`✗ ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Missing REQUIRED tools are failures; a missing optional one or a skipped check
|
||||
* is just absence, so it stays muted rather than shouting red at everyone
|
||||
* without LibreOffice installed.
|
||||
*/
|
||||
function dependencyTone(result: ToolResult): Tone {
|
||||
if (result.status === 'ok') return 'ok';
|
||||
if (result.status === 'skipped') return 'idle';
|
||||
return result.required ? 'err' : 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* The colorize hook `dependency-report.ts` was written for. Versions stay in the
|
||||
* default color (they are data, not a verdict); everything that IS a verdict is
|
||||
* painted, and the supporting detail is muted so the glyph column reads first.
|
||||
*/
|
||||
const DOCTOR_STYLE: ReportStyle = {
|
||||
title: (text) => palette.emph(text),
|
||||
heading: (text) => palette.emph(palette.info(text)),
|
||||
glyph: (result, glyph) => tint(dependencyTone(result), glyph),
|
||||
label: (text) => text,
|
||||
status: (result, text) => (result.status === 'ok' ? text : tint(dependencyTone(result), text)),
|
||||
path: (text) => palette.muted(text),
|
||||
meta: (text) => palette.muted(text),
|
||||
summary: (text) => palette.emph(text),
|
||||
};
|
||||
|
||||
program
|
||||
.command('doctor')
|
||||
.alias('check-deps')
|
||||
@@ -1204,9 +1268,10 @@ program
|
||||
const results = checkAll(registry, host);
|
||||
|
||||
if (options.json) {
|
||||
// Raw JSON, never styled: this output is parsed, not read.
|
||||
console.log(JSON.stringify(renderJson(results, host.environment), null, 2));
|
||||
} else {
|
||||
console.log(renderTable(results, host.environment));
|
||||
console.log(renderTable(results, host.environment, DOCTOR_STYLE));
|
||||
}
|
||||
process.exit(computeExitCode(results));
|
||||
});
|
||||
|
||||
@@ -40,6 +40,21 @@ const INSTANCE_SUFFIX = CODEMAN_INSTANCE ? `-${CODEMAN_INSTANCE}` : '';
|
||||
/** Default tmux socket for this instance. `CODEMAN_TMUX_SOCKET` still overrides. */
|
||||
export const DEFAULT_TMUX_SOCKET = `codeman${INSTANCE_SUFFIX}`;
|
||||
|
||||
/** Characters tmux accepts in a `-L` socket name. */
|
||||
export const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/;
|
||||
|
||||
/**
|
||||
* This instance's tmux socket: the `CODEMAN_TMUX_SOCKET` override when it is a
|
||||
* safe name, else the instance default. Every process that runs `tmux -L` has
|
||||
* to resolve it through here (the server via TmuxManager, the TUI for its
|
||||
* degraded-mode listing), or a beta instance ends up driving prod's sessions.
|
||||
*/
|
||||
export function resolveTmuxSocketName(): string {
|
||||
const raw = process.env.CODEMAN_TMUX_SOCKET;
|
||||
if (raw !== undefined && SAFE_TMUX_SOCKET_PATTERN.test(raw)) return raw;
|
||||
return DEFAULT_TMUX_SOCKET;
|
||||
}
|
||||
|
||||
let _ensured = false;
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
* src/web/public/constants.js and deliberately stays at 50k — 100k xterm lines per tab
|
||||
* is a mobile-memory hazard — so DEFAULT_TERMINAL_SCROLLBACK_LINES stays 50,000 to match.
|
||||
* The terminalScrollbackLines/terminalBufferMaxBytes/terminalBufferTrimBytes settings keys
|
||||
* remain schema-validated but inert (a follow-up wires them); only tmuxHistoryLimit is live.
|
||||
* remain schema-validated but inert (a follow-up wires them); only tmuxHistoryLimit is wired.
|
||||
* tmux <3.7 applies it to new panes; tmux 3.7+ can also resize live panes.
|
||||
* All values remain env- and settings-overridable and bounds-clamped via
|
||||
* resolveTerminalHistoryConfig().
|
||||
*/
|
||||
|
||||
+11
-2
@@ -479,14 +479,23 @@ export function gitNonInteractiveEnv(base: NodeJS.ProcessEnv = process.env): Nod
|
||||
|
||||
// ─── Pure: output handling ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Redact any `scheme://user:secret@host` credential pair embedded in text — a
|
||||
* remote URL stored with an inline token, or git stderr echoing such a URL
|
||||
* back. Shared by the clone error path (`sanitizeGitOutput`) and the
|
||||
* repository-status card fields (`web/repo-status.ts`).
|
||||
*/
|
||||
export function redactGitCredentials(text: string): string {
|
||||
return text.replace(/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/@\s]*:[^/@\s]*@/g, '$1***:***@');
|
||||
}
|
||||
|
||||
/**
|
||||
* Make git's stderr safe to show in the browser: strip ANSI/control bytes,
|
||||
* redact any `scheme://user:secret@host` that a credential helper echoed back,
|
||||
* and keep only the tail (the last lines are the ones that say why it failed).
|
||||
*/
|
||||
export function sanitizeGitOutput(text: string, maxBytes = MAX_STDERR_BYTES): string {
|
||||
const redacted = text
|
||||
.replace(/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/@\s]*:[^/@\s]*@/g, '$1***:***@')
|
||||
const redacted = redactGitCredentials(text)
|
||||
// eslint-disable-next-line no-control-regex -- deliberate: strip C0/C1 and DEL.
|
||||
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '')
|
||||
.trim();
|
||||
|
||||
@@ -84,7 +84,7 @@ export interface CreateSessionOptions {
|
||||
envOverrides?: Record<string, string>;
|
||||
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
|
||||
effort?: EffortLevel;
|
||||
/** tmux history-limit (scrollback lines) to set for this session. */
|
||||
/** tmux history-limit (scrollback lines) allocated when this session is created. */
|
||||
historyLimit?: number;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
remote?: SessionRemote;
|
||||
@@ -116,7 +116,7 @@ export interface RespawnPaneOptions {
|
||||
envOverrides?: Record<string, string>;
|
||||
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
|
||||
effort?: EffortLevel;
|
||||
/** tmux history-limit (scrollback lines) to set for this session after respawn. */
|
||||
/** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */
|
||||
historyLimit?: number;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
remote?: SessionRemote;
|
||||
@@ -216,7 +216,7 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
/** Update Ralph enabled state for a session */
|
||||
updateRalphEnabled(sessionId: string, enabled: boolean): void;
|
||||
|
||||
/** Apply a tmux history-limit to all tracked sessions. */
|
||||
/** Apply history-limit to live panes where tmux supports it, otherwise to future panes. */
|
||||
setHistoryLimit(limit: number): Promise<void>;
|
||||
|
||||
// ========== Discovery ==========
|
||||
|
||||
+8
-1
@@ -281,7 +281,14 @@ export class RalphLoop extends EventEmitter {
|
||||
// Guard: only reschedule if still running AND no timer is pending
|
||||
// (prevents race where stop() clears timer between our check and setTimeout)
|
||||
if (this._status === 'running' && this.loopTimer === null) {
|
||||
this.loopTimer = setTimeout(() => this.runLoop(), this.pollIntervalMs);
|
||||
// Null the handle when the timer fires, BEFORE re-entering runLoop —
|
||||
// otherwise the `loopTimer === null` guard above stays false on the
|
||||
// next pass and the loop stops rescheduling after 2 ticks.
|
||||
// Mirrors the orchestrator-loop reschedule pattern.
|
||||
this.loopTimer = setTimeout(() => {
|
||||
this.loopTimer = null;
|
||||
this.runLoop();
|
||||
}, this.pollIntervalMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+61
-3
@@ -415,6 +415,15 @@ export class Session extends EventEmitter {
|
||||
// sequences split across PTY chunks can't slip past the alt-screen/scrollback
|
||||
// strip (see _handleTerminalOutput / isAltScreenStripMode)
|
||||
private _altScreenSeqCarry: string = '';
|
||||
|
||||
/**
|
||||
* Mouse-tracking DECSET modes the CLI currently has ON, as observed while
|
||||
* STRIPPING them out of the stream below. Kept as a set rather than a boolean
|
||||
* because a TUI may enable 1002 and later disable 1000 (a mode it never
|
||||
* enabled); tracking is on while any of them is.
|
||||
*/
|
||||
private _cliMouseModes = new Set<number>();
|
||||
private _cliMouseTracking = false;
|
||||
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
|
||||
private rejectPromise: ((reason: Error) => void) | null = null;
|
||||
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
|
||||
@@ -510,7 +519,7 @@ export class Session extends EventEmitter {
|
||||
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
|
||||
private _effort: EffortLevel | undefined;
|
||||
|
||||
// tmux history-limit (scrollback lines) applied to this session's pane.
|
||||
// tmux history-limit (scrollback lines) allocated when this session's pane is created.
|
||||
private readonly _tmuxHistoryLimit: number;
|
||||
|
||||
// Remote execution metadata, present when this session runs over SSH through local tmux.
|
||||
@@ -600,7 +609,7 @@ export class Session extends EventEmitter {
|
||||
envOverrides?: Record<string, string>;
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort?: EffortLevel;
|
||||
/** tmux history-limit (scrollback lines) for this session's pane. */
|
||||
/** tmux history-limit (scrollback lines) allocated when this session's pane is created. */
|
||||
tmuxHistoryLimit?: number;
|
||||
/** Restored per-session attachment history. May include server-private external paths. */
|
||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
@@ -1285,6 +1294,7 @@ export class Session extends EventEmitter {
|
||||
niceValue: this._niceConfig.niceValue,
|
||||
color: this._color,
|
||||
flickerFilterEnabled: this._flickerFilterEnabled,
|
||||
cliMouseTracking: this._cliMouseTracking || undefined,
|
||||
cliVersion: this._cliVersion || undefined,
|
||||
cliModel: this._cliModel || undefined,
|
||||
cliAccountType: this._cliAccountType || undefined,
|
||||
@@ -1546,6 +1556,46 @@ export class Session extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember whether the CLI currently wants to be told about mouse clicks.
|
||||
*
|
||||
* The strip in {@link _handleTerminalOutput} is the ONLY place these sequences
|
||||
* exist. After it, neither the browser nor xterm can ever learn that the CLI
|
||||
* asked for mouse tracking, so `terminal.modes.mouseTrackingMode` is
|
||||
* permanently 'none' for a stripped mode. The browser hand-encodes SGR reports
|
||||
* to compensate (`_sendSyntheticSgrTap` in terminal-ui.js), and with no state
|
||||
* to consult it had to do that on EVERY click, delivering mouse reports to a
|
||||
* CLI that never asked for them. Publishing this through `toState()` is what
|
||||
* lets the browser report a click only when the CLI is listening.
|
||||
*
|
||||
* Only the TRACKING modes count. 1005/1006 select an encoding and 1007 is
|
||||
* alt-scroll; a CLI that picks SGR encoding without turning a tracking mode on
|
||||
* is not asking about clicks, and counting those would put the stray reports
|
||||
* straight back.
|
||||
*
|
||||
* This must stay in lockstep with the strip regex that calls it: a sequence
|
||||
* removed from the stream but not recorded here is one the browser can neither
|
||||
* see nor be told about.
|
||||
*/
|
||||
private _recordStrippedMouseMode(seq: string): void {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const match = /\x1b\[\?(\d+)([hl])$/.exec(seq);
|
||||
if (!match) return;
|
||||
const mode = Number(match[1]);
|
||||
if (mode !== 1000 && mode !== 1001 && mode !== 1002 && mode !== 1003) return;
|
||||
if (match[2] === 'h') this._cliMouseModes.add(mode);
|
||||
else this._cliMouseModes.delete(mode);
|
||||
this._syncCliMouseTracking();
|
||||
}
|
||||
|
||||
/** Emit only on a real transition: a TUI re-emitting its enable on every repaint costs nothing. */
|
||||
private _syncCliMouseTracking(): void {
|
||||
const active = this._cliMouseModes.size > 0;
|
||||
if (active === this._cliMouseTracking) return;
|
||||
this._cliMouseTracking = active;
|
||||
this.emit('mouseTrackingChanged', active);
|
||||
}
|
||||
|
||||
private _handleTerminalOutput(data: string): void {
|
||||
// Codex AND Claude Code emit sequences that wipe xterm.js scrollback, plus
|
||||
// mouse-tracking enables that hijack the scroll wheel so the user can't reach
|
||||
@@ -1598,7 +1648,10 @@ export class Session extends EventEmitter {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[3J/g, '')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, '');
|
||||
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, (seq) => {
|
||||
this._recordStrippedMouseMode(seq);
|
||||
return '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2525,6 +2578,11 @@ export class Session extends EventEmitter {
|
||||
this._messages = [];
|
||||
this._lineBuffer = '';
|
||||
this._altScreenSeqCarry = '';
|
||||
// A restarted pane starts with no mouse mode: the new program has not asked
|
||||
// for one yet, and carrying the old CLI's state over would report clicks
|
||||
// into a program that never enabled tracking.
|
||||
this._cliMouseModes.clear();
|
||||
this._syncCliMouseTracking();
|
||||
this._markActivity(true);
|
||||
}
|
||||
|
||||
|
||||
+75
-48
@@ -31,7 +31,13 @@ import { existsSync, readFileSync, mkdirSync } from 'node:fs';
|
||||
import { writeFile, rename } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { dataPath, DEFAULT_TMUX_SOCKET, CODEMAN_INSTANCE } from './config/instance.js';
|
||||
import {
|
||||
dataPath,
|
||||
DEFAULT_TMUX_SOCKET,
|
||||
CODEMAN_INSTANCE,
|
||||
SAFE_TMUX_SOCKET_PATTERN,
|
||||
resolveTmuxSocketName,
|
||||
} from './config/instance.js';
|
||||
import {
|
||||
ProcessStats,
|
||||
PersistedRespawnConfig,
|
||||
@@ -75,11 +81,17 @@ import {
|
||||
SAFE_PATH_PATTERN,
|
||||
findClaudeDir,
|
||||
getClaudeCliVersion,
|
||||
getClaudeNotFoundMessage,
|
||||
resolveOpenCodeDir,
|
||||
getOpenCodeNotFoundMessage,
|
||||
resolveCodexDir,
|
||||
getCodexNotFoundMessage,
|
||||
resolveGeminiDir,
|
||||
getGeminiNotFoundMessage,
|
||||
resolveAntigravityDir,
|
||||
getAntigravityNotFoundMessage,
|
||||
resolvePiDir,
|
||||
getPiNotFoundMessage,
|
||||
resolveLocalShell,
|
||||
loginShellArgs,
|
||||
} from './utils/index.js';
|
||||
@@ -194,9 +206,6 @@ const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/;
|
||||
* `codeman` for prod, `codeman-beta` on the beta branch). */
|
||||
const DEFAULT_CODEMAN_TMUX_SOCKET = DEFAULT_TMUX_SOCKET;
|
||||
|
||||
/** Regex to validate tmux socket names passed to `tmux -L`. */
|
||||
const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/;
|
||||
|
||||
/**
|
||||
* Separator used in `tmux list-panes -F` output between session name and pid.
|
||||
*
|
||||
@@ -591,9 +600,8 @@ function resolveConfiguredTmuxSocket(): string {
|
||||
const raw = process.env.CODEMAN_TMUX_SOCKET ?? DEFAULT_CODEMAN_TMUX_SOCKET;
|
||||
if (!SAFE_TMUX_SOCKET_PATTERN.test(raw)) {
|
||||
console.warn(`[TmuxManager] Ignoring invalid CODEMAN_TMUX_SOCKET: ${JSON.stringify(raw)}`);
|
||||
return DEFAULT_CODEMAN_TMUX_SOCKET;
|
||||
}
|
||||
return raw;
|
||||
return resolveTmuxSocketName();
|
||||
}
|
||||
|
||||
/** Build the `tmux -L <socket>` command prefix. Socket name is shell-escaped. */
|
||||
@@ -1562,6 +1570,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
private reconnectGuard: Set<string> = new Set();
|
||||
|
||||
private trueColorConfigured = false;
|
||||
/** tmux 3.7+ can resize pane history after creation; older releases cannot. */
|
||||
private liveHistoryResizeSupported: boolean | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -1580,6 +1590,26 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return tmuxCommand(this.tmuxSocket);
|
||||
}
|
||||
|
||||
private supportsLiveHistoryResize(): boolean {
|
||||
if (this.liveHistoryResizeSupported !== null) return this.liveHistoryResizeSupported;
|
||||
|
||||
try {
|
||||
const output = execSync(`${this.tmux()} -V`, {
|
||||
encoding: 'utf8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
const match = output.match(/(?:^|\D)(\d+)\.(\d+)/);
|
||||
const major = match ? Number(match[1]) : 0;
|
||||
const minor = match ? Number(match[2]) : 0;
|
||||
this.liveHistoryResizeSupported = major > 3 || (major === 3 && minor >= 7);
|
||||
} catch {
|
||||
// Unknown versions take the legacy path required by tmux <3.7.
|
||||
this.liveHistoryResizeSupported = false;
|
||||
}
|
||||
return this.liveHistoryResizeSupported;
|
||||
}
|
||||
|
||||
// Load saved sessions from disk (NEVER called in test mode)
|
||||
private loadSessions(): void {
|
||||
if (IS_TEST_MODE) return;
|
||||
@@ -1853,29 +1883,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return session;
|
||||
}
|
||||
|
||||
// Resolve CLI binary directory based on mode
|
||||
// Resolve CLI binary directory based on mode. The not-found messages come
|
||||
// from the resolvers (formatCliNotFoundMessage) so the error names WHERE it
|
||||
// looked — server PATH, login shell, checked directories — instead of just
|
||||
// asserting the CLI is missing (the classic systemd/launchd PATH trap).
|
||||
const { pathExport, dir: cliDir } = this.buildPathExport(mode);
|
||||
if (mode === 'claude' && !cliDir) {
|
||||
throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
|
||||
throw new Error(getClaudeNotFoundMessage());
|
||||
}
|
||||
if (mode === 'opencode' && !cliDir) {
|
||||
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
|
||||
throw new Error(getOpenCodeNotFoundMessage());
|
||||
}
|
||||
if (mode === 'codex' && !cliDir) {
|
||||
throw new Error('Codex CLI not found. Install with: npm install -g @openai/codex');
|
||||
throw new Error(getCodexNotFoundMessage());
|
||||
}
|
||||
if (mode === 'gemini' && !cliDir) {
|
||||
throw new Error('Gemini CLI not found. Install with: npm install -g @google/gemini-cli');
|
||||
throw new Error(getGeminiNotFoundMessage());
|
||||
}
|
||||
if (mode === 'antigravity' && !cliDir) {
|
||||
throw new Error(
|
||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
||||
);
|
||||
throw new Error(getAntigravityNotFoundMessage());
|
||||
}
|
||||
if (mode === 'pi' && !cliDir) {
|
||||
throw new Error(
|
||||
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
|
||||
);
|
||||
throw new Error(getPiNotFoundMessage());
|
||||
}
|
||||
|
||||
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
||||
@@ -1921,7 +1950,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// launched in TMUX_LAUNCH_CWD (/tmp) rather than the real workingDir: a FUSE/rclone
|
||||
// mount that isn't ready yet makes `getcwd` fail and breaks the spawn (see #110). The
|
||||
// pane cd's into workingDir below via respawn-pane.
|
||||
execSync(`${this.tmux()} new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD}`, {
|
||||
// tmux <3.7 allocates history only at pane creation, so its global default
|
||||
// must be set immediately BEFORE new-session. tmux 3.7+ can resize a pane
|
||||
// after creation; target only the new session there because changing the
|
||||
// global option can resize (and when lowered, trim) unrelated live panes.
|
||||
const safeHistoryLimit =
|
||||
Number.isSafeInteger(historyLimit) && historyLimit > 0 ? Math.trunc(historyLimit) : DEFAULT_TMUX_HISTORY_LIMIT;
|
||||
const createSessionCommand = this.supportsLiveHistoryResize()
|
||||
? `${this.tmux()} new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD} \\; set-option -t "${muxName}" history-limit ${safeHistoryLimit}`
|
||||
: `${this.tmux()} set-option -g history-limit ${safeHistoryLimit} \\; new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD} \\; set-option -t "${muxName}" history-limit ${safeHistoryLimit}`;
|
||||
execSync(createSessionCommand, {
|
||||
cwd: TMUX_LAUNCH_CWD,
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
@@ -1986,16 +2024,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
.catch(() => {
|
||||
/* Already set globally as fallback */
|
||||
}),
|
||||
// Raise tmux scrollback from its 2000-line default so re-attach preserves
|
||||
// more context. Intentionally exceeds the xterm-side DEFAULT_SCROLLBACK (50k
|
||||
// in constants.js), which stays lower to protect browser/mobile memory.
|
||||
execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit ${historyLimit}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
})
|
||||
.then(() => {})
|
||||
.catch(() => {
|
||||
/* Non-critical — falls back to tmux default */
|
||||
}),
|
||||
];
|
||||
|
||||
// Enable 24-bit true color passthrough — server-wide, set once per lifetime
|
||||
@@ -2119,7 +2147,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
effort,
|
||||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||
remote,
|
||||
docker,
|
||||
name,
|
||||
@@ -2130,16 +2157,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
|
||||
|
||||
// Re-apply the configured tmux history-limit after respawn (kept in sync
|
||||
// with the live setting via setHistoryLimit()).
|
||||
if (!IS_TEST_MODE) {
|
||||
await execAsync(`${this.tmux()} set-option -t ${shellescape(muxName)} history-limit ${historyLimit}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).catch(() => {
|
||||
/* Non-critical — keeps existing tmux history-limit */
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve CLI binary directory based on mode
|
||||
const { pathExport } = this.buildPathExport(mode);
|
||||
|
||||
@@ -2998,9 +3015,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a tmux history-limit to all tracked sessions (e.g. when the user
|
||||
* changes the terminal-history setting). Invalid limits fall back to the
|
||||
* default. Best-effort per session.
|
||||
* Apply a tmux history limit. tmux 3.7+ safely targets tracked live sessions;
|
||||
* older releases can only change the global default for future panes. Invalid
|
||||
* limits fall back to the default.
|
||||
*/
|
||||
async setHistoryLimit(limit: number): Promise<void> {
|
||||
const safeLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.trunc(limit) : DEFAULT_TMUX_HISTORY_LIMIT;
|
||||
@@ -3009,12 +3026,22 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return;
|
||||
}
|
||||
|
||||
const updates = Array.from(this.sessions.values()).map((session) =>
|
||||
execAsync(`${this.tmux()} set-option -t ${shellescape(session.muxName)} history-limit ${safeLimit}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
})
|
||||
);
|
||||
await Promise.allSettled(updates);
|
||||
if (this.supportsLiveHistoryResize()) {
|
||||
const updates = Array.from(this.sessions.values()).map((session) =>
|
||||
execAsync(`${this.tmux()} set-option -t ${shellescape(session.muxName)} history-limit ${safeLimit}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
})
|
||||
);
|
||||
await Promise.allSettled(updates);
|
||||
return;
|
||||
}
|
||||
|
||||
await execAsync(`${this.tmux()} set-option -g history-limit ${safeLimit}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).catch(() => {
|
||||
// No tmux server yet is fine: legacy createSession sets the same default
|
||||
// immediately before it creates the first pane.
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* @fileoverview Pure ANSI helpers for the TUI preview pane.
|
||||
*
|
||||
* The preview shows the tail of a session's raw terminal stream, which is
|
||||
* xterm-bound bytes: SGR colors, cursor jumps, OSC titles, DECSET modes and
|
||||
* carriage-return repaints. This is NOT a terminal emulator. It reconstructs a
|
||||
* readable, color-preserving tail: SGR survives, everything else that steers a
|
||||
* cursor is dropped, and a `\r` is honored as "back to column 0" so a spinner
|
||||
* that repaints its line 200 times contributes one line instead of 200.
|
||||
*
|
||||
* CURSOR ADDRESSING (`ESC [ r ; c H`) is honored too, and it has to be: an Ink
|
||||
* TUI like Claude Code repaints by ROW and emits almost no newlines, so
|
||||
* dropping those sequences collapses a whole screen into one unreadable line
|
||||
* (measured against a live pane, 2026-08-16). A jump to column 1 starts a new
|
||||
* display line, a jump within a row moves the write position, which is the same
|
||||
* reading `normalizeCapturedFrame` in `web/approval-inbox.ts` takes of the same
|
||||
* kind of frame.
|
||||
*
|
||||
* Two approximations are deliberate, because the alternative is an emulator:
|
||||
* a carriage-return overwrite counts CODE POINTS, not display columns (so a
|
||||
* repaint over CJK text can land one cell off), and tab stops are counted the
|
||||
* same way. Neither can corrupt output, they only shift a repaint's alignment.
|
||||
* Absolute ROW numbers are ignored as well: rows arrive in the order they are
|
||||
* painted, which for a tail is the order worth reading.
|
||||
*
|
||||
* @module tui/tui-ansi
|
||||
*/
|
||||
|
||||
const ESC = 0x1b;
|
||||
const BEL = 0x07;
|
||||
const ST_C1 = 0x9c;
|
||||
const DEL = 0x7f;
|
||||
|
||||
/** SGR reset, appended by `clipStyledLine` so a clipped line cannot bleed. */
|
||||
export const SGR_RESET = '\x1b[0m';
|
||||
|
||||
const TAB_WIDTH = 8;
|
||||
/** Cap on remembered SGR sequences per cell, so a pathological stream cannot grow one unboundedly. */
|
||||
const MAX_ACTIVE_SGR = 32;
|
||||
/** Ceiling on a display line's cells: a stream may address column 99999, a terminal has none. */
|
||||
const MAX_LINE_CELLS = 1000;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Escape-sequence scanning
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface EscapeScan {
|
||||
/** Index just past the sequence; `text.length` for a truncated one. */
|
||||
next: number;
|
||||
/** The sequence itself, only when it is SGR (`CSI ... m`) and therefore kept. */
|
||||
sgr?: string;
|
||||
/** 1-based column of a cursor-position sequence (`CSI r ; c H` or `f`). */
|
||||
column?: number;
|
||||
/** 1-based row of that same sequence. Row 1 means a repaint is starting. */
|
||||
row?: number;
|
||||
}
|
||||
|
||||
/** The row and column a `CSI r ; c H` addresses. Both parameters default to 1. */
|
||||
function cursorPosition(params: string): { row: number; column: number } {
|
||||
const parts = params.split(';');
|
||||
const read = (index: number): number => {
|
||||
const value = Number.parseInt(parts[index] ?? '', 10);
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : 1;
|
||||
};
|
||||
return { row: read(0), column: read(1) };
|
||||
}
|
||||
|
||||
/** Scan a CSI body starting at `from` (params, then intermediates, then a final byte). */
|
||||
function readCsi(text: string, start: number, from: number, keepSgr: boolean): EscapeScan {
|
||||
let j = from;
|
||||
while (j < text.length && text.charCodeAt(j) >= 0x30 && text.charCodeAt(j) <= 0x3f) j++;
|
||||
while (j < text.length && text.charCodeAt(j) >= 0x20 && text.charCodeAt(j) <= 0x2f) j++;
|
||||
if (j >= text.length) return { next: text.length };
|
||||
const next = j + 1;
|
||||
if (keepSgr && text[j] === 'm') return { next, sgr: text.slice(start, next) };
|
||||
if (keepSgr && (text[j] === 'H' || text[j] === 'f')) {
|
||||
return { next, ...cursorPosition(text.slice(from, j)) };
|
||||
}
|
||||
return { next };
|
||||
}
|
||||
|
||||
/** Scan an OSC/DCS/PM/APC body: everything up to BEL, C1 ST or `ESC \`. */
|
||||
function readStringSequence(text: string, from: number): number {
|
||||
let j = from;
|
||||
while (j < text.length) {
|
||||
const code = text.charCodeAt(j);
|
||||
if (code === BEL || code === ST_C1) return j + 1;
|
||||
if (code === ESC && text[j + 1] === '\\') return j + 2;
|
||||
j++;
|
||||
}
|
||||
return text.length;
|
||||
}
|
||||
|
||||
/** Scan the escape sequence starting at `i` (which must be an ESC). */
|
||||
function readEscape(text: string, i: number): EscapeScan {
|
||||
const second = text[i + 1];
|
||||
if (second === undefined) return { next: text.length };
|
||||
if (second === '[') return readCsi(text, i, i + 2, true);
|
||||
if (second === ']' || second === 'P' || second === 'X' || second === '^' || second === '_') {
|
||||
return { next: readStringSequence(text, i + 2) };
|
||||
}
|
||||
// Charset / character-set selection: one more byte belongs to the sequence.
|
||||
if (second === '(' || second === ')' || second === '*' || second === '+' || second === '#' || second === '%') {
|
||||
return { next: Math.min(text.length, i + 3) };
|
||||
}
|
||||
return { next: i + 2 };
|
||||
}
|
||||
|
||||
/** Scan a single-byte C1 control at `i` (0x80-0x9f). */
|
||||
function readC1(text: string, i: number): number {
|
||||
const code = text.charCodeAt(i);
|
||||
if (code === 0x9b) return readCsi(text, i, i + 1, false).next;
|
||||
if (code === 0x90 || code === 0x9d || code === 0x9e || code === 0x9f) return readStringSequence(text, i + 1);
|
||||
return i + 1;
|
||||
}
|
||||
|
||||
function isC1(code: number): boolean {
|
||||
return code >= 0x80 && code <= 0x9f;
|
||||
}
|
||||
|
||||
/** `CSI 0 m`, `CSI m` and `CSI 0;0 m` all mean "back to plain". */
|
||||
function isSgrReset(seq: string): boolean {
|
||||
const params = seq.slice(2, -1);
|
||||
return params === '' || /^0(?:;0)*$/.test(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one SGR sequence into the active set. Sequences accumulate in arrival
|
||||
* order (a later color simply wins when replayed), a reset clears them, and a
|
||||
* repeat moves rather than duplicates.
|
||||
*/
|
||||
function applySgr(active: string[], seq: string): string[] {
|
||||
if (isSgrReset(seq)) return [];
|
||||
const next = active.filter((s) => s !== seq);
|
||||
next.push(seq);
|
||||
return next.length > MAX_ACTIVE_SGR ? next.slice(-MAX_ACTIVE_SGR) : next;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Display width
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Combining marks, variation selectors and other zero-advance code points.
|
||||
* Pragmatic, not exhaustive: enough that accents and emoji modifiers do not
|
||||
* inflate a measured width.
|
||||
*/
|
||||
const ZERO_WIDTH_RANGES: ReadonlyArray<readonly [number, number]> = [
|
||||
[0x0300, 0x036f],
|
||||
[0x0483, 0x0489],
|
||||
[0x0591, 0x05bd],
|
||||
[0x05bf, 0x05bf],
|
||||
[0x0610, 0x061a],
|
||||
[0x064b, 0x065f],
|
||||
[0x0670, 0x0670],
|
||||
[0x06d6, 0x06dc],
|
||||
[0x0e31, 0x0e31],
|
||||
[0x0e34, 0x0e3a],
|
||||
[0x0e47, 0x0e4e],
|
||||
[0x200b, 0x200f],
|
||||
[0x2028, 0x202e],
|
||||
[0x2060, 0x2064],
|
||||
[0x20d0, 0x20f0],
|
||||
[0xfe00, 0xfe0f],
|
||||
[0xfe20, 0xfe2f],
|
||||
[0xfeff, 0xfeff],
|
||||
];
|
||||
|
||||
/**
|
||||
* East Asian Wide + Fullwidth, plus the standalone code points UAX #11 marks
|
||||
* Wide because they are emoji-presentation by default. This repo ships a zh-CN
|
||||
* locale, so CJK correctness is the point; exhaustive Unicode is not required,
|
||||
* but the scattered BMP entries below are not optional either: `✋` (U+270B) is
|
||||
* one of them and it is a glyph this TUI draws in every waiting row, so getting
|
||||
* it wrong mis-pads a column on every frame.
|
||||
*/
|
||||
const WIDE_RANGES: ReadonlyArray<readonly [number, number]> = [
|
||||
[0x1100, 0x115f],
|
||||
[0x231a, 0x231b],
|
||||
[0x23e9, 0x23ec],
|
||||
[0x23f0, 0x23f0],
|
||||
[0x23f3, 0x23f3],
|
||||
[0x25fd, 0x25fe],
|
||||
[0x2614, 0x2615],
|
||||
[0x2648, 0x2653],
|
||||
[0x267f, 0x267f],
|
||||
[0x2693, 0x2693],
|
||||
[0x26a1, 0x26a1],
|
||||
[0x26aa, 0x26ab],
|
||||
[0x26bd, 0x26be],
|
||||
[0x26c4, 0x26c5],
|
||||
[0x26ce, 0x26ce],
|
||||
[0x26d4, 0x26d4],
|
||||
[0x26ea, 0x26ea],
|
||||
[0x26f2, 0x26f3],
|
||||
[0x26f5, 0x26f5],
|
||||
[0x26fa, 0x26fa],
|
||||
[0x26fd, 0x26fd],
|
||||
[0x2705, 0x2705],
|
||||
[0x270a, 0x270b],
|
||||
[0x2728, 0x2728],
|
||||
[0x274c, 0x274c],
|
||||
[0x274e, 0x274e],
|
||||
[0x2753, 0x2755],
|
||||
[0x2757, 0x2757],
|
||||
[0x2795, 0x2797],
|
||||
[0x27b0, 0x27b0],
|
||||
[0x27bf, 0x27bf],
|
||||
[0x2b1b, 0x2b1c],
|
||||
[0x2b50, 0x2b50],
|
||||
[0x2b55, 0x2b55],
|
||||
[0x2e80, 0x303e],
|
||||
[0x3041, 0x33ff],
|
||||
[0x3400, 0x4dbf],
|
||||
[0x4e00, 0x9fff],
|
||||
[0xa000, 0xa4cf],
|
||||
[0xa960, 0xa97f],
|
||||
[0xac00, 0xd7a3],
|
||||
[0xf900, 0xfaff],
|
||||
[0xfe10, 0xfe19],
|
||||
[0xfe30, 0xfe6f],
|
||||
[0xff00, 0xff60],
|
||||
[0xffe0, 0xffe6],
|
||||
[0x1f004, 0x1f004],
|
||||
[0x1f0cf, 0x1f0cf],
|
||||
[0x1f18e, 0x1f18e],
|
||||
[0x1f191, 0x1f19a],
|
||||
[0x1f200, 0x1f320],
|
||||
[0x1f32d, 0x1f335],
|
||||
[0x1f337, 0x1f37c],
|
||||
[0x1f37e, 0x1f393],
|
||||
[0x1f3a0, 0x1f3ca],
|
||||
[0x1f3cf, 0x1f3d3],
|
||||
[0x1f3e0, 0x1f3f0],
|
||||
[0x1f3f4, 0x1f3f4],
|
||||
[0x1f3f8, 0x1f43e],
|
||||
[0x1f440, 0x1f440],
|
||||
[0x1f442, 0x1f4fc],
|
||||
[0x1f4ff, 0x1f53d],
|
||||
[0x1f54b, 0x1f54e],
|
||||
[0x1f550, 0x1f567],
|
||||
[0x1f57a, 0x1f57a],
|
||||
[0x1f595, 0x1f596],
|
||||
[0x1f5a4, 0x1f5a4],
|
||||
[0x1f5fb, 0x1f64f],
|
||||
[0x1f680, 0x1f6c5],
|
||||
[0x1f6cc, 0x1f6cc],
|
||||
[0x1f6d0, 0x1f6d2],
|
||||
[0x1f6eb, 0x1f6ec],
|
||||
[0x1f6f4, 0x1f6fc],
|
||||
[0x1f7e0, 0x1f7eb],
|
||||
[0x1f90c, 0x1f93a],
|
||||
[0x1f93c, 0x1f945],
|
||||
[0x1f947, 0x1f9ff],
|
||||
[0x1fa70, 0x1faff],
|
||||
[0x20000, 0x2fffd],
|
||||
[0x30000, 0x3fffd],
|
||||
];
|
||||
|
||||
function inRanges(cp: number, ranges: ReadonlyArray<readonly [number, number]>): boolean {
|
||||
for (const [lo, hi] of ranges) {
|
||||
if (cp < lo) return false;
|
||||
if (cp <= hi) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Columns one code point advances the cursor by: 0, 1 or 2. */
|
||||
export function charWidth(codePoint: number): number {
|
||||
if (codePoint < 0x20 || (codePoint >= DEL && codePoint <= 0x9f)) return 0;
|
||||
if (inRanges(codePoint, ZERO_WIDTH_RANGES)) return 0;
|
||||
if (inRanges(codePoint, WIDE_RANGES)) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Display width of a string: escape sequences take no columns, CJK takes two. */
|
||||
export function visibleWidth(text: string): number {
|
||||
let width = 0;
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const code = text.charCodeAt(i);
|
||||
if (code === ESC) {
|
||||
i = readEscape(text, i).next;
|
||||
continue;
|
||||
}
|
||||
if (isC1(code)) {
|
||||
i = readC1(text, i);
|
||||
continue;
|
||||
}
|
||||
if (code < 0x20 || code === DEL) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const cp = text.codePointAt(i) as number;
|
||||
i += cp > 0xffff ? 2 : 1;
|
||||
width += charWidth(cp);
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Raw stream to display lines
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** One printed code point (plus any combining marks) and the SGR state under it. */
|
||||
interface Cell {
|
||||
text: string;
|
||||
sgr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay cells into a string, emitting an SGR change only where the state
|
||||
* actually changes and closing the line so it is self-contained.
|
||||
*/
|
||||
function renderCells(cells: Cell[]): string {
|
||||
let out = '';
|
||||
let active = '';
|
||||
for (const cell of cells) {
|
||||
if (cell.sgr !== active) {
|
||||
if (active !== '') out += SGR_RESET;
|
||||
out += cell.sgr;
|
||||
active = cell.sgr;
|
||||
}
|
||||
out += cell.text;
|
||||
}
|
||||
if (active !== '') out += SGR_RESET;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a raw terminal stream into display lines: SGR preserved, every other
|
||||
* escape sequence dropped, `\r` treated as a return to column 0 (the following
|
||||
* text overwrites what is there), tabs expanded, other control characters
|
||||
* dropped.
|
||||
*
|
||||
* Splitting matches `String.split('\n')`, so `''` yields `['']` and a trailing
|
||||
* newline yields a trailing empty line.
|
||||
*/
|
||||
/**
|
||||
* Glyphs a CLI draws as chrome that a plain terminal font very often has no
|
||||
* coverage for, and the ASCII that means the same thing.
|
||||
*
|
||||
* ⚠️ This is NOT a substitute for the glyph TIER. The tier answers "can this
|
||||
* terminal do Unicode at all", which is a locale question, and it says yes for
|
||||
* exactly the terminals this table exists for: a beta tester's font rendered
|
||||
* `·`, `─`, `│` and `▶` perfectly while drawing claude's `❯` prompt and its
|
||||
* `⏵⏵` mode marker as empty boxes. Coverage is per-glyph and undetectable from
|
||||
* here, so the rare ones are folded and the common ones are left alone.
|
||||
*
|
||||
* Kept deliberately SHORT. Every entry is a glyph seen rendering as tofu in a
|
||||
* real terminal, not a guess, and each maps to the arrow it already looks like.
|
||||
*/
|
||||
const PREVIEW_GLYPH_FOLD: ReadonlyMap<string, string> = new Map([
|
||||
['\u276F', '>'], // ❯ heavy right-pointing angle quotation mark (claude, starship, zsh prompts)
|
||||
['\u276E', '<'], // ❮
|
||||
['\u23F5', '>'], // ⏵ black medium right-pointing triangle (claude's bypass-permissions marker)
|
||||
['\u23F4', '<'], // ⏴
|
||||
['\u23F6', '^'], // ⏶
|
||||
['\u23F7', 'v'], // ⏷
|
||||
['\u2771', '>'], // ❱
|
||||
['\u2770', '<'], // ❰
|
||||
// claude's own working/done spinner cycles through these, and they are the
|
||||
// same sparse-Dingbats class as `❯`: the animated line is exactly where a
|
||||
// reader looks, so tofu there is the most visible kind.
|
||||
['\u2722', '*'], // ✢
|
||||
['\u2733', '*'], // ✳
|
||||
['\u2217', '*'], // ∗
|
||||
['\u273B', '*'], // ✻
|
||||
['\u273D', '*'], // ✽
|
||||
['\u2734', '*'], // ✴
|
||||
['\u26A0', '!'], // ⚠ Misc Symbols, and emoji-presentation on many terminals
|
||||
]);
|
||||
|
||||
/**
|
||||
* Replace preview glyphs a plain font is likely to draw as an empty box.
|
||||
*
|
||||
* Applied to ANOTHER program's output on its way into the preview pane, never
|
||||
* to the TUI's own chrome, and skipped at the `nerd` tier where the user has
|
||||
* declared a font that can draw anything.
|
||||
*/
|
||||
export function foldPreviewGlyphs(line: string): string {
|
||||
let out = '';
|
||||
for (const char of line) out += PREVIEW_GLYPH_FOLD.get(char) ?? char;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function toDisplayLines(raw: string): string[] {
|
||||
const lines: string[] = [];
|
||||
let cells: Cell[] = [];
|
||||
let col = 0;
|
||||
let active: string[] = [];
|
||||
let sgr = '';
|
||||
|
||||
const endLine = (): void => {
|
||||
lines.push(renderCells(cells));
|
||||
cells = [];
|
||||
col = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Park the write position at a column, padding the gap so the cell array
|
||||
* never grows a hole (a hole would crash the replay, and a stream can address
|
||||
* any column it likes).
|
||||
*/
|
||||
const moveTo = (column: number): void => {
|
||||
const target = Math.min(column, MAX_LINE_CELLS);
|
||||
while (cells.length < target) cells.push({ text: ' ', sgr: '' });
|
||||
col = target;
|
||||
};
|
||||
|
||||
const write = (text: string, width: number): void => {
|
||||
if (width === 0) {
|
||||
// A combining mark belongs to the character it follows, never to a cell
|
||||
// of its own: keeping them together is what stops a clip from severing
|
||||
// an accent from its base letter.
|
||||
if (col > 0) cells[col - 1].text += text;
|
||||
return;
|
||||
}
|
||||
cells[col] = { text, sgr };
|
||||
col++;
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
while (i < raw.length) {
|
||||
const code = raw.charCodeAt(i);
|
||||
if (code === ESC) {
|
||||
const scan = readEscape(raw, i);
|
||||
if (scan.sgr !== undefined) {
|
||||
active = applySgr(active, scan.sgr);
|
||||
sgr = active.join('');
|
||||
} else if (scan.row === 1 && scan.column === 1) {
|
||||
// ⚠️ A HOME is a full-screen app announcing that it is repainting from
|
||||
// the top, and everything already on screen is about to be overwritten
|
||||
// in place. This replay is line-based and cannot overwrite, so the
|
||||
// faithful equivalent is to start over — without it every repaint was
|
||||
// APPENDED, and a claude pane's tail carried fifty stacked copies of
|
||||
// the same frame. The preview then showed the last N lines, which on a
|
||||
// tall terminal spanned two of them (reported from the beta as the
|
||||
// overview showing the session twice).
|
||||
lines.length = 0;
|
||||
cells = [];
|
||||
col = 0;
|
||||
} else if (scan.column !== undefined) {
|
||||
// Column 1 is a fresh row, which is the only thing a repainting TUI
|
||||
// gives us to split lines on.
|
||||
if (scan.column <= 1) endLine();
|
||||
else moveTo(scan.column - 1);
|
||||
}
|
||||
i = scan.next;
|
||||
continue;
|
||||
}
|
||||
if (isC1(code)) {
|
||||
i = readC1(raw, i);
|
||||
continue;
|
||||
}
|
||||
if (code === 0x0a) {
|
||||
endLine();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (code === 0x0d) {
|
||||
col = 0;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (code === 0x09) {
|
||||
const stop = TAB_WIDTH - (col % TAB_WIDTH);
|
||||
for (let n = 0; n < stop; n++) write(' ', 1);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (code < 0x20 || code === DEL) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const cp = raw.codePointAt(i) as number;
|
||||
const text = String.fromCodePoint(cp);
|
||||
i += text.length;
|
||||
write(text, charWidth(cp));
|
||||
}
|
||||
endLine();
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* The parameter bytes plus final byte of a CSI sequence whose `ESC [` was cut
|
||||
* off. Requires at least one parameter byte, so ordinary text starting with a
|
||||
* letter is never mistaken for one.
|
||||
*/
|
||||
const SEVERED_CSI = /^[0-9;?:<>=]+[A-Za-z]/;
|
||||
|
||||
/**
|
||||
* Drop the remains of an escape sequence a byte-sliced tail begins in the
|
||||
* middle of.
|
||||
*
|
||||
* `GET /api/sessions/:id/terminal?tail=N` cuts the buffer at a byte offset, so
|
||||
* a tail can start inside `ESC [ 12 ; 1 H` and hand the parser `;1H` as text,
|
||||
* which is exactly what it then prints (observed against a live Claude pane).
|
||||
* Only the severed head is dropped, never a whole line.
|
||||
*/
|
||||
export function dropSeveredEscape(raw: string): string {
|
||||
return raw.replace(SEVERED_CSI, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every escape sequence, keeping the visible text. Needed because the
|
||||
* preview carries the session's OWN colors: under NO_COLOR the frame must not
|
||||
* smuggle them back in.
|
||||
*/
|
||||
export function stripStyles(text: string): string {
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const code = text.charCodeAt(i);
|
||||
if (code === ESC) {
|
||||
i = readEscape(text, i).next;
|
||||
continue;
|
||||
}
|
||||
if (isC1(code)) {
|
||||
i = readC1(text, i);
|
||||
continue;
|
||||
}
|
||||
if (code < 0x20 || code === DEL) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const cp = text.codePointAt(i) as number;
|
||||
const size = cp > 0xffff ? 2 : 1;
|
||||
out += text.slice(i, i + size);
|
||||
i += size;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Clipping and padding
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Clip a line that carries SGR to `width` display columns, keeping the styling
|
||||
* that is active up to the clip point and closing it with a reset. Never splits
|
||||
* a code point, a combining sequence or an escape sequence, and never emits
|
||||
* half of a double-width character (the cell is dropped instead).
|
||||
*/
|
||||
export function clipStyledLine(line: string, width: number): string {
|
||||
if (width <= 0) return '';
|
||||
let out = '';
|
||||
let used = 0;
|
||||
let active: string[] = [];
|
||||
// Styles are emitted lazily, right before the character that wears them, so a
|
||||
// sequence sitting exactly on the clip boundary is not carried into a line it
|
||||
// no longer styles.
|
||||
let emitted = '';
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const code = line.charCodeAt(i);
|
||||
if (code === ESC) {
|
||||
const scan = readEscape(line, i);
|
||||
if (scan.sgr !== undefined) active = applySgr(active, scan.sgr);
|
||||
i = scan.next;
|
||||
continue;
|
||||
}
|
||||
if (isC1(code)) {
|
||||
i = readC1(line, i);
|
||||
continue;
|
||||
}
|
||||
if (code < 0x20 || code === DEL) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const cp = line.codePointAt(i) as number;
|
||||
const w = charWidth(cp);
|
||||
if (used + w > width) break;
|
||||
const style = active.join('');
|
||||
if (style !== emitted) {
|
||||
if (emitted !== '') out += SGR_RESET;
|
||||
out += style;
|
||||
emitted = style;
|
||||
}
|
||||
out += String.fromCodePoint(cp);
|
||||
used += w;
|
||||
i += cp > 0xffff ? 2 : 1;
|
||||
}
|
||||
return emitted !== '' ? out + SGR_RESET : out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad or clip to exactly `width` display columns. A clip that lands on a
|
||||
* double-width boundary leaves one column short, so the pad runs after it.
|
||||
*/
|
||||
export function padDisplay(text: string, width: number): string {
|
||||
if (width <= 0) return '';
|
||||
const w = visibleWidth(text);
|
||||
if (w === width) return text;
|
||||
if (w < width) return text + ' '.repeat(width - w);
|
||||
const clipped = clipStyledLine(text, width);
|
||||
return clipped + ' '.repeat(Math.max(0, width - visibleWidth(clipped)));
|
||||
}
|
||||
+2694
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @fileoverview Pure reading of an approvals-inbox item: what the card says,
|
||||
* which keys are live for it, and which of them just appeared.
|
||||
*
|
||||
* This is the half of "answer the dialog from the dashboard" that can be stated
|
||||
* as a function of the item. The IO half (`POST /api/approvals/:id/answer`)
|
||||
* lives in `tui-client.ts`, and the server re-captures the pane before it aims
|
||||
* any keystroke, so a card that went stale is refused rather than mis-answered.
|
||||
*
|
||||
* The key matrix is deliberately narrow, because the alternative is typing a
|
||||
* digit into whatever now has focus:
|
||||
*
|
||||
* | kind | y | n | 1-9 |
|
||||
* | ---------- | ------------ | ---------------------- | ------------------------- |
|
||||
* | permission | approve | the parsed "No" option, | only digits the server |
|
||||
* | question | approve | else Esc | actually parsed off screen |
|
||||
* | idle | not a dialog: `p` (the composer) is the reply path |
|
||||
*
|
||||
* A digit that is not among the parsed options returns null, which is what lets
|
||||
* the caller fall back to the list's own 1-9 jump instead of sending a keystroke
|
||||
* the dialog has no answer for.
|
||||
*
|
||||
* PURE: no IO, no timers, no `process.*`.
|
||||
*
|
||||
* @module tui/tui-approvals
|
||||
*/
|
||||
|
||||
import type { ApprovalItem, ApprovalOption } from '../web/approval-inbox.js';
|
||||
import type { TuiApprovalAnswer } from './tui-client.js';
|
||||
|
||||
/** Card severity, in the same red/yellow vocabulary the web inbox uses. */
|
||||
export type TuiApprovalTone = 'err' | 'warn';
|
||||
|
||||
export interface TuiApprovalCard {
|
||||
tone: TuiApprovalTone;
|
||||
/** One line: what is being asked. */
|
||||
title: string;
|
||||
/** Extra context, one entry per line, already trimmed. May be empty. */
|
||||
detail: string[];
|
||||
/** Numbered choices parsed off the pane, empty when the frame did not parse. */
|
||||
options: ApprovalOption[];
|
||||
/** What the user can press right now, in words. */
|
||||
hint: string;
|
||||
}
|
||||
|
||||
/** Longest single line the card contributes before the renderer clips it. */
|
||||
const MAX_CARD_TEXT = 400;
|
||||
|
||||
function clean(text: string | undefined): string {
|
||||
return (text ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_CARD_TEXT);
|
||||
}
|
||||
|
||||
export function approvalTone(item: ApprovalItem): TuiApprovalTone {
|
||||
return item.kind === 'idle' ? 'warn' : 'err';
|
||||
}
|
||||
|
||||
/**
|
||||
* What the card says. Permission prompts lead with the tool (that is the whole
|
||||
* question), questions lead with their message, and an idle prompt says what it
|
||||
* is, since there is nothing to approve.
|
||||
*/
|
||||
export function approvalCard(item: ApprovalItem): TuiApprovalCard {
|
||||
const options = item.options ?? [];
|
||||
const message = clean(item.message);
|
||||
const summary = clean(item.toolSummary) || clean(item.toolName);
|
||||
|
||||
if (item.kind === 'idle') {
|
||||
return {
|
||||
tone: 'warn',
|
||||
title: message || 'waiting for your reply',
|
||||
detail: [],
|
||||
options: [],
|
||||
hint: 'p to reply',
|
||||
};
|
||||
}
|
||||
|
||||
const title =
|
||||
item.kind === 'permission'
|
||||
? `requests: ${summary || 'permission'}`
|
||||
: message || `question: ${summary || 'Claude is asking'}`;
|
||||
const detail: string[] = [];
|
||||
if (item.kind === 'permission' && message && message !== summary) detail.push(message);
|
||||
|
||||
return {
|
||||
tone: 'err',
|
||||
title,
|
||||
detail,
|
||||
options,
|
||||
hint: options.length > 0 ? 'y approve · n deny · digit chooses' : 'y approve · n deny',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The parsed option that means "no". Claude renders it as `3. No, tell Claude
|
||||
* what to do (esc)`, and answering with its digit is the same keystroke the
|
||||
* dialog itself is waiting for; without a parsed one the answer route's `deny`
|
||||
* sends Esc, which every dialog understands.
|
||||
*/
|
||||
export function approvalDenyOption(item: ApprovalItem): number | null {
|
||||
const match = (item.options ?? []).find((option) => /^no\b/i.test(option.label));
|
||||
return match ? match.n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer one key produces, or null when that key means nothing here (so the
|
||||
* caller can let its normal binding through).
|
||||
*/
|
||||
export function approvalAnswerForKey(item: ApprovalItem, key: string): TuiApprovalAnswer | null {
|
||||
// An idle prompt has no dialog on screen: a digit or a `1` would land in the
|
||||
// composer as text. The card points at `p` instead.
|
||||
if (item.kind === 'idle') return null;
|
||||
if (key === 'y') return { action: 'approve' };
|
||||
if (key === 'n') {
|
||||
const deny = approvalDenyOption(item);
|
||||
return deny === null ? { action: 'deny' } : { action: 'option', option: deny };
|
||||
}
|
||||
if (key >= '1' && key <= '9') {
|
||||
const option = Number.parseInt(key, 10);
|
||||
return (item.options ?? []).some((entry) => entry.n === option) ? { action: 'option', option } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids in `items` that `seen` has not recorded. The bell rings for these and for
|
||||
* nothing else, which is what keeps a repaint (or a refetch that returns the
|
||||
* same pending item) silent.
|
||||
*
|
||||
* Answered ids stay in `seen` on purpose: the inbox restores an item under its
|
||||
* ORIGINAL id when a write fails, and re-ringing for a prompt the user already
|
||||
* heard about is worse than missing one.
|
||||
*/
|
||||
export function newApprovalIds(seen: ReadonlySet<string>, items: readonly ApprovalItem[]): string[] {
|
||||
const fresh: string[] = [];
|
||||
for (const item of items) if (!seen.has(item.id) && !fresh.includes(item.id)) fresh.push(item.id);
|
||||
return fresh;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @fileoverview Pure single-line editor behind the TUI's prompt composer (`p`)
|
||||
* and search query (`/`).
|
||||
*
|
||||
* Text is held as CODE POINTS rather than a string, because every operation
|
||||
* here is index-based and a cursor that can land inside a surrogate pair
|
||||
* eventually deletes half an emoji. Combining marks are their own entries: they
|
||||
* are zero-width, so they neither move the cursor's column nor cost a cell, and
|
||||
* backspace peeling one off a base letter is what a terminal editor does.
|
||||
*
|
||||
* Scrolling is derived, never remembered implicitly: `composerScroll()` takes
|
||||
* the width and returns the state whose window holds the cursor, which is what
|
||||
* keeps "what the footer shows" a function of the state plus the terminal width
|
||||
* rather than of the order the user pressed keys in.
|
||||
*
|
||||
* PURE: no IO, no timers, no `process.*`. Enter and Escape are reported as
|
||||
* `submit`/`cancel` rather than acted on, since only the caller knows whether
|
||||
* Enter means "send this prompt" or "open the highlighted search result".
|
||||
*
|
||||
* @module tui/tui-composer
|
||||
*/
|
||||
|
||||
import { charWidth } from './tui-ansi.js';
|
||||
import type { TuiInputEvent } from './tui-keys.js';
|
||||
|
||||
export interface TuiComposerState {
|
||||
/** Code points. `chars.join('')` is the text. */
|
||||
readonly chars: readonly string[];
|
||||
/** 0..chars.length. The cursor sits BEFORE `chars[cursor]`. */
|
||||
readonly cursor: number;
|
||||
/** First visible code point, as `composerScroll()` last resolved it. */
|
||||
readonly scroll: number;
|
||||
}
|
||||
|
||||
export function createComposer(text = ''): TuiComposerState {
|
||||
const chars = [...text];
|
||||
return { chars, cursor: chars.length, scroll: 0 };
|
||||
}
|
||||
|
||||
export function composerText(state: TuiComposerState): string {
|
||||
return state.chars.join('');
|
||||
}
|
||||
|
||||
function withChars(chars: readonly string[], cursor: number, scroll: number): TuiComposerState {
|
||||
const clampedCursor = Math.min(Math.max(0, cursor), chars.length);
|
||||
return { chars, cursor: clampedCursor, scroll: Math.min(Math.max(0, scroll), chars.length) };
|
||||
}
|
||||
|
||||
/** Insert typed text at the cursor. Newlines are stripped: this is one line. */
|
||||
export function composerInsert(state: TuiComposerState, value: string): TuiComposerState {
|
||||
const inserted = [...value.replace(/[\r\n]+/g, ' ')];
|
||||
if (inserted.length === 0) return state;
|
||||
const chars = [...state.chars.slice(0, state.cursor), ...inserted, ...state.chars.slice(state.cursor)];
|
||||
return withChars(chars, state.cursor + inserted.length, state.scroll);
|
||||
}
|
||||
|
||||
/** Delete the code point before the cursor. */
|
||||
export function composerBackspace(state: TuiComposerState): TuiComposerState {
|
||||
if (state.cursor === 0) return state;
|
||||
const chars = [...state.chars.slice(0, state.cursor - 1), ...state.chars.slice(state.cursor)];
|
||||
return withChars(chars, state.cursor - 1, state.scroll);
|
||||
}
|
||||
|
||||
/** Delete the code point under the cursor (the Delete key). */
|
||||
export function composerDelete(state: TuiComposerState): TuiComposerState {
|
||||
if (state.cursor >= state.chars.length) return state;
|
||||
const chars = [...state.chars.slice(0, state.cursor), ...state.chars.slice(state.cursor + 1)];
|
||||
return withChars(chars, state.cursor, state.scroll);
|
||||
}
|
||||
|
||||
/** Delete back to the start of the word before the cursor (Ctrl+W). */
|
||||
export function composerDeleteWord(state: TuiComposerState): TuiComposerState {
|
||||
let start = state.cursor;
|
||||
while (start > 0 && state.chars[start - 1] === ' ') start--;
|
||||
while (start > 0 && state.chars[start - 1] !== ' ') start--;
|
||||
if (start === state.cursor) return state;
|
||||
const chars = [...state.chars.slice(0, start), ...state.chars.slice(state.cursor)];
|
||||
return withChars(chars, start, state.scroll);
|
||||
}
|
||||
|
||||
export function composerMove(state: TuiComposerState, delta: number): TuiComposerState {
|
||||
const cursor = Math.min(Math.max(0, state.cursor + Math.trunc(delta)), state.chars.length);
|
||||
return cursor === state.cursor ? state : withChars(state.chars, cursor, state.scroll);
|
||||
}
|
||||
|
||||
export function composerHome(state: TuiComposerState): TuiComposerState {
|
||||
return state.cursor === 0 ? state : withChars(state.chars, 0, state.scroll);
|
||||
}
|
||||
|
||||
export function composerEnd(state: TuiComposerState): TuiComposerState {
|
||||
return state.cursor === state.chars.length ? state : withChars(state.chars, state.chars.length, state.scroll);
|
||||
}
|
||||
|
||||
export function composerClear(state: TuiComposerState): TuiComposerState {
|
||||
return state.chars.length === 0 ? state : { chars: [], cursor: 0, scroll: 0 };
|
||||
}
|
||||
|
||||
/** Display columns of `chars[from..to)`. */
|
||||
function widthOf(chars: readonly string[], from: number, to: number): number {
|
||||
let width = 0;
|
||||
for (let i = from; i < to; i++) width += charWidth(chars[i].codePointAt(0) ?? 0);
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `scroll` so the cursor is inside a window `width` columns wide,
|
||||
* scrolling the minimum needed. One column is reserved for the cursor itself,
|
||||
* so a cursor at the end of the text still has a cell to sit in instead of
|
||||
* hanging one past the edge where the terminal would wrap it.
|
||||
*/
|
||||
export function composerScroll(state: TuiComposerState, width: number): TuiComposerState {
|
||||
const usable = Math.max(0, Math.trunc(width) - 1);
|
||||
let scroll = Math.min(Math.max(0, state.scroll), state.cursor);
|
||||
while (scroll < state.cursor && widthOf(state.chars, scroll, state.cursor) > usable) scroll++;
|
||||
return scroll === state.scroll ? state : { chars: state.chars, cursor: state.cursor, scroll };
|
||||
}
|
||||
|
||||
export interface TuiComposerWindow {
|
||||
/** The visible slice of the text. */
|
||||
text: string;
|
||||
/** Cursor offset in display columns from the start of `text`. */
|
||||
cursorColumn: number;
|
||||
/** Resolved first visible code point (may differ from `state.scroll`). */
|
||||
scroll: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice the footer draws plus where the terminal cursor belongs. The scroll
|
||||
* is resolved here too, so a renderer that never writes state back still shows
|
||||
* the cursor.
|
||||
*/
|
||||
export function composerWindow(state: TuiComposerState, width: number): TuiComposerWindow {
|
||||
const columns = Math.max(1, Math.trunc(width));
|
||||
const scrolled = composerScroll(state, columns);
|
||||
const { chars, cursor, scroll } = scrolled;
|
||||
let used = 0;
|
||||
let end = scroll;
|
||||
while (end < chars.length) {
|
||||
const next = charWidth(chars[end].codePointAt(0) ?? 0);
|
||||
if (used + next > columns) break;
|
||||
used += next;
|
||||
end++;
|
||||
}
|
||||
return {
|
||||
text: chars.slice(scroll, Math.max(end, cursor)).join(''),
|
||||
cursorColumn: widthOf(chars, scroll, cursor),
|
||||
scroll,
|
||||
};
|
||||
}
|
||||
|
||||
export type TuiComposerStep =
|
||||
| { kind: 'edit'; state: TuiComposerState }
|
||||
| { kind: 'submit'; text: string }
|
||||
| { kind: 'cancel' }
|
||||
| { kind: 'ignore' };
|
||||
|
||||
/**
|
||||
* One keystroke. Enter and Escape are REPORTED rather than applied: `p` sends
|
||||
* the line while `/` opens the highlighted result, and only the caller knows
|
||||
* which.
|
||||
*/
|
||||
export function composerStep(state: TuiComposerState, event: TuiInputEvent): TuiComposerStep {
|
||||
switch (event.type) {
|
||||
case 'char':
|
||||
return { kind: 'edit', state: composerInsert(state, event.value) };
|
||||
case 'backspace':
|
||||
return { kind: 'edit', state: composerBackspace(state) };
|
||||
case 'enter':
|
||||
return { kind: 'submit', text: composerText(state) };
|
||||
case 'escape':
|
||||
return { kind: 'cancel' };
|
||||
case 'key':
|
||||
switch (event.name) {
|
||||
case 'left':
|
||||
return { kind: 'edit', state: composerMove(state, -1) };
|
||||
case 'right':
|
||||
return { kind: 'edit', state: composerMove(state, 1) };
|
||||
case 'home':
|
||||
return { kind: 'edit', state: composerHome(state) };
|
||||
case 'end':
|
||||
return { kind: 'edit', state: composerEnd(state) };
|
||||
case 'delete':
|
||||
return { kind: 'edit', state: composerDelete(state) };
|
||||
default:
|
||||
return { kind: 'ignore' };
|
||||
}
|
||||
case 'ctrl':
|
||||
switch (event.key) {
|
||||
case 'c':
|
||||
return { kind: 'cancel' };
|
||||
case 'a':
|
||||
return { kind: 'edit', state: composerHome(state) };
|
||||
case 'e':
|
||||
return { kind: 'edit', state: composerEnd(state) };
|
||||
case 'u':
|
||||
return { kind: 'edit', state: composerClear(state) };
|
||||
case 'w':
|
||||
return { kind: 'edit', state: composerDeleteWord(state) };
|
||||
default:
|
||||
return { kind: 'ignore' };
|
||||
}
|
||||
default:
|
||||
return { kind: 'ignore' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @fileoverview Pure formatting of `GET /api/away-digest` into the lines the
|
||||
* `g` overlay scrolls.
|
||||
*
|
||||
* The digest answers "what happened while I was away", so it is read top-down
|
||||
* and never studied: every entry is one line (age, session, what happened), a
|
||||
* long section is capped with a "… n more" tail rather than allowed to push the
|
||||
* next section off screen, and the counts that matter live in the first line
|
||||
* where they are visible without scrolling at all.
|
||||
*
|
||||
* PURE: no IO, no clock of its own (the caller passes `now`), no `process.*`.
|
||||
*
|
||||
* @module tui/tui-digest
|
||||
*/
|
||||
|
||||
import { formatElapsed, formatTokens } from './tui-render.js';
|
||||
import type { AwayDigestItem, AwayDigestResponse, AwayDigestSectionName } from '../web/away-digest.js';
|
||||
|
||||
/** Entries per section before the tail takes over. */
|
||||
export const DIGEST_SECTION_LIMIT = 6;
|
||||
|
||||
const SECTION_ORDER: ReadonlyArray<readonly [AwayDigestSectionName, string]> = [
|
||||
['needsAttention', 'NEEDS ATTENTION'],
|
||||
['completed', 'COMPLETED'],
|
||||
['stillRunning', 'STILL RUNNING'],
|
||||
['idle', 'IDLE'],
|
||||
['informational', 'INFO'],
|
||||
];
|
||||
|
||||
const RANGE_WORDS: Record<string, string> = {
|
||||
'since-last-visit': 'since your last visit',
|
||||
'1h': 'the last hour',
|
||||
today: 'today',
|
||||
'24h': 'the last 24 hours',
|
||||
custom: 'the selected window',
|
||||
};
|
||||
|
||||
export interface TuiDigestOptions {
|
||||
now: number;
|
||||
sectionLimit?: number;
|
||||
}
|
||||
|
||||
function ageColumn(item: AwayDigestItem, now: number): string {
|
||||
const age = item.timestamp > 0 ? formatElapsed(now - item.timestamp) : '';
|
||||
return age.padEnd(4);
|
||||
}
|
||||
|
||||
function itemLine(item: AwayDigestItem, now: number): string {
|
||||
const who = item.sessionName ?? item.sessionId?.slice(0, 8) ?? '';
|
||||
const what = [item.title, item.detail].filter((part) => part && part.trim() !== '').join(' · ');
|
||||
return ` ${ageColumn(item, now)} ${[who, what].filter((part) => part !== '').join(' ')}`.replace(/\s+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The digest as display lines. The first line is the summary, then one block
|
||||
* per non-empty section, then the token totals when the range had any.
|
||||
*/
|
||||
export function formatAwayDigest(digest: AwayDigestResponse, options: TuiDigestOptions): string[] {
|
||||
const limit = Math.max(1, Math.trunc(options.sectionLimit ?? DIGEST_SECTION_LIMIT));
|
||||
const { totals } = digest;
|
||||
const lines: string[] = [
|
||||
[
|
||||
RANGE_WORDS[digest.range.range] ?? 'recently',
|
||||
`${totals.sessionsCreated} started`,
|
||||
`${totals.sessionsExited} exited`,
|
||||
`${totals.activeSessions} running`,
|
||||
].join(' · '),
|
||||
];
|
||||
|
||||
let entries = 0;
|
||||
for (const [key, label] of SECTION_ORDER) {
|
||||
const items = digest.sections[key] ?? [];
|
||||
if (items.length === 0) continue;
|
||||
entries += items.length;
|
||||
lines.push('', `${label} (${items.length})`);
|
||||
for (const item of items.slice(0, limit)) lines.push(itemLine(item, options.now));
|
||||
if (items.length > limit) lines.push(` … ${items.length - limit} more`);
|
||||
}
|
||||
|
||||
if (entries === 0) lines.push('', 'nothing happened while you were away');
|
||||
|
||||
const tokens = [
|
||||
formatTokens(totals.inputTokens ?? 0) ? `${formatTokens(totals.inputTokens ?? 0)} in` : '',
|
||||
formatTokens(totals.outputTokens ?? 0) ? `${formatTokens(totals.outputTokens ?? 0)} out` : '',
|
||||
typeof totals.estimatedCost === 'number' && totals.estimatedCost > 0 ? `$${totals.estimatedCost.toFixed(2)}` : '',
|
||||
].filter((part) => part !== '');
|
||||
if (tokens.length > 0) lines.push('', `tokens: ${tokens.join(' · ')}`);
|
||||
|
||||
return lines;
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* @fileoverview Pure byte-stream to input-event parser for raw-mode stdin.
|
||||
*
|
||||
* Stateful (a sequence can arrive split across reads, and a UTF-8 character can
|
||||
* be split mid-code-point) but pure: it owns a byte buffer and nothing else, no
|
||||
* stdin, no timers. The one timing decision a terminal forces on us stays with
|
||||
* the caller: a lone ESC is indistinguishable from the start of an arrow key
|
||||
* until something either follows it or does not, so the parser HOLDS a trailing
|
||||
* ESC and the caller calls `flush()` after ~30ms of silence to turn it into an
|
||||
* Escape event.
|
||||
*
|
||||
* Unknown sequences are swallowed rather than leaked as text: a stray
|
||||
* `CSI 200~` must never end up typed into a prompt composer.
|
||||
*
|
||||
* @module tui/tui-keys
|
||||
*/
|
||||
|
||||
/** Keys with a name rather than a character. */
|
||||
export type TuiNamedKey =
|
||||
| 'up'
|
||||
| 'down'
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'home'
|
||||
| 'end'
|
||||
| 'pageup'
|
||||
| 'pagedown'
|
||||
| 'delete'
|
||||
| 'insert';
|
||||
|
||||
export type TuiMouseKind = 'press' | 'release' | 'wheel-up' | 'wheel-down';
|
||||
|
||||
/** Discriminated union, exhaustive-switch friendly (see `utils/assertNever`). */
|
||||
export type TuiInputEvent =
|
||||
| { type: 'char'; value: string }
|
||||
| { type: 'enter' }
|
||||
| { type: 'tab' }
|
||||
| { type: 'backspace' }
|
||||
| { type: 'escape' }
|
||||
| { type: 'ctrl'; key: string }
|
||||
| { type: 'alt'; value: string }
|
||||
| { type: 'key'; name: TuiNamedKey }
|
||||
| { type: 'mouse'; kind: TuiMouseKind; x: number; y: number; button: number };
|
||||
|
||||
export interface TuiKeyParser {
|
||||
/** Decode a chunk. Incomplete tails are held for the next call. */
|
||||
feed(chunk: Buffer | string): TuiInputEvent[];
|
||||
/** Resolve a held ESC (the caller's disambiguation timer fired). */
|
||||
flush(): TuiInputEvent[];
|
||||
/** Bytes currently held back. Exposed for the ESC timer and for tests. */
|
||||
pending(): number;
|
||||
}
|
||||
|
||||
/**
|
||||
* An unterminated sequence longer than this is not a sequence: the held bytes
|
||||
* are dropped whole, so a garbage burst can neither wedge the parser nor leak
|
||||
* its bytes into a prompt as typed characters.
|
||||
*/
|
||||
const MAX_PENDING_BYTES = 64;
|
||||
|
||||
/** Bytes in a UTF-8 sequence given its lead byte; 0 for a byte that cannot lead one. */
|
||||
function utf8SequenceLength(lead: number): number {
|
||||
if (lead < 0x80) return 1;
|
||||
if (lead >= 0xc2 && lead <= 0xdf) return 2;
|
||||
if (lead >= 0xe0 && lead <= 0xef) return 3;
|
||||
if (lead >= 0xf0 && lead <= 0xf4) return 4;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const CSI_FINAL_KEYS: Record<string, TuiNamedKey> = {
|
||||
A: 'up',
|
||||
B: 'down',
|
||||
C: 'right',
|
||||
D: 'left',
|
||||
H: 'home',
|
||||
F: 'end',
|
||||
};
|
||||
|
||||
/** `CSI <n> ~` keys, by their first numeric parameter. */
|
||||
const CSI_TILDE_KEYS: Record<number, TuiNamedKey> = {
|
||||
1: 'home',
|
||||
2: 'insert',
|
||||
3: 'delete',
|
||||
4: 'end',
|
||||
5: 'pageup',
|
||||
6: 'pagedown',
|
||||
7: 'home',
|
||||
8: 'end',
|
||||
};
|
||||
|
||||
/** Result of trying to parse one sequence off the front of the buffer. */
|
||||
type ParseStep = { consumed: number; events: TuiInputEvent[] } | 'incomplete';
|
||||
|
||||
const NOTHING: TuiInputEvent[] = [];
|
||||
|
||||
export function createKeyParser(): TuiKeyParser {
|
||||
let buf: Buffer = Buffer.alloc(0);
|
||||
|
||||
/** Parse the CSI/SS3 sequence that starts at buf[0] === ESC. */
|
||||
const parseEscape = (): ParseStep => {
|
||||
if (buf.length < 2) return 'incomplete';
|
||||
const second = buf[1];
|
||||
|
||||
// SS3 (`ESC O <final>`): the arrows/Home/End of application-cursor mode.
|
||||
if (second === 0x4f) {
|
||||
if (buf.length < 3) return 'incomplete';
|
||||
const name = CSI_FINAL_KEYS[String.fromCharCode(buf[2])];
|
||||
return { consumed: 3, events: name ? [{ type: 'key', name }] : NOTHING };
|
||||
}
|
||||
|
||||
// ESC followed by a printable character IN THE SAME READ is Alt+that key:
|
||||
// that is how every terminal sends a meta chord. A lone Esc cannot look
|
||||
// like this, because a buffer holding only ESC returns 'incomplete' above
|
||||
// and is flushed as `escape` when the read ends, which is the standard way
|
||||
// to tell the two apart without a timer.
|
||||
//
|
||||
// ⚠️ Three characters are deliberately NOT treated as Alt chords, because
|
||||
// the terminal uses them to introduce sequences and a chord is
|
||||
// indistinguishable from one: `[` (CSI) and `O` (SS3) would swallow every
|
||||
// arrow key, and `]` (OSC) would swallow a terminal's colour-query reply.
|
||||
// Alt+[ and Alt+] therefore cannot exist in a terminal at all, which is why
|
||||
// the list binds bare `[` and `]` for the same job.
|
||||
if (second !== 0x5b) {
|
||||
if (second >= 0x20 && second <= 0x7e && second !== 0x4f && second !== 0x5d) {
|
||||
return { consumed: 2, events: [{ type: 'alt', value: String.fromCharCode(second) }] };
|
||||
}
|
||||
return { consumed: 1, events: [{ type: 'escape' }] };
|
||||
}
|
||||
|
||||
let j = 2;
|
||||
while (j < buf.length && buf[j] >= 0x30 && buf[j] <= 0x3f) j++;
|
||||
while (j < buf.length && buf[j] >= 0x20 && buf[j] <= 0x2f) j++;
|
||||
if (j >= buf.length) return 'incomplete';
|
||||
const final = String.fromCharCode(buf[j]);
|
||||
const params = buf.subarray(2, j).toString('latin1');
|
||||
const consumed = j + 1;
|
||||
|
||||
// X10 mouse (`CSI M` + 3 raw bytes): swallowed, but its payload bytes must
|
||||
// be consumed or they would surface as typed characters.
|
||||
if (params === '' && final === 'M') {
|
||||
if (buf.length < consumed + 3) return 'incomplete';
|
||||
return { consumed: consumed + 3, events: NOTHING };
|
||||
}
|
||||
|
||||
if (params.startsWith('<') && (final === 'M' || final === 'm')) {
|
||||
return { consumed, events: parseSgrMouse(params.slice(1), final) };
|
||||
}
|
||||
|
||||
if (final === '~') {
|
||||
const name = CSI_TILDE_KEYS[Number.parseInt(params, 10)];
|
||||
return { consumed, events: name ? [{ type: 'key', name }] : NOTHING };
|
||||
}
|
||||
|
||||
// Modified arrows (`CSI 1;5A`) carry the same final byte; the modifier is
|
||||
// dropped rather than exposed, since nothing in the keymap wants it yet.
|
||||
const named = CSI_FINAL_KEYS[final];
|
||||
return { consumed, events: named ? [{ type: 'key', name: named }] : NOTHING };
|
||||
};
|
||||
|
||||
const parseSgrMouse = (params: string, final: string): TuiInputEvent[] => {
|
||||
const parts = params.split(';');
|
||||
if (parts.length < 3) return NOTHING;
|
||||
const button = Number.parseInt(parts[0], 10);
|
||||
const x = Number.parseInt(parts[1], 10);
|
||||
const y = Number.parseInt(parts[2], 10);
|
||||
if (!Number.isFinite(button) || !Number.isFinite(x) || !Number.isFinite(y)) return NOTHING;
|
||||
if (button >= 64) {
|
||||
// 64 = wheel up, 65 = wheel down (the low bit is the direction).
|
||||
const kind: TuiMouseKind = (button & 1) === 1 ? 'wheel-down' : 'wheel-up';
|
||||
return [{ type: 'mouse', kind, x, y, button }];
|
||||
}
|
||||
// Motion reports (bit 32) would fire on every pixel of a drag; nothing in
|
||||
// the keymap consumes them, so they are swallowed here rather than upstream.
|
||||
if ((button & 32) === 32) return NOTHING;
|
||||
return [{ type: 'mouse', kind: final === 'M' ? 'press' : 'release', x, y, button }];
|
||||
};
|
||||
|
||||
/** Parse one non-escape byte (or one UTF-8 character) off the front. */
|
||||
const parseByte = (): ParseStep => {
|
||||
const b = buf[0];
|
||||
// LF counts as Enter because some terminals send it for Return; the cost is
|
||||
// that Ctrl+J is not bindable, which no key in the plan's keymap wants.
|
||||
if (b === 0x0d || b === 0x0a) return { consumed: 1, events: [{ type: 'enter' }] };
|
||||
if (b === 0x09) return { consumed: 1, events: [{ type: 'tab' }] };
|
||||
if (b === 0x7f || b === 0x08) return { consumed: 1, events: [{ type: 'backspace' }] };
|
||||
if (b === 0x00) return { consumed: 1, events: [{ type: 'ctrl', key: '@' }] };
|
||||
if (b >= 0x01 && b <= 0x1a) {
|
||||
return { consumed: 1, events: [{ type: 'ctrl', key: String.fromCharCode(b + 0x60) }] };
|
||||
}
|
||||
if (b >= 0x1c && b <= 0x1f) {
|
||||
return { consumed: 1, events: [{ type: 'ctrl', key: String.fromCharCode(b + 0x40) }] };
|
||||
}
|
||||
const length = utf8SequenceLength(b);
|
||||
if (length === 0) return { consumed: 1, events: NOTHING };
|
||||
if (buf.length < length) return 'incomplete';
|
||||
const value = buf.subarray(0, length).toString('utf8');
|
||||
// A lead byte followed by junk decodes to U+FFFD; that is corruption on the
|
||||
// wire, not something to type into a composer. Only the bad lead byte is
|
||||
// dropped, so whatever valid input followed it still decodes.
|
||||
if (value.includes('�')) return { consumed: 1, events: NOTHING };
|
||||
return { consumed: length, events: [{ type: 'char', value }] };
|
||||
};
|
||||
|
||||
/** Drain the buffer, stopping at the first incomplete sequence. */
|
||||
const drain = (events: TuiInputEvent[]): void => {
|
||||
while (buf.length > 0) {
|
||||
const step = buf[0] === 0x1b ? parseEscape() : parseByte();
|
||||
if (step === 'incomplete') {
|
||||
if (buf.length > MAX_PENDING_BYTES) buf = Buffer.alloc(0);
|
||||
return;
|
||||
}
|
||||
for (const event of step.events) events.push(event);
|
||||
buf = buf.subarray(step.consumed);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
feed(chunk: Buffer | string): TuiInputEvent[] {
|
||||
const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk;
|
||||
buf = buf.length === 0 ? Buffer.from(bytes) : Buffer.concat([buf, bytes]);
|
||||
const events: TuiInputEvent[] = [];
|
||||
drain(events);
|
||||
return events;
|
||||
},
|
||||
|
||||
flush(): TuiInputEvent[] {
|
||||
const events: TuiInputEvent[] = [];
|
||||
if (buf.length > 0 && buf[0] === 0x1b) {
|
||||
events.push({ type: 'escape' });
|
||||
buf = buf.subarray(1);
|
||||
drain(events);
|
||||
}
|
||||
return events;
|
||||
},
|
||||
|
||||
pending(): number {
|
||||
return buf.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @fileoverview Pure responsive layout math for the TUI frame.
|
||||
*
|
||||
* One rule decides the shape: below 72 columns (Termius, iPhone portrait) the
|
||||
* preview pane is gone and rows take two lines, which is the constraint the
|
||||
* `sc` chooser was built around and the reason it is still usable on a phone.
|
||||
* Above it, a clamped sidebar carries the session list and the preview takes
|
||||
* the rest.
|
||||
*
|
||||
* Rectangles are 1-based (row 1, column 1 is the top-left cell) because that is
|
||||
* what `ESC [ <row>;<col> H` takes, and every region is clamped to a
|
||||
* non-negative size so a 5x5 terminal degrades instead of producing negative
|
||||
* widths that would crash the renderer.
|
||||
*
|
||||
* @module tui/tui-layout
|
||||
*/
|
||||
|
||||
import type { TuiConnectionStatus } from './tui-types.js';
|
||||
|
||||
/** Width at which the preview pane is dropped and rows become two lines. */
|
||||
export const NARROW_BREAKPOINT = 72;
|
||||
/** Sidebar clamp: narrower than this and a session name stops being readable. */
|
||||
export const SIDEBAR_MIN_WIDTH = 34;
|
||||
/** Sidebar clamp: wider than this is wasted on a list of short names. */
|
||||
export const SIDEBAR_MAX_WIDTH = 44;
|
||||
/** A preview thinner than this shows nothing useful, so the layout goes narrow instead. */
|
||||
export const PREVIEW_MIN_WIDTH = 24;
|
||||
/** Share of the width the sidebar aims for between the clamps. */
|
||||
const SIDEBAR_RATIO = 0.36;
|
||||
|
||||
export interface TuiRect {
|
||||
/** 1-based terminal row of the first line. */
|
||||
row: number;
|
||||
/** 1-based terminal column of the first cell. */
|
||||
col: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface TuiLayoutOptions {
|
||||
/**
|
||||
* Reserve one line under the header for the connection banner. The caller
|
||||
* decides with `needsBanner(model.connection)`, so layout stays pure math.
|
||||
*/
|
||||
banner?: boolean;
|
||||
}
|
||||
|
||||
export interface TuiLayout {
|
||||
cols: number;
|
||||
rows: number;
|
||||
/** No preview pane, two-line rows. */
|
||||
narrow: boolean;
|
||||
/** Terminal lines one session row occupies. */
|
||||
rowHeight: 1 | 2;
|
||||
header: TuiRect;
|
||||
/** Connection banner, when the caller asked for one and there was room. */
|
||||
banner: TuiRect | null;
|
||||
/** Everything between header and footer, banner included. */
|
||||
body: TuiRect;
|
||||
/** The session list. */
|
||||
list: TuiRect;
|
||||
/** The one-column rule between list and preview; null in narrow mode. */
|
||||
divider: TuiRect | null;
|
||||
/** The preview pane; null in narrow mode. */
|
||||
preview: TuiRect | null;
|
||||
footer: TuiRect;
|
||||
}
|
||||
|
||||
/** Which connection states get a banner line under the header. */
|
||||
export function needsBanner(connection: TuiConnectionStatus): boolean {
|
||||
return connection !== 'connected';
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rectangles for one frame at `cols` x `rows`.
|
||||
*
|
||||
* The header always exists; the footer appears from 2 rows up; the body is
|
||||
* whatever is left, which may legitimately be zero lines high.
|
||||
*/
|
||||
export function computeLayout(cols: number, rows: number, options: TuiLayoutOptions = {}): TuiLayout {
|
||||
const width = Math.max(1, Math.floor(cols) || 1);
|
||||
const height = Math.max(1, Math.floor(rows) || 1);
|
||||
|
||||
const headerHeight = 1;
|
||||
const footerHeight = height >= 2 ? 1 : 0;
|
||||
const bodyHeight = Math.max(0, height - headerHeight - footerHeight);
|
||||
const bodyRow = headerHeight + 1;
|
||||
|
||||
const header: TuiRect = { row: 1, col: 1, width, height: headerHeight };
|
||||
const footer: TuiRect = { row: height, col: 1, width, height: footerHeight };
|
||||
const body: TuiRect = { row: bodyRow, col: 1, width, height: bodyHeight };
|
||||
|
||||
const bannerHeight = options.banner === true && bodyHeight > 0 ? 1 : 0;
|
||||
const banner: TuiRect | null = bannerHeight > 0 ? { row: bodyRow, col: 1, width, height: 1 } : null;
|
||||
|
||||
const contentRow = bodyRow + bannerHeight;
|
||||
const contentHeight = Math.max(0, bodyHeight - bannerHeight);
|
||||
|
||||
const sidebarTarget = Math.floor(width * SIDEBAR_RATIO);
|
||||
const sidebarWidth = clamp(sidebarTarget, SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH);
|
||||
const previewWidth = width - sidebarWidth - 1;
|
||||
const narrow = width < NARROW_BREAKPOINT || previewWidth < PREVIEW_MIN_WIDTH;
|
||||
|
||||
if (narrow) {
|
||||
return {
|
||||
cols: width,
|
||||
rows: height,
|
||||
narrow: true,
|
||||
rowHeight: 2,
|
||||
header,
|
||||
banner,
|
||||
body,
|
||||
list: { row: contentRow, col: 1, width, height: contentHeight },
|
||||
divider: null,
|
||||
preview: null,
|
||||
footer,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
cols: width,
|
||||
rows: height,
|
||||
narrow: false,
|
||||
rowHeight: 1,
|
||||
header,
|
||||
banner,
|
||||
body,
|
||||
list: { row: contentRow, col: 1, width: sidebarWidth, height: contentHeight },
|
||||
divider: { row: contentRow, col: sidebarWidth + 1, width: 1, height: contentHeight },
|
||||
preview: { row: contentRow, col: sidebarWidth + 2, width: previewWidth, height: contentHeight },
|
||||
footer,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* @fileoverview Pure state, classification and grouping for the TUI dashboard.
|
||||
*
|
||||
* Classification speaks the web UI's language on purpose (red blocked, yellow
|
||||
* waiting, green working, muted idle), because a user who has both surfaces
|
||||
* open must never have to translate between them. The inputs are the ones the
|
||||
* server already computes: a unified-list row and, when the session is blocked,
|
||||
* the approvals-inbox item that blocks it. Nothing here screen-scrapes.
|
||||
*
|
||||
* Selection is tracked by session id, never by row index: rows re-sort under
|
||||
* the cursor constantly (a session starts working, an approval lands), and an
|
||||
* index-tracked cursor would silently move the selection to a different
|
||||
* session between two keystrokes.
|
||||
*
|
||||
* PURE: no IO, no timers, no `process.*`. The store mutates its own state and
|
||||
* nothing else.
|
||||
*
|
||||
* @module tui/tui-model
|
||||
*/
|
||||
|
||||
import type { SearchResultGroup, SearchSourceType } from '../types/search.js';
|
||||
import type { ApprovalItem } from '../web/approval-inbox.js';
|
||||
import type {
|
||||
TuiConfirmState,
|
||||
TuiConnectionStatus,
|
||||
TuiDigestState,
|
||||
TuiGroup,
|
||||
TuiGroupKey,
|
||||
TuiHeaderInfo,
|
||||
TuiMessage,
|
||||
TuiPickerState,
|
||||
TuiPreview,
|
||||
TuiPromptState,
|
||||
TuiRenderModel,
|
||||
TuiRow,
|
||||
TuiSearchEntry,
|
||||
TuiSearchState,
|
||||
TuiSessionRow,
|
||||
TuiSessionState,
|
||||
TuiUiMode,
|
||||
} from './tui-types.js';
|
||||
|
||||
/** How many history rows the RECENT group shows before it stops being a dashboard. */
|
||||
export const DEFAULT_RECENT_LIMIT = 8;
|
||||
|
||||
export const GROUP_ORDER: readonly TuiGroupKey[] = ['needs-you', 'working', 'idle', 'recent'];
|
||||
|
||||
export const GROUP_LABELS: Record<TuiGroupKey, string> = {
|
||||
'needs-you': 'NEEDS YOU',
|
||||
working: 'WORKING',
|
||||
idle: 'IDLE',
|
||||
recent: 'RECENT',
|
||||
};
|
||||
|
||||
const STATE_GROUP: Record<TuiSessionState, TuiGroupKey> = {
|
||||
'blocked-question': 'needs-you',
|
||||
'blocked-permission': 'needs-you',
|
||||
waiting: 'needs-you',
|
||||
working: 'working',
|
||||
idle: 'idle',
|
||||
recent: 'recent',
|
||||
};
|
||||
|
||||
/** A row is live when the unified merge saw it in the in-memory session map. */
|
||||
export function isLiveRow(session: TuiSessionRow): boolean {
|
||||
return Array.isArray(session.sources) && session.sources.includes('live');
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify one row.
|
||||
*
|
||||
* Order matters and mirrors `_mobileOverviewState()` in the web UI: a pending
|
||||
* prompt outranks everything (it is literally blocking the agent), and it
|
||||
* outranks a stale `busy` status because the hook is the newer signal. An
|
||||
* errored session has no state of its own here and joins the waiting tier,
|
||||
* since it is equally something only a human can clear.
|
||||
*/
|
||||
export function classifySession(session: TuiSessionRow, approval?: ApprovalItem): TuiSessionState {
|
||||
if (!isLiveRow(session)) return 'recent';
|
||||
if (approval) {
|
||||
if (approval.kind === 'permission') return 'blocked-permission';
|
||||
if (approval.kind === 'question') return 'blocked-question';
|
||||
return 'waiting';
|
||||
}
|
||||
if (session.status === 'error') return 'waiting';
|
||||
if (session.isWorking === true || session.status === 'busy') return 'working';
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* Epoch ms the session entered its current state, which is what the intra-group
|
||||
* ordering sorts on. 0 when nothing usable is known.
|
||||
*
|
||||
* A WORKING pane repaints about once a second, so its `lastActivityAt` is
|
||||
* always "now" and would report every running turn as freshly started; the
|
||||
* turn's own start is the pane's last Enter.
|
||||
*/
|
||||
export function stateSince(state: TuiSessionState, session: TuiSessionRow, approval?: ApprovalItem): number {
|
||||
if (approval) return approval.createdAt;
|
||||
if (state === 'working') return session.lastSubmitAt ?? session.createdAt ?? 0;
|
||||
return session.lastActivityAt ?? session.createdAt ?? 0;
|
||||
}
|
||||
|
||||
/** Classify a batch of rows against the pending approvals, keyed by session id. */
|
||||
export function buildRows(
|
||||
sessions: readonly TuiSessionRow[],
|
||||
approvals: ReadonlyMap<string, ApprovalItem> = new Map()
|
||||
): TuiRow[] {
|
||||
return sessions.map((session) => {
|
||||
const approval = approvals.get(session.sessionId);
|
||||
const state = classifySession(session, approval);
|
||||
const row: TuiRow = {
|
||||
session,
|
||||
state,
|
||||
group: STATE_GROUP[state],
|
||||
since: stateSince(state, session, approval),
|
||||
};
|
||||
if (approval) row.approval = approval;
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
function compareIds(a: TuiRow, b: TuiRow): number {
|
||||
if (a.session.sessionId < b.session.sessionId) return -1;
|
||||
if (a.session.sessionId > b.session.sessionId) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Longest first: the oldest anchor wins, and an unknown anchor sorts last. */
|
||||
function compareLongestFirst(a: TuiRow, b: TuiRow): number {
|
||||
const left = a.since || Number.MAX_SAFE_INTEGER;
|
||||
const right = b.since || Number.MAX_SAFE_INTEGER;
|
||||
return left !== right ? left - right : compareIds(a, b);
|
||||
}
|
||||
|
||||
/** Newest first: the freshest anchor wins, and an unknown anchor sorts last. */
|
||||
function compareNewestFirst(a: TuiRow, b: TuiRow): number {
|
||||
const left = a.since || 0;
|
||||
const right = b.since || 0;
|
||||
return left !== right ? right - left : compareIds(a, b);
|
||||
}
|
||||
|
||||
export interface GroupOptions {
|
||||
/** RECENT is a tail, not a list: everything past this is dropped. */
|
||||
recentLimit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split classified rows into the four display groups.
|
||||
*
|
||||
* Always returns all four in display order (empty ones included) so callers
|
||||
* never have to guess the shape; the renderer skips the empty ones.
|
||||
*
|
||||
* NEEDS YOU and WORKING are ordered by how long they have been in that state
|
||||
* (longest first: the thing that has waited longest for you is the thing to
|
||||
* look at). IDLE and RECENT are ordered by recency, newest first.
|
||||
*/
|
||||
export function groupSessions(rows: readonly TuiRow[], options: GroupOptions = {}): TuiGroup[] {
|
||||
const recentLimit = Math.max(0, Math.floor(options.recentLimit ?? DEFAULT_RECENT_LIMIT));
|
||||
const buckets: Record<TuiGroupKey, TuiRow[]> = {
|
||||
'needs-you': [],
|
||||
working: [],
|
||||
idle: [],
|
||||
recent: [],
|
||||
};
|
||||
for (const row of rows) buckets[row.group].push(row);
|
||||
|
||||
buckets['needs-you'].sort(compareLongestFirst);
|
||||
buckets.working.sort(compareLongestFirst);
|
||||
buckets.idle.sort(compareNewestFirst);
|
||||
buckets.recent.sort(compareNewestFirst);
|
||||
buckets.recent = buckets.recent.slice(0, recentLimit);
|
||||
|
||||
return GROUP_ORDER.map((key) => ({ key, label: GROUP_LABELS[key], rows: buckets[key] }));
|
||||
}
|
||||
|
||||
/** The cursor's list: group headers are chrome, only sessions are selectable. */
|
||||
export function flattenRows(groups: readonly TuiGroup[]): TuiRow[] {
|
||||
const rows: TuiRow[] = [];
|
||||
for (const group of groups) rows.push(...group.rows);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold an incoming row into a known one. Defined fields win, `undefined` never
|
||||
* clobbers (a live SSE payload carries no transcript fields, a unified refresh
|
||||
* carries no token counters), but a non-empty `sources` list REPLACES rather
|
||||
* than unions: a session that ended must be able to lose its `live` source and
|
||||
* fall to RECENT.
|
||||
*/
|
||||
export function mergeSessionRow(existing: TuiSessionRow, incoming: TuiSessionRow): TuiSessionRow {
|
||||
const merged: TuiSessionRow = { ...existing };
|
||||
for (const [key, value] of Object.entries(incoming)) {
|
||||
if (value === undefined) continue;
|
||||
(merged as unknown as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
merged.sources = incoming.sources?.length ? [...incoming.sources] : [...(existing.sources ?? [])];
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Search results (pure)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SEARCH_GROUP_LABELS: Record<SearchSourceType, string> = {
|
||||
session: 'SESSIONS',
|
||||
event: 'EVENTS',
|
||||
file: 'FILES',
|
||||
};
|
||||
|
||||
/**
|
||||
* A session snippet opens with the session's own name, which the row already
|
||||
* shows in its first column (`search-service.ts` builds it as
|
||||
* `w1-alpha <em dash> /tmp/alpha`, hence the separator in the pattern).
|
||||
* Dropping the repeat is what keeps a result row from reading as a stutter.
|
||||
*/
|
||||
function withoutLabelPrefix(snippet: string, label: string): string {
|
||||
const rest = snippet.startsWith(label) ? snippet.slice(label.length) : snippet;
|
||||
return rest === snippet ? snippet : rest.replace(/^\s*(?:[—:-]\s*)?/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten `GET /api/search`'s typed groups into the overlay's lines: a header
|
||||
* per group, then its results. Only a result row carries a session id, which is
|
||||
* what the cursor uses to skip headers.
|
||||
*
|
||||
* `isLive` decides which rows can hand the dashboard a session: a history hit
|
||||
* has a session id too, but selecting it would move the cursor to a row that is
|
||||
* not on the list.
|
||||
*/
|
||||
export function buildSearchEntries(
|
||||
groups: readonly SearchResultGroup[],
|
||||
isLive: (sessionId: string) => boolean
|
||||
): TuiSearchEntry[] {
|
||||
const entries: TuiSearchEntry[] = [];
|
||||
for (const group of groups) {
|
||||
if (group.results.length === 0) continue;
|
||||
entries.push({ kind: 'header', text: SEARCH_GROUP_LABELS[group.type] ?? group.type.toUpperCase() });
|
||||
for (const result of group.results) {
|
||||
const live = result.jumpTo.kind === 'session' && isLive(result.sessionId);
|
||||
const label = result.jumpTo.relativePath ?? result.sessionName ?? result.sessionId.slice(0, 8);
|
||||
entries.push({
|
||||
kind: 'result',
|
||||
text: label,
|
||||
detail: withoutLabelPrefix(result.snippet, label),
|
||||
sessionId: result.sessionId,
|
||||
live,
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** First selectable row, or -1 when the list is all headers (or empty). */
|
||||
export function firstSearchIndex(entries: readonly TuiSearchEntry[]): number {
|
||||
return entries.findIndex((entry) => entry.kind === 'result');
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the search cursor by `delta` result rows, skipping headers and stopping
|
||||
* at both ends (wrapping a search result list scrolls past the answer the user
|
||||
* was reading).
|
||||
*/
|
||||
export function moveSearchIndex(entries: readonly TuiSearchEntry[], index: number, delta: number): number {
|
||||
const step = Math.trunc(delta);
|
||||
if (step === 0) return index;
|
||||
const direction = step > 0 ? 1 : -1;
|
||||
let current = index;
|
||||
for (let remaining = Math.abs(step); remaining > 0; remaining--) {
|
||||
let next = current + direction;
|
||||
while (next >= 0 && next < entries.length && entries[next].kind !== 'result') next += direction;
|
||||
if (next < 0 || next >= entries.length) break;
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dashboard's state. Update methods mutate in place (one store per TUI
|
||||
* process, no subscribers) and every derived view is recomputed from scratch,
|
||||
* which keeps "what is on screen" a pure function of the stored facts.
|
||||
*/
|
||||
export class TuiModelStore implements TuiRenderModel {
|
||||
private sessionsById = new Map<string, TuiSessionRow>();
|
||||
private approvalsBySession = new Map<string, ApprovalItem>();
|
||||
private _revision = 0;
|
||||
|
||||
selectedId: string | null = null;
|
||||
connection: TuiConnectionStatus = 'connected';
|
||||
mode: TuiUiMode = 'list';
|
||||
header: TuiHeaderInfo = {};
|
||||
preview: TuiPreview | null = null;
|
||||
message: TuiMessage | null = null;
|
||||
confirm: TuiConfirmState | null = null;
|
||||
picker: TuiPickerState | null = null;
|
||||
prompt: TuiPromptState | null = null;
|
||||
search: TuiSearchState | null = null;
|
||||
digest: TuiDigestState | null = null;
|
||||
recentLimit: number;
|
||||
|
||||
constructor(options: GroupOptions = {}) {
|
||||
this.recentLimit = Math.max(0, Math.floor(options.recentLimit ?? DEFAULT_RECENT_LIMIT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bumped by every mutating method. The app layer repaints when this changed
|
||||
* (plus on resize and on the animation tick), which is what keeps an idle
|
||||
* dashboard from redrawing itself. Writing a public field directly bypasses
|
||||
* it, so state changes go through the methods below.
|
||||
*/
|
||||
get revision(): number {
|
||||
return this._revision;
|
||||
}
|
||||
|
||||
private touch(): void {
|
||||
this._revision++;
|
||||
}
|
||||
|
||||
// ── Data ───────────────────────────────────────────────────────────────────
|
||||
|
||||
upsertSession(session: TuiSessionRow): void {
|
||||
this.mutate(() => {
|
||||
const existing = this.sessionsById.get(session.sessionId);
|
||||
this.sessionsById.set(session.sessionId, existing ? mergeSessionRow(existing, session) : { ...session });
|
||||
});
|
||||
}
|
||||
|
||||
removeSession(sessionId: string): void {
|
||||
this.mutate(() => {
|
||||
this.sessionsById.delete(sessionId);
|
||||
this.approvalsBySession.delete(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
/** Full refresh (a `GET /api/sessions/unified` poll): the server is authoritative. */
|
||||
replaceSessions(sessions: readonly TuiSessionRow[]): void {
|
||||
this.mutate(() => {
|
||||
this.sessionsById.clear();
|
||||
for (const session of sessions) this.sessionsById.set(session.sessionId, { ...session });
|
||||
});
|
||||
}
|
||||
|
||||
setApprovals(items: readonly ApprovalItem[]): void {
|
||||
this.mutate(() => {
|
||||
this.approvalsBySession.clear();
|
||||
// One active item per session is an inbox invariant; the newest wins if
|
||||
// that ever stops being true.
|
||||
for (const item of items) this.approvalsBySession.set(item.sessionId, item);
|
||||
});
|
||||
}
|
||||
|
||||
sessions(): TuiSessionRow[] {
|
||||
return [...this.sessionsById.values()];
|
||||
}
|
||||
|
||||
// ── Chrome ─────────────────────────────────────────────────────────────────
|
||||
|
||||
setConnection(status: TuiConnectionStatus): void {
|
||||
if (this.connection === status) return;
|
||||
this.connection = status;
|
||||
this.touch();
|
||||
}
|
||||
|
||||
setHeader(header: TuiHeaderInfo): void {
|
||||
this.header = { ...this.header, ...header };
|
||||
this.touch();
|
||||
}
|
||||
|
||||
setPreview(preview: TuiPreview | null): void {
|
||||
this.preview = preview;
|
||||
this.touch();
|
||||
}
|
||||
|
||||
setMode(mode: TuiUiMode): void {
|
||||
if (this.mode === mode) return;
|
||||
this.mode = mode;
|
||||
this.touch();
|
||||
}
|
||||
|
||||
setMessage(message: TuiMessage | null): void {
|
||||
this.message = message;
|
||||
this.mode = message ? 'message' : 'list';
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/** Show (or clear) the overlay chooser. Setting one takes the keyboard. */
|
||||
setPicker(picker: TuiPickerState | null): void {
|
||||
this.picker = picker;
|
||||
this.mode = picker ? 'new-session' : 'list';
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/** Open (or close) the one-line prompt composer. Setting one takes the keyboard. */
|
||||
setPrompt(prompt: TuiPromptState | null): void {
|
||||
this.prompt = prompt;
|
||||
this.mode = prompt ? 'prompt' : 'list';
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/** Replace the composer's editor state, keeping the target session. */
|
||||
updatePrompt(composer: TuiPromptState['composer']): void {
|
||||
if (!this.prompt || this.prompt.composer === composer) return;
|
||||
this.prompt = { ...this.prompt, composer };
|
||||
this.touch();
|
||||
}
|
||||
|
||||
setSearch(search: TuiSearchState | null): void {
|
||||
this.search = search;
|
||||
this.mode = search ? 'search' : 'list';
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/** Fold a partial update into the open search overlay. No-op when it is closed. */
|
||||
updateSearch(patch: Partial<TuiSearchState>): void {
|
||||
if (!this.search) return;
|
||||
this.search = { ...this.search, ...patch };
|
||||
this.touch();
|
||||
}
|
||||
|
||||
setDigest(digest: TuiDigestState | null): void {
|
||||
this.digest = digest;
|
||||
this.mode = digest ? 'digest' : 'list';
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll the digest by `delta` lines. `capacity` is how many lines the box
|
||||
* shows, so the last page cannot scroll into empty space.
|
||||
*/
|
||||
scrollDigest(delta: number, capacity: number): void {
|
||||
if (!this.digest) return;
|
||||
const room = Math.max(0, this.digest.lines.length - Math.max(1, Math.trunc(capacity)));
|
||||
const offset = Math.min(Math.max(0, this.digest.offset + Math.trunc(delta)), room);
|
||||
if (offset === this.digest.offset) return;
|
||||
this.digest = { ...this.digest, offset };
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm the typed-name confirmation for `x` (kill). Whether what the user typed
|
||||
* AUTHORIZES the kill is `confirmAccepts()` in tui-app, which owns that rule
|
||||
* for every caller: a second copy here answered the same question differently
|
||||
* (it refused the id prefix a mux name carries) and nothing consulted it.
|
||||
*/
|
||||
beginConfirmKill(row: TuiRow, label: string): void {
|
||||
this.confirm = {
|
||||
sessionId: row.session.sessionId,
|
||||
// ⚠️ Passed in, not derived here. `row.session.name ?? id.slice(0,8)`
|
||||
// used to compute it, and `??` falls back only on null/undefined: a
|
||||
// session whose name is the EMPTY STRING (every session the server did
|
||||
// not name) sailed through it and the dialog read "Kill ?". A destructive
|
||||
// prompt that cannot say what it is about to destroy is worse than no
|
||||
// prompt, and it is now one keystroke. The caller passes the same label
|
||||
// the LIST shows, so the dialog names the row the user is looking at.
|
||||
name: label,
|
||||
};
|
||||
this.mode = 'confirm-kill';
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/** Drop whatever overlay owns the keyboard and go back to the list. */
|
||||
closeOverlay(): void {
|
||||
this.confirm = null;
|
||||
this.message = null;
|
||||
this.picker = null;
|
||||
this.prompt = null;
|
||||
this.search = null;
|
||||
this.digest = null;
|
||||
this.mode = 'list';
|
||||
this.touch();
|
||||
}
|
||||
|
||||
// ── Derived views ──────────────────────────────────────────────────────────
|
||||
|
||||
groups(): TuiGroup[] {
|
||||
return groupSessions(buildRows(this.sessions(), this.approvalsBySession), { recentLimit: this.recentLimit });
|
||||
}
|
||||
|
||||
rows(): TuiRow[] {
|
||||
return flattenRows(this.groups());
|
||||
}
|
||||
|
||||
get sessionCount(): number {
|
||||
let count = 0;
|
||||
for (const session of this.sessionsById.values()) if (isLiveRow(session)) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
// ── Cursor ─────────────────────────────────────────────────────────────────
|
||||
|
||||
selectedSession(): TuiRow | null {
|
||||
if (!this.selectedId) return null;
|
||||
return this.rows().find((row) => row.session.sessionId === this.selectedId) ?? null;
|
||||
}
|
||||
|
||||
/** Select a session by id. Returns false when it is not on screen. */
|
||||
select(sessionId: string): boolean {
|
||||
if (!this.rows().some((row) => row.session.sessionId === sessionId)) return false;
|
||||
this.moveTo(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Move by `delta` rows, skipping group headers and wrapping at both ends. */
|
||||
moveCursor(delta: number): void {
|
||||
const rows = this.rows();
|
||||
if (rows.length === 0) {
|
||||
this.moveTo(null);
|
||||
return;
|
||||
}
|
||||
const current = this.indexOfSelected(rows);
|
||||
if (current < 0) {
|
||||
this.moveTo(rows[delta >= 0 ? 0 : rows.length - 1].session.sessionId);
|
||||
return;
|
||||
}
|
||||
const step = Math.trunc(delta);
|
||||
const next = (((current + step) % rows.length) + rows.length) % rows.length;
|
||||
this.moveTo(rows[next].session.sessionId);
|
||||
}
|
||||
|
||||
/** The 1-9 jump: `n` is the 1-based position in the flattened list. */
|
||||
cursorToIndex(n: number): boolean {
|
||||
const rows = this.rows();
|
||||
const index = Math.trunc(n) - 1;
|
||||
if (index < 0 || index >= rows.length) return false;
|
||||
this.moveTo(rows[index].session.sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private moveTo(sessionId: string | null): void {
|
||||
if (this.selectedId === sessionId) return;
|
||||
this.selectedId = sessionId;
|
||||
this.touch();
|
||||
}
|
||||
|
||||
private indexOfSelected(rows: readonly TuiRow[] = this.rows()): number {
|
||||
if (!this.selectedId) return -1;
|
||||
return rows.findIndex((row) => row.session.sessionId === this.selectedId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a data mutation and keep the cursor sane afterwards: the selected
|
||||
* session stays selected wherever it moved to, and a session that vanished
|
||||
* hands the cursor to whatever now occupies its place.
|
||||
*/
|
||||
private mutate(apply: () => void): void {
|
||||
const previousIndex = this.indexOfSelected();
|
||||
apply();
|
||||
this.touch();
|
||||
const rows = this.rows();
|
||||
if (rows.length === 0) {
|
||||
this.moveTo(null);
|
||||
return;
|
||||
}
|
||||
if (this.selectedId !== null && rows.some((row) => row.session.sessionId === this.selectedId)) return;
|
||||
const index = Math.min(Math.max(previousIndex, 0), rows.length - 1);
|
||||
this.moveTo(rows[index].session.sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export function createTuiModel(options: GroupOptions = {}): TuiModelStore {
|
||||
return new TuiModelStore(options);
|
||||
}
|
||||
@@ -0,0 +1,921 @@
|
||||
/**
|
||||
* @fileoverview Pure frame renderer: model + layout in, one string out.
|
||||
*
|
||||
* The frame is absolute-addressed, one `ESC [ <row>;1 H` per line followed by
|
||||
* `ESC [ K`, so nothing ever scrolls and a repaint cannot leave debris. The
|
||||
* caller wraps the result in synchronized-output brackets (DECSET 2026) where
|
||||
* the terminal supports it; that is an IO decision and stays out of here.
|
||||
*
|
||||
* Color is decided by the caller and passed in, never detected here: chalk's
|
||||
* auto-detection is the right answer for the one-shot CLI (see `cli-style.ts`)
|
||||
* but it would make a frame non-deterministic, and "same inputs, same string"
|
||||
* is what makes this module testable. The palette below is the same semantic
|
||||
* vocabulary chalk gives `cli-style` (ok green, warn yellow, err red, info
|
||||
* cyan, muted gray, emph bold), written as raw SGR so the mapping is fixed.
|
||||
*
|
||||
* With `color: false` the frame contains no escape sequences at all beyond the
|
||||
* cursor addressing that puts each line in place.
|
||||
*
|
||||
* @module tui/tui-render
|
||||
*/
|
||||
|
||||
import { clipStyledLine, padDisplay, stripStyles, visibleWidth } from './tui-ansi.js';
|
||||
import { approvalCard } from './tui-approvals.js';
|
||||
import { composerText, composerWindow } from './tui-composer.js';
|
||||
import type { TuiLayout, TuiRect } from './tui-layout.js';
|
||||
import type { ApprovalItem } from '../web/approval-inbox.js';
|
||||
import type { StatusTelemetry } from '../usage-telemetry.js';
|
||||
import type {
|
||||
TuiDigestState,
|
||||
TuiGlyphTier,
|
||||
TuiGroup,
|
||||
TuiPickerState,
|
||||
TuiPromptState,
|
||||
TuiRenderModel,
|
||||
TuiRow,
|
||||
TuiSearchState,
|
||||
TuiSessionRow,
|
||||
TuiSessionState,
|
||||
} from './tui-types.js';
|
||||
|
||||
export interface TuiRenderOptions {
|
||||
/** Emit SGR color. False is NO_COLOR: cursor addressing and nothing else. */
|
||||
color: boolean;
|
||||
glyphs: TuiGlyphTier;
|
||||
/** Animation counter. The WORKING glyph cycles with it. */
|
||||
tick: number;
|
||||
/** Wall clock for elapsed times, passed in so a frame is reproducible. */
|
||||
now: number;
|
||||
/**
|
||||
* Footer entries, already labelled, joined here with the separator glyph.
|
||||
* The app layer passes the keys that actually do something right now (which
|
||||
* verbs are wired up, whether a server is answering); omitting it falls back
|
||||
* to the full keymap below.
|
||||
*/
|
||||
footerKeys?: readonly string[];
|
||||
/**
|
||||
* `[key, what it does]` pairs for the help overlay, same reasoning as
|
||||
* `footerKeys`: the app layer knows which verbs are wired up. Omitting it
|
||||
* falls back to the full keymap.
|
||||
*/
|
||||
helpKeys?: ReadonlyArray<readonly [string, string]>;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Palette and glyphs
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SGR = {
|
||||
reset: '\x1b[0m',
|
||||
bold: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
inverse: '\x1b[7m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m',
|
||||
gray: '\x1b[90m',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* One word per state, shared by the preview title and the `--list` output so
|
||||
* both surfaces call a session the same thing.
|
||||
*/
|
||||
export const STATE_WORDS: Record<TuiSessionState, string> = {
|
||||
'blocked-permission': 'blocked',
|
||||
'blocked-question': 'blocked',
|
||||
waiting: 'waiting',
|
||||
working: 'working',
|
||||
idle: 'idle',
|
||||
recent: 'done',
|
||||
};
|
||||
|
||||
const STATE_COLOR: Record<TuiSessionState, string> = {
|
||||
'blocked-permission': SGR.red,
|
||||
'blocked-question': SGR.red,
|
||||
waiting: SGR.yellow,
|
||||
working: SGR.green,
|
||||
idle: SGR.gray,
|
||||
recent: SGR.gray,
|
||||
};
|
||||
|
||||
export interface TuiGlyphSet {
|
||||
blockedPermission: string;
|
||||
blockedQuestion: string;
|
||||
waiting: string;
|
||||
/** WORKING animates through Claude's own glyph family, a deliberate nod. */
|
||||
working: readonly string[];
|
||||
idle: string;
|
||||
recent: string;
|
||||
cursor: string;
|
||||
rule: string;
|
||||
divider: string;
|
||||
boxTopLeft: string;
|
||||
boxTopRight: string;
|
||||
boxBottomLeft: string;
|
||||
boxBottomRight: string;
|
||||
boxHorizontal: string;
|
||||
boxVertical: string;
|
||||
enter: string;
|
||||
updown: string;
|
||||
separator: string;
|
||||
ellipsis: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Every glyph here must clear TWO bars that are easy to miss, and both were
|
||||
* failed at once by the first version of this table.
|
||||
*
|
||||
* WIDTH: the renderer addresses cells by column, so a glyph the terminal draws
|
||||
* two cells wide shifts everything after it. `east_asian_width` W or F is
|
||||
* therefore disqualifying. `✋` (U+270B) was Wide, and being an emoji is also
|
||||
* why fonts render it at emoji size in the middle of a text row.
|
||||
*
|
||||
* COVERAGE: a plain terminal font carries far less than the unicode TIER
|
||||
* implies. The tier answers "is the locale UTF-8", which says nothing about
|
||||
* whether a given codepoint has a glyph.
|
||||
*
|
||||
* One beta tester's font mapped the blocks like this, and it is the profile to
|
||||
* design against because it is an ordinary terminal font, not a broken one:
|
||||
*
|
||||
* RENDERS Latin-1 (·), Box Drawing (─ │), Block Elements (█ ▛ ▐),
|
||||
* Geometric Shapes (○ ▶), General Punctuation (…), Arrows
|
||||
* TOFU Misc Technical (⏎ U+23CE, ⏵ U+23F5), the sparse end of
|
||||
* Dingbats (❯ U+276F)
|
||||
*
|
||||
* So: draw from the blocks on the first line. Dingbats, Miscellaneous
|
||||
* Technical, Miscellaneous Symbols and anything with emoji presentation are
|
||||
* out — that class produced three separate "why are there boxes" reports, one
|
||||
* per glyph, because each was fixed on its own instead of as a class.
|
||||
*/
|
||||
const UNICODE_GLYPHS: TuiGlyphSet = {
|
||||
blockedPermission: '▲',
|
||||
blockedQuestion: '▲',
|
||||
waiting: '!',
|
||||
// Quadrant blocks, which rotate as a spinner and live in the same block as
|
||||
// the `▛█▐` art claude itself draws — proven to render on the font that
|
||||
// failed the dingbats this used to use.
|
||||
working: ['▖', '▘', '▝', '▗'],
|
||||
idle: '○',
|
||||
recent: '✔',
|
||||
cursor: '▶',
|
||||
rule: '─',
|
||||
divider: '│',
|
||||
boxTopLeft: '┌',
|
||||
boxTopRight: '┐',
|
||||
boxBottomLeft: '└',
|
||||
boxBottomRight: '┘',
|
||||
boxHorizontal: '─',
|
||||
boxVertical: '│',
|
||||
enter: '↵',
|
||||
updown: '↑↓',
|
||||
separator: '·',
|
||||
ellipsis: '…',
|
||||
};
|
||||
|
||||
/**
|
||||
* The lowest tier, for terminals that are not known-capable. Every state token
|
||||
* is three columns wide so rows still line up, mirroring what `sc` falls back
|
||||
* to today.
|
||||
*/
|
||||
const ASCII_GLYPHS: TuiGlyphSet = {
|
||||
blockedPermission: '[!]',
|
||||
blockedQuestion: '[?]',
|
||||
waiting: '[w]',
|
||||
working: ['[*]', '[+]', '[x]', '[+]'],
|
||||
idle: '[-]',
|
||||
recent: '[v]',
|
||||
cursor: '>',
|
||||
rule: '-',
|
||||
divider: '|',
|
||||
boxTopLeft: '+',
|
||||
boxTopRight: '+',
|
||||
boxBottomLeft: '+',
|
||||
boxBottomRight: '+',
|
||||
boxHorizontal: '-',
|
||||
boxVertical: '|',
|
||||
enter: 'enter',
|
||||
updown: 'up/dn',
|
||||
separator: '-',
|
||||
ellipsis: '..',
|
||||
};
|
||||
|
||||
/**
|
||||
* Glyphs for a tier. `nerd` currently renders like `unicode`: the tier exists
|
||||
* so detection has somewhere to land and a nerd-font-only set has a home,
|
||||
* without shipping glyphs nobody has reviewed on a real font.
|
||||
*/
|
||||
export function glyphsFor(tier: TuiGlyphTier): TuiGlyphSet {
|
||||
return tier === 'ascii' ? ASCII_GLYPHS : UNICODE_GLYPHS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Glyph tier from the environment. IO-ish by nature (it reads env), so it takes
|
||||
* the env as an argument and the app layer calls it once at startup. The
|
||||
* known-capable list is the same gate `scripts/tmux-chooser.sh` uses, plus a
|
||||
* UTF-8 locale check and an explicit override.
|
||||
*/
|
||||
export function detectGlyphTier(env: Record<string, string | undefined>): TuiGlyphTier {
|
||||
const override = env.CODEMAN_TUI_GLYPHS;
|
||||
if (override === 'ascii' || override === 'unicode' || override === 'nerd') return override;
|
||||
const term = env.TERM ?? '';
|
||||
if (term === '' || term === 'dumb') return 'ascii';
|
||||
const locale = env.LC_ALL || env.LC_CTYPE || env.LANG || '';
|
||||
if (!/utf-?8/i.test(locale)) return 'ascii';
|
||||
const termProgram = env.TERM_PROGRAM ?? '';
|
||||
if (termProgram.startsWith('iTerm') || term === 'xterm-kitty' || env.WEZTERM_PANE || env.LC_TERMINAL === 'iTerm2') {
|
||||
return 'nerd';
|
||||
}
|
||||
return 'unicode';
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Formatting helpers (pure, exported for tests and for the app layer)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Compact age: `45s`, `11m`, `2h`, `3d`. Empty when the anchor is unknown. */
|
||||
export function formatElapsed(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms < 0) return '';
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
|
||||
function trimTrailingZero(value: string): string {
|
||||
return value.endsWith('.0') ? value.slice(0, -2) : value;
|
||||
}
|
||||
|
||||
/** Compact token count: `842`, `45.2k`, `1.2M`. Empty when there is nothing to show. */
|
||||
export function formatTokens(total: number): string {
|
||||
if (!Number.isFinite(total) || total <= 0) return '';
|
||||
if (total < 1000) return String(Math.floor(total));
|
||||
if (total < 1_000_000) return `${trimTrailingZero((total / 1000).toFixed(1))}k`;
|
||||
return `${trimTrailingZero((total / 1_000_000).toFixed(1))}M`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The header's plan-usage chip: `5h 32% · wk 61%`, the same two windows the web
|
||||
* chip shows (the statusline telemetry carries no others). Empty when the
|
||||
* account reports neither, so the header shows no placeholder for a fact that
|
||||
* does not exist. The separator is passed in because the header's own comes
|
||||
* from the glyph tier, and an ASCII terminal must not get a stray `·`.
|
||||
*/
|
||||
export function formatPlanUsage(usage: StatusTelemetry | null | undefined, separator = ' · '): string {
|
||||
if (!usage) return '';
|
||||
const parts: string[] = [];
|
||||
if (typeof usage.fiveHour?.usedPercentage === 'number') {
|
||||
parts.push(`5h ${Math.round(usage.fiveHour.usedPercentage)}%`);
|
||||
}
|
||||
if (typeof usage.sevenDay?.usedPercentage === 'number') {
|
||||
parts.push(`wk ${Math.round(usage.sevenDay.usedPercentage)}%`);
|
||||
}
|
||||
return parts.join(separator);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a row is called. Same rule as the web history rows, including the
|
||||
* "(no content)" placeholder the transcript reader emits, which is not a title.
|
||||
*/
|
||||
export function rowLabel(session: TuiSessionRow): string {
|
||||
if (session.name) return session.name;
|
||||
const base = (session.workingDir ?? '').split('/').filter(Boolean).pop();
|
||||
// ⚠️ A LIVE pane (it has a mux name) is identified by WHERE it runs, never by
|
||||
// a line scraped out of its transcript. A session created before the user has
|
||||
// typed anything has no prompt to be named after, so the fallback took
|
||||
// whatever the CLI happened to print first: a beta tester's new session
|
||||
// appeared in the list called "Login interrupted", which reads like a failure
|
||||
// report and was in fact a healthy session. A history row is the opposite
|
||||
// case, where the prompt IS the identity, so it keeps the old order.
|
||||
if (session.muxName && base) return base;
|
||||
const prompt = (session.firstPrompt ?? '').trim();
|
||||
if (prompt && prompt !== '(no content)') return prompt;
|
||||
return base || session.sessionId.slice(0, 8);
|
||||
}
|
||||
|
||||
/** Keep the tail of a path: the last segments identify it, the root never does. */
|
||||
function truncatePathLeft(path: string, width: number, ellipsis: string): string {
|
||||
if (width <= 0) return '';
|
||||
if (visibleWidth(path) <= width) return path;
|
||||
const keep = Math.max(0, width - visibleWidth(ellipsis));
|
||||
return ellipsis + path.slice(path.length - keep);
|
||||
}
|
||||
|
||||
function tokensOf(session: TuiSessionRow): number {
|
||||
return (session.inputTokens ?? 0) + (session.outputTokens ?? 0);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Painting
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Painter = (text: string, code: string) => string;
|
||||
|
||||
function painterFor(enabled: boolean): Painter {
|
||||
return enabled ? (text, code) => (text === '' ? text : `${code}${text}${SGR.reset}`) : (text) => text;
|
||||
}
|
||||
|
||||
function stateGlyph(row: TuiRow, glyphs: TuiGlyphSet, tick: number): string {
|
||||
switch (row.state) {
|
||||
case 'blocked-permission':
|
||||
return glyphs.blockedPermission;
|
||||
case 'blocked-question':
|
||||
return glyphs.blockedQuestion;
|
||||
case 'waiting':
|
||||
return glyphs.waiting;
|
||||
case 'working': {
|
||||
const frames = glyphs.working;
|
||||
const index = ((Math.trunc(tick) % frames.length) + frames.length) % frames.length;
|
||||
return frames[index];
|
||||
}
|
||||
case 'idle':
|
||||
return glyphs.idle;
|
||||
case 'recent':
|
||||
return glyphs.recent;
|
||||
}
|
||||
}
|
||||
|
||||
function centered(text: string, width: number): string {
|
||||
const pad = Math.max(0, Math.floor((width - visibleWidth(text)) / 2));
|
||||
return padDisplay(`${' '.repeat(pad)}${text}`, width);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Rows and groups
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RowContext {
|
||||
width: number;
|
||||
/** 1-based position in the flattened list; only 1-9 get a jump digit. */
|
||||
index: number;
|
||||
selected: boolean;
|
||||
twoLine: boolean;
|
||||
glyphs: TuiGlyphSet;
|
||||
opts: TuiRenderOptions;
|
||||
}
|
||||
|
||||
function renderRowLines(row: TuiRow, ctx: RowContext): string[] {
|
||||
// A selected row is one inverse-video block, so its parts are built unpainted:
|
||||
// an inner reset would punch a hole in the highlight.
|
||||
const inverse = ctx.selected && ctx.opts.color;
|
||||
const paint = painterFor(ctx.opts.color && !inverse);
|
||||
const { session } = row;
|
||||
|
||||
const marker = ctx.selected ? padDisplay(ctx.glyphs.cursor, 2) : ' ';
|
||||
const digit = ctx.index >= 1 && ctx.index <= 9 ? `${ctx.index} ` : ' ';
|
||||
|
||||
const glyph = paint(stateGlyph(row, ctx.glyphs, ctx.opts.tick), STATE_COLOR[row.state]);
|
||||
const elapsed = row.since > 0 ? formatElapsed(ctx.opts.now - row.since) : '';
|
||||
const tokens = formatTokens(tokensOf(session));
|
||||
const rightParts = [glyph, paint(elapsed, SGR.gray)];
|
||||
if (!ctx.twoLine && tokens) rightParts.push(paint(tokens, SGR.gray));
|
||||
const right = rightParts.filter((part) => part !== '').join(' ');
|
||||
|
||||
const mode = session.mode && session.mode !== 'claude' ? session.mode : '';
|
||||
const nameWidth = Math.max(4, ctx.width - visibleWidth(marker + digit) - visibleWidth(right) - 1);
|
||||
const label = rowLabel(session);
|
||||
const name = mode ? `${label} ${paint(mode, SGR.magenta)}` : label;
|
||||
|
||||
const first = padDisplay(`${marker}${digit}${padDisplay(name, nameWidth)} ${right}`, ctx.width);
|
||||
const lines = [first];
|
||||
|
||||
if (ctx.twoLine) {
|
||||
const detail = [truncatePathLeft(session.workingDir ?? '', Math.max(0, ctx.width - 8), ctx.glyphs.ellipsis)];
|
||||
if (mode) detail.push(mode);
|
||||
if (tokens) detail.push(tokens);
|
||||
const text = detail.filter((part) => part !== '').join(` ${ctx.glyphs.separator} `);
|
||||
lines.push(padDisplay(` ${paint(text, SGR.gray)}`, ctx.width));
|
||||
}
|
||||
|
||||
return inverse ? lines.map((line) => `${SGR.inverse}${line}${SGR.reset}`) : lines;
|
||||
}
|
||||
|
||||
function renderGroupHeader(group: TuiGroup, width: number, glyphs: TuiGlyphSet, opts: TuiRenderOptions): string {
|
||||
const paint = painterFor(opts.color);
|
||||
const label = ` ${group.label} `;
|
||||
const fill = Math.max(0, width - visibleWidth(label));
|
||||
return padDisplay(`${paint(label, SGR.bold)}${paint(glyphs.rule.repeat(fill), SGR.gray)}`, width);
|
||||
}
|
||||
|
||||
export interface TuiListEntry {
|
||||
text: string;
|
||||
/** Set on the lines that belong to a session row, so the window can chase the cursor. */
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
function buildListEntries(model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): TuiListEntry[] {
|
||||
const glyphs = glyphsFor(opts.glyphs);
|
||||
const width = layout.list.width;
|
||||
const entries: TuiListEntry[] = [];
|
||||
let index = 0;
|
||||
for (const group of model.groups()) {
|
||||
if (group.rows.length === 0) continue;
|
||||
entries.push({ text: renderGroupHeader(group, width, glyphs, opts) });
|
||||
for (const row of group.rows) {
|
||||
index++;
|
||||
const ctx: RowContext = {
|
||||
width,
|
||||
index,
|
||||
selected: row.session.sessionId === model.selectedId,
|
||||
twoLine: layout.rowHeight === 2,
|
||||
glyphs,
|
||||
opts,
|
||||
};
|
||||
for (const text of renderRowLines(row, ctx)) entries.push({ text, sessionId: row.session.sessionId });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* First visible entry, scrolling the minimum needed to keep the selected row on
|
||||
* screen. Deterministic on purpose: the window is derived, never remembered, so
|
||||
* two identical models render identically.
|
||||
*/
|
||||
export function computeListWindow(
|
||||
entries: readonly TuiListEntry[],
|
||||
capacity: number,
|
||||
selectedId: string | null
|
||||
): number {
|
||||
if (capacity <= 0 || entries.length <= capacity) return 0;
|
||||
const maxStart = entries.length - capacity;
|
||||
if (!selectedId) return 0;
|
||||
const first = entries.findIndex((entry) => entry.sessionId === selectedId);
|
||||
if (first < 0) return 0;
|
||||
let last = first;
|
||||
while (last + 1 < entries.length && entries[last + 1].sessionId === selectedId) last++;
|
||||
let start = 0;
|
||||
if (last >= capacity) start = Math.min(last - capacity + 1, maxStart);
|
||||
if (first < start) start = first;
|
||||
return start;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Preview
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The pending dialog, drawn above the tail: the question, the parsed options
|
||||
* with their digits, and the keys that answer them. Red for a permission or
|
||||
* question prompt, yellow for an idle one, the same severity vocabulary the web
|
||||
* inbox uses.
|
||||
*/
|
||||
export function renderApprovalCard(
|
||||
item: ApprovalItem,
|
||||
width: number,
|
||||
glyphs: TuiGlyphSet,
|
||||
opts: TuiRenderOptions
|
||||
): string[] {
|
||||
const paint = painterFor(opts.color);
|
||||
const card = approvalCard(item);
|
||||
const color = card.tone === 'err' ? SGR.red : SGR.yellow;
|
||||
const glyph = card.tone === 'err' ? glyphs.blockedPermission : glyphs.waiting;
|
||||
const lines: string[] = [];
|
||||
const push = (text: string, style: string): void => {
|
||||
lines.push(padDisplay(paint(clipStyledLine(text, width), style), width));
|
||||
};
|
||||
|
||||
push(` ${glyph} ${card.title}`, color);
|
||||
for (const detail of card.detail) push(` ${detail}`, SGR.gray);
|
||||
for (const option of card.options) push(` ${option.n}. ${option.label}`, '');
|
||||
push(` ${card.hint}`, SGR.gray);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** The card may take half the pane at most: the tail is why the pane exists. */
|
||||
function cardCapacity(height: number): number {
|
||||
return Math.max(0, Math.floor((height - 1) / 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* `name · mode · dir · state`, with the DIRECTORY absorbing the squeeze: the
|
||||
* state word is the one fact the pane exists to confirm, so it must survive a
|
||||
* narrow preview that a full path would push off the end.
|
||||
*/
|
||||
function previewTitle(row: TuiRow, width: number, glyphs: TuiGlyphSet): string {
|
||||
const { session } = row;
|
||||
const sep = ` ${glyphs.separator} `;
|
||||
const head = ` ${rowLabel(session)}${sep}${session.mode ?? 'claude'}`;
|
||||
const tail = `${sep}${STATE_WORDS[row.state]}`;
|
||||
const dirBudget = width - visibleWidth(head) - visibleWidth(tail) - visibleWidth(sep);
|
||||
const dir = session.workingDir ? truncatePathLeft(session.workingDir, Math.max(0, dirBudget), glyphs.ellipsis) : '';
|
||||
return clipStyledLine(dir ? `${head}${sep}${dir}${tail}` : `${head}${tail}`, width);
|
||||
}
|
||||
|
||||
function buildPreviewLines(model: TuiRenderModel, rect: TuiRect, opts: TuiRenderOptions): string[] {
|
||||
const paint = painterFor(opts.color);
|
||||
const glyphs = glyphsFor(opts.glyphs);
|
||||
const lines: string[] = [];
|
||||
const selected = model.selectedId
|
||||
? (model
|
||||
.groups()
|
||||
.flatMap((group) => group.rows)
|
||||
.find((row) => row.session.sessionId === model.selectedId) ?? null)
|
||||
: null;
|
||||
|
||||
if (!selected) {
|
||||
lines.push(padDisplay(paint(' no session selected', SGR.gray), rect.width));
|
||||
} else {
|
||||
lines.push(padDisplay(paint(previewTitle(selected, rect.width, glyphs), SGR.bold), rect.width));
|
||||
}
|
||||
|
||||
const budget = cardCapacity(rect.height);
|
||||
if (selected?.approval && budget > 0) {
|
||||
for (const line of renderApprovalCard(selected.approval, rect.width, glyphs, opts).slice(0, budget)) {
|
||||
lines.push(line);
|
||||
}
|
||||
if (lines.length < rect.height) lines.push(' '.repeat(rect.width));
|
||||
}
|
||||
|
||||
const body = previewBody(model, selected, rect, opts, rect.height - lines.length);
|
||||
for (const line of body) lines.push(line);
|
||||
while (lines.length < rect.height) lines.push(' '.repeat(rect.width));
|
||||
return lines.slice(0, Math.max(0, rect.height));
|
||||
}
|
||||
|
||||
function previewBody(
|
||||
model: TuiRenderModel,
|
||||
selected: TuiRow | null,
|
||||
rect: TuiRect,
|
||||
opts: TuiRenderOptions,
|
||||
capacity: number
|
||||
): string[] {
|
||||
const paint = painterFor(opts.color);
|
||||
if (capacity <= 0) return [];
|
||||
const hint = (text: string): string[] => [padDisplay(paint(` ${text}`, SGR.gray), rect.width)];
|
||||
|
||||
if (!selected) return [];
|
||||
if (model.connection === 'degraded' || model.connection === 'down') {
|
||||
return hint('preview unavailable while the server is down');
|
||||
}
|
||||
const preview = model.preview;
|
||||
if (!preview || preview.sessionId !== selected.session.sessionId) return hint('loading preview…');
|
||||
if (preview.note) return hint(preview.note);
|
||||
if (preview.error) return hint(preview.error);
|
||||
|
||||
const trimmed = [...preview.lines];
|
||||
while (trimmed.length > 0 && trimmed[trimmed.length - 1].trim() === '') trimmed.pop();
|
||||
if (trimmed.length === 0) return hint('(no output yet)');
|
||||
// The tail carries the session's OWN colors, which is the point of the pane,
|
||||
// but under NO_COLOR they must go too.
|
||||
return trimmed
|
||||
.slice(-capacity)
|
||||
.map((line) => padDisplay(` ${clipStyledLine(opts.color ? line : stripStyles(line), rect.width - 1)}`, rect.width));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Chrome
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Sessions with a prompt waiting on a human, which is what the badge counts. */
|
||||
export function pendingApprovalCount(model: TuiRenderModel): number {
|
||||
let count = 0;
|
||||
for (const group of model.groups()) for (const row of group.rows) if (row.approval) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
function renderHeaderLine(model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): string {
|
||||
const paint = painterFor(opts.color);
|
||||
const glyphs = glyphsFor(opts.glyphs);
|
||||
const { hostname, instance, version, planUsage } = model.header;
|
||||
const facts = [
|
||||
instance ? `${hostname ?? ''}:${instance}` : (hostname ?? ''),
|
||||
version ? `v${version}` : '',
|
||||
`${model.sessionCount} session${model.sessionCount === 1 ? '' : 's'}`,
|
||||
planUsage ?? '',
|
||||
].filter((part) => part !== '');
|
||||
|
||||
const pending = pendingApprovalCount(model);
|
||||
const badge = pending > 0 ? `${paint(`${glyphs.blockedPermission} ${pending}`, SGR.red)} ` : '';
|
||||
const left = ` ${paint('codeman', SGR.bold)} ${badge}${paint(facts.join(` ${glyphs.separator} `), SGR.gray)}`;
|
||||
const right = paint('? help q quit ', SGR.gray);
|
||||
const gap = layout.cols - visibleWidth(left) - visibleWidth(right);
|
||||
if (gap < 1) return padDisplay(left, layout.cols);
|
||||
return `${left}${' '.repeat(gap)}${right}`;
|
||||
}
|
||||
|
||||
function renderBannerLine(model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): string {
|
||||
const paint = painterFor(opts.color);
|
||||
const glyphs = glyphsFor(opts.glyphs);
|
||||
const [text, color] =
|
||||
model.connection === 'degraded'
|
||||
? ['server not running: attach only', SGR.yellow]
|
||||
: model.connection === 'reconnecting'
|
||||
? ['reconnecting to the server…', SGR.yellow]
|
||||
: ['server unreachable', SGR.red];
|
||||
return padDisplay(paint(` ${glyphs.blockedPermission} ${text}`, color), layout.cols);
|
||||
}
|
||||
|
||||
const FOOTER_KEYS: Record<string, (glyphs: TuiGlyphSet) => string> = {
|
||||
list: (g) =>
|
||||
[
|
||||
`${g.updown} select`,
|
||||
`${g.enter} attach`,
|
||||
'1-9 switch',
|
||||
'y/n answer',
|
||||
'p prompt',
|
||||
'n new',
|
||||
'x kill',
|
||||
'/ search',
|
||||
'g digest',
|
||||
'? help',
|
||||
'q quit',
|
||||
].join(` ${g.separator} `),
|
||||
help: (g) => `esc ${g.separator} ? close`,
|
||||
'confirm-kill': (g) => `y kill ${g.separator} any other key cancels`,
|
||||
message: () => 'esc dismiss',
|
||||
prompt: (g) => `${g.enter} send ${g.separator} esc cancel`,
|
||||
search: (g) => `${g.updown} results ${g.separator} ${g.enter} open ${g.separator} esc close`,
|
||||
digest: (g) => `j/k ${g.separator} ${g.updown} scroll ${g.separator} esc close`,
|
||||
'new-session': (g) => `${g.updown} select ${g.separator} ${g.enter} choose ${g.separator} esc cancel`,
|
||||
};
|
||||
|
||||
/**
|
||||
* The composer's prefix. Fixed width on purpose: the terminal cursor is placed
|
||||
* by column arithmetic (`composerCursorCell`), and a prefix that changed with
|
||||
* the session name would move the cursor with it.
|
||||
*/
|
||||
export const COMPOSER_PREFIX = ' > ';
|
||||
|
||||
function renderComposerLine(prompt: TuiPromptState, layout: TuiLayout, opts: TuiRenderOptions): string {
|
||||
const paint = painterFor(opts.color);
|
||||
const window = composerWindow(prompt.composer, Math.max(1, layout.cols - visibleWidth(COMPOSER_PREFIX)));
|
||||
return padDisplay(`${paint(COMPOSER_PREFIX, SGR.cyan)}${window.text}`, layout.cols);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the terminal's own cursor belongs, or null when nothing is being typed
|
||||
* into a single-line editor. The app shows the cursor there and hides it
|
||||
* otherwise, because a blinking cursor parked in a dashboard reads as a bug.
|
||||
*/
|
||||
export function composerCursorCell(model: TuiRenderModel, layout: TuiLayout): { row: number; col: number } | null {
|
||||
if (model.mode !== 'prompt' || !model.prompt || layout.footer.height <= 0) return null;
|
||||
const prefix = visibleWidth(COMPOSER_PREFIX);
|
||||
const window = composerWindow(model.prompt.composer, Math.max(1, layout.cols - prefix));
|
||||
return { row: layout.footer.row, col: Math.min(layout.cols, prefix + 1 + window.cursorColumn) };
|
||||
}
|
||||
|
||||
function renderFooterLine(model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): string {
|
||||
const paint = painterFor(opts.color);
|
||||
const glyphs = glyphsFor(opts.glyphs);
|
||||
if (model.mode === 'prompt' && model.prompt) return renderComposerLine(model.prompt, layout, opts);
|
||||
const text = opts.footerKeys
|
||||
? opts.footerKeys.join(` ${glyphs.separator} `)
|
||||
: (FOOTER_KEYS[model.mode] ?? FOOTER_KEYS.list)(glyphs);
|
||||
return padDisplay(paint(clipStyledLine(` ${text}`, layout.cols), SGR.gray), layout.cols);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Overlays
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface OverlayContent {
|
||||
title: string;
|
||||
lines: string[];
|
||||
/**
|
||||
* Floor for the box's inner width. The search and digest panels are lists
|
||||
* people scan, so they keep a stable width instead of snapping around their
|
||||
* longest current line.
|
||||
*/
|
||||
minWidth?: number;
|
||||
}
|
||||
|
||||
function wrapText(text: string, width: number): string[] {
|
||||
if (width <= 0) return [];
|
||||
const out: string[] = [];
|
||||
let line = '';
|
||||
for (const word of text.split(/\s+/).filter((part) => part !== '')) {
|
||||
const candidate = line === '' ? word : `${line} ${word}`;
|
||||
if (visibleWidth(candidate) > width && line !== '') {
|
||||
out.push(line);
|
||||
line = word;
|
||||
} else {
|
||||
line = candidate;
|
||||
}
|
||||
}
|
||||
if (line !== '') out.push(line);
|
||||
return out.length > 0 ? out : [''];
|
||||
}
|
||||
|
||||
function helpLines(glyphs: TuiGlyphSet, custom?: ReadonlyArray<readonly [string, string]>): string[] {
|
||||
const pairs: ReadonlyArray<readonly [string, string]> = custom ?? [
|
||||
[`${glyphs.updown} / j k`, 'select'],
|
||||
[glyphs.enter, 'attach'],
|
||||
['1-9', 'jump'],
|
||||
['y / n', 'answer the pending approval'],
|
||||
['p', 'send a prompt'],
|
||||
['n', 'new session'],
|
||||
['x', 'kill (typed confirmation)'],
|
||||
['/', 'search'],
|
||||
['g', 'away digest'],
|
||||
['?', 'this help'],
|
||||
['q', 'quit'],
|
||||
];
|
||||
const keyWidth = Math.max(...pairs.map(([key]) => visibleWidth(key)));
|
||||
return pairs.map(([key, description]) => `${padDisplay(key, keyWidth)} ${description}`);
|
||||
}
|
||||
|
||||
/** Longest item list a picker overlay shows, however tall the terminal is. */
|
||||
const PICKER_MAX_ROWS = 10;
|
||||
|
||||
/**
|
||||
* A picker's lines: hint, a window of items around the cursor, then the filter
|
||||
* echo. Windowed rather than clipped, so the selected item is always visible in
|
||||
* a long case list.
|
||||
*/
|
||||
function pickerLines(picker: TuiPickerState, glyphs: TuiGlyphSet, capacity: number): string[] {
|
||||
const head: string[] = picker.hint ? [picker.hint, ''] : [];
|
||||
const tail: string[] = picker.filter === undefined ? [] : ['', `filter: ${picker.filter}_`];
|
||||
if (picker.items.length === 0) return [...head, '(nothing to choose)', ...tail];
|
||||
|
||||
const budget = Math.max(1, Math.min(PICKER_MAX_ROWS, capacity - head.length - tail.length));
|
||||
const first = Math.max(0, Math.min(picker.index - Math.floor(budget / 2), picker.items.length - budget));
|
||||
const rows = picker.items.slice(first, first + budget).map((item, i) => {
|
||||
const marker = first + i === picker.index ? glyphs.cursor : ' '.repeat(visibleWidth(glyphs.cursor));
|
||||
return `${marker} ${item.label}${item.detail ? ` ${item.detail}` : ''}`;
|
||||
});
|
||||
return [...head, ...rows, ...tail];
|
||||
}
|
||||
|
||||
/**
|
||||
* The `/` overlay: the query with a caret, one status line, then the results.
|
||||
*
|
||||
* The caret is a trailing `_` rather than the terminal's own cursor, and that is
|
||||
* why the search keymap leaves left/right to the result list: a caret that
|
||||
* cannot move is honest, an invisible one that can is not.
|
||||
*/
|
||||
function searchLines(state: TuiSearchState, glyphs: TuiGlyphSet, capacity: number): string[] {
|
||||
const head = [`${composerText(state.composer)}_`];
|
||||
if (state.note) head.push(state.note);
|
||||
head.push('');
|
||||
|
||||
const budget = Math.max(1, capacity - head.length);
|
||||
if (state.entries.length === 0) {
|
||||
return [...head, state.status === 'searching' ? 'searching…' : '(type to search sessions, events and files)'];
|
||||
}
|
||||
const first = Math.max(0, Math.min(state.index - Math.floor(budget / 2), state.entries.length - budget));
|
||||
const rows = state.entries.slice(first, first + budget).map((entry, i) => {
|
||||
if (entry.kind === 'header') return entry.text;
|
||||
const marker = first + i === state.index ? glyphs.cursor : ' '.repeat(visibleWidth(glyphs.cursor));
|
||||
return `${marker} ${entry.text}${entry.detail ? ` ${entry.detail}` : ''}`;
|
||||
});
|
||||
return [...head, ...rows];
|
||||
}
|
||||
|
||||
/** Lines an overlay box can show inside its border, given the body's height. */
|
||||
function overlayCapacity(height: number): number {
|
||||
return Math.max(1, height - 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many digest lines fit. Exported because the app scrolls by pages and must
|
||||
* not scroll the last page into empty space, which needs this exact number.
|
||||
*/
|
||||
export function digestCapacity(layout: TuiLayout): number {
|
||||
return overlayCapacity(layout.body.height);
|
||||
}
|
||||
|
||||
function digestLines(state: TuiDigestState, capacity: number): string[] {
|
||||
const offset = Math.min(Math.max(0, state.offset), Math.max(0, state.lines.length - 1));
|
||||
return state.lines.slice(offset, offset + capacity);
|
||||
}
|
||||
|
||||
function overlayContent(
|
||||
model: TuiRenderModel,
|
||||
opts: TuiRenderOptions,
|
||||
width: number,
|
||||
height: number
|
||||
): OverlayContent | null {
|
||||
const glyphs = glyphsFor(opts.glyphs);
|
||||
const panelWidth = Math.max(20, Math.min(width - 8, 72));
|
||||
switch (model.mode) {
|
||||
case 'help':
|
||||
return { title: 'Keys', lines: helpLines(glyphs, opts.helpKeys) };
|
||||
case 'search': {
|
||||
if (!model.search) return null;
|
||||
return {
|
||||
title: 'Search',
|
||||
lines: searchLines(model.search, glyphs, overlayCapacity(height)),
|
||||
minWidth: panelWidth,
|
||||
};
|
||||
}
|
||||
case 'digest': {
|
||||
if (!model.digest) return null;
|
||||
return {
|
||||
title: model.digest.title,
|
||||
lines: digestLines(model.digest, overlayCapacity(height)),
|
||||
minWidth: panelWidth,
|
||||
};
|
||||
}
|
||||
case 'new-session': {
|
||||
if (!model.picker) return null;
|
||||
return { title: model.picker.title, lines: pickerLines(model.picker, glyphs, Math.max(1, height - 2)) };
|
||||
}
|
||||
case 'confirm-kill': {
|
||||
if (!model.confirm) return null;
|
||||
return {
|
||||
title: 'Kill session',
|
||||
lines: [`Kill ${model.confirm.name}?`, '', 'press y to kill, any other key cancels'],
|
||||
};
|
||||
}
|
||||
case 'message':
|
||||
if (!model.message) return null;
|
||||
return {
|
||||
title: model.message.tone === 'err' ? 'Error' : model.message.tone === 'warn' ? 'Warning' : 'Notice',
|
||||
lines: wrapText(model.message.text, Math.max(8, width - 8)),
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Paint an overlay box over the body, centered, replacing whole terminal rows. */
|
||||
function applyOverlay(lines: string[], model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): void {
|
||||
const body = layout.body;
|
||||
if (body.height < 3 || body.width < 12) return;
|
||||
const content = overlayContent(model, opts, body.width, body.height);
|
||||
if (!content) return;
|
||||
|
||||
const paint = painterFor(opts.color);
|
||||
const glyphs = glyphsFor(opts.glyphs);
|
||||
const maxInner = body.width - 4;
|
||||
const visible = content.lines.slice(0, Math.max(1, body.height - 2));
|
||||
const inner = Math.min(
|
||||
maxInner,
|
||||
Math.max(content.minWidth ?? 0, visibleWidth(content.title) + 2, ...visible.map((line) => visibleWidth(line)))
|
||||
);
|
||||
const boxWidth = inner + 4;
|
||||
const boxHeight = visible.length + 2;
|
||||
const left = body.col + Math.max(0, Math.floor((body.width - boxWidth) / 2));
|
||||
const top = body.row + Math.max(0, Math.floor((body.height - boxHeight) / 2));
|
||||
|
||||
const titleText = ` ${content.title} `;
|
||||
const titleFill = Math.max(0, inner + 2 - visibleWidth(titleText));
|
||||
const boxLines = [
|
||||
`${glyphs.boxTopLeft}${titleText}${glyphs.boxHorizontal.repeat(titleFill)}${glyphs.boxTopRight}`,
|
||||
...visible.map((line) => `${glyphs.boxVertical} ${padDisplay(line, inner)} ${glyphs.boxVertical}`),
|
||||
`${glyphs.boxBottomLeft}${glyphs.boxHorizontal.repeat(inner + 2)}${glyphs.boxBottomRight}`,
|
||||
];
|
||||
|
||||
for (let i = 0; i < boxLines.length; i++) {
|
||||
const row = top + i - 1;
|
||||
if (row < 0 || row >= lines.length) continue;
|
||||
lines[row] = `${' '.repeat(left - 1)}${paint(boxLines[i], SGR.cyan)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Frame
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function writeBody(lines: string[], model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): void {
|
||||
const { list, preview, divider } = layout;
|
||||
if (list.height <= 0) return;
|
||||
const paint = painterFor(opts.color);
|
||||
const glyphs = glyphsFor(opts.glyphs);
|
||||
|
||||
const entries = buildListEntries(model, layout, opts);
|
||||
if (entries.length === 0) {
|
||||
const hint = paint('No sessions. n to start one, q to quit.', SGR.gray);
|
||||
const row = list.row + Math.floor((list.height - 1) / 2);
|
||||
lines[row - 1] = centered(hint, layout.cols);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = computeListWindow(entries, list.height, model.selectedId);
|
||||
const previewLines = preview ? buildPreviewLines(model, preview, opts) : [];
|
||||
|
||||
for (let i = 0; i < list.height; i++) {
|
||||
const left = entries[start + i]?.text ?? ' '.repeat(list.width);
|
||||
if (!preview || !divider) {
|
||||
lines[list.row - 1 + i] = left;
|
||||
continue;
|
||||
}
|
||||
const right = previewLines[i] ?? ' '.repeat(preview.width);
|
||||
lines[list.row - 1 + i] = `${left}${paint(glyphs.divider, SGR.gray)}${right}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole frame as one string: absolute cursor addressing per line, each line
|
||||
* closed with an erase-to-end so a shorter line cannot leave the previous
|
||||
* frame's tail behind.
|
||||
*/
|
||||
export function renderFrame(model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): string {
|
||||
const lines: string[] = new Array<string>(layout.rows).fill('');
|
||||
lines[0] = renderHeaderLine(model, layout, opts);
|
||||
if (layout.banner) lines[layout.banner.row - 1] = renderBannerLine(model, layout, opts);
|
||||
writeBody(lines, model, layout, opts);
|
||||
if (layout.footer.height > 0) lines[layout.footer.row - 1] = renderFooterLine(model, layout, opts);
|
||||
applyOverlay(lines, model, layout, opts);
|
||||
|
||||
let frame = '';
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
frame += `\x1b[${i + 1};1H${clipStyledLine(lines[i], layout.cols)}\x1b[K`;
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* @fileoverview Pure SSE wire parsing, event classification and reconnect math.
|
||||
*
|
||||
* Node has no `EventSource`, so the TUI reads `GET /api/events` as a raw stream
|
||||
* and decodes the wire format here. Everything in this module is pure: bytes
|
||||
* (as decoded strings) in, frames out. The socket, the timers and the backoff
|
||||
* loop live in `tui-client.ts`.
|
||||
*
|
||||
* Three wire details this parser exists to get right:
|
||||
*
|
||||
* 1. **Frames split across chunk boundaries.** A TCP read can end anywhere,
|
||||
* including between the `\r` and the `\n` of a CRLF, so a lone trailing
|
||||
* `\r` is held back rather than treated as a line end.
|
||||
* 2. **Comments are not frames.** The server appends a `:pppp…` padding line
|
||||
* after a frame while a Cloudflare tunnel is up (it flushes the proxy
|
||||
* buffer) and that line carries no blank line after it. Dispatch happens on
|
||||
* a blank line and on nothing else, so padding cannot split a frame.
|
||||
* 3. **The keepalive is a NAMED event** (`sse:heartbeat`), because an SSE
|
||||
* comment is invisible to a browser `EventSource` by spec. We treat ANY
|
||||
* inbound bytes as liveness, comments included, which is why comments need
|
||||
* no representation in the returned frames.
|
||||
*
|
||||
* @module tui/tui-sse
|
||||
*/
|
||||
|
||||
import {
|
||||
ApprovalPending,
|
||||
ApprovalResolved,
|
||||
ApprovalUpdated,
|
||||
Heartbeat,
|
||||
Init,
|
||||
MuxCreated,
|
||||
MuxDied,
|
||||
MuxKilled,
|
||||
RemoteSessionDropped,
|
||||
RemoteSessionReconnected,
|
||||
SessionCliInfo,
|
||||
SessionCompletion,
|
||||
SessionCreated,
|
||||
SessionDeleted,
|
||||
SessionError,
|
||||
SessionExit,
|
||||
SessionIdle,
|
||||
SessionInteractive,
|
||||
SessionPinned,
|
||||
SessionRunning,
|
||||
SessionStatusTelemetry,
|
||||
SessionUpdated,
|
||||
SessionWorking,
|
||||
} from '../web/sse-events.js';
|
||||
|
||||
/** One dispatched SSE frame. `event` defaults to `message` per the spec. */
|
||||
export interface SseFrame {
|
||||
event: string;
|
||||
data: string;
|
||||
id?: string;
|
||||
retry?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ceiling on the unterminated tail the parser will hold. The `init` frame
|
||||
* carries the whole light state and is legitimately large, so this is not a
|
||||
* frame-size limit but a guard against a non-SSE endpoint streaming something
|
||||
* with no line terminators at all.
|
||||
*/
|
||||
export const MAX_PENDING_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/** Incremental decoder. One instance per connection; `reset()` on reconnect. */
|
||||
export class SseFrameParser {
|
||||
private buffer = '';
|
||||
private eventName = '';
|
||||
private dataLines: string[] = [];
|
||||
private lastId: string | undefined;
|
||||
private retry: number | undefined;
|
||||
|
||||
/** Decode one chunk, returning every frame it completed (possibly none). */
|
||||
feed(chunk: string): SseFrame[] {
|
||||
this.buffer += chunk;
|
||||
const frames: SseFrame[] = [];
|
||||
let start = 0;
|
||||
|
||||
for (let i = 0; i < this.buffer.length; i++) {
|
||||
const ch = this.buffer[i];
|
||||
if (ch !== '\n' && ch !== '\r') continue;
|
||||
// A trailing CR may be the first half of a CRLF the next chunk finishes.
|
||||
if (ch === '\r' && i === this.buffer.length - 1) break;
|
||||
const line = this.buffer.slice(start, i);
|
||||
if (ch === '\r' && this.buffer[i + 1] === '\n') i++;
|
||||
start = i + 1;
|
||||
const frame = this.consumeLine(line);
|
||||
if (frame) frames.push(frame);
|
||||
}
|
||||
|
||||
this.buffer = this.buffer.slice(start);
|
||||
if (this.buffer.length > MAX_PENDING_BYTES) this.reset();
|
||||
return frames;
|
||||
}
|
||||
|
||||
/** Drop every partial frame. Called when a connection is torn down. */
|
||||
reset(): void {
|
||||
this.buffer = '';
|
||||
this.eventName = '';
|
||||
this.dataLines = [];
|
||||
this.lastId = undefined;
|
||||
this.retry = undefined;
|
||||
}
|
||||
|
||||
private consumeLine(line: string): SseFrame | null {
|
||||
if (line === '') return this.dispatch();
|
||||
if (line.startsWith(':')) return null;
|
||||
|
||||
const colon = line.indexOf(':');
|
||||
const field = colon === -1 ? line : line.slice(0, colon);
|
||||
let value = colon === -1 ? '' : line.slice(colon + 1);
|
||||
if (value.startsWith(' ')) value = value.slice(1);
|
||||
|
||||
switch (field) {
|
||||
case 'event':
|
||||
this.eventName = value;
|
||||
break;
|
||||
case 'data':
|
||||
this.dataLines.push(value);
|
||||
break;
|
||||
case 'id':
|
||||
this.lastId = value;
|
||||
break;
|
||||
case 'retry': {
|
||||
const ms = Number.parseInt(value, 10);
|
||||
if (Number.isSafeInteger(ms) && ms >= 0) this.retry = ms;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A blank line ends a frame. Per the spec an empty data buffer dispatches
|
||||
* nothing (it still clears the event name), which is what makes a bare
|
||||
* `event:` line or a stray blank line harmless.
|
||||
*/
|
||||
private dispatch(): SseFrame | null {
|
||||
if (this.dataLines.length === 0) {
|
||||
this.eventName = '';
|
||||
return null;
|
||||
}
|
||||
const frame: SseFrame = {
|
||||
event: this.eventName || 'message',
|
||||
data: this.dataLines.join('\n'),
|
||||
};
|
||||
if (this.lastId !== undefined) frame.id = this.lastId;
|
||||
if (this.retry !== undefined) frame.retry = this.retry;
|
||||
this.eventName = '';
|
||||
this.dataLines = [];
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
|
||||
/** What the app layer should do with a frame. */
|
||||
export type SseEventClass = 'init' | 'heartbeat' | 'resync' | 'approval' | 'plan-usage' | 'ignore';
|
||||
|
||||
/**
|
||||
* Events that change WHICH sessions exist or WHAT state they are in.
|
||||
*
|
||||
* The TUI never patches a single row from a payload: it re-fetches the unified
|
||||
* list, which is the only source that also carries history rows, so this set
|
||||
* only has to answer "is a refetch worth it". `session:terminal` is
|
||||
* deliberately absent (it is the bulk of the stream and the preview pane pulls
|
||||
* its own tail), as are the ralph/respawn/subagent/orchestrator families, which
|
||||
* change nothing the dashboard draws.
|
||||
*/
|
||||
const RESYNC_EVENTS: ReadonlySet<string> = new Set<string>([
|
||||
SessionCreated,
|
||||
SessionUpdated,
|
||||
SessionDeleted,
|
||||
SessionExit,
|
||||
SessionError,
|
||||
SessionIdle,
|
||||
SessionWorking,
|
||||
SessionCompletion,
|
||||
SessionInteractive,
|
||||
SessionRunning,
|
||||
SessionPinned,
|
||||
SessionCliInfo,
|
||||
MuxCreated,
|
||||
MuxKilled,
|
||||
MuxDied,
|
||||
RemoteSessionDropped,
|
||||
RemoteSessionReconnected,
|
||||
]);
|
||||
|
||||
const APPROVAL_EVENTS: ReadonlySet<string> = new Set<string>([ApprovalPending, ApprovalUpdated, ApprovalResolved]);
|
||||
|
||||
/** Which approval event this is, or null when the name is not one. */
|
||||
export function approvalEventKind(name: string): 'pending' | 'updated' | 'resolved' | null {
|
||||
if (name === ApprovalPending) return 'pending';
|
||||
if (name === ApprovalUpdated) return 'updated';
|
||||
if (name === ApprovalResolved) return 'resolved';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Route one event name. Unknown names are ignored, never a resync. */
|
||||
export function classifySseEvent(name: string): SseEventClass {
|
||||
if (name === Init) return 'init';
|
||||
if (name === Heartbeat) return 'heartbeat';
|
||||
if (APPROVAL_EVENTS.has(name)) return 'approval';
|
||||
if (name === SessionStatusTelemetry) return 'plan-usage';
|
||||
if (RESYNC_EVENTS.has(name)) return 'resync';
|
||||
return 'ignore';
|
||||
}
|
||||
|
||||
/**
|
||||
* Silence that means the stream is dead even though the socket never errored.
|
||||
* The server heartbeats every 15s, so three missed beats is the signal.
|
||||
*/
|
||||
export const SSE_STALE_TIMEOUT_MS = 45_000;
|
||||
|
||||
/** Reconnect delay ceiling. A local server is back in milliseconds, not minutes. */
|
||||
export const SSE_MAX_BACKOFF_MS = 15_000;
|
||||
|
||||
/** First reconnect delay; doubles per consecutive failure up to the ceiling. */
|
||||
export const SSE_BASE_BACKOFF_MS = 500;
|
||||
|
||||
/**
|
||||
* Delay before reconnect attempt `attempt` (1-based). Deterministic, with no
|
||||
* jitter on purpose: one client talks to one loopback server, so there is no
|
||||
* herd to spread out and a reproducible delay is testable.
|
||||
*/
|
||||
export function sseBackoffDelay(attempt: number, base = SSE_BASE_BACKOFF_MS, max = SSE_MAX_BACKOFF_MS): number {
|
||||
const step = Math.max(1, Math.trunc(attempt));
|
||||
const exponent = Math.min(step - 1, 30);
|
||||
return Math.min(max, base * 2 ** exponent);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* @fileoverview Shared types for the `codeman tui` pure core.
|
||||
*
|
||||
* The TUI is a client of the server, never a second brain: its rows are the
|
||||
* rows `GET /api/sessions/unified` already returns (`UnifiedSessionItem`) and
|
||||
* its blocked states are the items `GET /api/approvals` already parsed
|
||||
* (`ApprovalItem`). Both are imported as TYPES only, so nothing here pulls the
|
||||
* server, node-pty or the utils barrel into a CLI process.
|
||||
*
|
||||
* Everything in `src/tui/*` except `tui-app.ts` / `tui-client.ts` is pure:
|
||||
* deterministic outputs from inputs, no `process.*`, no timers, no IO.
|
||||
*
|
||||
* @module tui/tui-types
|
||||
*/
|
||||
|
||||
import type { UnifiedSessionItem } from '../services/unified-session-service.js';
|
||||
import type { ApprovalItem } from '../web/approval-inbox.js';
|
||||
import type { TuiComposerState } from './tui-composer.js';
|
||||
|
||||
/**
|
||||
* A unified-list row plus the few live-only extras the dashboard shows.
|
||||
*
|
||||
* The unified list is the spine (it is the only source that carries history
|
||||
* rows), but it has no token counters and no turn-start stamp, so the client
|
||||
* merges those from the live session payload (`GET /api/sessions` /
|
||||
* `session_updated` SSE) when a row is live. History rows simply lack them.
|
||||
*/
|
||||
export interface TuiSessionRow extends UnifiedSessionItem {
|
||||
/**
|
||||
* Wall-clock ms of the pane's last Enter (`SessionState.lastSubmitAt`). The
|
||||
* only usable "working since" anchor: a working pane repaints about once a
|
||||
* second, so its `lastActivityAt` is always "now".
|
||||
*/
|
||||
lastSubmitAt?: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
/**
|
||||
* tmux session name to attach to (`codeman-<first 8 of the id>`).
|
||||
*
|
||||
* The unified list does not carry it (no server view merges the mux name into
|
||||
* a row), so the app layer fills it in from the local tmux enumeration, which
|
||||
* is also the only thing that proves the pane really exists. A row without one
|
||||
* cannot be attached: it is either history or a direct-PTY session.
|
||||
*/
|
||||
muxName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Row state, in the web UI's vocabulary so both surfaces read the same.
|
||||
*
|
||||
* There is deliberately no `error` member: an errored session is something a
|
||||
* human has to look at, so it classifies as `waiting` and lands in NEEDS YOU
|
||||
* rather than growing a fifth color nobody designed.
|
||||
*/
|
||||
export type TuiSessionState = 'blocked-question' | 'blocked-permission' | 'waiting' | 'working' | 'idle' | 'recent';
|
||||
|
||||
/** The four display groups, in display order. */
|
||||
export type TuiGroupKey = 'needs-you' | 'working' | 'idle' | 'recent';
|
||||
|
||||
/** A classified session: what the cursor moves over and the renderer paints. */
|
||||
export interface TuiRow {
|
||||
session: TuiSessionRow;
|
||||
state: TuiSessionState;
|
||||
group: TuiGroupKey;
|
||||
/** The pending prompt that blocks this session, when it has one. */
|
||||
approval?: ApprovalItem;
|
||||
/** Epoch ms the session entered `state`; the intra-group sort key. 0 when unknown. */
|
||||
since: number;
|
||||
}
|
||||
|
||||
export interface TuiGroup {
|
||||
key: TuiGroupKey;
|
||||
label: string;
|
||||
rows: TuiRow[];
|
||||
}
|
||||
|
||||
/** How the client currently sees the server. */
|
||||
export type TuiConnectionStatus = 'connected' | 'reconnecting' | 'degraded' | 'down';
|
||||
|
||||
/** Which overlay (if any) owns the keyboard. */
|
||||
export type TuiUiMode = 'list' | 'help' | 'confirm-kill' | 'prompt' | 'search' | 'digest' | 'message' | 'new-session';
|
||||
|
||||
/**
|
||||
* Glyph capability tier. Detection is env-driven and therefore lives in a tiny
|
||||
* function the app layer calls (`detectGlyphTier`); the renderer only ever
|
||||
* takes the resolved tier as an input.
|
||||
*/
|
||||
export type TuiGlyphTier = 'nerd' | 'unicode' | 'ascii';
|
||||
|
||||
/** Header facts, all optional: the header degrades to just the product name. */
|
||||
export interface TuiHeaderInfo {
|
||||
hostname?: string;
|
||||
instance?: string;
|
||||
version?: string;
|
||||
/** Plan-usage chip text, e.g. `5h 32% · wk 61%`. */
|
||||
planUsage?: string;
|
||||
}
|
||||
|
||||
/** The selected session's terminal tail, already run through `toDisplayLines()`. */
|
||||
export interface TuiPreview {
|
||||
sessionId: string;
|
||||
/** Display lines, oldest first. */
|
||||
lines: string[];
|
||||
/** Set instead of lines when the tail could not be fetched. */
|
||||
error?: string;
|
||||
/**
|
||||
* Set instead of lines when there is nothing to fetch (a history row has no
|
||||
* live buffer). Distinct from `error`: nothing failed, so it must not read
|
||||
* like something did.
|
||||
*/
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface TuiMessage {
|
||||
text: string;
|
||||
tone: 'info' | 'warn' | 'err';
|
||||
}
|
||||
|
||||
/** Typed-confirmation state for `x` (kill): the user retypes the session name. */
|
||||
export interface TuiConfirmState {
|
||||
sessionId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TuiPickerItem {
|
||||
/** What choosing this item means to the caller; never shown. */
|
||||
id: string;
|
||||
label: string;
|
||||
/** Second column, dimmed (a case path, a mode description). */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-column chooser drawn as an overlay (the case and mode pickers behind
|
||||
* `n`). Items are already filtered: the app owns the unfiltered list, the
|
||||
* renderer only paints what it is given.
|
||||
*/
|
||||
export interface TuiPickerState {
|
||||
title: string;
|
||||
items: TuiPickerItem[];
|
||||
/** Index into `items`; -1 when the list is empty. */
|
||||
index: number;
|
||||
/** Current filter text, when the picker filters as you type. */
|
||||
filter?: string;
|
||||
/** One line above the list: what is being chosen, or why the list is empty. */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/** The `p` composer: one line aimed at one session. */
|
||||
export interface TuiPromptState {
|
||||
sessionId: string;
|
||||
/** What the session is called on screen, for the footer prefix. */
|
||||
label: string;
|
||||
composer: TuiComposerState;
|
||||
}
|
||||
|
||||
/**
|
||||
* One line of the `/` overlay. Group headers are chrome (the API returns typed
|
||||
* groups), so only `result` rows are selectable.
|
||||
*/
|
||||
export interface TuiSearchEntry {
|
||||
kind: 'header' | 'result';
|
||||
text: string;
|
||||
detail?: string;
|
||||
sessionId?: string;
|
||||
/** The row can hand the dashboard a session that is open right now. */
|
||||
live?: boolean;
|
||||
}
|
||||
|
||||
export interface TuiSearchState {
|
||||
composer: TuiComposerState;
|
||||
/** The query `entries` answer. Lags the composer while a search is in flight. */
|
||||
query: string;
|
||||
entries: TuiSearchEntry[];
|
||||
/** Index into `entries`, always a `result` row; -1 when none is selectable. */
|
||||
index: number;
|
||||
status: 'idle' | 'searching' | 'done' | 'error';
|
||||
/** One line under the query: what happened, or why there is nothing. */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** The `g` overlay: pre-formatted lines plus where the window starts. */
|
||||
export interface TuiDigestState {
|
||||
title: string;
|
||||
lines: string[];
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What `renderFrame()` reads. The store implements it; a test can hand-build
|
||||
* one, which is what keeps the renderer testable without the model.
|
||||
*/
|
||||
export interface TuiRenderModel {
|
||||
groups(): TuiGroup[];
|
||||
readonly selectedId: string | null;
|
||||
readonly connection: TuiConnectionStatus;
|
||||
readonly mode: TuiUiMode;
|
||||
readonly header: TuiHeaderInfo;
|
||||
readonly preview: TuiPreview | null;
|
||||
readonly message: TuiMessage | null;
|
||||
readonly confirm: TuiConfirmState | null;
|
||||
/** Optional so a test can hand-build a model without one. */
|
||||
readonly picker?: TuiPickerState | null;
|
||||
readonly prompt?: TuiPromptState | null;
|
||||
readonly search?: TuiSearchState | null;
|
||||
readonly digest?: TuiDigestState | null;
|
||||
/** Live sessions only (RECENT rows are history, not sessions you have open). */
|
||||
readonly sessionCount: number;
|
||||
}
|
||||
@@ -500,6 +500,14 @@ export interface SessionState {
|
||||
color?: SessionColor;
|
||||
/** Flicker filter enabled (buffers output after screen clears) */
|
||||
flickerFilterEnabled?: boolean;
|
||||
/**
|
||||
* True while the CLI in the pane has a mouse-tracking DECSET on, as observed
|
||||
* by the server on its way out of the stream (those sequences are stripped for
|
||||
* claude/codex/gemini, so the browser can never see them itself). The browser
|
||||
* hand-encodes a click report ONLY when this is true; without it, every click
|
||||
* sent mouse reports to a CLI that never asked for them.
|
||||
*/
|
||||
cliMouseTracking?: boolean;
|
||||
/** Claude Code CLI version (parsed from terminal, e.g., "2.1.27") */
|
||||
cliVersion?: string;
|
||||
/** Claude model in use (parsed from terminal, e.g., "Opus 4.5") */
|
||||
|
||||
@@ -98,3 +98,52 @@ export interface UpdateCheckResult {
|
||||
source: 'github-api' | 'git-ls-remote' | 'none';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Role of a remote in the repository-status view.
|
||||
* - `tracking`: the current branch's `@{upstream}` remote (where `git pull` goes).
|
||||
* - `upstream`: the canonical project (a remote named `origin`/`upstream` that is
|
||||
* not the tracking remote).
|
||||
* - `other`: anything else explicitly requested via `CODEMAN_UPDATE_REMOTES`.
|
||||
*/
|
||||
export type RepoRemoteRole = 'tracking' | 'upstream' | 'other';
|
||||
|
||||
/** A single incoming commit — present on the remote ref but not in local HEAD. */
|
||||
export interface RepoIncomingCommit {
|
||||
/** Abbreviated SHA. */
|
||||
sha: string;
|
||||
/** Commit subject (first line). */
|
||||
subject: string;
|
||||
}
|
||||
|
||||
/** Ahead/behind + incoming summary for local HEAD vs one remote's compare ref. */
|
||||
export interface RepoRemoteStatus {
|
||||
/** Remote name, e.g. `origin`, `bitbucket`. */
|
||||
name: string;
|
||||
/** Remote URL (best-effort; empty if unresolved). */
|
||||
url: string;
|
||||
role: RepoRemoteRole;
|
||||
/** Ref HEAD is compared against, e.g. `origin/master`, `bitbucket/local`. */
|
||||
compareRef: string;
|
||||
/** Commits in local HEAD not on the remote ref (local-only / unpushed). */
|
||||
ahead: number;
|
||||
/** Commits on the remote ref not in local HEAD (incoming). */
|
||||
behind: number;
|
||||
/** Up to N most recent incoming commits (newest first). */
|
||||
incoming: RepoIncomingCommit[];
|
||||
/** Set when this remote could not be fetched/compared. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Result of the repository-status check across the configured remotes. */
|
||||
export interface RepositoryStatusResult {
|
||||
/** epoch ms of the check. */
|
||||
checkedAt: number;
|
||||
/** False when this is not a git install (then `remotes` is empty + `error` set). */
|
||||
isGit: boolean;
|
||||
/** Current running version, for display. */
|
||||
currentVersion: string;
|
||||
remotes: RepoRemoteStatus[];
|
||||
/** Top-level error (e.g. not a git install, or no remotes resolved). */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -7,22 +7,37 @@
|
||||
* @module utils/antigravity-cli-resolver
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import {
|
||||
createCliExecutableResolver,
|
||||
formatCliNotFoundMessage,
|
||||
type CliResolverHost,
|
||||
} from './cli-executable-resolver.js';
|
||||
|
||||
/** Common directories where the Antigravity CLI binary may be installed */
|
||||
const ANTIGRAVITY_SEARCH_DIRS = [
|
||||
join(homedir(), '.local', 'bin'),
|
||||
join(homedir(), '.antigravity', 'bin'),
|
||||
'/usr/local/bin',
|
||||
join(homedir(), '.bun', 'bin'),
|
||||
join(homedir(), '.npm-global', 'bin'),
|
||||
join(homedir(), 'bin'),
|
||||
];
|
||||
|
||||
/** Cached directory containing the agy binary (empty string = searched but not found) */
|
||||
let _antigravityDir: string | null = null;
|
||||
const ANTIGRAVITY_NOT_FOUND =
|
||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash';
|
||||
|
||||
function createAntigravityResolver(host?: CliResolverHost, now?: () => number) {
|
||||
return createCliExecutableResolver({ binary: 'agy', searchDirs: ANTIGRAVITY_SEARCH_DIRS, now }, host);
|
||||
}
|
||||
|
||||
/** Creates an isolated Antigravity wrapper around an injected resolver host and clock. */
|
||||
export function createAntigravityResolverForTest(host: CliResolverHost, now?: () => number) {
|
||||
return createAntigravityResolver(host, now);
|
||||
}
|
||||
|
||||
const antigravityResolver = createAntigravityResolver();
|
||||
|
||||
/**
|
||||
* Finds the directory containing the `agy` binary.
|
||||
@@ -31,30 +46,7 @@ let _antigravityDir: string | null = null;
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolveAntigravityDir(): string | null {
|
||||
if (_antigravityDir !== null) return _antigravityDir || null;
|
||||
|
||||
try {
|
||||
const result = execSync('which agy', {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (result && existsSync(result)) {
|
||||
_antigravityDir = dirname(result);
|
||||
return _antigravityDir;
|
||||
}
|
||||
} catch {
|
||||
// agy not in PATH, will check common locations
|
||||
}
|
||||
|
||||
for (const dir of ANTIGRAVITY_SEARCH_DIRS) {
|
||||
if (existsSync(join(dir, 'agy'))) {
|
||||
_antigravityDir = dir;
|
||||
return _antigravityDir;
|
||||
}
|
||||
}
|
||||
|
||||
_antigravityDir = '';
|
||||
return null;
|
||||
return antigravityResolver.resolve()?.directory ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,3 +55,7 @@ export function resolveAntigravityDir(): string | null {
|
||||
export function isAntigravityAvailable(): boolean {
|
||||
return resolveAntigravityDir() !== null;
|
||||
}
|
||||
|
||||
export function getAntigravityNotFoundMessage(): string {
|
||||
return formatCliNotFoundMessage(ANTIGRAVITY_NOT_FOUND, antigravityResolver.diagnostics());
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
* @module utils/claude-cli-resolver
|
||||
*/
|
||||
|
||||
import { execSync, execFileSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { delimiter, dirname, join } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { delimiter, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
|
||||
|
||||
/** Common directories where the Claude CLI binary may be installed */
|
||||
const CLAUDE_SEARCH_DIRS = [
|
||||
@@ -23,8 +23,8 @@ const CLAUDE_SEARCH_DIRS = [
|
||||
join(homedir(), 'bin'),
|
||||
];
|
||||
|
||||
/** Cached directory containing the claude binary (empty string = searched but not found) */
|
||||
let _claudeDir: string | null = null;
|
||||
const claudeResolver = createCliExecutableResolver({ binary: 'claude', searchDirs: CLAUDE_SEARCH_DIRS });
|
||||
const CLAUDE_NOT_FOUND = 'Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash';
|
||||
|
||||
/**
|
||||
* Returns true if the Claude CLI binary can be located (via `which` or one of
|
||||
@@ -43,29 +43,11 @@ export function isClaudeAvailable(): boolean {
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function findClaudeDir(): string | null {
|
||||
if (_claudeDir !== null) return _claudeDir || null;
|
||||
return claudeResolver.resolve()?.directory ?? null;
|
||||
}
|
||||
|
||||
// Try `which` first (respects current PATH)
|
||||
try {
|
||||
const result = execSync('which claude', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim();
|
||||
if (result && existsSync(result)) {
|
||||
_claudeDir = dirname(result);
|
||||
return _claudeDir;
|
||||
}
|
||||
} catch {
|
||||
// Claude not in PATH, will check common locations
|
||||
}
|
||||
|
||||
// Fallback: check common installation directories
|
||||
for (const dir of CLAUDE_SEARCH_DIRS) {
|
||||
if (existsSync(join(dir, 'claude'))) {
|
||||
_claudeDir = dir;
|
||||
return _claudeDir;
|
||||
}
|
||||
}
|
||||
|
||||
_claudeDir = ''; // mark as searched, not found
|
||||
return null;
|
||||
export function getClaudeNotFoundMessage(): string {
|
||||
return formatCliNotFoundMessage(CLAUDE_NOT_FOUND, claudeResolver.diagnostics());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,7 +81,9 @@ export function getAugmentedPath(): string {
|
||||
const currentPath = process.env.PATH || '';
|
||||
const claudeDir = findClaudeDir();
|
||||
|
||||
if (claudeDir && !currentPath.split(delimiter).includes(claudeDir)) {
|
||||
if (!claudeDir) return currentPath;
|
||||
|
||||
if (!currentPath.split(delimiter).includes(claudeDir)) {
|
||||
_augmentedPath = `${claudeDir}${delimiter}${currentPath}`;
|
||||
return _augmentedPath;
|
||||
}
|
||||
@@ -193,6 +177,9 @@ function probeClaudeCliVersion(): string | null {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
env: { ...process.env, PATH: getAugmentedPath() },
|
||||
// execFileSync's timeout only SENDS the signal and then keeps waiting; a
|
||||
// child that ignores SIGTERM would block the server thread permanently.
|
||||
killSignal: 'SIGKILL',
|
||||
});
|
||||
const match = out.match(/(\d+\.\d+\.\d+)/);
|
||||
return match ? match[1] : null;
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* @fileoverview Shared CLI executable resolution for the per-CLI resolvers.
|
||||
*
|
||||
* One lookup chain behind all six *-cli-resolver modules (claude, opencode,
|
||||
* codex, gemini, antigravity, pi): the server process PATH first, then the
|
||||
* CLI's common install directories in order, then — last, because it is the
|
||||
* only step that spawns anything — an interactive login shell, which is what
|
||||
* finds nvm/Homebrew/user-npm installs when Codeman runs as a systemd/launchd
|
||||
* service with a minimal PATH (launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin`).
|
||||
*
|
||||
* Caching is asymmetric, same shape as `resolveClaudeCliVersion` in
|
||||
* claude-cli-resolver.ts: a successful resolution is cached for the process
|
||||
* lifetime, a MISS is negative-cached and retried only after a doubling backoff
|
||||
* (`cliResolveRetryDelayMs`). The callers are request-facing (the per-CLI
|
||||
* status endpoints in system-routes.ts, the availability gates in
|
||||
* session-routes.ts, and tmux-manager's spawn path), and the login-shell probe
|
||||
* is a SYNCHRONOUS spawn bounded by `EXEC_TIMEOUT_MS` — without the negative
|
||||
* cache, a missing CLI re-ran the whole chain and stalled the event loop for up
|
||||
* to 5s on every request, forever.
|
||||
*
|
||||
* Test hermeticity: under vitest (`process.env.VITEST`) the production host
|
||||
* short-circuits — IO primitives that were not injected become inert stubs, so
|
||||
* a suite can never scan the machine's PATH or spawn login shells (the same
|
||||
* rule as `IS_TEST_MODE` in tmux-manager and the VITEST gate in
|
||||
* `getClaudeCliVersion`). Tests opt back in through the injection hooks
|
||||
* (`runCommand`/`isExecutableFile` fakes do no real IO by construction) or, for
|
||||
* fixtures that need the real filesystem predicate against their own temp
|
||||
* files, via `allowRealIoUnderVitest`.
|
||||
*
|
||||
* @module utils/cli-executable-resolver
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { accessSync, constants, statSync } from 'node:fs';
|
||||
import { basename, delimiter, dirname, isAbsolute, join } from 'node:path';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import { loginShellArgs, resolveLocalShell } from './shell-resolver.js';
|
||||
|
||||
const SAFE_BINARY_NAME = /^[a-z0-9][a-z0-9._-]*$/i;
|
||||
const LOGIN_SHELL_BEGIN_MARKER = '__CODEMAN_CLI_RESOLVE_BEGIN__';
|
||||
const LOGIN_SHELL_END_MARKER = '__CODEMAN_CLI_RESOLVE_END__';
|
||||
/** Maximum rendered length of each bounded diagnostic field, excluding its label. */
|
||||
const DIAGNOSTIC_FIELD_MAX_LENGTH = 1024;
|
||||
|
||||
/** First retry window after a full-chain resolution miss. */
|
||||
const RESOLVE_RETRY_BASE_MS = 60_000;
|
||||
/**
|
||||
* Ceiling for the doubling backoff. Deliberately shorter than the 15min cap on
|
||||
* the claude version probe: that one is cosmetic, while this gates the Run
|
||||
* flow, and "installing a CLI while the server is running is picked up without
|
||||
* a restart" should stay true within minutes.
|
||||
*/
|
||||
const RESOLVE_RETRY_MAX_MS = 5 * 60_000;
|
||||
|
||||
/**
|
||||
* How long to wait before re-running the resolution chain after `failures`
|
||||
* consecutive misses: 1min, 2min, 4min… capped at 5min. Mirrors
|
||||
* `claudeVersionRetryDelayMs` in claude-cli-resolver.ts. Exported for tests.
|
||||
*/
|
||||
export function cliResolveRetryDelayMs(failures: number): number {
|
||||
if (failures <= 0) return 0;
|
||||
return Math.min(RESOLVE_RETRY_BASE_MS * 2 ** (failures - 1), RESOLVE_RETRY_MAX_MS);
|
||||
}
|
||||
|
||||
export type CliResolutionSource = 'process-path' | 'common-directory' | 'login-shell';
|
||||
|
||||
export interface CliResolutionDiagnostics {
|
||||
binary: string;
|
||||
processPath: string;
|
||||
shellPath: string;
|
||||
shellArgs: string[];
|
||||
searchDirs: string[];
|
||||
}
|
||||
|
||||
export interface CliResolverHost {
|
||||
processPath: string;
|
||||
shellPath: string;
|
||||
shellArgs: string[];
|
||||
findOnProcessPath(binary: string): string | null;
|
||||
findInLoginShell(binary: string): string | null;
|
||||
exists(path: string): boolean;
|
||||
}
|
||||
|
||||
export interface CandidateValidation<T> {
|
||||
accepted: boolean;
|
||||
metadata?: T;
|
||||
}
|
||||
|
||||
export interface CliResolution<T = undefined> {
|
||||
binaryPath: string;
|
||||
directory: string;
|
||||
source: CliResolutionSource;
|
||||
metadata?: T;
|
||||
}
|
||||
|
||||
export interface CliExecutableResolver<T = undefined> {
|
||||
resolve(): CliResolution<T> | null;
|
||||
diagnostics(): CliResolutionDiagnostics;
|
||||
}
|
||||
|
||||
export interface CliResolverCommandOptions {
|
||||
encoding: 'utf8';
|
||||
timeout: number;
|
||||
stdio: ['ignore', 'pipe', 'ignore'];
|
||||
killSignal: 'SIGKILL';
|
||||
}
|
||||
|
||||
export type CliResolverCommandRunner = (file: string, args: string[], options: CliResolverCommandOptions) => string;
|
||||
|
||||
export interface ProductionCliResolverHostOptions {
|
||||
processPath?: string;
|
||||
shellPath?: string;
|
||||
shellArgs?: string[];
|
||||
runCommand?: CliResolverCommandRunner;
|
||||
isExecutableFile?: (path: string) => boolean;
|
||||
/**
|
||||
* Test-only escape hatch: keep the REAL IO primitives even under vitest.
|
||||
* For tests that exercise `isExecutableRegularFile` against their own temp
|
||||
* fixtures. Such a test must still inject `runCommand` if it can reach the
|
||||
* login-shell step, or it would spawn a real interactive shell.
|
||||
*/
|
||||
allowRealIoUnderVitest?: boolean;
|
||||
}
|
||||
|
||||
function isExecutableRegularFile(path: string): boolean {
|
||||
try {
|
||||
if (!statSync(path).isFile()) return false;
|
||||
accessSync(path, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseLoginShellResult(output: string, binary: string): string | null {
|
||||
const lines = output.split(/\r?\n/).map((line) => line.trim());
|
||||
const begin = lines.indexOf(LOGIN_SHELL_BEGIN_MARKER);
|
||||
if (begin === -1) return null;
|
||||
const end = lines.indexOf(LOGIN_SHELL_END_MARKER, begin + 1);
|
||||
if (end === -1) return null;
|
||||
|
||||
for (const candidate of lines.slice(begin + 1, end)) {
|
||||
if (isAbsolute(candidate) && basename(candidate) === binary) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function loginShellCommand(binary: string): string {
|
||||
return [
|
||||
`printf '%s\\n' '${LOGIN_SHELL_BEGIN_MARKER}'`,
|
||||
`command -v -- ${binary}`,
|
||||
`printf '%s\\n' '${LOGIN_SHELL_END_MARKER}'`,
|
||||
].join('; ');
|
||||
}
|
||||
|
||||
export function createProductionCliResolverHost(options: ProductionCliResolverHostOptions = {}): CliResolverHost {
|
||||
const shellPath = options.shellPath ?? resolveLocalShell();
|
||||
const shellArgs = options.shellArgs ?? loginShellArgs(shellPath).trim().split(/\s+/).filter(Boolean);
|
||||
const processPath = options.processPath ?? process.env.PATH ?? '';
|
||||
// Hermeticity gate (see @fileoverview): under vitest, any IO primitive the
|
||||
// caller did not inject is replaced by an inert stub. The suites must never
|
||||
// depend on — or execute — whatever happens to be installed on the machine
|
||||
// running them, and route tests hitting the per-CLI status endpoints would
|
||||
// otherwise scan the real PATH and spawn real login shells on CI.
|
||||
const inert = Boolean(process.env.VITEST) && options.allowRealIoUnderVitest !== true;
|
||||
const isExecutableFile = options.isExecutableFile ?? (inert ? () => false : isExecutableRegularFile);
|
||||
const runCommand: CliResolverCommandRunner =
|
||||
options.runCommand ?? (inert ? () => '' : (file, args, commandOptions) => execFileSync(file, args, commandOptions));
|
||||
const run = (file: string, args: string[]): string => {
|
||||
try {
|
||||
return runCommand(file, args, {
|
||||
encoding: 'utf8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
// SIGKILL is load-bearing: execFileSync's `timeout` only SENDS the kill
|
||||
// signal and then keeps waiting for the child to exit. Interactive bash
|
||||
// ignores SIGTERM (the default), so a login shell stuck in a blocking
|
||||
// .bash_profile would survive the timeout and block the server forever.
|
||||
killSignal: 'SIGKILL',
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
processPath,
|
||||
shellPath,
|
||||
shellArgs: [...shellArgs],
|
||||
findOnProcessPath: (binary) => {
|
||||
if (!SAFE_BINARY_NAME.test(binary)) return null;
|
||||
for (const directory of processPath.split(delimiter).filter(Boolean)) {
|
||||
const candidate = join(directory, binary);
|
||||
if (isAbsolute(candidate) && isExecutableFile(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
findInLoginShell: (binary) => {
|
||||
if (!SAFE_BINARY_NAME.test(binary)) return null;
|
||||
const candidate = parseLoginShellResult(run(shellPath, [...shellArgs, '-c', loginShellCommand(binary)]), binary);
|
||||
return candidate && isExecutableFile(candidate) ? candidate : null;
|
||||
},
|
||||
exists: isExecutableFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCliExecutableResolver<T = undefined>(
|
||||
options: {
|
||||
binary: string;
|
||||
searchDirs: string[];
|
||||
validateCandidate?: (path: string) => CandidateValidation<T>;
|
||||
/** Clock injection for tests driving the failure backoff. Defaults to `Date.now`. */
|
||||
now?: () => number;
|
||||
},
|
||||
host: CliResolverHost = createProductionCliResolverHost()
|
||||
): CliExecutableResolver<T> {
|
||||
if (!SAFE_BINARY_NAME.test(options.binary)) {
|
||||
throw new Error(`Unsafe CLI binary name: ${options.binary}`);
|
||||
}
|
||||
|
||||
const now = options.now ?? Date.now;
|
||||
/** Successful resolution, cached for the process lifetime. */
|
||||
let cached: CliResolution<T> | null = null;
|
||||
/** Consecutive full-chain misses (drives the retry backoff). */
|
||||
let failures = 0;
|
||||
/** Timestamp of the most recent miss. */
|
||||
let lastFailureAt = 0;
|
||||
const accept = (path: string | null, source: CliResolutionSource): CliResolution<T> | null => {
|
||||
if (!path || !isAbsolute(path) || !host.exists(path)) return null;
|
||||
const validation = options.validateCandidate?.(path) ?? ({ accepted: true } as CandidateValidation<T>);
|
||||
if (!validation.accepted) return null;
|
||||
return {
|
||||
binaryPath: path,
|
||||
directory: dirname(path),
|
||||
source,
|
||||
metadata: validation.metadata,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
resolve() {
|
||||
if (cached) return cached;
|
||||
// Negative cache: a miss is remembered and the chain — whose login-shell
|
||||
// tail is a synchronous 5s-bounded spawn — is not re-run until the
|
||||
// backoff elapses. Without this, every status poll and Run click against
|
||||
// a missing CLI froze the event loop for the full probe, forever.
|
||||
if (failures > 0 && now() - lastFailureAt < cliResolveRetryDelayMs(failures)) return null;
|
||||
|
||||
cached = accept(host.findOnProcessPath(options.binary), 'process-path');
|
||||
if (!cached) {
|
||||
for (const dir of options.searchDirs) {
|
||||
cached = accept(join(dir, options.binary), 'common-directory');
|
||||
if (cached) break;
|
||||
}
|
||||
}
|
||||
if (!cached) {
|
||||
cached = accept(host.findInLoginShell(options.binary), 'login-shell');
|
||||
}
|
||||
|
||||
if (cached) {
|
||||
failures = 0;
|
||||
lastFailureAt = 0;
|
||||
return cached;
|
||||
}
|
||||
failures += 1;
|
||||
lastFailureAt = now();
|
||||
return null;
|
||||
},
|
||||
diagnostics: () => ({
|
||||
binary: options.binary,
|
||||
processPath: host.processPath,
|
||||
shellPath: host.shellPath,
|
||||
shellArgs: [...host.shellArgs],
|
||||
searchDirs: [...options.searchDirs],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeDiagnosticField(value: string, emptyMarker: string): string {
|
||||
const flattened = Array.from(value, (character) => {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
const isControl = codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
|
||||
return isControl || codePoint === 0x2028 || codePoint === 0x2029 ? ' ' : character;
|
||||
})
|
||||
.join('')
|
||||
.replace(/ +/g, ' ')
|
||||
.trim();
|
||||
if (!flattened) return emptyMarker;
|
||||
if (flattened.length <= DIAGNOSTIC_FIELD_MAX_LENGTH) return flattened;
|
||||
return `${flattened.slice(0, DIAGNOSTIC_FIELD_MAX_LENGTH - 1)}…`;
|
||||
}
|
||||
|
||||
export function formatCliNotFoundMessage(base: string, diagnostics: CliResolutionDiagnostics): string {
|
||||
const processPath = sanitizeDiagnosticField(diagnostics.processPath, '(empty)');
|
||||
const shell = sanitizeDiagnosticField(
|
||||
[diagnostics.shellPath, ...diagnostics.shellArgs].filter(Boolean).join(' '),
|
||||
'(none)'
|
||||
);
|
||||
const dirs = sanitizeDiagnosticField(diagnostics.searchDirs.join(', '), '(none)');
|
||||
return `${base}\nServer PATH: ${processPath}\nLogin shell: ${shell}\nChecked directories: ${dirs}`;
|
||||
}
|
||||
@@ -7,11 +7,9 @@
|
||||
* @module utils/codex-cli-resolver
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
|
||||
|
||||
/** Common directories where the Codex CLI binary may be installed */
|
||||
const CODEX_SEARCH_DIRS = [
|
||||
@@ -23,8 +21,8 @@ const CODEX_SEARCH_DIRS = [
|
||||
join(homedir(), 'bin'), // User bin
|
||||
];
|
||||
|
||||
/** Cached directory containing the codex binary (empty string = searched but not found) */
|
||||
let _codexDir: string | null = null;
|
||||
const codexResolver = createCliExecutableResolver({ binary: 'codex', searchDirs: CODEX_SEARCH_DIRS });
|
||||
const CODEX_NOT_FOUND = 'Codex CLI not found. Install with: npm install -g @openai/codex';
|
||||
|
||||
/**
|
||||
* Finds the directory containing the `codex` binary.
|
||||
@@ -34,31 +32,7 @@ let _codexDir: string | null = null;
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolveCodexDir(): string | null {
|
||||
if (_codexDir !== null) return _codexDir || null;
|
||||
|
||||
// Try `which` first (respects current PATH)
|
||||
try {
|
||||
const result = execSync('which codex', {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (result && existsSync(result)) {
|
||||
_codexDir = dirname(result);
|
||||
return _codexDir;
|
||||
}
|
||||
} catch {
|
||||
// Codex not in PATH, will check common locations
|
||||
}
|
||||
|
||||
for (const dir of CODEX_SEARCH_DIRS) {
|
||||
if (existsSync(join(dir, 'codex'))) {
|
||||
_codexDir = dir;
|
||||
return _codexDir;
|
||||
}
|
||||
}
|
||||
|
||||
_codexDir = ''; // mark as searched, not found
|
||||
return null;
|
||||
return codexResolver.resolve()?.directory ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,3 +41,7 @@ export function resolveCodexDir(): string | null {
|
||||
export function isCodexAvailable(): boolean {
|
||||
return resolveCodexDir() !== null;
|
||||
}
|
||||
|
||||
export function getCodexNotFoundMessage(): string {
|
||||
return formatCliNotFoundMessage(CODEX_NOT_FOUND, codexResolver.diagnostics());
|
||||
}
|
||||
|
||||
@@ -1,17 +1,50 @@
|
||||
/**
|
||||
* @fileoverview Renders ToolResult[] from the dependency checker into a
|
||||
* human-readable grouped table or JSON, and computes the process exit code.
|
||||
* Plain text only (no color) so output is stable and snapshot-friendly; the
|
||||
* CLI layer may colorize.
|
||||
* Plain text by default (no color) so output is stable and snapshot-friendly;
|
||||
* the CLI layer passes a `ReportStyle` to paint it (see `cli.ts`, `doctor`).
|
||||
*
|
||||
* Column widths are measured, not hardcoded: "Antigravity CLI" is 15 characters
|
||||
* and the old `padEnd(14)` pushed its whole row one column right.
|
||||
*
|
||||
* @module utils/dependency-report
|
||||
*/
|
||||
|
||||
import { columnWidths, padStyled } from '../cli-style.js';
|
||||
import type { ProbeEnvironment, ToolCategory } from '../config/dependency-registry.js';
|
||||
import type { ToolResult, ToolStatus } from './dependency-checker.js';
|
||||
|
||||
const CATEGORY_ORDER: ToolCategory[] = ['core', 'office', 'other'];
|
||||
|
||||
/**
|
||||
* Paint hooks for the CLI layer. Every hook is identity by default, so this
|
||||
* module never decides anything about color and its output stays byte-stable
|
||||
* for tests.
|
||||
*/
|
||||
export interface ReportStyle {
|
||||
title(text: string): string;
|
||||
heading(text: string): string;
|
||||
glyph(result: ToolResult, glyph: string): string;
|
||||
label(text: string): string;
|
||||
status(result: ToolResult, text: string): string;
|
||||
path(text: string): string;
|
||||
meta(text: string): string;
|
||||
summary(text: string): string;
|
||||
}
|
||||
|
||||
const identity = (text: string): string => text;
|
||||
|
||||
const PLAIN_STYLE: ReportStyle = {
|
||||
title: identity,
|
||||
heading: identity,
|
||||
glyph: (_result, glyph) => glyph,
|
||||
label: identity,
|
||||
status: (_result, text) => text,
|
||||
path: identity,
|
||||
meta: identity,
|
||||
summary: identity,
|
||||
};
|
||||
|
||||
function glyph(r: ToolResult): string {
|
||||
if (r.status === 'ok') return '✓';
|
||||
if (r.status === 'skipped') return '○';
|
||||
@@ -33,24 +66,34 @@ export function computeExitCode(results: ToolResult[]): number {
|
||||
return failed ? 1 : 0;
|
||||
}
|
||||
|
||||
export function renderTable(results: ToolResult[], environment: ProbeEnvironment): string {
|
||||
const lines: string[] = [`Codeman dependency check — ${environment}`, ''];
|
||||
export function renderTable(
|
||||
results: ToolResult[],
|
||||
environment: ProbeEnvironment,
|
||||
style: ReportStyle = PLAIN_STYLE
|
||||
): string {
|
||||
// Widths are taken across ALL categories so the groups line up with each other.
|
||||
const [labelWidth, statusWidth] = columnWidths(results.map((r) => [r.label, statusText(r)]));
|
||||
const lines: string[] = [style.title(`Codeman dependency check — ${environment}`), ''];
|
||||
for (const category of CATEGORY_ORDER) {
|
||||
const rows = results.filter((r) => r.category === category);
|
||||
if (rows.length === 0) continue;
|
||||
lines.push(category.toUpperCase());
|
||||
lines.push(style.heading(category.toUpperCase()));
|
||||
for (const r of rows) {
|
||||
const detail = r.path ? ` ${r.path}` : '';
|
||||
lines.push(` ${glyph(r)} ${r.label.padEnd(14)} ${statusText(r).padEnd(22)}${detail}`);
|
||||
if (r.usedBy.length) lines.push(` used by: ${r.usedBy.join(', ')}`);
|
||||
if (r.installHint) lines.push(` install: ${r.installHint}`);
|
||||
const label = padStyled(r.label, labelWidth ?? 0, style.label);
|
||||
const status = padStyled(statusText(r), statusWidth ?? 0, (text) => style.status(r, text));
|
||||
const detail = r.path ? ` ${style.path(r.path)}` : '';
|
||||
lines.push(` ${style.glyph(r, glyph(r))} ${label} ${status}${detail}`.trimEnd());
|
||||
if (r.usedBy.length) lines.push(style.meta(` used by: ${r.usedBy.join(', ')}`));
|
||||
if (r.installHint) lines.push(style.meta(` install: ${r.installHint}`));
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
const ok = results.filter((r) => r.status === 'ok').length;
|
||||
const requiredMissing = results.filter((r) => r.required && r.status !== 'ok' && r.status !== 'skipped').length;
|
||||
const optionalMissing = results.filter((r) => !r.required && r.status === 'missing').length;
|
||||
lines.push(`Summary: ${ok} ok · ${requiredMissing} required missing · ${optionalMissing} optional missing`);
|
||||
lines.push(
|
||||
style.summary(`Summary: ${ok} ok · ${requiredMissing} required missing · ${optionalMissing} optional missing`)
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @fileoverview Pure file-name/path query matcher for the Files panel search
|
||||
* (COD-236). Compiles a user query string into a reusable predicate so the
|
||||
* server-side file walk can prune to matching entries instead of streaming the
|
||||
* whole tree.
|
||||
*
|
||||
* Semantics:
|
||||
* - Empty / whitespace-only query → `compileFileQuery` returns `null` (the
|
||||
* caller treats this as "no search", falling back to the full tree). A query
|
||||
* longer than `MAX_QUERY_LENGTH` compiles to `null` too: no honest filename
|
||||
* search is that long, and the glob walk below is O(text · pattern).
|
||||
* - A query containing a glob metachar (`*` or `?`) matches anchored and
|
||||
* case-insensitively, `*` spanning any run (slashes included) and `?` exactly
|
||||
* one character; every other character matches literally.
|
||||
* - Otherwise the query is a plain case-insensitive substring.
|
||||
* - When the query contains a `/` it matches against the relative path; else it
|
||||
* matches against the bare entry name.
|
||||
*
|
||||
* ⚠️ Globs are matched by `globMatch` below, never by compiling the query into
|
||||
* a RegExp: `*a*a*a…` translated to `^.*a.*a.*a…$` is a classic backtracking
|
||||
* blowup, evaluated synchronously against every walked path — a pathological
|
||||
* query could freeze the event loop for the whole server (the same reason
|
||||
* `search-service.ts` is regex-free). The two-pointer wildcard walk is
|
||||
* O(text · pattern) worst case, with both operands short by construction.
|
||||
*
|
||||
* No fs / IO — safe to unit-test directly.
|
||||
*/
|
||||
|
||||
export type FileQueryMatcher = (name: string, relativePath: string) => boolean;
|
||||
|
||||
// Longer than any honest file search; bounds the O(text · pattern) glob walk.
|
||||
const MAX_QUERY_LENGTH = 256;
|
||||
|
||||
/**
|
||||
* Anchored glob match, linear-space two-pointer walk (no RegExp — see the
|
||||
* fileoverview). `pattern` must already be lowercased; `text` is lowercased
|
||||
* here so one compiled matcher serves many entries.
|
||||
*/
|
||||
function globMatch(pattern: string, rawText: string): boolean {
|
||||
const text = rawText.toLowerCase();
|
||||
let p = 0;
|
||||
let t = 0;
|
||||
let starP = -1;
|
||||
let starT = -1;
|
||||
while (t < text.length) {
|
||||
const pc = p < pattern.length ? pattern[p] : '';
|
||||
if (pc === '?' || pc === text[t]) {
|
||||
p++;
|
||||
t++;
|
||||
} else if (pc === '*') {
|
||||
// Remember the star; try matching zero characters first, and on a later
|
||||
// mismatch re-expand it one character at a time from here.
|
||||
starP = p++;
|
||||
starT = t;
|
||||
} else if (starP !== -1) {
|
||||
p = starP + 1;
|
||||
t = ++starT;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
while (p < pattern.length && pattern[p] === '*') p++;
|
||||
return p === pattern.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a query string into a matcher predicate, or `null` when the query is
|
||||
* empty/whitespace or overlong (caller treats null as "no search").
|
||||
*/
|
||||
export function compileFileQuery(query: string): FileQueryMatcher | null {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed === '' || trimmed.length > MAX_QUERY_LENGTH) return null;
|
||||
|
||||
const matchesPath = trimmed.includes('/');
|
||||
const isGlob = trimmed.includes('*') || trimmed.includes('?');
|
||||
|
||||
if (isGlob) {
|
||||
const pattern = trimmed.toLowerCase();
|
||||
return (name, relativePath) => globMatch(pattern, matchesPath ? relativePath : name);
|
||||
}
|
||||
|
||||
const needle = trimmed.toLowerCase();
|
||||
return (name, relativePath) => (matchesPath ? relativePath : name).toLowerCase().includes(needle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: compile the query and apply it in one call. Returns false when
|
||||
* the query compiles to null (empty).
|
||||
*/
|
||||
export function matchFileQuery(query: string, name: string, relativePath: string): boolean {
|
||||
const matcher = compileFileQuery(query);
|
||||
return matcher ? matcher(name, relativePath) : false;
|
||||
}
|
||||
@@ -7,11 +7,9 @@
|
||||
* @module utils/gemini-cli-resolver
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
|
||||
|
||||
/** Common directories where the Gemini CLI binary may be installed */
|
||||
const GEMINI_SEARCH_DIRS = [
|
||||
@@ -23,8 +21,8 @@ const GEMINI_SEARCH_DIRS = [
|
||||
join(homedir(), 'bin'),
|
||||
];
|
||||
|
||||
/** Cached directory containing the gemini binary (empty string = searched but not found) */
|
||||
let _geminiDir: string | null = null;
|
||||
const geminiResolver = createCliExecutableResolver({ binary: 'gemini', searchDirs: GEMINI_SEARCH_DIRS });
|
||||
const GEMINI_NOT_FOUND = 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli';
|
||||
|
||||
/**
|
||||
* Finds the directory containing the `gemini` binary.
|
||||
@@ -33,30 +31,7 @@ let _geminiDir: string | null = null;
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolveGeminiDir(): string | null {
|
||||
if (_geminiDir !== null) return _geminiDir || null;
|
||||
|
||||
try {
|
||||
const result = execSync('which gemini', {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (result && existsSync(result)) {
|
||||
_geminiDir = dirname(result);
|
||||
return _geminiDir;
|
||||
}
|
||||
} catch {
|
||||
// Gemini not in PATH, will check common locations
|
||||
}
|
||||
|
||||
for (const dir of GEMINI_SEARCH_DIRS) {
|
||||
if (existsSync(join(dir, 'gemini'))) {
|
||||
_geminiDir = dir;
|
||||
return _geminiDir;
|
||||
}
|
||||
}
|
||||
|
||||
_geminiDir = '';
|
||||
return null;
|
||||
return geminiResolver.resolve()?.directory ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,3 +40,7 @@ export function resolveGeminiDir(): string | null {
|
||||
export function isGeminiAvailable(): boolean {
|
||||
return resolveGeminiDir() !== null;
|
||||
}
|
||||
|
||||
export function getGeminiNotFoundMessage(): string {
|
||||
return formatCliNotFoundMessage(GEMINI_NOT_FOUND, geminiResolver.diagnostics());
|
||||
}
|
||||
|
||||
+18
-6
@@ -28,10 +28,22 @@ export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-si
|
||||
export { assertNever } from './type-safety.js';
|
||||
export { wrapWithNice } from './nice-wrapper.js';
|
||||
export { resolveLocalShell, loginShellArgs } from './shell-resolver.js';
|
||||
export { findClaudeDir, getAugmentedPath, getClaudeCliVersion, getClaudeBinaryPath } from './claude-cli-resolver.js';
|
||||
export {
|
||||
findClaudeDir,
|
||||
getAugmentedPath,
|
||||
getClaudeCliVersion,
|
||||
getClaudeBinaryPath,
|
||||
getClaudeNotFoundMessage,
|
||||
} from './claude-cli-resolver.js';
|
||||
export { spawnPtyWithHelperRepair } from './node-pty-repair.js';
|
||||
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
||||
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
|
||||
export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js';
|
||||
export { resolveAntigravityDir, isAntigravityAvailable } from './antigravity-cli-resolver.js';
|
||||
export { resolvePiDir, isPiAvailable, getPiCliVersion } from './pi-cli-resolver.js';
|
||||
export { resolveOpenCodeDir, getOpenCodeNotFoundMessage } from './opencode-cli-resolver.js';
|
||||
export { resolveCodexDir, isCodexAvailable, getCodexNotFoundMessage } from './codex-cli-resolver.js';
|
||||
export { resolveGeminiDir, isGeminiAvailable, getGeminiNotFoundMessage } from './gemini-cli-resolver.js';
|
||||
export {
|
||||
resolveAntigravityDir,
|
||||
isAntigravityAvailable,
|
||||
getAntigravityNotFoundMessage,
|
||||
} from './antigravity-cli-resolver.js';
|
||||
export { resolvePiDir, isPiAvailable, getPiCliVersion, getPiNotFoundMessage } from './pi-cli-resolver.js';
|
||||
export { compileFileQuery, matchFileQuery } from './file-query.js';
|
||||
export type { FileQueryMatcher } from './file-query.js';
|
||||
|
||||
@@ -7,11 +7,9 @@
|
||||
* @module utils/opencode-cli-resolver
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
|
||||
|
||||
/** Common directories where the OpenCode CLI binary may be installed */
|
||||
const OPENCODE_SEARCH_DIRS = [
|
||||
@@ -24,8 +22,8 @@ const OPENCODE_SEARCH_DIRS = [
|
||||
join(homedir(), 'bin'), // User bin
|
||||
];
|
||||
|
||||
/** Cached directory containing the opencode binary (empty string = searched but not found) */
|
||||
let _openCodeDir: string | null = null;
|
||||
const openCodeResolver = createCliExecutableResolver({ binary: 'opencode', searchDirs: OPENCODE_SEARCH_DIRS });
|
||||
const OPENCODE_NOT_FOUND = 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash';
|
||||
|
||||
/**
|
||||
* Finds the directory containing the `opencode` binary.
|
||||
@@ -35,32 +33,7 @@ let _openCodeDir: string | null = null;
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolveOpenCodeDir(): string | null {
|
||||
if (_openCodeDir !== null) return _openCodeDir || null;
|
||||
|
||||
// Try `which` first (respects current PATH)
|
||||
try {
|
||||
const result = execSync('which opencode', {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (result && existsSync(result)) {
|
||||
_openCodeDir = dirname(result);
|
||||
return _openCodeDir;
|
||||
}
|
||||
} catch {
|
||||
// OpenCode not in PATH, will check common locations
|
||||
}
|
||||
|
||||
// Fallback: check common installation directories
|
||||
for (const dir of OPENCODE_SEARCH_DIRS) {
|
||||
if (existsSync(join(dir, 'opencode'))) {
|
||||
_openCodeDir = dir;
|
||||
return _openCodeDir;
|
||||
}
|
||||
}
|
||||
|
||||
_openCodeDir = ''; // mark as searched, not found
|
||||
return null;
|
||||
return openCodeResolver.resolve()?.directory ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,3 +42,7 @@ export function resolveOpenCodeDir(): string | null {
|
||||
export function isOpenCodeAvailable(): boolean {
|
||||
return resolveOpenCodeDir() !== null;
|
||||
}
|
||||
|
||||
export function getOpenCodeNotFoundMessage(): string {
|
||||
return formatCliNotFoundMessage(OPENCODE_NOT_FOUND, openCodeResolver.diagnostics());
|
||||
}
|
||||
|
||||
@@ -15,11 +15,15 @@
|
||||
* @module utils/pi-cli-resolver
|
||||
*/
|
||||
|
||||
import { execFileSync, execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import {
|
||||
createCliExecutableResolver,
|
||||
formatCliNotFoundMessage,
|
||||
type CliResolverHost,
|
||||
} from './cli-executable-resolver.js';
|
||||
|
||||
/** Common directories where the Pi CLI binary may be installed */
|
||||
const PI_SEARCH_DIRS = [
|
||||
@@ -45,10 +49,7 @@ const PI_SEARCH_DIRS = [
|
||||
*/
|
||||
export const PI_VERSION_REGEX = /(?:^|\s)(\d+\.\d+\.\d+)/;
|
||||
|
||||
/** Cached directory containing the pi binary (empty string = searched but not found) */
|
||||
let _piDir: string | null = null;
|
||||
/** Cached version string reported by the resolved binary (empty string = probed, unusable) */
|
||||
let _piVersion: string | null = null;
|
||||
const PI_NOT_FOUND = 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent';
|
||||
|
||||
/**
|
||||
* Run `pi --version` on a candidate path and return the trimmed version when it
|
||||
@@ -57,7 +58,12 @@ let _piVersion: string | null = null;
|
||||
* (which is how an unrelated `pi` on PATH gets rejected).
|
||||
*
|
||||
* Never runs under vitest: the suites must stay hermetic and must not depend on
|
||||
* whether the dev box happens to have pi installed.
|
||||
* whether the dev box happens to have pi installed — and since `pi` is a short
|
||||
* GENERIC name, this probe would EXECUTE whatever binary of that name the
|
||||
* machine carries. The shared resolver host is already inert under vitest, so
|
||||
* this gate is defense in depth for any opted-in host that still carries the
|
||||
* default probe; tests drive resolution via `createPiResolverForTest`, whose
|
||||
* injected probe bypasses it. Pinned by test/pi-cli-resolver.test.ts.
|
||||
*/
|
||||
function probePiVersion(binPath: string): string | null {
|
||||
if (process.env.VITEST) return null;
|
||||
@@ -66,6 +72,9 @@ function probePiVersion(binPath: string): string | null {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
// A stuck or hostile `pi` that ignores SIGTERM would survive the timeout
|
||||
// and block the server (execFileSync keeps waiting after the signal).
|
||||
killSignal: 'SIGKILL',
|
||||
}).trim();
|
||||
// Upstream prints a bare version today; tolerate a `pi 0.84.1` style prefix too.
|
||||
const candidate = PI_VERSION_REGEX.exec(out)?.[1];
|
||||
@@ -77,6 +86,34 @@ function probePiVersion(binPath: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
type PiVersionProbe = (binPath: string) => string | null;
|
||||
|
||||
function createPiResolver(host?: CliResolverHost, versionProbe: PiVersionProbe = probePiVersion, now?: () => number) {
|
||||
return createCliExecutableResolver<string>(
|
||||
{
|
||||
binary: 'pi',
|
||||
searchDirs: PI_SEARCH_DIRS,
|
||||
validateCandidate: (binPath) => {
|
||||
const version = versionProbe(binPath);
|
||||
return version ? { accepted: true, metadata: version } : { accepted: false };
|
||||
},
|
||||
now,
|
||||
},
|
||||
host
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an isolated Pi wrapper around an injected host, version probe and
|
||||
* clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, which
|
||||
* is exactly what the hermeticity test exercises.
|
||||
*/
|
||||
export function createPiResolverForTest(host: CliResolverHost, versionProbe?: PiVersionProbe, now?: () => number) {
|
||||
return createPiResolver(host, versionProbe ?? probePiVersion, now);
|
||||
}
|
||||
|
||||
const piResolver = createPiResolver();
|
||||
|
||||
/**
|
||||
* Finds the directory containing a verified `pi` binary.
|
||||
* Checks `which pi` first, then falls back to common install locations. Every
|
||||
@@ -86,46 +123,7 @@ function probePiVersion(binPath: string): string | null {
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolvePiDir(): string | null {
|
||||
if (_piDir !== null) return _piDir || null;
|
||||
|
||||
const accept = (binPath: string): string | null => {
|
||||
// Under vitest the probe never runs, so existence alone decides (keeps the
|
||||
// suites hermetic and matches how the sibling resolvers behave there).
|
||||
if (process.env.VITEST) {
|
||||
_piDir = dirname(binPath);
|
||||
_piVersion = '';
|
||||
return _piDir;
|
||||
}
|
||||
const version = probePiVersion(binPath);
|
||||
if (!version) return null;
|
||||
_piDir = dirname(binPath);
|
||||
_piVersion = version;
|
||||
return _piDir;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = execSync('which pi', {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (result && existsSync(result)) {
|
||||
const dir = accept(result);
|
||||
if (dir) return dir;
|
||||
}
|
||||
} catch {
|
||||
// pi not in PATH, will check common locations
|
||||
}
|
||||
|
||||
for (const dir of PI_SEARCH_DIRS) {
|
||||
const binPath = join(dir, 'pi');
|
||||
if (!existsSync(binPath)) continue;
|
||||
const accepted = accept(binPath);
|
||||
if (accepted) return accepted;
|
||||
}
|
||||
|
||||
_piDir = '';
|
||||
_piVersion = '';
|
||||
return null;
|
||||
return piResolver.resolve()?.directory ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,12 +133,14 @@ export function isPiAvailable(): boolean {
|
||||
return resolvePiDir() !== null;
|
||||
}
|
||||
|
||||
export function getPiNotFoundMessage(): string {
|
||||
return formatCliNotFoundMessage(PI_NOT_FOUND, piResolver.diagnostics());
|
||||
}
|
||||
|
||||
/**
|
||||
* Version reported by the resolved `pi` binary, or null when pi is unavailable
|
||||
* (or when the probe was skipped, i.e. under vitest). Surfaced through
|
||||
* `GET /api/pi/status` so a misresolution is diagnosable from the UI.
|
||||
* Version reported by the resolved `pi` binary, or null when pi is unavailable.
|
||||
* Surfaced through `GET /api/pi/status` so a misresolution is diagnosable from the UI.
|
||||
*/
|
||||
export function getPiCliVersion(): string | null {
|
||||
resolvePiDir();
|
||||
return _piVersion || null;
|
||||
return piResolver.resolve()?.metadata ?? null;
|
||||
}
|
||||
|
||||
+110
-26
@@ -22,14 +22,14 @@
|
||||
* - Acknowledgement (`acknowledge()`, idle items only) is NOT resolution: the
|
||||
* item stays pending, it just stops arming the tab alert on every client.
|
||||
*
|
||||
* @dependencies utils (stripAnsi)
|
||||
* @dependencies utils (stripAnsi, CLAUDE_WORKING_LINE_PATTERN)
|
||||
* @consumedby web/routes/hook-event-routes (notePrompt/resolve), web/routes/approval-routes,
|
||||
* web/session-listener-wiring (working/exit resolution), web/server (emit callbacks + stop)
|
||||
*
|
||||
* @module web/approval-inbox
|
||||
*/
|
||||
|
||||
import { stripAnsi } from '../utils/index.js';
|
||||
import { stripAnsi, CLAUDE_WORKING_LINE_PATTERN } from '../utils/index.js';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -106,6 +106,12 @@ const ITEM_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
* single delayed re-capture picks up the frame the immediate capture missed.
|
||||
*/
|
||||
const RECAPTURE_DELAY_MS = 600;
|
||||
/**
|
||||
* Delayed staleness pass for the late-hook case (see notePrompt). Comfortably
|
||||
* clear of RECAPTURE_DELAY_MS so a dialog Ink has not painted yet is never
|
||||
* mistaken for one that is gone.
|
||||
*/
|
||||
const STALE_CHECK_DELAY_MS = 3000;
|
||||
/** Context kept per item: enough for a dialog plus a few lines above it. */
|
||||
const MAX_CONTEXT_CHARS = 4000;
|
||||
const MAX_CONTEXT_LINES = 30;
|
||||
@@ -195,7 +201,8 @@ export function parseDialogOptions(context: string | undefined): ApprovalOption[
|
||||
export class ApprovalInbox {
|
||||
/** Keyed by sessionId; the one-active-item-per-session invariant lives here. */
|
||||
private items = new Map<string, ApprovalItem>();
|
||||
private recaptureTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
/** Post-capture timers per item id (re-capture + the delayed staleness check). */
|
||||
private itemTimers = new Map<string, ReturnType<typeof setTimeout>[]>();
|
||||
/** Capture callbacks kept for answer-time re-verification; dropped on remove. */
|
||||
private captures = new Map<string, () => string | null>();
|
||||
private seq = 0;
|
||||
@@ -229,31 +236,55 @@ export class ApprovalInbox {
|
||||
if (args.capture) this.captures.set(args.sessionId, args.capture);
|
||||
this.onPending?.(item);
|
||||
if (args.capture && !this.stopped) {
|
||||
const timer = setTimeout(() => {
|
||||
this.recaptureTimers.delete(item.id);
|
||||
// Only update the item if it is still the live one for the session.
|
||||
if (this.items.get(args.sessionId)?.id !== item.id) return;
|
||||
// Pass 1 (600ms): enrich the card with the painted frame.
|
||||
this.scheduleForItem(item, RECAPTURE_DELAY_MS, () => {
|
||||
this.applyCapture(item, args.capture);
|
||||
this.onUpdated?.(item);
|
||||
}, RECAPTURE_DELAY_MS);
|
||||
this.recaptureTimers.set(item.id, timer);
|
||||
});
|
||||
// Pass 2: the late-hook staleness check. Claude Code fires the
|
||||
// Notification behind the dialog, so a prompt answered before the hook
|
||||
// lands creates an item for a dialog that is ALREADY gone: nothing ever
|
||||
// parsed, so the "options vanished" test can never fire, `stop` may have
|
||||
// gone by already, and the red alert then outlived reloads until the 12h
|
||||
// TTL. This pass re-reads the pane and resolves when the frame proves no
|
||||
// dialog is up. Deliberately LATER than the re-capture, whose whole
|
||||
// reason for existing is that Ink may not have painted the dialog yet:
|
||||
// resolving inside that window could clear the alert for a dialog that
|
||||
// was about to appear.
|
||||
this.scheduleForItem(item, STALE_CHECK_DELAY_MS, () => {
|
||||
this.verifyStillAnswerable(item.id);
|
||||
});
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer-time guard: re-capture the pane and check the dialog is still on
|
||||
* screen before keystrokes are sent at it. Only conclusive when the ORIGINAL
|
||||
* frame parsed options: if a fresh capture then parses none, the dialog is
|
||||
* gone (answered in the terminal moments ago), so the item resolves and the
|
||||
* answer must be refused, because the digit would land in whatever now has
|
||||
* focus. Unparseable-from-the-start items stay answerable (approve/deny
|
||||
* only), same risk the terminal user already carries.
|
||||
* screen before keystrokes are sent at it. If the dialog is gone (answered in
|
||||
* the terminal moments ago) the item resolves and the answer is refused,
|
||||
* because the digit would land in whatever now has focus.
|
||||
*
|
||||
* A fresh frame that parses NO options is conclusive in two cases, and only
|
||||
* those; anything else stays answerable, so an unreadable capture keeps the
|
||||
* alert rather than losing a live dialog:
|
||||
*
|
||||
* 1. The item HAD parsed options. They cannot vanish while the dialog is up.
|
||||
* 2. The frame shows Claude actively running a turn. A modal dialog BLOCKS
|
||||
* the turn, so a working line and a dialog cannot coexist — measured on
|
||||
* v2.1.237: a live-dialog frame carries neither the `… (13s` timer nor
|
||||
* even the `esc to interrupt` footer, which the dialog replaces with
|
||||
* `Enter to select · ↑/↓ to navigate · Esc to cancel`.
|
||||
*
|
||||
* Case 2 is what closes the late-hook hole. Claude Code fires the
|
||||
* Notification behind the dialog, so a prompt answered before the hook lands
|
||||
* produces an item whose FIRST capture already has no dialog in it — never
|
||||
* parsed, so case 1 can never fire, and the red alert then outlived even
|
||||
* `stop` (which had already fired) and survived reloads until the 12h TTL.
|
||||
*/
|
||||
verifyStillAnswerable(id: string): boolean {
|
||||
const item = this.getById(id);
|
||||
if (!item) return false;
|
||||
if (item.kind === 'idle' || !item.options) return true;
|
||||
if (item.kind === 'idle') return true;
|
||||
const capture = this.captures.get(item.sessionId);
|
||||
if (!capture) return true;
|
||||
let raw: string | null = null;
|
||||
@@ -266,14 +297,39 @@ export class ApprovalInbox {
|
||||
if (!context) return true;
|
||||
const options = parseDialogOptions(context);
|
||||
if (!options) {
|
||||
this.remove(item, 'resolved_in_terminal');
|
||||
return false;
|
||||
if (item.options || CLAUDE_WORKING_LINE_PATTERN.test(context)) {
|
||||
this.remove(item, 'resolved_in_terminal');
|
||||
return false;
|
||||
}
|
||||
return true; // never parsed and the pane is not visibly working: unreadable, not gone
|
||||
}
|
||||
item.context = context;
|
||||
item.options = options;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* "This session's pane started moving again": re-verify its pending DIALOG
|
||||
* item against the screen and resolve it if the dialog is gone.
|
||||
*
|
||||
* The staleness check itself lived only in `GET /api/approvals`, which
|
||||
* nothing calls while a page is open (`seedApprovals()` runs on init and
|
||||
* reconnect), so a dialog answered in the terminal kept its red tab alert for
|
||||
* the whole rest of the turn. The `working` signal is exactly the moment an
|
||||
* answer lands, and routing it through `verifyStillAnswerable` is what makes
|
||||
* it safe to act on for a permission/question item: `working` is heuristic
|
||||
* and can flap, but it only decides WHEN to look — the pane decides the
|
||||
* outcome, and an unreadable capture keeps the alert.
|
||||
*
|
||||
* Cheap by construction: a Map miss unless a dialog item is actually pending,
|
||||
* and the item is gone after the first successful resolve.
|
||||
*/
|
||||
resolveIfDialogGone(sessionId: string): void {
|
||||
const item = this.getForSession(sessionId);
|
||||
if (!item || item.kind === 'idle') return;
|
||||
this.verifyStillAnswerable(item.id);
|
||||
}
|
||||
|
||||
/** Pending item for a session, TTL-checked. */
|
||||
getForSession(sessionId: string): ApprovalItem | undefined {
|
||||
const item = this.items.get(sessionId);
|
||||
@@ -359,12 +415,27 @@ export class ApprovalInbox {
|
||||
/** Clear all timers (shutdown/tests). Items become inert; no events fire after this. */
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
for (const timer of this.recaptureTimers.values()) clearTimeout(timer);
|
||||
this.recaptureTimers.clear();
|
||||
for (const timers of this.itemTimers.values()) for (const timer of timers) clearTimeout(timer);
|
||||
this.itemTimers.clear();
|
||||
this.items.clear();
|
||||
this.captures.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` after `delayMs` if the item is still the live one for its session,
|
||||
* tracking the timer so `remove()`/`stop()` can cancel it.
|
||||
*/
|
||||
private scheduleForItem(item: ApprovalItem, delayMs: number, fn: () => void): void {
|
||||
const timer = setTimeout(() => {
|
||||
const timers = this.itemTimers.get(item.id)?.filter((t) => t !== timer) ?? [];
|
||||
if (timers.length > 0) this.itemTimers.set(item.id, timers);
|
||||
else this.itemTimers.delete(item.id);
|
||||
if (this.items.get(item.sessionId)?.id !== item.id) return;
|
||||
fn();
|
||||
}, delayMs);
|
||||
this.itemTimers.set(item.id, [...(this.itemTimers.get(item.id) ?? []), timer]);
|
||||
}
|
||||
|
||||
private applyCapture(item: ApprovalItem, capture?: () => string | null): void {
|
||||
if (!capture) return;
|
||||
let raw: string | null = null;
|
||||
@@ -377,17 +448,30 @@ export class ApprovalInbox {
|
||||
if (!context) return;
|
||||
item.context = context;
|
||||
// Idle prompts are not dialogs; never offer digit answers for them.
|
||||
if (item.kind !== 'idle') item.options = parseDialogOptions(context);
|
||||
if (item.kind === 'idle') return;
|
||||
const options = parseDialogOptions(context);
|
||||
// ⚠️ ADD-ONLY: a re-capture that parses NOTHING must never erase options a
|
||||
// previous capture found. Claude Code delays the Notification hook behind
|
||||
// the dialog (measured 6s here, up to ~30s), so the 600ms re-capture very
|
||||
// often lands AFTER the user has already answered in the terminal, on a
|
||||
// frame with no dialog in it. Clearing the field there was the whole bug:
|
||||
// `verifyStillAnswerable` reads a MISSING `options` as "never parsed" and
|
||||
// keeps such an item answerable by design, so a cleared field made the item
|
||||
// permanently unsweepable — the red "needs you" alert then survived every
|
||||
// `GET /api/approvals` and every page reload and only went away on `stop`
|
||||
// (owner report 2026-08-20: a confirmed question left a tab flowing red for
|
||||
// ~8 minutes while the turn ran on), and the stale card still accepted an
|
||||
// answer, typing a bare `1` into a composer with no dialog under it.
|
||||
// Keeping the parse means a later capture is CONCLUSIVE: options present +
|
||||
// fresh frame without them == answered in the terminal.
|
||||
if (options) item.options = options;
|
||||
}
|
||||
|
||||
private remove(item: ApprovalItem, resolution: ApprovalResolution): void {
|
||||
this.items.delete(item.sessionId);
|
||||
this.captures.delete(item.sessionId);
|
||||
const timer = this.recaptureTimers.get(item.id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.recaptureTimers.delete(item.id);
|
||||
}
|
||||
for (const timer of this.itemTimers.get(item.id) ?? []) clearTimeout(timer);
|
||||
this.itemTimers.delete(item.id);
|
||||
if (!this.stopped) {
|
||||
this.onResolved?.({ id: item.id, sessionId: item.sessionId, kind: item.kind, resolution });
|
||||
}
|
||||
|
||||
+154
-37
@@ -536,10 +536,10 @@ class CodemanApp {
|
||||
this._initGeneration = 0; // dedup concurrent handleInit calls
|
||||
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
|
||||
this._selectGeneration = 0; // cancel stale selectSession loads
|
||||
// Sessions whose full tmux scrollback has already been replayed this page load
|
||||
// (COD-47). Tracked PER SESSION rather than as a single "first load" flag: the
|
||||
// flag was consumed by whichever session auto-selected at page load, so every
|
||||
// OTHER tab started life with one visible frame of history (issue #205).
|
||||
// Non-shell sessions whose full tmux scrollback has already been replayed this
|
||||
// page load (COD-47). Shells deliberately start from a bounded tail because
|
||||
// their scrollback can be very large; full history stays available on demand.
|
||||
// Tracked PER SESSION rather than as a single "first load" flag (issue #205).
|
||||
this._fullHistoryLoaded = new Set();
|
||||
// Cooldown per session for the scroll-to-top "load more history" re-pull.
|
||||
this._fullHistoryRepullAt = new Map(); // Map<sessionId, timestamp>
|
||||
@@ -2053,6 +2053,28 @@ class CodemanApp {
|
||||
wrap.appendChild(actions);
|
||||
wrap.appendChild(pre);
|
||||
});
|
||||
// Links open in a NEW tab.
|
||||
//
|
||||
// marked emits a bare `<a href>` and the sanitizer's allowlist has no
|
||||
// `target`, so a tap in the chat NAVIGATED THE APP AWAY: on a phone that
|
||||
// unloads the whole dashboard — SSE, terminal buffers, unsent composer
|
||||
// text — and the OS back gesture reloads it from scratch, which is what
|
||||
// "links don't open" reads as on mobile, with no middle-click or
|
||||
// open-in-new-tab affordance to work around it.
|
||||
//
|
||||
// This pass runs AFTER sanitizing, so it is the only source of these two
|
||||
// attributes: whatever an agent wrote is already gone, and `rel` is set on
|
||||
// the same element in the same breath, so no page Codeman opens ever gets
|
||||
// a `window.opener` handle back (reverse tabnabbing).
|
||||
//
|
||||
// A fragment link stays in-page, and mailto:/tel: are handed to the OS —
|
||||
// giving those a target just strands an empty tab.
|
||||
tmpl.content.querySelectorAll('a[href]').forEach((a) => {
|
||||
const href = a.getAttribute('href') || '';
|
||||
if (!href || href.startsWith('#') || /^(?:mailto|tel):/i.test(href)) return;
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
return tmpl.innerHTML;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
@@ -5246,6 +5268,22 @@ class CodemanApp {
|
||||
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
||||
}
|
||||
|
||||
_recordTerminalLoadTiming(timing) {
|
||||
this._lastTerminalLoadTiming = timing;
|
||||
console.info('[TERMINAL-PERF]', timing);
|
||||
const resetAndParseMs =
|
||||
(timing.cacheResetAndParseMs || 0) +
|
||||
(timing.freshResetAndParseMs || 0) +
|
||||
(timing.resetAndParseMs || 0);
|
||||
const totalMs = timing.selectDoneMs ?? timing.totalMs ?? timing.selectToReplayCompleteMs ?? 0;
|
||||
_crashDiag.log(
|
||||
`TERMINAL_LOAD: ${timing.trigger} ${timing.full ? 'full' : 'tail'} ${timing.chars} chars ` +
|
||||
`ttfb=${timing.ttfbMs.toFixed(0)}ms body+json=${timing.bodyAndJsonMs.toFixed(0)}ms ` +
|
||||
`reset+parse=${resetAndParseMs.toFixed(0)}ms total=${totalMs.toFixed(0)}ms ` +
|
||||
`server="${timing.serverTiming}"${timing.refused ? ' refused-downgrade' : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Load more history": re-pull the whole tmux scrollback when the user scrolls up
|
||||
* while already at the top of what the browser has.
|
||||
@@ -5274,6 +5312,11 @@ class CodemanApp {
|
||||
const sessionId = this.activeSessionId;
|
||||
if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return;
|
||||
if (this.detachedSessions?.has(sessionId)) return;
|
||||
const session = this.sessions.get(sessionId);
|
||||
// A shell's full capture can be many megabytes. Replaying it from an
|
||||
// ordinary scroll gesture blocks xterm's main thread, so keep that cost
|
||||
// behind the explicit "Load full history" button.
|
||||
if (!force && session?.mode === 'shell') return;
|
||||
const now = Date.now();
|
||||
// Momentum scrolling fires this dozens of times per flick, and a burst of new
|
||||
// output is the normal reason to want a re-pull, so cooldown rather than latch.
|
||||
@@ -5285,13 +5328,32 @@ class CodemanApp {
|
||||
this._fullHistoryRepullAt.set(sessionId, now);
|
||||
this._fullHistoryRepullInFlight = true;
|
||||
try {
|
||||
const requestStartedAt = performance.now();
|
||||
const res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`);
|
||||
const headersReceivedAt = performance.now();
|
||||
const payload = (await res.json())?.data ?? {};
|
||||
const bodyParsedAt = performance.now();
|
||||
const buffer = payload.terminalBuffer;
|
||||
const timing = {
|
||||
trigger: force ? 'full-history-button' : 'full-history-scroll',
|
||||
mode: session?.mode || 'unknown',
|
||||
full: true,
|
||||
source: payload.source || 'unknown',
|
||||
chars: buffer?.length || 0,
|
||||
ttfbMs: headersReceivedAt - requestStartedAt,
|
||||
bodyAndJsonMs: bodyParsedAt - headersReceivedAt,
|
||||
resetAndParseMs: 0,
|
||||
totalMs: 0,
|
||||
serverTiming: res.headers?.get?.('server-timing') || '',
|
||||
refused: false,
|
||||
};
|
||||
// Bail on a tab switch mid-fetch: writing here would paint another session's
|
||||
// history into the terminal the user is now looking at.
|
||||
if (!buffer || this.activeSessionId !== sessionId) return;
|
||||
if (this._replayWouldShrinkBuffer(buffer)) {
|
||||
timing.refused = true;
|
||||
timing.totalMs = performance.now() - requestStartedAt;
|
||||
this._recordTerminalLoadTiming(timing);
|
||||
(this._fullHistoryRepullUseless ||= new Set()).add(sessionId);
|
||||
this._logScrollRouting?.('repull-refused-downgrade');
|
||||
// The browser already holds more than tmux can give back, so there is
|
||||
@@ -5302,17 +5364,32 @@ class CodemanApp {
|
||||
this._setHistoryTruncation(sessionId, payload);
|
||||
this._fullHistoryRepullUseless?.delete(sessionId);
|
||||
const rowsBefore = this.terminal.buffer.active.length;
|
||||
const replayStartedAt = performance.now();
|
||||
this._resetTerminalForReplay();
|
||||
await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId);
|
||||
if (this.activeSessionId !== sessionId) return;
|
||||
this.terminalBufferCache.set(sessionId, buffer);
|
||||
const {
|
||||
parsedAt,
|
||||
bufferLength: parsedBufferLength,
|
||||
completed,
|
||||
} = await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId);
|
||||
timing.resetAndParseMs = parsedAt - replayStartedAt;
|
||||
if (!completed || this.activeSessionId !== sessionId) return;
|
||||
// Keep shell tab restores bounded too. A user-triggered full-history pull
|
||||
// may be tens of MB; caching it would replay that whole payload again on
|
||||
// the next tab switch before the normal 1MB tail fetch replaces it.
|
||||
if (this.sessions.get(sessionId)?.mode !== 'shell') {
|
||||
this.terminalBufferCache.set(sessionId, buffer);
|
||||
} else {
|
||||
this.terminalBufferCache.delete(sessionId);
|
||||
}
|
||||
// Hold the user's place. The replay is a superset that grew the buffer
|
||||
// UPWARD, so what used to be row 0 (what they were looking at) is now `delta`
|
||||
// rows down; scrolling there reveals the recovered history above it instead
|
||||
// of teleporting them to the bottom the way a normal buffer load does.
|
||||
const delta = this.terminal.buffer.active.length - rowsBefore;
|
||||
const delta = parsedBufferLength - rowsBefore;
|
||||
if (delta > 0) this.terminal.scrollToLine(delta);
|
||||
else this.terminal.scrollToTop();
|
||||
timing.totalMs = performance.now() - requestStartedAt;
|
||||
this._recordTerminalLoadTiming(timing);
|
||||
} catch {
|
||||
// Transient (offline, 5xx) — the next scroll-up past the cooldown retries.
|
||||
} finally {
|
||||
@@ -5592,6 +5669,7 @@ class CodemanApp {
|
||||
// COD-144: track whether the load painted nothing (empty fetch + no cache).
|
||||
// For that just-created-session case we flush (not discard) queued SSE events.
|
||||
let bufferWasEmpty = false;
|
||||
let cacheResetAndParseMs = 0;
|
||||
try {
|
||||
// Fit terminal to container BEFORE writing any buffer data.
|
||||
// If the browser was resized while viewing another session, the terminal
|
||||
@@ -5669,23 +5747,30 @@ class CodemanApp {
|
||||
// blank and rewrites with fresh data. Skip the cache and write the fresh
|
||||
// buffer once for a single clean transition.
|
||||
const cachedBuffer = this.terminalBufferCache.get(sessionId);
|
||||
let clearedForBusy = false;
|
||||
if (cachedBuffer && !sessionIsBusy && !restoredSnapshot) {
|
||||
let clearedBeforeFresh = false;
|
||||
if (cachedBuffer && !sessionIsBusy && !restoredSnapshot && session?.mode !== 'shell') {
|
||||
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
||||
const cacheReplayStartedAt = performance.now();
|
||||
this._resetTerminalForReplay();
|
||||
await this.chunkedTerminalWrite(cachedBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner);
|
||||
const { parsedAt: cacheParsedAt } = await this.chunkedTerminalWrite(
|
||||
cachedBuffer,
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
bufferLoadOwner
|
||||
);
|
||||
cacheResetAndParseMs = cacheParsedAt - cacheReplayStartedAt;
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
}
|
||||
this.terminal.scrollToBottom();
|
||||
_crashDiag.log('CACHE_DONE');
|
||||
} else if (sessionIsBusy) {
|
||||
// Clear stale content immediately — fresh buffer is being fetched
|
||||
} else if (sessionIsBusy || session?.mode === 'shell') {
|
||||
// Busy sessions have stale caches. Shell sessions deliberately skip even
|
||||
// an idle cache so a changed 1MB tail cannot cause two back-to-back parses.
|
||||
this._resetTerminalForReplay();
|
||||
clearedForBusy = true;
|
||||
_crashDiag.log('CACHE_SKIP_BUSY');
|
||||
clearedBeforeFresh = true;
|
||||
_crashDiag.log(session?.mode === 'shell' ? 'CACHE_SKIP_SHELL' : 'CACHE_SKIP_BUSY');
|
||||
}
|
||||
|
||||
// Give TUI sessions a short chance to redraw after resize before the
|
||||
@@ -5703,26 +5788,29 @@ class CodemanApp {
|
||||
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'fetching');
|
||||
_crashDiag.log('FETCH_START');
|
||||
// The first load OF EACH SESSION this page load requests the full tmux
|
||||
// scrollback (?full=1, COD-47) so history that scrolled off the server's byte
|
||||
// buffer comes back. Later switches to an already-replayed session keep the
|
||||
// fast ?tail= frame path, which is why this is a Set and not a flag: the flag
|
||||
// version gave the full replay to the auto-selected tab and one frame of
|
||||
// history to every other one (issue #205).
|
||||
const useFullHistory = !this._fullHistoryLoaded.has(sessionId);
|
||||
// TUI sessions still get one canonical full replay per page (COD-47/#205).
|
||||
// A shell can retain hundreds of thousands of plain scrollback lines, so
|
||||
// automatically replaying all of them makes tab selection scale with the
|
||||
// entire session. Load its bounded 1MB tail first; the existing truncation
|
||||
// banner action fetches ?full=1 when the user explicitly asks for it.
|
||||
const useFullHistory = session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId);
|
||||
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
|
||||
const fetchStartedAt = performance.now();
|
||||
const res = await fetch(
|
||||
useFullHistory
|
||||
? `/api/sessions/${sessionId}/terminal?full=1`
|
||||
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
|
||||
);
|
||||
const headersReceivedAt = performance.now();
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
}
|
||||
const data = (await res.json())?.data ?? {};
|
||||
const bodyParsedAt = performance.now();
|
||||
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
|
||||
|
||||
let freshResetAndParseMs = 0;
|
||||
if (data.terminalBuffer) {
|
||||
// Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
|
||||
// On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is
|
||||
@@ -5731,10 +5819,11 @@ class CodemanApp {
|
||||
// something other than the cache, so the fetched buffer must be
|
||||
// replayed even when it byte-matches the cache.
|
||||
const needsRewrite =
|
||||
restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer;
|
||||
restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer;
|
||||
if (needsRewrite) {
|
||||
_crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
||||
const replayStartedAt = performance.now();
|
||||
this._resetTerminalForReplay();
|
||||
// Truncation is reported OUT OF BAND (#258). This used to write a grey
|
||||
// "... earlier output truncated ..." line into the
|
||||
@@ -5742,7 +5831,12 @@ class CodemanApp {
|
||||
// cannot be actioned, and is indistinguishable from real CLI output.
|
||||
this._setHistoryTruncation(sessionId, data);
|
||||
// Use chunked write for large buffers to avoid UI jank
|
||||
await this.chunkedTerminalWrite(data.terminalBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner);
|
||||
const { parsedAt: freshParsedAt } = await this.chunkedTerminalWrite(
|
||||
data.terminalBuffer,
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
bufferLoadOwner
|
||||
);
|
||||
freshResetAndParseMs = freshParsedAt - replayStartedAt;
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
@@ -5751,22 +5845,42 @@ class CodemanApp {
|
||||
this.terminal.scrollToBottom();
|
||||
}
|
||||
|
||||
// Update cache (cap at 20 entries)
|
||||
this.terminalBufferCache.set(sessionId, data.terminalBuffer);
|
||||
if (this.terminalBufferCache.size > 20) {
|
||||
// Evict oldest entry (first key in Map iteration order)
|
||||
const oldest = this.terminalBufferCache.keys().next().value;
|
||||
this.terminalBufferCache.delete(oldest);
|
||||
// Shell selection always uses a fresh bounded tail, so retaining its
|
||||
// payload only wastes memory and can evict useful TUI caches.
|
||||
if (session?.mode === 'shell') {
|
||||
this.terminalBufferCache.delete(sessionId);
|
||||
} else {
|
||||
// Update cache (cap at 20 entries)
|
||||
this.terminalBufferCache.set(sessionId, data.terminalBuffer);
|
||||
if (this.terminalBufferCache.size > 20) {
|
||||
// Evict oldest entry (first key in Map iteration order)
|
||||
const oldest = this.terminalBufferCache.keys().next().value;
|
||||
this.terminalBufferCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
} else if (!cachedBuffer) {
|
||||
// No fresh buffer and no cache — clear any stale content
|
||||
this._resetTerminalForReplay();
|
||||
} else if (!cachedBuffer || clearedBeforeFresh) {
|
||||
// Nothing was painted. If this path was not already cleared above,
|
||||
// clear stale content now; either way queued live output must be flushed.
|
||||
if (!clearedBeforeFresh) this._resetTerminalForReplay();
|
||||
bufferWasEmpty = true;
|
||||
}
|
||||
|
||||
const terminalLoadTiming = {
|
||||
trigger: 'session-select',
|
||||
mode: session?.mode || 'unknown',
|
||||
full: useFullHistory,
|
||||
source: data.source || 'unknown',
|
||||
chars: data.terminalBuffer?.length || 0,
|
||||
ttfbMs: headersReceivedAt - fetchStartedAt,
|
||||
bodyAndJsonMs: bodyParsedAt - headersReceivedAt,
|
||||
cacheResetAndParseMs,
|
||||
freshResetAndParseMs,
|
||||
selectToReplayCompleteMs: performance.now() - _selStart,
|
||||
serverTiming: res.headers?.get?.('server-timing') || '',
|
||||
};
|
||||
// Buffer load complete — unblock live SSE writes. chunkedTerminalWrite calls
|
||||
// _finishBufferLoad internally (discarding queued events to prevent duplicate
|
||||
// content); if we skipped the write (cache hit or empty), call it here.
|
||||
// _finishBufferLoad after ordering the fetched snapshot in xterm; if we skipped
|
||||
// the write (cache hit or empty), call it here.
|
||||
// COD-144: when the load painted nothing, FLUSH the queued events instead of
|
||||
// discarding — a new session's prompt arrives only as a queued SSE event.
|
||||
if (this._isLoadingBuffer) {
|
||||
@@ -5895,9 +6009,12 @@ class CodemanApp {
|
||||
if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) {
|
||||
KeyboardHandler.onKeyboardShow();
|
||||
}
|
||||
const selectDoneMs = performance.now() - _selStart;
|
||||
terminalLoadTiming.selectDoneMs = selectDoneMs;
|
||||
this._recordTerminalLoadTiming(terminalLoadTiming);
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
_crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`);
|
||||
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${(performance.now() - _selStart).toFixed(0)}ms`);
|
||||
_crashDiag.log(`SELECT_DONE: ${selectDoneMs.toFixed(0)}ms`);
|
||||
console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${selectDoneMs.toFixed(0)}ms`);
|
||||
} catch (err) {
|
||||
if (this._isLoadingBuffer) this._finishBufferLoad(bufferLoadOwner);
|
||||
this._restoringFlushedState = false;
|
||||
|
||||
@@ -527,6 +527,90 @@ function sortSessionsByActivity(rows) {
|
||||
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
|
||||
}
|
||||
|
||||
// Terminal font stack — the single source for every xterm surface (the main
|
||||
// terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
|
||||
// "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
|
||||
// @font-face in styles.css): browsers fall back PER GLYPH, so Nerd Font
|
||||
// prompt icons (powerline segments, folder/git glyphs from p10k, starship,
|
||||
// oh-my-posh) render even though the text fonts carry no private-use-area
|
||||
// symbols — while all readable text keeps coming from the text fonts.
|
||||
const TERMINAL_FONT_DEFAULT_STACK =
|
||||
'"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, "Symbols Nerd Font Mono", monospace';
|
||||
|
||||
/**
|
||||
* Resolve the xterm fontFamily from the per-device `terminalFontFamily`
|
||||
* setting. A user-set family (or comma-separated list) is PREPENDED to the
|
||||
* built-in stack, never a replacement — the symbols fallback and a final
|
||||
* `monospace` must survive whatever the user types. Blank input yields the
|
||||
* default. Unquoted names that need quoting for CSS (spaces, digits leading,
|
||||
* etc.) are quoted; embedded quotes are stripped rather than escaped, since
|
||||
* a font name cannot contain them anyway.
|
||||
*/
|
||||
function resolveTerminalFontFamily(custom) {
|
||||
const raw = typeof custom === 'string' ? custom.trim() : '';
|
||||
if (!raw) return TERMINAL_FONT_DEFAULT_STACK;
|
||||
const families = raw
|
||||
.split(',')
|
||||
.map((f) => f.trim().replace(/^["']|["']$/g, '').replace(/["']/g, '').trim())
|
||||
.filter(Boolean)
|
||||
// Drop generic families the user may append — the default stack already
|
||||
// ends in `monospace`, and a duplicate earlier entry would shadow the
|
||||
// symbols fallback behind it.
|
||||
.filter((f) => !/^(monospace|serif|sans-serif|system-ui)$/i.test(f))
|
||||
.map((f) => (/^[A-Za-z][A-Za-z0-9-]*$/.test(f) ? f : `"${f}"`));
|
||||
if (!families.length) return TERMINAL_FONT_DEFAULT_STACK;
|
||||
return `${families.join(', ')}, ${TERMINAL_FONT_DEFAULT_STACK}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto Copy (copy-on-select). Pure decision, so every guard below is testable
|
||||
// without a terminal, a clipboard, or a browser.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Upper bound on an AUTO-copied selection.
|
||||
*
|
||||
* A drag that runs off the top of the viewport autoscrolls, so one gesture can
|
||||
* sweep the entire 50k-line scrollback (millions of characters), and writing
|
||||
* that to the clipboard on every mouseup is a real hazard on a phone. Past the
|
||||
* cap the copy is REFUSED rather than truncated (half a selection on the
|
||||
* clipboard is worse than none) and the user is told to press Ctrl+C, which
|
||||
* still copies the whole thing through the explicit path.
|
||||
*/
|
||||
const AUTO_COPY_MAX_CHARS = 1_000_000;
|
||||
|
||||
/**
|
||||
* What an auto-copy attempt should do at the end of a selection gesture.
|
||||
*
|
||||
* `pending` is set by xterm's onSelectionChange and cleared on every flush;
|
||||
* `lastCopied` is the text this surface auto-copied last. Either one alone is
|
||||
* wrong, which is why both are here:
|
||||
*
|
||||
* - onSelectionChange does not reliably fire BEFORE the mouseup that ends the
|
||||
* drag (xterm fires it from its own document-level mouseup handler, and
|
||||
* listener order between the two is registration order, not something this
|
||||
* code controls). Gating on `pending` alone would silently drop the first
|
||||
* copy of a drag-selection.
|
||||
* - Gating on `text !== lastCopied` alone drops a deliberate re-selection of
|
||||
* the same text after the user copied something else in between, and it
|
||||
* would let any unrelated mouseup on the page re-copy a stale selection.
|
||||
*
|
||||
* So: a genuine selection change (`pending`) always copies, and otherwise only
|
||||
* text that differs from the last auto-copy does.
|
||||
*
|
||||
* @param {{enabled?: boolean, text?: string, lastCopied?: string, pending?: boolean}} params
|
||||
* @returns {'copy'|'skip'|'too-large'}
|
||||
*/
|
||||
function decideAutoCopy({ enabled, text, lastCopied, pending } = {}) {
|
||||
if (!enabled) return 'skip';
|
||||
// Whitespace-only is what a drag across blank cells produces; putting a wall
|
||||
// of spaces on the clipboard is never what the gesture meant.
|
||||
if (typeof text !== 'string' || !text.trim()) return 'skip';
|
||||
if (!pending && text === lastCopied) return 'skip';
|
||||
if (text.length > AUTO_COPY_MAX_CHARS) return 'too-large';
|
||||
return 'copy';
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||||
@@ -560,6 +644,14 @@ if (typeof window !== 'undefined') {
|
||||
compare: compareSessionActivity,
|
||||
sort: sortSessionsByActivity,
|
||||
};
|
||||
window.CodemanAutoCopy = {
|
||||
decide: decideAutoCopy,
|
||||
MAX_CHARS: AUTO_COPY_MAX_CHARS,
|
||||
};
|
||||
window.CodemanTerminalFont = {
|
||||
DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
|
||||
resolve: resolveTerminalFontFamily,
|
||||
};
|
||||
}
|
||||
|
||||
// Scheduler API — prioritize terminal writes over background UI updates.
|
||||
@@ -1035,7 +1127,112 @@ function previewsInFileViewer(filePath) {
|
||||
return FILE_PREVIEW_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The LOGICAL line a terminal row belongs to — the rows it spans, its text as one
|
||||
* string, and a two-way map between that string and terminal cells.
|
||||
*
|
||||
* One definition, two consumers: the link provider matches its patterns over this
|
||||
* text (`registerFilePathLinkProvider`) and touch selection measures words and
|
||||
* whole lines with it (`_touchSelectionLogicalLine`). They MUST agree — a link that
|
||||
* spans a wrap and a "Line" that stops at the screen edge is the same bug twice.
|
||||
*
|
||||
* Two kinds of continuation, and handling only the first is not enough:
|
||||
*
|
||||
* 1. **Soft wrap** — the emulator ran out of columns and flags the next row
|
||||
* `isWrapped`. It inserts nothing, so the row's text is joined verbatim.
|
||||
* 2. **Hard wrap** — the program wrapped the text itself and emitted a real
|
||||
* newline, so nothing is flagged. A row that fills the last column is taken
|
||||
* as continuing into the next; that is the only trace a hard wrap leaves.
|
||||
*
|
||||
* ⚠️ A hard-wrapped continuation may carry the program's own INDENT, and joining
|
||||
* that verbatim puts whitespace in the middle of the token being stitched. That is
|
||||
* why an agent's numbered list —
|
||||
*
|
||||
* 1. https://github.com/users/someone/packages/container/p
|
||||
* ackage/thing
|
||||
*
|
||||
* — opened only `…/container/p`: the URL pattern stops at the space the indent
|
||||
* contributed. So the leading whitespace of a HARD continuation is dropped, and
|
||||
* `colStart` on that segment records how much, keeping the cell mapping exact. A
|
||||
* soft continuation keeps its leading whitespace, since the terminal never adds
|
||||
* any and it is therefore real content.
|
||||
*
|
||||
* ⚠️ Only the final row is trimmed. Continuation rows are read UNTRIMMED so each
|
||||
* contributes exactly `cols` cells; trimming one would shift every later offset.
|
||||
*
|
||||
* The row span is bounded by `maxRows` (12 by default): this runs on every hover,
|
||||
* and a screenful of full-width output would otherwise re-scan the viewport each
|
||||
* time.
|
||||
*
|
||||
* @param {{getLine: (row: number) => any, length: number}} buffer xterm buffer.
|
||||
* @param {number} row 0-based ABSOLUTE buffer row to expand around.
|
||||
* @param {number} cols Terminal width.
|
||||
* @param {number} [maxRows] Row-span bound.
|
||||
* @returns {{startRow: number, endRow: number, text: string,
|
||||
* offsetToCell: (offset: number) => {row: number, col: number},
|
||||
* cellToOffset: (row: number, col: number) => number} | null}
|
||||
* 0-based rows and columns throughout; null when the row does not exist.
|
||||
*/
|
||||
function terminalLogicalLine(buffer, row, cols, maxRows) {
|
||||
if (!buffer || typeof buffer.getLine !== 'function') return null;
|
||||
const width = Math.max(1, cols || 1);
|
||||
const bound = Math.max(1, maxRows || 12);
|
||||
const lineAt = (r) => (r >= 0 ? buffer.getLine(r) : undefined);
|
||||
if (!lineAt(row)) return null;
|
||||
|
||||
const continuesPrevious = (r) => {
|
||||
if (r <= 0) return false;
|
||||
if (lineAt(r)?.isWrapped) return true;
|
||||
const prev = lineAt(r - 1);
|
||||
return !!prev && (prev.translateToString(true) || '').length >= width;
|
||||
};
|
||||
|
||||
let startRow = row;
|
||||
while (startRow > 0 && row - startRow < bound && continuesPrevious(startRow)) startRow--;
|
||||
let endRow = row;
|
||||
const length = Number.isFinite(buffer.length) ? buffer.length : endRow + 1;
|
||||
while (endRow + 1 < length && endRow - startRow < bound && continuesPrevious(endRow + 1)) endRow++;
|
||||
|
||||
const segments = [];
|
||||
let text = '';
|
||||
for (let r = startRow; r <= endRow; r++) {
|
||||
const line = lineAt(r);
|
||||
if (!line) break;
|
||||
let rowText = line.translateToString(r === endRow) || '';
|
||||
let colStart = 0;
|
||||
if (r > startRow && !line.isWrapped) {
|
||||
const indent = rowText.length - rowText.replace(/^\s+/, '').length;
|
||||
colStart = indent;
|
||||
rowText = rowText.slice(indent);
|
||||
}
|
||||
segments.push({ row: r, textStart: text.length, colStart, length: rowText.length });
|
||||
text += rowText;
|
||||
}
|
||||
|
||||
const offsetToCell = (offset) => {
|
||||
for (let i = segments.length - 1; i >= 0; i--) {
|
||||
const seg = segments[i];
|
||||
if (offset >= seg.textStart || i === 0) {
|
||||
return { row: seg.row, col: seg.colStart + (offset - seg.textStart) };
|
||||
}
|
||||
}
|
||||
return { row: startRow, col: offset };
|
||||
};
|
||||
|
||||
const cellToOffset = (targetRow, targetCol) => {
|
||||
for (const seg of segments) {
|
||||
if (seg.row !== targetRow) continue;
|
||||
return seg.textStart + Math.max(0, targetCol - seg.colStart);
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
return { startRow, endRow, text, offsetToCell, cellToOffset };
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
||||
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
|
||||
window.CodemanTerminalLines = { terminalLogicalLine };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 Ryan L McIntyre
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Binary file not shown.
@@ -326,11 +326,20 @@
|
||||
|
||||
// Input settings
|
||||
Input: '输入',
|
||||
Font: '字体',
|
||||
'Terminal font': '终端字体',
|
||||
'Prepended to the built-in stack, so fallbacks (including bundled Nerd Font symbols) keep working. Must be installed on this device. Leave empty for the default.':
|
||||
'置于内置字体栈之前,回退字体(包括内置的 Nerd Font 图标)仍然生效。需已安装在本设备上。留空使用默认值。',
|
||||
'Local Echo': '本地回显',
|
||||
'CJK Input': '中日韩输入',
|
||||
'Extended Keyboard Bar': '扩展键盘栏',
|
||||
'Gesture Control (beta)': '手势控制(测试版)',
|
||||
'Wheel Scrolls Local History': '滚轮滚动本地历史',
|
||||
'Auto Copy Selection': '自动复制选中内容',
|
||||
'Selection & clipboard': '选中与剪贴板',
|
||||
'Auto Copy: selection copied': '自动复制:已复制选中内容',
|
||||
'Auto Copy failed: the browser blocked clipboard access': '自动复制失败:浏览器阻止了剪贴板访问',
|
||||
'Selection too large to copy automatically. Press Ctrl+C.': '选中内容过大,无法自动复制。请按 Ctrl+C。',
|
||||
'Instant typing feedback with local echo': '通过本地回显即时显示输入',
|
||||
'Dedicated IME input field for CJK languages': '为中日韩语言提供专用输入法文本框',
|
||||
'Extra keys: Tab, Esc, arrows, Ctrl+O': '附加按键:Tab、Esc、方向键、Ctrl+O',
|
||||
@@ -500,6 +509,11 @@
|
||||
'Respawn Blocked': '重生已阻止',
|
||||
'Task Complete': '任务完成',
|
||||
'Copied to clipboard': '已复制到剪贴板',
|
||||
// Terminal touch-selection bar (long-press to select). The bar is a sibling of
|
||||
// `.xterm`, not a descendant, so SKIP_SELECTOR does not cover it and these apply.
|
||||
Copy: '复制',
|
||||
Line: '整行',
|
||||
'Clear selection': '清除选择',
|
||||
'Failed to copy': '复制失败',
|
||||
'Checking…': '正在检查…',
|
||||
'Starting…': '正在启动…',
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
<!-- Preload critical resources — lets browser discover these during HTML parse
|
||||
instead of waiting until <script> tags at bottom-of-body are reached. -->
|
||||
<link rel="preload" href="vendor/xterm.min.js" as="script">
|
||||
<!-- Symbols font before the terminal first paints — a late-loading icon font
|
||||
leaves tofu in xterm's glyph atlas until something forces a re-render. -->
|
||||
<link rel="preload" href="fonts/symbols-nerd-font-mono.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="constants.js" as="script">
|
||||
<link rel="preload" href="app.js" as="script">
|
||||
<!-- Self-hosted xterm.js — eliminates CDN DNS/TLS latency (~100ms).
|
||||
@@ -1624,6 +1627,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Selection & clipboard</h4><span class="set-scope">device</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="auto copy selection clipboard highlight copy on select mouse">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Auto Copy Selection</span>
|
||||
<span class="set-row-desc">Put highlighted terminal text on the clipboard as soon as you finish selecting it, with mouse, double-click or long-press. Ctrl+C still copies on demand, and nothing outside the terminal is copied.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAutoCopySelection"><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Font</h4><span class="set-scope">device</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row has-field" data-search="terminal font family nerd custom typeface">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Terminal font</span>
|
||||
<span class="set-row-desc">Prepended to the built-in stack, so fallbacks (including bundled Nerd Font symbols) keep working. Must be installed on this device. Leave empty for the default.</span>
|
||||
</div>
|
||||
<input type="text" id="appSettingsTerminalFont" class="set-input" placeholder='e.g. JetBrainsMono Nerd Font'>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Scrolling & rendering</h4></div>
|
||||
<div class="set-group-body">
|
||||
|
||||
+27
-25
@@ -27,8 +27,8 @@
|
||||
*
|
||||
* Solution: outside composition, flush is DEBOUNCED (200ms). The entire
|
||||
* delete→reinsert cycle collapses into one flush of the final textarea value.
|
||||
* Keyboard typing of single printable characters still goes through the
|
||||
* keydown handler (immediate, no debounce).
|
||||
* Physical-keyboard commits are flushed immediately after the input event
|
||||
* exposes the final browser/IME text; keydown never guesses that text.
|
||||
*
|
||||
* ## Phantom character for Android backspace
|
||||
*
|
||||
@@ -56,8 +56,7 @@ const CjkInput = (() => {
|
||||
let _compositionFlushTimer = null;
|
||||
let _dictationActive = false;
|
||||
let _dictationDecayTimer = null;
|
||||
let _keydownSentAt = 0;
|
||||
let _keydownSentText = '';
|
||||
let _printableKeydownAt = null;
|
||||
const _listeners = {};
|
||||
|
||||
const PHANTOM = '';
|
||||
@@ -197,6 +196,7 @@ const CjkInput = (() => {
|
||||
|
||||
_send = send;
|
||||
_composing = false;
|
||||
_printableKeydownAt = null;
|
||||
_flushTimer = null;
|
||||
_textarea = document.getElementById('cjkInput');
|
||||
if (!_textarea) return this;
|
||||
@@ -234,6 +234,7 @@ const CjkInput = (() => {
|
||||
};
|
||||
_listeners.blur = () => {
|
||||
_t(`blur composing=${_composing} ${_vdesc(_textarea.value)}`);
|
||||
_printableKeydownAt = null;
|
||||
// Keep cjkActive while CJK input is visible — iOS dictation and system
|
||||
// UI may steal focus temporarily, and clearing the flag during that
|
||||
// window lets xterm's onData process duplicated input.
|
||||
@@ -253,6 +254,7 @@ const CjkInput = (() => {
|
||||
_listeners.compositionstart = () => {
|
||||
_t(`compstart ${_vdesc(_textarea.value)}`);
|
||||
_composing = true;
|
||||
_printableKeydownAt = null;
|
||||
_cancelDebouncedFlush();
|
||||
// Leave textarea.value untouched — programmatic changes during
|
||||
// compositionstart cancel the IME composition on iOS Safari.
|
||||
@@ -277,6 +279,7 @@ const CjkInput = (() => {
|
||||
// ── Keydown: special keys work REGARDLESS of composition state ──
|
||||
_listeners.keydown = (e) => {
|
||||
_t(`keydown ${_kdesc(e.key)} kc=${e.keyCode} ic=${e.isComposing} c=${_composing}`);
|
||||
_printableKeydownAt = null;
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
_composing = false;
|
||||
@@ -325,16 +328,11 @@ const CjkInput = (() => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Single printable character: send immediately to PTY.
|
||||
// Third-party IMEs on iOS may ignore preventDefault, so the char
|
||||
// still enters the textarea and fires an input event — _keydownSentAt
|
||||
// tells the input handler to skip that echo.
|
||||
// A printable KeyboardEvent.key is the physical key, not necessarily
|
||||
// the committed text. Let the browser/IME produce the input event so
|
||||
// full-width punctuation and other layout transforms are preserved.
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send(e.key);
|
||||
_keydownSentAt = performance.now();
|
||||
_keydownSentText = e.key;
|
||||
_resetToPhantom();
|
||||
_printableKeydownAt = performance.now();
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -343,6 +341,8 @@ const CjkInput = (() => {
|
||||
// ── Input event: primary path for virtual keyboards + dictation ──
|
||||
_listeners.input = (e) => {
|
||||
_t(`input ${e.inputType || '?'} ic=${e.isComposing} c=${_composing} ${_vdesc(_textarea.value)}`);
|
||||
const printableKeydownAt = _printableKeydownAt;
|
||||
_printableKeydownAt = null;
|
||||
// ── Stuck-composition recovery ──
|
||||
// Some IMEs (WeChat/Sogou keyboards) fire compositionstart without a
|
||||
// matching compositionend. A stale _composing=true blocks every flush
|
||||
@@ -388,18 +388,18 @@ const CjkInput = (() => {
|
||||
|
||||
if (_composing) return;
|
||||
|
||||
// Keydown handler already sent this character — clear the textarea
|
||||
// echo that the IME inserted despite preventDefault. Content-checked:
|
||||
// only a value matching the sent char is an echo. Anything else (e.g.
|
||||
// an IME committing CJK text right after a keydown-sent char) is real
|
||||
// input and must flow through to the debounced flush, not be dropped.
|
||||
if (performance.now() - _keydownSentAt < 100) {
|
||||
const cur = _strip(_textarea.value);
|
||||
if (cur === '' || cur === _keydownSentText) {
|
||||
_t('echo-drop');
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
// A recent physical printable key makes this insertText a keyboard
|
||||
// commit, so keep the old zero-latency path. Send the textarea's final
|
||||
// Unicode value, never KeyboardEvent.key, because the IME may have
|
||||
// transformed punctuation or the active layout may differ.
|
||||
if (
|
||||
e.inputType === 'insertText' &&
|
||||
printableKeydownAt !== null &&
|
||||
performance.now() - printableKeydownAt < 100
|
||||
) {
|
||||
_cancelDebouncedFlush();
|
||||
_flush();
|
||||
return;
|
||||
}
|
||||
|
||||
// Outside composition: keyboard typing or voice dictation.
|
||||
@@ -425,6 +425,7 @@ const CjkInput = (() => {
|
||||
clearTimeout(_compositionFlushTimer);
|
||||
_compositionFlushTimer = null;
|
||||
_composing = false;
|
||||
_printableKeydownAt = null;
|
||||
_resetToPhantom();
|
||||
},
|
||||
|
||||
@@ -446,6 +447,7 @@ const CjkInput = (() => {
|
||||
}
|
||||
window.cjkActive = false;
|
||||
_composing = false;
|
||||
_printableKeydownAt = null;
|
||||
for (const key of Object.keys(_listeners)) delete _listeners[key];
|
||||
_initialized = false;
|
||||
},
|
||||
|
||||
@@ -573,6 +573,42 @@ const KeyboardHandler = {
|
||||
* space below the last row. After fitAddon.fit(), measure the gap and
|
||||
* reduce padding by that amount so the terminal sits flush against the bars.
|
||||
*/
|
||||
/**
|
||||
* Combined height of the fixed bars that overlay the terminal's bottom edge.
|
||||
*
|
||||
* On phones the toolbar and the accessory bar are `position: fixed`, so they
|
||||
* occupy no layout space of their own — `main`'s padding-bottom is the only
|
||||
* thing reserving room for them, and any pixel taken out of it is a pixel of
|
||||
* terminal painted underneath them.
|
||||
*/
|
||||
_fixedBottomBarsHeight() {
|
||||
let px = 0;
|
||||
for (const selector of ['.toolbar', '.keyboard-accessory-bar', '#cjkInput.cjk-input-visible']) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) continue;
|
||||
const style = window.getComputedStyle?.(el);
|
||||
if (style && (style.display === 'none' || style.visibility === 'hidden')) continue;
|
||||
px += el.offsetHeight || 0;
|
||||
}
|
||||
return px;
|
||||
},
|
||||
|
||||
/**
|
||||
* Reclaim sub-row slack at the bottom of the terminal — but never the space the
|
||||
* fixed bars stand in.
|
||||
*
|
||||
* Shrinking the padding by the whole slack pulled the terminal's bottom edge
|
||||
* DOWN under those bars, and the row the following re-fit then gained was
|
||||
* painted behind them: on a long wrapped prompt the last line was clipped by
|
||||
* the accessory bar, i.e. the bottom half of the text being typed. The floor is
|
||||
* now the bars' MEASURED height, so a device where the hard-coded 84px
|
||||
* over-reserves still reclaims the difference, while one that genuinely needs
|
||||
* it keeps every pixel.
|
||||
*
|
||||
* ⚠️ The floor can only ever prevent a shrink, never cause a grow
|
||||
* (`Math.min(currentPadding, …)`): a measured height LARGER than the current
|
||||
* padding makes this a no-op rather than silently resizing the terminal.
|
||||
*/
|
||||
_shrinkPaddingToFit() {
|
||||
try {
|
||||
const container = document.getElementById('terminalContainer');
|
||||
@@ -583,7 +619,8 @@ const KeyboardHandler = {
|
||||
const gap = container.clientHeight - app.terminal.rows * cellH;
|
||||
if (gap > 0 && gap < cellH) {
|
||||
const currentPadding = parseInt(main.style.paddingBottom) || 0;
|
||||
main.style.paddingBottom = Math.max(0, currentPadding - gap) + 'px';
|
||||
const floor = Math.min(currentPadding, this._fixedBottomBarsHeight());
|
||||
main.style.paddingBottom = Math.max(floor, currentPadding - gap) + 'px';
|
||||
if (app.fitAddon)
|
||||
try {
|
||||
app.fitAddon.fit();
|
||||
|
||||
@@ -2279,7 +2279,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const terminal = new Terminal({
|
||||
theme: { ...window.codemanCurrentXtermTheme() },
|
||||
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
||||
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
|
||||
fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily),
|
||||
fontSize: 12,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: true,
|
||||
|
||||
@@ -41,8 +41,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
_onHookElicitationComplete(data) {
|
||||
// Question answered in the terminal: clear the action alert without
|
||||
// waiting for `stop` (the turn may keep running for a long time).
|
||||
// ⚠️ BOTH action kinds, matching the server's APPROVAL_RESOLVING_EVENTS,
|
||||
// which resolves a session's pending item whatever its kind. An
|
||||
// AskUserQuestion dialog arrives as `permission_prompt` (only MCP
|
||||
// elicitation is `elicitation_dialog`), so clearing just the elicitation
|
||||
// entry left the red alert armed on exactly the dialog these events are
|
||||
// most often about. Normally the server's `approval:resolved` broadcast
|
||||
// clears it too; this is the path that still works when the store holds no
|
||||
// item for the session (restart, superseded).
|
||||
if (data.sessionId) {
|
||||
this.clearPendingHooks(data.sessionId, 'elicitation_dialog');
|
||||
this.clearPendingHooks(data.sessionId, 'permission_prompt');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -381,6 +390,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
|
||||
this.loadTunnelStatus();
|
||||
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
// Auto Copy (copy-on-select): per-device, default OFF everywhere. It quietly
|
||||
// overwrites the system clipboard on a gesture the user may have meant only as
|
||||
// a way to read, so it is opt-in rather than a default anyone has to discover.
|
||||
document.getElementById('appSettingsAutoCopySelection').checked = settings.autoCopySelection === true;
|
||||
document.getElementById('appSettingsTerminalFont').value = settings.terminalFontFamily || '';
|
||||
document.getElementById('appSettingsTerminalWheelLocal').checked =
|
||||
settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false;
|
||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
|
||||
@@ -2006,6 +2020,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
|
||||
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
|
||||
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
||||
autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,
|
||||
terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(),
|
||||
terminalWheelLocalScrollback: document.getElementById('appSettingsTerminalWheelLocal').checked,
|
||||
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
|
||||
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
|
||||
@@ -2052,6 +2068,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Save to localStorage
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
this._updateLocalEchoState();
|
||||
this.applyTerminalFontFamily?.(settings.terminalFontFamily);
|
||||
|
||||
// A real OFF→ON flip of the WebGL toggle retires the GPU-stall auto-fallback
|
||||
// marker so the next reload actually re-tries WebGL. Only the transition
|
||||
@@ -2200,6 +2217,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
showFileViewerButton: _fvb,
|
||||
webglRendererEnabled: _wgl,
|
||||
terminalWheelLocalScrollback: _twls,
|
||||
// Copy-on-select. Per-device (clipboard access differs by device and by
|
||||
// origin: the plain-HTTP LAN install has no navigator.clipboard at all)
|
||||
// and absent from SettingsUpdateSchema (.strict()), so sending it would
|
||||
// 400 the whole settings PUT.
|
||||
autoCopySelection: _acs,
|
||||
// Per-device by nature (the font must exist on the device) and absent
|
||||
// from SettingsUpdateSchema (.strict()) — sending it would 400 the PUT.
|
||||
terminalFontFamily: _tff,
|
||||
// Per-device header/toolbar button toggles — client-only, and absent from
|
||||
// SettingsUpdateSchema (.strict()), so sending them would 400 the PUT.
|
||||
showSessionButton: _ssb,
|
||||
@@ -2874,8 +2899,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'sessionListLayout', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'terminalFontFamily',
|
||||
'language',
|
||||
'terminalWheelLocalScrollback',
|
||||
'autoCopySelection',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||
'showTabDetachButton',
|
||||
'mobileOverviewEnabled',
|
||||
|
||||
@@ -16,6 +16,19 @@
|
||||
font-weight: 400 700;
|
||||
src: url('fonts/jetbrains-mono-variable.woff2') format('woff2');
|
||||
}
|
||||
/* Icons-only per-glyph fallback for the terminal (Symbols Nerd Font Mono, MIT,
|
||||
fonts/LICENSE-nerd-fonts.txt). Sits BEHIND the text fonts in the xterm stack
|
||||
(constants.js: TERMINAL_FONT_DEFAULT_STACK), so it only ever supplies the
|
||||
private-use-area glyphs shell prompts draw (powerline, p10k/starship folder
|
||||
and git icons) — text rendering is untouched. `block` display, not `swap`:
|
||||
there is no fallback that CAN render these glyphs, so swapping in tofu first
|
||||
would poison xterm's glyph atlas until the next re-render. */
|
||||
@font-face {
|
||||
font-family: 'Symbols Nerd Font Mono';
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
src: url('fonts/symbols-nerd-font-mono.woff2') format('woff2');
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Dark is the safe fallback. Light skins override this so native selects,
|
||||
@@ -3395,6 +3408,57 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
-webkit-touch-callout: none !important;
|
||||
}
|
||||
|
||||
/* Touch text-selection bar (long-press → select → Copy).
|
||||
Lives in styles.css, NOT mobile.css: the gesture is touch-driven, not
|
||||
width-driven, and mobile.css is media-gated to ≤1023px — a touch tablet in
|
||||
landscape would get the gesture with no bar to copy from.
|
||||
Built in JS (index.html is read once at server start, so markup added there
|
||||
would need a restart to appear). z-index 900 sits above terminal content and
|
||||
the local-echo overlay (7) and deliberately BELOW floating agent windows
|
||||
(1000), so it can never cover their controls. */
|
||||
.term-select-bar {
|
||||
position: absolute;
|
||||
z-index: 900;
|
||||
display: none;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.term-select-bar.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.term-select-btn {
|
||||
min-height: 38px;
|
||||
min-width: 46px;
|
||||
padding: 0 0.7rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
/* The bar is the one place in the terminal subtree a tap must land on a
|
||||
control rather than a cell, so it opts out of the gesture styles above. */
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.term-select-btn:active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.term-select-btn--close {
|
||||
min-width: 38px;
|
||||
padding: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Welcome Overlay */
|
||||
.welcome-overlay {
|
||||
position: absolute;
|
||||
|
||||
+781
-185
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* @fileoverview Repository-status check (App Settings → Updates → "Repository
|
||||
* status").
|
||||
*
|
||||
* INFORMATIONAL companion to the release-tag self-updater (`self-update.ts`).
|
||||
* Where the updater answers "is there a newer published release tag, and do you
|
||||
* want to `git checkout` it", this module answers "where does my local checkout
|
||||
* sit relative to the upstream project AND my own fork" — by commit ahead/behind
|
||||
* count plus a short list of the incoming commits.
|
||||
*
|
||||
* This needs the actual commits locally, so it does a READ-ONLY `git fetch` of
|
||||
* the compared ref per remote (updates only remote-tracking refs under `.git`,
|
||||
* never the working tree), then `git rev-list`/`git log`. Works uniformly for
|
||||
* GitHub and non-GitHub remotes (e.g. Bitbucket) — no release tags required.
|
||||
*
|
||||
* Remote selection (generalizable): by default the union of `origin` and the
|
||||
* current branch's `@{upstream}` tracking remote, deduped. Override with the
|
||||
* `CODEMAN_UPDATE_REMOTES` env var (comma-separated remote names) for any other
|
||||
* layout (e.g. the common `origin`=fork / `upstream`=canonical convention).
|
||||
*
|
||||
* Split PURE helpers (parsing + remote-set/role/compare-ref decisions, unit
|
||||
* tested) from the IO wrapper `getRepositoryStatus()` (touches git).
|
||||
*
|
||||
* PERFORMANCE: every git invocation is ASYNC (`execFile`), never `execFileSync`
|
||||
* — the fetch path can spend `2 × FETCH_TIMEOUT_MS` per remote on the network,
|
||||
* and a synchronous version froze the whole event loop (SSE, PTY streaming) for
|
||||
* up to a minute per request. The computation is additionally single-flight
|
||||
* with a short TTL cache: concurrent requests share one in-flight promise, and
|
||||
* a fresh-enough result is served without spawning git at all.
|
||||
*
|
||||
* SECURITY: remote URLs (and git stderr echoing them) can embed
|
||||
* `scheme://user:token@host` credentials, so every `url`/`error` field is
|
||||
* passed through `redactGitCredentials()` (shared with `git-clone.ts`) before
|
||||
* it reaches a client.
|
||||
*
|
||||
* @module web/repo-status
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { promisify } from 'node:util';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import { gitNonInteractiveEnv, redactGitCredentials } from '../git-clone.js';
|
||||
import { getInstallInfo } from './self-update.js';
|
||||
import type { RepoIncomingCommit, RepoRemoteRole, RepoRemoteStatus, RepositoryStatusResult } from '../types/update.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version: APP_VERSION } = require('../../package.json') as { version: string };
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Network/git timeout for the fetch path (longer than EXEC_TIMEOUT_MS — hits network). */
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
/** Max incoming commit subjects to list per remote. */
|
||||
const MAX_INCOMING = 10;
|
||||
/** Fresh-enough window for a cached status — repeated UI polls reuse it instead of re-running git. */
|
||||
const STATUS_CACHE_TTL_MS = 45_000;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PURE helpers (unit tested, no IO)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse `git rev-list --left-right --count HEAD...<ref>` output.
|
||||
* Git prints two tab-separated counts: LEFT (commits in HEAD not in ref → ahead)
|
||||
* and RIGHT (commits in ref not in HEAD → behind). Returns null on malformed input.
|
||||
*/
|
||||
export function parseAheadBehind(out: string): { ahead: number; behind: number } | null {
|
||||
const m = out.trim().match(/^(\d+)\s+(\d+)$/);
|
||||
if (!m) return null;
|
||||
return { ahead: parseInt(m[1], 10), behind: parseInt(m[2], 10) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `git log --oneline` output into commits. Each non-empty line is
|
||||
* `<sha> <subject>`; the first whitespace-delimited token is the SHA.
|
||||
*/
|
||||
export function parseLogLines(out: string): RepoIncomingCommit[] {
|
||||
const commits: RepoIncomingCommit[] = [];
|
||||
for (const line of out.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const idx = trimmed.indexOf(' ');
|
||||
if (idx === -1) {
|
||||
commits.push({ sha: trimmed, subject: '' });
|
||||
} else {
|
||||
commits.push({ sha: trimmed.slice(0, idx), subject: trimmed.slice(idx + 1).trim() });
|
||||
}
|
||||
}
|
||||
return commits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the default branch name from `git ls-remote --symref <remote> HEAD`
|
||||
* output (a line like `ref: refs/heads/master\tHEAD`). Returns null if absent.
|
||||
*
|
||||
* SECURITY: this parses UNTRUSTED remote output, and the result flows into a
|
||||
* later `git fetch <remote> <branch>` positional. The capture is constrained to
|
||||
* start with an alphanumeric (no leading `-`) so a hostile remote can't return
|
||||
* `ref: refs/heads/--upload-pack=<cmd>\tHEAD` and smuggle an argv flag (RCE) —
|
||||
* see `isSafeGitPositional` for the defense-in-depth re-check at the call site.
|
||||
*/
|
||||
export function parseSymrefDefaultBranch(out: string): string | null {
|
||||
const m = out.match(/^ref:\s+refs\/heads\/([A-Za-z0-9_./][A-Za-z0-9_./+-]*)\s+HEAD$/m);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a value that would be unsafe as a git positional argument (remote name
|
||||
* or branch). A leading `-` lets untrusted ls-remote/symref output or a stray
|
||||
* `CODEMAN_UPDATE_REMOTES` entry inject an option (e.g. `--upload-pack=<cmd>`)
|
||||
* into a subsequent `git fetch`. Empty values are rejected too.
|
||||
*/
|
||||
export function isSafeGitPositional(value: string | null | undefined): value is string {
|
||||
return typeof value === 'string' && value.length > 0 && !value.startsWith('-');
|
||||
}
|
||||
|
||||
/** Split a comma-separated env value into trimmed, non-empty names. */
|
||||
export function parseRemotesEnv(value: string | null | undefined): string[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which remote NAMES the status view covers, in display order.
|
||||
*
|
||||
* - If `envRemotes` is non-empty, use exactly those that actually exist (order
|
||||
* preserved). This is the full-control escape hatch.
|
||||
* - Otherwise the union of `origin` (if it exists) and the tracking remote (if
|
||||
* any), deduped. The tracking remote is listed first when it isn't `origin`,
|
||||
* so "your fork" leads and "upstream" follows.
|
||||
*/
|
||||
export function resolveRemoteSet(opts: {
|
||||
existingRemotes: string[];
|
||||
trackingRemote: string | null;
|
||||
envRemotes: string[];
|
||||
}): string[] {
|
||||
const exists = new Set(opts.existingRemotes);
|
||||
if (opts.envRemotes.length > 0) {
|
||||
return dedupe(opts.envRemotes.filter((n) => exists.has(n)));
|
||||
}
|
||||
const out: string[] = [];
|
||||
if (opts.trackingRemote && exists.has(opts.trackingRemote) && opts.trackingRemote !== 'origin') {
|
||||
out.push(opts.trackingRemote);
|
||||
}
|
||||
if (exists.has('origin')) out.push('origin');
|
||||
if (opts.trackingRemote && exists.has(opts.trackingRemote)) out.push(opts.trackingRemote);
|
||||
return dedupe(out);
|
||||
}
|
||||
|
||||
/** Classify a remote's role relative to the tracking remote. */
|
||||
export function roleForRemote(name: string, trackingRemote: string | null): RepoRemoteRole {
|
||||
if (trackingRemote && name === trackingRemote) return 'tracking';
|
||||
if (name === 'origin' || name === 'upstream') return 'upstream';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function dedupe(names: string[]): string[] {
|
||||
return [...new Set(names)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the remote name from an `@{upstream}` short ref like `origin/master`.
|
||||
*
|
||||
* A ref with NO slash is a LOCAL-branch upstream (`git branch -u otherbranch`):
|
||||
* `rev-parse --abbrev-ref @{upstream}` prints just the branch name, and there
|
||||
* is no remote in it — the old `slice(0, indexOf('/'))` became `slice(0, -1)`
|
||||
* there and yielded garbage like `maste`. Returns null for that case (treated
|
||||
* as "no tracking remote"), and for empty/degenerate refs.
|
||||
*/
|
||||
export function parseTrackingRemote(trackingRef: string | null | undefined): string | null {
|
||||
if (!trackingRef) return null;
|
||||
const idx = trackingRef.indexOf('/');
|
||||
if (idx <= 0) return null;
|
||||
return trackingRef.slice(0, idx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact embedded `scheme://user:secret@host` credentials from the fields of
|
||||
* one remote's status that can carry them: the remote URL itself, and the
|
||||
* error string (which may include git stderr echoing that URL back).
|
||||
*/
|
||||
export function redactRemoteStatus(status: RepoRemoteStatus): RepoRemoteStatus {
|
||||
const out: RepoRemoteStatus = { ...status, url: redactGitCredentials(status.url) };
|
||||
if (out.error !== undefined) out.error = redactGitCredentials(out.error);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-flight TTL cache around an async compute: concurrent callers await
|
||||
* the SAME in-flight promise, and a result younger than `ttlMs` is returned
|
||||
* without recomputing. A rejected compute is never cached, so the next call
|
||||
* retries. `now` is injectable for deterministic TTL tests.
|
||||
*/
|
||||
export function createSingleFlightCache<T>(
|
||||
ttlMs: number,
|
||||
compute: () => Promise<T>
|
||||
): { get(now?: number): Promise<T> } {
|
||||
let cachedAt = 0;
|
||||
let cachedValue: T | undefined;
|
||||
let hasValue = false;
|
||||
let inFlight: Promise<T> | null = null;
|
||||
return {
|
||||
get(now = Date.now()): Promise<T> {
|
||||
if (hasValue && now - cachedAt < ttlMs) return Promise.resolve(cachedValue as T);
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = compute().then(
|
||||
(value) => {
|
||||
cachedValue = value;
|
||||
hasValue = true;
|
||||
cachedAt = Date.now();
|
||||
inFlight = null;
|
||||
return value;
|
||||
},
|
||||
(err: unknown) => {
|
||||
inFlight = null;
|
||||
throw err;
|
||||
}
|
||||
);
|
||||
return inFlight;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// IO wrapper
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface GitResult {
|
||||
ok: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run git non-interactively (no credential or SSH prompts — a missing key/cred
|
||||
* fails fast instead of hanging; env via the shared `gitNonInteractiveEnv()`,
|
||||
* which also closes the askpass/GCM/DISPLAY prompt paths). ASYNC on purpose:
|
||||
* the fetch path hits the network for up to `FETCH_TIMEOUT_MS`, and a sync
|
||||
* spawn would block the event loop for the whole wait.
|
||||
*/
|
||||
async function runGit(args: string[], cwd: string, timeout = EXEC_TIMEOUT_MS): Promise<GitResult> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
timeout,
|
||||
env: gitNonInteractiveEnv(),
|
||||
});
|
||||
return { ok: true, stdout: stdout.trim(), stderr: '' };
|
||||
} catch (err: unknown) {
|
||||
const e = err as { stdout?: Buffer | string; stderr?: Buffer | string };
|
||||
return {
|
||||
ok: false,
|
||||
stdout: e.stdout ? String(e.stdout).trim() : '',
|
||||
stderr: e.stderr ? String(e.stderr).trim() : '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** First line of stderr, trimmed — a compact human-readable failure reason. */
|
||||
function firstLine(s: string): string {
|
||||
return (
|
||||
s
|
||||
.split('\n')
|
||||
.find((l) => l.trim())
|
||||
?.trim() ?? 'git command failed'
|
||||
);
|
||||
}
|
||||
|
||||
/** Compute ahead/behind + incoming commits for one already-selected remote. */
|
||||
async function statusForRemote(
|
||||
dir: string,
|
||||
name: string,
|
||||
trackingRemote: string,
|
||||
trackingRef: string | null
|
||||
): Promise<RepoRemoteStatus> {
|
||||
const role = roleForRemote(name, trackingRemote);
|
||||
const url = (await runGit(['remote', 'get-url', name], dir)).stdout || '';
|
||||
const base: RepoRemoteStatus = { name, url, role, compareRef: '', ahead: 0, behind: 0, incoming: [] };
|
||||
|
||||
// SECURITY: the remote name reaches git as a positional; reject a `-` prefix
|
||||
// (e.g. a stray CODEMAN_UPDATE_REMOTES entry) before it can act as a flag.
|
||||
if (!isSafeGitPositional(name)) {
|
||||
return { ...base, error: `Refusing unsafe remote name "${name}".` };
|
||||
}
|
||||
|
||||
// Resolve the compare ref + the remote branch to fetch.
|
||||
let branch: string | null;
|
||||
if (role === 'tracking' && trackingRef) {
|
||||
// e.g. trackingRef = "bitbucket/local" → branch = "local"
|
||||
branch = trackingRef.slice(name.length + 1) || null;
|
||||
} else {
|
||||
const symref = await runGit(['ls-remote', '--symref', name, 'HEAD'], dir, FETCH_TIMEOUT_MS);
|
||||
branch = symref.ok ? parseSymrefDefaultBranch(symref.stdout) : null;
|
||||
if (!branch && !symref.ok) {
|
||||
return { ...base, error: `Could not reach ${name}: ${firstLine(symref.stderr)}` };
|
||||
}
|
||||
branch = branch ?? 'master';
|
||||
}
|
||||
if (!branch) return { ...base, error: `Could not resolve a branch on ${name}.` };
|
||||
// SECURITY: defense-in-depth — `branch` may come from untrusted symref output
|
||||
// or a tracking-ref slice; never let a `-`-prefixed value reach `git fetch`.
|
||||
if (!isSafeGitPositional(branch)) {
|
||||
return { ...base, error: `Refusing unsafe branch name "${branch}" from ${name}.` };
|
||||
}
|
||||
const compareRef = `${name}/${branch}`;
|
||||
|
||||
// Read-only fetch of just that ref so the local rev-list/log can see it.
|
||||
// `--` ends option parsing so neither `name` nor `branch` can be read as a flag.
|
||||
const fetched = await runGit(['fetch', '--no-tags', name, '--', branch], dir, FETCH_TIMEOUT_MS);
|
||||
if (!fetched.ok) {
|
||||
return { ...base, compareRef, error: `Could not fetch ${compareRef}: ${firstLine(fetched.stderr)}` };
|
||||
}
|
||||
|
||||
const counts = await runGit(['rev-list', '--left-right', '--count', `HEAD...${compareRef}`], dir);
|
||||
if (!counts.ok) {
|
||||
return { ...base, compareRef, error: `Could not compare against ${compareRef}: ${firstLine(counts.stderr)}` };
|
||||
}
|
||||
const ab = parseAheadBehind(counts.stdout);
|
||||
if (!ab) return { ...base, compareRef, error: `Unexpected git output comparing ${compareRef}.` };
|
||||
|
||||
const log = await runGit(['log', '--oneline', '-n', String(MAX_INCOMING), `HEAD..${compareRef}`], dir);
|
||||
const incoming = log.ok ? parseLogLines(log.stdout) : [];
|
||||
|
||||
return { ...base, compareRef, ahead: ab.ahead, behind: ab.behind, incoming };
|
||||
}
|
||||
|
||||
/** The uncached computation behind `getRepositoryStatus()`. */
|
||||
async function computeRepositoryStatus(): Promise<RepositoryStatusResult> {
|
||||
const checkedAt = Date.now();
|
||||
const info = getInstallInfo();
|
||||
const base: RepositoryStatusResult = {
|
||||
checkedAt,
|
||||
isGit: info.installKind === 'git',
|
||||
currentVersion: info.currentVersion || APP_VERSION,
|
||||
remotes: [],
|
||||
};
|
||||
if (info.installKind !== 'git') {
|
||||
return { ...base, error: 'Not a git install — repository status is unavailable.' };
|
||||
}
|
||||
|
||||
const dir = info.installDir;
|
||||
|
||||
// Tracking ref of the current branch, e.g. "bitbucket/local" (empty if none).
|
||||
const trackingRef =
|
||||
(await runGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], dir)).stdout || null;
|
||||
const trackingRemote = parseTrackingRemote(trackingRef);
|
||||
// A local-branch upstream (no slash) carries no remote — drop the ref too, so
|
||||
// nothing downstream can mistake a bare branch name for a remote-tracking ref.
|
||||
const remoteTrackingRef = trackingRemote ? trackingRef : null;
|
||||
|
||||
const remotesOut = await runGit(['remote'], dir);
|
||||
const existingRemotes = remotesOut.ok
|
||||
? remotesOut.stdout
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const selected = resolveRemoteSet({
|
||||
existingRemotes,
|
||||
trackingRemote,
|
||||
envRemotes: parseRemotesEnv(process.env.CODEMAN_UPDATE_REMOTES),
|
||||
});
|
||||
|
||||
if (selected.length === 0) {
|
||||
return { ...base, error: 'No comparable remotes found (set CODEMAN_UPDATE_REMOTES to choose).' };
|
||||
}
|
||||
|
||||
// Sequential on purpose: concurrent `git fetch` in one repo can contend on
|
||||
// ref locks, and the event loop no longer cares how long this takes.
|
||||
const remotes: RepoRemoteStatus[] = [];
|
||||
for (const name of selected) {
|
||||
// SECURITY: redact `scheme://user:token@host` credentials from the URL and
|
||||
// any error string (git stderr echoes the URL back) before they leave the server.
|
||||
remotes.push(redactRemoteStatus(await statusForRemote(dir, name, trackingRemote ?? '', remoteTrackingRef)));
|
||||
}
|
||||
return { ...base, remotes };
|
||||
}
|
||||
|
||||
const statusCache = createSingleFlightCache(STATUS_CACHE_TTL_MS, computeRepositoryStatus);
|
||||
|
||||
/**
|
||||
* Inspect how the local checkout sits relative to the configured remotes.
|
||||
* Each remote is fetched + compared independently; a single unreachable remote
|
||||
* surfaces as that card's `error` and never fails the whole call.
|
||||
*
|
||||
* Single-flight + TTL-cached: concurrent requests share one in-flight
|
||||
* computation, and a result younger than `STATUS_CACHE_TTL_MS` is served
|
||||
* without spawning git.
|
||||
*/
|
||||
export function getRepositoryStatus(): Promise<RepositoryStatusResult> {
|
||||
return statusCache.get();
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
export type ResponseViewerTranscriptKind = 'prompt' | 'response' | 'status' | 'tool';
|
||||
|
||||
export interface ResponseViewerTranscriptBlock {
|
||||
kind: ResponseViewerTranscriptKind;
|
||||
label: 'Prompt' | 'Response' | 'Status' | 'Tool';
|
||||
/** What the frontend renders by: 'user' gets the "You" badge, everything else the agent badge. */
|
||||
role: 'user' | 'assistant';
|
||||
text: string;
|
||||
}
|
||||
|
||||
// Keep in lockstep with isExternalCliMode() in src/session.ts. Importing it here
|
||||
// would drag node-pty and the whole session layer into this pure module, so the
|
||||
// list is duplicated and test/response-viewer-transcript.test.ts pins the parity.
|
||||
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi']);
|
||||
|
||||
function isPromptLine(line: string): boolean {
|
||||
return /^\s*›\s*/.test(line);
|
||||
}
|
||||
|
||||
function isDividerDashChar(ch: string): boolean {
|
||||
return ch === '─' || ch === '-';
|
||||
}
|
||||
|
||||
function isWhitespaceChar(ch: string): boolean {
|
||||
return /\s/.test(ch);
|
||||
}
|
||||
|
||||
// Linear-time equivalent of the old /^[─-]+\s*(.+?)\s*[─-]{3,}$/. The lazy
|
||||
// middle of that pattern backtracked catastrophically on a long dash run that
|
||||
// does NOT end in 3+ dashes (measured >2min at 8,000 chars) — and pane text is
|
||||
// agent-controlled with buffers up to 32MB, so this ran on hostile input. Same
|
||||
// accept set and same captured content, computed with counters; equivalence is
|
||||
// pinned char-for-char against the old regex by the brute-force corpus test in
|
||||
// test/response-viewer-transcript.test.ts.
|
||||
function normalizeDividerStatusLine(line: string): string | null {
|
||||
const s = line.trim();
|
||||
const n = s.length;
|
||||
// Minimum match: 1 leading dash + 1 content char + 3 trailing dashes.
|
||||
if (n < 5) return null;
|
||||
|
||||
let lead = 0;
|
||||
while (lead < n && isDividerDashChar(s.charAt(lead))) lead += 1;
|
||||
if (lead === 0) return null;
|
||||
|
||||
let trail = 0;
|
||||
while (trail < n && isDividerDashChar(s.charAt(n - 1 - trail))) trail += 1;
|
||||
if (trail < 3) return null;
|
||||
|
||||
// The regex was greedy on the leading run but gave dashes back until at least
|
||||
// one content char plus the 3-dash tail fit (an all-dash line matched with a
|
||||
// single leftover dash as its "content"), so the content window starts at the
|
||||
// end of the leading run, clamped to leave 4 chars.
|
||||
const contentStart = Math.min(lead, n - 4);
|
||||
let ws = 0;
|
||||
while (contentStart + ws < n - 4 && isWhitespaceChar(s.charAt(contentStart + ws))) ws += 1;
|
||||
const from = contentStart + ws;
|
||||
|
||||
// The lazy middle stopped at the first position from which "optional
|
||||
// whitespace, then dashes to end-of-line" matches: the start of the
|
||||
// whitespace padding in front of the trailing dash run (never before the
|
||||
// first content char).
|
||||
const tailStart = n - trail;
|
||||
let padded = tailStart;
|
||||
while (padded > 0 && isWhitespaceChar(s.charAt(padded - 1))) padded -= 1;
|
||||
const end = Math.max(from + 1, padded);
|
||||
|
||||
return s.slice(from, end).trim() || null;
|
||||
}
|
||||
|
||||
function isDividerOnlyLine(line: string): boolean {
|
||||
return /^[\s─-]{8,}$/.test(line.trim());
|
||||
}
|
||||
|
||||
// COD-227: unambiguous Codex tool-call markers. These only ever appear as internal
|
||||
// activity, never as ordinary assistant prose, so they are always a Tool block.
|
||||
function isToolActivityMarker(line: string): boolean {
|
||||
return /^\s*[•*-]\s+(Calling|Called)\b/.test(line.trim());
|
||||
}
|
||||
|
||||
// COD-227: action verbs that ALSO occur in ordinary assistant prose (e.g.
|
||||
// "• Created COD-226: …"). These are a Tool header only when corroborated by a
|
||||
// box-drawing result tree on the next non-blank line (see the caller); the verb
|
||||
// alone is not sufficient.
|
||||
function isToolVerbBullet(line: string): boolean {
|
||||
return /^\s*[•*-]\s+(Explored|Viewed|Read|Edited|Updated|Created|Deleted|Ran|Searched|Opened|Listed|Found|Applied|Patched|Used|Wrote|Executed|Modified|Analyzed|Compared|Fetched|Installed)\b/.test(
|
||||
line.trim()
|
||||
);
|
||||
}
|
||||
|
||||
// A genuine Codex tool block renders its result as a box-drawing tree (└ │ ├).
|
||||
function isBoxDrawingLine(line: string): boolean {
|
||||
return /^[│├└]/.test(line.trim());
|
||||
}
|
||||
|
||||
// Look past blank lines from `fromIndex + 1` for the next non-blank line and report
|
||||
// whether it is a box-drawing tool-result line — the signal that a verb bullet is a
|
||||
// real tool block rather than assistant prose that happens to start with a verb.
|
||||
function nextNonBlankIsBoxDrawing(lines: string[], fromIndex: number): boolean {
|
||||
for (let j = fromIndex + 1; j < lines.length; j += 1) {
|
||||
const trimmed = (lines[j] || '').trim();
|
||||
if (!trimmed) continue;
|
||||
return isBoxDrawingLine(trimmed);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isToolContinuationLine(line: string, currentKind: ResponseViewerTranscriptKind | null): boolean {
|
||||
if (currentKind !== 'tool') return false;
|
||||
const trimmed = line.trimEnd();
|
||||
if (!trimmed) return true;
|
||||
return /^\s*[│├└]/.test(trimmed) || /^\s{2,}\S/.test(line);
|
||||
}
|
||||
|
||||
function isStatusLine(line: string, mode: string): boolean {
|
||||
if (!EXTERNAL_CLI_MODES.has(mode)) return false;
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return false;
|
||||
if (normalizeDividerStatusLine(trimmed)) return true;
|
||||
if (/^(model|directory):\s+/i.test(trimmed)) return true;
|
||||
if (/\bContext\b.*\bleft\b/i.test(trimmed)) return true;
|
||||
if (/\b\/model to change\b/i.test(trimmed)) return true;
|
||||
if (/\bReady\b/i.test(trimmed) && /·/.test(trimmed)) return true;
|
||||
if (/^(gpt|o\d|claude|gemini)\b/i.test(trimmed) && /·/.test(trimmed)) return true;
|
||||
if (/^Tip:/i.test(trimmed)) return true;
|
||||
if (/^\s*[•*-]\s+(Working|Thinking|Loading|Starting\b|Waiting\b)/i.test(trimmed)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isStandaloneMarkdownLine(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^#{1,6}\s/.test(trimmed)) return true;
|
||||
if (/^>\s/.test(trimmed)) return true;
|
||||
if (/^(```|~~~)/.test(trimmed)) return true;
|
||||
if (/^[-*+]\s/.test(trimmed)) return true;
|
||||
if (/^\d+[.)]\s/.test(trimmed)) return true;
|
||||
if (/^\|/.test(trimmed)) return true;
|
||||
if (/^\s{4,}\S/.test(line)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldJoinWrappedLine(previous: string, next: string): boolean {
|
||||
const prev = previous.trimEnd();
|
||||
const curr = next.trim();
|
||||
if (!prev || !curr) return false;
|
||||
if (/[.!?]$/.test(prev)) return false;
|
||||
if (/[:;]$/.test(prev)) return false;
|
||||
if (isStandaloneMarkdownLine(curr)) return false;
|
||||
if (/^[a-z(]/.test(curr)) return true;
|
||||
if (prev.length >= 72 && /^[A-Za-z0-9"'(]/.test(curr)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeWrappedText(lines: string[]): string {
|
||||
const out: string[] = [];
|
||||
let paragraph = '';
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (!paragraph) return;
|
||||
out.push(paragraph);
|
||||
paragraph = '';
|
||||
};
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trimEnd();
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
flushParagraph();
|
||||
if (out[out.length - 1] !== '') out.push('');
|
||||
continue;
|
||||
}
|
||||
if (isStandaloneMarkdownLine(line)) {
|
||||
flushParagraph();
|
||||
out.push(trimmed);
|
||||
continue;
|
||||
}
|
||||
if (!paragraph) {
|
||||
paragraph = trimmed;
|
||||
continue;
|
||||
}
|
||||
if (shouldJoinWrappedLine(paragraph, trimmed)) {
|
||||
paragraph += ` ${trimmed}`;
|
||||
continue;
|
||||
}
|
||||
flushParagraph();
|
||||
paragraph = trimmed;
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
return out
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function cleanTerminalTranscript(buffer: string): string {
|
||||
// Stripping ANSI/OSC/DCS escape sequences and stray C0/C1 control bytes
|
||||
// legitimately requires control characters in these patterns.
|
||||
/* eslint-disable no-control-regex */
|
||||
return String(buffer || '')
|
||||
.replace(/\x1b\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]/g, '')
|
||||
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
||||
.replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, '')
|
||||
.replace(/\x1b[NO()][A-Z0-9]?/g, '')
|
||||
.replace(/\x1b[>=<78cDEHM]/g, '')
|
||||
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.replace(/[ \t]+$/gm, '')
|
||||
.trim();
|
||||
/* eslint-enable no-control-regex */
|
||||
}
|
||||
|
||||
function trimLeadingStartup(lines: string[]): string[] {
|
||||
let index = 0;
|
||||
while (index < lines.length) {
|
||||
const line = lines[index] || '';
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^[╭╰│─].*[╮╯│]?$/.test(trimmed)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^>_\s*OpenAI Codex/i.test(trimmed)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^(model|directory):\s+/i.test(trimmed)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return lines.slice(index);
|
||||
}
|
||||
|
||||
function pushBlock(
|
||||
blocks: ResponseViewerTranscriptBlock[],
|
||||
kind: ResponseViewerTranscriptKind | null,
|
||||
lines: string[]
|
||||
): void {
|
||||
if (!kind || lines.length === 0) return;
|
||||
const normalizedLines =
|
||||
kind === 'status'
|
||||
? lines.map((line) => normalizeDividerStatusLine(line) || line.trim()).filter((line) => line.length > 0)
|
||||
: lines;
|
||||
const text =
|
||||
kind === 'tool' || kind === 'status'
|
||||
? normalizedLines
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
: normalizeWrappedText(normalizedLines);
|
||||
if (!text) return;
|
||||
const label = (kind.charAt(0).toUpperCase() + kind.slice(1)) as ResponseViewerTranscriptBlock['label'];
|
||||
// The frontend's loadFullContext() renders via msg.role — a block without it
|
||||
// lost the "You" badge on prompts and rendered every block as the agent.
|
||||
blocks.push({ kind, label, role: kind === 'prompt' ? 'user' : 'assistant', text });
|
||||
}
|
||||
|
||||
export function isExternalCliTranscriptMode(mode: string | null | undefined): boolean {
|
||||
return EXTERNAL_CLI_MODES.has(String(mode || ''));
|
||||
}
|
||||
|
||||
export function parseExternalCliTranscript(
|
||||
buffer: string,
|
||||
mode: string | null | undefined
|
||||
): ResponseViewerTranscriptBlock[] {
|
||||
const resolvedMode = String(mode || '');
|
||||
if (!isExternalCliTranscriptMode(resolvedMode)) return [];
|
||||
|
||||
const cleaned = cleanTerminalTranscript(buffer);
|
||||
if (!cleaned) return [];
|
||||
|
||||
const lines = trimLeadingStartup(cleaned.split('\n'));
|
||||
const blocks: ResponseViewerTranscriptBlock[] = [];
|
||||
let currentKind: ResponseViewerTranscriptKind | null = null;
|
||||
let currentLines: string[] = [];
|
||||
|
||||
const flush = () => {
|
||||
pushBlock(blocks, currentKind, currentLines);
|
||||
currentKind = null;
|
||||
currentLines = [];
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i] ?? '';
|
||||
// COD-226: within a prompt, the 2-space Codex gutter is authoritative. Blank
|
||||
// lines and gutter-indented (2+ leading spaces) continuation lines stay in the
|
||||
// Prompt block ahead of every structural detector below, so multiline prompts —
|
||||
// including bullets, indented dividers, and literal › lines — are not
|
||||
// misclassified as responses. Only a non-blank, non-gutter line ends the prompt
|
||||
// and falls through (a column-0 › then opens a NEW prompt).
|
||||
if (currentKind === 'prompt') {
|
||||
if (!line.trim()) {
|
||||
currentLines.push('');
|
||||
continue;
|
||||
}
|
||||
if (/^ {2,}\S/.test(line)) {
|
||||
currentLines.push(line);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDividerOnlyLine(line)) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isPromptLine(line)) {
|
||||
flush();
|
||||
currentKind = 'prompt';
|
||||
currentLines = [line.replace(/^\s*›\s*/, '').trim()];
|
||||
continue;
|
||||
}
|
||||
|
||||
// COD-227: • Calling / • Called are always tool markers; the other action verbs
|
||||
// are a tool header only when a box-drawing result tree follows on the next
|
||||
// non-blank line — otherwise the verb bullet is ordinary assistant prose.
|
||||
if (isToolActivityMarker(line) || (isToolVerbBullet(line) && nextNonBlankIsBoxDrawing(lines, i))) {
|
||||
if (currentKind !== 'tool') flush();
|
||||
currentKind = 'tool';
|
||||
currentLines.push(line.trimEnd());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isToolContinuationLine(line, currentKind)) {
|
||||
currentLines.push(line.trimEnd());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isStatusLine(line, resolvedMode)) {
|
||||
if (currentKind !== 'status') flush();
|
||||
currentKind = 'status';
|
||||
currentLines.push(line.trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.trim()) {
|
||||
currentLines.push('');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentKind !== 'response') flush();
|
||||
currentKind = 'response';
|
||||
currentLines.push(line);
|
||||
}
|
||||
|
||||
flush();
|
||||
return blocks.filter((block) => block.text.trim().length > 0);
|
||||
}
|
||||
|
||||
export function getLastTranscriptResponse(blocks: ResponseViewerTranscriptBlock[]): string {
|
||||
for (let i = blocks.length - 1; i >= 0; i -= 1) {
|
||||
if (blocks[i]?.kind === 'response') return blocks[i].text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
FileWriteData,
|
||||
} from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { compileFileQuery } from '../../utils/file-query.js';
|
||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||
import {
|
||||
AUDIO_ATTACHMENT_EXTENSIONS,
|
||||
@@ -937,12 +938,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// File tree listing
|
||||
app.get('/api/sessions/:id/files', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { depth, showHidden } = req.query as { depth?: string; showHidden?: string };
|
||||
const { depth, showHidden, q } = req.query as { depth?: string; showHidden?: string; q?: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const maxDepth = Math.min(parseInt(depth || '5', 10), 10);
|
||||
const includeHidden = showHidden === 'true';
|
||||
const workingDir = session.workingDir;
|
||||
// null for an empty/whitespace query, which is what keeps the default
|
||||
// tree response byte-identical when no search is requested.
|
||||
const matcher = compileFileQuery(q ?? '');
|
||||
|
||||
// Default excludes - large/generated directories
|
||||
const excludeDirs = new Set([
|
||||
@@ -976,6 +980,92 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
let truncated = false;
|
||||
const maxFiles = 5000;
|
||||
|
||||
// ===== Search mode =====
|
||||
// A query turns this endpoint into a FLAT match list rather than a nested
|
||||
// tree. It recurses past non-matching directories on purpose — the whole
|
||||
// point of searching is to reach a file whose ancestors do not match — so
|
||||
// it is bounded independently by maxMatches on top of the shared maxFiles
|
||||
// and maxDepth caps, and reports `truncated` when it stops early.
|
||||
if (matcher) {
|
||||
const matches: FileTreeNode[] = [];
|
||||
const maxMatches = 1000;
|
||||
|
||||
const searchDirectory = async (dirPath: string, currentDepth: number): Promise<void> => {
|
||||
if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles || matches.length >= maxMatches) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let entries: import('node:fs').Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
// Can't read directory (permission denied, etc.)
|
||||
return;
|
||||
}
|
||||
entries.sort((a, b) => {
|
||||
if (a.isDirectory() && !b.isDirectory()) return -1;
|
||||
if (!a.isDirectory() && b.isDirectory()) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
if (totalFiles + totalDirectories > maxFiles || matches.length >= maxMatches) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
if (!includeHidden && entry.name.startsWith('.')) continue;
|
||||
if (entry.isDirectory() && excludeDirs.has(entry.name)) continue;
|
||||
|
||||
const fullPath = join(dirPath, entry.name);
|
||||
const relativePath = relative(workingDir, fullPath);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
totalDirectories++;
|
||||
if (matcher(entry.name, relativePath)) {
|
||||
matches.push({ name: entry.name, path: relativePath, type: 'directory' });
|
||||
}
|
||||
// Always recurse, even when this directory does not match.
|
||||
await searchDirectory(fullPath, currentDepth + 1);
|
||||
} else {
|
||||
totalFiles++;
|
||||
if (matcher(entry.name, relativePath)) {
|
||||
let size: number | undefined;
|
||||
try {
|
||||
size = (await fs.stat(fullPath)).size;
|
||||
} catch {
|
||||
// Skip size if we can't stat the match.
|
||||
}
|
||||
matches.push({
|
||||
name: entry.name,
|
||||
path: relativePath,
|
||||
type: 'file',
|
||||
size,
|
||||
extension: entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await searchDirectory(workingDir, 1);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
root: workingDir,
|
||||
tree: [],
|
||||
matches,
|
||||
totalFiles,
|
||||
totalDirectories,
|
||||
truncated,
|
||||
matchCount: matches.length,
|
||||
query: (q ?? '').trim(),
|
||||
mode: 'search' as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const scanDirectory = async (dirPath: string, currentDepth: number): Promise<FileTreeNode[]> => {
|
||||
if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles) {
|
||||
truncated = true;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import {
|
||||
ApiErrorCode,
|
||||
createErrorResponse,
|
||||
@@ -131,6 +132,11 @@ import {
|
||||
toSessionDocker,
|
||||
} from '../../docker-hosts.js';
|
||||
import { LRUMap } from '../../utils/lru-map.js';
|
||||
import {
|
||||
getLastTranscriptResponse,
|
||||
isExternalCliTranscriptMode,
|
||||
parseExternalCliTranscript,
|
||||
} from '../response-viewer-transcript.js';
|
||||
|
||||
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
@@ -797,54 +803,42 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Check OpenCode availability if requested
|
||||
// Check OpenCode availability if requested. The error text comes from the
|
||||
// resolver (formatCliNotFoundMessage) so it names where resolution looked —
|
||||
// server PATH, login shell, common directories — same for the modes below.
|
||||
if (body.mode === 'opencode') {
|
||||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
||||
const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } = await import('../../utils/opencode-cli-resolver.js');
|
||||
if (!isOpenCodeAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Codex availability if requested
|
||||
if (body.mode === 'codex') {
|
||||
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
|
||||
const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js');
|
||||
if (!isCodexAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Gemini availability if requested
|
||||
if (body.mode === 'gemini') {
|
||||
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
|
||||
const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js');
|
||||
if (!isGeminiAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage());
|
||||
}
|
||||
}
|
||||
if (body.mode === 'antigravity') {
|
||||
const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js');
|
||||
const { isAntigravityAvailable, getAntigravityNotFoundMessage } =
|
||||
await import('../../utils/antigravity-cli-resolver.js');
|
||||
if (!isAntigravityAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage());
|
||||
}
|
||||
}
|
||||
if (body.mode === 'pi') {
|
||||
const { isPiAvailable } = await import('../../utils/pi-cli-resolver.js');
|
||||
const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js');
|
||||
if (!isPiAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1898,6 +1892,22 @@ export function registerSessionRoutes(
|
||||
return await readCodexLastResponse(session, codexQuery.context === 'full');
|
||||
}
|
||||
|
||||
// OpenCode / Gemini / Antigravity / Pi render their own TUIs and write no
|
||||
// Claude transcript, so the scan below finds nothing and the response viewer
|
||||
// renders permanently empty for them. Segment the terminal buffer instead —
|
||||
// the pane IS the transcript for these CLIs. Codex is already handled above,
|
||||
// where a real rollout file is the better source.
|
||||
if (isExternalCliTranscriptMode(session.mode)) {
|
||||
const externalQuery = req.query as { context?: string };
|
||||
const blocks = parseExternalCliTranscript(session.terminalBuffer, session.mode);
|
||||
return {
|
||||
text: getLastTranscriptResponse(blocks),
|
||||
timestamp: '',
|
||||
hasContext: blocks.length > 0,
|
||||
messages: externalQuery.context === 'full' ? blocks : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Scan ~/.claude/projects/*/ for the transcript file
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
|
||||
@@ -2270,18 +2280,17 @@ export function registerSessionRoutes(
|
||||
|
||||
// Query params:
|
||||
// tail=<bytes> - Only return last N bytes (faster initial load)
|
||||
// full=1 - Full page reload: replay the entire tmux scrollback (COD-47)
|
||||
app.get('/api/sessions/:id/terminal', async (req) => {
|
||||
// full=1 - Explicitly request the entire tmux scrollback (COD-47)
|
||||
app.get('/api/sessions/:id/terminal', async (req, reply) => {
|
||||
const routeStartedAt = performance.now();
|
||||
const { id } = req.params as { id: string };
|
||||
const query = req.query as { tail?: string; full?: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// `full=1` is the EXPLICIT full-reload signal (COD-47): the browser reloaded
|
||||
// the page and wants the whole scroll history back, so we capture the ENTIRE
|
||||
// tmux scrollback and the user gets back history that scrolled off Codeman's
|
||||
// byte buffer. Requests WITHOUT it — tab switches (`tail=`) and the legacy
|
||||
// no-param callers (response-viewer fallback, clearTerminal refresh) — keep
|
||||
// the fast visible-frame capture.
|
||||
// `full=1` is the EXPLICIT full-history signal (COD-47): capture the ENTIRE
|
||||
// tmux scrollback so history beyond the server byte buffer can be recovered.
|
||||
// Requests WITHOUT it — shell selection/tab switches (`tail=`) and legacy
|
||||
// no-param callers — keep the fast visible-frame capture.
|
||||
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
||||
const isFullReload = query.full === '1' || query.full === 'true';
|
||||
const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig();
|
||||
@@ -2294,6 +2303,7 @@ export function registerSessionRoutes(
|
||||
// overlap. `captureActivePaneBuffer` is a no-op ('') under test mode and
|
||||
// returns null when unavailable, in which case we fall back to history.
|
||||
const muxName = session.muxName;
|
||||
const captureStartedAt = performance.now();
|
||||
const liveMuxBuffer =
|
||||
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
||||
? ctx.mux.captureActivePaneBuffer(
|
||||
@@ -2303,6 +2313,7 @@ export function registerSessionRoutes(
|
||||
: undefined
|
||||
)
|
||||
: null;
|
||||
const captureFinishedAt = performance.now();
|
||||
const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
|
||||
const source: 'history' | 'mux-visible' | 'mux-full-history' = hasLiveMuxBuffer
|
||||
? isFullReload
|
||||
@@ -2406,6 +2417,14 @@ export function registerSessionRoutes(
|
||||
// Remove Ctrl+L and leading whitespace (cheap on tailed subset)
|
||||
cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, '');
|
||||
|
||||
const finishedAt = performance.now();
|
||||
reply.header(
|
||||
'Server-Timing',
|
||||
`capture;dur=${(captureFinishedAt - captureStartedAt).toFixed(1)}, ` +
|
||||
`prepare;dur=${(finishedAt - captureFinishedAt).toFixed(1)}, ` +
|
||||
`total;dur=${(finishedAt - routeStartedAt).toFixed(1)}`
|
||||
);
|
||||
|
||||
return {
|
||||
terminalBuffer: cleanBuffer,
|
||||
status: session.status,
|
||||
@@ -2806,58 +2825,46 @@ export function registerSessionRoutes(
|
||||
dockerResumeId = dockerCase.lastClaudeSessionId;
|
||||
}
|
||||
} else {
|
||||
// Check OpenCode availability if requested
|
||||
// Check OpenCode availability if requested. Error text comes from the
|
||||
// resolver so it carries the resolution diagnostics; same for the modes below.
|
||||
if (mode === 'opencode') {
|
||||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
||||
const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } =
|
||||
await import('../../utils/opencode-cli-resolver.js');
|
||||
if (!isOpenCodeAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Codex availability if requested
|
||||
if (mode === 'codex') {
|
||||
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
|
||||
const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js');
|
||||
if (!isCodexAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Gemini availability if requested
|
||||
if (mode === 'gemini') {
|
||||
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
|
||||
const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js');
|
||||
if (!isGeminiAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Antigravity availability if requested
|
||||
if (mode === 'antigravity') {
|
||||
const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js');
|
||||
const { isAntigravityAvailable, getAntigravityNotFoundMessage } =
|
||||
await import('../../utils/antigravity-cli-resolver.js');
|
||||
if (!isAntigravityAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Pi availability if requested
|
||||
if (mode === 'pi') {
|
||||
const { isPiAvailable } = await import('../../utils/pi-cli-resolver.js');
|
||||
const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js');
|
||||
if (!isPiAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
|
||||
import { getRepositoryStatus } from '../repo-status.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
|
||||
@@ -354,6 +355,11 @@ export function registerSystemRoutes(
|
||||
// Poll target for update progress — survives the restart the update triggers.
|
||||
app.get('/api/system/update/status', async () => getUpdateStatusForApi());
|
||||
|
||||
// Informational companion to the release-tag updater above: what this CHECKOUT
|
||||
// looks like against its own remotes (ahead/behind, incoming commits), which a
|
||||
// release tag cannot answer for a git install tracking a branch.
|
||||
app.get('/api/system/repo-status', async () => getRepositoryStatus());
|
||||
|
||||
// Kick off a detached update to the latest release. Returns immediately; the
|
||||
// browser then polls /api/system/update/status across the service restart.
|
||||
app.post('/api/system/update', async (_req, reply) => {
|
||||
@@ -722,7 +728,8 @@ export function registerSystemRoutes(
|
||||
const merged = { ...existing, ...settingsToStore };
|
||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
||||
|
||||
// Apply a changed tmux history-limit to all live sessions immediately.
|
||||
// tmux 3.7+ resizes tracked panes; older versions apply this to new panes.
|
||||
// Already-evicted history cannot be recovered on either version.
|
||||
if (settings.tmuxHistoryLimit !== undefined) {
|
||||
await ctx.mux.setHistoryLimit(resolveTerminalHistoryConfig(merged).tmuxHistoryLimit);
|
||||
}
|
||||
|
||||
+24
-11
@@ -41,6 +41,7 @@ import fs from 'node:fs/promises';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { hostname as getHostname } from 'node:os';
|
||||
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
|
||||
import { GLYPH, palette } from '../cli-style.js';
|
||||
import { getHookSecret } from '../config/hook-secret.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Session, isExternalCliMode, type BackgroundTask } from '../session.js';
|
||||
@@ -2395,7 +2396,9 @@ export class WebServer extends EventEmitter {
|
||||
await this.app.listen({ port: this.port, host: this.host });
|
||||
const protocol = this.https ? 'https' : 'http';
|
||||
const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host;
|
||||
console.log(`Codeman web interface running at ${protocol}://${displayHost}:${this.port}`);
|
||||
// The only startup banner: `codeman web` used to print its own copy of this
|
||||
// line, but the daemon and service launch paths never go through the CLI.
|
||||
console.log(palette.ok(`${GLYPH.ok} Codeman web interface running at ${protocol}://${displayHost}:${this.port}`));
|
||||
|
||||
// Opt-in: also serve the HOOK endpoints on the docker bridge gateway so
|
||||
// in-container hooks (permission/idle/stop callbacks) can reach a loopback-bound
|
||||
@@ -2425,20 +2428,30 @@ export class WebServer extends EventEmitter {
|
||||
// without CODEMAN_PASSWORD (every person has their own credential).
|
||||
const authActive = !!process.env.CODEMAN_PASSWORD || (isMultiUserMode() && (await hasUsers()));
|
||||
if (!isLoopbackBindHost(this.host) && !authActive) {
|
||||
// Painted like the CLI's copy of the same warning. chalk degrades to plain
|
||||
// text off a TTY, so journald and web.log stay free of escape codes.
|
||||
if (this.allowUnauthenticatedNetwork) {
|
||||
console.warn(
|
||||
`\n⚠ Codeman is reachable WITHOUT a password on ${displayHost}:${this.port} ` +
|
||||
'(explicitly allowed). Anyone who can reach it can control your Claude sessions.\n'
|
||||
palette.warn(
|
||||
`\n${GLYPH.warn} Codeman is reachable WITHOUT a password on ${displayHost}:${this.port} ` +
|
||||
'(explicitly allowed). Anyone who can reach it can control your Claude sessions.\n'
|
||||
)
|
||||
);
|
||||
} else {
|
||||
console.warn(`\n⚠ WARNING: Codeman is bound to a non-loopback host (${this.host}) with NO password.`);
|
||||
console.warn(` Anyone who can reach ${displayHost}:${this.port} can control your Claude sessions.`);
|
||||
console.warn(' Secure it with ONE of:');
|
||||
console.warn(' • set CODEMAN_PASSWORD=<password> (HTTP Basic auth), or');
|
||||
console.warn(' • bind loopback only: --host 127.0.0.1, then front it with an');
|
||||
console.warn(' authenticated tunnel (cloudflared) or `tailscale serve`, or');
|
||||
console.warn(' • keep this bind and accept the risk: --allow-unauthenticated-network');
|
||||
console.warn(' See docs/security-architecture.md for details.\n');
|
||||
console.warn(
|
||||
palette.err(
|
||||
`\n${GLYPH.warn} WARNING: Codeman is bound to a non-loopback host (${this.host}) with NO password.`
|
||||
)
|
||||
);
|
||||
console.warn(
|
||||
palette.err(` Anyone who can reach ${displayHost}:${this.port} can control your Claude sessions.`)
|
||||
);
|
||||
console.warn(palette.warn(' Secure it with ONE of:'));
|
||||
console.warn(palette.warn(' • set CODEMAN_PASSWORD=<password> (HTTP Basic auth), or'));
|
||||
console.warn(palette.warn(' • bind loopback only: --host 127.0.0.1, then front it with an'));
|
||||
console.warn(palette.warn(' authenticated tunnel (cloudflared) or `tailscale serve`, or'));
|
||||
console.warn(palette.warn(' • keep this bind and accept the risk: --allow-unauthenticated-network'));
|
||||
console.warn(palette.muted(' See docs/security-architecture.md for details.\n'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface SessionListenerRefs {
|
||||
limitResumeCancelled: (data: { reason: string }) => void;
|
||||
respawnBreakerTripped: (data: { count: number }) => void;
|
||||
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => void;
|
||||
mouseTrackingChanged: (active: boolean) => void;
|
||||
ralphLoopUpdate: (state: RalphTrackerState) => void;
|
||||
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
|
||||
ralphCompletionDetected: (phrase: string) => void;
|
||||
@@ -219,10 +220,18 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
// An idle-prompt inbox item means "composer is waiting"; any working
|
||||
// transition means input arrived, so the item is moot. ONLY the idle
|
||||
// kind: `working` is heuristic and can flap mid-turn, so clearing a
|
||||
// pending permission/question dialog on it would false-clear real
|
||||
// approvals (those resolve via stop / elicitation hooks / answer-time
|
||||
// re-capture instead).
|
||||
// pending permission/question dialog on the signal ALONE would
|
||||
// false-clear real approvals.
|
||||
approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
|
||||
// A permission/question dialog gets the pane-VERIFIED variant instead:
|
||||
// the signal only decides when to look, `verifyStillAnswerable` re-reads
|
||||
// the screen and resolves only when the dialog is really gone. Without
|
||||
// this, answering a dialog in the terminal left its red "needs you" alert
|
||||
// armed for the rest of the turn, because the only other staleness check
|
||||
// lives in `GET /api/approvals` and nothing calls that while a page is
|
||||
// open. `stop` was the first thing to clear it, which on a long turn is
|
||||
// minutes away.
|
||||
approvalInbox.resolveIfDialogGone(session.id);
|
||||
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
|
||||
// Full state ride-along: the home screens sort the running group on
|
||||
// lastSubmitAt, and without this the browser keeps the stamp it loaded
|
||||
@@ -342,6 +351,20 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
/**
|
||||
* The CLI turned mouse tracking on or off (observed while stripping the
|
||||
* DECSETs out of the stream). Rides the full session state so the browser
|
||||
* learns it through the session object it already merges, with no new SSE
|
||||
* event to keep in sync across the two registries.
|
||||
*
|
||||
* Broadcast IMMEDIATELY, not debounced: this flips when a dialog opens, and
|
||||
* a user can click that dialog inside the 500ms debounce window, which is
|
||||
* exactly the click that has to be reported.
|
||||
*/
|
||||
mouseTrackingChanged: () => {
|
||||
deps.broadcast(SseEvent.SessionUpdated, { session: deps.getSessionStateWithRespawn(session) });
|
||||
},
|
||||
|
||||
// ─── Ralph Tracking Events ──────────────────────────────
|
||||
|
||||
/** Broadcasts `session:ralphLoopUpdate` — Ralph tracker loop state changed (iteration, phase) */
|
||||
@@ -453,6 +476,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.on('limitResumeCancelled', refs.limitResumeCancelled);
|
||||
session.on('respawnBreakerTripped', refs.respawnBreakerTripped);
|
||||
session.on('cliInfoUpdated', refs.cliInfoUpdated);
|
||||
session.on('mouseTrackingChanged', refs.mouseTrackingChanged);
|
||||
session.on('ralphLoopUpdate', refs.ralphLoopUpdate);
|
||||
session.on('ralphTodoUpdate', refs.ralphTodoUpdate);
|
||||
session.on('ralphCompletionDetected', refs.ralphCompletionDetected);
|
||||
@@ -487,6 +511,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.off('limitResumeCancelled', refs.limitResumeCancelled);
|
||||
session.off('respawnBreakerTripped', refs.respawnBreakerTripped);
|
||||
session.off('cliInfoUpdated', refs.cliInfoUpdated);
|
||||
session.off('mouseTrackingChanged', refs.mouseTrackingChanged);
|
||||
session.off('ralphLoopUpdate', refs.ralphLoopUpdate);
|
||||
session.off('ralphTodoUpdate', refs.ralphTodoUpdate);
|
||||
session.off('ralphCompletionDetected', refs.ralphCompletionDetected);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @fileoverview Tests for the Antigravity CLI resolver wrapper.
|
||||
*/
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createAntigravityResolverForTest, isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js';
|
||||
import {
|
||||
cliResolveRetryDelayMs,
|
||||
type CliResolution,
|
||||
type CliResolverHost,
|
||||
} from '../src/utils/cli-executable-resolver.js';
|
||||
|
||||
const availabilityResolution = vi.hoisted(() => ({ current: null as CliResolution | null }));
|
||||
|
||||
vi.mock('../src/utils/cli-executable-resolver.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../src/utils/cli-executable-resolver.js')>();
|
||||
return {
|
||||
...actual,
|
||||
createCliExecutableResolver: (options: { binary: string; searchDirs: string[] }, host?: CliResolverHost) =>
|
||||
host
|
||||
? actual.createCliExecutableResolver(options, host)
|
||||
: {
|
||||
resolve: () => availabilityResolution.current,
|
||||
diagnostics: () => ({
|
||||
binary: options.binary,
|
||||
processPath: '/service/bin',
|
||||
shellPath: '/bin/zsh',
|
||||
shellArgs: ['-l'],
|
||||
searchDirs: [...options.searchDirs],
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function createHost(
|
||||
options: {
|
||||
processPathResult?: string | null;
|
||||
loginShellResults?: Array<string | null>;
|
||||
existingPaths?: string[];
|
||||
} = {}
|
||||
): CliResolverHost {
|
||||
const loginShellResults = [...(options.loginShellResults ?? [])];
|
||||
const existingPaths = new Set(options.existingPaths ?? []);
|
||||
return {
|
||||
processPath: '/service/bin',
|
||||
shellPath: '/bin/zsh',
|
||||
shellArgs: ['-l'],
|
||||
findOnProcessPath: () => options.processPathResult ?? null,
|
||||
findInLoginShell: () => loginShellResults.shift() ?? null,
|
||||
exists: (path) => existingPaths.has(path),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Antigravity CLI resolver', () => {
|
||||
beforeEach(() => {
|
||||
availabilityResolution.current = null;
|
||||
});
|
||||
|
||||
it('resolves agy from the service PATH', () => {
|
||||
const binaryPath = '/service/bin/agy';
|
||||
const resolver = createAntigravityResolverForTest(
|
||||
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] })
|
||||
);
|
||||
|
||||
expect(resolver.resolve()?.directory).toBe('/service/bin');
|
||||
});
|
||||
|
||||
it('falls back to a common install directory', () => {
|
||||
const binaryPath = join(homedir(), '.local', 'bin', 'agy');
|
||||
const resolver = createAntigravityResolverForTest(createHost({ existingPaths: [binaryPath] }));
|
||||
|
||||
expect(resolver.resolve()?.directory).toBe(join(homedir(), '.local', 'bin'));
|
||||
});
|
||||
|
||||
it('resolves agy found only by the login shell', () => {
|
||||
const binaryPath = '/login-shell/bin/agy';
|
||||
const resolver = createAntigravityResolverForTest(
|
||||
createHost({ loginShellResults: [binaryPath], existingPaths: [binaryPath] })
|
||||
);
|
||||
|
||||
expect(resolver.resolve()?.directory).toBe('/login-shell/bin');
|
||||
});
|
||||
|
||||
it('returns null when agy is unavailable', () => {
|
||||
const resolver = createAntigravityResolverForTest(createHost());
|
||||
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
});
|
||||
|
||||
it('retries a failed lookup after the backoff and caches the first successful login-shell discovery', () => {
|
||||
const binaryPath = '/late-login-shell/bin/agy';
|
||||
let now = 0;
|
||||
const resolver = createAntigravityResolverForTest(
|
||||
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }),
|
||||
() => now
|
||||
);
|
||||
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
// A miss is negative-cached: within the backoff window nothing re-runs the
|
||||
// chain (its login-shell tail is a synchronous bounded spawn in production).
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
now = cliResolveRetryDelayMs(1);
|
||||
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
|
||||
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
|
||||
});
|
||||
|
||||
it('reports the public wrapper as available when agy resolves', () => {
|
||||
availabilityResolution.current = {
|
||||
binaryPath: '/service/bin/agy',
|
||||
directory: '/service/bin',
|
||||
source: 'process-path',
|
||||
};
|
||||
|
||||
expect(isAntigravityAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the public wrapper as unavailable when agy does not resolve', () => {
|
||||
expect(isAntigravityAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,39 @@ const ASK_USER_QUESTION_FRAME = [
|
||||
'Enter to select · ↑/↓ to navigate · Esc to cancel',
|
||||
].join('\n');
|
||||
|
||||
// Both frames captured off a live Claude Code v2.1.237 pane. They are the
|
||||
// discriminator behind the late-hook resolution: a modal dialog BLOCKS the
|
||||
// turn, so a working line and a dialog cannot coexist. Note the dialog frame
|
||||
// carries no `esc to interrupt` footer either — the dialog replaces it.
|
||||
const LIVE_DIALOG_FRAME = [
|
||||
'● Bash(sleep 12; echo "slept 12s")',
|
||||
' ⎿ slept 12s',
|
||||
'────────────────────────────────────────',
|
||||
' ☐ Proceed',
|
||||
'',
|
||||
'Proceed?',
|
||||
'',
|
||||
'❯ 1. Yes',
|
||||
' Go ahead and proceed.',
|
||||
' 2. No',
|
||||
' Do not proceed.',
|
||||
' 3. Type something.',
|
||||
'────────────────────────────────────────',
|
||||
' 4. Chat about this',
|
||||
'',
|
||||
'Enter to select · ↑/↓ to navigate · Esc to cancel',
|
||||
].join('\n');
|
||||
|
||||
const WORKING_FRAME = [
|
||||
'● Bash(sleep 25)',
|
||||
' ⎿ Tip: Use git worktrees to run multiple Claude sessions in parallel.',
|
||||
'✢ Clauding… (13s · ↓ 1.4k tokens)',
|
||||
'────────────────────────────────────────',
|
||||
'❯',
|
||||
'────────────────────────────────────────',
|
||||
' ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt · ← for agents',
|
||||
].join('\n');
|
||||
|
||||
function collect(inbox: ApprovalInbox) {
|
||||
const pending: ApprovalItem[] = [];
|
||||
const updated: ApprovalItem[] = [];
|
||||
@@ -333,6 +366,186 @@ describe('ApprovalInbox', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('answered-in-the-terminal staleness', () => {
|
||||
// Claude Code delays the Notification hook behind the dialog (measured 6s
|
||||
// on v2.1.237, documented up to ~30s), so the 600ms re-capture routinely
|
||||
// lands on a frame the user has ALREADY answered. Erasing `options` there
|
||||
// made the item permanently unsweepable, because a missing `options` is how
|
||||
// "we never could read this dialog" is expressed, and such items stay
|
||||
// answerable on purpose. The red tab alert then survived every
|
||||
// `GET /api/approvals` and every reload, clearing only on `stop`.
|
||||
it('a re-capture taken after the answer does not erase parsed options', () => {
|
||||
const { updated } = collect(inbox);
|
||||
let frame = ASK_USER_QUESTION_FRAME;
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => frame,
|
||||
});
|
||||
expect(item.options).toHaveLength(5);
|
||||
|
||||
// Answered in the terminal before the re-capture fires.
|
||||
frame = "● User answered Claude's questions:\n ⎿ · Which color do you prefer? → Red\n\n✶ Cooking… (6s)";
|
||||
vi.advanceTimersByTime(600);
|
||||
|
||||
expect(updated).toHaveLength(1);
|
||||
expect(inbox.getById(item.id)?.context).toContain('User answered');
|
||||
expect(inbox.getById(item.id)?.options).toHaveLength(5);
|
||||
// ...which keeps the staleness check conclusive instead of inconclusive.
|
||||
expect(inbox.verifyStillAnswerable(item.id)).toBe(false);
|
||||
expect(inbox.getById(item.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolveIfDialogGone resolves a dialog answered in the terminal', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
let frame = PERMISSION_FRAME;
|
||||
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission', capture: () => frame });
|
||||
frame = 'the dialog is gone, claude is typing';
|
||||
|
||||
inbox.resolveIfDialogGone('s1');
|
||||
|
||||
expect(inbox.getById(item.id)).toBeUndefined();
|
||||
expect(resolved.at(-1)).toMatchObject({ id: item.id, resolution: 'resolved_in_terminal' });
|
||||
});
|
||||
|
||||
it('resolveIfDialogGone keeps a dialog that is still on screen', () => {
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => PERMISSION_FRAME,
|
||||
});
|
||||
inbox.resolveIfDialogGone('s1');
|
||||
expect(inbox.getById(item.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolveIfDialogGone never touches an idle item (that is the working signal job)', () => {
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'idle',
|
||||
capture: () => 'composer is empty',
|
||||
});
|
||||
inbox.resolveIfDialogGone('s1');
|
||||
expect(inbox.getById(item.id)).toBeDefined();
|
||||
});
|
||||
|
||||
// The late-hook hole: Claude Code fires the Notification behind the dialog,
|
||||
// so a prompt answered before the hook lands produces an item whose FIRST
|
||||
// capture already has no dialog in it. Nothing ever parsed, so "options
|
||||
// vanished" can never fire, and `stop` had already gone by too — the red
|
||||
// alert then survived reloads until the 12h TTL.
|
||||
it('resolves an item that never parsed options once the pane is visibly working', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => WORKING_FRAME,
|
||||
});
|
||||
expect(item.options).toBeUndefined();
|
||||
|
||||
expect(inbox.verifyStillAnswerable(item.id)).toBe(false);
|
||||
expect(inbox.getById(item.id)).toBeUndefined();
|
||||
expect(resolved.at(-1)).toMatchObject({ id: item.id, resolution: 'resolved_in_terminal' });
|
||||
});
|
||||
|
||||
it('keeps an unreadable dialog answerable when the pane is NOT visibly working', () => {
|
||||
// The conservative rule this fix must not loosen: no options and no proof
|
||||
// the turn is running means "we cannot read it", not "it is gone".
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => 'some dialog shape we cannot parse',
|
||||
});
|
||||
expect(item.options).toBeUndefined();
|
||||
expect(inbox.verifyStillAnswerable(item.id)).toBe(true);
|
||||
expect(inbox.getById(item.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it('a real live-dialog frame carries no working line, so it is never false-resolved', () => {
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => LIVE_DIALOG_FRAME,
|
||||
});
|
||||
expect(item.options).toHaveLength(4);
|
||||
expect(inbox.verifyStillAnswerable(item.id)).toBe(true);
|
||||
expect(inbox.getById(item.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolveIfDialogGone clears a late-hook item on the next working signal', () => {
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => WORKING_FRAME,
|
||||
});
|
||||
inbox.resolveIfDialogGone('s1');
|
||||
expect(inbox.getById(item.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('the delayed staleness pass resolves a late-hook item with no working signal needed', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => WORKING_FRAME,
|
||||
});
|
||||
expect(item.options).toBeUndefined();
|
||||
|
||||
vi.advanceTimersByTime(600); // re-capture: enrichment only, never resolves
|
||||
expect(inbox.getById(item.id)).toBeDefined();
|
||||
|
||||
vi.advanceTimersByTime(2400); // the delayed staleness pass
|
||||
expect(inbox.getById(item.id)).toBeUndefined();
|
||||
expect(resolved.at(-1)).toMatchObject({ id: item.id, resolution: 'resolved_in_terminal' });
|
||||
});
|
||||
|
||||
it('a dialog Ink paints late is NOT resolved by the delayed pass', () => {
|
||||
// The paint race the re-capture exists for: the hook can beat Ink to the
|
||||
// screen. Resolving inside that window would clear the alert for a dialog
|
||||
// that was about to appear, so the frame is what decides, every time.
|
||||
let frame = WORKING_FRAME;
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => frame,
|
||||
});
|
||||
frame = LIVE_DIALOG_FRAME; // Ink finishes painting
|
||||
|
||||
vi.advanceTimersByTime(600);
|
||||
expect(inbox.getById(item.id)?.options).toHaveLength(4);
|
||||
vi.advanceTimersByTime(2400);
|
||||
expect(inbox.getById(item.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolving cancels both pending timers', () => {
|
||||
const { updated } = collect(inbox);
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => PERMISSION_FRAME,
|
||||
});
|
||||
inbox.dismiss(item.id);
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(updated).toHaveLength(0);
|
||||
expect(inbox.listPending()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('resolveIfDialogGone is a no-op for a session with nothing pending', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
expect(() => inbox.resolveIfDialogGone('nobody')).not.toThrow();
|
||||
expect(resolved).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('stop() clears items and silences events', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
|
||||
|
||||
@@ -147,3 +147,94 @@ describe('tmux-backed shell: strip tmux’s own client smcup, keep everything el
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Whatever the strip removes, the server has to remember, because after it runs
|
||||
* nothing downstream can ever see it. The browser hand-encodes click reports for
|
||||
* these modes (`_sendSyntheticSgrTap`), and with no state to consult it did that
|
||||
* on EVERY click, delivering mouse reports to a CLI that never asked for them.
|
||||
*/
|
||||
describe('stripped mouse-tracking state', () => {
|
||||
const trackingOf = (session: Session) => session.toState().cliMouseTracking;
|
||||
|
||||
it('starts off, and stays off for output that never enables tracking', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
expect(trackingOf(session)).toBeUndefined();
|
||||
|
||||
handleOutput(session, 'plain output\x1b[?1049h\x1b[3J');
|
||||
|
||||
expect(trackingOf(session)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('follows the CLI enabling and disabling a tracking mode', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
const changes: boolean[] = [];
|
||||
session.on('mouseTrackingChanged', (active: boolean) => changes.push(active));
|
||||
|
||||
handleOutput(session, '\x1b[?1002hdialog');
|
||||
expect(trackingOf(session)).toBe(true);
|
||||
|
||||
handleOutput(session, '\x1b[?1002ldismissed');
|
||||
expect(trackingOf(session)).toBeUndefined();
|
||||
expect(changes).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('ignores encoding and alt-scroll modes, which do not ask about clicks', () => {
|
||||
// 1005/1006 pick an ENCODING and 1007 is alt-scroll. Counting them would put
|
||||
// the stray reports straight back: a CLI can select SGR encoding without ever
|
||||
// asking to be told where the user clicked.
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
|
||||
handleOutput(session, '\x1b[?1006h\x1b[?1005h\x1b[?1007h');
|
||||
|
||||
expect(trackingOf(session)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stays on until the LAST tracking mode goes away', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
|
||||
handleOutput(session, '\x1b[?1000h\x1b[?1002h\x1b[?1006h');
|
||||
expect(trackingOf(session)).toBe(true);
|
||||
|
||||
// A TUI may disable a mode it never enabled; that must not clear the rest.
|
||||
handleOutput(session, '\x1b[?1003l');
|
||||
expect(trackingOf(session)).toBe(true);
|
||||
|
||||
handleOutput(session, '\x1b[?1000l');
|
||||
expect(trackingOf(session)).toBe(true);
|
||||
|
||||
handleOutput(session, '\x1b[?1002l');
|
||||
expect(trackingOf(session)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits only on a real transition, so a repainting TUI costs nothing', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
const changes: boolean[] = [];
|
||||
session.on('mouseTrackingChanged', (active: boolean) => changes.push(active));
|
||||
|
||||
handleOutput(session, '\x1b[?1002h\x1b[?1002h\x1b[?1002h');
|
||||
|
||||
expect(changes).toEqual([true]);
|
||||
});
|
||||
|
||||
it('sees a sequence split across PTY chunks, like the strip that carries it', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
|
||||
handleOutput(session, 'before\x1b[?100');
|
||||
handleOutput(session, '2h after');
|
||||
|
||||
expect(session.terminalBuffer).toBe('before after');
|
||||
expect(trackingOf(session)).toBe(true);
|
||||
});
|
||||
|
||||
it('tracks nothing for a mode whose DECSETs are never stripped', () => {
|
||||
// shell keeps its mouse DECSETs, so xterm sees them and owns the reporting.
|
||||
// A flag set here would mean a SECOND, hand-encoded report on every click.
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
|
||||
|
||||
handleOutput(session, '\x1b[?1002hhtop');
|
||||
|
||||
expect(trackingOf(session)).toBeUndefined();
|
||||
expect(session.terminalBuffer).toBe('\x1b[?1002hhtop');
|
||||
});
|
||||
});
|
||||
|
||||
+177
-805
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,400 @@
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EXEC_TIMEOUT_MS } from '../src/config/exec-timeout.js';
|
||||
import {
|
||||
cliResolveRetryDelayMs,
|
||||
createCliExecutableResolver,
|
||||
createProductionCliResolverHost,
|
||||
formatCliNotFoundMessage,
|
||||
type CliResolverHost,
|
||||
} from '../src/utils/cli-executable-resolver.js';
|
||||
|
||||
// Pass-through spy on execFileSync so the vitest-hermeticity test below can
|
||||
// PROVE the un-injected production host never spawns anything.
|
||||
const { execFileSyncSpy } = vi.hoisted(() => ({ execFileSyncSpy: vi.fn() }));
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:child_process')>();
|
||||
execFileSyncSpy.mockImplementation(actual.execFileSync as (...args: unknown[]) => unknown);
|
||||
return { ...actual, execFileSync: execFileSyncSpy };
|
||||
});
|
||||
|
||||
const BEGIN_MARKER = '__CODEMAN_CLI_RESOLVE_BEGIN__';
|
||||
const END_MARKER = '__CODEMAN_CLI_RESOLVE_END__';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function host(overrides: Partial<CliResolverHost> = {}): CliResolverHost {
|
||||
return {
|
||||
processPath: '/usr/bin:/bin',
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'],
|
||||
findOnProcessPath: vi.fn(() => null),
|
||||
findInLoginShell: vi.fn(() => null),
|
||||
exists: vi.fn(() => false),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('createCliExecutableResolver', () => {
|
||||
it('prefers the server process PATH over common directories and the login shell', () => {
|
||||
const h = host({
|
||||
findOnProcessPath: vi.fn(() => '/process/bin/codex'),
|
||||
findInLoginShell: vi.fn(() => '/shell/bin/codex'),
|
||||
exists: vi.fn(() => true),
|
||||
});
|
||||
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: ['/known/bin'] }, h);
|
||||
|
||||
expect(resolver.resolve()).toMatchObject({ binaryPath: '/process/bin/codex', source: 'process-path' });
|
||||
expect(h.exists).toHaveBeenCalledTimes(1);
|
||||
expect(h.findInLoginShell).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prefers common directories in order over the login shell', () => {
|
||||
const h = host({
|
||||
findInLoginShell: vi.fn(() => '/shell/bin/codex'),
|
||||
exists: vi.fn((path) => path === '/second/bin/codex' || path === '/shell/bin/codex'),
|
||||
});
|
||||
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: ['/first/bin', '/second/bin'] }, h);
|
||||
|
||||
expect(resolver.resolve()).toMatchObject({ binaryPath: '/second/bin/codex', source: 'common-directory' });
|
||||
expect(h.exists).toHaveBeenNthCalledWith(1, '/first/bin/codex');
|
||||
expect(h.exists).toHaveBeenNthCalledWith(2, '/second/bin/codex');
|
||||
expect(h.findInLoginShell).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('finds an executable exposed only by the interactive login shell', () => {
|
||||
const h = host({
|
||||
findInLoginShell: vi.fn(() => '/home/u/.nvm/versions/node/v22/bin/codex'),
|
||||
exists: vi.fn((path) => path === '/home/u/.nvm/versions/node/v22/bin/codex'),
|
||||
});
|
||||
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: ['/known/bin'] }, h);
|
||||
|
||||
expect(resolver.resolve()).toMatchObject({
|
||||
binaryPath: '/home/u/.nvm/versions/node/v22/bin/codex',
|
||||
directory: '/home/u/.nvm/versions/node/v22/bin',
|
||||
source: 'login-shell',
|
||||
});
|
||||
});
|
||||
|
||||
it('continues after a validator rejects an earlier candidate', () => {
|
||||
const h = host({
|
||||
findOnProcessPath: vi.fn(() => '/usr/bin/pi'),
|
||||
findInLoginShell: vi.fn(() => '/home/u/.npm/bin/pi'),
|
||||
exists: vi.fn(() => true),
|
||||
});
|
||||
const resolver = createCliExecutableResolver(
|
||||
{
|
||||
binary: 'pi',
|
||||
searchDirs: [],
|
||||
validateCandidate: (path) =>
|
||||
path.includes('.npm') ? { accepted: true, metadata: '0.84.1' } : { accepted: false },
|
||||
},
|
||||
h
|
||||
);
|
||||
|
||||
expect(resolver.resolve()).toMatchObject({
|
||||
binaryPath: '/home/u/.npm/bin/pi',
|
||||
source: 'login-shell',
|
||||
metadata: '0.84.1',
|
||||
});
|
||||
});
|
||||
|
||||
it('caches success, and retries a miss only after the backoff elapses', () => {
|
||||
let now = 0;
|
||||
const findInLoginShell = vi.fn<() => string | null>().mockReturnValueOnce(null).mockReturnValue('/new/bin/codex');
|
||||
const h = host({ findInLoginShell, exists: vi.fn((path) => path === '/new/bin/codex') });
|
||||
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [], now: () => now }, h);
|
||||
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
// Within the backoff window the miss is answered from the negative cache:
|
||||
// the chain — whose login-shell tail is a synchronous 5s-bounded spawn —
|
||||
// must NOT re-run per call, or a missing CLI stalls every status request.
|
||||
now = cliResolveRetryDelayMs(1) - 1;
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Once the backoff elapses the retry runs, so installing a CLI while the
|
||||
// server is up is still picked up without a restart.
|
||||
now = cliResolveRetryDelayMs(1);
|
||||
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex');
|
||||
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex');
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('doubles the retry delay per consecutive miss and caps it at five minutes', () => {
|
||||
let now = 0;
|
||||
const findInLoginShell = vi.fn(() => null);
|
||||
const resolver = createCliExecutableResolver(
|
||||
{ binary: 'codex', searchDirs: [], now: () => now },
|
||||
host({ findInLoginShell })
|
||||
);
|
||||
|
||||
expect(cliResolveRetryDelayMs(0)).toBe(0);
|
||||
expect(cliResolveRetryDelayMs(1)).toBe(60_000);
|
||||
expect(cliResolveRetryDelayMs(2)).toBe(120_000);
|
||||
expect(cliResolveRetryDelayMs(3)).toBe(240_000);
|
||||
expect(cliResolveRetryDelayMs(4)).toBe(300_000);
|
||||
expect(cliResolveRetryDelayMs(60)).toBe(300_000);
|
||||
|
||||
// Consecutive misses stack: after the second miss the SECOND delay applies.
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
now += cliResolveRetryDelayMs(1);
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||
now += cliResolveRetryDelayMs(2) - 1;
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||
now += 1;
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('rejects unsafe binary names', () => {
|
||||
const h = host();
|
||||
|
||||
expect(() => createCliExecutableResolver({ binary: 'codex;id', searchDirs: [] }, h)).toThrow(
|
||||
'Unsafe CLI binary name'
|
||||
);
|
||||
expect(() => createCliExecutableResolver({ binary: '../codex', searchDirs: [] }, h)).toThrow(
|
||||
'Unsafe CLI binary name'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects relative and nonexistent candidates', () => {
|
||||
let now = 0;
|
||||
const findInLoginShell = vi
|
||||
.fn<() => string | null>()
|
||||
.mockReturnValueOnce('relative/codex')
|
||||
.mockReturnValue('/missing/codex');
|
||||
const h = host({ findInLoginShell, exists: vi.fn(() => false) });
|
||||
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [], now: () => now }, h);
|
||||
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
now = cliResolveRetryDelayMs(1);
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(h.exists).toHaveBeenCalledTimes(1);
|
||||
expect(h.exists).toHaveBeenCalledWith('/missing/codex');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCliNotFoundMessage', () => {
|
||||
it('includes only the base install hint and bounded resolution diagnostics', () => {
|
||||
const base = 'Codex CLI not found. Install with: npm install -g @openai/codex';
|
||||
const diagnostics = {
|
||||
binary: 'codex',
|
||||
processPath: '/usr/bin:/bin',
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'],
|
||||
searchDirs: ['/home/u/.local/bin', '/usr/local/bin'],
|
||||
API_KEY: 'super-secret',
|
||||
};
|
||||
const message = formatCliNotFoundMessage(base, diagnostics);
|
||||
|
||||
expect(message).toContain(base);
|
||||
expect(message).toContain('Server PATH: /usr/bin:/bin');
|
||||
expect(message).toContain('Login shell: /bin/bash -i -l');
|
||||
expect(message).toContain('Checked directories: /home/u/.local/bin, /usr/local/bin');
|
||||
expect(message).not.toContain('API_KEY');
|
||||
expect(message).not.toContain('super-secret');
|
||||
});
|
||||
|
||||
it('marks empty diagnostic values without dumping arbitrary environment data', () => {
|
||||
const message = formatCliNotFoundMessage('Missing CLI', {
|
||||
binary: 'codex',
|
||||
processPath: '',
|
||||
shellPath: '',
|
||||
shellArgs: [],
|
||||
searchDirs: [],
|
||||
});
|
||||
|
||||
expect(message).toBe('Missing CLI\nServer PATH: (empty)\nLogin shell: (none)\nChecked directories: (none)');
|
||||
expect(message).not.toContain('HOME=');
|
||||
expect(message).not.toContain('TOKEN=');
|
||||
});
|
||||
|
||||
it('flattens control characters and bounds every diagnostic field', () => {
|
||||
const pathological = `first\r\nforged label: value\u0000${'x'.repeat(10_000)}`;
|
||||
const message = formatCliNotFoundMessage('Missing CLI', {
|
||||
binary: 'codex',
|
||||
processPath: pathological,
|
||||
shellPath: pathological,
|
||||
shellArgs: [pathological],
|
||||
searchDirs: [pathological, pathological],
|
||||
});
|
||||
const lines = message.split('\n');
|
||||
|
||||
expect(lines).toHaveLength(4);
|
||||
expect(lines[1]).toMatch(/^Server PATH: first forged label: value x+…$/);
|
||||
expect(lines[2]).toMatch(/^Login shell: first forged label: value x+…$/);
|
||||
expect(lines[3]).toMatch(/^Checked directories: first forged label: value x+…$/);
|
||||
expect(lines.slice(1).every((line) => line.length <= 1_050)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createProductionCliResolverHost', () => {
|
||||
// Hermeticity gate (the guards PR #329 deleted, restored shared): under
|
||||
// vitest an un-injected host must neither scan the machine nor spawn a login
|
||||
// shell — route tests hitting the per-CLI status endpoints would otherwise
|
||||
// walk the real PATH and execute real binaries on whatever box runs the suite.
|
||||
it('never scans the machine or spawns a login shell under vitest without injected IO', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'codeman-cli-vitest-gate-'));
|
||||
temporaryDirectories.push(root);
|
||||
writeFileSync(join(root, 'codex'), '#!/bin/sh\n');
|
||||
chmodSync(join(root, 'codex'), 0o755);
|
||||
execFileSyncSpy.mockClear();
|
||||
|
||||
const gatedHost = createProductionCliResolverHost({
|
||||
processPath: root,
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'],
|
||||
});
|
||||
|
||||
// The real, executable candidate is invisible: the filesystem predicate is inert.
|
||||
expect(gatedHost.findOnProcessPath('codex')).toBeNull();
|
||||
expect(gatedHost.exists(join(root, 'codex'))).toBe(false);
|
||||
// The login-shell step yields nothing and never reaches execFileSync.
|
||||
expect(gatedHost.findInLoginShell('codex')).toBeNull();
|
||||
expect(execFileSyncSpy).not.toHaveBeenCalled();
|
||||
|
||||
// The same fixture through the test-only real-IO opt-in IS found, proving
|
||||
// the nulls above come from the vitest gate rather than from the fixture.
|
||||
const optedInHost = createProductionCliResolverHost({
|
||||
processPath: root,
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'],
|
||||
runCommand: () => '',
|
||||
allowRealIoUnderVitest: true,
|
||||
});
|
||||
expect(optedInHost.findOnProcessPath('codex')).toBe(join(root, 'codex'));
|
||||
});
|
||||
|
||||
it('resolves through injected IO hooks under vitest (injection is the opt-in)', () => {
|
||||
const runCommand = vi.fn(() => `${BEGIN_MARKER}\n/home/u/.nvm/bin/codex\n${END_MARKER}`);
|
||||
const productionHost = createProductionCliResolverHost({
|
||||
processPath: '',
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'],
|
||||
runCommand,
|
||||
isExecutableFile: () => true,
|
||||
});
|
||||
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [] }, productionHost);
|
||||
|
||||
expect(resolver.resolve()).toMatchObject({
|
||||
binaryPath: '/home/u/.nvm/bin/codex',
|
||||
source: 'login-shell',
|
||||
});
|
||||
expect(runCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('searches the captured process PATH directly in directory order without running a command', () => {
|
||||
const runCommand = vi.fn(() => '');
|
||||
const isExecutableFile = vi.fn((path: string) => path === '/second/bin/codex');
|
||||
const productionHost = createProductionCliResolverHost({
|
||||
processPath: '/first/bin:/second/bin:/third/bin',
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'],
|
||||
runCommand,
|
||||
isExecutableFile,
|
||||
});
|
||||
|
||||
expect(productionHost.findOnProcessPath('codex')).toBe('/second/bin/codex');
|
||||
expect(isExecutableFile).toHaveBeenNthCalledWith(1, '/first/bin/codex');
|
||||
expect(isExecutableFile).toHaveBeenNthCalledWith(2, '/second/bin/codex');
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts only executable regular files with the production predicate', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'codeman-cli-resolver-'));
|
||||
temporaryDirectories.push(root);
|
||||
const executableDirectory = join(root, 'executable');
|
||||
const plainDirectory = join(root, 'plain');
|
||||
const directoryCandidate = join(root, 'directory');
|
||||
mkdirSync(executableDirectory);
|
||||
mkdirSync(plainDirectory);
|
||||
mkdirSync(directoryCandidate);
|
||||
writeFileSync(join(executableDirectory, 'codex'), '#!/bin/sh\n');
|
||||
chmodSync(join(executableDirectory, 'codex'), 0o755);
|
||||
writeFileSync(join(plainDirectory, 'codex'), '#!/bin/sh\n');
|
||||
mkdirSync(join(directoryCandidate, 'codex'));
|
||||
const productionHost = createProductionCliResolverHost({
|
||||
processPath: [directoryCandidate, plainDirectory, executableDirectory].join(':'),
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'],
|
||||
// This test exists to exercise the REAL executable-regular-file predicate
|
||||
// against its own temp fixtures, so it opts out of the vitest inert-IO
|
||||
// gate; the stubbed runCommand keeps the login-shell path inert anyway.
|
||||
runCommand: () => '',
|
||||
allowRealIoUnderVitest: true,
|
||||
});
|
||||
|
||||
expect(productionHost.findOnProcessPath('codex')).toBe(join(executableDirectory, 'codex'));
|
||||
});
|
||||
|
||||
it('uses the resolved shell, allowlisted args, tagged command, and bounded timeout', () => {
|
||||
const runCommand = vi.fn(() =>
|
||||
['/profile/absolute-noise', BEGIN_MARKER, '/home/u/.nvm/bin/codex', END_MARKER, '/exit-trap/absolute-noise'].join(
|
||||
'\n'
|
||||
)
|
||||
);
|
||||
const productionHost = createProductionCliResolverHost({
|
||||
processPath: '',
|
||||
shellPath: '/usr/bin/fish',
|
||||
shellArgs: ['-i', '-l'],
|
||||
runCommand,
|
||||
isExecutableFile: () => true,
|
||||
});
|
||||
|
||||
expect(productionHost.findInLoginShell('codex')).toBe('/home/u/.nvm/bin/codex');
|
||||
expect(runCommand).toHaveBeenCalledWith(
|
||||
'/usr/bin/fish',
|
||||
['-i', '-l', '-c', `printf '%s\\n' '${BEGIN_MARKER}'; command -v -- codex; printf '%s\\n' '${END_MARKER}'`],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
// SIGKILL is load-bearing: interactive bash ignores SIGTERM, and
|
||||
// execFileSync's timeout only sends the signal, then keeps waiting.
|
||||
killSignal: 'SIGKILL',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['mismatched basename', `${BEGIN_MARKER}\n/opt/bin/not-codex\n${END_MARKER}`],
|
||||
['missing begin marker', `/opt/bin/codex\n${END_MARKER}`],
|
||||
['missing end marker', `${BEGIN_MARKER}\n/opt/bin/codex`],
|
||||
['absolute output outside markers', `/profile/codex\n${BEGIN_MARKER}\nrelative/codex\n${END_MARKER}\n/exit/codex`],
|
||||
])('rejects malformed tagged shell output: %s', (_name, output) => {
|
||||
const productionHost = createProductionCliResolverHost({
|
||||
processPath: '',
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'],
|
||||
runCommand: () => output,
|
||||
isExecutableFile: () => true,
|
||||
});
|
||||
|
||||
expect(productionHost.findInLoginShell('codex')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the shell command throws', () => {
|
||||
const productionHost = createProductionCliResolverHost({
|
||||
processPath: '',
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'],
|
||||
runCommand: () => {
|
||||
throw new Error('exit 1');
|
||||
},
|
||||
isExecutableFile: () => true,
|
||||
});
|
||||
|
||||
expect(productionHost.findInLoginShell('codex')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the pure half of the CLI style kit: display-width
|
||||
* math, column layout, kv padding, glyph selection, and the spinner's non-TTY
|
||||
* behavior. Nothing here needs a terminal.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
GLYPH,
|
||||
SPINNER_FRAMES,
|
||||
columnWidths,
|
||||
confirm,
|
||||
displayWidth,
|
||||
glyphFor,
|
||||
isInteractive,
|
||||
kv,
|
||||
layoutTable,
|
||||
padCell,
|
||||
padStyled,
|
||||
palette,
|
||||
spinner,
|
||||
table,
|
||||
tint,
|
||||
type SpinnerStream,
|
||||
} from '../src/cli-style.js';
|
||||
|
||||
/** Recording stand-in for `process.stderr`. */
|
||||
function fakeStream(isTTY: boolean): SpinnerStream & { writes: string[] } {
|
||||
const writes: string[] = [];
|
||||
return {
|
||||
isTTY,
|
||||
writes,
|
||||
write(chunk: string) {
|
||||
writes.push(chunk);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('displayWidth', () => {
|
||||
it('counts printable columns, not bytes', () => {
|
||||
expect(displayWidth('tmux')).toBe(4);
|
||||
expect(displayWidth('')).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores ANSI sequences', () => {
|
||||
expect(displayWidth('\x1b[32mok\x1b[39m')).toBe(2);
|
||||
expect(displayWidth(palette.ok('ok'))).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('padCell', () => {
|
||||
it('pads to the requested column count', () => {
|
||||
expect(padCell('ab', 5)).toBe('ab ');
|
||||
expect(padCell('ab', 5, 'right')).toBe(' ab');
|
||||
});
|
||||
|
||||
it('never truncates a cell that is already too wide', () => {
|
||||
expect(padCell('Antigravity CLI', 4)).toBe('Antigravity CLI');
|
||||
});
|
||||
|
||||
it('pads a colored cell by its printed width', () => {
|
||||
const padded = padCell(palette.ok('ok'), 6);
|
||||
expect(displayWidth(padded)).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('padStyled', () => {
|
||||
it('keeps the fill outside the paint so trailing space can be trimmed', () => {
|
||||
const cell = padStyled('ok', 6, (t) => `<${t}>`);
|
||||
expect(cell).toBe('<ok> ');
|
||||
expect(cell.trimEnd()).toBe('<ok>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('columnWidths', () => {
|
||||
it('measures the widest cell per column', () => {
|
||||
expect(
|
||||
columnWidths([
|
||||
['tmux', '3.4'],
|
||||
['Antigravity CLI', 'not found'],
|
||||
])
|
||||
).toEqual([15, 9]);
|
||||
});
|
||||
|
||||
it('treats missing cells as empty, never as a narrower column', () => {
|
||||
expect(columnWidths([['a', 'bbb'], ['a']])).toEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layoutTable', () => {
|
||||
// The bug this replaces: `padEnd(14)` with a 15-character label ("Antigravity
|
||||
// CLI") pushed that row's remaining columns one column right.
|
||||
const rows = [
|
||||
['✓', 'tmux', '3.4'],
|
||||
['✓', 'Antigravity CLI', '1.1.12'],
|
||||
['○', 'Pi CLI', 'not found'],
|
||||
];
|
||||
|
||||
it('starts every column at the same offset regardless of cell length', () => {
|
||||
const lines = layoutTable(rows, { indent: ' ' });
|
||||
expect(lines[0].indexOf('3.4')).toBe(lines[1].indexOf('1.1.12'));
|
||||
expect(lines[1].indexOf('1.1.12')).toBe(lines[2].indexOf('not found'));
|
||||
// Widest label (15) + indent (2) + glyph column (1) + two gaps.
|
||||
expect(lines[1].indexOf('1.1.12')).toBe(2 + 1 + 1 + 15 + 1);
|
||||
});
|
||||
|
||||
it('leaves no trailing whitespace on the last column', () => {
|
||||
for (const line of layoutTable(rows)) {
|
||||
expect(line).toBe(line.trimEnd());
|
||||
}
|
||||
});
|
||||
|
||||
it('honors indent, gap and right alignment', () => {
|
||||
const lines = layoutTable(
|
||||
[
|
||||
['a', '1'],
|
||||
['bbb', '22'],
|
||||
],
|
||||
{ indent: '> ', gap: 3, align: ['right'] }
|
||||
);
|
||||
expect(lines[0]).toBe('> a 1');
|
||||
expect(lines[1]).toBe('> bbb 22');
|
||||
});
|
||||
|
||||
it('aligns colored cells by printed width', () => {
|
||||
const lines = layoutTable([
|
||||
[palette.ok('✓'), palette.emph('tmux'), '3.4'],
|
||||
[palette.err('✗'), 'Antigravity CLI', 'not found'],
|
||||
]);
|
||||
expect(lines[0].indexOf('3.4')).toBe(lines[1].indexOf('not found'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('table', () => {
|
||||
it('joins the laid-out rows', () => {
|
||||
expect(
|
||||
table([
|
||||
['a', 'b'],
|
||||
['cc', 'd'],
|
||||
])
|
||||
).toBe('a b\ncc d');
|
||||
});
|
||||
});
|
||||
|
||||
describe('kv', () => {
|
||||
it('indents and appends the colon', () => {
|
||||
expect(kv('Status', 'running')).toBe(' Status: running');
|
||||
});
|
||||
|
||||
it('aligns a block when a pad width is given', () => {
|
||||
const lines = [kv('Daemon pid', '42', 11), kv('Log', '/tmp/web.log', 11)];
|
||||
expect(lines[0].indexOf('42')).toBe(lines[1].indexOf('/tmp/web.log'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('glyphs', () => {
|
||||
it('maps tones to the CLI glyph vocabulary', () => {
|
||||
expect(glyphFor('ok')).toBe(GLYPH.ok);
|
||||
expect(glyphFor('err')).toBe(GLYPH.fail);
|
||||
expect(glyphFor('warn')).toBe(GLYPH.warn);
|
||||
expect(glyphFor('idle')).toBe(GLYPH.idle);
|
||||
expect(glyphFor('info')).toBe(GLYPH.dot);
|
||||
});
|
||||
|
||||
it('tints without changing the printed text', () => {
|
||||
expect(displayWidth(tint('err', 'nope'))).toBe(4);
|
||||
expect(tint('err', 'nope')).toContain('nope');
|
||||
});
|
||||
});
|
||||
|
||||
describe('spinner', () => {
|
||||
it('prints the text once and stays silent when the stream is not a TTY', () => {
|
||||
const stream = fakeStream(false);
|
||||
const handle = spinner('waiting', { stream }).start();
|
||||
handle.setText('still waiting');
|
||||
handle.stop('done');
|
||||
expect(stream.writes).toEqual(['waiting\n']);
|
||||
});
|
||||
|
||||
it('animates in place on a TTY and restores the cursor on stop', () => {
|
||||
const stream = fakeStream(true);
|
||||
const handle = spinner('waiting', { stream, intervalMs: 60_000 }).start();
|
||||
expect(stream.writes[0]).toBe('\x1b[?25l');
|
||||
expect(stream.writes[1]).toContain(SPINNER_FRAMES[0]);
|
||||
expect(stream.writes[1]).toContain('waiting');
|
||||
|
||||
handle.stop();
|
||||
const tail = stream.writes.join('');
|
||||
expect(tail).toContain('\r\x1b[K');
|
||||
expect(tail).toContain('\x1b[?25h');
|
||||
});
|
||||
|
||||
it('ignores a second stop', () => {
|
||||
const stream = fakeStream(true);
|
||||
const handle = spinner('waiting', { stream, intervalMs: 60_000 }).start();
|
||||
handle.stop();
|
||||
const afterFirst = stream.writes.length;
|
||||
handle.stop();
|
||||
expect(stream.writes.length).toBe(afterFirst);
|
||||
});
|
||||
|
||||
it('does nothing at all when it was never started', () => {
|
||||
const stream = fakeStream(true);
|
||||
spinner('waiting', { stream }).stop();
|
||||
expect(stream.writes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirm', () => {
|
||||
it('answers no without blocking when stdin is not a TTY', async () => {
|
||||
const original = process.stdin.isTTY;
|
||||
try {
|
||||
process.stdin.isTTY = false;
|
||||
expect(isInteractive()).toBe(false);
|
||||
await expect(confirm('Reset all Codeman state?')).resolves.toBe(false);
|
||||
} finally {
|
||||
process.stdin.isTTY = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,7 @@ describe('xterm snapshot/replay (codex tab-switch)', () => {
|
||||
const declaration = source.indexOf('let restoredSnapshot = false;', selectStart);
|
||||
const snapshotBranch = source.indexOf("if (snapshot && !sessionIsBusy && session?.mode !== 'shell')", selectStart);
|
||||
const rewriteDecision = source.indexOf(
|
||||
'restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer',
|
||||
'restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer',
|
||||
selectStart
|
||||
);
|
||||
|
||||
@@ -58,7 +58,9 @@ describe('xterm snapshot/replay (codex tab-switch)', () => {
|
||||
// Snapshot restore must NOT short-circuit the canonical fetch.
|
||||
expect(snapshotBlock).not.toContain('this._finishBufferLoad();');
|
||||
expect(postSnapshotRestore).toContain('restoredSnapshot');
|
||||
expect(postSnapshotRestore).toContain('restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer');
|
||||
expect(postSnapshotRestore).toContain(
|
||||
'restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer'
|
||||
);
|
||||
});
|
||||
|
||||
it('forces replay after clearing a busy tab even when the fetched frame matches cache', () => {
|
||||
@@ -71,8 +73,8 @@ describe('xterm snapshot/replay (codex tab-switch)', () => {
|
||||
expect(cacheRestore).toBeGreaterThan(-1);
|
||||
expect(busyClear).toBeGreaterThan(cacheRestore);
|
||||
expect(needsRewrite).toBeGreaterThan(busyClear);
|
||||
expect(replayBlock).toContain('clearedForBusy');
|
||||
expect(replayBlock).toContain('restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer');
|
||||
expect(replayBlock).toContain('clearedBeforeFresh');
|
||||
expect(replayBlock).toContain('restoredSnapshot || clearedBeforeFresh || data.terminalBuffer !== cachedBuffer');
|
||||
});
|
||||
|
||||
it('loads the SerializeAddon and keeps a per-session snapshot map', () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ vi.mock('node:fs', async (orig) => {
|
||||
return { ...actual, mkdirSync: vi.fn() };
|
||||
});
|
||||
|
||||
const ENV_KEYS = ['CODEMAN_INSTANCE', 'CODEMAN_DATA_DIR'] as const;
|
||||
const ENV_KEYS = ['CODEMAN_INSTANCE', 'CODEMAN_DATA_DIR', 'CODEMAN_TMUX_SOCKET'] as const;
|
||||
const ORIG: Record<string, string | undefined> = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
||||
|
||||
async function load(env: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>> = {}) {
|
||||
@@ -77,3 +77,22 @@ describe('config/instance', () => {
|
||||
expect(m.DEFAULT_TMUX_SOCKET).toBe('codeman-beta');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTmuxSocketName', () => {
|
||||
it('is the instance socket when no override is set', async () => {
|
||||
const m = await load({ CODEMAN_INSTANCE: 'beta', CODEMAN_TMUX_SOCKET: undefined });
|
||||
expect(m.resolveTmuxSocketName()).toBe('codeman-beta');
|
||||
});
|
||||
|
||||
it('honours a safe CODEMAN_TMUX_SOCKET override', async () => {
|
||||
const m = await load({ CODEMAN_INSTANCE: undefined, CODEMAN_TMUX_SOCKET: 'codeman-test.1' });
|
||||
expect(m.resolveTmuxSocketName()).toBe('codeman-test.1');
|
||||
});
|
||||
|
||||
it('ignores an override that could not be passed to `tmux -L` safely', async () => {
|
||||
// A socket name reaches a command line, so anything outside the pattern
|
||||
// falls back to the instance default rather than being escaped.
|
||||
const m = await load({ CODEMAN_INSTANCE: undefined, CODEMAN_TMUX_SOCKET: 'bad; rm -rf /' });
|
||||
expect(m.resolveTmuxSocketName()).toBe('codeman');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,48 @@ describe('renderTable', () => {
|
||||
expect(out).toContain('document preview');
|
||||
expect(out).toContain('sudo apt install tmux');
|
||||
});
|
||||
|
||||
it('stays color-free unless the caller passes a style', () => {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
expect(renderTable(results, 'linux')).not.toMatch(/\x1b\[/);
|
||||
});
|
||||
|
||||
it('aligns the status column past a label wider than the old padEnd(14)', () => {
|
||||
const wide: ToolResult[] = [
|
||||
...results,
|
||||
{ id: 'agy', label: 'Antigravity CLI', category: 'core', required: false, usedBy: [], status: 'missing' },
|
||||
];
|
||||
const lines = renderTable(wide, 'linux').split('\n');
|
||||
const nodeLine = lines.find((l) => l.includes('Node.js'))!;
|
||||
const agyLine = lines.find((l) => l.includes('Antigravity CLI'))!;
|
||||
expect(nodeLine.indexOf('22.22.1')).toBe(agyLine.indexOf('not found'));
|
||||
});
|
||||
|
||||
it('applies the caller-supplied paint hooks without shifting the columns', () => {
|
||||
const plain = renderTable(results, 'linux').split('\n');
|
||||
const styled = renderTable(results, 'linux', {
|
||||
title: (t) => `T{${t}}`,
|
||||
heading: (t) => `H{${t}}`,
|
||||
glyph: (_r, g) => `G{${g}}`,
|
||||
label: (t) => `L{${t}}`,
|
||||
status: (_r, t) => `S{${t}}`,
|
||||
path: (t) => `P{${t}}`,
|
||||
meta: (t) => `M{${t}}`,
|
||||
summary: (t) => `Z{${t}}`,
|
||||
}).split('\n');
|
||||
expect(styled[0]).toBe(`T{${plain[0]}}`);
|
||||
expect(styled.find((l) => l.includes('CORE'))).toBe('H{CORE}');
|
||||
const nodeLine = styled.find((l) => l.includes('Node.js'))!;
|
||||
expect(nodeLine).toContain('G{✓}');
|
||||
expect(nodeLine).toContain('L{Node.js}');
|
||||
expect(nodeLine).toContain('S{22.22.1}');
|
||||
expect(nodeLine).toContain('P{/n}');
|
||||
expect(styled.some((l) => l.startsWith('M{ used by:'))).toBe(true);
|
||||
expect(styled[styled.length - 1]).toBe(`Z{${plain[plain.length - 1]}}`);
|
||||
// Padding lives outside the paint, so a row with no path detail ends at its
|
||||
// status text rather than trailing invisible spaces inside a color run.
|
||||
expect(styled.find((l) => l.includes('S{n/a}'))!.endsWith('S{n/a}')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderJson', () => {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @fileoverview Tests for the pure file-query matcher (COD-236).
|
||||
*
|
||||
* Node-safe: no fs, no ports, no jsdom. Just the compile/apply matcher.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { compileFileQuery, matchFileQuery } from '../src/utils/file-query.js';
|
||||
|
||||
describe('compileFileQuery', () => {
|
||||
it('returns null for empty / whitespace-only queries', () => {
|
||||
expect(compileFileQuery('')).toBeNull();
|
||||
expect(compileFileQuery(' ')).toBeNull();
|
||||
expect(compileFileQuery('\t\n')).toBeNull();
|
||||
});
|
||||
|
||||
it('does a case-insensitive substring match on the name by default', () => {
|
||||
const m = compileFileQuery('Route');
|
||||
expect(m).not.toBeNull();
|
||||
expect(m!('file-routes.ts', 'src/web/file-routes.ts')).toBe(true);
|
||||
expect(m!('FILE-ROUTES.TS', 'src/FILE-ROUTES.TS')).toBe(true);
|
||||
expect(m!('session.ts', 'src/session.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches against the relative path when the query contains a slash (substring)', () => {
|
||||
const m = compileFileQuery('web/file');
|
||||
expect(m).not.toBeNull();
|
||||
// Name alone would not contain the slash — must match the relative path.
|
||||
expect(m!('file-routes.ts', 'src/web/file-routes.ts')).toBe(true);
|
||||
expect(m!('file-routes.ts', 'src/api/file-routes.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('compiles an anchored, case-insensitive glob for * against the name', () => {
|
||||
const m = compileFileQuery('*.ts');
|
||||
expect(m).not.toBeNull();
|
||||
expect(m!('session.ts', 'src/session.ts')).toBe(true);
|
||||
expect(m!('SESSION.TS', 'src/SESSION.TS')).toBe(true);
|
||||
// Anchored: .ts must be at the end, not merely contained.
|
||||
expect(m!('session.tsx', 'src/session.tsx')).toBe(false);
|
||||
expect(m!('notes.md', 'notes.md')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats ? as a single-character glob wildcard', () => {
|
||||
const m = compileFileQuery('a?c.txt');
|
||||
expect(m).not.toBeNull();
|
||||
expect(m!('abc.txt', 'abc.txt')).toBe(true);
|
||||
expect(m!('axc.txt', 'axc.txt')).toBe(true);
|
||||
// ? matches exactly one char, not zero and not two.
|
||||
expect(m!('ac.txt', 'ac.txt')).toBe(false);
|
||||
expect(m!('abbc.txt', 'abbc.txt')).toBe(false);
|
||||
});
|
||||
|
||||
it('escapes regex metacharacters other than * and ? in glob mode', () => {
|
||||
// The dot is a literal, not "any char"; the + is literal too.
|
||||
const m = compileFileQuery('v1.2+*.log');
|
||||
expect(m).not.toBeNull();
|
||||
expect(m!('v1.2+final.log', 'v1.2+final.log')).toBe(true);
|
||||
expect(m!('v1X2Yfinal.log', 'v1X2Yfinal.log')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches a glob against the relative path when it contains a slash', () => {
|
||||
const m = compileFileQuery('src/*.ts');
|
||||
expect(m).not.toBeNull();
|
||||
expect(m!('session.ts', 'src/session.ts')).toBe(true);
|
||||
expect(m!('session.ts', 'lib/session.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('stays fast on a pathological star-heavy pattern (no regex backtracking)', () => {
|
||||
// `*a*a*a…` compiled to `^.*a.*a…$` is the classic backtracking blowup —
|
||||
// as a RegExp this match takes effectively forever and this test fails by
|
||||
// timeout. The two-pointer glob walk answers it in linear-ish time; the
|
||||
// 500ms ceiling is generous so a loaded CI box cannot flake it.
|
||||
const m = compileFileQuery('*a'.repeat(40) + 'b');
|
||||
expect(m).not.toBeNull();
|
||||
const started = performance.now();
|
||||
expect(m!('a'.repeat(200), 'a'.repeat(200))).toBe(false);
|
||||
expect(performance.now() - started).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it('treats an overlong query as no search, like an empty one', () => {
|
||||
// The glob walk is O(text · pattern); the length cap is what bounds it.
|
||||
expect(compileFileQuery('a'.repeat(257))).toBeNull();
|
||||
expect(compileFileQuery('a'.repeat(256))).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchFileQuery', () => {
|
||||
it('compiles then applies in one call', () => {
|
||||
expect(matchFileQuery('route', 'file-routes.ts', 'src/file-routes.ts')).toBe(true);
|
||||
expect(matchFileQuery('*.md', 'readme.md', 'docs/readme.md')).toBe(true);
|
||||
expect(matchFileQuery('*.md', 'readme.txt', 'docs/readme.txt')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the query compiles to null (empty)', () => {
|
||||
expect(matchFileQuery('', 'anything.ts', 'src/anything.ts')).toBe(false);
|
||||
expect(matchFileQuery(' ', 'anything.ts', 'src/anything.ts')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -122,6 +122,18 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
|
||||
expect(app).not.toContain('earlier output truncated for performance');
|
||||
});
|
||||
|
||||
it('loads a bounded shell tail first and keeps full history user-triggered', () => {
|
||||
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
expect(app).toContain("session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId)");
|
||||
expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'");
|
||||
expect(app).toContain('`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`');
|
||||
expect(app).toContain('fetch(`/api/sessions/${sessionId}/terminal?full=1`)');
|
||||
expect(app).toContain("if (this.sessions.get(sessionId)?.mode !== 'shell')");
|
||||
expect(app).toContain("if (session?.mode === 'shell')");
|
||||
expect(app).toContain("if (!force && session?.mode === 'shell') return;");
|
||||
expect(app).toContain("trigger: force ? 'full-history-button' : 'full-history-scroll'");
|
||||
});
|
||||
|
||||
it('renders the banner through textContent, never innerHTML', () => {
|
||||
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
const start = app.indexOf('_renderHistoryTruncationBanner() {');
|
||||
|
||||
+42
-11
@@ -5,7 +5,7 @@
|
||||
* composition/keydown/input event sequences against a stub textarea.
|
||||
* Focus: the intermittent "Chinese characters silently lost" failure modes —
|
||||
* stuck composition state, deferred flush racing the next composition, and
|
||||
* the keydown-echo suppression window swallowing a real IME commit.
|
||||
* physical-key punctuation transformed into full-width IME output.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
@@ -116,6 +116,36 @@ describe('CJK input module', () => {
|
||||
expect(textarea.value).toBe(PHANTOM);
|
||||
});
|
||||
|
||||
it('lets the IME transform printable keys before sending full-width punctuation', () => {
|
||||
const { textarea, sent } = loadCjkHarness();
|
||||
const committed = Array.from(',。!?;:“”、《》、()');
|
||||
const physicalKeys = [',', '.', '!', '?', ';', ':', '"', '"', '\\', '<', '>', '\\', '(', ')'];
|
||||
|
||||
textarea.fire('compositionstart');
|
||||
textarea.value = PHANTOM + '中文';
|
||||
textarea.fire('input', { isComposing: true, inputType: 'insertCompositionText' });
|
||||
textarea.fire('compositionend');
|
||||
vi.advanceTimersByTime(10);
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
for (const [index, punctuation] of committed.entries()) {
|
||||
const preventDefault = vi.fn();
|
||||
textarea.fire('keydown', {
|
||||
key: physicalKeys[index],
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
preventDefault,
|
||||
});
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
|
||||
textarea.value = PHANTOM + punctuation;
|
||||
textarea.fire('input', { isComposing: false, inputType: 'insertText' });
|
||||
}
|
||||
|
||||
expect(sent).toEqual(['中文', ...committed]);
|
||||
});
|
||||
|
||||
it('recovers committed text when compositionend never fires (stuck composition)', () => {
|
||||
const { textarea, sent } = loadCjkHarness();
|
||||
|
||||
@@ -124,6 +154,7 @@ describe('CJK input module', () => {
|
||||
textarea.value = PHANTOM + '你好';
|
||||
// The commit arrives as a plain input event outside composition.
|
||||
textarea.fire('input', { isComposing: false, inputType: 'insertText' });
|
||||
expect(sent).toEqual([]);
|
||||
vi.advanceTimersByTime(200);
|
||||
|
||||
expect(sent).toEqual(['你好']);
|
||||
@@ -151,30 +182,28 @@ describe('CJK input module', () => {
|
||||
expect(sent).toEqual(['你好世界']);
|
||||
});
|
||||
|
||||
it('does not discard an IME commit landing inside the keydown echo window', () => {
|
||||
it('sends the transformed IME commit that follows a printable keydown', () => {
|
||||
const { textarea, sent } = loadCjkHarness();
|
||||
|
||||
// English char goes out immediately via keydown.
|
||||
vi.advanceTimersByTime(1000);
|
||||
// The physical key is not committed text and must not be sent by itself.
|
||||
textarea.fire('keydown', { key: 'a', ctrlKey: false, altKey: false, metaKey: false });
|
||||
expect(sent).toEqual(['a']);
|
||||
expect(sent).toEqual([]);
|
||||
|
||||
// Within 100ms the IME commits Chinese via a bare input event.
|
||||
// The browser/IME supplies the canonical text in the following input.
|
||||
vi.advanceTimersByTime(50);
|
||||
textarea.value = PHANTOM + '你好';
|
||||
textarea.fire('input', { isComposing: false, inputType: 'insertText' });
|
||||
vi.advanceTimersByTime(200);
|
||||
|
||||
expect(sent).toEqual(['a', '你好']);
|
||||
expect(sent).toEqual(['你好']);
|
||||
});
|
||||
|
||||
it('still suppresses the true textarea echo of a keydown-sent character', () => {
|
||||
it('sends a printable physical key exactly once after its input event', () => {
|
||||
const { textarea, sent } = loadCjkHarness();
|
||||
|
||||
textarea.fire('keydown', { key: 'a', ctrlKey: false, altKey: false, metaKey: false });
|
||||
expect(sent).toEqual(['a']);
|
||||
expect(sent).toEqual([]);
|
||||
|
||||
// Third-party IME ignored preventDefault — the same char echoes into
|
||||
// the textarea. It must be dropped, not sent twice.
|
||||
vi.advanceTimersByTime(10);
|
||||
textarea.value = PHANTOM + 'a';
|
||||
textarea.fire('input', { isComposing: false, inputType: 'insertText' });
|
||||
@@ -242,6 +271,8 @@ describe('CJK input module', () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
textarea.fire('keydown', { key: '囍', ctrlKey: false, altKey: false, metaKey: false });
|
||||
textarea.value = PHANTOM + '囍';
|
||||
textarea.fire('input', { isComposing: false, inputType: 'insertText' });
|
||||
textarea.value = PHANTOM + '秘密';
|
||||
textarea.fire('blur');
|
||||
expect(sent).toEqual(['秘密口令', '囍']);
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// Port: none (pure logic in a vm context — no browser, no server).
|
||||
//
|
||||
// On phones the toolbar and the keyboard accessory bar are `position: fixed`, so
|
||||
// they take no layout space: `main`'s padding-bottom is the ONLY thing reserving
|
||||
// room for them, and every pixel taken out of it is a pixel of terminal painted
|
||||
// underneath them.
|
||||
//
|
||||
// `_shrinkPaddingToFit` reclaims the sub-row slack left after a keyboard-driven
|
||||
// re-fit. It used to take the whole slack, which pulled the terminal's bottom edge
|
||||
// down under those bars — and the row the following re-fit gained was painted
|
||||
// behind them, clipping the last line of a long wrapped prompt: the bottom half of
|
||||
// the text being typed. The floor is now the bars' MEASURED height.
|
||||
//
|
||||
// Lives outside test/mobile/ deliberately — that suite is Playwright-driven and
|
||||
// excluded from `npm run test:ci`, so a regression guarded only there is invisible
|
||||
// to CI (same reasoning as terminal-scroll-intent.test.ts).
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/mobile-handlers.js'), 'utf8');
|
||||
|
||||
interface Bar {
|
||||
offsetHeight: number;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
interface Setup {
|
||||
paddingBottom: string;
|
||||
containerHeight: number;
|
||||
rows: number;
|
||||
cellH: number;
|
||||
bars: Partial<Record<'.toolbar' | '.keyboard-accessory-bar' | '#cjkInput.cjk-input-visible', Bar>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load mobile-handlers.js and hand back its KeyboardHandler plus the fake `main`
|
||||
* whose inline padding the function edits.
|
||||
*
|
||||
* `const KeyboardHandler = {...}` is a lexical binding that does not survive to a
|
||||
* second `vm.runInContext`, so the export is appended to the SAME script.
|
||||
*/
|
||||
function loadHandler(setup: Setup) {
|
||||
const main = { style: { paddingBottom: setup.paddingBottom } };
|
||||
const container = { clientHeight: setup.containerHeight };
|
||||
let fits = 0;
|
||||
const app = {
|
||||
terminal: {
|
||||
rows: setup.rows,
|
||||
_core: { _renderService: { dimensions: { css: { cell: { height: setup.cellH } } } } },
|
||||
},
|
||||
fitAddon: {
|
||||
fit: () => {
|
||||
fits++;
|
||||
},
|
||||
},
|
||||
};
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
app,
|
||||
navigator: { userAgent: 'test', maxTouchPoints: 1 },
|
||||
window: {
|
||||
addEventListener: () => {},
|
||||
matchMedia: () => ({ matches: false }),
|
||||
scrollTo: () => {},
|
||||
getComputedStyle: (el: Bar) => ({ display: el.hidden ? 'none' : 'block', visibility: 'visible' }),
|
||||
},
|
||||
document: {
|
||||
body: { classList: { add: () => {}, remove: () => {} } },
|
||||
addEventListener: () => {},
|
||||
getElementById: (id: string) => (id === 'terminalContainer' ? container : null),
|
||||
querySelector: (sel: string) =>
|
||||
sel === '.main' ? main : (setup.bars as Record<string, Bar | undefined>)[sel] || null,
|
||||
},
|
||||
setTimeout: () => 1,
|
||||
clearTimeout: () => {},
|
||||
});
|
||||
vm.runInContext(`${SOURCE}\nglobalThis.__KH = KeyboardHandler;`, context, { filename: 'mobile-handlers.js' });
|
||||
return { handler: (context as { __KH: any }).__KH, main, fits: () => fits };
|
||||
}
|
||||
|
||||
// 10 rows × 19px = 190 in a 200px container → 10px of slack, less than one row.
|
||||
const BASE: Setup = {
|
||||
paddingBottom: '84px',
|
||||
containerHeight: 200,
|
||||
rows: 10,
|
||||
cellH: 19,
|
||||
bars: { '.toolbar': { offsetHeight: 40 }, '.keyboard-accessory-bar': { offsetHeight: 44 } },
|
||||
};
|
||||
|
||||
describe('_shrinkPaddingToFit', () => {
|
||||
it('reclaims the slack when the reservation over-reserves', () => {
|
||||
// Bars really need 60px, 84 is reserved → the 10px of slack is free to take.
|
||||
const { handler, main } = loadHandler({
|
||||
...BASE,
|
||||
bars: { '.toolbar': { offsetHeight: 30 }, '.keyboard-accessory-bar': { offsetHeight: 30 } },
|
||||
});
|
||||
|
||||
handler._shrinkPaddingToFit();
|
||||
|
||||
expect(main.style.paddingBottom).toBe('74px');
|
||||
});
|
||||
|
||||
it('never shrinks into the space the bars actually occupy', () => {
|
||||
// 40 + 44 = 84: the reservation is exactly right, so there is nothing to take
|
||||
// even though the terminal has 10px of slack.
|
||||
const { handler, main } = loadHandler(BASE);
|
||||
|
||||
handler._shrinkPaddingToFit();
|
||||
|
||||
expect(main.style.paddingBottom).toBe('84px');
|
||||
});
|
||||
|
||||
it('stops part-way when only some of the slack is free', () => {
|
||||
// Bars need 78px of the reserved 84 → 6px may be reclaimed, not the full 10.
|
||||
const { handler, main } = loadHandler({
|
||||
...BASE,
|
||||
bars: { '.toolbar': { offsetHeight: 34 }, '.keyboard-accessory-bar': { offsetHeight: 44 } },
|
||||
});
|
||||
|
||||
handler._shrinkPaddingToFit();
|
||||
|
||||
expect(main.style.paddingBottom).toBe('78px');
|
||||
});
|
||||
|
||||
it('is a no-op, never a grow, when the bars are taller than the reservation', () => {
|
||||
// Growing the padding here would resize the terminal as a side effect of a
|
||||
// function that exists to reclaim slack.
|
||||
const { handler, main } = loadHandler({
|
||||
...BASE,
|
||||
bars: { '.toolbar': { offsetHeight: 60 }, '.keyboard-accessory-bar': { offsetHeight: 60 } },
|
||||
});
|
||||
|
||||
handler._shrinkPaddingToFit();
|
||||
|
||||
expect(main.style.paddingBottom).toBe('84px');
|
||||
});
|
||||
|
||||
it('does not count a hidden bar', () => {
|
||||
// The accessory bar is display:none until the keyboard opens; counting it
|
||||
// would block a reclaim that is genuinely free.
|
||||
const { handler, main } = loadHandler({
|
||||
...BASE,
|
||||
bars: { '.toolbar': { offsetHeight: 40 }, '.keyboard-accessory-bar': { offsetHeight: 44, hidden: true } },
|
||||
});
|
||||
|
||||
handler._shrinkPaddingToFit();
|
||||
|
||||
expect(main.style.paddingBottom).toBe('74px');
|
||||
});
|
||||
|
||||
it('counts the CJK input strip when it is on screen', () => {
|
||||
const { handler, main } = loadHandler({
|
||||
...BASE,
|
||||
bars: {
|
||||
'.toolbar': { offsetHeight: 30 },
|
||||
'.keyboard-accessory-bar': { offsetHeight: 30 },
|
||||
'#cjkInput.cjk-input-visible': { offsetHeight: 20 },
|
||||
},
|
||||
});
|
||||
|
||||
handler._shrinkPaddingToFit();
|
||||
|
||||
expect(main.style.paddingBottom).toBe('80px');
|
||||
});
|
||||
|
||||
it('leaves the padding alone when the slack is a whole row or more', () => {
|
||||
// A full row of slack means the re-fit will claim it as a row; padding is not
|
||||
// the lever here.
|
||||
const { handler, main, fits } = loadHandler({ ...BASE, containerHeight: 190 + 19 });
|
||||
|
||||
handler._shrinkPaddingToFit();
|
||||
|
||||
expect(main.style.paddingBottom).toBe('84px');
|
||||
expect(fits()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @fileoverview Tests for the Pi CLI resolver wrapper.
|
||||
*
|
||||
* Pi is the resolver with a version probe: `pi` is a short, generic binary
|
||||
* name, so a resolved path is only accepted once `pi --version` prints a
|
||||
* semver-shaped string. The probe EXECUTES the candidate, which is exactly why
|
||||
* it must never run under vitest — the hermeticity test below pins that gate
|
||||
* with a real executable fixture that would make the test fail loudly if the
|
||||
* gate were deleted again (as PR #329 once did).
|
||||
*/
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPiResolverForTest } from '../src/utils/pi-cli-resolver.js';
|
||||
import {
|
||||
cliResolveRetryDelayMs,
|
||||
createProductionCliResolverHost,
|
||||
type CliResolverHost,
|
||||
} from '../src/utils/cli-executable-resolver.js';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createHost(
|
||||
options: {
|
||||
processPathResult?: string | null;
|
||||
loginShellResults?: Array<string | null>;
|
||||
existingPaths?: string[];
|
||||
} = {}
|
||||
): CliResolverHost {
|
||||
const loginShellResults = [...(options.loginShellResults ?? [])];
|
||||
const existingPaths = new Set(options.existingPaths ?? []);
|
||||
return {
|
||||
processPath: '/service/bin',
|
||||
shellPath: '/bin/zsh',
|
||||
shellArgs: ['-l'],
|
||||
findOnProcessPath: () => options.processPathResult ?? null,
|
||||
findInLoginShell: () => loginShellResults.shift() ?? null,
|
||||
exists: (path) => existingPaths.has(path),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Pi CLI resolver', () => {
|
||||
it('accepts a candidate the version probe verifies and carries the version as metadata', () => {
|
||||
const binaryPath = '/service/bin/pi';
|
||||
const probe = vi.fn(() => '0.84.1');
|
||||
const resolver = createPiResolverForTest(
|
||||
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }),
|
||||
probe
|
||||
);
|
||||
|
||||
expect(resolver.resolve()).toMatchObject({
|
||||
binaryPath,
|
||||
directory: '/service/bin',
|
||||
source: 'process-path',
|
||||
metadata: '0.84.1',
|
||||
});
|
||||
expect(probe).toHaveBeenCalledWith(binaryPath);
|
||||
});
|
||||
|
||||
it('rejects a candidate the probe refuses and falls through to a later one', () => {
|
||||
// An unrelated `pi` on the service PATH (probe returns null) must not mask
|
||||
// the real coding agent found by the login shell.
|
||||
const impostor = '/service/bin/pi';
|
||||
const genuine = '/login-shell/bin/pi';
|
||||
const probe = vi.fn((binPath: string) => (binPath === genuine ? '0.84.1' : null));
|
||||
const resolver = createPiResolverForTest(
|
||||
createHost({
|
||||
processPathResult: impostor,
|
||||
loginShellResults: [genuine],
|
||||
existingPaths: [impostor, genuine],
|
||||
}),
|
||||
probe
|
||||
);
|
||||
|
||||
expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell', metadata: '0.84.1' });
|
||||
});
|
||||
|
||||
it('negative-caches a miss and retries only after the backoff elapses', () => {
|
||||
const binaryPath = '/late/bin/pi';
|
||||
let now = 0;
|
||||
const probe = vi.fn(() => '0.84.1');
|
||||
const resolver = createPiResolverForTest(
|
||||
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }),
|
||||
probe,
|
||||
() => now
|
||||
);
|
||||
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
now = cliResolveRetryDelayMs(1);
|
||||
expect(resolver.resolve()?.metadata).toBe('0.84.1');
|
||||
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
|
||||
});
|
||||
|
||||
it('never executes a pi candidate under vitest (the ambient probe is VITEST-gated)', () => {
|
||||
// A REAL executable fixture that prints a valid version. If the guard in
|
||||
// probePiVersion is ever removed again, the probe runs this script, the
|
||||
// resolution SUCCEEDS, and this test fails — pinning hermeticity by
|
||||
// behavior rather than by source text. (The suites must never execute
|
||||
// whatever `pi` binary the machine running them happens to carry.)
|
||||
const root = mkdtempSync(join(tmpdir(), 'codeman-pi-vitest-gate-'));
|
||||
temporaryDirectories.push(root);
|
||||
const binaryPath = join(root, 'pi');
|
||||
writeFileSync(binaryPath, '#!/bin/sh\necho 0.99.0\n');
|
||||
chmodSync(binaryPath, 0o755);
|
||||
const hostOptions = {
|
||||
processPath: root,
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'] as string[],
|
||||
runCommand: () => '',
|
||||
isExecutableFile: (path: string) => path === binaryPath,
|
||||
};
|
||||
|
||||
// Default (ambient) probe: the candidate is found but never executed, so
|
||||
// the VITEST gate reports it unusable and resolution misses.
|
||||
const gated = createPiResolverForTest(createProductionCliResolverHost(hostOptions));
|
||||
expect(gated.resolve()).toBeNull();
|
||||
|
||||
// Control: identical setup with an injected probe resolves, proving the
|
||||
// null above comes from the gate, not from the fixture or the host.
|
||||
const control = createPiResolverForTest(createProductionCliResolverHost(hostOptions), () => '0.99.0');
|
||||
expect(control.resolve()).toMatchObject({ binaryPath, metadata: '0.99.0' });
|
||||
});
|
||||
});
|
||||
+31
-5
@@ -129,6 +129,36 @@ describe('RalphLoop', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('reschedule loop (regression)', () => {
|
||||
// tick() is the only path that calls setRalphLoopState with lastCheckAt and
|
||||
// no status field; start() carries status:'running', stop() status:'stopped'.
|
||||
const countTicks = () =>
|
||||
(mockState.store.setRalphLoopState as any).mock.calls.filter(
|
||||
(c: any[]) => c[0]?.lastCheckAt !== undefined && c[0]?.status === undefined
|
||||
).length;
|
||||
|
||||
it('keeps rescheduling past the second tick', async () => {
|
||||
// Real timers + a tiny poll interval: tick()'s async internals settle
|
||||
// naturally on the event loop, avoiding fake-timer microtask fragility.
|
||||
vi.useRealTimers();
|
||||
loop.destroy();
|
||||
// Keep the loop in the "running, should keep polling" state so it actually
|
||||
// exercises the reschedule path: min duration unreached + autoGenerate on
|
||||
// makes shouldStop() false (idle sessions are mocked empty, so nothing is
|
||||
// actually generated). Otherwise an empty queue self-stops after 1 tick.
|
||||
loop = new RalphLoop({ pollIntervalMs: 5, minDurationMs: 60_000, autoGenerateTasks: true });
|
||||
|
||||
await loop.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 80)); // ~16 poll intervals
|
||||
loop.stop();
|
||||
|
||||
// The bug: the loopTimer handle is never nulled in the setTimeout callback,
|
||||
// so the `loopTimer === null` reschedule guard is false after the first fire
|
||||
// and the loop dies at exactly 2 ticks. Fixed -> many ticks.
|
||||
expect(countTicks()).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should start in stopped status', () => {
|
||||
expect(loop.status).toBe('stopped');
|
||||
@@ -296,11 +326,7 @@ describe('RalphLoop', () => {
|
||||
describe('getStats', () => {
|
||||
it('should return complete stats object', async () => {
|
||||
const mockRunningTask = { id: '2', status: 'running', isTimedOut: () => false };
|
||||
mockState.taskQueue.tasks = [
|
||||
{ id: '1', status: 'pending' },
|
||||
mockRunningTask,
|
||||
{ id: '3', status: 'completed' },
|
||||
];
|
||||
mockState.taskQueue.tasks = [{ id: '1', status: 'pending' }, mockRunningTask, { id: '3', status: 'completed' }];
|
||||
mockState.taskQueue.getRunningTasks.mockReturnValue([mockRunningTask]);
|
||||
|
||||
await loop.start();
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the repository-status pure helpers: ahead/behind
|
||||
* + log + symref parsing, env parsing, the remote-set / role / tracking-remote
|
||||
* decisions, credential redaction of the returned fields, and the single-flight
|
||||
* TTL cache that keeps the async status computation off the git hot path.
|
||||
* No IO, no git, no port — safe to run individually.
|
||||
*
|
||||
* npm test -- test/repo-status.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseAheadBehind,
|
||||
parseLogLines,
|
||||
parseSymrefDefaultBranch,
|
||||
parseRemotesEnv,
|
||||
parseTrackingRemote,
|
||||
resolveRemoteSet,
|
||||
roleForRemote,
|
||||
isSafeGitPositional,
|
||||
redactRemoteStatus,
|
||||
createSingleFlightCache,
|
||||
} from '../src/web/repo-status.js';
|
||||
import { redactGitCredentials } from '../src/git-clone.js';
|
||||
import type { RepoRemoteStatus } from '../src/types/update.js';
|
||||
|
||||
describe('parseAheadBehind', () => {
|
||||
it('parses tab-separated left/right counts as ahead/behind', () => {
|
||||
expect(parseAheadBehind('3\t14')).toEqual({ ahead: 3, behind: 14 });
|
||||
});
|
||||
it('parses space-separated counts', () => {
|
||||
expect(parseAheadBehind('0 0')).toEqual({ ahead: 0, behind: 0 });
|
||||
});
|
||||
it('tolerates surrounding whitespace/newline', () => {
|
||||
expect(parseAheadBehind(' 5 2 \n')).toEqual({ ahead: 5, behind: 2 });
|
||||
});
|
||||
it('returns null on malformed output', () => {
|
||||
expect(parseAheadBehind('')).toBeNull();
|
||||
expect(parseAheadBehind('abc')).toBeNull();
|
||||
expect(parseAheadBehind('1')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLogLines', () => {
|
||||
it('splits each line into sha + subject', () => {
|
||||
const out = 'a1b2c3 fix: thing\nd4e5f6 COD-9 add digest';
|
||||
expect(parseLogLines(out)).toEqual([
|
||||
{ sha: 'a1b2c3', subject: 'fix: thing' },
|
||||
{ sha: 'd4e5f6', subject: 'COD-9 add digest' },
|
||||
]);
|
||||
});
|
||||
it('handles a subject with no space (sha only)', () => {
|
||||
expect(parseLogLines('deadbee')).toEqual([{ sha: 'deadbee', subject: '' }]);
|
||||
});
|
||||
it('ignores blank lines', () => {
|
||||
expect(parseLogLines('\n \nabc123 hi\n')).toEqual([{ sha: 'abc123', subject: 'hi' }]);
|
||||
});
|
||||
it('returns [] for empty input', () => {
|
||||
expect(parseLogLines('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSymrefDefaultBranch', () => {
|
||||
it('extracts the default branch from ls-remote --symref output', () => {
|
||||
const out = 'ref: refs/heads/master\tHEAD\n0123abc\tHEAD';
|
||||
expect(parseSymrefDefaultBranch(out)).toBe('master');
|
||||
});
|
||||
it('handles main', () => {
|
||||
expect(parseSymrefDefaultBranch('ref: refs/heads/main\tHEAD')).toBe('main');
|
||||
});
|
||||
it('returns null when no symref line present', () => {
|
||||
expect(parseSymrefDefaultBranch('0123abc\tHEAD')).toBeNull();
|
||||
});
|
||||
it('rejects a hostile branch that would smuggle a git flag (argv injection)', () => {
|
||||
// A malicious remote sets HEAD to a `-`-leading "branch"; the capture must
|
||||
// not start with `-`, so this yields null rather than `--upload-pack=...`.
|
||||
expect(parseSymrefDefaultBranch('ref: refs/heads/--upload-pack=touch\tHEAD')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSafeGitPositional', () => {
|
||||
it('accepts normal remote/branch names', () => {
|
||||
expect(isSafeGitPositional('origin')).toBe(true);
|
||||
expect(isSafeGitPositional('feature/foo')).toBe(true);
|
||||
});
|
||||
it('rejects flag-injecting and empty values', () => {
|
||||
expect(isSafeGitPositional('--upload-pack=touch /tmp/x')).toBe(false);
|
||||
expect(isSafeGitPositional('-x')).toBe(false);
|
||||
expect(isSafeGitPositional('')).toBe(false);
|
||||
expect(isSafeGitPositional(null)).toBe(false);
|
||||
expect(isSafeGitPositional(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRemotesEnv', () => {
|
||||
it('splits comma list, trims, drops empties', () => {
|
||||
expect(parseRemotesEnv('origin, bitbucket ,, fork')).toEqual(['origin', 'bitbucket', 'fork']);
|
||||
});
|
||||
it('returns [] for null/undefined/empty', () => {
|
||||
expect(parseRemotesEnv(null)).toEqual([]);
|
||||
expect(parseRemotesEnv(undefined)).toEqual([]);
|
||||
expect(parseRemotesEnv('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRemoteSet', () => {
|
||||
it('defaults to tracking-remote first, then origin (the maintainer-fork layout)', () => {
|
||||
// local branch tracks bitbucket; origin is the canonical upstream.
|
||||
const set = resolveRemoteSet({
|
||||
existingRemotes: ['bitbucket', 'fork', 'origin'],
|
||||
trackingRemote: 'bitbucket',
|
||||
envRemotes: [],
|
||||
});
|
||||
expect(set).toEqual(['bitbucket', 'origin']);
|
||||
});
|
||||
|
||||
it('collapses to a single entry when origin IS the tracking remote', () => {
|
||||
const set = resolveRemoteSet({
|
||||
existingRemotes: ['origin'],
|
||||
trackingRemote: 'origin',
|
||||
envRemotes: [],
|
||||
});
|
||||
expect(set).toEqual(['origin']);
|
||||
});
|
||||
|
||||
it('falls back to just origin when there is no tracking remote', () => {
|
||||
const set = resolveRemoteSet({
|
||||
existingRemotes: ['origin', 'fork'],
|
||||
trackingRemote: null,
|
||||
envRemotes: [],
|
||||
});
|
||||
expect(set).toEqual(['origin']);
|
||||
});
|
||||
|
||||
it('honors CODEMAN_UPDATE_REMOTES order and filters to existing remotes', () => {
|
||||
const set = resolveRemoteSet({
|
||||
existingRemotes: ['origin', 'bitbucket', 'fork'],
|
||||
trackingRemote: 'bitbucket',
|
||||
envRemotes: ['fork', 'origin', 'ghost'],
|
||||
});
|
||||
expect(set).toEqual(['fork', 'origin']); // 'ghost' dropped (does not exist)
|
||||
});
|
||||
|
||||
it('returns [] when env names none of the existing remotes', () => {
|
||||
const set = resolveRemoteSet({
|
||||
existingRemotes: ['origin'],
|
||||
trackingRemote: null,
|
||||
envRemotes: ['nope'],
|
||||
});
|
||||
expect(set).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roleForRemote', () => {
|
||||
it('labels the tracking remote as tracking even if named origin', () => {
|
||||
expect(roleForRemote('origin', 'origin')).toBe('tracking');
|
||||
expect(roleForRemote('bitbucket', 'bitbucket')).toBe('tracking');
|
||||
});
|
||||
it('labels origin/upstream (when not tracking) as upstream', () => {
|
||||
expect(roleForRemote('origin', 'bitbucket')).toBe('upstream');
|
||||
expect(roleForRemote('upstream', 'origin')).toBe('upstream');
|
||||
});
|
||||
it('labels anything else as other', () => {
|
||||
expect(roleForRemote('fork', 'bitbucket')).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTrackingRemote', () => {
|
||||
it('extracts the remote name from a remote-tracking short ref', () => {
|
||||
expect(parseTrackingRemote('origin/master')).toBe('origin');
|
||||
expect(parseTrackingRemote('bitbucket/feature/nested')).toBe('bitbucket');
|
||||
});
|
||||
it('returns null for a LOCAL-branch upstream (ref with no slash)', () => {
|
||||
// `git branch -u otherbranch` makes @{upstream} a bare branch name; the old
|
||||
// slice(0, indexOf('/')) turned "master" into "maste" here.
|
||||
expect(parseTrackingRemote('master')).toBeNull();
|
||||
expect(parseTrackingRemote('main')).toBeNull();
|
||||
});
|
||||
it('returns null for null/empty/degenerate refs', () => {
|
||||
expect(parseTrackingRemote(null)).toBeNull();
|
||||
expect(parseTrackingRemote(undefined)).toBeNull();
|
||||
expect(parseTrackingRemote('')).toBeNull();
|
||||
expect(parseTrackingRemote('/leading-slash')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('credential redaction', () => {
|
||||
it('redactGitCredentials masks scheme://user:secret@host pairs', () => {
|
||||
expect(redactGitCredentials('https://user:ghp_token123@github.com/o/r.git')).toBe(
|
||||
'https://***:***@github.com/o/r.git'
|
||||
);
|
||||
expect(redactGitCredentials('plain text, no url')).toBe('plain text, no url');
|
||||
expect(redactGitCredentials('https://github.com/o/r.git')).toBe('https://github.com/o/r.git');
|
||||
});
|
||||
|
||||
it('redactRemoteStatus masks the url field', () => {
|
||||
const status: RepoRemoteStatus = {
|
||||
name: 'origin',
|
||||
url: 'https://alice:s3cret@example.com/repo.git',
|
||||
role: 'upstream',
|
||||
compareRef: 'origin/master',
|
||||
ahead: 1,
|
||||
behind: 2,
|
||||
incoming: [{ sha: 'abc', subject: 'hi' }],
|
||||
};
|
||||
const out = redactRemoteStatus(status);
|
||||
expect(out.url).toBe('https://***:***@example.com/repo.git');
|
||||
// Everything else passes through untouched.
|
||||
expect(out.name).toBe('origin');
|
||||
expect(out.compareRef).toBe('origin/master');
|
||||
expect(out.ahead).toBe(1);
|
||||
expect(out.behind).toBe(2);
|
||||
expect(out.incoming).toEqual([{ sha: 'abc', subject: 'hi' }]);
|
||||
expect(out.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('redactRemoteStatus masks credentials echoed into the error string by git stderr', () => {
|
||||
const status: RepoRemoteStatus = {
|
||||
name: 'origin',
|
||||
url: 'https://alice:s3cret@example.com/repo.git',
|
||||
role: 'upstream',
|
||||
compareRef: '',
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
incoming: [],
|
||||
error: "Could not reach origin: fatal: unable to access 'https://alice:s3cret@example.com/repo.git/'",
|
||||
};
|
||||
const out = redactRemoteStatus(status);
|
||||
expect(out.error).toBe("Could not reach origin: fatal: unable to access 'https://***:***@example.com/repo.git/'");
|
||||
expect(out.error).not.toContain('s3cret');
|
||||
expect(out.url).not.toContain('s3cret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSingleFlightCache', () => {
|
||||
it('shares one in-flight computation across concurrent callers', async () => {
|
||||
let calls = 0;
|
||||
let release!: (v: string) => void;
|
||||
const cache = createSingleFlightCache(60_000, () => {
|
||||
calls++;
|
||||
return new Promise<string>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
});
|
||||
const a = cache.get();
|
||||
const b = cache.get();
|
||||
release('result');
|
||||
expect(await a).toBe('result');
|
||||
expect(await b).toBe('result');
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it('serves a fresh-enough cached result without recomputing', async () => {
|
||||
let calls = 0;
|
||||
const cache = createSingleFlightCache(60_000, async () => ++calls);
|
||||
expect(await cache.get()).toBe(1);
|
||||
expect(await cache.get()).toBe(1);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it('recomputes once the TTL has elapsed', async () => {
|
||||
let calls = 0;
|
||||
const cache = createSingleFlightCache(60_000, async () => ++calls);
|
||||
expect(await cache.get()).toBe(1);
|
||||
// Inject a "now" past the TTL instead of sleeping.
|
||||
expect(await cache.get(Date.now() + 60_001)).toBe(2);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it('does not cache a rejected computation — the next call retries', async () => {
|
||||
let calls = 0;
|
||||
const cache = createSingleFlightCache(60_000, async () => {
|
||||
calls++;
|
||||
if (calls === 1) throw new Error('boom');
|
||||
return 'ok';
|
||||
});
|
||||
await expect(cache.get()).rejects.toThrow('boom');
|
||||
expect(await cache.get()).toBe('ok');
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @fileoverview Response-viewer links open in a NEW tab (`CodemanApp._renderMarkdown`).
|
||||
*
|
||||
* `marked` emits a bare `<a href>` and the markdown sanitizer's allowlist carries no
|
||||
* `target`, so every link in the chat used to navigate the CURRENT tab. On a phone that
|
||||
* unloads the whole dashboard — SSE, terminal buffers, unsent composer text — and the OS
|
||||
* back gesture reloads it from scratch, with no middle-click or open-in-new-tab affordance
|
||||
* to work around it. That is the "links don't open on mobile" report.
|
||||
*
|
||||
* `_renderMarkdown` therefore decorates anchors AFTER sanitizing, which makes it the single
|
||||
* source of both attributes: whatever an agent wrote is already stripped by then, and `rel`
|
||||
* is set on the same element in the same pass, so no page Codeman opens can reach back
|
||||
* through `window.opener` (reverse tabnabbing).
|
||||
*
|
||||
* Drives the SHIPPING artifacts — vendored `marked`, vendored DOMPurify + `sanitize-html.js`,
|
||||
* and `app.js` itself — in a `vm` with a jsdom document injected (the technique from
|
||||
* markdown-sanitizer.test.ts / response-viewer-file-links.test.ts; a per-file jsdom
|
||||
* environment would externalize node:fs under vite).
|
||||
*
|
||||
* No port / server needed.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const publicFile = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
|
||||
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
|
||||
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
|
||||
const { document, NodeFilter } = dom.window;
|
||||
|
||||
/** The shipping sanitizer: vendored DOMPurify bound to our jsdom window + the real config. */
|
||||
function loadShippingSanitizer(): (html: string) => string {
|
||||
const dpModule: { exports: unknown } = { exports: {} };
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||||
new Function('module', 'exports', publicFile('vendor/dompurify.min.js'))(dpModule, dpModule.exports);
|
||||
const DOMPurify = (dpModule.exports as (win: unknown) => unknown)(jsdomWindow);
|
||||
|
||||
const sanModule: { exports: { createMarkdownSanitizer?: (dp: unknown) => (html: string) => string } } = {
|
||||
exports: {},
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||||
new Function('module', 'exports', publicFile('sanitize-html.js'))(sanModule, sanModule.exports);
|
||||
const create = sanModule.exports.createMarkdownSanitizer;
|
||||
if (typeof create !== 'function') throw new Error('createMarkdownSanitizer not exported');
|
||||
return create(DOMPurify);
|
||||
}
|
||||
|
||||
/** The vendored `marked` build the page loads, evaluated as CommonJS. */
|
||||
function loadShippingMarked(): { parse: (src: string, opts?: unknown) => string } {
|
||||
const module: { exports: unknown } = { exports: {} };
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||||
new Function('module', 'exports', publicFile('vendor/marked.min.js'))(module, module.exports);
|
||||
return module.exports as { parse: (src: string, opts?: unknown) => string };
|
||||
}
|
||||
|
||||
type RenderApp = { _renderMarkdown(text: string): string };
|
||||
|
||||
function loadCodemanAppClass(): { prototype: RenderApp } {
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
fetch: vi.fn(),
|
||||
document,
|
||||
NodeFilter,
|
||||
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
// The page wires the sanitizer onto window; _sanitizeHtml fails closed without it,
|
||||
// and a closed-failing render would make every assertion below vacuous.
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), sanitizeMarkdownHtml: loadShippingSanitizer() },
|
||||
marked: loadShippingMarked(),
|
||||
MobileDetection: {},
|
||||
});
|
||||
vm.runInContext(
|
||||
`${publicFile('constants.js')}\n${publicFile('app.js')}\nglobalThis.__CodemanApp = CodemanApp;`,
|
||||
context
|
||||
);
|
||||
return (context as { __CodemanApp: { prototype: RenderApp } }).__CodemanApp;
|
||||
}
|
||||
|
||||
const CodemanApp = loadCodemanAppClass();
|
||||
|
||||
/** Render markdown the way the response viewer does and return the resulting element. */
|
||||
function render(markdown: string): HTMLElement {
|
||||
const app = Object.create(CodemanApp.prototype) as RenderApp;
|
||||
const root = document.createElement('div');
|
||||
root.className = 'rv-text';
|
||||
root.innerHTML = app._renderMarkdown(markdown);
|
||||
return root as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
const anchor = (root: HTMLElement, index = 0) => Array.from(root.querySelectorAll('a'))[index];
|
||||
|
||||
describe('response viewer external links', () => {
|
||||
it('opens a markdown link in a new tab, with rel set in the same pass', () => {
|
||||
const root = render('See [the docs](https://example.com/docs?a=1&b=2) for details.');
|
||||
|
||||
const a = anchor(root);
|
||||
expect(a, 'the link survived sanitizing').toBeDefined();
|
||||
expect(a.getAttribute('href')).toBe('https://example.com/docs?a=1&b=2');
|
||||
expect(a.getAttribute('target')).toBe('_blank');
|
||||
expect(a.getAttribute('rel')).toBe('noopener noreferrer');
|
||||
});
|
||||
|
||||
it('opens an autolinked bare URL in a new tab too', () => {
|
||||
// gfm autolinks a bare URL, which is how an agent usually prints one.
|
||||
const root = render('Login at https://claude.ai/oauth/authorize?code=true&client_id=abc to continue.');
|
||||
|
||||
const a = anchor(root);
|
||||
expect(a.getAttribute('href')).toBe('https://claude.ai/oauth/authorize?code=true&client_id=abc');
|
||||
expect(a.getAttribute('target')).toBe('_blank');
|
||||
expect(a.getAttribute('rel')).toBe('noopener noreferrer');
|
||||
});
|
||||
|
||||
it('sends a same-origin path to a new tab as well — it is still a navigation away', () => {
|
||||
const root = render('Check [status](/api/status).');
|
||||
|
||||
expect(anchor(root).getAttribute('target')).toBe('_blank');
|
||||
});
|
||||
|
||||
it('leaves an in-page fragment link alone', () => {
|
||||
// A target here would open a second copy of the app to scroll it.
|
||||
const root = render('Jump to [the section](#results).');
|
||||
|
||||
const a = anchor(root);
|
||||
expect(a.getAttribute('href')).toBe('#results');
|
||||
expect(a.hasAttribute('target')).toBe(false);
|
||||
expect(a.hasAttribute('rel')).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves mailto: and tel: to the OS instead of stranding an empty tab', () => {
|
||||
const root = render('Mail [me](mailto:a@example.com) or call [now](tel:+15551234).');
|
||||
|
||||
for (const a of Array.from(root.querySelectorAll('a'))) {
|
||||
expect(a.hasAttribute('target'), a.getAttribute('href') || '').toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('is the ONLY source of target/rel: an agent cannot ask for an opener', () => {
|
||||
// The sanitizer's allowlist has neither attribute, so agent-authored ones are gone
|
||||
// before this pass runs — and the pass sets both together, so `rel` can never end up
|
||||
// weaker than the target it accompanies.
|
||||
const root = render('<a href="https://evil.example/" target="_self" rel="opener">click</a>');
|
||||
|
||||
const a = anchor(root);
|
||||
expect(a.getAttribute('target')).toBe('_blank');
|
||||
expect(a.getAttribute('rel')).toBe('noopener noreferrer');
|
||||
});
|
||||
|
||||
it('still drops a javascript: link rather than decorating it', () => {
|
||||
const root = render('[x](javascript:alert(1))');
|
||||
|
||||
const a = anchor(root);
|
||||
// DOMPurify strips the unsafe href; whatever is left must not carry a target either,
|
||||
// which would turn a hollow anchor into a window-opening one.
|
||||
expect(a?.getAttribute('href') ?? null).toBeNull();
|
||||
expect(a?.hasAttribute('target') ?? false).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps code blocks and their copy affordance intact', () => {
|
||||
// The anchor pass shares the one template walk with the code-block wrapper; a mistake
|
||||
// there would silently drop the toolbar rather than fail loudly.
|
||||
const root = render('```\nconst a = 1;\n```');
|
||||
|
||||
expect(root.querySelector('.rv-code-wrap')).not.toBeNull();
|
||||
expect(root.querySelector('.rv-copy-btn')).not.toBeNull();
|
||||
expect(root.querySelector('pre code')?.textContent).toContain('const a = 1;');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getLastTranscriptResponse,
|
||||
isExternalCliTranscriptMode,
|
||||
parseExternalCliTranscript,
|
||||
} from '../src/web/response-viewer-transcript.js';
|
||||
import { isExternalCliMode } from '../src/session.js';
|
||||
import type { SessionMode } from '../src/types.js';
|
||||
|
||||
describe('response viewer transcript parser', () => {
|
||||
it('extracts structured COD transcript blocks and keeps labeled status/tool entries', () => {
|
||||
const transcript = `
|
||||
╭──────────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.143.0) │
|
||||
│ model: gpt-5.4 medium /model to change │
|
||||
│ directory: /mnt/c/Users/aakhter/.../kb │
|
||||
╰──────────────────────────────────────────────────────╯
|
||||
|
||||
Tip: Use /side to start a side conversation in a temporary fork without polluting the main thread.
|
||||
|
||||
› i need to create a naming recommendation for project helix
|
||||
|
||||
gpt-5.4 medium · kb · main · Ready · Context 100% left
|
||||
|
||||
• Explored
|
||||
└ Read SKILL.md
|
||||
|
||||
Subject: Naming Recommendation for Project Helix
|
||||
|
||||
The product helps customers move virtual machine workloads between hypervisors while keeping operations
|
||||
stable. It improves visibility into application dependencies, supports pre-migration validation, and reduces
|
||||
the risk
|
||||
and effort involved in moving workloads.
|
||||
|
||||
› Improve documentation in @filename
|
||||
|
||||
gpt-5.4 medium · kb · main · Ready · Context 92% left
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
|
||||
expect(blocks.map((block) => block.kind)).toEqual([
|
||||
'status',
|
||||
'prompt',
|
||||
'status',
|
||||
'tool',
|
||||
'response',
|
||||
'prompt',
|
||||
'status',
|
||||
]);
|
||||
expect(blocks[0]?.label).toBe('Status');
|
||||
expect(blocks[3]?.label).toBe('Tool');
|
||||
expect(blocks[4]?.text).toContain('reduces the risk and effort involved');
|
||||
expect(blocks[4]?.text).not.toContain('reduces\nthe risk');
|
||||
|
||||
// The frontend's loadFullContext() renders via msg.role: 'user' gets the
|
||||
// "You" badge, everything else the agent badge. A block without role
|
||||
// rendered every prompt as the agent.
|
||||
expect(blocks[1]?.role).toBe('user');
|
||||
expect(blocks[5]?.role).toBe('user');
|
||||
expect(blocks[0]?.role).toBe('assistant');
|
||||
expect(blocks[3]?.role).toBe('assistant');
|
||||
expect(blocks[4]?.role).toBe('assistant');
|
||||
});
|
||||
|
||||
it('returns the most recent completed response when the last block is a new prompt', () => {
|
||||
const transcript = `
|
||||
› say again
|
||||
|
||||
Final polished answer
|
||||
With two lines
|
||||
|
||||
› Improve documentation in @filename
|
||||
|
||||
gpt-5.4 medium · kb · main · Ready · Context 92% left
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
|
||||
expect(getLastTranscriptResponse(blocks)).toBe('Final polished answer\nWith two lines');
|
||||
});
|
||||
|
||||
it('drops box-drawing dividers from the last response and treats worked-for lines as status', () => {
|
||||
const transcript = `
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
|
||||
• The launcher supports CODEMAN_APP_DIR, so I can deploy this exact worktree without merging it back first.
|
||||
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
|
||||
• Deployed.
|
||||
|
||||
The local Codeman service is now running from the worktree at app/.worktrees/cod-215-response-viewer on
|
||||
commit 8bbcf77bafbdbf01a34653386c256b685898ad06.
|
||||
|
||||
Verified:
|
||||
|
||||
- process pid: 2708947
|
||||
- HTTPS health: https://127.0.0.1:3000/ returned 401 as expected
|
||||
|
||||
One detail: I had to restart it outside the sandbox because the sandboxed launch path could not see tmux.
|
||||
|
||||
─ Worked for 1m 51s ───────────────────────────────────────────────────
|
||||
|
||||
› what are these lines in the middle column?
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
|
||||
expect(getLastTranscriptResponse(blocks)).toContain('• Deployed.');
|
||||
expect(getLastTranscriptResponse(blocks)).not.toContain('────────────────');
|
||||
expect(getLastTranscriptResponse(blocks)).not.toContain('Worked for 1m 51s');
|
||||
expect(blocks.some((block) => block.kind === 'status' && block.text === 'Worked for 1m 51s')).toBe(true);
|
||||
});
|
||||
|
||||
// COD-226: multiline prompt continuations (2-space Codex gutter) must stay in the
|
||||
// Prompt block, not be misclassified as Response. The gutter is authoritative ahead
|
||||
// of every structural detector (divider, prompt-marker, tool, status).
|
||||
describe('COD-226 multiline prompt gutter is authoritative', () => {
|
||||
it('keeps bullet/prose continuations and internal blank lines in the Prompt block', () => {
|
||||
const transcript = `
|
||||
› i think we can improve this slide. or maybe a follow on slide. here is what i'm thinking:
|
||||
* left hand side: current AI stack (frontier model, cloud hosted, sovereign concerns)
|
||||
right hand -> future enterprise stack: frontier model (optional, cloud), on-prem model router, OSS models
|
||||
|
||||
make the point that the right hand side addresses the concerns.
|
||||
|
||||
gpt-5.4 medium · kb · main · Ready · Context 100% left
|
||||
|
||||
• Explored
|
||||
└ Read SKILL.md
|
||||
|
||||
Here is the actual assistant answer that starts at column zero and is a real response.
|
||||
|
||||
› next prompt
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||
|
||||
// Exactly two prompts, and the first holds the whole multiline prompt.
|
||||
expect(promptBlocks).toHaveLength(2);
|
||||
expect(promptBlocks[0]?.text).toContain('left hand side');
|
||||
expect(promptBlocks[0]?.text).toContain('right hand');
|
||||
expect(promptBlocks[0]?.text).toContain('make the point that the right hand side');
|
||||
|
||||
// The continuation must NOT have leaked into a Response block.
|
||||
const responseBlocks = blocks.filter((b) => b.kind === 'response');
|
||||
expect(responseBlocks.some((b) => b.text.includes('make the point'))).toBe(false);
|
||||
expect(getLastTranscriptResponse(blocks)).toContain('actual assistant answer');
|
||||
expect(getLastTranscriptResponse(blocks)).not.toContain('make the point');
|
||||
});
|
||||
|
||||
it('does not let an indented divider inside a prompt flush the Prompt block', () => {
|
||||
const transcript = `
|
||||
› compare these two layouts
|
||||
first layout uses a single column
|
||||
────────────────────────────
|
||||
second layout uses two columns
|
||||
|
||||
The response begins here at column zero.
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||
|
||||
expect(promptBlocks).toHaveLength(1);
|
||||
expect(promptBlocks[0]?.text).toContain('first layout');
|
||||
expect(promptBlocks[0]?.text).toContain('second layout uses two columns');
|
||||
expect(getLastTranscriptResponse(blocks)).toBe('The response begins here at column zero.');
|
||||
});
|
||||
|
||||
it('treats a gutter-indented literal › as prompt content, not a new prompt', () => {
|
||||
const transcript = `
|
||||
› here is my question about the ui
|
||||
› should this arrow start a new prompt?
|
||||
no it should not — it is part of my question
|
||||
|
||||
Answer at column zero.
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||
|
||||
// The gutter-indented › must NOT open a second prompt.
|
||||
expect(promptBlocks).toHaveLength(1);
|
||||
expect(promptBlocks[0]?.text).toContain('here is my question');
|
||||
expect(promptBlocks[0]?.text).toContain('no it should not');
|
||||
expect(getLastTranscriptResponse(blocks)).toBe('Answer at column zero.');
|
||||
});
|
||||
|
||||
// The two exact live roadmap-tab examples from the ticket (AC: use both verbatim).
|
||||
it('live example 1: two-line prompt keeps the gutter continuation in the Prompt block', () => {
|
||||
const transcript = `
|
||||
› create uid for each feature so it's easy to ref.
|
||||
for the p200 - why is diffentiation only 2/5?
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||
|
||||
expect(promptBlocks).toHaveLength(1);
|
||||
expect(promptBlocks[0]?.text).toContain('create uid for each feature');
|
||||
expect(promptBlocks[0]?.text).toContain('for the p200 - why is diffentiation only 2/5?');
|
||||
// The continuation must not have leaked into a Response block.
|
||||
expect(blocks.some((b) => b.kind === 'response')).toBe(false);
|
||||
});
|
||||
|
||||
it('live example 2: five-line prompt (bullet + prose + blank + prose) stays one Prompt block', () => {
|
||||
const transcript = `
|
||||
› i think we can improve this slide. or maybe a follow on slide. here is what i'm thinking:
|
||||
* left hand side... current AI stack: (frontier model), large component cloud hosted, soverign concerns etc.
|
||||
right hand -> future-enterprise-stack: frontier-model (optional, in cloud), on-prem: model router, OSS models, rest of stack (gpus, data, etc.)
|
||||
|
||||
make the point that the right hand side addresses the concerns.
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||
|
||||
expect(promptBlocks).toHaveLength(1);
|
||||
expect(promptBlocks[0]?.text).toContain('left hand side');
|
||||
expect(promptBlocks[0]?.text).toContain('right hand -> future-enterprise-stack');
|
||||
expect(promptBlocks[0]?.text).toContain('make the point that the right hand side addresses the concerns');
|
||||
expect(blocks.some((b) => b.kind === 'response')).toBe(false);
|
||||
});
|
||||
|
||||
it('still separates a single-line prompt from a column-zero response (no regression)', () => {
|
||||
const transcript = `
|
||||
› say again
|
||||
|
||||
Final polished answer at column zero.
|
||||
|
||||
› next
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||
|
||||
expect(promptBlocks).toHaveLength(2);
|
||||
expect(promptBlocks[0]?.text).toBe('say again');
|
||||
expect(getLastTranscriptResponse(blocks)).toBe('Final polished answer at column zero.');
|
||||
});
|
||||
});
|
||||
|
||||
// COD-227: Last Response must return the final assistant answer, not tool logs.
|
||||
// A response bullet beginning with a tool-like verb (• Created …) must not be
|
||||
// classified as Tool, and genuine • Calling / • Called blocks must be classified
|
||||
// as Tool. Disambiguator: a verb-bullet is a tool header only when followed by a
|
||||
// box-drawing result tree (└│├); Calling/Called are always tool markers.
|
||||
describe('COD-227 tool-header vs response disambiguation', () => {
|
||||
const MINIMAL_REPRO = `
|
||||
› new jira issue
|
||||
|
||||
• The fresh read shows a formatting problem.
|
||||
|
||||
• Calling
|
||||
└ atlassian.jira_update_issue({})
|
||||
|
||||
• Called atlassian.jira_get_issue({})
|
||||
└ { result: true }
|
||||
|
||||
• Created COD-226: View Response → More misclassifies multiline prompt continuations as responses.
|
||||
|
||||
It includes:
|
||||
|
||||
- Two concrete failures
|
||||
- Regression-test criteria
|
||||
`.trim();
|
||||
|
||||
it('returns the final • Created … answer, not the Jira Calling/Called tool log', () => {
|
||||
const blocks = parseExternalCliTranscript(MINIMAL_REPRO, 'codex');
|
||||
const last = getLastTranscriptResponse(blocks);
|
||||
|
||||
expect(last).toContain('Created COD-226');
|
||||
expect(last).toContain('Two concrete failures');
|
||||
// Must exclude tool invocations, raw results, and earlier commentary.
|
||||
expect(last).not.toContain('atlassian.jira');
|
||||
expect(last).not.toContain('Calling');
|
||||
expect(last).not.toContain('Called');
|
||||
expect(last).not.toContain('fresh read');
|
||||
});
|
||||
|
||||
it('classifies genuine • Calling / • Called (with box-drawing results) as Tool', () => {
|
||||
const blocks = parseExternalCliTranscript(MINIMAL_REPRO, 'codex');
|
||||
const toolText = blocks
|
||||
.filter((b) => b.kind === 'tool')
|
||||
.map((b) => b.text)
|
||||
.join('\n');
|
||||
|
||||
expect(toolText).toContain('Calling');
|
||||
expect(toolText).toContain('Called');
|
||||
expect(toolText).toContain('atlassian.jira_update_issue');
|
||||
// The final answer must not have been swallowed into the tool block.
|
||||
expect(toolText).not.toContain('Created COD-226');
|
||||
});
|
||||
|
||||
it('labels the repro chronologically: Prompt, Response (commentary), Tool, Response (final)', () => {
|
||||
const blocks = parseExternalCliTranscript(MINIMAL_REPRO, 'codex');
|
||||
|
||||
expect(blocks.map((b) => b.kind)).toEqual(['prompt', 'response', 'tool', 'response']);
|
||||
expect(blocks[1]?.text).toContain('fresh read');
|
||||
expect(blocks[3]?.text).toContain('Created COD-226');
|
||||
});
|
||||
|
||||
it('does not classify a verb-prefixed prose bullet as Tool when no result tree follows', () => {
|
||||
const transcript = `
|
||||
› do it
|
||||
|
||||
• Created COD-999: a brand new issue with a descriptive title.
|
||||
|
||||
Follow-up prose that belongs to the same answer.
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
|
||||
expect(blocks.some((b) => b.kind === 'tool')).toBe(false);
|
||||
expect(getLastTranscriptResponse(blocks)).toContain('Created COD-999');
|
||||
expect(getLastTranscriptResponse(blocks)).toContain('Follow-up prose');
|
||||
});
|
||||
|
||||
it('does not regress a genuine verb tool block that has a box-drawing continuation', () => {
|
||||
const transcript = `
|
||||
› look around
|
||||
|
||||
• Explored
|
||||
└ Read SKILL.md
|
||||
|
||||
Here is the assistant answer at column zero.
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
|
||||
expect(blocks.some((b) => b.kind === 'tool' && b.text.includes('Explored'))).toBe(true);
|
||||
expect(getLastTranscriptResponse(blocks)).toBe('Here is the assistant answer at column zero.');
|
||||
});
|
||||
});
|
||||
|
||||
// The divider-status detector used to be /^[─-]+\s*(.+?)\s*[─-]{3,}$/, whose
|
||||
// lazy middle backtracked catastrophically on a long dash run that does not
|
||||
// end in 3+ dashes (measured >2min at 8,000 chars). Pane text is
|
||||
// agent-controlled and buffers reach 32MB, so the pattern was replaced by a
|
||||
// linear counter walk. Same approach as the glob-matcher ReDoS fix (68ae9a8):
|
||||
// the hostile input below fails by timeout with the RegExp version.
|
||||
describe('divider-status ReDoS hardening', () => {
|
||||
it('classifies a hostile 10k dash run in linear time', () => {
|
||||
const hostile = '-'.repeat(10_000) + '>';
|
||||
const started = Date.now();
|
||||
const blocks = parseExternalCliTranscript(`› q\n${hostile}`, 'opencode');
|
||||
const elapsed = Date.now() - started;
|
||||
|
||||
expect(elapsed).toBeLessThan(2_000);
|
||||
// No 3-dash tail, so the line is not a status divider — it stays prose.
|
||||
expect(blocks.some((b) => b.kind === 'response' && b.text.includes('>'))).toBe(true);
|
||||
expect(blocks.some((b) => b.kind === 'status')).toBe(false);
|
||||
});
|
||||
|
||||
// The counter walk must keep the EXACT accept set and captured content of
|
||||
// the old regex (including its backtracking quirks on all-dash lines), so
|
||||
// brute-force both against a corpus. The old regex is safe here: probes are
|
||||
// capped at 24 chars, far below the blowup threshold.
|
||||
it('matches the old regex char-for-char on a brute-force corpus', () => {
|
||||
const oldNormalize = (line: string): string | null => {
|
||||
const trimmed = line.trim();
|
||||
const matched = trimmed.match(/^[─-]+\s*(.+?)\s*[─-]{3,}$/);
|
||||
if (!matched) return null;
|
||||
return matched[1]?.trim() || null;
|
||||
};
|
||||
|
||||
// Observe the private normalizeDividerStatusLine through the parser: after
|
||||
// a prompt, a column-zero probe from this alphabet is a status block iff
|
||||
// the divider detector matched, and the status text is its return value.
|
||||
// (The alphabet triggers no other status arm; letters are limited to 'x'.)
|
||||
const observedStatusText = (probe: string): string | null => {
|
||||
const blocks = parseExternalCliTranscript(`› q\n${probe}`, 'opencode');
|
||||
const status = blocks.find((b) => b.kind === 'status');
|
||||
return status ? status.text : null;
|
||||
};
|
||||
|
||||
const directed = [
|
||||
'──── Status ────',
|
||||
'─ Worked for 1m 51s ───────',
|
||||
'---',
|
||||
'----',
|
||||
'-----',
|
||||
'------',
|
||||
'-------',
|
||||
'---- ---',
|
||||
'- ---',
|
||||
'--x',
|
||||
'x---',
|
||||
'----x',
|
||||
'----x--',
|
||||
'----x---',
|
||||
'─x───',
|
||||
'- x ---',
|
||||
'--- x -',
|
||||
'─── ──',
|
||||
'-\tx\t---',
|
||||
'--->----',
|
||||
'─-─- x x ─-─',
|
||||
'- x x x ----',
|
||||
];
|
||||
|
||||
// Deterministic LCG so a failure reproduces byte-identically.
|
||||
let seed = 0x2f6e2b1;
|
||||
const rand = () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x80000000;
|
||||
};
|
||||
const alphabet = ['-', '-', '─', '─', ' ', ' ', '\t', 'x', '>'];
|
||||
const probes = [...directed];
|
||||
for (let i = 0; i < 3000; i += 1) {
|
||||
const len = Math.floor(rand() * 25);
|
||||
let probe = '';
|
||||
for (let j = 0; j < len; j += 1) {
|
||||
probe += alphabet[Math.floor(rand() * alphabet.length)];
|
||||
}
|
||||
probes.push(probe);
|
||||
}
|
||||
|
||||
for (const raw of probes) {
|
||||
const probe = raw.trim();
|
||||
// Divider-only lines (8+ dash/whitespace chars) are consumed by
|
||||
// isDividerOnlyLine before the status detector ever runs, in both the
|
||||
// old and new worlds — the detector is unobservable there.
|
||||
if (!probe || /^[\s─-]{8,}$/.test(probe)) continue;
|
||||
expect(observedStatusText(probe), `probe: ${JSON.stringify(probe)}`).toBe(oldNormalize(probe));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// EXTERNAL_CLI_MODES is a local duplicate of isExternalCliMode() in
|
||||
// src/session.ts (importing session.ts would drag node-pty and the whole
|
||||
// session layer into the pure transcript module). This pin is what keeps the
|
||||
// two from drifting — 'pi' was missing here while isExternalCliMode() had it,
|
||||
// which left pi sessions with the exact empty-viewer symptom the transcript
|
||||
// branch exists to fix.
|
||||
describe('mode parity with isExternalCliMode()', () => {
|
||||
// Exhaustive by construction: adding a SessionMode without deciding its
|
||||
// transcript behavior fails to compile here.
|
||||
const ALL_SESSION_MODES: Record<SessionMode, true> = {
|
||||
claude: true,
|
||||
shell: true,
|
||||
opencode: true,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
antigravity: true,
|
||||
pi: true,
|
||||
};
|
||||
|
||||
it('agrees with isExternalCliMode() for every SessionMode', () => {
|
||||
for (const mode of Object.keys(ALL_SESSION_MODES) as SessionMode[]) {
|
||||
expect(isExternalCliTranscriptMode(mode), `mode: ${mode}`).toBe(isExternalCliMode(mode));
|
||||
}
|
||||
});
|
||||
|
||||
it('covers pi', () => {
|
||||
expect(isExternalCliTranscriptMode('pi')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* GET /api/sessions/:id/files?q=... — search mode.
|
||||
*
|
||||
* A query turns the endpoint from a nested tree into a flat match list. Two
|
||||
* properties are worth pinning: the walk must recurse PAST non-matching
|
||||
* directories (searching is pointless if a file whose parents don't match is
|
||||
* unreachable), and an empty query must leave the default tree response exactly
|
||||
* as it was, since that is every existing caller.
|
||||
*
|
||||
* Runs against a real temp directory rather than a mocked fs: the value here is
|
||||
* the traversal, and a mocked readdir would just be asserting the mock.
|
||||
* Port: none (app.inject).
|
||||
*/
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'codeman-file-search-'));
|
||||
mkdirSync(join(root, 'src', 'deep', 'nested'), { recursive: true });
|
||||
mkdirSync(join(root, 'node_modules', 'pkg'), { recursive: true });
|
||||
writeFileSync(join(root, 'README.md'), '#\n');
|
||||
writeFileSync(join(root, 'src', 'widget.ts'), 'x\n');
|
||||
writeFileSync(join(root, 'src', 'deep', 'nested', 'widget-test.ts'), 'x\n');
|
||||
writeFileSync(join(root, 'node_modules', 'pkg', 'widget.ts'), 'x\n');
|
||||
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
describe('files endpoint search mode', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerFileRoutes);
|
||||
harness.ctx._session.workingDir = root;
|
||||
});
|
||||
|
||||
const get = async (query: string) => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/files${query}`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
return JSON.parse(res.body);
|
||||
};
|
||||
|
||||
it('returns a flat match list and reaches files under non-matching directories', () => {
|
||||
return get('?q=widget').then((body) => {
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.mode).toBe('search');
|
||||
|
||||
const paths = body.data.matches.map((m: { path: string }) => m.path).sort();
|
||||
// deep/nested matches only because the walk recursed through `src` and
|
||||
// `deep`, neither of which matches 'widget' itself.
|
||||
expect(paths).toEqual([join('src', 'deep', 'nested', 'widget-test.ts'), join('src', 'widget.ts')]);
|
||||
expect(body.data.matchCount).toBe(2);
|
||||
expect(body.data.query).toBe('widget');
|
||||
// The nested tree is not built in search mode.
|
||||
expect(body.data.tree).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('still honours the excluded-directory list while searching', async () => {
|
||||
const body = await get('?q=widget');
|
||||
const paths = body.data.matches.map((m: { path: string }) => m.path);
|
||||
|
||||
expect(paths.some((p: string) => p.includes('node_modules'))).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves the default tree response untouched when no query is given', async () => {
|
||||
const body = await get('');
|
||||
|
||||
expect(body.data.mode).toBeUndefined();
|
||||
expect(body.data.matches).toBeUndefined();
|
||||
expect(Array.isArray(body.data.tree)).toBe(true);
|
||||
expect(body.data.tree.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('treats a whitespace-only query as no query at all', async () => {
|
||||
const body = await get('?q=%20%20');
|
||||
|
||||
expect(body.data.mode).toBeUndefined();
|
||||
expect(Array.isArray(body.data.tree)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports directories that match as well as files', async () => {
|
||||
const body = await get('?q=nested');
|
||||
|
||||
expect(body.data.matches).toHaveLength(1);
|
||||
expect(body.data.matches[0]).toMatchObject({ name: 'nested', type: 'directory' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* @fileoverview Tests for the external-CLI branch of GET /api/sessions/:id/last-response.
|
||||
*
|
||||
* Uses app.inject() — no real HTTP ports needed.
|
||||
* Port: N/A (app.inject doesn't open ports)
|
||||
*
|
||||
* OpenCode / Gemini / Antigravity / Pi render their own TUIs and never write a
|
||||
* Claude transcript under ~/.claude/projects, so before this branch existed the
|
||||
* handler fell through to the Claude scan, found nothing, and the response viewer
|
||||
* was permanently empty for those modes. These tests pin:
|
||||
* - the pane buffer is segmented and the LAST response is returned
|
||||
* - ?context=full carries the parsed blocks, and the short form omits them
|
||||
* - ?context=full blocks carry role — the frontend renders via msg.role, so a
|
||||
* block without it lost the "You" badge on prompts
|
||||
* - a pane that has produced no output reports hasContext: false rather than 404ing
|
||||
* - Claude mode still takes the Claude path (regression guard)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
|
||||
interface LocalHarness {
|
||||
app: FastifyInstance;
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
/** Mirror of the production uniform-envelope hook (server.ts), as in the sibling suites. */
|
||||
async function createEnvelopeHarness(
|
||||
registerFn: (app: FastifyInstance, ctx: MockRouteContext) => void
|
||||
): Promise<LocalHarness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
const ctx = createMockRouteContext();
|
||||
registerFn(app, ctx);
|
||||
|
||||
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||
if (!req.url.startsWith('/api')) return done(null, payload);
|
||||
if (payload === null || typeof payload !== 'object') return done(null, payload);
|
||||
if (Buffer.isBuffer(payload) || typeof (payload as { pipe?: unknown }).pipe === 'function') {
|
||||
return done(null, payload);
|
||||
}
|
||||
const p = payload as { success?: unknown; errorCode?: unknown };
|
||||
if (p.success === false) {
|
||||
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
|
||||
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
|
||||
}
|
||||
return done(null, payload);
|
||||
}
|
||||
if (p.success === true) return done(null, payload);
|
||||
return done(null, { success: true, data: payload });
|
||||
});
|
||||
|
||||
await app.ready();
|
||||
return { app, ctx };
|
||||
}
|
||||
|
||||
// A pane as one of these CLIs actually leaves it: banner, a `›` prompt line, a
|
||||
// status divider, a tool-activity marker, then the assistant's prose.
|
||||
const PANE = `
|
||||
╭──────────────────────────────────────────────────────╮
|
||||
│ >_ OpenCode │
|
||||
│ directory: /workspace/project │
|
||||
╰──────────────────────────────────────────────────────╯
|
||||
|
||||
› summarise the retry logic
|
||||
|
||||
model · project · main · Ready · Context 100% left
|
||||
|
||||
• Explored
|
||||
└ Read src/retry.ts
|
||||
|
||||
The retry helper backs off exponentially and gives up after five attempts.
|
||||
|
||||
› now document it
|
||||
|
||||
model · project · main · Ready · Context 92% left
|
||||
|
||||
• Called write_file
|
||||
|
||||
Documented the helper in docs/retry.md, including the five-attempt ceiling.
|
||||
`.trim();
|
||||
|
||||
describe('GET /api/sessions/:id/last-response — external CLI panes', () => {
|
||||
let harness: LocalHarness;
|
||||
let session: ReturnType<typeof createMockSession>;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createEnvelopeHarness(registerSessionRoutes);
|
||||
session = harness.ctx._session;
|
||||
});
|
||||
|
||||
async function lastResponse(full = false) {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${session.id}/last-response${full ? '?context=full' : ''}`,
|
||||
});
|
||||
return { res, body: JSON.parse(res.body) };
|
||||
}
|
||||
|
||||
for (const mode of ['opencode', 'gemini', 'antigravity', 'pi'] as const) {
|
||||
it(`returns the last assistant response from the ${mode} pane buffer`, async () => {
|
||||
session.mode = mode;
|
||||
session.terminalBuffer = PANE;
|
||||
|
||||
const { res, body } = await lastResponse();
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
// The LAST response, not the first — the viewer shows the current turn.
|
||||
expect(body.data.text).toContain('Documented the helper in docs/retry.md');
|
||||
expect(body.data.text).not.toContain('backs off exponentially');
|
||||
expect(body.data.hasContext).toBe(true);
|
||||
// Short form stays short: blocks only travel under ?context=full.
|
||||
expect(body.data.messages).toBeUndefined();
|
||||
});
|
||||
}
|
||||
|
||||
it('carries the parsed blocks under ?context=full', async () => {
|
||||
session.mode = 'opencode';
|
||||
session.terminalBuffer = PANE;
|
||||
|
||||
const { body } = await lastResponse(true);
|
||||
|
||||
const kinds = body.data.messages.map((block: { kind: string }) => block.kind);
|
||||
expect(kinds).toContain('prompt');
|
||||
expect(kinds).toContain('response');
|
||||
expect(kinds).toContain('tool');
|
||||
// Both user turns survive segmentation, so the viewer can show the exchange.
|
||||
const prompts = body.data.messages.filter((b: { kind: string }) => b.kind === 'prompt');
|
||||
expect(prompts).toHaveLength(2);
|
||||
expect(prompts[1].text).toContain('now document it');
|
||||
|
||||
// loadFullContext() renders via msg.role — without it every block got the
|
||||
// agent badge and the user's own prompts lost their "You" attribution.
|
||||
for (const block of body.data.messages as Array<{ kind: string; role: string }>) {
|
||||
expect(block.role).toBe(block.kind === 'prompt' ? 'user' : 'assistant');
|
||||
}
|
||||
});
|
||||
|
||||
it('reports hasContext false for a pane that has produced no output', async () => {
|
||||
session.mode = 'gemini';
|
||||
// Session created but nothing rendered yet. Any non-prompt line counts as
|
||||
// prose to the parser, so the empty pane is the honest no-context case.
|
||||
session.terminalBuffer = '';
|
||||
|
||||
const { res, body } = await lastResponse();
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(body.data.text).toBe('');
|
||||
expect(body.data.hasContext).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves Claude mode on the Claude transcript path', async () => {
|
||||
// Regression guard: a claude pane must NOT be segmented off its terminal
|
||||
// buffer, or a real transcript would be shadowed by scraped pane text.
|
||||
session.mode = 'claude';
|
||||
session.terminalBuffer = PANE;
|
||||
|
||||
const { res, body } = await lastResponse();
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(body.data.text).not.toContain('Documented the helper');
|
||||
});
|
||||
});
|
||||
@@ -789,6 +789,7 @@ describe('session-routes', () => {
|
||||
expect(body.data.terminalBuffer).toContain(lastLine);
|
||||
expect(body.data.source).toBe('mux-full-history');
|
||||
expect(typeof body.data.fullSize).toBe('number');
|
||||
expect(res.headers['server-timing']).toMatch(/^capture;dur=\d+\.\d, prepare;dur=\d+\.\d, total;dur=\d+\.\d$/);
|
||||
});
|
||||
|
||||
it('full reload (?full=1) returns the tmux capture ALONE — byte history is not duplicated', async () => {
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Auto Copy (copy-on-select) guards.
|
||||
*
|
||||
* The feature is invisible when it works, so every bug in it is silent: a
|
||||
* clipboard that quietly stops updating, or one that quietly overwrites itself
|
||||
* on an unrelated click. These tests drive the SHIPPED decision helper
|
||||
* (constants.js) and the SHIPPED flush (terminal-ui.js), plus the settings
|
||||
* wiring that decides whether the toggle reaches the code at all.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8');
|
||||
|
||||
function loadHarness() {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const windowRef: Record<string, any> = {};
|
||||
const documentListeners = new Map<string, ((ev: any) => void)[]>();
|
||||
let activeElement: any = null;
|
||||
let now = 1_000;
|
||||
|
||||
const RealDate = Date;
|
||||
const DateStub: any = function DateStub(this: any, ...args: any[]) {
|
||||
return new (RealDate as any)(...args);
|
||||
};
|
||||
DateStub.now = () => now;
|
||||
|
||||
const context = vm.createContext({
|
||||
window: windowRef,
|
||||
document: {
|
||||
body: { classList: { contains: () => false } },
|
||||
get activeElement() {
|
||||
return activeElement;
|
||||
},
|
||||
getElementById: () => null,
|
||||
addEventListener: (type: string, listener: (ev: any) => void) => {
|
||||
const list = documentListeners.get(type) ?? [];
|
||||
list.push(listener);
|
||||
documentListeners.set(type, list);
|
||||
},
|
||||
},
|
||||
CodemanApp,
|
||||
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
|
||||
_crashDiag: { log: vi.fn() },
|
||||
Date: DateStub,
|
||||
performance: { now: () => now },
|
||||
requestAnimationFrame: (_fn: () => void) => 1,
|
||||
setTimeout: (_fn: () => void) => 1,
|
||||
Blob: function Blob() {},
|
||||
URL: { createObjectURL: () => 'blob:yield', revokeObjectURL: () => {} },
|
||||
Worker: function Worker(this: any) {
|
||||
this.postMessage = () => {};
|
||||
},
|
||||
MobileDetection: { isTouchDevice: () => false },
|
||||
KeyboardHandler: { keyboardVisible: false },
|
||||
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||
});
|
||||
|
||||
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||||
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
const toasts: { message: string; type: string }[] = [];
|
||||
app.showToast = (message: string, type: string) => toasts.push({ message, type });
|
||||
app._copyText = vi.fn(async () => true);
|
||||
app.loadAppSettingsFromStorage = () => ({ autoCopySelection: true });
|
||||
app.terminal = {
|
||||
hasSelection: () => true,
|
||||
getSelection: () => 'copied text',
|
||||
clearSelection: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
app,
|
||||
windowRef,
|
||||
toasts,
|
||||
setNow: (value: number) => {
|
||||
now = value;
|
||||
},
|
||||
setActiveElement: (element: any) => {
|
||||
activeElement = element;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('decideAutoCopy', () => {
|
||||
const decide = (params: Record<string, unknown>) => loadHarness().windowRef.CodemanAutoCopy.decide(params);
|
||||
|
||||
it('does nothing while the setting is off', () => {
|
||||
expect(decide({ enabled: false, text: 'hello', pending: true })).toBe('skip');
|
||||
});
|
||||
|
||||
it('ignores a blank or whitespace-only selection', () => {
|
||||
// A drag across empty cells; a wall of spaces is never what the gesture meant.
|
||||
expect(decide({ enabled: true, text: ' \n ', pending: true })).toBe('skip');
|
||||
expect(decide({ enabled: true, text: '', pending: true })).toBe('skip');
|
||||
});
|
||||
|
||||
it('copies a genuine selection change even when the text repeats', () => {
|
||||
// Re-selecting the same text after copying something else in between is a
|
||||
// deliberate act, so `pending` outranks the text dedupe.
|
||||
expect(decide({ enabled: true, text: 'same', lastCopied: 'same', pending: true })).toBe('copy');
|
||||
});
|
||||
|
||||
it('copies changed text when onSelectionChange has not fired yet', () => {
|
||||
// xterm fires onSelectionChange from its OWN document mouseup handler, and
|
||||
// listener order between the two is not something the app controls, so the
|
||||
// first copy of a drag must not depend on `pending`.
|
||||
expect(decide({ enabled: true, text: 'fresh', lastCopied: 'stale', pending: false })).toBe('copy');
|
||||
});
|
||||
|
||||
it('leaves an unchanged selection alone on an unrelated mouseup', () => {
|
||||
expect(decide({ enabled: true, text: 'same', lastCopied: 'same', pending: false })).toBe('skip');
|
||||
});
|
||||
|
||||
it('refuses a selection past the size cap instead of truncating it', () => {
|
||||
const { windowRef } = loadHarness();
|
||||
const huge = 'x'.repeat(windowRef.CodemanAutoCopy.MAX_CHARS + 1);
|
||||
expect(windowRef.CodemanAutoCopy.decide({ enabled: true, text: huge, pending: true })).toBe('too-large');
|
||||
});
|
||||
});
|
||||
|
||||
describe('_flushAutoCopySelection', () => {
|
||||
it('copies the selection without clearing it or stealing focus', async () => {
|
||||
const { app } = loadHarness();
|
||||
app._autoCopyPending = true;
|
||||
|
||||
await app._flushAutoCopySelection();
|
||||
|
||||
expect(app._copyText).toHaveBeenCalledWith('copied text');
|
||||
// The two things copyTerminalSelection does and this path must not: clearing
|
||||
// makes the text vanish under the cursor that highlighted it, and focusing
|
||||
// opens the on-screen keyboard over it on a phone.
|
||||
expect(app.terminal.clearSelection).not.toHaveBeenCalled();
|
||||
expect(app.terminal.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the copy synchronously, inside the gesture that triggered it', () => {
|
||||
// Both clipboard paths need user activation: Firefox gates
|
||||
// navigator.clipboard.writeText on it, and execCommand('copy') (the
|
||||
// plain-HTTP fallback) must run in the gesture's own task.
|
||||
const { app } = loadHarness();
|
||||
app._autoCopyPending = true;
|
||||
|
||||
void app._flushAutoCopySelection();
|
||||
|
||||
expect(app._copyText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stays silent while the setting is off', async () => {
|
||||
const { app, toasts } = loadHarness();
|
||||
app.loadAppSettingsFromStorage = () => ({ autoCopySelection: false });
|
||||
app._autoCopyPending = true;
|
||||
|
||||
await app._flushAutoCopySelection();
|
||||
|
||||
expect(app._copyText).not.toHaveBeenCalled();
|
||||
expect(toasts).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats a missing setting as off', async () => {
|
||||
const { app } = loadHarness();
|
||||
app.loadAppSettingsFromStorage = () => ({});
|
||||
app._autoCopyPending = true;
|
||||
|
||||
await app._flushAutoCopySelection();
|
||||
|
||||
expect(app._copyText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('announces itself once per page load, then goes quiet', async () => {
|
||||
const { app, toasts } = loadHarness();
|
||||
let text = 'first';
|
||||
app.terminal.getSelection = () => text;
|
||||
|
||||
app._autoCopyPending = true;
|
||||
await app._flushAutoCopySelection();
|
||||
text = 'second';
|
||||
app._autoCopyPending = true;
|
||||
await app._flushAutoCopySelection();
|
||||
|
||||
expect(app._copyText).toHaveBeenCalledTimes(2);
|
||||
expect(toasts).toEqual([{ message: 'Auto Copy: selection copied', type: 'success' }]);
|
||||
});
|
||||
|
||||
it('disarms the pending flag so the next unrelated mouseup copies nothing', async () => {
|
||||
const { app } = loadHarness();
|
||||
app._autoCopyPending = true;
|
||||
|
||||
await app._flushAutoCopySelection();
|
||||
await app._flushAutoCopySelection();
|
||||
|
||||
expect(app._autoCopyPending).toBe(false);
|
||||
expect(app._copyText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hands focus back when the execCommand fallback took it', async () => {
|
||||
const { app, setActiveElement } = loadHarness();
|
||||
const terminalTextarea = { focus: vi.fn(), isConnected: true };
|
||||
setActiveElement(terminalTextarea);
|
||||
// The fallback appends a temp textarea, selects it, then removes it, which
|
||||
// leaves the document with no focused element at all.
|
||||
app._copyText = vi.fn(async () => {
|
||||
setActiveElement(null);
|
||||
return true;
|
||||
});
|
||||
app._autoCopyPending = true;
|
||||
|
||||
await app._flushAutoCopySelection();
|
||||
|
||||
expect(terminalTextarea.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves focus alone when the clipboard API never moved it', async () => {
|
||||
const { app, setActiveElement } = loadHarness();
|
||||
const terminalTextarea = { focus: vi.fn(), isConnected: true };
|
||||
setActiveElement(terminalTextarea);
|
||||
app._autoCopyPending = true;
|
||||
|
||||
await app._flushAutoCopySelection();
|
||||
|
||||
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a blocked clipboard and lets the next gesture retry the same text', async () => {
|
||||
const { app, toasts } = loadHarness();
|
||||
app._copyText = vi.fn(async () => false);
|
||||
app._autoCopyPending = true;
|
||||
|
||||
await app._flushAutoCopySelection();
|
||||
expect(toasts).toEqual([{ message: 'Auto Copy failed: the browser blocked clipboard access', type: 'error' }]);
|
||||
|
||||
// Without the reset, the dedupe would swallow every retry of the same text.
|
||||
app._autoCopyPending = false;
|
||||
await app._flushAutoCopySelection();
|
||||
expect(app._copyText).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throttles the failure toast so a blocked clipboard cannot spam every drag', async () => {
|
||||
const { app, toasts, setNow } = loadHarness();
|
||||
app._copyText = vi.fn(async () => false);
|
||||
|
||||
app._autoCopyPending = true;
|
||||
await app._flushAutoCopySelection();
|
||||
setNow(2_000);
|
||||
app._autoCopyPending = true;
|
||||
await app._flushAutoCopySelection();
|
||||
expect(toasts).toHaveLength(1);
|
||||
|
||||
setNow(1_000 + 10_001);
|
||||
app._autoCopyPending = true;
|
||||
await app._flushAutoCopySelection();
|
||||
expect(toasts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('refuses an oversized selection and says how to copy it anyway', async () => {
|
||||
const { app, toasts, windowRef } = loadHarness();
|
||||
const huge = 'x'.repeat(windowRef.CodemanAutoCopy.MAX_CHARS + 1);
|
||||
app.terminal.getSelection = () => huge;
|
||||
app._autoCopyPending = true;
|
||||
|
||||
await app._flushAutoCopySelection();
|
||||
|
||||
expect(app._copyText).not.toHaveBeenCalled();
|
||||
expect(toasts).toEqual([{ message: 'Selection too large to copy automatically. Press Ctrl+C.', type: 'warning' }]);
|
||||
});
|
||||
|
||||
it('does nothing when the selection was already dropped', async () => {
|
||||
const { app } = loadHarness();
|
||||
app.terminal.hasSelection = () => false;
|
||||
app._autoCopyPending = true;
|
||||
|
||||
await app._flushAutoCopySelection();
|
||||
|
||||
expect(app._copyText).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto Copy wiring', () => {
|
||||
const terminalUi = read('terminal-ui.js');
|
||||
const settingsUi = read('settings-ui.js');
|
||||
const html = read('index.html');
|
||||
const schemas = readFileSync(resolve(import.meta.dirname, '../src/web/schemas.ts'), 'utf8');
|
||||
|
||||
it('arms on a selection change and disarms when the selection is dropped', () => {
|
||||
const start = terminalUi.indexOf('this.terminal.onSelectionChange?.(');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const body = terminalUi.slice(start, start + 1200);
|
||||
expect(body).toContain('this._autoCopyPending = false;');
|
||||
expect(body).toContain('this._autoCopyPending = true;');
|
||||
});
|
||||
|
||||
it('flushes from the document mouseup, not from the selection change', () => {
|
||||
// Copying inside onSelectionChange would be one clipboard write per cell the
|
||||
// drag crosses.
|
||||
expect(terminalUi).toContain("document.addEventListener('mouseup', () => this._flushAutoCopySelection());");
|
||||
const start = terminalUi.indexOf('this.terminal.onSelectionChange?.(');
|
||||
const body = terminalUi.slice(start, terminalUi.indexOf('_autoCopyListenerInstalled', start));
|
||||
expect(body).not.toContain('_flushAutoCopySelection');
|
||||
});
|
||||
|
||||
it('flushes from the touch gesture end, which never produces a mouseup', () => {
|
||||
// The touch path preventDefaults its touchend to stop the compat mouse pair
|
||||
// from stealing the selection back, so phones need their own call.
|
||||
const start = terminalUi.indexOf('_endTouchSelectionGesture() {');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
expect(terminalUi.slice(start, start + 900)).toContain('this._flushAutoCopySelection();');
|
||||
});
|
||||
|
||||
it('keeps the toggle per-device: display key, stripped from the PUT, absent from the schema', () => {
|
||||
const displayKeys = settingsUi.slice(
|
||||
settingsUi.indexOf('const displayKeys = new Set(['),
|
||||
settingsUi.indexOf('])', settingsUi.indexOf('const displayKeys = new Set(['))
|
||||
);
|
||||
expect(displayKeys).toContain("'autoCopySelection'");
|
||||
// SettingsUpdateSchema is .strict(), so a key it does not declare 400s the
|
||||
// whole settings PUT if the client sends it.
|
||||
expect(settingsUi).toContain('autoCopySelection: _acs,');
|
||||
expect(schemas).not.toContain('autoCopySelection');
|
||||
});
|
||||
|
||||
it('keeps the control loadable and savable by id', () => {
|
||||
expect(html).toContain('id="appSettingsAutoCopySelection"');
|
||||
expect(settingsUi).toContain(
|
||||
"document.getElementById('appSettingsAutoCopySelection').checked = settings.autoCopySelection === true;"
|
||||
);
|
||||
expect(settingsUi).toContain("autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,");
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@
|
||||
* emitted through onData (the bytes that would reach the PTY).
|
||||
*
|
||||
* Browser-driven, so it is excluded from `npm run test:ci` like the other
|
||||
* Playwright suites. Run locally: npm test -- test/terminal-copy-shortcut.test.ts
|
||||
* Playwright suites. Run locally: npm run test:browser -- test/terminal-copy-shortcut.test.ts
|
||||
*
|
||||
* Port: 3174 (per MEMORY.md, ports 3150+ for tests)
|
||||
*/
|
||||
@@ -23,6 +23,7 @@ import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3174;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
const IME_PUNCTUATION = ',。!?;:“”、《》、()';
|
||||
|
||||
describe('terminal Ctrl+C smart copy', () => {
|
||||
let server: WebServer;
|
||||
@@ -102,6 +103,59 @@ describe('terminal Ctrl+C smart copy', () => {
|
||||
}));
|
||||
}
|
||||
|
||||
async function captureImeInput(targetPage: Page) {
|
||||
await targetPage.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
await targetPage.evaluate(() => {
|
||||
const term = (window as any).app.terminal;
|
||||
(window as any).__data = [];
|
||||
if (!(window as any).__dataHooked) {
|
||||
term.onData((d: string) => (window as any).__data.push(d));
|
||||
(window as any).__dataHooked = true;
|
||||
}
|
||||
(document.querySelector('.xterm-helper-textarea') as HTMLElement).focus();
|
||||
});
|
||||
|
||||
const cdp = await targetPage.context().newCDPSession(targetPage);
|
||||
await cdp.send('Input.imeSetComposition', { text: '中文', selectionStart: 2, selectionEnd: 2 });
|
||||
await cdp.send('Input.insertText', { text: '中文' });
|
||||
await targetPage.waitForFunction(() => (window as any).__data.join('') === '中文', null, { polling: 10 });
|
||||
|
||||
let expected = '中文';
|
||||
for (const punctuation of Array.from(IME_PUNCTUATION)) {
|
||||
// Keep keydown -> DOM mutation -> input in one browser task, as a
|
||||
// native key default action does. Separate CDP calls can let xterm's
|
||||
// zero-delay textarea diff run before Input.insertText reaches the page.
|
||||
await targetPage.evaluate((text) => {
|
||||
const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement;
|
||||
const down = new KeyboardEvent('keydown', {
|
||||
key: 'Process',
|
||||
code: 'Unidentified',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
Object.defineProperties(down, { keyCode: { value: 229 }, which: { value: 229 } });
|
||||
textarea.dispatchEvent(down);
|
||||
if (!document.execCommand('insertText', false, text)) throw new Error('browser rejected insertText');
|
||||
const up = new KeyboardEvent('keyup', {
|
||||
key: 'Process',
|
||||
code: 'Unidentified',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
Object.defineProperties(up, { keyCode: { value: 229 }, which: { value: 229 } });
|
||||
textarea.dispatchEvent(up);
|
||||
}, punctuation);
|
||||
expected += punctuation;
|
||||
await targetPage.waitForFunction((text) => (window as any).__data.join('') === text, expected, {
|
||||
polling: 10,
|
||||
});
|
||||
}
|
||||
|
||||
return targetPage.evaluate(() => (window as any).__data as string[]);
|
||||
}
|
||||
|
||||
it('copies the selection and sends nothing to the PTY', async () => {
|
||||
await setup('COPY-CASE-SELECTED', true);
|
||||
await page.keyboard.press('Control+c');
|
||||
@@ -163,4 +217,20 @@ describe('terminal Ctrl+C smart copy', () => {
|
||||
expect(res.data.join('')).toContain('SENTINEL'); // pasted text, not ^V
|
||||
expect(res.data.join('')).not.toContain('\x16');
|
||||
});
|
||||
|
||||
it('forwards full-width punctuation after a Chinese IME composition', async () => {
|
||||
await setup('IME-PUNCTUATION', false);
|
||||
const desktopChunks = await captureImeInput(page);
|
||||
expect(desktopChunks.join('')).toBe('中文' + IME_PUNCTUATION);
|
||||
|
||||
const touchContext = await browser.newContext({ hasTouch: true });
|
||||
try {
|
||||
const touchPage = await touchContext.newPage();
|
||||
await touchPage.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
const touchChunks = await captureImeInput(touchPage);
|
||||
expect(touchChunks.join('')).toBe('中文' + IME_PUNCTUATION);
|
||||
} finally {
|
||||
await touchContext.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('terminal flush budget', () => {
|
||||
expect(app.pendingWrites.join('')).toHaveLength(32 * 1024);
|
||||
});
|
||||
|
||||
it('waits for xterm to process small buffer replays before completing buffer load', async () => {
|
||||
it('releases the live-output gate but waits for xterm to parse a small replay', async () => {
|
||||
const { app, writes } = loadTerminalUiHarness('codex');
|
||||
let writeDone: (() => void) | undefined;
|
||||
let resolved = false;
|
||||
@@ -109,13 +109,90 @@ describe('terminal flush budget', () => {
|
||||
expect(writes).toEqual(['fresh tmux pane frame']);
|
||||
expect(writeDone).toBeTypeOf('function');
|
||||
expect(resolved).toBe(false);
|
||||
expect(finishBufferLoad).not.toHaveBeenCalled();
|
||||
expect(finishBufferLoad).toHaveBeenCalledOnce();
|
||||
|
||||
writeDone?.();
|
||||
await promise;
|
||||
|
||||
expect(resolved).toBe(true);
|
||||
expect(finishBufferLoad).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('paces a large enqueue and releases live output before the parse marker completes', async () => {
|
||||
const { app, writes } = loadTerminalUiHarness('shell');
|
||||
const scheduled: Array<() => void> = [];
|
||||
let parseDone: (() => void) | undefined;
|
||||
let resolved = false;
|
||||
let result: { parsedAt: number; bufferLength: number; completed: boolean } | undefined;
|
||||
app._safeYield = (callback: () => void) => scheduled.push(callback);
|
||||
app.isTerminalAtBottom = () => true;
|
||||
app.terminal.buffer = { active: { length: 37 } };
|
||||
app.terminal.write = (data: string, callback?: () => void) => {
|
||||
writes.push(data);
|
||||
if (callback) parseDone = callback;
|
||||
};
|
||||
|
||||
const promise = app.chunkedTerminalWrite('x'.repeat(3 * 32 * 1024)).then((value: typeof result) => {
|
||||
result = value;
|
||||
resolved = true;
|
||||
});
|
||||
expect(writes).toEqual([]);
|
||||
expect(scheduled).toHaveLength(1);
|
||||
|
||||
scheduled.shift()?.();
|
||||
expect(writes.map((write) => write.length)).toEqual([32 * 1024]);
|
||||
expect(scheduled).toHaveLength(1);
|
||||
|
||||
scheduled.shift()?.();
|
||||
expect(writes.map((write) => write.length)).toEqual([32 * 1024, 32 * 1024]);
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
scheduled.shift()?.();
|
||||
expect(writes.map((write) => write.length)).toEqual([32 * 1024, 32 * 1024, 32 * 1024, 0]);
|
||||
expect(app._isLoadingBuffer).toBe(false);
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
app.batchTerminalWrite('new output after snapshot');
|
||||
expect(app._loadBufferQueue).toBe(null);
|
||||
expect(app.pendingWrites).toEqual(['new output after snapshot']);
|
||||
|
||||
parseDone?.();
|
||||
app.terminal.buffer.active.length = 42;
|
||||
await promise;
|
||||
expect(resolved).toBe(true);
|
||||
expect(result?.bufferLength).toBe(37);
|
||||
expect(result?.completed).toBe(true);
|
||||
});
|
||||
|
||||
it('marks a parse callback stale when a newer replay supersedes it', async () => {
|
||||
const { app } = loadTerminalUiHarness('shell');
|
||||
let parseDone: (() => void) | undefined;
|
||||
app.terminal.write = (_data: string, callback?: () => void) => {
|
||||
parseDone = callback;
|
||||
};
|
||||
|
||||
const firstReplay = app.chunkedTerminalWrite('old snapshot');
|
||||
app._chunkedWriteGen += 1;
|
||||
parseDone?.();
|
||||
|
||||
await expect(firstReplay).resolves.toMatchObject({ completed: false });
|
||||
});
|
||||
|
||||
it('scans xterm rows from buffer.length instead of double-counting baseY', () => {
|
||||
const { app } = loadTerminalUiHarness('shell');
|
||||
const getLine = vi.fn((index: number) =>
|
||||
index === 99 || index === 77 ? { translateToString: () => 'content' } : undefined
|
||||
);
|
||||
app.terminal = {
|
||||
rows: 24,
|
||||
buffer: { active: { baseY: 76, length: 100, getLine } },
|
||||
scrollToBottom: vi.fn(),
|
||||
scrollToLine: vi.fn(),
|
||||
};
|
||||
|
||||
app.scrollToLastNonEmptyLine();
|
||||
|
||||
expect(getLine.mock.calls[0]?.[0]).toBe(99);
|
||||
expect(app.terminal.scrollToLine).toHaveBeenCalledWith(77);
|
||||
});
|
||||
|
||||
it('keeps stale buffer load owners from finishing a newer load', () => {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function loadFontHelper() {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
vm.runInContext(source, context, { filename: 'constants.js' });
|
||||
return (
|
||||
context.window as {
|
||||
CodemanTerminalFont: {
|
||||
DEFAULT_STACK: string;
|
||||
resolve: (custom?: unknown) => string;
|
||||
};
|
||||
}
|
||||
).CodemanTerminalFont;
|
||||
}
|
||||
|
||||
const font = loadFontHelper();
|
||||
|
||||
describe('CodemanTerminalFont', () => {
|
||||
it('returns the default stack for empty or missing input', () => {
|
||||
expect(font.resolve(undefined)).toBe(font.DEFAULT_STACK);
|
||||
expect(font.resolve('')).toBe(font.DEFAULT_STACK);
|
||||
expect(font.resolve(' ')).toBe(font.DEFAULT_STACK);
|
||||
expect(font.resolve(42)).toBe(font.DEFAULT_STACK);
|
||||
});
|
||||
|
||||
it('keeps the symbols fallback ahead of monospace in the default stack', () => {
|
||||
const symbolsAt = font.DEFAULT_STACK.indexOf('"Symbols Nerd Font Mono"');
|
||||
const monoAt = font.DEFAULT_STACK.lastIndexOf('monospace');
|
||||
expect(symbolsAt).toBeGreaterThan(-1);
|
||||
expect(monoAt).toBeGreaterThan(symbolsAt);
|
||||
});
|
||||
|
||||
it('prepends a custom family and preserves the full default stack', () => {
|
||||
expect(font.resolve('Menlo')).toBe(`Menlo, ${font.DEFAULT_STACK}`);
|
||||
});
|
||||
|
||||
it('quotes names that need quoting for CSS', () => {
|
||||
expect(font.resolve('JetBrainsMono Nerd Font')).toBe(`"JetBrainsMono Nerd Font", ${font.DEFAULT_STACK}`);
|
||||
});
|
||||
|
||||
it('normalizes already-quoted input instead of double-quoting', () => {
|
||||
expect(font.resolve('"JetBrainsMono Nerd Font"')).toBe(`"JetBrainsMono Nerd Font", ${font.DEFAULT_STACK}`);
|
||||
expect(font.resolve("'Iosevka Term'")).toBe(`"Iosevka Term", ${font.DEFAULT_STACK}`);
|
||||
});
|
||||
|
||||
it('accepts a comma-separated list', () => {
|
||||
expect(font.resolve('Iosevka, MesloLGS NF')).toBe(`Iosevka, "MesloLGS NF", ${font.DEFAULT_STACK}`);
|
||||
});
|
||||
|
||||
it('drops generic families so they cannot shadow the symbols fallback', () => {
|
||||
expect(font.resolve('monospace')).toBe(font.DEFAULT_STACK);
|
||||
expect(font.resolve('Hack, monospace')).toBe(`Hack, ${font.DEFAULT_STACK}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
// Port: none (a pure function in a vm context — no browser, no server).
|
||||
//
|
||||
// `terminalLogicalLine` (constants.js) reconstructs the logical line a terminal row
|
||||
// belongs to. Two consumers depend on it and must not disagree: the link provider
|
||||
// matches its patterns over this text, and touch selection measures words and whole
|
||||
// lines with it.
|
||||
//
|
||||
// The bug it exists to fix, reported from a phone: an agent printing a numbered list
|
||||
//
|
||||
// 1. https://github.com/users/someone/packages/container/p
|
||||
// ackage/thing
|
||||
//
|
||||
// opened only `…/container/p`. Ink wraps its own output and emits a real newline, so
|
||||
// nothing is flagged `isWrapped`, and the continuation carries the list's indent —
|
||||
// joining the rows verbatim put whitespace in the middle of the URL, where the
|
||||
// pattern stops. "Line" was broken by the same shape: it walked `isWrapped` only, so
|
||||
// it grabbed the single row on screen.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
|
||||
type Row = { text: string; wrapped?: boolean };
|
||||
type Logical = {
|
||||
startRow: number;
|
||||
endRow: number;
|
||||
text: string;
|
||||
offsetToCell: (offset: number) => { row: number; col: number };
|
||||
cellToOffset: (row: number, col: number) => number;
|
||||
} | null;
|
||||
|
||||
const COLS = 60;
|
||||
|
||||
function load(): (buffer: unknown, row: number, cols: number, maxRows?: number) => Logical {
|
||||
const context = vm.createContext({ console, window: undefined });
|
||||
vm.runInContext(`${SOURCE}\nglobalThis.__fn = terminalLogicalLine;`, context, { filename: 'constants.js' });
|
||||
return (context as { __fn: never })['__fn'] as never;
|
||||
}
|
||||
|
||||
/** An xterm-shaped buffer: untrimmed rows pad to the full width, as xterm's do. */
|
||||
function buffer(rows: Row[]) {
|
||||
return {
|
||||
length: rows.length,
|
||||
getLine: (r: number) =>
|
||||
r >= 0 && r < rows.length
|
||||
? {
|
||||
isWrapped: !!rows[r].wrapped,
|
||||
translateToString: (trim?: boolean) => (trim === false ? rows[r].text.padEnd(COLS) : rows[r].text),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const terminalLogicalLine = load();
|
||||
const URL_RE = /https?:\/\/(?:[^\s"'<>|;&)\]\x00-\x1f]|&(?!&))+/;
|
||||
|
||||
describe('terminalLogicalLine', () => {
|
||||
// Row 0 runs to the last column — the only trace a hard wrap leaves — and row 1
|
||||
// carries the three spaces the list indent put there.
|
||||
const HEAD = '1. https://example.com/';
|
||||
const ROW0 = HEAD + 'a'.repeat(COLS - HEAD.length);
|
||||
const ROW1 = ' ackage/thing';
|
||||
const HARD = [{ text: ROW0 }, { text: ROW1 }];
|
||||
|
||||
it('stitches a hard wrap and drops the indent the continuation carries', () => {
|
||||
const line = terminalLogicalLine(buffer(HARD), 0, COLS)!;
|
||||
|
||||
expect(line.startRow).toBe(0);
|
||||
expect(line.endRow).toBe(1);
|
||||
expect(line.text).toBe(ROW0 + 'ackage/thing');
|
||||
// The whole URL now matches, which is the entire point.
|
||||
expect(line.text.match(URL_RE)![0]).toBe('https://example.com/' + 'a'.repeat(COLS - HEAD.length) + 'ackage/thing');
|
||||
});
|
||||
|
||||
it('finds the same line from the continuation row', () => {
|
||||
// A tap or hover lands on either row; both must resolve the whole thing.
|
||||
const line = terminalLogicalLine(buffer(HARD), 1, COLS)!;
|
||||
|
||||
expect([line.startRow, line.endRow]).toEqual([0, 1]);
|
||||
expect(line.text).toBe(ROW0 + 'ackage/thing');
|
||||
});
|
||||
|
||||
it('maps offsets back to the right cell across the dropped indent', () => {
|
||||
const line = terminalLogicalLine(buffer(HARD), 0, COLS)!;
|
||||
const at = line.text.indexOf('ackage/thing');
|
||||
|
||||
// 'a' of 'ackage' is the 4th cell of row 1 (0-based col 3), after the indent.
|
||||
expect(line.offsetToCell(at)).toEqual({ row: 1, col: 3 });
|
||||
// …and the reverse direction agrees.
|
||||
expect(line.cellToOffset(1, 3)).toBe(at);
|
||||
// Row 0 is unshifted.
|
||||
expect(line.offsetToCell(3)).toEqual({ row: 0, col: 3 });
|
||||
expect(line.cellToOffset(0, 3)).toBe(3);
|
||||
});
|
||||
|
||||
it('keeps a SOFT continuation verbatim, indent and all', () => {
|
||||
// The emulator inserts nothing when it wraps, so leading spaces there are real
|
||||
// content and dropping them would corrupt the text.
|
||||
const soft = [{ text: 'x'.repeat(COLS) }, { text: ' tail', wrapped: true }];
|
||||
const line = terminalLogicalLine(buffer(soft), 1, COLS)!;
|
||||
|
||||
expect(line.text).toBe('x'.repeat(COLS) + ' tail');
|
||||
expect(line.offsetToCell(COLS)).toEqual({ row: 1, col: 0 });
|
||||
});
|
||||
|
||||
it('does not stitch a row that stops short of the last column', () => {
|
||||
// A line that genuinely ended is not a wrap, and over-reaching would glue
|
||||
// unrelated output into one link.
|
||||
const rows = [{ text: 'short line' }, { text: 'next line' }];
|
||||
const line = terminalLogicalLine(buffer(rows), 0, COLS)!;
|
||||
|
||||
expect([line.startRow, line.endRow]).toEqual([0, 0]);
|
||||
expect(line.text).toBe('short line');
|
||||
});
|
||||
|
||||
it('bounds the span so a screenful of full-width output cannot be re-scanned per hover', () => {
|
||||
const rows = Array.from({ length: 40 }, () => ({ text: 'y'.repeat(COLS) }));
|
||||
const line = terminalLogicalLine(buffer(rows), 30, COLS, 4)!;
|
||||
|
||||
expect(line.endRow - line.startRow).toBeLessThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('trims only the final row, so every earlier offset stays aligned to a cell', () => {
|
||||
const rows = [{ text: 'z'.repeat(COLS) }, { text: 'tail' }];
|
||||
const line = terminalLogicalLine(buffer(rows), 0, COLS)!;
|
||||
|
||||
// Row 0 contributes exactly COLS characters; the last row is trimmed.
|
||||
expect(line.text).toBe('z'.repeat(COLS) + 'tail');
|
||||
expect(line.offsetToCell(COLS)).toEqual({ row: 1, col: 0 });
|
||||
});
|
||||
|
||||
it('answers null for a row that does not exist', () => {
|
||||
expect(terminalLogicalLine(buffer([{ text: 'a' }]), 5, COLS)).toBeNull();
|
||||
});
|
||||
});
|
||||
+509
-18
@@ -8,8 +8,11 @@ function loadTerminalUiHarness() {
|
||||
let now = 1_000;
|
||||
let keyboardVisible = false;
|
||||
let activeElement: unknown = null;
|
||||
// The module hangs its constants off window (CodemanTerminalInput) and the URL branch of
|
||||
// the link provider opens through window.open, so tests need a handle on the same object.
|
||||
const windowRef: Record<string, any> = {};
|
||||
const context = vm.createContext({
|
||||
window: {},
|
||||
window: windowRef,
|
||||
document: {
|
||||
body: { classList: { contains: () => false } },
|
||||
get activeElement() {
|
||||
@@ -18,7 +21,7 @@ function loadTerminalUiHarness() {
|
||||
getElementById: () => null,
|
||||
},
|
||||
CodemanApp,
|
||||
console: { warn: vi.fn(), log: vi.fn() },
|
||||
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
|
||||
_crashDiag: { log: vi.fn() },
|
||||
performance: { now: () => now },
|
||||
requestAnimationFrame: (_fn: () => void) => 1,
|
||||
@@ -43,12 +46,18 @@ function loadTerminalUiHarness() {
|
||||
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||
});
|
||||
|
||||
// constants.js first: the link provider calls absoluteFilePathPattern() and
|
||||
// previewsInFileViewer() at scan time, and the SHIPPED definitions are what keep a tap and
|
||||
// a hover resolving the same links.
|
||||
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||||
vm.runInContext(constants, context, { filename: 'constants.js' });
|
||||
vm.runInContext(code, context, { filename: 'terminal-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
return {
|
||||
app,
|
||||
windowRef,
|
||||
setNow: (value: number) => {
|
||||
now = value;
|
||||
},
|
||||
@@ -88,13 +97,23 @@ function createTerminalGrid(lines: string[], cursorY: number, wrappedRows = new
|
||||
active: {
|
||||
viewportY: 0,
|
||||
baseY: 0,
|
||||
length: lines.length,
|
||||
cursorY,
|
||||
getLine: (row: number) =>
|
||||
row >= 0 && row < lines.length
|
||||
? { isWrapped: wrappedRows.has(row), translateToString: () => lines[row] }
|
||||
? {
|
||||
isWrapped: wrappedRows.has(row),
|
||||
// xterm pads an UNTRIMMED row to the full width; the selection offset
|
||||
// math is linear over joined rows and would shift without it.
|
||||
translateToString: (trim?: boolean) => (trim === false ? lines[row].padEnd(80) : lines[row]),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
select: vi.fn(),
|
||||
clearSelection: vi.fn(),
|
||||
hasSelection: () => false,
|
||||
getSelectionPosition: () => undefined,
|
||||
element: {
|
||||
querySelector: (selector: string) =>
|
||||
selector === '.xterm-screen' ? { getBoundingClientRect: () => ({ left: 0, top: 0 }) } : null,
|
||||
@@ -121,7 +140,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
it('routes a readback row to the TUI while keeping the prompt row as keyboard input', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex', cliMouseTracking: true }]]);
|
||||
app.terminal = createTerminalGrid(
|
||||
['Agent readback mentions › inline', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'],
|
||||
4
|
||||
@@ -136,7 +155,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
it('classifies Claude background-agent status as content rather than keyboard input', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.220' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.220', cliMouseTracking: true }]]);
|
||||
app.terminal = createTerminalGrid(
|
||||
['', '', '', '• Working (1m 50s • esc to ', 'interrupt) · 1 background teammate', ''],
|
||||
4,
|
||||
@@ -149,7 +168,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
it('keeps the live cursor focusable when Claude temporarily omits its prompt glyph', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app.terminal = createTerminalGrid(['Prior response', '', 'ready for input', '', 'status footer', ''], 2);
|
||||
|
||||
expect(app._classifyMobileTerminalTap(9, 33)).toBe('input');
|
||||
@@ -159,7 +178,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
it('treats a highlighted numbered choice as TUI content, not an input prompt', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app.terminal = createTerminalGrid(['Would you like to proceed?', '', '❯ 1. Yes', ' 2. No', '', ''], 2);
|
||||
|
||||
expect(app._classifyMobileTerminalTap(9, 33)).toBe('content');
|
||||
@@ -174,7 +193,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
// commits an answer) as the only thing a phone could do.
|
||||
const { app, setActiveElement } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app.terminal = createTerminalGrid(
|
||||
['Do you want to proceed?', '', '❯ 1. Yes', ' 2. No, tell Claude what to do', '', ''],
|
||||
2
|
||||
@@ -200,7 +219,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
it('collapses TUI readback content without opening or retaining the keyboard', () => {
|
||||
const { app, setActiveElement } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex', cliMouseTracking: true }]]);
|
||||
app.terminal = createTerminalGrid(
|
||||
['Agent readback', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'],
|
||||
4
|
||||
@@ -233,7 +252,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
it('closes the keyboard on a second tap of INERT transcript content', () => {
|
||||
const { app, setActiveElement } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app.terminal = createTerminalGrid(['transcript line', '', '', '', '❯ ', ''], 4);
|
||||
app._sendInputAsync = vi.fn();
|
||||
|
||||
@@ -309,7 +328,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: Array<{ id: string; data: string }> = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app._sendInputAsync = (id: string, data: string) => sent.push({ id, data });
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
@@ -320,7 +339,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||
};
|
||||
|
||||
expect(app._sessionUsesServerMouseStrip()).toBe(true);
|
||||
expect(app._shouldReportMouseToCli()).toBe(true);
|
||||
// touch at x=10+8*20+1, y=20+16*5+1 → col 21, row 6 (1-based)
|
||||
app._sendSyntheticSgrTap(171, 101);
|
||||
|
||||
@@ -347,19 +366,60 @@ describe('terminal touch tap mouse guard', () => {
|
||||
expect(sent).toEqual(['\x1b[<0;1;24M\x1b[<0;1;24m']);
|
||||
});
|
||||
|
||||
it('does not treat shell sessions as server-mouse-strip mode', () => {
|
||||
it('never hand-reports for a shell session, even with tracking somehow set', () => {
|
||||
// Shell DECSETs are NOT stripped (narrow strip), so xterm's own encoder owns
|
||||
// the mouse there and a second, hand-encoded report would double-report.
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'shell' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'shell', cliMouseTracking: true }]]);
|
||||
|
||||
expect(app._sessionUsesServerMouseStrip()).toBe(false);
|
||||
expect(app._shouldReportMouseToCli()).toBe(false);
|
||||
});
|
||||
|
||||
it('hand-reports only while the CLI actually has mouse tracking on', () => {
|
||||
// The server strips the DECSETs, so xterm can never see them and the browser
|
||||
// reported EVERY click. A claude pane sitting at its composer, or one that
|
||||
// fell back to a shell prompt, was receiving mouse reports it never asked
|
||||
// for; a shell prints those as literal text and they garble the next line.
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
expect(app._shouldReportMouseToCli()).toBe(false);
|
||||
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: false }]]);
|
||||
expect(app._shouldReportMouseToCli()).toBe(false);
|
||||
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
expect(app._shouldReportMouseToCli()).toBe(true);
|
||||
});
|
||||
|
||||
it('desktop click: sends nothing while the CLI has no mouse mode on', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: string[] = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||
app.terminal = createTerminalGrid(['some output', '', '\u276f '], 2);
|
||||
app._linkHovered = false;
|
||||
|
||||
app._handleDesktopTerminalClick({
|
||||
isTrusted: true,
|
||||
button: 0,
|
||||
detail: 1,
|
||||
clientX: 40,
|
||||
clientY: 10,
|
||||
target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) },
|
||||
});
|
||||
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it('desktop click: encodes SGR press+release for a plain left-click in strip mode', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: Array<{ id: string; data: string }> = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app._sendInputAsync = (id: string, data: string) => sent.push({ id, data });
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
@@ -434,7 +494,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: string[] = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
@@ -468,7 +528,7 @@ describe('terminal touch tap mouse guard', () => {
|
||||
const { app, setNow } = loadTerminalUiHarness();
|
||||
const sent: string[] = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
@@ -732,3 +792,434 @@ describe('terminal touch tap mouse guard', () => {
|
||||
expect(event.stopImmediatePropagation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal link tap', () => {
|
||||
// xterm resolves a link from mousemove and activates it on mouseup over its SCREEN element.
|
||||
// A touch tap produces none of those (touch-action:none and touchstart's preventDefault
|
||||
// suppress the compatibility mouse events, the post-tap guard drops the rest, and the
|
||||
// synthetic pair this app dispatches for mouse REPORTING lands on the .xterm root, an
|
||||
// ancestor of the node the linkifier listens on). So the tap path activates the link
|
||||
// itself, through the same provider, or every URL and path in the terminal stays inert on
|
||||
// a phone.
|
||||
//
|
||||
// Grid geometry from createTerminalGrid: 8×16 cells, screen rect at (0,0), viewportY 0 —
|
||||
// so 0-based character index i on 0-based row r sits at (i * 8 + 4, r * 16 + 8).
|
||||
const at = (index: number, row = 0) => ({ clientX: index * 8 + 4, clientY: row * 16 + 8 });
|
||||
|
||||
/** A claude-mode app with the shipped link provider registered over `lines`. */
|
||||
function linkHarness(lines: string[], cursorY = lines.length - 1) {
|
||||
const harness = loadTerminalUiHarness();
|
||||
const { app, windowRef } = harness;
|
||||
const sent: string[] = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||
app.terminal = createTerminalGrid(lines, cursorY);
|
||||
app.terminal.registerLinkProvider = vi.fn();
|
||||
app.openFilePreview = vi.fn();
|
||||
app.openLogViewerWindow = vi.fn();
|
||||
app._isExternalPreviewPath = () => false;
|
||||
windowRef.open = vi.fn();
|
||||
app.registerFilePathLinkProvider();
|
||||
return { app, windowRef, sent };
|
||||
}
|
||||
|
||||
it('opens a URL under the finger in a new tab', () => {
|
||||
const line = 'Login at https://claude.ai/oauth/authorize?code=true&client_id=abc to finish';
|
||||
const { app, windowRef } = linkHarness([line, '', '❯ ']);
|
||||
|
||||
expect(app._handleMobileTerminalTap(at(line.indexOf('https')), false, 'content')).toBe('link');
|
||||
expect(windowRef.open).toHaveBeenCalledWith(
|
||||
'https://claude.ai/oauth/authorize?code=true&client_id=abc',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
});
|
||||
|
||||
it('sends no mouse report for the tap it just spent on a link', () => {
|
||||
// The CLI must not also see a click there: that is how a tap on a URL printed inside a
|
||||
// permission dialog would answer the dialog. Desktop already skips the SGR tap for a
|
||||
// hovered link (_handleDesktopTerminalClick).
|
||||
const line = 'see https://example.com/x for more';
|
||||
const { app, sent } = linkHarness([line, '', '❯ ']);
|
||||
|
||||
expect(app._shouldReportMouseToCli()).toBe(true);
|
||||
app._handleMobileTerminalTap(at(line.indexOf('https')), false, 'content');
|
||||
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves a tap beside the link as an ordinary tap', () => {
|
||||
// Containment is xterm's own rule (flattened cell index), so tap and click agree on
|
||||
// where a link ends; a tap on the prose around it keeps its mouse report.
|
||||
const line = 'see https://example.com/x for more';
|
||||
const { app, windowRef, sent } = linkHarness([line, '', '❯ ']);
|
||||
|
||||
expect(app._handleMobileTerminalTap(at(line.indexOf('for more') + 3), false, 'content')).toBe('content');
|
||||
expect(windowRef.open).not.toHaveBeenCalled();
|
||||
expect(sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('opens a tapped file path in the preview overlay', () => {
|
||||
const line = 'wrote the chart to /tmp/out/chart.png just now';
|
||||
const { app } = linkHarness([line, '', '❯ ']);
|
||||
|
||||
expect(app._handleMobileTerminalTap(at(line.indexOf('/tmp')), false, 'content')).toBe('link');
|
||||
expect(app.openFilePreview).toHaveBeenCalledWith('/tmp/out/chart.png', 'sess-1');
|
||||
expect(app.openLogViewerWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends a tapped log path to the log viewer', () => {
|
||||
const line = 'tail -f /var/log/app.log';
|
||||
const { app } = linkHarness([line, '', '❯ ']);
|
||||
|
||||
expect(app._handleMobileTerminalTap(at(line.indexOf('/var')), false, 'content')).toBe('link');
|
||||
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/var/log/app.log', 'sess-1');
|
||||
});
|
||||
|
||||
it('activates a link in scrollback, where the tap sends no report at all', () => {
|
||||
// A scrolled-up tap deliberately reports nothing (it would land on whatever row now
|
||||
// occupies the cell), but reading old output and tapping a URL in it is the common case.
|
||||
const line = 'docs at https://example.com/guide';
|
||||
const { app, windowRef, sent } = linkHarness([line, '', '']);
|
||||
|
||||
expect(app._handleMobileTerminalTap(at(line.indexOf('https')), false, 'history')).toBe('link');
|
||||
expect(windowRef.open).toHaveBeenCalledOnce();
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it('never hijacks a TUI-owned choice row that happens to carry a path', () => {
|
||||
// On a phone the dialog is the only interaction that matters, and its rows routinely
|
||||
// name the very file a link would open — answering it must keep winning.
|
||||
// ⚠️ The caret is parked on the QUESTION row, not the choice: with the caret on the
|
||||
// tapped row this would pass through _tapIsOnCaretLine and pin nothing.
|
||||
const line = '❯ 1. Yes, edit /home/user/src/app.ts';
|
||||
const { app, windowRef, sent } = linkHarness(['Do you want to make this edit?', line, ' 2. No, keep it as is'], 0);
|
||||
|
||||
expect(app._handleMobileTerminalTap(at(line.indexOf('/home'), 1), false, 'content')).toBe('content');
|
||||
expect(windowRef.open).not.toHaveBeenCalled();
|
||||
expect(app.openFilePreview).not.toHaveBeenCalled();
|
||||
expect(sent).toHaveLength(1); // the choice still reaches the CLI
|
||||
});
|
||||
|
||||
it('leaves a URL the user typed in the composer editable', () => {
|
||||
// Tapping your own prompt text means "put the caret here". Opening it instead would
|
||||
// punish the phone gesture for fixing a typo in a pasted link.
|
||||
const composer = '❯ summarize https://example.com/guide for me';
|
||||
const { app, windowRef, sent } = linkHarness(['earlier output', '', composer], 2);
|
||||
|
||||
expect(app._handleMobileTerminalTap(at(composer.indexOf('https'), 2), true, 'input')).toBe('input');
|
||||
expect(windowRef.open).not.toHaveBeenCalled();
|
||||
expect(sent).toHaveLength(1); // the tap still positions the caret via the mouse report
|
||||
});
|
||||
|
||||
it('activates a link in a plain shell session, where every tap classifies as input', () => {
|
||||
// A shell has no TUI to own taps, so _classifyMobileTerminalTap short-circuits to
|
||||
// 'input' for the whole screen — gating link taps on the intent would leave every URL
|
||||
// in shell output (curl, npm, git remote) inert. The caret line is the real boundary.
|
||||
const line = 'remote: https://github.com/Ark0N/Codeman.git';
|
||||
const harness = loadTerminalUiHarness();
|
||||
const { app, windowRef } = harness;
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'shell' }]]);
|
||||
app._sendInputAsync = vi.fn();
|
||||
app.terminal = createTerminalGrid([line, '', 'bash-5.3$ '], 2);
|
||||
app.terminal.registerLinkProvider = vi.fn();
|
||||
windowRef.open = vi.fn();
|
||||
app.registerFilePathLinkProvider();
|
||||
|
||||
// No cachedIntent below: real classification runs, and for a shell it answers 'input'.
|
||||
const point = at(line.indexOf('https'));
|
||||
expect(app._classifyMobileTerminalTap(point.clientX, point.clientY)).toBe('input');
|
||||
expect(app._handleMobileTerminalTap(point, false)).toBe('link');
|
||||
expect(windowRef.open).toHaveBeenCalledWith(
|
||||
'https://github.com/Ark0N/Codeman.git',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps taps working when no provider was ever registered', () => {
|
||||
const harness = loadTerminalUiHarness();
|
||||
const { app } = harness;
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.terminal = createTerminalGrid(['plain output', '', '❯ '], 2);
|
||||
|
||||
expect(app._terminalLinkAtPoint(4, 8)).toBeNull();
|
||||
expect(app._activateTerminalLinkAtPoint(4, 8)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal touch selection', () => {
|
||||
// Copying from a phone was impossible in three layers at once: `user-select: none`
|
||||
// on the whole terminal subtree, a WebGL renderer that draws glyphs as pixels with
|
||||
// only the accessibility tree behind them, and xterm's selection being a mouse DRAG
|
||||
// while the tap path dispatches a zero-movement mousedown/mouseup pair. The gesture
|
||||
// therefore drives xterm's own `select()`, which is renderer-independent.
|
||||
//
|
||||
// Grid geometry (createTerminalGrid): 80 cols, 8×16 cells, screen rect at (0,0),
|
||||
// viewportY 0 — 0-based index i on 0-based row r sits at (i * 8 + 4, r * 16 + 8).
|
||||
const at = (index: number, row = 0) => ({ clientX: index * 8 + 4, clientY: row * 16 + 8 });
|
||||
const press = (app: any, index: number, row = 0) =>
|
||||
app._beginTouchSelection(at(index, row).clientX, at(index, row).clientY);
|
||||
const dragTo = (app: any, index: number, row = 0) =>
|
||||
app._extendTouchSelection(at(index, row).clientX, at(index, row).clientY);
|
||||
const COLS = 80;
|
||||
const LINE = 'wrote the chart to /tmp/out/chart.png just now';
|
||||
const PATH_AT = LINE.indexOf('/tmp');
|
||||
|
||||
function selectionHarness(lines = [LINE, '', '❯ '], cursorY = 2, wrapped = new Set<number>()) {
|
||||
const { app, setNow } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app._sendInputAsync = vi.fn();
|
||||
app.terminal = createTerminalGrid(lines, cursorY, wrapped);
|
||||
return { app, setNow, select: app.terminal.select as ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
it('selects the whitespace-delimited token under a long press', () => {
|
||||
// Whitespace is the only delimiter on purpose: a punctuation-aware word rule
|
||||
// cuts a path, a URL or a hash in half, which is exactly what you came to copy.
|
||||
const { app, select } = selectionHarness();
|
||||
|
||||
expect(press(app, PATH_AT + 4)).toBe(true);
|
||||
expect(select).toHaveBeenCalledWith(PATH_AT, 0, '/tmp/out/chart.png'.length);
|
||||
});
|
||||
|
||||
it('selects nothing when the press lands on blank space', () => {
|
||||
const { app, select } = selectionHarness();
|
||||
|
||||
expect(press(app, LINE.length + 10)).toBe(false);
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('grows the selection as the finger drags past the anchor word', () => {
|
||||
const { app, select } = selectionHarness();
|
||||
press(app, PATH_AT + 4);
|
||||
select.mockClear();
|
||||
|
||||
dragTo(app, LINE.length - 1);
|
||||
|
||||
// From the word's start through the cell under the finger, inclusive.
|
||||
expect(select).toHaveBeenCalledWith(PATH_AT, 0, LINE.length - PATH_AT);
|
||||
});
|
||||
|
||||
it('keeps the anchor word inside the selection when the drag goes backwards', () => {
|
||||
const { app, select } = selectionHarness();
|
||||
press(app, PATH_AT + 4);
|
||||
select.mockClear();
|
||||
|
||||
dragTo(app, 6);
|
||||
|
||||
const wordEnd = PATH_AT + '/tmp/out/chart.png'.length;
|
||||
expect(select).toHaveBeenCalledWith(6, 0, wordEnd - 6);
|
||||
});
|
||||
|
||||
it('extends across rows, where a linear length is what xterm wants', () => {
|
||||
const { app, select } = selectionHarness(['first row text', 'second row text', '❯ '], 2);
|
||||
press(app, 0);
|
||||
select.mockClear();
|
||||
|
||||
dragTo(app, 5, 1);
|
||||
|
||||
// Row 1 cell 5 is absolute cell 85; the selection runs from 0 through it.
|
||||
expect(select).toHaveBeenCalledWith(0, 0, COLS + 6);
|
||||
});
|
||||
|
||||
it('Line takes the whole logical line, wraps included, without the padding', () => {
|
||||
const wrappedTail = 'tail';
|
||||
const { app, select } = selectionHarness(['x'.repeat(COLS), wrappedTail, '❯ '], 2, new Set([1]));
|
||||
press(app, 2, 1);
|
||||
select.mockClear();
|
||||
|
||||
app._selectTouchSelectionLine();
|
||||
|
||||
expect(select).toHaveBeenCalledWith(0, 0, COLS + wrappedTail.length);
|
||||
});
|
||||
|
||||
it('a tap while a selection is up extends it instead of moving the cursor', () => {
|
||||
const { app, select } = selectionHarness();
|
||||
press(app, PATH_AT + 4);
|
||||
select.mockClear();
|
||||
|
||||
expect(app._handleMobileTerminalTap(at(LINE.length - 1), false, 'content')).toBe('select');
|
||||
expect(select).toHaveBeenCalledWith(PATH_AT, 0, LINE.length - PATH_AT);
|
||||
// and the CLI never sees a click it would act on
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('copies through the shared clipboard path and drops the selection', () => {
|
||||
// copyTerminalSelection is the one that falls back to execCommand, which is the
|
||||
// only route that works on the plain-HTTP LAN install the installer offers.
|
||||
const { app } = selectionHarness();
|
||||
app.copyTerminalSelection = vi.fn().mockResolvedValue(true);
|
||||
press(app, PATH_AT + 4);
|
||||
|
||||
return app._copyTouchSelection().then(() => {
|
||||
expect(app.copyTerminalSelection).toHaveBeenCalledOnce();
|
||||
expect(app._touchSelectionActive).toBe(false);
|
||||
expect(app._touchSelectionAnchor).toBeNull();
|
||||
expect(app.terminal.clearSelection).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('lifting the finger cannot let a compat mousedown steal focus and drop the selection', () => {
|
||||
// The bug this pins: on lift the browser synthesizes a trusted mousedown, xterm
|
||||
// focuses on it (keyboard up) and SelectionService resets the model (bar gone).
|
||||
// Ending the gesture arms the same guard the tap path uses.
|
||||
const { app } = selectionHarness();
|
||||
const { element, dispatch } = createElementHarness();
|
||||
app.terminal.element = { ...app.terminal.element, addEventListener: element.addEventListener };
|
||||
app._installMobileTapMouseGuard();
|
||||
|
||||
press(app, PATH_AT + 4);
|
||||
app._endTouchSelectionGesture();
|
||||
|
||||
const ev = { isTrusted: true, preventDefault: vi.fn(), stopImmediatePropagation: vi.fn() };
|
||||
dispatch('mousedown', ev);
|
||||
|
||||
expect(ev.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(ev.stopImmediatePropagation).toHaveBeenCalledOnce();
|
||||
expect(app._touchSelecting).toBe(false);
|
||||
expect(app._touchSelectionActive).toBe(true); // the selection outlives the gesture
|
||||
});
|
||||
|
||||
it('copying does not pop the on-screen keyboard back over the text', () => {
|
||||
// copyTerminalSelection hands focus to the terminal, which on a phone means the
|
||||
// keyboard covers what you just copied with nothing waiting to be typed.
|
||||
const { app } = selectionHarness();
|
||||
app.copyTerminalSelection = vi.fn().mockResolvedValue(true);
|
||||
press(app, PATH_AT + 4);
|
||||
app._blurMobileTerminalInput = vi.fn(); // stubbed AFTER the press, which blurs too
|
||||
|
||||
return app._copyTouchSelection().then(() => {
|
||||
expect(app._blurMobileTerminalInput).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
it('blurs the terminal input if anything focuses it during the gesture', () => {
|
||||
// The one that matters on Android: Chrome runs its own long-press handling at
|
||||
// ~500ms and focuses the helper textarea directly — no mouse event to guard, so
|
||||
// the keyboard shot up over the selection the instant it appeared.
|
||||
const { app, setNow } = selectionHarness();
|
||||
const listeners = new Map<string, () => void>();
|
||||
app.terminal.textarea = {
|
||||
addEventListener: (type: string, fn: () => void) => listeners.set(type, fn),
|
||||
classList: { contains: (n: string) => n === 'xterm-helper-textarea' },
|
||||
blur: vi.fn(),
|
||||
};
|
||||
app._installTouchSelectionFocusGuard();
|
||||
press(app, PATH_AT + 4);
|
||||
app._endTouchSelectionGesture();
|
||||
|
||||
// Whatever focused it, the guard takes the focus straight back off.
|
||||
app._blurMobileTerminalInput = vi.fn();
|
||||
listeners.get('focus')?.();
|
||||
expect(app._blurMobileTerminalInput).toHaveBeenCalledOnce();
|
||||
|
||||
// …and the guard expires on its own, so a stuck flag can never make the
|
||||
// keyboard permanently unreachable.
|
||||
setNow(1_000 + 5_000);
|
||||
app._blurMobileTerminalInput = vi.fn();
|
||||
listeners.get('focus')?.();
|
||||
expect(app._blurMobileTerminalInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('survives a missing bar container instead of throwing mid-gesture', () => {
|
||||
// index.html is read once at server start, so the bar is built in JS — and a
|
||||
// solo popup or an early gesture can run before the container exists.
|
||||
const { app } = selectionHarness();
|
||||
|
||||
expect(() => app._showTouchSelectionBar()).not.toThrow();
|
||||
expect(app._ensureTouchSelectionBar()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal wrapped-line handling', () => {
|
||||
// Reported from a phone against the shipped fix: an agent's numbered list wraps its
|
||||
// URL, and tapping it opened only the part on screen. Ink emits a real newline (so
|
||||
// nothing is flagged isWrapped) and indents the continuation under the list marker,
|
||||
// so joining the rows verbatim put whitespace inside the URL. "Line" broke the same
|
||||
// way, grabbing the one visible row.
|
||||
//
|
||||
// Grid: 80 cols, 8×16 cells, screen rect at (0,0), viewportY 0.
|
||||
const COLS = 80;
|
||||
const at = (index: number, row = 0) => ({ clientX: index * 8 + 4, clientY: row * 16 + 8 });
|
||||
const press = (app: any, index: number, row = 0) =>
|
||||
app._beginTouchSelection(at(index, row).clientX, at(index, row).clientY);
|
||||
const HEAD = '1. https://example.com/';
|
||||
// Row 0 runs to the last column, which is the only trace a hard wrap leaves.
|
||||
const ROW0 = HEAD + 'a'.repeat(COLS - HEAD.length);
|
||||
const ROW1 = ' ackage/thing';
|
||||
const FULL_URL = 'https://example.com/' + 'a'.repeat(COLS - HEAD.length) + 'ackage/thing';
|
||||
|
||||
function wrappedHarness() {
|
||||
const { app, windowRef } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app._sendInputAsync = vi.fn();
|
||||
app.terminal = createTerminalGrid([ROW0, ROW1, '❯ '], 2);
|
||||
app.terminal.registerLinkProvider = vi.fn();
|
||||
app.openFilePreview = vi.fn();
|
||||
app.openLogViewerWindow = vi.fn();
|
||||
app._isExternalPreviewPath = () => false;
|
||||
windowRef.open = vi.fn();
|
||||
app.registerFilePathLinkProvider();
|
||||
return { app, windowRef, select: app.terminal.select as ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
it('opens the WHOLE wrapped URL, not the part on screen', () => {
|
||||
const { app, windowRef } = wrappedHarness();
|
||||
|
||||
expect(app._handleMobileTerminalTap(at(HEAD.length + 5), false, 'content')).toBe('link');
|
||||
expect(windowRef.open).toHaveBeenCalledWith(FULL_URL, '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
|
||||
it('opens the whole URL from the continuation row too', () => {
|
||||
// Tapping the second half is the natural gesture when that is what you can see.
|
||||
const { app, windowRef } = wrappedHarness();
|
||||
|
||||
expect(app._handleMobileTerminalTap(at(5, 1), false, 'content')).toBe('link');
|
||||
expect(windowRef.open).toHaveBeenCalledWith(FULL_URL, '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
|
||||
it('selects a token that spans the wrap, across both rows', () => {
|
||||
const { app, select } = wrappedHarness();
|
||||
|
||||
press(app, 5, 1); // inside 'ackage/thing' on the continuation row
|
||||
// From the URL's first cell (row 0, col 3) through the token's last cell
|
||||
// (row 1, col 14). The run covers the indent cells between the halves, because
|
||||
// an xterm selection is one contiguous run and a gap cannot be expressed.
|
||||
const index = 3;
|
||||
const end = COLS + ROW1.length - 1;
|
||||
expect(select).toHaveBeenCalledWith(3, 0, end - index + 1);
|
||||
});
|
||||
|
||||
it('Line takes every row of a HARD-wrapped line, not just the visible one', () => {
|
||||
const { app, select } = wrappedHarness();
|
||||
press(app, HEAD.length + 5);
|
||||
select.mockClear();
|
||||
|
||||
app._selectTouchSelectionLine();
|
||||
|
||||
// Row 0 col 0 through row 1's last non-blank cell.
|
||||
expect(select).toHaveBeenCalledWith(0, 0, COLS + ROW1.length);
|
||||
});
|
||||
|
||||
it('does not reach into the next line when a row stops short of the edge', () => {
|
||||
// Over-reaching would glue unrelated output into one link or one "Line".
|
||||
const { app, select } = loadTerminalUiHarness();
|
||||
void select;
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.terminal = createTerminalGrid(['short output', 'https://example.com/next', '❯ '], 2);
|
||||
app.terminal.registerLinkProvider = vi.fn();
|
||||
app.registerFilePathLinkProvider();
|
||||
|
||||
press(app, 2); // inside 'short'
|
||||
app._selectTouchSelectionLine();
|
||||
|
||||
expect(app.terminal.select).toHaveBeenCalledWith(0, 0, 'short output'.length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -675,6 +675,7 @@ describe('TmuxManager (unit)', () => {
|
||||
sessionId: 'abc12345-1234-5678-90ab-cdef12345678',
|
||||
workingDir: '/mnt/gdrive/project with spaces',
|
||||
mode: 'shell',
|
||||
historyLimit: 250_000,
|
||||
});
|
||||
|
||||
expect(session.workingDir).toBe('/mnt/gdrive/project with spaces');
|
||||
@@ -683,7 +684,9 @@ describe('TmuxManager (unit)', () => {
|
||||
const newSessionCall = mockedExecSync.mock.calls.find(
|
||||
([cmd]) => typeof cmd === 'string' && cmd.includes(' new-session ')
|
||||
);
|
||||
expect(newSessionCall?.[0]).toBe(`tmux -L 'codeman' new-session -ds "codeman-abc12345" -c /tmp`);
|
||||
expect(newSessionCall?.[0]).toBe(
|
||||
`tmux -L 'codeman' set-option -g history-limit 250000 \\; new-session -ds "codeman-abc12345" -c /tmp \\; set-option -t "codeman-abc12345" history-limit 250000`
|
||||
);
|
||||
expect(newSessionCall?.[1]).toEqual(expect.objectContaining({ cwd: '/tmp' }));
|
||||
|
||||
const respawnCall = mockedExecSync.mock.calls.find(
|
||||
@@ -696,6 +699,59 @@ describe('TmuxManager (unit)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('changes the global history default on tmux versions that cannot resize panes', async () => {
|
||||
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
|
||||
const nonTestManager = new NonTestTmuxManager();
|
||||
|
||||
try {
|
||||
await nonTestManager.setHistoryLimit(200_000);
|
||||
const historyCall = mockedExec.mock.calls.find(
|
||||
([cmd]) => typeof cmd === 'string' && cmd.includes(' history-limit ')
|
||||
);
|
||||
expect(historyCall?.[0]).toBe(`tmux -L 'codeman' set-option -g history-limit 200000`);
|
||||
expect(historyCall?.[0]).not.toContain(' -t ');
|
||||
} finally {
|
||||
nonTestManager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('targets only the new and tracked sessions on tmux 3.7+', async () => {
|
||||
mockedExecSync.mockImplementation((cmd: string) => {
|
||||
if (typeof cmd === 'string' && cmd.endsWith(' -V')) return 'tmux 3.7b\n';
|
||||
if (typeof cmd === 'string' && cmd.includes('which tmux')) return '/usr/bin/tmux\n';
|
||||
if (typeof cmd === 'string' && cmd.includes('display-message') && cmd.includes('#{pane_pid}')) return '4242\n';
|
||||
return '';
|
||||
});
|
||||
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
|
||||
const nonTestManager = new NonTestTmuxManager();
|
||||
|
||||
try {
|
||||
await nonTestManager.createSession({
|
||||
sessionId: 'def67890-1234-5678-90ab-cdef12345678',
|
||||
workingDir: '/project',
|
||||
mode: 'shell',
|
||||
historyLimit: 250_000,
|
||||
});
|
||||
const newSessionCall = mockedExecSync.mock.calls.find(
|
||||
([cmd]) => typeof cmd === 'string' && cmd.includes(' new-session ')
|
||||
);
|
||||
expect(newSessionCall?.[0]).toBe(
|
||||
`tmux -L 'codeman' new-session -ds "codeman-def67890" -c /tmp \\; set-option -t "codeman-def67890" history-limit 250000`
|
||||
);
|
||||
expect(newSessionCall?.[0]).not.toContain('set-option -g');
|
||||
|
||||
mockedExec.mockClear();
|
||||
await nonTestManager.setHistoryLimit(200_000);
|
||||
const historyCall = mockedExec.mock.calls.find(
|
||||
([cmd]) => typeof cmd === 'string' && cmd.includes(' history-limit ')
|
||||
);
|
||||
expect(historyCall?.[0]).toBe(`tmux -L 'codeman' set-option -t 'codeman-def67890' history-limit 200000`);
|
||||
expect(historyCall?.[0]).not.toContain('set-option -g');
|
||||
} finally {
|
||||
nonTestManager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('respawns existing panes from /tmp and cd-bounces into the requested workspace', async () => {
|
||||
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
|
||||
const nonTestManager = new NonTestTmuxManager();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user