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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user