From 85b69e0923088e46592e10064c3ab7b9d4d1c991 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 20:00:56 +0200 Subject: [PATCH] feat: render the TUI picker overlay and a caller-supplied keymap The footer and the help overlay held the plan's full keymap, which would advertise verbs (prompt, search, digest, answer, resume) that the build does not implement yet and teach users that the TUI ignores keys. Both now take their entries from the render options when the caller passes them; the built-in lists stay as the fallback. The picker overlay windows its items around the cursor rather than clipping them, so the selected case stays visible in a long list. Co-Authored-By: Claude Fable 5 --- src/tui/tui-render.ts | 71 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 63 insertions(+), 8 deletions(-) diff --git a/src/tui/tui-render.ts b/src/tui/tui-render.ts index 61522fa6..8c629abb 100644 --- a/src/tui/tui-render.ts +++ b/src/tui/tui-render.ts @@ -21,7 +21,15 @@ import { clipStyledLine, padDisplay, stripStyles, visibleWidth } from './tui-ansi.js'; import type { TuiLayout, TuiRect } from './tui-layout.js'; -import type { TuiGlyphTier, TuiGroup, TuiRenderModel, TuiRow, TuiSessionRow, TuiSessionState } from './tui-types.js'; +import type { + TuiGlyphTier, + TuiGroup, + TuiPickerState, + TuiRenderModel, + TuiRow, + TuiSessionRow, + TuiSessionState, +} from './tui-types.js'; export interface TuiRenderOptions { /** Emit SGR color. False is NO_COLOR: cursor addressing and nothing else. */ @@ -31,6 +39,19 @@ export interface TuiRenderOptions { tick: number; /** Wall clock for elapsed times, passed in so a frame is reproducible. */ now: number; + /** + * Footer entries, already labelled, joined here with the separator glyph. + * The app layer passes the keys that actually do something right now (which + * verbs are wired up, whether a server is answering); omitting it falls back + * to the full keymap below. + */ + footerKeys?: readonly string[]; + /** + * `[key, what it does]` pairs for the help overlay, same reasoning as + * `footerKeys`: the app layer knows which verbs are wired up. Omitting it + * falls back to the full keymap. + */ + helpKeys?: ReadonlyArray; } // ───────────────────────────────────────────────────────────────────────────── @@ -465,13 +486,16 @@ const FOOTER_KEYS: Record string> = { message: () => 'esc dismiss', prompt: (g) => `${g.enter} send ${g.separator} esc cancel`, search: (g) => `${g.enter} open ${g.separator} esc cancel`, + 'new-session': (g) => `${g.updown} select ${g.separator} ${g.enter} choose ${g.separator} esc cancel`, }; function renderFooterLine(model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): string { const paint = painterFor(opts.color); const glyphs = glyphsFor(opts.glyphs); - const build = FOOTER_KEYS[model.mode] ?? FOOTER_KEYS.list; - return padDisplay(paint(clipStyledLine(` ${build(glyphs)}`, layout.cols), SGR.gray), layout.cols); + const text = opts.footerKeys + ? opts.footerKeys.join(` ${glyphs.separator} `) + : (FOOTER_KEYS[model.mode] ?? FOOTER_KEYS.list)(glyphs); + return padDisplay(paint(clipStyledLine(` ${text}`, layout.cols), SGR.gray), layout.cols); } // ───────────────────────────────────────────────────────────────────────────── @@ -500,8 +524,8 @@ function wrapText(text: string, width: number): string[] { return out.length > 0 ? out : ['']; } -function helpLines(glyphs: TuiGlyphSet): string[] { - const pairs: Array<[string, string]> = [ +function helpLines(glyphs: TuiGlyphSet, custom?: ReadonlyArray): string[] { + const pairs: ReadonlyArray = custom ?? [ [`${glyphs.updown} / j k`, 'select'], [glyphs.enter, 'attach'], ['1-9', 'jump'], @@ -519,11 +543,42 @@ function helpLines(glyphs: TuiGlyphSet): string[] { return pairs.map(([key, description]) => `${padDisplay(key, keyWidth)} ${description}`); } -function overlayContent(model: TuiRenderModel, opts: TuiRenderOptions, width: number): OverlayContent | null { +/** Longest item list a picker overlay shows, however tall the terminal is. */ +const PICKER_MAX_ROWS = 10; + +/** + * A picker's lines: hint, a window of items around the cursor, then the filter + * echo. Windowed rather than clipped, so the selected item is always visible in + * a long case list. + */ +function pickerLines(picker: TuiPickerState, glyphs: TuiGlyphSet, capacity: number): string[] { + const head: string[] = picker.hint ? [picker.hint, ''] : []; + const tail: string[] = picker.filter === undefined ? [] : ['', `filter: ${picker.filter}_`]; + if (picker.items.length === 0) return [...head, '(nothing to choose)', ...tail]; + + const budget = Math.max(1, Math.min(PICKER_MAX_ROWS, capacity - head.length - tail.length)); + const first = Math.max(0, Math.min(picker.index - Math.floor(budget / 2), picker.items.length - budget)); + const rows = picker.items.slice(first, first + budget).map((item, i) => { + const marker = first + i === picker.index ? glyphs.cursor : ' '.repeat(visibleWidth(glyphs.cursor)); + return `${marker} ${item.label}${item.detail ? ` ${item.detail}` : ''}`; + }); + return [...head, ...rows, ...tail]; +} + +function overlayContent( + model: TuiRenderModel, + opts: TuiRenderOptions, + width: number, + height: number +): OverlayContent | null { const glyphs = glyphsFor(opts.glyphs); switch (model.mode) { case 'help': - return { title: 'Keys', lines: helpLines(glyphs) }; + return { title: 'Keys', lines: helpLines(glyphs, opts.helpKeys) }; + case 'new-session': { + if (!model.picker) return null; + return { title: model.picker.title, lines: pickerLines(model.picker, glyphs, Math.max(1, height - 2)) }; + } case 'confirm-kill': { if (!model.confirm) return null; const { name, typed } = model.confirm; @@ -547,7 +602,7 @@ function overlayContent(model: TuiRenderModel, opts: TuiRenderOptions, width: nu function applyOverlay(lines: string[], model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): void { const body = layout.body; if (body.height < 3 || body.width < 12) return; - const content = overlayContent(model, opts, body.width); + const content = overlayContent(model, opts, body.width, body.height); if (!content) return; const paint = painterFor(opts.color);