···88- Add integration coverage for both installers: a real `systemctl --user` install/uninstall test and a runit install test that boots the generated service under a private `runsvdir`.
991010### TUI framework
1111+- **Focus manager.** `createFocusManager()` + `ctx.focus` on every `ScreenContext`. Stack-based: `push(scope)` returns a disposer, `dispatchKey` / `dispatchMouse` walk innermost → outermost with bubbling (return `false` to let the parent scope try). Scopes can be conditionally active via an `active: () => boolean` predicate — useful for keeping sibling scopes alive (like one per pane) and switching which one dispatches without pushing/popping. Solves the "nested panes / modals / overlays" routing problem that every app was re-implementing by hand. The playground now uses it: global pane-switch chords via `AppConfig.onKey` (they intercept before any scope so modals can't swallow them); pane scopes in the focus stack with `active: () => pane.get() === ...`. Modal overlays just push a scope on top and dispose it when closed. `FocusManager`, `FocusScope` exported from `@myobie/pty/tui`.
1212+- **Mouse support.** Set `AppConfig.mouse: true` to enable SGR mouse reporting. Screens get an optional `handleMouse(event, ctx)` alongside `handleKey`. `MouseEvent` carries `action` (`press` / `release` / `drag` / `move` / `scrollUp` / `scrollDown`), `button` (left/middle/right/none), 0-based `x`/`y`, and modifier flags. `hitTest(roots, x, y)` walks the rendered tree by `_rect` to find the deepest node under a point; `findInPath` locates a typed ancestor. Widgets that benefit from mouse — `virtual-list` (click + scroll), `stream-view` (scroll), `tabs` (click to select) — gained `handleVirtualMouse` / `handleStreamMouse` / `handleTabsMouse` helpers. `parseInput(buf)` returns the unified `InputEvent` stream; the legacy `parseKey(buf)` filters it to keyboard-only so existing consumers are unaffected.
1313+- **Cursor rendering via `inverse`.** `text()` gained optional `inverse` and `background` style flags. Text widgets (`renderFieldText` / `renderTextArea`) render the cursor by painting the character under it with fg/bg swapped instead of inserting a block glyph that shifts neighbors sideways. New `renderFieldNodes(text, cursor, active, opts)` returns the three text nodes (before / inverse / after) for composing custom fields.
1414+- **Multi-line prompt bar.** `promptBar({ kind: "multi", state })` now renders each line of the `TextAreaState` as its own row within its column, so newlines actually show. Prompt glyph appears only on the first line; continuation rows are indented.
1515+- **Word motion in text fields.** `applyTextKey` / `applyTextAreaKey` respect `alt+left`/`alt+right` and `alt+b`/`alt+f` for word-boundary navigation. The input parser learned modified-arrow sequences (`ESC[1;mods<letter>`) so `option+arrow` on macOS terminals routes correctly.
1616+- **Separator bg preserved inside panels.** `hSepBuf` used to zero-out the cell bg, showing the terminal default through as a grey band when separators sat inside a panel. Now it preserves the underlying cell bg.
1717+- **Breaking: `handleKey` return value no longer quits the app.** Screens that want to quit now call `ctx.quit()` explicitly (`ScreenContext.quit`). The old "return `false` from `handleKey` → app exits" behavior was a footgun: any screen that forgot to return `true` on an unhandled key would accidentally terminate. The return value is now a hint for nested routing only; the framework ignores it. Also: `app()` now has a default ctrl+c handler that calls quit unless `AppConfig.onKey` intercepts the key first — most consumers duplicated this themselves and no longer need to. The interactive pty TUI and the playground demo have been migrated to the new pattern.
1818+- **New widgets tier.** `src/tui/widgets/` exposes higher-level building blocks on top of the core primitives: tree view (`tree.ts`), date picker (`date-picker.ts`), multi-field form with focus ring (`form.ts`), markdown renderer (`markdown.ts`), multi-line text composer (`text-area.ts`), virtualized list for large datasets (`virtual-list.ts`), sticky-bottom stream view for chat/logs (`stream-view.ts`), horizontal tabs (`tabs.ts`), confirm modal (`confirm.ts`), toast queue (`toast.ts`), fuzzy command palette (`command-palette.ts`), sortable table (`table.ts`), a help-overlay helper (`help-overlay.ts`), Claude-Code-style prompt bar (`prompt-bar.ts`), toolbar (`toolbar.ts`), sparkline (`sparkline.ts`), and bar chart (`bar-chart.ts`). All state-first: the consumer owns the signals, widgets are pure key-dispatch + pure render. Re-exported from `@myobie/pty/tui`; existing primitives (text, row, column, panel, selectable, etc.) are unchanged. Full tests per widget.
1919+- **Signal-backed command registry** (`command-registry.ts`): `registerGlobalCommand` for app-lifetime commands and `useCommandScope(scopeId, commands)` for screen/focus-scoped commands. The existing `command-palette.ts` widget takes a `Command[]` — pass `allCommands.get()` to get the aggregated live view. Scopes compose (global + screen + focus), replace in one call (useful for contextual focused-item commands), and auto-unregister via returned disposers. Enables the "command palette represents every action across the app" pattern without every call site knowing about every command.
2020+- **Panel footer caption.** `panel(title, children, { footerTitle: "x" })` renders an optional caption on the bottom border with the same chrome as the top title. Mirrors mactop's "4/17 layout (skyblue) · -/+ 1000ms" strip. Back-compat: the third arg can still be a plain `BoxStyle` string.
1121- **Shift+Tab (backtab) now fires as a proper key event.** The raw-stdin parser in `src/tui/input.ts` previously dropped the legacy `ESC[Z` encoding as "unknown CSI" and ignored the shift modifier in the kitty keyboard protocol for code 9 (tab). Both paths now produce `{ name: "backtab", shift: true, ctrl: false, alt: false }`, so form widgets across apps can bind shift-tab for backward field navigation. Added `shift: boolean` to `KeyEvent` as a required field — existing consumers that only read ctrl/alt are unaffected. Surfaced while building the `reminders` app where the form widget needed backtab to move between fields.
2222+2323+### Demos
2424+- Add `demos/playground/` — an interactive catalog of every widget the TUI framework ships, grouped by category (atoms, layout, inputs, lists, data, overlays, patterns, kitchen-sink). Each demo has its own state, live interaction, and a source snippet. Run via `./demos/run playground`. Intended as reference docs + a shareable "here's what the framework does."
12251326### Fixes
1427- **Shift+Enter / kitty keyboard protocol inside supervised (launchd-started) sessions.** Children of a daemon started from a minimal env (launchd, systemd, sparse CI runners) previously had no `TERM` at all, which caused modern TUIs — Claude Code, vim, nvim — to fall back to legacy key encoding where Shift+Enter is indistinguishable from Enter. Two related fixes:
+1
README.md
···326326- **file-browser** — two-pane directory tree + file preview with soft-wrap and markdown highlighting
327327- **reminders** — full CRUD backed by `.md` files, three views (list, board, calendar), overlays
328328- **agent-teams** — live dashboard of a simulated AI agent hierarchy with real-time updates
329329+- **playground** — interactive catalog of every TUI widget — atoms, layout, inputs, lists, data, overlays, and composition patterns, each with a live example and source snippet. A reference for anyone building on the TUI framework.
329330330331Run them with `node --experimental-strip-types demos/{name}/main.ts` (or `./demos/run <name>`). Each demo includes unit tests and PTY integration tests that exercise the testing library.
331332
···11+// All demos bundled for the sidebar. Order within the array is the order
22+// the sidebar renders them.
33+44+import { atoms } from "./demos/atoms.ts";
55+import { layout } from "./demos/layout.ts";
66+import {
77+ textInputDemo, textAreaDemo, formDemo, datePickerDemo,
88+} from "./demos/inputs.ts";
99+import {
1010+ virtualListDemo, treeDemo,
1111+} from "./demos/lists.ts";
1212+import { tableDemo } from "./demos/data.ts";
1313+import {
1414+ confirmDemo, toastDemo, commandPaletteDemo, helpDemo,
1515+} from "./demos/overlays.ts";
1616+import {
1717+ tabsDemo, markdownDemo, streamViewDemo,
1818+} from "./demos/patterns.ts";
1919+import {
2020+ promptBarDemo, promptBarMultiDemo, toolbarDemo,
2121+} from "./demos/bars.ts";
2222+import {
2323+ sparklineDemo, barChartDemo, meterGridDemo,
2424+} from "./demos/meters.ts";
2525+import { kitchenSink } from "./demos/kitchen-sink.ts";
2626+import type { Demo } from "./types.ts";
2727+2828+export const demos: Demo[] = [
2929+ atoms,
3030+ layout,
3131+ textInputDemo,
3232+ textAreaDemo,
3333+ formDemo,
3434+ datePickerDemo,
3535+ virtualListDemo,
3636+ treeDemo,
3737+ tableDemo,
3838+ sparklineDemo,
3939+ barChartDemo,
4040+ tabsDemo,
4141+ markdownDemo,
4242+ streamViewDemo,
4343+ promptBarDemo,
4444+ promptBarMultiDemo,
4545+ toolbarDemo,
4646+ meterGridDemo,
4747+ confirmDemo,
4848+ toastDemo,
4949+ commandPaletteDemo,
5050+ helpDemo,
5151+ kitchenSink,
5252+];
5353+5454+export function demosByCategory(): { category: string; demos: Demo[] }[] {
5555+ const order: string[] = [];
5656+ const groups = new Map<string, Demo[]>();
5757+ for (const d of demos) {
5858+ if (!groups.has(d.category)) { order.push(d.category); groups.set(d.category, []); }
5959+ groups.get(d.category)!.push(d);
6060+ }
6161+ return order.map(cat => ({ category: cat, demos: groups.get(cat)! }));
6262+}
+131
demos/playground/router.ts
···11+// Playground routing expressed as focus scopes + a top-level chord
22+// interceptor.
33+//
44+// - ctrl+h / ctrl+l (pane-switch) are wired into AppConfig.onKey so
55+// they intercept before ANY scope. Those chords must always win —
66+// even a modal overlay shouldn't swallow them.
77+//
88+// - "sidebar" and "main" pane scopes sit in the focus stack with
99+// `active: () => pane.get() === <id>`. Only one is ever dispatched
1010+// per key; the other is inert.
1111+//
1212+// - Modal overlays can push additional scopes on top at will; they'll
1313+// run before the pane scopes but after AppConfig.onKey, which is
1414+// the right ordering (modal can eat esc but not ctrl+c).
1515+//
1616+// Mouse is kept as a flat handler because the hit-test logic needs the
1717+// actual rendered rects, which scopes don't track. If we add focus-aware
1818+// hit-testing later we can revisit.
1919+2020+import { batch } from "../../src/tui/index.ts";
2121+import type {
2222+ KeyEvent, MouseEvent, ScreenContext, FocusManager,
2323+} from "../../src/tui/index.ts";
2424+// KeyEvent imported above — needed by makeGlobalChords's returned closure.
2525+import type { Signal } from "../../src/tui/signals.ts";
2626+import type { Demo } from "./types.ts";
2727+2828+export interface RouterDeps {
2929+ selectedDemoId: Signal<string>;
3030+ selectedPane: Signal<"sidebar" | "main">;
3131+ showHelp: Signal<boolean>;
3232+ showSource: Signal<boolean>;
3333+ sidebarEntries: () => { kind: "header" | "demo"; demoId?: string }[];
3434+ selectedDemo: () => Demo;
3535+ selectNextDemo: (delta: number) => void;
3636+ stepDemos: (delta: number) => void;
3737+}
3838+3939+/** Top-level chord interceptor. Install via `AppConfig.onKey` so it runs
4040+ * before the focus stack — even a modal overlay won't swallow pane
4141+ * navigation or app-wide shortcuts. */
4242+export function makeGlobalChords(deps: RouterDeps) {
4343+ return (key: KeyEvent): boolean => {
4444+ if (key.name === "h" && key.ctrl) { deps.selectedPane.set("sidebar"); return true; }
4545+ if (key.name === "l" && key.ctrl) { deps.selectedPane.set("main"); return true; }
4646+ return false;
4747+ };
4848+}
4949+5050+/** Push the playground's pane scopes onto `focus`. Returns a disposer. */
5151+export function registerPlaygroundScopes(
5252+ focus: FocusManager,
5353+ deps: RouterDeps,
5454+): () => void {
5555+ const disposeSidebar = focus.push({
5656+ id: "sidebar",
5757+ active: () => deps.selectedPane.get() === "sidebar",
5858+ onKey(key, ctx) {
5959+ if (key.char === "q") { ctx.quit(); return true; }
6060+ if (key.char === "?") { deps.showHelp.set(!deps.showHelp.peek()); return true; }
6161+ if (key.char === "s") { deps.showSource.set(!deps.showSource.peek()); return true; }
6262+ if (key.name === "up") { deps.selectNextDemo(-1); return true; }
6363+ if (key.name === "down") { deps.selectNextDemo(1); return true; }
6464+ if (key.name === "pageup") { deps.stepDemos(-5); return true; }
6565+ if (key.name === "pagedown") { deps.stepDemos(5); return true; }
6666+ if (key.name === "right" || key.name === "tab" || key.name === "return") {
6767+ deps.selectedPane.set("main");
6868+ return true;
6969+ }
7070+ // Unhandled — bubble up to the "global" scope so chords like ctrl+h
7171+ // still work. The main scope is inactive while we are, so nothing
7272+ // else can intercept en route.
7373+ return false;
7474+ },
7575+ });
7676+7777+ const disposeMain = focus.push({
7878+ id: "main",
7979+ active: () => deps.selectedPane.get() === "main",
8080+ onKey(key, ctx) {
8181+ // Every key the demo sees, regardless of whether the demo reports
8282+ // it as consumed — nothing should ever leak to the sidebar while
8383+ // main has focus.
8484+ deps.selectedDemo().handleKey(key, ctx);
8585+ return true;
8686+ },
8787+ });
8888+8989+ let disposed = false;
9090+ return () => {
9191+ if (disposed) return;
9292+ disposed = true;
9393+ disposeMain();
9494+ disposeSidebar();
9595+ };
9696+}
9797+9898+/** Mouse is simpler: a single flat handler that decides sidebar vs main
9999+ * by x-coordinate. Call from the screen's handleMouse. */
100100+export function handleMouse(deps: RouterDeps, event: MouseEvent): boolean {
101101+ const sidebarWidth = 24;
102102+ const inSidebar = event.x < sidebarWidth;
103103+104104+ if (inSidebar) {
105105+ if (event.action === "scrollUp") { deps.selectNextDemo(-1); return true; }
106106+ if (event.action === "scrollDown") { deps.selectNextDemo(1); return true; }
107107+ if (event.action === "press" && event.button === "left") {
108108+ const entries = deps.sidebarEntries();
109109+ const entryIdx = event.y - 1;
110110+ if (entryIdx >= 0 && entryIdx < entries.length) {
111111+ const entry = entries[entryIdx];
112112+ if (entry.kind === "demo" && entry.demoId) {
113113+ batch(() => {
114114+ deps.selectedDemoId.set(entry.demoId!);
115115+ deps.selectedPane.set("main");
116116+ });
117117+ return true;
118118+ }
119119+ }
120120+ }
121121+ return false;
122122+ }
123123+124124+ if (event.action === "press" && event.button === "left") {
125125+ deps.selectedPane.set("main");
126126+ return true;
127127+ }
128128+ if (event.action === "scrollUp") { deps.selectNextDemo(-1); return true; }
129129+ if (event.action === "scrollDown") { deps.selectNextDemo(1); return true; }
130130+ return false;
131131+}
+261
demos/playground/routing.test.ts
···11+// Integration tests for the playground's pane-focus routing.
22+//
33+// Exercises the REAL router in router.ts (not a mirror), wiring up the
44+// minimum deps it needs. Catches regressions like "down arrow leaked
55+// from main-pane demo into sidebar selection."
66+//
77+// Rules the router enforces:
88+//
99+// SIDEBAR FOCUSED:
1010+// up/down -> prev/next demo
1111+// pageup/pagedown -> jump by 5
1212+// right/tab/enter -> focus main
1313+// q / ? / s -> quit / help / source (playground-owned shortcuts)
1414+// anything else -> swallowed (does NOT leak to a demo)
1515+//
1616+// MAIN FOCUSED:
1717+// every key -> the demo's handleKey
1818+// sidebar never changes from a key typed here
1919+// ctrl+h -> focus sidebar (always works)
2020+//
2121+// UNIVERSAL:
2222+// ctrl+h / ctrl+l -> focus sidebar / main
2323+2424+import { describe, it, expect, beforeEach } from "vitest";
2525+import { signal } from "../../src/tui/index.ts";
2626+import type {
2727+ KeyEvent, MouseEvent, ScreenContext,
2828+} from "../../src/tui/index.ts";
2929+import { createFocusManager } from "../../src/tui/index.ts";
3030+import {
3131+ registerPlaygroundScopes, makeGlobalChords, handleMouse, type RouterDeps,
3232+} from "./router.ts";
3333+import type { Demo } from "./types.ts";
3434+3535+function k(name: string, extra: Partial<KeyEvent> = {}): KeyEvent {
3636+ return { kind: "key", name, ctrl: false, alt: false, shift: false, ...extra };
3737+}
3838+3939+function me(x: number, y: number, extra: Partial<MouseEvent> = {}): MouseEvent {
4040+ return {
4141+ kind: "mouse", action: "press", button: "left",
4242+ x, y, ctrl: false, alt: false, shift: false,
4343+ ...extra,
4444+ };
4545+}
4646+4747+function mkCtx(quit: () => void): ScreenContext {
4848+ return {
4949+ rows: 40, cols: 100,
5050+ theme: {} as any,
5151+ boxStyle: "rounded",
5252+ navigate: () => {},
5353+ back: () => {},
5454+ openOverlay: () => {},
5555+ closeOverlay: () => {},
5656+ isTextInputActive: () => false,
5757+ setTextInputActive: () => {},
5858+ quit,
5959+ };
6060+}
6161+6262+/** Build a self-contained router harness with fake demos so we can
6363+ * observe forwarded keys without real demos side-effecting. */
6464+function harness(demoCount = 10) {
6565+ const selectedDemoId = signal<string>("demo-0");
6666+ const selectedPane = signal<"sidebar" | "main">("sidebar");
6767+ const showHelp = signal(false);
6868+ const showSource = signal(true);
6969+7070+ let lastForwarded: { key: KeyEvent | null } = { key: null };
7171+ const fakeDemo: Demo = {
7272+ id: "demo-0",
7373+ category: "cat",
7474+ name: "demo-0",
7575+ blurb: "",
7676+ source: "",
7777+ render: () => [],
7878+ handleKey: (key) => { lastForwarded.key = key; return true; },
7979+ };
8080+8181+ const entries = [
8282+ { kind: "header" as const, demoId: undefined },
8383+ ...Array.from({ length: demoCount }, (_, i) => ({
8484+ kind: "demo" as const,
8585+ demoId: `demo-${i}`,
8686+ })),
8787+ ];
8888+8989+ let demoIdx = 0;
9090+ const step = (delta: number) => {
9191+ demoIdx = Math.max(0, Math.min(demoCount - 1, demoIdx + delta));
9292+ selectedDemoId.set(`demo-${demoIdx}`);
9393+ };
9494+9595+ const deps: RouterDeps = {
9696+ selectedDemoId,
9797+ selectedPane,
9898+ showHelp,
9999+ showSource,
100100+ sidebarEntries: () => entries,
101101+ selectedDemo: () => fakeDemo,
102102+ selectNextDemo: step,
103103+ stepDemos: step,
104104+ };
105105+106106+ const focus = createFocusManager();
107107+ const dispose = registerPlaygroundScopes(focus, deps);
108108+ const globalChords = makeGlobalChords(deps);
109109+110110+ // Mirror app()'s real dispatch order: onKey (global chords) first,
111111+ // then the focus stack.
112112+ const handleKey = (key: KeyEvent, ctx: ScreenContext): boolean => {
113113+ if (globalChords(key)) return true;
114114+ return focus.dispatchKey(key, ctx);
115115+ };
116116+117117+ return {
118118+ deps, selectedPane, selectedDemoId, showHelp, showSource,
119119+ lastForwarded, entries, focus, handleKey, dispose,
120120+ };
121121+}
122122+123123+describe("playground routing — sidebar focused", () => {
124124+ let h: ReturnType<typeof harness>;
125125+ beforeEach(() => { h = harness(); });
126126+127127+ it("up/down move the selection", () => {
128128+ h.handleKey(k("down"), mkCtx(() => {}));
129129+ expect(h.selectedDemoId.get()).toBe("demo-1");
130130+ h.handleKey(k("down"), mkCtx(() => {}));
131131+ expect(h.selectedDemoId.get()).toBe("demo-2");
132132+ h.handleKey(k("up"), mkCtx(() => {}));
133133+ expect(h.selectedDemoId.get()).toBe("demo-1");
134134+ });
135135+136136+ it("pageup/pagedown jump by 5", () => {
137137+ h.handleKey(k("pagedown"), mkCtx(() => {}));
138138+ expect(h.selectedDemoId.get()).toBe("demo-5");
139139+ h.handleKey(k("pageup"), mkCtx(() => {}));
140140+ expect(h.selectedDemoId.get()).toBe("demo-0");
141141+ });
142142+143143+ it("right / tab / return focus main", () => {
144144+ h.handleKey(k("right"), mkCtx(() => {}));
145145+ expect(h.selectedPane.get()).toBe("main");
146146+ });
147147+148148+ it("q quits, ? toggles help, s toggles source", () => {
149149+ let quit = 0;
150150+ h.handleKey(k("q", { char: "q" }), mkCtx(() => { quit++; }));
151151+ expect(quit).toBe(1);
152152+ h.handleKey(k("?", { char: "?" }), mkCtx(() => {}));
153153+ expect(h.showHelp.get()).toBe(true);
154154+ const before = h.showSource.get();
155155+ h.handleKey(k("s", { char: "s" }), mkCtx(() => {}));
156156+ expect(h.showSource.get()).toBe(!before);
157157+ });
158158+159159+ it("arbitrary keys are swallowed — don't leak to demo", () => {
160160+ h.handleKey(k("x", { char: "x" }), mkCtx(() => {}));
161161+ expect(h.selectedPane.get()).toBe("sidebar");
162162+ expect(h.lastForwarded.key).toBeNull();
163163+ });
164164+});
165165+166166+describe("playground routing — main focused (the regression guards)", () => {
167167+ let h: ReturnType<typeof harness>;
168168+ beforeEach(() => {
169169+ h = harness();
170170+ h.handleKey(k("right"), mkCtx(() => {}));
171171+ expect(h.selectedPane.get()).toBe("main");
172172+ });
173173+174174+ it("down arrow goes to the demo, NOT the sidebar", () => {
175175+ const before = h.selectedDemoId.get();
176176+ h.handleKey(k("down"), mkCtx(() => {}));
177177+ expect(h.selectedDemoId.get()).toBe(before);
178178+ expect(h.lastForwarded.key?.name).toBe("down");
179179+ });
180180+181181+ it("up arrow goes to the demo, NOT the sidebar", () => {
182182+ const before = h.selectedDemoId.get();
183183+ h.handleKey(k("up"), mkCtx(() => {}));
184184+ expect(h.selectedDemoId.get()).toBe(before);
185185+ expect(h.lastForwarded.key?.name).toBe("up");
186186+ });
187187+188188+ it("tab goes to the demo (forms use it)", () => {
189189+ h.handleKey(k("tab"), mkCtx(() => {}));
190190+ expect(h.selectedPane.get()).toBe("main");
191191+ expect(h.lastForwarded.key?.name).toBe("tab");
192192+ });
193193+194194+ it("left arrow goes to the demo (date picker uses it)", () => {
195195+ h.handleKey(k("left"), mkCtx(() => {}));
196196+ expect(h.selectedPane.get()).toBe("main");
197197+ expect(h.lastForwarded.key?.name).toBe("left");
198198+ });
199199+200200+ it("q / s / ? go to the demo (could be text input)", () => {
201201+ h.handleKey(k("q", { char: "q" }), mkCtx(() => {}));
202202+ h.handleKey(k("s", { char: "s" }), mkCtx(() => {}));
203203+ h.handleKey(k("?", { char: "?" }), mkCtx(() => {}));
204204+ expect(h.showHelp.get()).toBe(false);
205205+ expect(h.showSource.get()).toBe(true);
206206+ expect(h.lastForwarded.key?.char).toBe("?");
207207+ });
208208+209209+ it("escape does NOT change focus — demos may need it", () => {
210210+ h.handleKey(k("escape"), mkCtx(() => {}));
211211+ expect(h.selectedPane.get()).toBe("main");
212212+ expect(h.lastForwarded.key?.name).toBe("escape");
213213+ });
214214+215215+ it("ctrl+h is the escape hatch back to the sidebar", () => {
216216+ h.handleKey(k("h", { ctrl: true }), mkCtx(() => {}));
217217+ expect(h.selectedPane.get()).toBe("sidebar");
218218+ });
219219+});
220220+221221+describe("playground routing — universal shortcuts", () => {
222222+ it("ctrl+l focuses main from sidebar", () => {
223223+ const h = harness();
224224+ h.handleKey(k("l", { ctrl: true }), mkCtx(() => {}));
225225+ expect(h.selectedPane.get()).toBe("main");
226226+ });
227227+228228+ it("ctrl+h focuses sidebar from main", () => {
229229+ const h = harness();
230230+ h.handleKey(k("right"), mkCtx(() => {}));
231231+ h.handleKey(k("h", { ctrl: true }), mkCtx(() => {}));
232232+ expect(h.selectedPane.get()).toBe("sidebar");
233233+ });
234234+});
235235+236236+describe("playground routing — mouse", () => {
237237+ it("click on a sidebar demo row selects + focuses main", () => {
238238+ const h = harness();
239239+ // Panel border is row 0. entries[0] is the "cat" header at row 1.
240240+ // entries[1] is demo-0 at row 2. entries[2] is demo-1 at row 3.
241241+ // So y=3 → entryIdx=2 → demo-1.
242242+ handleMouse(h.deps, me(10, 3));
243243+ expect(h.selectedDemoId.get()).toBe("demo-1");
244244+ expect(h.selectedPane.get()).toBe("main");
245245+ });
246246+247247+ it("scroll over sidebar cycles demos without changing focus", () => {
248248+ const h = harness();
249249+ handleMouse(h.deps, me(10, 5, { action: "scrollDown", button: "none" }));
250250+ expect(h.selectedDemoId.get()).toBe("demo-1");
251251+ expect(h.selectedPane.get()).toBe("sidebar");
252252+ });
253253+254254+ it("click on main pane only focuses main, doesn't change selection", () => {
255255+ const h = harness();
256256+ const before = h.selectedDemoId.get();
257257+ handleMouse(h.deps, me(50, 10));
258258+ expect(h.selectedPane.get()).toBe("main");
259259+ expect(h.selectedDemoId.get()).toBe(before);
260260+ });
261261+});
+24
demos/playground/types.ts
···11+// Shape every playground demo exports.
22+//
33+// A demo is a bite-sized interactive example: its own state, its own
44+// render + key handler, and a source snippet shown under the live view.
55+// The main app routes input to whichever demo is currently selected.
66+77+import type { UINode, ScreenContext, KeyEvent } from "../../src/tui/index.ts";
88+99+export interface Demo {
1010+ /** Stable id — used as the tree node key in the sidebar. */
1111+ id: string;
1212+ /** Sidebar category label. */
1313+ category: string;
1414+ /** Display name inside the category. */
1515+ name: string;
1616+ /** One-line blurb shown above the live area. */
1717+ blurb: string;
1818+ /** Source snippet printed under the live view. */
1919+ source: string;
2020+ /** Render the live view. Return an array of nodes for the main pane. */
2121+ render(ctx: ScreenContext): UINode[];
2222+ /** Handle a key. Return true if consumed. */
2323+ handleKey(key: KeyEvent, ctx: ScreenContext): boolean;
2424+}
+3-1
demos/run
···22# Run a demo app: ./demos/run file-browser [path]
33# ./demos/run reminders
44# ./demos/run agent-teams
55+# ./demos/run playground
56set -e
6778demo="$1"
···1415 echo " file-browser [path] Two-pane file browser with preview"
1516 echo " reminders CRUD reminders backed by .md files"
1617 echo " agent-teams Live AI agent hierarchy dashboard (SPEED=6 for fast)"
1818+ echo " playground Interactive catalog of every TUI widget"
1719 exit 1
1820fi
1921···21232224if [ ! -f "$script" ]; then
2325 echo "Unknown demo: $demo"
2424- echo "Available: file-browser, reminders, agent-teams"
2626+ echo "Available: file-browser, reminders, agent-teams, playground"
2527 exit 1
2628fi
2729
+51-11
src/tui/app.ts
···22// Supports pause/resume for handing the terminal to another process (e.g. attach).
33import type { Screen, ScreenContext } from "./types.ts";
44import type { Theme, BoxStyle } from "./colors.ts";
55-import type { KeyEvent } from "./input.ts";
66-import { parseKey } from "./input.ts";
55+import type { KeyEvent, MouseEvent, InputEvent } from "./input.ts";
66+import { parseInput, isMouseEvent, MOUSE_ENABLE_SGR, MOUSE_DISABLE_SGR } from "./input.ts";
77+import { createFocusManager, type FocusManager } from "./focus.ts";
78import { hideCursor, showCursor, reset } from "./colors.ts";
89import { CellBuffer, diff, fullRender } from "./buffer.ts";
910import { recordFrame, getCurrentFPS, isFPSVisible } from "./fps.ts";
···2122 overlay?: () => Screen | null;
2223 /** Called before the screen's handleKey. Return true = key consumed, false = pass to screen. */
2324 onKey?: (key: KeyEvent) => boolean;
2525+ /** Called before the screen's handleMouse. Return true = consumed. */
2626+ onMouse?: (event: MouseEvent) => boolean;
2427 /** Theme provider. Defaults to coolBlue. */
2528 theme?: () => Theme;
2629 /** Box style provider. Defaults to "rounded". */
2730 boxStyle?: () => BoxStyle;
3131+ /** Enable SGR mouse reporting on start (CSI ?1002h + ?1006h) and disable
3232+ * on stop. Default: false. Enable this when your app wants to receive
3333+ * mouse events via screen.handleMouse / onMouse. */
3434+ mouse?: boolean;
3535+ /** Shared focus manager. If omitted, `app()` creates one automatically
3636+ * and exposes it as `ctx.focus` on every screen render/handleKey call.
3737+ * Provide your own to share focus state across app instances (rare). */
3838+ focus?: FocusManager;
2839}
29403041/** A running TUI app with lifecycle control. */
···5364 let exitHandler: (() => void) | null = null;
5465 let activeScreen: Screen | null = null;
5566 let activeOverlay: Screen | null = null;
6767+ const focus: FocusManager = config.focus ?? createFocusManager();
56685769 function getSize(): [number, number] {
5870 return [(stdout as any).rows ?? 35, (stdout as any).columns ?? 120];
···8294 closeOverlay: () => {},
8395 isTextInputActive: () => false,
8496 setTextInputActive: () => {},
9797+ quit: () => {
9898+ self.stop();
9999+ process.exit(0);
100100+ },
101101+ focus,
85102 };
86103 }
87104···166183 function registerListeners(): void {
167184 stdinHandler = (data: Buffer | string) => {
168185 const buf = typeof data === "string" ? Buffer.from(data) : data;
169169- const keys = parseKey(buf);
170170- for (const key of keys) {
171171- // Global key interceptor
186186+ const events = parseInput(buf);
187187+ for (const event of events) {
188188+ if (isMouseEvent(event)) {
189189+ // Global mouse interceptor.
190190+ if (config.onMouse && config.onMouse(event)) continue;
191191+ const [rows, cols] = getSize();
192192+ const ctx = createContext(rows, cols);
193193+ const scr = resolveScreen();
194194+ scr.handleMouse?.(event, ctx);
195195+ continue;
196196+ }
197197+ const key = event;
198198+199199+ // Global key interceptor — if onKey returns true, the screen never
200200+ // sees the event (used for global shortcuts like command palette).
172201 if (config.onKey && config.onKey(key)) continue;
173173- // Screen key handler
202202+203203+ // Default ctrl+c handler: quits unless the global onKey already
204204+ // consumed it above. Screens that want to override this (e.g. a
205205+ // composer with double-ctrl-c to confirm) can intercept via
206206+ // config.onKey.
207207+ if (key.name === "c" && key.ctrl) {
208208+ self.stop();
209209+ process.exit(130);
210210+ }
211211+212212+ // Screen key handler. Return value is a hint for nested routing;
213213+ // the app ignores it. Screens that want to quit call ctx.quit()
214214+ // explicitly — returning false from here used to quit the app
215215+ // and was a footgun.
174216 const [rows, cols] = getSize();
175217 const ctx = createContext(rows, cols);
176218 const scr = resolveScreen();
177177- const cont = scr.handleKey(key, ctx);
178178- if (!cont) {
179179- self.stop();
180180- process.exit(0);
181181- }
219219+ scr.handleKey(key, ctx);
182220 }
183221 };
184222 stdin.on("data", stdinHandler);
···207245208246 function enterTerminal(): void {
209247 stdout.write(enterAltScreen + hideCursor());
248248+ if (config.mouse) stdout.write(MOUSE_ENABLE_SGR);
210249 if (stdin.isTTY) stdin.setRawMode(true);
211250 stdin.resume();
212251 }
213252214253 function leaveTerminal(full: boolean): void {
254254+ if (config.mouse) stdout.write(MOUSE_DISABLE_SGR);
215255 if (full) {
216256 stdout.write(showCursor() + reset() + leaveAltScreen);
217257 } else {
···11+// Focus manager — a small, stack-based event router for TUI apps that
22+// have panes / overlays / nested widgets.
33+//
44+// Why: without this, every app rolls its own "which-area-gets-this-key"
55+// dispatcher. We've done that in the reminders TUI, the playground, and
66+// the interactive pty list — three apps, three bespoke routers, three
77+// ways to get it wrong. The most common bug: a key that one layer
88+// "didn't care about" silently reaches another layer that DID care and
99+// triggers the wrong action.
1010+//
1111+// Model:
1212+// - Scopes form a stack. Innermost = most recently pushed.
1313+// - Each scope has optional onKey / onMouse handlers.
1414+// - Dispatch walks innermost -> outermost. First handler to return
1515+// true consumes the event; the rest are skipped.
1616+// - A scope can declare itself conditionally active via `active()`.
1717+// Inactive scopes are skipped during dispatch (but still kept in
1818+// the stack so their disposers and onKey closures stay stable).
1919+//
2020+// Typical use:
2121+//
2222+// // Outermost: global shortcuts.
2323+// focus.push({
2424+// id: "global",
2525+// onKey(key, ctx) {
2626+// if (key.char === "q") { ctx.quit(); return true; }
2727+// return false;
2828+// },
2929+// });
3030+//
3131+// // Per-pane scopes. Both stay pushed; only the active one dispatches.
3232+// focus.push({
3333+// id: "sidebar",
3434+// active: () => pane.get() === "sidebar",
3535+// onKey(key) { if (key.name === "up") { ... return true; } return false; },
3636+// });
3737+// focus.push({
3838+// id: "main",
3939+// active: () => pane.get() === "main",
4040+// onKey(key, ctx) {
4141+// return currentDemo().handleKey(key, ctx);
4242+// },
4343+// });
4444+//
4545+// // Modal / overlay appears on top — its onKey runs first while alive.
4646+// const dispose = focus.push({ id: "confirm-delete", onKey: ... });
4747+// // when closed:
4848+// dispose();
4949+5050+import type { KeyEvent, MouseEvent } from "./input.ts";
5151+import type { ScreenContext } from "./types.ts";
5252+5353+export interface FocusScope {
5454+ /** Short identifier — used for debugging and introspection, not
5555+ * required to be unique (though it usually is). */
5656+ id: string;
5757+ /** Predicate controlling whether this scope dispatches events. Defaults
5858+ * to always-active. Use this to keep multiple sibling scopes in the
5959+ * stack (e.g. one per pane) and switch which one responds. */
6060+ active?: () => boolean;
6161+ /** Returns true when the scope consumed the key. False lets the event
6262+ * bubble to the next-outer scope. */
6363+ onKey?: (key: KeyEvent, ctx: ScreenContext) => boolean;
6464+ /** Same semantics as onKey for mouse events. */
6565+ onMouse?: (event: MouseEvent, ctx: ScreenContext) => boolean;
6666+}
6767+6868+export interface FocusManager {
6969+ /** Push a scope onto the stack. Returns a disposer that removes it.
7070+ * The disposer is idempotent — calling it twice is safe. */
7171+ push(scope: FocusScope): () => void;
7272+ /** Current innermost-active scope, or null if none. */
7373+ current(): FocusScope | null;
7474+ /** Snapshot of every scope, root -> innermost. */
7575+ stack(): readonly FocusScope[];
7676+ /** Dispatch a key event. Returns true if any scope consumed it. */
7777+ dispatchKey(key: KeyEvent, ctx: ScreenContext): boolean;
7878+ /** Dispatch a mouse event. */
7979+ dispatchMouse(event: MouseEvent, ctx: ScreenContext): boolean;
8080+}
8181+8282+export function createFocusManager(): FocusManager {
8383+ const scopes: FocusScope[] = [];
8484+8585+ function isActive(scope: FocusScope): boolean {
8686+ return scope.active ? scope.active() : true;
8787+ }
8888+8989+ return {
9090+ push(scope: FocusScope): () => void {
9191+ scopes.push(scope);
9292+ let disposed = false;
9393+ return () => {
9494+ if (disposed) return;
9595+ disposed = true;
9696+ const i = scopes.indexOf(scope);
9797+ if (i >= 0) scopes.splice(i, 1);
9898+ };
9999+ },
100100+101101+ current(): FocusScope | null {
102102+ for (let i = scopes.length - 1; i >= 0; i--) {
103103+ if (isActive(scopes[i])) return scopes[i];
104104+ }
105105+ return null;
106106+ },
107107+108108+ stack(): readonly FocusScope[] {
109109+ return scopes.slice();
110110+ },
111111+112112+ dispatchKey(key: KeyEvent, ctx: ScreenContext): boolean {
113113+ // Iterate on a snapshot so a handler can push or pop scopes
114114+ // without disturbing the current dispatch pass.
115115+ const snapshot = scopes.slice();
116116+ for (let i = snapshot.length - 1; i >= 0; i--) {
117117+ const scope = snapshot[i];
118118+ if (!isActive(scope)) continue;
119119+ if (scope.onKey && scope.onKey(key, ctx)) return true;
120120+ }
121121+ return false;
122122+ },
123123+124124+ dispatchMouse(event: MouseEvent, ctx: ScreenContext): boolean {
125125+ const snapshot = scopes.slice();
126126+ for (let i = snapshot.length - 1; i >= 0; i--) {
127127+ const scope = snapshot[i];
128128+ if (!isActive(scope)) continue;
129129+ if (scope.onMouse && scope.onMouse(event, ctx)) return true;
130130+ }
131131+ return false;
132132+ },
133133+ };
134134+}
+82
src/tui/hit-test.ts
···11+// Hit-testing for UINode trees. After layoutRoot runs, every node has a
22+// _rect (x, y, width, height) in screen coordinates. Given a mouse event
33+// with (x, y) in the same coordinate space, this walks the tree top-down
44+// and returns the deepest node whose rect contains the point, plus the
55+// chain of ancestors that also contained it.
66+//
77+// Consumers use this to route clicks to the right widget. Example:
88+//
99+// handleMouse(e, ctx) {
1010+// const hit = hitTest(renderedNodes, e.x, e.y);
1111+// if (!hit) return false;
1212+// // hit.path is [root, ..., deepest] — find the nearest selectable.
1313+// const sel = hit.path.find(n => n.type === "selectable");
1414+// if (sel) /* route the click to the selectable */;
1515+// }
1616+1717+import type { UINode, Rect } from "./nodes.ts";
1818+1919+export interface HitResult {
2020+ /** The deepest node containing the point. */
2121+ node: UINode;
2222+ /** Root-first chain of nodes containing the point. Last element === node. */
2323+ path: UINode[];
2424+}
2525+2626+function rectContains(rect: Rect, x: number, y: number): boolean {
2727+ return x >= rect.x && x < rect.x + rect.width
2828+ && y >= rect.y && y < rect.y + rect.height;
2929+}
3030+3131+/** Children that contain other UINodes and therefore participate in
3232+ * hit-testing. Leaf nodes (text, spacer, etc.) have no children. */
3333+function childrenOf(node: UINode): UINode[] {
3434+ switch (node.type) {
3535+ case "row": return node.children;
3636+ case "column": return node.children;
3737+ case "hstack": return node.children as unknown as UINode[];
3838+ case "panel": return node.children;
3939+ case "scrollable":
4040+ case "selectable":
4141+ // Items are arrays of arrays; flatten one level so walkers see every
4242+ // rendered child row.
4343+ return node.items.flat();
4444+ default: return [];
4545+ }
4646+}
4747+4848+/** Find the deepest node whose _rect contains (x, y). Returns null if no
4949+ * node covers the point (e.g. click outside the rendered area). */
5050+export function hitTest(roots: UINode[], x: number, y: number): HitResult | null {
5151+ const path: UINode[] = [];
5252+ for (const root of roots) {
5353+ if (!root._rect || !rectContains(root._rect, x, y)) continue;
5454+ path.push(root);
5555+ descend(root, x, y, path);
5656+ return { node: path[path.length - 1], path };
5757+ }
5858+ return null;
5959+}
6060+6161+function descend(node: UINode, x: number, y: number, path: UINode[]): void {
6262+ const kids = childrenOf(node);
6363+ for (const child of kids) {
6464+ if (!child._rect || !rectContains(child._rect, x, y)) continue;
6565+ path.push(child);
6666+ descend(child, x, y, path);
6767+ return; // Only one child per depth level should contain the point.
6868+ }
6969+}
7070+7171+/** Convenience: find the nearest ancestor of a given type in a hit path.
7272+ * Useful when you want "whichever scrollable/selectable/tabs the user
7373+ * clicked inside, not the deepest text node." */
7474+export function findInPath<T extends UINode["type"]>(
7575+ hit: HitResult,
7676+ type: T,
7777+): Extract<UINode, { type: T }> | null {
7878+ for (let i = hit.path.length - 1; i >= 0; i--) {
7979+ if (hit.path[i].type === type) return hit.path[i] as Extract<UINode, { type: T }>;
8080+ }
8181+ return null;
8282+}
+15-1
src/tui/index.ts
···11// Public API for the declarative TUI framework
2233// Input
44-export { parseKey, type KeyEvent } from "./input.ts";
44+export {
55+ parseKey, parseInput, isMouseEvent,
66+ MOUSE_ENABLE_SGR, MOUSE_DISABLE_SGR,
77+ type KeyEvent, type MouseEvent, type MouseButton, type MouseAction,
88+ type InputEvent,
99+} from "./input.ts";
510611// Signals
712export {
···8085// Screen wrapper
8186export { screen, overlay, type DeclarativeScreenConfig, type OverlayConfig } from "./screen.ts";
82878888+// Hit-testing (for mouse handlers)
8989+export { hitTest, findInPath, type HitResult } from "./hit-test.ts";
9090+9191+// Focus manager — stack-based key/mouse routing for panes, overlays, nested widgets.
9292+export { createFocusManager, type FocusManager, type FocusScope } from "./focus.ts";
9393+8394// Animation
8495export {
8596 spinnerChar, startSpinnerTimer, stopSpinnerTimer, isSpinnerRunning,
···9210393104// App lifecycle
94105export { app, type AppConfig, type App } from "./app.ts";
106106+107107+// Widgets — higher-level components built on the core primitives.
108108+export * from "./widgets/index.ts";
9510996110// Session management
97111export {
+117-2
src/tui/input.ts
···11-// Raw stdin keypress parsing
11+// Raw stdin input parsing — keyboard + mouse.
2233export interface KeyEvent {
44+ /** Discriminator for InputEvent union. Implicit on the existing API so
55+ * pre-mouse consumers keep working — the default is "key" when absent. */
66+ kind?: "key";
47 name: string;
58 char?: string;
69 ctrl: boolean;
···811 shift: boolean;
912}
10131414+export type MouseButton = "left" | "middle" | "right" | "none";
1515+export type MouseAction = "press" | "release" | "drag" | "move" | "scrollUp" | "scrollDown";
1616+1717+export interface MouseEvent {
1818+ kind: "mouse";
1919+ action: MouseAction;
2020+ button: MouseButton;
2121+ /** 0-based column of the cell the pointer is over. */
2222+ x: number;
2323+ /** 0-based row. */
2424+ y: number;
2525+ ctrl: boolean;
2626+ alt: boolean;
2727+ shift: boolean;
2828+}
2929+3030+export type InputEvent = KeyEvent | MouseEvent;
3131+3232+/** Type guard: narrows an InputEvent to a MouseEvent. */
3333+export function isMouseEvent(e: InputEvent): e is MouseEvent {
3434+ return (e as MouseEvent).kind === "mouse";
3535+}
3636+3737+/** ANSI sequences to enable / disable SGR mouse reporting. Use these when
3838+ * your app wants to receive mouse events. `parseInput` knows how to
3939+ * decode them once enabled. */
4040+export const MOUSE_ENABLE_SGR = "\x1b[?1002h\x1b[?1006h";
4141+export const MOUSE_DISABLE_SGR = "\x1b[?1006l\x1b[?1002l";
4242+4343+function decodeMouse(
4444+ buttonCode: number, x: number, y: number, isRelease: boolean,
4545+): MouseEvent | null {
4646+ // SGR mouse button-code encoding:
4747+ // low 2 bits: button (0=left, 1=middle, 2=right, 3=none for motion)
4848+ // bit 2 (0x04): shift
4949+ // bit 3 (0x08): alt
5050+ // bit 4 (0x10): ctrl
5151+ // bit 5 (0x20): motion flag (drag when a button is down)
5252+ // bit 6 (0x40): wheel (64 = scroll-up, 65 = scroll-down)
5353+ const shift = (buttonCode & 0x04) !== 0;
5454+ const alt = (buttonCode & 0x08) !== 0;
5555+ const ctrl = (buttonCode & 0x10) !== 0;
5656+ const motion = (buttonCode & 0x20) !== 0;
5757+ const wheel = (buttonCode & 0x40) !== 0;
5858+ const low = buttonCode & 0x03;
5959+6060+ const common = { x: Math.max(0, x - 1), y: Math.max(0, y - 1), ctrl, alt, shift };
6161+6262+ if (wheel) {
6363+ return {
6464+ kind: "mouse",
6565+ action: low === 0 ? "scrollUp" : "scrollDown",
6666+ button: "none",
6767+ ...common,
6868+ };
6969+ }
7070+7171+ const button: MouseButton =
7272+ low === 0 ? "left" : low === 1 ? "middle" : low === 2 ? "right" : "none";
7373+7474+ if (isRelease) {
7575+ return { kind: "mouse", action: "release", button, ...common };
7676+ }
7777+ if (motion) {
7878+ return { kind: "mouse", action: button === "none" ? "move" : "drag", button, ...common };
7979+ }
8080+ return { kind: "mouse", action: "press", button, ...common };
8181+}
8282+8383+/** Legacy entry point — keyboard only. Existing consumers keep using this;
8484+ * new code that wants mouse should call `parseInput` instead. */
1185export function parseKey(data: Buffer): KeyEvent[] {
1212- const events: KeyEvent[] = [];
8686+ return parseInput(data).filter((e): e is KeyEvent => !isMouseEvent(e));
8787+}
8888+8989+/** Parse a stdin chunk into an ordered list of keyboard + mouse events. */
9090+export function parseInput(data: Buffer): InputEvent[] {
9191+ const events: InputEvent[] = [];
1392 const str = data.toString("utf8");
1493 let i = 0;
1594···2099 if (i + 1 < str.length && str[i + 1] === "[") {
21100 const rest = str.slice(i + 2);
22101102102+ // SGR mouse: ESC[<b;x;y;(M|m)
103103+ // Recognised BEFORE the generic-arrow branches because the leading
104104+ // `<` disambiguates unambiguously and any other CSI starts with a
105105+ // parameter or a letter (never `<`).
106106+ if (rest[0] === "<") {
107107+ const mouseMatch = rest.match(/^<(\d+);(\d+);(\d+)([Mm])/);
108108+ if (mouseMatch) {
109109+ const b = parseInt(mouseMatch[1], 10);
110110+ const x = parseInt(mouseMatch[2], 10);
111111+ const y = parseInt(mouseMatch[3], 10);
112112+ const release = mouseMatch[4] === "m";
113113+ const ev = decodeMouse(b, x, y, release);
114114+ if (ev) events.push(ev);
115115+ i += 2 + mouseMatch[0].length;
116116+ continue;
117117+ }
118118+ }
119119+23120 // Arrow keys, home, end
24121 if (rest[0] === "A") { events.push({ name: "up", ctrl: false, alt: false, shift: false }); i += 3; continue; }
25122 if (rest[0] === "B") { events.push({ name: "down", ctrl: false, alt: false, shift: false }); i += 3; continue; }
···27124 if (rest[0] === "D") { events.push({ name: "left", ctrl: false, alt: false, shift: false }); i += 3; continue; }
28125 if (rest[0] === "H") { events.push({ name: "home", ctrl: false, alt: false, shift: false }); i += 3; continue; }
29126 if (rest[0] === "F") { events.push({ name: "end", ctrl: false, alt: false, shift: false }); i += 3; continue; }
127127+128128+ // Arrows with modifiers: ESC[1;<mods><letter>.
129129+ // mods - 1 is a bitmask: bit0=shift, bit1=alt, bit2=ctrl (same scheme
130130+ // as the kitty keyboard protocol). Terminal.app and kitty both emit
131131+ // this form for option+arrow (alt) and shift+arrow.
132132+ const modArrow = rest.match(/^1;(\d+)([ABCDHF])/);
133133+ if (modArrow) {
134134+ const mods = parseInt(modArrow[1], 10) - 1;
135135+ const shift = (mods & 0x01) !== 0;
136136+ const alt = (mods & 0x02) !== 0;
137137+ const ctrl = (mods & 0x04) !== 0;
138138+ const letterToName: Record<string, string> = {
139139+ A: "up", B: "down", C: "right", D: "left", H: "home", F: "end",
140140+ };
141141+ events.push({ name: letterToName[modArrow[2]], ctrl, alt, shift });
142142+ i += 2 + modArrow[0].length;
143143+ continue;
144144+ }
3014531146 // Shift+Tab (legacy xterm encoding): ESC[Z
32147 if (rest[0] === "Z") { events.push({ name: "backtab", ctrl: false, alt: false, shift: true }); i += 3; continue; }
+9-6
src/tui/interactive.ts
···375375 ];
376376 },
377377378378- handleKey(key: KeyEvent, _ctx: ScreenContext): boolean {
378378+ handleKey(key: KeyEvent, ctx: ScreenContext): boolean {
379379 const total = totalItems.get();
380380 const idx = selectedIndex.peek();
381381 const maxIndex = total - 1;
···437437 batch(() => { filterText.set(""); selectedIndex.set(0); });
438438 return true;
439439 }
440440- return false; // quit
440440+ ctx.quit();
441441+ return true;
441442 }
442443 if (key.char === "q" && !key.ctrl && !key.alt && !filterText.peek()) {
443443- return false; // quit
444444+ ctx.quit();
445445+ return true;
444446 }
445445- if (key.name === "c" && key.ctrl) {
446446- return false; // quit
447447- }
447447+ // ctrl+c is now handled globally by app(); leave the explicit check off
448448+ // here so the global default fires. If someone binds it in a context
449449+ // that should NOT quit (e.g. a composer with double-ctrl-c semantics),
450450+ // they intercept via AppConfig.onKey.
448451 if (key.name === "backspace") {
449452 if (filterText.peek().length > 0) {
450453 batch(() => { filterText.set(filterText.peek().slice(0, -1)); selectedIndex.set(0); });
+9
src/tui/nodes.ts
···3737 type: "text";
3838 text: string;
3939 color?: Color;
4040+ /** Explicit background color. Default is the ambient panel/screen bg. */
4141+ background?: Color;
4042 bold?: boolean;
4143 dim?: boolean;
4244 italic?: boolean;
4545+ /** Swap fg/bg for the rendered cells. Used for text cursors (paint the
4646+ * character UNDER the cursor with colors swapped, so the glyph doesn't
4747+ * push neighbors sideways). */
4848+ inverse?: boolean;
4349 truncate?: boolean;
4450 /** Soft-wrap text to fit the available width. Expands height as needed. */
4551 wrap?: boolean;
···130136export interface PanelNode {
131137 type: "panel";
132138 title: string;
139139+ /** Optional caption rendered on the bottom border (like mactop's
140140+ * "4/17 layout (skyblue)" strip). Same style as the top title. */
141141+ footerTitle?: string;
133142 children: UINode[];
134143 style?: BoxStyle;
135144 _rect?: Rect;
+44-9
src/tui/screen.ts
···4343 id: string;
4444 render: (ctx: ScreenContext) => UINode[];
4545 handleKey?: (key: KeyEvent, ctx: ScreenContext) => boolean;
4646+ handleMouse?: (event: import("./input.ts").MouseEvent, ctx: ScreenContext) => boolean;
4647 onEnter?: (ctx: ScreenContext) => void;
4748 onLeave?: (ctx: ScreenContext) => void;
4849 /**
···121122 return config.handleKey?.(key, ctx) ?? true;
122123 },
123124125125+ handleMouse(event, ctx) {
126126+ return config.handleMouse?.(event, ctx) ?? false;
127127+ },
128128+124129 onEnter(ctx: ScreenContext): void {
125130 config.onEnter?.(ctx);
126131 if (config.tick && !tickTimer) {
···151156 height: number | ((rows: number) => number);
152157 render: (ctx: ScreenContext) => UINode[];
153158 handleKey?: (key: KeyEvent, ctx: ScreenContext) => boolean;
159159+ handleMouse?: (event: import("./input.ts").MouseEvent, ctx: ScreenContext) => boolean;
154160 onEnter?: (ctx: ScreenContext) => void;
155161 onLeave?: (ctx: ScreenContext) => void;
156162}
···250256 return config.handleKey?.(key, ctx) ?? true;
251257 },
252258259259+ handleMouse(event, ctx) {
260260+ return config.handleMouse?.(event, ctx) ?? false;
261261+ },
262262+253263 onEnter(ctx: ScreenContext): void {
254264 config.onEnter?.(ctx);
255265 },
···427437): void {
428438 if (row < 0 || row >= buf.rows) return;
429439 const b = boxChars(style);
430430- if (col >= 0 && col < buf.cols) buf.cells[row][col] = makeCell(b.lj, fgc, bgc);
440440+ // Preserve the existing background at each cell. Separators live inside
441441+ // panels; if we zero the bg to null here the row renders with the
442442+ // terminal's default background, showing through as a grey band.
443443+ const bgAt = (c: number): [number, number, number] | null =>
444444+ bgc ?? buf.cells[row][c]?.bg ?? null;
445445+ if (col >= 0 && col < buf.cols) buf.cells[row][col] = makeCell(b.lj, fgc, bgAt(col));
431446 for (let c = col + 1; c < col + width - 1; c++) {
432432- if (c >= 0 && c < buf.cols) buf.cells[row][c] = makeCell(b.h, fgc, bgc);
447447+ if (c >= 0 && c < buf.cols) buf.cells[row][c] = makeCell(b.h, fgc, bgAt(c));
433448 }
434434- if (col + width - 1 >= 0 && col + width - 1 < buf.cols) buf.cells[row][col + width - 1] = makeCell(b.rj, fgc, bgc);
449449+ const last = col + width - 1;
450450+ if (last >= 0 && last < buf.cols) buf.cells[row][last] = makeCell(b.rj, fgc, bgAt(last));
435451}
436452437453// --- Main tree-to-buffer renderer ---
···461477462478 switch (node.type) {
463479 case "text": {
464464- const defaultColor = resolveColor(node.color, theme);
480480+ let fgColor = resolveColor(node.color, theme);
481481+ let bgColor = resolveColor(node.background, theme);
482482+ // inverse: swap fg/bg. If bg wasn't set, fall back to a sensible
483483+ // default so the cell is visibly "highlighted" — use the ambient
484484+ // foreground as the new bg, and the ambient bg as the new fg.
485485+ if (node.inverse) {
486486+ const fallbackBg = fgColor ?? theme.fg1;
487487+ const fallbackFg = bgColor ?? theme.bg1;
488488+ fgColor = fallbackFg;
489489+ bgColor = fallbackBg;
490490+ }
465491 const defaultBold = node.bold ?? false;
466492 const defaultDim = node.dim ?? false;
467493 const defaultItalic = node.italic ?? false;
···478504 const lineY = rect.y + i;
479505 if (lineY >= buf.rows) break;
480506 if (spans) {
481481- writeSpannedBuf(buf, lineY, rect.x, lines[i], offsets[i], spans, defaultColor, defaultBold, defaultDim, defaultItalic, theme, rect.width);
507507+ writeSpannedBuf(buf, lineY, rect.x, lines[i], offsets[i], spans, fgColor, defaultBold, defaultDim, defaultItalic, theme, rect.width);
482508 } else {
483483- writeStringBuf(buf, lineY, rect.x, lines[i], defaultColor, null, defaultBold, defaultDim, defaultItalic);
509509+ writeStringBuf(buf, lineY, rect.x, lines[i], fgColor, bgColor, defaultBold, defaultDim, defaultItalic);
484510 }
485511 }
486512 } else {
···502528 }
503529 }
504530 if (spans) {
505505- writeSpannedBuf(buf, rect.y, rect.x, displayContent, 0, spans, defaultColor, defaultBold, defaultDim, defaultItalic, theme, rect.width);
531531+ writeSpannedBuf(buf, rect.y, rect.x, displayContent, 0, spans, fgColor, defaultBold, defaultDim, defaultItalic, theme, rect.width);
506532 } else {
507507- writeStringBuf(buf, rect.y, rect.x, displayContent, defaultColor, null, defaultBold, defaultDim, defaultItalic);
533533+ writeStringBuf(buf, rect.y, rect.x, displayContent, fgColor, bgColor, defaultBold, defaultDim, defaultItalic);
508534 }
509535 }
510536 break;
···561587 fillBufRect(buf, rect.y, rect.x, rect.width, rect.height, null, theme.bg2);
562588 // Draw border
563589 drawBoxBuf(buf, rect.y, rect.x, rect.width, rect.height, style, theme.border, theme.bg2);
564564- // Title
590590+ // Top title
565591 if (node.title) {
566592 writeStringBuf(buf, rect.y, rect.x + 2, " ", theme.border, theme.bg2);
567593 writeStringBuf(buf, rect.y, rect.x + 3, node.title, theme.fgAc, theme.bg2, true);
568594 const titleEnd = rect.x + 3 + textWidth(node.title);
569595 writeStringBuf(buf, rect.y, titleEnd, " ", theme.border, theme.bg2);
596596+ }
597597+ // Bottom caption (optional) — rendered on the bottom border with the
598598+ // same chrome as the top title. Left-aligned to mirror the top.
599599+ if (node.footerTitle) {
600600+ const by = rect.y + rect.height - 1;
601601+ writeStringBuf(buf, by, rect.x + 2, " ", theme.border, theme.bg2);
602602+ writeStringBuf(buf, by, rect.x + 3, node.footerTitle, theme.fgAc, theme.bg2, true);
603603+ const capEnd = rect.x + 3 + textWidth(node.footerTitle);
604604+ writeStringBuf(buf, by, capEnd, " ", theme.border, theme.bg2);
570605 }
571606 // Render children
572607 for (const child of node.children) {
+13
src/tui/types.ts
···4949 closeOverlay: () => void;
5050 isTextInputActive: () => boolean;
5151 setTextInputActive: (active: boolean) => void;
5252+ /** Explicit app-exit hook. Screens that want to quit (on q, ctrl+c,
5353+ * a menu action, etc.) call this instead of returning false from
5454+ * handleKey — returning false used to quit the app and was a footgun.
5555+ * Safe to call from anywhere; tears down the app and exits the process. */
5656+ quit: () => void;
5757+ /** Stack-based focus manager for routing keys/mouse to nested scopes.
5858+ * Provided by `app()` automatically — every screen has access to the
5959+ * same instance. Screens opt into it by calling `ctx.focus.dispatchKey`
6060+ * from their `handleKey` (or ignore it entirely). */
6161+ focus: import("./focus.ts").FocusManager;
5262 [key: string]: any;
5363}
5464···5868 render(ctx: ScreenContext): string;
5969 renderToBuffer(ctx: ScreenContext): import("./buffer.ts").CellBuffer;
6070 handleKey(key: KeyEvent, ctx: ScreenContext): boolean;
7171+ /** Optional mouse event handler. Only fires when AppConfig.mouse is on.
7272+ * Return value is a hint for parent routing; app() ignores it. */
7373+ handleMouse?(event: import("./input.ts").MouseEvent, ctx: ScreenContext): boolean;
6174 onEnter?(ctx: ScreenContext): void;
6275 onLeave?(ctx: ScreenContext): void;
6376}
+109
src/tui/widgets/bar-chart.ts
···11+// Bar chart — vertical-bar histogram rendered with unicode block characters.
22+// Good for "CPU per core", power-draw over time, any small-cardinality
33+// histogram. For a high-cardinality scrolling chart use `sparkline` instead.
44+//
55+// Each bar is `barWidth` columns wide (default 2) with a one-column gap
66+// between bars (default). The bar height is encoded with the same 8-step
77+// block scale as sparkline, but here we use `height` rows per bar instead
88+// of a single row.
99+1010+import { canvas } from "../builders.ts";
1111+import type { UINode, Color } from "../nodes.ts";
1212+1313+const BLOCKS = [" ", "\u2581", "\u2582", "\u2583", "\u2584",
1414+ "\u2585", "\u2586", "\u2587", "\u2588"];
1515+1616+export interface BarChartItem {
1717+ label?: string;
1818+ value: number;
1919+ /** Per-bar color override. Falls back to `opts.color`. */
2020+ color?: Color;
2121+}
2222+2323+export interface BarChartOptions {
2424+ /** Total canvas height in rows (excluding label row). Default 6. */
2525+ height?: number;
2626+ /** Width of each bar in columns. Default 2. */
2727+ barWidth?: number;
2828+ /** Gap between bars in columns. Default 1. */
2929+ gap?: number;
3030+ /** Lower bound of the value axis. Default min(values). */
3131+ min?: number;
3232+ /** Upper bound of the value axis. Default max(values). */
3333+ max?: number;
3434+ /** Default bar color. Default "accent". */
3535+ color?: Color;
3636+ /** Render short labels under each bar (first char of `item.label`). */
3737+ showLabels?: boolean;
3838+ /** Color for labels. Default "muted". */
3939+ labelColor?: Color;
4040+}
4141+4242+/** Render a bar chart as a Canvas node. The caller places it inside a
4343+ * row/column/panel like any other UINode. */
4444+export function barChart(items: readonly BarChartItem[], opts: BarChartOptions = {}): UINode {
4545+ const height = Math.max(2, Math.floor(opts.height ?? 6));
4646+ const barWidth = Math.max(1, Math.floor(opts.barWidth ?? 2));
4747+ const gap = Math.max(0, Math.floor(opts.gap ?? 1));
4848+ const showLabels = opts.showLabels ?? false;
4949+ const labelColor = opts.labelColor ?? "muted";
5050+ const baseColor = opts.color ?? "accent";
5151+5252+ const values = items.map(it => it.value);
5353+ let lo = opts.min;
5454+ let hi = opts.max;
5555+ if (lo == null) {
5656+ lo = values.length ? Math.min(...values.filter(Number.isFinite)) : 0;
5757+ if (!Number.isFinite(lo)) lo = 0;
5858+ }
5959+ if (hi == null) {
6060+ hi = values.length ? Math.max(...values.filter(Number.isFinite)) : 1;
6161+ if (!Number.isFinite(hi)) hi = 1;
6262+ }
6363+ const range = hi - lo;
6464+6565+ const rowsForBars = height;
6666+ const totalRows = showLabels ? rowsForBars + 1 : rowsForBars;
6767+6868+ return canvas((ctx) => {
6969+ // Draw each bar column.
7070+ items.forEach((item, barIdx) => {
7171+ const v = Number.isFinite(item.value) ? item.value : lo!;
7272+ const fracTotal = range <= 0 ? 0.5 : (v - lo!) / range;
7373+ const clamped = Math.max(0, Math.min(1, fracTotal));
7474+ // Encode the total height in 1/8ths per row. Rows fill from the
7575+ // bottom up, then the top row uses a partial block.
7676+ const totalEighths = Math.round(clamped * rowsForBars * 8);
7777+ const fullRows = Math.floor(totalEighths / 8);
7878+ const topRemainder = totalEighths % 8;
7979+8080+ const barColor = item.color ?? baseColor;
8181+ const leftCol = barIdx * (barWidth + gap);
8282+8383+ // Full rows (from the bottom).
8484+ for (let r = 0; r < fullRows; r++) {
8585+ const y = rowsForBars - 1 - r;
8686+ for (let c = 0; c < barWidth; c++) {
8787+ ctx.write(leftCol + c, y, BLOCKS[8], barColor);
8888+ }
8989+ }
9090+ // Partial top row.
9191+ if (topRemainder > 0) {
9292+ const y = rowsForBars - 1 - fullRows;
9393+ if (y >= 0) {
9494+ for (let c = 0; c < barWidth; c++) {
9595+ ctx.write(leftCol + c, y, BLOCKS[topRemainder], barColor);
9696+ }
9797+ }
9898+ }
9999+100100+ // Label row.
101101+ if (showLabels) {
102102+ const labelStr = (item.label ?? "").slice(0, barWidth);
103103+ if (labelStr.length > 0) {
104104+ ctx.write(leftCol, rowsForBars, labelStr, labelColor);
105105+ }
106106+ }
107107+ });
108108+ }, { height: totalRows });
109109+}
+135
src/tui/widgets/command-palette.ts
···11+// Command palette — a fuzzy-matched action runner overlay. A classic
22+// "ctrl+p" / "ctrl+k" pattern: type → filter → enter to run.
33+//
44+// The consumer owns a `signal<CommandPaletteState | null>` (null when
55+// the palette is closed). When opening, register your commands and pass
66+// them through on every render + key dispatch. State is pure.
77+88+import { row, column, text, panel } from "../builders.ts";
99+import type { UINode, ColumnNode } from "../nodes.ts";
1010+import type { KeyEvent } from "../input.ts";
1111+import { fuzzyMatch } from "../fuzzy.ts";
1212+import { applyTextKey, type TextFieldState } from "./form.ts";
1313+1414+export interface Command {
1515+ id: string;
1616+ label: string;
1717+ /** Optional one-liner shown dim next to the label. */
1818+ hint?: string;
1919+ /** Arbitrary keywords mixed into the fuzzy match (tags, shortcuts). */
2020+ keywords?: string[];
2121+ /** Invoked when the user selects this command. Up to the consumer to
2222+ * define — a command is just a row with an id + a callback. */
2323+ run: () => void;
2424+}
2525+2626+export interface CommandPaletteState {
2727+ query: TextFieldState;
2828+ selectedIndex: number;
2929+}
3030+3131+export function createCommandPaletteState(): CommandPaletteState {
3232+ return { query: { text: "", cursor: 0 }, selectedIndex: 0 };
3333+}
3434+3535+export interface RankedCommand {
3636+ cmd: Command;
3737+ score: number;
3838+}
3939+4040+/** Filter + rank commands by fuzzy match. Empty query returns all commands
4141+ * in their original order. */
4242+export function filterCommands(
4343+ commands: readonly Command[],
4444+ query: string,
4545+): RankedCommand[] {
4646+ const q = query.trim();
4747+ if (q.length === 0) return commands.map(c => ({ cmd: c, score: 0 }));
4848+ const ranked: RankedCommand[] = [];
4949+ for (const cmd of commands) {
5050+ const hay = [cmd.label, cmd.hint ?? "", ...(cmd.keywords ?? [])].join(" ");
5151+ const m = fuzzyMatch(q, hay);
5252+ if (m.match) ranked.push({ cmd, score: m.score });
5353+ }
5454+ ranked.sort((a, b) => b.score - a.score);
5555+ return ranked;
5656+}
5757+5858+export interface HandleCommandPaletteResult {
5959+ state: CommandPaletteState;
6060+ action: "run" | "cancel" | "edited" | "moved" | "none";
6161+ /** Present when action === "run". */
6262+ command?: Command;
6363+}
6464+6565+export function handleCommandPaletteKey(
6666+ state: CommandPaletteState,
6767+ commands: readonly Command[],
6868+ key: KeyEvent,
6969+): HandleCommandPaletteResult {
7070+ if (key.name === "escape") {
7171+ return { state, action: "cancel" };
7272+ }
7373+7474+ const ranked = filterCommands(commands, state.query.text);
7575+7676+ if (key.name === "up") {
7777+ const next = Math.max(0, state.selectedIndex - 1);
7878+ return { state: { ...state, selectedIndex: next }, action: "moved" };
7979+ }
8080+ if (key.name === "down") {
8181+ const next = Math.min(Math.max(0, ranked.length - 1), state.selectedIndex + 1);
8282+ return { state: { ...state, selectedIndex: next }, action: "moved" };
8383+ }
8484+ if (key.name === "return") {
8585+ const picked = ranked[state.selectedIndex];
8686+ if (picked) return { state, action: "run", command: picked.cmd };
8787+ return { state, action: "none" };
8888+ }
8989+9090+ const updatedQuery = applyTextKey(state.query, key);
9191+ if (updatedQuery) {
9292+ return {
9393+ state: { query: updatedQuery, selectedIndex: 0 },
9494+ action: "edited",
9595+ };
9696+ }
9797+ return { state, action: "none" };
9898+}
9999+100100+/** Render the palette as an overlay panel with a query line + up to `limit`
101101+ * matching commands. The selected row is highlighted. */
102102+export function renderCommandPalette(
103103+ state: CommandPaletteState,
104104+ commands: readonly Command[],
105105+ opts: { title?: string; limit?: number } = {},
106106+): UINode {
107107+ const ranked = filterCommands(commands, state.query.text);
108108+ const limit = opts.limit ?? 10;
109109+ const visible = ranked.slice(0, limit);
110110+111111+ const queryLine = row(
112112+ text(" > ", "accent", { bold: true }),
113113+ text(state.query.text || "", "primary"),
114114+ text("\u2588", "accent"),
115115+ );
116116+117117+ const rows: UINode[] = [queryLine];
118118+ if (visible.length === 0) {
119119+ rows.push(row(text(" no matches", "muted", { dim: true })));
120120+ } else {
121121+ visible.forEach((r, i) => {
122122+ const selected = i === state.selectedIndex;
123123+ const prefix = selected ? text(" \u25b8 ", "accent", { bold: true })
124124+ : text(" ", "muted");
125125+ const label = text(r.cmd.label, selected ? "accent" : "primary", { bold: selected });
126126+ const children: UINode[] = [prefix, label];
127127+ if (r.cmd.hint) {
128128+ children.push(text(` ${r.cmd.hint}`, "muted", { dim: true }));
129129+ }
130130+ rows.push(row(...children));
131131+ });
132132+ }
133133+134134+ return panel(opts.title ?? "command palette", rows);
135135+}
+113
src/tui/widgets/command-registry.ts
···11+// Global, signal-backed command registry.
22+//
33+// The bare `command-palette.ts` takes `Command[]` at render time — fine for
44+// small apps, awkward for anything where commands should be "contributed"
55+// from all corners of the codebase. This registry fixes that.
66+//
77+// Two entry points:
88+//
99+// registerGlobalCommand(cmd) -> dispose
1010+// Registers a command for the lifetime of the app. Returns a disposer
1111+// that removes it.
1212+//
1313+// useCommandScope(scopeId, commands) -> dispose
1414+// Registers commands under a named scope. Use this inside a screen's
1515+// mount/unmount lifecycle to surface commands only while the screen is
1616+// active. The common contextual use-case is binding to a focused item:
1717+// "useCommandScope(`focused:${id}`, [{ ... }])".
1818+//
1919+// `allCommands` is a computed signal that flattens everything currently
2020+// registered. The `command-palette.ts` widget already takes a `Command[]`
2121+// array — just pass `allCommands.get()` to get all live commands.
2222+2323+import { signal, computed, type Signal, type Computed } from "../signals.ts";
2424+import type { Command } from "./command-palette.ts";
2525+2626+/** Internal: rev-counter that bumps on every change so `allCommands`
2727+ * recomputes. We store commands in a Map keyed by scopeId; the Map itself
2828+ * is the state, the rev signal is how we propagate through the reactive
2929+ * graph without allocating a new Map on every registration. */
3030+const rev = signal(0);
3131+const scopes = new Map<string, Command[]>();
3232+const GLOBAL = "__global__";
3333+3434+function touch(): void {
3535+ rev.set(rev.peek() + 1);
3636+}
3737+3838+/** Register a single command globally. Returns a disposer. */
3939+export function registerGlobalCommand(cmd: Command): () => void {
4040+ const existing = scopes.get(GLOBAL) ?? [];
4141+ scopes.set(GLOBAL, [...existing, cmd]);
4242+ touch();
4343+ return () => {
4444+ const list = scopes.get(GLOBAL);
4545+ if (!list) return;
4646+ const next = list.filter(c => c.id !== cmd.id);
4747+ if (next.length === 0) scopes.delete(GLOBAL);
4848+ else scopes.set(GLOBAL, next);
4949+ touch();
5050+ };
5151+}
5252+5353+/** Register a batch of commands under `scopeId`. Calling again with the
5454+ * same `scopeId` REPLACES the previous batch — convenient for contextual
5555+ * ("focused thing") commands that change with selection. Returns a
5656+ * disposer that removes the whole scope. */
5757+export function useCommandScope(scopeId: string, commands: Command[]): () => void {
5858+ if (scopeId === GLOBAL) {
5959+ throw new Error(`scope id "${GLOBAL}" is reserved`);
6060+ }
6161+ if (commands.length === 0) {
6262+ scopes.delete(scopeId);
6363+ } else {
6464+ scopes.set(scopeId, commands);
6565+ }
6666+ touch();
6767+ return () => {
6868+ if (scopes.delete(scopeId)) touch();
6969+ };
7070+}
7171+7272+/** Remove every command under `scopeId` (no-op if unknown). Useful when
7373+ * you want to clear a scope without holding a disposer. */
7474+export function clearCommandScope(scopeId: string): void {
7575+ if (scopes.delete(scopeId)) touch();
7676+}
7777+7878+/** Look up a command by id across all scopes. Returns the first match. */
7979+export function findCommand(id: string): Command | undefined {
8080+ for (const list of scopes.values()) {
8181+ const m = list.find(c => c.id === id);
8282+ if (m) return m;
8383+ }
8484+ return undefined;
8585+}
8686+8787+/** Reactive view of every registered command, flattened. Scope order is
8888+ * insertion-order of the underlying Map. Global commands stay at the
8989+ * front because they're inserted with the sentinel key at registration. */
9090+export const allCommands: Computed<Command[]> = computed(() => {
9191+ // Touch the rev signal so this recomputes on every registration change.
9292+ rev.get();
9393+ const out: Command[] = [];
9494+ // Iterate insertion-ordered entries.
9595+ for (const list of scopes.values()) out.push(...list);
9696+ return out;
9797+});
9898+9999+/** Run a command by id. Silently does nothing if the id is not registered.
100100+ * Convenience for keybindings that trigger a named command without
101101+ * opening the palette. */
102102+export function runCommand(id: string): boolean {
103103+ const cmd = findCommand(id);
104104+ if (!cmd) return false;
105105+ cmd.run();
106106+ return true;
107107+}
108108+109109+/** For tests / debugging — wipes the entire registry. */
110110+export function _resetCommandRegistry(): void {
111111+ scopes.clear();
112112+ touch();
113113+}
+85
src/tui/widgets/confirm.ts
···11+// Confirm modal — convenience over `overlay()` for yes/no dialogs.
22+//
33+// Usage: own a `signal<ConfirmState | null>`. When you want to ask, set it
44+// to a fresh state via `createConfirm(...)`. Route its overlay through
55+// your app config's `overlay: () => ...`. On the `action: "yes" | "no"`
66+// from `handleConfirmKey`, clear the signal and run whatever the answer
77+// means in your app.
88+99+import { row, column, text, panel, separator } from "../builders.ts";
1010+import type { UINode } from "../nodes.ts";
1111+import type { KeyEvent } from "../input.ts";
1212+1313+export interface ConfirmState {
1414+ title: string;
1515+ message: string;
1616+ yesLabel: string;
1717+ noLabel: string;
1818+ focused: "yes" | "no";
1919+}
2020+2121+export interface CreateConfirmOptions {
2222+ title: string;
2323+ message: string;
2424+ yesLabel?: string;
2525+ noLabel?: string;
2626+ /** Which button starts focused. Default "no" — safer for destructive ops. */
2727+ defaultFocus?: "yes" | "no";
2828+}
2929+3030+export function createConfirm(opts: CreateConfirmOptions): ConfirmState {
3131+ return {
3232+ title: opts.title,
3333+ message: opts.message,
3434+ yesLabel: opts.yesLabel ?? "Yes",
3535+ noLabel: opts.noLabel ?? "No",
3636+ focused: opts.defaultFocus ?? "no",
3737+ };
3838+}
3939+4040+export interface HandleConfirmResult {
4141+ state: ConfirmState;
4242+ action: "yes" | "no" | "pending";
4343+}
4444+4545+/** Default bindings:
4646+ * - left / right / tab / backtab -> toggle focus
4747+ * - return -> commit the focused button
4848+ * - escape -> always "no"
4949+ * - y / n -> shortcut commit (case-insensitive) */
5050+export function handleConfirmKey(state: ConfirmState, key: KeyEvent): HandleConfirmResult {
5151+ if (key.name === "left" || key.name === "right" ||
5252+ key.name === "tab" || key.name === "backtab") {
5353+ return { state: { ...state, focused: state.focused === "yes" ? "no" : "yes" }, action: "pending" };
5454+ }
5555+ if (key.name === "return") {
5656+ return { state, action: state.focused };
5757+ }
5858+ if (key.name === "escape") {
5959+ return { state, action: "no" };
6060+ }
6161+ if (key.char === "y" || key.char === "Y") return { state, action: "yes" };
6262+ if (key.char === "n" || key.char === "N") return { state, action: "no" };
6363+ return { state, action: "pending" };
6464+}
6565+6666+/** Render the confirm dialog body — wrap in an `overlay()` screen. */
6767+export function confirmPanel(state: ConfirmState): UINode {
6868+ const y = state.focused === "yes"
6969+ ? text(` ${state.yesLabel} `, "accent", { bold: true })
7070+ : text(` ${state.yesLabel} `, "muted");
7171+ const n = state.focused === "no"
7272+ ? text(` ${state.noLabel} `, "accent", { bold: true })
7373+ : text(` ${state.noLabel} `, "muted");
7474+ return panel(state.title, [
7575+ row(text(state.message, "primary")),
7676+ separator(),
7777+ row(
7878+ text(" ", "muted"),
7979+ y,
8080+ text(" ", "muted"),
8181+ n,
8282+ ),
8383+ row(text(" y / n / enter / esc", "muted", { dim: true })),
8484+ ]);
8585+}
+150
src/tui/widgets/date-picker.ts
···11+// Date picker widget — calendar grid + time shift helpers + overlay factory.
22+//
33+// State-first: the consumer owns a `DatePickerState` signal and passes it in
44+// on every render. Key handling is a pure function that returns a new state
55+// (or null when the key wasn't consumed so the caller can escape/submit).
66+//
77+// Promoted from demos/reminders/tui/widgets/date-picker.ts with a cleaner API.
88+99+import { canvas, row, text, column, panel } from "../builders.ts";
1010+import type { UINode } from "../nodes.ts";
1111+import type { KeyEvent } from "../input.ts";
1212+1313+export interface DatePickerState {
1414+ year: number;
1515+ month: number; // 0-11
1616+ day: number; // 1-31
1717+ hour: number; // 0-23
1818+ minute: number; // 0-59
1919+}
2020+2121+const DAY_HEADERS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
2222+2323+export const MONTH_NAMES = [
2424+ "January", "February", "March", "April", "May", "June",
2525+ "July", "August", "September", "October", "November", "December",
2626+];
2727+2828+export function daysInMonth(year: number, month: number): number {
2929+ return new Date(year, month + 1, 0).getDate();
3030+}
3131+3232+/** Anchored "now" as a DatePickerState, useful as a default. */
3333+export function datePickerFromDate(d: Date): DatePickerState {
3434+ return {
3535+ year: d.getFullYear(),
3636+ month: d.getMonth(),
3737+ day: d.getDate(),
3838+ hour: d.getHours(),
3939+ minute: d.getMinutes(),
4040+ };
4141+}
4242+4343+/** Clamp the day into the range for the current year/month. */
4444+export function clampDay(state: DatePickerState): DatePickerState {
4545+ const max = daysInMonth(state.year, state.month);
4646+ if (state.day > max) return { ...state, day: max };
4747+ if (state.day < 1) return { ...state, day: 1 };
4848+ return state;
4949+}
5050+5151+export function shiftDay(state: DatePickerState, delta: number): DatePickerState {
5252+ const d = new Date(state.year, state.month, state.day);
5353+ d.setDate(d.getDate() + delta);
5454+ return {
5555+ ...state,
5656+ year: d.getFullYear(),
5757+ month: d.getMonth(),
5858+ day: d.getDate(),
5959+ };
6060+}
6161+6262+export function shiftMonth(state: DatePickerState, delta: number): DatePickerState {
6363+ const d = new Date(state.year, state.month + delta, 1);
6464+ return clampDay({
6565+ ...state,
6666+ year: d.getFullYear(),
6767+ month: d.getMonth(),
6868+ });
6969+}
7070+7171+export function shiftTime(state: DatePickerState, unit: "h" | "m", delta: number): DatePickerState {
7272+ if (unit === "h") {
7373+ return { ...state, hour: (state.hour + delta + 24) % 24 };
7474+ }
7575+ return { ...state, minute: (state.minute + delta + 60) % 60 };
7676+}
7777+7878+export function toDate(state: DatePickerState): Date {
7979+ return new Date(state.year, state.month, state.day, state.hour, state.minute, 0, 0);
8080+}
8181+8282+/** Default key bindings. Returns a new state when the key was consumed,
8383+ * or `null` when the caller should handle it (escape / enter to commit). */
8484+export function handleDatePickerKey(
8585+ state: DatePickerState,
8686+ key: KeyEvent,
8787+): DatePickerState | null {
8888+ switch (key.name) {
8989+ case "up": return shiftDay(state, -7);
9090+ case "down": return shiftDay(state, 7);
9191+ case "left": return shiftDay(state, -1);
9292+ case "right": return shiftDay(state, 1);
9393+ case "[": return shiftMonth(state, -1);
9494+ case "]": return shiftMonth(state, 1);
9595+ case "h": return shiftTime(state, "h", -1);
9696+ case "H": return shiftTime(state, "h", 1);
9797+ case "m": return shiftTime(state, "m", -5);
9898+ case "M": return shiftTime(state, "m", 5);
9999+ }
100100+ return null;
101101+}
102102+103103+/** Render a month grid as a Canvas node, selection highlighted. */
104104+export function calendarCanvas(state: DatePickerState): UINode {
105105+ const first = new Date(state.year, state.month, 1);
106106+ const firstDow = first.getDay();
107107+ const max = daysInMonth(state.year, state.month);
108108+109109+ // 6 week rows worst case + 1 header row = 7 rows.
110110+ return canvas((ctx) => {
111111+ const cellW = 4;
112112+ for (let d = 0; d < 7; d++) {
113113+ ctx.write(d * cellW, 0, DAY_HEADERS[d], "accent", undefined, true);
114114+ }
115115+ let rowIdx = 1;
116116+ let col = firstDow;
117117+ for (let day = 1; day <= max; day++) {
118118+ const x = col * cellW;
119119+ const y = rowIdx;
120120+ const isSelected = day === state.day;
121121+ if (isSelected) {
122122+ ctx.write(x, y, String(day).padStart(2), "accent", "accent", true);
123123+ } else {
124124+ ctx.write(x, y, String(day).padStart(2), "primary");
125125+ }
126126+ col++;
127127+ if (col > 6) { col = 0; rowIdx++; }
128128+ }
129129+ }, { height: 7 });
130130+}
131131+132132+/** Full overlay body: month/year header, calendar grid, time line, hints.
133133+ * Returns a UINode[] suitable for putting inside an `overlay()` screen. */
134134+export function datePickerBody(state: DatePickerState): UINode[] {
135135+ const pad2 = (n: number) => String(n).padStart(2, "0");
136136+ const timeStr = `${pad2(state.hour)}:${pad2(state.minute)}`;
137137+ const heading = `${MONTH_NAMES[state.month]} ${state.year}`;
138138+ return [
139139+ row(text(heading, "accent", { bold: true })),
140140+ calendarCanvas(state),
141141+ row(text(`time ${timeStr}`, "muted")),
142142+ row(text("\u2190\u2192\u2191\u2193 day [ ] month h/H hour m/M \u00b15 min enter ok esc cancel", "muted", { dim: true })),
143143+ ];
144144+}
145145+146146+/** Convenience: wrap the body in a panel — useful when embedding inside an
147147+ * app's own overlay render() rather than as a full-fledged screen. */
148148+export function datePickerPanel(state: DatePickerState, title = "pick a date"): UINode {
149149+ return panel(title, datePickerBody(state));
150150+}
+238
src/tui/widgets/form.ts
···11+// Form widget — multi-field focus management on top of simple text fields.
22+//
33+// State-first: you own a `FormState<T>` signal. Key handling is a pure
44+// function that delegates to `applyTextKey` for printable/edit keys and to
55+// a focus-ring walker for tab/backtab.
66+//
77+// Promoted from demos/reminders/tui/widgets/form.ts with:
88+// - Generic over a field-id enum or string union.
99+// - Dedicated backtab handling (pty's input parser now emits it).
1010+// - A `handleFormKey` umbrella that consumers can delegate to.
1111+1212+import type { KeyEvent } from "../input.ts";
1313+import { text as textBuilder } from "../builders.ts";
1414+1515+export interface TextFieldState {
1616+ text: string;
1717+ cursor: number;
1818+}
1919+2020+/** Word-class predicates: alphanumerics and underscores are "word" chars,
2121+ * everything else is "non-word". Matches `\w` in most regex flavours and
2222+ * feels right for typical CLI / prose input. */
2323+function isWordChar(ch: string): boolean {
2424+ return /[\p{L}\p{N}_]/u.test(ch);
2525+}
2626+2727+/** Find the index of the start of the previous word, searching backward
2828+ * from `pos`. Skips any trailing whitespace/punctuation first (so the
2929+ * first backward keystroke always feels like it did something). */
3030+export function prevWordBoundary(text: string, pos: number): number {
3131+ let i = pos;
3232+ // Skip non-word chars immediately behind the cursor.
3333+ while (i > 0 && !isWordChar(text[i - 1])) i--;
3434+ // Skip the word itself.
3535+ while (i > 0 && isWordChar(text[i - 1])) i--;
3636+ return i;
3737+}
3838+3939+/** Find the index one past the end of the next word, searching forward. */
4040+export function nextWordBoundary(text: string, pos: number): number {
4141+ let i = pos;
4242+ while (i < text.length && !isWordChar(text[i])) i++;
4343+ while (i < text.length && isWordChar(text[i])) i++;
4444+ return i;
4545+}
4646+4747+/** Handle a keystroke against a simple single-line text field.
4848+ * Returns a new state if the key was consumed, else `null` so the caller
4949+ * can dispatch it elsewhere (e.g. treat Enter as submit at the form level). */
5050+export function applyTextKey(state: TextFieldState, key: KeyEvent): TextFieldState | null {
5151+ if (key.name === "backspace") {
5252+ if (state.cursor === 0) return state;
5353+ return {
5454+ text: state.text.slice(0, state.cursor - 1) + state.text.slice(state.cursor),
5555+ cursor: state.cursor - 1,
5656+ };
5757+ }
5858+ if (key.name === "delete") {
5959+ if (state.cursor >= state.text.length) return state;
6060+ return {
6161+ text: state.text.slice(0, state.cursor) + state.text.slice(state.cursor + 1),
6262+ cursor: state.cursor,
6363+ };
6464+ }
6565+ if (key.name === "left") {
6666+ if (key.alt) return { ...state, cursor: prevWordBoundary(state.text, state.cursor) };
6767+ if (state.cursor === 0) return state;
6868+ return { ...state, cursor: state.cursor - 1 };
6969+ }
7070+ if (key.name === "right") {
7171+ if (key.alt) return { ...state, cursor: nextWordBoundary(state.text, state.cursor) };
7272+ if (state.cursor >= state.text.length) return state;
7373+ return { ...state, cursor: state.cursor + 1 };
7474+ }
7575+ // emacs-style alt+b / alt+f word motion, same semantics as alt+arrows.
7676+ if (key.alt && key.char === "b") {
7777+ return { ...state, cursor: prevWordBoundary(state.text, state.cursor) };
7878+ }
7979+ if (key.alt && key.char === "f") {
8080+ return { ...state, cursor: nextWordBoundary(state.text, state.cursor) };
8181+ }
8282+ if (key.name === "home" || (key.name === "a" && key.ctrl)) {
8383+ return { ...state, cursor: 0 };
8484+ }
8585+ if (key.name === "end" || (key.name === "e" && key.ctrl)) {
8686+ return { ...state, cursor: state.text.length };
8787+ }
8888+ if (key.name === "u" && key.ctrl) {
8989+ return { text: state.text.slice(state.cursor), cursor: 0 };
9090+ }
9191+ // Printable character — ignore ctrl/alt-modified keys so shortcuts don't
9292+ // leak as text into the field.
9393+ if (key.char && !key.ctrl && !key.alt) {
9494+ return {
9595+ text: state.text.slice(0, state.cursor) + key.char + state.text.slice(state.cursor),
9696+ cursor: state.cursor + key.char.length,
9797+ };
9898+ }
9999+ return null;
100100+}
101101+102102+/** Compose a field display string with a block cursor when the field is
103103+ * active. This is the legacy "insert a block character" API — it pushes
104104+ * neighbors sideways and is kept only for consumers that want a single
105105+ * string back. For proper cursor-on-top-of-character rendering, use
106106+ * `renderFieldNodes` which returns 3 TextNodes (before / inverse-cursor /
107107+ * after). */
108108+export function renderFieldText(text: string, cursor: number, active: boolean): string {
109109+ if (!active) return text || "";
110110+ const before = text.slice(0, cursor);
111111+ const after = text.slice(cursor);
112112+ return `${before}\u2588${after}`;
113113+}
114114+115115+/** Render a text field as three inline TextNodes (before, cursor, after)
116116+ * so the cursor paints ON TOP of the character at `cursor` instead of
117117+ * shoving it sideways. Pass through `row(...renderFieldNodes(...))`. */
118118+export function renderFieldNodes(
119119+ text: string,
120120+ cursor: number,
121121+ active: boolean,
122122+ opts: { color?: import("../nodes.ts").Color; bold?: boolean } = {},
123123+): import("../nodes.ts").UINode[] {
124124+ const color = opts.color ?? "primary";
125125+ const bold = opts.bold ?? false;
126126+ if (!active) {
127127+ return [textBuilder(text || "", color, { bold })];
128128+ }
129129+ const before = text.slice(0, cursor);
130130+ const under = text.slice(cursor, cursor + 1) || " ";
131131+ const after = text.slice(cursor + 1);
132132+ return [
133133+ textBuilder(before, color, { bold }),
134134+ textBuilder(under, color, { bold, inverse: true }),
135135+ textBuilder(after, color, { bold }),
136136+ ];
137137+}
138138+139139+/** A form's per-field state. The consumer provides the field ids; the form
140140+ * tracks which one is focused and walks between them on tab/backtab. */
141141+export interface FormState<Id extends string> {
142142+ values: Record<Id, TextFieldState>;
143143+ /** null only when the field set is empty — otherwise always one of `order`. */
144144+ focused: Id | null;
145145+ order: readonly Id[];
146146+}
147147+148148+export function createFormState<Id extends string>(
149149+ order: readonly Id[],
150150+ initial: Record<Id, string>,
151151+): FormState<Id> {
152152+ const values = {} as Record<Id, TextFieldState>;
153153+ for (const id of order) {
154154+ const t = initial[id] ?? "";
155155+ values[id] = { text: t, cursor: t.length };
156156+ }
157157+ return {
158158+ values,
159159+ focused: order[0] ?? null,
160160+ order,
161161+ };
162162+}
163163+164164+function walkFocus<Id extends string>(state: FormState<Id>, delta: 1 | -1): FormState<Id> {
165165+ if (state.focused == null || state.order.length === 0) return state;
166166+ const idx = state.order.indexOf(state.focused);
167167+ if (idx < 0) return state;
168168+ const nextIdx = (idx + delta + state.order.length) % state.order.length;
169169+ return { ...state, focused: state.order[nextIdx] };
170170+}
171171+172172+export function focusField<Id extends string>(state: FormState<Id>, id: Id): FormState<Id> {
173173+ if (!state.order.includes(id)) return state;
174174+ return { ...state, focused: id };
175175+}
176176+177177+export function setFieldText<Id extends string>(
178178+ state: FormState<Id>,
179179+ id: Id,
180180+ text: string,
181181+): FormState<Id> {
182182+ const current = state.values[id];
183183+ if (!current) return state;
184184+ return {
185185+ ...state,
186186+ values: { ...state.values, [id]: { text, cursor: text.length } },
187187+ };
188188+}
189189+190190+export interface HandleFormKeyResult<Id extends string> {
191191+ state: FormState<Id>;
192192+ /** What happened — lets the consumer react (e.g. open a picker on Enter
193193+ * in a "due" field). "submit" means Enter in the last field, "cancel"
194194+ * means Escape anywhere, "activate" means Enter in a non-last field. */
195195+ action: "edited" | "moved" | "submit" | "cancel" | "activate" | "none";
196196+}
197197+198198+/** Default form key dispatch:
199199+ * - tab -> focus next field
200200+ * - backtab -> focus previous field (requires pty's backtab support)
201201+ * - enter -> "activate" if a non-last field, "submit" if the last
202202+ * - escape -> "cancel"
203203+ * - other -> delegate to `applyTextKey` on the focused field
204204+ * Consumers that want a custom behaviour (e.g. enter-opens-picker for the
205205+ * "due" field) should check `action` / `state.focused` and react BEFORE
206206+ * calling this, or construct their own dispatcher using the helpers. */
207207+export function handleFormKey<Id extends string>(
208208+ state: FormState<Id>,
209209+ key: KeyEvent,
210210+): HandleFormKeyResult<Id> {
211211+ if (key.name === "tab") {
212212+ return { state: walkFocus(state, 1), action: "moved" };
213213+ }
214214+ if (key.name === "backtab") {
215215+ return { state: walkFocus(state, -1), action: "moved" };
216216+ }
217217+ if (key.name === "escape") {
218218+ return { state, action: "cancel" };
219219+ }
220220+ if (key.name === "return") {
221221+ if (!state.focused) return { state, action: "none" };
222222+ const idx = state.order.indexOf(state.focused);
223223+ const isLast = idx === state.order.length - 1;
224224+ return { state, action: isLast ? "submit" : "activate" };
225225+ }
226226+227227+ if (!state.focused) return { state, action: "none" };
228228+ const field = state.values[state.focused];
229229+ const updated = applyTextKey(field, key);
230230+ if (updated === null) return { state, action: "none" };
231231+ return {
232232+ state: {
233233+ ...state,
234234+ values: { ...state.values, [state.focused]: updated },
235235+ },
236236+ action: "edited",
237237+ };
238238+}
+51
src/tui/widgets/help-overlay.ts
···11+// Help overlay — a keybinding reference. Consumers bind "?" to toggle a
22+// signal that owns a HelpSection[], then render the result via
33+// `helpPanel(sections)` as the body of an `overlay()` screen. Reads like a
44+// cheat-sheet, which is the point.
55+66+import { row, column, text, panel, separator } from "../builders.ts";
77+import type { UINode, ColumnNode } from "../nodes.ts";
88+99+export interface HelpBinding {
1010+ key: string;
1111+ desc: string;
1212+}
1313+1414+export interface HelpSection {
1515+ title: string;
1616+ bindings: HelpBinding[];
1717+}
1818+1919+function renderSection(sec: HelpSection, keyWidth: number): UINode[] {
2020+ const out: UINode[] = [
2121+ row(text(sec.title, "accent", { bold: true })),
2222+ ];
2323+ for (const b of sec.bindings) {
2424+ out.push(row(
2525+ text(" ", "muted"),
2626+ text(b.key.padEnd(keyWidth + 2), "accent"),
2727+ text(b.desc, "primary"),
2828+ ));
2929+ }
3030+ return out;
3131+}
3232+3333+/** Render the help as a panel body. Columns align within a section via a
3434+ * shared keyWidth derived from the widest key across ALL sections, so the
3535+ * sections line up visually when stacked. */
3636+export function helpPanel(sections: readonly HelpSection[], title = "keybindings"): UINode {
3737+ let keyWidth = 0;
3838+ for (const s of sections) for (const b of s.bindings) keyWidth = Math.max(keyWidth, b.key.length);
3939+4040+ const children: UINode[] = [];
4141+ sections.forEach((sec, i) => {
4242+ if (i > 0) {
4343+ children.push(separator());
4444+ }
4545+ children.push(...renderSection(sec, keyWidth));
4646+ });
4747+ children.push(separator());
4848+ children.push(row(text(" press ? or esc to close", "muted", { dim: true })));
4949+5050+ return panel(title, children);
5151+}
+113
src/tui/widgets/index.ts
···11+// Public surface for the widgets tier — higher-level components built on
22+// the core TUI primitives. All state-first: you own the state, widgets are
33+// pure render + pure key dispatch.
44+55+export {
66+ type TreeNode, type TreeRow, type TreeState,
77+ createTreeState, flattenTree, toggleExpanded, selectById,
88+ moveSelection, handleTreeKey, treeGlyph,
99+} from "./tree.ts";
1010+1111+export {
1212+ type DatePickerState,
1313+ MONTH_NAMES, daysInMonth,
1414+ datePickerFromDate, clampDay,
1515+ shiftDay, shiftMonth, shiftTime,
1616+ toDate, handleDatePickerKey,
1717+ calendarCanvas, datePickerBody, datePickerPanel,
1818+} from "./date-picker.ts";
1919+2020+export {
2121+ type TextFieldState, type FormState, type HandleFormKeyResult,
2222+ applyTextKey, renderFieldText, renderFieldNodes,
2323+ prevWordBoundary, nextWordBoundary,
2424+ createFormState, focusField, setFieldText, handleFormKey,
2525+} from "./form.ts";
2626+2727+export {
2828+ type MarkdownOptions,
2929+ parseMarkdown, parseInline, renderMarkdown,
3030+} from "./markdown.ts";
3131+3232+export {
3333+ type TextAreaState,
3434+ createTextArea, textAreaToString, applyTextAreaKey, renderTextArea,
3535+} from "./text-area.ts";
3636+3737+export {
3838+ type VirtualListState, type VirtualWindow,
3939+ type HandleVirtualKeyResult, type HandleVirtualMouseResult,
4040+ createVirtualListState, clampVirtual, virtualWindow,
4141+ moveVirtualSelection, pageVirtual,
4242+ jumpVirtualToStart, jumpVirtualToEnd, handleVirtualKey, handleVirtualMouse,
4343+ renderVirtualList,
4444+} from "./virtual-list.ts";
4545+4646+export {
4747+ type StreamViewState,
4848+ createStreamView, isPinned as streamIsPinned, streamPin,
4949+ streamScrollUp, streamScrollDown, streamWindow,
5050+ handleStreamKey, handleStreamMouse, renderStreamView,
5151+} from "./stream-view.ts";
5252+5353+export {
5454+ type TabDef, type TabsState,
5555+ createTabsState, selectTab, nextTab, prevTab,
5656+ handleTabsKey, handleTabsMouse,
5757+ renderTabs,
5858+} from "./tabs.ts";
5959+6060+export {
6161+ type ConfirmState, type CreateConfirmOptions, type HandleConfirmResult,
6262+ createConfirm, handleConfirmKey, confirmPanel,
6363+} from "./confirm.ts";
6464+6565+export {
6666+ type Toast, type ToastKind, type ToastQueue, type PushToastOptions,
6767+ createToastQueue, pushToast, pruneExpired, dismissToast, renderToasts,
6868+} from "./toast.ts";
6969+7070+export {
7171+ type Command, type CommandPaletteState, type RankedCommand,
7272+ type HandleCommandPaletteResult,
7373+ createCommandPaletteState, filterCommands,
7474+ handleCommandPaletteKey, renderCommandPalette,
7575+} from "./command-palette.ts";
7676+7777+export {
7878+ registerGlobalCommand, useCommandScope, clearCommandScope,
7979+ findCommand, runCommand, allCommands,
8080+ _resetCommandRegistry,
8181+} from "./command-registry.ts";
8282+8383+export {
8484+ type TableColumn, type TableState, type TableAlign,
8585+ type HandleTableKeyResult,
8686+ createTableState, sortRows, handleTableKey, renderTable,
8787+} from "./table.ts";
8888+8989+export {
9090+ type HelpSection, type HelpBinding,
9191+ helpPanel,
9292+} from "./help-overlay.ts";
9393+9494+export {
9595+ type PromptBarValue, type PromptBarTitle, type PromptBarStatus,
9696+ type PromptBarOptions, type TitleAlign,
9797+ promptBar,
9898+} from "./prompt-bar.ts";
9999+100100+export {
101101+ type ToolbarItem, type ToolbarOptions,
102102+ toolbar, toolbarItemFor,
103103+} from "./toolbar.ts";
104104+105105+export {
106106+ type SparklineOptions,
107107+ sparkline, sparklineString,
108108+} from "./sparkline.ts";
109109+110110+export {
111111+ type BarChartItem, type BarChartOptions,
112112+ barChart,
113113+} from "./bar-chart.ts";
+345
src/tui/widgets/markdown.ts
···11+// Markdown renderer — parses a subset of CommonMark and returns UINodes.
22+//
33+// Subset supported (v1):
44+// - Headings (#, ##, ###, ####)
55+// - Paragraphs (word-wrapped via `wrapText`)
66+// - Bold (**x**), italic (*x*), inline code (`x`), links ([text](url))
77+// - Fenced code blocks (```) — dim color, no syntax highlighting
88+// - Unordered lists (-, *) and ordered lists (1.)
99+// - Task lists (- [ ], - [x])
1010+// - Blockquotes (> line)
1111+// - Horizontal rules (---, ***)
1212+//
1313+// Deliberately NOT supported:
1414+// - Tables (too column-layout-dependent for terminal; can add later)
1515+// - HTML passthrough (security + rendering both complicated)
1616+// - Nested lists (would need indentation tracking; can add later)
1717+1818+import { text, row, separator, indent, checkbox, spacer, column } from "../builders.ts";
1919+import type { UINode, Color } from "../nodes.ts";
2020+2121+export interface MarkdownOptions {
2222+ /** Maximum width the renderer should assume when word-wrapping paragraphs.
2323+ * If omitted, paragraphs are not wrapped — useful when the renderer is
2424+ * nested inside a framework that does its own wrapping. */
2525+ width?: number;
2626+}
2727+2828+interface Block {
2929+ kind:
3030+ | "heading" | "paragraph" | "code" | "bullet" | "ordered"
3131+ | "task" | "quote" | "hr";
3232+ /** heading level (1-4) */
3333+ level?: number;
3434+ /** raw text for paragraph / heading / quote */
3535+ text?: string;
3636+ /** lines inside a code block */
3737+ lines?: string[];
3838+ /** items inside a list block */
3939+ items?: string[];
4040+ /** task items: [done, text] pairs for tasks */
4141+ tasks?: { done: boolean; text: string }[];
4242+}
4343+4444+const HEADING_RE = /^(#{1,4})\s+(.*)$/;
4545+const BULLET_RE = /^[-*]\s+(.*)$/;
4646+const ORDERED_RE = /^\d+\.\s+(.*)$/;
4747+const TASK_RE = /^[-*]\s+\[([ xX])\]\s+(.*)$/;
4848+const QUOTE_RE = /^>\s?(.*)$/;
4949+const FENCE_RE = /^```/;
5050+const HR_RE = /^(-{3,}|\*{3,}|_{3,})\s*$/;
5151+5252+/** Split the source into a list of block-level descriptors. This is the
5353+ * only parsing pass — rendering is then purely structural. */
5454+export function parseMarkdown(source: string): Block[] {
5555+ const lines = source.split("\n");
5656+ const blocks: Block[] = [];
5757+ let para: string[] = [];
5858+5959+ const flushPara = () => {
6060+ if (para.length === 0) return;
6161+ blocks.push({ kind: "paragraph", text: para.join(" ") });
6262+ para = [];
6363+ };
6464+6565+ for (let i = 0; i < lines.length; i++) {
6666+ const line = lines[i];
6767+6868+ // Fenced code — consume until the closing fence.
6969+ if (FENCE_RE.test(line)) {
7070+ flushPara();
7171+ const codeLines: string[] = [];
7272+ i++;
7373+ while (i < lines.length && !FENCE_RE.test(lines[i])) {
7474+ codeLines.push(lines[i]);
7575+ i++;
7676+ }
7777+ blocks.push({ kind: "code", lines: codeLines });
7878+ continue;
7979+ }
8080+8181+ // Blank line — ends the current paragraph.
8282+ if (line.trim() === "") {
8383+ flushPara();
8484+ continue;
8585+ }
8686+8787+ // Horizontal rule.
8888+ if (HR_RE.test(line)) {
8989+ flushPara();
9090+ blocks.push({ kind: "hr" });
9191+ continue;
9292+ }
9393+9494+ // Heading.
9595+ const h = HEADING_RE.exec(line);
9696+ if (h) {
9797+ flushPara();
9898+ blocks.push({ kind: "heading", level: h[1].length, text: h[2] });
9999+ continue;
100100+ }
101101+102102+ // Task item (must match BEFORE bullet since task is a bullet superset).
103103+ const t = TASK_RE.exec(line);
104104+ if (t) {
105105+ flushPara();
106106+ const prev = blocks[blocks.length - 1];
107107+ if (prev && prev.kind === "task") {
108108+ prev.tasks!.push({ done: t[1] !== " ", text: t[2] });
109109+ } else {
110110+ blocks.push({ kind: "task", tasks: [{ done: t[1] !== " ", text: t[2] }] });
111111+ }
112112+ continue;
113113+ }
114114+115115+ // Bullet list.
116116+ const b = BULLET_RE.exec(line);
117117+ if (b) {
118118+ flushPara();
119119+ const prev = blocks[blocks.length - 1];
120120+ if (prev && prev.kind === "bullet") prev.items!.push(b[1]);
121121+ else blocks.push({ kind: "bullet", items: [b[1]] });
122122+ continue;
123123+ }
124124+125125+ // Ordered list.
126126+ const o = ORDERED_RE.exec(line);
127127+ if (o) {
128128+ flushPara();
129129+ const prev = blocks[blocks.length - 1];
130130+ if (prev && prev.kind === "ordered") prev.items!.push(o[1]);
131131+ else blocks.push({ kind: "ordered", items: [o[1]] });
132132+ continue;
133133+ }
134134+135135+ // Blockquote.
136136+ const q = QUOTE_RE.exec(line);
137137+ if (q) {
138138+ flushPara();
139139+ const prev = blocks[blocks.length - 1];
140140+ if (prev && prev.kind === "quote") prev.text! += "\n" + q[1];
141141+ else blocks.push({ kind: "quote", text: q[1] });
142142+ continue;
143143+ }
144144+145145+ // Default — part of a paragraph.
146146+ para.push(line);
147147+ }
148148+ flushPara();
149149+ return blocks;
150150+}
151151+152152+/** Split a line of text into inline styled segments — bold, italic, code,
153153+ * link. Returns alternating plain+styled chunks as children of a row.
154154+ * Simplified inline grammar; good enough for most content, keeps the
155155+ * renderer output predictable. */
156156+interface InlineSegment {
157157+ text: string;
158158+ bold?: boolean;
159159+ italic?: boolean;
160160+ code?: boolean;
161161+ url?: string;
162162+}
163163+164164+export function parseInline(src: string): InlineSegment[] {
165165+ const out: InlineSegment[] = [];
166166+ let i = 0;
167167+ let buf = "";
168168+ const flushBuf = () => {
169169+ if (buf.length > 0) { out.push({ text: buf }); buf = ""; }
170170+ };
171171+172172+ while (i < src.length) {
173173+ // Links: [text](url)
174174+ if (src[i] === "[") {
175175+ const end = src.indexOf("]", i + 1);
176176+ if (end !== -1 && src[end + 1] === "(") {
177177+ const urlEnd = src.indexOf(")", end + 2);
178178+ if (urlEnd !== -1) {
179179+ flushBuf();
180180+ out.push({ text: src.slice(i + 1, end), url: src.slice(end + 2, urlEnd) });
181181+ i = urlEnd + 1;
182182+ continue;
183183+ }
184184+ }
185185+ }
186186+ // Inline code: `x`
187187+ if (src[i] === "`") {
188188+ const end = src.indexOf("`", i + 1);
189189+ if (end !== -1) {
190190+ flushBuf();
191191+ out.push({ text: src.slice(i + 1, end), code: true });
192192+ i = end + 1;
193193+ continue;
194194+ }
195195+ }
196196+ // Bold / italic. When we see `*`, decide which one we're looking for.
197197+ // Unmatched delimiters emit as plain text (via buf) rather than eating
198198+ // the next char on a mis-paired fallback.
199199+ if (src[i] === "*") {
200200+ if (src[i + 1] === "*") {
201201+ const end = src.indexOf("**", i + 2);
202202+ if (end !== -1) {
203203+ flushBuf();
204204+ out.push({ text: src.slice(i + 2, end), bold: true });
205205+ i = end + 2;
206206+ continue;
207207+ }
208208+ buf += "**";
209209+ i += 2;
210210+ continue;
211211+ }
212212+ const end = src.indexOf("*", i + 1);
213213+ if (end !== -1) {
214214+ flushBuf();
215215+ out.push({ text: src.slice(i + 1, end), italic: true });
216216+ i = end + 1;
217217+ continue;
218218+ }
219219+ buf += "*";
220220+ i++;
221221+ continue;
222222+ }
223223+ buf += src[i];
224224+ i++;
225225+ }
226226+ flushBuf();
227227+ return out;
228228+}
229229+230230+function renderInline(segments: InlineSegment[], baseColor: Color = "primary"): UINode[] {
231231+ return segments.map(s => {
232232+ if (s.code) return text(s.text, "accent", { italic: false });
233233+ if (s.url) return text(`${s.text} `, "accent");
234234+ return text(s.text, baseColor, { bold: !!s.bold, italic: !!s.italic });
235235+ });
236236+}
237237+238238+function headingColor(level: number): Color {
239239+ switch (level) {
240240+ case 1: return "accent";
241241+ case 2: return "primary";
242242+ case 3: return "accent";
243243+ default: return "muted";
244244+ }
245245+}
246246+247247+function wrapLine(src: string, width?: number): string[] {
248248+ if (!width || width <= 0) return [src];
249249+ // Simple word wrap that keeps whole words together.
250250+ const words = src.split(/(\s+)/);
251251+ const lines: string[] = [];
252252+ let line = "";
253253+ for (const w of words) {
254254+ if ((line + w).length <= width) { line += w; continue; }
255255+ if (line.length > 0) { lines.push(line.trimEnd()); line = ""; }
256256+ if (w.length > width) {
257257+ // Unbreakable word longer than width — hard-split.
258258+ for (let i = 0; i < w.length; i += width) lines.push(w.slice(i, i + width));
259259+ } else if (w.trim().length > 0) {
260260+ line = w;
261261+ }
262262+ }
263263+ if (line.length > 0) lines.push(line.trimEnd());
264264+ return lines;
265265+}
266266+267267+/** Render markdown source as an array of UINodes ready for a panel/column. */
268268+export function renderMarkdown(source: string, opts: MarkdownOptions = {}): UINode[] {
269269+ const blocks = parseMarkdown(source);
270270+ const out: UINode[] = [];
271271+272272+ for (let bi = 0; bi < blocks.length; bi++) {
273273+ const b = blocks[bi];
274274+ // Visual spacing between blocks: add a blank row between non-first blocks
275275+ // that aren't tightly related (lists, tasks accept internal spacing).
276276+ if (bi > 0) out.push(row(spacer()));
277277+278278+ switch (b.kind) {
279279+ case "heading": {
280280+ const c = headingColor(b.level!);
281281+ out.push(row(
282282+ text("#".repeat(b.level!) + " ", "muted", { dim: true }),
283283+ ...renderInline(parseInline(b.text!), c).map((n, i) => {
284284+ // First inline in heading is bold; subsequent inherit their own style.
285285+ if (i === 0 && (n as any).type === "text") {
286286+ return text((n as any).text, c, { bold: true });
287287+ }
288288+ return n;
289289+ }),
290290+ ));
291291+ break;
292292+ }
293293+ case "paragraph": {
294294+ const wrapped = wrapLine(b.text ?? "", opts.width);
295295+ for (const ln of wrapped) out.push(row(...renderInline(parseInline(ln))));
296296+ break;
297297+ }
298298+ case "code": {
299299+ for (const ln of b.lines!) {
300300+ out.push(row(text(" " + ln, "muted", { dim: false })));
301301+ }
302302+ break;
303303+ }
304304+ case "bullet": {
305305+ for (const item of b.items!) {
306306+ out.push(row(text(" • ", "muted"), ...renderInline(parseInline(item))));
307307+ }
308308+ break;
309309+ }
310310+ case "ordered": {
311311+ b.items!.forEach((item, i) => {
312312+ out.push(row(
313313+ text(` ${i + 1}. `, "muted"),
314314+ ...renderInline(parseInline(item)),
315315+ ));
316316+ });
317317+ break;
318318+ }
319319+ case "task": {
320320+ for (const t of b.tasks!) {
321321+ out.push(row(
322322+ text(" ", "muted"),
323323+ checkbox(t.done, t.done ? "muted" : "accent"),
324324+ text(" ", "muted"),
325325+ ...renderInline(parseInline(t.text), t.done ? "muted" : "primary"),
326326+ ));
327327+ }
328328+ break;
329329+ }
330330+ case "quote": {
331331+ for (const ln of (b.text ?? "").split("\n")) {
332332+ const wrapped = wrapLine(ln, opts.width ? opts.width - 2 : undefined);
333333+ for (const w of wrapped) {
334334+ out.push(row(text("\u2502 ", "muted"), ...renderInline(parseInline(w), "muted")));
335335+ }
336336+ }
337337+ break;
338338+ }
339339+ case "hr":
340340+ out.push(separator());
341341+ break;
342342+ }
343343+ }
344344+ return out;
345345+}
+135
src/tui/widgets/prompt-bar.ts
···11+// Prompt bar — a full-width input styled like Claude Code's message box.
22+//
33+// Layout:
44+// ───────────[title]────────────
55+// ❯ the input goes here█
66+// ───────────────────────────────
77+// status left status right
88+//
99+// Top and bottom horizontal rules (no side borders, unlike `panel`), a
1010+// prompt glyph, the text field itself, and an optional status strip.
1111+// Works with both single-line (TextFieldState) and multi-line
1212+// (TextAreaState) values — pass whichever you own.
1313+1414+import { row, column, text, separator, spacer } from "../builders.ts";
1515+import type { UINode, ColumnNode, Color } from "../nodes.ts";
1616+import { renderFieldNodes, type TextFieldState } from "./form.ts";
1717+import { renderTextArea, type TextAreaState } from "./text-area.ts";
1818+1919+export type PromptBarValue =
2020+ | { kind: "single"; state: TextFieldState }
2121+ | { kind: "multi"; state: TextAreaState };
2222+2323+export type TitleAlign = "left" | "center" | "right";
2424+2525+export interface PromptBarTitle {
2626+ text: string;
2727+ align?: TitleAlign;
2828+ /** Color for the title text. Defaults to "accent". */
2929+ color?: Color;
3030+}
3131+3232+export interface PromptBarStatus {
3333+ left?: string;
3434+ right?: string;
3535+ /** Color for the status text. Defaults to "muted". */
3636+ color?: Color;
3737+}
3838+3939+export interface PromptBarOptions {
4040+ /** Prompt glyph rendered before the value. Default: "\u276f" (❯). */
4141+ glyph?: string;
4242+ /** Color for the glyph. Default "accent". */
4343+ glyphColor?: Color;
4444+ /** Title overlaid on the top rule. Omit for a plain rule. */
4545+ title?: PromptBarTitle;
4646+ /** Optional status strip rendered under the bottom rule. */
4747+ status?: PromptBarStatus;
4848+ /** Whether the field is focused — controls cursor rendering. Default true. */
4949+ active?: boolean;
5050+}
5151+5252+function titleRule(title: PromptBarTitle | undefined, color: Color = "muted"): UINode {
5353+ if (!title) return separator();
5454+ const label = ` ${title.text} `;
5555+ const align = title.align ?? "left";
5656+ // Separator can't carry embedded text, so we approximate with a row made
5757+ // of three text segments: left-rule, title, right-rule. The layout engine
5858+ // handles width. We use long rule strings that will get clipped to width;
5959+ // for a principled fixed-width build use separator() without a title.
6060+ const ruleChar = "\u2500";
6161+ const leftRule = align === "left" ? ruleChar.repeat(2)
6262+ : align === "center" ? ruleChar.repeat(20)
6363+ : ruleChar.repeat(40);
6464+ const rightRule = align === "right" ? ruleChar.repeat(2)
6565+ : align === "center" ? ruleChar.repeat(20)
6666+ : ruleChar.repeat(40);
6767+ return row(
6868+ text(leftRule, color, { dim: true }),
6969+ text(label, title.color ?? "accent", { bold: true }),
7070+ text(rightRule, color, { dim: true }),
7171+ );
7272+}
7373+7474+function statusRow(s: PromptBarStatus | undefined): UINode | null {
7575+ if (!s) return null;
7676+ if (!s.left && !s.right) return null;
7777+ const color = s.color ?? "muted";
7878+ const children: UINode[] = [];
7979+ if (s.left) children.push(text(s.left, color, { dim: true }));
8080+ children.push(spacer());
8181+ if (s.right) children.push(text(s.right + " ", color, { dim: true }));
8282+ return row(...children);
8383+}
8484+8585+/** Render a prompt bar around `value`. Returns a column of rows — drop it
8686+ * into a screen's render() array directly. */
8787+export function promptBar(value: PromptBarValue, opts: PromptBarOptions = {}): UINode {
8888+ const glyph = opts.glyph ?? "\u276f";
8989+ const active = opts.active ?? true;
9090+ const glyphColor = opts.glyphColor ?? "accent";
9191+9292+ const children: UINode[] = [titleRule(opts.title)];
9393+9494+ if (value.kind === "multi") {
9595+ // Multi-line: prompt glyph on the FIRST row only; the text area
9696+ // is rendered as its own column of rows below (continued-line
9797+ // rows are indented to line up with the glyph width).
9898+ const lines = value.state.lines;
9999+ const active_ = active;
100100+ lines.forEach((line, i) => {
101101+ const onCursor = active_ && i === value.state.row;
102102+ const prompt = i === 0
103103+ ? text(` ${glyph} `, glyphColor, { bold: true })
104104+ : text(` `, "muted");
105105+ if (!onCursor) {
106106+ children.push(row(prompt, text(line.length === 0 ? " " : line, "primary")));
107107+ } else {
108108+ const col = value.state.col;
109109+ const before = line.slice(0, col);
110110+ const under = line.slice(col, col + 1) || " ";
111111+ const after = line.slice(col + 1);
112112+ children.push(row(
113113+ prompt,
114114+ text(before, "primary"),
115115+ text(under, "primary", { inverse: true }),
116116+ text(after, "primary"),
117117+ ));
118118+ }
119119+ });
120120+ } else {
121121+ // Single-line: everything fits on one row.
122122+ const s = value.state;
123123+ children.push(row(
124124+ text(` ${glyph} `, glyphColor, { bold: true }),
125125+ ...renderFieldNodes(s.text, s.cursor, active),
126126+ ));
127127+ }
128128+129129+ children.push(separator());
130130+ const status = statusRow(opts.status);
131131+ if (status) children.push(status);
132132+133133+ const node: ColumnNode = { type: "column", children };
134134+ return node;
135135+}
+74
src/tui/widgets/sparkline.ts
···11+// Sparkline — a compact inline chart drawn with unicode block characters.
22+// One character per sample; height encoded via eight steps from " " (empty)
33+// through "\u2581" (lower eighth) up to "\u2588" (full block).
44+//
55+// Pure: given a numeric series and a width, returns the string. Wrap it in
66+// text(...) / canvas(...) to place it anywhere.
77+88+import { text } from "../builders.ts";
99+import type { UINode, Color } from "../nodes.ts";
1010+1111+/** 0..8 block glyph levels used to pack a sample into a single cell. */
1212+const BLOCKS = [" ", "\u2581", "\u2582", "\u2583", "\u2584",
1313+ "\u2585", "\u2586", "\u2587", "\u2588"];
1414+1515+export interface SparklineOptions {
1616+ /** Explicit render width. If omitted, renders `series.length` cells. */
1717+ width?: number;
1818+ /** Explicit min/max — useful when the bounds should be stable across
1919+ * frames (e.g. 0..100 for a CPU %) rather than per-sample. */
2020+ min?: number;
2121+ max?: number;
2222+}
2323+2424+/** Render `series` as a unicode sparkline string. NaN / Infinity samples
2525+ * render as the empty cell. */
2626+export function sparklineString(series: readonly number[], opts: SparklineOptions = {}): string {
2727+ const width = Math.max(0, Math.floor(opts.width ?? series.length));
2828+ if (width === 0 || series.length === 0) return "";
2929+3030+ // If the caller wants a fixed width, sample the tail of the series.
3131+ // This matches the "scrolling window" UX of metric sparklines.
3232+ const slice = series.length <= width
3333+ ? series
3434+ : series.slice(series.length - width);
3535+3636+ let lo = opts.min;
3737+ let hi = opts.max;
3838+ if (lo == null || hi == null) {
3939+ let ilo = Infinity, ihi = -Infinity;
4040+ for (const v of slice) {
4141+ if (!Number.isFinite(v)) continue;
4242+ if (v < ilo) ilo = v;
4343+ if (v > ihi) ihi = v;
4444+ }
4545+ if (!Number.isFinite(ilo)) ilo = 0;
4646+ if (!Number.isFinite(ihi)) ihi = 1;
4747+ if (lo == null) lo = ilo;
4848+ if (hi == null) hi = ihi;
4949+ }
5050+5151+ const range = hi! - lo!;
5252+ const out: string[] = [];
5353+ // Left-pad if the series is shorter than width.
5454+ const pad = width - slice.length;
5555+ for (let i = 0; i < pad; i++) out.push(BLOCKS[0]);
5656+5757+ for (const v of slice) {
5858+ if (!Number.isFinite(v)) { out.push(BLOCKS[0]); continue; }
5959+ if (range <= 0) {
6060+ // All samples equal — pick the middle block so there's *something*.
6161+ out.push(BLOCKS[4]);
6262+ continue;
6363+ }
6464+ const frac = (v - lo!) / range;
6565+ const idx = Math.max(0, Math.min(8, Math.round(frac * 8)));
6666+ out.push(BLOCKS[idx]);
6767+ }
6868+ return out.join("");
6969+}
7070+7171+/** Convenience: render the sparkline as a text node with a given color. */
7272+export function sparkline(series: readonly number[], opts: SparklineOptions & { color?: Color } = {}): UINode {
7373+ return text(sparklineString(series, opts), opts.color ?? "accent");
7474+}
+122
src/tui/widgets/stream-view.ts
···11+// Stream view — "chat window" layout. Items flow in; by default the view
22+// is pinned to the newest (bottom). Scrolling up unpins and preserves the
33+// user's position even as new items arrive. Scrolling back to the bottom
44+// or explicitly re-pinning restores auto-follow.
55+//
66+// Used by chat apps, log tails, REPL output, streaming AI replies.
77+88+import { column, row, text } from "../builders.ts";
99+import type { UINode, ColumnNode, Rect } from "../nodes.ts";
1010+import type { KeyEvent, MouseEvent } from "../input.ts";
1111+1212+export interface StreamViewState {
1313+ /** Number of items to scroll back from the most recent. 0 = pinned. */
1414+ scrollback: number;
1515+}
1616+1717+export function createStreamView(): StreamViewState {
1818+ return { scrollback: 0 };
1919+}
2020+2121+export function isPinned(state: StreamViewState): boolean {
2222+ return state.scrollback === 0;
2323+}
2424+2525+export function streamPin(state: StreamViewState): StreamViewState {
2626+ return state.scrollback === 0 ? state : { ...state, scrollback: 0 };
2727+}
2828+2929+export function streamScrollUp(
3030+ state: StreamViewState,
3131+ delta: number,
3232+ total: number,
3333+ viewport: number,
3434+): StreamViewState {
3535+ const maxScrollback = Math.max(0, total - viewport);
3636+ const next = Math.min(maxScrollback, state.scrollback + delta);
3737+ if (next === state.scrollback) return state;
3838+ return { ...state, scrollback: next };
3939+}
4040+4141+export function streamScrollDown(state: StreamViewState, delta: number): StreamViewState {
4242+ const next = Math.max(0, state.scrollback - delta);
4343+ if (next === state.scrollback) return state;
4444+ return { ...state, scrollback: next };
4545+}
4646+4747+/** Compute the window of item indexes to draw. `total` and `viewport` are
4848+ * provided by the caller from layout each tick. */
4949+export function streamWindow(
5050+ state: StreamViewState,
5151+ total: number,
5252+ viewport: number,
5353+): { start: number; end: number } {
5454+ if (total <= 0 || viewport <= 0) return { start: 0, end: 0 };
5555+ // end (exclusive) = total - scrollback; start = end - viewport, clamped.
5656+ const end = Math.max(0, total - state.scrollback);
5757+ const start = Math.max(0, end - viewport);
5858+ return { start, end };
5959+}
6060+6161+/** Route a mouse event. Scroll-wheel adjusts scrollback; no click handling
6262+ * since items aren't individually selectable in a plain stream view. */
6363+export function handleStreamMouse(
6464+ state: StreamViewState,
6565+ event: MouseEvent,
6666+ rect: Rect,
6767+ total: number,
6868+ viewport: number,
6969+): StreamViewState | null {
7070+ const inside = event.x >= rect.x && event.x < rect.x + rect.width
7171+ && event.y >= rect.y && event.y < rect.y + rect.height;
7272+ if (!inside) return null;
7373+ if (event.action === "scrollUp") return streamScrollUp(state, 3, total, viewport);
7474+ if (event.action === "scrollDown") return streamScrollDown(state, 3);
7575+ return null;
7676+}
7777+7878+/** Default key handler — up/down for scrollback, pageup/pagedown for bigger
7979+ * jumps, end to re-pin. `total` and `viewport` are required for clamping. */
8080+export function handleStreamKey(
8181+ state: StreamViewState,
8282+ key: KeyEvent,
8383+ total: number,
8484+ viewport: number,
8585+): StreamViewState | null {
8686+ switch (key.name) {
8787+ case "up": return streamScrollUp(state, 1, total, viewport);
8888+ case "down": return streamScrollDown(state, 1);
8989+ case "pageup": return streamScrollUp(state, viewport, total, viewport);
9090+ case "pagedown": return streamScrollDown(state, viewport);
9191+ case "end": return streamPin(state);
9292+ case "home": return streamScrollUp(state, total, total, viewport);
9393+ }
9494+ return null;
9595+}
9696+9797+/** Render visible items using a per-item renderer. Appends an "N unread
9898+ * below" indicator row when scrolled-back and new items have arrived. */
9999+export function renderStreamView<T>(
100100+ items: T[],
101101+ state: StreamViewState,
102102+ viewport: number,
103103+ renderItem: (item: T, index: number) => UINode,
104104+): UINode {
105105+ const total = items.length;
106106+ const { start, end } = streamWindow(state, total, viewport);
107107+ const children: UINode[] = [];
108108+ for (let i = start; i < end; i++) {
109109+ children.push(renderItem(items[i], i));
110110+ }
111111+ if (!isPinned(state) && state.scrollback > 0) {
112112+ // Bottom hint row — unread count between the window and the tail.
113113+ const behind = total - end;
114114+ if (behind > 0) {
115115+ children.push(row(
116116+ text(`— ${behind} more below (end to jump) —`, "accent", { dim: true }),
117117+ ));
118118+ }
119119+ }
120120+ const node: ColumnNode = { type: "column", children };
121121+ return node;
122122+}
+168
src/tui/widgets/table.ts
···11+// Sortable table. Columns describe: how to render a row's cell, how to
22+// extract a sort value, and an alignment hint. State tracks the selected
33+// row, the active sort column, and direction. Pure helpers for sort and
44+// key dispatch; the render is straight-forward once the data is sorted.
55+66+import { row, column, text } from "../builders.ts";
77+import type { UINode, ColumnNode, Color } from "../nodes.ts";
88+import type { KeyEvent } from "../input.ts";
99+1010+export type TableAlign = "left" | "right";
1111+1212+export interface TableColumn<Row> {
1313+ id: string;
1414+ header: string;
1515+ /** Render the cell as a plain string. Width/truncation handled outside. */
1616+ render: (row: Row) => string;
1717+ /** Value used to sort. Defaults to the rendered string. */
1818+ getSortValue?: (row: Row) => string | number;
1919+ align?: TableAlign;
2020+ /** Explicit column width. Omitted means "auto" (max of header + cells). */
2121+ width?: number;
2222+}
2323+2424+export interface TableState {
2525+ sortColumnId: string | null;
2626+ sortDirection: "asc" | "desc";
2727+ selectedIndex: number;
2828+}
2929+3030+export function createTableState<Row>(
3131+ columns: readonly TableColumn<Row>[],
3232+ initialSortId?: string,
3333+): TableState {
3434+ const sortColumnId = initialSortId ?? columns[0]?.id ?? null;
3535+ return { sortColumnId, sortDirection: "asc", selectedIndex: 0 };
3636+}
3737+3838+function valueOf<Row>(col: TableColumn<Row>, r: Row): string | number {
3939+ return col.getSortValue ? col.getSortValue(r) : col.render(r);
4040+}
4141+4242+/** Returns a NEW array sorted per `state`. Stable — preserves original
4343+ * order for equal-keyed rows via the index tie-breaker. */
4444+export function sortRows<Row>(
4545+ rows: readonly Row[],
4646+ columns: readonly TableColumn<Row>[],
4747+ state: TableState,
4848+): Row[] {
4949+ if (!state.sortColumnId) return [...rows];
5050+ const col = columns.find(c => c.id === state.sortColumnId);
5151+ if (!col) return [...rows];
5252+ const sign = state.sortDirection === "asc" ? 1 : -1;
5353+ const indexed = rows.map((r, i) => ({ r, i }));
5454+ indexed.sort((a, b) => {
5555+ const va = valueOf(col, a.r);
5656+ const vb = valueOf(col, b.r);
5757+ if (va < vb) return -1 * sign;
5858+ if (va > vb) return 1 * sign;
5959+ return a.i - b.i;
6060+ });
6161+ return indexed.map(x => x.r);
6262+}
6363+6464+export interface HandleTableKeyResult<Row> {
6565+ state: TableState;
6666+ action: "moved" | "sorted" | "activate" | "none";
6767+ activated?: Row;
6868+}
6969+7070+/** Default bindings:
7171+ * up/down -> move selection
7272+ * home/end -> first/last row
7373+ * pageup/down -> +/- 10 rows
7474+ * return -> "activate" with the selected row
7575+ * 1..9 -> toggle sort on that column (by position). Same column
7676+ * twice flips direction. */
7777+export function handleTableKey<Row>(
7878+ state: TableState,
7979+ sortedRows: readonly Row[],
8080+ columns: readonly TableColumn<Row>[],
8181+ key: KeyEvent,
8282+): HandleTableKeyResult<Row> {
8383+ const clamp = (i: number) => Math.max(0, Math.min(Math.max(0, sortedRows.length - 1), i));
8484+8585+ switch (key.name) {
8686+ case "up": return { state: { ...state, selectedIndex: clamp(state.selectedIndex - 1) }, action: "moved" };
8787+ case "down": return { state: { ...state, selectedIndex: clamp(state.selectedIndex + 1) }, action: "moved" };
8888+ case "pageup": return { state: { ...state, selectedIndex: clamp(state.selectedIndex - 10) }, action: "moved" };
8989+ case "pagedown": return { state: { ...state, selectedIndex: clamp(state.selectedIndex + 10) }, action: "moved" };
9090+ case "home": return { state: { ...state, selectedIndex: 0 }, action: "moved" };
9191+ case "end": return { state: { ...state, selectedIndex: clamp(sortedRows.length - 1) }, action: "moved" };
9292+ case "return":
9393+ return { state, action: "activate", activated: sortedRows[state.selectedIndex] };
9494+ }
9595+9696+ if (key.char && /^[1-9]$/.test(key.char) && !key.ctrl && !key.alt) {
9797+ const idx = parseInt(key.char, 10) - 1;
9898+ const col = columns[idx];
9999+ if (col) {
100100+ if (state.sortColumnId === col.id) {
101101+ return {
102102+ state: { ...state, sortDirection: state.sortDirection === "asc" ? "desc" : "asc" },
103103+ action: "sorted",
104104+ };
105105+ }
106106+ return {
107107+ state: { ...state, sortColumnId: col.id, sortDirection: "asc" },
108108+ action: "sorted",
109109+ };
110110+ }
111111+ }
112112+113113+ return { state, action: "none" };
114114+}
115115+116116+function columnWidths<Row>(
117117+ rows: readonly Row[],
118118+ columns: readonly TableColumn<Row>[],
119119+): number[] {
120120+ return columns.map(col => {
121121+ if (col.width != null) return col.width;
122122+ let w = col.header.length;
123123+ for (const r of rows) w = Math.max(w, col.render(r).length);
124124+ return w;
125125+ });
126126+}
127127+128128+function padCell(s: string, w: number, align: TableAlign): string {
129129+ if (s.length >= w) return s.slice(0, w);
130130+ const pad = " ".repeat(w - s.length);
131131+ return align === "right" ? pad + s : s + pad;
132132+}
133133+134134+/** Render a sortable table. Rows should already be sorted via `sortRows`
135135+ * (we don't sort inside render so the caller can memoize). */
136136+export function renderTable<Row>(
137137+ sortedRows: readonly Row[],
138138+ columns: readonly TableColumn<Row>[],
139139+ state: TableState,
140140+): UINode {
141141+ const widths = columnWidths(sortedRows, columns);
142142+ const arrow = (colId: string): string => {
143143+ if (state.sortColumnId !== colId) return " ";
144144+ return state.sortDirection === "asc" ? " \u25b2" : " \u25bc";
145145+ };
146146+147147+ const header = row(...columns.flatMap((col, i) => {
148148+ const label = padCell(col.header + arrow(col.id), widths[i] + 2, col.align ?? "left");
149149+ return [text(label, "accent", { bold: true })];
150150+ }));
151151+152152+ const separator = row(...columns.flatMap((_, i) => [
153153+ text("\u2500".repeat(widths[i]) + " ", "muted", { dim: true }),
154154+ ]));
155155+156156+ const body: UINode[] = sortedRows.map((r, idx) => {
157157+ const selected = idx === state.selectedIndex;
158158+ const color: Color = selected ? "accent" : "primary";
159159+ const cells = columns.flatMap((col, i) => [
160160+ text(padCell(col.render(r), widths[i], col.align ?? "left") + " ",
161161+ color, { bold: selected }),
162162+ ]);
163163+ return row(...cells);
164164+ });
165165+166166+ const node: ColumnNode = { type: "column", children: [header, separator, ...body] };
167167+ return node;
168168+}
+106
src/tui/widgets/tabs.ts
···11+// Tabs — a horizontal strip of labels with one active. Lets apps organise
22+// multiple top-level views (like a mail client with Inbox/Sent/Drafts).
33+//
44+// Navigation: tab / ctrl+tab cycle forward, backtab / ctrl+shift+tab cycle
55+// backward. Or bind numbers 1-9 to jump directly. Activation is cosmetic
66+// only — the consumer decides what "active" means for their app.
77+88+import { row, text } from "../builders.ts";
99+import type { UINode, Rect } from "../nodes.ts";
1010+import type { KeyEvent, MouseEvent } from "../input.ts";
1111+1212+export interface TabDef<T = unknown> {
1313+ id: string;
1414+ label: string;
1515+ /** Optional data bag for the consumer — tabs don't care what this is. */
1616+ data?: T;
1717+}
1818+1919+export interface TabsState {
2020+ activeId: string | null;
2121+}
2222+2323+export function createTabsState<T>(tabs: readonly TabDef<T>[], initial?: string): TabsState {
2424+ return { activeId: initial ?? tabs[0]?.id ?? null };
2525+}
2626+2727+export function selectTab(state: TabsState, id: string): TabsState {
2828+ if (state.activeId === id) return state;
2929+ return { activeId: id };
3030+}
3131+3232+function stepTab<T>(state: TabsState, tabs: readonly TabDef<T>[], delta: 1 | -1): TabsState {
3333+ if (tabs.length === 0 || !state.activeId) return state;
3434+ const idx = tabs.findIndex(t => t.id === state.activeId);
3535+ if (idx < 0) return state;
3636+ const next = (idx + delta + tabs.length) % tabs.length;
3737+ return { activeId: tabs[next].id };
3838+}
3939+4040+export function nextTab<T>(state: TabsState, tabs: readonly TabDef<T>[]): TabsState {
4141+ return stepTab(state, tabs, 1);
4242+}
4343+4444+export function prevTab<T>(state: TabsState, tabs: readonly TabDef<T>[]): TabsState {
4545+ return stepTab(state, tabs, -1);
4646+}
4747+4848+/** Default key dispatch — ctrl+tab / ctrl+shift+tab to cycle. Returns
4949+ * `null` when the key wasn't consumed so the caller can handle focus. */
5050+export function handleTabsKey<T>(
5151+ state: TabsState,
5252+ tabs: readonly TabDef<T>[],
5353+ key: KeyEvent,
5454+): TabsState | null {
5555+ if (key.name === "tab" && key.ctrl) return nextTab(state, tabs);
5656+ if (key.name === "backtab" && key.ctrl) return prevTab(state, tabs);
5757+ // Numeric shortcuts: 1..9 select tabs 1..9.
5858+ if (key.char && /^[1-9]$/.test(key.char) && !key.ctrl && !key.alt) {
5959+ const idx = parseInt(key.char, 10) - 1;
6060+ const t = tabs[idx];
6161+ if (t) return selectTab(state, t.id);
6262+ }
6363+ return null;
6464+}
6565+6666+/** Route a left-click within the tab bar's rect to whichever tab was
6767+ * clicked. Returns the new state (or the same one if the click was
6868+ * outside the bar or on a gap). Computes tab widths on the fly from the
6969+ * same rendering rule renderTabs uses — consumers pass the row's rect
7070+ * (from layout) and the tabs array. */
7171+export function handleTabsMouse<T>(
7272+ state: TabsState,
7373+ tabs: readonly TabDef<T>[],
7474+ event: MouseEvent,
7575+ rect: Rect,
7676+): TabsState | null {
7777+ if (event.action !== "press" || event.button !== "left") return null;
7878+ if (event.y !== rect.y) return null;
7979+ // renderTabs layout: each tab is "[ Label ]" (active) or " Label "
8080+ // (inactive), joined by " " separators. Widths: "[ L ]" = L.length + 4.
8181+ let cursor = rect.x;
8282+ for (let i = 0; i < tabs.length; i++) {
8383+ if (i > 0) cursor += 2; // " " separator
8484+ const w = tabs[i].label.length + 4;
8585+ if (event.x >= cursor && event.x < cursor + w) {
8686+ return selectTab(state, tabs[i].id);
8787+ }
8888+ cursor += w;
8989+ }
9090+ return null;
9191+}
9292+9393+/** Render a tabs row: `[Active] inactive1 inactive2`. Active tab is bold
9494+ * with brackets; inactive tabs are dim. */
9595+export function renderTabs<T>(state: TabsState, tabs: readonly TabDef<T>[]): UINode {
9696+ const parts: UINode[] = [];
9797+ tabs.forEach((t, i) => {
9898+ if (i > 0) parts.push(text(" ", "muted"));
9999+ if (t.id === state.activeId) {
100100+ parts.push(text(`[ ${t.label} ]`, "accent", { bold: true }));
101101+ } else {
102102+ parts.push(text(` ${t.label} `, "muted", { dim: true }));
103103+ }
104104+ });
105105+ return row(...parts);
106106+}
+199
src/tui/widgets/text-area.ts
···11+// Multi-line text input widget (a.k.a. text area / composer).
22+//
33+// State-first: the consumer owns `TextAreaState`. Key handling is a pure
44+// function returning a new state or `null` when the key should escape the
55+// widget (unknown, or `ctrl+enter` — used by most shells to mean "submit"
66+// from a multi-line prompt).
77+//
88+// Model: `lines: string[]` (each is the logical line; not yet visually
99+// wrapped). `row`/`col` index into lines + character offset.
1010+// Rendering walks each line and inserts a block cursor at the active cell
1111+// for the focused row.
1212+1313+import { text, row as uiRow } from "../builders.ts";
1414+import type { ColumnNode } from "../nodes.ts";
1515+import type { UINode } from "../nodes.ts";
1616+import type { KeyEvent } from "../input.ts";
1717+import { prevWordBoundary, nextWordBoundary } from "./form.ts";
1818+1919+export interface TextAreaState {
2020+ lines: string[];
2121+ /** 0-based line index of the cursor. Always valid: >= 0 and < lines.length. */
2222+ row: number;
2323+ /** 0-based column offset into lines[row]. 0 ≤ col ≤ lines[row].length. */
2424+ col: number;
2525+}
2626+2727+export function createTextArea(initial = ""): TextAreaState {
2828+ const lines = initial.length === 0 ? [""] : initial.split("\n");
2929+ return { lines, row: 0, col: 0 };
3030+}
3131+3232+export function textAreaToString(state: TextAreaState): string {
3333+ return state.lines.join("\n");
3434+}
3535+3636+/** Apply one key to the text area. Returns a new state when consumed, else
3737+ * `null` — consumers interpret `null` as "use this key for something else"
3838+ * (e.g. submit the form, cancel, etc). Specifically:
3939+ * - tab / backtab / escape always return null (owned by the outer form)
4040+ * - ctrl+return returns null (conventional "submit" from multi-line input)
4141+ * - all other editing keys return a new state */
4242+export function applyTextAreaKey(state: TextAreaState, key: KeyEvent): TextAreaState | null {
4343+ // Reserved outer-form keys.
4444+ if (key.name === "tab" || key.name === "backtab" || key.name === "escape") return null;
4545+ if (key.name === "return" && key.ctrl) return null;
4646+4747+ // Newline.
4848+ if (key.name === "return") {
4949+ const curLine = state.lines[state.row] ?? "";
5050+ const before = curLine.slice(0, state.col);
5151+ const after = curLine.slice(state.col);
5252+ const nextLines = [
5353+ ...state.lines.slice(0, state.row),
5454+ before,
5555+ after,
5656+ ...state.lines.slice(state.row + 1),
5757+ ];
5858+ return { lines: nextLines, row: state.row + 1, col: 0 };
5959+ }
6060+6161+ // Deletions.
6262+ if (key.name === "backspace") {
6363+ // At col 0 of a non-first line: merge up.
6464+ if (state.col === 0) {
6565+ if (state.row === 0) return state;
6666+ const prev = state.lines[state.row - 1];
6767+ const cur = state.lines[state.row];
6868+ const mergedCol = prev.length;
6969+ const merged = prev + cur;
7070+ const nextLines = [
7171+ ...state.lines.slice(0, state.row - 1),
7272+ merged,
7373+ ...state.lines.slice(state.row + 1),
7474+ ];
7575+ return { lines: nextLines, row: state.row - 1, col: mergedCol };
7676+ }
7777+ // Mid-line backspace.
7878+ const curLine = state.lines[state.row];
7979+ const nextLine = curLine.slice(0, state.col - 1) + curLine.slice(state.col);
8080+ return {
8181+ lines: state.lines.map((l, i) => (i === state.row ? nextLine : l)),
8282+ row: state.row,
8383+ col: state.col - 1,
8484+ };
8585+ }
8686+ if (key.name === "delete") {
8787+ const curLine = state.lines[state.row];
8888+ // At end of line: merge with next line.
8989+ if (state.col >= curLine.length) {
9090+ if (state.row === state.lines.length - 1) return state;
9191+ const merged = curLine + state.lines[state.row + 1];
9292+ const nextLines = [
9393+ ...state.lines.slice(0, state.row),
9494+ merged,
9595+ ...state.lines.slice(state.row + 2),
9696+ ];
9797+ return { lines: nextLines, row: state.row, col: state.col };
9898+ }
9999+ // Mid-line delete.
100100+ const nextLine = curLine.slice(0, state.col) + curLine.slice(state.col + 1);
101101+ return {
102102+ lines: state.lines.map((l, i) => (i === state.row ? nextLine : l)),
103103+ row: state.row,
104104+ col: state.col,
105105+ };
106106+ }
107107+108108+ // Cursor movement.
109109+ if (key.name === "left") {
110110+ if (key.alt) {
111111+ const cur = state.lines[state.row];
112112+ if (state.col > 0) return { ...state, col: prevWordBoundary(cur, state.col) };
113113+ if (state.row > 0) {
114114+ const prev = state.lines[state.row - 1];
115115+ return { ...state, row: state.row - 1, col: prev.length };
116116+ }
117117+ return state;
118118+ }
119119+ if (state.col > 0) return { ...state, col: state.col - 1 };
120120+ if (state.row > 0) {
121121+ const prev = state.lines[state.row - 1];
122122+ return { ...state, row: state.row - 1, col: prev.length };
123123+ }
124124+ return state;
125125+ }
126126+ if (key.name === "right") {
127127+ const cur = state.lines[state.row];
128128+ if (key.alt) {
129129+ if (state.col < cur.length) return { ...state, col: nextWordBoundary(cur, state.col) };
130130+ if (state.row < state.lines.length - 1) {
131131+ return { ...state, row: state.row + 1, col: 0 };
132132+ }
133133+ return state;
134134+ }
135135+ if (state.col < cur.length) return { ...state, col: state.col + 1 };
136136+ if (state.row < state.lines.length - 1) {
137137+ return { ...state, row: state.row + 1, col: 0 };
138138+ }
139139+ return state;
140140+ }
141141+ if (key.alt && key.char === "b") {
142142+ const cur = state.lines[state.row];
143143+ return { ...state, col: prevWordBoundary(cur, state.col) };
144144+ }
145145+ if (key.alt && key.char === "f") {
146146+ const cur = state.lines[state.row];
147147+ return { ...state, col: nextWordBoundary(cur, state.col) };
148148+ }
149149+ if (key.name === "up") {
150150+ if (state.row === 0) return state;
151151+ const prev = state.lines[state.row - 1];
152152+ return { ...state, row: state.row - 1, col: Math.min(state.col, prev.length) };
153153+ }
154154+ if (key.name === "down") {
155155+ if (state.row === state.lines.length - 1) return state;
156156+ const next = state.lines[state.row + 1];
157157+ return { ...state, row: state.row + 1, col: Math.min(state.col, next.length) };
158158+ }
159159+ if (key.name === "home" || (key.name === "a" && key.ctrl)) {
160160+ return { ...state, col: 0 };
161161+ }
162162+ if (key.name === "end" || (key.name === "e" && key.ctrl)) {
163163+ return { ...state, col: state.lines[state.row].length };
164164+ }
165165+166166+ // Printable character.
167167+ if (key.char && !key.ctrl && !key.alt) {
168168+ const cur = state.lines[state.row];
169169+ const next = cur.slice(0, state.col) + key.char + cur.slice(state.col);
170170+ return {
171171+ lines: state.lines.map((l, i) => (i === state.row ? next : l)),
172172+ row: state.row,
173173+ col: state.col + key.char.length,
174174+ };
175175+ }
176176+177177+ return null;
178178+}
179179+180180+/** Render the text area as a column of rows. When `active`, the focused row
181181+ * paints an inverse-styled cell at the cursor position — the character
182182+ * UNDER the cursor gets its fg/bg swapped so neighbors don't shift. */
183183+export function renderTextArea(state: TextAreaState, active: boolean): UINode {
184184+ const children: UINode[] = state.lines.map((line, i) => {
185185+ if (!active || i !== state.row) {
186186+ return uiRow(text(line.length === 0 ? " " : line, "primary"));
187187+ }
188188+ const before = line.slice(0, state.col);
189189+ const under = line.slice(state.col, state.col + 1) || " ";
190190+ const after = line.slice(state.col + 1);
191191+ return uiRow(
192192+ text(before, "primary"),
193193+ text(under, "primary", { inverse: true }),
194194+ text(after, "primary"),
195195+ );
196196+ });
197197+ const node: ColumnNode = { type: "column", children };
198198+ return node;
199199+}
+103
src/tui/widgets/toast.ts
···11+// Toast / notification queue. Ephemeral banners that auto-dismiss after
22+// a timeout. Safe for non-blocking feedback ("saved", "sync failed") —
33+// not a replacement for confirm modals or dialogs.
44+//
55+// Queue is pure: `pushToast` / `pruneExpired` return new queues, never
66+// mutate. The consumer owns a `signal<ToastQueue>` and an effect that
77+// schedules `pruneExpired` via setInterval when the queue is non-empty.
88+99+import { column, row, text } from "../builders.ts";
1010+import type { UINode, ColumnNode, Color } from "../nodes.ts";
1111+1212+export type ToastKind = "info" | "success" | "warn" | "error";
1313+1414+export interface Toast {
1515+ id: string;
1616+ kind: ToastKind;
1717+ text: string;
1818+ /** Epoch ms when this toast should be dropped. */
1919+ expiresAt: number;
2020+}
2121+2222+export interface ToastQueue {
2323+ toasts: Toast[];
2424+}
2525+2626+let nextToastId = 0;
2727+function genToastId(): string {
2828+ nextToastId += 1;
2929+ return `toast-${nextToastId}`;
3030+}
3131+3232+export function createToastQueue(): ToastQueue {
3333+ return { toasts: [] };
3434+}
3535+3636+export interface PushToastOptions {
3737+ kind?: ToastKind;
3838+ /** Milliseconds the toast is visible. Default 3000. */
3939+ durationMs?: number;
4040+ now?: number;
4141+}
4242+4343+export function pushToast(
4444+ queue: ToastQueue,
4545+ message: string,
4646+ opts: PushToastOptions = {},
4747+): ToastQueue {
4848+ const now = opts.now ?? Date.now();
4949+ const toast: Toast = {
5050+ id: genToastId(),
5151+ kind: opts.kind ?? "info",
5252+ text: message,
5353+ expiresAt: now + (opts.durationMs ?? 3000),
5454+ };
5555+ return { toasts: [...queue.toasts, toast] };
5656+}
5757+5858+/** Drop toasts whose `expiresAt` has passed. Call from a setInterval. */
5959+export function pruneExpired(queue: ToastQueue, now?: number): ToastQueue {
6060+ const t = now ?? Date.now();
6161+ const kept = queue.toasts.filter(x => x.expiresAt > t);
6262+ if (kept.length === queue.toasts.length) return queue;
6363+ return { toasts: kept };
6464+}
6565+6666+export function dismissToast(queue: ToastQueue, id: string): ToastQueue {
6767+ return { toasts: queue.toasts.filter(t => t.id !== id) };
6868+}
6969+7070+function kindColor(kind: ToastKind): Color {
7171+ switch (kind) {
7272+ case "success": return "ok";
7373+ case "warn": return "warn";
7474+ case "error": return "error";
7575+ default: return "accent";
7676+ }
7777+}
7878+7979+function kindGlyph(kind: ToastKind): string {
8080+ switch (kind) {
8181+ case "success": return "\u2713"; // check
8282+ case "warn": return "\u26a0"; // warning sign
8383+ case "error": return "\u2717"; // ballot x
8484+ default: return "\u25cf"; // bullet
8585+ }
8686+}
8787+8888+/** Render the queue as a column of rows. Consumers typically overlay this
8989+ * in a corner of their app. */
9090+export function renderToasts(queue: ToastQueue): UINode {
9191+ if (queue.toasts.length === 0) {
9292+ const empty: ColumnNode = { type: "column", children: [] };
9393+ return empty;
9494+ }
9595+ const children: UINode[] = queue.toasts.map(t =>
9696+ row(
9797+ text(` ${kindGlyph(t.kind)} `, kindColor(t.kind), { bold: true }),
9898+ text(t.text, "primary"),
9999+ ),
100100+ );
101101+ const node: ColumnNode = { type: "column", children };
102102+ return node;
103103+}
+94
src/tui/widgets/toolbar.ts
···11+// Toolbar — a horizontal strip of labeled actions with highlighted hotkeys.
22+// Useful as a compact keybind legend or as a "buttons" row for mouse-free
33+// interfaces. The `[X]abel` convention makes the bound letter unambiguous
44+// without needing a separate legend.
55+66+import { row, text } from "../builders.ts";
77+import type { UINode, Color } from "../nodes.ts";
88+99+export interface ToolbarItem {
1010+ /** The key bound to this action. One character — letter or digit. */
1111+ key: string;
1212+ /** Label around the key. Leave {key} placeholder or the widget will
1313+ * prepend "[key] " to `label`. See `format` option. */
1414+ label: string;
1515+ /** Optional one-line hint shown dim after the label. */
1616+ hint?: string;
1717+ /** Mark this item currently active (highlighted). */
1818+ active?: boolean;
1919+ /** Disable visually (dim). Key-dispatching is up to the caller. */
2020+ disabled?: boolean;
2121+}
2222+2323+export interface ToolbarOptions {
2424+ /** Separator between items. Defaults to " " (two spaces). */
2525+ separator?: string;
2626+ /** Render format:
2727+ * - "bracket" (default): `[N]ew [S]ave`
2828+ * - "inline": first occurrence of `key` inside `label` is highlighted.
2929+ * Useful when the label naturally contains the key:
3030+ * { key: "n", label: "new" } -> "[n]ew". */
3131+ format?: "bracket" | "inline";
3232+ /** Accent color for active items. Default "accent". */
3333+ activeColor?: Color;
3434+}
3535+3636+function bracketize(item: ToolbarItem): UINode[] {
3737+ const baseColor: Color = item.active ? "accent" : "primary";
3838+ const hintColor: Color = item.disabled ? "muted" : "muted";
3939+ const cell: UINode[] = [
4040+ text("[", baseColor, { bold: item.active }),
4141+ text(item.key.toUpperCase(), baseColor, { bold: true }),
4242+ text("]", baseColor, { bold: item.active }),
4343+ text(item.label, item.disabled ? "muted" : baseColor, { bold: item.active, dim: item.disabled }),
4444+ ];
4545+ if (item.hint) {
4646+ cell.push(text(` ${item.hint}`, hintColor, { dim: true }));
4747+ }
4848+ return cell;
4949+}
5050+5151+function inlineize(item: ToolbarItem): UINode[] {
5252+ const baseColor: Color = item.active ? "accent" : "primary";
5353+ const keyIdx = item.label.toLowerCase().indexOf(item.key.toLowerCase());
5454+ if (keyIdx < 0) return bracketize(item);
5555+ const before = item.label.slice(0, keyIdx);
5656+ const kChar = item.label[keyIdx];
5757+ const after = item.label.slice(keyIdx + 1);
5858+ const cell: UINode[] = [
5959+ text(before, item.disabled ? "muted" : baseColor, { dim: item.disabled }),
6060+ text(kChar, item.active ? "accent" : "accent", { bold: true, dim: item.disabled }),
6161+ text(after, item.disabled ? "muted" : baseColor, { dim: item.disabled }),
6262+ ];
6363+ if (item.hint) cell.push(text(` ${item.hint}`, "muted", { dim: true }));
6464+ return cell;
6565+}
6666+6767+/** Render the toolbar as a single row. */
6868+export function toolbar(items: readonly ToolbarItem[], opts: ToolbarOptions = {}): UINode {
6969+ const separator = opts.separator ?? " ";
7070+ const format = opts.format ?? "bracket";
7171+ const children: UINode[] = [];
7272+ items.forEach((item, i) => {
7373+ if (i > 0) children.push(text(separator, "muted"));
7474+ const cell = format === "inline" ? inlineize(item) : bracketize(item);
7575+ children.push(...cell);
7676+ });
7777+ return row(...children);
7878+}
7979+8080+/** Check whether a KeyEvent-like `char` matches any non-disabled item's
8181+ * key. The caller wires the action via their own `switch (key.char)` — this
8282+ * helper is just for feature-detection / highlight decisions. */
8383+export function toolbarItemFor(
8484+ items: readonly ToolbarItem[],
8585+ char: string | undefined,
8686+): ToolbarItem | null {
8787+ if (!char) return null;
8888+ const c = char.toLowerCase();
8989+ for (const it of items) {
9090+ if (it.disabled) continue;
9191+ if (it.key.toLowerCase() === c) return it;
9292+ }
9393+ return null;
9494+}
+145
src/tui/widgets/tree.ts
···11+// Tree view widget — keyboard-navigable, expand/collapse, depth-aware.
22+//
33+// Design: state-first. `flattenTree` takes the tree + an expanded-set and
44+// returns a flat list of visible rows (each carrying a depth). The consumer
55+// renders those rows however it likes. Selection and expansion state are
66+// just signals you own.
77+//
88+// Promoted from local helpers in demos/reminders and demos/file-browser.
99+// Both relied on a flat visible-rows shape; this is the shared version.
1010+1111+import type { KeyEvent } from "../input.ts";
1212+1313+export interface TreeNode<T> {
1414+ /** Stable, unique id within the tree. Used for selection + expansion keys. */
1515+ id: string;
1616+ label: string;
1717+ data: T;
1818+ children?: TreeNode<T>[];
1919+}
2020+2121+export interface TreeRow<T> {
2222+ node: TreeNode<T>;
2323+ depth: number;
2424+ hasChildren: boolean;
2525+ expanded: boolean;
2626+}
2727+2828+export interface TreeState {
2929+ /** Ids of nodes whose children are shown. */
3030+ expanded: Set<string>;
3131+ /** Currently highlighted node, or null when the tree is empty. */
3232+ selectedId: string | null;
3333+}
3434+3535+export function createTreeState(): TreeState {
3636+ return { expanded: new Set(), selectedId: null };
3737+}
3838+3939+/** Depth-first walk of the tree, skipping the children of any node that is
4040+ * not in `expanded`. The resulting list is what the renderer draws. */
4141+export function flattenTree<T>(roots: TreeNode<T>[], expanded: Set<string>): TreeRow<T>[] {
4242+ const out: TreeRow<T>[] = [];
4343+ const walk = (nodes: TreeNode<T>[], depth: number) => {
4444+ for (const node of nodes) {
4545+ const hasChildren = !!node.children && node.children.length > 0;
4646+ const isExpanded = hasChildren && expanded.has(node.id);
4747+ out.push({ node, depth, hasChildren, expanded: isExpanded });
4848+ if (isExpanded && node.children) walk(node.children, depth + 1);
4949+ }
5050+ };
5151+ walk(roots, 0);
5252+ return out;
5353+}
5454+5555+export function toggleExpanded(state: TreeState, id: string): TreeState {
5656+ const next = new Set(state.expanded);
5757+ if (next.has(id)) next.delete(id);
5858+ else next.add(id);
5959+ return { ...state, expanded: next };
6060+}
6161+6262+export function selectById<T>(state: TreeState, id: string | null): TreeState {
6363+ return { ...state, selectedId: id };
6464+}
6565+6666+/** Move selection to the row `delta` away from the currently selected row.
6767+ * Clamps at the ends. Returns the same state reference if nothing changes. */
6868+export function moveSelection<T>(
6969+ state: TreeState,
7070+ rows: TreeRow<T>[],
7171+ delta: number,
7272+): TreeState {
7373+ if (rows.length === 0) return state;
7474+ const idx = state.selectedId
7575+ ? rows.findIndex(r => r.node.id === state.selectedId)
7676+ : -1;
7777+ // Nothing selected yet — any arrow key lands on the first row, rather than
7878+ // skipping past it. Deliberate: it's what users expect the first press to do.
7979+ if (idx === -1) return { ...state, selectedId: rows[0].node.id };
8080+ const next = Math.max(0, Math.min(rows.length - 1, idx + delta));
8181+ if (next === idx) return state;
8282+ return { ...state, selectedId: rows[next].node.id };
8383+}
8484+8585+/** Opinionated default key bindings — up/down moves, left/right collapses or
8686+ * expands, enter toggles expansion on folders. Returns the new state (or the
8787+ * same reference if the key was not consumed). The caller receives an extra
8888+ * hint about what happened via the returned `action` field. */
8989+export interface HandleKeyResult<T> {
9090+ state: TreeState;
9191+ /** What happened — useful for the consumer to wire side effects (e.g.,
9292+ * "activate" when the user hits enter on a leaf). */
9393+ action: "moved" | "expanded" | "collapsed" | "activated" | "none";
9494+ /** The row the action applied to, if any. */
9595+ row: TreeRow<T> | null;
9696+}
9797+9898+export function handleTreeKey<T>(
9999+ state: TreeState,
100100+ rows: TreeRow<T>[],
101101+ key: KeyEvent,
102102+): HandleKeyResult<T> {
103103+ const selectedRow =
104104+ state.selectedId != null
105105+ ? rows.find(r => r.node.id === state.selectedId) ?? null
106106+ : null;
107107+108108+ if (key.name === "up") {
109109+ return { state: moveSelection(state, rows, -1), action: "moved", row: null };
110110+ }
111111+ if (key.name === "down") {
112112+ return { state: moveSelection(state, rows, 1), action: "moved", row: null };
113113+ }
114114+ if (!selectedRow) {
115115+ return { state, action: "none", row: null };
116116+ }
117117+ if (key.name === "right") {
118118+ if (selectedRow.hasChildren && !selectedRow.expanded) {
119119+ return { state: toggleExpanded(state, selectedRow.node.id), action: "expanded", row: selectedRow };
120120+ }
121121+ return { state, action: "none", row: selectedRow };
122122+ }
123123+ if (key.name === "left") {
124124+ if (selectedRow.hasChildren && selectedRow.expanded) {
125125+ return { state: toggleExpanded(state, selectedRow.node.id), action: "collapsed", row: selectedRow };
126126+ }
127127+ return { state, action: "none", row: selectedRow };
128128+ }
129129+ if (key.name === "return") {
130130+ if (selectedRow.hasChildren) {
131131+ return { state: toggleExpanded(state, selectedRow.node.id),
132132+ action: selectedRow.expanded ? "collapsed" : "expanded",
133133+ row: selectedRow };
134134+ }
135135+ return { state, action: "activated", row: selectedRow };
136136+ }
137137+ return { state, action: "none", row: null };
138138+}
139139+140140+/** Glyph for the expand/collapse indicator in front of a row.
141141+ * ▸ collapsed folder, ▾ expanded folder, empty string for leaves. */
142142+export function treeGlyph<T>(row: TreeRow<T>): string {
143143+ if (!row.hasChildren) return " ";
144144+ return row.expanded ? "\u25be " : "\u25b8 ";
145145+}
+158
src/tui/widgets/virtual-list.ts
···11+// Virtualized list — keyboard-navigable list that only renders a visible
22+// slice. For datasets where rendering every row would be wasteful (email
33+// inboxes, RSS archives, long logs). The consumer still gives us `total`
44+// and an item-by-index callback; we never look at the full array.
55+//
66+// State-first: the caller owns `VirtualListState`. `moveVirtualSelection`
77+// and arrow-key handlers return new states.
88+99+import { column, row, text } from "../builders.ts";
1010+import type { UINode, ColumnNode, Rect } from "../nodes.ts";
1111+import type { KeyEvent, MouseEvent } from "../input.ts";
1212+1313+export interface VirtualListState {
1414+ total: number;
1515+ selectedIndex: number;
1616+ /** First visible index (scroll offset). */
1717+ offset: number;
1818+ /** How many rows the viewport shows. The caller drives this from layout. */
1919+ viewport: number;
2020+}
2121+2222+export interface VirtualWindow {
2323+ /** Inclusive first index to render. */
2424+ start: number;
2525+ /** Exclusive last index to render. */
2626+ end: number;
2727+}
2828+2929+export function createVirtualListState(total: number, viewport: number): VirtualListState {
3030+ return {
3131+ total,
3232+ selectedIndex: total > 0 ? 0 : -1,
3333+ offset: 0,
3434+ viewport: Math.max(1, viewport),
3535+ };
3636+}
3737+3838+/** Re-normalise state after total/viewport change. Keeps the selection in
3939+ * view and within bounds. */
4040+export function clampVirtual(state: VirtualListState): VirtualListState {
4141+ const total = Math.max(0, state.total);
4242+ const viewport = Math.max(1, state.viewport);
4343+ if (total === 0) {
4444+ return { total: 0, selectedIndex: -1, offset: 0, viewport };
4545+ }
4646+ const sel = Math.max(0, Math.min(total - 1, state.selectedIndex));
4747+ const maxOffset = Math.max(0, total - viewport);
4848+ let offset = Math.max(0, Math.min(maxOffset, state.offset));
4949+ if (sel < offset) offset = sel;
5050+ if (sel >= offset + viewport) offset = sel - viewport + 1;
5151+ return { total, selectedIndex: sel, offset, viewport };
5252+}
5353+5454+/** Compute the window of indexes that should be drawn right now. */
5555+export function virtualWindow(state: VirtualListState): VirtualWindow {
5656+ const s = clampVirtual(state);
5757+ return { start: s.offset, end: Math.min(s.total, s.offset + s.viewport) };
5858+}
5959+6060+/** Move selection by `delta`, adjusting offset to keep the selection in
6161+ * the viewport. Returns the same reference when nothing changed. */
6262+export function moveVirtualSelection(
6363+ state: VirtualListState,
6464+ delta: number,
6565+): VirtualListState {
6666+ if (state.total === 0) return state;
6767+ const target = Math.max(0, Math.min(state.total - 1, state.selectedIndex + delta));
6868+ if (target === state.selectedIndex) return state;
6969+ return clampVirtual({ ...state, selectedIndex: target });
7070+}
7171+7272+export function pageVirtual(state: VirtualListState, delta: number): VirtualListState {
7373+ return moveVirtualSelection(state, delta * state.viewport);
7474+}
7575+7676+export function jumpVirtualToStart(state: VirtualListState): VirtualListState {
7777+ return clampVirtual({ ...state, selectedIndex: 0 });
7878+}
7979+8080+export function jumpVirtualToEnd(state: VirtualListState): VirtualListState {
8181+ return clampVirtual({ ...state, selectedIndex: Math.max(0, state.total - 1) });
8282+}
8383+8484+export interface HandleVirtualKeyResult {
8585+ state: VirtualListState;
8686+ /** What the consumer might want to react to. "activate" = the user hit
8787+ * Enter on the selected row; the consumer should open it. */
8888+ action: "moved" | "activate" | "none";
8989+}
9090+9191+export interface HandleVirtualMouseResult {
9292+ state: VirtualListState;
9393+ /** "moved" = scroll or selection changed. "activate" = click picked a
9494+ * specific row the caller should open. "none" = event outside the list's
9595+ * rendered rect, or on the empty-state row. */
9696+ action: "moved" | "activate" | "none";
9797+}
9898+9999+/** Route a mouse event to the list given its rendered rect (from the
100100+ * layout pass). Handles click-to-select and scroll-wheel-to-scroll. */
101101+export function handleVirtualMouse(
102102+ state: VirtualListState,
103103+ event: MouseEvent,
104104+ rect: Rect,
105105+): HandleVirtualMouseResult {
106106+ const inside = event.x >= rect.x && event.x < rect.x + rect.width
107107+ && event.y >= rect.y && event.y < rect.y + rect.height;
108108+ if (!inside) return { state, action: "none" };
109109+110110+ if (event.action === "scrollUp") {
111111+ return { state: moveVirtualSelection(state, -3), action: "moved" };
112112+ }
113113+ if (event.action === "scrollDown") {
114114+ return { state: moveVirtualSelection(state, 3), action: "moved" };
115115+ }
116116+ if (event.action === "press" && event.button === "left") {
117117+ const rowIdx = (event.y - rect.y) + state.offset;
118118+ if (rowIdx < 0 || rowIdx >= state.total) return { state, action: "none" };
119119+ return {
120120+ state: clampVirtual({ ...state, selectedIndex: rowIdx }),
121121+ action: "activate",
122122+ };
123123+ }
124124+ return { state, action: "none" };
125125+}
126126+127127+/** Default key bindings: up/down, pageup/pagedown, home/end, return. */
128128+export function handleVirtualKey(state: VirtualListState, key: KeyEvent): HandleVirtualKeyResult {
129129+ switch (key.name) {
130130+ case "up": return { state: moveVirtualSelection(state, -1), action: "moved" };
131131+ case "down": return { state: moveVirtualSelection(state, 1), action: "moved" };
132132+ case "pageup": return { state: pageVirtual(state, -1), action: "moved" };
133133+ case "pagedown": return { state: pageVirtual(state, 1), action: "moved" };
134134+ case "home": return { state: jumpVirtualToStart(state), action: "moved" };
135135+ case "end": return { state: jumpVirtualToEnd(state), action: "moved" };
136136+ case "return": return { state, action: "activate" };
137137+ default: return { state, action: "none" };
138138+ }
139139+}
140140+141141+/** Render a column with one row per visible index. `renderItem(index, selected)`
142142+ * returns the UINode for that row. Never called for indexes outside the
143143+ * window — so this scales to arbitrary `total`. */
144144+export function renderVirtualList(
145145+ state: VirtualListState,
146146+ renderItem: (index: number, selected: boolean) => UINode,
147147+): UINode {
148148+ const win = virtualWindow(state);
149149+ const children: UINode[] = [];
150150+ for (let i = win.start; i < win.end; i++) {
151151+ children.push(renderItem(i, i === state.selectedIndex));
152152+ }
153153+ if (children.length === 0) {
154154+ children.push(row(text("(empty)", "muted", { dim: true })));
155155+ }
156156+ const node: ColumnNode = { type: "column", children };
157157+ return node;
158158+}