Files
Codeman/src/tui/tui-approvals.ts
T
Codeman maintainer bb73400afa 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>
2026-08-22 14:13:58 +02:00

138 lines
5.2 KiB
TypeScript

/**
* @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;
}