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