From a7f412f98fe44c942b0084e58cec7ae54eccbe8f Mon Sep 17 00:00:00 2001 From: Wei Zhang Date: Sat, 22 Aug 2026 12:37:59 +0800 Subject: [PATCH 1/5] fix(term): correctly handle control chars --- .../__tests__/session-terminal-query.test.ts | 91 ++++++++++++++++++ .../src/renderer/session-terminal-panel.tsx | 8 ++ .../src/renderer/session-terminal-query.ts | 95 +++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 apps/desktop/src/main/__tests__/session-terminal-query.test.ts create mode 100644 apps/desktop/src/renderer/session-terminal-query.ts 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..9c16f1ec30 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-terminal-query.test.ts @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { IDisposable, IParser } from '@xterm/xterm'; +import { + isColorQuery, + isDeviceAttributesQuery, + isDeviceStatusQuery, + isPrivateDeviceStatusQuery, + isWindowReportQuery, + suppressTerminalQueryReplies, +} from '../../renderer/session-terminal-query.js'; + +test('recognizes foreground, background, cursor, and indexed 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(4, '0;?'), true); + assert.equal(isColorQuery(4, '0;?;15;?'), true); +}); + +test('does not suppress OSC color setters or mixed set/query operations', () => { + 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, '0;?;1;#ffffff'), false); + assert.equal(isColorQuery(4, ''), false); + assert.equal(isColorQuery(3, '?'), false); +}); + +test('recognizes device attribute and status report queries', () => { + assert.equal(isDeviceAttributesQuery([0]), true); + assert.equal(isDeviceAttributesQuery([1]), false); + assert.equal(isDeviceStatusQuery([5]), true); + assert.equal(isDeviceStatusQuery([6]), true); + assert.equal(isDeviceStatusQuery([6, 1]), false); + assert.equal(isPrivateDeviceStatusQuery([6]), true); + assert.equal(isPrivateDeviceStatusQuery([5]), false); + assert.equal(isPrivateDeviceStatusQuery([[6]]), false); +}); + +test('recognizes window report queries 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([8, 24, 80]), false); + assert.equal(isWindowReportQuery([22, 0]), false); + assert.equal(isWindowReportQuery([[14]]), false); +}); + +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 queryReplies = suppressTerminalQueryReplies(parser); + + 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 === '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..b9b5a47cec 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.parser); + 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..0a73dda38a --- /dev/null +++ b/apps/desktop/src/renderer/session-terminal-query.ts @@ -0,0 +1,95 @@ +import type { IDisposable, IParser } from '@xterm/xterm'; + +export type TerminalParams = (number | number[])[]; + +const WINDOW_REPORT_OPERATIONS = new Set([11, 13, 14, 15, 16, 18, 19, 20, 21]); + +function isSingleParam(params: TerminalParams, expected: number): boolean { + return params.length === 1 && params[0] === expected; +} + +/** Returns true only for pure OSC color queries, never for color setters. */ +export function isColorQuery(ident: number, data: string): boolean { + if (ident === 4) { + const parts = data.split(';'); + if (parts.length === 0 || parts.length % 2 !== 0) return false; + for (let index = 0; index < parts.length; index += 2) { + if (!/^\d+$/.test(parts[index] ?? '') || parts[index + 1] !== '?') { + return false; + } + } + return true; + } + + // OSC 10 may query foreground, background, and cursor colors together by + // supplying additional semicolon-separated question marks. OSC 11 and 12 + // use the same payload grammar for their respective color. + return ( + (ident === 10 || ident === 11 || ident === 12) && + data.length > 0 && + data.split(';').every((part) => part === '?') + ); +} + +export function isDeviceAttributesQuery(params: TerminalParams): boolean { + return isSingleParam(params, 0); +} + +export function isDeviceStatusQuery(params: TerminalParams): boolean { + return isSingleParam(params, 5) || isSingleParam(params, 6); +} + +export function isPrivateDeviceStatusQuery(params: TerminalParams): boolean { + return isSingleParam(params, 6); +} + +export function isWindowReportQuery(params: TerminalParams): boolean { + return ( + params.length === 1 && + typeof params[0] === 'number' && + WINDOW_REPORT_OPERATIONS.has(params[0]) + ); +} + +/** + * 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 every response-generating query implemented by xterm: + * color reports, device attributes/status, mode/window reports, and DECRQSS. + * Setters and other terminal control sequences continue to xterm's handlers. + */ +export function suppressTerminalQueryReplies(parser: IParser): IDisposable { + const handlers: IDisposable[] = [ + ...[4, 10, 11, 12].map((ident) => + parser.registerOscHandler(ident, (data) => isColorQuery(ident, data)), + ), + parser.registerCsiHandler({ final: 'c' }, isDeviceAttributesQuery), + parser.registerCsiHandler( + { prefix: '>', final: 'c' }, + isDeviceAttributesQuery, + ), + parser.registerCsiHandler({ final: 'n' }, isDeviceStatusQuery), + parser.registerCsiHandler( + { prefix: '?', final: 'n' }, + isPrivateDeviceStatusQuery, + ), + 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(); + }, + }; +} From 2100630ff38281e18c56991791590f8153e7946e Mon Sep 17 00:00:00 2001 From: Wei Zhang Date: Sat, 22 Aug 2026 18:57:12 +0800 Subject: [PATCH 2/5] chore: handle general control char --- .../__tests__/session-terminal-query.test.ts | 74 +++++++++-- .../src/renderer/session-terminal-panel.tsx | 2 +- .../src/renderer/session-terminal-query.ts | 120 +++++++++++------- 3 files changed, 141 insertions(+), 55 deletions(-) diff --git a/apps/desktop/src/main/__tests__/session-terminal-query.test.ts b/apps/desktop/src/main/__tests__/session-terminal-query.test.ts index 9c16f1ec30..30d81b26b1 100644 --- a/apps/desktop/src/main/__tests__/session-terminal-query.test.ts +++ b/apps/desktop/src/main/__tests__/session-terminal-query.test.ts @@ -10,44 +10,96 @@ import { suppressTerminalQueryReplies, } from '../../renderer/session-terminal-query.js'; -test('recognizes foreground, background, cursor, and indexed color queries', () => { +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 mixed set/query operations', () => { +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, '0;?;1;#ffffff'), false); + assert.equal(isColorQuery(4, '256;?'), false); assert.equal(isColorQuery(4, ''), false); assert.equal(isColorQuery(3, '?'), false); }); -test('recognizes device attribute and status report queries', () => { +test('recognizes device attribute and status reports by their first parameter', () => { assert.equal(isDeviceAttributesQuery([0]), true); - assert.equal(isDeviceAttributesQuery([1]), false); + 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]), true); - assert.equal(isDeviceStatusQuery([6, 1]), false); + assert.equal(isDeviceStatusQuery([6, 1]), true); + assert.equal(isDeviceStatusQuery([4, 0]), false); + assert.equal(isPrivateDeviceStatusQuery([6]), true); - assert.equal(isPrivateDeviceStatusQuery([5]), false); + assert.equal(isPrivateDeviceStatusQuery([6, 1]), true); + assert.equal(isPrivateDeviceStatusQuery([5, 0]), false); assert.equal(isPrivateDeviceStatusQuery([[6]]), false); }); -test('recognizes window report queries without intercepting window commands', () => { +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; @@ -70,8 +122,9 @@ test('registers and disposes every xterm response-generating query handler', () return disposable(); }, } as unknown as IParser; + const terminal = { parser, write: (_data: string) => {} }; - const queryReplies = suppressTerminalQueryReplies(parser); + const queryReplies = suppressTerminalQueryReplies(terminal); assert.equal(registered.length, 12); assert.deepEqual( @@ -81,8 +134,7 @@ test('registers and disposes every xterm response-generating query handler', () assert.equal( registered.some( ({ kind, id }) => - kind === 'dcs' && - JSON.stringify(id) === JSON.stringify({ intermediates: '$', final: 'q' }), + kind === 'dcs' && JSON.stringify(id) === JSON.stringify({ intermediates: '$', final: 'q' }), ), true, ); diff --git a/apps/desktop/src/renderer/session-terminal-panel.tsx b/apps/desktop/src/renderer/session-terminal-panel.tsx index b9b5a47cec..e0c71f1735 100644 --- a/apps/desktop/src/renderer/session-terminal-panel.tsx +++ b/apps/desktop/src/renderer/session-terminal-panel.tsx @@ -83,7 +83,7 @@ export function SessionTerminalPanel(props: { // 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.parser); + const terminalQueryReplies = suppressTerminalQueryReplies(terminal); const writeEvent = (event: { sequence: number; data: string }) => { const live = hydration.accept(event); diff --git a/apps/desktop/src/renderer/session-terminal-query.ts b/apps/desktop/src/renderer/session-terminal-query.ts index 0a73dda38a..1d7d1e6b02 100644 --- a/apps/desktop/src/renderer/session-terminal-query.ts +++ b/apps/desktop/src/renderer/session-terminal-query.ts @@ -2,53 +2,91 @@ 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 isSingleParam(params: TerminalParams, expected: number): boolean { - return params.length === 1 && params[0] === expected; +function firstParamIs(params: TerminalParams, expected: number): boolean { + return params[0] === expected; } -/** Returns true only for pure OSC color queries, never for color setters. */ -export function isColorQuery(ident: number, data: string): boolean { +function getColorQueryAction(ident: number, data: string): ColorQueryAction { + let hasQuery = false; + const setters: string[] = []; + if (ident === 4) { const parts = data.split(';'); - if (parts.length === 0 || parts.length % 2 !== 0) return false; - for (let index = 0; index < parts.length; index += 2) { - if (!/^\d+$/.test(parts[index] ?? '') || parts[index + 1] !== '?') { - return false; + 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); } } - return true; + if (setterParts.length > 0) { + setters.push(`${OSC_START}4;${setterParts.join(';')}${STRING_TERMINATOR}`); + } + return { hasQuery, setters }; } - // OSC 10 may query foreground, background, and cursor colors together by - // supplying additional semicolon-separated question marks. OSC 11 and 12 - // use the same payload grammar for their respective color. - return ( - (ident === 10 || ident === 11 || ident === 12) && - data.length > 0 && - data.split(';').every((part) => part === '?') - ); + 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 isSingleParam(params, 0); + return firstParamIs(params, 0); } export function isDeviceStatusQuery(params: TerminalParams): boolean { - return isSingleParam(params, 5) || isSingleParam(params, 6); + return firstParamIs(params, 5) || firstParamIs(params, 6); } export function isPrivateDeviceStatusQuery(params: TerminalParams): boolean { - return isSingleParam(params, 6); + return firstParamIs(params, 6); } export function isWindowReportQuery(params: TerminalParams): boolean { - return ( - params.length === 1 && - typeof params[0] === 'number' && - WINDOW_REPORT_OPERATIONS.has(params[0]) - ); + 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; } /** @@ -59,32 +97,28 @@ export function isWindowReportQuery(params: TerminalParams): boolean { * These handlers cover every response-generating query implemented by xterm: * color reports, device attributes/status, mode/window reports, and DECRQSS. * Setters and other terminal control sequences 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(parser: IParser): IDisposable { +export function suppressTerminalQueryReplies(terminal: TerminalQueryTarget): IDisposable { + const { parser } = terminal; const handlers: IDisposable[] = [ ...[4, 10, 11, 12].map((ident) => - parser.registerOscHandler(ident, (data) => isColorQuery(ident, data)), + 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: 'c' }, isDeviceAttributesQuery), parser.registerCsiHandler({ final: 'n' }, isDeviceStatusQuery), - parser.registerCsiHandler( - { prefix: '?', final: 'n' }, - isPrivateDeviceStatusQuery, - ), + parser.registerCsiHandler({ prefix: '?', final: 'n' }, isPrivateDeviceStatusQuery), parser.registerCsiHandler({ intermediates: '$', final: 'p' }, () => true), - parser.registerCsiHandler( - { prefix: '?', intermediates: '$', final: 'p' }, - () => true, - ), + parser.registerCsiHandler({ prefix: '?', intermediates: '$', final: 'p' }, () => true), parser.registerCsiHandler({ final: 't' }, isWindowReportQuery), - parser.registerDcsHandler( - { intermediates: '$', final: 'q' }, - () => true, - ), + parser.registerDcsHandler({ intermediates: '$', final: 'q' }, () => true), ]; return { From aeee3ff6b6f8a5be449b6ab486b7d5ab3ea75799 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sat, 22 Aug 2026 19:26:35 +0800 Subject: [PATCH 3/5] fix(term): suppress xterm XTVERSION replies CSI > q still emits DCS > | xterm.js(...) ST into the durable Runtime Resource input path. Intercept the same first-parameter 0 default xterm uses, and leave CSI > 1 q unhandled. Generated-by: Grok --- .../main/__tests__/session-terminal-query.test.ts | 14 +++++++++++++- .../desktop/src/renderer/session-terminal-query.ts | 7 ++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/__tests__/session-terminal-query.test.ts b/apps/desktop/src/main/__tests__/session-terminal-query.test.ts index 30d81b26b1..3f89a49868 100644 --- a/apps/desktop/src/main/__tests__/session-terminal-query.test.ts +++ b/apps/desktop/src/main/__tests__/session-terminal-query.test.ts @@ -7,6 +7,7 @@ import { isDeviceStatusQuery, isPrivateDeviceStatusQuery, isWindowReportQuery, + isXtVersionQuery, suppressTerminalQueryReplies, } from '../../renderer/session-terminal-query.js'; @@ -46,6 +47,10 @@ test('recognizes device attribute and status reports by their first parameter', assert.equal(isPrivateDeviceStatusQuery([6, 1]), true); assert.equal(isPrivateDeviceStatusQuery([5, 0]), false); assert.equal(isPrivateDeviceStatusQuery([[6]]), 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', () => { @@ -126,11 +131,18 @@ test('registers and disposes every xterm response-generating query handler', () const queryReplies = suppressTerminalQueryReplies(terminal); - assert.equal(registered.length, 12); + assert.equal(registered.length, 13); 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 }) => diff --git a/apps/desktop/src/renderer/session-terminal-query.ts b/apps/desktop/src/renderer/session-terminal-query.ts index 1d7d1e6b02..efd784250a 100644 --- a/apps/desktop/src/renderer/session-terminal-query.ts +++ b/apps/desktop/src/renderer/session-terminal-query.ts @@ -71,6 +71,10 @@ 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) || firstParamIs(params, 6); } @@ -95,7 +99,7 @@ export function isWindowReportQuery(params: TerminalParams): boolean { * has restored canonical echo, making the reply visible at the next prompt. * * These handlers cover every response-generating query implemented by xterm: - * color reports, device attributes/status, mode/window reports, and DECRQSS. + * color reports, device attributes/status, XTVERSION, mode/window reports, and DECRQSS. * Setters and other terminal control sequences 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. @@ -113,6 +117,7 @@ export function suppressTerminalQueryReplies(terminal: TerminalQueryTarget): IDi ), parser.registerCsiHandler({ final: 'c' }, isDeviceAttributesQuery), parser.registerCsiHandler({ prefix: '>', final: 'c' }, isDeviceAttributesQuery), + parser.registerCsiHandler({ prefix: '>', final: 'q' }, isXtVersionQuery), parser.registerCsiHandler({ final: 'n' }, isDeviceStatusQuery), parser.registerCsiHandler({ prefix: '?', final: 'n' }, isPrivateDeviceStatusQuery), parser.registerCsiHandler({ intermediates: '$', final: 'p' }, () => true), From 07176e3f19144ef9073190d8483d578bf5db8937 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sat, 22 Aug 2026 19:33:17 +0800 Subject: [PATCH 4/5] fix(term): do not swallow cursor-position reports CSI 6 n and CSI ? 6 n are live CPR, not delayed capability probes. Full-screen apps need xterm to answer them; keep suppressing DSR status and other probe replies. Generated-by: Grok --- .../main/__tests__/session-terminal-query.test.ts | 12 +++--------- .../desktop/src/renderer/session-terminal-query.ts | 14 +++++--------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/main/__tests__/session-terminal-query.test.ts b/apps/desktop/src/main/__tests__/session-terminal-query.test.ts index 3f89a49868..76ca8aa48e 100644 --- a/apps/desktop/src/main/__tests__/session-terminal-query.test.ts +++ b/apps/desktop/src/main/__tests__/session-terminal-query.test.ts @@ -5,7 +5,6 @@ import { isColorQuery, isDeviceAttributesQuery, isDeviceStatusQuery, - isPrivateDeviceStatusQuery, isWindowReportQuery, isXtVersionQuery, suppressTerminalQueryReplies, @@ -39,15 +38,10 @@ test('recognizes device attribute and status reports by their first parameter', assert.equal(isDeviceStatusQuery([5]), true); assert.equal(isDeviceStatusQuery([5, 0]), true); - assert.equal(isDeviceStatusQuery([6]), true); - assert.equal(isDeviceStatusQuery([6, 1]), true); + assert.equal(isDeviceStatusQuery([6]), false); + assert.equal(isDeviceStatusQuery([6, 1]), false); assert.equal(isDeviceStatusQuery([4, 0]), false); - assert.equal(isPrivateDeviceStatusQuery([6]), true); - assert.equal(isPrivateDeviceStatusQuery([6, 1]), true); - assert.equal(isPrivateDeviceStatusQuery([5, 0]), false); - assert.equal(isPrivateDeviceStatusQuery([[6]]), false); - assert.equal(isXtVersionQuery([0]), true); assert.equal(isXtVersionQuery([0, 1]), true); assert.equal(isXtVersionQuery([1]), false); @@ -131,7 +125,7 @@ test('registers and disposes every xterm response-generating query handler', () const queryReplies = suppressTerminalQueryReplies(terminal); - assert.equal(registered.length, 13); + assert.equal(registered.length, 12); assert.deepEqual( registered.filter(({ kind }) => kind === 'osc').map(({ id }) => id), [4, 10, 11, 12], diff --git a/apps/desktop/src/renderer/session-terminal-query.ts b/apps/desktop/src/renderer/session-terminal-query.ts index efd784250a..a3d513cf8f 100644 --- a/apps/desktop/src/renderer/session-terminal-query.ts +++ b/apps/desktop/src/renderer/session-terminal-query.ts @@ -76,11 +76,7 @@ export function isXtVersionQuery(params: TerminalParams): boolean { } export function isDeviceStatusQuery(params: TerminalParams): boolean { - return firstParamIs(params, 5) || firstParamIs(params, 6); -} - -export function isPrivateDeviceStatusQuery(params: TerminalParams): boolean { - return firstParamIs(params, 6); + return firstParamIs(params, 5); } export function isWindowReportQuery(params: TerminalParams): boolean { @@ -98,9 +94,10 @@ export function isWindowReportQuery(params: TerminalParams): boolean { * 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 every response-generating query implemented by xterm: - * color reports, device attributes/status, XTVERSION, mode/window reports, and DECRQSS. - * Setters and other terminal control sequences continue to xterm's handlers. + * 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. */ @@ -119,7 +116,6 @@ export function suppressTerminalQueryReplies(terminal: TerminalQueryTarget): IDi parser.registerCsiHandler({ prefix: '>', final: 'c' }, isDeviceAttributesQuery), parser.registerCsiHandler({ prefix: '>', final: 'q' }, isXtVersionQuery), parser.registerCsiHandler({ final: 'n' }, isDeviceStatusQuery), - parser.registerCsiHandler({ prefix: '?', final: 'n' }, isPrivateDeviceStatusQuery), parser.registerCsiHandler({ intermediates: '$', final: 'p' }, () => true), parser.registerCsiHandler({ prefix: '?', intermediates: '$', final: 'p' }, () => true), parser.registerCsiHandler({ final: 't' }, isWindowReportQuery), From d909adf3d9a853d7b7f773973a1ffeae93972d91 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sat, 22 Aug 2026 19:36:17 +0800 Subject: [PATCH 5/5] fix(cli): set midTurn on /transcript The slash-command type requires midTurn. /transcript is a local TUI viewer and already opens during a running turn. Generated-by: Grok --- packages/cli/src/pi-tui-runner.ts | 1 + 1 file changed, 1 insertion(+) 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({