From bb73400afab92a98ac98216379ae0d97ca9df6f6 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 20:29:13 +0200 Subject: [PATCH] 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 --- src/tui/tui-approvals.ts | 137 ++++++++++++++++++++++ src/tui/tui-composer.ts | 205 +++++++++++++++++++++++++++++++++ src/tui/tui-digest.ts | 90 +++++++++++++++ test/tui/tui-approvals.test.ts | 143 +++++++++++++++++++++++ test/tui/tui-composer.test.ts | 150 ++++++++++++++++++++++++ test/tui/tui-digest.test.ts | 127 ++++++++++++++++++++ 6 files changed, 852 insertions(+) create mode 100644 src/tui/tui-approvals.ts create mode 100644 src/tui/tui-composer.ts create mode 100644 src/tui/tui-digest.ts create mode 100644 test/tui/tui-approvals.test.ts create mode 100644 test/tui/tui-composer.test.ts create mode 100644 test/tui/tui-digest.test.ts diff --git a/src/tui/tui-approvals.ts b/src/tui/tui-approvals.ts new file mode 100644 index 00000000..2cb10dea --- /dev/null +++ b/src/tui/tui-approvals.ts @@ -0,0 +1,137 @@ +/** + * @fileoverview Pure reading of an approvals-inbox item: what the card says, + * which keys are live for it, and which of them just appeared. + * + * This is the half of "answer the dialog from the dashboard" that can be stated + * as a function of the item. The IO half (`POST /api/approvals/:id/answer`) + * lives in `tui-client.ts`, and the server re-captures the pane before it aims + * any keystroke, so a card that went stale is refused rather than mis-answered. + * + * The key matrix is deliberately narrow, because the alternative is typing a + * digit into whatever now has focus: + * + * | kind | y | n | 1-9 | + * | ---------- | ------------ | ---------------------- | ------------------------- | + * | permission | approve | the parsed "No" option, | only digits the server | + * | question | approve | else Esc | actually parsed off screen | + * | idle | not a dialog: `p` (the composer) is the reply path | + * + * A digit that is not among the parsed options returns null, which is what lets + * the caller fall back to the list's own 1-9 jump instead of sending a keystroke + * the dialog has no answer for. + * + * PURE: no IO, no timers, no `process.*`. + * + * @module tui/tui-approvals + */ + +import type { ApprovalItem, ApprovalOption } from '../web/approval-inbox.js'; +import type { TuiApprovalAnswer } from './tui-client.js'; + +/** Card severity, in the same red/yellow vocabulary the web inbox uses. */ +export type TuiApprovalTone = 'err' | 'warn'; + +export interface TuiApprovalCard { + tone: TuiApprovalTone; + /** One line: what is being asked. */ + title: string; + /** Extra context, one entry per line, already trimmed. May be empty. */ + detail: string[]; + /** Numbered choices parsed off the pane, empty when the frame did not parse. */ + options: ApprovalOption[]; + /** What the user can press right now, in words. */ + hint: string; +} + +/** Longest single line the card contributes before the renderer clips it. */ +const MAX_CARD_TEXT = 400; + +function clean(text: string | undefined): string { + return (text ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_CARD_TEXT); +} + +export function approvalTone(item: ApprovalItem): TuiApprovalTone { + return item.kind === 'idle' ? 'warn' : 'err'; +} + +/** + * What the card says. Permission prompts lead with the tool (that is the whole + * question), questions lead with their message, and an idle prompt says what it + * is, since there is nothing to approve. + */ +export function approvalCard(item: ApprovalItem): TuiApprovalCard { + const options = item.options ?? []; + const message = clean(item.message); + const summary = clean(item.toolSummary) || clean(item.toolName); + + if (item.kind === 'idle') { + return { + tone: 'warn', + title: message || 'waiting for your reply', + detail: [], + options: [], + hint: 'p to reply', + }; + } + + const title = + item.kind === 'permission' + ? `requests: ${summary || 'permission'}` + : message || `question: ${summary || 'Claude is asking'}`; + const detail: string[] = []; + if (item.kind === 'permission' && message && message !== summary) detail.push(message); + + return { + tone: 'err', + title, + detail, + options, + hint: options.length > 0 ? 'y approve · n deny · digit chooses' : 'y approve · n deny', + }; +} + +/** + * The parsed option that means "no". Claude renders it as `3. No, tell Claude + * what to do (esc)`, and answering with its digit is the same keystroke the + * dialog itself is waiting for; without a parsed one the answer route's `deny` + * sends Esc, which every dialog understands. + */ +export function approvalDenyOption(item: ApprovalItem): number | null { + const match = (item.options ?? []).find((option) => /^no\b/i.test(option.label)); + return match ? match.n : null; +} + +/** + * The answer one key produces, or null when that key means nothing here (so the + * caller can let its normal binding through). + */ +export function approvalAnswerForKey(item: ApprovalItem, key: string): TuiApprovalAnswer | null { + // An idle prompt has no dialog on screen: a digit or a `1` would land in the + // composer as text. The card points at `p` instead. + if (item.kind === 'idle') return null; + if (key === 'y') return { action: 'approve' }; + if (key === 'n') { + const deny = approvalDenyOption(item); + return deny === null ? { action: 'deny' } : { action: 'option', option: deny }; + } + if (key >= '1' && key <= '9') { + const option = Number.parseInt(key, 10); + return (item.options ?? []).some((entry) => entry.n === option) ? { action: 'option', option } : null; + } + return null; +} + +/** + * Ids in `items` that `seen` has not recorded. The bell rings for these and for + * nothing else, which is what keeps a repaint (or a refetch that returns the + * same pending item) silent. + * + * Answered ids stay in `seen` on purpose: the inbox restores an item under its + * ORIGINAL id when a write fails, and re-ringing for a prompt the user already + * heard about is worse than missing one. + */ +export function newApprovalIds(seen: ReadonlySet, items: readonly ApprovalItem[]): string[] { + const fresh: string[] = []; + for (const item of items) if (!seen.has(item.id) && !fresh.includes(item.id)) fresh.push(item.id); + return fresh; +} diff --git a/src/tui/tui-composer.ts b/src/tui/tui-composer.ts new file mode 100644 index 00000000..4677a0c1 --- /dev/null +++ b/src/tui/tui-composer.ts @@ -0,0 +1,205 @@ +/** + * @fileoverview Pure single-line editor behind the TUI's prompt composer (`p`) + * and search query (`/`). + * + * Text is held as CODE POINTS rather than a string, because every operation + * here is index-based and a cursor that can land inside a surrogate pair + * eventually deletes half an emoji. Combining marks are their own entries: they + * are zero-width, so they neither move the cursor's column nor cost a cell, and + * backspace peeling one off a base letter is what a terminal editor does. + * + * Scrolling is derived, never remembered implicitly: `composerScroll()` takes + * the width and returns the state whose window holds the cursor, which is what + * keeps "what the footer shows" a function of the state plus the terminal width + * rather than of the order the user pressed keys in. + * + * PURE: no IO, no timers, no `process.*`. Enter and Escape are reported as + * `submit`/`cancel` rather than acted on, since only the caller knows whether + * Enter means "send this prompt" or "open the highlighted search result". + * + * @module tui/tui-composer + */ + +import { charWidth } from './tui-ansi.js'; +import type { TuiInputEvent } from './tui-keys.js'; + +export interface TuiComposerState { + /** Code points. `chars.join('')` is the text. */ + readonly chars: readonly string[]; + /** 0..chars.length. The cursor sits BEFORE `chars[cursor]`. */ + readonly cursor: number; + /** First visible code point, as `composerScroll()` last resolved it. */ + readonly scroll: number; +} + +export function createComposer(text = ''): TuiComposerState { + const chars = [...text]; + return { chars, cursor: chars.length, scroll: 0 }; +} + +export function composerText(state: TuiComposerState): string { + return state.chars.join(''); +} + +function withChars(chars: readonly string[], cursor: number, scroll: number): TuiComposerState { + const clampedCursor = Math.min(Math.max(0, cursor), chars.length); + return { chars, cursor: clampedCursor, scroll: Math.min(Math.max(0, scroll), chars.length) }; +} + +/** Insert typed text at the cursor. Newlines are stripped: this is one line. */ +export function composerInsert(state: TuiComposerState, value: string): TuiComposerState { + const inserted = [...value.replace(/[\r\n]+/g, ' ')]; + if (inserted.length === 0) return state; + const chars = [...state.chars.slice(0, state.cursor), ...inserted, ...state.chars.slice(state.cursor)]; + return withChars(chars, state.cursor + inserted.length, state.scroll); +} + +/** Delete the code point before the cursor. */ +export function composerBackspace(state: TuiComposerState): TuiComposerState { + if (state.cursor === 0) return state; + const chars = [...state.chars.slice(0, state.cursor - 1), ...state.chars.slice(state.cursor)]; + return withChars(chars, state.cursor - 1, state.scroll); +} + +/** Delete the code point under the cursor (the Delete key). */ +export function composerDelete(state: TuiComposerState): TuiComposerState { + if (state.cursor >= state.chars.length) return state; + const chars = [...state.chars.slice(0, state.cursor), ...state.chars.slice(state.cursor + 1)]; + return withChars(chars, state.cursor, state.scroll); +} + +/** Delete back to the start of the word before the cursor (Ctrl+W). */ +export function composerDeleteWord(state: TuiComposerState): TuiComposerState { + let start = state.cursor; + while (start > 0 && state.chars[start - 1] === ' ') start--; + while (start > 0 && state.chars[start - 1] !== ' ') start--; + if (start === state.cursor) return state; + const chars = [...state.chars.slice(0, start), ...state.chars.slice(state.cursor)]; + return withChars(chars, start, state.scroll); +} + +export function composerMove(state: TuiComposerState, delta: number): TuiComposerState { + const cursor = Math.min(Math.max(0, state.cursor + Math.trunc(delta)), state.chars.length); + return cursor === state.cursor ? state : withChars(state.chars, cursor, state.scroll); +} + +export function composerHome(state: TuiComposerState): TuiComposerState { + return state.cursor === 0 ? state : withChars(state.chars, 0, state.scroll); +} + +export function composerEnd(state: TuiComposerState): TuiComposerState { + return state.cursor === state.chars.length ? state : withChars(state.chars, state.chars.length, state.scroll); +} + +export function composerClear(state: TuiComposerState): TuiComposerState { + return state.chars.length === 0 ? state : { chars: [], cursor: 0, scroll: 0 }; +} + +/** Display columns of `chars[from..to)`. */ +function widthOf(chars: readonly string[], from: number, to: number): number { + let width = 0; + for (let i = from; i < to; i++) width += charWidth(chars[i].codePointAt(0) ?? 0); + return width; +} + +/** + * Resolve `scroll` so the cursor is inside a window `width` columns wide, + * scrolling the minimum needed. One column is reserved for the cursor itself, + * so a cursor at the end of the text still has a cell to sit in instead of + * hanging one past the edge where the terminal would wrap it. + */ +export function composerScroll(state: TuiComposerState, width: number): TuiComposerState { + const usable = Math.max(0, Math.trunc(width) - 1); + let scroll = Math.min(Math.max(0, state.scroll), state.cursor); + while (scroll < state.cursor && widthOf(state.chars, scroll, state.cursor) > usable) scroll++; + return scroll === state.scroll ? state : { chars: state.chars, cursor: state.cursor, scroll }; +} + +export interface TuiComposerWindow { + /** The visible slice of the text. */ + text: string; + /** Cursor offset in display columns from the start of `text`. */ + cursorColumn: number; + /** Resolved first visible code point (may differ from `state.scroll`). */ + scroll: number; +} + +/** + * The slice the footer draws plus where the terminal cursor belongs. The scroll + * is resolved here too, so a renderer that never writes state back still shows + * the cursor. + */ +export function composerWindow(state: TuiComposerState, width: number): TuiComposerWindow { + const columns = Math.max(1, Math.trunc(width)); + const scrolled = composerScroll(state, columns); + const { chars, cursor, scroll } = scrolled; + let used = 0; + let end = scroll; + while (end < chars.length) { + const next = charWidth(chars[end].codePointAt(0) ?? 0); + if (used + next > columns) break; + used += next; + end++; + } + return { + text: chars.slice(scroll, Math.max(end, cursor)).join(''), + cursorColumn: widthOf(chars, scroll, cursor), + scroll, + }; +} + +export type TuiComposerStep = + | { kind: 'edit'; state: TuiComposerState } + | { kind: 'submit'; text: string } + | { kind: 'cancel' } + | { kind: 'ignore' }; + +/** + * One keystroke. Enter and Escape are REPORTED rather than applied: `p` sends + * the line while `/` opens the highlighted result, and only the caller knows + * which. + */ +export function composerStep(state: TuiComposerState, event: TuiInputEvent): TuiComposerStep { + switch (event.type) { + case 'char': + return { kind: 'edit', state: composerInsert(state, event.value) }; + case 'backspace': + return { kind: 'edit', state: composerBackspace(state) }; + case 'enter': + return { kind: 'submit', text: composerText(state) }; + case 'escape': + return { kind: 'cancel' }; + case 'key': + switch (event.name) { + case 'left': + return { kind: 'edit', state: composerMove(state, -1) }; + case 'right': + return { kind: 'edit', state: composerMove(state, 1) }; + case 'home': + return { kind: 'edit', state: composerHome(state) }; + case 'end': + return { kind: 'edit', state: composerEnd(state) }; + case 'delete': + return { kind: 'edit', state: composerDelete(state) }; + default: + return { kind: 'ignore' }; + } + case 'ctrl': + switch (event.key) { + case 'c': + return { kind: 'cancel' }; + case 'a': + return { kind: 'edit', state: composerHome(state) }; + case 'e': + return { kind: 'edit', state: composerEnd(state) }; + case 'u': + return { kind: 'edit', state: composerClear(state) }; + case 'w': + return { kind: 'edit', state: composerDeleteWord(state) }; + default: + return { kind: 'ignore' }; + } + default: + return { kind: 'ignore' }; + } +} diff --git a/src/tui/tui-digest.ts b/src/tui/tui-digest.ts new file mode 100644 index 00000000..e93c4327 --- /dev/null +++ b/src/tui/tui-digest.ts @@ -0,0 +1,90 @@ +/** + * @fileoverview Pure formatting of `GET /api/away-digest` into the lines the + * `g` overlay scrolls. + * + * The digest answers "what happened while I was away", so it is read top-down + * and never studied: every entry is one line (age, session, what happened), a + * long section is capped with a "… n more" tail rather than allowed to push the + * next section off screen, and the counts that matter live in the first line + * where they are visible without scrolling at all. + * + * PURE: no IO, no clock of its own (the caller passes `now`), no `process.*`. + * + * @module tui/tui-digest + */ + +import { formatElapsed, formatTokens } from './tui-render.js'; +import type { AwayDigestItem, AwayDigestResponse, AwayDigestSectionName } from '../web/away-digest.js'; + +/** Entries per section before the tail takes over. */ +export const DIGEST_SECTION_LIMIT = 6; + +const SECTION_ORDER: ReadonlyArray = [ + ['needsAttention', 'NEEDS ATTENTION'], + ['completed', 'COMPLETED'], + ['stillRunning', 'STILL RUNNING'], + ['idle', 'IDLE'], + ['informational', 'INFO'], +]; + +const RANGE_WORDS: Record = { + 'since-last-visit': 'since your last visit', + '1h': 'the last hour', + today: 'today', + '24h': 'the last 24 hours', + custom: 'the selected window', +}; + +export interface TuiDigestOptions { + now: number; + sectionLimit?: number; +} + +function ageColumn(item: AwayDigestItem, now: number): string { + const age = item.timestamp > 0 ? formatElapsed(now - item.timestamp) : ''; + return age.padEnd(4); +} + +function itemLine(item: AwayDigestItem, now: number): string { + const who = item.sessionName ?? item.sessionId?.slice(0, 8) ?? ''; + const what = [item.title, item.detail].filter((part) => part && part.trim() !== '').join(' — '); + return ` ${ageColumn(item, now)} ${[who, what].filter((part) => part !== '').join(' ')}`.replace(/\s+$/, ''); +} + +/** + * The digest as display lines. The first line is the summary, then one block + * per non-empty section, then the token totals when the range had any. + */ +export function formatAwayDigest(digest: AwayDigestResponse, options: TuiDigestOptions): string[] { + const limit = Math.max(1, Math.trunc(options.sectionLimit ?? DIGEST_SECTION_LIMIT)); + const { totals } = digest; + const lines: string[] = [ + [ + RANGE_WORDS[digest.range.range] ?? 'recently', + `${totals.sessionsCreated} started`, + `${totals.sessionsExited} exited`, + `${totals.activeSessions} running`, + ].join(' · '), + ]; + + let entries = 0; + for (const [key, label] of SECTION_ORDER) { + const items = digest.sections[key] ?? []; + if (items.length === 0) continue; + entries += items.length; + lines.push('', `${label} (${items.length})`); + for (const item of items.slice(0, limit)) lines.push(itemLine(item, options.now)); + if (items.length > limit) lines.push(` … ${items.length - limit} more`); + } + + if (entries === 0) lines.push('', 'nothing happened while you were away'); + + const tokens = [ + formatTokens(totals.inputTokens ?? 0) ? `${formatTokens(totals.inputTokens ?? 0)} in` : '', + formatTokens(totals.outputTokens ?? 0) ? `${formatTokens(totals.outputTokens ?? 0)} out` : '', + typeof totals.estimatedCost === 'number' && totals.estimatedCost > 0 ? `$${totals.estimatedCost.toFixed(2)}` : '', + ].filter((part) => part !== ''); + if (tokens.length > 0) lines.push('', `tokens: ${tokens.join(' · ')}`); + + return lines; +} diff --git a/test/tui/tui-approvals.test.ts b/test/tui/tui-approvals.test.ts new file mode 100644 index 00000000..f9072f80 --- /dev/null +++ b/test/tui/tui-approvals.test.ts @@ -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 { + 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']); + }); +}); diff --git a/test/tui/tui-composer.test.ts b/test/tui/tui-composer.test.ts new file mode 100644 index 00000000..00a48e15 --- /dev/null +++ b/test/tui/tui-composer.test.ts @@ -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' }); + }); +}); diff --git a/test/tui/tui-digest.test.ts b/test/tui/tui-digest.test.ts new file mode 100644 index 00000000..2ac2bee5 --- /dev/null +++ b/test/tui/tui-digest.test.ts @@ -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 { + 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 { + 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'); + }); +});