mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Four faults, all reported at once, and three of them were mine from the last two commits. THE HINT VANISHED. Two independent causes. First, a leaked F1 binding: an attach whose TUI was killed leaves `F1 -> detach-client` in tmux's root table, and the claim treated "already bound" as someone else's key, so every later attach fell back to advertising the tmux chord — the bar stopped saying F1 while F1 still worked. A key already bound to `detach-client` now counts as ours. Second, width: tmux truncates a status line that overflows and drops the RIGHT-aligned segment, which is the hint. The strip now gets a budget measured from the terminal's width minus the hint, and it drops tabs from the far end until it fits. ⚠️ Measured on VISIBLE columns, not format bytes: `#[reverse]` costs zero columns, and counting it made a strip that "fitted" still truncate the hint at 80, 100, 120 and 176 columns on a real terminal. ALT+N DID NOT SWITCH. On the dashboard, a bare digit meant jump AND ATTACH, and a terminal sends Alt+N as ESC then N: when those land in separate reads — routine over SSH — the chord decodes as Escape plus a bare digit, so "switch to tab 2" threw the user into tab 2's pane. A digit now SELECTS, matching what Alt+N means in the web UI; Enter is how you go in. Inside a pane the keys never reached the TUI at all, since tmux owns the terminal, so the attach now binds Alt+1..9 in tmux's root table to `switch-client` — the strip is usable rather than decorative. ⚠️ The bar is applied to every session the strip can reach, each highlighting its own tab: with it on the attached session only, switching landed the user in a pane with no strip and no way out on screen. ⚠️ The leaked-state sweep was missing `status-position`, so it removed the marker and left the position behind — and with no marker the leftover no longer matched, making it permanently unsweepable. Found by diffing every session's options after a detach.
685 lines
27 KiB
TypeScript
685 lines
27 KiB
TypeScript
/**
|
||
* @fileoverview Unit tests for the frame renderer.
|
||
*
|
||
* The structural expectations below are full frames with the escapes stripped,
|
||
* which is what makes a layout regression readable in a diff; the escape
|
||
* sequences themselves are asserted separately, including the promise that
|
||
* NO_COLOR leaves nothing but cursor addressing behind.
|
||
*/
|
||
import { describe, it, expect } from 'vitest';
|
||
import { charWidth, stripStyles, toDisplayLines, visibleWidth } from '../../src/tui/tui-ansi.js';
|
||
import { composerMove, createComposer } from '../../src/tui/tui-composer.js';
|
||
import { computeLayout, needsBanner } from '../../src/tui/tui-layout.js';
|
||
import { createTuiModel, type TuiModelStore } from '../../src/tui/tui-model.js';
|
||
import {
|
||
composerCursorCell,
|
||
detectGlyphTier,
|
||
digestCapacity,
|
||
formatElapsed,
|
||
formatPlanUsage,
|
||
formatTokens,
|
||
glyphsFor,
|
||
renderFrame,
|
||
rowLabel,
|
||
type TuiRenderOptions,
|
||
} from '../../src/tui/tui-render.js';
|
||
|
||
const NOW = 1_700_000_000_000;
|
||
|
||
const PLAIN: TuiRenderOptions = { color: false, glyphs: 'unicode', tick: 4, now: NOW };
|
||
|
||
function fixture(): TuiModelStore {
|
||
const model = createTuiModel();
|
||
model.setHeader({ hostname: 'tnode', version: '1.19.0', planUsage: '5h 32% wk 61%' });
|
||
model.replaceSessions([
|
||
{
|
||
sessionId: 'aaa1',
|
||
name: 'w4-api-refactor',
|
||
mode: 'claude',
|
||
status: 'busy',
|
||
isWorking: true,
|
||
workingDir: '/home/dev/api',
|
||
createdAt: NOW - 9_000_000,
|
||
lastActivityAt: NOW - 1_000,
|
||
lastSubmitAt: NOW - 134_000,
|
||
inputTokens: 9_000,
|
||
outputTokens: 3_300,
|
||
sources: ['live', 'persisted'],
|
||
},
|
||
{
|
||
sessionId: 'bbb2',
|
||
name: 'w6-docs',
|
||
mode: 'claude',
|
||
status: 'idle',
|
||
workingDir: '/home/dev/docs',
|
||
createdAt: NOW - 8_000_000,
|
||
lastActivityAt: NOW - 660_000,
|
||
sources: ['live'],
|
||
},
|
||
{
|
||
sessionId: 'ccc3',
|
||
name: 'w1-codeman',
|
||
mode: 'claude',
|
||
status: 'busy',
|
||
isWorking: true,
|
||
workingDir: '/home/dev/codeman',
|
||
createdAt: NOW - 10_000_000,
|
||
lastActivityAt: NOW,
|
||
lastSubmitAt: NOW - 1_020_000,
|
||
inputTokens: 40_000,
|
||
outputTokens: 5_200,
|
||
sources: ['live'],
|
||
},
|
||
{
|
||
sessionId: 'ddd4',
|
||
name: 'w2-gallery',
|
||
mode: 'codex',
|
||
status: 'idle',
|
||
workingDir: '/home/dev/gallery',
|
||
createdAt: NOW - 6_000_000,
|
||
lastActivityAt: NOW - 7_200_000,
|
||
sources: ['live'],
|
||
},
|
||
{
|
||
sessionId: 'eee5',
|
||
firstPrompt: 'fix the release script',
|
||
workingDir: '/home/dev/api',
|
||
lastActivityAt: NOW - 3 * 86_400_000,
|
||
sources: ['history'],
|
||
},
|
||
]);
|
||
model.setApprovals([
|
||
{ id: 'bbb2:1', sessionId: 'bbb2', sessionName: 'w6-docs', kind: 'idle', createdAt: NOW - 680_000 },
|
||
{
|
||
id: 'aaa1:2',
|
||
sessionId: 'aaa1',
|
||
sessionName: 'w4-api-refactor',
|
||
kind: 'permission',
|
||
createdAt: NOW - 120_000,
|
||
toolName: 'Bash',
|
||
toolSummary: 'Bash(git push origin main)',
|
||
options: [
|
||
{ n: 1, label: 'Yes' },
|
||
{ n: 2, label: "Yes, don't ask again" },
|
||
{ n: 3, label: 'No, tell Claude what to do' },
|
||
],
|
||
},
|
||
]);
|
||
model.select('aaa1');
|
||
model.setPreview({
|
||
sessionId: 'aaa1',
|
||
lines: toDisplayLines('\x1b[32mActualizing...\x1b[0m (2m 14s)\nrunning tests\n\x1b[31mwarning\x1b[0m here\n'),
|
||
});
|
||
return model;
|
||
}
|
||
|
||
function render(model: TuiModelStore, cols: number, rows: number, opts: Partial<TuiRenderOptions> = {}): string {
|
||
const layout = computeLayout(cols, rows, { banner: needsBanner(model.connection) });
|
||
return renderFrame(model, layout, { ...PLAIN, ...opts });
|
||
}
|
||
|
||
/** The frame as visible lines: escapes stripped, trailing padding trimmed. */
|
||
function frameLines(frame: string): string[] {
|
||
return frame
|
||
.split(/\x1b\[\d+;1H/)
|
||
.slice(1)
|
||
.map((part) => stripStyles(part).trimEnd());
|
||
}
|
||
|
||
describe('renderFrame structure', () => {
|
||
it('paints the wide layout at 100x30', () => {
|
||
expect(frameLines(render(fixture(), 100, 30))).toEqual([
|
||
' codeman ▲ 2 tnode · v1.19.0 · 4 sessions · 5h 32% wk 61% ? help q quit',
|
||
' NEEDS YOU ─────────────────────────│ w4-api-refactor · claude · /home/dev/api · blocked',
|
||
' 1 w6-docs ! 11m│ ▲ requests: Bash(git push origin main)',
|
||
'▶ 2 w4-api-refactor ▲ 2m 12.3k│ 1. Yes',
|
||
" WORKING ───────────────────────────│ 2. Yes, don't ask again",
|
||
' 3 w1-codeman ▖ 17m 45.2k│ 3. No, tell Claude what to do',
|
||
' IDLE ──────────────────────────────│ y approve · n deny · digit chooses',
|
||
' 4 w2-gallery codex ○ 2h│',
|
||
' RECENT ────────────────────────────│ Actualizing... (2m 14s)',
|
||
' 5 fix the release script ✔ 3d│ running tests',
|
||
' │ warning here',
|
||
' │',
|
||
' │',
|
||
' │',
|
||
' │',
|
||
' │',
|
||
' │',
|
||
' │',
|
||
' │',
|
||
' │',
|
||
' │',
|
||
' │',
|
||
' │',
|
||
' │',
|
||
' │',
|
||
' │',
|
||
' │',
|
||
' │',
|
||
' │',
|
||
' ↑↓ select · ↵ attach · 1-9 switch · y/n answer · p prompt · n new · x kill · / search · g digest ·',
|
||
]);
|
||
});
|
||
|
||
it('paints the narrow two-line layout at 44x20', () => {
|
||
expect(frameLines(render(fixture(), 44, 20))).toEqual([
|
||
' codeman ▲ 2 tnode · v1.19.0 · 4 sessions',
|
||
' NEEDS YOU ─────────────────────────────────',
|
||
' 1 w6-docs ! 11m',
|
||
' /home/dev/docs',
|
||
'▶ 2 w4-api-refactor ▲ 2m',
|
||
' /home/dev/api · 12.3k',
|
||
' WORKING ───────────────────────────────────',
|
||
' 3 w1-codeman ▖ 17m',
|
||
' /home/dev/codeman · 45.2k',
|
||
' IDLE ──────────────────────────────────────',
|
||
' 4 w2-gallery codex ○ 2h',
|
||
' /home/dev/gallery · codex',
|
||
' RECENT ────────────────────────────────────',
|
||
' 5 fix the release script ✔ 3d',
|
||
' /home/dev/api',
|
||
'',
|
||
'',
|
||
'',
|
||
'',
|
||
' ↑↓ select · ↵ attach · 1-9 switch · y/n ans',
|
||
]);
|
||
});
|
||
|
||
it('addresses every line absolutely and erases its tail', () => {
|
||
const frame = render(fixture(), 100, 30);
|
||
const addresses = [...frame.matchAll(/\x1b\[(\d+);1H/g)].map((match) => Number(match[1]));
|
||
expect(addresses).toEqual(Array.from({ length: 30 }, (_, i) => i + 1));
|
||
expect(frame.split('\x1b[K')).toHaveLength(31);
|
||
expect(frame).not.toContain('\n');
|
||
});
|
||
|
||
it('never lets a line exceed the terminal width', () => {
|
||
for (const [cols, rows] of [
|
||
[100, 30],
|
||
[44, 20],
|
||
[72, 8],
|
||
[30, 6],
|
||
] as const) {
|
||
for (const line of frameLines(render(fixture(), cols, rows))) {
|
||
expect(visibleWidth(line)).toBeLessThanOrEqual(cols);
|
||
}
|
||
}
|
||
});
|
||
|
||
it('is deterministic for identical inputs', () => {
|
||
expect(render(fixture(), 100, 30)).toBe(render(fixture(), 100, 30));
|
||
});
|
||
|
||
it('degrades to a header-only frame on a 5x5 terminal without throwing', () => {
|
||
expect(() => render(fixture(), 5, 5)).not.toThrow();
|
||
expect(frameLines(render(fixture(), 5, 5))).toHaveLength(5);
|
||
});
|
||
});
|
||
|
||
describe('color', () => {
|
||
it('paints states and chrome when color is on', () => {
|
||
const model = fixture();
|
||
model.select('eee5');
|
||
const frame = render(model, 100, 30, { color: true });
|
||
expect(frame).toContain('\x1b[32m▖');
|
||
expect(frame).toContain('\x1b[31m▲');
|
||
expect(frame).toContain('\x1b[33m!');
|
||
expect(frame).toContain('\x1b[1mcodeman');
|
||
});
|
||
|
||
it('paints the selected row as one inverse block with no styling inside it', () => {
|
||
// An inner reset would punch a hole in the highlight, so the selected row
|
||
// is built unpainted and wrapped instead.
|
||
const frame = render(fixture(), 100, 30, { color: true });
|
||
const highlighted = frame.split('\x1b[7m')[1]?.split('\x1b[0m')[0] ?? '';
|
||
expect(highlighted).toContain('w4-api-refactor');
|
||
expect(highlighted).toContain('▲');
|
||
expect(highlighted).not.toContain('\x1b[');
|
||
});
|
||
|
||
it('emits nothing but cursor addressing when color is off', () => {
|
||
const frame = render(fixture(), 100, 30, { color: false });
|
||
const withoutAddressing = frame.replace(/\x1b\[\d+;1H/g, '').replace(/\x1b\[K/g, '');
|
||
expect(withoutAddressing).not.toContain('\x1b');
|
||
});
|
||
|
||
it('strips the session own colors out of the preview under NO_COLOR', () => {
|
||
const model = fixture();
|
||
model.setPreview({ sessionId: 'aaa1', lines: toDisplayLines('\x1b[31mred tail\x1b[0m') });
|
||
expect(render(model, 100, 30, { color: false })).not.toContain('\x1b[31m');
|
||
expect(render(model, 100, 30, { color: true })).toContain('\x1b[31m');
|
||
});
|
||
});
|
||
|
||
describe('glyph tiers', () => {
|
||
it('falls back to bracketed ASCII tokens', () => {
|
||
const lines = frameLines(render(fixture(), 100, 30, { glyphs: 'ascii' }));
|
||
const list = lines.map((line) => line.split('|')[0]);
|
||
expect(list[2]).toContain('[w]');
|
||
expect(list[3]).toContain('[!]');
|
||
expect(list[5]).toContain('[*]');
|
||
expect(list[7]).toContain('[-]');
|
||
expect(list[9]).toContain('[v]');
|
||
expect(list[3].startsWith('>')).toBe(true);
|
||
expect(lines.join('')).not.toContain('▝');
|
||
expect(lines.join('')).not.toContain('─');
|
||
});
|
||
|
||
it('animates the working glyph with the tick, and cycles', () => {
|
||
const model = fixture();
|
||
const frames = [0, 1, 2, 3, 4, 5].map((tick) => frameLines(render(model, 100, 30, { tick }))[5]);
|
||
// Quadrant blocks, rotating. Four of them, so the tick wraps every four
|
||
// frames rather than every six.
|
||
expect(frames[0]).toContain('▖');
|
||
expect(frames[1]).toContain('▘');
|
||
expect(frames[2]).toContain('▝');
|
||
expect(frames[3]).toContain('▗');
|
||
expect(frames[4]).toBe(frames[0]);
|
||
expect(frames[5]).toBe(frames[1]);
|
||
expect(new Set(frames).size).toBe(4);
|
||
});
|
||
|
||
it('detects a tier from the environment', () => {
|
||
expect(detectGlyphTier({ TERM: 'xterm-256color', LANG: 'en_US.UTF-8' })).toBe('unicode');
|
||
expect(detectGlyphTier({ TERM: 'xterm-kitty', LANG: 'en_US.UTF-8' })).toBe('nerd');
|
||
expect(detectGlyphTier({ TERM: 'xterm-256color', TERM_PROGRAM: 'iTerm.app', LANG: 'en_US.UTF-8' })).toBe('nerd');
|
||
expect(detectGlyphTier({ TERM: 'xterm-256color', LANG: 'C' })).toBe('ascii');
|
||
expect(detectGlyphTier({ TERM: 'dumb' })).toBe('ascii');
|
||
expect(detectGlyphTier({})).toBe('ascii');
|
||
expect(detectGlyphTier({ TERM: 'xterm-kitty', LANG: 'en_US.UTF-8', CODEMAN_TUI_GLYPHS: 'ascii' })).toBe('ascii');
|
||
});
|
||
});
|
||
|
||
describe('overlays', () => {
|
||
it('draws the help box over the body', () => {
|
||
const model = fixture();
|
||
model.setMode('help');
|
||
const lines = frameLines(render(model, 100, 30));
|
||
expect(lines.join('\n')).toContain('┌ Keys ');
|
||
expect(lines.some((line) => line.includes('attach'))).toBe(true);
|
||
expect(lines[lines.length - 1]).toContain('close');
|
||
});
|
||
|
||
it('names what a kill would destroy, and asks for one key', () => {
|
||
const model = fixture();
|
||
model.beginConfirmKill(model.rows()[0], 'w6-docs');
|
||
const text = frameLines(render(model, 100, 30)).join('\n');
|
||
expect(text).toContain('Kill w6-docs?');
|
||
expect(text).toContain('press y to kill, any other key cancels');
|
||
// The name is the point of the dialog: it is what tells the user WHICH
|
||
// session a keystroke is about to destroy.
|
||
expect(text).not.toContain('Type the name to confirm');
|
||
});
|
||
|
||
it('draws a message box', () => {
|
||
const model = fixture();
|
||
model.setMessage({ text: 'session refused to start: tmux is not installed', tone: 'err' });
|
||
const text = frameLines(render(model, 100, 30)).join('\n');
|
||
expect(text).toContain('Error');
|
||
expect(text).toContain('tmux is not installed');
|
||
});
|
||
|
||
it('skips the overlay when the body is too small to hold a box', () => {
|
||
const model = fixture();
|
||
model.setMode('help');
|
||
expect(frameLines(render(model, 100, 4)).join('\n')).not.toContain('Keys');
|
||
});
|
||
});
|
||
|
||
describe('connection states', () => {
|
||
it('banners a degraded server and says the preview is gone', () => {
|
||
const model = fixture();
|
||
model.setConnection('degraded');
|
||
const lines = frameLines(render(model, 100, 30));
|
||
expect(lines[1]).toContain('server not running: attach only');
|
||
expect(lines.join('\n')).toContain('preview unavailable while the server is down');
|
||
});
|
||
|
||
it('banners reconnecting and down differently', () => {
|
||
const model = fixture();
|
||
model.setConnection('reconnecting');
|
||
expect(frameLines(render(model, 100, 30))[1]).toContain('reconnecting');
|
||
model.setConnection('down');
|
||
expect(frameLines(render(model, 100, 30))[1]).toContain('server unreachable');
|
||
});
|
||
});
|
||
|
||
describe('empty and partial states', () => {
|
||
it('shows the empty hint when there are no sessions', () => {
|
||
const model = createTuiModel();
|
||
const lines = frameLines(render(model, 100, 30));
|
||
expect(lines.join('\n')).toContain('No sessions. n to start one, q to quit.');
|
||
expect(lines[1]).not.toContain('NEEDS YOU');
|
||
});
|
||
|
||
it('says the preview is still loading when it belongs to another session', () => {
|
||
const model = fixture();
|
||
model.setPreview({ sessionId: 'bbb2', lines: ['other session'] });
|
||
const text = frameLines(render(model, 100, 30)).join('\n');
|
||
expect(text).toContain('loading preview…');
|
||
expect(text).not.toContain('other session');
|
||
});
|
||
|
||
it('surfaces a preview error instead of a stale tail', () => {
|
||
const model = fixture();
|
||
model.setPreview({ sessionId: 'aaa1', lines: [], error: 'terminal capture failed' });
|
||
expect(frameLines(render(model, 100, 30)).join('\n')).toContain('terminal capture failed');
|
||
});
|
||
|
||
it('scrolls the list so the selected row stays visible', () => {
|
||
const model = createTuiModel();
|
||
model.replaceSessions(
|
||
Array.from({ length: 30 }, (_, i) => ({
|
||
sessionId: `s${String(i).padStart(2, '0')}`,
|
||
name: `session-${String(i).padStart(2, '0')}`,
|
||
sources: ['live'],
|
||
status: 'idle',
|
||
lastActivityAt: NOW - i * 1000,
|
||
}))
|
||
);
|
||
model.select('s29');
|
||
const text = frameLines(render(model, 100, 12)).join('\n');
|
||
expect(text).toContain('session-29');
|
||
expect(text).not.toContain('session-00');
|
||
});
|
||
});
|
||
|
||
describe('formatting helpers', () => {
|
||
it('formats elapsed time compactly', () => {
|
||
expect(formatElapsed(0)).toBe('0s');
|
||
expect(formatElapsed(45_000)).toBe('45s');
|
||
expect(formatElapsed(11 * 60_000)).toBe('11m');
|
||
expect(formatElapsed(2 * 3_600_000)).toBe('2h');
|
||
expect(formatElapsed(3 * 86_400_000)).toBe('3d');
|
||
expect(formatElapsed(-1)).toBe('');
|
||
expect(formatElapsed(Number.NaN)).toBe('');
|
||
});
|
||
|
||
it('formats token counts compactly', () => {
|
||
expect(formatTokens(0)).toBe('');
|
||
expect(formatTokens(842)).toBe('842');
|
||
expect(formatTokens(45_200)).toBe('45.2k');
|
||
expect(formatTokens(45_000)).toBe('45k');
|
||
expect(formatTokens(1_200_000)).toBe('1.2M');
|
||
});
|
||
|
||
it('names a row the way the web history list does', () => {
|
||
expect(rowLabel({ sessionId: 'abcdef12', name: 'w1', sources: [] })).toBe('w1');
|
||
expect(rowLabel({ sessionId: 'abcdef12', firstPrompt: 'do the thing', sources: [] })).toBe('do the thing');
|
||
expect(rowLabel({ sessionId: 'abcdef12', firstPrompt: '(no content)', workingDir: '/a/b/case', sources: [] })).toBe(
|
||
'case'
|
||
);
|
||
expect(rowLabel({ sessionId: 'abcdef1234', sources: [] })).toBe('abcdef12');
|
||
});
|
||
|
||
it('names a LIVE pane after its case, never after scraped output', () => {
|
||
// Regression: a session started from the TUI before the user typed anything
|
||
// had no name and no prompt, so the fallback took the CLI's first line of
|
||
// output. A healthy new session showed up in the list called
|
||
// "Login interrupted", which reads like a failure.
|
||
expect(
|
||
rowLabel({
|
||
sessionId: 'abcdef12',
|
||
firstPrompt: 'Login interrupted',
|
||
workingDir: '/home/u/codeman-cases/mirofish',
|
||
muxName: 'codeman-abcdef12',
|
||
sources: [],
|
||
})
|
||
).toBe('mirofish');
|
||
});
|
||
|
||
it('still names a HISTORY row by its prompt, where the prompt IS the identity', () => {
|
||
expect(
|
||
rowLabel({
|
||
sessionId: 'abcdef12',
|
||
firstPrompt: 'do the thing',
|
||
workingDir: '/home/u/codeman-cases/mirofish',
|
||
sources: [],
|
||
})
|
||
).toBe('do the thing');
|
||
});
|
||
|
||
it('prefers a real name over both, on a live row and a history row alike', () => {
|
||
const base = { sessionId: 'abcdef12', firstPrompt: 'Login interrupted', workingDir: '/a/b/case', sources: [] };
|
||
expect(rowLabel({ ...base, name: 'w2-case' })).toBe('w2-case');
|
||
expect(rowLabel({ ...base, name: 'w2-case', muxName: 'codeman-abcdef12' })).toBe('w2-case');
|
||
});
|
||
});
|
||
|
||
describe('the approval card', () => {
|
||
it('draws the dialog above the tail, with its digits', () => {
|
||
const text = frameLines(render(fixture(), 100, 30)).join('\n');
|
||
expect(text).toContain('▲ requests: Bash(git push origin main)');
|
||
expect(text).toContain('1. Yes');
|
||
expect(text).toContain('3. No, tell Claude what to do');
|
||
expect(text).toContain('y approve · n deny · digit chooses');
|
||
// The tail is still there, below the card.
|
||
expect(text).toContain('Actualizing...');
|
||
});
|
||
|
||
it('paints a dialog red and a waiting prompt yellow', () => {
|
||
const model = fixture();
|
||
const frame = render(model, 100, 30, { color: true });
|
||
expect(frame).toContain('\x1b[31m ▲ requests');
|
||
|
||
model.select('bbb2');
|
||
const idle = render(model, 100, 30, { color: true });
|
||
expect(idle).toContain('\x1b[33m !');
|
||
expect(idle).toContain('p to reply');
|
||
});
|
||
|
||
it('never lets the card push the tail off the pane', () => {
|
||
const model = fixture();
|
||
const lines = frameLines(render(model, 100, 10));
|
||
// 8 body lines: the card gets at most half, so some tail survives.
|
||
expect(lines.join('\n')).toContain('warning here');
|
||
});
|
||
|
||
it('counts pending prompts in the header badge', () => {
|
||
expect(frameLines(render(fixture(), 100, 30))[0]).toContain('▲ 2');
|
||
const model = createTuiModel();
|
||
model.replaceSessions([{ sessionId: 'aaa1', name: 'w1', sources: ['live'], status: 'idle' }]);
|
||
expect(frameLines(render(model, 100, 30))[0]).not.toContain('▲');
|
||
});
|
||
});
|
||
|
||
describe('the preview title', () => {
|
||
it('names the session, its CLI, its directory and its state', () => {
|
||
expect(frameLines(render(fixture(), 100, 30))[1]).toContain('w4-api-refactor · claude · /home/dev/api · blocked');
|
||
});
|
||
|
||
it('sacrifices the path rather than the state word when the pane is narrow', () => {
|
||
const model = fixture();
|
||
model.setApprovals([]);
|
||
model.select('ccc3');
|
||
const title = frameLines(render(model, 80, 30))[1];
|
||
expect(title).toContain('w1-codeman');
|
||
expect(title).toContain('working');
|
||
});
|
||
|
||
it('says a history row has nothing to show rather than claiming to load it', () => {
|
||
const model = fixture();
|
||
model.select('eee5');
|
||
model.setPreview({ sessionId: 'eee5', lines: [], note: 'this session is not running: no live output to show' });
|
||
expect(frameLines(render(model, 100, 30)).join('\n')).toContain('no live output to show');
|
||
});
|
||
});
|
||
|
||
describe('the prompt composer', () => {
|
||
function composing(text: string, cursorAt?: number): TuiModelStore {
|
||
const model = fixture();
|
||
let composer = createComposer(text);
|
||
if (cursorAt !== undefined) composer = composerMove(composer, cursorAt - text.length);
|
||
model.setPrompt({ sessionId: 'aaa1', label: 'w4-api-refactor', composer });
|
||
return model;
|
||
}
|
||
|
||
it('replaces the footer keys with the line being typed', () => {
|
||
const lines = frameLines(render(composing('deploy the thing'), 100, 30));
|
||
expect(lines[lines.length - 1]).toBe(' > deploy the thing');
|
||
});
|
||
|
||
it('puts the terminal cursor where the caret is', () => {
|
||
const layout = computeLayout(100, 30);
|
||
expect(composerCursorCell(composing('abc'), layout)).toEqual({ row: 30, col: 7 });
|
||
expect(composerCursorCell(composing('abc', 1), layout)).toEqual({ row: 30, col: 5 });
|
||
// No composer, no cursor: a blinking cursor in a dashboard reads as a bug.
|
||
expect(composerCursorCell(fixture(), layout)).toBeNull();
|
||
});
|
||
|
||
it('scrolls a long line so the caret stays on screen', () => {
|
||
const long = 'x'.repeat(200);
|
||
const lines = frameLines(render(composing(long), 100, 30));
|
||
const footer = lines[lines.length - 1];
|
||
expect(visibleWidth(footer)).toBeLessThanOrEqual(100);
|
||
const cursor = composerCursorCell(composing(long), computeLayout(100, 30));
|
||
expect(cursor?.col).toBeLessThanOrEqual(100);
|
||
});
|
||
});
|
||
|
||
describe('the search overlay', () => {
|
||
function searching(): TuiModelStore {
|
||
const model = fixture();
|
||
model.setSearch({
|
||
composer: createComposer('alpha'),
|
||
query: 'alpha',
|
||
status: 'done',
|
||
note: '2 results',
|
||
index: 1,
|
||
entries: [
|
||
{ kind: 'header', text: 'SESSIONS' },
|
||
{ kind: 'result', text: 'w1-alpha', detail: '/tmp/alpha', sessionId: 'aaa1', live: true },
|
||
{ kind: 'result', text: 'w9-old', detail: '/tmp/old', sessionId: 'zzz9', live: false },
|
||
],
|
||
});
|
||
return model;
|
||
}
|
||
|
||
it('shows the query with a caret, the count and the rows', () => {
|
||
const text = frameLines(render(searching(), 100, 30)).join('\n');
|
||
expect(text).toContain('┌ Search ');
|
||
expect(text).toContain('alpha_');
|
||
expect(text).toContain('2 results');
|
||
expect(text).toContain('SESSIONS');
|
||
expect(text).toContain('▶ w1-alpha /tmp/alpha');
|
||
expect(text).toContain('w9-old');
|
||
});
|
||
|
||
it('invites a query before anything has been typed', () => {
|
||
const model = fixture();
|
||
model.setSearch({ composer: createComposer(), query: '', entries: [], index: -1, status: 'idle' });
|
||
expect(frameLines(render(model, 100, 30)).join('\n')).toContain('type to search');
|
||
});
|
||
});
|
||
|
||
describe('the digest overlay', () => {
|
||
it('windows the lines it was given and scrolls with the offset', () => {
|
||
const model = fixture();
|
||
const lines = Array.from({ length: 40 }, (_, i) => `digest line ${i}`);
|
||
model.setDigest({ title: 'Away digest', lines, offset: 0 });
|
||
const top = frameLines(render(model, 100, 12)).join('\n');
|
||
expect(top).toContain('┌ Away digest ');
|
||
expect(top).toContain('digest line 0');
|
||
expect(top).not.toContain('digest line 30');
|
||
|
||
model.scrollDigest(30, digestCapacity(computeLayout(100, 12)));
|
||
const scrolled = frameLines(render(model, 100, 12)).join('\n');
|
||
expect(scrolled).toContain('digest line 30');
|
||
expect(scrolled).not.toContain('digest line 0\n');
|
||
});
|
||
});
|
||
|
||
describe('formatPlanUsage', () => {
|
||
it('mirrors the web chip, both windows and either alone', () => {
|
||
expect(
|
||
formatPlanUsage({ fiveHour: { usedPercentage: 32.4, resetAt: 1 }, sevenDay: { usedPercentage: 61, resetAt: 2 } })
|
||
).toBe('5h 32% · wk 61%');
|
||
expect(formatPlanUsage({ fiveHour: { usedPercentage: 5, resetAt: 1 } })).toBe('5h 5%');
|
||
expect(formatPlanUsage({ sevenDay: { usedPercentage: 90, resetAt: 1 } })).toBe('wk 90%');
|
||
});
|
||
|
||
it('punctuates with the separator it is given, so an ASCII terminal gets none', () => {
|
||
const usage = { fiveHour: { usedPercentage: 32, resetAt: 1 }, sevenDay: { usedPercentage: 61, resetAt: 2 } };
|
||
expect(formatPlanUsage(usage, ' - ')).toBe('5h 32% - wk 61%');
|
||
});
|
||
|
||
it('is empty when there is nothing to report, so the header shows no placeholder', () => {
|
||
expect(formatPlanUsage(null)).toBe('');
|
||
expect(formatPlanUsage(undefined)).toBe('');
|
||
expect(formatPlanUsage({})).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('the unicode glyph set is safe to render', () => {
|
||
// Two failures this pins, both found on one beta tester's terminal:
|
||
// a double-width glyph shifting every cell after it, and a codepoint their
|
||
// font had no glyph for at all.
|
||
const UNICODE = glyphsFor('unicode');
|
||
const every = [
|
||
UNICODE.blockedPermission,
|
||
UNICODE.blockedQuestion,
|
||
UNICODE.waiting,
|
||
...UNICODE.working,
|
||
UNICODE.idle,
|
||
UNICODE.recent,
|
||
UNICODE.cursor,
|
||
UNICODE.rule,
|
||
UNICODE.divider,
|
||
UNICODE.boxTopLeft,
|
||
UNICODE.boxTopRight,
|
||
UNICODE.boxBottomLeft,
|
||
UNICODE.boxBottomRight,
|
||
UNICODE.boxHorizontal,
|
||
UNICODE.boxVertical,
|
||
UNICODE.enter,
|
||
UNICODE.updown,
|
||
UNICODE.separator,
|
||
UNICODE.ellipsis,
|
||
];
|
||
|
||
it('has no double-width glyph, which would shift every cell after it', () => {
|
||
for (const glyph of every) {
|
||
for (const char of glyph) {
|
||
expect({ glyph, width: charWidth(char.codePointAt(0) ?? 0) }).toEqual({ glyph, width: 1 });
|
||
}
|
||
}
|
||
});
|
||
|
||
it('uses the arrow-block return symbol, not the one fonts lack', () => {
|
||
// U+23CE rendered as an empty box on a font that drew everything else here.
|
||
expect(UNICODE.enter).toBe('\u21B5');
|
||
expect(UNICODE.enter).not.toBe('\u23CE');
|
||
});
|
||
|
||
it('draws only from blocks a plain terminal font actually carries', () => {
|
||
// The rule, as a CLASS rather than one glyph at a time. Three separate
|
||
// "why are there boxes" reports came from this list, each fixed alone:
|
||
// ❯ (U+276F, sparse Dingbats), ⏵ (U+23F5) and ⏎ (U+23CE, both Misc
|
||
// Technical). The same font drew Box Drawing, Block Elements, Geometric
|
||
// Shapes and Latin-1 perfectly, so those are what the set may use.
|
||
const BANNED: Array<[number, number, string]> = [
|
||
[0x2300, 0x23ff, 'Miscellaneous Technical'],
|
||
[0x2600, 0x26ff, 'Miscellaneous Symbols'],
|
||
[0x2700, 0x27bf, 'Dingbats'],
|
||
];
|
||
// U+2714 is the one Dingbat kept: it was observed rendering on the very
|
||
// font that failed the others, and it is the list's "done" mark.
|
||
const ALLOWED = new Set([0x2714]);
|
||
for (const glyph of every) {
|
||
for (const char of glyph) {
|
||
const cp = char.codePointAt(0) ?? 0;
|
||
if (ALLOWED.has(cp)) continue;
|
||
const banned = BANNED.find(([lo, hi]) => cp >= lo && cp <= hi);
|
||
expect({ glyph, block: banned?.[2] ?? null }).toEqual({ glyph, block: null });
|
||
}
|
||
}
|
||
});
|
||
|
||
it('has no emoji where a text glyph belongs', () => {
|
||
// U+270B is Wide AND emoji-presentation: it drew at emoji size mid-row.
|
||
expect(every.join('')).not.toContain('\u270B');
|
||
});
|
||
});
|