Skip to content
13 changes: 13 additions & 0 deletions src/main/daemon/headless-emulator-kitty-flags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { HeadlessEmulator } from './headless-emulator'

describe('HeadlessEmulator kitty keyboard flags', () => {
it('stays at zero until the PTY pushes the protocol', async () => {
const emulator = new HeadlessEmulator({ cols: 80, rows: 24 })

expect(emulator.kittyKeyboardFlags()).toBe(0)
await emulator.applyKittyKeyboardFlags(1)

expect(emulator.kittyKeyboardFlags()).toBe(1)
})
})
7 changes: 6 additions & 1 deletion src/main/daemon/headless-emulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import { advancePartialEscapeTail } from '../../shared/terminal-partial-escape-tail'
import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes'
import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges'
import { readTerminalModes } from './headless-emulator-modes'
import { readKittyKeyboardFlags, readTerminalModes } from './headless-emulator-modes'
import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences'
import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner'
import { buildFrameRestoreSnapshotFields } from './terminal-frame-restore-sequences'
Expand Down Expand Up @@ -146,6 +146,11 @@ export class HeadlessEmulator {
this.viewAttributeResponder?.clearColorOverrides()
}

/** Flags the hidden xterm parsed from this PTY's output. `0` means the kitty keyboard protocol is off. */
kittyKeyboardFlags(): number {
return readKittyKeyboardFlags(this.terminal)
}

