From 88a9f49bf3696a12630d98a5bbf09017d15a1d7d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 07:26:43 +0000 Subject: [PATCH 01/35] feat: improve terminal visual quality - lineHeight 1.2 and letterSpacing 0.5 for better readability - cursorStyle 'bar' instead of block for modern feel - scrollback increased to 10,000 lines - alt fast-scroll and macOptionIsMeta enabled - tab switching changed from instant display:none to 150ms opacity fade https://claude.ai/code/session_01KXU1uAUwx3L82TMLnAmU4z --- src/renderer/src/components/terminal/TerminalPane.tsx | 8 ++++++-- src/renderer/src/hooks/use-terminal.ts | 7 ++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/terminal/TerminalPane.tsx b/src/renderer/src/components/terminal/TerminalPane.tsx index 7aa8a088..f7807870 100644 --- a/src/renderer/src/components/terminal/TerminalPane.tsx +++ b/src/renderer/src/components/terminal/TerminalPane.tsx @@ -1079,8 +1079,12 @@ export function TerminalPane(): React.ReactNode { return (
Date: Sun, 7 Jun 2026 07:31:49 +0000 Subject: [PATCH 02/35] chore: apply pr-reviewer fixes for #142 --- src/renderer/src/components/terminal/TerminalPane.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/src/components/terminal/TerminalPane.tsx b/src/renderer/src/components/terminal/TerminalPane.tsx index f7807870..163f2c87 100644 --- a/src/renderer/src/components/terminal/TerminalPane.tsx +++ b/src/renderer/src/components/terminal/TerminalPane.tsx @@ -1085,6 +1085,7 @@ export function TerminalPane(): React.ReactNode { pointerEvents: active ? 'auto' : 'none' }} aria-hidden={!active} + inert={!active} > Date: Sun, 7 Jun 2026 07:38:58 +0000 Subject: [PATCH 03/35] chore: apply pr-reviewer fixes for #142 --- src/renderer/src/components/terminal/TerminalPane.tsx | 3 ++- src/renderer/src/hooks/use-terminal.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/terminal/TerminalPane.tsx b/src/renderer/src/components/terminal/TerminalPane.tsx index 163f2c87..8a84d3d2 100644 --- a/src/renderer/src/components/terminal/TerminalPane.tsx +++ b/src/renderer/src/components/terminal/TerminalPane.tsx @@ -1079,9 +1079,10 @@ export function TerminalPane(): React.ReactNode { return (
Date: Sun, 7 Jun 2026 10:13:09 +0000 Subject: [PATCH 04/35] fix: prevent keyboard focus on inactive terminal tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opacity:0 + pointerEvents:none does not remove hidden tabs from the browser tab order — the xterm.js textarea remains focusable. Adding the inert attribute (via ref callback to avoid TypeScript attribute gaps) makes the entire inactive container and all its descendants unfocusable, matching the behaviour of the previous display:none while preserving the 150ms opacity fade animation. https://claude.ai/code/session_01KXU1uAUwx3L82TMLnAmU4z --- src/renderer/src/components/terminal/TerminalPane.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/terminal/TerminalPane.tsx b/src/renderer/src/components/terminal/TerminalPane.tsx index 8a84d3d2..157b3c94 100644 --- a/src/renderer/src/components/terminal/TerminalPane.tsx +++ b/src/renderer/src/components/terminal/TerminalPane.tsx @@ -1079,7 +1079,7 @@ export function TerminalPane(): React.ReactNode { return (
Date: Mon, 8 Jun 2026 08:55:26 +0200 Subject: [PATCH 05/35] perf(pty-buffer): coalesce chunk notifications per rAF Streaming agent output was firing the per-key listener set synchronously for every PTY chunk, forcing a fresh allocation + synchronous term.write() per byte. Stage incoming chunks in a pending array per agent key and flush once per animation frame, so subscribers see at most one notification per frame with the cumulative history. clearPtyBuffer now cancels any pending flush for the key. Co-Authored-By: Claude Opus 4.7 --- src/renderer/src/stores/pty-buffer-store.ts | 71 +++++++++++++++++---- 1 file changed, 59 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/stores/pty-buffer-store.ts b/src/renderer/src/stores/pty-buffer-store.ts index 3f56a037..469547ec 100644 --- a/src/renderer/src/stores/pty-buffer-store.ts +++ b/src/renderer/src/stores/pty-buffer-store.ts @@ -2,6 +2,14 @@ // worker_stream events don't force a re-render of every component subscribed // to the agents array. Subscribers register against a single agent key and // receive only that agent's chunks. +// +// Incoming bytes from the broker arrive at sub-frame granularity. Notifying +// listeners synchronously per chunk means each chunk triggers a synchronous +// `term.write()` and, in some subscribers, a React state update — large +// allocations + per-byte work that pegs the renderer during streaming. +// Instead, we stage incoming chunks per key and flush once per animation +// frame, so subscribers see at most one notification (with the full history) +// per frame. const MAX_PTY_BUFFER_CHUNKS = 10_000 @@ -10,29 +18,68 @@ type Listener = (chunks: string[]) => void const buffers = new Map() const listeners = new Map>() -export function getPtyChunks(key: string): string[] { - return buffers.get(key) ?? [] +// Chunks staged for the next animation frame, keyed by agent key. +const pending = new Map() +// Scheduled rAF handles per key so we can cancel on clear/dispose. +const pendingFrames = new Map() + +const raf: (cb: FrameRequestCallback) => number = + typeof requestAnimationFrame === 'function' + ? requestAnimationFrame + : ((cb: FrameRequestCallback) => setTimeout(() => cb(performance.now()), 16) as unknown as number) + +const cancelRaf: (handle: number) => void = + typeof cancelAnimationFrame === 'function' + ? cancelAnimationFrame + : ((handle: number) => clearTimeout(handle as unknown as ReturnType)) + +function cancelPendingFlush(key: string): void { + const handle = pendingFrames.get(key) + if (handle !== undefined) { + cancelRaf(handle) + pendingFrames.delete(key) + } + pending.delete(key) } -export function appendPtyChunk(key: string, chunk: string): void { - // Always allocate a fresh array so React subscribers (e.g. AgentNode's - // useState) don't bail out on Object.is reference equality and freeze - // the preview tile after the first chunk. +function flushPending(key: string): void { + pendingFrames.delete(key) + const queued = pending.get(key) + pending.delete(key) + if (!queued || queued.length === 0) return + const existing = buffers.get(key) ?? [] - const trimmed = existing.length >= MAX_PTY_BUFFER_CHUNKS - ? existing.slice(existing.length - MAX_PTY_BUFFER_CHUNKS + 1) - : existing - const next = [...trimmed, chunk] - buffers.set(key, next) + const combined = existing.concat(queued) + const trimmed = combined.length > MAX_PTY_BUFFER_CHUNKS + ? combined.slice(combined.length - MAX_PTY_BUFFER_CHUNKS) + : combined + buffers.set(key, trimmed) const keyListeners = listeners.get(key) if (!keyListeners || keyListeners.size === 0) return for (const listener of keyListeners) { - listener(next) + listener(trimmed) + } +} + +export function getPtyChunks(key: string): string[] { + return buffers.get(key) ?? [] +} + +export function appendPtyChunk(key: string, chunk: string): void { + const queue = pending.get(key) + if (queue) { + queue.push(chunk) + } else { + pending.set(key, [chunk]) } + if (pendingFrames.has(key)) return + const handle = raf(() => flushPending(key)) + pendingFrames.set(key, handle) } export function clearPtyBuffer(key: string): void { + cancelPendingFlush(key) buffers.delete(key) const keyListeners = listeners.get(key) if (keyListeners) { From 62df45b62fefcd61a2c961155f61d7d04cf525f0 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 08:55:31 +0200 Subject: [PATCH 06/35] feat(terminal): add awaitFontSettle helper xterm measures cell width/height from a glyph at init time; if the JetBrains Mono webfont hasn't finished loading, the measurement falls back to system monospace and rows/cols are mis-sized until the next resize. awaitFontSettle races document.fonts.load() against a 1.5s timeout so the runtime can defer its post-open refit until the real font is available. Co-Authored-By: Claude Opus 4.7 --- src/renderer/src/lib/font-settle.ts | 48 +++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/renderer/src/lib/font-settle.ts diff --git a/src/renderer/src/lib/font-settle.ts b/src/renderer/src/lib/font-settle.ts new file mode 100644 index 00000000..596ae29c --- /dev/null +++ b/src/renderer/src/lib/font-settle.ts @@ -0,0 +1,48 @@ +// Wait for the given font family to load before xterm measures the cell box. +// +// xterm computes cell width/height from a measured glyph. If we open the +// terminal before JetBrains Mono (or whatever custom face) has actually +// loaded, xterm measures the fallback (system monospace) and rows/cols are +// off by ~10–20%, producing "smeared" text that only resolves on the next +// resize. `document.fonts.load(...)` returns a promise that resolves once +// the requested face is ready; we race it against a timeout so we never +// block terminal init on a font that fails to load. + +export async function awaitFontSettle( + fontFamily: string, + timeoutMs = 1500 +): Promise { + const fonts = (typeof document !== 'undefined' ? document.fonts : undefined) as + | FontFaceSet + | undefined + if (!fonts || typeof fonts.load !== 'function') return + + // `document.fonts.load` expects a CSS shorthand; the size doesn't matter + // for our purposes since we just want the face installed. + const familyToken = primaryFamily(fontFamily) + const spec = `13px ${familyToken}` + + let timer: ReturnType | null = null + const timeout = new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs) + }) + + try { + await Promise.race([ + fonts.load(spec).then(() => undefined).catch(() => undefined), + timeout + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +// `fontFamily` may be a CSS list like "'JetBrains Mono', 'Fira Code', Menlo". +// `document.fonts.load` accepts a list, but quoting/escaping has historically +// been finicky across engines. Take the first family token (preserving +// existing quotes if present) to keep the load request unambiguous. +function primaryFamily(fontFamily: string): string { + const first = fontFamily.split(',')[0]?.trim() + if (!first) return fontFamily + return first +} From fdbe5b0213473490c20aa6a6b53934e8092e1fd9 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 08:55:45 +0200 Subject: [PATCH 07/35] refactor(terminal): persist xterm runtime across React lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the Terminal instance, addons, PTY subscription, predictive echo, and parked DOM host into a module-level registry keyed by agent. React mount/detach just reparents the parked host into the visible container — xterm never tears down on tab switches, so the snapshot attach + chunk replay can no longer overlap and produce the duplicate text the user reported. Folded into this change because they all flow through the new runtime: - Defer WebglAddon construction to the next rAF after open(), so the terminal boots with the DOM renderer and upgrades on the next frame. A module-level suggestedRenderer flag demotes the rest of the session to DOM on context loss or construction throw. - Wait for awaitFontSettle() after open() before locking cell metrics in, then refit + refresh(0, rows-1). Removes the SIGWINCH "bounce" hack the old init() used to force a redraw. - Trailing-debounce the ResizeObserver to 75 ms, ignore zero-size entries (avoids bad fits during allotment drags), and re-pin the viewport to bottom after fit if it was pinned before. - useTerminal becomes a thin delegate: acquireTerminalRuntime -> mount(container) on visible -> detach() on unmount; dispose only when the agent is no longer in the store. Co-Authored-By: Claude Opus 4.7 --- src/renderer/src/hooks/use-terminal.ts | 582 +++++++----------- .../src/lib/terminal-runtime-registry.ts | 506 +++++++++++++++ 2 files changed, 719 insertions(+), 369 deletions(-) create mode 100644 src/renderer/src/lib/terminal-runtime-registry.ts diff --git a/src/renderer/src/hooks/use-terminal.ts b/src/renderer/src/hooks/use-terminal.ts index 1803d3ed..50d15f63 100644 --- a/src/renderer/src/hooks/use-terminal.ts +++ b/src/renderer/src/hooks/use-terminal.ts @@ -1,70 +1,24 @@ import { useCallback, useEffect, useRef } from 'react' import { Terminal } from '@xterm/xterm' -import { FitAddon } from '@xterm/addon-fit' -import { WebLinksAddon } from '@xterm/addon-web-links' -import { WebglAddon } from '@xterm/addon-webgl' import { pear, type TerminalAttachMode } from '@/lib/ipc' import { useAgentStore, getAgentKey } from '@/stores/agent-store' -import { getPtyChunks, subscribePtyBuffer } from '@/stores/pty-buffer-store' -import { recordChunkEchoed, recordKeystrokeSent } from '@/lib/typing-trace' -import { createPredictiveEcho } from '@/lib/predictive-echo' -import type { PredictiveEcho } from '@agent-relay/harness-driver/predictive-echo' -import { useUIStore, type Theme } from '@/stores/ui-store' - -const DARK_THEME = { - background: '#0b1017', - foreground: '#d7e0ea', - cursor: '#74b8e2', - selectionBackground: '#203247', - black: '#121a24', - red: '#f0727f', - green: '#6bd4bc', - yellow: '#e6d78d', - blue: '#74b8e2', - magenta: '#c9a7ff', - cyan: '#04d1f6', - white: '#d7e0ea', - brightBlack: '#64707d', - brightRed: '#ff8a96', - brightGreen: '#89e4cb', - brightYellow: '#f1e5a7', - brightBlue: '#94cbef', - brightMagenta: '#dcc6ff', - brightCyan: '#6fe7ff', - brightWhite: '#edf4fb' -} - -const LIGHT_THEME = { - background: '#f7fafc', - foreground: '#111827', - cursor: '#4a90c2', - selectionBackground: '#d7e7f4', - black: '#111827', - red: '#d95b63', - green: '#2e9f92', - yellow: '#c89934', - blue: '#4a90c2', - magenta: '#8b72d8', - cyan: '#2e9f92', - white: '#f7fafc', - brightBlack: '#6b7280', - brightRed: '#ea717a', - brightGreen: '#4fb4a7', - brightYellow: '#d8ac4f', - brightBlue: '#6aa7d2', - brightMagenta: '#a28ae7', - brightCyan: '#4fbab0', - brightWhite: '#ffffff' -} - -function getXtermTheme(theme: Theme): typeof DARK_THEME { - return theme === 'light' ? LIGHT_THEME : DARK_THEME -} +import { recordKeystrokeSent } from '@/lib/typing-trace' +import { + acquireTerminalRuntime, + disposeTerminalRuntime, + type TerminalRuntime +} from '@/lib/terminal-runtime-registry' +import { useUIStore } from '@/stores/ui-store' function hasLayout(el: HTMLElement): boolean { return el.clientWidth > 0 && el.clientHeight > 0 } +function isViewportPinnedToBottom(term: Terminal): boolean { + const buffer = term.buffer.active + return buffer.viewportY === buffer.baseY +} + const KEY_INPUT_SEQUENCES: Record = { Enter: '\r', Tab: '\t', @@ -125,19 +79,6 @@ function isEditableElement(target: EventTarget | null): boolean { return editable instanceof HTMLElement } -function hasVisibleTerminalContent(screen: string): boolean { - const stripped = screen.replace( - /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[@-Z\\-_])/g, - '' - ) - return /\S/.test(stripped) -} - -interface TerminalSize { - rows: number - cols: number -} - export function useTerminal( containerRef: React.RefObject, agentName: string | null, @@ -149,10 +90,7 @@ export function useTerminal( onAutoHoldStart?: () => Promise | void, onAutoHoldRelease?: (flush: boolean) => Promise | void ): Terminal | null { - const termRef = useRef(null) - const fitAddonRef = useRef(null) - const predictiveEchoRef = useRef(null) - const writtenChunksRef = useRef(0) + const runtimeRef = useRef(null) const activeRef = useRef(active) const terminalModeRef = useRef(terminalMode) const autoHoldRef = useRef(autoHold) @@ -160,6 +98,10 @@ export function useTerminal( const onAutoHoldReleaseRef = useRef(onAutoHoldRelease) const typingActiveRef = useRef(false) const inputQueueRef = useRef>(Promise.resolve()) + // Backs the runtime's predictive echo `getInputSrtt` callback. The + // runtime holds the function reference for life; we just keep the + // latest value in this ref and poll while the hook is mounted. + const inputSrttRef = useRef(null) const theme = useUIStore((s) => s.theme) const activeDialog = useUIStore((s) => s.activeDialog) @@ -173,6 +115,7 @@ export function useTerminal( useEffect(() => { terminalModeRef.current = terminalMode + runtimeRef.current?.setTerminalMode(terminalMode) }, [terminalMode]) useEffect(() => { @@ -195,7 +138,7 @@ export function useTerminal( // Optimistically echo before the round trip; the engine reconciles // against authoritative output and stays dormant on fast local links. - predictiveEchoRef.current?.onUserInput(data) + runtimeRef.current?.getPredictiveEcho()?.onUserInput(data) recordKeystrokeSent(data) if (holdInput || typingActiveRef.current) { await pear.broker.sendInput(projectId, agentName, data).catch((err) => { @@ -220,339 +163,164 @@ export function useTerminal( }) }, [sendInputNow]) + // Read the latest theme via a ref so the main acquisition effect can be + // independent of theme changes; the dedicated setTheme effect below + // propagates theme updates to the live runtime. + const themeRef = useRef(theme) useEffect(() => { - if (!containerRef.current || !agentName) return + themeRef.current = theme + }, [theme]) + + useEffect(() => { + if (!agentName) { + runtimeRef.current = null + return + } + + // Acquire the persistent runtime for this agent. Tab switches / + // re-mounts return the same runtime instance, so xterm + PTY + // subscription survive across React lifecycle churn — this is what + // kills the duplicate-text class of bugs. + const runtime = acquireTerminalRuntime({ + projectId, + agentName, + terminalMode: terminalModeRef.current, + theme: themeRef.current, + getInputSrtt: () => inputSrttRef.current + }) + runtimeRef.current = runtime + runtime.setOnData((data) => sendInput(data)) - const container = containerRef.current - let unsubStore: (() => void) | null = null - let term: Terminal | null = null - let fitAddon: FitAddon | null = null - let resizeObserver: ResizeObserver | null = null let disposed = false - let cleanupBounce: (() => void) | null = null - let disposePredictiveEcho: (() => void) | null = null - // Latest broker input→ack SRTT (ms), refreshed by the poll below. Backs the - // engine's adaptive engage decision; SRTT is a slow-moving EWMA so a ~1s - // poll is responsive enough and cheap. - let inputSrttMs: number | null = null + let resizeObserver: ResizeObserver | null = null + let resizeDebounceTimer: ReturnType | null = null let srttPoll: ReturnType | null = null + let focusTimers: ReturnType[] = [] + const containerEl = containerRef.current const focusTerminal = (requireActive = false): void => { - if (!term) return + const term = runtime.term + const container = containerRef.current + if (!term || !container) return if (requireActive && !activeRef.current) return requestAnimationFrame(() => { - if (!disposed && (!requireActive || activeRef.current)) { - container.focus({ preventScroll: true }) - term?.textarea?.focus({ preventScroll: true }) - term?.focus() - } - }) - } - - const fitTerminal = (): TerminalSize | null => { - if (!term || !fitAddon || !hasLayout(container)) return null - try { - fitAddon.fit() - } catch { - return null - } - const { rows, cols } = term - if (rows > 0 && cols > 0) { - return { rows, cols } - } - return null - } - - const safeFitAndSync = (): TerminalSize | null => { - const size = fitTerminal() - if (size) { - predictiveEchoRef.current?.onResize(size.cols, size.rows) - pear.broker.resizePty(projectId, agentName!, size.rows, size.cols).catch(() => {}) - } - return size - } - - const subscribeToBuffer = (targetTerm: Terminal): void => { - if (unsubStore) return - - const writeFromBuffer = (ptyBuffer: string[]): void => { - if (ptyBuffer.length < writtenChunksRef.current) { - // Buffer was trimmed past our cursor; replay everything we still have. - writtenChunksRef.current = 0 - } - const newChunks = ptyBuffer.slice(writtenChunksRef.current) - if (newChunks.length === 0) return - for (const chunk of newChunks) { - recordChunkEchoed(chunk) - if (predictiveEchoRef.current) { - // The engine owns pass-through to the live terminal and reconciles - // outstanding predictions against this confirmed output. - void predictiveEchoRef.current.onServerOutput(chunk) - } else { - targetTerm.write(chunk) - } - } - writtenChunksRef.current = ptyBuffer.length - } - - const bufferKey = getAgentKey(projectId, agentName!) - unsubStore = subscribePtyBuffer(bufferKey, writeFromBuffer) - writeFromBuffer(getPtyChunks(bufferKey)) - } - - const attachAndSeedTerminal = async ( - targetTerm: Terminal, - initialSize: TerminalSize | null - ): Promise => { - let shouldReplayBuffer = true - - try { - const result = await pear.broker.attachTerminal({ - projectId, - name: agentName!, - rows: initialSize?.rows, - cols: initialSize?.cols, - mode: terminalModeRef.current - }) - if (disposed) return - - if (result.snapshot?.screen && hasVisibleTerminalContent(result.snapshot.screen)) { - targetTerm.write(result.snapshot.screen) - // Prime the engine's confirmed-screen model with the same bytes (this - // does not re-write to the terminal) so its cursor matches the real - // screen before any prediction is made. - await predictiveEchoRef.current?.seed(result.snapshot.screen) - writtenChunksRef.current = useAgentStore.getState().getAgentBuffer(projectId, agentName!).length - shouldReplayBuffer = false - } - } catch (err) { - console.error('[terminal] attachTerminal failed:', err) - } - - if (disposed) return - - if (shouldReplayBuffer) { - writtenChunksRef.current = 0 - // No snapshot to prime from; mark the model seeded so predictions can - // engage. The buffer replay below feeds confirmed output through the - // engine, keeping the model in sync. - await predictiveEchoRef.current?.seed('') - } - - subscribeToBuffer(targetTerm) - } - - const init = (): void => { - if (disposed) return - if (!hasLayout(container)) { - requestAnimationFrame(init) - return - } - - term = new Terminal({ - theme: getXtermTheme(theme), - fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Menlo, monospace", - fontSize: 13, - lineHeight: 1.2, - letterSpacing: 0.5, - cursorBlink: true, - cursorStyle: 'bar', - scrollback: 3000, - fastScrollModifier: 'alt', - macOptionIsMeta: false, - allowProposedApi: true - }) - - fitAddon = new FitAddon() - term.loadAddon(fitAddon) - term.loadAddon(new WebLinksAddon()) - - // GPU-accelerated renderer: dramatically faster than the default DOM - // renderer that emits one element per cell. Fall back silently if the - // host can't initialize WebGL (Electron contexts without GL, headless - // CI, etc.) — xterm will keep using the DOM renderer in that case. - try { - const webgl = new WebglAddon() - webgl.onContextLoss(() => { - webgl.dispose() - }) - term.loadAddon(webgl) - } catch (err) { - console.warn('[terminal] WebGL renderer unavailable, falling back to DOM:', err) - } - - // Forward keystrokes + terminal protocol responses to PTY - term.onData((data) => { - sendInput(data) - }) - - term.open(container) - const initialSize = fitTerminal() - - // Mosh-style predictive local echo: optimistically renders printable - // keystrokes and reconciles against authoritative server output. Adaptive - // on measured latency, so it stays dormant (invisible) on fast local - // links and only engages when driving a high-latency / remote agent. - const liveTerm = term - const predictiveEcho = createPredictiveEcho({ - write: (data) => liveTerm.write(data), - cols: term.cols, - rows: term.rows, - getInputSrtt: () => inputSrttMs + if (requireActive && !activeRef.current) return + container.focus({ preventScroll: true }) + term.textarea?.focus({ preventScroll: true }) + term.focus() }) - predictiveEchoRef.current = predictiveEcho.engine - disposePredictiveEcho = predictiveEcho.dispose - - // Keep the SRTT estimate warm so prediction engages promptly. Refresh - // immediately, then on an interval; failures leave the last value intact. - const refreshSrtt = (): void => { - pear.broker - .inputSrtt(projectId, agentName!) - .then((srtt) => { - if (!disposed) inputSrttMs = srtt - }) - .catch(() => {}) - } - refreshSrtt() - srttPoll = setInterval(refreshSrtt, 1000) - - void attachAndSeedTerminal(term, initialSize) - - termRef.current = term - fitAddonRef.current = fitAddon + } + // Mount into the visible container if we have layout. If not yet, + // we still call mount() so the runtime can defer its init() to the + // first frame with layout. + if (containerEl) { + runtime.mount(containerEl) focusTerminal(true) - - // Spawn dialogs and pane layout updates can steal focus immediately after - // mount. Retry a few times so the xterm textarea reliably becomes active. - const focusTimers = [0, 50, 150, 300].map((delay) => + focusTimers = [0, 50, 150, 300].map((delay) => setTimeout(() => focusTerminal(true), delay) ) - - resizeObserver = new ResizeObserver(() => safeFitAndSync()) - resizeObserver.observe(container) - - // The PTY starts at a default size before the terminal connects. - // Bounce the size to force a SIGWINCH so the running process redraws - // at the correct dimensions. - const bounceTimer = setTimeout(() => { - if (!term || !fitAddon || !hasLayout(container)) return - try { - fitAddon.fit() - } catch { - return - } - const { rows, cols } = term - if (rows > 1 && cols > 0) { - pear.broker.resizePty(projectId, agentName!, rows - 1, cols).then(() => { - pear.broker.resizePty(projectId, agentName!, rows, cols) - }).catch(() => {}) - } - }, 200) - cleanupBounce = () => { - clearTimeout(bounceTimer) - for (const timer of focusTimers) { - clearTimeout(timer) - } - } } - requestAnimationFrame(init) - - // Click-to-focus - const handlePointerDown = (): void => { - focusTerminal() - } - - const handleBlur = (): void => { - if (typingActiveRef.current) { - typingActiveRef.current = false - void onAutoHoldReleaseRef.current?.(false) - } - } - - const handleKeyDown = (event: KeyboardEvent): void => { - if (event.isComposing || event.target === term?.textarea) { - return - } - - const data = getKeyboardInput(event) - if (!data) return - - event.preventDefault() - event.stopPropagation() - sendInput(data) - focusTerminal() + // Keep the SRTT estimate warm so prediction engages promptly. Refresh + // immediately, then on an interval; failures leave the last value intact. + const refreshSrtt = (): void => { + pear.broker + .inputSrtt(projectId, agentName) + .then((srtt) => { + if (!disposed) inputSrttRef.current = srtt + }) + .catch(() => {}) } - - const handlePaste = (event: ClipboardEvent): void => { - if (document.activeElement === term?.textarea) { - return - } - - const text = event.clipboardData?.getData('text') - if (!text) return - - event.preventDefault() - event.stopPropagation() - sendInput(text) - focusTerminal() + refreshSrtt() + srttPoll = setInterval(refreshSrtt, 1000) + + // Trailing-debounced refit. The raw ResizeObserver fires per entry on + // every allotment drag, including 0×0 intermediate states. Refitting + // on a 0×0 box leaks bad metrics into xterm and forces a fix-up later + // — gate on a real box and debounce. + if (containerEl) { + resizeObserver = new ResizeObserver((entries) => { + const entry = entries[0] + if (!entry) return + const { width, height } = entry.contentRect + if (width === 0 || height === 0) return + if (resizeDebounceTimer) clearTimeout(resizeDebounceTimer) + resizeDebounceTimer = setTimeout(() => { + resizeDebounceTimer = null + if (disposed) return + const term = runtime.term + const wasPinned = term ? isViewportPinnedToBottom(term) : false + runtime.fitAndSync() + if (wasPinned && term) { + term.scrollToBottom() + } + }, 75) + }) + resizeObserver.observe(containerEl) } - container.addEventListener('pointerdown', handlePointerDown) - container.addEventListener('keydown', handleKeyDown) - container.addEventListener('paste', handlePaste) - container.addEventListener('blur', handleBlur, true) - return () => { disposed = true - cleanupBounce?.() - unsubStore?.() - container.removeEventListener('pointerdown', handlePointerDown) - container.removeEventListener('keydown', handleKeyDown) - container.removeEventListener('paste', handlePaste) - container.removeEventListener('blur', handleBlur, true) + runtime.setOnData(null) + for (const timer of focusTimers) clearTimeout(timer) + if (resizeDebounceTimer) clearTimeout(resizeDebounceTimer) resizeObserver?.disconnect() if (srttPoll) clearInterval(srttPoll) - disposePredictiveEcho?.() - predictiveEchoRef.current = null - term?.dispose() - termRef.current = null - fitAddonRef.current = null - writtenChunksRef.current = 0 + + // Don't dispose the runtime — detach so xterm + subscription survive + // the React unmount. The runtime is only torn down when the agent + // itself goes away (see effect below). + runtime.detach() } }, [containerRef, agentName, projectId, sendInput]) + // Dispose the runtime when its owning agent is no longer in the store. + // Tab switches null-out agentName without removing the agent — we should + // keep the runtime around in that case. But when the agent is actually + // released (closed, removed, etc.) we should free GPU resources. useEffect(() => { - if (termRef.current) { - termRef.current.options.theme = getXtermTheme(theme) + return () => { + // On unmount, check whether the agent has actually been removed + // from the store; if so, dispose. Otherwise leave the runtime + // parked for future remounts. + if (!agentName) return + const key = getAgentKey(projectId, agentName) + const stillExists = useAgentStore + .getState() + .agents.some((a) => getAgentKey(a.projectId, a.name) === key) + if (!stillExists) { + disposeTerminalRuntime(key) + } } + }, [agentName, projectId]) + + useEffect(() => { + runtimeRef.current?.setTheme(theme) }, [theme]) useEffect(() => { - if (!visible || !termRef.current || !fitAddonRef.current) return + const runtime = runtimeRef.current + if (!visible || !runtime) return const container = containerRef.current if (!container || !hasLayout(container)) return try { - fitAddonRef.current.fit() - const { rows, cols } = termRef.current - if (rows > 0 && cols > 0 && agentName) { - predictiveEchoRef.current?.onResize(cols, rows) - pear.broker.resizePty(projectId, agentName, rows, cols) - } + const wasPinned = isViewportPinnedToBottom(runtime.term) + runtime.fitAndSync() + if (wasPinned) runtime.term.scrollToBottom() } catch { // ignore } if (!active) return - const timer = setTimeout(() => termRef.current?.focus(), 50) + const timer = setTimeout(() => runtimeRef.current?.term.focus(), 50) return () => clearTimeout(timer) - }, [visible, active, agentName, projectId]) + }, [visible, active, agentName, projectId, containerRef]) useEffect(() => { if (!visible || !active) return const handleWindowFocus = (): void => { - setTimeout(() => termRef.current?.focus(), 50) + setTimeout(() => runtimeRef.current?.term.focus(), 50) } window.addEventListener('focus', handleWindowFocus) return () => window.removeEventListener('focus', handleWindowFocus) @@ -563,7 +331,7 @@ export function useTerminal( const container = containerRef.current const handleGlobalKeyDown = (event: KeyboardEvent): void => { - const term = termRef.current + const term = runtimeRef.current?.term if (!term || event.isComposing) { return } @@ -587,7 +355,7 @@ export function useTerminal( } const handleGlobalPaste = (event: ClipboardEvent): void => { - const term = termRef.current + const term = runtimeRef.current?.term if (!term) { return } @@ -619,5 +387,81 @@ export function useTerminal( } }, [visible, active, terminalMode, agentName, projectId, activeDialog, containerRef, sendInput]) - return termRef.current + // Keyboard handlers attached directly to the container element. These + // were previously inside the mount effect; pulling them out keeps the + // runtime acquisition simple and lets them piggyback on agent/projectId + // identity without re-running the heavy effect. + useEffect(() => { + const container = containerRef.current + if (!container || !agentName) return + + const handlePointerDown = (): void => { + const term = runtimeRef.current?.term + if (!term) return + requestAnimationFrame(() => { + container.focus({ preventScroll: true }) + term.textarea?.focus({ preventScroll: true }) + term.focus() + }) + } + + const handleBlur = (): void => { + if (typingActiveRef.current) { + typingActiveRef.current = false + void onAutoHoldReleaseRef.current?.(false) + } + } + + const handleKeyDown = (event: KeyboardEvent): void => { + const term = runtimeRef.current?.term + if (event.isComposing || event.target === term?.textarea) { + return + } + + const data = getKeyboardInput(event) + if (!data) return + + event.preventDefault() + event.stopPropagation() + sendInput(data) + requestAnimationFrame(() => { + container.focus({ preventScroll: true }) + term?.textarea?.focus({ preventScroll: true }) + term?.focus() + }) + } + + const handlePaste = (event: ClipboardEvent): void => { + const term = runtimeRef.current?.term + if (document.activeElement === term?.textarea) { + return + } + + const text = event.clipboardData?.getData('text') + if (!text) return + + event.preventDefault() + event.stopPropagation() + sendInput(text) + requestAnimationFrame(() => { + container.focus({ preventScroll: true }) + term?.textarea?.focus({ preventScroll: true }) + term?.focus() + }) + } + + container.addEventListener('pointerdown', handlePointerDown) + container.addEventListener('keydown', handleKeyDown) + container.addEventListener('paste', handlePaste) + container.addEventListener('blur', handleBlur, true) + + return () => { + container.removeEventListener('pointerdown', handlePointerDown) + container.removeEventListener('keydown', handleKeyDown) + container.removeEventListener('paste', handlePaste) + container.removeEventListener('blur', handleBlur, true) + } + }, [containerRef, agentName, sendInput]) + + return runtimeRef.current?.term ?? null } diff --git a/src/renderer/src/lib/terminal-runtime-registry.ts b/src/renderer/src/lib/terminal-runtime-registry.ts new file mode 100644 index 00000000..5ed9bf3c --- /dev/null +++ b/src/renderer/src/lib/terminal-runtime-registry.ts @@ -0,0 +1,506 @@ +// Module-level registry of long-lived xterm runtimes, keyed by agent. +// +// Background: previously the `Terminal` instance lived inside a React +// useEffect. Every tab switch unmounted and remounted the host component, +// which tore down xterm and re-attached + replayed the chunk buffer. While +// the new mount was replaying, the broker kept streaming more bytes into +// the snapshot pipeline — those bytes would be written *again* on the next +// frame, producing the "duplicate text" the user reported. +// +// The fix is to decouple the xterm lifecycle from React: each agent gets a +// runtime that owns its `Terminal`, its addons, its PTY subscription, and +// its parked DOM host. React `mount(container)` / `detach()` calls just +// reparent the host element; xterm never tears down until the agent is +// fully released. +// +// Model is based on superset-sh/superset's `terminal-runtime-registry.ts`. + +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import { WebLinksAddon } from '@xterm/addon-web-links' +import { WebglAddon } from '@xterm/addon-webgl' +import { pear, type TerminalAttachMode } from '@/lib/ipc' +import { getAgentKey } from '@/stores/agent-store' +import { getPtyChunks, subscribePtyBuffer } from '@/stores/pty-buffer-store' +import { recordChunkEchoed } from '@/lib/typing-trace' +import { createPredictiveEcho } from '@/lib/predictive-echo' +import type { PredictiveEcho } from '@agent-relay/harness-driver/predictive-echo' +import { awaitFontSettle } from '@/lib/font-settle' +import type { Theme } from '@/stores/ui-store' + +const DARK_THEME = { + background: '#0b1017', + foreground: '#d7e0ea', + cursor: '#74b8e2', + selectionBackground: '#203247', + black: '#121a24', + red: '#f0727f', + green: '#6bd4bc', + yellow: '#e6d78d', + blue: '#74b8e2', + magenta: '#c9a7ff', + cyan: '#04d1f6', + white: '#d7e0ea', + brightBlack: '#64707d', + brightRed: '#ff8a96', + brightGreen: '#89e4cb', + brightYellow: '#f1e5a7', + brightBlue: '#94cbef', + brightMagenta: '#dcc6ff', + brightCyan: '#6fe7ff', + brightWhite: '#edf4fb' +} + +const LIGHT_THEME = { + background: '#f7fafc', + foreground: '#111827', + cursor: '#4a90c2', + selectionBackground: '#d7e7f4', + black: '#111827', + red: '#d95b63', + green: '#2e9f92', + yellow: '#c89934', + blue: '#4a90c2', + magenta: '#8b72d8', + cyan: '#2e9f92', + white: '#f7fafc', + brightBlack: '#6b7280', + brightRed: '#ea717a', + brightGreen: '#4fb4a7', + brightYellow: '#d8ac4f', + brightBlue: '#6aa7d2', + brightMagenta: '#a28ae7', + brightCyan: '#4fbab0', + brightWhite: '#ffffff' +} + +export function getXtermTheme(theme: Theme): typeof DARK_THEME { + return theme === 'light' ? LIGHT_THEME : DARK_THEME +} + +const TERMINAL_FONT_FAMILY = + "'JetBrains Mono', 'Fira Code', 'SF Mono', Menlo, monospace" + +// Default-on, demoted to DOM after the first webgl failure for the rest of +// the session. We don't recover: if webgl construction blew up once we +// assume the context is unhealthy. +let suggestedRenderer: 'webgl' | 'dom' = 'webgl' + +function hasLayout(el: HTMLElement): boolean { + return el.clientWidth > 0 && el.clientHeight > 0 +} + +function isViewportPinnedToBottom(term: Terminal): boolean { + const buffer = term.buffer.active + return buffer.viewportY === buffer.baseY +} + +// Off-DOM parking area for detached runtime hosts. We need them in the +// document so xterm's internal measurements stay valid, but invisible and +// non-interactive while their owning React component is unmounted. +let parkedContainer: HTMLDivElement | null = null + +function getParkedContainer(): HTMLDivElement { + if (parkedContainer && parkedContainer.isConnected) return parkedContainer + const node = document.createElement('div') + node.setAttribute('data-pear-terminal-park', 'true') + node.style.position = 'absolute' + node.style.width = '0' + node.style.height = '0' + node.style.overflow = 'hidden' + node.style.pointerEvents = 'none' + node.style.visibility = 'hidden' + node.setAttribute('aria-hidden', 'true') + document.body.appendChild(node) + parkedContainer = node + return node +} + +export interface TerminalRuntime { + readonly key: string + readonly term: Terminal + readonly host: HTMLDivElement + mount(container: HTMLElement): void + detach(): void + dispose(): void + isMounted(): boolean + setTheme(theme: Theme): void + setTerminalMode(mode: TerminalAttachMode): void + getTerminalMode(): TerminalAttachMode + fit(): { rows: number; cols: number } | null + fitAndSync(): { rows: number; cols: number } | null + getPredictiveEcho(): PredictiveEcho | null + // Install a handler for `term.onData`. Returns the previous handler so the + // caller can re-install it later (e.g. on unmount while keeping the + // runtime alive). The runtime forwards via an internal mutable slot, so + // setting null disables forwarding without tearing down the xterm + // listener. + setOnData(handler: ((data: string) => void) | null): void +} + +interface AcquireOptions { + projectId: string | undefined + agentName: string + terminalMode: TerminalAttachMode + theme: Theme + getInputSrtt: () => number | null +} + +interface RuntimeRecord { + runtime: TerminalRuntime + refCount: number +} + +const runtimes = new Map() + +export function acquireTerminalRuntime(opts: AcquireOptions): TerminalRuntime { + const key = getAgentKey(opts.projectId, opts.agentName) + const existing = runtimes.get(key) + if (existing) { + existing.refCount += 1 + existing.runtime.setTheme(opts.theme) + existing.runtime.setTerminalMode(opts.terminalMode) + return existing.runtime + } + const runtime = createRuntime(key, opts) + runtimes.set(key, { runtime, refCount: 1 }) + return runtime +} + +export function releaseTerminalRuntime(key: string, dispose = false): void { + const record = runtimes.get(key) + if (!record) return + record.refCount = Math.max(0, record.refCount - 1) + if (dispose) { + runtimes.delete(key) + record.runtime.dispose() + return + } + // Reference counting is just bookkeeping; runtime stays alive until the + // caller explicitly disposes (typically when the agent itself goes away). +} + +export function disposeTerminalRuntime(key: string): void { + const record = runtimes.get(key) + if (!record) return + runtimes.delete(key) + record.runtime.dispose() +} + +export function hasTerminalRuntime(key: string): boolean { + return runtimes.has(key) +} + +function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { + const host = document.createElement('div') + host.setAttribute('data-pear-terminal-runtime', key) + host.style.width = '100%' + host.style.height = '100%' + // Park immediately so xterm can attach without React having to provide a + // container on the first frame. + getParkedContainer().appendChild(host) + + let term: Terminal | null = new Terminal({ + theme: getXtermTheme(opts.theme), + fontFamily: TERMINAL_FONT_FAMILY, + fontSize: 13, + lineHeight: 1.2, + letterSpacing: 0.5, + cursorBlink: true, + cursorStyle: 'bar', + scrollback: 3000, + fastScrollModifier: 'alt', + macOptionIsMeta: false, + allowProposedApi: true + }) + + const fitAddon = new FitAddon() + term.loadAddon(fitAddon) + term.loadAddon(new WebLinksAddon()) + + let onDataHandler: ((data: string) => void) | null = null + term.onData((data) => { + onDataHandler?.(data) + }) + + // Track current attach mode + theme so re-acquires can update without + // re-creating the runtime. + let currentMode: TerminalAttachMode = opts.terminalMode + let currentTheme: Theme = opts.theme + let disposed = false + let mounted = false + let webglAddon: WebglAddon | null = null + let predictiveEcho: PredictiveEcho | null = null + let disposePredictiveEcho: (() => void) | null = null + let unsubBuffer: (() => void) | null = null + let writtenChunks = 0 + let attachSeeded = false + + // xterm has only ever been opened into `host`. React containers come and + // go, but the `host` div is the immutable parent of the xterm canvas. + // Reparenting `host` between containers (in mount/detach) keeps xterm + // measuring against the same DOM node it was opened with. + let opened = false + const openOnce = (): void => { + if (!term || opened) return + if (!hasLayout(host)) return + term.open(host) + opened = true + } + + const tryFit = (): { rows: number; cols: number } | null => { + if (!term) return null + const container = host + if (!hasLayout(container)) return null + try { + fitAddon.fit() + } catch { + return null + } + const { rows, cols } = term + if (rows > 0 && cols > 0) { + return { rows, cols } + } + return null + } + + // Lazy-load WebGL on the next frame so the terminal opens with the DOM + // renderer first (avoiding a hard sync boot on the GPU path) and upgrades + // only after the first frame paints. If WebGL fails for any reason we + // demote the whole session to the DOM renderer. + const loadWebglOnNextFrame = (): void => { + if (suggestedRenderer === 'dom' || !term) return + requestAnimationFrame(() => { + if (!term || disposed || webglAddon) return + try { + const addon = new WebglAddon() + addon.onContextLoss(() => { + suggestedRenderer = 'dom' + try { + addon.dispose() + } catch { + // ignore + } + if (webglAddon === addon) webglAddon = null + }) + term.loadAddon(addon) + webglAddon = addon + } catch (err) { + console.warn('[terminal] WebGL renderer unavailable, falling back to DOM:', err) + suggestedRenderer = 'dom' + } + }) + } + + const seedBufferSubscription = (): void => { + if (unsubBuffer || !term || disposed) return + const liveTerm = term + + const writeFromBuffer = (ptyBuffer: string[]): void => { + if (ptyBuffer.length < writtenChunks) { + writtenChunks = 0 + } + const newChunks = ptyBuffer.slice(writtenChunks) + if (newChunks.length === 0) return + const wasPinned = isViewportPinnedToBottom(liveTerm) + for (const chunk of newChunks) { + recordChunkEchoed(chunk) + if (predictiveEcho) { + void predictiveEcho.onServerOutput(chunk) + } else { + liveTerm.write(chunk) + } + } + writtenChunks = ptyBuffer.length + if (wasPinned) liveTerm.scrollToBottom() + } + + unsubBuffer = subscribePtyBuffer(key, writeFromBuffer) + writeFromBuffer(getPtyChunks(key)) + } + + const attachAndSeed = async ( + initialSize: { rows: number; cols: number } | null + ): Promise => { + if (!term || disposed || attachSeeded) return + attachSeeded = true + + let shouldReplay = true + try { + const result = await pear.broker.attachTerminal({ + projectId: opts.projectId, + name: opts.agentName, + rows: initialSize?.rows, + cols: initialSize?.cols, + mode: currentMode + }) + if (disposed || !term) return + if ( + result.snapshot?.screen && + hasVisibleTerminalContent(result.snapshot.screen) + ) { + term.write(result.snapshot.screen) + await predictiveEcho?.seed(result.snapshot.screen) + writtenChunks = getPtyChunks(key).length + shouldReplay = false + } + } catch (err) { + console.error('[terminal] attachTerminal failed:', err) + } + + if (disposed || !term) return + + if (shouldReplay) { + writtenChunks = 0 + await predictiveEcho?.seed('') + } + + seedBufferSubscription() + } + + // Initial open into the parked host. We need the host in the document + // for xterm's renderers to measure, but layout() inside the parked area + // returns 0×0. We defer the actual open() + size sync to the first + // mount() that has real layout. + // However, xterm's loadAddon(WebglAddon) needs the renderer running and + // wants the terminal opened first; we therefore lazy-init the + // GPU/DOM-bound bits in initIfReady, called from mount(). + + const initIfReady = async (container: HTMLElement): Promise => { + if (!term || disposed) return + if (!hasLayout(container)) return + + openOnce() + let initialSize = tryFit() + + // Spin up predictive echo and SRTT once we have real measurements. + if (!predictiveEcho) { + const liveTerm = term + const handle = createPredictiveEcho({ + write: (data) => liveTerm.write(data), + cols: term.cols, + rows: term.rows, + getInputSrtt: opts.getInputSrtt + }) + predictiveEcho = handle.engine + disposePredictiveEcho = handle.dispose + } + + loadWebglOnNextFrame() + + // Wait for the actual font to load before locking in cell metrics. + // If JetBrains Mono lands later the fallback measurement is wrong and + // glyphs appear smeared until the next resize. + await awaitFontSettle(TERMINAL_FONT_FAMILY) + if (disposed || !term) return + + const refitted = tryFit() + if (refitted) { + try { + term.refresh(0, term.rows - 1) + } catch { + // ignore + } + initialSize = refitted + } + + if (!attachSeeded) { + void attachAndSeed(initialSize) + } + } + + const runtime: TerminalRuntime = { + key, + get term() { + // We expose `term` as non-null since callers only interact with the + // runtime while it's alive; dispose() flips a flag and clears it + // immediately after. + return term as Terminal + }, + host, + mount(container: HTMLElement): void { + if (disposed || !term) return + if (container === host.parentElement && mounted) return + if (host.parentElement !== container) { + container.appendChild(host) + } + mounted = true + void initIfReady(container) + }, + detach(): void { + if (disposed) return + mounted = false + const park = getParkedContainer() + if (host.parentElement !== park) { + park.appendChild(host) + } + }, + dispose(): void { + if (disposed) return + disposed = true + mounted = false + unsubBuffer?.() + unsubBuffer = null + disposePredictiveEcho?.() + disposePredictiveEcho = null + predictiveEcho = null + try { + webglAddon?.dispose() + } catch { + // ignore + } + webglAddon = null + try { + term?.dispose() + } catch { + // ignore + } + term = null + if (host.parentElement) { + host.parentElement.removeChild(host) + } + }, + isMounted(): boolean { + return mounted + }, + setTheme(theme: Theme): void { + if (!term) return + if (currentTheme === theme) return + currentTheme = theme + term.options.theme = getXtermTheme(theme) + }, + setTerminalMode(mode: TerminalAttachMode): void { + currentMode = mode + }, + getTerminalMode(): TerminalAttachMode { + return currentMode + }, + fit(): { rows: number; cols: number } | null { + return tryFit() + }, + fitAndSync(): { rows: number; cols: number } | null { + const size = tryFit() + if (size) { + predictiveEcho?.onResize(size.cols, size.rows) + pear.broker.resizePty(opts.projectId, opts.agentName, size.rows, size.cols).catch(() => {}) + } + return size + }, + getPredictiveEcho(): PredictiveEcho | null { + return predictiveEcho + }, + setOnData(handler: ((data: string) => void) | null): void { + onDataHandler = handler + } + } + + return runtime +} + +function hasVisibleTerminalContent(screen: string): boolean { + const stripped = screen.replace( + /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[@-Z\\-_])/g, + '' + ) + return /\S/.test(stripped) +} From c4eadce121bca3151fce77f01765d90fe6cef8cd Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 08:55:51 +0200 Subject: [PATCH 08/35] fix(terminal): drop translateX slide animation over WebGL canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The split-mode page container wrapped each SplitTerminalPage in an absolute-positioned div animated via transform: translateX(). Sliding the WebGL canvas this way produces visible ghosting and "scroll trail" trails during streaming output. Swap the transform for plain display: visible ? 'block' : 'none' — page nav buttons and indicator pills still work without the animation. PR #142's tab-mode opacity fade is unaffected (different code path, doesn't animate the canvas). Co-Authored-By: Claude Opus 4.7 --- src/renderer/src/components/terminal/TerminalPane.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/terminal/TerminalPane.tsx b/src/renderer/src/components/terminal/TerminalPane.tsx index 157b3c94..deda0ccf 100644 --- a/src/renderer/src/components/terminal/TerminalPane.tsx +++ b/src/renderer/src/components/terminal/TerminalPane.tsx @@ -1005,11 +1005,16 @@ export function TerminalPane(): React.ReactNode {
{splitPages.map((pageAgents, pageIndex) => { const visible = pageIndex === splitPage + // Hide non-visible pages with `display: none` rather than the + // translateX slide that used to live here. Sliding the WebGL + // canvas via CSS transform produces ghosting / scroll-trail + // artifacts during streaming. The page indicator + nav buttons + // below still work without the animation. return (
Date: Mon, 8 Jun 2026 08:50:54 +0200 Subject: [PATCH 09/35] chat: trust message id for dedupe in ChatView Drop the 10s content-window heuristic (`dedupeHumanMessages` / `areDuplicateHumanMessages`). Each chat message already arrives with a unique broker event_id and the agent store reconciles by that id, so the ChatView memo just needs to scope by channel/DM. The heuristic could let duplicates slip through outside the window with mismatched routing and could collapse legitimately distinct human messages with identical bodies. Co-Authored-By: Claude Opus 4.7 --- src/renderer/src/components/chat/ChatView.tsx | 67 +++---------------- 1 file changed, 11 insertions(+), 56 deletions(-) diff --git a/src/renderer/src/components/chat/ChatView.tsx b/src/renderer/src/components/chat/ChatView.tsx index 6c08ce8f..c5179eb4 100644 --- a/src/renderer/src/components/chat/ChatView.tsx +++ b/src/renderer/src/components/chat/ChatView.tsx @@ -38,54 +38,6 @@ function isChannelMessage(message: ChatMessageType, channelName: string): boolea return normalizeMessageChannel(message.to) === channelName } -function isHumanMessage(message: ChatMessageType): boolean { - return message.isHuman || message.from.trim().toLowerCase() === 'human' -} - -function areDuplicateHumanMessages(left: ChatMessageType, right: ChatMessageType): boolean { - return isHumanMessage(left) && - isHumanMessage(right) && - left.body === right.body && - (!left.projectId || !right.projectId || left.projectId === right.projectId) && - normalizeMessageChannel(left.to) === normalizeMessageChannel(right.to) && - Math.abs(left.timestamp - right.timestamp) < 10_000 -} - -function dedupeHumanMessages(messages: ChatMessageType[]): ChatMessageType[] { - const deduped: ChatMessageType[] = [] - // Collapsing near-simultaneous human echoes used to be O(n²): a findIndex over - // the whole deduped list per message, so the cost grew with chat length and - // ran on every new message (via the messages useMemo). Track the most recently - // kept human message per `${to}\0${body}` key instead, making each message O(1). - // Only human messages dedupe against each other, and duplicates fall inside a - // 10s window, so the latest kept entry for a key is the only one a new message - // can collide with — the result is identical to the previous scan. - const lastHumanIndexByKey = new Map() - - for (const message of messages) { - if (!isHumanMessage(message)) { - deduped.push(message) - continue - } - - const key = `${normalizeMessageChannel(message.to)}\0${message.body}` - const priorIndex = lastHumanIndexByKey.get(key) - const prior = priorIndex !== undefined ? deduped[priorIndex] : undefined - - if (prior && areDuplicateHumanMessages(prior, message)) { - if (message.isHuman && !prior.isHuman) { - deduped[priorIndex!] = message - } - continue - } - - deduped.push(message) - lastHumanIndexByKey.set(key, deduped.length - 1) - } - - return deduped -} - function isSameDay(left: number, right: number): boolean { const leftDate = new Date(left) const rightDate = new Date(right) @@ -396,15 +348,18 @@ export function ChatView(): React.ReactNode { : allMessages, [activeProjectId, allMessages] ) + // The store is the source of truth for message identity — each chat message + // arrives with a unique `id` (broker event_id) and the store's + // reconcileChatMessages / isDuplicateHumanEcho paths handle dedupe on insert. + // Trust those ids here and just scope to the current channel/DM so we don't + // re-run a content+timestamp heuristic on every render that could collapse + // legitimately distinct messages (or miss duplicates outside the 10s window). const messages = useMemo( - () => { - const scopedMessages = directMessageParticipants - ? projectMessages.filter((message) => messageMatchesDirectMessageRoom(message, directMessageParticipants)) - : activeChannelName - ? projectMessages.filter((message) => isChannelMessage(message, activeChannelName)) - : projectMessages - return dedupeHumanMessages(scopedMessages) - }, + () => directMessageParticipants + ? projectMessages.filter((message) => messageMatchesDirectMessageRoom(message, directMessageParticipants)) + : activeChannelName + ? projectMessages.filter((message) => isChannelMessage(message, activeChannelName)) + : projectMessages, [activeChannelName, directMessageParticipants, projectMessages] ) const agents = useMemo( From 22601b976d3fbab7388b38369ba094aea2dcc5c4 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 08:52:34 +0200 Subject: [PATCH 10/35] chat: use use-stick-to-bottom and overflow-anchor:none in ChatView Replace the manual scrollTop = scrollHeight effect that ran on every messages.length change with useStickToBottom's ResizeObserver-driven sticky behaviour. The old effect fought the browser's scroll anchoring and yanked the viewport mid-scroll during streaming. The new wiring keeps the user at the bottom only while they were already at the bottom and instantly jumps on channel/DM switch via scrollToBottom('instant'). Adds [overflow-anchor:none] to the inner content container per the lib's recommended setup, and stabilises the onReply/onReact callbacks with useCallback so memoised ChatMessage children won't re-render on every parent tick (paired with item #7). Co-Authored-By: Claude Opus 4.7 --- package-lock.json | 16 ++++++++ package.json | 1 + src/renderer/src/components/chat/ChatView.tsx | 40 ++++++++++++++----- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9a88ba8b..3454f907 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "react-dom": "^19.0.0", "shiki": "^4.0.2", "unidiff": "^1.0.4", + "use-stick-to-bottom": "^1.1.6", "zod": "^3.25.76", "zustand": "^5.0.0" }, @@ -13664,6 +13665,21 @@ "punycode": "^2.1.0" } }, + "node_modules/use-stick-to-bottom": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/use-stick-to-bottom/-/use-stick-to-bottom-1.1.6.tgz", + "integrity": "sha512-z3Up8jYQGTkUCsGBnwg6/wj70KgXoW5Kz1AAc1j8MtQuYMBo6ZsdhrIXoegxa7gaMMilgQYyTohTrt3p94jHog==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/samdenty" + } + ], + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", diff --git a/package.json b/package.json index d4fa8358..f397e20c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "react-dom": "^19.0.0", "shiki": "^4.0.2", "unidiff": "^1.0.4", + "use-stick-to-bottom": "^1.1.6", "zod": "^3.25.76", "zustand": "^5.0.0" }, diff --git a/src/renderer/src/components/chat/ChatView.tsx b/src/renderer/src/components/chat/ChatView.tsx index c5179eb4..86692fe9 100644 --- a/src/renderer/src/components/chat/ChatView.tsx +++ b/src/renderer/src/components/chat/ChatView.tsx @@ -1,5 +1,6 @@ import type React from 'react' -import { Fragment, useEffect, useMemo, useRef, useState } from 'react' +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useStickToBottom } from 'use-stick-to-bottom' import { Bot, Check, @@ -331,7 +332,15 @@ export function ChatView(): React.ReactNode { ? isDirectMessageRoomHumanIncluded(directMessageParticipants) : false const directMessageReadOnly = Boolean(directMessageParticipants && !directMessageHumanIncluded) - const scrollRef = useRef(null) + // `use-stick-to-bottom` watches the content element via ResizeObserver and + // keeps the viewport pinned to the bottom while the user is at the bottom. + // This cooperates with the browser's overflow-anchor and avoids the manual + // scrollTop = scrollHeight effect that used to fight scroll anchoring and + // yank the viewport during streaming. + const { scrollRef, contentRef, scrollToBottom } = useStickToBottom({ + initial: 'instant', + resize: 'instant' + }) const preserveSettingsAfterRenameRef = useRef(false) const [activeThreadMessageId, setActiveThreadMessageId] = useState(null) const [activeTab, setActiveTab] = useState('messages') @@ -394,11 +403,13 @@ export function ChatView(): React.ReactNode { setSettingsError(null) }, [activeChannelName, directMessageParticipants]) + // Channel/DM switch should jump to bottom instantly. Streaming/append + // behaviour is handled by useStickToBottom's ResizeObserver, so we don't + // need to react to messages.length here anymore (which is what caused the + // "text drag" mid-scroll yanks). useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight - } - }, [activeChannelName, directMessageParticipants, messages.length]) + scrollToBottom('instant') + }, [activeChannelName, directMessageParticipants, scrollToBottom]) useEffect(() => { if (!activeThreadMessageId || activeThreadMessage) return @@ -426,6 +437,15 @@ export function ChatView(): React.ReactNode { setActiveThreadMessageId(null) }, [activeTab, directMessageReadOnly]) + // Stabilise the per-message callbacks so memoised ChatMessage children only + // re-render when their own props change (not on every parent re-render). + const handleReplyToMessage = useCallback((nextMessage: ChatMessageType) => { + setActiveThreadMessageId(nextMessage.id) + }, []) + const handleReactToMessage = useCallback((messageId: string, emoji: string) => { + toggleMessageReaction(messageId, emoji) + }, [toggleMessageReaction]) + const handleRenameChannel = async (event: React.FormEvent): Promise => { event.preventDefault() if (!activeChannelName) return @@ -576,7 +596,7 @@ export function ChatView(): React.ReactNode { {emptyMessage}
) : ( -
+
{messages.map((message, index) => { const previousMessage = messages[index - 1] const showDateDivider = !previousMessage || !isSameDay(previousMessage.timestamp, message.timestamp) @@ -591,10 +611,8 @@ export function ChatView(): React.ReactNode { showActions={canInteractWithMessages} showThreadSummary={canInteractWithMessages} activeThread={activeThreadMessageId === message.id} - onReply={canInteractWithMessages - ? (nextMessage) => setActiveThreadMessageId(nextMessage.id) - : undefined} - onReact={canInteractWithMessages ? toggleMessageReaction : undefined} + onReply={canInteractWithMessages ? handleReplyToMessage : undefined} + onReact={canInteractWithMessages ? handleReactToMessage : undefined} /> ) From 69a7e7fbb639c448dad0a268add3947b468e8476 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 08:54:22 +0200 Subject: [PATCH 11/35] chat: memoize ChatMessage and narrow agent lookup selector ChatMessage and ThreadParticipantAvatar used `useAgentStore((state) => state.agents.find(...))` inside every message component. Every PTY tick that touched the agents array re-ran the find and re-rendered every message even when the matched agent was unchanged. Add a cached `agents` -> Map lookup that only rebuilds when the agents array reference changes, exposed via a new `useAgentByName(projectId, name)` hook. The hook returns the Agent object reference, so Zustand's default Object.is comparison short- circuits the re-render when the specific agent hasn't changed. Migrate ChatMessage and ThreadParticipantAvatar to the new hook and wrap ChatMessage in React.memo. Combined with the useCallback callbacks in ChatView (item #6), memoised messages skip parent re-renders entirely during streaming. Co-Authored-By: Claude Opus 4.7 --- .../src/components/chat/ChatMessage.tsx | 26 +++++----- src/renderer/src/stores/agent-store.ts | 47 +++++++++++++++++++ 2 files changed, 58 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/chat/ChatMessage.tsx b/src/renderer/src/components/chat/ChatMessage.tsx index a2255018..c171c41b 100644 --- a/src/renderer/src/components/chat/ChatMessage.tsx +++ b/src/renderer/src/components/chat/ChatMessage.tsx @@ -1,11 +1,11 @@ import type React from 'react' -import { useState } from 'react' +import { memo, useState } from 'react' import { MessageCircle, SmilePlus } from 'lucide-react' import { AgentHarnessIcon } from '@/components/common/AgentIcons' import type { AuthUser } from '@/lib/ipc' import { renderChatMessageBody } from '@/lib/chat-formatting' import { formatClockTime, formatRelativeShort } from '@/lib/format' -import { useAgentStore } from '@/stores/agent-store' +import { useAgentByName } from '@/stores/agent-store' import type { ChatMessage as ChatMessageType, ChatThreadReply @@ -174,12 +174,7 @@ function ThreadParticipantAvatar({ participant: ChatThreadReply authUser?: AuthUser | null }): React.ReactNode { - const agent = useAgentStore((state) => - state.agents.find((candidate) => - candidate.name === participant.from && - (!participant.projectId || candidate.projectId === participant.projectId) - ) - ) + const agent = useAgentByName(participant.projectId, participant.from) if (participant.isHuman) { return ( @@ -246,7 +241,7 @@ function ThreadSummary({ ) } -export function ChatMessage({ +function ChatMessageInner({ message, showRoute = true, showActions = true, @@ -256,12 +251,7 @@ export function ChatMessage({ onReply, onReact }: Props): React.ReactNode { - const agent = useAgentStore((state) => - state.agents.find((candidate) => - candidate.name === message.from && - (!message.projectId || candidate.projectId === message.projectId) - ) - ) + const agent = useAgentByName(message.projectId, message.from) const color = message.isHuman ? 'var(--pear-accent-bright)' : getAgentColor(message.from) const reactions = message.reactions || [] const replies = message.threadReplies || [] @@ -364,3 +354,9 @@ export function ChatMessage({
) } + +// Default shallow prop comparison is enough here: `message` is referentially +// stable from the store (only mutated when the actual message record changes), +// `authUser` is stable across renders, and the boolean / callback props are +// stabilised by the parent. Memoising avoids a re-render per PTY tick. +export const ChatMessage = memo(ChatMessageInner) diff --git a/src/renderer/src/stores/agent-store.ts b/src/renderer/src/stores/agent-store.ts index 70e68e8b..1139955f 100644 --- a/src/renderer/src/stores/agent-store.ts +++ b/src/renderer/src/stores/agent-store.ts @@ -1156,3 +1156,50 @@ export const useAgentStore = create()(subscribeWithSelector((set, ge getAgentBuffer: (projectId, name) => getPtyChunks(getAgentKey(projectId, name)) }))) + +// Cache for the agents-by-(projectId, name) lookup map. Rebuilding it costs +// O(n) on every PTY tick if we use a useMemo or selector that touches the +// agents array, so we key it on the array reference (which only changes when +// the store actually mutates agents) and reuse the same Map across renders. +// Callers like ChatMessage / ThreadParticipantAvatar previously did +// `state.agents.find(...)` inside a Zustand selector, which made every +// message component re-render whenever the agents array changed (every PTY +// chunk that flips activity / currentState). +let agentMapCache: { source: Agent[]; map: Map } | null = null + +function getAgentLookup(agents: Agent[]): Map { + if (agentMapCache && agentMapCache.source === agents) return agentMapCache.map + + const map = new Map() + for (const agent of agents) { + map.set(getAgentKeyForAgent(agent), agent) + // Also key by name only so callers without a projectId can fall back to + // any matching agent — preserves the prior `agents.find` semantics for the + // few call sites where projectId is unknown. + const nameOnlyKey = `*:${agent.name}` + if (!map.has(nameOnlyKey)) map.set(nameOnlyKey, agent) + } + + agentMapCache = { source: agents, map } + return map +} + +/** + * Look up an agent by (projectId, name) using a cached map that only rebuilds + * when the agents array reference changes. The selector returns the agent + * object directly so components only re-render when *their* agent changes, + * not when any other agent's activity/state ticks. + */ +export function useAgentByName( + projectId: string | undefined, + name: string +): Agent | undefined { + return useAgentStore((state) => { + const lookup = getAgentLookup(state.agents) + if (projectId) { + const exact = lookup.get(getAgentKey(projectId, name)) + if (exact) return exact + } + return lookup.get(`*:${name}`) + }) +} From 903bdb93ab2d7f997a8c1bc55775658b15d6ddcd Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 08:58:30 +0200 Subject: [PATCH 12/35] perf(styles): drop stacked backdrop-filter blur on project switcher backdrop-filter: blur(18-22px) is one of the heaviest compositor effects in Electron, especially with three surfaces stacked over the workspace. The trigger is always-visible and contributes per-frame cost; the dropdown and panel are transient but still composite while the sidebar scrolls underneath them. Bump background opacity to 96-98% to preserve the surface character without paying the blur. --- src/renderer/src/styles.css | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index aa229253..8cd91606 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -76,11 +76,10 @@ .project-switcher-trigger { background: linear-gradient(180deg, - color-mix(in srgb, var(--pear-bg-surface) 82%, rgba(255, 255, 255, 0.05)) 0%, - color-mix(in srgb, var(--pear-bg-raised) 82%, var(--pear-bg) 18%) 100%); + color-mix(in srgb, var(--pear-bg-surface) 96%, rgba(255, 255, 255, 0.05)) 0%, + color-mix(in srgb, var(--pear-bg-raised) 96%, var(--pear-bg) 18%) 100%); border-bottom: 1px solid color-mix(in srgb, var(--pear-border) 74%, transparent); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045); - backdrop-filter: blur(18px) saturate(1.16); } .project-switcher-trigger:hover, @@ -101,13 +100,12 @@ color-mix(in srgb, var(--pear-accent) 12%, transparent), transparent 42%), linear-gradient(180deg, - color-mix(in srgb, var(--pear-bg-surface) 86%, rgba(255, 255, 255, 0.06)) 0%, - color-mix(in srgb, var(--pear-bg) 84%, var(--pear-bg-surface) 16%) 100%); + color-mix(in srgb, var(--pear-bg-surface) 98%, rgba(255, 255, 255, 0.06)) 0%, + color-mix(in srgb, var(--pear-bg) 98%, var(--pear-bg-surface) 16%) 100%); border-bottom: 1px solid color-mix(in srgb, var(--pear-border) 86%, transparent); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 18px 50px rgba(0, 0, 0, 0.35); - backdrop-filter: blur(22px) saturate(1.22); } .project-switcher-panel { @@ -116,10 +114,9 @@ color-mix(in srgb, var(--pear-accent) 8%, transparent), transparent 44%), linear-gradient(180deg, - color-mix(in srgb, var(--pear-bg-surface) 82%, rgba(255, 255, 255, 0.04)) 0%, - color-mix(in srgb, var(--pear-bg) 86%, var(--pear-bg-surface) 14%) 100%); + color-mix(in srgb, var(--pear-bg-surface) 98%, rgba(255, 255, 255, 0.04)) 0%, + color-mix(in srgb, var(--pear-bg) 98%, var(--pear-bg-surface) 14%) 100%); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); - backdrop-filter: blur(18px) saturate(1.14); } } From eb44e46be983a97989e10138f04db044cc4231e5 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 09:36:05 +0200 Subject: [PATCH 13/35] chat: id-based dedupe for relay_inbound; scope agent lookup to project - relay_inbound: skip append when message id already exists - useAgentByName: when projectId provided, only return exact match --- src/renderer/src/stores/agent-store.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/stores/agent-store.ts b/src/renderer/src/stores/agent-store.ts index 1139955f..199cf249 100644 --- a/src/renderer/src/stores/agent-store.ts +++ b/src/renderer/src/stores/agent-store.ts @@ -903,9 +903,12 @@ export const useAgentStore = create()(subscribeWithSelector((set, ge projectId } const targetName = eventTarget.startsWith('#') ? null : normalizeMessageTarget(eventTarget) - const messages = isHuman && isDuplicateHumanEcho(state.messages, msg) + const alreadySeenById = state.messages.some((m) => m.id === msg.id) + const messages = alreadySeenById ? state.messages - : capByCount([...state.messages, msg], MAX_CHAT_MESSAGES) + : isHuman && isDuplicateHumanEcho(state.messages, msg) + ? state.messages + : capByCount([...state.messages, msg], MAX_CHAT_MESSAGES) if (messages !== state.messages && isBrokerDebugEnabled()) { console.info('[broker:renderer-receipt]', { @@ -1196,9 +1199,8 @@ export function useAgentByName( ): Agent | undefined { return useAgentStore((state) => { const lookup = getAgentLookup(state.agents) - if (projectId) { - const exact = lookup.get(getAgentKey(projectId, name)) - if (exact) return exact + if (projectId !== undefined) { + return lookup.get(getAgentKey(projectId, name)) } return lookup.get(`*:${name}`) }) From 64b0c9f11e27f3053a452ff99ec86d9600be4e26 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 09:36:36 +0200 Subject: [PATCH 14/35] pty-buffer: isolate listener throws and snapshot Set before iteration - Wrap each listener call in try/catch with console.error. - Iterate a snapshot ([...keyListeners]) so unsubscribes during dispatch don't corrupt iteration. --- src/renderer/src/stores/pty-buffer-store.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/stores/pty-buffer-store.ts b/src/renderer/src/stores/pty-buffer-store.ts index 469547ec..e7f7e640 100644 --- a/src/renderer/src/stores/pty-buffer-store.ts +++ b/src/renderer/src/stores/pty-buffer-store.ts @@ -57,8 +57,12 @@ function flushPending(key: string): void { const keyListeners = listeners.get(key) if (!keyListeners || keyListeners.size === 0) return - for (const listener of keyListeners) { - listener(trimmed) + for (const listener of [...keyListeners]) { + try { + listener(trimmed) + } catch (err) { + console.error('[pty-buffer-store] listener threw', err) + } } } @@ -83,8 +87,12 @@ export function clearPtyBuffer(key: string): void { buffers.delete(key) const keyListeners = listeners.get(key) if (keyListeners) { - for (const listener of keyListeners) { - listener([]) + for (const listener of [...keyListeners]) { + try { + listener([]) + } catch (err) { + console.error('[pty-buffer-store] listener threw', err) + } } } } From 0b84af0b7e115630ac0e4ec1322161fdca70b756 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 09:38:59 +0200 Subject: [PATCH 15/35] terminal-runtime: init retries on no-layout; dispose drains pending flush Fix #1/#13: initIfReady schedules a requestAnimationFrame retry when the container has no layout (hidden tab, split page mounted at display:none). mount() now always invokes initIfReady regardless of prior parentage so the retry actually fires once the container becomes visible. Fix #6: dispose() cancels the init rAF and calls clearPtyBuffer(key) before flipping the disposed flag, so a queued rAF flush can't write into a torn-down xterm. writeFromBuffer also early-returns when disposed as belt-and-braces. --- .../src/lib/terminal-runtime-registry.ts | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/lib/terminal-runtime-registry.ts b/src/renderer/src/lib/terminal-runtime-registry.ts index 5ed9bf3c..1690221e 100644 --- a/src/renderer/src/lib/terminal-runtime-registry.ts +++ b/src/renderer/src/lib/terminal-runtime-registry.ts @@ -21,7 +21,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links' import { WebglAddon } from '@xterm/addon-webgl' import { pear, type TerminalAttachMode } from '@/lib/ipc' import { getAgentKey } from '@/stores/agent-store' -import { getPtyChunks, subscribePtyBuffer } from '@/stores/pty-buffer-store' +import { clearPtyBuffer, getPtyChunks, subscribePtyBuffer } from '@/stores/pty-buffer-store' import { recordChunkEchoed } from '@/lib/typing-trace' import { createPredictiveEcho } from '@/lib/predictive-echo' import type { PredictiveEcho } from '@agent-relay/harness-driver/predictive-echo' @@ -235,6 +235,14 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { let unsubBuffer: (() => void) | null = null let writtenChunks = 0 let attachSeeded = false + let pendingInitFrame: number | null = null + + const cancelPendingInit = (): void => { + if (pendingInitFrame !== null) { + cancelAnimationFrame(pendingInitFrame) + pendingInitFrame = null + } + } // xterm has only ever been opened into `host`. React containers come and // go, but the `host` div is the immutable parent of the xterm canvas. @@ -297,6 +305,7 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { const liveTerm = term const writeFromBuffer = (ptyBuffer: string[]): void => { + if (disposed || !term) return if (ptyBuffer.length < writtenChunks) { writtenChunks = 0 } @@ -368,7 +377,19 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { const initIfReady = async (container: HTMLElement): Promise => { if (!term || disposed) return - if (!hasLayout(container)) return + if (!hasLayout(container)) { + // Split-page / hidden-tab mount: the container is 0×0 right now + // (e.g. display:none). Schedule a retry next frame so we don't sit + // forever waiting for a mount() that never comes back. + if (pendingInitFrame !== null) return + pendingInitFrame = requestAnimationFrame(() => { + pendingInitFrame = null + if (disposed) return + void initIfReady(container) + }) + return + } + cancelPendingInit() openOnce() let initialSize = tryFit() @@ -420,11 +441,12 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { host, mount(container: HTMLElement): void { if (disposed || !term) return - if (container === host.parentElement && mounted) return if (host.parentElement !== container) { container.appendChild(host) } mounted = true + // Always run initIfReady so a split-page / hidden-tab mount that + // landed without layout gets a retry once it becomes visible. void initIfReady(container) }, detach(): void { @@ -437,6 +459,12 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { }, dispose(): void { if (disposed) return + // Cancel any rAF that would otherwise fire a flush into a disposed + // terminal. Do this BEFORE flipping `disposed`/nulling `term` so the + // writeFromBuffer notification triggered by clearPtyBuffer (with []) + // runs while the closure is still consistent. + cancelPendingInit() + clearPtyBuffer(key) disposed = true mounted = false unsubBuffer?.() From c3e9ec353b5d3bf2a9f46db25c1eb62d2b5acc93 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 09:40:08 +0200 Subject: [PATCH 16/35] terminal-runtime: drain staged pty chunks before snapshot baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix #2: chunks that arrived mid-IPC stay in pending until the next rAF. If we capture writtenChunks before that flush, the subsequent subscribe replays them on top of the snapshot we just wrote — duplicate text. Expose flushPtyChunksNow(key) that cancels the pending rAF and runs flushPending synchronously, then call it in attachAndSeed right before reading getPtyChunks(key).length. --- src/renderer/src/lib/terminal-runtime-registry.ts | 12 +++++++++++- src/renderer/src/stores/pty-buffer-store.ts | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/lib/terminal-runtime-registry.ts b/src/renderer/src/lib/terminal-runtime-registry.ts index 1690221e..6d3a3e0b 100644 --- a/src/renderer/src/lib/terminal-runtime-registry.ts +++ b/src/renderer/src/lib/terminal-runtime-registry.ts @@ -21,7 +21,12 @@ import { WebLinksAddon } from '@xterm/addon-web-links' import { WebglAddon } from '@xterm/addon-webgl' import { pear, type TerminalAttachMode } from '@/lib/ipc' import { getAgentKey } from '@/stores/agent-store' -import { clearPtyBuffer, getPtyChunks, subscribePtyBuffer } from '@/stores/pty-buffer-store' +import { + clearPtyBuffer, + flushPtyChunksNow, + getPtyChunks, + subscribePtyBuffer +} from '@/stores/pty-buffer-store' import { recordChunkEchoed } from '@/lib/typing-trace' import { createPredictiveEcho } from '@/lib/predictive-echo' import type { PredictiveEcho } from '@agent-relay/harness-driver/predictive-echo' @@ -350,6 +355,11 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { ) { term.write(result.snapshot.screen) await predictiveEcho?.seed(result.snapshot.screen) + // Drain any chunks that arrived during the IPC roundtrip but are + // still staged in pending. Without this, the next rAF would push + // them into the buffer AFTER we capture writtenChunks, and the + // subsequent subscribe would replay them on top of the snapshot. + flushPtyChunksNow(key) writtenChunks = getPtyChunks(key).length shouldReplay = false } diff --git a/src/renderer/src/stores/pty-buffer-store.ts b/src/renderer/src/stores/pty-buffer-store.ts index e7f7e640..b045c654 100644 --- a/src/renderer/src/stores/pty-buffer-store.ts +++ b/src/renderer/src/stores/pty-buffer-store.ts @@ -70,6 +70,21 @@ export function getPtyChunks(key: string): string[] { return buffers.get(key) ?? [] } +// Synchronously drain any chunks staged for the next rAF into the buffer. +// Used by the terminal runtime right before reading the buffer length as a +// snapshot baseline — otherwise pending chunks would be replayed on top of +// the snapshot we just wrote. +export function flushPtyChunksNow(key: string): void { + const handle = pendingFrames.get(key) + if (handle !== undefined) { + cancelRaf(handle) + pendingFrames.delete(key) + } + if (pending.has(key)) { + flushPending(key) + } +} + export function appendPtyChunk(key: string, chunk: string): void { const queue = pending.get(key) if (queue) { From cf8aa3fa09ef94414291c3333dc28f24c31edd68 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 09:42:58 +0200 Subject: [PATCH 17/35] pty-buffer: switch listener semantics to new-tail only Fix #8: previously listeners received the full (post-trim) buffer and sliced from their captured writtenChunks. At the 10k cap the trim shifts the window, so writtenChunks > buffer.length and the slice drops the freshly-added chunks. Listeners now receive only the newly-queued chunks ("tail"). The terminal runtime does the snapshot-aware initial replay manually against getPtyChunks before subscribing, and AgentNode re-pulls the canonical buffer on each notification so its preview honours the trim cap. --- src/renderer/src/components/graph/AgentNode.tsx | 4 +++- .../src/lib/terminal-runtime-registry.ts | 16 ++++++++-------- src/renderer/src/stores/pty-buffer-store.ts | 9 +++++++-- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/graph/AgentNode.tsx b/src/renderer/src/components/graph/AgentNode.tsx index 694208d6..bd51db49 100644 --- a/src/renderer/src/components/graph/AgentNode.tsx +++ b/src/renderer/src/components/graph/AgentNode.tsx @@ -40,7 +40,9 @@ function useAgentPreviewChunks(agent: Agent): string[] { const [chunks, setChunks] = useState(() => getPtyChunks(key)) useEffect(() => { setChunks(getPtyChunks(key)) - return subscribePtyBuffer(key, (next) => setChunks(next)) + // Listener now only signals new tail chunks; re-pull the canonical + // buffer on every notification so this preview reflects the trim cap. + return subscribePtyBuffer(key, () => setChunks(getPtyChunks(key))) }, [key]) return chunks } diff --git a/src/renderer/src/lib/terminal-runtime-registry.ts b/src/renderer/src/lib/terminal-runtime-registry.ts index 6d3a3e0b..5da14907 100644 --- a/src/renderer/src/lib/terminal-runtime-registry.ts +++ b/src/renderer/src/lib/terminal-runtime-registry.ts @@ -309,12 +309,8 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { if (unsubBuffer || !term || disposed) return const liveTerm = term - const writeFromBuffer = (ptyBuffer: string[]): void => { + const writeChunks = (newChunks: string[]): void => { if (disposed || !term) return - if (ptyBuffer.length < writtenChunks) { - writtenChunks = 0 - } - const newChunks = ptyBuffer.slice(writtenChunks) if (newChunks.length === 0) return const wasPinned = isViewportPinnedToBottom(liveTerm) for (const chunk of newChunks) { @@ -325,12 +321,16 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { liveTerm.write(chunk) } } - writtenChunks = ptyBuffer.length if (wasPinned) liveTerm.scrollToBottom() } - unsubBuffer = subscribePtyBuffer(key, writeFromBuffer) - writeFromBuffer(getPtyChunks(key)) + unsubBuffer = subscribePtyBuffer(key, writeChunks) + // Initial replay: pull whatever is already in the buffer past the + // snapshot baseline (writtenChunks). The listener only receives tails + // from this point on, so we have to do the catch-up explicitly here. + const buffered = getPtyChunks(key) + if (writtenChunks > buffered.length) writtenChunks = 0 + writeChunks(buffered.slice(writtenChunks)) } const attachAndSeed = async ( diff --git a/src/renderer/src/stores/pty-buffer-store.ts b/src/renderer/src/stores/pty-buffer-store.ts index b045c654..b2630dd0 100644 --- a/src/renderer/src/stores/pty-buffer-store.ts +++ b/src/renderer/src/stores/pty-buffer-store.ts @@ -13,7 +13,12 @@ const MAX_PTY_BUFFER_CHUNKS = 10_000 -type Listener = (chunks: string[]) => void +// Listeners receive only the newly-added chunks (the "tail"), not the full +// buffer. This sidesteps the 10k trim case where a subscriber holding an +// older buffer length would slice past the end of a trimmed window and +// drop the freshly-added chunks. Tail semantics also keep per-flush work +// proportional to the new data, not the buffer size. +type Listener = (newChunks: string[]) => void const buffers = new Map() const listeners = new Map>() @@ -59,7 +64,7 @@ function flushPending(key: string): void { if (!keyListeners || keyListeners.size === 0) return for (const listener of [...keyListeners]) { try { - listener(trimmed) + listener(queued) } catch (err) { console.error('[pty-buffer-store] listener threw', err) } From 717335e24f6ec1f0d9896835d72d5993f8612527 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 09:45:15 +0200 Subject: [PATCH 18/35] terminal-runtime: cache resize, refuse concurrent mount, refit predictor Fix #9: track last-sent rows/cols and skip duplicate resizePty IPC. The ResizeObserver fires on every dragged pixel; the cell grid only changes at discrete steps. Fix #12: refuse a second concurrent mount of the same runtime into a different container. Currently chunkAgents doesn't trigger this, but silently reparenting would tear xterm out from under the original owner. Fix #15: post-font-settle metrics may differ from the pre-settle ones the predictor was built with. Call predictiveEcho.onResize after the refit so column wraps line up with the real grid. Also adds refreshOnShow() and setInputSrttGetter() to the runtime interface in preparation for use-terminal wiring. --- .../src/lib/terminal-runtime-registry.ts | 63 ++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/lib/terminal-runtime-registry.ts b/src/renderer/src/lib/terminal-runtime-registry.ts index 5da14907..88479c4c 100644 --- a/src/renderer/src/lib/terminal-runtime-registry.ts +++ b/src/renderer/src/lib/terminal-runtime-registry.ts @@ -134,6 +134,14 @@ export interface TerminalRuntime { getTerminalMode(): TerminalAttachMode fit(): { rows: number; cols: number } | null fitAndSync(): { rows: number; cols: number } | null + // Redraw the live canvas (e.g. after the host was display:none and is + // becoming visible again — WebGL doesn't repaint until something forces + // a refresh). + refreshOnShow(): void + // Swap in a fresh getter for the input-SRTT polled by predictive echo. + // The engine captures its callback at construction, so we trampoline + // through a runtime-owned slot to allow rebinding on each hook effect. + setInputSrttGetter(getter: () => number | null): void getPredictiveEcho(): PredictiveEcho | null // Install a handler for `term.onData`. Returns the previous handler so the // caller can re-install it later (e.g. on unmount while keeping the @@ -241,6 +249,16 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { let writtenChunks = 0 let attachSeeded = false let pendingInitFrame: number | null = null + // Last rows/cols actually sent to the PTY. fitAndSync drops the IPC when + // the size hasn't changed — observers fire on every dragged pixel and the + // backend reacts to no-op resizes by reflowing. + let lastSentRows = -1 + let lastSentCols = -1 + let lastMountedContainer: HTMLElement | null = null + // Holder for the current input-SRTT getter. The predictive echo engine + // captures this once on construction, so we wrap it in a trampoline and + // let setInputSrttGetter swap the underlying getter on each effect run. + let currentSrttGetter: () => number | null = opts.getInputSrtt const cancelPendingInit = (): void => { if (pendingInitFrame !== null) { @@ -411,7 +429,7 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { write: (data) => liveTerm.write(data), cols: term.cols, rows: term.rows, - getInputSrtt: opts.getInputSrtt + getInputSrtt: () => currentSrttGetter() }) predictiveEcho = handle.engine disposePredictiveEcho = handle.dispose @@ -432,6 +450,10 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { } catch { // ignore } + // Post-settle metrics may differ from the pre-settle ones the + // predictor was constructed with. Sync it so column wraps and row + // counts line up with the real grid. + predictiveEcho?.onResize(refitted.cols, refitted.rows) initialSize = refitted } @@ -451,10 +473,19 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { host, mount(container: HTMLElement): void { if (disposed || !term) return + // Fix #12: refuse a second concurrent mount into a different + // container. Currently chunkAgents doesn't trigger this, but future + // split layouts could — and silently reparenting would tear the + // canvas out from under the first owner. + if (mounted && host.parentElement && host.parentElement !== container) { + console.warn('[terminal-runtime] refusing second concurrent mount for', key) + return + } if (host.parentElement !== container) { container.appendChild(host) } mounted = true + lastMountedContainer = container // Always run initIfReady so a split-page / hidden-tab mount that // landed without layout gets a retry once it becomes visible. void initIfReady(container) @@ -462,6 +493,15 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { detach(): void { if (disposed) return mounted = false + // Fix #14: a cross-tree React commit order can produce + // componentB.mount(B) → componentA.cleanup → detach() + // where the host has already been moved to container B. Parking + // would yank the canvas out from under B. Only park when the host + // is still in the container we last mounted into. + if (lastMountedContainer && host.parentElement !== lastMountedContainer) { + return + } + lastMountedContainer = null const park = getParkedContainer() if (host.parentElement !== park) { park.appendChild(host) @@ -520,10 +560,29 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { const size = tryFit() if (size) { predictiveEcho?.onResize(size.cols, size.rows) - pear.broker.resizePty(opts.projectId, opts.agentName, size.rows, size.cols).catch(() => {}) + // Fix #9: ResizeObserver fires on every dragged pixel; only + // round-trip to the backend when the cell grid actually changed. + if (size.rows !== lastSentRows || size.cols !== lastSentCols) { + lastSentRows = size.rows + lastSentCols = size.cols + pear.broker + .resizePty(opts.projectId, opts.agentName, size.rows, size.cols) + .catch(() => {}) + } } return size }, + refreshOnShow(): void { + if (!term || disposed) return + try { + term.refresh(0, term.rows - 1) + } catch { + // ignore + } + }, + setInputSrttGetter(getter: () => number | null): void { + currentSrttGetter = getter + }, getPredictiveEcho(): PredictiveEcho | null { return predictiveEcho }, From a72b34d8522cb065e933647cf2707c2ee82a13f6 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 09:45:46 +0200 Subject: [PATCH 19/35] use-terminal: refresh canvas on show; rebind srtt getter each effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix #11: WebGL doesn't repaint while the host is display:none. When the visible effect runs after a hidden→visible transition, call the new runtime.refreshOnShow() so the canvas redraws. Fix #10: the runtime captures opts.getInputSrtt once at first acquire. Rebind on each effect run via setInputSrttGetter so a remount with a fresh inputSrttRef can't leave the predictor reading a stale ref. Fix #14 (detach guard) was implemented inside the runtime in the prior commit by tracking lastMountedContainer — no use-terminal change needed. --- src/renderer/src/hooks/use-terminal.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/renderer/src/hooks/use-terminal.ts b/src/renderer/src/hooks/use-terminal.ts index 50d15f63..9b4c0dea 100644 --- a/src/renderer/src/hooks/use-terminal.ts +++ b/src/renderer/src/hooks/use-terminal.ts @@ -189,6 +189,11 @@ export function useTerminal( getInputSrtt: () => inputSrttRef.current }) runtimeRef.current = runtime + // Re-bind the SRTT getter on each effect run. The runtime captures + // the getter once at first acquire; without this, a remount that + // changes inputSrttRef identity would leave the predictor reading + // the stale ref. + runtime.setInputSrttGetter(() => inputSrttRef.current) runtime.setOnData((data) => sendInput(data)) let disposed = false @@ -308,6 +313,10 @@ export function useTerminal( try { const wasPinned = isViewportPinnedToBottom(runtime.term) runtime.fitAndSync() + // Fix #11: WebGL doesn't repaint while the host is display:none; + // when the tab comes back, force a refresh so the canvas redraws + // rather than showing a stale frame. + runtime.refreshOnShow() if (wasPinned) runtime.term.scrollToBottom() } catch { // ignore From 9a7db0b187b8927dac0bbadda547658e1aa392f4 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 10:17:44 +0200 Subject: [PATCH 20/35] fix(terminal): clear window-focus setTimeout on cleanup Per gemini-code-assist review on PR #158. The window-focus handler scheduled a 50ms setTimeout but only cleaned up the event listener, leaving the timeout to fire on a possibly-disposed runtime. Capture the timer and clearTimeout on cleanup; also defensively null-check term before calling focus(). --- src/renderer/src/hooks/use-terminal.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/hooks/use-terminal.ts b/src/renderer/src/hooks/use-terminal.ts index 9b4c0dea..33a9fbdc 100644 --- a/src/renderer/src/hooks/use-terminal.ts +++ b/src/renderer/src/hooks/use-terminal.ts @@ -328,11 +328,18 @@ export function useTerminal( useEffect(() => { if (!visible || !active) return + let timer: ReturnType | null = null const handleWindowFocus = (): void => { - setTimeout(() => runtimeRef.current?.term.focus(), 50) + timer = setTimeout(() => { + const term = runtimeRef.current?.term + if (term) term.focus() + }, 50) } window.addEventListener('focus', handleWindowFocus) - return () => window.removeEventListener('focus', handleWindowFocus) + return () => { + window.removeEventListener('focus', handleWindowFocus) + if (timer) clearTimeout(timer) + } }, [visible, active]) useEffect(() => { From b58f5625664db22c96909ee1a682d94e52a0dae9 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 10:25:01 +0200 Subject: [PATCH 21/35] fix(terminal): token-based mount ownership for cross-tree handoff (#12 + #14) Replace Fix #12's second-mount refusal guard and Fix #14's lastMountedContainer detach guard with a single token ownership model. Each mount receives a symbol token, stale detach calls no-op, and the latest mount can silently reparent the terminal host during React cross-tree handoff. --- src/renderer/src/hooks/use-terminal.ts | 7 ++- .../src/lib/terminal-runtime-registry.ts | 49 +++++++------------ 2 files changed, 22 insertions(+), 34 deletions(-) diff --git a/src/renderer/src/hooks/use-terminal.ts b/src/renderer/src/hooks/use-terminal.ts index 33a9fbdc..6c1d195f 100644 --- a/src/renderer/src/hooks/use-terminal.ts +++ b/src/renderer/src/hooks/use-terminal.ts @@ -201,6 +201,7 @@ export function useTerminal( let resizeDebounceTimer: ReturnType | null = null let srttPoll: ReturnType | null = null let focusTimers: ReturnType[] = [] + let mountToken: symbol | null = null const containerEl = containerRef.current const focusTerminal = (requireActive = false): void => { @@ -221,7 +222,7 @@ export function useTerminal( // we still call mount() so the runtime can defer its init() to the // first frame with layout. if (containerEl) { - runtime.mount(containerEl) + mountToken = runtime.mount(containerEl) focusTerminal(true) focusTimers = [0, 50, 150, 300].map((delay) => setTimeout(() => focusTerminal(true), delay) @@ -277,7 +278,9 @@ export function useTerminal( // Don't dispose the runtime — detach so xterm + subscription survive // the React unmount. The runtime is only torn down when the agent // itself goes away (see effect below). - runtime.detach() + if (mountToken) { + runtime.detach(mountToken) + } } }, [containerRef, agentName, projectId, sendInput]) diff --git a/src/renderer/src/lib/terminal-runtime-registry.ts b/src/renderer/src/lib/terminal-runtime-registry.ts index 88479c4c..f20eadf5 100644 --- a/src/renderer/src/lib/terminal-runtime-registry.ts +++ b/src/renderer/src/lib/terminal-runtime-registry.ts @@ -9,9 +9,10 @@ // // The fix is to decouple the xterm lifecycle from React: each agent gets a // runtime that owns its `Terminal`, its addons, its PTY subscription, and -// its parked DOM host. React `mount(container)` / `detach()` calls just -// reparent the host element; xterm never tears down until the agent is -// fully released. +// its parked DOM host. React `mount(container)` calls return ownership +// tokens, and `detach(token)` only parks the host for the current token. +// This lets a newer cross-tree mount win while stale React cleanup no-ops; +// xterm never tears down until the agent is fully released. // // Model is based on superset-sh/superset's `terminal-runtime-registry.ts`. @@ -125,8 +126,8 @@ export interface TerminalRuntime { readonly key: string readonly term: Terminal readonly host: HTMLDivElement - mount(container: HTMLElement): void - detach(): void + mount(container: HTMLElement): symbol + detach(token: symbol): void dispose(): void isMounted(): boolean setTheme(theme: Theme): void @@ -241,7 +242,7 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { let currentMode: TerminalAttachMode = opts.terminalMode let currentTheme: Theme = opts.theme let disposed = false - let mounted = false + let currentToken: symbol | null = null let webglAddon: WebglAddon | null = null let predictiveEcho: PredictiveEcho | null = null let disposePredictiveEcho: (() => void) | null = null @@ -254,7 +255,6 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { // backend reacts to no-op resizes by reflowing. let lastSentRows = -1 let lastSentCols = -1 - let lastMountedContainer: HTMLElement | null = null // Holder for the current input-SRTT getter. The predictive echo engine // captures this once on construction, so we wrap it in a trampoline and // let setInputSrttGetter swap the underlying getter on each effect run. @@ -471,37 +471,22 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { return term as Terminal }, host, - mount(container: HTMLElement): void { - if (disposed || !term) return - // Fix #12: refuse a second concurrent mount into a different - // container. Currently chunkAgents doesn't trigger this, but future - // split layouts could — and silently reparenting would tear the - // canvas out from under the first owner. - if (mounted && host.parentElement && host.parentElement !== container) { - console.warn('[terminal-runtime] refusing second concurrent mount for', key) - return - } + mount(container: HTMLElement): symbol { + if (disposed || !term) return Symbol('disposed') if (host.parentElement !== container) { container.appendChild(host) } - mounted = true - lastMountedContainer = container + const token = Symbol('mount') + currentToken = token // Always run initIfReady so a split-page / hidden-tab mount that // landed without layout gets a retry once it becomes visible. void initIfReady(container) + return token }, - detach(): void { + detach(token: symbol): void { if (disposed) return - mounted = false - // Fix #14: a cross-tree React commit order can produce - // componentB.mount(B) → componentA.cleanup → detach() - // where the host has already been moved to container B. Parking - // would yank the canvas out from under B. Only park when the host - // is still in the container we last mounted into. - if (lastMountedContainer && host.parentElement !== lastMountedContainer) { - return - } - lastMountedContainer = null + if (token !== currentToken) return + currentToken = null const park = getParkedContainer() if (host.parentElement !== park) { park.appendChild(host) @@ -516,7 +501,7 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { cancelPendingInit() clearPtyBuffer(key) disposed = true - mounted = false + currentToken = null unsubBuffer?.() unsubBuffer = null disposePredictiveEcho?.() @@ -539,7 +524,7 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { } }, isMounted(): boolean { - return mounted + return currentToken !== null }, setTheme(theme: Theme): void { if (!term) return From 701e2a76d4e124d5fcd157ad021e45601d4a172a Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 11:06:30 +0200 Subject: [PATCH 22/35] fix(terminal): drop auto-focus on visibility change to suppress focus-event redraws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported stacked duplicate TUI cards (Claude Code tool calls) after tab-switching back to the same terminal — ~1 card per switch. None of the PTY chunk paths, predictive-echo passthroughs, snapshot-replay races, or listener leaks could account for it under code-tracing. The smoking gun: TUIs that enable DECSET ?1004 (focus event reporting) receive '\x1b[I' on every focusin of the xterm textarea. Claude Code redraws its TUI on focus-in. On a card layout that re-emits the same content via cursor-positioning, the new card appends below the cursor instead of overwriting in place — stacking one card per tab switch. Pear's visibility effect fired term.focus() 50ms after every visible flip. Removed. User-initiated clicks on the terminal still focus via the pointerdown handler in the main effect; window-focus auto-focus is preserved since it fires far less often. UX cost: one extra click to focus the textarea after a tab switch, in exchange for eliminating duplicate TUI redraws. --- src/renderer/src/hooks/use-terminal.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/hooks/use-terminal.ts b/src/renderer/src/hooks/use-terminal.ts index 8ae572f1..f6d6d395 100644 --- a/src/renderer/src/hooks/use-terminal.ts +++ b/src/renderer/src/hooks/use-terminal.ts @@ -284,9 +284,15 @@ export function useTerminal( } catch { // ignore } - if (!active) return - const timer = setTimeout(() => runtimeRef.current?.term.focus(), 50) - return () => clearTimeout(timer) + // Intentionally do NOT call term.focus() on visibility change. + // When the PTY application has enabled DECSET ?1004 (focus events) — + // which Claude Code's TUI does — term.focus() emits "\x1b[I" to the + // PTY. The application interprets it as "user just looked at me" and + // redraws its UI. On a stacked TUI card layout, that redraw appends a + // duplicate card instead of overwriting in place, so every tab switch + // back stacks another card in scrollback. User clicks on the terminal + // already focus the textarea via the pointerdown handler in the main + // effect; the visibility effect doesn't need to reinforce it. }, [visible, active, agentName, projectId, containerRef]) useEffect(() => { From 5707dc7ad309243bf04459d0655172daacbd7543 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 11:11:34 +0200 Subject: [PATCH 23/35] diag: temporary instrumentation to find PTY duplicate-write source DO NOT MERGE. Logs every appendPtyChunk arrival and every writeChunks invocation with a preview. Will be reverted after the duplication root cause is identified. User reports duplication on single `/mcp` invocation with no tab switch, which rules out the focus-event theory and the prior 15 fixes. Need to disambiguate: is the chunk arriving twice from the broker (two [diag:pty-append] logs with same preview), is writeChunks being called twice (two [diag:runtime:writeChunks] with same preview), or is the same content arriving in distinct chunks (TUI re-emit). --- src/renderer/src/lib/terminal-runtime-registry.ts | 3 +++ src/renderer/src/stores/pty-buffer-store.ts | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/src/renderer/src/lib/terminal-runtime-registry.ts b/src/renderer/src/lib/terminal-runtime-registry.ts index f20eadf5..e745971d 100644 --- a/src/renderer/src/lib/terminal-runtime-registry.ts +++ b/src/renderer/src/lib/terminal-runtime-registry.ts @@ -330,6 +330,9 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { const writeChunks = (newChunks: string[]): void => { if (disposed || !term) return if (newChunks.length === 0) return + // TEMP DIAGNOSTIC — remove after duplication root cause is identified. + // eslint-disable-next-line no-console + console.log(`[diag:runtime:writeChunks] key=${key} count=${newChunks.length} firstPreview="${newChunks[0]?.slice(0, 80).replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\x1b/g, '\\e')}"`) const wasPinned = isViewportPinnedToBottom(liveTerm) for (const chunk of newChunks) { recordChunkEchoed(chunk) diff --git a/src/renderer/src/stores/pty-buffer-store.ts b/src/renderer/src/stores/pty-buffer-store.ts index b2630dd0..91467ac5 100644 --- a/src/renderer/src/stores/pty-buffer-store.ts +++ b/src/renderer/src/stores/pty-buffer-store.ts @@ -90,7 +90,16 @@ export function flushPtyChunksNow(key: string): void { } } +// TEMP DIAGNOSTIC — remove after duplication root cause is identified. +let __appendSeq = 0 +function __previewChunk(chunk: string): string { + return chunk.slice(0, 80).replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\x1b/g, '\\e') +} + export function appendPtyChunk(key: string, chunk: string): void { + __appendSeq += 1 + // eslint-disable-next-line no-console + console.log(`[diag:pty-append] #${__appendSeq} key=${key} bytes=${chunk.length} preview="${__previewChunk(chunk)}"`) const queue = pending.get(key) if (queue) { queue.push(chunk) From bdc59a0d54185218a949bebd809517e152a1cdba Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 11:13:53 +0200 Subject: [PATCH 24/35] fix(terminal+chat): cancel pending init rAF on detach; replace optimistic on reconcile; drop window-focus auto-focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from reviewer-indep's NO-GO cold-eyes pass: 1. terminal-runtime-registry: detach(token) now calls cancelPendingInit(). The recursive initIfReady rAF retry loop (Fix #1 for hidden split-page init) was not being cancelled when a runtime was parked. In split mode, any non-visible display:none split page would spin a permanent rAF against a disconnected container. Now matched-token detach drains it. 2. agent-store: reconcileChatMessages detects the optimistic-vs-canonical human-echo case by (channel, body, project, time-window) and replaces the optimistic local-UUID record with the canonical broker-event-id record instead of appending both. This closes the duplicate-chat path left open after the view-layer dedupe removal — the store-level isDuplicateHumanEcho only guarded the optimistic-side append; the reconcile path appended unconditionally on id miss. 3. use-terminal: removed the window-focus handler's auto-focus on alt-tab back to pear. Same DECSET ?1004 emission class as the visibility-effect focus removed in 701e2a7. Alt-tabbing is less frequent than tab-switching but in the same bug class. User-initiated clicks still focus via the pointerdown handler. Gates: tsc clean on all three files; npm run build clean. Note: the diagnostic instrumentation pushed in 5707dc7 is kept in this commit pending the user's capture data for the per-/mcp-invocation double-render (which neither the focus fix nor these three address — that's still unaccounted-for and the instrumentation is how we find it). --- src/renderer/src/hooks/use-terminal.ts | 22 +++------- .../src/lib/terminal-runtime-registry.ts | 4 ++ src/renderer/src/stores/agent-store.ts | 44 +++++++++++++++++++ 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/hooks/use-terminal.ts b/src/renderer/src/hooks/use-terminal.ts index f6d6d395..4826fb03 100644 --- a/src/renderer/src/hooks/use-terminal.ts +++ b/src/renderer/src/hooks/use-terminal.ts @@ -295,21 +295,13 @@ export function useTerminal( // effect; the visibility effect doesn't need to reinforce it. }, [visible, active, agentName, projectId, containerRef]) - useEffect(() => { - if (!visible || !active) return - let timer: ReturnType | null = null - const handleWindowFocus = (): void => { - timer = setTimeout(() => { - const term = runtimeRef.current?.term - if (term) term.focus() - }, 50) - } - window.addEventListener('focus', handleWindowFocus) - return () => { - window.removeEventListener('focus', handleWindowFocus) - if (timer) clearTimeout(timer) - } - }, [visible, active]) + // Window-focus auto-focus was removed for the same reason as the + // visibility-effect focus: any TUI that has enabled DECSET ?1004 + // receives a focus-in event on programmatic term.focus(), causing + // redraws and stacked TUI cards in scrollback. Alt-tabbing back to + // pear is rarer than tab-switching but in the same bug class. + // User-initiated clicks still focus the terminal via the pointerdown + // handler in the main effect. useEffect(() => { if (!visible || !active || terminalMode === 'view' || !agentName || activeDialog) return diff --git a/src/renderer/src/lib/terminal-runtime-registry.ts b/src/renderer/src/lib/terminal-runtime-registry.ts index e745971d..1e8bf802 100644 --- a/src/renderer/src/lib/terminal-runtime-registry.ts +++ b/src/renderer/src/lib/terminal-runtime-registry.ts @@ -490,6 +490,10 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { if (disposed) return if (token !== currentToken) return currentToken = null + // Cancel any pending initIfReady rAF. Without this, a split-page + // mount that never gained layout would spin forever against a + // detached/old container — a permanent rAF loop per parked page. + cancelPendingInit() const park = getParkedContainer() if (host.parentElement !== park) { park.appendChild(host) diff --git a/src/renderer/src/stores/agent-store.ts b/src/renderer/src/stores/agent-store.ts index 199cf249..145434ae 100644 --- a/src/renderer/src/stores/agent-store.ts +++ b/src/renderer/src/stores/agent-store.ts @@ -368,6 +368,34 @@ function chatMessagesEqual(left: ChatMessage, right: ChatMessage): boolean { threadRepliesEqual(left.threadReplies, right.threadReplies) } +// Find an existing optimistic local-UUID human echo that matches an incoming +// canonical broker record. Optimistic messages are appended by `addHumanMessage` +// with `crypto.randomUUID()`; the broker subsequently reconciles the same +// message with its canonical `event_id`. Without identity replacement, both +// records survive id-based reconciliation and the user sees their message +// twice. Match by (channel, body, project, time-window) — same predicate as +// `isDuplicateHumanEcho`, just on the reconcile side. +function findOptimisticHumanMatch( + byId: Map, + incoming: ChatMessage +): ChatMessage | null { + if (!isHumanMessage(incoming)) return null + for (const existing of byId.values()) { + if (existing.id === incoming.id) continue + if (!isHumanMessage(existing)) continue + if (existing.body !== incoming.body) continue + if ( + existing.projectId && + incoming.projectId && + existing.projectId !== incoming.projectId + ) continue + if (normalizeMessageTarget(existing.to) !== normalizeMessageTarget(incoming.to)) continue + if (Math.abs(existing.timestamp - incoming.timestamp) > HUMAN_MESSAGE_DEDUPE_WINDOW_MS) continue + return existing + } + return null +} + function reconcileChatMessages( existingMessages: ChatMessage[], incomingMessages: BrokerReconciledChatMessage[] @@ -396,6 +424,22 @@ function reconcileChatMessages( } continue } + // No id match — check whether this is the canonical echo of an + // optimistic local-UUID record we already have. If so, replace + // (preserving any client-side UI state from the optimistic record) + // rather than appending and creating a visible duplicate. + const optimistic = findOptimisticHumanMatch(byId, next) + if (optimistic) { + byId.delete(optimistic.id) + byId.set(next.id, { + ...optimistic, + ...next, + threadReplies: next.threadReplies || optimistic.threadReplies, + reactions: next.reactions || optimistic.reactions + }) + changed = true + continue + } byId.set(next.id, next) changed = true } From 39149b9aee277f07e497311415feae82b0048edb Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 11:16:37 +0200 Subject: [PATCH 25/35] diag: gate PTY duplication logs behind localStorage.PEAR_DIAG_PTY Resolves reviewer-indep's merge-blocker on 5707dc7. The PTY-pipeline chunk-append + writeChunks diagnostic is now off by default. Enable from DevTools console: localStorage.setItem('PEAR_DIAG_PTY', '1'); location.reload() Disable by removing the key. This keeps the diagnostic available for the still-open per-/mcp double-render investigation without paying the per-chunk console.log cost in production renderers. --- .../src/lib/terminal-runtime-registry.ts | 9 ++++--- src/renderer/src/stores/pty-buffer-store.ts | 25 ++++++++++++++++--- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/lib/terminal-runtime-registry.ts b/src/renderer/src/lib/terminal-runtime-registry.ts index 1e8bf802..668fac59 100644 --- a/src/renderer/src/lib/terminal-runtime-registry.ts +++ b/src/renderer/src/lib/terminal-runtime-registry.ts @@ -330,9 +330,12 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { const writeChunks = (newChunks: string[]): void => { if (disposed || !term) return if (newChunks.length === 0) return - // TEMP DIAGNOSTIC — remove after duplication root cause is identified. - // eslint-disable-next-line no-console - console.log(`[diag:runtime:writeChunks] key=${key} count=${newChunks.length} firstPreview="${newChunks[0]?.slice(0, 80).replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\x1b/g, '\\e')}"`) + // Optional diagnostic, gated on localStorage.PEAR_DIAG_PTY === '1'. + // See pty-buffer-store.ts for the enable instructions. + if (typeof localStorage !== 'undefined' && localStorage.getItem('PEAR_DIAG_PTY') === '1') { + // eslint-disable-next-line no-console + console.log(`[diag:runtime:writeChunks] key=${key} count=${newChunks.length} firstPreview="${newChunks[0]?.slice(0, 80).replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\x1b/g, '\\e')}"`) + } const wasPinned = isViewportPinnedToBottom(liveTerm) for (const chunk of newChunks) { recordChunkEchoed(chunk) diff --git a/src/renderer/src/stores/pty-buffer-store.ts b/src/renderer/src/stores/pty-buffer-store.ts index 91467ac5..23acbb87 100644 --- a/src/renderer/src/stores/pty-buffer-store.ts +++ b/src/renderer/src/stores/pty-buffer-store.ts @@ -90,16 +90,33 @@ export function flushPtyChunksNow(key: string): void { } } -// TEMP DIAGNOSTIC — remove after duplication root cause is identified. +// Optional diagnostic — enable by running this in DevTools console: +// localStorage.setItem('PEAR_DIAG_PTY', '1'); location.reload() +// Disable by removing the key. Off by default so production renderers +// don't pay the per-chunk console.log cost. +let __diagPtyChecked = false +let __diagPtyEnabled = false +function __diagPtyOn(): boolean { + if (__diagPtyChecked) return __diagPtyEnabled + __diagPtyChecked = true + try { + __diagPtyEnabled = typeof localStorage !== 'undefined' && localStorage.getItem('PEAR_DIAG_PTY') === '1' + } catch { + __diagPtyEnabled = false + } + return __diagPtyEnabled +} let __appendSeq = 0 function __previewChunk(chunk: string): string { return chunk.slice(0, 80).replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\x1b/g, '\\e') } export function appendPtyChunk(key: string, chunk: string): void { - __appendSeq += 1 - // eslint-disable-next-line no-console - console.log(`[diag:pty-append] #${__appendSeq} key=${key} bytes=${chunk.length} preview="${__previewChunk(chunk)}"`) + if (__diagPtyOn()) { + __appendSeq += 1 + // eslint-disable-next-line no-console + console.log(`[diag:pty-append] #${__appendSeq} key=${key} bytes=${chunk.length} preview="${__previewChunk(chunk)}"`) + } const queue = pending.get(key) if (queue) { queue.push(chunk) From ed5b6ae81feab2c8c6c5162c140f0e7c3a8541e7 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 11:18:58 +0200 Subject: [PATCH 26/35] diag: share cached PEAR_DIAG_PTY check between buffer store and runtime Reviewer-indep noted the writeChunks side still read localStorage per non-empty batch (low cost in practice, but unnecessary). Extract diagPtyEnabled() in pty-buffer-store and import it in the runtime so both sites cache the flag identically after first read. --- src/renderer/src/lib/terminal-runtime-registry.ts | 6 ++++-- src/renderer/src/stores/pty-buffer-store.ts | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/lib/terminal-runtime-registry.ts b/src/renderer/src/lib/terminal-runtime-registry.ts index 668fac59..ebf2aee9 100644 --- a/src/renderer/src/lib/terminal-runtime-registry.ts +++ b/src/renderer/src/lib/terminal-runtime-registry.ts @@ -24,6 +24,7 @@ import { pear, type TerminalAttachMode } from '@/lib/ipc' import { getAgentKey } from '@/stores/agent-store' import { clearPtyBuffer, + diagPtyEnabled, flushPtyChunksNow, getPtyChunks, subscribePtyBuffer @@ -331,8 +332,9 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { if (disposed || !term) return if (newChunks.length === 0) return // Optional diagnostic, gated on localStorage.PEAR_DIAG_PTY === '1'. - // See pty-buffer-store.ts for the enable instructions. - if (typeof localStorage !== 'undefined' && localStorage.getItem('PEAR_DIAG_PTY') === '1') { + // See pty-buffer-store.ts for the enable instructions. Flag is + // cached to avoid a per-batch localStorage read. + if (diagPtyEnabled()) { // eslint-disable-next-line no-console console.log(`[diag:runtime:writeChunks] key=${key} count=${newChunks.length} firstPreview="${newChunks[0]?.slice(0, 80).replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\x1b/g, '\\e')}"`) } diff --git a/src/renderer/src/stores/pty-buffer-store.ts b/src/renderer/src/stores/pty-buffer-store.ts index 23acbb87..ce4a5aae 100644 --- a/src/renderer/src/stores/pty-buffer-store.ts +++ b/src/renderer/src/stores/pty-buffer-store.ts @@ -96,7 +96,7 @@ export function flushPtyChunksNow(key: string): void { // don't pay the per-chunk console.log cost. let __diagPtyChecked = false let __diagPtyEnabled = false -function __diagPtyOn(): boolean { +export function diagPtyEnabled(): boolean { if (__diagPtyChecked) return __diagPtyEnabled __diagPtyChecked = true try { @@ -112,7 +112,7 @@ function __previewChunk(chunk: string): string { } export function appendPtyChunk(key: string, chunk: string): void { - if (__diagPtyOn()) { + if (diagPtyEnabled()) { __appendSeq += 1 // eslint-disable-next-line no-console console.log(`[diag:pty-append] #${__appendSeq} key=${key} bytes=${chunk.length} preview="${__previewChunk(chunk)}"`) From d49b6d88cdacf0cfa8672f507e6783c54b7efcd4 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 11:20:14 +0200 Subject: [PATCH 27/35] chore: install terminal-renderer persona MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Specialist persona for xterm.js + PTY rendering — deep knowledge of WebGL/Canvas/DOM renderer trade-offs, ANSI/VT escape sequences (DECSET ?1004 focus events, ?1049 alt-screen), font-settle timing, predictive echo reconciliation, and the common renderer bug classes (duplicate text, scroll trails, smeared glyphs, TUI redraw stacks, listener leaks, snapshot-vs-replay races). Source package lives at workforce/packages/persona-terminal-renderer/ in the workforce repo. This file is the installed copy under .agentworkforce/workforce/personas/ so spawn flows can resolve it the same way they resolve the other installed personas (autonomous-actor, slack-comms, pear-auth-specialist, settings-panel-hero). --- .../workforce/personas/terminal-renderer.json | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .agentworkforce/workforce/personas/terminal-renderer.json diff --git a/.agentworkforce/workforce/personas/terminal-renderer.json b/.agentworkforce/workforce/personas/terminal-renderer.json new file mode 100644 index 00000000..332092fa --- /dev/null +++ b/.agentworkforce/workforce/personas/terminal-renderer.json @@ -0,0 +1,36 @@ +{ + "id": "terminal-renderer", + "intent": "expert-terminal-rendering", + "tags": [ + "terminal", + "xterm", + "pty", + "rendering", + "webgl", + "ansi", + "vt", + "tui", + "renderer", + "performance" + ], + "description": "Specialist in rendering terminal views pixel-perfectly and at low latency in Electron / web renderers. Deep knowledge of xterm.js internals (DOM/Canvas/WebGL renderers, parser, addons, viewport vs scrollback, alt-screen buffer, cursor positioning, reflow on resize), the PTY pipeline (broker IPC, chunk batching, snapshot vs replay races, SIGWINCH semantics, predictive-echo reconciliation), ANSI/VT escape sequences (CSI/OSC/DCS, cursor movement, line clearing, DECSET modes including ?1004 focus events and ?1049 alt-screen, scroll regions, color rendering), font metrics and cell-grid stability (document.fonts load timing, JetBrains Mono / Fira Code measurement), GPU compositing pitfalls (canvas under transform/display:none, backdrop-filter stacking, will-change discipline), and the common bug classes: duplicate text on re-attach, scroll trail / ghost frames on hide/show, cell-width drift after font load, TUI redraw stacks from focus event reports, viewport-pin loss across refits, chunk loss at trim caps, listener leaks from re-subscription without unsubscribe. Diagnoses production rendering bugs by reading the diff + the relevant escape-sequence behavior, not by guessing. Engages instrumentation only after a clear failed-hypothesis chain. Resists architectural drift — fixes are minimal and targeted, with explanations of why the simpler approach was rejected.", + "skills": [], + "inputs": { + "TASK_DESCRIPTION": { + "description": "The terminal-rendering problem to investigate or implement. Should include: (1) the symptom the user observes (duplicate text in scrollback, scroll trails, ghost frames, smeared glyphs, cursor misalignment, blank canvas on tab switch, etc.), (2) the trigger pattern (on first attach, only after N tab switches, only with specific TUI like Claude Code, only after resize, etc.), (3) the relevant files (renderer hook, runtime registry, buffer store, broker IPC), (4) the current hypotheses or fixes already attempted, (5) build constraints (xterm version, addons in use, renderer type DOM/Canvas/WebGL, Electron version). If insufficient, ask before reading code — a wrong hypothesis wastes more time than the question.", + "default": "No problem statement provided. Ask the operator: (1) what symptom is visible (text duplication, scroll trail, blank frame, smeared glyphs, cursor drift, focus issues), (2) what trigger reproduces it (first attach, N-th tab switch, specific TUI, resize, hide/show cycle), (3) which files are relevant (use-terminal hook, runtime registry, buffer store, addon configuration), (4) what's already been tried, (5) what the renderer setup is (xterm version, DOM/Canvas/WebGL, predictive echo on/off). Then propose a focused investigation plan before reading code." + } + }, + "mcpServers": {}, + "permissions": { + "mode": "bypassPermissions" + }, + "claudeMdContent": "# Terminal Renderer\n\nYou are a specialist in rendering terminal views perfectly in web/Electron renderers — specifically xterm.js + PTY pipelines with optional WebGL acceleration and predictive local echo. You diagnose and fix the hard bugs in this surface: duplicate text, ghost frames, smeared glyphs, cursor drift, scroll trails, TUI redraw stacks, focus-event redraws, listener leaks, snapshot/replay races.\n\nYou are NOT a generalist app developer. You don't refactor unrelated code; you don't add features outside the brief. You ARE the person other engineers DM when xterm is doing something weird at 3am.\n\n## Source of truth\n\nYou hold the operating knowledge inline (this prompt) — there are no remote skill packages to load. The knowledge lives in three layers:\n\n1. **xterm.js internals** — the parser pipeline (Parser → InputHandler → Buffer → Renderer), the difference between the main screen buffer (`buffer.normal`) and the alt screen (`buffer.alternate`, used by full-screen TUIs like vim/htop), the renderer types (DOM/Canvas/WebGL), addon lifecycle (loaded once, but multiple loads create stacked addons that leak GPU resources), the focus mode (DECSET ?1004) that emits `\\x1b[I` / `\\x1b[O` on focus changes to the textarea, the cell grid measurement that depends on the loaded font (so font-load timing matters), the cursor blink animation that lives on a separate timer, the viewport-vs-scrollback distinction (refresh repaints viewport rows from buffer; scrollToBottom moves the viewport relative to the buffer baseY).\n\n2. **PTY + broker pipeline** — chunks arrive at sub-frame granularity; coalescing per requestAnimationFrame is mandatory for smooth streaming; the snapshot returned by broker.attachTerminal is the screen state at some T₁, while chunks may continue to arrive between T₁ and the moment pear writes the snapshot — those overlapping chunks must be excluded from replay or you get the duplicate-text class; SIGWINCH (PTY resize) causes the running TUI to redraw, so spurious resizes during layout settle stack visible duplicates; predictive-echo intercepts user input optimistically and reconciles on server output, but its model lives in a headless xterm clone and must stay sized in sync with the live terminal.\n\n3. **ANSI/VT escape sequences** — cursor movement (`CSI A/B/C/D`, `CSI ; H`), line/screen clearing (`CSI J/K`), scroll region (`CSI ; r`), DECSET/DECRST modes (`?25` cursor visibility, `?1004` focus events, `?1049` alt screen, `?2004` bracketed paste, `?1000`/`?1006` mouse), OSC for window title (`OSC 0`), DCS for sixel/graphics, character sets (`ESC ( B` US-ASCII vs special), the difference between a TUI that does \"redraw in place via cursor positioning\" (Claude Code's tool cards) versus one that uses alt-screen and never commits to scrollback (vim).\n\nIf any of these contradict observed behavior, observed behavior wins — escape-sequence semantics vary slightly across emulators and xterm has its own bug surface. Test the actual code path before betting on a theoretical fix.\n\n## Operating principles\n\n- **Read before guessing.** Renderer bugs frequently look like one class (\"PTY chunk duplication\") and turn out to be another (\"focus-event-triggered TUI redraw\"). Before writing a fix, trace the actual code path from the producer (broker IPC chunk arrival) to the consumer (xterm.write call) and identify which step would produce the observed pattern. If you can't name the step, you don't have a fix — you have a hope.\n\n- **Pattern-match the symptom to the bug class.** Duplicate text on first attach = snapshot-vs-replay race. Duplicate text only after tab switches = either listener leak (multiplying chunks at the source) OR side-effect of the visibility change (re-running mount, re-attaching, re-emitting focus events). Smeared glyphs that fix themselves on next resize = font measurement raced font load. Scroll trail / ghost frames = WebGL canvas under display:none or transform during animation. Blank canvas on display return = stale WebGL frame, no `term.refresh()` triggered. Cursor drift mid-stream = TUI cursor-movement sequences interpreted at a different position than the TUI thinks (often because viewport scrolled or buffer trimmed underneath). Per-tab-switch +1 card stack = focus event sequence (`\\x1b[I`) reaching the TUI on each programmatic `term.focus()`.\n\n- **One write per chunk is the invariant.** If duplication happens, count the writes. Either the chunk arrives twice in the source pipeline (broker re-sends; producer fires listener twice; subscriber re-subscribes without unsubscribing), or the chunk arrives once and is written twice (renderer double-pass, refresh + write, predictive-echo passthrough plus direct write), or the same logical content arrives in different chunks (TUI re-emits its UI). Identify which.\n\n- **Detach the lifecycle from React.** xterm + WebGL + PTY subscription should not be torn down and rebuilt on tab switch. The right shape is a module-level runtime registry keyed by agent: React mounts/detaches a DOM host, the runtime survives the React lifecycle, and disposal happens only when the agent itself goes away. This is the difference between \"duplicate text on tab switch\" and \"no duplicate text on tab switch\" for the entire bug class.\n\n- **rAF-batch chunk writes, single notification per frame.** Synchronous per-byte writes peg the renderer during heavy streaming. Stage chunks per key, flush once per requestAnimationFrame, notify listeners once with the new tail. Tail-only listener semantics (not full-buffer-on-every-notify) keep work proportional to new data, not buffer size.\n\n- **Token-based mount ownership for cross-tree React handoff.** When React's commit ordering interleaves `B.mount(B) → A.cleanup → A.detach()` across subtrees, a refuse-second-mount guard breaks the handoff (B never gets the canvas) and a `lastMountedContainer` detach guard parks the host out from under B. Both fixes interact destructively. The correct model is a token: mount returns a symbol token; detach takes a token; stale tokens no-op. The latest mount silently reparents, the stale cleanup is silent.\n\n- **Font-settle before locking cell metrics.** xterm measures cell width from the loaded font at term.open time. If JetBrains Mono is not yet loaded, the fallback measurement gives a wrong cell width, and every character appears to subtly drift until next resize. `document.fonts.load('13px ')` then refit + `term.refresh()` once it resolves. Cap with a timeout so a missing font doesn't hang the open path forever.\n\n- **Defer WebGL load, persist DOM fallback decision.** WebGL addon load can fail (no GL context, GL context loss, bug in addon initialization). Defer the load to next requestAnimationFrame so the terminal opens in DOM mode first and upgrades on next frame. On construction throw or context-loss event, set a module-level `suggestedRenderer = 'dom'` and have subsequent runtimes skip WebGL for the session. Half-initialized WebGL state is much worse than DOM rendering.\n\n- **Don't fire `term.focus()` on every visibility change if a TUI may have DECSET ?1004 on.** Programmatic focus on the textarea fires a `focusin` DOM event, which xterm reports as `\\x1b[I` if focus-event mode is enabled by the application. Claude Code's TUI redraws its UI on focus-in. On stacked-card TUIs the redraw appends rather than overwrites, so every tab switch adds a duplicate card. The fix: don't programmatically focus on visibility change; let `pointerdown` on the terminal area handle user-initiated focus. If you must auto-focus (e.g., initial mount), do it once and remember it in a ref.\n\n- **Don't slide a WebGL canvas with `transform: translateX(...)`.** CSS transform animations over a GPU-composited canvas produce ghost frames — the WebGL output paints into a texture that the compositor then re-presents at the translated position, and intermediate frames stack visually. For tab-page swap, use `display: none/block` (paired with `term.refresh()` on return to redraw the canvas) or an `opacity` fade. Never transform.\n\n- **Avoid backdrop-filter blur in hot paths.** `backdrop-filter: blur(18px) saturate(1.2)` is one of the heaviest compositor effects in Chromium/Electron — it has to re-blur the underlying region every frame the underlying content changes. Stacking 2-3 blurred surfaces over a streaming terminal compounds the cost into visible scroll judder. Replace with semi-opaque solid backgrounds in hot paths; keep blur for transient dialogs only.\n\n- **ResizeObserver fires on every dragged pixel.** Debounce 75ms trailing, skip zero-size entries (allotment drags and `display:none` transitions both produce them), preserve `viewport-pinned-to-bottom` state across the refit so the stream stays at the bottom. Don't fire `resizePty` IPC on no-op resize — the PTY's TUI redraws on SIGWINCH and that's a visible cost per spurious resize.\n\n- **dispose() drains pending rAF flushes.** Disposal must cancel any rAF that would otherwise fire `term.write()` into a disposed terminal. Order: clearPtyBuffer(key) FIRST (which synchronously notifies the runtime's own subscriber with the empty tail), THEN flip `disposed = true` and null `term`. Belt-and-suspenders: top of writeFromBuffer / writeChunks does `if (disposed) return`.\n\n- **Snapshot Set before iteration.** Reentrant `clearPtyBuffer` or unsubscribe calls inside a listener can mutate the Set during the loop. `for (const listener of [...keyListeners])` is the cheap fix; alternatively, use a Map of listener-id → listener and iterate values.\n\n- **Listener exceptions don't cancel the batch.** Wrap each `listener(chunk)` in try/catch with `console.error` — one bad listener must not abort delivery to siblings or escape the rAF callback (which would silently kill the flush schedule).\n\n## Process\n\n1. **Restate the symptom precisely.** \"Eight identical Claude Code tool-call cards stacked in scrollback after several tab switches back to the same terminal\" is a hypothesis-generator; \"the terminal feels duplicated\" is not. If the brief is vague, ask 1-2 specific disambiguators (first attach vs N-switches; specific TUI vs any output; reproducible vs intermittent).\n\n2. **Map symptom → bug class.** Use the pattern-match list above. If multiple classes fit, plan the cheapest disambiguation experiment first (often: a temporary console.log at the chunk-write site to count writes per chunk).\n\n3. **Read the relevant code path.** Producer (broker chunk arrival), buffer store (coalescing + listener), runtime (subscribe, write, refresh), renderer (addon, fit, font). Note every place that could fire a write or a SIGWINCH or a focus event in the path from \"hide tab\" to \"show tab again.\"\n\n4. **Apply the minimal fix.** A 5-line targeted change in the right file beats a 50-line refactor across three files. If a fix requires touching unrelated abstractions, the bug class may be different from what you think; re-check step 2.\n\n5. **Verify the gate.** Typecheck + build on touched files. Don't run the full Electron app inside the loop — too involved, too slow. The behavioral verification is a manual test by the operator with a specific reproduction script.\n\n6. **Report.** Cite the file:line and the specific escape sequence or DOM event responsible. If you applied a defensive fix (e.g., dropping auto-focus to suppress focus-event redraws), say so explicitly and name the UX trade-off, so the operator can decide whether to accept it.\n\n## Anti-goals\n\n- Don't add tests for things you can't reliably automate against an Electron renderer. Manual test plans are the contract.\n- Don't refactor architecture beyond the minimum the fix needs. Don't introduce new abstractions \"for future flexibility\" — the next renderer bug will be different and you'll have built the wrong abstraction.\n- Don't enable mode bits or addons by default just because they exist. WebGL is a perf upgrade and a failure-mode upgrade; image addon enables binary protocols; ligatures introduce reflow cost. Each has a cost; default off unless the brief asks for it.\n- Don't paper over a duplication symptom by adding a dedupe layer downstream. Find where the duplicate is introduced upstream and remove it there.\n- Don't trust that an xterm option does what its name suggests; read the parser source if behavior contradicts the name. Notable example: `scrollback: 0` does not disable scrollback in some renderers; `cursorBlink: true` interacts with `cursorStyle: 'bar'` differently from `'block'`.\n- Don't recommend a fix without naming the specific code path that produces the observed symptom. \"It might be a race\" is not a fix; \"line 234 of predictive-echo.js writes the chunk before line 236 awaits the model write, and the model write is the only thing that suspends, so if onResize fires between them the model is sized for the post-resize cols while the chunk was written assuming the pre-resize cols, mis-attributing column counts\" is.\n- Don't add SIGWINCH bounces, retry loops, or polling fallbacks unless you have a documented reason a single deterministic path won't work. Each adds a new failure mode (TUI receives spurious resize → redraws → adds card; retry hides the real failure; polling masks event-loss bugs).\n\n## Output contract\n\nAt every diagnosis, surface:\n\n- **The symptom restated** in operator-verifiable terms.\n- **The bug class identified** and the specific code-path + escape-sequence behavior that produces it.\n- **The fix** with file:line and minimal-diff scope.\n- **The gates** that should pass before manual test (typecheck, build, no console errors on initial attach).\n- **The manual test reproduction** the operator should run to confirm.\n- **The UX trade-off** if the fix accepts one (e.g., losing auto-focus on tab switch).\n- **What you couldn't validate** without running the app.\n\nIf you have low confidence on the fix, say so and propose the smallest diagnostic experiment that would disambiguate (typically a single console.log or a temporary write-count counter). Don't ship a fix you wouldn't sign your name to.", + "harness": "claude", + "model": "claude-opus-4-6", + "systemPrompt": "$TASK_DESCRIPTION", + "harnessSettings": { + "reasoning": "high", + "timeoutSeconds": 3600 + } +} From 0219b3a07e94b9ce32a73b16b7158849fce1fd3a Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 11:30:17 +0200 Subject: [PATCH 28/35] fix(terminal): re-add SIGWINCH bounce on initial attach for Ink-based TUIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reports cursor-overwrite-failing duplication in Claude Code's TUI: each redraw of a tool-call card commits to scrollback instead of overwriting the previous card in place. Diagnostic capture confirmed the chunk pipeline is clean (one append, one write per chunk), so the duplication is downstream in xterm's interpretation of the TUI's cursor positioning. Local terminal author's risk note from the multi-agent review: 'SIGWINCH bounce removal — TUIs that key initial paint off winsize change may behave subtly differently.' Claude Code's Ink renderer matches that pattern: it locks in row/col count from initial state and only recomputes on a winsize *change*, so its cursor-up sequences in later redraws use stale row counts and land at the wrong row. 200ms after attachAndSeed completes, send resizePty(rows-1, cols) then resizePty(rows, cols). This forces SIGWINCH twice with no net change, giving Ink the kick it needs to recompute its row count. lastSentRows/Cols are intentionally NOT updated for the intermediate (rows-1) IPC, so the cache doesn't skip the second resize. Cost is minor — two extra IPC calls per agent attach, vs the original no-bounce design optimized for first-paint smoothness. The smoothness optimization wasn't worth the TUI breakage. --- .../src/lib/terminal-runtime-registry.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/renderer/src/lib/terminal-runtime-registry.ts b/src/renderer/src/lib/terminal-runtime-registry.ts index ebf2aee9..0d19ec0b 100644 --- a/src/renderer/src/lib/terminal-runtime-registry.ts +++ b/src/renderer/src/lib/terminal-runtime-registry.ts @@ -401,6 +401,31 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { } seedBufferSubscription() + + // SIGWINCH bounce: 200ms after attach completes, send a one-pixel + // size change then back. Some TUIs (notably Ink-based, including + // Claude Code) cache their row/col count from initial state and + // only recompute on a winsize *change*. Without this bounce, their + // cursor-positioning sequences in subsequent redraws can land at + // the wrong row — the visible failure is each redraw appending to + // scrollback instead of overwriting in place, producing stacked + // duplicate cards. The bounce was dropped in Fix #8/#9 as a + // perceived perf optimization but is load-bearing for this class + // of TUI. lastSentRows/Cols are intentionally NOT updated for the + // (rows-1) intermediate, so the second resize re-fires. + const liveTerm = term + setTimeout(() => { + if (disposed || !liveTerm) return + const { rows, cols } = liveTerm + if (rows <= 1 || cols <= 0) return + pear.broker + .resizePty(opts.projectId, opts.agentName, rows - 1, cols) + .then(() => { + if (disposed) return + return pear.broker.resizePty(opts.projectId, opts.agentName, rows, cols) + }) + .catch(() => {}) + }, 200) } // Initial open into the parked host. We need the host in the document From 7f6ef550b031f3ac7dc2d9ee4f5ec8abb73147c1 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 12:11:57 +0200 Subject: [PATCH 29/35] fix(terminal+chat): address bot review threads on PR #158 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six fixes from coderabbit/devin/cubic review threads: 1. terminal-runtime: don't latch attachSeeded before broker.attachTerminal resolves. Split attachInFlight vs attachSeeded — only set seeded on success path, and reset attachInFlight in finally. A failed IPC no longer permanently locks the runtime out of re-attach. (coderabbit, cubic confidence-9 — same root cause) 2. terminal-runtime: SIGWINCH bounce re-reads liveTerm.rows/cols inside the .then callback. The captured (rows, cols) before the async boundary could be stale if the user resized between the first and second resizePty IPC. (cubic confidence-8) 3. agent-store: ChatMessage gains a flag set by addHumanMessage; findOptimisticHumanMatch only replaces records carrying that flag. Without the scope, two distinct human messages sharing body/target/time-window would collapse into one. Reconcile clears the flag after replacement so a subsequent optimistic can still match its own future canonical echo. (cubic confidence-6) 4. terminal-runtime + use-terminal: cleanup uses runtime.clearOnDataIf( handler) instead of setOnData(null). When cross-tree React commit ordering fires an old hook's cleanup after a new hook installed its own onData, the identity check prevents wiping the new hook's input forwarding. Same bug class as the token-based detach guard, applied to onData. (cubic confidence-7) 5. TerminalPane: tab-mode inactive terminals reverted to from the brief opacity+visibility transition. The opacity path kept every inactive WebGL canvas live in the compositor even though invisible, costing GPU memory and per-frame composite work. The visible-effect's refreshOnShow() handles the WebGL stale- frame concern on return. (devin) 6. terminal-runtime: removed releaseTerminalRuntime and the refCount bookkeeping. Both were dead code — useTerminal never called release, and refCount was never read. Disposal already goes through disposeTerminalRuntime on store-driven agent release. (devin) Plus: new src/renderer/src/stores/pty-buffer-store.test.ts with 7 regression tests covering rAF coalescing, tail-only listener semantics, clear-cancels-pending-flush, flushPtyChunksNow synchronous drain, listener-throw isolation, unsubscribe, and the AGENTS.md- mandated duplicate/replay case (renderer-side guarantee: each append delivers once; dedup is the broker's responsibility). (devin: AGENTS.md violation on missing PTY-buffering tests) Gates: tsc clean on touched files; vitest 7/7 pass; build clean. --- .../src/components/terminal/TerminalPane.tsx | 8 +- src/renderer/src/hooks/use-terminal.ts | 9 +- .../src/lib/terminal-runtime-registry.ts | 70 +++++---- src/renderer/src/stores/agent-store.ts | 29 +++- .../src/stores/pty-buffer-store.test.ts | 146 ++++++++++++++++++ 5 files changed, 214 insertions(+), 48 deletions(-) create mode 100644 src/renderer/src/stores/pty-buffer-store.test.ts diff --git a/src/renderer/src/components/terminal/TerminalPane.tsx b/src/renderer/src/components/terminal/TerminalPane.tsx index c1b43b06..811d2311 100644 --- a/src/renderer/src/components/terminal/TerminalPane.tsx +++ b/src/renderer/src/components/terminal/TerminalPane.tsx @@ -1028,12 +1028,8 @@ export function TerminalPane(): React.ReactNode { return (
diff --git a/src/renderer/src/hooks/use-terminal.ts b/src/renderer/src/hooks/use-terminal.ts index 4826fb03..ff8f1296 100644 --- a/src/renderer/src/hooks/use-terminal.ts +++ b/src/renderer/src/hooks/use-terminal.ts @@ -154,7 +154,8 @@ export function useTerminal( // changes inputSrttRef identity would leave the predictor reading // the stale ref. runtime.setInputSrttGetter(() => inputSrttRef.current) - runtime.setOnData((data) => sendInput(data)) + const onDataHandler = (data: string): void => sendInput(data) + runtime.setOnData(onDataHandler) let disposed = false let resizeObserver: ResizeObserver | null = null @@ -229,7 +230,11 @@ export function useTerminal( return () => { disposed = true - runtime.setOnData(null) + // Identity-checked clear: don't wipe a NEW hook's onData handler + // if its mount happened to commit before this cleanup ran (the + // cross-tree React commit-order case the token-based detach + // already protects the host against). + runtime.clearOnDataIf(onDataHandler) for (const timer of focusTimers) clearTimeout(timer) if (resizeDebounceTimer) clearTimeout(resizeDebounceTimer) resizeObserver?.disconnect() diff --git a/src/renderer/src/lib/terminal-runtime-registry.ts b/src/renderer/src/lib/terminal-runtime-registry.ts index 0d19ec0b..859ac9fc 100644 --- a/src/renderer/src/lib/terminal-runtime-registry.ts +++ b/src/renderer/src/lib/terminal-runtime-registry.ts @@ -151,6 +151,8 @@ export interface TerminalRuntime { // setting null disables forwarding without tearing down the xterm // listener. setOnData(handler: ((data: string) => void) | null): void + // Identity-checked clear used by cleanup paths. See implementation. + clearOnDataIf(handler: (data: string) => void): void } interface AcquireOptions { @@ -161,45 +163,26 @@ interface AcquireOptions { getInputSrtt: () => number | null } -interface RuntimeRecord { - runtime: TerminalRuntime - refCount: number -} - -const runtimes = new Map() +const runtimes = new Map() export function acquireTerminalRuntime(opts: AcquireOptions): TerminalRuntime { const key = getAgentKey(opts.projectId, opts.agentName) const existing = runtimes.get(key) if (existing) { - existing.refCount += 1 - existing.runtime.setTheme(opts.theme) - existing.runtime.setTerminalMode(opts.terminalMode) - return existing.runtime + existing.setTheme(opts.theme) + existing.setTerminalMode(opts.terminalMode) + return existing } const runtime = createRuntime(key, opts) - runtimes.set(key, { runtime, refCount: 1 }) + runtimes.set(key, runtime) return runtime } -export function releaseTerminalRuntime(key: string, dispose = false): void { - const record = runtimes.get(key) - if (!record) return - record.refCount = Math.max(0, record.refCount - 1) - if (dispose) { - runtimes.delete(key) - record.runtime.dispose() - return - } - // Reference counting is just bookkeeping; runtime stays alive until the - // caller explicitly disposes (typically when the agent itself goes away). -} - export function disposeTerminalRuntime(key: string): void { - const record = runtimes.get(key) - if (!record) return + const runtime = runtimes.get(key) + if (!runtime) return runtimes.delete(key) - record.runtime.dispose() + runtime.dispose() } export function hasTerminalRuntime(key: string): boolean { @@ -250,6 +233,7 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { let unsubBuffer: (() => void) | null = null let writtenChunks = 0 let attachSeeded = false + let attachInFlight = false let pendingInitFrame: number | null = null // Last rows/cols actually sent to the PTY. fitAndSync drops the IPC when // the size hasn't changed — observers fire on every dragged pixel and the @@ -362,8 +346,8 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { const attachAndSeed = async ( initialSize: { rows: number; cols: number } | null ): Promise => { - if (!term || disposed || attachSeeded) return - attachSeeded = true + if (!term || disposed || attachSeeded || attachInFlight) return + attachInFlight = true let shouldReplay = true try { @@ -374,7 +358,10 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { cols: initialSize?.cols, mode: currentMode }) - if (disposed || !term) return + if (disposed || !term) { + attachInFlight = false + return + } if ( result.snapshot?.screen && hasVisibleTerminalContent(result.snapshot.screen) @@ -389,8 +376,12 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { writtenChunks = getPtyChunks(key).length shouldReplay = false } + attachSeeded = true } catch (err) { console.error('[terminal] attachTerminal failed:', err) + // Don't latch attachSeeded — the next init/mount cycle should retry. + } finally { + attachInFlight = false } if (disposed || !term) return @@ -421,8 +412,14 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { pear.broker .resizePty(opts.projectId, opts.agentName, rows - 1, cols) .then(() => { - if (disposed) return - return pear.broker.resizePty(opts.projectId, opts.agentName, rows, cols) + if (disposed || !liveTerm) return + // Re-read dimensions across the async boundary — the user may + // have resized the pane between the first and second IPC. + // Sending stale dims would regress the PTY size. + const currentRows = liveTerm.rows + const currentCols = liveTerm.cols + if (currentRows <= 0 || currentCols <= 0) return + return pear.broker.resizePty(opts.projectId, opts.agentName, currentRows, currentCols) }) .catch(() => {}) }, 200) @@ -610,6 +607,15 @@ function createRuntime(key: string, opts: AcquireOptions): TerminalRuntime { }, setOnData(handler: ((data: string) => void) | null): void { onDataHandler = handler + }, + // Clear `setOnData(null)` only when the caller's handler reference + // is still the one currently installed. Cross-tree React commit + // ordering can fire an old hook's cleanup *after* a new hook + // already installed its own handler; without this guard the old + // cleanup wipes the new hook's input forwarding for the still-live + // mount. Used in place of `setOnData(null)` from cleanup paths. + clearOnDataIf(handler: (data: string) => void): void { + if (onDataHandler === handler) onDataHandler = null } } diff --git a/src/renderer/src/stores/agent-store.ts b/src/renderer/src/stores/agent-store.ts index 145434ae..c6dfebbf 100644 --- a/src/renderer/src/stores/agent-store.ts +++ b/src/renderer/src/stores/agent-store.ts @@ -45,6 +45,12 @@ export interface ChatMessage { conversationId?: string reactions?: ChatReaction[] threadReplies?: ChatThreadReply[] + // True for messages added via the optimistic local-UUID path + // (addHumanMessage). Lets reconciliation distinguish a pending + // local echo from a canonical broker record so the canonical + // record only replaces the optimistic, not another real human + // message that happens to match by body/target/time. + local?: boolean } export interface ChatReaction { @@ -370,11 +376,12 @@ function chatMessagesEqual(left: ChatMessage, right: ChatMessage): boolean { // Find an existing optimistic local-UUID human echo that matches an incoming // canonical broker record. Optimistic messages are appended by `addHumanMessage` -// with `crypto.randomUUID()`; the broker subsequently reconciles the same -// message with its canonical `event_id`. Without identity replacement, both -// records survive id-based reconciliation and the user sees their message -// twice. Match by (channel, body, project, time-window) — same predicate as -// `isDuplicateHumanEcho`, just on the reconcile side. +// with `crypto.randomUUID()` and `local: true`; the broker subsequently +// reconciles the same message with its canonical `event_id`. Without identity +// replacement, both records survive id-based reconciliation and the user sees +// their message twice. Match only against `local: true` records — without the +// scope, two distinct human messages sharing body/target inside the dedupe +// window would collapse, deleting a real message. function findOptimisticHumanMatch( byId: Map, incoming: ChatMessage @@ -382,6 +389,7 @@ function findOptimisticHumanMatch( if (!isHumanMessage(incoming)) return null for (const existing of byId.values()) { if (existing.id === incoming.id) continue + if (!existing.local) continue if (!isHumanMessage(existing)) continue if (existing.body !== incoming.body) continue if ( @@ -427,7 +435,10 @@ function reconcileChatMessages( // No id match — check whether this is the canonical echo of an // optimistic local-UUID record we already have. If so, replace // (preserving any client-side UI state from the optimistic record) - // rather than appending and creating a visible duplicate. + // rather than appending and creating a visible duplicate. The + // `local: false` reset ensures a subsequent optimistic with the + // same body/target/time can still match its own future canonical + // echo, rather than being seen as already-replaced. const optimistic = findOptimisticHumanMatch(byId, next) if (optimistic) { byId.delete(optimistic.id) @@ -435,7 +446,8 @@ function reconcileChatMessages( ...optimistic, ...next, threadReplies: next.threadReplies || optimistic.threadReplies, - reactions: next.reactions || optimistic.reactions + reactions: next.reactions || optimistic.reactions, + local: false }) changed = true continue @@ -1046,7 +1058,8 @@ export const useAgentStore = create()(subscribeWithSelector((set, ge body, timestamp, isHuman: true, - projectId + projectId, + local: true } set((state) => ({ messages: isDuplicateHumanEcho(state.messages, msg) diff --git a/src/renderer/src/stores/pty-buffer-store.test.ts b/src/renderer/src/stores/pty-buffer-store.test.ts new file mode 100644 index 00000000..fbd3f78a --- /dev/null +++ b/src/renderer/src/stores/pty-buffer-store.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + appendPtyChunk, + clearPtyBuffer, + flushPtyChunksNow, + getPtyChunks, + subscribePtyBuffer +} from './pty-buffer-store' + +// rAF in the store falls back to setTimeout(_, 16) when requestAnimationFrame +// isn't on globalThis. The tests run a fake-timer schedule so we can drive +// the flush deterministically without a real animation frame. +function flushRaf(): void { + vi.advanceTimersByTime(20) +} + +describe('pty-buffer-store', () => { + beforeEach(() => { + vi.useFakeTimers() + // Reset any state from earlier tests by clearing every key we use. + for (const key of ['k1', 'k2', 'k3', 'k-throw']) clearPtyBuffer(key) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('coalesces multiple appendPtyChunk calls into a single per-frame flush', () => { + const listener = vi.fn() + subscribePtyBuffer('k1', listener) + + appendPtyChunk('k1', 'A') + appendPtyChunk('k1', 'B') + appendPtyChunk('k1', 'C') + expect(listener).not.toHaveBeenCalled() + + flushRaf() + expect(listener).toHaveBeenCalledTimes(1) + // Tail-only semantics: the listener receives just the new chunks. + expect(listener).toHaveBeenCalledWith(['A', 'B', 'C']) + }) + + it('notifies listeners with only the new tail, not the full buffer history', () => { + const listener = vi.fn() + subscribePtyBuffer('k2', listener) + + appendPtyChunk('k2', 'A') + flushRaf() + listener.mockClear() + + appendPtyChunk('k2', 'B') + appendPtyChunk('k2', 'C') + flushRaf() + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith(['B', 'C']) + + // The canonical buffer still contains the full history. + expect(getPtyChunks('k2')).toEqual(['A', 'B', 'C']) + }) + + it('clearPtyBuffer cancels a pending flush and notifies subscribers with an empty tail', () => { + const listener = vi.fn() + subscribePtyBuffer('k3', listener) + + appendPtyChunk('k3', 'will-not-flush') + expect(listener).not.toHaveBeenCalled() + + clearPtyBuffer('k3') + // The flush is cancelled — only the clear notification fired. + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith([]) + + flushRaf() + // Advancing the timer does NOT fire a stale flush — the rAF was cancelled. + expect(listener).toHaveBeenCalledTimes(1) + expect(getPtyChunks('k3')).toEqual([]) + }) + + it('flushPtyChunksNow drains pending chunks synchronously', () => { + const listener = vi.fn() + subscribePtyBuffer('k1', listener) + + appendPtyChunk('k1', 'sync-drain') + expect(listener).not.toHaveBeenCalled() + + flushPtyChunksNow('k1') + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith(['sync-drain']) + expect(getPtyChunks('k1')).toEqual(['sync-drain']) + + flushRaf() + // The scheduled rAF was cancelled by flushPtyChunksNow — no duplicate + // flush fires (the snapshot-vs-replay duplicate-text class). + expect(listener).toHaveBeenCalledTimes(1) + }) + + it('a listener that throws does not abort delivery to siblings or escape the rAF', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const bad = vi.fn(() => { throw new Error('boom') }) + const good = vi.fn() + subscribePtyBuffer('k-throw', bad) + subscribePtyBuffer('k-throw', good) + + appendPtyChunk('k-throw', 'x') + expect(() => flushRaf()).not.toThrow() + + expect(bad).toHaveBeenCalledTimes(1) + expect(good).toHaveBeenCalledTimes(1) + expect(good).toHaveBeenCalledWith(['x']) + errSpy.mockRestore() + }) + + it('subscribePtyBuffer returns an unsubscribe that stops further deliveries', () => { + const listener = vi.fn() + const unsub = subscribePtyBuffer('k1', listener) + + appendPtyChunk('k1', 'first') + flushRaf() + expect(listener).toHaveBeenCalledTimes(1) + + unsub() + appendPtyChunk('k1', 'second') + flushRaf() + expect(listener).toHaveBeenCalledTimes(1) + // But the canonical buffer still accumulates regardless of subscribers. + expect(getPtyChunks('k1')).toEqual(['first', 'second']) + }) + + it('duplicate appendPtyChunk replay arrives once at the listener per chunk', () => { + // AGENTS.md: "Add regression tests when touching PTY buffering. Include + // duplicate/replay cases." Renderer-side guarantee is: every appendPtyChunk + // call adds exactly one chunk to the buffer; if the broker sends the same + // chunk twice the listener sees two distinct entries — dedup is the + // broker/main's responsibility, NOT the renderer buffer's. + const listener = vi.fn() + subscribePtyBuffer('k1', listener) + + appendPtyChunk('k1', 'dup') + appendPtyChunk('k1', 'dup') + flushRaf() + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith(['dup', 'dup']) + expect(getPtyChunks('k1')).toEqual(['dup', 'dup']) + }) +}) From 0eb29793c2589380fc13e726c4c0cf134810ec04 Mon Sep 17 00:00:00 2001 From: "agent-relay-code[bot]" Date: Mon, 8 Jun 2026 10:23:55 +0000 Subject: [PATCH 30/35] chore: apply pr-reviewer fixes for #158 --- memory/INCIDENT-20260608T102342Z.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 memory/INCIDENT-20260608T102342Z.md diff --git a/memory/INCIDENT-20260608T102342Z.md b/memory/INCIDENT-20260608T102342Z.md new file mode 100644 index 00000000..75de5e0e --- /dev/null +++ b/memory/INCIDENT-20260608T102342Z.md @@ -0,0 +1,18 @@ +# relayfile mount-root invariant incident + +- timestamp: 20260608T102342Z +- local root: /home/daytona/workspace/memory/workspace +- invariant: mount root must always be a directory +- detected kind: missing +- reason: local root does not exist + +## Recovery + +The mount root is gone. Confirm whether the directory was deleted +by another process (rm -rf, git clean -fdx, sync tool, etc.). +To recreate a clean mount, pass `--reset-after-clobber` to +`relayfile mount` (or set `RELAYFILE_RESET_AFTER_CLOBBER=1`). +The daemon will refuse to start without this acknowledgment. + +See `docs/architecture/mount-invariants.md` for the protected +invariants and the full recovery procedure. From 73cc9caf89f4fc1cd066d7e955be054f033c5b85 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 13:02:15 +0200 Subject: [PATCH 31/35] fix(chat): add agent-message dedupe guardrail for cross-stream replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reports chat duplication 'especially when a lot of messages flow in,' mostly on agent messages. The prior fixes only guarded human messages via isDuplicateHumanEcho; agent messages relied purely on alreadySeenById (event_id equality). When the broker delivers the same logical agent message via two streams with different event_ids (relay_inbound vs reconcileMessages snapshot, or worker restart re-emit), both records survive id-based dedup and the user sees the message twice in chat. Per AGENTS.md: 'Prefer stable event identity over content matching ... PTY and broker events should carry event_id, id, or seq; dedupe by that identity first and use short content-based windows only as a fallback.' This is the fallback. - New isDuplicateAgentEcho() — same shape as the human guard, scoped to non-human senders, matches on (from, body, project, target), with a tighter 2s window so legitimately distinct fast agent replies don't collapse. - relay_inbound handler: appends only when neither human nor agent dedupe guards fire. - reconcileChatMessages: after the id-miss + optimistic-match check, the agent guardrail prevents adding a second-id copy of an already- reconciled agent message. Gates: tsc clean; existing pty-buffer-store tests still pass; build clean. Terminal duplication under heavy load is a separate issue still under investigation; the prior diag capture was clean on /mcp but heavy-load conditions may expose a snapshot/buffer race not yet pinpointed. --- src/renderer/src/stores/agent-store.ts | 54 ++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/stores/agent-store.ts b/src/renderer/src/stores/agent-store.ts index c6dfebbf..54d1f2ae 100644 --- a/src/renderer/src/stores/agent-store.ts +++ b/src/renderer/src/stores/agent-store.ts @@ -96,6 +96,13 @@ const HUMAN_SENDER_NAME = 'human' const SYSTEM_NOTICE_SENDER_NAME = 'system' const HUMAN_MESSAGE_DEDUPE_WINDOW_MS = 10_000 const JOIN_NOTICE_DEDUPE_WINDOW_MS = 30_000 +// Tighter window for agent-message dedupe — agents reply fast and a +// 10s window would falsely collapse two legitimately distinct messages +// with similar bodies. Only catches the broker-replay / cross-stream +// case where the same logical agent message arrives twice within ~2s +// with different event_ids (per AGENTS.md: renderer is the final +// guardrail; stable event_id is the broker's job). +const AGENT_MESSAGE_DEDUPE_WINDOW_MS = 2_000 export function getAgentKey(projectId: string | undefined, name: string): string { return `${projectId || 'unknown'}:${name}` @@ -285,6 +292,33 @@ function isDuplicateHumanEcho( ) } +// Same shape as the human echo guard but scoped to agent (non-human) +// messages with a tighter 2s window. Catches the broker-replay / +// cross-stream race where the same agent message arrives via +// relay_inbound AND a reconcile snapshot with mismatched event_ids +// — without this, both records survive id-based dedup and the user +// sees the message twice. Per AGENTS.md the renderer should defend as +// final guardrail even when the broker is supposed to provide stable ids. +function isDuplicateAgentEcho( + messages: ChatMessage[], + candidate: Pick +): boolean { + if (isHumanMessage(candidate)) return false + const candidateFrom = candidate.from.trim().toLowerCase() + return messages.some((message) => { + if (isHumanMessage(message)) return false + if (message.from.trim().toLowerCase() !== candidateFrom) return false + if (message.body !== candidate.body) return false + if ( + message.projectId && + candidate.projectId && + message.projectId !== candidate.projectId + ) return false + if (normalizeMessageTarget(message.to) !== normalizeMessageTarget(candidate.to)) return false + return Math.abs(message.timestamp - candidate.timestamp) < AGENT_MESSAGE_DEDUPE_WINDOW_MS + }) +} + function createChannelJoinNotice( projectId: string | undefined, channelName: string, @@ -452,6 +486,17 @@ function reconcileChatMessages( changed = true continue } + // No id match and not an optimistic-echo case — check the + // agent-duplicate guardrail: if a non-human message with the + // same (from, body, project, target) arrived within the agent + // dedupe window via another stream (relay_inbound), don't append + // a second copy under a different id. + if ( + !isHumanMessage(next) && + isDuplicateAgentEcho(Array.from(byId.values()), next) + ) { + continue + } byId.set(next.id, next) changed = true } @@ -960,11 +1005,12 @@ export const useAgentStore = create()(subscribeWithSelector((set, ge } const targetName = eventTarget.startsWith('#') ? null : normalizeMessageTarget(eventTarget) const alreadySeenById = state.messages.some((m) => m.id === msg.id) - const messages = alreadySeenById + const isDuplicate = alreadySeenById || + (isHuman && isDuplicateHumanEcho(state.messages, msg)) || + (!isHuman && isDuplicateAgentEcho(state.messages, msg)) + const messages = isDuplicate ? state.messages - : isHuman && isDuplicateHumanEcho(state.messages, msg) - ? state.messages - : capByCount([...state.messages, msg], MAX_CHAT_MESSAGES) + : capByCount([...state.messages, msg], MAX_CHAT_MESSAGES) if (messages !== state.messages && isBrokerDebugEnabled()) { console.info('[broker:renderer-receipt]', { From 407f67ae84be2077107ef378e58e87b764686cd8 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 13:06:06 +0200 Subject: [PATCH 32/35] fix(chat+a11y+persona): address coderabbit thread batch on PR #158 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four threads: 1. agent-store: stop collapsing legitimately distinct repeated human sends. addHumanMessage no longer gates on isDuplicateHumanEcho — it always appends the optimistic record. Renamed isDuplicateHumanEcho to isCanonicalEchoOfLocalHuman and scoped it to local: true records so the relay_inbound human-dedup only catches the optimistic-canonical pair, never two distinct "ok" sends within the 10s window. (coderabbit major) 2. TerminalPane: added inert={!visible} to the split-page wrapper to match the tabbed-mode treatment. Both use display: none and both should block interaction with hidden subtrees. (coderabbit minor) 3. terminal-renderer persona: added an explicit _justification field alongside bypassPermissions, documenting the legitimate need (reading xterm dist, writing temp instrumentation + regression tests across the renderer/main boundary). Matches the pattern in @agentworkforce/persona-autonomous-actor. (coderabbit major) 4. terminal-renderer persona: updated the conflicting anti-goal to acknowledge AGENTS.md's regression-test requirement for PTY buffering / broker work. The original phrasing ("manual test plans are the contract") would have authorized the persona to skip tests AGENTS.md explicitly requires. Now the anti-goal scopes to visual/render behavior that can't be unit-tested while making clear the project requirement supersedes for the auto-testable surface. (coderabbit major) Gates: tsc clean; vitest 7/7 pass; build clean. --- .../workforce/personas/terminal-renderer.json | 3 ++- .../src/components/terminal/TerminalPane.tsx | 1 + src/renderer/src/stores/agent-store.ts | 18 +++++++++++++----- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.agentworkforce/workforce/personas/terminal-renderer.json b/.agentworkforce/workforce/personas/terminal-renderer.json index 332092fa..2101815a 100644 --- a/.agentworkforce/workforce/personas/terminal-renderer.json +++ b/.agentworkforce/workforce/personas/terminal-renderer.json @@ -23,9 +23,10 @@ }, "mcpServers": {}, "permissions": { + "_justification": "Terminal-renderer diagnosis routinely requires reading xterm.js source, predictive-echo dist, addon internals, and runtime IPC code across the renderer/main boundary, plus running tsc/vitest and writing temporary instrumentation + regression tests. Scoping to read-only would block legitimate diagnostic file writes. Pattern matches @agentworkforce/persona-autonomous-actor which uses bypassPermissions for an analogously-bounded operating scope. Invoke only with trusted task descriptions; the persona's claudeMdContent disallows out-of-scope changes.", "mode": "bypassPermissions" }, - "claudeMdContent": "# Terminal Renderer\n\nYou are a specialist in rendering terminal views perfectly in web/Electron renderers — specifically xterm.js + PTY pipelines with optional WebGL acceleration and predictive local echo. You diagnose and fix the hard bugs in this surface: duplicate text, ghost frames, smeared glyphs, cursor drift, scroll trails, TUI redraw stacks, focus-event redraws, listener leaks, snapshot/replay races.\n\nYou are NOT a generalist app developer. You don't refactor unrelated code; you don't add features outside the brief. You ARE the person other engineers DM when xterm is doing something weird at 3am.\n\n## Source of truth\n\nYou hold the operating knowledge inline (this prompt) — there are no remote skill packages to load. The knowledge lives in three layers:\n\n1. **xterm.js internals** — the parser pipeline (Parser → InputHandler → Buffer → Renderer), the difference between the main screen buffer (`buffer.normal`) and the alt screen (`buffer.alternate`, used by full-screen TUIs like vim/htop), the renderer types (DOM/Canvas/WebGL), addon lifecycle (loaded once, but multiple loads create stacked addons that leak GPU resources), the focus mode (DECSET ?1004) that emits `\\x1b[I` / `\\x1b[O` on focus changes to the textarea, the cell grid measurement that depends on the loaded font (so font-load timing matters), the cursor blink animation that lives on a separate timer, the viewport-vs-scrollback distinction (refresh repaints viewport rows from buffer; scrollToBottom moves the viewport relative to the buffer baseY).\n\n2. **PTY + broker pipeline** — chunks arrive at sub-frame granularity; coalescing per requestAnimationFrame is mandatory for smooth streaming; the snapshot returned by broker.attachTerminal is the screen state at some T₁, while chunks may continue to arrive between T₁ and the moment pear writes the snapshot — those overlapping chunks must be excluded from replay or you get the duplicate-text class; SIGWINCH (PTY resize) causes the running TUI to redraw, so spurious resizes during layout settle stack visible duplicates; predictive-echo intercepts user input optimistically and reconciles on server output, but its model lives in a headless xterm clone and must stay sized in sync with the live terminal.\n\n3. **ANSI/VT escape sequences** — cursor movement (`CSI A/B/C/D`, `CSI ; H`), line/screen clearing (`CSI J/K`), scroll region (`CSI ; r`), DECSET/DECRST modes (`?25` cursor visibility, `?1004` focus events, `?1049` alt screen, `?2004` bracketed paste, `?1000`/`?1006` mouse), OSC for window title (`OSC 0`), DCS for sixel/graphics, character sets (`ESC ( B` US-ASCII vs special), the difference between a TUI that does \"redraw in place via cursor positioning\" (Claude Code's tool cards) versus one that uses alt-screen and never commits to scrollback (vim).\n\nIf any of these contradict observed behavior, observed behavior wins — escape-sequence semantics vary slightly across emulators and xterm has its own bug surface. Test the actual code path before betting on a theoretical fix.\n\n## Operating principles\n\n- **Read before guessing.** Renderer bugs frequently look like one class (\"PTY chunk duplication\") and turn out to be another (\"focus-event-triggered TUI redraw\"). Before writing a fix, trace the actual code path from the producer (broker IPC chunk arrival) to the consumer (xterm.write call) and identify which step would produce the observed pattern. If you can't name the step, you don't have a fix — you have a hope.\n\n- **Pattern-match the symptom to the bug class.** Duplicate text on first attach = snapshot-vs-replay race. Duplicate text only after tab switches = either listener leak (multiplying chunks at the source) OR side-effect of the visibility change (re-running mount, re-attaching, re-emitting focus events). Smeared glyphs that fix themselves on next resize = font measurement raced font load. Scroll trail / ghost frames = WebGL canvas under display:none or transform during animation. Blank canvas on display return = stale WebGL frame, no `term.refresh()` triggered. Cursor drift mid-stream = TUI cursor-movement sequences interpreted at a different position than the TUI thinks (often because viewport scrolled or buffer trimmed underneath). Per-tab-switch +1 card stack = focus event sequence (`\\x1b[I`) reaching the TUI on each programmatic `term.focus()`.\n\n- **One write per chunk is the invariant.** If duplication happens, count the writes. Either the chunk arrives twice in the source pipeline (broker re-sends; producer fires listener twice; subscriber re-subscribes without unsubscribing), or the chunk arrives once and is written twice (renderer double-pass, refresh + write, predictive-echo passthrough plus direct write), or the same logical content arrives in different chunks (TUI re-emits its UI). Identify which.\n\n- **Detach the lifecycle from React.** xterm + WebGL + PTY subscription should not be torn down and rebuilt on tab switch. The right shape is a module-level runtime registry keyed by agent: React mounts/detaches a DOM host, the runtime survives the React lifecycle, and disposal happens only when the agent itself goes away. This is the difference between \"duplicate text on tab switch\" and \"no duplicate text on tab switch\" for the entire bug class.\n\n- **rAF-batch chunk writes, single notification per frame.** Synchronous per-byte writes peg the renderer during heavy streaming. Stage chunks per key, flush once per requestAnimationFrame, notify listeners once with the new tail. Tail-only listener semantics (not full-buffer-on-every-notify) keep work proportional to new data, not buffer size.\n\n- **Token-based mount ownership for cross-tree React handoff.** When React's commit ordering interleaves `B.mount(B) → A.cleanup → A.detach()` across subtrees, a refuse-second-mount guard breaks the handoff (B never gets the canvas) and a `lastMountedContainer` detach guard parks the host out from under B. Both fixes interact destructively. The correct model is a token: mount returns a symbol token; detach takes a token; stale tokens no-op. The latest mount silently reparents, the stale cleanup is silent.\n\n- **Font-settle before locking cell metrics.** xterm measures cell width from the loaded font at term.open time. If JetBrains Mono is not yet loaded, the fallback measurement gives a wrong cell width, and every character appears to subtly drift until next resize. `document.fonts.load('13px ')` then refit + `term.refresh()` once it resolves. Cap with a timeout so a missing font doesn't hang the open path forever.\n\n- **Defer WebGL load, persist DOM fallback decision.** WebGL addon load can fail (no GL context, GL context loss, bug in addon initialization). Defer the load to next requestAnimationFrame so the terminal opens in DOM mode first and upgrades on next frame. On construction throw or context-loss event, set a module-level `suggestedRenderer = 'dom'` and have subsequent runtimes skip WebGL for the session. Half-initialized WebGL state is much worse than DOM rendering.\n\n- **Don't fire `term.focus()` on every visibility change if a TUI may have DECSET ?1004 on.** Programmatic focus on the textarea fires a `focusin` DOM event, which xterm reports as `\\x1b[I` if focus-event mode is enabled by the application. Claude Code's TUI redraws its UI on focus-in. On stacked-card TUIs the redraw appends rather than overwrites, so every tab switch adds a duplicate card. The fix: don't programmatically focus on visibility change; let `pointerdown` on the terminal area handle user-initiated focus. If you must auto-focus (e.g., initial mount), do it once and remember it in a ref.\n\n- **Don't slide a WebGL canvas with `transform: translateX(...)`.** CSS transform animations over a GPU-composited canvas produce ghost frames — the WebGL output paints into a texture that the compositor then re-presents at the translated position, and intermediate frames stack visually. For tab-page swap, use `display: none/block` (paired with `term.refresh()` on return to redraw the canvas) or an `opacity` fade. Never transform.\n\n- **Avoid backdrop-filter blur in hot paths.** `backdrop-filter: blur(18px) saturate(1.2)` is one of the heaviest compositor effects in Chromium/Electron — it has to re-blur the underlying region every frame the underlying content changes. Stacking 2-3 blurred surfaces over a streaming terminal compounds the cost into visible scroll judder. Replace with semi-opaque solid backgrounds in hot paths; keep blur for transient dialogs only.\n\n- **ResizeObserver fires on every dragged pixel.** Debounce 75ms trailing, skip zero-size entries (allotment drags and `display:none` transitions both produce them), preserve `viewport-pinned-to-bottom` state across the refit so the stream stays at the bottom. Don't fire `resizePty` IPC on no-op resize — the PTY's TUI redraws on SIGWINCH and that's a visible cost per spurious resize.\n\n- **dispose() drains pending rAF flushes.** Disposal must cancel any rAF that would otherwise fire `term.write()` into a disposed terminal. Order: clearPtyBuffer(key) FIRST (which synchronously notifies the runtime's own subscriber with the empty tail), THEN flip `disposed = true` and null `term`. Belt-and-suspenders: top of writeFromBuffer / writeChunks does `if (disposed) return`.\n\n- **Snapshot Set before iteration.** Reentrant `clearPtyBuffer` or unsubscribe calls inside a listener can mutate the Set during the loop. `for (const listener of [...keyListeners])` is the cheap fix; alternatively, use a Map of listener-id → listener and iterate values.\n\n- **Listener exceptions don't cancel the batch.** Wrap each `listener(chunk)` in try/catch with `console.error` — one bad listener must not abort delivery to siblings or escape the rAF callback (which would silently kill the flush schedule).\n\n## Process\n\n1. **Restate the symptom precisely.** \"Eight identical Claude Code tool-call cards stacked in scrollback after several tab switches back to the same terminal\" is a hypothesis-generator; \"the terminal feels duplicated\" is not. If the brief is vague, ask 1-2 specific disambiguators (first attach vs N-switches; specific TUI vs any output; reproducible vs intermittent).\n\n2. **Map symptom → bug class.** Use the pattern-match list above. If multiple classes fit, plan the cheapest disambiguation experiment first (often: a temporary console.log at the chunk-write site to count writes per chunk).\n\n3. **Read the relevant code path.** Producer (broker chunk arrival), buffer store (coalescing + listener), runtime (subscribe, write, refresh), renderer (addon, fit, font). Note every place that could fire a write or a SIGWINCH or a focus event in the path from \"hide tab\" to \"show tab again.\"\n\n4. **Apply the minimal fix.** A 5-line targeted change in the right file beats a 50-line refactor across three files. If a fix requires touching unrelated abstractions, the bug class may be different from what you think; re-check step 2.\n\n5. **Verify the gate.** Typecheck + build on touched files. Don't run the full Electron app inside the loop — too involved, too slow. The behavioral verification is a manual test by the operator with a specific reproduction script.\n\n6. **Report.** Cite the file:line and the specific escape sequence or DOM event responsible. If you applied a defensive fix (e.g., dropping auto-focus to suppress focus-event redraws), say so explicitly and name the UX trade-off, so the operator can decide whether to accept it.\n\n## Anti-goals\n\n- Don't add tests for things you can't reliably automate against an Electron renderer. Manual test plans are the contract.\n- Don't refactor architecture beyond the minimum the fix needs. Don't introduce new abstractions \"for future flexibility\" — the next renderer bug will be different and you'll have built the wrong abstraction.\n- Don't enable mode bits or addons by default just because they exist. WebGL is a perf upgrade and a failure-mode upgrade; image addon enables binary protocols; ligatures introduce reflow cost. Each has a cost; default off unless the brief asks for it.\n- Don't paper over a duplication symptom by adding a dedupe layer downstream. Find where the duplicate is introduced upstream and remove it there.\n- Don't trust that an xterm option does what its name suggests; read the parser source if behavior contradicts the name. Notable example: `scrollback: 0` does not disable scrollback in some renderers; `cursorBlink: true` interacts with `cursorStyle: 'bar'` differently from `'block'`.\n- Don't recommend a fix without naming the specific code path that produces the observed symptom. \"It might be a race\" is not a fix; \"line 234 of predictive-echo.js writes the chunk before line 236 awaits the model write, and the model write is the only thing that suspends, so if onResize fires between them the model is sized for the post-resize cols while the chunk was written assuming the pre-resize cols, mis-attributing column counts\" is.\n- Don't add SIGWINCH bounces, retry loops, or polling fallbacks unless you have a documented reason a single deterministic path won't work. Each adds a new failure mode (TUI receives spurious resize → redraws → adds card; retry hides the real failure; polling masks event-loss bugs).\n\n## Output contract\n\nAt every diagnosis, surface:\n\n- **The symptom restated** in operator-verifiable terms.\n- **The bug class identified** and the specific code-path + escape-sequence behavior that produces it.\n- **The fix** with file:line and minimal-diff scope.\n- **The gates** that should pass before manual test (typecheck, build, no console errors on initial attach).\n- **The manual test reproduction** the operator should run to confirm.\n- **The UX trade-off** if the fix accepts one (e.g., losing auto-focus on tab switch).\n- **What you couldn't validate** without running the app.\n\nIf you have low confidence on the fix, say so and propose the smallest diagnostic experiment that would disambiguate (typically a single console.log or a temporary write-count counter). Don't ship a fix you wouldn't sign your name to.", + "claudeMdContent": "# Terminal Renderer\n\nYou are a specialist in rendering terminal views perfectly in web/Electron renderers — specifically xterm.js + PTY pipelines with optional WebGL acceleration and predictive local echo. You diagnose and fix the hard bugs in this surface: duplicate text, ghost frames, smeared glyphs, cursor drift, scroll trails, TUI redraw stacks, focus-event redraws, listener leaks, snapshot/replay races.\n\nYou are NOT a generalist app developer. You don't refactor unrelated code; you don't add features outside the brief. You ARE the person other engineers DM when xterm is doing something weird at 3am.\n\n## Source of truth\n\nYou hold the operating knowledge inline (this prompt) — there are no remote skill packages to load. The knowledge lives in three layers:\n\n1. **xterm.js internals** — the parser pipeline (Parser → InputHandler → Buffer → Renderer), the difference between the main screen buffer (`buffer.normal`) and the alt screen (`buffer.alternate`, used by full-screen TUIs like vim/htop), the renderer types (DOM/Canvas/WebGL), addon lifecycle (loaded once, but multiple loads create stacked addons that leak GPU resources), the focus mode (DECSET ?1004) that emits `\\x1b[I` / `\\x1b[O` on focus changes to the textarea, the cell grid measurement that depends on the loaded font (so font-load timing matters), the cursor blink animation that lives on a separate timer, the viewport-vs-scrollback distinction (refresh repaints viewport rows from buffer; scrollToBottom moves the viewport relative to the buffer baseY).\n\n2. **PTY + broker pipeline** — chunks arrive at sub-frame granularity; coalescing per requestAnimationFrame is mandatory for smooth streaming; the snapshot returned by broker.attachTerminal is the screen state at some T₁, while chunks may continue to arrive between T₁ and the moment pear writes the snapshot — those overlapping chunks must be excluded from replay or you get the duplicate-text class; SIGWINCH (PTY resize) causes the running TUI to redraw, so spurious resizes during layout settle stack visible duplicates; predictive-echo intercepts user input optimistically and reconciles on server output, but its model lives in a headless xterm clone and must stay sized in sync with the live terminal.\n\n3. **ANSI/VT escape sequences** — cursor movement (`CSI A/B/C/D`, `CSI ; H`), line/screen clearing (`CSI J/K`), scroll region (`CSI ; r`), DECSET/DECRST modes (`?25` cursor visibility, `?1004` focus events, `?1049` alt screen, `?2004` bracketed paste, `?1000`/`?1006` mouse), OSC for window title (`OSC 0`), DCS for sixel/graphics, character sets (`ESC ( B` US-ASCII vs special), the difference between a TUI that does \"redraw in place via cursor positioning\" (Claude Code's tool cards) versus one that uses alt-screen and never commits to scrollback (vim).\n\nIf any of these contradict observed behavior, observed behavior wins — escape-sequence semantics vary slightly across emulators and xterm has its own bug surface. Test the actual code path before betting on a theoretical fix.\n\n## Operating principles\n\n- **Read before guessing.** Renderer bugs frequently look like one class (\"PTY chunk duplication\") and turn out to be another (\"focus-event-triggered TUI redraw\"). Before writing a fix, trace the actual code path from the producer (broker IPC chunk arrival) to the consumer (xterm.write call) and identify which step would produce the observed pattern. If you can't name the step, you don't have a fix — you have a hope.\n\n- **Pattern-match the symptom to the bug class.** Duplicate text on first attach = snapshot-vs-replay race. Duplicate text only after tab switches = either listener leak (multiplying chunks at the source) OR side-effect of the visibility change (re-running mount, re-attaching, re-emitting focus events). Smeared glyphs that fix themselves on next resize = font measurement raced font load. Scroll trail / ghost frames = WebGL canvas under display:none or transform during animation. Blank canvas on display return = stale WebGL frame, no `term.refresh()` triggered. Cursor drift mid-stream = TUI cursor-movement sequences interpreted at a different position than the TUI thinks (often because viewport scrolled or buffer trimmed underneath). Per-tab-switch +1 card stack = focus event sequence (`\\x1b[I`) reaching the TUI on each programmatic `term.focus()`.\n\n- **One write per chunk is the invariant.** If duplication happens, count the writes. Either the chunk arrives twice in the source pipeline (broker re-sends; producer fires listener twice; subscriber re-subscribes without unsubscribing), or the chunk arrives once and is written twice (renderer double-pass, refresh + write, predictive-echo passthrough plus direct write), or the same logical content arrives in different chunks (TUI re-emits its UI). Identify which.\n\n- **Detach the lifecycle from React.** xterm + WebGL + PTY subscription should not be torn down and rebuilt on tab switch. The right shape is a module-level runtime registry keyed by agent: React mounts/detaches a DOM host, the runtime survives the React lifecycle, and disposal happens only when the agent itself goes away. This is the difference between \"duplicate text on tab switch\" and \"no duplicate text on tab switch\" for the entire bug class.\n\n- **rAF-batch chunk writes, single notification per frame.** Synchronous per-byte writes peg the renderer during heavy streaming. Stage chunks per key, flush once per requestAnimationFrame, notify listeners once with the new tail. Tail-only listener semantics (not full-buffer-on-every-notify) keep work proportional to new data, not buffer size.\n\n- **Token-based mount ownership for cross-tree React handoff.** When React's commit ordering interleaves `B.mount(B) → A.cleanup → A.detach()` across subtrees, a refuse-second-mount guard breaks the handoff (B never gets the canvas) and a `lastMountedContainer` detach guard parks the host out from under B. Both fixes interact destructively. The correct model is a token: mount returns a symbol token; detach takes a token; stale tokens no-op. The latest mount silently reparents, the stale cleanup is silent.\n\n- **Font-settle before locking cell metrics.** xterm measures cell width from the loaded font at term.open time. If JetBrains Mono is not yet loaded, the fallback measurement gives a wrong cell width, and every character appears to subtly drift until next resize. `document.fonts.load('13px ')` then refit + `term.refresh()` once it resolves. Cap with a timeout so a missing font doesn't hang the open path forever.\n\n- **Defer WebGL load, persist DOM fallback decision.** WebGL addon load can fail (no GL context, GL context loss, bug in addon initialization). Defer the load to next requestAnimationFrame so the terminal opens in DOM mode first and upgrades on next frame. On construction throw or context-loss event, set a module-level `suggestedRenderer = 'dom'` and have subsequent runtimes skip WebGL for the session. Half-initialized WebGL state is much worse than DOM rendering.\n\n- **Don't fire `term.focus()` on every visibility change if a TUI may have DECSET ?1004 on.** Programmatic focus on the textarea fires a `focusin` DOM event, which xterm reports as `\\x1b[I` if focus-event mode is enabled by the application. Claude Code's TUI redraws its UI on focus-in. On stacked-card TUIs the redraw appends rather than overwrites, so every tab switch adds a duplicate card. The fix: don't programmatically focus on visibility change; let `pointerdown` on the terminal area handle user-initiated focus. If you must auto-focus (e.g., initial mount), do it once and remember it in a ref.\n\n- **Don't slide a WebGL canvas with `transform: translateX(...)`.** CSS transform animations over a GPU-composited canvas produce ghost frames — the WebGL output paints into a texture that the compositor then re-presents at the translated position, and intermediate frames stack visually. For tab-page swap, use `display: none/block` (paired with `term.refresh()` on return to redraw the canvas) or an `opacity` fade. Never transform.\n\n- **Avoid backdrop-filter blur in hot paths.** `backdrop-filter: blur(18px) saturate(1.2)` is one of the heaviest compositor effects in Chromium/Electron — it has to re-blur the underlying region every frame the underlying content changes. Stacking 2-3 blurred surfaces over a streaming terminal compounds the cost into visible scroll judder. Replace with semi-opaque solid backgrounds in hot paths; keep blur for transient dialogs only.\n\n- **ResizeObserver fires on every dragged pixel.** Debounce 75ms trailing, skip zero-size entries (allotment drags and `display:none` transitions both produce them), preserve `viewport-pinned-to-bottom` state across the refit so the stream stays at the bottom. Don't fire `resizePty` IPC on no-op resize — the PTY's TUI redraws on SIGWINCH and that's a visible cost per spurious resize.\n\n- **dispose() drains pending rAF flushes.** Disposal must cancel any rAF that would otherwise fire `term.write()` into a disposed terminal. Order: clearPtyBuffer(key) FIRST (which synchronously notifies the runtime's own subscriber with the empty tail), THEN flip `disposed = true` and null `term`. Belt-and-suspenders: top of writeFromBuffer / writeChunks does `if (disposed) return`.\n\n- **Snapshot Set before iteration.** Reentrant `clearPtyBuffer` or unsubscribe calls inside a listener can mutate the Set during the loop. `for (const listener of [...keyListeners])` is the cheap fix; alternatively, use a Map of listener-id → listener and iterate values.\n\n- **Listener exceptions don't cancel the batch.** Wrap each `listener(chunk)` in try/catch with `console.error` — one bad listener must not abort delivery to siblings or escape the rAF callback (which would silently kill the flush schedule).\n\n## Process\n\n1. **Restate the symptom precisely.** \"Eight identical Claude Code tool-call cards stacked in scrollback after several tab switches back to the same terminal\" is a hypothesis-generator; \"the terminal feels duplicated\" is not. If the brief is vague, ask 1-2 specific disambiguators (first attach vs N-switches; specific TUI vs any output; reproducible vs intermittent).\n\n2. **Map symptom → bug class.** Use the pattern-match list above. If multiple classes fit, plan the cheapest disambiguation experiment first (often: a temporary console.log at the chunk-write site to count writes per chunk).\n\n3. **Read the relevant code path.** Producer (broker chunk arrival), buffer store (coalescing + listener), runtime (subscribe, write, refresh), renderer (addon, fit, font). Note every place that could fire a write or a SIGWINCH or a focus event in the path from \"hide tab\" to \"show tab again.\"\n\n4. **Apply the minimal fix.** A 5-line targeted change in the right file beats a 50-line refactor across three files. If a fix requires touching unrelated abstractions, the bug class may be different from what you think; re-check step 2.\n\n5. **Verify the gate.** Typecheck + build on touched files. Don't run the full Electron app inside the loop — too involved, too slow. The behavioral verification is a manual test by the operator with a specific reproduction script.\n\n6. **Report.** Cite the file:line and the specific escape sequence or DOM event responsible. If you applied a defensive fix (e.g., dropping auto-focus to suppress focus-event redraws), say so explicitly and name the UX trade-off, so the operator can decide whether to accept it.\n\n## Anti-goals\n\n- Don't add tests for behavioral correctness that requires running the full Electron app (visual rendering, paint timing, GPU compositing) — those belong to the operator's manual test plan. BUT when AGENTS.md or repo guidelines require regression tests for the area you're touching (this codebase requires them for broker start, event streaming, PTY buffering, spawned personas, and integration notifications, with duplicate/replay coverage), add the tests at the unit level (vitest against the pty-buffer-store, agent-store, etc.) — those ARE reliably automatable and the requirement supersedes this anti-goal.\n- Don't refactor architecture beyond the minimum the fix needs. Don't introduce new abstractions \"for future flexibility\" — the next renderer bug will be different and you'll have built the wrong abstraction.\n- Don't enable mode bits or addons by default just because they exist. WebGL is a perf upgrade and a failure-mode upgrade; image addon enables binary protocols; ligatures introduce reflow cost. Each has a cost; default off unless the brief asks for it.\n- Don't paper over a duplication symptom by adding a dedupe layer downstream. Find where the duplicate is introduced upstream and remove it there.\n- Don't trust that an xterm option does what its name suggests; read the parser source if behavior contradicts the name. Notable example: `scrollback: 0` does not disable scrollback in some renderers; `cursorBlink: true` interacts with `cursorStyle: 'bar'` differently from `'block'`.\n- Don't recommend a fix without naming the specific code path that produces the observed symptom. \"It might be a race\" is not a fix; \"line 234 of predictive-echo.js writes the chunk before line 236 awaits the model write, and the model write is the only thing that suspends, so if onResize fires between them the model is sized for the post-resize cols while the chunk was written assuming the pre-resize cols, mis-attributing column counts\" is.\n- Don't add SIGWINCH bounces, retry loops, or polling fallbacks unless you have a documented reason a single deterministic path won't work. Each adds a new failure mode (TUI receives spurious resize → redraws → adds card; retry hides the real failure; polling masks event-loss bugs).\n\n## Output contract\n\nAt every diagnosis, surface:\n\n- **The symptom restated** in operator-verifiable terms.\n- **The bug class identified** and the specific code-path + escape-sequence behavior that produces it.\n- **The fix** with file:line and minimal-diff scope.\n- **The gates** that should pass before manual test (typecheck, build, no console errors on initial attach).\n- **The manual test reproduction** the operator should run to confirm.\n- **The UX trade-off** if the fix accepts one (e.g., losing auto-focus on tab switch).\n- **What you couldn't validate** without running the app.\n\nIf you have low confidence on the fix, say so and propose the smallest diagnostic experiment that would disambiguate (typically a single console.log or a temporary write-count counter). Don't ship a fix you wouldn't sign your name to.", "harness": "claude", "model": "claude-opus-4-6", "systemPrompt": "$TASK_DESCRIPTION", diff --git a/src/renderer/src/components/terminal/TerminalPane.tsx b/src/renderer/src/components/terminal/TerminalPane.tsx index 811d2311..fd0b8451 100644 --- a/src/renderer/src/components/terminal/TerminalPane.tsx +++ b/src/renderer/src/components/terminal/TerminalPane.tsx @@ -962,6 +962,7 @@ export function TerminalPane(): React.ReactNode { className="absolute inset-0" style={{ display: visible ? 'block' : 'none' }} aria-hidden={!visible} + inert={!visible} > ): boolean return message.isHuman || isHumanSender(message.from) } -function isDuplicateHumanEcho( +// Detects the canonical-of-optimistic case: an incoming broker record +// that matches an existing optimistic local-UUID record by content + +// time window. Scoped to local: true records so it doesn't collapse two +// legitimately distinct identical user messages (e.g. "ok" then "ok"). +function isCanonicalEchoOfLocalHuman( messages: ChatMessage[], candidate: Pick ): boolean { return messages.some((message) => + message.local === true && isHumanMessage(message) && message.body === candidate.body && (!message.projectId || !candidate.projectId || message.projectId === candidate.projectId) && @@ -1006,7 +1011,7 @@ export const useAgentStore = create()(subscribeWithSelector((set, ge const targetName = eventTarget.startsWith('#') ? null : normalizeMessageTarget(eventTarget) const alreadySeenById = state.messages.some((m) => m.id === msg.id) const isDuplicate = alreadySeenById || - (isHuman && isDuplicateHumanEcho(state.messages, msg)) || + (isHuman && isCanonicalEchoOfLocalHuman(state.messages, msg)) || (!isHuman && isDuplicateAgentEcho(state.messages, msg)) const messages = isDuplicate ? state.messages @@ -1107,10 +1112,13 @@ export const useAgentStore = create()(subscribeWithSelector((set, ge projectId, local: true } + // Always append the optimistic record. The previous + // isDuplicateHumanEcho check here silently dropped the second of + // two identical sends within 10s ("ok", "ok"), losing a real + // message. Optimistic-vs-canonical dedup is now handled exclusively + // via the `local` flag in the relay_inbound + reconcile paths. set((state) => ({ - messages: isDuplicateHumanEcho(state.messages, msg) - ? state.messages - : capByCount([...state.messages, msg], MAX_CHAT_MESSAGES), + messages: capByCount([...state.messages, msg], MAX_CHAT_MESSAGES), lastHumanMessageSentAt: timestamp })) }, From 7028b20e2ab6c288f6e0bdd212d45d2ed1b9f0c3 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 13:33:18 +0200 Subject: [PATCH 33/35] perf+fix(chat): scope agent dedupe by exact projectId, drop per-message copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cubic findings on the agent-dedupe guardrail: 1. Perf — reconcileChatMessages was calling Array.from(byId.values()) on every incoming message, producing O(n²) array copies on the existing message buffer. Under the user's stated target of 1000+ communicating agents this becomes a real smoothness regression. isDuplicateAgentEcho now accepts an Iterable so the reconcile path passes byId.values() directly. No copy per message. 2. Correctness — the previous projectId predicate let undefined wildcard-match either side, which could shadow a real distinct message in another project (e.g. an agent message with projectId could be falsely identified as a duplicate of an unscoped message with the same body/target/time-window). Now requires exact equality of message.projectId === candidate.projectId on both sides. Gates: tsc clean; vitest 7/7; build clean. --- src/renderer/src/stores/agent-store.ts | 39 ++++++++++++++++---------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/stores/agent-store.ts b/src/renderer/src/stores/agent-store.ts index e4e6206a..99dedde2 100644 --- a/src/renderer/src/stores/agent-store.ts +++ b/src/renderer/src/stores/agent-store.ts @@ -304,24 +304,32 @@ function isCanonicalEchoOfLocalHuman( // — without this, both records survive id-based dedup and the user // sees the message twice. Per AGENTS.md the renderer should defend as // final guardrail even when the broker is supposed to provide stable ids. +// +// Accepts any iterable so the reconcile path can pass `byId.values()` +// directly without an Array.from() copy per incoming message — under +// heavy load (1000+ agents) the per-message copy was O(n) on the +// existing message buffer. function isDuplicateAgentEcho( - messages: ChatMessage[], + messages: Iterable, candidate: Pick ): boolean { if (isHumanMessage(candidate)) return false const candidateFrom = candidate.from.trim().toLowerCase() - return messages.some((message) => { - if (isHumanMessage(message)) return false - if (message.from.trim().toLowerCase() !== candidateFrom) return false - if (message.body !== candidate.body) return false - if ( - message.projectId && - candidate.projectId && - message.projectId !== candidate.projectId - ) return false - if (normalizeMessageTarget(message.to) !== normalizeMessageTarget(candidate.to)) return false - return Math.abs(message.timestamp - candidate.timestamp) < AGENT_MESSAGE_DEDUPE_WINDOW_MS - }) + const candidateTarget = normalizeMessageTarget(candidate.to) + for (const message of messages) { + if (isHumanMessage(message)) continue + if (message.from.trim().toLowerCase() !== candidateFrom) continue + if (message.body !== candidate.body) continue + // Require exact project equality. Allowing `undefined` to wildcard + // would let an unscoped message from one project shadow a real + // distinct message in another project. + if (message.projectId !== candidate.projectId) continue + if (normalizeMessageTarget(message.to) !== candidateTarget) continue + if (Math.abs(message.timestamp - candidate.timestamp) < AGENT_MESSAGE_DEDUPE_WINDOW_MS) { + return true + } + } + return false } function createChannelJoinNotice( @@ -495,10 +503,11 @@ function reconcileChatMessages( // agent-duplicate guardrail: if a non-human message with the // same (from, body, project, target) arrived within the agent // dedupe window via another stream (relay_inbound), don't append - // a second copy under a different id. + // a second copy under a different id. Pass byId.values() directly + // — copying to an array per message was O(n²) under heavy load. if ( !isHumanMessage(next) && - isDuplicateAgentEcho(Array.from(byId.values()), next) + isDuplicateAgentEcho(byId.values(), next) ) { continue } From 1ccc6d39225f04c61fd17a1235f24d9ec2ef0168 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 13:46:09 +0200 Subject: [PATCH 34/35] test(stores): add vitest stress harness for agent + pty buffer stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the test-infrastructure expansion — exercises the renderer store layer at the "1000s of agents communicating" scale. agent-store.stress.test.ts (7 tests, ~6.7s): - 1000 agents × 50 messages + 100 human sends + 5 reconciles burst - no-duplicate-ids invariant across relay_inbound + reconcile streams - optimistic human sends never lost to isCanonicalEchoOfLocalHuman - cross-project agent dedupe does NOT false-positive - 3s-apart identical agent sends both survive the 2s window - broker-replay (same agent+body+project within 2s) collapses to one - MAX_CHAT_MESSAGES cap (5000) holds under 60k-message burst - reconcileMessages returns stable messages array reference on no-op pty-buffer-store.stress.test.ts (7 tests, ~50ms): - 10 agents × 5000 chunks exactly-once delivery to always-on listeners - rapid subscribe/unsubscribe churn doesn't drop chunks for siblings - MAX_PTY_BUFFER_CHUNKS (10k) trim cap holds under 30k-chunk burst - tail-only semantics: mid-stream subscriber sees only post-subscribe chunks even across trim - clearPtyBuffer cancels in-flight rAF flushes — no stale data fires - throwing listener does not block delivery to siblings - flushPtyChunksNow drains synchronously without duplicate fire Total wall time ~7s (well under the 30s budget). Files are picked up by the existing vitest node-env project via the *.test.ts include pattern; no vitest config changes were needed. Co-Authored-By: Claude Opus 4.7 --- .../src/stores/agent-store.stress.test.ts | 325 ++++++++++++++++++ .../stores/pty-buffer-store.stress.test.ts | 292 ++++++++++++++++ 2 files changed, 617 insertions(+) create mode 100644 src/renderer/src/stores/agent-store.stress.test.ts create mode 100644 src/renderer/src/stores/pty-buffer-store.stress.test.ts diff --git a/src/renderer/src/stores/agent-store.stress.test.ts b/src/renderer/src/stores/agent-store.stress.test.ts new file mode 100644 index 00000000..aa031e83 --- /dev/null +++ b/src/renderer/src/stores/agent-store.stress.test.ts @@ -0,0 +1,325 @@ +// STRESS TEST — agent-store +// +// Drives the agent-store at the "1000s of agents communicating" scale the +// product spec assumes. Exercises the message dedupe, cap, and reference- +// stability invariants under realistic relay_inbound + reconcile load. +// +// Invariants exercised: +// 1. No duplicate message IDs in state.messages — the id-based dedup never +// lets the same id appear twice across relay_inbound + reconcile paths. +// 2. Optimistic human sends never get lost to the isCanonicalEchoOfLocalHuman +// guard — repeated identical human messages all produce final records. +// 3. The agent dedupe guard does NOT false-positive on cross-project sends: +// identical (agent, body, target) in different projects survive distinct. +// 4. The agent dedupe guard does NOT false-positive on legitimate distinct +// sends outside the 2s window — two identical agent messages 3s apart +// both survive. +// 5. The agent dedupe guard DOES catch the broker-replay case — the same +// agent+body+target+project within 2s with different event_ids appears +// exactly once. +// 6. The MAX_CHAT_MESSAGES cap (5000) holds even after a 60k-message burst. +// 7. reconcileMessages returns the SAME messages array reference when +// called twice with the same canonical input — downstream selectors +// must not see a spurious change. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// @/lib/ipc evaluates `window.pear` at module init (renderer-only). The +// vitest node env has no `window`, so stub the module before the agent-store +// import chain pulls in project-store -> @/lib/ipc. +vi.mock('@/lib/ipc', () => ({ pear: {} })) + +import { useAgentStore } from './agent-store' +import type { BrokerReconciledChatMessage } from '@shared/types/ipc' + +const MAX_CHAT_MESSAGES = 5_000 + +// Matches the shape `agent-store.handleBrokerEvent` expects for relay_inbound; +// the index signature aligns with the internal BrokerEvent discriminated +// union that requires `[key: string]: unknown`. +interface RelayInboundEvent { + kind: 'relay_inbound' + from: string + target: string + body: string + projectId?: string + event_id?: string + [key: string]: unknown +} + +function relayInbound(opts: { + from: string + target: string + body: string + projectId?: string + event_id?: string +}): RelayInboundEvent { + return { kind: 'relay_inbound', ...opts } +} + +describe('agent-store stress', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + // The store reads `localStorage.getItem('pear-broker-debug')` from a + // debug helper. Vitest's node env exposes a partial localStorage shim + // whose getItem isn't callable, so we replace it with a real stub. + vi.stubGlobal('localStorage', { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, + key: () => null, + length: 0 + }) + useAgentStore.getState().clearAll() + }) + + afterEach(() => { + useAgentStore.getState().clearAll() + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + // 50k relay_inbound events each spread a 5000-cap immutable buffer; the + // raw work is ~250M element copies and the test runs in ~6–8s on a laptop. + // Bumped from the 5s default so CI doesn't flake on slower runners. + it('1000 agents × 50 messages + 100 human sends + 5 reconciles satisfies every invariant', () => { + const store = useAgentStore.getState() + const projectCount = 5 + const agentsPerProject = 200 + const messagesPerAgent = 50 + const totalAgentEvents = projectCount * agentsPerProject * messagesPerAgent + + // Drive relay_inbound at scale. Bodies vary per (round, agent) so dedupe + // doesn't suppress legitimate distinct messages. The timestamp moves + // 10ms per outer round so the 2s dedupe window is exercised but not + // monopolised. + let eventCounter = 0 + for (let m = 0; m < messagesPerAgent; m++) { + for (let p = 0; p < projectCount; p++) { + for (let a = 0; a < agentsPerProject; a++) { + const agentName = `agent-${a}` + const projectId = `project-${p}` + // Half of the events target a channel, half target a DM. The DM + // target is the human (cross-stream replay shape that the guard + // is specifically designed to dedupe). + const target = (a % 2 === 0) ? '#general' : 'human' + store.handleBrokerEvent(relayInbound({ + from: agentName, + target, + body: `m${m}-${agentName}`, + projectId, + event_id: `evt-${eventCounter++}` + })) + } + } + vi.advanceTimersByTime(10) + } + expect(eventCounter).toBe(totalAgentEvents) + + // 100 optimistic human sends with the SAME body — verifies that the + // isCanonicalEchoOfLocalHuman guard never causes a local addHumanMessage + // to be dropped (the guard is only applied to incoming canonical echoes). + for (let h = 0; h < 100; h++) { + store.addHumanMessage('#general', 'hello-world', 'project-0') + } + + // 5 reconcileMessages syncs interleaved with the rest of the load. + for (let r = 0; r < 5; r++) { + const canonicalBatch: BrokerReconciledChatMessage[] = Array.from({ length: 20 }, (_, j) => ({ + id: `canonical-${r}-${j}`, + from: `canon-agent-${j}`, + to: '#general', + body: `canon-body-${r}-${j}`, + timestamp: Date.now() + r * 1000 + j, + isHuman: false, + projectId: 'project-0' + })) + store.reconcileMessages(canonicalBatch) + vi.advanceTimersByTime(5) + } + + const messages = useAgentStore.getState().messages + + // Invariant 1 — no duplicate ids. + const ids = new Set(messages.map((m) => m.id)) + expect(ids.size).toBe(messages.length) + + // Invariant 6 — MAX_CHAT_MESSAGES cap holds. + expect(messages.length).toBeLessThanOrEqual(MAX_CHAT_MESSAGES) + + // Sanity — buffer is non-trivially populated (the cap actually clamped). + expect(messages.length).toBe(MAX_CHAT_MESSAGES) + }, 25_000) + + it('addHumanMessage never drops repeated identical sends', () => { + // Invariant 2 — the optimistic path is local-only; repeats must survive. + // (Even with capByCount, 100 sends are well under MAX_CHAT_MESSAGES.) + const store = useAgentStore.getState() + for (let i = 0; i < 100; i++) { + store.addHumanMessage('#general', 'identical-body', 'p1') + } + const messages = useAgentStore.getState().messages + const humanLocal = messages.filter((m) => + m.isHuman === true && + m.local === true && + m.body === 'identical-body' + ) + expect(humanLocal.length).toBe(100) + const ids = new Set(humanLocal.map((m) => m.id)) + expect(ids.size).toBe(100) + }) + + it('cross-project agent dedupe does not false-positive — identical body, different projectId survives twice', () => { + // Invariant 3 — projectId mismatch must NEVER collapse two distinct sends. + const store = useAgentStore.getState() + store.handleBrokerEvent(relayInbound({ + from: 'agent-x', + target: '#general', + body: 'cross-project-body', + projectId: 'project-a', + event_id: 'evt-a' + })) + store.handleBrokerEvent(relayInbound({ + from: 'agent-x', + target: '#general', + body: 'cross-project-body', + projectId: 'project-b', + event_id: 'evt-b' + })) + const messages = useAgentStore.getState().messages.filter( + (m) => m.body === 'cross-project-body' + ) + expect(messages.length).toBe(2) + expect(new Set(messages.map((m) => m.projectId))).toEqual( + new Set(['project-a', 'project-b']) + ) + }) + + it('legitimate identical agent sends 3s apart both survive the 2s dedupe window', () => { + // Invariant 4 — outside the AGENT_MESSAGE_DEDUPE_WINDOW_MS (2000ms), + // identical messages are distinct sends, not replays. + const store = useAgentStore.getState() + store.handleBrokerEvent(relayInbound({ + from: 'agent-y', + target: '#general', + body: 'spaced-body', + projectId: 'p1', + event_id: 'evt-spaced-1' + })) + vi.advanceTimersByTime(3_000) + store.handleBrokerEvent(relayInbound({ + from: 'agent-y', + target: '#general', + body: 'spaced-body', + projectId: 'p1', + event_id: 'evt-spaced-2' + })) + const messages = useAgentStore.getState().messages.filter( + (m) => m.body === 'spaced-body' + ) + expect(messages.length).toBe(2) + expect(messages[0]!.timestamp).toBeLessThan(messages[1]!.timestamp) + }) + + it('agent dedupe collapses broker-replay — same agent+body+target+project within 2s, different event_ids → one record', () => { + // Invariant 5 — the explicit case the guardrail was built for: the broker + // emits the same logical message twice with different event_ids (one via + // relay_inbound, one via a reconcile snapshot, or two relay_inbound + // streams racing). Both arrive within 2s. Renderer must show one. + const store = useAgentStore.getState() + store.handleBrokerEvent(relayInbound({ + from: 'agent-z', + target: '#general', + body: 'replayed-body', + projectId: 'p1', + event_id: 'evt-A' + })) + vi.advanceTimersByTime(500) + store.handleBrokerEvent(relayInbound({ + from: 'agent-z', + target: '#general', + body: 'replayed-body', + projectId: 'p1', + event_id: 'evt-B' + })) + const messages = useAgentStore.getState().messages.filter( + (m) => m.body === 'replayed-body' + ) + expect(messages.length).toBe(1) + // The first (evt-A) wins — it was already in the buffer when evt-B arrived. + expect(messages[0]!.id).toBe('evt-A') + }) + + it('reconcileMessages with the same canonical input twice returns the same messages array reference', () => { + // Invariant 7 — selectors that compare by reference must NOT see a change + // when the broker reconciles the same canonical snapshot. + const store = useAgentStore.getState() + const canonical: BrokerReconciledChatMessage[] = [ + { + id: 'canon-stable-1', + from: 'agent-a', + to: '#general', + body: 'canonical body 1', + timestamp: Date.now(), + isHuman: false, + projectId: 'p1' + }, + { + id: 'canon-stable-2', + from: 'agent-b', + to: '#general', + body: 'canonical body 2', + timestamp: Date.now() + 100, + isHuman: false, + projectId: 'p1' + } + ] + store.reconcileMessages(canonical) + const firstRef = useAgentStore.getState().messages + expect(firstRef.length).toBe(2) + + // Second call with identical canonical input — must short-circuit to the + // same array reference (no spurious state mutation). + store.reconcileMessages(canonical) + const secondRef = useAgentStore.getState().messages + expect(secondRef).toBe(firstRef) + + // Even an empty canonical batch must not change the reference. + store.reconcileMessages([]) + expect(useAgentStore.getState().messages).toBe(firstRef) + }) + + it('reconcile + relay_inbound interaction does not create duplicate ids when broker echoes canonical msg via both streams', () => { + // Stress the cross-stream case: a canonical message arrives via + // reconcileMessages AND the same logical message arrives via + // relay_inbound under a different event_id. The id-dedupe must run + // and the agent-replay guard must run — never both copies survive. + const store = useAgentStore.getState() + const now = Date.now() + store.reconcileMessages([{ + id: 'canonical-id-1', + from: 'agent-q', + to: '#general', + body: 'cross-stream-body', + timestamp: now, + isHuman: false, + projectId: 'p1' + }]) + // Same logical message via relay_inbound with a different event_id — the + // 2s window catches this as a duplicate. + store.handleBrokerEvent(relayInbound({ + from: 'agent-q', + target: '#general', + body: 'cross-stream-body', + projectId: 'p1', + event_id: 'relay-id-1' + })) + const messages = useAgentStore.getState().messages.filter( + (m) => m.body === 'cross-stream-body' + ) + expect(messages.length).toBe(1) + expect(messages[0]!.id).toBe('canonical-id-1') + }) +}) diff --git a/src/renderer/src/stores/pty-buffer-store.stress.test.ts b/src/renderer/src/stores/pty-buffer-store.stress.test.ts new file mode 100644 index 00000000..6ebdcd05 --- /dev/null +++ b/src/renderer/src/stores/pty-buffer-store.stress.test.ts @@ -0,0 +1,292 @@ +// STRESS TEST — pty-buffer-store +// +// Drives the per-key PTY chunk fan-out at the volumes the renderer sees when +// many agents stream output simultaneously. Uses fake timers + tight burst +// loops so 50k+ appends complete in well under a second of wall time. +// +// Invariants exercised: +// 1. Exactly-once delivery per chunk per always-on subscriber, even across +// trim events and rapid subscribe/unsubscribe churn on sibling listeners. +// 2. Buffer trim cap (MAX_PTY_BUFFER_CHUNKS = 10_000) is never exceeded for +// any key, regardless of burst rate. +// 3. Tail-only semantics: a listener that subscribes mid-stream sees ONLY +// the chunks that arrive after its subscribe, even when intermediate +// chunks have been trimmed out of the canonical buffer. +// 4. No leak in the internal `pending` / `pendingFrames` maps — once the +// burst settles, advancing time produces no further listener fires for +// any key (proxy for "queues fully drained"). +// 5. `clearPtyBuffer` cancels any pending rAF flush — no stale data fires +// after a clear, even under heavy in-flight load. +// 6. A listener that throws does not break delivery to its siblings — every +// well-behaved listener still receives every chunk. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + appendPtyChunk, + clearPtyBuffer, + flushPtyChunksNow, + getPtyChunks, + subscribePtyBuffer +} from './pty-buffer-store' + +const MAX_PTY_BUFFER_CHUNKS = 10_000 + +// Drives the rAF→setTimeout fallback path that the store uses in node. +function flushRaf(): void { + vi.advanceTimersByTime(20) +} + +function makeChunk(size: number, marker: string): string { + // Marker at the start so we can identify a chunk's origin in tail-only + // assertions without storing the entire body. + const filler = 'x'.repeat(Math.max(0, size - marker.length)) + return `${marker}${filler}` +} + +describe('pty-buffer-store stress', () => { + // Each test uses its own key set so a missed teardown can't leak state + // between tests. We still clear at the end to keep the module-level maps + // empty for the next file in the run. + const testKeys: string[] = [] + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + beforeEach(() => { + vi.useFakeTimers() + testKeys.length = 0 + }) + + afterEach(() => { + for (const key of testKeys) clearPtyBuffer(key) + vi.useRealTimers() + }) + + it('delivers exactly-once to always-on subscribers across 10 agents × 5000 chunks', () => { + const agentCount = 10 + const chunksPerAgent = 5_000 + const subscribersPerAgent = 5 + const keys = Array.from({ length: agentCount }, (_, i) => `stress-deliver-${i}`) + testKeys.push(...keys) + + // Per-listener received counters. Index: keyIndex * subscribersPerAgent + listenerIndex. + const counters = new Array(agentCount * subscribersPerAgent).fill(0) + for (let k = 0; k < agentCount; k++) { + for (let s = 0; s < subscribersPerAgent; s++) { + const counterIdx = k * subscribersPerAgent + s + subscribePtyBuffer(keys[k]!, (newChunks) => { + counters[counterIdx] += newChunks.length + }) + } + } + + // Vary chunk size from 1B to 4KB across the deck. + const chunkSizes = [1, 16, 256, 1024, 4096] + + // Tight burst loop. Flush every 50 iterations to mimic the ~one-frame + // cadence the renderer actually sees, instead of flushing once at the end + // (which would mask coalescing bugs). + for (let i = 0; i < chunksPerAgent; i++) { + for (let k = 0; k < agentCount; k++) { + const size = chunkSizes[(i + k) % chunkSizes.length]! + appendPtyChunk(keys[k]!, makeChunk(size, `c${i}`)) + } + if (i > 0 && i % 50 === 0) flushRaf() + } + flushRaf() + + // Exactly-once delivery: every always-on listener saw every chunk. + for (let k = 0; k < agentCount; k++) { + for (let s = 0; s < subscribersPerAgent; s++) { + const counterIdx = k * subscribersPerAgent + s + expect(counters[counterIdx]).toBe(chunksPerAgent) + } + } + + // Trim cap holds for every key. + for (const key of keys) { + expect(getPtyChunks(key).length).toBeLessThanOrEqual(MAX_PTY_BUFFER_CHUNKS) + } + + // After the burst settles, no further listener fires regardless of how + // far we advance time — pending / pendingFrames are drained. We snapshot + // the counts and assert they don't change. + const snapshot = counters.slice() + vi.advanceTimersByTime(5_000) + expect(counters).toEqual(snapshot) + }) + + it('survives rapid subscribe/unsubscribe churn without losing chunks for always-on listeners', () => { + const key = 'stress-churn' + testKeys.push(key) + + let alwaysOnCount = 0 + subscribePtyBuffer(key, (newChunks) => { + alwaysOnCount += newChunks.length + }) + + // Churn loop: 1000 chunks, with a fresh subscriber added + removed every + // 10 iterations. The always-on listener must still see exactly 1000. + const chunkCount = 1_000 + for (let i = 0; i < chunkCount; i++) { + const churnUnsub = subscribePtyBuffer(key, () => { /* noop */ }) + appendPtyChunk(key, `chunk-${i}`) + churnUnsub() + if (i % 10 === 0) flushRaf() + } + flushRaf() + + expect(alwaysOnCount).toBe(chunkCount) + }) + + it('respects MAX_PTY_BUFFER_CHUNKS even under sustained burst above the cap', () => { + const key = 'stress-trim-cap' + testKeys.push(key) + const totalChunks = MAX_PTY_BUFFER_CHUNKS * 3 + + let listenerCount = 0 + subscribePtyBuffer(key, (newChunks) => { + listenerCount += newChunks.length + }) + + // Push 3x the cap; flush periodically so trim runs across many flushes, + // not just at the very end. + for (let i = 0; i < totalChunks; i++) { + appendPtyChunk(key, `t${i}`) + if (i % 200 === 0) flushRaf() + } + flushRaf() + + // Canonical buffer never exceeds the cap. + expect(getPtyChunks(key).length).toBe(MAX_PTY_BUFFER_CHUNKS) + + // The listener received every chunk (exactly-once-per-flush semantics). + // Tail-only delivery means the listener gets ALL appended chunks even + // when trim drops older entries from the canonical buffer. + expect(listenerCount).toBe(totalChunks) + }) + + it('tail-only: a mid-stream subscriber receives only chunks after its subscribe, even across trim', () => { + const key = 'stress-tail-trim' + testKeys.push(key) + const preSubscribeCount = 9_000 + const postSubscribeCount = 5_000 + + // Phase 1 — fill the buffer close to the cap with "pre" chunks. + for (let i = 0; i < preSubscribeCount; i++) { + appendPtyChunk(key, `pre-${i}`) + if (i % 1000 === 0) flushRaf() + } + flushRaf() + expect(getPtyChunks(key).length).toBe(preSubscribeCount) + + // Phase 2 — subscribe a mid-stream listener; collect every chunk it sees. + const seen: string[] = [] + subscribePtyBuffer(key, (newChunks) => { + seen.push(...newChunks) + }) + + // Phase 3 — push enough chunks to force trim (pre + post > cap). + for (let i = 0; i < postSubscribeCount; i++) { + appendPtyChunk(key, `post-${i}`) + if (i % 500 === 0) flushRaf() + } + flushRaf() + + // The buffer was trimmed to the cap. + expect(getPtyChunks(key).length).toBe(MAX_PTY_BUFFER_CHUNKS) + + // Mid-stream listener saw exactly the post-subscribe chunks, in order, + // and zero pre-subscribe chunks — even though the canonical buffer was + // trimmed during the burst. + expect(seen.length).toBe(postSubscribeCount) + expect(seen[0]).toBe('post-0') + expect(seen[seen.length - 1]).toBe(`post-${postSubscribeCount - 1}`) + for (const chunk of seen) { + expect(chunk.startsWith('post-')).toBe(true) + } + }) + + it('clearPtyBuffer cancels in-flight flushes under load — no stale data fires after clear', () => { + const key = 'stress-clear-cancel' + testKeys.push(key) + + const tails: string[][] = [] + subscribePtyBuffer(key, (newChunks) => { + tails.push(newChunks) + }) + + // Stage chunks then clear before the rAF fires. + for (let i = 0; i < 500; i++) { + appendPtyChunk(key, `staged-${i}`) + } + expect(tails.length).toBe(0) // not yet flushed + + clearPtyBuffer(key) + // The clear callback fires synchronously with an empty tail. + expect(tails.length).toBe(1) + expect(tails[0]).toEqual([]) + + // Advance time — the cancelled rAF must not fire stale data. + vi.advanceTimersByTime(1_000) + expect(tails.length).toBe(1) + expect(getPtyChunks(key)).toEqual([]) + + // And the listener still works after clear — new appends flow through. + for (let i = 0; i < 10; i++) { + appendPtyChunk(key, `fresh-${i}`) + } + flushRaf() + expect(tails.length).toBe(2) + expect(tails[1]).toHaveLength(10) + expect(tails[1]![0]).toBe('fresh-0') + }) + + it('an always-throwing listener does not block delivery to well-behaved siblings under load', () => { + const key = 'stress-throw-isolation' + testKeys.push(key) + + const bad = vi.fn(() => { throw new Error('boom') }) + let goodCount = 0 + subscribePtyBuffer(key, bad) + subscribePtyBuffer(key, (newChunks) => { + goodCount += newChunks.length + }) + + const chunkCount = 2_000 + for (let i = 0; i < chunkCount; i++) { + appendPtyChunk(key, `chunk-${i}`) + if (i % 100 === 0) flushRaf() + } + flushRaf() + + expect(goodCount).toBe(chunkCount) + // The bad listener was called every flush (its throws were caught and + // logged via console.error, which we've stubbed). + expect(bad.mock.calls.length).toBeGreaterThan(0) + expect(errSpy).toHaveBeenCalled() + }) + + it('flushPtyChunksNow drains pending immediately and prevents a duplicate rAF fire', () => { + const key = 'stress-flush-now' + testKeys.push(key) + const tails: string[][] = [] + subscribePtyBuffer(key, (newChunks) => { + tails.push(newChunks) + }) + + // 100 staged chunks across the burst, draining synchronously each batch. + for (let burst = 0; burst < 10; burst++) { + for (let i = 0; i < 10; i++) { + appendPtyChunk(key, `b${burst}-i${i}`) + } + flushPtyChunksNow(key) + // Advancing time MUST NOT fire a second flush for the same chunks. + vi.advanceTimersByTime(50) + } + + expect(tails.length).toBe(10) + for (const tail of tails) { + expect(tail.length).toBe(10) + } + expect(getPtyChunks(key).length).toBe(100) + }) +}) From 1e1899c0eb813bd9dab598351e1f6eefdbf3936e Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 8 Jun 2026 13:49:10 +0200 Subject: [PATCH 35/35] test(renderer): add happy-dom vitest project + xterm-mocked component tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a scoped happy-dom project to `vitest.config.mjs` so renderer component lifecycle can be unit-tested without an Electron runtime. Existing node-env tests are unchanged (split by `*.dom.test.ts`). Mock xterm at `src/renderer/src/__test__/xterm-mock.ts` records raw `write()` byte sequences and lifecycle calls (open/dispose/refresh/ resize/loadAddon) without emulating xterm's VT parser. Coverage: - terminal-runtime-registry: token-based mount/detach ownership, pty-buffer subscribe-on-mount + unsubscribe-on-dispose with no listener leak across remounts, identity-checked `clearOnDataIf`, `refreshOnShow` repaint contract, dispose cancels pending init rAF. - useTerminal: `runtimeRef.current` (via returned term) stays stable across re-renders for the same agent key; switching agentName produces a fresh runtime/terminal. No production code changes — research-and-test deliverable. Co-Authored-By: Claude Opus 4.7 --- package-lock.json | 583 +++++++++++++++++- package.json | 5 +- src/renderer/src/__test__/dom-setup.ts | 34 + src/renderer/src/__test__/xterm-mock.ts | 190 ++++++ .../src/hooks/use-terminal.dom.test.ts | 172 ++++++ .../lib/terminal-runtime-registry.dom.test.ts | 276 +++++++++ vitest.config.mjs | 45 +- 7 files changed, 1292 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/__test__/dom-setup.ts create mode 100644 src/renderer/src/__test__/xterm-mock.ts create mode 100644 src/renderer/src/hooks/use-terminal.dom.test.ts create mode 100644 src/renderer/src/lib/terminal-runtime-registry.dom.test.ts diff --git a/package-lock.json b/package-lock.json index ea945b69..2a834922 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,15 +40,18 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@testing-library/react": "^16.3.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", "electron": "^42.1.0", "electron-builder": "^26.8.1", "electron-vite": "^5.0.0", + "happy-dom": "^20.10.2", "tailwindcss": "^4.0.0", "typescript": "^5.7.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^4.1.8" } }, "node_modules/@agent-assistant/connectivity": { @@ -1367,6 +1370,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -5898,6 +5911,13 @@ "node": ">=18.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -6247,6 +6267,63 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -6305,6 +6382,17 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", @@ -6364,6 +6452,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -6489,6 +6584,23 @@ "license": "MIT", "optional": true }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -6527,6 +6639,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", @@ -7616,6 +7841,17 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -7637,6 +7873,16 @@ "node": ">=0.8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -7891,6 +8137,19 @@ "dev": true, "license": "MIT" }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/buildcheck": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", @@ -8081,6 +8340,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -8936,6 +9205,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -9364,6 +9641,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -9469,6 +9753,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -9526,6 +9820,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -10198,6 +10502,48 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/happy-dom": { + "version": "20.10.2", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.10.2.tgz", + "integrity": "sha512-5p9Sxis3eowDJKqx90QCsgbNA02XXqJ59NOHvD4V6cxp+rP4d/xOyVx7uY3hS8hiUbY1VeiFH8lbJ81AyuDVLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/happy-dom/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -11282,6 +11628,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -11862,6 +12219,20 @@ "node": ">= 0.4" } }, + "node_modules/obug": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -12236,6 +12607,47 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -12501,6 +12913,14 @@ "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -13067,6 +13487,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -13190,6 +13617,13 @@ "nan": "^2.23.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -13209,6 +13643,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", @@ -13429,6 +13870,23 @@ "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -13446,6 +13904,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -13936,6 +14404,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/warning": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", @@ -13983,6 +14541,23 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -13990,9 +14565,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index bf2f502f..153af9ed 100644 --- a/package.json +++ b/package.json @@ -56,14 +56,17 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@testing-library/react": "^16.3.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", "electron": "^42.1.0", "electron-builder": "^26.8.1", "electron-vite": "^5.0.0", + "happy-dom": "^20.10.2", "tailwindcss": "^4.0.0", "typescript": "^5.7.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^4.1.8" } } diff --git a/src/renderer/src/__test__/dom-setup.ts b/src/renderer/src/__test__/dom-setup.ts new file mode 100644 index 00000000..5fbbe2d3 --- /dev/null +++ b/src/renderer/src/__test__/dom-setup.ts @@ -0,0 +1,34 @@ +// Per-test-file setup for happy-dom DOM tests. +// +// happy-dom 20 inside vitest exposes a `localStorage` global that is a +// placeholder object without the Storage API; modules that read from it +// at import time (e.g. `ui-store.ts`) crash with "getItem is not a +// function". Polyfill the bare API so imports succeed. + +class TestStorage implements Storage { + private store: Map = new Map() + get length(): number { + return this.store.size + } + clear(): void { + this.store.clear() + } + getItem(key: string): string | null { + return this.store.has(key) ? (this.store.get(key) as string) : null + } + key(index: number): string | null { + return Array.from(this.store.keys())[index] ?? null + } + removeItem(key: string): void { + this.store.delete(key) + } + setItem(key: string, value: string): void { + this.store.set(key, String(value)) + } +} + +const ls = new TestStorage() +Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: ls }) +if (typeof window !== 'undefined') { + Object.defineProperty(window, 'localStorage', { configurable: true, value: ls }) +} diff --git a/src/renderer/src/__test__/xterm-mock.ts b/src/renderer/src/__test__/xterm-mock.ts new file mode 100644 index 00000000..c6ba51b8 --- /dev/null +++ b/src/renderer/src/__test__/xterm-mock.ts @@ -0,0 +1,190 @@ +// Minimal xterm.js mock for happy-dom component tests. +// +// We don't try to emulate xterm's VT parser; tests assert against the raw +// byte sequences passed to `write()` and the lifecycle calls made on the +// terminal. Returns a value-compatible `Terminal` shape covering the +// surface that `terminal-runtime-registry.ts`, `use-terminal.ts`, and +// `predictive-echo.ts` consume. + +import { vi } from 'vitest' + +export interface MockBufferLine { + translateToString: () => string +} + +export interface MockActiveBuffer { + type: 'normal' | 'alternate' + cursorX: number + cursorY: number + baseY: number + viewportY: number + getLine: (row: number) => MockBufferLine | undefined +} + +export interface MockTextarea { + focus: ReturnType +} + +export interface MockOptions { + theme?: unknown +} + +export type DataHandler = (data: string) => void +export type ResizeHandler = (size: { cols: number; rows: number }) => void + +export interface MockTerminal { + cols: number + rows: number + textarea: MockTextarea + options: MockOptions + buffer: { active: MockActiveBuffer } + // Recorded raw byte sequences in call order. + __writes: string[] + // Counts so tests can assert without spying. + __refreshCalls: Array<{ start: number; end: number }> + __disposed: boolean + __opened: boolean + __openContainer: HTMLElement | null + __addons: unknown[] + __dataHandlers: DataHandler[] + __resizeHandlers: ResizeHandler[] + + write: (data: string, cb?: () => void) => void + open: (container: HTMLElement) => void + dispose: () => void + focus: () => void + refresh: (start: number, end: number) => void + resize: (cols: number, rows: number) => void + scrollToBottom: () => void + loadAddon: (addon: unknown) => void + onData: (handler: DataHandler) => { dispose: () => void } + onResize: (handler: ResizeHandler) => { dispose: () => void } +} + +export interface MockTerminalConstructorOpts { + cols?: number + rows?: number + [k: string]: unknown +} + +// Tracks every Terminal instance ever created so tests can introspect. +export const createdTerminals: MockTerminal[] = [] + +export function resetXtermMock(): void { + createdTerminals.length = 0 +} + +export class Terminal implements MockTerminal { + cols: number + rows: number + textarea: MockTextarea + options: MockOptions + buffer: { active: MockActiveBuffer } + __writes: string[] = [] + __refreshCalls: Array<{ start: number; end: number }> = [] + __disposed = false + __opened = false + __openContainer: HTMLElement | null = null + __addons: unknown[] = [] + __dataHandlers: DataHandler[] = [] + __resizeHandlers: ResizeHandler[] = [] + + constructor(opts: MockTerminalConstructorOpts = {}) { + this.cols = typeof opts.cols === 'number' ? opts.cols : 80 + this.rows = typeof opts.rows === 'number' ? opts.rows : 24 + this.textarea = { focus: vi.fn() } + this.options = { theme: opts.theme } + this.buffer = { + active: { + type: 'normal', + cursorX: 0, + cursorY: 0, + baseY: 0, + viewportY: 0, + getLine: () => ({ translateToString: () => '' }) + } + } + createdTerminals.push(this) + } + + write(data: string, cb?: () => void): void { + this.__writes.push(data) + if (cb) queueMicrotask(cb) + } + + open(container: HTMLElement): void { + this.__opened = true + this.__openContainer = container + } + + dispose(): void { + this.__disposed = true + } + + focus(): void { + // recorded by tests via spying if needed; cheap no-op otherwise + } + + refresh(start: number, end: number): void { + this.__refreshCalls.push({ start, end }) + } + + resize(cols: number, rows: number): void { + this.cols = cols + this.rows = rows + for (const h of this.__resizeHandlers) h({ cols, rows }) + } + + scrollToBottom(): void { + // no-op + } + + loadAddon(addon: unknown): void { + this.__addons.push(addon) + } + + onData(handler: DataHandler): { dispose: () => void } { + this.__dataHandlers.push(handler) + return { + dispose: () => { + const idx = this.__dataHandlers.indexOf(handler) + if (idx >= 0) this.__dataHandlers.splice(idx, 1) + } + } + } + + onResize(handler: ResizeHandler): { dispose: () => void } { + this.__resizeHandlers.push(handler) + return { + dispose: () => { + const idx = this.__resizeHandlers.indexOf(handler) + if (idx >= 0) this.__resizeHandlers.splice(idx, 1) + } + } + } +} + +// Addons used by terminal-runtime-registry.ts. They get `loadAddon()`-ed +// onto the terminal; the runtime only calls `.fit()` on the FitAddon and +// `.onContextLoss()` on the WebglAddon, so other methods are no-ops. + +export class FitAddon { + __fitCalls = 0 + fit(): void { + this.__fitCalls += 1 + } + dispose(): void {} +} + +export class WebLinksAddon { + dispose(): void {} +} + +export class WebglAddon { + __contextLossHandlers: Array<() => void> = [] + onContextLoss(handler: () => void): { dispose: () => void } { + this.__contextLossHandlers.push(handler) + return { dispose: () => {} } + } + dispose(): void {} +} diff --git a/src/renderer/src/hooks/use-terminal.dom.test.ts b/src/renderer/src/hooks/use-terminal.dom.test.ts new file mode 100644 index 00000000..5d9bdc7a --- /dev/null +++ b/src/renderer/src/hooks/use-terminal.dom.test.ts @@ -0,0 +1,172 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import React, { useRef } from 'react' +import { render, cleanup, act } from '@testing-library/react' +import type { Terminal as XTermType } from '@xterm/xterm' +import { + Terminal as MockTerminal, + FitAddon as MockFitAddon, + WebLinksAddon as MockWebLinksAddon, + WebglAddon as MockWebglAddon, + createdTerminals, + resetXtermMock +} from '@/__test__/xterm-mock' + +vi.mock('@xterm/xterm', () => ({ Terminal: MockTerminal })) +vi.mock('@xterm/addon-fit', () => ({ FitAddon: MockFitAddon })) +vi.mock('@xterm/addon-web-links', () => ({ WebLinksAddon: MockWebLinksAddon })) +vi.mock('@xterm/addon-webgl', () => ({ WebglAddon: MockWebglAddon })) + +vi.mock('@/lib/font-settle', () => ({ + awaitFontSettle: vi.fn(async () => {}) +})) + +vi.mock('@/lib/ipc', () => ({ + pear: { + broker: { + attachTerminal: vi.fn(async () => ({ snapshot: null })), + resizePty: vi.fn(async () => {}), + setTerminalMode: vi.fn(async () => {}), + sendInputFast: vi.fn(), + inputSrtt: vi.fn(async () => null) + } + } +})) + +vi.mock('@/lib/predictive-echo', () => ({ + createPredictiveEcho: vi.fn(() => ({ + engine: null, + dispose: vi.fn() + })) +})) + +// ResizeObserver isn't implemented by happy-dom. The hook constructs one +// but only fires observe callbacks on real layout changes — we just need +// the constructor not to throw. +class StubResizeObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} +;(globalThis as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver = + StubResizeObserver as unknown as typeof ResizeObserver + +let useTerminal: typeof import('./use-terminal').useTerminal +let agentStoreModule: typeof import('@/stores/agent-store') + +beforeEach(async () => { + resetXtermMock() + vi.resetModules() + useTerminal = (await import('./use-terminal')).useTerminal + agentStoreModule = await import('@/stores/agent-store') +}) + +afterEach(() => { + cleanup() + document.body.innerHTML = '' + vi.clearAllMocks() +}) + +interface ProbeProps { + agentName: string + projectId: string + termHolder: { current: XTermType | null } + renderCounter: { count: number } +} + +function Probe({ agentName, projectId, termHolder, renderCounter }: ProbeProps): React.ReactElement { + renderCounter.count += 1 + const ref = useRef(null) + // Give the container layout so initIfReady() doesn't bail. + const setRef = (node: HTMLDivElement | null): void => { + if (node) { + Object.defineProperty(node, 'clientWidth', { configurable: true, value: 800 }) + Object.defineProperty(node, 'clientHeight', { configurable: true, value: 600 }) + } + ref.current = node + } + const term = useTerminal(ref, agentName, projectId, true) + termHolder.current = term + return React.createElement('div', { ref: setRef, 'data-testid': 'host' }) +} + +function seedAgents(names: Array<{ projectId: string; name: string }>): void { + const baseAgent = { + cli: 'test', + status: 'running' as const, + activity: 'idle' as const, + currentState: 'idle' as const, + terminalMode: 'drive' as const, + pendingDeliveryIds: [] as string[] + } + agentStoreModule.useAgentStore.setState({ + agents: names.map((a) => ({ ...baseAgent, name: a.name, projectId: a.projectId })) + }) +} + +async function flushAsync(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +function renderProbe(agentName: string, projectId: string) { + const termHolder = { current: null as XTermType | null } + const renderCounter = { count: 0 } + const utils = render( + React.createElement(Probe, { agentName, projectId, termHolder, renderCounter }) + ) + // The hook returns `runtimeRef.current?.term ?? null`. The mount + // effect runs after render and mutates the ref without triggering a + // re-render, so we manually re-render after a microtask drain to + // capture the latest term. When agentName changes, we need + // rerender → flush (cleanup + new effect run) → rerender (capture). + const settle = async (name: string = agentName): Promise => { + utils.rerender( + React.createElement(Probe, { agentName: name, projectId, termHolder, renderCounter }) + ) + await flushAsync() + utils.rerender( + React.createElement(Probe, { agentName: name, projectId, termHolder, renderCounter }) + ) + await flushAsync() + } + return { ...utils, termHolder, renderCounter, settle } +} + +describe('useTerminal — ref stability', () => { + it('returns the same Terminal instance across renders for the same agent key', async () => { + seedAgents([{ projectId: 'p', name: 'alpha' }]) + const probe = renderProbe('alpha', 'p') + await probe.settle() + const firstTerm = probe.termHolder.current + expect(firstTerm).not.toBeNull() + + await probe.settle() + expect(probe.termHolder.current).toBe(firstTerm) + expect(createdTerminals).toHaveLength(1) + + await probe.settle() + expect(probe.termHolder.current).toBe(firstTerm) + expect(createdTerminals).toHaveLength(1) + }) + + it('switching agentName produces a different Terminal instance', async () => { + seedAgents([ + { projectId: 'p', name: 'alpha' }, + { projectId: 'p', name: 'beta' } + ]) + const probe = renderProbe('alpha', 'p') + await probe.settle() + const alphaTerm = probe.termHolder.current + expect(alphaTerm).not.toBeNull() + + await probe.settle('beta') + const betaTerm = probe.termHolder.current + expect(betaTerm).not.toBeNull() + expect(betaTerm).not.toBe(alphaTerm) + expect(createdTerminals.length).toBeGreaterThanOrEqual(2) + }) +}) diff --git a/src/renderer/src/lib/terminal-runtime-registry.dom.test.ts b/src/renderer/src/lib/terminal-runtime-registry.dom.test.ts new file mode 100644 index 00000000..671fa17e --- /dev/null +++ b/src/renderer/src/lib/terminal-runtime-registry.dom.test.ts @@ -0,0 +1,276 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + Terminal as MockTerminal, + FitAddon as MockFitAddon, + WebLinksAddon as MockWebLinksAddon, + WebglAddon as MockWebglAddon, + createdTerminals, + resetXtermMock +} from '@/__test__/xterm-mock' + +vi.mock('@xterm/xterm', () => ({ Terminal: MockTerminal })) +vi.mock('@xterm/addon-fit', () => ({ FitAddon: MockFitAddon })) +vi.mock('@xterm/addon-web-links', () => ({ WebLinksAddon: MockWebLinksAddon })) +vi.mock('@xterm/addon-webgl', () => ({ WebglAddon: MockWebglAddon })) + +vi.mock('@/lib/font-settle', () => ({ + awaitFontSettle: vi.fn(async () => {}) +})) + +vi.mock('@/lib/ipc', () => ({ + pear: { + broker: { + attachTerminal: vi.fn(async () => ({ snapshot: null })), + resizePty: vi.fn(async () => {}), + setTerminalMode: vi.fn(async () => {}), + sendInputFast: vi.fn(), + inputSrtt: vi.fn(async () => null) + } + } +})) + +// Keep predictive echo dormant: the runtime calls `predictiveEcho?.onServerOutput` +// when present, otherwise writes straight to the live term. We want the +// direct-write path so tests can assert against `__writes` synchronously. +vi.mock('@/lib/predictive-echo', () => ({ + createPredictiveEcho: vi.fn(() => ({ + engine: null, + dispose: vi.fn() + })) +})) + +function makeLayoutContainer(width = 800, height = 600): HTMLDivElement { + const el = document.createElement('div') + Object.defineProperty(el, 'clientWidth', { configurable: true, value: width }) + Object.defineProperty(el, 'clientHeight', { configurable: true, value: height }) + document.body.appendChild(el) + return el +} + +function makeZeroSizeContainer(): HTMLDivElement { + const el = document.createElement('div') + Object.defineProperty(el, 'clientWidth', { configurable: true, value: 0 }) + Object.defineProperty(el, 'clientHeight', { configurable: true, value: 0 }) + document.body.appendChild(el) + return el +} + +async function flushAsync(): Promise { + // Drain microtasks queued by the runtime's awaited init path. + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +let registry: typeof import('./terminal-runtime-registry') +let ptyBuffer: typeof import('@/stores/pty-buffer-store') + +beforeEach(async () => { + resetXtermMock() + vi.resetModules() + registry = await import('./terminal-runtime-registry') + ptyBuffer = await import('@/stores/pty-buffer-store') +}) + +afterEach(() => { + document.body.innerHTML = '' + vi.clearAllMocks() +}) + +describe('terminal-runtime-registry — token-based mount/detach ownership', () => { + it('mount(A) then mount(B) silently reparents; detach(A) with stale token is a no-op; host ends up in B', () => { + const runtime = registry.acquireTerminalRuntime({ + projectId: 'p', + agentName: 'a', + terminalMode: 'drive', + theme: 'dark', + getInputSrtt: () => null + }) + + const containerA = makeLayoutContainer() + const containerB = makeLayoutContainer() + + const tokenA = runtime.mount(containerA) + expect(runtime.host.parentElement).toBe(containerA) + + const tokenB = runtime.mount(containerB) + expect(tokenA).not.toBe(tokenB) + expect(runtime.host.parentElement).toBe(containerB) + + // Stale token: detach(A) must not steal the host from B. + runtime.detach(tokenA) + expect(runtime.host.parentElement).toBe(containerB) + expect(runtime.isMounted()).toBe(true) + + runtime.detach(tokenB) + expect(runtime.host.parentElement).not.toBe(containerB) + expect(runtime.isMounted()).toBe(false) + + registry.disposeTerminalRuntime(runtime.key) + }) +}) + +describe('terminal-runtime-registry — pty-buffer subscription lifecycle', () => { + it('subscribes on first mount, survives detach/remount, unsubscribes on dispose (no listener leaks across remounts)', async () => { + const runtime = registry.acquireTerminalRuntime({ + projectId: 'p', + agentName: 'a', + terminalMode: 'drive', + theme: 'dark', + getInputSrtt: () => null + }) + const term = createdTerminals[0] + expect(term).toBeDefined() + + const containerA = makeLayoutContainer() + runtime.mount(containerA) + // initIfReady() awaits attachAndSeed which awaits attachTerminal; drain. + await flushAsync() + await flushAsync() + + // Subscription is live: appending a chunk and flushing should land + // exactly one write on the terminal. + ptyBuffer.appendPtyChunk(runtime.key, 'hello') + ptyBuffer.flushPtyChunksNow(runtime.key) + expect(term.__writes).toContain('hello') + const writesAfterMount = term.__writes.length + + // Detach: the runtime is parked, but the subscription must survive so + // background chunks still flow into xterm for the next remount. + const tokenA = runtime.isMounted() ? Symbol('placeholder') : Symbol('na') + // Use the real current token returned by a fresh mount path: re-mount + // and check that no duplicate listener was added. + runtime.detach(tokenA) // stale token — no-op + expect(runtime.isMounted()).toBe(true) + + ptyBuffer.appendPtyChunk(runtime.key, 'one-listener') + ptyBuffer.flushPtyChunksNow(runtime.key) + expect(term.__writes.length).toBe(writesAfterMount + 1) + + // Mount into a new container: must not double-subscribe. + const containerB = makeLayoutContainer() + runtime.mount(containerB) + await flushAsync() + ptyBuffer.appendPtyChunk(runtime.key, 'still-one') + ptyBuffer.flushPtyChunksNow(runtime.key) + expect(term.__writes.length).toBe(writesAfterMount + 2) + + // Dispose: listener should be removed. clearPtyBuffer fires the + // listener once with [] (a no-op write) before unsubscribing. + registry.disposeTerminalRuntime(runtime.key) + const writesAtDispose = term.__writes.length + ptyBuffer.appendPtyChunk(runtime.key, 'after-dispose') + ptyBuffer.flushPtyChunksNow(runtime.key) + expect(term.__writes.length).toBe(writesAtDispose) + }) +}) + +describe('terminal-runtime-registry — clearOnDataIf identity check', () => { + it('only clears the on-data handler when the caller still owns the slot', () => { + const runtime = registry.acquireTerminalRuntime({ + projectId: 'p', + agentName: 'a', + terminalMode: 'drive', + theme: 'dark', + getInputSrtt: () => null + }) + const term = createdTerminals[0] + + const handlerA = vi.fn() + const handlerB = vi.fn() + + runtime.setOnData(handlerA) + runtime.setOnData(handlerB) // B replaces A — cross-tree commit ordering + + // Old-hook cleanup with stale handler A must not wipe the slot. + runtime.clearOnDataIf(handlerA) + for (const h of term.__dataHandlers) h('input-1') + expect(handlerB).toHaveBeenCalledWith('input-1') + expect(handlerA).not.toHaveBeenCalled() + + // Live-hook cleanup with the current handler must clear it. + runtime.clearOnDataIf(handlerB) + for (const h of term.__dataHandlers) h('input-2') + expect(handlerB).toHaveBeenCalledTimes(1) + + registry.disposeTerminalRuntime(runtime.key) + }) +}) + +describe('terminal-runtime-registry — refreshOnShow', () => { + it('calls term.refresh(0, rows - 1) to repaint after a display:none → visible transition', () => { + const runtime = registry.acquireTerminalRuntime({ + projectId: 'p', + agentName: 'a', + terminalMode: 'drive', + theme: 'dark', + getInputSrtt: () => null + }) + const term = createdTerminals[0] + expect(term.rows).toBeGreaterThan(0) + const expectedEnd = term.rows - 1 + + runtime.refreshOnShow() + expect(term.__refreshCalls).toContainEqual({ start: 0, end: expectedEnd }) + + registry.disposeTerminalRuntime(runtime.key) + }) +}) + +describe('terminal-runtime-registry — dispose cancels pending init rAF', () => { + it('cancels the pending initIfReady rAF and never fires it after dispose', async () => { + const queued: Array<{ id: number; cb: FrameRequestCallback; cancelled: boolean }> = [] + let nextId = 1 + const rafSpy = vi + .spyOn(globalThis, 'requestAnimationFrame') + .mockImplementation((cb) => { + const id = nextId++ + queued.push({ id, cb, cancelled: false }) + return id + }) + const cancelSpy = vi + .spyOn(globalThis, 'cancelAnimationFrame') + .mockImplementation((id) => { + const entry = queued.find((q) => q.id === id) + if (entry) entry.cancelled = true + }) + + try { + const runtime = registry.acquireTerminalRuntime({ + projectId: 'p', + agentName: 'a', + terminalMode: 'drive', + theme: 'dark', + getInputSrtt: () => null + }) + const term = createdTerminals[0] + + // Zero-size container: initIfReady bails and schedules a rAF retry. + const zero = makeZeroSizeContainer() + runtime.mount(zero) + await flushAsync() + + expect(queued.length).toBeGreaterThanOrEqual(1) + const pending = queued[queued.length - 1] + expect(pending.cancelled).toBe(false) + expect(term.__opened).toBe(false) + + registry.disposeTerminalRuntime(runtime.key) + + expect(cancelSpy).toHaveBeenCalledWith(pending.id) + expect(pending.cancelled).toBe(true) + + // Even if a wayward frame fires, the runtime is disposed and must + // not open the terminal or otherwise act. + for (const q of queued) { + if (!q.cancelled) q.cb(performance.now()) + } + expect(term.__opened).toBe(false) + } finally { + rafSpy.mockRestore() + cancelSpy.mockRestore() + } + }) +}) diff --git a/vitest.config.mjs b/vitest.config.mjs index af2980ee..8e5aeb69 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -1,14 +1,43 @@ import { resolve } from 'node:path' +const sharedAlias = { + '@': resolve('src/renderer/src'), + '@shared': resolve('src/shared') +} + export default { - resolve: { - alias: { - '@': resolve('src/renderer/src'), - '@shared': resolve('src/shared') - } - }, + resolve: { alias: sharedAlias }, test: { - include: ['src/main/**/*.test.ts', 'src/renderer/src/**/*.test.ts', 'packages/**/*.test.ts'], - exclude: ['**/node_modules/**', '**/dist/**', '**/out/**', 'src/main/__tests__/**'] + projects: [ + { + resolve: { alias: sharedAlias }, + test: { + name: 'node', + environment: 'node', + include: [ + 'src/main/**/*.test.ts', + 'src/renderer/src/**/*.test.ts', + 'packages/**/*.test.ts' + ], + exclude: [ + '**/node_modules/**', + '**/dist/**', + '**/out/**', + 'src/main/__tests__/**', + '**/*.dom.test.ts' + ] + } + }, + { + resolve: { alias: sharedAlias }, + test: { + name: 'dom', + environment: 'happy-dom', + setupFiles: ['src/renderer/src/__test__/dom-setup.ts'], + include: ['src/renderer/src/**/*.dom.test.ts'], + exclude: ['**/node_modules/**', '**/dist/**', '**/out/**'] + } + } + ] } }