Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
"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 <noreply@anthropic.com>
…nfirm) - 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
`codeman attach <path>` 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Ctrl+J is unbindable as a result, which is worth knowing before someone tries to bind it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
`codeman tui` opens the dashboard, `codeman tui --list` prints the numbered list and exits (the `sc -l` replacement, plain when piped) and `codeman tui <n>` 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…nges 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
`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 <n>` 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) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…me 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 "<prefix> 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) <noreply@anthropic.com>
…nting 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.
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.
…fter CLI noise
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<n>-<case>` 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.
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.
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.
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 "<name>"`. 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.
…ng as boxes 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.
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.
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.
…d kill 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
codeman tui: a full-screen terminal dashboard for your sessions, for the times you are in SSH/Termius instead of a browser. The web UI remains the primary surface; this is strictly additive (barecodemanstill prints help).(The frame above is a real captured render from the E2E harness, not a mockup.)
Sessions are grouped NEEDS YOU / WORKING / IDLE / RECENT with the same status language as the web tabs and phone overview. The states come from the server (hooks, idle confirmation, approvals inbox) over the existing HTTP/SSE API. That is the difference from terminal agent multiplexers like herdr, which have to infer blocked/working from screen-scraping heuristics: Codeman already knows, so the sidebar is exact, and the TUI can go one step further and let you answer the actual permission dialog from the dashboard.
What you can do from it
Enter/1-9attach (hands the terminal totmux attach, returns to the dashboard on detach;switch-clientwhen already inside the codeman tmux socket)y/n/digits answer the selected session's pending permission/question card throughPOST /api/approvals/:id/answer(the server re-captures the pane first and 409s a stale dialog, so a keystroke can never land in the composer)psend a one-line prompt without attaching,xkill behind a typed-name confirmation,nstart a new session (case + CLI picker over quick-start)/cross-session search,gaway digest,?help; live preview pane with the session's own colors; plan-usage chip in the header; terminal bell once when a new approval arrivescodeman tui --listandcodeman tui <n>as scriptable fast paths (thesc -l/sc 2equivalents); both short-circuit before any screen setupNarrow terminals (under 72 cols, e.g. a phone SSH client) drop the preview and get a single-column layout.
NO_COLOR, non-UTF-8 glyph fallback, and a non-TTY refusal with a pointer to--listare all handled.Design
docs/tui-plan.md(committed here as the design record;docs/tui.mdis the user guide).src/tui/splits one IO layer (tui-app.tsloop +tui-client.tsAPI/SSE/degraded enumeration) from a pure, unit-tested core (tui-model,tui-layout,tui-render,tui-keys,tui-ansi,tui-composer,tui-approvals,tui-digest,tui-sse,tui-types).src/web/change is cosmetic (colored startup line and security warning).tmux -Lcallers must resolve the socket throughresolveTmuxSocketName(), now exported fromconfig/instance.ts(the TUI is the first non-server process to shell out to tmux; a hardcoded name would point a beta instance at prod's panes). CLAUDE.md's instance-isolation rule is extended accordingly.CLI polish that rode along (phase 0 of the plan)
src/cli-style.ts(semantic palette, glyphs, width-aware table, spinner, confirm) now used across the CLI.codeman doctoris colorized through the hookdependency-report.tsalways advertised, and its table is measured (the "Antigravity CLI" label no longer overflows its column).--jsonunchanged.codeman webno longer prints its "running at" line twice; the server's security warning is painted like the CLI's version (chalk degrades off-TTY, so logs stay clean).codeman web -d,web --stop, andservice install.codeman resetasks a real y/N confirmation on a TTY (--forceunchanged for scripts).codeman listandcodeman session listshare one renderer instead of drifting copies.codeman attachdescribed correctly (it posts an attachment card), detach hint corrected toCtrl+B D.test/cli-commands.test.tsnow derives its inventory from the real commander program instead of a hand-written fixture (which had drifted, down to listing atuicommand that did not exist yet).Testing
test/tui/+test/cli-style.test.ts, including an end-to-end suite that drives the real built CLI under node-pty against a fake API server (navigation, attach argv, approvals answering with recorded POST bodies, composer round-trip, search, digest, bell, screen restore on quit).npm run test:cisweep green (270 files / 5379 tests), plus typecheck, lint, format:check, check:public-assets, check:frontend-syntax, knip (only pre-existing unrelated findings remain), and a one-offnpm run buildto prove the esbuild bundle.Not in this PR (phase 3-4 follow-ups per the plan)
Mouse support, a
--pickpopup switcher inside tmux, the opt-in attach status line, OSC 9 notifications, resuming RECENT rows, and retiring/aliasing thescbash chooser (it stays untouched for now).