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
+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);