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
@@ -5,11 +5,26 @@ typeset -g _PASEO_ZSH_INTEGRATION_LOADED=1
autoload -Uz add-zsh-hook
typeset -g _PASEO_ZSH_COMMAND_ACTIVE=0
function _paseo_osc633() {
printf '\e]633;%s\a' "$1"
}
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/~}"
_paseo_osc633 "A"
}
function _paseo_preexec() {
_PASEO_ZSH_COMMAND_ACTIVE=1
_paseo_osc633 "B"
_paseo_osc633 "C"
printf '\e]2;%s\a' "$1"
}
@@ -21,6 +21,7 @@ export interface TerminalManager {
id?: string;
cwd: string;
name?: string;
title?: string;
env?: Record<string, string>;
command?: string;
args?: string[];
@@ -160,6 +161,7 @@ export function createTerminalManager(): TerminalManager {
id?: string;
cwd: string;
name?: string;
title?: string;
env?: Record<string, string>;
command?: string;
args?: string[];
@@ -178,6 +180,7 @@ export function createTerminalManager(): TerminalManager {
...(options.id ? { id: options.id } : {}),
cwd: options.cwd,
name: options.name ?? defaultName,
...(options.title ? { title: options.title } : {}),
...(options.command ? { command: options.command } : {}),
...(options.args ? { args: options.args } : {}),
...(mergedEnv ? { env: mergedEnv } : {}),
@@ -443,6 +443,87 @@ describe("Terminal", () => {
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 () => {
const session = trackSession(
await createTerminal({
@@ -506,6 +587,42 @@ describe("Terminal", () => {
await waitForTitle(session, (title) => title === "sleep 1");
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", () => {
+71 -15
View File
@@ -21,6 +21,10 @@ export interface TerminalExitInfo {
lastOutputLines: string[];
}
export interface TerminalCommandFinishedInfo {
exitCode: number | null;
}
export type ClientMessage =
| { type: "input"; data: string }
| { type: "resize"; rows: number; cols: number }
@@ -38,6 +42,7 @@ export interface TerminalSession {
send(msg: ClientMessage): void;
subscribe(listener: (msg: ServerMessage) => void): () => void;
onExit(listener: (info: TerminalExitInfo) => void): () => void;
onCommandFinished(listener: (info: TerminalCommandFinishedInfo) => void): () => void;
onTitleChange(listener: (title?: string) => void): () => void;
getSize(): { rows: number; cols: number };
getState(): TerminalState;
@@ -47,6 +52,23 @@ export interface TerminalSession {
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 {
id?: string;
cwd: string;
@@ -55,6 +77,7 @@ export interface CreateTerminalOptions {
rows?: number;
cols?: number;
name?: string;
title?: string;
command?: string;
args?: string[];
}
@@ -516,6 +539,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
rows = 24,
cols = 80,
name = "Terminal",
title: presetTitle,
command,
args = [],
} = options;
@@ -524,6 +548,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
const id = options.id ?? randomUUID();
const listeners = new Set<(msg: ServerMessage) => void>();
const exitListeners = new Set<(info: TerminalExitInfo) => void>();
const commandFinishedListeners = new Set<(info: TerminalCommandFinishedInfo) => void>();
const titleChangeListeners = new Set<(title?: string) => void>();
let killed = false;
let disposed = false;
@@ -578,10 +603,12 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
}
}
const initialTitle = command
? (humanizeProcessTitle([command, ...args].join(" ")) ??
normalizeProcessTitle([command, ...args].join(" ")))
: undefined;
const lockedTitle = presetTitle?.trim() || undefined;
const processTitle = command ? [command, ...args].join(" ") : null;
let initialTitle = lockedTitle;
if (!initialTitle && processTitle) {
initialTitle = humanizeProcessTitle(processTitle) ?? normalizeProcessTitle(processTitle);
}
emitTitleChange(initialTitle);
// 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;
});
const disposeTitleChangeSubscription = terminal.onTitleChange((nextTitle) => {
if (disposed || killed) {
return;
let disposeTitleChangeSubscription: { dispose(): void } | null = null;
if (!lockedTitle) {
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) {
clearTimeout(titleDebounceTimer);
for (const listener of Array.from(commandFinishedListeners)) {
try {
listener(commandFinished);
} catch {
// no-op
}
}
titleDebounceTimer = setTimeout(() => {
titleDebounceTimer = null;
emitTitleChange(pendingTitle);
}, TERMINAL_TITLE_DEBOUNCE_MS);
return true;
});
function buildExitInfo(input?: {
@@ -647,10 +693,12 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
clearTimeout(titleDebounceTimer);
titleDebounceTimer = null;
}
disposeTitleChangeSubscription.dispose();
disposeTitleChangeSubscription?.dispose();
disposeCommandLifecycleSubscription.dispose();
terminal.dispose();
listeners.clear();
exitListeners.clear();
commandFinishedListeners.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 {
titleChangeListeners.add(listener);
if (title !== undefined) {
@@ -854,6 +909,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
send,
subscribe,
onExit,
onCommandFinished,
onTitleChange,
getSize,
getState,