mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
+1231
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user