/** Re-seeds snapshot kitty flags via the live-push parse, routed unflagged so it can never answer a query (terminal-query-authority.md). */
applyKittyKeyboardFlags(flags: number): Promise<void> {
if (!Number.isInteger(flags) || flags <= 0) {
Expand Down
29 changes: 29 additions & 0 deletions src/main/runtime/headless-hydration-ownership.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { expect, it, vi } from 'vitest'
import {
TERMINAL_INTERRUPT_KITTY_CTRL_C,
terminalInterruptBytes
} from '../../shared/terminal-interrupt-bytes'
import { deferred, makeDeferred } from './orca-runtime-test-fixtures.spec'
import {
createHydrationRuntime,
Expand Down Expand Up @@ -26,6 +30,31 @@ function prepare() {
return { runtime, snapshot, serialize }
}

it('reapplies kitty keyboard flags proven by the renderer buffer', async () => {
const { runtime, snapshot } = prepare()
runtime.onPtyData(PTY_ID, 'LIVE', 1)
const current = runtime.model()
snapshot.resolve({ ...RETIRED_SNAPSHOT, seq: 4, kittyKeyboardFlags: 1 })
await current.writeChain
expect(current.emulator.kittyKeyboardFlags()).toBe(1)
expect(terminalInterruptBytes(current.emulator.kittyKeyboardFlags())).toBe(
TERMINAL_INTERRUPT_KITTY_CTRL_C
)
})

it('applies kitty keyboard flags when the renderer buffer is empty', async () => {
const { runtime, snapshot } = prepare()
runtime.onPtyData(PTY_ID, 'LIVE', 1)
const current = runtime.model()
snapshot.resolve({ ...RETIRED_SNAPSHOT, data: '', seq: 4, kittyKeyboardFlags: 1 })
await current.writeChain
expect(current.emulator.kittyKeyboardFlags()).toBe(1)
expect(terminalInterruptBytes(current.emulator.kittyKeyboardFlags())).toBe(
TERMINAL_INTERRUPT_KITTY_CTRL_C
)
expect(current.emulator.getVisibleLines().join('\n')).not.toContain('RETIRED-SEED')
})

it('does not start renderer hydration after the model retires before its callback', async () => {
const { runtime, snapshot, serialize } = prepare()
runtime.onPtyData(PTY_ID, 'queued-live', 1)
Expand Down
9 changes: 9 additions & 0 deletions src/main/runtime/headless-seed-ownership.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ it('preserves current seed metadata and ordered live output', async () => {
expect(snapshot.modes.kittyKeyboardFlags).toBe(3)
})

it('restores kitty keyboard flags from the provider snapshot after replacement', async () => {
const { runtime, snapshot } = prepareProvider()
runtime.replaceExecutionContext()
const model = runtime.model()
snapshot.resolve({ ...PROVIDER_SNAPSHOT, kittyKeyboardFlags: 1 })
await model.writeChain
expect(runtime.model().emulator.kittyKeyboardFlags()).toBe(1)
})

it('does not acquire a provider snapshot for a model retired before its callback', async () => {
const { runtime, snapshot, serialize } = prepareProvider()
runtime.replaceExecutionContext()
Expand Down
7 changes: 5 additions & 2 deletions src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,10 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso
if (!pty.pty.connected) {
throw new Error('terminal_not_writable')
}
const payload = buildTerminalSendPayload(action)
const payload = buildTerminalSendPayload(
action,
this.readPtyKittyKeyboardFlags(pty.pty.ptyId)
)
if (payload === null) {
throw new Error('invalid_terminal_send')
}
Expand All @@ -129,7 +132,7 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso
if (!leaf.writable || !leaf.ptyId) {
throw new Error('terminal_not_writable')
}
const payload = buildTerminalSendPayload(action)
const payload = buildTerminalSendPayload(action, this.readPtyKittyKeyboardFlags(leaf.ptyId))
if (payload === null) {
throw new Error('invalid_terminal_send')
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWi
if (this.headlessTerminals.get(ptyId) !== state) {
return
}
// The provider snapshot omits kitty pushes, same as a renderer rehydrate.
// Reapply the flags after the seed so a later interrupt still encodes Ctrl+C.
if (typeof snapshot.kittyKeyboardFlags === 'number') {
await state.emulator.applyKittyKeyboardFlags(snapshot.kittyKeyboardFlags)
if (this.headlessTerminals.get(ptyId) !== state) {
return
}
}
Comment thread
innocarpe marked this conversation as resolved.
if (snapshot.cwd !== undefined) {
state.emulator.setCwd(snapshot.cwd)
if (!this.terminalCwdByPtyId.has(ptyId) && snapshot.cwd?.trim()) {
Expand Down
5 changes: 5 additions & 0 deletions src/main/runtime/orca-runtime-fit-override-listeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ export class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWithStopRequ

protected headlessTerminals = new Map<string, RuntimeHeadlessTerminal>()

/** `0` when this PTY has no headless model yet, so an interrupt stays a bare ETX. */
protected readPtyKittyKeyboardFlags(ptyId: string): number {
return this.headlessTerminals.get(ptyId)?.emulator.kittyKeyboardFlags() ?? 0
Comment thread
innocarpe marked this conversation as resolved.
}

protected ptyOutputSequenceById = new Map<string, number>()

protected providerSequenceInitializedPtys = new Set<string>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,7 @@ import { detectAgentStatusFromTitle, normalizeTerminalTitle } from '../../shared
import { shouldModelAnswerHiddenPtyQueries } from './terminal-model-query-authority'

export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntimeWithSerializeMainTerminalBuffer {
// Why: hydrate the runtime headless emulator from the desktop renderer's
// xterm buffer on the first onPtyData byte after a PTY is taken over by a
// pane. Eager-state pattern matches seedHeadlessTerminal: headlessTerminals
// is populated synchronously so concurrent live writes from
// trackHeadlessTerminalData chain after the seed via the same writeChain.
// See docs/mobile-prefer-renderer-scrollback.md.
/** Hydrates a taken-over PTY from the renderer buffer before live writes use its headless emulator. */
protected maybeHydrateHeadlessFromRenderer(ptyId: string): void {
if (this.headlessHydrationState.has(ptyId)) {
return
Expand Down Expand Up @@ -63,11 +58,16 @@ export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntime
const rendered = await controller.serializeBuffer!(ptyId, {
scrollbackRows: MOBILE_SUBSCRIBE_SCROLLBACK_ROWS
})
if (
this.headlessTerminals.get(ptyId) !== state ||
!rendered ||
rendered.data.length === 0
) {
if (this.headlessTerminals.get(ptyId) !== state || !rendered) {
return
}
// Why: a blank renderer buffer can still carry kitty flags beside the
// payload. Returning on empty data left the new emulator at 0, so an
// interrupt sent ETX while the TUI was in the protocol.
if (rendered.data.length === 0) {
if (typeof rendered.kittyKeyboardFlags === 'number') {
await state.emulator.applyKittyKeyboardFlags(rendered.kittyKeyboardFlags)
}
return
}
this.recordOsc7MetadataForPty(ptyId, rendered.data)
Expand All @@ -82,6 +82,16 @@ export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntime
if (this.headlessTerminals.get(ptyId) !== state) {
return
}
// Why AFTER the seed write: a renderer buffer omits kitty pushes, same as a
// provider snapshot. The proven flags ride beside the payload. Without this
// reapply, a model seeded from the mounted renderer reports 0 and an
// interrupt falls back to ETX while the TUI is still in the protocol.
if (typeof rendered.kittyKeyboardFlags === 'number') {
await state.emulator.applyKittyKeyboardFlags(rendered.kittyKeyboardFlags)
if (this.headlessTerminals.get(ptyId) !== state) {
return
}
}
Comment thread
innocarpe marked this conversation as resolved.
const ptyDims = this.getTerminalSize(ptyId)
if (ptyDims && (ptyDims.cols !== rendered.cols || ptyDims.rows !== rendered.rows)) {
state.emulator.resize(ptyDims.cols, ptyDims.rows)
Expand Down
32 changes: 32 additions & 0 deletions src/main/runtime/runtime-terminal-writer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { TERMINAL_INTERRUPT_KITTY_CTRL_C } from '../../shared/terminal-interrupt-bytes'
import { RuntimeTerminalWriter } from './runtime-terminal-writer'

describe('RuntimeTerminalWriter interrupt bytes', () => {
it('writes a bare ETX when the PTY has no kitty keyboard flags', async () => {
const writes: string[] = []
const writer = new RuntimeTerminalWriter((ptyId, data) => {
expect(ptyId).toBe('pty-1')
writes.push(data)
return true
})

await writer.writeAction('pty-1', { interrupt: true }, '\x03', { inputKind: 'driving' })

expect(writes).toEqual(['\x03'])
})

it('writes the interrupt bytes already chosen for the payload', async () => {
const writes: string[] = []
const writer = new RuntimeTerminalWriter((_ptyId, data) => {
writes.push(data)
return true
})

await writer.writeAction('pty-1', { interrupt: true }, TERMINAL_INTERRUPT_KITTY_CTRL_C, {
inputKind: 'driving'
})

expect(writes).toEqual([TERMINAL_INTERRUPT_KITTY_CTRL_C])
})
})
4 changes: 3 additions & 1 deletion src/main/runtime/runtime-terminal-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ export class RuntimeTerminalWriter {
await this.writeChunks(ptyId, text, options)
}
if (hasSuffix) {
const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '')
// The payload already chose the interrupt bytes. Reading the flags again
// after the write delay can send a different sequence than the one counted.
const suffix = payload.slice(text.length)
if (text) {
// Why: same hazard as the agent-prompt path -- Enter must not overtake text the
// execution host is still ingesting, and a flat 500 ms cannot cover 16 MB.
Expand Down
17 changes: 11 additions & 6 deletions src/main/runtime/terminal-send-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@ import {
isTerminalInputTooLargeWithYield,
TERMINAL_INPUT_TOO_LARGE_ERROR
} from '../../shared/terminal-input'
import { terminalInterruptBytes } from '../../shared/terminal-interrupt-bytes'

export function buildTerminalSendPayload(action: {
text?: string
enter?: boolean
interrupt?: boolean
}): string | null {
/** Builds terminal.send bytes using the PTY's current kitty flags for Ctrl+C. */
export function buildTerminalSendPayload(
action: {
text?: string
enter?: boolean
interrupt?: boolean
},
kittyKeyboardFlags = 0
): string | null {
let payload = ''
if (typeof action.text === 'string' && action.text.length > 0) {
payload += action.text
Expand All @@ -16,7 +21,7 @@ export function buildTerminalSendPayload(action: {
payload += '\r'
}
if (action.interrupt) {
payload += '\x03'
payload += terminalInterruptBytes(kittyKeyboardFlags)
}
return payload.length > 0 ? payload : null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,62 @@ describe('connectPanePty', () => {
})
})

it('infers Kitty Ctrl+C from a non-Latin key path without captured intent', async () => {
const { connectPanePty } = await import('./pty-connection')
const { pasteTerminalText } = await import('./terminal-bracketed-paste')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
vi.useFakeTimers()
vi.setSystemTime(1_100)
const paneKey = makePaneKey('tab-1', LEAF_1)
mockStoreState.agentStatusByPaneKey[paneKey] = {
state: 'working',
prompt: 'stop from a non-Latin keyboard layout',
updatedAt: 1_000,
stateStartedAt: 900,
agentType: 'custom-agent',
paneKey,
terminalTitle: 'Codex working',
stateHistory: []
}
const terminalTarget = createKeyboardEventTarget()
const pane = createPane(1)
;(pane.terminal as { element?: unknown }).element = terminalTarget.target
pane.terminal.modes.bracketedPasteMode = true
const observedIgnoreValues: (boolean | undefined)[] = []
pane.terminal.paste.mockImplementation(() => {
observedIgnoreValues.push(pane.terminal.options.ignoreBracketedPasteMode)
})
let onDataHandler: ((data: string) => void) | null = null
pane.terminal.onData = vi.fn(((handler: (data: string) => void) => {
onDataHandler = handler
return { dispose: vi.fn() }
}) as typeof pane.terminal.onData)

connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
if (!onDataHandler) {
throw new Error('expected onData handler to be registered')
}
terminalTarget.dispatch(keyEvent({ key: 'ㅊ', code: 'KeyC', ctrlKey: true }))
;(onDataHandler as unknown as (data: string) => void)('\x1b[99;5u')
await flushAsyncTicks()
vi.advanceTimersByTime(500)
await flushAsyncTicks()
pasteTerminalText(pane.terminal as never, 'a69ce28e1d092e0c8825cd1a109ac36409962bc1')

expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x1b[99;5u', 'query-reply')
expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({
paneKey,
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 900,
baselinePrompt: 'stop from a non-Latin keyboard layout',
baselineAgentType: 'custom-agent',
intent: 'ctrl-c'
})
expect(observedIgnoreValues).toEqual([true])
expect(pane.terminal.options.ignoreBracketedPasteMode).toBe(false)
})

it('marks bracketed paste as stale after acknowledged Ctrl+C input', async () => {
const { connectPanePty } = await import('./pty-connection')
const { pasteTerminalText } = await import('./terminal-bracketed-paste')
Expand Down
Loading