diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index b391e01ca4..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') @@ -3051,6 +3052,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 7d1637545d..68ab31fe6e 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -772,14 +772,37 @@ export default function TerminalPane({ const clearPaneScrollback = useCallback( (pane: ManagedPane): void => { clearedScrollbackLeafIdsRef.current.add(pane.leafId) + const transport = paneTransportsRef.current.get(pane.id) + const ptyId = transport?.getPtyId() ?? null 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 clearWebRuntimeTerminalBuffer(ptyId) - persistLayoutSnapshot() + const isRemoteRuntimePty = typeof ptyId === 'string' && isRemoteRuntimePtyId(ptyId) + 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) + 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) + pane.terminal.refresh(0, Math.max(0, pane.terminal.rows - 1)) + }) + } + // Why: provider/session buffers can outlive the visible xterm. Clear + // them through the same contract used by remote/mobile terminal state. + void Promise.resolve(transport?.clearBuffer?.()).finally(() => { + repairWindowsClear() + persistLayoutSnapshot() + }) }, - [paneTransportsRef, persistLayoutSnapshot] + [paneTransportsRef, persistLayoutSnapshot, worktreeId] ) useEffect(() => { 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