mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 06:29:42 +02:00
feat: add the TUI's editor, approval and digest pure cores
Three small pure modules the phase-2 verbs are built on: - tui-composer: the single-line editor behind `p` and `/`, holding text as code points so a cursor can never split a surrogate pair, with the scroll window derived from the width rather than remembered. - tui-approvals: what an approvals-inbox item's card says, which keys are live for it (a digit answers only when the server parsed that option, and an idle prompt answers to none of them), and which ids the bell has not rung for yet. - tui-digest: the away digest as compact lines, counts first and one line per entry, with a capped tail per section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for reading an approvals-inbox item.
|
||||
*
|
||||
* The key matrix is the part worth pinning: a digit the server did not parse
|
||||
* off the pane must NOT produce an answer (it would be typed at a dialog that
|
||||
* has no such option), and an idle prompt must produce none at all, since there
|
||||
* is no dialog on screen and every keystroke would land in the composer.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
approvalAnswerForKey,
|
||||
approvalCard,
|
||||
approvalDenyOption,
|
||||
approvalTone,
|
||||
newApprovalIds,
|
||||
} from '../../src/tui/tui-approvals.js';
|
||||
import type { ApprovalItem } from '../../src/web/approval-inbox.js';
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
function item(overrides: Partial<ApprovalItem> = {}): ApprovalItem {
|
||||
return {
|
||||
id: 'sess:1',
|
||||
sessionId: 'sess',
|
||||
sessionName: 'w4-api',
|
||||
kind: 'permission',
|
||||
createdAt: NOW,
|
||||
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 (esc)' },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('approvalCard', () => {
|
||||
it('leads a permission prompt with the tool it wants to run', () => {
|
||||
const card = approvalCard(item());
|
||||
expect(card.tone).toBe('err');
|
||||
expect(card.title).toContain('Bash(git push origin main)');
|
||||
expect(card.options).toHaveLength(3);
|
||||
expect(card.hint).toContain('y approve');
|
||||
expect(card.hint).toContain('digit');
|
||||
});
|
||||
|
||||
it('leads a question with its message', () => {
|
||||
const card = approvalCard(item({ kind: 'question', message: 'Which color?', toolSummary: undefined }));
|
||||
expect(card.title).toBe('Which color?');
|
||||
expect(card.tone).toBe('err');
|
||||
});
|
||||
|
||||
it('says an idle prompt is answered by typing, not by approving', () => {
|
||||
const card = approvalCard(item({ kind: 'idle', message: 'waiting for input', options: undefined }));
|
||||
expect(card.tone).toBe('warn');
|
||||
expect(card.options).toEqual([]);
|
||||
expect(card.hint).toBe('p to reply');
|
||||
});
|
||||
|
||||
it('drops the approve/deny-only hint when the frame did not parse', () => {
|
||||
const card = approvalCard(item({ options: undefined }));
|
||||
expect(card.options).toEqual([]);
|
||||
expect(card.hint).toBe('y approve · n deny');
|
||||
});
|
||||
|
||||
it('keeps the message as detail when it says more than the tool line', () => {
|
||||
expect(approvalCard(item({ message: 'about to force-push' })).detail).toEqual(['about to force-push']);
|
||||
expect(approvalCard(item({ message: 'Bash(git push origin main)' })).detail).toEqual([]);
|
||||
});
|
||||
|
||||
it('collapses whitespace so a wrapped hook field cannot break the card', () => {
|
||||
expect(approvalCard(item({ toolSummary: 'Bash(git\n push)' })).title).toBe('requests: Bash(git push)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('approvalTone', () => {
|
||||
it('is red for a dialog and yellow for a waiting prompt', () => {
|
||||
expect(approvalTone(item())).toBe('err');
|
||||
expect(approvalTone(item({ kind: 'question' }))).toBe('err');
|
||||
expect(approvalTone(item({ kind: 'idle' }))).toBe('warn');
|
||||
});
|
||||
});
|
||||
|
||||
describe('approvalAnswerForKey', () => {
|
||||
it('approves with y', () => {
|
||||
expect(approvalAnswerForKey(item(), 'y')).toEqual({ action: 'approve' });
|
||||
});
|
||||
|
||||
it('denies with the parsed No option when there is one', () => {
|
||||
expect(approvalDenyOption(item())).toBe(3);
|
||||
expect(approvalAnswerForKey(item(), 'n')).toEqual({ action: 'option', option: 3 });
|
||||
});
|
||||
|
||||
it('falls back to Esc semantics when no No option parsed', () => {
|
||||
expect(approvalDenyOption(item({ options: undefined }))).toBeNull();
|
||||
expect(approvalAnswerForKey(item({ options: undefined }), 'n')).toEqual({ action: 'deny' });
|
||||
expect(
|
||||
approvalAnswerForKey(
|
||||
item({
|
||||
options: [
|
||||
{ n: 1, label: 'Red' },
|
||||
{ n: 2, label: 'Blue' },
|
||||
],
|
||||
}),
|
||||
'n'
|
||||
)
|
||||
).toEqual({
|
||||
action: 'deny',
|
||||
});
|
||||
});
|
||||
|
||||
it('answers with a digit only when the server parsed that option', () => {
|
||||
expect(approvalAnswerForKey(item(), '2')).toEqual({ action: 'option', option: 2 });
|
||||
expect(approvalAnswerForKey(item(), '4')).toBeNull();
|
||||
expect(approvalAnswerForKey(item({ options: undefined }), '1')).toBeNull();
|
||||
});
|
||||
|
||||
it('makes no key an answer for an idle prompt', () => {
|
||||
const idle = item({ kind: 'idle', options: undefined });
|
||||
for (const key of ['y', 'n', '1', '2', '9']) expect(approvalAnswerForKey(idle, key)).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves every other key to the list', () => {
|
||||
for (const key of ['j', 'k', 'q', 'x', 'p', '/', 'g', '0']) {
|
||||
expect(approvalAnswerForKey(item(), key)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('newApprovalIds', () => {
|
||||
it('reports only ids the set has not seen', () => {
|
||||
const seen = new Set(['sess:1']);
|
||||
expect(newApprovalIds(seen, [item(), item({ id: 'other:7', sessionId: 'other' })])).toEqual(['other:7']);
|
||||
expect(newApprovalIds(seen, [item()])).toEqual([]);
|
||||
expect(newApprovalIds(new Set(), [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports one id once even when it arrives twice', () => {
|
||||
expect(newApprovalIds(new Set(), [item(), item()])).toEqual(['sess:1']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the single-line editor behind `p` and `/`.
|
||||
*
|
||||
* The interesting parts are the ones a terminal makes hard to see: a cursor
|
||||
* that must not split a surrogate pair, a combining mark that belongs to the
|
||||
* character before it, and the scroll window, which is the only reason a long
|
||||
* prompt stays typeable in a footer one line tall.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
composerBackspace,
|
||||
composerDelete,
|
||||
composerDeleteWord,
|
||||
composerEnd,
|
||||
composerHome,
|
||||
composerInsert,
|
||||
composerMove,
|
||||
composerScroll,
|
||||
composerStep,
|
||||
composerText,
|
||||
composerWindow,
|
||||
createComposer,
|
||||
} from '../../src/tui/tui-composer.js';
|
||||
|
||||
describe('editing', () => {
|
||||
it('inserts at the cursor and keeps it after the insertion', () => {
|
||||
let state = createComposer('abc');
|
||||
expect(composerText(state)).toBe('abc');
|
||||
expect(state.cursor).toBe(3);
|
||||
|
||||
state = composerMove(state, -1);
|
||||
state = composerInsert(state, 'XY');
|
||||
expect(composerText(state)).toBe('abXYc');
|
||||
expect(state.cursor).toBe(4);
|
||||
});
|
||||
|
||||
it('never lets a newline into a single-line editor', () => {
|
||||
const state = composerInsert(createComposer(), 'one\ntwo\r\nthree');
|
||||
expect(composerText(state)).toBe('one two three');
|
||||
});
|
||||
|
||||
it('deletes whole characters, not code units', () => {
|
||||
const state = composerBackspace(createComposer('a🙂'));
|
||||
expect(composerText(state)).toBe('a');
|
||||
expect(state.cursor).toBe(1);
|
||||
});
|
||||
|
||||
it('deletes forward under the cursor and stops at the end', () => {
|
||||
const state = composerHome(createComposer('abc'));
|
||||
expect(composerText(composerDelete(state))).toBe('bc');
|
||||
expect(composerText(composerDelete(createComposer('abc')))).toBe('abc');
|
||||
});
|
||||
|
||||
it('deletes a word back over its trailing spaces', () => {
|
||||
expect(composerText(composerDeleteWord(createComposer('fix the bug ')))).toBe('fix the ');
|
||||
expect(composerText(composerDeleteWord(createComposer('word')))).toBe('');
|
||||
expect(composerText(composerDeleteWord(createComposer('')))).toBe('');
|
||||
});
|
||||
|
||||
it('clamps the cursor at both ends', () => {
|
||||
const state = createComposer('abc');
|
||||
expect(composerMove(state, 10).cursor).toBe(3);
|
||||
expect(composerMove(state, -10).cursor).toBe(0);
|
||||
expect(composerHome(state).cursor).toBe(0);
|
||||
expect(composerEnd(composerHome(state)).cursor).toBe(3);
|
||||
});
|
||||
|
||||
it('leaves a no-op edit as the same object, so nothing repaints', () => {
|
||||
const state = createComposer('abc');
|
||||
const atStart = composerHome(state);
|
||||
expect(composerInsert(state, '')).toBe(state);
|
||||
expect(composerMove(state, 1)).toBe(state);
|
||||
expect(composerBackspace(atStart)).toBe(atStart);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the scroll window', () => {
|
||||
it('shows the whole text while it fits', () => {
|
||||
const window = composerWindow(createComposer('short'), 20);
|
||||
expect(window.text).toBe('short');
|
||||
expect(window.cursorColumn).toBe(5);
|
||||
expect(window.scroll).toBe(0);
|
||||
});
|
||||
|
||||
it('scrolls just far enough to keep the cursor visible', () => {
|
||||
// 10 columns of room, one reserved for the cursor itself.
|
||||
const state = composerScroll(createComposer('0123456789abcdef'), 10);
|
||||
const window = composerWindow(state, 10);
|
||||
expect(window.scroll).toBe(7);
|
||||
expect(window.text).toBe('789abcdef');
|
||||
expect(window.cursorColumn).toBe(9);
|
||||
});
|
||||
|
||||
it('scrolls back when the cursor moves left out of the window', () => {
|
||||
let state = composerScroll(createComposer('0123456789abcdef'), 10);
|
||||
expect(state.scroll).toBe(7);
|
||||
state = composerScroll(composerHome(state), 10);
|
||||
expect(state.scroll).toBe(0);
|
||||
expect(composerWindow(state, 10).cursorColumn).toBe(0);
|
||||
});
|
||||
|
||||
it('counts a double-width character as the two columns it takes', () => {
|
||||
const state = composerScroll(createComposer('日本語です'), 6);
|
||||
const window = composerWindow(state, 6);
|
||||
// Five wide characters = 10 columns; the window holds the last three (6
|
||||
// columns) minus the cell the cursor needs.
|
||||
expect(window.cursorColumn).toBeLessThanOrEqual(5);
|
||||
expect(window.text.length).toBeLessThanOrEqual(5);
|
||||
expect(composerText(state)).toBe('日本語です');
|
||||
});
|
||||
|
||||
it('survives a width of one', () => {
|
||||
const state = composerScroll(createComposer('abc'), 1);
|
||||
expect(() => composerWindow(state, 1)).not.toThrow();
|
||||
expect(composerWindow(state, 1).cursorColumn).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composerStep', () => {
|
||||
const state = createComposer('ab');
|
||||
|
||||
it('reports Enter and Escape instead of acting on them', () => {
|
||||
expect(composerStep(state, { type: 'enter' })).toEqual({ kind: 'submit', text: 'ab' });
|
||||
expect(composerStep(state, { type: 'escape' })).toEqual({ kind: 'cancel' });
|
||||
expect(composerStep(state, { type: 'ctrl', key: 'c' })).toEqual({ kind: 'cancel' });
|
||||
});
|
||||
|
||||
it('maps the editing keys', () => {
|
||||
expect(composerStep(state, { type: 'char', value: 'c' })).toEqual({
|
||||
kind: 'edit',
|
||||
state: expect.objectContaining({ cursor: 3 }),
|
||||
});
|
||||
expect(composerStep(state, { type: 'key', name: 'left' })).toEqual({
|
||||
kind: 'edit',
|
||||
state: expect.objectContaining({ cursor: 1 }),
|
||||
});
|
||||
expect(composerStep(state, { type: 'ctrl', key: 'u' })).toEqual({
|
||||
kind: 'edit',
|
||||
state: expect.objectContaining({ cursor: 0 }),
|
||||
});
|
||||
expect(composerText((composerStep(state, { type: 'ctrl', key: 'u' }) as { state: never }).state)).toBe('');
|
||||
});
|
||||
|
||||
it('ignores keys that mean nothing to an editor', () => {
|
||||
expect(composerStep(state, { type: 'tab' })).toEqual({ kind: 'ignore' });
|
||||
expect(composerStep(state, { type: 'key', name: 'pageup' })).toEqual({ kind: 'ignore' });
|
||||
expect(composerStep(state, { type: 'ctrl', key: 'x' })).toEqual({ kind: 'ignore' });
|
||||
expect(composerStep(state, { type: 'mouse', kind: 'press', x: 1, y: 1, button: 0 })).toEqual({ kind: 'ignore' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the away digest's compact rendering.
|
||||
*
|
||||
* The digest is read top-down and never studied, so the promises worth pinning
|
||||
* are: the counts sit in the first line, every entry is exactly one line, and a
|
||||
* long section is capped with a tail rather than pushing the next section off
|
||||
* the overlay.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatAwayDigest } from '../../src/tui/tui-digest.js';
|
||||
import type { AwayDigestItem, AwayDigestResponse } from '../../src/web/away-digest.js';
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
function entry(overrides: Partial<AwayDigestItem> = {}): AwayDigestItem {
|
||||
return {
|
||||
id: 'e1',
|
||||
timestamp: NOW - 120_000,
|
||||
category: 'needs_attention',
|
||||
severity: 'warning',
|
||||
title: 'permission prompt',
|
||||
source: 'lifecycle',
|
||||
sessionName: 'w4-api',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function digest(overrides: Partial<AwayDigestResponse> = {}): AwayDigestResponse {
|
||||
return {
|
||||
range: { range: '24h', since: NOW - 86_400_000, until: NOW },
|
||||
generatedAt: NOW,
|
||||
dataFreshness: {
|
||||
lifecyclePersisted: true,
|
||||
tokenStatsPersisted: true,
|
||||
runSummariesLiveOnly: true,
|
||||
subagentsLiveOnly: true,
|
||||
},
|
||||
totals: {
|
||||
sessionsCreated: 3,
|
||||
sessionsExited: 1,
|
||||
activeSessions: 2,
|
||||
needsAttention: 1,
|
||||
completed: 1,
|
||||
errors: 0,
|
||||
warnings: 1,
|
||||
tokenWindowPrecision: 'day',
|
||||
},
|
||||
sections: { needsAttention: [], completed: [], stillRunning: [], idle: [], informational: [] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('formatAwayDigest', () => {
|
||||
it('opens with the range and the counts', () => {
|
||||
const lines = formatAwayDigest(digest(), { now: NOW });
|
||||
expect(lines[0]).toBe('the last 24 hours · 3 started · 1 exited · 2 running');
|
||||
});
|
||||
|
||||
it('names the range the way the API labels it', () => {
|
||||
const since = digest({ range: { range: 'since-last-visit', since: NOW - 1000, until: NOW } });
|
||||
expect(formatAwayDigest(since, { now: NOW })[0]).toContain('since your last visit');
|
||||
});
|
||||
|
||||
it('gives every entry one line, with its age and session', () => {
|
||||
const lines = formatAwayDigest(
|
||||
digest({
|
||||
sections: {
|
||||
needsAttention: [entry({ detail: 'Bash(git push)' })],
|
||||
completed: [],
|
||||
stillRunning: [],
|
||||
idle: [],
|
||||
informational: [],
|
||||
},
|
||||
}),
|
||||
{ now: NOW }
|
||||
);
|
||||
expect(lines).toContain('NEEDS ATTENTION (1)');
|
||||
expect(lines).toContain(' 2m w4-api permission prompt — Bash(git push)');
|
||||
});
|
||||
|
||||
it('caps a long section instead of burying the next one', () => {
|
||||
const many = Array.from({ length: 9 }, (_, i) => entry({ id: `e${i}`, title: `event ${i}` }));
|
||||
const lines = formatAwayDigest(
|
||||
digest({
|
||||
sections: {
|
||||
needsAttention: many,
|
||||
completed: [entry({ id: 'c1', category: 'completed', title: 'finished' })],
|
||||
stillRunning: [],
|
||||
idle: [],
|
||||
informational: [],
|
||||
},
|
||||
}),
|
||||
{ now: NOW, sectionLimit: 3 }
|
||||
);
|
||||
expect(lines).toContain('NEEDS ATTENTION (9)');
|
||||
expect(lines).toContain(' … 6 more');
|
||||
expect(lines).toContain('COMPLETED (1)');
|
||||
});
|
||||
|
||||
it('says so when nothing happened', () => {
|
||||
expect(formatAwayDigest(digest(), { now: NOW })).toContain('nothing happened while you were away');
|
||||
});
|
||||
|
||||
it('adds the token totals only when the range had any', () => {
|
||||
expect(formatAwayDigest(digest(), { now: NOW }).join('\n')).not.toContain('tokens:');
|
||||
const withTokens = digest({
|
||||
totals: { ...digest().totals, inputTokens: 45_200, outputTokens: 12_100, estimatedCost: 1.234 },
|
||||
});
|
||||
expect(formatAwayDigest(withTokens, { now: NOW })).toContain('tokens: 45.2k in · 12.1k out · $1.23');
|
||||
});
|
||||
|
||||
it('drops the age column for an entry with no usable timestamp', () => {
|
||||
const lines = formatAwayDigest(
|
||||
digest({
|
||||
sections: {
|
||||
needsAttention: [entry({ timestamp: 0, sessionName: undefined, sessionId: 'abcdef1234' })],
|
||||
completed: [],
|
||||
stillRunning: [],
|
||||
idle: [],
|
||||
informational: [],
|
||||
},
|
||||
}),
|
||||
{ now: NOW }
|
||||
);
|
||||
expect(lines).toContain(' abcdef12 permission prompt');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user