This repository has no description
0

Configure Feed

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

Remove loggy — extracted to its own repo at ../loggy/

Loggy was always intended to become its own package once the shape
settled. It has, so it now lives at github.com/myobie/loggy and
consumes pty's TUI framework via relative imports (../pty/src/tui).
Removes demos/loggy/, bin/loggy, the package.json bin entry, and
references in demos/run + README.md.

Nathan Herald (Apr 20, 2026, 3:12 PM +0200) edd10535 a0e6fa8c

+2 -1450
-1
README.md
··· 319 319 - **file-browser** — two-pane directory tree + file preview with soft-wrap and markdown highlighting 320 320 - **reminders** — full CRUD backed by `.md` files, three views (list, board, calendar), overlays 321 321 - **agent-teams** — live dashboard of a simulated AI agent hierarchy with real-time updates 322 - - **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. 323 322 324 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. 325 324
-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 - });
-238
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. Deps are auto-registered by reads inside 207 - // renderFrame. The store's `version` is a debouncedSignal so a 208 - // firehose of appends coalesces to at most one notification per tick 209 - // — the effect naturally runs at most once per tick without any 210 - // render-side throttling. 211 - effect(() => { renderFrame(); }); 212 - 213 - process.stdin.on("data", (data: Buffer | string) => { 214 - const buf = typeof data === "string" ? Buffer.from(data) : data; 215 - const keys = parseKey(buf); 216 - for (const key of keys) { 217 - // Any non-Ctrl+C key cancels a pending quit. Ctrl+C itself is 218 - // handled by the screen (which will either arm or confirm). 219 - if (quitPending.peek() && !(key.name === "c" && key.ctrl)) { 220 - clearQuit(); 221 - } 222 - const [rows, cols] = getSize(); 223 - const ctx = createContext(rows, cols); 224 - screen.handleKey(key, ctx); 225 - } 226 - }); 227 - 228 - process.stdout.on("resize", () => { 229 - prevBuffer = null; 230 - renderFrame(); 231 - }); 232 - 233 - // In raw mode, Ctrl+C arrives as a 0x03 byte via stdin (handled by the 234 - // screen's double-press logic). We still register SIGINT as a safety net 235 - // for cases where raw mode isn't active (e.g., piped stdin) — those 236 - // contexts don't get the double-press UX, but quitting is the right thing. 237 - process.on("SIGINT", () => { void quit(); }); 238 - process.on("SIGTERM", () => { void quit(); });
-286
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 - // If follow mode is on, always position the viewport at the bottom 60 - // regardless of the stored selectedIndex. We do NOT write to the 61 - // signal from inside render — that causes cascading re-renders and 62 - // stale-state bugs. Keeping this read-only keeps the render 63 - // deterministic; manual scrolling flips follow off in handleKey. 64 - const follow = state.follow.get(); 65 - const last = Math.max(0, entries.length - 1); 66 - const effectiveSelected = follow 67 - ? last 68 - : Math.min(state.selectedIndex.get(), last); 69 - 70 - const region = updateScrollRegion( 71 - { 72 - offset: state.scrollOffset.get(), 73 - selectedIndex: effectiveSelected, 74 - totalItems: entries.length, 75 - viewportHeight: viewport, 76 - }, 77 - entries.length, 78 - viewport, 79 - ); 80 - 81 - const title = "loggy — " + state.commandDisplay; 82 - 83 - // When entries is empty we still want the panel at full height — 84 - // otherwise layoutPanel shrinks to fit the fixed-height text node 85 - // and the panel collapses to one row. Pair the text with a flex 86 - // spacer so the panel stays the same size regardless of state. 87 - const panelBody = entries.length === 0 88 - ? [ 89 - text(" (waiting for matching output…)", "muted", { dim: true }), 90 - canvas(() => {}, {}), // fills remaining height 91 - ] 92 - : [ 93 - // selectable is already flex — DO NOT add a sibling flex 94 - // spacer here, or the panel's flex budget will be split and 95 - // only half the viewport will show log lines. 96 - selectable(region, entries, (entry, _i, _selected) => 97 - renderLogLine(entry, follow)), 98 - ]; 99 - 100 - return [ 101 - panel(title, panelBody), 102 - renderFooter(state), 103 - ]; 104 - }, 105 - 106 - handleKey(key: KeyEvent, ctx: ScreenContext): boolean { 107 - return handleKey(state, key, ctx); 108 - }, 109 - }); 110 - } 111 - 112 - function renderLogLine(entry: LogEntry, _followOn: boolean): UINode[] { 113 - // " ● out " or " ● err " prefix then the content. stderr lines render 114 - // bold + error color; stdout keep their original colors (ANSI passes 115 - // through via the text builder). 116 - if (entry.source === "err") { 117 - return [ 118 - row( 119 - text(" ● err ", "error", { bold: true }), 120 - text(entry.line, "error", { truncate: true }), 121 - ), 122 - ]; 123 - } 124 - return [ 125 - row( 126 - text(" ● out ", "muted", { dim: true }), 127 - text(entry.line, "primary", { truncate: true }), 128 - ), 129 - ]; 130 - } 131 - 132 - function renderFooter(state: ScreenState): UINode { 133 - const filter = state.filter.get(); 134 - const searchActive = state.searchActive.get(); 135 - const query = state.searchQuery.get(); 136 - const follow = state.follow.get(); 137 - const child = state.childState.get(); 138 - 139 - const quitPending = state.quitPending.get(); 140 - 141 - const leftText = quitPending 142 - ? "Press Ctrl+C again to quit (or any other key to cancel)" 143 - : searchActive 144 - ? `/ ${query}\u2588` 145 - : `${marker("b", filter === "both")}oth ` + 146 - `${marker("o", filter === "out")}ut ` + 147 - `${marker("e", filter === "err")}rr ` + 148 - (query ? `search="${query}" ` : "/search ") + 149 - `${marker("f", follow)}ollow ctrl+c\u00d72 quit`; 150 - 151 - const statusText = child.alive 152 - ? `\u25cf running (${child.pid ?? "?"})` 153 - : child.signal 154 - ? `\u2717 signaled (${child.signal})` 155 - : `\u25cb exited (code ${child.exitCode ?? "?"})`; 156 - 157 - // Two-column bottom bar: keybindings / search prompt on the left, 158 - // child process status right-aligned. Anchored to the bottom by the 159 - // framework's root layout. 160 - return footer(leftText, statusText); 161 - } 162 - 163 - function marker(letter: string, active: boolean): string { 164 - return active ? `[${letter.toUpperCase()}]` : `[${letter}]`; 165 - } 166 - 167 - function handleKey(state: ScreenState, key: KeyEvent, ctx: ScreenContext): boolean { 168 - // Ctrl+C takes two consecutive presses to quit. main.ts handles the 169 - // first-vs-second state + timeout; we just tell it one happened. Any 170 - // OTHER key press cancels the pending quit before falling through. 171 - if (key.name === "c" && key.ctrl) { 172 - state.onCtrlC(); 173 - return true; 174 - } 175 - 176 - // --- Search mode captures most keys --- 177 - if (state.searchActive.peek()) { 178 - if (key.name === "escape") { 179 - // Esc clears the search AND exits search mode 180 - state.searchActive.set(false); 181 - state.searchQuery.set(""); 182 - return true; 183 - } 184 - if (key.name === "return") { 185 - // Return commits the query and exits search-input mode; query stays 186 - state.searchActive.set(false); 187 - return true; 188 - } 189 - if (key.name === "backspace") { 190 - const q = state.searchQuery.peek(); 191 - if (q.length > 0) state.searchQuery.set(q.slice(0, -1)); 192 - return true; 193 - } 194 - if (key.char && !key.ctrl && !key.alt) { 195 - state.searchQuery.set(state.searchQuery.peek() + key.char); 196 - return true; 197 - } 198 - return true; // swallow other keys in search mode 199 - } 200 - 201 - // --- Global keys (not in search mode) --- 202 - switch (key.name) { 203 - case "o": 204 - state.filter.set("out"); 205 - resetScroll(state); 206 - return true; 207 - 208 - case "e": 209 - state.filter.set("err"); 210 - resetScroll(state); 211 - return true; 212 - 213 - case "b": 214 - state.filter.set("both"); 215 - resetScroll(state); 216 - return true; 217 - 218 - case "f": 219 - state.follow.set(!state.follow.peek()); 220 - return true; 221 - 222 - case "/": 223 - state.searchActive.set(true); 224 - return true; 225 - 226 - case "escape": 227 - // Outside search mode: clear any active search query. 228 - if (state.searchQuery.peek()) { 229 - state.searchQuery.set(""); 230 - return true; 231 - } 232 - return true; 233 - 234 - case "up": 235 - manualScroll(state, -1); 236 - return true; 237 - 238 - case "down": 239 - manualScroll(state, 1); 240 - return true; 241 - 242 - case "pageup": 243 - manualScroll(state, -Math.max(1, ctx.rows - 6)); 244 - return true; 245 - 246 - case "pagedown": 247 - manualScroll(state, Math.max(1, ctx.rows - 6)); 248 - return true; 249 - 250 - case "g": { 251 - // lowercase g → top, uppercase G → bottom + follow 252 - if (key.char === "G") { 253 - state.selectedIndex.set(Math.max(0, state.visibleLogs.get().length - 1)); 254 - state.follow.set(true); 255 - } else { 256 - state.follow.set(false); 257 - state.selectedIndex.set(0); 258 - state.scrollOffset.set(0); 259 - } 260 - return true; 261 - } 262 - } 263 - 264 - return true; 265 - } 266 - 267 - function manualScroll(state: ScreenState, delta: number): void { 268 - state.follow.set(false); 269 - const total = state.visibleLogs.get().length; 270 - if (total === 0) return; 271 - const next = clamp(state.selectedIndex.peek() + delta, 0, total - 1); 272 - state.selectedIndex.set(next); 273 - } 274 - 275 - function resetScroll(state: ScreenState): void { 276 - // When filter/search changes, jump back to the bottom and resume follow. 277 - state.follow.set(true); 278 - state.scrollOffset.set(0); 279 - state.selectedIndex.set(0); 280 - } 281 - 282 - function clamp(n: number, lo: number, hi: number): number { 283 - if (n < lo) return lo; 284 - if (n > hi) return hi; 285 - return n; 286 - }
-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 - }
-128
demos/loggy/store.ts
··· 1 - // In-memory log store + pure filter/search over entries. 2 - // 3 - // Design 4 - // ------ 5 - // Backing storage is a single mutable array (`buffer`). Append pushes 6 - // onto it (amortized O(1)) and triggers a DEBOUNCED change notification 7 - // via `debouncedSignal` — writers mark the store dirty as often as they 8 - // like; subscribers only hear about it once per `setImmediate` tick. 9 - // That keeps a firehose child from saturating the reactive graph and 10 - // gives the TUI a predictable at-most-one-render-per-tick cadence 11 - // without the render layer needing to know anything about throttling. 12 - // 13 - // `applyView` is a pure function over an array snapshot so it's easy 14 - // to unit-test. 15 - 16 - import { debouncedSignal, stripAnsi, type DebouncedSignal } from "../../src/tui/index.ts"; 17 - 18 - export type LogSource = "out" | "err"; 19 - 20 - export interface LogEntry { 21 - /** Monotonic per-store sequence. Survives ring-buffer drops. */ 22 - seq: number; 23 - /** Epoch ms when the line was captured. */ 24 - ts: number; 25 - source: LogSource; 26 - /** Raw line, possibly containing ANSI escape sequences. No trailing \n. */ 27 - line: string; 28 - /** Lazy cache of `stripAnsi(line).toLowerCase()` — materialized on 29 - * first search to avoid redoing the work on every render. */ 30 - _plainLower?: string; 31 - } 32 - 33 - export type FilterMode = "both" | "out" | "err"; 34 - 35 - export interface ReadableEntries { 36 - /** Returns the current window of entries (at most `scrollbackLimit`). 37 - * Registers a dep on the underlying debounced signal so reactive 38 - * effects re-run on append (coalesced to once per tick). Returned 39 - * array MUST NOT be mutated by callers. */ 40 - get(): readonly LogEntry[]; 41 - /** Same as `get()` but without subscribing. Safe outside effects. */ 42 - peek(): readonly LogEntry[]; 43 - } 44 - 45 - export interface LogStore { 46 - readonly entries: ReadableEntries; 47 - append(source: LogSource, line: string, now?: number): LogEntry; 48 - clear(): void; 49 - /** Force the debounced change notification to fire synchronously. 50 - * Useful in tests that want to observe post-append state without 51 - * waiting a tick. */ 52 - flush(): void; 53 - readonly scrollbackLimit: number; 54 - } 55 - 56 - export function createStore(scrollbackLimit: number): LogStore { 57 - if (scrollbackLimit <= 0) throw new Error("scrollbackLimit must be positive"); 58 - 59 - const buffer: LogEntry[] = []; 60 - const version: DebouncedSignal = debouncedSignal(); 61 - let nextSeq = 0; 62 - const compactAt = scrollbackLimit * 2; 63 - 64 - function snapshot(): readonly LogEntry[] { 65 - if (buffer.length > scrollbackLimit) { 66 - return buffer.slice(buffer.length - scrollbackLimit); 67 - } 68 - return buffer; 69 - } 70 - 71 - return { 72 - scrollbackLimit, 73 - entries: { 74 - get(): readonly LogEntry[] { 75 - version.get(); // subscribe (coalesced) — triggers at-most-once-per-tick 76 - return snapshot(); 77 - }, 78 - peek(): readonly LogEntry[] { 79 - return snapshot(); 80 - }, 81 - }, 82 - append(source, line, now = Date.now()) { 83 - const entry: LogEntry = { seq: nextSeq++, ts: now, source, line }; 84 - buffer.push(entry); 85 - if (buffer.length >= compactAt) { 86 - buffer.splice(0, buffer.length - scrollbackLimit); 87 - } 88 - version.bump(); 89 - return entry; 90 - }, 91 - clear() { 92 - buffer.length = 0; 93 - version.bump(); 94 - }, 95 - flush() { 96 - version.flush(); 97 - }, 98 - }; 99 - } 100 - 101 - /** Apply the active filter + search to a list of entries. Pure function. 102 - * Search is case-insensitive substring match against the *plain text* 103 - * of the line (ANSI stripped, lowercased), cached on the entry so the 104 - * expensive regex doesn't re-run per render. */ 105 - export function applyView( 106 - entries: readonly LogEntry[], 107 - filter: FilterMode, 108 - query: string, 109 - ): LogEntry[] { 110 - const q = query.trim().toLowerCase(); 111 - const result: LogEntry[] = []; 112 - for (const entry of entries) { 113 - if (filter === "out" && entry.source !== "out") continue; 114 - if (filter === "err" && entry.source !== "err") continue; 115 - if (q) { 116 - // Cache the plain-text form; stripAnsi + toLowerCase is the hot 117 - // path for searches over a large scrollback. 118 - let plain = entry._plainLower; 119 - if (plain === undefined) { 120 - plain = stripAnsi(entry.line).toLowerCase(); 121 - entry._plainLower = plain; 122 - } 123 - if (!plain.includes(q)) continue; 124 - } 125 - result.push(entry); 126 - } 127 - return result; 128 - }
+1 -3
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...] 6 5 set -e 7 6 8 7 demo="$1" ··· 15 14 echo " file-browser [path] Two-pane file browser with preview" 16 15 echo " reminders CRUD reminders backed by .md files" 17 16 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" 19 17 exit 1 20 18 fi 21 19 ··· 23 21 24 22 if [ ! -f "$script" ]; then 25 23 echo "Unknown demo: $demo" 26 - echo "Available: file-browser, reminders, agent-teams, loggy" 24 + echo "Available: file-browser, reminders, agent-teams" 27 25 exit 1 28 26 fi 29 27
+1 -2
package.json
··· 27 27 "LICENSE" 28 28 ], 29 29 "bin": { 30 - "pty": "./bin/pty", 31 - "loggy": "./bin/loggy" 30 + "pty": "./bin/pty" 32 31 }, 33 32 "exports": { 34 33 "./testing": {