From 4c3868be514da277fd68708cf383f578cf1e2ac6 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Thu, 23 Jul 2026 01:03:09 -0500 Subject: [PATCH 1/7] progressive inputs through to event iterators --- examples/input/package.json | 10 +- examples/input/ui/{raw.ts => 1-raw.ts} | 27 ++--- examples/input/ui/2-parser.ts | 89 ++++++++++++++++ examples/input/ui/3-signals.ts | 92 ++++++++++++++++ examples/input/ui/4-modes.ts | 111 +++++++++++++++++++ examples/input/ui/5-stream.ts | 142 +++++++++++++++++++++++++ examples/input/ui/helper.ts | 9 ++ pnpm-lock.yaml | 14 +++ pnpm-workspace.yaml | 1 + 9 files changed, 476 insertions(+), 19 deletions(-) rename examples/input/ui/{raw.ts => 1-raw.ts} (71%) create mode 100644 examples/input/ui/2-parser.ts create mode 100644 examples/input/ui/3-signals.ts create mode 100644 examples/input/ui/4-modes.ts create mode 100644 examples/input/ui/5-stream.ts create mode 100644 examples/input/ui/helper.ts diff --git a/examples/input/package.json b/examples/input/package.json index 3c5c7b6..ff5db1e 100644 --- a/examples/input/package.json +++ b/examples/input/package.json @@ -3,10 +3,16 @@ "private": true, "type": "module", "exports": { - "./ui/raw": "./ui/raw.ts", + "./1-raw": "./ui/1-raw.ts", + "./2-parser": "./ui/2-parser.ts", + "./3-signals": "./ui/3-signals.ts", + "./4-modes": "./ui/4-modes.ts", + "./5-stream": "./ui/5-stream.ts", "./package.json": "./package.json" }, "dependencies": { - "@clack/ui": "catalog:" + "@bomb.sh/tty": "catalog:", + "@clack/ui": "catalog:", + "alien-signals": "catalog:" } } diff --git a/examples/input/ui/raw.ts b/examples/input/ui/1-raw.ts similarity index 71% rename from examples/input/ui/raw.ts rename to examples/input/ui/1-raw.ts index ce36992..aa0bf8f 100644 --- a/examples/input/ui/raw.ts +++ b/examples/input/ui/1-raw.ts @@ -1,16 +1,8 @@ -import { box, text, createRoot, rgba } from '@clack/ui'; - -const cyan = rgba(0, 205, 205); -const white = rgba(229, 229, 229); -const gray = rgba(127, 127, 127); -const blue = rgba(0, 0, 238); -const yellow = rgba(255, 255, 0); -const red = rgba(255, 80, 80); +import { box, text, createRoot } from '@clack/ui'; +import { cyan, white, gray, blue, yellow, red } from './helper.ts'; const root = await createRoot(); -// a raw variable which we cannot subscribe to -// so we have to re-render on every input event let inputBuffer = ''; let lastKey = ''; @@ -21,7 +13,7 @@ function render(): void { layout: { direction: 'ttb', gap: 1, padding: { top: 1, bottom: 1, left: 2, right: 2 } }, border: { color: blue, top: 1, right: 1, bottom: 1, left: 1 }, }, - text({ color: cyan }, 'Level 1 — Raw String Input'), + text({ color: cyan }, 'Raw String Input'), text({ color: gray }, 'Uses data.toString() — arrow keys produce garbage'), box( { layout: { direction: 'ltr', gap: 0 } }, @@ -38,27 +30,27 @@ function render(): void { ); } +// Initial render before we start reading input. render(); +// Raw mode pipes all bytes from stdin on 'data' process.stdin.setRawMode(true); -process.stdin.resume(); -// listen for input events and call render() on every event process.stdin.on('data', (data: Buffer) => { + // data.toString() allows observation of all input, + // but it doesn't interpret the terminal protocol so + // we need to manually handle special keys. See + // ./2-parser.ts for a more robust solution. const str = data.toString(); if (str === '\x03') { - // Ctrl+C process.exit(0); } if (str === '\x7f' || str === '\b') { - // DEL or BS inputBuffer = inputBuffer.slice(0, -1); lastKey = 'Backspace'; } else if (str === '\r' || str === '\n') { - // CR or LF inputBuffer = ''; lastKey = 'Enter'; } else if (str.startsWith('\x1b')) { - // ESC — start of ANSI escape sequence lastKey = `Raw escape: ${str .split('') .map((c) => `0x${c.charCodeAt(0).toString(16)}`) @@ -68,5 +60,6 @@ process.stdin.on('data', (data: Buffer) => { inputBuffer += str; lastKey = `Char: ${JSON.stringify(str)}`; } + // Re-render after every chunk input render(); }); diff --git a/examples/input/ui/2-parser.ts b/examples/input/ui/2-parser.ts new file mode 100644 index 0000000..d874240 --- /dev/null +++ b/examples/input/ui/2-parser.ts @@ -0,0 +1,89 @@ +import { box, text, createRoot } from '@clack/ui'; +import { createInput, type InputEvent } from '@bomb.sh/tty'; +import { cyan, white, gray, blue, yellow, green } from './helper.ts'; + +const root = await createRoot(); +// createInput() returns a parser that decodes raw VT/ANSI bytes +// into structured InputEvents (keydown, mousemove, cursor, etc.). +const input = await createInput(); + +let inputBuffer = ''; +let lastKey = ''; + +function render(): void { + root.render( + box( + { + layout: { direction: 'ttb', gap: 1, padding: { top: 1, bottom: 1, left: 2, right: 2 } }, + border: { color: blue, top: 1, right: 1, bottom: 1, left: 1 }, + }, + text({ color: cyan }, 'Input Parser'), + text({ color: gray }, 'Uses createInput() — arrow keys parsed correctly'), + box( + { layout: { direction: 'ltr', gap: 0 } }, + text({ color: yellow }, '> '), + text({ color: white }, inputBuffer || '(type something)'), + ), + text({ color: gray }, lastKey ? `Last: ${lastKey}` : ''), + text({ color: green }, 'Arrow keys, Escape, and special keys all work'), + text({ color: gray }, 'Press Ctrl+C to exit'), + ), + ); +} + +// Paint the initial frame before we start reading input. +render(); +// This function handles the parsed input and is a separate function as we need to call it from two places. +function processEvent( + event: InputEvent, + s: { inputBuffer: string; lastKey: string }, +): { inputBuffer: string; lastKey: string } { + if (event.type !== 'keydown') return s; + if (event.ctrl && event.code === 'c') process.exit(0); + if (event.code === 'Backspace') + return { inputBuffer: s.inputBuffer.slice(0, -1), lastKey: 'Backspace' }; + if (event.code === 'Enter') return { inputBuffer: '', lastKey: 'Enter' }; + if (event.code === 'Escape') return { ...s, lastKey: 'Escape' }; + if (event.code.startsWith('Arrow')) return { ...s, lastKey: event.code }; + if (event.text) + return { + inputBuffer: s.inputBuffer + event.text, + lastKey: `Char: ${JSON.stringify(event.text)}`, + }; + return s; +} + +// Accumulate state from events so we can call render once +function processEvents(events: InputEvent[]): void { + for (const event of events) { + const next = processEvent(event, { inputBuffer, lastKey }); + inputBuffer = next.inputBuffer; + lastKey = next.lastKey; + } +} + +process.stdin.setRawMode(true); +let timer: ReturnType; + +// Each data chunk is raw bytes from the terminal. `input.scan()` feeds them +// into the parser, which emits zero or more structured events plus a +// `pending` hint if an incomplete escape sequence was encountered. +process.stdin.on('data', (buf: Buffer) => { + clearTimeout(timer); + const { events, pending } = input.scan(new Uint8Array(buf)); + processEvents(events); + if (pending) { + // A partial escape sequence was split across chunks (e.g. arrow key bytes + // arriving in two data events). Wait for the remainder to arrive, flush it + // through the parser, then render once with all events processed. + timer = setTimeout(() => { + processEvents(input.scan().events); + render(); + }, pending.delay); + } else { + // This conditional render has becomes more complicated with scale however + // as we need to track state changes through multiple events and appropriately + // render once at the end. See ./3-signals.ts for a more robust solution. + render(); + } +}); diff --git a/examples/input/ui/3-signals.ts b/examples/input/ui/3-signals.ts new file mode 100644 index 0000000..710ae93 --- /dev/null +++ b/examples/input/ui/3-signals.ts @@ -0,0 +1,92 @@ +import { box, text, createRoot } from '@clack/ui'; +import { createInput, type InputEvent } from '@bomb.sh/tty'; +import { signal, effect } from 'alien-signals'; +import { cyan, white, gray, blue, yellow, green } from './helper.ts'; + +const root = await createRoot(); +const input = await createInput(); + +interface State { + inputBuffer: string; + lastKey: string; +} + +const state = signal({ inputBuffer: '', lastKey: '' }); + +// Same pure function as ./2-parser.ts +function processEvent(s: State, event: InputEvent): State { + if (event.type !== 'keydown') return s; + // calling process.exit() here is a bad idea because it doesn't give the caller + // a chance to clean up terminal modes. See ./4-modes.ts for a better approach. + if (event.ctrl && event.code === 'c') process.exit(0); + if (event.code === 'Backspace') { + return { ...s, inputBuffer: s.inputBuffer.slice(0, -1), lastKey: 'Backspace' }; + } + if (event.code === 'Enter') { + return { ...s, inputBuffer: '', lastKey: 'Enter' }; + } + if (event.code === 'Escape') { + return { ...s, lastKey: 'Escape' }; + } + if (event.code.startsWith('Arrow')) { + return { ...s, lastKey: event.code }; + } + if (event.text) { + return { + ...s, + inputBuffer: s.inputBuffer + event.text, + lastKey: `Char: ${JSON.stringify(event.text)}`, + }; + } + return s; +} + +// Process all events from this batch and update the state signal. If the data changed, +// effect() will automatically re-run the render callback. +function processEvents(events: InputEvent[]): void { + let current = state(); + for (const event of events) { + current = processEvent(current, event); + } + state(current); +} + +// Here our state changes automatically trigger a re-render via effect(). +effect(() => { + const s = state(); + root.render( + box( + { + layout: { direction: 'ttb', gap: 1, padding: { top: 1, bottom: 1, left: 2, right: 2 } }, + border: { color: blue, top: 1, right: 1, bottom: 1, left: 1 }, + }, + text({ color: cyan }, 'Reactive Signals'), + text({ color: gray }, 'Same processEvent, but effect() auto-renders on state change'), + box( + { layout: { direction: 'ltr', gap: 0 } }, + text({ color: yellow }, '> '), + text({ color: white }, s.inputBuffer || '(type something)'), + ), + text({ color: gray }, s.lastKey ? `Last: ${s.lastKey}` : ''), + text({ color: green }, 'Arrow keys, Escape, and special keys all work'), + text({ color: gray }, 'Press Ctrl+C to exit'), + ), + ); +}); + +process.stdin.setRawMode(true); +let timer: ReturnType; + +process.stdin.on('data', (buf: Buffer) => { + clearTimeout(timer); + const { events, pending } = input.scan(new Uint8Array(buf)); + // We can more confidently process the events here since effect() will handle re-rendering. + // This is a more robust solution than ./2-parser.ts as we don't need to worry about + // batching state changes and rendering at the right time. + processEvents(events); + if (pending) { + timer = setTimeout(() => { + processEvents(input.scan().events); + }, pending.delay); + } +}); diff --git a/examples/input/ui/4-modes.ts b/examples/input/ui/4-modes.ts new file mode 100644 index 0000000..c8d2939 --- /dev/null +++ b/examples/input/ui/4-modes.ts @@ -0,0 +1,111 @@ +import { box, text, createRoot } from '@clack/ui'; +import { createInput, alternateBuffer, settings, type InputEvent } from '@bomb.sh/tty'; +import { signal, effect } from 'alien-signals'; +import { cyan, white, gray, blue, yellow, green } from './helper.ts'; + +// ./3-signals.ts gave us signals + effect() for reactive rendering, but every event +// was a local state change where effect() was able to handle it all. Some events +// need to do more: exit the process, revert terminal modes, trigger I/O or other computation. +// For a concrete example: In ./3-signals.ts, we just called process.exit() inside processEvent, +// but that only works when there's nothing to clean up. + +// This adds terminal settings which require the mode to be reverted +// before exiting. We can't call process.exit() inside processEvent() because it doesn't +// give the caller a chance to revert the terminal modes. + +const root = await createRoot(); +const input = await createInput(); + +interface State { + inputBuffer: string; + lastKey: string; + quit: boolean; +} + +const state = signal({ inputBuffer: '', lastKey: '', quit: false }); + +// Same processEvent as ./3-signals.ts, but quit becomes a flag instead of +// calling process.exit() directly. +function processEvent(s: State, event: InputEvent): State { + if (event.type !== 'keydown') return s; + if (event.ctrl && event.code === 'c') return { ...s, quit: true }; + if (event.code === 'Escape') return { ...s, quit: true }; + if (event.code === 'Backspace') { + return { ...s, inputBuffer: s.inputBuffer.slice(0, -1), lastKey: 'Backspace' }; + } + if (event.code === 'Enter') { + return { ...s, inputBuffer: '', lastKey: 'Enter' }; + } + if (event.code.startsWith('Arrow')) { + return { ...s, lastKey: event.code }; + } + if (event.text) { + return { + ...s, + inputBuffer: s.inputBuffer + event.text, + lastKey: `Char: ${JSON.stringify(event.text)}`, + }; + } + return s; +} + +function processEvents(events: InputEvent[]): State { + let current = state(); + for (const event of events) { + current = processEvent(current, event); + } + state(current); + return current; +} + +// Alternate buffer keeps the user's scrollback clean, but this +// needs to be reverted before exiting; see quit() below. +const mode = settings(alternateBuffer({ clear: true })); +process.stdout.write(mode.apply); + +effect(() => { + const s = state(); + root.render( + box( + { + layout: { direction: 'ttb', gap: 1, padding: { top: 1, bottom: 1, left: 2, right: 2 } }, + border: { color: blue, top: 1, right: 1, bottom: 1, left: 1 }, + }, + text({ color: cyan }, 'Terminal Modes'), + text({ color: gray }, 'Same input as ./3-signals.ts, but in an alternate buffer'), + box( + { layout: { direction: 'ltr', gap: 0 } }, + text({ color: yellow }, '> '), + text({ color: white }, s.inputBuffer || '(type something)'), + ), + text({ color: gray }, s.lastKey ? `Last: ${s.lastKey}` : ''), + text({ color: green }, 'Arrow keys, Escape, and special keys all work'), + text({ color: gray }, 'Press Escape to exit (reverts terminal modes)'), + ), + ); +}); + +function quit(): void { + process.stdout.write(mode.revert); + process.exit(0); +} + +process.stdin.setRawMode(true); +let timer: ReturnType; + +process.stdin.on('data', (buf: Buffer) => { + clearTimeout(timer); + const { events, pending } = input.scan(new Uint8Array(buf)); + // We can process different outcomes from processEvents() here, + // but the data handler is getting overloaded. Imagine handling a quit + // event which raises a modal to request confirmation before quitting. + // See ./5-stream.ts for a more robust approach. + const after = processEvents(events); + if (after.quit) quit(); + if (pending) { + timer = setTimeout(() => { + const afterFlush = processEvents(input.scan().events); + if (afterFlush.quit) quit(); + }, pending.delay); + } +}); diff --git a/examples/input/ui/5-stream.ts b/examples/input/ui/5-stream.ts new file mode 100644 index 0000000..fc6e600 --- /dev/null +++ b/examples/input/ui/5-stream.ts @@ -0,0 +1,142 @@ +import { box, text, createRoot } from '@clack/ui'; +import { createInput, alternateBuffer, settings, type InputEvent } from '@bomb.sh/tty'; +import { signal, effect } from 'alien-signals'; +import { cyan, white, gray, blue, yellow, green } from './helper.ts'; + +// ./4-modes.ts's data handler was getting overloaded — processEvents, quit checks, +// pending flush timers, all crammed into one callback. An async iterator +// moves that complexity into its own function, so the consumer just writes +// `for await (const event of events())` and handles one event at a time. +// The pending-flush timer lives inside the iterator; the consumer never sees it. + +const root = await createRoot(); +const input = await createInput(); + +interface State { + inputBuffer: string; + lastKey: string; + quit: boolean; +} + +const state = signal({ inputBuffer: '', lastKey: '', quit: false }); + +// Same processEvent as ./4-modes.ts. +function processEvent(s: State, event: InputEvent): State { + if (event.type !== 'keydown') return s; + if (event.ctrl && event.code === 'c') return { ...s, quit: true }; + if (event.code === 'Escape') return { ...s, quit: true }; + if (event.code === 'Backspace') { + return { ...s, inputBuffer: s.inputBuffer.slice(0, -1), lastKey: 'Backspace' }; + } + if (event.code === 'Enter') { + return { ...s, inputBuffer: '', lastKey: 'Enter' }; + } + if (event.code.startsWith('Arrow')) { + return { ...s, lastKey: event.code }; + } + if (event.text) { + return { + ...s, + inputBuffer: s.inputBuffer + event.text, + lastKey: `Char: ${JSON.stringify(event.text)}`, + }; + } + return s; +} + +function processEvents(events: InputEvent[]): State { + let current = state(); + for (const event of events) { + current = processEvent(current, event); + } + state(current); + return current; +} + +const mode = settings(alternateBuffer({ clear: true })); +process.stdout.write(mode.apply); + +// Note that our state is still reactive, but holds the sole ownership is triggering a render. +// But how would you handle events which require a render, but aren't directly attached to a state change? +// See ./6-mouse.ts for an example of handling events from a mouse. +effect(() => { + const s = state(); + root.render( + box( + { + layout: { direction: 'ttb', gap: 1, padding: { top: 1, bottom: 1, left: 2, right: 2 } }, + border: { color: blue, top: 1, right: 1, bottom: 1, left: 1 }, + }, + text({ color: cyan }, 'Stream Input'), + text({ color: gray }, 'Async iterator hides the pending-flush timer from the consumer'), + box( + { layout: { direction: 'ltr', gap: 0 } }, + text({ color: yellow }, '> '), + text({ color: white }, s.inputBuffer || '(type something)'), + ), + text({ color: gray }, s.lastKey ? `Last: ${s.lastKey}` : ''), + text({ color: green }, 'Arrow keys, Escape, and special keys all work'), + text({ color: gray }, 'Press Escape to exit'), + ), + ); +}); + +// The iterator owns the pending-flush timer. The consumer just gets a stream +// of events and doesn't need to know about chunk splitting or timeouts. +// We could choose to make this a function within `@clack/ui` and +// have it return an AsyncIterable directly. +const queue: InputEvent[] = []; +let resolve: (() => void) | null = null; + +function push(events: InputEvent[]): void { + queue.push(...events); + resolve?.(); + resolve = null; +} + +function stdinEvents(): AsyncIterable { + return { + [Symbol.asyncIterator]() { + return { + async next() { + while (queue.length === 0) { + await new Promise((r) => { + resolve = r; + }); + } + return { value: queue.shift()!, done: false }; + }, + }; + }, + }; +} + +let pending: ReturnType['pending']; + +function processChunk(buf: Uint8Array): void { + const { events, pending: p } = input.scan(buf); + push(events); + if (pending) return; + pending = p; + if (pending) { + setTimeout(() => { + const flush = input.scan(); + push(flush.events); + pending = flush.pending; + if (pending) processChunk(new Uint8Array()); + }, pending.delay); + } +} + +process.stdin.setRawMode(true); +process.stdin.on('data', (buf: Buffer) => processChunk(new Uint8Array(buf))); + +// The consumer is now a simple loop with no timers or flush logic. +// Each event is processed and the quit flag is checked immediately afterwards. +for await (const event of stdinEvents()) { + const after = processEvents([event]); + if (after.quit) { + process.stdout.write(mode.revert); + process.exit(0); + } +} diff --git a/examples/input/ui/helper.ts b/examples/input/ui/helper.ts new file mode 100644 index 0000000..d425d20 --- /dev/null +++ b/examples/input/ui/helper.ts @@ -0,0 +1,9 @@ +import { rgba } from '@clack/ui'; + +export const cyan = rgba(0, 205, 205); +export const white = rgba(229, 229, 229); +export const gray = rgba(127, 127, 127); +export const blue = rgba(0, 0, 238); +export const yellow = rgba(255, 255, 0); +export const green = rgba(0, 200, 0); +export const red = rgba(255, 80, 80); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c2e92a..85e2e0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ catalogs: '@clack/ui': specifier: https://pkg.pr.new/bombshell-dev/ui/@clack/ui@a60ac12 version: 0.0.0 + alien-signals: + specifier: ^2.0.0 + version: 2.0.8 importers: @@ -44,9 +47,15 @@ importers: examples/input: dependencies: + '@bomb.sh/tty': + specifier: 'catalog:' + version: 0.8.0 '@clack/ui': specifier: 'catalog:' version: https://pkg.pr.new/bombshell-dev/ui/@clack/ui@a60ac12 + alien-signals: + specifier: 'catalog:' + version: 2.0.8 experiments/ghostwright: dependencies: @@ -987,6 +996,9 @@ packages: '@yuku-toolchain/types@0.5.43': resolution: {integrity: sha512-kSpvPntnXw5+lYjO71ffBEnQ5ycQ74KGIYknh0TS4xeyCuBkOqxyJumxZkMhLBBUCLjDAbx2+Icnr3Zh4ftjpQ==} + alien-signals@2.0.8: + resolution: {integrity: sha512-844G1VLkk0Pe2SJjY0J8vp8ADI73IM4KliNu2OGlYzWpO28NexEUvjHTcFjFX3VXoiUtwTbHxLNI9ImkcoBqzA==} + ansis@4.3.1: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} @@ -2169,6 +2181,8 @@ snapshots: '@yuku-toolchain/types@0.5.43': {} + alien-signals@2.0.8: {} + ansis@4.3.1: {} assertion-error@2.0.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b36892f..642d957 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,3 +10,4 @@ catalog: '@bomb.sh/args': '^0.3.1' '@clack/ui': 'https://pkg.pr.new/bombshell-dev/ui/@clack/ui@a60ac12' '@clack/prompts': '^1.7.0' + 'alien-signals': '^2.0.0' From 5e10042a2962a2684046cc3c18994084f26ceda9 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Thu, 23 Jul 2026 01:08:58 -0500 Subject: [PATCH 2/7] rename folder --- examples/input/package.json | 10 +++++----- examples/input/{ui => src}/1-raw.ts | 0 examples/input/{ui => src}/2-parser.ts | 0 examples/input/{ui => src}/3-signals.ts | 0 examples/input/{ui => src}/4-modes.ts | 0 examples/input/{ui => src}/5-stream.ts | 0 examples/input/{ui => src}/helper.ts | 0 7 files changed, 5 insertions(+), 5 deletions(-) rename examples/input/{ui => src}/1-raw.ts (100%) rename examples/input/{ui => src}/2-parser.ts (100%) rename examples/input/{ui => src}/3-signals.ts (100%) rename examples/input/{ui => src}/4-modes.ts (100%) rename examples/input/{ui => src}/5-stream.ts (100%) rename examples/input/{ui => src}/helper.ts (100%) diff --git a/examples/input/package.json b/examples/input/package.json index ff5db1e..0d3bbad 100644 --- a/examples/input/package.json +++ b/examples/input/package.json @@ -3,11 +3,11 @@ "private": true, "type": "module", "exports": { - "./1-raw": "./ui/1-raw.ts", - "./2-parser": "./ui/2-parser.ts", - "./3-signals": "./ui/3-signals.ts", - "./4-modes": "./ui/4-modes.ts", - "./5-stream": "./ui/5-stream.ts", + "./1-raw": "./src/1-raw.ts", + "./2-parser": "./src/2-parser.ts", + "./3-signals": "./src/3-signals.ts", + "./4-modes": "./src/4-modes.ts", + "./5-stream": "./src/5-stream.ts", "./package.json": "./package.json" }, "dependencies": { diff --git a/examples/input/ui/1-raw.ts b/examples/input/src/1-raw.ts similarity index 100% rename from examples/input/ui/1-raw.ts rename to examples/input/src/1-raw.ts diff --git a/examples/input/ui/2-parser.ts b/examples/input/src/2-parser.ts similarity index 100% rename from examples/input/ui/2-parser.ts rename to examples/input/src/2-parser.ts diff --git a/examples/input/ui/3-signals.ts b/examples/input/src/3-signals.ts similarity index 100% rename from examples/input/ui/3-signals.ts rename to examples/input/src/3-signals.ts diff --git a/examples/input/ui/4-modes.ts b/examples/input/src/4-modes.ts similarity index 100% rename from examples/input/ui/4-modes.ts rename to examples/input/src/4-modes.ts diff --git a/examples/input/ui/5-stream.ts b/examples/input/src/5-stream.ts similarity index 100% rename from examples/input/ui/5-stream.ts rename to examples/input/src/5-stream.ts diff --git a/examples/input/ui/helper.ts b/examples/input/src/helper.ts similarity index 100% rename from examples/input/ui/helper.ts rename to examples/input/src/helper.ts From 97d78a57f4ef742fd49a4de66c8def18c1ed620a Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Sat, 25 Jul 2026 01:16:23 -0500 Subject: [PATCH 3/7] many event handling + vendor ui package --- examples/input/package.json | 3 + examples/input/src/5-stream.ts | 10 +- examples/input/src/6-events.ts | 224 +++++++++++++++++++++++++++++ pnpm-lock.yaml | 21 ++- vendor/ui/package.json | 57 ++++++++ vendor/ui/src/children.test.ts | 49 +++++++ vendor/ui/src/children.ts | 45 ++++++ vendor/ui/src/components/box.ts | 15 ++ vendor/ui/src/components/text.ts | 15 ++ vendor/ui/src/index.ts | 4 + vendor/ui/src/render/createRoot.ts | 74 ++++++++++ vendor/ui/src/render/ids.test.ts | 51 +++++++ vendor/ui/src/render/ids.ts | 37 +++++ vendor/ui/tsconfig.json | 6 + 14 files changed, 604 insertions(+), 7 deletions(-) create mode 100644 examples/input/src/6-events.ts create mode 100644 vendor/ui/package.json create mode 100644 vendor/ui/src/children.test.ts create mode 100644 vendor/ui/src/children.ts create mode 100644 vendor/ui/src/components/box.ts create mode 100644 vendor/ui/src/components/text.ts create mode 100644 vendor/ui/src/index.ts create mode 100644 vendor/ui/src/render/createRoot.ts create mode 100644 vendor/ui/src/render/ids.test.ts create mode 100644 vendor/ui/src/render/ids.ts create mode 100644 vendor/ui/tsconfig.json diff --git a/examples/input/package.json b/examples/input/package.json index 0d3bbad..b22af1a 100644 --- a/examples/input/package.json +++ b/examples/input/package.json @@ -8,11 +8,14 @@ "./3-signals": "./src/3-signals.ts", "./4-modes": "./src/4-modes.ts", "./5-stream": "./src/5-stream.ts", + "./6-events": "./src/6-events.ts", + "./7-focus": "./src/7-focus.ts", "./package.json": "./package.json" }, "dependencies": { "@bomb.sh/tty": "catalog:", "@clack/ui": "catalog:", + "@clack/vendored.ui": "workspace:", "alien-signals": "catalog:" } } diff --git a/examples/input/src/5-stream.ts b/examples/input/src/5-stream.ts index fc6e600..878d98d 100644 --- a/examples/input/src/5-stream.ts +++ b/examples/input/src/5-stream.ts @@ -86,12 +86,12 @@ effect(() => { // We could choose to make this a function within `@clack/ui` and // have it return an AsyncIterable directly. const queue: InputEvent[] = []; -let resolve: (() => void) | null = null; +let resolver = Promise.withResolvers(); function push(events: InputEvent[]): void { queue.push(...events); - resolve?.(); - resolve = null; + resolver.resolve(); + resolver = Promise.withResolvers(); } function stdinEvents(): AsyncIterable { @@ -100,9 +100,7 @@ function stdinEvents(): AsyncIterable { return { async next() { while (queue.length === 0) { - await new Promise((r) => { - resolve = r; - }); + await resolver.promise; } return { value: queue.shift()!, done: false }; }, diff --git a/examples/input/src/6-events.ts b/examples/input/src/6-events.ts new file mode 100644 index 0000000..cbb4df8 --- /dev/null +++ b/examples/input/src/6-events.ts @@ -0,0 +1,224 @@ +import { box, text, createRoot } from '@clack/vendored.ui'; +import { + createInput, + alternateBuffer, + settings, + mouseTracking, + rgba, + type InputEvent, +} from '@bomb.sh/tty'; +import { signal, effect } from 'alien-signals'; +import { cyan, white, gray, green } from './helper.ts'; + +// ./5-stream.ts gave us an async iterator over stdin events. But stdin can +// carry keyboard, mouse, and resize events, and we can handle all these +// through the same iterator. +// This example shows how one stream feeds a reactive state model. The box +// color reacts to your input: typing speed pushes red, mouse distance +// pushes blue. Window resizes redraw the layout. +// The progression from 5→6 is: one iterator, multiple event sources. + +const input = await createInput(); + +const renderTick = signal(0); + +interface State { + inputBuffer: string; + lastKey: string; + quit: boolean; + keyIntensity: number; + lastKeyAt: number; + mouse: { x: number; y: number }; + size: { width: number; height: number }; +} + +const state = signal({ + inputBuffer: '', + lastKey: '', + quit: false, + keyIntensity: 0, + lastKeyAt: 0, + mouse: { x: 0, y: 0 }, + size: { width: process.stdout.columns, height: process.stdout.rows }, +}); + +const intensityPerKey = 5; // each key adds this until maximum intensity +const maxKeyIntensity = 20; // max for visual typing intensity, each key adds intensityPerKey +const typingDecay = 400; // ms + +function processEvent(s: State, event: InputEvent): State { + if (event.type === 'keydown') { + const now = performance.now(); + const elapsed = now - s.lastKeyAt; + // value based on time due to decay + const decayed = Math.max(0, s.keyIntensity * Math.exp(-elapsed / typingDecay)); + const next = Math.min(decayed + intensityPerKey, maxKeyIntensity); + if (event.ctrl && event.code === 'c') return { ...s, quit: true }; + if (event.code === 'Escape') return { ...s, quit: true }; + if (event.code === 'Backspace') { + return { + ...s, + inputBuffer: s.inputBuffer.slice(0, -1), + lastKey: 'Backspace', + keyIntensity: next, + lastKeyAt: now, + }; + } + if (event.code === 'Enter') { + return { ...s, inputBuffer: '', lastKey: 'Enter', keyIntensity: next, lastKeyAt: now }; + } + if (event.code.startsWith('Arrow')) { + return { ...s, lastKey: event.code, keyIntensity: next, lastKeyAt: now }; + } + if (event.text) { + return { + ...s, + inputBuffer: s.inputBuffer + event.text, + lastKey: `Char: ${JSON.stringify(event.text)}`, + keyIntensity: next, + lastKeyAt: now, + }; + } + return s; + } + if (event.type === 'mousemove' || event.type === 'mousedown' || event.type === 'mouseup') { + return { ...s, mouse: { x: event.x, y: event.y } }; + } + if (event.type === 'resize') { + return { ...s, size: { width: event.width, height: event.height } }; + } + return s; +} + +function processEvents(events: InputEvent[]): State { + let current = state(); + for (const event of events) { + current = processEvent(current, event); + } + state(current); + return current; +} + +// mouseTracking() adds SGR mouse reports. Resize events come through +// automatically when the terminal window changes size. +const mode = settings(alternateBuffer({ clear: true }), mouseTracking()); +process.stdout.write(mode.apply); + +const root = await createRoot(); + +function buildBox(): ReturnType { + const s = state(); + + const elapsed = performance.now() - s.lastKeyAt; + const intensity = Math.max(0, s.keyIntensity * Math.exp(-elapsed / typingDecay)); + // Normalize value from 0 to 1 to scale max red + const intensityRange = Math.min(intensity / maxKeyIntensity, 1); + const maxRed = 200; // Max red for RGB + const redFromIntensity = Math.round(intensityRange * maxRed); + + // Mouse proximity to box, from top-left corner (where the box sits) + // divided by the terminal diagonal. Closer = more blue. + const maxDist = Math.sqrt(s.size.width ** 2 + s.size.height ** 2); + const distance = Math.sqrt(s.mouse.x ** 2 + s.mouse.y ** 2); + const blueFromDistance = Math.round(Math.min((maxDist - distance) / maxDist, 1) * 200); + + // Determine box size and pulse color on hover + const insideBox = s.mouse.y < 15 && s.mouse.x < s.size.width; + const pulseMaxIntensity = 40; // Max pulse, adds to RGB value + // 150ms period to pulse over 300ms, * 0.5 + 0.5 to normalize from 0 to 1, then scale by pulseMaxIntensity + const pulse = insideBox ? (Math.sin(performance.now() / 150) * 0.5 + 0.5) * pulseMaxIntensity : 0; + + return box( + { + layout: { direction: 'ttb', gap: 1, padding: { top: 1, bottom: 1, left: 2, right: 2 } }, + border: { + color: rgba(40, 40, 60 + blueFromDistance / 3), + top: 1, + right: 1, + bottom: 1, + left: 1, + }, + bg: rgba(redFromIntensity + pulse, 2, blueFromDistance + pulse), + transition: { duration: 0.1, easing: 'easeOut', properties: ['bg'] }, + }, + text({ color: cyan }, 'Stream Events'), + text({ color: gray }, 'One iterator handles keyboard, mouse, transitions, and resize'), + box( + { layout: { direction: 'ltr', gap: 0 } }, + text({ color: green }, '> '), + text({ color: white }, s.inputBuffer || '(type something)'), + ), + text({ color: gray }, s.lastKey ? `Last: ${s.lastKey}` : ''), + text({ color: gray }, `Mouse: (${s.mouse.x}, ${s.mouse.y})`), + text({ color: gray }, `Size: ${s.size.width}x${s.size.height}`), + ); +} + +// effect() re-renders when any signal changes +effect(() => { + state(); + // primes the `renderTick` signal so `renderTimer` changes fire `effect()` + renderTick(); + root.render(buildBox()); +}); + +// Tick every 50ms so the effect re-fires and `buildBox` recomputes the +// time-based intensity decay. Without this, the visual would only update +// on keystrokes. +const renderTimer = setInterval(() => { + renderTick(renderTick() + 1); +}, 50); + +// Same iterator as ./5-stream.ts — it already yields mouse and resize events. +const queue: InputEvent[] = []; +let resolver = Promise.withResolvers(); + +function push(events: InputEvent[]): void { + queue.push(...events); + resolver.resolve(); + resolver = Promise.withResolvers(); +} + +function stdinEvents(): AsyncIterable { + return { + [Symbol.asyncIterator]() { + return { + async next() { + while (queue.length === 0) { + await resolver.promise; + } + return { value: queue.shift()!, done: false }; + }, + }; + }, + }; +} + +let pending: ReturnType['pending']; + +function processChunk(buf: Uint8Array): void { + const { events, pending: p } = input.scan(buf); + push(events); + if (pending) return; + pending = p; + if (pending) { + setTimeout(() => { + const flush = input.scan(); + push(flush.events); + pending = flush.pending; + if (pending) processChunk(new Uint8Array()); + }, pending.delay); + } +} + +process.stdin.setRawMode(true); +process.stdin.on('data', (buf: Buffer) => processChunk(new Uint8Array(buf))); + +for await (const event of stdinEvents()) { + const after = processEvents([event]); + if (after.quit) { + clearInterval(renderTimer); + process.stdout.write(mode.revert); + process.exit(0); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85e2e0a..27c4527 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@clack/ui': specifier: 'catalog:' version: https://pkg.pr.new/bombshell-dev/ui/@clack/ui@a60ac12 + '@clack/vendored.ui': + specifier: 'workspace:' + version: link:../../vendor/ui alien-signals: specifier: 'catalog:' version: 2.0.8 @@ -63,6 +66,22 @@ importers: specifier: ^4.0.2 version: 4.0.3 + vendor/ui: + dependencies: + '@bomb.sh/tty': + specifier: ^0.8.0 + version: 0.8.0 + '@types/node': + specifier: ^22.20.0 + version: 22.20.1 + devDependencies: + '@bomb.sh/tools': + specifier: ^0.5.4 + version: 0.5.4(@types/node@22.20.1)(oxc-resolver@11.21.3)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) + packages: '@bomb.sh/args@0.3.1': @@ -2615,7 +2634,7 @@ snapshots: magic-string: 0.30.21 obug: 2.1.3 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.2.4 diff --git a/vendor/ui/package.json b/vendor/ui/package.json new file mode 100644 index 0000000..b224762 --- /dev/null +++ b/vendor/ui/package.json @@ -0,0 +1,57 @@ +{ + "name": "@clack/vendored.ui", + "version": "0.0.0", + "keywords": [ + "bombshell", + "cli" + ], + "homepage": "https://bomb.sh/docs/ui", + "license": "MIT", + "author": { + "name": "Bombshell", + "email": "oss@bomb.sh", + "url": "https://bomb.sh" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/bombshell-dev/ui.git" + }, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "dev": "bsh dev", + "build": "bsh build --dts", + "format": "bsh format", + "lint": "bsh lint", + "test": "bsh test" + }, + "dependencies": { + "@bomb.sh/tty": "^0.8.0", + "@types/node": "^22.20.0" + }, + "devDependencies": { + "@bomb.sh/tools": "^0.5.4", + "vitest": "^4.1.9" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "^10.7.0", + "onFail": "error" + }, + "runtime": { + "name": "node", + "version": "^22.14.0", + "onFail": "error" + } + } +} diff --git a/vendor/ui/src/children.test.ts b/vendor/ui/src/children.test.ts new file mode 100644 index 0000000..be30c3e --- /dev/null +++ b/vendor/ui/src/children.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'vitest'; +import { text } from './components/text.ts'; +import { normalizeChildren, stringifyPrimitive } from './children.ts'; + +// `@bomb.sh/tty` OP_TEXT directive. +const TEXT = 3; + +describe('stringifyPrimitive', () => { + test('keeps strings and numbers (incl. 0 and ""), drops nullish and booleans', () => { + expect(stringifyPrimitive('a')).toBe('a'); + expect(stringifyPrimitive(0)).toBe('0'); + expect(stringifyPrimitive('')).toBe(''); + expect(stringifyPrimitive(null)).toBeUndefined(); + expect(stringifyPrimitive(undefined)).toBeUndefined(); + expect(stringifyPrimitive(true)).toBeUndefined(); + expect(stringifyPrimitive(false)).toBeUndefined(); + }); +}); + +describe('normalizeChildren — React JSX coercion', () => { + test('strings and numbers become text ops', () => { + expect(normalizeChildren(['hi', 42])).toMatchObject([ + { directive: TEXT, content: 'hi' }, + { directive: TEXT, content: '42' }, + ]); + }); + + test('keeps 0 and empty string — the drop is nullish/boolean, not falsy', () => { + expect(normalizeChildren([0, '', false, null, undefined, true])).toMatchObject([ + { directive: TEXT, content: '0' }, + { directive: TEXT, content: '' }, + ]); + }); + + test('flattens arrays recursively', () => { + expect(normalizeChildren(['a', ['b', ['c']]])).toMatchObject([ + { content: 'a' }, + { content: 'b' }, + { content: 'c' }, + ]); + }); + + test('passes existing ops through by reference, untouched', () => { + const t = text({ color: 9 }, 'x'); + const ops = normalizeChildren(['a', t]); + expect(ops[0]).toMatchObject({ directive: TEXT, content: 'a' }); + expect(ops[1]).toBe(t); + }); +}); diff --git a/vendor/ui/src/children.ts b/vendor/ui/src/children.ts new file mode 100644 index 0000000..6af2c19 --- /dev/null +++ b/vendor/ui/src/children.ts @@ -0,0 +1,45 @@ +import { text as ttyText, type Op } from '@bomb.sh/tty'; + +/** A rendered node — what a component returns: one op (`text`) or a list of ops (`box`). */ +export type Node = Op | Op[]; + +/** A primitive child. Strings and numbers render as text (`0` and `''` included); `null`, `undefined`, and booleans render nothing — matching React's JSX rules. */ +export type Primitive = string | number | boolean | null | undefined; + +/** Anything you can pass as a child: a node, a primitive, or a (possibly nested) array of children. */ +export type Child = Node | Primitive | Child[]; + +/** + * Coerce a primitive child to its text form, or `undefined` if it renders nothing + * (`null`, `undefined`, or a boolean). Strings and numbers are kept — including `0` + * and `''` — so this is an explicit nullish/boolean check, not a truthiness test. + */ +export function stringifyPrimitive(value: Primitive): string | undefined { + if (value == null || typeof value === 'boolean') return undefined; + return String(value); +} + +/** + * Normalize heterogeneous children into the flat `Op[]` stream the renderer consumes, + * applying React's JSX coercion rules: strings and numbers become text ops, `null`/ + * `undefined`/booleans drop, arrays flatten recursively, and existing ops pass through. + */ +export function normalizeChildren(children: Child[]): Op[] { + const ops: Op[] = []; + // Recurse into one accumulator rather than spreading per level — avoids intermediate + // arrays and the argument-count ceiling of `push(...arr)` on large flattened lists. + const walk = (nodes: Child[]): void => { + for (const child of nodes) { + if (Array.isArray(child)) { + walk(child); + } else if (child !== null && typeof child === 'object') { + ops.push(child); + } else { + const str = stringifyPrimitive(child); + if (str !== undefined) ops.push(ttyText(str)); + } + } + }; + walk(children); + return ops; +} diff --git a/vendor/ui/src/components/box.ts b/vendor/ui/src/components/box.ts new file mode 100644 index 0000000..49e9d9e --- /dev/null +++ b/vendor/ui/src/components/box.ts @@ -0,0 +1,15 @@ +import { open as ttyOpen, type OpenElement, type Op, close as ttyClose } from '@bomb.sh/tty'; +import { type Child, normalizeChildren } from '../children.ts'; + +/** Box props — a 1:1 passthrough to the tty `open` op, plus an optional `key` for identity across renders. */ +export type BoxProps = Omit & { key?: string }; + +export function box({ key, ...props }: BoxProps, ...children: Child[]): Op[] { + return [ttyOpen(key ?? '', boxPropsToOps(props)), ...normalizeChildren(children), ttyClose()]; +} + +function boxPropsToOps( + props: Omit, +): Omit { + return props; +} diff --git a/vendor/ui/src/components/text.ts b/vendor/ui/src/components/text.ts new file mode 100644 index 0000000..6215129 --- /dev/null +++ b/vendor/ui/src/components/text.ts @@ -0,0 +1,15 @@ +import { text as ttyText, type Op, type Text as TextOp } from '@bomb.sh/tty'; +import { type Primitive, stringifyPrimitive } from '../children.ts'; + +/** Text props — a 1:1 passthrough to the tty `text` op for now (friendlier shapes later). */ +export type TextProps = Omit; + +export function text(props: TextProps, ...content: Primitive[]): Op { + // `join` coerces the `undefined` drops (null/undefined/booleans) to '', leaving `0`/`''` intact. + const str = content.map(stringifyPrimitive).join(''); + return ttyText(str, textPropsToOps(props)); +} + +function textPropsToOps(props: TextProps): Omit { + return props; +} diff --git a/vendor/ui/src/index.ts b/vendor/ui/src/index.ts new file mode 100644 index 0000000..5ffcc04 --- /dev/null +++ b/vendor/ui/src/index.ts @@ -0,0 +1,4 @@ +export { rgba } from '@bomb.sh/tty'; +export { box, type BoxProps } from './components/box.ts'; +export { text, type TextProps } from './components/text.ts'; +export { createRoot, type RenderRoot, type RenderRootOptions } from './render/createRoot.ts'; diff --git a/vendor/ui/src/render/createRoot.ts b/vendor/ui/src/render/createRoot.ts new file mode 100644 index 0000000..004bba4 --- /dev/null +++ b/vendor/ui/src/render/createRoot.ts @@ -0,0 +1,74 @@ +import type { WriteStream } from 'node:tty'; +import { createTerm, type Op, type Term } from '@bomb.sh/tty'; +import { type Child, normalizeChildren } from '../children.ts'; +import { resolveIds } from './ids.ts'; + +export interface RenderRootOptions { + /** Stream to write rendered frames to. Defaults to `process.stdout`. */ + output?: WriteStream; + /** Render width in columns. Defaults to the output's column count. */ + width?: number; + /** Render height in rows. Defaults to the output's row count. */ + height?: number; +} + +/** Owns a tty terminal and its children; each {@link RenderRoot.render} re-renders the full op stream (diffing happens in `Term.render`). */ +export class RenderRoot { + #output: WriteStream; + #width: number; + #height: number; + #term?: Term; + #children: Op[] = []; + #rafId: ReturnType | null = null; + #wasAnimating = false; + + constructor(options: RenderRootOptions = {}) { + this.#output = options.output ?? process.stdout; + this.#width = options.width ?? this.#output.columns ?? 80; + this.#height = options.height ?? this.#output.rows ?? 24; + } + + /** Initialize the terminal. Required before {@link RenderRoot.render}; {@link createRoot} awaits it for you. */ + async ready(): Promise { + this.#term ??= await createTerm({ width: this.#width, height: this.#height }); + } + + /** + * Render `children` to the output, replacing the previous frame. Call with no + * arguments to re-render the current children (e.g. on resize). + * + * Synchronous on purpose: `Term.render` and the stream write are both sync, so each + * call runs to completion before the next starts — no await means no microtask gap in + * which a later `render` could clobber this frame. Requires {@link RenderRoot.ready}. + * + * When transitions are active, follow-up frames are scheduled automatically + * so animations play out without the caller needing to drive the loop. + */ + render(...children: Child[]): void { + if (!this.#term) + throw new Error( + 'RenderRoot is not ready: await `ready()` (or use `createRoot`) before `render`.', + ); + if (children.length > 0) this.#children = resolveIds(normalizeChildren(children)); + if (this.#rafId !== null) { + clearTimeout(this.#rafId); + this.#rafId = null; + } + const { output, animating } = this.#term.render(this.#children, { mode: 'line' }); + this.#output.write(output); + if (animating) { + this.#wasAnimating = true; + this.#rafId = setTimeout(() => { + this.#rafId = null; + this.render(); + }, 16); + } else if (this.#wasAnimating) { + this.#wasAnimating = false; + } + } +} +export async function createRoot(options?: Pick): Promise { + const root = new RenderRoot(options); + await root.ready(); + return root; +} diff --git a/vendor/ui/src/render/ids.test.ts b/vendor/ui/src/render/ids.test.ts new file mode 100644 index 0000000..00f4ce4 --- /dev/null +++ b/vendor/ui/src/render/ids.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'vitest'; +import { box } from '../components/box.ts'; +import { text } from '../components/text.ts'; +import { resolveIds } from './ids.ts'; + +/** Pull the resolved ids off the open ops, in document order. */ +function ids(ops: ReturnType): string[] { + return ops.filter((op) => op.directive === 2).map((op) => (op as { id: string }).id); +} + +describe('resolveIds — hierarchical key paths', () => { + test('keyed boxes resolve to their parent path + key', () => { + const ops = box({ key: 'app' }, box({ key: 'sidebar' }), box({ key: 'body' })); + expect(ids(resolveIds(ops))).toEqual(['app', 'app/sidebar', 'app/body']); + }); + + test('a kept sibling keeps its id when a conditional sibling is dropped', () => { + const withSidebar = box({ key: 'app' }, box({ key: 'sidebar' }), box({ key: 'body' })); + const withoutSidebar = box( + { key: 'app' }, + false && box({ key: 'sidebar' }), + box({ key: 'body' }), + ); + const bodyId = (resolved: string[]) => resolved.find((id) => id.endsWith('body')); + expect(bodyId(ids(resolveIds(withSidebar)))).toBe('app/body'); + expect(bodyId(ids(resolveIds(withoutSidebar)))).toBe('app/body'); + }); + + test('unkeyed boxes fall back to their positional index among siblings', () => { + const ops = box({}, box({}), box({})); + expect(ids(resolveIds(ops))).toEqual(['0', '0/0', '0/1']); + }); + + test('a key overrides the index, but the index slot still advances', () => { + const ops = box({ key: 'app' }, box({}), box({ key: 'x' }), box({})); + expect(ids(resolveIds(ops))).toEqual(['app', 'app/0', 'app/x', 'app/2']); + }); + + test('text children do not consume a sibling index slot', () => { + const ops = box({ key: 'app' }, text({}, 'hi'), box({})); + expect(ids(resolveIds(ops))).toEqual(['app', 'app/0']); + }); + + test('does not mutate its input — the source keeps its raw key segments', () => { + const ops = box({ key: 'app' }, box({ key: 'body' })); + resolveIds(ops); + // raw segments survive, so re-resolving is idempotent + expect(ids(ops)).toEqual(['app', 'body']); + expect(ids(resolveIds(ops))).toEqual(['app', 'app/body']); + }); +}); diff --git a/vendor/ui/src/render/ids.ts b/vendor/ui/src/render/ids.ts new file mode 100644 index 0000000..d60a234 --- /dev/null +++ b/vendor/ui/src/render/ids.ts @@ -0,0 +1,37 @@ +import type { Op } from '@bomb.sh/tty'; + +// `@bomb.sh/tty` op directives (OP_OPEN_ELEMENT / OP_CLOSE_ELEMENT); not exported by the package. +const OPEN = 2; +const CLOSE = 4; + +/** Separator between key segments in a resolved id path. */ +const SEP = '/'; + +/** + * Resolve each box's raw segment (its `key`, or `''` when unkeyed) into a full + * hierarchical id, so `@bomb.sh/tty` keeps element identity stable across renders. + * + * The id is the path of segments from the root: a keyed box contributes its key, + * an unkeyed box falls back to its positional index among siblings. Returns a new + * op array — the input keeps its raw segments, so re-resolving the same source is + * idempotent (important when a `box(...)` result is rendered more than once). + */ +export function resolveIds(ops: Op[]): Op[] { + const stack = [{ path: '', index: 0 }]; + const out: Op[] = []; + for (const op of ops) { + if (op.directive === OPEN) { + const parent = stack[stack.length - 1]!; + const segment = op.id || String(parent.index); + parent.index++; + const path = parent.path ? `${parent.path}${SEP}${segment}` : segment; + stack.push({ path, index: 0 }); + out.push({ ...op, id: path }); + } else { + // Keep the root frame so a peek is always safe, even on an unbalanced stream. + if (op.directive === CLOSE && stack.length > 1) stack.pop(); + out.push(op); + } + } + return out; +} diff --git a/vendor/ui/tsconfig.json b/vendor/ui/tsconfig.json new file mode 100644 index 0000000..a055ad0 --- /dev/null +++ b/vendor/ui/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@bomb.sh/tools/tsconfig.json", + "compilerOptions": { + "types": ["node"] + } +} From 191a55014231b1519898ce3284a53dcaecbaf7ba Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Sat, 25 Jul 2026 01:55:36 -0500 Subject: [PATCH 4/7] spike out focus handling --- examples/input/src/7-focus.ts | 263 ++++++++++++++++++++++++++++++++++ pnpm-lock.yaml | 2 +- 2 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 examples/input/src/7-focus.ts diff --git a/examples/input/src/7-focus.ts b/examples/input/src/7-focus.ts new file mode 100644 index 0000000..56406a1 --- /dev/null +++ b/examples/input/src/7-focus.ts @@ -0,0 +1,263 @@ +import { box, text, createRoot, rgba } from '@clack/vendored.ui'; +import { + createInput, + alternateBuffer, + settings, + mouseTracking, + type InputEvent, +} from '@bomb.sh/tty'; +import { signal, effect } from 'alien-signals'; +import { cyan, white, gray, green, blue } from './helper.ts'; + +// 6-events.ts showed one reactive box responding to keyboard, mouse, and resize. +// This example adds another input. Two panels, same state model, same transitions. +// With two inputs, a focus index determines which panel receives keystrokes. +// Everything else is identical to ./6-events.ts. + +const input = await createInput(); + +const renderTick = signal(0); + +interface Panel { + inputBuffer: string; + lastKey: string; + keyIntensity: number; + lastKeyAt: number; +} + +interface State { + panels: [Panel, Panel]; + focusIndex: number; + mouse: { x: number; y: number }; + size: { width: number; height: number }; + quit: boolean; +} + +function blankPanel(): Panel { + return { inputBuffer: '', lastKey: '', keyIntensity: 0, lastKeyAt: 0 }; +} + +const state = signal({ + panels: [blankPanel(), blankPanel()], + focusIndex: 0, + mouse: { x: 0, y: 0 }, + size: { width: process.stdout.columns, height: process.stdout.rows }, + quit: false, +}); + +const intensityPerKey = 5; +const maxKeyIntensity = 20; +const typingDecay = 400; + +function processEvent(s: State, event: InputEvent): State { + if (event.type === 'keydown') { + if (event.ctrl && event.code === 'c') return { ...s, quit: true }; + if (event.code === 'Escape') return { ...s, quit: true }; + if (event.code === 'Tab') return { ...s, focusIndex: (s.focusIndex + 1) % 2 }; + if (event.code === 'Backtab') return { ...s, focusIndex: (s.focusIndex + 1) % 2 }; + + // Everything below applies to the focused panel — identical to ./6-events.ts + const now = performance.now(); + const fi = s.focusIndex; + const p = s.panels[fi]!; + const elapsed = now - p.lastKeyAt; + const decayed = Math.max(0, p.keyIntensity * Math.exp(-elapsed / typingDecay)); + const next = Math.min(decayed + intensityPerKey, maxKeyIntensity); + + let updated: Panel; + if (event.code === 'Backspace') { + updated = { + ...p, + inputBuffer: p.inputBuffer.slice(0, -1), + lastKey: 'Backspace', + keyIntensity: next, + lastKeyAt: now, + }; + } else if (event.code === 'Enter') { + updated = { ...p, inputBuffer: '', lastKey: 'Enter', keyIntensity: next, lastKeyAt: now }; + } else if (event.code.startsWith('Arrow')) { + updated = { ...p, lastKey: event.code as string, keyIntensity: next, lastKeyAt: now }; + } else if (event.text) { + updated = { + ...p, + inputBuffer: p.inputBuffer + event.text, + lastKey: `Char: ${JSON.stringify(event.text)}`, + keyIntensity: next, + lastKeyAt: now, + }; + } else { + return s; + } + + const panels: [Panel, Panel] = [...s.panels] as [Panel, Panel]; + panels[fi] = updated; + return { ...s, panels }; + } + if (event.type === 'mousemove' || event.type === 'mousedown' || event.type === 'mouseup') { + return { ...s, mouse: { x: event.x, y: event.y } }; + } + if (event.type === 'resize') { + return { ...s, size: { width: event.width, height: event.height } }; + } + return s; +} + +function processEvents(events: InputEvent[]): State { + let current = state(); + for (const event of events) { + current = processEvent(current, event); + } + state(current); + return current; +} + +const mode = settings(alternateBuffer({ clear: true }), mouseTracking()); +process.stdout.write(mode.apply); + +const root = await createRoot(); + +function buildFocusedPanel(p: Panel, s: State): ReturnType { + const elapsed = performance.now() - p.lastKeyAt; + const intensity = Math.max(0, p.keyIntensity * Math.exp(-elapsed / typingDecay)); + const intensityRange = Math.min(intensity / maxKeyIntensity, 1); + const maxRed = 200; + const redFromIntensity = Math.round(intensityRange * maxRed); + + // Panel A starts at row 4, Panel B at row 14 (each panel is 9 rows tall) + const panelTop = s.focusIndex === 0 ? 4 : 14; + const panelHeight = 9; + const panelCenterY = panelTop + panelHeight / 2; + const panelCenterX = s.size.width / 2; + const maxDist = Math.sqrt((s.size.width / 2) ** 2 + (s.size.height / 2) ** 2); + const dist = Math.sqrt((s.mouse.x - panelCenterX) ** 2 + (s.mouse.y - panelCenterY) ** 2); + const blueFromDistance = Math.round(Math.min((maxDist - dist) / maxDist, 1) * 200); + + const insideBox = + s.mouse.y >= panelTop && s.mouse.y < panelTop + panelHeight && s.mouse.x < s.size.width; + const pulseMaxIntensity = 40; + const pulse = insideBox ? (Math.sin(performance.now() / 150) * 0.5 + 0.5) * pulseMaxIntensity : 0; + + return box( + { + layout: { direction: 'ttb', gap: 1, padding: { top: 1, bottom: 1, left: 2, right: 2 } }, + border: { + color: rgba(40, 40, 60 + blueFromDistance / 3), + top: 1, + right: 1, + bottom: 1, + left: 1, + }, + bg: rgba(redFromIntensity + pulse, 2, blueFromDistance + pulse), + transition: { duration: 0.1, easing: 'easeOut', properties: ['bg'] }, + }, + text({ color: cyan }, `Panel ${s.focusIndex === 0 ? 'A' : 'B'} (focused)`), + box( + { layout: { direction: 'ltr', gap: 0 } }, + text({ color: green }, '> '), + text({ color: white }, p.inputBuffer || '(type something)'), + ), + text({ color: gray }, p.lastKey ? `Last: ${p.lastKey}` : ''), + ); +} + +function buildUnfocusedPanel(p: Panel, index: number): ReturnType { + return box( + { + layout: { direction: 'ttb', gap: 1, padding: { top: 1, bottom: 1, left: 2, right: 2 } }, + border: { color: gray, top: 1, right: 1, bottom: 1, left: 1 }, + }, + text({ color: gray }, `Panel ${index === 0 ? 'A' : 'B'}`), + box( + { layout: { direction: 'ltr', gap: 0 } }, + text({ color: white }, ' '), + text({ color: white }, p.inputBuffer || '(type something)'), + ), + text({ color: gray }, p.lastKey ? `Last: ${p.lastKey}` : ''), + ); +} + +function buildBox(): ReturnType { + const s = state(); + + return box( + { + layout: { direction: 'ttb', gap: 1, padding: { top: 1, bottom: 1, left: 2, right: 2 } }, + border: { color: blue, top: 1, right: 1, bottom: 1, left: 1 }, + }, + text({ color: cyan }, 'Focus Management'), + text( + { color: gray }, + 'Same as ./6-events.ts — twice. Tab/Shift+Tab switches which panel is active.', + ), + s.focusIndex === 0 ? buildFocusedPanel(s.panels[0]!, s) : buildUnfocusedPanel(s.panels[0]!, 0), + s.focusIndex === 1 ? buildFocusedPanel(s.panels[1]!, s) : buildUnfocusedPanel(s.panels[1]!, 1), + text( + { color: gray }, + `Mouse: (${s.mouse.x}, ${s.mouse.y}) · Size: ${s.size.width}x${s.size.height}`, + ), + text({ color: gray }, 'Press Escape to quit'), + ); +} + +effect(() => { + state(); + renderTick(); + root.render(buildBox()); +}); + +const renderTimer = setInterval(() => { + renderTick(renderTick() + 1); +}, 50); + +const queue: InputEvent[] = []; +let resolver = Promise.withResolvers(); + +function push(events: InputEvent[]): void { + queue.push(...events); + resolver.resolve(); + resolver = Promise.withResolvers(); +} + +function stdinEvents(): AsyncIterable { + return { + [Symbol.asyncIterator]() { + return { + async next() { + while (queue.length === 0) { + await resolver.promise; + } + return { value: queue.shift()!, done: false }; + }, + }; + }, + }; +} + +let pending: ReturnType['pending']; + +function processChunk(buf: Uint8Array): void { + const { events, pending: p } = input.scan(buf); + push(events); + if (pending) return; + pending = p; + if (pending) { + setTimeout(() => { + const flush = input.scan(); + push(flush.events); + pending = flush.pending; + if (pending) processChunk(new Uint8Array()); + }, pending.delay); + } +} + +process.stdin.setRawMode(true); +process.stdin.on('data', (buf: Buffer) => processChunk(new Uint8Array(buf))); + +for await (const event of stdinEvents()) { + const after = processEvents([event]); + if (after.quit) { + clearInterval(renderTimer); + process.stdout.write(mode.revert); + process.exit(0); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a678503..4f5e3cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,7 +77,7 @@ importers: devDependencies: '@bomb.sh/tools': specifier: ^0.5.4 - version: 0.5.4(@types/node@22.20.1)(oxc-resolver@11.21.3)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 0.5.5(@types/node@22.20.1)(oxc-resolver@11.21.3)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) vitest: specifier: ^4.1.9 version: 4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) From 72d992d8fc25ffc9eeb62235ecb440d906749234 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Sat, 25 Jul 2026 23:48:15 -0500 Subject: [PATCH 5/7] fix vendored.ui lint error --- vendor/ui/src/render/createRoot.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/vendor/ui/src/render/createRoot.ts b/vendor/ui/src/render/createRoot.ts index 004bba4..aee1884 100644 --- a/vendor/ui/src/render/createRoot.ts +++ b/vendor/ui/src/render/createRoot.ts @@ -3,6 +3,15 @@ import { createTerm, type Op, type Term } from '@bomb.sh/tty'; import { type Child, normalizeChildren } from '../children.ts'; import { resolveIds } from './ids.ts'; +/** Thrown when {@link RenderRoot.render} is called before {@link RenderRoot.ready} completes. */ +export class RenderRootNotReadyError extends Error { + readonly code = 'CLACK_RENDER_ROOT_NOT_READY'; + constructor() { + super('RenderRoot is not ready: await `ready()` (or use `createRoot`) before `render`.'); + this.name = 'RenderRootNotReadyError'; + } +} + export interface RenderRootOptions { /** Stream to write rendered frames to. Defaults to `process.stdout`. */ output?: WriteStream; @@ -45,10 +54,7 @@ export class RenderRoot { * so animations play out without the caller needing to drive the loop. */ render(...children: Child[]): void { - if (!this.#term) - throw new Error( - 'RenderRoot is not ready: await `ready()` (or use `createRoot`) before `render`.', - ); + if (!this.#term) throw new RenderRootNotReadyError(); if (children.length > 0) this.#children = resolveIds(normalizeChildren(children)); if (this.#rafId !== null) { clearTimeout(this.#rafId); From 2103ac803e5d84d281ba71698962a81a4f140102 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Sun, 26 Jul 2026 01:40:10 -0500 Subject: [PATCH 6/7] readme --- examples/input/README.md | 13 ++++++++++++ examples/input/src/7-focus.ts | 38 ++++++++++++++++++++++++----------- 2 files changed, 39 insertions(+), 12 deletions(-) create mode 100644 examples/input/README.md diff --git a/examples/input/README.md b/examples/input/README.md new file mode 100644 index 0000000..f53ac61 --- /dev/null +++ b/examples/input/README.md @@ -0,0 +1,13 @@ +# Input Examples Progression + +Seven files that build on each other, each solving a specific problem or expanding features from the previous. Start at [`1-raw.ts`](./src/1-raw.ts) and work down. + +| File | Problem | Solution | Deps | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------- | +| [`1-raw.ts`](./src/1-raw.ts) | `data.toString()` can't decode escape sequences — arrow keys produce mojibake | `createInput()` decodes raw bytes into structured events | `@bomb.sh/tty` | +| [`2-parser.ts`](./src/2-parser.ts) | Duplicated event handling in data handler and flush timeout | Extract `processEvent()` as a pure function | — | +| [`3-signals.ts`](./src/3-signals.ts) | Manual `render()` calls become difficult manage with scale. Use a library which can subscribe to changes and trigger a render after every state change. | `signal()` + `effect()` auto-renders on state change | `alien-signals` (choice optional, user can choose) | +| [`4-modes.ts`](./src/4-modes.ts) | Change `seeings()` which requires a revert on before process exits. | `alternateBuffer()` + `settings()` — quit becomes a state flag | `@bomb.sh/tty` | +| [`5-stream.ts`](./src/5-stream.ts) | Refactor overloaded data handler with an async iterator and flush all events in one callback. | Async iterator moves flush logic into its own function | `@bomb.sh/tty` | +| [`6-events.ts`](./src/6-events.ts) | One all event types including mouse movements and element transitions | Same iterator yields all types; reactive signals drive color transitions | `@bomb.sh/tty` | +| [`7-focus.ts`](./src/7-focus.ts) | Double our inputs and handle focus changes (Tab) between them while still supporting transitions | Two panels, Tab/Shift+Tab focus, full reactive treatment on focused panel | `@clack/ui` | diff --git a/examples/input/src/7-focus.ts b/examples/input/src/7-focus.ts index 56406a1..b627eb2 100644 --- a/examples/input/src/7-focus.ts +++ b/examples/input/src/7-focus.ts @@ -9,7 +9,7 @@ import { import { signal, effect } from 'alien-signals'; import { cyan, white, gray, green, blue } from './helper.ts'; -// 6-events.ts showed one reactive box responding to keyboard, mouse, and resize. +// 6-events.ts showed one input reacting with transitions responding to keyboard, mouse, and resize. // This example adds another input. Two panels, same state model, same transitions. // With two inputs, a focus index determines which panel receives keystrokes. // Everything else is identical to ./6-events.ts. @@ -58,29 +58,43 @@ function processEvent(s: State, event: InputEvent): State { // Everything below applies to the focused panel — identical to ./6-events.ts const now = performance.now(); - const fi = s.focusIndex; - const p = s.panels[fi]!; - const elapsed = now - p.lastKeyAt; - const decayed = Math.max(0, p.keyIntensity * Math.exp(-elapsed / typingDecay)); + const currentFocusedIndex = s.focusIndex; + const currentFocusedPanel = s.panels[currentFocusedIndex]!; + const elapsed = now - currentFocusedPanel.lastKeyAt; + const decayed = Math.max( + 0, + currentFocusedPanel.keyIntensity * Math.exp(-elapsed / typingDecay), + ); const next = Math.min(decayed + intensityPerKey, maxKeyIntensity); let updated: Panel; if (event.code === 'Backspace') { updated = { - ...p, - inputBuffer: p.inputBuffer.slice(0, -1), + ...currentFocusedPanel, + inputBuffer: currentFocusedPanel.inputBuffer.slice(0, -1), lastKey: 'Backspace', keyIntensity: next, lastKeyAt: now, }; } else if (event.code === 'Enter') { - updated = { ...p, inputBuffer: '', lastKey: 'Enter', keyIntensity: next, lastKeyAt: now }; + updated = { + ...currentFocusedPanel, + inputBuffer: '', + lastKey: 'Enter', + keyIntensity: next, + lastKeyAt: now, + }; } else if (event.code.startsWith('Arrow')) { - updated = { ...p, lastKey: event.code as string, keyIntensity: next, lastKeyAt: now }; + updated = { + ...currentFocusedPanel, + lastKey: event.code as string, + keyIntensity: next, + lastKeyAt: now, + }; } else if (event.text) { updated = { - ...p, - inputBuffer: p.inputBuffer + event.text, + ...currentFocusedPanel, + inputBuffer: currentFocusedPanel.inputBuffer + event.text, lastKey: `Char: ${JSON.stringify(event.text)}`, keyIntensity: next, lastKeyAt: now, @@ -90,7 +104,7 @@ function processEvent(s: State, event: InputEvent): State { } const panels: [Panel, Panel] = [...s.panels] as [Panel, Panel]; - panels[fi] = updated; + panels[currentFocusedIndex] = updated; return { ...s, panels }; } if (event.type === 'mousemove' || event.type === 'mousedown' || event.type === 'mouseup') { From 0962621f0736893fbbf15b1daa618b73964f6932 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Sun, 26 Jul 2026 10:45:02 -0500 Subject: [PATCH 7/7] lint on vendored.ui test --- vendor/ui/src/render/ids.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vendor/ui/src/render/ids.test.ts b/vendor/ui/src/render/ids.test.ts index 00f4ce4..7537c9a 100644 --- a/vendor/ui/src/render/ids.test.ts +++ b/vendor/ui/src/render/ids.test.ts @@ -16,9 +16,10 @@ describe('resolveIds — hierarchical key paths', () => { test('a kept sibling keeps its id when a conditional sibling is dropped', () => { const withSidebar = box({ key: 'app' }, box({ key: 'sidebar' }), box({ key: 'body' })); + const conditionalSidebar = false; const withoutSidebar = box( { key: 'app' }, - false && box({ key: 'sidebar' }), + conditionalSidebar ? box({ key: 'sidebar' }) : null, box({ key: 'body' }), ); const bodyId = (resolved: string[]) => resolved.find((id) => id.endsWith('body'));