Fix Windows terminal clear repaint (#6382)
This commit is contained in:
parent
d188e7d2f6
commit
daab519c5c
|
|
@ -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 }[]> => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue