You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by ja...@apache.org on 2026/08/22 11:50:01 UTC
(maka) branch main updated: fix(term): correctly handle control chars (#3459)
This is an automated email from the ASF dual-hosted git repository.
jackwener pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new b434a491a fix(term): correctly handle control chars (#3459)
b434a491a is described below
commit b434a491ae6fe526b1458d6ed4fb26bd454d18b8
Author: Wei Zhang <kw...@tabbyml.com>
AuthorDate: Sat Aug 22 19:49:56 2026 +0800
fix(term): correctly handle control chars (#3459)
* fix(term): correctly handle control chars
* chore: handle general control char
* 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
* 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
* 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
---------
Co-authored-by: jackwener <ja...@gmail.com>
---
.../main/__tests__/session-terminal-query.test.ts | 149 +++++++++++++++++++++
.../src/renderer/session-terminal-panel.tsx | 8 ++
.../desktop/src/renderer/session-terminal-query.ts | 130 ++++++++++++++++++
packages/cli/src/pi-tui-runner.ts | 1 +
4 files changed, 288 insertions(+)
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 000000000..76ca8aa48
--- /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 6e3fa73cd..e0c71f173 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 000000000..a3d513cf8
--- /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 516c309d4..2f7a0a030 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<void> {
},
transcript: {
description: primaryGuidance.commands.transcript,
+ midTurn: 'local',
run: (parts: string[]) => {
if (parts.length !== 1) {
state.entries.push({
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org