This repository has no description
0

Configure Feed

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

Add TUI framework (alpha), three demo apps, testing docs

TUI framework (src/tui/):
Declarative UI with reactive signals, two-pass layout engine,
cell-buffer diffing, and 5 color themes. Exported as ptym/tui.
Includes text wrapping with word-boundary breaking and a
highlight callback for per-character span styling.

Demo apps (demos/):
Three standalone apps that showcase the framework and the
PTY testing library:

- file-browser: two-pane directory tree + file preview with
soft-wrap and markdown highlighting
- reminders: full CRUD backed by .md files with YAML frontmatter,
three views (grouped list, kanban board, calendar), overlays
- agent-teams: live dashboard of a simulated AI agent hierarchy
with real-time timeline, inter-agent messages, progress bars

Each demo has unit tests and PTY integration tests that verify
actual rendered screen output — not just string presence, but
layout correctness (gutter alignment, panel sizing, selection
indicators, overlay compositing).

Testing improvements:
- JSDoc on all Session methods and type fields
- docs/testing.md: server-mode API (attach, reconnect, resize,
connectToExisting, hasExited)
- Fix timing-sensitive filter test (waitForText instead of sleep)

README:
- Beta notice
- Expanded testing library section with realistic example
- TUI framework alpha notice with demo descriptions

Also: vitest.config.ts, demos/run convenience script.

Nathan Herald (Mar 24, 2026, 11:16 PM +0100) 72c58dcb 31be35a0

+8334 -13
+33 -6
README.md
··· 1 1 # pty 2 2 3 + > **Beta** — the CLI and testing library are usable but the API may change before 1.0. 4 + 3 5 Persistent terminal sessions. Run a process, detach, reconnect later. From anywhere, locally and over SSH. 4 6 5 7 Uses [@xterm/headless](https://github.com/xtermjs/xterm.js/tree/master/headless) internally. ··· 74 76 75 77 ## Testing Library 76 78 77 - ptym includes a terminal testing library — like Playwright, but for the terminal. 78 - Spawn any process in a real PTY, send input, capture screenshots, assert on screen state. 79 + ptym includes a terminal testing library — like Playwright, but for the terminal. Spawn any process in a real PTY, send keystrokes, take screenshots, assert on visible output. 79 80 80 81 ```typescript 81 82 import { Session } from "ptym/testing"; 82 83 83 - const session = Session.spawn("echo", ["hello world"]); 84 - const ss = await session.waitForText("hello world"); 85 - console.log(ss.text); // "hello world" 84 + const session = Session.spawn("node", ["--experimental-strip-types", "my-app.ts"]); 85 + await session.waitForText("Ready"); 86 + 87 + session.press("down"); 88 + session.press("return"); 89 + await session.waitForText("Selected!"); 90 + 91 + const ss = session.screenshot(); 92 + expect(ss.text).toContain("Selected!"); 93 + expect(ss.lines[0]).toMatch(/My App/); 94 + 95 + session.press("ctrl+c"); 96 + await session.waitForAbsent("My App"); 86 97 await session.close(); 87 98 ``` 88 99 89 - See [docs/testing.md](docs/testing.md) for the full API and examples. 100 + Works with any process: CLI tools, interactive TUIs, shells, vim, even `top`. The test runs in a real PTY with a real xterm terminal emulator, so you test exactly what users see. 101 + 102 + See **[docs/testing.md](docs/testing.md)** for the full API reference, key names, patterns, and tips. 103 + 104 + ## TUI Framework (alpha) 105 + 106 + ptym also includes an experimental declarative TUI framework for building terminal interfaces with reactive signals, layout, and efficient cell-buffer diffing. Import from `ptym/tui`. 107 + 108 + > **Alpha** — the TUI framework API is unstable and will change. Use it for experiments, not production. 109 + 110 + The `demos/` directory has three working apps built with the framework: 111 + 112 + - **file-browser** — two-pane directory tree + file preview with soft-wrap and markdown highlighting 113 + - **reminders** — full CRUD backed by `.md` files, three views (list, board, calendar), overlays 114 + - **agent-teams** — live dashboard of a simulated AI agent hierarchy with real-time updates 115 + 116 + 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. 90 117 91 118 ## Tab Completion 92 119
+151
demos/agent-teams/agents-integration.test.ts
··· 1 + // Integration tests for agent teams demo — runs through real PTY 2 + import { describe, it, expect, afterEach } from "vitest"; 3 + import * as path from "node:path"; 4 + import { fileURLToPath } from "node:url"; 5 + import { Session } from "../../src/testing/index.ts"; 6 + 7 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 8 + const mainScript = path.join(__dirname, "main.ts"); 9 + 10 + let session: Session; 11 + 12 + async function startApp(rows = 35, cols = 120): Promise<Session> { 13 + session = Session.spawn("node", ["--experimental-strip-types", "--no-warnings", mainScript], { 14 + rows, 15 + cols, 16 + env: { TERM: "xterm-256color", SPEED: "60" }, // 60x speed = 10 seconds for full timeline 17 + }); 18 + await session.waitForText("Agent Teams", 15000); 19 + return session; 20 + } 21 + 22 + afterEach(async () => { 23 + if (session) { 24 + await session.close(); 25 + } 26 + }); 27 + 28 + describe("Agent Teams Integration", () => { 29 + it("boots and shows the Main Agent", async () => { 30 + await startApp(); 31 + const ss = session.screenshot(); 32 + expect(ss.text).toContain("Agent Teams"); 33 + expect(ss.text).toContain("Main Agent"); 34 + expect(ss.text).toContain("Agents"); 35 + expect(ss.text).toContain("Detail"); 36 + expect(ss.text).toContain("Activity"); 37 + }, 20000); 38 + 39 + it("sub-agents appear after timeline events fire", async () => { 40 + await startApp(); 41 + // At 60x speed, the researcher/coder appear at ~0.5s 42 + await session.waitForText("Researcher", 10000); 43 + const ss = session.screenshot(); 44 + expect(ss.text).toContain("Researcher"); 45 + expect(ss.text).toContain("Coder"); 46 + }, 20000); 47 + 48 + it("arrow keys change selection and update detail", async () => { 49 + await startApp(); 50 + // Wait for sub-agents to appear 51 + await session.waitForText("Researcher", 10000); 52 + 53 + // Move down to select Researcher 54 + session.press("down"); 55 + await new Promise(r => setTimeout(r, 500)); 56 + 57 + const ss = session.screenshot(); 58 + // Detail panel should show Researcher info 59 + expect(ss.text).toContain("Researcher"); 60 + }, 20000); 61 + 62 + it("activity log updates with events", async () => { 63 + await startApp(); 64 + // Activity should show seeded events (the most recent ones visible) 65 + await session.waitForText("Started writing tests", 10000); 66 + const ss = session.screenshot(); 67 + expect(ss.text).toContain("Started writing tests"); 68 + }, 20000); 69 + 70 + it("progress updates over time", async () => { 71 + await startApp(); 72 + // Wait for researcher to start (at 60x, this is ~1 second) 73 + await session.waitForText("Researcher", 10000); 74 + 75 + // Wait a bit for progress updates 76 + await new Promise(r => setTimeout(r, 3000)); 77 + 78 + const ss = session.screenshot(); 79 + // The elapsed counter should have advanced 80 + expect(ss.text).toContain("Elapsed:"); 81 + }, 20000); 82 + 83 + it("p pauses the timeline", async () => { 84 + await startApp(); 85 + await session.waitForText("Researcher", 10000); 86 + 87 + session.type("p"); 88 + await session.waitForText("PAUSED", 5000); 89 + 90 + const ss = session.screenshot(); 91 + expect(ss.text).toContain("PAUSED"); 92 + 93 + // Unpause 94 + session.type("p"); 95 + await session.waitForAbsent("PAUSED", 5000); 96 + }, 20000); 97 + 98 + it("T cycles the theme", async () => { 99 + await startApp(); 100 + const ss1 = session.screenshot(); 101 + 102 + session.type("T"); 103 + await new Promise(r => setTimeout(r, 500)); 104 + 105 + const ss2 = session.screenshot(); 106 + expect(ss2.text).toContain("Agent Teams"); 107 + expect(ss2.ansi).not.toEqual(ss1.ansi); 108 + }, 20000); 109 + 110 + it("ctrl+c quits the app", async () => { 111 + await startApp(); 112 + session.press("ctrl+c"); 113 + await session.waitForAbsent("Agent Teams", 5000); 114 + }, 20000); 115 + 116 + it("detail panel shows selected agent name and status", async () => { 117 + await startApp(); 118 + 119 + const ss = session.screenshot(); 120 + // Detail panel should show Main Agent info (selected by default) 121 + // Find a line in the Detail panel that has "Main Agent" 122 + const detailLine = ss.lines.find(l => l.includes("Main Agent") && l.includes("working")); 123 + expect(detailLine).toBeDefined(); 124 + }, 20000); 125 + 126 + it("activity log timestamps are not all identical", async () => { 127 + await startApp(); 128 + 129 + const ss = session.screenshot(); 130 + // Find lines with timestamps [HH:MM] 131 + const tsLines = ss.lines.filter(l => l.match(/\[\d{2}:\d{2}\]/)); 132 + expect(tsLines.length).toBeGreaterThanOrEqual(2); 133 + 134 + // Extract timestamps 135 + const timestamps = tsLines.map(l => l.match(/\[(\d{2}:\d{2})\]/)![1]); 136 + // Not all the same 137 + const unique = [...new Set(timestamps)]; 138 + expect(unique.length).toBeGreaterThanOrEqual(2); 139 + }, 20000); 140 + 141 + it("both panels fill available height (no empty gap)", async () => { 142 + await startApp(25, 100); 143 + 144 + const ss = session.screenshot(); 145 + // The Agents panel and Detail panel should both have bottom borders 146 + // near the same row (they're in an hstack) 147 + // The Activity panel should have its bottom border near the bottom 148 + const activityBottom = ss.lines.findLastIndex(l => l.includes("Activity") || l.includes("\u2570")); 149 + expect(activityBottom).toBeGreaterThanOrEqual(20); // near row 23 for 25-row terminal 150 + }, 20000); 151 + });
+233
demos/agent-teams/agents.test.ts
··· 1 + // Unit tests for agent teams demo 2 + import { describe, it, expect, beforeEach, afterEach } from "vitest"; 3 + import * as fs from "node:fs"; 4 + import * as path from "node:path"; 5 + import * as os from "node:os"; 6 + import { 7 + parseStatusFile, parseAgentDir, reloadAgents, 8 + rootAgent, flatAgents, selectedIndex, selectedAgent, 9 + activityLog, addActivity, elapsedTime, formatElapsed, 10 + moveUp, moveDown, togglePause, paused, 11 + dataDir, stopWatching, 12 + type AgentNode, 13 + } from "./state.ts"; 14 + import { timeline, initDataDir } from "./timeline.ts"; 15 + import { dashboardScreen } from "./screens/dashboard.ts"; 16 + import { themes, type Theme, type ScreenContext } from "../../src/tui/index.ts"; 17 + 18 + let testDir: string; 19 + 20 + function testCtx(rows = 35, cols = 120): ScreenContext { 21 + const theme = themes.coolBlue as Theme; 22 + return { 23 + rows, cols, theme, boxStyle: "rounded", 24 + navigate: () => {}, back: () => {}, 25 + openOverlay: () => {}, closeOverlay: () => {}, 26 + isTextInputActive: () => false, setTextInputActive: () => {}, 27 + }; 28 + } 29 + 30 + function writeStatusFile(relPath: string, name: string, status: string, task: string, progress: number, body: string): void { 31 + const fullPath = path.join(testDir, relPath); 32 + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); 33 + fs.writeFileSync(fullPath, `--- 34 + name: ${name} 35 + status: ${status} 36 + task: ${task} 37 + progress: ${progress.toFixed(2)} 38 + started: 2024-03-20T10:00:00Z 39 + updated: 2024-03-20T10:30:00Z 40 + --- 41 + ${body} 42 + `); 43 + } 44 + 45 + beforeEach(() => { 46 + testDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-test-")); 47 + dataDir.set(testDir + "/"); 48 + activityLog.set([]); 49 + elapsedTime.set(0); 50 + selectedIndex.set(0); 51 + paused.set(false); 52 + }); 53 + 54 + afterEach(() => { 55 + stopWatching(); 56 + fs.rmSync(testDir, { recursive: true, force: true }); 57 + }); 58 + 59 + // --- parseStatusFile --- 60 + describe("parseStatusFile", () => { 61 + it("parses a valid status file", () => { 62 + writeStatusFile("main-agent/status.md", "Main Agent", "working", "Building something", 0.5, "Working on it."); 63 + const parsed = parseStatusFile(path.join(testDir, "main-agent/status.md")); 64 + expect(parsed.name).toBe("Main Agent"); 65 + expect(parsed.status).toBe("working"); 66 + expect(parsed.task).toBe("Building something"); 67 + expect(parsed.progress).toBeCloseTo(0.5); 68 + expect(parsed.body).toBe("Working on it."); 69 + }); 70 + 71 + it("returns defaults for non-existent file", () => { 72 + const parsed = parseStatusFile(path.join(testDir, "nope.md")); 73 + expect(parsed.name).toBeUndefined(); 74 + }); 75 + }); 76 + 77 + // --- parseAgentDir --- 78 + describe("parseAgentDir", () => { 79 + it("parses a single agent", () => { 80 + writeStatusFile("main-agent/status.md", "Main", "working", "Task", 0.3, "Body"); 81 + const agent = parseAgentDir(path.join(testDir, "main-agent"), 0); 82 + expect(agent).not.toBeNull(); 83 + expect(agent!.name).toBe("Main"); 84 + expect(agent!.status).toBe("working"); 85 + expect(agent!.children).toHaveLength(0); 86 + }); 87 + 88 + it("parses nested sub-agents", () => { 89 + writeStatusFile("main-agent/status.md", "Main", "working", "Task", 0.3, "Body"); 90 + writeStatusFile("main-agent/sub-agents/researcher/status.md", "Researcher", "working", "Researching", 0.5, "Scraping"); 91 + writeStatusFile("main-agent/sub-agents/coder/status.md", "Coder", "idle", "Waiting", 0, "Ready"); 92 + 93 + const agent = parseAgentDir(path.join(testDir, "main-agent"), 0); 94 + expect(agent!.children).toHaveLength(2); 95 + const names = agent!.children.map(c => c.name).sort(); 96 + expect(names).toEqual(["Coder", "Researcher"]); 97 + }); 98 + 99 + it("reads plan and output files", () => { 100 + writeStatusFile("main-agent/status.md", "Main", "done", "Done", 1.0, "Finished"); 101 + fs.writeFileSync(path.join(testDir, "main-agent/plan.md"), "# Plan\n\n1. Do stuff\n"); 102 + fs.writeFileSync(path.join(testDir, "main-agent/output.md"), "# Output\n\nResults here.\n"); 103 + 104 + const agent = parseAgentDir(path.join(testDir, "main-agent"), 0); 105 + expect(agent!.plan).toContain("Do stuff"); 106 + expect(agent!.output).toContain("Results here"); 107 + }); 108 + }); 109 + 110 + // --- flatAgents --- 111 + describe("flatAgents", () => { 112 + it("flattens the agent tree", () => { 113 + writeStatusFile("main-agent/status.md", "Main", "working", "Task", 0.3, "Body"); 114 + writeStatusFile("main-agent/sub-agents/researcher/status.md", "Researcher", "working", "R", 0.5, "S"); 115 + writeStatusFile("main-agent/sub-agents/coder/status.md", "Coder", "idle", "W", 0, "R"); 116 + reloadAgents(); 117 + 118 + const list = flatAgents.get(); 119 + expect(list).toHaveLength(3); 120 + expect(list[0].name).toBe("Main"); 121 + }); 122 + }); 123 + 124 + // --- Timeline events --- 125 + describe("timeline", () => { 126 + it("first event creates main agent status", () => { 127 + initDataDir(); 128 + const agent = rootAgent.peek(); 129 + expect(agent).not.toBeNull(); 130 + expect(agent!.name).toBe("Main Agent"); 131 + expect(agent!.status).toBe("working"); 132 + }); 133 + 134 + it("initial state has all seeded agents", () => { 135 + initDataDir(); 136 + const agent = rootAgent.peek()!; 137 + expect(agent.children.length).toBe(3); 138 + const names = agent.children.map(c => c.name).sort(); 139 + expect(names).toEqual(["Coder", "Designer", "Researcher"]); 140 + }); 141 + 142 + it("researcher completes at event index 3", () => { 143 + initDataDir(); 144 + // Fire events 0-3 (researcher completes at 30s) 145 + for (let i = 0; i <= 3; i++) { 146 + timeline[i].action(); 147 + } 148 + reloadAgents(); 149 + const list = flatAgents.get(); 150 + const researcher = list.find(a => a.name === "Researcher"); 151 + expect(researcher).toBeDefined(); 152 + expect(researcher!.status).toBe("done"); 153 + expect(researcher!.progress).toBeCloseTo(1.0); 154 + }); 155 + }); 156 + 157 + // --- Activity log --- 158 + describe("activityLog", () => { 159 + it("addActivity appends entries", () => { 160 + addActivity("Test Agent", "Did something"); 161 + addActivity("Test Agent", "Did another thing"); 162 + expect(activityLog.peek()).toHaveLength(2); 163 + expect(activityLog.peek()[0].agent).toBe("Test Agent"); 164 + expect(activityLog.peek()[0].event).toBe("Did something"); 165 + }); 166 + }); 167 + 168 + // --- Navigation --- 169 + describe("navigation", () => { 170 + beforeEach(() => { 171 + writeStatusFile("main-agent/status.md", "Main", "working", "T", 0.3, "B"); 172 + writeStatusFile("main-agent/sub-agents/researcher/status.md", "R", "working", "T", 0.5, "B"); 173 + writeStatusFile("main-agent/sub-agents/coder/status.md", "C", "idle", "T", 0, "B"); 174 + reloadAgents(); 175 + selectedIndex.set(0); 176 + }); 177 + 178 + it("moveDown increments selectedIndex", () => { 179 + moveDown(); 180 + expect(selectedIndex.peek()).toBe(1); 181 + }); 182 + 183 + it("moveUp decrements selectedIndex", () => { 184 + selectedIndex.set(2); 185 + moveUp(); 186 + expect(selectedIndex.peek()).toBe(1); 187 + }); 188 + 189 + it("togglePause toggles paused state", () => { 190 + expect(paused.peek()).toBe(false); 191 + togglePause(); 192 + expect(paused.peek()).toBe(true); 193 + togglePause(); 194 + expect(paused.peek()).toBe(false); 195 + }); 196 + }); 197 + 198 + // --- formatElapsed --- 199 + describe("formatElapsed", () => { 200 + it("formats seconds as m:ss", () => { 201 + expect(formatElapsed(0)).toBe("0:00"); 202 + expect(formatElapsed(65)).toBe("1:05"); 203 + expect(formatElapsed(600)).toBe("10:00"); 204 + }); 205 + }); 206 + 207 + // --- Dashboard rendering --- 208 + describe("dashboard screen", () => { 209 + it("renders without crashing", () => { 210 + initDataDir(); 211 + const ctx = testCtx(); 212 + const buf = dashboardScreen.renderToBuffer(ctx); 213 + expect(buf.rows).toBe(35); 214 + expect(buf.cols).toBe(120); 215 + }); 216 + 217 + it("shows agent name in buffer", () => { 218 + initDataDir(); 219 + const ctx = testCtx(); 220 + const buf = dashboardScreen.renderToBuffer(ctx); 221 + const allText = buf.cells.map(row => row.map(c => c.char).join("")).join("\n"); 222 + expect(allText).toContain("Main Agent"); 223 + }); 224 + 225 + it("shows activity log entries", () => { 226 + initDataDir(); 227 + const ctx = testCtx(); 228 + const buf = dashboardScreen.renderToBuffer(ctx); 229 + const allText = buf.cells.map(row => row.map(c => c.char).join("")).join("\n"); 230 + // Activity panel shows the most recent entries (limited by panel height) 231 + expect(allText).toContain("Started writing tests"); 232 + }); 233 + });
+113
demos/agent-teams/main.ts
··· 1 + // Entry point: agent teams TUI demo 2 + import { 3 + parseKey, CellBuffer, diff, fullRender, effect, 4 + hideCursor, showCursor, reset, recordFrame, toggleFPS, 5 + } from "../../src/tui/index.ts"; 6 + import type { ScreenContext } from "../../src/tui/index.ts"; 7 + import { currentTheme, boxStyle, stopWatching, startWatching } from "./state.ts"; 8 + import { dashboardScreen } from "./screens/dashboard.ts"; 9 + import { initDataDir, startTimeline, stopTimeline } from "./timeline.ts"; 10 + 11 + const enterAltScreen = "\x1b[?1049h"; 12 + const leaveAltScreen = "\x1b[?1049l"; 13 + 14 + let running = true; 15 + let prevBuffer: CellBuffer | null = null; 16 + 17 + function getSize(): [number, number] { 18 + return [process.stdout.rows ?? 35, process.stdout.columns ?? 120]; 19 + } 20 + 21 + function cleanup(): void { 22 + running = false; 23 + stopTimeline(); 24 + stopWatching(); 25 + process.stdout.write(showCursor() + reset() + leaveAltScreen); 26 + if (process.stdin.isTTY) { 27 + process.stdin.setRawMode(false); 28 + } 29 + process.stdin.pause(); 30 + } 31 + 32 + function createContext(rows: number, cols: number): ScreenContext { 33 + return { 34 + rows, 35 + cols, 36 + theme: currentTheme.get(), 37 + boxStyle: boxStyle.get(), 38 + navigate: () => {}, 39 + back: () => {}, 40 + openOverlay: () => {}, 41 + closeOverlay: () => {}, 42 + isTextInputActive: () => false, 43 + setTextInputActive: () => {}, 44 + }; 45 + } 46 + 47 + function renderFrame(): void { 48 + if (!running) return; 49 + recordFrame(); 50 + 51 + const [rows, cols] = getSize(); 52 + const ctx = createContext(rows, cols); 53 + const buf = dashboardScreen.renderToBuffer(ctx); 54 + 55 + let output: string; 56 + if (prevBuffer && prevBuffer.rows === rows && prevBuffer.cols === cols) { 57 + output = diff(prevBuffer, buf); 58 + } else { 59 + output = fullRender(buf); 60 + } 61 + 62 + prevBuffer = buf; 63 + process.stdout.write(hideCursor() + output); 64 + } 65 + 66 + // --- Initialize --- 67 + initDataDir(); 68 + startWatching(); 69 + startTimeline(); 70 + 71 + // --- Terminal setup --- 72 + process.stdout.write(enterAltScreen + hideCursor()); 73 + if (process.stdin.isTTY) { 74 + process.stdin.setRawMode(true); 75 + } 76 + process.stdin.resume(); 77 + 78 + // Reactive render loop 79 + effect(() => { 80 + renderFrame(); 81 + }); 82 + 83 + // Handle stdin 84 + process.stdin.on("data", (data: Buffer | string) => { 85 + const buf = typeof data === "string" ? Buffer.from(data) : data; 86 + const keys = parseKey(buf); 87 + 88 + for (const key of keys) { 89 + if (key.char === "F") { 90 + toggleFPS(); 91 + continue; 92 + } 93 + 94 + const [rows, cols] = getSize(); 95 + const ctx = createContext(rows, cols); 96 + const cont = dashboardScreen.handleKey(key, ctx); 97 + if (!cont) { 98 + cleanup(); 99 + process.exit(0); 100 + } 101 + } 102 + }); 103 + 104 + // Handle resize 105 + process.stdout.on("resize", () => { 106 + prevBuffer = null; 107 + renderFrame(); 108 + }); 109 + 110 + // Handle cleanup 111 + process.on("SIGINT", () => { cleanup(); process.exit(0); }); 112 + process.on("SIGTERM", () => { cleanup(); process.exit(0); }); 113 + process.on("exit", cleanup);
+158
demos/agent-teams/screens/dashboard.ts
··· 1 + // Dashboard screen: agent tree + detail + activity log 2 + import { 3 + screen, text, row, column, hstack, panel, selectable, scrollable, canvas, 4 + statusBar, footer, spinner, dot, progressBar, separator, 5 + type KeyEvent, type ScreenContext, type UINode, 6 + } from "../../../src/tui/index.ts"; 7 + import { updateScrollRegion } from "../../../src/tui/index.ts"; 8 + import { 9 + flatAgents, selectedIndex, agentScroll, selectedAgent, agentCount, 10 + activityLog, elapsedTime, paused, formatElapsed, 11 + moveUp, moveDown, togglePause, cycleTheme, 12 + type AgentNode, 13 + } from "../state.ts"; 14 + 15 + function statusColor(status: AgentNode["status"]): "ok" | "accent" | "error" | "warn" | "muted" { 16 + switch (status) { 17 + case "working": return "accent"; 18 + case "done": return "ok"; 19 + case "blocked": return "error"; 20 + case "error": return "warn"; 21 + default: return "muted"; 22 + } 23 + } 24 + 25 + function statusIcon(status: AgentNode["status"]): UINode { 26 + switch (status) { 27 + case "working": return spinner("accent"); 28 + case "done": return dot(true, "ok"); 29 + case "blocked": return dot(true, "error"); 30 + case "error": return dot(true, "warn"); 31 + default: return dot(false, "muted"); 32 + } 33 + } 34 + 35 + function renderAgentRow(agent: AgentNode, _index: number, selected: boolean): UINode[] { 36 + const prefix = " ".repeat(agent.depth); 37 + return [ 38 + text(prefix, "primary"), 39 + statusIcon(agent.status), 40 + text(" " + agent.name, selected ? "primary" : "secondary", { bold: selected, truncate: true }), 41 + text(" " + agent.status, statusColor(agent.status)), 42 + ]; 43 + } 44 + 45 + function renderDetail(agent: AgentNode | null, maxHeight: number): UINode[] { 46 + if (!agent) return [text("(no agent selected)", "muted")]; 47 + 48 + const nodes: UINode[] = [ 49 + row(text(agent.name, "accent", { bold: true }), text(" "), dot(agent.status === "working", statusColor(agent.status)), text(" " + agent.status, statusColor(agent.status))), 50 + ]; 51 + 52 + if (agent.task) { 53 + nodes.push(text("Task: " + agent.task, "primary", { truncate: true })); 54 + } 55 + 56 + if (agent.progress > 0) { 57 + nodes.push(row(text("Progress: ", "muted"), progressBar(agent.progress, { color: statusColor(agent.status) }))); 58 + } 59 + 60 + if (agent.started) { 61 + nodes.push(text("Started: " + agent.started, "muted", { truncate: true })); 62 + } 63 + 64 + if (agent.body) { 65 + nodes.push(separator()); 66 + const bodyLines = agent.body.split("\n").slice(0, Math.max(1, maxHeight - 8)); 67 + for (const line of bodyLines) { 68 + nodes.push(text(line, "secondary", { truncate: true })); 69 + } 70 + } 71 + 72 + if (agent.plan) { 73 + nodes.push(separator()); 74 + nodes.push(text("Plan:", "accent", { bold: true })); 75 + const planLines = agent.plan.split("\n").slice(0, 5); 76 + for (const line of planLines) { 77 + nodes.push(text(line, "secondary", { truncate: true })); 78 + } 79 + } 80 + 81 + if (agent.output) { 82 + nodes.push(separator()); 83 + nodes.push(text("Output:", "accent", { bold: true })); 84 + const outputLines = agent.output.split("\n").slice(0, 5); 85 + for (const line of outputLines) { 86 + nodes.push(text(line, "secondary", { truncate: true })); 87 + } 88 + } 89 + 90 + return nodes; 91 + } 92 + 93 + function renderActivity(maxLines: number): UINode[] { 94 + const log = activityLog.get(); 95 + const visible = log.slice(-maxLines); 96 + if (visible.length === 0) return [text("(no activity yet)", "muted")]; 97 + return visible.map(entry => { 98 + const isMessage = entry.agent.includes("\u2192"); 99 + const color = isMessage ? "info" : "secondary"; 100 + return text(`[${entry.timestamp}] ${entry.agent}: ${entry.event}`, color, { truncate: true }); 101 + }); 102 + } 103 + 104 + export const dashboardScreen = screen({ 105 + id: "dashboard", 106 + 107 + render(ctx: ScreenContext): UINode[] { 108 + const agents = flatAgents.get(); 109 + const sel = selectedIndex.get(); 110 + const agent = selectedAgent.get(); 111 + const elapsed = elapsedTime.get(); 112 + const isPaused = paused.get(); 113 + const count = agentCount.get(); 114 + 115 + const viewport = Math.max(1, ctx.rows - 12); // leave room for activity + chrome 116 + const region = updateScrollRegion( 117 + { ...agentScroll.get(), selectedIndex: sel, totalItems: agents.length }, 118 + agents.length, 119 + viewport, 120 + ); 121 + 122 + const treeWidth = Math.max(30, Math.floor(ctx.cols * 0.35)); 123 + const activityHeight = Math.min(8, Math.max(3, Math.floor(ctx.rows * 0.2))); 124 + 125 + const pauseLabel = isPaused ? " PAUSED" : ""; 126 + 127 + return [ 128 + statusBar("Agent Teams", `Elapsed: ${formatElapsed(elapsed)} \u2502 ${count} agents${pauseLabel}`), 129 + hstack({ gap: 0 }, [ 130 + column({ width: treeWidth }, [ 131 + panel("Agents", [ 132 + selectable(region, agents, renderAgentRow), 133 + ]), 134 + ]), 135 + column({ flex: true }, [ 136 + panel("Detail", [ 137 + ...renderDetail(agent, ctx.rows - activityHeight - 4), 138 + canvas(() => {}, {}), // flex spacer to fill remaining height 139 + ]), 140 + ]), 141 + ]), 142 + panel("Activity", [ 143 + ...renderActivity(activityHeight - 2), 144 + canvas(() => {}, {}), // flex spacer 145 + ]), 146 + footer("\u2191\u2193 select p pause T theme q quit"), 147 + ]; 148 + }, 149 + 150 + handleKey(key: KeyEvent, ctx: ScreenContext): boolean { 151 + if (key.char === "q" || (key.name === "c" && key.ctrl)) return false; 152 + if (key.char === "T") { cycleTheme(); return true; } 153 + if (key.char === "p") { togglePause(); return true; } 154 + if (key.name === "up") { moveUp(); return true; } 155 + if (key.name === "down") { moveDown(); return true; } 156 + return true; 157 + }, 158 + });
+238
demos/agent-teams/state.ts
··· 1 + // Agent teams state: signals, file watcher, agent tree parsing 2 + import * as fs from "node:fs"; 3 + import * as path from "node:path"; 4 + import { 5 + signal, computed, batch, 6 + createScrollRegion, updateScrollRegion, scrollUp, scrollDown, 7 + themes, 8 + type ScrollRegion, type Theme, type BoxStyle, 9 + } from "../../src/tui/index.ts"; 10 + 11 + // --- Types --- 12 + export interface AgentNode { 13 + name: string; 14 + path: string; 15 + status: "idle" | "working" | "blocked" | "done" | "error"; 16 + task: string; 17 + progress: number; 18 + started: string; 19 + updated: string; 20 + body: string; 21 + plan: string; 22 + output: string; 23 + children: AgentNode[]; 24 + depth: number; 25 + } 26 + 27 + export interface ActivityEntry { 28 + timestamp: string; 29 + agent: string; 30 + event: string; 31 + } 32 + 33 + // --- Theme --- 34 + const themeNames = Object.keys(themes); 35 + export const themeIndex = signal(0); 36 + export const currentTheme = computed<Theme>(() => themes[themeNames[themeIndex.get()]] as Theme); 37 + export const boxStyle = signal<BoxStyle>("rounded"); 38 + 39 + export function cycleTheme(): void { 40 + themeIndex.set((themeIndex.peek() + 1) % themeNames.length); 41 + } 42 + 43 + // --- Data directory --- 44 + export const dataDir = signal(`/tmp/agent-teams-demo-${process.pid}/`); 45 + 46 + // --- Signals --- 47 + export const rootAgent = signal<AgentNode | null>(null); 48 + export const selectedIndex = signal(0); 49 + export const agentScroll = signal<ScrollRegion>(createScrollRegion(0, 20)); 50 + export const activityLog = signal<ActivityEntry[]>([]); 51 + export const elapsedTime = signal(0); 52 + export const paused = signal(false); 53 + 54 + // --- Flatten agents --- 55 + export const flatAgents = computed<AgentNode[]>(() => { 56 + const root = rootAgent.get(); 57 + if (!root) return []; 58 + const result: AgentNode[] = []; 59 + function walk(node: AgentNode): void { 60 + result.push(node); 61 + for (const child of node.children) { 62 + walk(child); 63 + } 64 + } 65 + walk(root); 66 + return result; 67 + }); 68 + 69 + export const selectedAgent = computed<AgentNode | null>(() => { 70 + const list = flatAgents.get(); 71 + const idx = selectedIndex.get(); 72 + return list[idx] ?? null; 73 + }); 74 + 75 + export const agentCount = computed<number>(() => flatAgents.get().length); 76 + 77 + // --- Parse status file --- 78 + export function parseStatusFile(filePath: string): Partial<AgentNode> { 79 + try { 80 + const content = fs.readFileSync(filePath, "utf-8"); 81 + const lines = content.split("\n"); 82 + const meta: Record<string, string> = {}; 83 + let bodyLines: string[] = []; 84 + 85 + if (lines[0] === "---") { 86 + let endIdx = -1; 87 + for (let i = 1; i < lines.length; i++) { 88 + if (lines[i] === "---") { endIdx = i; break; } 89 + } 90 + if (endIdx > 0) { 91 + for (let i = 1; i < endIdx; i++) { 92 + const colonIdx = lines[i].indexOf(":"); 93 + if (colonIdx === -1) continue; 94 + meta[lines[i].slice(0, colonIdx).trim()] = lines[i].slice(colonIdx + 1).trim(); 95 + } 96 + bodyLines = lines.slice(endIdx + 1); 97 + } 98 + } 99 + 100 + return { 101 + name: meta.name ?? "", 102 + status: (meta.status as AgentNode["status"]) ?? "idle", 103 + task: meta.task ?? "", 104 + progress: parseFloat(meta.progress ?? "0"), 105 + started: meta.started ?? "", 106 + updated: meta.updated ?? "", 107 + body: bodyLines.join("\n").trim(), 108 + }; 109 + } catch { 110 + return {}; 111 + } 112 + } 113 + 114 + function readFileOr(filePath: string): string { 115 + try { 116 + return fs.readFileSync(filePath, "utf-8").trim(); 117 + } catch { 118 + return ""; 119 + } 120 + } 121 + 122 + // --- Parse agent directory recursively --- 123 + export function parseAgentDir(dirPath: string, depth: number): AgentNode | null { 124 + const statusPath = path.join(dirPath, "status.md"); 125 + if (!fs.existsSync(statusPath)) return null; 126 + 127 + const parsed = parseStatusFile(statusPath); 128 + const plan = readFileOr(path.join(dirPath, "plan.md")); 129 + const output = readFileOr(path.join(dirPath, "output.md")); 130 + 131 + const children: AgentNode[] = []; 132 + const subAgentsDir = path.join(dirPath, "sub-agents"); 133 + if (fs.existsSync(subAgentsDir)) { 134 + const entries = fs.readdirSync(subAgentsDir, { withFileTypes: true }); 135 + for (const entry of entries) { 136 + if (entry.isDirectory()) { 137 + const child = parseAgentDir(path.join(subAgentsDir, entry.name), depth + 1); 138 + if (child) children.push(child); 139 + } 140 + } 141 + } 142 + 143 + return { 144 + name: parsed.name || path.basename(dirPath), 145 + path: dirPath, 146 + status: parsed.status ?? "idle", 147 + task: parsed.task ?? "", 148 + progress: parsed.progress ?? 0, 149 + started: parsed.started ?? "", 150 + updated: parsed.updated ?? "", 151 + body: parsed.body ?? "", 152 + plan, 153 + output, 154 + children, 155 + depth, 156 + }; 157 + } 158 + 159 + // --- Load from disk --- 160 + export function reloadAgents(): void { 161 + const dir = dataDir.peek(); 162 + const mainDir = path.join(dir, "main-agent"); 163 + if (!fs.existsSync(mainDir)) return; 164 + const root = parseAgentDir(mainDir, 0); 165 + if (root) { 166 + rootAgent.set(root); 167 + const list = flatAgents.get(); 168 + agentScroll.set(updateScrollRegion(agentScroll.peek(), list.length)); 169 + } 170 + } 171 + 172 + // --- Activity log --- 173 + export function addActivity(agent: string, event: string, timestamp?: string): void { 174 + const ts = timestamp ?? (() => { 175 + const now = new Date(); 176 + return `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; 177 + })(); 178 + const log = activityLog.peek(); 179 + activityLog.set([...log, { timestamp: ts, agent, event }]); 180 + } 181 + 182 + // --- File watcher --- 183 + let watchDebounce: ReturnType<typeof setTimeout> | null = null; 184 + let watcher: fs.FSWatcher | null = null; 185 + 186 + export function startWatching(): void { 187 + const dir = dataDir.peek(); 188 + try { 189 + watcher = fs.watch(dir, { recursive: true }, () => { 190 + if (watchDebounce) clearTimeout(watchDebounce); 191 + watchDebounce = setTimeout(() => reloadAgents(), 100); 192 + }); 193 + } catch { 194 + // Directory may not exist yet 195 + } 196 + } 197 + 198 + export function stopWatching(): void { 199 + if (watcher) { 200 + watcher.close(); 201 + watcher = null; 202 + } 203 + if (watchDebounce) { 204 + clearTimeout(watchDebounce); 205 + watchDebounce = null; 206 + } 207 + } 208 + 209 + // --- Actions --- 210 + export function moveUp(): void { 211 + const idx = selectedIndex.peek(); 212 + if (idx <= 0) return; 213 + batch(() => { 214 + selectedIndex.set(idx - 1); 215 + agentScroll.set(scrollUp(agentScroll.peek())); 216 + }); 217 + } 218 + 219 + export function moveDown(): void { 220 + const idx = selectedIndex.peek(); 221 + const list = flatAgents.get(); 222 + if (idx >= list.length - 1) return; 223 + batch(() => { 224 + selectedIndex.set(idx + 1); 225 + agentScroll.set(scrollDown(agentScroll.peek())); 226 + }); 227 + } 228 + 229 + export function togglePause(): void { 230 + paused.set(!paused.peek()); 231 + } 232 + 233 + // --- Format elapsed --- 234 + export function formatElapsed(seconds: number): string { 235 + const m = Math.floor(seconds / 60); 236 + const s = seconds % 60; 237 + return `${m}:${String(s).padStart(2, "0")}`; 238 + }
+375
demos/agent-teams/timeline.ts
··· 1 + // Scripted 10-minute scenario for the agent teams demo 2 + import * as fs from "node:fs"; 3 + import * as path from "node:path"; 4 + import { dataDir, addActivity, reloadAgents, elapsedTime, paused } from "./state.ts"; 5 + 6 + interface TimelineEvent { 7 + atSeconds: number; 8 + action: () => void; 9 + } 10 + 11 + function writeStatus(agentPath: string, name: string, status: string, task: string, progress: number, body: string): void { 12 + const dir = dataDir.peek(); 13 + const fullPath = path.join(dir, agentPath); 14 + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); 15 + const content = `--- 16 + name: ${name} 17 + status: ${status} 18 + task: ${task} 19 + progress: ${progress.toFixed(2)} 20 + started: 2024-03-20T10:00:00Z 21 + updated: ${new Date().toISOString()} 22 + --- 23 + ${body} 24 + `; 25 + fs.writeFileSync(fullPath, content); 26 + } 27 + 28 + function writePlan(agentPath: string, plan: string): void { 29 + const dir = dataDir.peek(); 30 + const fullPath = path.join(dir, agentPath); 31 + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); 32 + fs.writeFileSync(fullPath, plan); 33 + } 34 + 35 + function writeOutput(agentPath: string, output: string): void { 36 + const dir = dataDir.peek(); 37 + const fullPath = path.join(dir, agentPath); 38 + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); 39 + fs.writeFileSync(fullPath, output); 40 + } 41 + 42 + export const timeline: TimelineEvent[] = [ 43 + // --- Phase 1: Researcher wraps up (5s-30s at default speed) --- 44 + 45 + // 0:05 — Researcher progress update 46 + { 47 + atSeconds: 5, 48 + action() { 49 + writeStatus("main-agent/sub-agents/researcher/status.md", "Researcher", "working", "Analyzing competitor pricing data", 0.75, "Scraping 8/10 competitor websites.\nFound pricing data for 9 products so far."); 50 + addActivity("Researcher", "Scraped 8/10 competitor sites"); 51 + }, 52 + }, 53 + // 0:15 — Researcher progress 54 + { 55 + atSeconds: 15, 56 + action() { 57 + writeStatus("main-agent/sub-agents/researcher/status.md", "Researcher", "working", "Analyzing competitor pricing data", 0.9, "Scraping 9/10 competitor websites.\nCompiling final dataset."); 58 + }, 59 + }, 60 + // 0:25 — Researcher sends data to Coder 61 + { 62 + atSeconds: 25, 63 + action() { 64 + addActivity("Researcher \u2192 Coder", "Here's the pricing data you requested \u2014 9 competitors analyzed so far"); 65 + }, 66 + }, 67 + // 0:30 — Researcher completes 68 + { 69 + atSeconds: 30, 70 + action() { 71 + writeStatus("main-agent/sub-agents/researcher/status.md", "Researcher", "done", "Research complete", 1.0, "Completed scraping all 10 competitor websites.\nFull pricing data collected for 12 products."); 72 + writeOutput("main-agent/sub-agents/researcher/output.md", "# Research Output\n\nAnalyzed 10 competitors, 12 products.\nPricing range: $29-$199/mo.\nKey findings:\n- 70% offer free tier\n- Average enterprise price: $149/mo\n- Most charge per-seat\n"); 73 + addActivity("Researcher", "Completed research \u2014 10 competitors analyzed"); 74 + addActivity("Researcher \u2192 Main Agent", "Research complete. Full dataset ready for integration."); 75 + }, 76 + }, 77 + 78 + // --- Phase 2: Test Writer finds bug, Coder blocked (45s-90s) --- 79 + 80 + // 0:45 — Coder progress 81 + { 82 + atSeconds: 45, 83 + action() { 84 + writeStatus("main-agent/sub-agents/coder/status.md", "Coder", "working", "Implementing pricing analysis tool", 0.5, "Core data models done. Building API layer.\nIntegrating research data."); 85 + writeStatus("main-agent/sub-agents/coder/sub-agents/test-writer/status.md", "Test Writer", "working", "Writing unit tests for data models", 0.5, "Writing unit tests for core models.\n22 tests written so far."); 86 + addActivity("Coder", "Integrating research data into models"); 87 + addActivity("Test Writer \u2192 Coder", "Tests for data models look good so far, continuing with API tests"); 88 + }, 89 + }, 90 + // 1:00 — Test Writer finds a bug 91 + { 92 + atSeconds: 60, 93 + action() { 94 + writeStatus("main-agent/sub-agents/coder/sub-agents/test-writer/status.md", "Test Writer", "error", "Found failing test", 0.6, "FAIL: test_price_comparison \u2014 null pointer on missing competitor data.\nThe pricing model doesn't handle missing entries."); 95 + addActivity("Test Writer", "Found bug: null pointer in price comparison"); 96 + addActivity("Test Writer \u2192 Coder", "BLOCKER: test_price_comparison fails with null pointer when competitor data is missing"); 97 + }, 98 + }, 99 + // 1:10 — Coder blocked 100 + { 101 + atSeconds: 70, 102 + action() { 103 + writeStatus("main-agent/sub-agents/coder/status.md", "Coder", "blocked", "Bug found by Test Writer", 0.5, "Blocked: null pointer in price comparison.\nNeed to add null checks for missing competitor data."); 104 + addActivity("Coder", "Blocked on null pointer bug"); 105 + addActivity("Coder \u2192 Test Writer", "Acknowledged. Working on fix for missing data handling."); 106 + }, 107 + }, 108 + // 1:30 — Coder fixes bug, resumes 109 + { 110 + atSeconds: 90, 111 + action() { 112 + writeStatus("main-agent/sub-agents/coder/status.md", "Coder", "working", "Implementing pricing analysis tool", 0.6, "Fixed null pointer bug. Added optional chaining for missing competitor data.\nResuming API implementation."); 113 + writeStatus("main-agent/sub-agents/coder/sub-agents/test-writer/status.md", "Test Writer", "working", "Running test suite", 0.7, "All tests passing now. Writing integration tests.\n30 tests, all green."); 114 + addActivity("Coder", "Fixed null pointer bug, resumed implementation"); 115 + addActivity("Coder \u2192 Test Writer", "Fix pushed. Please re-run the full suite."); 116 + addActivity("Test Writer", "All tests passing after fix"); 117 + }, 118 + }, 119 + 120 + // --- Phase 3: Designer completes, Reviewer starts (120s-180s) --- 121 + 122 + // 2:00 — Designer progress 123 + { 124 + atSeconds: 120, 125 + action() { 126 + writeStatus("main-agent/sub-agents/designer/status.md", "Designer", "working", "Creating dashboard mockups", 0.7, "Dashboard layout finalized.\nWorking on chart components for pricing comparison."); 127 + addActivity("Designer", "Dashboard layout finalized, building charts"); 128 + }, 129 + }, 130 + // 2:15 — Designer sends update to Main Agent 131 + { 132 + atSeconds: 135, 133 + action() { 134 + addActivity("Designer \u2192 Main Agent", "Dashboard mockups 70% done \u2014 pricing charts looking great"); 135 + }, 136 + }, 137 + // 2:30 — Reviewer starts reviewing 138 + { 139 + atSeconds: 150, 140 + action() { 141 + writeStatus("main-agent/sub-agents/coder/sub-agents/reviewer/status.md", "Reviewer", "working", "Reviewing code quality", 0.2, "Reviewing data model layer.\nChecking for SOLID principles compliance."); 142 + addActivity("Reviewer", "Started code review"); 143 + addActivity("Reviewer \u2192 Coder", "Starting review of data model and API layers"); 144 + }, 145 + }, 146 + // 2:45 — Test Writer completes 147 + { 148 + atSeconds: 165, 149 + action() { 150 + writeStatus("main-agent/sub-agents/coder/sub-agents/test-writer/status.md", "Test Writer", "done", "All tests passing", 1.0, "42 unit tests, 8 integration tests. All green."); 151 + writeOutput("main-agent/sub-agents/coder/sub-agents/test-writer/output.md", "# Test Results\n\n42 unit tests, 8 integration tests.\nAll passing. Coverage: 87%.\n"); 152 + addActivity("Test Writer", "Completed \u2014 42 unit tests, 8 integration tests"); 153 + addActivity("Test Writer \u2192 Coder", "Full test suite green. 87% coverage."); 154 + }, 155 + }, 156 + // 3:00 — Designer completes 157 + { 158 + atSeconds: 180, 159 + action() { 160 + writeStatus("main-agent/sub-agents/designer/status.md", "Designer", "done", "Dashboard mockups complete", 1.0, "All dashboard mockups complete.\n3 pages: Overview, Comparison, Detail.\n12 chart components designed."); 161 + writeOutput("main-agent/sub-agents/designer/output.md", "# Design Output\n\n3 dashboard pages designed:\n- Overview (summary metrics)\n- Comparison (side-by-side pricing)\n- Detail (per-competitor breakdown)\n\n12 chart components, responsive layout.\n"); 162 + addActivity("Designer", "Completed dashboard mockups \u2014 3 pages, 12 components"); 163 + addActivity("Designer \u2192 Main Agent", "All mockups delivered. Ready for implementation."); 164 + }, 165 + }, 166 + 167 + // --- Phase 4: Reviewer finds issues, Coder blocked again (210s-270s) --- 168 + 169 + // 3:30 — Reviewer finds issues 170 + { 171 + atSeconds: 210, 172 + action() { 173 + writeStatus("main-agent/sub-agents/coder/sub-agents/reviewer/status.md", "Reviewer", "working", "Found issues", 0.6, "Issues found:\n1. Magic numbers in pricing calculation\n2. Missing error handling in API calls\n3. No retry logic for failed scrapes"); 174 + addActivity("Reviewer", "Found 3 issues in code review"); 175 + addActivity("Reviewer \u2192 Coder", "3 issues found: magic numbers, missing error handling, no retry logic. See review comments."); 176 + }, 177 + }, 178 + // 3:40 — Coder blocked on review 179 + { 180 + atSeconds: 220, 181 + action() { 182 + writeStatus("main-agent/sub-agents/coder/status.md", "Coder", "blocked", "Reviewer found issues", 0.75, "Addressing code review feedback:\n- Extract constants\n- Add error handling\n- Implement retry logic"); 183 + addActivity("Coder", "Blocked on review feedback"); 184 + addActivity("Coder \u2192 Reviewer", "Acknowledged all 3 issues. Working on fixes now."); 185 + }, 186 + }, 187 + // 4:00 — Coder progress on fixing issues 188 + { 189 + atSeconds: 240, 190 + action() { 191 + writeStatus("main-agent/sub-agents/coder/status.md", "Coder", "working", "Fixing review issues", 0.8, "Fixed magic numbers and error handling.\nImplementing retry logic with exponential backoff."); 192 + addActivity("Coder", "Fixed 2/3 review issues"); 193 + }, 194 + }, 195 + // 4:30 — Coder fixes all issues, Reviewer approves 196 + { 197 + atSeconds: 270, 198 + action() { 199 + writeStatus("main-agent/sub-agents/coder/status.md", "Coder", "working", "Finalizing implementation", 0.9, "All review feedback addressed. Final cleanup."); 200 + writeStatus("main-agent/sub-agents/coder/sub-agents/reviewer/status.md", "Reviewer", "done", "Review approved", 1.0, "All issues resolved. Code quality acceptable.\nApproved for merge."); 201 + addActivity("Coder", "Fixed all review issues"); 202 + addActivity("Reviewer", "Approved \u2014 all issues resolved"); 203 + addActivity("Reviewer \u2192 Coder", "LGTM! All issues addressed. Approved for merge."); 204 + }, 205 + }, 206 + 207 + // --- Phase 5: Coder completes, Deployer spawns (300s-390s) --- 208 + 209 + // 5:00 — Coder done 210 + { 211 + atSeconds: 300, 212 + action() { 213 + writeStatus("main-agent/sub-agents/coder/status.md", "Coder", "done", "Implementation complete", 1.0, "Pricing analysis tool fully implemented and reviewed.\nAll tests passing, all review issues addressed."); 214 + writeOutput("main-agent/sub-agents/coder/output.md", "# Implementation Output\n\nBuilt pricing analysis tool:\n- Data models for 12 product categories\n- REST API with 6 endpoints\n- Report generator with PDF/CSV export\n- 42 unit tests + 8 integration tests\n- All code reviewed and approved\n"); 215 + addActivity("Coder", "Completed implementation"); 216 + addActivity("Coder \u2192 Main Agent", "Implementation complete. All tests green, review approved."); 217 + }, 218 + }, 219 + // 5:15 — Main Agent spawns Deployer as Coder sub-agent 220 + { 221 + atSeconds: 315, 222 + action() { 223 + writeStatus("main-agent/sub-agents/coder/sub-agents/deployer/status.md", "Deployer", "working", "Preparing deployment pipeline", 0.1, "Setting up CI/CD pipeline.\nConfiguring staging environment."); 224 + writeStatus("main-agent/status.md", "Main Agent", "working", "Coordinating full-stack feature implementation", 0.7, "Research complete. Implementation complete.\nDesign complete. Preparing deployment."); 225 + addActivity("Main Agent", "Spawned Deployer for deployment prep"); 226 + addActivity("Main Agent \u2192 Deployer", "Please set up CI/CD and deploy to staging"); 227 + }, 228 + }, 229 + // 5:45 — Deployer progress 230 + { 231 + atSeconds: 345, 232 + action() { 233 + writeStatus("main-agent/sub-agents/coder/sub-agents/deployer/status.md", "Deployer", "working", "Preparing deployment pipeline", 0.4, "CI/CD pipeline configured.\nRunning deployment checks.\nStaging environment ready."); 234 + addActivity("Deployer", "CI/CD pipeline configured, running checks"); 235 + addActivity("Deployer \u2192 Main Agent", "Staging environment ready. Running deployment checks."); 236 + }, 237 + }, 238 + // 6:00 — Deployer progress 239 + { 240 + atSeconds: 360, 241 + action() { 242 + writeStatus("main-agent/sub-agents/coder/sub-agents/deployer/status.md", "Deployer", "working", "Preparing deployment pipeline", 0.6, "All checks passing.\nDeploying to staging environment.\nRunning smoke tests."); 243 + addActivity("Deployer", "Deploying to staging environment"); 244 + }, 245 + }, 246 + // 6:30 — Deployer completes 247 + { 248 + atSeconds: 390, 249 + action() { 250 + writeStatus("main-agent/sub-agents/coder/sub-agents/deployer/status.md", "Deployer", "done", "Deployment complete", 1.0, "Successfully deployed to staging.\nAll smoke tests passing.\nReady for production deployment."); 251 + writeOutput("main-agent/sub-agents/coder/sub-agents/deployer/output.md", "# Deployment Output\n\nDeployed to staging:\n- CI/CD pipeline: configured\n- Staging URL: https://staging.example.com\n- Smoke tests: 12/12 passing\n- Ready for production\n"); 252 + addActivity("Deployer", "Deployment complete \u2014 staging live, all smoke tests passing"); 253 + addActivity("Deployer \u2192 Main Agent", "Staging deployment successful. Ready for production when you are."); 254 + }, 255 + }, 256 + 257 + // --- Phase 6: Final compilation and completion (450s-600s) --- 258 + 259 + // 7:30 — Main Agent starts compiling 260 + { 261 + atSeconds: 450, 262 + action() { 263 + writeStatus("main-agent/status.md", "Main Agent", "working", "Compiling final results", 0.85, "Integrating research findings with implementation.\nMerging design assets.\nGenerating final report."); 264 + addActivity("Main Agent", "Compiling final results"); 265 + addActivity("Main Agent \u2192 Researcher", "Using your research data for the final report"); 266 + addActivity("Main Agent \u2192 Designer", "Integrating your mockups into the deliverables"); 267 + }, 268 + }, 269 + // 8:00 — Main Agent progress 270 + { 271 + atSeconds: 480, 272 + action() { 273 + writeStatus("main-agent/status.md", "Main Agent", "working", "Compiling final results", 0.92, "Final report draft complete.\nReviewing all deliverables."); 274 + addActivity("Main Agent", "Final report draft complete"); 275 + }, 276 + }, 277 + // 9:00 — Main Agent nearing completion 278 + { 279 + atSeconds: 540, 280 + action() { 281 + writeStatus("main-agent/status.md", "Main Agent", "working", "Final review", 0.97, "All deliverables verified.\nRunning final quality checks."); 282 + addActivity("Main Agent", "Running final quality checks"); 283 + }, 284 + }, 285 + // 10:00 — All done 286 + { 287 + atSeconds: 600, 288 + action() { 289 + writeStatus("main-agent/status.md", "Main Agent", "done", "Project complete", 1.0, "All tasks completed successfully.\nResearch: 10 competitors analyzed\nTool: fully implemented and tested\nDashboard: designed and ready\nDeployment: staging live\nReport: generated and ready for review"); 290 + writeOutput("main-agent/output.md", "# Final Report\n\nCompetitor pricing analysis complete.\n\n## Key Findings\n- 10 competitors analyzed\n- Price range: $29-$199/mo\n- Our recommended pricing: $79/mo (mid-market)\n\n## Deliverables\n1. Research data (12 products, 10 competitors)\n2. Pricing analysis tool (6 API endpoints)\n3. Dashboard mockups (3 pages, 12 components)\n4. Deployment (staging live, smoke tests passing)\n5. Report generator (PDF/CSV)\n\nAll agents completed successfully.\n"); 291 + addActivity("Main Agent", "Project complete!"); 292 + }, 293 + }, 294 + ]; 295 + 296 + // --- Timeline runner --- 297 + let tickTimer: ReturnType<typeof setInterval> | null = null; 298 + let nextEventIdx = 0; 299 + 300 + export function startTimeline(): void { 301 + const speed = parseInt(process.env.SPEED ?? "1", 10) || 1; 302 + const tickMs = Math.max(50, Math.floor(1000 / speed)); 303 + 304 + nextEventIdx = 0; 305 + 306 + tickTimer = setInterval(() => { 307 + if (paused.peek()) return; 308 + 309 + const elapsed = elapsedTime.peek() + 1; 310 + elapsedTime.set(elapsed); 311 + 312 + // Fire events that should have happened by now 313 + while (nextEventIdx < timeline.length && timeline[nextEventIdx].atSeconds <= elapsed) { 314 + timeline[nextEventIdx].action(); 315 + nextEventIdx++; 316 + } 317 + 318 + // Reload agent tree from disk (the actions write files, watcher picks up changes too) 319 + reloadAgents(); 320 + 321 + // Stop when all events are done and 5 seconds have passed since last event 322 + if (nextEventIdx >= timeline.length && elapsed > timeline[timeline.length - 1].atSeconds + 5) { 323 + // Keep running but stop advancing 324 + } 325 + }, tickMs); 326 + } 327 + 328 + export function stopTimeline(): void { 329 + if (tickTimer) { 330 + clearInterval(tickTimer); 331 + tickTimer = null; 332 + } 333 + } 334 + 335 + // --- Initialize data directory with rich initial state --- 336 + export function initDataDir(): void { 337 + const dir = dataDir.peek(); 338 + 339 + // Create all agent directories and status files for initial state 340 + // Main Agent (working) 341 + writeStatus("main-agent/status.md", "Main Agent", "working", "Coordinating full-stack feature implementation", 0.15, "Project in progress. Research, coding, and design underway."); 342 + writePlan("main-agent/plan.md", "# Plan\n\n1. Research competitor pricing\n2. Implement scraping tool\n3. Design dashboard\n4. Analyze data\n5. Deploy to staging\n6. Generate report\n"); 343 + 344 + // Researcher (working, 0.65) 345 + writeStatus("main-agent/sub-agents/researcher/status.md", "Researcher", "working", "Analyzing competitor pricing data", 0.65, "Scraping 7/10 competitor websites."); 346 + 347 + // Coder (working, 0.4) 348 + writeStatus("main-agent/sub-agents/coder/status.md", "Coder", "working", "Implementing pricing analysis tool", 0.4, "Core data models done. Building API layer."); 349 + 350 + // Test Writer (working, 0.3) — sub-agent of Coder 351 + writeStatus("main-agent/sub-agents/coder/sub-agents/test-writer/status.md", "Test Writer", "working", "Writing unit tests for data models", 0.3, "Writing unit tests for core models."); 352 + 353 + // Reviewer (idle) — sub-agent of Coder 354 + writeStatus("main-agent/sub-agents/coder/sub-agents/reviewer/status.md", "Reviewer", "idle", "Waiting for code review assignment", 0, "Initialized. Waiting for code to review."); 355 + 356 + // Designer (working, 0.5) 357 + writeStatus("main-agent/sub-agents/designer/status.md", "Designer", "working", "Creating dashboard mockups", 0.5, "Working on dashboard layout and chart components."); 358 + 359 + // Icon Artist (done, 1.0) — sub-agent of Designer 360 + writeStatus("main-agent/sub-agents/designer/sub-agents/icon-artist/status.md", "Icon Artist", "done", "Completed icon set", 1.0, "Delivered 24 custom icons for the dashboard."); 361 + writeOutput("main-agent/sub-agents/designer/sub-agents/icon-artist/output.md", "# Icon Set\n\n24 custom icons delivered:\n- 8 navigation icons\n- 8 chart icons\n- 8 action icons\n\nAll in SVG format, 24x24 grid.\n"); 362 + 363 + // Seed activity log with staggered timestamps so they look natural 364 + addActivity("Main Agent", "Started project coordination", "10:00"); 365 + addActivity("Researcher", "Began competitor analysis", "10:15"); 366 + addActivity("Coder", "Started implementation", "10:20"); 367 + addActivity("Designer", "Started dashboard mockups", "10:25"); 368 + addActivity("Icon Artist", "Completed icon set \u2014 24 icons delivered", "10:30"); 369 + addActivity("Test Writer", "Started writing tests", "10:32"); 370 + 371 + // Set nextEventIdx to 0 so timeline events start from the beginning 372 + nextEventIdx = 0; 373 + 374 + reloadAgents(); 375 + }
+299
demos/file-browser/browser-integration.test.ts
··· 1 + // Integration tests for file browser demo — runs through real PTY 2 + import { describe, it, expect, afterEach, beforeAll } from "vitest"; 3 + import * as path from "node:path"; 4 + import * as fs from "node:fs"; 5 + import * as os from "node:os"; 6 + import { fileURLToPath } from "node:url"; 7 + import { Session } from "../../src/testing/index.ts"; 8 + 9 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 10 + const mainScript = path.join(__dirname, "main.ts"); 11 + 12 + let testDir: string; 13 + let session: Session; 14 + 15 + beforeAll(() => { 16 + // Create a test directory with known structure 17 + testDir = fs.mkdtempSync(path.join(os.tmpdir(), "fb-integ-")); 18 + fs.mkdirSync(path.join(testDir, "docs")); 19 + fs.writeFileSync(path.join(testDir, "docs", "readme.txt"), "This is the readme.\nLine 2.\nLine 3.\n"); 20 + fs.mkdirSync(path.join(testDir, "src")); 21 + fs.writeFileSync(path.join(testDir, "src", "main.ts"), "console.log('hello');\nconst x = 42;\n"); 22 + fs.writeFileSync(path.join(testDir, "package.json"), '{\n "name": "test"\n}\n'); 23 + fs.writeFileSync(path.join(testDir, "notes.txt"), "Some notes here.\n"); 24 + fs.writeFileSync(path.join(testDir, "readme.md"), "# Project Title\n\nThis is a description.\n\n## Getting Started\n\nRun the app.\n"); 25 + }); 26 + 27 + async function startApp(rows = 30, cols = 100): Promise<Session> { 28 + session = Session.spawn("node", ["--experimental-strip-types", "--no-warnings", mainScript, testDir], { 29 + rows, 30 + cols, 31 + env: { TERM: "xterm-256color" }, 32 + }); 33 + // Wait for the File Browser status bar to appear 34 + await session.waitForText("File Browser", 15000); 35 + return session; 36 + } 37 + 38 + afterEach(async () => { 39 + if (session) { 40 + await session.close(); 41 + } 42 + }); 43 + 44 + describe("File Browser Integration", () => { 45 + it("boots and shows the directory listing", async () => { 46 + await startApp(); 47 + const ss = session.screenshot(); 48 + expect(ss.text).toContain("File Browser"); 49 + expect(ss.text).toContain("Files"); 50 + expect(ss.text).toContain("docs"); 51 + expect(ss.text).toContain("src"); 52 + expect(ss.text).toContain("notes.txt"); 53 + expect(ss.text).toContain("package.json"); 54 + }, 20000); 55 + 56 + it("arrow keys move selection through the tree", async () => { 57 + await startApp(); 58 + 59 + // First item should be "docs" (dirs first) 60 + let ss = session.screenshot(); 61 + expect(ss.text).toContain("docs"); 62 + 63 + // Move down to select next item 64 + session.press("down"); 65 + await new Promise(r => setTimeout(r, 300)); 66 + 67 + ss = session.screenshot(); 68 + // Should still show all items 69 + expect(ss.text).toContain("src"); 70 + }, 20000); 71 + 72 + it("right arrow expands a directory", async () => { 73 + await startApp(); 74 + 75 + // First item should be "docs" 76 + session.press("right"); 77 + await session.waitForText("readme.txt", 5000); 78 + 79 + const ss = session.screenshot(); 80 + expect(ss.text).toContain("readme.txt"); 81 + }, 20000); 82 + 83 + it("left arrow collapses an expanded directory", async () => { 84 + await startApp(); 85 + 86 + // Expand docs 87 + session.press("right"); 88 + await session.waitForText("readme.txt", 5000); 89 + 90 + // Collapse docs 91 + session.press("left"); 92 + await session.waitForAbsent("readme.txt", 5000); 93 + }, 20000); 94 + 95 + it("enter on a text file shows preview content", async () => { 96 + await startApp(); 97 + 98 + // Navigate: docs(0), src(1), notes.txt(2), package.json(3) 99 + // Move to notes.txt 100 + session.press("down"); 101 + session.press("down"); 102 + await new Promise(r => setTimeout(r, 300)); 103 + 104 + session.press("return"); 105 + await session.waitForText("Some notes", 5000); 106 + 107 + const ss = session.screenshot(); 108 + expect(ss.text).toContain("Preview"); 109 + expect(ss.text).toContain("Some notes"); 110 + }, 20000); 111 + 112 + it("tab switches between panes", async () => { 113 + await startApp(); 114 + 115 + // Load a file first 116 + session.press("down"); 117 + session.press("down"); 118 + session.press("return"); 119 + await session.waitForText("Some notes", 5000); 120 + 121 + // Switch to preview pane 122 + session.press("tab"); 123 + await new Promise(r => setTimeout(r, 300)); 124 + 125 + // Now up/down should scroll preview, not tree 126 + // We can't directly verify focus visually in a simple way, 127 + // but the app shouldn't crash 128 + session.press("down"); 129 + await new Promise(r => setTimeout(r, 200)); 130 + 131 + const ss = session.screenshot(); 132 + expect(ss.text).toContain("File Browser"); 133 + }, 20000); 134 + 135 + it("T cycles the theme", async () => { 136 + await startApp(); 137 + const ss1 = session.screenshot(); 138 + 139 + session.type("T"); 140 + await new Promise(r => setTimeout(r, 500)); 141 + 142 + const ss2 = session.screenshot(); 143 + // The screen should still show content but ANSI codes change 144 + expect(ss2.text).toContain("File Browser"); 145 + // The ansi output should differ due to theme change 146 + expect(ss2.ansi).not.toEqual(ss1.ansi); 147 + }, 20000); 148 + 149 + it("ctrl+c quits the app", async () => { 150 + await startApp(); 151 + session.press("ctrl+c"); 152 + // App should exit and leave alt screen — File Browser will disappear 153 + await session.waitForAbsent("File Browser", 5000); 154 + }, 20000); 155 + 156 + it("q quits the app", async () => { 157 + await startApp(); 158 + session.type("q"); 159 + // App should exit and leave alt screen 160 + await session.waitForAbsent("File Browser", 5000); 161 + }, 20000); 162 + 163 + it("markdown file preview shows heading content", async () => { 164 + await startApp(); 165 + 166 + // Navigate to readme.md (dirs first: docs, src, then files alphabetically) 167 + // Files: notes.txt, package.json, readme.md — so readme.md is at index 4 168 + // Let's navigate down to it 169 + session.press("down"); // src 170 + session.press("down"); // notes.txt 171 + session.press("down"); // package.json 172 + session.press("down"); // readme.md 173 + await new Promise(r => setTimeout(r, 300)); 174 + 175 + session.press("return"); 176 + await session.waitForText("Project Title", 5000); 177 + 178 + const ss = session.screenshot(); 179 + expect(ss.text).toContain("Project Title"); 180 + expect(ss.text).toContain("Getting Started"); 181 + }, 20000); 182 + 183 + it("wrapped lines: continuation text starts after the gutter, not at the panel edge", async () => { 184 + // Create a file with a long line that will wrap 185 + const longLine = "word ".repeat(30); // 150 chars 186 + fs.writeFileSync(path.join(testDir, "long.txt"), longLine + "\nshort\n"); 187 + 188 + await startApp(20, 60); 189 + 190 + // Navigate: docs(0), src(1), long.txt(2) 191 + session.press("down"); // src 192 + session.press("down"); // long.txt 193 + await new Promise(r => setTimeout(r, 300)); 194 + 195 + session.press("return"); 196 + await session.waitForText("word", 5000); 197 + 198 + const ss = session.screenshot(); 199 + 200 + // Find the first preview line — it has line number "1 │" followed by content 201 + const gutterLine = ss.lines.find(l => l.includes("1 \u2502 word")); 202 + expect(gutterLine).toBeDefined(); 203 + 204 + // Find where the content text starts (the "w" in "word" after the gutter) 205 + const contentCol = gutterLine!.indexOf("word"); 206 + 207 + // The next screen row should be a continuation line (wrapped content) 208 + const gutterLineIdx = ss.lines.indexOf(gutterLine!); 209 + const nextLine = ss.lines[gutterLineIdx + 1]; 210 + 211 + // Continuation content should start at or near the same column as the first line's content 212 + // (off-by-one is expected: word-wrap breaks before the space, so continuation 213 + // may start with a space before the first word) 214 + const nextWordCol = nextLine.indexOf("word"); 215 + expect(nextWordCol).toBeGreaterThanOrEqual(0); 216 + expect(Math.abs(nextWordCol - contentCol)).toBeLessThanOrEqual(1); 217 + 218 + // The continuation should NOT have a line number 219 + expect(nextLine).not.toMatch(/\d\s*\u2502\s*word/); 220 + }, 20000); 221 + 222 + it("short lines do not wrap: line number and content on the same row", async () => { 223 + await startApp(20, 100); 224 + 225 + // notes.txt has a short line 226 + session.press("down"); // src 227 + session.press("down"); // notes.txt (after long.txt created above) 228 + session.press("down"); 229 + await new Promise(r => setTimeout(r, 300)); 230 + 231 + session.press("return"); 232 + await session.waitForText("Some notes", 5000); 233 + 234 + const ss = session.screenshot(); 235 + 236 + // Line number and content should be on the same row 237 + const notesLine = ss.lines.find(l => l.includes("Some notes here")); 238 + expect(notesLine).toBeDefined(); 239 + // Same line must have gutter separator 240 + expect(notesLine).toMatch(/\d\s*\u2502/); 241 + }, 20000); 242 + 243 + it("preview panel fills the available height, not just content height", async () => { 244 + await startApp(25, 80); 245 + 246 + // Select any file — navigate past dirs to first file, press enter 247 + // Keep pressing down until we're past the dirs, then load preview 248 + for (let i = 0; i < 8; i++) session.press("down"); 249 + await new Promise(r => setTimeout(r, 300)); 250 + session.press("return"); 251 + await new Promise(r => setTimeout(r, 500)); 252 + 253 + const ss = session.screenshot(); 254 + 255 + // The preview panel's bottom border ╰ should be near the bottom of the screen, 256 + // not right after the content. Find the last row with ╰ on the right side. 257 + const previewBottomBorder = ss.lines.findLastIndex(l => { 258 + // The right panel's bottom border contains ╰ after column 30ish 259 + const rightHalf = l.slice(25); 260 + return rightHalf.includes("\u2570"); 261 + }); 262 + 263 + // The Files panel bottom border 264 + const filesBottomBorder = ss.lines.findLastIndex(l => { 265 + const leftHalf = l.slice(0, 30); 266 + return leftHalf.includes("\u2570"); 267 + }); 268 + 269 + // Both panels should end at roughly the same row (near the bottom) 270 + expect(Math.abs(previewBottomBorder - filesBottomBorder)).toBeLessThanOrEqual(1); 271 + // And that row should be close to the bottom (row 23 for 25-row terminal) 272 + expect(previewBottomBorder).toBeGreaterThanOrEqual(20); 273 + }, 20000); 274 + 275 + it("each source line gets its own line number", async () => { 276 + fs.writeFileSync(path.join(testDir, "multi.txt"), "alpha\nbeta\ngamma\n"); 277 + 278 + await startApp(20, 80); 279 + 280 + // Navigate to multi.txt 281 + session.press("down"); // src 282 + session.press("down"); // long.txt 283 + session.press("down"); // multi.txt 284 + await new Promise(r => setTimeout(r, 300)); 285 + 286 + session.press("return"); 287 + await session.waitForText("alpha", 5000); 288 + 289 + const ss = session.screenshot(); 290 + 291 + // Each line should have its own line number with gutter 292 + const alphaLine = ss.lines.find(l => l.match(/1\s*\u2502/) && l.includes("alpha")); 293 + const betaLine = ss.lines.find(l => l.match(/2\s*\u2502/) && l.includes("beta")); 294 + const gammaLine = ss.lines.find(l => l.match(/3\s*\u2502/) && l.includes("gamma")); 295 + expect(alphaLine).toBeDefined(); 296 + expect(betaLine).toBeDefined(); 297 + expect(gammaLine).toBeDefined(); 298 + }, 20000); 299 + });
+306
demos/file-browser/browser.test.ts
··· 1 + // Unit tests for file browser demo 2 + import { describe, it, expect, beforeEach } from "vitest"; 3 + import * as fs from "node:fs"; 4 + import * as path from "node:path"; 5 + import * as os from "node:os"; 6 + import { 7 + loadDirectory, isBinary, loadPreview, 8 + tree, flatList, selectedIndex, selectedNode, 9 + previewContent, previewIsBinary, focusPane, treeScroll, 10 + moveUp, moveDown, expandOrOpen, collapseDir, switchPane, 11 + initTree, rootPath, 12 + type TreeNode, 13 + } from "./state.ts"; 14 + import { browserScreen } from "./screens/browser.ts"; 15 + import { batch, themes, type ScreenContext, type Theme } from "../../src/tui/index.ts"; 16 + 17 + // --- Test fixtures --- 18 + let testDir: string; 19 + 20 + function makeTestDir(): string { 21 + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fb-test-")); 22 + // Create structure: 23 + // testDir/ 24 + // alpha/ 25 + // nested.txt 26 + // beta/ 27 + // hello.txt 28 + // binary.bin 29 + fs.mkdirSync(path.join(dir, "alpha")); 30 + fs.writeFileSync(path.join(dir, "alpha", "nested.txt"), "nested content\nline 2\n"); 31 + fs.mkdirSync(path.join(dir, "beta")); 32 + fs.writeFileSync(path.join(dir, "hello.txt"), "hello world\nline 2\nline 3\n"); 33 + // Binary file with null bytes 34 + const binBuf = Buffer.alloc(64); 35 + binBuf.write("MZ"); 36 + binBuf[10] = 0; 37 + fs.writeFileSync(path.join(dir, "binary.bin"), binBuf); 38 + return dir; 39 + } 40 + 41 + function testCtx(rows = 35, cols = 120): ScreenContext { 42 + const theme = themes.coolBlue as Theme; 43 + return { 44 + rows, cols, 45 + theme, 46 + boxStyle: "rounded", 47 + navigate: () => {}, 48 + back: () => {}, 49 + openOverlay: () => {}, 50 + closeOverlay: () => {}, 51 + isTextInputActive: () => false, 52 + setTextInputActive: () => {}, 53 + }; 54 + } 55 + 56 + beforeEach(() => { 57 + testDir = makeTestDir(); 58 + rootPath.set(testDir); 59 + initTree(); 60 + }); 61 + 62 + // --- loadDirectory --- 63 + describe("loadDirectory", () => { 64 + it("returns directories first, then files, sorted alphabetically", () => { 65 + const entries = loadDirectory(testDir, 0); 66 + const names = entries.map(e => e.name); 67 + expect(names).toEqual(["alpha", "beta", "binary.bin", "hello.txt"]); 68 + }); 69 + 70 + it("marks dirs and files correctly", () => { 71 + const entries = loadDirectory(testDir, 0); 72 + expect(entries[0].type).toBe("dir"); 73 + expect(entries[1].type).toBe("dir"); 74 + expect(entries[2].type).toBe("file"); 75 + expect(entries[3].type).toBe("file"); 76 + }); 77 + 78 + it("sets depth on entries", () => { 79 + const entries = loadDirectory(testDir, 2); 80 + expect(entries[0].depth).toBe(2); 81 + }); 82 + 83 + it("skips .git and node_modules", () => { 84 + fs.mkdirSync(path.join(testDir, ".git")); 85 + fs.mkdirSync(path.join(testDir, "node_modules")); 86 + const entries = loadDirectory(testDir, 0); 87 + const names = entries.map(e => e.name); 88 + expect(names).not.toContain(".git"); 89 + expect(names).not.toContain("node_modules"); 90 + }); 91 + 92 + it("returns empty array for non-existent directory", () => { 93 + const entries = loadDirectory(path.join(testDir, "nonexistent"), 0); 94 + expect(entries).toEqual([]); 95 + }); 96 + }); 97 + 98 + // --- Binary detection --- 99 + describe("isBinary", () => { 100 + it("detects binary files via null bytes", () => { 101 + expect(isBinary(path.join(testDir, "binary.bin"))).toBe(true); 102 + }); 103 + 104 + it("detects text files as non-binary", () => { 105 + expect(isBinary(path.join(testDir, "hello.txt"))).toBe(false); 106 + }); 107 + 108 + it("returns true for non-existent files", () => { 109 + expect(isBinary(path.join(testDir, "nope.xyz"))).toBe(true); 110 + }); 111 + }); 112 + 113 + // --- loadPreview --- 114 + describe("loadPreview", () => { 115 + it("loads text file lines", () => { 116 + loadPreview(path.join(testDir, "hello.txt")); 117 + const lines = previewContent.peek(); 118 + expect(lines[0]).toBe("hello world"); 119 + expect(lines[1]).toBe("line 2"); 120 + expect(previewIsBinary.peek()).toBe(false); 121 + }); 122 + 123 + it("marks binary files", () => { 124 + loadPreview(path.join(testDir, "binary.bin")); 125 + expect(previewIsBinary.peek()).toBe(true); 126 + expect(previewContent.peek()).toEqual(["(binary file)"]); 127 + }); 128 + }); 129 + 130 + // --- Flat list --- 131 + describe("flatList", () => { 132 + it("flattens only expanded dirs", () => { 133 + const list = flatList.get(); 134 + // Initially nothing expanded: just top-level entries 135 + expect(list.length).toBe(4); 136 + }); 137 + 138 + it("includes children when dir is expanded", () => { 139 + // Expand alpha 140 + selectedIndex.set(0); // alpha 141 + expandOrOpen(); 142 + const list = flatList.get(); 143 + // alpha + alpha/nested.txt + beta + binary.bin + hello.txt 144 + expect(list.length).toBe(5); 145 + expect(list[1].name).toBe("nested.txt"); 146 + }); 147 + }); 148 + 149 + // --- Navigation --- 150 + describe("navigation", () => { 151 + it("moveDown increments selectedIndex", () => { 152 + selectedIndex.set(0); 153 + moveDown(); 154 + expect(selectedIndex.peek()).toBe(1); 155 + }); 156 + 157 + it("moveUp decrements selectedIndex", () => { 158 + selectedIndex.set(2); 159 + moveUp(); 160 + expect(selectedIndex.peek()).toBe(1); 161 + }); 162 + 163 + it("moveUp at 0 stays at 0", () => { 164 + selectedIndex.set(0); 165 + moveUp(); 166 + expect(selectedIndex.peek()).toBe(0); 167 + }); 168 + 169 + it("moveDown at end stays at end", () => { 170 + const list = flatList.get(); 171 + selectedIndex.set(list.length - 1); 172 + moveDown(); 173 + expect(selectedIndex.peek()).toBe(list.length - 1); 174 + }); 175 + }); 176 + 177 + // --- Expand/Collapse --- 178 + describe("expand/collapse", () => { 179 + it("expandOrOpen expands a directory", () => { 180 + selectedIndex.set(0); // alpha 181 + expandOrOpen(); 182 + const node = selectedNode.get(); 183 + expect(node?.expanded).toBe(true); 184 + }); 185 + 186 + it("collapseDir collapses an expanded directory", () => { 187 + selectedIndex.set(0); 188 + expandOrOpen(); 189 + expect(selectedNode.get()?.expanded).toBe(true); 190 + collapseDir(); 191 + expect(flatList.get()[0].expanded).toBe(false); 192 + }); 193 + 194 + it("expandOrOpen on file loads preview", () => { 195 + // Select hello.txt (index 3) 196 + selectedIndex.set(3); 197 + expandOrOpen(); 198 + const lines = previewContent.peek(); 199 + expect(lines[0]).toBe("hello world"); 200 + }); 201 + }); 202 + 203 + // --- Pane switching --- 204 + describe("pane switching", () => { 205 + it("switchPane toggles focus", () => { 206 + expect(focusPane.peek()).toBe("tree"); 207 + switchPane(); 208 + expect(focusPane.peek()).toBe("preview"); 209 + switchPane(); 210 + expect(focusPane.peek()).toBe("tree"); 211 + }); 212 + }); 213 + 214 + // --- Screen rendering --- 215 + describe("browser screen", () => { 216 + it("renders without crashing", () => { 217 + const ctx = testCtx(); 218 + const buf = browserScreen.renderToBuffer(ctx); 219 + expect(buf.rows).toBe(35); 220 + expect(buf.cols).toBe(120); 221 + }); 222 + 223 + it("renders the status bar and footer", () => { 224 + const ctx = testCtx(); 225 + const rendered = browserScreen.render(ctx); 226 + expect(rendered).toContain("File Browser"); 227 + }); 228 + 229 + it("shows tree entries in the buffer output", () => { 230 + const ctx = testCtx(); 231 + const buf = browserScreen.renderToBuffer(ctx); 232 + // Extract all text from the buffer 233 + const allText = buf.cells.map(row => row.map(c => c.char).join("")).join("\n"); 234 + expect(allText).toContain("alpha"); 235 + expect(allText).toContain("hello.txt"); 236 + }); 237 + }); 238 + 239 + // --- Wrap & Highlight --- 240 + describe("soft wrap and markdown highlighting", () => { 241 + it("preview lines use wrap instead of truncate", () => { 242 + // Select hello.txt and load preview 243 + selectedIndex.set(3); // hello.txt 244 + expandOrOpen(); 245 + const ctx = testCtx(35, 120); 246 + const buf = browserScreen.renderToBuffer(ctx); 247 + const allText = buf.cells.map(r => r.map(c => c.char).join("")).join("\n"); 248 + // hello.txt content should appear in the preview 249 + expect(allText).toContain("hello world"); 250 + }); 251 + 252 + it("markdown headings are bold in preview", () => { 253 + // Create a markdown file in the test dir 254 + const mdPath = path.join(testDir, "readme.md"); 255 + fs.writeFileSync(mdPath, "# Main Title\n\nSome text here.\n\n## Section\n\nMore text.\n"); 256 + // Reinitialize to pick up the new file 257 + rootPath.set(testDir); 258 + initTree(); 259 + 260 + // Find readme.md in the flat list 261 + const list = flatList.get(); 262 + const mdIdx = list.findIndex(n => n.name === "readme.md"); 263 + expect(mdIdx).toBeGreaterThanOrEqual(0); 264 + selectedIndex.set(mdIdx); 265 + expandOrOpen(); // load preview 266 + 267 + const ctx = testCtx(35, 120); 268 + const buf = browserScreen.renderToBuffer(ctx); 269 + const allText = buf.cells.map(r => r.map(c => c.char).join("")).join("\n"); 270 + 271 + // The heading text should appear 272 + expect(allText).toContain("Main Title"); 273 + 274 + // Find the row containing "# Main Title" and check that chars are bold 275 + for (let r = 0; r < buf.rows; r++) { 276 + const rowText = buf.cells[r].map(c => c.char).join(""); 277 + if (rowText.includes("# Main Title")) { 278 + // Find the # character in this row 279 + const hashCol = rowText.indexOf("#"); 280 + expect(buf.cells[r][hashCol].bold).toBe(true); 281 + // The "M" in "Main" should also be bold 282 + const mCol = rowText.indexOf("Main"); 283 + expect(buf.cells[r][mCol].bold).toBe(true); 284 + break; 285 + } 286 + } 287 + }); 288 + 289 + it("non-markdown files have no highlight bolding", () => { 290 + // hello.txt is not markdown — no bolding 291 + selectedIndex.set(3); // hello.txt 292 + expandOrOpen(); 293 + const ctx = testCtx(35, 120); 294 + const buf = browserScreen.renderToBuffer(ctx); 295 + 296 + // Find the row with "hello world" 297 + for (let r = 0; r < buf.rows; r++) { 298 + const rowText = buf.cells[r].map(c => c.char).join(""); 299 + if (rowText.includes("hello world")) { 300 + const col = rowText.indexOf("hello"); 301 + expect(buf.cells[r][col].bold).toBe(false); 302 + break; 303 + } 304 + } 305 + }); 306 + });
+106
demos/file-browser/main.ts
··· 1 + // Entry point: file browser TUI demo 2 + import { parseKey, CellBuffer, diff, fullRender, effect, hideCursor, showCursor, reset, recordFrame, toggleFPS, isFPSVisible } from "../../src/tui/index.ts"; 3 + import { currentTheme, boxStyle, initTree } from "./state.ts"; 4 + import { browserScreen } from "./screens/browser.ts"; 5 + import type { ScreenContext } from "../../src/tui/index.ts"; 6 + 7 + const enterAltScreen = "\x1b[?1049h"; 8 + const leaveAltScreen = "\x1b[?1049l"; 9 + 10 + let running = true; 11 + let prevBuffer: CellBuffer | null = null; 12 + 13 + function getSize(): [number, number] { 14 + return [process.stdout.rows ?? 35, process.stdout.columns ?? 120]; 15 + } 16 + 17 + function cleanup(): void { 18 + running = false; 19 + process.stdout.write(showCursor() + reset() + leaveAltScreen); 20 + if (process.stdin.isTTY) { 21 + process.stdin.setRawMode(false); 22 + } 23 + process.stdin.pause(); 24 + } 25 + 26 + function createContext(rows: number, cols: number): ScreenContext { 27 + return { 28 + rows, 29 + cols, 30 + theme: currentTheme.get(), 31 + boxStyle: boxStyle.get(), 32 + navigate: () => {}, 33 + back: () => {}, 34 + openOverlay: () => {}, 35 + closeOverlay: () => {}, 36 + isTextInputActive: () => false, 37 + setTextInputActive: () => {}, 38 + }; 39 + } 40 + 41 + function renderFrame(): void { 42 + if (!running) return; 43 + recordFrame(); 44 + 45 + const [rows, cols] = getSize(); 46 + const ctx = createContext(rows, cols); 47 + const buf = browserScreen.renderToBuffer(ctx); 48 + 49 + let output: string; 50 + if (prevBuffer && prevBuffer.rows === rows && prevBuffer.cols === cols) { 51 + output = diff(prevBuffer, buf); 52 + } else { 53 + output = fullRender(buf); 54 + } 55 + 56 + prevBuffer = buf; 57 + process.stdout.write(hideCursor() + output); 58 + } 59 + 60 + // --- Initialize --- 61 + initTree(); 62 + 63 + // --- Terminal setup --- 64 + process.stdout.write(enterAltScreen + hideCursor()); 65 + if (process.stdin.isTTY) { 66 + process.stdin.setRawMode(true); 67 + } 68 + process.stdin.resume(); 69 + 70 + // Reactive render loop 71 + effect(() => { 72 + renderFrame(); 73 + }); 74 + 75 + // Handle stdin 76 + process.stdin.on("data", (data: Buffer | string) => { 77 + const buf = typeof data === "string" ? Buffer.from(data) : data; 78 + const keys = parseKey(buf); 79 + 80 + for (const key of keys) { 81 + // FPS toggle (global) 82 + if (key.char === "F") { 83 + toggleFPS(); 84 + continue; 85 + } 86 + 87 + const [rows, cols] = getSize(); 88 + const ctx = createContext(rows, cols); 89 + const cont = browserScreen.handleKey(key, ctx); 90 + if (!cont) { 91 + cleanup(); 92 + process.exit(0); 93 + } 94 + } 95 + }); 96 + 97 + // Handle resize 98 + process.stdout.on("resize", () => { 99 + prevBuffer = null; 100 + renderFrame(); 101 + }); 102 + 103 + // Handle cleanup 104 + process.on("SIGINT", () => { cleanup(); process.exit(0); }); 105 + process.on("SIGTERM", () => { cleanup(); process.exit(0); }); 106 + process.on("exit", cleanup);
+105
demos/file-browser/preview.ts
··· 1 + // Preview rendering: wraps long lines with gutter-aligned line numbers 2 + // and optional syntax highlighting (e.g. markdown headings). 3 + import { 4 + text, wrapText, visibleLength, 5 + type UINode, type Span, 6 + } from "../../src/tui/index.ts"; 7 + 8 + const GUTTER_WIDTH = 7; // " 1 │ " = 7 chars 9 + 10 + /** Compute the content width available for text after the gutter. */ 11 + export function previewContentWidth(cols: number, treeWidth: number): number { 12 + const panelWidth = cols - treeWidth; 13 + return Math.max(1, panelWidth - 4 - GUTTER_WIDTH); 14 + } 15 + 16 + /** 17 + * Build preview nodes: each source line becomes one or more text nodes. 18 + * The first visual line gets a line number gutter. Continuation lines get 19 + * a blank gutter of the same width so content stays aligned. 20 + */ 21 + export function buildPreviewNodes( 22 + lines: string[], 23 + startLine: number, 24 + contentWidth: number, 25 + highlighter?: (content: string) => Span[], 26 + ): UINode[] { 27 + const nodes: UINode[] = []; 28 + 29 + for (let i = 0; i < lines.length; i++) { 30 + const line = lines[i]; 31 + const lineNum = String(startLine + i + 1).padStart(4, " "); 32 + const gutterPrefix = lineNum + " \u2502 "; 33 + const blankPrefix = " ".repeat(GUTTER_WIDTH); 34 + 35 + if (contentWidth > 0 && visibleLength(line) > contentWidth) { 36 + const { lines: wrapped } = wrapText(line, contentWidth); 37 + for (let w = 0; w < wrapped.length; w++) { 38 + const prefix = w === 0 ? gutterPrefix : blankPrefix; 39 + const content = wrapped[w]; 40 + nodes.push(text(prefix + content, "primary", { 41 + truncate: true, 42 + highlight: lineHighlight(prefix, content, w === 0, highlighter), 43 + })); 44 + } 45 + } else { 46 + nodes.push(text(gutterPrefix + line, "primary", { 47 + truncate: true, 48 + highlight: lineHighlight(gutterPrefix, line, true, highlighter), 49 + })); 50 + } 51 + } 52 + 53 + return nodes; 54 + } 55 + 56 + /** Build a highlight callback for a single preview line. */ 57 + function lineHighlight( 58 + prefix: string, 59 + content: string, 60 + hasLineNumber: boolean, 61 + highlighter?: (content: string) => Span[], 62 + ): (fullText: string) => Span[] { 63 + return () => { 64 + const prefixLen = [...prefix].length; 65 + const spans: Span[] = []; 66 + 67 + if (hasLineNumber) { 68 + spans.push({ start: 0, end: prefixLen - 2, color: "muted" as const }); 69 + spans.push({ start: prefixLen - 2, end: prefixLen, color: "border" as const }); 70 + } else { 71 + spans.push({ start: 0, end: prefixLen, color: "muted" as const }); 72 + } 73 + 74 + if (highlighter) { 75 + for (const s of highlighter(content)) { 76 + spans.push({ ...s, start: s.start + prefixLen, end: s.end + prefixLen }); 77 + } 78 + } 79 + 80 + return spans; 81 + }; 82 + } 83 + 84 + // --- Markdown highlighter --- 85 + 86 + export function markdownHighlight(content: string): Span[] { 87 + const len = [...content].length; 88 + if (content.startsWith("#")) { 89 + return [{ start: 0, end: len, bold: true, color: "accent" as const }]; 90 + } 91 + if (content.startsWith("---")) { 92 + return [{ start: 0, end: len, dim: true, color: "muted" as const }]; 93 + } 94 + if (content.startsWith("- ") || content.startsWith("* ")) { 95 + return [{ start: 0, end: 2, bold: true, color: "accent" as const }]; 96 + } 97 + if (content.startsWith("> ")) { 98 + return [{ start: 0, end: len, italic: true, color: "secondary" as const }]; 99 + } 100 + return []; 101 + } 102 + 103 + export function isMarkdown(filename: string): boolean { 104 + return filename.endsWith(".md") || filename.endsWith(".markdown") || filename.endsWith(".mdx"); 105 + }
+102
demos/file-browser/screens/browser.ts
··· 1 + // Two-pane file browser screen 2 + import { 3 + screen, text, column, hstack, panel, selectable, scrollable, 4 + statusBar, footer, 5 + type KeyEvent, type ScreenContext, type UINode, 6 + } from "../../../src/tui/index.ts"; 7 + import { updateScrollRegion } from "../../../src/tui/index.ts"; 8 + import { 9 + flatList, selectedIndex, treeScroll, focusPane, 10 + previewContent, previewScroll, previewIsBinary, previewFileName, selectedNode, 11 + moveUp, moveDown, expandOrOpen, collapseDir, switchPane, 12 + scrollPreviewUp, scrollPreviewDown, cycleTheme, 13 + type TreeNode, 14 + } from "../state.ts"; 15 + import { 16 + buildPreviewNodes, previewContentWidth, markdownHighlight, isMarkdown, 17 + } from "../preview.ts"; 18 + 19 + function treeIcon(node: TreeNode): string { 20 + if (node.type === "dir") return node.expanded ? "\u25be" : "\u25b8"; 21 + return " "; 22 + } 23 + 24 + function renderTreeRow(node: TreeNode, _index: number, selected: boolean): UINode[] { 25 + const ind = " ".repeat(node.depth); 26 + const isFocused = selected && focusPane.get() === "tree"; 27 + const sel = isFocused ? "> " : " "; 28 + const nameColor = isFocused ? "accent" : (node.type === "dir" ? "accent" : "primary"); 29 + return [text(sel + ind + treeIcon(node) + " " + node.name, nameColor, { bold: isFocused, truncate: true })]; 30 + } 31 + 32 + export const browserScreen = screen({ 33 + id: "browser", 34 + 35 + render(ctx: ScreenContext): UINode[] { 36 + const list = flatList.get(); 37 + const sel = selectedIndex.get(); 38 + const preview = previewContent.get(); 39 + const pScroll = previewScroll.get(); 40 + const node = selectedNode.get(); 41 + const binary = previewIsBinary.get(); 42 + const fileName = previewFileName.get(); 43 + const highlighter = isMarkdown(fileName) ? markdownHighlight : undefined; 44 + 45 + const treeViewport = Math.max(1, ctx.rows - 4); 46 + const region = updateScrollRegion( 47 + { ...treeScroll.get(), selectedIndex: sel, totalItems: list.length }, 48 + list.length, 49 + treeViewport, 50 + ); 51 + 52 + const rightInfo = node 53 + ? `${node.type === "dir" ? "dir" : "file"}: ${node.name}` 54 + : ""; 55 + 56 + const previewViewport = Math.max(1, ctx.rows - 4); 57 + const visiblePreview = preview.slice(pScroll, pScroll + previewViewport); 58 + const treeWidth = Math.max(25, Math.floor(ctx.cols * 0.35)); 59 + 60 + return [ 61 + statusBar("File Browser", rightInfo), 62 + hstack({ gap: 0 }, [ 63 + column({ width: treeWidth }, [ 64 + panel("Files", [ 65 + selectable(region, list, renderTreeRow), 66 + ]), 67 + ]), 68 + column({ flex: true }, [ 69 + panel("Preview", [ 70 + scrollable( 71 + binary 72 + ? ["(binary file)"] 73 + : visiblePreview.length > 0 74 + ? buildPreviewNodes(visiblePreview, pScroll, previewContentWidth(ctx.cols, treeWidth), highlighter) 75 + : ["(no preview)"], 76 + (item) => typeof item === "string" ? [text(item, "muted")] : [item], 77 + ), 78 + ]), 79 + ]), 80 + ]), 81 + footer("\u2191\u2193 nav \u2190\u2192 expand/collapse tab pane T theme q quit"), 82 + ]; 83 + }, 84 + 85 + handleKey(key: KeyEvent, ctx: ScreenContext): boolean { 86 + if (key.char === "q" || (key.name === "c" && key.ctrl)) return false; 87 + if (key.char === "T") { cycleTheme(); return true; } 88 + if (key.name === "tab") { switchPane(); return true; } 89 + 90 + if (focusPane.peek() === "tree") { 91 + if (key.name === "up") { moveUp(); return true; } 92 + if (key.name === "down") { moveDown(); return true; } 93 + if (key.name === "right" || key.name === "return") { expandOrOpen(); return true; } 94 + if (key.name === "left") { collapseDir(); return true; } 95 + } else { 96 + if (key.name === "up") { scrollPreviewUp(); return true; } 97 + if (key.name === "down") { scrollPreviewDown(); return true; } 98 + } 99 + 100 + return true; 101 + }, 102 + });
+255
demos/file-browser/state.ts
··· 1 + // File browser state: tree structure, signals, directory loading, preview 2 + import * as fs from "node:fs"; 3 + import * as path from "node:path"; 4 + import { 5 + signal, computed, batch, 6 + createScrollRegion, updateScrollRegion, scrollUp, scrollDown, 7 + themes, 8 + type ScrollRegion, type Theme, type BoxStyle, 9 + } from "../../src/tui/index.ts"; 10 + 11 + // --- Tree node --- 12 + export interface TreeNode { 13 + name: string; 14 + path: string; 15 + type: "file" | "dir"; 16 + expanded: boolean; 17 + children: TreeNode[] | null; 18 + depth: number; 19 + } 20 + 21 + // --- Signals --- 22 + export const rootPath = signal(process.argv[2] || process.cwd()); 23 + export const tree = signal<TreeNode[]>([]); 24 + export const selectedIndex = signal(0); 25 + export const treeScroll = signal<ScrollRegion>(createScrollRegion(0, 20)); 26 + export const focusPane = signal<"tree" | "preview">("tree"); 27 + export const previewContent = signal<string[]>([]); 28 + export const previewScroll = signal(0); 29 + export const previewIsBinary = signal(false); 30 + export const previewFileName = signal(""); 31 + 32 + const SKIP_DIRS = new Set([".git", "node_modules", ".DS_Store", ".hg", ".svn", "__pycache__", ".cache"]); 33 + const MAX_DEPTH = 5; 34 + 35 + // --- Theme --- 36 + const themeNames = Object.keys(themes); 37 + export const themeIndex = signal(0); 38 + export const currentTheme = computed<Theme>(() => themes[themeNames[themeIndex.get()]] as Theme); 39 + export const boxStyle = signal<BoxStyle>("rounded"); 40 + 41 + export function cycleTheme(): void { 42 + themeIndex.set((themeIndex.peek() + 1) % themeNames.length); 43 + } 44 + 45 + // --- Flatten tree --- 46 + export const flatList = computed<TreeNode[]>(() => { 47 + const result: TreeNode[] = []; 48 + function walk(nodes: TreeNode[]): void { 49 + for (const node of nodes) { 50 + result.push(node); 51 + if (node.type === "dir" && node.expanded && node.children) { 52 + walk(node.children); 53 + } 54 + } 55 + } 56 + walk(tree.get()); 57 + return result; 58 + }); 59 + 60 + // --- Selected node --- 61 + export const selectedNode = computed<TreeNode | null>(() => { 62 + const list = flatList.get(); 63 + const idx = selectedIndex.get(); 64 + return list[idx] ?? null; 65 + }); 66 + 67 + // --- Load directory --- 68 + export function loadDirectory(dirPath: string, depth: number): TreeNode[] { 69 + if (depth > MAX_DEPTH) return []; 70 + let entries: fs.Dirent[]; 71 + try { 72 + entries = fs.readdirSync(dirPath, { withFileTypes: true }); 73 + } catch { 74 + return []; 75 + } 76 + 77 + const dirs: TreeNode[] = []; 78 + const files: TreeNode[] = []; 79 + 80 + for (const entry of entries) { 81 + if (SKIP_DIRS.has(entry.name)) continue; 82 + if (entry.name.startsWith(".") && entry.name !== ".") continue; 83 + 84 + const fullPath = path.join(dirPath, entry.name); 85 + if (entry.isDirectory()) { 86 + dirs.push({ 87 + name: entry.name, 88 + path: fullPath, 89 + type: "dir", 90 + expanded: false, 91 + children: null, 92 + depth, 93 + }); 94 + } else if (entry.isFile()) { 95 + files.push({ 96 + name: entry.name, 97 + path: fullPath, 98 + type: "file", 99 + expanded: false, 100 + children: null, 101 + depth, 102 + }); 103 + } 104 + } 105 + 106 + // Sort: dirs first (alphabetical), then files (alphabetical) 107 + dirs.sort((a, b) => a.name.localeCompare(b.name)); 108 + files.sort((a, b) => a.name.localeCompare(b.name)); 109 + return [...dirs, ...files]; 110 + } 111 + 112 + // --- Binary detection --- 113 + export function isBinary(filePath: string): boolean { 114 + try { 115 + const fd = fs.openSync(filePath, "r"); 116 + const buf = Buffer.alloc(512); 117 + const bytesRead = fs.readSync(fd, buf, 0, 512, 0); 118 + fs.closeSync(fd); 119 + for (let i = 0; i < bytesRead; i++) { 120 + if (buf[i] === 0) return true; 121 + } 122 + return false; 123 + } catch { 124 + return true; 125 + } 126 + } 127 + 128 + // --- Load preview --- 129 + export function loadPreview(filePath: string): void { 130 + if (isBinary(filePath)) { 131 + batch(() => { 132 + previewContent.set(["(binary file)"]); 133 + previewIsBinary.set(true); 134 + previewFileName.set(path.basename(filePath)); 135 + previewScroll.set(0); 136 + }); 137 + return; 138 + } 139 + 140 + try { 141 + const content = fs.readFileSync(filePath, "utf-8"); 142 + const lines = content.split("\n").slice(0, 1000); 143 + batch(() => { 144 + previewContent.set(lines); 145 + previewIsBinary.set(false); 146 + previewFileName.set(path.basename(filePath)); 147 + previewScroll.set(0); 148 + }); 149 + } catch { 150 + batch(() => { 151 + previewContent.set(["(unable to read file)"]); 152 + previewIsBinary.set(false); 153 + previewFileName.set(""); 154 + previewScroll.set(0); 155 + }); 156 + } 157 + } 158 + 159 + // --- Initialize --- 160 + export function initTree(): void { 161 + const root = rootPath.peek(); 162 + const children = loadDirectory(root, 0); 163 + tree.set(children); 164 + const count = children.length; 165 + treeScroll.set(updateScrollRegion(treeScroll.peek(), count, 20)); 166 + if (count > 0) { 167 + selectedIndex.set(0); 168 + const first = children[0]; 169 + if (first.type === "file") loadPreview(first.path); 170 + } 171 + } 172 + 173 + // --- Actions --- 174 + export function moveUp(): void { 175 + const idx = selectedIndex.peek(); 176 + if (idx <= 0) return; 177 + const newIdx = idx - 1; 178 + batch(() => { 179 + selectedIndex.set(newIdx); 180 + treeScroll.set(scrollUp(treeScroll.peek())); 181 + }); 182 + autoPreview(); 183 + } 184 + 185 + export function moveDown(): void { 186 + const idx = selectedIndex.peek(); 187 + const list = flatList.get(); 188 + if (idx >= list.length - 1) return; 189 + const newIdx = idx + 1; 190 + batch(() => { 191 + selectedIndex.set(newIdx); 192 + treeScroll.set(scrollDown(treeScroll.peek())); 193 + }); 194 + autoPreview(); 195 + } 196 + 197 + function autoPreview(): void { 198 + const node = selectedNode.get(); 199 + if (node && node.type === "file") { 200 + loadPreview(node.path); 201 + } 202 + } 203 + 204 + export function expandOrOpen(): void { 205 + const node = selectedNode.get(); 206 + if (!node) return; 207 + 208 + if (node.type === "dir") { 209 + if (!node.expanded) { 210 + // Expand: load children if not loaded 211 + if (!node.children) { 212 + node.children = loadDirectory(node.path, node.depth + 1); 213 + } 214 + node.expanded = true; 215 + // Trigger reactivity 216 + tree.set([...tree.peek()]); 217 + const list = flatList.get(); 218 + treeScroll.set(updateScrollRegion(treeScroll.peek(), list.length)); 219 + } 220 + } else { 221 + loadPreview(node.path); 222 + } 223 + } 224 + 225 + export function collapseDir(): void { 226 + const node = selectedNode.get(); 227 + if (!node) return; 228 + 229 + if (node.type === "dir" && node.expanded) { 230 + node.expanded = false; 231 + tree.set([...tree.peek()]); 232 + const list = flatList.get(); 233 + // Ensure selected index is still valid 234 + const newIdx = Math.min(selectedIndex.peek(), list.length - 1); 235 + batch(() => { 236 + selectedIndex.set(newIdx); 237 + treeScroll.set(updateScrollRegion(treeScroll.peek(), list.length)); 238 + }); 239 + } 240 + } 241 + 242 + export function switchPane(): void { 243 + focusPane.set(focusPane.peek() === "tree" ? "preview" : "tree"); 244 + } 245 + 246 + export function scrollPreviewUp(): void { 247 + const s = previewScroll.peek(); 248 + if (s > 0) previewScroll.set(s - 1); 249 + } 250 + 251 + export function scrollPreviewDown(): void { 252 + const s = previewScroll.peek(); 253 + const lines = previewContent.peek(); 254 + if (s < lines.length - 1) previewScroll.set(s + 1); 255 + }
+168
demos/reminders/main.ts
··· 1 + // Entry point: reminders TUI demo 2 + import { 3 + parseKey, CellBuffer, diff, fullRender, effect, 4 + hideCursor, showCursor, reset, recordFrame, 5 + toggleFPS, 6 + } from "../../src/tui/index.ts"; 7 + import type { ScreenContext } from "../../src/tui/index.ts"; 8 + import { currentTheme, boxStyle, init, stopWatching, currentView } from "./state.ts"; 9 + import { handleGlobalKey, activeOverlay } from "./router.ts"; 10 + import { renderListView } from "./screens/list-view.ts"; 11 + import { renderBoardView } from "./screens/board-view.ts"; 12 + import { renderCalendarView } from "./screens/calendar-view.ts"; 13 + import { newReminderOverlay } from "./screens/new-reminder.ts"; 14 + import { confirmDeleteOverlay } from "./screens/confirm-delete.ts"; 15 + import { 16 + screen, layoutRoot, renderToAnsi, 17 + type RenderOpts, 18 + } from "../../src/tui/index.ts"; 19 + 20 + const enterAltScreen = "\x1b[?1049h"; 21 + const leaveAltScreen = "\x1b[?1049l"; 22 + 23 + let running = true; 24 + let prevBuffer: CellBuffer | null = null; 25 + 26 + function getSize(): [number, number] { 27 + return [process.stdout.rows ?? 35, process.stdout.columns ?? 120]; 28 + } 29 + 30 + function cleanup(): void { 31 + running = false; 32 + stopWatching(); 33 + process.stdout.write(showCursor() + reset() + leaveAltScreen); 34 + if (process.stdin.isTTY) { 35 + process.stdin.setRawMode(false); 36 + } 37 + process.stdin.pause(); 38 + } 39 + 40 + function createContext(rows: number, cols: number): ScreenContext { 41 + return { 42 + rows, 43 + cols, 44 + theme: currentTheme.get(), 45 + boxStyle: boxStyle.get(), 46 + navigate: () => {}, 47 + back: () => {}, 48 + openOverlay: () => {}, 49 + closeOverlay: () => {}, 50 + isTextInputActive: () => false, 51 + setTextInputActive: () => {}, 52 + }; 53 + } 54 + 55 + // Create a dynamic screen that renders the active view 56 + const appScreen = screen({ 57 + id: "reminders", 58 + render(ctx: ScreenContext) { 59 + const view = currentView.get(); 60 + if (view === "board") return renderBoardView(ctx); 61 + if (view === "calendar") return renderCalendarView(ctx); 62 + return renderListView(ctx); 63 + }, 64 + }); 65 + 66 + function renderFrame(): void { 67 + if (!running) return; 68 + recordFrame(); 69 + 70 + const [rows, cols] = getSize(); 71 + const ctx = createContext(rows, cols); 72 + 73 + // Render main screen 74 + const buf = appScreen.renderToBuffer(ctx); 75 + 76 + // Composite overlay if open 77 + const overlay = activeOverlay.get(); 78 + if (overlay === "new" || overlay === "edit") { 79 + const overlayBuf = newReminderOverlay.renderToBuffer(ctx); 80 + compositeOverlay(buf, overlayBuf); 81 + } else if (overlay === "confirm-delete") { 82 + const overlayBuf = confirmDeleteOverlay.renderToBuffer(ctx); 83 + compositeOverlay(buf, overlayBuf); 84 + } 85 + 86 + let output: string; 87 + if (prevBuffer && prevBuffer.rows === rows && prevBuffer.cols === cols) { 88 + output = diff(prevBuffer, buf); 89 + } else { 90 + output = fullRender(buf); 91 + } 92 + 93 + prevBuffer = buf; 94 + process.stdout.write(hideCursor() + output); 95 + } 96 + 97 + function compositeOverlay(base: CellBuffer, overlay: CellBuffer): void { 98 + // First pass: find the bounding box of non-empty cells in the overlay 99 + let minR = base.rows, maxR = 0, minC = base.cols, maxC = 0; 100 + for (let r = 0; r < base.rows; r++) { 101 + for (let c = 0; c < base.cols; c++) { 102 + const cell = overlay.cells[r]?.[c]; 103 + if (cell && (cell.char !== " " || cell.bg !== null)) { 104 + minR = Math.min(minR, r); 105 + maxR = Math.max(maxR, r); 106 + minC = Math.min(minC, c); 107 + maxC = Math.max(maxC, c); 108 + } 109 + } 110 + } 111 + 112 + // Second pass: copy ALL cells within the bounding box (including spaces) 113 + for (let r = minR; r <= maxR; r++) { 114 + for (let c = minC; c <= maxC; c++) { 115 + const cell = overlay.cells[r]?.[c]; 116 + if (cell) { 117 + base.cells[r][c] = cell; 118 + } 119 + } 120 + } 121 + } 122 + 123 + // --- Initialize --- 124 + init(); 125 + 126 + // --- Terminal setup --- 127 + process.stdout.write(enterAltScreen + hideCursor()); 128 + if (process.stdin.isTTY) { 129 + process.stdin.setRawMode(true); 130 + } 131 + process.stdin.resume(); 132 + 133 + // Reactive render loop 134 + effect(() => { 135 + renderFrame(); 136 + }); 137 + 138 + // Handle stdin 139 + process.stdin.on("data", (data: Buffer | string) => { 140 + const buf = typeof data === "string" ? Buffer.from(data) : data; 141 + const keys = parseKey(buf); 142 + 143 + for (const key of keys) { 144 + if (key.char === "F") { 145 + toggleFPS(); 146 + continue; 147 + } 148 + 149 + const [rows, cols] = getSize(); 150 + const ctx = createContext(rows, cols); 151 + const cont = handleGlobalKey(key, ctx); 152 + if (!cont) { 153 + cleanup(); 154 + process.exit(0); 155 + } 156 + } 157 + }); 158 + 159 + // Handle resize 160 + process.stdout.on("resize", () => { 161 + prevBuffer = null; 162 + renderFrame(); 163 + }); 164 + 165 + // Handle cleanup 166 + process.on("SIGINT", () => { cleanup(); process.exit(0); }); 167 + process.on("SIGTERM", () => { cleanup(); process.exit(0); }); 168 + process.on("exit", cleanup);
+183
demos/reminders/reminders-integration.test.ts
··· 1 + // Integration tests for reminders demo — runs through real PTY 2 + import { describe, it, expect, afterEach } from "vitest"; 3 + import * as path from "node:path"; 4 + import { fileURLToPath } from "node:url"; 5 + import { Session } from "../../src/testing/index.ts"; 6 + 7 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 8 + const mainScript = path.join(__dirname, "main.ts"); 9 + 10 + let session: Session; 11 + 12 + async function startApp(rows = 35, cols = 120): Promise<Session> { 13 + session = Session.spawn("node", ["--experimental-strip-types", "--no-warnings", mainScript], { 14 + rows, 15 + cols, 16 + env: { TERM: "xterm-256color" }, 17 + }); 18 + await session.waitForText("Reminders", 15000); 19 + return session; 20 + } 21 + 22 + afterEach(async () => { 23 + if (session) { 24 + await session.close(); 25 + } 26 + }); 27 + 28 + describe("Reminders Integration", () => { 29 + it("boots with seeded data and shows reminders", async () => { 30 + await startApp(); 31 + const ss = session.screenshot(); 32 + expect(ss.text).toContain("Reminders"); 33 + expect(ss.text).toContain("Buy groceries"); 34 + }, 20000); 35 + 36 + it("arrow keys navigate the list", async () => { 37 + await startApp(); 38 + 39 + session.press("down"); 40 + await new Promise(r => setTimeout(r, 300)); 41 + 42 + session.press("down"); 43 + await new Promise(r => setTimeout(r, 300)); 44 + 45 + const ss = session.screenshot(); 46 + // Should still show the reminders app 47 + expect(ss.text).toContain("Reminders"); 48 + }, 20000); 49 + 50 + it("v cycles through views", async () => { 51 + await startApp(); 52 + 53 + // Start in list view 54 + let ss = session.screenshot(); 55 + expect(ss.text).toContain("list"); 56 + 57 + // Switch to board view 58 + session.type("v"); 59 + await session.waitForText("board", 5000); 60 + ss = session.screenshot(); 61 + expect(ss.text).toContain("board"); 62 + expect(ss.text).toContain("Todo"); 63 + expect(ss.text).toContain("Done"); 64 + 65 + // Switch to calendar view 66 + session.type("v"); 67 + await session.waitForText("calendar", 5000); 68 + ss = session.screenshot(); 69 + expect(ss.text).toContain("calendar"); 70 + 71 + // Back to list 72 + session.type("v"); 73 + await session.waitForText("list", 5000); 74 + }, 20000); 75 + 76 + it("space toggles completion", async () => { 77 + await startApp(); 78 + 79 + // Find "Buy groceries" and check its initial state 80 + let ss = session.screenshot(); 81 + expect(ss.text).toContain("Buy groceries"); 82 + 83 + // Toggle completion on first item 84 + session.press("space"); 85 + await new Promise(r => setTimeout(r, 500)); 86 + 87 + ss = session.screenshot(); 88 + // The item should still be present (moved to Completed group) 89 + expect(ss.text).toContain("Buy groceries"); 90 + }, 20000); 91 + 92 + it("n opens new reminder overlay", async () => { 93 + await startApp(); 94 + 95 + session.type("n"); 96 + await session.waitForText("New Reminder", 5000); 97 + 98 + const ss = session.screenshot(); 99 + expect(ss.text).toContain("New Reminder"); 100 + expect(ss.text).toContain("Title"); 101 + expect(ss.text).toContain("Priority"); 102 + 103 + // Close with escape 104 + session.press("escape"); 105 + await session.waitForAbsent("New Reminder", 5000); 106 + }, 20000); 107 + 108 + it("d + y deletes a reminder", async () => { 109 + await startApp(); 110 + 111 + session.type("d"); 112 + await session.waitForText("Delete", 5000); 113 + 114 + let ss = session.screenshot(); 115 + expect(ss.text).toContain("Delete"); 116 + 117 + session.type("y"); 118 + await session.waitForAbsent("Delete", 5000); 119 + 120 + // Should have fewer reminders 121 + ss = session.screenshot(); 122 + expect(ss.text).toContain("Reminders"); 123 + }, 20000); 124 + 125 + it("T cycles theme", async () => { 126 + await startApp(); 127 + const ss1 = session.screenshot(); 128 + 129 + session.type("T"); 130 + await new Promise(r => setTimeout(r, 500)); 131 + 132 + const ss2 = session.screenshot(); 133 + expect(ss2.text).toContain("Reminders"); 134 + expect(ss2.ansi).not.toEqual(ss1.ansi); 135 + }, 20000); 136 + 137 + it("ctrl+c quits", async () => { 138 + await startApp(); 139 + session.press("ctrl+c"); 140 + await session.waitForAbsent("Reminders", 5000); 141 + }, 20000); 142 + 143 + it("overlay form fields render cleanly without bleed-through", async () => { 144 + await startApp(25, 80); 145 + 146 + session.type("n"); 147 + await session.waitForText("New Reminder", 5000); 148 + 149 + const ss = session.screenshot(); 150 + // The Title field should render as "▸ Title: █" — no garbled characters 151 + const titleLine = ss.lines.find(l => l.includes("Title:")); 152 + expect(titleLine).toBeDefined(); 153 + expect(titleLine).toMatch(/▸ Title:/); 154 + // No stray characters between ▸ and Title 155 + expect(titleLine).not.toMatch(/▸\S+Title/); 156 + 157 + session.press("escape"); 158 + await session.waitForAbsent("New Reminder", 5000); 159 + }, 20000); 160 + 161 + it("space toggles the visually selected item", async () => { 162 + await startApp(); 163 + 164 + // The first item should have ▸ indicator 165 + let ss = session.screenshot(); 166 + const selLine = ss.lines.find(l => l.includes("\u25b8")); 167 + expect(selLine).toBeDefined(); 168 + 169 + // Extract the title of the selected item (text after the indicator + checkbox + priority) 170 + const selTitle = selLine!.replace(/.*[□■][^a-zA-Z]*/, "").replace(/\s+\d{4}.*/, "").trim(); 171 + expect(selTitle.length).toBeGreaterThan(0); 172 + 173 + // Toggle it 174 + session.press("space"); 175 + await new Promise(r => setTimeout(r, 500)); 176 + 177 + ss = session.screenshot(); 178 + // The toggled item should now appear in the Completed section 179 + expect(ss.text).toContain("Completed"); 180 + // The item title should still exist on screen 181 + expect(ss.text).toContain(selTitle); 182 + }, 20000); 183 + });
+224
demos/reminders/reminders.test.ts
··· 1 + // Unit tests for reminders demo 2 + import { describe, it, expect, beforeEach, afterEach } from "vitest"; 3 + import * as fs from "node:fs"; 4 + import * as path from "node:path"; 5 + import * as os from "node:os"; 6 + import { 7 + parseFrontmatter, parseReminderFile, serializeReminder, seedData, 8 + loadReminders, reminders, groupedByDate, boardColumns, calendarData, 9 + selectedIndex, selectedReminder, currentView, cycleView, 10 + toggleComplete, deleteReminder, createReminder, 11 + dataDir, init, stopWatching, calendarMonth, calendarYear, 12 + type Reminder, 13 + } from "./state.ts"; 14 + import { themes, type Theme, type ScreenContext } from "../../src/tui/index.ts"; 15 + import { renderListView } from "./screens/list-view.ts"; 16 + 17 + let testDir: string; 18 + 19 + function testCtx(rows = 35, cols = 120): ScreenContext { 20 + const theme = themes.coolBlue as Theme; 21 + return { 22 + rows, cols, theme, boxStyle: "rounded", 23 + navigate: () => {}, back: () => {}, 24 + openOverlay: () => {}, closeOverlay: () => {}, 25 + isTextInputActive: () => false, setTextInputActive: () => {}, 26 + }; 27 + } 28 + 29 + beforeEach(() => { 30 + testDir = fs.mkdtempSync(path.join(os.tmpdir(), "rem-test-")); 31 + dataDir.set(testDir + "/"); 32 + seedData(); 33 + loadReminders(); 34 + }); 35 + 36 + afterEach(() => { 37 + stopWatching(); 38 + fs.rmSync(testDir, { recursive: true, force: true }); 39 + }); 40 + 41 + // --- Frontmatter parsing --- 42 + describe("parseFrontmatter", () => { 43 + it("parses valid frontmatter", () => { 44 + const content = `--- 45 + due: 2024-03-25 46 + priority: high 47 + completed: false 48 + tags: [work, urgent] 49 + created: 2024-03-20T10:00:00Z 50 + --- 51 + # Buy groceries 52 + 53 + Get milk, eggs, bread.`; 54 + const { meta, body } = parseFrontmatter(content); 55 + expect(meta.due).toBe("2024-03-25"); 56 + expect(meta.priority).toBe("high"); 57 + expect(meta.completed).toBe("false"); 58 + expect(meta.tags).toBe("[work, urgent]"); 59 + expect(body).toContain("Buy groceries"); 60 + expect(body).toContain("Get milk"); 61 + }); 62 + 63 + it("returns empty meta for no frontmatter", () => { 64 + const { meta, body } = parseFrontmatter("Hello world"); 65 + expect(Object.keys(meta)).toHaveLength(0); 66 + expect(body).toBe("Hello world"); 67 + }); 68 + 69 + it("handles unclosed frontmatter", () => { 70 + const { meta, body } = parseFrontmatter("---\nkey: value\nno closing"); 71 + expect(Object.keys(meta)).toHaveLength(0); 72 + }); 73 + }); 74 + 75 + // --- Seed data --- 76 + describe("seedData", () => { 77 + it("creates reminder files on disk", () => { 78 + const files = fs.readdirSync(testDir).filter(f => f.endsWith(".md")); 79 + expect(files.length).toBe(15); 80 + }); 81 + 82 + it("creates parseable files", () => { 83 + const files = fs.readdirSync(testDir).filter(f => f.endsWith(".md")); 84 + const r = parseReminderFile(path.join(testDir, files[0])); 85 + expect(r).not.toBeNull(); 86 + expect(r!.title.length).toBeGreaterThan(0); 87 + }); 88 + }); 89 + 90 + // --- Load reminders --- 91 + describe("loadReminders", () => { 92 + it("loads all seeded reminders", () => { 93 + expect(reminders.peek().length).toBe(15); 94 + }); 95 + 96 + it("parses reminder fields correctly", () => { 97 + const all = reminders.peek(); 98 + const buy = all.find(r => r.title === "Buy groceries"); 99 + expect(buy).toBeDefined(); 100 + expect(buy!.priority).toBe("high"); 101 + expect(buy!.tags).toContain("personal"); 102 + }); 103 + }); 104 + 105 + // --- Grouping --- 106 + describe("groupedByDate", () => { 107 + it("groups reminders into date categories", () => { 108 + const groups = groupedByDate.get(); 109 + expect(groups.length).toBeGreaterThan(0); 110 + // Should have at least Overdue, Today, and Completed groups 111 + const titles = groups.map(g => g.title); 112 + expect(titles).toContain("Completed"); 113 + expect(titles).toContain("Today"); 114 + }); 115 + 116 + it("completed group has 3 items", () => { 117 + const groups = groupedByDate.get(); 118 + const completed = groups.find(g => g.title === "Completed"); 119 + expect(completed).toBeDefined(); 120 + expect(completed!.items.length).toBe(3); 121 + }); 122 + }); 123 + 124 + // --- Board columns --- 125 + describe("boardColumns", () => { 126 + it("splits into todo, in-progress, done", () => { 127 + const [todo, inProgress, done] = boardColumns.get(); 128 + expect(done.length).toBe(3); // 3 completed 129 + expect(inProgress.length).toBeGreaterThan(0); // high priority items 130 + expect(todo.length + inProgress.length).toBe(12); // 15 - 3 completed 131 + }); 132 + }); 133 + 134 + // --- Calendar data --- 135 + describe("calendarData", () => { 136 + it("returns counts for days with reminders", () => { 137 + const month = new Date().getMonth(); 138 + const year = new Date().getFullYear(); 139 + calendarMonth.set(month); 140 + calendarYear.set(year); 141 + const counts = calendarData.get(); 142 + // Today should have reminders (we seeded some for today) 143 + const today = new Date().getDate(); 144 + expect(counts.get(today)).toBeGreaterThan(0); 145 + }); 146 + }); 147 + 148 + // --- View cycling --- 149 + describe("cycleView", () => { 150 + it("cycles through list, board, calendar", () => { 151 + expect(currentView.peek()).toBe("list"); 152 + cycleView(); 153 + expect(currentView.peek()).toBe("board"); 154 + cycleView(); 155 + expect(currentView.peek()).toBe("calendar"); 156 + cycleView(); 157 + expect(currentView.peek()).toBe("list"); 158 + }); 159 + }); 160 + 161 + // --- Toggle complete --- 162 + describe("toggleComplete", () => { 163 + it("toggles the completed flag and rewrites file", () => { 164 + selectedIndex.set(0); 165 + const r = selectedReminder.get()!; 166 + const wasDone = r.completed; 167 + toggleComplete(); 168 + loadReminders(); 169 + const updated = reminders.peek().find(x => x.id === r.id); 170 + expect(updated!.completed).toBe(!wasDone); 171 + }); 172 + }); 173 + 174 + // --- Delete reminder --- 175 + describe("deleteReminder", () => { 176 + it("removes the reminder file", () => { 177 + const before = reminders.peek().length; 178 + selectedIndex.set(0); 179 + const r = selectedReminder.get()!; 180 + deleteReminder(); 181 + expect(reminders.peek().length).toBe(before - 1); 182 + expect(fs.existsSync(r.filePath)).toBe(false); 183 + }); 184 + }); 185 + 186 + // --- Create reminder --- 187 + describe("createReminder", () => { 188 + it("creates a new reminder file", () => { 189 + const before = reminders.peek().length; 190 + createReminder("Test reminder", "2024-12-25", "low", ["test"], "Body text"); 191 + expect(reminders.peek().length).toBe(before + 1); 192 + const r = reminders.peek().find(x => x.title === "Test reminder"); 193 + expect(r).toBeDefined(); 194 + expect(r!.priority).toBe("low"); 195 + }); 196 + }); 197 + 198 + // --- Serialize --- 199 + describe("serializeReminder", () => { 200 + it("produces valid markdown with frontmatter", () => { 201 + const r: Reminder = { 202 + id: "test", title: "Test", body: "Body", 203 + due: "2024-03-25", priority: "high", completed: false, 204 + tags: ["a", "b"], created: "2024-03-20T10:00:00Z", 205 + filePath: "/tmp/test.md", 206 + }; 207 + const md = serializeReminder(r); 208 + expect(md).toContain("---"); 209 + expect(md).toContain("due: 2024-03-25"); 210 + expect(md).toContain("priority: high"); 211 + expect(md).toContain("# Test"); 212 + expect(md).toContain("Body"); 213 + expect(md).toContain("tags: [a, b]"); 214 + }); 215 + }); 216 + 217 + // --- List view rendering --- 218 + describe("list view", () => { 219 + it("renders without crashing", () => { 220 + const ctx = testCtx(); 221 + const nodes = renderListView(ctx); 222 + expect(nodes.length).toBeGreaterThan(0); 223 + }); 224 + });
+275
demos/reminders/router.ts
··· 1 + // Simple router for the reminders demo: view switching + overlay management 2 + import * as fs from "node:fs"; 3 + import { signal, batch } from "../../src/tui/index.ts"; 4 + import type { KeyEvent, ScreenContext } from "../../src/tui/index.ts"; 5 + import { 6 + cycleTheme, cycleView, currentView, 7 + moveUp, moveDown, toggleComplete, deleteReminder, 8 + selectedReminder, boardColumn, boardScroll, boardColumns, 9 + calendarMonth, calendarYear, calendarSelectedDay, 10 + createReminder, loadReminders, serializeReminder, 11 + } from "./state.ts"; 12 + import { scrollUp, scrollDown, updateScrollRegion } from "../../src/tui/index.ts"; 13 + 14 + // --- Overlay state --- 15 + export type OverlayType = "new" | "edit" | "confirm-delete" | null; 16 + export const activeOverlay = signal<OverlayType>(null); 17 + 18 + // --- Form state --- 19 + export const formTitle = signal(""); 20 + export const formDue = signal(""); 21 + export const formPriority = signal<"low" | "medium" | "high">("medium"); 22 + export const formTags = signal(""); 23 + export const formBody = signal(""); 24 + export const formField = signal(0); // which field is focused 25 + 26 + const FIELD_COUNT = 5; 27 + const PRIORITY_ORDER: Array<"low" | "medium" | "high"> = ["low", "medium", "high"]; 28 + 29 + export function openNewOverlay(): void { 30 + batch(() => { 31 + formTitle.set(""); 32 + formDue.set(new Date().toISOString().split("T")[0]); 33 + formPriority.set("medium"); 34 + formTags.set(""); 35 + formBody.set(""); 36 + formField.set(0); 37 + activeOverlay.set("new"); 38 + }); 39 + } 40 + 41 + export function openEditOverlay(): void { 42 + const r = selectedReminder.get(); 43 + if (!r) return; 44 + batch(() => { 45 + formTitle.set(r.title); 46 + formDue.set(r.due); 47 + formPriority.set(r.priority); 48 + formTags.set(r.tags.join(", ")); 49 + formBody.set(r.body); 50 + formField.set(0); 51 + activeOverlay.set("edit"); 52 + }); 53 + } 54 + 55 + export function openDeleteOverlay(): void { 56 + if (!selectedReminder.get()) return; 57 + activeOverlay.set("confirm-delete"); 58 + } 59 + 60 + export function closeOverlay(): void { 61 + activeOverlay.set(null); 62 + } 63 + 64 + // --- Key handling --- 65 + export function handleGlobalKey(key: KeyEvent, ctx: ScreenContext): boolean { 66 + // Ctrl+C always quits 67 + if (key.name === "c" && key.ctrl) return false; 68 + if (key.char === "q") return false; 69 + 70 + const overlay = activeOverlay.peek(); 71 + 72 + // Handle overlay keys 73 + if (overlay === "confirm-delete") { 74 + if (key.char === "y" || key.char === "Y") { 75 + deleteReminder(); 76 + closeOverlay(); 77 + return true; 78 + } 79 + if (key.char === "n" || key.char === "N" || key.name === "escape") { 80 + closeOverlay(); 81 + return true; 82 + } 83 + return true; 84 + } 85 + 86 + if (overlay === "new" || overlay === "edit") { 87 + return handleFormKey(key); 88 + } 89 + 90 + // Global keys 91 + if (key.char === "T") { cycleTheme(); return true; } 92 + if (key.char === "v") { cycleView(); return true; } 93 + if (key.char === "n") { openNewOverlay(); return true; } 94 + if (key.name === "escape") { closeOverlay(); return true; } 95 + 96 + // View-specific keys 97 + const view = currentView.peek(); 98 + if (view === "list") return handleListKey(key); 99 + if (view === "board") return handleBoardKey(key); 100 + if (view === "calendar") return handleCalendarKey(key); 101 + 102 + return true; 103 + } 104 + 105 + function handleListKey(key: KeyEvent): boolean { 106 + if (key.name === "up") { moveUp(); return true; } 107 + if (key.name === "down") { moveDown(); return true; } 108 + if (key.char === " ") { toggleComplete(); return true; } 109 + if (key.char === "e") { openEditOverlay(); return true; } 110 + if (key.char === "d") { openDeleteOverlay(); return true; } 111 + if (key.name === "return") { openEditOverlay(); return true; } 112 + return true; 113 + } 114 + 115 + function handleBoardKey(key: KeyEvent): boolean { 116 + const col = boardColumn.peek(); 117 + const cols = boardColumns.get(); 118 + const scrolls = boardScroll.peek(); 119 + 120 + if (key.name === "left") { 121 + if (col > 0) boardColumn.set(col - 1); 122 + return true; 123 + } 124 + if (key.name === "right") { 125 + if (col < 2) boardColumn.set(col + 1); 126 + return true; 127 + } 128 + if (key.name === "up") { 129 + const s = scrolls[col]; 130 + scrolls[col] = scrollUp(s); 131 + boardScroll.set([...scrolls]); 132 + return true; 133 + } 134 + if (key.name === "down") { 135 + const s = scrolls[col]; 136 + scrolls[col] = scrollDown(s); 137 + boardScroll.set([...scrolls]); 138 + return true; 139 + } 140 + if (key.char === " ") { toggleComplete(); return true; } 141 + return true; 142 + } 143 + 144 + function handleCalendarKey(key: KeyEvent): boolean { 145 + const day = calendarSelectedDay.peek(); 146 + const month = calendarMonth.peek(); 147 + const year = calendarYear.peek(); 148 + const daysInMonth = new Date(year, month + 1, 0).getDate(); 149 + 150 + if (key.name === "left") { 151 + if (day > 1) calendarSelectedDay.set(day - 1); 152 + return true; 153 + } 154 + if (key.name === "right") { 155 + if (day < daysInMonth) calendarSelectedDay.set(day + 1); 156 + return true; 157 + } 158 + if (key.name === "up") { 159 + if (day > 7) calendarSelectedDay.set(day - 7); 160 + return true; 161 + } 162 + if (key.name === "down") { 163 + if (day + 7 <= daysInMonth) calendarSelectedDay.set(day + 7); 164 + return true; 165 + } 166 + // Previous/next month 167 + if (key.char === "[") { 168 + if (month === 0) { calendarMonth.set(11); calendarYear.set(year - 1); } 169 + else calendarMonth.set(month - 1); 170 + calendarSelectedDay.set(1); 171 + return true; 172 + } 173 + if (key.char === "]") { 174 + if (month === 11) { calendarMonth.set(0); calendarYear.set(year + 1); } 175 + else calendarMonth.set(month + 1); 176 + calendarSelectedDay.set(1); 177 + return true; 178 + } 179 + return true; 180 + } 181 + 182 + // --- Form key handling --- 183 + function handleFormKey(key: KeyEvent): boolean { 184 + if (key.name === "escape") { 185 + closeOverlay(); 186 + return true; 187 + } 188 + 189 + const field = formField.peek(); 190 + 191 + // Tab / Shift+Tab to switch fields 192 + if (key.name === "tab") { 193 + formField.set((field + 1) % FIELD_COUNT); 194 + return true; 195 + } 196 + 197 + // Enter = save 198 + if (key.name === "return") { 199 + const title = formTitle.peek(); 200 + if (title.length > 0) { 201 + const isEdit = activeOverlay.peek() === "edit"; 202 + if (isEdit) { 203 + const r = selectedReminder.get(); 204 + if (r) { 205 + r.title = title; 206 + r.due = formDue.peek(); 207 + r.priority = formPriority.peek(); 208 + r.tags = formTags.peek().split(",").map(t => t.trim()).filter(Boolean); 209 + r.body = formBody.peek(); 210 + fs.writeFileSync(r.filePath, serializeReminder(r)); 211 + loadReminders(); 212 + } 213 + } else { 214 + createReminder( 215 + title, 216 + formDue.peek(), 217 + formPriority.peek(), 218 + formTags.peek().split(",").map(t => t.trim()).filter(Boolean), 219 + formBody.peek(), 220 + ); 221 + } 222 + closeOverlay(); 223 + } 224 + return true; 225 + } 226 + 227 + // Field-specific editing 228 + if (field === 2) { 229 + // Priority: left/right to cycle 230 + if (key.name === "left" || key.name === "right") { 231 + const idx = PRIORITY_ORDER.indexOf(formPriority.peek()); 232 + const dir = key.name === "right" ? 1 : -1; 233 + const newIdx = Math.max(0, Math.min(PRIORITY_ORDER.length - 1, idx + dir)); 234 + formPriority.set(PRIORITY_ORDER[newIdx]); 235 + return true; 236 + } 237 + } 238 + 239 + // Text input for all fields 240 + if (key.name === "backspace") { 241 + const current = getFieldValue(field); 242 + if (current.length > 0) { 243 + setFieldValue(field, current.slice(0, -1)); 244 + } 245 + return true; 246 + } 247 + 248 + if (key.char && !key.ctrl && !key.alt) { 249 + const current = getFieldValue(field); 250 + setFieldValue(field, current + key.char); 251 + return true; 252 + } 253 + 254 + return true; 255 + } 256 + 257 + function getFieldValue(field: number): string { 258 + switch (field) { 259 + case 0: return formTitle.peek(); 260 + case 1: return formDue.peek(); 261 + case 2: return formPriority.peek(); 262 + case 3: return formTags.peek(); 263 + case 4: return formBody.peek(); 264 + default: return ""; 265 + } 266 + } 267 + 268 + function setFieldValue(field: number, value: string): void { 269 + switch (field) { 270 + case 0: formTitle.set(value); break; 271 + case 1: formDue.set(value); break; 272 + case 3: formTags.set(value); break; 273 + case 4: formBody.set(value); break; 274 + } 275 + }
+53
demos/reminders/screens/board-view.ts
··· 1 + // Board view: kanban columns (todo / in-progress / done) 2 + import { 3 + text, column, hstack, panel, selectable, checkbox, 4 + statusBar, footer, 5 + type UINode, type ScreenContext, 6 + } from "../../../src/tui/index.ts"; 7 + import { updateScrollRegion } from "../../../src/tui/index.ts"; 8 + import { 9 + boardColumns, boardColumn, boardScroll, currentView, dataDir, reminders, 10 + type Reminder, 11 + } from "../state.ts"; 12 + 13 + const COLUMN_TITLES = ["Todo", "In Progress", "Done"]; 14 + 15 + function renderCard(r: Reminder, _index: number, selected: boolean): UINode[] { 16 + const sel = selected ? "\u25b8 " : " "; 17 + return [ 18 + text(sel, selected ? "accent" : "muted"), 19 + checkbox(r.completed, r.completed ? "ok" : "muted"), 20 + text(" " + r.title, selected ? "primary" : "secondary", { bold: selected, truncate: true }), 21 + ]; 22 + } 23 + 24 + export function renderBoardView(ctx: ScreenContext): UINode[] { 25 + const cols = boardColumns.get(); 26 + const activeCol = boardColumn.get(); 27 + const scrolls = boardScroll.get(); 28 + const all = reminders.get(); 29 + const viewport = Math.max(1, ctx.rows - 4); 30 + 31 + const columnNodes = cols.map((items, i) => { 32 + const region = updateScrollRegion( 33 + { ...scrolls[i], totalItems: items.length }, 34 + items.length, 35 + viewport, 36 + ); 37 + const isActive = i === activeCol; 38 + return column({ flex: true }, [ 39 + panel(COLUMN_TITLES[i] + ` (${items.length})`, [ 40 + items.length > 0 41 + ? selectable(region, items, (r, idx, sel) => 42 + renderCard(r, idx, isActive && sel)) 43 + : text("(empty)", "muted"), 44 + ]), 45 + ]); 46 + }); 47 + 48 + return [ 49 + statusBar("Reminders", `${dataDir.get()} \u2502 board \u2502 ${all.length} items`), 50 + hstack({ gap: 0 }, columnNodes as any), 51 + footer("v view \u2190\u2192 column \u2191\u2193 nav space toggle n new T theme q quit"), 52 + ]; 53 + }
+83
demos/reminders/screens/calendar-view.ts
··· 1 + // Calendar view: month grid with dots on days with reminders 2 + import { 3 + text, row, panel, canvas, scrollable, 4 + statusBar, footer, 5 + type UINode, type ScreenContext, 6 + } from "../../../src/tui/index.ts"; 7 + import { 8 + calendarMonth, calendarYear, calendarSelectedDay, 9 + calendarData, currentView, dataDir, reminders, 10 + } from "../state.ts"; 11 + 12 + const MONTH_NAMES = [ 13 + "January", "February", "March", "April", "May", "June", 14 + "July", "August", "September", "October", "November", "December", 15 + ]; 16 + const DAY_HEADERS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; 17 + 18 + export function renderCalendarView(ctx: ScreenContext): UINode[] { 19 + const month = calendarMonth.get(); 20 + const year = calendarYear.get(); 21 + const selectedDay = calendarSelectedDay.get(); 22 + const counts = calendarData.get(); 23 + const all = reminders.get(); 24 + 25 + const daysInMonth = new Date(year, month + 1, 0).getDate(); 26 + const firstDow = new Date(year, month, 1).getDay(); 27 + 28 + // Reminders for selected day 29 + const dayReminders = all.filter(r => { 30 + if (!r.due) return false; 31 + const d = new Date(r.due + "T00:00:00"); 32 + return d.getDate() === selectedDay && d.getMonth() === month && d.getFullYear() === year; 33 + }); 34 + 35 + return [ 36 + statusBar("Reminders", `${dataDir.get()} \u2502 calendar \u2502 ${MONTH_NAMES[month]} ${year}`), 37 + panel(`${MONTH_NAMES[month]} ${year}`, [ 38 + canvas((ctx) => { 39 + const cellW = 4; 40 + // Header 41 + for (let d = 0; d < 7; d++) { 42 + ctx.write(d * cellW, 0, DAY_HEADERS[d], "accent", undefined, true); 43 + } 44 + // Days 45 + let row = 1; 46 + let col = firstDow; 47 + for (let day = 1; day <= daysInMonth; day++) { 48 + const x = col * cellW; 49 + const y = row; 50 + const isSelected = day === selectedDay; 51 + const hasReminders = (counts.get(day) ?? 0) > 0; 52 + 53 + if (isSelected) { 54 + ctx.write(x, y, String(day).padStart(2), "accent", "accent", true); 55 + } else if (hasReminders) { 56 + ctx.write(x, y, String(day).padStart(2), "ok"); 57 + } else { 58 + ctx.write(x, y, String(day).padStart(2), "primary"); 59 + } 60 + 61 + if (hasReminders && !isSelected) { 62 + ctx.set(x + 2, y, "\u2022", "info"); 63 + } 64 + 65 + col++; 66 + if (col > 6) { col = 0; row++; } 67 + } 68 + }, { height: 8 }), 69 + scrollable( 70 + [ 71 + `${MONTH_NAMES[month]} ${selectedDay}: ${dayReminders.length} reminder(s)`, 72 + ...dayReminders.map(r => 73 + ` ${r.completed ? "\u2713" : "\u25cb"} ${r.title}` 74 + ), 75 + ], 76 + (item, i) => i === 0 77 + ? [text(item, "accent", { bold: true })] 78 + : [text(item, item.includes("\u2713") ? "muted" : "primary")], 79 + ), 80 + ]), 81 + footer("v view \u2190\u2192 day \u2191\u2193 week [ ] month n new T theme q quit"), 82 + ]; 83 + }
+22
demos/reminders/screens/confirm-delete.ts
··· 1 + // Confirm delete overlay 2 + import { 3 + overlay, text, separator, 4 + type UINode, type ScreenContext, 5 + } from "../../../src/tui/index.ts"; 6 + import { selectedReminder } from "../state.ts"; 7 + 8 + export const confirmDeleteOverlay = overlay({ 9 + id: "confirm-delete", 10 + title: "Confirm Delete", 11 + width: (cols) => Math.min(50, cols - 8), 12 + height: 8, 13 + render(_ctx: ScreenContext): UINode[] { 14 + const r = selectedReminder.get(); 15 + const name = r?.title ?? "this reminder"; 16 + return [ 17 + text(`Delete "${name}"?`, "warn", { bold: true }), 18 + separator(), 19 + text("y = yes, n = no", "muted"), 20 + ]; 21 + }, 22 + });
+48
demos/reminders/screens/list-view.ts
··· 1 + // List view: reminders grouped by due date 2 + import { 3 + text, row, column, hstack, panel, groupedSelectable, checkbox, 4 + statusBar, footer, 5 + type UINode, type ScreenContext, 6 + } from "../../../src/tui/index.ts"; 7 + import { updateScrollRegion } from "../../../src/tui/index.ts"; 8 + import { 9 + groupedByDate, selectedIndex, listScroll, flatReminders, 10 + currentView, dataDir, type Reminder, 11 + } from "../state.ts"; 12 + 13 + function priorityColor(p: string): "error" | "warn" | "ok" { 14 + if (p === "high") return "error"; 15 + if (p === "medium") return "warn"; 16 + return "ok"; 17 + } 18 + 19 + function renderItem(r: Reminder, _index: number, selected: boolean): UINode[] { 20 + const sel = selected ? "\u25b8 " : " "; 21 + const prio = r.priority === "high" ? "!" : r.priority === "medium" ? "\u2022" : " "; 22 + return [ 23 + text(sel, selected ? "accent" : "muted"), 24 + checkbox(r.completed, r.completed ? "ok" : "muted"), 25 + text(" " + prio + " ", priorityColor(r.priority)), 26 + text(r.title, selected ? "primary" : "secondary", { bold: selected, truncate: true }), 27 + text(" " + r.due, "muted"), 28 + ]; 29 + } 30 + 31 + export function renderListView(ctx: ScreenContext): UINode[] { 32 + const groups = groupedByDate.get(); 33 + const all = flatReminders.get(); 34 + const viewport = Math.max(1, ctx.rows - 4); 35 + const region = updateScrollRegion( 36 + { ...listScroll.get(), selectedIndex: selectedIndex.get(), totalItems: all.length }, 37 + all.length, 38 + viewport, 39 + ); 40 + 41 + return [ 42 + statusBar("Reminders", `${dataDir.get()} \u2502 ${currentView.get()} \u2502 ${all.length} items`), 43 + panel("Reminders", [ 44 + groupedSelectable(region, groups, renderItem), 45 + ]), 46 + footer("v view \u2191\u2193 nav space toggle n new e edit d delete T theme q quit"), 47 + ]; 48 + }
+57
demos/reminders/screens/new-reminder.ts
··· 1 + // New/Edit reminder overlay 2 + import { 3 + overlay, text, row, separator, 4 + type KeyEvent, type ScreenContext, type UINode, 5 + } from "../../../src/tui/index.ts"; 6 + import { 7 + formTitle, formDue, formPriority, formBody, formTags, formField, 8 + activeOverlay, 9 + } from "../router.ts"; 10 + 11 + function fieldLabel(idx: number, label: string, value: string, active: boolean): UINode[] { 12 + const indicator = active ? "\u25b8 " : " "; 13 + const color = active ? "accent" : "muted"; 14 + const display = value + (active ? "\u2588" : ""); 15 + return [ 16 + row( 17 + text(indicator + label + ": ", color, { bold: active }), 18 + text(display, active ? "primary" : "secondary"), 19 + ), 20 + ]; 21 + } 22 + 23 + function renderFormContent(): UINode[] { 24 + const field = formField.get(); 25 + const isEdit = activeOverlay.get() === "edit"; 26 + 27 + return [ 28 + text(isEdit ? "Edit Reminder" : "New Reminder", "accent", { bold: true }), 29 + separator(), 30 + ...fieldLabel(0, "Title", formTitle.get(), field === 0), 31 + ...fieldLabel(1, "Due", formDue.get(), field === 1), 32 + row( 33 + text(field === 2 ? "\u25b8 " : " ", field === 2 ? "accent" : "muted"), 34 + text("Priority: ", field === 2 ? "accent" : "muted", { bold: field === 2 }), 35 + text("\u25c0 ", "muted"), 36 + text(formPriority.get(), formPriority.get() === "high" ? "error" : formPriority.get() === "medium" ? "warn" : "ok", { bold: true }), 37 + text(" \u25b6", "muted"), 38 + ), 39 + ...fieldLabel(3, "Tags", formTags.get(), field === 3), 40 + ...fieldLabel(4, "Notes", formBody.get(), field === 4), 41 + separator(), 42 + text("tab next \u2190\u2192 priority enter save esc cancel", "muted"), 43 + ]; 44 + } 45 + 46 + export const newReminderOverlay = overlay({ 47 + id: "new-reminder", 48 + title: "New Reminder", 49 + width: (cols) => Math.min(60, cols - 8), 50 + height: (rows) => Math.min(16, rows - 4), 51 + render(_ctx: ScreenContext): UINode[] { 52 + return renderFormContent(); 53 + }, 54 + handleKey(_key: KeyEvent, _ctx: ScreenContext): boolean { 55 + return true; // All keys handled by router's handleFormKey 56 + }, 57 + });
+381
demos/reminders/state.ts
··· 1 + // Reminders state: signals, file I/O, parsing, seeding 2 + import * as fs from "node:fs"; 3 + import * as path from "node:path"; 4 + import { 5 + signal, computed, batch, 6 + createScrollRegion, updateScrollRegion, scrollUp, scrollDown, 7 + themes, 8 + type ScrollRegion, type Theme, type BoxStyle, 9 + } from "../../src/tui/index.ts"; 10 + 11 + // --- Types --- 12 + export interface Reminder { 13 + id: string; 14 + title: string; 15 + body: string; 16 + due: string; // YYYY-MM-DD 17 + priority: "low" | "medium" | "high"; 18 + completed: boolean; 19 + tags: string[]; 20 + created: string; // ISO 21 + filePath: string; 22 + } 23 + 24 + export interface ReminderGroup { 25 + title: string; 26 + items: Reminder[]; 27 + } 28 + 29 + // --- Theme --- 30 + const themeNames = Object.keys(themes); 31 + export const themeIndex = signal(0); 32 + export const currentTheme = computed<Theme>(() => themes[themeNames[themeIndex.get()]] as Theme); 33 + export const boxStyle = signal<BoxStyle>("rounded"); 34 + 35 + export function cycleTheme(): void { 36 + themeIndex.set((themeIndex.peek() + 1) % themeNames.length); 37 + } 38 + 39 + // --- Data directory --- 40 + export const dataDir = signal(`/tmp/reminders-demo-${process.pid}/`); 41 + 42 + // --- Signals --- 43 + export const reminders = signal<Reminder[]>([]); 44 + export const selectedIndex = signal(0); 45 + export const listScroll = signal<ScrollRegion>(createScrollRegion(0, 20)); 46 + 47 + // --- View switching --- 48 + export type ViewType = "list" | "board" | "calendar"; 49 + const viewOrder: ViewType[] = ["list", "board", "calendar"]; 50 + export const currentView = signal<ViewType>("list"); 51 + export function cycleView(): void { 52 + const idx = viewOrder.indexOf(currentView.peek()); 53 + currentView.set(viewOrder[(idx + 1) % viewOrder.length]); 54 + selectedIndex.set(0); 55 + } 56 + 57 + // --- Board state --- 58 + export const boardColumn = signal(0); // 0=todo, 1=in-progress, 2=done 59 + export const boardScroll = signal<ScrollRegion[]>([ 60 + createScrollRegion(0, 20), 61 + createScrollRegion(0, 20), 62 + createScrollRegion(0, 20), 63 + ]); 64 + 65 + // --- Calendar state --- 66 + export const calendarMonth = signal(new Date().getMonth()); // 0-11 67 + export const calendarYear = signal(new Date().getFullYear()); 68 + export const calendarSelectedDay = signal(new Date().getDate()); 69 + 70 + // --- Frontmatter parsing --- 71 + export function parseFrontmatter(content: string): { meta: Record<string, string>; body: string } { 72 + const lines = content.split("\n"); 73 + if (lines[0] !== "---") return { meta: {}, body: content }; 74 + 75 + let endIdx = -1; 76 + for (let i = 1; i < lines.length; i++) { 77 + if (lines[i] === "---") { 78 + endIdx = i; 79 + break; 80 + } 81 + } 82 + if (endIdx === -1) return { meta: {}, body: content }; 83 + 84 + const meta: Record<string, string> = {}; 85 + for (let i = 1; i < endIdx; i++) { 86 + const line = lines[i]; 87 + const colonIdx = line.indexOf(":"); 88 + if (colonIdx === -1) continue; 89 + const key = line.slice(0, colonIdx).trim(); 90 + let value = line.slice(colonIdx + 1).trim(); 91 + meta[key] = value; 92 + } 93 + 94 + const body = lines.slice(endIdx + 1).join("\n").trim(); 95 + return { meta, body }; 96 + } 97 + 98 + function parseTags(raw: string): string[] { 99 + // Parse "[tag1, tag2]" format 100 + const trimmed = raw.replace(/^\[|\]$/g, "").trim(); 101 + if (!trimmed) return []; 102 + return trimmed.split(",").map(t => t.trim()); 103 + } 104 + 105 + export function parseReminderFile(filePath: string): Reminder | null { 106 + try { 107 + const content = fs.readFileSync(filePath, "utf-8"); 108 + const { meta, body } = parseFrontmatter(content); 109 + const title = body.split("\n")[0]?.replace(/^#\s*/, "") ?? "Untitled"; 110 + const bodyText = body.split("\n").slice(1).join("\n").trim(); 111 + 112 + return { 113 + id: path.basename(filePath, ".md"), 114 + title, 115 + body: bodyText, 116 + due: meta.due ?? "", 117 + priority: (meta.priority as Reminder["priority"]) ?? "medium", 118 + completed: meta.completed === "true", 119 + tags: meta.tags ? parseTags(meta.tags) : [], 120 + created: meta.created ?? new Date().toISOString(), 121 + filePath, 122 + }; 123 + } catch { 124 + return null; 125 + } 126 + } 127 + 128 + // --- Serialize to markdown --- 129 + export function serializeReminder(r: Reminder): string { 130 + const lines = [ 131 + "---", 132 + `due: ${r.due}`, 133 + `priority: ${r.priority}`, 134 + `completed: ${r.completed}`, 135 + `tags: [${r.tags.join(", ")}]`, 136 + `created: ${r.created}`, 137 + "---", 138 + `# ${r.title}`, 139 + "", 140 + r.body, 141 + ]; 142 + return lines.join("\n") + "\n"; 143 + } 144 + 145 + // --- Load all reminders from disk --- 146 + export function loadReminders(): void { 147 + const dir = dataDir.peek(); 148 + if (!fs.existsSync(dir)) return; 149 + const files = fs.readdirSync(dir).filter(f => f.endsWith(".md")).sort(); 150 + const parsed: Reminder[] = []; 151 + for (const file of files) { 152 + const r = parseReminderFile(path.join(dir, file)); 153 + if (r) parsed.push(r); 154 + } 155 + reminders.set(parsed); 156 + listScroll.set(updateScrollRegion(listScroll.peek(), totalSelectableItems(), 20)); 157 + } 158 + 159 + // --- Computed groupings --- 160 + function dateLabel(due: string): string { 161 + if (!due) return "No Date"; 162 + const today = new Date(); 163 + today.setHours(0, 0, 0, 0); 164 + const dueDate = new Date(due + "T00:00:00"); 165 + const diff = Math.floor((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); 166 + 167 + if (diff < 0) return "Overdue"; 168 + if (diff === 0) return "Today"; 169 + if (diff === 1) return "Tomorrow"; 170 + if (diff <= 7) return "This Week"; 171 + return "Later"; 172 + } 173 + 174 + export const groupedByDate = computed<ReminderGroup[]>(() => { 175 + const all = reminders.get(); 176 + const active = all.filter(r => !r.completed); 177 + const completed = all.filter(r => r.completed); 178 + 179 + const groups: Record<string, Reminder[]> = {}; 180 + const order = ["Overdue", "Today", "Tomorrow", "This Week", "Later", "No Date"]; 181 + 182 + for (const r of active) { 183 + const label = dateLabel(r.due); 184 + if (!groups[label]) groups[label] = []; 185 + groups[label].push(r); 186 + } 187 + 188 + const result: ReminderGroup[] = []; 189 + for (const key of order) { 190 + if (groups[key] && groups[key].length > 0) { 191 + result.push({ title: key, items: groups[key] }); 192 + } 193 + } 194 + if (completed.length > 0) { 195 + result.push({ title: "Completed", items: completed }); 196 + } 197 + return result; 198 + }); 199 + 200 + export const boardColumns = computed<[Reminder[], Reminder[], Reminder[]]>(() => { 201 + const all = reminders.get(); 202 + const todo = all.filter(r => !r.completed && r.priority !== "high"); 203 + const inProgress = all.filter(r => !r.completed && r.priority === "high"); 204 + const done = all.filter(r => r.completed); 205 + return [todo, inProgress, done]; 206 + }); 207 + 208 + export const calendarData = computed<Map<number, number>>(() => { 209 + const all = reminders.get(); 210 + const month = calendarMonth.get(); 211 + const year = calendarYear.get(); 212 + const counts = new Map<number, number>(); 213 + 214 + for (const r of all) { 215 + if (!r.due) continue; 216 + const d = new Date(r.due + "T00:00:00"); 217 + if (d.getMonth() === month && d.getFullYear() === year) { 218 + counts.set(d.getDate(), (counts.get(d.getDate()) ?? 0) + 1); 219 + } 220 + } 221 + return counts; 222 + }); 223 + 224 + // --- Flat list of reminders in grouped order (matches visual display order) --- 225 + export const flatReminders = computed<Reminder[]>(() => { 226 + const groups = groupedByDate.get(); 227 + const result: Reminder[] = []; 228 + for (const group of groups) { 229 + for (const item of group.items) { 230 + result.push(item); 231 + } 232 + } 233 + return result; 234 + }); 235 + 236 + // --- Count total selectable items across groups --- 237 + function totalSelectableItems(): number { 238 + return flatReminders.get().length; 239 + } 240 + 241 + export const selectedReminder = computed<Reminder | null>(() => { 242 + const flat = flatReminders.get(); 243 + const idx = selectedIndex.get(); 244 + return flat[idx] ?? null; 245 + }); 246 + 247 + // --- Seed data --- 248 + export function seedData(): void { 249 + const dir = dataDir.peek(); 250 + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); 251 + 252 + const today = new Date(); 253 + const fmt = (d: Date) => d.toISOString().split("T")[0]; 254 + const addDays = (d: Date, n: number) => new Date(d.getTime() + n * 86400000); 255 + 256 + const seeds: Omit<Reminder, "id" | "filePath">[] = [ 257 + { title: "Buy groceries", body: "Milk, eggs, bread, coffee", due: fmt(today), priority: "high", completed: false, tags: ["personal", "errands"], created: addDays(today, -3).toISOString() }, 258 + { title: "Review PR #847", body: "Check auth flow changes", due: fmt(today), priority: "high", completed: false, tags: ["work", "code"], created: addDays(today, -1).toISOString() }, 259 + { title: "Call dentist", body: "Schedule annual checkup", due: fmt(addDays(today, 1)), priority: "medium", completed: false, tags: ["personal", "health"], created: addDays(today, -5).toISOString() }, 260 + { title: "Write blog post", body: "TUI framework design decisions", due: fmt(addDays(today, 2)), priority: "low", completed: false, tags: ["work", "writing"], created: addDays(today, -7).toISOString() }, 261 + { title: "Deploy v2.1", body: "Stage, test, then production", due: fmt(addDays(today, 3)), priority: "high", completed: false, tags: ["work", "ops"], created: addDays(today, -2).toISOString() }, 262 + { title: "Plan team offsite", body: "Book venue, send invites", due: fmt(addDays(today, 5)), priority: "medium", completed: false, tags: ["work", "planning"], created: addDays(today, -10).toISOString() }, 263 + { title: "Update dependencies", body: "Run npm audit and update", due: fmt(addDays(today, 7)), priority: "low", completed: false, tags: ["work", "maintenance"], created: addDays(today, -4).toISOString() }, 264 + { title: "Read Designing Data Apps", body: "Chapters 5-8", due: fmt(addDays(today, 14)), priority: "low", completed: false, tags: ["personal", "learning"], created: addDays(today, -14).toISOString() }, 265 + { title: "Fix login bug", body: "Session tokens expire prematurely", due: fmt(addDays(today, -1)), priority: "high", completed: false, tags: ["work", "bug"], created: addDays(today, -6).toISOString() }, 266 + { title: "Prepare slides", body: "Q1 review presentation", due: fmt(addDays(today, -2)), priority: "medium", completed: false, tags: ["work", "presentation"], created: addDays(today, -8).toISOString() }, 267 + { title: "Backup photos", body: "Sync to cloud storage", due: fmt(addDays(today, 10)), priority: "low", completed: false, tags: ["personal"], created: addDays(today, -12).toISOString() }, 268 + { title: "Submit expense report", body: "March travel expenses", due: fmt(addDays(today, 4)), priority: "medium", completed: false, tags: ["work", "admin"], created: addDays(today, -3).toISOString() }, 269 + { title: "Set up CI pipeline", body: "GitHub Actions for the new repo", due: fmt(today), priority: "medium", completed: true, tags: ["work", "devops"], created: addDays(today, -15).toISOString() }, 270 + { title: "Order monitor stand", body: "The ergonomic one from the review", due: fmt(addDays(today, -5)), priority: "low", completed: true, tags: ["personal", "office"], created: addDays(today, -20).toISOString() }, 271 + { title: "Refactor auth module", body: "Extract middleware, add tests", due: fmt(addDays(today, -3)), priority: "high", completed: true, tags: ["work", "code"], created: addDays(today, -10).toISOString() }, 272 + ]; 273 + 274 + for (let i = 0; i < seeds.length; i++) { 275 + const s = seeds[i]; 276 + const id = `reminder-${String(i + 1).padStart(3, "0")}`; 277 + const r: Reminder = { ...s, id, filePath: path.join(dir, `${id}.md`) }; 278 + fs.writeFileSync(r.filePath, serializeReminder(r)); 279 + } 280 + } 281 + 282 + // --- File watcher --- 283 + let watchDebounce: ReturnType<typeof setTimeout> | null = null; 284 + let watcher: fs.FSWatcher | null = null; 285 + 286 + export function startWatching(): void { 287 + const dir = dataDir.peek(); 288 + try { 289 + watcher = fs.watch(dir, () => { 290 + if (watchDebounce) clearTimeout(watchDebounce); 291 + watchDebounce = setTimeout(() => loadReminders(), 100); 292 + }); 293 + } catch { 294 + // Directory may not exist yet 295 + } 296 + } 297 + 298 + export function stopWatching(): void { 299 + if (watcher) { 300 + watcher.close(); 301 + watcher = null; 302 + } 303 + if (watchDebounce) { 304 + clearTimeout(watchDebounce); 305 + watchDebounce = null; 306 + } 307 + } 308 + 309 + // --- Actions --- 310 + export function moveUp(): void { 311 + const idx = selectedIndex.peek(); 312 + if (idx <= 0) return; 313 + batch(() => { 314 + selectedIndex.set(idx - 1); 315 + listScroll.set(scrollUp(listScroll.peek())); 316 + }); 317 + } 318 + 319 + export function moveDown(): void { 320 + const idx = selectedIndex.peek(); 321 + const flat = flatReminders.get(); 322 + if (idx >= flat.length - 1) return; 323 + batch(() => { 324 + selectedIndex.set(idx + 1); 325 + listScroll.set(scrollDown(listScroll.peek())); 326 + }); 327 + } 328 + 329 + export function toggleComplete(): void { 330 + const r = selectedReminder.get(); 331 + if (!r) return; 332 + r.completed = !r.completed; 333 + fs.writeFileSync(r.filePath, serializeReminder(r)); 334 + loadReminders(); 335 + // Clamp selectedIndex to valid range after items shift between groups 336 + const flat = flatReminders.get(); 337 + if (selectedIndex.peek() >= flat.length) { 338 + selectedIndex.set(Math.max(0, flat.length - 1)); 339 + } 340 + } 341 + 342 + export function deleteReminder(): void { 343 + const r = selectedReminder.get(); 344 + if (!r) return; 345 + try { 346 + fs.unlinkSync(r.filePath); 347 + } catch {} 348 + loadReminders(); 349 + const flat = flatReminders.get(); 350 + if (selectedIndex.peek() >= flat.length) { 351 + selectedIndex.set(Math.max(0, flat.length - 1)); 352 + } 353 + } 354 + 355 + export function createReminder(title: string, due: string, priority: Reminder["priority"], tags: string[], body: string): void { 356 + const dir = dataDir.peek(); 357 + const id = `reminder-${Date.now()}`; 358 + const r: Reminder = { 359 + id, 360 + title, 361 + body, 362 + due, 363 + priority, 364 + completed: false, 365 + tags, 366 + created: new Date().toISOString(), 367 + filePath: path.join(dir, `${id}.md`), 368 + }; 369 + fs.writeFileSync(r.filePath, serializeReminder(r)); 370 + loadReminders(); 371 + } 372 + 373 + // --- Init --- 374 + export function init(): void { 375 + const dir = dataDir.peek(); 376 + if (!fs.existsSync(dir)) { 377 + seedData(); 378 + } 379 + loadReminders(); 380 + startWatching(); 381 + }
+28
demos/run
··· 1 + #!/bin/sh 2 + # Run a demo app: ./demos/run file-browser [path] 3 + # ./demos/run reminders 4 + # ./demos/run agent-teams 5 + set -e 6 + 7 + demo="$1" 8 + shift 2>/dev/null || true 9 + 10 + if [ -z "$demo" ]; then 11 + echo "Usage: ./demos/run <demo-name> [args...]" 12 + echo "" 13 + 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)" 17 + exit 1 18 + fi 19 + 20 + script="$(dirname "$0")/$demo/main.ts" 21 + 22 + if [ ! -f "$script" ]; then 23 + echo "Unknown demo: $demo" 24 + echo "Available: file-browser, reminders, agent-teams" 25 + exit 1 26 + fi 27 + 28 + exec node --experimental-strip-types --no-warnings "$script" "$@"
+63
docs/testing.md
··· 295 295 await session.close(); 296 296 ``` 297 297 298 + ## Server-Mode Methods 299 + 300 + These methods are only available on sessions created with `Session.server()`. 301 + 302 + ### attach() 303 + 304 + Start receiving output from the server. Required after `Session.server()`: 305 + 306 + ```typescript 307 + const session = await Session.server("bash", ["--norc"]); 308 + await session.attach(); 309 + await session.waitForText("$"); 310 + ``` 311 + 312 + ### reconnect() 313 + 314 + Simulate a detach + reattach cycle. Destroys the connection, resets the 315 + terminal, and reconnects: 316 + 317 + ```typescript 318 + const session = await Session.server("bash", ["--norc"]); 319 + await session.attach(); 320 + await session.waitForText("$"); 321 + session.sendKeys("echo hello\n"); 322 + await session.waitForText("hello"); 323 + 324 + // Simulate disconnect and reconnect 325 + await session.reconnect(); 326 + // Screen state is replayed — "hello" should still be visible 327 + await session.waitForText("hello"); 328 + ``` 329 + 330 + ### resize(rows, cols) 331 + 332 + Resize the terminal dimensions: 333 + 334 + ```typescript 335 + session.resize(40, 120); 336 + ``` 337 + 338 + ### connectToExisting(session) 339 + 340 + Create a second client attached to the same server process: 341 + 342 + ```typescript 343 + const session1 = await Session.server("bash", ["--norc"]); 344 + await session1.attach(); 345 + 346 + const session2 = await Session.connectToExisting(session1); 347 + await session2.attach(); 348 + 349 + // Both clients see the same terminal output 350 + session1.sendKeys("echo shared\n"); 351 + await session2.waitForText("shared"); 352 + ``` 353 + 354 + ### Properties 355 + 356 + - `session.hasExited` — whether the process has exited (always `false` for spawn-mode) 357 + - `session.name` — the session name (server-mode only) 358 + - `session.server` — the underlying `PtyServer` instance (server-mode only) 359 + - `session.rows` / `session.cols` — current terminal dimensions 360 + 298 361 ## Running Tests 299 362 300 363 Use the `pty test` command (a thin vitest wrapper):
+1 -1
flake.nix
··· 29 29 30 30 # Generated from package-lock.json. 31 31 # Regenerate with: nix run nixpkgs#prefetch-npm-deps -- package-lock.json 32 - npmDepsHash = "sha256-DKmpmKWDnZxcTgzhIt2iFniRQLp1YPxxRHhEI/vZNaU="; 32 + npmDepsHash = "sha256-+65s9CLTJNlPt82HKOkngir4KZF1yCy96hderY0m2qQ="; 33 33 34 34 # node-pty has native code that needs these at build time 35 35 nativeBuildInputs = with pkgs; [ python3 pkg-config ];
+11
package-lock.json
··· 8 8 "name": "ptym", 9 9 "version": "0.1.0", 10 10 "dependencies": { 11 + "@preact/signals-core": "^1.14.0", 11 12 "@xterm/addon-serialize": "^0.14.0", 12 13 "@xterm/headless": "^6.0.0", 13 14 "node-pty": "^1.0.0" ··· 469 470 "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", 470 471 "dev": true, 471 472 "license": "MIT" 473 + }, 474 + "node_modules/@preact/signals-core": { 475 + "version": "1.14.0", 476 + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.0.tgz", 477 + "integrity": "sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ==", 478 + "license": "MIT", 479 + "funding": { 480 + "type": "opencollective", 481 + "url": "https://opencollective.com/preact" 482 + } 472 483 }, 473 484 "node_modules/@rollup/rollup-android-arm-eabi": { 474 485 "version": "4.59.0",
+3 -1
package.json
··· 7 7 "pty": "./bin/pty" 8 8 }, 9 9 "exports": { 10 - "./testing": "./src/testing/index.ts" 10 + "./testing": "./src/testing/index.ts", 11 + "./tui": "./src/tui/index.ts" 11 12 }, 12 13 "scripts": { 13 14 "prepare": "git config core.hooksPath githooks 2>/dev/null || true; chmod +x node_modules/node-pty/prebuilds/darwin-*/spawn-helper 2>/dev/null || true; sh scripts/update-nix-hash.sh 2>/dev/null || true", ··· 18 19 "verify-docs": "node scripts/verify-docs.ts" 19 20 }, 20 21 "dependencies": { 22 + "@preact/signals-core": "^1.14.0", 21 23 "@xterm/addon-serialize": "^0.14.0", 22 24 "@xterm/headless": "^6.0.0", 23 25 "node-pty": "^1.0.0"
+38
src/testing/session.ts
··· 49 49 50 50 // ── Factories ── 51 51 52 + /** 53 + * Spawn a process in a direct PTY. Use this for testing CLI tools, TUI apps, 54 + * or any process where you send input and check screen output. 55 + * 56 + * ```typescript 57 + * const session = Session.spawn("node", ["--experimental-strip-types", "my-app.ts"], { rows: 30, cols: 100 }); 58 + * await session.waitForText("Ready"); 59 + * session.press("ctrl+c"); 60 + * await session.close(); 61 + * ``` 62 + */ 52 63 static spawn( 53 64 command: string, 54 65 args: string[] = [], ··· 88 99 return new Session(terminal, serialize, backend, rows, cols); 89 100 } 90 101 102 + /** 103 + * Create a persistent session backed by a PtyServer. Use this when testing 104 + * detach/reattach behavior, multiple clients, or resize. Call `attach()` 105 + * after creation to start receiving output. 106 + */ 91 107 static async server( 92 108 command: string, 93 109 args: string[] = [], ··· 133 149 return session; 134 150 } 135 151 152 + /** 153 + * Create a second client connected to the same server as an existing session. 154 + * Use this to test multi-client scenarios (e.g., two terminals attached to 155 + * the same process). 156 + */ 136 157 static async connectToExisting( 137 158 existing: Session, 138 159 opts: { rows?: number; cols?: number } = {} ··· 171 192 172 193 // ── Properties ── 173 194 195 + /** Current terminal height in rows. */ 174 196 get rows(): number { 175 197 return this._rows; 176 198 } 177 199 200 + /** Current terminal width in columns. */ 178 201 get cols(): number { 179 202 return this._cols; 180 203 } 181 204 205 + /** Whether the process has exited. Server-mode only; always false for spawn-mode. */ 182 206 get hasExited(): boolean { 183 207 if (this.backend.kind === "server") { 184 208 return this.backend.exitCode !== null; ··· 204 228 205 229 // ── Input ── 206 230 231 + /** Send raw keystrokes to the process. Use for literal text or escape sequences. */ 207 232 sendKeys(keys: string): void { 208 233 if (this.backend.kind === "spawn") { 209 234 this.backend.ptyProcess.write(keys); ··· 212 237 } 213 238 } 214 239 240 + /** 241 + * Send a named key. Supports modifiers: `"ctrl+c"`, `"alt+x"`, `"shift+a"`. 242 + * See docs/testing.md for the full list of key names. 243 + */ 215 244 press(keyName: string): void { 216 245 this.sendKeys(resolveKey(keyName)); 217 246 } 218 247 248 + /** Send text to the process. Alias for `sendKeys()`. */ 219 249 type(text: string): void { 220 250 this.sendKeys(text); 221 251 } 222 252 223 253 // ── Screen ── 224 254 255 + /** Capture the current terminal state. Returns plain text lines, joined text, and ANSI output. */ 225 256 screenshot(): Screenshot { 226 257 return captureScreenshot(this.terminal, this.serialize); 227 258 } 228 259 229 260 // ── Waiting ── 230 261 262 + /** Poll until the terminal contains the given text. Returns the matching screenshot. */ 231 263 async waitForText(text: string, timeoutMs = 5000): Promise<Screenshot> { 232 264 const start = Date.now(); 233 265 while (Date.now() - start < timeoutMs) { ··· 241 273 ); 242 274 } 243 275 276 + /** Poll until the terminal no longer contains the given text. */ 244 277 async waitForAbsent(text: string, timeoutMs = 5000): Promise<Screenshot> { 245 278 const start = Date.now(); 246 279 while (Date.now() - start < timeoutMs) { ··· 254 287 ); 255 288 } 256 289 290 + /** Poll until a custom predicate returns true. The `description` is used in timeout error messages. */ 257 291 async waitFor( 258 292 predicate: (ss: Screenshot) => boolean, 259 293 timeoutMs = 5000, ··· 273 307 274 308 // ── Server-mode only ── 275 309 310 + /** Start receiving output from the server. Required after `Session.server()`. Server-mode only. */ 276 311 async attach(): Promise<void> { 277 312 if (this.backend.kind !== "server") { 278 313 throw new Error("attach() is only available in server mode"); ··· 289 324 await screenPromise; 290 325 } 291 326 327 + /** Simulate a detach + reattach cycle. Destroys the socket, resets the terminal, and reconnects. Server-mode only. */ 292 328 async reconnect(): Promise<void> { 293 329 if (this.backend.kind !== "server") { 294 330 throw new Error("reconnect() is only available in server mode"); ··· 300 336 await this.attach(); 301 337 } 302 338 339 + /** Resize the terminal. Server-mode only. */ 303 340 resize(rows: number, cols: number): void { 304 341 if (this.backend.kind !== "server") { 305 342 throw new Error("resize() is only available in server mode"); ··· 312 349 313 350 // ── Lifecycle ── 314 351 352 + /** Clean up the session. Kills the process (spawn) or destroys the socket and server (server). Always call this in `afterEach`. */ 315 353 async close(): Promise<void> { 316 354 if (this.backend.kind === "spawn") { 317 355 try {
+14 -3
src/testing/types.ts
··· 1 + /** Captured terminal state at a point in time. */ 1 2 export interface Screenshot { 2 - /** Plain text lines (trailing whitespace trimmed per line) */ 3 + /** Plain text lines. Trailing whitespace is trimmed per line. Trailing empty lines are removed. */ 3 4 lines: string[]; 4 - /** All lines joined with newline */ 5 + /** All lines joined with `"\n"`. Convenient for `.toContain()` assertions. */ 5 6 text: string; 6 - /** ANSI-serialized terminal state (includes escape codes) */ 7 + /** Full ANSI-serialized terminal state, including escape codes. Use to verify colors, bold, etc. */ 7 8 ansi: string; 8 9 } 9 10 11 + /** Options for `Session.spawn()`. */ 10 12 export interface SpawnOptions { 13 + /** Terminal height in rows. Default: 24. */ 11 14 rows?: number; 15 + /** Terminal width in columns. Default: 80. */ 12 16 cols?: number; 17 + /** Working directory for the spawned process. */ 13 18 cwd?: string; 19 + /** Extra environment variables, merged with `process.env`. */ 14 20 env?: Record<string, string>; 15 21 } 16 22 23 + /** Options for `Session.server()`. */ 17 24 export interface ServerOptions { 25 + /** Session name. Auto-generated if omitted. */ 18 26 name?: string; 27 + /** Terminal height in rows. Default: 24. */ 19 28 rows?: number; 29 + /** Terminal width in columns. Default: 80. */ 20 30 cols?: number; 31 + /** Working directory for the spawned process. */ 21 32 cwd?: string; 22 33 }
+36
src/tui/animation.ts
··· 1 + // Spinner animation: reference-counted timer driving a spinnerFrame signal 2 + 3 + import { signal, computed } from "./signals.ts"; 4 + 5 + const SPINNER_CHARS = "\u280b\u2819\u2839\u2838\u283c\u2834\u2826\u2827\u2807\u280f"; 6 + const SPINNER_INTERVAL = 80; 7 + 8 + const spinnerFrame = signal(0); 9 + let spinnerTimer: ReturnType<typeof setInterval> | null = null; 10 + let spinnerRefCount = 0; 11 + 12 + export const spinnerChar = computed(() => { 13 + const frame = spinnerFrame.get(); 14 + return SPINNER_CHARS[frame % SPINNER_CHARS.length]!; 15 + }); 16 + 17 + export function startSpinnerTimer(): void { 18 + spinnerRefCount++; 19 + if (spinnerTimer === null) { 20 + spinnerTimer = setInterval(() => { 21 + spinnerFrame.set(spinnerFrame.peek() + 1); 22 + }, SPINNER_INTERVAL); 23 + } 24 + } 25 + 26 + export function stopSpinnerTimer(): void { 27 + spinnerRefCount = Math.max(0, spinnerRefCount - 1); 28 + if (spinnerRefCount === 0 && spinnerTimer !== null) { 29 + clearInterval(spinnerTimer); 30 + spinnerTimer = null; 31 + } 32 + } 33 + 34 + export function isSpinnerRunning(): boolean { 35 + return spinnerTimer !== null; 36 + }
+368
src/tui/buffer.ts
··· 1 + // CellBuffer: ANSI string → cell grid, with diff-based rendering 2 + 3 + import { type Cell, emptyCell, cellsEqual } from "./types.ts"; 4 + import { charWidth } from "./colors.ts"; 5 + 6 + export class CellBuffer { 7 + rows: number; 8 + cols: number; 9 + cells: Cell[][]; 10 + 11 + constructor(rows: number, cols: number) { 12 + this.rows = rows; 13 + this.cols = cols; 14 + this.cells = []; 15 + for (let r = 0; r < rows; r++) { 16 + const row: Cell[] = []; 17 + for (let c = 0; c < cols; c++) { 18 + row.push(emptyCell()); 19 + } 20 + this.cells.push(row); 21 + } 22 + } 23 + 24 + clear(): void { 25 + for (let r = 0; r < this.rows; r++) { 26 + for (let c = 0; c < this.cols; c++) { 27 + this.cells[r][c] = emptyCell(); 28 + } 29 + } 30 + } 31 + 32 + getCell(row: number, col: number): Cell | undefined { 33 + return this.cells[row]?.[col]; 34 + } 35 + 36 + setCell(row: number, col: number, cell: Cell): void { 37 + if (row >= 0 && row < this.rows && col >= 0 && col < this.cols) { 38 + this.cells[row][col] = cell; 39 + } 40 + } 41 + 42 + /** Parse an ANSI string and write it into the buffer. 43 + * Coordinates are 1-based (matching terminal conventions from render.ts moveTo). 44 + */ 45 + writeAnsi(ansi: string): void { 46 + // Parser state 47 + let curRow = 0; // 0-based internal 48 + let curCol = 0; 49 + let fgColor: [number, number, number] | null = null; 50 + let bgColor: [number, number, number] | null = null; 51 + let isBold = false; 52 + let isDim = false; 53 + let isItalic = false; 54 + let isUnderline = false; 55 + 56 + let i = 0; 57 + while (i < ansi.length) { 58 + if (ansi[i] === "\x1b") { 59 + if (ansi[i + 1] === "[") { 60 + // CSI sequence — params may include private prefix chars like ? > < = 61 + i += 2; 62 + let params = ""; 63 + while (i < ansi.length && ((ansi[i] >= "0" && ansi[i] <= "9") || ansi[i] === ";" || ansi[i] === "?" || ansi[i] === ">" || ansi[i] === "<" || ansi[i] === "=" || ansi[i] === " ")) { 64 + params += ansi[i]; 65 + i++; 66 + } 67 + const cmd = ansi[i] ?? ""; 68 + i++; 69 + 70 + if (cmd === "m") { 71 + // SGR - colors/attributes 72 + const parts = params ? params.split(";").map(Number) : [0]; 73 + let j = 0; 74 + while (j < parts.length) { 75 + const p = parts[j]!; 76 + if (p === 0) { 77 + fgColor = null; bgColor = null; 78 + isBold = false; isDim = false; isItalic = false; isUnderline = false; 79 + } else if (p === 1) isBold = true; 80 + else if (p === 2) isDim = true; 81 + else if (p === 3) isItalic = true; 82 + else if (p === 4) isUnderline = true; 83 + else if (p === 22) { isBold = false; isDim = false; } 84 + else if (p === 23) isItalic = false; 85 + else if (p === 24) isUnderline = false; 86 + else if (p === 27) { /* reset inverse - ignore */ } 87 + else if (p === 39) fgColor = null; 88 + else if (p === 49) bgColor = null; 89 + else if (p === 38 && parts[j + 1] === 2) { 90 + fgColor = [parts[j + 2] ?? 0, parts[j + 3] ?? 0, parts[j + 4] ?? 0]; 91 + j += 4; 92 + } else if (p === 48 && parts[j + 1] === 2) { 93 + bgColor = [parts[j + 2] ?? 0, parts[j + 3] ?? 0, parts[j + 4] ?? 0]; 94 + j += 4; 95 + } else if (p === 38 && parts[j + 1] === 5) { 96 + fgColor = ansi256ToRgb(parts[j + 2] ?? 0); 97 + j += 2; 98 + } else if (p === 48 && parts[j + 1] === 5) { 99 + bgColor = ansi256ToRgb(parts[j + 2] ?? 0); 100 + j += 2; 101 + } else if (p >= 30 && p <= 37) fgColor = ansi16ToRgb(p - 30); 102 + else if (p >= 40 && p <= 47) bgColor = ansi16ToRgb(p - 40); 103 + else if (p >= 90 && p <= 97) fgColor = ansi16ToRgb(p - 90 + 8); 104 + else if (p >= 100 && p <= 107) bgColor = ansi16ToRgb(p - 100 + 8); 105 + j++; 106 + } 107 + } else if (cmd === "H") { 108 + // Cursor position: ESC[row;colH (1-based) 109 + const positions = params ? params.split(";").map(Number) : [1, 1]; 110 + curRow = (positions[0] ?? 1) - 1; 111 + curCol = (positions[1] ?? 1) - 1; 112 + } else if (cmd === "J") { 113 + // Clear screen - we handle this by clearing the buffer 114 + if (params === "" || params === "2") { 115 + this.clear(); 116 + curRow = 0; 117 + curCol = 0; 118 + } 119 + } else if (cmd === "K") { 120 + // Clear line from cursor to end 121 + for (let c = curCol; c < this.cols; c++) { 122 + this.cells[curRow]![c] = emptyCell(); 123 + } 124 + } 125 + // Skip other CSI commands (cursor show/hide, etc.) 126 + } else if (ansi[i + 1] === "]") { 127 + // OSC sequence - skip until ST 128 + i += 2; 129 + while (i < ansi.length && ansi[i] !== "\x07" && !(ansi[i] === "\x1b" && ansi[i + 1] === "\\")) i++; 130 + if (ansi[i] === "\x07") i++; 131 + else i += 2; 132 + } else if (ansi[i + 1] === "(" || ansi[i + 1] === ")") { 133 + // Character set designation - skip 134 + i += 3; 135 + } else { 136 + i += 2; // skip unknown escape 137 + } 138 + } else if (ansi[i] === "\n") { 139 + curRow++; 140 + curCol = 0; 141 + i++; 142 + } else if (ansi[i] === "\r") { 143 + curCol = 0; 144 + i++; 145 + } else { 146 + // Printable character — may be wide (2 cells) 147 + const ch = ansi[i]; 148 + const cw = charWidth(ch); 149 + if (curRow >= 0 && curRow < this.rows && curCol >= 0 && curCol < this.cols) { 150 + this.cells[curRow][curCol] = { 151 + char: ch, 152 + fg: fgColor ? [...fgColor] : null, 153 + bg: bgColor ? [...bgColor] : null, 154 + bold: isBold, 155 + dim: isDim, 156 + italic: isItalic, 157 + underline: isUnderline, 158 + }; 159 + // For wide characters, fill the next cell with an empty placeholder 160 + if (cw === 2 && curCol + 1 < this.cols) { 161 + this.cells[curRow][curCol + 1] = { 162 + char: "", 163 + fg: fgColor ? [...fgColor] : null, 164 + bg: bgColor ? [...bgColor] : null, 165 + bold: isBold, 166 + dim: isDim, 167 + italic: isItalic, 168 + underline: isUnderline, 169 + }; 170 + } 171 + } 172 + curCol += cw; 173 + i++; 174 + } 175 + } 176 + } 177 + 178 + clone(): CellBuffer { 179 + const buf = new CellBuffer(this.rows, this.cols); 180 + for (let r = 0; r < this.rows; r++) { 181 + for (let c = 0; c < this.cols; c++) { 182 + const cell = this.cells[r][c]; 183 + buf.cells[r][c] = { ...cell, fg: cell.fg ? [...cell.fg] : null, bg: cell.bg ? [...cell.bg] : null }; 184 + } 185 + } 186 + return buf; 187 + } 188 + } 189 + 190 + /** Diff two buffers and emit minimal ANSI to update from prev to next. 191 + * Uses DEC synchronized output (mode 2026) to prevent tearing. */ 192 + export function diff(prev: CellBuffer, next: CellBuffer): string { 193 + let out = "\x1b[?2026h"; // Begin synchronized update 194 + let lastRow = -1; 195 + let lastCol = -1; 196 + let lastFg: [number, number, number] | null = null; 197 + let lastBg: [number, number, number] | null = null; 198 + let lastBold = false; 199 + let lastDim = false; 200 + let lastItalic = false; 201 + let lastUnderline = false; 202 + let needsReset = true; 203 + 204 + for (let r = 0; r < next.rows; r++) { 205 + for (let c = 0; c < next.cols; c++) { 206 + const nc = next.cells[r][c]; 207 + 208 + // Skip wide-char placeholder cells 209 + if (nc.char === "") continue; 210 + 211 + const pc = prev.cells[r]?.[c]; 212 + 213 + if (pc && cellsEqual(pc, nc)) continue; 214 + 215 + // Move cursor if not adjacent 216 + if (r !== lastRow || c !== lastCol) { 217 + out += `\x1b[${r + 1};${c + 1}H`; 218 + } 219 + 220 + // Apply style changes 221 + const needReset = 222 + (nc.bold !== lastBold && !nc.bold) || 223 + (nc.dim !== lastDim && !nc.dim) || 224 + (nc.italic !== lastItalic && !nc.italic) || 225 + (nc.underline !== lastUnderline && !nc.underline) || 226 + needsReset; 227 + 228 + if (needReset) { 229 + out += "\x1b[0m"; 230 + lastFg = null; 231 + lastBg = null; 232 + lastBold = false; 233 + lastDim = false; 234 + lastItalic = false; 235 + lastUnderline = false; 236 + needsReset = false; 237 + } 238 + 239 + if (nc.bold && !lastBold) out += "\x1b[1m"; 240 + if (nc.dim && !lastDim) out += "\x1b[2m"; 241 + if (nc.italic && !lastItalic) out += "\x1b[3m"; 242 + if (nc.underline && !lastUnderline) out += "\x1b[4m"; 243 + 244 + if (nc.fg && !colorEq(nc.fg, lastFg)) { 245 + out += `\x1b[38;2;${nc.fg[0]};${nc.fg[1]};${nc.fg[2]}m`; 246 + } else if (!nc.fg && lastFg) { 247 + out += "\x1b[39m"; 248 + } 249 + 250 + if (nc.bg && !colorEq(nc.bg, lastBg)) { 251 + out += `\x1b[48;2;${nc.bg[0]};${nc.bg[1]};${nc.bg[2]}m`; 252 + } else if (!nc.bg && lastBg) { 253 + out += "\x1b[49m"; 254 + } 255 + 256 + out += nc.char; 257 + 258 + lastRow = r; 259 + lastCol = c + 1; 260 + lastFg = nc.fg; 261 + lastBg = nc.bg; 262 + lastBold = nc.bold; 263 + lastDim = nc.dim; 264 + lastItalic = nc.italic; 265 + lastUnderline = nc.underline; 266 + } 267 + } 268 + 269 + out += "\x1b[0m"; // Reset at end 270 + out += "\x1b[?2026l"; // End synchronized update 271 + return out; 272 + } 273 + 274 + /** Render the full buffer to ANSI (for initial draw). */ 275 + export function fullRender(buf: CellBuffer): string { 276 + let out = "\x1b[?2026h\x1b[H\x1b[0m"; 277 + let lastFg: [number, number, number] | null = null; 278 + let lastBg: [number, number, number] | null = null; 279 + let lastBold = false; 280 + let lastDim = false; 281 + let lastItalic = false; 282 + let lastUnderline = false; 283 + 284 + for (let r = 0; r < buf.rows; r++) { 285 + if (r > 0) out += `\x1b[${r + 1};1H`; 286 + for (let c = 0; c < buf.cols; c++) { 287 + const cell = buf.cells[r][c]; 288 + 289 + // Skip wide-char placeholder cells (second cell of a 2-cell character) 290 + if (cell.char === "") continue; 291 + 292 + const needReset = 293 + (cell.bold !== lastBold && !cell.bold) || 294 + (cell.dim !== lastDim && !cell.dim) || 295 + (cell.italic !== lastItalic && !cell.italic) || 296 + (cell.underline !== lastUnderline && !cell.underline); 297 + 298 + if (needReset) { 299 + out += "\x1b[0m"; 300 + lastFg = null; 301 + lastBg = null; 302 + lastBold = false; 303 + lastDim = false; 304 + lastItalic = false; 305 + lastUnderline = false; 306 + } 307 + 308 + if (cell.bold && !lastBold) out += "\x1b[1m"; 309 + if (cell.dim && !lastDim) out += "\x1b[2m"; 310 + if (cell.italic && !lastItalic) out += "\x1b[3m"; 311 + if (cell.underline && !lastUnderline) out += "\x1b[4m"; 312 + 313 + if (cell.fg && !colorEq(cell.fg, lastFg)) { 314 + out += `\x1b[38;2;${cell.fg[0]};${cell.fg[1]};${cell.fg[2]}m`; 315 + } else if (!cell.fg && lastFg) { 316 + out += "\x1b[39m"; 317 + } 318 + 319 + if (cell.bg && !colorEq(cell.bg, lastBg)) { 320 + out += `\x1b[48;2;${cell.bg[0]};${cell.bg[1]};${cell.bg[2]}m`; 321 + } else if (!cell.bg && lastBg) { 322 + out += "\x1b[49m"; 323 + } 324 + 325 + out += cell.char; 326 + lastFg = cell.fg; 327 + lastBg = cell.bg; 328 + lastBold = cell.bold; 329 + lastDim = cell.dim; 330 + lastItalic = cell.italic; 331 + lastUnderline = cell.underline; 332 + } 333 + } 334 + 335 + out += "\x1b[0m\x1b[?2026l"; 336 + return out; 337 + } 338 + 339 + function colorEq(a: [number, number, number] | null, b: [number, number, number] | null): boolean { 340 + if (a === b) return true; 341 + if (!a || !b) return false; 342 + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; 343 + } 344 + 345 + // --- Color conversion helpers --- 346 + const COLORS_16: [number, number, number][] = [ 347 + [0, 0, 0], [204, 0, 0], [0, 204, 0], [204, 204, 0], 348 + [0, 0, 204], [204, 0, 204], [0, 204, 204], [204, 204, 204], 349 + [85, 85, 85], [255, 85, 85], [85, 255, 85], [255, 255, 85], 350 + [85, 85, 255], [255, 85, 255], [85, 255, 255], [255, 255, 255], 351 + ]; 352 + 353 + function ansi16ToRgb(n: number): [number, number, number] { 354 + return COLORS_16[n] ?? [255, 255, 255]; 355 + } 356 + 357 + function ansi256ToRgb(n: number): [number, number, number] { 358 + if (n < 16) return ansi16ToRgb(n); 359 + if (n >= 232) { 360 + const v = (n - 232) * 10 + 8; 361 + return [v, v, v]; 362 + } 363 + const idx = n - 16; 364 + const r = Math.floor(idx / 36) * 51; 365 + const g = Math.floor((idx % 36) / 6) * 51; 366 + const b = (idx % 6) * 51; 367 + return [r, g, b]; 368 + }
+574
src/tui/builders.ts
··· 1 + // Builder functions for constructing UI node trees 2 + 3 + import type { 4 + UINode, Color, TextNode, SpacerNode, GapNode, SeparatorNode, 5 + IndentNode, DotNode, CheckboxNode, ProgressBarNode, SpinnerNode, 6 + IconNode, RowNode, ColumnNode, HStackNode, PanelNode, 7 + ScrollableNode, SelectableNode, StatusBarNode, FooterNode, 8 + AskBarNode, TextInputNode, FPSCounterNode, CanvasNode, 9 + DrawContext, PtyHandle, PtyViewNode, 10 + } from "./nodes.ts"; 11 + import type { BoxStyle, Theme } from "./colors.ts"; 12 + import type { ScrollRegion } from "./scrollable.ts"; 13 + import type { TextInputState } from "./text-input.ts"; 14 + import { createRequire } from "node:module"; 15 + const require = createRequire(import.meta.url); 16 + 17 + function rgbToHex(c: [number, number, number]): string { 18 + return "#" + c.map(v => v.toString(16).padStart(2, "0")).join(""); 19 + } 20 + 21 + function brighten(c: [number, number, number], amt = 40): [number, number, number] { 22 + return [Math.min(255, c[0] + amt), Math.min(255, c[1] + amt), Math.min(255, c[2] + amt)]; 23 + } 24 + 25 + /** 26 + * Convert our app Theme into an xterm ITheme with a full 16-color ANSI palette. 27 + * This makes CLI tools (ls, git, etc.) inside embedded PTYs use colors that 28 + * are coherent with the surrounding UI. 29 + */ 30 + export function themeToXterm(theme: Theme): Record<string, string> { 31 + return { 32 + foreground: rgbToHex(theme.fg1), 33 + background: rgbToHex(theme.bg1), 34 + cursor: rgbToHex(theme.fgAc), 35 + cursorAccent: rgbToHex(theme.bg1), 36 + selection: rgbToHex(theme.bgHi), 37 + // Standard 8 colors 38 + black: rgbToHex(theme.bg1), 39 + red: rgbToHex(theme.err), 40 + green: rgbToHex(theme.ok), 41 + yellow: rgbToHex(theme.warn), 42 + blue: rgbToHex(theme.info), 43 + magenta: rgbToHex(theme.fgAc), 44 + cyan: rgbToHex(theme.fg2), 45 + white: rgbToHex(theme.fg1), 46 + // Bright variants 47 + brightBlack: rgbToHex(theme.fgMu), 48 + brightRed: rgbToHex(brighten(theme.err)), 49 + brightGreen: rgbToHex(brighten(theme.ok)), 50 + brightYellow: rgbToHex(brighten(theme.warn)), 51 + brightBlue: rgbToHex(brighten(theme.info)), 52 + brightMagenta: rgbToHex(brighten(theme.fgAc)), 53 + brightCyan: rgbToHex(brighten(theme.fg2)), 54 + brightWhite: rgbToHex(brighten(theme.fg1)), 55 + }; 56 + } 57 + 58 + export function text( 59 + str: string, 60 + color?: Color, 61 + opts?: { bold?: boolean; dim?: boolean; italic?: boolean; truncate?: boolean; wrap?: boolean; highlight?: (text: string) => import("./nodes.ts").Span[] }, 62 + ): TextNode { 63 + return { type: "text", text: str, color, ...opts }; 64 + } 65 + 66 + export function spacer(): SpacerNode { 67 + return { type: "spacer" }; 68 + } 69 + 70 + export function gap(size: number | "center"): GapNode { 71 + return { type: "gap", size }; 72 + } 73 + 74 + export function separator(): SeparatorNode { 75 + return { type: "separator" }; 76 + } 77 + 78 + export function indent(depth: number): IndentNode { 79 + return { type: "indent", depth }; 80 + } 81 + 82 + export function dot(filled: boolean, color?: Color): DotNode { 83 + return { type: "dot", filled, color }; 84 + } 85 + 86 + export function checkbox(checked: boolean, color?: Color): CheckboxNode { 87 + return { type: "checkbox", checked, color }; 88 + } 89 + 90 + export function progressBar( 91 + percent: number, 92 + opts?: { width?: number; color?: Color }, 93 + ): ProgressBarNode { 94 + return { type: "progressBar", percent, width: opts?.width, color: opts?.color }; 95 + } 96 + 97 + export function spinner(color?: Color): SpinnerNode { 98 + return { type: "spinner", color }; 99 + } 100 + 101 + export function icon(char: string, color?: Color): IconNode { 102 + return { type: "icon", char, color }; 103 + } 104 + 105 + export function row(...children: UINode[]): RowNode { 106 + return { type: "row", children }; 107 + } 108 + 109 + export function column( 110 + opts: { width?: number; flex?: boolean }, 111 + children: UINode[], 112 + ): ColumnNode { 113 + return { type: "column", children, width: opts.width, flex: opts.flex }; 114 + } 115 + 116 + export function hstack( 117 + opts: { gap?: number }, 118 + children: ColumnNode[], 119 + ): HStackNode { 120 + return { type: "hstack", children, gap: opts.gap }; 121 + } 122 + 123 + export function panel( 124 + title: string, 125 + children: UINode[], 126 + style?: BoxStyle, 127 + ): PanelNode { 128 + return { type: "panel", title, children, style }; 129 + } 130 + 131 + export function scrollable<T>( 132 + items: T[], 133 + renderFn: (item: T, index: number) => UINode[], 134 + ): ScrollableNode { 135 + const rendered = items.map((item, i) => renderFn(item, i)); 136 + return { 137 + type: "scrollable", 138 + items: rendered, 139 + offset: 0, 140 + totalItems: items.length, 141 + }; 142 + } 143 + 144 + export function selectable<T>( 145 + region: ScrollRegion, 146 + items: T[], 147 + renderFn: (item: T, index: number, selected: boolean) => UINode[], 148 + ): SelectableNode { 149 + const rendered = items.map((item, i) => renderFn(item, i, i === region.selectedIndex)); 150 + return { 151 + type: "selectable", 152 + items: rendered, 153 + selectedIndex: region.selectedIndex, 154 + offset: region.offset, 155 + totalItems: region.totalItems, 156 + }; 157 + } 158 + 159 + /** 160 + * Grouped selectable: renders groups of items with section headers. 161 + * The ScrollRegion's selectedIndex counts only selectable items (not headers). 162 + * Headers and spacing rows are included in the visual output but the 163 + * scroll offset is mapped from item-space to visual-row-space automatically. 164 + */ 165 + export function groupedSelectable<T>( 166 + region: ScrollRegion, 167 + groups: { title: string; items: T[] }[], 168 + renderItem: (item: T, globalIndex: number, selected: boolean) => UINode[], 169 + renderHeader: (title: string, count: number) => UINode[] = 170 + (title, count) => [text(title, "accent", { bold: true }), text(` (${count})`, "muted")], 171 + ): SelectableNode { 172 + const allRows: UINode[][] = []; 173 + let globalIdx = 0; 174 + // Track which visual row each selectable item maps to 175 + let selectedVisualRow = 0; 176 + 177 + for (const group of groups) { 178 + if (group.items.length === 0) continue; 179 + if (allRows.length > 0) allRows.push([]); // spacing 180 + allRows.push(renderHeader(group.title, group.items.length)); // header 181 + for (const item of group.items) { 182 + if (globalIdx === region.selectedIndex) { 183 + selectedVisualRow = allRows.length; 184 + } 185 + allRows.push(renderItem(item, globalIdx, globalIdx === region.selectedIndex)); 186 + globalIdx++; 187 + } 188 + } 189 + 190 + // Compute visual offset: try to keep the selected row visible in the viewport. 191 + // Start from 0 and scroll down enough so the selected row is in view. 192 + let visualOffset = 0; 193 + if (selectedVisualRow >= region.viewportHeight) { 194 + visualOffset = selectedVisualRow - region.viewportHeight + 2; 195 + } 196 + 197 + return { 198 + type: "selectable", 199 + items: allRows, 200 + selectedIndex: region.selectedIndex, 201 + offset: Math.max(0, visualOffset), 202 + totalItems: region.totalItems, 203 + }; 204 + } 205 + 206 + export function statusBar(left: string, right: string): StatusBarNode { 207 + return { type: "statusBar", left, right }; 208 + } 209 + 210 + export function footer(hints: string): FooterNode { 211 + return { type: "footer", hints }; 212 + } 213 + 214 + export function askBar( 215 + state: TextInputState, 216 + opts?: { placeholder?: string; rightLabel?: string; style?: BoxStyle }, 217 + ): AskBarNode { 218 + return { 219 + type: "askBar", 220 + text: state.text, 221 + placeholder: opts?.placeholder ?? "> Ask anything...", 222 + active: state.active, 223 + rightLabel: opts?.rightLabel, 224 + style: opts?.style, 225 + }; 226 + } 227 + 228 + export function textInput( 229 + state: TextInputState, 230 + opts?: { placeholder?: string }, 231 + ): TextInputNode { 232 + return { 233 + type: "textInput", 234 + text: state.text, 235 + cursor: state.cursor, 236 + active: state.active, 237 + placeholder: opts?.placeholder, 238 + }; 239 + } 240 + 241 + export function fpsCounter(): FPSCounterNode { 242 + return { type: "fpsCounter" }; 243 + } 244 + 245 + /** 246 + * Free-form drawing surface. Participates in layout like any other node 247 + * (flex by default, or set fixed height/widthHint), but gives you a 248 + * DrawContext callback to place characters at arbitrary positions. 249 + * 250 + * ``` 251 + * canvas((ctx) => { 252 + * ctx.fill(0, 0, ctx.width, ctx.height, ".", "muted"); 253 + * ctx.write(2, 1, "Player", "ok"); 254 + * ctx.set(playerX, playerY, "@", "accent"); 255 + * }) 256 + * ``` 257 + */ 258 + export function canvas( 259 + draw: (ctx: DrawContext) => void, 260 + opts?: { height?: number; width?: number }, 261 + ): CanvasNode { 262 + return { 263 + type: "canvas", 264 + draw, 265 + height: opts?.height, 266 + widthHint: opts?.width, 267 + _cells: [], 268 + }; 269 + } 270 + 271 + /** 272 + * Spawn a child process in an embedded PTY. Returns a PtyHandle that you 273 + * manage in onEnter/onLeave and render with ptyView(). 274 + * 275 + * ``` 276 + * const handle = createPty("/bin/bash", [], { cols: 80, rows: 24 }); 277 + * // in render: ptyView(handle) 278 + * // in handleKey: handle.write(key sequence) 279 + * // in onLeave: handle.kill() 280 + * ``` 281 + */ 282 + // --- Palette → RGB conversion (for xterm palette color mode) --- 283 + const PALETTE_16: [number, number, number][] = [ 284 + [0, 0, 0], [204, 0, 0], [0, 204, 0], [204, 204, 0], 285 + [0, 0, 204], [204, 0, 204], [0, 204, 204], [204, 204, 204], 286 + [85, 85, 85], [255, 85, 85], [85, 255, 85], [255, 255, 85], 287 + [85, 85, 255], [255, 85, 255], [85, 255, 255], [255, 255, 255], 288 + ]; 289 + function paletteToRgb(n: number): [number, number, number] { 290 + if (n < 16) return PALETTE_16[n] ?? [255, 255, 255]; 291 + if (n >= 232) { const v = (n - 232) * 10 + 8; return [v, v, v]; } 292 + const idx = n - 16; 293 + return [Math.floor(idx / 36) * 51, Math.floor((idx % 36) / 6) * 51, (idx % 6) * 51]; 294 + } 295 + 296 + /** 297 + * Read xterm-headless terminal buffer cells directly with full color fidelity. 298 + * No serialize round-trip. Uses xterm's cell API to extract RGB/palette/default colors. 299 + */ 300 + function readXtermCells( 301 + terminal: any, 302 + rows: number, 303 + cols: number, 304 + ): ReturnType<PtyHandle["readCells"]> { 305 + const buf = terminal.buffer.active; 306 + const grid: ReturnType<PtyHandle["readCells"]> = []; 307 + 308 + for (let r = 0; r < rows; r++) { 309 + const line = buf.getLine(r); 310 + const row: typeof grid[0] = []; 311 + for (let c = 0; c < cols; c++) { 312 + if (!line) { 313 + row.push({ char: " ", fg: null, bg: null, bold: false, dim: false, italic: false, underline: false }); 314 + continue; 315 + } 316 + const cell = line.getCell(c); 317 + if (!cell) { 318 + row.push({ char: " ", fg: null, bg: null, bold: false, dim: false, italic: false, underline: false }); 319 + continue; 320 + } 321 + 322 + // Extract foreground color 323 + let fg: [number, number, number] | null = null; 324 + if (cell.isFgRGB()) { 325 + const v = cell.getFgColor(); 326 + fg = [(v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff]; 327 + } else if (cell.isFgPalette()) { 328 + fg = paletteToRgb(cell.getFgColor()); 329 + } 330 + // isFgDefault() → null (use theme default) 331 + 332 + // Extract background color 333 + let bg: [number, number, number] | null = null; 334 + if (cell.isBgRGB()) { 335 + const v = cell.getBgColor(); 336 + bg = [(v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff]; 337 + } else if (cell.isBgPalette()) { 338 + bg = paletteToRgb(cell.getBgColor()); 339 + } 340 + // isBgDefault() → null (use theme default) 341 + 342 + row.push({ 343 + char: cell.getChars() || " ", 344 + fg, 345 + bg, 346 + bold: !!cell.isBold(), 347 + dim: !!cell.isDim(), 348 + italic: !!cell.isItalic(), 349 + underline: !!cell.isUnderline(), 350 + }); 351 + } 352 + grid.push(row); 353 + } 354 + return grid; 355 + } 356 + 357 + export function createPty( 358 + command: string, 359 + args: string[] = [], 360 + opts?: { cols?: number; rows?: number; cwd?: string; env?: Record<string, string>; theme?: Theme }, 361 + ): PtyHandle { 362 + // Lazy-import node-pty and @xterm/headless to avoid top-level side effects 363 + // and keep the module loadable in test environments that don't need PTY. 364 + const pty = require("node-pty") as typeof import("node-pty"); 365 + const xtermMod = require("@xterm/headless") as { Terminal: typeof import("@xterm/headless").Terminal }; 366 + // @xterm/headless is CJS — the Terminal class may be on .default or directly 367 + const TerminalClass = (xtermMod as any).default?.Terminal ?? xtermMod.Terminal; 368 + 369 + const cols = opts?.cols ?? 80; 370 + const rows = opts?.rows ?? 24; 371 + 372 + const xtermOpts: any = { rows, cols, scrollback: 0, allowProposedApi: true }; 373 + if (opts?.theme) xtermOpts.theme = themeToXterm(opts.theme); 374 + const terminal = new TerminalClass(xtermOpts); 375 + 376 + const childEnv: Record<string, string> = { 377 + ...(process.env as Record<string, string>), 378 + ...opts?.env, 379 + TERM: "xterm-256color", 380 + }; 381 + 382 + const proc = pty.spawn(command, args, { 383 + name: "xterm-256color", 384 + cols, 385 + rows, 386 + cwd: opts?.cwd ?? process.cwd(), 387 + env: childEnv, 388 + }); 389 + 390 + let exited = false; 391 + let dirty = false; 392 + 393 + proc.onData((data: string) => { 394 + terminal.write(data, () => { 395 + dirty = true; 396 + handle.onActivity?.(); 397 + }); 398 + }); 399 + proc.onExit(() => { 400 + exited = true; 401 + dirty = true; 402 + handle.onActivity?.(); 403 + }); 404 + 405 + const handle: PtyHandle = { 406 + write(data: string) { if (!exited) proc.write(data); }, 407 + 408 + resize(newCols: number, newRows: number) { 409 + if (!exited && (newCols !== handle.cols || newRows !== handle.rows)) { 410 + handle.cols = newCols; 411 + handle.rows = newRows; 412 + proc.resize(newCols, newRows); 413 + terminal.resize(newCols, newRows); 414 + dirty = true; 415 + } 416 + }, 417 + 418 + readCells() { return readXtermCells(terminal, handle.rows, handle.cols); }, 419 + 420 + kill() { 421 + if (!exited) { 422 + try { proc.kill(); } catch {} 423 + exited = true; 424 + } 425 + terminal.dispose(); 426 + }, 427 + 428 + setTheme(t: Theme) { 429 + terminal.options.theme = themeToXterm(t); 430 + dirty = true; 431 + }, 432 + 433 + cols, 434 + rows, 435 + get exited() { return exited; }, 436 + get dirty() { return dirty; }, 437 + set dirty(v: boolean) { dirty = v; }, 438 + onActivity: null, 439 + }; 440 + 441 + return handle; 442 + } 443 + 444 + /** 445 + * Attach to an existing named PTY session (started with `pty run`). 446 + * Returns the same PtyHandle interface, but the child process is owned 447 + * by the external daemon — kill() detaches instead of terminating it. 448 + * 449 + * ``` 450 + * const handle = await attachPty("my-server"); 451 + * // in render: ptyView(handle) 452 + * // in handleKey: handle.write(key sequence) 453 + * // in onLeave: handle.kill() — detaches, process keeps running 454 + * ``` 455 + */ 456 + export async function attachPty( 457 + name: string, 458 + opts?: { cols?: number; rows?: number; theme?: Theme }, 459 + ): Promise<PtyHandle> { 460 + const net = require("node:net") as typeof import("node:net"); 461 + const xtermMod = require("@xterm/headless") as any; 462 + const TerminalClass = xtermMod.default?.Terminal ?? xtermMod.Terminal; 463 + const { getSocketPath } = require("../sessions.ts") as typeof import("../sessions.ts"); 464 + const { 465 + MessageType, PacketReader, encodeAttach, encodeData, encodeResize, encodeDetach, 466 + } = require("../protocol.ts") as typeof import("../protocol.ts"); 467 + 468 + const cols = opts?.cols ?? 80; 469 + const rows = opts?.rows ?? 24; 470 + 471 + const xtermOpts: any = { rows, cols, scrollback: 0, allowProposedApi: true }; 472 + if (opts?.theme) xtermOpts.theme = themeToXterm(opts.theme); 473 + const terminal = new TerminalClass(xtermOpts); 474 + const socketPath = getSocketPath(name); 475 + 476 + // Connect to the daemon socket 477 + const socket: import("node:net").Socket = await new Promise((resolve, reject) => { 478 + const s = net.createConnection(socketPath); 479 + s.on("connect", () => resolve(s)); 480 + s.on("error", (err) => reject(new Error(`Failed to attach to session "${name}": ${err.message}`))); 481 + }); 482 + 483 + let exited = false; 484 + let exitCode: number | null = null; 485 + let dirty = false; 486 + const reader = new PacketReader(); 487 + 488 + const handle: PtyHandle = { 489 + write(data: string) { if (!exited) socket.write(encodeData(data)); }, 490 + 491 + resize(newCols: number, newRows: number) { 492 + if (!exited && (newCols !== handle.cols || newRows !== handle.rows)) { 493 + handle.cols = newCols; 494 + handle.rows = newRows; 495 + socket.write(encodeResize(newRows, newCols)); 496 + terminal.resize(newCols, newRows); 497 + dirty = true; 498 + } 499 + }, 500 + 501 + readCells() { return readXtermCells(terminal, handle.rows, handle.cols); }, 502 + 503 + kill() { 504 + // Detach only — the daemon keeps the process running 505 + try { socket.write(encodeDetach()); } catch {} 506 + try { socket.destroy(); } catch {} 507 + terminal.dispose(); 508 + exited = true; 509 + }, 510 + 511 + setTheme(t: Theme) { 512 + terminal.options.theme = themeToXterm(t); 513 + dirty = true; 514 + }, 515 + 516 + cols, 517 + rows, 518 + get exited() { return exited; }, 519 + get dirty() { return dirty; }, 520 + set dirty(v: boolean) { dirty = v; }, 521 + onActivity: null, 522 + }; 523 + 524 + socket.on("data", (data: Buffer) => { 525 + const packets = reader.feed(data); 526 + for (const packet of packets) { 527 + switch (packet.type) { 528 + case MessageType.SCREEN: 529 + terminal.reset(); 530 + terminal.write(packet.payload.toString()); 531 + dirty = true; 532 + handle.onActivity?.(); 533 + break; 534 + case MessageType.DATA: 535 + terminal.write(packet.payload.toString(), () => { 536 + dirty = true; 537 + handle.onActivity?.(); 538 + }); 539 + break; 540 + case MessageType.EXIT: 541 + exitCode = packet.payload.readInt32BE(0); 542 + exited = true; 543 + dirty = true; 544 + handle.onActivity?.(); 545 + break; 546 + } 547 + } 548 + }); 549 + 550 + socket.write(encodeAttach(rows, cols)); 551 + await new Promise(r => setTimeout(r, 100)); 552 + 553 + return handle; 554 + } 555 + 556 + /** 557 + * Render an embedded PTY session into the layout. Flex-sized by default. 558 + * The PTY is automatically resized to match the layout rect. 559 + * 560 + * ``` 561 + * hstack({ gap: 1 }, [ 562 + * column({ width: 30 }, [panel("Sidebar", [...])]), 563 + * column({ flex: true }, [ptyView(handle)]), 564 + * ]) 565 + * ``` 566 + */ 567 + export function ptyView(handle: PtyHandle): PtyViewNode { 568 + return { 569 + type: "ptyView", 570 + handle, 571 + _lastCols: handle.cols, 572 + _lastRows: handle.rows, 573 + }; 574 + }
+410
src/tui/colors.ts
··· 1 + // Extended ANSI rendering utilities for threadsafe TUI experiments 2 + 3 + const ESC = "\x1b"; 4 + 5 + // --- Screen management --- 6 + export function clearScreen(): string { return `${ESC}[2J${ESC}[H`; } 7 + export function hideCursor(): string { return `${ESC}[?25l`; } 8 + export function showCursor(): string { return `${ESC}[?25h`; } 9 + export function moveTo(row: number, col: number): string { return `${ESC}[${row};${col}H`; } 10 + 11 + // --- True-color support --- 12 + export function fg(r: number, g: number, b: number): string { return `${ESC}[38;2;${r};${g};${b}m`; } 13 + export function bg(r: number, g: number, b: number): string { return `${ESC}[48;2;${r};${g};${b}m`; } 14 + export function reset(): string { return `${ESC}[0m`; } 15 + 16 + // --- Text styles --- 17 + export function bold(s: string): string { return `${ESC}[1m${s}${ESC}[22m`; } 18 + export function dim(s: string): string { return `${ESC}[2m${s}${ESC}[22m`; } 19 + export function italic(s: string): string { return `${ESC}[3m${s}${ESC}[23m`; } 20 + export function underline(s: string): string { return `${ESC}[4m${s}${ESC}[24m`; } 21 + export function inverse(s: string): string { return `${ESC}[7m${s}${ESC}[27m`; } 22 + 23 + // Raw style codes 24 + export const BOLD = `${ESC}[1m`; 25 + export const DIM = `${ESC}[2m`; 26 + export const RESET = `${ESC}[0m`; 27 + 28 + // --- ANSI stripping --- 29 + const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g; 30 + export function stripAnsi(text: string): string { return text.replace(ANSI_RE, ""); } 31 + 32 + // --- Character width (wcwidth-style) --- 33 + // Returns the display cell width of a single character in a terminal. 34 + // Most characters are 1 cell. East Asian wide chars and certain emoji are 2 cells. 35 + export function charWidth(ch: string): number { 36 + const code = ch.codePointAt(0); 37 + if (code === undefined) return 0; 38 + // Control characters 39 + if (code < 0x20) return 0; 40 + // Standard ASCII + most Latin/symbols 41 + if (code < 0x2500) return 1; 42 + // Box drawing U+2500–U+257F: 1 cell 43 + if (code >= 0x2500 && code <= 0x257F) return 1; 44 + // Block elements U+2580–U+259F: 1 cell 45 + if (code >= 0x2580 && code <= 0x259F) return 1; 46 + // Geometric shapes U+25A0–U+25FF: 1 cell 47 + if (code >= 0x25A0 && code <= 0x25FF) return 1; 48 + // Miscellaneous Symbols U+2600–U+26FF: mostly 1, but specific emoji are 2 cells wide 49 + if (code >= 0x2600 && code <= 0x26FF) { 50 + // These specific codepoints render as 2 cells in most terminals (emoji presentation) 51 + if (code >= 0x2614 && code <= 0x2615) return 2; // ☔☕ 52 + if (code >= 0x2648 && code <= 0x2653) return 2; // zodiac signs 53 + if (code === 0x267F) return 2; // ♿ 54 + if (code === 0x2693) return 2; // ⚓ 55 + if (code >= 0x26A0 && code <= 0x26A1) return 2; // ⚠⚡ 56 + if (code >= 0x26AA && code <= 0x26AB) return 2; // ⚪⚫ 57 + if (code >= 0x26BD && code <= 0x26BE) return 2; // ⚽⚾ 58 + if (code >= 0x26C4 && code <= 0x26C5) return 2; // ⛄⛅ 59 + if (code === 0x26D4) return 2; // ⛔ 60 + if (code === 0x26EA) return 2; // ⛪ 61 + if (code >= 0x26F2 && code <= 0x26F3) return 2; // ⛲⛳ 62 + if (code === 0x26F5) return 2; // ⛵ 63 + if (code === 0x26FA) return 2; // ⛺ 64 + if (code === 0x26FD) return 2; // ⛽ 65 + return 1; 66 + } 67 + // Dingbats U+2700–U+27BF: 1 cell 68 + if (code >= 0x2700 && code <= 0x27BF) return 1; 69 + // CJK Unified Ideographs and related 70 + if (code >= 0x2E80 && code <= 0x9FFF) return 2; 71 + if (code >= 0xAC00 && code <= 0xD7AF) return 2; // Hangul 72 + if (code >= 0xF900 && code <= 0xFAFF) return 2; 73 + if (code >= 0xFE10 && code <= 0xFE6F) return 2; 74 + if (code >= 0xFF01 && code <= 0xFF60) return 2; // Fullwidth forms 75 + if (code >= 0xFFE0 && code <= 0xFFE6) return 2; 76 + if (code >= 0x1F000 && code <= 0x1FBFF) return 2; // All emoji in supplementary planes 77 + if (code >= 0x20000 && code <= 0x3FFFF) return 2; // CJK extensions 78 + return 1; 79 + } 80 + 81 + export function visibleLength(text: string): number { 82 + const plain = stripAnsi(text); 83 + let w = 0; 84 + for (const ch of plain) { 85 + w += charWidth(ch); 86 + } 87 + return w; 88 + } 89 + 90 + // --- Text utilities --- 91 + export function truncate(text: string, maxWidth: number): string { 92 + if (visibleLength(text) <= maxWidth) return text; 93 + if (maxWidth <= 1) return text.slice(0, maxWidth); 94 + // Build up result character by character, tracking display width 95 + let w = 0; 96 + let result = ""; 97 + for (const ch of text) { 98 + const cw = charWidth(ch); 99 + if (w + cw + 1 > maxWidth) break; // +1 for the ellipsis 100 + result += ch; 101 + w += cw; 102 + } 103 + return result + "\u2026"; 104 + } 105 + 106 + /** 107 + * Soft-wrap text into multiple visual lines that fit within `maxWidth` columns. 108 + * Breaks at word boundaries (spaces) when possible, falling back to character- 109 + * boundary breaking only when a single word exceeds the line width. CJK 110 + * characters (charWidth === 2) are always valid break points — a line can 111 + * break after any CJK character. 112 + * 113 + * Break semantics: when breaking at a space, the break happens BEFORE the 114 + * space so the space appears at the start of the next line. This preserves 115 + * the invariant that concatenating all output lines reproduces the original 116 + * text exactly, keeping offset tracking simple. 117 + * 118 + * Returns at least one line, plus the starting code-point offset of each line 119 + * for span splitting. 120 + */ 121 + export function wrapText(text: string, maxWidth: number): { lines: string[]; offsets: number[] } { 122 + if (maxWidth <= 0) return { lines: [text], offsets: [0] }; 123 + const lines: string[] = []; 124 + const offsets: number[] = []; 125 + 126 + // Expand code points into an array for easy indexing 127 + const chars = [...text]; 128 + const len = chars.length; 129 + let pos = 0; 130 + 131 + while (pos < len) { 132 + let lineWidth = 0; 133 + let lineEnd = pos; 134 + let lastBreak = -1; // code-point index where we can break (exclusive end of line) 135 + 136 + while (lineEnd < len) { 137 + const ch = chars[lineEnd]; 138 + const w = charWidth(ch); 139 + 140 + if (lineWidth + w > maxWidth) break; // this char would overflow 141 + 142 + // A space is a break opportunity BEFORE it (so the space goes to next line) 143 + if (ch === " " && lineEnd > pos) { 144 + lastBreak = lineEnd; 145 + } 146 + 147 + lineWidth += w; 148 + lineEnd++; 149 + 150 + // CJK / wide chars: can break AFTER them 151 + if (w >= 2) { 152 + lastBreak = lineEnd; 153 + } 154 + } 155 + 156 + if (lineEnd >= len) { 157 + // Rest of text fits on this line 158 + lines.push(chars.slice(pos, len).join("")); 159 + offsets.push(pos); 160 + break; 161 + } 162 + 163 + // Overflow — pick a break point 164 + if (lastBreak > pos) { 165 + lines.push(chars.slice(pos, lastBreak).join("")); 166 + offsets.push(pos); 167 + pos = lastBreak; 168 + } else { 169 + // No word break available — force character break 170 + if (lineEnd === pos) lineEnd++; // guarantee forward progress 171 + lines.push(chars.slice(pos, lineEnd).join("")); 172 + offsets.push(pos); 173 + pos = lineEnd; 174 + } 175 + } 176 + 177 + if (lines.length === 0) { 178 + lines.push(""); 179 + offsets.push(0); 180 + } 181 + 182 + return { lines, offsets }; 183 + } 184 + 185 + export function pad(text: string, width: number, align: "left" | "right" | "center" = "left"): string { 186 + const len = visibleLength(text); 187 + if (len >= width) return text; 188 + const p = width - len; 189 + if (align === "right") return " ".repeat(p) + text; 190 + if (align === "center") return " ".repeat(Math.floor(p / 2)) + text + " ".repeat(Math.ceil(p / 2)); 191 + return text + " ".repeat(p); 192 + } 193 + 194 + // --- Convenience --- 195 + export function writeAt(row: number, col: number, text: string): string { 196 + return moveTo(row, col) + text; 197 + } 198 + 199 + export function fillRect(row: number, col: number, width: number, height: number, char = " "): string { 200 + let out = ""; 201 + const line = char.repeat(width); 202 + for (let r = 0; r < height; r++) { 203 + out += moveTo(row + r, col) + line; 204 + } 205 + return out; 206 + } 207 + 208 + export function fillLine(row: number, col: number, width: number, char = " "): string { 209 + return moveTo(row, col) + char.repeat(width); 210 + } 211 + 212 + // --- Box drawing --- 213 + export type BoxStyle = "rounded" | "sharp" | "double" | "heavy"; 214 + 215 + interface BoxChars { 216 + tl: string; tr: string; bl: string; br: string; 217 + h: string; v: string; lj: string; rj: string; 218 + } 219 + 220 + const BOX: Record<BoxStyle, BoxChars> = { 221 + rounded: { tl: "\u256d", tr: "\u256e", bl: "\u2570", br: "\u256f", h: "\u2500", v: "\u2502", lj: "\u251c", rj: "\u2524" }, 222 + sharp: { tl: "\u250c", tr: "\u2510", bl: "\u2514", br: "\u2518", h: "\u2500", v: "\u2502", lj: "\u251c", rj: "\u2524" }, 223 + double: { tl: "\u2554", tr: "\u2557", bl: "\u255a", br: "\u255d", h: "\u2550", v: "\u2551", lj: "\u2560", rj: "\u2563" }, 224 + heavy: { tl: "\u250f", tr: "\u2513", bl: "\u2517", br: "\u251b", h: "\u2501", v: "\u2503", lj: "\u2523", rj: "\u252b" }, 225 + }; 226 + 227 + export function boxChars(style: BoxStyle = "rounded"): BoxChars { return BOX[style]; } 228 + 229 + export function drawBox( 230 + row: number, col: number, width: number, height: number, 231 + opts: { style?: BoxStyle; title?: string; fill?: boolean } = {} 232 + ): string { 233 + const c = BOX[opts.style ?? "rounded"]; 234 + let out = ""; 235 + 236 + // Top border 237 + let top = c.h.repeat(width - 2); 238 + if (opts.title) { 239 + const tLen = visibleLength(opts.title) + 2; 240 + if (tLen < width - 4) { 241 + const rest = width - 2 - tLen - 1; 242 + top = c.h + " " + opts.title + " " + c.h.repeat(rest); 243 + } 244 + } 245 + out += moveTo(row, col) + c.tl + top + c.tr; 246 + 247 + // Sides + optional fill 248 + for (let r = 1; r < height - 1; r++) { 249 + out += moveTo(row + r, col) + c.v; 250 + if (opts.fill) out += " ".repeat(width - 2); 251 + out += moveTo(row + r, col + width - 1) + c.v; 252 + } 253 + 254 + // Bottom 255 + out += moveTo(row + height - 1, col) + c.bl + c.h.repeat(width - 2) + c.br; 256 + return out; 257 + } 258 + 259 + export function hSep(row: number, col: number, width: number, style: BoxStyle = "rounded"): string { 260 + const c = BOX[style]; 261 + return moveTo(row, col) + c.lj + c.h.repeat(width - 2) + c.rj; 262 + } 263 + 264 + // --- Progress bar --- 265 + export function progressBar(width: number, pct: number): string { 266 + const filled = Math.round(width * Math.min(1, Math.max(0, pct))); 267 + return "\u2588".repeat(filled) + "\u2591".repeat(width - filled); 268 + } 269 + 270 + // --- Theme --- 271 + export interface Theme { 272 + bg1: [number, number, number]; 273 + bg2: [number, number, number]; 274 + bgHi: [number, number, number]; 275 + bgAc: [number, number, number]; 276 + fg1: [number, number, number]; 277 + fg2: [number, number, number]; 278 + fgAc: [number, number, number]; 279 + fgMu: [number, number, number]; 280 + ok: [number, number, number]; 281 + warn: [number, number, number]; 282 + err: [number, number, number]; 283 + info: [number, number, number]; 284 + border: [number, number, number]; 285 + } 286 + 287 + export const themes: Record<string, Theme> = { 288 + coolBlue: { 289 + bg1: [15, 17, 26], bg2: [22, 27, 42], bgHi: [30, 40, 65], bgAc: [40, 80, 140], 290 + fg1: [210, 218, 235], fg2: [140, 155, 185], fgAc: [100, 160, 255], fgMu: [70, 80, 105], 291 + ok: [80, 200, 120], warn: [240, 180, 50], err: [240, 80, 80], info: [80, 170, 240], 292 + border: [50, 60, 85], 293 + }, 294 + warmAmber: { 295 + bg1: [24, 18, 12], bg2: [36, 28, 18], bgHi: [55, 42, 25], bgAc: [120, 80, 30], 296 + fg1: [235, 220, 195], fg2: [180, 160, 130], fgAc: [255, 190, 60], fgMu: [100, 85, 60], 297 + ok: [120, 200, 80], warn: [255, 200, 80], err: [220, 80, 60], info: [100, 180, 220], 298 + border: [80, 65, 40], 299 + }, 300 + mono: { 301 + bg1: [18, 18, 18], bg2: [28, 28, 28], bgHi: [48, 48, 48], bgAc: [70, 70, 70], 302 + fg1: [220, 220, 220], fg2: [160, 160, 160], fgAc: [255, 255, 255], fgMu: [90, 90, 90], 303 + ok: [160, 220, 160], warn: [220, 200, 130], err: [220, 140, 140], info: [140, 180, 220], 304 + border: [60, 60, 60], 305 + }, 306 + dracula: { 307 + bg1: [40, 42, 54], bg2: [50, 52, 68], bgHi: [68, 71, 90], bgAc: [98, 114, 164], 308 + fg1: [248, 248, 242], fg2: [189, 147, 249], fgAc: [139, 233, 253], fgMu: [98, 114, 164], 309 + ok: [80, 250, 123], warn: [241, 250, 140], err: [255, 85, 85], info: [139, 233, 253], 310 + border: [80, 83, 105], 311 + }, 312 + forest: { 313 + bg1: [12, 20, 14], bg2: [18, 32, 22], bgHi: [28, 48, 32], bgAc: [40, 80, 50], 314 + fg1: [200, 225, 205], fg2: [140, 175, 150], fgAc: [100, 220, 130], fgMu: [65, 90, 70], 315 + ok: [80, 230, 120], warn: [230, 200, 80], err: [230, 90, 80], info: [80, 190, 220], 316 + border: [45, 65, 48], 317 + }, 318 + }; 319 + 320 + // Quick access to theme color codes 321 + export function c(theme: Theme) { 322 + return { 323 + bg1: bg(...theme.bg1), bg2: bg(...theme.bg2), bgHi: bg(...theme.bgHi), bgAc: bg(...theme.bgAc), 324 + fg1: fg(...theme.fg1), fg2: fg(...theme.fg2), fgAc: fg(...theme.fgAc), fgMu: fg(...theme.fgMu), 325 + ok: fg(...theme.ok), warn: fg(...theme.warn), err: fg(...theme.err), info: fg(...theme.info), 326 + border: fg(...theme.border), 327 + bgOk: bg(...theme.ok), bgWarn: bg(...theme.warn), bgErr: bg(...theme.err), bgInfo: bg(...theme.info), 328 + }; 329 + } 330 + 331 + // Init screen: clear + fill with bg1 332 + export function initScreen(rows: number, cols: number, theme: Theme): string { 333 + const clr = c(theme); 334 + return hideCursor() + clearScreen() + clr.bg1 + clr.fg1 + fillRect(1, 1, cols, rows) + reset(); 335 + } 336 + 337 + // Title bar across top 338 + export function titleBar(cols: number, left: string, right: string, theme: Theme): string { 339 + const clr = c(theme); 340 + let o = clr.bgAc + clr.fg1; 341 + o += fillLine(1, 1, cols); 342 + o += writeAt(1, 3, bold(left)); 343 + if (right) o += writeAt(1, cols - visibleLength(right) - 2, right); 344 + o += reset(); 345 + return o; 346 + } 347 + 348 + // Footer bar across bottom 349 + export function footerBar(row: number, cols: number, text: string, theme: Theme): string { 350 + const clr = c(theme); 351 + return clr.bg1 + clr.fgMu + writeAt(row, 2, text) + reset(); 352 + } 353 + 354 + // Draw a panel: filled box with title 355 + export function panel( 356 + row: number, col: number, w: number, h: number, 357 + title: string, theme: Theme, style: BoxStyle = "rounded" 358 + ): string { 359 + const clr = c(theme); 360 + let o = clr.bg2 + fillRect(row, col, w, h); 361 + o += clr.border + drawBox(row, col, w, h, { style, fill: false }); 362 + if (title) { 363 + o += writeAt(row, col + 2, " " + clr.fgAc + bold(title) + reset() + clr.bg2 + clr.border + " "); 364 + } 365 + return o; 366 + } 367 + 368 + // Write content line inside a panel 369 + export function panelLine(row: number, col: number, text: string): string { 370 + return writeAt(row, col + 2, text); 371 + } 372 + 373 + // Ask-anything input bar (boxed, 3 rows tall) 374 + export function askBar( 375 + row: number, col: number, width: number, 376 + theme: Theme, agentContext: string, style: BoxStyle = "rounded" 377 + ): string { 378 + const clr = c(theme); 379 + let o = clr.bg2 + fillRect(row, col, width, 3); 380 + o += clr.border + drawBox(row, col, width, 3, { style }); 381 + o += writeAt(row + 1, col + 3, clr.fgMu + "> Ask anything..."); 382 + o += writeAt(row + 1, col + width - visibleLength(agentContext) - 3, clr.fg2 + agentContext); 383 + return o; 384 + } 385 + 386 + // Compact ask bar (1 row, no box — for small terminals) 387 + export function askBarCompact( 388 + row: number, col: number, width: number, 389 + theme: Theme, agentContext: string 390 + ): string { 391 + const clr = c(theme); 392 + let o = clr.bg2 + fillLine(row, col, width); 393 + o += writeAt(row, col + 2, clr.fgMu + "> Ask anything..."); 394 + o += writeAt(row, col + width - visibleLength(agentContext) - 2, clr.fg2 + agentContext); 395 + return o; 396 + } 397 + 398 + // Agent activity status line 399 + export function agentActivity(row: number, col: number, items: [string, string, string][], theme: Theme): string { 400 + const clr = c(theme); 401 + let o = clr.bg1; 402 + let text = ""; 403 + items.forEach(([name, status, color], i) => { 404 + const colorCode = color === "ok" ? clr.ok : color === "warn" ? clr.warn : color === "info" ? clr.info : clr.fgMu; 405 + if (i > 0) text += clr.fgMu + " \u2502 "; 406 + text += colorCode + "\u25cf " + clr.fg2 + name + ": " + clr.fgMu + status; 407 + }); 408 + o += writeAt(row, col, text); 409 + return o; 410 + }
+34
src/tui/fps.ts
··· 1 + // FPS counter: tracks frame timestamps and computes rolling FPS 2 + 3 + import { signal } from "./signals.ts"; 4 + 5 + const showFPS = signal(false); 6 + 7 + const frameTimes: number[] = []; 8 + const FPS_WINDOW = 60; 9 + let currentFPS = 0; 10 + 11 + export function recordFrame(): void { 12 + const now = performance.now(); 13 + frameTimes.push(now); 14 + while (frameTimes.length > FPS_WINDOW) frameTimes.shift(); 15 + 16 + if (frameTimes.length >= 2) { 17 + const elapsed = now - frameTimes[0]!; 18 + if (elapsed > 0) { 19 + currentFPS = Math.round(((frameTimes.length - 1) / elapsed) * 1000); 20 + } 21 + } 22 + } 23 + 24 + export function getCurrentFPS(): number { 25 + return currentFPS; 26 + } 27 + 28 + export function isFPSVisible(): boolean { 29 + return showFPS.get(); 30 + } 31 + 32 + export function toggleFPS(): void { 33 + showFPS.set(!showFPS.peek()); 34 + }
+87
src/tui/index.ts
··· 1 + // Public API for the declarative TUI framework 2 + 3 + // Input 4 + export { parseKey, type KeyEvent } from "./input.ts"; 5 + 6 + // Signals 7 + export { signal, computed, effect, batch, type Signal } from "./signals.ts"; 8 + 9 + // Types 10 + export type { Cell, Screen, ScreenContext } from "./types.ts"; 11 + export { emptyCell, cellsEqual } from "./types.ts"; 12 + 13 + // Colors / rendering utilities 14 + export type { Theme, BoxStyle } from "./colors.ts"; 15 + export { 16 + charWidth, visibleLength, stripAnsi, truncate, wrapText, pad, 17 + moveTo, fg, bg, reset, bold, dim, italic, underline, inverse, 18 + BOLD, DIM, RESET, 19 + clearScreen, hideCursor, showCursor, 20 + writeAt, fillRect, fillLine, 21 + drawBox, hSep, boxChars, 22 + progressBar as progressBarString, 23 + themes, c, initScreen, titleBar, footerBar, 24 + panel as drawPanel, panelLine, 25 + askBar as drawAskBar, askBarCompact, 26 + agentActivity, 27 + } from "./colors.ts"; 28 + 29 + // Cell buffer 30 + export { CellBuffer, diff, fullRender } from "./buffer.ts"; 31 + 32 + // Scrollable 33 + export { 34 + createScrollRegion, updateScrollRegion, 35 + scrollUp, scrollDown, pageUp, pageDown, 36 + scrollToTop, scrollToBottom, visibleSlice, 37 + type ScrollRegion, 38 + } from "./scrollable.ts"; 39 + 40 + // Text input 41 + export { 42 + createTextInput, activateTextInput, deactivateTextInput, 43 + handleTextInputKey, finishProcessing, 44 + type TextInputState, 45 + } from "./text-input.ts"; 46 + 47 + // Node types 48 + export type { 49 + UINode, Color, SemanticColor, Rect, Span, 50 + TextNode, SpacerNode, GapNode, SeparatorNode, IndentNode, 51 + DotNode, CheckboxNode, ProgressBarNode, SpinnerNode, IconNode, 52 + RowNode, ColumnNode, HStackNode, PanelNode, 53 + ScrollableNode, SelectableNode, 54 + StatusBarNode, FooterNode, AskBarNode, TextInputNode, 55 + FPSCounterNode, CanvasNode, CanvasCell, DrawContext, 56 + PtyHandle, PtyViewNode, 57 + } from "./nodes.ts"; 58 + 59 + // Builders 60 + export { 61 + text, spacer, gap, separator, indent, 62 + dot, checkbox, progressBar, spinner, icon, 63 + row, column, hstack, panel, 64 + scrollable, selectable, groupedSelectable, 65 + statusBar, footer, askBar, textInput, 66 + fpsCounter, canvas, 67 + createPty, attachPty, ptyView, themeToXterm, 68 + } from "./builders.ts"; 69 + 70 + // Layout 71 + export { layoutRoot, layoutVertical, layoutRow, layoutPanel, textWidth } from "./layout.ts"; 72 + 73 + // Renderer 74 + export { renderToAnsi, resolveColor, type RenderOpts } from "./renderer.ts"; 75 + 76 + // Screen wrapper 77 + export { screen, overlay, type DeclarativeScreenConfig, type OverlayConfig } from "./screen.ts"; 78 + 79 + // Animation 80 + export { 81 + spinnerChar, startSpinnerTimer, stopSpinnerTimer, isSpinnerRunning, 82 + } from "./animation.ts"; 83 + 84 + // FPS 85 + export { 86 + recordFrame, getCurrentFPS, isFPSVisible, toggleFPS, 87 + } from "./fps.ts";
+354
src/tui/layout.ts
··· 1 + // Two-pass layout engine: measure (bottom-up) then position (top-down) 2 + 3 + import type { 4 + UINode, Rect, RowNode, ColumnNode, HStackNode, PanelNode, 5 + ScrollableNode, SelectableNode, 6 + } from "./nodes.ts"; 7 + import { charWidth, wrapText } from "./colors.ts"; 8 + 9 + // --- Clipping helper --- 10 + 11 + /** Clip a child rect to stay within a parent boundary. Returns the clipped rect. */ 12 + function clipRect(child: Rect, parent: Rect): Rect { 13 + const right = Math.min(child.x + child.width, parent.x + parent.width); 14 + const bottom = Math.min(child.y + child.height, parent.y + parent.height); 15 + const x = Math.max(child.x, parent.x); 16 + const y = Math.max(child.y, parent.y); 17 + return { 18 + x, 19 + y, 20 + width: Math.max(0, right - x), 21 + height: Math.max(0, bottom - y), 22 + }; 23 + } 24 + 25 + // --- Text width measurement (no ANSI, plain chars only) --- 26 + 27 + export function textWidth(str: string): number { 28 + let w = 0; 29 + for (const ch of str) { 30 + w += charWidth(ch); 31 + } 32 + return w; 33 + } 34 + 35 + // --- Height measurement --- 36 + // Returns intrinsic height or "flex" for nodes that expand to fill space. 37 + 38 + export function measureHeight(node: UINode, maxWidth: number): number | "flex" { 39 + switch (node.type) { 40 + case "text": { 41 + if (node.wrap && maxWidth > 0) { 42 + const tw = textWidth(node.text); 43 + if (tw <= maxWidth) return 1; 44 + return wrapText(node.text, maxWidth).lines.length; 45 + } 46 + return 1; 47 + } 48 + case "spacer": return 0; 49 + case "gap": return node.size === "center" ? "flex" : node.size; 50 + case "separator": return 1; 51 + case "indent": return 0; 52 + case "dot": return 1; 53 + case "checkbox": return 1; 54 + case "progressBar": return 1; 55 + case "spinner": return 1; 56 + case "icon": return 1; 57 + case "row": return 1; 58 + case "column": { 59 + const cw = node.width ?? maxWidth; 60 + let hasFlexChild = false; 61 + let fixedHeight = 0; 62 + for (const child of node.children) { 63 + const h = measureHeight(child, cw); 64 + if (h === "flex") hasFlexChild = true; 65 + else fixedHeight += h; 66 + } 67 + return hasFlexChild ? "flex" : fixedHeight; 68 + } 69 + case "hstack": { 70 + let hasFlexCol = false; 71 + let maxH = 0; 72 + for (const col of node.children) { 73 + const h = measureHeight(col, maxWidth); 74 + if (h === "flex") hasFlexCol = true; 75 + else maxH = Math.max(maxH, h); 76 + } 77 + return hasFlexCol ? "flex" : maxH; 78 + } 79 + case "panel": { 80 + const contentWidth = maxWidth - 4; 81 + let hasFlexChild = false; 82 + let fixedHeight = 0; 83 + for (const child of node.children) { 84 + const h = measureHeight(child, contentWidth); 85 + if (h === "flex") hasFlexChild = true; 86 + else fixedHeight += h; 87 + } 88 + return hasFlexChild ? "flex" : fixedHeight + 2; 89 + } 90 + case "scrollable": 91 + case "selectable": 92 + return "flex"; 93 + case "statusBar": return 1; 94 + case "footer": return 1; 95 + case "askBar": return 3; 96 + case "textInput": return 1; 97 + case "fpsCounter": return 1; 98 + case "canvas": return node.height ?? "flex"; 99 + case "ptyView": return "flex"; 100 + } 101 + } 102 + 103 + // --- Width measurement --- 104 + // Returns intrinsic width or "flex" for nodes that expand horizontally. 105 + 106 + export function measureWidth(node: UINode): number | "flex" { 107 + switch (node.type) { 108 + case "text": return (node.truncate || node.wrap) ? "flex" : textWidth(node.text); 109 + case "spacer": return "flex"; 110 + case "gap": return 0; 111 + case "separator": return "flex"; 112 + case "indent": return node.depth * 2; 113 + case "dot": return 1; 114 + case "checkbox": return 1; 115 + case "progressBar": return node.width ?? "flex"; 116 + case "spinner": return 1; 117 + case "icon": return charWidth(node.char); 118 + case "textInput": return "flex"; 119 + case "fpsCounter": return 8; 120 + case "canvas": return node.widthHint ?? "flex"; 121 + case "ptyView": return "flex"; 122 + default: return 0; 123 + } 124 + } 125 + 126 + // --- Layout entry point --- 127 + 128 + export function layoutRoot(nodes: UINode[], viewport: Rect): void { 129 + // Extract statusBar and footer from the top-level list 130 + let topY = viewport.y; 131 + let bottomY = viewport.y + viewport.height; 132 + 133 + for (const node of nodes) { 134 + if (node.type === "statusBar") { 135 + node._rect = { x: viewport.x, y: topY, width: viewport.width, height: 1 }; 136 + topY++; 137 + } 138 + } 139 + 140 + // Footer anchors to bottom (process in reverse to stack correctly) 141 + for (let i = nodes.length - 1; i >= 0; i--) { 142 + if (nodes[i].type === "footer") { 143 + bottomY--; 144 + nodes[i]._rect = { x: viewport.x, y: bottomY, width: viewport.width, height: 1 }; 145 + } 146 + } 147 + 148 + // Layout remaining children vertically in the middle area 149 + const middle = nodes.filter(n => n.type !== "statusBar" && n.type !== "footer"); 150 + const middleRect = { x: viewport.x, y: topY, width: viewport.width, height: bottomY - topY }; 151 + layoutVertical(middle, middleRect); 152 + } 153 + 154 + // --- Vertical flow layout --- 155 + 156 + export function layoutVertical(nodes: UINode[], rect: Rect): void { 157 + if (nodes.length === 0) return; 158 + 159 + // First pass: measure all heights 160 + const heights: (number | "flex")[] = nodes.map(n => measureHeight(n, rect.width)); 161 + 162 + // Calculate flex distribution 163 + let fixedTotal = 0; 164 + let flexCount = 0; 165 + for (const h of heights) { 166 + if (h === "flex") flexCount++; 167 + else fixedTotal += h; 168 + } 169 + 170 + const remaining = Math.max(0, rect.height - fixedTotal); 171 + const flexSize = flexCount > 0 ? Math.floor(remaining / flexCount) : 0; 172 + const flexRemainder = flexCount > 0 ? remaining - flexSize * flexCount : 0; 173 + 174 + // Second pass: assign rects 175 + let y = rect.y; 176 + let flexIndex = 0; 177 + for (let i = 0; i < nodes.length; i++) { 178 + const h = heights[i] === "flex" 179 + ? flexSize + (flexIndex++ < flexRemainder ? 1 : 0) 180 + : heights[i] as number; 181 + 182 + const nodeRect = clipRect({ x: rect.x, y, width: rect.width, height: h }, rect); 183 + nodes[i]._rect = nodeRect; 184 + if (nodeRect.width > 0 && nodeRect.height > 0) { 185 + layoutChildren(nodes[i], nodeRect); 186 + } 187 + y += h; 188 + } 189 + } 190 + 191 + // --- Horizontal flow layout (for rows) --- 192 + 193 + export function layoutRow(children: UINode[], rect: Rect): void { 194 + if (children.length === 0) return; 195 + 196 + // First pass: measure widths 197 + const widths: (number | "flex")[] = children.map(n => measureWidth(n)); 198 + 199 + // Calculate flex distribution 200 + let fixedTotal = 0; 201 + let flexCount = 0; 202 + for (const w of widths) { 203 + if (w === "flex") flexCount++; 204 + else fixedTotal += w; 205 + } 206 + 207 + const remaining = Math.max(0, rect.width - fixedTotal); 208 + const flexSize = flexCount > 0 ? Math.floor(remaining / flexCount) : 0; 209 + const flexRemainder = flexCount > 0 ? remaining - flexSize * flexCount : 0; 210 + 211 + // Second pass: assign positions 212 + let x = rect.x; 213 + let flexIndex = 0; 214 + for (let i = 0; i < children.length; i++) { 215 + const w = widths[i] === "flex" 216 + ? flexSize + (flexIndex++ < flexRemainder ? 1 : 0) 217 + : widths[i] as number; 218 + 219 + children[i]._rect = clipRect({ x, y: rect.y, width: w, height: rect.height }, rect); 220 + x += w; 221 + } 222 + } 223 + 224 + // --- Container-specific layout --- 225 + 226 + function layoutChildren(node: UINode, rect: Rect): void { 227 + if (rect.width <= 0 || rect.height <= 0) return; 228 + switch (node.type) { 229 + case "row": 230 + layoutRow(node.children, rect); 231 + break; 232 + case "column": 233 + layoutVertical(node.children, rect); 234 + break; 235 + case "hstack": 236 + layoutHStack(node, rect); 237 + break; 238 + case "panel": 239 + layoutPanel(node, rect); 240 + break; 241 + case "scrollable": 242 + layoutScrollable(node, rect); 243 + break; 244 + case "selectable": 245 + layoutScrollable(node, rect); 246 + break; 247 + } 248 + } 249 + 250 + function layoutHStack(node: HStackNode, rect: Rect): void { 251 + const columns = node.children; 252 + const gapSize = node.gap ?? 0; 253 + const totalGap = gapSize * Math.max(0, columns.length - 1); 254 + 255 + // Measure fixed-width columns 256 + let fixedWidth = 0; 257 + let flexCount = 0; 258 + for (const col of columns) { 259 + if (col.width !== undefined) { 260 + fixedWidth += col.width; 261 + } else { 262 + flexCount++; 263 + } 264 + } 265 + 266 + const remaining = Math.max(0, rect.width - fixedWidth - totalGap); 267 + const flexWidth = flexCount > 0 ? Math.floor(remaining / flexCount) : 0; 268 + const flexRemainder = flexCount > 0 ? remaining - flexWidth * flexCount : 0; 269 + 270 + let x = rect.x; 271 + let flexIndex = 0; 272 + for (const col of columns) { 273 + let w: number; 274 + if (col.width !== undefined) { 275 + w = col.width; 276 + } else { 277 + w = flexWidth + (flexIndex < flexRemainder ? 1 : 0); 278 + flexIndex++; 279 + } 280 + 281 + col._rect = clipRect({ x, y: rect.y, width: w, height: rect.height }, rect); 282 + if (col._rect.width > 0 && col._rect.height > 0) { 283 + layoutVertical(col.children, col._rect); 284 + } 285 + x += w + gapSize; 286 + } 287 + } 288 + 289 + export function layoutPanel(node: PanelNode, rect: Rect): void { 290 + // Content area: inset 2 cols from sides (border + padding), 1 row top/bottom 291 + const contentX = rect.x + 2; 292 + const contentY = rect.y + 1; 293 + const contentW = Math.max(0, rect.width - 4); 294 + const contentH = Math.max(0, rect.height - 2); 295 + const contentRect: Rect = { x: contentX, y: contentY, width: contentW, height: contentH }; 296 + 297 + // Measure children heights 298 + const heights: (number | "flex")[] = node.children.map(child => 299 + measureHeight(child, contentW), 300 + ); 301 + 302 + let fixedTotal = 0; 303 + let flexCount = 0; 304 + for (const h of heights) { 305 + if (h === "flex") flexCount++; 306 + else fixedTotal += h; 307 + } 308 + 309 + const remaining = Math.max(0, contentH - fixedTotal); 310 + const flexSize = flexCount > 0 ? Math.floor(remaining / flexCount) : 0; 311 + const flexRemainder = flexCount > 0 ? remaining - flexSize * flexCount : 0; 312 + 313 + let y = contentY; 314 + let flexIndex = 0; 315 + for (let i = 0; i < node.children.length; i++) { 316 + const child = node.children[i]; 317 + const h = heights[i] === "flex" 318 + ? flexSize + (flexIndex++ < flexRemainder ? 1 : 0) 319 + : heights[i] as number; 320 + 321 + if (child.type === "separator") { 322 + // Separator spans the full panel width (connects to borders) 323 + const sepRect = clipRect({ x: rect.x, y, width: rect.width, height: 1 }, { x: rect.x, y: contentY, width: rect.width, height: contentH }); 324 + child._rect = sepRect; 325 + } else { 326 + const childRect = clipRect({ x: contentX, y, width: contentW, height: h }, contentRect); 327 + child._rect = childRect; 328 + if (childRect.width > 0 && childRect.height > 0) { 329 + layoutChildren(child, child._rect); 330 + } 331 + } 332 + y += h; 333 + } 334 + } 335 + 336 + function layoutScrollable( 337 + node: ScrollableNode | SelectableNode, 338 + rect: Rect, 339 + ): void { 340 + const offset = node.offset; 341 + const visibleCount = Math.min(node.items.length - offset, rect.height); 342 + 343 + for (let i = 0; i < visibleCount; i++) { 344 + const itemIndex = offset + i; 345 + const itemNodes = node.items[itemIndex]; 346 + if (!itemNodes) continue; 347 + 348 + const rowRect = clipRect({ x: rect.x, y: rect.y + i, width: rect.width, height: 1 }, rect); 349 + if (rowRect.width > 0 && rowRect.height > 0) { 350 + // Layout each item's nodes as a horizontal row 351 + layoutRow(itemNodes, rowRect); 352 + } 353 + } 354 + }
+289
src/tui/nodes.ts
··· 1 + // UINode type definitions for the declarative UI framework 2 + 3 + import type { BoxStyle } from "./colors.ts"; 4 + 5 + // --- Color types --- 6 + export type SemanticColor = 7 + | "ok" | "muted" | "error" | "accent" 8 + | "primary" | "secondary" | "warn" | "info" | "border"; 9 + 10 + export type Color = SemanticColor | [number, number, number]; 11 + 12 + // --- Layout rect (0-based coordinates) --- 13 + export interface Rect { 14 + x: number; 15 + y: number; 16 + width: number; 17 + height: number; 18 + } 19 + 20 + // --- Highlight spans --- 21 + 22 + /** A colored span within a text node, used by the highlight callback. */ 23 + export interface Span { 24 + /** Start character index (inclusive, code-point based). */ 25 + start: number; 26 + /** End character index (exclusive, code-point based). */ 27 + end: number; 28 + color?: Color; 29 + bold?: boolean; 30 + dim?: boolean; 31 + italic?: boolean; 32 + } 33 + 34 + // --- Node definitions --- 35 + 36 + export interface TextNode { 37 + type: "text"; 38 + text: string; 39 + color?: Color; 40 + bold?: boolean; 41 + dim?: boolean; 42 + italic?: boolean; 43 + truncate?: boolean; 44 + /** Soft-wrap text to fit the available width. Expands height as needed. */ 45 + wrap?: boolean; 46 + /** Highlight callback: receives the full text, returns colored spans. 47 + * Applied before wrapping — the framework splits spans at wrap boundaries. */ 48 + highlight?: (text: string) => Span[]; 49 + _rect?: Rect; 50 + } 51 + 52 + export interface SpacerNode { 53 + type: "spacer"; 54 + _rect?: Rect; 55 + } 56 + 57 + export interface GapNode { 58 + type: "gap"; 59 + size: number | "center"; 60 + _rect?: Rect; 61 + } 62 + 63 + export interface SeparatorNode { 64 + type: "separator"; 65 + _rect?: Rect; 66 + } 67 + 68 + export interface IndentNode { 69 + type: "indent"; 70 + depth: number; 71 + _rect?: Rect; 72 + } 73 + 74 + export interface DotNode { 75 + type: "dot"; 76 + filled: boolean; 77 + color?: Color; 78 + _rect?: Rect; 79 + } 80 + 81 + export interface CheckboxNode { 82 + type: "checkbox"; 83 + checked: boolean; 84 + color?: Color; 85 + _rect?: Rect; 86 + } 87 + 88 + export interface ProgressBarNode { 89 + type: "progressBar"; 90 + percent: number; 91 + width?: number; 92 + color?: Color; 93 + _rect?: Rect; 94 + } 95 + 96 + export interface SpinnerNode { 97 + type: "spinner"; 98 + color?: Color; 99 + _rect?: Rect; 100 + } 101 + 102 + export interface IconNode { 103 + type: "icon"; 104 + char: string; 105 + color?: Color; 106 + _rect?: Rect; 107 + } 108 + 109 + export interface RowNode { 110 + type: "row"; 111 + children: UINode[]; 112 + _rect?: Rect; 113 + } 114 + 115 + export interface ColumnNode { 116 + type: "column"; 117 + children: UINode[]; 118 + width?: number; 119 + flex?: boolean; 120 + _rect?: Rect; 121 + } 122 + 123 + export interface HStackNode { 124 + type: "hstack"; 125 + children: ColumnNode[]; 126 + gap?: number; 127 + _rect?: Rect; 128 + } 129 + 130 + export interface PanelNode { 131 + type: "panel"; 132 + title: string; 133 + children: UINode[]; 134 + style?: BoxStyle; 135 + _rect?: Rect; 136 + } 137 + 138 + export interface ScrollableNode { 139 + type: "scrollable"; 140 + items: UINode[][]; 141 + offset: number; 142 + totalItems: number; 143 + _rect?: Rect; 144 + } 145 + 146 + export interface SelectableNode { 147 + type: "selectable"; 148 + items: UINode[][]; 149 + selectedIndex: number; 150 + offset: number; 151 + totalItems: number; 152 + _rect?: Rect; 153 + } 154 + 155 + export interface StatusBarNode { 156 + type: "statusBar"; 157 + left: string; 158 + right: string; 159 + _rect?: Rect; 160 + } 161 + 162 + export interface FooterNode { 163 + type: "footer"; 164 + hints: string; 165 + _rect?: Rect; 166 + } 167 + 168 + export interface AskBarNode { 169 + type: "askBar"; 170 + text: string; 171 + placeholder: string; 172 + active: boolean; 173 + rightLabel?: string; 174 + style?: BoxStyle; 175 + _rect?: Rect; 176 + } 177 + 178 + export interface TextInputNode { 179 + type: "textInput"; 180 + text: string; 181 + cursor: number; 182 + active: boolean; 183 + placeholder?: string; 184 + _rect?: Rect; 185 + } 186 + 187 + export interface FPSCounterNode { 188 + type: "fpsCounter"; 189 + _rect?: Rect; 190 + } 191 + 192 + /** A cell written by a canvas draw callback. */ 193 + export interface CanvasCell { 194 + x: number; 195 + y: number; 196 + char: string; 197 + color?: Color; 198 + bg?: Color; 199 + bold?: boolean; 200 + dim?: boolean; 201 + } 202 + 203 + /** Drawing context passed to the canvas draw callback. */ 204 + export interface DrawContext { 205 + /** Canvas width in columns. */ 206 + width: number; 207 + /** Canvas height in rows. */ 208 + height: number; 209 + /** Place a single character at (x, y) relative to the canvas origin. */ 210 + set(x: number, y: number, char: string, color?: Color, bg?: Color, bold?: boolean): void; 211 + /** Write a string starting at (x, y). Flows left-to-right, no wrapping. */ 212 + write(x: number, y: number, str: string, color?: Color, bg?: Color, bold?: boolean): void; 213 + /** Fill a rectangle with a character. */ 214 + fill(x: number, y: number, w: number, h: number, char?: string, color?: Color, bg?: Color): void; 215 + } 216 + 217 + export interface CanvasNode { 218 + type: "canvas"; 219 + /** Fixed height if set. Otherwise flex (fills available space). */ 220 + height?: number; 221 + /** Fixed width if set. Otherwise flex (fills available space in a row). */ 222 + widthHint?: number; 223 + /** Draw callback — called during rendering with the resolved rect dimensions. */ 224 + draw: (ctx: DrawContext) => void; 225 + /** Cells produced by the draw callback. Populated during rendering. */ 226 + _cells: CanvasCell[]; 227 + _rect?: Rect; 228 + } 229 + 230 + /** Handle returned by createPty(). Holds the spawned process + xterm terminal. */ 231 + export interface PtyHandle { 232 + /** Write raw input to the child process. */ 233 + write(data: string): void; 234 + /** Resize the child PTY. Called automatically by the layout engine. */ 235 + resize(cols: number, rows: number): void; 236 + /** 237 + * Read the terminal's cell grid directly. Full fidelity — every attribute 238 + * xterm parsed is preserved. No serialize round-trip. 239 + */ 240 + readCells(): { char: string; fg: [number, number, number] | null; bg: [number, number, number] | null; bold: boolean; dim: boolean; italic: boolean; underline: boolean }[][]; 241 + /** Current PTY dimensions. */ 242 + cols: number; 243 + rows: number; 244 + /** Kill the child process / detach from server. */ 245 + kill(): void; 246 + /** True after the child has exited or been detached. */ 247 + exited: boolean; 248 + /** True when the PTY has received new data since the last render. Cleared by the consumer. */ 249 + dirty: boolean; 250 + /** Optional callback fired when the PTY receives data. Used for event-driven rendering. */ 251 + onActivity: (() => void) | null; 252 + /** Update the xterm terminal's color theme. Call when the app theme changes. */ 253 + setTheme(theme: import("./colors.ts").Theme): void; 254 + } 255 + 256 + export interface PtyViewNode { 257 + type: "ptyView"; 258 + handle: PtyHandle; 259 + /** Last size the PTY was resized to, tracked to avoid redundant resize calls. */ 260 + _lastCols: number; 261 + _lastRows: number; 262 + _rect?: Rect; 263 + } 264 + 265 + // --- Union type --- 266 + export type UINode = 267 + | TextNode 268 + | SpacerNode 269 + | GapNode 270 + | SeparatorNode 271 + | IndentNode 272 + | DotNode 273 + | CheckboxNode 274 + | ProgressBarNode 275 + | SpinnerNode 276 + | IconNode 277 + | RowNode 278 + | ColumnNode 279 + | HStackNode 280 + | PanelNode 281 + | ScrollableNode 282 + | SelectableNode 283 + | StatusBarNode 284 + | FooterNode 285 + | AskBarNode 286 + | TextInputNode 287 + | FPSCounterNode 288 + | CanvasNode 289 + | PtyViewNode;
+543
src/tui/renderer.ts
··· 1 + // Renders positioned UI nodes to ANSI strings 2 + 3 + import type { 4 + UINode, Color, Rect, TextNode, DotNode, CheckboxNode, 5 + ProgressBarNode, SpinnerNode, IconNode, RowNode, ColumnNode, 6 + HStackNode, PanelNode, ScrollableNode, SelectableNode, 7 + StatusBarNode, FooterNode, AskBarNode, TextInputNode, 8 + CanvasNode, CanvasCell, DrawContext, PtyViewNode, 9 + } from "./nodes.ts"; 10 + import type { Theme, BoxStyle } from "./colors.ts"; 11 + import { 12 + moveTo, fg, bg, reset, bold as boldFn, dim as dimFn, italic as italicFn, 13 + BOLD, DIM, RESET, fillRect, fillLine, writeAt, drawBox, hSep, 14 + visibleLength, charWidth, wrapText, progressBar as renderProgressBarStr, 15 + boxChars, 16 + } from "./colors.ts"; 17 + import { textWidth } from "./layout.ts"; 18 + 19 + // --- Render options passed from screen wrapper --- 20 + 21 + export interface RenderOpts { 22 + spinnerChar: string; 23 + fps: number; 24 + showFPS: boolean; 25 + } 26 + 27 + // --- Semantic color resolution --- 28 + 29 + export function resolveColor(color: Color | undefined, theme: Theme): [number, number, number] | null { 30 + if (!color) return null; 31 + if (Array.isArray(color)) return color; 32 + switch (color) { 33 + case "primary": return theme.fg1; 34 + case "secondary": return theme.fg2; 35 + case "accent": return theme.fgAc; 36 + case "muted": return theme.fgMu; 37 + case "ok": return theme.ok; 38 + case "warn": return theme.warn; 39 + case "error": return theme.err; 40 + case "info": return theme.info; 41 + case "border": return theme.border; 42 + default: return null; 43 + } 44 + } 45 + 46 + function fgColor(rgb: [number, number, number] | null): string { 47 + return rgb ? fg(rgb[0], rgb[1], rgb[2]) : ""; 48 + } 49 + 50 + function bgColor(rgb: [number, number, number] | null): string { 51 + return rgb ? bg(rgb[0], rgb[1], rgb[2]) : ""; 52 + } 53 + 54 + // --- Truncate plain text to fit width --- 55 + 56 + function truncateText(str: string, maxWidth: number): string { 57 + if (maxWidth <= 1) return str.slice(0, maxWidth); 58 + let w = 0; 59 + let result = ""; 60 + for (const ch of str) { 61 + const cw = charWidth(ch); 62 + if (w + cw + 1 > maxWidth) break; 63 + result += ch; 64 + w += cw; 65 + } 66 + return result + "\u2026"; 67 + } 68 + 69 + // --- Main render entry point --- 70 + 71 + export function renderToAnsi( 72 + nodes: UINode[], 73 + theme: Theme, 74 + boxStyle: BoxStyle, 75 + opts: RenderOpts, 76 + clip?: Rect, 77 + ): string { 78 + let out = ""; 79 + for (const node of nodes) { 80 + out += renderNode(node, theme, boxStyle, opts, clip); 81 + } 82 + return out; 83 + } 84 + 85 + // --- Node dispatch --- 86 + 87 + function renderNode( 88 + node: UINode, 89 + theme: Theme, 90 + boxStyle: BoxStyle, 91 + opts: RenderOpts, 92 + clip?: Rect, 93 + ): string { 94 + if (!node._rect) return ""; 95 + const rect = node._rect; 96 + 97 + if (clip) { 98 + if (rect.y >= clip.y + clip.height || rect.y + rect.height <= clip.y || 99 + rect.x >= clip.x + clip.width || rect.x + rect.width <= clip.x) { 100 + return ""; 101 + } 102 + } 103 + 104 + switch (node.type) { 105 + case "text": return renderText(node, rect, theme, clip); 106 + case "spacer": return ""; 107 + case "gap": return ""; 108 + case "separator": return renderSeparator(rect, theme, boxStyle); 109 + case "indent": return ""; 110 + case "dot": return renderDot(node, rect, theme); 111 + case "checkbox": return renderCheckbox(node, rect, theme); 112 + case "progressBar": return renderProgressBar(node, rect, theme); 113 + case "spinner": return renderSpinner(node, rect, theme, opts); 114 + case "icon": return renderIcon(node, rect, theme); 115 + case "row": return renderContainer(node.children, theme, boxStyle, opts, clip); 116 + case "column": return renderContainer(node.children, theme, boxStyle, opts, clip); 117 + case "hstack": return renderHStack(node, theme, boxStyle, opts, clip); 118 + case "panel": return renderPanel(node, theme, boxStyle, opts, clip); 119 + case "scrollable": return renderScrollable(node, theme, boxStyle, opts, clip); 120 + case "selectable": return renderScrollable(node, theme, boxStyle, opts, clip); 121 + case "statusBar": return renderStatusBar(node, rect, theme); 122 + case "footer": return renderFooter(node, rect, theme); 123 + case "askBar": return renderAskBar(node, rect, theme, boxStyle); 124 + case "textInput": return renderTextInput(node, rect, theme); 125 + case "fpsCounter": return renderFPSCounter(rect, theme, opts); 126 + case "canvas": return renderCanvas(node, rect, theme); 127 + case "ptyView": return renderPtyView(node, rect, theme); 128 + default: return ""; 129 + } 130 + } 131 + 132 + // --- Leaf renderers --- 133 + 134 + function renderText(node: TextNode, rect: Rect, theme: Theme, clip?: Rect): string { 135 + const color = resolveColor(node.color, theme); 136 + const content = node.text; 137 + const spans = node.highlight ? node.highlight(content) : null; 138 + 139 + let maxW = rect.width; 140 + if (clip) { 141 + maxW = Math.min(maxW, (clip.x + clip.width) - rect.x); 142 + if (maxW <= 0) return ""; 143 + } 144 + 145 + if (node.wrap && maxW > 0) { 146 + // Soft-wrap: render multiple lines 147 + const { lines, offsets } = wrapText(content, maxW); 148 + const maxLines = Math.min(lines.length, rect.height); 149 + let out = ""; 150 + for (let i = 0; i < maxLines; i++) { 151 + out += renderTextLine(lines[i], offsets[i], rect.y + i, rect.x, color, node.bold, node.dim, node.italic, spans, theme); 152 + } 153 + return out; 154 + } 155 + 156 + // Single-line: truncate if needed 157 + let displayContent = content; 158 + if (node.truncate && textWidth(content) > maxW) { 159 + displayContent = truncateText(content, maxW); 160 + } else if (clip && textWidth(content) > maxW) { 161 + displayContent = truncateText(content, maxW); 162 + } 163 + 164 + if (spans) { 165 + return renderTextLine(displayContent, 0, rect.y, rect.x, color, node.bold, node.dim, node.italic, spans, theme); 166 + } 167 + 168 + let out = moveTo(rect.y + 1, rect.x + 1); 169 + if (color) out += fgColor(color); 170 + if (node.bold) out += BOLD; 171 + if (node.dim) out += DIM; 172 + if (node.italic) out += "\x1b[3m"; 173 + out += displayContent; 174 + out += RESET; 175 + return out; 176 + } 177 + 178 + /** Render a single text line with optional highlight spans. */ 179 + function renderTextLine( 180 + line: string, 181 + textOffset: number, 182 + y: number, 183 + x: number, 184 + defaultColor: [number, number, number] | null, 185 + bold?: boolean, 186 + dim?: boolean, 187 + italic?: boolean, 188 + spans?: import("./nodes.ts").Span[] | null, 189 + theme?: Theme, 190 + ): string { 191 + if (!spans || !theme) { 192 + // No highlighting — render as a simple string 193 + let out = moveTo(y + 1, x + 1); 194 + if (defaultColor) out += fgColor(defaultColor); 195 + if (bold) out += BOLD; 196 + if (dim) out += DIM; 197 + if (italic) out += "\x1b[3m"; 198 + out += line; 199 + out += RESET; 200 + return out; 201 + } 202 + 203 + // Render character by character with span styles 204 + let out = moveTo(y + 1, x + 1); 205 + let charIdx = textOffset; 206 + let lastStyle = ""; 207 + 208 + for (const ch of line) { 209 + let fg = defaultColor; 210 + let b = bold ?? false; 211 + let d = dim ?? false; 212 + let it = italic ?? false; 213 + 214 + for (const span of spans) { 215 + if (charIdx >= span.start && charIdx < span.end) { 216 + if (span.color !== undefined) fg = resolveColor(span.color, theme); 217 + if (span.bold !== undefined) b = span.bold; 218 + if (span.dim !== undefined) d = span.dim; 219 + if (span.italic !== undefined) it = span.italic; 220 + break; 221 + } 222 + } 223 + 224 + // Build style string 225 + let style = ""; 226 + if (fg) style += fgColor(fg); 227 + if (b) style += BOLD; 228 + if (d) style += DIM; 229 + if (it) style += "\x1b[3m"; 230 + 231 + if (style !== lastStyle) { 232 + out += RESET + style; 233 + lastStyle = style; 234 + } 235 + out += ch; 236 + charIdx++; 237 + } 238 + out += RESET; 239 + return out; 240 + } 241 + 242 + function renderDot(node: DotNode, rect: Rect, theme: Theme): string { 243 + const color = resolveColor(node.color, theme); 244 + const ch = node.filled ? "\u25cf" : "\u25cb"; 245 + return moveTo(rect.y + 1, rect.x + 1) + fgColor(color) + ch + RESET; 246 + } 247 + 248 + function renderCheckbox(node: CheckboxNode, rect: Rect, theme: Theme): string { 249 + const color = resolveColor(node.color, theme); 250 + const ch = node.checked ? "\u25a0" : "\u25a1"; 251 + return moveTo(rect.y + 1, rect.x + 1) + fgColor(color) + ch + RESET; 252 + } 253 + 254 + function renderProgressBar(node: ProgressBarNode, rect: Rect, theme: Theme): string { 255 + const color = resolveColor(node.color, theme) ?? theme.info; 256 + const barStr = renderProgressBarStr(rect.width, node.percent); 257 + return moveTo(rect.y + 1, rect.x + 1) + fgColor(color) + barStr + RESET; 258 + } 259 + 260 + function renderSpinner(node: SpinnerNode, rect: Rect, theme: Theme, opts: RenderOpts): string { 261 + const color = resolveColor(node.color, theme) ?? theme.fgAc; 262 + return moveTo(rect.y + 1, rect.x + 1) + fgColor(color) + opts.spinnerChar + RESET; 263 + } 264 + 265 + function renderIcon(node: IconNode, rect: Rect, theme: Theme): string { 266 + const color = resolveColor(node.color, theme); 267 + return moveTo(rect.y + 1, rect.x + 1) + fgColor(color) + node.char + RESET; 268 + } 269 + 270 + function renderSeparator(rect: Rect, theme: Theme, boxStyle: BoxStyle): string { 271 + return fg(theme.border[0], theme.border[1], theme.border[2]) 272 + + hSep(rect.y + 1, rect.x + 1, rect.width, boxStyle) 273 + + RESET; 274 + } 275 + 276 + // --- Container renderers --- 277 + 278 + function renderContainer( 279 + children: UINode[], 280 + theme: Theme, 281 + boxStyle: BoxStyle, 282 + opts: RenderOpts, 283 + clip?: Rect, 284 + ): string { 285 + let out = ""; 286 + for (const child of children) { 287 + out += renderNode(child, theme, boxStyle, opts, clip); 288 + } 289 + return out; 290 + } 291 + 292 + function renderHStack( 293 + node: HStackNode, 294 + theme: Theme, 295 + boxStyle: BoxStyle, 296 + opts: RenderOpts, 297 + clip?: Rect, 298 + ): string { 299 + let out = ""; 300 + for (const col of node.children) { 301 + out += renderNode(col, theme, boxStyle, opts, clip); 302 + } 303 + return out; 304 + } 305 + 306 + function renderPanel( 307 + node: PanelNode, 308 + theme: Theme, 309 + bs: BoxStyle, 310 + opts: RenderOpts, 311 + clip?: Rect, 312 + ): string { 313 + const rect = node._rect!; 314 + const style = node.style ?? bs; 315 + let out = ""; 316 + 317 + // Fill panel background 318 + out += bgColor(theme.bg2) + fillRect(rect.y + 1, rect.x + 1, rect.width, rect.height); 319 + 320 + // Draw border 321 + out += fg(theme.border[0], theme.border[1], theme.border[2]) 322 + + drawBox(rect.y + 1, rect.x + 1, rect.width, rect.height, { style }); 323 + 324 + // Title 325 + if (node.title) { 326 + out += writeAt(rect.y + 1, rect.x + 3, 327 + " " + fgColor(theme.fgAc) + BOLD + node.title + RESET 328 + + bgColor(theme.bg2) + fg(theme.border[0], theme.border[1], theme.border[2]) + " "); 329 + } 330 + 331 + // Render children 332 + for (const child of node.children) { 333 + out += renderNode(child, theme, style, opts, clip); 334 + } 335 + 336 + return out; 337 + } 338 + 339 + function renderScrollable( 340 + node: ScrollableNode | SelectableNode, 341 + theme: Theme, 342 + boxStyle: BoxStyle, 343 + opts: RenderOpts, 344 + clip?: Rect, 345 + ): string { 346 + const rect = node._rect!; 347 + const offset = node.offset; 348 + const visibleCount = Math.min(node.items.length - offset, rect.height); 349 + let out = ""; 350 + 351 + for (let i = 0; i < visibleCount; i++) { 352 + const itemIndex = offset + i; 353 + const itemNodes = node.items[itemIndex]; 354 + if (!itemNodes) continue; 355 + 356 + for (const itemNode of itemNodes) { 357 + out += renderNode(itemNode, theme, boxStyle, opts, clip); 358 + } 359 + } 360 + 361 + return out; 362 + } 363 + 364 + // --- Composite renderers --- 365 + 366 + function renderStatusBar(node: StatusBarNode, rect: Rect, theme: Theme): string { 367 + let out = ""; 368 + out += bgColor(theme.bgAc) + fgColor(theme.fg1); 369 + out += fillLine(rect.y + 1, rect.x + 1, rect.width); 370 + out += writeAt(rect.y + 1, rect.x + 3, BOLD + node.left + RESET + bgColor(theme.bgAc)); 371 + if (node.right) { 372 + const rightWidth = visibleLength(node.right); 373 + out += writeAt(rect.y + 1, rect.x + rect.width - rightWidth - 2, 374 + fgColor(theme.fg1) + bgColor(theme.bgAc) + node.right); 375 + } 376 + out += RESET; 377 + return out; 378 + } 379 + 380 + function renderFooter(node: FooterNode, rect: Rect, theme: Theme): string { 381 + return fgColor(theme.fgMu) + writeAt(rect.y + 1, rect.x + 2, node.hints) + RESET; 382 + } 383 + 384 + function renderAskBar( 385 + node: AskBarNode, 386 + rect: Rect, 387 + theme: Theme, 388 + bs: BoxStyle, 389 + ): string { 390 + const style = node.style ?? bs; 391 + let out = ""; 392 + 393 + // Background 394 + out += bgColor(theme.bg2) + fillRect(rect.y + 1, rect.x + 1, rect.width, rect.height); 395 + 396 + // Border 397 + out += fg(theme.border[0], theme.border[1], theme.border[2]) 398 + + drawBox(rect.y + 1, rect.x + 1, rect.width, rect.height, { style }); 399 + 400 + if (node.active) { 401 + out += writeAt(rect.y + 2, rect.x + 4, 402 + fgColor(theme.fg1) + "> " + node.text + "\u2588"); 403 + } else { 404 + out += writeAt(rect.y + 2, rect.x + 4, fgColor(theme.fgMu) + node.placeholder); 405 + } 406 + 407 + if (node.rightLabel) { 408 + const labelW = visibleLength(node.rightLabel); 409 + out += writeAt(rect.y + 2, rect.x + rect.width - labelW - 3, 410 + fgColor(theme.fg2) + node.rightLabel); 411 + } 412 + 413 + out += RESET; 414 + return out; 415 + } 416 + 417 + function renderTextInput(node: TextInputNode, rect: Rect, theme: Theme): string { 418 + if (node.active) { 419 + const before = node.text.slice(0, node.cursor); 420 + const after = node.text.slice(node.cursor); 421 + return moveTo(rect.y + 1, rect.x + 1) + fgColor(theme.fg1) 422 + + before + "\u2588" + after + RESET; 423 + } 424 + if (node.placeholder) { 425 + return moveTo(rect.y + 1, rect.x + 1) + fgColor(theme.fgMu) 426 + + node.placeholder + RESET; 427 + } 428 + return ""; 429 + } 430 + 431 + function renderFPSCounter(rect: Rect, theme: Theme, opts: RenderOpts): string { 432 + if (!opts.showFPS) return ""; 433 + const label = `${opts.fps} FPS`; 434 + return moveTo(rect.y + 1, rect.x + 1) + fgColor(theme.fgAc) + label + RESET; 435 + } 436 + 437 + /** Execute the canvas draw callback, then render the produced cells to ANSI. */ 438 + function renderCanvas(node: CanvasNode, rect: Rect, theme: Theme): string { 439 + // Run the draw callback to populate _cells 440 + executeCanvasDraw(node, rect, theme); 441 + 442 + // Render each cell 443 + let out = ""; 444 + for (const cell of node._cells) { 445 + const absX = rect.x + cell.x; 446 + const absY = rect.y + cell.y; 447 + if (absY < 0 || absY >= rect.y + rect.height) continue; 448 + if (absX < 0 || absX >= rect.x + rect.width) continue; 449 + 450 + out += moveTo(absY + 1, absX + 1); 451 + const fgC = resolveColor(cell.color, theme); 452 + const bgC = resolveColor(cell.bg, theme); 453 + if (bgC) out += bgColor(bgC); 454 + if (fgC) out += fgColor(fgC); 455 + if (cell.bold) out += BOLD; 456 + if (cell.dim) out += DIM; 457 + out += cell.char; 458 + out += RESET; 459 + } 460 + return out; 461 + } 462 + 463 + /** 464 + * Run the canvas draw callback, building the DrawContext and collecting cells. 465 + * Exported so the buffer renderer can also call it. 466 + */ 467 + export function executeCanvasDraw(node: CanvasNode, rect: Rect, theme: Theme): void { 468 + node._cells = []; 469 + const cells = node._cells; 470 + const w = rect.width; 471 + const h = rect.height; 472 + 473 + const ctx: DrawContext = { 474 + width: w, 475 + height: h, 476 + 477 + set(x: number, y: number, char: string, color?: Color, bgc?: Color, bold?: boolean): void { 478 + if (x >= 0 && x < w && y >= 0 && y < h) { 479 + cells.push({ x, y, char, color, bg: bgc, bold }); 480 + } 481 + }, 482 + 483 + write(x: number, y: number, str: string, color?: Color, bgc?: Color, bold?: boolean): void { 484 + let cx = x; 485 + for (const ch of str) { 486 + if (cx >= w) break; 487 + if (cx >= 0 && y >= 0 && y < h) { 488 + cells.push({ x: cx, y, char: ch, color, bg: bgc, bold }); 489 + } 490 + cx += charWidth(ch); 491 + } 492 + }, 493 + 494 + fill(x: number, y: number, fw: number, fh: number, char?: string, color?: Color, bgc?: Color): void { 495 + const ch = char ?? " "; 496 + for (let fy = y; fy < y + fh && fy < h; fy++) { 497 + for (let fx = x; fx < x + fw && fx < w; fx++) { 498 + if (fx >= 0 && fy >= 0) { 499 + cells.push({ x: fx, y: fy, char: ch, color, bg: bgc }); 500 + } 501 + } 502 + } 503 + }, 504 + }; 505 + 506 + node.draw(ctx); 507 + } 508 + 509 + /** Render an embedded PTY view by reading xterm cells directly. */ 510 + function renderPtyView(node: PtyViewNode, rect: Rect, theme: Theme): string { 511 + const handle = node.handle; 512 + 513 + // Resize the PTY to match the layout rect 514 + if (rect.width !== node._lastCols || rect.height !== node._lastRows) { 515 + handle.resize(rect.width, rect.height); 516 + node._lastCols = rect.width; 517 + node._lastRows = rect.height; 518 + } 519 + 520 + // Read cells directly from xterm's buffer — no serialize round-trip 521 + const cells = handle.readCells(); 522 + 523 + let out = ""; 524 + for (let r = 0; r < Math.min(cells.length, rect.height); r++) { 525 + const row = cells[r]; 526 + if (!row) continue; 527 + out += moveTo(rect.y + r + 1, rect.x + 1); 528 + for (let c = 0; c < Math.min(row.length, rect.width); c++) { 529 + const cell = row[c]; 530 + if (cell.bold) out += "\x1b[1m"; 531 + if (cell.dim) out += "\x1b[2m"; 532 + if (cell.italic) out += "\x1b[3m"; 533 + if (cell.underline) out += "\x1b[4m"; 534 + const fgC = cell.fg ?? theme.fg1; 535 + const bgC = cell.bg ?? theme.bg1; 536 + out += fg(fgC[0], fgC[1], fgC[2]); 537 + out += bg(bgC[0], bgC[1], bgC[2]); 538 + out += cell.char; 539 + out += RESET; 540 + } 541 + } 542 + return out; 543 + }
+681
src/tui/screen.ts
··· 1 + // screen() wrapper: bridges declarative UI nodes to the existing Screen interface 2 + 3 + import type { Screen, ScreenContext, Cell } from "./types.ts"; 4 + import { emptyCell } from "./types.ts"; 5 + import type { KeyEvent } from "./input.ts"; 6 + import type { UINode, PanelNode, Rect } from "./nodes.ts"; 7 + import { layoutRoot, layoutVertical, layoutPanel, textWidth } from "./layout.ts"; 8 + import { renderToAnsi, resolveColor, executeCanvasDraw, type RenderOpts } from "./renderer.ts"; 9 + import { CellBuffer } from "./buffer.ts"; 10 + import { 11 + fillRect, drawBox, writeAt, bg, fg, reset as resetAnsi, 12 + BOLD, RESET, moveTo, hSep, 13 + charWidth, visibleLength, boxChars, wrapText, 14 + progressBar as renderProgressBarStr, 15 + } from "./colors.ts"; 16 + import type { Theme, BoxStyle } from "./colors.ts"; 17 + import { spinnerChar, startSpinnerTimer, stopSpinnerTimer } from "./animation.ts"; 18 + import { getCurrentFPS, isFPSVisible } from "./fps.ts"; 19 + 20 + /** Check if any node in the tree is a spinner */ 21 + function treeHasSpinner(nodes: UINode[]): boolean { 22 + for (const node of nodes) { 23 + if (node.type === "spinner") return true; 24 + if (node.type === "row" || node.type === "column") { 25 + if (treeHasSpinner(node.children)) return true; 26 + } 27 + if (node.type === "hstack") { 28 + if (treeHasSpinner(node.children)) return true; 29 + } 30 + if (node.type === "panel") { 31 + if (treeHasSpinner(node.children)) return true; 32 + } 33 + if (node.type === "scrollable" || node.type === "selectable") { 34 + for (const item of node.items) { 35 + if (treeHasSpinner(item)) return true; 36 + } 37 + } 38 + } 39 + return false; 40 + } 41 + 42 + export interface DeclarativeScreenConfig { 43 + id: string; 44 + render: (ctx: ScreenContext) => UINode[]; 45 + handleKey?: (key: KeyEvent, ctx: ScreenContext) => boolean; 46 + onEnter?: (ctx: ScreenContext) => void; 47 + onLeave?: (ctx: ScreenContext) => void; 48 + /** 49 + * Game/animation tick loop. When set, a setInterval runs at the given `ms` 50 + * rate while this screen is active. The `update` callback should mutate 51 + * signals — the reactive render loop picks up the changes automatically. 52 + * The timer starts on onEnter and stops on onLeave. 53 + */ 54 + tick?: { 55 + ms: number; 56 + update: () => void; 57 + }; 58 + } 59 + 60 + /** 61 + * Creates a Screen from declarative UI node builders. 62 + * The returned Screen is fully compatible with the existing router. 63 + */ 64 + export function screen(config: DeclarativeScreenConfig): Screen { 65 + let hasSpinners = false; 66 + let tickTimer: ReturnType<typeof setInterval> | null = null; 67 + 68 + return { 69 + id: config.id, 70 + 71 + render(ctx: ScreenContext): string { 72 + const nodes = config.render(ctx); 73 + 74 + // Layout pass 75 + const viewport = { x: 0, y: 0, width: ctx.cols, height: ctx.rows }; 76 + layoutRoot(nodes, viewport); 77 + 78 + // Manage spinner timer based on tree content 79 + const newHasSpinners = treeHasSpinner(nodes); 80 + if (newHasSpinners && !hasSpinners) startSpinnerTimer(); 81 + if (!newHasSpinners && hasSpinners) stopSpinnerTimer(); 82 + hasSpinners = newHasSpinners; 83 + 84 + // Build render opts — only track spinnerChar signal if spinners exist 85 + const opts: RenderOpts = { 86 + spinnerChar: newHasSpinners ? spinnerChar.get() : "\u280b", 87 + fps: getCurrentFPS(), 88 + showFPS: isFPSVisible(), 89 + }; 90 + 91 + return renderToAnsi(nodes, ctx.theme, ctx.boxStyle, opts); 92 + }, 93 + 94 + renderToBuffer(ctx: ScreenContext): CellBuffer { 95 + const nodes = config.render(ctx); 96 + 97 + // Layout pass 98 + const viewport = { x: 0, y: 0, width: ctx.cols, height: ctx.rows }; 99 + layoutRoot(nodes, viewport); 100 + 101 + // Manage spinner timer based on tree content 102 + const newHasSpinners = treeHasSpinner(nodes); 103 + if (newHasSpinners && !hasSpinners) startSpinnerTimer(); 104 + if (!newHasSpinners && hasSpinners) stopSpinnerTimer(); 105 + hasSpinners = newHasSpinners; 106 + 107 + const opts: RenderOpts = { 108 + spinnerChar: newHasSpinners ? spinnerChar.get() : "\u280b", 109 + fps: getCurrentFPS(), 110 + showFPS: isFPSVisible(), 111 + }; 112 + 113 + const buf = new CellBuffer(ctx.rows, ctx.cols); 114 + // Fill with theme bg1 115 + fillBufRect(buf, 0, 0, ctx.cols, ctx.rows, null, ctx.theme.bg1); 116 + renderTreeToBuffer(nodes, buf, ctx.theme, ctx.boxStyle, opts); 117 + return buf; 118 + }, 119 + 120 + handleKey(key: KeyEvent, ctx: ScreenContext): boolean { 121 + return config.handleKey?.(key, ctx) ?? true; 122 + }, 123 + 124 + onEnter(ctx: ScreenContext): void { 125 + config.onEnter?.(ctx); 126 + if (config.tick && !tickTimer) { 127 + tickTimer = setInterval(config.tick.update, config.tick.ms); 128 + } 129 + }, 130 + 131 + onLeave(ctx: ScreenContext): void { 132 + if (tickTimer) { 133 + clearInterval(tickTimer); 134 + tickTimer = null; 135 + } 136 + if (hasSpinners) { 137 + stopSpinnerTimer(); 138 + hasSpinners = false; 139 + } 140 + config.onLeave?.(ctx); 141 + }, 142 + }; 143 + } 144 + 145 + // --- Overlay wrapper --- 146 + 147 + export interface OverlayConfig { 148 + id: string; 149 + title: string; 150 + width: number | ((cols: number) => number); 151 + height: number | ((rows: number) => number); 152 + render: (ctx: ScreenContext) => UINode[]; 153 + handleKey?: (key: KeyEvent, ctx: ScreenContext) => boolean; 154 + onEnter?: (ctx: ScreenContext) => void; 155 + onLeave?: (ctx: ScreenContext) => void; 156 + } 157 + 158 + /** 159 + * Creates a centered overlay Screen with shadow and panel chrome. 160 + * Content nodes are laid out inside the panel's content area. 161 + */ 162 + export function overlay(config: OverlayConfig): Screen { 163 + let hasSpinners = false; 164 + 165 + return { 166 + id: config.id, 167 + 168 + render(ctx: ScreenContext): string { 169 + const ow = typeof config.width === "function" ? config.width(ctx.cols) : config.width; 170 + const oh = typeof config.height === "function" ? config.height(ctx.rows) : config.height; 171 + const ox = Math.floor((ctx.cols - ow) / 2); 172 + const oy = Math.floor((ctx.rows - oh) / 2); 173 + 174 + let out = ""; 175 + 176 + // Shadow (offset 1 down, 2 right) 177 + out += bg(8, 10, 16) + fillRect(oy + 2, ox + 3, ow, oh) + RESET; 178 + 179 + // Create a panel node for the overlay content 180 + const nodes = config.render(ctx); 181 + const panelNode: PanelNode = { 182 + type: "panel", 183 + title: config.title, 184 + children: nodes, 185 + style: ctx.boxStyle, 186 + _rect: { x: ox, y: oy, width: ow, height: oh }, 187 + }; 188 + 189 + // Layout panel content (handles separators correctly) 190 + layoutPanel(panelNode, { x: ox, y: oy, width: ow, height: oh }); 191 + 192 + // Manage spinners 193 + const newHasSpinners = treeHasSpinner(nodes); 194 + if (newHasSpinners && !hasSpinners) startSpinnerTimer(); 195 + if (!newHasSpinners && hasSpinners) stopSpinnerTimer(); 196 + hasSpinners = newHasSpinners; 197 + 198 + const opts: RenderOpts = { 199 + spinnerChar: newHasSpinners ? spinnerChar.get() : "\u280b", 200 + fps: getCurrentFPS(), 201 + showFPS: isFPSVisible(), 202 + }; 203 + 204 + // Render the panel (draws border, bg, title, children) 205 + out += renderToAnsi([panelNode], ctx.theme, ctx.boxStyle, opts); 206 + return out; 207 + }, 208 + 209 + renderToBuffer(ctx: ScreenContext): CellBuffer { 210 + const ow = typeof config.width === "function" ? config.width(ctx.cols) : config.width; 211 + const oh = typeof config.height === "function" ? config.height(ctx.rows) : config.height; 212 + const ox = Math.floor((ctx.cols - ow) / 2); 213 + const oy = Math.floor((ctx.rows - oh) / 2); 214 + 215 + const buf = new CellBuffer(ctx.rows, ctx.cols); 216 + 217 + // Shadow (offset 1 down, 2 right) 218 + const shadowBg: [number, number, number] = [8, 10, 16]; 219 + fillBufRect(buf, oy + 1, ox + 2, ow, oh, null, shadowBg); 220 + 221 + // Create a panel node for the overlay content 222 + const nodes = config.render(ctx); 223 + const panelNode: PanelNode = { 224 + type: "panel", 225 + title: config.title, 226 + children: nodes, 227 + style: ctx.boxStyle, 228 + _rect: { x: ox, y: oy, width: ow, height: oh }, 229 + }; 230 + 231 + layoutPanel(panelNode, { x: ox, y: oy, width: ow, height: oh }); 232 + 233 + // Manage spinners 234 + const newHasSpinners = treeHasSpinner(nodes); 235 + if (newHasSpinners && !hasSpinners) startSpinnerTimer(); 236 + if (!newHasSpinners && hasSpinners) stopSpinnerTimer(); 237 + hasSpinners = newHasSpinners; 238 + 239 + const opts: RenderOpts = { 240 + spinnerChar: newHasSpinners ? spinnerChar.get() : "\u280b", 241 + fps: getCurrentFPS(), 242 + showFPS: isFPSVisible(), 243 + }; 244 + 245 + renderTreeToBuffer([panelNode], buf, ctx.theme, ctx.boxStyle, opts); 246 + return buf; 247 + }, 248 + 249 + handleKey(key: KeyEvent, ctx: ScreenContext): boolean { 250 + return config.handleKey?.(key, ctx) ?? true; 251 + }, 252 + 253 + onEnter(ctx: ScreenContext): void { 254 + config.onEnter?.(ctx); 255 + }, 256 + 257 + onLeave(ctx: ScreenContext): void { 258 + if (hasSpinners) { 259 + stopSpinnerTimer(); 260 + hasSpinners = false; 261 + } 262 + config.onLeave?.(ctx); 263 + }, 264 + }; 265 + } 266 + 267 + // ========================================================================== 268 + // Buffer rendering helpers — write positioned nodes directly to CellBuffer 269 + // ========================================================================== 270 + 271 + function makeCell( 272 + ch: string, 273 + fgc: [number, number, number] | null, 274 + bgc: [number, number, number] | null, 275 + bold: boolean = false, 276 + dim: boolean = false, 277 + italic: boolean = false, 278 + ): Cell { 279 + return { char: ch, fg: fgc ? [...fgc] : null, bg: bgc ? [...bgc] : null, bold, dim, italic, underline: false }; 280 + } 281 + 282 + /** Write a plain string into the buffer at (row, col). Clips to buffer bounds. */ 283 + function writeStringBuf( 284 + buf: CellBuffer, 285 + row: number, 286 + col: number, 287 + str: string, 288 + fgc: [number, number, number] | null, 289 + bgc: [number, number, number] | null, 290 + bold: boolean = false, 291 + dim: boolean = false, 292 + italic: boolean = false, 293 + ): void { 294 + let c = col; 295 + for (const ch of str) { 296 + const cw = charWidth(ch); 297 + if (row < 0 || row >= buf.rows) return; 298 + if (c >= buf.cols) return; 299 + if (c >= 0) { 300 + buf.cells[row][c] = makeCell(ch, fgc, bgc, bold, dim, italic); 301 + if (cw === 2 && c + 1 < buf.cols) { 302 + buf.cells[row][c + 1] = makeCell("", fgc, bgc, bold, dim, italic); 303 + } 304 + } 305 + c += cw; 306 + } 307 + } 308 + 309 + /** Write a string into the buffer, applying highlight spans for per-character styling. */ 310 + function writeSpannedBuf( 311 + buf: CellBuffer, 312 + row: number, 313 + col: number, 314 + str: string, 315 + textOffset: number, // code-point offset of this line in the original text 316 + spans: import("./nodes.ts").Span[], 317 + defaultFg: [number, number, number] | null, 318 + defaultBold: boolean, 319 + defaultDim: boolean, 320 + defaultItalic: boolean, 321 + theme: Theme, 322 + maxWidth: number, 323 + ): void { 324 + let c = col; 325 + let charIdx = textOffset; 326 + for (const ch of str) { 327 + const cw = charWidth(ch); 328 + if (c - col >= maxWidth) break; 329 + if (row < 0 || row >= buf.rows) return; 330 + if (c >= buf.cols) break; 331 + 332 + // Find the span covering this character 333 + let fg = defaultFg; 334 + let bold = defaultBold; 335 + let dim = defaultDim; 336 + let italic = defaultItalic; 337 + for (const span of spans) { 338 + if (charIdx >= span.start && charIdx < span.end) { 339 + if (span.color !== undefined) fg = resolveColor(span.color, theme); 340 + if (span.bold !== undefined) bold = span.bold; 341 + if (span.dim !== undefined) dim = span.dim; 342 + if (span.italic !== undefined) italic = span.italic; 343 + break; 344 + } 345 + } 346 + 347 + if (c >= 0) { 348 + buf.cells[row][c] = makeCell(ch, fg, null, bold, dim, italic); 349 + if (cw === 2 && c + 1 < buf.cols) { 350 + buf.cells[row][c + 1] = makeCell("", fg, null, bold, dim, italic); 351 + } 352 + } 353 + c += cw; 354 + charIdx++; 355 + } 356 + } 357 + 358 + /** Fill a rectangle in the buffer with a character. */ 359 + function fillBufRect( 360 + buf: CellBuffer, 361 + row: number, 362 + col: number, 363 + width: number, 364 + height: number, 365 + fgc: [number, number, number] | null = null, 366 + bgc: [number, number, number] | null = null, 367 + ch: string = " ", 368 + ): void { 369 + for (let r = row; r < row + height; r++) { 370 + if (r < 0 || r >= buf.rows) continue; 371 + for (let c = col; c < col + width; c++) { 372 + if (c < 0 || c >= buf.cols) continue; 373 + buf.cells[r][c] = makeCell(ch, fgc, bgc); 374 + } 375 + } 376 + } 377 + 378 + /** Draw a box border into the buffer. */ 379 + function drawBoxBuf( 380 + buf: CellBuffer, 381 + row: number, 382 + col: number, 383 + width: number, 384 + height: number, 385 + style: BoxStyle, 386 + fgc: [number, number, number], 387 + bgc: [number, number, number] | null, 388 + ): void { 389 + const b = boxChars(style); 390 + // Top border 391 + if (row >= 0 && row < buf.rows) { 392 + if (col >= 0 && col < buf.cols) buf.cells[row][col] = makeCell(b.tl, fgc, bgc); 393 + for (let c = col + 1; c < col + width - 1; c++) { 394 + if (c >= 0 && c < buf.cols) buf.cells[row][c] = makeCell(b.h, fgc, bgc); 395 + } 396 + if (col + width - 1 >= 0 && col + width - 1 < buf.cols) buf.cells[row][col + width - 1] = makeCell(b.tr, fgc, bgc); 397 + } 398 + // Sides 399 + for (let r = row + 1; r < row + height - 1; r++) { 400 + if (r < 0 || r >= buf.rows) continue; 401 + if (col >= 0 && col < buf.cols) buf.cells[r][col] = makeCell(b.v, fgc, bgc); 402 + if (col + width - 1 >= 0 && col + width - 1 < buf.cols) buf.cells[r][col + width - 1] = makeCell(b.v, fgc, bgc); 403 + } 404 + // Bottom border 405 + const bottomRow = row + height - 1; 406 + if (bottomRow >= 0 && bottomRow < buf.rows) { 407 + if (col >= 0 && col < buf.cols) buf.cells[bottomRow][col] = makeCell(b.bl, fgc, bgc); 408 + for (let c = col + 1; c < col + width - 1; c++) { 409 + if (c >= 0 && c < buf.cols) buf.cells[bottomRow][c] = makeCell(b.h, fgc, bgc); 410 + } 411 + if (col + width - 1 >= 0 && col + width - 1 < buf.cols) buf.cells[bottomRow][col + width - 1] = makeCell(b.br, fgc, bgc); 412 + } 413 + } 414 + 415 + /** Draw a horizontal separator into the buffer. */ 416 + function hSepBuf( 417 + buf: CellBuffer, 418 + row: number, 419 + col: number, 420 + width: number, 421 + style: BoxStyle, 422 + fgc: [number, number, number], 423 + bgc: [number, number, number] | null, 424 + ): void { 425 + if (row < 0 || row >= buf.rows) return; 426 + const b = boxChars(style); 427 + if (col >= 0 && col < buf.cols) buf.cells[row][col] = makeCell(b.lj, fgc, bgc); 428 + for (let c = col + 1; c < col + width - 1; c++) { 429 + if (c >= 0 && c < buf.cols) buf.cells[row][c] = makeCell(b.h, fgc, bgc); 430 + } 431 + if (col + width - 1 >= 0 && col + width - 1 < buf.cols) buf.cells[row][col + width - 1] = makeCell(b.rj, fgc, bgc); 432 + } 433 + 434 + // --- Main tree-to-buffer renderer --- 435 + 436 + function renderTreeToBuffer( 437 + nodes: UINode[], 438 + buf: CellBuffer, 439 + theme: Theme, 440 + boxStyle: BoxStyle, 441 + opts: RenderOpts, 442 + ): void { 443 + for (const node of nodes) { 444 + renderNodeToBuffer(node, buf, theme, boxStyle, opts); 445 + } 446 + } 447 + 448 + function renderNodeToBuffer( 449 + node: UINode, 450 + buf: CellBuffer, 451 + theme: Theme, 452 + bs: BoxStyle, 453 + opts: RenderOpts, 454 + ): void { 455 + if (!node._rect) return; 456 + const rect = node._rect; 457 + if (rect.width <= 0 || rect.height <= 0) return; 458 + 459 + switch (node.type) { 460 + case "text": { 461 + const defaultColor = resolveColor(node.color, theme); 462 + const defaultBold = node.bold ?? false; 463 + const defaultDim = node.dim ?? false; 464 + const defaultItalic = node.italic ?? false; 465 + const content = node.text; 466 + 467 + // Compute highlight spans once (if any) 468 + const spans = node.highlight ? node.highlight(content) : null; 469 + 470 + if (node.wrap && rect.width > 0) { 471 + // Soft-wrap: split into visual lines and render each 472 + const { lines, offsets } = wrapText(content, rect.width); 473 + const maxLines = Math.min(lines.length, rect.height); 474 + for (let i = 0; i < maxLines; i++) { 475 + const lineY = rect.y + i; 476 + if (lineY >= buf.rows) break; 477 + if (spans) { 478 + writeSpannedBuf(buf, lineY, rect.x, lines[i], offsets[i], spans, defaultColor, defaultBold, defaultDim, defaultItalic, theme, rect.width); 479 + } else { 480 + writeStringBuf(buf, lineY, rect.x, lines[i], defaultColor, null, defaultBold, defaultDim, defaultItalic); 481 + } 482 + } 483 + } else { 484 + // Single line: truncate if needed 485 + let displayContent = content; 486 + if ((node.truncate || textWidth(content) > rect.width) && textWidth(content) > rect.width) { 487 + if (rect.width <= 1) { 488 + displayContent = content.slice(0, rect.width); 489 + } else { 490 + let w = 0; 491 + let result = ""; 492 + for (const ch of content) { 493 + const cw = charWidth(ch); 494 + if (w + cw + 1 > rect.width) break; 495 + result += ch; 496 + w += cw; 497 + } 498 + displayContent = result + "\u2026"; 499 + } 500 + } 501 + if (spans) { 502 + writeSpannedBuf(buf, rect.y, rect.x, displayContent, 0, spans, defaultColor, defaultBold, defaultDim, defaultItalic, theme, rect.width); 503 + } else { 504 + writeStringBuf(buf, rect.y, rect.x, displayContent, defaultColor, null, defaultBold, defaultDim, defaultItalic); 505 + } 506 + } 507 + break; 508 + } 509 + case "spacer": 510 + case "gap": 511 + case "indent": 512 + break; 513 + case "separator": 514 + hSepBuf(buf, rect.y, rect.x, rect.width, bs, theme.border, null); 515 + break; 516 + case "dot": { 517 + const color = resolveColor(node.color, theme); 518 + const ch = node.filled ? "\u25cf" : "\u25cb"; 519 + writeStringBuf(buf, rect.y, rect.x, ch, color, null); 520 + break; 521 + } 522 + case "checkbox": { 523 + const color = resolveColor(node.color, theme); 524 + const ch = node.checked ? "\u25a0" : "\u25a1"; 525 + writeStringBuf(buf, rect.y, rect.x, ch, color, null); 526 + break; 527 + } 528 + case "progressBar": { 529 + const color = resolveColor(node.color, theme) ?? theme.info; 530 + const barStr = renderProgressBarStr(rect.width, node.percent); 531 + writeStringBuf(buf, rect.y, rect.x, barStr, color, null); 532 + break; 533 + } 534 + case "spinner": { 535 + const color = resolveColor(node.color, theme) ?? theme.fgAc; 536 + writeStringBuf(buf, rect.y, rect.x, opts.spinnerChar, color, null); 537 + break; 538 + } 539 + case "icon": { 540 + const color = resolveColor(node.color, theme); 541 + writeStringBuf(buf, rect.y, rect.x, node.char, color, null); 542 + break; 543 + } 544 + case "row": 545 + case "column": 546 + for (const child of node.children) { 547 + renderNodeToBuffer(child, buf, theme, bs, opts); 548 + } 549 + break; 550 + case "hstack": 551 + for (const col of node.children) { 552 + renderNodeToBuffer(col, buf, theme, bs, opts); 553 + } 554 + break; 555 + case "panel": { 556 + const style = node.style ?? bs; 557 + // Fill panel background 558 + fillBufRect(buf, rect.y, rect.x, rect.width, rect.height, null, theme.bg2); 559 + // Draw border 560 + drawBoxBuf(buf, rect.y, rect.x, rect.width, rect.height, style, theme.border, theme.bg2); 561 + // Title 562 + if (node.title) { 563 + writeStringBuf(buf, rect.y, rect.x + 2, " ", theme.border, theme.bg2); 564 + writeStringBuf(buf, rect.y, rect.x + 3, node.title, theme.fgAc, theme.bg2, true); 565 + const titleEnd = rect.x + 3 + textWidth(node.title); 566 + writeStringBuf(buf, rect.y, titleEnd, " ", theme.border, theme.bg2); 567 + } 568 + // Render children 569 + for (const child of node.children) { 570 + renderNodeToBuffer(child, buf, theme, style, opts); 571 + } 572 + break; 573 + } 574 + case "scrollable": 575 + case "selectable": { 576 + const offset = node.offset; 577 + const visibleCount = Math.min(node.items.length - offset, rect.height); 578 + for (let i = 0; i < visibleCount; i++) { 579 + const itemIndex = offset + i; 580 + const itemNodes = node.items[itemIndex]; 581 + if (!itemNodes) continue; 582 + for (const itemNode of itemNodes) { 583 + renderNodeToBuffer(itemNode, buf, theme, bs, opts); 584 + } 585 + } 586 + break; 587 + } 588 + case "statusBar": { 589 + // Fill line with accent background 590 + fillBufRect(buf, rect.y, rect.x, rect.width, 1, theme.fg1, theme.bgAc); 591 + writeStringBuf(buf, rect.y, rect.x + 2, node.left, theme.fg1, theme.bgAc, true); 592 + if (node.right) { 593 + const rightWidth = visibleLength(node.right); 594 + // node.right may contain ANSI — strip it for buffer rendering 595 + const plain = node.right.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); 596 + writeStringBuf(buf, rect.y, rect.x + rect.width - rightWidth - 2, plain, theme.fg1, theme.bgAc); 597 + } 598 + break; 599 + } 600 + case "footer": { 601 + writeStringBuf(buf, rect.y, rect.x + 1, node.hints, theme.fgMu, null); 602 + break; 603 + } 604 + case "askBar": { 605 + const style = node.style ?? bs; 606 + // Background 607 + fillBufRect(buf, rect.y, rect.x, rect.width, rect.height, null, theme.bg2); 608 + // Border 609 + drawBoxBuf(buf, rect.y, rect.x, rect.width, rect.height, style, theme.border, theme.bg2); 610 + if (node.active) { 611 + writeStringBuf(buf, rect.y + 1, rect.x + 3, "> " + node.text + "\u2588", theme.fg1, theme.bg2); 612 + } else { 613 + writeStringBuf(buf, rect.y + 1, rect.x + 3, node.placeholder, theme.fgMu, theme.bg2); 614 + } 615 + if (node.rightLabel) { 616 + const labelW = visibleLength(node.rightLabel); 617 + writeStringBuf(buf, rect.y + 1, rect.x + rect.width - labelW - 3, node.rightLabel, theme.fg2, theme.bg2); 618 + } 619 + break; 620 + } 621 + case "textInput": { 622 + if (node.active) { 623 + const before = node.text.slice(0, node.cursor); 624 + const after = node.text.slice(node.cursor); 625 + writeStringBuf(buf, rect.y, rect.x, before + "\u2588" + after, theme.fg1, null); 626 + } else if (node.placeholder) { 627 + writeStringBuf(buf, rect.y, rect.x, node.placeholder, theme.fgMu, null); 628 + } 629 + break; 630 + } 631 + case "fpsCounter": { 632 + if (!opts.showFPS) break; 633 + const label = `${opts.fps} FPS`; 634 + writeStringBuf(buf, rect.y, rect.x, label, theme.fgAc, null); 635 + break; 636 + } 637 + case "canvas": { 638 + executeCanvasDraw(node, rect, theme); 639 + for (const cell of node._cells) { 640 + const absX = rect.x + cell.x; 641 + const absY = rect.y + cell.y; 642 + if (absY < 0 || absY >= rect.y + rect.height) continue; 643 + if (absX < 0 || absX >= rect.x + rect.width) continue; 644 + const fgC = resolveColor(cell.color, theme); 645 + const bgC = resolveColor(cell.bg, theme); 646 + if (absY >= 0 && absY < buf.rows && absX >= 0 && absX < buf.cols) { 647 + buf.cells[absY][absX] = makeCell(cell.char, fgC, bgC, cell.bold ?? false, cell.dim ?? false); 648 + } 649 + } 650 + break; 651 + } 652 + case "ptyView": { 653 + // Resize PTY to match layout 654 + if (rect.width !== node._lastCols || rect.height !== node._lastRows) { 655 + node.handle.resize(rect.width, rect.height); 656 + node._lastCols = rect.width; 657 + node._lastRows = rect.height; 658 + } 659 + // Read xterm cells directly — no serialize round-trip 660 + const ptyCells = node.handle.readCells(); 661 + for (let r = 0; r < Math.min(ptyCells.length, rect.height); r++) { 662 + const absY = rect.y + r; 663 + if (absY < 0 || absY >= buf.rows) continue; 664 + const ptyRow = ptyCells[r]; 665 + if (!ptyRow) continue; 666 + for (let c = 0; c < Math.min(ptyRow.length, rect.width); c++) { 667 + const absX = rect.x + c; 668 + if (absX < 0 || absX >= buf.cols) continue; 669 + const cell = ptyRow[c]; 670 + buf.cells[absY][absX] = makeCell( 671 + cell.char || " ", 672 + cell.fg ?? [...theme.fg1], 673 + cell.bg ?? [...theme.bg1], 674 + cell.bold, cell.dim, cell.italic, 675 + ); 676 + } 677 + } 678 + break; 679 + } 680 + } 681 + }
+62
src/tui/scrollable.ts
··· 1 + // ScrollRegion: manages offset, selection, and viewport for scrollable lists 2 + 3 + export interface ScrollRegion { 4 + offset: number; 5 + selectedIndex: number; 6 + totalItems: number; 7 + viewportHeight: number; 8 + } 9 + 10 + export function createScrollRegion(totalItems: number, viewportHeight: number): ScrollRegion { 11 + return { offset: 0, selectedIndex: 0, totalItems, viewportHeight }; 12 + } 13 + 14 + export function updateScrollRegion(region: ScrollRegion, totalItems: number, viewportHeight?: number): ScrollRegion { 15 + const vh = viewportHeight ?? region.viewportHeight; 16 + const sel = Math.min(region.selectedIndex, Math.max(0, totalItems - 1)); 17 + return ensureVisible({ ...region, totalItems, viewportHeight: vh, selectedIndex: sel }); 18 + } 19 + 20 + export function scrollUp(region: ScrollRegion): ScrollRegion { 21 + if (region.selectedIndex <= 0) return region; 22 + return ensureVisible({ ...region, selectedIndex: region.selectedIndex - 1 }); 23 + } 24 + 25 + export function scrollDown(region: ScrollRegion): ScrollRegion { 26 + if (region.selectedIndex >= region.totalItems - 1) return region; 27 + return ensureVisible({ ...region, selectedIndex: region.selectedIndex + 1 }); 28 + } 29 + 30 + export function pageUp(region: ScrollRegion): ScrollRegion { 31 + const newIndex = Math.max(0, region.selectedIndex - region.viewportHeight); 32 + return ensureVisible({ ...region, selectedIndex: newIndex }); 33 + } 34 + 35 + export function pageDown(region: ScrollRegion): ScrollRegion { 36 + const newIndex = Math.min(region.totalItems - 1, region.selectedIndex + region.viewportHeight); 37 + return ensureVisible({ ...region, selectedIndex: newIndex }); 38 + } 39 + 40 + export function scrollToTop(region: ScrollRegion): ScrollRegion { 41 + return ensureVisible({ ...region, selectedIndex: 0 }); 42 + } 43 + 44 + export function scrollToBottom(region: ScrollRegion): ScrollRegion { 45 + return ensureVisible({ ...region, selectedIndex: Math.max(0, region.totalItems - 1) }); 46 + } 47 + 48 + function ensureVisible(region: ScrollRegion): ScrollRegion { 49 + let { offset, selectedIndex, viewportHeight } = region; 50 + if (selectedIndex < offset) { 51 + offset = selectedIndex; 52 + } else if (selectedIndex >= offset + viewportHeight) { 53 + offset = selectedIndex - viewportHeight + 1; 54 + } 55 + offset = Math.max(0, Math.min(offset, Math.max(0, region.totalItems - viewportHeight))); 56 + return { ...region, offset, selectedIndex }; 57 + } 58 + 59 + /** Return the visible slice of items given a scroll region. */ 60 + export function visibleSlice<T>(items: T[], region: ScrollRegion): T[] { 61 + return items.slice(region.offset, region.offset + region.viewportHeight); 62 + }
+33
src/tui/signals.ts
··· 1 + // Reactive signals — wraps @preact/signals-core with .get()/.set() API 2 + import { 3 + signal as preactSignal, 4 + computed as preactComputed, 5 + effect as preactEffect, 6 + batch as preactBatch, 7 + type Signal as PreactSignal, 8 + type ReadonlySignal as PreactReadonlySignal, 9 + } from "@preact/signals-core"; 10 + 11 + export interface Signal<T> { 12 + get(): T; 13 + set(value: T): void; 14 + peek(): T; 15 + } 16 + 17 + export function signal<T>(initial: T): Signal<T> { 18 + const s = preactSignal(initial); 19 + return { 20 + get() { return s.value; }, 21 + set(value: T) { s.value = value; }, 22 + peek() { return s.peek(); }, 23 + }; 24 + } 25 + 26 + export function computed<T>(fn: () => T): { get(): T } { 27 + const c = preactComputed(fn); 28 + return { 29 + get() { return c.value; }, 30 + }; 31 + } 32 + 33 + export { preactEffect as effect, preactBatch as batch };
+92
src/tui/text-input.ts
··· 1 + // TextInput state machine for the ask bar 2 + import type { KeyEvent } from "./input.ts"; 3 + 4 + export interface TextInputState { 5 + text: string; 6 + cursor: number; 7 + active: boolean; 8 + processing: boolean; 9 + } 10 + 11 + export function createTextInput(): TextInputState { 12 + return { text: "", cursor: 0, active: false, processing: false }; 13 + } 14 + 15 + export function activateTextInput(state: TextInputState): TextInputState { 16 + return { ...state, active: true }; 17 + } 18 + 19 + export function deactivateTextInput(state: TextInputState): TextInputState { 20 + return { ...state, active: false }; 21 + } 22 + 23 + export function handleTextInputKey( 24 + state: TextInputState, 25 + key: KeyEvent, 26 + onSubmit?: (text: string) => void, 27 + ): TextInputState | null { 28 + if (!state.active || state.processing) return null; 29 + 30 + if (key.name === "escape") { 31 + return { text: "", cursor: 0, active: false, processing: false }; 32 + } 33 + 34 + if (key.name === "return") { 35 + if (state.text.length > 0) { 36 + onSubmit?.(state.text); 37 + return { ...state, processing: true }; 38 + } 39 + return null; 40 + } 41 + 42 + if (key.name === "backspace") { 43 + if (state.cursor > 0) { 44 + const text = state.text.slice(0, state.cursor - 1) + state.text.slice(state.cursor); 45 + return { ...state, text, cursor: state.cursor - 1 }; 46 + } 47 + return null; 48 + } 49 + 50 + if (key.name === "delete") { 51 + if (state.cursor < state.text.length) { 52 + const text = state.text.slice(0, state.cursor) + state.text.slice(state.cursor + 1); 53 + return { ...state, text }; 54 + } 55 + return null; 56 + } 57 + 58 + if (key.name === "left") { 59 + if (state.cursor > 0) return { ...state, cursor: state.cursor - 1 }; 60 + return null; 61 + } 62 + 63 + if (key.name === "right") { 64 + if (state.cursor < state.text.length) return { ...state, cursor: state.cursor + 1 }; 65 + return null; 66 + } 67 + 68 + if (key.name === "home" || (key.name === "a" && key.ctrl)) { 69 + return { ...state, cursor: 0 }; 70 + } 71 + 72 + if (key.name === "end" || (key.name === "e" && key.ctrl)) { 73 + return { ...state, cursor: state.text.length }; 74 + } 75 + 76 + // Ctrl+U: clear line 77 + if (key.name === "u" && key.ctrl) { 78 + return { ...state, text: state.text.slice(state.cursor), cursor: 0 }; 79 + } 80 + 81 + // Printable character 82 + if (key.char && !key.ctrl && !key.alt) { 83 + const text = state.text.slice(0, state.cursor) + key.char + state.text.slice(state.cursor); 84 + return { ...state, text, cursor: state.cursor + 1 }; 85 + } 86 + 87 + return null; 88 + } 89 + 90 + export function finishProcessing(state: TextInputState): TextInputState { 91 + return { text: "", cursor: 0, active: false, processing: false }; 92 + }
+63
src/tui/types.ts
··· 1 + // Core types for the TUI framework 2 + 3 + import type { Theme, BoxStyle } from "./colors.ts"; 4 + import type { KeyEvent } from "./input.ts"; 5 + 6 + // --- Cell buffer types --- 7 + export interface Cell { 8 + char: string; 9 + fg: [number, number, number] | null; 10 + bg: [number, number, number] | null; 11 + bold: boolean; 12 + dim: boolean; 13 + italic: boolean; 14 + underline: boolean; 15 + } 16 + 17 + export function emptyCell(): Cell { 18 + return { char: " ", fg: null, bg: null, bold: false, dim: false, italic: false, underline: false }; 19 + } 20 + 21 + export function cellsEqual(a: Cell, b: Cell): boolean { 22 + return ( 23 + a.char === b.char && 24 + a.bold === b.bold && 25 + a.dim === b.dim && 26 + a.italic === b.italic && 27 + a.underline === b.underline && 28 + colorEqual(a.fg, b.fg) && 29 + colorEqual(a.bg, b.bg) 30 + ); 31 + } 32 + 33 + function colorEqual(a: [number, number, number] | null, b: [number, number, number] | null): boolean { 34 + if (a === b) return true; 35 + if (!a || !b) return false; 36 + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; 37 + } 38 + 39 + // --- Screen context (minimal base) --- 40 + // Apps can extend this interface with additional methods/properties. 41 + export interface ScreenContext { 42 + rows: number; 43 + cols: number; 44 + theme: Theme; 45 + boxStyle: BoxStyle; 46 + navigate: (screenId: string) => void; 47 + back: () => void; 48 + openOverlay: (screenId: string) => void; 49 + closeOverlay: () => void; 50 + isTextInputActive: () => boolean; 51 + setTextInputActive: (active: boolean) => void; 52 + [key: string]: any; 53 + } 54 + 55 + // --- Screen interface --- 56 + export interface Screen { 57 + id: string; 58 + render(ctx: ScreenContext): string; 59 + renderToBuffer(ctx: ScreenContext): import("./buffer.ts").CellBuffer; 60 + handleKey(key: KeyEvent, ctx: ScreenContext): boolean; 61 + onEnter?(ctx: ScreenContext): void; 62 + onLeave?(ctx: ScreenContext): void; 63 + }
+571
tests/tui-framework.test.ts
··· 1 + // Tests for the declarative TUI framework (src/tui/). 2 + // These are pure framework tests — no experiment-app dependencies. 3 + import { describe, it, expect } from "vitest"; 4 + import { 5 + text, spacer, gap, separator, indent, dot, checkbox, progressBar, 6 + spinner, icon, row, column, hstack, panel, scrollable, selectable, 7 + groupedSelectable, statusBar, footer, askBar, textInput, fpsCounter, 8 + canvas, screen, overlay, 9 + layoutRoot, layoutVertical, layoutRow, textWidth, 10 + renderToAnsi, resolveColor, 11 + type UINode, type RenderOpts, 12 + } from "../src/tui/index.ts"; 13 + import { CellBuffer } from "../src/tui/buffer.ts"; 14 + import { createScrollRegion } from "../src/tui/scrollable.ts"; 15 + import { createTextInput } from "../src/tui/text-input.ts"; 16 + import { themes, type Theme } from "../src/tui/colors.ts"; 17 + 18 + const theme: Theme = themes.coolBlue; 19 + const defaultOpts: RenderOpts = { spinnerChar: "\u280b", fps: 60, showFPS: false }; 20 + 21 + // ── Builders ── 22 + describe("builders", () => { 23 + it("text() with all options", () => { 24 + const n = text("hello", "primary", { bold: true, truncate: true }); 25 + expect(n.type).toBe("text"); 26 + expect(n.text).toBe("hello"); 27 + expect(n.color).toBe("primary"); 28 + expect(n.bold).toBe(true); 29 + expect(n.truncate).toBe(true); 30 + }); 31 + 32 + it("text() with RGB color", () => { 33 + const n = text("hi", [255, 0, 0]); 34 + expect(n.color).toEqual([255, 0, 0]); 35 + }); 36 + 37 + it("spacer, gap, separator, indent", () => { 38 + expect(spacer().type).toBe("spacer"); 39 + expect(gap(3).size).toBe(3); 40 + expect(gap("center").size).toBe("center"); 41 + expect(separator().type).toBe("separator"); 42 + expect(indent(2).depth).toBe(2); 43 + }); 44 + 45 + it("dot, checkbox, progressBar, spinner, icon", () => { 46 + expect(dot(true, "ok").filled).toBe(true); 47 + expect(dot(false).filled).toBe(false); 48 + expect(checkbox(true).checked).toBe(true); 49 + expect(progressBar(0.5, { width: 20 }).percent).toBe(0.5); 50 + expect(spinner("accent").type).toBe("spinner"); 51 + expect(icon("\u2605", "warn").char).toBe("\u2605"); 52 + }); 53 + 54 + it("row, column, hstack compose", () => { 55 + const h = hstack({ gap: 2 }, [ 56 + column({ width: 20 }, [text("left")]), 57 + column({ flex: true }, [text("right")]), 58 + ]); 59 + expect(h.type).toBe("hstack"); 60 + expect(h.children.length).toBe(2); 61 + expect(h.children[0].width).toBe(20); 62 + expect(h.children[1].flex).toBe(true); 63 + }); 64 + 65 + it("panel wraps children", () => { 66 + const p = panel("Tasks", [text("a"), separator(), text("b")]); 67 + expect(p.title).toBe("Tasks"); 68 + expect(p.children.length).toBe(3); 69 + }); 70 + 71 + it("selectable maps ScrollRegion", () => { 72 + const region = createScrollRegion(3, 10); 73 + const s = selectable(region, ["a", "b", "c"], (item, _i, sel) => [ 74 + text(sel ? "> " + item : " " + item), 75 + ]); 76 + expect(s.items.length).toBe(3); 77 + expect((s.items[0][0] as any).text).toBe("> a"); 78 + expect((s.items[1][0] as any).text).toBe(" b"); 79 + }); 80 + 81 + it("groupedSelectable renders headers + items", () => { 82 + const region = createScrollRegion(4, 10); 83 + const s = groupedSelectable(region, [ 84 + { title: "A", items: [1, 2] }, 85 + { title: "B", items: [3, 4] }, 86 + ], (item, _idx, sel) => [text(sel ? `> ${item}` : ` ${item}`)]); 87 + // header A, item 1, item 2, spacing, header B, item 3, item 4 88 + expect(s.items.length).toBe(7); 89 + expect((s.items[0][0] as any).text).toBe("A"); 90 + expect((s.items[1][0] as any).text).toBe("> 1"); 91 + }); 92 + 93 + it("statusBar, footer, askBar, textInput, fpsCounter", () => { 94 + expect(statusBar("T", "R").left).toBe("T"); 95 + expect(footer("hints").hints).toBe("hints"); 96 + const ti = createTextInput(); 97 + expect(askBar(ti).type).toBe("askBar"); 98 + expect(textInput(ti).type).toBe("textInput"); 99 + expect(fpsCounter().type).toBe("fpsCounter"); 100 + }); 101 + 102 + it("canvas produces CanvasNode", () => { 103 + const c = canvas(() => {}); 104 + expect(c.type).toBe("canvas"); 105 + }); 106 + }); 107 + 108 + // ── textWidth ── 109 + describe("textWidth", () => { 110 + it("ASCII", () => expect(textWidth("hello")).toBe(5)); 111 + it("CJK", () => expect(textWidth("\u4e2d\u6587")).toBe(4)); 112 + it("empty", () => expect(textWidth("")).toBe(0)); 113 + }); 114 + 115 + // ── Layout ── 116 + describe("layout", () => { 117 + it("statusBar top, footer bottom", () => { 118 + const nodes: UINode[] = [statusBar("App", "info"), text("body"), footer("help")]; 119 + layoutRoot(nodes, { x: 0, y: 0, width: 80, height: 24 }); 120 + expect(nodes[0]._rect).toEqual({ x: 0, y: 0, width: 80, height: 1 }); 121 + expect(nodes[2]._rect).toEqual({ x: 0, y: 23, width: 80, height: 1 }); 122 + expect(nodes[1]._rect!.y).toBe(1); 123 + }); 124 + 125 + it("gap(center) centers vertically", () => { 126 + const nodes: UINode[] = [gap("center"), text("mid"), gap("center")]; 127 + layoutRoot(nodes, { x: 0, y: 0, width: 80, height: 21 }); 128 + expect(nodes[1]._rect!.y).toBe(10); 129 + }); 130 + 131 + it("row with spacer distributes width", () => { 132 + const r = row(text("left"), spacer(), text("right")); 133 + layoutRoot([r], { x: 0, y: 0, width: 80, height: 1 }); 134 + expect(r.children[0]._rect!.width).toBe(4); 135 + expect(r.children[2]._rect!.x).toBe(75); 136 + }); 137 + 138 + it("hstack with fixed and flex columns", () => { 139 + const h = hstack({ gap: 1 }, [ 140 + column({ width: 20 }, [text("left")]), 141 + column({ flex: true }, [text("right")]), 142 + ]); 143 + layoutRoot([h], { x: 0, y: 0, width: 80, height: 24 }); 144 + expect(h.children[0]._rect!.width).toBe(20); 145 + expect(h.children[1]._rect!.x).toBe(21); 146 + expect(h.children[1]._rect!.width).toBe(59); 147 + }); 148 + 149 + it("panel auto-heights", () => { 150 + const p = panel("Test", [text("a"), text("b"), text("c")]); 151 + layoutRoot([p], { x: 0, y: 0, width: 40, height: 24 }); 152 + expect(p._rect!.height).toBe(5); 153 + expect(p.children[0]._rect!.x).toBe(2); 154 + expect(p.children[0]._rect!.y).toBe(1); 155 + }); 156 + 157 + it("clipping: vertical overflow", () => { 158 + const nodes: UINode[] = [text("1"), text("2"), text("3")]; 159 + layoutVertical(nodes, { x: 0, y: 0, width: 80, height: 2 }); 160 + expect(nodes[2]._rect!.height).toBe(0); 161 + }); 162 + 163 + it("clipping: horizontal overflow", () => { 164 + const r = row(text("a".repeat(50)), text("b".repeat(50))); 165 + layoutRoot([r], { x: 0, y: 0, width: 80, height: 1 }); 166 + expect(r.children[0]._rect!.width).toBe(50); 167 + expect(r.children[1]._rect!.width).toBe(30); 168 + }); 169 + 170 + it("scrollable viewport", () => { 171 + const items = Array.from({ length: 20 }, (_, i) => `item ${i}`); 172 + const s = scrollable(items, (item) => [text(item)]); 173 + s.offset = 5; 174 + layoutRoot([s], { x: 0, y: 0, width: 80, height: 10 }); 175 + expect(s.items[5]![0]._rect).toBeDefined(); 176 + expect(s.items[5]![0]._rect!.y).toBe(0); 177 + }); 178 + 179 + it("canvas gets flex height", () => { 180 + const nodes: UINode[] = [text("above"), canvas(() => {}), text("below")]; 181 + layoutRoot(nodes, { x: 0, y: 0, width: 80, height: 20 }); 182 + expect(nodes[1]._rect!.height).toBe(18); 183 + }); 184 + 185 + it("canvas respects fixed height", () => { 186 + const nodes: UINode[] = [canvas(() => {}, { height: 5 })]; 187 + layoutRoot(nodes, { x: 0, y: 0, width: 80, height: 20 }); 188 + expect(nodes[0]._rect!.height).toBe(5); 189 + }); 190 + }); 191 + 192 + // ── Renderer ── 193 + describe("renderer", () => { 194 + it("resolveColor maps semantic names", () => { 195 + expect(resolveColor("ok", theme)).toEqual(theme.ok); 196 + expect(resolveColor("primary", theme)).toEqual(theme.fg1); 197 + expect(resolveColor([10, 20, 30], theme)).toEqual([10, 20, 30]); 198 + expect(resolveColor(undefined, theme)).toBeNull(); 199 + }); 200 + 201 + it("renders text at correct position", () => { 202 + const nodes: UINode[] = [text("hello", "primary")]; 203 + layoutRoot(nodes, { x: 0, y: 0, width: 80, height: 24 }); 204 + const ansi = renderToAnsi(nodes, theme, "rounded", defaultOpts); 205 + expect(ansi).toContain("\x1b[1;1H"); 206 + expect(ansi).toContain("hello"); 207 + }); 208 + 209 + it("renders panel with border and title", () => { 210 + const nodes: UINode[] = [panel("Tasks", [text("item 1")])]; 211 + layoutRoot(nodes, { x: 0, y: 0, width: 40, height: 10 }); 212 + const ansi = renderToAnsi(nodes, theme, "rounded", defaultOpts); 213 + expect(ansi).toContain("Tasks"); 214 + expect(ansi).toContain("item 1"); 215 + expect(ansi).toContain("\u256d"); 216 + }); 217 + 218 + it("renders dot, checkbox, progressBar", () => { 219 + const nodes: UINode[] = [row(dot(true, "ok"), checkbox(false), progressBar(0.5, { width: 10 }))]; 220 + layoutRoot(nodes, { x: 0, y: 0, width: 80, height: 1 }); 221 + const ansi = renderToAnsi(nodes, theme, "rounded", defaultOpts); 222 + expect(ansi).toContain("\u25cf"); 223 + expect(ansi).toContain("\u25a1"); 224 + expect(ansi).toContain("\u2588"); 225 + }); 226 + 227 + it("renders spinner", () => { 228 + const nodes: UINode[] = [spinner("accent")]; 229 + layoutRoot(nodes, { x: 0, y: 0, width: 80, height: 1 }); 230 + const ansi = renderToAnsi(nodes, theme, "rounded", { ...defaultOpts, spinnerChar: "\u2819" }); 231 + expect(ansi).toContain("\u2819"); 232 + }); 233 + 234 + it("centered text via spacers", () => { 235 + const r = row(spacer(), text("CENTER"), spacer()); 236 + layoutRoot([r], { x: 0, y: 0, width: 80, height: 1 }); 237 + const ansi = renderToAnsi([r], theme, "rounded", defaultOpts); 238 + const buf = new CellBuffer(1, 80); 239 + buf.writeAnsi(ansi); 240 + const start = buf.cells[0].findIndex(c => c.char === "C"); 241 + expect(start).toBe(37); 242 + }); 243 + }); 244 + 245 + // ── screen() wrapper ── 246 + describe("screen() wrapper", () => { 247 + it("produces Screen with render + renderToBuffer + handleKey", () => { 248 + const s = screen({ id: "test", render: () => [text("hi")] }); 249 + expect(s.id).toBe("test"); 250 + expect(typeof s.render).toBe("function"); 251 + expect(typeof s.renderToBuffer).toBe("function"); 252 + expect(typeof s.handleKey).toBe("function"); 253 + }); 254 + 255 + it("render returns ANSI with content", () => { 256 + const s = screen({ id: "test", render: () => [text("body")] }); 257 + const ctx = { rows: 24, cols: 80, theme, boxStyle: "rounded" as const } as any; 258 + expect(s.render(ctx)).toContain("body"); 259 + }); 260 + 261 + it("renderToBuffer returns CellBuffer", () => { 262 + const s = screen({ id: "test", render: () => [text("hello")] }); 263 + const ctx = { rows: 24, cols: 80, theme, boxStyle: "rounded" as const } as any; 264 + const buf = s.renderToBuffer(ctx); 265 + expect(buf).toBeInstanceOf(CellBuffer); 266 + expect(buf.cells[0].map(c => c.char).join("")).toContain("hello"); 267 + }); 268 + 269 + it("renderToBuffer fills background with theme bg1", () => { 270 + const s = screen({ id: "test", render: () => [text("x")] }); 271 + const ctx = { rows: 24, cols: 80, theme, boxStyle: "rounded" as const } as any; 272 + const buf = s.renderToBuffer(ctx); 273 + expect(buf.cells[20][60].bg).toEqual([...theme.bg1]); 274 + }); 275 + }); 276 + 277 + // ── overlay() wrapper ── 278 + describe("overlay() wrapper", () => { 279 + it("produces Screen with renderToBuffer", () => { 280 + const o = overlay({ 281 + id: "test-overlay", title: "Test", width: 40, height: 10, 282 + render: () => [text("content")], 283 + }); 284 + expect(typeof o.renderToBuffer).toBe("function"); 285 + }); 286 + 287 + it("renderToBuffer has centered content", () => { 288 + const o = overlay({ 289 + id: "test-overlay", title: "Test", width: 40, height: 10, 290 + render: () => [text("inside overlay")], 291 + }); 292 + const ctx = { rows: 24, cols: 80, theme, boxStyle: "rounded" as const } as any; 293 + const buf = o.renderToBuffer(ctx); 294 + let found = false; 295 + for (let r = 0; r < buf.rows; r++) { 296 + if (buf.cells[r].map(c => c.char).join("").includes("inside overlay")) { found = true; break; } 297 + } 298 + expect(found).toBe(true); 299 + }); 300 + 301 + it("has border characters", () => { 302 + const o = overlay({ 303 + id: "test-overlay", title: "Test", width: 40, height: 10, 304 + render: () => [text("x")], 305 + }); 306 + const ctx = { rows: 24, cols: 80, theme, boxStyle: "rounded" as const } as any; 307 + const buf = o.renderToBuffer(ctx); 308 + let hasCorner = false; 309 + for (let r = 0; r < buf.rows && !hasCorner; r++) { 310 + for (let c = 0; c < buf.cols && !hasCorner; c++) { 311 + if (buf.cells[r][c].char === "\u256d") hasCorner = true; 312 + } 313 + } 314 + expect(hasCorner).toBe(true); 315 + }); 316 + }); 317 + 318 + // ── Canvas ── 319 + describe("canvas", () => { 320 + it("draw callback receives width/height", () => { 321 + let w = 0, h = 0; 322 + const nodes: UINode[] = [canvas((ctx) => { w = ctx.width; h = ctx.height; })]; 323 + layoutRoot(nodes, { x: 0, y: 0, width: 60, height: 15 }); 324 + renderToAnsi(nodes, theme, "rounded", defaultOpts); 325 + expect(w).toBe(60); 326 + expect(h).toBe(15); 327 + }); 328 + 329 + it("set() places character at correct position", () => { 330 + const nodes: UINode[] = [canvas((ctx) => { ctx.set(5, 3, "@", "accent"); })]; 331 + layoutRoot(nodes, { x: 0, y: 0, width: 80, height: 20 }); 332 + const ansi = renderToAnsi(nodes, theme, "rounded", defaultOpts); 333 + expect(ansi).toContain("\x1b[4;6H"); 334 + expect(ansi).toContain("@"); 335 + }); 336 + 337 + it("fill() fills rectangle", () => { 338 + const nodes: UINode[] = [canvas((ctx) => { ctx.fill(0, 0, 3, 2, "#", "muted"); })]; 339 + layoutRoot(nodes, { x: 0, y: 0, width: 80, height: 20 }); 340 + const ansi = renderToAnsi(nodes, theme, "rounded", defaultOpts); 341 + expect((ansi.match(/#/g) || []).length).toBe(6); 342 + }); 343 + 344 + it("clips to bounds", () => { 345 + let cellCount = 0; 346 + const c = canvas((ctx) => { 347 + ctx.set(-1, 0, "X"); 348 + ctx.set(ctx.width, 0, "X"); 349 + ctx.set(0, -1, "X"); 350 + ctx.set(0, ctx.height, "X"); 351 + ctx.set(0, 0, "V"); 352 + cellCount = (c as any)._cells.length; 353 + }); 354 + layoutRoot([c], { x: 0, y: 0, width: 10, height: 5 }); 355 + renderToAnsi([c], theme, "rounded", defaultOpts); 356 + expect(cellCount).toBe(1); 357 + }); 358 + 359 + it("canvas inside panel gets inset rect", () => { 360 + let w = 0, h = 0; 361 + const nodes: UINode[] = [panel("Game", [canvas((ctx) => { w = ctx.width; h = ctx.height; })])]; 362 + layoutRoot(nodes, { x: 0, y: 0, width: 40, height: 12 }); 363 + renderToAnsi(nodes, theme, "rounded", defaultOpts); 364 + expect(w).toBe(36); 365 + expect(h).toBe(10); 366 + }); 367 + 368 + it("sidebar + canvas in hstack", () => { 369 + let gw = 0, gh = 0; 370 + const nodes: UINode[] = [ 371 + statusBar("Game", "score"), 372 + hstack({ gap: 0 }, [ 373 + column({ width: 20 }, [panel("Inv", [text("sword")])]), 374 + column({ flex: true }, [canvas((ctx) => { gw = ctx.width; gh = ctx.height; })]), 375 + ]), 376 + footer("quit"), 377 + ]; 378 + layoutRoot(nodes, { x: 0, y: 0, width: 80, height: 24 }); 379 + renderToAnsi(nodes, theme, "rounded", defaultOpts); 380 + expect(gw).toBe(60); 381 + expect(gh).toBe(22); 382 + }); 383 + }); 384 + 385 + // ── CellBuffer ANSI parser ── 386 + describe("CellBuffer", () => { 387 + it("handles CSI private parameter prefixes without leaking text", () => { 388 + const buf = new CellBuffer(5, 40); 389 + buf.writeAnsi("\x1b[?2004h\x1b[?25lhello"); 390 + const row0 = buf.cells[0].map(c => c.char).join("").trim(); 391 + expect(row0).toBe("hello"); 392 + expect(row0).not.toContain("2004"); 393 + }); 394 + }); 395 + 396 + // ── Text wrapping ── 397 + describe("text wrap", () => { 398 + it("wrapText breaks at word boundaries (space)", () => { 399 + const { wrapText } = require("../src/tui/colors.ts"); 400 + const { lines, offsets } = wrapText("Hello World", 8); 401 + expect(lines).toEqual(["Hello", " World"]); 402 + expect(offsets).toEqual([0, 5]); 403 + }); 404 + 405 + it("wrapText falls back to char-breaking for long words", () => { 406 + const { wrapText } = require("../src/tui/colors.ts"); 407 + const { lines, offsets } = wrapText("abcdefgh", 5); 408 + expect(lines).toEqual(["abcde", "fgh"]); 409 + expect(offsets).toEqual([0, 5]); 410 + }); 411 + 412 + it("wrapText char-breaks when a word exceeds width", () => { 413 + const { wrapText } = require("../src/tui/colors.ts"); 414 + // "Hello" fits in 5, " World!" starts next line but 7 > 5, char-breaks 415 + const { lines, offsets } = wrapText("Hello World!", 5); 416 + expect(lines).toEqual(["Hello", " Worl", "d!"]); 417 + expect(offsets).toEqual([0, 5, 10]); 418 + }); 419 + 420 + it("wrapText breaks CJK characters correctly", () => { 421 + const { wrapText } = require("../src/tui/colors.ts"); 422 + // Each CJK char is 2 cells wide; width 6 fits 3 CJK chars per line 423 + const { lines, offsets } = wrapText("\u4e2d\u6587\u6d4b\u8bd5\u6570\u636e", 6); 424 + expect(lines).toEqual(["\u4e2d\u6587\u6d4b", "\u8bd5\u6570\u636e"]); 425 + expect(offsets).toEqual([0, 3]); 426 + }); 427 + 428 + it("wrapText returns single line if text fits", () => { 429 + const { wrapText } = require("../src/tui/colors.ts"); 430 + const { lines } = wrapText("Hi", 10); 431 + expect(lines).toEqual(["Hi"]); 432 + }); 433 + 434 + it("wrapText handles empty string", () => { 435 + const { wrapText } = require("../src/tui/colors.ts"); 436 + const { lines } = wrapText("", 10); 437 + expect(lines).toEqual([""]); 438 + }); 439 + 440 + it("text node with wrap: true has correct measured height", () => { 441 + const node = text("Hello World, this is a long line", "primary", { wrap: true }); 442 + const nodes: UINode[] = [node]; 443 + // 10 cols wide — word-wrap: "Hello" / " World," / " this is" / " a long" / " line" = 5 lines 444 + layoutRoot(nodes, { x: 0, y: 0, width: 10, height: 20 }); 445 + expect(node._rect!.height).toBe(5); 446 + }); 447 + 448 + it("text node with wrap: true renders multiple lines in buffer", () => { 449 + const myScreen = screen({ 450 + id: "wrap-test", 451 + render() { 452 + return [text("AAAA BBBB CCCC DDDD", "primary", { wrap: true })]; 453 + }, 454 + }); 455 + const ctx = { 456 + rows: 10, cols: 10, 457 + theme, boxStyle: "rounded" as const, 458 + navigate: () => {}, back: () => {}, 459 + openOverlay: () => {}, closeOverlay: () => {}, 460 + isTextInputActive: () => false, setTextInputActive: () => {}, 461 + }; 462 + const buf = myScreen.renderToBuffer(ctx); 463 + const line0 = buf.cells[0].map(c => c.char).join("").trim(); 464 + const line1 = buf.cells[1].map(c => c.char).join("").trim(); 465 + expect(line0).toBe("AAAA BBBB"); 466 + expect(line1).toContain("CCCC"); 467 + }); 468 + 469 + it("text node without wrap truncates", () => { 470 + const node = text("Hello World", "primary", { truncate: true }); 471 + const nodes: UINode[] = [node]; 472 + layoutRoot(nodes, { x: 0, y: 0, width: 8, height: 1 }); 473 + expect(node._rect!.height).toBe(1); 474 + }); 475 + }); 476 + 477 + // ── Text highlight ── 478 + describe("text highlight", () => { 479 + it("text() accepts a highlight callback", () => { 480 + const highlighter = (t: string) => [{ start: 0, end: 5, bold: true }]; 481 + const n = text("Hello World", "primary", { highlight: highlighter }); 482 + expect(n.highlight).toBe(highlighter); 483 + }); 484 + 485 + it("highlight applies bold to span range in buffer", () => { 486 + const highlighter = (t: string) => [ 487 + { start: 0, end: 5, color: "accent" as const, bold: true }, 488 + ]; 489 + const myScreen = screen({ 490 + id: "highlight-test", 491 + render() { 492 + return [text("Hello World", "primary", { highlight: highlighter })]; 493 + }, 494 + }); 495 + const ctx = { 496 + rows: 5, cols: 40, 497 + theme, boxStyle: "rounded" as const, 498 + navigate: () => {}, back: () => {}, 499 + openOverlay: () => {}, closeOverlay: () => {}, 500 + isTextInputActive: () => false, setTextInputActive: () => {}, 501 + }; 502 + const buf = myScreen.renderToBuffer(ctx); 503 + // "Hello" (chars 0-4) should be bold + accent color 504 + expect(buf.cells[0][0].bold).toBe(true); 505 + expect(buf.cells[0][0].char).toBe("H"); 506 + expect(buf.cells[0][4].bold).toBe(true); 507 + expect(buf.cells[0][4].char).toBe("o"); 508 + // " World" (chars 5+) should NOT be bold 509 + expect(buf.cells[0][5].bold).toBe(false); 510 + expect(buf.cells[0][5].char).toBe(" "); 511 + }); 512 + 513 + it("highlight works with wrap", () => { 514 + const highlighter = (t: string) => [ 515 + { start: 0, end: 3, bold: true }, // "AAA" 516 + { start: 4, end: 7, bold: true }, // "BBB" 517 + ]; 518 + const myScreen = screen({ 519 + id: "highlight-wrap-test", 520 + render() { 521 + return [text("AAA BBB CCC", "primary", { wrap: true, highlight: highlighter })]; 522 + }, 523 + }); 524 + const ctx = { 525 + rows: 5, cols: 6, 526 + theme, boxStyle: "rounded" as const, 527 + navigate: () => {}, back: () => {}, 528 + openOverlay: () => {}, closeOverlay: () => {}, 529 + isTextInputActive: () => false, setTextInputActive: () => {}, 530 + }; 531 + const buf = myScreen.renderToBuffer(ctx); 532 + // Word-wrap: "AAA" / " BBB" / " CCC" (breaks before spaces) 533 + // Line 0: "AAA" — chars 0-2 bold (span 0..3) 534 + expect(buf.cells[0][0].bold).toBe(true); // A (index 0) 535 + expect(buf.cells[0][2].bold).toBe(true); // A (index 2) 536 + // Line 1: " BBB" — char 0 is space (index 3, not bold), chars 1-3 are B (indices 4-6, bold) 537 + expect(buf.cells[1][0].bold).toBe(false); // space (index 3) 538 + expect(buf.cells[1][1].bold).toBe(true); // B (index 4) 539 + expect(buf.cells[1][3].bold).toBe(true); // B (index 6) 540 + // Line 2: " CCC" — none bold 541 + expect(buf.cells[2][0].bold).toBe(false); // space (index 7) 542 + expect(buf.cells[2][1].bold).toBe(false); // C (index 8) 543 + }); 544 + 545 + it("markdown heading highlight: lines starting with # are bold", () => { 546 + function markdownHighlight(content: string): { start: number; end: number; bold: boolean }[] { 547 + if (content.startsWith("#")) { 548 + return [{ start: 0, end: [...content].length, bold: true }]; 549 + } 550 + return []; 551 + } 552 + const myScreen = screen({ 553 + id: "md-test", 554 + render() { 555 + return [text("# Hello", "primary", { highlight: markdownHighlight })]; 556 + }, 557 + }); 558 + const ctx = { 559 + rows: 5, cols: 40, 560 + theme, boxStyle: "rounded" as const, 561 + navigate: () => {}, back: () => {}, 562 + openOverlay: () => {}, closeOverlay: () => {}, 563 + isTextInputActive: () => false, setTextInputActive: () => {}, 564 + }; 565 + const buf = myScreen.renderToBuffer(ctx); 566 + expect(buf.cells[0][0].bold).toBe(true); 567 + expect(buf.cells[0][0].char).toBe("#"); 568 + expect(buf.cells[0][6].bold).toBe(true); 569 + expect(buf.cells[0][6].char).toBe("o"); 570 + }); 571 + });
+2 -2
tests/tui.test.ts
··· 324 324 // Type part of name1 to filter 325 325 const filterText = name1.slice(-4); 326 326 tui.type(filterText); 327 - await new Promise((r) => setTimeout(r, 300)); 327 + await tui.waitForText(filterText, 5000); 328 328 329 329 let ss = tui.screenshot(); 330 330 expect(ss.text).toContain(name1); ··· 334 334 for (let i = 0; i < filterText.length; i++) { 335 335 tui.press("backspace"); 336 336 } 337 - await new Promise((r) => setTimeout(r, 300)); 337 + await tui.waitForText(name2, 5000); 338 338 339 339 ss = tui.screenshot(); 340 340 expect(ss.text).toContain(name1);
+9
vitest.config.ts
··· 1 + import { defineConfig } from "vitest/config"; 2 + 3 + export default defineConfig({ 4 + test: { 5 + exclude: [ 6 + "node_modules/**", 7 + ], 8 + }, 9 + });