This repository has no description
0

Configure Feed

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

Interactive

Nathan Herald (Mar 18, 2026, 8:10 PM +0100) bf46807d 4a0abf64

+2090 -148
+16 -131
src/cli.ts
··· 1 - import { spawn, execFileSync } from "node:child_process"; 2 - import * as fs from "node:fs"; 3 1 import * as os from "node:os"; 4 - import * as path from "node:path"; 5 2 import * as readline from "node:readline/promises"; 6 - import * as tty from "node:tty"; 7 - import { fileURLToPath } from "node:url"; 8 3 import { attach, peek, send } from "./client.ts"; 9 4 import { parseSeqValue } from "./keys.ts"; 10 5 import { 11 6 listSessions, 12 7 getSession, 13 - getSocketPath, 14 8 cleanupAll, 15 9 cleanupSocket, 16 10 validateName, ··· 18 12 releaseLock, 19 13 type SessionInfo, 20 14 } from "./sessions.ts"; 21 - 22 - const __dirname = path.dirname(fileURLToPath(import.meta.url)); 15 + import { spawnDaemon, resolveCommand } from "./spawn.ts"; 16 + import { runInteractive } from "./tui/interactive.ts"; 23 17 24 18 function usage(): void { 25 19 console.log(`Usage: 20 + pty Interactive session manager 26 21 pty run <name> <command> [args...] Create a session and attach 27 22 pty run -d <name> <command> [args...] Create a session in the background 28 23 pty run -a <name> <command> [args...] Create or attach if already running ··· 47 42 const args = process.argv.slice(2); 48 43 49 44 if (args.length === 0) { 50 - await cmdList(); 45 + await runInteractive(); 51 46 return; 52 47 } 53 48 54 49 const command = args[0]; 55 50 56 51 switch (command) { 52 + case "interactive": 53 + case "i": { 54 + await runInteractive(); 55 + break; 56 + } 57 + 57 58 case "run": { 58 59 // Parse flags before positional args 59 60 let detach = false; ··· 97 98 process.exit(1); 98 99 } 99 100 const displayCmd = cmd; 100 - cmd = resolveCommand(cmd); 101 + try { 102 + cmd = resolveCommand(cmd); 103 + } catch (e: any) { 104 + console.error(e.message); 105 + process.exit(1); 106 + } 101 107 await cmdRun(name, cmd, cmdArgs, detach, attachExisting, displayCmd); 102 108 break; 103 109 } ··· 506 512 doAttach(name); 507 513 } 508 514 509 - async function spawnDaemon( 510 - name: string, 511 - command: string, 512 - args: string[], 513 - displayCommand: string, 514 - cwd?: string 515 - ): Promise<void> { 516 - const stdout = process.stdout as tty.WriteStream; 517 - const rows = stdout.rows ?? 24; 518 - const cols = stdout.columns ?? 80; 519 - 520 - const tsxBin = path.join(__dirname, "..", "node_modules", ".bin", "tsx"); 521 - const serverModule = path.join(__dirname, "server.ts"); 522 - const config = JSON.stringify({ 523 - name, 524 - command, 525 - args, 526 - displayCommand, 527 - cwd: cwd ?? process.cwd(), 528 - rows, 529 - cols, 530 - }); 531 - 532 - const child = spawn(tsxBin, [serverModule], { 533 - detached: true, 534 - stdio: ["ignore", "ignore", "pipe"], 535 - env: { ...process.env, PTY_SERVER_CONFIG: config }, 536 - }); 537 - 538 - // Capture stderr for better error reporting 539 - let stderrOutput = ""; 540 - child.stderr?.on("data", (data: Buffer) => { 541 - stderrOutput += data.toString(); 542 - }); 543 - 544 - // Detect early daemon crash before the socket appears 545 - let earlyExit = false; 546 - let earlyExitCode: number | null = null; 547 - child.on("exit", (code) => { 548 - earlyExit = true; 549 - earlyExitCode = code; 550 - }); 551 - 552 - (child.stderr as any)?.unref?.(); 553 - child.unref(); 554 - 555 - await waitForSocket(name, 3000, () => { 556 - if (earlyExit) { 557 - const details = stderrOutput.trim(); 558 - const msg = `Daemon process exited immediately (code ${earlyExitCode ?? "unknown"}).`; 559 - throw new Error(details ? `${msg}\n${details}` : `${msg} Is the command valid?`); 560 - } 561 - }); 562 - } 563 - 564 - function waitForSocket( 565 - name: string, 566 - timeoutMs: number, 567 - earlyCheck?: () => void 568 - ): Promise<void> { 569 - const socketPath = getSocketPath(name); 570 - const start = Date.now(); 571 - 572 - return new Promise((resolve, reject) => { 573 - function check(): void { 574 - // Check for early daemon failure 575 - try { 576 - earlyCheck?.(); 577 - } catch (e) { 578 - reject(e); 579 - return; 580 - } 581 - 582 - if (Date.now() - start > timeoutMs) { 583 - reject(new Error(`Timeout waiting for session "${name}" to start`)); 584 - return; 585 - } 586 - 587 - try { 588 - const stat = fs.statSync(socketPath); 589 - if (stat) { 590 - setTimeout(resolve, 100); 591 - return; 592 - } 593 - } catch {} 594 - 595 - setTimeout(check, 50); 596 - } 597 - check(); 598 - }); 599 - } 600 - 601 515 function ask(prompt: string): Promise<string> { 602 516 const rl = readline.createInterface({ 603 517 input: process.stdin, ··· 607 521 rl.close(); 608 522 return answer; 609 523 }); 610 - } 611 - 612 - function resolveCommand(cmd: string): string { 613 - // Already absolute — just verify it exists 614 - if (path.isAbsolute(cmd)) { 615 - if (!fs.existsSync(cmd)) { 616 - console.error(`Command not found: ${cmd}`); 617 - process.exit(1); 618 - } 619 - return cmd; 620 - } 621 - 622 - // Relative path (contains /) — resolve against cwd 623 - if (cmd.includes("/")) { 624 - const resolved = path.resolve(cmd); 625 - if (!fs.existsSync(resolved)) { 626 - console.error(`Command not found: ${cmd}`); 627 - process.exit(1); 628 - } 629 - return resolved; 630 - } 631 - 632 - // Bare command name — look up in PATH 633 - try { 634 - return execFileSync("which", [cmd], { encoding: "utf8" }).trim(); 635 - } catch { 636 - console.error(`Command not found: ${cmd}`); 637 - process.exit(1); 638 - } 639 524 } 640 525 641 526 function shortPath(p: string): string {
+32 -7
src/client.ts
··· 198 198 let detaching = false; 199 199 let rawWasSet = false; 200 200 let exitCode = 0; 201 + let stdinDataHandler: ((data: Buffer) => void) | null = null; 202 + let resizeHandler: (() => void) | null = null; 201 203 202 204 function enterRawMode(): void { 203 205 if (stdin.isTTY && !stdin.isRaw) { ··· 213 215 } 214 216 215 217 function cleanExit(): void { 218 + if (stdinDataHandler) { 219 + stdin.removeListener("data", stdinDataHandler); 220 + stdinDataHandler = null; 221 + } 222 + if (resizeHandler && stdout instanceof tty.WriteStream) { 223 + stdout.removeListener("resize", resizeHandler); 224 + resizeHandler = null; 225 + } 216 226 exitRawMode(); 217 227 socket.destroy(); 218 228 } ··· 230 240 let lastDetachKeyTime = 0; 231 241 const DOUBLE_TAP_MS = 300; 232 242 233 - stdin.on("data", (raw: Buffer) => { 243 + stdinDataHandler = (raw: Buffer) => { 234 244 const data = normalizeDetachKey(raw); 235 245 236 246 // Fast path: no detach key in this chunk ··· 270 280 if (forward.length > 0) { 271 281 socket.write(encodeData(Buffer.from(forward).toString())); 272 282 } 273 - }); 283 + }; 284 + stdin.on("data", stdinDataHandler); 274 285 275 286 // Explicitly resume stdin. We cannot rely on the auto-resume from 276 287 // .on("data") because Node.js skips it when _readableState.flowing ··· 281 292 282 293 // Handle terminal resize 283 294 if (stdout instanceof tty.WriteStream) { 284 - stdout.on("resize", () => { 295 + resizeHandler = () => { 285 296 const rows = stdout.rows; 286 297 const cols = stdout.columns; 287 298 socket.write(encodeResize(rows, cols)); 288 - }); 299 + }; 300 + stdout.on("resize", resizeHandler); 289 301 } 290 302 }); 291 303 ··· 313 325 } 314 326 }); 315 327 328 + let exitHandled = false; 329 + 316 330 socket.on("error", (err: NodeJS.ErrnoException) => { 331 + if (exitHandled) return; 332 + exitHandled = true; 317 333 cleanExit(); 318 334 if (err.code === "ENOENT" || err.code === "ECONNREFUSED") { 319 335 console.error(`Session "${options.name}" not found or not running.`); 320 336 } else { 321 337 console.error(`Connection error: ${err.message}`); 322 338 } 323 - process.exit(1); 339 + if (options.onExit) { 340 + options.onExit(1); 341 + } else { 342 + process.exit(1); 343 + } 324 344 }); 325 345 326 346 socket.on("close", () => { 327 - if (!detaching) { 347 + if (!detaching && !exitHandled) { 348 + exitHandled = true; 328 349 cleanExit(); 329 - process.exit(exitCode); 350 + if (options.onExit) { 351 + options.onExit(exitCode); 352 + } else { 353 + process.exit(exitCode); 354 + } 330 355 } 331 356 }); 332 357 }
+8 -10
src/sessions.ts
··· 3 3 import * as os from "node:os"; 4 4 import * as net from "node:net"; 5 5 6 - const SESSION_DIR = 7 - process.env.PTY_SESSION_DIR ?? 8 - path.join(os.homedir(), ".local", "state", "pty"); 6 + const DEFAULT_SESSION_DIR = path.join(os.homedir(), ".local", "state", "pty"); 9 7 10 8 const DEAD_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours 11 9 ··· 26 24 } 27 25 28 26 export function getSessionDir(): string { 29 - return SESSION_DIR; 27 + return process.env.PTY_SESSION_DIR ?? DEFAULT_SESSION_DIR; 30 28 } 31 29 32 30 export function ensureSessionDir(): void { 33 - fs.mkdirSync(SESSION_DIR, { recursive: true }); 31 + fs.mkdirSync(getSessionDir(), { recursive: true }); 34 32 } 35 33 36 34 export function getSocketPath(name: string): string { 37 - return path.join(SESSION_DIR, `${name}.sock`); 35 + return path.join(getSessionDir(), `${name}.sock`); 38 36 } 39 37 40 38 export function getPidPath(name: string): string { 41 - return path.join(SESSION_DIR, `${name}.pid`); 39 + return path.join(getSessionDir(), `${name}.pid`); 42 40 } 43 41 44 42 export function getMetadataPath(name: string): string { 45 - return path.join(SESSION_DIR, `${name}.json`); 43 + return path.join(getSessionDir(), `${name}.json`); 46 44 } 47 45 48 46 export interface SessionMetadata { ··· 83 81 84 82 let entries: string[]; 85 83 try { 86 - entries = fs.readdirSync(SESSION_DIR); 84 + entries = fs.readdirSync(getSessionDir()); 87 85 } catch { 88 86 return []; 89 87 } ··· 213 211 } 214 212 215 213 function getLockPath(name: string): string { 216 - return path.join(SESSION_DIR, `${name}.lock`); 214 + return path.join(getSessionDir(), `${name}.lock`); 217 215 } 218 216 219 217 /**
+126
src/spawn.ts
··· 1 + import { spawn, execFileSync } from "node:child_process"; 2 + import * as fs from "node:fs"; 3 + import * as path from "node:path"; 4 + import * as tty from "node:tty"; 5 + import { fileURLToPath } from "node:url"; 6 + import { getSocketPath } from "./sessions.ts"; 7 + 8 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 + 10 + export async function spawnDaemon( 11 + name: string, 12 + command: string, 13 + args: string[], 14 + displayCommand: string, 15 + cwd?: string 16 + ): Promise<void> { 17 + const stdout = process.stdout as tty.WriteStream; 18 + const rows = stdout.rows ?? 24; 19 + const cols = stdout.columns ?? 80; 20 + 21 + const tsxBin = path.join(__dirname, "..", "node_modules", ".bin", "tsx"); 22 + const serverModule = path.join(__dirname, "server.ts"); 23 + const config = JSON.stringify({ 24 + name, 25 + command, 26 + args, 27 + displayCommand, 28 + cwd: cwd ?? process.cwd(), 29 + rows, 30 + cols, 31 + }); 32 + 33 + const child = spawn(tsxBin, [serverModule], { 34 + detached: true, 35 + stdio: ["ignore", "ignore", "pipe"], 36 + env: { ...process.env, PTY_SERVER_CONFIG: config }, 37 + }); 38 + 39 + // Capture stderr for better error reporting 40 + let stderrOutput = ""; 41 + child.stderr?.on("data", (data: Buffer) => { 42 + stderrOutput += data.toString(); 43 + }); 44 + 45 + // Detect early daemon crash before the socket appears 46 + let earlyExit = false; 47 + let earlyExitCode: number | null = null; 48 + child.on("exit", (code) => { 49 + earlyExit = true; 50 + earlyExitCode = code; 51 + }); 52 + 53 + (child.stderr as any)?.unref?.(); 54 + child.unref(); 55 + 56 + await waitForSocket(name, 3000, () => { 57 + if (earlyExit) { 58 + const details = stderrOutput.trim(); 59 + const msg = `Daemon process exited immediately (code ${earlyExitCode ?? "unknown"}).`; 60 + throw new Error(details ? `${msg}\n${details}` : `${msg} Is the command valid?`); 61 + } 62 + }); 63 + } 64 + 65 + export function waitForSocket( 66 + name: string, 67 + timeoutMs: number, 68 + earlyCheck?: () => void 69 + ): Promise<void> { 70 + const socketPath = getSocketPath(name); 71 + const start = Date.now(); 72 + 73 + return new Promise((resolve, reject) => { 74 + function check(): void { 75 + // Check for early daemon failure 76 + try { 77 + earlyCheck?.(); 78 + } catch (e) { 79 + reject(e); 80 + return; 81 + } 82 + 83 + if (Date.now() - start > timeoutMs) { 84 + reject(new Error(`Timeout waiting for session "${name}" to start`)); 85 + return; 86 + } 87 + 88 + try { 89 + const stat = fs.statSync(socketPath); 90 + if (stat) { 91 + setTimeout(resolve, 100); 92 + return; 93 + } 94 + } catch {} 95 + 96 + setTimeout(check, 50); 97 + } 98 + check(); 99 + }); 100 + } 101 + 102 + export function resolveCommand(cmd: string): string { 103 + // Already absolute — just verify it exists 104 + if (path.isAbsolute(cmd)) { 105 + if (!fs.existsSync(cmd)) { 106 + throw new Error(`Command not found: ${cmd}`); 107 + } 108 + return cmd; 109 + } 110 + 111 + // Relative path (contains /) — resolve against cwd 112 + if (cmd.includes("/")) { 113 + const resolved = path.resolve(cmd); 114 + if (!fs.existsSync(resolved)) { 115 + throw new Error(`Command not found: ${cmd}`); 116 + } 117 + return resolved; 118 + } 119 + 120 + // Bare command name — look up in PATH 121 + try { 122 + return execFileSync("which", [cmd], { encoding: "utf8" }).trim(); 123 + } catch { 124 + throw new Error(`Command not found: ${cmd}`); 125 + } 126 + }
+99
src/tui/input.ts
··· 1 + // Raw stdin keypress parsing 2 + 3 + export interface KeyEvent { 4 + name: string; 5 + char?: string; 6 + ctrl: boolean; 7 + alt: boolean; 8 + } 9 + 10 + export function parseKey(data: Buffer): KeyEvent[] { 11 + const events: KeyEvent[] = []; 12 + const str = data.toString("utf8"); 13 + let i = 0; 14 + 15 + while (i < str.length) { 16 + // ESC sequences 17 + if (str[i] === "\x1b") { 18 + // ESC [ ... sequences (CSI) 19 + if (i + 1 < str.length && str[i + 1] === "[") { 20 + const rest = str.slice(i + 2); 21 + 22 + // Arrow keys, home, end 23 + if (rest[0] === "A") { events.push({ name: "up", ctrl: false, alt: false }); i += 3; continue; } 24 + if (rest[0] === "B") { events.push({ name: "down", ctrl: false, alt: false }); i += 3; continue; } 25 + if (rest[0] === "C") { events.push({ name: "right", ctrl: false, alt: false }); i += 3; continue; } 26 + if (rest[0] === "D") { events.push({ name: "left", ctrl: false, alt: false }); i += 3; continue; } 27 + if (rest[0] === "H") { events.push({ name: "home", ctrl: false, alt: false }); i += 3; continue; } 28 + if (rest[0] === "F") { events.push({ name: "end", ctrl: false, alt: false }); i += 3; continue; } 29 + 30 + // Delete: ESC[3~ 31 + if (rest.startsWith("3~")) { events.push({ name: "delete", ctrl: false, alt: false }); i += 4; continue; } 32 + // Page Up: ESC[5~ 33 + if (rest.startsWith("5~")) { events.push({ name: "pageup", ctrl: false, alt: false }); i += 4; continue; } 34 + // Page Down: ESC[6~ 35 + if (rest.startsWith("6~")) { events.push({ name: "pagedown", ctrl: false, alt: false }); i += 4; continue; } 36 + 37 + // Kitty keyboard protocol: ESC[<code>;<modifiers>u 38 + const kittyMatch = rest.match(/^(\d+);(\d+)u/); 39 + if (kittyMatch) { 40 + const codepoint = parseInt(kittyMatch[1], 10); 41 + const mods = parseInt(kittyMatch[2], 10) - 1; 42 + const ctrl = (mods & 0x04) !== 0; 43 + const alt = (mods & 0x02) !== 0; 44 + const ch = String.fromCodePoint(codepoint); 45 + events.push({ name: ch, char: ch, ctrl, alt }); 46 + i += 2 + kittyMatch[0].length; 47 + continue; 48 + } 49 + 50 + // Unknown CSI sequence — skip to end 51 + let j = 0; 52 + while (j < rest.length && !(rest[j] >= "@" && rest[j] <= "~")) j++; 53 + i += 2 + j + 1; 54 + continue; 55 + } 56 + 57 + // Alt+<char>: ESC followed by printable character 58 + if (i + 1 < str.length && str[i + 1] >= " ") { 59 + const ch = str[i + 1]; 60 + events.push({ name: ch, char: ch, ctrl: false, alt: true }); 61 + i += 2; 62 + continue; 63 + } 64 + 65 + // Bare ESC 66 + events.push({ name: "escape", ctrl: false, alt: false }); 67 + i++; 68 + continue; 69 + } 70 + 71 + // Control characters 72 + const code = str.charCodeAt(i); 73 + 74 + if (code === 0x0d) { events.push({ name: "return", ctrl: false, alt: false }); i++; continue; } 75 + if (code === 0x09) { events.push({ name: "tab", ctrl: false, alt: false }); i++; continue; } 76 + if (code === 0x7f) { events.push({ name: "backspace", ctrl: false, alt: false }); i++; continue; } 77 + if (code === 0x1c) { events.push({ name: "\\", ctrl: true, alt: false }); i++; continue; } 78 + 79 + // Ctrl+A through Ctrl+Z (0x01–0x1a) 80 + if (code >= 0x01 && code <= 0x1a) { 81 + const letter = String.fromCharCode(code + 0x60); // a-z 82 + events.push({ name: letter, ctrl: true, alt: false }); 83 + i++; 84 + continue; 85 + } 86 + 87 + // Regular printable character 88 + if (code >= 0x20) { 89 + events.push({ name: str[i], char: str[i], ctrl: false, alt: false }); 90 + i++; 91 + continue; 92 + } 93 + 94 + // Unknown — skip 95 + i++; 96 + } 97 + 98 + return events; 99 + }
+265
src/tui/interactive.ts
··· 1 + import * as tty from "node:tty"; 2 + import { attach } from "../client.ts"; 3 + import { listSessions, validateName, acquireLock, releaseLock, cleanupAll, getSession } from "../sessions.ts"; 4 + import { spawnDaemon, resolveCommand } from "../spawn.ts"; 5 + import { parseKey } from "./input.ts"; 6 + import { 7 + enterAltScreen, 8 + leaveAltScreen, 9 + clearScreen, 10 + hideCursor, 11 + showCursor, 12 + } from "./render.ts"; 13 + import { 14 + createListState, 15 + handleListKey, 16 + renderList, 17 + updateSessions, 18 + type ListState, 19 + } from "./screen-list.ts"; 20 + import { 21 + createCreateState, 22 + handleCreateKey, 23 + renderCreate, 24 + type CreateState, 25 + } from "./screen-create.ts"; 26 + 27 + type Screen = "list" | "create"; 28 + 29 + const stdout = process.stdout as tty.WriteStream; 30 + const stdin = process.stdin; 31 + 32 + export async function runInteractive(): Promise<void> { 33 + let currentScreen: Screen = "list"; 34 + let listState: ListState; 35 + let createState: CreateState | null = null; 36 + 37 + // Load sessions 38 + const sessions = await listSessions(); 39 + const width = stdout.columns ?? 80; 40 + const height = stdout.rows ?? 24; 41 + listState = createListState(sessions, width, height); 42 + 43 + // Enter TUI mode 44 + stdout.write(enterAltScreen() + hideCursor() + clearScreen()); 45 + 46 + if (stdin.isTTY) { 47 + stdin.setRawMode(true); 48 + } 49 + stdin.resume(); 50 + 51 + // Render current screen 52 + function render(): void { 53 + stdout.write(clearScreen()); 54 + if (currentScreen === "list") { 55 + stdout.write(renderList(listState)); 56 + } else if (currentScreen === "create" && createState) { 57 + stdout.write(renderCreate(createState)); 58 + } 59 + } 60 + 61 + // Handle resize 62 + function onResize(): void { 63 + const w = stdout.columns ?? 80; 64 + const h = stdout.rows ?? 24; 65 + listState.termWidth = w; 66 + listState.termHeight = h; 67 + if (createState) { 68 + createState.termWidth = w; 69 + createState.termHeight = h; 70 + } 71 + render(); 72 + } 73 + stdout.on("resize", onResize); 74 + 75 + // Exit TUI mode 76 + function exitTui(): void { 77 + stdout.removeListener("resize", onResize); 78 + stdin.removeAllListeners("data"); 79 + if (stdin.isTTY && stdin.isRaw) { 80 + stdin.setRawMode(false); 81 + } 82 + stdout.write(showCursor() + leaveAltScreen()); 83 + } 84 + 85 + // Pause TUI for attach (leave alt screen, raw mode off, hand off stdin) 86 + function pauseTui(): void { 87 + stdout.removeListener("resize", onResize); 88 + stdin.removeAllListeners("data"); 89 + if (stdin.isTTY && stdin.isRaw) { 90 + stdin.setRawMode(false); 91 + } 92 + stdin.pause(); 93 + stdout.write(showCursor() + leaveAltScreen()); 94 + } 95 + 96 + // Resume TUI after attach returns 97 + async function resumeTui(): Promise<void> { 98 + // Reload sessions 99 + const sessions = await listSessions(); 100 + const w = stdout.columns ?? 80; 101 + const h = stdout.rows ?? 24; 102 + listState.termWidth = w; 103 + listState.termHeight = h; 104 + updateSessions(listState, sessions); 105 + currentScreen = "list"; 106 + createState = null; 107 + 108 + stdout.write(enterAltScreen() + hideCursor() + clearScreen()); 109 + if (stdin.isTTY) { 110 + stdin.setRawMode(true); 111 + } 112 + stdin.resume(); 113 + stdout.on("resize", onResize); 114 + setupInput(); 115 + render(); 116 + } 117 + 118 + // Attach to a session and return when detach/exit happens 119 + function doAttach(name: string): void { 120 + pauseTui(); 121 + 122 + attach({ 123 + name, 124 + onDetach: async () => { 125 + await resumeTui(); 126 + }, 127 + onExit: async (_code) => { 128 + await resumeTui(); 129 + }, 130 + }); 131 + } 132 + 133 + // Create a new session and attach 134 + async function doCreate(dir: string, name: string, command: string): Promise<void> { 135 + pauseTui(); 136 + 137 + try { 138 + validateName(name); 139 + } catch (e: any) { 140 + console.error(e.message); 141 + await resumeTui(); 142 + return; 143 + } 144 + 145 + // Check if session already exists 146 + let existing; 147 + try { 148 + existing = await getSession(name); 149 + } catch { 150 + // Corrupted metadata — proceed as if no session exists 151 + existing = null; 152 + } 153 + 154 + if (existing?.status === "running") { 155 + // Attach to existing 156 + attach({ 157 + name, 158 + onDetach: async () => { await resumeTui(); }, 159 + onExit: async () => { await resumeTui(); }, 160 + }); 161 + return; 162 + } 163 + 164 + if (!acquireLock(name)) { 165 + console.error(`Session "${name}" is being created by another process.`); 166 + await resumeTui(); 167 + return; 168 + } 169 + 170 + // Clean up dead session with same name 171 + if (existing?.status === "exited") { 172 + cleanupAll(name); 173 + } 174 + 175 + // Parse command into cmd + args 176 + const parts = command.split(/\s+/); 177 + const cmd = parts[0]; 178 + const args = parts.slice(1); 179 + 180 + let resolvedCmd: string; 181 + try { 182 + resolvedCmd = resolveCommand(cmd); 183 + } catch (e: any) { 184 + releaseLock(name); 185 + console.error(e.message); 186 + await resumeTui(); 187 + return; 188 + } 189 + 190 + try { 191 + await spawnDaemon(name, resolvedCmd, args, cmd, dir); 192 + } catch (e: any) { 193 + releaseLock(name); 194 + console.error(e.message); 195 + await resumeTui(); 196 + return; 197 + } finally { 198 + releaseLock(name); 199 + } 200 + 201 + attach({ 202 + name, 203 + onDetach: async () => { await resumeTui(); }, 204 + onExit: async () => { await resumeTui(); }, 205 + }); 206 + } 207 + 208 + function setupInput(): void { 209 + stdin.on("data", (data: Buffer) => { 210 + const keys = parseKey(data); 211 + for (const key of keys) { 212 + if (currentScreen === "list") { 213 + const action = handleListKey(listState, key); 214 + switch (action.type) { 215 + case "attach": 216 + if (action.session) { 217 + doAttach(action.session.name); 218 + return; 219 + } 220 + break; 221 + case "create": { 222 + currentScreen = "create"; 223 + const names = listState.sessions.map((s) => s.name); 224 + createState = createCreateState( 225 + listState.termWidth, 226 + listState.termHeight, 227 + names 228 + ); 229 + render(); 230 + return; 231 + } 232 + case "quit": 233 + exitTui(); 234 + process.exit(0); 235 + return; 236 + case "none": 237 + render(); 238 + break; 239 + } 240 + } else if (currentScreen === "create" && createState) { 241 + const action = handleCreateKey(createState, key); 242 + switch (action.type) { 243 + case "create": 244 + if (action.dir && action.name && action.command) { 245 + doCreate(action.dir, action.name, action.command); 246 + return; 247 + } 248 + break; 249 + case "cancel": 250 + currentScreen = "list"; 251 + createState = null; 252 + render(); 253 + return; 254 + case "none": 255 + render(); 256 + break; 257 + } 258 + } 259 + } 260 + }); 261 + } 262 + 263 + setupInput(); 264 + render(); 265 + }
+128
src/tui/render.ts
··· 1 + // ANSI rendering primitives for the TUI 2 + 3 + export const ESC = "\x1b"; 4 + 5 + // Strip ANSI escape sequences to get visible text 6 + const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g; 7 + 8 + export function stripAnsi(text: string): string { 9 + return text.replace(ANSI_RE, ""); 10 + } 11 + 12 + export function visibleLength(text: string): number { 13 + return stripAnsi(text).length; 14 + } 15 + 16 + // Screen management 17 + export function enterAltScreen(): string { 18 + return `${ESC}[?1049h`; 19 + } 20 + export function leaveAltScreen(): string { 21 + return `${ESC}[?1049l`; 22 + } 23 + export function clearScreen(): string { 24 + return `${ESC}[2J${ESC}[H`; 25 + } 26 + export function hideCursor(): string { 27 + return `${ESC}[?25l`; 28 + } 29 + export function showCursor(): string { 30 + return `${ESC}[?25h`; 31 + } 32 + export function moveTo(row: number, col: number): string { 33 + return `${ESC}[${row};${col}H`; 34 + } 35 + 36 + // Text styles 37 + export function bold(text: string): string { 38 + return `${ESC}[1m${text}${ESC}[22m`; 39 + } 40 + export function dim(text: string): string { 41 + return `${ESC}[2m${text}${ESC}[22m`; 42 + } 43 + export function inverse(text: string): string { 44 + return `${ESC}[7m${text}${ESC}[27m`; 45 + } 46 + export function green(text: string): string { 47 + return `${ESC}[32m${text}${ESC}[39m`; 48 + } 49 + export function red(text: string): string { 50 + return `${ESC}[31m${text}${ESC}[39m`; 51 + } 52 + export function yellow(text: string): string { 53 + return `${ESC}[33m${text}${ESC}[39m`; 54 + } 55 + export function cyan(text: string): string { 56 + return `${ESC}[36m${text}${ESC}[39m`; 57 + } 58 + 59 + // Text utilities — operate on plain text (no ANSI codes) 60 + export function truncate(text: string, maxWidth: number): string { 61 + if (text.length <= maxWidth) return text; 62 + if (maxWidth <= 1) return text.slice(0, maxWidth); 63 + return text.slice(0, maxWidth - 1) + "\u2026"; 64 + } 65 + 66 + export function pad(text: string, width: number, align: "left" | "right" = "left"): string { 67 + if (text.length >= width) return text.slice(0, width); 68 + const padding = " ".repeat(width - text.length); 69 + return align === "right" ? padding + text : text + padding; 70 + } 71 + 72 + // Box drawing (Unicode) 73 + // Rounded corners (╭╮╰╯) like mactop 74 + const BOX = { 75 + topLeft: "\u256d", 76 + topRight: "\u256e", 77 + bottomLeft: "\u2570", 78 + bottomRight: "\u256f", 79 + horizontal: "\u2500", 80 + vertical: "\u2502", 81 + }; 82 + 83 + export function drawBox( 84 + row: number, 85 + col: number, 86 + width: number, 87 + height: number, 88 + title?: string 89 + ): string { 90 + let out = ""; 91 + 92 + // Top border 93 + let topLine = BOX.horizontal.repeat(width - 2); 94 + if (title) { 95 + const visLen = visibleLength(title); 96 + const titleCells = visLen + 2; // space on each side 97 + if (titleCells < width - 2) { 98 + const remaining = width - 2 - titleCells - 1; // -1 for leading ─ 99 + topLine = BOX.horizontal + ` ${title} ` + BOX.horizontal.repeat(remaining); 100 + } 101 + } 102 + out += moveTo(row, col) + BOX.topLeft + topLine + BOX.topRight; 103 + 104 + // Side borders 105 + for (let r = 1; r < height - 1; r++) { 106 + out += moveTo(row + r, col) + BOX.vertical; 107 + out += moveTo(row + r, col + width - 1) + BOX.vertical; 108 + } 109 + 110 + // Bottom border 111 + out += moveTo(row + height - 1, col) + BOX.bottomLeft + BOX.horizontal.repeat(width - 2) + BOX.bottomRight; 112 + 113 + return out; 114 + } 115 + 116 + // Layout helpers 117 + export function renderHeader(width: number, title: string): string { 118 + return drawBox(1, 1, width, 3, title); 119 + } 120 + 121 + export function renderFooter(width: number, bindings: string[]): string { 122 + const text = bindings.join(" "); 123 + return moveTo(width, 1) + dim(` ${text}`); 124 + } 125 + 126 + export function clearLine(row: number, col: number, width: number): string { 127 + return moveTo(row, col) + " ".repeat(width); 128 + }
+396
src/tui/screen-create.ts
··· 1 + import * as fs from "node:fs"; 2 + import * as os from "node:os"; 3 + import * as path from "node:path"; 4 + import type { KeyEvent } from "./input.ts"; 5 + import { 6 + moveTo, 7 + drawBox, 8 + bold, 9 + dim, 10 + inverse, 11 + truncate, 12 + pad, 13 + } from "./render.ts"; 14 + 15 + export interface CreateAction { 16 + type: "create" | "cancel" | "none"; 17 + dir?: string; 18 + name?: string; 19 + command?: string; 20 + } 21 + 22 + export type CreateStep = "dir-initial" | "dir-browse" | "name-command"; 23 + 24 + export interface CreateState { 25 + step: CreateStep; 26 + // Dir picker 27 + selectedIndex: number; 28 + cwdPath: string; 29 + browsePath: string; 30 + browseFilter: string; 31 + // Name + command 32 + name: string; 33 + command: string; 34 + focusedField: "name" | "command"; 35 + // Layout 36 + termWidth: number; 37 + termHeight: number; 38 + // For dedup 39 + existingNames: Set<string>; 40 + } 41 + 42 + export function createCreateState( 43 + termWidth: number, 44 + termHeight: number, 45 + existingNames: string[] 46 + ): CreateState { 47 + const cwdPath = process.cwd(); 48 + return { 49 + step: "dir-initial", 50 + selectedIndex: 0, 51 + cwdPath, 52 + browsePath: cwdPath, 53 + browseFilter: "", 54 + name: path.basename(cwdPath), 55 + command: "", 56 + focusedField: "command", 57 + termWidth, 58 + termHeight, 59 + existingNames: new Set(existingNames), 60 + }; 61 + } 62 + 63 + function shortPath(p: string): string { 64 + const home = os.homedir(); 65 + if (p === home) return "~"; 66 + if (p.startsWith(home + "/")) return "~" + p.slice(home.length); 67 + return p; 68 + } 69 + 70 + function dedupName(base: string, existing: Set<string>): string { 71 + if (!existing.has(base)) return base; 72 + for (let i = 2; ; i++) { 73 + const candidate = `${base}-${i}`; 74 + if (!existing.has(candidate)) return candidate; 75 + } 76 + } 77 + 78 + function listDirs(dirPath: string, filter: string): string[] { 79 + let entries: fs.Dirent[]; 80 + try { 81 + entries = fs.readdirSync(dirPath, { withFileTypes: true }); 82 + } catch { 83 + return []; 84 + } 85 + const dirs = entries 86 + .filter((e) => { 87 + if (e.name.startsWith(".")) return false; 88 + if (e.isDirectory()) return true; 89 + // Follow symlinks — include if target is a directory 90 + if (e.isSymbolicLink()) { 91 + try { 92 + const target = fs.statSync(path.join(dirPath, e.name)); 93 + return target.isDirectory(); 94 + } catch { 95 + return false; // broken symlink 96 + } 97 + } 98 + return false; 99 + }) 100 + .map((e) => e.name) 101 + .sort(); 102 + if (!filter) return dirs; 103 + const lf = filter.toLowerCase(); 104 + return dirs.filter((d) => d.toLowerCase().includes(lf)); 105 + } 106 + 107 + export function handleCreateKey(state: CreateState, key: KeyEvent): CreateAction { 108 + switch (state.step) { 109 + case "dir-initial": 110 + return handleDirInitial(state, key); 111 + case "dir-browse": 112 + return handleDirBrowse(state, key); 113 + case "name-command": 114 + return handleNameCommand(state, key); 115 + } 116 + } 117 + 118 + function handleDirInitial(state: CreateState, key: KeyEvent): CreateAction { 119 + // Two items: 0 = cwd, 1 = "Choose disk location..." 120 + if (key.name === "up") { 121 + state.selectedIndex = 0; 122 + return { type: "none" }; 123 + } 124 + if (key.name === "down") { 125 + state.selectedIndex = 1; 126 + return { type: "none" }; 127 + } 128 + if (key.name === "return") { 129 + if (state.selectedIndex === 0) { 130 + // Use cwd 131 + state.step = "name-command"; 132 + state.name = dedupName(path.basename(state.cwdPath), state.existingNames); 133 + return { type: "none" }; 134 + } else { 135 + // Browse 136 + state.step = "dir-browse"; 137 + state.browsePath = state.cwdPath; 138 + state.selectedIndex = 0; 139 + state.browseFilter = ""; 140 + return { type: "none" }; 141 + } 142 + } 143 + if (key.name === "escape" || (key.name === "c" && key.ctrl)) { 144 + return { type: "cancel" }; 145 + } 146 + return { type: "none" }; 147 + } 148 + 149 + function handleDirBrowse(state: CreateState, key: KeyEvent): CreateAction { 150 + const dirs = listDirs(state.browsePath, state.browseFilter); 151 + // Items: [Select this directory], .., ...dirs 152 + const totalItems = 2 + dirs.length; 153 + 154 + if (key.name === "up") { 155 + state.selectedIndex = Math.max(0, state.selectedIndex - 1); 156 + return { type: "none" }; 157 + } 158 + if (key.name === "down") { 159 + state.selectedIndex = Math.min(totalItems - 1, state.selectedIndex + 1); 160 + return { type: "none" }; 161 + } 162 + if (key.name === "return") { 163 + if (state.selectedIndex === 0) { 164 + // Select this directory 165 + state.step = "name-command"; 166 + state.name = dedupName(path.basename(state.browsePath), state.existingNames); 167 + return { type: "none" }; 168 + } 169 + if (state.selectedIndex === 1) { 170 + // .. 171 + const parent = path.dirname(state.browsePath); 172 + if (parent !== state.browsePath) { 173 + state.browsePath = parent; 174 + state.selectedIndex = 0; 175 + state.browseFilter = ""; 176 + } 177 + return { type: "none" }; 178 + } 179 + // Navigate into a directory 180 + const dirName = dirs[state.selectedIndex - 2]; 181 + if (dirName) { 182 + state.browsePath = path.join(state.browsePath, dirName); 183 + state.selectedIndex = 0; 184 + state.browseFilter = ""; 185 + } 186 + return { type: "none" }; 187 + } 188 + if (key.name === "escape") { 189 + if (state.browseFilter) { 190 + state.browseFilter = ""; 191 + state.selectedIndex = 0; 192 + return { type: "none" }; 193 + } 194 + state.step = "dir-initial"; 195 + state.selectedIndex = 0; 196 + return { type: "none" }; 197 + } 198 + if (key.name === "c" && key.ctrl) { 199 + return { type: "cancel" }; 200 + } 201 + if (key.name === "backspace") { 202 + if (state.browseFilter.length > 0) { 203 + state.browseFilter = state.browseFilter.slice(0, -1); 204 + state.selectedIndex = Math.min(state.selectedIndex, 1 + listDirs(state.browsePath, state.browseFilter).length); 205 + } 206 + return { type: "none" }; 207 + } 208 + // Typing — filter directories 209 + if (key.char && !key.ctrl && !key.alt) { 210 + state.browseFilter += key.char; 211 + state.selectedIndex = 2; // Jump to first directory match 212 + return { type: "none" }; 213 + } 214 + return { type: "none" }; 215 + } 216 + 217 + function handleNameCommand(state: CreateState, key: KeyEvent): CreateAction { 218 + if (key.name === "tab") { 219 + state.focusedField = state.focusedField === "name" ? "command" : "name"; 220 + return { type: "none" }; 221 + } 222 + if (key.name === "return") { 223 + if (state.name.trim() && state.command.trim()) { 224 + const dir = state.step === "name-command" 225 + ? (state.browsePath !== state.cwdPath ? state.browsePath : state.cwdPath) 226 + : state.cwdPath; 227 + return { 228 + type: "create", 229 + dir, 230 + name: state.name.trim(), 231 + command: state.command.trim(), 232 + }; 233 + } 234 + return { type: "none" }; 235 + } 236 + if (key.name === "escape") { 237 + state.step = "dir-initial"; 238 + state.selectedIndex = 0; 239 + return { type: "none" }; 240 + } 241 + if (key.name === "c" && key.ctrl) { 242 + return { type: "cancel" }; 243 + } 244 + 245 + // Text editing for focused field 246 + const field = state.focusedField; 247 + if (key.name === "backspace") { 248 + if (field === "name") { 249 + state.name = state.name.slice(0, -1); 250 + } else { 251 + state.command = state.command.slice(0, -1); 252 + } 253 + return { type: "none" }; 254 + } 255 + if (key.char && !key.ctrl && !key.alt) { 256 + if (field === "name") { 257 + state.name += key.char; 258 + } else { 259 + state.command += key.char; 260 + } 261 + return { type: "none" }; 262 + } 263 + return { type: "none" }; 264 + } 265 + 266 + export function renderCreate(state: CreateState): string { 267 + switch (state.step) { 268 + case "dir-initial": 269 + return renderDirInitial(state); 270 + case "dir-browse": 271 + return renderDirBrowse(state); 272 + case "name-command": 273 + return renderNameCommand(state); 274 + } 275 + } 276 + 277 + function renderDirInitial(state: CreateState): string { 278 + const { termWidth, termHeight } = state; 279 + const boxWidth = Math.min(Math.max(40, termWidth - 4), termWidth - 2); 280 + const boxCol = Math.max(1, Math.floor((termWidth - boxWidth) / 2) + 1); 281 + const boxHeight = 8; 282 + const boxRow = Math.max(1, Math.floor((termHeight - boxHeight - 1) / 2) + 1); 283 + const contentWidth = boxWidth - 4; 284 + 285 + let out = drawBox(boxRow, boxCol, boxWidth, boxHeight, bold("New Session \u2014 Choose Directory")); 286 + 287 + let row = boxRow + 2; 288 + // Build items as plain text first, then style 289 + const cwdText = shortPath(state.cwdPath); 290 + const plainItems = [ 291 + ` ${cwdText} (current directory)`, 292 + ` Choose disk location\u2026`, 293 + ]; 294 + const styledItems = [ 295 + ` ${cwdText} ${dim("(current directory)")}`, 296 + ` Choose disk location\u2026`, 297 + ]; 298 + for (let i = 0; i < plainItems.length; i++) { 299 + const plain = pad(truncate(plainItems[i], contentWidth), contentWidth); 300 + // Rebuild styled version with correct padding 301 + const styled = styledItems[i]; 302 + const padAmount = contentWidth - plainItems[i].length; 303 + const paddedStyled = padAmount > 0 ? styled + " ".repeat(padAmount) : styled; 304 + out += moveTo(row, boxCol + 2) + (i === state.selectedIndex ? inverse(paddedStyled) : paddedStyled); 305 + row++; 306 + } 307 + 308 + const footerRow = boxRow + boxHeight; 309 + out += moveTo(footerRow, boxCol + 1) + dim("\u2191\u2193 select \u23ce confirm \u238b back"); 310 + 311 + return out; 312 + } 313 + 314 + function renderDirBrowse(state: CreateState): string { 315 + const { termWidth, termHeight } = state; 316 + const boxWidth = Math.min(Math.max(40, termWidth - 4), termWidth - 2); 317 + const boxCol = Math.max(1, Math.floor((termWidth - boxWidth) / 2) + 1); 318 + const contentWidth = boxWidth - 4; 319 + 320 + const dirs = listDirs(state.browsePath, state.browseFilter); 321 + const maxItems = Math.min(dirs.length + 2, termHeight - 6); 322 + const boxHeight = Math.max(8, maxItems + 5); 323 + const boxRow = Math.max(1, Math.floor((termHeight - boxHeight - 1) / 2) + 1); 324 + 325 + let out = drawBox(boxRow, boxCol, boxWidth, boxHeight, bold("Browse \u2014 " + shortPath(state.browsePath))); 326 + 327 + let row = boxRow + 1; 328 + 329 + // Filter line 330 + if (state.browseFilter) { 331 + row++; 332 + out += moveTo(row, boxCol + 2) + `Filter: ${state.browseFilter}`; 333 + } 334 + 335 + row++; 336 + 337 + // Fixed items — build plain first, style after 338 + const plainFixed = [ 339 + " [Select this directory]", 340 + " ..", 341 + ]; 342 + const styledFixed = [ 343 + ` ${dim("[Select this directory]")}`, 344 + ` ${dim("..")}`, 345 + ]; 346 + for (let i = 0; i < plainFixed.length; i++) { 347 + row++; 348 + const padAmount = Math.max(0, contentWidth - plainFixed[i].length); 349 + const line = styledFixed[i] + " ".repeat(padAmount); 350 + out += moveTo(row, boxCol + 2) + (i === state.selectedIndex ? inverse(line) : line); 351 + } 352 + 353 + // Directory entries 354 + const visibleDirs = dirs.slice(0, boxHeight - 7); 355 + for (let i = 0; i < visibleDirs.length; i++) { 356 + row++; 357 + const actualIndex = i + 2; 358 + const line = pad(` ${visibleDirs[i]}/`, contentWidth); 359 + out += moveTo(row, boxCol + 2) + (actualIndex === state.selectedIndex ? inverse(line) : line); 360 + } 361 + 362 + const footerRow = boxRow + boxHeight; 363 + out += moveTo(footerRow, boxCol + 1) + dim("\u2191\u2193 select \u23ce enter \u238b back type to filter"); 364 + 365 + return out; 366 + } 367 + 368 + function renderNameCommand(state: CreateState): string { 369 + const { termWidth, termHeight } = state; 370 + const boxWidth = Math.min(Math.max(40, termWidth - 4), termWidth - 2); 371 + const boxCol = Math.max(1, Math.floor((termWidth - boxWidth) / 2) + 1); 372 + const boxHeight = 10; 373 + const boxRow = Math.max(1, Math.floor((termHeight - boxHeight - 1) / 2) + 1); 374 + 375 + const dir = state.browsePath !== state.cwdPath ? state.browsePath : state.cwdPath; 376 + 377 + let out = drawBox(boxRow, boxCol, boxWidth, boxHeight, bold("New Session")); 378 + 379 + let row = boxRow + 2; 380 + out += moveTo(row, boxCol + 2) + `Directory: ${dim(shortPath(dir))}`; 381 + 382 + row += 2; 383 + const nameLabel = "Name: "; 384 + const nameValue = state.name + (state.focusedField === "name" ? "\u2588" : ""); 385 + out += moveTo(row, boxCol + 2) + (state.focusedField === "name" ? bold(nameLabel) : nameLabel) + nameValue; 386 + 387 + row++; 388 + const cmdLabel = "Command: "; 389 + const cmdValue = state.command + (state.focusedField === "command" ? "\u2588" : ""); 390 + out += moveTo(row, boxCol + 2) + (state.focusedField === "command" ? bold(cmdLabel) : cmdLabel) + cmdValue; 391 + 392 + const footerRow = boxRow + boxHeight; 393 + out += moveTo(footerRow, boxCol + 1) + dim("\u21e5 switch field \u23ce create \u238b back"); 394 + 395 + return out; 396 + }
+282
src/tui/screen-list.ts
··· 1 + import * as os from "node:os"; 2 + import type { SessionInfo } from "../sessions.ts"; 3 + import type { KeyEvent } from "./input.ts"; 4 + import { 5 + moveTo, 6 + drawBox, 7 + bold, 8 + dim, 9 + green, 10 + red, 11 + inverse, 12 + truncate, 13 + pad, 14 + } from "./render.ts"; 15 + 16 + export interface ListAction { 17 + type: "attach" | "create" | "quit" | "none"; 18 + session?: SessionInfo; 19 + } 20 + 21 + export interface ListState { 22 + sessions: SessionInfo[]; 23 + filterText: string; 24 + selectedIndex: number; 25 + termWidth: number; 26 + termHeight: number; 27 + } 28 + 29 + export function createListState( 30 + sessions: SessionInfo[], 31 + termWidth: number, 32 + termHeight: number 33 + ): ListState { 34 + return { 35 + sessions: sortSessions(sessions), 36 + filterText: "", 37 + selectedIndex: 0, 38 + termWidth, 39 + termHeight, 40 + }; 41 + } 42 + 43 + export function updateSessions(state: ListState, sessions: SessionInfo[]): void { 44 + state.sessions = sortSessions(sessions); 45 + // Clamp selection 46 + const itemCount = filteredItems(state).length; 47 + if (state.selectedIndex >= itemCount) { 48 + state.selectedIndex = Math.max(0, itemCount - 1); 49 + } 50 + } 51 + 52 + function sortSessions(sessions: SessionInfo[]): SessionInfo[] { 53 + return [...sessions].sort((a, b) => { 54 + const aTime = a.metadata?.createdAt ?? ""; 55 + const bTime = b.metadata?.createdAt ?? ""; 56 + return aTime.localeCompare(bTime); 57 + }); 58 + } 59 + 60 + function shortPath(p: string): string { 61 + const home = os.homedir(); 62 + if (p === home) return "~"; 63 + if (p.startsWith(home + "/")) return "~" + p.slice(home.length); 64 + return p; 65 + } 66 + 67 + function timeAgo(date: Date): string { 68 + const seconds = Math.floor((Date.now() - date.getTime()) / 1000); 69 + if (seconds < 60) return `${seconds}s ago`; 70 + const minutes = Math.floor(seconds / 60); 71 + if (minutes < 60) return `${minutes}m ago`; 72 + const hours = Math.floor(minutes / 60); 73 + if (hours < 24) return `${hours}h ago`; 74 + const days = Math.floor(hours / 24); 75 + return `${days}d ago`; 76 + } 77 + 78 + interface ListItem { 79 + type: "session" | "create"; 80 + session?: SessionInfo; 81 + label: string; 82 + } 83 + 84 + function filteredItems(state: ListState): ListItem[] { 85 + const items: ListItem[] = []; 86 + const filter = state.filterText.toLowerCase(); 87 + 88 + for (const s of state.sessions) { 89 + const cmd = s.metadata 90 + ? [s.metadata.displayCommand, ...s.metadata.args].join(" ") 91 + : ""; 92 + const cwd = s.metadata?.cwd ?? ""; 93 + const searchable = `${s.name} ${cwd} ${cmd}`.toLowerCase(); 94 + if (filter && !searchable.includes(filter)) continue; 95 + items.push({ type: "session", session: s, label: s.name }); 96 + } 97 + 98 + // Always show "Create new session..." 99 + items.push({ type: "create", label: "Create new session..." }); 100 + 101 + return items; 102 + } 103 + 104 + export function handleListKey(state: ListState, key: KeyEvent): ListAction { 105 + const items = filteredItems(state); 106 + const maxIndex = items.length - 1; 107 + 108 + // Navigation 109 + if (key.name === "up") { 110 + state.selectedIndex = Math.max(0, state.selectedIndex - 1); 111 + return { type: "none" }; 112 + } 113 + if (key.name === "down") { 114 + state.selectedIndex = Math.min(maxIndex, state.selectedIndex + 1); 115 + return { type: "none" }; 116 + } 117 + 118 + // Select 119 + if (key.name === "return") { 120 + const item = items[state.selectedIndex]; 121 + if (!item) return { type: "none" }; 122 + if (item.type === "create") return { type: "create" }; 123 + return { type: "attach", session: item.session }; 124 + } 125 + 126 + // Quit 127 + if (key.name === "q" && !key.ctrl && !key.alt && !state.filterText) { 128 + return { type: "quit" }; 129 + } 130 + if (key.name === "c" && key.ctrl) { 131 + return { type: "quit" }; 132 + } 133 + 134 + // Escape: clear filter or quit 135 + if (key.name === "escape") { 136 + if (state.filterText) { 137 + state.filterText = ""; 138 + state.selectedIndex = 0; 139 + return { type: "none" }; 140 + } 141 + return { type: "quit" }; 142 + } 143 + 144 + // Backspace 145 + if (key.name === "backspace") { 146 + if (state.filterText.length > 0) { 147 + state.filterText = state.filterText.slice(0, -1); 148 + state.selectedIndex = 0; 149 + } 150 + return { type: "none" }; 151 + } 152 + 153 + // Typing — filter 154 + if (key.char && !key.ctrl && !key.alt) { 155 + state.filterText += key.char; 156 + state.selectedIndex = 0; 157 + return { type: "none" }; 158 + } 159 + 160 + return { type: "none" }; 161 + } 162 + 163 + export function renderList(state: ListState): string { 164 + const { termWidth, termHeight } = state; 165 + // Use full terminal width with 1-column margin each side 166 + const boxWidth = Math.max(40, termWidth - 2); 167 + const boxCol = Math.max(1, Math.floor((termWidth - boxWidth) / 2) + 1); 168 + const items = filteredItems(state); 169 + // contentWidth = space between the box's left and right borders, minus 1 padding each side 170 + const contentWidth = boxWidth - 4; 171 + 172 + // Box height: filter line + blank + items + blank + borders 173 + const minBoxHeight = Math.min(items.length + 5, termHeight - 2); 174 + const boxHeight = Math.max(7, minBoxHeight); 175 + const boxRow = Math.max(1, Math.floor((termHeight - boxHeight - 1) / 2) + 1); 176 + 177 + let out = ""; 178 + 179 + // Draw box 180 + out += drawBox(boxRow, boxCol, boxWidth, boxHeight, bold("pty")); 181 + 182 + let row = boxRow + 1; 183 + 184 + // Filter line 185 + row++; 186 + const filterLabel = "Filter: "; 187 + const filterDisplay = state.filterText || dim("(type to filter)"); 188 + out += moveTo(row, boxCol + 2) + filterLabel + filterDisplay; 189 + 190 + row++; 191 + 192 + // Compute dynamic column widths for session rows 193 + // Layout: " ● name path command" 194 + // Fixed: 2 (indent) + 2 (icon + space) + 2 (gap after name) + 2 (gap after path) = 8 195 + const fixedChars = 8; 196 + const flexWidth = contentWidth - fixedChars; 197 + 198 + // Compute name column width from actual data 199 + const sessionItems = items.filter((it) => it.type === "session"); 200 + const maxNameLen = sessionItems.reduce( 201 + (max, it) => Math.max(max, it.session!.name.length), 202 + 0 203 + ); 204 + const nameWidth = Math.min(Math.max(maxNameLen, 8), Math.floor(flexWidth * 0.25)); 205 + 206 + // Remaining space split between path and command 207 + const remaining = flexWidth - nameWidth; 208 + const pathWidth = Math.max(4, Math.floor(remaining * 0.55)); 209 + const cmdWidth = Math.max(4, remaining - pathWidth); 210 + 211 + // Items 212 + const maxVisibleItems = boxHeight - 5; // borders + filter + padding 213 + let startIdx = 0; 214 + if (state.selectedIndex >= startIdx + maxVisibleItems) { 215 + startIdx = state.selectedIndex - maxVisibleItems + 1; 216 + } 217 + const visibleItems = items.slice(startIdx, startIdx + maxVisibleItems); 218 + 219 + for (let i = 0; i < visibleItems.length; i++) { 220 + row++; 221 + const item = visibleItems[i]; 222 + const actualIndex = startIdx + i; 223 + const selected = actualIndex === state.selectedIndex; 224 + 225 + let line: string; 226 + if (item.type === "create") { 227 + // Plain text, then pad to contentWidth 228 + line = pad(" + " + item.label, contentWidth); 229 + } else { 230 + const s = item.session!; 231 + const icon = s.status === "running" ? "\u25cf" : "\u25cb"; 232 + const cmd = s.metadata 233 + ? [s.metadata.displayCommand, ...s.metadata.args].join(" ") 234 + : ""; 235 + 236 + // Build plain-text columns, truncate/pad each 237 + const nameCol = pad(truncate(s.name, nameWidth), nameWidth); 238 + let pathCol: string; 239 + let cmdCol: string; 240 + 241 + if (s.status === "running") { 242 + const cwd = s.metadata?.cwd ? shortPath(s.metadata.cwd) : ""; 243 + pathCol = pad(truncate(cwd, pathWidth), pathWidth); 244 + cmdCol = truncate(cmd, cmdWidth); 245 + } else { 246 + const ago = s.metadata?.exitedAt ? timeAgo(new Date(s.metadata.exitedAt)) : ""; 247 + pathCol = pad(truncate(`(exited ${ago})`, pathWidth), pathWidth); 248 + cmdCol = truncate(cmd, cmdWidth); 249 + } 250 + 251 + // Assemble plain-text line first, then apply styles 252 + // " ● name path command" — all plain text sizes are correct 253 + const plainLine = ` ${icon} ${nameCol} ${pathCol} ${cmdCol}`; 254 + // Pad the full line to contentWidth using plain text length 255 + line = pad(plainLine, contentWidth); 256 + 257 + // Now apply ANSI styles to the assembled plain-text line 258 + // Re-build with styles — column positions are fixed, so we can reconstruct 259 + const styledIcon = s.status === "running" ? green(icon) : red(icon); 260 + const styledPath = s.status === "running" ? dim(pathCol) : dim(pathCol); 261 + line = ` ${styledIcon} ${nameCol} ${styledPath} ${cmdCol}`; 262 + // Pad with spaces to contentWidth (line's visible length = plainLine.length) 263 + const visLen = plainLine.length; 264 + if (visLen < contentWidth) { 265 + line += " ".repeat(contentWidth - visLen); 266 + } 267 + } 268 + 269 + out += moveTo(row, boxCol + 2) + (selected ? inverse(line) : line); 270 + } 271 + 272 + // Footer (below box) 273 + const footerRow = boxRow + boxHeight; 274 + const bindings = [ 275 + "\u2191\u2193 select", 276 + "\u23ce attach", 277 + "q quit", 278 + ]; 279 + out += moveTo(footerRow, boxCol + 1) + dim(bindings.join(" ")); 280 + 281 + return out; 282 + }
+4
tests/integration.test.ts
··· 28 28 29 29 // All tests run in a tmp directory to avoid polluting the project 30 30 const testCwd = fs.mkdtempSync(path.join(os.tmpdir(), "pty-int-")); 31 + // Isolate session metadata/sockets from the real session directory 32 + const testSessionDir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-int-sd-")); 33 + process.env.PTY_SESSION_DIR = testSessionDir; 31 34 afterAll(() => { 32 35 fs.rmSync(testCwd, { recursive: true, force: true }); 36 + fs.rmSync(testSessionDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 33 37 }); 34 38 35 39 let servers: PtyServer[] = [];
+4
tests/screenshot.test.ts
··· 19 19 20 20 // All tests run in a tmp directory to avoid polluting the project 21 21 const testCwd = fs.mkdtempSync(path.join(os.tmpdir(), "pty-ss-")); 22 + // Isolate session metadata/sockets from the real session directory 23 + const testSessionDir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-ss-sd-")); 24 + process.env.PTY_SESSION_DIR = testSessionDir; 22 25 afterAll(() => { 23 26 fs.rmSync(testCwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 27 + fs.rmSync(testSessionDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 24 28 }); 25 29 26 30 // ─── Types ───
+233
tests/tui-harness.ts
··· 1 + import * as fs from "node:fs"; 2 + import * as os from "node:os"; 3 + import * as path from "node:path"; 4 + import { fileURLToPath } from "node:url"; 5 + import { spawn, type ChildProcess } from "node:child_process"; 6 + import { Terminal } from "@xterm/headless"; 7 + import { SerializeAddon } from "@xterm/addon-serialize"; 8 + import * as pty from "node-pty"; 9 + import { resolveKey } from "../src/keys.ts"; 10 + 11 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 12 + 13 + export interface Screenshot { 14 + lines: string[]; 15 + text: string; 16 + ansi: string; 17 + } 18 + 19 + /** 20 + * Spawns the CLI as a pty process and provides helpers for 21 + * driving the interactive TUI in tests. 22 + */ 23 + export class TuiSession { 24 + private ptyProcess: pty.IPty; 25 + private terminal: Terminal; 26 + private serialize: SerializeAddon; 27 + rows: number; 28 + cols: number; 29 + 30 + private constructor( 31 + ptyProcess: pty.IPty, 32 + rows: number, 33 + cols: number 34 + ) { 35 + this.ptyProcess = ptyProcess; 36 + this.rows = rows; 37 + this.cols = cols; 38 + this.terminal = new Terminal({ 39 + rows, 40 + cols, 41 + scrollback: 1000, 42 + allowProposedApi: true, 43 + }); 44 + this.serialize = new SerializeAddon(); 45 + this.terminal.loadAddon(this.serialize); 46 + 47 + // Feed pty output into xterm-headless 48 + this.ptyProcess.onData((data: string) => { 49 + this.terminal.write(data); 50 + }); 51 + } 52 + 53 + /** 54 + * Create a TUI session that runs `tsx src/cli.ts` with a custom session dir. 55 + */ 56 + static create(opts: { 57 + sessionDir: string; 58 + rows?: number; 59 + cols?: number; 60 + cwd?: string; 61 + args?: string[]; 62 + }): TuiSession { 63 + const rows = opts.rows ?? 24; 64 + const cols = opts.cols ?? 80; 65 + const cwd = opts.cwd ?? process.cwd(); 66 + 67 + const tsxBin = path.join(__dirname, "..", "node_modules", ".bin", "tsx"); 68 + const cliModule = path.join(__dirname, "..", "src", "cli.ts"); 69 + const args = opts.args ?? []; 70 + 71 + const env = { 72 + ...process.env, 73 + PTY_SESSION_DIR: opts.sessionDir, 74 + TERM: "xterm-256color", 75 + }; 76 + delete (env as any).PTY_SERVER_CONFIG; 77 + 78 + const proc = pty.spawn(tsxBin, [cliModule, ...args], { 79 + name: "xterm-256color", 80 + cols, 81 + rows, 82 + cwd, 83 + env: env as Record<string, string>, 84 + }); 85 + 86 + return new TuiSession(proc, rows, cols); 87 + } 88 + 89 + screenshot(): Screenshot { 90 + const buffer = this.terminal.buffer.active; 91 + const lines: string[] = []; 92 + for (let i = 0; i < buffer.length; i++) { 93 + const line = buffer.getLine(i); 94 + if (line) { 95 + lines.push(line.translateToString(true)); 96 + } 97 + } 98 + while (lines.length > 0 && lines[lines.length - 1].trim() === "") { 99 + lines.pop(); 100 + } 101 + return { 102 + lines, 103 + text: lines.join("\n"), 104 + ansi: this.serialize.serialize(), 105 + }; 106 + } 107 + 108 + sendKeys(keys: string): void { 109 + this.ptyProcess.write(keys); 110 + } 111 + 112 + press(keyName: string): void { 113 + this.sendKeys(resolveKey(keyName)); 114 + } 115 + 116 + type(text: string): void { 117 + this.sendKeys(text); 118 + } 119 + 120 + async waitForText(text: string, timeoutMs = 5000): Promise<Screenshot> { 121 + const start = Date.now(); 122 + while (Date.now() - start < timeoutMs) { 123 + await new Promise((r) => setTimeout(r, 50)); 124 + const ss = this.screenshot(); 125 + if (ss.text.includes(text)) return ss; 126 + } 127 + const ss = this.screenshot(); 128 + throw new Error( 129 + `Timed out after ${timeoutMs}ms waiting for "${text}".\nScreen:\n${ss.text}` 130 + ); 131 + } 132 + 133 + async waitForAbsent(text: string, timeoutMs = 5000): Promise<Screenshot> { 134 + const start = Date.now(); 135 + while (Date.now() - start < timeoutMs) { 136 + await new Promise((r) => setTimeout(r, 50)); 137 + const ss = this.screenshot(); 138 + if (!ss.text.includes(text)) return ss; 139 + } 140 + const ss = this.screenshot(); 141 + throw new Error( 142 + `Timed out after ${timeoutMs}ms waiting for "${text}" to disappear.\nScreen:\n${ss.text}` 143 + ); 144 + } 145 + 146 + async waitFor( 147 + predicate: (ss: Screenshot) => boolean, 148 + timeoutMs = 5000, 149 + description = "predicate" 150 + ): Promise<Screenshot> { 151 + const start = Date.now(); 152 + while (Date.now() - start < timeoutMs) { 153 + await new Promise((r) => setTimeout(r, 50)); 154 + const ss = this.screenshot(); 155 + if (predicate(ss)) return ss; 156 + } 157 + const ss = this.screenshot(); 158 + throw new Error( 159 + `Timed out after ${timeoutMs}ms waiting for ${description}.\nScreen:\n${ss.text}` 160 + ); 161 + } 162 + 163 + close(): void { 164 + try { 165 + this.ptyProcess.kill(); 166 + } catch {} 167 + this.terminal.dispose(); 168 + } 169 + } 170 + 171 + /** 172 + * Spawn a background session as a separate daemon process with PTY_SESSION_DIR set. 173 + * Returns the child process (and its PID for cleanup). 174 + */ 175 + export async function createBackgroundSession( 176 + sessionDir: string, 177 + name: string, 178 + command: string, 179 + args: string[] = [], 180 + cwd?: string 181 + ): Promise<{ child: ChildProcess; pid: number }> { 182 + const tsxBin = path.join(__dirname, "..", "node_modules", ".bin", "tsx"); 183 + const serverModule = path.join(__dirname, "..", "src", "server.ts"); 184 + 185 + const config = JSON.stringify({ 186 + name, 187 + command, 188 + args, 189 + displayCommand: command, 190 + cwd: cwd ?? os.tmpdir(), 191 + rows: 24, 192 + cols: 80, 193 + }); 194 + 195 + const child = spawn(tsxBin, [serverModule], { 196 + detached: true, 197 + stdio: ["ignore", "ignore", "pipe"], 198 + env: { 199 + ...process.env, 200 + PTY_SERVER_CONFIG: config, 201 + PTY_SESSION_DIR: sessionDir, 202 + }, 203 + }); 204 + 205 + let stderr = ""; 206 + child.stderr?.on("data", (d: Buffer) => { 207 + stderr += d.toString(); 208 + }); 209 + 210 + let exitCode: number | null = null; 211 + child.on("exit", (code) => { 212 + exitCode = code; 213 + }); 214 + 215 + (child.stderr as any)?.unref?.(); 216 + child.unref(); 217 + 218 + // Wait for socket to appear 219 + const socketPath = path.join(sessionDir, `${name}.sock`); 220 + const start = Date.now(); 221 + while (Date.now() - start < 5000) { 222 + if (exitCode !== null) { 223 + throw new Error(`Background session daemon exited with code ${exitCode}. stderr:\n${stderr}`); 224 + } 225 + try { 226 + fs.statSync(socketPath); 227 + await new Promise((r) => setTimeout(r, 100)); 228 + return { child, pid: child.pid! }; 229 + } catch {} 230 + await new Promise((r) => setTimeout(r, 50)); 231 + } 232 + throw new Error(`Timeout waiting for background session "${name}" socket at ${socketPath}. stderr:\n${stderr}`); 233 + }
+497
tests/tui.test.ts
··· 1 + import { describe, it, expect, afterEach, afterAll } from "vitest"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 4 + import * as path from "node:path"; 5 + import { TuiSession, createBackgroundSession } from "./tui-harness.ts"; 6 + 7 + // Each test gets its own temp session dir 8 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ptui-")); 9 + afterAll(() => { 10 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 11 + }); 12 + 13 + let tuiSessions: TuiSession[] = []; 14 + let bgPids: number[] = []; 15 + let sessionDirs: string[] = []; 16 + 17 + function makeSessionDir(): string { 18 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 19 + sessionDirs.push(dir); 20 + return dir; 21 + } 22 + 23 + afterEach(async () => { 24 + for (const s of tuiSessions) { 25 + s.close(); 26 + } 27 + tuiSessions = []; 28 + for (const pid of bgPids) { 29 + try { 30 + process.kill(pid, "SIGTERM"); 31 + } catch {} 32 + } 33 + bgPids = []; 34 + // Clean up session dir contents 35 + for (const dir of sessionDirs) { 36 + try { 37 + const entries = fs.readdirSync(dir); 38 + for (const e of entries) { 39 + try { fs.unlinkSync(path.join(dir, e)); } catch {} 40 + } 41 + } catch {} 42 + } 43 + sessionDirs = []; 44 + }); 45 + 46 + let nameCounter = 0; 47 + function uniqueName(): string { 48 + // Keep names short to avoid EINVAL on Unix domain sockets (104-byte path limit on macOS) 49 + return `s${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; 50 + } 51 + 52 + describe("interactive TUI layout", () => { 53 + for (const cols of [80, 120, 200]) { 54 + it( 55 + `box fills width at ${cols} columns`, 56 + async () => { 57 + const sessionDir = makeSessionDir(); 58 + const name = uniqueName(); 59 + 60 + const { pid } = await createBackgroundSession( 61 + sessionDir, 62 + name, 63 + "sh", 64 + ["-c", "sleep 300"], 65 + os.tmpdir() 66 + ); 67 + bgPids.push(pid); 68 + 69 + const tui = TuiSession.create({ sessionDir, cols, rows: 24 }); 70 + tuiSessions.push(tui); 71 + 72 + const ss = await tui.waitForText(name, 10000); 73 + 74 + // Find the top border line (contains ╭ and ╮) 75 + const topLine = ss.lines.find((l) => l.includes("\u256d") && l.includes("\u256e")); 76 + expect(topLine).toBeDefined(); 77 + // The top border should span nearly the full width (cols - 2 margin) 78 + const expectedWidth = cols - 2; 79 + // Count visible characters: ╭ + ─...─ + ╮ should equal expectedWidth 80 + const trimmed = topLine!.trim(); 81 + expect(trimmed.length).toBe(expectedWidth); 82 + 83 + // Find a session row — it should NOT overflow past the right border 84 + const sessionLine = ss.lines.find((l) => l.includes(name)); 85 + expect(sessionLine).toBeDefined(); 86 + // The visible content should not exceed terminal width 87 + expect(sessionLine!.length).toBeLessThanOrEqual(cols); 88 + 89 + // The bottom border should match the top border width 90 + const bottomLine = ss.lines.find((l) => l.includes("\u2570") && l.includes("\u256f")); 91 + expect(bottomLine).toBeDefined(); 92 + expect(bottomLine!.trim().length).toBe(expectedWidth); 93 + }, 94 + 15000 95 + ); 96 + } 97 + 98 + it( 99 + "paths are not truncated when space is available at 120 cols", 100 + async () => { 101 + const sessionDir = makeSessionDir(); 102 + const name = uniqueName(); 103 + 104 + const { pid } = await createBackgroundSession( 105 + sessionDir, 106 + name, 107 + "sh", 108 + ["-c", "sleep 300"], 109 + // Use a path that's longish but fits in 120 cols 110 + os.tmpdir() 111 + ); 112 + bgPids.push(pid); 113 + 114 + const tui = TuiSession.create({ sessionDir, cols: 120, rows: 24 }); 115 + tuiSessions.push(tui); 116 + 117 + const ss = await tui.waitForText(name, 10000); 118 + const sessionLine = ss.lines.find((l) => l.includes(name)); 119 + expect(sessionLine).toBeDefined(); 120 + 121 + // The tmp dir path should NOT be truncated (no ellipsis) at 120 cols 122 + // os.tmpdir() is typically short like /tmp or /var/folders/... 123 + // At 120 cols there's plenty of room 124 + const tmpShort = os.tmpdir().startsWith(os.homedir()) 125 + ? "~" + os.tmpdir().slice(os.homedir().length) 126 + : os.tmpdir(); 127 + // If the path is short enough it should appear fully 128 + if (tmpShort.length < 50) { 129 + expect(sessionLine).toContain(tmpShort); 130 + } 131 + }, 132 + 15000 133 + ); 134 + 135 + it( 136 + "session list renders correctly at narrow 60 columns", 137 + async () => { 138 + const sessionDir = makeSessionDir(); 139 + const tui = TuiSession.create({ sessionDir, cols: 60, rows: 24 }); 140 + tuiSessions.push(tui); 141 + 142 + const ss = await tui.waitForText("Create new session...", 10000); 143 + 144 + // Box should still be drawn 145 + expect(ss.text).toMatch(/[╭╮╰╯│─]/); 146 + // Content should fit 147 + const topLine = ss.lines.find((l) => l.includes("\u256d") && l.includes("\u256e")); 148 + expect(topLine).toBeDefined(); 149 + expect(topLine!.trim().length).toBe(58); // 60 - 2 margin 150 + }, 151 + 15000 152 + ); 153 + }); 154 + 155 + describe("interactive TUI", () => { 156 + it( 157 + "renders session list with borders and 'Create new session...'", 158 + async () => { 159 + const sessionDir = makeSessionDir(); 160 + const tui = TuiSession.create({ sessionDir }); 161 + tuiSessions.push(tui); 162 + 163 + const ss = await tui.waitForText("Create new session...", 10000); 164 + expect(ss.text).toContain("pty"); 165 + expect(ss.text).toContain("Create new session..."); 166 + // Should have rounded box-drawing characters 167 + expect(ss.text).toMatch(/[╭╮╰╯│─]/); 168 + }, 169 + 15000 170 + ); 171 + 172 + it( 173 + "shows active sessions in the list", 174 + async () => { 175 + const sessionDir = makeSessionDir(); 176 + const name = uniqueName(); 177 + 178 + const { pid } = await createBackgroundSession( 179 + sessionDir, 180 + name, 181 + "sh", 182 + ["-c", "sleep 300"], 183 + os.tmpdir() 184 + ); 185 + bgPids.push(pid); 186 + 187 + const tui = TuiSession.create({ sessionDir }); 188 + tuiSessions.push(tui); 189 + 190 + const ss = await tui.waitForText(name, 10000); 191 + expect(ss.text).toContain(name); 192 + expect(ss.text).toContain("Create new session..."); 193 + }, 194 + 15000 195 + ); 196 + 197 + it( 198 + "arrow keys move selection", 199 + async () => { 200 + const sessionDir = makeSessionDir(); 201 + const name = uniqueName(); 202 + 203 + const { pid } = await createBackgroundSession( 204 + sessionDir, 205 + name, 206 + "sh", 207 + ["-c", "sleep 300"], 208 + os.tmpdir() 209 + ); 210 + bgPids.push(pid); 211 + 212 + const tui = TuiSession.create({ sessionDir }); 213 + tuiSessions.push(tui); 214 + 215 + await tui.waitForText(name, 10000); 216 + 217 + // Press down then up 218 + tui.press("down"); 219 + await new Promise((r) => setTimeout(r, 200)); 220 + tui.press("up"); 221 + await new Promise((r) => setTimeout(r, 200)); 222 + 223 + const ss = tui.screenshot(); 224 + expect(ss.text).toContain(name); 225 + expect(ss.text).toContain("Create new session..."); 226 + }, 227 + 15000 228 + ); 229 + 230 + it( 231 + "typing filters the list, backspace unfilters", 232 + async () => { 233 + const sessionDir = makeSessionDir(); 234 + const name1 = uniqueName(); 235 + const name2 = uniqueName(); 236 + 237 + const bg1 = await createBackgroundSession(sessionDir, name1, "sh", ["-c", "sleep 300"], os.tmpdir()); 238 + bgPids.push(bg1.pid); 239 + const bg2 = await createBackgroundSession(sessionDir, name2, "sh", ["-c", "sleep 300"], os.tmpdir()); 240 + bgPids.push(bg2.pid); 241 + 242 + const tui = TuiSession.create({ sessionDir }); 243 + tuiSessions.push(tui); 244 + 245 + await tui.waitForText(name1, 10000); 246 + await tui.waitForText(name2, 10000); 247 + 248 + // Type part of name1 to filter 249 + const filterText = name1.slice(-4); 250 + tui.type(filterText); 251 + await new Promise((r) => setTimeout(r, 300)); 252 + 253 + let ss = tui.screenshot(); 254 + expect(ss.text).toContain(name1); 255 + expect(ss.text).toContain("Create new session..."); 256 + 257 + // Backspace to remove filter 258 + for (let i = 0; i < filterText.length; i++) { 259 + tui.press("backspace"); 260 + } 261 + await new Promise((r) => setTimeout(r, 300)); 262 + 263 + ss = tui.screenshot(); 264 + expect(ss.text).toContain(name1); 265 + expect(ss.text).toContain(name2); 266 + }, 267 + 20000 268 + ); 269 + 270 + it( 271 + "enter on session triggers attach", 272 + async () => { 273 + const sessionDir = makeSessionDir(); 274 + const name = uniqueName(); 275 + 276 + const { pid } = await createBackgroundSession( 277 + sessionDir, 278 + name, 279 + "sh", 280 + ["-c", "echo 'HELLO_FROM_SESSION'; sleep 300"], 281 + os.tmpdir() 282 + ); 283 + bgPids.push(pid); 284 + 285 + const tui = TuiSession.create({ sessionDir }); 286 + tuiSessions.push(tui); 287 + 288 + await tui.waitForText(name, 10000); 289 + tui.press("return"); 290 + 291 + const ss = await tui.waitForText("HELLO_FROM_SESSION", 10000); 292 + expect(ss.text).toContain("HELLO_FROM_SESSION"); 293 + }, 294 + 15000 295 + ); 296 + 297 + it( 298 + "Ctrl+\\ detaches and returns to session list", 299 + async () => { 300 + const sessionDir = makeSessionDir(); 301 + const name = uniqueName(); 302 + 303 + const { pid } = await createBackgroundSession( 304 + sessionDir, 305 + name, 306 + "sh", 307 + ["-c", "echo 'IN_SESSION'; sleep 300"], 308 + os.tmpdir() 309 + ); 310 + bgPids.push(pid); 311 + 312 + const tui = TuiSession.create({ sessionDir }); 313 + tuiSessions.push(tui); 314 + 315 + await tui.waitForText(name, 10000); 316 + tui.press("return"); 317 + await tui.waitForText("IN_SESSION", 10000); 318 + 319 + // Detach with Ctrl+backslash 320 + tui.sendKeys("\x1c"); 321 + 322 + const ss = await tui.waitForText("Create new session...", 10000); 323 + expect(ss.text).toContain(name); 324 + }, 325 + 20000 326 + ); 327 + 328 + it( 329 + "create wizard: shows directory picker", 330 + async () => { 331 + const sessionDir = makeSessionDir(); 332 + const tui = TuiSession.create({ sessionDir }); 333 + tuiSessions.push(tui); 334 + 335 + await tui.waitForText("Create new session...", 10000); 336 + tui.press("return"); 337 + 338 + const ss = await tui.waitForText("Choose Directory", 5000); 339 + expect(ss.text).toContain("current directory"); 340 + }, 341 + 15000 342 + ); 343 + 344 + it( 345 + "create wizard: name auto-fills from directory", 346 + async () => { 347 + const sessionDir = makeSessionDir(); 348 + const tui = TuiSession.create({ sessionDir }); 349 + tuiSessions.push(tui); 350 + 351 + await tui.waitForText("Create new session...", 10000); 352 + tui.press("return"); 353 + await tui.waitForText("Choose Directory", 5000); 354 + tui.press("return"); 355 + 356 + const ss = await tui.waitForText("Name:", 5000); 357 + expect(ss.text).toContain("Command:"); 358 + }, 359 + 15000 360 + ); 361 + 362 + it( 363 + "empty state shows only 'Create new session...'", 364 + async () => { 365 + const sessionDir = makeSessionDir(); 366 + const tui = TuiSession.create({ sessionDir }); 367 + tuiSessions.push(tui); 368 + 369 + const ss = await tui.waitForText("Create new session...", 10000); 370 + expect(ss.text).toContain("Create new session..."); 371 + expect(ss.text).toContain("select"); 372 + }, 373 + 15000 374 + ); 375 + 376 + it( 377 + "q quits the interactive TUI", 378 + async () => { 379 + const sessionDir = makeSessionDir(); 380 + const tui = TuiSession.create({ sessionDir }); 381 + tuiSessions.push(tui); 382 + 383 + await tui.waitForText("Create new session...", 10000); 384 + tui.type("q"); 385 + await new Promise((r) => setTimeout(r, 500)); 386 + 387 + const ss = tui.screenshot(); 388 + expect(ss.text).not.toContain("Create new session..."); 389 + }, 390 + 15000 391 + ); 392 + 393 + it( 394 + "escape clears filter, then quits", 395 + async () => { 396 + const sessionDir = makeSessionDir(); 397 + const tui = TuiSession.create({ sessionDir }); 398 + tuiSessions.push(tui); 399 + 400 + await tui.waitForText("Create new session...", 10000); 401 + 402 + tui.type("xyz"); 403 + await new Promise((r) => setTimeout(r, 200)); 404 + 405 + let ss = tui.screenshot(); 406 + expect(ss.text).toContain("xyz"); 407 + 408 + // Escape clears filter 409 + tui.press("escape"); 410 + await new Promise((r) => setTimeout(r, 200)); 411 + 412 + ss = tui.screenshot(); 413 + expect(ss.text).toContain("Create new session..."); 414 + 415 + // Escape again quits 416 + tui.press("escape"); 417 + await new Promise((r) => setTimeout(r, 500)); 418 + 419 + ss = tui.screenshot(); 420 + expect(ss.text).not.toContain("Create new session..."); 421 + }, 422 + 15000 423 + ); 424 + 425 + it( 426 + "multiple attach/detach cycles work without breaking input", 427 + async () => { 428 + const sessionDir = makeSessionDir(); 429 + const name = uniqueName(); 430 + 431 + const { pid } = await createBackgroundSession( 432 + sessionDir, 433 + name, 434 + "sh", 435 + ["-c", "echo 'CYCLE_TEST'; sleep 300"], 436 + os.tmpdir() 437 + ); 438 + bgPids.push(pid); 439 + 440 + const tui = TuiSession.create({ sessionDir }); 441 + tuiSessions.push(tui); 442 + 443 + // Cycle 1: attach and detach 444 + await tui.waitForText(name, 10000); 445 + tui.press("return"); 446 + await tui.waitForText("CYCLE_TEST", 10000); 447 + tui.sendKeys("\x1c"); 448 + await tui.waitForText("Create new session...", 10000); 449 + 450 + // Cycle 2: attach and detach again — verifies listeners are cleaned up 451 + tui.press("return"); 452 + await tui.waitForText("CYCLE_TEST", 10000); 453 + tui.sendKeys("\x1c"); 454 + await tui.waitForText("Create new session...", 10000); 455 + 456 + // Cycle 3: verify TUI still responds to input 457 + tui.type("q"); 458 + await new Promise((r) => setTimeout(r, 500)); 459 + const ss = tui.screenshot(); 460 + expect(ss.text).not.toContain("Create new session..."); 461 + }, 462 + 30000 463 + ); 464 + 465 + it( 466 + "session list reloads after returning from attach", 467 + async () => { 468 + const sessionDir = makeSessionDir(); 469 + const name = uniqueName(); 470 + 471 + const { pid } = await createBackgroundSession( 472 + sessionDir, 473 + name, 474 + "sh", 475 + ["-c", "echo 'RELOAD_TEST'; sleep 300"], 476 + os.tmpdir() 477 + ); 478 + bgPids.push(pid); 479 + 480 + const tui = TuiSession.create({ sessionDir }); 481 + tuiSessions.push(tui); 482 + 483 + await tui.waitForText(name, 10000); 484 + 485 + // Attach then detach 486 + tui.press("return"); 487 + await tui.waitForText("RELOAD_TEST", 10000); 488 + tui.sendKeys("\x1c"); 489 + 490 + // After returning, session should still be in the list 491 + const ss = await tui.waitForText(name, 10000); 492 + expect(ss.text).toContain(name); 493 + expect(ss.text).toContain("Create new session..."); 494 + }, 495 + 20000 496 + ); 497 + });