/** * @fileoverview `codeman tui`: the full-screen dashboard plus the two * non-interactive fast paths (`--list`, ``). * * This is the IO half of `src/tui/`: it owns the terminal, the timers, stdin * and the tmux handoff. Every decision it makes that can be stated as a * function of its inputs lives at the top of this file as an exported pure * helper (attach planning, the kill confirmation, footer selection, the * repaint test, degraded-row building), because none of those can be tested * through a real terminal. * * TERMINAL LIFECYCLE is the part users judge. A TUI that dies leaving the * terminal in raw mode with a hidden cursor is unusable until the user types a * blind `reset`, so `leave()` is idempotent and runs from every exit path there * is: normal quit, SIGINT/SIGTERM, and a `process.on('exit')` backstop that * fires even when someone else's handler calls `process.exit()`. The fatal * handlers are installed with `prependListener` on purpose: `src/index.ts` * already handles `uncaughtException` by exiting, and a listener registered * after it would never run. * * ATTACH is a handoff, never a proxy: the screen is fully restored and tmux * gets the real terminal (`stdio: 'inherit'`), so mouse, paste and colors are * tmux's own. Inside tmux on the same socket there is nothing to hand off to, * so the TUI issues `switch-client` and EXITS: the client it would draw on is * now showing the target session, and a dashboard nobody can see must not keep * polling the server. * * REPAINT POLICY: on state change (the model's revision), on resize, and on a * 500ms tick that runs only while a WORKING row is on screen (the glyph * animates). An idle dashboard writes nothing at all. 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 { toDisplayLines } from './tui-ansi.js'; import { approvalAnswerForKey, newApprovalIds } from './tui-approvals.js'; import { composerScroll, composerStep, composerText, createComposer, type TuiComposerState } from './tui-composer.js'; import { formatAwayDigest } from './tui-digest.js'; import { TuiClient, type TuiApprovalAnswer, type TuiEventStream, type TuiQuickStartOptions, type TuiTmuxSession, } from './tui-client.js'; import { createKeyParser, type TuiInputEvent, type TuiKeyParser } from './tui-keys.js'; import { computeLayout, needsBanner, 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; /** Poll period once the client reports SSE is not carrying events. */ const POLL_INTERVAL_MS = 2_000; /** Degraded mode re-probes this often, so a server that starts upgrades the TUI live. */ const REPROBE_INTERVAL_MS = 10_000; /** Unified-list page size. RECENT is capped far lower by the model. */ const UNIFIED_LIMIT = 60; /** How often the selected session's tail is re-read while the list has focus. */ const PREVIEW_INTERVAL_MS = 1_000; /** Tail size. Enough for a tall pane's last screens, small enough to poll every second. */ const PREVIEW_TAIL_BYTES = 12 * 1024; /** Lines kept from a tail. The pane shows a fraction of these; the rest is headroom. */ const PREVIEW_MAX_LINES = 200; /** Quiet time after the last keystroke before the search query goes to the server. */ const SEARCH_DEBOUNCE_MS = 250; const SEARCH_LIMIT = 40; /** How long a "sent" style notice stays up before it clears itself. */ const NOTICE_MS = 1_500; /** Approval ids remembered for the bell before the set is rebuilt from what is pending. */ const SEEN_APPROVAL_CAP = 500; const ALT_SCREEN_ON = '\x1b[?1049h'; const ALT_SCREEN_OFF = '\x1b[?1049l'; 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[]; hint: string } | { kind: 'switch'; file: string; args: string[] } | { kind: 'refuse'; reason: TuiAttachRefusal; message: string }; export interface TuiAttachContext { /** This instance's tmux socket name (`-L`). */ socket: string; /** `$TMUX` as tmux sets it inside a pane: `,,`. */ tmux?: string; } /** * The socket NAME behind a `$TMUX` value, or null when we are not inside tmux. * tmux itself splits the variable on commas, so the path can be taken as * everything before the first one; `-L ` sockets live in one directory * per user, which makes the basename the name we compare against. */ export function tmuxSocketFromEnv(tmux: string | undefined): string | null { const raw = (tmux ?? '').trim(); if (!raw) return null; const path = raw.split(',')[0]; const name = path.split('/').filter(Boolean).pop(); return name ?? null; } /** * How to reach a session's pane from where we are standing. * * Nesting is the case worth spelling out: inside tmux on a FOREIGN socket an * attach would either be refused by tmux or produce a terminal inside a * terminal whose prefix keys collide, so the TUI explains the situation instead * of trying. */ export function planAttach(muxName: string | undefined, context: TuiAttachContext): TuiAttachPlan { const name = (muxName ?? '').trim(); if (!name) { return { kind: 'refuse', reason: 'no-mux-name', message: 'that session has no tmux pane to attach to (it is history, or it runs on a direct PTY)', }; } const inside = tmuxSocketFromEnv(context.tmux); if (inside === null) { return { kind: 'attach', file: 'tmux', args: ['-L', context.socket, 'attach-session', '-t', name], hint: 'detach with Ctrl+B D to come back', }; } if (inside === context.socket) { return { kind: 'switch', file: 'tmux', args: ['-L', context.socket, 'switch-client', '-t', name] }; } return { kind: 'refuse', reason: 'nested-foreign-socket', message: `this terminal is already inside tmux on socket "${inside}", and Codeman's sessions live on "${context.socket}". ` + 'Detach first (Ctrl+B D), then run codeman tui again.', }; } /** * Is this the session the TUI itself runs in? Codeman exports * `CODEMAN_SESSION_ID` into every managed pane, and killing that one would take * the TUI down with it. Ids reach agents truncated, so either side may be the * prefix; anything shorter than 8 characters is not identification. */ export function isSelfSession(sessionId: string, env: { CODEMAN_SESSION_ID?: string } = {}): boolean { const self = (env.CODEMAN_SESSION_ID ?? '').trim(); if (self.length < 8 || sessionId.length < 8) return false; return sessionId.startsWith(self) || self.startsWith(sessionId); } // ───────────────────────────────────────────────────────────────────────────── // Kill confirmation (pure) // ───────────────────────────────────────────────────────────────────────────── export type TuiConfirmStep = | { kind: 'typing'; typed: string } | { kind: 'confirm' } | { kind: 'reject' } | { kind: 'cancel' } | { kind: 'ignore' }; /** Does the typed text authorize the kill? The shown name, or the id prefix a mux name carries. */ export function confirmAccepts(state: TuiConfirmState, typed = state.typed): boolean { const value = typed.trim(); if (value === '') return false; return value === state.name || value === state.sessionId.slice(0, 8); } /** * One keystroke of the typed confirmation. Enter on text that does not match is * a `reject`, never a silent no-op: a confirmation that appears to do nothing * reads as a broken key. */ export function confirmKillStep(state: TuiConfirmState, event: TuiInputEvent): TuiConfirmStep { switch (event.type) { case 'char': return { kind: 'typing', typed: state.typed + event.value }; case 'backspace': return { kind: 'typing', typed: [...state.typed].slice(0, -1).join('') }; case 'enter': return confirmAccepts(state) ? { kind: 'confirm' } : { kind: 'reject' }; case 'escape': return { kind: 'cancel' }; case 'ctrl': return event.key === 'c' ? { kind: 'cancel' } : { kind: 'ignore' }; default: return { kind: 'ignore' }; } } // ───────────────────────────────────────────────────────────────────────────── // Footer (pure) // ───────────────────────────────────────────────────────────────────────────── /** * Which approval keys the selected row makes live. `menu` is a dialog on * screen (y/n/digits answer it); `idle` is a prompt with no dialog, where the * only reply path is the composer. */ export type TuiApprovalKeys = 'menu' | 'idle' | null; export interface TuiKeymapContext { /** False in degraded mode, where the only verb that works is attach. */ server: boolean; approval?: TuiApprovalKeys; } /** * The footer keys for a mode. This is the honest inventory of what 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 ['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'], ['1-9', 'jump and attach'], ]; if (context.server) { keys.push( ['y / n', 'approve or deny the selected dialog'], ['1-9', 'answer with that option, when a dialog is on screen'], ['p', 'send one line to the selected session'], ['/', 'search sessions, events and files'], ['g', 'away digest'], ['n', 'new session'], ['x', 'kill (typed confirmation)'] ); } keys.push(['?', 'this help'], ['esc', 'close an overlay'], ['q', 'quit']); return keys; } // ───────────────────────────────────────────────────────────────────────────── // Preview policy (pure) // ───────────────────────────────────────────────────────────────────────────── export interface TuiPreviewContext { mode: TuiUiMode; /** Narrow layouts have no preview pane at all, so a poll would be wasted. */ narrow: boolean; connection: TuiConnectionStatus; row: TuiRow | null; } /** * Is the selected row worth polling for a tail? Only a live session that is on * screen with the list in focus qualifies: a history row has no buffer to read, * and an overlay hides the pane it would repaint. */ export function shouldFetchPreview(context: TuiPreviewContext): boolean { if (context.mode !== 'list' || context.narrow) return false; if (context.connection === 'degraded' || context.connection === 'down') return false; const row = context.row; return row !== null && row.group !== 'recent'; } /** * The static line a pane shows instead of a tail, or null when a tail is on its * way. Says what is true rather than "loading", which would never resolve. */ export function previewNoteFor(row: TuiRow | null, connection: TuiConnectionStatus): string | null { if (!row) return null; if (connection === 'degraded' || connection === 'down') return null; if (row.group === 'recent') return 'this session is not running: no live output to show'; return null; } /** * Would painting `next` change anything? The preview polls once a second, and a * quiet session returns the same bytes every time; comparing here is what keeps * that poll from bumping the model's revision and repainting the frame. */ export function samePreview(previous: TuiPreview | null, next: TuiPreview | null): boolean { if (previous === next) return true; if (!previous || !next) return false; if (previous.sessionId !== next.sessionId) return false; if (previous.error !== next.error || previous.note !== next.note) return false; if (previous.lines.length !== next.lines.length) return false; return previous.lines.every((line, i) => line === next.lines[i]); } // ───────────────────────────────────────────────────────────────────────────── // Repaint policy (pure) // ───────────────────────────────────────────────────────────────────────────── /** Everything a frame depends on, cheap enough to compare on every event. */ export interface TuiFrameKey { revision: number; cols: number; rows: number; tick: number; } export function sameFrame(previous: TuiFrameKey | null, next: TuiFrameKey): boolean { return ( previous !== null && previous.revision === next.revision && previous.cols === next.cols && previous.rows === next.rows && previous.tick === next.tick ); } /** Only a WORKING row animates, so only a WORKING row justifies a tick timer. */ export function shouldAnimate(rows: readonly TuiRow[]): boolean { return rows.some((row) => row.state === 'working'); } // ───────────────────────────────────────────────────────────────────────────── // Rows (pure) // ───────────────────────────────────────────────────────────────────────────── /** * tmux sessions as dashboard rows, for when no server answered. * * They are tagged `live` deliberately: a running pane is the only liveness * evidence that exists without a server, and the alternative (no `live` source) * would classify every attachable session as history and file it under RECENT. * With no classification running they land in IDLE, which is honest: unknown * state, still attachable. */ export function tmuxRowsToSessions(sessions: readonly TuiTmuxSession[]): TuiSessionRow[] { return sessions.map((session) => ({ sessionId: session.sessionId ?? session.muxName, muxName: session.muxName, sources: ['live', 'mux'], ...(session.name ? { name: session.name } : {}), ...(session.mode ? { mode: session.mode } : {}), ...(session.workingDir ? { workingDir: session.workingDir } : {}), ...(session.createdAt ? { createdAt: session.createdAt, lastActivityAt: session.createdAt } : {}), })); } /** * Stamp each row with the tmux session that backs it. The mux name carries only * the first 8 characters of the session id (`codeman-`), so the join is * by prefix; a row that matches nothing keeps no mux name and cannot be * attached, which is exactly what the attach path then reports. */ export function applyMuxNames(sessions: readonly TuiSessionRow[], tmux: readonly TuiTmuxSession[]): TuiSessionRow[] { if (tmux.length === 0) return sessions.map((session) => ({ ...session })); return sessions.map((session) => { const match = tmux.find( (entry) => entry.sessionId === session.sessionId || session.sessionId.startsWith(entry.sessionIdPrefix) ); return match ? { ...session, muxName: match.muxName } : { ...session }; }); } const STATE_TONE: Record = { 'blocked-permission': 'err', 'blocked-question': 'err', waiting: 'warn', working: 'ok', idle: 'idle', recent: 'idle', }; export interface TuiListLine { /** 1-based position, the same number `codeman tui ` takes. */ index: number; state: TuiSessionState; label: string; workingDir: string; } /** * Label column cap. History rows are labelled by their opening prompt, and one * long prompt pads every other row of the table out to its width. */ const LIST_LABEL_WIDTH = 48; function truncateLabel(text: string, width: number): string { return text.length <= width ? text : `${text.slice(0, Math.max(1, width - 1))}…`; } /** `--list` rows, in the dashboard's own order so `` and the TUI agree. */ export function buildListLines(rows: readonly TuiRow[], labelWidth = LIST_LABEL_WIDTH): TuiListLine[] { return rows.map((row, i) => ({ index: i + 1, state: row.state, label: truncateLabel(rowLabel(row.session), labelWidth), workingDir: row.session.workingDir ?? '', })); } // ───────────────────────────────────────────────────────────────────────────── // Session modes offered by the new-session picker // ───────────────────────────────────────────────────────────────────────────── type TuiRunMode = NonNullable; const MODE_ITEMS: ReadonlyArray<{ id: TuiRunMode; label: string; detail: string }> = [ { id: 'claude', label: 'claude', detail: 'Claude Code' }, { id: 'shell', label: 'shell', detail: 'plain shell' }, { id: 'opencode', label: 'opencode', detail: 'OpenCode' }, { id: 'codex', label: 'codex', detail: 'OpenAI Codex' }, { id: 'gemini', label: 'gemini', detail: 'Google Gemini' }, { id: 'antigravity', label: 'antigravity', detail: 'Google Antigravity' }, { id: 'pi', label: 'pi', detail: 'pi.dev' }, ]; // ───────────────────────────────────────────────────────────────────────────── // Terminal // ───────────────────────────────────────────────────────────────────────────── /** The slices of stdin/stdout the TUI uses; `process.stdin`/`stdout` satisfy both. */ export interface TuiStdin extends NodeJS.EventEmitter { isTTY?: boolean; setRawMode?(mode: boolean): unknown; resume(): unknown; pause(): unknown; } export interface TuiStdout { isTTY?: boolean; columns?: number; rows?: number; write(chunk: string): unknown; on(event: 'resize', listener: () => void): unknown; off(event: 'resize', listener: () => void): unknown; } /** * Alternate screen + raw mode, entered and left as one unit. `leave()` is * idempotent and safe to call from a signal handler, an exit hook and the * normal path in any order. */ class TerminalScreen { private entered = false; constructor( private readonly stdin: TuiStdin, private readonly stdout: TuiStdout, private readonly onResize: () => void ) {} get active(): boolean { return this.entered; } enter(): void { if (this.entered) return; this.entered = true; this.stdout.write(`${ALT_SCREEN_ON}${CURSOR_HIDE}`); if (this.stdin.isTTY) this.stdin.setRawMode?.(true); this.stdin.resume(); this.stdout.on('resize', this.onResize); } leave(): void { if (!this.entered) return; this.entered = false; this.stdout.off('resize', this.onResize); if (this.stdin.isTTY) this.stdin.setRawMode?.(false); this.stdin.pause(); this.stdout.write(`${CURSOR_SHOW}${ALT_SCREEN_OFF}`); } } // ───────────────────────────────────────────────────────────────────────────── // The app // ───────────────────────────────────────────────────────────────────────────── export interface TuiRunOptions { stdin?: TuiStdin; stdout?: TuiStdout; env?: NodeJS.ProcessEnv; /** Injected by tests; the default builds one from the environment. */ client?: TuiClient; /** Overrides chalk's detection. Chalk owns it everywhere else (see cli-style). */ color?: boolean; } interface PickerRuntime { stage: 'case' | 'mode'; caseName?: string; /** Unfiltered items; the model holds the filtered view the renderer paints. */ all: TuiPickerItem[]; } class TuiApp { private readonly model: TuiModelStore = createTuiModel(); private readonly parser: TuiKeyParser = createKeyParser(); private readonly screen: TerminalScreen; private readonly stdin: TuiStdin; private readonly stdout: TuiStdout; private readonly env: NodeJS.ProcessEnv; private readonly client: TuiClient; private readonly color: boolean; private readonly glyphTier: TuiGlyphTier; private readonly glyphs: TuiGlyphSet; private readonly socket = resolveTmuxSocketName(); private stream: TuiEventStream | null = null; private tick = 0; private lastFrame: TuiFrameKey | null = null; private escTimer: NodeJS.Timeout | null = null; private tickTimer: NodeJS.Timeout | null = null; private resyncTimer: NodeJS.Timeout | null = null; private pollTimer: NodeJS.Timeout | null = null; private probeTimer: NodeJS.Timeout | null = null; private previewTimer: NodeJS.Timeout | null = null; private searchTimer: NodeJS.Timeout | null = null; private noticeTimer: NodeJS.Timeout | null = null; private refreshing = false; private refreshQueued = false; private picker: PickerRuntime | null = null; private pendingSelectId: string | null = null; /** Whose tail the preview is currently following; null when nothing is polled. */ private previewSessionId: string | null = null; private previewFetching = false; /** Bumped per search so a slow response cannot overwrite a newer query's results. */ private searchSeq = 0; /** Approval ids the bell has already rung for. See `newApprovalIds`. */ private readonly seenApprovals = new Set(); private exiting = false; private resolveExit: ((code: number) => void) | null = null; private readonly onData = (chunk: Buffer): void => this.feed(chunk); private readonly onResize = (): void => this.paint(true); private readonly onProcessExit = (): void => this.screen.leave(); private readonly onSignal = (): void => this.quit(0); private readonly onFatal = (error: unknown): void => { this.screen.leave(); process.stderr.write(`codeman tui: ${getErrorMessage(error)}\n`); if (error instanceof Error && error.stack) process.stderr.write(`${error.stack}\n`); process.exit(1); }; constructor(options: TuiRunOptions) { this.stdin = options.stdin ?? process.stdin; this.stdout = options.stdout ?? process.stdout; this.env = options.env ?? process.env; this.client = options.client ?? new TuiClient(); this.color = options.color ?? chalk.level > 0; this.glyphTier = detectGlyphTier(this.env); this.glyphs = glyphsFor(this.glyphTier); this.screen = new TerminalScreen(this.stdin, this.stdout, this.onResize); } async run(): Promise { const server = await this.client.connect(); if (server?.authRequired) { this.client.close(); process.stderr.write( `${palette.err('The Codeman server rejected these credentials.')}\n` + `Set ${palette.info('CODEMAN_PASSWORD')} (and ${palette.info('CODEMAN_USERNAME')} if it is not "admin"), ` + 'or put them in ~/.codeman/.env, then run codeman tui again.\n' ); return 1; } if (server) { this.model.setConnection('connected'); this.model.setHeader({ ...(server.hostname ? { hostname: server.hostname } : {}), ...(server.instance ? { instance: server.instance } : {}), ...(server.version ? { version: server.version } : {}), ...(server.planUsage ? { planUsage: formatPlanUsage(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(); return new Promise((resolve) => { this.resolveExit = resolve; }); } // ── Data ─────────────────────────────────────────────────────────────────── private subscribe(): void { this.stream = this.client.subscribeEvents({ onInit: (state) => { if (state.version) this.model.setHeader({ version: state.version }); if (state.planUsage) this.model.setHeader({ planUsage: formatPlanUsage(state.planUsage) }); this.paint(); }, onResync: () => this.scheduleRefresh(), // The bell and the card both ride the refetch this schedules: the event // carries the item, but the list has to be re-read anyway (an approval // changes which group its row is in), and one code path cannot double-ring. onApproval: () => this.scheduleRefresh(), onPlanUsage: (usage) => { this.model.setHeader({ planUsage: formatPlanUsage(usage) }); this.paint(); }, onStatus: (status, detail) => { this.model.setConnection(status === 'connected' ? 'connected' : 'reconnecting'); if (detail.recommendPolling) this.startPolling(); else this.stopPolling(); if (status === 'connected') this.scheduleRefresh(); this.paint(); }, }); } private scheduleRefresh(): void { if (this.resyncTimer) return; this.resyncTimer = setTimeout(() => { this.resyncTimer = null; void this.refresh(); }, RESYNC_DEBOUNCE_MS); } /** * Re-read everything the dashboard shows. Overlapping calls collapse: a burst * of events must not queue a burst of round trips, and the last one has to * still run or the list would sit one change behind. */ private async refresh(): Promise { if (this.exiting) return; if (this.refreshing) { this.refreshQueued = true; return; } this.refreshing = true; try { if (this.model.connection === 'degraded') await this.refreshDegraded(); else await this.refreshConnected(); } finally { this.refreshing = false; } if (this.refreshQueued && !this.exiting) { this.refreshQueued = false; await this.refresh(); } } private async refreshConnected(): Promise { try { const [sessions, approvals, tmux] = await Promise.all([ this.client.fetchUnifiedSessions(UNIFIED_LIMIT), this.client.fetchApprovals().catch(() => []), this.client.enumerateTmuxSessions().catch(() => [] as TuiTmuxSession[]), ]); this.model.replaceSessions(applyMuxNames(sessions, tmux)); this.model.setApprovals(approvals); 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 { const tmux = await this.client.enumerateTmuxSessions().catch(() => [] as TuiTmuxSession[]); this.model.replaceSessions(tmuxRowsToSessions(tmux)); 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 { if (this.exiting || this.model.connection !== 'degraded') return; const server = await this.client.connect().catch(() => null); if (!server || server.authRequired) { await this.refresh(); return; } if (this.probeTimer) { clearInterval(this.probeTimer); this.probeTimer = null; } this.model.setConnection('connected'); this.model.setHeader({ ...(server.hostname ? { hostname: server.hostname } : {}), ...(server.instance ? { instance: server.instance } : {}), ...(server.version ? { version: server.version } : {}), }); await this.refresh(); this.subscribe(); } // ── 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; } if (changed) { // Null rather than an empty tail: the renderer reads that as "loading", // while empty lines would claim the session has printed nothing. this.applyPreview(null); void this.fetchPreview(); } if (!this.previewTimer) { this.previewTimer = setInterval(() => void this.fetchPreview(), PREVIEW_INTERVAL_MS); } } private stopPreview(): void { if (!this.previewTimer) return; clearInterval(this.previewTimer); this.previewTimer = null; } private applyPreview(preview: TuiPreview | null): void { if (samePreview(this.model.preview, preview)) return; this.model.setPreview(preview); } private async fetchPreview(): Promise { const sessionId = this.previewSessionId; if (!sessionId || this.previewFetching || this.exiting) return; this.previewFetching = true; try { const raw = await this.client.fetchTerminalTail(sessionId, PREVIEW_TAIL_BYTES); if (this.previewSessionId !== sessionId) return; this.applyPreview({ sessionId, lines: toDisplayLines(raw).slice(-PREVIEW_MAX_LINES) }); } catch { // A tail that cannot be read is a pane-level fact, not a connection one: // the list stays exactly as it is and only this pane says so. if (this.previewSessionId !== sessionId) return; this.applyPreview({ sessionId, lines: [], error: 'could not read this session’s terminal' }); } finally { this.previewFetching = false; } this.paint(); } // ── Painting ─────────────────────────────────────────────────────────────── private currentLayout(): TuiLayout { return computeLayout(this.stdout.columns ?? 80, this.stdout.rows ?? 24, { banner: needsBanner(this.model.connection), }); } private keymapContext(): TuiKeymapContext { const approval = this.model.selectedSession()?.approval; return { server: this.model.connection !== 'degraded', approval: approval ? (approval.kind === 'idle' ? 'idle' : 'menu') : null, }; } private paint(force = false): void { if (this.exiting || !this.screen.active) return; const 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 'help': case 'message': // Any key dismisses; the footer says esc because that is the one key // every overlay in the app answers to. if (event.type !== 'mouse') this.model.closeOverlay(); return; default: this.handleList(event); } } private handleList(event: TuiInputEvent): void { switch (event.type) { case 'key': if (event.name === 'up') this.model.moveCursor(-1); else if (event.name === 'down') this.model.moveCursor(1); else if (event.name === 'pageup') this.model.moveCursor(-5); else if (event.name === 'pagedown') this.model.moveCursor(5); 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 }); } /** * A message that clears itself. Used for outcomes the user already expects * ("sent"), where a box waiting to be dismissed is one keystroke of ceremony * for no information. */ private notice(text: string): void { this.model.setMessage({ tone: 'info', text }); const shown = this.model.message; if (this.noticeTimer) clearTimeout(this.noticeTimer); this.noticeTimer = setTimeout(() => { this.noticeTimer = null; // Only clear the notice this timer armed: anything the user opened in the // meantime owns the screen now. if (this.model.message !== shown) return; this.closeOverlayAndResume(); }, NOTICE_MS); } /** Drop the overlay and let the preview start following the list again. */ private closeOverlayAndResume(): void { this.model.closeOverlay(); this.updatePreview(); this.paint(); } private async answerApproval(item: ApprovalItem, answer: TuiApprovalAnswer): Promise { let result; try { result = await this.client.answerApproval(item.id, answer); } catch (error) { this.message('err', `could not answer that prompt: ${getErrorMessage(error)}`); this.paint(); return; } await this.refresh(); if (result.ok) this.notice(`answered ${item.sessionName || item.sessionId.slice(0, 8)}`); // The server re-captures the pane before it types, so this is the normal // outcome when the dialog was answered in tmux a moment ago. else if (result.reason === 'gone') this.message('warn', 'that dialog is no longer on screen'); else this.message('err', result.message); this.paint(); } private openPrompt(): void { const row = this.model.selectedSession(); if (!row) return; if (this.model.connection === 'degraded') { this.message('warn', 'sending a prompt needs the server; only attach works while it is down'); return; } if (row.group === 'recent') { this.message('warn', 'that session is not running: there is nothing to type at'); return; } this.model.setPrompt({ sessionId: row.session.sessionId, label: rowLabel(row.session), composer: createComposer(), }); } private async sendPrompt(sessionId: string, text: string): Promise { const line = text.trim(); this.model.closeOverlay(); if (line === '') { this.updatePreview(); this.paint(); return; } try { await this.client.sendInput(sessionId, line); await this.refresh(); this.notice('sent'); } catch (error) { this.message('err', `could not send that prompt: ${getErrorMessage(error)}`); } this.updatePreview(); this.paint(); } private openSearch(): void { if (this.model.connection === 'degraded') { this.message('warn', 'search needs the server; only attach works while it is down'); return; } this.model.setSearch({ composer: createComposer(), query: '', entries: [], index: -1, status: 'idle' }); } private closeSearch(): void { if (this.searchTimer) { clearTimeout(this.searchTimer); this.searchTimer = null; } this.closeOverlayAndResume(); } private scheduleSearch(query: string): void { if (this.searchTimer) clearTimeout(this.searchTimer); this.searchTimer = setTimeout(() => { this.searchTimer = null; void this.runSearch(query); }, SEARCH_DEBOUNCE_MS); } private async runSearch(query: string): Promise { if (this.model.mode !== 'search' || !this.model.search) return; const needle = query.trim(); const seq = ++this.searchSeq; if (needle === '') { this.model.updateSearch({ query: '', entries: [], index: -1, status: 'idle', note: undefined }); this.paint(); return; } this.model.updateSearch({ status: 'searching', note: 'searching…' }); this.paint(); try { const data = await this.client.search(needle, SEARCH_LIMIT); if (seq !== this.searchSeq || this.model.mode !== 'search') return; // Only a session that is on the list can be selected; a history hit has a // session id but no row to move the cursor to. const live = new Set( this.model .rows() .filter((row) => row.group !== 'recent') .map((row) => row.session.sessionId) ); const entries = buildSearchEntries(data.groups, (id) => live.has(id)); this.model.updateSearch({ query: needle, entries, index: firstSearchIndex(entries), status: 'done', note: entries.length === 0 ? 'no matches' : `${data.totalResults} result${data.totalResults === 1 ? '' : 's'}${data.truncated ? ' (capped)' : ''}`, }); } catch (error) { if (seq !== this.searchSeq || this.model.mode !== 'search') return; this.model.updateSearch({ status: 'error', entries: [], index: -1, note: `search failed: ${getErrorMessage(error)}`, }); } this.paint(); } private openSearchResult(): void { const state = this.model.search; if (!state) return; const entry = state.entries[state.index]; if (!entry || entry.kind !== 'result') return; if (entry.live && entry.sessionId && this.model.select(entry.sessionId)) { this.closeSearch(); return; } // Nothing to switch to (a history or file hit), so the row's own facts are // the answer; resuming one is phase 3. this.model.updateSearch({ note: [entry.text, entry.detail].filter((part) => part).join(' — ') }); } private async openDigest(): Promise { if (this.model.connection === 'degraded') { this.message('warn', 'the digest needs the server; only attach works while it is down'); return; } this.model.setDigest({ title: 'Away digest', lines: ['loading…'], offset: 0 }); this.updatePreview(); this.paint(); let lines: string[]; try { const digest = await this.client.fetchAwayDigest(); lines = formatAwayDigest(digest, { now: Date.now() }); } catch (error) { lines = [`could not load the digest: ${getErrorMessage(error)}`]; } // Esc works throughout the round trip, and a digest that lands afterwards // must not reopen the overlay the user just closed. if (this.model.mode !== 'digest') return; this.model.setDigest({ title: 'Away digest', lines, offset: 0 }); this.paint(); } private async attachSelected(): Promise { const row = this.model.selectedSession(); if (!row) return; if (row.group === 'recent') { this.message('warn', 'that session is not running; resuming a past session is not wired up yet'); return; } const plan = planAttach(row.session.muxName, { socket: this.socket, ...(this.env.TMUX ? { tmux: this.env.TMUX } : {}), }); if (plan.kind === 'refuse') { this.message('warn', plan.message); return; } if (plan.kind === 'switch') { // The client this TUI draws on is about to show another session, so the // dashboard has nothing left to draw and no reason to keep polling. this.screen.leave(); const result = spawnSync(plan.file, plan.args, { stdio: 'inherit' }); if (result.error) { process.stderr.write(`codeman tui: ${getErrorMessage(result.error)}\n`); this.quit(1); return; } this.quit(result.status ?? 0); return; } this.screen.leave(); this.stdout.write(`${plan.hint}\n`); const result = spawnSync(plan.file, plan.args, { stdio: 'inherit' }); this.screen.enter(); this.paint(true); if (result.error) { this.message('err', `tmux attach failed: ${getErrorMessage(result.error)}`); return; } await this.refresh(); this.paint(true); } private beginKill(): void { const row = this.model.selectedSession(); if (!row) return; if (this.model.connection === 'degraded') { this.message('warn', 'killing a session needs the server; only attach works while it is down'); return; } if (row.group === 'recent') { this.message('warn', 'that row is history: there is no session left to kill'); return; } if (isSelfSession(row.session.sessionId, this.env)) { this.message('warn', 'that is the session this TUI is running in'); return; } this.model.beginConfirmKill(row); } private async killSession(sessionId: string, name: string): Promise { this.model.closeOverlay(); try { await this.client.deleteSession(sessionId); await this.refresh(); this.message('info', `killed ${name}`); } catch (error) { this.message('err', `could not kill ${name}: ${getErrorMessage(error)}`); } this.paint(); } private async openNewSession(): Promise { if (this.model.connection === 'degraded') { this.message('warn', 'starting a session needs the server; only attach works while it is down'); return; } this.picker = { stage: 'case', all: [] }; this.model.setPicker({ title: 'New session', items: [], index: -1, filter: '', hint: 'loading cases…' }); this.paint(); let items: TuiPickerItem[]; try { const cases = await this.client.fetchCases(); items = cases.map((entry) => ({ id: entry.name, label: entry.name, ...(entry.location && entry.location !== 'local' ? { detail: entry.location } : {}), })); } catch (error) { this.picker = null; this.message('err', `could not list cases: ${getErrorMessage(error)}`); this.paint(); return; } // The picker can be gone already: fetching cases takes a round trip and esc // works throughout it. if (!this.picker || this.picker.stage !== 'case') return; this.picker.all = items; this.showPicker('Pick a case', items.length > 0 ? 'which case should the session run in?' : 'no cases found'); this.paint(); } private showPicker(title: string, hint: string, filter = ''): void { const runtime = this.picker; if (!runtime) return; const needle = filter.trim().toLowerCase(); const items = needle ? runtime.all.filter((item) => item.label.toLowerCase().includes(needle)) : [...runtime.all]; this.model.setPicker({ title, items, index: items.length > 0 ? 0 : -1, filter, hint }); } private handlePicker(event: TuiInputEvent): void { const state = this.model.picker; const runtime = this.picker; if (!state || !runtime) { this.model.closeOverlay(); return; } switch (event.type) { case 'escape': this.picker = null; this.model.closeOverlay(); return; case 'ctrl': if (event.key === 'c') { this.picker = null; this.model.closeOverlay(); } return; case 'key': if (event.name === 'up') this.movePicker(-1); else if (event.name === 'down') this.movePicker(1); return; case 'backspace': this.showPicker(state.title, state.hint ?? '', [...(state.filter ?? '')].slice(0, -1).join('')); return; case 'char': this.showPicker(state.title, state.hint ?? '', `${state.filter ?? ''}${event.value}`); return; case 'enter': this.choosePicked(); return; default: return; } } private movePicker(delta: number): void { const state = this.model.picker; if (!state || state.items.length === 0) return; const next = (((state.index + delta) % state.items.length) + state.items.length) % state.items.length; this.model.setPicker({ ...state, index: next }); } private choosePicked(): void { const state = this.model.picker; const runtime = this.picker; if (!state || !runtime || state.index < 0 || state.index >= state.items.length) return; const chosen = state.items[state.index]; if (runtime.stage === 'case') { this.picker = { stage: 'mode', caseName: chosen.id, all: MODE_ITEMS.map((mode) => ({ id: mode.id, label: mode.label, detail: mode.detail })), }; this.showPicker('Pick a CLI', `new session in ${chosen.label}`); return; } const caseName = runtime.caseName; // Resolved against the table rather than cast: the picker's ids are strings // and quick-start refuses a mode the server does not know. const mode = MODE_ITEMS.find((entry) => entry.id === chosen.id); this.picker = null; this.model.closeOverlay(); if (caseName && mode) void this.startSession(caseName, mode.id); } private async startSession(caseName: string, mode: TuiRunMode): Promise { try { const result = await this.client.quickStart({ caseName, mode }); // The row appears with the next resync; remember which one to select. this.pendingSelectId = result.sessionId; this.message('info', `started ${mode} in ${caseName}`); await this.refresh(); } catch (error) { this.message('err', `could not start a session in ${caseName}: ${getErrorMessage(error)}`); } this.paint(); } // ── Lifecycle ────────────────────────────────────────────────────────────── private installSafetyNets(): void { process.on('exit', this.onProcessExit); process.on('SIGINT', this.onSignal); process.on('SIGTERM', this.onSignal); // Prepended: src/index.ts already handles both by exiting, and a listener // registered after it would never run, leaving the terminal in raw mode. process.prependListener('uncaughtException', this.onFatal); process.prependListener('unhandledRejection', this.onFatal); } private removeSafetyNets(): void { process.off('exit', this.onProcessExit); process.off('SIGINT', this.onSignal); process.off('SIGTERM', this.onSignal); process.off('uncaughtException', this.onFatal); process.off('unhandledRejection', this.onFatal); } private quit(code: number): void { if (this.exiting) return; this.exiting = true; for (const timer of [this.escTimer, this.resyncTimer, this.searchTimer, this.noticeTimer]) { if (timer) clearTimeout(timer); } for (const timer of [this.tickTimer, this.pollTimer, this.probeTimer, this.previewTimer]) { if (timer) clearInterval(timer); } this.escTimer = null; this.resyncTimer = null; this.searchTimer = null; this.noticeTimer = null; this.tickTimer = null; this.pollTimer = null; this.probeTimer = null; this.previewTimer = null; this.stream?.close(); this.client.close(); this.stdin.off('data', this.onData); this.screen.leave(); this.removeSafetyNets(); this.resolveExit?.(code); this.resolveExit = null; } } // ───────────────────────────────────────────────────────────────────────────── // Entry points // ───────────────────────────────────────────────────────────────────────────── type Snapshot = { kind: 'ok'; rows: TuiRow[]; degraded: boolean } | { kind: 'auth' }; /** * One read of the world for the non-interactive paths. Same client, same * classification and same ordering as the dashboard, so `--list`'s numbers are * the numbers `codeman tui ` takes. */ async function snapshot(client: TuiClient): Promise { const server = await client.connect(); if (server?.authRequired) return { kind: 'auth' }; const model = createTuiModel(); if (!server) { model.replaceSessions(tmuxRowsToSessions(await client.enumerateTmuxSessions())); return { kind: 'ok', rows: model.rows(), degraded: true }; } const [sessions, approvals, tmux] = await Promise.all([ client.fetchUnifiedSessions(UNIFIED_LIMIT), client.fetchApprovals().catch(() => []), client.enumerateTmuxSessions().catch(() => [] as TuiTmuxSession[]), ]); model.replaceSessions(applyMuxNames(sessions, tmux)); model.setApprovals(approvals); return { kind: 'ok', rows: model.rows(), degraded: false }; } function authHint(): string { return ( `${palette.err('The Codeman server rejected these credentials.')}\n` + `Set ${palette.info('CODEMAN_PASSWORD')} (and ${palette.info('CODEMAN_USERNAME')} if it is not "admin"), ` + 'or put them in ~/.codeman/.env.\n' ); } /** The full-screen dashboard. */ export async function runTui(options: TuiRunOptions = {}): Promise { const stdin = options.stdin ?? process.stdin; const stdout = options.stdout ?? process.stdout; if (!stdout.isTTY || !stdin.isTTY) { process.stderr.write( `${palette.err('codeman tui needs an interactive terminal.')}\n` + `Use ${palette.info('codeman tui --list')} for a plain list, or ${palette.info('codeman tui ')} to attach.\n` ); return 1; } return new TuiApp(options).run(); } /** * `codeman tui --list`: the `sc -l` replacement. Prints and exits, colored when * the output is a terminal and plain when it is piped (chalk's call, not ours). */ export async function runTuiList(options: TuiRunOptions = {}): Promise { const stdout = options.stdout ?? process.stdout; const client = options.client ?? new TuiClient(); try { const state = await snapshot(client).catch((error: unknown) => { process.stderr.write(`${palette.err(getErrorMessage(error))}\n`); return null; }); if (!state) return 1; if (state.kind === 'auth') { process.stderr.write(authHint()); return 1; } if (state.degraded) { process.stderr.write(`${palette.warn('server not running: listing tmux sessions only')}\n`); } const lines = buildListLines(state.rows); if (lines.length === 0) { process.stderr.write(`${palette.muted('no sessions')}\n`); return 0; } const rows = lines.map((line) => [ palette.muted(String(line.index)), tint(STATE_TONE[line.state], STATE_WORDS[line.state]), line.label, palette.muted(line.workingDir), ]); stdout.write(`${table(rows, { indent: ' ' })}\n`); return 0; } finally { client.close(); } } /** * `codeman tui `: the `sc 2` replacement. No screen setup at all, so it is * as fast as the API call it makes. */ export async function runTuiAttach(position: number, options: TuiRunOptions = {}): Promise { const stdin = options.stdin ?? process.stdin; const stdout = options.stdout ?? process.stdout; if (!stdout.isTTY || !stdin.isTTY) { process.stderr.write(`${palette.err('attaching needs an interactive terminal.')}\n`); return 1; } const env = options.env ?? process.env; const client = options.client ?? new TuiClient(); try { const state = await snapshot(client).catch((error: unknown) => { process.stderr.write(`${palette.err(getErrorMessage(error))}\n`); return null; }); if (!state) return 1; if (state.kind === 'auth') { process.stderr.write(authHint()); return 1; } const row = state.rows[Math.trunc(position) - 1]; if (!row) { process.stderr.write( `${palette.err(`there is no session ${position}`)}\nRun ${palette.info('codeman tui --list')} to see the numbers.\n` ); return 1; } const plan = planAttach(row.session.muxName, { socket: resolveTmuxSocketName(), ...(env.TMUX ? { tmux: env.TMUX } : {}), }); if (plan.kind === 'refuse') { process.stderr.write(`${palette.warn(plan.message)}\n`); return 1; } if (plan.kind === 'attach') stdout.write(`${palette.muted(plan.hint)}\n`); const result = spawnSync(plan.file, plan.args, { stdio: 'inherit' }); if (result.error) { process.stderr.write(`${palette.err(getErrorMessage(result.error))}\n`); return 1; } return result.status ?? 0; } finally { client.close(); } }