From 9985ffe1d60c1c8d97c4263af928e70c928ca1e1 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:01:42 -0700 Subject: [PATCH 1/3] Fix Windows terminal clear repaint --- .../components/terminal-pane/TerminalPane.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 7d1637545d..95e1832469 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -775,11 +775,28 @@ export default function TerminalPane({ pane.terminal.clear() // Why: also clear the host buffer for remote-server panes, or the next // host snapshot replays the scrollback we just cleared locally. - const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null + const transport = paneTransportsRef.current.get(pane.id) + const ptyId = transport?.getPtyId() ?? null clearWebRuntimeTerminalBuffer(ptyId) + const isRemoteRuntimePty = typeof ptyId === 'string' && isRemoteRuntimePtyId(ptyId) + if (isWindowsUserAgent() && getConnectionId(worktreeId) === null && !isRemoteRuntimePty) { + // Why: local Windows ConPTY can keep wrapped prompt/cursor paint stale + // after xterm.clear(), including after a TUI exit drops the live pty id. + safeFit(pane) + transport?.resize(pane.terminal.cols, pane.terminal.rows) + pane.terminal.refresh(0, Math.max(0, pane.terminal.rows - 1)) + requestAnimationFrame(() => { + if (!managerRef.current?.getPanes().some((livePane) => livePane.id === pane.id)) { + return + } + safeFit(pane) + transport?.resize(pane.terminal.cols, pane.terminal.rows) + pane.terminal.refresh(0, Math.max(0, pane.terminal.rows - 1)) + }) + } persistLayoutSnapshot() }, - [paneTransportsRef, persistLayoutSnapshot] + [paneTransportsRef, persistLayoutSnapshot, worktreeId] ) useEffect(() => { From 555652ae6b5f90b8388b1935b4677474ddf39a03 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:23:24 -0700 Subject: [PATCH 2/3] Fix terminal clear buffer path --- src/main/ipc/pty.ts | 12 ++ src/preload/api-types.ts | 1 + src/preload/index.ts | 1 + .../components/terminal-pane/TerminalPane.tsx | 24 ++-- .../terminal-pane/pty-dispatcher.ts | 1 + .../terminal-pane/pty-transport.test.ts | 15 +++ .../components/terminal-pane/pty-transport.ts | 7 ++ src/renderer/src/web/web-preload-api.ts | 1 + tests/e2e/terminal-shortcuts.spec.ts | 114 ++++++++++++++++++ 9 files changed, 167 insertions(+), 9 deletions(-) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index b391e01ca4..25ae9223d3 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -3051,6 +3051,18 @@ export function registerPtyHandlers( runtime?.onPtyExit(args.id, -1) }) + ipcMain.handle('pty:clearBuffer', async (_event, args: { id?: unknown }): Promise => { + if (typeof args?.id !== 'string' || args.id.length === 0) { + return + } + mainWindow.webContents.send('pty:clearBuffer:request', { ptyId: args.id }) + try { + await getProviderForPty(args.id).clearBuffer(args.id) + } catch { + /* best effort: renderer clear still handles local PTYs */ + } + }) + ipcMain.handle( 'pty:listSessions', async (): Promise<{ id: string; cwd: string; title: string }[]> => { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 55c32a9a5d..f9ba7165ef 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1055,6 +1055,7 @@ export type PreloadApi = { reportGeometry: (id: string, cols: number, rows: number) => void signal: (id: string, signal: string) => void kill: (id: string, opts?: { keepHistory?: boolean }) => Promise + clearBuffer: (id: string) => Promise ackColdRestore: (id: string) => void ackData: (id: string, charCount: number) => void setActiveRendererPty: (id: string, active: boolean) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 7936edb853..91a5aa3a92 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -771,6 +771,7 @@ const api = { kill: (id: string, opts?: { keepHistory?: boolean }): Promise => ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), + clearBuffer: (id: string): Promise => ipcRenderer.invoke('pty:clearBuffer', { id }), listSessions: (): Promise<{ id: string; cwd: string; title: string }[]> => ipcRenderer.invoke('pty:listSessions'), diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 95e1832469..0fc03c971c 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -772,29 +772,35 @@ export default function TerminalPane({ const clearPaneScrollback = useCallback( (pane: ManagedPane): void => { clearedScrollbackLeafIdsRef.current.add(pane.leafId) - pane.terminal.clear() - // Why: also clear the host buffer for remote-server panes, or the next - // host snapshot replays the scrollback we just cleared locally. const transport = paneTransportsRef.current.get(pane.id) const ptyId = transport?.getPtyId() ?? null + pane.terminal.clear() clearWebRuntimeTerminalBuffer(ptyId) const isRemoteRuntimePty = typeof ptyId === 'string' && isRemoteRuntimePtyId(ptyId) - if (isWindowsUserAgent() && getConnectionId(worktreeId) === null && !isRemoteRuntimePty) { - // Why: local Windows ConPTY can keep wrapped prompt/cursor paint stale - // after xterm.clear(), including after a TUI exit drops the live pty id. + const shouldRepairWindowsClear = + isWindowsUserAgent() && getConnectionId(worktreeId) === null && !isRemoteRuntimePty + const repairWindowsClear = (): void => { + if (!shouldRepairWindowsClear) { + return + } + // Why: local Windows xterm can keep wrapped prompt/cursor paint stale + // after clear; keep this frontend-only so ConPTY does not repaint it. safeFit(pane) - transport?.resize(pane.terminal.cols, pane.terminal.rows) pane.terminal.refresh(0, Math.max(0, pane.terminal.rows - 1)) requestAnimationFrame(() => { if (!managerRef.current?.getPanes().some((livePane) => livePane.id === pane.id)) { return } safeFit(pane) - transport?.resize(pane.terminal.cols, pane.terminal.rows) pane.terminal.refresh(0, Math.max(0, pane.terminal.rows - 1)) }) } - persistLayoutSnapshot() + // Why: provider/session buffers can outlive the visible xterm. Clear + // them through the same contract used by remote/mobile terminal state. + void transport?.clearBuffer?.().finally(() => { + repairWindowsClear() + persistLayoutSnapshot() + }) }, [paneTransportsRef, persistLayoutSnapshot, worktreeId] ) diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 2ef4be23c3..36bca3a56a 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -363,6 +363,7 @@ export type PtyTransport = { getPtyId: () => string | null getConnectionId?: () => string | null | undefined getLocalSessionMetadata?: () => LocalPtySessionMetadata | null + clearBuffer?: () => Promise serializeBuffer?: (opts?: { scrollbackRows?: number }) => Promise preserve?: () => void /** Unregister PTY handlers without killing the process for pane remounts. */ diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 13343da40f..62e370e87d 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -40,6 +40,7 @@ describe('createIpcPtyTransport', () => { writeAccepted: vi.fn().mockResolvedValue(true), resize: vi.fn(), kill: vi.fn(), + clearBuffer: vi.fn().mockResolvedValue(undefined), onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => { onData = callback return () => {} @@ -103,6 +104,20 @@ describe('createIpcPtyTransport', () => { expect(sshTransport.getLocalSessionMetadata?.()).toBeNull() }) + it('clears the connected PTY buffer through the preload API', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const transport = createIpcPtyTransport({}) + + await transport.clearBuffer?.() + expect(window.api.pty.clearBuffer).not.toHaveBeenCalled() + + await transport.connect({ url: '', callbacks: {} }) + await transport.clearBuffer?.() + + expect(window.api.pty.clearBuffer).toHaveBeenCalledWith('pty-1') + transport.disconnect() + }) + it('defers title side effects until after terminal data is delivered', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const onTitleChange = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 43aa4b8c1d..ad9d2f9049 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -955,6 +955,13 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } }, + async clearBuffer() { + if (!connected || !ptyId) { + return + } + await window.api.pty.clearBuffer(ptyId) + }, + destroy() { destroyed = true this.disconnect() diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 4eaf43372f..b701cc5df3 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2442,6 +2442,7 @@ function createPtyApi(): NonNullable['pty']> { onSerializeBufferRequest: () => noopUnsubscribe, onClearBufferRequest: () => noopUnsubscribe, sendSerializedBuffer: () => {}, + clearBuffer: () => Promise.resolve(), declarePendingPaneSerializer: () => Promise.resolve(0), settlePaneSerializer: () => Promise.resolve(), clearPendingPaneSerializer: () => Promise.resolve(), diff --git a/tests/e2e/terminal-shortcuts.spec.ts b/tests/e2e/terminal-shortcuts.spec.ts index de15695de1..dad8b81cba 100644 --- a/tests/e2e/terminal-shortcuts.spec.ts +++ b/tests/e2e/terminal-shortcuts.spec.ts @@ -93,6 +93,68 @@ async function focusActiveTerminal(page: Page): Promise { }) } +async function writeActiveViewportMarker( + page: Page, + promptMarker: string, + staleMarker: string +): Promise<{ cursorX: number; cursorY: number }> { + return page.evaluate( + async ({ prompt, stale }) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error('No active terminal pane for viewport marker') + } + await new Promise((resolve) => { + pane.terminal.write(`\x1b[2J\x1b[H${prompt}\x1b7\r\n${stale}\r\n${stale}\x1b8`, resolve) + }) + return { + cursorX: pane.terminal.buffer.active.cursorX, + cursorY: pane.terminal.buffer.active.cursorY + } + }, + { prompt: promptMarker, stale: staleMarker } + ) +} + +async function getActiveTerminalSnapshot( + page: Page +): Promise<{ content: string; cursorLine: string; cursorX: number; cursorY: number }> { + return page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error('No active terminal pane for cursor read') + } + const cursorY = pane.terminal.buffer.active.cursorY + const cursorLineIndex = pane.terminal.buffer.active.baseY + cursorY + return { + content: pane.serializeAddon?.serialize?.() ?? '', + cursorLine: + pane.terminal.buffer.active.getLine(cursorLineIndex)?.translateToString(true).trimEnd() ?? + '', + cursorX: pane.terminal.buffer.active.cursorX, + cursorY + } + }) +} + async function dispatchCtrlCToActiveTerminalTextarea( page: Page, options: { keyupCtrlKey?: boolean } = {} @@ -670,6 +732,58 @@ test.describe('Terminal Shortcuts', () => { ) }) + test('Cmd/Ctrl+K preserves prompt line and erases stale viewport rows', async ({ orcaPage }) => { + await waitForActivePanePtyId(orcaPage) + const promptMarker = `PROMPT_MARKER_${Date.now()}> ` + const staleMarker = `STALE_VIEWPORT_${Date.now()}` + await expect( + await writeActiveViewportMarker(orcaPage, promptMarker, staleMarker) + ).toMatchObject({ + cursorX: promptMarker.length, + cursorY: 0 + }) + await expect + .poll(async () => (await getActiveTerminalSnapshot(orcaPage)).content.includes(staleMarker), { + timeout: 3_000, + message: 'stale viewport marker was not written below the active prompt' + }) + .toBe(true) + await focusActiveTerminal(orcaPage) + await orcaPage.keyboard.press(`${mod}+k`) + await expect + .poll( + async () => { + const snapshot = await getActiveTerminalSnapshot(orcaPage) + return { + promptPreserved: snapshot.content.includes(promptMarker), + staleRowsPresent: snapshot.content.includes(staleMarker) + } + }, + { + timeout: 5_000, + message: 'Cmd+K did not preserve the prompt while erasing stale rows' + } + ) + .toEqual({ + promptPreserved: true, + staleRowsPresent: false + }) + const inputMarker = `AFTER_CLEAR_${Date.now()}` + await orcaPage.keyboard.type(inputMarker) + await expect + .poll(async () => { + const snapshot = await getActiveTerminalSnapshot(orcaPage) + return { + oldViewportPresent: snapshot.content.includes(staleMarker), + typedOnCursorLine: snapshot.cursorLine.includes(inputMarker) + } + }) + .toEqual({ + oldViewportPresent: false, + typedOnCursorLine: true + }) + }) + test('all terminal chords reach the PTY or fire their action', async ({ orcaPage, electronApp From 3635fb04ccc7f1359fe60a1cc24453d4fe8379e2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:32:26 -0700 Subject: [PATCH 3/3] Address terminal clear review issues --- src/main/ipc/pty.ts | 1 + src/renderer/src/components/terminal-pane/TerminalPane.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 25ae9223d3..b4ac6971d7 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1118,6 +1118,7 @@ export function registerPtyHandlers( // (e.g. when macOS re-activates the app and creates a new window). ipcMain.removeHandler('pty:spawn') ipcMain.removeHandler('pty:kill') + ipcMain.removeHandler('pty:clearBuffer') ipcMain.removeHandler('pty:listSessions') ipcMain.removeHandler('pty:hasChildProcesses') ipcMain.removeHandler('pty:getForegroundProcess') diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 0fc03c971c..68ab31fe6e 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -797,7 +797,7 @@ export default function TerminalPane({ } // Why: provider/session buffers can outlive the visible xterm. Clear // them through the same contract used by remote/mobile terminal state. - void transport?.clearBuffer?.().finally(() => { + void Promise.resolve(transport?.clearBuffer?.()).finally(() => { repairWindowsClear() persistLayoutSnapshot() })