Files
Codeman/test/tui/tui-app.test.ts
T
Codeman maintainer 84f8a8a2fe feat(tui): offer r to resume a session whose pane has died
Refusing the attach stopped the freeze but told the user to throw the session
away (`x` to close, `n` for new), which loses the conversation. tmux's own
dead-pane screen already says what to do instead: `claude --resume "<name>"`.

The Error card now offers `r` when the row can actually be resumed (claude,
with a conversation id and a working directory), and the footer says so. One
press resumes into a fresh pane and attaches to it, so a dead end becomes
recovery.

⚠️ Three things keep this from becoming the resume runaway that once spawned 35
sessions in 40 seconds. The offer holds a session ID, not a row, and is
re-resolved from the model when the key is pressed: a row captured when the
card opened is stale by then. It disarms BEFORE anything async, so a second `r`
cannot start a second resume. And it routes through resumeSelected(), which
owns the `resuming` flag and ends in attachToSession() rather than the group
dispatch.

⚠️ The `r` branch has to run BEFORE the generic dismiss, because a message
overlay is dismissed by ANY key: without that ordering the offer is consumed as
"some key was pressed" and the card merely closes. `help` keeps the any-key
behaviour, so the two modes no longer share a case.

Verified end to end against a genuinely dead claude pane: card, footer, one
press, one new session, and F12 back to the dashboard.
2026-08-22 14:13:58 +02:00

