Fix Windows terminal clear repaint (#6382)

This commit is contained in:
Jinwoo Hong 2026-06-26 13:59:00 -07:00 committed by GitHub
parent d188e7d2f6
commit daab519c5c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 181 additions and 5 deletions

View File

@ -1179,6 +1179,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')
@ -3167,6 +3168,18 @@ export function registerPtyHandlers(
runtime?.onPtyExit(args.id, -1)
})
ipcMain.handle('pty:clearBuffer', async (_event, args: { id?: unknown }): Promise<void> => {
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 }[]> => {

View File

@ -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<void>
clearBuffer: (id: string) => Promise<void>
ackColdRestore: (id: string) => void
ackData: (id: string, charCount: number) => void
setActiveRendererPty: (id: string, active: boolean) => void

View File

@ -771,6 +771,7 @@ const api = {
kill: (id: string, opts?: { keepHistory?: boolean }): Promise<void> =>
ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }),
clearBuffer: (id: string): Promise<void> => ipcRenderer.invoke('pty:clearBuffer', { id }),
listSessions: (): Promise<{ id: string; cwd: string; title: string }[]> =>
ipcRenderer.invoke('pty:listSessions'),

View File

@ -779,14 +779,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(() => {

View File

@ -363,6 +363,7 @@ export type PtyTransport = {
getPtyId: () => string | null
getConnectionId?: () => string | null | undefined
getLocalSessionMetadata?: () => LocalPtySessionMetadata | null
clearBuffer?: () => Promise<void>
serializeBuffer?: (opts?: { scrollbackRows?: number }) => Promise<PtyBufferSnapshot | null>
preserve?: () => void
/** Unregister PTY handlers without killing the process for pane remounts. */

View File

@ -42,6 +42,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 () => {}
@ -163,6 +164,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()

View File

@ -967,6 +967,13 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
}
},
async clearBuffer() {
if (!connected || !ptyId) {
return
}
await window.api.pty.clearBuffer(ptyId)
},
destroy() {
destroyed = true
this.disconnect()

View File

@ -2445,6 +2445,7 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
onSerializeBufferRequest: () => noopUnsubscribe,
onClearBufferRequest: () => noopUnsubscribe,
sendSerializedBuffer: () => {},
clearBuffer: () => Promise.resolve(),
declarePendingPaneSerializer: () => Promise.resolve(0),
settlePaneSerializer: () => Promise.resolve(),
clearPendingPaneSerializer: () => Promise.resolve(),

View File

@ -93,6 +93,68 @@ async function focusActiveTerminal(page: Page): Promise<void> {
})
}
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<void>((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 } = {}
@ -680,6 +742,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