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:
@@ -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