This repository has no description
0

Configure Feed

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

Add mouseMode, cursorRow/Col, and scrollback support to PtyHandle

- Track mouse mode (1000/1002/1003) via CSI parser on local and
attached handles, exposed as readonly mouseMode property
- Add cursorRow/cursorCol properties (from xterm buffer cursor)
- Accept scrollback option in createPty/attachPty (default 0)
- Add bufferLength/baseY properties for scrollback navigation
- Support readCells(scrollOffset) to read into scrollback history
- Fix readXtermCells to read from baseY (was reading from line 0,
masked by scrollback:0)
- Re-export spawnDaemon from tui index

Nathan Herald (Apr 2, 2026, 5:28 PM +0200) 08f987e1 555fd345

+312 -8
+75 -7
src/tui/builders.ts
··· 305 305 terminal: any, 306 306 rows: number, 307 307 cols: number, 308 + scrollOffset: number = 0, 308 309 ): ReturnType<PtyHandle["readCells"]> { 309 310 const buf = terminal.buffer.active; 311 + const baseY = buf.baseY as number; 312 + const startLine = Math.max(0, baseY - scrollOffset); 310 313 const grid: ReturnType<PtyHandle["readCells"]> = []; 311 314 312 315 for (let r = 0; r < rows; r++) { 313 - const line = buf.getLine(r); 316 + const lineIdx = startLine + r; 317 + const line = lineIdx < buf.length ? buf.getLine(lineIdx) : null; 314 318 const row: typeof grid[0] = []; 315 319 for (let c = 0; c < cols; c++) { 316 320 if (!line) { ··· 361 365 export function createPty( 362 366 command: string, 363 367 args: string[] = [], 364 - opts?: { cols?: number; rows?: number; cwd?: string; env?: Record<string, string>; theme?: Theme }, 368 + opts?: { cols?: number; rows?: number; scrollback?: number; cwd?: string; env?: Record<string, string>; theme?: Theme }, 365 369 ): PtyHandle { 366 370 // Lazy-import node-pty and @xterm/headless to avoid top-level side effects 367 371 // and keep the module loadable in test environments that don't need PTY. ··· 372 376 373 377 const cols = opts?.cols ?? 80; 374 378 const rows = opts?.rows ?? 24; 379 + const scrollbackSize = opts?.scrollback ?? 0; 375 380 376 - const xtermOpts: any = { rows, cols, scrollback: 0, allowProposedApi: true }; 381 + const xtermOpts: any = { rows, cols, scrollback: scrollbackSize, allowProposedApi: true }; 377 382 if (opts?.theme) xtermOpts.theme = themeToXterm(opts.theme); 378 383 const terminal = new TerminalClass(xtermOpts); 379 384 385 + // Track mouse mode via CSI parser (same approach as server.ts) 386 + let mouseMode = false; 387 + terminal.parser.registerCsiHandler( 388 + { prefix: "?", final: "h" }, 389 + (params: any) => { 390 + for (const p of params) { 391 + const v = typeof p === "number" ? p : p[0]; 392 + if (v === 1000 || v === 1002 || v === 1003) mouseMode = true; 393 + } 394 + return false; 395 + }, 396 + ); 397 + terminal.parser.registerCsiHandler( 398 + { prefix: "?", final: "l" }, 399 + (params: any) => { 400 + for (const p of params) { 401 + const v = typeof p === "number" ? p : p[0]; 402 + if (v === 1000 || v === 1002 || v === 1003) mouseMode = false; 403 + } 404 + return false; 405 + }, 406 + ); 407 + 380 408 const childEnv: Record<string, string> = { 381 409 ...(process.env as Record<string, string>), 382 410 ...opts?.env, ··· 419 447 } 420 448 }, 421 449 422 - readCells() { return readXtermCells(terminal, handle.rows, handle.cols); }, 450 + readCells(scrollOffset?: number) { 451 + return readXtermCells(terminal, handle.rows, handle.cols, scrollOffset ?? 0); 452 + }, 423 453 424 454 kill() { 425 455 if (!exited) { ··· 440 470 get dirty() { return dirty; }, 441 471 set dirty(v: boolean) { dirty = v; }, 442 472 onActivity: null, 473 + get cursorRow() { return terminal.buffer.active.cursorY; }, 474 + get cursorCol() { return terminal.buffer.active.cursorX; }, 475 + get mouseMode() { return mouseMode; }, 476 + get scrollback() { return scrollbackSize; }, 477 + get bufferLength() { return terminal.buffer.active.length; }, 478 + get baseY() { return terminal.buffer.active.baseY; }, 443 479 }; 444 480 445 481 return handle; ··· 459 495 */ 460 496 export async function attachPty( 461 497 name: string, 462 - opts?: { cols?: number; rows?: number; theme?: Theme }, 498 + opts?: { cols?: number; rows?: number; scrollback?: number; theme?: Theme }, 463 499 ): Promise<PtyHandle> { 464 500 const net = require("node:net") as typeof import("node:net"); 465 501 const xtermMod = require("@xterm/headless") as any; ··· 471 507 472 508 const cols = opts?.cols ?? 80; 473 509 const rows = opts?.rows ?? 24; 510 + const scrollbackSize = opts?.scrollback ?? 0; 474 511 475 - const xtermOpts: any = { rows, cols, scrollback: 0, allowProposedApi: true }; 512 + const xtermOpts: any = { rows, cols, scrollback: scrollbackSize, allowProposedApi: true }; 476 513 if (opts?.theme) xtermOpts.theme = themeToXterm(opts.theme); 477 514 const terminal = new TerminalClass(xtermOpts); 515 + 516 + // Track mouse mode via CSI parser 517 + let mouseMode = false; 518 + terminal.parser.registerCsiHandler( 519 + { prefix: "?", final: "h" }, 520 + (params: any) => { 521 + for (const p of params) { 522 + const v = typeof p === "number" ? p : p[0]; 523 + if (v === 1000 || v === 1002 || v === 1003) mouseMode = true; 524 + } 525 + return false; 526 + }, 527 + ); 528 + terminal.parser.registerCsiHandler( 529 + { prefix: "?", final: "l" }, 530 + (params: any) => { 531 + for (const p of params) { 532 + const v = typeof p === "number" ? p : p[0]; 533 + if (v === 1000 || v === 1002 || v === 1003) mouseMode = false; 534 + } 535 + return false; 536 + }, 537 + ); 478 538 const socketPath = getSocketPath(name); 479 539 480 540 // Connect to the daemon socket ··· 502 562 } 503 563 }, 504 564 505 - readCells() { return readXtermCells(terminal, handle.rows, handle.cols); }, 565 + readCells(scrollOffset?: number) { 566 + return readXtermCells(terminal, handle.rows, handle.cols, scrollOffset ?? 0); 567 + }, 506 568 507 569 kill() { 508 570 // Detach only — the daemon keeps the process running ··· 523 585 get dirty() { return dirty; }, 524 586 set dirty(v: boolean) { dirty = v; }, 525 587 onActivity: null, 588 + get cursorRow() { return terminal.buffer.active.cursorY; }, 589 + get cursorCol() { return terminal.buffer.active.cursorX; }, 590 + get mouseMode() { return mouseMode; }, 591 + get scrollback() { return scrollbackSize; }, 592 + get bufferLength() { return terminal.buffer.active.length; }, 593 + get baseY() { return terminal.buffer.active.baseY; }, 526 594 }; 527 595 528 596 socket.on("data", (data: Buffer) => {
+3
src/tui/index.ts
··· 88 88 89 89 // App lifecycle 90 90 export { app, type AppConfig, type App } from "./app.ts"; 91 + 92 + // Daemon spawn 93 + export { spawnDaemon } from "../spawn.ts";
+14 -1
src/tui/nodes.ts
··· 236 236 /** 237 237 * Read the terminal's cell grid directly. Full fidelity — every attribute 238 238 * xterm parsed is preserved. No serialize round-trip. 239 + * @param scrollOffset Lines to scroll back into history (0 = live viewport). 239 240 */ 240 - readCells(): { char: string; fg: [number, number, number] | null; bg: [number, number, number] | null; bold: boolean; dim: boolean; italic: boolean; underline: boolean }[][]; 241 + readCells(scrollOffset?: number): { char: string; fg: [number, number, number] | null; bg: [number, number, number] | null; bold: boolean; dim: boolean; italic: boolean; underline: boolean }[][]; 241 242 /** Current PTY dimensions. */ 242 243 cols: number; 243 244 rows: number; ··· 251 252 onActivity: (() => void) | null; 252 253 /** Update the xterm terminal's color theme. Call when the app theme changes. */ 253 254 setTheme(theme: import("./colors.ts").Theme): void; 255 + /** Cursor row position (0-indexed, relative to viewport). */ 256 + readonly cursorRow: number; 257 + /** Cursor column position (0-indexed). */ 258 + readonly cursorCol: number; 259 + /** Whether the child process has enabled mouse tracking (modes 1000/1002/1003). */ 260 + readonly mouseMode: boolean; 261 + /** Configured scrollback line count. */ 262 + readonly scrollback: number; 263 + /** Total lines in the buffer (viewport + scrollback history). */ 264 + readonly bufferLength: number; 265 + /** Line index at the top of the live viewport. */ 266 + readonly baseY: number; 254 267 } 255 268 256 269 export interface PtyViewNode {
+220
tests/pty-handle.test.ts
··· 1 + import { describe, it, expect, afterEach } from "vitest"; 2 + import { createPty, type PtyHandle } from "../src/tui/index.ts"; 3 + 4 + const handles: PtyHandle[] = []; 5 + 6 + function spawn( 7 + command: string, 8 + args: string[] = [], 9 + opts?: { cols?: number; rows?: number; scrollback?: number }, 10 + ): PtyHandle { 11 + const handle = createPty(command, args, { 12 + cols: opts?.cols ?? 80, 13 + rows: opts?.rows ?? 24, 14 + scrollback: opts?.scrollback, 15 + }); 16 + handles.push(handle); 17 + return handle; 18 + } 19 + 20 + /** Wait for the handle's onActivity to fire. */ 21 + function waitForActivity(handle: PtyHandle, timeoutMs = 5000): Promise<void> { 22 + return new Promise((resolve, reject) => { 23 + const prev = handle.onActivity; 24 + const timer = setTimeout(() => { 25 + handle.onActivity = prev; 26 + reject(new Error(`Timed out waiting for activity after ${timeoutMs}ms`)); 27 + }, timeoutMs); 28 + handle.onActivity = () => { 29 + clearTimeout(timer); 30 + handle.onActivity = prev; 31 + prev?.(); 32 + resolve(); 33 + }; 34 + }); 35 + } 36 + 37 + /** Wait until a predicate returns true by polling onActivity. */ 38 + async function waitFor( 39 + handle: PtyHandle, 40 + predicate: () => boolean, 41 + timeoutMs = 5000, 42 + ): Promise<void> { 43 + if (predicate()) return; 44 + const deadline = Date.now() + timeoutMs; 45 + while (Date.now() < deadline) { 46 + await waitForActivity(handle, deadline - Date.now()); 47 + if (predicate()) return; 48 + } 49 + throw new Error(`waitFor timed out after ${timeoutMs}ms`); 50 + } 51 + 52 + /** Read the text content of a cell grid as joined lines. */ 53 + function cellsToText(cells: ReturnType<PtyHandle["readCells"]>): string { 54 + return cells.map(row => row.map(c => c.char).join("")).join("\n"); 55 + } 56 + 57 + afterEach(() => { 58 + for (const h of handles) { 59 + try { h.kill(); } catch {} 60 + } 61 + handles.length = 0; 62 + }); 63 + 64 + // --- cursorRow / cursorCol --- 65 + 66 + describe("cursorRow and cursorCol", () => { 67 + it("reports initial cursor at (0, 0)", () => { 68 + const h = spawn("cat"); 69 + expect(h.cursorRow).toBe(0); 70 + expect(h.cursorCol).toBe(0); 71 + }); 72 + 73 + it("cursor moves when output is written", async () => { 74 + const h = spawn("bash", ["-c", "printf 'hello'"]); 75 + await waitFor(h, () => h.cursorCol > 0); 76 + expect(h.cursorRow).toBe(0); 77 + expect(h.cursorCol).toBe(5); 78 + }); 79 + 80 + it("cursor row advances with newlines", async () => { 81 + const h = spawn("bash", ["-c", "printf 'a\\nb\\nc'"]); 82 + await waitFor(h, () => h.cursorRow >= 2); 83 + expect(h.cursorRow).toBe(2); 84 + expect(h.cursorCol).toBe(1); 85 + }); 86 + }); 87 + 88 + // --- mouseMode --- 89 + 90 + describe("mouseMode", () => { 91 + it("is false by default", () => { 92 + const h = spawn("cat"); 93 + expect(h.mouseMode).toBe(false); 94 + }); 95 + 96 + it("becomes true when child enables mouse tracking (mode 1000)", async () => { 97 + const h = spawn("bash", ["-c", "printf '\\x1b[?1000h'; sleep 10"]); 98 + await waitFor(h, () => h.mouseMode === true); 99 + expect(h.mouseMode).toBe(true); 100 + }); 101 + 102 + it("becomes false when child disables mouse tracking", async () => { 103 + const h = spawn("bash", ["-c", "printf '\\x1b[?1000h'; sleep 0.1; printf '\\x1b[?1000l'; sleep 10"]); 104 + await waitFor(h, () => h.mouseMode === true); 105 + await waitFor(h, () => h.mouseMode === false); 106 + expect(h.mouseMode).toBe(false); 107 + }); 108 + 109 + it("tracks mode 1002 (button-motion)", async () => { 110 + const h = spawn("bash", ["-c", "printf '\\x1b[?1002h'; sleep 10"]); 111 + await waitFor(h, () => h.mouseMode === true); 112 + expect(h.mouseMode).toBe(true); 113 + }); 114 + 115 + it("tracks mode 1003 (any-motion)", async () => { 116 + const h = spawn("bash", ["-c", "printf '\\x1b[?1003h'; sleep 10"]); 117 + await waitFor(h, () => h.mouseMode === true); 118 + expect(h.mouseMode).toBe(true); 119 + }); 120 + }); 121 + 122 + // --- scrollback --- 123 + 124 + describe("scrollback", () => { 125 + it("defaults to 0", () => { 126 + const h = spawn("cat"); 127 + expect(h.scrollback).toBe(0); 128 + expect(h.baseY).toBe(0); 129 + }); 130 + 131 + it("respects configured scrollback", () => { 132 + const h = spawn("cat", [], { scrollback: 500 }); 133 + expect(h.scrollback).toBe(500); 134 + }); 135 + 136 + it("bufferLength equals rows when no scrollback content", () => { 137 + const h = spawn("cat", [], { rows: 10 }); 138 + expect(h.bufferLength).toBe(10); 139 + }); 140 + 141 + it("bufferLength grows as content pushes into scrollback", async () => { 142 + const h = spawn("bash", ["-c", "for i in $(seq 1 50); do echo line-$i; done; sleep 10"], { 143 + rows: 10, 144 + scrollback: 100, 145 + }); 146 + await waitFor(h, () => h.baseY > 0); 147 + expect(h.bufferLength).toBeGreaterThan(10); 148 + expect(h.baseY).toBeGreaterThan(0); 149 + }); 150 + 151 + it("baseY stays 0 with scrollback: 0 (old lines discarded)", async () => { 152 + const h = spawn("bash", ["-c", "for i in $(seq 1 50); do echo line-$i; done; sleep 10"], { 153 + rows: 10, 154 + scrollback: 0, 155 + }); 156 + await waitFor(h, () => h.dirty); 157 + // Give it a moment to process all output 158 + await new Promise(r => setTimeout(r, 500)); 159 + expect(h.baseY).toBe(0); 160 + }); 161 + }); 162 + 163 + // --- readCells with scrollOffset --- 164 + 165 + describe("readCells", () => { 166 + it("returns viewport-sized grid", () => { 167 + const h = spawn("cat", [], { rows: 10, cols: 20 }); 168 + const cells = h.readCells(); 169 + expect(cells.length).toBe(10); 170 + expect(cells[0]!.length).toBe(20); 171 + }); 172 + 173 + it("readCells(0) shows live viewport content", async () => { 174 + const h = spawn("bash", ["-c", "for i in $(seq 1 50); do echo line-$i; done; sleep 10"], { 175 + rows: 10, 176 + scrollback: 100, 177 + }); 178 + await waitFor(h, () => h.baseY > 0); 179 + // Give time for all output 180 + await new Promise(r => setTimeout(r, 500)); 181 + 182 + const text = cellsToText(h.readCells(0)); 183 + // Live viewport should have recent lines (high numbers) 184 + expect(text).toMatch(/line-[4-5]\d/); 185 + }); 186 + 187 + it("readCells with scrollOffset reads history", async () => { 188 + const h = spawn("bash", ["-c", "for i in $(seq 1 50); do echo line-$i; done; sleep 10"], { 189 + rows: 10, 190 + scrollback: 100, 191 + }); 192 + await waitFor(h, () => h.baseY > 0); 193 + await new Promise(r => setTimeout(r, 500)); 194 + 195 + // Scroll all the way back 196 + const cells = h.readCells(h.baseY); 197 + const text = cellsToText(cells); 198 + // Should see early lines 199 + expect(text).toMatch(/line-[1-9]\b/); 200 + }); 201 + 202 + it("scrollOffset is clamped (does not crash for large values)", () => { 203 + const h = spawn("cat", [], { rows: 10, scrollback: 100 }); 204 + const cells = h.readCells(99999); 205 + expect(cells.length).toBe(10); 206 + }); 207 + 208 + it("readCells without scrollback returns current content", async () => { 209 + const h = spawn("bash", ["-c", "echo hello-world; sleep 10"], { 210 + rows: 10, 211 + scrollback: 0, 212 + }); 213 + await waitFor(h, () => { 214 + const text = cellsToText(h.readCells()); 215 + return text.includes("hello-world"); 216 + }); 217 + const text = cellsToText(h.readCells()); 218 + expect(text).toContain("hello-world"); 219 + }); 220 + });