fix(tui): make an attach fit the terminal, show the way out, and resume history

Three things the first beta test surfaced.

1. Attaching from a terminal of a different shape showed the pane clipped to the
   browser's size, with tmux's dot padding filling the rest. Codeman pins every
   window it owns to `window-size manual` at whatever the web client reports
   (tmux-manager.ts), so no attaching client can resize it. The handoff now
   brackets the attach with `window-size latest` and restores the snapshot on
   detach. `latest`, rather than a one-off resize to our own size, is also what
   lets a terminal resized MID-attach follow along: tmux recomputes on every
   SIGWINCH while the TUI is blocked in spawnSync and cannot.

2. Nothing on screen said how to get back out, because Codeman keeps the status
   bar off on its panes (the web UI carries that information around the terminal
   instead). The tester exited the agent looking for the exit, leaving a dead
   pane. An attach now wears a `status-format[0]` bar reading "<prefix> D
   detach, back to the codeman dashboard", with the prefix READ from tmux rather
   than assumed, and the session's options are put back exactly as they were on
   detach. One option, not status-left/status-right, so tmux draws no window
   list beside it; `reverse` so it inherits the terminal's own theme. Restoring
   an array option unsets the BASE name, since dropping the `[0]` index leaves
   an empty array, which renders as a blank bar on a session that had one. The
   help overlay names the chord, and the dashboard confirms the detach.

3. Enter on a RECENT row said resuming was not wired up. It now creates a
   session carrying that conversation (`resumeSessionId` plus `/interactive`,
   the path the web UI's Resume Conversation list already uses), in the
   directory it ran in and under its old name, then attaches to it.

   The attach mechanics deliberately sit in a method the group dispatch cannot
   reach, plus a re-entrancy flag: routing resume back through the Enter handler
   re-dispatched on "this row is RECENT" and spawned one session per pass, 35 in
   about 40 seconds on the beta before it was killed. test/tui/tui-e2e.test.ts
   pins one press to one session with a pane that never appears, which is
   exactly the case that looped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-22 14:13:58 +02:00
parent 75b272ff0a
commit 0919f9da62
6 changed files with 1017 additions and 11 deletions
+18 -1
View File
@@ -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.
+293 -7
View File
@@ -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<string, string> {
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 <session>` 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<void>;
}
/**
* Prepare a tmux window for a human terminal: let it follow the attaching
* client's shape, and give it a status bar naming the way out. Both halves are
* best-effort and both are put back by `restore()`, so a session that was
* `window-size manual` with no status bar (what Codeman creates) is exactly
* that again after the detach.
*/
export async function beginAttachHandoff(client: TuiClient, muxName: string, label: string): Promise<TuiAttachHandoff> {
const prefix = (await client.readPrefixKey(muxName)) ?? undefined;
// 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<void> {
// 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<void> {
if (this.resuming) return;
if (this.model.connection === 'degraded') {
this.message('warn', 'resuming needs the server; only attach works while it is down');
return;
}
const plan = planResume(row.session);
if (plan.kind === 'refuse') {
this.message('warn', plan.message);
return;
}
// The flag is the loop breaker, not decoration: a resume ends in an attach,
// and one that could re-enter this method would spawn a session per pass.
this.resuming = true;
let sessionId: string;
try {
this.notice(`resuming ${truncateLabel(rowLabel(row.session), RESUME_NOTICE_WIDTH)}…`);
this.paint(true);
sessionId = await this.client.resumeSession({
workingDir: plan.workingDir,
resumeSessionId: plan.resumeSessionId,
...(plan.sessionName ? { sessionName: plan.sessionName } : {}),
});
} catch (error) {
this.message('err', `could not resume that conversation: ${getErrorMessage(error)}`);
this.paint(true);
return;
} finally {
this.resuming = false;
}
// Selected whichever way the race goes: a row that is not in the model yet
// is picked up by the next resync instead.
this.pendingSelectId = sessionId;
const fresh = await this.awaitResumedRow(sessionId);
if (!fresh) {
this.message('info', 'resumed; its pane is still starting — press ⏎ on the new row when it appears');
this.paint(true);
return;
}
this.pendingSelectId = null;
await this.attachToSession(fresh);
}
/**
* Wait for the resumed session to exist as a LIVE row with a pane, so the
* attach that follows has something to attach to. Bounded, and it gives up by
* returning null rather than by trying again from the top.
*/
private async awaitResumedRow(sessionId: string): Promise<TuiRow | null> {
const deadline = Date.now() + RESUME_PANE_TIMEOUT_MS;
for (;;) {
if (await this.awaitPane(sessionId, 0)) {
await this.refresh();
if (this.model.select(sessionId)) {
const row = this.model.selectedSession();
if (row && row.group !== 'recent' && (row.session.muxName ?? '').trim()) return row;
}
}
if (Date.now() >= deadline) return null;
await new Promise((resolve) => setTimeout(resolve, RESUME_PANE_POLL_MS));
}
}
/**
* Wait for a just-created session's tmux pane to exist, by the same prefix
* join `applyMuxNames()` uses. Enumeration is the only honest evidence the
* pane is really there; deriving `codeman-<prefix>` by hand would attach to a
* name that may not exist yet.
*/
private async awaitPane(sessionId: string, timeoutMs = RESUME_PANE_TIMEOUT_MS): Promise<string | null> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const tmux = await this.client.enumerateTmuxSessions().catch(() => []);
const match = tmux.find((entry) => entry.sessionId === sessionId || sessionId.startsWith(entry.sessionIdPrefix));
if (match) return match.muxName;
if (Date.now() >= deadline) return null;
await new Promise((resolve) => setTimeout(resolve, RESUME_PANE_POLL_MS));
}
}
private async attachSelected(): Promise<void> {
const row = this.model.selectedSession();
if (!row) return;
if (row.group === 'recent') {
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<void> {
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;
+275 -1
View File
@@ -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<string, string | null>;
/**
* Parse `show-options -t <session>` (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<string, string>();
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<SseStream>();
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<string> {
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<CaseInfo[]> {
const data = await this.requestData<CaseInfo[]>('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<TuiTmuxSession[]> {
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<TuiWindowSizing | null> {
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<boolean> {
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<string | null> {
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<TuiSessionOptions | null> {
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<string, string>): Promise<void> {
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<void> {
if (!MUX_NAME_PATTERN.test(muxName)) return;
const arrays = new Set<string>();
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<void> {
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<void> {
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 ───────────────────────────────────────────────────────────
+110 -2
View File
@@ -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',
});
});
});
+272
View File
@@ -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<readonly string[]> = [];
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<readonly string[]> = [];
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<readonly string[]> = [];
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<readonly string[]> = [];
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<readonly string[]> = [];
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<readonly string[]> = [];
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<readonly string[]> = [];
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<readonly string[]> = [];
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<readonly string[]> = [];
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);
});
});
+49
View File
@@ -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<Record<string, unknown>> = [];
let terminalReads = 0;
/** Everything the TUI posted, so a test can assert on the exact body. */
const answered: Array<{ id: string; body: Record<string, unknown> }> = [];
/** `POST /api/sessions` bodies: resuming a RECENT row is the only thing that sends one. */
const created: Array<Record<string, unknown>> = [];
/** Sessions the TUI asked to start a pane for, in order. */
const started: string[] = [];
const inputs: Array<{ sessionId: string; body: Record<string, unknown> }> = [];
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);