···313313314314> **Alpha** — the TUI framework API is unstable and will change. Use it for experiments, not production.
315315316316-The `demos/` directory has three working apps built with the framework:
316316+The `demos/` directory has four working apps built with the framework:
317317318318- **file-browser** — two-pane directory tree + file preview with soft-wrap and markdown highlighting
319319- **reminders** — full CRUD backed by `.md` files, three views (list, board, calendar), overlays
320320- **agent-teams** — live dashboard of a simulated AI agent hierarchy with real-time updates
321321+- **loggy** — live log viewer that wraps a command, captures stdout and stderr as separate streams (via pipes, not a PTY), and renders them with interactive filter (`o`/`e`/`b`), search (`/`), and follow-mode (`f`). Optional tee to `--out` / `--err` / `--log` files.
321322322322-Run them with `node --experimental-strip-types demos/{name}/main.ts`. Each demo includes unit tests and PTY integration tests that exercise the testing library.
323323+Run them with `node --experimental-strip-types demos/{name}/main.ts` (or `./demos/run <name>`). Each demo includes unit tests and PTY integration tests that exercise the testing library.
323324324325## Skill Reference
325326
+26
bin/loggy
···11+#!/usr/bin/env node
22+// Thin shim that execs node against demos/loggy/main.ts with
33+// --experimental-strip-types so it runs directly from source without a
44+// separate build step. Matches the "run a demo from source" pattern used
55+// by ./demos/run.
66+77+import { existsSync } from 'node:fs';
88+import { spawnSync } from 'node:child_process';
99+import { fileURLToPath } from 'node:url';
1010+import { dirname, join } from 'node:path';
1111+1212+const __dirname = dirname(fileURLToPath(import.meta.url));
1313+const mainTs = join(__dirname, '..', 'demos', 'loggy', 'main.ts');
1414+1515+if (!existsSync(mainTs)) {
1616+ console.error(`loggy: missing ${mainTs}`);
1717+ process.exit(1);
1818+}
1919+2020+const result = spawnSync(
2121+ process.execPath,
2222+ ['--experimental-strip-types', '--no-warnings', mainTs, ...process.argv.slice(2)],
2323+ { stdio: 'inherit', env: process.env },
2424+);
2525+2626+process.exit(result.status ?? 1);
+107
demos/loggy/child.ts
···11+// Spawn a child process with piped stdout/stderr (NOT a PTY), line-buffer
22+// each stream, and emit tagged `onLine` callbacks.
33+//
44+// We explicitly avoid node-pty here: the whole point of loggy is to keep
55+// stdout and stderr distinguishable, and a PTY merges them at the kernel
66+// level. The tradeoff is that the child process doesn't get a terminal —
77+// TUI apps like vim/htop won't work, but line-oriented logs (CI, builds,
88+// servers) are exactly right.
99+1010+import { spawn, type ChildProcess } from "node:child_process";
1111+import type { LogSource } from "./store.ts";
1212+1313+export interface SpawnChildOptions {
1414+ forceColor: boolean;
1515+ onLine: (source: LogSource, line: string) => void;
1616+ onExit: (exitCode: number | null, signal: NodeJS.Signals | null) => void;
1717+ /** Called when spawn itself fails (ENOENT etc.). */
1818+ onError?: (err: Error) => void;
1919+}
2020+2121+export interface ChildHandle {
2222+ readonly pid: number | undefined;
2323+ /** Ask the child to exit. SIGTERM first; after `graceMs` if still alive,
2424+ * SIGKILL. No-op if the child has already exited. */
2525+ stop(graceMs?: number): Promise<void>;
2626+ /** True once the child's `exit` event has fired. */
2727+ readonly exited: boolean;
2828+}
2929+3030+export function spawnChild(
3131+ command: string,
3232+ args: readonly string[],
3333+ opts: SpawnChildOptions,
3434+): ChildHandle {
3535+ const env: Record<string, string> = { ...(process.env as Record<string, string>) };
3636+ if (opts.forceColor && !env.FORCE_COLOR) env.FORCE_COLOR = "1";
3737+3838+ const child: ChildProcess = spawn(command, args as string[], {
3939+ stdio: ["ignore", "pipe", "pipe"],
4040+ env,
4141+ });
4242+4343+ let exited = false;
4444+ child.on("exit", (code, signal) => {
4545+ exited = true;
4646+ // Flush any partial buffered line on exit so we don't drop the last
4747+ // piece of output that didn't end in \n.
4848+ flushRemainder("out");
4949+ flushRemainder("err");
5050+ opts.onExit(code, signal);
5151+ });
5252+5353+ if (opts.onError) {
5454+ child.on("error", opts.onError);
5555+ }
5656+5757+ const remainders: Record<LogSource, string> = { out: "", err: "" };
5858+5959+ function feed(source: LogSource, chunk: Buffer | string) {
6060+ const text = remainders[source] + (typeof chunk === "string" ? chunk : chunk.toString("utf-8"));
6161+ const lines = text.split("\n");
6262+ // The last element is the partial line after the final \n (or the whole
6363+ // thing if there was no \n at all).
6464+ remainders[source] = lines.pop() ?? "";
6565+ for (const line of lines) {
6666+ opts.onLine(source, line);
6767+ }
6868+ }
6969+7070+ function flushRemainder(source: LogSource) {
7171+ const partial = remainders[source];
7272+ if (partial.length > 0) {
7373+ remainders[source] = "";
7474+ opts.onLine(source, partial);
7575+ }
7676+ }
7777+7878+ child.stdout?.on("data", (chunk) => feed("out", chunk));
7979+ child.stderr?.on("data", (chunk) => feed("err", chunk));
8080+ child.stdout?.on("end", () => flushRemainder("out"));
8181+ child.stderr?.on("end", () => flushRemainder("err"));
8282+8383+ return {
8484+ get pid() { return child.pid; },
8585+ get exited() { return exited; },
8686+ stop(graceMs = 2000) {
8787+ return new Promise<void>((resolve) => {
8888+ if (exited) { resolve(); return; }
8989+9090+ try { child.kill("SIGTERM"); } catch {}
9191+9292+ const killTimer = setTimeout(() => {
9393+ if (!exited) {
9494+ try { child.kill("SIGKILL"); } catch {}
9595+ }
9696+ }, graceMs);
9797+9898+ const onExit = () => {
9999+ clearTimeout(killTimer);
100100+ resolve();
101101+ };
102102+ if (exited) { clearTimeout(killTimer); resolve(); }
103103+ else child.once("exit", onExit);
104104+ });
105105+ },
106106+ };
107107+}
+115
demos/loggy/cli.ts
···11+// Pure argv parser for loggy. No I/O — safe to unit-test.
22+33+export interface ParsedArgs {
44+ /** File to tee child stdout to (raw). */
55+ out: string | null;
66+ /** File to tee child stderr to (raw). */
77+ err: string | null;
88+ /** File to tee combined tagged output to (plain-text with prefix). */
99+ log: string | null;
1010+ /** When true, don't set FORCE_COLOR=1 in the child's env. */
1111+ noColor: boolean;
1212+ /** In-memory line limit before oldest lines are dropped. */
1313+ scrollback: number;
1414+ /** The command to run. */
1515+ command: string;
1616+ /** Arguments to pass to the command. */
1717+ args: string[];
1818+}
1919+2020+export const DEFAULT_SCROLLBACK = 10_000;
2121+2222+export const USAGE =
2323+ `Usage: loggy [flags] <command> [args...]\n\n` +
2424+ `Wrap a command, capture stdout and stderr as separate streams, and stream\n` +
2525+ `them into a live TUI with filter (o/e/b), search (/), and follow (f).\n\n` +
2626+ `Flags:\n` +
2727+ ` --out <path> Tee child stdout to <path>\n` +
2828+ ` --err <path> Tee child stderr to <path>\n` +
2929+ ` --log <path> Tee both streams to <path> as tagged plain-text\n` +
3030+ ` --no-color Do not set FORCE_COLOR=1 in the child's env\n` +
3131+ ` --scrollback <n> In-memory line limit (default ${DEFAULT_SCROLLBACK})\n` +
3232+ ` -h, --help Show this help\n\n` +
3333+ `Examples:\n` +
3434+ ` loggy npm run build\n` +
3535+ ` loggy --log /tmp/all.log -- tsc --watch\n` +
3636+ ` loggy --out /tmp/o.log --err /tmp/e.log bin/my-server\n`;
3737+3838+export class UsageError extends Error {
3939+ constructor(message: string) {
4040+ super(message);
4141+ this.name = "UsageError";
4242+ }
4343+}
4444+4545+/** Parse loggy's argv. `argv` should exclude the node and script entries
4646+ * (i.e. `process.argv.slice(2)`). Throws `UsageError` on malformed input
4747+ * or when the user asked for help. */
4848+export function parseArgs(argv: readonly string[]): ParsedArgs {
4949+ const out: ParsedArgs = {
5050+ out: null,
5151+ err: null,
5252+ log: null,
5353+ noColor: false,
5454+ scrollback: DEFAULT_SCROLLBACK,
5555+ command: "",
5656+ args: [],
5757+ };
5858+5959+ let i = 0;
6060+ while (i < argv.length) {
6161+ const arg = argv[i];
6262+6363+ if (arg === "--") {
6464+ // Everything after -- is the command + its args
6565+ i++;
6666+ if (i >= argv.length) throw new UsageError("Missing command after --");
6767+ out.command = argv[i];
6868+ out.args = argv.slice(i + 1);
6969+ return finalize(out);
7070+ }
7171+7272+ if (arg === "-h" || arg === "--help") {
7373+ throw new UsageError("__help__");
7474+ }
7575+7676+ if (arg === "--no-color") { out.noColor = true; i++; continue; }
7777+7878+ if (arg === "--out") {
7979+ if (i + 1 >= argv.length) throw new UsageError("--out requires a path");
8080+ out.out = argv[i + 1]; i += 2; continue;
8181+ }
8282+ if (arg === "--err") {
8383+ if (i + 1 >= argv.length) throw new UsageError("--err requires a path");
8484+ out.err = argv[i + 1]; i += 2; continue;
8585+ }
8686+ if (arg === "--log") {
8787+ if (i + 1 >= argv.length) throw new UsageError("--log requires a path");
8888+ out.log = argv[i + 1]; i += 2; continue;
8989+ }
9090+ if (arg === "--scrollback") {
9191+ if (i + 1 >= argv.length) throw new UsageError("--scrollback requires a number");
9292+ const n = parseInt(argv[i + 1], 10);
9393+ if (!Number.isFinite(n) || n <= 0) throw new UsageError(`--scrollback must be a positive integer (got "${argv[i + 1]}")`);
9494+ out.scrollback = n;
9595+ i += 2;
9696+ continue;
9797+ }
9898+9999+ if (arg.startsWith("-")) {
100100+ throw new UsageError(`Unknown flag: ${arg}`);
101101+ }
102102+103103+ // First non-flag: treat as the command. Rest are its args.
104104+ out.command = arg;
105105+ out.args = argv.slice(i + 1);
106106+ return finalize(out);
107107+ }
108108+109109+ throw new UsageError("Missing command");
110110+}
111111+112112+function finalize(out: ParsedArgs): ParsedArgs {
113113+ if (!out.command) throw new UsageError("Missing command");
114114+ return out;
115115+}
+22
demos/loggy/fixtures/emitter.js
···11+#!/usr/bin/env node
22+// Fixture used by loggy integration tests. Emits a paced, deterministic
33+// sequence of stdout and stderr lines, then stays alive long enough for
44+// the test to exercise the TUI.
55+//
66+// Line cadence is deliberately slower than the TUI's render tick so each
77+// line reliably shows up in at least one frame before the next arrives.
88+99+process.stdout.write("stdout one\n");
1010+process.stderr.write("stderr one\n");
1111+1212+setTimeout(() => process.stdout.write("stdout two\n"), 60);
1313+setTimeout(() => process.stderr.write("ERROR: something broke\n"), 120);
1414+setTimeout(() => process.stdout.write("stdout three\n"), 180);
1515+setTimeout(() => process.stdout.write("stdout four\n"), 240);
1616+setTimeout(() => process.stderr.write("stderr four\n"), 300);
1717+1818+// Allow the test to interact (filter, search, scroll) before the child
1919+// exits on its own.
2020+setTimeout(() => process.exit(0), 10_000);
2121+2222+process.on("SIGTERM", () => process.exit(0));
+15
demos/loggy/fixtures/firehose.js
···11+#!/usr/bin/env node
22+// Fires a large volume of lines as fast as Node can write them, to stand
33+// in for a child like `find /` whose output rate can outpace naive
44+// signal + render loops. Used by the "stays interactive under load"
55+// integration test.
66+77+const TOTAL = 5_000;
88+for (let i = 0; i < TOTAL; i++) {
99+ const stream = i % 13 === 0 ? process.stderr : process.stdout;
1010+ stream.write(`line ${i}\n`);
1111+}
1212+// Keep the process alive so the test can poke at the TUI after the
1313+// firehose finishes writing.
1414+setTimeout(() => process.exit(0), 10_000);
1515+process.on("SIGTERM", () => process.exit(0));
+250
demos/loggy/loggy-integration.test.ts
···11+// End-to-end integration tests for loggy: spawn the demo as a real PTY
22+// process, let it wrap a deterministic emitter fixture, and drive the TUI
33+// via keystrokes.
44+55+import { describe, it, expect, afterEach } from "vitest";
66+import * as fs from "node:fs";
77+import * as os from "node:os";
88+import * as path from "node:path";
99+import { fileURLToPath } from "node:url";
1010+import { Session } from "../../src/testing/index.ts";
1111+1212+const __dirname = path.dirname(fileURLToPath(import.meta.url));
1313+const mainScript = path.join(__dirname, "main.ts");
1414+const emitter = path.join(__dirname, "fixtures", "emitter.js");
1515+const firehose = path.join(__dirname, "fixtures", "firehose.js");
1616+1717+let session: Session | null = null;
1818+const tmpPaths: string[] = [];
1919+2020+async function startLoggy(extraArgs: readonly string[] = [], rows = 24, cols = 100): Promise<Session> {
2121+ return spawnLoggyWrapping(emitter, extraArgs, rows, cols);
2222+}
2323+2424+async function spawnLoggyWrapping(
2525+ childScript: string,
2626+ extraArgs: readonly string[] = [],
2727+ rows = 24,
2828+ cols = 100,
2929+): Promise<Session> {
3030+ const args = [
3131+ "--experimental-strip-types",
3232+ "--no-warnings",
3333+ mainScript,
3434+ ...extraArgs,
3535+ "--",
3636+ process.execPath,
3737+ childScript,
3838+ ];
3939+ session = Session.spawn("node", args, { rows, cols, env: { TERM: "xterm-256color" } });
4040+ await session.waitForText("loggy", 15_000);
4141+ return session;
4242+}
4343+4444+afterEach(async () => {
4545+ if (session) {
4646+ await session.close();
4747+ session = null;
4848+ }
4949+ for (const p of tmpPaths) {
5050+ try { fs.unlinkSync(p); } catch {}
5151+ }
5252+ tmpPaths.length = 0;
5353+});
5454+5555+function tmpFile(suffix: string): string {
5656+ const p = path.join(os.tmpdir(), `loggy-test-${Date.now()}-${Math.random().toString(36).slice(2, 6)}-${suffix}`);
5757+ tmpPaths.push(p);
5858+ return p;
5959+}
6060+6161+// ===========================================================================
6262+6363+describe("loggy integration", () => {
6464+ it("renders the rounded panel with command in the title", async () => {
6565+ const s = await startLoggy();
6666+ const ss = await s.waitForText("loggy \u2014", 10_000);
6767+ // Rounded corners come from panel's default box style.
6868+ expect(ss.text).toMatch(/[╭╮╰╯]/);
6969+ }, 20_000);
7070+7171+ it("shows both stdout and stderr lines with source tags", async () => {
7272+ const s = await startLoggy();
7373+ await s.waitForText("stdout one", 10_000);
7474+ await s.waitForText("stderr one", 10_000);
7575+ const ss = s.screenshot();
7676+ // Both labels appear in the rendered list (our format is "● out" / "● err").
7777+ expect(ss.text).toContain("out");
7878+ expect(ss.text).toContain("err");
7979+ expect(ss.text).toContain("stdout one");
8080+ expect(ss.text).toContain("stderr one");
8181+ }, 20_000);
8282+8383+ it("'o' filters to stdout only", async () => {
8484+ const s = await startLoggy();
8585+ await s.waitForText("stderr one", 10_000);
8686+ s.press("o");
8787+ await s.waitFor((ss) => !ss.text.includes("stderr one"), 5_000, "stderr hidden");
8888+ const ss = s.screenshot();
8989+ expect(ss.text).toContain("stdout one");
9090+ expect(ss.text).not.toContain("stderr one");
9191+ }, 20_000);
9292+9393+ it("'e' filters to stderr only", async () => {
9494+ const s = await startLoggy();
9595+ await s.waitForText("stdout one", 10_000);
9696+ s.press("e");
9797+ await s.waitFor((ss) => !ss.text.includes("stdout one"), 5_000, "stdout hidden");
9898+ const ss = s.screenshot();
9999+ expect(ss.text).not.toContain("stdout one");
100100+ expect(ss.text).toContain("stderr one");
101101+ }, 20_000);
102102+103103+ it("'b' restores both streams after a filter", async () => {
104104+ const s = await startLoggy();
105105+ await s.waitForText("stderr one", 10_000);
106106+ s.press("o");
107107+ await s.waitFor((ss) => !ss.text.includes("stderr one"), 5_000, "stderr hidden");
108108+ s.press("b");
109109+ await s.waitFor((ss) => ss.text.includes("stderr one") && ss.text.includes("stdout one"), 5_000, "both restored");
110110+ }, 20_000);
111111+112112+ it("'/' opens search and typed query filters to matching lines", async () => {
113113+ const s = await startLoggy();
114114+ await s.waitForText("ERROR: something broke", 10_000);
115115+ s.type("/");
116116+ // Search prompt appears in the status bar.
117117+ await s.waitForText("/", 5_000);
118118+ s.type("ERROR");
119119+ await s.waitFor(
120120+ (ss) => ss.text.includes("ERROR") && !ss.text.includes("stdout one"),
121121+ 5_000,
122122+ "only ERROR line visible",
123123+ );
124124+ const ss = s.screenshot();
125125+ expect(ss.text).toContain("ERROR: something broke");
126126+ expect(ss.text).not.toContain("stdout one");
127127+ expect(ss.text).not.toContain("stderr one");
128128+ }, 20_000);
129129+130130+ it("escape clears the active search", async () => {
131131+ const s = await startLoggy();
132132+ await s.waitForText("ERROR:", 10_000);
133133+ s.type("/");
134134+ s.type("ERROR");
135135+ await s.waitFor((ss) => !ss.text.includes("stdout one"), 5_000, "search active");
136136+ s.press("escape");
137137+ await s.waitFor((ss) => ss.text.includes("stdout one"), 5_000, "search cleared");
138138+ }, 20_000);
139139+140140+ it("child status shows 'running' while child is alive", async () => {
141141+ const s = await startLoggy();
142142+ await s.waitForText("stdout one", 10_000);
143143+ const ss = s.screenshot();
144144+ expect(ss.text).toContain("running");
145145+ }, 20_000);
146146+147147+ it("'q' alone does NOT quit (requires Ctrl+C twice now)", async () => {
148148+ const s = await startLoggy();
149149+ await s.waitForText("stdout one", 10_000);
150150+ s.type("q");
151151+ // Give it a moment; loggy should still be alive and rendering
152152+ await new Promise((r) => setTimeout(r, 300));
153153+ const ss = s.screenshot();
154154+ expect(ss.text).toContain("loggy");
155155+ expect(ss.text).toContain("running");
156156+ }, 20_000);
157157+158158+ it("first Ctrl+C arms a confirmation prompt in the footer", async () => {
159159+ const s = await startLoggy();
160160+ await s.waitForText("stdout one", 10_000);
161161+ s.press("ctrl+c");
162162+ await s.waitForText("Press Ctrl+C again to quit", 3_000);
163163+ const ss = s.screenshot();
164164+ expect(ss.text).toContain("Press Ctrl+C again");
165165+ }, 20_000);
166166+167167+ it("stays interactive under a firehose of output (no event-loop starvation)", async () => {
168168+ // Regression test for an O(n²) append bug: wrapping a child that
169169+ // emits 20,000 lines would lock up the TUI. After the store fix
170170+ // (mutable buffer + version signal) and render throttling, filter
171171+ // keys should still register and redraw within a reasonable window.
172172+ const s = await spawnLoggyWrapping(firehose);
173173+174174+ // Wait for late output so we know the firehose is well underway.
175175+ await s.waitForText("line 49", 15_000);
176176+177177+ // The TUI must still register keystrokes while the firehose is
178178+ // streaming / has just finished — press a filter key and confirm
179179+ // the footer reflects it. Before the fix, the filter keypress
180180+ // would not register because the event loop was saturated.
181181+ s.press("o");
182182+ await s.waitFor(
183183+ (ss) => ss.text.includes("[O]ut"),
184184+ 5_000,
185185+ "filter=out registered under load",
186186+ );
187187+188188+ // Every 13th line was routed to stderr (seq 0, 13, 26, …). Switch
189189+ // to err-only and verify one of those shows up.
190190+ s.press("e");
191191+ await s.waitFor(
192192+ (ss) => ss.text.includes("[E]rr"),
193193+ 5_000,
194194+ "filter=err registered under load",
195195+ );
196196+ }, 30_000);
197197+198198+ it("any other key cancels the pending quit", async () => {
199199+ const s = await startLoggy();
200200+ await s.waitForText("stdout one", 10_000);
201201+ s.press("ctrl+c");
202202+ await s.waitForText("Press Ctrl+C again", 3_000);
203203+ s.press("b"); // filter key, but any key cancels
204204+ await s.waitFor(
205205+ (ss) => !ss.text.includes("Press Ctrl+C again") && ss.text.includes("quit"),
206206+ 3_000,
207207+ "prompt cleared, normal footer back",
208208+ );
209209+ }, 20_000);
210210+211211+ it("--out tees stdout lines to the given file", async () => {
212212+ const outFile = tmpFile("out.log");
213213+ const s = await startLoggy(["--out", outFile]);
214214+ // Wait for the last emitted line so we know the child has written
215215+ // everything before we read the file.
216216+ await s.waitForText("stderr four", 10_000);
217217+ const content = fs.readFileSync(outFile, "utf-8");
218218+ expect(content).toContain("stdout one");
219219+ expect(content).toContain("stdout four");
220220+ // stderr lines must NOT be in the --out file
221221+ expect(content).not.toContain("stderr one");
222222+ expect(content).not.toContain("ERROR: something broke");
223223+ }, 20_000);
224224+225225+ it("--err tees stderr lines to the given file", async () => {
226226+ const errFile = tmpFile("err.log");
227227+ const s = await startLoggy(["--err", errFile]);
228228+ await s.waitForText("stderr four", 10_000);
229229+ const content = fs.readFileSync(errFile, "utf-8");
230230+ expect(content).toContain("stderr one");
231231+ expect(content).toContain("ERROR: something broke");
232232+ expect(content).not.toContain("stdout one");
233233+ }, 20_000);
234234+235235+ it("--log writes combined tagged output", async () => {
236236+ const logFile = tmpFile("combined.log");
237237+ const s = await startLoggy(["--log", logFile]);
238238+ await s.waitForText("stderr four", 10_000);
239239+ const content = fs.readFileSync(logFile, "utf-8");
240240+ // Each line has the [HH:MM:SS.mmm source] prefix
241241+ const lines = content.trim().split("\n");
242242+ for (const line of lines) {
243243+ expect(line).toMatch(/^\[\d{2}:\d{2}:\d{2}\.\d{3} (out|err)\] /);
244244+ }
245245+ // All expected content present
246246+ expect(content).toMatch(/ out\] stdout one/);
247247+ expect(content).toMatch(/ err\] stderr one/);
248248+ expect(content).toMatch(/ err\] ERROR: something broke/);
249249+ }, 20_000);
250250+});
+195
demos/loggy/loggy.test.ts
···11+import { describe, it, expect } from "vitest";
22+import { parseArgs, UsageError, DEFAULT_SCROLLBACK } from "./cli.ts";
33+import { createStore, applyView, type LogEntry } from "./store.ts";
44+import { formatTaggedLine } from "./sinks.ts";
55+66+// ============================================================
77+// cli.parseArgs
88+// ============================================================
99+1010+describe("parseArgs", () => {
1111+ it("parses command + args with no flags", () => {
1212+ const p = parseArgs(["npm", "run", "build"]);
1313+ expect(p.command).toBe("npm");
1414+ expect(p.args).toEqual(["run", "build"]);
1515+ expect(p.out).toBeNull();
1616+ expect(p.err).toBeNull();
1717+ expect(p.log).toBeNull();
1818+ expect(p.noColor).toBe(false);
1919+ expect(p.scrollback).toBe(DEFAULT_SCROLLBACK);
2020+ });
2121+2222+ it("treats first non-flag positional as the command", () => {
2323+ const p = parseArgs(["--out", "/tmp/o.log", "my-cmd", "--my-flag"]);
2424+ expect(p.command).toBe("my-cmd");
2525+ expect(p.args).toEqual(["--my-flag"]);
2626+ expect(p.out).toBe("/tmp/o.log");
2727+ });
2828+2929+ it("supports -- as a separator", () => {
3030+ const p = parseArgs(["--log", "/tmp/a.log", "--", "tsc", "--watch"]);
3131+ expect(p.command).toBe("tsc");
3232+ expect(p.args).toEqual(["--watch"]);
3333+ expect(p.log).toBe("/tmp/a.log");
3434+ });
3535+3636+ it("parses --out, --err, and --log paths", () => {
3737+ const p = parseArgs(["--out", "o", "--err", "e", "--log", "l", "cat"]);
3838+ expect(p.out).toBe("o");
3939+ expect(p.err).toBe("e");
4040+ expect(p.log).toBe("l");
4141+ });
4242+4343+ it("parses --no-color", () => {
4444+ const p = parseArgs(["--no-color", "cat"]);
4545+ expect(p.noColor).toBe(true);
4646+ });
4747+4848+ it("parses --scrollback with a positive integer", () => {
4949+ const p = parseArgs(["--scrollback", "500", "cat"]);
5050+ expect(p.scrollback).toBe(500);
5151+ });
5252+5353+ it("rejects --scrollback with non-numeric or non-positive values", () => {
5454+ expect(() => parseArgs(["--scrollback", "abc", "cat"])).toThrow(UsageError);
5555+ expect(() => parseArgs(["--scrollback", "0", "cat"])).toThrow(UsageError);
5656+ expect(() => parseArgs(["--scrollback", "-5", "cat"])).toThrow(UsageError);
5757+ });
5858+5959+ it("throws on missing command", () => {
6060+ expect(() => parseArgs([])).toThrow(UsageError);
6161+ expect(() => parseArgs(["--no-color"])).toThrow(UsageError);
6262+ });
6363+6464+ it("throws on unknown flag", () => {
6565+ expect(() => parseArgs(["--nope", "cat"])).toThrow(/Unknown flag/);
6666+ });
6767+6868+ it("throws a special help marker on -h / --help", () => {
6969+ expect(() => parseArgs(["--help"])).toThrow(/__help__/);
7070+ expect(() => parseArgs(["-h"])).toThrow(/__help__/);
7171+ });
7272+7373+ it("throws when --out is given without a value", () => {
7474+ expect(() => parseArgs(["--out"])).toThrow(/requires a path/);
7575+ });
7676+});
7777+7878+// ============================================================
7979+// store — ring buffer + reactive entries
8080+// ============================================================
8181+8282+describe("createStore", () => {
8383+ it("appends entries with monotonic seq", () => {
8484+ const s = createStore(100);
8585+ s.append("out", "a");
8686+ s.append("err", "b");
8787+ s.append("out", "c");
8888+ const all = s.entries.get();
8989+ expect(all.map((e) => e.seq)).toEqual([0, 1, 2]);
9090+ expect(all.map((e) => e.source)).toEqual(["out", "err", "out"]);
9191+ expect(all.map((e) => e.line)).toEqual(["a", "b", "c"]);
9292+ });
9393+9494+ it("drops oldest when scrollback limit is reached; seq stays monotonic", () => {
9595+ const s = createStore(3);
9696+ s.append("out", "1");
9797+ s.append("out", "2");
9898+ s.append("out", "3");
9999+ s.append("out", "4");
100100+ s.append("out", "5");
101101+ const all = s.entries.get();
102102+ expect(all.length).toBe(3);
103103+ expect(all.map((e) => e.line)).toEqual(["3", "4", "5"]);
104104+ expect(all.map((e) => e.seq)).toEqual([2, 3, 4]);
105105+ });
106106+107107+ it("rejects non-positive scrollback", () => {
108108+ expect(() => createStore(0)).toThrow();
109109+ expect(() => createStore(-1)).toThrow();
110110+ });
111111+});
112112+113113+// ============================================================
114114+// applyView — filter + search
115115+// ============================================================
116116+117117+function mk(source: "out" | "err", line: string, seq = 0): LogEntry {
118118+ return { seq, ts: 0, source, line };
119119+}
120120+121121+describe("applyView", () => {
122122+ const entries: LogEntry[] = [
123123+ mk("out", "hello world", 0),
124124+ mk("err", "Oh no an error", 1),
125125+ mk("out", "finished", 2),
126126+ mk("err", "ERROR 42", 3),
127127+ ];
128128+129129+ it("filter=both passes everything", () => {
130130+ expect(applyView(entries, "both", "").length).toBe(4);
131131+ });
132132+133133+ it("filter=out keeps only stdout", () => {
134134+ const v = applyView(entries, "out", "");
135135+ expect(v.map((e) => e.line)).toEqual(["hello world", "finished"]);
136136+ });
137137+138138+ it("filter=err keeps only stderr", () => {
139139+ const v = applyView(entries, "err", "");
140140+ expect(v.map((e) => e.line)).toEqual(["Oh no an error", "ERROR 42"]);
141141+ });
142142+143143+ it("search is case-insensitive substring", () => {
144144+ expect(applyView(entries, "both", "error").map((e) => e.line))
145145+ .toEqual(["Oh no an error", "ERROR 42"]);
146146+ expect(applyView(entries, "both", "HELLO").map((e) => e.line))
147147+ .toEqual(["hello world"]);
148148+ });
149149+150150+ it("search composes with filter", () => {
151151+ expect(applyView(entries, "err", "42").map((e) => e.line))
152152+ .toEqual(["ERROR 42"]);
153153+ expect(applyView(entries, "out", "error")).toEqual([]);
154154+ });
155155+156156+ it("search ignores ANSI escape sequences in the line", () => {
157157+ const colored: LogEntry[] = [
158158+ mk("out", "\x1b[31mred error\x1b[0m", 0),
159159+ mk("out", "plain error", 1),
160160+ ];
161161+ const v = applyView(colored, "both", "red error");
162162+ expect(v).toHaveLength(1);
163163+ expect(v[0].line).toContain("\x1b[31m"); // ANSI preserved in output
164164+ });
165165+166166+ it("empty search is a no-op", () => {
167167+ expect(applyView(entries, "both", " ").length).toBe(4);
168168+ expect(applyView(entries, "both", "").length).toBe(4);
169169+ });
170170+});
171171+172172+// ============================================================
173173+// sinks.formatTaggedLine
174174+// ============================================================
175175+176176+describe("formatTaggedLine", () => {
177177+ it("produces [HH:MM:SS.mmm source] line format", () => {
178178+ // Fix the timestamp to avoid TZ drift: 2026-04-17T15:30:45.123 local —
179179+ // just check the structure, not the exact hour (depends on TZ).
180180+ const ts = Date.parse("2026-04-17T15:30:45.123");
181181+ const line = formatTaggedLine(ts, "out", "hello");
182182+ expect(line).toMatch(/^\[\d{2}:\d{2}:\d{2}\.\d{3} out\] hello$/);
183183+ });
184184+185185+ it("uses the right source tag for err", () => {
186186+ const line = formatTaggedLine(Date.now(), "err", "boom");
187187+ expect(line).toMatch(/ err\] boom$/);
188188+ });
189189+190190+ it("zero-pads small time components", () => {
191191+ const ts = Date.parse("2026-04-17T01:02:03.004");
192192+ const line = formatTaggedLine(ts, "out", "x");
193193+ expect(line).toMatch(/^\[\d{2}:02:03\.004 out\] x$/);
194194+ });
195195+});
+255
demos/loggy/main.ts
···11+// loggy — wrap a command, capture stdout/stderr separately, live TUI with
22+// filter/search. See demos/loggy/README-ish notes at the top of the repo's
33+// changelog when shipped; for now, ./demos/run loggy <command>.
44+55+import {
66+ parseKey, CellBuffer, diff, fullRender, effect, batch,
77+ hideCursor, showCursor, reset,
88+ signal, computed, themes,
99+ type ScreenContext, type Theme,
1010+} from "../../src/tui/index.ts";
1111+import { parseArgs, UsageError, USAGE } from "./cli.ts";
1212+import { createStore, applyView, type FilterMode } from "./store.ts";
1313+import { createSinks } from "./sinks.ts";
1414+import { spawnChild } from "./child.ts";
1515+import { buildLoggyScreen, type ScreenState } from "./screen.ts";
1616+1717+const enterAltScreen = "\x1b[?1049h";
1818+const leaveAltScreen = "\x1b[?1049l";
1919+2020+// ---- Parse argv ------------------------------------------------------------
2121+2222+let parsed;
2323+try {
2424+ parsed = parseArgs(process.argv.slice(2));
2525+} catch (e) {
2626+ const err = e as Error;
2727+ if (err.message === "__help__") {
2828+ process.stdout.write(USAGE);
2929+ process.exit(0);
3030+ }
3131+ if (err instanceof UsageError) {
3232+ process.stderr.write(`loggy: ${err.message}\n\n${USAGE}`);
3333+ process.exit(2);
3434+ }
3535+ throw e;
3636+}
3737+3838+// ---- Signals ---------------------------------------------------------------
3939+4040+const store = createStore(parsed.scrollback);
4141+const filter = signal<FilterMode>("both");
4242+const searchQuery = signal("");
4343+const searchActive = signal(false);
4444+const follow = signal(true);
4545+const selectedIndex = signal(0);
4646+const scrollOffset = signal(0);
4747+const childState = signal<{
4848+ alive: boolean;
4949+ pid: number | undefined;
5050+ exitCode: number | null;
5151+ signal: string | null;
5252+}>({ alive: true, pid: undefined, exitCode: null, signal: null });
5353+5454+/** True between the first and second Ctrl+C (or until the timeout clears
5555+ * it). Drives the footer prompt and the quit-vs-arm decision. */
5656+const quitPending = signal(false);
5757+const QUIT_WINDOW_MS = 2000;
5858+let quitPendingTimer: ReturnType<typeof setTimeout> | null = null;
5959+6060+function armQuit(): void {
6161+ quitPending.set(true);
6262+ if (quitPendingTimer) clearTimeout(quitPendingTimer);
6363+ quitPendingTimer = setTimeout(() => {
6464+ quitPending.set(false);
6565+ quitPendingTimer = null;
6666+ }, QUIT_WINDOW_MS);
6767+}
6868+6969+function clearQuit(): void {
7070+ if (quitPendingTimer) {
7171+ clearTimeout(quitPendingTimer);
7272+ quitPendingTimer = null;
7373+ }
7474+ if (quitPending.peek()) quitPending.set(false);
7575+}
7676+7777+const visibleLogs = computed(() => applyView(store.entries.get(), filter.get(), searchQuery.get()));
7878+7979+// ---- Sinks + child ---------------------------------------------------------
8080+8181+const sinks = createSinks({ out: parsed.out, err: parsed.err, log: parsed.log });
8282+8383+const child = spawnChild(parsed.command, parsed.args, {
8484+ forceColor: !parsed.noColor,
8585+ onLine: (source, line) => {
8686+ const entry = store.append(source, line);
8787+ sinks.write(source, line, entry.ts);
8888+ },
8989+ onExit: (code, signal) => {
9090+ batch(() => {
9191+ childState.set({
9292+ alive: false,
9393+ pid: child.pid,
9494+ exitCode: code,
9595+ signal,
9696+ });
9797+ });
9898+ },
9999+ onError: (err) => {
100100+ // Surface spawn failures in the log itself so the user sees them.
101101+ store.append("err", `loggy: failed to spawn: ${err.message}`);
102102+ batch(() => {
103103+ childState.set({ alive: false, pid: undefined, exitCode: 127, signal: null });
104104+ });
105105+ },
106106+});
107107+108108+// Once we have a PID, update the status line.
109109+childState.set({ alive: true, pid: child.pid, exitCode: null, signal: null });
110110+111111+// ---- Screen + render loop --------------------------------------------------
112112+113113+let running = true;
114114+let prevBuffer: CellBuffer | null = null;
115115+116116+function currentTheme(): Theme {
117117+ // Match other demos: pick the default terminal theme so colors follow
118118+ // the user's actual terminal palette.
119119+ return themes.terminal ?? themes.coolBlue ?? Object.values(themes)[0];
120120+}
121121+122122+function getSize(): [number, number] {
123123+ return [process.stdout.rows ?? 35, process.stdout.columns ?? 120];
124124+}
125125+126126+function createContext(rows: number, cols: number): ScreenContext {
127127+ return {
128128+ rows, cols,
129129+ theme: currentTheme(),
130130+ boxStyle: "rounded",
131131+ navigate: () => {},
132132+ back: () => {},
133133+ openOverlay: () => {},
134134+ closeOverlay: () => {},
135135+ isTextInputActive: () => searchActive.get(),
136136+ setTextInputActive: (v) => searchActive.set(v),
137137+ };
138138+}
139139+140140+let quitting = false;
141141+async function cleanup(): Promise<void> {
142142+ if (!running) return;
143143+ running = false;
144144+ // Restore terminal first so any subsequent messages aren't eaten by the
145145+ // alt-screen buffer.
146146+ process.stdout.write(showCursor() + reset() + leaveAltScreen);
147147+ if (process.stdin.isTTY) {
148148+ try { process.stdin.setRawMode(false); } catch {}
149149+ }
150150+ process.stdin.pause();
151151+ await child.stop(2000);
152152+ await sinks.close();
153153+}
154154+155155+async function quit(): Promise<void> {
156156+ if (quitting) return;
157157+ quitting = true;
158158+ await cleanup();
159159+ process.exit(childState.peek().exitCode ?? 0);
160160+}
161161+162162+const state: ScreenState = {
163163+ commandDisplay: [parsed.command, ...parsed.args].join(" "),
164164+ visibleLogs,
165165+ filter, searchQuery, searchActive, follow,
166166+ selectedIndex, scrollOffset,
167167+ childState,
168168+ quitPending,
169169+ onCtrlC: () => {
170170+ if (quitPending.peek()) {
171171+ // Second press within the window — actually quit.
172172+ clearQuit();
173173+ void quit();
174174+ } else {
175175+ armQuit();
176176+ }
177177+ },
178178+};
179179+180180+const screen = buildLoggyScreen(state);
181181+182182+function renderFrame(): void {
183183+ if (!running) return;
184184+ const [rows, cols] = getSize();
185185+ const ctx = createContext(rows, cols);
186186+ const buf = screen.renderToBuffer(ctx);
187187+188188+ let output: string;
189189+ if (prevBuffer && prevBuffer.rows === rows && prevBuffer.cols === cols) {
190190+ output = diff(prevBuffer, buf);
191191+ } else {
192192+ output = fullRender(buf);
193193+ }
194194+ prevBuffer = buf;
195195+ process.stdout.write(hideCursor() + output);
196196+}
197197+198198+// ---- Terminal setup --------------------------------------------------------
199199+200200+process.stdout.write(enterAltScreen + hideCursor());
201201+if (process.stdin.isTTY) {
202202+ process.stdin.setRawMode(true);
203203+}
204204+process.stdin.resume();
205205+206206+// 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+});
229229+230230+process.stdin.on("data", (data: Buffer | string) => {
231231+ const buf = typeof data === "string" ? Buffer.from(data) : data;
232232+ const keys = parseKey(buf);
233233+ for (const key of keys) {
234234+ // Any non-Ctrl+C key cancels a pending quit. Ctrl+C itself is
235235+ // handled by the screen (which will either arm or confirm).
236236+ if (quitPending.peek() && !(key.name === "c" && key.ctrl)) {
237237+ clearQuit();
238238+ }
239239+ const [rows, cols] = getSize();
240240+ const ctx = createContext(rows, cols);
241241+ screen.handleKey(key, ctx);
242242+ }
243243+});
244244+245245+process.stdout.on("resize", () => {
246246+ prevBuffer = null;
247247+ renderFrame();
248248+});
249249+250250+// In raw mode, Ctrl+C arrives as a 0x03 byte via stdin (handled by the
251251+// screen's double-press logic). We still register SIGINT as a safety net
252252+// for cases where raw mode isn't active (e.g., piped stdin) — those
253253+// contexts don't get the double-press UX, but quitting is the right thing.
254254+process.on("SIGINT", () => { void quit(); });
255255+process.on("SIGTERM", () => { void quit(); });
+272
demos/loggy/screen.ts
···11+// The single loggy TUI screen.
22+//
33+// Layout:
44+//
55+// ╭─ loggy — <command> ─────────────────────────────╮
66+// │ ● out line one │
77+// │ ● err an error here │
88+// │ ... │
99+// ╰─────────────────────────────────────────────────╯
1010+// [b]oth [o]ut [e]rr /search [f]ollow q quit ● running (pid)
1111+1212+import {
1313+ screen, text, row, panel, selectable, footer, canvas,
1414+ updateScrollRegion,
1515+ type ScreenContext, type UINode, type KeyEvent,
1616+} from "../../src/tui/index.ts";
1717+import type { LogEntry, FilterMode } from "./store.ts";
1818+import type { Signal } from "../../src/tui/index.ts";
1919+2020+/** Narrow read-only interface compatible with both Signal<T> and
2121+ * computed<T>. screen.ts only reads the visible logs via `.get()`;
2222+ * ownership of the underlying reactive state stays with main.ts. */
2323+export interface ReadableSignal<T> {
2424+ get(): T;
2525+}
2626+2727+export interface ScreenState {
2828+ commandDisplay: string;
2929+ /** Derived view of log entries after filter + search. */
3030+ visibleLogs: ReadableSignal<LogEntry[]>;
3131+ filter: Signal<FilterMode>;
3232+ searchQuery: Signal<string>;
3333+ searchActive: Signal<boolean>;
3434+ follow: Signal<boolean>;
3535+ selectedIndex: Signal<number>;
3636+ scrollOffset: Signal<number>;
3737+ childState: Signal<{
3838+ alive: boolean;
3939+ pid: number | undefined;
4040+ exitCode: number | null;
4141+ signal: string | null;
4242+ }>;
4343+ /** Set by main.ts when the first Ctrl+C lands; cleared by main.ts after
4444+ * a short window. Screen reads this to prompt the user to confirm. */
4545+ quitPending: ReadableSignal<boolean>;
4646+ /** Called on every Ctrl+C. main.ts handles the first-vs-second logic
4747+ * and the teardown timer. */
4848+ onCtrlC: () => void;
4949+}
5050+5151+export function buildLoggyScreen(state: ScreenState) {
5252+ return screen({
5353+ id: "loggy",
5454+5555+ render(ctx: ScreenContext): UINode[] {
5656+ const entries = state.visibleLogs.get();
5757+ const viewport = Math.max(1, ctx.rows - 4); // panel top + bottom + footer
5858+5959+ // 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+ }
6565+6666+ const region = updateScrollRegion(
6767+ {
6868+ offset: state.scrollOffset.get(),
6969+ selectedIndex: state.selectedIndex.get(),
7070+ totalItems: entries.length,
7171+ viewportHeight: viewport,
7272+ },
7373+ entries.length,
7474+ viewport,
7575+ );
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);
7878+7979+ const title = "loggy — " + state.commandDisplay;
8080+8181+ 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+ ]),
8989+ renderFooter(state),
9090+ ];
9191+ },
9292+9393+ handleKey(key: KeyEvent, ctx: ScreenContext): boolean {
9494+ return handleKey(state, key, ctx);
9595+ },
9696+ });
9797+}
9898+9999+function renderLogLine(entry: LogEntry, _followOn: boolean): UINode[] {
100100+ // " ● out " or " ● err " prefix then the content. stderr lines render
101101+ // bold + error color; stdout keep their original colors (ANSI passes
102102+ // through via the text builder).
103103+ if (entry.source === "err") {
104104+ return [
105105+ row(
106106+ text(" ● err ", "error", { bold: true }),
107107+ text(entry.line, "error", { truncate: true }),
108108+ ),
109109+ ];
110110+ }
111111+ return [
112112+ row(
113113+ text(" ● out ", "muted", { dim: true }),
114114+ text(entry.line, "primary", { truncate: true }),
115115+ ),
116116+ ];
117117+}
118118+119119+function renderFooter(state: ScreenState): UINode {
120120+ const filter = state.filter.get();
121121+ const searchActive = state.searchActive.get();
122122+ const query = state.searchQuery.get();
123123+ const follow = state.follow.get();
124124+ const child = state.childState.get();
125125+126126+ const quitPending = state.quitPending.get();
127127+128128+ const leftText = quitPending
129129+ ? "Press Ctrl+C again to quit (or any other key to cancel)"
130130+ : searchActive
131131+ ? `/ ${query}\u2588`
132132+ : `${marker("b", filter === "both")}oth ` +
133133+ `${marker("o", filter === "out")}ut ` +
134134+ `${marker("e", filter === "err")}rr ` +
135135+ (query ? `search="${query}" ` : "/search ") +
136136+ `${marker("f", follow)}ollow ctrl+c\u00d72 quit`;
137137+138138+ const statusText = child.alive
139139+ ? `\u25cf running (${child.pid ?? "?"})`
140140+ : child.signal
141141+ ? `\u2717 signaled (${child.signal})`
142142+ : `\u25cb exited (code ${child.exitCode ?? "?"})`;
143143+144144+ // 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}`);
147147+}
148148+149149+function marker(letter: string, active: boolean): string {
150150+ return active ? `[${letter.toUpperCase()}]` : `[${letter}]`;
151151+}
152152+153153+function handleKey(state: ScreenState, key: KeyEvent, ctx: ScreenContext): boolean {
154154+ // Ctrl+C takes two consecutive presses to quit. main.ts handles the
155155+ // first-vs-second state + timeout; we just tell it one happened. Any
156156+ // OTHER key press cancels the pending quit before falling through.
157157+ if (key.name === "c" && key.ctrl) {
158158+ state.onCtrlC();
159159+ return true;
160160+ }
161161+162162+ // --- Search mode captures most keys ---
163163+ if (state.searchActive.peek()) {
164164+ if (key.name === "escape") {
165165+ // Esc clears the search AND exits search mode
166166+ state.searchActive.set(false);
167167+ state.searchQuery.set("");
168168+ return true;
169169+ }
170170+ if (key.name === "return") {
171171+ // Return commits the query and exits search-input mode; query stays
172172+ state.searchActive.set(false);
173173+ return true;
174174+ }
175175+ if (key.name === "backspace") {
176176+ const q = state.searchQuery.peek();
177177+ if (q.length > 0) state.searchQuery.set(q.slice(0, -1));
178178+ return true;
179179+ }
180180+ if (key.char && !key.ctrl && !key.alt) {
181181+ state.searchQuery.set(state.searchQuery.peek() + key.char);
182182+ return true;
183183+ }
184184+ return true; // swallow other keys in search mode
185185+ }
186186+187187+ // --- Global keys (not in search mode) ---
188188+ switch (key.name) {
189189+ case "o":
190190+ state.filter.set("out");
191191+ resetScroll(state);
192192+ return true;
193193+194194+ case "e":
195195+ state.filter.set("err");
196196+ resetScroll(state);
197197+ return true;
198198+199199+ case "b":
200200+ state.filter.set("both");
201201+ resetScroll(state);
202202+ return true;
203203+204204+ case "f":
205205+ state.follow.set(!state.follow.peek());
206206+ return true;
207207+208208+ case "/":
209209+ state.searchActive.set(true);
210210+ return true;
211211+212212+ case "escape":
213213+ // Outside search mode: clear any active search query.
214214+ if (state.searchQuery.peek()) {
215215+ state.searchQuery.set("");
216216+ return true;
217217+ }
218218+ return true;
219219+220220+ case "up":
221221+ manualScroll(state, -1);
222222+ return true;
223223+224224+ case "down":
225225+ manualScroll(state, 1);
226226+ return true;
227227+228228+ case "pageup":
229229+ manualScroll(state, -Math.max(1, ctx.rows - 6));
230230+ return true;
231231+232232+ case "pagedown":
233233+ manualScroll(state, Math.max(1, ctx.rows - 6));
234234+ return true;
235235+236236+ case "g": {
237237+ // lowercase g → top, uppercase G → bottom + follow
238238+ if (key.char === "G") {
239239+ state.selectedIndex.set(Math.max(0, state.visibleLogs.get().length - 1));
240240+ state.follow.set(true);
241241+ } else {
242242+ state.follow.set(false);
243243+ state.selectedIndex.set(0);
244244+ state.scrollOffset.set(0);
245245+ }
246246+ return true;
247247+ }
248248+ }
249249+250250+ return true;
251251+}
252252+253253+function manualScroll(state: ScreenState, delta: number): void {
254254+ state.follow.set(false);
255255+ const total = state.visibleLogs.get().length;
256256+ if (total === 0) return;
257257+ const next = clamp(state.selectedIndex.peek() + delta, 0, total - 1);
258258+ state.selectedIndex.set(next);
259259+}
260260+261261+function resetScroll(state: ScreenState): void {
262262+ // When filter/search changes, jump back to the bottom and resume follow.
263263+ state.follow.set(true);
264264+ state.scrollOffset.set(0);
265265+ state.selectedIndex.set(0);
266266+}
267267+268268+function clamp(n: number, lo: number, hi: number): number {
269269+ if (n < lo) return lo;
270270+ if (n > hi) return hi;
271271+ return n;
272272+}
+62
demos/loggy/sinks.ts
···11+// Optional file writers for --out, --err, --log.
22+//
33+// Each configured path opens an append-only write stream at startup. Writes
44+// are fire-and-forget (queued by Node's stream buffer); `close()` flushes
55+// and closes them. No file rotation — user rotates externally if needed.
66+77+import * as fs from "node:fs";
88+import type { LogSource } from "./store.ts";
99+1010+export interface SinksConfig {
1111+ /** Tee raw stdout bytes to this path (as-received). */
1212+ out: string | null;
1313+ /** Tee raw stderr bytes to this path (as-received). */
1414+ err: string | null;
1515+ /** Tee both streams to this path, tagged + timestamped plain-text. */
1616+ log: string | null;
1717+}
1818+1919+export interface Sinks {
2020+ /** Write a line to the appropriate sinks. `line` must not include a
2121+ * trailing newline; one is added here so callers don't double-add. */
2222+ write(source: LogSource, line: string, ts?: number): void;
2323+ close(): Promise<void>;
2424+}
2525+2626+export function createSinks(config: SinksConfig): Sinks {
2727+ const outStream = config.out ? fs.createWriteStream(config.out, { flags: "a" }) : null;
2828+ const errStream = config.err ? fs.createWriteStream(config.err, { flags: "a" }) : null;
2929+ const logStream = config.log ? fs.createWriteStream(config.log, { flags: "a" }) : null;
3030+3131+ return {
3232+ write(source, line, ts = Date.now()) {
3333+ if (source === "out" && outStream) outStream.write(line + "\n");
3434+ if (source === "err" && errStream) errStream.write(line + "\n");
3535+ if (logStream) logStream.write(formatTaggedLine(ts, source, line) + "\n");
3636+ },
3737+ close() {
3838+ return new Promise<void>((resolve) => {
3939+ let pending = 0;
4040+ const done = () => { if (--pending <= 0) resolve(); };
4141+ for (const s of [outStream, errStream, logStream]) {
4242+ if (!s) continue;
4343+ pending++;
4444+ s.end(done);
4545+ }
4646+ if (pending === 0) resolve();
4747+ });
4848+ },
4949+ };
5050+}
5151+5252+/** Format one line for the combined `--log` sink:
5353+ * `[HH:MM:SS.mmm out] the line` or `[... err] the line`.
5454+ * Exported for unit testing. */
5555+export function formatTaggedLine(ts: number, source: LogSource, line: string): string {
5656+ const d = new Date(ts);
5757+ const hh = String(d.getHours()).padStart(2, "0");
5858+ const mm = String(d.getMinutes()).padStart(2, "0");
5959+ const ss = String(d.getSeconds()).padStart(2, "0");
6060+ const ms = String(d.getMilliseconds()).padStart(3, "0");
6161+ return `[${hh}:${mm}:${ss}.${ms} ${source}] ${line}`;
6262+}
+108
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.
1111+//
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.
1717+1818+import { signal, stripAnsi } from "../../src/tui/index.ts";
1919+2020+export type LogSource = "out" | "err";
2121+2222+export interface LogEntry {
2323+ /** Monotonic per-store sequence. Survives ring-buffer drops. */
2424+ seq: number;
2525+ /** Epoch ms when the line was captured. */
2626+ ts: number;
2727+ source: LogSource;
2828+ /** Raw line, possibly containing ANSI escape sequences. No trailing \n. */
2929+ line: string;
3030+}
3131+3232+export type FilterMode = "both" | "out" | "err";
3333+3434+export interface ReadableEntries {
3535+ /** 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. */
3838+ get(): readonly LogEntry[];
3939+}
4040+4141+export interface LogStore {
4242+ readonly entries: ReadableEntries;
4343+ append(source: LogSource, line: string, now?: number): LogEntry;
4444+ clear(): void;
4545+ readonly scrollbackLimit: number;
4646+}
4747+4848+export function createStore(scrollbackLimit: number): LogStore {
4949+ if (scrollbackLimit <= 0) throw new Error("scrollbackLimit must be positive");
5050+5151+ const buffer: LogEntry[] = [];
5252+ const version = signal(0);
5353+ let nextSeq = 0;
5454+ const compactAt = scrollbackLimit * 2;
5555+5656+ return {
5757+ scrollbackLimit,
5858+ entries: {
5959+ 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;
6767+ },
6868+ },
6969+ append(source, line, now = Date.now()) {
7070+ const entry: LogEntry = { seq: nextSeq++, ts: now, source, line };
7171+ 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.
7474+ if (buffer.length >= compactAt) {
7575+ buffer.splice(0, buffer.length - scrollbackLimit);
7676+ }
7777+ version.set(version.peek() + 1);
7878+ return entry;
7979+ },
8080+ clear() {
8181+ buffer.length = 0;
8282+ version.set(version.peek() + 1);
8383+ },
8484+ };
8585+}
8686+8787+/** 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. */
9191+export function applyView(
9292+ entries: readonly LogEntry[],
9393+ filter: FilterMode,
9494+ query: string,
9595+): LogEntry[] {
9696+ const q = query.trim().toLowerCase();
9797+ const result: LogEntry[] = [];
9898+ for (const entry of entries) {
9999+ if (filter === "out" && entry.source !== "out") continue;
100100+ if (filter === "err" && entry.source !== "err") continue;
101101+ if (q) {
102102+ const plain = stripAnsi(entry.line).toLowerCase();
103103+ if (!plain.includes(q)) continue;
104104+ }
105105+ result.push(entry);
106106+ }
107107+ return result;
108108+}