Reuse terminals for non-service scripts and track lifecycle via OSC 633

Scripts launched from the UI now run in a dedicated terminal per script
(titled after the script) and reuse the same terminal across re-runs.
Script lifecycle is driven by a new onCommandFinished signal sourced from
OSC 633 command-finished events emitted by the zsh shell integration, so
foreground script exit updates status without killing the terminal.
Script payloads now carry terminalId so the workspace can surface the
associated tab from the scripts dropdown.
This commit is contained in:
Mohamed Boudra
2026-04-21 17:12:53 +07:00
parent 3e2faab7f8
commit cea97c83b3
20 changed files with 1358 additions and 261 deletions
@@ -62,6 +62,7 @@ const runningScript: WorkspaceScriptPayload = {
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null, exitCode: null,
terminalId: null,
}; };
function workspace(input: { function workspace(input: {
@@ -32,6 +32,7 @@ const runningScript: WorkspaceScriptPayload = {
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null, exitCode: null,
terminalId: null,
}; };
describe("patchWorkspaceScripts", () => { describe("patchWorkspaceScripts", () => {
@@ -697,6 +697,55 @@ function WorkspaceScreenContent({
staleTime: TERMINALS_QUERY_STALE_TIME, staleTime: TERMINALS_QUERY_STALE_TIME,
}); });
const terminals = terminalsQuery.data?.terminals ?? []; const terminals = terminalsQuery.data?.terminals ?? [];
const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]);
const [pendingScriptTerminalIds, setPendingScriptTerminalIds] = useState<Map<string, number>>(
() => new Map(),
);
useEffect(() => {
setPendingScriptTerminalIds(new Map());
}, [normalizedServerId, normalizedWorkspaceId]);
useEffect(() => {
setPendingScriptTerminalIds((pendingTerminalIds) => {
if (pendingTerminalIds.size === 0) {
return pendingTerminalIds;
}
const liveIds = new Set(liveTerminalIds);
let changed = false;
const nextTerminalIds = new Map<string, number>();
for (const [terminalId, listedAt] of pendingTerminalIds) {
if (liveIds.has(terminalId) || terminalsQuery.dataUpdatedAt > listedAt) {
changed = true;
continue;
}
nextTerminalIds.set(terminalId, listedAt);
}
return changed ? nextTerminalIds : pendingTerminalIds;
});
}, [liveTerminalIds, terminalsQuery.dataUpdatedAt]);
const knownTerminalIds = useMemo(() => {
const terminalIds = new Set(liveTerminalIds);
for (const terminalId of pendingScriptTerminalIds.keys()) {
terminalIds.add(terminalId);
}
return Array.from(terminalIds);
}, [liveTerminalIds, pendingScriptTerminalIds]);
const scriptTerminalIds = useMemo(() => {
const terminalIds = new Set(pendingScriptTerminalIds.keys());
for (const script of workspaceDescriptor?.scripts ?? []) {
if (script.terminalId) {
terminalIds.add(script.terminalId);
}
}
return terminalIds;
}, [pendingScriptTerminalIds, workspaceDescriptor?.scripts]);
const standaloneTerminalIds = useMemo(
() =>
terminals
.filter((terminal) => !scriptTerminalIds.has(terminal.id))
.map((terminal) => terminal.id),
[scriptTerminalIds, terminals],
);
const createTerminalMutation = useMutation({ const createTerminalMutation = useMutation({
mutationFn: async (input?: { paneId?: string }) => { mutationFn: async (input?: { paneId?: string }) => {
if (!client || !workspaceDirectory) { if (!client || !workspaceDirectory) {
@@ -948,6 +997,38 @@ function WorkspaceScreenContent({
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty); const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane); const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane); const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
const handleScriptTerminalStarted = useCallback(
(terminalId: string) => {
setPendingScriptTerminalIds((pendingTerminalIds) => {
if (pendingTerminalIds.get(terminalId) === terminalsQuery.dataUpdatedAt) {
return pendingTerminalIds;
}
const nextTerminalIds = new Map(pendingTerminalIds);
nextTerminalIds.set(terminalId, terminalsQuery.dataUpdatedAt);
return nextTerminalIds;
});
if (persistenceKey) {
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
}
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
},
[
openWorkspaceTabFocused,
persistenceKey,
queryClient,
terminalsQuery.dataUpdatedAt,
terminalsQueryKey,
],
);
const handleViewScriptTerminal = useCallback(
(terminalId: string) => {
if (!persistenceKey) {
return;
}
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
},
[openWorkspaceTabFocused, persistenceKey],
);
const paneFocusSuppressedRef = useRef(false); const paneFocusSuppressedRef = useRef(false);
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit); const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane); const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
@@ -1065,7 +1146,8 @@ function WorkspaceScreenContent({
terminalsHydrated: terminalsQuery.isSuccess, terminalsHydrated: terminalsQuery.isSuccess,
activeAgentIds: Array.from(workspaceAgentVisibility.activeAgentIds), activeAgentIds: Array.from(workspaceAgentVisibility.activeAgentIds),
knownAgentIds: Array.from(workspaceAgentVisibility.knownAgentIds), knownAgentIds: Array.from(workspaceAgentVisibility.knownAgentIds),
standaloneTerminalIds: terminals.map((terminal) => terminal.id), knownTerminalIds,
standaloneTerminalIds,
hasActivePendingDraftCreate: hasActivePendingDraftCreateInWorkspace, hasActivePendingDraftCreate: hasActivePendingDraftCreateInWorkspace,
}); });
}, [ }, [
@@ -1074,7 +1156,8 @@ function WorkspaceScreenContent({
pendingByDraftId, pendingByDraftId,
persistenceKey, persistenceKey,
reconcileWorkspaceTabs, reconcileWorkspaceTabs,
terminals, knownTerminalIds,
standaloneTerminalIds,
terminalsQuery.isSuccess, terminalsQuery.isSuccess,
uiTabs, uiTabs,
workspaceAgentVisibility, workspaceAgentVisibility,
@@ -2195,6 +2278,9 @@ function WorkspaceScreenContent({
serverId={normalizedServerId} serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId} workspaceId={normalizedWorkspaceId}
scripts={workspaceDescriptor.scripts} scripts={workspaceDescriptor.scripts}
liveTerminalIds={liveTerminalIds}
onScriptTerminalStarted={handleScriptTerminalStarted}
onViewTerminal={handleViewScriptTerminal}
hideLabels={showCompactButtonLabels} hideLabels={showCompactButtonLabels}
/> />
) : null} ) : null}
@@ -0,0 +1,320 @@
/**
* @vitest-environment jsdom
*/
import React, { type ReactElement } from "react";
import { act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { WorkspaceScriptPayload } from "@server/shared/messages";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
const { theme, startWorkspaceScriptMock } = vi.hoisted(() => {
const theme = {
spacing: { 1: 4, 1.5: 6, 2: 8, 3: 12 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { normal: "400", medium: "500" },
colors: {
foreground: "#fff",
foregroundMuted: "#aaa",
surface2: "#222",
borderAccent: "#444",
palette: {
blue: { 500: "#0a84ff" },
green: { 500: "#30d158" },
red: { 300: "#ff9f99", 500: "#ff453a" },
},
},
};
return {
theme,
startWorkspaceScriptMock: vi.fn(async () => ({ terminalId: "terminal-script-1" })),
};
});
vi.hoisted(() => {
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
}));
vi.mock("@/constants/platform", () => ({
isNative: false,
isWeb: true,
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeSnapshot: () => ({ activeConnection: null }),
}));
vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: unknown) => unknown) =>
selector({
sessions: {
"test-server": {
client: {
startWorkspaceScript: startWorkspaceScriptMock,
},
},
},
}),
}));
vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ show: vi.fn(), error: vi.fn() }),
}));
vi.mock("@/utils/open-external-url", () => ({
openExternalUrl: vi.fn(),
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
<div data-testid={testID}>{children}</div>
),
DropdownMenuSeparator: () => <div role="separator" />,
DropdownMenuTrigger: ({
children,
testID,
}: {
children:
| React.ReactNode
| ((state: { hovered: boolean; pressed: boolean; open: boolean }) => React.ReactNode);
testID?: string;
}) => (
<button type="button" data-testid={testID}>
{typeof children === "function"
? children({ hovered: false, pressed: false, open: true })
: children}
</button>
),
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", {
"data-icon": name,
"data-color": props.color,
"data-size": props.size,
"data-testid": props.testID,
});
return {
ChevronDown: createIcon("ChevronDown"),
ExternalLink: createIcon("ExternalLink"),
Globe: createIcon("Globe"),
Play: createIcon("Play"),
SquareTerminal: createIcon("SquareTerminal"),
};
});
vi.mock("react-native", async () => {
const actual = await vi.importActual<Record<string, unknown>>("react-native");
return actual;
});
function script(
input: Partial<WorkspaceScriptPayload> & Pick<WorkspaceScriptPayload, "scriptName">,
): WorkspaceScriptPayload {
return {
scriptName: input.scriptName,
type: input.type ?? "script",
hostname: input.hostname ?? input.scriptName,
port: input.port ?? null,
proxyUrl: input.proxyUrl ?? null,
lifecycle: input.lifecycle ?? "stopped",
health: input.health ?? null,
exitCode: input.exitCode ?? null,
terminalId: input.terminalId ?? null,
};
}
function renderScripts(scripts: WorkspaceScriptPayload[]): {
rerender: (nextScripts: WorkspaceScriptPayload[]) => Promise<void>;
unmount: () => void;
} {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
function element(nextScripts: WorkspaceScriptPayload[]): ReactElement {
return (
<QueryClientProvider client={queryClient}>
<WorkspaceScriptsButton
serverId="test-server"
workspaceId="workspace-1"
scripts={nextScripts}
liveTerminalIds={["terminal-script-1"]}
/>
</QueryClientProvider>
);
}
act(() => {
root.render(element(scripts));
});
return {
rerender: async (nextScripts) => {
await act(async () => {
root.render(element(nextScripts));
});
},
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
};
}
function requireRow(scriptName: string): HTMLElement {
const row = document.querySelector(`[data-testid="workspace-scripts-item-${scriptName}"]`);
if (!(row instanceof HTMLElement)) {
throw new Error(`Missing script row for ${scriptName}`);
}
return row;
}
function requirePrimaryIcon(row: HTMLElement): HTMLElement {
const icon = row.querySelector("[data-icon]");
if (!(icon instanceof HTMLElement)) {
throw new Error("Missing row icon");
}
return icon;
}
describe("WorkspaceScriptsButton", () => {
let current: ReturnType<typeof renderScripts> | null = null;
beforeEach(() => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal(
"ResizeObserver",
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
},
);
document.body.innerHTML = "";
startWorkspaceScriptMock.mockClear();
});
afterEach(() => {
current?.unmount();
current = null;
vi.unstubAllGlobals();
});
it("keeps completed script row icons visible and muted while the menu content stays mounted", async () => {
current = renderScripts([
script({
scriptName: "typecheck",
lifecycle: "running",
terminalId: "terminal-script-1",
}),
]);
let row = requireRow("typecheck");
let icon = requirePrimaryIcon(row);
expect(icon.dataset.icon).toBe("SquareTerminal");
expect(icon.dataset.color).toBe(theme.colors.palette.blue[500]);
await current.rerender([
script({
scriptName: "typecheck",
lifecycle: "stopped",
exitCode: 0,
terminalId: "terminal-script-1",
}),
]);
row = requireRow("typecheck");
icon = requirePrimaryIcon(row);
expect(icon.dataset.icon).toBe("SquareTerminal");
expect(icon.dataset.color).toBe(theme.colors.foregroundMuted);
expect(row.textContent).toContain("typecheck");
expect(row.textContent).toContain("exit 0");
expect(row.textContent).toContain("Run");
await current.rerender([
script({
scriptName: "typecheck",
lifecycle: "stopped",
exitCode: 7,
terminalId: "terminal-script-1",
}),
]);
row = requireRow("typecheck");
icon = requirePrimaryIcon(row);
expect(icon.dataset.icon).toBe("SquareTerminal");
expect(icon.dataset.color).toBe(theme.colors.foregroundMuted);
expect(row.textContent).toContain("exit 7");
expect(row.textContent).toContain("Run");
});
it("uses service icon color for service health and running unknown status only", () => {
current = renderScripts([
script({
scriptName: "web",
type: "service",
hostname: "web.paseo.localhost",
lifecycle: "running",
health: "healthy",
port: 3000,
}),
script({
scriptName: "api",
type: "service",
hostname: "api.paseo.localhost",
lifecycle: "running",
health: "unhealthy",
port: 4000,
}),
script({
scriptName: "worker",
type: "service",
hostname: "worker.paseo.localhost",
lifecycle: "running",
health: null,
port: 5000,
}),
script({
scriptName: "old-service",
type: "service",
hostname: "old-service.paseo.localhost",
lifecycle: "stopped",
exitCode: 1,
}),
]);
expect(requirePrimaryIcon(requireRow("web")).dataset.color).toBe(
theme.colors.palette.green[500],
);
expect(requirePrimaryIcon(requireRow("api")).dataset.color).toBe(theme.colors.palette.red[500]);
expect(requirePrimaryIcon(requireRow("worker")).dataset.color).toBe(
theme.colors.palette.blue[500],
);
expect(requirePrimaryIcon(requireRow("old-service")).dataset.color).toBe(
theme.colors.foregroundMuted,
);
});
});
@@ -1,14 +1,7 @@
import { type ReactElement } from "react"; import { Fragment, useMemo, type ReactElement } from "react";
import { Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from "react-native";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { import { ChevronDown, ExternalLink, Globe, Play, SquareTerminal } from "lucide-react-native";
ChevronDown,
ExternalLink,
Globe,
LoaderCircle,
Play,
SquareTerminal,
} from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
@@ -16,44 +9,153 @@ import { useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { isNative } from "@/constants/platform";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { resolveWorkspaceScriptLink } from "@/utils/workspace-script-links"; import { resolveWorkspaceScriptLink } from "@/utils/workspace-script-links";
type Script = WorkspaceDescriptor["scripts"][number]; type ScriptActionIcon = "start" | "view";
function getScriptHealthColor(
health: Script["health"],
theme: ReturnType<typeof useUnistyles>["theme"],
): string {
if (health === "healthy") {
return theme.colors.palette.blue[500];
}
if (health === "unhealthy") {
return theme.colors.palette.red[500];
}
return theme.colors.foregroundMuted;
}
interface WorkspaceScriptsButtonProps { interface WorkspaceScriptsButtonProps {
serverId: string; serverId: string;
workspaceId: string; workspaceId: string;
scripts: WorkspaceDescriptor["scripts"]; scripts: WorkspaceDescriptor["scripts"];
liveTerminalIds?: readonly string[];
onScriptTerminalStarted?: (terminalId: string) => void;
onViewTerminal?: (terminalId: string) => void;
hideLabels?: boolean; hideLabels?: boolean;
} }
interface ScriptActionButtonProps {
accessibilityLabel: string;
disabled?: boolean;
icon: ScriptActionIcon;
label: string;
onPress: () => void;
testID: string;
}
function ScriptActionButton({
accessibilityLabel,
disabled,
icon,
label,
onPress,
testID,
}: ScriptActionButtonProps): ReactElement {
const { theme } = useUnistyles();
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID={testID}
hitSlop={4}
disabled={disabled}
onPress={(event) => {
event.stopPropagation();
onPress();
}}
style={styles.actionButton}
>
{({ hovered }) => {
const color = hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconProps = { size: 10, color };
let iconElement: ReactElement;
if (icon === "view") {
iconElement = <SquareTerminal {...iconProps} />;
} else {
iconElement = <Play {...iconProps} fill="transparent" />;
}
return (
<>
{iconElement}
<Text style={[styles.actionButtonLabel, { color }]}>{label}</Text>
</>
);
}}
</Pressable>
);
}
function stripUrlProtocol(url: string): string {
return url.replace(/^https?:\/\//, "");
}
interface HostLinkProps {
label: string;
url: string | null;
scriptName: string;
}
function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement {
const { theme } = useUnistyles();
const disabled = !url;
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={`Open ${scriptName} at ${label}`}
disabled={disabled}
hitSlop={2}
onPress={(event) => {
event.stopPropagation();
if (url) void openExternalUrl(url);
}}
style={styles.hostRow}
>
{({ hovered }) => {
const showIcon = !disabled && (hovered || isNative);
const color = hovered && !disabled ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<>
<Text style={[styles.hostLabel, { color }]} numberOfLines={1}>
{label}
</Text>
<View style={styles.hostIconSlot}>
{showIcon ? <ExternalLink size={10} color={color} /> : null}
</View>
</>
);
}}
</Pressable>
);
}
function ExitCodeBadge({ code }: { code: number }): ReactElement {
const { theme } = useUnistyles();
const color = code === 0 ? theme.colors.foregroundMuted : theme.colors.palette.red[300];
return (
<View style={styles.exitBadge}>
<Text style={[styles.exitBadgeText, { color }]}>exit {code}</Text>
</View>
);
}
interface HostLink {
key: string;
label: string;
url: string | null;
}
export function WorkspaceScriptsButton({ export function WorkspaceScriptsButton({
serverId, serverId,
workspaceId, workspaceId,
scripts, scripts,
liveTerminalIds = [],
onScriptTerminalStarted,
onViewTerminal,
hideLabels, hideLabels,
}: WorkspaceScriptsButtonProps): ReactElement | null { }: WorkspaceScriptsButtonProps): ReactElement | null {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const toast = useToast(); const toast = useToast();
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null; const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null;
const liveTerminalIdSet = useMemo(() => new Set(liveTerminalIds), [liveTerminalIds]);
const startScriptMutation = useMutation({ const startScriptMutation = useMutation({
mutationFn: async (scriptName: string) => { mutationFn: async (scriptName: string) => {
@@ -71,6 +173,11 @@ export function WorkspaceScriptsButton({
variant: "error", variant: "error",
}); });
}, },
onSuccess: (result) => {
if (result.terminalId) {
onScriptTerminalStarted?.(result.terminalId);
}
},
}); });
if (scripts.length === 0) { if (scripts.length === 0) {
@@ -111,50 +218,90 @@ export function WorkspaceScriptsButton({
testID="workspace-scripts-menu" testID="workspace-scripts-menu"
> >
<View style={styles.scriptList}> <View style={styles.scriptList}>
{scripts.map((script) => { {scripts.map((script, index) => {
const isRunning = script.lifecycle === "running"; const isRunning = script.lifecycle === "running";
const isService = (script.type ?? "service") === "service"; const isService = (script.type ?? "service") === "service";
const serviceLink = resolveWorkspaceScriptLink({ script, activeConnection });
const isLinkable = isService && isRunning && !!serviceLink.openUrl;
const exitCode = script.exitCode ?? null; const exitCode = script.exitCode ?? null;
const serviceLink = resolveWorkspaceScriptLink({ script, activeConnection });
const serviceOpenUrl = isService && isRunning ? serviceLink.openUrl : null;
const liveTerminalId =
script.terminalId && liveTerminalIdSet.has(script.terminalId)
? script.terminalId
: null;
let dotColor: string; const hostLinks: HostLink[] = [];
if (isService && isRunning) {
const routedUrl = script.proxyUrl ?? serviceLink.labelUrl;
if (routedUrl) {
hostLinks.push({
key: "proxy",
label: stripUrlProtocol(routedUrl),
url: serviceOpenUrl,
});
}
if (script.port !== null) {
const localhostLabel = `localhost:${script.port}`;
const alreadyShown = hostLinks.some((l) => l.label === localhostLabel);
if (!alreadyShown) {
hostLinks.push({
key: "localhost",
label: localhostLabel,
url: `http://localhost:${script.port}`,
});
}
}
}
let iconColor = theme.colors.foregroundMuted;
if (isService) { if (isService) {
dotColor = isRunning if (isRunning && script.health === "healthy") {
? getScriptHealthColor(script.health, theme) iconColor = theme.colors.palette.green[500];
: theme.colors.foregroundMuted; } else if (isRunning && script.health === "unhealthy") {
iconColor = theme.colors.palette.red[500];
} else if (isRunning) {
iconColor = theme.colors.palette.blue[500];
}
} else if (isRunning) { } else if (isRunning) {
dotColor = theme.colors.palette.blue[500]; iconColor = theme.colors.palette.blue[500];
} else if (exitCode === 0) { }
dotColor = theme.colors.palette.green[500];
} else if (exitCode !== null) { const ScriptIcon = isService ? Globe : SquareTerminal;
dotColor = theme.colors.palette.red[500]; const showExitBadge = !isRunning && exitCode !== null;
} else {
dotColor = theme.colors.foregroundMuted; let primaryAction: ReactElement | null = null;
if (isRunning && liveTerminalId) {
primaryAction = (
<ScriptActionButton
accessibilityLabel={`View ${script.scriptName} terminal`}
testID={`workspace-scripts-view-${script.scriptName}`}
icon="view"
label="View"
onPress={() => onViewTerminal?.(liveTerminalId)}
/>
);
} else if (!isRunning) {
primaryAction = (
<ScriptActionButton
accessibilityLabel={`Run ${script.scriptName} script`}
testID={`workspace-scripts-start-${script.scriptName}`}
disabled={startScriptMutation.isPending}
icon="start"
label="Run"
onPress={() => startScriptMutation.mutate(script.scriptName)}
/>
);
} }
return ( return (
<Pressable <Fragment key={script.scriptName}>
key={script.scriptName} {index > 0 ? <DropdownMenuSeparator /> : null}
testID={`workspace-scripts-item-${script.scriptName}`} <View
accessibilityRole={isLinkable ? "link" : undefined} testID={`workspace-scripts-item-${script.scriptName}`}
accessibilityLabel={`${script.scriptName} script`} accessibilityLabel={`${script.scriptName} script`}
style={({ hovered }) => [ style={styles.scriptItem}
styles.scriptRow, >
hovered && isLinkable && styles.scriptRowHovered, <View style={styles.scriptHeader}>
]} <ScriptIcon size={14} color={iconColor} style={styles.scriptIcon} />
onPress={
isLinkable ? () => void openExternalUrl(serviceLink.openUrl!) : undefined
}
disabled={!isLinkable}
>
{({ hovered }) => (
<>
{isService ? (
<Globe size={14} color={dotColor} style={styles.scriptIcon} />
) : (
<SquareTerminal size={14} color={dotColor} style={styles.scriptIcon} />
)}
<Text <Text
style={[ style={[
styles.scriptName, styles.scriptName,
@@ -168,78 +315,24 @@ export function WorkspaceScriptsButton({
> >
{script.scriptName} {script.scriptName}
</Text> </Text>
{isService && isRunning && serviceLink.labelUrl ? ( {showExitBadge ? <ExitCodeBadge code={exitCode} /> : null}
<Text style={styles.scriptUrl} numberOfLines={1}> <View style={styles.spacer} />
{serviceLink.labelUrl.replace(/^https?:\/\//, "")} {primaryAction}
</Text> </View>
) : !isService && !isRunning && exitCode !== null && exitCode !== 0 ? ( {hostLinks.length > 0 ? (
<Text style={styles.scriptUrl} numberOfLines={1}> <View style={styles.hostList}>
exit {exitCode} {hostLinks.map((link) => (
</Text> <HostLinkRow
) : ( key={link.key}
<View style={styles.spacer} /> label={link.label}
)} url={link.url}
{isRunning ? ( scriptName={script.scriptName}
isLinkable && hovered ? ( />
<View ))}
style={[ </View>
styles.externalLinkOverlay, ) : null}
{ </View>
backgroundImage: `linear-gradient(to right, transparent, ${theme.colors.surface2} 40%)`, </Fragment>
} as any,
]}
>
<ExternalLink size={12} color={theme.colors.foreground} />
</View>
) : null
) : (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Run ${script.scriptName} script`}
testID={`workspace-scripts-start-${script.scriptName}`}
hitSlop={4}
disabled={startScriptMutation.isPending}
onPress={(event) => {
event.stopPropagation();
startScriptMutation.mutate(script.scriptName);
}}
style={styles.startButton}
>
{({ hovered: actionHovered }) =>
startScriptMutation.isPending &&
startScriptMutation.variables === script.scriptName ? (
<LoaderCircle size={12} color={theme.colors.foregroundMuted} />
) : (
<>
<Play
size={10}
color={
actionHovered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
fill="transparent"
/>
<Text
style={[
styles.startButtonLabel,
{
color: actionHovered
? theme.colors.foreground
: theme.colors.foregroundMuted,
},
]}
>
Run
</Text>
</>
)
}
</Pressable>
)}
</>
)}
</Pressable>
); );
})} })}
</View> </View>
@@ -270,11 +363,6 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
justifyContent: "center", justifyContent: "center",
}, },
splitButtonPrimaryIconOnly: {
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
justifyContent: "center",
},
splitButtonPrimaryHovered: { splitButtonPrimaryHovered: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
@@ -293,16 +381,15 @@ const styles = StyleSheet.create((theme) => ({
scriptList: { scriptList: {
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
}, },
scriptRow: { scriptItem: {
paddingVertical: 6,
},
scriptHeader: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: 6, minHeight: 24,
minHeight: 28,
},
scriptRowHovered: {
backgroundColor: theme.colors.surface2,
}, },
scriptIcon: { scriptIcon: {
flexShrink: 0, flexShrink: 0,
@@ -311,36 +398,53 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
lineHeight: 18, lineHeight: 18,
flexShrink: 0, flexShrink: 1,
},
scriptUrl: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: 18,
flex: 1,
minWidth: 0, minWidth: 0,
textAlign: "right",
}, },
spacer: { spacer: {
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
}, },
externalLinkOverlay: { hostList: {
position: "absolute", marginTop: 2,
right: 0, paddingHorizontal: theme.spacing[3],
top: 0, },
bottom: 0, hostRow: {
paddingLeft: theme.spacing[4], flexDirection: "row",
paddingRight: theme.spacing[3], alignItems: "center",
gap: theme.spacing[1.5],
paddingVertical: 2,
minHeight: 18,
},
hostLabel: {
flexShrink: 1,
fontSize: theme.fontSize.xs,
lineHeight: 14,
},
hostIconSlot: {
width: 10,
height: 10,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
flexShrink: 0,
}, },
startButton: { exitBadge: {
paddingHorizontal: theme.spacing[1.5],
paddingVertical: 1,
borderRadius: 2,
backgroundColor: theme.colors.surface2,
},
exitBadgeText: {
fontSize: 10,
lineHeight: 12,
fontWeight: theme.fontWeight.medium,
},
actionButton: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: 3, gap: 3,
}, },
startButtonLabel: { actionButtonLabel: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
}, },
@@ -61,6 +61,7 @@ describe("normalizeWorkspaceDescriptor", () => {
lifecycle: "running" as const, lifecycle: "running" as const,
health: "healthy" as const, health: "healthy" as const,
exitCode: null, exitCode: null,
terminalId: null,
}, },
]; ];
const workspace = normalizeWorkspaceDescriptor({ const workspace = normalizeWorkspaceDescriptor({
@@ -88,6 +89,7 @@ describe("normalizeWorkspaceDescriptor", () => {
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null, exitCode: null,
terminalId: null,
}, },
]); ]);
expect(workspace.scripts).not.toBe(scripts); expect(workspace.scripts).not.toBe(scripts);
@@ -137,6 +139,7 @@ describe("mergeWorkspaces", () => {
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null, exitCode: null,
terminalId: null,
}, },
], ],
}), }),
@@ -152,6 +155,7 @@ describe("mergeWorkspaces", () => {
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null, exitCode: null,
terminalId: null,
}, },
]); ]);
}); });
@@ -263,6 +267,7 @@ describe("patchWorkspaceScripts", () => {
lifecycle: "running" as const, lifecycle: "running" as const,
health: "healthy" as const, health: "healthy" as const,
exitCode: null, exitCode: null,
terminalId: null,
}; };
const workspace = createWorkspace({ id: "/repo/main", scripts: [script] }); const workspace = createWorkspace({ id: "/repo/main", scripts: [script] });
const current = new Map([[workspace.id, workspace]]); const current = new Map([[workspace.id, workspace]]);
@@ -203,6 +203,7 @@ export interface WorkspaceTabSnapshot {
terminalsHydrated: boolean; terminalsHydrated: boolean;
activeAgentIds: Iterable<string>; activeAgentIds: Iterable<string>;
knownAgentIds: Iterable<string>; knownAgentIds: Iterable<string>;
knownTerminalIds?: Iterable<string>;
standaloneTerminalIds: Iterable<string>; standaloneTerminalIds: Iterable<string>;
hasActivePendingDraftCreate?: boolean; hasActivePendingDraftCreate?: boolean;
} }
@@ -1454,6 +1455,9 @@ export function reconcileWorkspaceTabs(
const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds); const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds);
const knownAgentIds = normalizeStringSet(snapshot.knownAgentIds); const knownAgentIds = normalizeStringSet(snapshot.knownAgentIds);
const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds); const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds);
const knownTerminalIds = snapshot.knownTerminalIds
? normalizeStringSet(snapshot.knownTerminalIds)
: standaloneTerminalIds;
const visibleAgentIds = new Set(activeAgentIds); const visibleAgentIds = new Set(activeAgentIds);
for (const agentId of pinnedAgentIds) { for (const agentId of pinnedAgentIds) {
if (knownAgentIds.has(agentId)) { if (knownAgentIds.has(agentId)) {
@@ -1537,7 +1541,7 @@ export function reconcileWorkspaceTabs(
if ( if (
isTerminalTab(tab) && isTerminalTab(tab) &&
snapshot.terminalsHydrated && snapshot.terminalsHydrated &&
!standaloneTerminalIds.has(tab.target.terminalId) !knownTerminalIds.has(tab.target.terminalId)
) { ) {
nextLayout = nextLayout =
closeTabInLayout({ closeTabInLayout({
@@ -1107,6 +1107,47 @@ describe("workspace-layout-store actions", () => {
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
}); });
it("reconcileTabs auto-opens only standalone terminals while keeping explicitly opened live terminals", () => {
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const scriptTabId = store.openTabFocused(workspaceKey, {
kind: "terminal",
terminalId: "term-script",
});
store.reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
knownAgentIds: [],
knownTerminalIds: ["term-script", "term-manual"],
standaloneTerminalIds: ["term-manual"],
hasActivePendingDraftCreate: false,
});
const tabs = useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(tabs.map((tab) => tab.tabId)).toEqual(["terminal_term-script", "terminal_term-manual"]);
expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(scriptTabId);
});
it("reconcileTabs does not auto-open live non-standalone terminals", () => {
const workspaceKey = createWorkspaceKey();
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
knownAgentIds: [],
knownTerminalIds: ["term-script"],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
});
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
});
it("explicitly opening an agent tab clears hidden intent", () => { it("explicitly opening an agent tab clears hidden intent", () => {
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
@@ -12,6 +12,7 @@ const runningService: WorkspaceScriptPayload = {
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null, exitCode: null,
terminalId: null,
}; };
function resolveLink(activeConnection: ActiveConnection | null) { function resolveLink(activeConnection: ActiveConnection | null) {
@@ -57,6 +57,7 @@ function createStubTerminalManager(
send: () => {}, send: () => {},
subscribe: () => () => {}, subscribe: () => () => {},
onExit: () => () => {}, onExit: () => () => {},
onCommandFinished: () => () => {},
getState: () => ({ getState: () => ({
rows: 1, rows: 1,
cols: 1, cols: 1,
@@ -69,6 +70,7 @@ function createStubTerminalManager(
getSize: () => ({ rows: 1, cols: 1 }), getSize: () => ({ rows: 1, cols: 1 }),
getTitle: () => undefined, getTitle: () => undefined,
getExitInfo: () => null, getExitInfo: () => null,
killAndWait: async () => {},
}; };
}, },
registerCwdEnv() {}, registerCwdEnv() {},
@@ -76,6 +78,7 @@ function createStubTerminalManager(
return undefined; return undefined;
}, },
killTerminal() {}, killTerminal() {},
async killTerminalAndWait() {},
listDirectories() { listDirectories() {
return []; return [];
}, },
@@ -8,6 +8,7 @@ import {
buildWorkspaceScriptPayloads, buildWorkspaceScriptPayloads,
createScriptStatusEmitter, createScriptStatusEmitter,
} from "./script-status-projection.js"; } from "./script-status-projection.js";
import { WorkspaceScriptPayloadSchema } from "../shared/messages.js";
import type { ScriptHealthState } from "./script-health-monitor.js"; import type { ScriptHealthState } from "./script-health-monitor.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
@@ -50,6 +51,21 @@ function buildPayloads(input: {
} }
describe("script-status-projection", () => { describe("script-status-projection", () => {
it("defaults omitted workspace script terminal ids to null", () => {
expect(
WorkspaceScriptPayloadSchema.parse({
scriptName: "typecheck",
type: "script",
hostname: "typecheck",
port: null,
proxyUrl: null,
lifecycle: "stopped",
health: null,
exitCode: 0,
}).terminalId,
).toBeNull();
});
it("projects plain scripts and services differently", () => { it("projects plain scripts and services differently", () => {
const workspaceId = "workspace-plain-and-service"; const workspaceId = "workspace-plain-and-service";
const workspace = createWorkspaceRepo({ const workspace = createWorkspaceRepo({
@@ -90,6 +106,7 @@ describe("script-status-projection", () => {
lifecycle: "stopped", lifecycle: "stopped",
health: null, health: null,
exitCode: 0, exitCode: 0,
terminalId: "term-script",
}, },
{ {
scriptName: "web", scriptName: "web",
@@ -100,6 +117,7 @@ describe("script-status-projection", () => {
lifecycle: "stopped", lifecycle: "stopped",
health: null, health: null,
exitCode: null, exitCode: null,
terminalId: null,
}, },
]); ]);
} finally { } finally {
@@ -121,19 +139,19 @@ describe("script-status-projection", () => {
const runtimeStore = new WorkspaceScriptRuntimeStore(); const runtimeStore = new WorkspaceScriptRuntimeStore();
try { try {
expect( const payloads = buildPayloads({
buildPayloads({ workspaceId,
workspaceId, workspaceDirectory: workspace.repoDir,
workspaceDirectory: workspace.repoDir, routeStore,
routeStore, runtimeStore,
runtimeStore, daemonPort: 6767,
daemonPort: 6767, gitMetadata: {
gitMetadata: { projectSlug: "service-provided",
projectSlug: "service-provided", currentBranch: "feature/from-service",
currentBranch: "feature/from-service", },
}, });
}),
).toEqual([ expect(payloads).toEqual([
{ {
scriptName: "web", scriptName: "web",
type: "service", type: "service",
@@ -143,6 +161,7 @@ describe("script-status-projection", () => {
lifecycle: "stopped", lifecycle: "stopped",
health: null, health: null,
exitCode: null, exitCode: null,
terminalId: null,
}, },
]); ]);
} finally { } finally {
@@ -198,6 +217,7 @@ describe("script-status-projection", () => {
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null, exitCode: null,
terminalId: "term-web",
}, },
]); ]);
} finally { } finally {
@@ -252,6 +272,7 @@ describe("script-status-projection", () => {
lifecycle: "running", lifecycle: "running",
health: null, health: null,
exitCode: null, exitCode: null,
terminalId: "term-web",
}, },
]); ]);
} finally { } finally {
@@ -299,6 +320,7 @@ describe("script-status-projection", () => {
lifecycle: "running", lifecycle: "running",
health: null, health: null,
exitCode: null, exitCode: null,
terminalId: "term-docs",
}, },
]); ]);
} finally { } finally {
@@ -339,6 +361,7 @@ describe("script-status-projection", () => {
lifecycle: "running", lifecycle: "running",
health: null, health: null,
exitCode: null, exitCode: null,
terminalId: "term-typecheck",
}, },
]); ]);
} finally { } finally {
@@ -409,6 +432,7 @@ describe("script-status-projection", () => {
lifecycle: "running", lifecycle: "running",
health: "healthy", health: "healthy",
exitCode: null, exitCode: null,
terminalId: "term-api",
}, },
{ {
scriptName: "typecheck", scriptName: "typecheck",
@@ -419,6 +443,7 @@ describe("script-status-projection", () => {
lifecycle: "stopped", lifecycle: "stopped",
health: null, health: null,
exitCode: null, exitCode: null,
terminalId: null,
}, },
], ],
}, },
@@ -103,6 +103,7 @@ export function buildWorkspaceScriptPayloads(
lifecycle: runtimeEntry?.lifecycle ?? "stopped", lifecycle: runtimeEntry?.lifecycle ?? "stopped",
health: type === "service" ? toWireHealth(options.resolveHealth?.(hostname) ?? null) : null, health: type === "service" ? toWireHealth(options.resolveHealth?.(hostname) ?? null) : null,
exitCode: runtimeEntry?.exitCode ?? null, exitCode: runtimeEntry?.exitCode ?? null,
terminalId: runtimeEntry?.terminalId ?? null,
}); });
} }
@@ -134,6 +135,7 @@ export function buildWorkspaceScriptPayloads(
? toWireHealth(options.resolveHealth?.(hostname) ?? null) ? toWireHealth(options.resolveHealth?.(hostname) ?? null)
: null, : null,
exitCode: runtimeEntry.exitCode, exitCode: runtimeEntry.exitCode,
terminalId: runtimeEntry.terminalId,
}); });
} }
@@ -427,6 +427,11 @@ describe("runAsyncWorktreeBootstrap", () => {
}; };
}, },
onExit: () => () => {}, onExit: () => () => {},
onCommandFinished: () => () => {},
onTitleChange: () => () => {},
getSize: () => ({ rows: 0, cols: 0 }),
getTitle: () => undefined,
getExitInfo: () => null,
getState: () => ({ getState: () => ({
rows: 0, rows: 0,
cols: 0, cols: 0,
@@ -435,6 +440,7 @@ describe("runAsyncWorktreeBootstrap", () => {
cursor: { row: 0, col: 0 }, cursor: { row: 0, col: 0 },
}), }),
kill: () => {}, kill: () => {},
killAndWait: async () => {},
}; };
}, },
registerCwdEnv() {}, registerCwdEnv() {},
@@ -442,6 +448,7 @@ describe("runAsyncWorktreeBootstrap", () => {
return undefined; return undefined;
}, },
killTerminal() {}, killTerminal() {},
async killTerminalAndWait() {},
listDirectories() { listDirectories() {
return []; return [];
}, },
@@ -508,6 +515,11 @@ describe("runAsyncWorktreeBootstrap", () => {
send: () => {}, send: () => {},
subscribe: () => () => {}, subscribe: () => () => {},
onExit: () => () => {}, onExit: () => () => {},
onCommandFinished: () => () => {},
onTitleChange: () => () => {},
getSize: () => ({ rows: 1, cols: 1 }),
getTitle: () => undefined,
getExitInfo: () => null,
getState: () => ({ getState: () => ({
rows: 1, rows: 1,
cols: 1, cols: 1,
@@ -516,6 +528,7 @@ describe("runAsyncWorktreeBootstrap", () => {
cursor: { row: 0, col: 0 }, cursor: { row: 0, col: 0 },
}), }),
kill: () => {}, kill: () => {},
killAndWait: async () => {},
}; };
}, },
registerCwdEnv(options) { registerCwdEnv(options) {
@@ -525,6 +538,7 @@ describe("runAsyncWorktreeBootstrap", () => {
return undefined; return undefined;
}, },
killTerminal() {}, killTerminal() {},
async killTerminalAndWait() {},
listDirectories() { listDirectories() {
return []; return [];
}, },
@@ -563,11 +577,15 @@ describe("runAsyncWorktreeBootstrap", () => {
interface CreateTerminalCall { interface CreateTerminalCall {
cwd: string; cwd: string;
name?: string; name?: string;
title?: string;
env?: Record<string, string>; env?: Record<string, string>;
} }
interface StubTerminalRecord { interface StubTerminalRecord {
id: string;
triggerExit: (exitCode: number) => void; triggerExit: (exitCode: number) => void;
triggerCommandFinished: (exitCode: number) => void;
sentInputs: string[];
} }
function createStubTerminalManager( function createStubTerminalManager(
@@ -575,6 +593,7 @@ describe("runAsyncWorktreeBootstrap", () => {
terminalRecords: StubTerminalRecord[] = [], terminalRecords: StubTerminalRecord[] = [],
): TerminalManager { ): TerminalManager {
let terminalCounter = 0; let terminalCounter = 0;
const sessionsById = new Map<string, TerminalSession>();
return { return {
async getTerminals() { async getTerminals() {
@@ -583,9 +602,16 @@ describe("runAsyncWorktreeBootstrap", () => {
async createTerminal(options: CreateTerminalCall): Promise<TerminalSession> { async createTerminal(options: CreateTerminalCall): Promise<TerminalSession> {
createTerminalCalls.push(options); createTerminalCalls.push(options);
terminalCounter += 1; terminalCounter += 1;
const terminalId = `term-service-${terminalCounter}`; const terminalId = `term-${terminalCounter}`;
let exitHandler: ((info: { exitCode: number | null }) => void) | null = null; let exitHandler: ((info: { exitCode: number | null }) => void) | null = null;
let commandFinishedHandler: ((info: { exitCode: number | null }) => void) | null = null;
const sentInputs: string[] = [];
terminalRecords.push({ terminalRecords.push({
id: terminalId,
sentInputs,
triggerCommandFinished: (exitCode) => {
commandFinishedHandler?.({ exitCode });
},
triggerExit: (exitCode) => { triggerExit: (exitCode) => {
if (exitHandler) { if (exitHandler) {
exitHandler({ exitCode }); exitHandler({ exitCode });
@@ -593,11 +619,15 @@ describe("runAsyncWorktreeBootstrap", () => {
}, },
}); });
return { const session: TerminalSession = {
id: terminalId, id: terminalId,
name: options.name ?? "Terminal", name: options.name ?? "Terminal",
cwd: options.cwd, cwd: options.cwd,
send: () => {}, send: (message) => {
if (message.type === "input") {
sentInputs.push(message.data);
}
},
subscribe: () => () => {}, subscribe: () => () => {},
onExit: (handler) => { onExit: (handler) => {
exitHandler = handler; exitHandler = handler;
@@ -607,6 +637,14 @@ describe("runAsyncWorktreeBootstrap", () => {
} }
}; };
}, },
onCommandFinished: (handler) => {
commandFinishedHandler = handler;
return () => {
if (commandFinishedHandler === handler) {
commandFinishedHandler = null;
}
};
},
getState: () => ({ getState: () => ({
rows: 1, rows: 1,
cols: 1, cols: 1,
@@ -621,10 +659,12 @@ describe("runAsyncWorktreeBootstrap", () => {
getExitInfo: () => null, getExitInfo: () => null,
killAndWait: async () => {}, killAndWait: async () => {},
}; };
sessionsById.set(terminalId, session);
return session;
}, },
registerCwdEnv() {}, registerCwdEnv() {},
getTerminal() { getTerminal(id) {
return undefined; return sessionsById.get(id);
}, },
killTerminal() {}, killTerminal() {},
async killTerminalAndWait() {}, async killTerminalAndWait() {},
@@ -653,26 +693,29 @@ describe("runAsyncWorktreeBootstrap", () => {
return env; return env;
} }
it("spawns plain scripts without env injection or routes", async () => { function commitPaseoScripts(
writeFileSync( scripts: Record<string, { command: string; type?: "script" | "service" }>,
join(repoDir, "paseo.json"), message = "add script config",
JSON.stringify({ ): void {
scripts: { writeFileSync(join(repoDir, "paseo.json"), JSON.stringify({ scripts }));
web: {
command: "npm run dev",
},
},
}),
);
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" }); execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add script config'", { execSync(`git -c commit.gpgsign=false commit -m ${JSON.stringify(message)}`, {
cwd: repoDir, cwd: repoDir,
stdio: "pipe", stdio: "pipe",
}); });
}
it("spawns plain scripts in persistent shell terminals without env injection or routes", async () => {
commitPaseoScripts({
web: {
command: "npm run dev",
},
});
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore(); const runtimeStore = new WorkspaceScriptRuntimeStore();
const createTerminalCalls: CreateTerminalCall[] = []; const createTerminalCalls: CreateTerminalCall[] = [];
const terminalRecords: StubTerminalRecord[] = [];
const results = await spawnWorktreeScripts({ const results = await spawnWorktreeScripts({
repoRoot: repoDir, repoRoot: repoDir,
@@ -681,7 +724,7 @@ describe("runAsyncWorktreeBootstrap", () => {
daemonPort: null, daemonPort: null,
routeStore, routeStore,
runtimeStore, runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls), terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
}); });
expect(results).toHaveLength(1); expect(results).toHaveLength(1);
@@ -689,40 +732,277 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(createTerminalCalls).toHaveLength(1); expect(createTerminalCalls).toHaveLength(1);
expect(createTerminalCalls[0]?.cwd).toBe(repoDir); expect(createTerminalCalls[0]?.cwd).toBe(repoDir);
expect(createTerminalCalls[0]?.name).toBe("web"); expect(createTerminalCalls[0]?.name).toBe("web");
expect(createTerminalCalls[0]?.title).toBe("web");
expect(createTerminalCalls[0]?.env).toBeUndefined(); expect(createTerminalCalls[0]?.env).toBeUndefined();
expect(terminalRecords[0]?.sentInputs).toEqual(["npm run dev\r"]);
expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "web" })).toMatchObject({ expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "web" })).toMatchObject({
type: "script", type: "script",
lifecycle: "running", lifecycle: "running",
exitCode: null, exitCode: null,
terminalId: "term-service-1", terminalId: "term-1",
}); });
}); });
it("spawns services with route registration and injected peer service env vars", async () => { it("records plain script exit codes from shell command completion without terminal exit", async () => {
writeFileSync( commitPaseoScripts(
join(repoDir, "paseo.json"), {
JSON.stringify({ typecheck: {
scripts: { command: 'node -e "process.exit(7)"',
api: {
type: "service",
command: "npm run api",
},
"app-server": {
type: "service",
command: "npm run app",
},
}, },
}), },
"add one-off script config",
); );
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add service script config'", {
cwd: repoDir,
stdio: "pipe",
});
const routeStore = new ScriptRouteStore(); const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore(); const runtimeStore = new WorkspaceScriptRuntimeStore();
const createTerminalCalls: CreateTerminalCall[] = []; const createTerminalCalls: CreateTerminalCall[] = [];
const terminalRecords: StubTerminalRecord[] = [];
const terminalManager = createStubTerminalManager(createTerminalCalls, terminalRecords);
const result = await spawnWorkspaceScript({
repoRoot: repoDir,
workspaceId: repoDir,
projectSlug: "repo",
branchName: "feature-script-exit",
scriptName: "typecheck",
daemonPort: null,
routeStore,
runtimeStore,
terminalManager,
});
expect(createTerminalCalls).toHaveLength(1);
terminalRecords[0]?.triggerCommandFinished(7);
expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "typecheck" })).toMatchObject({
type: "script",
lifecycle: "stopped",
terminalId: result.terminalId,
exitCode: 7,
});
expect(terminalRecords[0]?.sentInputs).toEqual(['node -e "process.exit(7)"\r']);
});
it("reuses a live terminal when rerunning after plain script completion", async () => {
commitPaseoScripts(
{
typecheck: {
command: "npm run typecheck",
},
},
"add one-off script config",
);
const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
const createTerminalCalls: CreateTerminalCall[] = [];
const terminalRecords: StubTerminalRecord[] = [];
const terminalManager = createStubTerminalManager(createTerminalCalls, terminalRecords);
const firstResult = await spawnWorkspaceScript({
repoRoot: repoDir,
workspaceId: repoDir,
projectSlug: "repo",
branchName: "feature-script-rerun",
scriptName: "typecheck",
daemonPort: null,
routeStore,
runtimeStore,
terminalManager,
});
terminalRecords[0]?.triggerCommandFinished(7);
expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "typecheck" })).toMatchObject({
type: "script",
lifecycle: "stopped",
terminalId: firstResult.terminalId,
exitCode: 7,
});
const secondResult = await spawnWorkspaceScript({
repoRoot: repoDir,
workspaceId: repoDir,
projectSlug: "repo",
branchName: "feature-script-rerun",
scriptName: "typecheck",
daemonPort: null,
routeStore,
runtimeStore,
terminalManager,
});
expect(secondResult.terminalId).toBe(firstResult.terminalId);
expect(createTerminalCalls).toHaveLength(1);
expect(terminalRecords[0]?.sentInputs).toEqual(["npm run typecheck\r", "npm run typecheck\r"]);
expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "typecheck" })).toMatchObject({
type: "script",
lifecycle: "running",
terminalId: firstResult.terminalId,
exitCode: null,
});
terminalRecords[0]?.triggerCommandFinished(0);
expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "typecheck" })).toMatchObject({
type: "script",
lifecycle: "stopped",
terminalId: firstResult.terminalId,
exitCode: 0,
});
});
it("tracks command completion when reusing a live terminal from a stopped plain script entry", async () => {
commitPaseoScripts(
{
typecheck: {
command: "npm run typecheck",
},
},
"add one-off script config",
);
const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
const createTerminalCalls: CreateTerminalCall[] = [];
const terminalRecords: StubTerminalRecord[] = [];
const terminalManager = createStubTerminalManager(createTerminalCalls, terminalRecords);
const existingTerminal = await terminalManager.createTerminal({
cwd: repoDir,
name: "typecheck",
title: "typecheck",
});
runtimeStore.set({
workspaceId: repoDir,
scriptName: "typecheck",
type: "script",
lifecycle: "stopped",
terminalId: existingTerminal.id,
exitCode: 1,
});
const result = await spawnWorkspaceScript({
repoRoot: repoDir,
workspaceId: repoDir,
projectSlug: "repo",
branchName: "feature-script-existing-terminal",
scriptName: "typecheck",
daemonPort: null,
routeStore,
runtimeStore,
terminalManager,
});
expect(result.terminalId).toBe(existingTerminal.id);
expect(createTerminalCalls).toHaveLength(1);
expect(terminalRecords[0]?.sentInputs).toEqual(["npm run typecheck\r"]);
terminalRecords[0]?.triggerCommandFinished(0);
expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "typecheck" })).toMatchObject({
type: "script",
lifecycle: "stopped",
terminalId: existingTerminal.id,
exitCode: 0,
});
});
it("uses terminal exit as a fallback before shell command completion", async () => {
commitPaseoScripts(
{
typecheck: {
command: "npm run typecheck",
},
},
"add one-off script config",
);
const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
const createTerminalCalls: CreateTerminalCall[] = [];
const terminalRecords: StubTerminalRecord[] = [];
const terminalManager = createStubTerminalManager(createTerminalCalls, terminalRecords);
const result = await spawnWorkspaceScript({
repoRoot: repoDir,
workspaceId: repoDir,
projectSlug: "repo",
branchName: "feature-script-terminal-exit",
scriptName: "typecheck",
daemonPort: null,
routeStore,
runtimeStore,
terminalManager,
});
terminalRecords[0]?.triggerExit(9);
expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "typecheck" })).toMatchObject({
type: "script",
lifecycle: "stopped",
terminalId: result.terminalId,
exitCode: 9,
});
});
it("rejects duplicate plain script starts while running", async () => {
commitPaseoScripts(
{
typecheck: {
command: 'node -e "setTimeout(() => {}, 30000)"',
},
},
"add long-running one-off script",
);
const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
const createTerminalCalls: CreateTerminalCall[] = [];
const terminalManager = createStubTerminalManager(createTerminalCalls);
await spawnWorkspaceScript({
repoRoot: repoDir,
workspaceId: repoDir,
projectSlug: "repo",
branchName: "feature-script-duplicate",
scriptName: "typecheck",
daemonPort: null,
routeStore,
runtimeStore,
terminalManager,
});
await expect(
spawnWorkspaceScript({
repoRoot: repoDir,
workspaceId: repoDir,
projectSlug: "repo",
branchName: "feature-script-duplicate",
scriptName: "typecheck",
daemonPort: null,
routeStore,
runtimeStore,
terminalManager,
}),
).rejects.toThrow("Script 'typecheck' is already running");
expect(createTerminalCalls).toHaveLength(1);
});
it("spawns services with route registration and injected peer service env vars", async () => {
commitPaseoScripts(
{
api: {
type: "service",
command: "npm run api",
},
"app-server": {
type: "service",
command: "npm run app",
},
},
"add service script config",
);
const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
const createTerminalCalls: CreateTerminalCall[] = [];
const terminalRecords: StubTerminalRecord[] = [];
const result = await spawnWorkspaceScript({ const result = await spawnWorkspaceScript({
repoRoot: repoDir, repoRoot: repoDir,
@@ -733,7 +1013,7 @@ describe("runAsyncWorktreeBootstrap", () => {
daemonPort: 6767, daemonPort: 6767,
routeStore, routeStore,
runtimeStore, runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls), terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
}); });
expect(result.scriptName).toBe("api"); expect(result.scriptName).toBe("api");
@@ -749,6 +1029,7 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(createTerminalCalls).toHaveLength(1); expect(createTerminalCalls).toHaveLength(1);
expect(createTerminalCalls[0]?.cwd).toBe(repoDir); expect(createTerminalCalls[0]?.cwd).toBe(repoDir);
expect(createTerminalCalls[0]?.name).toBe("api"); expect(createTerminalCalls[0]?.name).toBe("api");
expect(terminalRecords[0]?.sentInputs).toEqual(["npm run api\r"]);
expect(createTerminalCalls[0]?.env).not.toHaveProperty("PORT"); expect(createTerminalCalls[0]?.env).not.toHaveProperty("PORT");
expect(createTerminalCalls[0]?.env?.PASEO_PORT).toEqual(expect.any(String)); expect(createTerminalCalls[0]?.env?.PASEO_PORT).toEqual(expect.any(String));
expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1"); expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1");
@@ -686,11 +686,6 @@ export interface WorktreeScriptResult {
terminalId: string; terminalId: string;
} }
interface WorkspaceServiceDeclaration {
scriptName: string;
port?: number;
}
interface SpawnWorkspaceScriptOptions { interface SpawnWorkspaceScriptOptions {
repoRoot: string; repoRoot: string;
workspaceId: string; workspaceId: string;
@@ -730,26 +725,28 @@ export async function spawnWorkspaceScript(
} }
const serviceScript = isServiceScript(config); const serviceScript = isServiceScript(config);
const scriptType = serviceScript ? "service" : "script";
let hostname: string | null = null; let hostname: string | null = null;
let port: number | null = null; let port: number | null = null;
let terminal: TerminalSession | null = null;
let runtimeRegistered = false; let runtimeRegistered = false;
let routeRegistered = false; let routeRegistered = false;
let disposeLifecycleListeners: (() => void) | null = null;
try { try {
if (runtimeStore.isRunning({ workspaceId, scriptName })) { if (runtimeStore.isRunning({ workspaceId, scriptName })) {
throw new Error(`Script '${scriptName}' is already running`); throw new Error(`Script '${scriptName}' is already running`);
} }
const existingRuntimeEntry = runtimeStore.get({ workspaceId, scriptName });
let env: Record<string, string> | undefined; let env: Record<string, string> | undefined;
if (serviceScript) { if (serviceScript) {
const serviceHostname = buildScriptHostname({ hostname = buildScriptHostname({
projectSlug, projectSlug,
branchName, branchName,
scriptName, scriptName,
}); });
hostname = serviceHostname;
const serviceDeclarations: WorkspaceServiceDeclaration[] = []; const serviceDeclarations: Array<{ scriptName: string; port?: number }> = [];
for (const [configuredScriptName, scriptConfig] of scriptConfigs) { for (const [configuredScriptName, scriptConfig] of scriptConfigs) {
if (isServiceScript(scriptConfig)) { if (isServiceScript(scriptConfig)) {
serviceDeclarations.push({ serviceDeclarations.push({
@@ -767,8 +764,7 @@ export async function spawnWorkspaceScript(
services: serviceDeclarations, services: serviceDeclarations,
allocatePort: findFreePort, allocatePort: findFreePort,
}); });
const existingRuntimeEntry = runtimeStore.get({ workspaceId, scriptName }); port =
const servicePort =
existingRuntimeEntry?.lifecycle === "stopped" existingRuntimeEntry?.lifecycle === "stopped"
? await refreshWorkspaceServicePort({ ? await refreshWorkspaceServicePort({
workspaceId, workspaceId,
@@ -777,13 +773,11 @@ export async function spawnWorkspaceScript(
}) })
: requirePlannedWorkspaceServicePort(plannedPorts, scriptName); : requirePlannedWorkspaceServicePort(plannedPorts, scriptName);
port = servicePort;
const peers: WorkspaceServicePeer[] = []; const peers: WorkspaceServicePeer[] = [];
for (const [peerScriptName, peerPort] of plannedPorts) { for (const [peerScriptName, peerPort] of plannedPorts) {
peers.push({ peers.push({
scriptName: peerScriptName, scriptName: peerScriptName,
port: peerScriptName === scriptName ? servicePort : peerPort, port: peerScriptName === scriptName ? port : peerPort,
}); });
} }
@@ -797,8 +791,8 @@ export async function spawnWorkspaceScript(
}); });
routeStore.registerRoute({ routeStore.registerRoute({
hostname: serviceHostname, hostname,
port: servicePort, port,
workspaceId, workspaceId,
projectSlug, projectSlug,
scriptName, scriptName,
@@ -806,47 +800,82 @@ export async function spawnWorkspaceScript(
routeRegistered = true; routeRegistered = true;
} }
const createdTerminal = await terminalManager.createTerminal({ let reusableTerminal: TerminalSession | null = null;
cwd: repoRoot, if (!serviceScript && existingRuntimeEntry?.terminalId) {
name: scriptName, reusableTerminal = terminalManager.getTerminal(existingRuntimeEntry.terminalId) ?? null;
env, }
}); const terminal =
terminal = createdTerminal; reusableTerminal ??
(await terminalManager.createTerminal({
cwd: repoRoot,
name: scriptName,
title: scriptName,
env,
}));
runtimeStore.set({ runtimeStore.set({
workspaceId, workspaceId,
scriptName, scriptName,
type: serviceScript ? "service" : "script", type: scriptType,
lifecycle: "running", lifecycle: "running",
terminalId: createdTerminal.id, terminalId: terminal.id,
exitCode: null, exitCode: null,
}); });
runtimeRegistered = true; runtimeRegistered = true;
terminal.onExit((info) => { const stopRuntimeIfCurrent = (input: { exitCode: number | null; removeRoute: boolean }) => {
if (hostname) { const current = runtimeStore.get({ workspaceId, scriptName });
if (current?.terminalId !== terminal.id || current.lifecycle !== "running") {
return;
}
disposeLifecycleListeners?.();
disposeLifecycleListeners = null;
if (input.removeRoute && hostname) {
routeStore.removeRouteForWorkspaceScript({ workspaceId, scriptName }); routeStore.removeRouteForWorkspaceScript({ workspaceId, scriptName });
} }
runtimeStore.set({ runtimeStore.set({
workspaceId, workspaceId,
scriptName, scriptName,
type: serviceScript ? "service" : "script", type: scriptType,
lifecycle: "stopped", lifecycle: "stopped",
terminalId: createdTerminal.id, terminalId: terminal.id,
exitCode: info.exitCode, exitCode: input.exitCode,
}); });
onLifecycleChanged?.(); onLifecycleChanged?.();
logger?.info( logger?.info(
{ {
scriptName, scriptName,
hostname, hostname,
exitCode: info.exitCode, exitCode: input.exitCode,
terminalId: createdTerminal.id, terminalId: terminal.id,
}, },
"Stopped worktree script", "Stopped worktree script",
); );
};
const unsubscribeExit = terminal.onExit((info) => {
stopRuntimeIfCurrent({
exitCode: info.exitCode,
removeRoute: true,
});
}); });
await waitForTerminalBootstrapReadiness(terminal); let unsubscribeCommandFinished: (() => void) | null = null;
if (!serviceScript) {
unsubscribeCommandFinished = terminal.onCommandFinished((info) => {
stopRuntimeIfCurrent({ exitCode: info.exitCode, removeRoute: false });
});
}
disposeLifecycleListeners = () => {
unsubscribeExit();
unsubscribeCommandFinished?.();
};
if (!reusableTerminal) {
await waitForTerminalBootstrapReadiness(terminal);
}
terminal.send({ type: "input", data: `${config.command}\r` }); terminal.send({ type: "input", data: `${config.command}\r` });
logger?.info( logger?.info(
@@ -855,7 +884,7 @@ export async function spawnWorkspaceScript(
hostname, hostname,
port, port,
terminalId: terminal.id, terminalId: terminal.id,
type: serviceScript ? "service" : "script", type: scriptType,
}, },
serviceScript serviceScript
? `Registered script proxy: ${hostname} -> 127.0.0.1:${port}` ? `Registered script proxy: ${hostname} -> 127.0.0.1:${port}`
@@ -870,6 +899,7 @@ export async function spawnWorkspaceScript(
terminalId: terminal.id, terminalId: terminal.id,
}; };
} catch (error) { } catch (error) {
disposeLifecycleListeners?.();
if (routeRegistered && hostname) { if (routeRegistered && hostname) {
routeStore.removeRoute(hostname); routeStore.removeRoute(hostname);
} }
@@ -143,6 +143,7 @@ function createTerminalManagerStub(options?: {
}), }),
subscribe: () => () => {}, subscribe: () => () => {},
onExit: () => () => {}, onExit: () => () => {},
onCommandFinished: () => () => {},
onTitleChange: () => () => {}, onTitleChange: () => () => {},
send: (message: { type: string; data: string }) => { send: (message: { type: string; data: string }) => {
if (message.type === "input") { if (message.type === "input") {
+1
View File
@@ -1991,6 +1991,7 @@ export const WorkspaceScriptPayloadSchema = z.object({
lifecycle: WorkspaceScriptLifecycleSchema, lifecycle: WorkspaceScriptLifecycleSchema,
health: WorkspaceScriptHealthSchema.nullable(), health: WorkspaceScriptHealthSchema.nullable(),
exitCode: z.number().nullable().optional().default(null), exitCode: z.number().nullable().optional().default(null),
terminalId: z.string().nullable().optional().default(null),
}); });
const WorkspaceGitRuntimePayloadSchema = z const WorkspaceGitRuntimePayloadSchema = z
@@ -5,11 +5,26 @@ typeset -g _PASEO_ZSH_INTEGRATION_LOADED=1
autoload -Uz add-zsh-hook autoload -Uz add-zsh-hook
typeset -g _PASEO_ZSH_COMMAND_ACTIVE=0
function _paseo_osc633() {
printf '\e]633;%s\a' "$1"
}
function _paseo_precmd() { function _paseo_precmd() {
local command_status=$?
if [[ "$_PASEO_ZSH_COMMAND_ACTIVE" == "1" ]]; then
_paseo_osc633 "D;${command_status}"
_PASEO_ZSH_COMMAND_ACTIVE=0
fi
printf '\e]2;%s\a' "${PWD/#$HOME/~}" printf '\e]2;%s\a' "${PWD/#$HOME/~}"
_paseo_osc633 "A"
} }
function _paseo_preexec() { function _paseo_preexec() {
_PASEO_ZSH_COMMAND_ACTIVE=1
_paseo_osc633 "B"
_paseo_osc633 "C"
printf '\e]2;%s\a' "$1" printf '\e]2;%s\a' "$1"
} }
@@ -21,6 +21,7 @@ export interface TerminalManager {
id?: string; id?: string;
cwd: string; cwd: string;
name?: string; name?: string;
title?: string;
env?: Record<string, string>; env?: Record<string, string>;
command?: string; command?: string;
args?: string[]; args?: string[];
@@ -160,6 +161,7 @@ export function createTerminalManager(): TerminalManager {
id?: string; id?: string;
cwd: string; cwd: string;
name?: string; name?: string;
title?: string;
env?: Record<string, string>; env?: Record<string, string>;
command?: string; command?: string;
args?: string[]; args?: string[];
@@ -178,6 +180,7 @@ export function createTerminalManager(): TerminalManager {
...(options.id ? { id: options.id } : {}), ...(options.id ? { id: options.id } : {}),
cwd: options.cwd, cwd: options.cwd,
name: options.name ?? defaultName, name: options.name ?? defaultName,
...(options.title ? { title: options.title } : {}),
...(options.command ? { command: options.command } : {}), ...(options.command ? { command: options.command } : {}),
...(options.args ? { args: options.args } : {}), ...(options.args ? { args: options.args } : {}),
...(mergedEnv ? { env: mergedEnv } : {}), ...(mergedEnv ? { env: mergedEnv } : {}),
@@ -443,6 +443,87 @@ describe("Terminal", () => {
unsubscribeTitle(); unsubscribeTitle();
}); });
it("keeps preset titles instead of applying OSC title updates", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
title: "typecheck",
}),
);
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
await new Promise((resolve) => setTimeout(resolve, 150));
expect(session.getTitle()).toBe("typecheck");
expect(session.getState().title).toBe("typecheck");
});
it("emits command completion from VS Code OSC 633 without visible output", async () => {
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-"));
temporaryDirs.push(packageRoot);
const scriptPath = join(packageRoot, "emit-command-finished.sh");
writeFileSync(scriptPath, "#!/bin/sh\nprintf '\\033]633;D;7\\007'\n");
chmodSync(scriptPath, 0o755);
const session = trackSession(
await createTerminal({
cwd: packageRoot,
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
const commandCompletions: Array<number | null> = [];
const unsubscribeCommandFinished = session.onCommandFinished((info) => {
commandCompletions.push(info.exitCode);
});
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "./emit-command-finished.sh\r" });
await waitForState(session, () => commandCompletions.length === 1);
expect(commandCompletions).toEqual([7]);
expect(getLines(session.getState()).join("\n")).not.toContain("633;D;7");
unsubscribeCommandFinished();
});
it("ignores malformed VS Code OSC 633 command completion payloads", async () => {
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-malformed-"));
temporaryDirs.push(packageRoot);
const scriptPath = join(packageRoot, "emit-malformed-command-finished.sh");
writeFileSync(
scriptPath,
"#!/bin/sh\nprintf '\\033]633;D;garbage\\007\\033]633;D;8;extra\\007\\033]633;D;3\\007'\n",
);
chmodSync(scriptPath, 0o755);
const session = trackSession(
await createTerminal({
cwd: packageRoot,
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
const commandCompletions: Array<number | null> = [];
const unsubscribeCommandFinished = session.onCommandFinished((info) => {
commandCompletions.push(info.exitCode);
});
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "./emit-malformed-command-finished.sh\r" });
await waitForState(session, () => commandCompletions.length === 1);
expect(commandCompletions).toEqual([3]);
expect(getLines(session.getState()).join("\n")).not.toContain("633;D;garbage");
unsubscribeCommandFinished();
});
it("debounces rapid title changes and emits only the final title", async () => { it("debounces rapid title changes and emits only the final title", async () => {
const session = trackSession( const session = trackSession(
await createTerminal({ await createTerminal({
@@ -506,6 +587,42 @@ describe("Terminal", () => {
await waitForTitle(session, (title) => title === "sleep 1"); await waitForTitle(session, (title) => title === "sleep 1");
await waitForTitle(session, (title) => title === "~/dev/faro", 4000); await waitForTitle(session, (title) => title === "~/dev/faro", 4000);
}); });
it.skipIf(!hasZsh)("emits zsh shell integration command completion", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-command-finished-home-"));
temporaryDirs.push(homeDir);
const realZdotdir = join(homeDir, ".config", "zsh");
const workingDir = join(homeDir, "dev", "faro");
mkdirSync(realZdotdir, { recursive: true });
mkdirSync(workingDir, { recursive: true });
writeFileSync(join(realZdotdir, ".zshenv"), "");
writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n");
const session = trackSession(
await createTerminal({
cwd: workingDir,
shell: "/bin/zsh",
env: {
HOME: homeDir,
ZDOTDIR: realZdotdir,
},
}),
);
const commandCompletions: Array<number | null> = [];
const unsubscribeCommandFinished = session.onCommandFinished((info) => {
commandCompletions.push(info.exitCode);
});
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "false\r" });
await waitForState(session, () => commandCompletions.includes(1));
expect(commandCompletions).toEqual([1]);
expect(getLines(session.getState()).join("\n")).not.toContain("633;D;1");
unsubscribeCommandFinished();
});
}); });
describe("colors", () => { describe("colors", () => {
+71 -15
View File
@@ -21,6 +21,10 @@ export interface TerminalExitInfo {
lastOutputLines: string[]; lastOutputLines: string[];
} }
export interface TerminalCommandFinishedInfo {
exitCode: number | null;
}
export type ClientMessage = export type ClientMessage =
| { type: "input"; data: string } | { type: "input"; data: string }
| { type: "resize"; rows: number; cols: number } | { type: "resize"; rows: number; cols: number }
@@ -38,6 +42,7 @@ export interface TerminalSession {
send(msg: ClientMessage): void; send(msg: ClientMessage): void;
subscribe(listener: (msg: ServerMessage) => void): () => void; subscribe(listener: (msg: ServerMessage) => void): () => void;
onExit(listener: (info: TerminalExitInfo) => void): () => void; onExit(listener: (info: TerminalExitInfo) => void): () => void;
onCommandFinished(listener: (info: TerminalCommandFinishedInfo) => void): () => void;
onTitleChange(listener: (title?: string) => void): () => void; onTitleChange(listener: (title?: string) => void): () => void;
getSize(): { rows: number; cols: number }; getSize(): { rows: number; cols: number };
getState(): TerminalState; getState(): TerminalState;
@@ -47,6 +52,23 @@ export interface TerminalSession {
killAndWait(options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number }): Promise<void>; killAndWait(options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number }): Promise<void>;
} }
function parseCommandFinishedOsc(data: string): TerminalCommandFinishedInfo | null {
// OSC 633 is terminal control traffic, but a foreground command can still
// print arbitrary control bytes. Keep this boundary to the exact VS Code
// command-finished shape emitted by our shell integration.
const parts = data.split(";");
if (parts[0] !== "D") {
return null;
}
if (parts.length === 1) {
return { exitCode: null };
}
if (parts.length !== 2 || !/^-?\d+$/.test(parts[1])) {
return null;
}
return { exitCode: Number(parts[1]) };
}
export interface CreateTerminalOptions { export interface CreateTerminalOptions {
id?: string; id?: string;
cwd: string; cwd: string;
@@ -55,6 +77,7 @@ export interface CreateTerminalOptions {
rows?: number; rows?: number;
cols?: number; cols?: number;
name?: string; name?: string;
title?: string;
command?: string; command?: string;
args?: string[]; args?: string[];
} }
@@ -516,6 +539,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
rows = 24, rows = 24,
cols = 80, cols = 80,
name = "Terminal", name = "Terminal",
title: presetTitle,
command, command,
args = [], args = [],
} = options; } = options;
@@ -524,6 +548,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
const id = options.id ?? randomUUID(); const id = options.id ?? randomUUID();
const listeners = new Set<(msg: ServerMessage) => void>(); const listeners = new Set<(msg: ServerMessage) => void>();
const exitListeners = new Set<(info: TerminalExitInfo) => void>(); const exitListeners = new Set<(info: TerminalExitInfo) => void>();
const commandFinishedListeners = new Set<(info: TerminalCommandFinishedInfo) => void>();
const titleChangeListeners = new Set<(title?: string) => void>(); const titleChangeListeners = new Set<(title?: string) => void>();
let killed = false; let killed = false;
let disposed = false; let disposed = false;
@@ -578,10 +603,12 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
} }
} }
const initialTitle = command const lockedTitle = presetTitle?.trim() || undefined;
? (humanizeProcessTitle([command, ...args].join(" ")) ?? const processTitle = command ? [command, ...args].join(" ") : null;
normalizeProcessTitle([command, ...args].join(" "))) let initialTitle = lockedTitle;
: undefined; if (!initialTitle && processTitle) {
initialTitle = humanizeProcessTitle(processTitle) ?? normalizeProcessTitle(processTitle);
}
emitTitleChange(initialTitle); emitTitleChange(initialTitle);
// Respond to DA1 queries (CSI c or CSI 0 c) — apps like nvim query terminal capabilities // Respond to DA1 queries (CSI c or CSI 0 c) — apps like nvim query terminal capabilities
@@ -593,18 +620,37 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
return false; return false;
}); });
const disposeTitleChangeSubscription = terminal.onTitleChange((nextTitle) => { let disposeTitleChangeSubscription: { dispose(): void } | null = null;
if (disposed || killed) { if (!lockedTitle) {
return; disposeTitleChangeSubscription = terminal.onTitleChange((nextTitle) => {
if (disposed || killed) {
return;
}
pendingTitle = nextTitle.trim().length > 0 ? nextTitle : undefined;
if (titleDebounceTimer) {
clearTimeout(titleDebounceTimer);
}
titleDebounceTimer = setTimeout(() => {
titleDebounceTimer = null;
emitTitleChange(pendingTitle);
}, TERMINAL_TITLE_DEBOUNCE_MS);
});
}
const disposeCommandLifecycleSubscription = terminal.parser.registerOscHandler(633, (data) => {
const commandFinished = parseCommandFinishedOsc(data);
if (!commandFinished) {
return true;
} }
pendingTitle = nextTitle.trim().length > 0 ? nextTitle : undefined;
if (titleDebounceTimer) { for (const listener of Array.from(commandFinishedListeners)) {
clearTimeout(titleDebounceTimer); try {
listener(commandFinished);
} catch {
// no-op
}
} }
titleDebounceTimer = setTimeout(() => { return true;
titleDebounceTimer = null;
emitTitleChange(pendingTitle);
}, TERMINAL_TITLE_DEBOUNCE_MS);
}); });
function buildExitInfo(input?: { function buildExitInfo(input?: {
@@ -647,10 +693,12 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
clearTimeout(titleDebounceTimer); clearTimeout(titleDebounceTimer);
titleDebounceTimer = null; titleDebounceTimer = null;
} }
disposeTitleChangeSubscription.dispose(); disposeTitleChangeSubscription?.dispose();
disposeCommandLifecycleSubscription.dispose();
terminal.dispose(); terminal.dispose();
listeners.clear(); listeners.clear();
exitListeners.clear(); exitListeners.clear();
commandFinishedListeners.clear();
titleChangeListeners.clear(); titleChangeListeners.clear();
} }
@@ -759,6 +807,13 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
}; };
} }
function onCommandFinished(listener: (info: TerminalCommandFinishedInfo) => void): () => void {
commandFinishedListeners.add(listener);
return () => {
commandFinishedListeners.delete(listener);
};
}
function onTitleChange(listener: (title?: string) => void): () => void { function onTitleChange(listener: (title?: string) => void): () => void {
titleChangeListeners.add(listener); titleChangeListeners.add(listener);
if (title !== undefined) { if (title !== undefined) {
@@ -854,6 +909,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
send, send,
subscribe, subscribe,
onExit, onExit,
onCommandFinished,
onTitleChange, onTitleChange,
getSize, getSize,
getState, getState,