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 に近いほど安全