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'); + }); +});