Merge branch 'storage-terminal-ui-dev' into dev

# Conflicts:
#	nix/package.nix
#	packages/app/src/app/_layout.tsx
#	packages/app/src/components/sidebar-workspace-list.tsx
#	packages/app/src/hooks/use-command-center.ts
#	packages/app/src/screens/agent/draft-agent-screen.tsx
#	packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx
#	packages/app/src/screens/workspace/workspace-screen.tsx
#	packages/server/src/server/session.ts
#	packages/server/src/server/session.workspaces.test.ts
#	packages/server/src/terminal/terminal.test.ts
#	packages/server/src/terminal/terminal.ts
This commit is contained in:
Mohamed Boudra
2026-04-01 21:51:29 +07:00
217 changed files with 21075 additions and 4943 deletions
@@ -0,0 +1,17 @@
typeset -g PASEO_SHELL_INTEGRATION_DIR="${${(%):-%N}:A:h}"
if [[ -n "${PASEO_ZSH_ZDOTDIR-}" ]]; then
export ZDOTDIR="${PASEO_ZSH_ZDOTDIR}"
else
unset ZDOTDIR
fi
if [[ -n "${ZDOTDIR-}" ]]; then
if [[ -f "${ZDOTDIR}/.zshenv" ]]; then
source "${ZDOTDIR}/.zshenv"
fi
elif [[ -f "${HOME}/.zshenv" ]]; then
source "${HOME}/.zshenv"
fi
source "${PASEO_SHELL_INTEGRATION_DIR}/paseo-integration.zsh"
@@ -0,0 +1,17 @@
if [[ -n "${_PASEO_ZSH_INTEGRATION_LOADED-}" ]]; then
return
fi
typeset -g _PASEO_ZSH_INTEGRATION_LOADED=1
autoload -Uz add-zsh-hook
function _paseo_precmd() {
printf '\e]2;%s\a' "${PWD/#$HOME/~}"
}
function _paseo_preexec() {
printf '\e]2;%s\a' "$1"
}
add-zsh-hook precmd _paseo_precmd
add-zsh-hook preexec _paseo_preexec
@@ -1,6 +1,6 @@
import { describe, it, expect, afterEach } from "vitest";
import { describe, it, expect, afterEach, vi } from "vitest";
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -301,11 +301,14 @@ describe("TerminalManager", () => {
describe("subscribeTerminalsChanged", () => {
it("emits cwd snapshots when terminals are created", async () => {
manager = createTerminalManager();
const snapshots: Array<{ cwd: string; terminalNames: string[] }> = [];
const snapshots: Array<{ cwd: string; terminals: Array<{ name: string; title?: string }> }> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push({
cwd: input.cwd,
terminalNames: input.terminals.map((terminal) => terminal.name),
terminals: input.terminals.map((terminal) => ({
name: terminal.name,
...(terminal.title ? { title: terminal.title } : {}),
})),
});
});
@@ -314,16 +317,111 @@ describe("TerminalManager", () => {
expect(snapshots).toContainEqual({
cwd: "/tmp",
terminalNames: ["Terminal 1"],
terminals: [{ name: "Terminal 1" }],
});
expect(snapshots).toContainEqual({
cwd: "/tmp",
terminalNames: ["Terminal 1", "Dev Server"],
terminals: [{ name: "Terminal 1" }, { name: "Dev Server" }],
});
unsubscribe();
});
it(
"emits updated terminal titles after debounced title changes",
async () => {
await withShell("/bin/sh", async () => {
manager = createTerminalManager();
const snapshots: Array<Array<{ id: string; title?: string }>> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push(
input.terminals.map((terminal) => ({
id: terminal.id,
...(terminal.title ? { title: terminal.title } : {}),
})),
);
});
const session = await manager.createTerminal({ cwd: "/tmp" });
session.send({ type: "input", data: "printf '\\033]0;Logs\\007'\r" });
await waitForCondition(
() =>
snapshots.some((snapshot) =>
snapshot.some((terminal) => terminal.id === session.id && terminal.title === "Logs"),
),
10000,
);
unsubscribe();
});
},
10000,
);
it("forwards bound terminal titles through the agent bridge without changing standalone lists", async () => {
await withShell("/bin/sh", async () => {
const onAgentBoundTerminalTitleChange = vi.fn();
manager = createTerminalManager({
resolveAgentIdForTerminal: () => "agent-1",
onAgentBoundTerminalTitleChange,
});
const snapshots: Array<Array<{ id: string; title?: string }>> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push(
input.terminals.map((terminal) => ({
id: terminal.id,
...(terminal.title ? { title: terminal.title } : {}),
})),
);
});
const session = await manager.createTerminal({ cwd: "/tmp" });
session.send({ type: "input", data: "printf '\\033]0;Agent Shell\\007'\r" });
await waitForCondition(() => onAgentBoundTerminalTitleChange.mock.calls.length > 0, 10000);
expect(onAgentBoundTerminalTitleChange).toHaveBeenCalledWith({
agentId: "agent-1",
title: "Agent Shell",
});
expect(
snapshots.some((snapshot) =>
snapshot.some((terminal) => terminal.id === session.id && terminal.title === "Agent Shell"),
),
).toBe(true);
unsubscribe();
});
});
it("forwards initial titles for agent-bound terminals created with command args", async () => {
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-manager-title-script-"));
temporaryDirs.push(packageRoot);
const scriptPath = join(packageRoot, "npm-cli.js");
writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n");
const onAgentBoundTerminalTitleChange = vi.fn();
manager = createTerminalManager({
resolveAgentIdForTerminal: () => "agent-1",
onAgentBoundTerminalTitleChange,
});
await manager.createTerminal({
cwd: packageRoot,
command: process.execPath,
args: [scriptPath, "run", "dev"],
});
await waitForCondition(() => onAgentBoundTerminalTitleChange.mock.calls.length > 0, 10000);
expect(onAgentBoundTerminalTitleChange).toHaveBeenCalledWith({
agentId: "agent-1",
title: "npm run dev",
});
});
it("emits empty snapshot when last terminal is removed", async () => {
manager = createTerminalManager();
const snapshots: Array<{ cwd: string; terminalCount: number }> = [];
@@ -5,6 +5,7 @@ export interface TerminalListItem {
id: string;
name: string;
cwd: string;
title?: string;
}
export interface TerminalsChangedEvent {
@@ -17,9 +18,12 @@ export type TerminalsChangedListener = (input: TerminalsChangedEvent) => void;
export interface TerminalManager {
getTerminals(cwd: string): Promise<TerminalSession[]>;
createTerminal(options: {
id?: string;
cwd: string;
name?: string;
env?: Record<string, string>;
command?: string;
args?: string[];
}): Promise<TerminalSession>;
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
getTerminal(id: string): TerminalSession | undefined;
@@ -29,10 +33,16 @@ export interface TerminalManager {
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void;
}
export function createTerminalManager(): TerminalManager {
type AgentBoundTerminalTitleHandler = (input: { agentId: string; title: string }) => Promise<void> | void;
export function createTerminalManager(options?: {
resolveAgentIdForTerminal?: (terminalId: string) => string | null;
onAgentBoundTerminalTitleChange?: AgentBoundTerminalTitleHandler;
}): TerminalManager {
const terminalsByCwd = new Map<string, TerminalSession[]>();
const terminalsById = new Map<string, TerminalSession>();
const terminalExitUnsubscribeById = new Map<string, () => void>();
const terminalTitleUnsubscribeById = new Map<string, () => void>();
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
@@ -53,6 +63,11 @@ export function createTerminalManager(): TerminalManager {
unsubscribeExit();
terminalExitUnsubscribeById.delete(id);
}
const unsubscribeTitle = terminalTitleUnsubscribeById.get(id);
if (unsubscribeTitle) {
unsubscribeTitle();
terminalTitleUnsubscribeById.delete(id);
}
terminalsById.delete(id);
@@ -96,7 +111,27 @@ export function createTerminalManager(): TerminalManager {
const unsubscribeExit = session.onExit(() => {
removeSessionById(session.id, { kill: false });
});
const unsubscribeTitle = session.onTitleChange((title) => {
emitTerminalsChanged({ cwd: session.cwd });
const normalizedTitle = title?.trim();
if (!normalizedTitle) {
return;
}
const agentId = options?.resolveAgentIdForTerminal?.(session.id) ?? null;
if (!agentId) {
return;
}
void Promise.resolve(
options?.onAgentBoundTerminalTitleChange?.({
agentId,
title: normalizedTitle,
}),
).catch(() => {
// no-op
});
});
terminalExitUnsubscribeById.set(session.id, unsubscribeExit);
terminalTitleUnsubscribeById.set(session.id, unsubscribeTitle);
return session;
}
@@ -105,6 +140,7 @@ export function createTerminalManager(): TerminalManager {
id: input.session.id,
name: input.session.name,
cwd: input.session.cwd,
title: input.session.getTitle(),
};
}
@@ -138,9 +174,12 @@ export function createTerminalManager(): TerminalManager {
},
async createTerminal(options: {
id?: string;
cwd: string;
name?: string;
env?: Record<string, string>;
command?: string;
args?: string[];
}): Promise<TerminalSession> {
assertAbsolutePath(options.cwd);
@@ -153,8 +192,11 @@ export function createTerminalManager(): TerminalManager {
: undefined;
const session = registerSession(
await createTerminal({
...(options.id ? { id: options.id } : {}),
cwd: options.cwd,
name: options.name ?? defaultName,
...(options.command ? { command: options.command } : {}),
...(options.args ? { args: options.args } : {}),
...(mergedEnv ? { env: mergedEnv } : {}),
}),
);
@@ -1,8 +1,12 @@
import { describe, it, expect, afterEach } from "vitest";
import {
buildTerminalEnvironment,
createTerminal,
ensureNodePtySpawnHelperExecutableForCurrentPlatform,
resolveDefaultTerminalShell,
humanizeProcessTitle,
normalizeProcessTitle,
resolveZshShellIntegrationDir,
type TerminalSession,
} from "./terminal.js";
import { chmodSync, mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -71,6 +75,23 @@ async function waitForState(
throw new Error("Timeout waiting for terminal state predicate to match");
}
async function waitForTitle(
session: TerminalSession,
predicate: (title: string | undefined) => boolean,
timeoutMs = 5000,
): Promise<string | undefined> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const title = session.getTitle();
if (predicate(title)) {
return title;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error("Timeout waiting for terminal title predicate to match");
}
describe("Terminal", () => {
const sessions: TerminalSession[] = [];
const temporaryDirs: string[] = [];
@@ -94,6 +115,30 @@ describe("Terminal", () => {
}
describe("createTerminal", () => {
it("keeps full process titles while stripping path prefixes", () => {
expect(normalizeProcessTitle(" /usr/local/bin/npm run dev ")).toBe("npm run dev");
expect(normalizeProcessTitle("/opt/homebrew/bin/node /tmp/work/npm-cli.js run dev")).toBe(
"node npm-cli.js run dev",
);
expect(normalizeProcessTitle("")).toBeUndefined();
});
it("humanizes interpreter-backed package manager commands", () => {
expect(
humanizeProcessTitle("/usr/local/bin/node /opt/homebrew/lib/node_modules/npm/bin/npm-cli.js run dev"),
).toBe("npm run dev");
expect(
humanizeProcessTitle("/usr/bin/env FOO=bar /opt/homebrew/bin/node /tmp/npm-cli.js test"),
).toBe("npm test");
});
it("drops common interpreter prefixes for direct scripts", () => {
expect(humanizeProcessTitle("/usr/bin/python3 /tmp/server.py --port 3000")).toBe(
"server.py --port 3000",
);
expect(humanizeProcessTitle("/bin/bash /tmp/dev.sh")).toBe("dev.sh");
});
it("ensures darwin prebuild spawn-helper is executable", () => {
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-node-pty-helper-"));
temporaryDirs.push(packageRoot);
@@ -140,6 +185,20 @@ describe("Terminal", () => {
expect(session.cwd).toBe("/tmp");
});
it("sets zsh wrapper env when spawning zsh", () => {
const resolvedEnv = buildTerminalEnvironment({
shell: "/bin/zsh",
env: {
HOME: "/tmp/paseo-home",
ZDOTDIR: "/tmp/paseo-zdotdir",
},
});
expect(resolvedEnv.TERM).toBe("xterm-256color");
expect(resolvedEnv.PASEO_ZSH_ZDOTDIR).toBe("/tmp/paseo-zdotdir");
expect(resolvedEnv.ZDOTDIR).toBe(resolveZshShellIntegrationDir());
});
it("uses custom name when provided", async () => {
const session = trackSession(
await createTerminal({
@@ -192,6 +251,28 @@ describe("Terminal", () => {
expect(state.rows).toBe(40);
expect(state.cols).toBe(120);
});
it("captures exit diagnostics from the terminal buffer", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
command: "/bin/sh",
args: ["-lc", "printf 'launch failed\\ncommand missing\\n'; exit 127"],
}),
);
const exitInfo = await new Promise<NonNullable<ReturnType<TerminalSession["getExitInfo"]>>>(
(resolve) => {
session.onExit((info) => resolve(info));
},
);
expect(exitInfo.exitCode).toBe(127);
expect(exitInfo.signal).toBeNull();
// lastOutputLines may be empty if the process exits before xterm processes the data write
expect(Array.isArray(exitInfo.lastOutputLines)).toBe(true);
expect(session.getExitInfo()).toEqual(exitInfo);
});
});
describe("send input", () => {
@@ -268,6 +349,154 @@ describe("Terminal", () => {
});
});
describe("terminal title", () => {
it("restores the user's ZDOTDIR through the zsh wrapper", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-home-"));
temporaryDirs.push(homeDir);
const realZdotdir = join(homeDir, ".config", "zsh");
mkdirSync(realZdotdir, { recursive: true });
writeFileSync(join(realZdotdir, ".zshenv"), "export PASEO_TEST_REAL_ZDOTDIR=1\n");
const session = trackSession(
await createTerminal({
cwd: homeDir,
command: "/bin/zsh",
args: ["-c", "printf '%s\\n%s\\n' \"${ZDOTDIR-}\" \"${PASEO_TEST_REAL_ZDOTDIR-}\""],
env: {
HOME: homeDir,
ZDOTDIR: realZdotdir,
},
}),
);
const exitInfo = await new Promise<NonNullable<ReturnType<TerminalSession["getExitInfo"]>>>(
(resolve) => {
session.onExit((info) => resolve(info));
},
);
expect(exitInfo.lastOutputLines).toEqual([realZdotdir, "1"]);
});
it("emits the initial title from command args to title listeners", async () => {
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-title-script-"));
temporaryDirs.push(packageRoot);
const scriptPath = join(packageRoot, "npm-cli.js");
writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n");
const session = trackSession(
await createTerminal({
cwd: packageRoot,
command: process.execPath,
args: [scriptPath, "run", "dev"],
}),
);
const seenTitles: Array<string | undefined> = [];
const unsubscribeTitle = session.onTitleChange((title) => {
seenTitles.push(title);
});
await waitForTitle(session, (title) => title === "npm run dev");
await waitForState(session, (state) => state.title === "npm run dev");
expect(seenTitles).toContain("npm run dev");
expect(session.getTitle()).toBe("npm run dev");
expect(session.getState().title).toBe("npm run dev");
unsubscribeTitle();
});
it("emits OSC title updates to title listeners", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
const seenTitles: Array<string | undefined> = [];
const unsubscribeTitle = session.onTitleChange((title) => {
seenTitles.push(title);
});
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
await waitForTitle(session, (title) => title === "Build Log");
expect(seenTitles).toContain("Build Log");
expect(session.getTitle()).toBe("Build Log");
expect(session.getState().title).toBe("Build Log");
unsubscribeTitle();
});
it("debounces rapid title changes and emits only the final title", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
const seenTitles: Array<string | undefined> = [];
const seenMessages: Array<string | undefined> = [];
const unsubscribeTitle = session.onTitleChange((title) => {
seenTitles.push(title);
});
const unsubscribeMessages = session.subscribe((message) => {
if (message.type === "titleChange") {
seenMessages.push(message.title);
}
});
await waitForLines(session, ["$"]);
session.send({
type: "input",
data:
"printf '\\033]0;First\\007\\033]0;Second\\007\\033]0;Final\\007'\r",
});
await waitForTitle(session, (title) => title === "Final");
expect(seenTitles).toEqual(["Final"]);
expect(seenMessages).toEqual(["Final"]);
unsubscribeMessages();
unsubscribeTitle();
});
it("emits zsh shell integration titles for commands and prompts", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-integration-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,
},
}),
);
await waitForLines(session, ["$"]);
await waitForTitle(session, (title) => title === "~/dev/faro");
session.send({ type: "input", data: "sleep 1\r" });
await waitForTitle(session, (title) => title === "sleep 1");
await waitForTitle(session, (title) => title === "~/dev/faro", 4000);
});
});
describe("colors", () => {
it("captures ANSI 16 color codes (mode 1)", async () => {
const session = trackSession(
+320 -18
View File
@@ -2,14 +2,24 @@ import * as pty from "node-pty";
import xterm, { type Terminal as TerminalType } from "@xterm/headless";
import { randomUUID } from "crypto";
import { chmodSync, existsSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { basename, dirname, join } from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import stripAnsi from "strip-ansi";
import type { TerminalCell, TerminalState } from "../shared/messages.js";
const { Terminal } = xterm;
const require = createRequire(import.meta.url);
let nodePtySpawnHelperChecked = false;
const TERMINAL_TITLE_DEBOUNCE_MS = 150;
const TERMINAL_EXIT_OUTPUT_LINE_LIMIT = 12;
const TERMINAL_EXIT_OUTPUT_CHAR_LIMIT = 16000;
export interface TerminalExitInfo {
exitCode: number | null;
signal: number | null;
lastOutputLines: string[];
}
export type ClientMessage =
| { type: "input"; data: string }
@@ -18,7 +28,8 @@ export type ClientMessage =
export type ServerMessage =
| { type: "output"; data: string }
| { type: "snapshot"; state: TerminalState };
| { type: "snapshot"; state: TerminalState }
| { type: "titleChange"; title?: string };
export interface TerminalSession {
id: string;
@@ -26,19 +37,30 @@ export interface TerminalSession {
cwd: string;
send(msg: ClientMessage): void;
subscribe(listener: (msg: ServerMessage) => void): () => void;
onExit(listener: () => void): () => void;
onExit(listener: (info: TerminalExitInfo) => void): () => void;
onTitleChange(listener: (title?: string) => void): () => void;
getSize(): { rows: number; cols: number };
getState(): TerminalState;
getTitle(): string | undefined;
getExitInfo(): TerminalExitInfo | null;
kill(): void;
}
export interface CreateTerminalOptions {
id?: string;
cwd: string;
shell?: string;
env?: Record<string, string>;
rows?: number;
cols?: number;
name?: string;
command?: string;
args?: string[];
}
interface BuildTerminalEnvironmentInput {
shell: string;
env: Record<string, string>;
}
export interface CaptureTerminalLinesOptions {
@@ -135,6 +157,29 @@ export function resolveDefaultTerminalShell(
return env.SHELL || "/bin/sh";
}
export function resolveZshShellIntegrationDir(): string {
return fileURLToPath(new URL("./shell-integration/zsh", import.meta.url));
}
export function buildTerminalEnvironment(input: BuildTerminalEnvironmentInput): Record<string, string> {
const baseEnv: Record<string, string> = {
...process.env,
...input.env,
TERM: "xterm-256color",
};
if (basename(input.shell) !== "zsh") {
return baseEnv;
}
const originalZdotdir = baseEnv.ZDOTDIR ?? "";
return {
...baseEnv,
PASEO_ZSH_ZDOTDIR: originalZdotdir,
ZDOTDIR: resolveZshShellIntegrationDir(),
};
}
function extractCell(terminal: TerminalType, row: number, col: number): TerminalCell {
const buffer = terminal.buffer.active;
const line = buffer.getLine(row);
@@ -258,6 +303,157 @@ function extractCursorState(terminal: TerminalType): TerminalState["cursor"] {
};
}
function normalizeProcessToken(token: string): string {
if (token.length === 0) {
return token;
}
const quote =
token.startsWith('"') && token.endsWith('"')
? '"'
: token.startsWith("'") && token.endsWith("'")
? "'"
: "";
const rawToken = quote ? token.slice(1, -1) : token;
if (rawToken.length === 0) {
return token;
}
const assignmentMatch = rawToken.match(/^([A-Za-z_][A-Za-z0-9_]*=)(.+)$/);
const prefix = assignmentMatch ? assignmentMatch[1] : "";
const value = assignmentMatch ? assignmentMatch[2] : rawToken;
if (!value.includes("/")) {
return token;
}
const normalized = `${prefix}${basename(value)}`;
return quote ? `${quote}${normalized}${quote}` : normalized;
}
export function normalizeProcessTitle(processTitle: string): string | undefined {
const trimmed = processTitle.trim().replace(/\s+/g, " ");
if (trimmed.length === 0) {
return undefined;
}
const normalized = trimmed
.split(" ")
.map((token) => normalizeProcessToken(token))
.join(" ")
.trim();
return normalized.length > 0 ? normalized : undefined;
}
const PROCESS_INTERPRETERS = new Set([
"bash",
"bun",
"deno",
"node",
"nodejs",
"python",
"python3",
"ruby",
"sh",
"tsx",
"zsh",
]);
const PACKAGE_MANAGER_SCRIPT_NAMES = new Map<string, string>([
["bun.js", "bun"],
["npm-cli.js", "npm"],
["npx-cli.js", "npx"],
["pnpm.cjs", "pnpm"],
["pnpm.js", "pnpm"],
["yarn.cjs", "yarn"],
["yarn.js", "yarn"],
]);
export function humanizeProcessTitle(processTitle: string): string | undefined {
const normalized = normalizeProcessTitle(processTitle);
if (!normalized) {
return undefined;
}
const tokens = normalized.split(" ").filter(Boolean);
if (tokens.length === 0) {
return undefined;
}
while (tokens[0] === "env") {
tokens.shift();
while (tokens[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) {
tokens.shift();
}
}
if (tokens.length === 0) {
return normalized;
}
const first = tokens[0];
const second = tokens[1];
if (PROCESS_INTERPRETERS.has(first) && second) {
const packageManager = PACKAGE_MANAGER_SCRIPT_NAMES.get(second);
if (packageManager) {
return [packageManager, ...tokens.slice(2)].join(" ").trim() || packageManager;
}
if (!second.startsWith("-")) {
return [second, ...tokens.slice(2)].join(" ").trim();
}
}
return normalized;
}
function extractLastOutputLines(terminal: TerminalType, limit: number): string[] {
const buffer = terminal.buffer.active;
const mergedLines: string[] = [];
for (let row = 0; row < buffer.length; row++) {
const line = buffer.getLine(row);
if (!line) {
continue;
}
const text = line.translateToString(true);
const isWrapped = (line as { isWrapped?: boolean }).isWrapped === true;
if (isWrapped && mergedLines.length > 0) {
mergedLines[mergedLines.length - 1] += text;
continue;
}
mergedLines.push(text);
}
while (mergedLines.length > 0 && mergedLines[0]?.trim().length === 0) {
mergedLines.shift();
}
while (mergedLines.length > 0 && mergedLines[mergedLines.length - 1]?.trim().length === 0) {
mergedLines.pop();
}
return mergedLines.slice(-limit);
}
function stripAnsiSequences(input: string): string {
return input.replace(
/\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\].*?(?:\x07|\x1b\\))/g,
"",
);
}
function extractLastOutputLinesFromText(text: string, limit: number): string[] {
const normalized = stripAnsiSequences(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const lines = normalized.split("\n").map((line) => line.trimEnd());
while (lines[0]?.trim().length === 0) {
lines.shift();
}
while (lines[lines.length - 1]?.trim().length === 0) {
lines.pop();
}
return lines.slice(-limit);
}
function cellsToPlainText(cells: TerminalCell[], options: { stripAnsi: boolean }): string {
const text = cells.map((cell) => cell.char).join("").trimEnd();
return options.stripAnsi ? stripAnsi(text) : text;
@@ -320,15 +516,23 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
rows = 24,
cols = 80,
name = "Terminal",
command,
args = [],
} = options;
const resolvedShell = shell ?? resolveDefaultTerminalShell();
const id = randomUUID();
const id = options.id ?? randomUUID();
const listeners = new Set<(msg: ServerMessage) => void>();
const exitListeners = new Set<() => void>();
const exitListeners = new Set<(info: TerminalExitInfo) => void>();
const titleChangeListeners = new Set<(title?: string) => void>();
let killed = false;
let disposed = false;
let exitEmitted = false;
let exitInfo: TerminalExitInfo | null = null;
let recentOutputText = "";
let title: string | undefined;
let pendingTitle: string | undefined;
let titleDebounceTimer: ReturnType<typeof setTimeout> | null = null;
// Create xterm.js headless terminal
const terminal = new Terminal({
@@ -341,18 +545,43 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
ensureNodePtySpawnHelperExecutableForCurrentPlatform();
// Create PTY
const ptyProcess = pty.spawn(resolvedShell, [], {
const spawnCommand = command ?? resolvedShell;
const spawnArgs = command ? args : [];
const ptyProcess = pty.spawn(spawnCommand, spawnArgs, {
name: "xterm-256color",
cols,
rows,
cwd,
env: {
...process.env,
...env,
TERM: "xterm-256color",
},
env: buildTerminalEnvironment({ shell: spawnCommand, env }),
});
function emitTitleChange(nextTitle: string | undefined): void {
if (title === nextTitle) {
return;
}
title = nextTitle;
for (const listener of Array.from(titleChangeListeners)) {
try {
listener(title);
} catch {
// no-op
}
}
for (const listener of Array.from(listeners)) {
try {
listener({ type: "titleChange", title });
} catch {
// no-op
}
}
}
const initialTitle = command
? humanizeProcessTitle([command, ...args].join(" ")) ??
normalizeProcessTitle([command, ...args].join(" "))
: undefined;
emitTitleChange(initialTitle);
// Respond to DA1 queries (CSI c or CSI 0 c) — apps like nvim query terminal capabilities
terminal.parser.registerCsiHandler({ final: "c" }, (params) => {
if (params.length === 0 || (params.length === 1 && params[0] === 0)) {
@@ -362,14 +591,41 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
return false;
});
function emitExit(): void {
const 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);
});
function buildExitInfo(input?: { exitCode?: number | null; signal?: number | null }): TerminalExitInfo {
const lastOutputLines = extractLastOutputLines(terminal, TERMINAL_EXIT_OUTPUT_LINE_LIMIT);
return {
exitCode: input?.exitCode ?? null,
signal: input?.signal && input.signal > 0 ? input.signal : null,
lastOutputLines:
lastOutputLines.length > 0
? lastOutputLines
: extractLastOutputLinesFromText(recentOutputText, TERMINAL_EXIT_OUTPUT_LINE_LIMIT),
};
}
function emitExit(info: TerminalExitInfo): void {
if (exitEmitted) {
return;
}
exitEmitted = true;
exitInfo = info;
for (const listener of Array.from(exitListeners)) {
try {
listener();
listener(info);
} catch {
// no-op
}
@@ -382,14 +638,24 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
return;
}
disposed = true;
if (titleDebounceTimer) {
clearTimeout(titleDebounceTimer);
titleDebounceTimer = null;
}
disposeTitleChangeSubscription.dispose();
terminal.dispose();
listeners.clear();
exitListeners.clear();
titleChangeListeners.clear();
}
// Pipe PTY output to terminal emulator
ptyProcess.onData((data) => {
if (killed) return;
recentOutputText = `${recentOutputText}${data}`;
if (recentOutputText.length > TERMINAL_EXIT_OUTPUT_CHAR_LIMIT) {
recentOutputText = recentOutputText.slice(-TERMINAL_EXIT_OUTPUT_CHAR_LIMIT);
}
terminal.write(data, () => {
if (disposed || killed) {
return;
@@ -400,9 +666,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
});
});
ptyProcess.onExit(() => {
ptyProcess.onExit((event) => {
killed = true;
emitExit();
emitExit(
buildExitInfo({
exitCode: event.exitCode,
signal: event.signal,
}),
);
disposeResources();
});
@@ -413,6 +684,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
grid: extractGrid(terminal),
scrollback: extractScrollback(terminal),
cursor: extractCursorState(terminal),
...(title ? { title } : {}),
};
}
@@ -455,11 +727,11 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
};
}
function onExit(listener: () => void): () => void {
function onExit(listener: (info: TerminalExitInfo) => void): () => void {
if (killed) {
queueMicrotask(() => {
try {
listener();
listener(exitInfo ?? buildExitInfo());
} catch {
// no-op
}
@@ -473,11 +745,38 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
};
}
function onTitleChange(listener: (title?: string) => void): () => void {
titleChangeListeners.add(listener);
if (title !== undefined) {
queueMicrotask(() => {
if (disposed || !titleChangeListeners.has(listener)) {
return;
}
try {
listener(title);
} catch {
// no-op
}
});
}
return () => {
titleChangeListeners.delete(listener);
};
}
function getTitle(): string | undefined {
return title;
}
function getExitInfo(): TerminalExitInfo | null {
return exitInfo;
}
function kill(): void {
if (!killed) {
killed = true;
ptyProcess.kill();
emitExit();
emitExit(buildExitInfo());
}
disposeResources();
}
@@ -492,8 +791,11 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
send,
subscribe,
onExit,
onTitleChange,
getSize,
getState,
getTitle,
getExitInfo,
kill,
};
}