Files
Codeman/src/tui/tui-app.ts
T
Codeman maintainer 643edc65ca feat(tui): offer r to resume a session whose pane has died
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.
2026-08-20 01:08:49 +02:00

2450 lines
98 KiB
TypeScript

/**
* @fileoverview `codeman tui`: the full-screen dashboard plus the two
* non-interactive fast paths (`--list`, `<n>`).
*
* 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. 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.
*
* 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
*/
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 { 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';
import {
ATTACH_BANNER_MARKER,
TuiClient,
type TuiApprovalAnswer,
type TuiEventStream,
type TuiLiveSessionMetrics,
type TuiPlanUsage,
type TuiQuickStartOptions,
type TuiTmuxSession,
} from './tui-client.js';
import { createKeyParser, type TuiInputEvent, type TuiKeyParser } from './tui-keys.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,
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;
/**
* 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 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. */
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;
/**
* 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;
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';
/** One BEL when a prompt starts waiting on a human, and never for a repaint. */
const BELL = '\x07';
// ─────────────────────────────────────────────────────────────────────────────
// Attach planning (pure)
// ─────────────────────────────────────────────────────────────────────────────
export type TuiAttachRefusal = 'no-mux-name' | 'nested-foreign-socket';
export type TuiAttachPlan =
| { kind: 'attach'; file: string; args: 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: `<socket path>,<pid>,<session index>`. */
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 <name>` 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],
};
}
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.',
};
}
/**
* 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;
}
/** 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`.
*
* ⚠️ 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`.
*
* ⚠️ 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. */
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.
*
* ⚠️ `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;
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<string, string> {
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*`)
// from the middle of it. The window-status options that would otherwise hide
// it are WINDOW options, so `set-option -t <session>` cannot even reach them.
const right = label ? `#[align=right] ${label} ` : '';
return {
status: 'on',
'status-style': 'bg=default,fg=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]`,
};
}
/** 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<n>-<case>`, 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.
*
* 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<void>;
}
/**
* 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<TuiAttachHandoff> {
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
// 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);
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: oneKey ? ONE_KEY_DETACH : detachChord(prefix, detachKey),
async restore(): Promise<void> {
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);
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
* 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)
// ─────────────────────────────────────────────────────────────────────────────
/**
* 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;
/** 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;
}
/**
* 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) {
case 'help':
return ['esc close'];
case 'confirm-kill':
return ['type the name', `${glyphs.enter} confirm`, 'esc cancel'];
case 'message':
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':
return [`${glyphs.enter} send`, 'esc cancel'];
case 'search':
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;
}
}
}
/**
* 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 — 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(
['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;
}
/**
* 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, 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;
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)
// ─────────────────────────────────────────────────────────────────────────────
/** 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-<prefix>`), 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 };
});
}
/**
* 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<TuiSessionState, Tone> = {
'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 <n>` 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 `<n>` 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<TuiQuickStartOptions['mode']>;
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();
/**
* 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;
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 previewTimer: NodeJS.Timeout | null = null;
private searchTimer: NodeJS.Timeout | null = null;
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`. */
private readonly seenApprovals = new Set<string>();
private exiting = false;
private resolveExit: ((code: number) => void) | null = null;
private readonly onData = (chunk: Buffer): void => this.feed(chunk);
// 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 => {
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<number> {
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 } : {}),
...(server.planUsage ? { planUsage: this.planUsageChip(server.planUsage) } : {}),
});
} 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();
// 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<number>((resolve) => {
this.resolveExit = resolve;
});
}
/** 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: this.planUsageChip(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: this.planUsageChip(usage) });
this.paint();
},
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();
},
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<void> {
if (this.exiting) return;
if (this.refreshing) {
this.refreshQueued = true;
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();
} finally {
this.refreshing = false;
}
if (this.refreshQueued && !this.exiting) {
this.refreshQueued = false;
this.scheduleRefresh();
}
}
private async refreshConnected(): Promise<void> {
try {
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(applyLiveMetrics(applyMuxNames(sessions, tmux), metrics));
this.model.setApprovals(approvals);
this.noteApprovals(approvals);
if (this.pendingSelectId && this.model.select(this.pendingSelectId)) this.pendingSelectId = null;
this.updatePreview();
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<void> {
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();
}
/**
* 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);
}
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<void> {
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 } : {}),
...(server.planUsage ? { planUsage: this.planUsageChip(server.planUsage) } : {}),
});
await this.refresh();
this.subscribe();
}
// ── Preview ────────────────────────────────────────────────────────────────
/**
* 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 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;
}
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();
}
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;
clearTimeout(this.previewTimer);
this.previewTimer = null;
}
/** 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<void> {
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;
const lines = toDisplayLines(dropSeveredEscape(raw)).slice(-PREVIEW_MAX_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. 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;
}
this.paint();
}
// ── Painting ───────────────────────────────────────────────────────────────
private currentLayout(): TuiLayout {
return computeLayout(this.stdout.columns ?? 80, this.stdout.rows ?? 24, {
banner: needsBanner(this.model.connection),
});
}
/**
* 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,
};
}
private paint(force = false): void {
if (this.exiting || !this.screen.active) return;
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 keymap = this.keymapContext();
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),
};
// 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();
}
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.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();
}
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 'prompt':
this.handlePrompt(event);
return;
case 'search':
this.handleSearch(event);
return;
case 'digest':
this.handleDigest(event);
return;
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();
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);
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 {
// 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);
return;
case 'k':
this.model.moveCursor(-1);
return;
case 'q':
this.quit(0);
return;
case '?':
this.model.setMode('help');
return;
case 'x':
this.beginKill();
return;
case 'n':
void this.openNewSession();
return;
case 'p':
this.openPrompt();
return;
case '/':
this.openSearch();
return;
case 'g':
void this.openDigest();
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;
}
}
// ── 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 });
// 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();
}
/**
* 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 });
this.updatePreview();
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<void> {
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;
}
// 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
// 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<void> {
const line = text.trim();
this.model.closeOverlay();
if (line === '') {
this.updatePreview();
this.paint();
return;
}
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) {
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<void> {
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<void> {
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();
}
/**
* 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<void> {
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<TuiRow | null> {
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-<prefix>` by hand would attach to a
* name that may not exist yet.
*/
private async awaitPane(sessionId: string, timeoutMs = RESUME_PANE_TIMEOUT_MS): Promise<string | null> {
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<void> {
const row = this.model.selectedSession();
if (!row) return;
if (row.group === 'recent') {
await this.resumeSelected(row);
return;
}
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<void> {
const muxName = (row.session.muxName ?? '').trim();
const plan = planAttach(muxName, {
socket: this.socket,
...(this.env.TMUX ? { tmux: this.env.TMUX } : {}),
});
if (plan.kind === 'refuse') {
this.message('warn', plan.message);
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)) {
// Its conversation usually survives the pane: tmux's own dead-pane screen
// says `claude --resume "<name>"`. 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. ` +
(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
// 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
// can last hours).
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();
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;
}
// 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(`${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.
this.previewQuiet = 0;
this.updatePreview();
this.paint(true);
if (result.error) {
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);
}
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<void> {
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<void> {
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);
}
/**
* 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<void> {
try {
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<n>-<case>` 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}`);
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, this.searchTimer, this.noticeTimer, this.previewTimer]) {
if (timer) clearTimeout(timer);
}
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;
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);
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 <n>` takes.
*/
async function snapshot(client: TuiClient): Promise<Snapshot> {
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, metrics] = await Promise.all([
client.fetchUnifiedSessions(UNIFIED_LIMIT),
client.fetchApprovals().catch(() => []),
client.enumerateTmuxSessions().catch(() => [] as TuiTmuxSession[]),
client.fetchLiveSessionMetrics().catch(() => [] as TuiLiveSessionMetrics[]),
]);
// Same merge as the dashboard, so `--list`'s numbers stay the numbers
// `codeman tui <n>` 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 };
}
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<number> {
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 <n>')} 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<number> {
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_WORDS[line.state]),
line.label,
palette.muted(line.workingDir),
]);
stdout.write(`${table(rows, { indent: ' ' })}\n`);
return 0;
} finally {
client.close();
}
}
/**
* `codeman tui <n>`: 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<number> {
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;
}
// 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;
}
return result.status ?? 0;
} finally {
client.close();
}
}