Files
Codeman/src/tui/tui-composer.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

206 lines
8.0 KiB
TypeScript

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