673 lines
30 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,
heldCtrlAlias,
ONE_KEY_DETACH,
nextSessionName,
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 dead-row resume offer', () => {
it('advertises r on the message footer only while an offer is armed', () => {
const base = { server: true } as const;
expect(footerKeysFor('message', GLYPHS, { ...base, resumeOffer: true })).toEqual(['r resume', 'esc dismiss']);
// Without an offer the card is a plain notice, and a footer promising `r`
// would be advertising a key that does nothing.
expect(footerKeysFor('message', GLYPHS, base)).toEqual(['esc dismiss']);
});
});
describe('the one-key way out', () => {
it('names a single key with no modifier at all', () => {
// The whole point: three beta rounds died on a chord that had to be typed
// in the right order with the modifier released at the right moment.
expect(ONE_KEY_DETACH).toBe('F12');
expect(ONE_KEY_DETACH).not.toContain('C-');
expect(ONE_KEY_DETACH).not.toContain('+');
});
it('puts ONE instruction on the bar, not a menu of ways out', () => {
const banner = buildAttachBanner({ prefix: 'C-b', detachKey: 'd', heldAlias: 'C-d', oneKey: 'F12' });
const bar = banner['status-format[0]'];
expect(bar).toContain('press #[bold]F12#[nobold] to get back to the codeman dashboard');
// Even though both fallbacks still work, the bar must not offer them: a bar
// listing three ways to leave is what the tester called way too complicated.
expect(bar).not.toContain('Ctrl+B');
expect(bar).not.toContain('or Ctrl+D');
});
it('falls back to the chord when the key could not be claimed', () => {
// Never advertise a key we did not get: a bar naming an inert key is the
// original bug, in a new costume.
const bar = buildAttachBanner({ prefix: 'C-b', detachKey: 'd', heldAlias: 'C-d' })['status-format[0]'];
expect(bar).toContain('Ctrl+B then d');
expect(bar).toContain('(or Ctrl+D)');
expect(bar).not.toContain('F12');
});
});
describe('the held-Ctrl detach alias', () => {
it('names the key a user produces when they never let go of Ctrl', () => {
// The failure this exists for: "Ctrl+B then d" typed as one held chord
// sends 0x02 then 0x04, and tmux leaves C-d unbound, so nothing happens.
expect(heldCtrlAlias('d')).toBe('C-d');
});
it('lowercases, so a rebound uppercase key still yields the chord it produces', () => {
expect(heldCtrlAlias('Q')).toBe('C-q');
});
it('has no alias for a key with no held-Ctrl form', () => {
expect(heldCtrlAlias('F1')).toBeNull();
expect(heldCtrlAlias('C-d')).toBeNull();
expect(heldCtrlAlias('')).toBeNull();
expect(heldCtrlAlias('1')).toBeNull();
});
it('advertises the alias on the bar only once it has been claimed', () => {
const withAlias = buildAttachBanner({ prefix: 'C-b', detachKey: 'd', heldAlias: 'C-d' });
expect(withAlias['status-format[0]']).toContain('Ctrl+B then d');
expect(withAlias['status-format[0]']).toContain('(or Ctrl+D)');
// Not claimed (the key was already bound to something of the user's) means
// not advertised: a bar naming a key that does nothing is the original bug.
expect(buildAttachBanner({ prefix: 'C-b', detachKey: 'd' })['status-format[0]']).not.toContain('or Ctrl');
});
});
describe('naming a session the TUI starts', () => {
it("follows the web UI's w<n>-<case> convention", () => {
expect(nextSessionName('mirofish', [])).toBe('w1-mirofish');
expect(nextSessionName('mirofish', ['w1-codeman', 'w2-codeman'])).toBe('w3-mirofish');
});
it('counts past names that are not w<n>- at all', () => {
// A session named by hand, or by another surface, must not reset the run.
expect(nextSessionName('demo', ['tui-demo-agent', 'w4-codeman', ''])).toBe('w5-demo');
});
it('never returns an empty name, which is what caused the bad label', () => {
// An unnamed session falls through rowLabel() to the transcript's first
// line, which put "Login interrupted" in the list as a session name.
expect(nextSessionName('c', [])).not.toBe('');
expect(nextSessionName('c', ['w9007199254740991-x'])).toMatch(/^w\d+-c$/);
});
});
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', 'd')).toBe('Ctrl+A then d');
expect(detachChord()).toBe('Ctrl+B then d');
});
it('names the LOWERCASE detach key, because capital D is choose-client', () => {
// Regression: the bar shipped reading `Ctrl+B D`, and a beta tester pressing
// exactly that landed in tmux's client chooser while staying attached. tmux
// key tables are case-sensitive and `D` is bound to a different command.
expect(detachChord()).not.toContain(' D');
expect(detachChord()).toMatch(/ then d$/);
expect(buildAttachBanner({})['status-format[0]']).not.toContain('#[bold]Ctrl+B D#[nobold]');
});
it('prints a rebound detach key verbatim, never uppercased like the prefix', () => {
// formatPrefixKey() uppercases (`C-a` → `Ctrl+A`); running the detach key
// through it would reintroduce the same class of bug on a rebound tmux.
expect(detachChord('C-a', 'q')).toBe('Ctrl+A then q');
expect(buildAttachBanner({ prefix: 'C-a', detachKey: 'q' })['status-format[0]']).toContain('Ctrl+A then q');
});
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]', 'status-style']);
expect(banner.status).toBe('on');
expect(banner['status-format[0]']).toContain('#[bold]Ctrl+B then d#[nobold]');
expect(banner['status-format[0]']).toContain('#[align=right] w3-codeman ');
});
it('sets status-style, or tmux paints its stock green bar under the bar', () => {
// Regression: styling only status-format[0] left tmux's default
// `bg=green,fg=black` status-style underneath, which a beta tester saw as a
// full-width bright green slab across the bottom of the pane.
const banner = buildAttachBanner({ prefix: 'C-b' });
expect(banner['status-style']).toBe('bg=default,fg=default');
expect(banner['status-format[0]']).not.toContain('#[reverse]');
});
it('carries the remapped prefix into the bar', () => {
expect(buildAttachBanner({ prefix: 'C-a' })['status-format[0]']).toContain('Ctrl+A then 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 then d' });
const detach = keys.find(([key]) => key === 'Ctrl+A then 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 then 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',
});
});
});