From 7d18075e2c489c1534ac92b929e05c4cb7c539e0 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 18:02:49 +0200 Subject: [PATCH 01/57] docs: TUI rework plan (codeman tui, herdr research) Co-Authored-By: Claude Fable 5 --- docs/tui-plan.md | 214 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/tui-plan.md diff --git a/docs/tui-plan.md b/docs/tui-plan.md new file mode 100644 index 00000000..d42a5aa9 --- /dev/null +++ b/docs/tui-plan.md @@ -0,0 +1,214 @@ +# Codeman TUI Rework Plan + +Status: PROPOSED (research done, nothing implemented). Owner review needed on the open questions at the bottom. + +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 `; `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 attach-session -t ` 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`/`` 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`/`` 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 ` 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. From ddcfac5dbc1fbdc2793d5c3d805b83a16f2bd8cb Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 18:23:30 +0200 Subject: [PATCH 02/57] feat: shared CLI style kit One vocabulary for everything the codeman CLI prints: semantic palette, the glyph set the commands already used, heading/rule/kv, width-aware table layout, a stderr spinner and a y/N confirm. Color detection stays chalk's, so NO_COLOR and non-TTY degradation keep working with no second detector to disagree with it. The layout math and glyph selection are pure and exported, which is what lets the dependency report reuse them while staying color-free. Co-Authored-By: Claude Fable 5 --- src/cli-style.ts | 304 +++++++++++++++++++++++++++++++++++++++++ test/cli-style.test.ts | 221 ++++++++++++++++++++++++++++++ 2 files changed, 525 insertions(+) create mode 100644 src/cli-style.ts create mode 100644 test/cli-style.test.ts diff --git a/src/cli-style.ts b/src/cli-style.ts new file mode 100644 index 00000000..b128ed7f --- /dev/null +++ b/src/cli-style.ts @@ -0,0 +1,304 @@ +/** + * @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; + +/** 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 = { + ok: GLYPH.ok, + warn: GLYPH.warn, + err: GLYPH.fail, + idle: GLYPH.idle, + info: GLYPH.dot, +}; + +const TONE_STYLE: Record = { + 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); +} + +/** Colored glyph for a tone, the `✓ ` / `✗ ` prefix most command output opens with. */ +export function mark(tone: Tone): string { + return tint(tone, glyphFor(tone)); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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(text: string, work: () => Promise, options?: SpinnerOptions): Promise { + 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 { + if (!isInteractive()) return false; + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await new Promise((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(); + } +} diff --git a/test/cli-style.test.ts b/test/cli-style.test.ts new file mode 100644 index 00000000..0bef7851 --- /dev/null +++ b/test/cli-style.test.ts @@ -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(' '); + expect(cell.trimEnd()).toBe(''); + }); +}); + +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; + } + }); +}); From e48dd35f6d124d1ec8c3a36ec31bdee1dd744ae1 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 18:23:36 +0200 Subject: [PATCH 03/57] fix: measure the doctor table columns and let the CLI paint them "Antigravity CLI" is 15 characters and the hardcoded padEnd(14) pushed that whole row one column right. Widths now come from the widest cell. The header always said the CLI layer may colorize, but there was no way to: renderTable now takes an optional ReportStyle whose hooks are identity by default, so the module still decides nothing about color and its output stays byte-stable. Padding is applied outside the paint, so a row with no path detail ends at its status text instead of trailing spaces inside a color run. Co-Authored-By: Claude Fable 5 --- src/utils/dependency-report.ts | 63 ++++++++++++++++++++++++++++------ test/dependency-report.test.ts | 42 +++++++++++++++++++++++ 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/src/utils/dependency-report.ts b/src/utils/dependency-report.ts index 0737322f..f4cd6ca0 100644 --- a/src/utils/dependency-report.ts +++ b/src/utils/dependency-report.ts @@ -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'); } diff --git a/test/dependency-report.test.ts b/test/dependency-report.test.ts index 813a9497..cc8c1814 100644 --- a/test/dependency-report.test.ts +++ b/test/dependency-report.test.ts @@ -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', () => { From 5893f93862ac7cdeb83ec0af7f43b6d1cde10de9 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 18:23:44 +0200 Subject: [PATCH 04/57] feat: wire the CLI through the style kit (doctor colors, spinners, confirm) - doctor is colorized through the ReportStyle hook: verdict glyph and failing status text painted, paths and hints muted, versions left alone. `doctor --json` still prints raw JSON. - `codeman web -d`, `web --stop` and `service install` block for up to 30s polling /api/status; each now runs under a spinner instead of a silent terminal. - `codeman reset` asks a real y/N question on a TTY. Non-interactive callers keep the old "Use --force to confirm." refusal, so no script can be answered by a question it cannot see. - `codeman list` was a drifted copy of `codeman session list`; both now call one renderer, with the shorthand opting out of the stopped and web-server sections. - `web` no longer prints its own "running at" line: the server prints one, and unlike this one it also covers the daemon and service paths. - every chalk call goes through the palette, so the CLI has one place where colors are decided. Co-Authored-By: Claude Fable 5 --- src/cli.ts | 429 ++++++++++++++++++++++++++++------------------------- 1 file changed, 231 insertions(+), 198 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 3e796632..a1f18270 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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); } }); -sessionCmd - .command('list') - .alias('ls') - .description('List all sessions') - .action(() => { - const manager = getSessionManager(); - const sessions = manager.getAllSessions(); - const stored = manager.getStoredSessions(); +/** 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); +} - if (sessions.length === 0 && Object.keys(stored).length === 0) { - console.log(chalk.yellow('No sessions found')); - return; - } +/** + * 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(); - 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 (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(chalk.bold('\nStopped Sessions:')); + console.log(heading('Stopped 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}`); + console.log( + ` ${palette.muted(session.id.slice(0, 8))} ${palette.muted('stopped')}${name} ${session.workingDir}` + ); } } - // Show active sessions from state (when web server manages them) + // 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(chalk.bold('\nActive Sessions (from web server):')); + console.log(heading('Active 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}`); + 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(''); - }); + } + console.log(''); +} + +sessionCmd + .command('list') + .alias('ls') + .description('List all sessions') + .action(() => printSessionList({ includeStored: true })); sessionCmd .command('logs ') @@ -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['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['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,39 +791,14 @@ 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(); - - 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}`); - } - } - console.log(''); - }); + .description('List active sessions (shorthand; `codeman session list` also shows stopped ones)') + .action(() => printSessionList({ includeStored: false })); // ============ Web / daemon / service Commands ============ @@ -831,7 +830,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 +851,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 +871,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 +890,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 +926,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 +956,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 +972,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 +1001,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 +1012,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 +1090,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 +1101,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 +1131,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 +1151,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 --admin)')); + console.log(palette.warn('No users defined (run: codeman users add --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 +1176,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 +1236,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)); }); From d764917cd3c3036320cdd32bbca99f000db65cb6 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 18:23:55 +0200 Subject: [PATCH 05/57] fix: color the server startup line and its security warning The startup banner is now the only one (the CLI printed a duplicate) and is painted like the rest of the CLI. The non-loopback-without-password warning was plain console.warn while the CLI's copy of the same warning was yellow; chalk degrades off a TTY, so journald and web.log stay free of escape codes. Co-Authored-By: Claude Fable 5 --- src/web/server.ts | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/web/server.ts b/src/web/server.ts index a2dd30c2..a89d5ec6 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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'; @@ -2383,7 +2384,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 @@ -2413,20 +2416,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= (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= (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')); } } From 0d3b13e8797d180fcfcc0a7baa4393e1f5bf1782 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 18:23:56 +0200 Subject: [PATCH 06/57] test: derive the CLI inventory from the real commander program The file asserted against a hand-written fixture array with its own argument parser, so it could not see a command being renamed, losing an alias or disappearing, and it described a `tui` command that does not exist. It now walks program.commands: names, aliases, subcommands, option flags, operands, descriptions, and a guard against registering a name or alias twice at one level. Assertions are "at least this exists", so a new command (including the tui one this plan adds later) passes without editing the test. Co-Authored-By: Claude Fable 5 --- test/cli-commands.test.ts | 967 +++++++------------------------------- 1 file changed, 166 insertions(+), 801 deletions(-) diff --git a/test/cli-commands.test.ts b/test/cli-commands.test.ts index ae65fb5d..93a010ed 100644 --- a/test/cli-commands.test.ts +++ b/test/cli-commands.test.ts @@ -1,836 +1,201 @@ /** - * @fileoverview Tests for CLI command parsing and validation + * @fileoverview Inventory tests for the real commander program in `src/cli.ts`. * - * Tests command argument parsing, validation, and help text generation. + * Everything here walks the actual `program` object. The previous version of + * this file asserted against a hand-written fixture array (with its own arg + * parser), so it could not see a command being renamed, losing an alias or + * disappearing entirely, and it happily described a `tui` command that did not + * exist. Assertions are deliberately "at least this exists", so adding a new + * command does not fail the suite. */ import { describe, it, expect } from 'vitest'; +import type { Command } from 'commander'; import { program } from '../src/cli.js'; -describe('CLI Command Parsing', () => { - describe('Command Structure', () => { - interface Command { - name: string; - aliases: string[]; - description: string; - subcommands?: Command[]; +/** Resolve a subcommand the way commander does, by name or alias. */ +function find(parent: Command, name: string): Command | undefined { + return parent.commands.find((c) => c.name() === name || c.aliases().includes(name)); +} + +/** Every `-x` / `--long` flag a command registers. */ +function flagsOf(cmd: Command): string[] { + return cmd.options.flatMap((option) => [option.short, option.long].filter((f): f is string => Boolean(f))); +} + +/** Depth-first walk over the whole command tree, including the root. */ +function walk(cmd: Command, path: string[] = []): Array<{ path: string[]; cmd: Command }> { + const here = [...path, cmd.name()]; + return [{ path: here, cmd }, ...cmd.commands.flatMap((child) => walk(child, here))]; +} + +/** Top-level commands and the aliases they must keep. */ +const TOP_LEVEL: Record = { + attach: [], + skill: [], + session: ['s'], + task: ['t'], + ralph: ['r'], + status: [], + reset: [], + start: [], + list: ['ls'], + web: [], + service: [], + users: [], + doctor: ['check-deps'], +}; + +/** Subcommands per parent, with their aliases. */ +const SUBCOMMANDS: Record> = { + session: { start: [], stop: [], list: ['ls'], logs: [] }, + task: { add: [], list: ['ls'], status: [], remove: ['rm'], clear: [] }, + ralph: { start: [], stop: [], status: [] }, + skill: { install: [], uninstall: [] }, + service: { install: [], uninstall: [], status: [] }, + users: { add: [], passwd: [], list: ['ls'], rm: [] }, +}; + +describe('registered commands', () => { + it('is the codeman program', () => { + expect(program.name()).toBe('codeman'); + }); + + it.each(Object.entries(TOP_LEVEL))('registers `%s` with its aliases', (name, aliases) => { + const cmd = find(program, name); + expect(cmd, `top-level command "${name}" is missing`).toBeDefined(); + expect(cmd!.name()).toBe(name); + expect(cmd!.aliases()).toEqual(expect.arrayContaining(aliases)); + }); + + it.each(Object.entries(SUBCOMMANDS))('registers the `%s` subcommands', (parentName, children) => { + const parent = find(program, parentName)!; + for (const [name, aliases] of Object.entries(children)) { + const child = find(parent, name); + expect(child, `${parentName} ${name} is missing`).toBeDefined(); + expect(child!.name()).toBe(name); + expect(child!.aliases()).toEqual(expect.arrayContaining(aliases)); } - - const commands: Command[] = [ - { - name: 'session', - aliases: ['s'], - description: 'Manage Claude sessions', - subcommands: [ - { name: 'start', aliases: [], description: 'Start new session' }, - { name: 'stop', aliases: [], description: 'Stop session' }, - { name: 'list', aliases: ['ls'], description: 'List all sessions' }, - { name: 'logs', aliases: [], description: 'View session output' }, - ], - }, - { - name: 'task', - aliases: ['t'], - description: 'Manage tasks', - subcommands: [ - { name: 'add', aliases: [], description: 'Add task' }, - { name: 'list', aliases: ['ls'], description: 'List tasks' }, - { name: 'status', aliases: [], description: 'Task details' }, - { name: 'remove', aliases: ['rm'], description: 'Remove task' }, - { name: 'clear', aliases: [], description: 'Clear completed' }, - ], - }, - { - name: 'ralph', - aliases: ['r'], - description: 'Control Ralph loop', - subcommands: [ - { name: 'start', aliases: [], description: 'Start loop' }, - { name: 'stop', aliases: [], description: 'Stop loop' }, - { name: 'status', aliases: [], description: 'Show status' }, - ], - }, - { - name: 'web', - aliases: [], - description: 'Start web interface', - }, - { - name: 'tui', - aliases: [], - description: 'Start TUI', - }, - { - name: 'status', - aliases: [], - description: 'Overall status', - }, - { - name: 'reset', - aliases: [], - description: 'Reset all state', - }, - ]; - - const findCommand = (name: string): Command | undefined => { - return commands.find((c) => c.name === name || c.aliases.includes(name)); - }; - - const findSubcommand = (parent: Command, name: string): Command | undefined => { - return parent.subcommands?.find((c) => c.name === name || c.aliases.includes(name)); - }; - - it('should find commands by name', () => { - expect(findCommand('session')?.name).toBe('session'); - expect(findCommand('task')?.name).toBe('task'); - expect(findCommand('ralph')?.name).toBe('ralph'); - }); - - it('should find commands by alias', () => { - expect(findCommand('s')?.name).toBe('session'); - expect(findCommand('t')?.name).toBe('task'); - expect(findCommand('r')?.name).toBe('ralph'); - }); - - it('should find subcommands', () => { - const session = findCommand('session')!; - expect(findSubcommand(session, 'start')?.name).toBe('start'); - expect(findSubcommand(session, 'list')?.name).toBe('list'); - expect(findSubcommand(session, 'ls')?.name).toBe('list'); - }); - - it('should return undefined for unknown commands', () => { - expect(findCommand('unknown')).toBeUndefined(); - }); - - it('should have descriptions for all commands', () => { - commands.forEach((cmd) => { - expect(cmd.description).toBeTruthy(); - }); - }); }); - describe('Argument Parsing', () => { - interface ParsedArgs { - command?: string; - subcommand?: string; - args: string[]; - flags: Record; - } - - const parseArgs = (argv: string[]): ParsedArgs => { - const result: ParsedArgs = { args: [], flags: {} }; - let i = 0; - - // Skip node and script name if present - while (i < argv.length && (argv[i].includes('node') || argv[i].endsWith('.js'))) { - i++; - } - - // Parse remaining args - while (i < argv.length) { - const arg = argv[i]; - - if (arg.startsWith('--')) { - const key = arg.slice(2); - const nextArg = argv[i + 1]; - if (nextArg && !nextArg.startsWith('-')) { - result.flags[key] = nextArg; - i++; - } else { - result.flags[key] = true; - } - } else if (arg.startsWith('-')) { - const key = arg.slice(1); - const nextArg = argv[i + 1]; - if (nextArg && !nextArg.startsWith('-')) { - result.flags[key] = nextArg; - i++; - } else { - result.flags[key] = true; - } - } else if (!result.command) { - result.command = arg; - } else if (!result.subcommand) { - result.subcommand = arg; - } else { - result.args.push(arg); - } - i++; - } - - return result; - }; - - it('should parse simple command', () => { - const parsed = parseArgs(['status']); - expect(parsed.command).toBe('status'); - }); - - it('should parse command with subcommand', () => { - const parsed = parseArgs(['session', 'start']); - expect(parsed.command).toBe('session'); - expect(parsed.subcommand).toBe('start'); - }); - - it('should parse boolean flags', () => { - const parsed = parseArgs(['web', '--verbose']); - expect(parsed.flags.verbose).toBe(true); - }); - - it('should parse flags with values', () => { - const parsed = parseArgs(['web', '-p', '8080']); - expect(parsed.flags.p).toBe('8080'); - }); - - it('should parse long flags with values', () => { - const parsed = parseArgs(['web', '--port', '8080']); - expect(parsed.flags.port).toBe('8080'); - }); - - it('should parse positional arguments', () => { - const parsed = parseArgs(['session', 'stop', 'session-123']); - expect(parsed.args).toEqual(['session-123']); - }); - - it('should handle multiple flags', () => { - const parsed = parseArgs(['tui', '--with-web', '-p', '3000']); - expect(parsed.flags['with-web']).toBe(true); - expect(parsed.flags.p).toBe('3000'); - }); - - it('should handle empty input', () => { - const parsed = parseArgs([]); - expect(parsed.command).toBeUndefined(); - expect(parsed.args).toEqual([]); - }); + it('gives every command a description', () => { + const missing = walk(program) + .slice(1) + .filter(({ cmd }) => !cmd.description()) + .map(({ path }) => path.join(' ')); + expect(missing).toEqual([]); }); - describe('Flag Validation', () => { - interface FlagDef { - name: string; - short?: string; - type: 'boolean' | 'string' | 'number'; - required?: boolean; - default?: unknown; + it('never registers the same name or alias twice at one level', () => { + for (const { path, cmd } of walk(program)) { + const taken = cmd.commands.flatMap((child) => [child.name(), ...child.aliases()]); + expect(new Set(taken).size, `duplicate command name/alias under "${path.join(' ')}"`).toBe(taken.length); } - - const webFlags: FlagDef[] = [ - { name: 'port', short: 'p', type: 'number', default: 3000 }, - { name: 'host', short: 'h', type: 'string', default: '0.0.0.0' }, - ]; - - const tuiFlags: FlagDef[] = [ - { name: 'port', short: 'p', type: 'number', default: 3000 }, - { name: 'with-web', type: 'boolean', default: false }, - { name: 'no-web', type: 'boolean', default: false }, - ]; - - const validateFlag = (value: unknown, def: FlagDef): boolean => { - if (value === undefined) return !def.required; - - switch (def.type) { - case 'boolean': - return typeof value === 'boolean'; - case 'string': - return typeof value === 'string' && value.length > 0; - case 'number': - return typeof value === 'number' || /^\d+$/.test(String(value)); - default: - return false; - } - }; - - it('should validate boolean flags', () => { - expect(validateFlag(true, { name: 'verbose', type: 'boolean' })).toBe(true); - expect(validateFlag(false, { name: 'verbose', type: 'boolean' })).toBe(true); - expect(validateFlag('true', { name: 'verbose', type: 'boolean' })).toBe(false); - }); - - it('should validate string flags', () => { - expect(validateFlag('value', { name: 'host', type: 'string' })).toBe(true); - expect(validateFlag('', { name: 'host', type: 'string' })).toBe(false); - expect(validateFlag(123, { name: 'host', type: 'string' })).toBe(false); - }); - - it('should validate number flags', () => { - expect(validateFlag(8080, { name: 'port', type: 'number' })).toBe(true); - expect(validateFlag('8080', { name: 'port', type: 'number' })).toBe(true); - expect(validateFlag('abc', { name: 'port', type: 'number' })).toBe(false); - }); - - it('should handle missing optional flags', () => { - expect(validateFlag(undefined, { name: 'port', type: 'number' })).toBe(true); - }); - - it('should reject missing required flags', () => { - expect(validateFlag(undefined, { name: 'port', type: 'number', required: true })).toBe(false); - }); - - it('should have defaults for web flags', () => { - webFlags.forEach((flag) => { - expect(flag.default).toBeDefined(); - }); - }); - - it('should have defaults for tui flags', () => { - tuiFlags.forEach((flag) => { - expect(flag.default).toBeDefined(); - }); - }); - }); - - describe('Port Validation', () => { - const isValidPort = (port: number): boolean => { - return Number.isInteger(port) && port >= 1 && port <= 65535; - }; - - const isPrivilegedPort = (port: number): boolean => { - return port < 1024; - }; - - it('should accept valid ports', () => { - expect(isValidPort(80)).toBe(true); - expect(isValidPort(3000)).toBe(true); - expect(isValidPort(8080)).toBe(true); - expect(isValidPort(65535)).toBe(true); - }); - - it('should reject invalid ports', () => { - expect(isValidPort(0)).toBe(false); - expect(isValidPort(-1)).toBe(false); - expect(isValidPort(65536)).toBe(false); - expect(isValidPort(100000)).toBe(false); - }); - - it('should reject non-integer ports', () => { - expect(isValidPort(3000.5)).toBe(false); - expect(isValidPort(NaN)).toBe(false); - }); - - it('should detect privileged ports', () => { - expect(isPrivilegedPort(80)).toBe(true); - expect(isPrivilegedPort(443)).toBe(true); - expect(isPrivilegedPort(1023)).toBe(true); - expect(isPrivilegedPort(1024)).toBe(false); - expect(isPrivilegedPort(3000)).toBe(false); - }); }); - describe('Help Text Generation', () => { - const generateHelp = (command: string, description: string, options: string[]): string => { - let help = `Usage: codeman ${command}\n\n`; - help += `${description}\n`; - if (options.length > 0) { - help += '\nOptions:\n'; - options.forEach((opt) => { - help += ` ${opt}\n`; - }); - } - return help; - }; - - it('should generate basic help', () => { - const help = generateHelp('status', 'Show overall status', []); - expect(help).toContain('Usage: codeman status'); - expect(help).toContain('Show overall status'); - }); - - it('should include options', () => { - const help = generateHelp('web', 'Start web interface', [ - '-p, --port Server port (default: 3000)', - '-h, --host Server host (default: 0.0.0.0)', - ]); - expect(help).toContain('Options:'); - expect(help).toContain('--port'); - expect(help).toContain('--host'); - }); - - it('documents the unauthenticated network override in real web command help', () => { - const webCommand = program.commands.find((command) => command.name() === 'web'); - expect(webCommand).toBeDefined(); - - const help = webCommand!.helpInformation(); - expect(help).toContain('--allow-unauthenticated-network'); - expect(help).toMatch(/without\s+CODEMAN_PASSWORD/); - }); - - it('should format properly', () => { - const help = generateHelp('test', 'Test command', ['--flag']); - const lines = help.split('\n'); - expect(lines[0]).toMatch(/^Usage:/); - }); + it('resolves commands by alias, not just by name', () => { + expect(find(program, 's')?.name()).toBe('session'); + expect(find(program, 't')?.name()).toBe('task'); + expect(find(program, 'r')?.name()).toBe('ralph'); + expect(find(program, 'ls')?.name()).toBe('list'); + expect(find(program, 'check-deps')?.name()).toBe('doctor'); + expect(find(find(program, 'session')!, 'ls')?.name()).toBe('list'); }); - describe('Session ID Validation', () => { - const isValidSessionId = (id: string): boolean => { - // UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - return /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(id); - }; - - const isValidShortId = (id: string): boolean => { - // Short format: session-timestamp-random - return /^session-\d+-[a-z0-9]+$/.test(id); - }; - - it('should validate UUID session IDs', () => { - expect(isValidSessionId('550e8400-e29b-41d4-a716-446655440000')).toBe(true); - expect(isValidSessionId('123e4567-e89b-12d3-a456-426614174000')).toBe(true); - }); - - it('should reject invalid UUIDs', () => { - expect(isValidSessionId('not-a-uuid')).toBe(false); - expect(isValidSessionId('550e8400-e29b-41d4-a716')).toBe(false); - expect(isValidSessionId('')).toBe(false); - }); - - it('should validate short session IDs', () => { - expect(isValidShortId('session-1234567890-abc123')).toBe(true); - }); - - it('should reject invalid short IDs', () => { - expect(isValidShortId('session-abc-123')).toBe(false); - expect(isValidShortId('not-session-123-abc')).toBe(false); - }); - }); - - describe('Case Name Validation', () => { - const isValidCaseName = (name: string): boolean => { - if (name.length === 0 || name.length > 100) return false; - // Allow alphanumeric, hyphens, underscores - return /^[a-zA-Z0-9_-]+$/.test(name); - }; - - const sanitizeCaseName = (name: string): string => { - return name - .toLowerCase() - .replace(/[^a-z0-9_-]/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') - .substring(0, 100); - }; - - it('should accept valid case names', () => { - expect(isValidCaseName('my-project')).toBe(true); - expect(isValidCaseName('project_v2')).toBe(true); - expect(isValidCaseName('Test123')).toBe(true); - }); - - it('should reject invalid case names', () => { - expect(isValidCaseName('')).toBe(false); - expect(isValidCaseName('my project')).toBe(false); - expect(isValidCaseName('project@v2')).toBe(false); - }); - - it('should reject too long names', () => { - expect(isValidCaseName('x'.repeat(101))).toBe(false); - }); - - it('should sanitize names', () => { - expect(sanitizeCaseName('My Project!')).toBe('my-project'); - expect(sanitizeCaseName('test@#$123')).toBe('test-123'); - expect(sanitizeCaseName(' spaces ')).toBe('spaces'); - }); - - it('should handle consecutive special chars', () => { - expect(sanitizeCaseName('a!!!b')).toBe('a-b'); - }); - }); - - describe('Prompt Validation', () => { - const MAX_PROMPT_LENGTH = 100000; - - const isValidPrompt = (prompt: string): boolean => { - return prompt.length > 0 && prompt.length <= MAX_PROMPT_LENGTH; - }; - - const truncatePrompt = (prompt: string, maxLength: number = MAX_PROMPT_LENGTH): string => { - if (prompt.length <= maxLength) return prompt; - return prompt.substring(0, maxLength - 3) + '...'; - }; - - it('should accept valid prompts', () => { - expect(isValidPrompt('Hello')).toBe(true); - expect(isValidPrompt('A'.repeat(1000))).toBe(true); - }); - - it('should reject empty prompts', () => { - expect(isValidPrompt('')).toBe(false); - }); - - it('should reject too long prompts', () => { - expect(isValidPrompt('A'.repeat(MAX_PROMPT_LENGTH + 1))).toBe(false); - }); - - it('should truncate long prompts', () => { - const longPrompt = 'A'.repeat(100); - const truncated = truncatePrompt(longPrompt, 50); - expect(truncated.length).toBe(50); - expect(truncated.endsWith('...')).toBe(true); - }); - - it('should not truncate short prompts', () => { - const shortPrompt = 'Hello'; - expect(truncatePrompt(shortPrompt, 50)).toBe(shortPrompt); - }); + it('has no command for an unknown name', () => { + expect(find(program, 'unknown')).toBeUndefined(); }); }); -describe('CLI Output Formatting', () => { - describe('Table Formatting', () => { - interface Column { - header: string; - width: number; +describe('registered options', () => { + it('exposes the web launch flags', () => { + const flags = flagsOf(find(program, 'web')!); + expect(flags).toEqual( + expect.arrayContaining([ + '-H', + '--host', + '-p', + '--port', + '--https', + '--title-hostname', + '--allow-unauthenticated-network', + '--multiuser', + '-d', + '--daemon', + '--stop', + '--status', + ]) + ); + }); + + it('gives `service install` the same launch flags as `web`', () => { + const install = flagsOf(find(find(program, 'service')!, 'install')!); + expect(install).toEqual(expect.arrayContaining(['-H', '--host', '-p', '--port', '--https', '--multiuser'])); + }); + + it('keeps `doctor --json` and `--category`', () => { + expect(flagsOf(find(program, 'doctor')!)).toEqual(expect.arrayContaining(['--json', '--category'])); + }); + + it('keeps the escape hatches that scripts depend on', () => { + expect(flagsOf(find(program, 'reset')!)).toEqual(expect.arrayContaining(['-f', '--force'])); + expect(flagsOf(find(program, 'status')!)).toEqual(expect.arrayContaining(['--url'])); + expect(flagsOf(find(program, 'attach')!)).toEqual(expect.arrayContaining(['-s', '--session', '--url'])); + const users = find(program, 'users')!; + expect(flagsOf(find(users, 'add')!)).toEqual(expect.arrayContaining(['--admin', '--password-stdin'])); + expect(flagsOf(find(users, 'passwd')!)).toEqual(expect.arrayContaining(['--password-stdin'])); + expect(flagsOf(find(users, 'rm')!)).toEqual(expect.arrayContaining(['--delete-space'])); + }); + + it('takes a working directory for the session commands', () => { + expect(flagsOf(find(find(program, 'session')!, 'start')!)).toEqual(expect.arrayContaining(['-d', '--dir'])); + expect(flagsOf(find(program, 'start')!)).toEqual(expect.arrayContaining(['-d', '--dir'])); + }); + + it('scopes skill install/uninstall to a case', () => { + const skill = find(program, 'skill')!; + for (const name of ['install', 'uninstall']) { + expect(flagsOf(find(skill, name)!)).toEqual(expect.arrayContaining(['-g', '--global', '-c', '--case'])); } - - const formatRow = (values: string[], columns: Column[]): string => { - return values - .map((val, i) => { - const width = columns[i]?.width || 10; - return val.padEnd(width).substring(0, width); - }) - .join(' '); - }; - - const formatTable = (headers: string[], rows: string[][], widths: number[]): string => { - const columns = headers.map((h, i) => ({ header: h, width: widths[i] })); - const headerRow = formatRow(headers, columns); - const separator = columns.map((c) => '-'.repeat(c.width)).join(' '); - const dataRows = rows.map((row) => formatRow(row, columns)); - return [headerRow, separator, ...dataRows].join('\n'); - }; - - it('should format single row', () => { - const columns = [ - { header: 'ID', width: 10 }, - { header: 'Status', width: 8 }, - ]; - const row = formatRow(['123', 'active'], columns); - expect(row).toBe('123 active '); - }); - - it('should truncate long values', () => { - const columns = [{ header: 'Name', width: 5 }]; - const row = formatRow(['verylongname'], columns); - expect(row).toBe('veryl'); - }); - - it('should format complete table', () => { - const table = formatTable( - ['ID', 'Status'], - [ - ['1', 'active'], - ['2', 'idle'], - ], - [5, 8] - ); - expect(table).toContain('ID'); - expect(table).toContain('Status'); - expect(table).toContain('-----'); - expect(table).toContain('active'); - }); - }); - - describe('Status Formatting', () => { - const formatStatus = (status: string): string => { - const statusMap: Record = { - active: '● active', - idle: '○ idle', - working: '◐ working', - error: '✗ error', - stopped: '□ stopped', - }; - return statusMap[status] || status; - }; - - it('should format active status', () => { - expect(formatStatus('active')).toBe('● active'); - }); - - it('should format idle status', () => { - expect(formatStatus('idle')).toBe('○ idle'); - }); - - it('should format working status', () => { - expect(formatStatus('working')).toBe('◐ working'); - }); - - it('should format error status', () => { - expect(formatStatus('error')).toBe('✗ error'); - }); - - it('should return unknown status as-is', () => { - expect(formatStatus('unknown')).toBe('unknown'); - }); - }); - - describe('Token Formatting', () => { - const formatTokens = (tokens: number): string => { - if (tokens >= 1_000_000) { - return `${(tokens / 1_000_000).toFixed(1)}M`; - } - if (tokens >= 1_000) { - return `${(tokens / 1_000).toFixed(1)}k`; - } - return tokens.toString(); - }; - - it('should format millions', () => { - expect(formatTokens(1_500_000)).toBe('1.5M'); - expect(formatTokens(2_000_000)).toBe('2.0M'); - }); - - it('should format thousands', () => { - expect(formatTokens(1_500)).toBe('1.5k'); - expect(formatTokens(100_000)).toBe('100.0k'); - }); - - it('should format small numbers', () => { - expect(formatTokens(500)).toBe('500'); - expect(formatTokens(0)).toBe('0'); - }); - }); - - describe('Cost Formatting', () => { - const formatCost = (cost: number): string => { - if (cost < 0.01) { - return `$${cost.toFixed(4)}`; - } - return `$${cost.toFixed(2)}`; - }; - - it('should format small costs with 4 decimals', () => { - expect(formatCost(0.0015)).toBe('$0.0015'); - expect(formatCost(0.0001)).toBe('$0.0001'); - }); - - it('should format normal costs with 2 decimals', () => { - expect(formatCost(1.5)).toBe('$1.50'); - expect(formatCost(0.05)).toBe('$0.05'); - }); - - it('should handle zero', () => { - expect(formatCost(0)).toBe('$0.0000'); - }); - }); - - describe('Duration Formatting', () => { - const formatDuration = (ms: number): string => { - const seconds = Math.floor(ms / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - - if (hours > 0) { - return `${hours}h ${minutes % 60}m`; - } - if (minutes > 0) { - return `${minutes}m ${seconds % 60}s`; - } - return `${seconds}s`; - }; - - it('should format seconds', () => { - expect(formatDuration(5000)).toBe('5s'); - expect(formatDuration(45000)).toBe('45s'); - }); - - it('should format minutes', () => { - expect(formatDuration(90000)).toBe('1m 30s'); - expect(formatDuration(300000)).toBe('5m 0s'); - }); - - it('should format hours', () => { - expect(formatDuration(3600000)).toBe('1h 0m'); - expect(formatDuration(5400000)).toBe('1h 30m'); - }); - - it('should handle zero', () => { - expect(formatDuration(0)).toBe('0s'); - }); }); +}); - describe('List Formatting', () => { - const formatList = (items: string[], bullet: string = '-'): string => { - return items.map((item) => `${bullet} ${item}`).join('\n'); - }; - - const formatNumberedList = (items: string[]): string => { - return items.map((item, i) => `${i + 1}. ${item}`).join('\n'); - }; - - it('should format bulleted list', () => { - const list = formatList(['item1', 'item2', 'item3']); - expect(list).toBe('- item1\n- item2\n- item3'); - }); - - it('should format with custom bullet', () => { - const list = formatList(['item1', 'item2'], '*'); - expect(list).toBe('* item1\n* item2'); - }); - - it('should format numbered list', () => { - const list = formatNumberedList(['item1', 'item2', 'item3']); - expect(list).toBe('1. item1\n2. item2\n3. item3'); - }); - - it('should handle empty list', () => { - expect(formatList([])).toBe(''); - expect(formatNumberedList([])).toBe(''); - }); +describe('registered arguments', () => { + it.each([ + ['attach', ['path']], + ['start', []], + ])('declares the operands of `%s`', (name, expected) => { + const args = find(program, name)!.registeredArguments.map((arg) => arg.name()); + expect(args).toEqual(expected); }); - describe('Error Message Formatting', () => { - const formatError = (message: string, code?: string): string => { - if (code) { - return `Error [${code}]: ${message}`; - } - return `Error: ${message}`; - }; - - it('should format error with code', () => { - const error = formatError('Session not found', 'NOT_FOUND'); - expect(error).toBe('Error [NOT_FOUND]: Session not found'); - }); - - it('should format error without code', () => { - const error = formatError('Something went wrong'); - expect(error).toBe('Error: Something went wrong'); - }); - }); - - describe('Progress Formatting', () => { - const formatProgress = (current: number, total: number, width: number = 20): string => { - const percent = Math.round((current / total) * 100); - const filled = Math.round((current / total) * width); - const empty = width - filled; - return `[${'='.repeat(filled)}${' '.repeat(empty)}] ${percent}%`; - }; - - it('should format progress at 0%', () => { - const progress = formatProgress(0, 100, 10); - expect(progress).toBe('[ ] 0%'); - }); - - it('should format progress at 50%', () => { - const progress = formatProgress(50, 100, 10); - expect(progress).toBe('[===== ] 50%'); - }); - - it('should format progress at 100%', () => { - const progress = formatProgress(100, 100, 10); - expect(progress).toBe('[==========] 100%'); - }); - - it('should handle non-round percentages', () => { - const progress = formatProgress(33, 100, 10); - expect(progress).toBe('[=== ] 33%'); - }); + it('requires an id for the commands that act on one session or task', () => { + const session = find(program, 'session')!; + expect(find(session, 'stop')!.registeredArguments.map((a) => a.name())).toEqual(['id']); + expect(find(session, 'logs')!.registeredArguments.map((a) => a.name())).toEqual(['id']); + const task = find(program, 'task')!; + expect(find(task, 'status')!.registeredArguments.map((a) => a.name())).toEqual(['id']); + expect(find(task, 'remove')!.registeredArguments.map((a) => a.name())).toEqual(['id']); }); }); -describe('CLI Configuration', () => { - describe('Default Configuration', () => { - interface CLIConfig { - maxConcurrentSessions: number; - defaultPort: number; - defaultHost: string; - casesDirectory: string; - stateDirectory: string; - } - - const defaultConfig: CLIConfig = { - maxConcurrentSessions: 5, - defaultPort: 3000, - defaultHost: '0.0.0.0', - casesDirectory: '~/codeman-cases', - stateDirectory: '~/.codeman', - }; - - const expandPath = (path: string): string => { - if (path.startsWith('~/')) { - return `/home/user${path.slice(1)}`; - } - return path; - }; - - it('should have sensible defaults', () => { - expect(defaultConfig.maxConcurrentSessions).toBe(5); - expect(defaultConfig.defaultPort).toBe(3000); - expect(defaultConfig.defaultHost).toBe('0.0.0.0'); - }); - - it('should expand home directory paths', () => { - expect(expandPath('~/codeman-cases')).toBe('/home/user/codeman-cases'); - expect(expandPath('~/.codeman')).toBe('/home/user/.codeman'); - }); - - it('should not modify absolute paths', () => { - expect(expandPath('/var/data')).toBe('/var/data'); - }); +describe('help text', () => { + it('documents the unauthenticated network override in the real web command help', () => { + const help = find(program, 'web')!.helpInformation(); + expect(help).toContain('--allow-unauthenticated-network'); + expect(help).toMatch(/without\s+CODEMAN_PASSWORD/); }); - describe('Environment Variable Parsing', () => { - const parseEnvInt = (value: string | undefined, defaultValue: number): number => { - if (value === undefined) return defaultValue; - const parsed = parseInt(value, 10); - return isNaN(parsed) ? defaultValue : parsed; - }; - - const parseEnvBool = (value: string | undefined, defaultValue: boolean): boolean => { - if (value === undefined) return defaultValue; - return value.toLowerCase() === 'true' || value === '1'; - }; - - it('should parse integer env vars', () => { - expect(parseEnvInt('8080', 3000)).toBe(8080); - expect(parseEnvInt(undefined, 3000)).toBe(3000); - expect(parseEnvInt('invalid', 3000)).toBe(3000); - }); - - it('should parse boolean env vars', () => { - expect(parseEnvBool('true', false)).toBe(true); - expect(parseEnvBool('1', false)).toBe(true); - expect(parseEnvBool('false', true)).toBe(false); - expect(parseEnvBool(undefined, true)).toBe(true); - }); + it('describes `attach` as the attachment card command, not a hook context', () => { + const help = find(program, 'attach')!.helpInformation(); + expect(help).toContain('attachment card'); + expect(help).not.toContain('hook context'); }); - describe('Config File Parsing', () => { - interface ConfigFile { - port?: number; - host?: string; - maxSessions?: number; + it('lists every top-level command in the root help', () => { + const help = program.helpInformation(); + for (const name of Object.keys(TOP_LEVEL)) { + expect(help).toContain(name); } - - const parseConfigFile = (content: string): ConfigFile => { - try { - return JSON.parse(content); - } catch { - return {}; - } - }; - - const mergeConfigs = (defaults: ConfigFile, file: ConfigFile, env: ConfigFile): ConfigFile => { - return { ...defaults, ...file, ...env }; - }; - - it('should parse valid JSON config', () => { - const config = parseConfigFile('{"port": 8080}'); - expect(config.port).toBe(8080); - }); - - it('should handle invalid JSON', () => { - const config = parseConfigFile('invalid json'); - expect(config).toEqual({}); - }); - - it('should merge configs with priority', () => { - const defaults = { port: 3000, host: 'localhost' }; - const file = { port: 8080 }; - const env = { host: '0.0.0.0' }; - const merged = mergeConfigs(defaults, file, env); - expect(merged.port).toBe(8080); - expect(merged.host).toBe('0.0.0.0'); - }); }); }); From 6f9fb10366e45970bf3a500dbfc01b4a193368b9 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 18:23:57 +0200 Subject: [PATCH 07/57] docs: fix the codeman attach description and the detach prefix `codeman attach ` posts an attachment card for a local file; it was described as attaching a Claude hook context. And Codeman never overrides the tmux prefix for local sessions (only remote-SSH and docker panes get C-q), so the detach hint is Ctrl+B D, matching the chooser. Co-Authored-By: Claude Fable 5 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 32a1c3ea..38241cd6 100644 --- a/README.md +++ b/README.md @@ -668,7 +668,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. Detach with `Ctrl+B D` (tmux's default prefix, which Codeman does not change for local sessions). --- @@ -893,7 +893,7 @@ codeman session start -d /path/to/repo # (s) start a session codeman session list # list sessions codeman session logs # tail output codeman task add "fix the failing test" # (t) queue a task -codeman attach # attach a Claude hook context +codeman attach # show an attachment card for a local file ``` ### Hooks (events flowing _back_ to Codeman) From 5e5d504cf5e3910a35e850403555707ce5f5ab6b Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 18:54:32 +0200 Subject: [PATCH 08/57] chore: stop ignoring src/tui The entry dates from an abandoned prototype (0.1427) and would have kept the real TUI modules untracked while `git status` stayed silent about it. Co-Authored-By: Claude Fable 5 --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index f29cf3e9..954c0144 100644 --- a/.gitignore +++ b/.gitignore @@ -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 From 3c3a8f8f436e0c410416c467071d002b9bde6a19 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 18:54:38 +0200 Subject: [PATCH 09/57] feat: add the TUI's SGR-aware preview helpers The preview pane shows a session's raw terminal stream, so it needs the tail reconstructed rather than emulated: SGR survives, cursor steering and OSC do not, and a carriage return returns to column 0 so a spinner that repaints its line 200 times contributes one line instead of 200. Widths count East Asian Wide characters as two columns, which the clip and pad helpers rely on to never cut a wide character, a code point or an escape sequence in half. Co-Authored-By: Claude Fable 5 --- src/tui/tui-ansi.ts | 473 ++++++++++++++++++++++++++++++++++++++ test/tui/tui-ansi.test.ts | 174 ++++++++++++++ 2 files changed, 647 insertions(+) create mode 100644 src/tui/tui-ansi.ts create mode 100644 test/tui/tui-ansi.test.ts diff --git a/src/tui/tui-ansi.ts b/src/tui/tui-ansi.ts new file mode 100644 index 00000000..80118c36 --- /dev/null +++ b/src/tui/tui-ansi.ts @@ -0,0 +1,473 @@ +/** + * @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. + * + * 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. + * + * @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; + +// ───────────────────────────────────────────────────────────────────────────── +// 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; +} + +/** 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) }; + 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 = [ + [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 = [ + [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): 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. + */ +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; + }; + + 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(''); + } + 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; +} + +/** + * 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))); +} diff --git a/test/tui/tui-ansi.test.ts b/test/tui/tui-ansi.test.ts new file mode 100644 index 00000000..0dc3b83c --- /dev/null +++ b/test/tui/tui-ansi.test.ts @@ -0,0 +1,174 @@ +/** + * @fileoverview Unit tests for the TUI's SGR-aware preview helpers + * (toDisplayLines / clipStyledLine / visibleWidth / padDisplay / stripStyles). + * + * The invariants under test are the ones a preview pane fails visibly on: color + * survives, cursor steering does not, a carriage-return repaint collapses to + * one line, and no clip ever cuts a code point, a wide character or an escape + * sequence in half. + */ +import { describe, it, expect } from 'vitest'; +import { + clipStyledLine, + padDisplay, + stripStyles, + toDisplayLines, + visibleWidth, + charWidth, +} from '../../src/tui/tui-ansi.js'; + +const RED = '\x1b[31m'; +const BOLD = '\x1b[1m'; +const RESET = '\x1b[0m'; + +describe('toDisplayLines', () => { + it('splits like String.split, trailing newline included', () => { + expect(toDisplayLines('a\nb')).toEqual(['a', 'b']); + expect(toDisplayLines('a\n')).toEqual(['a', '']); + expect(toDisplayLines('')).toEqual(['']); + }); + + it('preserves SGR and closes an open style at end of line', () => { + expect(toDisplayLines(`${RED}red${RESET} done`)).toEqual([`${RED}red${RESET} done`]); + expect(toDisplayLines(`${BOLD}bold`)).toEqual([`${BOLD}bold${RESET}`]); + }); + + it('accumulates SGR state across a line', () => { + expect(toDisplayLines(`${BOLD}a${RED}b`)).toEqual([`${BOLD}a${RESET}${BOLD}${RED}b${RESET}`]); + }); + + it('strips OSC sequences (BEL and ST terminated)', () => { + expect(toDisplayLines('\x1b]0;window title\x07text')).toEqual(['text']); + expect(toDisplayLines('\x1b]0;window title\x1b\\text')).toEqual(['text']); + }); + + it('strips DECSET/DECRST, cursor movement and charset selection', () => { + expect(toDisplayLines('\x1b[?25lvisible\x1b[?25h')).toEqual(['visible']); + expect(toDisplayLines('a\x1b[5Cb')).toEqual(['ab']); + expect(toDisplayLines('\x1b[2J\x1b[H\x1b[1;1Hhome')).toEqual(['home']); + expect(toDisplayLines('\x1b(0lqk\x1b(B')).toEqual(['lqk']); + expect(toDisplayLines('\x1b=app\x1b>')).toEqual(['app']); + }); + + it('strips C1 controls and their sequences', () => { + expect(toDisplayLines('a\x9b31mb')).toEqual(['ab']); + expect(toDisplayLines('a\x9d0;title\x9cb')).toEqual(['ab']); + }); + + it('drops control characters but keeps tabs as spaces', () => { + expect(toDisplayLines('a\x07b\x00c')).toEqual(['abc']); + expect(toDisplayLines('a\tb')).toEqual(['a b']); + expect(toDisplayLines('\tx')).toEqual([' x']); + }); + + it('treats a bare \\r as a return to column zero (spinner repaint)', () => { + expect(toDisplayLines('abcdef\rXY')).toEqual(['XYcdef']); + expect(toDisplayLines('long line here\rshort')).toEqual(['shortline here']); + expect(toDisplayLines('\rWorking 1%\rWorking 99%')).toEqual(['Working 99%']); + }); + + it('keeps \\r\\n as a plain newline', () => { + expect(toDisplayLines('a\r\nb')).toEqual(['a', 'b']); + }); + + it('carries the overwriting text style, not the overwritten one', () => { + expect(toDisplayLines(`${RED}aaa\r${RESET}b`)).toEqual([`b${RED}aa${RESET}`]); + }); + + it('keeps whole code points and attaches combining marks to their base', () => { + expect(toDisplayLines('a\u{1f600}b')).toEqual(['a\u{1f600}b']); + expect(toDisplayLines('éx')).toEqual(['éx']); + }); + + it('does not throw on truncated or malformed escapes', () => { + expect(toDisplayLines('abc\x1b')).toEqual(['abc']); + expect(toDisplayLines('abc\x1b[')).toEqual(['abc']); + expect(toDisplayLines('abc\x1b[31')).toEqual(['abc']); + expect(toDisplayLines('\x1b]0;no terminator')).toEqual(['']); + expect(() => toDisplayLines('\x1b\x1b\x1b[[[m')).not.toThrow(); + }); +}); + +describe('visibleWidth', () => { + it('ignores escape sequences', () => { + expect(visibleWidth(`${RED}abc${RESET}`)).toBe(3); + expect(visibleWidth('\x1b]0;title\x07abc')).toBe(3); + }); + + it('counts East Asian wide characters as two columns', () => { + expect(visibleWidth('中文')).toBe(4); + expect(visibleWidth('a中b')).toBe(4); + expect(visibleWidth('full')).toBe(8); + expect(visibleWidth('\u{1f600}')).toBe(2); + }); + + it('counts combining marks and zero-width joiners as nothing', () => { + expect(visibleWidth('é')).toBe(1); + expect(visibleWidth('a‍b')).toBe(2); + expect(visibleWidth('')).toBe(0); + }); + + it('agrees with charWidth on the boundaries', () => { + expect(charWidth(0x41)).toBe(1); + expect(charWidth(0x4e00)).toBe(2); + expect(charWidth(0x0301)).toBe(0); + expect(charWidth(0x07)).toBe(0); + }); +}); + +describe('clipStyledLine', () => { + it('clips plain text by display width', () => { + expect(clipStyledLine('abcdef', 3)).toBe('abc'); + expect(clipStyledLine('abc', 10)).toBe('abc'); + expect(clipStyledLine('abc', 0)).toBe(''); + expect(clipStyledLine('abc', -4)).toBe(''); + }); + + it('keeps the SGR state active at the clip point and closes it', () => { + expect(clipStyledLine(`${RED}abcdef${RESET}`, 3)).toBe(`${RED}abc${RESET}`); + expect(clipStyledLine(`${BOLD}${RED}abcdef`, 2)).toBe(`${BOLD}${RED}ab${RESET}`); + }); + + it('adds no reset when the kept part already reset', () => { + expect(clipStyledLine(`${RED}ab${RESET}cdef`, 4)).toBe(`${RED}ab${RESET}cd`); + }); + + it('never emits half of a double-width character', () => { + expect(clipStyledLine('中文abc', 3)).toBe('中'); + expect(clipStyledLine('中文', 4)).toBe('中文'); + expect(visibleWidth(clipStyledLine('中文abc', 3))).toBe(2); + }); + + it('never splits a surrogate pair or a combining sequence', () => { + expect(clipStyledLine('\u{1f600}x', 2)).toBe('\u{1f600}'); + expect(clipStyledLine('\u{1f600}x', 1)).toBe(''); + expect(clipStyledLine('éx', 1)).toBe('é'); + }); + + it('drops escape sequences that sit past the clip point', () => { + expect(clipStyledLine(`ab${RED}cd`, 2)).toBe('ab'); + }); +}); + +describe('padDisplay', () => { + it('pads short text and clips long text', () => { + expect(padDisplay('ab', 5)).toBe('ab '); + expect(padDisplay('abcdef', 3)).toBe('abc'); + expect(padDisplay('abc', 3)).toBe('abc'); + expect(padDisplay('abc', 0)).toBe(''); + }); + + it('pads to the exact display width around a wide-character boundary', () => { + expect(visibleWidth(padDisplay('中文', 3))).toBe(3); + expect(padDisplay('中文', 3)).toBe('中 '); + expect(visibleWidth(padDisplay(`${RED}中${RESET}x`, 6))).toBe(6); + }); +}); + +describe('stripStyles', () => { + it('removes every escape sequence and control character', () => { + expect(stripStyles(`${RED}red${RESET}`)).toBe('red'); + expect(stripStyles('\x1b]0;t\x07a\x1b[?25lb')).toBe('ab'); + expect(stripStyles('a\u{1f600}中')).toBe('a\u{1f600}中'); + }); +}); From f161c4ea00d83862bc20772acfcd28d502770f60 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 18:54:45 +0200 Subject: [PATCH 10/57] feat: add the TUI raw-mode key parser Decodes printable UTF-8, the control keys, arrows in both CSI and SS3 forms and SGR mouse reports out of a byte stream that can tear anywhere, so a sequence split across two reads decodes the same as one that arrives whole. A lone ESC cannot be told from the start of an arrow key by looking at bytes, so the parser holds it and the caller resolves it with flush() once its disambiguation timer fires. Unknown sequences are swallowed: a stray CSI must never reach a prompt composer as typed text. Co-Authored-By: Claude Fable 5 --- src/tui/tui-keys.ts | 222 ++++++++++++++++++++++++++++++++++++++ test/tui/tui-keys.test.ts | 210 ++++++++++++++++++++++++++++++++++++ 2 files changed, 432 insertions(+) create mode 100644 src/tui/tui-keys.ts create mode 100644 test/tui/tui-keys.test.ts diff --git a/src/tui/tui-keys.ts b/src/tui/tui-keys.ts new file mode 100644 index 00000000..fb476e3a --- /dev/null +++ b/src/tui/tui-keys.ts @@ -0,0 +1,222 @@ +/** + * @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: '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 = { + A: 'up', + B: 'down', + C: 'right', + D: 'left', + H: 'home', + F: 'end', +}; + +/** `CSI ~` keys, by their first numeric parameter. */ +const CSI_TILDE_KEYS: Record = { + 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 `): 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 }; + } + + // Anything that is not a CSI is a lone ESC as far as we are concerned; the + // next byte then parses on its own (so Alt+x reads as Escape then `x`). + if (second !== 0x5b) 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]; + 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; + }, + }; +} diff --git a/test/tui/tui-keys.test.ts b/test/tui/tui-keys.test.ts new file mode 100644 index 00000000..e94af8c2 --- /dev/null +++ b/test/tui/tui-keys.test.ts @@ -0,0 +1,210 @@ +/** + * @fileoverview Unit tests for the raw-mode key parser. + * + * The two failure modes that matter are covered explicitly: a sequence that + * arrives split across reads must decode identically at EVERY split position + * (a terminal is free to break a chunk anywhere), and an unknown sequence must + * be swallowed rather than leaked as typed text. + */ +import { describe, it, expect } from 'vitest'; +import { createKeyParser, type TuiInputEvent } from '../../src/tui/tui-keys.js'; + +/** Feed a whole sequence in one go. */ +function decode(input: string | Buffer): TuiInputEvent[] { + return createKeyParser().feed(input); +} + +/** Feed the same bytes split at `at`, so a torn read must not change the result. */ +function decodeSplit(bytes: Buffer, at: number): TuiInputEvent[] { + const parser = createKeyParser(); + return [...parser.feed(bytes.subarray(0, at)), ...parser.feed(bytes.subarray(at))]; +} + +describe('printable input', () => { + it('emits one event per code point', () => { + expect(decode('ab')).toEqual([ + { type: 'char', value: 'a' }, + { type: 'char', value: 'b' }, + ]); + }); + + it('decodes multi-byte UTF-8', () => { + expect(decode('é中')).toEqual([ + { type: 'char', value: 'é' }, + { type: 'char', value: '中' }, + ]); + expect(decode('\u{1f600}')).toEqual([{ type: 'char', value: '\u{1f600}' }]); + }); + + it('holds a UTF-8 character split across chunks', () => { + const bytes = Buffer.from('中', 'utf8'); + const parser = createKeyParser(); + expect(parser.feed(bytes.subarray(0, 1))).toEqual([]); + expect(parser.pending()).toBe(1); + expect(parser.feed(bytes.subarray(1, 2))).toEqual([]); + expect(parser.feed(bytes.subarray(2))).toEqual([{ type: 'char', value: '中' }]); + expect(parser.pending()).toBe(0); + }); + + it('decodes a 4-byte character at every split position', () => { + const bytes = Buffer.from('\u{1f600}', 'utf8'); + for (let at = 0; at <= bytes.length; at++) { + expect(decodeSplit(bytes, at)).toEqual([{ type: 'char', value: '\u{1f600}' }]); + } + }); + + it('swallows invalid UTF-8 rather than typing a replacement character', () => { + expect(decode(Buffer.from([0xc3, 0x28]))).toEqual([{ type: 'char', value: '(' }]); + }); +}); + +describe('control keys', () => { + it('maps Enter, Tab and Backspace', () => { + expect(decode('\r')).toEqual([{ type: 'enter' }]); + expect(decode('\n')).toEqual([{ type: 'enter' }]); + expect(decode('\t')).toEqual([{ type: 'tab' }]); + expect(decode('\x7f')).toEqual([{ type: 'backspace' }]); + expect(decode('\x08')).toEqual([{ type: 'backspace' }]); + }); + + it('maps Ctrl+letter, keeping Ctrl+I and Ctrl+M as Tab and Enter', () => { + expect(decode('\x03')).toEqual([{ type: 'ctrl', key: 'c' }]); + expect(decode('\x17')).toEqual([{ type: 'ctrl', key: 'w' }]); + expect(decode('\x01')).toEqual([{ type: 'ctrl', key: 'a' }]); + expect(decode('\x09')).toEqual([{ type: 'tab' }]); + expect(decode('\x0d')).toEqual([{ type: 'enter' }]); + expect(decode('\x00')).toEqual([{ type: 'ctrl', key: '@' }]); + }); +}); + +describe('escape sequences', () => { + it('decodes CSI arrows, Home and End', () => { + expect(decode('\x1b[A')).toEqual([{ type: 'key', name: 'up' }]); + expect(decode('\x1b[B')).toEqual([{ type: 'key', name: 'down' }]); + expect(decode('\x1b[C')).toEqual([{ type: 'key', name: 'right' }]); + expect(decode('\x1b[D')).toEqual([{ type: 'key', name: 'left' }]); + expect(decode('\x1b[H')).toEqual([{ type: 'key', name: 'home' }]); + expect(decode('\x1b[F')).toEqual([{ type: 'key', name: 'end' }]); + }); + + it('decodes the SS3 variants application-cursor mode sends', () => { + expect(decode('\x1bOA')).toEqual([{ type: 'key', name: 'up' }]); + expect(decode('\x1bOD')).toEqual([{ type: 'key', name: 'left' }]); + expect(decode('\x1bOH')).toEqual([{ type: 'key', name: 'home' }]); + expect(decode('\x1bOP')).toEqual([]); + }); + + it('decodes the numbered CSI keys', () => { + expect(decode('\x1b[2~')).toEqual([{ type: 'key', name: 'insert' }]); + expect(decode('\x1b[3~')).toEqual([{ type: 'key', name: 'delete' }]); + expect(decode('\x1b[5~')).toEqual([{ type: 'key', name: 'pageup' }]); + expect(decode('\x1b[6~')).toEqual([{ type: 'key', name: 'pagedown' }]); + expect(decode('\x1b[1~')).toEqual([{ type: 'key', name: 'home' }]); + expect(decode('\x1b[4~')).toEqual([{ type: 'key', name: 'end' }]); + }); + + it('ignores modifiers on an arrow rather than dropping the key', () => { + expect(decode('\x1b[1;5A')).toEqual([{ type: 'key', name: 'up' }]); + }); + + it('swallows unknown sequences instead of leaking them as text', () => { + expect(decode('\x1b[Z')).toEqual([]); + expect(decode('\x1b[999~')).toEqual([]); + expect(decode('\x1b[?1049h')).toEqual([]); + expect(decode('\x1b[200~hi\x1b[201~')).toEqual([ + { type: 'char', value: 'h' }, + { type: 'char', value: 'i' }, + ]); + }); + + it('consumes the payload of an X10 mouse report', () => { + expect(decode('\x1b[M !!x')).toEqual([{ type: 'char', value: 'x' }]); + }); + + it('reads ESC followed by a letter as Escape then that letter', () => { + expect(decode('\x1bx')).toEqual([{ type: 'escape' }, { type: 'char', value: 'x' }]); + }); +}); + +describe('lone escape', () => { + it('holds a trailing ESC until the caller flushes', () => { + const parser = createKeyParser(); + expect(parser.feed('\x1b')).toEqual([]); + expect(parser.pending()).toBe(1); + expect(parser.flush()).toEqual([{ type: 'escape' }]); + expect(parser.pending()).toBe(0); + }); + + it('completes the sequence instead when the rest arrives', () => { + const parser = createKeyParser(); + expect(parser.feed('\x1b')).toEqual([]); + expect(parser.feed('[A')).toEqual([{ type: 'key', name: 'up' }]); + expect(parser.flush()).toEqual([]); + }); + + it('turns a half-typed sequence into Escape plus its characters', () => { + const parser = createKeyParser(); + expect(parser.feed('\x1b[')).toEqual([]); + expect(parser.flush()).toEqual([{ type: 'escape' }, { type: 'char', value: '[' }]); + }); +}); + +describe('SGR mouse', () => { + it('decodes press and release with 1-based coordinates', () => { + expect(decode('\x1b[<0;12;34M')).toEqual([{ type: 'mouse', kind: 'press', x: 12, y: 34, button: 0 }]); + expect(decode('\x1b[<0;12;34m')).toEqual([{ type: 'mouse', kind: 'release', x: 12, y: 34, button: 0 }]); + }); + + it('decodes the wheel', () => { + expect(decode('\x1b[<64;3;4M')).toEqual([{ type: 'mouse', kind: 'wheel-up', x: 3, y: 4, button: 64 }]); + expect(decode('\x1b[<65;3;4M')).toEqual([{ type: 'mouse', kind: 'wheel-down', x: 3, y: 4, button: 65 }]); + }); + + it('swallows drag/motion reports', () => { + expect(decode('\x1b[<32;5;6M')).toEqual([]); + }); + + it('swallows a malformed report', () => { + expect(decode('\x1b[<0;12M')).toEqual([]); + }); +}); + +describe('torn reads', () => { + const cases: Array<[string, TuiInputEvent[]]> = [ + ['\x1b[A', [{ type: 'key', name: 'up' }]], + ['\x1b[6~', [{ type: 'key', name: 'pagedown' }]], + ['\x1b[<64;3;4M', [{ type: 'mouse', kind: 'wheel-up', x: 3, y: 4, button: 64 }]], + ['\x1bOB', [{ type: 'key', name: 'down' }]], + ['\x1b[1;5C', [{ type: 'key', name: 'right' }]], + ]; + + for (const [sequence, expected] of cases) { + it(`decodes ${JSON.stringify(sequence)} at every split position`, () => { + const bytes = Buffer.from(sequence, 'utf8'); + for (let at = 0; at <= bytes.length; at++) { + expect(decodeSplit(bytes, at)).toEqual(expected); + } + }); + } + + it('decodes a mixed burst split anywhere', () => { + const bytes = Buffer.from('a\x1b[Bx\r\x1b[<65;1;1M', 'utf8'); + const expected: TuiInputEvent[] = [ + { type: 'char', value: 'a' }, + { type: 'key', name: 'down' }, + { type: 'char', value: 'x' }, + { type: 'enter' }, + { type: 'mouse', kind: 'wheel-down', x: 1, y: 1, button: 65 }, + ]; + for (let at = 0; at <= bytes.length; at++) { + expect(decodeSplit(bytes, at)).toEqual(expected); + } + }); + + it('drops a garbage burst whole instead of wedging or leaking it', () => { + const parser = createKeyParser(); + expect(parser.feed(`\x1b[${'1'.repeat(200)}`)).toEqual([]); + expect(parser.pending()).toBe(0); + expect(parser.feed('\x1b[A')).toEqual([{ type: 'key', name: 'up' }]); + }); +}); From 44ad932447a97777f443161d1c735e64fae64da5 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 18:54:53 +0200 Subject: [PATCH 11/57] feat: add the TUI session model, classification and cursor Rows are the ones GET /api/sessions/unified already returns and blocked states are the items the approvals inbox already parsed, both imported as types only so a CLI process pulls in neither the server nor node-pty. Classification speaks the web UI's language (red blocked, yellow waiting, green working) so a user with both surfaces open never has to translate between them. Groups order by how long a session has been in its state, which is why WORKING anchors on the pane's last Enter: a working pane repaints about once a second, so its last-activity stamp always says "now". Selection is tracked by session id, never by row index: rows re-sort under the cursor whenever a session starts working or an approval lands, and an index-tracked cursor would quietly move the selection to another session between two keystrokes. Co-Authored-By: Claude Fable 5 --- src/tui/tui-model.ts | 389 +++++++++++++++++++++++++++++++++++++ src/tui/tui-types.ts | 125 ++++++++++++ test/tui/tui-model.test.ts | 283 +++++++++++++++++++++++++++ 3 files changed, 797 insertions(+) create mode 100644 src/tui/tui-model.ts create mode 100644 src/tui/tui-types.ts create mode 100644 test/tui/tui-model.test.ts diff --git a/src/tui/tui-model.ts b/src/tui/tui-model.ts new file mode 100644 index 00000000..81e94ca6 --- /dev/null +++ b/src/tui/tui-model.ts @@ -0,0 +1,389 @@ +/** + * @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 { ApprovalItem } from '../web/approval-inbox.js'; +import type { + TuiConfirmState, + TuiConnectionStatus, + TuiGroup, + TuiGroupKey, + TuiHeaderInfo, + TuiMessage, + TuiPreview, + TuiRenderModel, + TuiRow, + 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 = { + 'needs-you': 'NEEDS YOU', + working: 'WORKING', + idle: 'IDLE', + recent: 'RECENT', +}; + +const STATE_GROUP: Record = { + '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 = 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 = { + '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)[key] = value; + } + merged.sources = incoming.sources?.length ? [...incoming.sources] : [...(existing.sources ?? [])]; + return merged; +} + +/** + * 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(); + private approvalsBySession = new Map(); + + selectedId: string | null = null; + connection: TuiConnectionStatus = 'connected'; + mode: TuiUiMode = 'list'; + header: TuiHeaderInfo = {}; + preview: TuiPreview | null = null; + message: TuiMessage | null = null; + confirm: TuiConfirmState | null = null; + recentLimit: number; + + constructor(options: GroupOptions = {}) { + this.recentLimit = Math.max(0, Math.floor(options.recentLimit ?? DEFAULT_RECENT_LIMIT)); + } + + // ── 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); + }); + } + + approvalFor(sessionId: string): ApprovalItem | undefined { + return this.approvalsBySession.get(sessionId); + } + + sessions(): TuiSessionRow[] { + return [...this.sessionsById.values()]; + } + + // ── Chrome ───────────────────────────────────────────────────────────────── + + setConnection(status: TuiConnectionStatus): void { + this.connection = status; + } + + setHeader(header: TuiHeaderInfo): void { + this.header = { ...this.header, ...header }; + } + + setPreview(preview: TuiPreview | null): void { + this.preview = preview; + } + + setMode(mode: TuiUiMode): void { + this.mode = mode; + } + + setMessage(message: TuiMessage | null): void { + this.message = message; + this.mode = message ? 'message' : 'list'; + } + + /** Arm the typed-name confirmation for `x` (kill). */ + beginConfirmKill(row: TuiRow): void { + this.confirm = { + sessionId: row.session.sessionId, + name: row.session.name ?? row.session.sessionId.slice(0, 8), + typed: '', + }; + this.mode = 'confirm-kill'; + } + + setConfirmInput(typed: string): void { + if (this.confirm) this.confirm = { ...this.confirm, typed }; + } + + /** Does the typed text authorize the kill? Exact match on the name shown. */ + confirmSatisfied(): boolean { + return this.confirm !== null && this.confirm.typed.trim() === this.confirm.name; + } + + /** Drop whatever overlay owns the keyboard and go back to the list. */ + closeOverlay(): void { + this.confirm = null; + this.message = null; + this.mode = 'list'; + } + + // ── 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.selectedId = 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.selectedId = null; + return; + } + const current = this.indexOfSelected(rows); + if (current < 0) { + this.selectedId = 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.selectedId = 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.selectedId = rows[index].session.sessionId; + return true; + } + + 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(); + const rows = this.rows(); + if (rows.length === 0) { + this.selectedId = 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.selectedId = rows[index].session.sessionId; + } +} + +export function createTuiModel(options: GroupOptions = {}): TuiModelStore { + return new TuiModelStore(options); +} diff --git a/src/tui/tui-types.ts b/src/tui/tui-types.ts new file mode 100644 index 00000000..4609b8b0 --- /dev/null +++ b/src/tui/tui-types.ts @@ -0,0 +1,125 @@ +/** + * @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'; + +/** + * 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; +} + +/** + * 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' | 'message'; + +/** + * 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; +} + +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; + typed: string; +} + +/** + * 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; + /** Live sessions only (RECENT rows are history, not sessions you have open). */ + readonly sessionCount: number; +} diff --git a/test/tui/tui-model.test.ts b/test/tui/tui-model.test.ts new file mode 100644 index 00000000..9ac08952 --- /dev/null +++ b/test/tui/tui-model.test.ts @@ -0,0 +1,283 @@ +/** + * @fileoverview Unit tests for TUI classification, grouping and the cursor. + * + * Rows are built in the shape `GET /api/sessions/unified` really returns + * (`UnifiedSessionItem`, `sources` and all), and approvals in the shape the + * approvals inbox really emits, so a change to either surface breaks these + * tests rather than the dashboard. + */ +import { describe, it, expect } from 'vitest'; +import type { ApprovalItem } from '../../src/web/approval-inbox.js'; +import { + buildRows, + classifySession, + createTuiModel, + flattenRows, + groupSessions, + mergeSessionRow, +} from '../../src/tui/tui-model.js'; +import type { TuiSessionRow } from '../../src/tui/tui-types.js'; + +const NOW = 1_700_000_000_000; + +function session(overrides: Partial & { sessionId: string }): TuiSessionRow { + return { + sources: ['live'], + name: overrides.sessionId, + mode: 'claude', + status: 'idle', + workingDir: '/home/dev/case', + createdAt: NOW - 60_000, + lastActivityAt: NOW - 60_000, + ...overrides, + }; +} + +function approval(overrides: Partial & { sessionId: string }): ApprovalItem { + return { + id: `${overrides.sessionId}:1`, + sessionName: overrides.sessionId, + kind: 'permission', + createdAt: NOW - 30_000, + ...overrides, + }; +} + +function approvalMap(items: ApprovalItem[]): Map { + return new Map(items.map((item) => [item.sessionId, item])); +} + +describe('classifySession', () => { + it('classifies live sessions by status', () => { + expect(classifySession(session({ sessionId: 'a', status: 'busy' }))).toBe('working'); + expect(classifySession(session({ sessionId: 'a', status: 'idle', isWorking: true }))).toBe('working'); + expect(classifySession(session({ sessionId: 'a', status: 'idle' }))).toBe('idle'); + expect(classifySession(session({ sessionId: 'a', status: 'stopped' }))).toBe('idle'); + }); + + it('puts an errored session in the needs-you tier', () => { + expect(classifySession(session({ sessionId: 'a', status: 'error' }))).toBe('waiting'); + }); + + it('classifies by the pending prompt, which outranks a stale busy status', () => { + const row = session({ sessionId: 'a', status: 'busy' }); + expect(classifySession(row, approval({ sessionId: 'a', kind: 'permission' }))).toBe('blocked-permission'); + expect(classifySession(row, approval({ sessionId: 'a', kind: 'question' }))).toBe('blocked-question'); + expect(classifySession(row, approval({ sessionId: 'a', kind: 'idle' }))).toBe('waiting'); + }); + + it('classifies a row the server no longer has live as history', () => { + expect(classifySession(session({ sessionId: 'a', sources: ['history'], status: 'busy' }))).toBe('recent'); + expect(classifySession(session({ sessionId: 'a', sources: ['persisted', 'lifecycle'] }))).toBe('recent'); + expect(classifySession(session({ sessionId: 'a', sources: ['history', 'live'] }))).toBe('idle'); + }); +}); + +describe('groupSessions', () => { + it('always returns the four groups in display order', () => { + expect(groupSessions([]).map((group) => group.key)).toEqual(['needs-you', 'working', 'idle', 'recent']); + expect(groupSessions([]).map((group) => group.label)).toEqual(['NEEDS YOU', 'WORKING', 'IDLE', 'RECENT']); + }); + + it('orders NEEDS YOU by how long each has been blocked, longest first', () => { + const sessions = [session({ sessionId: 'fresh' }), session({ sessionId: 'old' }), session({ sessionId: 'middle' })]; + const approvals = approvalMap([ + approval({ sessionId: 'fresh', createdAt: NOW - 5_000 }), + approval({ sessionId: 'old', createdAt: NOW - 900_000, kind: 'idle' }), + approval({ sessionId: 'middle', createdAt: NOW - 60_000, kind: 'question' }), + ]); + const groups = groupSessions(buildRows(sessions, approvals)); + expect(groups[0].rows.map((row) => row.session.sessionId)).toEqual(['old', 'middle', 'fresh']); + }); + + it('orders WORKING by turn start, longest-running first', () => { + const sessions = [ + session({ sessionId: 'short', status: 'busy', lastSubmitAt: NOW - 10_000, lastActivityAt: NOW }), + session({ sessionId: 'long', status: 'busy', lastSubmitAt: NOW - 3_600_000, lastActivityAt: NOW }), + session({ sessionId: 'nosubmit', status: 'busy', createdAt: NOW - 500, lastActivityAt: NOW }), + ]; + const groups = groupSessions(buildRows(sessions)); + expect(groups[1].rows.map((row) => row.session.sessionId)).toEqual(['long', 'short', 'nosubmit']); + }); + + it('orders IDLE and RECENT newest first', () => { + const sessions = [ + session({ sessionId: 'i-old', lastActivityAt: NOW - 900_000 }), + session({ sessionId: 'i-new', lastActivityAt: NOW - 1_000 }), + session({ sessionId: 'h-old', sources: ['history'], lastActivityAt: NOW - 86_400_000 }), + session({ sessionId: 'h-new', sources: ['history'], lastActivityAt: NOW - 3_600_000 }), + ]; + const groups = groupSessions(buildRows(sessions)); + expect(groups[2].rows.map((row) => row.session.sessionId)).toEqual(['i-new', 'i-old']); + expect(groups[3].rows.map((row) => row.session.sessionId)).toEqual(['h-new', 'h-old']); + }); + + it('caps RECENT', () => { + const sessions = Array.from({ length: 20 }, (_, i) => + session({ sessionId: `h${i}`, sources: ['history'], lastActivityAt: NOW - i * 1000 }) + ); + expect(groupSessions(buildRows(sessions))[3].rows).toHaveLength(8); + expect(groupSessions(buildRows(sessions), { recentLimit: 3 })[3].rows.map((r) => r.session.sessionId)).toEqual([ + 'h0', + 'h1', + 'h2', + ]); + expect(groupSessions(buildRows(sessions), { recentLimit: 0 })[3].rows).toEqual([]); + }); + + it('sorts deterministically when the anchors tie', () => { + const sessions = [ + session({ sessionId: 'b', lastActivityAt: NOW }), + session({ sessionId: 'a', lastActivityAt: NOW }), + ]; + expect(groupSessions(buildRows(sessions))[2].rows.map((row) => row.session.sessionId)).toEqual(['a', 'b']); + }); + + it('sorts an unknown anchor last in both directions', () => { + const withAnchor = session({ sessionId: 'known', lastActivityAt: NOW - 1000 }); + const without = session({ sessionId: 'unknown', lastActivityAt: undefined, createdAt: undefined }); + const idle = groupSessions(buildRows([without, withAnchor]))[2]; + expect(idle.rows.map((row) => row.session.sessionId)).toEqual(['known', 'unknown']); + }); +}); + +describe('mergeSessionRow', () => { + it('keeps fields the incoming row does not carry', () => { + const existing = session({ sessionId: 'a', firstPrompt: 'hello', inputTokens: 10 }); + const merged = mergeSessionRow(existing, { sessionId: 'a', sources: ['live'], status: 'busy' }); + expect(merged.firstPrompt).toBe('hello'); + expect(merged.inputTokens).toBe(10); + expect(merged.status).toBe('busy'); + }); + + it('lets a session lose its live source when the server drops it', () => { + const existing = session({ sessionId: 'a', sources: ['live', 'persisted'] }); + const merged = mergeSessionRow(existing, { sessionId: 'a', sources: ['history'] }); + expect(merged.sources).toEqual(['history']); + expect(classifySession(merged)).toBe('recent'); + }); +}); + +describe('the store', () => { + it('selects the first row as soon as there is one', () => { + const model = createTuiModel(); + expect(model.selectedSession()).toBeNull(); + model.replaceSessions([session({ sessionId: 'a' }), session({ sessionId: 'b' })]); + expect(model.selectedId).toBe(model.rows()[0].session.sessionId); + }); + + it('moves the cursor over rows only, wrapping at both ends', () => { + const model = createTuiModel(); + model.replaceSessions([ + session({ sessionId: 'needs' }), + session({ sessionId: 'work', status: 'busy', lastSubmitAt: NOW - 1000 }), + session({ sessionId: 'idle' }), + session({ sessionId: 'past', sources: ['history'] }), + ]); + model.setApprovals([approval({ sessionId: 'needs' })]); + // One row per group: the cursor must cross the group headers without stopping. + expect(model.rows().map((row) => row.session.sessionId)).toEqual(['needs', 'work', 'idle', 'past']); + + model.select('needs'); + model.moveCursor(1); + expect(model.selectedId).toBe('work'); + model.moveCursor(-1); + expect(model.selectedId).toBe('needs'); + model.moveCursor(-1); + expect(model.selectedId).toBe('past'); + model.moveCursor(1); + expect(model.selectedId).toBe('needs'); + }); + + it('jumps by 1-based index and refuses one that is off the list', () => { + const model = createTuiModel(); + model.replaceSessions([ + session({ sessionId: 'a', lastActivityAt: NOW }), + session({ sessionId: 'b', lastActivityAt: NOW - 1 }), + session({ sessionId: 'c', lastActivityAt: NOW - 2 }), + ]); + expect(model.cursorToIndex(3)).toBe(true); + expect(model.selectedId).toBe('c'); + expect(model.cursorToIndex(9)).toBe(false); + expect(model.selectedId).toBe('c'); + expect(model.cursorToIndex(0)).toBe(false); + }); + + it('keeps the selection on its session when the rows re-sort under it', () => { + const model = createTuiModel(); + model.replaceSessions([ + session({ sessionId: 'a', lastActivityAt: NOW }), + session({ sessionId: 'b', lastActivityAt: NOW - 1000 }), + ]); + model.select('b'); + expect(model.rows()[1].session.sessionId).toBe('b'); + + // b becomes blocked and jumps to the top of the list. + model.setApprovals([approval({ sessionId: 'b' })]); + expect(model.rows()[0].session.sessionId).toBe('b'); + expect(model.selectedId).toBe('b'); + expect(model.selectedSession()?.state).toBe('blocked-permission'); + }); + + it('hands the cursor to whatever takes the place of a removed session', () => { + const model = createTuiModel(); + model.replaceSessions([ + session({ sessionId: 'a', lastActivityAt: NOW }), + session({ sessionId: 'b', lastActivityAt: NOW - 1 }), + session({ sessionId: 'c', lastActivityAt: NOW - 2 }), + ]); + model.select('b'); + model.removeSession('b'); + expect(model.selectedId).toBe('c'); + model.replaceSessions([session({ sessionId: 'a', lastActivityAt: NOW })]); + expect(model.selectedId).toBe('a'); + model.replaceSessions([]); + expect(model.selectedId).toBeNull(); + expect(model.selectedSession()).toBeNull(); + }); + + it('merges an SSE update into the row it already has', () => { + const model = createTuiModel(); + model.replaceSessions([session({ sessionId: 'a', firstPrompt: 'first thing' })]); + model.upsertSession({ sessionId: 'a', sources: ['live'], status: 'busy', inputTokens: 5 }); + const row = model.rows()[0]; + expect(row.state).toBe('working'); + expect(row.session.firstPrompt).toBe('first thing'); + expect(row.session.inputTokens).toBe(5); + }); + + it('counts only live sessions', () => { + const model = createTuiModel(); + model.replaceSessions([ + session({ sessionId: 'a' }), + session({ sessionId: 'b' }), + session({ sessionId: 'h', sources: ['history'] }), + ]); + expect(model.sessionCount).toBe(2); + expect(flattenRows(model.groups())).toHaveLength(3); + }); + + it('tracks the confirm-kill overlay and only accepts the exact name', () => { + const model = createTuiModel(); + model.replaceSessions([session({ sessionId: 'a', name: 'w4-api' })]); + model.beginConfirmKill(model.rows()[0]); + expect(model.mode).toBe('confirm-kill'); + expect(model.confirmSatisfied()).toBe(false); + model.setConfirmInput('w4-ap'); + expect(model.confirmSatisfied()).toBe(false); + model.setConfirmInput('w4-api'); + expect(model.confirmSatisfied()).toBe(true); + model.closeOverlay(); + expect(model.mode).toBe('list'); + expect(model.confirm).toBeNull(); + }); + + it('drops a session approval along with the session', () => { + const model = createTuiModel(); + model.replaceSessions([session({ sessionId: 'a' })]); + model.setApprovals([approval({ sessionId: 'a' })]); + expect(model.approvalFor('a')).toBeDefined(); + model.removeSession('a'); + expect(model.approvalFor('a')).toBeUndefined(); + }); +}); From b8d00b76be1830bad629eb94c5ae0f79bbb2baa8 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 18:55:00 +0200 Subject: [PATCH 12/57] feat: add the TUI responsive layout math Below 72 columns the preview pane is dropped and rows take two lines, 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 list and the preview takes the rest. Every region is clamped to a non-negative size, so a 5x5 terminal degrades to a header instead of handing the renderer negative widths. Co-Authored-By: Claude Fable 5 --- src/tui/tui-layout.ts | 137 ++++++++++++++++++++++++++++++++++++ test/tui/tui-layout.test.ts | 133 ++++++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 src/tui/tui-layout.ts create mode 100644 test/tui/tui-layout.test.ts diff --git a/src/tui/tui-layout.ts b/src/tui/tui-layout.ts new file mode 100644 index 00000000..664d6da4 --- /dev/null +++ b/src/tui/tui-layout.ts @@ -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 [ ; 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, + }; +} diff --git a/test/tui/tui-layout.test.ts b/test/tui/tui-layout.test.ts new file mode 100644 index 00000000..031a4a60 --- /dev/null +++ b/test/tui/tui-layout.test.ts @@ -0,0 +1,133 @@ +/** + * @fileoverview Unit tests for the responsive layout math. + * + * Two things are pinned here because the renderer trusts them blindly: the + * regions tile the screen exactly (no gaps, no overlap, full coverage), and no + * region is ever negative, however small or absurd the terminal gets. + */ +import { describe, it, expect } from 'vitest'; +import { + computeLayout, + needsBanner, + NARROW_BREAKPOINT, + SIDEBAR_MAX_WIDTH, + SIDEBAR_MIN_WIDTH, + type TuiLayout, +} from '../../src/tui/tui-layout.js'; + +function rects(layout: TuiLayout) { + return [layout.header, layout.banner, layout.body, layout.list, layout.divider, layout.preview, layout.footer]; +} + +function expectSane(layout: TuiLayout): void { + for (const rect of rects(layout)) { + if (!rect) continue; + expect(rect.width).toBeGreaterThanOrEqual(0); + expect(rect.height).toBeGreaterThanOrEqual(0); + expect(rect.row).toBeGreaterThanOrEqual(1); + expect(rect.col).toBeGreaterThanOrEqual(1); + expect(rect.col + rect.width - 1).toBeLessThanOrEqual(Math.max(1, layout.cols)); + if (rect.height > 0) expect(rect.row + rect.height - 1).toBeLessThanOrEqual(layout.rows); + } + expect(layout.header.height + layout.body.height + layout.footer.height).toBe(layout.rows); +} + +describe('computeLayout', () => { + it('stacks header, body and footer with no gap', () => { + const layout = computeLayout(100, 30); + expect(layout.header).toEqual({ row: 1, col: 1, width: 100, height: 1 }); + expect(layout.body.row).toBe(2); + expect(layout.body.height).toBe(28); + expect(layout.footer).toEqual({ row: 30, col: 1, width: 100, height: 1 }); + expectSane(layout); + }); + + it('splits a wide body into sidebar, divider and preview covering every column', () => { + const layout = computeLayout(100, 30); + expect(layout.narrow).toBe(false); + expect(layout.rowHeight).toBe(1); + expect(layout.list.width).toBe(36); + expect(layout.divider).toEqual({ row: 2, col: 37, width: 1, height: 28 }); + expect(layout.preview).toEqual({ row: 2, col: 38, width: 63, height: 28 }); + expect(layout.list.width + 1 + (layout.preview?.width ?? 0)).toBe(layout.cols); + }); + + it('clamps the sidebar at both ends', () => { + expect(computeLayout(NARROW_BREAKPOINT, 30).list.width).toBe(SIDEBAR_MIN_WIDTH); + expect(computeLayout(200, 30).list.width).toBe(SIDEBAR_MAX_WIDTH); + expect(computeLayout(400, 30).list.width).toBe(SIDEBAR_MAX_WIDTH); + }); + + it('drops the preview and doubles the row height below the breakpoint', () => { + const narrow = computeLayout(NARROW_BREAKPOINT - 1, 24); + expect(narrow.narrow).toBe(true); + expect(narrow.rowHeight).toBe(2); + expect(narrow.preview).toBeNull(); + expect(narrow.divider).toBeNull(); + expect(narrow.list.width).toBe(NARROW_BREAKPOINT - 1); + expect(computeLayout(NARROW_BREAKPOINT, 24).narrow).toBe(false); + expectSane(narrow); + }); + + it('carves the banner out of the top of the body when asked', () => { + const plain = computeLayout(100, 30); + const banner = computeLayout(100, 30, { banner: true }); + expect(plain.banner).toBeNull(); + expect(banner.banner).toEqual({ row: 2, col: 1, width: 100, height: 1 }); + expect(banner.body.height).toBe(plain.body.height); + expect(banner.list.row).toBe(plain.list.row + 1); + expect(banner.list.height).toBe(plain.list.height - 1); + expectSane(banner); + }); + + it('degrades on a tiny terminal without producing negative sizes', () => { + for (const [cols, rows] of [ + [5, 5], + [1, 1], + [1, 2], + [3, 3], + [80, 2], + [80, 1], + ] as const) { + const layout = computeLayout(cols, rows, { banner: true }); + expectSane(layout); + // The shape is width-driven, never height-driven: an 80x1 terminal is + // still a wide one, it just has nowhere to put the body. + expect(layout.narrow).toBe(cols < NARROW_BREAKPOINT); + } + const one = computeLayout(1, 1); + expect(one.body.height).toBe(0); + expect(one.footer.height).toBe(0); + const two = computeLayout(40, 2); + expect(two.body.height).toBe(0); + expect(two.banner).toBeNull(); + expect(two.footer.height).toBe(1); + }); + + it('clamps nonsense dimensions to one cell', () => { + for (const [cols, rows] of [ + [0, 0], + [-10, -10], + [Number.NaN, Number.NaN], + ] as const) { + const layout = computeLayout(cols, rows); + expect(layout.cols).toBe(1); + expect(layout.rows).toBe(1); + expectSane(layout); + } + }); + + it('floors fractional dimensions', () => { + expect(computeLayout(100.9, 30.9).cols).toBe(100); + expect(computeLayout(100.9, 30.9).rows).toBe(30); + }); +}); + +describe('needsBanner', () => { + it('is the caller-side rule for reserving the banner row', () => { + expect(needsBanner('connected')).toBe(false); + expect(needsBanner('reconnecting')).toBe(true); + expect(needsBanner('degraded')).toBe(true); + expect(needsBanner('down')).toBe(true); + }); +}); From f0138fbe47bbec634083b157f40d5e350bc7b7d5 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 18:55:01 +0200 Subject: [PATCH 13/57] feat: render TUI frames from the model and layout One absolutely-addressed line per row, each closed with an erase-to-end, so nothing scrolls and a repaint cannot leave the previous frame's tail behind. The caller wraps the result in synchronized-output brackets; that is an IO decision and stays out of the renderer. Color is passed in rather than detected. chalk's detection is right for the one-shot CLI but would make a frame non-deterministic, so the palette is raw SGR in the same semantic roles cli-style uses, and `color: false` emits nothing but the cursor addressing, the session's own colors in the preview included. Co-Authored-By: Claude Fable 5 --- src/tui/tui-render.ts | 631 ++++++++++++++++++++++++++++++++++++ test/tui/tui-render.test.ts | 403 +++++++++++++++++++++++ 2 files changed, 1034 insertions(+) create mode 100644 src/tui/tui-render.ts create mode 100644 test/tui/tui-render.test.ts diff --git a/src/tui/tui-render.ts b/src/tui/tui-render.ts new file mode 100644 index 00000000..61522fa6 --- /dev/null +++ b/src/tui/tui-render.ts @@ -0,0 +1,631 @@ +/** + * @fileoverview Pure frame renderer: model + layout in, one string out. + * + * The frame is absolute-addressed, one `ESC [ ;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 type { TuiLayout, TuiRect } from './tui-layout.js'; +import type { TuiGlyphTier, TuiGroup, TuiRenderModel, TuiRow, 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; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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; + +const STATE_COLOR: Record = { + '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; +} + +const UNICODE_GLYPHS: TuiGlyphSet = { + blockedPermission: '⚠', + blockedQuestion: '⚠', + waiting: '✋', + 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): 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`; +} + +/** + * 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 prompt = (session.firstPrompt ?? '').trim(); + if (prompt && prompt !== '(no content)') return prompt; + const base = (session.workingDir ?? '').split('/').filter(Boolean).pop(); + 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 +// ───────────────────────────────────────────────────────────────────────────── + +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 { + const { session } = selected; + const parts = [session.mode ?? 'claude', session.workingDir ?? ''].filter((part) => part !== ''); + const title = ` ${rowLabel(session)} ${glyphs.separator} ${parts.join(` ${glyphs.separator} `)}`; + lines.push(padDisplay(paint(clipStyledLine(title, rect.width), SGR.bold), rect.width)); + } + + const body = previewBody(model, selected, rect, opts); + 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): string[] { + const paint = painterFor(opts.color); + const capacity = Math.max(0, rect.height - 1); + 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.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 +// ───────────────────────────────────────────────────────────────────────────── + +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 left = ` ${paint('codeman', SGR.bold)} ${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> = { + list: (g) => + [ + `${g.updown} select`, + `${g.enter} attach`, + '1-9 jump', + '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) => `type the name ${g.separator} ${g.enter} confirm ${g.separator} esc cancel`, + message: () => 'esc dismiss', + prompt: (g) => `${g.enter} send ${g.separator} esc cancel`, + search: (g) => `${g.enter} open ${g.separator} esc cancel`, +}; + +function renderFooterLine(model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): string { + const paint = painterFor(opts.color); + const glyphs = glyphsFor(opts.glyphs); + const build = FOOTER_KEYS[model.mode] ?? FOOTER_KEYS.list; + return padDisplay(paint(clipStyledLine(` ${build(glyphs)}`, layout.cols), SGR.gray), layout.cols); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Overlays +// ───────────────────────────────────────────────────────────────────────────── + +interface OverlayContent { + title: string; + lines: string[]; +} + +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): string[] { + const pairs: Array<[string, string]> = [ + [`${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'], + ['r', 'resume a recent session'], + ['?', 'this help'], + ['q', 'quit'], + ]; + const keyWidth = Math.max(...pairs.map(([key]) => visibleWidth(key))); + return pairs.map(([key, description]) => `${padDisplay(key, keyWidth)} ${description}`); +} + +function overlayContent(model: TuiRenderModel, opts: TuiRenderOptions, width: number): OverlayContent | null { + const glyphs = glyphsFor(opts.glyphs); + switch (model.mode) { + case 'help': + return { title: 'Keys', lines: helpLines(glyphs) }; + case 'confirm-kill': { + if (!model.confirm) return null; + const { name, typed } = model.confirm; + return { + title: 'Kill session', + lines: [`Kill ${name}?`, '', 'Type the name to confirm:', ` ${typed}_`], + }; + } + 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); + 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(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(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; +} diff --git a/test/tui/tui-render.test.ts b/test/tui/tui-render.test.ts new file mode 100644 index 00000000..21a936b7 --- /dev/null +++ b/test/tui/tui-render.test.ts @@ -0,0 +1,403 @@ +/** + * @fileoverview Unit tests for the frame renderer. + * + * The structural expectations below are full frames with the escapes stripped, + * which is what makes a layout regression readable in a diff; the escape + * sequences themselves are asserted separately, including the promise that + * NO_COLOR leaves nothing but cursor addressing behind. + */ +import { describe, it, expect } from 'vitest'; +import { stripStyles, toDisplayLines, visibleWidth } from '../../src/tui/tui-ansi.js'; +import { computeLayout, needsBanner } from '../../src/tui/tui-layout.js'; +import { createTuiModel, type TuiModelStore } from '../../src/tui/tui-model.js'; +import { + detectGlyphTier, + formatElapsed, + formatTokens, + renderFrame, + rowLabel, + type TuiRenderOptions, +} from '../../src/tui/tui-render.js'; + +const NOW = 1_700_000_000_000; + +const PLAIN: TuiRenderOptions = { color: false, glyphs: 'unicode', tick: 4, now: NOW }; + +function fixture(): TuiModelStore { + const model = createTuiModel(); + model.setHeader({ hostname: 'tnode', version: '1.19.0', planUsage: '5h 32% wk 61%' }); + model.replaceSessions([ + { + sessionId: 'aaa1', + name: 'w4-api-refactor', + mode: 'claude', + status: 'busy', + isWorking: true, + workingDir: '/home/dev/api', + createdAt: NOW - 9_000_000, + lastActivityAt: NOW - 1_000, + lastSubmitAt: NOW - 134_000, + inputTokens: 9_000, + outputTokens: 3_300, + sources: ['live', 'persisted'], + }, + { + sessionId: 'bbb2', + name: 'w6-docs', + mode: 'claude', + status: 'idle', + workingDir: '/home/dev/docs', + createdAt: NOW - 8_000_000, + lastActivityAt: NOW - 660_000, + sources: ['live'], + }, + { + sessionId: 'ccc3', + name: 'w1-codeman', + mode: 'claude', + status: 'busy', + isWorking: true, + workingDir: '/home/dev/codeman', + createdAt: NOW - 10_000_000, + lastActivityAt: NOW, + lastSubmitAt: NOW - 1_020_000, + inputTokens: 40_000, + outputTokens: 5_200, + sources: ['live'], + }, + { + sessionId: 'ddd4', + name: 'w2-gallery', + mode: 'codex', + status: 'idle', + workingDir: '/home/dev/gallery', + createdAt: NOW - 6_000_000, + lastActivityAt: NOW - 7_200_000, + sources: ['live'], + }, + { + sessionId: 'eee5', + firstPrompt: 'fix the release script', + workingDir: '/home/dev/api', + lastActivityAt: NOW - 3 * 86_400_000, + sources: ['history'], + }, + ]); + model.setApprovals([ + { id: 'bbb2:1', sessionId: 'bbb2', sessionName: 'w6-docs', kind: 'idle', createdAt: NOW - 680_000 }, + { + id: 'aaa1:2', + sessionId: 'aaa1', + sessionName: 'w4-api-refactor', + kind: 'permission', + createdAt: NOW - 120_000, + toolName: 'Bash', + }, + ]); + model.select('aaa1'); + model.setPreview({ + sessionId: 'aaa1', + lines: toDisplayLines('\x1b[32mActualizing...\x1b[0m (2m 14s)\nrunning tests\n\x1b[31mwarning\x1b[0m here\n'), + }); + return model; +} + +function render(model: TuiModelStore, cols: number, rows: number, opts: Partial = {}): string { + const layout = computeLayout(cols, rows, { banner: needsBanner(model.connection) }); + return renderFrame(model, layout, { ...PLAIN, ...opts }); +} + +/** The frame as visible lines: escapes stripped, trailing padding trimmed. */ +function frameLines(frame: string): string[] { + return frame + .split(/\x1b\[\d+;1H/) + .slice(1) + .map((part) => stripStyles(part).trimEnd()); +} + +describe('renderFrame structure', () => { + it('paints the wide layout at 100x30', () => { + expect(frameLines(render(fixture(), 100, 30))).toEqual([ + ' codeman tnode · v1.19.0 · 4 sessions · 5h 32% wk 61% ? help q quit', + ' NEEDS YOU ─────────────────────────│ w4-api-refactor · claude · /home/dev/api', + ' 1 w6-docs ✋ 11m│ Actualizing... (2m 14s)', + '▶ 2 w4-api-refactor ⚠ 2m 12.3k│ running tests', + ' WORKING ───────────────────────────│ warning here', + ' 3 w1-codeman ✻ 17m 45.2k│', + ' IDLE ──────────────────────────────│', + ' 4 w2-gallery codex ○ 2h│', + ' RECENT ────────────────────────────│', + ' 5 fix the release script ✔ 3d│', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' ↑↓ select · ⏎ attach · 1-9 jump · y/n answer · p prompt · n new · x kill · / search · g digest · ?', + ]); + }); + + it('paints the narrow two-line layout at 44x20', () => { + expect(frameLines(render(fixture(), 44, 20))).toEqual([ + ' codeman tnode · v1.19.0 · 4 sessions · 5h', + ' NEEDS YOU ─────────────────────────────────', + ' 1 w6-docs ✋ 11m', + ' /home/dev/docs', + '▶ 2 w4-api-refactor ⚠ 2m', + ' /home/dev/api · 12.3k', + ' WORKING ───────────────────────────────────', + ' 3 w1-codeman ✻ 17m', + ' /home/dev/codeman · 45.2k', + ' IDLE ──────────────────────────────────────', + ' 4 w2-gallery codex ○ 2h', + ' /home/dev/gallery · codex', + ' RECENT ────────────────────────────────────', + ' 5 fix the release script ✔ 3d', + ' /home/dev/api', + '', + '', + '', + '', + ' ↑↓ select · ⏎ attach · 1-9 jump · y/n answe', + ]); + }); + + it('addresses every line absolutely and erases its tail', () => { + const frame = render(fixture(), 100, 30); + const addresses = [...frame.matchAll(/\x1b\[(\d+);1H/g)].map((match) => Number(match[1])); + expect(addresses).toEqual(Array.from({ length: 30 }, (_, i) => i + 1)); + expect(frame.split('\x1b[K')).toHaveLength(31); + expect(frame).not.toContain('\n'); + }); + + it('never lets a line exceed the terminal width', () => { + for (const [cols, rows] of [ + [100, 30], + [44, 20], + [72, 8], + [30, 6], + ] as const) { + for (const line of frameLines(render(fixture(), cols, rows))) { + expect(visibleWidth(line)).toBeLessThanOrEqual(cols); + } + } + }); + + it('is deterministic for identical inputs', () => { + expect(render(fixture(), 100, 30)).toBe(render(fixture(), 100, 30)); + }); + + it('degrades to a header-only frame on a 5x5 terminal without throwing', () => { + expect(() => render(fixture(), 5, 5)).not.toThrow(); + expect(frameLines(render(fixture(), 5, 5))).toHaveLength(5); + }); +}); + +describe('color', () => { + it('paints states and chrome when color is on', () => { + const model = fixture(); + model.select('eee5'); + const frame = render(model, 100, 30, { color: true }); + expect(frame).toContain('\x1b[32m✻'); + expect(frame).toContain('\x1b[31m⚠'); + expect(frame).toContain('\x1b[33m✋'); + expect(frame).toContain('\x1b[1mcodeman'); + }); + + it('paints the selected row as one inverse block with no styling inside it', () => { + // An inner reset would punch a hole in the highlight, so the selected row + // is built unpainted and wrapped instead. + const frame = render(fixture(), 100, 30, { color: true }); + const highlighted = frame.split('\x1b[7m')[1]?.split('\x1b[0m')[0] ?? ''; + expect(highlighted).toContain('w4-api-refactor'); + expect(highlighted).toContain('⚠'); + expect(frame).not.toContain('\x1b[31m⚠'); + }); + + it('emits nothing but cursor addressing when color is off', () => { + const frame = render(fixture(), 100, 30, { color: false }); + const withoutAddressing = frame.replace(/\x1b\[\d+;1H/g, '').replace(/\x1b\[K/g, ''); + expect(withoutAddressing).not.toContain('\x1b'); + }); + + it('strips the session own colors out of the preview under NO_COLOR', () => { + const model = fixture(); + model.setPreview({ sessionId: 'aaa1', lines: toDisplayLines('\x1b[31mred tail\x1b[0m') }); + expect(render(model, 100, 30, { color: false })).not.toContain('\x1b[31m'); + expect(render(model, 100, 30, { color: true })).toContain('\x1b[31m'); + }); +}); + +describe('glyph tiers', () => { + it('falls back to bracketed ASCII tokens', () => { + const lines = frameLines(render(fixture(), 100, 30, { glyphs: 'ascii' })); + const list = lines.map((line) => line.split('|')[0]); + expect(list[2]).toContain('[w]'); + expect(list[3]).toContain('[!]'); + expect(list[5]).toContain('[*]'); + expect(list[7]).toContain('[-]'); + expect(list[9]).toContain('[v]'); + expect(list[3].startsWith('>')).toBe(true); + expect(lines.join('')).not.toContain('✻'); + expect(lines.join('')).not.toContain('─'); + }); + + it('animates the working glyph with the tick', () => { + const model = fixture(); + const frames = [0, 1, 2, 3, 4, 5].map((tick) => frameLines(render(model, 100, 30, { tick }))[5]); + expect(frames[0]).toContain('·'); + expect(frames[1]).toContain('✢'); + expect(frames[2]).toContain('✳'); + expect(frames[3]).toContain('∗'); + expect(frames[4]).toContain('✻'); + expect(frames[5]).toContain('✽'); + expect(new Set(frames).size).toBe(6); + }); + + it('detects a tier from the environment', () => { + expect(detectGlyphTier({ TERM: 'xterm-256color', LANG: 'en_US.UTF-8' })).toBe('unicode'); + expect(detectGlyphTier({ TERM: 'xterm-kitty', LANG: 'en_US.UTF-8' })).toBe('nerd'); + expect(detectGlyphTier({ TERM: 'xterm-256color', TERM_PROGRAM: 'iTerm.app', LANG: 'en_US.UTF-8' })).toBe('nerd'); + expect(detectGlyphTier({ TERM: 'xterm-256color', LANG: 'C' })).toBe('ascii'); + expect(detectGlyphTier({ TERM: 'dumb' })).toBe('ascii'); + expect(detectGlyphTier({})).toBe('ascii'); + expect(detectGlyphTier({ TERM: 'xterm-kitty', LANG: 'en_US.UTF-8', CODEMAN_TUI_GLYPHS: 'ascii' })).toBe('ascii'); + }); +}); + +describe('overlays', () => { + it('draws the help box over the body', () => { + const model = fixture(); + model.setMode('help'); + const lines = frameLines(render(model, 100, 30)); + expect(lines.join('\n')).toContain('┌ Keys '); + expect(lines.some((line) => line.includes('attach'))).toBe(true); + expect(lines[lines.length - 1]).toContain('close'); + }); + + it('draws the typed confirmation for a kill', () => { + const model = fixture(); + model.beginConfirmKill(model.rows()[0]); + model.setConfirmInput('w6-d'); + const text = frameLines(render(model, 100, 30)).join('\n'); + expect(text).toContain('Kill w6-docs?'); + expect(text).toContain('Type the name to confirm:'); + expect(text).toContain('w6-d_'); + expect(text).toContain('esc cancel'); + }); + + it('draws a message box', () => { + const model = fixture(); + model.setMessage({ text: 'session refused to start: tmux is not installed', tone: 'err' }); + const text = frameLines(render(model, 100, 30)).join('\n'); + expect(text).toContain('Error'); + expect(text).toContain('tmux is not installed'); + }); + + it('skips the overlay when the body is too small to hold a box', () => { + const model = fixture(); + model.setMode('help'); + expect(frameLines(render(model, 100, 4)).join('\n')).not.toContain('Keys'); + }); +}); + +describe('connection states', () => { + it('banners a degraded server and says the preview is gone', () => { + const model = fixture(); + model.setConnection('degraded'); + const lines = frameLines(render(model, 100, 30)); + expect(lines[1]).toContain('server not running: attach only'); + expect(lines.join('\n')).toContain('preview unavailable while the server is down'); + }); + + it('banners reconnecting and down differently', () => { + const model = fixture(); + model.setConnection('reconnecting'); + expect(frameLines(render(model, 100, 30))[1]).toContain('reconnecting'); + model.setConnection('down'); + expect(frameLines(render(model, 100, 30))[1]).toContain('server unreachable'); + }); +}); + +describe('empty and partial states', () => { + it('shows the empty hint when there are no sessions', () => { + const model = createTuiModel(); + const lines = frameLines(render(model, 100, 30)); + expect(lines.join('\n')).toContain('No sessions. n to start one, q to quit.'); + expect(lines[1]).not.toContain('NEEDS YOU'); + }); + + it('says the preview is still loading when it belongs to another session', () => { + const model = fixture(); + model.setPreview({ sessionId: 'bbb2', lines: ['other session'] }); + const text = frameLines(render(model, 100, 30)).join('\n'); + expect(text).toContain('loading preview…'); + expect(text).not.toContain('other session'); + }); + + it('surfaces a preview error instead of a stale tail', () => { + const model = fixture(); + model.setPreview({ sessionId: 'aaa1', lines: [], error: 'terminal capture failed' }); + expect(frameLines(render(model, 100, 30)).join('\n')).toContain('terminal capture failed'); + }); + + it('scrolls the list so the selected row stays visible', () => { + const model = createTuiModel(); + model.replaceSessions( + Array.from({ length: 30 }, (_, i) => ({ + sessionId: `s${String(i).padStart(2, '0')}`, + name: `session-${String(i).padStart(2, '0')}`, + sources: ['live'], + status: 'idle', + lastActivityAt: NOW - i * 1000, + })) + ); + model.select('s29'); + const text = frameLines(render(model, 100, 12)).join('\n'); + expect(text).toContain('session-29'); + expect(text).not.toContain('session-00'); + }); +}); + +describe('formatting helpers', () => { + it('formats elapsed time compactly', () => { + expect(formatElapsed(0)).toBe('0s'); + expect(formatElapsed(45_000)).toBe('45s'); + expect(formatElapsed(11 * 60_000)).toBe('11m'); + expect(formatElapsed(2 * 3_600_000)).toBe('2h'); + expect(formatElapsed(3 * 86_400_000)).toBe('3d'); + expect(formatElapsed(-1)).toBe(''); + expect(formatElapsed(Number.NaN)).toBe(''); + }); + + it('formats token counts compactly', () => { + expect(formatTokens(0)).toBe(''); + expect(formatTokens(842)).toBe('842'); + expect(formatTokens(45_200)).toBe('45.2k'); + expect(formatTokens(45_000)).toBe('45k'); + expect(formatTokens(1_200_000)).toBe('1.2M'); + }); + + it('names a row the way the web history list does', () => { + expect(rowLabel({ sessionId: 'abcdef12', name: 'w1', sources: [] })).toBe('w1'); + expect(rowLabel({ sessionId: 'abcdef12', firstPrompt: 'do the thing', sources: [] })).toBe('do the thing'); + expect(rowLabel({ sessionId: 'abcdef12', firstPrompt: '(no content)', workingDir: '/a/b/case', sources: [] })).toBe( + 'case' + ); + expect(rowLabel({ sessionId: 'abcdef1234', sources: [] })).toBe('abcdef12'); + }); +}); From 4060696faa9a038061494cd20f141b895bd9e213 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 19:06:56 +0200 Subject: [PATCH 14/57] docs: record why the key parser reads LF as Enter Ctrl+J is unbindable as a result, which is worth knowing before someone tries to bind it. Co-Authored-By: Claude Fable 5 --- src/tui/tui-keys.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tui/tui-keys.ts b/src/tui/tui-keys.ts index fb476e3a..2435f0c4 100644 --- a/src/tui/tui-keys.ts +++ b/src/tui/tui-keys.ts @@ -162,6 +162,8 @@ export function createKeyParser(): TuiKeyParser { /** 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' }] }; From 00ef72c80e8b448b73d5eb1bc9439c5086c8d226 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 19:29:49 +0200 Subject: [PATCH 15/57] refactor: resolve the tmux socket from the instance config The socket name was computed inside tmux-manager, which the TUI cannot import just to learn which `-L` name its degraded-mode listing belongs on (that module is the server's tmux driver, not a lookup table). The resolver moves next to `dataPath()`, where the other half of the instance identity already lives, so both processes agree by construction instead of by a copied default. Behaviour is unchanged: the override still wins only when it is a name that can be passed to `tmux -L` safely, and TmuxManager keeps warning about one that cannot. Co-Authored-By: Claude Fable 5 --- src/config/instance.ts | 15 +++++++++++++++ src/tmux-manager.ts | 14 ++++++++------ test/config/instance.test.ts | 21 ++++++++++++++++++++- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/config/instance.ts b/src/config/instance.ts index 22ed7a30..471a921b 100644 --- a/src/config/instance.ts +++ b/src/config/instance.ts @@ -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; /** diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 1c781a23..0fcaab80 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -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, @@ -194,9 +200,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 +594,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 ` command prefix. Socket name is shell-escaped. */ diff --git a/test/config/instance.test.ts b/test/config/instance.test.ts index 867a526f..8f473d29 100644 --- a/test/config/instance.test.ts +++ b/test/config/instance.test.ts @@ -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 = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); async function load(env: Partial> = {}) { @@ -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'); + }); +}); From 6b7fe7b24b89068304c6cb52fcbe6a1891253586 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 19:30:09 +0200 Subject: [PATCH 16/57] feat: decode the SSE wire format for the TUI Node has no EventSource, so the live-update stream is read as raw bytes and decoded here. Three details are what the parser exists for: a TCP read can end between the CR and the LF of a CRLF, so a trailing CR is held back rather than dispatched; the tunnel padding the server appends after a frame is a comment with no blank line after it and must not split anything; and the keepalive is a NAMED event, because an SSE comment is invisible to a browser client by spec. Event classification lives here too, as a set rather than a prefix test: `session:terminal` is most of the stream and the preview pane pulls its own tail, so it is deliberately not a resync trigger. Co-Authored-By: Claude Fable 5 --- src/tui/tui-sse.ts | 234 +++++++++++++++++++++++++++++++++++++++ test/tui/tui-sse.test.ts | 151 +++++++++++++++++++++++++ 2 files changed, 385 insertions(+) create mode 100644 src/tui/tui-sse.ts create mode 100644 test/tui/tui-sse.test.ts diff --git a/src/tui/tui-sse.ts b/src/tui/tui-sse.ts new file mode 100644 index 00000000..fddc4933 --- /dev/null +++ b/src/tui/tui-sse.ts @@ -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 = new Set([ + SessionCreated, + SessionUpdated, + SessionDeleted, + SessionExit, + SessionError, + SessionIdle, + SessionWorking, + SessionCompletion, + SessionInteractive, + SessionRunning, + SessionPinned, + SessionCliInfo, + MuxCreated, + MuxKilled, + MuxDied, + RemoteSessionDropped, + RemoteSessionReconnected, +]); + +const APPROVAL_EVENTS: ReadonlySet = new Set([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); +} diff --git a/test/tui/tui-sse.test.ts b/test/tui/tui-sse.test.ts new file mode 100644 index 00000000..0b60f89f --- /dev/null +++ b/test/tui/tui-sse.test.ts @@ -0,0 +1,151 @@ +/** + * @fileoverview Unit tests for the TUI's SSE wire decoding and reconnect math. + * + * The frames here are byte-for-byte what `sse-stream-manager.ts` writes + * (`event: \ndata: \n\n`, plus the `:pppp…` tunnel padding line), + * so a change to the server's writer breaks these tests rather than the + * dashboard. + */ +import { describe, it, expect } from 'vitest'; +import { + MAX_PENDING_BYTES, + SSE_MAX_BACKOFF_MS, + SseFrameParser, + approvalEventKind, + classifySseEvent, + sseBackoffDelay, +} from '../../src/tui/tui-sse.js'; + +/** Feed a whole stream one character at a time: every boundary is a split. */ +function feedByChar(parser: SseFrameParser, text: string) { + const frames = []; + for (const ch of text) frames.push(...parser.feed(ch)); + return frames; +} + +describe('SseFrameParser', () => { + it('decodes a plain named frame', () => { + const frames = new SseFrameParser().feed('event: session:created\ndata: {"id":"a"}\n\n'); + expect(frames).toEqual([{ event: 'session:created', data: '{"id":"a"}' }]); + }); + + it('defaults the event name to message', () => { + expect(new SseFrameParser().feed('data: hello\n\n')).toEqual([{ event: 'message', data: 'hello' }]); + }); + + it('survives a frame split across every possible chunk boundary', () => { + const parser = new SseFrameParser(); + const frames = feedByChar( + parser, + 'event: session:updated\ndata: {"id":"b","n":1}\n\nevent: sse:heartbeat\ndata: {}\n\n' + ); + expect(frames).toEqual([ + { event: 'session:updated', data: '{"id":"b","n":1}' }, + { event: 'sse:heartbeat', data: '{}' }, + ]); + }); + + it('joins multi-line data with newlines and strips one leading space per line', () => { + const frames = new SseFrameParser().feed('event: x\ndata: line one\ndata: line two\ndata: indented\n\n'); + expect(frames).toEqual([{ event: 'x', data: 'line one\nline two\n indented' }]); + }); + + it('ignores comments, including the tunnel padding that trails a frame', () => { + const parser = new SseFrameParser(); + const padding = ':' + 'p'.repeat(64) + '\n'; + const frames = parser.feed(`event: a\ndata: 1\n\n${padding}event: b\ndata: 2\n\n`); + expect(frames).toEqual([ + { event: 'a', data: '1' }, + { event: 'b', data: '2' }, + ]); + }); + + it('handles CRLF, including a CR that lands at the end of a chunk', () => { + const parser = new SseFrameParser(); + expect(parser.feed('event: a\r')).toEqual([]); + expect(parser.feed('\ndata: 1\r\n\r\n')).toEqual([{ event: 'a', data: '1' }]); + }); + + it('treats a bare CR as a line end, once a following byte proves it is not half a CRLF', () => { + const parser = new SseFrameParser(); + expect(parser.feed('event: a\rdata: 1\r\r')).toEqual([]); + expect(parser.feed('event: b\rdata: 2\r\r\n')).toEqual([ + { event: 'a', data: '1' }, + { event: 'b', data: '2' }, + ]); + }); + + it('dispatches nothing for a frame with no data, and clears the event name', () => { + const parser = new SseFrameParser(); + expect(parser.feed('event: a\n\n')).toEqual([]); + expect(parser.feed('data: 1\n\n')).toEqual([{ event: 'message', data: '1' }]); + }); + + it('carries id and retry when the server sends them', () => { + const frames = new SseFrameParser().feed('id: 7\nretry: 2500\nevent: a\ndata: 1\n\n'); + expect(frames).toEqual([{ event: 'a', data: '1', id: '7', retry: 2500 }]); + }); + + it('accepts a field with no colon at all', () => { + // Per spec `data` alone means an empty data line, which still dispatches. + expect(new SseFrameParser().feed('data\n\n')).toEqual([{ event: 'message', data: '' }]); + }); + + it('drops a partial frame on reset so a reconnect cannot splice two streams', () => { + const parser = new SseFrameParser(); + parser.feed('event: a\ndata: half'); + parser.reset(); + expect(parser.feed('data: whole\n\n')).toEqual([{ event: 'message', data: 'whole' }]); + }); + + it('discards a pending tail that grows past the guard', () => { + const parser = new SseFrameParser(); + parser.feed('x'.repeat(MAX_PENDING_BYTES + 1)); + expect(parser.feed('data: after\n\n')).toEqual([{ event: 'message', data: 'after' }]); + }); +}); + +describe('classifySseEvent', () => { + it('routes the events the dashboard reacts to', () => { + expect(classifySseEvent('init')).toBe('init'); + expect(classifySseEvent('sse:heartbeat')).toBe('heartbeat'); + expect(classifySseEvent('approval:pending')).toBe('approval'); + expect(classifySseEvent('approval:resolved')).toBe('approval'); + expect(classifySseEvent('session:statusTelemetry')).toBe('plan-usage'); + expect(classifySseEvent('session:created')).toBe('resync'); + expect(classifySseEvent('session:deleted')).toBe('resync'); + expect(classifySseEvent('mux:died')).toBe('resync'); + }); + + it('ignores the high-volume and irrelevant families', () => { + // session:terminal is most of the stream and the preview pulls its own tail. + expect(classifySseEvent('session:terminal')).toBe('ignore'); + expect(classifySseEvent('respawn:log')).toBe('ignore'); + expect(classifySseEvent('subagent:progress')).toBe('ignore'); + expect(classifySseEvent('something:invented')).toBe('ignore'); + }); +}); + +describe('approvalEventKind', () => { + it('names the three approval events and nothing else', () => { + expect(approvalEventKind('approval:pending')).toBe('pending'); + expect(approvalEventKind('approval:updated')).toBe('updated'); + expect(approvalEventKind('approval:resolved')).toBe('resolved'); + expect(approvalEventKind('session:created')).toBeNull(); + }); +}); + +describe('sseBackoffDelay', () => { + it('doubles from the base and stops at the ceiling', () => { + expect(sseBackoffDelay(1)).toBe(500); + expect(sseBackoffDelay(2)).toBe(1000); + expect(sseBackoffDelay(3)).toBe(2000); + expect(sseBackoffDelay(6)).toBe(15_000); + expect(sseBackoffDelay(50)).toBe(SSE_MAX_BACKOFF_MS); + }); + + it('treats a zero or negative attempt as the first one', () => { + expect(sseBackoffDelay(0)).toBe(500); + expect(sseBackoffDelay(-4)).toBe(500); + }); +}); From b828920102acc118a123fd07f97f166e74a419bc Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 19:30:27 +0200 Subject: [PATCH 17/57] feat: add the TUI's API, SSE and degraded-mode client Everything the dashboard needs from outside the process, behind one typed surface, so the app loop stays a loop. It is a client of the running server and nothing else: rows come from the unified list, blocked states from the approvals inbox, and answering goes through the endpoint that re-captures the pane and refuses with a 409 when the dialog has already been answered in tmux. That refusal is a typed result rather than an exception, because a human beating you to a prompt is normal operation. Discovery mirrors the daemon probe (`CODEMAN_API_URL`, else loopback on `CODEMAN_PORT`, self-signed TLS accepted) and credentials come from where `codeman attach` already reads them. An explicit port outranks the ambient `CODEMAN_API_URL`, which every managed session exports: a caller that named a port must not be redirected at whatever server owns its shell. Input is single-line and `\r`-terminated at this layer, so no caller can strand text on an unsubmitted composer, and each send is tagged for the server's exactly-once path. The event stream defaults to a `?sessions=` filter that matches nothing, which drops the terminal firehose while lifecycle, hook and approval events still arrive. A silent-but-open stream is caught by a watchdog rather than a socket error, since that failure mode reports nothing at all. With no server answering, sessions are listed from tmux on the instance socket (argv, never a shell string) and decorated from a read-only peek at state.json, which keeps the "the server died, get me to my sessions" path alive. Co-Authored-By: Claude Fable 5 --- src/tui/tui-client.ts | 997 ++++++++++++++++++++++++++++++++ test/tui/tui-client-sse.test.ts | 206 +++++++ test/tui/tui-client.test.ts | 465 +++++++++++++++ 3 files changed, 1668 insertions(+) create mode 100644 src/tui/tui-client.ts create mode 100644 test/tui/tui-client-sse.test.ts create mode 100644 test/tui/tui-client.test.ts diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts new file mode 100644 index 00000000..80e3fae2 --- /dev/null +++ b/src/tui/tui-client.ts @@ -0,0 +1,997 @@ +/** + * @fileoverview Everything `codeman tui` needs from the outside world. + * + * The TUI is a CLIENT of the running server, never a second brain (see + * docs/tui-plan.md §4): states, approvals and history all come from the same + * API the web UI uses, so the two surfaces can never disagree. This module is + * the only place in `src/tui/` that does IO. It covers four jobs: + * + * 1. **Discovery + auth** — find the instance's server (`CODEMAN_API_URL`, else + * loopback on `CODEMAN_PORT`), accepting the self-signed cert `--https` + * generates, and read credentials the way `codeman attach` already does + * (env, then the data dir's `.env`). + * 2. **Typed API calls** that unwrap the `{success,data}` envelope and throw a + * `TuiApiError` carrying the status and `errorCode` on failure. + * 3. **Live updates** over SSE, decoded by `tui-sse.ts`, with a staleness + * watchdog and capped backoff. The TUI does not patch rows from payloads: an + * interesting event means "resync", and the app layer debounces the refetch. + * 4. **Degraded mode** — when nothing answers, sessions are enumerated straight + * from tmux plus a read-only peek at `state.json`, which keeps the "the + * server died, get me to my sessions" path that `sc` has today. + * + * IMPORT-SAFE: no probing, no timers and no tmux at import time. Everything a + * `TuiClient` starts is owned by it and released by `close()`; a leaked SSE + * socket or watchdog interval would keep the process alive after the TUI exits. + * + * LIMITATIONS (server surfaces that do not exist, worked around here rather + * than by touching `src/web/`): + * - There is no endpoint that reports the server's hostname, so the header's + * hostname is this machine's (`os.hostname()`) unless `CODEMAN_API_URL` + * points somewhere non-loopback, in which case that host is used. + * - Plan usage has no route of its own: the last-known snapshot rides + * `GET /api/status` as `planUsage` (`web/plan-usage-latest.ts`) and updates + * arrive as `session:statusTelemetry` SSE frames. + * - The unified list carries no token counters or turn-start stamp + * (`TuiSessionRow.lastSubmitAt`), so those stay unset here; the app layer + * merges them from live session state when it wants them. + * + * @module tui/tui-client + */ + +import { execFile as execFileCb } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import http from 'node:http'; +import https from 'node:https'; +import { hostname as osHostname } from 'node:os'; +import { promisify } from 'node:util'; +import { CODEMAN_INSTANCE, dataPath, resolveTmuxSocketName } from '../config/instance.js'; +import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { probeServer } from '../daemon-control.js'; +import { getErrorMessage } from '../types/api.js'; +import { + SseFrameParser, + SSE_BASE_BACKOFF_MS, + SSE_MAX_BACKOFF_MS, + SSE_STALE_TIMEOUT_MS, + approvalEventKind, + classifySseEvent, + sseBackoffDelay, +} from './tui-sse.js'; +import type { UnifiedSessionItem } from '../services/unified-session-service.js'; +import type { CaseInfo } from '../types/api.js'; +import type { SearchResponseData } from '../types/search.js'; +import type { StatusTelemetry } from '../usage-telemetry.js'; +import type { ApprovalItem, ApprovalResolvedInfo } from '../web/approval-inbox.js'; +import type { AwayDigestResponse } from '../web/away-digest.js'; + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +/** A non-2xx answer, or a `success:false` envelope. */ +export class TuiApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly errorCode?: string + ) { + super(message); + this.name = 'TuiApiError'; + } +} + +export interface TuiServerInfo { + /** Origin only, no trailing slash: `https://127.0.0.1:3000`. */ + baseUrl: string; + version?: string; + hostname?: string; + /** `CODEMAN_INSTANCE`, empty string for the production layout. */ + instance: string; + /** A server answered but rejected our credentials. */ + authRequired?: boolean; +} + +export interface TuiClientOptions { + /** Skip discovery and talk to this origin. */ + baseUrl?: string; + /** Loopback port to probe. Outranks `CODEMAN_API_URL`, so a caller that names a port cannot be redirected by the ambient environment. */ + port?: number | string; + username?: string; + password?: string; + /** Where credentials come from when none are passed. Defaults to the instance's `.env`. */ + envFilePath?: string; + /** Per-request timeout. Discovery probes use `probeTimeoutMs`. */ + timeoutMs?: number; + probeTimeoutMs?: number; + /** Injected for tests; the default shells out to `tmux` via execFile. */ + exec?: TuiExecFile; + /** Read-only source of names/dirs in degraded mode. Defaults to the instance's. */ + statePath?: string; +} + +/** Plan-usage snapshot as the server broadcasts it (telemetry plus its source). */ +export type TuiPlanUsage = StatusTelemetry & { sessionId?: string }; + +export type TuiApprovalAnswer = + | { action: 'approve' } + | { action: 'deny' } + | { action: 'option'; option: number } + | { action: 'text'; text: string }; + +/** + * Answering is a conversation with a live terminal, so refusal is a normal + * outcome, not an exception: the server re-captures the pane first and 409s + * when the dialog is gone (someone answered it in tmux a second ago). + */ +export type TuiAnswerResult = + | { ok: true; id: string; sessionId: string; action: string } + | { ok: false; reason: 'gone' | 'not-found' | 'rejected' | 'failed'; message: string }; + +export interface TuiQuickStartOptions { + caseName: string; + mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi'; + sessionName?: string; + /** The tab this spawn came from, for the lineage lines (cosmetic, dropped if unresolvable). */ + parentSessionId?: string; +} + +export interface TuiQuickStartResult { + sessionId: string; + casePath?: string; + caseName?: string; +} + +/** Init snapshot, narrowed to the two facts the dashboard header shows. */ +export interface TuiInitState { + version?: string; + planUsage?: TuiPlanUsage | null; +} + +export type TuiApprovalEvent = + | { kind: 'pending'; item: ApprovalItem } + | { kind: 'updated'; item: ApprovalItem } + | { kind: 'resolved'; info: ApprovalResolvedInfo }; + +export type TuiSseStatus = 'connected' | 'reconnecting'; + +export interface TuiSseStatusDetail { + /** Consecutive failed connects; 0 while connected. */ + attempt: number; + /** + * SSE has failed often enough that the app should poll + * `fetchUnifiedSessions()` instead of waiting for events. + */ + recommendPolling: boolean; + message?: string; +} + +export interface TuiEventHandlers { + onInit?(state: TuiInitState): void; + /** Something session-shaped changed; the argument is the event name. */ + onResync?(event: string): void; + onApproval?(event: TuiApprovalEvent): void; + onPlanUsage?(usage: TuiPlanUsage): void; + onStatus?(status: TuiSseStatus, detail: TuiSseStatusDetail): void; +} + +export interface TuiSubscribeOptions { + /** + * Sessions whose `session:terminal` frames this stream wants. The default is + * a sentinel that matches no session id, which suppresses the terminal + * stream (by far the bulk of the wire) without suppressing anything else: + * the `?sessions=` filter applies to terminal frames ONLY, lifecycle and + * hook events still reach every client. The preview pane pulls its own tail + * over HTTP, so the TUI never needs those bytes. + */ + sessionIds?: readonly string[]; + staleTimeoutMs?: number; + /** How often the staleness watchdog fires. Defaults to a third of the timeout. */ + checkIntervalMs?: number; + baseBackoffMs?: number; + maxBackoffMs?: number; + /** Consecutive failed connects before `recommendPolling` flips on. */ + pollingAfterFailures?: number; +} + +export interface TuiEventStream { + close(): void; + readonly status: TuiSseStatus; + readonly recommendPolling: boolean; +} + +export type TuiExecFile = (file: string, args: readonly string[]) => Promise<{ stdout: string; stderr: string }>; + +/** One tmux session as degraded mode sees it. */ +export interface TuiTmuxSession { + muxName: string; + /** The `codeman-` fragment; mux names carry only the first 8 chars of the id. */ + sessionIdPrefix: string; + /** Full id, when `state.json` had exactly one session starting with the prefix. */ + sessionId?: string; + name?: string; + workingDir?: string; + mode?: string; + attached: boolean; + /** Epoch ms from tmux's `session_created` (which reports seconds). */ + createdAt?: number; + windows?: number; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Discovery + credentials (pure halves, exported for tests) +// ───────────────────────────────────────────────────────────────────────────── + +const DEFAULT_PORT = 3000; +const DEFAULT_TIMEOUT_MS = 10_000; +const DEFAULT_PROBE_TIMEOUT_MS = 1_500; +/** Ceiling on a single response body. A tail request asks for far less. */ +const MAX_RESPONSE_BYTES = 16 * 1024 * 1024; + +/** Trim a trailing slash so `new URL(path, base)` never doubles it. */ +function normalizeOrigin(url: string): string { + return url.trim().replace(/\/+$/, ''); +} + +/** + * Origins to probe, in preference order. `CODEMAN_API_URL` (the variable hooks + * already get) wins outright; otherwise both schemes on loopback, https first + * because a production install is HTTPS-only and a plain-http server rejects a + * TLS handshake immediately rather than hanging. + */ +export function tuiServerCandidates(env: { apiUrl?: string; port?: string | number } = {}): string[] { + if (env.apiUrl && env.apiUrl.trim()) return [normalizeOrigin(env.apiUrl)]; + const parsed = typeof env.port === 'number' ? env.port : Number.parseInt(String(env.port ?? ''), 10); + const port = Number.isSafeInteger(parsed) && parsed > 0 && parsed < 65536 ? parsed : DEFAULT_PORT; + return [`https://127.0.0.1:${port}`, `http://127.0.0.1:${port}`]; +} + +/** + * Parse a `KEY=value` env file. Mirrors `readCodemanEnv()` in `cli.ts`: blank + * lines and `#` comments skipped, one layer of matching quotes stripped. + */ +export function parseEnvFile(text: string): Record { + const result: Record = {}; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!match) continue; + let value = match[2].trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + result[match[1]] = value; + } + return result; +} + +export interface TuiCredentials { + username: string; + password?: string; +} + +/** + * Credentials for the API, env first and the data dir's `.env` as the fallback, + * exactly like the `codeman attach` path. No password means no auth is + * configured (or the user has it only in the server's environment, in which + * case the API answers 401 and `connect()` reports `authRequired`). + */ +export function readCodemanCredentials(envFilePath = dataPath('.env')): TuiCredentials { + let fileEnv: Record = {}; + try { + fileEnv = parseEnvFile(readFileSync(envFilePath, 'utf-8')); + } catch { + /* absent or unreadable: env-only */ + } + const username = process.env.CODEMAN_USERNAME || fileEnv.CODEMAN_USERNAME || 'admin'; + const password = process.env.CODEMAN_PASSWORD || fileEnv.CODEMAN_PASSWORD; + return password ? { username, password } : { username }; +} + +export function basicAuthHeader(credentials: TuiCredentials): string | undefined { + if (!credentials.password) return undefined; + return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Degraded mode +// ───────────────────────────────────────────────────────────────────────────── + +const execFileAsync = promisify(execFileCb); + +const defaultExecFile: TuiExecFile = async (file, args) => { + const { stdout, stderr } = await execFileAsync(file, [...args], { + timeout: EXEC_TIMEOUT_MS, + encoding: 'utf-8', + maxBuffer: 1024 * 1024, + }); + return { stdout, stderr }; +}; + +/** + * Session names Codeman owns on this socket. Remote (`codeman-ssh-…`) and + * docker (`codeman-dkr-…`) names deliberately fail this pattern (they live on + * their own sockets and must never be adopted), and the letters they use are + * outside `[a-f0-9-]`, so this is the same fence `tmux-manager.ts` draws. + */ +const MUX_NAME_PATTERN = /^(?:codeman|claudeman)-([a-f0-9-]+)$/; + +/** Field separator for `list-sessions -F`. Session names cannot contain a tab. */ +const TMUX_FIELD_SEPARATOR = '\t'; + +const TMUX_LIST_FORMAT = ['#{session_name}', '#{session_attached}', '#{session_created}', '#{session_windows}'].join( + TMUX_FIELD_SEPARATOR +); + +/** Names/dirs from `state.json`, keyed by full session id. Read-only, tolerant. */ +function readStateSessions(statePath: string): Map { + const sessions = new Map(); + try { + const parsed = JSON.parse(readFileSync(statePath, 'utf-8')) as { + sessions?: Record; + }; + for (const [id, value] of Object.entries(parsed.sessions ?? {})) { + if (value && typeof value === 'object') sessions.set(id, value); + } + } catch { + /* no state file, or mid-write garbage: degraded mode is best-effort */ + } + return sessions; +} + +/** Parse `list-sessions -F` output. Pure, so the format string is unit-testable. */ +export function parseTmuxSessionList(stdout: string): TuiTmuxSession[] { + const rows: TuiTmuxSession[] = []; + for (const line of stdout.split('\n')) { + if (!line.trim()) continue; + const [muxName = '', attached = '', created = '', windows = ''] = line.split(TMUX_FIELD_SEPARATOR); + const match = MUX_NAME_PATTERN.exec(muxName); + if (!match) continue; + const createdSeconds = Number.parseInt(created, 10); + const windowCount = Number.parseInt(windows, 10); + rows.push({ + muxName, + sessionIdPrefix: match[1], + attached: attached.trim() !== '' && attached.trim() !== '0', + ...(Number.isSafeInteger(createdSeconds) && createdSeconds > 0 ? { createdAt: createdSeconds * 1000 } : {}), + ...(Number.isSafeInteger(windowCount) && windowCount > 0 ? { windows: windowCount } : {}), + }); + } + return rows; +} + +/** + * List Codeman's tmux sessions without a server, decorating them with whatever + * `state.json` remembers. Attach is all this supports: there are no states, no + * approvals and no previews when nothing is running the classification. + * + * The tmux call is `execFile` with an argv array (never a shell string), and + * the socket comes from the instance config, so a beta TUI sees only the beta + * instance's sessions. + */ +export async function enumerateTmuxSessions( + options: { exec?: TuiExecFile; socket?: string; statePath?: string } = {} +): Promise { + const exec = options.exec ?? defaultExecFile; + const socket = options.socket ?? resolveTmuxSocketName(); + let stdout = ''; + try { + ({ stdout } = await exec('tmux', ['-L', socket, 'list-sessions', '-F', TMUX_LIST_FORMAT])); + } catch { + // "no server running on ..." exits non-zero, which is simply an empty list. + return []; + } + + const rows = parseTmuxSessionList(stdout); + if (rows.length === 0) return rows; + + const state = readStateSessions(options.statePath ?? dataPath('state.json')); + for (const row of rows) { + const matches = [...state.entries()].filter(([id]) => id.startsWith(row.sessionIdPrefix)); + // Ambiguity is meaningless here: two ids sharing an 8-char prefix cannot both + // own one mux name, and guessing would put the wrong name on a row. + if (matches.length !== 1) continue; + const [id, entry] = matches[0]; + row.sessionId = id; + if (entry.name) row.name = entry.name; + if (entry.workingDir) row.workingDir = entry.workingDir; + if (entry.mode) row.mode = entry.mode; + } + return rows; +} + +// ───────────────────────────────────────────────────────────────────────────── +// The client +// ───────────────────────────────────────────────────────────────────────────── + +interface RawResponse { + status: number; + body: string; +} + +export class TuiClient { + private base: string | null; + private readonly credentials: TuiCredentials; + private readonly timeoutMs: number; + private readonly probeTimeoutMs: number; + private readonly exec: TuiExecFile; + private readonly statePath: string; + private readonly streams = new Set(); + private readonly clientId = `codeman-tui-${process.pid}`; + private seq = 0; + private server: TuiServerInfo | null = null; + + constructor(private readonly options: TuiClientOptions = {}) { + this.base = options.baseUrl ? normalizeOrigin(options.baseUrl) : null; + const credentials = readCodemanCredentials(options.envFilePath ?? dataPath('.env')); + this.credentials = { + username: options.username ?? credentials.username, + ...((options.password ?? credentials.password) ? { password: options.password ?? credentials.password } : {}), + }; + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; + this.exec = options.exec ?? defaultExecFile; + this.statePath = options.statePath ?? dataPath('state.json'); + } + + /** The origin in use, or null before a successful `connect()`. */ + get baseUrl(): string | null { + return this.base; + } + + get serverInfo(): TuiServerInfo | null { + return this.server; + } + + /** + * Find the server and read its identity. Returns null when nothing answers, + * which is the app's cue to fall back to `enumerateTmuxSessions()`. + */ + async connect(): Promise { + // An explicit port outranks the ambient `CODEMAN_API_URL` (which every + // Codeman-managed session exports): a caller that named a port must not be + // silently redirected at whatever server happens to own this shell. + const candidates = this.base + ? [this.base] + : this.options.port !== undefined + ? tuiServerCandidates({ port: this.options.port }) + : tuiServerCandidates({ apiUrl: process.env.CODEMAN_API_URL, port: process.env.CODEMAN_PORT }); + + const probes = await Promise.all( + candidates.map((origin) => probeServer(`${origin}/api/status`, this.probeTimeoutMs)) + ); + const index = probes.findIndex((probe) => probe.up); + if (index === -1) { + this.server = null; + return null; + } + + this.base = candidates[index]; + const info: TuiServerInfo = { + baseUrl: this.base, + instance: CODEMAN_INSTANCE, + hostname: this.resolveHostname(this.base), + }; + if (probes[index].version) info.version = probes[index].version; + + // The probe is unauthenticated, so behind a password it learns nothing but + // "something is there". Ask again with credentials for the version. + try { + const status = await this.requestData<{ version?: string; planUsage?: TuiPlanUsage | null }>( + 'GET', + '/api/status' + ); + if (status?.version) info.version = status.version; + } catch (err) { + if (err instanceof TuiApiError && (err.status === 401 || err.status === 403)) { + info.authRequired = true; + } + } + + this.server = info; + return info; + } + + // ── API ──────────────────────────────────────────────────────────────────── + + async fetchUnifiedSessions(limit?: number): Promise { + const query = limit !== undefined ? `?limit=${encodeURIComponent(String(Math.max(1, Math.trunc(limit))))}` : ''; + const data = await this.requestData<{ sessions?: UnifiedSessionItem[] }>('GET', `/api/sessions/unified${query}`); + return data?.sessions ?? []; + } + + async fetchApprovals(): Promise { + const data = await this.requestData<{ approvals?: ApprovalItem[] }>('GET', '/api/approvals'); + return data?.approvals ?? []; + } + + /** + * Answer a pending prompt. Every refusal the server can reasonably give + * (dialog gone, item already resolved, digit not among the parsed options) + * comes back as a typed result: a human answering a dialog that just closed + * is normal operation, not an error condition. + */ + async answerApproval(id: string, answer: TuiApprovalAnswer): Promise { + try { + const data = await this.requestData<{ id: string; sessionId: string; action: string }>( + 'POST', + `/api/approvals/${encodeURIComponent(id)}/answer`, + answer + ); + return { ok: true, id: data?.id ?? id, sessionId: data?.sessionId ?? '', action: data?.action ?? answer.action }; + } catch (err) { + if (!(err instanceof TuiApiError)) throw err; + return { ok: false, reason: answerFailureReason(err), message: err.message }; + } + } + + /** Raw terminal bytes (ANSI intact) for the preview pane. */ + async fetchTerminalTail(sessionId: string, bytes: number): Promise { + const tail = Math.max(1, Math.trunc(bytes)); + const data = await this.requestData<{ terminalBuffer?: string }>( + 'GET', + `/api/sessions/${encodeURIComponent(sessionId)}/terminal?tail=${tail}` + ); + return data?.terminalBuffer ?? ''; + } + + /** + * Type one line at a session's composer. + * + * Two hard rules from CLAUDE.md, both enforced here so no caller can get them + * wrong: the payload must END with `\r` or the server never issues Enter and + * the text sits unsubmitted, and embedded newlines are stripped rather than + * sent (multi-line input breaks Ink, and the server would join the lines). + * The `clientId`/`seq` pair makes delivery exactly-once, so a retry after a + * dropped connection cannot type the prompt twice. + */ + async sendInput(sessionId: string, text: string): Promise { + const line = text.replace(/[\r\n]+/g, ' ').trim(); + this.seq += 1; + await this.requestData('POST', `/api/sessions/${encodeURIComponent(sessionId)}/input`, { + input: `${line}\r`, + useMux: true, + clientId: this.clientId, + seq: this.seq, + }); + } + + /** Last `seq` sent. Monotonic per process; exposed for tests and diagnostics. */ + get lastInputSeq(): number { + return this.seq; + } + + /** + * Start a session. Always `quick-start`, never `POST /api/sessions`: only + * this route resolves a case NAME, and it is what routes remote/docker cases + * to the right host instead of stat-ing the path locally. + */ + async quickStart(options: TuiQuickStartOptions): Promise { + const data = await this.requestData('POST', '/api/quick-start', { + caseName: options.caseName, + ...(options.mode ? { mode: options.mode } : {}), + ...(options.sessionName ? { sessionName: options.sessionName } : {}), + ...(options.parentSessionId ? { parentSessionId: options.parentSessionId } : {}), + }); + if (!data?.sessionId) throw new TuiApiError('quick-start returned no session id', 502); + return data; + } + + async fetchCases(): Promise { + const data = await this.requestData('GET', '/api/cases'); + return Array.isArray(data) ? data : []; + } + + async deleteSession(sessionId: string): Promise { + await this.requestData('DELETE', `/api/sessions/${encodeURIComponent(sessionId)}`); + } + + async search(query: string, limit?: number): Promise { + const params = new URLSearchParams({ q: query }); + if (limit !== undefined) params.set('limit', String(Math.max(1, Math.trunc(limit)))); + const data = await this.requestData('GET', `/api/search?${params.toString()}`); + return data ?? { query, groups: [], totalResults: 0, truncated: false }; + } + + /** + * The away digest. This route predates the envelope and answers + * `{success:true, digest}` with the payload at the TOP level, so it reads the + * raw body rather than `data` (see CLAUDE.md, away-digest). + */ + async fetchAwayDigest(range?: string): Promise { + const query = range ? `?range=${encodeURIComponent(range)}` : ''; + const body = await this.requestJson<{ digest?: AwayDigestResponse }>('GET', `/api/away-digest${query}`); + const digest = body?.digest; + if (!digest) throw new TuiApiError('away-digest returned no digest', 502); + return digest; + } + + /** Last-known plan-usage snapshot, or null when the account reports none. */ + async fetchPlanUsage(): Promise { + const data = await this.requestData<{ planUsage?: TuiPlanUsage | null }>('GET', '/api/status'); + return data?.planUsage ?? null; + } + + // ── Degraded mode ────────────────────────────────────────────────────────── + + /** Sessions straight from tmux, for when no server answered. */ + enumerateTmuxSessions(): Promise { + return enumerateTmuxSessions({ exec: this.exec, statePath: this.statePath }); + } + + // ── Live updates ─────────────────────────────────────────────────────────── + + /** + * Subscribe to `/api/events`. The returned stream owns its socket, its + * watchdog and its backoff timer; `close()` (or the client's) releases all + * three. Handlers are called synchronously as frames decode. + */ + subscribeEvents(handlers: TuiEventHandlers, options: TuiSubscribeOptions = {}): TuiEventStream { + if (!this.base) throw new Error('subscribeEvents() needs a connected client: call connect() first'); + const stream = new SseStream(this.base, this.authHeader(), handlers, options, () => this.streams.delete(stream)); + this.streams.add(stream); + stream.start(); + return stream; + } + + /** Tear down every stream this client opened. Safe to call twice. */ + close(): void { + for (const stream of [...this.streams]) stream.close(); + this.streams.clear(); + } + + // ── Internals ────────────────────────────────────────────────────────────── + + private authHeader(): string | undefined { + return basicAuthHeader(this.credentials); + } + + /** + * The header's hostname. No endpoint reports the server's own, so a loopback + * origin means "this machine" and anything else is named by its URL. + */ + private resolveHostname(origin: string): string { + try { + const host = new URL(origin).hostname; + if (host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '[::1]') return osHostname(); + return host; + } catch { + return osHostname(); + } + } + + /** Unwrap `{success:true,data}`; throw `TuiApiError` on `success:false`. */ + private async requestData(method: string, path: string, body?: unknown): Promise { + const payload = await this.requestJson<{ success?: boolean; data?: T }>(method, path, body); + if (payload && typeof payload === 'object' && payload.success === true) return payload.data; + return payload as unknown as T; + } + + /** The parsed response body, envelope and all. */ + private async requestJson(method: string, path: string, body?: unknown): Promise { + const response = await this.raw(method, path, body); + let parsed: unknown; + if (response.body.trim()) { + try { + parsed = JSON.parse(response.body); + } catch { + if (response.status >= 400) { + throw new TuiApiError(httpErrorMessage(response), response.status); + } + throw new TuiApiError(`${method} ${path} returned a non-JSON body`, response.status); + } + } + + const envelope = parsed as { success?: boolean; error?: string; errorCode?: string } | undefined; + if (envelope && typeof envelope === 'object' && envelope.success === false) { + throw new TuiApiError(envelope.error || 'request failed', response.status, envelope.errorCode); + } + if (response.status >= 400) { + throw new TuiApiError(httpErrorMessage(response), response.status); + } + return parsed as T | undefined; + } + + private raw(method: string, path: string, body?: unknown): Promise { + if (!this.base) throw new Error('the TUI client is not connected to a server'); + const url = new URL(path, `${this.base}/`); + const payload = body === undefined ? undefined : JSON.stringify(body); + const transport = url.protocol === 'https:' ? https : http; + const auth = this.authHeader(); + + return new Promise((resolve, reject) => { + const headers: Record = { Accept: 'application/json' }; + if (payload !== undefined) { + headers['Content-Type'] = 'application/json'; + headers['Content-Length'] = Buffer.byteLength(payload); + } + if (auth) headers.Authorization = auth; + + const req = transport.request( + { + protocol: url.protocol, + hostname: url.hostname, + port: url.port, + method, + path: `${url.pathname}${url.search}`, + // Loopback with the self-signed cert `--https` generates: verifying it + // would fail every local request. Same call the CLI already makes. + rejectUnauthorized: false, + timeout: this.timeoutMs, + headers, + }, + (res) => { + let text = ''; + let overflowed = false; + res.setEncoding('utf-8'); + res.on('data', (chunk: string) => { + if (overflowed) return; + if (text.length + chunk.length > MAX_RESPONSE_BYTES) { + overflowed = true; + res.destroy(); + reject(new TuiApiError(`${method} ${path} response exceeded ${MAX_RESPONSE_BYTES} bytes`, 507)); + return; + } + text += chunk; + }); + res.on('end', () => { + if (!overflowed) resolve({ status: res.statusCode ?? 0, body: text }); + }); + res.on('error', (err) => reject(new TuiApiError(getErrorMessage(err), 0))); + } + ); + req.on('timeout', () => { + req.destroy(); + reject(new TuiApiError(`${method} ${path} timed out after ${this.timeoutMs}ms`, 0)); + }); + req.on('error', (err) => reject(new TuiApiError(getErrorMessage(err), 0))); + if (payload !== undefined) req.write(payload); + req.end(); + }); + } +} + +function httpErrorMessage(response: RawResponse): string { + const detail = response.body.trim().slice(0, 200); + return detail ? `HTTP ${response.status}: ${detail}` : `HTTP ${response.status}`; +} + +/** Map an answer failure onto the outcomes the dashboard renders differently. */ +function answerFailureReason(err: TuiApiError): 'gone' | 'not-found' | 'rejected' | 'failed' { + if (err.errorCode === 'CONFLICT' || err.status === 409) return 'gone'; + if (err.errorCode === 'NOT_FOUND' || err.status === 404) return 'not-found'; + if (err.errorCode === 'INVALID_INPUT' || err.status === 400) return 'rejected'; + return 'failed'; +} + +// ───────────────────────────────────────────────────────────────────────────── +// SSE connection +// ───────────────────────────────────────────────────────────────────────────── + +/** + * A session id can never look like this, so passing it as the `?sessions=` + * filter drops every `session:terminal` frame while leaving lifecycle, hook and + * approval events untouched. + */ +const NO_TERMINAL_FILTER = 'tui-no-terminal'; + +const DEFAULT_POLLING_AFTER_FAILURES = 2; + +class SseStream implements TuiEventStream { + private req: http.ClientRequest | null = null; + private res: http.IncomingMessage | null = null; + private parser = new SseFrameParser(); + private watchdog: NodeJS.Timeout | null = null; + private retryTimer: NodeJS.Timeout | null = null; + private lastTraffic = 0; + private attempt = 0; + private closed = false; + /** Bumped per connection so a late `error`/`close` cannot fail a newer socket. */ + private generation = 0; + private _status: TuiSseStatus = 'reconnecting'; + private _recommendPolling = false; + + private readonly staleTimeoutMs: number; + private readonly checkIntervalMs: number; + private readonly baseBackoffMs: number; + private readonly maxBackoffMs: number; + private readonly pollingAfterFailures: number; + private readonly sessionsParam: string; + + constructor( + private readonly baseUrl: string, + private readonly auth: string | undefined, + private readonly handlers: TuiEventHandlers, + options: TuiSubscribeOptions, + private readonly onClosed: () => void + ) { + this.staleTimeoutMs = options.staleTimeoutMs ?? SSE_STALE_TIMEOUT_MS; + this.checkIntervalMs = options.checkIntervalMs ?? Math.max(250, Math.floor(this.staleTimeoutMs / 3)); + this.baseBackoffMs = options.baseBackoffMs ?? SSE_BASE_BACKOFF_MS; + this.maxBackoffMs = options.maxBackoffMs ?? SSE_MAX_BACKOFF_MS; + this.pollingAfterFailures = options.pollingAfterFailures ?? DEFAULT_POLLING_AFTER_FAILURES; + this.sessionsParam = options.sessionIds?.length ? options.sessionIds.join(',') : NO_TERMINAL_FILTER; + } + + get status(): TuiSseStatus { + return this._status; + } + + get recommendPolling(): boolean { + return this._recommendPolling; + } + + start(): void { + this.open(); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.teardown(); + this.onClosed(); + } + + private open(): void { + if (this.closed) return; + const generation = ++this.generation; + const url = new URL('/api/events', `${this.baseUrl}/`); + url.searchParams.set('sessions', this.sessionsParam); + const transport = url.protocol === 'https:' ? https : http; + + const headers: Record = { Accept: 'text/event-stream', 'Cache-Control': 'no-cache' }; + if (this.auth) headers.Authorization = this.auth; + + const req = transport.request( + { + protocol: url.protocol, + hostname: url.hostname, + port: url.port, + method: 'GET', + path: `${url.pathname}${url.search}`, + rejectUnauthorized: false, + headers, + }, + (res) => { + if (generation !== this.generation || this.closed) { + res.destroy(); + return; + } + if (res.statusCode !== 200) { + res.resume(); + this.fail(generation, `event stream refused with HTTP ${res.statusCode ?? 0}`); + return; + } + this.res = res; + this.attempt = 0; + this._recommendPolling = false; + this._status = 'connected'; + this.touch(); + this.armWatchdog(); + this.handlers.onStatus?.('connected', { attempt: 0, recommendPolling: false }); + + res.setEncoding('utf-8'); + res.on('data', (chunk: string) => { + if (generation !== this.generation) return; + // Any inbound bytes are liveness, comments and padding included. + this.touch(); + for (const frame of this.parser.feed(chunk)) this.dispatch(frame.event, frame.data); + }); + res.on('end', () => this.fail(generation, 'event stream ended')); + res.on('close', () => this.fail(generation, 'event stream closed')); + res.on('error', (err) => this.fail(generation, getErrorMessage(err))); + } + ); + req.on('error', (err) => this.fail(generation, getErrorMessage(err))); + this.req = req; + req.end(); + } + + private dispatch(event: string, data: string): void { + switch (classifySseEvent(event)) { + case 'heartbeat': + return; + case 'init': { + const state = parseJson<{ version?: string; planUsage?: TuiPlanUsage | null }>(data); + if (state) this.handlers.onInit?.({ version: state.version, planUsage: state.planUsage ?? null }); + return; + } + case 'approval': { + const payload = parseJson(data); + const kind = approvalEventKind(event); + if (!payload || !kind) return; + if (kind === 'resolved') { + this.handlers.onApproval?.({ kind, info: payload as ApprovalResolvedInfo }); + } else { + this.handlers.onApproval?.({ kind, item: payload as ApprovalItem }); + } + // An approval landing or clearing also changes how its row is grouped. + this.handlers.onResync?.(event); + return; + } + case 'plan-usage': { + const usage = parseJson(data); + if (usage) this.handlers.onPlanUsage?.(usage); + return; + } + case 'resync': + this.handlers.onResync?.(event); + return; + case 'ignore': + return; + } + } + + private touch(): void { + this.lastTraffic = Date.now(); + } + + private armWatchdog(): void { + if (this.watchdog) return; + this.watchdog = setInterval(() => { + if (this.closed) return; + if (Date.now() - this.lastTraffic <= this.staleTimeoutMs) return; + // The socket never errored, it just went quiet: the server heartbeats + // every 15s, so this is a dead stream that would otherwise freeze every + // SSE-driven surface until the user restarted the TUI. + this.fail(this.generation, `no traffic for ${this.staleTimeoutMs}ms`); + }, this.checkIntervalMs); + } + + private fail(generation: number, message: string): void { + if (this.closed || generation !== this.generation) return; + this.generation++; + this.teardown(); + + this.attempt++; + this._status = 'reconnecting'; + this._recommendPolling = this.attempt >= this.pollingAfterFailures; + this.handlers.onStatus?.('reconnecting', { + attempt: this.attempt, + recommendPolling: this._recommendPolling, + message, + }); + + const delay = sseBackoffDelay(this.attempt, this.baseBackoffMs, this.maxBackoffMs); + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + this.open(); + }, delay); + } + + /** Drop the socket and both timers. Never touches `closed`, so `fail()` can reuse it. */ + private teardown(): void { + if (this.watchdog) { + clearInterval(this.watchdog); + this.watchdog = null; + } + if (this.retryTimer) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + // The no-op error listeners are not decoration: destroying a socket can + // emit ECONNRESET, and an `error` event with no listener is an uncaught + // exception that would take the TUI down on a routine reconnect. + if (this.res) { + this.res.removeAllListeners(); + this.res.on('error', () => {}); + this.res.destroy(); + this.res = null; + } + if (this.req) { + this.req.removeAllListeners(); + this.req.on('error', () => {}); + this.req.destroy(); + this.req = null; + } + this.parser.reset(); + } +} + +function parseJson(text: string): T | null { + try { + return JSON.parse(text) as T; + } catch { + return null; + } +} diff --git a/test/tui/tui-client-sse.test.ts b/test/tui/tui-client-sse.test.ts new file mode 100644 index 00000000..a811b91c --- /dev/null +++ b/test/tui/tui-client-sse.test.ts @@ -0,0 +1,206 @@ +/** + * @fileoverview Integration tests for the TUI's live-update stream. + * + * These run against a real loopback `text/event-stream` endpoint rather than a + * mocked socket, because the behaviours that matter here are all socket-level: + * a stream that ENDS, a stream that goes SILENT without erroring (the failure + * mode `EventSource` cannot see, which is why the server heartbeats), and a + * teardown that must leave no timer behind. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import http from 'node:http'; +import { TuiClient, type TuiApprovalEvent, type TuiSseStatusDetail } from '../../src/tui/tui-client.js'; + +const PORT = 3242; +const BASE_URL = `http://127.0.0.1:${PORT}`; + +interface Connection { + url: string; + headers: http.IncomingHttpHeaders; + res: http.ServerResponse; +} + +const connections: Connection[] = []; +/** Flipped by a test that wants every connect attempt to fail. */ +let refuse = false; + +let server: http.Server; +let client: TuiClient | null = null; + +function frame(event: string, data: unknown): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +} + +async function until(predicate: () => boolean, timeoutMs = 3000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error('condition not met before the deadline'); +} + +beforeAll(async () => { + server = http.createServer((req, res) => { + if (!req.url?.startsWith('/api/events')) { + res.writeHead(404).end(); + return; + } + if (refuse) { + res.writeHead(503).end('busy'); + return; + } + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }); + // Node holds headers back until the first body write; the real server sends + // an `init` frame immediately, so flush to match it. Without this the + // client never sees a response and every test here waits forever. + res.flushHeaders(); + connections.push({ url: req.url, headers: req.headers, res }); + }); + await new Promise((resolve) => server.listen(PORT, '127.0.0.1', resolve)); +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); +}); + +beforeEach(() => { + connections.length = 0; + refuse = false; +}); + +afterEach(() => { + client?.close(); + client = null; + for (const connection of connections) connection.res.end(); +}); + +describe('subscribeEvents', () => { + it('routes each frame to the handler that owns it', async () => { + const resyncs: string[] = []; + const approvals: TuiApprovalEvent[] = []; + let planUsage: unknown = null; + let init: unknown = null; + + client = new TuiClient({ baseUrl: BASE_URL, password: 's3cret' }); + client.subscribeEvents({ + onInit: (state) => { + init = state; + }, + onResync: (event) => resyncs.push(event), + onApproval: (event) => approvals.push(event), + onPlanUsage: (usage) => { + planUsage = usage; + }, + }); + + await until(() => connections.length === 1); + const { res } = connections[0]; + res.write(frame('init', { version: '9.9.9', planUsage: { fiveHour: { usedPercentage: 5, resetAt: 1 } } })); + res.write(frame('session:created', { id: 'a' })); + // Split across writes on purpose: the parser must not need frame-aligned reads. + res.write('event: approval:pending\ndata: {"id":"a:1","sessionId":"a",'); + res.write('"kind":"permission","createdAt":7}\n\n'); + res.write(frame('session:terminal', { id: 'a', data: 'noise' })); + res.write(frame('sse:heartbeat', { t: 1 })); + res.write(frame('session:statusTelemetry', { sessionId: 'a', fiveHour: { usedPercentage: 41, resetAt: 2 } })); + + await until(() => planUsage !== null); + expect(init).toEqual({ version: '9.9.9', planUsage: { fiveHour: { usedPercentage: 5, resetAt: 1 } } }); + expect(approvals).toEqual([ + { kind: 'pending', item: { id: 'a:1', sessionId: 'a', kind: 'permission', createdAt: 7 } }, + ]); + expect(planUsage).toEqual({ sessionId: 'a', fiveHour: { usedPercentage: 41, resetAt: 2 } }); + // The approval also regrouped a row, so it resyncs too. Terminal and + // heartbeat frames never do. + expect(resyncs).toEqual(['session:created', 'approval:pending']); + }); + + it('suppresses the terminal firehose by default and carries the auth header', async () => { + client = new TuiClient({ baseUrl: BASE_URL, password: 's3cret' }); + client.subscribeEvents({}); + await until(() => connections.length === 1); + expect(connections[0].url).toBe('/api/events?sessions=tui-no-terminal'); + expect(connections[0].headers.authorization).toBe(`Basic ${Buffer.from('admin:s3cret').toString('base64')}`); + expect(connections[0].headers.accept).toBe('text/event-stream'); + }); + + it('subscribes to the terminal stream of named sessions when asked', async () => { + client = new TuiClient({ baseUrl: BASE_URL }); + client.subscribeEvents({}, { sessionIds: ['a', 'b'] }); + await until(() => connections.length === 1); + expect(connections[0].url).toBe('/api/events?sessions=a%2Cb'); + }); + + it('reconnects when the stream ends', async () => { + const statuses: Array<[string, TuiSseStatusDetail]> = []; + client = new TuiClient({ baseUrl: BASE_URL }); + const stream = client.subscribeEvents( + { onStatus: (status, detail) => statuses.push([status, detail]) }, + { baseBackoffMs: 10, maxBackoffMs: 20 } + ); + + await until(() => stream.status === 'connected'); + connections[0].res.end(); + await until(() => connections.length === 2 && stream.status === 'connected'); + expect(statuses.map(([status]) => status)).toEqual(['connected', 'reconnecting', 'connected']); + expect(statuses[1][1].message).toBeTruthy(); + }); + + it('reconnects when a live stream goes silent, which no socket error reports', async () => { + client = new TuiClient({ baseUrl: BASE_URL }); + client.subscribeEvents({}, { staleTimeoutMs: 150, checkIntervalMs: 25, baseBackoffMs: 10, maxBackoffMs: 20 }); + + await until(() => connections.length === 1); + // The server holds the connection open and says nothing: exactly the case + // the watchdog exists for. + await until(() => connections.length === 2); + expect(connections).toHaveLength(2); + }); + + it('recommends polling once connecting keeps failing', async () => { + refuse = true; + const details: TuiSseStatusDetail[] = []; + client = new TuiClient({ baseUrl: BASE_URL }); + const stream = client.subscribeEvents( + { onStatus: (_status, detail) => details.push(detail) }, + { baseBackoffMs: 10, maxBackoffMs: 20, pollingAfterFailures: 2 } + ); + + await until(() => details.length >= 2); + expect(details[0]).toMatchObject({ attempt: 1, recommendPolling: false }); + expect(details[1]).toMatchObject({ attempt: 2, recommendPolling: true }); + expect(stream.recommendPolling).toBe(true); + expect(stream.status).toBe('reconnecting'); + }); + + it('stops reconnecting after close, so the process can exit', async () => { + client = new TuiClient({ baseUrl: BASE_URL }); + const stream = client.subscribeEvents({}, { baseBackoffMs: 10, maxBackoffMs: 20 }); + await until(() => connections.length === 1); + + connections[0].res.end(); + stream.close(); + const seen = connections.length; + await new Promise((resolve) => setTimeout(resolve, 120)); + expect(connections.length).toBe(seen); + }); + + it('closes every stream the client opened', async () => { + client = new TuiClient({ baseUrl: BASE_URL }); + client.subscribeEvents({}); + client.subscribeEvents({}); + await until(() => connections.length === 2); + + client.close(); + await until(() => connections.every((connection) => connection.res.socket === null || connection.res.destroyed)); + const seen = connections.length; + await new Promise((resolve) => setTimeout(resolve, 120)); + expect(connections.length).toBe(seen); + }); + + it('refuses to subscribe before the client knows where the server is', () => { + const disconnected = new TuiClient({ port: 3999 }); + expect(() => disconnected.subscribeEvents({})).toThrow(/connect\(\)/); + }); +}); diff --git a/test/tui/tui-client.test.ts b/test/tui/tui-client.test.ts new file mode 100644 index 00000000..5e3e4029 --- /dev/null +++ b/test/tui/tui-client.test.ts @@ -0,0 +1,465 @@ +/** + * @fileoverview Unit tests for the TUI's IO layer: discovery, credentials, the + * typed API surface and degraded-mode tmux enumeration. + * + * The API calls run against a real loopback HTTP server that answers in the + * shapes the routes really produce (the `{success,data}` envelope, plus + * away-digest's legacy top-level `digest`), so an envelope change breaks these + * tests rather than the dashboard. tmux is never executed: the exec function is + * injected, and `test/setup.ts` gives this file its own HOME, so the state file + * it reads is a fixture of its own making. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import http from 'node:http'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { dataPath } from '../../src/config/instance.js'; +import { + TuiApiError, + TuiClient, + basicAuthHeader, + enumerateTmuxSessions, + parseEnvFile, + parseTmuxSessionList, + readCodemanCredentials, + tuiServerCandidates, + type TuiExecFile, +} from '../../src/tui/tui-client.js'; + +const PORT = 3241; +/** Nothing ever listens here: the "no server" path. */ +const DEAD_PORT = 3243; +const BASE_URL = `http://127.0.0.1:${PORT}`; + +interface Recorded { + method: string; + url: string; + headers: http.IncomingHttpHeaders; + body: string; +} + +const recorded: Recorded[] = []; +type Responder = (req: http.IncomingMessage, res: http.ServerResponse, body: string) => void; + +/** Per-test override; falls back to `defaultResponder`. */ +let responder: Responder | null = null; + +function sendJson(res: http.ServerResponse, status: number, payload: unknown): void { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); +} + +const defaultResponder: Responder = (req, res) => { + const url = req.url ?? ''; + if (url.startsWith('/api/status')) { + return sendJson(res, 200, { + success: true, + data: { version: '9.9.9', planUsage: { fiveHour: { usedPercentage: 32, resetAt: 1000 } } }, + }); + } + if (url.startsWith('/api/sessions/unified')) { + return sendJson(res, 200, { + success: true, + data: { sessions: [{ sessionId: 'abc', name: 'w1-codeman', sources: ['live'] }], total: 1 }, + }); + } + if (url.startsWith('/api/approvals')) { + return sendJson(res, 200, { + success: true, + data: { approvals: [{ id: 'abc:1', sessionId: 'abc', sessionName: 'w1', kind: 'permission', createdAt: 5 }] }, + }); + } + if (url.includes('/terminal')) { + return sendJson(res, 200, { success: true, data: { terminalBuffer: 'tail bytes', status: 'idle' } }); + } + if (url.endsWith('/input')) { + return sendJson(res, 200, { success: true, data: {} }); + } + if (url.startsWith('/api/quick-start')) { + return sendJson(res, 200, { success: true, data: { sessionId: 'new-1', casePath: '/cases/x', caseName: 'x' } }); + } + if (url.startsWith('/api/cases')) { + return sendJson(res, 200, { success: true, data: [{ name: 'x', path: '/cases/x', location: 'local' }] }); + } + if (url.startsWith('/api/search')) { + return sendJson(res, 200, { + success: true, + data: { query: 'foo', groups: [], totalResults: 0, truncated: false }, + }); + } + if (url.startsWith('/api/away-digest')) { + // Legacy shape: the payload sits at the TOP level, not under `data`. + return sendJson(res, 200, { success: true, digest: { totals: { activeSessions: 2 } } }); + } + if (req.method === 'DELETE') { + return sendJson(res, 200, { success: true, data: {} }); + } + return sendJson(res, 404, { success: false, error: 'no route', errorCode: 'NOT_FOUND' }); +}; + +function client(overrides: Record = {}): TuiClient { + return new TuiClient({ baseUrl: BASE_URL, timeoutMs: 4000, ...overrides }); +} + +let server: http.Server; +const originalApiUrl = process.env.CODEMAN_API_URL; +const originalPort = process.env.CODEMAN_PORT; + +beforeAll(async () => { + // This suite runs inside a Codeman-managed session, which exports + // CODEMAN_API_URL pointing at the LIVE server. Discovery consults it, so it + // has to be out of the way before any test calls connect(). + delete process.env.CODEMAN_API_URL; + delete process.env.CODEMAN_PORT; + + server = http.createServer((req, res) => { + let body = ''; + req.setEncoding('utf-8'); + req.on('data', (chunk: string) => { + body += chunk; + }); + req.on('end', () => { + recorded.push({ method: req.method ?? '', url: req.url ?? '', headers: req.headers, body }); + (responder ?? defaultResponder)(req, res, body); + }); + }); + await new Promise((resolve) => server.listen(PORT, '127.0.0.1', resolve)); +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + if (originalApiUrl !== undefined) process.env.CODEMAN_API_URL = originalApiUrl; + if (originalPort !== undefined) process.env.CODEMAN_PORT = originalPort; +}); + +beforeEach(() => { + recorded.length = 0; + responder = null; +}); + +describe('parseEnvFile', () => { + it('reads plain assignments and skips comments and blanks', () => { + expect(parseEnvFile('# comment\n\nCODEMAN_USERNAME=bob\nCODEMAN_PASSWORD=hunter2\n')).toEqual({ + CODEMAN_USERNAME: 'bob', + CODEMAN_PASSWORD: 'hunter2', + }); + }); + + it('strips one layer of matching quotes', () => { + expect(parseEnvFile('A="quoted"\nB=\'single\'\nC="mismatched\'')).toEqual({ + A: 'quoted', + B: 'single', + C: '"mismatched\'', + }); + }); + + it('ignores lines that are not assignments', () => { + expect(parseEnvFile('not an assignment\n1BAD=x\nGOOD=y')).toEqual({ GOOD: 'y' }); + }); +}); + +describe('readCodemanCredentials', () => { + it('falls back to the data dir .env when the environment has nothing', () => { + const envPath = dataPath('.env'); + mkdirSync(dirname(envPath), { recursive: true }); + writeFileSync(envPath, 'CODEMAN_USERNAME=fileuser\nCODEMAN_PASSWORD=filepass\n', 'utf-8'); + expect(readCodemanCredentials()).toEqual({ username: 'fileuser', password: 'filepass' }); + }); + + it('lets the environment win over the file', () => { + const envPath = dataPath('.env'); + writeFileSync(envPath, 'CODEMAN_USERNAME=fileuser\nCODEMAN_PASSWORD=filepass\n', 'utf-8'); + process.env.CODEMAN_PASSWORD = 'envpass'; + try { + expect(readCodemanCredentials()).toEqual({ username: 'fileuser', password: 'envpass' }); + } finally { + delete process.env.CODEMAN_PASSWORD; + } + }); + + it('reports admin with no password when nothing is configured', () => { + expect(readCodemanCredentials('/nonexistent/codeman/.env')).toEqual({ username: 'admin' }); + }); +}); + +describe('basicAuthHeader', () => { + it('is absent without a password and base64 with one', () => { + expect(basicAuthHeader({ username: 'admin' })).toBeUndefined(); + expect(basicAuthHeader({ username: 'admin', password: 's3cret' })).toBe( + `Basic ${Buffer.from('admin:s3cret').toString('base64')}` + ); + }); +}); + +describe('tuiServerCandidates', () => { + it('prefers an explicit API url and trims its trailing slash', () => { + expect(tuiServerCandidates({ apiUrl: 'https://box:8443/' })).toEqual(['https://box:8443']); + }); + + it('probes both schemes on loopback, https first', () => { + expect(tuiServerCandidates({ port: 5000 })).toEqual(['https://127.0.0.1:5000', 'http://127.0.0.1:5000']); + }); + + it('falls back to port 3000 for junk', () => { + expect(tuiServerCandidates({ port: 'not-a-port' })).toEqual(['https://127.0.0.1:3000', 'http://127.0.0.1:3000']); + }); +}); + +describe('TuiClient envelope handling', () => { + it('unwraps the sessions list', async () => { + const sessions = await client().fetchUnifiedSessions(10); + expect(sessions).toEqual([{ sessionId: 'abc', name: 'w1-codeman', sources: ['live'] }]); + expect(recorded[0].url).toBe('/api/sessions/unified?limit=10'); + }); + + it('unwraps pending approvals', async () => { + const approvals = await client().fetchApprovals(); + expect(approvals).toHaveLength(1); + expect(approvals[0].id).toBe('abc:1'); + }); + + it('turns a success:false envelope into a typed error carrying the code', async () => { + responder = (_req, res) => + sendJson(res, 404, { success: false, error: 'Session not found', errorCode: 'NOT_FOUND' }); + await expect(client().fetchTerminalTail('gone', 1000)).rejects.toMatchObject({ + name: 'TuiApiError', + status: 404, + errorCode: 'NOT_FOUND', + message: 'Session not found', + }); + }); + + it('turns a non-JSON failure into a typed error too', async () => { + responder = (_req, res) => { + res.writeHead(502, { 'Content-Type': 'text/plain' }); + res.end('bad gateway'); + }; + const err = await client() + .fetchApprovals() + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(TuiApiError); + expect((err as TuiApiError).status).toBe(502); + }); + + it('sends Basic auth when a password is configured, and none when it is not', async () => { + await client({ username: 'admin', password: 's3cret' }).fetchApprovals(); + expect(recorded[0].headers.authorization).toBe(`Basic ${Buffer.from('admin:s3cret').toString('base64')}`); + + recorded.length = 0; + await client({ envFilePath: '/nonexistent/codeman/.env' }).fetchApprovals(); + expect(recorded[0].headers.authorization).toBeUndefined(); + }); +}); + +describe('TuiClient.answerApproval', () => { + it('reports success', async () => { + responder = (_req, res) => + sendJson(res, 200, { success: true, data: { id: 'abc:1', sessionId: 'abc', action: 'approve' } }); + await expect(client().answerApproval('abc:1', { action: 'approve' })).resolves.toEqual({ + ok: true, + id: 'abc:1', + sessionId: 'abc', + action: 'approve', + }); + }); + + it('reports a 409 as a typed "gone" result, not an exception', async () => { + responder = (_req, res) => + sendJson(res, 409, { success: false, error: 'The dialog is no longer on screen', errorCode: 'CONFLICT' }); + const result = await client().answerApproval('abc:1', { action: 'option', option: 2 }); + expect(result).toEqual({ ok: false, reason: 'gone', message: 'The dialog is no longer on screen' }); + }); + + it('separates "already resolved" from "digit rejected"', async () => { + responder = (_req, res) => sendJson(res, 404, { success: false, error: 'gone', errorCode: 'NOT_FOUND' }); + expect((await client().answerApproval('x', { action: 'deny' })).ok).toBe(false); + expect(await client().answerApproval('x', { action: 'deny' })).toMatchObject({ reason: 'not-found' }); + + responder = (_req, res) => sendJson(res, 400, { success: false, error: 'bad option', errorCode: 'INVALID_INPUT' }); + expect(await client().answerApproval('x', { action: 'option', option: 9 })).toMatchObject({ reason: 'rejected' }); + }); + + it('posts the answer body verbatim', async () => { + responder = (_req, res) => sendJson(res, 200, { success: true, data: { id: 'a', sessionId: 'b', action: 'text' } }); + await client().answerApproval('a b/c', { action: 'text', text: 'yes please' }); + expect(recorded[0].method).toBe('POST'); + expect(recorded[0].url).toBe('/api/approvals/a%20b%2Fc/answer'); + expect(JSON.parse(recorded[0].body)).toEqual({ action: 'text', text: 'yes please' }); + }); +}); + +describe('TuiClient.sendInput', () => { + it('always terminates with a carriage return and never sends a bare newline', async () => { + await client().sendInput('abc', 'hello world'); + expect(JSON.parse(recorded[0].body)).toMatchObject({ input: 'hello world\r', useMux: true }); + }); + + it('collapses embedded newlines into spaces (multi-line breaks Ink)', async () => { + await client().sendInput('abc', 'echo A\necho B\r\nline three '); + expect(JSON.parse(recorded[0].body).input).toBe('echo A echo B line three\r'); + }); + + it('tags every send with a stable clientId and a monotonic seq', async () => { + const c = client(); + await c.sendInput('abc', 'one'); + await c.sendInput('abc', 'two'); + await c.sendInput('def', 'three'); + const bodies = recorded.map((entry) => JSON.parse(entry.body) as { clientId: string; seq: number }); + expect(bodies.map((b) => b.seq)).toEqual([1, 2, 3]); + expect(new Set(bodies.map((b) => b.clientId)).size).toBe(1); + expect(bodies[0].clientId).toMatch(/^codeman-tui-\d+$/); + expect(c.lastInputSeq).toBe(3); + }); +}); + +describe('TuiClient remaining API surface', () => { + it('fetches a terminal tail by byte count', async () => { + await expect(client().fetchTerminalTail('abc', 4096)).resolves.toBe('tail bytes'); + expect(recorded[0].url).toBe('/api/sessions/abc/terminal?tail=4096'); + }); + + it('starts sessions through quick-start', async () => { + const result = await client().quickStart({ caseName: 'x', mode: 'claude', parentSessionId: 'abc' }); + expect(result.sessionId).toBe('new-1'); + expect(recorded[0].url).toBe('/api/quick-start'); + expect(JSON.parse(recorded[0].body)).toEqual({ caseName: 'x', mode: 'claude', parentSessionId: 'abc' }); + }); + + it('lists cases', async () => { + await expect(client().fetchCases()).resolves.toEqual([{ name: 'x', path: '/cases/x', location: 'local' }]); + }); + + it('deletes a session by exact id', async () => { + await client().deleteSession('abc'); + expect(recorded[0].method).toBe('DELETE'); + expect(recorded[0].url).toBe('/api/sessions/abc'); + }); + + it('searches with an encoded query', async () => { + await client().search('a b', 5); + expect(recorded[0].url).toBe('/api/search?q=a+b&limit=5'); + }); + + it('reads the away digest from its legacy top-level shape', async () => { + await expect(client().fetchAwayDigest('24h')).resolves.toEqual({ totals: { activeSessions: 2 } }); + expect(recorded[0].url).toBe('/api/away-digest?range=24h'); + }); + + it('reads plan usage off the status snapshot', async () => { + await expect(client().fetchPlanUsage()).resolves.toEqual({ fiveHour: { usedPercentage: 32, resetAt: 1000 } }); + }); + + it('reports a missing plan-usage snapshot as null rather than throwing', async () => { + responder = (_req, res) => sendJson(res, 200, { success: true, data: { version: '1.0.0', planUsage: null } }); + await expect(client().fetchPlanUsage()).resolves.toBeNull(); + }); +}); + +describe('TuiClient.connect', () => { + it('discovers the loopback server and reports its identity', async () => { + const info = await new TuiClient({ port: PORT, probeTimeoutMs: 1000 }).connect(); + expect(info?.baseUrl).toBe(BASE_URL); + expect(info?.version).toBe('9.9.9'); + expect(info?.hostname).toBeTruthy(); + expect(info?.authRequired).toBeUndefined(); + }); + + it('reports a server that rejects our credentials instead of calling it down', async () => { + responder = (_req, res) => { + res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="codeman"' }); + res.end('Unauthorized'); + }; + const info = await new TuiClient({ port: PORT, probeTimeoutMs: 1000 }).connect(); + expect(info?.baseUrl).toBe(BASE_URL); + expect(info?.authRequired).toBe(true); + }); + + it('returns null when nothing answers', async () => { + await expect(new TuiClient({ port: DEAD_PORT, probeTimeoutMs: 500 }).connect()).resolves.toBeNull(); + }); + + it('refuses to talk to an unconnected client', async () => { + await expect(new TuiClient({ port: DEAD_PORT }).fetchApprovals()).rejects.toThrow(/not connected/); + }); +}); + +describe('degraded-mode tmux enumeration', () => { + const listing = [ + 'codeman-1a2b3c4d\t1\t1700000000\t1', + 'codeman-deadbeef\t0\t1700000100\t2', + 'claudeman-cafe0001\t0\t1700000200\t1', + 'my-own-tmux-session\t1\t1700000300\t1', + 'codeman-ssh-abc\t0\t1700000400\t1', + ].join('\n'); + + it('parses the list format and keeps only Codeman-owned names', () => { + const rows = parseTmuxSessionList(listing); + expect(rows.map((row) => row.muxName)).toEqual(['codeman-1a2b3c4d', 'codeman-deadbeef', 'claudeman-cafe0001']); + expect(rows[0]).toMatchObject({ sessionIdPrefix: '1a2b3c4d', attached: true, createdAt: 1_700_000_000_000 }); + expect(rows[1]).toMatchObject({ attached: false, windows: 2 }); + }); + + it('never shells out: the tmux call is an argv array on the instance socket', async () => { + const calls: Array<{ file: string; args: readonly string[] }> = []; + const exec: TuiExecFile = async (file, args) => { + calls.push({ file, args }); + return { stdout: listing, stderr: '' }; + }; + await enumerateTmuxSessions({ exec, socket: 'codeman-beta', statePath: '/nonexistent/state.json' }); + expect(calls).toHaveLength(1); + expect(calls[0].file).toBe('tmux'); + expect(calls[0].args.slice(0, 4)).toEqual(['-L', 'codeman-beta', 'list-sessions', '-F']); + }); + + it('decorates rows with names and dirs from state.json, matching on the id prefix', async () => { + const statePath = dataPath('state.json'); + writeFileSync( + statePath, + JSON.stringify({ + sessions: { + '1a2b3c4d-1111-2222-3333-444444444444': { + name: 'w1-codeman', + workingDir: '/home/dev/codeman', + mode: 'claude', + }, + }, + }), + 'utf-8' + ); + const exec: TuiExecFile = async () => ({ stdout: listing, stderr: '' }); + const rows = await enumerateTmuxSessions({ exec, statePath }); + expect(rows[0]).toMatchObject({ + sessionId: '1a2b3c4d-1111-2222-3333-444444444444', + name: 'w1-codeman', + workingDir: '/home/dev/codeman', + mode: 'claude', + }); + // No state entry: the row still exists, it just has no decoration. + expect(rows[1].sessionId).toBeUndefined(); + expect(rows[1].name).toBeUndefined(); + }); + + it('refuses to guess when two ids share a prefix', async () => { + const statePath = dataPath('ambiguous-state.json'); + writeFileSync( + statePath, + JSON.stringify({ + sessions: { + '1a2b3c4d-aaaa': { name: 'first' }, + '1a2b3c4d-bbbb': { name: 'second' }, + }, + }), + 'utf-8' + ); + const exec: TuiExecFile = async () => ({ stdout: 'codeman-1a2b3c4d\t0\t1700000000\t1', stderr: '' }); + const rows = await enumerateTmuxSessions({ exec, statePath }); + expect(rows[0].name).toBeUndefined(); + }); + + it('treats a dead tmux server as an empty list', async () => { + const exec: TuiExecFile = async () => { + throw new Error('no server running on /tmp/tmux-1000/codeman'); + }; + await expect(enumerateTmuxSessions({ exec })).resolves.toEqual([]); + }); +}); From c25aaca96b58a975cbbde67239754d27e75c30fa Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 20:00:51 +0200 Subject: [PATCH 18/57] feat: give the TUI model a revision signal and picker state The app layer repaints on state change, so the store has to be able to say that something changed: `revision` is bumped by every mutating method, and the repaint test compares it against the last painted frame. Without it an idle dashboard would either redraw on a timer or go stale. Three additions come with it, all optional so nothing existing changes shape: `TuiSessionRow.muxName` (the unified list carries no mux name, so the app fills it in from the local tmux enumeration and a row without one cannot be attached), a `new-session` UI mode, and `TuiPickerState`, the one-column chooser behind `n`. Co-Authored-By: Claude Fable 5 --- src/tui/tui-model.ts | 59 ++++++++++++++++++++++++++++++++++++++------ src/tui/tui-types.ts | 37 ++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/src/tui/tui-model.ts b/src/tui/tui-model.ts index 81e94ca6..2512f131 100644 --- a/src/tui/tui-model.ts +++ b/src/tui/tui-model.ts @@ -26,6 +26,7 @@ import type { TuiGroupKey, TuiHeaderInfo, TuiMessage, + TuiPickerState, TuiPreview, TuiRenderModel, TuiRow, @@ -200,6 +201,7 @@ export function mergeSessionRow(existing: TuiSessionRow, incoming: TuiSessionRow export class TuiModelStore implements TuiRenderModel { private sessionsById = new Map(); private approvalsBySession = new Map(); + private _revision = 0; selectedId: string | null = null; connection: TuiConnectionStatus = 'connected'; @@ -208,12 +210,27 @@ export class TuiModelStore implements TuiRenderModel { preview: TuiPreview | null = null; message: TuiMessage | null = null; confirm: TuiConfirmState | null = null; + picker: TuiPickerState | 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 { @@ -258,24 +275,38 @@ export class TuiModelStore implements TuiRenderModel { // ── 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(); } /** Arm the typed-name confirmation for `x` (kill). */ @@ -286,10 +317,13 @@ export class TuiModelStore implements TuiRenderModel { typed: '', }; this.mode = 'confirm-kill'; + this.touch(); } setConfirmInput(typed: string): void { - if (this.confirm) this.confirm = { ...this.confirm, typed }; + if (!this.confirm) return; + this.confirm = { ...this.confirm, typed }; + this.touch(); } /** Does the typed text authorize the kill? Exact match on the name shown. */ @@ -301,7 +335,9 @@ export class TuiModelStore implements TuiRenderModel { closeOverlay(): void { this.confirm = null; this.message = null; + this.picker = null; this.mode = 'list'; + this.touch(); } // ── Derived views ────────────────────────────────────────────────────────── @@ -330,7 +366,7 @@ export class TuiModelStore implements TuiRenderModel { /** 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.selectedId = sessionId; + this.moveTo(sessionId); return true; } @@ -338,17 +374,17 @@ export class TuiModelStore implements TuiRenderModel { moveCursor(delta: number): void { const rows = this.rows(); if (rows.length === 0) { - this.selectedId = null; + this.moveTo(null); return; } const current = this.indexOfSelected(rows); if (current < 0) { - this.selectedId = rows[delta >= 0 ? 0 : rows.length - 1].session.sessionId; + 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.selectedId = rows[next].session.sessionId; + this.moveTo(rows[next].session.sessionId); } /** The 1-9 jump: `n` is the 1-based position in the flattened list. */ @@ -356,10 +392,16 @@ export class TuiModelStore implements TuiRenderModel { const rows = this.rows(); const index = Math.trunc(n) - 1; if (index < 0 || index >= rows.length) return false; - this.selectedId = rows[index].session.sessionId; + 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); @@ -373,14 +415,15 @@ export class TuiModelStore implements TuiRenderModel { private mutate(apply: () => void): void { const previousIndex = this.indexOfSelected(); apply(); + this.touch(); const rows = this.rows(); if (rows.length === 0) { - this.selectedId = null; + 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.selectedId = rows[index].session.sessionId; + this.moveTo(rows[index].session.sessionId); } } diff --git a/src/tui/tui-types.ts b/src/tui/tui-types.ts index 4609b8b0..ffbf9e39 100644 --- a/src/tui/tui-types.ts +++ b/src/tui/tui-types.ts @@ -33,6 +33,15 @@ export interface TuiSessionRow extends UnifiedSessionItem { lastSubmitAt?: number; inputTokens?: number; outputTokens?: number; + /** + * tmux session name to attach to (`codeman-`). + * + * 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; } /** @@ -68,7 +77,7 @@ export interface TuiGroup { export type TuiConnectionStatus = 'connected' | 'reconnecting' | 'degraded' | 'down'; /** Which overlay (if any) owns the keyboard. */ -export type TuiUiMode = 'list' | 'help' | 'confirm-kill' | 'prompt' | 'search' | 'message'; +export type TuiUiMode = 'list' | 'help' | 'confirm-kill' | 'prompt' | 'search' | 'message' | 'new-session'; /** * Glyph capability tier. Detection is env-driven and therefore lives in a tiny @@ -107,6 +116,30 @@ export interface TuiConfirmState { typed: 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; +} + /** * What `renderFrame()` reads. The store implements it; a test can hand-build * one, which is what keeps the renderer testable without the model. @@ -120,6 +153,8 @@ export interface TuiRenderModel { 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; /** Live sessions only (RECENT rows are history, not sessions you have open). */ readonly sessionCount: number; } From 658aa281e5ee14a16a6c91f429fbed2dd1dbe492 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 20:00:56 +0200 Subject: [PATCH 19/57] feat: render the TUI picker overlay and a caller-supplied keymap The footer and the help overlay held the plan's full keymap, which would advertise verbs (prompt, search, digest, answer, resume) that the build does not implement yet and teach users that the TUI ignores keys. Both now take their entries from the render options when the caller passes them; the built-in lists stay as the fallback. The picker overlay windows its items around the cursor rather than clipping them, so the selected case stays visible in a long list. Co-Authored-By: Claude Fable 5 --- src/tui/tui-render.ts | 71 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 63 insertions(+), 8 deletions(-) diff --git a/src/tui/tui-render.ts b/src/tui/tui-render.ts index 61522fa6..8c629abb 100644 --- a/src/tui/tui-render.ts +++ b/src/tui/tui-render.ts @@ -21,7 +21,15 @@ import { clipStyledLine, padDisplay, stripStyles, visibleWidth } from './tui-ansi.js'; import type { TuiLayout, TuiRect } from './tui-layout.js'; -import type { TuiGlyphTier, TuiGroup, TuiRenderModel, TuiRow, TuiSessionRow, TuiSessionState } from './tui-types.js'; +import type { + TuiGlyphTier, + TuiGroup, + TuiPickerState, + TuiRenderModel, + TuiRow, + TuiSessionRow, + TuiSessionState, +} from './tui-types.js'; export interface TuiRenderOptions { /** Emit SGR color. False is NO_COLOR: cursor addressing and nothing else. */ @@ -31,6 +39,19 @@ export interface TuiRenderOptions { 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; } // ───────────────────────────────────────────────────────────────────────────── @@ -465,13 +486,16 @@ const FOOTER_KEYS: Record string> = { message: () => 'esc dismiss', prompt: (g) => `${g.enter} send ${g.separator} esc cancel`, search: (g) => `${g.enter} open ${g.separator} esc cancel`, + 'new-session': (g) => `${g.updown} select ${g.separator} ${g.enter} choose ${g.separator} esc cancel`, }; function renderFooterLine(model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): string { const paint = painterFor(opts.color); const glyphs = glyphsFor(opts.glyphs); - const build = FOOTER_KEYS[model.mode] ?? FOOTER_KEYS.list; - return padDisplay(paint(clipStyledLine(` ${build(glyphs)}`, layout.cols), SGR.gray), layout.cols); + 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); } // ───────────────────────────────────────────────────────────────────────────── @@ -500,8 +524,8 @@ function wrapText(text: string, width: number): string[] { return out.length > 0 ? out : ['']; } -function helpLines(glyphs: TuiGlyphSet): string[] { - const pairs: Array<[string, string]> = [ +function helpLines(glyphs: TuiGlyphSet, custom?: ReadonlyArray): string[] { + const pairs: ReadonlyArray = custom ?? [ [`${glyphs.updown} / j k`, 'select'], [glyphs.enter, 'attach'], ['1-9', 'jump'], @@ -519,11 +543,42 @@ function helpLines(glyphs: TuiGlyphSet): string[] { return pairs.map(([key, description]) => `${padDisplay(key, keyWidth)} ${description}`); } -function overlayContent(model: TuiRenderModel, opts: TuiRenderOptions, width: number): OverlayContent | null { +/** 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]; +} + +function overlayContent( + model: TuiRenderModel, + opts: TuiRenderOptions, + width: number, + height: number +): OverlayContent | null { const glyphs = glyphsFor(opts.glyphs); switch (model.mode) { case 'help': - return { title: 'Keys', lines: helpLines(glyphs) }; + return { title: 'Keys', lines: helpLines(glyphs, opts.helpKeys) }; + 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; const { name, typed } = model.confirm; @@ -547,7 +602,7 @@ function overlayContent(model: TuiRenderModel, opts: TuiRenderOptions, width: nu 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); + const content = overlayContent(model, opts, body.width, body.height); if (!content) return; const paint = painterFor(opts.color); From 8fd57dd1e2a422bb8f1d42999e3653b272350259 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 20:01:07 +0200 Subject: [PATCH 20/57] feat: add the codeman tui dashboard The IO half of src/tui: it owns the terminal, the timers, stdin and the tmux handoff, and every decision it makes that is a function of its inputs is an exported pure helper with unit tests (attach planning, the typed kill confirmation, keymap selection, the repaint test, degraded rows). What it does: live session list over the unified API with SSE-driven resync (debounced, with a 2s poll fallback the client asks for), cursor and 1-9 navigation, attach and return, kill behind a typed confirmation that refuses history rows and the session hosting the TUI, a new-session case and CLI picker over quick-start, and degraded mode straight from tmux when no server answers, re-probing so a server that starts upgrades the dashboard in place. Restoring the terminal is the part that has to be bulletproof: leave() is idempotent and runs from normal quit, SIGINT/SIGTERM, a process exit hook and prepended fatal handlers (src/index.ts already handles those by exiting, so a listener registered after it would never run). Attach is a handoff, never a proxy: the screen is restored and tmux gets the real terminal. Inside tmux on the same socket there is nothing to hand off to, so it issues switch-client and exits. The preview pane, approvals answering, the prompt composer, search and the digest are the next step; the region renders a placeholder rather than pretending to load something. Co-Authored-By: Claude Fable 5 --- src/tui/tui-app.ts | 1231 ++++++++++++++++++++++++++++++++++++++ test/tui/tui-app.test.ts | 300 ++++++++++ 2 files changed, 1531 insertions(+) create mode 100644 src/tui/tui-app.ts create mode 100644 test/tui/tui-app.test.ts diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts new file mode 100644 index 00000000..9fce42fd --- /dev/null +++ b/src/tui/tui-app.ts @@ -0,0 +1,1231 @@ +/** + * @fileoverview `codeman tui`: the full-screen dashboard plus the two + * non-interactive fast paths (`--list`, ``). + * + * This is the IO half of `src/tui/`: it owns the terminal, the timers, stdin + * and the tmux handoff. Every decision it makes that can be stated as a + * function of its inputs lives at the top of this file as an exported pure + * helper (attach planning, the kill confirmation, footer selection, the + * repaint test, degraded-row building), because none of those can be tested + * through a real terminal. + * + * TERMINAL LIFECYCLE is the part users judge. A TUI that dies leaving the + * terminal in raw mode with a hidden cursor is unusable until the user types a + * blind `reset`, so `leave()` is idempotent and runs from every exit path there + * is: normal quit, SIGINT/SIGTERM, and a `process.on('exit')` backstop that + * fires even when someone else's handler calls `process.exit()`. The fatal + * handlers are installed with `prependListener` on purpose: `src/index.ts` + * already handles `uncaughtException` by exiting, and a listener registered + * after it would never run. + * + * ATTACH is a handoff, never a proxy: the screen is fully restored and tmux + * gets the real terminal (`stdio: 'inherit'`), so mouse, paste and colors are + * tmux's own. Inside tmux on the same socket there is nothing to hand off to, + * so the TUI issues `switch-client` and EXITS: the client it would draw on is + * now showing the target session, and a dashboard nobody can see must not keep + * polling the server. + * + * REPAINT POLICY: on state change (the model's revision), on resize, and on a + * 500ms tick that runs only while a WORKING row is on screen (the glyph + * animates). An idle dashboard writes nothing at all. + * + * NOT HERE YET (phase 2 of docs/tui-plan.md): the preview pane's live tail, + * answering approvals, the prompt composer, search and the away digest. The + * seams are in place (the preview region renders a placeholder, the client + * already carries the calls) and the footer advertises only what works. + * + * @module tui/tui-app + */ + +import { spawnSync } from 'node:child_process'; +import { hostname as osHostname } from 'node:os'; +import chalk from 'chalk'; +import { palette, table, tint, type Tone } from '../cli-style.js'; +import { CODEMAN_INSTANCE, resolveTmuxSocketName } from '../config/instance.js'; +import { getErrorMessage } from '../types/api.js'; +import { TuiClient, type TuiEventStream, type TuiQuickStartOptions, type TuiTmuxSession } from './tui-client.js'; +import { createKeyParser, type TuiInputEvent, type TuiKeyParser } from './tui-keys.js'; +import { computeLayout, needsBanner } from './tui-layout.js'; +import { createTuiModel, type TuiModelStore } from './tui-model.js'; +import { detectGlyphTier, glyphsFor, renderFrame, rowLabel, type TuiGlyphSet } from './tui-render.js'; +import type { TuiRenderOptions } from './tui-render.js'; +import type { + TuiConfirmState, + TuiGlyphTier, + TuiPickerItem, + TuiRow, + TuiSessionRow, + TuiSessionState, + TuiUiMode, +} from './tui-types.js'; + +// ───────────────────────────────────────────────────────────────────────────── +// Timing +// ───────────────────────────────────────────────────────────────────────────── + +/** How long a lone ESC waits for the rest of a sequence before it counts as Escape. */ +const ESC_FLUSH_MS = 30; +/** Animation period. Two frames a second is what the plan's WORKING glyph asks for. */ +const TICK_MS = 500; +/** A burst of SSE events (one session change fans out to several) becomes one refetch. */ +const RESYNC_DEBOUNCE_MS = 250; +/** Poll period once the client reports SSE is not carrying events. */ +const POLL_INTERVAL_MS = 2_000; +/** Degraded mode re-probes this often, so a server that starts upgrades the TUI live. */ +const REPROBE_INTERVAL_MS = 10_000; +/** Unified-list page size. RECENT is capped far lower by the model. */ +const UNIFIED_LIMIT = 60; + +const ALT_SCREEN_ON = '\x1b[?1049h'; +const ALT_SCREEN_OFF = '\x1b[?1049l'; +const CURSOR_HIDE = '\x1b[?25l'; +const CURSOR_SHOW = '\x1b[?25h'; +/** DECSET 2026: terminals that know it show the frame atomically, the rest ignore it. */ +const SYNC_BEGIN = '\x1b[?2026h'; +const SYNC_END = '\x1b[?2026l'; + +// ───────────────────────────────────────────────────────────────────────────── +// Attach planning (pure) +// ───────────────────────────────────────────────────────────────────────────── + +export type TuiAttachRefusal = 'no-mux-name' | 'nested-foreign-socket'; + +export type TuiAttachPlan = + | { kind: 'attach'; file: string; args: string[]; hint: string } + | { kind: 'switch'; file: string; args: string[] } + | { kind: 'refuse'; reason: TuiAttachRefusal; message: string }; + +export interface TuiAttachContext { + /** This instance's tmux socket name (`-L`). */ + socket: string; + /** `$TMUX` as tmux sets it inside a pane: `,,`. */ + tmux?: string; +} + +/** + * The socket NAME behind a `$TMUX` value, or null when we are not inside tmux. + * tmux itself splits the variable on commas, so the path can be taken as + * everything before the first one; `-L ` sockets live in one directory + * per user, which makes the basename the name we compare against. + */ +export function tmuxSocketFromEnv(tmux: string | undefined): string | null { + const raw = (tmux ?? '').trim(); + if (!raw) return null; + const path = raw.split(',')[0]; + const name = path.split('/').filter(Boolean).pop(); + return name ?? null; +} + +/** + * How to reach a session's pane from where we are standing. + * + * Nesting is the case worth spelling out: inside tmux on a FOREIGN socket an + * attach would either be refused by tmux or produce a terminal inside a + * terminal whose prefix keys collide, so the TUI explains the situation instead + * of trying. + */ +export function planAttach(muxName: string | undefined, context: TuiAttachContext): TuiAttachPlan { + const name = (muxName ?? '').trim(); + if (!name) { + return { + kind: 'refuse', + reason: 'no-mux-name', + message: 'that session has no tmux pane to attach to (it is history, or it runs on a direct PTY)', + }; + } + const inside = tmuxSocketFromEnv(context.tmux); + if (inside === null) { + return { + kind: 'attach', + file: 'tmux', + args: ['-L', context.socket, 'attach-session', '-t', name], + hint: 'detach with Ctrl+B D to come back', + }; + } + if (inside === context.socket) { + return { kind: 'switch', file: 'tmux', args: ['-L', context.socket, 'switch-client', '-t', name] }; + } + return { + kind: 'refuse', + reason: 'nested-foreign-socket', + message: + `this terminal is already inside tmux on socket "${inside}", and Codeman's sessions live on "${context.socket}". ` + + 'Detach first (Ctrl+B D), then run codeman tui again.', + }; +} + +/** + * Is this the session the TUI itself runs in? Codeman exports + * `CODEMAN_SESSION_ID` into every managed pane, and killing that one would take + * the TUI down with it. Ids reach agents truncated, so either side may be the + * prefix; anything shorter than 8 characters is not identification. + */ +export function isSelfSession(sessionId: string, env: { CODEMAN_SESSION_ID?: string } = {}): boolean { + const self = (env.CODEMAN_SESSION_ID ?? '').trim(); + if (self.length < 8 || sessionId.length < 8) return false; + return sessionId.startsWith(self) || self.startsWith(sessionId); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Kill confirmation (pure) +// ───────────────────────────────────────────────────────────────────────────── + +export type TuiConfirmStep = + | { kind: 'typing'; typed: string } + | { kind: 'confirm' } + | { kind: 'reject' } + | { kind: 'cancel' } + | { kind: 'ignore' }; + +/** Does the typed text authorize the kill? The shown name, or the id prefix a mux name carries. */ +export function confirmAccepts(state: TuiConfirmState, typed = state.typed): boolean { + const value = typed.trim(); + if (value === '') return false; + return value === state.name || value === state.sessionId.slice(0, 8); +} + +/** + * One keystroke of the typed confirmation. Enter on text that does not match is + * a `reject`, never a silent no-op: a confirmation that appears to do nothing + * reads as a broken key. + */ +export function confirmKillStep(state: TuiConfirmState, event: TuiInputEvent): TuiConfirmStep { + switch (event.type) { + case 'char': + return { kind: 'typing', typed: state.typed + event.value }; + case 'backspace': + return { kind: 'typing', typed: [...state.typed].slice(0, -1).join('') }; + case 'enter': + return confirmAccepts(state) ? { kind: 'confirm' } : { kind: 'reject' }; + case 'escape': + return { kind: 'cancel' }; + case 'ctrl': + return event.key === 'c' ? { kind: 'cancel' } : { kind: 'ignore' }; + default: + return { kind: 'ignore' }; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Footer (pure) +// ───────────────────────────────────────────────────────────────────────────── + +export interface TuiKeymapContext { + /** False in degraded mode, where the only verb that works is attach. */ + server: boolean; +} + +/** + * The footer keys for a mode. This is the honest inventory of what the build + * actually does, not the plan's full keymap: a footer advertising `p prompt` + * before the composer exists teaches users that the TUI ignores keys. + */ +export function footerKeysFor(mode: TuiUiMode, glyphs: TuiGlyphSet, context: TuiKeymapContext): string[] { + switch (mode) { + case 'help': + return ['esc close']; + case 'confirm-kill': + return ['type the name', `${glyphs.enter} confirm`, 'esc cancel']; + case 'message': + return ['esc dismiss']; + case 'new-session': + return [`${glyphs.updown} select`, `${glyphs.enter} choose`, 'type to filter', 'esc cancel']; + case 'prompt': + case 'search': + return ['esc cancel']; + case 'list': + return context.server + ? [`${glyphs.updown} select`, `${glyphs.enter} attach`, '1-9 jump', 'n new', 'x kill', '? help', 'q quit'] + : [`${glyphs.updown} select`, `${glyphs.enter} attach`, '1-9 jump', '? help', 'q quit']; + } +} + +/** + * The help overlay's rows, for the same reason `footerKeysFor` exists: a help + * screen listing verbs the build does not implement is worse than no help. + */ +export function helpKeysFor(glyphs: TuiGlyphSet, context: TuiKeymapContext): Array<[string, string]> { + const keys: Array<[string, string]> = [ + [`${glyphs.updown} / j k`, 'select'], + [glyphs.enter, 'attach'], + ['1-9', 'jump and attach'], + ]; + if (context.server) keys.push(['n', 'new session'], ['x', 'kill (typed confirmation)']); + keys.push(['?', 'this help'], ['esc', 'close an overlay'], ['q', 'quit']); + return keys; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Repaint policy (pure) +// ───────────────────────────────────────────────────────────────────────────── + +/** Everything a frame depends on, cheap enough to compare on every event. */ +export interface TuiFrameKey { + revision: number; + cols: number; + rows: number; + tick: number; +} + +export function sameFrame(previous: TuiFrameKey | null, next: TuiFrameKey): boolean { + return ( + previous !== null && + previous.revision === next.revision && + previous.cols === next.cols && + previous.rows === next.rows && + previous.tick === next.tick + ); +} + +/** Only a WORKING row animates, so only a WORKING row justifies a tick timer. */ +export function shouldAnimate(rows: readonly TuiRow[]): boolean { + return rows.some((row) => row.state === 'working'); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Rows (pure) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * tmux sessions as dashboard rows, for when no server answered. + * + * They are tagged `live` deliberately: a running pane is the only liveness + * evidence that exists without a server, and the alternative (no `live` source) + * would classify every attachable session as history and file it under RECENT. + * With no classification running they land in IDLE, which is honest: unknown + * state, still attachable. + */ +export function tmuxRowsToSessions(sessions: readonly TuiTmuxSession[]): TuiSessionRow[] { + return sessions.map((session) => ({ + sessionId: session.sessionId ?? session.muxName, + muxName: session.muxName, + sources: ['live', 'mux'], + ...(session.name ? { name: session.name } : {}), + ...(session.mode ? { mode: session.mode } : {}), + ...(session.workingDir ? { workingDir: session.workingDir } : {}), + ...(session.createdAt ? { createdAt: session.createdAt, lastActivityAt: session.createdAt } : {}), + })); +} + +/** + * Stamp each row with the tmux session that backs it. The mux name carries only + * the first 8 characters of the session id (`codeman-`), so the join is + * by prefix; a row that matches nothing keeps no mux name and cannot be + * attached, which is exactly what the attach path then reports. + */ +export function applyMuxNames(sessions: readonly TuiSessionRow[], tmux: readonly TuiTmuxSession[]): TuiSessionRow[] { + if (tmux.length === 0) return sessions.map((session) => ({ ...session })); + return sessions.map((session) => { + const match = tmux.find( + (entry) => entry.sessionId === session.sessionId || session.sessionId.startsWith(entry.sessionIdPrefix) + ); + return match ? { ...session, muxName: match.muxName } : { ...session }; + }); +} + +const STATE_WORD: Record = { + 'blocked-permission': 'blocked', + 'blocked-question': 'blocked', + waiting: 'waiting', + working: 'working', + idle: 'idle', + recent: 'done', +}; + +const STATE_TONE: Record = { + 'blocked-permission': 'err', + 'blocked-question': 'err', + waiting: 'warn', + working: 'ok', + idle: 'idle', + recent: 'idle', +}; + +export interface TuiListLine { + /** 1-based position, the same number `codeman tui ` takes. */ + index: number; + state: TuiSessionState; + label: string; + workingDir: string; +} + +/** + * Label column cap. History rows are labelled by their opening prompt, and one + * long prompt pads every other row of the table out to its width. + */ +const LIST_LABEL_WIDTH = 48; + +function truncateLabel(text: string, width: number): string { + return text.length <= width ? text : `${text.slice(0, Math.max(1, width - 1))}…`; +} + +/** `--list` rows, in the dashboard's own order so `` and the TUI agree. */ +export function buildListLines(rows: readonly TuiRow[], labelWidth = LIST_LABEL_WIDTH): TuiListLine[] { + return rows.map((row, i) => ({ + index: i + 1, + state: row.state, + label: truncateLabel(rowLabel(row.session), labelWidth), + workingDir: row.session.workingDir ?? '', + })); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Session modes offered by the new-session picker +// ───────────────────────────────────────────────────────────────────────────── + +type TuiRunMode = NonNullable; + +const MODE_ITEMS: ReadonlyArray<{ id: TuiRunMode; label: string; detail: string }> = [ + { id: 'claude', label: 'claude', detail: 'Claude Code' }, + { id: 'shell', label: 'shell', detail: 'plain shell' }, + { id: 'opencode', label: 'opencode', detail: 'OpenCode' }, + { id: 'codex', label: 'codex', detail: 'OpenAI Codex' }, + { id: 'gemini', label: 'gemini', detail: 'Google Gemini' }, + { id: 'antigravity', label: 'antigravity', detail: 'Google Antigravity' }, + { id: 'pi', label: 'pi', detail: 'pi.dev' }, +]; + +// ───────────────────────────────────────────────────────────────────────────── +// Terminal +// ───────────────────────────────────────────────────────────────────────────── + +/** The slices of stdin/stdout the TUI uses; `process.stdin`/`stdout` satisfy both. */ +export interface TuiStdin extends NodeJS.EventEmitter { + isTTY?: boolean; + setRawMode?(mode: boolean): unknown; + resume(): unknown; + pause(): unknown; +} + +export interface TuiStdout { + isTTY?: boolean; + columns?: number; + rows?: number; + write(chunk: string): unknown; + on(event: 'resize', listener: () => void): unknown; + off(event: 'resize', listener: () => void): unknown; +} + +/** + * Alternate screen + raw mode, entered and left as one unit. `leave()` is + * idempotent and safe to call from a signal handler, an exit hook and the + * normal path in any order. + */ +class TerminalScreen { + private entered = false; + + constructor( + private readonly stdin: TuiStdin, + private readonly stdout: TuiStdout, + private readonly onResize: () => void + ) {} + + get active(): boolean { + return this.entered; + } + + enter(): void { + if (this.entered) return; + this.entered = true; + this.stdout.write(`${ALT_SCREEN_ON}${CURSOR_HIDE}`); + if (this.stdin.isTTY) this.stdin.setRawMode?.(true); + this.stdin.resume(); + this.stdout.on('resize', this.onResize); + } + + leave(): void { + if (!this.entered) return; + this.entered = false; + this.stdout.off('resize', this.onResize); + if (this.stdin.isTTY) this.stdin.setRawMode?.(false); + this.stdin.pause(); + this.stdout.write(`${CURSOR_SHOW}${ALT_SCREEN_OFF}`); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// The app +// ───────────────────────────────────────────────────────────────────────────── + +export interface TuiRunOptions { + stdin?: TuiStdin; + stdout?: TuiStdout; + env?: NodeJS.ProcessEnv; + /** Injected by tests; the default builds one from the environment. */ + client?: TuiClient; + /** Overrides chalk's detection. Chalk owns it everywhere else (see cli-style). */ + color?: boolean; +} + +interface PickerRuntime { + stage: 'case' | 'mode'; + caseName?: string; + /** Unfiltered items; the model holds the filtered view the renderer paints. */ + all: TuiPickerItem[]; +} + +class TuiApp { + private readonly model: TuiModelStore = createTuiModel(); + private readonly parser: TuiKeyParser = createKeyParser(); + private readonly screen: TerminalScreen; + private readonly stdin: TuiStdin; + private readonly stdout: TuiStdout; + private readonly env: NodeJS.ProcessEnv; + private readonly client: TuiClient; + private readonly color: boolean; + private readonly glyphTier: TuiGlyphTier; + private readonly glyphs: TuiGlyphSet; + private readonly socket = resolveTmuxSocketName(); + + private stream: TuiEventStream | null = null; + private tick = 0; + private lastFrame: TuiFrameKey | null = null; + private escTimer: NodeJS.Timeout | null = null; + private tickTimer: NodeJS.Timeout | null = null; + private resyncTimer: NodeJS.Timeout | null = null; + private pollTimer: NodeJS.Timeout | null = null; + private probeTimer: NodeJS.Timeout | null = null; + private refreshing = false; + private refreshQueued = false; + private picker: PickerRuntime | null = null; + private pendingSelectId: string | null = null; + private exiting = false; + private resolveExit: ((code: number) => void) | null = null; + + private readonly onData = (chunk: Buffer): void => this.feed(chunk); + private readonly onResize = (): void => this.paint(true); + private readonly onProcessExit = (): void => this.screen.leave(); + private readonly onSignal = (): void => this.quit(0); + private readonly onFatal = (error: unknown): void => { + this.screen.leave(); + process.stderr.write(`codeman tui: ${getErrorMessage(error)}\n`); + if (error instanceof Error && error.stack) process.stderr.write(`${error.stack}\n`); + process.exit(1); + }; + + constructor(options: TuiRunOptions) { + this.stdin = options.stdin ?? process.stdin; + this.stdout = options.stdout ?? process.stdout; + this.env = options.env ?? process.env; + this.client = options.client ?? new TuiClient(); + this.color = options.color ?? chalk.level > 0; + this.glyphTier = detectGlyphTier(this.env); + this.glyphs = glyphsFor(this.glyphTier); + this.screen = new TerminalScreen(this.stdin, this.stdout, this.onResize); + } + + async run(): Promise { + const server = await this.client.connect(); + if (server?.authRequired) { + this.client.close(); + process.stderr.write( + `${palette.err('The Codeman server rejected these credentials.')}\n` + + `Set ${palette.info('CODEMAN_PASSWORD')} (and ${palette.info('CODEMAN_USERNAME')} if it is not "admin"), ` + + 'or put them in ~/.codeman/.env, then run codeman tui again.\n' + ); + return 1; + } + + if (server) { + this.model.setConnection('connected'); + this.model.setHeader({ + ...(server.hostname ? { hostname: server.hostname } : {}), + ...(server.instance ? { instance: server.instance } : {}), + ...(server.version ? { version: server.version } : {}), + }); + } else { + this.model.setConnection('degraded'); + // No server to name the machine, and tmux is local by definition. + this.model.setHeader({ + hostname: osHostname(), + ...(CODEMAN_INSTANCE ? { instance: CODEMAN_INSTANCE } : {}), + }); + this.startProbing(); + } + + this.installSafetyNets(); + this.stdin.on('data', this.onData); + this.screen.enter(); + this.paint(true); + + await this.refresh(); + if (server) this.subscribe(); + + return new Promise((resolve) => { + this.resolveExit = resolve; + }); + } + + // ── Data ─────────────────────────────────────────────────────────────────── + + private subscribe(): void { + this.stream = this.client.subscribeEvents({ + onInit: (state) => { + if (state.version) this.model.setHeader({ version: state.version }); + this.paint(); + }, + onResync: () => this.scheduleRefresh(), + onApproval: () => this.scheduleRefresh(), + onStatus: (status, detail) => { + this.model.setConnection(status === 'connected' ? 'connected' : 'reconnecting'); + if (detail.recommendPolling) this.startPolling(); + else this.stopPolling(); + if (status === 'connected') this.scheduleRefresh(); + this.paint(); + }, + }); + } + + private scheduleRefresh(): void { + if (this.resyncTimer) return; + this.resyncTimer = setTimeout(() => { + this.resyncTimer = null; + void this.refresh(); + }, RESYNC_DEBOUNCE_MS); + } + + /** + * Re-read everything the dashboard shows. Overlapping calls collapse: a burst + * of events must not queue a burst of round trips, and the last one has to + * still run or the list would sit one change behind. + */ + private async refresh(): Promise { + if (this.exiting) return; + if (this.refreshing) { + this.refreshQueued = true; + return; + } + this.refreshing = true; + try { + if (this.model.connection === 'degraded') await this.refreshDegraded(); + else await this.refreshConnected(); + } finally { + this.refreshing = false; + } + if (this.refreshQueued && !this.exiting) { + this.refreshQueued = false; + await this.refresh(); + } + } + + private async refreshConnected(): Promise { + try { + const [sessions, approvals, tmux] = await Promise.all([ + this.client.fetchUnifiedSessions(UNIFIED_LIMIT), + this.client.fetchApprovals().catch(() => []), + this.client.enumerateTmuxSessions().catch(() => [] as TuiTmuxSession[]), + ]); + this.model.replaceSessions(applyMuxNames(sessions, tmux)); + this.model.setApprovals(approvals); + if (this.pendingSelectId && this.model.select(this.pendingSelectId)) this.pendingSelectId = null; + this.syncPreviewPlaceholder(); + this.paint(); + } catch (error) { + // A failed refresh is a connection symptom, not a reason to lose the list: + // the rows on screen stay, the banner explains why they may be stale. + this.model.setConnection('reconnecting'); + this.paint(); + if (this.env.CODEMAN_TUI_DEBUG) process.stderr.write(`refresh failed: ${getErrorMessage(error)}\n`); + } + } + + private async refreshDegraded(): Promise { + const tmux = await this.client.enumerateTmuxSessions().catch(() => [] as TuiTmuxSession[]); + this.model.replaceSessions(tmuxRowsToSessions(tmux)); + this.syncPreviewPlaceholder(); + this.paint(); + } + + private startPolling(): void { + if (this.pollTimer) return; + this.pollTimer = setInterval(() => void this.refresh(), POLL_INTERVAL_MS); + } + + private stopPolling(): void { + if (!this.pollTimer) return; + clearInterval(this.pollTimer); + this.pollTimer = null; + } + + /** Degraded mode: re-probe so a server that comes up upgrades the TUI in place. */ + private startProbing(): void { + if (this.probeTimer) return; + this.probeTimer = setInterval(() => void this.probe(), REPROBE_INTERVAL_MS); + } + + private async probe(): Promise { + if (this.exiting || this.model.connection !== 'degraded') return; + const server = await this.client.connect().catch(() => null); + if (!server || server.authRequired) { + await this.refresh(); + return; + } + if (this.probeTimer) { + clearInterval(this.probeTimer); + this.probeTimer = null; + } + this.model.setConnection('connected'); + this.model.setHeader({ + ...(server.hostname ? { hostname: server.hostname } : {}), + ...(server.instance ? { instance: server.instance } : {}), + ...(server.version ? { version: server.version } : {}), + }); + await this.refresh(); + this.subscribe(); + } + + /** + * The preview pane is phase 2. Until then the selected row still gets a + * preview object, so the pane says what it is instead of claiming to load + * something forever. + */ + private syncPreviewPlaceholder(): void { + const selected = this.model.selectedId; + if (!selected) { + if (this.model.preview) this.model.setPreview(null); + return; + } + if (this.model.preview?.sessionId === selected) return; + this.model.setPreview({ sessionId: selected, lines: [], error: 'live preview is not wired up yet' }); + } + + // ── Painting ─────────────────────────────────────────────────────────────── + + private paint(force = false): void { + if (this.exiting || !this.screen.active) return; + const cols = this.stdout.columns ?? 80; + const rows = this.stdout.rows ?? 24; + const key: TuiFrameKey = { revision: this.model.revision, cols, rows, tick: this.tick }; + if (!force && sameFrame(this.lastFrame, key)) return; + this.lastFrame = key; + + const layout = computeLayout(cols, rows, { banner: needsBanner(this.model.connection) }); + const keymap: TuiKeymapContext = { server: this.model.connection !== 'degraded' }; + const options: TuiRenderOptions = { + color: this.color, + glyphs: this.glyphTier, + tick: this.tick, + now: Date.now(), + footerKeys: footerKeysFor(this.model.mode, this.glyphs, keymap), + helpKeys: helpKeysFor(this.glyphs, keymap), + }; + this.stdout.write(`${SYNC_BEGIN}${renderFrame(this.model, layout, options)}${SYNC_END}`); + this.syncAnimation(); + } + + private syncAnimation(): void { + const wanted = shouldAnimate(this.model.rows()); + if (wanted && !this.tickTimer) { + this.tickTimer = setInterval(() => { + this.tick++; + this.paint(); + }, TICK_MS); + return; + } + if (!wanted && this.tickTimer) { + clearInterval(this.tickTimer); + this.tickTimer = null; + } + } + + // ── Input ────────────────────────────────────────────────────────────────── + + private feed(chunk: Buffer): void { + for (const event of this.parser.feed(chunk)) this.handle(event); + if (this.escTimer) { + clearTimeout(this.escTimer); + this.escTimer = null; + } + // A held ESC is either a lone Escape or the head of a sequence still in + // flight; only silence tells the two apart. + if (this.parser.pending() > 0) { + this.escTimer = setTimeout(() => { + this.escTimer = null; + for (const event of this.parser.flush()) this.handle(event); + this.paint(); + }, ESC_FLUSH_MS); + } + this.paint(); + } + + private handle(event: TuiInputEvent): void { + if (this.exiting) return; + switch (this.model.mode) { + case 'confirm-kill': + this.handleConfirm(event); + return; + case 'new-session': + this.handlePicker(event); + return; + case 'help': + case 'message': + // Any key dismisses; the footer says esc because that is the one key + // every overlay in the app answers to. + if (event.type !== 'mouse') this.model.closeOverlay(); + return; + default: + this.handleList(event); + } + } + + private handleList(event: TuiInputEvent): void { + switch (event.type) { + case 'key': + if (event.name === 'up') this.model.moveCursor(-1); + else if (event.name === 'down') this.model.moveCursor(1); + else if (event.name === 'pageup') this.model.moveCursor(-5); + else if (event.name === 'pagedown') this.model.moveCursor(5); + else return; + this.syncPreviewPlaceholder(); + return; + case 'enter': + void this.attachSelected(); + return; + case 'ctrl': + if (event.key === 'c') this.quit(0); + return; + case 'char': + this.handleListChar(event.value); + return; + default: + return; + } + } + + private handleListChar(value: string): void { + if (value >= '1' && value <= '9') { + if (this.model.cursorToIndex(Number.parseInt(value, 10))) { + this.syncPreviewPlaceholder(); + void this.attachSelected(); + } + return; + } + switch (value) { + case 'j': + this.model.moveCursor(1); + this.syncPreviewPlaceholder(); + return; + case 'k': + this.model.moveCursor(-1); + this.syncPreviewPlaceholder(); + return; + case 'q': + this.quit(0); + return; + case '?': + this.model.setMode('help'); + return; + case 'x': + this.beginKill(); + return; + case 'n': + void this.openNewSession(); + return; + default: + return; + } + } + + private handleConfirm(event: TuiInputEvent): void { + const state = this.model.confirm; + if (!state) { + this.model.closeOverlay(); + return; + } + const step = confirmKillStep(state, event); + switch (step.kind) { + case 'typing': + this.model.setConfirmInput(step.typed); + return; + case 'cancel': + this.model.closeOverlay(); + return; + case 'reject': + this.message('warn', `type "${state.name}" exactly, or esc to cancel`); + return; + case 'confirm': + void this.killSession(state.sessionId, state.name); + return; + case 'ignore': + return; + } + } + + // ── Actions ──────────────────────────────────────────────────────────────── + + private message(tone: 'info' | 'warn' | 'err', text: string): void { + this.model.setMessage({ tone, text }); + } + + private async attachSelected(): Promise { + const row = this.model.selectedSession(); + if (!row) return; + if (row.group === 'recent') { + this.message('warn', 'that session is not running; resuming a past session is not wired up yet'); + return; + } + const plan = planAttach(row.session.muxName, { + socket: this.socket, + ...(this.env.TMUX ? { tmux: this.env.TMUX } : {}), + }); + if (plan.kind === 'refuse') { + this.message('warn', plan.message); + return; + } + + if (plan.kind === 'switch') { + // The client this TUI draws on is about to show another session, so the + // dashboard has nothing left to draw and no reason to keep polling. + this.screen.leave(); + const result = spawnSync(plan.file, plan.args, { stdio: 'inherit' }); + if (result.error) { + process.stderr.write(`codeman tui: ${getErrorMessage(result.error)}\n`); + this.quit(1); + return; + } + this.quit(result.status ?? 0); + return; + } + + this.screen.leave(); + this.stdout.write(`${plan.hint}\n`); + const result = spawnSync(plan.file, plan.args, { stdio: 'inherit' }); + this.screen.enter(); + this.paint(true); + if (result.error) { + this.message('err', `tmux attach failed: ${getErrorMessage(result.error)}`); + return; + } + await this.refresh(); + this.paint(true); + } + + private beginKill(): void { + const row = this.model.selectedSession(); + if (!row) return; + if (this.model.connection === 'degraded') { + this.message('warn', 'killing a session needs the server; only attach works while it is down'); + return; + } + if (row.group === 'recent') { + this.message('warn', 'that row is history: there is no session left to kill'); + return; + } + if (isSelfSession(row.session.sessionId, this.env)) { + this.message('warn', 'that is the session this TUI is running in'); + return; + } + this.model.beginConfirmKill(row); + } + + private async killSession(sessionId: string, name: string): Promise { + this.model.closeOverlay(); + try { + await this.client.deleteSession(sessionId); + await this.refresh(); + this.message('info', `killed ${name}`); + } catch (error) { + this.message('err', `could not kill ${name}: ${getErrorMessage(error)}`); + } + this.paint(); + } + + private async openNewSession(): Promise { + if (this.model.connection === 'degraded') { + this.message('warn', 'starting a session needs the server; only attach works while it is down'); + return; + } + this.picker = { stage: 'case', all: [] }; + this.model.setPicker({ title: 'New session', items: [], index: -1, filter: '', hint: 'loading cases…' }); + this.paint(); + + let items: TuiPickerItem[]; + try { + const cases = await this.client.fetchCases(); + items = cases.map((entry) => ({ + id: entry.name, + label: entry.name, + ...(entry.location && entry.location !== 'local' ? { detail: entry.location } : {}), + })); + } catch (error) { + this.picker = null; + this.message('err', `could not list cases: ${getErrorMessage(error)}`); + this.paint(); + return; + } + + // The picker can be gone already: fetching cases takes a round trip and esc + // works throughout it. + if (!this.picker || this.picker.stage !== 'case') return; + this.picker.all = items; + this.showPicker('Pick a case', items.length > 0 ? 'which case should the session run in?' : 'no cases found'); + this.paint(); + } + + private showPicker(title: string, hint: string, filter = ''): void { + const runtime = this.picker; + if (!runtime) return; + const needle = filter.trim().toLowerCase(); + const items = needle ? runtime.all.filter((item) => item.label.toLowerCase().includes(needle)) : [...runtime.all]; + this.model.setPicker({ title, items, index: items.length > 0 ? 0 : -1, filter, hint }); + } + + private handlePicker(event: TuiInputEvent): void { + const state = this.model.picker; + const runtime = this.picker; + if (!state || !runtime) { + this.model.closeOverlay(); + return; + } + switch (event.type) { + case 'escape': + this.picker = null; + this.model.closeOverlay(); + return; + case 'ctrl': + if (event.key === 'c') { + this.picker = null; + this.model.closeOverlay(); + } + return; + case 'key': + if (event.name === 'up') this.movePicker(-1); + else if (event.name === 'down') this.movePicker(1); + return; + case 'backspace': + this.showPicker(state.title, state.hint ?? '', [...(state.filter ?? '')].slice(0, -1).join('')); + return; + case 'char': + this.showPicker(state.title, state.hint ?? '', `${state.filter ?? ''}${event.value}`); + return; + case 'enter': + this.choosePicked(); + return; + default: + return; + } + } + + private movePicker(delta: number): void { + const state = this.model.picker; + if (!state || state.items.length === 0) return; + const next = (((state.index + delta) % state.items.length) + state.items.length) % state.items.length; + this.model.setPicker({ ...state, index: next }); + } + + private choosePicked(): void { + const state = this.model.picker; + const runtime = this.picker; + if (!state || !runtime || state.index < 0 || state.index >= state.items.length) return; + const chosen = state.items[state.index]; + + if (runtime.stage === 'case') { + this.picker = { + stage: 'mode', + caseName: chosen.id, + all: MODE_ITEMS.map((mode) => ({ id: mode.id, label: mode.label, detail: mode.detail })), + }; + this.showPicker('Pick a CLI', `new session in ${chosen.label}`); + return; + } + + const caseName = runtime.caseName; + // Resolved against the table rather than cast: the picker's ids are strings + // and quick-start refuses a mode the server does not know. + const mode = MODE_ITEMS.find((entry) => entry.id === chosen.id); + this.picker = null; + this.model.closeOverlay(); + if (caseName && mode) void this.startSession(caseName, mode.id); + } + + private async startSession(caseName: string, mode: TuiRunMode): Promise { + try { + const result = await this.client.quickStart({ caseName, mode }); + // The row appears with the next resync; remember which one to select. + this.pendingSelectId = result.sessionId; + this.message('info', `started ${mode} in ${caseName}`); + await this.refresh(); + } catch (error) { + this.message('err', `could not start a session in ${caseName}: ${getErrorMessage(error)}`); + } + this.paint(); + } + + // ── Lifecycle ────────────────────────────────────────────────────────────── + + private installSafetyNets(): void { + process.on('exit', this.onProcessExit); + process.on('SIGINT', this.onSignal); + process.on('SIGTERM', this.onSignal); + // Prepended: src/index.ts already handles both by exiting, and a listener + // registered after it would never run, leaving the terminal in raw mode. + process.prependListener('uncaughtException', this.onFatal); + process.prependListener('unhandledRejection', this.onFatal); + } + + private removeSafetyNets(): void { + process.off('exit', this.onProcessExit); + process.off('SIGINT', this.onSignal); + process.off('SIGTERM', this.onSignal); + process.off('uncaughtException', this.onFatal); + process.off('unhandledRejection', this.onFatal); + } + + private quit(code: number): void { + if (this.exiting) return; + this.exiting = true; + for (const timer of [this.escTimer, this.resyncTimer]) if (timer) clearTimeout(timer); + for (const timer of [this.tickTimer, this.pollTimer, this.probeTimer]) if (timer) clearInterval(timer); + this.escTimer = null; + this.resyncTimer = null; + this.tickTimer = null; + this.pollTimer = null; + this.probeTimer = null; + this.stream?.close(); + this.client.close(); + this.stdin.off('data', this.onData); + this.screen.leave(); + this.removeSafetyNets(); + this.resolveExit?.(code); + this.resolveExit = null; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Entry points +// ───────────────────────────────────────────────────────────────────────────── + +type Snapshot = { kind: 'ok'; rows: TuiRow[]; degraded: boolean } | { kind: 'auth' }; + +/** + * One read of the world for the non-interactive paths. Same client, same + * classification and same ordering as the dashboard, so `--list`'s numbers are + * the numbers `codeman tui ` takes. + */ +async function snapshot(client: TuiClient): Promise { + const server = await client.connect(); + if (server?.authRequired) return { kind: 'auth' }; + const model = createTuiModel(); + if (!server) { + model.replaceSessions(tmuxRowsToSessions(await client.enumerateTmuxSessions())); + return { kind: 'ok', rows: model.rows(), degraded: true }; + } + const [sessions, approvals, tmux] = await Promise.all([ + client.fetchUnifiedSessions(UNIFIED_LIMIT), + client.fetchApprovals().catch(() => []), + client.enumerateTmuxSessions().catch(() => [] as TuiTmuxSession[]), + ]); + model.replaceSessions(applyMuxNames(sessions, tmux)); + model.setApprovals(approvals); + return { kind: 'ok', rows: model.rows(), degraded: false }; +} + +function authHint(): string { + return ( + `${palette.err('The Codeman server rejected these credentials.')}\n` + + `Set ${palette.info('CODEMAN_PASSWORD')} (and ${palette.info('CODEMAN_USERNAME')} if it is not "admin"), ` + + 'or put them in ~/.codeman/.env.\n' + ); +} + +/** The full-screen dashboard. */ +export async function runTui(options: TuiRunOptions = {}): Promise { + const stdin = options.stdin ?? process.stdin; + const stdout = options.stdout ?? process.stdout; + if (!stdout.isTTY || !stdin.isTTY) { + process.stderr.write( + `${palette.err('codeman tui needs an interactive terminal.')}\n` + + `Use ${palette.info('codeman tui --list')} for a plain list, or ${palette.info('codeman tui ')} to attach.\n` + ); + return 1; + } + return new TuiApp(options).run(); +} + +/** + * `codeman tui --list`: the `sc -l` replacement. Prints and exits, colored when + * the output is a terminal and plain when it is piped (chalk's call, not ours). + */ +export async function runTuiList(options: TuiRunOptions = {}): Promise { + const stdout = options.stdout ?? process.stdout; + const client = options.client ?? new TuiClient(); + try { + const state = await snapshot(client).catch((error: unknown) => { + process.stderr.write(`${palette.err(getErrorMessage(error))}\n`); + return null; + }); + if (!state) return 1; + if (state.kind === 'auth') { + process.stderr.write(authHint()); + return 1; + } + if (state.degraded) { + process.stderr.write(`${palette.warn('server not running: listing tmux sessions only')}\n`); + } + const lines = buildListLines(state.rows); + if (lines.length === 0) { + process.stderr.write(`${palette.muted('no sessions')}\n`); + return 0; + } + const rows = lines.map((line) => [ + palette.muted(String(line.index)), + tint(STATE_TONE[line.state], STATE_WORD[line.state]), + line.label, + palette.muted(line.workingDir), + ]); + stdout.write(`${table(rows, { indent: ' ' })}\n`); + return 0; + } finally { + client.close(); + } +} + +/** + * `codeman tui `: the `sc 2` replacement. No screen setup at all, so it is + * as fast as the API call it makes. + */ +export async function runTuiAttach(position: number, options: TuiRunOptions = {}): Promise { + const stdin = options.stdin ?? process.stdin; + const stdout = options.stdout ?? process.stdout; + if (!stdout.isTTY || !stdin.isTTY) { + process.stderr.write(`${palette.err('attaching needs an interactive terminal.')}\n`); + return 1; + } + const env = options.env ?? process.env; + const client = options.client ?? new TuiClient(); + try { + const state = await snapshot(client).catch((error: unknown) => { + process.stderr.write(`${palette.err(getErrorMessage(error))}\n`); + return null; + }); + if (!state) return 1; + if (state.kind === 'auth') { + process.stderr.write(authHint()); + return 1; + } + const row = state.rows[Math.trunc(position) - 1]; + if (!row) { + process.stderr.write( + `${palette.err(`there is no session ${position}`)}\nRun ${palette.info('codeman tui --list')} to see the numbers.\n` + ); + return 1; + } + const plan = planAttach(row.session.muxName, { + socket: resolveTmuxSocketName(), + ...(env.TMUX ? { tmux: env.TMUX } : {}), + }); + if (plan.kind === 'refuse') { + process.stderr.write(`${palette.warn(plan.message)}\n`); + return 1; + } + if (plan.kind === 'attach') stdout.write(`${palette.muted(plan.hint)}\n`); + const result = spawnSync(plan.file, plan.args, { stdio: 'inherit' }); + if (result.error) { + process.stderr.write(`${palette.err(getErrorMessage(result.error))}\n`); + return 1; + } + return result.status ?? 0; + } finally { + client.close(); + } +} diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts new file mode 100644 index 00000000..d940db0a --- /dev/null +++ b/test/tui/tui-app.test.ts @@ -0,0 +1,300 @@ +/** + * @fileoverview Unit tests for the decisions `codeman tui` makes, without a + * terminal. + * + * Everything the app does that can be stated as a function of its inputs is + * exported from `tui-app.ts` for exactly this reason: the attach handoff (which + * changes shape inside tmux), the typed kill confirmation, the footer's honest + * key inventory, the repaint test and the row building for degraded mode. The + * full-screen loop itself is covered end-to-end under node-pty in + * `tui-e2e.test.ts`. + */ +import { describe, it, expect } from 'vitest'; +import { + applyMuxNames, + buildListLines, + confirmAccepts, + confirmKillStep, + footerKeysFor, + helpKeysFor, + isSelfSession, + planAttach, + sameFrame, + shouldAnimate, + tmuxRowsToSessions, + tmuxSocketFromEnv, +} from '../../src/tui/tui-app.js'; +import { createTuiModel } from '../../src/tui/tui-model.js'; +import { glyphsFor } from '../../src/tui/tui-render.js'; +import type { TuiTmuxSession } from '../../src/tui/tui-client.js'; +import type { TuiConfirmState, TuiRow, TuiSessionRow } from '../../src/tui/tui-types.js'; + +const GLYPHS = glyphsFor('unicode'); + +function tmuxSession(overrides: Partial & { muxName: string }): TuiTmuxSession { + return { + sessionIdPrefix: overrides.muxName.replace(/^codeman-/, ''), + attached: false, + ...overrides, + }; +} + +function row(state: TuiRow['state'], sessionId = 'abcdef0123'): TuiRow { + const session: TuiSessionRow = { sessionId, sources: ['live'] }; + return { session, state, group: state === 'working' ? 'working' : 'idle', since: 0 }; +} + +describe('tmuxSocketFromEnv', () => { + it('reads the socket name out of a $TMUX value', () => { + expect(tmuxSocketFromEnv('/tmp/tmux-1000/codeman,31415,0')).toBe('codeman'); + expect(tmuxSocketFromEnv('/tmp/tmux-1000/codeman-beta,7,2')).toBe('codeman-beta'); + // A bare default socket is still the name a `-L` comparison needs. + expect(tmuxSocketFromEnv('/tmp/tmux-1000/default,7,2')).toBe('default'); + }); + + it('reports "not inside tmux" for an absent or empty value', () => { + expect(tmuxSocketFromEnv(undefined)).toBeNull(); + expect(tmuxSocketFromEnv('')).toBeNull(); + expect(tmuxSocketFromEnv(' ')).toBeNull(); + }); +}); + +describe('planAttach', () => { + it('attaches with the instance socket when the terminal is not inside tmux', () => { + const plan = planAttach('codeman-abcdef01', { socket: 'codeman' }); + expect(plan).toEqual({ + kind: 'attach', + file: 'tmux', + args: ['-L', 'codeman', 'attach-session', '-t', 'codeman-abcdef01'], + hint: expect.stringContaining('Ctrl+B D'), + }); + }); + + it('switches the current client when already inside tmux on the same socket', () => { + const plan = planAttach('codeman-abcdef01', { socket: 'codeman', tmux: '/tmp/tmux-1000/codeman,31415,0' }); + expect(plan).toEqual({ + kind: 'switch', + file: 'tmux', + args: ['-L', 'codeman', 'switch-client', '-t', 'codeman-abcdef01'], + }); + }); + + it('refuses to nest when the surrounding tmux is a different server', () => { + const plan = planAttach('codeman-abcdef01', { socket: 'codeman', tmux: '/tmp/tmux-1000/default,31415,0' }); + expect(plan.kind).toBe('refuse'); + if (plan.kind !== 'refuse') throw new Error('expected a refusal'); + expect(plan.reason).toBe('nested-foreign-socket'); + expect(plan.message).toContain('default'); + expect(plan.message).toContain('codeman'); + }); + + it('refuses a row with no tmux session behind it', () => { + for (const name of [undefined, '', ' ']) { + const plan = planAttach(name, { socket: 'codeman' }); + expect(plan.kind).toBe('refuse'); + if (plan.kind !== 'refuse') throw new Error('expected a refusal'); + expect(plan.reason).toBe('no-mux-name'); + } + }); + + it('never builds a shell string: every field is its own argv entry', () => { + const plan = planAttach('codeman-abcdef01', { socket: 'codeman' }); + if (plan.kind !== 'attach') throw new Error('expected an attach'); + expect(plan.args.some((arg) => arg.includes(' '))).toBe(false); + }); +}); + +describe('isSelfSession', () => { + const id = 'abcdef01-2345-6789-abcd-ef0123456789'; + + it('recognizes the session the TUI runs in, however the id was truncated', () => { + expect(isSelfSession(id, { CODEMAN_SESSION_ID: id })).toBe(true); + expect(isSelfSession(id, { CODEMAN_SESSION_ID: 'abcdef01' })).toBe(true); + expect(isSelfSession('abcdef01', { CODEMAN_SESSION_ID: id })).toBe(true); + }); + + it('is false for another session, and for env that identifies nothing', () => { + expect(isSelfSession(id, { CODEMAN_SESSION_ID: 'ffffffff' })).toBe(false); + expect(isSelfSession(id, {})).toBe(false); + expect(isSelfSession(id, { CODEMAN_SESSION_ID: 'abc' })).toBe(false); + }); +}); + +describe('the kill confirmation', () => { + const state: TuiConfirmState = { sessionId: 'abcdef01-2345', name: 'w4-api', typed: '' }; + + it('accepts the shown name or the id prefix a mux name carries, and nothing else', () => { + expect(confirmAccepts(state, 'w4-api')).toBe(true); + expect(confirmAccepts(state, ' w4-api ')).toBe(true); + expect(confirmAccepts(state, 'abcdef01')).toBe(true); + expect(confirmAccepts(state, 'w4')).toBe(false); + expect(confirmAccepts(state, 'W4-API')).toBe(false); + expect(confirmAccepts(state, '')).toBe(false); + expect(confirmAccepts(state, ' ')).toBe(false); + }); + + it('types, backspaces and cancels', () => { + expect(confirmKillStep({ ...state, typed: 'w4' }, { type: 'char', value: '-' })).toEqual({ + kind: 'typing', + typed: 'w4-', + }); + expect(confirmKillStep({ ...state, typed: 'w4-' }, { type: 'backspace' })).toEqual({ kind: 'typing', typed: 'w4' }); + expect(confirmKillStep({ ...state, typed: '' }, { type: 'backspace' })).toEqual({ kind: 'typing', typed: '' }); + expect(confirmKillStep(state, { type: 'escape' })).toEqual({ kind: 'cancel' }); + expect(confirmKillStep(state, { type: 'ctrl', key: 'c' })).toEqual({ kind: 'cancel' }); + expect(confirmKillStep(state, { type: 'ctrl', key: 'a' })).toEqual({ kind: 'ignore' }); + expect(confirmKillStep(state, { type: 'tab' })).toEqual({ kind: 'ignore' }); + }); + + it('confirms only on a match, and says so rather than doing nothing otherwise', () => { + expect(confirmKillStep({ ...state, typed: 'w4-api' }, { type: 'enter' })).toEqual({ kind: 'confirm' }); + expect(confirmKillStep({ ...state, typed: 'w4' }, { type: 'enter' })).toEqual({ kind: 'reject' }); + expect(confirmKillStep({ ...state, typed: '' }, { type: 'enter' })).toEqual({ kind: 'reject' }); + }); + + it('backspaces one whole character, not one code unit', () => { + expect(confirmKillStep({ ...state, typed: 'a🙂' }, { type: 'backspace' })).toEqual({ kind: 'typing', typed: 'a' }); + }); +}); + +describe('footerKeysFor', () => { + it('advertises only the verbs this build implements', () => { + const keys = footerKeysFor('list', GLYPHS, { server: true }).join(' '); + expect(keys).toContain('attach'); + expect(keys).toContain('1-9 jump'); + expect(keys).toContain('n new'); + expect(keys).toContain('x kill'); + expect(keys).toContain('q quit'); + for (const missing of ['prompt', 'search', 'digest', 'answer', 'resume']) { + expect(keys).not.toContain(missing); + } + }); + + it('drops the server-only verbs in degraded mode', () => { + const keys = footerKeysFor('list', GLYPHS, { server: false }).join(' '); + expect(keys).toContain('attach'); + expect(keys).not.toContain('kill'); + expect(keys).not.toContain('new'); + }); + + it('keeps the help overlay to the same inventory', () => { + const help = helpKeysFor(GLYPHS, { server: true }); + expect(help.map(([, description]) => description)).toEqual( + expect.arrayContaining(['attach', 'new session', 'kill (typed confirmation)', 'quit']) + ); + expect(help.flat().join(' ')).not.toContain('search'); + expect(helpKeysFor(GLYPHS, { server: false }).flat().join(' ')).not.toContain('kill'); + }); + + it('follows the overlay that owns the keyboard', () => { + expect(footerKeysFor('help', GLYPHS, { server: true })).toEqual(['esc close']); + expect(footerKeysFor('confirm-kill', GLYPHS, { server: true }).join(' ')).toContain('type the name'); + expect(footerKeysFor('message', GLYPHS, { server: true })).toEqual(['esc dismiss']); + expect(footerKeysFor('new-session', GLYPHS, { server: true }).join(' ')).toContain('type to filter'); + }); +}); + +describe('the repaint test', () => { + const key = { revision: 3, cols: 100, rows: 30, tick: 0 }; + + it('repaints on a first frame, a state change, a resize and a tick', () => { + expect(sameFrame(null, key)).toBe(false); + expect(sameFrame(key, { ...key, revision: 4 })).toBe(false); + expect(sameFrame(key, { ...key, cols: 80 })).toBe(false); + expect(sameFrame(key, { ...key, rows: 24 })).toBe(false); + expect(sameFrame(key, { ...key, tick: 1 })).toBe(false); + }); + + it('writes nothing when nothing changed', () => { + expect(sameFrame(key, { ...key })).toBe(true); + }); + + it('only animates while a WORKING row is on screen', () => { + expect(shouldAnimate([row('idle'), row('recent')])).toBe(false); + expect(shouldAnimate([row('idle'), row('working', 'bbbb1111')])).toBe(true); + expect(shouldAnimate([])).toBe(false); + }); +}); + +describe('degraded-mode rows', () => { + const sessions: TuiTmuxSession[] = [ + tmuxSession({ muxName: 'codeman-abcdef01', sessionId: 'abcdef01-2345', name: 'w4-api', workingDir: '/dev/api' }), + tmuxSession({ muxName: 'codeman-99887766', createdAt: 1_000_000 }), + ]; + + it('keeps the tmux name and falls back to it as the row key', () => { + const rows = tmuxRowsToSessions(sessions); + expect(rows[0]).toMatchObject({ + sessionId: 'abcdef01-2345', + muxName: 'codeman-abcdef01', + name: 'w4-api', + workingDir: '/dev/api', + }); + // No state.json entry: the mux name is the only identity there is. + expect(rows[1].sessionId).toBe('codeman-99887766'); + expect(rows[1].muxName).toBe('codeman-99887766'); + }); + + it('classifies a running pane as IDLE rather than history', () => { + const model = createTuiModel(); + model.replaceSessions(tmuxRowsToSessions(sessions)); + const groups = model.groups(); + expect(groups.find((group) => group.key === 'idle')?.rows).toHaveLength(2); + expect(groups.find((group) => group.key === 'recent')?.rows).toHaveLength(0); + expect(model.sessionCount).toBe(2); + }); +}); + +describe('applyMuxNames', () => { + const tmux: TuiTmuxSession[] = [ + tmuxSession({ muxName: 'codeman-abcdef01', sessionId: 'abcdef01-2345-6789' }), + tmuxSession({ muxName: 'codeman-99887766' }), + ]; + + it('joins on the 8-character prefix a mux name carries', () => { + const rows = applyMuxNames( + [ + { sessionId: 'abcdef01-2345-6789', sources: ['live'] }, + { sessionId: '99887766-0000-1111', sources: ['live'] }, + { sessionId: 'deadbeef-0000-1111', sources: ['history'] }, + ], + tmux + ); + expect(rows[0].muxName).toBe('codeman-abcdef01'); + expect(rows[1].muxName).toBe('codeman-99887766'); + // Nothing in tmux backs it, so attach has to refuse rather than guess. + expect(rows[2].muxName).toBeUndefined(); + }); + + it('copies rather than mutating its input, and survives an empty tmux list', () => { + const input: TuiSessionRow[] = [{ sessionId: 'abcdef01-2345-6789', sources: ['live'] }]; + const rows = applyMuxNames(input, []); + expect(rows[0]).not.toBe(input[0]); + expect(rows[0].muxName).toBeUndefined(); + expect(input[0].muxName).toBeUndefined(); + }); +}); + +describe('buildListLines', () => { + it('numbers rows in the dashboard order, so `tui ` and `tui --list` agree', () => { + const model = createTuiModel(); + model.replaceSessions([ + { sessionId: 'aaaa1111', name: 'quiet', sources: ['live'], lastActivityAt: 10 }, + { sessionId: 'bbbb2222', name: 'busy', sources: ['live'], isWorking: true, lastSubmitAt: 5 }, + { sessionId: 'cccc3333', name: 'past', sources: ['history'], lastActivityAt: 1 }, + ]); + expect(buildListLines(model.rows())).toEqual([ + { index: 1, state: 'working', label: 'busy', workingDir: '' }, + { index: 2, state: 'idle', label: 'quiet', workingDir: '' }, + { index: 3, state: 'recent', label: 'past', workingDir: '' }, + ]); + }); + + it('truncates the label so one long prompt cannot pad the whole table', () => { + const model = createTuiModel(); + model.replaceSessions([{ sessionId: 'aaaa1111', firstPrompt: 'x'.repeat(200), sources: ['history'] }]); + const [line] = buildListLines(model.rows(), 20); + expect(line.label).toHaveLength(20); + expect(line.label.endsWith('…')).toBe(true); + }); +}); From 941b11fcc35bf8cd4a5dc2e08424535bd5eb2e3f Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 20:01:17 +0200 Subject: [PATCH 21/57] feat: register the tui command with its two fast paths `codeman tui` opens the dashboard, `codeman tui --list` prints the numbered list and exits (the `sc -l` replacement, plain when piped) and `codeman tui ` attaches straight to a row (the `sc 2` replacement). Both fast paths short-circuit before any screen setup, and both refuse the numbers path without a terminal instead of half-opening a UI. Bare `codeman` still prints help: the web UI stays the primary surface. The TUI module is imported lazily so the other commands do not pay for it at startup. Co-Authored-By: Claude Fable 5 --- src/cli.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/cli.ts b/src/cli.ts index a1f18270..7408f131 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -800,6 +800,38 @@ program .description('List active sessions (shorthand; `codeman session list` also shows stopped ones)') .action(() => printSessionList({ includeStored: false })); +// ============ 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; + } + 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; + } + // 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 ============ /** Shared option set for the commands that can launch a web server. */ From b39bb40c11bbbaf136ab13eaaa88171a2179f5fe Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 20:01:19 +0200 Subject: [PATCH 22/57] test: drive codeman tui end to end under a pty Spawns the real command in a pseudo-terminal against a fake API server (canned status/unified/approvals plus an SSE stream the test pushes into), which is the only way to cover raw-mode key decoding, frames reaching a terminal, SSE-driven refresh and the exit sequence that has to restore the user's screen. Two details the assertions depend on: frames are addressed absolutely rather than newline-separated, so the parser takes the last COMPLETE frame (the pty delivers one in several chunks, and reading a half-written frame would be racy), and it reads the sidebar column only, or a name echoed in the preview pane could answer for a row. The child gets its own data dir and a tmux socket name nothing runs on, so nothing here can see or touch the machine's real sessions. Co-Authored-By: Claude Fable 5 --- test/tui/tui-e2e.test.ts | 354 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 test/tui/tui-e2e.test.ts diff --git a/test/tui/tui-e2e.test.ts b/test/tui/tui-e2e.test.ts new file mode 100644 index 00000000..3bda7d96 --- /dev/null +++ b/test/tui/tui-e2e.test.ts @@ -0,0 +1,354 @@ +/** + * @fileoverview End-to-end test for `codeman tui` in a real terminal. + * + * The dashboard is spawned under node-pty against a fake API server, so this + * covers everything the pure tests cannot: raw-mode key decoding, the frame + * actually reaching a terminal, SSE-driven refresh, and the exit sequence that + * has to restore the user's screen. Frames are addressed absolutely rather than + * newline-separated, so the assertions parse the LAST frame out of the captured + * bytes and read its list column. + * + * The child gets its own data dir and a tmux socket name nothing runs on, which + * keeps the enumeration that degraded mode and the attach path use from seeing + * the machine's real sessions. Nothing here attaches, kills or writes anything. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn } from 'node:child_process'; +import http from 'node:http'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import * as pty from 'node-pty'; +import { computeLayout } from '../../src/tui/tui-layout.js'; +import type { UnifiedSessionItem } from '../../src/services/unified-session-service.js'; + +const PORT = 3244; +const BASE_URL = `http://127.0.0.1:${PORT}`; +const ROOT = resolve(import.meta.dirname, '..', '..'); +const COLS = 100; +const ROWS = 30; +const LIST_WIDTH = computeLayout(COLS, ROWS).list.width; + +const NOW = Date.now(); + +/** Mutable so a test can add a session and announce it over SSE. */ +let sessions: UnifiedSessionItem[] = []; + +function resetSessions(): void { + sessions = [ + { + sessionId: 'bbbb2222-0000-0000-0000-000000000000', + name: 'w2-beta', + mode: 'claude', + sources: ['live'], + isWorking: true, + workingDir: '/tmp/beta', + createdAt: NOW - 600_000, + lastActivityAt: NOW, + }, + { + sessionId: 'aaaa1111-0000-0000-0000-000000000000', + name: 'w1-alpha', + mode: 'claude', + sources: ['live'], + status: 'idle', + workingDir: '/tmp/alpha', + createdAt: NOW - 900_000, + lastActivityAt: NOW - 60_000, + }, + { + sessionId: 'cccc3333-0000-0000-0000-000000000000', + name: 'w3-gamma', + sources: ['history'], + workingDir: '/tmp/gamma', + lastActivityAt: NOW - 3_600_000, + }, + ]; +} + +let server: http.Server; +const sseClients = new Set(); +let dataDir = ''; + +function sendJson(res: http.ServerResponse, payload: unknown): void { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); +} + +function pushEvent(event: string, data: unknown): void { + for (const client of sseClients) client.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); +} + +function childEnv(): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) if (value !== undefined) env[key] = value; + // The suite runs inside a Codeman-managed tmux pane, whose environment would + // otherwise point the child at the LIVE server and make it think it is nested. + delete env.TMUX; + delete env.TMUX_PANE; + delete env.CODEMAN_SESSION_ID; + delete env.FORCE_COLOR; + delete env.CODEMAN_PORT; + return { + ...env, + CODEMAN_API_URL: BASE_URL, + CODEMAN_DATA_DIR: dataDir, + CODEMAN_TMUX_SOCKET: 'codeman-tui-e2e', + CODEMAN_TUI_GLYPHS: 'ascii', + NO_COLOR: '1', + TERM: 'xterm-256color', + LANG: 'C.UTF-8', + }; +} + +beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'codeman-tui-e2e-')); + resetSessions(); + server = http.createServer((req, res) => { + const url = req.url ?? ''; + if (url.startsWith('/api/events')) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + res.write(`event: init\ndata: ${JSON.stringify({ version: '9.9.9', planUsage: null })}\n\n`); + sseClients.add(res); + req.on('close', () => sseClients.delete(res)); + return; + } + if (url.startsWith('/api/status')) return sendJson(res, { success: true, data: { version: '9.9.9' } }); + if (url.startsWith('/api/sessions/unified')) return sendJson(res, { success: true, data: { sessions } }); + if (url.startsWith('/api/approvals')) return sendJson(res, { success: true, data: { approvals: [] } }); + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'no route', errorCode: 'NOT_FOUND' })); + }); + await new Promise((done) => server.listen(PORT, '127.0.0.1', done)); +}); + +afterAll(async () => { + for (const client of sseClients) client.destroy(); + sseClients.clear(); + await new Promise((done) => server.close(() => done())); + if (dataDir) rmSync(dataDir, { recursive: true, force: true }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Frame parsing +// ───────────────────────────────────────────────────────────────────────────── + +const ANSI = /\u001b\[[0-9;?]*[a-zA-Z]/g; + +/** + * The last COMPLETE frame, one entry per terminal row. Frames start at + * `ESC [ 1;1 H` and address every row absolutely, so splitting on the + * addressing sequences reconstructs the lines. The pty delivers a frame in + * several chunks, so the newest one is often half-written: taking it would make + * every assertion that indexes a line racy. + */ +function frameLines(raw: string): string[] { + const frames = raw.split('\u001b[1;1H').slice(1); + for (let i = frames.length - 1; i >= 0; i--) { + const lines = frames[i].split(/\u001b\[\d+;1H/).map((line) => line.replace(ANSI, '').replace(/\s+$/, '')); + if (lines.length >= ROWS) return lines.slice(0, ROWS); + } + return []; +} + +/** Just the sidebar column, so a name in the preview pane cannot answer for a row. */ +function listLines(raw: string): string[] { + return frameLines(raw).map((line) => line.slice(0, LIST_WIDTH).replace(/\s+$/, '')); +} + +function rowFor(raw: string, name: string): string { + return listLines(raw).find((line) => line.includes(name)) ?? ''; +} + +async function waitFor(predicate: () => boolean, what: string, timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + if (predicate()) return; + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await new Promise((done) => setTimeout(done, 50)); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// The dashboard +// ───────────────────────────────────────────────────────────────────────────── + +describe('codeman tui (under a pty)', () => { + let term: pty.IPty; + let output = ''; + let exitCode: number | null = null; + + beforeAll(async () => { + term = pty.spawn('npx', ['tsx', 'src/index.ts', 'tui'], { + name: 'xterm-256color', + cols: COLS, + rows: ROWS, + cwd: ROOT, + env: childEnv(), + }); + term.onData((data) => { + output += data; + }); + term.onExit(({ exitCode: code }) => { + exitCode = code; + }); + await waitFor(() => output.includes('w2-beta'), 'the first frame', 25_000); + }, 40_000); + + afterAll(() => { + if (exitCode === null) term.kill(); + }); + + it('enters the alternate screen and hides the cursor', () => { + expect(output).toContain('\u001b[?1049h'); + expect(output).toContain('\u001b[?25l'); + }); + + it('groups the sessions the way the dashboard promises', () => { + const lines = listLines(output); + const index = (needle: string) => lines.findIndex((line) => line.includes(needle)); + expect(index('WORKING')).toBeGreaterThan(0); + expect(index('WORKING')).toBeLessThan(index('w2-beta')); + expect(index('w2-beta')).toBeLessThan(index('IDLE')); + expect(index('IDLE')).toBeLessThan(index('w1-alpha')); + expect(index('w1-alpha')).toBeLessThan(index('RECENT')); + expect(index('RECENT')).toBeLessThan(index('w3-gamma')); + }); + + it('shows the header facts and only the keys that work', () => { + const lines = frameLines(output); + expect(lines[0]).toContain('codeman'); + expect(lines[0]).toContain('v9.9.9'); + // Two live rows; the history row is not a session you have open. + expect(lines[0]).toContain('2 sessions'); + const footer = lines[ROWS - 1]; + expect(footer).toContain('attach'); + expect(footer).toContain('x kill'); + expect(footer).not.toContain('search'); + }); + + it('holds the preview seam open instead of pretending to load one', () => { + expect(frameLines(output).join('\n')).toContain('live preview is not wired up yet'); + }); + + it('starts with the first row selected and moves the cursor with j / k', async () => { + expect(rowFor(output, 'w2-beta').startsWith('>')).toBe(true); + + term.write('j'); + await waitFor(() => rowFor(output, 'w1-alpha').startsWith('>'), 'j to select the next row'); + expect(rowFor(output, 'w2-beta').startsWith('>')).toBe(false); + + term.write('k'); + await waitFor(() => rowFor(output, 'w2-beta').startsWith('>'), 'k to select the previous row'); + }); + + it('moves the cursor with the arrow keys', async () => { + term.write('\u001b[B'); + await waitFor(() => rowFor(output, 'w1-alpha').startsWith('>'), 'the down arrow to move the cursor'); + term.write('\u001b[A'); + await waitFor(() => rowFor(output, 'w2-beta').startsWith('>'), 'the up arrow to move the cursor'); + }); + + it('picks up a session announced over SSE', async () => { + sessions = [ + ...sessions, + { + sessionId: 'dddd4444-0000-0000-0000-000000000000', + name: 'w4-delta', + mode: 'shell', + sources: ['live'], + status: 'idle', + workingDir: '/tmp/delta', + createdAt: NOW, + lastActivityAt: NOW, + }, + ]; + pushEvent('session:created', { id: 'dddd4444-0000-0000-0000-000000000000' }); + await waitFor(() => listLines(output).some((line) => line.includes('w4-delta')), 'the new session to appear'); + expect(frameLines(output)[0]).toContain('3 sessions'); + }); + + it('opens and closes the help overlay', async () => { + term.write('?'); + await waitFor(() => frameLines(output).some((line) => line.includes('Keys')), 'the help overlay'); + expect(frameLines(output).join('\n')).toContain('kill (typed confirmation)'); + + term.write('\u001b'); + await waitFor(() => !frameLines(output).some((line) => line.includes('Keys')), 'escape to close the overlay'); + }); + + it('asks for the session name before killing anything', async () => { + term.write('x'); + await waitFor(() => frameLines(output).some((line) => line.includes('Kill session')), 'the kill confirmation'); + const overlay = frameLines(output).join('\n'); + expect(overlay).toContain('Type the name to confirm'); + expect(overlay).toContain('w2-beta'); + + term.write('\u001b'); + await waitFor(() => !frameLines(output).some((line) => line.includes('Kill session')), 'escape to cancel the kill'); + }); + + it('quits on q and restores the screen it took over', async () => { + term.write('q'); + await waitFor(() => exitCode !== null, 'the TUI to exit', 10_000); + expect(exitCode).toBe(0); + expect(output).toContain('\u001b[?25h'); + expect(output).toContain('\u001b[?1049l'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// The non-interactive fast paths +// ───────────────────────────────────────────────────────────────────────────── + +interface RunResult { + code: number | null; + stdout: string; + stderr: string; +} + +/** Run the CLI with pipes, which is exactly the "not a TTY" case. */ +function runPiped(args: string[]): Promise { + return new Promise((done) => { + const child = spawn('npx', ['tsx', 'src/index.ts', ...args], { cwd: ROOT, env: childEnv() }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf-8'); + child.stderr.setEncoding('utf-8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('close', (code) => done({ code, stdout, stderr })); + }); +} + +describe('codeman tui --list', () => { + it('prints the numbered list and exits 0 when piped', async () => { + const result = await runPiped(['tui', '--list']); + expect(result.code).toBe(0); + expect(result.stdout).toContain('w2-beta'); + expect(result.stdout).toContain('w1-alpha'); + expect(result.stdout).toContain('/tmp/alpha'); + // Same ordering as the dashboard: WORKING first, history last. + expect(result.stdout.indexOf('w2-beta')).toBeLessThan(result.stdout.indexOf('w1-alpha')); + expect(result.stdout.indexOf('w1-alpha')).toBeLessThan(result.stdout.indexOf('w3-gamma')); + expect(result.stdout).toMatch(/^\s+1\s+working\s+w2-beta/m); + }, 30_000); +}); + +describe('codeman tui without a terminal', () => { + it('refuses to open the dashboard and points at the fast paths', async () => { + const result = await runPiped(['tui']); + expect(result.code).toBe(1); + expect(result.stderr).toContain('interactive terminal'); + expect(result.stderr).toContain('--list'); + expect(result.stdout).not.toContain('\u001b[?1049h'); + }, 30_000); +}); From bfc7e698b601ac36862cb45dee16845eceb7046c Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 20:29:13 +0200 Subject: [PATCH 23/57] feat: add the TUI's editor, approval and digest pure cores Three small pure modules the phase-2 verbs are built on: - tui-composer: the single-line editor behind `p` and `/`, holding text as code points so a cursor can never split a surrogate pair, with the scroll window derived from the width rather than remembered. - tui-approvals: what an approvals-inbox item's card says, which keys are live for it (a digit answers only when the server parsed that option, and an idle prompt answers to none of them), and which ids the bell has not rung for yet. - tui-digest: the away digest as compact lines, counts first and one line per entry, with a capped tail per section. Co-Authored-By: Claude Fable 5 --- src/tui/tui-approvals.ts | 137 ++++++++++++++++++++++ src/tui/tui-composer.ts | 205 +++++++++++++++++++++++++++++++++ src/tui/tui-digest.ts | 90 +++++++++++++++ test/tui/tui-approvals.test.ts | 143 +++++++++++++++++++++++ test/tui/tui-composer.test.ts | 150 ++++++++++++++++++++++++ test/tui/tui-digest.test.ts | 127 ++++++++++++++++++++ 6 files changed, 852 insertions(+) create mode 100644 src/tui/tui-approvals.ts create mode 100644 src/tui/tui-composer.ts create mode 100644 src/tui/tui-digest.ts create mode 100644 test/tui/tui-approvals.test.ts create mode 100644 test/tui/tui-composer.test.ts create mode 100644 test/tui/tui-digest.test.ts diff --git a/src/tui/tui-approvals.ts b/src/tui/tui-approvals.ts new file mode 100644 index 00000000..2cb10dea --- /dev/null +++ b/src/tui/tui-approvals.ts @@ -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, 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; +} diff --git a/src/tui/tui-composer.ts b/src/tui/tui-composer.ts new file mode 100644 index 00000000..4677a0c1 --- /dev/null +++ b/src/tui/tui-composer.ts @@ -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' }; + } +} diff --git a/src/tui/tui-digest.ts b/src/tui/tui-digest.ts new file mode 100644 index 00000000..e93c4327 --- /dev/null +++ b/src/tui/tui-digest.ts @@ -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 = [ + ['needsAttention', 'NEEDS ATTENTION'], + ['completed', 'COMPLETED'], + ['stillRunning', 'STILL RUNNING'], + ['idle', 'IDLE'], + ['informational', 'INFO'], +]; + +const RANGE_WORDS: Record = { + '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; +} diff --git a/test/tui/tui-approvals.test.ts b/test/tui/tui-approvals.test.ts new file mode 100644 index 00000000..f9072f80 --- /dev/null +++ b/test/tui/tui-approvals.test.ts @@ -0,0 +1,143 @@ +/** + * @fileoverview Unit tests for reading an approvals-inbox item. + * + * The key matrix is the part worth pinning: a digit the server did not parse + * off the pane must NOT produce an answer (it would be typed at a dialog that + * has no such option), and an idle prompt must produce none at all, since there + * is no dialog on screen and every keystroke would land in the composer. + */ +import { describe, it, expect } from 'vitest'; +import { + approvalAnswerForKey, + approvalCard, + approvalDenyOption, + approvalTone, + newApprovalIds, +} from '../../src/tui/tui-approvals.js'; +import type { ApprovalItem } from '../../src/web/approval-inbox.js'; + +const NOW = 1_700_000_000_000; + +function item(overrides: Partial = {}): ApprovalItem { + return { + id: 'sess:1', + sessionId: 'sess', + sessionName: 'w4-api', + kind: 'permission', + createdAt: NOW, + toolName: 'Bash', + toolSummary: 'Bash(git push origin main)', + options: [ + { n: 1, label: 'Yes' }, + { n: 2, label: "Yes, don't ask again" }, + { n: 3, label: 'No, tell Claude what to do (esc)' }, + ], + ...overrides, + }; +} + +describe('approvalCard', () => { + it('leads a permission prompt with the tool it wants to run', () => { + const card = approvalCard(item()); + expect(card.tone).toBe('err'); + expect(card.title).toContain('Bash(git push origin main)'); + expect(card.options).toHaveLength(3); + expect(card.hint).toContain('y approve'); + expect(card.hint).toContain('digit'); + }); + + it('leads a question with its message', () => { + const card = approvalCard(item({ kind: 'question', message: 'Which color?', toolSummary: undefined })); + expect(card.title).toBe('Which color?'); + expect(card.tone).toBe('err'); + }); + + it('says an idle prompt is answered by typing, not by approving', () => { + const card = approvalCard(item({ kind: 'idle', message: 'waiting for input', options: undefined })); + expect(card.tone).toBe('warn'); + expect(card.options).toEqual([]); + expect(card.hint).toBe('p to reply'); + }); + + it('drops the approve/deny-only hint when the frame did not parse', () => { + const card = approvalCard(item({ options: undefined })); + expect(card.options).toEqual([]); + expect(card.hint).toBe('y approve · n deny'); + }); + + it('keeps the message as detail when it says more than the tool line', () => { + expect(approvalCard(item({ message: 'about to force-push' })).detail).toEqual(['about to force-push']); + expect(approvalCard(item({ message: 'Bash(git push origin main)' })).detail).toEqual([]); + }); + + it('collapses whitespace so a wrapped hook field cannot break the card', () => { + expect(approvalCard(item({ toolSummary: 'Bash(git\n push)' })).title).toBe('requests: Bash(git push)'); + }); +}); + +describe('approvalTone', () => { + it('is red for a dialog and yellow for a waiting prompt', () => { + expect(approvalTone(item())).toBe('err'); + expect(approvalTone(item({ kind: 'question' }))).toBe('err'); + expect(approvalTone(item({ kind: 'idle' }))).toBe('warn'); + }); +}); + +describe('approvalAnswerForKey', () => { + it('approves with y', () => { + expect(approvalAnswerForKey(item(), 'y')).toEqual({ action: 'approve' }); + }); + + it('denies with the parsed No option when there is one', () => { + expect(approvalDenyOption(item())).toBe(3); + expect(approvalAnswerForKey(item(), 'n')).toEqual({ action: 'option', option: 3 }); + }); + + it('falls back to Esc semantics when no No option parsed', () => { + expect(approvalDenyOption(item({ options: undefined }))).toBeNull(); + expect(approvalAnswerForKey(item({ options: undefined }), 'n')).toEqual({ action: 'deny' }); + expect( + approvalAnswerForKey( + item({ + options: [ + { n: 1, label: 'Red' }, + { n: 2, label: 'Blue' }, + ], + }), + 'n' + ) + ).toEqual({ + action: 'deny', + }); + }); + + it('answers with a digit only when the server parsed that option', () => { + expect(approvalAnswerForKey(item(), '2')).toEqual({ action: 'option', option: 2 }); + expect(approvalAnswerForKey(item(), '4')).toBeNull(); + expect(approvalAnswerForKey(item({ options: undefined }), '1')).toBeNull(); + }); + + it('makes no key an answer for an idle prompt', () => { + const idle = item({ kind: 'idle', options: undefined }); + for (const key of ['y', 'n', '1', '2', '9']) expect(approvalAnswerForKey(idle, key)).toBeNull(); + }); + + it('leaves every other key to the list', () => { + for (const key of ['j', 'k', 'q', 'x', 'p', '/', 'g', '0']) { + expect(approvalAnswerForKey(item(), key)).toBeNull(); + } + }); +}); + +describe('newApprovalIds', () => { + it('reports only ids the set has not seen', () => { + const seen = new Set(['sess:1']); + expect(newApprovalIds(seen, [item(), item({ id: 'other:7', sessionId: 'other' })])).toEqual(['other:7']); + expect(newApprovalIds(seen, [item()])).toEqual([]); + expect(newApprovalIds(new Set(), [])).toEqual([]); + }); + + it('reports one id once even when it arrives twice', () => { + expect(newApprovalIds(new Set(), [item(), item()])).toEqual(['sess:1']); + }); +}); diff --git a/test/tui/tui-composer.test.ts b/test/tui/tui-composer.test.ts new file mode 100644 index 00000000..00a48e15 --- /dev/null +++ b/test/tui/tui-composer.test.ts @@ -0,0 +1,150 @@ +/** + * @fileoverview Unit tests for the single-line editor behind `p` and `/`. + * + * The interesting parts are the ones a terminal makes hard to see: a cursor + * that must not split a surrogate pair, a combining mark that belongs to the + * character before it, and the scroll window, which is the only reason a long + * prompt stays typeable in a footer one line tall. + */ +import { describe, it, expect } from 'vitest'; +import { + composerBackspace, + composerDelete, + composerDeleteWord, + composerEnd, + composerHome, + composerInsert, + composerMove, + composerScroll, + composerStep, + composerText, + composerWindow, + createComposer, +} from '../../src/tui/tui-composer.js'; + +describe('editing', () => { + it('inserts at the cursor and keeps it after the insertion', () => { + let state = createComposer('abc'); + expect(composerText(state)).toBe('abc'); + expect(state.cursor).toBe(3); + + state = composerMove(state, -1); + state = composerInsert(state, 'XY'); + expect(composerText(state)).toBe('abXYc'); + expect(state.cursor).toBe(4); + }); + + it('never lets a newline into a single-line editor', () => { + const state = composerInsert(createComposer(), 'one\ntwo\r\nthree'); + expect(composerText(state)).toBe('one two three'); + }); + + it('deletes whole characters, not code units', () => { + const state = composerBackspace(createComposer('a🙂')); + expect(composerText(state)).toBe('a'); + expect(state.cursor).toBe(1); + }); + + it('deletes forward under the cursor and stops at the end', () => { + const state = composerHome(createComposer('abc')); + expect(composerText(composerDelete(state))).toBe('bc'); + expect(composerText(composerDelete(createComposer('abc')))).toBe('abc'); + }); + + it('deletes a word back over its trailing spaces', () => { + expect(composerText(composerDeleteWord(createComposer('fix the bug ')))).toBe('fix the '); + expect(composerText(composerDeleteWord(createComposer('word')))).toBe(''); + expect(composerText(composerDeleteWord(createComposer('')))).toBe(''); + }); + + it('clamps the cursor at both ends', () => { + const state = createComposer('abc'); + expect(composerMove(state, 10).cursor).toBe(3); + expect(composerMove(state, -10).cursor).toBe(0); + expect(composerHome(state).cursor).toBe(0); + expect(composerEnd(composerHome(state)).cursor).toBe(3); + }); + + it('leaves a no-op edit as the same object, so nothing repaints', () => { + const state = createComposer('abc'); + const atStart = composerHome(state); + expect(composerInsert(state, '')).toBe(state); + expect(composerMove(state, 1)).toBe(state); + expect(composerBackspace(atStart)).toBe(atStart); + }); +}); + +describe('the scroll window', () => { + it('shows the whole text while it fits', () => { + const window = composerWindow(createComposer('short'), 20); + expect(window.text).toBe('short'); + expect(window.cursorColumn).toBe(5); + expect(window.scroll).toBe(0); + }); + + it('scrolls just far enough to keep the cursor visible', () => { + // 10 columns of room, one reserved for the cursor itself. + const state = composerScroll(createComposer('0123456789abcdef'), 10); + const window = composerWindow(state, 10); + expect(window.scroll).toBe(7); + expect(window.text).toBe('789abcdef'); + expect(window.cursorColumn).toBe(9); + }); + + it('scrolls back when the cursor moves left out of the window', () => { + let state = composerScroll(createComposer('0123456789abcdef'), 10); + expect(state.scroll).toBe(7); + state = composerScroll(composerHome(state), 10); + expect(state.scroll).toBe(0); + expect(composerWindow(state, 10).cursorColumn).toBe(0); + }); + + it('counts a double-width character as the two columns it takes', () => { + const state = composerScroll(createComposer('日本語です'), 6); + const window = composerWindow(state, 6); + // Five wide characters = 10 columns; the window holds the last three (6 + // columns) minus the cell the cursor needs. + expect(window.cursorColumn).toBeLessThanOrEqual(5); + expect(window.text.length).toBeLessThanOrEqual(5); + expect(composerText(state)).toBe('日本語です'); + }); + + it('survives a width of one', () => { + const state = composerScroll(createComposer('abc'), 1); + expect(() => composerWindow(state, 1)).not.toThrow(); + expect(composerWindow(state, 1).cursorColumn).toBe(0); + }); +}); + +describe('composerStep', () => { + const state = createComposer('ab'); + + it('reports Enter and Escape instead of acting on them', () => { + expect(composerStep(state, { type: 'enter' })).toEqual({ kind: 'submit', text: 'ab' }); + expect(composerStep(state, { type: 'escape' })).toEqual({ kind: 'cancel' }); + expect(composerStep(state, { type: 'ctrl', key: 'c' })).toEqual({ kind: 'cancel' }); + }); + + it('maps the editing keys', () => { + expect(composerStep(state, { type: 'char', value: 'c' })).toEqual({ + kind: 'edit', + state: expect.objectContaining({ cursor: 3 }), + }); + expect(composerStep(state, { type: 'key', name: 'left' })).toEqual({ + kind: 'edit', + state: expect.objectContaining({ cursor: 1 }), + }); + expect(composerStep(state, { type: 'ctrl', key: 'u' })).toEqual({ + kind: 'edit', + state: expect.objectContaining({ cursor: 0 }), + }); + expect(composerText((composerStep(state, { type: 'ctrl', key: 'u' }) as { state: never }).state)).toBe(''); + }); + + it('ignores keys that mean nothing to an editor', () => { + expect(composerStep(state, { type: 'tab' })).toEqual({ kind: 'ignore' }); + expect(composerStep(state, { type: 'key', name: 'pageup' })).toEqual({ kind: 'ignore' }); + expect(composerStep(state, { type: 'ctrl', key: 'x' })).toEqual({ kind: 'ignore' }); + expect(composerStep(state, { type: 'mouse', kind: 'press', x: 1, y: 1, button: 0 })).toEqual({ kind: 'ignore' }); + }); +}); diff --git a/test/tui/tui-digest.test.ts b/test/tui/tui-digest.test.ts new file mode 100644 index 00000000..2ac2bee5 --- /dev/null +++ b/test/tui/tui-digest.test.ts @@ -0,0 +1,127 @@ +/** + * @fileoverview Unit tests for the away digest's compact rendering. + * + * The digest is read top-down and never studied, so the promises worth pinning + * are: the counts sit in the first line, every entry is exactly one line, and a + * long section is capped with a tail rather than pushing the next section off + * the overlay. + */ +import { describe, it, expect } from 'vitest'; +import { formatAwayDigest } from '../../src/tui/tui-digest.js'; +import type { AwayDigestItem, AwayDigestResponse } from '../../src/web/away-digest.js'; + +const NOW = 1_700_000_000_000; + +function entry(overrides: Partial = {}): AwayDigestItem { + return { + id: 'e1', + timestamp: NOW - 120_000, + category: 'needs_attention', + severity: 'warning', + title: 'permission prompt', + source: 'lifecycle', + sessionName: 'w4-api', + ...overrides, + }; +} + +function digest(overrides: Partial = {}): AwayDigestResponse { + return { + range: { range: '24h', since: NOW - 86_400_000, until: NOW }, + generatedAt: NOW, + dataFreshness: { + lifecyclePersisted: true, + tokenStatsPersisted: true, + runSummariesLiveOnly: true, + subagentsLiveOnly: true, + }, + totals: { + sessionsCreated: 3, + sessionsExited: 1, + activeSessions: 2, + needsAttention: 1, + completed: 1, + errors: 0, + warnings: 1, + tokenWindowPrecision: 'day', + }, + sections: { needsAttention: [], completed: [], stillRunning: [], idle: [], informational: [] }, + ...overrides, + }; +} + +describe('formatAwayDigest', () => { + it('opens with the range and the counts', () => { + const lines = formatAwayDigest(digest(), { now: NOW }); + expect(lines[0]).toBe('the last 24 hours · 3 started · 1 exited · 2 running'); + }); + + it('names the range the way the API labels it', () => { + const since = digest({ range: { range: 'since-last-visit', since: NOW - 1000, until: NOW } }); + expect(formatAwayDigest(since, { now: NOW })[0]).toContain('since your last visit'); + }); + + it('gives every entry one line, with its age and session', () => { + const lines = formatAwayDigest( + digest({ + sections: { + needsAttention: [entry({ detail: 'Bash(git push)' })], + completed: [], + stillRunning: [], + idle: [], + informational: [], + }, + }), + { now: NOW } + ); + expect(lines).toContain('NEEDS ATTENTION (1)'); + expect(lines).toContain(' 2m w4-api permission prompt — Bash(git push)'); + }); + + it('caps a long section instead of burying the next one', () => { + const many = Array.from({ length: 9 }, (_, i) => entry({ id: `e${i}`, title: `event ${i}` })); + const lines = formatAwayDigest( + digest({ + sections: { + needsAttention: many, + completed: [entry({ id: 'c1', category: 'completed', title: 'finished' })], + stillRunning: [], + idle: [], + informational: [], + }, + }), + { now: NOW, sectionLimit: 3 } + ); + expect(lines).toContain('NEEDS ATTENTION (9)'); + expect(lines).toContain(' … 6 more'); + expect(lines).toContain('COMPLETED (1)'); + }); + + it('says so when nothing happened', () => { + expect(formatAwayDigest(digest(), { now: NOW })).toContain('nothing happened while you were away'); + }); + + it('adds the token totals only when the range had any', () => { + expect(formatAwayDigest(digest(), { now: NOW }).join('\n')).not.toContain('tokens:'); + const withTokens = digest({ + totals: { ...digest().totals, inputTokens: 45_200, outputTokens: 12_100, estimatedCost: 1.234 }, + }); + expect(formatAwayDigest(withTokens, { now: NOW })).toContain('tokens: 45.2k in · 12.1k out · $1.23'); + }); + + it('drops the age column for an entry with no usable timestamp', () => { + const lines = formatAwayDigest( + digest({ + sections: { + needsAttention: [entry({ timestamp: 0, sessionName: undefined, sessionId: 'abcdef1234' })], + completed: [], + stillRunning: [], + idle: [], + informational: [], + }, + }), + { now: NOW } + ); + expect(lines).toContain(' abcdef12 permission prompt'); + }); +}); From ff9735fc166356cfca5c2f1f1cdfc471add5aa9e Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 20:29:20 +0200 Subject: [PATCH 24/57] feat: hold composer, search and digest state in the TUI model The store gains the three overlays phase 2 needs, each taking the keyboard when it is set and all of them cleared together by closeOverlay(), plus the pure flattening of `GET /api/search`'s typed groups into rows a cursor can move over: headers are chrome, and only a session that is on the list counts as selectable, since a history hit has no row to move the cursor to. Co-Authored-By: Claude Fable 5 --- src/tui/tui-model.ts | 122 +++++++++++++++++++++++++++++++++++++ src/tui/tui-types.ts | 52 +++++++++++++++- test/tui/tui-model.test.ts | 107 ++++++++++++++++++++++++++++++++ 3 files changed, 280 insertions(+), 1 deletion(-) diff --git a/src/tui/tui-model.ts b/src/tui/tui-model.ts index 2512f131..aebaa15d 100644 --- a/src/tui/tui-model.ts +++ b/src/tui/tui-model.ts @@ -18,18 +18,23 @@ * @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, @@ -193,6 +198,71 @@ export function mergeSessionRow(existing: TuiSessionRow, incoming: TuiSessionRow return merged; } +// ───────────────────────────────────────────────────────────────────────────── +// Search results (pure) +// ───────────────────────────────────────────────────────────────────────────── + +const SEARCH_GROUP_LABELS: Record = { + session: 'SESSIONS', + event: 'EVENTS', + file: 'FILES', +}; + +/** + * 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); + entries.push({ + kind: 'result', + text: result.jumpTo.relativePath ?? result.sessionName ?? result.sessionId.slice(0, 8), + detail: result.snippet, + 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, @@ -211,6 +281,9 @@ export class TuiModelStore implements TuiRenderModel { 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 = {}) { @@ -309,6 +382,52 @@ export class TuiModelStore implements TuiRenderModel { 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): 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). */ beginConfirmKill(row: TuiRow): void { this.confirm = { @@ -336,6 +455,9 @@ export class TuiModelStore implements TuiRenderModel { this.confirm = null; this.message = null; this.picker = null; + this.prompt = null; + this.search = null; + this.digest = null; this.mode = 'list'; this.touch(); } diff --git a/src/tui/tui-types.ts b/src/tui/tui-types.ts index ffbf9e39..c30232be 100644 --- a/src/tui/tui-types.ts +++ b/src/tui/tui-types.ts @@ -15,6 +15,7 @@ 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. @@ -77,7 +78,7 @@ export interface TuiGroup { export type TuiConnectionStatus = 'connected' | 'reconnecting' | 'degraded' | 'down'; /** Which overlay (if any) owns the keyboard. */ -export type TuiUiMode = 'list' | 'help' | 'confirm-kill' | 'prompt' | 'search' | 'message' | 'new-session'; +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 @@ -102,6 +103,12 @@ export interface TuiPreview { 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 { @@ -140,6 +147,46 @@ export interface TuiPickerState { 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. @@ -155,6 +202,9 @@ export interface TuiRenderModel { 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; } diff --git a/test/tui/tui-model.test.ts b/test/tui/tui-model.test.ts index 9ac08952..1a9c1924 100644 --- a/test/tui/tui-model.test.ts +++ b/test/tui/tui-model.test.ts @@ -8,13 +8,18 @@ */ import { describe, it, expect } from 'vitest'; import type { ApprovalItem } from '../../src/web/approval-inbox.js'; +import type { SearchResultGroup } from '../../src/types/search.js'; +import { createComposer } from '../../src/tui/tui-composer.js'; import { buildRows, + buildSearchEntries, classifySession, createTuiModel, + firstSearchIndex, flattenRows, groupSessions, mergeSessionRow, + moveSearchIndex, } from '../../src/tui/tui-model.js'; import type { TuiSessionRow } from '../../src/tui/tui-types.js'; @@ -281,3 +286,105 @@ describe('the store', () => { expect(model.approvalFor('a')).toBeUndefined(); }); }); + +describe('the phase-2 overlays', () => { + it('gives one overlay the keyboard at a time and clears them together', () => { + const model = createTuiModel(); + model.setPrompt({ sessionId: 'a', label: 'w4-api', composer: createComposer('hi') }); + expect(model.mode).toBe('prompt'); + model.setSearch({ composer: createComposer(), query: '', entries: [], index: -1, status: 'idle' }); + expect(model.mode).toBe('search'); + model.setDigest({ title: 'Away digest', lines: ['a', 'b'], offset: 0 }); + expect(model.mode).toBe('digest'); + model.closeOverlay(); + expect(model.mode).toBe('list'); + expect([model.prompt, model.search, model.digest]).toEqual([null, null, null]); + }); + + it('keeps the composer pointed at its session while the text changes', () => { + const model = createTuiModel(); + model.setPrompt({ sessionId: 'a', label: 'w4-api', composer: createComposer() }); + const revision = model.revision; + model.updatePrompt(createComposer('deploy')); + expect(model.prompt?.sessionId).toBe('a'); + expect(model.revision).toBeGreaterThan(revision); + }); + + it('scrolls the digest without running off either end', () => { + const model = createTuiModel(); + model.setDigest({ title: 'Away digest', lines: Array.from({ length: 10 }, (_, i) => `line ${i}`), offset: 0 }); + model.scrollDigest(3, 4); + expect(model.digest?.offset).toBe(3); + model.scrollDigest(100, 4); + expect(model.digest?.offset).toBe(6); + model.scrollDigest(-100, 4); + expect(model.digest?.offset).toBe(0); + }); +}); + +describe('search results', () => { + const groups: SearchResultGroup[] = [ + { + type: 'session', + results: [ + { + type: 'session', + sessionId: 'live-1', + sessionName: 'w1-alpha', + timestamp: NOW, + snippet: '/tmp/alpha', + exactMatch: true, + jumpTo: { kind: 'session', sessionId: 'live-1' }, + }, + { + type: 'session', + sessionId: 'past-1', + sessionName: 'w9-old', + timestamp: NOW - 1000, + snippet: '/tmp/old', + exactMatch: false, + jumpTo: { kind: 'resume-session', sessionId: 'past-1' }, + }, + ], + }, + { + type: 'file', + results: [ + { + type: 'file', + sessionId: 'live-1', + sessionName: 'w1-alpha', + timestamp: NOW, + snippet: 'notes.md', + exactMatch: false, + jumpTo: { kind: 'file-preview', sessionId: 'live-1', relativePath: 'docs/notes.md' }, + }, + ], + }, + ]; + + it('flattens the typed groups into headers and rows', () => { + const entries = buildSearchEntries(groups, (id) => id === 'live-1'); + expect(entries.map((entry) => entry.kind)).toEqual(['header', 'result', 'result', 'header', 'result']); + expect(entries[0].text).toBe('SESSIONS'); + expect(entries[1]).toMatchObject({ text: 'w1-alpha', sessionId: 'live-1', live: true }); + // A session that is not on the list cannot be selected into. + expect(entries[2]).toMatchObject({ text: 'w9-old', live: false }); + expect(entries[4]).toMatchObject({ text: 'docs/notes.md', live: false }); + }); + + it('drops an empty group instead of printing a header with nothing under it', () => { + expect(buildSearchEntries([{ type: 'event', results: [] }], () => false)).toEqual([]); + }); + + it('starts on the first result and never lands on a header', () => { + const entries = buildSearchEntries(groups, () => true); + expect(firstSearchIndex(entries)).toBe(1); + expect(moveSearchIndex(entries, 1, 1)).toBe(2); + expect(moveSearchIndex(entries, 2, 1)).toBe(4); + // Both ends stop rather than wrap: a result list is read, not cycled. + expect(moveSearchIndex(entries, 4, 1)).toBe(4); + expect(moveSearchIndex(entries, 1, -1)).toBe(1); + expect(firstSearchIndex([])).toBe(-1); + }); +}); From 36d1ef64f5b9d38e3e3c061473b28df29247b8ca Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 20:29:28 +0200 Subject: [PATCH 25/57] feat: render the approval card, composer, search and digest The preview pane now leads with the pending dialog when the selected session has one: the question, the options with their digits, and the keys that answer them, red for a dialog and yellow for a waiting prompt. The card is capped at half the pane, because the tail is why the pane exists. Around it: a header badge counting prompts that need a human, a preview title that sacrifices the path rather than the state word, the footer becoming the composer line while one is open (with the cell the terminal cursor belongs in, so it can be shown there and hidden everywhere else), and the search and digest panels as overlays with a stable width. Co-Authored-By: Claude Fable 5 --- src/tui/tui-render.ts | 221 ++++++++++++++++++++++++++++++++++-- test/tui/tui-render.test.ts | 193 +++++++++++++++++++++++++++++-- 2 files changed, 391 insertions(+), 23 deletions(-) diff --git a/src/tui/tui-render.ts b/src/tui/tui-render.ts index 8c629abb..0c8a1e9a 100644 --- a/src/tui/tui-render.ts +++ b/src/tui/tui-render.ts @@ -20,13 +20,20 @@ */ 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'; @@ -71,6 +78,19 @@ const SGR = { 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 = { + 'blocked-permission': 'blocked', + 'blocked-question': 'blocked', + waiting: 'waiting', + working: 'working', + idle: 'idle', + recent: 'done', +}; + const STATE_COLOR: Record = { 'blocked-permission': SGR.red, 'blocked-question': SGR.red, @@ -209,6 +229,24 @@ export function formatTokens(total: number): string { 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. + */ +export function formatPlanUsage(usage: StatusTelemetry | null | undefined): 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(' · '); +} + /** * 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. @@ -382,6 +420,54 @@ export function computeListWindow( // 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); @@ -396,22 +482,32 @@ function buildPreviewLines(model: TuiRenderModel, rect: TuiRect, opts: TuiRender if (!selected) { lines.push(padDisplay(paint(' no session selected', SGR.gray), rect.width)); } else { - const { session } = selected; - const parts = [session.mode ?? 'claude', session.workingDir ?? ''].filter((part) => part !== ''); - const title = ` ${rowLabel(session)} ${glyphs.separator} ${parts.join(` ${glyphs.separator} `)}`; - lines.push(padDisplay(paint(clipStyledLine(title, rect.width), SGR.bold), rect.width)); + lines.push(padDisplay(paint(previewTitle(selected, rect.width, glyphs), SGR.bold), rect.width)); } - const body = previewBody(model, selected, rect, opts); + 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): string[] { +function previewBody( + model: TuiRenderModel, + selected: TuiRow | null, + rect: TuiRect, + opts: TuiRenderOptions, + capacity: number +): string[] { const paint = painterFor(opts.color); - const capacity = Math.max(0, rect.height - 1); - if (capacity === 0) return []; + if (capacity <= 0) return []; const hint = (text: string): string[] => [padDisplay(paint(` ${text}`, SGR.gray), rect.width)]; if (!selected) return []; @@ -420,6 +516,7 @@ function previewBody(model: TuiRenderModel, selected: TuiRow | null, rect: TuiRe } 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]; @@ -436,6 +533,13 @@ function previewBody(model: TuiRenderModel, selected: TuiRow | null, rect: TuiRe // 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); @@ -447,7 +551,9 @@ function renderHeaderLine(model: TuiRenderModel, layout: TuiLayout, opts: TuiRen planUsage ?? '', ].filter((part) => part !== ''); - const left = ` ${paint('codeman', SGR.bold)} ${paint(facts.join(` ${glyphs.separator} `), SGR.gray)}`; + 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); @@ -485,13 +591,40 @@ const FOOTER_KEYS: Record string> = { 'confirm-kill': (g) => `type the name ${g.separator} ${g.enter} confirm ${g.separator} esc cancel`, message: () => 'esc dismiss', prompt: (g) => `${g.enter} send ${g.separator} esc cancel`, - search: (g) => `${g.enter} open ${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); @@ -505,6 +638,12 @@ function renderFooterLine(model: TuiRenderModel, layout: TuiLayout, opts: TuiRen 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[] { @@ -565,6 +704,49 @@ function pickerLines(picker: TuiPickerState, glyphs: TuiGlyphSet, capacity: numb 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, @@ -572,9 +754,26 @@ function overlayContent( 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)) }; @@ -611,7 +810,7 @@ function applyOverlay(lines: string[], model: TuiRenderModel, layout: TuiLayout, const visible = content.lines.slice(0, Math.max(1, body.height - 2)); const inner = Math.min( maxInner, - Math.max(visibleWidth(content.title) + 2, ...visible.map((line) => visibleWidth(line))) + Math.max(content.minWidth ?? 0, visibleWidth(content.title) + 2, ...visible.map((line) => visibleWidth(line))) ); const boxWidth = inner + 4; const boxHeight = visible.length + 2; diff --git a/test/tui/tui-render.test.ts b/test/tui/tui-render.test.ts index 21a936b7..ec7569a7 100644 --- a/test/tui/tui-render.test.ts +++ b/test/tui/tui-render.test.ts @@ -8,11 +8,15 @@ */ import { describe, it, expect } from 'vitest'; import { stripStyles, toDisplayLines, visibleWidth } from '../../src/tui/tui-ansi.js'; +import { composerMove, createComposer } from '../../src/tui/tui-composer.js'; import { computeLayout, needsBanner } from '../../src/tui/tui-layout.js'; import { createTuiModel, type TuiModelStore } from '../../src/tui/tui-model.js'; import { + composerCursorCell, detectGlyphTier, + digestCapacity, formatElapsed, + formatPlanUsage, formatTokens, renderFrame, rowLabel, @@ -92,6 +96,12 @@ function fixture(): TuiModelStore { kind: 'permission', createdAt: NOW - 120_000, toolName: 'Bash', + toolSummary: 'Bash(git push origin main)', + options: [ + { n: 1, label: 'Yes' }, + { n: 2, label: "Yes, don't ask again" }, + { n: 3, label: 'No, tell Claude what to do' }, + ], }, ]); model.select('aaa1'); @@ -118,17 +128,17 @@ function frameLines(frame: string): string[] { describe('renderFrame structure', () => { it('paints the wide layout at 100x30', () => { expect(frameLines(render(fixture(), 100, 30))).toEqual([ - ' codeman tnode · v1.19.0 · 4 sessions · 5h 32% wk 61% ? help q quit', - ' NEEDS YOU ─────────────────────────│ w4-api-refactor · claude · /home/dev/api', - ' 1 w6-docs ✋ 11m│ Actualizing... (2m 14s)', - '▶ 2 w4-api-refactor ⚠ 2m 12.3k│ running tests', - ' WORKING ───────────────────────────│ warning here', - ' 3 w1-codeman ✻ 17m 45.2k│', - ' IDLE ──────────────────────────────│', + ' codeman ⚠ 2 tnode · v1.19.0 · 4 sessions · 5h 32% wk 61% ? help q quit', + ' NEEDS YOU ─────────────────────────│ w4-api-refactor · claude · /home/dev/api · blocked', + ' 1 w6-docs ✋ 11m│ ⚠ requests: Bash(git push origin main)', + '▶ 2 w4-api-refactor ⚠ 2m 12.3k│ 1. Yes', + " WORKING ───────────────────────────│ 2. Yes, don't ask again", + ' 3 w1-codeman ✻ 17m 45.2k│ 3. No, tell Claude what to do', + ' IDLE ──────────────────────────────│ y approve · n deny · digit chooses', ' 4 w2-gallery codex ○ 2h│', - ' RECENT ────────────────────────────│', - ' 5 fix the release script ✔ 3d│', - ' │', + ' RECENT ────────────────────────────│ Actualizing... (2m 14s)', + ' 5 fix the release script ✔ 3d│ running tests', + ' │ warning here', ' │', ' │', ' │', @@ -153,7 +163,7 @@ describe('renderFrame structure', () => { it('paints the narrow two-line layout at 44x20', () => { expect(frameLines(render(fixture(), 44, 20))).toEqual([ - ' codeman tnode · v1.19.0 · 4 sessions · 5h', + ' codeman ⚠ 2 tnode · v1.19.0 · 4 sessions', ' NEEDS YOU ─────────────────────────────────', ' 1 w6-docs ✋ 11m', ' /home/dev/docs', @@ -225,7 +235,7 @@ describe('color', () => { const highlighted = frame.split('\x1b[7m')[1]?.split('\x1b[0m')[0] ?? ''; expect(highlighted).toContain('w4-api-refactor'); expect(highlighted).toContain('⚠'); - expect(frame).not.toContain('\x1b[31m⚠'); + expect(highlighted).not.toContain('\x1b['); }); it('emits nothing but cursor addressing when color is off', () => { @@ -401,3 +411,162 @@ describe('formatting helpers', () => { expect(rowLabel({ sessionId: 'abcdef1234', sources: [] })).toBe('abcdef12'); }); }); + +describe('the approval card', () => { + it('draws the dialog above the tail, with its digits', () => { + const text = frameLines(render(fixture(), 100, 30)).join('\n'); + expect(text).toContain('⚠ requests: Bash(git push origin main)'); + expect(text).toContain('1. Yes'); + expect(text).toContain('3. No, tell Claude what to do'); + expect(text).toContain('y approve · n deny · digit chooses'); + // The tail is still there, below the card. + expect(text).toContain('Actualizing...'); + }); + + it('paints a dialog red and a waiting prompt yellow', () => { + const model = fixture(); + const frame = render(model, 100, 30, { color: true }); + expect(frame).toContain('\x1b[31m ⚠ requests'); + + model.select('bbb2'); + const idle = render(model, 100, 30, { color: true }); + expect(idle).toContain('\x1b[33m ✋'); + expect(idle).toContain('p to reply'); + }); + + it('never lets the card push the tail off the pane', () => { + const model = fixture(); + const lines = frameLines(render(model, 100, 10)); + // 8 body lines: the card gets at most half, so some tail survives. + expect(lines.join('\n')).toContain('warning here'); + }); + + it('counts pending prompts in the header badge', () => { + expect(frameLines(render(fixture(), 100, 30))[0]).toContain('⚠ 2'); + const model = createTuiModel(); + model.replaceSessions([{ sessionId: 'aaa1', name: 'w1', sources: ['live'], status: 'idle' }]); + expect(frameLines(render(model, 100, 30))[0]).not.toContain('⚠'); + }); +}); + +describe('the preview title', () => { + it('names the session, its CLI, its directory and its state', () => { + expect(frameLines(render(fixture(), 100, 30))[1]).toContain('w4-api-refactor · claude · /home/dev/api · blocked'); + }); + + it('sacrifices the path rather than the state word when the pane is narrow', () => { + const model = fixture(); + model.setApprovals([]); + model.select('ccc3'); + const title = frameLines(render(model, 80, 30))[1]; + expect(title).toContain('w1-codeman'); + expect(title).toContain('working'); + }); + + it('says a history row has nothing to show rather than claiming to load it', () => { + const model = fixture(); + model.select('eee5'); + model.setPreview({ sessionId: 'eee5', lines: [], note: 'this session is not running: no live output to show' }); + expect(frameLines(render(model, 100, 30)).join('\n')).toContain('no live output to show'); + }); +}); + +describe('the prompt composer', () => { + function composing(text: string, cursorAt?: number): TuiModelStore { + const model = fixture(); + let composer = createComposer(text); + if (cursorAt !== undefined) composer = composerMove(composer, cursorAt - text.length); + model.setPrompt({ sessionId: 'aaa1', label: 'w4-api-refactor', composer }); + return model; + } + + it('replaces the footer keys with the line being typed', () => { + const lines = frameLines(render(composing('deploy the thing'), 100, 30)); + expect(lines[lines.length - 1]).toBe(' > deploy the thing'); + }); + + it('puts the terminal cursor where the caret is', () => { + const layout = computeLayout(100, 30); + expect(composerCursorCell(composing('abc'), layout)).toEqual({ row: 30, col: 7 }); + expect(composerCursorCell(composing('abc', 1), layout)).toEqual({ row: 30, col: 5 }); + // No composer, no cursor: a blinking cursor in a dashboard reads as a bug. + expect(composerCursorCell(fixture(), layout)).toBeNull(); + }); + + it('scrolls a long line so the caret stays on screen', () => { + const long = 'x'.repeat(200); + const lines = frameLines(render(composing(long), 100, 30)); + const footer = lines[lines.length - 1]; + expect(visibleWidth(footer)).toBeLessThanOrEqual(100); + const cursor = composerCursorCell(composing(long), computeLayout(100, 30)); + expect(cursor?.col).toBeLessThanOrEqual(100); + }); +}); + +describe('the search overlay', () => { + function searching(): TuiModelStore { + const model = fixture(); + model.setSearch({ + composer: createComposer('alpha'), + query: 'alpha', + status: 'done', + note: '2 results', + index: 1, + entries: [ + { kind: 'header', text: 'SESSIONS' }, + { kind: 'result', text: 'w1-alpha', detail: '/tmp/alpha', sessionId: 'aaa1', live: true }, + { kind: 'result', text: 'w9-old', detail: '/tmp/old', sessionId: 'zzz9', live: false }, + ], + }); + return model; + } + + it('shows the query with a caret, the count and the rows', () => { + const text = frameLines(render(searching(), 100, 30)).join('\n'); + expect(text).toContain('┌ Search '); + expect(text).toContain('alpha_'); + expect(text).toContain('2 results'); + expect(text).toContain('SESSIONS'); + expect(text).toContain('▶ w1-alpha /tmp/alpha'); + expect(text).toContain('w9-old'); + }); + + it('invites a query before anything has been typed', () => { + const model = fixture(); + model.setSearch({ composer: createComposer(), query: '', entries: [], index: -1, status: 'idle' }); + expect(frameLines(render(model, 100, 30)).join('\n')).toContain('type to search'); + }); +}); + +describe('the digest overlay', () => { + it('windows the lines it was given and scrolls with the offset', () => { + const model = fixture(); + const lines = Array.from({ length: 40 }, (_, i) => `digest line ${i}`); + model.setDigest({ title: 'Away digest', lines, offset: 0 }); + const top = frameLines(render(model, 100, 12)).join('\n'); + expect(top).toContain('┌ Away digest '); + expect(top).toContain('digest line 0'); + expect(top).not.toContain('digest line 30'); + + model.scrollDigest(30, digestCapacity(computeLayout(100, 12))); + const scrolled = frameLines(render(model, 100, 12)).join('\n'); + expect(scrolled).toContain('digest line 30'); + expect(scrolled).not.toContain('digest line 0\n'); + }); +}); + +describe('formatPlanUsage', () => { + it('mirrors the web chip, both windows and either alone', () => { + expect( + formatPlanUsage({ fiveHour: { usedPercentage: 32.4, resetAt: 1 }, sevenDay: { usedPercentage: 61, resetAt: 2 } }) + ).toBe('5h 32% · wk 61%'); + expect(formatPlanUsage({ fiveHour: { usedPercentage: 5, resetAt: 1 } })).toBe('5h 5%'); + expect(formatPlanUsage({ sevenDay: { usedPercentage: 90, resetAt: 1 } })).toBe('wk 90%'); + }); + + it('is empty when there is nothing to report, so the header shows no placeholder', () => { + expect(formatPlanUsage(null)).toBe(''); + expect(formatPlanUsage(undefined)).toBe(''); + expect(formatPlanUsage({})).toBe(''); + }); +}); From f644ce3af909e647c8f828b024b566409a07a272 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 20:29:39 +0200 Subject: [PATCH 26/57] feat: answer approvals and send prompts from the dashboard The dashboard stops being read-only. The selected session's tail is polled once a second while the plain list has focus and the layout is wide, and an unchanged tail never reaches the model, so a quiet session costs no repaint. A row with no live buffer says so instead of polling forever. Keys: y/n and the parsed digits answer the selected session's dialog through `POST /api/approvals/:id/answer` (never a blind keystroke: that route re-captures the pane and 409s when the dialog has moved on, which the TUI reports as "no longer on screen"); `p` opens a one-line composer aimed at the selected session; `/` searches with a 250ms debounce and Enter switches to a live session result; `g` shows the away digest. A new prompt rings the bell exactly once, tracked by item id so a repaint or a refetch cannot stutter, and the plan-usage chip rides `GET /api/status` plus its telemetry event. Co-Authored-By: Claude Fable 5 --- src/tui/tui-app.ts | 669 +++++++++++++++++++++++++++++++++++---- src/tui/tui-client.ts | 7 + test/tui/tui-app.test.ts | 74 ++++- 3 files changed, 689 insertions(+), 61 deletions(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 9fce42fd..8006584a 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -27,12 +27,19 @@ * * REPAINT POLICY: on state change (the model's revision), on resize, and on a * 500ms tick that runs only while a WORKING row is on screen (the glyph - * animates). An idle dashboard writes nothing at all. + * animates). An idle dashboard writes nothing at all. The preview's own 1s poll + * obeys the same rule: an unchanged tail never reaches the model, so it cannot + * bump the revision and cannot repaint. * - * NOT HERE YET (phase 2 of docs/tui-plan.md): the preview pane's live tail, - * answering approvals, the prompt composer, search and the away digest. The - * seams are in place (the preview region renders a placeholder, the client - * already carries the calls) and the footer advertises only what works. + * ANSWERING A DIALOG goes through `POST /api/approvals/:id/answer` and nothing + * else. That route re-captures the pane before it sends a keystroke and refuses + * with 409 when the dialog has moved on, which is the only reason it is safe to + * bind a single digit to it; a blind `send-keys` from here would type into + * whatever now has focus. + * + * NOT HERE YET (phase 3 of docs/tui-plan.md): mouse support, the `--pick` popup + * switcher, the opt-in attach status line, OSC 9 notifications, and resuming a + * RECENT row. * * @module tui/tui-app */ @@ -43,16 +50,46 @@ import chalk from 'chalk'; import { palette, table, tint, type Tone } from '../cli-style.js'; import { CODEMAN_INSTANCE, resolveTmuxSocketName } from '../config/instance.js'; import { getErrorMessage } from '../types/api.js'; -import { TuiClient, type TuiEventStream, type TuiQuickStartOptions, type TuiTmuxSession } from './tui-client.js'; +import { toDisplayLines } from './tui-ansi.js'; +import { approvalAnswerForKey, newApprovalIds } from './tui-approvals.js'; +import { composerScroll, composerStep, composerText, createComposer, type TuiComposerState } from './tui-composer.js'; +import { formatAwayDigest } from './tui-digest.js'; +import { + TuiClient, + type TuiApprovalAnswer, + type TuiEventStream, + type TuiQuickStartOptions, + type TuiTmuxSession, +} from './tui-client.js'; import { createKeyParser, type TuiInputEvent, type TuiKeyParser } from './tui-keys.js'; -import { computeLayout, needsBanner } from './tui-layout.js'; -import { createTuiModel, type TuiModelStore } from './tui-model.js'; -import { detectGlyphTier, glyphsFor, renderFrame, rowLabel, type TuiGlyphSet } from './tui-render.js'; +import { computeLayout, needsBanner, type TuiLayout } from './tui-layout.js'; +import { + buildSearchEntries, + createTuiModel, + firstSearchIndex, + moveSearchIndex, + type TuiModelStore, +} from './tui-model.js'; +import { + COMPOSER_PREFIX, + composerCursorCell, + detectGlyphTier, + digestCapacity, + formatPlanUsage, + glyphsFor, + renderFrame, + rowLabel, + STATE_WORDS, + type TuiGlyphSet, +} from './tui-render.js'; import type { TuiRenderOptions } from './tui-render.js'; +import type { ApprovalItem } from '../web/approval-inbox.js'; import type { TuiConfirmState, + TuiConnectionStatus, TuiGlyphTier, TuiPickerItem, + TuiPreview, TuiRow, TuiSessionRow, TuiSessionState, @@ -75,6 +112,19 @@ const POLL_INTERVAL_MS = 2_000; const REPROBE_INTERVAL_MS = 10_000; /** Unified-list page size. RECENT is capped far lower by the model. */ const UNIFIED_LIMIT = 60; +/** How often the selected session's tail is re-read while the list has focus. */ +const PREVIEW_INTERVAL_MS = 1_000; +/** Tail size. Enough for a tall pane's last screens, small enough to poll every second. */ +const PREVIEW_TAIL_BYTES = 12 * 1024; +/** Lines kept from a tail. The pane shows a fraction of these; the rest is headroom. */ +const PREVIEW_MAX_LINES = 200; +/** Quiet time after the last keystroke before the search query goes to the server. */ +const SEARCH_DEBOUNCE_MS = 250; +const SEARCH_LIMIT = 40; +/** How long a "sent" style notice stays up before it clears itself. */ +const NOTICE_MS = 1_500; +/** Approval ids remembered for the bell before the set is rebuilt from what is pending. */ +const SEEN_APPROVAL_CAP = 500; const ALT_SCREEN_ON = '\x1b[?1049h'; const ALT_SCREEN_OFF = '\x1b[?1049l'; @@ -83,6 +133,8 @@ const CURSOR_SHOW = '\x1b[?25h'; /** DECSET 2026: terminals that know it show the frame atomically, the rest ignore it. */ const SYNC_BEGIN = '\x1b[?2026h'; const SYNC_END = '\x1b[?2026l'; +/** One BEL when a prompt starts waiting on a human, and never for a repaint. */ +const BELL = '\x07'; // ───────────────────────────────────────────────────────────────────────────── // Attach planning (pure) @@ -210,15 +262,24 @@ export function confirmKillStep(state: TuiConfirmState, event: TuiInputEvent): T // Footer (pure) // ───────────────────────────────────────────────────────────────────────────── +/** + * Which approval keys the selected row makes live. `menu` is a dialog on + * screen (y/n/digits answer it); `idle` is a prompt with no dialog, where the + * only reply path is the composer. + */ +export type TuiApprovalKeys = 'menu' | 'idle' | null; + export interface TuiKeymapContext { /** False in degraded mode, where the only verb that works is attach. */ server: boolean; + approval?: TuiApprovalKeys; } /** - * The footer keys for a mode. This is the honest inventory of what the build - * actually does, not the plan's full keymap: a footer advertising `p prompt` - * before the composer exists teaches users that the TUI ignores keys. + * The footer keys for a mode. This is the honest inventory of what works RIGHT + * NOW, not a fixed list: `n` starts a session normally and denies a dialog when + * one is on the selected row, and a footer that advertised both at once would + * be wrong half the time. */ export function footerKeysFor(mode: TuiUiMode, glyphs: TuiGlyphSet, context: TuiKeymapContext): string[] { switch (mode) { @@ -231,12 +292,23 @@ export function footerKeysFor(mode: TuiUiMode, glyphs: TuiGlyphSet, context: Tui case 'new-session': return [`${glyphs.updown} select`, `${glyphs.enter} choose`, 'type to filter', 'esc cancel']; case 'prompt': + return [`${glyphs.enter} send`, 'esc cancel']; case 'search': - return ['esc cancel']; - case 'list': - return context.server - ? [`${glyphs.updown} select`, `${glyphs.enter} attach`, '1-9 jump', 'n new', 'x kill', '? help', 'q quit'] - : [`${glyphs.updown} select`, `${glyphs.enter} attach`, '1-9 jump', '? help', 'q quit']; + return [`${glyphs.updown} results`, `${glyphs.enter} open`, 'type to search', 'esc close']; + case 'digest': + return ['j/k scroll', 'esc close']; + case 'list': { + if (!context.server) { + return [`${glyphs.updown} select`, `${glyphs.enter} attach`, '1-9 jump', '? help', 'q quit']; + } + const keys = [`${glyphs.updown} select`, `${glyphs.enter} attach`]; + if (context.approval === 'menu') keys.push('y approve', 'n deny', '1-9 option'); + else keys.push('1-9 jump'); + keys.push(context.approval === 'idle' ? 'p reply' : 'p prompt'); + if (context.approval !== 'menu') keys.push('n new'); + keys.push('x kill', '/ search', 'g digest', '? help', 'q quit'); + return keys; + } } } @@ -250,11 +322,70 @@ export function helpKeysFor(glyphs: TuiGlyphSet, context: TuiKeymapContext): Arr [glyphs.enter, 'attach'], ['1-9', 'jump and attach'], ]; - if (context.server) keys.push(['n', 'new session'], ['x', 'kill (typed confirmation)']); + if (context.server) { + keys.push( + ['y / n', 'approve or deny the selected dialog'], + ['1-9', 'answer with that option, when a dialog is on screen'], + ['p', 'send one line to the selected session'], + ['/', 'search sessions, events and files'], + ['g', 'away digest'], + ['n', 'new session'], + ['x', 'kill (typed confirmation)'] + ); + } keys.push(['?', 'this help'], ['esc', 'close an overlay'], ['q', 'quit']); return keys; } +// ───────────────────────────────────────────────────────────────────────────── +// Preview policy (pure) +// ───────────────────────────────────────────────────────────────────────────── + +export interface TuiPreviewContext { + mode: TuiUiMode; + /** Narrow layouts have no preview pane at all, so a poll would be wasted. */ + narrow: boolean; + connection: TuiConnectionStatus; + row: TuiRow | null; +} + +/** + * Is the selected row worth polling for a tail? Only a live session that is on + * screen with the list in focus qualifies: a history row has no buffer to read, + * and an overlay hides the pane it would repaint. + */ +export function shouldFetchPreview(context: TuiPreviewContext): boolean { + if (context.mode !== 'list' || context.narrow) return false; + if (context.connection === 'degraded' || context.connection === 'down') return false; + const row = context.row; + return row !== null && row.group !== 'recent'; +} + +/** + * The static line a pane shows instead of a tail, or null when a tail is on its + * way. Says what is true rather than "loading", which would never resolve. + */ +export function previewNoteFor(row: TuiRow | null, connection: TuiConnectionStatus): string | null { + if (!row) return null; + if (connection === 'degraded' || connection === 'down') return null; + if (row.group === 'recent') return 'this session is not running: no live output to show'; + return null; +} + +/** + * Would painting `next` change anything? The preview polls once a second, and a + * quiet session returns the same bytes every time; comparing here is what keeps + * that poll from bumping the model's revision and repainting the frame. + */ +export function samePreview(previous: TuiPreview | null, next: TuiPreview | null): boolean { + if (previous === next) return true; + if (!previous || !next) return false; + if (previous.sessionId !== next.sessionId) return false; + if (previous.error !== next.error || previous.note !== next.note) return false; + if (previous.lines.length !== next.lines.length) return false; + return previous.lines.every((line, i) => line === next.lines[i]); +} + // ───────────────────────────────────────────────────────────────────────────── // Repaint policy (pure) // ───────────────────────────────────────────────────────────────────────────── @@ -323,15 +454,6 @@ export function applyMuxNames(sessions: readonly TuiSessionRow[], tmux: readonly }); } -const STATE_WORD: Record = { - 'blocked-permission': 'blocked', - 'blocked-question': 'blocked', - waiting: 'waiting', - working: 'working', - idle: 'idle', - recent: 'done', -}; - const STATE_TONE: Record = { 'blocked-permission': 'err', 'blocked-question': 'err', @@ -485,10 +607,20 @@ class TuiApp { private resyncTimer: NodeJS.Timeout | null = null; private pollTimer: NodeJS.Timeout | null = null; private probeTimer: NodeJS.Timeout | null = null; + private previewTimer: NodeJS.Timeout | null = null; + private searchTimer: NodeJS.Timeout | null = null; + private noticeTimer: NodeJS.Timeout | null = null; private refreshing = false; private refreshQueued = false; private picker: PickerRuntime | null = null; private pendingSelectId: string | null = null; + /** Whose tail the preview is currently following; null when nothing is polled. */ + private previewSessionId: string | null = null; + private previewFetching = false; + /** Bumped per search so a slow response cannot overwrite a newer query's results. */ + private searchSeq = 0; + /** Approval ids the bell has already rung for. See `newApprovalIds`. */ + private readonly seenApprovals = new Set(); private exiting = false; private resolveExit: ((code: number) => void) | null = null; @@ -532,6 +664,7 @@ class TuiApp { ...(server.hostname ? { hostname: server.hostname } : {}), ...(server.instance ? { instance: server.instance } : {}), ...(server.version ? { version: server.version } : {}), + ...(server.planUsage ? { planUsage: formatPlanUsage(server.planUsage) } : {}), }); } else { this.model.setConnection('degraded'); @@ -562,10 +695,18 @@ class TuiApp { this.stream = this.client.subscribeEvents({ onInit: (state) => { if (state.version) this.model.setHeader({ version: state.version }); + if (state.planUsage) this.model.setHeader({ planUsage: formatPlanUsage(state.planUsage) }); this.paint(); }, onResync: () => this.scheduleRefresh(), + // The bell and the card both ride the refetch this schedules: the event + // carries the item, but the list has to be re-read anyway (an approval + // changes which group its row is in), and one code path cannot double-ring. onApproval: () => this.scheduleRefresh(), + onPlanUsage: (usage) => { + this.model.setHeader({ planUsage: formatPlanUsage(usage) }); + this.paint(); + }, onStatus: (status, detail) => { this.model.setConnection(status === 'connected' ? 'connected' : 'reconnecting'); if (detail.recommendPolling) this.startPolling(); @@ -617,8 +758,9 @@ class TuiApp { ]); this.model.replaceSessions(applyMuxNames(sessions, tmux)); this.model.setApprovals(approvals); + this.noteApprovals(approvals); if (this.pendingSelectId && this.model.select(this.pendingSelectId)) this.pendingSelectId = null; - this.syncPreviewPlaceholder(); + this.updatePreview(); this.paint(); } catch (error) { // A failed refresh is a connection symptom, not a reason to lose the list: @@ -632,10 +774,26 @@ class TuiApp { private async refreshDegraded(): Promise { const tmux = await this.client.enumerateTmuxSessions().catch(() => [] as TuiTmuxSession[]); this.model.replaceSessions(tmuxRowsToSessions(tmux)); - this.syncPreviewPlaceholder(); + this.updatePreview(); this.paint(); } + /** + * Ring once for prompts that were not pending a moment ago. Answered ids stay + * in the set on purpose (the inbox restores a failed write under the SAME id), + * so the bell cannot stutter on one dialog. + */ + private noteApprovals(items: readonly ApprovalItem[]): void { + const fresh = newApprovalIds(this.seenApprovals, items); + if (fresh.length === 0) return; + for (const id of fresh) this.seenApprovals.add(id); + if (this.seenApprovals.size > SEEN_APPROVAL_CAP) { + this.seenApprovals.clear(); + for (const item of items) this.seenApprovals.add(item.id); + } + this.stdout.write(BELL); + } + private startPolling(): void { if (this.pollTimer) return; this.pollTimer = setInterval(() => void this.refresh(), POLL_INTERVAL_MS); @@ -674,33 +832,104 @@ class TuiApp { this.subscribe(); } + // ── Preview ──────────────────────────────────────────────────────────────── + /** - * The preview pane is phase 2. Until then the selected row still gets a - * preview object, so the pane says what it is instead of claiming to load - * something forever. + * Keep the preview pointed at the selected session: start polling when the + * selection is a live row with the list in focus, stop when it is not, and + * say why when there is nothing to poll. */ - private syncPreviewPlaceholder(): void { - const selected = this.model.selectedId; - if (!selected) { - if (this.model.preview) this.model.setPreview(null); + private updatePreview(): void { + const row = this.model.selectedSession(); + const sessionId = row?.session.sessionId ?? null; + const changed = sessionId !== this.previewSessionId; + this.previewSessionId = sessionId; + + const wanted = shouldFetchPreview({ + mode: this.model.mode, + narrow: this.currentLayout().narrow, + connection: this.model.connection, + row, + }); + + if (!wanted) { + this.stopPreview(); + const note = previewNoteFor(row, this.model.connection); + if (row && note) this.applyPreview({ sessionId: row.session.sessionId, lines: [], note }); + else if (changed) this.applyPreview(null); return; } - if (this.model.preview?.sessionId === selected) return; - this.model.setPreview({ sessionId: selected, lines: [], error: 'live preview is not wired up yet' }); + + if (changed) { + // Null rather than an empty tail: the renderer reads that as "loading", + // while empty lines would claim the session has printed nothing. + this.applyPreview(null); + void this.fetchPreview(); + } + if (!this.previewTimer) { + this.previewTimer = setInterval(() => void this.fetchPreview(), PREVIEW_INTERVAL_MS); + } + } + + private stopPreview(): void { + if (!this.previewTimer) return; + clearInterval(this.previewTimer); + this.previewTimer = null; + } + + private applyPreview(preview: TuiPreview | null): void { + if (samePreview(this.model.preview, preview)) return; + this.model.setPreview(preview); + } + + private async fetchPreview(): Promise { + const sessionId = this.previewSessionId; + if (!sessionId || this.previewFetching || this.exiting) return; + this.previewFetching = true; + try { + const raw = await this.client.fetchTerminalTail(sessionId, PREVIEW_TAIL_BYTES); + if (this.previewSessionId !== sessionId) return; + this.applyPreview({ sessionId, lines: toDisplayLines(raw).slice(-PREVIEW_MAX_LINES) }); + } catch { + // A tail that cannot be read is a pane-level fact, not a connection one: + // the list stays exactly as it is and only this pane says so. + if (this.previewSessionId !== sessionId) return; + this.applyPreview({ sessionId, lines: [], error: 'could not read this session’s terminal' }); + } finally { + this.previewFetching = false; + } + this.paint(); } // ── Painting ─────────────────────────────────────────────────────────────── + private currentLayout(): TuiLayout { + return computeLayout(this.stdout.columns ?? 80, this.stdout.rows ?? 24, { + banner: needsBanner(this.model.connection), + }); + } + + private keymapContext(): TuiKeymapContext { + const approval = this.model.selectedSession()?.approval; + return { + server: this.model.connection !== 'degraded', + approval: approval ? (approval.kind === 'idle' ? 'idle' : 'menu') : null, + }; + } + private paint(force = false): void { if (this.exiting || !this.screen.active) return; - const cols = this.stdout.columns ?? 80; - const rows = this.stdout.rows ?? 24; - const key: TuiFrameKey = { revision: this.model.revision, cols, rows, tick: this.tick }; + const layout = this.currentLayout(); + const key: TuiFrameKey = { + revision: this.model.revision, + cols: layout.cols, + rows: layout.rows, + tick: this.tick, + }; if (!force && sameFrame(this.lastFrame, key)) return; this.lastFrame = key; - const layout = computeLayout(cols, rows, { banner: needsBanner(this.model.connection) }); - const keymap: TuiKeymapContext = { server: this.model.connection !== 'degraded' }; + const keymap = this.keymapContext(); const options: TuiRenderOptions = { color: this.color, glyphs: this.glyphTier, @@ -709,7 +938,11 @@ class TuiApp { footerKeys: footerKeysFor(this.model.mode, this.glyphs, keymap), helpKeys: helpKeysFor(this.glyphs, keymap), }; - this.stdout.write(`${SYNC_BEGIN}${renderFrame(this.model, layout, options)}${SYNC_END}`); + // The cursor belongs in the composer while one is open and nowhere else: a + // blinking cursor parked in a dashboard reads as a stuck program. + const cursor = composerCursorCell(this.model, layout); + const place = cursor ? `\x1b[${cursor.row};${cursor.col}H${CURSOR_SHOW}` : CURSOR_HIDE; + this.stdout.write(`${SYNC_BEGIN}${renderFrame(this.model, layout, options)}${place}${SYNC_END}`); this.syncAnimation(); } @@ -742,9 +975,16 @@ class TuiApp { this.escTimer = setTimeout(() => { this.escTimer = null; for (const event of this.parser.flush()) this.handle(event); - this.paint(); + this.afterInput(); }, ESC_FLUSH_MS); } + this.afterInput(); + } + + /** Every key can change the selection or the mode, and both steer the preview. */ + private afterInput(): void { + if (this.exiting) return; + this.updatePreview(); this.paint(); } @@ -757,6 +997,15 @@ class TuiApp { case 'new-session': this.handlePicker(event); return; + case 'prompt': + this.handlePrompt(event); + return; + case 'search': + this.handleSearch(event); + return; + case 'digest': + this.handleDigest(event); + return; case 'help': case 'message': // Any key dismisses; the footer says esc because that is the one key @@ -775,8 +1024,6 @@ class TuiApp { else if (event.name === 'down') this.model.moveCursor(1); else if (event.name === 'pageup') this.model.moveCursor(-5); else if (event.name === 'pagedown') this.model.moveCursor(5); - else return; - this.syncPreviewPlaceholder(); return; case 'enter': void this.attachSelected(); @@ -793,21 +1040,28 @@ class TuiApp { } private handleListChar(value: string): void { - if (value >= '1' && value <= '9') { - if (this.model.cursorToIndex(Number.parseInt(value, 10))) { - this.syncPreviewPlaceholder(); - void this.attachSelected(); + // A pending dialog takes the keys it can answer, and only those: the mapping + // returns null for a digit the dialog has no option for (and for every key + // on an idle prompt), which leaves the list's own bindings intact. + const approval = this.model.selectedSession()?.approval; + if (approval) { + const answer = approvalAnswerForKey(approval, value); + if (answer) { + void this.answerApproval(approval, answer); + return; } + } + + if (value >= '1' && value <= '9') { + if (this.model.cursorToIndex(Number.parseInt(value, 10))) void this.attachSelected(); return; } switch (value) { case 'j': this.model.moveCursor(1); - this.syncPreviewPlaceholder(); return; case 'k': this.model.moveCursor(-1); - this.syncPreviewPlaceholder(); return; case 'q': this.quit(0); @@ -821,6 +1075,15 @@ class TuiApp { case 'n': void this.openNewSession(); return; + case 'p': + this.openPrompt(); + return; + case '/': + this.openSearch(); + return; + case 'g': + void this.openDigest(); + return; default: return; } @@ -851,12 +1114,299 @@ class TuiApp { } } + // ── Composer, search and digest input ────────────────────────────────────── + + /** Columns the composer's text gets, once its fixed prefix is paid for. */ + private composerWidth(): number { + return Math.max(1, (this.stdout.columns ?? 80) - COMPOSER_PREFIX.length); + } + + private handlePrompt(event: TuiInputEvent): void { + const state = this.model.prompt; + if (!state) { + this.model.closeOverlay(); + return; + } + const step = composerStep(state.composer, event); + switch (step.kind) { + case 'edit': + this.model.updatePrompt(this.scrolled(step.state)); + return; + case 'cancel': + this.model.closeOverlay(); + return; + case 'submit': + void this.sendPrompt(state.sessionId, step.text); + return; + case 'ignore': + return; + } + } + + private scrolled(state: TuiComposerState): TuiComposerState { + return composerScroll(state, this.composerWidth()); + } + + private handleSearch(event: TuiInputEvent): void { + const state = this.model.search; + if (!state) { + this.model.closeOverlay(); + return; + } + // The arrows drive the RESULT list, not the query caret: the query renders + // its caret as a trailing underscore, so a caret that could move would move + // invisibly. + if (event.type === 'key') { + if (event.name === 'up') this.moveSearch(-1); + else if (event.name === 'down') this.moveSearch(1); + else if (event.name === 'pageup') this.moveSearch(-5); + else if (event.name === 'pagedown') this.moveSearch(5); + return; + } + if (event.type === 'enter') { + this.openSearchResult(); + return; + } + const step = composerStep(state.composer, event); + switch (step.kind) { + case 'edit': + this.model.updateSearch({ composer: step.state }); + this.scheduleSearch(composerText(step.state)); + return; + case 'cancel': + this.closeSearch(); + return; + default: + return; + } + } + + private moveSearch(delta: number): void { + const state = this.model.search; + if (!state || state.entries.length === 0) return; + this.model.updateSearch({ index: moveSearchIndex(state.entries, state.index, delta) }); + } + + private handleDigest(event: TuiInputEvent): void { + const capacity = digestCapacity(this.currentLayout()); + const page = Math.max(1, capacity - 1); + switch (event.type) { + case 'escape': + this.closeOverlayAndResume(); + return; + case 'ctrl': + if (event.key === 'c') this.closeOverlayAndResume(); + return; + case 'key': + if (event.name === 'up') this.model.scrollDigest(-1, capacity); + else if (event.name === 'down') this.model.scrollDigest(1, capacity); + else if (event.name === 'pageup') this.model.scrollDigest(-page, capacity); + else if (event.name === 'pagedown') this.model.scrollDigest(page, capacity); + else if (event.name === 'home') this.model.scrollDigest(-Number.MAX_SAFE_INTEGER, capacity); + else if (event.name === 'end') this.model.scrollDigest(Number.MAX_SAFE_INTEGER, capacity); + return; + case 'char': + if (event.value === 'j') this.model.scrollDigest(1, capacity); + else if (event.value === 'k') this.model.scrollDigest(-1, capacity); + else if (event.value === 'q' || event.value === 'g') this.closeOverlayAndResume(); + return; + default: + return; + } + } + // ── Actions ──────────────────────────────────────────────────────────────── private message(tone: 'info' | 'warn' | 'err', text: string): void { this.model.setMessage({ tone, text }); } + /** + * A message that clears itself. Used for outcomes the user already expects + * ("sent"), where a box waiting to be dismissed is one keystroke of ceremony + * for no information. + */ + private notice(text: string): void { + this.model.setMessage({ tone: 'info', text }); + const shown = this.model.message; + if (this.noticeTimer) clearTimeout(this.noticeTimer); + this.noticeTimer = setTimeout(() => { + this.noticeTimer = null; + // Only clear the notice this timer armed: anything the user opened in the + // meantime owns the screen now. + if (this.model.message !== shown) return; + this.closeOverlayAndResume(); + }, NOTICE_MS); + } + + /** Drop the overlay and let the preview start following the list again. */ + private closeOverlayAndResume(): void { + this.model.closeOverlay(); + this.updatePreview(); + this.paint(); + } + + private async answerApproval(item: ApprovalItem, answer: TuiApprovalAnswer): Promise { + let result; + try { + result = await this.client.answerApproval(item.id, answer); + } catch (error) { + this.message('err', `could not answer that prompt: ${getErrorMessage(error)}`); + this.paint(); + return; + } + await this.refresh(); + if (result.ok) this.notice(`answered ${item.sessionName || item.sessionId.slice(0, 8)}`); + // The server re-captures the pane before it types, so this is the normal + // outcome when the dialog was answered in tmux a moment ago. + else if (result.reason === 'gone') this.message('warn', 'that dialog is no longer on screen'); + else this.message('err', result.message); + this.paint(); + } + + private openPrompt(): void { + const row = this.model.selectedSession(); + if (!row) return; + if (this.model.connection === 'degraded') { + this.message('warn', 'sending a prompt needs the server; only attach works while it is down'); + return; + } + if (row.group === 'recent') { + this.message('warn', 'that session is not running: there is nothing to type at'); + return; + } + this.model.setPrompt({ + sessionId: row.session.sessionId, + label: rowLabel(row.session), + composer: createComposer(), + }); + } + + private async sendPrompt(sessionId: string, text: string): Promise { + const line = text.trim(); + this.model.closeOverlay(); + if (line === '') { + this.updatePreview(); + this.paint(); + return; + } + try { + await this.client.sendInput(sessionId, line); + await this.refresh(); + this.notice('sent'); + } catch (error) { + this.message('err', `could not send that prompt: ${getErrorMessage(error)}`); + } + this.updatePreview(); + this.paint(); + } + + private openSearch(): void { + if (this.model.connection === 'degraded') { + this.message('warn', 'search needs the server; only attach works while it is down'); + return; + } + this.model.setSearch({ composer: createComposer(), query: '', entries: [], index: -1, status: 'idle' }); + } + + private closeSearch(): void { + if (this.searchTimer) { + clearTimeout(this.searchTimer); + this.searchTimer = null; + } + this.closeOverlayAndResume(); + } + + private scheduleSearch(query: string): void { + if (this.searchTimer) clearTimeout(this.searchTimer); + this.searchTimer = setTimeout(() => { + this.searchTimer = null; + void this.runSearch(query); + }, SEARCH_DEBOUNCE_MS); + } + + private async runSearch(query: string): Promise { + if (this.model.mode !== 'search' || !this.model.search) return; + const needle = query.trim(); + const seq = ++this.searchSeq; + if (needle === '') { + this.model.updateSearch({ query: '', entries: [], index: -1, status: 'idle', note: undefined }); + this.paint(); + return; + } + + this.model.updateSearch({ status: 'searching', note: 'searching…' }); + this.paint(); + try { + const data = await this.client.search(needle, SEARCH_LIMIT); + if (seq !== this.searchSeq || this.model.mode !== 'search') return; + // Only a session that is on the list can be selected; a history hit has a + // session id but no row to move the cursor to. + const live = new Set( + this.model + .rows() + .filter((row) => row.group !== 'recent') + .map((row) => row.session.sessionId) + ); + const entries = buildSearchEntries(data.groups, (id) => live.has(id)); + this.model.updateSearch({ + query: needle, + entries, + index: firstSearchIndex(entries), + status: 'done', + note: + entries.length === 0 + ? 'no matches' + : `${data.totalResults} result${data.totalResults === 1 ? '' : 's'}${data.truncated ? ' (capped)' : ''}`, + }); + } catch (error) { + if (seq !== this.searchSeq || this.model.mode !== 'search') return; + this.model.updateSearch({ + status: 'error', + entries: [], + index: -1, + note: `search failed: ${getErrorMessage(error)}`, + }); + } + this.paint(); + } + + private openSearchResult(): void { + const state = this.model.search; + if (!state) return; + const entry = state.entries[state.index]; + if (!entry || entry.kind !== 'result') return; + if (entry.live && entry.sessionId && this.model.select(entry.sessionId)) { + this.closeSearch(); + return; + } + // Nothing to switch to (a history or file hit), so the row's own facts are + // the answer; resuming one is phase 3. + this.model.updateSearch({ note: [entry.text, entry.detail].filter((part) => part).join(' — ') }); + } + + private async openDigest(): Promise { + if (this.model.connection === 'degraded') { + this.message('warn', 'the digest needs the server; only attach works while it is down'); + return; + } + this.model.setDigest({ title: 'Away digest', lines: ['loading…'], offset: 0 }); + this.updatePreview(); + this.paint(); + let lines: string[]; + try { + const digest = await this.client.fetchAwayDigest(); + lines = formatAwayDigest(digest, { now: Date.now() }); + } catch (error) { + lines = [`could not load the digest: ${getErrorMessage(error)}`]; + } + // Esc works throughout the round trip, and a digest that lands afterwards + // must not reopen the overlay the user just closed. + if (this.model.mode !== 'digest') return; + this.model.setDigest({ title: 'Away digest', lines, offset: 0 }); + this.paint(); + } + private async attachSelected(): Promise { const row = this.model.selectedSession(); if (!row) return; @@ -1074,13 +1624,20 @@ class TuiApp { private quit(code: number): void { if (this.exiting) return; this.exiting = true; - for (const timer of [this.escTimer, this.resyncTimer]) if (timer) clearTimeout(timer); - for (const timer of [this.tickTimer, this.pollTimer, this.probeTimer]) if (timer) clearInterval(timer); + for (const timer of [this.escTimer, this.resyncTimer, this.searchTimer, this.noticeTimer]) { + if (timer) clearTimeout(timer); + } + for (const timer of [this.tickTimer, this.pollTimer, this.probeTimer, this.previewTimer]) { + if (timer) clearInterval(timer); + } this.escTimer = null; this.resyncTimer = null; + this.searchTimer = null; + this.noticeTimer = null; this.tickTimer = null; this.pollTimer = null; this.probeTimer = null; + this.previewTimer = null; this.stream?.close(); this.client.close(); this.stdin.off('data', this.onData); @@ -1169,7 +1726,7 @@ export async function runTuiList(options: TuiRunOptions = {}): Promise { } const rows = lines.map((line) => [ palette.muted(String(line.index)), - tint(STATE_TONE[line.state], STATE_WORD[line.state]), + tint(STATE_TONE[line.state], STATE_WORDS[line.state]), line.label, palette.muted(line.workingDir), ]); diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index 80e3fae2..981d992b 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -89,6 +89,12 @@ export interface TuiServerInfo { instance: string; /** A server answered but rejected our credentials. */ authRequired?: boolean; + /** + * Last-known plan usage, which rides `GET /api/status` rather than having a + * route of its own. Null when the account reports none (no subscription + * windows, or no statusline render yet this server process). + */ + planUsage?: TuiPlanUsage | null; } export interface TuiClientOptions { @@ -482,6 +488,7 @@ export class TuiClient { '/api/status' ); if (status?.version) info.version = status.version; + if (status?.planUsage) info.planUsage = status.planUsage; } catch (err) { if (err instanceof TuiApiError && (err.status === 401 || err.status === 403)) { info.authRequired = true; diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts index d940db0a..f2403c3c 100644 --- a/test/tui/tui-app.test.ts +++ b/test/tui/tui-app.test.ts @@ -19,8 +19,11 @@ import { helpKeysFor, isSelfSession, planAttach, + previewNoteFor, sameFrame, + samePreview, shouldAnimate, + shouldFetchPreview, tmuxRowsToSessions, tmuxSocketFromEnv, } from '../../src/tui/tui-app.js'; @@ -163,11 +166,30 @@ describe('footerKeysFor', () => { expect(keys).toContain('attach'); expect(keys).toContain('1-9 jump'); expect(keys).toContain('n new'); + expect(keys).toContain('p prompt'); + expect(keys).toContain('/ search'); + expect(keys).toContain('g digest'); expect(keys).toContain('x kill'); expect(keys).toContain('q quit'); - for (const missing of ['prompt', 'search', 'digest', 'answer', 'resume']) { - expect(keys).not.toContain(missing); - } + // Resuming a RECENT row is still phase 3. + expect(keys).not.toContain('resume'); + }); + + it('swaps in the answer keys while a dialog is on the selected row', () => { + const keys = footerKeysFor('list', GLYPHS, { server: true, approval: 'menu' }).join(' '); + expect(keys).toContain('y approve'); + expect(keys).toContain('n deny'); + expect(keys).toContain('1-9 option'); + // `n` cannot mean two things at once, and denying is what it does here. + expect(keys).not.toContain('n new'); + expect(keys).not.toContain('1-9 jump'); + }); + + it('sends an idle prompt to the composer instead of offering approve/deny', () => { + const keys = footerKeysFor('list', GLYPHS, { server: true, approval: 'idle' }).join(' '); + expect(keys).toContain('p reply'); + expect(keys).toContain('n new'); + expect(keys).not.toContain('y approve'); }); it('drops the server-only verbs in degraded mode', () => { @@ -175,6 +197,7 @@ describe('footerKeysFor', () => { expect(keys).toContain('attach'); expect(keys).not.toContain('kill'); expect(keys).not.toContain('new'); + expect(keys).not.toContain('search'); }); it('keeps the help overlay to the same inventory', () => { @@ -182,8 +205,10 @@ describe('footerKeysFor', () => { expect(help.map(([, description]) => description)).toEqual( expect.arrayContaining(['attach', 'new session', 'kill (typed confirmation)', 'quit']) ); - expect(help.flat().join(' ')).not.toContain('search'); - expect(helpKeysFor(GLYPHS, { server: false }).flat().join(' ')).not.toContain('kill'); + expect(help.flat().join(' ')).toContain('search'); + const degraded = helpKeysFor(GLYPHS, { server: false }).flat().join(' '); + expect(degraded).not.toContain('kill'); + expect(degraded).not.toContain('search'); }); it('follows the overlay that owns the keyboard', () => { @@ -191,6 +216,45 @@ describe('footerKeysFor', () => { expect(footerKeysFor('confirm-kill', GLYPHS, { server: true }).join(' ')).toContain('type the name'); expect(footerKeysFor('message', GLYPHS, { server: true })).toEqual(['esc dismiss']); expect(footerKeysFor('new-session', GLYPHS, { server: true }).join(' ')).toContain('type to filter'); + expect(footerKeysFor('prompt', GLYPHS, { server: true }).join(' ')).toContain('send'); + expect(footerKeysFor('search', GLYPHS, { server: true }).join(' ')).toContain('open'); + expect(footerKeysFor('digest', GLYPHS, { server: true }).join(' ')).toContain('scroll'); + }); +}); + +describe('the preview policy', () => { + const live: TuiRow = { ...row('idle', 'aaaaaaaa11'), group: 'idle' }; + const history: TuiRow = { ...row('recent', 'bbbbbbbb22'), group: 'recent' }; + + it('polls a live row only while the plain list is on screen and wide', () => { + const base = { mode: 'list' as const, narrow: false, connection: 'connected' as const, row: live }; + expect(shouldFetchPreview(base)).toBe(true); + expect(shouldFetchPreview({ ...base, mode: 'prompt' })).toBe(false); + expect(shouldFetchPreview({ ...base, mode: 'search' })).toBe(false); + expect(shouldFetchPreview({ ...base, narrow: true })).toBe(false); + expect(shouldFetchPreview({ ...base, connection: 'degraded' })).toBe(false); + expect(shouldFetchPreview({ ...base, row: history })).toBe(false); + expect(shouldFetchPreview({ ...base, row: null })).toBe(false); + }); + + it('explains a history row instead of polling one', () => { + expect(previewNoteFor(history, 'connected')).toContain('not running'); + expect(previewNoteFor(live, 'connected')).toBeNull(); + // The renderer already says why a degraded server has no preview. + expect(previewNoteFor(history, 'degraded')).toBeNull(); + expect(previewNoteFor(null, 'connected')).toBeNull(); + }); + + it('treats an unchanged tail as nothing to repaint', () => { + const preview = { sessionId: 'a', lines: ['one', 'two'] }; + expect(samePreview(preview, { sessionId: 'a', lines: ['one', 'two'] })).toBe(true); + expect(samePreview(preview, { sessionId: 'a', lines: ['one', 'three'] })).toBe(false); + expect(samePreview(preview, { sessionId: 'a', lines: ['one'] })).toBe(false); + expect(samePreview(preview, { sessionId: 'b', lines: ['one', 'two'] })).toBe(false); + expect(samePreview(preview, { sessionId: 'a', lines: ['one', 'two'], error: 'boom' })).toBe(false); + expect(samePreview(preview, { sessionId: 'a', lines: ['one', 'two'], note: 'history' })).toBe(false); + expect(samePreview(null, null)).toBe(true); + expect(samePreview(null, preview)).toBe(false); }); }); From 4696c381ddfc47aef690f018cf0cc61a1cfa310b Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 20:36:39 +0200 Subject: [PATCH 27/57] test: drive the phase-2 verbs end to end under a pty The fake API server grows the routes the dashboard now calls (terminal tail, input, approvals answer, search, away digest, plan usage on status), and the new cases assert on what the server RECEIVED rather than on the frame: the prompt arrives as one line ending in a carriage return, and the answers as the exact action and option digit. Also covered: the tail refreshing in place, the search overlay selecting a live session, the digest rendering, one bell for an item announced twice, and the 409 path reported as "no longer on screen". The plan-usage chip is punctuated with the glyph tier's separator, so an ASCII terminal no longer gets a stray middle dot in the header. Co-Authored-By: Claude Fable 5 --- src/tui/tui-app.ts | 12 +- src/tui/tui-render.ts | 7 +- test/tui/tui-e2e.test.ts | 338 +++++++++++++++++++++++++++++++++++- test/tui/tui-render.test.ts | 5 + 4 files changed, 348 insertions(+), 14 deletions(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 8006584a..760f65f6 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -58,6 +58,7 @@ import { TuiClient, type TuiApprovalAnswer, type TuiEventStream, + type TuiPlanUsage, type TuiQuickStartOptions, type TuiTmuxSession, } from './tui-client.js'; @@ -664,7 +665,7 @@ class TuiApp { ...(server.hostname ? { hostname: server.hostname } : {}), ...(server.instance ? { instance: server.instance } : {}), ...(server.version ? { version: server.version } : {}), - ...(server.planUsage ? { planUsage: formatPlanUsage(server.planUsage) } : {}), + ...(server.planUsage ? { planUsage: this.planUsageChip(server.planUsage) } : {}), }); } else { this.model.setConnection('degraded'); @@ -689,13 +690,18 @@ class TuiApp { }); } + /** The chip, punctuated with the glyph tier's own separator. */ + private planUsageChip(usage: TuiPlanUsage): string { + return formatPlanUsage(usage, ` ${this.glyphs.separator} `); + } + // ── Data ─────────────────────────────────────────────────────────────────── private subscribe(): void { this.stream = this.client.subscribeEvents({ onInit: (state) => { if (state.version) this.model.setHeader({ version: state.version }); - if (state.planUsage) this.model.setHeader({ planUsage: formatPlanUsage(state.planUsage) }); + if (state.planUsage) this.model.setHeader({ planUsage: this.planUsageChip(state.planUsage) }); this.paint(); }, onResync: () => this.scheduleRefresh(), @@ -704,7 +710,7 @@ class TuiApp { // changes which group its row is in), and one code path cannot double-ring. onApproval: () => this.scheduleRefresh(), onPlanUsage: (usage) => { - this.model.setHeader({ planUsage: formatPlanUsage(usage) }); + this.model.setHeader({ planUsage: this.planUsageChip(usage) }); this.paint(); }, onStatus: (status, detail) => { diff --git a/src/tui/tui-render.ts b/src/tui/tui-render.ts index 0c8a1e9a..d673c34d 100644 --- a/src/tui/tui-render.ts +++ b/src/tui/tui-render.ts @@ -233,9 +233,10 @@ export function formatTokens(total: number): string { * 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. + * 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): string { +export function formatPlanUsage(usage: StatusTelemetry | null | undefined, separator = ' · '): string { if (!usage) return ''; const parts: string[] = []; if (typeof usage.fiveHour?.usedPercentage === 'number') { @@ -244,7 +245,7 @@ export function formatPlanUsage(usage: StatusTelemetry | null | undefined): stri if (typeof usage.sevenDay?.usedPercentage === 'number') { parts.push(`wk ${Math.round(usage.sevenDay.usedPercentage)}%`); } - return parts.join(' · '); + return parts.join(separator); } /** diff --git a/test/tui/tui-e2e.test.ts b/test/tui/tui-e2e.test.ts index 3bda7d96..62c0e4bc 100644 --- a/test/tui/tui-e2e.test.ts +++ b/test/tui/tui-e2e.test.ts @@ -8,9 +8,19 @@ * newline-separated, so the assertions parse the LAST frame out of the captured * bytes and read its list column. * + * Every verb that leaves the process is asserted on the REQUEST the fake server + * received, not on the frame: a prompt has to arrive as one line ending in a + * carriage return, and an approval as the exact action and option digit, both + * of which a rendered frame would happily lie about. + * * The child gets its own data dir and a tmux socket name nothing runs on, which * keeps the enumeration that degraded mode and the attach path use from seeing * the machine's real sessions. Nothing here attaches, kills or writes anything. + * + * TIMING: the tests share one long-lived dashboard, so each one leaves the list + * in focus for the next. Where a notice can still be up (it clears itself after + * ~1.5s), the wait is on the FOOTER showing the keys that must be live, because + * an overlay would swallow the next keystroke as a dismissal. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { spawn } from 'node:child_process'; @@ -21,6 +31,9 @@ import { join, resolve } from 'node:path'; import * as pty from 'node-pty'; import { computeLayout } from '../../src/tui/tui-layout.js'; import type { UnifiedSessionItem } from '../../src/services/unified-session-service.js'; +import type { SearchResponseData } from '../../src/types/search.js'; +import type { ApprovalItem } from '../../src/web/approval-inbox.js'; +import type { AwayDigestResponse } from '../../src/web/away-digest.js'; const PORT = 3244; const BASE_URL = `http://127.0.0.1:${PORT}`; @@ -31,13 +44,120 @@ const LIST_WIDTH = computeLayout(COLS, ROWS).list.width; const NOW = Date.now(); +const ALPHA = 'aaaa1111-0000-0000-0000-000000000000'; +const BETA = 'bbbb2222-0000-0000-0000-000000000000'; + /** Mutable so a test can add a session and announce it over SSE. */ let sessions: UnifiedSessionItem[] = []; +/** What the dashboard can answer: pending items, keyed the way the inbox keys them. */ +let approvals: ApprovalItem[] = []; +/** Terminal buffers the preview pane polls, by session id. */ +const terminals = new Map(); +/** Everything the TUI posted, so a test can assert on the exact body. */ +const answered: Array<{ id: string; body: Record }> = []; +const inputs: Array<{ sessionId: string; body: Record }> = []; + +const PLAN_USAGE = { + fiveHour: { usedPercentage: 32, resetAt: NOW + 3_600_000 }, + sevenDay: { usedPercentage: 61, resetAt: NOW + 86_400_000 }, +}; + +const SEARCH_RESULTS: SearchResponseData = { + query: 'alpha', + groups: [ + { + type: 'session', + results: [ + { + type: 'session', + sessionId: ALPHA, + sessionName: 'w1-alpha', + timestamp: NOW, + snippet: '/tmp/alpha', + exactMatch: true, + jumpTo: { kind: 'session', sessionId: ALPHA }, + }, + ], + }, + { + type: 'file', + results: [ + { + type: 'file', + sessionId: ALPHA, + sessionName: 'w1-alpha', + timestamp: NOW, + snippet: 'alpha notes', + exactMatch: false, + jumpTo: { kind: 'file-preview', sessionId: ALPHA, relativePath: 'docs/alpha.md' }, + }, + ], + }, + ], + totalResults: 2, + truncated: false, +}; + +const DIGEST: AwayDigestResponse = { + range: { range: '24h', since: NOW - 86_400_000, until: NOW }, + generatedAt: NOW, + dataFreshness: { + lifecyclePersisted: true, + tokenStatsPersisted: true, + runSummariesLiveOnly: true, + subagentsLiveOnly: true, + }, + totals: { + sessionsCreated: 4, + sessionsExited: 1, + activeSessions: 3, + needsAttention: 1, + completed: 1, + errors: 0, + warnings: 1, + tokenWindowPrecision: 'day', + }, + sections: { + needsAttention: [ + { + id: 'd1', + sessionId: BETA, + sessionName: 'w2-beta', + timestamp: NOW - 300_000, + category: 'needs_attention', + severity: 'warning', + title: 'waited for approval', + source: 'lifecycle', + }, + ], + completed: [], + stillRunning: [], + idle: [], + informational: [], + }, +}; + +function permissionApproval(id: string): ApprovalItem { + return { + id, + sessionId: BETA, + sessionName: 'w2-beta', + kind: 'permission', + createdAt: Date.now(), + toolName: 'Bash', + toolSummary: 'Bash(git push origin main)', + options: [ + { n: 1, label: 'Yes' }, + { n: 2, label: 'Yes, and do not ask again' }, + { n: 3, label: 'No, tell Claude what to do' }, + ], + }; +} function resetSessions(): void { sessions = [ { - sessionId: 'bbbb2222-0000-0000-0000-000000000000', + sessionId: BETA, name: 'w2-beta', mode: 'claude', sources: ['live'], @@ -47,7 +167,7 @@ function resetSessions(): void { lastActivityAt: NOW, }, { - sessionId: 'aaaa1111-0000-0000-0000-000000000000', + sessionId: ALPHA, name: 'w1-alpha', mode: 'claude', sources: ['live'], @@ -101,6 +221,29 @@ function childEnv(): Record { }; } +/** The session id in `/api/sessions//`, or null. */ +function sessionRoute(url: string, what: string): string | null { + const match = url.match(new RegExp(`^/api/sessions/([^/?]+)/${what}`)); + return match ? decodeURIComponent(match[1]) : null; +} + +function readBody(req: http.IncomingMessage): Promise> { + return new Promise((done) => { + let raw = ''; + req.setEncoding('utf-8'); + req.on('data', (chunk: string) => { + raw += chunk; + }); + req.on('end', () => { + try { + done(JSON.parse(raw || '{}') as Record); + } catch { + done({}); + } + }); + }); +} + beforeAll(async () => { dataDir = mkdtempSync(join(tmpdir(), 'codeman-tui-e2e-')); resetSessions(); @@ -112,14 +255,55 @@ beforeAll(async () => { 'Cache-Control': 'no-cache', Connection: 'keep-alive', }); - res.write(`event: init\ndata: ${JSON.stringify({ version: '9.9.9', planUsage: null })}\n\n`); + res.write(`event: init\ndata: ${JSON.stringify({ version: '9.9.9', planUsage: PLAN_USAGE })}\n\n`); sseClients.add(res); req.on('close', () => sseClients.delete(res)); return; } - if (url.startsWith('/api/status')) return sendJson(res, { success: true, data: { version: '9.9.9' } }); + if (url.startsWith('/api/status')) { + return sendJson(res, { success: true, data: { version: '9.9.9', planUsage: PLAN_USAGE } }); + } if (url.startsWith('/api/sessions/unified')) return sendJson(res, { success: true, data: { sessions } }); - if (url.startsWith('/api/approvals')) return sendJson(res, { success: true, data: { approvals: [] } }); + + const previewFor = sessionRoute(url, 'terminal'); + if (previewFor) { + return sendJson(res, { success: true, data: { terminalBuffer: terminals.get(previewFor) ?? '' } }); + } + + const inputFor = sessionRoute(url, 'input'); + if (inputFor) { + void readBody(req).then((body) => { + inputs.push({ sessionId: inputFor, body }); + sendJson(res, { success: true, data: { delivered: true } }); + }); + return; + } + + const answerMatch = url.match(/^\/api\/approvals\/([^/?]+)\/answer/); + if (answerMatch) { + const id = decodeURIComponent(answerMatch[1]); + void readBody(req).then((body) => { + const item = approvals.find((entry) => entry.id === id); + if (!item) { + res.writeHead(409, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ success: false, error: 'The dialog is no longer on screen', errorCode: 'CONFLICT' }) + ); + return; + } + answered.push({ id, body }); + approvals = approvals.filter((entry) => entry.id !== id); + sendJson(res, { success: true, data: { id, sessionId: item.sessionId, action: body.action } }); + pushEvent('approval:resolved', { id, sessionId: item.sessionId, kind: item.kind, resolution: 'answered' }); + }); + return; + } + + if (url.startsWith('/api/approvals')) return sendJson(res, { success: true, data: { approvals } }); + if (url.startsWith('/api/search')) return sendJson(res, { success: true, data: SEARCH_RESULTS }); + // The away digest predates the envelope: its payload sits at the top level. + if (url.startsWith('/api/away-digest')) return sendJson(res, { success: true, digest: DIGEST }); + res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: 'no route', errorCode: 'NOT_FOUND' })); }); @@ -160,6 +344,13 @@ function listLines(raw: string): string[] { return frameLines(raw).map((line) => line.slice(0, LIST_WIDTH).replace(/\s+$/, '')); } +/** Just the preview column, for the same reason in reverse. */ +function previewText(raw: string): string { + return frameLines(raw) + .map((line) => line.slice(LIST_WIDTH + 1).replace(/\s+$/, '')) + .join('\n'); +} + function rowFor(raw: string, name: string): string { return listLines(raw).find((line) => line.includes(name)) ?? ''; } @@ -183,6 +374,8 @@ describe('codeman tui (under a pty)', () => { let exitCode: number | null = null; beforeAll(async () => { + terminals.set(BETA, '\x1b[32mready\x1b[0m\nbeta is thinking\n'); + terminals.set(ALPHA, 'alpha has been quiet\n'); term = pty.spawn('npx', ['tsx', 'src/index.ts', 'tui'], { name: 'xterm-256color', cols: COLS, @@ -203,6 +396,18 @@ describe('codeman tui (under a pty)', () => { if (exitCode === null) term.kill(); }); + /** + * Walk the cursor onto a row by name. Rows re-sort when an approval lands, so + * a test can never assume a position; `j` wraps, so this always terminates. + */ + async function selectRow(name: string): Promise { + for (let i = 0; i < 12 && !rowFor(output, name).startsWith('>'); i++) { + term.write('j'); + await new Promise((done) => setTimeout(done, 120)); + } + await waitFor(() => rowFor(output, name).startsWith('>'), `${name} to be selected`); + } + it('enters the alternate screen and hides the cursor', () => { expect(output).toContain('\u001b[?1049h'); expect(output).toContain('\u001b[?25l'); @@ -225,14 +430,23 @@ describe('codeman tui (under a pty)', () => { expect(lines[0]).toContain('v9.9.9'); // Two live rows; the history row is not a session you have open. expect(lines[0]).toContain('2 sessions'); + // The plan-usage chip, punctuated with this tier's separator. + expect(lines[0]).toContain('5h 32%'); + expect(lines[0]).toContain('wk 61%'); const footer = lines[ROWS - 1]; expect(footer).toContain('attach'); expect(footer).toContain('x kill'); - expect(footer).not.toContain('search'); + expect(footer).toContain('p prompt'); + expect(footer).toContain('/ search'); }); - it('holds the preview seam open instead of pretending to load one', () => { - expect(frameLines(output).join('\n')).toContain('live preview is not wired up yet'); + it('shows the selected session tail and follows it as it changes', async () => { + await waitFor(() => previewText(output).includes('beta is thinking'), 'the preview tail'); + expect(previewText(output)).toContain('w2-beta'); + expect(previewText(output)).toContain('/tmp/beta'); + + terminals.set(BETA, '\x1b[32mready\x1b[0m\nbeta is thinking\nbeta finished the job\n'); + await waitFor(() => previewText(output).includes('beta finished the job'), 'the tail to refresh'); }); it('starts with the first row selected and moves the cursor with j / k', async () => { @@ -292,6 +506,114 @@ describe('codeman tui (under a pty)', () => { await waitFor(() => !frameLines(output).some((line) => line.includes('Kill session')), 'escape to cancel the kill'); }); + it('sends a one-line prompt with p', async () => { + term.write('p'); + await waitFor(() => frameLines(output)[ROWS - 1].startsWith(' >'), 'the composer to open'); + + term.write('deploy the thing'); + await waitFor(() => frameLines(output)[ROWS - 1].includes('deploy the thing'), 'the typed line'); + // Backspace edits the line rather than moving the list cursor. + term.write('\u007f'.repeat(5)); + await waitFor(() => !frameLines(output)[ROWS - 1].includes('thing'), 'backspace to edit the line'); + + term.write('\r'); + await waitFor(() => inputs.length > 0, 'the input POST'); + expect(inputs[0].sessionId).toBe(BETA); + // Single line, ended with a carriage return, or the server never presses Enter. + expect(inputs[0].body.input).toBe('deploy the\r'); + expect(String(inputs[0].body.input)).not.toContain('\n'); + expect(inputs[0].body.clientId).toBeTruthy(); + + await waitFor(() => frameLines(output).join('\n').includes('sent'), 'the sent notice'); + await waitFor(() => !frameLines(output).join('\n').includes('Notice'), 'the notice to clear itself', 5_000); + }); + + it('searches with / and selects a live result', async () => { + term.write('/'); + await waitFor(() => frameLines(output).join('\n').includes('Search'), 'the search overlay'); + + term.write('alpha'); + await waitFor(() => frameLines(output).join('\n').includes('2 results'), 'the debounced search to answer'); + const overlay = frameLines(output).join('\n'); + expect(overlay).toContain('alpha_'); + expect(overlay).toContain('SESSIONS'); + expect(overlay).toContain('w1-alpha'); + expect(overlay).toContain('docs/alpha.md'); + + term.write('\r'); + await waitFor(() => !frameLines(output).join('\n').includes('Search'), 'the overlay to close'); + await waitFor(() => rowFor(output, 'w1-alpha').startsWith('>'), 'the searched session to be selected'); + }); + + it('shows the away digest with g', async () => { + term.write('g'); + await waitFor(() => frameLines(output).join('\n').includes('Away digest'), 'the digest overlay'); + const panel = frameLines(output).join('\n'); + expect(panel).toContain('the last 24 hours'); + expect(panel).toContain('4 started'); + expect(panel).toContain('NEEDS ATTENTION (1)'); + expect(panel).toContain('waited for approval'); + + term.write('\u001b'); + await waitFor(() => !frameLines(output).join('\n').includes('Away digest'), 'escape to close the digest'); + }); + + it('renders the pending dialog as a card and rings the bell once for it', async () => { + await selectRow('w2-beta'); + const before = output.length; + + approvals = [permissionApproval(`${BETA}:1`)]; + pushEvent('approval:pending', approvals[0]); + await waitFor(() => previewText(output).includes('requests: Bash(git push origin main)'), 'the approval card'); + const card = previewText(output); + expect(card).toContain('1. Yes'); + expect(card).toContain('3. No, tell Claude what to do'); + expect(card).toContain('y approve'); + expect(frameLines(output)[0]).toContain('[!] 1'); + + // The same item announced twice is one prompt, so it must not ring twice. + pushEvent('approval:pending', approvals[0]); + await new Promise((done) => setTimeout(done, 1_200)); + expect(output.slice(before).split('\u0007')).toHaveLength(2); + }); + + it('answers the dialog with the option digit and clears the card', async () => { + // The footer is the honest signal that the list has the keyboard: a notice + // still on screen would swallow the digit as a dismissal. + await waitFor(() => frameLines(output)[ROWS - 1].includes('y approve'), 'the answer keys in the footer'); + expect(frameLines(output)[ROWS - 1]).toContain('1-9 option'); + + term.write('1'); + await waitFor(() => answered.length > 0, 'the answer POST'); + expect(answered[0]).toEqual({ id: `${BETA}:1`, body: { action: 'option', option: 1 } }); + await waitFor(() => !previewText(output).includes('requests: Bash'), 'the card to clear'); + }); + + it('approves with y', async () => { + approvals = [permissionApproval(`${BETA}:2`)]; + pushEvent('approval:pending', approvals[0]); + await waitFor(() => previewText(output).includes('requests: Bash(git push origin main)'), 'the second card'); + await waitFor(() => frameLines(output)[ROWS - 1].includes('y approve'), 'the answer keys in the footer'); + + term.write('y'); + await waitFor(() => answered.length > 1, 'the approve POST'); + expect(answered[1]).toEqual({ id: `${BETA}:2`, body: { action: 'approve' } }); + }); + + it('says so when the dialog has already left the screen', async () => { + approvals = [permissionApproval(`${BETA}:3`)]; + pushEvent('approval:pending', approvals[0]); + await waitFor(() => previewText(output).includes('requests: Bash(git push origin main)'), 'the third card'); + + await waitFor(() => frameLines(output)[ROWS - 1].includes('y approve'), 'the answer keys in the footer'); + // Answered in tmux a moment ago: the server 409s and the TUI explains. + approvals = []; + term.write('y'); + await waitFor(() => frameLines(output).join('\n').includes('no longer on screen'), 'the gone-dialog message'); + term.write('\u001b'); + await waitFor(() => !frameLines(output).join('\n').includes('no longer on screen'), 'escape to dismiss it'); + }); + it('quits on q and restores the screen it took over', async () => { term.write('q'); await waitFor(() => exitCode !== null, 'the TUI to exit', 10_000); diff --git a/test/tui/tui-render.test.ts b/test/tui/tui-render.test.ts index ec7569a7..ce34c9f8 100644 --- a/test/tui/tui-render.test.ts +++ b/test/tui/tui-render.test.ts @@ -564,6 +564,11 @@ describe('formatPlanUsage', () => { expect(formatPlanUsage({ sevenDay: { usedPercentage: 90, resetAt: 1 } })).toBe('wk 90%'); }); + it('punctuates with the separator it is given, so an ASCII terminal gets none', () => { + const usage = { fiveHour: { usedPercentage: 32, resetAt: 1 }, sevenDay: { usedPercentage: 61, resetAt: 2 } }; + expect(formatPlanUsage(usage, ' - ')).toBe('5h 32% - wk 61%'); + }); + it('is empty when there is nothing to report, so the header shows no placeholder', () => { expect(formatPlanUsage(null)).toBe(''); expect(formatPlanUsage(undefined)).toBe(''); From 39a13371f8c7af69bf70400ea33f13611dcc80b1 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 20:43:43 +0200 Subject: [PATCH 28/57] fix: read a row-addressed repaint as lines in the preview Measured against a live Claude pane: an Ink TUI paints by ROW and emits almost no newlines, so dropping cursor-position sequences collapsed a whole screen into one unreadable line, and a tail cut mid-sequence printed the remains of it (";1H") as text. Now a jump to column 1 starts a display line, a jump inside a row moves the write position (capped, since a stream may address a column no terminal has), and a severed CSI head is dropped before parsing. The preview is readable against a real session as a result: tool calls, the working line and the composer all land where they belong. Also drop the repeated session name from a search row, whose snippet opens with the name the row already shows in its first column. Co-Authored-By: Claude Fable 5 --- src/tui/tui-ansi.ts | 60 ++++++++++++++++++++++++++++++++++++++ src/tui/tui-app.ts | 5 ++-- src/tui/tui-model.ts | 15 ++++++++-- test/tui/tui-ansi.test.ts | 36 +++++++++++++++++++++-- test/tui/tui-model.test.ts | 4 ++- 5 files changed, 113 insertions(+), 7 deletions(-) diff --git a/src/tui/tui-ansi.ts b/src/tui/tui-ansi.ts index 80118c36..76e0c73f 100644 --- a/src/tui/tui-ansi.ts +++ b/src/tui/tui-ansi.ts @@ -8,10 +8,20 @@ * 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 */ @@ -27,6 +37,8 @@ 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 @@ -37,6 +49,15 @@ interface EscapeScan { 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; +} + +/** The column a `CSI r ; c H` addresses. Both parameters default to 1. */ +function cursorColumn(params: string): number { + const parts = params.split(';'); + const column = Number.parseInt(parts[1] ?? '', 10); + return Number.isSafeInteger(column) && column > 0 ? column : 1; } /** Scan a CSI body starting at `from` (params, then intermediates, then a final byte). */ @@ -47,6 +68,9 @@ function readCsi(text: string, start: number, from: number, keepSgr: boolean): E 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, column: cursorColumn(text.slice(from, j)) }; + } return { next }; } @@ -320,6 +344,17 @@ export function toDisplayLines(raw: string): string[] { 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 @@ -340,6 +375,11 @@ export function toDisplayLines(raw: string): string[] { if (scan.sgr !== undefined) { active = applySgr(active, scan.sgr); sgr = active.join(''); + } 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; @@ -377,6 +417,26 @@ export function toDisplayLines(raw: string): string[] { 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 diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 760f65f6..7e485e05 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -50,7 +50,7 @@ import chalk from 'chalk'; import { palette, table, tint, type Tone } from '../cli-style.js'; import { CODEMAN_INSTANCE, resolveTmuxSocketName } from '../config/instance.js'; import { getErrorMessage } from '../types/api.js'; -import { toDisplayLines } from './tui-ansi.js'; +import { dropSeveredEscape, toDisplayLines } from './tui-ansi.js'; import { approvalAnswerForKey, newApprovalIds } from './tui-approvals.js'; import { composerScroll, composerStep, composerText, createComposer, type TuiComposerState } from './tui-composer.js'; import { formatAwayDigest } from './tui-digest.js'; @@ -895,7 +895,8 @@ class TuiApp { try { const raw = await this.client.fetchTerminalTail(sessionId, PREVIEW_TAIL_BYTES); if (this.previewSessionId !== sessionId) return; - this.applyPreview({ sessionId, lines: toDisplayLines(raw).slice(-PREVIEW_MAX_LINES) }); + const lines = toDisplayLines(dropSeveredEscape(raw)).slice(-PREVIEW_MAX_LINES); + this.applyPreview({ sessionId, lines }); } catch { // A tail that cannot be read is a pane-level fact, not a connection one: // the list stays exactly as it is and only this pane says so. diff --git a/src/tui/tui-model.ts b/src/tui/tui-model.ts index aebaa15d..59c81c18 100644 --- a/src/tui/tui-model.ts +++ b/src/tui/tui-model.ts @@ -217,6 +217,16 @@ const SEARCH_GROUP_LABELS: Record = { * has a session id too, but selecting it would move the cursor to a row that is * not on the list. */ +/** + * A session snippet opens with the session's own name (`w1-alpha — /tmp/alpha`), + * which the row already shows in its first column. 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*)?/, ''); +} + export function buildSearchEntries( groups: readonly SearchResultGroup[], isLive: (sessionId: string) => boolean @@ -227,10 +237,11 @@ export function buildSearchEntries( 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: result.jumpTo.relativePath ?? result.sessionName ?? result.sessionId.slice(0, 8), - detail: result.snippet, + text: label, + detail: withoutLabelPrefix(result.snippet, label), sessionId: result.sessionId, live, }); diff --git a/test/tui/tui-ansi.test.ts b/test/tui/tui-ansi.test.ts index 0dc3b83c..1a0687df 100644 --- a/test/tui/tui-ansi.test.ts +++ b/test/tui/tui-ansi.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect } from 'vitest'; import { clipStyledLine, + dropSeveredEscape, padDisplay, stripStyles, toDisplayLines, @@ -42,14 +43,31 @@ describe('toDisplayLines', () => { expect(toDisplayLines('\x1b]0;window title\x1b\\text')).toEqual(['text']); }); - it('strips DECSET/DECRST, cursor movement and charset selection', () => { + it('strips DECSET/DECRST, relative cursor movement and charset selection', () => { expect(toDisplayLines('\x1b[?25lvisible\x1b[?25h')).toEqual(['visible']); expect(toDisplayLines('a\x1b[5Cb')).toEqual(['ab']); - expect(toDisplayLines('\x1b[2J\x1b[H\x1b[1;1Hhome')).toEqual(['home']); expect(toDisplayLines('\x1b(0lqk\x1b(B')).toEqual(['lqk']); expect(toDisplayLines('\x1b=app\x1b>')).toEqual(['app']); }); + it('splits a row-addressed repaint into lines, which is how an Ink TUI paints', () => { + // Claude Code emits almost no newlines: without this the whole screen is + // one line and nothing in the preview is readable. + expect(toDisplayLines('\x1b[1;1Hfirst\x1b[2;1Hsecond\x1b[3;1Hthird')).toEqual(['', 'first', 'second', 'third']); + // A jump inside a row is a write position, not a new line. + expect(toDisplayLines('\x1b[1;1Hab\x1b[1;5Hcd')).toEqual(['', 'ab cd']); + expect(toDisplayLines('\x1b[1;1Habcdef\x1b[1;2HXY')).toEqual(['', 'aXYdef']); + // Both parameters default to 1, so a bare CUP is a fresh row. + expect(toDisplayLines('a\x1b[Hb')).toEqual(['a', 'b']); + expect(toDisplayLines('a\x1b[3;1fb')).toEqual(['a', 'b']); + }); + + it('refuses to allocate a line for a column no terminal has', () => { + const lines = toDisplayLines('\x1b[1;99999Hx'); + expect(lines).toHaveLength(1); + expect(visibleWidth(lines[0])).toBeLessThanOrEqual(1001); + }); + it('strips C1 controls and their sequences', () => { expect(toDisplayLines('a\x9b31mb')).toEqual(['ab']); expect(toDisplayLines('a\x9d0;title\x9cb')).toEqual(['ab']); @@ -89,6 +107,20 @@ describe('toDisplayLines', () => { }); }); +describe('dropSeveredEscape', () => { + it('drops the remains of a sequence a byte-sliced tail starts inside', () => { + expect(dropSeveredEscape(';1Hstill here')).toBe('still here'); + expect(dropSeveredEscape('12;3Htext')).toBe('text'); + expect(dropSeveredEscape('31mred')).toBe('red'); + }); + + it('leaves ordinary text alone', () => { + expect(dropSeveredEscape('hello world')).toBe('hello world'); + expect(dropSeveredEscape('\x1b[31mred')).toBe('\x1b[31mred'); + expect(dropSeveredEscape('')).toBe(''); + }); +}); + describe('visibleWidth', () => { it('ignores escape sequences', () => { expect(visibleWidth(`${RED}abc${RESET}`)).toBe(3); diff --git a/test/tui/tui-model.test.ts b/test/tui/tui-model.test.ts index 1a9c1924..4076d5b4 100644 --- a/test/tui/tui-model.test.ts +++ b/test/tui/tui-model.test.ts @@ -332,7 +332,7 @@ describe('search results', () => { sessionId: 'live-1', sessionName: 'w1-alpha', timestamp: NOW, - snippet: '/tmp/alpha', + snippet: 'w1-alpha — /tmp/alpha', exactMatch: true, jumpTo: { kind: 'session', sessionId: 'live-1' }, }, @@ -368,6 +368,8 @@ describe('search results', () => { expect(entries.map((entry) => entry.kind)).toEqual(['header', 'result', 'result', 'header', 'result']); expect(entries[0].text).toBe('SESSIONS'); expect(entries[1]).toMatchObject({ text: 'w1-alpha', sessionId: 'live-1', live: true }); + // The snippet opens with the session name, which the row already shows. + expect(entries[1].detail).toBe('/tmp/alpha'); // A session that is not on the list cannot be selected into. expect(entries[2]).toMatchObject({ text: 'w9-old', live: false }); expect(entries[4]).toMatchObject({ text: 'docs/notes.md', live: false }); From d1077155aa63ff3856dd6792af0ac6d988acf8aa Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 20:45:47 +0200 Subject: [PATCH 29/57] fix: drop stale approvals and re-check the preview when the world changes Two small honesty fixes at the edges: a server that goes down leaves the dashboard holding prompts nothing can classify any more and whose answer route is unreachable, so degraded mode clears them; and a resize can cross the narrow breakpoint, where there is no preview pane to poll for. Co-Authored-By: Claude Fable 5 --- src/tui/tui-app.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 7e485e05..fb2eb652 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -626,7 +626,12 @@ class TuiApp { private resolveExit: ((code: number) => void) | null = null; private readonly onData = (chunk: Buffer): void => this.feed(chunk); - private readonly onResize = (): void => this.paint(true); + // A resize can cross the narrow breakpoint, where there is no preview pane to + // poll for. + private readonly onResize = (): void => { + this.updatePreview(); + this.paint(true); + }; private readonly onProcessExit = (): void => this.screen.leave(); private readonly onSignal = (): void => this.quit(0); private readonly onFatal = (error: unknown): void => { @@ -780,6 +785,10 @@ class TuiApp { private async refreshDegraded(): Promise { const tmux = await this.client.enumerateTmuxSessions().catch(() => [] as TuiTmuxSession[]); this.model.replaceSessions(tmuxRowsToSessions(tmux)); + // Nothing classifies states without a server, so a prompt that was pending + // when it went down is no longer a fact we can stand behind, and a card + // whose answer route is unreachable is worse than no card. + this.model.setApprovals([]); this.updatePreview(); this.paint(); } From ee3ebc0ed19f7f5050a6416de6925555a458aabd Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 20:47:25 +0200 Subject: [PATCH 30/57] fix: keep the plan-usage chip across a degraded-to-connected upgrade A server that comes up mid-run was upgrading the header's hostname and version but not its chip, which then stayed blank until the next telemetry event. Also swaps a typographic apostrophe out of a preview error, which is not renderable on the ASCII glyph tier. Co-Authored-By: Claude Fable 5 --- src/tui/tui-app.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index fb2eb652..e893fb83 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -842,6 +842,7 @@ class TuiApp { ...(server.hostname ? { hostname: server.hostname } : {}), ...(server.instance ? { instance: server.instance } : {}), ...(server.version ? { version: server.version } : {}), + ...(server.planUsage ? { planUsage: this.planUsageChip(server.planUsage) } : {}), }); await this.refresh(); this.subscribe(); @@ -910,7 +911,7 @@ class TuiApp { // A tail that cannot be read is a pane-level fact, not a connection one: // the list stays exactly as it is and only this pane says so. if (this.previewSessionId !== sessionId) return; - this.applyPreview({ sessionId, lines: [], error: 'could not read this session’s terminal' }); + this.applyPreview({ sessionId, lines: [], error: "could not read that session's terminal" }); } finally { this.previewFetching = false; } From 9e3e446704eeb12e09ba026dcc11b63a6c1994a3 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 21:01:27 +0200 Subject: [PATCH 31/57] docs: document codeman tui The user guide covers what the dashboard is (and is not), the two non-interactive fast paths, the four groups and their ordering, the full keymap, what answering an approval does server-side, and the SSH/narrow and degraded cases. The example frame is a real 100x30 capture against the E2E fake server, not a drawing. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 +- README.md | 18 ++++ docs/tui-plan.md | 7 +- docs/tui.md | 238 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 docs/tui.md diff --git a/CLAUDE.md b/CLAUDE.md index 327f6924..49116212 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,6 +94,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | Task | Command | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Terminal dashboard | `codeman tui` (`--list` prints the numbered list and exits, `codeman tui ` attaches to row n; both short-circuit before any screen setup). Needs a TTY; without a server it starts attach-only. `docs/tui.md` | | Dev with TLS | `npx tsx src/index.ts web --https` | | Override window title hostname | `npx tsx src/index.ts web --title-hostname ` (default: `os.hostname()` — `codeman:` is used for tab title, title-flash, and OS desktop notification prefix) | | Bind a non-loopback host | `npx tsx src/index.ts web --host 0.0.0.0` (or `-H`; env `CODEMAN_HOST`; default `127.0.0.1`). Without `CODEMAN_PASSWORD` it **starts but warns loudly** — see Common Gotchas + `docs/security-architecture.md` | @@ -143,7 +144,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | Domain | Key files | Notes | | ---------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| **Entry** | `src/index.ts`, `src/cli.ts`, `daemon-control`, `service-installer`, `config/service-names` | The last three back `web -d` / `service install` | +| **Entry** | `src/index.ts`, `src/cli.ts`, `daemon-control`, `service-installer`, `config/service-names`, `cli-style` | The last three back `web -d` / `service install`; `cli-style` is the shared palette/table/spinner/confirm kit | +| **TUI** | `src/tui/`: `tui-app` ★ + `tui-client` (the only IO) over a pure core (`-model`, `-layout`, `-render`, `-keys`, `-ansi`, `-composer`, `-approvals`, `-digest`, `-sse`, `-types`) | `codeman tui`, a CLIENT of the server, never a second brain. Design doc: `docs/tui-plan.md`; user guide `docs/tui.md` | | **Session** | `src/session.ts` ★, `session-manager`, `session-auto-ops`, `session-cli-builder`, `session-task-cache`, `session-order` (pure), `session-pty-exit-breaker`, `usage-limit-patterns`, `usage-telemetry`; `src/services/unified-session-service.ts` | Pure/unit-tested helpers are split out of `session.ts` on purpose | | **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` ★ | | | **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first | diff --git a/README.md b/README.md index 38241cd6..f8a7241f 100644 --- a/README.md +++ b/README.md @@ -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, `Enter` attaches into the tmux pane (`Ctrl+B D` to come back), `1`-`9` jump-attach. `y`/`n`/digit answer a pending permission dialog right from the list, `p` sends a one-line prompt, `n` starts a session, `x` kills one after a typed confirmation, `/` 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: @@ -894,6 +910,8 @@ codeman session list # list sessions codeman session logs # tail output codeman task add "fix the failing test" # (t) queue a task codeman attach # 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) diff --git a/docs/tui-plan.md b/docs/tui-plan.md index d42a5aa9..3af9fe4c 100644 --- a/docs/tui-plan.md +++ b/docs/tui-plan.md @@ -1,6 +1,11 @@ # Codeman TUI Rework Plan -Status: PROPOSED (research done, nothing implemented). Owner review needed on the open questions at the bottom. +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. diff --git a/docs/tui.md b/docs/tui.md new file mode 100644 index 00000000..97a39321 --- /dev/null +++ b/docs/tui.md @@ -0,0 +1,238 @@ +# 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 ` 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, with a typed confirmation | +| `/` | 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. Detach with **`Ctrl+B D`** (tmux's default prefix, which Codeman does not +change for local sessions) and the dashboard comes back and refreshes. + +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 first (`Ctrl+B D`), then run `codeman tui` again | + +A RECENT row and a direct-PTY session have no pane to attach to, and say so. + +`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 (`Ctrl+B D`) 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. From 03f5f5666a0d004c07a794516a524239ae5f60b3 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 21:08:14 +0200 Subject: [PATCH 32/57] fix: drop the two keymap and style entries nothing reaches `mark()` had no callers (knip's only finding on this branch), and the renderer's fallback help list advertised `r` resume, which is deferred with the rest of phase 3: a help screen naming a verb the build does not implement is worse than no help. Co-Authored-By: Claude Fable 5 --- src/cli-style.ts | 5 ----- src/tui/tui-render.ts | 1 - 2 files changed, 6 deletions(-) diff --git a/src/cli-style.ts b/src/cli-style.ts index b128ed7f..7c4e2192 100644 --- a/src/cli-style.ts +++ b/src/cli-style.ts @@ -77,11 +77,6 @@ export function tint(tone: Tone, text: string): string { return TONE_STYLE[tone](text); } -/** Colored glyph for a tone, the `✓ ` / `✗ ` prefix most command output opens with. */ -export function mark(tone: Tone): string { - return tint(tone, glyphFor(tone)); -} - // ───────────────────────────────────────────────────────────────────────────── // Blocks // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/tui/tui-render.ts b/src/tui/tui-render.ts index d673c34d..3e0757a4 100644 --- a/src/tui/tui-render.ts +++ b/src/tui/tui-render.ts @@ -675,7 +675,6 @@ function helpLines(glyphs: TuiGlyphSet, custom?: ReadonlyArray Date: Sun, 16 Aug 2026 21:21:02 +0200 Subject: [PATCH 33/57] fix: cover tui in the CLI inventory and drop the em-dashes it printed The inventory test predates the `tui` command, so a rename or an accidental removal would have gone unnoticed: it now asserts the command, its `-l`/`--list` flag and its optional position operand. The digest and search-result lines joined their halves with an em-dash, which the repo's own convention rules out, so both now use the middle dot the surrounding lines already use. The one em-dash left in `src/tui/` is load-bearing: `search-service.ts` builds a session snippet with it, and the pattern that strips the repeated label has to match it. Also moves `buildSearchEntries`'s doc comment back onto `buildSearchEntries`; it had ended up stacked above a helper. Co-Authored-By: Claude Fable 5 --- src/tui/tui-app.ts | 2 +- src/tui/tui-client.ts | 4 ++-- src/tui/tui-digest.ts | 2 +- src/tui/tui-model.ts | 21 +++++++++++---------- test/cli-commands.test.ts | 7 +++++++ test/tui/tui-digest.test.ts | 2 +- 6 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index e893fb83..c600acb3 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -1399,7 +1399,7 @@ class TuiApp { } // Nothing to switch to (a history or file hit), so the row's own facts are // the answer; resuming one is phase 3. - this.model.updateSearch({ note: [entry.text, entry.detail].filter((part) => part).join(' — ') }); + this.model.updateSearch({ note: [entry.text, entry.detail].filter((part) => part).join(' · ') }); } private async openDigest(): Promise { diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index 981d992b..e9261c1d 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -6,7 +6,7 @@ * API the web UI uses, so the two surfaces can never disagree. This module is * the only place in `src/tui/` that does IO. It covers four jobs: * - * 1. **Discovery + auth** — find the instance's server (`CODEMAN_API_URL`, else + * 1. **Discovery + auth**: find the instance's server (`CODEMAN_API_URL`, else * loopback on `CODEMAN_PORT`), accepting the self-signed cert `--https` * generates, and read credentials the way `codeman attach` already does * (env, then the data dir's `.env`). @@ -15,7 +15,7 @@ * 3. **Live updates** over SSE, decoded by `tui-sse.ts`, with a staleness * watchdog and capped backoff. The TUI does not patch rows from payloads: an * interesting event means "resync", and the app layer debounces the refetch. - * 4. **Degraded mode** — when nothing answers, sessions are enumerated straight + * 4. **Degraded mode**: when nothing answers, sessions are enumerated straight * from tmux plus a read-only peek at `state.json`, which keeps the "the * server died, get me to my sessions" path that `sc` has today. * diff --git a/src/tui/tui-digest.ts b/src/tui/tui-digest.ts index e93c4327..2ad04da2 100644 --- a/src/tui/tui-digest.ts +++ b/src/tui/tui-digest.ts @@ -47,7 +47,7 @@ function ageColumn(item: AwayDigestItem, now: number): string { 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(' — '); + const what = [item.title, item.detail].filter((part) => part && part.trim() !== '').join(' · '); return ` ${ageColumn(item, now)} ${[who, what].filter((part) => part !== '').join(' ')}`.replace(/\s+$/, ''); } diff --git a/src/tui/tui-model.ts b/src/tui/tui-model.ts index 59c81c18..4d6957be 100644 --- a/src/tui/tui-model.ts +++ b/src/tui/tui-model.ts @@ -208,6 +208,17 @@ const SEARCH_GROUP_LABELS: Record = { 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 /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 @@ -217,16 +228,6 @@ const SEARCH_GROUP_LABELS: Record = { * has a session id too, but selecting it would move the cursor to a row that is * not on the list. */ -/** - * A session snippet opens with the session's own name (`w1-alpha — /tmp/alpha`), - * which the row already shows in its first column. 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*)?/, ''); -} - export function buildSearchEntries( groups: readonly SearchResultGroup[], isLive: (sessionId: string) => boolean diff --git a/test/cli-commands.test.ts b/test/cli-commands.test.ts index 93a010ed..9946a961 100644 --- a/test/cli-commands.test.ts +++ b/test/cli-commands.test.ts @@ -40,6 +40,7 @@ const TOP_LEVEL: Record = { reset: [], start: [], list: ['ls'], + tui: [], web: [], service: [], users: [], @@ -137,6 +138,10 @@ describe('registered options', () => { expect(flagsOf(find(program, 'doctor')!)).toEqual(expect.arrayContaining(['--json', '--category'])); }); + it('keeps the two `tui` fast paths, which stand in for `sc -l` and `sc 2`', () => { + expect(flagsOf(find(program, 'tui')!)).toEqual(expect.arrayContaining(['-l', '--list'])); + }); + it('keeps the escape hatches that scripts depend on', () => { expect(flagsOf(find(program, 'reset')!)).toEqual(expect.arrayContaining(['-f', '--force'])); expect(flagsOf(find(program, 'status')!)).toEqual(expect.arrayContaining(['--url'])); @@ -164,6 +169,8 @@ describe('registered arguments', () => { it.each([ ['attach', ['path']], ['start', []], + // Optional: bare `codeman tui` opens the dashboard. + ['tui', ['n']], ])('declares the operands of `%s`', (name, expected) => { const args = find(program, name)!.registeredArguments.map((arg) => arg.name()); expect(args).toEqual(expected); diff --git a/test/tui/tui-digest.test.ts b/test/tui/tui-digest.test.ts index 2ac2bee5..1efac8af 100644 --- a/test/tui/tui-digest.test.ts +++ b/test/tui/tui-digest.test.ts @@ -75,7 +75,7 @@ describe('formatAwayDigest', () => { { now: NOW } ); expect(lines).toContain('NEEDS ATTENTION (1)'); - expect(lines).toContain(' 2m w4-api permission prompt — Bash(git push)'); + expect(lines).toContain(' 2m w4-api permission prompt · Bash(git push)'); }); it('caps a long section instead of burying the next one', () => { From b555d2b7b31b4c1817f5bf590ed030c8463a9465 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 21:22:35 +0200 Subject: [PATCH 34/57] docs: extend the instance-isolation rule to tmux socket resolution The data-dir half was already spelled out; the socket half only lived in a function docstring, and the TUI is the first code that shells out to `tmux -L` from a process that is not the server. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 49116212..f4107239 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,7 +132,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph - **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. This has caused real shipped bugs twice - **`xterm-zerolag-input` is single-source** — BOTH echo addons live ONLY in `packages/xterm-zerolag-input/src/`, bundled into TWO **gitignored** vendor files: `vendor/xterm-zerolag-input.js` (buffer overlay, entry `zerolag-input-addon.ts`) and `vendor/xterm-predictive-echo.js` (codex write-through, entry `predictive-echo-addon.ts`) — dev by `scripts/postinstall.js`, prod by `scripts/build.mjs`. `app.js`/terminal-ui.js only **consume** them via `new LocalEchoOverlay(terminal)` / `new PredictiveEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundles.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md` - **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md` -- **Instance isolation / multi-instance attach danger** — the 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`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger) +- **Instance isolation / multi-instance attach danger** — the 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`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`, and **any new `tmux -L` caller through `resolveTmuxSocketName()`** (both in `config/instance.ts`): the TUI shells out to tmux from a second process, and a hardcoded `codeman` there would point a beta instance at prod's panes. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger) - **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ Look in **`prebuilds/-/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable) - **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture) From 715b484fc63898ef9ebf52c8f79d4a24b876a712 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 17 Aug 2026 15:59:17 +0200 Subject: [PATCH 35/57] fix: date a working row by its turn, not by the session age `TuiSessionRow` declared `lastSubmitAt`/`inputTokens`/`outputTokens`, `stateSince()` ordered the WORKING group by the first of them and `renderRowLines()` painted the other two, but nothing ever filled any of them in: the unified list carries none, and the `session:updated` payload that does was discarded (an event only schedules a refetch). So a running turn was dated by its SESSION's creation instead. Measured against the live server before the fix: w65 (created 21h ago, turn started one minute earlier) outranked w67 (created 15 minutes ago, turn started five minutes earlier), the reverse of the rule docs/tui.md states, and the elapsed column read `21h` for a turn a minute old. The token column was unreachable code for the same reason. `fetchLiveSessionMetrics()` reads the three fields from `GET /api/sessions` and `applyLiveMetrics()` folds them onto the rows. That route answers from the server's cached LIGHT state (no terminal buffers): 10-20ms measured, against the ~550ms the unified list in the same `Promise.all` already costs, so it is cheap enough to ride every refresh. It is best-effort like the approvals and tmux reads beside it, because losing the anchor is better than losing the list. A ZERO is treated as unknown rather than merged: `stateSince()` reads `lastSubmitAt ?? createdAt` and 0 is not nullish, so a merged 0 would date every never-submitted session to the epoch. The snapshot path gets the same merge, or `codeman tui --list` would number the WORKING group differently from the dashboard that `codeman tui ` indexes into. Verified live: working rows now show 28m/8m (turn age, tokens 280.5k/65.2k) where they showed 21h/34m and no tokens. The e2e assertion fails on master's wiring with `[*] 10m` against a session that pressed Enter one minute ago. Co-Authored-By: Claude Opus 5 (1M context) --- src/tui/tui-app.ts | 44 ++++++++++++++++++++++--- src/tui/tui-client.ts | 52 +++++++++++++++++++++++++++-- test/tui/tui-app.test.ts | 65 +++++++++++++++++++++++++++++++++++-- test/tui/tui-client.test.ts | 28 ++++++++++++++++ test/tui/tui-e2e.test.ts | 25 ++++++++++++++ 5 files changed, 205 insertions(+), 9 deletions(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index c600acb3..25f1eb96 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -58,6 +58,7 @@ import { TuiClient, type TuiApprovalAnswer, type TuiEventStream, + type TuiLiveSessionMetrics, type TuiPlanUsage, type TuiQuickStartOptions, type TuiTmuxSession, @@ -455,6 +456,35 @@ export function applyMuxNames(sessions: readonly TuiSessionRow[], tmux: readonly }); } +/** + * Fold the live-only counters onto the rows the unified list produced: the + * pane's last Enter (which dates a running turn and orders the WORKING group) + * and the token totals the wide layout shows. + * + * A ZERO is treated as "unknown" rather than merged, and that is the whole + * reason this is not a spread: `stateSince()` reads `lastSubmitAt ?? createdAt`, + * and 0 is not nullish, so merging a 0 would date every never-submitted session + * to the epoch and sort it as the oldest turn on the list. The join is on the + * FULL id, since both sides come from the same server. + */ +export function applyLiveMetrics( + sessions: readonly TuiSessionRow[], + metrics: readonly TuiLiveSessionMetrics[] +): TuiSessionRow[] { + if (metrics.length === 0) return sessions.map((session) => ({ ...session })); + const byId = new Map(metrics.map((entry) => [entry.sessionId, entry])); + return sessions.map((session) => { + const live = byId.get(session.sessionId); + if (!live) return { ...session }; + return { + ...session, + ...(live.lastSubmitAt ? { lastSubmitAt: live.lastSubmitAt } : {}), + ...(live.inputTokens ? { inputTokens: live.inputTokens } : {}), + ...(live.outputTokens ? { outputTokens: live.outputTokens } : {}), + }; + }); +} + const STATE_TONE: Record = { 'blocked-permission': 'err', 'blocked-question': 'err', @@ -762,12 +792,15 @@ class TuiApp { private async refreshConnected(): Promise { try { - const [sessions, approvals, tmux] = await Promise.all([ + const [sessions, approvals, tmux, metrics] = await Promise.all([ this.client.fetchUnifiedSessions(UNIFIED_LIMIT), this.client.fetchApprovals().catch(() => []), this.client.enumerateTmuxSessions().catch(() => [] as TuiTmuxSession[]), + // Best-effort like the other two: without it a running turn is dated by + // its session's creation, which is worse than the list going stale. + this.client.fetchLiveSessionMetrics().catch(() => [] as TuiLiveSessionMetrics[]), ]); - this.model.replaceSessions(applyMuxNames(sessions, tmux)); + this.model.replaceSessions(applyLiveMetrics(applyMuxNames(sessions, tmux), metrics)); this.model.setApprovals(approvals); this.noteApprovals(approvals); if (this.pendingSelectId && this.model.select(this.pendingSelectId)) this.pendingSelectId = null; @@ -1684,12 +1717,15 @@ async function snapshot(client: TuiClient): Promise { model.replaceSessions(tmuxRowsToSessions(await client.enumerateTmuxSessions())); return { kind: 'ok', rows: model.rows(), degraded: true }; } - const [sessions, approvals, tmux] = await Promise.all([ + const [sessions, approvals, tmux, metrics] = await Promise.all([ client.fetchUnifiedSessions(UNIFIED_LIMIT), client.fetchApprovals().catch(() => []), client.enumerateTmuxSessions().catch(() => [] as TuiTmuxSession[]), + client.fetchLiveSessionMetrics().catch(() => [] as TuiLiveSessionMetrics[]), ]); - model.replaceSessions(applyMuxNames(sessions, tmux)); + // Same merge as the dashboard, so `--list`'s numbers stay the numbers + // `codeman tui ` takes: the WORKING group's order depends on it. + model.replaceSessions(applyLiveMetrics(applyMuxNames(sessions, tmux), metrics)); model.setApprovals(approvals); return { kind: 'ok', rows: model.rows(), degraded: false }; } diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index e9261c1d..38ded631 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -31,9 +31,12 @@ * - Plan usage has no route of its own: the last-known snapshot rides * `GET /api/status` as `planUsage` (`web/plan-usage-latest.ts`) and updates * arrive as `session:statusTelemetry` SSE frames. - * - The unified list carries no token counters or turn-start stamp - * (`TuiSessionRow.lastSubmitAt`), so those stay unset here; the app layer - * merges them from live session state when it wants them. + * - The unified list carries no token counters and no turn-start stamp + * (`TuiSessionRow.lastSubmitAt`), which the WORKING group is ordered by, so + * `fetchLiveSessionMetrics()` reads them from `GET /api/sessions` and + * `applyLiveMetrics()` (tui-app) folds them onto the rows. That route answers + * from the server's cached LIGHT state (no terminal buffers, ~10ms), which is + * what makes it cheap enough to ride every refresh. * * @module tui/tui-client */ @@ -147,6 +150,22 @@ export interface TuiQuickStartResult { caseName?: string; } +/** + * The live-only fields the unified list does not carry, per session id. + * + * `lastSubmitAt` is the one the dashboard cannot do without: it is the pane's + * last Enter, which is what a WORKING row's elapsed column shows and what the + * WORKING group is sorted by. Without it a running turn is dated by the + * SESSION's creation instead, so a day-old session that started a turn a minute + * ago outranks one that has been working for an hour. + */ +export interface TuiLiveSessionMetrics { + sessionId: string; + lastSubmitAt?: number; + inputTokens?: number; + outputTokens?: number; +} + /** Init snapshot, narrowed to the two facts the dashboard header shows. */ export interface TuiInitState { version?: string; @@ -507,6 +526,33 @@ export class TuiClient { return data?.sessions ?? []; } + /** + * The turn-start stamp and token counters for every LIVE session. + * + * `GET /api/sessions` is the light state (`getLightSessionsState()`, itself + * cached server-side): no terminal buffers, so this is a ~10ms read next to + * the unified list's transcript scan. Rows are narrowed to the three fields + * the dashboard actually merges, and a row with no usable id is dropped + * rather than folded in under an empty key. + */ + async fetchLiveSessionMetrics(): Promise { + const data = await this.requestData< + Array<{ id?: unknown; lastSubmitAt?: unknown; inputTokens?: unknown; outputTokens?: unknown }> + >('GET', '/api/sessions'); + if (!Array.isArray(data)) return []; + const rows: TuiLiveSessionMetrics[] = []; + for (const entry of data) { + if (!entry || typeof entry.id !== 'string' || entry.id === '') continue; + rows.push({ + sessionId: entry.id, + ...(typeof entry.lastSubmitAt === 'number' ? { lastSubmitAt: entry.lastSubmitAt } : {}), + ...(typeof entry.inputTokens === 'number' ? { inputTokens: entry.inputTokens } : {}), + ...(typeof entry.outputTokens === 'number' ? { outputTokens: entry.outputTokens } : {}), + }); + } + return rows; + } + async fetchApprovals(): Promise { const data = await this.requestData<{ approvals?: ApprovalItem[] }>('GET', '/api/approvals'); return data?.approvals ?? []; diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts index f2403c3c..e5652151 100644 --- a/test/tui/tui-app.test.ts +++ b/test/tui/tui-app.test.ts @@ -11,6 +11,7 @@ */ import { describe, it, expect } from 'vitest'; import { + applyLiveMetrics, applyMuxNames, buildListLines, confirmAccepts, @@ -27,9 +28,9 @@ import { tmuxRowsToSessions, tmuxSocketFromEnv, } from '../../src/tui/tui-app.js'; -import { createTuiModel } from '../../src/tui/tui-model.js'; +import { createTuiModel, stateSince } from '../../src/tui/tui-model.js'; import { glyphsFor } from '../../src/tui/tui-render.js'; -import type { TuiTmuxSession } from '../../src/tui/tui-client.js'; +import type { TuiLiveSessionMetrics, TuiTmuxSession } from '../../src/tui/tui-client.js'; import type { TuiConfirmState, TuiRow, TuiSessionRow } from '../../src/tui/tui-types.js'; const GLYPHS = glyphsFor('unicode'); @@ -339,6 +340,66 @@ describe('applyMuxNames', () => { }); }); +describe('applyLiveMetrics', () => { + const metrics: TuiLiveSessionMetrics[] = [ + { sessionId: 'aaaa1111', lastSubmitAt: 5_000, inputTokens: 900, outputTokens: 100 }, + { sessionId: 'bbbb2222', lastSubmitAt: 0, inputTokens: 0, outputTokens: 0 }, + ]; + + it('folds the turn stamp and the token totals onto the matching row', () => { + const rows = applyLiveMetrics( + [ + { sessionId: 'aaaa1111', sources: ['live'] }, + { sessionId: 'cccc3333', sources: ['live'] }, + ], + metrics + ); + expect(rows[0]).toMatchObject({ lastSubmitAt: 5_000, inputTokens: 900, outputTokens: 100 }); + // No live counterpart (a history row): nothing to fold, nothing invented. + expect(rows[1].lastSubmitAt).toBeUndefined(); + expect(rows[1].inputTokens).toBeUndefined(); + }); + + it('treats a zero as unknown, so a never-submitted session is not dated to the epoch', () => { + const [merged] = applyLiveMetrics([{ sessionId: 'bbbb2222', sources: ['live'], createdAt: 1_000 }], metrics); + expect(merged.lastSubmitAt).toBeUndefined(); + expect(merged.inputTokens).toBeUndefined(); + expect(stateSince('working', merged)).toBe(1_000); + }); + + it('copies rather than mutating its input, and survives an empty metrics list', () => { + const input: TuiSessionRow[] = [{ sessionId: 'aaaa1111', sources: ['live'] }]; + const rows = applyLiveMetrics(input, []); + expect(rows[0]).not.toBe(input[0]); + expect(rows[0].lastSubmitAt).toBeUndefined(); + expect(input[0].lastSubmitAt).toBeUndefined(); + }); + + it('orders the WORKING group by when the turn started, not by session age', () => { + // The regression this merge exists for: `old` was created a day before + // `fresh` but started its turn a minute AFTER it, so `fresh` has been + // working longer and has to lead. Without the merge both fall back to + // createdAt and `old` wins. + const unified: TuiSessionRow[] = [ + { sessionId: 'old00000', name: 'old', sources: ['live'], isWorking: true, createdAt: 1_000 }, + { sessionId: 'fresh000', name: 'fresh', sources: ['live'], isWorking: true, createdAt: 500_000 }, + ]; + const turns: TuiLiveSessionMetrics[] = [ + { sessionId: 'old00000', lastSubmitAt: 900_000 }, + { sessionId: 'fresh000', lastSubmitAt: 600_000 }, + ]; + + const before = createTuiModel(); + before.replaceSessions(unified); + expect(before.rows().map((r) => r.session.name)).toEqual(['old', 'fresh']); + + const after = createTuiModel(); + after.replaceSessions(applyLiveMetrics(unified, turns)); + expect(after.rows().map((r) => r.session.name)).toEqual(['fresh', 'old']); + expect(after.rows()[0].since).toBe(600_000); + }); +}); + describe('buildListLines', () => { it('numbers rows in the dashboard order, so `tui ` and `tui --list` agree', () => { const model = createTuiModel(); diff --git a/test/tui/tui-client.test.ts b/test/tui/tui-client.test.ts index 5e3e4029..db4cdf7f 100644 --- a/test/tui/tui-client.test.ts +++ b/test/tui/tui-client.test.ts @@ -63,6 +63,17 @@ const defaultResponder: Responder = (req, res) => { data: { sessions: [{ sessionId: 'abc', name: 'w1-codeman', sources: ['live'] }], total: 1 }, }); } + if (url === '/api/sessions' || url.startsWith('/api/sessions?')) { + // The light state, plus the two rows the narrowing has to discard. + return sendJson(res, 200, { + success: true, + data: [ + { id: 'abc', lastSubmitAt: 4000, inputTokens: 900, outputTokens: 100, status: 'busy' }, + { id: '', lastSubmitAt: 7000 }, + { id: 'zzz', lastSubmitAt: '5', inputTokens: null }, + ], + }); + } if (url.startsWith('/api/approvals')) { return sendJson(res, 200, { success: true, @@ -318,6 +329,23 @@ describe('TuiClient remaining API surface', () => { expect(recorded[0].url).toBe('/api/sessions/abc/terminal?tail=4096'); }); + it('reads the turn stamp and token counters off the light session state', async () => { + const metrics = await client().fetchLiveSessionMetrics(); + expect(recorded[0].url).toBe('/api/sessions'); + // Narrowed to the three fields, keyed by id: a row with no usable id is + // dropped rather than folded in under an empty key, and a field of the + // wrong type is left absent rather than merged as a string. + expect(metrics).toEqual([ + { sessionId: 'abc', lastSubmitAt: 4000, inputTokens: 900, outputTokens: 100 }, + { sessionId: 'zzz' }, + ]); + }); + + it('reports an unreadable session list as empty rather than throwing', async () => { + responder = (_req, res) => sendJson(res, 200, { success: true, data: { sessions: 'not an array' } }); + await expect(client().fetchLiveSessionMetrics()).resolves.toEqual([]); + }); + it('starts sessions through quick-start', async () => { const result = await client().quickStart({ caseName: 'x', mode: 'claude', parentSessionId: 'abc' }); expect(result.sessionId).toBe('new-1'); diff --git a/test/tui/tui-e2e.test.ts b/test/tui/tui-e2e.test.ts index 62c0e4bc..41caa78a 100644 --- a/test/tui/tui-e2e.test.ts +++ b/test/tui/tui-e2e.test.ts @@ -53,6 +53,13 @@ let sessions: UnifiedSessionItem[] = []; let approvals: ApprovalItem[] = []; /** Terminal buffers the preview pane polls, by session id. */ const terminals = new Map(); +/** + * The LIGHT session state (`GET /api/sessions`), which is where the turn stamp + * and the token counters come from: the unified list carries neither, so w2-beta + * is deliberately a session created 10 minutes ago whose turn started one minute + * ago. A row dated by the wrong one of those reads `10m` instead of `1m`. + */ +let liveState: Array> = []; /** Everything the TUI posted, so a test can assert on the exact body. */ const answered: Array<{ id: string; body: Record }> = []; const inputs: Array<{ sessionId: string; body: Record }> = []; @@ -184,6 +191,10 @@ function resetSessions(): void { lastActivityAt: NOW - 3_600_000, }, ]; + liveState = [ + { id: BETA, status: 'busy', lastSubmitAt: NOW - 60_000, inputTokens: 42_000, outputTokens: 3_200 }, + { id: ALPHA, status: 'idle', lastSubmitAt: NOW - 60_000 }, + ]; } let server: http.Server; @@ -264,6 +275,9 @@ beforeAll(async () => { return sendJson(res, { success: true, data: { version: '9.9.9', planUsage: PLAN_USAGE } }); } if (url.startsWith('/api/sessions/unified')) return sendJson(res, { success: true, data: { sessions } }); + if (url === '/api/sessions' || url.startsWith('/api/sessions?')) { + return sendJson(res, { success: true, data: liveState }); + } const previewFor = sessionRoute(url, 'terminal'); if (previewFor) { @@ -424,6 +438,17 @@ describe('codeman tui (under a pty)', () => { expect(index('RECENT')).toBeLessThan(index('w3-gamma')); }); + it('dates a working row by its turn, not by the session age', () => { + // w2-beta was created 10 minutes ago and pressed Enter one minute ago, and + // only `GET /api/sessions` knows the second number. `1m` proves the merge + // ran end to end; `10m` would mean the row fell back to createdAt. + const beta = rowFor(output, 'w2-beta'); + expect(beta).toMatch(/\b1m\b/); + expect(beta).not.toMatch(/\b10m\b/); + // The token column rides the same read. + expect(beta).toContain('45.2k'); + }); + it('shows the header facts and only the keys that work', () => { const lines = frameLines(output); expect(lines[0]).toContain('codeman'); From 0a8067170d0dd61cc0557dd65eb14d705d606127 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 17 Aug 2026 15:59:31 +0200 Subject: [PATCH 36/57] refactor: drop the two store members nothing consults `TuiModelStore.confirmSatisfied()` and `approvalFor()` had no caller outside their own tests. The first one mattered: it answered "does the typed text authorize this kill?" with an exact name match, while the rule actually consulted (`confirmAccepts()` in tui-app) also accepts the 8-character id prefix a mux name carries. Two divergent answers to one question, the stricter one unreachable and waiting to be picked up by mistake. knip cannot see class members, so the dead-code sweep never flagged either. The tests they existed for now assert observable state instead, and the approvals one got stronger on the way: it checks that a session id coming back does not inherit the dead session's dialog, which is the invariant `removeSession()` is actually keeping. Co-Authored-By: Claude Opus 5 (1M context) --- src/tui/tui-model.ts | 16 ++++++---------- test/tui/tui-model.test.ts | 20 ++++++++++++-------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/tui/tui-model.ts b/src/tui/tui-model.ts index 4d6957be..30f15fbf 100644 --- a/src/tui/tui-model.ts +++ b/src/tui/tui-model.ts @@ -349,10 +349,6 @@ export class TuiModelStore implements TuiRenderModel { }); } - approvalFor(sessionId: string): ApprovalItem | undefined { - return this.approvalsBySession.get(sessionId); - } - sessions(): TuiSessionRow[] { return [...this.sessionsById.values()]; } @@ -440,7 +436,12 @@ export class TuiModelStore implements TuiRenderModel { this.touch(); } - /** Arm the typed-name confirmation for `x` (kill). */ + /** + * 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): void { this.confirm = { sessionId: row.session.sessionId, @@ -457,11 +458,6 @@ export class TuiModelStore implements TuiRenderModel { this.touch(); } - /** Does the typed text authorize the kill? Exact match on the name shown. */ - confirmSatisfied(): boolean { - return this.confirm !== null && this.confirm.typed.trim() === this.confirm.name; - } - /** Drop whatever overlay owns the keyboard and go back to the list. */ closeOverlay(): void { this.confirm = null; diff --git a/test/tui/tui-model.test.ts b/test/tui/tui-model.test.ts index 4076d5b4..3a490831 100644 --- a/test/tui/tui-model.test.ts +++ b/test/tui/tui-model.test.ts @@ -262,28 +262,32 @@ describe('the store', () => { expect(flattenRows(model.groups())).toHaveLength(3); }); - it('tracks the confirm-kill overlay and only accepts the exact name', () => { + // Whether the typed text AUTHORIZES the kill is `confirmAccepts()` in + // tui-app, tested there; the store only carries what was typed. + it('tracks the confirm-kill overlay, keyed to the name it showed', () => { const model = createTuiModel(); model.replaceSessions([session({ sessionId: 'a', name: 'w4-api' })]); model.beginConfirmKill(model.rows()[0]); expect(model.mode).toBe('confirm-kill'); - expect(model.confirmSatisfied()).toBe(false); + expect(model.confirm).toEqual({ sessionId: 'a', name: 'w4-api', typed: '' }); model.setConfirmInput('w4-ap'); - expect(model.confirmSatisfied()).toBe(false); - model.setConfirmInput('w4-api'); - expect(model.confirmSatisfied()).toBe(true); + expect(model.confirm?.typed).toBe('w4-ap'); model.closeOverlay(); expect(model.mode).toBe('list'); expect(model.confirm).toBeNull(); }); - it('drops a session approval along with the session', () => { + it('drops a session approval along with the session, and never resurrects it', () => { const model = createTuiModel(); model.replaceSessions([session({ sessionId: 'a' })]); model.setApprovals([approval({ sessionId: 'a' })]); - expect(model.approvalFor('a')).toBeDefined(); + expect(model.rows()[0].approval).toBeDefined(); model.removeSession('a'); - expect(model.approvalFor('a')).toBeUndefined(); + expect(model.rows()).toHaveLength(0); + // The same id coming back must not inherit the dead session's dialog. + model.upsertSession(session({ sessionId: 'a' })); + expect(model.rows()[0].approval).toBeUndefined(); + expect(model.rows()[0].state).toBe('idle'); }); }); From c9b3d518e290074c2e497fabd176f1ebb53cbdb4 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 17 Aug 2026 16:58:37 +0200 Subject: [PATCH 37/57] perf: pace the refetch and back the tail poll off a quiet pane Both of the dashboard's periodic reads hit endpoints that are far more expensive than their cadence assumed, and the cost lands on the SERVER's event loop, so it is paid by every browser client too. `GET /api/sessions/unified` is ~550ms against 11 live sessions: it scans every Claude transcript plus the lifecycle log, uncached, and republishes the search index. `scheduleRefresh()` was a 250ms trailing debounce with no floor, and a queued refresh re-ran the instant the previous one returned (by recursing, which also chained one pending promise per iteration), so a stream of events paced the refetches at the endpoint's own latency: with `session:updated` broadcast per session per 500ms while anything is working, the scans ran back to back. `resyncDelayMs()` now keeps ambient refetches 3s apart, measured start-to-start. The user's own actions call `refresh()` directly and are unaffected, so what this paces is only "notice what changed elsewhere". `GET /api/sessions/:id/terminal` is ~80-100ms: two `execSync` tmux calls, then the whole byte buffer normalized before the tail is taken. It was polled every second for as long as a live row was selected. It now backs off 1s, 2s, 4s, 5s while consecutive reads change nothing, and resets to 1s on any change, when the selection moves, when this dashboard sends input or answers a dialog, and on return from an attach. A pane that is printing is still read every second; a pane at its composer is not. The poll also kept running in three places it had nothing to draw for: the whole time the user was attached in tmux (an attach can last hours), and behind the message overlays that an async action opens (answered, killed, started), which are not keystroke-driven and so never reached the `afterInput()` path that stops it. `setInterval` becomes a chained `setTimeout`, since the delay now varies. Measured against the live server, same idle row selected, 25s window: 22 tail reads before, 5 after. With a working pane selected it stays at 22, which is the intended cadence for a pane whose output you are watching. Co-Authored-By: Claude Opus 5 (1M context) --- src/tui/tui-app.ts | 163 ++++++++++++++++++++++++++++++++++----- test/tui/tui-app.test.ts | 25 ++++++ test/tui/tui-e2e.test.ts | 23 ++++++ 3 files changed, 193 insertions(+), 18 deletions(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 25f1eb96..b2476e28 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -108,14 +108,40 @@ const ESC_FLUSH_MS = 30; const TICK_MS = 500; /** A burst of SSE events (one session change fans out to several) becomes one refetch. */ const RESYNC_DEBOUNCE_MS = 250; +/** + * Floor between two AMBIENT refetches, i.e. the ones an SSE event asks for. + * + * `GET /api/sessions/unified` is the expensive read in the app (~550ms measured + * against 11 live sessions: it scans every Claude transcript plus the lifecycle + * log, uncached, and republishes the search index), and the server broadcasts + * `session:updated` per session per 500ms while anything is working. A trailing + * debounce collapses a BURST but does not rate-limit a stream, so without a + * floor the dashboard runs those scans back to back for as long as sessions are + * busy, and the stall lands on every other client of the same server. + * + * A user's OWN actions bypass this (they call `refresh()` directly), so what it + * paces is only "notice what changed elsewhere", where three seconds of + * staleness on a status dot is invisible. + */ +const RESYNC_MIN_INTERVAL_MS = 3_000; /** Poll period once the client reports SSE is not carrying events. */ const POLL_INTERVAL_MS = 2_000; /** Degraded mode re-probes this often, so a server that starts upgrades the TUI live. */ const REPROBE_INTERVAL_MS = 10_000; /** Unified-list page size. RECENT is capped far lower by the model. */ const UNIFIED_LIMIT = 60; -/** How often the selected session's tail is re-read while the list has focus. */ +/** How often the selected session's tail is re-read while it is producing output. */ const PREVIEW_INTERVAL_MS = 1_000; +/** + * Ceiling the tail poll backs off to once the pane stops changing. + * + * `GET /api/sessions/:id/terminal` is not a cheap read either (~80-100ms + * measured): it runs two `execSync` tmux calls and normalizes the whole byte + * buffer before it takes the tail, all of it blocking the server's event loop. + * A pane at its composer prints nothing, so polling it every second buys + * nothing; anything landing in the tail resets the cadence to fast again. + */ +const PREVIEW_MAX_INTERVAL_MS = 5_000; /** Tail size. Enough for a tall pane's last screens, small enough to poll every second. */ const PREVIEW_TAIL_BYTES = 12 * 1024; /** Lines kept from a tail. The pane shows a fraction of these; the rest is headroom. */ @@ -375,9 +401,47 @@ export function previewNoteFor(row: TuiRow | null, connection: TuiConnectionStat } /** - * Would painting `next` change anything? The preview polls once a second, and a + * Delay before the next AMBIENT refetch: the debounce, unless that would land + * inside the floor since the last one started, in which case it waits out the + * rest of the floor. Measured start-to-start, so a slow scan cannot be followed + * immediately by another one. + * + * `lastRefreshAt` of 0 means "never refreshed", which the arithmetic handles on + * its own: the gap is enormous, so the first refetch pays the debounce only. + */ +export function resyncDelayMs( + now: number, + lastRefreshAt: number, + debounceMs = RESYNC_DEBOUNCE_MS, + minIntervalMs = RESYNC_MIN_INTERVAL_MS +): number { + return Math.max(debounceMs, minIntervalMs - (now - lastRefreshAt)); +} + +/** + * How long to wait before re-reading the selected session's tail, given how + * many consecutive reads came back identical. + * + * Doubling from one second to a five-second ceiling, and ANY change resets the + * count, so a pane that is printing is read every second while a pane sitting + * at its composer costs one read every five. The counter is also reset when the + * selection moves and when this dashboard sends input, so the read that should + * show a reply is never the backed-off one. + */ +export function previewIntervalMs( + unchangedReads: number, + baseMs = PREVIEW_INTERVAL_MS, + maxMs = PREVIEW_MAX_INTERVAL_MS +): number { + const steps = Math.min(Math.max(0, Math.trunc(unchangedReads)), 10); + return Math.min(baseMs * 2 ** steps, maxMs); +} + +/** + * Would painting `next` change anything? The preview polls on a timer, and a * quiet session returns the same bytes every time; comparing here is what keeps - * that poll from bumping the model's revision and repainting the frame. + * that poll from bumping the model's revision and repainting the frame, and it + * is also what drives the poll's own backoff. */ export function samePreview(previous: TuiPreview | null, next: TuiPreview | null): boolean { if (previous === next) return true; @@ -643,11 +707,17 @@ class TuiApp { private noticeTimer: NodeJS.Timeout | null = null; private refreshing = false; private refreshQueued = false; + /** When the last refresh STARTED, which is what `resyncDelayMs()` paces off. */ + private lastRefreshAt = 0; private picker: PickerRuntime | null = null; private pendingSelectId: string | null = null; /** Whose tail the preview is currently following; null when nothing is polled. */ private previewSessionId: string | null = null; private previewFetching = false; + /** Is the tail still worth re-reading? The chained timeout stops when it is not. */ + private previewFollowing = false; + /** Consecutive tail reads that changed nothing; the poll's backoff counter. */ + private previewQuiet = 0; /** Bumped per search so a slow response cannot overwrite a newer query's results. */ private searchSeq = 0; /** Approval ids the bell has already rung for. See `newApprovalIds`. */ @@ -760,16 +830,25 @@ class TuiApp { private scheduleRefresh(): void { if (this.resyncTimer) return; - this.resyncTimer = setTimeout(() => { - this.resyncTimer = null; - void this.refresh(); - }, RESYNC_DEBOUNCE_MS); + this.resyncTimer = setTimeout( + () => { + this.resyncTimer = null; + void this.refresh(); + }, + resyncDelayMs(Date.now(), this.lastRefreshAt) + ); } /** * Re-read everything the dashboard shows. Overlapping calls collapse: a burst * of events must not queue a burst of round trips, and the last one has to * still run or the list would sit one change behind. + * + * A call that arrived while this one was in flight is handed back to + * `scheduleRefresh()` rather than run on the spot. Recursing there instead + * (which is what this did) paced the refetches at the endpoint's own latency + * and chained one pending promise per iteration, so a busy machine kept the + * server scanning transcripts continuously. */ private async refresh(): Promise { if (this.exiting) return; @@ -778,6 +857,9 @@ class TuiApp { return; } this.refreshing = true; + // Stamped at the START, so the floor is start-to-start and a direct call + // (an action of the user's own) also pushes the next ambient one out. + this.lastRefreshAt = Date.now(); try { if (this.model.connection === 'degraded') await this.refreshDegraded(); else await this.refreshConnected(); @@ -786,7 +868,7 @@ class TuiApp { } if (this.refreshQueued && !this.exiting) { this.refreshQueued = false; - await this.refresh(); + this.scheduleRefresh(); } } @@ -909,26 +991,44 @@ class TuiApp { return; } + this.previewFollowing = true; if (changed) { + // A different pane, so what the last one printed says nothing about how + // fast this one needs reading. + this.previewQuiet = 0; // Null rather than an empty tail: the renderer reads that as "loading", // while empty lines would claim the session has printed nothing. this.applyPreview(null); void this.fetchPreview(); } - if (!this.previewTimer) { - this.previewTimer = setInterval(() => void this.fetchPreview(), PREVIEW_INTERVAL_MS); - } + this.armPreview(); + } + + /** + * Arm the next tail read. A chained timeout rather than an interval, because + * the delay depends on how long the pane has been quiet, and re-arming is the + * LAST thing each read does so a slow response can never stack two in flight. + */ + private armPreview(): void { + if (this.previewTimer || !this.previewFollowing || this.exiting) return; + this.previewTimer = setTimeout(() => { + this.previewTimer = null; + void this.fetchPreview().finally(() => this.armPreview()); + }, previewIntervalMs(this.previewQuiet)); } private stopPreview(): void { + this.previewFollowing = false; if (!this.previewTimer) return; - clearInterval(this.previewTimer); + clearTimeout(this.previewTimer); this.previewTimer = null; } - private applyPreview(preview: TuiPreview | null): void { - if (samePreview(this.model.preview, preview)) return; + /** Paint a preview, reporting whether it actually differed from what is up. */ + private applyPreview(preview: TuiPreview | null): boolean { + if (samePreview(this.model.preview, preview)) return false; this.model.setPreview(preview); + return true; } private async fetchPreview(): Promise { @@ -939,12 +1039,17 @@ class TuiApp { const raw = await this.client.fetchTerminalTail(sessionId, PREVIEW_TAIL_BYTES); if (this.previewSessionId !== sessionId) return; const lines = toDisplayLines(dropSeveredEscape(raw)).slice(-PREVIEW_MAX_LINES); - this.applyPreview({ sessionId, lines }); + // An identical tail is what the backoff counts; anything new resets it, so + // a pane that starts printing again is back to one read a second. + if (this.applyPreview({ sessionId, lines })) this.previewQuiet = 0; + else this.previewQuiet++; } catch { // A tail that cannot be read is a pane-level fact, not a connection one: - // the list stays exactly as it is and only this pane says so. + // the list stays exactly as it is and only this pane says so. It counts as + // quiet either way, so a pane that cannot be read is not retried hard. if (this.previewSessionId !== sessionId) return; this.applyPreview({ sessionId, lines: [], error: "could not read that session's terminal" }); + this.previewQuiet++; } finally { this.previewFetching = false; } @@ -1269,6 +1374,10 @@ class TuiApp { private message(tone: 'info' | 'warn' | 'err', text: string): void { this.model.setMessage({ tone, text }); + // An overlay hides the preview pane, so stop re-reading the tail behind it. + // Keystroke-driven overlays get this from `afterInput()`; the ones an async + // action opens (answered, killed, started) would otherwise keep polling. + this.updatePreview(); } /** @@ -1278,6 +1387,7 @@ class TuiApp { */ private notice(text: string): void { this.model.setMessage({ tone: 'info', text }); + this.updatePreview(); const shown = this.model.message; if (this.noticeTimer) clearTimeout(this.noticeTimer); this.noticeTimer = setTimeout(() => { @@ -1305,6 +1415,8 @@ class TuiApp { this.paint(); return; } + // Answering a dialog unblocks the agent, so the pane starts printing again. + if (result.ok) this.previewQuiet = 0; await this.refresh(); if (result.ok) this.notice(`answered ${item.sessionName || item.sessionId.slice(0, 8)}`); // The server re-captures the pane before it types, so this is the normal @@ -1342,6 +1454,9 @@ class TuiApp { } try { await this.client.sendInput(sessionId, line); + // The pane is about to print the reply, so read it at the fast cadence + // however long it had been sitting quiet before this. + this.previewQuiet = 0; await this.refresh(); this.notice('sent'); } catch (error) { @@ -1473,6 +1588,12 @@ class TuiApp { return; } + // tmux is about to own this terminal. The dashboard is not on screen, and + // the pane the preview would keep re-reading is the one the user is now + // looking at directly, so the poll stops for the whole handoff (an attach + // can last hours). + this.stopPreview(); + if (plan.kind === 'switch') { // The client this TUI draws on is about to show another session, so the // dashboard has nothing left to draw and no reason to keep polling. @@ -1491,6 +1612,10 @@ class TuiApp { this.stdout.write(`${plan.hint}\n`); const result = spawnSync(plan.file, plan.args, { stdio: 'inherit' }); this.screen.enter(); + // Whatever happened in the pane happened while nobody was reading it, so the + // first tail after a detach must not be a backed-off one. + this.previewQuiet = 0; + this.updatePreview(); this.paint(true); if (result.error) { this.message('err', `tmux attach failed: ${getErrorMessage(result.error)}`); @@ -1674,12 +1799,14 @@ class TuiApp { private quit(code: number): void { if (this.exiting) return; this.exiting = true; - for (const timer of [this.escTimer, this.resyncTimer, this.searchTimer, this.noticeTimer]) { + for (const timer of [this.escTimer, this.resyncTimer, this.searchTimer, this.noticeTimer, this.previewTimer]) { if (timer) clearTimeout(timer); } - for (const timer of [this.tickTimer, this.pollTimer, this.probeTimer, this.previewTimer]) { + for (const timer of [this.tickTimer, this.pollTimer, this.probeTimer]) { if (timer) clearInterval(timer); } + // Not only the timer: the chain re-arms itself, so the flag has to go too. + this.previewFollowing = false; this.escTimer = null; this.resyncTimer = null; this.searchTimer = null; diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts index e5652151..faf8a05c 100644 --- a/test/tui/tui-app.test.ts +++ b/test/tui/tui-app.test.ts @@ -20,7 +20,9 @@ import { helpKeysFor, isSelfSession, planAttach, + previewIntervalMs, previewNoteFor, + resyncDelayMs, sameFrame, samePreview, shouldAnimate, @@ -259,6 +261,29 @@ describe('the preview policy', () => { }); }); +describe('the refetch and tail-read cadence', () => { + it('debounces a burst but paces a stream, measured from the last start', () => { + // Nothing has been refetched yet: pay the debounce and nothing more. + expect(resyncDelayMs(10_000, 0, 250, 3_000)).toBe(250); + // A refetch that started 2.9s ago: wait out the rest of the floor. + expect(resyncDelayMs(10_000, 9_900, 250, 3_000)).toBe(2_900); + // Past the floor: back to the debounce, never below it. + expect(resyncDelayMs(10_000, 6_000, 250, 3_000)).toBe(250); + expect(resyncDelayMs(10_000, 1_000, 250, 3_000)).toBe(250); + }); + + it('reads a printing pane every second and a quiet one every five', () => { + expect(previewIntervalMs(0, 1_000, 5_000)).toBe(1_000); + expect(previewIntervalMs(1, 1_000, 5_000)).toBe(2_000); + expect(previewIntervalMs(2, 1_000, 5_000)).toBe(4_000); + // The ceiling holds however long the pane stays quiet, and a silly counter + // cannot overflow the doubling into Infinity. + expect(previewIntervalMs(3, 1_000, 5_000)).toBe(5_000); + expect(previewIntervalMs(50, 1_000, 5_000)).toBe(5_000); + expect(previewIntervalMs(-5, 1_000, 5_000)).toBe(1_000); + }); +}); + describe('the repaint test', () => { const key = { revision: 3, cols: 100, rows: 30, tick: 0 }; diff --git a/test/tui/tui-e2e.test.ts b/test/tui/tui-e2e.test.ts index 41caa78a..5ae094e3 100644 --- a/test/tui/tui-e2e.test.ts +++ b/test/tui/tui-e2e.test.ts @@ -60,6 +60,12 @@ const terminals = new Map(); * ago. A row dated by the wrong one of those reads `10m` instead of `1m`. */ let liveState: Array> = []; +/** + * Tail reads the preview pane has asked for. On the real server that route runs + * two synchronous tmux calls and normalizes the whole byte buffer, so how often + * a quiet pane is re-read is a property worth pinning. + */ +let terminalReads = 0; /** Everything the TUI posted, so a test can assert on the exact body. */ const answered: Array<{ id: string; body: Record }> = []; const inputs: Array<{ sessionId: string; body: Record }> = []; @@ -281,6 +287,7 @@ beforeAll(async () => { const previewFor = sessionRoute(url, 'terminal'); if (previewFor) { + terminalReads++; return sendJson(res, { success: true, data: { terminalBuffer: terminals.get(previewFor) ?? '' } }); } @@ -492,6 +499,22 @@ describe('codeman tui (under a pty)', () => { await waitFor(() => rowFor(output, 'w2-beta').startsWith('>'), 'the up arrow to move the cursor'); }); + it('keeps re-reading the tail, but backs off while the pane stays quiet', async () => { + await selectRow('w2-beta'); + // Let the selection's own immediate read land, then measure a window in + // which nothing writes to the pane. + await new Promise((done) => setTimeout(done, 400)); + const before = terminalReads; + await new Promise((done) => setTimeout(done, 6_000)); + const reads = terminalReads - before; + // Still following: a chain that forgot to re-arm would freeze the pane at + // whatever it last showed, which no frame assertion would notice. + expect(reads).toBeGreaterThan(0); + // A fixed one-second poll would be six. The ladder (1s, 2s, 4s, then the + // 5s ceiling) cannot exceed four in this window. + expect(reads).toBeLessThanOrEqual(4); + }, 20_000); + it('picks up a session announced over SSE', async () => { sessions = [ ...sessions, From 61d84ac3190132149237472f7804584771564ff7 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 19 Aug 2026 23:21:42 +0200 Subject: [PATCH 38/57] fix(tui): make an attach fit the terminal, show the way out, and resume history Three things the first beta test surfaced. 1. Attaching from a terminal of a different shape showed the pane clipped to the browser's size, with tmux's dot padding filling the rest. Codeman pins every window it owns to `window-size manual` at whatever the web client reports (tmux-manager.ts), so no attaching client can resize it. The handoff now brackets the attach with `window-size latest` and restores the snapshot on detach. `latest`, rather than a one-off resize to our own size, is also what lets a terminal resized MID-attach follow along: tmux recomputes on every SIGWINCH while the TUI is blocked in spawnSync and cannot. 2. Nothing on screen said how to get back out, because Codeman keeps the status bar off on its panes (the web UI carries that information around the terminal instead). The tester exited the agent looking for the exit, leaving a dead pane. An attach now wears a `status-format[0]` bar reading " D detach, back to the codeman dashboard", with the prefix READ from tmux rather than assumed, and the session's options are put back exactly as they were on detach. One option, not status-left/status-right, so tmux draws no window list beside it; `reverse` so it inherits the terminal's own theme. Restoring an array option unsets the BASE name, since dropping the `[0]` index leaves an empty array, which renders as a blank bar on a session that had one. The help overlay names the chord, and the dashboard confirms the detach. 3. Enter on a RECENT row said resuming was not wired up. It now creates a session carrying that conversation (`resumeSessionId` plus `/interactive`, the path the web UI's Resume Conversation list already uses), in the directory it ran in and under its old name, then attaches to it. The attach mechanics deliberately sit in a method the group dispatch cannot reach, plus a re-entrancy flag: routing resume back through the Enter handler re-dispatched on "this row is RECENT" and spawned one session per pass, 35 in about 40 seconds on the beta before it was killed. test/tui/tui-e2e.test.ts pins one press to one session with a pane that never appears, which is exactly the case that looped. Co-Authored-By: Claude Opus 5 (1M context) --- docs/tui.md | 19 ++- src/tui/tui-app.ts | 300 +++++++++++++++++++++++++++++++++++- src/tui/tui-client.ts | 276 ++++++++++++++++++++++++++++++++- test/tui/tui-app.test.ts | 112 +++++++++++++- test/tui/tui-client.test.ts | 272 ++++++++++++++++++++++++++++++++ test/tui/tui-e2e.test.ts | 49 ++++++ 6 files changed, 1017 insertions(+), 11 deletions(-) diff --git a/docs/tui.md b/docs/tui.md index 97a39321..bd8ef35b 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -168,6 +168,16 @@ terminal to tmux with `stdio: inherit`. Colors, mouse and paste are tmux's, at f fidelity. Detach with **`Ctrl+B D`** (tmux's default prefix, which Codeman does not change for local sessions) and the dashboard comes back and refreshes. +You do not have to remember that: for as long as the attach lasts, the session wears +a status bar reading **`Ctrl+B D detach, back to the codeman dashboard`**, in the +prefix your own `~/.tmux.conf` sets if you remapped it. 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 the window size, which follows your terminal while you are attached and +returns to the browser's afterwards. Detaching leaves the agent running; typing +`exit` or pressing `Ctrl+D` would end it, which is the difference the bar exists to +make obvious. + Three cases: | Where you are | What happens | @@ -176,7 +186,14 @@ Three cases: | 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 first (`Ctrl+B D`), then run `codeman tui` again | -A RECENT row and a direct-PTY session have no pane to attach to, and say so. +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. diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index b2476e28..94123bfc 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -151,6 +151,16 @@ const SEARCH_DEBOUNCE_MS = 250; const SEARCH_LIMIT = 40; /** How long a "sent" style notice stays up before it clears itself. */ const NOTICE_MS = 1_500; + +/** + * How long a resumed session gets to grow a tmux pane before the TUI stops + * waiting and simply selects its row. Generous: `POST /interactive` spawns the + * CLI, and a cold claude start is seconds, not milliseconds. + */ +const RESUME_PANE_TIMEOUT_MS = 8_000; +const RESUME_PANE_POLL_MS = 250; +/** History rows are labelled by their whole opening prompt; the notice shows a slice of it. */ +const RESUME_NOTICE_WIDTH = 48; /** Approval ids remembered for the bell before the set is rebuilt from what is pending. */ const SEEN_APPROVAL_CAP = 500; @@ -171,7 +181,7 @@ const BELL = '\x07'; export type TuiAttachRefusal = 'no-mux-name' | 'nested-foreign-socket'; export type TuiAttachPlan = - | { kind: 'attach'; file: string; args: string[]; hint: string } + | { kind: 'attach'; file: string; args: string[] } | { kind: 'switch'; file: string; args: string[] } | { kind: 'refuse'; reason: TuiAttachRefusal; message: string }; @@ -219,7 +229,6 @@ export function planAttach(muxName: string | undefined, context: TuiAttachContex kind: 'attach', file: 'tmux', args: ['-L', context.socket, 'attach-session', '-t', name], - hint: 'detach with Ctrl+B D to come back', }; } if (inside === context.socket) { @@ -234,6 +243,147 @@ export function planAttach(muxName: string | undefined, context: TuiAttachContex }; } +/** + * tmux's prefix key as a human reads it: `C-b` → `Ctrl+B`, `M-a` → `Alt+A`. + * + * Never hardcoded: the socket reads the user's `~/.tmux.conf`, so a config with + * `set -g prefix C-a` makes every "press Ctrl+B" instruction a lie, and the one + * instruction that matters here is how to get back OUT of an attach. + */ +export function formatPrefixKey(prefix: string | undefined): string { + const raw = (prefix ?? '').trim(); + if (!raw) return 'Ctrl+B'; + const ctrl = /^C-(.+)$/.exec(raw); + if (ctrl) return `Ctrl+${ctrl[1].toUpperCase()}`; + const meta = /^M-(.+)$/.exec(raw); + if (meta) return `Alt+${meta[1].toUpperCase()}`; + return raw; +} + +/** The whole chord: prefix, then `d`. */ +export function detachChord(prefix?: string): string { + return `${formatPrefixKey(prefix)} D`; +} + +/** `#` opens `#[…]`/`#{…}` in a tmux format, so a name carrying one must double it. */ +function escapeTmuxFormat(value: string): string { + return value.replace(/#/g, '##'); +} + +/** + * The status line an attached session wears, as tmux option → value. + * + * Codeman turns the status bar OFF on every pane it owns (tmux-manager.ts): the + * web UI carries that information around the terminal instead. A terminal + * attach has no such frame, so the way out is invisible, and "how do I get out + * of this?" is answered by exiting the agent (measured: a tester left a dead + * pane behind on the first try). The bar exists for the length of the attach + * and is put back exactly as it was on detach. + * + * `reverse` rather than a palette: the TUI paints its own selected row with the + * same SGR 7, so the bar inherits whatever theme the terminal has instead of + * guessing at light or dark. + */ +export function buildAttachBanner(options: { prefix?: string; label?: string }): Record { + const chord = escapeTmuxFormat(detachChord(options.prefix)); + const label = escapeTmuxFormat(truncateLabel((options.label ?? '').trim(), ATTACH_BANNER_LABEL_MAX)); + // ONE option, not `status-left`/`status-right`/`status-style`: `status-format[0]` + // owns the whole line, which is what removes tmux's window list (`0:bash*`) + // from the middle of it. The window-status options that would otherwise hide + // it are WINDOW options, so `set-option -t ` cannot even reach them. + const right = label ? `#[align=right] ${label} ` : ''; + return { + status: 'on', + 'status-format[0]': `#[reverse] #[bold]${chord}#[nobold] detach, back to the codeman dashboard${right}#[default]`, + }; +} + +/** Long enough for a session name, short enough to survive a narrow terminal. */ +const ATTACH_BANNER_LABEL_MAX = 28; + +/** + * What pressing Enter on a RECENT row does, decided from the row alone. + * + * Resuming is Claude Code's `--resume`, so it is claude-only, needs the + * directory the conversation ran in, and needs the CONVERSATION's id + * (`claudeSessionId`) rather than the Codeman row's: a `/clear`-respawned or + * re-attached session carries a different one, and the server's regex only + * accepts the hex-and-dashes shape a real transcript id has. + */ +export type TuiResumePlan = + | { kind: 'resume'; workingDir: string; resumeSessionId: string; sessionName?: string } + | { kind: 'refuse'; message: string }; + +/** Ids the server's `resumeSessionId` accepts (`/^[a-f0-9-]+$/`), checked before the round trip. */ +const RESUME_ID_PATTERN = /^[a-f0-9-]+$/; + +export function planResume(session: TuiSessionRow): TuiResumePlan { + const workingDir = (session.workingDir ?? '').trim(); + if (!workingDir) { + return { kind: 'refuse', message: 'that row has no working directory recorded, so there is nothing to resume in' }; + } + const mode = (session.mode ?? 'claude').trim(); + if (mode !== 'claude') { + return { + kind: 'refuse', + message: `resuming is a Claude Code feature; this row is a ${mode} session, so start a new one with n`, + }; + } + const resumeSessionId = (session.claudeSessionId ?? session.sessionId ?? '').trim(); + if (!RESUME_ID_PATTERN.test(resumeSessionId)) { + return { kind: 'refuse', message: 'that row carries no Claude conversation id, so it cannot be resumed' }; + } + const sessionName = (session.name ?? '').trim(); + return { + kind: 'resume', + workingDir, + resumeSessionId, + // Kept rather than synthesized: a resumed session losing its name is how + // the web UI's COD-143 bug read. + ...(sessionName ? { sessionName } : {}), + }; +} + +/** A handoff to tmux, set up so it can be left and put back. */ +export interface TuiAttachHandoff { + /** The chord that ends it, in the local tmux's own prefix. */ + chord: string; + /** Undo everything the handoff changed. Idempotent enough to call once per attach. */ + restore(): Promise; +} + +/** + * Prepare a tmux window for a human terminal: let it follow the attaching + * client's shape, and give it a status bar naming the way out. Both halves are + * best-effort and both are put back by `restore()`, so a session that was + * `window-size manual` with no status bar (what Codeman creates) is exactly + * that again after the detach. + */ +export async function beginAttachHandoff(client: TuiClient, muxName: string, label: string): Promise { + const prefix = (await client.readPrefixKey(muxName)) ?? undefined; + // Codeman pins its windows to the size the BROWSER dictates (`window-size + // manual` + `resize-window`, tmux-manager.ts), so a terminal of any other + // shape attaches to a window that does not fill it and tmux pads the gap with + // dots. `latest` (not a one-off resize to our size) is also what makes a + // terminal resized MID-attach follow along: tmux recomputes on every SIGWINCH + // and the caller is blocked in `spawnSync`. + const sizing = await client.readWindowSizing(muxName); + await client.followAttachingClient(muxName); + const banner = buildAttachBanner({ ...(prefix ? { prefix } : {}), label }); + const options = await client.readSessionOptions(muxName, Object.keys(banner)); + await client.applySessionOptions(muxName, banner); + return { + chord: detachChord(prefix), + async restore(): Promise { + // Options first, then the size: dropping the status bar gives its row + // back to the pane, and the resize is what re-pins the browser's + // authority over the window. + if (options) await client.restoreSessionOptions(muxName, options); + if (sizing) await client.restoreWindowSizing(muxName, sizing); + }, + }; +} + /** * Is this the session the TUI itself runs in? Codeman exports * `CODEMAN_SESSION_ID` into every managed pane, and killing that one would take @@ -301,6 +451,8 @@ export interface TuiKeymapContext { /** False in degraded mode, where the only verb that works is attach. */ server: boolean; approval?: TuiApprovalKeys; + /** tmux's detach chord as this socket reports it. Defaults to the stock `Ctrl+B D`. */ + detach?: string; } /** @@ -347,8 +499,11 @@ export function footerKeysFor(mode: TuiUiMode, glyphs: TuiGlyphSet, context: Tui export function helpKeysFor(glyphs: TuiGlyphSet, context: TuiKeymapContext): Array<[string, string]> { const keys: Array<[string, string]> = [ [`${glyphs.updown} / j k`, 'select'], - [glyphs.enter, 'attach'], + [glyphs.enter, 'attach — on a RECENT row, resume that conversation'], ['1-9', 'jump and attach'], + // The one key that is not the TUI's: an attach hands the terminal to tmux, + // and leaving it is the question every first attach asks. + [context.detach ?? detachChord(), 'detach from an attached session, back to here'], ]; if (context.server) { keys.push( @@ -693,6 +848,14 @@ class TuiApp { private readonly glyphTier: TuiGlyphTier; private readonly glyphs: TuiGlyphSet; private readonly socket = resolveTmuxSocketName(); + /** + * How to leave an attach, in the local tmux's own prefix. Read per attach + * (a session can override the prefix) and remembered so the help overlay + * names the real chord even before the first attach. + */ + private detachChordLabel = detachChord(); + /** True for the length of one resume. The only thing standing between a resume and a loop. */ + private resuming = false; private stream: TuiEventStream | null = null; private tick = 0; @@ -1069,6 +1232,7 @@ class TuiApp { return { server: this.model.connection !== 'degraded', approval: approval ? (approval.kind === 'idle' ? 'idle' : 'menu') : null, + detach: this.detachChordLabel, }; } @@ -1572,14 +1736,116 @@ class TuiApp { this.paint(); } + /** + * Enter on a RECENT row: resume that conversation and hand the terminal to + * it, so one key means the same thing everywhere in the list ("put me in + * this"). The row itself is history and has no pane, so the resumed session + * is a NEW one carrying the old conversation, exactly like the web UI's + * Resume Conversation list. + */ + private async resumeSelected(row: TuiRow): Promise { + if (this.resuming) return; + if (this.model.connection === 'degraded') { + this.message('warn', 'resuming needs the server; only attach works while it is down'); + return; + } + const plan = planResume(row.session); + if (plan.kind === 'refuse') { + this.message('warn', plan.message); + return; + } + // The flag is the loop breaker, not decoration: a resume ends in an attach, + // and one that could re-enter this method would spawn a session per pass. + this.resuming = true; + let sessionId: string; + try { + this.notice(`resuming ${truncateLabel(rowLabel(row.session), RESUME_NOTICE_WIDTH)}…`); + this.paint(true); + sessionId = await this.client.resumeSession({ + workingDir: plan.workingDir, + resumeSessionId: plan.resumeSessionId, + ...(plan.sessionName ? { sessionName: plan.sessionName } : {}), + }); + } catch (error) { + this.message('err', `could not resume that conversation: ${getErrorMessage(error)}`); + this.paint(true); + return; + } finally { + this.resuming = false; + } + + // Selected whichever way the race goes: a row that is not in the model yet + // is picked up by the next resync instead. + this.pendingSelectId = sessionId; + const fresh = await this.awaitResumedRow(sessionId); + if (!fresh) { + this.message('info', 'resumed; its pane is still starting — press ⏎ on the new row when it appears'); + this.paint(true); + return; + } + this.pendingSelectId = null; + await this.attachToSession(fresh); + } + + /** + * Wait for the resumed session to exist as a LIVE row with a pane, so the + * attach that follows has something to attach to. Bounded, and it gives up by + * returning null rather than by trying again from the top. + */ + private async awaitResumedRow(sessionId: string): Promise { + const deadline = Date.now() + RESUME_PANE_TIMEOUT_MS; + for (;;) { + if (await this.awaitPane(sessionId, 0)) { + await this.refresh(); + if (this.model.select(sessionId)) { + const row = this.model.selectedSession(); + if (row && row.group !== 'recent' && (row.session.muxName ?? '').trim()) return row; + } + } + if (Date.now() >= deadline) return null; + await new Promise((resolve) => setTimeout(resolve, RESUME_PANE_POLL_MS)); + } + } + + /** + * Wait for a just-created session's tmux pane to exist, by the same prefix + * join `applyMuxNames()` uses. Enumeration is the only honest evidence the + * pane is really there; deriving `codeman-` by hand would attach to a + * name that may not exist yet. + */ + private async awaitPane(sessionId: string, timeoutMs = RESUME_PANE_TIMEOUT_MS): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const tmux = await this.client.enumerateTmuxSessions().catch(() => []); + const match = tmux.find((entry) => entry.sessionId === sessionId || sessionId.startsWith(entry.sessionIdPrefix)); + if (match) return match.muxName; + if (Date.now() >= deadline) return null; + await new Promise((resolve) => setTimeout(resolve, RESUME_PANE_POLL_MS)); + } + } + private async attachSelected(): Promise { const row = this.model.selectedSession(); if (!row) return; if (row.group === 'recent') { - this.message('warn', 'that session is not running; resuming a past session is not wired up yet'); + await this.resumeSelected(row); return; } - const plan = planAttach(row.session.muxName, { + await this.attachToSession(row); + } + + /** + * The attach itself, for a row that is known to be live. + * + * ⚠️ Deliberately NOT reachable through `attachSelected()`: resuming ends by + * attaching, and routing that back through the group dispatch turned one + * keystroke into an unbounded resume loop (measured: 35 sessions in 40 + * seconds before it was killed) whenever the fresh row was not selectable + * yet. Nothing here looks at `group` again. + */ + private async attachToSession(row: TuiRow): Promise { + const muxName = (row.session.muxName ?? '').trim(); + const plan = planAttach(muxName, { socket: this.socket, ...(this.env.TMUX ? { tmux: this.env.TMUX } : {}), }); @@ -1595,6 +1861,13 @@ class TuiApp { this.stopPreview(); if (plan.kind === 'switch') { + // Only the sizing, and no restore: this client keeps showing the other + // session after the TUI exits, so snapping the window back to the + // browser's size would put the dots on screen at the moment the user + // arrives, and a bar reading "back to the dashboard" would point at a + // dashboard that is gone. The web UI reclaims the size on its next + // resize, which sets `manual` again on its own. + await this.client.followAttachingClient(muxName); // The client this TUI draws on is about to show another session, so the // dashboard has nothing left to draw and no reason to keep polling. this.screen.leave(); @@ -1608,9 +1881,15 @@ class TuiApp { return; } + // The way OUT, set up before tmux takes the terminal: a status bar that + // stays for the whole attach. The line written below is on a screen tmux + // repaints a moment later, so it is not what the user reads. + const handoff = await beginAttachHandoff(this.client, muxName, rowLabel(row.session)); + this.detachChordLabel = handoff.chord; this.screen.leave(); - this.stdout.write(`${plan.hint}\n`); + this.stdout.write(`${handoff.chord} detaches and brings you back here.\n`); const result = spawnSync(plan.file, plan.args, { stdio: 'inherit' }); + await handoff.restore(); this.screen.enter(); // Whatever happened in the pane happened while nobody was reading it, so the // first tail after a detach must not be a backed-off one. @@ -1621,6 +1900,7 @@ class TuiApp { this.message('err', `tmux attach failed: ${getErrorMessage(result.error)}`); return; } + this.notice(`detached from ${rowLabel(row.session)} · it keeps running`); await this.refresh(); this.paint(true); } @@ -1955,8 +2235,14 @@ export async function runTuiAttach(position: number, options: TuiRunOptions = {} process.stderr.write(`${palette.warn(plan.message)}\n`); return 1; } - if (plan.kind === 'attach') stdout.write(`${palette.muted(plan.hint)}\n`); + // Same handoff the dashboard does: the window follows this terminal and + // wears a bar naming the way out. This path has no dashboard to come back + // to, so the bar's wording is the only thing the detach hint has to carry. + const muxName = (row.session.muxName ?? '').trim(); + const handoff = plan.kind === 'attach' ? await beginAttachHandoff(client, muxName, rowLabel(row.session)) : null; + if (handoff) stdout.write(`${palette.muted(`${handoff.chord} detaches and leaves the session running.`)}\n`); const result = spawnSync(plan.file, plan.args, { stdio: 'inherit' }); + await handoff?.restore(); if (result.error) { process.stderr.write(`${palette.err(getErrorMessage(result.error))}\n`); return 1; diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index 38ded631..81051105 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -100,6 +100,15 @@ export interface TuiServerInfo { planUsage?: TuiPlanUsage | null; } +/** What `resumeSession()` needs: where the conversation ran, and which one it was. */ +export interface TuiResumeOptions { + workingDir: string; + /** The Claude conversation id (`claudeSessionId`, else the row's own id). */ + resumeSessionId: string; + /** Kept from the row when it had one, so a resumed session does not lose its name. */ + sessionName?: string; +} + export interface TuiClientOptions { /** Skip discovery and talk to this origin. */ baseUrl?: string; @@ -116,6 +125,8 @@ export interface TuiClientOptions { exec?: TuiExecFile; /** Read-only source of names/dirs in degraded mode. Defaults to the instance's. */ statePath?: string; + /** tmux socket name. Defaults to the instance's, which is what keeps a beta TUI off prod's sessions. */ + socket?: string; } /** Plan-usage snapshot as the server broadcasts it (telemetry plus its source). */ @@ -385,6 +396,89 @@ export function parseTmuxSessionList(stdout: string): TuiTmuxSession[] { return rows; } +/** + * How tmux is sizing a session's window, as `readWindowSizing()` found it. + * + * Codeman pins every window it owns to the size the BROWSER dictates + * (`window-size manual` plus an explicit `resize-window`, see + * `tmux-manager.ts`), which is what stops a stray attach from shrinking the web + * terminal. The cost is paid by the terminal: a client of any other shape + * attaches to a window that does not fill it, and tmux pads the difference with + * dots. The attach path therefore brackets the handoff with `latest` and puts + * this snapshot back afterwards. + */ +export interface TuiWindowSizing { + cols: number; + rows: number; + /** tmux's `window-size` option: `manual`, `latest`, `largest` or `smallest`. */ + mode: string; +} + +const TMUX_SIZING_FORMAT = ['#{window_width}', '#{window_height}', '#{window-size}'].join(TMUX_FIELD_SEPARATOR); + +/** Parse the sizing format above. Pure, so the format string is unit-testable. */ +export function parseWindowSizing(stdout: string): TuiWindowSizing | null { + const [width = '', height = '', mode = ''] = stdout.trim().split(TMUX_FIELD_SEPARATOR); + const cols = Number.parseInt(width, 10); + const rows = Number.parseInt(height, 10); + if (!Number.isSafeInteger(cols) || !Number.isSafeInteger(rows) || cols <= 0 || rows <= 0) return null; + const value = mode.trim(); + return { cols, rows, ...(value ? { mode: value } : { mode: 'manual' }) }; +} + +/** + * Session-level tmux options, as `readSessionOptions()` found them. `null` is + * "not set on this session", which restores by UNSETTING rather than by writing + * a value back: writing tmux's inherited value would pin an option the session + * never had, and Codeman's own `status off` is exactly such a session-level + * option that must survive the round trip. + */ +export type TuiSessionOptions = Record; + +/** + * Parse `show-options -t ` (session-level options only, one `key value` + * per line) for the keys asked about. Values tmux quotes are unquoted here, so + * what comes back can be handed straight to `set-option` as an argv element. + */ +export function parseSessionOptions(stdout: string, keys: readonly string[]): TuiSessionOptions { + const found = new Map(); + for (const line of stdout.split('\n')) { + const trimmed = line.trimEnd(); + if (!trimmed) continue; + const space = trimmed.indexOf(' '); + const key = space === -1 ? trimmed : trimmed.slice(0, space); + const raw = space === -1 ? '' : trimmed.slice(space + 1); + found.set(key, unquoteTmuxValue(raw)); + } + const options: TuiSessionOptions = {}; + for (const key of keys) { + const base = arrayOptionBase(key); + if (base === null) { + options[key] = found.get(key) ?? null; + continue; + } + // An array option is captured WHOLE: restoring `status-format[0]` alone + // would silently drop a second status line the user configured. + options[key] = found.get(key) ?? null; + for (const [name, value] of found) { + if (arrayOptionBase(name) === base) options[name] = value; + } + } + return options; +} + +/** `status-format[0]` → `status-format`; a plain option name → null. */ +export function arrayOptionBase(key: string): string | null { + const match = /^([^[\]]+)\[\d+\]$/.exec(key); + return match ? (match[1] ?? null) : null; +} + +/** tmux quotes a value only when it has to; `"a \"b\""` comes back as `a "b"`. */ +function unquoteTmuxValue(value: string): string { + if (value.length < 2 || !value.startsWith('"') || !value.endsWith('"')) return value; + return value.slice(1, -1).replace(/\\(["\\])/g, '$1'); +} + /** * List Codeman's tmux sessions without a server, decorating them with whatever * `state.json` remembers. Attach is all this supports: there are no states, no @@ -441,6 +535,7 @@ export class TuiClient { private readonly probeTimeoutMs: number; private readonly exec: TuiExecFile; private readonly statePath: string; + private readonly socket: string; private readonly streams = new Set(); private readonly clientId = `codeman-tui-${process.pid}`; private seq = 0; @@ -457,6 +552,7 @@ export class TuiClient { this.probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; this.exec = options.exec ?? defaultExecFile; this.statePath = options.statePath ?? dataPath('state.json'); + this.socket = options.socket ?? resolveTmuxSocketName(); } /** The origin in use, or null before a successful `connect()`. */ @@ -630,6 +726,27 @@ export class TuiClient { return data; } + /** + * Resume a past Claude conversation as a NEW session, the way the web UI's + * "Resume Conversation" list does: `POST /api/sessions` carrying + * `resumeSessionId` (quick-start has no such field), then `/interactive` to + * give it a pane. Returns the new session's id. + */ + async resumeSession(options: TuiResumeOptions): Promise { + const data = await this.requestData<{ session?: { id?: string } }>('POST', '/api/sessions', { + workingDir: options.workingDir, + resumeSessionId: options.resumeSessionId, + mode: 'claude', + ...(options.sessionName ? { name: options.sessionName } : {}), + }); + const sessionId = data?.session?.id; + if (!sessionId) throw new TuiApiError('resuming returned no session id', 502); + // Creating a session does not start one: without this it has no pane, and + // the row would sit there unattachable. + await this.requestData('POST', `/api/sessions/${encodeURIComponent(sessionId)}/interactive`, {}); + return sessionId; + } + async fetchCases(): Promise { const data = await this.requestData('GET', '/api/cases'); return Array.isArray(data) ? data : []; @@ -669,7 +786,164 @@ export class TuiClient { /** Sessions straight from tmux, for when no server answered. */ enumerateTmuxSessions(): Promise { - return enumerateTmuxSessions({ exec: this.exec, statePath: this.statePath }); + return enumerateTmuxSessions({ exec: this.exec, socket: this.socket, statePath: this.statePath }); + } + + // ── Attach sizing ────────────────────────────────────────────────────────────────── + + /** + * The window's current size and sizing mode, or null when the socket, the + * session or tmux itself is not there. Best-effort by design: an attach that + * cannot be measured still attaches. + */ + async readWindowSizing(muxName: string): Promise { + if (!MUX_NAME_PATTERN.test(muxName)) return null; + try { + const { stdout } = await this.exec('tmux', [ + '-L', + this.socket, + 'display-message', + '-p', + '-t', + muxName, + TMUX_SIZING_FORMAT, + ]); + return parseWindowSizing(stdout); + } catch { + return null; + } + } + + /** + * Let the window follow whichever client is in front of it, for the length of + * an attach. `latest` (rather than a one-off `resize-window` to our own size) + * is what makes a terminal resized MID-attach follow along: tmux recomputes + * on every SIGWINCH, and the TUI process is blocked in `spawnSync` and cannot. + */ + async followAttachingClient(muxName: string): Promise { + if (!MUX_NAME_PATTERN.test(muxName)) return false; + try { + await this.exec('tmux', ['-L', this.socket, 'set-window-option', '-t', muxName, 'window-size', 'latest']); + return true; + } catch { + return false; + } + } + + /** + * tmux's prefix key for a session (`C-b` unless the user's config says + * otherwise), or null when tmux cannot say. Session-level first, then global: + * `show-options -v` resolves the chain on its own, and an empty answer simply + * means "nothing set here". + */ + async readPrefixKey(muxName: string): Promise { + if (!MUX_NAME_PATTERN.test(muxName)) return null; + for (const args of [ + ['-L', this.socket, 'show-options', '-t', muxName, '-v', 'prefix'], + ['-L', this.socket, 'show-options', '-gv', 'prefix'], + ]) { + try { + const { stdout } = await this.exec('tmux', args); + const value = stdout.trim(); + if (value) return value; + } catch { + return null; + } + } + return null; + } + + /** Snapshot the session-level options an attach is about to overwrite. */ + async readSessionOptions(muxName: string, keys: readonly string[]): Promise { + if (!MUX_NAME_PATTERN.test(muxName)) return null; + try { + const { stdout } = await this.exec('tmux', ['-L', this.socket, 'show-options', '-t', muxName]); + return parseSessionOptions(stdout, keys); + } catch { + return null; + } + } + + /** + * Write session options, one `set-option` per key. Sequential rather than a + * single `;`-chained invocation on purpose: a value tmux rejects then costs + * that one option instead of every option after it. + */ + async applySessionOptions(muxName: string, values: Record): Promise { + if (!MUX_NAME_PATTERN.test(muxName)) return; + for (const [key, value] of Object.entries(values)) { + try { + await this.exec('tmux', ['-L', this.socket, 'set-option', '-t', muxName, key, value]); + } catch { + /* an option this tmux does not know is not worth failing an attach over */ + } + } + } + + /** + * Put every snapshotted option back: a value writes, a `null` unsets. + * + * ⚠️ An ARRAY option (`status-format[0]`) cannot be restored element by + * element: `set -u status-format[0]` leaves an EMPTY array rather than + * falling back to the inherited default, which renders as a BLANK status bar + * on a session that legitimately had one (measured). The whole array is + * therefore dropped first, and any indices the snapshot captured are written + * back on top. + */ + async restoreSessionOptions(muxName: string, snapshot: TuiSessionOptions): Promise { + if (!MUX_NAME_PATTERN.test(muxName)) return; + const arrays = new Set(); + for (const key of Object.keys(snapshot)) { + const base = arrayOptionBase(key); + if (base) arrays.add(base); + } + for (const base of arrays) await this.setOption(['-u', '-t', muxName, base]); + for (const [key, value] of Object.entries(snapshot)) { + if (value === null) { + // Indexed nulls are already gone with the array drop above. + if (arrayOptionBase(key) === null) await this.setOption(['-u', '-t', muxName, key]); + continue; + } + await this.setOption(['-t', muxName, key, value]); + } + } + + /** One `set-option`, swallowing failure: the session may be gone by now. */ + private async setOption(args: readonly string[]): Promise { + try { + await this.exec('tmux', ['-L', this.socket, 'set-option', ...args]); + } catch { + /* the user may have exited the agent from inside the attach */ + } + } + + /** + * Put a window back the way `readWindowSizing()` found it, so the web UI + * keeps the authority it had before the attach. The resize goes last: + * `resize-window` sets `window-size manual` on its own, so ordering it after + * the option write would silently undo a restored `latest`. + */ + async restoreWindowSizing(muxName: string, sizing: TuiWindowSizing): Promise { + if (!MUX_NAME_PATTERN.test(muxName)) return; + try { + if (sizing.mode === 'manual') { + await this.exec('tmux', [ + '-L', + this.socket, + 'resize-window', + '-t', + muxName, + '-x', + String(sizing.cols), + '-y', + String(sizing.rows), + ]); + return; + } + await this.exec('tmux', ['-L', this.socket, 'set-window-option', '-t', muxName, 'window-size', sizing.mode]); + } catch { + /* the pane may be gone (the user exited the agent from inside the attach) */ + } } // ── Live updates ─────────────────────────────────────────────────────────── diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts index faf8a05c..3f450f5b 100644 --- a/test/tui/tui-app.test.ts +++ b/test/tui/tui-app.test.ts @@ -13,13 +13,17 @@ import { describe, it, expect } from 'vitest'; import { applyLiveMetrics, applyMuxNames, + buildAttachBanner, buildListLines, confirmAccepts, confirmKillStep, + detachChord, footerKeysFor, + formatPrefixKey, helpKeysFor, isSelfSession, planAttach, + planResume, previewIntervalMs, previewNoteFor, resyncDelayMs, @@ -72,7 +76,6 @@ describe('planAttach', () => { kind: 'attach', file: 'tmux', args: ['-L', 'codeman', 'attach-session', '-t', 'codeman-abcdef01'], - hint: expect.stringContaining('Ctrl+B D'), }); }); @@ -206,7 +209,12 @@ describe('footerKeysFor', () => { it('keeps the help overlay to the same inventory', () => { const help = helpKeysFor(GLYPHS, { server: true }); expect(help.map(([, description]) => description)).toEqual( - expect.arrayContaining(['attach', 'new session', 'kill (typed confirmation)', 'quit']) + expect.arrayContaining([ + 'attach — on a RECENT row, resume that conversation', + 'new session', + 'kill (typed confirmation)', + 'quit', + ]) ); expect(help.flat().join(' ')).toContain('search'); const degraded = helpKeysFor(GLYPHS, { server: false }).flat().join(' '); @@ -448,3 +456,103 @@ describe('buildListLines', () => { expect(line.label.endsWith('…')).toBe(true); }); }); + +describe('the way out of an attach', () => { + it('spells the prefix the way a human reads it, and never assumes C-b', () => { + expect(formatPrefixKey('C-b')).toBe('Ctrl+B'); + // A user who remapped the prefix must not be told to press Ctrl+B. + expect(formatPrefixKey('C-a')).toBe('Ctrl+A'); + expect(formatPrefixKey('M-x')).toBe('Alt+X'); + // Nothing to go on: the tmux default is the honest guess. + expect(formatPrefixKey(undefined)).toBe('Ctrl+B'); + expect(formatPrefixKey(' ')).toBe('Ctrl+B'); + // A shape we do not recognise passes through rather than being mangled. + expect(formatPrefixKey('F1')).toBe('F1'); + }); + + it('names the chord, not just the prefix', () => { + expect(detachChord('C-a')).toBe('Ctrl+A D'); + expect(detachChord()).toBe('Ctrl+B D'); + }); + + it('builds ONE status-format option, so tmux draws no window list beside it', () => { + const banner = buildAttachBanner({ prefix: 'C-b', label: 'w3-codeman' }); + expect(Object.keys(banner).sort()).toEqual(['status', 'status-format[0]']); + expect(banner.status).toBe('on'); + expect(banner['status-format[0]']).toContain('#[bold]Ctrl+B D#[nobold]'); + expect(banner['status-format[0]']).toContain('#[align=right] w3-codeman '); + }); + + it('carries the remapped prefix into the bar', () => { + expect(buildAttachBanner({ prefix: 'C-a' })['status-format[0]']).toContain('Ctrl+A D'); + }); + + it('escapes a label that would otherwise open a tmux format', () => { + const banner = buildAttachBanner({ label: 'fix #42 #[bold]' }); + expect(banner['status-format[0]']).toContain('fix ##42 ##[bold]'); + }); + + it('truncates a long label instead of pushing the instruction off the bar', () => { + const banner = buildAttachBanner({ label: 'w12-codeman: a very long session label indeed' }); + const right = (banner['status-format[0]'].split('#[align=right]')[1] ?? '').replace('#[default]', ''); + // 28 characters of label plus the space either side. + expect(right.length).toBeLessThanOrEqual(30); + expect(right).toContain('…'); + expect(banner['status-format[0]']).toContain('detach, back to the codeman dashboard'); + }); + + it('leaves the right side out entirely when there is no label', () => { + expect(buildAttachBanner({})['status-format[0]']).not.toContain('#[align=right]'); + }); + + it("tells the help overlay how to get back, in the socket's own prefix", () => { + const keys = helpKeysFor(GLYPHS, { server: true, detach: 'Ctrl+A D' }); + const detach = keys.find(([key]) => key === 'Ctrl+A D'); + expect(detach?.[1]).toContain('detach'); + // Degraded mode still attaches, so it still needs the way out. + expect(helpKeysFor(GLYPHS, { server: false }).map(([key]) => key)).toContain('Ctrl+B D'); + }); +}); + +describe('planResume', () => { + const base = { sessionId: 'aaaaaaaa-1111-2222-3333-444444444444', sources: ['transcript'] } as const; + + it('resumes the CONVERSATION id, not the row id', () => { + const plan = planResume({ + ...base, + claudeSessionId: 'bbbbbbbb-5555-6666-7777-888888888888', + workingDir: '/home/dev/codeman', + name: 'w7-codeman', + }); + expect(plan).toEqual({ + kind: 'resume', + workingDir: '/home/dev/codeman', + resumeSessionId: 'bbbbbbbb-5555-6666-7777-888888888888', + sessionName: 'w7-codeman', + }); + }); + + it('falls back to the row id when the row IS the transcript', () => { + const plan = planResume({ ...base, workingDir: '/home/dev/codeman' }); + expect(plan).toMatchObject({ kind: 'resume', resumeSessionId: base.sessionId }); + // No name to keep: the server names it rather than the TUI inventing one. + expect(plan).not.toHaveProperty('sessionName'); + }); + + it('refuses a row with nowhere to run', () => { + expect(planResume({ ...base })).toMatchObject({ kind: 'refuse' }); + }); + + it('refuses a non-claude row, since resume is a Claude Code feature', () => { + const plan = planResume({ ...base, workingDir: '/home/dev/codeman', mode: 'codex' }); + expect(plan.kind).toBe('refuse'); + if (plan.kind === 'refuse') expect(plan.message).toContain('codex'); + }); + + it('refuses an id the server would reject anyway', () => { + // The route validates `/^[a-f0-9-]+$/`; a mux-derived row id is not that. + expect(planResume({ ...base, sessionId: 'codeman-w1', workingDir: '/home/dev' })).toMatchObject({ + kind: 'refuse', + }); + }); +}); diff --git a/test/tui/tui-client.test.ts b/test/tui/tui-client.test.ts index db4cdf7f..4fbc9667 100644 --- a/test/tui/tui-client.test.ts +++ b/test/tui/tui-client.test.ts @@ -20,7 +20,10 @@ import { basicAuthHeader, enumerateTmuxSessions, parseEnvFile, + arrayOptionBase, + parseSessionOptions, parseTmuxSessionList, + parseWindowSizing, readCodemanCredentials, tuiServerCandidates, type TuiExecFile, @@ -491,3 +494,272 @@ describe('degraded-mode tmux enumeration', () => { await expect(enumerateTmuxSessions({ exec })).resolves.toEqual([]); }); }); + +describe('attach window sizing', () => { + /** A client that only ever needs its injected exec: none of this talks to a server. */ + function sizingClient(exec: TuiExecFile): TuiClient { + return new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec }); + } + + it('parses the sizing format, and rejects a window tmux could not measure', () => { + expect(parseWindowSizing('183\t38\tmanual\n')).toEqual({ cols: 183, rows: 38, mode: 'manual' }); + expect(parseWindowSizing('120\t40\tlatest')).toEqual({ cols: 120, rows: 40, mode: 'latest' }); + // No mode reported (an ancient tmux) still yields a usable size. + expect(parseWindowSizing('120\t40\t')).toEqual({ cols: 120, rows: 40, mode: 'manual' }); + expect(parseWindowSizing('')).toBeNull(); + expect(parseWindowSizing("can't find window\n")).toBeNull(); + }); + + it('reads the sizing with an argv array on the client socket', async () => { + const calls: Array = []; + const client = sizingClient(async (_file, args) => { + calls.push(args); + return { stdout: '120\t40\tmanual', stderr: '' }; + }); + await expect(client.readWindowSizing('codeman-1a2b3c4d')).resolves.toEqual({ + cols: 120, + rows: 40, + mode: 'manual', + }); + expect(calls[0].slice(0, 6)).toEqual(['-L', 'codeman-beta', 'display-message', '-p', '-t', 'codeman-1a2b3c4d']); + }); + + it('hands the window to the attaching client with window-size latest', async () => { + const calls: Array = []; + const client = sizingClient(async (_file, args) => { + calls.push(args); + return { stdout: '', stderr: '' }; + }); + await expect(client.followAttachingClient('codeman-1a2b3c4d')).resolves.toBe(true); + expect(calls[0]).toEqual([ + '-L', + 'codeman-beta', + 'set-window-option', + '-t', + 'codeman-1a2b3c4d', + 'window-size', + 'latest', + ]); + }); + + it('restores a manual window with resize-window alone, which re-pins the mode itself', async () => { + const calls: Array = []; + const client = sizingClient(async (_file, args) => { + calls.push(args); + return { stdout: '', stderr: '' }; + }); + await client.restoreWindowSizing('codeman-1a2b3c4d', { cols: 120, rows: 40, mode: 'manual' }); + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual([ + '-L', + 'codeman-beta', + 'resize-window', + '-t', + 'codeman-1a2b3c4d', + '-x', + '120', + '-y', + '40', + ]); + }); + + it('restores a non-manual window by putting its mode back', async () => { + const calls: Array = []; + const client = sizingClient(async (_file, args) => { + calls.push(args); + return { stdout: '', stderr: '' }; + }); + await client.restoreWindowSizing('codeman-1a2b3c4d', { cols: 120, rows: 40, mode: 'latest' }); + expect(calls).toEqual([ + ['-L', 'codeman-beta', 'set-window-option', '-t', 'codeman-1a2b3c4d', 'window-size', 'latest'], + ]); + }); + + it('never targets a name Codeman does not own', async () => { + const calls: Array = []; + const client = sizingClient(async (_file, args) => { + calls.push(args); + return { stdout: '120\t40\tmanual', stderr: '' }; + }); + await expect(client.readWindowSizing('codeman-ssh-prod')).resolves.toBeNull(); + await expect(client.followAttachingClient('other-session')).resolves.toBe(false); + await client.restoreWindowSizing('codeman-dkr-box', { cols: 80, rows: 24, mode: 'manual' }); + expect(calls).toEqual([]); + }); + + it('swallows a dead tmux: an attach must never fail over cosmetics', async () => { + const client = sizingClient(async () => { + throw new Error('no server running on /tmp/tmux-1000/codeman-beta'); + }); + await expect(client.readWindowSizing('codeman-1a2b3c4d')).resolves.toBeNull(); + await expect(client.followAttachingClient('codeman-1a2b3c4d')).resolves.toBe(false); + await expect( + client.restoreWindowSizing('codeman-1a2b3c4d', { cols: 120, rows: 40, mode: 'manual' }) + ).resolves.toBeUndefined(); + }); +}); + +describe('attach status bar options', () => { + function optionsClient(exec: TuiExecFile): TuiClient { + return new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec }); + } + + const SHOW = [ + 'history-limit 100000', + 'mouse off', + 'status off', + 'status-format[0] "#[reverse] left "', + 'status-format[1] "#[align=right] second line "', + 'status-left " plain #[bold]value\\" quoted "', + ].join('\n'); + + it('reads session-level options, unquoting what tmux quoted', () => { + const parsed = parseSessionOptions(SHOW, ['status', 'status-left', 'status-right']); + expect(parsed.status).toBe('off'); + expect(parsed['status-left']).toBe(' plain #[bold]value" quoted '); + // Not set on this session: restoring must UNSET it, not write a value back. + expect(parsed['status-right']).toBeNull(); + }); + + it('captures a whole array when one index is asked for', () => { + const parsed = parseSessionOptions(SHOW, ['status-format[0]']); + expect(parsed['status-format[0]']).toBe('#[reverse] left '); + // The second status line the user configured comes along, or the restore + // would silently delete it. + expect(parsed['status-format[1]']).toBe('#[align=right] second line '); + }); + + it('knows an array element from a plain option', () => { + expect(arrayOptionBase('status-format[0]')).toBe('status-format'); + expect(arrayOptionBase('status')).toBeNull(); + }); + + it('reads the prefix from the session, falling back to the global one', () => { + const calls: Array = []; + const client = optionsClient(async (_file, args) => { + calls.push(args); + // Session level says nothing; the global answer is the real one. + return { stdout: args.includes('-gv') ? 'C-a\n' : '\n', stderr: '' }; + }); + return expect(client.readPrefixKey('codeman-1a2b3c4d')) + .resolves.toBe('C-a') + .then(() => { + expect(calls).toHaveLength(2); + expect(calls[1]).toContain('-gv'); + }); + }); + + it('restores an array by dropping it FIRST, then writing the captured indices', async () => { + const calls: Array = []; + const client = optionsClient(async (_file, args) => { + calls.push(args); + return { stdout: '', stderr: '' }; + }); + await client.restoreSessionOptions('codeman-1a2b3c4d', { + status: 'off', + 'status-format[0]': null, + 'status-format[1]': '#[align=right] second ', + }); + // Unsetting one index leaves an EMPTY array (a blank status bar), so the + // base option goes first and the survivors are written back on top. + expect(calls[0].slice(2)).toEqual(['set-option', '-u', '-t', 'codeman-1a2b3c4d', 'status-format']); + expect(calls.map((args) => args.slice(2))).toContainEqual([ + 'set-option', + '-t', + 'codeman-1a2b3c4d', + 'status', + 'off', + ]); + expect(calls.map((args) => args.slice(2))).toContainEqual([ + 'set-option', + '-t', + 'codeman-1a2b3c4d', + 'status-format[1]', + '#[align=right] second ', + ]); + // The null index is covered by the array drop; it never gets its own unset. + expect(calls.some((args) => args.includes('status-format[0]'))).toBe(false); + }); + + it('unsets a plain option that was not set on the session', async () => { + const calls: Array = []; + const client = optionsClient(async (_file, args) => { + calls.push(args); + return { stdout: '', stderr: '' }; + }); + await client.restoreSessionOptions('codeman-1a2b3c4d', { status: null }); + expect(calls[0].slice(2)).toEqual(['set-option', '-u', '-t', 'codeman-1a2b3c4d', 'status']); + }); + + it('writes the banner one option at a time, and never at a foreign session', async () => { + const calls: Array = []; + const client = optionsClient(async (_file, args) => { + calls.push(args); + return { stdout: '', stderr: '' }; + }); + await client.applySessionOptions('codeman-1a2b3c4d', { status: 'on', 'status-format[0]': 'x' }); + expect(calls).toHaveLength(2); + expect(calls[0].slice(2)).toEqual(['set-option', '-t', 'codeman-1a2b3c4d', 'status', 'on']); + calls.length = 0; + await client.applySessionOptions('codeman-ssh-prod', { status: 'on' }); + await client.restoreSessionOptions('codeman-dkr-box', { status: null }); + await expect(client.readPrefixKey('other-thing')).resolves.toBeNull(); + expect(calls).toEqual([]); + }); + + it('keeps going when tmux rejects one option', async () => { + let seen = 0; + const client = optionsClient(async (_file, args) => { + seen += 1; + if (args.includes('status-format[0]')) throw new Error('unknown option'); + return { stdout: '', stderr: '' }; + }); + await client.applySessionOptions('codeman-1a2b3c4d', { 'status-format[0]': 'x', status: 'on' }); + expect(seen).toBe(2); + }); +}); + +describe('resumeSession', () => { + it('creates with resumeSessionId and then starts the pane', async () => { + recorded.length = 0; + responder = (req, res) => { + if (req.url === '/api/sessions') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ success: true, data: { session: { id: 'new-session-id' } } })); + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ success: true, data: {} })); + }; + const id = await client().resumeSession({ + workingDir: '/home/dev/codeman', + resumeSessionId: 'bbbbbbbb-5555-6666-7777-888888888888', + sessionName: 'w7-codeman', + }); + expect(id).toBe('new-session-id'); + expect(recorded.map((entry) => `${entry.method} ${entry.url}`)).toEqual([ + 'POST /api/sessions', + // Creating a session gives it no pane; without this the resumed row would + // sit in the list unattachable. + 'POST /api/sessions/new-session-id/interactive', + ]); + expect(JSON.parse(recorded[0].body)).toMatchObject({ + workingDir: '/home/dev/codeman', + resumeSessionId: 'bbbbbbbb-5555-6666-7777-888888888888', + mode: 'claude', + name: 'w7-codeman', + }); + }); + + it('does not start a pane when creation answered without an id', async () => { + recorded.length = 0; + responder = (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ success: true, data: {} })); + }; + await expect(client().resumeSession({ workingDir: '/home/dev', resumeSessionId: 'aaaa-bbbb' })).rejects.toThrow( + /no session id/ + ); + expect(recorded).toHaveLength(1); + }); +}); diff --git a/test/tui/tui-e2e.test.ts b/test/tui/tui-e2e.test.ts index 5ae094e3..0191f599 100644 --- a/test/tui/tui-e2e.test.ts +++ b/test/tui/tui-e2e.test.ts @@ -45,6 +45,8 @@ const LIST_WIDTH = computeLayout(COLS, ROWS).list.width; const NOW = Date.now(); const ALPHA = 'aaaa1111-0000-0000-0000-000000000000'; +/** The id the fake server hands back for a resumed conversation. */ +const RESUMED = 'dddd4444-0000-0000-0000-000000000000'; const BETA = 'bbbb2222-0000-0000-0000-000000000000'; /** Mutable so a test can add a session and announce it over SSE. */ @@ -68,6 +70,10 @@ let liveState: Array> = []; let terminalReads = 0; /** Everything the TUI posted, so a test can assert on the exact body. */ const answered: Array<{ id: string; body: Record }> = []; +/** `POST /api/sessions` bodies: resuming a RECENT row is the only thing that sends one. */ +const created: Array> = []; +/** Sessions the TUI asked to start a pane for, in order. */ +const started: string[] = []; const inputs: Array<{ sessionId: string; body: Record }> = []; const PLAN_USAGE = { @@ -282,9 +288,22 @@ beforeAll(async () => { } if (url.startsWith('/api/sessions/unified')) return sendJson(res, { success: true, data: { sessions } }); if (url === '/api/sessions' || url.startsWith('/api/sessions?')) { + if (req.method === 'POST') { + void readBody(req).then((body) => { + created.push(body); + sendJson(res, { success: true, data: { session: { id: RESUMED } } }); + }); + return; + } return sendJson(res, { success: true, data: liveState }); } + const startFor = sessionRoute(url, 'interactive'); + if (startFor) { + started.push(startFor); + return sendJson(res, { success: true, data: {} }); + } + const previewFor = sessionRoute(url, 'terminal'); if (previewFor) { terminalReads++; @@ -662,6 +681,36 @@ describe('codeman tui (under a pty)', () => { await waitFor(() => !frameLines(output).join('\n').includes('no longer on screen'), 'escape to dismiss it'); }); + it('resumes a RECENT row exactly ONCE, however long its pane takes to appear', async () => { + await waitFor(() => frameLines(output)[ROWS - 1].includes('attach'), 'the list to have focus'); + for (let i = 0; i < 8 && !rowFor(output, 'w3-gamma').startsWith('>'); i++) { + term.write('\u001b[B'); + await new Promise((done) => setTimeout(done, 120)); + } + await waitFor(() => rowFor(output, 'w3-gamma').startsWith('>'), 'the history row to be selected'); + + term.write('\r'); + await waitFor(() => created.length > 0, 'the resume POST'); + expect(created[0]).toMatchObject({ + // The conversation, in the directory it ran in, as a claude session. + resumeSessionId: 'cccc3333-0000-0000-0000-000000000000', + workingDir: '/tmp/gamma', + mode: 'claude', + name: 'w3-gamma', + }); + await waitFor(() => started.includes(RESUMED), 'the resumed session to be started'); + + // The pane never appears here (the child runs on an empty tmux socket), which + // is precisely the case that used to re-enter the resume: one session per + // second until something killed it. One press must stay one session. + await new Promise((done) => setTimeout(done, 2_500)); + expect(created).toHaveLength(1); + expect(started).toEqual([RESUMED]); + + term.write('\u001b'); + await waitFor(() => frameLines(output)[ROWS - 1].includes('attach'), 'the list to take focus back'); + }); + it('quits on q and restores the screen it took over', async () => { term.write('q'); await waitFor(() => exitCode !== null, 'the TUI to exit', 10_000); From 1fea9d1d94cbc03e0ccb420b5dfb0c3ff9c6b70c Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 00:28:11 +0200 Subject: [PATCH 39/57] fix(tui): advertise the key that actually detaches, and stop tmux painting it green Two things the attach status bar got wrong, both found in a beta test. The bar read `Ctrl+B D`. tmux key tables are case-sensitive: lowercase `d` is `detach-client`, capital `D` is `choose-client`. Pressing what the bar said opened a client chooser and left the tester attached, with the way out on screen and inert. The key is now READ from `list-keys -T prefix` the same way the prefix already was, rather than hardcoded, so a rebound tmux is followed too and the label cannot drift from the binding again. It never goes through formatPrefixKey(), which uppercases. The bar also rendered as a full-width bright green slab. Only `status-format[0]` was styled, so tmux's stock `status-style` (`bg=green,fg=black`) stayed underneath it and won; `#[reverse]` on top could not undo it. `status-style` is now set explicitly to `bg=default,fg=default` and snapshotted/restored with the rest, so the bar sits on the terminal's own background and reads as a hint line. Tests pin both: that the chord ends in lowercase `d` and never ` D`, that a rebound key prints verbatim, that `status-style` is part of the banner, and that parseDetachKey() picks `d` out of verbatim tmux 3.4 `list-keys` output while ignoring `detach-client -a`/`-P`, which act on other clients. --- src/tui/tui-app.ts | 50 +++++++++++++++++++++++++++++-------- src/tui/tui-client.ts | 36 ++++++++++++++++++++++++++ test/tui/tui-app.test.ts | 41 ++++++++++++++++++++++++------ test/tui/tui-client.test.ts | 42 +++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 19 deletions(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 94123bfc..4054619f 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -260,9 +260,22 @@ export function formatPrefixKey(prefix: string | undefined): string { return raw; } -/** The whole chord: prefix, then `d`. */ -export function detachChord(prefix?: string): string { - return `${formatPrefixKey(prefix)} D`; +/** tmux's stock `detach-client` binding, used when `list-keys` cannot be read. */ +export const DEFAULT_DETACH_KEY = 'd'; + +/** + * The chord that ends an attach: the prefix, then the key bound to + * `detach-client`. + * + * ⚠️ Case is load-bearing here and this shipped wrong once. tmux binds + * LOWERCASE `d` to `detach-client` and CAPITAL `D` to `choose-client`, so a bar + * advertising `Ctrl+B D` opened a client chooser and nothing detached (reported + * from the beta: the way out was on screen and still did not work). The key is + * therefore READ from tmux exactly like the prefix already is, and never passed + * through `formatPrefixKey`, which uppercases. + */ +export function detachChord(prefix?: string, key?: string): string { + return `${formatPrefixKey(prefix)} then ${key || DEFAULT_DETACH_KEY}`; } /** `#` opens `#[…]`/`#{…}` in a tmux format, so a name carrying one must double it. */ @@ -280,12 +293,20 @@ function escapeTmuxFormat(value: string): string { * pane behind on the first try). The bar exists for the length of the attach * and is put back exactly as it was on detach. * - * `reverse` rather than a palette: the TUI paints its own selected row with the - * same SGR 7, so the bar inherits whatever theme the terminal has instead of - * guessing at light or dark. + * ⚠️ `status-style` is set EXPLICITLY and is not optional. Styling only + * `status-format[0]` leaves tmux's stock `status-style` (`bg=green,fg=black`) + * underneath it, which paints a full-width bright green slab across the bottom + * of the pane, `#[reverse]` on top of it included (reported from the beta as + * "a big green line"). `bg=default,fg=default` lets the bar sit on the + * terminal's own background so it reads as a hint line rather than a banner, + * and the chord alone carries emphasis. */ -export function buildAttachBanner(options: { prefix?: string; label?: string }): Record { - const chord = escapeTmuxFormat(detachChord(options.prefix)); +export function buildAttachBanner(options: { + prefix?: string; + label?: string; + detachKey?: string; +}): Record { + const chord = escapeTmuxFormat(detachChord(options.prefix, options.detachKey)); const label = escapeTmuxFormat(truncateLabel((options.label ?? '').trim(), ATTACH_BANNER_LABEL_MAX)); // ONE option, not `status-left`/`status-right`/`status-style`: `status-format[0]` // owns the whole line, which is what removes tmux's window list (`0:bash*`) @@ -294,7 +315,8 @@ export function buildAttachBanner(options: { prefix?: string; label?: string }): const right = label ? `#[align=right] ${label} ` : ''; return { status: 'on', - 'status-format[0]': `#[reverse] #[bold]${chord}#[nobold] detach, back to the codeman dashboard${right}#[default]`, + 'status-style': 'bg=default,fg=default', + 'status-format[0]': `#[align=left] press #[bold]${chord}#[nobold] to detach, back to the codeman dashboard${right}#[default]`, }; } @@ -361,6 +383,8 @@ export interface TuiAttachHandoff { */ export async function beginAttachHandoff(client: TuiClient, muxName: string, label: string): Promise { const prefix = (await client.readPrefixKey(muxName)) ?? undefined; + // Read, not assumed: see detachChord() for the `d` vs `D` mix-up this closes. + const detachKey = (await client.readDetachKey()) ?? undefined; // Codeman pins its windows to the size the BROWSER dictates (`window-size // manual` + `resize-window`, tmux-manager.ts), so a terminal of any other // shape attaches to a window that does not fill it and tmux pads the gap with @@ -369,11 +393,15 @@ export async function beginAttachHandoff(client: TuiClient, muxName: string, lab // and the caller is blocked in `spawnSync`. const sizing = await client.readWindowSizing(muxName); await client.followAttachingClient(muxName); - const banner = buildAttachBanner({ ...(prefix ? { prefix } : {}), label }); + const banner = buildAttachBanner({ + ...(prefix ? { prefix } : {}), + ...(detachKey ? { detachKey } : {}), + label, + }); const options = await client.readSessionOptions(muxName, Object.keys(banner)); await client.applySessionOptions(muxName, banner); return { - chord: detachChord(prefix), + chord: detachChord(prefix, detachKey), async restore(): Promise { // Options first, then the size: dropping the status bar gives its row // back to the pane, and the resize is what re-pins the browser's diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index 81051105..fe3dde26 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -467,6 +467,28 @@ export function parseSessionOptions(stdout: string, keys: readonly string[]): Tu return options; } +/** + * The key bound to a bare `detach-client` in `list-keys -T prefix` output, or + * null when nothing there detaches. + * + * ⚠️ Read rather than assumed because the two candidates differ only by case: + * tmux ships `d` as `detach-client` and `D` as `choose-client`, and advertising + * the wrong one leaves a tester attached with the way out on screen. Bindings + * that pass ARGUMENTS to `detach-client` (`-a`, `-P`) are skipped: those act on + * other clients or kill the pane's process, which is not what the bar promises. + * A single-character binding wins over a named key, since that is what a status + * line can print literally. + */ +export function parseDetachKey(stdout: string): string | null { + const candidates: string[] = []; + for (const line of stdout.split('\n')) { + const match = /^bind-key\s+(?:-\S+\s+)*?-T\s+prefix\s+(\S+)\s+detach-client\s*$/.exec(line.trim()); + const key = match?.[1]; + if (key) candidates.push(unquoteTmuxValue(key)); + } + return candidates.find((key) => key.length === 1) ?? candidates[0] ?? null; +} + /** `status-format[0]` → `status-format`; a plain option name → null. */ export function arrayOptionBase(key: string): string | null { const match = /^([^[\]]+)\[\d+\]$/.exec(key); @@ -853,6 +875,20 @@ export class TuiClient { return null; } + /** + * The key that detaches, straight from tmux's own key table. Key tables are + * server-global, so unlike the prefix this takes no session. A failure is + * null and the caller falls back to tmux's stock `d`. + */ + async readDetachKey(): Promise { + try { + const { stdout } = await this.exec('tmux', ['-L', this.socket, 'list-keys', '-T', 'prefix']); + return parseDetachKey(stdout); + } catch { + return null; + } + } + /** Snapshot the session-level options an attach is about to overwrite. */ async readSessionOptions(muxName: string, keys: readonly string[]): Promise { if (!MUX_NAME_PATTERN.test(muxName)) return null; diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts index 3f450f5b..28b1d751 100644 --- a/test/tui/tui-app.test.ts +++ b/test/tui/tui-app.test.ts @@ -471,20 +471,45 @@ describe('the way out of an attach', () => { }); it('names the chord, not just the prefix', () => { - expect(detachChord('C-a')).toBe('Ctrl+A D'); - expect(detachChord()).toBe('Ctrl+B D'); + expect(detachChord('C-a', 'd')).toBe('Ctrl+A then d'); + expect(detachChord()).toBe('Ctrl+B then d'); + }); + + it('names the LOWERCASE detach key, because capital D is choose-client', () => { + // Regression: the bar shipped reading `Ctrl+B D`, and a beta tester pressing + // exactly that landed in tmux's client chooser while staying attached. tmux + // key tables are case-sensitive and `D` is bound to a different command. + expect(detachChord()).not.toContain(' D'); + expect(detachChord()).toMatch(/ then d$/); + expect(buildAttachBanner({})['status-format[0]']).not.toContain('#[bold]Ctrl+B D#[nobold]'); + }); + + it('prints a rebound detach key verbatim, never uppercased like the prefix', () => { + // formatPrefixKey() uppercases (`C-a` → `Ctrl+A`); running the detach key + // through it would reintroduce the same class of bug on a rebound tmux. + expect(detachChord('C-a', 'q')).toBe('Ctrl+A then q'); + expect(buildAttachBanner({ prefix: 'C-a', detachKey: 'q' })['status-format[0]']).toContain('Ctrl+A then q'); }); it('builds ONE status-format option, so tmux draws no window list beside it', () => { const banner = buildAttachBanner({ prefix: 'C-b', label: 'w3-codeman' }); - expect(Object.keys(banner).sort()).toEqual(['status', 'status-format[0]']); + expect(Object.keys(banner).sort()).toEqual(['status', 'status-format[0]', 'status-style']); expect(banner.status).toBe('on'); - expect(banner['status-format[0]']).toContain('#[bold]Ctrl+B D#[nobold]'); + expect(banner['status-format[0]']).toContain('#[bold]Ctrl+B then d#[nobold]'); expect(banner['status-format[0]']).toContain('#[align=right] w3-codeman '); }); + it('sets status-style, or tmux paints its stock green bar under the bar', () => { + // Regression: styling only status-format[0] left tmux's default + // `bg=green,fg=black` status-style underneath, which a beta tester saw as a + // full-width bright green slab across the bottom of the pane. + const banner = buildAttachBanner({ prefix: 'C-b' }); + expect(banner['status-style']).toBe('bg=default,fg=default'); + expect(banner['status-format[0]']).not.toContain('#[reverse]'); + }); + it('carries the remapped prefix into the bar', () => { - expect(buildAttachBanner({ prefix: 'C-a' })['status-format[0]']).toContain('Ctrl+A D'); + expect(buildAttachBanner({ prefix: 'C-a' })['status-format[0]']).toContain('Ctrl+A then d'); }); it('escapes a label that would otherwise open a tmux format', () => { @@ -506,11 +531,11 @@ describe('the way out of an attach', () => { }); it("tells the help overlay how to get back, in the socket's own prefix", () => { - const keys = helpKeysFor(GLYPHS, { server: true, detach: 'Ctrl+A D' }); - const detach = keys.find(([key]) => key === 'Ctrl+A D'); + const keys = helpKeysFor(GLYPHS, { server: true, detach: 'Ctrl+A then d' }); + const detach = keys.find(([key]) => key === 'Ctrl+A then d'); expect(detach?.[1]).toContain('detach'); // Degraded mode still attaches, so it still needs the way out. - expect(helpKeysFor(GLYPHS, { server: false }).map(([key]) => key)).toContain('Ctrl+B D'); + expect(helpKeysFor(GLYPHS, { server: false }).map(([key]) => key)).toContain('Ctrl+B then d'); }); }); diff --git a/test/tui/tui-client.test.ts b/test/tui/tui-client.test.ts index 4fbc9667..b5d695e5 100644 --- a/test/tui/tui-client.test.ts +++ b/test/tui/tui-client.test.ts @@ -22,6 +22,7 @@ import { parseEnvFile, arrayOptionBase, parseSessionOptions, + parseDetachKey, parseTmuxSessionList, parseWindowSizing, readCodemanCredentials, @@ -599,6 +600,47 @@ describe('attach window sizing', () => { }); }); +describe('parseDetachKey', () => { + // Verbatim from `tmux -L codeman list-keys -T prefix` on tmux 3.4, trimmed to + // the two lines that matter. They differ only by case, which is the whole + // point: `D` is choose-client and advertising it leaves the tester attached. + const REAL_TMUX_34 = [ + 'bind-key -T prefix C-b send-prefix', + 'bind-key -T prefix d detach-client', + 'bind-key -T prefix D choose-client -Z', + 'bind-key -T prefix x confirm-before -p "kill-pane #P? (y/n)" kill-pane', + ].join('\n'); + + it('picks the lowercase detach-client binding out of real tmux output', () => { + expect(parseDetachKey(REAL_TMUX_34)).toBe('d'); + }); + + it('follows a rebound detach key rather than assuming d', () => { + expect(parseDetachKey('bind-key -T prefix Q detach-client')).toBe('Q'); + }); + + it('tolerates the -r repeat flag ahead of -T', () => { + expect(parseDetachKey('bind-key -r -T prefix d detach-client')).toBe('d'); + }); + + it('ignores detach-client bindings that carry arguments', () => { + // `-a` detaches OTHER clients and `-P` kills the pane's process; neither is + // what the bar promises, so a socket with only those reports nothing. + expect(parseDetachKey('bind-key -T prefix X detach-client -a')).toBeNull(); + expect(parseDetachKey('bind-key -T prefix Y detach-client -P')).toBeNull(); + }); + + it('prefers a single-character binding over a named key', () => { + const both = 'bind-key -T prefix F1 detach-client\nbind-key -T prefix d detach-client'; + expect(parseDetachKey(both)).toBe('d'); + }); + + it('is null when nothing detaches, so the caller can fall back', () => { + expect(parseDetachKey('')).toBeNull(); + expect(parseDetachKey('bind-key -T prefix D choose-client -Z')).toBeNull(); + }); +}); + describe('attach status bar options', () => { function optionsClient(exec: TuiExecFile): TuiClient { return new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec }); From 1b6cab11737c1b33cb054acc46b7767d2e02b0a6 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 00:32:13 +0200 Subject: [PATCH 40/57] fix(tui): sweep an attach status bar a killed terminal left behind restore() runs after spawnSync returns, which covers detaching and the agent exiting inside the pane, but not the terminal dying while attached. Closing the window or dropping the SSH kills the TUI where it stands, and the bar it installed stays pinned on the session: the next attach wears a stale bar naming a different session, and the pane is a row shorter for good. Seen on the beta, where the tester closed the window instead of detaching. One sweep at startup, fire-and-forget so it can neither delay the first frame nor fail a start. Only a bar carrying our own marker is touched, and the marker is now the single source of the bar's own wording so the two cannot drift; a user's hand-written status bar on the same session is left exactly as it is. The session goes back to `status off`, which is how Codeman creates every pane it owns and the only state this bar is ever applied over. --- src/tui/tui-app.ts | 7 ++++- src/tui/tui-client.ts | 61 +++++++++++++++++++++++++++++++++++++ test/tui/tui-client.test.ts | 56 ++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 4054619f..04d222a6 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -55,6 +55,7 @@ import { approvalAnswerForKey, newApprovalIds } from './tui-approvals.js'; import { composerScroll, composerStep, composerText, createComposer, type TuiComposerState } from './tui-composer.js'; import { formatAwayDigest } from './tui-digest.js'; import { + ATTACH_BANNER_MARKER, TuiClient, type TuiApprovalAnswer, type TuiEventStream, @@ -316,7 +317,7 @@ export function buildAttachBanner(options: { return { status: 'on', 'status-style': 'bg=default,fg=default', - 'status-format[0]': `#[align=left] press #[bold]${chord}#[nobold] to detach, back to the codeman dashboard${right}#[default]`, + 'status-format[0]': `#[align=left] press #[bold]${chord}#[nobold] to detach, ${ATTACH_BANNER_MARKER}${right}#[default]`, }; } @@ -981,6 +982,10 @@ class TuiApp { await this.refresh(); if (server) this.subscribe(); + // Fire and forget: a bar stranded by a previous run is cosmetic, so it must + // never delay the first frame or fail a start. + void this.client.clearLeakedAttachBanners().catch(() => undefined); + return new Promise((resolve) => { this.resolveExit = resolve; }); diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index fe3dde26..0216cd2b 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -467,6 +467,14 @@ export function parseSessionOptions(stdout: string, keys: readonly string[]): Tu return options; } +/** + * The phrase the attach status bar carries. It doubles as the marker that tells + * OUR bar apart from a user's own when sweeping one that leaked, so + * `buildAttachBanner()` composes its text from this constant rather than + * repeating the words. + */ +export const ATTACH_BANNER_MARKER = 'back to the codeman dashboard'; + /** * The key bound to a bare `detach-client` in `list-keys -T prefix` output, or * null when nothing there detaches. @@ -945,6 +953,59 @@ export class TuiClient { } /** One `set-option`, swallowing failure: the session may be gone by now. */ + /** + * Drop attach status bars that a previous TUI never got to take down. + * + * `restore()` runs after `spawnSync` returns, which covers a detach and an + * agent exiting inside the pane, but not the terminal DYING while attached: + * a closed window or a dropped SSH kills the TUI where it stands, and the bar + * it installed stays pinned on the session (observed on the beta, where the + * tester closed the window instead of detaching and the next attach still + * wore a stale bar naming the wrong session). One sweep at startup makes that + * self-healing. + * + * Only a bar carrying our own marker is touched, and it is put back the way + * Codeman creates its panes (`status off`), which is the only state this bar + * is ever applied over. + */ + async clearLeakedAttachBanners(): Promise { + let names: string[]; + try { + const { stdout } = await this.exec('tmux', ['-L', this.socket, 'list-sessions', '-F', '#{session_name}']); + names = stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => MUX_NAME_PATTERN.test(line)); + } catch { + return 0; + } + let cleared = 0; + for (const name of names) { + let current = ''; + try { + ({ stdout: current } = await this.exec('tmux', [ + '-L', + this.socket, + 'show-options', + '-t', + name, + '-v', + 'status-format[0]', + ])); + } catch { + continue; + } + if (!current.includes(ATTACH_BANNER_MARKER)) continue; + // The array WHOLE, for the same reason restoreSessionOptions() does it: + // unsetting one index leaves an empty array, which renders as a blank bar. + await this.setOption(['-u', '-t', name, 'status-format']); + await this.setOption(['-u', '-t', name, 'status-style']); + await this.setOption(['-t', name, 'status', 'off']); + cleared += 1; + } + return cleared; + } + private async setOption(args: readonly string[]): Promise { try { await this.exec('tmux', ['-L', this.socket, 'set-option', ...args]); diff --git a/test/tui/tui-client.test.ts b/test/tui/tui-client.test.ts index b5d695e5..01e274cc 100644 --- a/test/tui/tui-client.test.ts +++ b/test/tui/tui-client.test.ts @@ -23,6 +23,7 @@ import { arrayOptionBase, parseSessionOptions, parseDetachKey, + ATTACH_BANNER_MARKER, parseTmuxSessionList, parseWindowSizing, readCodemanCredentials, @@ -600,6 +601,61 @@ describe('attach window sizing', () => { }); }); +describe('TuiClient.clearLeakedAttachBanners', () => { + const OURS = `#[align=left] press #[bold]Ctrl+B then d#[nobold] to detach, ${ATTACH_BANNER_MARKER} #[default]`; + + function sweeper(formats: Record): { client: TuiClient; calls: string[][] } { + const calls: string[][] = []; + const exec: TuiExecFile = async (_file, args) => { + calls.push([...args]); + if (args.includes('list-sessions')) return { stdout: Object.keys(formats).join('\n'), stderr: '' }; + if (args.includes('show-options')) { + const name = args[args.indexOf('-t') + 1] ?? ''; + return { stdout: formats[name] ?? '', stderr: '' }; + } + return { stdout: '', stderr: '' }; + }; + return { client: new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec }), calls }; + } + + it('takes down a bar a killed TUI left behind, and puts status back off', async () => { + // The case that produced this: the tester closed the terminal window while + // attached, so restore() never ran and the bar stayed pinned. + const { client, calls } = sweeper({ 'codeman-aaaa1111': OURS }); + expect(await client.clearLeakedAttachBanners()).toBe(1); + const sets = calls.filter((args) => args.includes('set-option')); + // The array whole, never one index: unsetting `status-format[0]` alone + // leaves an EMPTY array, which renders as a blank bar. + expect(sets.some((args) => args.includes('-u') && args.includes('status-format'))).toBe(true); + expect(sets.some((args) => args.includes('-u') && args.includes('status-style'))).toBe(true); + expect(sets.some((args) => args.join(' ').endsWith('status off'))).toBe(true); + expect(sets.every((args) => !args.includes('status-format[0]'))).toBe(true); + }); + + it('never touches a status bar that is not ours', async () => { + const { client, calls } = sweeper({ 'codeman-bbbb2222': '#[align=right] my own bar ' }); + expect(await client.clearLeakedAttachBanners()).toBe(0); + expect(calls.filter((args) => args.includes('set-option'))).toEqual([]); + }); + + it('sweeps only the sessions that leaked, leaving the rest alone', async () => { + const { client } = sweeper({ + 'codeman-aaaa1111': OURS, + 'codeman-bbbb2222': '', + 'codeman-cccc3333': OURS, + }); + expect(await client.clearLeakedAttachBanners()).toBe(2); + }); + + it('reports nothing rather than throwing when there is no tmux server', async () => { + const exec: TuiExecFile = async () => { + throw new Error('no server running on /tmp/tmux-1000/codeman-beta'); + }; + const client = new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec }); + await expect(client.clearLeakedAttachBanners()).resolves.toBe(0); + }); +}); + describe('parseDetachKey', () => { // Verbatim from `tmux -L codeman list-keys -T prefix` on tmux 3.4, trimmed to // the two lines that matter. They differ only by case, which is the whole From 6cce77eebb47d4e1163d38c804da074d5e3ffd1b Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 00:45:27 +0200 Subject: [PATCH 41/57] fix(tui): refuse to attach to a dead pane, and stop naming sessions after CLI noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more from the same beta round, both reported as "basic things are broken". Attaching to a DEAD pane trapped the user. Codeman sets `remain-on-exit on`, so a session whose agent has exited does not disappear: the row looks ordinary, the server still reports it idle, and Enter handed the terminal to a pane that reads no input. With the detach chord also wrong at the time, that was a hard freeze with no way out. Enter now probes `#{pane_dead}` first and refuses with an Error card naming the session and what to do instead. The probe fails OPEN, so it can never block an attach to a live pane. ⚠️ It also has to paint: the keypress that reaches attachToSession() has already painted by the time an awaited probe resolves, so message() alone left the refusal invisible and Enter looked inert, which is the bug it was added to fix. A session started from the TUI came out unnamed, because startSession() sent no sessionName and rowLabel() then fell back to the transcript's first line. A brand-new session has no prompt to be named after, so the list showed a perfectly healthy session called "Login interrupted" — the CLI's startup output, reading like a failure report. Sessions the TUI starts are now named `w-` like the web UI's, and rowLabel() prefers the case directory over a scraped prompt for any row with a mux name, since a LIVE pane is identified by where it runs while a history row genuinely is its prompt. --- src/tui/tui-app.ts | 49 ++++++++++++++++++++++++++++++++++++- src/tui/tui-client.ts | 30 +++++++++++++++++++++++ src/tui/tui-render.ts | 10 +++++++- test/tui/tui-app.test.ts | 20 +++++++++++++++ test/tui/tui-render.test.ts | 33 +++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 2 deletions(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 04d222a6..33f27edd 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -324,6 +324,24 @@ export function buildAttachBanner(options: { /** Long enough for a session name, short enough to survive a narrow terminal. */ const ATTACH_BANNER_LABEL_MAX = 28; +/** + * The name a newly started session gets: `w-`, the same convention the + * web UI uses, with `n` one past the highest already in use. + * + * A session created with no name at all is not merely unlabelled: rowLabel() + * falls back to the transcript's first line, and a session that has not been + * prompted yet gets named after whatever its CLI printed while starting up. + */ +export function nextSessionName(caseName: string, existing: readonly string[]): string { + let highest = 0; + for (const name of existing) { + const match = /^w(\d+)-/.exec((name ?? '').trim()); + const index = match ? Number.parseInt(match[1] ?? '', 10) : Number.NaN; + if (Number.isSafeInteger(index) && index > highest) highest = index; + } + return `w${highest + 1}-${caseName}`; +} + /** * What pressing Enter on a RECENT row does, decided from the row alone. * @@ -1887,6 +1905,23 @@ class TuiApp { return; } + // A dead pane still LISTS, because Codeman keeps `remain-on-exit on`: the + // row looks ordinary and the server still calls it idle. Attaching to one + // hands the terminal to a pane that reads nothing, which a beta tester + // experienced as the TUI freezing with no way out. + if (await this.client.isPaneDead(muxName)) { + this.message( + 'err', + `${rowLabel(row.session)} has exited — its pane is dead, so there is nothing there to type into. ` + + 'Close the row with x, or start a fresh session with n.' + ); + // ⚠️ message() only sets state. The keypress that got us here painted + // BEFORE this await resolved, so without a paint of our own the refusal + // is invisible and Enter looks like it did nothing at all. + this.paint(); + return; + } + // tmux is about to own this terminal. The dashboard is not on screen, and // the pane the preview would keep re-reading is the one the user is now // looking at directly, so the poll stops for the whole handoff (an attach @@ -2078,7 +2113,19 @@ class TuiApp { private async startSession(caseName: string, mode: TuiRunMode): Promise { try { - const result = await this.client.quickStart({ caseName, mode }); + const result = await this.client.quickStart({ + caseName, + mode, + // Named here rather than left to the server: an unnamed session falls + // back to whatever rowLabel() can find, and before the user has typed + // anything that was the CLI's first line of output. `w-` is + // the web UI's own convention, so a session started from either surface + // reads the same in both. + sessionName: nextSessionName( + caseName, + this.model.sessions().map((session) => session.name ?? '') + ), + }); // The row appears with the next resync; remember which one to select. this.pendingSelectId = result.sessionId; this.message('info', `started ${mode} in ${caseName}`); diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index 0216cd2b..71a75980 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -897,6 +897,36 @@ export class TuiClient { } } + /** + * Is this session's active pane DEAD — the process it ran has exited and tmux + * is holding the corpse on screen? + * + * Codeman sets `remain-on-exit on` for every pane it owns, so a session whose + * agent exited does not disappear: it stays listed, the server still reports + * it `idle`, and attaching hands the terminal to a pane that reads no input. + * A beta tester hit exactly that and could not type or get out. + * + * Fails OPEN (`false`): a probe that cannot run must never block an attach to + * a pane that is perfectly alive. + */ + async isPaneDead(muxName: string): Promise { + if (!MUX_NAME_PATTERN.test(muxName)) return false; + try { + const { stdout } = await this.exec('tmux', [ + '-L', + this.socket, + 'display-message', + '-p', + '-t', + muxName, + '#{pane_dead}', + ]); + return stdout.trim() === '1'; + } catch { + return false; + } + } + /** Snapshot the session-level options an attach is about to overwrite. */ async readSessionOptions(muxName: string, keys: readonly string[]): Promise { if (!MUX_NAME_PATTERN.test(muxName)) return null; diff --git a/src/tui/tui-render.ts b/src/tui/tui-render.ts index 3e0757a4..e393f218 100644 --- a/src/tui/tui-render.ts +++ b/src/tui/tui-render.ts @@ -254,9 +254,17 @@ export function formatPlanUsage(usage: StatusTelemetry | null | undefined, separ */ 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; - const base = (session.workingDir ?? '').split('/').filter(Boolean).pop(); return base || session.sessionId.slice(0, 8); } diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts index 28b1d751..9283a078 100644 --- a/test/tui/tui-app.test.ts +++ b/test/tui/tui-app.test.ts @@ -18,6 +18,7 @@ import { confirmAccepts, confirmKillStep, detachChord, + nextSessionName, footerKeysFor, formatPrefixKey, helpKeysFor, @@ -457,6 +458,25 @@ describe('buildListLines', () => { }); }); +describe('naming a session the TUI starts', () => { + it("follows the web UI's w- convention", () => { + expect(nextSessionName('mirofish', [])).toBe('w1-mirofish'); + expect(nextSessionName('mirofish', ['w1-codeman', 'w2-codeman'])).toBe('w3-mirofish'); + }); + + it('counts past names that are not w- at all', () => { + // A session named by hand, or by another surface, must not reset the run. + expect(nextSessionName('demo', ['tui-demo-agent', 'w4-codeman', ''])).toBe('w5-demo'); + }); + + it('never returns an empty name, which is what caused the bad label', () => { + // An unnamed session falls through rowLabel() to the transcript's first + // line, which put "Login interrupted" in the list as a session name. + expect(nextSessionName('c', [])).not.toBe(''); + expect(nextSessionName('c', ['w9007199254740991-x'])).toMatch(/^w\d+-c$/); + }); +}); + describe('the way out of an attach', () => { it('spells the prefix the way a human reads it, and never assumes C-b', () => { expect(formatPrefixKey('C-b')).toBe('Ctrl+B'); diff --git a/test/tui/tui-render.test.ts b/test/tui/tui-render.test.ts index ce34c9f8..36e07cef 100644 --- a/test/tui/tui-render.test.ts +++ b/test/tui/tui-render.test.ts @@ -410,6 +410,39 @@ describe('formatting helpers', () => { ); expect(rowLabel({ sessionId: 'abcdef1234', sources: [] })).toBe('abcdef12'); }); + + it('names a LIVE pane after its case, never after scraped output', () => { + // Regression: a session started from the TUI before the user typed anything + // had no name and no prompt, so the fallback took the CLI's first line of + // output. A healthy new session showed up in the list called + // "Login interrupted", which reads like a failure. + expect( + rowLabel({ + sessionId: 'abcdef12', + firstPrompt: 'Login interrupted', + workingDir: '/home/u/codeman-cases/mirofish', + muxName: 'codeman-abcdef12', + sources: [], + }) + ).toBe('mirofish'); + }); + + it('still names a HISTORY row by its prompt, where the prompt IS the identity', () => { + expect( + rowLabel({ + sessionId: 'abcdef12', + firstPrompt: 'do the thing', + workingDir: '/home/u/codeman-cases/mirofish', + sources: [], + }) + ).toBe('do the thing'); + }); + + it('prefers a real name over both, on a live row and a history row alike', () => { + const base = { sessionId: 'abcdef12', firstPrompt: 'Login interrupted', workingDir: '/a/b/case', sources: [] }; + expect(rowLabel({ ...base, name: 'w2-case' })).toBe('w2-case'); + expect(rowLabel({ ...base, name: 'w2-case', muxName: 'codeman-abcdef12' })).toBe('w2-case'); + }); }); describe('the approval card', () => { From 027e40f9622e89c0af441dee87ef7dd7d0b449c4 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 00:59:15 +0200 Subject: [PATCH 42/57] fix(tui): make the detach chord work when Ctrl is never released MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported three times as "Ctrl+B and d is still not working", on a build whose bar already named the right key. Measured against a live pane: of the three ways a person types this, only one worked. Ctrl+B, release Ctrl, then d detaches Ctrl+B then Ctrl+D (held) nothing happens Ctrl+B then Shift+D nothing happens Holding Ctrl through both keys sends 0x02 then 0x04, and tmux ships `C-d` unbound in the prefix table, so the keystroke is swallowed in silence and the attach looks frozen. That is not a user error worth documenting around: holding the modifier is how most people type a two-key chord. The attach now claims the held-Ctrl form of whatever key detaches (`d` → `C-d`) for its own duration and gives it back on restore, and the bar advertises it only once the claim succeeded, so it can never name a key that does nothing. ⚠️ The key is claimed ONLY when tmux reports it unbound, and released only while it still means `detach-client`, so a binding of the user's own is never shadowed or removed. The alias is deliberately excluded from the leaked-state sweep: key tables are server-global, so the sweep cannot tell a leak from a second TUI's live claim, and a stray `C-d`→detach is harmless either way. Ruled out along the way, with evidence rather than assumption: the encoding. tmux negotiates no extended-key mode upstream on attach (no kitty CSI-u, no modifyOtherKeys, no DECSET 2017), so Ctrl+B does arrive as a plain 0x02 even from a Claude pane, which has its own keyboard protocol. --- src/tui/tui-app.ts | 32 +++++++++++++++++++- src/tui/tui-client.ts | 58 +++++++++++++++++++++++++++++++++++++ test/tui/tui-app.test.ts | 29 +++++++++++++++++++ test/tui/tui-client.test.ts | 22 ++++++++++++++ 4 files changed, 140 insertions(+), 1 deletion(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 33f27edd..68112438 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -264,6 +264,24 @@ export function formatPrefixKey(prefix: string | undefined): string { /** tmux's stock `detach-client` binding, used when `list-keys` cannot be read. */ export const DEFAULT_DETACH_KEY = 'd'; +/** + * The key a user produces when they DON'T let go of Ctrl: `d` becomes `C-d`. + * + * ⚠️ This is the single most reported way the way-out fails. "Ctrl+B then d" + * gets typed as one held chord, the terminal sends 0x02 then 0x04, and tmux + * leaves `C-d` unbound in the prefix table, so absolutely nothing happens and + * the user concludes the app is frozen (measured on the beta: three separate + * reports, and both variants verified inert against a live pane). + * + * Null when there is no sensible alias: a key that is already a chord, or not a + * single letter, has no "held Ctrl" form worth claiming. + */ +export function heldCtrlAlias(key: string): string | null { + const trimmed = (key ?? '').trim(); + if (!/^[A-Za-z]$/.test(trimmed)) return null; + return `C-${trimmed.toLowerCase()}`; +} + /** * The chord that ends an attach: the prefix, then the key bound to * `detach-client`. @@ -306,8 +324,14 @@ export function buildAttachBanner(options: { prefix?: string; label?: string; detachKey?: string; + /** The held-Ctrl form, when the attach managed to claim it. */ + heldAlias?: string; }): Record { const chord = escapeTmuxFormat(detachChord(options.prefix, options.detachKey)); + // Named on the bar because it is what people actually type: keeping Ctrl held + // is the common way to press this, and the bar has to say that it works. + const held = options.heldAlias ? escapeTmuxFormat(formatPrefixKey(options.heldAlias)) : ''; + const alias = held ? ` (or ${held})` : ''; const label = escapeTmuxFormat(truncateLabel((options.label ?? '').trim(), ATTACH_BANNER_LABEL_MAX)); // ONE option, not `status-left`/`status-right`/`status-style`: `status-format[0]` // owns the whole line, which is what removes tmux's window list (`0:bash*`) @@ -317,7 +341,7 @@ export function buildAttachBanner(options: { return { status: 'on', 'status-style': 'bg=default,fg=default', - 'status-format[0]': `#[align=left] press #[bold]${chord}#[nobold] to detach, ${ATTACH_BANNER_MARKER}${right}#[default]`, + 'status-format[0]': `#[align=left] press #[bold]${chord}#[nobold]${alias} to detach, ${ATTACH_BANNER_MARKER}${right}#[default]`, }; } @@ -412,9 +436,14 @@ export async function beginAttachHandoff(client: TuiClient, muxName: string, lab // and the caller is blocked in `spawnSync`. const sizing = await client.readWindowSizing(muxName); await client.followAttachingClient(muxName); + // Claimed only when tmux has nothing there: an attach must never shadow a + // binding the user put in their own config. + const alias = heldCtrlAlias(detachKey ?? DEFAULT_DETACH_KEY); + const claimed = alias && (await client.readPrefixBinding(alias)) === null ? await client.bindDetachKey(alias) : false; const banner = buildAttachBanner({ ...(prefix ? { prefix } : {}), ...(detachKey ? { detachKey } : {}), + ...(claimed && alias ? { heldAlias: alias } : {}), label, }); const options = await client.readSessionOptions(muxName, Object.keys(banner)); @@ -422,6 +451,7 @@ export async function beginAttachHandoff(client: TuiClient, muxName: string, lab return { chord: detachChord(prefix, detachKey), async restore(): Promise { + if (claimed && alias) await client.unbindDetachKey(alias); // Options first, then the size: dropping the status bar gives its row // back to the pane, and the resize is what re-pins the browser's // authority over the window. diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index 71a75980..4e0a3403 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -467,6 +467,21 @@ export function parseSessionOptions(stdout: string, keys: readonly string[]): Tu return options; } +/** + * What `list-keys -T prefix` says a single key is bound to, or null when the + * key appears nowhere in the table. The command is returned verbatim, so a + * caller can insist on an exact match rather than a prefix of one. + */ +export function parsePrefixBinding(stdout: string, key: string): string | null { + for (const line of stdout.split('\n')) { + const match = /^bind-key\s+(?:-\S+\s+)*?-T\s+prefix\s+(\S+)\s+(.+)$/.exec(line.trim()); + if (!match) continue; + if (unquoteTmuxValue(match[1] ?? '') !== key) continue; + return (match[2] ?? '').trim(); + } + return null; +} + /** * The phrase the attach status bar carries. It doubles as the marker that tells * OUR bar apart from a user's own when sweeping one that leaked, so @@ -897,6 +912,44 @@ export class TuiClient { } } + /** + * The command bound to a key in tmux's prefix table, or null when the key is + * free. Used to check a key is unbound BEFORE claiming it, so the attach can + * never shadow a binding the user relies on. + */ + async readPrefixBinding(key: string): Promise { + try { + const { stdout } = await this.exec('tmux', ['-L', this.socket, 'list-keys', '-T', 'prefix']); + return parsePrefixBinding(stdout, key); + } catch { + return null; + } + } + + /** Claim a prefix key for `detach-client`. Best effort; a failure is not fatal to an attach. */ + async bindDetachKey(key: string): Promise { + try { + await this.exec('tmux', ['-L', this.socket, 'bind-key', '-T', 'prefix', key, 'detach-client']); + return true; + } catch { + return false; + } + } + + /** + * Give a prefix key back, but ONLY while it still means `detach-client`. + * Anything else there is the user's, arrived after we bound ours, and must + * not be removed. + */ + async unbindDetachKey(key: string): Promise { + if ((await this.readPrefixBinding(key)) !== 'detach-client') return; + try { + await this.exec('tmux', ['-L', this.socket, 'unbind-key', '-T', 'prefix', key]); + } catch { + /* a key we cannot give back is a stray convenience binding, not a failure */ + } + } + /** * Is this session's active pane DEAD — the process it ran has exited and tmux * is holding the corpse on screen? @@ -997,6 +1050,11 @@ export class TuiClient { * Only a bar carrying our own marker is touched, and it is put back the way * Codeman creates its panes (`status off`), which is the only state this bar * is ever applied over. + * + * The held-Ctrl detach alias is deliberately NOT swept here. It is invisible, + * harmless and arguably useful if it leaks, while sweeping it would rip the + * key out from under a SECOND TUI that is mid-attach right now — key tables + * are server-global, so this method cannot tell a leak from a live claim. */ async clearLeakedAttachBanners(): Promise { let names: string[]; diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts index 9283a078..6ca4a5ed 100644 --- a/test/tui/tui-app.test.ts +++ b/test/tui/tui-app.test.ts @@ -18,6 +18,7 @@ import { confirmAccepts, confirmKillStep, detachChord, + heldCtrlAlias, nextSessionName, footerKeysFor, formatPrefixKey, @@ -458,6 +459,34 @@ describe('buildListLines', () => { }); }); +describe('the held-Ctrl detach alias', () => { + it('names the key a user produces when they never let go of Ctrl', () => { + // The failure this exists for: "Ctrl+B then d" typed as one held chord + // sends 0x02 then 0x04, and tmux leaves C-d unbound, so nothing happens. + expect(heldCtrlAlias('d')).toBe('C-d'); + }); + + it('lowercases, so a rebound uppercase key still yields the chord it produces', () => { + expect(heldCtrlAlias('Q')).toBe('C-q'); + }); + + it('has no alias for a key with no held-Ctrl form', () => { + expect(heldCtrlAlias('F1')).toBeNull(); + expect(heldCtrlAlias('C-d')).toBeNull(); + expect(heldCtrlAlias('')).toBeNull(); + expect(heldCtrlAlias('1')).toBeNull(); + }); + + it('advertises the alias on the bar only once it has been claimed', () => { + const withAlias = buildAttachBanner({ prefix: 'C-b', detachKey: 'd', heldAlias: 'C-d' }); + expect(withAlias['status-format[0]']).toContain('Ctrl+B then d'); + expect(withAlias['status-format[0]']).toContain('(or Ctrl+D)'); + // Not claimed (the key was already bound to something of the user's) means + // not advertised: a bar naming a key that does nothing is the original bug. + expect(buildAttachBanner({ prefix: 'C-b', detachKey: 'd' })['status-format[0]']).not.toContain('or Ctrl'); + }); +}); + describe('naming a session the TUI starts', () => { it("follows the web UI's w- convention", () => { expect(nextSessionName('mirofish', [])).toBe('w1-mirofish'); diff --git a/test/tui/tui-client.test.ts b/test/tui/tui-client.test.ts index 01e274cc..c6736eb9 100644 --- a/test/tui/tui-client.test.ts +++ b/test/tui/tui-client.test.ts @@ -23,6 +23,7 @@ import { arrayOptionBase, parseSessionOptions, parseDetachKey, + parsePrefixBinding, ATTACH_BANNER_MARKER, parseTmuxSessionList, parseWindowSizing, @@ -601,6 +602,27 @@ describe('attach window sizing', () => { }); }); +describe('parsePrefixBinding', () => { + const REAL = [ + 'bind-key -T prefix d detach-client', + 'bind-key -T prefix D choose-client -Z', + 'bind-key -T prefix C-b send-prefix', + ].join('\n'); + + it('reports what a key is bound to, verbatim', () => { + expect(parsePrefixBinding(REAL, 'd')).toBe('detach-client'); + expect(parsePrefixBinding(REAL, 'D')).toBe('choose-client -Z'); + }); + + it('is null for a key nothing claims, which is what makes it safe to claim', () => { + expect(parsePrefixBinding(REAL, 'C-d')).toBeNull(); + }); + + it('is case-sensitive, like tmux itself', () => { + expect(parsePrefixBinding('bind-key -T prefix D choose-client', 'd')).toBeNull(); + }); +}); + describe('TuiClient.clearLeakedAttachBanners', () => { const OURS = `#[align=left] press #[bold]Ctrl+B then d#[nobold] to detach, ${ATTACH_BANNER_MARKER} #[default]`; From 20cd47e2c3c9091d686df25526aa6939e0379b1c Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 01:04:08 +0200 Subject: [PATCH 43/57] feat(tui): leave an attach with ONE key, F12, and no modifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three beta rounds died on tmux's native way out, and the last one died on the instruction rather than the mechanism: "press Ctrl+B, release Ctrl, then d" is, in the tester's words, very unclear, and holding the modifier through both keys silently does nothing. So the way out stops being a chord. The attach claims F12 in tmux's prefix-less `root` table for its own duration, and the bar reads "press F12 to get back to the codeman dashboard" — one keystroke, nothing to hold, nothing to release, no order to get right. F12 because stock tmux ships an empty root table apart from mouse bindings, and none of the CLIs that run in these panes want the key. ⚠️ The bar names the one key ONLY when the claim succeeded, and falls back to the chord wording otherwise. A bar advertising a key that does nothing is the bug this whole series started with, and it must not come back in a new costume. Same claim rules as the prefix alias: taken only when tmux reports the key unbound, given back only while it still means `detach-client`. The chord and the held-Ctrl alias both keep working; they are simply no longer what the user is told to press. --- src/tui/tui-app.ts | 35 ++++++++++++++++++++++++++++-- src/tui/tui-client.ts | 43 +++++++++++++++++++++++++------------ test/tui/tui-app.test.ts | 30 ++++++++++++++++++++++++++ test/tui/tui-client.test.ts | 9 ++++++++ 4 files changed, 101 insertions(+), 16 deletions(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 68112438..900484f1 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -264,6 +264,23 @@ export function formatPrefixKey(prefix: string | undefined): string { /** tmux's stock `detach-client` binding, used when `list-keys` cannot be read. */ export const DEFAULT_DETACH_KEY = 'd'; +/** + * The ONE key that leaves an attach, bound in tmux's prefix-less `root` table. + * + * ⚠️ Everything else here is a fallback. tmux's native way out is a chord typed + * in a particular order — press the prefix, LET GO of the modifier, then a + * letter — and three rounds of beta testing died on it: first the bar named the + * wrong letter, then the right letter failed because the modifier was held. + * The instruction itself was the problem ("release Ctrl and THEN d" is, in the + * tester's words, very unclear), so the way out stopped being a chord. + * + * F12 because it is a single keystroke with no modifier to hold or release, and + * because no CLI that runs in these panes wants it: claude, codex, a shell and + * vim all leave it alone, and tmux ships an EMPTY root table apart from mouse + * bindings, so claiming it shadows nothing. + */ +export const ONE_KEY_DETACH = 'F12'; + /** * The key a user produces when they DON'T let go of Ctrl: `d` becomes `C-d`. * @@ -326,6 +343,8 @@ export function buildAttachBanner(options: { detachKey?: string; /** The held-Ctrl form, when the attach managed to claim it. */ heldAlias?: string; + /** The prefix-less key, when the attach managed to claim it. Preferred over every chord. */ + oneKey?: string; }): Record { const chord = escapeTmuxFormat(detachChord(options.prefix, options.detachKey)); // Named on the bar because it is what people actually type: keeping Ctrl held @@ -341,7 +360,12 @@ export function buildAttachBanner(options: { return { status: 'on', 'status-style': 'bg=default,fg=default', - 'status-format[0]': `#[align=left] press #[bold]${chord}#[nobold]${alias} to detach, ${ATTACH_BANNER_MARKER}${right}#[default]`, + // One key when we have one, the chord only as a fallback. The bar is the + // ONLY instruction a user gets during an attach, so it names the simplest + // thing that is known to work, never a menu of ways. + 'status-format[0]': options.oneKey + ? `#[align=left] press #[bold]${escapeTmuxFormat(options.oneKey)}#[nobold] to get ${ATTACH_BANNER_MARKER}${right}#[default]` + : `#[align=left] press #[bold]${chord}#[nobold]${alias} to detach, ${ATTACH_BANNER_MARKER}${right}#[default]`, }; } @@ -440,17 +464,24 @@ export async function beginAttachHandoff(client: TuiClient, muxName: string, lab // binding the user put in their own config. const alias = heldCtrlAlias(detachKey ?? DEFAULT_DETACH_KEY); const claimed = alias && (await client.readPrefixBinding(alias)) === null ? await client.bindDetachKey(alias) : false; + // The one-key way out, in the prefix-less table. Same rule: only if free. + const oneKey = + (await client.readPrefixBinding(ONE_KEY_DETACH, 'root')) === null + ? await client.bindDetachKey(ONE_KEY_DETACH, 'root') + : false; const banner = buildAttachBanner({ ...(prefix ? { prefix } : {}), ...(detachKey ? { detachKey } : {}), ...(claimed && alias ? { heldAlias: alias } : {}), + ...(oneKey ? { oneKey: ONE_KEY_DETACH } : {}), label, }); const options = await client.readSessionOptions(muxName, Object.keys(banner)); await client.applySessionOptions(muxName, banner); return { - chord: detachChord(prefix, detachKey), + chord: oneKey ? ONE_KEY_DETACH : detachChord(prefix, detachKey), async restore(): Promise { + if (oneKey) await client.unbindDetachKey(ONE_KEY_DETACH, 'root'); if (claimed && alias) await client.unbindDetachKey(alias); // Options first, then the size: dropping the status bar gives its row // back to the pane, and the resize is what re-pins the browser's diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index 4e0a3403..679cbf27 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -472,9 +472,10 @@ export function parseSessionOptions(stdout: string, keys: readonly string[]): Tu * key appears nowhere in the table. The command is returned verbatim, so a * caller can insist on an exact match rather than a prefix of one. */ -export function parsePrefixBinding(stdout: string, key: string): string | null { +export function parsePrefixBinding(stdout: string, key: string, table: TuiKeyTable = 'prefix'): string | null { + const pattern = new RegExp(`^bind-key\\s+(?:-\\S+\\s+)*?-T\\s+${table}\\s+(\\S+)\\s+(.+)$`); for (const line of stdout.split('\n')) { - const match = /^bind-key\s+(?:-\S+\s+)*?-T\s+prefix\s+(\S+)\s+(.+)$/.exec(line.trim()); + const match = pattern.exec(line.trim()); if (!match) continue; if (unquoteTmuxValue(match[1] ?? '') !== key) continue; return (match[2] ?? '').trim(); @@ -490,6 +491,13 @@ export function parsePrefixBinding(stdout: string, key: string): string | null { */ export const ATTACH_BANNER_MARKER = 'back to the codeman dashboard'; +/** + * The tmux key tables an attach touches. `root` is the one without a prefix: + * a key bound there is delivered on its own, which is what makes a one-key way + * out possible at all. + */ +export type TuiKeyTable = 'prefix' | 'root'; + /** * The key bound to a bare `detach-client` in `list-keys -T prefix` output, or * null when nothing there detaches. @@ -917,19 +925,26 @@ export class TuiClient { * free. Used to check a key is unbound BEFORE claiming it, so the attach can * never shadow a binding the user relies on. */ - async readPrefixBinding(key: string): Promise { + async readPrefixBinding(key: string, table: TuiKeyTable = 'prefix'): Promise { try { - const { stdout } = await this.exec('tmux', ['-L', this.socket, 'list-keys', '-T', 'prefix']); - return parsePrefixBinding(stdout, key); + const { stdout } = await this.exec('tmux', ['-L', this.socket, 'list-keys', '-T', table]); + return parsePrefixBinding(stdout, key, table); } catch { return null; } } - /** Claim a prefix key for `detach-client`. Best effort; a failure is not fatal to an attach. */ - async bindDetachKey(key: string): Promise { + /** + * Claim a key for `detach-client`. Best effort; a failure is not fatal to an + * attach, and the caller advertises the key only if this returned true. + * + * The `root` table is the one that matters for a way OUT: a key bound there + * needs no prefix at all, so leaving a session is one keypress rather than a + * chord typed in the right order. + */ + async bindDetachKey(key: string, table: TuiKeyTable = 'prefix'): Promise { try { - await this.exec('tmux', ['-L', this.socket, 'bind-key', '-T', 'prefix', key, 'detach-client']); + await this.exec('tmux', ['-L', this.socket, 'bind-key', '-T', table, key, 'detach-client']); return true; } catch { return false; @@ -937,14 +952,14 @@ export class TuiClient { } /** - * Give a prefix key back, but ONLY while it still means `detach-client`. - * Anything else there is the user's, arrived after we bound ours, and must - * not be removed. + * Give a key back, but ONLY while it still means `detach-client`. Anything + * else there is the user's, arrived after we bound ours, and must not be + * removed. */ - async unbindDetachKey(key: string): Promise { - if ((await this.readPrefixBinding(key)) !== 'detach-client') return; + async unbindDetachKey(key: string, table: TuiKeyTable = 'prefix'): Promise { + if ((await this.readPrefixBinding(key, table)) !== 'detach-client') return; try { - await this.exec('tmux', ['-L', this.socket, 'unbind-key', '-T', 'prefix', key]); + await this.exec('tmux', ['-L', this.socket, 'unbind-key', '-T', table, key]); } catch { /* a key we cannot give back is a stray convenience binding, not a failure */ } diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts index 6ca4a5ed..d66a1ed7 100644 --- a/test/tui/tui-app.test.ts +++ b/test/tui/tui-app.test.ts @@ -19,6 +19,7 @@ import { confirmKillStep, detachChord, heldCtrlAlias, + ONE_KEY_DETACH, nextSessionName, footerKeysFor, formatPrefixKey, @@ -459,6 +460,35 @@ describe('buildListLines', () => { }); }); +describe('the one-key way out', () => { + it('names a single key with no modifier at all', () => { + // The whole point: three beta rounds died on a chord that had to be typed + // in the right order with the modifier released at the right moment. + expect(ONE_KEY_DETACH).toBe('F12'); + expect(ONE_KEY_DETACH).not.toContain('C-'); + expect(ONE_KEY_DETACH).not.toContain('+'); + }); + + it('puts ONE instruction on the bar, not a menu of ways out', () => { + const banner = buildAttachBanner({ prefix: 'C-b', detachKey: 'd', heldAlias: 'C-d', oneKey: 'F12' }); + const bar = banner['status-format[0]']; + expect(bar).toContain('press #[bold]F12#[nobold] to get back to the codeman dashboard'); + // Even though both fallbacks still work, the bar must not offer them: a bar + // listing three ways to leave is what the tester called way too complicated. + expect(bar).not.toContain('Ctrl+B'); + expect(bar).not.toContain('or Ctrl+D'); + }); + + it('falls back to the chord when the key could not be claimed', () => { + // Never advertise a key we did not get: a bar naming an inert key is the + // original bug, in a new costume. + const bar = buildAttachBanner({ prefix: 'C-b', detachKey: 'd', heldAlias: 'C-d' })['status-format[0]']; + expect(bar).toContain('Ctrl+B then d'); + expect(bar).toContain('(or Ctrl+D)'); + expect(bar).not.toContain('F12'); + }); +}); + describe('the held-Ctrl detach alias', () => { it('names the key a user produces when they never let go of Ctrl', () => { // The failure this exists for: "Ctrl+B then d" typed as one held chord diff --git a/test/tui/tui-client.test.ts b/test/tui/tui-client.test.ts index c6736eb9..659dbae5 100644 --- a/test/tui/tui-client.test.ts +++ b/test/tui/tui-client.test.ts @@ -621,6 +621,15 @@ describe('parsePrefixBinding', () => { it('is case-sensitive, like tmux itself', () => { expect(parsePrefixBinding('bind-key -T prefix D choose-client', 'd')).toBeNull(); }); + + it('reads the prefix-less root table too, where the one-key exit lives', () => { + const root = ['bind-key -T root MouseDown1Pane select-pane -t =', 'bind-key -T root F12 detach-client'].join('\n'); + expect(parsePrefixBinding(root, 'F12', 'root')).toBe('detach-client'); + // Stock tmux has no F12 there, which is what makes it safe to claim. + expect(parsePrefixBinding('bind-key -T root MouseDown1Pane select-pane -t =', 'F12', 'root')).toBeNull(); + // A prefix binding must not be mistaken for a root one. + expect(parsePrefixBinding('bind-key -T prefix F12 detach-client', 'F12', 'root')).toBeNull(); + }); }); describe('TuiClient.clearLeakedAttachBanners', () => { From 643edc65ca1a43cd6c5c0eea626fb9062a8461bf Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 01:08:49 +0200 Subject: [PATCH 44/57] feat(tui): offer r to resume a session whose pane has died MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refusing the attach stopped the freeze but told the user to throw the session away (`x` to close, `n` for new), which loses the conversation. tmux's own dead-pane screen already says what to do instead: `claude --resume ""`. The Error card now offers `r` when the row can actually be resumed (claude, with a conversation id and a working directory), and the footer says so. One press resumes into a fresh pane and attaches to it, so a dead end becomes recovery. ⚠️ Three things keep this from becoming the resume runaway that once spawned 35 sessions in 40 seconds. The offer holds a session ID, not a row, and is re-resolved from the model when the key is pressed: a row captured when the card opened is stale by then. It disarms BEFORE anything async, so a second `r` cannot start a second resume. And it routes through resumeSelected(), which owns the `resuming` flag and ends in attachToSession() rather than the group dispatch. ⚠️ The `r` branch has to run BEFORE the generic dismiss, because a message overlay is dismissed by ANY key: without that ordering the offer is consumed as "some key was pressed" and the card merely closes. `help` keeps the any-key behaviour, so the two modes no longer share a case. Verified end to end against a genuinely dead claude pane: card, footer, one press, one new session, and F12 back to the dashboard. --- src/tui/tui-app.ts | 60 ++++++++++++++++++++++++++++++++++++++-- test/tui/tui-app.test.ts | 10 +++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 900484f1..8d7b8f8a 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -561,6 +561,8 @@ export interface TuiKeymapContext { approval?: TuiApprovalKeys; /** tmux's detach chord as this socket reports it. Defaults to the stock `Ctrl+B D`. */ detach?: string; + /** A dead-row card is offering `r` to resume, so the footer has to say so. */ + resumeOffer?: boolean; } /** @@ -576,7 +578,7 @@ export function footerKeysFor(mode: TuiUiMode, glyphs: TuiGlyphSet, context: Tui case 'confirm-kill': return ['type the name', `${glyphs.enter} confirm`, 'esc cancel']; case 'message': - return ['esc dismiss']; + return context.resumeOffer ? ['r resume', 'esc dismiss'] : ['esc dismiss']; case 'new-session': return [`${glyphs.updown} select`, `${glyphs.enter} choose`, 'type to filter', 'esc cancel']; case 'prompt': @@ -1339,12 +1341,24 @@ class TuiApp { }); } + /** + * The session a dead-row error card is offering to resume, by id. + * + * An id rather than the row: by the time the key is pressed the model has + * resynced at least once, and acting on a captured row would resume whatever + * that stale object still pointed at. Armed only while the card is up, and + * cleared the moment anything else happens, so `r` can never resume a session + * the user is no longer looking at. + */ + private resumeOffer: string | null = null; + private keymapContext(): TuiKeymapContext { const approval = this.model.selectedSession()?.approval; return { server: this.model.connection !== 'degraded', approval: approval ? (approval.kind === 'idle' ? 'idle' : 'menu') : null, detach: this.detachChordLabel, + resumeOffer: this.resumeOffer !== null, }; } @@ -1437,8 +1451,18 @@ class TuiApp { case 'digest': this.handleDigest(event); return; - case 'help': case 'message': + // ⚠️ `r` is checked BEFORE the dismiss, because a message overlay is + // dismissed by ANY key: without this branch the offer would be consumed + // as "some key was pressed" and the card would just close. + if (this.resumeOffer && event.type === 'char' && event.value === 'r') { + this.takeResumeOffer(); + return; + } + this.resumeOffer = null; + if (event.type !== 'mouse') this.model.closeOverlay(); + return; + case 'help': // Any key dismisses; the footer says esc because that is the one key // every overlay in the app answers to. if (event.type !== 'mouse') this.model.closeOverlay(); @@ -1971,10 +1995,17 @@ class TuiApp { // hands the terminal to a pane that reads nothing, which a beta tester // experienced as the TUI freezing with no way out. if (await this.client.isPaneDead(muxName)) { + // Its conversation usually survives the pane: tmux's own dead-pane screen + // says `claude --resume ""`. Offering that turns a dead end into + // recovery, instead of telling the user to throw the work away. + const offer = planResume(row.session).kind === 'resume'; + this.resumeOffer = offer ? row.session.sessionId : null; this.message( 'err', `${rowLabel(row.session)} has exited — its pane is dead, so there is nothing there to type into. ` + - 'Close the row with x, or start a fresh session with n.' + (offer + ? 'Press r to resume the conversation in a fresh pane, or x to close the row.' + : 'Close the row with x, or start a fresh session with n.') ); // ⚠️ message() only sets state. The keypress that got us here painted // BEFORE this await resolved, so without a paint of our own the refusal @@ -2172,6 +2203,29 @@ class TuiApp { if (caseName && mode) void this.startSession(caseName, mode.id); } + /** + * Act on the dead-row card's offer: resume the conversation whose pane died. + * + * ⚠️ Re-resolved from the model by id and disarmed BEFORE anything async, so + * a second `r` cannot start a second resume. `resumeSelected()` owns the rest + * of the loop safety (its own `resuming` flag, and it ends in + * `attachToSession`, never the group dispatch that once produced 35 sessions + * in 40 seconds). + */ + private takeResumeOffer(): void { + const sessionId = this.resumeOffer; + this.resumeOffer = null; + this.model.closeOverlay(); + if (!sessionId) return; + const row = this.model.rows().find((candidate) => candidate.session.sessionId === sessionId); + if (!row) { + this.message('warn', 'that row is gone; the list has moved on since the card opened'); + this.paint(); + return; + } + void this.resumeSelected(row); + } + private async startSession(caseName: string, mode: TuiRunMode): Promise { try { const result = await this.client.quickStart({ diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts index d66a1ed7..c220d308 100644 --- a/test/tui/tui-app.test.ts +++ b/test/tui/tui-app.test.ts @@ -460,6 +460,16 @@ describe('buildListLines', () => { }); }); +describe('the dead-row resume offer', () => { + it('advertises r on the message footer only while an offer is armed', () => { + const base = { server: true } as const; + expect(footerKeysFor('message', GLYPHS, { ...base, resumeOffer: true })).toEqual(['r resume', 'esc dismiss']); + // Without an offer the card is a plain notice, and a footer promising `r` + // would be advertising a key that does nothing. + expect(footerKeysFor('message', GLYPHS, base)).toEqual(['esc dismiss']); + }); +}); + describe('the one-key way out', () => { it('names a single key with no modifier at all', () => { // The whole point: three beta rounds died on a chord that had to be typed From ecce1e2ea4a54fccc03c31107eee0b3734063ba3 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 01:11:14 +0200 Subject: [PATCH 45/57] fix(tui): fold rare prompt glyphs in the preview so they stop rendering as boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A beta tester photographed claude's `❯` prompt and its `⏵⏵` bypass-permissions marker rendering as empty boxes in the preview pane. Their font has no coverage for those codepoints while drawing `·`, `─`, `│` and `▶` perfectly. The glyph TIER cannot help here. It answers "can this terminal do Unicode at all", which is a locale question, and it correctly says yes for exactly the terminals this affects. Coverage is per-glyph and undetectable from inside the process, so the handful of rare glyphs CLIs use as chrome are folded to the ASCII arrows they already look like, and everything a plain font does render is left alone. Scoped tightly: the preview only, never the TUI's own chrome, and skipped entirely at the `nerd` tier where the user has declared a font that can draw anything. The table is short and every entry was seen as tofu in a real terminal rather than guessed at. The fold is length-preserving, so the preview pane's column arithmetic is unaffected. --- src/tui/tui-ansi.ts | 38 ++++++++++++++++++++++++++++++++++++++ src/tui/tui-app.ts | 9 +++++++-- test/tui/tui-ansi.test.ts | 29 +++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/tui/tui-ansi.ts b/src/tui/tui-ansi.ts index 76e0c73f..40fdcfa7 100644 --- a/src/tui/tui-ansi.ts +++ b/src/tui/tui-ansi.ts @@ -331,6 +331,44 @@ function renderCells(cells: Cell[]): string { * 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 = 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', '<'], // ❰ +]); + +/** + * 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[] = []; diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 8d7b8f8a..ff42352b 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -50,7 +50,7 @@ import chalk from 'chalk'; import { palette, table, tint, type Tone } from '../cli-style.js'; import { CODEMAN_INSTANCE, resolveTmuxSocketName } from '../config/instance.js'; import { getErrorMessage } from '../types/api.js'; -import { dropSeveredEscape, toDisplayLines } from './tui-ansi.js'; +import { dropSeveredEscape, foldPreviewGlyphs, toDisplayLines } from './tui-ansi.js'; import { approvalAnswerForKey, newApprovalIds } from './tui-approvals.js'; import { composerScroll, composerStep, composerText, createComposer, type TuiComposerState } from './tui-composer.js'; import { formatAwayDigest } from './tui-digest.js'; @@ -1315,7 +1315,12 @@ class TuiApp { try { const raw = await this.client.fetchTerminalTail(sessionId, PREVIEW_TAIL_BYTES); if (this.previewSessionId !== sessionId) return; - const lines = toDisplayLines(dropSeveredEscape(raw)).slice(-PREVIEW_MAX_LINES); + const tail = toDisplayLines(dropSeveredEscape(raw)).slice(-PREVIEW_MAX_LINES); + // A nerd font can draw anything; every other terminal gets the rare + // prompt glyphs folded to the arrows they already look like, because a + // font's per-glyph coverage cannot be detected from in here and tofu is + // worse than an ASCII arrow. + const lines = this.glyphTier === 'nerd' ? tail : tail.map(foldPreviewGlyphs); // An identical tail is what the backoff counts; anything new resets it, so // a pane that starts printing again is back to one read a second. if (this.applyPreview({ sessionId, lines })) this.previewQuiet = 0; diff --git a/test/tui/tui-ansi.test.ts b/test/tui/tui-ansi.test.ts index 1a0687df..a06dfef0 100644 --- a/test/tui/tui-ansi.test.ts +++ b/test/tui/tui-ansi.test.ts @@ -16,6 +16,7 @@ import { toDisplayLines, visibleWidth, charWidth, + foldPreviewGlyphs, } from '../../src/tui/tui-ansi.js'; const RED = '\x1b[31m'; @@ -204,3 +205,31 @@ describe('stripStyles', () => { expect(stripStyles('a\u{1f600}中')).toBe('a\u{1f600}中'); }); }); + +describe('foldPreviewGlyphs', () => { + it("turns claude's prompt and mode markers into the arrows they look like", () => { + // Exactly what a beta tester photographed as empty boxes. + expect(foldPreviewGlyphs('\u276F Try "how does report_agent.py work?"')).toBe( + '> Try "how does report_agent.py work?"' + ); + expect(foldPreviewGlyphs(' \u23F5\u23F5 bypass permissions on')).toBe(' >> bypass permissions on'); + }); + + it('leaves the glyphs that plain fonts DO render', () => { + // The same terminal drew all of these correctly, so folding them would be a + // downgrade for everyone to fix a problem nobody has. + const kept = '\u00B7 \u2500 \u2502 \u25B6 \u25CB \u2714 \u2192'; + expect(foldPreviewGlyphs(kept)).toBe(kept); + }); + + it('leaves ordinary text and box drawing exactly alone', () => { + const line = ' 1 tui-demo-shell shell \u25CB 4m\u2502'; + expect(foldPreviewGlyphs(line)).toBe(line); + expect(foldPreviewGlyphs('')).toBe(''); + }); + + it('preserves length, so preview column arithmetic is unaffected', () => { + const line = '\u276F hello \u23F5\u23F5 world'; + expect(foldPreviewGlyphs(line)).toHaveLength([...line].length); + }); +}); From fea8fb1f9579d98b965c120182a83d0abb45f1dd Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 01:17:13 +0200 Subject: [PATCH 46/57] feat(tui): switch sessions with the web UI's shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alt+1..9 switches to that session, and `[` / `]` / Tab step through them, so the muscle memory from the web UI carries over. Alt+N SELECTS rather than attaches, which is what the web UI's Alt+N does: switching which tab you look at is cheap and reversible, and the terminal equivalent is moving the selection and its preview, not handing the whole terminal to a pane. Bare 1-9 keeps its documented jump-and-attach meaning. Two of the web UI's chords cannot cross into a terminal, so the nearest transmittable keys carry them instead: Alt+[ / Alt+] ESC+[ IS the CSI introducer every arrow key arrives on, and ESC+] is OSC, so neither chord is distinguishable from a sequence. Bare `[` and `]` do the job. Ctrl+Tab a terminal cannot report the Ctrl, so plain Tab carries it. ⚠️ The parser now decodes ESC + a printable character in ONE read as an Alt chord, and the app replays every chord it does not claim as `escape` then that character. That fallback is load-bearing, not tidiness: a real Esc landing in the same read as the next keystroke is byte-identical to a chord, and without the replay "Esc then q" typed quickly decoded as Alt+Q, matched nothing and was swallowed. The e2e suite caught exactly that as the dashboard refusing to quit. A lone Esc is still held and flushed on the caller's timer, which is what keeps the two separable at all. --- src/tui/tui-app.ts | 49 +++++++++++++++++++++++++++++++++++++++ src/tui/tui-keys.ts | 22 +++++++++++++++--- test/tui/tui-keys.test.ts | 39 +++++++++++++++++++++++++++++-- 3 files changed, 105 insertions(+), 5 deletions(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index ff42352b..cdecf68d 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -611,6 +611,12 @@ export function helpKeysFor(glyphs: TuiGlyphSet, context: TuiKeymapContext): Arr [`${glyphs.updown} / j k`, 'select'], [glyphs.enter, 'attach — on a RECENT row, resume that conversation'], ['1-9', 'jump and attach'], + // The web UI's tab switching, as close as a terminal can carry it: Alt+N + // matches exactly, while Alt+[ / Alt+] cannot be transmitted (ESC+[ IS the + // CSI introducer) so the brackets do that job unmodified. + ['alt+1-9', 'switch to that session, without attaching'], + ['[ / ]', 'previous / next session'], + ['tab', 'next session'], // The one key that is not the TUI's: an attach hands the terminal to tmux, // and leaving it is the question every first attach asks. [context.detach ?? detachChord(), 'detach from an attached session, back to here'], @@ -1431,6 +1437,31 @@ class TuiApp { this.afterInput(); } + /** + * An Alt chord: `Alt+1`..`Alt+9` switch session, everything else is replayed. + * + * Alt+N SELECTS rather than attaches. In the web UI Alt+N switches which tab + * you are looking at, which is cheap and reversible; the terminal equivalent + * is moving the selection and its preview, not handing the whole terminal to + * a pane. Bare 1-9 keeps its documented jump-and-attach meaning. + * + * ⚠️ Every OTHER chord is replayed as `escape` then the character, and that + * fallback is load-bearing rather than tidiness. A terminal encodes Alt+x as + * ESC then x, so a real Esc that lands in the same read as the next keystroke + * is byte-identical to a chord. Without the replay, "Esc then q" typed + * quickly decoded as Alt+Q, matched nothing, and was swallowed — the e2e test + * caught it as the dashboard refusing to quit. Replaying keeps every overlay + * dismissal and every existing key working exactly as before. + */ + private handleAlt(value: string): void { + if (this.model.mode === 'list' && value >= '1' && value <= '9') { + this.model.cursorToIndex(Number.parseInt(value, 10)); + return; + } + this.handle({ type: 'escape' }); + this.handle({ type: 'char', value }); + } + /** Every key can change the selection or the mode, and both steer the preview. */ private afterInput(): void { if (this.exiting) return; @@ -1440,6 +1471,10 @@ class TuiApp { private handle(event: TuiInputEvent): void { if (this.exiting) return; + if (event.type === 'alt') { + this.handleAlt(event.value); + return; + } switch (this.model.mode) { case 'confirm-kill': this.handleConfirm(event); @@ -1494,6 +1529,11 @@ class TuiApp { case 'char': this.handleListChar(event.value); return; + case 'tab': + // Ctrl+Tab in the web UI. A terminal cannot report the Ctrl, so plain + // Tab carries it: nothing else in the list wants the key. + this.model.moveCursor(1); + return; default: return; } @@ -1523,6 +1563,15 @@ class TuiApp { case 'k': this.model.moveCursor(-1); return; + // The web UI's Alt+[ / Alt+] for previous/next tab. WITHOUT the Alt, + // because ESC+[ is byte-identical to the CSI introducer every arrow key + // arrives on, so the chord cannot be transmitted by a terminal at all. + case '[': + this.model.moveCursor(-1); + return; + case ']': + this.model.moveCursor(1); + return; case 'q': this.quit(0); return; diff --git a/src/tui/tui-keys.ts b/src/tui/tui-keys.ts index 2435f0c4..2ea9cb8e 100644 --- a/src/tui/tui-keys.ts +++ b/src/tui/tui-keys.ts @@ -38,6 +38,7 @@ export type TuiInputEvent = | { 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 }; @@ -107,9 +108,24 @@ export function createKeyParser(): TuiKeyParser { return { consumed: 3, events: name ? [{ type: 'key', name }] : NOTHING }; } - // Anything that is not a CSI is a lone ESC as far as we are concerned; the - // next byte then parses on its own (so Alt+x reads as Escape then `x`). - if (second !== 0x5b) return { consumed: 1, events: [{ type: 'escape' }] }; + // 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++; diff --git a/test/tui/tui-keys.test.ts b/test/tui/tui-keys.test.ts index e94af8c2..f4c2c8ff 100644 --- a/test/tui/tui-keys.test.ts +++ b/test/tui/tui-keys.test.ts @@ -121,8 +121,11 @@ describe('escape sequences', () => { expect(decode('\x1b[M !!x')).toEqual([{ type: 'char', value: 'x' }]); }); - it('reads ESC followed by a letter as Escape then that letter', () => { - expect(decode('\x1bx')).toEqual([{ type: 'escape' }, { type: 'char', value: 'x' }]); + it('reads ESC followed by a letter as the Alt chord it is', () => { + // Changed deliberately: this used to decode as Escape + `x`, which made + // Alt+N unreachable. A lone Esc is still separable because it is HELD until + // the caller's timer flushes it (see the 'lone escape' suite). + expect(decode('\x1bx')).toEqual([{ type: 'alt', value: 'x' }]); }); }); @@ -208,3 +211,35 @@ describe('torn reads', () => { expect(parser.feed('\x1b[A')).toEqual([{ type: 'key', name: 'up' }]); }); }); + +describe('Alt chords', () => { + it('reads ESC + a printable character in one read as Alt+that key', () => { + expect(decode('\x1b1')).toEqual([{ type: 'alt', value: '1' }]); + expect(decode('\x1bk')).toEqual([{ type: 'alt', value: 'k' }]); + }); + + it('never steals the sequence introducers, or every arrow key would break', () => { + // ESC [ is CSI and ESC O is SS3: both are Up, not Alt+[ / Alt+O. + expect(decode('\x1b[A')).toEqual([{ type: 'key', name: 'up' }]); + expect(decode('\x1bOA')).toEqual([{ type: 'key', name: 'up' }]); + }); + + it('leaves ESC ] alone, so a terminal colour reply is never read as a chord', () => { + // OSC introducer: decoded as Escape then `]`, exactly as before. + expect(decode('\x1b]')).toEqual([{ type: 'escape' }, { type: 'char', value: ']' }]); + }); + + it('keeps a lone ESC held, which is what separates it from a chord', () => { + const parser = createKeyParser(); + expect(parser.feed('\x1b')).toEqual([]); + expect(parser.flush()).toEqual([{ type: 'escape' }]); + }); + + it('decodes a chord torn across two reads as Escape then the character', () => { + // The unavoidable ambiguity, resolved the standard way: same read = chord. + const parser = createKeyParser(); + expect(parser.feed('\x1b')).toEqual([]); + expect(parser.flush()).toEqual([{ type: 'escape' }]); + expect(parser.feed('1')).toEqual([{ type: 'char', value: '1' }]); + }); +}); From 5202eac1e937f145cb8a73433402b268f04ca8c8 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 01:32:44 +0200 Subject: [PATCH 47/57] fix(tui): start a session straight into it, and drop two unsafe glyphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reports from the same beta screenshot. Starting a session left the user on the dashboard next to the row they had just asked for, which reads as the create having silently failed. Starting a session is a request to WORK in it, so the terminal now goes there as soon as the pane exists, and the CLI booting is worth watching. If the pane is slow the notice says so and the row is left selected, exactly as the resume path does. The footer's `↵` was drawing as an empty box: `⏎` (U+23CE) has poor font coverage, on the same terminal that renders `·`, `─`, `│`, `○`, `▶` and `✔` perfectly. It is now U+21B5, from the Arrows block every monospace font ships. `✋` (U+270B) was worse than a coverage problem: it is East Asian WIDE, so the renderer, which addresses cells by column, was reserving two cells for it. The golden frames had the age column shifted a space left to match, which is how long that had been wrong. It is now `!`, and the frames align correctly. A test walks the whole unicode glyph set and fails on any entry wider than one cell, so a glyph that shifts the layout cannot be added again. The comment on the table spells out both bars a glyph has to clear, because the tier check answers neither: it asks whether the LOCALE is UTF-8, which says nothing about whether a font has the glyph or how wide it draws. --- src/tui/tui-app.ts | 17 ++++++++++- src/tui/tui-render.ts | 21 +++++++++++-- test/tui/tui-render.test.ts | 61 ++++++++++++++++++++++++++++++++----- 3 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index cdecf68d..1222199a 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -2297,8 +2297,23 @@ class TuiApp { }); // The row appears with the next resync; remember which one to select. this.pendingSelectId = result.sessionId; - this.message('info', `started ${mode} in ${caseName}`); + this.notice(`starting ${mode} in ${caseName}…`); + this.paint(true); await this.refresh(); + // Starting a session is a request to WORK in it, so the terminal goes + // there as soon as the pane exists. Leaving the user on the dashboard + // next to a session they just asked for reads as the create having + // silently failed (reported from the beta as "it doesn't go into it + // directly"), and the pane is worth watching while the CLI boots. + const fresh = await this.awaitResumedRow(result.sessionId); + if (!fresh) { + this.message('info', `started ${mode} in ${caseName}; its pane is still starting — press ⏎ on the new row`); + this.paint(true); + return; + } + this.pendingSelectId = null; + await this.attachToSession(fresh); + return; } catch (error) { this.message('err', `could not start a session in ${caseName}: ${getErrorMessage(error)}`); } diff --git a/src/tui/tui-render.ts b/src/tui/tui-render.ts index e393f218..c54f27ca 100644 --- a/src/tui/tui-render.ts +++ b/src/tui/tui-render.ts @@ -123,10 +123,27 @@ export interface TuiGlyphSet { 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. A beta tester's font drew `·`, `─`, + * `│`, `○`, `▶` and `✔` perfectly while drawing `⏎` (U+23CE) as an empty box. + * Prefer Latin-1, Arrows (U+2190–21FF), Box Drawing, Block Elements and + * Geometric Shapes, which every monospace font ships; treat Dingbats, + * Miscellaneous Symbols and anything with emoji presentation as suspect. + */ const UNICODE_GLYPHS: TuiGlyphSet = { blockedPermission: '⚠', blockedQuestion: '⚠', - waiting: '✋', + waiting: '!', working: ['·', '✢', '✳', '∗', '✻', '✽'], idle: '○', recent: '✔', @@ -139,7 +156,7 @@ const UNICODE_GLYPHS: TuiGlyphSet = { boxBottomRight: '┘', boxHorizontal: '─', boxVertical: '│', - enter: '⏎', + enter: '↵', updown: '↑↓', separator: '·', ellipsis: '…', diff --git a/test/tui/tui-render.test.ts b/test/tui/tui-render.test.ts index 36e07cef..2fa113a7 100644 --- a/test/tui/tui-render.test.ts +++ b/test/tui/tui-render.test.ts @@ -7,7 +7,7 @@ * NO_COLOR leaves nothing but cursor addressing behind. */ import { describe, it, expect } from 'vitest'; -import { stripStyles, toDisplayLines, visibleWidth } from '../../src/tui/tui-ansi.js'; +import { charWidth, stripStyles, toDisplayLines, visibleWidth } from '../../src/tui/tui-ansi.js'; import { composerMove, createComposer } from '../../src/tui/tui-composer.js'; import { computeLayout, needsBanner } from '../../src/tui/tui-layout.js'; import { createTuiModel, type TuiModelStore } from '../../src/tui/tui-model.js'; @@ -18,6 +18,7 @@ import { formatElapsed, formatPlanUsage, formatTokens, + glyphsFor, renderFrame, rowLabel, type TuiRenderOptions, @@ -130,7 +131,7 @@ describe('renderFrame structure', () => { expect(frameLines(render(fixture(), 100, 30))).toEqual([ ' codeman ⚠ 2 tnode · v1.19.0 · 4 sessions · 5h 32% wk 61% ? help q quit', ' NEEDS YOU ─────────────────────────│ w4-api-refactor · claude · /home/dev/api · blocked', - ' 1 w6-docs ✋ 11m│ ⚠ requests: Bash(git push origin main)', + ' 1 w6-docs ! 11m│ ⚠ requests: Bash(git push origin main)', '▶ 2 w4-api-refactor ⚠ 2m 12.3k│ 1. Yes', " WORKING ───────────────────────────│ 2. Yes, don't ask again", ' 3 w1-codeman ✻ 17m 45.2k│ 3. No, tell Claude what to do', @@ -157,7 +158,7 @@ describe('renderFrame structure', () => { ' │', ' │', ' │', - ' ↑↓ select · ⏎ attach · 1-9 jump · y/n answer · p prompt · n new · x kill · / search · g digest · ?', + ' ↑↓ select · ↵ attach · 1-9 jump · y/n answer · p prompt · n new · x kill · / search · g digest · ?', ]); }); @@ -165,7 +166,7 @@ describe('renderFrame structure', () => { expect(frameLines(render(fixture(), 44, 20))).toEqual([ ' codeman ⚠ 2 tnode · v1.19.0 · 4 sessions', ' NEEDS YOU ─────────────────────────────────', - ' 1 w6-docs ✋ 11m', + ' 1 w6-docs ! 11m', ' /home/dev/docs', '▶ 2 w4-api-refactor ⚠ 2m', ' /home/dev/api · 12.3k', @@ -182,7 +183,7 @@ describe('renderFrame structure', () => { '', '', '', - ' ↑↓ select · ⏎ attach · 1-9 jump · y/n answe', + ' ↑↓ select · ↵ attach · 1-9 jump · y/n answe', ]); }); @@ -224,7 +225,7 @@ describe('color', () => { const frame = render(model, 100, 30, { color: true }); expect(frame).toContain('\x1b[32m✻'); expect(frame).toContain('\x1b[31m⚠'); - expect(frame).toContain('\x1b[33m✋'); + expect(frame).toContain('\x1b[33m!'); expect(frame).toContain('\x1b[1mcodeman'); }); @@ -463,7 +464,7 @@ describe('the approval card', () => { model.select('bbb2'); const idle = render(model, 100, 30, { color: true }); - expect(idle).toContain('\x1b[33m ✋'); + expect(idle).toContain('\x1b[33m !'); expect(idle).toContain('p to reply'); }); @@ -608,3 +609,49 @@ describe('formatPlanUsage', () => { expect(formatPlanUsage({})).toBe(''); }); }); + +describe('the unicode glyph set is safe to render', () => { + // Two failures this pins, both found on one beta tester's terminal: + // a double-width glyph shifting every cell after it, and a codepoint their + // font had no glyph for at all. + const UNICODE = glyphsFor('unicode'); + const every = [ + UNICODE.blockedPermission, + UNICODE.blockedQuestion, + UNICODE.waiting, + ...UNICODE.working, + UNICODE.idle, + UNICODE.recent, + UNICODE.cursor, + UNICODE.rule, + UNICODE.divider, + UNICODE.boxTopLeft, + UNICODE.boxTopRight, + UNICODE.boxBottomLeft, + UNICODE.boxBottomRight, + UNICODE.boxHorizontal, + UNICODE.boxVertical, + UNICODE.enter, + UNICODE.separator, + UNICODE.ellipsis, + ]; + + it('has no double-width glyph, which would shift every cell after it', () => { + for (const glyph of every) { + for (const char of glyph) { + expect({ glyph, width: charWidth(char.codePointAt(0) ?? 0) }).toEqual({ glyph, width: 1 }); + } + } + }); + + it('uses the arrow-block return symbol, not the one fonts lack', () => { + // U+23CE rendered as an empty box on a font that drew everything else here. + expect(UNICODE.enter).toBe('\u21B5'); + expect(UNICODE.enter).not.toBe('\u23CE'); + }); + + it('has no emoji where a text glyph belongs', () => { + // U+270B is Wide AND emoji-presentation: it drew at emoji size mid-row. + expect(every.join('')).not.toContain('\u270B'); + }); +}); From ed4d0da2da09b8e4cea4e45656591677752e3cdd Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 01:49:59 +0200 Subject: [PATCH 48/57] fix(tui): confirm a kill with y, and make the dialog say what it would kill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Killing demanded the session's NAME typed out in full. That is the right ceremony for dropping a production database and the wrong one for closing a pane you are looking at; the beta tester's verdict was "thats stupid, just make me type Y to confirm". `x` then `y` is already two deliberate keystrokes on a row the user selected, and the conversation lives in its transcript, which a kill does not touch. Everything that is not `y` CANCELS rather than being ignored, so a stray key closes the dialog instead of leaving a destructive prompt armed and waiting for whatever gets typed next. Enter cancels too: it is the key most likely to be hit by reflex, and this is the one dialog that destroys something. ⚠️ Found while verifying the new dialog: it did not name the session. The label was computed as `row.session.name ?? id.slice(0, 8)`, and `??` falls back only on null or undefined, so every session the server left with an EMPTY name — all of them, until the TUI started naming its own — sailed through and the box read "Kill ?". A destructive prompt that cannot say what it will destroy is worse than no prompt, and it is now a single keystroke. The caller passes the same label the LIST shows, so the dialog names the row in front of the user. The typed-name machinery goes with it: TuiConfirmState.typed, setConfirmInput(), confirmAccepts() and the 'typing'/'reject' steps are all removed rather than left as unreachable branches. --- src/tui/tui-app.ts | 51 +++++++++++++++---------------------- src/tui/tui-model.ts | 18 ++++++------- src/tui/tui-render.ts | 5 ++-- src/tui/tui-types.ts | 1 - test/tui/tui-app.test.ts | 43 +++++++++++-------------------- test/tui/tui-e2e.test.ts | 15 ++++++++--- test/tui/tui-model.test.ts | 14 +++++++--- test/tui/tui-render.test.ts | 12 ++++----- 8 files changed, 75 insertions(+), 84 deletions(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 1222199a..299852ec 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -508,33 +508,30 @@ export function isSelfSession(sessionId: string, env: { CODEMAN_SESSION_ID?: str // Kill confirmation (pure) // ───────────────────────────────────────────────────────────────────────────── -export type TuiConfirmStep = - | { kind: 'typing'; typed: string } - | { kind: 'confirm' } - | { kind: 'reject' } - | { kind: 'cancel' } - | { kind: 'ignore' }; - -/** Does the typed text authorize the kill? The shown name, or the id prefix a mux name carries. */ -export function confirmAccepts(state: TuiConfirmState, typed = state.typed): boolean { - const value = typed.trim(); - if (value === '') return false; - return value === state.name || value === state.sessionId.slice(0, 8); -} +export type TuiConfirmStep = { kind: 'confirm' } | { kind: 'cancel' } | { kind: 'ignore' }; /** - * One keystroke of the typed confirmation. Enter on text that does not match is - * a `reject`, never a silent no-op: a confirmation that appears to do nothing - * reads as a broken key. + * One keystroke of the kill confirmation: `y` kills, anything else does not. + * + * ⚠️ It used to demand the session's NAME typed out in full. That is the right + * ceremony for deleting a production database and the wrong one for closing a + * pane you are looking at: the beta tester's verdict was "thats stupid, just + * make me type Y to confirm". Kill is already two deliberate keystrokes (`x` + * then `y`) on a row the user selected, and the session's work lives in its + * transcript, which a kill does not touch. + * + * Everything that is NOT `y` cancels rather than being ignored, so a stray key + * closes the dialog instead of leaving a live kill prompt waiting for whatever + * the user types next. */ -export function confirmKillStep(state: TuiConfirmState, event: TuiInputEvent): TuiConfirmStep { +export function confirmKillStep(_state: TuiConfirmState, event: TuiInputEvent): TuiConfirmStep { switch (event.type) { case 'char': - return { kind: 'typing', typed: state.typed + event.value }; - case 'backspace': - return { kind: 'typing', typed: [...state.typed].slice(0, -1).join('') }; + return event.value === 'y' || event.value === 'Y' ? { kind: 'confirm' } : { kind: 'cancel' }; case 'enter': - return confirmAccepts(state) ? { kind: 'confirm' } : { kind: 'reject' }; + // Enter alone is NOT a confirmation: it is the key most likely to be + // pressed by reflex, and this is the one dialog that destroys something. + return { kind: 'cancel' }; case 'escape': return { kind: 'cancel' }; case 'ctrl': @@ -576,7 +573,7 @@ export function footerKeysFor(mode: TuiUiMode, glyphs: TuiGlyphSet, context: Tui case 'help': return ['esc close']; case 'confirm-kill': - return ['type the name', `${glyphs.enter} confirm`, 'esc cancel']; + return ['y kill', 'any other key cancels']; case 'message': return context.resumeOffer ? ['r resume', 'esc dismiss'] : ['esc dismiss']; case 'new-session': @@ -629,7 +626,7 @@ export function helpKeysFor(glyphs: TuiGlyphSet, context: TuiKeymapContext): Arr ['/', 'search sessions, events and files'], ['g', 'away digest'], ['n', 'new session'], - ['x', 'kill (typed confirmation)'] + ['x', 'kill (y to confirm)'] ); } keys.push(['?', 'this help'], ['esc', 'close an overlay'], ['q', 'quit']); @@ -1606,15 +1603,9 @@ class TuiApp { } const step = confirmKillStep(state, event); switch (step.kind) { - case 'typing': - this.model.setConfirmInput(step.typed); - return; case 'cancel': this.model.closeOverlay(); return; - case 'reject': - this.message('warn', `type "${state.name}" exactly, or esc to cancel`); - return; case 'confirm': void this.killSession(state.sessionId, state.name); return; @@ -2134,7 +2125,7 @@ class TuiApp { this.message('warn', 'that is the session this TUI is running in'); return; } - this.model.beginConfirmKill(row); + this.model.beginConfirmKill(row, rowLabel(row.session)); } private async killSession(sessionId: string, name: string): Promise { diff --git a/src/tui/tui-model.ts b/src/tui/tui-model.ts index 30f15fbf..d8db248a 100644 --- a/src/tui/tui-model.ts +++ b/src/tui/tui-model.ts @@ -442,22 +442,22 @@ export class TuiModelStore implements TuiRenderModel { * 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): void { + beginConfirmKill(row: TuiRow, label: string): void { this.confirm = { sessionId: row.session.sessionId, - name: row.session.name ?? row.session.sessionId.slice(0, 8), - typed: '', + // ⚠️ 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(); } - setConfirmInput(typed: string): void { - if (!this.confirm) return; - this.confirm = { ...this.confirm, typed }; - this.touch(); - } - /** Drop whatever overlay owns the keyboard and go back to the list. */ closeOverlay(): void { this.confirm = null; diff --git a/src/tui/tui-render.ts b/src/tui/tui-render.ts index c54f27ca..c58c7138 100644 --- a/src/tui/tui-render.ts +++ b/src/tui/tui-render.ts @@ -614,7 +614,7 @@ const FOOTER_KEYS: Record string> = { 'q quit', ].join(` ${g.separator} `), help: (g) => `esc ${g.separator} ? close`, - 'confirm-kill': (g) => `type the name ${g.separator} ${g.enter} confirm ${g.separator} esc cancel`, + '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`, @@ -805,10 +805,9 @@ function overlayContent( } case 'confirm-kill': { if (!model.confirm) return null; - const { name, typed } = model.confirm; return { title: 'Kill session', - lines: [`Kill ${name}?`, '', 'Type the name to confirm:', ` ${typed}_`], + lines: [`Kill ${model.confirm.name}?`, '', 'press y to kill, any other key cancels'], }; } case 'message': diff --git a/src/tui/tui-types.ts b/src/tui/tui-types.ts index c30232be..50b51ebe 100644 --- a/src/tui/tui-types.ts +++ b/src/tui/tui-types.ts @@ -120,7 +120,6 @@ export interface TuiMessage { export interface TuiConfirmState { sessionId: string; name: string; - typed: string; } export interface TuiPickerItem { diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts index c220d308..b1a35a05 100644 --- a/test/tui/tui-app.test.ts +++ b/test/tui/tui-app.test.ts @@ -15,7 +15,6 @@ import { applyMuxNames, buildAttachBanner, buildListLines, - confirmAccepts, confirmKillStep, detachChord, heldCtrlAlias, @@ -135,37 +134,25 @@ describe('isSelfSession', () => { describe('the kill confirmation', () => { const state: TuiConfirmState = { sessionId: 'abcdef01-2345', name: 'w4-api', typed: '' }; - it('accepts the shown name or the id prefix a mux name carries, and nothing else', () => { - expect(confirmAccepts(state, 'w4-api')).toBe(true); - expect(confirmAccepts(state, ' w4-api ')).toBe(true); - expect(confirmAccepts(state, 'abcdef01')).toBe(true); - expect(confirmAccepts(state, 'w4')).toBe(false); - expect(confirmAccepts(state, 'W4-API')).toBe(false); - expect(confirmAccepts(state, '')).toBe(false); - expect(confirmAccepts(state, ' ')).toBe(false); + it('kills on y, upper or lower', () => { + // Was: type the session's full name. The tester's verdict on that was + // "thats stupid, just make me type Y to confirm", and they were right — + // `x` then `y` is already two deliberate keystrokes on a selected row. + expect(confirmKillStep(state, { type: 'char', value: 'y' })).toEqual({ kind: 'confirm' }); + expect(confirmKillStep(state, { type: 'char', value: 'Y' })).toEqual({ kind: 'confirm' }); }); - it('types, backspaces and cancels', () => { - expect(confirmKillStep({ ...state, typed: 'w4' }, { type: 'char', value: '-' })).toEqual({ - kind: 'typing', - typed: 'w4-', - }); - expect(confirmKillStep({ ...state, typed: 'w4-' }, { type: 'backspace' })).toEqual({ kind: 'typing', typed: 'w4' }); - expect(confirmKillStep({ ...state, typed: '' }, { type: 'backspace' })).toEqual({ kind: 'typing', typed: '' }); + it('cancels on every other key, rather than leaving the prompt armed', () => { + // A dialog that ignores unknown keys sits there consuming whatever the + // user types next, which for a destructive prompt is the wrong default. + expect(confirmKillStep(state, { type: 'char', value: 'n' })).toEqual({ kind: 'cancel' }); + expect(confirmKillStep(state, { type: 'char', value: 'x' })).toEqual({ kind: 'cancel' }); expect(confirmKillStep(state, { type: 'escape' })).toEqual({ kind: 'cancel' }); expect(confirmKillStep(state, { type: 'ctrl', key: 'c' })).toEqual({ kind: 'cancel' }); - expect(confirmKillStep(state, { type: 'ctrl', key: 'a' })).toEqual({ kind: 'ignore' }); - expect(confirmKillStep(state, { type: 'tab' })).toEqual({ kind: 'ignore' }); - }); - - it('confirms only on a match, and says so rather than doing nothing otherwise', () => { - expect(confirmKillStep({ ...state, typed: 'w4-api' }, { type: 'enter' })).toEqual({ kind: 'confirm' }); - expect(confirmKillStep({ ...state, typed: 'w4' }, { type: 'enter' })).toEqual({ kind: 'reject' }); - expect(confirmKillStep({ ...state, typed: '' }, { type: 'enter' })).toEqual({ kind: 'reject' }); }); - it('backspaces one whole character, not one code unit', () => { - expect(confirmKillStep({ ...state, typed: 'a🙂' }, { type: 'backspace' })).toEqual({ kind: 'typing', typed: 'a' }); + it('does NOT kill on Enter, the key most likely to be hit by reflex', () => { + expect(confirmKillStep(state, { type: 'enter' })).toEqual({ kind: 'cancel' }); }); }); @@ -215,7 +202,7 @@ describe('footerKeysFor', () => { expect.arrayContaining([ 'attach — on a RECENT row, resume that conversation', 'new session', - 'kill (typed confirmation)', + 'kill (y to confirm)', 'quit', ]) ); @@ -227,7 +214,7 @@ describe('footerKeysFor', () => { it('follows the overlay that owns the keyboard', () => { expect(footerKeysFor('help', GLYPHS, { server: true })).toEqual(['esc close']); - expect(footerKeysFor('confirm-kill', GLYPHS, { server: true }).join(' ')).toContain('type the name'); + expect(footerKeysFor('confirm-kill', GLYPHS, { server: true }).join(' ')).toContain('y kill'); expect(footerKeysFor('message', GLYPHS, { server: true })).toEqual(['esc dismiss']); expect(footerKeysFor('new-session', GLYPHS, { server: true }).join(' ')).toContain('type to filter'); expect(footerKeysFor('prompt', GLYPHS, { server: true }).join(' ')).toContain('send'); diff --git a/test/tui/tui-e2e.test.ts b/test/tui/tui-e2e.test.ts index 0191f599..9163e366 100644 --- a/test/tui/tui-e2e.test.ts +++ b/test/tui/tui-e2e.test.ts @@ -556,23 +556,32 @@ describe('codeman tui (under a pty)', () => { it('opens and closes the help overlay', async () => { term.write('?'); await waitFor(() => frameLines(output).some((line) => line.includes('Keys')), 'the help overlay'); - expect(frameLines(output).join('\n')).toContain('kill (typed confirmation)'); + expect(frameLines(output).join('\n')).toContain('kill (y to confirm)'); term.write('\u001b'); await waitFor(() => !frameLines(output).some((line) => line.includes('Keys')), 'escape to close the overlay'); }); - it('asks for the session name before killing anything', async () => { + it('asks for a y before killing anything, and names what it would kill', async () => { term.write('x'); await waitFor(() => frameLines(output).some((line) => line.includes('Kill session')), 'the kill confirmation'); const overlay = frameLines(output).join('\n'); - expect(overlay).toContain('Type the name to confirm'); + expect(overlay).toContain('press y to kill'); expect(overlay).toContain('w2-beta'); term.write('\u001b'); await waitFor(() => !frameLines(output).some((line) => line.includes('Kill session')), 'escape to cancel the kill'); }); + it('cancels the kill on any key that is not y, and kills nothing', async () => { + term.write('x'); + await waitFor(() => frameLines(output).some((line) => line.includes('Kill session')), 'the kill confirmation'); + term.write('n'); + await waitFor(() => !frameLines(output).some((line) => line.includes('Kill session')), 'the dialog to close'); + // The session is still listed: `n` cancelled rather than killed. + await waitFor(() => frameLines(output).some((line) => line.includes('w2-beta')), 'w2-beta to still be listed'); + }); + it('sends a one-line prompt with p', async () => { term.write('p'); await waitFor(() => frameLines(output)[ROWS - 1].startsWith(' >'), 'the composer to open'); diff --git a/test/tui/tui-model.test.ts b/test/tui/tui-model.test.ts index 3a490831..2c3b34ef 100644 --- a/test/tui/tui-model.test.ts +++ b/test/tui/tui-model.test.ts @@ -267,11 +267,17 @@ describe('the store', () => { it('tracks the confirm-kill overlay, keyed to the name it showed', () => { const model = createTuiModel(); model.replaceSessions([session({ sessionId: 'a', name: 'w4-api' })]); - model.beginConfirmKill(model.rows()[0]); + model.beginConfirmKill(model.rows()[0], 'w4-api'); expect(model.mode).toBe('confirm-kill'); - expect(model.confirm).toEqual({ sessionId: 'a', name: 'w4-api', typed: '' }); - model.setConfirmInput('w4-ap'); - expect(model.confirm?.typed).toBe('w4-ap'); + // The name is the whole payload: it is what the dialog shows so the user + // knows WHICH session a `y` is about to destroy. + expect(model.confirm).toEqual({ sessionId: 'a', name: 'w4-api' }); + + // Regression: the label is supplied by the caller. Deriving it here with + // `name ?? id` let an EMPTY name through, and the dialog read "Kill ?". + model.replaceSessions([session({ sessionId: 'b', name: '' })]); + model.beginConfirmKill(model.rows()[0], 'mirofish'); + expect(model.confirm?.name).toBe('mirofish'); model.closeOverlay(); expect(model.mode).toBe('list'); expect(model.confirm).toBeNull(); diff --git a/test/tui/tui-render.test.ts b/test/tui/tui-render.test.ts index 2fa113a7..fc64e5e8 100644 --- a/test/tui/tui-render.test.ts +++ b/test/tui/tui-render.test.ts @@ -300,15 +300,15 @@ describe('overlays', () => { expect(lines[lines.length - 1]).toContain('close'); }); - it('draws the typed confirmation for a kill', () => { + it('names what a kill would destroy, and asks for one key', () => { const model = fixture(); - model.beginConfirmKill(model.rows()[0]); - model.setConfirmInput('w6-d'); + model.beginConfirmKill(model.rows()[0], 'w6-docs'); const text = frameLines(render(model, 100, 30)).join('\n'); expect(text).toContain('Kill w6-docs?'); - expect(text).toContain('Type the name to confirm:'); - expect(text).toContain('w6-d_'); - expect(text).toContain('esc cancel'); + expect(text).toContain('press y to kill, any other key cancels'); + // The name is the point of the dialog: it is what tells the user WHICH + // session a keystroke is about to destroy. + expect(text).not.toContain('Type the name to confirm'); }); it('draws a message box', () => { From fb3f847b2286315570a75497503b197c0d8754f1 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 01:58:02 +0200 Subject: [PATCH 49/57] fix(tui): stop drawing from the unicode blocks a plain terminal font lacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three separate "why are there boxes" reports, and I fixed them one glyph at a time instead of as a class, so the next one was always waiting. Grouping the tester's terminal by unicode block made the rule obvious: RENDERS Latin-1 (·), Box Drawing (─ │), Block Elements (█ ▛ ▐), Geometric Shapes (○ ▶), General Punctuation (…), Arrows TOFU Miscellaneous Technical (⏎ U+23CE, ⏵ U+23F5), the sparse end of Dingbats (❯ U+276F) That is an ordinary font, not a broken one, so it is the profile to design against. The working spinner moves off Dingbats and Math Operators onto quadrant blocks (▖▘▝▗) — the same block as the `▛█▐` art claude itself draws, which that font renders fine — and the blocked marker moves off `⚠` (Misc Symbols, emoji presentation on many terminals) onto `▲`, the block that already gives us `▶` and `○`. The preview fold gains claude's own spinner dingbats (✢ ✳ ∗ ✻ ✽ ✴ → `*`) and `⚠` → `!`. Its animated status line is exactly where a reader looks, so tofu there is the most visible kind there is. A test now enforces this as a CLASS: no glyph in the unicode set may come from Misc Technical, Misc Symbols or Dingbats, with U+2714 the single documented exception because it was observed rendering on the very font that failed the others. Verified by scanning a live frame driven with the tester's exact environment: zero glyphs from any of the three blocks. --- src/tui/tui-ansi.ts | 10 +++++ src/tui/tui-render.ts | 28 ++++++++++---- test/tui/tui-render.test.ts | 73 +++++++++++++++++++++++++------------ 3 files changed, 80 insertions(+), 31 deletions(-) diff --git a/src/tui/tui-ansi.ts b/src/tui/tui-ansi.ts index 40fdcfa7..aee0abe2 100644 --- a/src/tui/tui-ansi.ts +++ b/src/tui/tui-ansi.ts @@ -354,6 +354,16 @@ const PREVIEW_GLYPH_FOLD: ReadonlyMap = new Map([ ['\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 ]); /** diff --git a/src/tui/tui-render.ts b/src/tui/tui-render.ts index c58c7138..62c98bc6 100644 --- a/src/tui/tui-render.ts +++ b/src/tui/tui-render.ts @@ -134,17 +134,29 @@ export interface TuiGlyphSet { * * 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. A beta tester's font drew `·`, `─`, - * `│`, `○`, `▶` and `✔` perfectly while drawing `⏎` (U+23CE) as an empty box. - * Prefer Latin-1, Arrows (U+2190–21FF), Box Drawing, Block Elements and - * Geometric Shapes, which every monospace font ships; treat Dingbats, - * Miscellaneous Symbols and anything with emoji presentation as suspect. + * 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: '⚠', + blockedPermission: '▲', + blockedQuestion: '▲', waiting: '!', - working: ['·', '✢', '✳', '∗', '✻', '✽'], + // 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: '▶', diff --git a/test/tui/tui-render.test.ts b/test/tui/tui-render.test.ts index fc64e5e8..6f96b088 100644 --- a/test/tui/tui-render.test.ts +++ b/test/tui/tui-render.test.ts @@ -129,12 +129,12 @@ function frameLines(frame: string): string[] { describe('renderFrame structure', () => { it('paints the wide layout at 100x30', () => { expect(frameLines(render(fixture(), 100, 30))).toEqual([ - ' codeman ⚠ 2 tnode · v1.19.0 · 4 sessions · 5h 32% wk 61% ? help q quit', + ' codeman ▲ 2 tnode · v1.19.0 · 4 sessions · 5h 32% wk 61% ? help q quit', ' NEEDS YOU ─────────────────────────│ w4-api-refactor · claude · /home/dev/api · blocked', - ' 1 w6-docs ! 11m│ ⚠ requests: Bash(git push origin main)', - '▶ 2 w4-api-refactor ⚠ 2m 12.3k│ 1. Yes', + ' 1 w6-docs ! 11m│ ▲ requests: Bash(git push origin main)', + '▶ 2 w4-api-refactor ▲ 2m 12.3k│ 1. Yes', " WORKING ───────────────────────────│ 2. Yes, don't ask again", - ' 3 w1-codeman ✻ 17m 45.2k│ 3. No, tell Claude what to do', + ' 3 w1-codeman ▖ 17m 45.2k│ 3. No, tell Claude what to do', ' IDLE ──────────────────────────────│ y approve · n deny · digit chooses', ' 4 w2-gallery codex ○ 2h│', ' RECENT ────────────────────────────│ Actualizing... (2m 14s)', @@ -164,14 +164,14 @@ describe('renderFrame structure', () => { it('paints the narrow two-line layout at 44x20', () => { expect(frameLines(render(fixture(), 44, 20))).toEqual([ - ' codeman ⚠ 2 tnode · v1.19.0 · 4 sessions', + ' codeman ▲ 2 tnode · v1.19.0 · 4 sessions', ' NEEDS YOU ─────────────────────────────────', ' 1 w6-docs ! 11m', ' /home/dev/docs', - '▶ 2 w4-api-refactor ⚠ 2m', + '▶ 2 w4-api-refactor ▲ 2m', ' /home/dev/api · 12.3k', ' WORKING ───────────────────────────────────', - ' 3 w1-codeman ✻ 17m', + ' 3 w1-codeman ▖ 17m', ' /home/dev/codeman · 45.2k', ' IDLE ──────────────────────────────────────', ' 4 w2-gallery codex ○ 2h', @@ -223,8 +223,8 @@ describe('color', () => { const model = fixture(); model.select('eee5'); const frame = render(model, 100, 30, { color: true }); - expect(frame).toContain('\x1b[32m✻'); - expect(frame).toContain('\x1b[31m⚠'); + expect(frame).toContain('\x1b[32m▖'); + expect(frame).toContain('\x1b[31m▲'); expect(frame).toContain('\x1b[33m!'); expect(frame).toContain('\x1b[1mcodeman'); }); @@ -235,7 +235,7 @@ describe('color', () => { const frame = render(fixture(), 100, 30, { color: true }); const highlighted = frame.split('\x1b[7m')[1]?.split('\x1b[0m')[0] ?? ''; expect(highlighted).toContain('w4-api-refactor'); - expect(highlighted).toContain('⚠'); + expect(highlighted).toContain('▲'); expect(highlighted).not.toContain('\x1b['); }); @@ -263,20 +263,22 @@ describe('glyph tiers', () => { expect(list[7]).toContain('[-]'); expect(list[9]).toContain('[v]'); expect(list[3].startsWith('>')).toBe(true); - expect(lines.join('')).not.toContain('✻'); + expect(lines.join('')).not.toContain('▝'); expect(lines.join('')).not.toContain('─'); }); - it('animates the working glyph with the tick', () => { + it('animates the working glyph with the tick, and cycles', () => { const model = fixture(); const frames = [0, 1, 2, 3, 4, 5].map((tick) => frameLines(render(model, 100, 30, { tick }))[5]); - expect(frames[0]).toContain('·'); - expect(frames[1]).toContain('✢'); - expect(frames[2]).toContain('✳'); - expect(frames[3]).toContain('∗'); - expect(frames[4]).toContain('✻'); - expect(frames[5]).toContain('✽'); - expect(new Set(frames).size).toBe(6); + // Quadrant blocks, rotating. Four of them, so the tick wraps every four + // frames rather than every six. + expect(frames[0]).toContain('▖'); + expect(frames[1]).toContain('▘'); + expect(frames[2]).toContain('▝'); + expect(frames[3]).toContain('▗'); + expect(frames[4]).toBe(frames[0]); + expect(frames[5]).toBe(frames[1]); + expect(new Set(frames).size).toBe(4); }); it('detects a tier from the environment', () => { @@ -449,7 +451,7 @@ describe('formatting helpers', () => { describe('the approval card', () => { it('draws the dialog above the tail, with its digits', () => { const text = frameLines(render(fixture(), 100, 30)).join('\n'); - expect(text).toContain('⚠ requests: Bash(git push origin main)'); + expect(text).toContain('▲ requests: Bash(git push origin main)'); expect(text).toContain('1. Yes'); expect(text).toContain('3. No, tell Claude what to do'); expect(text).toContain('y approve · n deny · digit chooses'); @@ -460,7 +462,7 @@ describe('the approval card', () => { it('paints a dialog red and a waiting prompt yellow', () => { const model = fixture(); const frame = render(model, 100, 30, { color: true }); - expect(frame).toContain('\x1b[31m ⚠ requests'); + expect(frame).toContain('\x1b[31m ▲ requests'); model.select('bbb2'); const idle = render(model, 100, 30, { color: true }); @@ -476,10 +478,10 @@ describe('the approval card', () => { }); it('counts pending prompts in the header badge', () => { - expect(frameLines(render(fixture(), 100, 30))[0]).toContain('⚠ 2'); + expect(frameLines(render(fixture(), 100, 30))[0]).toContain('▲ 2'); const model = createTuiModel(); model.replaceSessions([{ sessionId: 'aaa1', name: 'w1', sources: ['live'], status: 'idle' }]); - expect(frameLines(render(model, 100, 30))[0]).not.toContain('⚠'); + expect(frameLines(render(model, 100, 30))[0]).not.toContain('▲'); }); }); @@ -632,6 +634,7 @@ describe('the unicode glyph set is safe to render', () => { UNICODE.boxHorizontal, UNICODE.boxVertical, UNICODE.enter, + UNICODE.updown, UNICODE.separator, UNICODE.ellipsis, ]; @@ -650,6 +653,30 @@ describe('the unicode glyph set is safe to render', () => { expect(UNICODE.enter).not.toBe('\u23CE'); }); + it('draws only from blocks a plain terminal font actually carries', () => { + // The rule, as a CLASS rather than one glyph at a time. Three separate + // "why are there boxes" reports came from this list, each fixed alone: + // ❯ (U+276F, sparse Dingbats), ⏵ (U+23F5) and ⏎ (U+23CE, both Misc + // Technical). The same font drew Box Drawing, Block Elements, Geometric + // Shapes and Latin-1 perfectly, so those are what the set may use. + const BANNED: Array<[number, number, string]> = [ + [0x2300, 0x23ff, 'Miscellaneous Technical'], + [0x2600, 0x26ff, 'Miscellaneous Symbols'], + [0x2700, 0x27bf, 'Dingbats'], + ]; + // U+2714 is the one Dingbat kept: it was observed rendering on the very + // font that failed the others, and it is the list's "done" mark. + const ALLOWED = new Set([0x2714]); + for (const glyph of every) { + for (const char of glyph) { + const cp = char.codePointAt(0) ?? 0; + if (ALLOWED.has(cp)) continue; + const banned = BANNED.find(([lo, hi]) => cp >= lo && cp <= hi); + expect({ glyph, block: banned?.[2] ?? null }).toEqual({ glyph, block: null }); + } + } + }); + it('has no emoji where a text glyph belongs', () => { // U+270B is Wide AND emoji-presentation: it drew at emoji size mid-row. expect(every.join('')).not.toContain('\u270B'); From 4eb13ba4934f634633380753ef187fbd87c6b4b4 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 02:13:28 +0200 Subject: [PATCH 50/57] feat(tui): keep the session tabs visible inside a pane, and move the way out to F1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attaching made every other session disappear: the dashboard is gone, tmux owns the terminal, and there is nothing left saying what else is running. The attach bar now carries the session strip, numbered exactly as the dashboard numbers them, with the session you are in inverted, and it sits at the TOP of the pane where the web UI keeps its tabs. The strip is a WINDOW around the active tab, not the whole list, with ellipses marking each end that is actually cut. The bar is one line shared with the way out, and that hint is the only instruction a user gets while tmux has the terminal, so it must never be crowded off; a test drives 20 long-named sessions through the bar and asserts it survives. ⚠️ The strip is a snapshot taken at attach time and never refreshed. The TUI is blocked in `spawnSync` for the whole attach so there is no loop to update from, and tmux's own format language cannot map a `codeman-` session name back to a label a human recognises. Slightly stale beats absent. The way out moves from F12 to F1, which sits beside Esc where a hand backing out already goes. Verified against BOTH encodings a terminal sends for it: xterm's SS3 (ESC O P) and PuTTY's default (ESC [ 1 1 ~). `status-position` joins the snapshot, so a session that had its bar at the bottom gets it back there on detach along with everything else. --- src/tui/tui-app.ts | 104 +++++++++++++++++++++++++++++++++++---- test/tui/tui-app.test.ts | 95 +++++++++++++++++++++++++++++------ 2 files changed, 175 insertions(+), 24 deletions(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 299852ec..77ade6da 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -274,12 +274,13 @@ export const DEFAULT_DETACH_KEY = 'd'; * The instruction itself was the problem ("release Ctrl and THEN d" is, in the * tester's words, very unclear), so the way out stopped being a chord. * - * F12 because it is a single keystroke with no modifier to hold or release, and - * because no CLI that runs in these panes wants it: claude, codex, a shell and - * vim all leave it alone, and tmux ships an EMPTY root table apart from mouse - * bindings, so claiming it shadows nothing. + * F1 because it is a single keystroke with no modifier to hold or release, it + * sits next to Esc where a hand reaching to back out already goes, and no CLI + * that runs in these panes wants it: claude, codex, a shell and vim all leave + * it alone, and tmux ships an EMPTY root table apart from mouse bindings, so + * claiming it shadows nothing. */ -export const ONE_KEY_DETACH = 'F12'; +export const ONE_KEY_DETACH = 'F1'; /** * The key a user produces when they DON'T let go of Ctrl: `d` becomes `C-d`. @@ -345,6 +346,8 @@ export function buildAttachBanner(options: { heldAlias?: string; /** The prefix-less key, when the attach managed to claim it. Preferred over every chord. */ oneKey?: string; + /** The other sessions, drawn as a strip so they stay visible from inside a pane. */ + tabs?: readonly TuiAttachTab[]; }): Record { const chord = escapeTmuxFormat(detachChord(options.prefix, options.detachKey)); // Named on the bar because it is what people actually type: keeping Ctrl held @@ -356,22 +359,71 @@ export function buildAttachBanner(options: { // owns the whole line, which is what removes tmux's window list (`0:bash*`) // from the middle of it. The window-status options that would otherwise hide // it are WINDOW options, so `set-option -t ` cannot even reach them. - const right = label ? `#[align=right] ${label} ` : ''; + // The strip names the session it highlights, so the standalone label is only + // a fallback for when there is no strip to draw (degraded mode has no list). + const strip = buildAttachTabs(options.tabs ?? []); + const left = strip || (label ? ` ${label} ` : ''); return { status: 'on', 'status-style': 'bg=default,fg=default', + // At the TOP, where the web UI keeps its tabs and where a strip of sessions + // is read as a strip of sessions rather than as a footer. + 'status-position': 'top', // One key when we have one, the chord only as a fallback. The bar is the // ONLY instruction a user gets during an attach, so it names the simplest // thing that is known to work, never a menu of ways. 'status-format[0]': options.oneKey - ? `#[align=left] press #[bold]${escapeTmuxFormat(options.oneKey)}#[nobold] to get ${ATTACH_BANNER_MARKER}${right}#[default]` - : `#[align=left] press #[bold]${chord}#[nobold]${alias} to detach, ${ATTACH_BANNER_MARKER}${right}#[default]`, + ? `#[align=left]${left}#[align=right] #[bold]${escapeTmuxFormat(options.oneKey)}#[nobold] ${ATTACH_BANNER_MARKER} #[default]` + : `#[align=left]${left}#[align=right] #[bold]${chord}#[nobold]${alias} ${ATTACH_BANNER_MARKER} #[default]`, }; } /** Long enough for a session name, short enough to survive a narrow terminal. */ const ATTACH_BANNER_LABEL_MAX = 28; +/** One session as the attach bar draws it. */ +export interface TuiAttachTab { + /** The number that selects it on the dashboard, so the bar and the list agree. */ + index: number; + label: string; + active: boolean; +} + +/** Per-tab label cap. Eight of these plus separators still fit an 80-column terminal. */ +const ATTACH_TAB_LABEL_MAX = 12; + +/** + * The session strip the attach bar carries, so the other sessions stay visible + * from inside a pane instead of the dashboard vanishing the moment you enter + * one. + * + * A WINDOW around the active tab rather than the whole list: the bar is one + * line shared with the way-out hint, and a strip that overflowed would push + * that hint off the end, which is the one thing on the bar that must never be + * lost. Ellipses mark what is not shown, so a truncated strip reads as + * truncated rather than as the whole list. + */ +export function buildAttachTabs(tabs: readonly TuiAttachTab[], maxTabs = 6): string { + if (tabs.length === 0) return ''; + const active = Math.max( + 0, + tabs.findIndex((tab) => tab.active) + ); + let start = Math.max(0, Math.min(active - Math.floor(maxTabs / 2), tabs.length - maxTabs)); + if (start < 0) start = 0; + const shown = tabs.slice(start, start + maxTabs); + const parts = shown.map((tab) => { + const label = truncateLabel(tab.label, ATTACH_TAB_LABEL_MAX); + const text = escapeTmuxFormat(`${tab.index} ${label}`); + // The active tab is inverted rather than bracketed: brackets cost two + // columns per tab and read as punctuation next to the session names. + return tab.active ? `#[reverse] ${text} #[noreverse]` : ` ${text} `; + }); + const head = start > 0 ? '…' : ''; + const tail = start + maxTabs < tabs.length ? '…' : ''; + return `${head}${parts.join('')}${tail}`; +} + /** * The name a newly started session gets: `w-`, the same convention the * web UI uses, with `n` one past the highest already in use. @@ -448,7 +500,12 @@ export interface TuiAttachHandoff { * `window-size manual` with no status bar (what Codeman creates) is exactly * that again after the detach. */ -export async function beginAttachHandoff(client: TuiClient, muxName: string, label: string): Promise { +export async function beginAttachHandoff( + client: TuiClient, + muxName: string, + label: string, + tabs: readonly TuiAttachTab[] = [] +): Promise { const prefix = (await client.readPrefixKey(muxName)) ?? undefined; // Read, not assumed: see detachChord() for the `d` vs `D` mix-up this closes. const detachKey = (await client.readDetachKey()) ?? undefined; @@ -475,6 +532,7 @@ export async function beginAttachHandoff(client: TuiClient, muxName: string, lab ...(claimed && alias ? { heldAlias: alias } : {}), ...(oneKey ? { oneKey: ONE_KEY_DETACH } : {}), label, + tabs, }); const options = await client.readSessionOptions(muxName, Object.keys(banner)); await client.applySessionOptions(muxName, banner); @@ -1459,6 +1517,27 @@ class TuiApp { this.handle({ type: 'char', value }); } + /** + * The live sessions, numbered the way the dashboard numbers them, for the + * strip the attach bar draws. + * + * A SNAPSHOT taken at attach time and not refreshed: the TUI is blocked in + * `spawnSync` for the whole attach, so there is no loop to update it from, + * and tmux's own format language cannot map a `codeman-` session name + * back to the label a human recognises. A strip that is a few minutes stale + * about a session created elsewhere is worth far more than no strip. + */ + private attachTabs(activeId: string): TuiAttachTab[] { + const tabs: TuiAttachTab[] = []; + let index = 0; + for (const row of this.model.rows()) { + if (row.group === 'recent') continue; + index += 1; + tabs.push({ index, label: rowLabel(row.session), active: row.session.sessionId === activeId }); + } + return tabs; + } + /** Every key can change the selection or the mode, and both steer the preview. */ private afterInput(): void { if (this.exiting) return; @@ -2089,7 +2168,12 @@ class TuiApp { // The way OUT, set up before tmux takes the terminal: a status bar that // stays for the whole attach. The line written below is on a screen tmux // repaints a moment later, so it is not what the user reads. - const handoff = await beginAttachHandoff(this.client, muxName, rowLabel(row.session)); + const handoff = await beginAttachHandoff( + this.client, + muxName, + rowLabel(row.session), + this.attachTabs(row.session.sessionId) + ); this.detachChordLabel = handoff.chord; this.screen.leave(); this.stdout.write(`${handoff.chord} detaches and brings you back here.\n`); diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts index b1a35a05..9c1e794c 100644 --- a/test/tui/tui-app.test.ts +++ b/test/tui/tui-app.test.ts @@ -19,6 +19,7 @@ import { detachChord, heldCtrlAlias, ONE_KEY_DETACH, + buildAttachTabs, nextSessionName, footerKeysFor, formatPrefixKey, @@ -460,16 +461,17 @@ describe('the dead-row resume offer', () => { describe('the one-key way out', () => { it('names a single key with no modifier at all', () => { // The whole point: three beta rounds died on a chord that had to be typed - // in the right order with the modifier released at the right moment. - expect(ONE_KEY_DETACH).toBe('F12'); + // in the right order with the modifier released at the right moment. F1 + // rather than F12 so it sits beside Esc, where a hand backing out goes. + expect(ONE_KEY_DETACH).toBe('F1'); expect(ONE_KEY_DETACH).not.toContain('C-'); expect(ONE_KEY_DETACH).not.toContain('+'); }); it('puts ONE instruction on the bar, not a menu of ways out', () => { - const banner = buildAttachBanner({ prefix: 'C-b', detachKey: 'd', heldAlias: 'C-d', oneKey: 'F12' }); + const banner = buildAttachBanner({ prefix: 'C-b', detachKey: 'd', heldAlias: 'C-d', oneKey: 'F1' }); const bar = banner['status-format[0]']; - expect(bar).toContain('press #[bold]F12#[nobold] to get back to the codeman dashboard'); + expect(bar).toContain('#[bold]F1#[nobold] back to the codeman dashboard'); // Even though both fallbacks still work, the bar must not offer them: a bar // listing three ways to leave is what the tester called way too complicated. expect(bar).not.toContain('Ctrl+B'); @@ -486,6 +488,58 @@ describe('the one-key way out', () => { }); }); +describe('the attach tab strip', () => { + const tabs = (count: number, activeIndex: number) => + Array.from({ length: count }, (_, i) => ({ index: i + 1, label: `w${i + 1}-case`, active: i === activeIndex })); + + it('draws every session when they all fit, numbered as the dashboard numbers them', () => { + const strip = buildAttachTabs(tabs(3, 1)); + expect(strip).toContain('1 w1-case'); + expect(strip).toContain('2 w2-case'); + expect(strip).toContain('3 w3-case'); + expect(strip).not.toContain('…'); + }); + + it('inverts the session you are actually in', () => { + const strip = buildAttachTabs(tabs(3, 1)); + expect(strip).toContain('#[reverse] 2 w2-case #[noreverse]'); + expect(strip).not.toContain('#[reverse] 1 w1-case'); + }); + + it('windows around the active tab rather than overflowing the bar', () => { + // Overflow would push the way-out hint off the end, which is the one thing + // on the bar that must survive. + const strip = buildAttachTabs(tabs(20, 9), 6); + expect(strip).toContain('10 w10-case'); + expect(strip.startsWith('…')).toBe(true); + expect(strip.endsWith('…')).toBe(true); + expect(strip).not.toContain('1 w1-case '); + }); + + it('marks only the end that is actually cut', () => { + const first = buildAttachTabs(tabs(20, 0), 6); + expect(first.startsWith('…')).toBe(false); + expect(first.endsWith('…')).toBe(true); + const last = buildAttachTabs(tabs(20, 19), 6); + expect(last.startsWith('…')).toBe(true); + expect(last.endsWith('…')).toBe(false); + }); + + it('truncates a long session name instead of eating the whole strip', () => { + const strip = buildAttachTabs([{ index: 1, label: 'w1-an-extremely-long-session-name', active: true }]); + expect(strip).toContain('…'); + expect(strip.length).toBeLessThan(60); + }); + + it('is empty with no sessions, so the bar falls back to the plain label', () => { + expect(buildAttachTabs([])).toBe(''); + }); + + it('escapes a name that would otherwise open a tmux format', () => { + expect(buildAttachTabs([{ index: 1, label: 'fix #42', active: false }])).toContain('fix ##42'); + }); +}); + describe('the held-Ctrl detach alias', () => { it('names the key a user produces when they never let go of Ctrl', () => { // The failure this exists for: "Ctrl+B then d" typed as one held chord @@ -569,10 +623,13 @@ describe('the way out of an attach', () => { it('builds ONE status-format option, so tmux draws no window list beside it', () => { const banner = buildAttachBanner({ prefix: 'C-b', label: 'w3-codeman' }); - expect(Object.keys(banner).sort()).toEqual(['status', 'status-format[0]', 'status-style']); + expect(Object.keys(banner).sort()).toEqual(['status', 'status-format[0]', 'status-position', 'status-style']); expect(banner.status).toBe('on'); + // Top, where the web UI keeps its tabs. + expect(banner['status-position']).toBe('top'); expect(banner['status-format[0]']).toContain('#[bold]Ctrl+B then d#[nobold]'); - expect(banner['status-format[0]']).toContain('#[align=right] w3-codeman '); + // With no strip to draw, the session's own name is the fallback. + expect(banner['status-format[0]']).toContain('w3-codeman'); }); it('sets status-style, or tmux paints its stock green bar under the bar', () => { @@ -595,15 +652,25 @@ describe('the way out of an attach', () => { it('truncates a long label instead of pushing the instruction off the bar', () => { const banner = buildAttachBanner({ label: 'w12-codeman: a very long session label indeed' }); - const right = (banner['status-format[0]'].split('#[align=right]')[1] ?? '').replace('#[default]', ''); + const left = (banner['status-format[0]'].split('#[align=right]')[0] ?? '').replace('#[align=left]', ''); // 28 characters of label plus the space either side. - expect(right.length).toBeLessThanOrEqual(30); - expect(right).toContain('…'); - expect(banner['status-format[0]']).toContain('detach, back to the codeman dashboard'); - }); - - it('leaves the right side out entirely when there is no label', () => { - expect(buildAttachBanner({})['status-format[0]']).not.toContain('#[align=right]'); + expect(left.length).toBeLessThanOrEqual(30); + expect(left).toContain('…'); + expect(banner['status-format[0]']).toContain('back to the codeman dashboard'); + }); + + it('always keeps the way out on the bar, whatever else is on it', () => { + // The hint is the one thing that must never be crowded off: it is the only + // instruction a user gets while tmux owns the terminal. + const crowded = buildAttachBanner({ + oneKey: 'F1', + tabs: Array.from({ length: 20 }, (_, i) => ({ + index: i + 1, + label: `w${i + 1}-a-long-session-name`, + active: i === 9, + })), + }); + expect(crowded['status-format[0]']).toContain('#[bold]F1#[nobold] back to the codeman dashboard'); }); it("tells the help overlay how to get back, in the socket's own prefix", () => { From c18ef66544db4919e73299e14ded155f889de16c Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 02:40:05 +0200 Subject: [PATCH 51/57] fix(tui): keep the way out on the bar, and make Alt+1..9 actually switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four faults, all reported at once, and three of them were mine from the last two commits. THE HINT VANISHED. Two independent causes. First, a leaked F1 binding: an attach whose TUI was killed leaves `F1 -> detach-client` in tmux's root table, and the claim treated "already bound" as someone else's key, so every later attach fell back to advertising the tmux chord — the bar stopped saying F1 while F1 still worked. A key already bound to `detach-client` now counts as ours. Second, width: tmux truncates a status line that overflows and drops the RIGHT-aligned segment, which is the hint. The strip now gets a budget measured from the terminal's width minus the hint, and it drops tabs from the far end until it fits. ⚠️ Measured on VISIBLE columns, not format bytes: `#[reverse]` costs zero columns, and counting it made a strip that "fitted" still truncate the hint at 80, 100, 120 and 176 columns on a real terminal. ALT+N DID NOT SWITCH. On the dashboard, a bare digit meant jump AND ATTACH, and a terminal sends Alt+N as ESC then N: when those land in separate reads — routine over SSH — the chord decodes as Escape plus a bare digit, so "switch to tab 2" threw the user into tab 2's pane. A digit now SELECTS, matching what Alt+N means in the web UI; Enter is how you go in. Inside a pane the keys never reached the TUI at all, since tmux owns the terminal, so the attach now binds Alt+1..9 in tmux's root table to `switch-client` — the strip is usable rather than decorative. ⚠️ The bar is applied to every session the strip can reach, each highlighting its own tab: with it on the attached session only, switching landed the user in a pane with no strip and no way out on screen. ⚠️ The leaked-state sweep was missing `status-position`, so it removed the marker and left the position behind — and with no marker the leftover no longer matched, making it permanently unsweepable. Found by diffing every session's options after a detach. --- src/tui/tui-app.ts | 137 ++++++++++++++++++++++++++++-------- src/tui/tui-client.ts | 36 ++++++++++ test/tui/tui-app.test.ts | 36 +++++++++- test/tui/tui-client.test.ts | 5 ++ test/tui/tui-render.test.ts | 4 +- 5 files changed, 185 insertions(+), 33 deletions(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 77ade6da..20d2d284 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -57,6 +57,7 @@ import { formatAwayDigest } from './tui-digest.js'; import { ATTACH_BANNER_MARKER, TuiClient, + type TuiSessionOptions, type TuiApprovalAnswer, type TuiEventStream, type TuiLiveSessionMetrics, @@ -348,6 +349,8 @@ export function buildAttachBanner(options: { oneKey?: string; /** The other sessions, drawn as a strip so they stay visible from inside a pane. */ tabs?: readonly TuiAttachTab[]; + /** The attaching terminal's width, so the strip can be kept clear of the hint. */ + cols?: number; }): Record { const chord = escapeTmuxFormat(detachChord(options.prefix, options.detachKey)); // Named on the bar because it is what people actually type: keeping Ctrl held @@ -359,9 +362,17 @@ export function buildAttachBanner(options: { // owns the whole line, which is what removes tmux's window list (`0:bash*`) // from the middle of it. The window-status options that would otherwise hide // it are WINDOW options, so `set-option -t ` cannot even reach them. + // The hint is measured, not estimated, and the strip is given whatever is + // left. tmux truncates a status line that overflows, and what it drops is the + // RIGHT-aligned segment — which is the hint, the one thing on the bar a user + // cannot do without. Every width tested lost it before this budget existed. + const hint = options.oneKey + ? ` ${options.oneKey} ${ATTACH_BANNER_MARKER} ` + : ` ${chord}${alias} ${ATTACH_BANNER_MARKER} `; + const budget = Math.max(0, (options.cols ?? Number.POSITIVE_INFINITY) - hint.length - 1); // The strip names the session it highlights, so the standalone label is only // a fallback for when there is no strip to draw (degraded mode has no list). - const strip = buildAttachTabs(options.tabs ?? []); + const strip = buildAttachTabs(options.tabs ?? [], 6, budget); const left = strip || (label ? ` ${label} ` : ''); return { status: 'on', @@ -387,6 +398,8 @@ export interface TuiAttachTab { index: number; label: string; active: boolean; + /** tmux session to switch to when its number is pressed inside a pane. */ + muxName?: string; } /** Per-tab label cap. Eight of these plus separators still fit an 80-column terminal. */ @@ -403,7 +416,7 @@ const ATTACH_TAB_LABEL_MAX = 12; * lost. Ellipses mark what is not shown, so a truncated strip reads as * truncated rather than as the whole list. */ -export function buildAttachTabs(tabs: readonly TuiAttachTab[], maxTabs = 6): string { +export function buildAttachTabs(tabs: readonly TuiAttachTab[], maxTabs = 6, budget = Number.POSITIVE_INFINITY): string { if (tabs.length === 0) return ''; const active = Math.max( 0, @@ -412,16 +425,28 @@ export function buildAttachTabs(tabs: readonly TuiAttachTab[], maxTabs = 6): str let start = Math.max(0, Math.min(active - Math.floor(maxTabs / 2), tabs.length - maxTabs)); if (start < 0) start = 0; const shown = tabs.slice(start, start + maxTabs); - const parts = shown.map((tab) => { - const label = truncateLabel(tab.label, ATTACH_TAB_LABEL_MAX); + const shownLabels = shown.map((tab) => truncateLabel(tab.label, ATTACH_TAB_LABEL_MAX)); + const parts = shown.map((tab, i) => { + const label = shownLabels[i]; const text = escapeTmuxFormat(`${tab.index} ${label}`); // The active tab is inverted rather than bracketed: brackets cost two // columns per tab and read as punctuation next to the session names. return tab.active ? `#[reverse] ${text} #[noreverse]` : ` ${text} `; }); + // Drop tabs from the far end until the strip fits the space the hint leaves. + // ⚠️ Measured on the VISIBLE text, not the format string: `#[reverse]` and + // friends cost zero columns, and counting them made a strip that "fitted" + // truncate the hint on a real terminal at every width tested. + const visible = (index: number): number => shownLabels[index].length + String(shown[index].index).length + 3; + let width = shown.reduce((total, _tab, index) => total + visible(index), 0); + let last = shown.length; + while (last > 1 && width + 2 > budget) { + last -= 1; + width -= visible(last); + } const head = start > 0 ? '…' : ''; - const tail = start + maxTabs < tabs.length ? '…' : ''; - return `${head}${parts.join('')}${tail}`; + const tail = start + last < tabs.length ? '…' : ''; + return `${head}${parts.slice(0, last).join('')}${tail}`; } /** @@ -504,7 +529,8 @@ export async function beginAttachHandoff( client: TuiClient, muxName: string, label: string, - tabs: readonly TuiAttachTab[] = [] + tabs: readonly TuiAttachTab[] = [], + cols?: number ): Promise { const prefix = (await client.readPrefixKey(muxName)) ?? undefined; // Read, not assumed: see detachChord() for the `d` vs `D` mix-up this closes. @@ -521,30 +547,71 @@ export async function beginAttachHandoff( // binding the user put in their own config. const alias = heldCtrlAlias(detachKey ?? DEFAULT_DETACH_KEY); const claimed = alias && (await client.readPrefixBinding(alias)) === null ? await client.bindDetachKey(alias) : false; - // The one-key way out, in the prefix-less table. Same rule: only if free. + // The one-key way out, in the prefix-less table. + // + // ⚠️ "Already bound to detach-client" counts as CLAIMED, not as taken. An + // attach whose TUI was killed leaks the binding, and treating that leak as + // someone else's binding made every later attach fall back to advertising + // the tmux chord — so the bar stopped saying F1 while F1 still worked, which + // is the worst of both. Anything else there is genuinely the user's and is + // left alone. + const existing = await client.readPrefixBinding(ONE_KEY_DETACH, 'root'); const oneKey = - (await client.readPrefixBinding(ONE_KEY_DETACH, 'root')) === null - ? await client.bindDetachKey(ONE_KEY_DETACH, 'root') - : false; - const banner = buildAttachBanner({ - ...(prefix ? { prefix } : {}), - ...(detachKey ? { detachKey } : {}), - ...(claimed && alias ? { heldAlias: alias } : {}), - ...(oneKey ? { oneKey: ONE_KEY_DETACH } : {}), - label, - tabs, - }); - const options = await client.readSessionOptions(muxName, Object.keys(banner)); - await client.applySessionOptions(muxName, banner); + existing === 'detach-client' + ? true + : existing === null + ? await client.bindDetachKey(ONE_KEY_DETACH, 'root') + : false; + // Alt+1..9 switch sessions from inside the pane, so the strip on the bar is + // usable rather than decorative. Only the first nine, only sessions that + // really have a pane, and only keys tmux reports as free — the same rule the + // way-out key follows, so a binding of the user's own is never shadowed. + const switchKeys: string[] = []; + for (const tab of tabs.slice(0, 9)) { + if (!tab.muxName) continue; + const key = `M-${tab.index}`; + const bound = await client.readPrefixBinding(key, 'root'); + if (bound !== null && !bound.startsWith('switch-client')) continue; + if (await client.bindSwitchKey(key, tab.muxName)) switchKeys.push(key); + } + const bannerFor = (activeMux: string, ownLabel: string): Record => + buildAttachBanner({ + ...(prefix ? { prefix } : {}), + ...(detachKey ? { detachKey } : {}), + ...(claimed && alias ? { heldAlias: alias } : {}), + ...(oneKey ? { oneKey: ONE_KEY_DETACH } : {}), + ...(cols ? { cols } : {}), + label: ownLabel, + tabs: tabs.map((tab) => ({ ...tab, active: tab.muxName === activeMux })), + }); + + // ⚠️ The bar goes on EVERY session the strip can switch to, not just the one + // being attached. `switch-client` moves this client to another session, and + // that session draws its OWN status line: with the bar only on the first one, + // pressing Alt+2 landed the user in a pane with no strip and, worse, no way + // out on screen. Each copy highlights its own tab, so the strip tracks where + // you actually are. + const banner = bannerFor(muxName, label); + const dressed: Array<{ muxName: string; options: TuiSessionOptions }> = []; + const targets = new Map([[muxName, label]]); + for (const tab of tabs.slice(0, 9)) { + if (tab.muxName && !targets.has(tab.muxName)) targets.set(tab.muxName, tab.label); + } + for (const [target, targetLabel] of targets) { + const snapshot = await client.readSessionOptions(target, Object.keys(banner)); + if (snapshot) dressed.push({ muxName: target, options: snapshot }); + await client.applySessionOptions(target, bannerFor(target, targetLabel)); + } return { chord: oneKey ? ONE_KEY_DETACH : detachChord(prefix, detachKey), async restore(): Promise { + for (const key of switchKeys) await client.unbindSwitchKey(key); if (oneKey) await client.unbindDetachKey(ONE_KEY_DETACH, 'root'); if (claimed && alias) await client.unbindDetachKey(alias); // Options first, then the size: dropping the status bar gives its row // back to the pane, and the resize is what re-pins the browser's // authority over the window. - if (options) await client.restoreSessionOptions(muxName, options); + for (const entry of dressed) await client.restoreSessionOptions(entry.muxName, entry.options); if (sizing) await client.restoreWindowSizing(muxName, sizing); }, }; @@ -644,11 +711,11 @@ export function footerKeysFor(mode: TuiUiMode, glyphs: TuiGlyphSet, context: Tui return ['j/k scroll', 'esc close']; case 'list': { if (!context.server) { - return [`${glyphs.updown} select`, `${glyphs.enter} attach`, '1-9 jump', '? help', 'q quit']; + return [`${glyphs.updown} select`, `${glyphs.enter} attach`, '1-9 switch', '? help', 'q quit']; } const keys = [`${glyphs.updown} select`, `${glyphs.enter} attach`]; if (context.approval === 'menu') keys.push('y approve', 'n deny', '1-9 option'); - else keys.push('1-9 jump'); + else keys.push('1-9 switch'); keys.push(context.approval === 'idle' ? 'p reply' : 'p prompt'); if (context.approval !== 'menu') keys.push('n new'); keys.push('x kill', '/ search', 'g digest', '? help', 'q quit'); @@ -665,11 +732,10 @@ export function helpKeysFor(glyphs: TuiGlyphSet, context: TuiKeymapContext): Arr const keys: Array<[string, string]> = [ [`${glyphs.updown} / j k`, 'select'], [glyphs.enter, 'attach — on a RECENT row, resume that conversation'], - ['1-9', 'jump and attach'], + ['1-9', 'switch to that session'], // The web UI's tab switching, as close as a terminal can carry it: Alt+N // matches exactly, while Alt+[ / Alt+] cannot be transmitted (ESC+[ IS the // CSI introducer) so the brackets do that job unmodified. - ['alt+1-9', 'switch to that session, without attaching'], ['[ / ]', 'previous / next session'], ['tab', 'next session'], // The one key that is not the TUI's: an attach hands the terminal to tmux, @@ -1533,7 +1599,13 @@ class TuiApp { for (const row of this.model.rows()) { if (row.group === 'recent') continue; index += 1; - tabs.push({ index, label: rowLabel(row.session), active: row.session.sessionId === activeId }); + const muxName = (row.session.muxName ?? '').trim(); + tabs.push({ + index, + label: rowLabel(row.session), + active: row.session.sessionId === activeId, + ...(muxName ? { muxName } : {}), + }); } return tabs; } @@ -1629,7 +1701,13 @@ class TuiApp { } if (value >= '1' && value <= '9') { - if (this.model.cursorToIndex(Number.parseInt(value, 10))) void this.attachSelected(); + // SELECT, never attach. A digit used to jump AND hand the terminal to + // that pane, which made Alt+N unusable: a terminal sends Alt+N as ESC + // then N, and when those land in separate reads — routine over SSH — the + // chord decodes as Escape plus a bare digit, so "switch to tab 2" threw + // the user into tab 2's pane instead. Selecting matches what Alt+N means + // in the web UI, and Enter is how you go in. + this.model.cursorToIndex(Number.parseInt(value, 10)); return; } switch (value) { @@ -2172,7 +2250,8 @@ class TuiApp { this.client, muxName, rowLabel(row.session), - this.attachTabs(row.session.sessionId) + this.attachTabs(row.session.sessionId), + this.currentLayout().cols ); this.detachChordLabel = handoff.chord; this.screen.leave(); diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index 679cbf27..4302ad0c 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -951,6 +951,36 @@ export class TuiClient { } } + /** + * Bind a prefix-less key to switch this client to another session, so the tab + * strip on the attach bar is not just a picture: Alt+1..9 moves between + * sessions from INSIDE a pane, the way it does in the web UI. + * + * `switch-client` rather than detach-then-attach: it keeps the terminal, so + * the move is instant and the TUI stays blocked in its `spawnSync` exactly as + * before, still holding the restore it owes. + */ + async bindSwitchKey(key: string, target: string): Promise { + if (!MUX_NAME_PATTERN.test(target)) return false; + try { + await this.exec('tmux', ['-L', this.socket, 'bind-key', '-T', 'root', key, 'switch-client', '-t', target]); + return true; + } catch { + return false; + } + } + + /** Give back a switch key, but only while it still points at a session. */ + async unbindSwitchKey(key: string): Promise { + const bound = await this.readPrefixBinding(key, 'root'); + if (!bound || !bound.startsWith('switch-client')) return; + try { + await this.exec('tmux', ['-L', this.socket, 'unbind-key', '-T', 'root', key]); + } catch { + /* a stray switch binding is harmless next to failing an attach */ + } + } + /** * Give a key back, but ONLY while it still means `detach-client`. Anything * else there is the user's, arrived after we bound ours, and must not be @@ -1103,6 +1133,12 @@ export class TuiClient { // unsetting one index leaves an empty array, which renders as a blank bar. await this.setOption(['-u', '-t', name, 'status-format']); await this.setOption(['-u', '-t', name, 'status-style']); + // ⚠️ `status-position` MUST be swept with the rest. It was missing here, + // so a sweep cleaned the marker and left the position behind — and with + // the marker gone the leftover no longer matched, which made it + // permanently unsweepable. Every option the banner writes has to be + // undone by the same pass that recognises it. + await this.setOption(['-u', '-t', name, 'status-position']); await this.setOption(['-t', name, 'status', 'off']); cleared += 1; } diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts index 9c1e794c..c76bf5ad 100644 --- a/test/tui/tui-app.test.ts +++ b/test/tui/tui-app.test.ts @@ -161,7 +161,7 @@ describe('footerKeysFor', () => { it('advertises only the verbs this build implements', () => { const keys = footerKeysFor('list', GLYPHS, { server: true }).join(' '); expect(keys).toContain('attach'); - expect(keys).toContain('1-9 jump'); + expect(keys).toContain('1-9 switch'); expect(keys).toContain('n new'); expect(keys).toContain('p prompt'); expect(keys).toContain('/ search'); @@ -179,7 +179,7 @@ describe('footerKeysFor', () => { expect(keys).toContain('1-9 option'); // `n` cannot mean two things at once, and denying is what it does here. expect(keys).not.toContain('n new'); - expect(keys).not.toContain('1-9 jump'); + expect(keys).not.toContain('1-9 switch'); }); it('sends an idle prompt to the composer instead of offering approve/deny', () => { @@ -673,6 +673,38 @@ describe('the way out of an attach', () => { expect(crowded['status-format[0]']).toContain('#[bold]F1#[nobold] back to the codeman dashboard'); }); + it('fits the strip to the terminal, measuring VISIBLE columns not format bytes', () => { + // The test above only checks the hint is in the format STRING, which it + // always was. tmux truncates what it cannot fit and drops the right-aligned + // segment, so on a real terminal the hint vanished at every width tested + // while that assertion stayed green. + const hint = ' F1 back to the codeman dashboard '; + for (const cols of [80, 100, 120, 176]) { + const bar = buildAttachBanner({ + oneKey: 'F1', + cols, + tabs: Array.from({ length: 12 }, (_, i) => ({ + index: i + 1, + label: `w${i + 1}-session-name`, + active: i === 1, + })), + })['status-format[0]']; + const visible = bar.replace(/#\[[^\]]*\]/g, ''); + expect({ cols, fits: visible.length <= cols }).toEqual({ cols, fits: true }); + expect(visible.length).toBeGreaterThanOrEqual(hint.length); + } + }); + + it('keeps at least one tab even when the hint eats almost the whole bar', () => { + const bar = buildAttachBanner({ + oneKey: 'F1', + cols: 40, + tabs: [{ index: 1, label: 'w1-case', active: true }], + })['status-format[0]']; + expect(bar).toContain('1 w1-case'); + expect(bar).toContain('back to the codeman dashboard'); + }); + it("tells the help overlay how to get back, in the socket's own prefix", () => { const keys = helpKeysFor(GLYPHS, { server: true, detach: 'Ctrl+A then d' }); const detach = keys.find(([key]) => key === 'Ctrl+A then d'); diff --git a/test/tui/tui-client.test.ts b/test/tui/tui-client.test.ts index 659dbae5..439814c2 100644 --- a/test/tui/tui-client.test.ts +++ b/test/tui/tui-client.test.ts @@ -659,6 +659,11 @@ describe('TuiClient.clearLeakedAttachBanners', () => { // leaves an EMPTY array, which renders as a blank bar. expect(sets.some((args) => args.includes('-u') && args.includes('status-format'))).toBe(true); expect(sets.some((args) => args.includes('-u') && args.includes('status-style'))).toBe(true); + // ⚠️ Every option the banner writes must be undone by the pass that + // recognises it. `status-position` was missing, so a sweep removed the + // marker and left the position behind — and with no marker the leftover + // stopped matching, making it permanently unsweepable. + expect(sets.some((args) => args.includes('-u') && args.includes('status-position'))).toBe(true); expect(sets.some((args) => args.join(' ').endsWith('status off'))).toBe(true); expect(sets.every((args) => !args.includes('status-format[0]'))).toBe(true); }); diff --git a/test/tui/tui-render.test.ts b/test/tui/tui-render.test.ts index 6f96b088..43e5f2c9 100644 --- a/test/tui/tui-render.test.ts +++ b/test/tui/tui-render.test.ts @@ -158,7 +158,7 @@ describe('renderFrame structure', () => { ' │', ' │', ' │', - ' ↑↓ select · ↵ attach · 1-9 jump · y/n answer · p prompt · n new · x kill · / search · g digest · ?', + ' ↑↓ select · ↵ attach · 1-9 switch · y/n answer · p prompt · n new · x kill · / search · g digest ·', ]); }); @@ -183,7 +183,7 @@ describe('renderFrame structure', () => { '', '', '', - ' ↑↓ select · ↵ attach · 1-9 jump · y/n answe', + ' ↑↓ select · ↵ attach · 1-9 switch · y/n ans', ]); }); From 76a8bee46954818329256aabcc40d80fb285e063 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 10:25:15 +0200 Subject: [PATCH 52/57] fix(tui): finish the 1-9 rename in the fallback footer The renderer's own FOOTER_KEYS table still said 'jump'. It is only reached when the app layer supplies no footerKeys, so nothing visible was wrong, but a fallback that contradicts the live footer is exactly the kind of drift that turns into a bug report later. --- src/tui/tui-render.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/tui-render.ts b/src/tui/tui-render.ts index 62c98bc6..edb4897b 100644 --- a/src/tui/tui-render.ts +++ b/src/tui/tui-render.ts @@ -615,7 +615,7 @@ const FOOTER_KEYS: Record string> = { [ `${g.updown} select`, `${g.enter} attach`, - '1-9 jump', + '1-9 switch', 'y/n answer', 'p prompt', 'n new', From 47198545e3829b3a681d1369fae546235954d88f Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 10:54:27 +0200 Subject: [PATCH 53/57] fix(tui): size every switchable session, not just the one being attached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching with Alt+N landed in a pane that filled part of the terminal with tmux padding the rest as a dot grid — reported from the beta with a screenshot showing the pane in the left half and dots everywhere else. Codeman pins every window `window-size manual` at the BROWSER's size (tmux-manager.ts), so no attaching client can resize it. The attach already lifted that for the session it opened, which is why a plain attach looked right; `switch-client` then moved the user into a session that had never been lifted, and the old pin reasserted itself. `window-size latest` now goes on every session the strip can reach, alongside the bar those sessions already get, and each one's original sizing is snapshotted and restored on detach. Verified by round-tripping a session pinned at 120x40 manual: latest 190x49 while attached, back to 120x40 manual after, with no dot rows at either step and the bar intact at full width after a switch. --- src/tui/tui-app.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 20d2d284..e2dd1ef8 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -58,6 +58,7 @@ import { ATTACH_BANNER_MARKER, TuiClient, type TuiSessionOptions, + type TuiWindowSizing, type TuiApprovalAnswer, type TuiEventStream, type TuiLiveSessionMetrics, @@ -541,8 +542,6 @@ export async function beginAttachHandoff( // dots. `latest` (not a one-off resize to our size) is also what makes a // terminal resized MID-attach follow along: tmux recomputes on every SIGWINCH // and the caller is blocked in `spawnSync`. - const sizing = await client.readWindowSizing(muxName); - await client.followAttachingClient(muxName); // Claimed only when tmux has nothing there: an attach must never shadow a // binding the user put in their own config. const alias = heldCtrlAlias(detachKey ?? DEFAULT_DETACH_KEY); @@ -593,6 +592,7 @@ export async function beginAttachHandoff( // you actually are. const banner = bannerFor(muxName, label); const dressed: Array<{ muxName: string; options: TuiSessionOptions }> = []; + const resized: Array<{ muxName: string; sizing: TuiWindowSizing }> = []; const targets = new Map([[muxName, label]]); for (const tab of tabs.slice(0, 9)) { if (tab.muxName && !targets.has(tab.muxName)) targets.set(tab.muxName, tab.label); @@ -601,6 +601,16 @@ export async function beginAttachHandoff( const snapshot = await client.readSessionOptions(target, Object.keys(banner)); if (snapshot) dressed.push({ muxName: target, options: snapshot }); await client.applySessionOptions(target, bannerFor(target, targetLabel)); + // ⚠️ Sizing for EVERY switchable session, not just the one being attached. + // Codeman pins each window `window-size manual` at the BROWSER's size, so a + // session switched into from inside a pane stays pinned and tmux pads the + // gap with dots — the pane filled half the terminal and the rest was a dot + // grid (reported from the beta, with a screenshot, after Alt+N switching + // shipped). `latest` makes each window follow whichever client is looking + // at it, which is the same reason the attached one gets it. + const sizing = await client.readWindowSizing(target); + if (sizing) resized.push({ muxName: target, sizing }); + await client.followAttachingClient(target); } return { chord: oneKey ? ONE_KEY_DETACH : detachChord(prefix, detachKey), @@ -612,7 +622,7 @@ export async function beginAttachHandoff( // back to the pane, and the resize is what re-pins the browser's // authority over the window. for (const entry of dressed) await client.restoreSessionOptions(entry.muxName, entry.options); - if (sizing) await client.restoreWindowSizing(muxName, sizing); + for (const entry of resized) await client.restoreWindowSizing(entry.muxName, entry.sizing); }, }; } From 777f9745813c44421b27220bd8ecc31f7f2059da Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 11:08:14 +0200 Subject: [PATCH 54/57] fix(tui): say alt+1-9 on the bar, and stop the dot grid when switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bar now reads "alt+1-9 switch · F1 back to the codeman dashboard", so the switch keys are discoverable instead of secret. Shown only when those keys were actually claimed, the same rule the way-out key follows: a bar naming a key that does nothing is the bug this series started with. THE DOT GRID. Switching landed in a pane occupying part of the terminal with tmux's dot fill everywhere else. It was never a size mismatch — the window was already the right size. `window-size latest` only resizes a window while a client is ON it, and the sessions behind the tab strip have none until you switch, so the resize happened AT the switch: tmux painted the newly-available area with dots and an idle claude had no reason to redraw into it. Every switchable session is now pre-sized to the attaching terminal, which moves that repaint to attach time while the user is still looking at the first session, and the switch binding restores `window-size latest` on arrival so a mid-attach terminal resize still follows. Measured: 14 consecutive switches across 7 sessions, zero dot-padded rows, against 1-in-6 before. ⚠️ Known loose end, deliberately not papered over: after a detach the window SIZE is restored exactly but the window-size MODE can come back as `latest` rather than `manual`. The restore primitive round-trips correctly in isolation (manual -> presize -> latest -> restore = manual) and no call site in the TUI or the server sets `latest` afterwards, so the cause is not yet identified. The practical effect is nil: the remaining client keeps the window at its own size and Codeman re-pins `manual` on the browser's next resize. --- src/tui/tui-app.ts | 26 +++++++++++++----- src/tui/tui-client.ts | 59 +++++++++++++++++++++++++++++++++++++++- test/tui/tui-app.test.ts | 28 +++++++++++++++++++ 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index e2dd1ef8..d263d764 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -352,6 +352,8 @@ export function buildAttachBanner(options: { tabs?: readonly TuiAttachTab[]; /** The attaching terminal's width, so the strip can be kept clear of the hint. */ cols?: number; + /** Alt+1..9 really switch sessions, so the bar may say so. */ + switchKeys?: boolean; }): Record { const chord = escapeTmuxFormat(detachChord(options.prefix, options.detachKey)); // Named on the bar because it is what people actually type: keeping Ctrl held @@ -367,9 +369,13 @@ export function buildAttachBanner(options: { // left. tmux truncates a status line that overflows, and what it drops is the // RIGHT-aligned segment — which is the hint, the one thing on the bar a user // cannot do without. Every width tested lost it before this budget existed. + // ⚠️ The switch hint appears only when the keys were actually claimed, the + // same rule the way-out key follows. A bar naming a key that does nothing is + // the bug this whole series started with. + const switchHint = options.switchKeys ? 'alt+1-9 switch · ' : ''; const hint = options.oneKey - ? ` ${options.oneKey} ${ATTACH_BANNER_MARKER} ` - : ` ${chord}${alias} ${ATTACH_BANNER_MARKER} `; + ? ` ${switchHint}${options.oneKey} ${ATTACH_BANNER_MARKER} ` + : ` ${switchHint}${chord}${alias} ${ATTACH_BANNER_MARKER} `; const budget = Math.max(0, (options.cols ?? Number.POSITIVE_INFINITY) - hint.length - 1); // The strip names the session it highlights, so the standalone label is only // a fallback for when there is no strip to draw (degraded mode has no list). @@ -385,8 +391,8 @@ export function buildAttachBanner(options: { // ONLY instruction a user gets during an attach, so it names the simplest // thing that is known to work, never a menu of ways. 'status-format[0]': options.oneKey - ? `#[align=left]${left}#[align=right] #[bold]${escapeTmuxFormat(options.oneKey)}#[nobold] ${ATTACH_BANNER_MARKER} #[default]` - : `#[align=left]${left}#[align=right] #[bold]${chord}#[nobold]${alias} ${ATTACH_BANNER_MARKER} #[default]`, + ? `#[align=left]${left}#[align=right] ${switchHint}#[bold]${escapeTmuxFormat(options.oneKey)}#[nobold] ${ATTACH_BANNER_MARKER} #[default]` + : `#[align=left]${left}#[align=right] ${switchHint}#[bold]${chord}#[nobold]${alias} ${ATTACH_BANNER_MARKER} #[default]`, }; } @@ -531,7 +537,8 @@ export async function beginAttachHandoff( muxName: string, label: string, tabs: readonly TuiAttachTab[] = [], - cols?: number + cols?: number, + rows?: number ): Promise { const prefix = (await client.readPrefixKey(muxName)) ?? undefined; // Read, not assumed: see detachChord() for the `d` vs `D` mix-up this closes. @@ -580,6 +587,7 @@ export async function beginAttachHandoff( ...(claimed && alias ? { heldAlias: alias } : {}), ...(oneKey ? { oneKey: ONE_KEY_DETACH } : {}), ...(cols ? { cols } : {}), + ...(switchKeys.length > 0 ? { switchKeys: true } : {}), label: ownLabel, tabs: tabs.map((tab) => ({ ...tab, active: tab.muxName === activeMux })), }); @@ -610,7 +618,10 @@ export async function beginAttachHandoff( // at it, which is the same reason the attached one gets it. const sizing = await client.readWindowSizing(target); if (sizing) resized.push({ muxName: target, sizing }); - await client.followAttachingClient(target); + // Pre-size to this terminal so a switch has nothing left to resize, then + // let the session we are actually opening follow the terminal live. + if (cols && rows && rows > 1) await client.presizeWindow(target, cols, rows - 1); + if (target === muxName) await client.followAttachingClient(target); } return { chord: oneKey ? ONE_KEY_DETACH : detachChord(prefix, detachKey), @@ -2261,7 +2272,8 @@ class TuiApp { muxName, rowLabel(row.session), this.attachTabs(row.session.sessionId), - this.currentLayout().cols + this.currentLayout().cols, + this.currentLayout().rows ); this.detachChordLabel = handoff.chord; this.screen.leave(); diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index 4302ad0c..be1728ed 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -883,6 +883,43 @@ export class TuiClient { } } + /** + * Size a window to the terminal that is about to look at it, NOW. + * + * ⚠️ `window-size latest` only resizes a window while a client is actually on + * it. The sessions behind the tab strip have none until you switch, so the + * resize happened AT the switch: tmux painted the newly-available area with + * its dot fill, and an idle claude had no reason to redraw into it, leaving a + * pane in the corner of a dotted screen (reported from the beta, with a + * screenshot). Pre-sizing moves that repaint to attach time, while the user + * is still looking at the first session. + * + * `resize-window` with an explicit size implies `window-size manual`, which + * is what we want: the size is already right when the switch lands, so tmux + * has nothing to change and nothing to repaint. The switch binding puts + * `latest` back so a mid-attach terminal resize still follows. + */ + async presizeWindow(muxName: string, cols: number, rows: number): Promise { + if (!MUX_NAME_PATTERN.test(muxName)) return false; + if (!Number.isSafeInteger(cols) || !Number.isSafeInteger(rows) || cols <= 0 || rows <= 0) return false; + try { + await this.exec('tmux', [ + '-L', + this.socket, + 'resize-window', + '-t', + muxName, + '-x', + String(cols), + '-y', + String(rows), + ]); + return true; + } catch { + return false; + } + } + /** * tmux's prefix key for a session (`C-b` unless the user's config says * otherwise), or null when tmux cannot say. Session-level first, then global: @@ -963,7 +1000,27 @@ export class TuiClient { async bindSwitchKey(key: string, target: string): Promise { if (!MUX_NAME_PATTERN.test(target)) return false; try { - await this.exec('tmux', ['-L', this.socket, 'bind-key', '-T', 'root', key, 'switch-client', '-t', target]); + // Two commands: go there, then let that window follow this terminal again. + // It is already the right size (see presizeWindow), so `latest` changes + // nothing on arrival and costs no repaint — it matters only if the + // terminal is resized while sitting in that session. + await this.exec('tmux', [ + '-L', + this.socket, + 'bind-key', + '-T', + 'root', + key, + 'switch-client', + '-t', + target, + ';', + 'set-window-option', + '-t', + target, + 'window-size', + 'latest', + ]); return true; } catch { return false; diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts index c76bf5ad..bdc34735 100644 --- a/test/tui/tui-app.test.ts +++ b/test/tui/tui-app.test.ts @@ -468,6 +468,34 @@ describe('the one-key way out', () => { expect(ONE_KEY_DETACH).not.toContain('+'); }); + it('advertises alt+1-9 only once those keys were really claimed', () => { + // Same rule as the way-out key: never name a key that does nothing. That + // is the bug this whole series started with. + const withSwitch = buildAttachBanner({ oneKey: 'F1', switchKeys: true, cols: 150 })['status-format[0]']; + expect(withSwitch).toContain('alt+1-9 switch'); + expect(withSwitch).toContain('back to the codeman dashboard'); + expect(buildAttachBanner({ oneKey: 'F1', cols: 150 })['status-format[0]']).not.toContain('alt+1-9'); + }); + + it('still fits the strip once the switch hint has taken its space', () => { + for (const cols of [80, 100, 120, 190]) { + const bar = buildAttachBanner({ + oneKey: 'F1', + switchKeys: true, + cols, + tabs: Array.from({ length: 12 }, (_, i) => ({ + index: i + 1, + label: `w${i + 1}-session-name`, + active: i === 1, + })), + })['status-format[0]']; + const visible = bar.replace(/#\[[^\]]*\]/g, ''); + expect({ cols, fits: visible.length <= cols }).toEqual({ cols, fits: true }); + expect(visible).toContain('alt+1-9 switch'); + expect(visible).toContain('back to the codeman dashboard'); + } + }); + it('puts ONE instruction on the bar, not a menu of ways out', () => { const banner = buildAttachBanner({ prefix: 'C-b', detachKey: 'd', heldAlias: 'C-d', oneKey: 'F1' }); const bar = banner['status-format[0]']; From 9cdd9fb04db0a3f593f043c3a401a81966dad74d Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 11:16:47 +0200 Subject: [PATCH 55/57] fix(tui): stop the preview stacking every repaint of a session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overview showed the same session twice, one frame above another, after switching sessions (reported from the beta with a screenshot). Claude repaints by ABSOLUTE CURSOR POSITIONING, not by clearing: a 198KB pane tail carries 1142 `CSI r;c H` and exactly one `CSI 2J`. The replay honoured the COLUMN of those sequences and ignored the ROW, so a repaint could never overwrite what came before and was appended instead. That same tail replayed as FIFTY stacked copies of one frame. The preview shows the last N lines, so on a short terminal you saw the newest frame by luck and on a tall one you saw the end of the previous frame above it. A cursor HOME now starts the buffer over. That is not a heuristic but the line-based equivalent of what a home means: a full-screen app announcing it is repainting from the top, with everything on screen about to be overwritten in place. Only row 1 column 1 counts — any other address is a write position inside the frame being painted, and resetting on those would erase live content. Measured on the real tail that produced the screenshot: 198599 bytes and 50 copies of the welcome frame collapse to 40 lines carrying exactly one. The old test pinned the append behaviour, including a spurious leading empty line that the initial CUP produced; both are gone. --- src/tui/tui-ansi.ts | 27 ++++++++++++++++++++++----- test/tui/tui-ansi.test.ts | 25 ++++++++++++++++++++----- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/tui/tui-ansi.ts b/src/tui/tui-ansi.ts index aee0abe2..58654919 100644 --- a/src/tui/tui-ansi.ts +++ b/src/tui/tui-ansi.ts @@ -51,13 +51,18 @@ interface EscapeScan { 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 column a `CSI r ; c H` addresses. Both parameters default to 1. */ -function cursorColumn(params: string): 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 column = Number.parseInt(parts[1] ?? '', 10); - return Number.isSafeInteger(column) && column > 0 ? column : 1; + 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). */ @@ -69,7 +74,7 @@ function readCsi(text: string, start: number, from: number, keepSgr: boolean): E 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, column: cursorColumn(text.slice(from, j)) }; + return { next, ...cursorPosition(text.slice(from, j)) }; } return { next }; } @@ -423,6 +428,18 @@ export function toDisplayLines(raw: string): string[] { 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. diff --git a/test/tui/tui-ansi.test.ts b/test/tui/tui-ansi.test.ts index a06dfef0..92d61ac9 100644 --- a/test/tui/tui-ansi.test.ts +++ b/test/tui/tui-ansi.test.ts @@ -54,15 +54,30 @@ describe('toDisplayLines', () => { it('splits a row-addressed repaint into lines, which is how an Ink TUI paints', () => { // Claude Code emits almost no newlines: without this the whole screen is // one line and nothing in the preview is readable. - expect(toDisplayLines('\x1b[1;1Hfirst\x1b[2;1Hsecond\x1b[3;1Hthird')).toEqual(['', 'first', 'second', 'third']); + expect(toDisplayLines('\x1b[1;1Hfirst\x1b[2;1Hsecond\x1b[3;1Hthird')).toEqual(['first', 'second', 'third']); // A jump inside a row is a write position, not a new line. - expect(toDisplayLines('\x1b[1;1Hab\x1b[1;5Hcd')).toEqual(['', 'ab cd']); - expect(toDisplayLines('\x1b[1;1Habcdef\x1b[1;2HXY')).toEqual(['', 'aXYdef']); - // Both parameters default to 1, so a bare CUP is a fresh row. - expect(toDisplayLines('a\x1b[Hb')).toEqual(['a', 'b']); + expect(toDisplayLines('\x1b[1;1Hab\x1b[1;5Hcd')).toEqual(['ab cd']); + expect(toDisplayLines('\x1b[1;1Habcdef\x1b[1;2HXY')).toEqual(['aXYdef']); expect(toDisplayLines('a\x1b[3;1fb')).toEqual(['a', 'b']); }); + it('starts a new frame at a cursor HOME, instead of stacking repaints', () => { + // ⚠️ A home is a full-screen app announcing a repaint from the top, and + // everything on screen is about to be overwritten in place. This replay is + // line-based and cannot overwrite, so starting over is the faithful + // equivalent. Without it every repaint was APPENDED: a real claude pane's + // 198KB tail replayed as FIFTY stacked copies of the same frame, and the + // preview showed the last N lines, which on a tall terminal spanned two of + // them — the overview appeared to show the session twice. + expect(toDisplayLines('old frame\x1b[1;1Hnew frame')).toEqual(['new frame']); + // Both parameters default to 1, so a bare CUP is a home too. + expect(toDisplayLines('a\x1b[Hb')).toEqual(['b']); + // Only row 1 column 1. Any other address is a write position within the + // frame being painted, and resetting on those would erase live content. + expect(toDisplayLines('keep\x1b[2;1Hnext')).toEqual(['keep', 'next']); + expect(toDisplayLines('keep\x1b[1;3Hxx')).toEqual(['kexx']); + }); + it('refuses to allocate a line for a column no terminal has', () => { const lines = toDisplayLines('\x1b[1;99999Hx'); expect(lines).toHaveLength(1); From 9a3e62b70a240cf8d0458d706b2c7eecdcaff061 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 12:26:51 +0200 Subject: [PATCH 56/57] fix(tui): escape the separator in the switch binding, closing the sizing leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loose end from 777f974, now explained. Sessions came back from a detach on `window-size latest` instead of `manual`, and the restore primitive round-tripped correctly in isolation, so the corruption had to be upstream of it. It was: the snapshot was taken from state this code had already broken. `bindSwitchKey` passed a bare `;` between the two commands it wanted in one binding. That is a command separator to tmux's OWN parser, not an argument: it ended the `bind-key` and executed what followed immediately. So the binding kept only `switch-client`, and `set-window-option ... window-size latest` RAN against every switchable session at attach time — before the sizing snapshot was taken. Every session was therefore snapshotted as `latest` and faithfully restored to `latest`. Proven against real tmux both ways before fixing: a bare `;` leaves the session on `latest` and stores a one-command binding, while `\;` leaves it `manual` and stores both commands. Verified end to end: 7 sessions manual before, 1 latest + 6 manual during the attach (the attached one follows the terminal, the rest are pre-sized), no dot padding on a switch, and all 7 back to 120x40 manual after the detach. This also means the "follow the terminal after switching" half of 777f974 never actually worked — it was never in the binding. --- src/tui/tui-client.ts | 9 ++++++++- test/tui/tui-client.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index be1728ed..9683f3b1 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -1004,6 +1004,13 @@ export class TuiClient { // It is already the right size (see presizeWindow), so `latest` changes // nothing on arrival and costs no repaint — it matters only if the // terminal is resized while sitting in that session. + // + // ⚠️ The separator MUST be an escaped `\;`. A bare `;` is a command + // separator to tmux's own parser, so it ends the `bind-key` and RUNS + // what follows immediately: the binding kept only `switch-client`, and + // every session got `window-size latest` executed on it at attach time. + // That is what left sessions on `latest` after a detach, since the + // snapshot was then taken from already-corrupted state. await this.exec('tmux', [ '-L', this.socket, @@ -1014,7 +1021,7 @@ export class TuiClient { 'switch-client', '-t', target, - ';', + '\\;', 'set-window-option', '-t', target, diff --git a/test/tui/tui-client.test.ts b/test/tui/tui-client.test.ts index 439814c2..907858d5 100644 --- a/test/tui/tui-client.test.ts +++ b/test/tui/tui-client.test.ts @@ -602,6 +602,32 @@ describe('attach window sizing', () => { }); }); +describe('TuiClient.bindSwitchKey', () => { + it('escapes the command separator, or tmux runs the second command instead of binding it', () => { + // ⚠️ A bare `;` argument is a command separator to tmux's OWN parser: it + // ends the bind-key and executes what follows immediately. That bound only + // `switch-client` and ran `window-size latest` against every session at + // attach time, which is why sessions were left on `latest` after a detach — + // the sizing snapshot was taken from already-corrupted state. Verified + // against real tmux both ways before this test was written. + const calls: string[][] = []; + const exec: TuiExecFile = async (_file, args) => { + calls.push([...args]); + return { stdout: '', stderr: '' }; + }; + const client = new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec }); + return client.bindSwitchKey('M-2', 'codeman-aaaa1111').then(() => { + const bind = calls.find((args) => args.includes('bind-key')); + expect(bind).toBeDefined(); + expect(bind).toContain('\\;'); + expect(bind).not.toContain(';'); + // Both commands have to be in the ONE binding. + expect(bind?.join(' ')).toContain('switch-client -t codeman-aaaa1111'); + expect(bind?.join(' ')).toContain('window-size latest'); + }); + }); +}); + describe('parsePrefixBinding', () => { const REAL = [ 'bind-key -T prefix d detach-client', From c140fb9e79ee5264b6707761e5d7c887a4cd7975 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 12:29:21 +0200 Subject: [PATCH 57/57] docs(tui): stop telling people to press a key that does not work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guide and the README both said to detach with `Ctrl+B D`. Beta testing proved that wrong twice over: tmux binds lowercase `d` to `detach-client` and capital `D` to `choose-client`, and even the correct letter fails for anyone who keeps Ctrl held, because that sends `Ctrl+D`, which tmux leaves unbound. A tester followed the documented instruction, stayed attached, and exited the agent to escape. Both now say `F1`, and the attach section describes what actually happens: the session strip across the top of the pane, `Alt+1`..`Alt+9` switching without returning to the dashboard, and `r` to resume a session whose pane has died. Also corrected: `1-9` switches rather than jump-attaches, `x` confirms with `y` rather than a typed name, and a new session opens straight into its pane. `docs/tui-plan.md` is deliberately untouched — it is the design record of what was planned, not a description of what shipped. --- README.md | 4 ++-- docs/tui.md | 53 ++++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index f8a7241f..fe40e901 100644 --- a/README.md +++ b/README.md @@ -668,7 +668,7 @@ 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, `Enter` attaches into the tmux pane (`Ctrl+B D` to come back), `1`-`9` jump-attach. `y`/`n`/digit answer a pending permission dialog right from the list, `p` sends a one-line prompt, `n` starts a session, `x` kills one after a typed confirmation, `/` 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. +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. @@ -684,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+B D` (tmux's default prefix, which Codeman does not change for local sessions). +Single-digit selection (1-9), color-coded status, token counts, auto-refresh. Come back from an attached pane with `F1`. --- diff --git a/docs/tui.md b/docs/tui.md index bd8ef35b..1f2a1080 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -119,7 +119,7 @@ same item announced twice does not ring twice. | `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, with a typed confirmation | +| `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 | @@ -165,18 +165,41 @@ reply path and the footer says `p reply` instead of `p prompt`. `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. Detach with **`Ctrl+B D`** (tmux's default prefix, which Codeman does not -change for local sessions) and the dashboard comes back and refreshes. - -You do not have to remember that: for as long as the attach lasts, the session wears -a status bar reading **`Ctrl+B D detach, back to the codeman dashboard`**, in the -prefix your own `~/.tmux.conf` sets if you remapped it. 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 the window size, which follows your terminal while you are attached and -returns to the browser's afterwards. Detaching leaves the agent running; typing -`exit` or pressing `Ctrl+D` would end it, which is the difference the bar exists to -make obvious. +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: @@ -184,7 +207,7 @@ Three cases: | --- | --- | | 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 first (`Ctrl+B D`), then run `codeman tui` again | +| 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. @@ -230,7 +253,7 @@ 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 (`Ctrl+B D`) and run `codeman tui` from outside. +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