diff --git a/docs/tui.md b/docs/tui.md index 97a39321..bd8ef35b 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -168,6 +168,16 @@ terminal to tmux with `stdio: inherit`. Colors, mouse and paste are tmux's, at f fidelity. Detach with **`Ctrl+B D`** (tmux's default prefix, which Codeman does not change for local sessions) and the dashboard comes back and refreshes. +You do not have to remember that: for as long as the attach lasts, the session wears +a status bar reading **`Ctrl+B D detach, back to the codeman dashboard`**, in the +prefix your own `~/.tmux.conf` sets if you remapped it. Codeman keeps the status bar +off on its panes (the web UI carries that information around the terminal instead), +so the TUI turns it on for the attach and puts it back exactly as it was on detach — +along with the window size, which follows your terminal while you are attached and +returns to the browser's afterwards. Detaching leaves the agent running; typing +`exit` or pressing `Ctrl+D` would end it, which is the difference the bar exists to +make obvious. + Three cases: | Where you are | What happens | @@ -176,7 +186,14 @@ Three cases: | Already in tmux on Codeman's socket | `switch-client`, so you do not nest | | In tmux on a **different** socket | Refused, with an explanation: detach first (`Ctrl+B D`), then run `codeman tui` again | -A RECENT row and a direct-PTY session have no pane to attach to, and say so. +A direct-PTY session has no pane to attach to, and says so. + +**`Enter` on a RECENT row resumes that conversation** instead: there is no pane to +attach to, so the TUI creates a new claude session carrying the old transcript +(`resumeSessionId`, exactly what the web UI's "Resume Conversation" list does), in +the directory it originally ran in and under its old name, then attaches to it. It +is claude-only, and a row with no working directory or no conversation id says why +rather than resuming something else. `x` never bulk-kills: it kills one session, only after you retype its name, never a history row, and never the session the TUI itself is running in. diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index b2476e28..94123bfc 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -151,6 +151,16 @@ const SEARCH_DEBOUNCE_MS = 250; const SEARCH_LIMIT = 40; /** How long a "sent" style notice stays up before it clears itself. */ const NOTICE_MS = 1_500; + +/** + * How long a resumed session gets to grow a tmux pane before the TUI stops + * waiting and simply selects its row. Generous: `POST /interactive` spawns the + * CLI, and a cold claude start is seconds, not milliseconds. + */ +const RESUME_PANE_TIMEOUT_MS = 8_000; +const RESUME_PANE_POLL_MS = 250; +/** History rows are labelled by their whole opening prompt; the notice shows a slice of it. */ +const RESUME_NOTICE_WIDTH = 48; /** Approval ids remembered for the bell before the set is rebuilt from what is pending. */ const SEEN_APPROVAL_CAP = 500; @@ -171,7 +181,7 @@ const BELL = '\x07'; export type TuiAttachRefusal = 'no-mux-name' | 'nested-foreign-socket'; export type TuiAttachPlan = - | { kind: 'attach'; file: string; args: string[]; hint: string } + | { kind: 'attach'; file: string; args: string[] } | { kind: 'switch'; file: string; args: string[] } | { kind: 'refuse'; reason: TuiAttachRefusal; message: string }; @@ -219,7 +229,6 @@ export function planAttach(muxName: string | undefined, context: TuiAttachContex kind: 'attach', file: 'tmux', args: ['-L', context.socket, 'attach-session', '-t', name], - hint: 'detach with Ctrl+B D to come back', }; } if (inside === context.socket) { @@ -234,6 +243,147 @@ export function planAttach(muxName: string | undefined, context: TuiAttachContex }; } +/** + * tmux's prefix key as a human reads it: `C-b` → `Ctrl+B`, `M-a` → `Alt+A`. + * + * Never hardcoded: the socket reads the user's `~/.tmux.conf`, so a config with + * `set -g prefix C-a` makes every "press Ctrl+B" instruction a lie, and the one + * instruction that matters here is how to get back OUT of an attach. + */ +export function formatPrefixKey(prefix: string | undefined): string { + const raw = (prefix ?? '').trim(); + if (!raw) return 'Ctrl+B'; + const ctrl = /^C-(.+)$/.exec(raw); + if (ctrl) return `Ctrl+${ctrl[1].toUpperCase()}`; + const meta = /^M-(.+)$/.exec(raw); + if (meta) return `Alt+${meta[1].toUpperCase()}`; + return raw; +} + +/** The whole chord: prefix, then `d`. */ +export function detachChord(prefix?: string): string { + return `${formatPrefixKey(prefix)} D`; +} + +/** `#` opens `#[…]`/`#{…}` in a tmux format, so a name carrying one must double it. */ +function escapeTmuxFormat(value: string): string { + return value.replace(/#/g, '##'); +} + +/** + * The status line an attached session wears, as tmux option → value. + * + * Codeman turns the status bar OFF on every pane it owns (tmux-manager.ts): the + * web UI carries that information around the terminal instead. A terminal + * attach has no such frame, so the way out is invisible, and "how do I get out + * of this?" is answered by exiting the agent (measured: a tester left a dead + * pane behind on the first try). The bar exists for the length of the attach + * and is put back exactly as it was on detach. + * + * `reverse` rather than a palette: the TUI paints its own selected row with the + * same SGR 7, so the bar inherits whatever theme the terminal has instead of + * guessing at light or dark. + */ +export function buildAttachBanner(options: { prefix?: string; label?: string }): Record { + const chord = escapeTmuxFormat(detachChord(options.prefix)); + const label = escapeTmuxFormat(truncateLabel((options.label ?? '').trim(), ATTACH_BANNER_LABEL_MAX)); + // ONE option, not `status-left`/`status-right`/`status-style`: `status-format[0]` + // owns the whole line, which is what removes tmux's window list (`0:bash*`) + // from the middle of it. The window-status options that would otherwise hide + // it are WINDOW options, so `set-option -t ` cannot even reach them. + const right = label ? `#[align=right] ${label} ` : ''; + return { + status: 'on', + 'status-format[0]': `#[reverse] #[bold]${chord}#[nobold] detach, back to the codeman dashboard${right}#[default]`, + }; +} + +/** Long enough for a session name, short enough to survive a narrow terminal. */ +const ATTACH_BANNER_LABEL_MAX = 28; + +/** + * What pressing Enter on a RECENT row does, decided from the row alone. + * + * Resuming is Claude Code's `--resume`, so it is claude-only, needs the + * directory the conversation ran in, and needs the CONVERSATION's id + * (`claudeSessionId`) rather than the Codeman row's: a `/clear`-respawned or + * re-attached session carries a different one, and the server's regex only + * accepts the hex-and-dashes shape a real transcript id has. + */ +export type TuiResumePlan = + | { kind: 'resume'; workingDir: string; resumeSessionId: string; sessionName?: string } + | { kind: 'refuse'; message: string }; + +/** Ids the server's `resumeSessionId` accepts (`/^[a-f0-9-]+$/`), checked before the round trip. */ +const RESUME_ID_PATTERN = /^[a-f0-9-]+$/; + +export function planResume(session: TuiSessionRow): TuiResumePlan { + const workingDir = (session.workingDir ?? '').trim(); + if (!workingDir) { + return { kind: 'refuse', message: 'that row has no working directory recorded, so there is nothing to resume in' }; + } + const mode = (session.mode ?? 'claude').trim(); + if (mode !== 'claude') { + return { + kind: 'refuse', + message: `resuming is a Claude Code feature; this row is a ${mode} session, so start a new one with n`, + }; + } + const resumeSessionId = (session.claudeSessionId ?? session.sessionId ?? '').trim(); + if (!RESUME_ID_PATTERN.test(resumeSessionId)) { + return { kind: 'refuse', message: 'that row carries no Claude conversation id, so it cannot be resumed' }; + } + const sessionName = (session.name ?? '').trim(); + return { + kind: 'resume', + workingDir, + resumeSessionId, + // Kept rather than synthesized: a resumed session losing its name is how + // the web UI's COD-143 bug read. + ...(sessionName ? { sessionName } : {}), + }; +} + +/** A handoff to tmux, set up so it can be left and put back. */ +export interface TuiAttachHandoff { + /** The chord that ends it, in the local tmux's own prefix. */ + chord: string; + /** Undo everything the handoff changed. Idempotent enough to call once per attach. */ + restore(): Promise; +} + +/** + * Prepare a tmux window for a human terminal: let it follow the attaching + * client's shape, and give it a status bar naming the way out. Both halves are + * best-effort and both are put back by `restore()`, so a session that was + * `window-size manual` with no status bar (what Codeman creates) is exactly + * that again after the detach. + */ +export async function beginAttachHandoff(client: TuiClient, muxName: string, label: string): Promise { + const prefix = (await client.readPrefixKey(muxName)) ?? undefined; + // Codeman pins its windows to the size the BROWSER dictates (`window-size + // manual` + `resize-window`, tmux-manager.ts), so a terminal of any other + // shape attaches to a window that does not fill it and tmux pads the gap with + // dots. `latest` (not a one-off resize to our size) is also what makes a + // terminal resized MID-attach follow along: tmux recomputes on every SIGWINCH + // and the caller is blocked in `spawnSync`. + const sizing = await client.readWindowSizing(muxName); + await client.followAttachingClient(muxName); + const banner = buildAttachBanner({ ...(prefix ? { prefix } : {}), label }); + const options = await client.readSessionOptions(muxName, Object.keys(banner)); + await client.applySessionOptions(muxName, banner); + return { + chord: detachChord(prefix), + async restore(): Promise { + // Options first, then the size: dropping the status bar gives its row + // back to the pane, and the resize is what re-pins the browser's + // authority over the window. + if (options) await client.restoreSessionOptions(muxName, options); + if (sizing) await client.restoreWindowSizing(muxName, sizing); + }, + }; +} + /** * Is this the session the TUI itself runs in? Codeman exports * `CODEMAN_SESSION_ID` into every managed pane, and killing that one would take @@ -301,6 +451,8 @@ export interface TuiKeymapContext { /** False in degraded mode, where the only verb that works is attach. */ server: boolean; approval?: TuiApprovalKeys; + /** tmux's detach chord as this socket reports it. Defaults to the stock `Ctrl+B D`. */ + detach?: string; } /** @@ -347,8 +499,11 @@ export function footerKeysFor(mode: TuiUiMode, glyphs: TuiGlyphSet, context: Tui export function helpKeysFor(glyphs: TuiGlyphSet, context: TuiKeymapContext): Array<[string, string]> { const keys: Array<[string, string]> = [ [`${glyphs.updown} / j k`, 'select'], - [glyphs.enter, 'attach'], + [glyphs.enter, 'attach — on a RECENT row, resume that conversation'], ['1-9', 'jump and attach'], + // The one key that is not the TUI's: an attach hands the terminal to tmux, + // and leaving it is the question every first attach asks. + [context.detach ?? detachChord(), 'detach from an attached session, back to here'], ]; if (context.server) { keys.push( @@ -693,6 +848,14 @@ class TuiApp { private readonly glyphTier: TuiGlyphTier; private readonly glyphs: TuiGlyphSet; private readonly socket = resolveTmuxSocketName(); + /** + * How to leave an attach, in the local tmux's own prefix. Read per attach + * (a session can override the prefix) and remembered so the help overlay + * names the real chord even before the first attach. + */ + private detachChordLabel = detachChord(); + /** True for the length of one resume. The only thing standing between a resume and a loop. */ + private resuming = false; private stream: TuiEventStream | null = null; private tick = 0; @@ -1069,6 +1232,7 @@ class TuiApp { return { server: this.model.connection !== 'degraded', approval: approval ? (approval.kind === 'idle' ? 'idle' : 'menu') : null, + detach: this.detachChordLabel, }; } @@ -1572,14 +1736,116 @@ class TuiApp { this.paint(); } + /** + * Enter on a RECENT row: resume that conversation and hand the terminal to + * it, so one key means the same thing everywhere in the list ("put me in + * this"). The row itself is history and has no pane, so the resumed session + * is a NEW one carrying the old conversation, exactly like the web UI's + * Resume Conversation list. + */ + private async resumeSelected(row: TuiRow): Promise { + if (this.resuming) return; + if (this.model.connection === 'degraded') { + this.message('warn', 'resuming needs the server; only attach works while it is down'); + return; + } + const plan = planResume(row.session); + if (plan.kind === 'refuse') { + this.message('warn', plan.message); + return; + } + // The flag is the loop breaker, not decoration: a resume ends in an attach, + // and one that could re-enter this method would spawn a session per pass. + this.resuming = true; + let sessionId: string; + try { + this.notice(`resuming ${truncateLabel(rowLabel(row.session), RESUME_NOTICE_WIDTH)}…`); + this.paint(true); + sessionId = await this.client.resumeSession({ + workingDir: plan.workingDir, + resumeSessionId: plan.resumeSessionId, + ...(plan.sessionName ? { sessionName: plan.sessionName } : {}), + }); + } catch (error) { + this.message('err', `could not resume that conversation: ${getErrorMessage(error)}`); + this.paint(true); + return; + } finally { + this.resuming = false; + } + + // Selected whichever way the race goes: a row that is not in the model yet + // is picked up by the next resync instead. + this.pendingSelectId = sessionId; + const fresh = await this.awaitResumedRow(sessionId); + if (!fresh) { + this.message('info', 'resumed; its pane is still starting — press ⏎ on the new row when it appears'); + this.paint(true); + return; + } + this.pendingSelectId = null; + await this.attachToSession(fresh); + } + + /** + * Wait for the resumed session to exist as a LIVE row with a pane, so the + * attach that follows has something to attach to. Bounded, and it gives up by + * returning null rather than by trying again from the top. + */ + private async awaitResumedRow(sessionId: string): Promise { + const deadline = Date.now() + RESUME_PANE_TIMEOUT_MS; + for (;;) { + if (await this.awaitPane(sessionId, 0)) { + await this.refresh(); + if (this.model.select(sessionId)) { + const row = this.model.selectedSession(); + if (row && row.group !== 'recent' && (row.session.muxName ?? '').trim()) return row; + } + } + if (Date.now() >= deadline) return null; + await new Promise((resolve) => setTimeout(resolve, RESUME_PANE_POLL_MS)); + } + } + + /** + * Wait for a just-created session's tmux pane to exist, by the same prefix + * join `applyMuxNames()` uses. Enumeration is the only honest evidence the + * pane is really there; deriving `codeman-` by hand would attach to a + * name that may not exist yet. + */ + private async awaitPane(sessionId: string, timeoutMs = RESUME_PANE_TIMEOUT_MS): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const tmux = await this.client.enumerateTmuxSessions().catch(() => []); + const match = tmux.find((entry) => entry.sessionId === sessionId || sessionId.startsWith(entry.sessionIdPrefix)); + if (match) return match.muxName; + if (Date.now() >= deadline) return null; + await new Promise((resolve) => setTimeout(resolve, RESUME_PANE_POLL_MS)); + } + } + private async attachSelected(): Promise { const row = this.model.selectedSession(); if (!row) return; if (row.group === 'recent') { - this.message('warn', 'that session is not running; resuming a past session is not wired up yet'); + await this.resumeSelected(row); return; } - const plan = planAttach(row.session.muxName, { + await this.attachToSession(row); + } + + /** + * The attach itself, for a row that is known to be live. + * + * ⚠️ Deliberately NOT reachable through `attachSelected()`: resuming ends by + * attaching, and routing that back through the group dispatch turned one + * keystroke into an unbounded resume loop (measured: 35 sessions in 40 + * seconds before it was killed) whenever the fresh row was not selectable + * yet. Nothing here looks at `group` again. + */ + private async attachToSession(row: TuiRow): Promise { + const muxName = (row.session.muxName ?? '').trim(); + const plan = planAttach(muxName, { socket: this.socket, ...(this.env.TMUX ? { tmux: this.env.TMUX } : {}), }); @@ -1595,6 +1861,13 @@ class TuiApp { this.stopPreview(); if (plan.kind === 'switch') { + // Only the sizing, and no restore: this client keeps showing the other + // session after the TUI exits, so snapping the window back to the + // browser's size would put the dots on screen at the moment the user + // arrives, and a bar reading "back to the dashboard" would point at a + // dashboard that is gone. The web UI reclaims the size on its next + // resize, which sets `manual` again on its own. + await this.client.followAttachingClient(muxName); // The client this TUI draws on is about to show another session, so the // dashboard has nothing left to draw and no reason to keep polling. this.screen.leave(); @@ -1608,9 +1881,15 @@ class TuiApp { return; } + // The way OUT, set up before tmux takes the terminal: a status bar that + // stays for the whole attach. The line written below is on a screen tmux + // repaints a moment later, so it is not what the user reads. + const handoff = await beginAttachHandoff(this.client, muxName, rowLabel(row.session)); + this.detachChordLabel = handoff.chord; this.screen.leave(); - this.stdout.write(`${plan.hint}\n`); + this.stdout.write(`${handoff.chord} detaches and brings you back here.\n`); const result = spawnSync(plan.file, plan.args, { stdio: 'inherit' }); + await handoff.restore(); this.screen.enter(); // Whatever happened in the pane happened while nobody was reading it, so the // first tail after a detach must not be a backed-off one. @@ -1621,6 +1900,7 @@ class TuiApp { this.message('err', `tmux attach failed: ${getErrorMessage(result.error)}`); return; } + this.notice(`detached from ${rowLabel(row.session)} · it keeps running`); await this.refresh(); this.paint(true); } @@ -1955,8 +2235,14 @@ export async function runTuiAttach(position: number, options: TuiRunOptions = {} process.stderr.write(`${palette.warn(plan.message)}\n`); return 1; } - if (plan.kind === 'attach') stdout.write(`${palette.muted(plan.hint)}\n`); + // Same handoff the dashboard does: the window follows this terminal and + // wears a bar naming the way out. This path has no dashboard to come back + // to, so the bar's wording is the only thing the detach hint has to carry. + const muxName = (row.session.muxName ?? '').trim(); + const handoff = plan.kind === 'attach' ? await beginAttachHandoff(client, muxName, rowLabel(row.session)) : null; + if (handoff) stdout.write(`${palette.muted(`${handoff.chord} detaches and leaves the session running.`)}\n`); const result = spawnSync(plan.file, plan.args, { stdio: 'inherit' }); + await handoff?.restore(); if (result.error) { process.stderr.write(`${palette.err(getErrorMessage(result.error))}\n`); return 1; diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index 38ded631..81051105 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -100,6 +100,15 @@ export interface TuiServerInfo { planUsage?: TuiPlanUsage | null; } +/** What `resumeSession()` needs: where the conversation ran, and which one it was. */ +export interface TuiResumeOptions { + workingDir: string; + /** The Claude conversation id (`claudeSessionId`, else the row's own id). */ + resumeSessionId: string; + /** Kept from the row when it had one, so a resumed session does not lose its name. */ + sessionName?: string; +} + export interface TuiClientOptions { /** Skip discovery and talk to this origin. */ baseUrl?: string; @@ -116,6 +125,8 @@ export interface TuiClientOptions { exec?: TuiExecFile; /** Read-only source of names/dirs in degraded mode. Defaults to the instance's. */ statePath?: string; + /** tmux socket name. Defaults to the instance's, which is what keeps a beta TUI off prod's sessions. */ + socket?: string; } /** Plan-usage snapshot as the server broadcasts it (telemetry plus its source). */ @@ -385,6 +396,89 @@ export function parseTmuxSessionList(stdout: string): TuiTmuxSession[] { return rows; } +/** + * How tmux is sizing a session's window, as `readWindowSizing()` found it. + * + * Codeman pins every window it owns to the size the BROWSER dictates + * (`window-size manual` plus an explicit `resize-window`, see + * `tmux-manager.ts`), which is what stops a stray attach from shrinking the web + * terminal. The cost is paid by the terminal: a client of any other shape + * attaches to a window that does not fill it, and tmux pads the difference with + * dots. The attach path therefore brackets the handoff with `latest` and puts + * this snapshot back afterwards. + */ +export interface TuiWindowSizing { + cols: number; + rows: number; + /** tmux's `window-size` option: `manual`, `latest`, `largest` or `smallest`. */ + mode: string; +} + +const TMUX_SIZING_FORMAT = ['#{window_width}', '#{window_height}', '#{window-size}'].join(TMUX_FIELD_SEPARATOR); + +/** Parse the sizing format above. Pure, so the format string is unit-testable. */ +export function parseWindowSizing(stdout: string): TuiWindowSizing | null { + const [width = '', height = '', mode = ''] = stdout.trim().split(TMUX_FIELD_SEPARATOR); + const cols = Number.parseInt(width, 10); + const rows = Number.parseInt(height, 10); + if (!Number.isSafeInteger(cols) || !Number.isSafeInteger(rows) || cols <= 0 || rows <= 0) return null; + const value = mode.trim(); + return { cols, rows, ...(value ? { mode: value } : { mode: 'manual' }) }; +} + +/** + * Session-level tmux options, as `readSessionOptions()` found them. `null` is + * "not set on this session", which restores by UNSETTING rather than by writing + * a value back: writing tmux's inherited value would pin an option the session + * never had, and Codeman's own `status off` is exactly such a session-level + * option that must survive the round trip. + */ +export type TuiSessionOptions = Record; + +/** + * Parse `show-options -t ` (session-level options only, one `key value` + * per line) for the keys asked about. Values tmux quotes are unquoted here, so + * what comes back can be handed straight to `set-option` as an argv element. + */ +export function parseSessionOptions(stdout: string, keys: readonly string[]): TuiSessionOptions { + const found = new Map(); + for (const line of stdout.split('\n')) { + const trimmed = line.trimEnd(); + if (!trimmed) continue; + const space = trimmed.indexOf(' '); + const key = space === -1 ? trimmed : trimmed.slice(0, space); + const raw = space === -1 ? '' : trimmed.slice(space + 1); + found.set(key, unquoteTmuxValue(raw)); + } + const options: TuiSessionOptions = {}; + for (const key of keys) { + const base = arrayOptionBase(key); + if (base === null) { + options[key] = found.get(key) ?? null; + continue; + } + // An array option is captured WHOLE: restoring `status-format[0]` alone + // would silently drop a second status line the user configured. + options[key] = found.get(key) ?? null; + for (const [name, value] of found) { + if (arrayOptionBase(name) === base) options[name] = value; + } + } + return options; +} + +/** `status-format[0]` → `status-format`; a plain option name → null. */ +export function arrayOptionBase(key: string): string | null { + const match = /^([^[\]]+)\[\d+\]$/.exec(key); + return match ? (match[1] ?? null) : null; +} + +/** tmux quotes a value only when it has to; `"a \"b\""` comes back as `a "b"`. */ +function unquoteTmuxValue(value: string): string { + if (value.length < 2 || !value.startsWith('"') || !value.endsWith('"')) return value; + return value.slice(1, -1).replace(/\\(["\\])/g, '$1'); +} + /** * List Codeman's tmux sessions without a server, decorating them with whatever * `state.json` remembers. Attach is all this supports: there are no states, no @@ -441,6 +535,7 @@ export class TuiClient { private readonly probeTimeoutMs: number; private readonly exec: TuiExecFile; private readonly statePath: string; + private readonly socket: string; private readonly streams = new Set(); private readonly clientId = `codeman-tui-${process.pid}`; private seq = 0; @@ -457,6 +552,7 @@ export class TuiClient { this.probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; this.exec = options.exec ?? defaultExecFile; this.statePath = options.statePath ?? dataPath('state.json'); + this.socket = options.socket ?? resolveTmuxSocketName(); } /** The origin in use, or null before a successful `connect()`. */ @@ -630,6 +726,27 @@ export class TuiClient { return data; } + /** + * Resume a past Claude conversation as a NEW session, the way the web UI's + * "Resume Conversation" list does: `POST /api/sessions` carrying + * `resumeSessionId` (quick-start has no such field), then `/interactive` to + * give it a pane. Returns the new session's id. + */ + async resumeSession(options: TuiResumeOptions): Promise { + const data = await this.requestData<{ session?: { id?: string } }>('POST', '/api/sessions', { + workingDir: options.workingDir, + resumeSessionId: options.resumeSessionId, + mode: 'claude', + ...(options.sessionName ? { name: options.sessionName } : {}), + }); + const sessionId = data?.session?.id; + if (!sessionId) throw new TuiApiError('resuming returned no session id', 502); + // Creating a session does not start one: without this it has no pane, and + // the row would sit there unattachable. + await this.requestData('POST', `/api/sessions/${encodeURIComponent(sessionId)}/interactive`, {}); + return sessionId; + } + async fetchCases(): Promise { const data = await this.requestData('GET', '/api/cases'); return Array.isArray(data) ? data : []; @@ -669,7 +786,164 @@ export class TuiClient { /** Sessions straight from tmux, for when no server answered. */ enumerateTmuxSessions(): Promise { - return enumerateTmuxSessions({ exec: this.exec, statePath: this.statePath }); + return enumerateTmuxSessions({ exec: this.exec, socket: this.socket, statePath: this.statePath }); + } + + // ── Attach sizing ────────────────────────────────────────────────────────────────── + + /** + * The window's current size and sizing mode, or null when the socket, the + * session or tmux itself is not there. Best-effort by design: an attach that + * cannot be measured still attaches. + */ + async readWindowSizing(muxName: string): Promise { + if (!MUX_NAME_PATTERN.test(muxName)) return null; + try { + const { stdout } = await this.exec('tmux', [ + '-L', + this.socket, + 'display-message', + '-p', + '-t', + muxName, + TMUX_SIZING_FORMAT, + ]); + return parseWindowSizing(stdout); + } catch { + return null; + } + } + + /** + * Let the window follow whichever client is in front of it, for the length of + * an attach. `latest` (rather than a one-off `resize-window` to our own size) + * is what makes a terminal resized MID-attach follow along: tmux recomputes + * on every SIGWINCH, and the TUI process is blocked in `spawnSync` and cannot. + */ + async followAttachingClient(muxName: string): Promise { + if (!MUX_NAME_PATTERN.test(muxName)) return false; + try { + await this.exec('tmux', ['-L', this.socket, 'set-window-option', '-t', muxName, 'window-size', 'latest']); + return true; + } catch { + return false; + } + } + + /** + * tmux's prefix key for a session (`C-b` unless the user's config says + * otherwise), or null when tmux cannot say. Session-level first, then global: + * `show-options -v` resolves the chain on its own, and an empty answer simply + * means "nothing set here". + */ + async readPrefixKey(muxName: string): Promise { + if (!MUX_NAME_PATTERN.test(muxName)) return null; + for (const args of [ + ['-L', this.socket, 'show-options', '-t', muxName, '-v', 'prefix'], + ['-L', this.socket, 'show-options', '-gv', 'prefix'], + ]) { + try { + const { stdout } = await this.exec('tmux', args); + const value = stdout.trim(); + if (value) return value; + } catch { + return null; + } + } + return null; + } + + /** Snapshot the session-level options an attach is about to overwrite. */ + async readSessionOptions(muxName: string, keys: readonly string[]): Promise { + if (!MUX_NAME_PATTERN.test(muxName)) return null; + try { + const { stdout } = await this.exec('tmux', ['-L', this.socket, 'show-options', '-t', muxName]); + return parseSessionOptions(stdout, keys); + } catch { + return null; + } + } + + /** + * Write session options, one `set-option` per key. Sequential rather than a + * single `;`-chained invocation on purpose: a value tmux rejects then costs + * that one option instead of every option after it. + */ + async applySessionOptions(muxName: string, values: Record): Promise { + if (!MUX_NAME_PATTERN.test(muxName)) return; + for (const [key, value] of Object.entries(values)) { + try { + await this.exec('tmux', ['-L', this.socket, 'set-option', '-t', muxName, key, value]); + } catch { + /* an option this tmux does not know is not worth failing an attach over */ + } + } + } + + /** + * Put every snapshotted option back: a value writes, a `null` unsets. + * + * ⚠️ An ARRAY option (`status-format[0]`) cannot be restored element by + * element: `set -u status-format[0]` leaves an EMPTY array rather than + * falling back to the inherited default, which renders as a BLANK status bar + * on a session that legitimately had one (measured). The whole array is + * therefore dropped first, and any indices the snapshot captured are written + * back on top. + */ + async restoreSessionOptions(muxName: string, snapshot: TuiSessionOptions): Promise { + if (!MUX_NAME_PATTERN.test(muxName)) return; + const arrays = new Set(); + for (const key of Object.keys(snapshot)) { + const base = arrayOptionBase(key); + if (base) arrays.add(base); + } + for (const base of arrays) await this.setOption(['-u', '-t', muxName, base]); + for (const [key, value] of Object.entries(snapshot)) { + if (value === null) { + // Indexed nulls are already gone with the array drop above. + if (arrayOptionBase(key) === null) await this.setOption(['-u', '-t', muxName, key]); + continue; + } + await this.setOption(['-t', muxName, key, value]); + } + } + + /** One `set-option`, swallowing failure: the session may be gone by now. */ + private async setOption(args: readonly string[]): Promise { + try { + await this.exec('tmux', ['-L', this.socket, 'set-option', ...args]); + } catch { + /* the user may have exited the agent from inside the attach */ + } + } + + /** + * Put a window back the way `readWindowSizing()` found it, so the web UI + * keeps the authority it had before the attach. The resize goes last: + * `resize-window` sets `window-size manual` on its own, so ordering it after + * the option write would silently undo a restored `latest`. + */ + async restoreWindowSizing(muxName: string, sizing: TuiWindowSizing): Promise { + if (!MUX_NAME_PATTERN.test(muxName)) return; + try { + if (sizing.mode === 'manual') { + await this.exec('tmux', [ + '-L', + this.socket, + 'resize-window', + '-t', + muxName, + '-x', + String(sizing.cols), + '-y', + String(sizing.rows), + ]); + return; + } + await this.exec('tmux', ['-L', this.socket, 'set-window-option', '-t', muxName, 'window-size', sizing.mode]); + } catch { + /* the pane may be gone (the user exited the agent from inside the attach) */ + } } // ── Live updates ─────────────────────────────────────────────────────────── diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts index faf8a05c..3f450f5b 100644 --- a/test/tui/tui-app.test.ts +++ b/test/tui/tui-app.test.ts @@ -13,13 +13,17 @@ import { describe, it, expect } from 'vitest'; import { applyLiveMetrics, applyMuxNames, + buildAttachBanner, buildListLines, confirmAccepts, confirmKillStep, + detachChord, footerKeysFor, + formatPrefixKey, helpKeysFor, isSelfSession, planAttach, + planResume, previewIntervalMs, previewNoteFor, resyncDelayMs, @@ -72,7 +76,6 @@ describe('planAttach', () => { kind: 'attach', file: 'tmux', args: ['-L', 'codeman', 'attach-session', '-t', 'codeman-abcdef01'], - hint: expect.stringContaining('Ctrl+B D'), }); }); @@ -206,7 +209,12 @@ describe('footerKeysFor', () => { it('keeps the help overlay to the same inventory', () => { const help = helpKeysFor(GLYPHS, { server: true }); expect(help.map(([, description]) => description)).toEqual( - expect.arrayContaining(['attach', 'new session', 'kill (typed confirmation)', 'quit']) + expect.arrayContaining([ + 'attach — on a RECENT row, resume that conversation', + 'new session', + 'kill (typed confirmation)', + 'quit', + ]) ); expect(help.flat().join(' ')).toContain('search'); const degraded = helpKeysFor(GLYPHS, { server: false }).flat().join(' '); @@ -448,3 +456,103 @@ describe('buildListLines', () => { expect(line.label.endsWith('…')).toBe(true); }); }); + +describe('the way out of an attach', () => { + it('spells the prefix the way a human reads it, and never assumes C-b', () => { + expect(formatPrefixKey('C-b')).toBe('Ctrl+B'); + // A user who remapped the prefix must not be told to press Ctrl+B. + expect(formatPrefixKey('C-a')).toBe('Ctrl+A'); + expect(formatPrefixKey('M-x')).toBe('Alt+X'); + // Nothing to go on: the tmux default is the honest guess. + expect(formatPrefixKey(undefined)).toBe('Ctrl+B'); + expect(formatPrefixKey(' ')).toBe('Ctrl+B'); + // A shape we do not recognise passes through rather than being mangled. + expect(formatPrefixKey('F1')).toBe('F1'); + }); + + it('names the chord, not just the prefix', () => { + expect(detachChord('C-a')).toBe('Ctrl+A D'); + expect(detachChord()).toBe('Ctrl+B D'); + }); + + it('builds ONE status-format option, so tmux draws no window list beside it', () => { + const banner = buildAttachBanner({ prefix: 'C-b', label: 'w3-codeman' }); + expect(Object.keys(banner).sort()).toEqual(['status', 'status-format[0]']); + expect(banner.status).toBe('on'); + expect(banner['status-format[0]']).toContain('#[bold]Ctrl+B D#[nobold]'); + expect(banner['status-format[0]']).toContain('#[align=right] w3-codeman '); + }); + + it('carries the remapped prefix into the bar', () => { + expect(buildAttachBanner({ prefix: 'C-a' })['status-format[0]']).toContain('Ctrl+A D'); + }); + + it('escapes a label that would otherwise open a tmux format', () => { + const banner = buildAttachBanner({ label: 'fix #42 #[bold]' }); + expect(banner['status-format[0]']).toContain('fix ##42 ##[bold]'); + }); + + it('truncates a long label instead of pushing the instruction off the bar', () => { + const banner = buildAttachBanner({ label: 'w12-codeman: a very long session label indeed' }); + const right = (banner['status-format[0]'].split('#[align=right]')[1] ?? '').replace('#[default]', ''); + // 28 characters of label plus the space either side. + expect(right.length).toBeLessThanOrEqual(30); + expect(right).toContain('…'); + expect(banner['status-format[0]']).toContain('detach, back to the codeman dashboard'); + }); + + it('leaves the right side out entirely when there is no label', () => { + expect(buildAttachBanner({})['status-format[0]']).not.toContain('#[align=right]'); + }); + + it("tells the help overlay how to get back, in the socket's own prefix", () => { + const keys = helpKeysFor(GLYPHS, { server: true, detach: 'Ctrl+A D' }); + const detach = keys.find(([key]) => key === 'Ctrl+A D'); + expect(detach?.[1]).toContain('detach'); + // Degraded mode still attaches, so it still needs the way out. + expect(helpKeysFor(GLYPHS, { server: false }).map(([key]) => key)).toContain('Ctrl+B D'); + }); +}); + +describe('planResume', () => { + const base = { sessionId: 'aaaaaaaa-1111-2222-3333-444444444444', sources: ['transcript'] } as const; + + it('resumes the CONVERSATION id, not the row id', () => { + const plan = planResume({ + ...base, + claudeSessionId: 'bbbbbbbb-5555-6666-7777-888888888888', + workingDir: '/home/dev/codeman', + name: 'w7-codeman', + }); + expect(plan).toEqual({ + kind: 'resume', + workingDir: '/home/dev/codeman', + resumeSessionId: 'bbbbbbbb-5555-6666-7777-888888888888', + sessionName: 'w7-codeman', + }); + }); + + it('falls back to the row id when the row IS the transcript', () => { + const plan = planResume({ ...base, workingDir: '/home/dev/codeman' }); + expect(plan).toMatchObject({ kind: 'resume', resumeSessionId: base.sessionId }); + // No name to keep: the server names it rather than the TUI inventing one. + expect(plan).not.toHaveProperty('sessionName'); + }); + + it('refuses a row with nowhere to run', () => { + expect(planResume({ ...base })).toMatchObject({ kind: 'refuse' }); + }); + + it('refuses a non-claude row, since resume is a Claude Code feature', () => { + const plan = planResume({ ...base, workingDir: '/home/dev/codeman', mode: 'codex' }); + expect(plan.kind).toBe('refuse'); + if (plan.kind === 'refuse') expect(plan.message).toContain('codex'); + }); + + it('refuses an id the server would reject anyway', () => { + // The route validates `/^[a-f0-9-]+$/`; a mux-derived row id is not that. + expect(planResume({ ...base, sessionId: 'codeman-w1', workingDir: '/home/dev' })).toMatchObject({ + kind: 'refuse', + }); + }); +}); diff --git a/test/tui/tui-client.test.ts b/test/tui/tui-client.test.ts index db4cdf7f..4fbc9667 100644 --- a/test/tui/tui-client.test.ts +++ b/test/tui/tui-client.test.ts @@ -20,7 +20,10 @@ import { basicAuthHeader, enumerateTmuxSessions, parseEnvFile, + arrayOptionBase, + parseSessionOptions, parseTmuxSessionList, + parseWindowSizing, readCodemanCredentials, tuiServerCandidates, type TuiExecFile, @@ -491,3 +494,272 @@ describe('degraded-mode tmux enumeration', () => { await expect(enumerateTmuxSessions({ exec })).resolves.toEqual([]); }); }); + +describe('attach window sizing', () => { + /** A client that only ever needs its injected exec: none of this talks to a server. */ + function sizingClient(exec: TuiExecFile): TuiClient { + return new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec }); + } + + it('parses the sizing format, and rejects a window tmux could not measure', () => { + expect(parseWindowSizing('183\t38\tmanual\n')).toEqual({ cols: 183, rows: 38, mode: 'manual' }); + expect(parseWindowSizing('120\t40\tlatest')).toEqual({ cols: 120, rows: 40, mode: 'latest' }); + // No mode reported (an ancient tmux) still yields a usable size. + expect(parseWindowSizing('120\t40\t')).toEqual({ cols: 120, rows: 40, mode: 'manual' }); + expect(parseWindowSizing('')).toBeNull(); + expect(parseWindowSizing("can't find window\n")).toBeNull(); + }); + + it('reads the sizing with an argv array on the client socket', async () => { + const calls: Array = []; + const client = sizingClient(async (_file, args) => { + calls.push(args); + return { stdout: '120\t40\tmanual', stderr: '' }; + }); + await expect(client.readWindowSizing('codeman-1a2b3c4d')).resolves.toEqual({ + cols: 120, + rows: 40, + mode: 'manual', + }); + expect(calls[0].slice(0, 6)).toEqual(['-L', 'codeman-beta', 'display-message', '-p', '-t', 'codeman-1a2b3c4d']); + }); + + it('hands the window to the attaching client with window-size latest', async () => { + const calls: Array = []; + const client = sizingClient(async (_file, args) => { + calls.push(args); + return { stdout: '', stderr: '' }; + }); + await expect(client.followAttachingClient('codeman-1a2b3c4d')).resolves.toBe(true); + expect(calls[0]).toEqual([ + '-L', + 'codeman-beta', + 'set-window-option', + '-t', + 'codeman-1a2b3c4d', + 'window-size', + 'latest', + ]); + }); + + it('restores a manual window with resize-window alone, which re-pins the mode itself', async () => { + const calls: Array = []; + const client = sizingClient(async (_file, args) => { + calls.push(args); + return { stdout: '', stderr: '' }; + }); + await client.restoreWindowSizing('codeman-1a2b3c4d', { cols: 120, rows: 40, mode: 'manual' }); + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual([ + '-L', + 'codeman-beta', + 'resize-window', + '-t', + 'codeman-1a2b3c4d', + '-x', + '120', + '-y', + '40', + ]); + }); + + it('restores a non-manual window by putting its mode back', async () => { + const calls: Array = []; + const client = sizingClient(async (_file, args) => { + calls.push(args); + return { stdout: '', stderr: '' }; + }); + await client.restoreWindowSizing('codeman-1a2b3c4d', { cols: 120, rows: 40, mode: 'latest' }); + expect(calls).toEqual([ + ['-L', 'codeman-beta', 'set-window-option', '-t', 'codeman-1a2b3c4d', 'window-size', 'latest'], + ]); + }); + + it('never targets a name Codeman does not own', async () => { + const calls: Array = []; + const client = sizingClient(async (_file, args) => { + calls.push(args); + return { stdout: '120\t40\tmanual', stderr: '' }; + }); + await expect(client.readWindowSizing('codeman-ssh-prod')).resolves.toBeNull(); + await expect(client.followAttachingClient('other-session')).resolves.toBe(false); + await client.restoreWindowSizing('codeman-dkr-box', { cols: 80, rows: 24, mode: 'manual' }); + expect(calls).toEqual([]); + }); + + it('swallows a dead tmux: an attach must never fail over cosmetics', async () => { + const client = sizingClient(async () => { + throw new Error('no server running on /tmp/tmux-1000/codeman-beta'); + }); + await expect(client.readWindowSizing('codeman-1a2b3c4d')).resolves.toBeNull(); + await expect(client.followAttachingClient('codeman-1a2b3c4d')).resolves.toBe(false); + await expect( + client.restoreWindowSizing('codeman-1a2b3c4d', { cols: 120, rows: 40, mode: 'manual' }) + ).resolves.toBeUndefined(); + }); +}); + +describe('attach status bar options', () => { + function optionsClient(exec: TuiExecFile): TuiClient { + return new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec }); + } + + const SHOW = [ + 'history-limit 100000', + 'mouse off', + 'status off', + 'status-format[0] "#[reverse] left "', + 'status-format[1] "#[align=right] second line "', + 'status-left " plain #[bold]value\\" quoted "', + ].join('\n'); + + it('reads session-level options, unquoting what tmux quoted', () => { + const parsed = parseSessionOptions(SHOW, ['status', 'status-left', 'status-right']); + expect(parsed.status).toBe('off'); + expect(parsed['status-left']).toBe(' plain #[bold]value" quoted '); + // Not set on this session: restoring must UNSET it, not write a value back. + expect(parsed['status-right']).toBeNull(); + }); + + it('captures a whole array when one index is asked for', () => { + const parsed = parseSessionOptions(SHOW, ['status-format[0]']); + expect(parsed['status-format[0]']).toBe('#[reverse] left '); + // The second status line the user configured comes along, or the restore + // would silently delete it. + expect(parsed['status-format[1]']).toBe('#[align=right] second line '); + }); + + it('knows an array element from a plain option', () => { + expect(arrayOptionBase('status-format[0]')).toBe('status-format'); + expect(arrayOptionBase('status')).toBeNull(); + }); + + it('reads the prefix from the session, falling back to the global one', () => { + const calls: Array = []; + const client = optionsClient(async (_file, args) => { + calls.push(args); + // Session level says nothing; the global answer is the real one. + return { stdout: args.includes('-gv') ? 'C-a\n' : '\n', stderr: '' }; + }); + return expect(client.readPrefixKey('codeman-1a2b3c4d')) + .resolves.toBe('C-a') + .then(() => { + expect(calls).toHaveLength(2); + expect(calls[1]).toContain('-gv'); + }); + }); + + it('restores an array by dropping it FIRST, then writing the captured indices', async () => { + const calls: Array = []; + const client = optionsClient(async (_file, args) => { + calls.push(args); + return { stdout: '', stderr: '' }; + }); + await client.restoreSessionOptions('codeman-1a2b3c4d', { + status: 'off', + 'status-format[0]': null, + 'status-format[1]': '#[align=right] second ', + }); + // Unsetting one index leaves an EMPTY array (a blank status bar), so the + // base option goes first and the survivors are written back on top. + expect(calls[0].slice(2)).toEqual(['set-option', '-u', '-t', 'codeman-1a2b3c4d', 'status-format']); + expect(calls.map((args) => args.slice(2))).toContainEqual([ + 'set-option', + '-t', + 'codeman-1a2b3c4d', + 'status', + 'off', + ]); + expect(calls.map((args) => args.slice(2))).toContainEqual([ + 'set-option', + '-t', + 'codeman-1a2b3c4d', + 'status-format[1]', + '#[align=right] second ', + ]); + // The null index is covered by the array drop; it never gets its own unset. + expect(calls.some((args) => args.includes('status-format[0]'))).toBe(false); + }); + + it('unsets a plain option that was not set on the session', async () => { + const calls: Array = []; + const client = optionsClient(async (_file, args) => { + calls.push(args); + return { stdout: '', stderr: '' }; + }); + await client.restoreSessionOptions('codeman-1a2b3c4d', { status: null }); + expect(calls[0].slice(2)).toEqual(['set-option', '-u', '-t', 'codeman-1a2b3c4d', 'status']); + }); + + it('writes the banner one option at a time, and never at a foreign session', async () => { + const calls: Array = []; + const client = optionsClient(async (_file, args) => { + calls.push(args); + return { stdout: '', stderr: '' }; + }); + await client.applySessionOptions('codeman-1a2b3c4d', { status: 'on', 'status-format[0]': 'x' }); + expect(calls).toHaveLength(2); + expect(calls[0].slice(2)).toEqual(['set-option', '-t', 'codeman-1a2b3c4d', 'status', 'on']); + calls.length = 0; + await client.applySessionOptions('codeman-ssh-prod', { status: 'on' }); + await client.restoreSessionOptions('codeman-dkr-box', { status: null }); + await expect(client.readPrefixKey('other-thing')).resolves.toBeNull(); + expect(calls).toEqual([]); + }); + + it('keeps going when tmux rejects one option', async () => { + let seen = 0; + const client = optionsClient(async (_file, args) => { + seen += 1; + if (args.includes('status-format[0]')) throw new Error('unknown option'); + return { stdout: '', stderr: '' }; + }); + await client.applySessionOptions('codeman-1a2b3c4d', { 'status-format[0]': 'x', status: 'on' }); + expect(seen).toBe(2); + }); +}); + +describe('resumeSession', () => { + it('creates with resumeSessionId and then starts the pane', async () => { + recorded.length = 0; + responder = (req, res) => { + if (req.url === '/api/sessions') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ success: true, data: { session: { id: 'new-session-id' } } })); + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ success: true, data: {} })); + }; + const id = await client().resumeSession({ + workingDir: '/home/dev/codeman', + resumeSessionId: 'bbbbbbbb-5555-6666-7777-888888888888', + sessionName: 'w7-codeman', + }); + expect(id).toBe('new-session-id'); + expect(recorded.map((entry) => `${entry.method} ${entry.url}`)).toEqual([ + 'POST /api/sessions', + // Creating a session gives it no pane; without this the resumed row would + // sit in the list unattachable. + 'POST /api/sessions/new-session-id/interactive', + ]); + expect(JSON.parse(recorded[0].body)).toMatchObject({ + workingDir: '/home/dev/codeman', + resumeSessionId: 'bbbbbbbb-5555-6666-7777-888888888888', + mode: 'claude', + name: 'w7-codeman', + }); + }); + + it('does not start a pane when creation answered without an id', async () => { + recorded.length = 0; + responder = (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ success: true, data: {} })); + }; + await expect(client().resumeSession({ workingDir: '/home/dev', resumeSessionId: 'aaaa-bbbb' })).rejects.toThrow( + /no session id/ + ); + expect(recorded).toHaveLength(1); + }); +}); diff --git a/test/tui/tui-e2e.test.ts b/test/tui/tui-e2e.test.ts index 5ae094e3..0191f599 100644 --- a/test/tui/tui-e2e.test.ts +++ b/test/tui/tui-e2e.test.ts @@ -45,6 +45,8 @@ const LIST_WIDTH = computeLayout(COLS, ROWS).list.width; const NOW = Date.now(); const ALPHA = 'aaaa1111-0000-0000-0000-000000000000'; +/** The id the fake server hands back for a resumed conversation. */ +const RESUMED = 'dddd4444-0000-0000-0000-000000000000'; const BETA = 'bbbb2222-0000-0000-0000-000000000000'; /** Mutable so a test can add a session and announce it over SSE. */ @@ -68,6 +70,10 @@ let liveState: Array> = []; let terminalReads = 0; /** Everything the TUI posted, so a test can assert on the exact body. */ const answered: Array<{ id: string; body: Record }> = []; +/** `POST /api/sessions` bodies: resuming a RECENT row is the only thing that sends one. */ +const created: Array> = []; +/** Sessions the TUI asked to start a pane for, in order. */ +const started: string[] = []; const inputs: Array<{ sessionId: string; body: Record }> = []; const PLAN_USAGE = { @@ -282,9 +288,22 @@ beforeAll(async () => { } if (url.startsWith('/api/sessions/unified')) return sendJson(res, { success: true, data: { sessions } }); if (url === '/api/sessions' || url.startsWith('/api/sessions?')) { + if (req.method === 'POST') { + void readBody(req).then((body) => { + created.push(body); + sendJson(res, { success: true, data: { session: { id: RESUMED } } }); + }); + return; + } return sendJson(res, { success: true, data: liveState }); } + const startFor = sessionRoute(url, 'interactive'); + if (startFor) { + started.push(startFor); + return sendJson(res, { success: true, data: {} }); + } + const previewFor = sessionRoute(url, 'terminal'); if (previewFor) { terminalReads++; @@ -662,6 +681,36 @@ describe('codeman tui (under a pty)', () => { await waitFor(() => !frameLines(output).join('\n').includes('no longer on screen'), 'escape to dismiss it'); }); + it('resumes a RECENT row exactly ONCE, however long its pane takes to appear', async () => { + await waitFor(() => frameLines(output)[ROWS - 1].includes('attach'), 'the list to have focus'); + for (let i = 0; i < 8 && !rowFor(output, 'w3-gamma').startsWith('>'); i++) { + term.write('\u001b[B'); + await new Promise((done) => setTimeout(done, 120)); + } + await waitFor(() => rowFor(output, 'w3-gamma').startsWith('>'), 'the history row to be selected'); + + term.write('\r'); + await waitFor(() => created.length > 0, 'the resume POST'); + expect(created[0]).toMatchObject({ + // The conversation, in the directory it ran in, as a claude session. + resumeSessionId: 'cccc3333-0000-0000-0000-000000000000', + workingDir: '/tmp/gamma', + mode: 'claude', + name: 'w3-gamma', + }); + await waitFor(() => started.includes(RESUMED), 'the resumed session to be started'); + + // The pane never appears here (the child runs on an empty tmux socket), which + // is precisely the case that used to re-enter the resume: one session per + // second until something killed it. One press must stay one session. + await new Promise((done) => setTimeout(done, 2_500)); + expect(created).toHaveLength(1); + expect(started).toEqual([RESUMED]); + + term.write('\u001b'); + await waitFor(() => frameLines(output)[ROWS - 1].includes('attach'), 'the list to take focus back'); + }); + it('quits on q and restores the screen it took over', async () => { term.write('q'); await waitFor(() => exitCode !== null, 'the TUI to exit', 10_000);