diff --git a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.test.ts b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.test.ts index b6164ea1069..88cd78a3951 100644 --- a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.test.ts @@ -839,6 +839,9 @@ export SUPERSET_WORKSPACE_PATH="/wrong/path" expect(args[0]).toBe("-l"); expect(args[1]).toBe("--init-command"); expect(args[2]).toContain(`set -l _superset_bin "${TEST_BIN_DIR}"`); + // Both markers are emitted so old v1 daemons (777 scanner) and new + // scanners (133;A) both detect readiness without a daemon restart. + expect(args[2]).toContain("\\033]777;superset-shell-ready\\007"); expect(args[2]).toContain("\\033]133;A\\007"); }); @@ -854,7 +857,21 @@ export SUPERSET_WORKSPACE_PATH="/wrong/path" expect(args[2]).toContain( 'set -l _superset_bin "/tmp/with space/quote\\"buck\\$slash\\\\bin"', ); + expect(args[2]).toContain("777;superset-shell-ready"); expect(args[2]).toContain("133;A"); }); + + it("zsh/bash wrappers emit both legacy 777 and current 133;A markers", () => { + createZshWrapper(TEST_PATHS); + createBashWrapper(TEST_PATHS); + + const zlogin = readFileSync(path.join(TEST_ZSH_DIR, ".zlogin"), "utf-8"); + const rcfile = readFileSync(path.join(TEST_BASH_DIR, "rcfile"), "utf-8"); + + for (const wrapper of [zlogin, rcfile]) { + expect(wrapper).toContain("\\033]777;superset-shell-ready\\007"); + expect(wrapper).toContain("\\033]133;A\\007"); + } + }); }); }); diff --git a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts index 4489f574861..3c8ae59f329 100644 --- a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts @@ -218,10 +218,15 @@ ${SUPERSET_ENV_RESTORE} ${buildZshPrecmdHook(paths.BIN_DIR)} ${buildPathPrependFunction(paths.BIN_DIR)} rehash 2>/dev/null || true -# OSC 133;A prompt marker (FinalTerm standard) — signals shell readiness. +# Shell readiness markers. Emitting both keeps us compatible across daemon +# versions: the legacy v1 daemon scans for OSC 777, the current scanner (v1 +# post-refactor + v2 host-service) scans for OSC 133;A (FinalTerm standard). +# Wrappers are rewritten on every app launch, so main always ships the +# superset of markers; daemons that only get restarted on protocol bumps +# still match against their own scanner. # Protocol ref: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md __superset_prompt_mark() { - printf "\\033]133;A\\007" + printf "\\033]777;superset-shell-ready\\007\\033]133;A\\007" } # Keep our hook LAST so it fires after direnv and other precmd hooks complete. precmd_functions=(\${precmd_functions[@]} __superset_prompt_mark) @@ -268,10 +273,10 @@ ${buildPathPrependFunction(paths.BIN_DIR)} hash -r 2>/dev/null || true # Minimal prompt (path/env shown in toolbar) - emerald to match app theme export PS1=$'\\[\\e[1;38;2;52;211;153m\\]❯\\[\\e[0m\\] ' -# OSC 133;A prompt marker (FinalTerm standard) — signals shell readiness. +# Shell readiness markers — see zsh wrapper for rationale on emitting both. # Protocol ref: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md __superset_prompt_mark() { - printf "\\033]133;A\\007" + printf "\\033]777;superset-shell-ready\\007\\033]133;A\\007" } # Hook via PROMPT_COMMAND. Supports both scalar and array forms (Bash 5.1+). if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then @@ -315,7 +320,8 @@ export function getShellArgs( if (shellName === "fish") { // Use --init-command to prepend BIN_DIR to PATH after config is loaded. // Use fish list-aware checks to avoid duplicate PATH entries across nested shells. - // OSC 133;A emitted on fish_prompt — signals shell readiness. + // Emit both OSC 777 (legacy v1 daemon) and OSC 133;A (current scanner) + // on fish_prompt. See zsh wrapper for rationale. const escapedBinDir = escapeFishDoubleQuoted(paths.BIN_DIR); return [ "-l", @@ -325,7 +331,7 @@ export function getShellArgs( `contains -- "$_superset_bin" $PATH`, `or set -gx PATH "$_superset_bin" $PATH`, `function _superset_prompt_mark --on-event fish_prompt`, - `printf '\\033]133;A\\007'`, + `printf '\\033]777;superset-shell-ready\\007\\033]133;A\\007'`, `end`, ].join("; "), ]; diff --git a/apps/desktop/src/main/terminal-host/session-shell-ready.test.ts b/apps/desktop/src/main/terminal-host/session-shell-ready.test.ts index c7b33195f6d..7b9ebb169de 100644 --- a/apps/desktop/src/main/terminal-host/session-shell-ready.test.ts +++ b/apps/desktop/src/main/terminal-host/session-shell-ready.test.ts @@ -9,8 +9,6 @@ import { /** OSC 133;A marker emitted by shell wrappers (FinalTerm standard). */ const SHELL_READY_MARKER = "\x1b]133;A\x07"; -/** Legacy OSC 777 marker emitted by pre-#3348 wrappers / stale daemons. */ -const LEGACY_READY_MARKER = "\x1b]777;superset-shell-ready\x07"; import "./xterm-env-polyfill"; const { Session } = await import("./session"); @@ -270,46 +268,26 @@ describe("Session shell-ready: marker detection", () => { sendData(proc, SHELL_READY_MARKER); expect(getWrittenData(proc)).toEqual(["buffered\n"]); }); -}); -describe("Session shell-ready: legacy OSC 777 marker", () => { - it("unblocks writes when only the legacy marker arrives", () => { + // Wrappers now emit both the legacy OSC 777 and the current OSC 133;A in + // a single printf so either daemon version can detect readiness without a + // restart. The scanner only matches 133;A — 777 passes through to the + // emulator, which drops unknown OSC sequences silently. This test guards + // against a future wrapper regression that swaps the order (which would + // leave 133;A in the pre-777 slice and still work) or drops 133;A + // entirely (which would regress readiness on the current scanner). + it("resolves readiness when wrapper emits both 777 and 133;A markers together", () => { const { session, proc } = createTestSession("/bin/zsh"); spawnAndReady(session, proc); session.write("buffered\n"); expect(getWrittenData(proc)).toEqual([]); - // Stale daemon / old wrapper emits the pre-#3348 OSC 777 marker. - sendData(proc, `direnv output...${LEGACY_READY_MARKER}prompt$ `); + const COMBINED_MARKER = "\x1b]777;superset-shell-ready\x07\x1b]133;A\x07"; + sendData(proc, `direnv output...${COMBINED_MARKER}prompt$ `); expect(getWrittenData(proc)).toEqual(["buffered\n"]); }); - - it("detects legacy marker split across two PTY data frames", () => { - const { session, proc } = createTestSession("/bin/bash"); - spawnAndReady(session, proc); - - const half = Math.floor(LEGACY_READY_MARKER.length / 2); - sendData(proc, `output${LEGACY_READY_MARKER.slice(0, half)}`); - - session.write("buffered\n"); - expect(getWrittenData(proc)).toEqual([]); - - sendData(proc, `${LEGACY_READY_MARKER.slice(half)}prompt`); - expect(getWrittenData(proc)).toEqual(["buffered\n"]); - }); - - it("strips legacy marker from emitted output", () => { - const { session, proc } = createTestSession("/bin/zsh"); - spawnAndReady(session, proc); - - sendData(proc, `before${LEGACY_READY_MARKER}after`); - - // Shell is ready — write passes through. - session.write("ok\n"); - expect(getWrittenData(proc)).toEqual(["ok\n"]); - }); }); describe("Session shell-ready: kill/exit before readiness", () => { diff --git a/apps/desktop/src/main/terminal-host/session.ts b/apps/desktop/src/main/terminal-host/session.ts index 1aa917c104f..24287b59393 100644 --- a/apps/desktop/src/main/terminal-host/session.ts +++ b/apps/desktop/src/main/terminal-host/session.ts @@ -42,55 +42,6 @@ import { PtySubprocessIpcType, } from "./pty-subprocess-ipc"; -// ============================================================================= -// Legacy OSC 777 shell-ready scanner (pre-#3348 wrappers) -// ============================================================================= - -/** - * Marker emitted by the previous generation of shell wrappers - * (see commit 2d1885a3f). Fixed literal — no optional params. - */ -const LEGACY_OSC_777 = "\x1b]777;superset-shell-ready\x07"; - -interface LegacyShellReadyScanState { - matchPos: number; - heldBytes: string; -} - -function createLegacyScanState(): LegacyShellReadyScanState { - return { matchPos: 0, heldBytes: "" }; -} - -function scanForLegacyShellReady( - state: LegacyShellReadyScanState, - data: string, -): { output: string; matched: boolean } { - let output = ""; - for (let i = 0; i < data.length; i++) { - const ch = data[i] as string; - if (ch === LEGACY_OSC_777[state.matchPos]) { - state.heldBytes += ch; - state.matchPos++; - if (state.matchPos === LEGACY_OSC_777.length) { - state.heldBytes = ""; - state.matchPos = 0; - return { output: output + data.slice(i + 1), matched: true }; - } - } else { - output += state.heldBytes; - state.heldBytes = ""; - state.matchPos = 0; - if (ch === LEGACY_OSC_777[0]) { - state.heldBytes = ch; - state.matchPos = 1; - } else { - output += ch; - } - } - } - return { output, matched: false }; -} - // ============================================================================= // Constants // ============================================================================= @@ -125,12 +76,10 @@ const EMULATOR_WRITE_QUEUE_LOW_WATERMARK_BYTES = 250_000; /** * How long to wait for the shell-ready marker before unblocking writes. - * On timeout, buffered writes flush immediately (same behavior as before - * this feature). Kept short so a broken/missing marker doesn't leave the - * terminal feeling frozen — slower shell startups (direnv, nix) can still - * race us, but the user can always re-type. + * 15s covers heavy setups like Nix-based devenv via direnv. On timeout, + * buffered writes flush immediately (same behavior as before this feature). */ -const SHELL_READY_TIMEOUT_MS = 5_000; +const SHELL_READY_TIMEOUT_MS = 15_000; /** * Shell readiness lifecycle: @@ -217,11 +166,6 @@ export class Session { private preReadyStdinQueue: string[] = []; // OSC 133;A scanner state — shared with v2 host-service via @superset/shared private scanState: ShellReadyScanState = createScanState(); - // Legacy OSC 777 scanner state. Matches the pre-#3348 marker emitted by - // older shell wrappers still present on users' disks, or by terminals - // spawned through a stale daemon that bundles the old scanner code. - // Without this fallback, input stays buffered until the 15s timeout. - private legacyScanState: LegacyShellReadyScanState = createLegacyScanState(); private emulatorWriteQueue: string[] = []; private emulatorWriteQueuedBytes = 0; @@ -418,17 +362,10 @@ export class Session { let data = payload.toString("utf8"); // Scan for OSC 133;A (shell ready) and strip from output. - // Also scan for the legacy OSC 777 marker emitted by older - // shell wrappers / stale daemons — see legacyScanState. if (this.shellReadyState === "pending") { const result = scanForShellReady(this.scanState, data); data = result.output; - const legacyResult = scanForLegacyShellReady( - this.legacyScanState, - data, - ); - data = legacyResult.output; - if (result.matched || legacyResult.matched) { + if (result.matched) { this.resolveShellReady("ready"); } } @@ -1057,7 +994,6 @@ export class Session { } this.preReadyStdinQueue = []; this.scanState = createScanState(); - this.legacyScanState = createLegacyScanState(); this.subprocessStdinQueue = []; this.subprocessStdinQueuedBytes = 0; this.subprocessStdinDrainArmed = false; @@ -1101,18 +1037,15 @@ export class Session { this.shellReadyTimeoutId = null; } // Flush held marker bytes — they weren't part of a full marker - const heldBytes = this.scanState.heldBytes + this.legacyScanState.heldBytes; - if (heldBytes.length > 0) { - this.enqueueEmulatorWrite(heldBytes); + if (this.scanState.heldBytes.length > 0) { + this.enqueueEmulatorWrite(this.scanState.heldBytes); this.broadcastEvent("data", { type: "data", - data: heldBytes, + data: this.scanState.heldBytes, } satisfies TerminalDataEvent); this.scanState.heldBytes = ""; - this.legacyScanState.heldBytes = ""; } this.scanState.matchPos = 0; - this.legacyScanState.matchPos = 0; // Flush queued writes in FIFO order const queue = this.preReadyStdinQueue; this.preReadyStdinQueue = []; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts index 5052fb1c120..605d324cb3f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts @@ -234,7 +234,14 @@ export function useTerminalLifecycle({ // Use the v1 terminal cache: reuse existing xterm instance across tab // switches instead of creating/disposing each time (v2 "hide attach" pattern). - const isReattach = v1TerminalCache.has(paneId); + // Only treat as reattach when the prior mount actually completed attach — + // a cache entry can exist with streamReady=false if the previous mount + // unmounted before createOrAttach finished (e.g. bulk tab creation where + // React remounts a pane mid-attach). Taking the reattach fast path in + // that state leaves the pane permanently disconnected with no daemon + // session and no stream subscription. + const cachedBeforeCreate = v1TerminalCache.get(paneId); + const isReattach = cachedBeforeCreate?.streamReady === true; if (DEBUG_TERMINAL) { console.log(`[Terminal] isReattach=${isReattach} paneId=${paneId}`); }