/** Live PTY attach surface for hosted agent-board rows. */ import { spawn } from "node:child_process"; import { createRequire } from "node:module"; import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs"; import { createConnection, type Socket } from "node:net"; import type { Component, KeybindingsManager, TUI } from "@earendil-works/pi-tui"; import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { findHttpUrlAtCells, findWordRangeAtCells } from "../core/pty-links.mjs"; import { createAttachOutputRenderScheduler, detectCursorDesync, isPtyCursorHidden, nextAttachRender, projectPtyCursor, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs"; import { evaluateAttachReconnect, shouldEscapeAttach } from "../core/pty-attach-reconnect.mjs"; import { installImeCursorCoalesce } from "../core/ime-cursor-coalesce.mjs"; import { createJiggleRetryController } from "../core/pty-attach-jiggle-controller.mjs"; import { createTerminalAttachClient } from "../core/terminal-attach-client.mjs"; import { clampInt, parseMouseInputChunk, resolveWheelLines, scrollViewportTop, selectionDragScrollLines } from "../core/pty-scroll.mjs"; export type PtyAttachResult = { action: "detached" } | { action: "closed"; exitCode?: number | null }; type ThemeLike = { fg(color: string, text: string): string; bold(text: string): string; }; export interface PtyAttachOptions { socketPath: string; screenLogPath?: string; title: string; } const require = createRequire(import.meta.url); const { Terminal } = require("@xterm/headless") as { Terminal: new (opts: Record) => XtermLike }; const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1002h\x1b[?1006h"; const MOUSE_DISABLE = "\x1b[?1006l\x1b[?1002l\x1b[?1000l"; const XTSHIFTESCAPE_SELECT = "\x1b[>0s"; const DOUBLE_CLICK_MS = 260; const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const; const LOADING_TICK_MS = 120; /** How long to keep the loading banner after the last resize-jiggle during attach. */ const ATTACH_SETTLE_MS = 250; /** Hard cap on the attach transition so a silent session can't stall the banner. */ const ATTACH_HARD_TIMEOUT_MS = 2500; /** Protocol-race guard for legacy jiggle arming (issue #91 phase 4): the * undecided window must not pulse the child before the snapshot probe * resolves — a resize-free attach is the protocol-mode contract. Invisible * for legacy (the probe timeout decides at 1500ms ≫ 100ms) and the protocol * answer (~ms) reliably beats it; a pathologically late snapshot merely costs * one harmless shrink pulse that the protocol mode event unwinds. */ const LEGACY_JIGGLE_ARM_DELAY_MS = 100; /** Give ordered detach packets time to flush before using destroy as a fallback. */ const GRACEFUL_SOCKET_CLOSE_MS = 1000; /** Desync detection window: how long output must stay silent before a * misaligned cursor counts as desync (issue #11). Streaming keeps the cursor * on plain output cells legitimately; a healthy idle child re-parks it on the * inverse fake-cursor cell on its last rendered frame. */ const DESYNC_QUIET_MS = 1500; /** How often the post-settle probe runs checkDesync() (issue #11). */ const DESYNC_PROBE_INTERVAL_MS = 2000; /** Minimum spacing between two runtime heals (issue #11). */ const HEAL_RATELIMIT_MS = 10000; /** How many tail bytes of the screen log to replay on attach. Read from the file tail * (not the whole file) so multi-MB logs don't block startup; ~60KB covers the last * handful of screens, which is all a fresh attach needs. */ const ATTACH_REPLAY_BYTES = 60_000; const OSC52_PREFIX = "\x1b]52;"; const OSC52_MAX_BYTES = 1_000_000; const OSC52_CARRY_MAX_BYTES = OSC52_MAX_BYTES + 4096; const TERMINAL_PASSTHROUGH_MAX_BYTES = 5_000_000; const TERMINAL_PASSTHROUGH_CARRY_MAX_BYTES = TERMINAL_PASSTHROUGH_MAX_BYTES + 4096; const KITTY_IMAGE_PREFIX = "\x1b_G"; const ITERM2_FILE_PREFIX = "\x1b]1337;File="; interface XtermLike { write(data: string, cb?: () => void): void; resize(cols: number, rows: number): void; // Full buffer wipe (@xterm/headless Terminal.reset): the attach client's // "empty/resnapshot → UI resets its buffer" contract (phase 4, F3). reset(): void; buffer: { active: { baseY: number; cursorX?: number; cursorY?: number; length: number; getLine(index: number): BufferLineLike | undefined; getNullCell(): BufferCellLike; }; }; _core?: { coreService?: { isCursorHidden?: boolean }; _oscLinkService?: { getLinkData?: (id: number) => { uri?: string } | undefined; _dataByLinkId?: Map; }; }; } interface BufferLineLike { length: number; getCell(x: number, cell?: BufferCellLike): BufferCellLike | undefined; translateToString(trimRight?: boolean): string; } interface BufferCellLike { getWidth(): number; getChars(): string; extended?: { urlId?: number; _urlId?: number }; getFgColor(): number; getBgColor(): number; isFgRGB(): boolean; isBgRGB(): boolean; isFgPalette(): boolean; isBgPalette(): boolean; isFgDefault(): boolean; isBgDefault(): boolean; isBold(): number; isItalic(): number; isDim(): number; isUnderline(): number; isBlink(): number; isInverse(): number; isInvisible(): number; isStrikethrough(): number; isOverline(): number; } type MousePoint = { line: number; col: number }; type MouseSelection = { anchor: MousePoint; focus: MousePoint }; type NormalizedSelection = { start: MousePoint; end: MousePoint }; export class PtyAttachComponent implements Component { private socket: Socket | null = null; private connected = false; private closed = false; private status = "connecting"; private parserBuffer = ""; private retryTimer: ReturnType | null = null; private loadingTimer: ReturnType | null = null; private mouseRefreshTimers: Array> = []; // Shrink-and-hold jiggle protocol (issue #25): on attach we resize the child to // (cols-1, rows-1) and hold it there until the child emits a full clear // (\x1b[2J) — any render the child makes while held sees a width delta and // must fullRender, so boot-storm coalescing of ±1 pulses can no longer // produce a net-zero change. The controller restores the original size on // clear / first TUI frame (re-arm) / no-frame 6s guard / budget exhaustion / // close / external resize (guards G1-G5, see controller module). private readonly jiggleRetry = createJiggleRetryController({ sendResize: (cols, rows) => this.sendResize(cols, rows), setTimeoutFn: (fn, ms) => { const t = setTimeout(fn, ms); t.unref?.(); return t; }, clearTimeoutFn: (t) => clearTimeout(t as ReturnType), }); // Snapshot+subscribe attach client (issue #91 phase 4, D2). Created ONCE — // reconnects re-point send at the CURRENT socket (this.send routes through // this.socket) and resume from the applied cursor; wiring shape per the // verified reference in test/terminal-snapshot.integration.test.mjs // (wireClient). AGENT_BOARD_TERMINAL_SNAPSHOT=0 forces the legacy path // (escape hatch + deterministic legacy tests). private readonly attachClient = createTerminalAttachClient({ send: (msg) => this.send(msg), emit: (event, payload) => this.handleAttachEvent(event, payload), }); /** Decided attach path: "undecided" until the probe resolves (snapshot * begin → protocol; probe timeout / version mismatch → legacy). Gates * jiggle arming and probe-vs-cursor reconnects. */ private attachMode: "undecided" | "protocol" | "legacy" = "undecided"; private jiggleStartTimer: ReturnType | null = null; private osc52Carry = ""; private passthroughCarry = ""; private readonly connectStartedAt = Date.now(); // Set once the first socket connect succeeds; drives the reconnect give-up // policy (short window after a live session dies, long window during cold // host start — see pty-attach-reconnect.mjs). private everConnected = false; // Timestamp of the first disconnect that started the current reconnect // cycle; reset on every successful (re)connect. private disconnectedAt: number | null = null; // Lines scrolled per mouse-wheel event; configurable via $AGENT_BOARD_WHEEL_LINES. private readonly mouseWheelLines = resolveWheelLines(); private readonly term: XtermLike; private cols = 120; private rows = 24; // Absolute buffer line shown at the top of the viewport. null means follow bottom. private viewportTop: number | null = null; // Runtime desync backstop state (issue #11): last socket-output timestamp, // last heal timestamp, the probe timer, and an injectable clock for tests. private lastOutputAt = 0; private lastHealAt = 0; private desyncProbeTimer: ReturnType | null = null; /** Injectable clock for desync gating (tests override this). */ private nowFn: () => number = () => Date.now(); private selection: MouseSelection | null = null; private selectionDragging = false; private selectionAutoScrollTimer: ReturnType | null = null; private selectionAutoScrollLines = 0; private selectionAutoScrollMouse: { row: number; col: number } | null = null; private pendingClickTimer: ReturnType | null = null; private lastClickPoint: MousePoint | null = null; private lastClickAt = 0; /** Authoritative editor emptiness pushed by the child Pi extension via the * control socket (issue #68). null = unknown — forward conservatively * (spec §D1); only true detaches. */ private editorEmpty: boolean | null = null; // Whether any PTY output (live or replayed) has been shown yet. Until then we paint a // loading banner instead of an empty buffer so a slow (cold) host start doesn't leave // the previous screen visible. private receivedOutput = false; // Attach transition: hold the loading banner until the screen-log replay and the // initial resize-jiggle redraws settle, so attach never visibly scrolls/flashes the // buffer. `receivedOutput` tracks buffer content; `attaching` gates whether we paint it. private attaching = true; private attachSettleTimer: ReturnType | null = null; private attachHardTimeout: ReturnType | null = null; private gracefulSocketCloseTimer: ReturnType | null = null; // Force a single full-clear on the first paint so the prior session/dashboard can't // ghost behind this overlay; every later paint uses the TUI's coalesced, throttled, // differential renderer so wheel/output bursts don't each trigger a full repaint. private firstPaint = true; // Coalesce live PTY output repaints to ~25fps. node-pty splits one child-TUI update // into many small chunks; painting after each chunk would drive the outer TUI to its // frame cap and expose intermediate frames (visible as flicker on a busy session). private readonly outputRenderScheduler = createAttachOutputRenderScheduler(() => this.scheduleRender()); // Fold pi-tui's post-frame cursor-park writes back into the frame's sync block so the // terminal reports one stable IME cursor rect per frame instead of two (issue #28). // Never active when AGENT_BOARD_IME_FIX=0; no-op passthrough if pi-tui changes shape. private readonly imeCoalesceUninstall: (() => void) | null; constructor( private readonly tui: TUI, private readonly theme: ThemeLike, _keybindings: KeybindingsManager, private readonly done: (result: PtyAttachResult) => void, private readonly opts: PtyAttachOptions, ) { const size = this.currentSize(); this.cols = size.cols; this.rows = size.rows; // Assigned in the body (not as a field initializer) so it runs after the `tui` // parameter property is set regardless of the TS loader's field-init semantics. this.imeCoalesceUninstall = installImeCursorCoalesce(this.tui); this.term = new Terminal({ cols: this.cols, rows: this.rows, scrollback: 2000, allowProposedApi: true }); // Keep mouse reporting enabled by default so wheel scrolling and local drag-to-copy // selection can coexist inside the attach surface. Set AGENT_BOARD_ATTACH_MOUSE=0 // to fall back to terminal-native selection only. this.disableMouseScroll(); this.enableMouseScroll(); this.refreshMouseScrollMode(); this.replayScreenLog(); this.connect(); this.startLoadingTicker(); // Paint immediately (forced once) so the loading banner replaces the previous // surface the instant we attach, rather than after the first reconnect tick. this.scheduleRender(); } handleInput(data: string): void { const mouseInput = parseMouseInputChunk(data); if (mouseInput && this.handleMouseInputChunk(mouseInput)) return; if (matchesKey(data, Key.pageUp)) { this.clearPendingClick(); this.clearSelection(); if (this.tryScrollBy(this.pageSize())) return; this.send({ type: "input", data }); return; } if (matchesKey(data, Key.pageDown)) { this.clearPendingClick(); this.clearSelection(); if (this.tryScrollBy(-this.pageSize())) return; this.send({ type: "input", data }); return; } if (matchesKey(data, Key.home)) { this.clearPendingClick(); this.clearSelection(); if (this.tryScrollToTop()) return; this.send({ type: "input", data }); return; } if (matchesKey(data, Key.end)) { this.clearPendingClick(); this.clearSelection(); if (this.tryScrollToBottom()) return; this.send({ type: "input", data }); return; } if (matchesKey(data, Key.ctrl("left"))) { // Explicit detach chord (issue #89): single ← is gated on editor state // (it doubles as cursor-left inside a non-empty draft), so a user with // a draft had no way out short of Ctrl+C/D, which kills the child Pi. // Ctrl+← is unambiguous intent — detach unconditionally, regardless of // editor state or socket liveness (same always-exitable guarantee as // the disconnected-← escape, issue #48). this.detach(); return; } if (matchesKey(data, Key.left)) { // While the socket is down the key can never reach the child, so // escape unconditionally — the view must always be exitable, even // after the host crashes mid-output (issue #48). Note: ctrl+] is NOT // a detach key — it passes through to Pi (tui.editor.jumpForward). // Issue #91 Phase 6 (spec §D1): the gate reads ONLY the pushed // editorEmpty side channel — true detaches; false and null (child // extension missing, hello not yet arrived, old runner) forward via // the explicit conservative policy. The old terminal-buffer // heuristics are deleted for good: rendered bytes carry no // input-buffer semantics, so that chain guessed at a fact it // could not know (issues #42/#66/#69/#103). Escape without a pushed // state is Ctrl+← (issue #89), never a buffer guess. if (shouldEscapeAttach(this.connected, this.editorEmpty === true)) { this.detach(); return; } this.clearPendingClick(); this.clearSelection(); this.scrollToBottom(); this.send({ type: "input", data }); return; } this.clearPendingClick(); this.clearSelection(); this.scrollToBottom(); this.send({ type: "input", data }); } render(width: number): string[] { this.resizeIfNeeded(width); const height = this.tui.terminal?.rows ?? 24; const bodyHeight = Math.max(1, height - 2); let body: string[]; if (!this.attaching && this.receivedOutput) { const projected = this.project(bodyHeight); body = projected.lines; while (body.length < bodyHeight) body.unshift(""); } else { body = this.renderLoading(bodyHeight, width); } const header = this.theme.fg("accent", this.theme.bold(` ${this.opts.title} `)) + this.theme.fg("muted", `${this.status} · click opens links · dblclick/drag selects+copies · Ctrl+← detach`); return [clip(header, width), ...body.map((l) => clipTerminalLine(l, width)), this.theme.fg("dim", "─".repeat(width))]; } /** Centered "loading" surface shown until the first PTY output paints the session. */ private renderLoading(height: number, width: number): string[] { const elapsedMs = Date.now() - this.connectStartedAt; const spinner = this.closed ? "·" : SPINNER[Math.floor(elapsedMs / LOADING_TICK_MS) % SPINNER.length]; const title = `${spinner} Loading "${this.opts.title}"…`; const detail = this.loadingDetail(Math.max(0, Math.round(elapsedMs / 1000))); const out: string[] = []; const top = Math.max(0, Math.floor((height - 3) / 2)); for (let i = 0; i < top; i++) out.push(""); out.push(center(this.theme.fg("accent", this.theme.bold(title)), width)); out.push(center(this.theme.fg("muted", detail), width)); out.push(""); out.push(center(this.theme.fg("dim", "Ctrl+← to detach"), width)); while (out.length < height) out.push(""); return out.slice(0, height); } private loadingDetail(elapsedSeconds: number): string { if (this.status === "attached") return "Attached · waiting for the session to render…"; if (this.status.startsWith("error") || this.status === "host exited" || this.status === "host not reachable") return this.status; if (this.status === "disconnected") return `Reconnecting to the session host… ${elapsedSeconds}s`; return `Starting the session host… ${elapsedSeconds}s`; } invalidate(): void {} dispose(): void { this.close(); } private detach(): void { // Restore a held PTY before ending the control socket. The runner closes // the socket immediately after receiving detach, so sending detach first // can drop the G3 restore resize (issue #42). this.jiggleRetry.restoreAndStop(); // Phase 5 (D4): enveloped detach (correlated applied ack) with legacy // fallback; both reach the same runner detach handler. if (!this.attachClient.sendControl("detach")) { this.send({ type: "detach" }); } this.close(true); this.done({ action: "detached" }); } private connect(): void { if (this.closed || this.connected || this.socket) return; // On Windows the control socket is a named pipe — it never exists as a // filesystem entry, so existsSync can't gate the connect; a missing pipe // surfaces as an error event on the socket and retries via scheduleReconnect. if (process.platform !== "win32" && !existsSync(this.opts.socketPath)) { this.status = `starting host… ${Math.ceil((Date.now() - this.connectStartedAt) / 1000)}s`; this.scheduleReconnect(); return; } const socket = createConnection(this.opts.socketPath); this.socket = socket; // A partial protocol line left over from a failed socket would prefix the // replacement connection's first line; start each socket with a clean buffer. this.parserBuffer = ""; socket.on("connect", () => { if (this.socket !== socket) { try { socket.destroy(); } catch {} return; } this.clearRetry(); this.connected = true; this.everConnected = true; this.disconnectedAt = null; this.status = "attached"; this.send({ type: "hello", clientId: `ui-${Date.now()}`, wantOutput: true }); // Snapshot+subscribe attach (issue #91 phase 4): the first connect // probes with subscribe_terminal (old runners stay silent → probe // timeout → legacy); protocol reconnects resume from the applied // cursor over the SAME client instance (ring replay is seamless; // eviction/restart answers a fresh snapshot). hello stays UI-owned // and precedes the client's subscribe (established ordering). if (this.attachMode === "undecided") this.attachClient.start(); else if (this.attachMode === "protocol") this.attachClient.reconnect(this.attachClient.getLastSeq()); // The shrink-and-hold redraw protocol is a LEGACY-path correctness // tool: snapshot mode owns screen correctness via canonical frames, // so the jiggle never runs there (armLegacyJiggle guards the race). this.armLegacyJiggle(); this.enableMouseScroll(); this.scheduleRender(); this.startAttachSettle(); }); socket.on("data", (chunk) => { if (this.socket !== socket) return; this.onSocketData(chunk.toString("utf8")); }); socket.on("close", () => { // A failed socket can close after a replacement connection has already // succeeded. Never let that stale event clear the replacement state. if (this.socket !== socket) return; this.socket = null; this.connected = false; this.disconnectedAt ??= Date.now(); // A drop inside the probe window must not leave the absolute 1500ms // deadline armed: firing during the dead window would permanently // downgrade a protocol-capable runner to legacy (CR R1 advisory). The // reconnect's start()/reconnect() re-arms on the new socket. this.attachClient.onDisconnect(); if (!this.closed && this.status !== "host exited") { this.status = "disconnected"; this.scheduleReconnect(); } if (!this.closed) this.scheduleRender(); }); socket.on("error", (err) => { // The error may belong to an old socket after reconnect. Dispose that // socket, but do not alter the state of the current connection. if (this.socket !== socket) { try { socket.destroy(); } catch {} return; } this.socket = null; this.connected = false; // Notify before nulling: the close event that follows will hit the // stale-socket guard (this.socket no longer matches) and must not be // the only path that cancels the probe deadline (CR R1 advisory). this.attachClient.onDisconnect(); try { socket.destroy(); } catch {} this.disconnectedAt ??= Date.now(); if (this.closed) return; this.status = `waiting for host… ${err.message}`; this.scheduleReconnect(); this.scheduleRender(); }); } /** * Request a repaint. The first paint is forced (clears any prior screen so the previous * session can't ghost behind us); all later paints are coalesced + throttled + differential * by the TUI, so bursts of wheel/output events don't each clear-and-repaint the whole screen. */ private scheduleRender(force = false): void { const next = nextAttachRender(this.firstPaint, force); this.firstPaint = next.firstPaint; this.tui.requestRender(next.force); } private scheduleReconnect(): void { if (this.closed || this.retryTimer) return; const verdict = evaluateAttachReconnect({ everConnected: this.everConnected, disconnectedAt: this.disconnectedAt, connectStartedAt: this.connectStartedAt, now: Date.now(), }); if (verdict.giveUp) { this.status = verdict.status ?? "host exited"; this.scheduleRender(); return; } this.retryTimer = setTimeout(() => { this.retryTimer = null; this.connect(); this.scheduleRender(); }, 150); } /** Animate the loading banner until the first PTY output arrives (or we close). */ private startLoadingTicker(): void { if (this.loadingTimer || !this.attaching || this.closed) return; this.loadingTimer = setInterval(() => { if (this.closed || !this.attaching) return this.stopLoadingTicker(); this.tui.requestRender(); }, LOADING_TICK_MS); this.loadingTimer.unref?.(); } private stopLoadingTicker(): void { if (!this.loadingTimer) return; clearInterval(this.loadingTimer); this.loadingTimer = null; } /** * Attach transition lifecycle. Keep the loading banner up while the screen-log replay * and the initial resize-jiggle redraws settle, so the buffer doesn't visibly scroll or * flash through the viewport on attach. Each output chunk defers the settle * window; a hard timeout guards against a session that never produces output. */ private startAttachSettle(): void { if (!this.attaching) return; this.deferAttachSettle(); if (!this.attachHardTimeout) { this.attachHardTimeout = setTimeout(() => this.finishAttachTransition(), ATTACH_HARD_TIMEOUT_MS); this.attachHardTimeout.unref?.(); } } private deferAttachSettle(): void { if (!this.attaching) return; if (this.attachSettleTimer) clearTimeout(this.attachSettleTimer); this.attachSettleTimer = setTimeout(() => this.finishAttachTransition(), ATTACH_SETTLE_MS); this.attachSettleTimer.unref?.(); } private finishAttachTransition(): void { if (!this.attaching) return; // Note: jiggle retry chain is NOT cancelled here — it survives the settle // transition because any successful jiggle triggers a fullRender which emits // \x1b[2J, self-cancelling the chain. Post-settle jiggles only occur when the // child consumed none of the earlier ones (the exact failure mode this // feature fixes); in that case the screen is already stale, so the trade-off // of a brief full-render flicker (which also clears any in-progress text // selection) is acceptable in exchange for self-healing. When the child is // healthy, the very first jiggle's fullRender is detected and the chain // stops before settle ends, so no post-settle jiggle fires at all. this.attaching = false; if (this.attachSettleTimer) { clearTimeout(this.attachSettleTimer); this.attachSettleTimer = null; } if (this.attachHardTimeout) { clearTimeout(this.attachHardTimeout); this.attachHardTimeout = null; } this.stopLoadingTicker(); // Force a full clear so the loading banner is replaced atomically by the settled // buffer, instead of diffing banner lines into buffer lines. this.scheduleRender(true); this.startDesyncProbe(); } private startDesyncProbe(): void { this.stopDesyncProbe(); // The probe is deliberately NOT hooked into the render path: rendering is // event-driven (socket output / keypress / resize) and stops exactly when // desync strikes (output goes quiet). A self-contained timer is the only // way an idle desynced screen gets its self-heal without user action. this.desyncProbeTimer = setInterval(() => this.checkDesync(), DESYNC_PROBE_INTERVAL_MS); this.desyncProbeTimer.unref?.(); } private stopDesyncProbe(): void { if (this.desyncProbeTimer) { clearInterval(this.desyncProbeTimer); this.desyncProbeTimer = null; } } /** * Runtime desync backstop (issue #11). Seven gates, cheapest first: * settled+connected, child is a TUI (frame seen), chain idle, output quiet, * heal rate limit, misaligned cursor. All pass → heal() re-arms * shrink-and-hold; the child's fullRender clear then restores the size * and repaints a consistent screen. */ private checkDesync(): void { if (this.closed || this.attaching || !this.connected) return; // gates 1, 7 // Protocol mode owns screen correctness via canonical frames (issue #91 // phase 4, CR R1 advisory): a "misaligned cursor" there is frame content // or a stale-frame transient that resync heals — a jiggle heal would fire // resize pulses in a mode whose e2e pins zero resizes. The undecided // window keeps legacy semantics on purpose: until the snapshot answer // arrives the jiggle IS armed and the legacy fallthrough owns the screen. if (this.attachMode === "protocol") return; const chain = this.jiggleRetry.getState(); if (!chain.tuiFrameSeen) return; // gate 2: shell/vim children never heal if (!chain.stopped || chain.held) return; // gate 5: attach/heal chain active const now = this.nowFn(); if (now - this.lastOutputAt <= DESYNC_QUIET_MS) return; // gate 4: streaming if (now - this.lastHealAt <= HEAL_RATELIMIT_MS) return; // gate 6: rate limit const height = this.bodyHeight(); this.clampViewportTop(height); const start = this.viewportTop ?? this.bottomViewportTop(height); const buf = this.term.buffer.active; if (detectCursorDesync(buf, projectPtyCursor(buf, start, height)) !== "misaligned") return; // gate 3 this.lastHealAt = now; this.jiggleRetry.heal(this.cols, this.rows); } private clearMouseRefreshTimers(): void { for (const timer of this.mouseRefreshTimers) clearTimeout(timer); this.mouseRefreshTimers = []; } /** * Legacy-path jiggle arming with a protocol-race guard (issue #91 phase 4). * Legacy decided immediately (env-forced) → start now; undecided → start * after the guard delay unless the snapshot answer wins (protocol). The * timer guard accepts ANY non-protocol mode, so a fast in-flight decision * (e.g. frame_version_mismatch landing ~1ms into the probe window) still * arms the chain — an early-decided legacy session must not end up * jiggle-less. start() re-arms idempotently, so a late duplicate is safe. */ private armLegacyJiggle(): void { this.clearJiggleStartTimer(); if (this.attachMode === "protocol") return; if (this.attachMode === "legacy") { if (this.connected) this.jiggleRetry.start(this.cols, this.rows); return; } this.jiggleStartTimer = setTimeout(() => { this.jiggleStartTimer = null; if (!this.closed && this.connected && this.attachMode !== "protocol") { this.jiggleRetry.start(this.cols, this.rows); } }, LEGACY_JIGGLE_ARM_DELAY_MS); this.jiggleStartTimer.unref?.(); } private clearJiggleStartTimer(): void { if (!this.jiggleStartTimer) return; clearTimeout(this.jiggleStartTimer); this.jiggleStartTimer = null; } private clearRetry(): void { if (!this.retryTimer) return; clearTimeout(this.retryTimer); this.retryTimer = null; } private clearGracefulSocketCloseTimer(): void { if (!this.gracefulSocketCloseTimer) return; clearTimeout(this.gracefulSocketCloseTimer); this.gracefulSocketCloseTimer = null; } private enableMouseScroll(): void { if (!this.mouseScrollEnabled()) return; try { this.tui.terminal.write(XTSHIFTESCAPE_SELECT); this.tui.terminal.write(MOUSE_ENABLE); } catch { /* best-effort: some terminals reject these sequences; mouse reporting is optional */ } } private mouseScrollEnabled(): boolean { const mode = (process.env.AGENT_BOARD_ATTACH_MOUSE ?? "").trim().toLowerCase(); if (mode === "0" || mode === "off" || mode === "false") return false; if (mode === "1" || mode === "on" || mode === "true" || mode === "classic") return true; if ((process.env.AGENT_BOARD_ENABLE_MOUSE_SCROLL ?? "").trim() === "0") return false; return true; } private refreshMouseScrollMode(): void { this.clearMouseRefreshTimers(); if (!this.mouseScrollEnabled()) return; for (const delay of [0, 50, 250]) { const timer = setTimeout(() => { if (!this.closed) this.enableMouseScroll(); }, delay); timer.unref?.(); this.mouseRefreshTimers.push(timer); } } private disableMouseScroll(): void { try { this.tui.terminal.write(MOUSE_DISABLE); } catch { /* best-effort: terminal may already be gone at teardown */ } } private handleMouseInputChunk(events: Array<{ raw: string; mouse: { button: number; row: number; col: number; action: string } }>): boolean { let handled = false; for (const entry of events) { const { mouse, raw } = entry; if ((mouse.button & 64) !== 0) { handled = true; const wheelButton = mouse.button & 3; const wheel = wheelButton === 0 ? 1 : wheelButton === 1 ? -1 : 0; if (wheel === 0) continue; this.clearPendingClick(); this.clearSelection(); if (!this.tryScrollBy(wheel * this.mouseWheelLines)) this.send({ type: "input", data: raw }); continue; } handled = true; this.handleLocalMouseEvent(mouse); } return handled; } private updateSelectionAutoScroll(row: number, col: number): void { this.selectionAutoScrollMouse = { row, col }; if (!this.selection || !this.selectionDragging) return this.clearSelectionAutoScroll(false); const lines = selectionDragScrollLines(row, this.bodyHeight()); if (lines === 0) return this.clearSelectionAutoScroll(false); if (this.selectionAutoScrollTimer && this.selectionAutoScrollLines === lines) return; this.clearSelectionAutoScroll(false); this.selectionAutoScrollLines = lines; this.selectionAutoScrollTimer = setInterval(() => this.tickSelectionAutoScroll(), 50); this.selectionAutoScrollTimer.unref?.(); } private tickSelectionAutoScroll(): void { if (this.closed || !this.selection || !this.selectionDragging || !this.selectionAutoScrollMouse || !this.selectionAutoScrollLines) { this.clearSelectionAutoScroll(); return; } const changed = this.tryScrollBy(this.selectionAutoScrollLines); const point = this.mousePointForEvent(this.selectionAutoScrollMouse.row, this.selectionAutoScrollMouse.col, true); if (point) this.selection.focus = point; if (!changed) this.clearSelectionAutoScroll(false); } private clearSelectionAutoScroll(clearPointer = true): void { if (this.selectionAutoScrollTimer) { clearInterval(this.selectionAutoScrollTimer); this.selectionAutoScrollTimer = null; } this.selectionAutoScrollLines = 0; if (clearPointer) this.selectionAutoScrollMouse = null; } private handleLocalMouseEvent(mouse: { button: number; row: number; col: number; action: string }): void { const primary = (mouse.button & 3) === 0; const middleButton = (mouse.button & 3) === 1; if (mouse.action === "press") { this.clearSelectionAutoScroll(); if (middleButton) { // Middle-click paste: forward the X11 PRIMARY selection to the hosted // session as input, mimicking the terminal-native middle-click paste // that mouse reporting (kept on for wheel scrolling) would swallow. this.pastePrimarySelection(); this.clearPendingClick(); this.clearSelection(); return; } if (!primary) { this.clearPendingClick(); this.clearSelection(); return; } const point = this.mousePointForEvent(mouse.row, mouse.col, false); if (!point) { this.clearPendingClick(); this.clearSelection(); return; } if (this.isDoubleClickCandidate(point)) this.clearPendingClick(); this.selection = { anchor: point, focus: point }; this.selectionDragging = false; this.scheduleRender(); return; } if (!this.selection) return; if (mouse.action === "move") { if (!primary) return; const point = this.mousePointForEvent(mouse.row, mouse.col, true); if (!point) return; this.selection.focus = point; this.selectionDragging = true; this.updateSelectionAutoScroll(mouse.row, mouse.col); this.scheduleRender(); return; } if (mouse.action === "release") { this.clearSelectionAutoScroll(); const point = this.mousePointForEvent(mouse.row, mouse.col, true); if (point) this.selection.focus = point; const shouldCopy = this.selectionDragging; this.selectionDragging = false; if (shouldCopy) { this.clearPendingClick(); this.lastClickPoint = null; this.lastClickAt = 0; this.copySelectionToClipboard(); this.scheduleRender(); return; } if (!point) { this.clearPendingClick(); this.clearSelection(); return; } if (this.isDoubleClickCandidate(point)) { this.clearPendingClick(); this.lastClickPoint = null; this.lastClickAt = 0; if (!this.selectWordAtPoint(point)) this.selection = null; this.scheduleRender(); return; } this.lastClickPoint = point; this.lastClickAt = Date.now(); this.schedulePendingClick(point); } } private mousePointForEvent(row: number, col: number, clampToBody: boolean): MousePoint | null { const height = this.bodyHeight(); const bodyRow = row - 2; if (!clampToBody && (bodyRow < 0 || bodyRow >= height)) return null; const clampedRow = clampInt(bodyRow, 0, Math.max(0, height - 1)); this.clampViewportTop(height); const start = this.viewportTop ?? this.bottomViewportTop(height); const line = clampInt(start + clampedRow, 0, Math.max(0, this.term.buffer.active.length - 1)); const cellCol = clampInt(col - 1, 0, Math.max(0, this.cols - 1)); return { line, col: cellCol }; } private isDoubleClickCandidate(point: MousePoint): boolean { return !!this.lastClickPoint && Date.now() - this.lastClickAt <= DOUBLE_CLICK_MS && sameMousePoint(this.lastClickPoint, point); } private schedulePendingClick(point: MousePoint): void { this.clearPendingClick(); this.pendingClickTimer = setTimeout(() => { this.pendingClickTimer = null; this.lastClickPoint = null; this.lastClickAt = 0; if (this.closed) return; this.openLinkAtPoint(point); this.selection = null; this.selectionDragging = false; this.tui.requestRender(); }, DOUBLE_CLICK_MS); this.pendingClickTimer.unref?.(); } private clearPendingClick(): void { if (!this.pendingClickTimer) return; clearTimeout(this.pendingClickTimer); this.pendingClickTimer = null; } private selectWordAtPoint(point: MousePoint): boolean { const buf = this.term.buffer.active; const line = buf.getLine(point.line); if (!line) return false; const reusable = buf.getNullCell(); const range = findWordRangeAtCells(asciiCellsForBufferLine(line, reusable), point.col); if (!range) return false; this.selection = { anchor: { line: point.line, col: range.start }, focus: { line: point.line, col: range.end }, }; this.selectionDragging = false; this.copySelectionToClipboard(); return true; } private openLinkAtPoint(point: MousePoint): boolean { const target = this.linkAtPoint(point); return target ? openExternalTarget(target) : false; } private linkAtPoint(point: MousePoint): string | null { const buf = this.term.buffer.active; const line = buf.getLine(point.line); if (!line) return null; const reusable = buf.getNullCell(); const cell = line.getCell(point.col, reusable); const osc8 = cell ? osc8UriForCell(this.term, cell) : ""; if (osc8) return osc8; return findHttpUrlAtCells(asciiCellsForBufferLine(line, reusable), point.col); } private clearSelection(): void { this.clearSelectionAutoScroll(); if (!this.selection && !this.selectionDragging) return; this.selection = null; this.selectionDragging = false; this.scheduleRender(); } private copySelectionToClipboard(): void { const text = this.selectionText(); if (!text) return; const seq = osc52CopySequence(text); if (seq) { try { this.tui.terminal.write(seq); } catch { /* best-effort: OSC52 clipboard support is optional */ } } // Also mirror the selection into the X11 PRIMARY selection so the rest of the // desktop can middle-click-paste it — closes the loop with pastePrimarySelection(). this.writePrimarySelection(text); } /** * Middle-click paste. Reads the X11 PRIMARY selection via xclip and forwards it to * the hosted session as input, mimicking the terminal-native middle-click paste that * mouse reporting (kept on for wheel scrolling) would otherwise swallow. Silent no-op * when xclip is absent or AGENT_BOARD_ATTACH_NATIVE_PASTE=0. */ private pastePrimarySelection(): void { if (process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE === "0") return; try { const child = spawn("xclip", ["-o", "-selection", "primary"], { stdio: ["ignore", "pipe", "ignore"] }); let out = ""; const timer = setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* the child may have already exited before the timeout fired */ } }, 800); child.stdout?.on("data", (chunk: Buffer) => { out += chunk.toString("utf8"); }); child.on("error", () => clearTimeout(timer)); child.on("close", () => { clearTimeout(timer); if (!this.closed && out) this.send({ type: "input", data: out }); }); } catch { /* silent no-op when xclip is absent — documented contract of this helper */ } } /** Write `text` to the X11 PRIMARY selection so other apps can middle-click-paste it. */ private writePrimarySelection(text: string): void { if (process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE === "0") return; try { const child = spawn("xclip", ["-selection", "primary"], { stdio: ["pipe", "ignore", "ignore"] }); child.stdin?.on("error", () => {}); child.on("error", () => {}); child.stdin?.end(text); } catch { /* silent no-op when xclip is absent */ } } private selectionText(): string { const range = normalizeSelection(this.selection); if (!range) return ""; const buf = this.term.buffer.active; const reusable = buf.getNullCell(); const parts: string[] = []; for (let lineIndex = range.start.line; lineIndex <= range.end.line; lineIndex++) { const line = buf.getLine(lineIndex); if (!line) { parts.push(""); continue; } const from = lineIndex === range.start.line ? range.start.col : 0; const to = lineIndex === range.end.line ? range.end.col : line.length - 1; let text = ""; for (let x = Math.max(0, from); x <= Math.max(from, to); x++) { const cell = line.getCell(x, reusable); if (!cell || cell.getWidth() === 0) continue; text += cell.getChars() || " "; } parts.push(text.replace(/\s+$/u, "")); } return parts.join("\n").replace(/^\n+|\n+$/gu, ""); } private currentSize(): { cols: number; rows: number } { // SAFETY: duck-typed read — Pi TUI's Terminal type does not consistently expose // cols/columns/rows across versions (see resizeIfNeeded below). Runtime // fallbacks (120/24) keep this safe when the fields are absent. const term = this.tui.terminal as unknown as { cols?: number; columns?: number; rows?: number } | undefined; return { cols: Math.max(20, term?.cols ?? term?.columns ?? 120), rows: Math.max(5, (term?.rows ?? 24) - 2), }; } private resizeIfNeeded(width: number): void { const size = this.currentSize(); // Render width is authoritative inside ctx.ui.custom; terminal.cols is not // consistently exposed by all Pi TUI versions. size.cols = Math.max(20, width); if (size.cols === this.cols && size.rows === this.rows) return; this.cols = size.cols; this.rows = size.rows; // A real terminal resize supersedes the hold protocol: cancel any armed // hold and adopt the new size as the baseline (guard G4, issue #25). this.jiggleRetry.notifyExternalResize(size.cols, size.rows); this.term.resize(this.cols, this.rows); this.sendResize(); this.enableMouseScroll(); } private sendResize(cols = this.cols, rows = this.rows): void { // Phase 5 (D4): enveloped resize when the host has an instance fence // (correlated applied/superseded acks; starting-window host_starting is // retried client-side); legacy plain message otherwise. Both paths reach // the same runner resize handler. if (!this.attachClient.sendControl("resize", { cols, rows })) { this.send({ type: "resize", cols, rows }); } this.clampViewportTop(this.bodyHeight()); } /** Feed socket output into the jiggle retry controller (clear/frame detection). */ private checkClearSequence(data: string): void { this.jiggleRetry.feed(data); } private tryScrollBy(linesUp: number): boolean { const result = scrollViewportTop(this.viewportTop, this.bottomViewportTop(this.bodyHeight()), linesUp); this.viewportTop = result.viewportTop; if (result.changed) this.requestScrollRender(); return result.changed; } private tryScrollToTop(): boolean { if (this.bottomViewportTop(this.bodyHeight()) <= 0 || this.viewportTop === 0) return false; this.viewportTop = 0; this.requestScrollRender(); return true; } private tryScrollToBottom(): boolean { if (this.viewportTop === null) return false; this.viewportTop = null; this.requestScrollRender(); return true; } private scrollToBottom(): void { this.viewportTop = null; this.requestScrollRender(); } private requestScrollRender(): void { this.scheduleRender(); } private bodyHeight(): number { return Math.max(1, (this.tui.terminal?.rows ?? 24) - 2); } private pageSize(): number { return Math.max(1, this.bodyHeight() - 2); } private bottomViewportTop(height: number): number { const buf = this.term.buffer.active; const bottom = Math.min(buf.length, buf.baseY + this.rows); return Math.max(0, bottom - height); } private clampViewportTop(height: number): void { if (this.viewportTop === null) return; this.viewportTop = clampInt(this.viewportTop, 0, this.bottomViewportTop(height)); } private send(msg: Record): void { if (!this.socket || !this.connected) return; this.socket.write(JSON.stringify(msg) + "\n"); } private onSocketData(text: string): void { this.parserBuffer += text; const lines = this.parserBuffer.split("\n"); this.parserBuffer = lines.pop() ?? ""; let needsRender = false; for (const line of lines) { if (!line.trim()) continue; try { const msg = JSON.parse(line); // Protocol-managed messages (snapshot windows, seq-checked live // output, recovery) are consumed by the attach client and re-emitted // through handleAttachEvent. UI-owned messages (hello/status/ // editor_state/exit/error) and pre-decision broadcast output return // false and fall through to the legacy path below. if (this.attachClient.handleMessage(msg)) continue; if (msg.type === "output" && typeof msg.data === "string") { this.pushOutput(msg.data, { forwardProtocols: true }); this.checkClearSequence(msg.data); continue; } if (msg.type === "hello" || msg.type === "status") { this.status = "attached"; if (msg.type === "hello") this.editorEmpty = typeof msg.editorEmpty === "boolean" ? msg.editorEmpty : null; } else if (msg.type === "editor_state") { this.editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null; } else if (msg.type === "exit") { this.status = "host exited"; this.done({ action: "closed", exitCode: msg.exitCode ?? null }); } else if (msg.type === "error") this.status = `error: ${msg.message ?? "host error"}`; if (shouldScheduleAttachRenderForMessage(msg.type)) needsRender = true; } catch { // Ignore malformed protocol lines; raw PTY data is only legal inside output.data. } } if (needsRender) this.scheduleRender(); } /** * Attach-client events (issue #91 phase 4). The client owns protocol * recovery (it resubscribes internally); the UI only switches paths and * hydrates frames. */ private handleAttachEvent( event: "mode" | "snapshotBegin" | "snapshotReady" | "output" | "resubscribing" | "protocolError" | "cmdAck" | "reconciled" | "epochReset", payload: any, ): void { if (event === "mode") { const prev = this.attachMode; this.attachMode = payload as "protocol" | "legacy"; if (this.attachMode === "protocol") { // Snapshot mode owns screen correctness: cancel any pending legacy // jiggle arming and unwind anything already armed (restores a held // resize; no-ops otherwise). this.clearJiggleStartTimer(); this.jiggleRetry.restoreAndStop(); } else if (prev === "protocol") { // Downgrade after a protocol session (recovery budget exhausted): // re-arm the legacy screen-healing machinery. This must run whenever // the socket is alive — not only during the attach transition. A // post-settle downgrade that skipped start() left the chain stopped // forever (restoreAndStop on the protocol side), so feed() early- // returned and raw output could never heal a desynced screen. this.clearJiggleStartTimer(); if (this.connected) this.jiggleRetry.start(this.cols, this.rows); } // Legacy decided during the undecided window: the race-guard timer // already fired at 100ms (≪ the 1500ms probe timeout) — nothing to do. return; } if (event === "snapshotBegin") { // Protocol attach size-sync (CR R1 blocking): the legacy attach resized // the child at every connect (jiggle start); subscribe_terminal carries // no size, so without this the child keeps its host-creation geometry // and full-screen TUI children lay out at a stale size. begin is // authoritative for the runner's CURRENT size; the in-flight frame stays // old-geometry either way, but resizing now starts the child's redraw at // the true size sooner. Matched sizes send nothing (zero-resize // contract for same-size attach stays intact). const beginSize = (payload ?? {}) as { cols?: number; rows?: number }; if ( typeof beginSize.cols === "number" && typeof beginSize.rows === "number" && (beginSize.cols !== this.cols || beginSize.rows !== this.rows) ) { this.sendResize(); } return; } if (event === "snapshotReady") { const snap = (payload ?? {}) as { frame?: string; empty?: boolean; resnapshot?: boolean }; // The synthesized frame is self-contained on dirty terminals (phase 3 // torture-proven: DECSTR + clear preamble wipes the constructor // screen.log replay and any pre-probe broadcast bytes), so hydrating is // a plain push: the write callback drives receivedOutput/settle/render // exactly like the legacy replay path. No protocol forwarding — the // frame is runner-synthesized grid content, not child passthrough // sequences. Empty baselines (the COMMON initial attach state: the host // publishes alive+childPid before the child's first output) carry no // frame; the loading banner persists until the first live output. // Empty/resnapshot answers ALWAYS wipe the local buffer, attaching or // not: while attaching the banner hides it (zero visual cost), and the // constructor's screen.log warm-start must not outlive the protocol // answer — a gated reset let the dead session's tail render as a // cold-start ghost once the banner lifted (final-review F1). if (typeof snap.frame === "string") this.pushOutput(snap.frame); else if (snap.empty || snap.resnapshot) this.term.reset(); return; } if (event === "output") { // Live/replay protocol output: no jiggle clear-detection — that feed // exists to cancel the shrink-and-hold chain, which never runs in // protocol mode. if (typeof payload === "string") this.pushOutput(payload, { forwardProtocols: true }); return; } // "resubscribing": recovery is client-internal (informational only). // "protocolError": observability only — recovery is automatic until the // legacy fallback, whose mode event the UI acts on above. // "cmdAck"/"reconciled"/"epochReset" (phase 5): correlation/observability // only — applied/superseded bookkeeping, reconcile baselines and the // generation epoch rule live inside the client; the UI's behavioral // surface (hydrate, mode switching, size-sync) is unchanged. host_starting // resize retries are also client-internal (parity with the legacy runner- // side cachedResize). } private forwardTerminalProtocols(data: string): void { const toWrite: string[] = []; if (process.env.AGENT_BOARD_FORWARD_OSC52 !== "0") { const { sequences, carry } = extractOsc52Sequences(this.osc52Carry + data); this.osc52Carry = carry; toWrite.push(...sequences); } if (process.env.AGENT_BOARD_FORWARD_IMAGES !== "0") { const { sequences, carry } = extractTerminalPassthroughSequences(this.passthroughCarry + data); this.passthroughCarry = carry; toWrite.push(...sequences); } for (const seq of toWrite) { try { this.tui.terminal.write(seq); } catch { /* best-effort: forwarded sequences are enhancements, never critical */ } } } /** LEGACY FALLBACK (issue #91 phase 6 marking): the screen.log tail replay * is kept only for pre-protocol runners and the * AGENT_BOARD_TERMINAL_SNAPSHOT=0 kill switch. It is NOT a correctness * path of the snapshot+subscribe protocol — the protocol hydrates from * runner-owned snapshots and resumes from a sequence cursor (phase 4), * and this replay runs before the probe resolves. Removal condition: the * installed runner fleet is on the snapshot protocol baseline * (wire-detectable via hello protocol fields). */ private replayScreenLog(): void { if (!this.opts.screenLogPath || !existsSync(this.opts.screenLogPath)) return; try { // Read only the tail of the log instead of the whole file: large logs (tens of MB) // would otherwise block the attach on a full readFileSync + UTF-8 decode before the // first frame. Skip a leading partial line so we don't inject a half escape sequence. const { size } = statSync(this.opts.screenLogPath); const start = Math.max(0, size - ATTACH_REPLAY_BYTES); const length = size - start; if (length <= 0) return; const fd = openSync(this.opts.screenLogPath, "r"); try { const buf = Buffer.alloc(length); readSync(fd, buf, 0, length, start); let tail = buf.toString("utf8"); if (start > 0) { const nl = tail.indexOf("\n"); tail = nl >= 0 ? tail.slice(nl + 1) : tail; } this.pushOutput(tail); } finally { closeSync(fd); } } catch { /* best-effort: a missing or racing screen.log must not block attach */ } } private pushOutput(data: string, opts: { forwardProtocols?: boolean } = {}): void { if (data.length === 0) return; // Recorded synchronously BEFORE term.write (whose callback is async): the // desync quiet-window must measure when output ARRIVED, not when parsing finished. this.lastOutputAt = this.nowFn(); if (opts.forwardProtocols) this.forwardTerminalProtocols(data); // @xterm/headless parses asynchronously; the buffer is only populated once this // callback fires. Mark the buffer as ready (so the project path can paint it), but // stay on the loading banner while `attaching` — otherwise the screen-log replay and // the initial resize-jiggle redraws would flash through the viewport. Each parsed // chunk also defers the settle window, so the banner holds until output actually // stops arriving (i.e. the redraw finished), not just until the resize jiggle ends. this.term.write(data, () => { this.receivedOutput = true; this.deferAttachSettle(); this.outputRenderScheduler.request(); }); } private project(height: number): { lines: string[]; cursor: { row: number; col: number } | null } { const out: string[] = []; const buf = this.term.buffer.active; const selection = normalizeSelection(this.selection); this.clampViewportTop(height); const start = this.viewportTop ?? this.bottomViewportTop(height); const end = Math.min(buf.length, start + height); const reusable = buf.getNullCell(); // The PTY cursor: xterm buffer cursorY is relative to baseY (the viewport top of // the child terminal); cursorX may equal cols (one past the last cell). Only render // it when it lands inside the projected viewport. const cursor = projectPtyCursor(buf, start, height); const cursorHidden = isPtyCursorHidden(this.term); for (let i = start; i < end; i++) { out.push(lineToAnsi(buf.getLine(i), reusable, this.term, i, selection, cursor, cursorHidden)); } if (out.length === 0) out.push("Waiting for PTY output…"); return { lines: out.slice(-height), cursor }; } private close(gracefulSocket = false): void { if (this.closed && !this.socket) { // Already closed (e.g. dispose() after detach()); a pending graceful-close // fallback timer stays armed on purpose — it self-clears on socket close // or destroys the socket after the grace window. return; } this.closed = true; this.imeCoalesceUninstall?.(); this.attachClient.close(); this.jiggleRetry.restoreAndStop(); this.disableMouseScroll(); this.clearMouseRefreshTimers(); this.clearPendingClick(); this.clearSelectionAutoScroll(); this.clearRetry(); this.stopLoadingTicker(); this.outputRenderScheduler.dispose(); this.stopDesyncProbe(); if (this.attachSettleTimer) { clearTimeout(this.attachSettleTimer); this.attachSettleTimer = null; } if (this.attachHardTimeout) { clearTimeout(this.attachHardTimeout); this.attachHardTimeout = null; } this.clearGracefulSocketCloseTimer(); const socket = this.socket; this.socket = null; this.connected = false; if (!socket) return; if (!gracefulSocket) { try { socket.destroy(); } catch {} return; } // socket.end() preserves the ordering of the already-buffered restore and // detach packets. destroy() would discard buffered writes under backpressure. socket.once("close", () => this.clearGracefulSocketCloseTimer()); try { socket.end(); } catch {} this.gracefulSocketCloseTimer = setTimeout(() => { this.gracefulSocketCloseTimer = null; try { socket.destroy(); } catch {} }, GRACEFUL_SOCKET_CLOSE_MS); this.gracefulSocketCloseTimer.unref?.(); } } function sameMousePoint(a: MousePoint, b: MousePoint): boolean { return a.line === b.line && Math.abs(a.col - b.col) <= 1; } function asciiCellsForBufferLine(line: BufferLineLike, reusable: BufferCellLike): string[] { const cells: string[] = []; for (let x = 0; x < line.length; x++) { const cell = line.getCell(x, reusable); if (!cell || cell.getWidth() === 0) { cells.push(" "); continue; } const chars = cell.getChars() || " "; cells.push(chars.length === 1 && chars >= " " && chars <= "~" ? chars : " "); } return cells; } function openExternalTarget(target: string): boolean { const sanitized = sanitizeOscPayload(target).trim(); if (!sanitized) return false; // Opening the target is best-effort: an async spawn failure (e.g. xdg-open // missing on a minimal server) must be swallowed, not crash the host (issue #86). try { if (process.platform === "darwin") { const child = spawn("open", [sanitized], { detached: true, stdio: "ignore" }); child.on("error", () => {}); child.unref(); return true; } if (process.platform === "win32") { const child = spawn("cmd", ["/c", "start", "", sanitized], { detached: true, stdio: "ignore" }); child.on("error", () => {}); child.unref(); return true; } const child = spawn("xdg-open", [sanitized], { detached: true, stdio: "ignore" }); child.on("error", () => {}); child.unref(); return true; } catch { return false; } } function compareMousePoints(a: MousePoint, b: MousePoint): number { return a.line === b.line ? a.col - b.col : a.line - b.line; } function normalizeSelection(selection: MouseSelection | null): NormalizedSelection | null { if (!selection) return null; return compareMousePoints(selection.anchor, selection.focus) <= 0 ? { start: selection.anchor, end: selection.focus } : { start: selection.focus, end: selection.anchor }; } function pointWithinSelection(line: number, col: number, selection: NormalizedSelection | null): boolean { if (!selection) return false; if (line < selection.start.line || line > selection.end.line) return false; if (selection.start.line === selection.end.line) return col >= selection.start.col && col <= selection.end.col; if (line === selection.start.line) return col >= selection.start.col; if (line === selection.end.line) return col <= selection.end.col; return true; } function osc52CopySequence(text: string): string { if (!text) return ""; const data = Buffer.from(text, "utf8").toString("base64"); const seq = `\x1b]52;c;${data}\x07`; return Buffer.byteLength(seq, "utf8") <= OSC52_MAX_BYTES ? seq : ""; } function lineToAnsi( line: BufferLineLike | undefined, reusable: BufferCellLike, term: XtermLike, lineIndex: number, selection: NormalizedSelection | null, cursor: { row: number; col: number } | null, cursorHidden = false, ): string { const isCursorRow = cursor !== null && cursor.row === lineIndex; let last = -1; if (!line) { // No buffer line: keep the marker (IME positioning) and paint the inverse block // only while the child reports a visible cursor. if (isCursorRow && cursor!.col >= 0) return cursorHidden ? CURSOR_MARKER : CURSOR_MARKER + "\x1b[7m \x1b[0m"; return ""; } for (let x = 0; x < line.length; x++) { const cell = line.getCell(x, reusable); if (!cell || cell.getWidth() === 0) continue; if (cell.getChars()) last = x; } if (last < 0) { // Empty line: same split as above. if (isCursorRow && cursor!.col >= 0) return cursorHidden ? CURSOR_MARKER : CURSOR_MARKER + "\x1b[7m \x1b[0m"; return ""; } let out = ""; let prevAttr = ""; let prevUri = ""; for (let x = 0; x <= last; x++) { const cell = line.getCell(x, reusable); if (!cell || cell.getWidth() === 0) continue; const uri = osc8UriForCell(term, cell); if (uri !== prevUri) { if (prevUri) out += closeOsc8(); if (uri) out += openOsc8(uri); prevUri = uri; } const selected = pointWithinSelection(lineIndex, x, selection); // Position and visibility are separate concerns: the zero-width CURSOR_MARKER // (stripped by the TUI) always marks where the hardware cursor belongs for IME // and PI_HARDWARE_CURSOR=1 terminals, while the solid inverse block is only // painted when the child terminal itself reports the cursor as visible. pi-tui // parks a hidden cursor at a diff-write byproduct position, so painting it // unconditionally showed a ghost block (issue #102). const isCursor = isCursorRow && x === cursor!.col; if (isCursor) out += CURSOR_MARKER; const paintCursor = isCursor && !cursorHidden; const key = attrKey(cell, selected, paintCursor); if (key !== prevAttr) { out += attrsToAnsi(cell, selected, paintCursor); prevAttr = key; } out += cell.getChars() || " "; } if (prevUri) out += closeOsc8(); // Cursor past the end of the line content (cursorX == cols or beyond last cell): // append an inverse space so a VISIBLE position shows, keeping the marker either way. if (isCursorRow && cursor!.col > last) { out += CURSOR_MARKER + (cursorHidden ? "" : "\x1b[7m \x1b[0m"); } return out + "\x1b[0m"; } function attrKey(cell: BufferCellLike, selected = false, isCursor = false): string { return [ selected ? 1 : 0, isCursor ? 1 : 0, cell.isBold(), cell.isDim(), cell.isItalic(), cell.isUnderline(), cell.isBlink(), cell.isInverse(), cell.isInvisible(), cell.isStrikethrough(), cell.isOverline(), cell.isFgRGB(), cell.isFgPalette(), cell.getFgColor(), cell.isBgRGB(), cell.isBgPalette(), cell.getBgColor(), ].join(";"); } function attrsToAnsi(cell: BufferCellLike, selected = false, isCursor = false): string { const codes: string[] = ["0"]; if (cell.isBold()) codes.push("1"); if (cell.isDim()) codes.push("2"); if (cell.isItalic()) codes.push("3"); if (cell.isUnderline()) codes.push("4"); if (cell.isBlink()) codes.push("5"); if (cell.isInverse() || selected || isCursor) codes.push("7"); if (cell.isInvisible()) codes.push("8"); if (cell.isStrikethrough()) codes.push("9"); if (cell.isOverline()) codes.push("53"); codes.push(...colorCodes(cell, "fg")); codes.push(...colorCodes(cell, "bg")); return `\x1b[${codes.join(";")}m`; } function colorCodes(cell: BufferCellLike, kind: "fg" | "bg"): string[] { const isFg = kind === "fg"; const color = isFg ? cell.getFgColor() : cell.getBgColor(); if (isFg ? cell.isFgRGB() : cell.isBgRGB()) { return [isFg ? "38" : "48", "2", String((color >> 16) & 255), String((color >> 8) & 255), String(color & 255)]; } if (isFg ? cell.isFgPalette() : cell.isBgPalette()) { if (color >= 0 && color <= 7) return [String((isFg ? 30 : 40) + color)]; if (color >= 8 && color <= 15) return [String((isFg ? 90 : 100) + color - 8)]; return [isFg ? "38" : "48", "5", String(color)]; } return [isFg ? "39" : "49"]; } function clip(line: string, width: number): string { return truncateToWidth(line, width, ""); } function clipTerminalLine(line: string, width: number): string { // pi-tui's width helpers are ANSI-aware, but OSC sequences are terminal // protocols rather than SGR styling. Avoid truncating inside OSC 8 hyperlinks; // these lines are already projected from an xterm buffer sized to the viewport. return line.includes("\x1b]") ? line : clip(line, width); } function osc8UriForCell(term: XtermLike, cell: BufferCellLike): string { const id = cell.extended?.urlId ?? cell.extended?._urlId ?? 0; if (!id) return ""; const service = term._core?._oscLinkService; const uri = service?.getLinkData?.(id)?.uri ?? service?._dataByLinkId?.get(id)?.data?.uri; return sanitizeOscPayload(uri ?? ""); } function openOsc8(uri: string): string { return uri ? `\x1b]8;;${uri}\x07` : ""; } function closeOsc8(): string { return "\x1b]8;;\x07"; } function extractOsc52Sequences(input: string): { sequences: string[]; carry: string } { const sequences: string[] = []; let scanFrom = 0; let carryStart = -1; while (scanFrom < input.length) { const start = input.indexOf(OSC52_PREFIX, scanFrom); if (start < 0) break; const bel = input.indexOf("\x07", start + OSC52_PREFIX.length); const st = input.indexOf("\x1b\\", start + OSC52_PREFIX.length); const end = firstTerminator(bel, st); if (!end) { carryStart = start; break; } const [endIndex, terminatorLength] = end; const seq = input.slice(start, endIndex + terminatorLength); if (isForwardableOsc52(seq)) sequences.push(seq); scanFrom = endIndex + terminatorLength; } const carry = carryStart >= 0 ? input.slice(carryStart).slice(-OSC52_CARRY_MAX_BYTES) : osc52PrefixSuffix(input); return { sequences, carry }; } function osc52PrefixSuffix(input: string): string { const max = Math.min(input.length, OSC52_PREFIX.length - 1); for (let len = max; len > 0; len--) { const suffix = input.slice(-len); if (OSC52_PREFIX.startsWith(suffix)) return suffix; } return ""; } function firstTerminator(bel: number, st: number): [number, number] | null { if (bel < 0 && st < 0) return null; if (bel >= 0 && (st < 0 || bel < st)) return [bel, 1]; return [st, 2]; } function extractTerminalPassthroughSequences(input: string): { sequences: string[]; carry: string } { const sequences: string[] = []; let scanFrom = 0; let carryStart = -1; while (scanFrom < input.length) { const kitty = input.indexOf(KITTY_IMAGE_PREFIX, scanFrom); const iterm = input.indexOf(ITERM2_FILE_PREFIX, scanFrom); const start = firstIndex(kitty, iterm); if (start < 0) break; const prefix = start === kitty ? KITTY_IMAGE_PREFIX : ITERM2_FILE_PREFIX; const bel = prefix === ITERM2_FILE_PREFIX ? input.indexOf("\x07", start + prefix.length) : -1; const st = input.indexOf("\x1b\\", start + prefix.length); const end = firstTerminator(bel, st); if (!end) { carryStart = start; break; } const [endIndex, terminatorLength] = end; const seq = input.slice(start, endIndex + terminatorLength); if (seq.length <= TERMINAL_PASSTHROUGH_MAX_BYTES) sequences.push(seq); scanFrom = endIndex + terminatorLength; } const carry = carryStart >= 0 ? input.slice(carryStart).slice(-TERMINAL_PASSTHROUGH_CARRY_MAX_BYTES) : terminalPassthroughPrefixSuffix(input); return { sequences, carry }; } function firstIndex(a: number, b: number): number { if (a < 0) return b; if (b < 0) return a; return Math.min(a, b); } function terminalPassthroughPrefixSuffix(input: string): string { let best = ""; for (const prefix of [KITTY_IMAGE_PREFIX, ITERM2_FILE_PREFIX]) { const max = Math.min(input.length, prefix.length - 1); for (let len = max; len > best.length; len--) { const suffix = input.slice(-len); if (prefix.startsWith(suffix)) best = suffix; } } return best; } function isForwardableOsc52(seq: string): boolean { if (seq.length > OSC52_MAX_BYTES) return false; const terminatorLength = seq.endsWith("\x1b\\") ? 2 : 1; const body = seq.slice(2, -terminatorLength); // strip ESC] and BEL/ST const firstSemi = body.indexOf(";"); const secondSemi = body.indexOf(";", firstSemi + 1); if (!body.startsWith("52;") || secondSemi < 0) return false; const payload = body.slice(secondSemi + 1).replace(/[\r\n]/g, ""); // Do not forward clipboard-read requests (OSC 52 ; ... ; ?) to the outer terminal. if (payload === "?") return false; return /^[A-Za-z0-9+/=]*$/.test(payload); } function sanitizeOscPayload(value: string): string { return value.replace(/[\x00-\x1f\x7f]/g, ""); } function center(text: string, width: number): string { const w = visibleWidth(text); if (w >= width) return clip(text, width); return " ".repeat(Math.floor((width - w) / 2)) + text; }