diff --git a/apps/desktop/src/main/__tests__/session-terminal-query.test.ts b/apps/desktop/src/main/__tests__/session-terminal-query.test.ts new file mode 100644 index 0000000000..76ca8aa48e --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-terminal-query.test.ts @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { IDisposable, IParser } from '@xterm/xterm'; +import { + isColorQuery, + isDeviceAttributesQuery, + isDeviceStatusQuery, + isWindowReportQuery, + isXtVersionQuery, + suppressTerminalQueryReplies, +} from '../../renderer/session-terminal-query.js'; + +test('recognizes pure and mixed OSC color queries', () => { + assert.equal(isColorQuery(10, '?'), true); + assert.equal(isColorQuery(11, '?'), true); + assert.equal(isColorQuery(12, '?'), true); + assert.equal(isColorQuery(10, '?;?;?'), true); + assert.equal(isColorQuery(10, '?;#fff'), true); + assert.equal(isColorQuery(4, '0;?'), true); + assert.equal(isColorQuery(4, '0;?;15;?'), true); + assert.equal(isColorQuery(4, '0;?;1;#fff'), true); +}); + +test('does not suppress OSC color setters or invalid query forms', () => { + assert.equal(isColorQuery(10, 'rgb:0f0f/0f0f/1212'), false); + assert.equal(isColorQuery(11, '#ffffff'), false); + assert.equal(isColorQuery(4, '0;rgb:0000/0000/0000'), false); + assert.equal(isColorQuery(4, '256;?'), false); + assert.equal(isColorQuery(4, ''), false); + assert.equal(isColorQuery(3, '?'), false); +}); + +test('recognizes device attribute and status reports by their first parameter', () => { + assert.equal(isDeviceAttributesQuery([0]), true); + assert.equal(isDeviceAttributesQuery([0, 0]), true); + assert.equal(isDeviceAttributesQuery([0, 1]), true); + assert.equal(isDeviceAttributesQuery([1, 0]), false); + + assert.equal(isDeviceStatusQuery([5]), true); + assert.equal(isDeviceStatusQuery([5, 0]), true); + assert.equal(isDeviceStatusQuery([6]), false); + assert.equal(isDeviceStatusQuery([6, 1]), false); + assert.equal(isDeviceStatusQuery([4, 0]), false); + + assert.equal(isXtVersionQuery([0]), true); + assert.equal(isXtVersionQuery([0, 1]), true); + assert.equal(isXtVersionQuery([1]), false); +}); + +test('recognizes window reports without intercepting window commands', () => { + for (const operation of [11, 13, 14, 15, 16, 18, 19, 20, 21]) { + assert.equal(isWindowReportQuery([operation]), true); + } + assert.equal(isWindowReportQuery([14, 0]), true); + assert.equal(isWindowReportQuery([14, 2]), false); + assert.equal(isWindowReportQuery([16, 0]), true); + assert.equal(isWindowReportQuery([8, 24, 80]), false); + assert.equal(isWindowReportQuery([22, 0]), false); + assert.equal(isWindowReportQuery([23, 0]), false); + assert.equal(isWindowReportQuery([[14]]), false); +}); + +test('mixed OSC color queries are suppressed and their setters are replayed', () => { + const registered: Array<{ kind: string; id: unknown; callback: unknown }> = []; + const writes: string[] = []; + const disposable = (): IDisposable => ({ dispose() {} }); + const parser = { + registerOscHandler: (id: number, callback: unknown) => { + registered.push({ kind: 'osc', id, callback }); + return disposable(); + }, + registerCsiHandler: () => disposable(), + registerDcsHandler: () => disposable(), + } as unknown as IParser; + const terminal = { parser, write: (data: string) => writes.push(data) }; + const queryReplies = suppressTerminalQueryReplies(terminal); + const oscHandler = (id: number): ((data: string) => boolean) => { + const callback = registered.find( + (handler) => handler.kind === 'osc' && handler.id === id, + )?.callback; + assert.equal(typeof callback, 'function'); + return callback as (data: string) => boolean; + }; + + assert.equal(oscHandler(4)('0;?;1;#fff'), true); + assert.deepEqual(writes, ['\x1b]4;1;#fff\x1b\\']); + + writes.length = 0; + assert.equal(oscHandler(10)('?;#fff'), true); + assert.deepEqual(writes, ['\x1b]11;#fff\x1b\\']); + + writes.length = 0; + assert.equal(oscHandler(10)('#000;?;#fff'), true); + assert.deepEqual(writes, ['\x1b]10;#000\x1b\\', '\x1b]12;#fff\x1b\\']); + + writes.length = 0; + assert.equal(oscHandler(4)('0;#000;1;#fff'), false); + assert.deepEqual(writes, []); + queryReplies.dispose(); +}); + +test('registers and disposes every xterm response-generating query handler', () => { + const registered: Array<{ kind: string; id: unknown; callback: unknown }> = []; + let disposed = 0; + const disposable = (): IDisposable => ({ + dispose: () => { + disposed += 1; + }, + }); + const parser = { + registerOscHandler: (id: number, callback: unknown) => { + registered.push({ kind: 'osc', id, callback }); + return disposable(); + }, + registerCsiHandler: (id: unknown, callback: unknown) => { + registered.push({ kind: 'csi', id, callback }); + return disposable(); + }, + registerDcsHandler: (id: unknown, callback: unknown) => { + registered.push({ kind: 'dcs', id, callback }); + return disposable(); + }, + } as unknown as IParser; + const terminal = { parser, write: (_data: string) => {} }; + + const queryReplies = suppressTerminalQueryReplies(terminal); + + assert.equal(registered.length, 12); + assert.deepEqual( + registered.filter(({ kind }) => kind === 'osc').map(({ id }) => id), + [4, 10, 11, 12], + ); + assert.equal( + registered.some( + ({ kind, id }) => + kind === 'csi' && JSON.stringify(id) === JSON.stringify({ prefix: '>', final: 'q' }), + ), + true, + ); + assert.equal( + registered.some( + ({ kind, id }) => + kind === 'dcs' && JSON.stringify(id) === JSON.stringify({ intermediates: '$', final: 'q' }), + ), + true, + ); + queryReplies.dispose(); + assert.equal(disposed, registered.length); +}); diff --git a/apps/desktop/src/renderer/session-terminal-panel.tsx b/apps/desktop/src/renderer/session-terminal-panel.tsx index 6e3fa73cdf..e0c71f1735 100644 --- a/apps/desktop/src/renderer/session-terminal-panel.tsx +++ b/apps/desktop/src/renderer/session-terminal-panel.tsx @@ -12,6 +12,7 @@ import { Terminal } from '@xterm/xterm'; import '@xterm/xterm/css/xterm.css'; import { getDesktopConversationCopy } from './locales/conversation-copy'; import { SessionTerminalHydration } from './session-terminal-hydration'; +import { suppressTerminalQueryReplies } from './session-terminal-query'; function terminalTheme(element: HTMLElement) { const styles = getComputedStyle(element); @@ -78,6 +79,12 @@ export function SessionTerminalPanel(props: { terminalRef.current = terminal; fitRef.current = fit; + // Runtime Resource controls are durable, serialized operations. Terminal + // replies can therefore outlive short capability probes and be echoed into + // the next prompt. Do not route xterm-generated query replies through that + // input path; terminal setters and ordinary user input remain unaffected. + const terminalQueryReplies = suppressTerminalQueryReplies(terminal); + const writeEvent = (event: { sequence: number; data: string }) => { const live = hydration.accept(event); if (live) terminal.write(live.data); @@ -170,6 +177,7 @@ export function SessionTerminalPanel(props: { unsubscribe(); unsubscribeResync(); inputSubscription.dispose(); + terminalQueryReplies.dispose(); void window.maka.shellRuns .detach({ sessionId: props.sessionId, ref: props.terminalRef! }) .catch(() => {}); diff --git a/apps/desktop/src/renderer/session-terminal-query.ts b/apps/desktop/src/renderer/session-terminal-query.ts new file mode 100644 index 0000000000..a3d513cf8f --- /dev/null +++ b/apps/desktop/src/renderer/session-terminal-query.ts @@ -0,0 +1,130 @@ +import type { IDisposable, IParser } from '@xterm/xterm'; + +export type TerminalParams = (number | number[])[]; + +interface TerminalQueryTarget { + readonly parser: IParser; + write(data: string): void; +} + +interface ColorQueryAction { + hasQuery: boolean; + setters: string[]; +} + +const WINDOW_REPORT_OPERATIONS = new Set([11, 13, 14, 15, 16, 18, 19, 20, 21]); +const OSC_START = '\x1b]'; +const STRING_TERMINATOR = '\x1b\\'; + +function firstParamIs(params: TerminalParams, expected: number): boolean { + return params[0] === expected; +} + +function getColorQueryAction(ident: number, data: string): ColorQueryAction { + let hasQuery = false; + const setters: string[] = []; + + if (ident === 4) { + const parts = data.split(';'); + const setterParts: string[] = []; + for (let offset = 0; offset + 1 < parts.length; offset += 2) { + const indexText = parts[offset] ?? ''; + const value = parts[offset + 1] ?? ''; + const index = /^\d+$/.test(indexText) ? Number(indexText) : -1; + if (index < 0 || index > 255) continue; + if (value === '?') { + hasQuery = true; + } else { + setterParts.push(indexText, value); + } + } + if (setterParts.length > 0) { + setters.push(`${OSC_START}4;${setterParts.join(';')}${STRING_TERMINATOR}`); + } + return { hasQuery, setters }; + } + + if (ident < 10 || ident > 12) return { hasQuery, setters }; + + // OSC 10 can address foreground, background, and cursor colors by adding + // values; OSC 11 can address the latter two, and OSC 12 only the cursor. + // Replay non-query values separately because removing a query value would + // otherwise shift every following value to the wrong color slot. + const parts = data.split(';'); + for (let offset = 0; offset < parts.length && ident + offset <= 12; offset += 1) { + const value = parts[offset] ?? ''; + if (value === '?') { + hasQuery = true; + } else { + setters.push(`${OSC_START}${ident + offset};${value}${STRING_TERMINATOR}`); + } + } + return { hasQuery, setters }; +} + +/** Returns true when an OSC color payload contains a reply-generating query. */ +export function isColorQuery(ident: number, data: string): boolean { + return getColorQueryAction(ident, data).hasQuery; +} + +export function isDeviceAttributesQuery(params: TerminalParams): boolean { + return firstParamIs(params, 0); +} + +export function isXtVersionQuery(params: TerminalParams): boolean { + return firstParamIs(params, 0); +} + +export function isDeviceStatusQuery(params: TerminalParams): boolean { + return firstParamIs(params, 5); +} + +export function isWindowReportQuery(params: TerminalParams): boolean { + const operation = params[0]; + if (typeof operation !== 'number' || !WINDOW_REPORT_OPERATIONS.has(operation)) { + return false; + } + + // CSI 14;2 t requests only the text-area size, which xterm does not report. + return operation !== 14 || params[1] !== 2; +} + +/** + * Prevent xterm-generated capability replies from entering the durable Runtime + * Resource input path. That path can deliver a reply after a short-lived probe + * has restored canonical echo, making the reply visible at the next prompt. + * + * These handlers cover capability probes that xterm would otherwise answer into + * onData: color reports, DA, XTVERSION, DSR status, mode/window reports, and DECRQSS. + * Cursor-position reports (CSI 6 n / CSI ? 6 n) stay with xterm so full-screen + * apps can still locate the cursor. Setters continue to xterm's handlers. + * Mixed OSC color payloads are intercepted in full, then their setter-only + * portions are written back so xterm applies them without emitting replies. + */ +export function suppressTerminalQueryReplies(terminal: TerminalQueryTarget): IDisposable { + const { parser } = terminal; + const handlers: IDisposable[] = [ + ...[4, 10, 11, 12].map((ident) => + parser.registerOscHandler(ident, (data) => { + const action = getColorQueryAction(ident, data); + if (!action.hasQuery) return false; + for (const setter of action.setters) terminal.write(setter); + return true; + }), + ), + parser.registerCsiHandler({ final: 'c' }, isDeviceAttributesQuery), + parser.registerCsiHandler({ prefix: '>', final: 'c' }, isDeviceAttributesQuery), + parser.registerCsiHandler({ prefix: '>', final: 'q' }, isXtVersionQuery), + parser.registerCsiHandler({ final: 'n' }, isDeviceStatusQuery), + parser.registerCsiHandler({ intermediates: '$', final: 'p' }, () => true), + parser.registerCsiHandler({ prefix: '?', intermediates: '$', final: 'p' }, () => true), + parser.registerCsiHandler({ final: 't' }, isWindowReportQuery), + parser.registerDcsHandler({ intermediates: '$', final: 'q' }, () => true), + ]; + + return { + dispose() { + for (const handler of handlers.reverse()) handler.dispose(); + }, + }; +} diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 516c309d44..2f7a0a0309 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -2876,6 +2876,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }, transcript: { description: primaryGuidance.commands.transcript, + midTurn: 'local', run: (parts: string[]) => { if (parts.length !== 1) { state.entries.push({