···203203}
204204process.stdin.resume();
205205206206-// Reactive render loop, throttled to at most one render per macrotask so
207207-// a fast-emitting child (e.g., `find /`) doesn't starve the event loop
208208-// of its chance to service stdin keystrokes. The effect body registers
209209-// deps explicitly by reading every signal that should trigger a
210210-// re-render, then schedules renderFrame for the next tick.
211211-let renderPending = false;
212212-effect(() => {
213213- visibleLogs.get();
214214- filter.get();
215215- searchQuery.get();
216216- searchActive.get();
217217- follow.get();
218218- selectedIndex.get();
219219- scrollOffset.get();
220220- childState.get();
221221- quitPending.get();
222222- if (renderPending) return;
223223- renderPending = true;
224224- setImmediate(() => {
225225- renderPending = false;
226226- renderFrame();
227227- });
228228-});
206206+// Reactive render loop. Deps are auto-registered by reads inside
207207+// renderFrame. The store's `version` is a debouncedSignal so a
208208+// firehose of appends coalesces to at most one notification per tick
209209+// — the effect naturally runs at most once per tick without any
210210+// render-side throttling.
211211+effect(() => { renderFrame(); });
229212230213process.stdin.on("data", (data: Buffer | string) => {
231214 const buf = typeof data === "string" ? Buffer.from(data) : data;
+33-19
demos/loggy/screen.ts
···5656 const entries = state.visibleLogs.get();
5757 const viewport = Math.max(1, ctx.rows - 4); // panel top + bottom + footer
58585959- // Follow mode: keep selectedIndex pinned to the last entry so the
6060- // scroll region clamps its offset to show the bottom.
6161- if (state.follow.get() && entries.length > 0) {
6262- const last = entries.length - 1;
6363- if (state.selectedIndex.peek() !== last) state.selectedIndex.set(last);
6464- }
5959+ // If follow mode is on, always position the viewport at the bottom
6060+ // regardless of the stored selectedIndex. We do NOT write to the
6161+ // signal from inside render — that causes cascading re-renders and
6262+ // stale-state bugs. Keeping this read-only keeps the render
6363+ // deterministic; manual scrolling flips follow off in handleKey.
6464+ const follow = state.follow.get();
6565+ const last = Math.max(0, entries.length - 1);
6666+ const effectiveSelected = follow
6767+ ? last
6868+ : Math.min(state.selectedIndex.get(), last);
65696670 const region = updateScrollRegion(
6771 {
6872 offset: state.scrollOffset.get(),
6969- selectedIndex: state.selectedIndex.get(),
7373+ selectedIndex: effectiveSelected,
7074 totalItems: entries.length,
7175 viewportHeight: viewport,
7276 },
7377 entries.length,
7478 viewport,
7579 );
7676- // Persist the clamped offset so subsequent ticks don't fight with it
7777- if (region.offset !== state.scrollOffset.peek()) state.scrollOffset.set(region.offset);
78807981 const title = "loggy — " + state.commandDisplay;
80828383+ // When entries is empty we still want the panel at full height —
8484+ // otherwise layoutPanel shrinks to fit the fixed-height text node
8585+ // and the panel collapses to one row. Pair the text with a flex
8686+ // spacer so the panel stays the same size regardless of state.
8787+ const panelBody = entries.length === 0
8888+ ? [
8989+ text(" (waiting for matching output…)", "muted", { dim: true }),
9090+ canvas(() => {}, {}), // fills remaining height
9191+ ]
9292+ : [
9393+ // selectable is already flex — DO NOT add a sibling flex
9494+ // spacer here, or the panel's flex budget will be split and
9595+ // only half the viewport will show log lines.
9696+ selectable(region, entries, (entry, _i, _selected) =>
9797+ renderLogLine(entry, follow)),
9898+ ];
9999+81100 return [
8282- panel(title, [
8383- entries.length === 0
8484- ? text(" (waiting for output…)", "muted", { dim: true })
8585- : selectable(region, entries, (entry, _i, _selected) =>
8686- renderLogLine(entry, state.follow.get())),
8787- canvas(() => {}, {}), // flex spacer
8888- ]),
101101+ panel(title, panelBody),
89102 renderFooter(state),
90103 ];
91104 },
···141154 ? `\u2717 signaled (${child.signal})`
142155 : `\u25cb exited (code ${child.exitCode ?? "?"})`;
143156144144- // footer() is a single dim line anchored to the bottom by the framework's
145145- // root layout. We join the two halves with a middle-dot for visual weight.
146146- return footer(`${leftText} \u00b7 ${statusText}`);
157157+ // Two-column bottom bar: keybindings / search prompt on the left,
158158+ // child process status right-aligned. Anchored to the bottom by the
159159+ // framework's root layout.
160160+ return footer(leftText, statusText);
147161}
148162149163function marker(letter: string, active: boolean): string {
+52-32
demos/loggy/store.ts
···11// In-memory log store + pure filter/search over entries.
22//
33-// Design notes
44-// ------------
55-// Backing storage is a single mutable array (`buffer`). We never allocate
66-// a new array on append — that would be O(n) per append and for a
77-// high-volume source like `find /` quickly drags the event loop to a
88-// halt. Instead we `.push()` (amortized O(1)) and do a bulk
99-// `.splice(0, …)` only when we've drifted past `scrollbackLimit * 2`,
1010-// which amortizes ring-buffer maintenance to O(1) per append.
33+// Design
44+// ------
55+// Backing storage is a single mutable array (`buffer`). Append pushes
66+// onto it (amortized O(1)) and triggers a DEBOUNCED change notification
77+// via `debouncedSignal` — writers mark the store dirty as often as they
88+// like; subscribers only hear about it once per `setImmediate` tick.
99+// That keeps a firehose child from saturating the reactive graph and
1010+// gives the TUI a predictable at-most-one-render-per-tick cadence
1111+// without the render layer needing to know anything about throttling.
1112//
1212-// Reactivity is a `version` signal bumped on each mutation. `entries.get`
1313-// registers a dep on `version` so the effect-driven render loop re-runs
1414-// when new lines arrive, without allocating a fresh array each time.
1515-// `applyView` is a pure function over a snapshot — unit-testable without
1616-// the store.
1313+// `applyView` is a pure function over an array snapshot so it's easy
1414+// to unit-test.
17151818-import { signal, stripAnsi } from "../../src/tui/index.ts";
1616+import { debouncedSignal, stripAnsi, type DebouncedSignal } from "../../src/tui/index.ts";
19172018export type LogSource = "out" | "err";
2119···2725 source: LogSource;
2826 /** Raw line, possibly containing ANSI escape sequences. No trailing \n. */
2927 line: string;
2828+ /** Lazy cache of `stripAnsi(line).toLowerCase()` — materialized on
2929+ * first search to avoid redoing the work on every render. */
3030+ _plainLower?: string;
3031}
31323233export type FilterMode = "both" | "out" | "err";
33343435export interface ReadableEntries {
3536 /** Returns the current window of entries (at most `scrollbackLimit`).
3636- * Registers a dep on the underlying version signal so reactive effects
3737- * re-run on append. Returned array MUST NOT be mutated by callers. */
3737+ * Registers a dep on the underlying debounced signal so reactive
3838+ * effects re-run on append (coalesced to once per tick). Returned
3939+ * array MUST NOT be mutated by callers. */
3840 get(): readonly LogEntry[];
4141+ /** Same as `get()` but without subscribing. Safe outside effects. */
4242+ peek(): readonly LogEntry[];
3943}
40444145export interface LogStore {
4246 readonly entries: ReadableEntries;
4347 append(source: LogSource, line: string, now?: number): LogEntry;
4448 clear(): void;
4949+ /** Force the debounced change notification to fire synchronously.
5050+ * Useful in tests that want to observe post-append state without
5151+ * waiting a tick. */
5252+ flush(): void;
4553 readonly scrollbackLimit: number;
4654}
4755···4957 if (scrollbackLimit <= 0) throw new Error("scrollbackLimit must be positive");
50585159 const buffer: LogEntry[] = [];
5252- const version = signal(0);
6060+ const version: DebouncedSignal = debouncedSignal();
5361 let nextSeq = 0;
5462 const compactAt = scrollbackLimit * 2;
55636464+ function snapshot(): readonly LogEntry[] {
6565+ if (buffer.length > scrollbackLimit) {
6666+ return buffer.slice(buffer.length - scrollbackLimit);
6767+ }
6868+ return buffer;
6969+ }
7070+5671 return {
5772 scrollbackLimit,
5873 entries: {
5974 get(): readonly LogEntry[] {
6060- version.get(); // register dep so effects re-fire on mutation
6161- // Trim the viewer's window to the scrollback size without
6262- // compacting the underlying buffer on every read.
6363- if (buffer.length > scrollbackLimit) {
6464- return buffer.slice(buffer.length - scrollbackLimit);
6565- }
6666- return buffer;
7575+ version.get(); // subscribe (coalesced) — triggers at-most-once-per-tick
7676+ return snapshot();
7777+ },
7878+ peek(): readonly LogEntry[] {
7979+ return snapshot();
6780 },
6881 },
6982 append(source, line, now = Date.now()) {
7083 const entry: LogEntry = { seq: nextSeq++, ts: now, source, line };
7184 buffer.push(entry);
7272- // Amortized O(1): only compact when we've drifted past 2x the
7373- // scrollback limit. Single bulk splice, not one-shift-per-append.
7485 if (buffer.length >= compactAt) {
7586 buffer.splice(0, buffer.length - scrollbackLimit);
7687 }
7777- version.set(version.peek() + 1);
8888+ version.bump();
7889 return entry;
7990 },
8091 clear() {
8192 buffer.length = 0;
8282- version.set(version.peek() + 1);
9393+ version.bump();
9494+ },
9595+ flush() {
9696+ version.flush();
8397 },
8498 };
8599}
8610087101/** Apply the active filter + search to a list of entries. Pure function.
8888- * Search is case-insensitive substring match against the *plain text* of
8989- * the line (ANSI stripped), so users can search for literal text that
9090- * might be wrapped in color codes. */
102102+ * Search is case-insensitive substring match against the *plain text*
103103+ * of the line (ANSI stripped, lowercased), cached on the entry so the
104104+ * expensive regex doesn't re-run per render. */
91105export function applyView(
92106 entries: readonly LogEntry[],
93107 filter: FilterMode,
···99113 if (filter === "out" && entry.source !== "out") continue;
100114 if (filter === "err" && entry.source !== "err") continue;
101115 if (q) {
102102- const plain = stripAnsi(entry.line).toLowerCase();
116116+ // Cache the plain-text form; stripAnsi + toLowerCase is the hot
117117+ // path for searches over a large scrollback.
118118+ let plain = entry._plainLower;
119119+ if (plain === undefined) {
120120+ plain = stripAnsi(entry.line).toLowerCase();
121121+ entry._plainLower = plain;
122122+ }
103123 if (!plain.includes(q)) continue;
104124 }
105125 result.push(entry);
+2-2
src/tui/builders.ts
···215215 return { type: "statusBar", left, right };
216216}
217217218218-export function footer(hints: string): FooterNode {
219219- return { type: "footer", hints };
218218+export function footer(hints: string, right?: string): FooterNode {
219219+ return right == null ? { type: "footer", hints } : { type: "footer", hints, right };
220220}
221221222222export function askBar(
+5-1
src/tui/index.ts
···44export { parseKey, type KeyEvent } from "./input.ts";
5566// Signals
77-export { signal, computed, effect, batch, type Signal } from "./signals.ts";
77+export {
88+ signal, computed, effect, batch,
99+ debouncedSignal,
1010+ type Signal, type Computed, type DebouncedSignal,
1111+} from "./signals.ts";
812913// Types
1014export type { Cell, Screen, ScreenContext } from "./types.ts";
+5
src/tui/nodes.ts
···161161162162export interface FooterNode {
163163 type: "footer";
164164+ /** Left-aligned text. For a single-column footer, pass only this. */
164165 hints: string;
166166+ /** Optional right-aligned text. When set, the footer renders two
167167+ * columns — hints on the left, this on the right — padded to fill
168168+ * the viewport width. */
169169+ right?: string;
165170 _rect?: Rect;
166171}
167172
···2323 };
2424}
25252626-export function computed<T>(fn: () => T): { get(): T } {
2626+export interface Computed<T> {
2727+ /** Read and subscribe (inside an effect) to this computed's value. */
2828+ get(): T;
2929+ /** Read the current value without subscribing. Safe to call from
3030+ * non-reactive contexts. */
3131+ peek(): T;
3232+}
3333+3434+export function computed<T>(fn: () => T): Computed<T> {
2735 const c = preactComputed(fn);
2836 return {
2937 get() { return c.value; },
3838+ peek() { return c.peek(); },
3039 };
3140}
32413342export { preactEffect as effect, preactBatch as batch };
4343+4444+/** Create a signal that debounces its own change notifications. Writers
4545+ * `bump()` as often as they like; the underlying preact signal receives
4646+ * at most one `.set(v+1)` per `setImmediate`-scheduled tick. Effects that
4747+ * depend on it run at most once per tick regardless of bump rate.
4848+ *
4949+ * Use for high-volume producers that would otherwise saturate the
5050+ * reactive graph (e.g., a log store fed by a firehose). Consumers read
5151+ * the current state from whatever mutable structure the producer owns;
5252+ * the signal itself is purely a change notifier.
5353+ *
5454+ * Returns:
5555+ * - `get()` — subscribe to change events (returns the monotonic tick
5656+ * counter; consumers don't need the value itself — they re-read the
5757+ * producer's source-of-truth data on each update).
5858+ * - `peek()` — read the current tick without subscribing.
5959+ * - `bump()` — mark a change; may schedule a tick if one isn't already
6060+ * pending. Does nothing extra if a tick is in flight.
6161+ * - `flush()` — force the pending tick to fire synchronously (useful
6262+ * in tests so `await flush()` yields a consistent observable state).
6363+ */
6464+export interface DebouncedSignal {
6565+ get(): number;
6666+ peek(): number;
6767+ bump(): void;
6868+ flush(): void;
6969+}
7070+7171+export function debouncedSignal(): DebouncedSignal {
7272+ const s = preactSignal(0);
7373+ let pending = false;
7474+ let pendingTicks = 0;
7575+ let scheduled: ReturnType<typeof setImmediate> | null = null;
7676+7777+ const flushNow = (): void => {
7878+ if (!pending) return;
7979+ pending = false;
8080+ if (scheduled) { clearImmediate(scheduled); scheduled = null; }
8181+ s.value = s.peek() + pendingTicks;
8282+ pendingTicks = 0;
8383+ };
8484+8585+ return {
8686+ get() { return s.value; },
8787+ peek() { return s.peek(); },
8888+ bump() {
8989+ pendingTicks++;
9090+ if (pending) return;
9191+ pending = true;
9292+ scheduled = setImmediate(flushNow);
9393+ },
9494+ flush: flushNow,
9595+ };
9696+}