Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions apps/desktop/src/main/lib/agent-setup/shell-wrappers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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");
});
});
});
49 changes: 22 additions & 27 deletions apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
`;
Expand Down Expand Up @@ -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)) {
Expand Down
12 changes: 0 additions & 12 deletions apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
61 changes: 19 additions & 42 deletions apps/desktop/src/main/terminal-host/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,7 +40,6 @@ import {
createFrameHeader,
PtySubprocessFrameDecoder,
PtySubprocessIpcType,
SHELL_READY_MARKER,
} from "./pty-subprocess-ipc";

// =============================================================================
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -162,13 +164,8 @@ export class Session {
private shellReadyState: ShellReadyState;
private shellReadyTimeoutId: ReturnType<typeof setTimeout> | 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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 = [];
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PaneViewerData> {
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,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down Expand Up @@ -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 },
Expand All @@ -201,7 +219,7 @@ function PendingWorkspacePage() {
collections.pendingWorkspaces.delete(pendingId);
}, 1000);
}
}, [collections, navigate, pending, pendingId]);
}, [collections, ensureWorkspaceInSidebar, navigate, pending, pendingId]);

if (!pending) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading