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:
Codeman maintainer
2026-08-22 14:13:58 +02:00
parent 9d7dd2ab62
commit bb73400afa
6 changed files with 852 additions and 0 deletions
+137
View File
@@ -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<string>, 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;
}
+205
View File
@@ -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' };
}
}
+90
View File
@@ -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<readonly [AwayDigestSectionName, string]> = [
['needsAttention', 'NEEDS ATTENTION'],
['completed', 'COMPLETED'],
['stillRunning', 'STILL RUNNING'],
['idle', 'IDLE'],
['informational', 'INFO'],
];
const RANGE_WORDS: Record<string, string> = {
'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;
}