Files
Codeman/test/tui/tui-app.test.ts
T
Codeman maintainer 0919f9da62 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>
2026-08-22 14:13:58 +02:00

559 lines
24 KiB
TypeScript

/**
* @fileoverview Unit tests for the decisions `codeman tui` makes, without a
* terminal.
*
* Everything the app does that can be stated as a function of its inputs is
* exported from `tui-app.ts` for exactly this reason: the attach handoff (which
* changes shape inside tmux), the typed kill confirmation, the footer's honest
* key inventory, the repaint test and the row building for degraded mode. The
* full-screen loop itself is covered end-to-end under node-pty in
* `tui-e2e.test.ts`.
*/
import { describe, it, expect } from 'vitest';
import {
applyLiveMetrics,
applyMuxNames,
buildAttachBanner,
buildListLines,
confirmAccepts,
confirmKillStep,
detachChord,
footerKeysFor,
formatPrefixKey,
helpKeysFor,
isSelfSession,
planAttach,
planResume,
previewIntervalMs,
previewNoteFor,
resyncDelayMs,
sameFrame,
samePreview,
shouldAnimate,
shouldFetchPreview,
tmuxRowsToSessions,
tmuxSocketFromEnv,
} from '../../src/tui/tui-app.js';
import { createTuiModel, stateSince } from '../../src/tui/tui-model.js';
import { glyphsFor } from '../../src/tui/tui-render.js';
import type { TuiLiveSessionMetrics, TuiTmuxSession } from '../../src/tui/tui-client.js';
import type { TuiConfirmState, TuiRow, TuiSessionRow } from '../../src/tui/tui-types.js';
const GLYPHS = glyphsFor('unicode');
function tmuxSession(overrides: Partial<TuiTmuxSession> & { muxName: string }): TuiTmuxSession {
return {
sessionIdPrefix: overrides.muxName.replace(/^codeman-/, ''),
attached: false,
...overrides,
};
}
function row(state: TuiRow['state'], sessionId = 'abcdef0123'): TuiRow {
const session: TuiSessionRow = { sessionId, sources: ['live'] };
return { session, state, group: state === 'working' ? 'working' : 'idle', since: 0 };
}
describe('tmuxSocketFromEnv', () => {
it('reads the socket name out of a $TMUX value', () => {
expect(tmuxSocketFromEnv('/tmp/tmux-1000/codeman,31415,0')).toBe('codeman');
expect(tmuxSocketFromEnv('/tmp/tmux-1000/codeman-beta,7,2')).toBe('codeman-beta');
// A bare default socket is still the name a `-L` comparison needs.
expect(tmuxSocketFromEnv('/tmp/tmux-1000/default,7,2')).toBe('default');
});
it('reports "not inside tmux" for an absent or empty value', () => {
expect(tmuxSocketFromEnv(undefined)).toBeNull();
expect(tmuxSocketFromEnv('')).toBeNull();
expect(tmuxSocketFromEnv(' ')).toBeNull();
});
});
describe('planAttach', () => {
it('attaches with the instance socket when the terminal is not inside tmux', () => {
const plan = planAttach('codeman-abcdef01', { socket: 'codeman' });
expect(plan).toEqual({
kind: 'attach',
file: 'tmux',
args: ['-L', 'codeman', 'attach-session', '-t', 'codeman-abcdef01'],
});
});
it('switches the current client when already inside tmux on the same socket', () => {
const plan = planAttach('codeman-abcdef01', { socket: 'codeman', tmux: '/tmp/tmux-1000/codeman,31415,0' });
expect(plan).toEqual({
kind: 'switch',
file: 'tmux',
args: ['-L', 'codeman', 'switch-client', '-t', 'codeman-abcdef01'],
});
});
it('refuses to nest when the surrounding tmux is a different server', () => {
const plan = planAttach('codeman-abcdef01', { socket: 'codeman', tmux: '/tmp/tmux-1000/default,31415,0' });
expect(plan.kind).toBe('refuse');
if (plan.kind !== 'refuse') throw new Error('expected a refusal');
expect(plan.reason).toBe('nested-foreign-socket');
expect(plan.message).toContain('default');
expect(plan.message).toContain('codeman');
});
it('refuses a row with no tmux session behind it', () => {
for (const name of [undefined, '', ' ']) {
const plan = planAttach(name, { socket: 'codeman' });
expect(plan.kind).toBe('refuse');
if (plan.kind !== 'refuse') throw new Error('expected a refusal');
expect(plan.reason).toBe('no-mux-name');
}
});
it('never builds a shell string: every field is its own argv entry', () => {
const plan = planAttach('codeman-abcdef01', { socket: 'codeman' });
if (plan.kind !== 'attach') throw new Error('expected an attach');
expect(plan.args.some((arg) => arg.includes(' '))).toBe(false);
});
});
describe('isSelfSession', () => {
const id = 'abcdef01-2345-6789-abcd-ef0123456789';
it('recognizes the session the TUI runs in, however the id was truncated', () => {
expect(isSelfSession(id, { CODEMAN_SESSION_ID: id })).toBe(true);
expect(isSelfSession(id, { CODEMAN_SESSION_ID: 'abcdef01' })).toBe(true);
expect(isSelfSession('abcdef01', { CODEMAN_SESSION_ID: id })).toBe(true);
});
it('is false for another session, and for env that identifies nothing', () => {
expect(isSelfSession(id, { CODEMAN_SESSION_ID: 'ffffffff' })).toBe(false);
expect(isSelfSession(id, {})).toBe(false);
expect(isSelfSession(id, { CODEMAN_SESSION_ID: 'abc' })).toBe(false);
});
});
describe('the kill confirmation', () => {
const state: TuiConfirmState = { sessionId: 'abcdef01-2345', name: 'w4-api', typed: '' };
it('accepts the shown name or the id prefix a mux name carries, and nothing else', () => {
expect(confirmAccepts(state, 'w4-api')).toBe(true);
expect(confirmAccepts(state, ' w4-api ')).toBe(true);
expect(confirmAccepts(state, 'abcdef01')).toBe(true);
expect(confirmAccepts(state, 'w4')).toBe(false);
expect(confirmAccepts(state, 'W4-API')).toBe(false);
expect(confirmAccepts(state, '')).toBe(false);
expect(confirmAccepts(state, ' ')).toBe(false);
});
it('types, backspaces and cancels', () => {
expect(confirmKillStep({ ...state, typed: 'w4' }, { type: 'char', value: '-' })).toEqual({
kind: 'typing',
typed: 'w4-',
});
expect(confirmKillStep({ ...state, typed: 'w4-' }, { type: 'backspace' })).toEqual({ kind: 'typing', typed: 'w4' });
expect(confirmKillStep({ ...state, typed: '' }, { type: 'backspace' })).toEqual({ kind: 'typing', typed: '' });
expect(confirmKillStep(state, { type: 'escape' })).toEqual({ kind: 'cancel' });
expect(confirmKillStep(state, { type: 'ctrl', key: 'c' })).toEqual({ kind: 'cancel' });
expect(confirmKillStep(state, { type: 'ctrl', key: 'a' })).toEqual({ kind: 'ignore' });
expect(confirmKillStep(state, { type: 'tab' })).toEqual({ kind: 'ignore' });
});
it('confirms only on a match, and says so rather than doing nothing otherwise', () => {
expect(confirmKillStep({ ...state, typed: 'w4-api' }, { type: 'enter' })).toEqual({ kind: 'confirm' });
expect(confirmKillStep({ ...state, typed: 'w4' }, { type: 'enter' })).toEqual({ kind: 'reject' });
expect(confirmKillStep({ ...state, typed: '' }, { type: 'enter' })).toEqual({ kind: 'reject' });
});
it('backspaces one whole character, not one code unit', () => {
expect(confirmKillStep({ ...state, typed: 'a🙂' }, { type: 'backspace' })).toEqual({ kind: 'typing', typed: 'a' });
});
});
describe('footerKeysFor', () => {
it('advertises only the verbs this build implements', () => {
const keys = footerKeysFor('list', GLYPHS, { server: true }).join(' ');
expect(keys).toContain('attach');
expect(keys).toContain('1-9 jump');
expect(keys).toContain('n new');
expect(keys).toContain('p prompt');
expect(keys).toContain('/ search');
expect(keys).toContain('g digest');
expect(keys).toContain('x kill');
expect(keys).toContain('q quit');
// Resuming a RECENT row is still phase 3.
expect(keys).not.toContain('resume');
});
it('swaps in the answer keys while a dialog is on the selected row', () => {
const keys = footerKeysFor('list', GLYPHS, { server: true, approval: 'menu' }).join(' ');
expect(keys).toContain('y approve');
expect(keys).toContain('n deny');
expect(keys).toContain('1-9 option');
// `n` cannot mean two things at once, and denying is what it does here.
expect(keys).not.toContain('n new');
expect(keys).not.toContain('1-9 jump');
});
it('sends an idle prompt to the composer instead of offering approve/deny', () => {
const keys = footerKeysFor('list', GLYPHS, { server: true, approval: 'idle' }).join(' ');
expect(keys).toContain('p reply');
expect(keys).toContain('n new');
expect(keys).not.toContain('y approve');
});
it('drops the server-only verbs in degraded mode', () => {
const keys = footerKeysFor('list', GLYPHS, { server: false }).join(' ');
expect(keys).toContain('attach');
expect(keys).not.toContain('kill');
expect(keys).not.toContain('new');
expect(keys).not.toContain('search');
});
it('keeps the help overlay to the same inventory', () => {
const help = helpKeysFor(GLYPHS, { server: true });
expect(help.map(([, description]) => description)).toEqual(
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(' ');
expect(degraded).not.toContain('kill');
expect(degraded).not.toContain('search');
});
it('follows the overlay that owns the keyboard', () => {
expect(footerKeysFor('help', GLYPHS, { server: true })).toEqual(['esc close']);
expect(footerKeysFor('confirm-kill', GLYPHS, { server: true }).join(' ')).toContain('type the name');
expect(footerKeysFor('message', GLYPHS, { server: true })).toEqual(['esc dismiss']);
expect(footerKeysFor('new-session', GLYPHS, { server: true }).join(' ')).toContain('type to filter');
expect(footerKeysFor('prompt', GLYPHS, { server: true }).join(' ')).toContain('send');
expect(footerKeysFor('search', GLYPHS, { server: true }).join(' ')).toContain('open');
expect(footerKeysFor('digest', GLYPHS, { server: true }).join(' ')).toContain('scroll');
});
});
describe('the preview policy', () => {
const live: TuiRow = { ...row('idle', 'aaaaaaaa11'), group: 'idle' };
const history: TuiRow = { ...row('recent', 'bbbbbbbb22'), group: 'recent' };
it('polls a live row only while the plain list is on screen and wide', () => {
const base = { mode: 'list' as const, narrow: false, connection: 'connected' as const, row: live };
expect(shouldFetchPreview(base)).toBe(true);
expect(shouldFetchPreview({ ...base, mode: 'prompt' })).toBe(false);
expect(shouldFetchPreview({ ...base, mode: 'search' })).toBe(false);
expect(shouldFetchPreview({ ...base, narrow: true })).toBe(false);
expect(shouldFetchPreview({ ...base, connection: 'degraded' })).toBe(false);
expect(shouldFetchPreview({ ...base, row: history })).toBe(false);
expect(shouldFetchPreview({ ...base, row: null })).toBe(false);
});
it('explains a history row instead of polling one', () => {
expect(previewNoteFor(history, 'connected')).toContain('not running');
expect(previewNoteFor(live, 'connected')).toBeNull();
// The renderer already says why a degraded server has no preview.
expect(previewNoteFor(history, 'degraded')).toBeNull();
expect(previewNoteFor(null, 'connected')).toBeNull();
});
it('treats an unchanged tail as nothing to repaint', () => {
const preview = { sessionId: 'a', lines: ['one', 'two'] };
expect(samePreview(preview, { sessionId: 'a', lines: ['one', 'two'] })).toBe(true);
expect(samePreview(preview, { sessionId: 'a', lines: ['one', 'three'] })).toBe(false);
expect(samePreview(preview, { sessionId: 'a', lines: ['one'] })).toBe(false);
expect(samePreview(preview, { sessionId: 'b', lines: ['one', 'two'] })).toBe(false);
expect(samePreview(preview, { sessionId: 'a', lines: ['one', 'two'], error: 'boom' })).toBe(false);
expect(samePreview(preview, { sessionId: 'a', lines: ['one', 'two'], note: 'history' })).toBe(false);
expect(samePreview(null, null)).toBe(true);
expect(samePreview(null, preview)).toBe(false);
});
});
describe('the refetch and tail-read cadence', () => {
it('debounces a burst but paces a stream, measured from the last start', () => {
// Nothing has been refetched yet: pay the debounce and nothing more.
expect(resyncDelayMs(10_000, 0, 250, 3_000)).toBe(250);
// A refetch that started 2.9s ago: wait out the rest of the floor.
expect(resyncDelayMs(10_000, 9_900, 250, 3_000)).toBe(2_900);
// Past the floor: back to the debounce, never below it.
expect(resyncDelayMs(10_000, 6_000, 250, 3_000)).toBe(250);
expect(resyncDelayMs(10_000, 1_000, 250, 3_000)).toBe(250);
});
it('reads a printing pane every second and a quiet one every five', () => {
expect(previewIntervalMs(0, 1_000, 5_000)).toBe(1_000);
expect(previewIntervalMs(1, 1_000, 5_000)).toBe(2_000);
expect(previewIntervalMs(2, 1_000, 5_000)).toBe(4_000);
// The ceiling holds however long the pane stays quiet, and a silly counter
// cannot overflow the doubling into Infinity.
expect(previewIntervalMs(3, 1_000, 5_000)).toBe(5_000);
expect(previewIntervalMs(50, 1_000, 5_000)).toBe(5_000);
expect(previewIntervalMs(-5, 1_000, 5_000)).toBe(1_000);
});
});
describe('the repaint test', () => {
const key = { revision: 3, cols: 100, rows: 30, tick: 0 };
it('repaints on a first frame, a state change, a resize and a tick', () => {
expect(sameFrame(null, key)).toBe(false);
expect(sameFrame(key, { ...key, revision: 4 })).toBe(false);
expect(sameFrame(key, { ...key, cols: 80 })).toBe(false);
expect(sameFrame(key, { ...key, rows: 24 })).toBe(false);
expect(sameFrame(key, { ...key, tick: 1 })).toBe(false);
});
it('writes nothing when nothing changed', () => {
expect(sameFrame(key, { ...key })).toBe(true);
});
it('only animates while a WORKING row is on screen', () => {
expect(shouldAnimate([row('idle'), row('recent')])).toBe(false);
expect(shouldAnimate([row('idle'), row('working', 'bbbb1111')])).toBe(true);
expect(shouldAnimate([])).toBe(false);
});
});
describe('degraded-mode rows', () => {
const sessions: TuiTmuxSession[] = [
tmuxSession({ muxName: 'codeman-abcdef01', sessionId: 'abcdef01-2345', name: 'w4-api', workingDir: '/dev/api' }),
tmuxSession({ muxName: 'codeman-99887766', createdAt: 1_000_000 }),
];
it('keeps the tmux name and falls back to it as the row key', () => {
const rows = tmuxRowsToSessions(sessions);
expect(rows[0]).toMatchObject({
sessionId: 'abcdef01-2345',
muxName: 'codeman-abcdef01',
name: 'w4-api',
workingDir: '/dev/api',
});
// No state.json entry: the mux name is the only identity there is.
expect(rows[1].sessionId).toBe('codeman-99887766');
expect(rows[1].muxName).toBe('codeman-99887766');
});
it('classifies a running pane as IDLE rather than history', () => {
const model = createTuiModel();
model.replaceSessions(tmuxRowsToSessions(sessions));
const groups = model.groups();
expect(groups.find((group) => group.key === 'idle')?.rows).toHaveLength(2);
expect(groups.find((group) => group.key === 'recent')?.rows).toHaveLength(0);
expect(model.sessionCount).toBe(2);
});
});
describe('applyMuxNames', () => {
const tmux: TuiTmuxSession[] = [
tmuxSession({ muxName: 'codeman-abcdef01', sessionId: 'abcdef01-2345-6789' }),
tmuxSession({ muxName: 'codeman-99887766' }),
];
it('joins on the 8-character prefix a mux name carries', () => {
const rows = applyMuxNames(
[
{ sessionId: 'abcdef01-2345-6789', sources: ['live'] },
{ sessionId: '99887766-0000-1111', sources: ['live'] },
{ sessionId: 'deadbeef-0000-1111', sources: ['history'] },
],
tmux
);
expect(rows[0].muxName).toBe('codeman-abcdef01');
expect(rows[1].muxName).toBe('codeman-99887766');
// Nothing in tmux backs it, so attach has to refuse rather than guess.
expect(rows[2].muxName).toBeUndefined();
});
it('copies rather than mutating its input, and survives an empty tmux list', () => {
const input: TuiSessionRow[] = [{ sessionId: 'abcdef01-2345-6789', sources: ['live'] }];
const rows = applyMuxNames(input, []);
expect(rows[0]).not.toBe(input[0]);
expect(rows[0].muxName).toBeUndefined();
expect(input[0].muxName).toBeUndefined();
});
});
describe('applyLiveMetrics', () => {
const metrics: TuiLiveSessionMetrics[] = [
{ sessionId: 'aaaa1111', lastSubmitAt: 5_000, inputTokens: 900, outputTokens: 100 },
{ sessionId: 'bbbb2222', lastSubmitAt: 0, inputTokens: 0, outputTokens: 0 },
];
it('folds the turn stamp and the token totals onto the matching row', () => {
const rows = applyLiveMetrics(
[
{ sessionId: 'aaaa1111', sources: ['live'] },
{ sessionId: 'cccc3333', sources: ['live'] },
],
metrics
);
expect(rows[0]).toMatchObject({ lastSubmitAt: 5_000, inputTokens: 900, outputTokens: 100 });
// No live counterpart (a history row): nothing to fold, nothing invented.
expect(rows[1].lastSubmitAt).toBeUndefined();
expect(rows[1].inputTokens).toBeUndefined();
});
it('treats a zero as unknown, so a never-submitted session is not dated to the epoch', () => {
const [merged] = applyLiveMetrics([{ sessionId: 'bbbb2222', sources: ['live'], createdAt: 1_000 }], metrics);
expect(merged.lastSubmitAt).toBeUndefined();
expect(merged.inputTokens).toBeUndefined();
expect(stateSince('working', merged)).toBe(1_000);
});
it('copies rather than mutating its input, and survives an empty metrics list', () => {
const input: TuiSessionRow[] = [{ sessionId: 'aaaa1111', sources: ['live'] }];
const rows = applyLiveMetrics(input, []);
expect(rows[0]).not.toBe(input[0]);
expect(rows[0].lastSubmitAt).toBeUndefined();
expect(input[0].lastSubmitAt).toBeUndefined();
});
it('orders the WORKING group by when the turn started, not by session age', () => {
// The regression this merge exists for: `old` was created a day before
// `fresh` but started its turn a minute AFTER it, so `fresh` has been
// working longer and has to lead. Without the merge both fall back to
// createdAt and `old` wins.
const unified: TuiSessionRow[] = [
{ sessionId: 'old00000', name: 'old', sources: ['live'], isWorking: true, createdAt: 1_000 },
{ sessionId: 'fresh000', name: 'fresh', sources: ['live'], isWorking: true, createdAt: 500_000 },
];
const turns: TuiLiveSessionMetrics[] = [
{ sessionId: 'old00000', lastSubmitAt: 900_000 },
{ sessionId: 'fresh000', lastSubmitAt: 600_000 },
];
const before = createTuiModel();
before.replaceSessions(unified);
expect(before.rows().map((r) => r.session.name)).toEqual(['old', 'fresh']);
const after = createTuiModel();
after.replaceSessions(applyLiveMetrics(unified, turns));
expect(after.rows().map((r) => r.session.name)).toEqual(['fresh', 'old']);
expect(after.rows()[0].since).toBe(600_000);
});
});
describe('buildListLines', () => {
it('numbers rows in the dashboard order, so `tui <n>` and `tui --list` agree', () => {
const model = createTuiModel();
model.replaceSessions([
{ sessionId: 'aaaa1111', name: 'quiet', sources: ['live'], lastActivityAt: 10 },
{ sessionId: 'bbbb2222', name: 'busy', sources: ['live'], isWorking: true, lastSubmitAt: 5 },
{ sessionId: 'cccc3333', name: 'past', sources: ['history'], lastActivityAt: 1 },
]);
expect(buildListLines(model.rows())).toEqual([
{ index: 1, state: 'working', label: 'busy', workingDir: '' },
{ index: 2, state: 'idle', label: 'quiet', workingDir: '' },
{ index: 3, state: 'recent', label: 'past', workingDir: '' },
]);
});
it('truncates the label so one long prompt cannot pad the whole table', () => {
const model = createTuiModel();
model.replaceSessions([{ sessionId: 'aaaa1111', firstPrompt: 'x'.repeat(200), sources: ['history'] }]);
const [line] = buildListLines(model.rows(), 20);
expect(line.label).toHaveLength(20);
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',
});
});
});