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 3baa8ab0d64..b6164ea1069 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 @@ -836,11 +836,10 @@ export SUPERSET_WORKSPACE_PATH="/wrong/path" it("uses --init-command to prepend BIN_DIR to PATH for fish", () => { const args = getShellArgs("/opt/homebrew/bin/fish", TEST_PATHS); - expect(args).toEqual([ - "-l", - "--init-command", - `set -l _superset_bin "${TEST_BIN_DIR}"; contains -- "$_superset_bin" $PATH; or set -gx PATH "$_superset_bin" $PATH; function _superset_shell_ready --on-event fish_prompt; printf '\\033]777;superset-shell-ready\\007'; functions -e _superset_shell_ready; end`, - ]); + expect(args[0]).toBe("-l"); + expect(args[1]).toBe("--init-command"); + expect(args[2]).toContain(`set -l _superset_bin "${TEST_BIN_DIR}"`); + expect(args[2]).toContain("\\033]133;A\\007"); }); it("escapes fish init-command BIN_DIR safely", () => { @@ -850,11 +849,12 @@ export SUPERSET_WORKSPACE_PATH="/wrong/path" BIN_DIR: fishPath, }); - expect(args).toEqual([ - "-l", - "--init-command", - `set -l _superset_bin "/tmp/with space/quote\\"buck\\$slash\\\\bin"; contains -- "$_superset_bin" $PATH; or set -gx PATH "$_superset_bin" $PATH; function _superset_shell_ready --on-event fish_prompt; printf '\\033]777;superset-shell-ready\\007'; functions -e _superset_shell_ready; end`, - ]); + expect(args[0]).toBe("-l"); + expect(args[1]).toBe("--init-command"); + expect(args[2]).toContain( + 'set -l _superset_bin "/tmp/with space/quote\\"buck\\$slash\\\\bin"', + ); + expect(args[2]).toContain("133;A"); }); }); }); 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 a38d404c3eb..4489f574861 100644 --- a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts @@ -218,15 +218,13 @@ ${SUPERSET_ENV_RESTORE} ${buildZshPrecmdHook(paths.BIN_DIR)} ${buildPathPrependFunction(paths.BIN_DIR)} rehash 2>/dev/null || true -# One-shot shell-ready marker for preset command timing. -# Uses precmd so it fires AFTER direnv and other hooks complete, -# right before the first prompt is displayed. -_superset_shell_ready() { - precmd_functions=(\${precmd_functions:#_superset_shell_ready}) - printf '\\033]777;superset-shell-ready\\007' +# OSC 133;A prompt marker (FinalTerm standard) — signals shell readiness. +# Protocol ref: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md +__superset_prompt_mark() { + printf "\\033]133;A\\007" } # Keep our hook LAST so it fires after direnv and other precmd hooks complete. -precmd_functions=(\${precmd_functions[@]} _superset_shell_ready) +precmd_functions=(\${precmd_functions[@]} __superset_prompt_mark) export ZDOTDIR="$_superset_home" `; const wroteZlogin = writeFileIfChanged(zloginPath, zloginScript, 0o644); @@ -270,31 +268,20 @@ ${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\\] ' -# One-shot shell-ready marker for preset command timing. -# Uses PROMPT_COMMAND so it fires AFTER direnv and other hooks complete. -# Supports both scalar and array PROMPT_COMMAND (Bash 5.1+). -_superset_shell_ready() { - printf '\\033]777;superset-shell-ready\\007' - if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then - local -a _new=() - for _cmd in "\${PROMPT_COMMAND[@]}"; do - [[ "$_cmd" != "_superset_shell_ready" ]] && _new+=("$_cmd") - done - PROMPT_COMMAND=("\${_new[@]}") - else - PROMPT_COMMAND="\${_superset_orig_prompt_cmd}" - unset _superset_orig_prompt_cmd - fi - unset -f _superset_shell_ready +# OSC 133;A prompt marker (FinalTerm standard) — signals shell readiness. +# Protocol ref: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md +__superset_prompt_mark() { + printf "\\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 - PROMPT_COMMAND=("\${PROMPT_COMMAND[@]}" "_superset_shell_ready") + PROMPT_COMMAND=("\${PROMPT_COMMAND[@]}" "__superset_prompt_mark") else _superset_orig_prompt_cmd="\${PROMPT_COMMAND}" if [[ -n "\${_superset_orig_prompt_cmd}" ]]; then - PROMPT_COMMAND="\${_superset_orig_prompt_cmd};_superset_shell_ready" + PROMPT_COMMAND="\${_superset_orig_prompt_cmd};__superset_prompt_mark" else - PROMPT_COMMAND="_superset_shell_ready" + PROMPT_COMMAND="__superset_prompt_mark" fi fi `; @@ -328,11 +315,19 @@ 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. const escapedBinDir = escapeFishDoubleQuoted(paths.BIN_DIR); return [ "-l", "--init-command", - `set -l _superset_bin "${escapedBinDir}"; contains -- "$_superset_bin" $PATH; or set -gx PATH "$_superset_bin" $PATH; function _superset_shell_ready --on-event fish_prompt; printf '\\033]777;superset-shell-ready\\007'; functions -e _superset_shell_ready; end`, + [ + `set -l _superset_bin "${escapedBinDir}"`, + `contains -- "$_superset_bin" $PATH`, + `or set -gx PATH "$_superset_bin" $PATH`, + `function _superset_prompt_mark --on-event fish_prompt`, + `printf '\\033]133;A\\007'`, + `end`, + ].join("; "), ]; } if (["zsh", "sh", "ksh"].includes(shellName)) { diff --git a/apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts b/apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts index d4ce9cf0efe..d4d0680ee7a 100644 --- a/apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts +++ b/apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts @@ -127,15 +127,3 @@ export class PtySubprocessFrameDecoder { return frames; } } - -/** - * OSC 777 escape sequence emitted once by shell prompt hooks (precmd in - * zsh, PROMPT_COMMAND in bash, fish_prompt in fish) right before the - * first interactive prompt is displayed. {@link Session} scans PTY - * output for this marker to know when the shell is ready for stdin, - * then strips it so it never reaches the terminal renderer. - * - * Uses the private-use OSC 777 code to avoid conflicts with VS Code - * (OSC 133), iTerm2 (OSC 1337), or Warp (OSC 9001). - */ -export const SHELL_READY_MARKER = "\x1b]777;superset-shell-ready\x07"; 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 791ef23e10e..de7305af9d0 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 @@ -5,8 +5,10 @@ import { createFrameHeader, PtySubprocessFrameDecoder, PtySubprocessIpcType, - SHELL_READY_MARKER, } from "./pty-subprocess-ipc"; + +/** OSC 133;A marker emitted by shell wrappers (FinalTerm standard). */ +const SHELL_READY_MARKER = "\x1b]133;A\x07"; import "./xterm-env-polyfill"; const { Session } = await import("./session"); diff --git a/apps/desktop/src/main/terminal-host/session.ts b/apps/desktop/src/main/terminal-host/session.ts index 6e810b9ad27..24287b59393 100644 --- a/apps/desktop/src/main/terminal-host/session.ts +++ b/apps/desktop/src/main/terminal-host/session.ts @@ -11,6 +11,12 @@ import { type ChildProcess, spawn } from "node:child_process"; import type { Socket } from "node:net"; import * as path from "node:path"; +import { + createScanState, + SHELLS_WITH_READY_MARKER, + type ShellReadyScanState, + scanForShellReady, +} from "@superset/shared/shell-ready-scanner"; import { DEFAULT_TERMINAL_SCROLLBACK } from "shared/constants"; import { getCommandShellArgs, @@ -34,7 +40,6 @@ import { createFrameHeader, PtySubprocessFrameDecoder, PtySubprocessIpcType, - SHELL_READY_MARKER, } from "./pty-subprocess-ipc"; // ============================================================================= @@ -76,9 +81,6 @@ const EMULATOR_WRITE_QUEUE_LOW_WATERMARK_BYTES = 250_000; */ const SHELL_READY_TIMEOUT_MS = 15_000; -/** Shells whose wrapper files inject a {@link SHELL_READY_MARKER}. */ -const SHELLS_WITH_READY_MARKER = new Set(["zsh", "bash", "fish"]); - /** * Shell readiness lifecycle: * - `pending` — shell is initializing; user writes are buffered, escape sequences dropped @@ -162,13 +164,8 @@ export class Session { private shellReadyState: ShellReadyState; private shellReadyTimeoutId: ReturnType | null = null; private preReadyStdinQueue: string[] = []; - // Marker scanner — tracks how many characters of SHELL_READY_MARKER - // we've matched so far. Held bytes are withheld from terminal output - // until we confirm a full match (discard them) or a mismatch (flush - // them as regular output). This prevents partial OSC sequences from - // ever reaching the renderer, even when the marker spans two Data frames. - private markerMatchPos = 0; - private markerHeldBytes = ""; + // OSC 133;A scanner state — shared with v2 host-service via @superset/shared + private scanState: ShellReadyScanState = createScanState(); private emulatorWriteQueue: string[] = []; private emulatorWriteQueuedBytes = 0; @@ -364,32 +361,13 @@ export class Session { if (payload.length === 0) break; let data = payload.toString("utf8"); - // Scan for SHELL_READY_MARKER one character at a time. - // Matching bytes are held back from output; on full match - // they're discarded and readiness resolves. On mismatch - // they're flushed as regular terminal output. + // Scan for OSC 133;A (shell ready) and strip from output. if (this.shellReadyState === "pending") { - let output = ""; - for (let i = 0; i < data.length; i++) { - if (data[i] === SHELL_READY_MARKER[this.markerMatchPos]) { - this.markerHeldBytes += data[i]; - this.markerMatchPos++; - if (this.markerMatchPos === SHELL_READY_MARKER.length) { - // Full match — discard held bytes, resolve - this.markerHeldBytes = ""; - this.markerMatchPos = 0; - this.resolveShellReady("ready"); - output += data.slice(i + 1); - break; - } - } else { - // Mismatch — flush held bytes as regular output - output += this.markerHeldBytes + data[i]; - this.markerHeldBytes = ""; - this.markerMatchPos = 0; - } + const result = scanForShellReady(this.scanState, data); + data = result.output; + if (result.matched) { + this.resolveShellReady("ready"); } - data = output; } if (data.length === 0) break; @@ -1015,8 +993,7 @@ export class Session { this.shellReadyTimeoutId = null; } this.preReadyStdinQueue = []; - this.markerMatchPos = 0; - this.markerHeldBytes = ""; + this.scanState = createScanState(); this.subprocessStdinQueue = []; this.subprocessStdinQueuedBytes = 0; this.subprocessStdinDrainArmed = false; @@ -1060,15 +1037,15 @@ export class Session { this.shellReadyTimeoutId = null; } // Flush held marker bytes — they weren't part of a full marker - if (this.markerHeldBytes.length > 0) { - this.enqueueEmulatorWrite(this.markerHeldBytes); + if (this.scanState.heldBytes.length > 0) { + this.enqueueEmulatorWrite(this.scanState.heldBytes); this.broadcastEvent("data", { type: "data", - data: this.markerHeldBytes, + data: this.scanState.heldBytes, } satisfies TerminalDataEvent); - this.markerHeldBytes = ""; + this.scanState.heldBytes = ""; } - this.markerMatchPos = 0; + this.scanState.matchPos = 0; // Flush queued writes in FIFO order const queue = this.preReadyStdinQueue; this.preReadyStdinQueue = []; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildSetupPaneLayout.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildSetupPaneLayout.ts new file mode 100644 index 00000000000..6a8abfb8746 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildSetupPaneLayout.ts @@ -0,0 +1,39 @@ +import type { WorkspaceState } from "@superset/panes"; +import type { + PaneViewerData, + TerminalPaneData, +} from "../../v2-workspace/$workspaceId/types"; + +/** + * Build a pane layout from terminal descriptors returned by workspace creation. + * Each terminal becomes its own tab. The renderer just attaches — sessions are + * already running on the host-service. + */ +export function buildSetupPaneLayout( + terminals: Array<{ id: string; role: string; label: string }>, +): WorkspaceState { + const tabs = terminals.map((t) => { + const paneId = `pane-${crypto.randomUUID()}`; + const tabId = `tab-${crypto.randomUUID()}`; + return { + id: tabId, + titleOverride: t.label, + createdAt: Date.now(), + activePaneId: paneId, + layout: { type: "pane" as const, paneId }, + panes: { + [paneId]: { + id: paneId, + kind: "terminal", + data: { terminalId: t.id } as TerminalPaneData, + }, + }, + }; + }); + + return { + version: 1, + activeTabId: tabs[0]?.id ?? null, + tabs, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx index f4ab5c46b1b..239263c2eea 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx @@ -13,8 +13,10 @@ import { loadAttachments, } from "renderer/lib/pending-attachment-store"; import { useCreateDashboardWorkspace } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { buildSetupPaneLayout } from "./buildSetupPaneLayout"; /** * Pending workspace progress page. @@ -109,7 +111,7 @@ function useRetryCreate( collections.pendingWorkspaces.update(pendingId, (draft) => { draft.status = "succeeded"; draft.workspaceId = result.workspace?.id ?? null; - draft.initialCommands = result.initialCommands ?? null; + draft.terminals = result.terminals ?? []; }); void clearAttachments(pendingId); } catch (err) { @@ -127,6 +129,7 @@ function PendingWorkspacePage() { const navigate = useNavigate(); const collections = useCollections(); const { activeHostUrl } = useLocalHostService(); + const { ensureWorkspaceInSidebar } = useDashboardSidebarState(); const navigatedRef = useRef(false); // Read pending workspace from collection (declared early for useRetryCreate) @@ -192,6 +195,21 @@ function PendingWorkspacePage() { !navigatedRef.current ) { navigatedRef.current = true; + + // Ensure sidebar local state row exists before writing pane layout + ensureWorkspaceInSidebar(pending.workspaceId, pending.projectId); + + // Pre-populate pane layout with setup terminals (already running on host) + if (pending.terminals.length > 0) { + const paneLayout = buildSetupPaneLayout(pending.terminals); + collections.v2WorkspaceLocalState.update( + pending.workspaceId, + (draft) => { + draft.paneLayout = paneLayout; + }, + ); + } + void navigate({ to: "/v2-workspace/$workspaceId", params: { workspaceId: pending.workspaceId }, @@ -201,7 +219,7 @@ function PendingWorkspacePage() { collections.pendingWorkspaces.delete(pendingId); }, 1000); } - }, [collections, navigate, pending, pendingId]); + }, [collections, ensureWorkspaceInSidebar, navigate, pending, pendingId]); if (!pending) { return ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index d8ed3f2a987..4eb5af43698 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -116,22 +116,6 @@ export function TerminalPane({ ctx, workspaceId }: TerminalPaneProps) { terminalRuntimeRegistry.updateAppearance(terminalId, appearance); }, [terminalId, appearance]); - // --- Initial command delivery --- - // When initialCommand is set on pane data, send it once the WebSocket is - // open and immediately clear the field. Clearing prevents re-send on - // reconnect, and allows a new initialCommand to be set later (e.g. "run - // in current terminal"). - useEffect(() => { - if (connectionState !== "open") return; - if (!paneData.initialCommand) return; - - const command = paneData.initialCommand.endsWith("\n") - ? paneData.initialCommand - : `${paneData.initialCommand}\n`; - terminalRuntimeRegistry.writeInput(terminalId, command); - ctx.actions.updateData({ ...paneData, initialCommand: undefined }); - }, [connectionState, terminalId, paneData, ctx.actions]); - // --- Link handlers --- // All filesystem operations go through the host service. // statPath is a mutation (POST) to avoid tRPC GET URL encoding issues diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts index df2c8cf220b..87209a159d0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts @@ -1,6 +1,8 @@ import type { CreatePaneInput, WorkspaceStore } from "@superset/panes"; +import { toast } from "@superset/ui/sonner"; +import { workspaceTrpc } from "@superset/workspace-client"; import { useLiveQuery } from "@tanstack/react-db"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { V2TerminalPresetRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; import { getPresetLaunchPlan } from "renderer/stores/tabs/preset-launch"; @@ -8,13 +10,10 @@ import { filterMatchingPresetsForProject } from "shared/preset-project-targeting import type { StoreApi } from "zustand/vanilla"; import type { PaneViewerData, TerminalPaneData } from "../../types"; -function makeTerminalPane(command?: string): CreatePaneInput { +function makeTerminalPane(terminalId: string): CreatePaneInput { return { kind: "terminal", - data: { - terminalId: crypto.randomUUID(), - initialCommand: command, - } as TerminalPaneData, + data: { terminalId } as TerminalPaneData, }; } @@ -24,14 +23,19 @@ function resolveTarget(executionMode: V2TerminalPresetRow["executionMode"]) { interface UseV2PresetExecutionArgs { store: StoreApi>; + workspaceId: string; projectId: string; } export function useV2PresetExecution({ store, + workspaceId, projectId, }: UseV2PresetExecutionArgs) { const collections = useCollections(); + const ensureSession = workspaceTrpc.terminal.ensureSession.useMutation(); + const ensureSessionRef = useRef(ensureSession); + ensureSessionRef.current = ensureSession; const { data: allPresets = [] } = useLiveQuery( (query) => @@ -46,8 +50,22 @@ export function useV2PresetExecution({ [allPresets, projectId], ); + /** Create a terminal session with a command on the host-service, return the terminalId. */ + const createSessionWithCommand = useCallback( + async (command: string): Promise => { + const terminalId = crypto.randomUUID(); + await ensureSessionRef.current.mutateAsync({ + terminalId, + workspaceId, + initialCommand: command, + }); + return terminalId; + }, + [workspaceId], + ); + const executePreset = useCallback( - (preset: V2TerminalPresetRow) => { + async (preset: V2TerminalPresetRow) => { const state = store.getState(); const activeTabId = state.activeTabId; const target = resolveTarget(preset.executionMode); @@ -59,58 +77,24 @@ export function useV2PresetExecution({ hasActiveTab: !!activeTabId, }); - switch (plan) { - case "new-tab-single": { - state.addTab({ - titleOverride: preset.name || "Terminal", - panes: [makeTerminalPane(preset.commands[0])], - }); - break; - } - - case "new-tab-multi-pane": { - const panes = preset.commands.map((cmd) => makeTerminalPane(cmd)); - state.addTab({ - titleOverride: preset.name || "Terminal", - panes: - panes.length > 0 - ? (panes as [ - CreatePaneInput, - ...CreatePaneInput[], - ]) - : [makeTerminalPane()], - }); - break; - } - - case "new-tab-per-command": { - for (const command of preset.commands) { + try { + switch (plan) { + case "new-tab-single": { + const id = await createSessionWithCommand( + preset.commands[0] as string, + ); state.addTab({ titleOverride: preset.name || "Terminal", - panes: [makeTerminalPane(command)], - }); - } - break; - } - - case "active-tab-single": { - if (!activeTabId) { - state.addTab({ - titleOverride: preset.name || "Terminal", - panes: [makeTerminalPane(preset.commands[0])], + panes: [makeTerminalPane(id)], }); break; } - state.addPane({ - tabId: activeTabId, - pane: makeTerminalPane(preset.commands[0]), - }); - break; - } - case "active-tab-multi-pane": { - if (!activeTabId) { - const panes = preset.commands.map((cmd) => makeTerminalPane(cmd)); + case "new-tab-multi-pane": { + const ids = await Promise.all( + preset.commands.map((cmd) => createSessionWithCommand(cmd)), + ); + const panes = ids.map((id) => makeTerminalPane(id)); state.addTab({ titleOverride: preset.name || "Terminal", panes: @@ -119,21 +103,80 @@ export function useV2PresetExecution({ CreatePaneInput, ...CreatePaneInput[], ]) - : [makeTerminalPane()], + : [makeTerminalPane(crypto.randomUUID())], }); break; } - for (const command of preset.commands) { + + case "new-tab-per-command": { + const ids = await Promise.all( + preset.commands.map((cmd) => createSessionWithCommand(cmd)), + ); + for (let i = 0; i < ids.length; i++) { + state.addTab({ + titleOverride: preset.name || "Terminal", + panes: [makeTerminalPane(ids[i] as string)], + }); + } + break; + } + + case "active-tab-single": { + const id = await createSessionWithCommand( + preset.commands[0] as string, + ); + if (!activeTabId) { + state.addTab({ + titleOverride: preset.name || "Terminal", + panes: [makeTerminalPane(id)], + }); + break; + } state.addPane({ tabId: activeTabId, - pane: makeTerminalPane(command), + pane: makeTerminalPane(id), }); + break; + } + + case "active-tab-multi-pane": { + const ids = await Promise.all( + preset.commands.map((cmd) => createSessionWithCommand(cmd)), + ); + if (!activeTabId) { + const panes = ids.map((id) => makeTerminalPane(id)); + state.addTab({ + titleOverride: preset.name || "Terminal", + panes: + panes.length > 0 + ? (panes as [ + CreatePaneInput, + ...CreatePaneInput[], + ]) + : [makeTerminalPane(crypto.randomUUID())], + }); + break; + } + for (const id of ids) { + state.addPane({ + tabId: activeTabId, + pane: makeTerminalPane(id), + }); + } + break; } - break; } + } catch (err) { + console.error("[useV2PresetExecution] Failed to execute preset:", err); + toast.error("Failed to run preset", { + description: + err instanceof Error + ? err.message + : "Terminal session creation failed.", + }); } }, - [store], + [store, createSessionWithCommand], ); return { matchedPresets, executePreset }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index af078ebd2be..249621c333c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -88,6 +88,7 @@ function WorkspaceContent({ }); const { matchedPresets, executePreset } = useV2PresetExecution({ store, + workspaceId, projectId, }); const paneRegistry = usePaneRegistry(workspaceId); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts index e68bf910314..b8971ddd8c6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts @@ -7,7 +7,6 @@ export interface FilePaneData { export interface TerminalPaneData { terminalId: string; - initialCommand?: string; } export interface ChatPaneData { diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts index 4f7022ee8d6..429f045df9d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts @@ -67,7 +67,6 @@ export function useSubmitWorkspace(projectId: string | null) { status: "creating", error: null, workspaceId: null, - initialCommands: null, createdAt: new Date(), }); @@ -112,7 +111,7 @@ export function useSubmitWorkspace(projectId: string | null) { collections.pendingWorkspaces.update(pendingId, (row) => { row.status = "succeeded"; row.workspaceId = result.workspace?.id ?? null; - row.initialCommands = result.initialCommands ?? null; + row.terminals = result.terminals ?? []; }); void clearAttachments(pendingId); } catch (err) { diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts index 231518f89b6..316c857c141 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts @@ -90,7 +90,9 @@ export const pendingWorkspaceSchema = z.object({ status: z.enum(["creating", "failed", "succeeded"]).default("creating"), error: z.string().nullable().default(null), workspaceId: z.string().nullable().default(null), - initialCommands: z.array(z.string()).nullable().default(null), + terminals: z + .array(z.object({ id: z.string(), role: z.string(), label: z.string() })) + .default([]), createdAt: persistedDateSchema, }); diff --git a/docs/V2_WORKSPACE_SETUP_SCRIPTS.md b/docs/V2_WORKSPACE_SETUP_SCRIPTS.md new file mode 100644 index 00000000000..66da7ef3eac --- /dev/null +++ b/docs/V2_WORKSPACE_SETUP_SCRIPTS.md @@ -0,0 +1,161 @@ +# V2 Workspace Setup Script Execution + +## Problem + +1. V2 workspace creation returns `initialCommands` (from `.superset/setup.sh`) but never executes them. +2. Presets race with shell init — commands fire before the shell is ready. + +## Approach + +One unified API: all initial commands go through `createTerminalSessionInternal({ initialCommand })`, gated behind `shellReadyPromise`. The renderer never writes commands — it only attaches to sessions. + +1. OSC 133 (FinalTerm standard) for shell readiness detection +2. `initialCommand` on `createTerminalSessionInternal` — queues command behind `shellReadyPromise` +3. `ensureSession` gains optional `initialCommand`, passes through to `createTerminalSessionInternal` +4. Presets call `await ensureSession({ initialCommand })` before adding pane — no `initialCommand` on pane data +5. Setup scripts call `createTerminalSessionInternal({ initialCommand })` directly during workspace creation +6. `TerminalPane` simplified — just calls `ensureSession()` and attaches WebSocket. No command delivery logic. + +Existing output buffering (`bufferOutput`/`replayBuffer`) handles the gap between session creation and WebSocket connect. + +## Phase 1: Shell Readiness via OSC 133 ✅ DONE + +Shell wrappers updated to emit OSC 133 A/C/D. Scanner + `shellReadyPromise` added to `terminal.ts`. + +--- + +## Phase 2: `initialCommand` on Session Creation + +### `createTerminalSessionInternal` + +**File:** `packages/host-service/src/terminal/terminal.ts` + +```typescript +interface CreateTerminalSessionOptions { + // ...existing... + initialCommand?: string; +} + +// After PTY creation + shell ready setup: +if (initialCommand) { + session.shellReadyPromise.then(() => { + if (!session.exited) { + pty.write(initialCommand.endsWith("\n") ? initialCommand : `${initialCommand}\n`); + } + }); +} +``` + +### `ensureSession` tRPC + +**File:** `packages/host-service/src/trpc/router/terminal/terminal.ts` + +Add optional `initialCommand` to input, pass through: + +```typescript +ensureSession: protectedProcedure + .input(z.object({ + terminalId: z.string(), + workspaceId: z.string(), + themeType: z.string().optional(), + initialCommand: z.string().optional(), + })) + .mutation(({ ctx, input }) => { + const result = createTerminalSessionInternal({ + terminalId: input.terminalId, + workspaceId: input.workspaceId, + themeType: parseThemeType(input.themeType), + db: ctx.db, + initialCommand: input.initialCommand, + }); + // ... + }), +``` + +### Update presets + +**File:** `apps/desktop/.../useV2PresetExecution/useV2PresetExecution.ts` + +Before adding the pane, create the session with the command: + +```typescript +const terminalId = crypto.randomUUID(); +await ensureSession({ terminalId, workspaceId, initialCommand: command }); +store.addTab({ panes: [{ kind: "terminal", data: { terminalId } }] }); +``` + +### Simplify TerminalPane + +**File:** `apps/desktop/.../TerminalPane/TerminalPane.tsx` + +Delete the `initialCommand` delivery effect (lines 119-133). `TerminalPane` only does: `ensureSession()` + attach WebSocket. + +### Remove `initialCommand` from pane data + +**File:** `apps/desktop/.../v2-workspace/$workspaceId/types.ts` + +```typescript +export interface TerminalPaneData { + terminalId: string; + // initialCommand removed +} +``` + +--- + +## Phase 3: Create Setup Terminal During Workspace Creation + +**File:** `packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts` + +Replace command resolution (lines 462-469) with terminal creation. Return terminal descriptors: + +```typescript +const terminals: Array<{ id: string; role: string; label: string }> = []; + +if (input.composer.runSetupScript) { + const setupScriptPath = join(worktreePath, ".superset", "setup.sh"); + if (existsSync(setupScriptPath)) { + const terminalId = crypto.randomUUID(); + const result = createTerminalSessionInternal({ + terminalId, + workspaceId: cloudRow.id, + db: ctx.db, + initialCommand: `bash "${setupScriptPath}"`, + }); + if (!("error" in result)) { + terminals.push({ id: terminalId, role: "setup", label: "Workspace Setup" }); + } + } +} + +return { workspace: cloudRow, terminals, warnings: [] as string[] }; +``` + +--- + +## Phase 4: Renderer Attaches to Pre-Started Terminals + +- Add `terminals` to `pendingWorkspaceSchema` +- Before navigating to workspace, pre-populate `v2WorkspaceLocalState.paneLayout` with terminal panes referencing host-provided `terminalId`s +- `TerminalPane` mounts → `ensureSession` (idempotent, session exists) → WebSocket connects → buffered output replays + +**Files:** +- `apps/desktop/.../dashboardSidebarLocal/schema.ts` +- `apps/desktop/.../pending/$pendingId/page.tsx` +- `apps/desktop/.../pending/$pendingId/buildSetupPaneLayout.ts` (new) + +--- + +## Future: "Run in Current Terminal" + +Not used in v2 today. When needed, add a `terminal.writeCommand` tRPC mutation. + +--- + +## Attribution + +Shell integration protocol vendored from: +- **WezTerm** (MIT License, Copyright 2018-Present Wez Furlong) — `assets/shell-integration/wezterm.sh` +- **FinalTerm semantic prompts spec** — https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md + +Scanner pattern adapted from our v1 desktop terminal host (`apps/desktop/src/main/terminal-host/session.ts`). diff --git a/packages/host-service/src/terminal/env.test.ts b/packages/host-service/src/terminal/env.test.ts index d1375fdde0c..490eed59d7c 100644 --- a/packages/host-service/src/terminal/env.test.ts +++ b/packages/host-service/src/terminal/env.test.ts @@ -242,7 +242,7 @@ describe("getShellLaunchArgs", () => { expect(args[0]).toBe("-l"); expect(args[1]).toBe("--init-command"); expect(args[2]).toContain("_superset_bin"); - expect(args[2]).toContain("superset-shell-ready"); + expect(args[2]).toContain("133;A"); }); test("sh launches as login shell", () => { diff --git a/packages/host-service/src/terminal/shell-launch.ts b/packages/host-service/src/terminal/shell-launch.ts index 950ee0cd256..079f4857b48 100644 --- a/packages/host-service/src/terminal/shell-launch.ts +++ b/packages/host-service/src/terminal/shell-launch.ts @@ -35,7 +35,12 @@ function getShellName(shell: string): string { return path.basename(shell); } -/** Matches desktop shell-wrappers.ts fish init: idempotent PATH prepend + shell-ready OSC marker. */ +/** + * Matches desktop shell-wrappers.ts fish init: idempotent PATH prepend + + * OSC 133;A prompt marker (FinalTerm standard) for shell readiness. + * + * Protocol ref: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md + */ function buildFishInitCommand(binDir: string): string { const escaped = binDir .replaceAll("\\", "\\\\") @@ -45,9 +50,8 @@ function buildFishInitCommand(binDir: string): string { `set -l _superset_bin "${escaped}"`, `contains -- "$_superset_bin" $PATH`, `or set -gx PATH "$_superset_bin" $PATH`, - `function _superset_shell_ready --on-event fish_prompt`, - `printf '\\033]777;superset-shell-ready\\007'`, - `functions -e _superset_shell_ready`, + `function _superset_prompt_mark --on-event fish_prompt`, + `printf '\\033]133;A\\007'`, `end`, ].join("; "); } diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 1d9c23e329f..99d91475ba6 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -1,5 +1,11 @@ import { existsSync } from "node:fs"; import type { NodeWebSocket } from "@hono/node-ws"; +import { + createScanState, + SHELLS_WITH_READY_MARKER, + type ShellReadyScanState, + scanForShellReady, +} from "@superset/shared/shell-ready-scanner"; import { eq } from "drizzle-orm"; import type { Hono } from "hono"; import { type IPty, spawn } from "node-pty"; @@ -37,6 +43,27 @@ type TerminalServerMessage = const MAX_BUFFER_BYTES = 64 * 1024; +// --------------------------------------------------------------------------- +// OSC 133 shell readiness detection (FinalTerm semantic prompt standard). +// Scanner logic lives in @superset/shared/shell-ready-scanner. +// --------------------------------------------------------------------------- + +/** + * How long to wait for the shell-ready marker before unblocking writes. + * 15 s covers heavy setups like Nix-based devenv via direnv. On timeout + * buffered writes flush immediately (same behaviour as before this feature). + */ +const SHELL_READY_TIMEOUT_MS = 15_000; + +/** + * Shell readiness lifecycle: + * - `pending` — shell initialising; scanner active + * - `ready` — OSC 133;A detected; scanner off + * - `timed_out` — marker never arrived within timeout; scanner off + * - `unsupported` — shell has no marker (sh, ksh); scanner never started + */ +type ShellReadyState = "pending" | "ready" | "timed_out" | "unsupported"; + interface TerminalSession { terminalId: string; pty: IPty; @@ -50,6 +77,13 @@ interface TerminalSession { exited: boolean; exitCode: number; exitSignal: number; + + // Shell readiness (OSC 133) + shellReadyState: ShellReadyState; + shellReadyResolve: (() => void) | null; + shellReadyPromise: Promise; + shellReadyTimeoutId: ReturnType | null; + scanState: ShellReadyScanState; } /** PTY lifetime is independent of socket lifetime — sockets detach/reattach freely. */ @@ -84,10 +118,41 @@ function replayBuffer( sendMessage(socket, { type: "replay", data: combined }); } +/** + * Transition out of `pending`. Flushes any partially-matched marker + * bytes as terminal output (they weren't a real marker). Idempotent. + */ +function resolveShellReady( + session: TerminalSession, + state: "ready" | "timed_out", +): void { + if (session.shellReadyState !== "pending") return; + session.shellReadyState = state; + if (session.shellReadyTimeoutId) { + clearTimeout(session.shellReadyTimeoutId); + session.shellReadyTimeoutId = null; + } + // Flush held marker bytes — they weren't part of a full marker + if (session.scanState.heldBytes.length > 0) { + bufferOutput(session, session.scanState.heldBytes); + session.scanState.heldBytes = ""; + } + session.scanState.matchPos = 0; + if (session.shellReadyResolve) { + session.shellReadyResolve(); + session.shellReadyResolve = null; + } +} + function disposeSession(terminalId: string, db: HostDb) { const session = sessions.get(terminalId); if (!session) return; + if (session.shellReadyTimeoutId) { + clearTimeout(session.shellReadyTimeoutId); + session.shellReadyTimeoutId = null; + } + if (!session.exited) { try { session.pty.kill(); @@ -108,6 +173,8 @@ interface CreateTerminalSessionOptions { workspaceId: string; themeType?: "dark" | "light"; db: HostDb; + /** Command to run after the shell is ready. Queued behind shellReadyPromise. */ + initialCommand?: string; } export function createTerminalSessionInternal({ @@ -115,6 +182,7 @@ export function createTerminalSessionInternal({ workspaceId, themeType, db, + initialCommand, }: CreateTerminalSessionOptions): TerminalSession | { error: string } { const existing = sessions.get(terminalId); if (existing) { @@ -190,6 +258,17 @@ export function createTerminalSessionInternal({ }) .run(); + // Determine shell readiness support + const shellName = shell.split("/").pop() || shell; + const shellSupportsReady = SHELLS_WITH_READY_MARKER.has(shellName); + + let shellReadyResolve: (() => void) | null = null; + const shellReadyPromise = shellSupportsReady + ? new Promise((resolve) => { + shellReadyResolve = resolve; + }) + : Promise.resolve(); + const session: TerminalSession = { terminalId, pty, @@ -199,10 +278,34 @@ export function createTerminalSessionInternal({ exited: false, exitCode: 0, exitSignal: 0, + shellReadyState: shellSupportsReady ? "pending" : "unsupported", + shellReadyResolve, + shellReadyPromise, + shellReadyTimeoutId: null, + scanState: createScanState(), }; sessions.set(terminalId, session); - pty.onData((data) => { + // If the marker never arrives (broken wrapper, unsupported config), + // the timeout unblocks so the session degrades gracefully. + if (session.shellReadyState === "pending") { + session.shellReadyTimeoutId = setTimeout(() => { + resolveShellReady(session, "timed_out"); + }, SHELL_READY_TIMEOUT_MS); + } + + pty.onData((rawData) => { + // Scan for OSC 133;A and strip it from output + let data = rawData; + if (session.shellReadyState === "pending") { + const result = scanForShellReady(session.scanState, rawData); + data = result.output; + if (result.matched) { + resolveShellReady(session, "ready"); + } + } + if (data.length === 0) return; + if (session.socket?.readyState === 1) { sendMessage(session.socket, { type: "data", data }); } else { @@ -229,6 +332,17 @@ export function createTerminalSessionInternal({ } }); + if (initialCommand) { + const cmd = initialCommand.endsWith("\n") + ? initialCommand + : `${initialCommand}\n`; + session.shellReadyPromise.then(() => { + if (!session.exited) { + pty.write(cmd); + } + }); + } + return session; } diff --git a/packages/host-service/src/trpc/router/terminal/terminal.ts b/packages/host-service/src/trpc/router/terminal/terminal.ts index d6f2bec8de0..c90dfd7253b 100644 --- a/packages/host-service/src/trpc/router/terminal/terminal.ts +++ b/packages/host-service/src/trpc/router/terminal/terminal.ts @@ -12,6 +12,7 @@ export const terminalRouter = router({ terminalId: z.string(), workspaceId: z.string(), themeType: z.string().optional(), + initialCommand: z.string().optional(), }), ) .mutation(({ ctx, input }) => { @@ -20,6 +21,7 @@ export const terminalRouter = router({ workspaceId: input.workspaceId, themeType: parseThemeType(input.themeType), db: ctx.db, + initialCommand: input.initialCommand, }); if ("error" in result) { diff --git a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts index c6785f407fe..e72c27e92eb 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts @@ -6,6 +6,7 @@ import { eq } from "drizzle-orm"; import simpleGit from "simple-git"; import { z } from "zod"; import { projects, workspaces } from "../../../db/schema"; +import { createTerminalSessionInternal } from "../../../terminal/terminal"; import type { HostServiceContext } from "../../../types"; import { protectedProcedure, router } from "../../index"; import { deduplicateBranchName } from "./utils/sanitize-branch"; @@ -459,12 +460,33 @@ export const workspaceCreationRouter = router({ }) .run(); - // 5. Resolve setup commands (returned to renderer, not executed here) - let initialCommands: string[] | null = null; + // 5. Create setup terminal if setup script exists + const terminals: Array<{ + id: string; + role: string; + label: string; + }> = []; + const warnings: string[] = []; + if (input.composer.runSetupScript) { const setupScriptPath = join(worktreePath, ".superset", "setup.sh"); if (existsSync(setupScriptPath)) { - initialCommands = [`bash "${setupScriptPath}"`]; + const terminalId = crypto.randomUUID(); + const result = createTerminalSessionInternal({ + terminalId, + workspaceId: cloudRow.id, + db: ctx.db, + initialCommand: `bash "${setupScriptPath}"`, + }); + if ("error" in result) { + warnings.push(`Failed to start setup terminal: ${result.error}`); + } else { + terminals.push({ + id: terminalId, + role: "setup", + label: "Workspace Setup", + }); + } } } @@ -472,8 +494,8 @@ export const workspaceCreationRouter = router({ return { workspace: cloudRow, - initialCommands, - warnings: [] as string[], + terminals, + warnings, }; }), diff --git a/packages/shared/package.json b/packages/shared/package.json index e12d11886e4..ea93057b6a6 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -63,6 +63,10 @@ "./device-info": { "types": "./src/device-info.ts", "default": "./src/device-info.ts" + }, + "./shell-ready-scanner": { + "types": "./src/shell-ready-scanner.ts", + "default": "./src/shell-ready-scanner.ts" } }, "scripts": { diff --git a/packages/shared/src/shell-ready-scanner.ts b/packages/shared/src/shell-ready-scanner.ts new file mode 100644 index 00000000000..9600e83694d --- /dev/null +++ b/packages/shared/src/shell-ready-scanner.ts @@ -0,0 +1,87 @@ +/** + * OSC 133 shell readiness scanner (FinalTerm semantic prompt standard). + * + * Pure scanning logic — no side effects. Callers handle their own readiness + * resolution (promises, state machines, event broadcasts, etc.). + * + * Protocol ref: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md + * Vendored from WezTerm (MIT, Copyright 2018-Present Wez Furlong). + */ + +/** The OSC 133;A prefix that signals shell prompt start (= shell ready). */ +const OSC_133_A = "\x1b]133;A"; + +/** Shells whose wrapper files inject OSC 133 markers. */ +export const SHELLS_WITH_READY_MARKER = new Set(["zsh", "bash", "fish"]); + +/** + * Mutable state for the character-by-character scanner. + * Callers should create one per terminal session via {@link createScanState}. + */ +export interface ShellReadyScanState { + matchPos: number; + heldBytes: string; +} + +export interface ShellReadyScanResult { + /** Output data with the marker stripped (if found). */ + output: string; + /** Whether the full OSC 133;A marker was matched in this chunk. */ + matched: boolean; +} + +export function createScanState(): ShellReadyScanState { + return { matchPos: 0, heldBytes: "" }; +} + +/** + * Scan a chunk of PTY output for the OSC 133;A (prompt start) marker. + * + * Matching bytes are held back from output. On full match (prefix + optional + * params + string terminator `\a`), they're discarded and `matched` is true. + * On mismatch, held bytes are flushed as regular terminal output. + * + * The scanner handles the marker spanning multiple data chunks. + */ +export function scanForShellReady( + state: ShellReadyScanState, + data: string, +): ShellReadyScanResult { + let output = ""; + + for (let i = 0; i < data.length; i++) { + const ch = data[i] as string; + if (state.matchPos < OSC_133_A.length) { + // Still matching the "\x1b]133;A" prefix + if (ch === OSC_133_A[state.matchPos]) { + state.heldBytes += ch; + state.matchPos++; + } else { + // Mismatch — flush held bytes, then re-test current char as a + // fresh match start (e.g. stale ESC followed by real marker). + output += state.heldBytes; + state.heldBytes = ""; + state.matchPos = 0; + if (ch === OSC_133_A[0]) { + state.heldBytes = ch; + state.matchPos = 1; + } else { + output += ch; + } + } + } else { + // Matched prefix — consume optional params until string terminator + if (ch === "\x07") { + // Full match — discard held bytes + const remaining = data.slice(i + 1); + state.heldBytes = ""; + state.matchPos = 0; + return { output: output + remaining, matched: true }; + } + // Consume optional params (e.g. ";cl=m;aid=123") before \a + state.heldBytes += ch; + } + } + + return { output, matched: false }; +}