Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/main/ipc/pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -3051,6 +3052,18 @@ export function registerPtyHandlers(
runtime?.onPtyExit(args.id, -1)
})

ipcMain.handle('pty:clearBuffer', async (_event, args: { id?: unknown }): Promise<void> => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 }[]> => {
Expand Down
1 change: 1 addition & 0 deletions src/preload/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
33 changes: 28 additions & 5 deletions src/renderer/src/components/terminal-pane/TerminalPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
15 changes: 15 additions & 0 deletions src/renderer/src/components/terminal-pane/pty-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {}
Expand Down Expand Up @@ -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()
Expand Down
7 changes: 7 additions & 0 deletions src/renderer/src/components/terminal-pane/pty-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/web/web-preload-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2442,6 +2442,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(),
Expand Down
114 changes: 114 additions & 0 deletions tests/e2e/terminal-shortcuts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {}
Expand Down Expand Up @@ -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
Expand Down
Loading