This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

loggy polish + TUI debounce primitive

Nathan Herald (Apr 17, 2026, 6:44 PM +0200) 8a93e2da dd68da3c

+178 -79
+6 -23
demos/loggy/main.ts
··· 203 203 } 204 204 process.stdin.resume(); 205 205 206 - // Reactive render loop, throttled to at most one render per macrotask so 207 - // a fast-emitting child (e.g., `find /`) doesn't starve the event loop 208 - // of its chance to service stdin keystrokes. The effect body registers 209 - // deps explicitly by reading every signal that should trigger a 210 - // re-render, then schedules renderFrame for the next tick. 211 - let renderPending = false; 212 - effect(() => { 213 - visibleLogs.get(); 214 - filter.get(); 215 - searchQuery.get(); 216 - searchActive.get(); 217 - follow.get(); 218 - selectedIndex.get(); 219 - scrollOffset.get(); 220 - childState.get(); 221 - quitPending.get(); 222 - if (renderPending) return; 223 - renderPending = true; 224 - setImmediate(() => { 225 - renderPending = false; 226 - renderFrame(); 227 - }); 228 - }); 206 + // Reactive render loop. Deps are auto-registered by reads inside 207 + // renderFrame. The store's `version` is a debouncedSignal so a 208 + // firehose of appends coalesces to at most one notification per tick 209 + // — the effect naturally runs at most once per tick without any 210 + // render-side throttling. 211 + effect(() => { renderFrame(); }); 229 212 230 213 process.stdin.on("data", (data: Buffer | string) => { 231 214 const buf = typeof data === "string" ? Buffer.from(data) : data;
+33 -19
demos/loggy/screen.ts
··· 56 56 const entries = state.visibleLogs.get(); 57 57 const viewport = Math.max(1, ctx.rows - 4); // panel top + bottom + footer 58 58 59 - // Follow mode: keep selectedIndex pinned to the last entry so the 60 - // scroll region clamps its offset to show the bottom. 61 - if (state.follow.get() && entries.length > 0) { 62 - const last = entries.length - 1; 63 - if (state.selectedIndex.peek() !== last) state.selectedIndex.set(last); 64 - } 59 + // If follow mode is on, always position the viewport at the bottom 60 + // regardless of the stored selectedIndex. We do NOT write to the 61 + // signal from inside render — that causes cascading re-renders and 62 + // stale-state bugs. Keeping this read-only keeps the render 63 + // deterministic; manual scrolling flips follow off in handleKey. 64 + const follow = state.follow.get(); 65 + const last = Math.max(0, entries.length - 1); 66 + const effectiveSelected = follow 67 + ? last 68 + : Math.min(state.selectedIndex.get(), last); 65 69 66 70 const region = updateScrollRegion( 67 71 { 68 72 offset: state.scrollOffset.get(), 69 - selectedIndex: state.selectedIndex.get(), 73 + selectedIndex: effectiveSelected, 70 74 totalItems: entries.length, 71 75 viewportHeight: viewport, 72 76 }, 73 77 entries.length, 74 78 viewport, 75 79 ); 76 - // Persist the clamped offset so subsequent ticks don't fight with it 77 - if (region.offset !== state.scrollOffset.peek()) state.scrollOffset.set(region.offset); 78 80 79 81 const title = "loggy — " + state.commandDisplay; 80 82 83 + // When entries is empty we still want the panel at full height — 84 + // otherwise layoutPanel shrinks to fit the fixed-height text node 85 + // and the panel collapses to one row. Pair the text with a flex 86 + // spacer so the panel stays the same size regardless of state. 87 + const panelBody = entries.length === 0 88 + ? [ 89 + text(" (waiting for matching output…)", "muted", { dim: true }), 90 + canvas(() => {}, {}), // fills remaining height 91 + ] 92 + : [ 93 + // selectable is already flex — DO NOT add a sibling flex 94 + // spacer here, or the panel's flex budget will be split and 95 + // only half the viewport will show log lines. 96 + selectable(region, entries, (entry, _i, _selected) => 97 + renderLogLine(entry, follow)), 98 + ]; 99 + 81 100 return [ 82 - panel(title, [ 83 - entries.length === 0 84 - ? text(" (waiting for output…)", "muted", { dim: true }) 85 - : selectable(region, entries, (entry, _i, _selected) => 86 - renderLogLine(entry, state.follow.get())), 87 - canvas(() => {}, {}), // flex spacer 88 - ]), 101 + panel(title, panelBody), 89 102 renderFooter(state), 90 103 ]; 91 104 }, ··· 141 154 ? `\u2717 signaled (${child.signal})` 142 155 : `\u25cb exited (code ${child.exitCode ?? "?"})`; 143 156 144 - // footer() is a single dim line anchored to the bottom by the framework's 145 - // root layout. We join the two halves with a middle-dot for visual weight. 146 - return footer(`${leftText} \u00b7 ${statusText}`); 157 + // Two-column bottom bar: keybindings / search prompt on the left, 158 + // child process status right-aligned. Anchored to the bottom by the 159 + // framework's root layout. 160 + return footer(leftText, statusText); 147 161 } 148 162 149 163 function marker(letter: string, active: boolean): string {
+52 -32
demos/loggy/store.ts
··· 1 1 // In-memory log store + pure filter/search over entries. 2 2 // 3 - // Design notes 4 - // ------------ 5 - // Backing storage is a single mutable array (`buffer`). We never allocate 6 - // a new array on append — that would be O(n) per append and for a 7 - // high-volume source like `find /` quickly drags the event loop to a 8 - // halt. Instead we `.push()` (amortized O(1)) and do a bulk 9 - // `.splice(0, …)` only when we've drifted past `scrollbackLimit * 2`, 10 - // which amortizes ring-buffer maintenance to O(1) per append. 3 + // Design 4 + // ------ 5 + // Backing storage is a single mutable array (`buffer`). Append pushes 6 + // onto it (amortized O(1)) and triggers a DEBOUNCED change notification 7 + // via `debouncedSignal` — writers mark the store dirty as often as they 8 + // like; subscribers only hear about it once per `setImmediate` tick. 9 + // That keeps a firehose child from saturating the reactive graph and 10 + // gives the TUI a predictable at-most-one-render-per-tick cadence 11 + // without the render layer needing to know anything about throttling. 11 12 // 12 - // Reactivity is a `version` signal bumped on each mutation. `entries.get` 13 - // registers a dep on `version` so the effect-driven render loop re-runs 14 - // when new lines arrive, without allocating a fresh array each time. 15 - // `applyView` is a pure function over a snapshot — unit-testable without 16 - // the store. 13 + // `applyView` is a pure function over an array snapshot so it's easy 14 + // to unit-test. 17 15 18 - import { signal, stripAnsi } from "../../src/tui/index.ts"; 16 + import { debouncedSignal, stripAnsi, type DebouncedSignal } from "../../src/tui/index.ts"; 19 17 20 18 export type LogSource = "out" | "err"; 21 19 ··· 27 25 source: LogSource; 28 26 /** Raw line, possibly containing ANSI escape sequences. No trailing \n. */ 29 27 line: string; 28 + /** Lazy cache of `stripAnsi(line).toLowerCase()` — materialized on 29 + * first search to avoid redoing the work on every render. */ 30 + _plainLower?: string; 30 31 } 31 32 32 33 export type FilterMode = "both" | "out" | "err"; 33 34 34 35 export interface ReadableEntries { 35 36 /** Returns the current window of entries (at most `scrollbackLimit`). 36 - * Registers a dep on the underlying version signal so reactive effects 37 - * re-run on append. Returned array MUST NOT be mutated by callers. */ 37 + * Registers a dep on the underlying debounced signal so reactive 38 + * effects re-run on append (coalesced to once per tick). Returned 39 + * array MUST NOT be mutated by callers. */ 38 40 get(): readonly LogEntry[]; 41 + /** Same as `get()` but without subscribing. Safe outside effects. */ 42 + peek(): readonly LogEntry[]; 39 43 } 40 44 41 45 export interface LogStore { 42 46 readonly entries: ReadableEntries; 43 47 append(source: LogSource, line: string, now?: number): LogEntry; 44 48 clear(): void; 49 + /** Force the debounced change notification to fire synchronously. 50 + * Useful in tests that want to observe post-append state without 51 + * waiting a tick. */ 52 + flush(): void; 45 53 readonly scrollbackLimit: number; 46 54 } 47 55 ··· 49 57 if (scrollbackLimit <= 0) throw new Error("scrollbackLimit must be positive"); 50 58 51 59 const buffer: LogEntry[] = []; 52 - const version = signal(0); 60 + const version: DebouncedSignal = debouncedSignal(); 53 61 let nextSeq = 0; 54 62 const compactAt = scrollbackLimit * 2; 55 63 64 + function snapshot(): readonly LogEntry[] { 65 + if (buffer.length > scrollbackLimit) { 66 + return buffer.slice(buffer.length - scrollbackLimit); 67 + } 68 + return buffer; 69 + } 70 + 56 71 return { 57 72 scrollbackLimit, 58 73 entries: { 59 74 get(): readonly LogEntry[] { 60 - version.get(); // register dep so effects re-fire on mutation 61 - // Trim the viewer's window to the scrollback size without 62 - // compacting the underlying buffer on every read. 63 - if (buffer.length > scrollbackLimit) { 64 - return buffer.slice(buffer.length - scrollbackLimit); 65 - } 66 - return buffer; 75 + version.get(); // subscribe (coalesced) — triggers at-most-once-per-tick 76 + return snapshot(); 77 + }, 78 + peek(): readonly LogEntry[] { 79 + return snapshot(); 67 80 }, 68 81 }, 69 82 append(source, line, now = Date.now()) { 70 83 const entry: LogEntry = { seq: nextSeq++, ts: now, source, line }; 71 84 buffer.push(entry); 72 - // Amortized O(1): only compact when we've drifted past 2x the 73 - // scrollback limit. Single bulk splice, not one-shift-per-append. 74 85 if (buffer.length >= compactAt) { 75 86 buffer.splice(0, buffer.length - scrollbackLimit); 76 87 } 77 - version.set(version.peek() + 1); 88 + version.bump(); 78 89 return entry; 79 90 }, 80 91 clear() { 81 92 buffer.length = 0; 82 - version.set(version.peek() + 1); 93 + version.bump(); 94 + }, 95 + flush() { 96 + version.flush(); 83 97 }, 84 98 }; 85 99 } 86 100 87 101 /** Apply the active filter + search to a list of entries. Pure function. 88 - * Search is case-insensitive substring match against the *plain text* of 89 - * the line (ANSI stripped), so users can search for literal text that 90 - * might be wrapped in color codes. */ 102 + * Search is case-insensitive substring match against the *plain text* 103 + * of the line (ANSI stripped, lowercased), cached on the entry so the 104 + * expensive regex doesn't re-run per render. */ 91 105 export function applyView( 92 106 entries: readonly LogEntry[], 93 107 filter: FilterMode, ··· 99 113 if (filter === "out" && entry.source !== "out") continue; 100 114 if (filter === "err" && entry.source !== "err") continue; 101 115 if (q) { 102 - const plain = stripAnsi(entry.line).toLowerCase(); 116 + // Cache the plain-text form; stripAnsi + toLowerCase is the hot 117 + // path for searches over a large scrollback. 118 + let plain = entry._plainLower; 119 + if (plain === undefined) { 120 + plain = stripAnsi(entry.line).toLowerCase(); 121 + entry._plainLower = plain; 122 + } 103 123 if (!plain.includes(q)) continue; 104 124 } 105 125 result.push(entry);
+2 -2
src/tui/builders.ts
··· 215 215 return { type: "statusBar", left, right }; 216 216 } 217 217 218 - export function footer(hints: string): FooterNode { 219 - return { type: "footer", hints }; 218 + export function footer(hints: string, right?: string): FooterNode { 219 + return right == null ? { type: "footer", hints } : { type: "footer", hints, right }; 220 220 } 221 221 222 222 export function askBar(
+5 -1
src/tui/index.ts
··· 4 4 export { parseKey, type KeyEvent } from "./input.ts"; 5 5 6 6 // Signals 7 - export { signal, computed, effect, batch, type Signal } from "./signals.ts"; 7 + export { 8 + signal, computed, effect, batch, 9 + debouncedSignal, 10 + type Signal, type Computed, type DebouncedSignal, 11 + } from "./signals.ts"; 8 12 9 13 // Types 10 14 export type { Cell, Screen, ScreenContext } from "./types.ts";
+5
src/tui/nodes.ts
··· 161 161 162 162 export interface FooterNode { 163 163 type: "footer"; 164 + /** Left-aligned text. For a single-column footer, pass only this. */ 164 165 hints: string; 166 + /** Optional right-aligned text. When set, the footer renders two 167 + * columns — hints on the left, this on the right — padded to fill 168 + * the viewport width. */ 169 + right?: string; 165 170 _rect?: Rect; 166 171 } 167 172
+6 -1
src/tui/renderer.ts
··· 378 378 } 379 379 380 380 function renderFooter(node: FooterNode, rect: Rect, theme: Theme): string { 381 - return fgColor(theme.fgMu) + writeAt(rect.y + 1, rect.x + 2, node.hints) + RESET; 381 + let out = fgColor(theme.fgMu) + writeAt(rect.y + 1, rect.x + 2, node.hints); 382 + if (node.right) { 383 + const rightWidth = visibleLength(node.right); 384 + out += writeAt(rect.y + 1, rect.x + rect.width - rightWidth - 2, node.right); 385 + } 386 + return out + RESET; 382 387 } 383 388 384 389 function renderAskBar(
+5
src/tui/screen.ts
··· 602 602 } 603 603 case "footer": { 604 604 writeStringBuf(buf, rect.y, rect.x + 1, node.hints, theme.fgMu, null); 605 + if (node.right) { 606 + const rightWidth = visibleLength(node.right); 607 + const plain = node.right.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); 608 + writeStringBuf(buf, rect.y, rect.x + rect.width - rightWidth - 2, plain, theme.fgMu, null); 609 + } 605 610 break; 606 611 } 607 612 case "askBar": {
+64 -1
src/tui/signals.ts
··· 23 23 }; 24 24 } 25 25 26 - export function computed<T>(fn: () => T): { get(): T } { 26 + export interface Computed<T> { 27 + /** Read and subscribe (inside an effect) to this computed's value. */ 28 + get(): T; 29 + /** Read the current value without subscribing. Safe to call from 30 + * non-reactive contexts. */ 31 + peek(): T; 32 + } 33 + 34 + export function computed<T>(fn: () => T): Computed<T> { 27 35 const c = preactComputed(fn); 28 36 return { 29 37 get() { return c.value; }, 38 + peek() { return c.peek(); }, 30 39 }; 31 40 } 32 41 33 42 export { preactEffect as effect, preactBatch as batch }; 43 + 44 + /** Create a signal that debounces its own change notifications. Writers 45 + * `bump()` as often as they like; the underlying preact signal receives 46 + * at most one `.set(v+1)` per `setImmediate`-scheduled tick. Effects that 47 + * depend on it run at most once per tick regardless of bump rate. 48 + * 49 + * Use for high-volume producers that would otherwise saturate the 50 + * reactive graph (e.g., a log store fed by a firehose). Consumers read 51 + * the current state from whatever mutable structure the producer owns; 52 + * the signal itself is purely a change notifier. 53 + * 54 + * Returns: 55 + * - `get()` — subscribe to change events (returns the monotonic tick 56 + * counter; consumers don't need the value itself — they re-read the 57 + * producer's source-of-truth data on each update). 58 + * - `peek()` — read the current tick without subscribing. 59 + * - `bump()` — mark a change; may schedule a tick if one isn't already 60 + * pending. Does nothing extra if a tick is in flight. 61 + * - `flush()` — force the pending tick to fire synchronously (useful 62 + * in tests so `await flush()` yields a consistent observable state). 63 + */ 64 + export interface DebouncedSignal { 65 + get(): number; 66 + peek(): number; 67 + bump(): void; 68 + flush(): void; 69 + } 70 + 71 + export function debouncedSignal(): DebouncedSignal { 72 + const s = preactSignal(0); 73 + let pending = false; 74 + let pendingTicks = 0; 75 + let scheduled: ReturnType<typeof setImmediate> | null = null; 76 + 77 + const flushNow = (): void => { 78 + if (!pending) return; 79 + pending = false; 80 + if (scheduled) { clearImmediate(scheduled); scheduled = null; } 81 + s.value = s.peek() + pendingTicks; 82 + pendingTicks = 0; 83 + }; 84 + 85 + return { 86 + get() { return s.value; }, 87 + peek() { return s.peek(); }, 88 + bump() { 89 + pendingTicks++; 90 + if (pending) return; 91 + pending = true; 92 + scheduled = setImmediate(flushNow); 93 + }, 94 + flush: flushNow, 95 + }; 96 + }