mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 07:59:42 +02:00
Attaching made every other session disappear: the dashboard is gone, tmux owns
the terminal, and there is nothing left saying what else is running. The attach
bar now carries the session strip, numbered exactly as the dashboard numbers
them, with the session you are in inverted, and it sits at the TOP of the pane
where the web UI keeps its tabs.
The strip is a WINDOW around the active tab, not the whole list, with ellipses
marking each end that is actually cut. The bar is one line shared with the way
out, and that hint is the only instruction a user gets while tmux has the
terminal, so it must never be crowded off; a test drives 20 long-named sessions
through the bar and asserts it survives.
⚠️ The strip is a snapshot taken at attach time and never refreshed. The TUI is
blocked in `spawnSync` for the whole attach so there is no loop to update from,
and tmux's own format language cannot map a `codeman-<hex>` session name back
to a label a human recognises. Slightly stale beats absent.
The way out moves from F12 to F1, which sits beside Esc where a hand backing
out already goes. Verified against BOTH encodings a terminal sends for it:
xterm's SS3 (ESC O P) and PuTTY's default (ESC [ 1 1 ~).
`status-position` joins the snapshot, so a session that had its bar at the
bottom gets it back there on detach along with everything else.
727 lines
32 KiB
TypeScript
727 lines
32 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,
|
|
confirmKillStep,
|
|
detachChord,
|
|
heldCtrlAlias,
|
|
ONE_KEY_DETACH,
|
|
buildAttachTabs,
|
|
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('kills on y, upper or lower', () => {
|
|
// Was: type the session's full name. The tester's verdict on that was
|
|
// "thats stupid, just make me type Y to confirm", and they were right —
|
|
// `x` then `y` is already two deliberate keystrokes on a selected row.
|
|
expect(confirmKillStep(state, { type: 'char', value: 'y' })).toEqual({ kind: 'confirm' });
|
|
expect(confirmKillStep(state, { type: 'char', value: 'Y' })).toEqual({ kind: 'confirm' });
|
|
});
|
|
|
|
it('cancels on every other key, rather than leaving the prompt armed', () => {
|
|
// A dialog that ignores unknown keys sits there consuming whatever the
|
|
// user types next, which for a destructive prompt is the wrong default.
|
|
expect(confirmKillStep(state, { type: 'char', value: 'n' })).toEqual({ kind: 'cancel' });
|
|
expect(confirmKillStep(state, { type: 'char', value: 'x' })).toEqual({ kind: 'cancel' });
|
|
expect(confirmKillStep(state, { type: 'escape' })).toEqual({ kind: 'cancel' });
|
|
expect(confirmKillStep(state, { type: 'ctrl', key: 'c' })).toEqual({ kind: 'cancel' });
|
|
});
|
|
|
|
it('does NOT kill on Enter, the key most likely to be hit by reflex', () => {
|
|
expect(confirmKillStep(state, { type: 'enter' })).toEqual({ kind: 'cancel' });
|
|
});
|
|
});
|
|
|
|
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 (y to confirm)',
|
|
'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('y kill');
|
|
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. F1
|
|
// rather than F12 so it sits beside Esc, where a hand backing out goes.
|
|
expect(ONE_KEY_DETACH).toBe('F1');
|
|
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: 'F1' });
|
|
const bar = banner['status-format[0]'];
|
|
expect(bar).toContain('#[bold]F1#[nobold] 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 attach tab strip', () => {
|
|
const tabs = (count: number, activeIndex: number) =>
|
|
Array.from({ length: count }, (_, i) => ({ index: i + 1, label: `w${i + 1}-case`, active: i === activeIndex }));
|
|
|
|
it('draws every session when they all fit, numbered as the dashboard numbers them', () => {
|
|
const strip = buildAttachTabs(tabs(3, 1));
|
|
expect(strip).toContain('1 w1-case');
|
|
expect(strip).toContain('2 w2-case');
|
|
expect(strip).toContain('3 w3-case');
|
|
expect(strip).not.toContain('…');
|
|
});
|
|
|
|
it('inverts the session you are actually in', () => {
|
|
const strip = buildAttachTabs(tabs(3, 1));
|
|
expect(strip).toContain('#[reverse] 2 w2-case #[noreverse]');
|
|
expect(strip).not.toContain('#[reverse] 1 w1-case');
|
|
});
|
|
|
|
it('windows around the active tab rather than overflowing the bar', () => {
|
|
// Overflow would push the way-out hint off the end, which is the one thing
|
|
// on the bar that must survive.
|
|
const strip = buildAttachTabs(tabs(20, 9), 6);
|
|
expect(strip).toContain('10 w10-case');
|
|
expect(strip.startsWith('…')).toBe(true);
|
|
expect(strip.endsWith('…')).toBe(true);
|
|
expect(strip).not.toContain('1 w1-case ');
|
|
});
|
|
|
|
it('marks only the end that is actually cut', () => {
|
|
const first = buildAttachTabs(tabs(20, 0), 6);
|
|
expect(first.startsWith('…')).toBe(false);
|
|
expect(first.endsWith('…')).toBe(true);
|
|
const last = buildAttachTabs(tabs(20, 19), 6);
|
|
expect(last.startsWith('…')).toBe(true);
|
|
expect(last.endsWith('…')).toBe(false);
|
|
});
|
|
|
|
it('truncates a long session name instead of eating the whole strip', () => {
|
|
const strip = buildAttachTabs([{ index: 1, label: 'w1-an-extremely-long-session-name', active: true }]);
|
|
expect(strip).toContain('…');
|
|
expect(strip.length).toBeLessThan(60);
|
|
});
|
|
|
|
it('is empty with no sessions, so the bar falls back to the plain label', () => {
|
|
expect(buildAttachTabs([])).toBe('');
|
|
});
|
|
|
|
it('escapes a name that would otherwise open a tmux format', () => {
|
|
expect(buildAttachTabs([{ index: 1, label: 'fix #42', active: false }])).toContain('fix ##42');
|
|
});
|
|
});
|
|
|
|
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-position', 'status-style']);
|
|
expect(banner.status).toBe('on');
|
|
// Top, where the web UI keeps its tabs.
|
|
expect(banner['status-position']).toBe('top');
|
|
expect(banner['status-format[0]']).toContain('#[bold]Ctrl+B then d#[nobold]');
|
|
// With no strip to draw, the session's own name is the fallback.
|
|
expect(banner['status-format[0]']).toContain('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 left = (banner['status-format[0]'].split('#[align=right]')[0] ?? '').replace('#[align=left]', '');
|
|
// 28 characters of label plus the space either side.
|
|
expect(left.length).toBeLessThanOrEqual(30);
|
|
expect(left).toContain('…');
|
|
expect(banner['status-format[0]']).toContain('back to the codeman dashboard');
|
|
});
|
|
|
|
it('always keeps the way out on the bar, whatever else is on it', () => {
|
|
// The hint is the one thing that must never be crowded off: it is the only
|
|
// instruction a user gets while tmux owns the terminal.
|
|
const crowded = buildAttachBanner({
|
|
oneKey: 'F1',
|
|
tabs: Array.from({ length: 20 }, (_, i) => ({
|
|
index: i + 1,
|
|
label: `w${i + 1}-a-long-session-name`,
|
|
active: i === 9,
|
|
})),
|
|
});
|
|
expect(crowded['status-format[0]']).toContain('#[bold]F1#[nobold] back to the codeman dashboard');
|
|
});
|
|
|
|
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',
|
|
});
|
|
});
|
|
});
|