This repository has no description
0

Configure Feed

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

loggy demo

Nathan Herald (Apr 17, 2026, 4:39 PM +0200) 8e801067 313dd711

+1438 -7
+3 -2
README.md
··· 313 313 314 314 > **Alpha** — the TUI framework API is unstable and will change. Use it for experiments, not production. 315 315 316 - The `demos/` directory has three working apps built with the framework: 316 + The `demos/` directory has four working apps built with the framework: 317 317 318 318 - **file-browser** — two-pane directory tree + file preview with soft-wrap and markdown highlighting 319 319 - **reminders** — full CRUD backed by `.md` files, three views (list, board, calendar), overlays 320 320 - **agent-teams** — live dashboard of a simulated AI agent hierarchy with real-time updates 321 + - **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. 321 322 322 - 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. 323 + 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. 323 324 324 325 ## Skill Reference 325 326
+26
bin/loggy
··· 1 + #!/usr/bin/env node 2 + // Thin shim that execs node against demos/loggy/main.ts with 3 + // --experimental-strip-types so it runs directly from source without a 4 + // separate build step. Matches the "run a demo from source" pattern used 5 + // by ./demos/run. 6 + 7 + import { existsSync } from 'node:fs'; 8 + import { spawnSync } from 'node:child_process'; 9 + import { fileURLToPath } from 'node:url'; 10 + import { dirname, join } from 'node:path'; 11 + 12 + const __dirname = dirname(fileURLToPath(import.meta.url)); 13 + const mainTs = join(__dirname, '..', 'demos', 'loggy', 'main.ts'); 14 + 15 + if (!existsSync(mainTs)) { 16 + console.error(`loggy: missing ${mainTs}`); 17 + process.exit(1); 18 + } 19 + 20 + const result = spawnSync( 21 + process.execPath, 22 + ['--experimental-strip-types', '--no-warnings', mainTs, ...process.argv.slice(2)], 23 + { stdio: 'inherit', env: process.env }, 24 + ); 25 + 26 + process.exit(result.status ?? 1);
+107
demos/loggy/child.ts
··· 1 + // Spawn a child process with piped stdout/stderr (NOT a PTY), line-buffer 2 + // each stream, and emit tagged `onLine` callbacks. 3 + // 4 + // We explicitly avoid node-pty here: the whole point of loggy is to keep 5 + // stdout and stderr distinguishable, and a PTY merges them at the kernel 6 + // level. The tradeoff is that the child process doesn't get a terminal — 7 + // TUI apps like vim/htop won't work, but line-oriented logs (CI, builds, 8 + // servers) are exactly right. 9 + 10 + import { spawn, type ChildProcess } from "node:child_process"; 11 + import type { LogSource } from "./store.ts"; 12 + 13 + export interface SpawnChildOptions { 14 + forceColor: boolean; 15 + onLine: (source: LogSource, line: string) => void; 16 + onExit: (exitCode: number | null, signal: NodeJS.Signals | null) => void; 17 + /** Called when spawn itself fails (ENOENT etc.). */ 18 + onError?: (err: Error) => void; 19 + } 20 + 21 + export interface ChildHandle { 22 + readonly pid: number | undefined; 23 + /** Ask the child to exit. SIGTERM first; after `graceMs` if still alive, 24 + * SIGKILL. No-op if the child has already exited. */ 25 + stop(graceMs?: number): Promise<void>; 26 + /** True once the child's `exit` event has fired. */ 27 + readonly exited: boolean; 28 + } 29 + 30 + export function spawnChild( 31 + command: string, 32 + args: readonly string[], 33 + opts: SpawnChildOptions, 34 + ): ChildHandle { 35 + const env: Record<string, string> = { ...(process.env as Record<string, string>) }; 36 + if (opts.forceColor && !env.FORCE_COLOR) env.FORCE_COLOR = "1"; 37 + 38 + const child: ChildProcess = spawn(command, args as string[], { 39 + stdio: ["ignore", "pipe", "pipe"], 40 + env, 41 + }); 42 + 43 + let exited = false; 44 + child.on("exit", (code, signal) => { 45 + exited = true; 46 + // Flush any partial buffered line on exit so we don't drop the last 47 + // piece of output that didn't end in \n. 48 + flushRemainder("out"); 49 + flushRemainder("err"); 50 + opts.onExit(code, signal); 51 + }); 52 + 53 + if (opts.onError) { 54 + child.on("error", opts.onError); 55 + } 56 + 57 + const remainders: Record<LogSource, string> = { out: "", err: "" }; 58 + 59 + function feed(source: LogSource, chunk: Buffer | string) { 60 + const text = remainders[source] + (typeof chunk === "string" ? chunk : chunk.toString("utf-8")); 61 + const lines = text.split("\n"); 62 + // The last element is the partial line after the final \n (or the whole 63 + // thing if there was no \n at all). 64 + remainders[source] = lines.pop() ?? ""; 65 + for (const line of lines) { 66 + opts.onLine(source, line); 67 + } 68 + } 69 + 70 + function flushRemainder(source: LogSource) { 71 + const partial = remainders[source]; 72 + if (partial.length > 0) { 73 + remainders[source] = ""; 74 + opts.onLine(source, partial); 75 + } 76 + } 77 + 78 + child.stdout?.on("data", (chunk) => feed("out", chunk)); 79 + child.stderr?.on("data", (chunk) => feed("err", chunk)); 80 + child.stdout?.on("end", () => flushRemainder("out")); 81 + child.stderr?.on("end", () => flushRemainder("err")); 82 + 83 + return { 84 + get pid() { return child.pid; }, 85 + get exited() { return exited; }, 86 + stop(graceMs = 2000) { 87 + return new Promise<void>((resolve) => { 88 + if (exited) { resolve(); return; } 89 + 90 + try { child.kill("SIGTERM"); } catch {} 91 + 92 + const killTimer = setTimeout(() => { 93 + if (!exited) { 94 + try { child.kill("SIGKILL"); } catch {} 95 + } 96 + }, graceMs); 97 + 98 + const onExit = () => { 99 + clearTimeout(killTimer); 100 + resolve(); 101 + }; 102 + if (exited) { clearTimeout(killTimer); resolve(); } 103 + else child.once("exit", onExit); 104 + }); 105 + }, 106 + }; 107 + }
+115
demos/loggy/cli.ts
··· 1 + // Pure argv parser for loggy. No I/O — safe to unit-test. 2 + 3 + export interface ParsedArgs { 4 + /** File to tee child stdout to (raw). */ 5 + out: string | null; 6 + /** File to tee child stderr to (raw). */ 7 + err: string | null; 8 + /** File to tee combined tagged output to (plain-text with prefix). */ 9 + log: string | null; 10 + /** When true, don't set FORCE_COLOR=1 in the child's env. */ 11 + noColor: boolean; 12 + /** In-memory line limit before oldest lines are dropped. */ 13 + scrollback: number; 14 + /** The command to run. */ 15 + command: string; 16 + /** Arguments to pass to the command. */ 17 + args: string[]; 18 + } 19 + 20 + export const DEFAULT_SCROLLBACK = 10_000; 21 + 22 + export const USAGE = 23 + `Usage: loggy [flags] <command> [args...]\n\n` + 24 + `Wrap a command, capture stdout and stderr as separate streams, and stream\n` + 25 + `them into a live TUI with filter (o/e/b), search (/), and follow (f).\n\n` + 26 + `Flags:\n` + 27 + ` --out <path> Tee child stdout to <path>\n` + 28 + ` --err <path> Tee child stderr to <path>\n` + 29 + ` --log <path> Tee both streams to <path> as tagged plain-text\n` + 30 + ` --no-color Do not set FORCE_COLOR=1 in the child's env\n` + 31 + ` --scrollback <n> In-memory line limit (default ${DEFAULT_SCROLLBACK})\n` + 32 + ` -h, --help Show this help\n\n` + 33 + `Examples:\n` + 34 + ` loggy npm run build\n` + 35 + ` loggy --log /tmp/all.log -- tsc --watch\n` + 36 + ` loggy --out /tmp/o.log --err /tmp/e.log bin/my-server\n`; 37 + 38 + export class UsageError extends Error { 39 + constructor(message: string) { 40 + super(message); 41 + this.name = "UsageError"; 42 + } 43 + } 44 + 45 + /** Parse loggy's argv. `argv` should exclude the node and script entries 46 + * (i.e. `process.argv.slice(2)`). Throws `UsageError` on malformed input 47 + * or when the user asked for help. */ 48 + export function parseArgs(argv: readonly string[]): ParsedArgs { 49 + const out: ParsedArgs = { 50 + out: null, 51 + err: null, 52 + log: null, 53 + noColor: false, 54 + scrollback: DEFAULT_SCROLLBACK, 55 + command: "", 56 + args: [], 57 + }; 58 + 59 + let i = 0; 60 + while (i < argv.length) { 61 + const arg = argv[i]; 62 + 63 + if (arg === "--") { 64 + // Everything after -- is the command + its args 65 + i++; 66 + if (i >= argv.length) throw new UsageError("Missing command after --"); 67 + out.command = argv[i]; 68 + out.args = argv.slice(i + 1); 69 + return finalize(out); 70 + } 71 + 72 + if (arg === "-h" || arg === "--help") { 73 + throw new UsageError("__help__"); 74 + } 75 + 76 + if (arg === "--no-color") { out.noColor = true; i++; continue; } 77 + 78 + if (arg === "--out") { 79 + if (i + 1 >= argv.length) throw new UsageError("--out requires a path"); 80 + out.out = argv[i + 1]; i += 2; continue; 81 + } 82 + if (arg === "--err") { 83 + if (i + 1 >= argv.length) throw new UsageError("--err requires a path"); 84 + out.err = argv[i + 1]; i += 2; continue; 85 + } 86 + if (arg === "--log") { 87 + if (i + 1 >= argv.length) throw new UsageError("--log requires a path"); 88 + out.log = argv[i + 1]; i += 2; continue; 89 + } 90 + if (arg === "--scrollback") { 91 + if (i + 1 >= argv.length) throw new UsageError("--scrollback requires a number"); 92 + const n = parseInt(argv[i + 1], 10); 93 + if (!Number.isFinite(n) || n <= 0) throw new UsageError(`--scrollback must be a positive integer (got "${argv[i + 1]}")`); 94 + out.scrollback = n; 95 + i += 2; 96 + continue; 97 + } 98 + 99 + if (arg.startsWith("-")) { 100 + throw new UsageError(`Unknown flag: ${arg}`); 101 + } 102 + 103 + // First non-flag: treat as the command. Rest are its args. 104 + out.command = arg; 105 + out.args = argv.slice(i + 1); 106 + return finalize(out); 107 + } 108 + 109 + throw new UsageError("Missing command"); 110 + } 111 + 112 + function finalize(out: ParsedArgs): ParsedArgs { 113 + if (!out.command) throw new UsageError("Missing command"); 114 + return out; 115 + }
+22
demos/loggy/fixtures/emitter.js
··· 1 + #!/usr/bin/env node 2 + // Fixture used by loggy integration tests. Emits a paced, deterministic 3 + // sequence of stdout and stderr lines, then stays alive long enough for 4 + // the test to exercise the TUI. 5 + // 6 + // Line cadence is deliberately slower than the TUI's render tick so each 7 + // line reliably shows up in at least one frame before the next arrives. 8 + 9 + process.stdout.write("stdout one\n"); 10 + process.stderr.write("stderr one\n"); 11 + 12 + setTimeout(() => process.stdout.write("stdout two\n"), 60); 13 + setTimeout(() => process.stderr.write("ERROR: something broke\n"), 120); 14 + setTimeout(() => process.stdout.write("stdout three\n"), 180); 15 + setTimeout(() => process.stdout.write("stdout four\n"), 240); 16 + setTimeout(() => process.stderr.write("stderr four\n"), 300); 17 + 18 + // Allow the test to interact (filter, search, scroll) before the child 19 + // exits on its own. 20 + setTimeout(() => process.exit(0), 10_000); 21 + 22 + process.on("SIGTERM", () => process.exit(0));
+15
demos/loggy/fixtures/firehose.js
··· 1 + #!/usr/bin/env node 2 + // Fires a large volume of lines as fast as Node can write them, to stand 3 + // in for a child like `find /` whose output rate can outpace naive 4 + // signal + render loops. Used by the "stays interactive under load" 5 + // integration test. 6 + 7 + const TOTAL = 5_000; 8 + for (let i = 0; i < TOTAL; i++) { 9 + const stream = i % 13 === 0 ? process.stderr : process.stdout; 10 + stream.write(`line ${i}\n`); 11 + } 12 + // Keep the process alive so the test can poke at the TUI after the 13 + // firehose finishes writing. 14 + setTimeout(() => process.exit(0), 10_000); 15 + process.on("SIGTERM", () => process.exit(0));
+250
demos/loggy/loggy-integration.test.ts
··· 1 + // End-to-end integration tests for loggy: spawn the demo as a real PTY 2 + // process, let it wrap a deterministic emitter fixture, and drive the TUI 3 + // via keystrokes. 4 + 5 + import { describe, it, expect, afterEach } from "vitest"; 6 + import * as fs from "node:fs"; 7 + import * as os from "node:os"; 8 + import * as path from "node:path"; 9 + import { fileURLToPath } from "node:url"; 10 + import { Session } from "../../src/testing/index.ts"; 11 + 12 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 13 + const mainScript = path.join(__dirname, "main.ts"); 14 + const emitter = path.join(__dirname, "fixtures", "emitter.js"); 15 + const firehose = path.join(__dirname, "fixtures", "firehose.js"); 16 + 17 + let session: Session | null = null; 18 + const tmpPaths: string[] = []; 19 + 20 + async function startLoggy(extraArgs: readonly string[] = [], rows = 24, cols = 100): Promise<Session> { 21 + return spawnLoggyWrapping(emitter, extraArgs, rows, cols); 22 + } 23 + 24 + async function spawnLoggyWrapping( 25 + childScript: string, 26 + extraArgs: readonly string[] = [], 27 + rows = 24, 28 + cols = 100, 29 + ): Promise<Session> { 30 + const args = [ 31 + "--experimental-strip-types", 32 + "--no-warnings", 33 + mainScript, 34 + ...extraArgs, 35 + "--", 36 + process.execPath, 37 + childScript, 38 + ]; 39 + session = Session.spawn("node", args, { rows, cols, env: { TERM: "xterm-256color" } }); 40 + await session.waitForText("loggy", 15_000); 41 + return session; 42 + } 43 + 44 + afterEach(async () => { 45 + if (session) { 46 + await session.close(); 47 + session = null; 48 + } 49 + for (const p of tmpPaths) { 50 + try { fs.unlinkSync(p); } catch {} 51 + } 52 + tmpPaths.length = 0; 53 + }); 54 + 55 + function tmpFile(suffix: string): string { 56 + const p = path.join(os.tmpdir(), `loggy-test-${Date.now()}-${Math.random().toString(36).slice(2, 6)}-${suffix}`); 57 + tmpPaths.push(p); 58 + return p; 59 + } 60 + 61 + // =========================================================================== 62 + 63 + describe("loggy integration", () => { 64 + it("renders the rounded panel with command in the title", async () => { 65 + const s = await startLoggy(); 66 + const ss = await s.waitForText("loggy \u2014", 10_000); 67 + // Rounded corners come from panel's default box style. 68 + expect(ss.text).toMatch(/[╭╮╰╯]/); 69 + }, 20_000); 70 + 71 + it("shows both stdout and stderr lines with source tags", async () => { 72 + const s = await startLoggy(); 73 + await s.waitForText("stdout one", 10_000); 74 + await s.waitForText("stderr one", 10_000); 75 + const ss = s.screenshot(); 76 + // Both labels appear in the rendered list (our format is "● out" / "● err"). 77 + expect(ss.text).toContain("out"); 78 + expect(ss.text).toContain("err"); 79 + expect(ss.text).toContain("stdout one"); 80 + expect(ss.text).toContain("stderr one"); 81 + }, 20_000); 82 + 83 + it("'o' filters to stdout only", async () => { 84 + const s = await startLoggy(); 85 + await s.waitForText("stderr one", 10_000); 86 + s.press("o"); 87 + await s.waitFor((ss) => !ss.text.includes("stderr one"), 5_000, "stderr hidden"); 88 + const ss = s.screenshot(); 89 + expect(ss.text).toContain("stdout one"); 90 + expect(ss.text).not.toContain("stderr one"); 91 + }, 20_000); 92 + 93 + it("'e' filters to stderr only", async () => { 94 + const s = await startLoggy(); 95 + await s.waitForText("stdout one", 10_000); 96 + s.press("e"); 97 + await s.waitFor((ss) => !ss.text.includes("stdout one"), 5_000, "stdout hidden"); 98 + const ss = s.screenshot(); 99 + expect(ss.text).not.toContain("stdout one"); 100 + expect(ss.text).toContain("stderr one"); 101 + }, 20_000); 102 + 103 + it("'b' restores both streams after a filter", async () => { 104 + const s = await startLoggy(); 105 + await s.waitForText("stderr one", 10_000); 106 + s.press("o"); 107 + await s.waitFor((ss) => !ss.text.includes("stderr one"), 5_000, "stderr hidden"); 108 + s.press("b"); 109 + await s.waitFor((ss) => ss.text.includes("stderr one") && ss.text.includes("stdout one"), 5_000, "both restored"); 110 + }, 20_000); 111 + 112 + it("'/' opens search and typed query filters to matching lines", async () => { 113 + const s = await startLoggy(); 114 + await s.waitForText("ERROR: something broke", 10_000); 115 + s.type("/"); 116 + // Search prompt appears in the status bar. 117 + await s.waitForText("/", 5_000); 118 + s.type("ERROR"); 119 + await s.waitFor( 120 + (ss) => ss.text.includes("ERROR") && !ss.text.includes("stdout one"), 121 + 5_000, 122 + "only ERROR line visible", 123 + ); 124 + const ss = s.screenshot(); 125 + expect(ss.text).toContain("ERROR: something broke"); 126 + expect(ss.text).not.toContain("stdout one"); 127 + expect(ss.text).not.toContain("stderr one"); 128 + }, 20_000); 129 + 130 + it("escape clears the active search", async () => { 131 + const s = await startLoggy(); 132 + await s.waitForText("ERROR:", 10_000); 133 + s.type("/"); 134 + s.type("ERROR"); 135 + await s.waitFor((ss) => !ss.text.includes("stdout one"), 5_000, "search active"); 136 + s.press("escape"); 137 + await s.waitFor((ss) => ss.text.includes("stdout one"), 5_000, "search cleared"); 138 + }, 20_000); 139 + 140 + it("child status shows 'running' while child is alive", async () => { 141 + const s = await startLoggy(); 142 + await s.waitForText("stdout one", 10_000); 143 + const ss = s.screenshot(); 144 + expect(ss.text).toContain("running"); 145 + }, 20_000); 146 + 147 + it("'q' alone does NOT quit (requires Ctrl+C twice now)", async () => { 148 + const s = await startLoggy(); 149 + await s.waitForText("stdout one", 10_000); 150 + s.type("q"); 151 + // Give it a moment; loggy should still be alive and rendering 152 + await new Promise((r) => setTimeout(r, 300)); 153 + const ss = s.screenshot(); 154 + expect(ss.text).toContain("loggy"); 155 + expect(ss.text).toContain("running"); 156 + }, 20_000); 157 + 158 + it("first Ctrl+C arms a confirmation prompt in the footer", async () => { 159 + const s = await startLoggy(); 160 + await s.waitForText("stdout one", 10_000); 161 + s.press("ctrl+c"); 162 + await s.waitForText("Press Ctrl+C again to quit", 3_000); 163 + const ss = s.screenshot(); 164 + expect(ss.text).toContain("Press Ctrl+C again"); 165 + }, 20_000); 166 + 167 + it("stays interactive under a firehose of output (no event-loop starvation)", async () => { 168 + // Regression test for an O(n²) append bug: wrapping a child that 169 + // emits 20,000 lines would lock up the TUI. After the store fix 170 + // (mutable buffer + version signal) and render throttling, filter 171 + // keys should still register and redraw within a reasonable window. 172 + const s = await spawnLoggyWrapping(firehose); 173 + 174 + // Wait for late output so we know the firehose is well underway. 175 + await s.waitForText("line 49", 15_000); 176 + 177 + // The TUI must still register keystrokes while the firehose is 178 + // streaming / has just finished — press a filter key and confirm 179 + // the footer reflects it. Before the fix, the filter keypress 180 + // would not register because the event loop was saturated. 181 + s.press("o"); 182 + await s.waitFor( 183 + (ss) => ss.text.includes("[O]ut"), 184 + 5_000, 185 + "filter=out registered under load", 186 + ); 187 + 188 + // Every 13th line was routed to stderr (seq 0, 13, 26, …). Switch 189 + // to err-only and verify one of those shows up. 190 + s.press("e"); 191 + await s.waitFor( 192 + (ss) => ss.text.includes("[E]rr"), 193 + 5_000, 194 + "filter=err registered under load", 195 + ); 196 + }, 30_000); 197 + 198 + it("any other key cancels the pending quit", async () => { 199 + const s = await startLoggy(); 200 + await s.waitForText("stdout one", 10_000); 201 + s.press("ctrl+c"); 202 + await s.waitForText("Press Ctrl+C again", 3_000); 203 + s.press("b"); // filter key, but any key cancels 204 + await s.waitFor( 205 + (ss) => !ss.text.includes("Press Ctrl+C again") && ss.text.includes("quit"), 206 + 3_000, 207 + "prompt cleared, normal footer back", 208 + ); 209 + }, 20_000); 210 + 211 + it("--out tees stdout lines to the given file", async () => { 212 + const outFile = tmpFile("out.log"); 213 + const s = await startLoggy(["--out", outFile]); 214 + // Wait for the last emitted line so we know the child has written 215 + // everything before we read the file. 216 + await s.waitForText("stderr four", 10_000); 217 + const content = fs.readFileSync(outFile, "utf-8"); 218 + expect(content).toContain("stdout one"); 219 + expect(content).toContain("stdout four"); 220 + // stderr lines must NOT be in the --out file 221 + expect(content).not.toContain("stderr one"); 222 + expect(content).not.toContain("ERROR: something broke"); 223 + }, 20_000); 224 + 225 + it("--err tees stderr lines to the given file", async () => { 226 + const errFile = tmpFile("err.log"); 227 + const s = await startLoggy(["--err", errFile]); 228 + await s.waitForText("stderr four", 10_000); 229 + const content = fs.readFileSync(errFile, "utf-8"); 230 + expect(content).toContain("stderr one"); 231 + expect(content).toContain("ERROR: something broke"); 232 + expect(content).not.toContain("stdout one"); 233 + }, 20_000); 234 + 235 + it("--log writes combined tagged output", async () => { 236 + const logFile = tmpFile("combined.log"); 237 + const s = await startLoggy(["--log", logFile]); 238 + await s.waitForText("stderr four", 10_000); 239 + const content = fs.readFileSync(logFile, "utf-8"); 240 + // Each line has the [HH:MM:SS.mmm source] prefix 241 + const lines = content.trim().split("\n"); 242 + for (const line of lines) { 243 + expect(line).toMatch(/^\[\d{2}:\d{2}:\d{2}\.\d{3} (out|err)\] /); 244 + } 245 + // All expected content present 246 + expect(content).toMatch(/ out\] stdout one/); 247 + expect(content).toMatch(/ err\] stderr one/); 248 + expect(content).toMatch(/ err\] ERROR: something broke/); 249 + }, 20_000); 250 + });
+195
demos/loggy/loggy.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { parseArgs, UsageError, DEFAULT_SCROLLBACK } from "./cli.ts"; 3 + import { createStore, applyView, type LogEntry } from "./store.ts"; 4 + import { formatTaggedLine } from "./sinks.ts"; 5 + 6 + // ============================================================ 7 + // cli.parseArgs 8 + // ============================================================ 9 + 10 + describe("parseArgs", () => { 11 + it("parses command + args with no flags", () => { 12 + const p = parseArgs(["npm", "run", "build"]); 13 + expect(p.command).toBe("npm"); 14 + expect(p.args).toEqual(["run", "build"]); 15 + expect(p.out).toBeNull(); 16 + expect(p.err).toBeNull(); 17 + expect(p.log).toBeNull(); 18 + expect(p.noColor).toBe(false); 19 + expect(p.scrollback).toBe(DEFAULT_SCROLLBACK); 20 + }); 21 + 22 + it("treats first non-flag positional as the command", () => { 23 + const p = parseArgs(["--out", "/tmp/o.log", "my-cmd", "--my-flag"]); 24 + expect(p.command).toBe("my-cmd"); 25 + expect(p.args).toEqual(["--my-flag"]); 26 + expect(p.out).toBe("/tmp/o.log"); 27 + }); 28 + 29 + it("supports -- as a separator", () => { 30 + const p = parseArgs(["--log", "/tmp/a.log", "--", "tsc", "--watch"]); 31 + expect(p.command).toBe("tsc"); 32 + expect(p.args).toEqual(["--watch"]); 33 + expect(p.log).toBe("/tmp/a.log"); 34 + }); 35 + 36 + it("parses --out, --err, and --log paths", () => { 37 + const p = parseArgs(["--out", "o", "--err", "e", "--log", "l", "cat"]); 38 + expect(p.out).toBe("o"); 39 + expect(p.err).toBe("e"); 40 + expect(p.log).toBe("l"); 41 + }); 42 + 43 + it("parses --no-color", () => { 44 + const p = parseArgs(["--no-color", "cat"]); 45 + expect(p.noColor).toBe(true); 46 + }); 47 + 48 + it("parses --scrollback with a positive integer", () => { 49 + const p = parseArgs(["--scrollback", "500", "cat"]); 50 + expect(p.scrollback).toBe(500); 51 + }); 52 + 53 + it("rejects --scrollback with non-numeric or non-positive values", () => { 54 + expect(() => parseArgs(["--scrollback", "abc", "cat"])).toThrow(UsageError); 55 + expect(() => parseArgs(["--scrollback", "0", "cat"])).toThrow(UsageError); 56 + expect(() => parseArgs(["--scrollback", "-5", "cat"])).toThrow(UsageError); 57 + }); 58 + 59 + it("throws on missing command", () => { 60 + expect(() => parseArgs([])).toThrow(UsageError); 61 + expect(() => parseArgs(["--no-color"])).toThrow(UsageError); 62 + }); 63 + 64 + it("throws on unknown flag", () => { 65 + expect(() => parseArgs(["--nope", "cat"])).toThrow(/Unknown flag/); 66 + }); 67 + 68 + it("throws a special help marker on -h / --help", () => { 69 + expect(() => parseArgs(["--help"])).toThrow(/__help__/); 70 + expect(() => parseArgs(["-h"])).toThrow(/__help__/); 71 + }); 72 + 73 + it("throws when --out is given without a value", () => { 74 + expect(() => parseArgs(["--out"])).toThrow(/requires a path/); 75 + }); 76 + }); 77 + 78 + // ============================================================ 79 + // store — ring buffer + reactive entries 80 + // ============================================================ 81 + 82 + describe("createStore", () => { 83 + it("appends entries with monotonic seq", () => { 84 + const s = createStore(100); 85 + s.append("out", "a"); 86 + s.append("err", "b"); 87 + s.append("out", "c"); 88 + const all = s.entries.get(); 89 + expect(all.map((e) => e.seq)).toEqual([0, 1, 2]); 90 + expect(all.map((e) => e.source)).toEqual(["out", "err", "out"]); 91 + expect(all.map((e) => e.line)).toEqual(["a", "b", "c"]); 92 + }); 93 + 94 + it("drops oldest when scrollback limit is reached; seq stays monotonic", () => { 95 + const s = createStore(3); 96 + s.append("out", "1"); 97 + s.append("out", "2"); 98 + s.append("out", "3"); 99 + s.append("out", "4"); 100 + s.append("out", "5"); 101 + const all = s.entries.get(); 102 + expect(all.length).toBe(3); 103 + expect(all.map((e) => e.line)).toEqual(["3", "4", "5"]); 104 + expect(all.map((e) => e.seq)).toEqual([2, 3, 4]); 105 + }); 106 + 107 + it("rejects non-positive scrollback", () => { 108 + expect(() => createStore(0)).toThrow(); 109 + expect(() => createStore(-1)).toThrow(); 110 + }); 111 + }); 112 + 113 + // ============================================================ 114 + // applyView — filter + search 115 + // ============================================================ 116 + 117 + function mk(source: "out" | "err", line: string, seq = 0): LogEntry { 118 + return { seq, ts: 0, source, line }; 119 + } 120 + 121 + describe("applyView", () => { 122 + const entries: LogEntry[] = [ 123 + mk("out", "hello world", 0), 124 + mk("err", "Oh no an error", 1), 125 + mk("out", "finished", 2), 126 + mk("err", "ERROR 42", 3), 127 + ]; 128 + 129 + it("filter=both passes everything", () => { 130 + expect(applyView(entries, "both", "").length).toBe(4); 131 + }); 132 + 133 + it("filter=out keeps only stdout", () => { 134 + const v = applyView(entries, "out", ""); 135 + expect(v.map((e) => e.line)).toEqual(["hello world", "finished"]); 136 + }); 137 + 138 + it("filter=err keeps only stderr", () => { 139 + const v = applyView(entries, "err", ""); 140 + expect(v.map((e) => e.line)).toEqual(["Oh no an error", "ERROR 42"]); 141 + }); 142 + 143 + it("search is case-insensitive substring", () => { 144 + expect(applyView(entries, "both", "error").map((e) => e.line)) 145 + .toEqual(["Oh no an error", "ERROR 42"]); 146 + expect(applyView(entries, "both", "HELLO").map((e) => e.line)) 147 + .toEqual(["hello world"]); 148 + }); 149 + 150 + it("search composes with filter", () => { 151 + expect(applyView(entries, "err", "42").map((e) => e.line)) 152 + .toEqual(["ERROR 42"]); 153 + expect(applyView(entries, "out", "error")).toEqual([]); 154 + }); 155 + 156 + it("search ignores ANSI escape sequences in the line", () => { 157 + const colored: LogEntry[] = [ 158 + mk("out", "\x1b[31mred error\x1b[0m", 0), 159 + mk("out", "plain error", 1), 160 + ]; 161 + const v = applyView(colored, "both", "red error"); 162 + expect(v).toHaveLength(1); 163 + expect(v[0].line).toContain("\x1b[31m"); // ANSI preserved in output 164 + }); 165 + 166 + it("empty search is a no-op", () => { 167 + expect(applyView(entries, "both", " ").length).toBe(4); 168 + expect(applyView(entries, "both", "").length).toBe(4); 169 + }); 170 + }); 171 + 172 + // ============================================================ 173 + // sinks.formatTaggedLine 174 + // ============================================================ 175 + 176 + describe("formatTaggedLine", () => { 177 + it("produces [HH:MM:SS.mmm source] line format", () => { 178 + // Fix the timestamp to avoid TZ drift: 2026-04-17T15:30:45.123 local — 179 + // just check the structure, not the exact hour (depends on TZ). 180 + const ts = Date.parse("2026-04-17T15:30:45.123"); 181 + const line = formatTaggedLine(ts, "out", "hello"); 182 + expect(line).toMatch(/^\[\d{2}:\d{2}:\d{2}\.\d{3} out\] hello$/); 183 + }); 184 + 185 + it("uses the right source tag for err", () => { 186 + const line = formatTaggedLine(Date.now(), "err", "boom"); 187 + expect(line).toMatch(/ err\] boom$/); 188 + }); 189 + 190 + it("zero-pads small time components", () => { 191 + const ts = Date.parse("2026-04-17T01:02:03.004"); 192 + const line = formatTaggedLine(ts, "out", "x"); 193 + expect(line).toMatch(/^\[\d{2}:02:03\.004 out\] x$/); 194 + }); 195 + });
+255
demos/loggy/main.ts
··· 1 + // loggy — wrap a command, capture stdout/stderr separately, live TUI with 2 + // filter/search. See demos/loggy/README-ish notes at the top of the repo's 3 + // changelog when shipped; for now, ./demos/run loggy <command>. 4 + 5 + import { 6 + parseKey, CellBuffer, diff, fullRender, effect, batch, 7 + hideCursor, showCursor, reset, 8 + signal, computed, themes, 9 + type ScreenContext, type Theme, 10 + } from "../../src/tui/index.ts"; 11 + import { parseArgs, UsageError, USAGE } from "./cli.ts"; 12 + import { createStore, applyView, type FilterMode } from "./store.ts"; 13 + import { createSinks } from "./sinks.ts"; 14 + import { spawnChild } from "./child.ts"; 15 + import { buildLoggyScreen, type ScreenState } from "./screen.ts"; 16 + 17 + const enterAltScreen = "\x1b[?1049h"; 18 + const leaveAltScreen = "\x1b[?1049l"; 19 + 20 + // ---- Parse argv ------------------------------------------------------------ 21 + 22 + let parsed; 23 + try { 24 + parsed = parseArgs(process.argv.slice(2)); 25 + } catch (e) { 26 + const err = e as Error; 27 + if (err.message === "__help__") { 28 + process.stdout.write(USAGE); 29 + process.exit(0); 30 + } 31 + if (err instanceof UsageError) { 32 + process.stderr.write(`loggy: ${err.message}\n\n${USAGE}`); 33 + process.exit(2); 34 + } 35 + throw e; 36 + } 37 + 38 + // ---- Signals --------------------------------------------------------------- 39 + 40 + const store = createStore(parsed.scrollback); 41 + const filter = signal<FilterMode>("both"); 42 + const searchQuery = signal(""); 43 + const searchActive = signal(false); 44 + const follow = signal(true); 45 + const selectedIndex = signal(0); 46 + const scrollOffset = signal(0); 47 + const childState = signal<{ 48 + alive: boolean; 49 + pid: number | undefined; 50 + exitCode: number | null; 51 + signal: string | null; 52 + }>({ alive: true, pid: undefined, exitCode: null, signal: null }); 53 + 54 + /** True between the first and second Ctrl+C (or until the timeout clears 55 + * it). Drives the footer prompt and the quit-vs-arm decision. */ 56 + const quitPending = signal(false); 57 + const QUIT_WINDOW_MS = 2000; 58 + let quitPendingTimer: ReturnType<typeof setTimeout> | null = null; 59 + 60 + function armQuit(): void { 61 + quitPending.set(true); 62 + if (quitPendingTimer) clearTimeout(quitPendingTimer); 63 + quitPendingTimer = setTimeout(() => { 64 + quitPending.set(false); 65 + quitPendingTimer = null; 66 + }, QUIT_WINDOW_MS); 67 + } 68 + 69 + function clearQuit(): void { 70 + if (quitPendingTimer) { 71 + clearTimeout(quitPendingTimer); 72 + quitPendingTimer = null; 73 + } 74 + if (quitPending.peek()) quitPending.set(false); 75 + } 76 + 77 + const visibleLogs = computed(() => applyView(store.entries.get(), filter.get(), searchQuery.get())); 78 + 79 + // ---- Sinks + child --------------------------------------------------------- 80 + 81 + const sinks = createSinks({ out: parsed.out, err: parsed.err, log: parsed.log }); 82 + 83 + const child = spawnChild(parsed.command, parsed.args, { 84 + forceColor: !parsed.noColor, 85 + onLine: (source, line) => { 86 + const entry = store.append(source, line); 87 + sinks.write(source, line, entry.ts); 88 + }, 89 + onExit: (code, signal) => { 90 + batch(() => { 91 + childState.set({ 92 + alive: false, 93 + pid: child.pid, 94 + exitCode: code, 95 + signal, 96 + }); 97 + }); 98 + }, 99 + onError: (err) => { 100 + // Surface spawn failures in the log itself so the user sees them. 101 + store.append("err", `loggy: failed to spawn: ${err.message}`); 102 + batch(() => { 103 + childState.set({ alive: false, pid: undefined, exitCode: 127, signal: null }); 104 + }); 105 + }, 106 + }); 107 + 108 + // Once we have a PID, update the status line. 109 + childState.set({ alive: true, pid: child.pid, exitCode: null, signal: null }); 110 + 111 + // ---- Screen + render loop -------------------------------------------------- 112 + 113 + let running = true; 114 + let prevBuffer: CellBuffer | null = null; 115 + 116 + function currentTheme(): Theme { 117 + // Match other demos: pick the default terminal theme so colors follow 118 + // the user's actual terminal palette. 119 + return themes.terminal ?? themes.coolBlue ?? Object.values(themes)[0]; 120 + } 121 + 122 + function getSize(): [number, number] { 123 + return [process.stdout.rows ?? 35, process.stdout.columns ?? 120]; 124 + } 125 + 126 + function createContext(rows: number, cols: number): ScreenContext { 127 + return { 128 + rows, cols, 129 + theme: currentTheme(), 130 + boxStyle: "rounded", 131 + navigate: () => {}, 132 + back: () => {}, 133 + openOverlay: () => {}, 134 + closeOverlay: () => {}, 135 + isTextInputActive: () => searchActive.get(), 136 + setTextInputActive: (v) => searchActive.set(v), 137 + }; 138 + } 139 + 140 + let quitting = false; 141 + async function cleanup(): Promise<void> { 142 + if (!running) return; 143 + running = false; 144 + // Restore terminal first so any subsequent messages aren't eaten by the 145 + // alt-screen buffer. 146 + process.stdout.write(showCursor() + reset() + leaveAltScreen); 147 + if (process.stdin.isTTY) { 148 + try { process.stdin.setRawMode(false); } catch {} 149 + } 150 + process.stdin.pause(); 151 + await child.stop(2000); 152 + await sinks.close(); 153 + } 154 + 155 + async function quit(): Promise<void> { 156 + if (quitting) return; 157 + quitting = true; 158 + await cleanup(); 159 + process.exit(childState.peek().exitCode ?? 0); 160 + } 161 + 162 + const state: ScreenState = { 163 + commandDisplay: [parsed.command, ...parsed.args].join(" "), 164 + visibleLogs, 165 + filter, searchQuery, searchActive, follow, 166 + selectedIndex, scrollOffset, 167 + childState, 168 + quitPending, 169 + onCtrlC: () => { 170 + if (quitPending.peek()) { 171 + // Second press within the window — actually quit. 172 + clearQuit(); 173 + void quit(); 174 + } else { 175 + armQuit(); 176 + } 177 + }, 178 + }; 179 + 180 + const screen = buildLoggyScreen(state); 181 + 182 + function renderFrame(): void { 183 + if (!running) return; 184 + const [rows, cols] = getSize(); 185 + const ctx = createContext(rows, cols); 186 + const buf = screen.renderToBuffer(ctx); 187 + 188 + let output: string; 189 + if (prevBuffer && prevBuffer.rows === rows && prevBuffer.cols === cols) { 190 + output = diff(prevBuffer, buf); 191 + } else { 192 + output = fullRender(buf); 193 + } 194 + prevBuffer = buf; 195 + process.stdout.write(hideCursor() + output); 196 + } 197 + 198 + // ---- Terminal setup -------------------------------------------------------- 199 + 200 + process.stdout.write(enterAltScreen + hideCursor()); 201 + if (process.stdin.isTTY) { 202 + process.stdin.setRawMode(true); 203 + } 204 + process.stdin.resume(); 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 + }); 229 + 230 + process.stdin.on("data", (data: Buffer | string) => { 231 + const buf = typeof data === "string" ? Buffer.from(data) : data; 232 + const keys = parseKey(buf); 233 + for (const key of keys) { 234 + // Any non-Ctrl+C key cancels a pending quit. Ctrl+C itself is 235 + // handled by the screen (which will either arm or confirm). 236 + if (quitPending.peek() && !(key.name === "c" && key.ctrl)) { 237 + clearQuit(); 238 + } 239 + const [rows, cols] = getSize(); 240 + const ctx = createContext(rows, cols); 241 + screen.handleKey(key, ctx); 242 + } 243 + }); 244 + 245 + process.stdout.on("resize", () => { 246 + prevBuffer = null; 247 + renderFrame(); 248 + }); 249 + 250 + // In raw mode, Ctrl+C arrives as a 0x03 byte via stdin (handled by the 251 + // screen's double-press logic). We still register SIGINT as a safety net 252 + // for cases where raw mode isn't active (e.g., piped stdin) — those 253 + // contexts don't get the double-press UX, but quitting is the right thing. 254 + process.on("SIGINT", () => { void quit(); }); 255 + process.on("SIGTERM", () => { void quit(); });
+272
demos/loggy/screen.ts
··· 1 + // The single loggy TUI screen. 2 + // 3 + // Layout: 4 + // 5 + // ╭─ loggy — <command> ─────────────────────────────╮ 6 + // │ ● out line one │ 7 + // │ ● err an error here │ 8 + // │ ... │ 9 + // ╰─────────────────────────────────────────────────╯ 10 + // [b]oth [o]ut [e]rr /search [f]ollow q quit ● running (pid) 11 + 12 + import { 13 + screen, text, row, panel, selectable, footer, canvas, 14 + updateScrollRegion, 15 + type ScreenContext, type UINode, type KeyEvent, 16 + } from "../../src/tui/index.ts"; 17 + import type { LogEntry, FilterMode } from "./store.ts"; 18 + import type { Signal } from "../../src/tui/index.ts"; 19 + 20 + /** Narrow read-only interface compatible with both Signal<T> and 21 + * computed<T>. screen.ts only reads the visible logs via `.get()`; 22 + * ownership of the underlying reactive state stays with main.ts. */ 23 + export interface ReadableSignal<T> { 24 + get(): T; 25 + } 26 + 27 + export interface ScreenState { 28 + commandDisplay: string; 29 + /** Derived view of log entries after filter + search. */ 30 + visibleLogs: ReadableSignal<LogEntry[]>; 31 + filter: Signal<FilterMode>; 32 + searchQuery: Signal<string>; 33 + searchActive: Signal<boolean>; 34 + follow: Signal<boolean>; 35 + selectedIndex: Signal<number>; 36 + scrollOffset: Signal<number>; 37 + childState: Signal<{ 38 + alive: boolean; 39 + pid: number | undefined; 40 + exitCode: number | null; 41 + signal: string | null; 42 + }>; 43 + /** Set by main.ts when the first Ctrl+C lands; cleared by main.ts after 44 + * a short window. Screen reads this to prompt the user to confirm. */ 45 + quitPending: ReadableSignal<boolean>; 46 + /** Called on every Ctrl+C. main.ts handles the first-vs-second logic 47 + * and the teardown timer. */ 48 + onCtrlC: () => void; 49 + } 50 + 51 + export function buildLoggyScreen(state: ScreenState) { 52 + return screen({ 53 + id: "loggy", 54 + 55 + render(ctx: ScreenContext): UINode[] { 56 + const entries = state.visibleLogs.get(); 57 + const viewport = Math.max(1, ctx.rows - 4); // panel top + bottom + footer 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 + } 65 + 66 + const region = updateScrollRegion( 67 + { 68 + offset: state.scrollOffset.get(), 69 + selectedIndex: state.selectedIndex.get(), 70 + totalItems: entries.length, 71 + viewportHeight: viewport, 72 + }, 73 + entries.length, 74 + viewport, 75 + ); 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 + 79 + const title = "loggy — " + state.commandDisplay; 80 + 81 + 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 + ]), 89 + renderFooter(state), 90 + ]; 91 + }, 92 + 93 + handleKey(key: KeyEvent, ctx: ScreenContext): boolean { 94 + return handleKey(state, key, ctx); 95 + }, 96 + }); 97 + } 98 + 99 + function renderLogLine(entry: LogEntry, _followOn: boolean): UINode[] { 100 + // " ● out " or " ● err " prefix then the content. stderr lines render 101 + // bold + error color; stdout keep their original colors (ANSI passes 102 + // through via the text builder). 103 + if (entry.source === "err") { 104 + return [ 105 + row( 106 + text(" ● err ", "error", { bold: true }), 107 + text(entry.line, "error", { truncate: true }), 108 + ), 109 + ]; 110 + } 111 + return [ 112 + row( 113 + text(" ● out ", "muted", { dim: true }), 114 + text(entry.line, "primary", { truncate: true }), 115 + ), 116 + ]; 117 + } 118 + 119 + function renderFooter(state: ScreenState): UINode { 120 + const filter = state.filter.get(); 121 + const searchActive = state.searchActive.get(); 122 + const query = state.searchQuery.get(); 123 + const follow = state.follow.get(); 124 + const child = state.childState.get(); 125 + 126 + const quitPending = state.quitPending.get(); 127 + 128 + const leftText = quitPending 129 + ? "Press Ctrl+C again to quit (or any other key to cancel)" 130 + : searchActive 131 + ? `/ ${query}\u2588` 132 + : `${marker("b", filter === "both")}oth ` + 133 + `${marker("o", filter === "out")}ut ` + 134 + `${marker("e", filter === "err")}rr ` + 135 + (query ? `search="${query}" ` : "/search ") + 136 + `${marker("f", follow)}ollow ctrl+c\u00d72 quit`; 137 + 138 + const statusText = child.alive 139 + ? `\u25cf running (${child.pid ?? "?"})` 140 + : child.signal 141 + ? `\u2717 signaled (${child.signal})` 142 + : `\u25cb exited (code ${child.exitCode ?? "?"})`; 143 + 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}`); 147 + } 148 + 149 + function marker(letter: string, active: boolean): string { 150 + return active ? `[${letter.toUpperCase()}]` : `[${letter}]`; 151 + } 152 + 153 + function handleKey(state: ScreenState, key: KeyEvent, ctx: ScreenContext): boolean { 154 + // Ctrl+C takes two consecutive presses to quit. main.ts handles the 155 + // first-vs-second state + timeout; we just tell it one happened. Any 156 + // OTHER key press cancels the pending quit before falling through. 157 + if (key.name === "c" && key.ctrl) { 158 + state.onCtrlC(); 159 + return true; 160 + } 161 + 162 + // --- Search mode captures most keys --- 163 + if (state.searchActive.peek()) { 164 + if (key.name === "escape") { 165 + // Esc clears the search AND exits search mode 166 + state.searchActive.set(false); 167 + state.searchQuery.set(""); 168 + return true; 169 + } 170 + if (key.name === "return") { 171 + // Return commits the query and exits search-input mode; query stays 172 + state.searchActive.set(false); 173 + return true; 174 + } 175 + if (key.name === "backspace") { 176 + const q = state.searchQuery.peek(); 177 + if (q.length > 0) state.searchQuery.set(q.slice(0, -1)); 178 + return true; 179 + } 180 + if (key.char && !key.ctrl && !key.alt) { 181 + state.searchQuery.set(state.searchQuery.peek() + key.char); 182 + return true; 183 + } 184 + return true; // swallow other keys in search mode 185 + } 186 + 187 + // --- Global keys (not in search mode) --- 188 + switch (key.name) { 189 + case "o": 190 + state.filter.set("out"); 191 + resetScroll(state); 192 + return true; 193 + 194 + case "e": 195 + state.filter.set("err"); 196 + resetScroll(state); 197 + return true; 198 + 199 + case "b": 200 + state.filter.set("both"); 201 + resetScroll(state); 202 + return true; 203 + 204 + case "f": 205 + state.follow.set(!state.follow.peek()); 206 + return true; 207 + 208 + case "/": 209 + state.searchActive.set(true); 210 + return true; 211 + 212 + case "escape": 213 + // Outside search mode: clear any active search query. 214 + if (state.searchQuery.peek()) { 215 + state.searchQuery.set(""); 216 + return true; 217 + } 218 + return true; 219 + 220 + case "up": 221 + manualScroll(state, -1); 222 + return true; 223 + 224 + case "down": 225 + manualScroll(state, 1); 226 + return true; 227 + 228 + case "pageup": 229 + manualScroll(state, -Math.max(1, ctx.rows - 6)); 230 + return true; 231 + 232 + case "pagedown": 233 + manualScroll(state, Math.max(1, ctx.rows - 6)); 234 + return true; 235 + 236 + case "g": { 237 + // lowercase g → top, uppercase G → bottom + follow 238 + if (key.char === "G") { 239 + state.selectedIndex.set(Math.max(0, state.visibleLogs.get().length - 1)); 240 + state.follow.set(true); 241 + } else { 242 + state.follow.set(false); 243 + state.selectedIndex.set(0); 244 + state.scrollOffset.set(0); 245 + } 246 + return true; 247 + } 248 + } 249 + 250 + return true; 251 + } 252 + 253 + function manualScroll(state: ScreenState, delta: number): void { 254 + state.follow.set(false); 255 + const total = state.visibleLogs.get().length; 256 + if (total === 0) return; 257 + const next = clamp(state.selectedIndex.peek() + delta, 0, total - 1); 258 + state.selectedIndex.set(next); 259 + } 260 + 261 + function resetScroll(state: ScreenState): void { 262 + // When filter/search changes, jump back to the bottom and resume follow. 263 + state.follow.set(true); 264 + state.scrollOffset.set(0); 265 + state.selectedIndex.set(0); 266 + } 267 + 268 + function clamp(n: number, lo: number, hi: number): number { 269 + if (n < lo) return lo; 270 + if (n > hi) return hi; 271 + return n; 272 + }
+62
demos/loggy/sinks.ts
··· 1 + // Optional file writers for --out, --err, --log. 2 + // 3 + // Each configured path opens an append-only write stream at startup. Writes 4 + // are fire-and-forget (queued by Node's stream buffer); `close()` flushes 5 + // and closes them. No file rotation — user rotates externally if needed. 6 + 7 + import * as fs from "node:fs"; 8 + import type { LogSource } from "./store.ts"; 9 + 10 + export interface SinksConfig { 11 + /** Tee raw stdout bytes to this path (as-received). */ 12 + out: string | null; 13 + /** Tee raw stderr bytes to this path (as-received). */ 14 + err: string | null; 15 + /** Tee both streams to this path, tagged + timestamped plain-text. */ 16 + log: string | null; 17 + } 18 + 19 + export interface Sinks { 20 + /** Write a line to the appropriate sinks. `line` must not include a 21 + * trailing newline; one is added here so callers don't double-add. */ 22 + write(source: LogSource, line: string, ts?: number): void; 23 + close(): Promise<void>; 24 + } 25 + 26 + export function createSinks(config: SinksConfig): Sinks { 27 + const outStream = config.out ? fs.createWriteStream(config.out, { flags: "a" }) : null; 28 + const errStream = config.err ? fs.createWriteStream(config.err, { flags: "a" }) : null; 29 + const logStream = config.log ? fs.createWriteStream(config.log, { flags: "a" }) : null; 30 + 31 + return { 32 + write(source, line, ts = Date.now()) { 33 + if (source === "out" && outStream) outStream.write(line + "\n"); 34 + if (source === "err" && errStream) errStream.write(line + "\n"); 35 + if (logStream) logStream.write(formatTaggedLine(ts, source, line) + "\n"); 36 + }, 37 + close() { 38 + return new Promise<void>((resolve) => { 39 + let pending = 0; 40 + const done = () => { if (--pending <= 0) resolve(); }; 41 + for (const s of [outStream, errStream, logStream]) { 42 + if (!s) continue; 43 + pending++; 44 + s.end(done); 45 + } 46 + if (pending === 0) resolve(); 47 + }); 48 + }, 49 + }; 50 + } 51 + 52 + /** Format one line for the combined `--log` sink: 53 + * `[HH:MM:SS.mmm out] the line` or `[... err] the line`. 54 + * Exported for unit testing. */ 55 + export function formatTaggedLine(ts: number, source: LogSource, line: string): string { 56 + const d = new Date(ts); 57 + const hh = String(d.getHours()).padStart(2, "0"); 58 + const mm = String(d.getMinutes()).padStart(2, "0"); 59 + const ss = String(d.getSeconds()).padStart(2, "0"); 60 + const ms = String(d.getMilliseconds()).padStart(3, "0"); 61 + return `[${hh}:${mm}:${ss}.${ms} ${source}] ${line}`; 62 + }
+108
demos/loggy/store.ts
··· 1 + // In-memory log store + pure filter/search over entries. 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. 11 + // 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. 17 + 18 + import { signal, stripAnsi } from "../../src/tui/index.ts"; 19 + 20 + export type LogSource = "out" | "err"; 21 + 22 + export interface LogEntry { 23 + /** Monotonic per-store sequence. Survives ring-buffer drops. */ 24 + seq: number; 25 + /** Epoch ms when the line was captured. */ 26 + ts: number; 27 + source: LogSource; 28 + /** Raw line, possibly containing ANSI escape sequences. No trailing \n. */ 29 + line: string; 30 + } 31 + 32 + export type FilterMode = "both" | "out" | "err"; 33 + 34 + export interface ReadableEntries { 35 + /** 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. */ 38 + get(): readonly LogEntry[]; 39 + } 40 + 41 + export interface LogStore { 42 + readonly entries: ReadableEntries; 43 + append(source: LogSource, line: string, now?: number): LogEntry; 44 + clear(): void; 45 + readonly scrollbackLimit: number; 46 + } 47 + 48 + export function createStore(scrollbackLimit: number): LogStore { 49 + if (scrollbackLimit <= 0) throw new Error("scrollbackLimit must be positive"); 50 + 51 + const buffer: LogEntry[] = []; 52 + const version = signal(0); 53 + let nextSeq = 0; 54 + const compactAt = scrollbackLimit * 2; 55 + 56 + return { 57 + scrollbackLimit, 58 + entries: { 59 + 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; 67 + }, 68 + }, 69 + append(source, line, now = Date.now()) { 70 + const entry: LogEntry = { seq: nextSeq++, ts: now, source, line }; 71 + 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 + if (buffer.length >= compactAt) { 75 + buffer.splice(0, buffer.length - scrollbackLimit); 76 + } 77 + version.set(version.peek() + 1); 78 + return entry; 79 + }, 80 + clear() { 81 + buffer.length = 0; 82 + version.set(version.peek() + 1); 83 + }, 84 + }; 85 + } 86 + 87 + /** 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. */ 91 + export function applyView( 92 + entries: readonly LogEntry[], 93 + filter: FilterMode, 94 + query: string, 95 + ): LogEntry[] { 96 + const q = query.trim().toLowerCase(); 97 + const result: LogEntry[] = []; 98 + for (const entry of entries) { 99 + if (filter === "out" && entry.source !== "out") continue; 100 + if (filter === "err" && entry.source !== "err") continue; 101 + if (q) { 102 + const plain = stripAnsi(entry.line).toLowerCase(); 103 + if (!plain.includes(q)) continue; 104 + } 105 + result.push(entry); 106 + } 107 + return result; 108 + }
+6 -4
demos/run
··· 2 2 # Run a demo app: ./demos/run file-browser [path] 3 3 # ./demos/run reminders 4 4 # ./demos/run agent-teams 5 + # ./demos/run loggy [flags] -- <command> [args...] 5 6 set -e 6 7 7 8 demo="$1" ··· 11 12 echo "Usage: ./demos/run <demo-name> [args...]" 12 13 echo "" 13 14 echo "Available demos:" 14 - echo " file-browser [path] Two-pane file browser with preview" 15 - echo " reminders CRUD reminders backed by .md files" 16 - echo " agent-teams Live AI agent hierarchy dashboard (SPEED=6 for fast)" 15 + echo " file-browser [path] Two-pane file browser with preview" 16 + echo " reminders CRUD reminders backed by .md files" 17 + echo " agent-teams Live AI agent hierarchy dashboard (SPEED=6 for fast)" 18 + echo " loggy [flags] -- <cmd> Live log viewer — wraps a command, captures out/err separately" 17 19 exit 1 18 20 fi 19 21 ··· 21 23 22 24 if [ ! -f "$script" ]; then 23 25 echo "Unknown demo: $demo" 24 - echo "Available: file-browser, reminders, agent-teams" 26 + echo "Available: file-browser, reminders, agent-teams, loggy" 25 27 exit 1 26 28 fi 27 29
+2 -1
package.json
··· 27 27 "LICENSE" 28 28 ], 29 29 "bin": { 30 - "pty": "./bin/pty" 30 + "pty": "./bin/pty", 31 + "loggy": "./bin/loggy" 31 32 }, 32 33 "exports": { 33 34 "./testing": {