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
149 changes: 149 additions & 0 deletions apps/desktop/src/main/__tests__/session-terminal-query.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
8 changes: 8 additions & 0 deletions apps/desktop/src/renderer/session-terminal-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(() => {});
Expand Down
130 changes: 130 additions & 0 deletions apps/desktop/src/renderer/session-terminal-query.ts
Original file line number Diff line number Diff line change
@@ -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();
},
};
}
1 change: 1 addition & 0 deletions packages/cli/src/pi-tui-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2876,6 +2876,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
},
transcript: {
description: primaryGuidance.commands.transcript,
midTurn: 'local',
run: (parts: string[]) => {
if (parts.length !== 1) {
state.entries.push({
Expand Down