diff --git a/.gitignore b/.gitignore
index 32fcf9a1ed3..9f925b63328 100644
--- a/.gitignore
+++ b/.gitignore
@@ -94,6 +94,9 @@ superset-dev-data/
test-conflict-repo/
.amp/*
+# Crush project context
+.crush/
+
# Claude Code session lock (runtime artifact)
.claude/scheduled_tasks.lock
temp/
diff --git a/apps/desktop/src/main/terminal-host/pty-subprocess.ts b/apps/desktop/src/main/terminal-host/pty-subprocess.ts
index 5778288dc84..6ca5241d664 100644
--- a/apps/desktop/src/main/terminal-host/pty-subprocess.ts
+++ b/apps/desktop/src/main/terminal-host/pty-subprocess.ts
@@ -57,7 +57,7 @@ const INPUT_QUEUE_HARD_LIMIT_BYTES = 64 * 1024 * 1024; // 64MB
let outputChunks: string[] = [];
let outputBytesQueued = 0;
let outputFlushScheduled = false;
-const OUTPUT_FLUSH_INTERVAL_MS = 16; // Match terminal-style frame batching (~60fps)
+const OUTPUT_FLUSH_INTERVAL_MS = 0;
const MAX_OUTPUT_BATCH_SIZE_BYTES = 128 * 1024; // 128KB max per flush
// Backpressure - track if stdout is draining
diff --git a/apps/desktop/src/main/terminal-host/session.ts b/apps/desktop/src/main/terminal-host/session.ts
index 6b39f3e48a6..4359a8248ec 100644
--- a/apps/desktop/src/main/terminal-host/session.ts
+++ b/apps/desktop/src/main/terminal-host/session.ts
@@ -95,7 +95,7 @@ const SHELL_READY_TIMEOUT_MS = 15_000;
*
* Disable by setting SUPERSET_TERMINAL_BROADCAST_COALESCE=0.
*/
-const BROADCAST_COALESCE_INTERVAL_MS = 16;
+const BROADCAST_COALESCE_INTERVAL_MS = 0;
const BROADCAST_COALESCE_MAX_BYTES = 131_072;
const BROADCAST_COALESCE_ENABLED =
process.env.SUPERSET_TERMINAL_BROADCAST_COALESCE !== "0";
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx
index fec521ab7ac..b457ff992a0 100644
--- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx
@@ -13,6 +13,7 @@ import { SessionKilledOverlay } from "./components";
import {
DEFAULT_TERMINAL_FONT_FAMILY,
DEFAULT_TERMINAL_FONT_SIZE,
+ TERMINAL_OPTIONS,
} from "./config";
import { getDefaultTerminalBg } from "./helpers";
import {
@@ -521,8 +522,16 @@ export const Terminal = memo(function Terminal({
useEffect(() => {
const xterm = xtermRef.current;
- if (!xterm || !terminalTheme) return;
- xterm.options.theme = terminalTheme;
+ if (!xterm) return;
+ xterm.options.vtExtensions = { ...TERMINAL_OPTIONS.vtExtensions };
+ xterm.options.scrollOnEraseInDisplay =
+ TERMINAL_OPTIONS.scrollOnEraseInDisplay;
+ xterm.options.macOptionIsMeta = TERMINAL_OPTIONS.macOptionIsMeta;
+ xterm.options.cursorStyle = TERMINAL_OPTIONS.cursorStyle;
+ xterm.options.cursorInactiveStyle = TERMINAL_OPTIONS.cursorInactiveStyle;
+ if (terminalTheme) {
+ xterm.options.theme = terminalTheme;
+ }
}, [terminalTheme]);
const { data: fontSettings } = electronTrpc.settings.getFontSettings.useQuery(
@@ -581,7 +590,7 @@ export const Terminal = memo(function Terminal({
return (
-
+
{xtermInstance && typingPreviewText && (
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/config.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/config.ts
index 477ac70ae03..05f139e3889 100644
--- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/config.ts
+++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/config.ts
@@ -33,6 +33,7 @@ export const TERMINAL_OPTIONS: ITerminalOptions = {
allowTransparency: true,
allowProposedApi: true,
scrollback: DEFAULT_TERMINAL_SCROLLBACK,
+ scrollOnEraseInDisplay: true,
// Allow Option+key to type special characters on international keyboards (e.g., Option+2 = @)
macOptionIsMeta: false,
cursorStyle: "block",
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts
index 51e7153af80..72a20cfe225 100644
--- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts
+++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts
@@ -100,6 +100,7 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): {
searchAddon: SearchAddon;
wrapper: HTMLDivElement;
linkManager: TerminalLinkManager;
+ openOnce: () => void;
cleanup: () => void;
} {
const {
@@ -120,13 +121,18 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): {
const imageAddon = new ImageAddon();
let disposed = false;
+ let opened = false;
let webglAddon: WebglAddon | null = null;
+ let webglRafId: number | null = null;
- // Open into a detached wrapper div — not the live container.
+ // Create a detached wrapper div. xterm.open() is deferred until the wrapper
+ // is attached to a live DOM container.
const wrapper = document.createElement("div");
wrapper.style.width = "100%";
wrapper.style.height = "100%";
- xterm.open(wrapper);
+ wrapper.style.minWidth = "0";
+ wrapper.style.minHeight = "0";
+ wrapper.style.overflow = "hidden";
xterm.loadAddon(fitAddon);
xterm.loadAddon(searchAddon);
@@ -140,39 +146,46 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): {
// Ligatures not supported by current font
}
- // Defer WebGL to rAF — same pattern as v2 terminal-addons.ts.
- const rafId = requestAnimationFrame(() => {
- if (disposed || suggestedRendererType === "dom") return;
+ const openOnce = () => {
+ if (disposed || opened) return;
+ opened = true;
+ xterm.open(wrapper);
- try {
- webglAddon = new WebglAddon();
- terminalRendererDebug.info(
- "webgl-addon-loaded",
- { suggestedRendererType: suggestedRendererType ?? "auto" },
- {
- captureMessage: true,
- fingerprint: ["terminal.renderer", "webgl-loaded"],
- },
- );
- webglAddon.onContextLoss(() => {
- webglAddon?.dispose();
+ // Defer WebGL until after open() so renderer initialization sees a live DOM node.
+ webglRafId = requestAnimationFrame(() => {
+ webglRafId = null;
+ if (disposed || suggestedRendererType === "dom") return;
+
+ try {
+ webglAddon = new WebglAddon();
+ terminalRendererDebug.info(
+ "webgl-addon-loaded",
+ { suggestedRendererType: suggestedRendererType ?? "auto" },
+ {
+ captureMessage: true,
+ fingerprint: ["terminal.renderer", "webgl-loaded"],
+ },
+ );
+ webglAddon.onContextLoss(() => {
+ webglAddon?.dispose();
+ webglAddon = null;
+ terminalRendererDebug.warn("webgl-context-lost", undefined, {
+ captureMessage: true,
+ fingerprint: ["terminal.renderer", "webgl-context-lost"],
+ });
+ xterm.refresh(0, xterm.rows - 1);
+ });
+ xterm.loadAddon(webglAddon);
+ } catch {
+ suggestedRendererType = "dom";
webglAddon = null;
- terminalRendererDebug.warn("webgl-context-lost", undefined, {
+ terminalRendererDebug.warn("webgl-addon-fallback-dom", undefined, {
captureMessage: true,
- fingerprint: ["terminal.renderer", "webgl-context-lost"],
+ fingerprint: ["terminal.renderer", "webgl-fallback-dom"],
});
- xterm.refresh(0, xterm.rows - 1);
- });
- xterm.loadAddon(webglAddon);
- } catch {
- suggestedRendererType = "dom";
- webglAddon = null;
- terminalRendererDebug.warn("webgl-addon-fallback-dom", undefined, {
- captureMessage: true,
- fingerprint: ["terminal.renderer", "webgl-fallback-dom"],
- });
- }
- });
+ }
+ });
+ };
const cleanupQuerySuppression = suppressQueryResponses(xterm);
@@ -233,9 +246,12 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): {
searchAddon,
wrapper,
linkManager,
+ openOnce,
cleanup: () => {
disposed = true;
- cancelAnimationFrame(rafId);
+ if (webglRafId !== null) {
+ cancelAnimationFrame(webglRafId);
+ }
cleanupQuerySuppression();
linkManager.dispose();
try {
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts
index 4dd7dce52ca..61e5b756755 100644
--- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts
+++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts
@@ -6,7 +6,6 @@ import { setPaneWorkspaceRunState } from "renderer/stores/tabs/workspace-run";
import { DEBUG_TERMINAL } from "../config";
import { logTerminalWrite, terminalRendererDebug } from "../debug";
import type { TerminalExitReason, TerminalStreamEvent } from "../types";
-import { flushWrite, scheduleWrite } from "../v1-terminal-cache";
export interface UseTerminalStreamOptions {
paneId: string;
@@ -190,21 +189,17 @@ export function useTerminalStream({
terminalRendererDebug.observe("stream-data-bytes", event.data.length, {
data: { paneId },
});
-
updateModesRef.current(event.data);
logTerminalWrite("stream-data", event.data.length, { paneId });
- scheduleWrite(paneId, event.data);
+ xterm.write(event.data);
updateCwdRef.current(event.data);
} else if (event.type === "exit") {
- flushWrite(paneId);
handleTerminalExit(event.exitCode, xterm, event.reason);
} else if (event.type === "disconnect") {
- flushWrite(paneId);
setConnectionError(
event.reason || "Connection to terminal daemon lost",
);
} else if (event.type === "error") {
- flushWrite(paneId);
handleStreamError(event, xterm);
}
},
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/suppressQueryResponses.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/suppressQueryResponses.ts
index 74cd9afc04d..673c6dc5625 100644
--- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/suppressQueryResponses.ts
+++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/suppressQueryResponses.ts
@@ -1,21 +1,18 @@
import type { Terminal } from "@xterm/xterm";
/**
- * Registers parser hooks to suppress terminal query responses from being displayed.
+ * Registers parser hooks to suppress terminal query responses and queries
+ * on the renderer's xterm instance.
*
- * These handlers intercept specific response-only sequences that should not appear
- * as visible text. We only suppress sequences where the response has a DIFFERENT
- * format than the query, ensuring we don't break terminal functionality.
+ * In the desktop terminal architecture, both the daemon-side headless emulator
+ * and the renderer's xterm process the same PTY output stream. If the renderer
+ * is allowed to answer terminal queries (DA/DSR/OSC color queries), those
+ * responses are forwarded back into the PTY and interactive CLIs can receive
+ * duplicate escape-sequence data.
*
- * SAFE to suppress (response-only, query uses different format):
- * - CSI R: CPR response (query is CSI 6n)
- * - CSI I/O: Focus reports (no query, just mode enable)
- * - CSI $y: Mode report (query is CSI $p)
- *
- * NOT suppressed (would break queries/commands):
- * - CSI c: DA query AND response both end in 'c'
- * - CSI t: Window query AND response both end in 't'
- * - OSC colors: Set command AND response have same format
+ * We suppress:
+ * 1. Terminal queries, so the renderer does not generate responses
+ * 2. Response-only sequences, so echoed responses do not render as garbage
*
* @param terminal - The xterm.js Terminal instance
* @returns Cleanup function to dispose all registered handlers
@@ -24,22 +21,50 @@ export function suppressQueryResponses(terminal: Terminal): () => void {
const disposables: { dispose: () => void }[] = [];
const parser = terminal.parser;
- // CSI sequences ending in 'R' - Cursor Position Report (SAFE)
- // Query: ESC[6n (ends in 'n'), Response: ESC[24;1R (ends in 'R')
- // Different final bytes, so suppressing 'R' only catches responses
+ // =========================================================================
+ // Suppress terminal QUERIES — prevents the renderer from generating a reply.
+ // The daemon-side headless emulator remains the single source of truth.
+ // =========================================================================
+
+ // DA1 (primary device attributes): CSI c / CSI 0 c
+ disposables.push(parser.registerCsiHandler({ final: "c" }, () => true));
+
+ // DA2 (secondary device attributes): CSI > c
+ disposables.push(
+ parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true),
+ );
+
+ // DA3 (tertiary device attributes): CSI = c
+ disposables.push(
+ parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true),
+ );
+
+ // DSR queries: CSI n / CSI ? n
+ disposables.push(parser.registerCsiHandler({ final: "n" }, () => true));
+ disposables.push(
+ parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true),
+ );
+
+ // OSC color queries — only suppress actual queries, not set operations.
+ disposables.push(parser.registerOscHandler(4, (data) => data.includes("?")));
+ disposables.push(parser.registerOscHandler(10, (data) => data === "?"));
+ disposables.push(parser.registerOscHandler(11, (data) => data === "?"));
+ disposables.push(parser.registerOscHandler(12, (data) => data === "?"));
+
+ // =========================================================================
+ // Suppress RESPONSE-ONLY sequences — prevents echoed responses from rendering.
+ // =========================================================================
+
+ // CSI R: Cursor Position Report response (query is CSI 6n)
disposables.push(parser.registerCsiHandler({ final: "R" }, () => true));
- // CSI sequences ending in 'I' - Focus In report (SAFE)
- // No query - this is sent when terminal gains focus (mode 1004)
+ // CSI I: Focus In report
disposables.push(parser.registerCsiHandler({ final: "I" }, () => true));
- // CSI sequences ending in 'O' - Focus Out report (SAFE)
- // No query - this is sent when terminal loses focus (mode 1004)
+ // CSI O: Focus Out report
disposables.push(parser.registerCsiHandler({ final: "O" }, () => true));
- // CSI sequences ending in 'y' with '$' intermediate - Mode Reports (SAFE)
- // Query: ESC[?Ps$p (ends in 'p'), Response: ESC[?Ps;Pm$y (ends in 'y')
- // Different final bytes, so suppressing '$y' only catches responses
+ // CSI $y: Mode report response (query is CSI $p)
disposables.push(
parser.registerCsiHandler({ intermediates: "$", final: "y" }, () => true),
);
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts
index 53d43c8b95c..93210cf4fe0 100644
--- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts
+++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts
@@ -23,6 +23,7 @@ export interface CachedTerminal {
fitAddon: FitAddon;
searchAddon: SearchAddon;
wrapper: HTMLDivElement;
+ openOnce: () => void;
/** Disposes renderer RAF, query suppression, GPU renderer, etc. */
cleanupCreation: () => void;
/** Last known dimensions — used to skip no-op resize events. */
@@ -54,9 +55,8 @@ export interface CachedTerminal {
subscriptionErrorHandler: ((error: unknown) => void) | null;
/** ResizeObserver for the attached container. Managed by attach/detach. */
resizeObserver: ResizeObserver | null;
- /** rAF-batched write buffer: data accumulates here until the next frame. */
- rafWriteBuffer: string;
- rafWriteId: ReturnType
| null;
+ /** Debounce timer — fitAddon.fit() と onResize 通知を一括で発火させる */
+ resizeDebounceTimer: ReturnType | null;
}
const cache = new Map();
@@ -80,7 +80,7 @@ export function getOrCreate(
console.log(`[v1-terminal-cache] Creating new terminal: ${paneId}`);
}
- const { xterm, fitAddon, searchAddon, wrapper, cleanup } =
+ const { xterm, fitAddon, searchAddon, wrapper, openOnce, cleanup } =
createTerminalInWrapper(options);
const entry: CachedTerminal = {
@@ -88,6 +88,7 @@ export function getOrCreate(
fitAddon,
searchAddon,
wrapper,
+ openOnce,
cleanupCreation: cleanup,
subscription: null,
streamReady: false,
@@ -96,10 +97,9 @@ export function getOrCreate(
eventHandler: null,
subscriptionErrorHandler: null,
resizeObserver: null,
+ resizeDebounceTimer: null,
lastCols: xterm.cols,
lastRows: xterm.rows,
- rafWriteBuffer: "",
- rafWriteId: null,
};
cache.set(paneId, entry);
@@ -117,6 +117,7 @@ export function attachToContainer(
if (!entry) return;
container.appendChild(entry.wrapper);
+ entry.openOnce();
terminalRendererDebug.info(
"cache-attach-to-container",
{
@@ -141,13 +142,19 @@ export function attachToContainer(
// Manage ResizeObserver lifecycle in the cache, not in React.
entry.resizeObserver?.disconnect();
+ if (entry.resizeDebounceTimer) {
+ clearTimeout(entry.resizeDebounceTimer);
+ entry.resizeDebounceTimer = null;
+ }
const observer = new ResizeObserver(() => {
if (container.clientWidth === 0 || container.clientHeight === 0) return;
+
const prevCols = entry.lastCols;
const prevRows = entry.lastRows;
entry.fitAddon.fit();
entry.lastCols = entry.xterm.cols;
entry.lastRows = entry.xterm.rows;
+
if (entry.lastCols !== prevCols || entry.lastRows !== prevRows) {
onResize?.();
}
@@ -220,55 +227,20 @@ export function updateAppearance(
// --- rAF write buffer ---
/**
- * Batch xterm.write calls into one per animation frame to reduce the number
- * of parser/render cycles. Callers accumulate data here; the actual write
- * fires in the next rAF, coalescing all chunks that arrived within ~16 ms.
+ * Thin wrapper around xterm.write so callers share cache lookup and logging.
+ * Keep the PTY -> xterm path as close to VS Code/xterm's recommended
+ * integration as possible.
*/
export function scheduleWrite(paneId: string, data: string): void {
const entry = cache.get(paneId);
if (!entry) return;
- entry.rafWriteBuffer += data;
- // Flush immediately if buffer exceeds 1MB — guards against unbounded growth
- // when Electron's backgroundThrottling stops rAF (minimized/backgrounded window).
- if (entry.rafWriteBuffer.length > 1_048_576) {
- if (entry.rafWriteId !== null) {
- cancelAnimationFrame(entry.rafWriteId);
- entry.rafWriteId = null;
- }
- entry.xterm.write(entry.rafWriteBuffer);
- entry.rafWriteBuffer = "";
- return;
- }
- if (entry.rafWriteId === null) {
- entry.rafWriteId = requestAnimationFrame(() => {
- const e = cache.get(paneId);
- if (!e) return;
- if (e.rafWriteBuffer) {
- e.xterm.write(e.rafWriteBuffer);
- e.rafWriteBuffer = "";
- }
- e.rafWriteId = null;
- });
- }
+ entry.xterm.write(data);
}
/**
- * Immediately flush any buffered data to xterm, cancelling the pending rAF.
- * Must be called before processing exit/error/disconnect events so that
- * trailing output is rendered before the exit banner or pane disposal.
+ * Backward-compatible no-op now that writes go directly to xterm.
*/
-export function flushWrite(paneId: string): void {
- const entry = cache.get(paneId);
- if (!entry) return;
- if (entry.rafWriteId !== null) {
- cancelAnimationFrame(entry.rafWriteId);
- entry.rafWriteId = null;
- }
- if (entry.rafWriteBuffer) {
- entry.xterm.write(entry.rafWriteBuffer);
- entry.rafWriteBuffer = "";
- }
-}
+export function flushWrite(_paneId: string): void {}
// --- Stream subscription ---
@@ -290,9 +262,6 @@ function routeEvent(
}
// Component unmounted — write data directly to xterm, queue the rest.
- // ここは hidden terminal 継続処理の観測点で、主問題ではなく副次仮説。
- // 「表示中なのに描画されない」問題とは別軸で、
- // hidden 中も xterm.write が走り続けていないかを見る。
if (event.type === "data") {
terminalRendererDebug.increment("hidden-data-events", 1, {
data: { paneId, bytes: event.data.length },
@@ -461,9 +430,6 @@ export function dispose(paneId: string): void {
entry.resizeObserver?.disconnect();
entry.subscription?.unsubscribe();
- if (entry.rafWriteId !== null) {
- cancelAnimationFrame(entry.rafWriteId);
- }
entry.cleanupCreation();
entry.xterm.dispose();
cache.delete(paneId);
@@ -477,8 +443,6 @@ if (hot) {
| undefined;
if (existing) {
for (const [k, v] of existing) {
- v.rafWriteBuffer ??= "";
- v.rafWriteId ??= null;
cache.set(k, v);
}
}
diff --git a/knowledge.md b/knowledge.md
new file mode 100644
index 00000000000..086a2ae5132
--- /dev/null
+++ b/knowledge.md
@@ -0,0 +1,164 @@
+# Desktop terminal instability with Codex CLI
+
+最終更新: 2026-04-22
+
+## 背景
+
+`apps/desktop` のターミナルで Codex CLI を使っていると、回答生成中や回答直後の描画が不安定になることがあった。特に「回答本文だけ少し揺れる」「応答後の表示が崩れやすい」「分割リサイズ後に不安定さが増す」といった症状が出ていた。
+
+今回の対応では、単なる描画ノイズではなく、以下の 2 系統が重なっていた可能性が高い。
+
+1. ターミナルの attach / resize 周りの不安定さ
+2. renderer 側 xterm が端末問い合わせに反応し、PTY に重複レスポンスを返していた問題
+
+## 典型的な症状
+
+- Codex の回答中だけ表示が揺れる
+- 回答が返ってきた直後の本文部分だけ不安定になる
+- pane のサイズ変更後に表示崩れが起きやすくなる
+- ログ上で `route-event-to-handler` と `resize-observer` が大量に出る
+- `renderer-to-pty` に terminal query response が流れている
+
+## ログを見るときの判断基準
+
+### 危険信号
+
+以下は今回の不安定化と強く相関していた。
+
+- `renderer-to-pty` で `CSI ... R` が出る
+ - 例: `hex: '1b 5b 32 39 3b 33 52'`
+ - これは CPR (`ESC [ row ; col R`) で、renderer 側 xterm が端末問い合わせへ返答してしまっているサイン
+- `renderer-to-pty` に DA / DSR / OSC query 由来の応答が混ざる
+- 回答レンダリングのタイミングで上記応答が連続する
+
+### すぐに異常扱いしなくてよいもの
+
+- `renderer-to-pty` の `hex: '1b 5b 49'`
+ - Focus In (`ESC [ I`)
+- `renderer-to-pty` の `hex: '1b 5b 4f'`
+ - Focus Out (`ESC [ O`)
+- `route-event-to-handler` の小さな chunk
+ - 例: `dataBytes: 4`, `18`, `141`, `355`
+ - TUI の差分描画だけでも普通に出る
+- `resize-observer` / `resize:mutate` / `resize:trpc`
+ - pane サイズ変更と対応していれば自然
+- macOS / Electron の以下の warning
+ - `representedObject is not a WeakPtrToElectronMenuModelAsNSObject`
+ - 今回の範囲では terminal 描画不安定化の主因には見えなかった
+
+## 今回効いた対策
+
+### 1. hidden tab stack の見直しは有効だったが、現時点では採用していない
+
+hidden の `TabView` を積んだまま persistent に保持すると、見えていない terminal が裏でぶら下がり続け、描画破損や状態競合の温床になりやすい。
+
+試したこと:
+
+- `PersistentTabRenderer` の利用をやめる
+- `TabsContent` では active tab の `TabView` のみ描画する
+
+観測:
+
+- offscreen terminal の干渉を減らせる
+- upstream の修正方針とも一致する
+
+ただし、この変更は別の UX / 状態保持回帰を招いたため、最終的には revert した。現行コードでは `PersistentTabRenderer` を使っている。
+
+### 2. resize 処理を複雑化しすぎない
+
+rows 即時反映、cols debounce、`proposeDimensions()`、手動 `xterm.resize()` のような独自パスは、一見丁寧でも不安定化要因になりやすい。
+
+対応:
+
+- `ResizeObserver` でコンテナサイズを監視
+- `fitAddon.fit()` を素直に呼ぶ
+- 反映後の `cols/rows` が本当に変わった時だけ `onResize` を呼ぶ
+
+効果:
+
+- splitter 操作時の揺れが減る
+- resize 経路が単純になり、ログの解釈もしやすくなる
+
+### 3. terminal query response を renderer から PTY に返さない
+
+Codex CLI 系の TUI では terminal query が多く、renderer 側 xterm がそれに自動応答し、その応答が PTY に戻ると相互作用が壊れやすい。
+
+今回特に重要だったのは `CSI ... R` の抑止。
+
+対応:
+
+- 既存の response suppression を拡張
+- response だけでなく query 自体も抑止対象に追加
+- 少なくとも以下を抑止対象に含める
+ - `CSI c`
+ - `CSI > c`
+ - `CSI = c`
+ - `CSI n`
+ - `CSI ? n`
+ - `OSC 4/10/11/12` の query 形式
+ - `CSI R`
+ - `CSI I`
+ - `CSI O`
+ - `CSI $y`
+
+効果:
+
+- `renderer-to-pty` の CPR 応答が消えた
+- 回答本文付近の不安定さが大きく改善した
+
+### 4. `xterm.open()` は live DOM に attach してから 1 回だけ呼ぶ
+
+detached な wrapper に対して先に `xterm.open()` すると、初期描画や texture atlas 初期化が不安定になることがある。
+
+対応:
+
+- terminal 作成時は wrapper だけ用意する
+- `appendChild()` で live DOM に attach した直後に `openOnce()` を呼ぶ
+- `open()` の多重実行はガードする
+
+効果:
+
+- 初回描画と応答直後の表示が安定しやすくなる
+
+## 今回変更した箇所
+
+- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts`
+ - resize 経路の単純化
+ - attach 後 `openOnce()` 呼び出し
+- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/suppressQueryResponses.ts`
+ - query / response の抑止対象を拡張
+- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts`
+ - detached open をやめ、attach 後 open に変更
+
+試して戻したもの:
+
+- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx`
+ - active tab のみ描画
+ - offscreen terminal 干渉の切り分けには有効だったが、現時点では未採用
+
+## upstream 追跡上のメモ
+
+今回の方向性は upstream の過去対応と整合している。
+
+- hidden な mosaic / tab stack を避ける方針
+- v1 terminal でも hide-attach 系の安定化を取り込む流れ
+- duplicate terminal query response が interactive CLI を壊すという既知問題
+
+つまり、fork 独自の複雑化より upstream に近づける方が安定しやすい。
+
+## 再発時の切り分け手順
+
+1. まず `renderer-to-pty` を確認する
+2. `CSI ... R` が出ていないかを見る
+3. 出ているなら query suppression の退行を疑う
+4. 出ていないなら `resize-observer` と pane リサイズ操作の相関を見る
+5. hidden terminal を再導入していないか確認する
+6. `xterm.open()` が detached DOM で呼ばれていないか確認する
+7. Electron の menu warning は一旦 terminal 問題と切り離して考える
+
+## 運用メモ
+
+- この問題は「完全にゼロになった」より「再発しにくい構成に寄せた」と考えるべき
+- 症状が再発した場合、最初に疑うべきは renderer-to-pty の query response 退行
+- その次に疑うべきは hidden terminal の復活と独自 resize ロジックの再肥大化
+- terminal 周りは、安定性を優先するなら upstream に近いほど安全