feat: add the codeman tui dashboard

The IO half of src/tui: it owns the terminal, the timers, stdin and the
tmux handoff, and every decision it makes that is a function of its
inputs is an exported pure helper with unit tests (attach planning, the
typed kill confirmation, keymap selection, the repaint test, degraded
rows).

What it does: live session list over the unified API with SSE-driven
resync (debounced, with a 2s poll fallback the client asks for), cursor
and 1-9 navigation, attach and return, kill behind a typed confirmation
that refuses history rows and the session hosting the TUI, a new-session
case and CLI picker over quick-start, and degraded mode straight from
tmux when no server answers, re-probing so a server that starts upgrades
the dashboard in place.

Restoring the terminal is the part that has to be bulletproof: leave() is
idempotent and runs from normal quit, SIGINT/SIGTERM, a process exit hook
and prepended fatal handlers (src/index.ts already handles those by
exiting, so a listener registered after it would never run).

Attach is a handoff, never a proxy: the screen is restored and tmux gets
the real terminal. Inside tmux on the same socket there is nothing to
hand off to, so it issues switch-client and exits.

The preview pane, approvals answering, the prompt composer, search and
the digest are the next step; the region renders a placeholder rather
than pretending to load something.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-22 14:13:58 +02:00
parent 85b69e0923
commit e5ae2826c6
2 changed files with 1531 additions and 0 deletions
+1231
View File
File diff suppressed because it is too large Load Diff
+300
View File
@@ -0,0 +1,300 @@
/**
* @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 {
applyMuxNames,
buildListLines,
confirmAccepts,
confirmKillStep,
footerKeysFor,
helpKeysFor,
isSelfSession,
planAttach,
sameFrame,
shouldAnimate,
tmuxRowsToSessions,
tmuxSocketFromEnv,
} from '../../src/tui/tui-app.js';
import { createTuiModel } from '../../src/tui/tui-model.js';
import { glyphsFor } from '../../src/tui/tui-render.js';
import type { 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'],
hint: expect.stringContaining('Ctrl+B D'),
});
});
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('x kill');
expect(keys).toContain('q quit');
for (const missing of ['prompt', 'search', 'digest', 'answer', 'resume']) {
expect(keys).not.toContain(missing);
}
});
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');
});
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(help.flat().join(' ')).not.toContain('search');
expect(helpKeysFor(GLYPHS, { server: false }).flat().join(' ')).not.toContain('kill');
});
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');
});
});
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('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);
});
});