242 lines
7.1 KiB
TypeScript
242 lines
7.1 KiB
TypeScript
import { createTerminal, type TerminalSession } from "./terminal.js";
|
|
import { resolve, sep, win32, posix } from "node:path";
|
|
|
|
export interface TerminalListItem {
|
|
id: string;
|
|
name: string;
|
|
cwd: string;
|
|
title?: string;
|
|
}
|
|
|
|
export interface TerminalsChangedEvent {
|
|
cwd: string;
|
|
terminals: TerminalListItem[];
|
|
}
|
|
|
|
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;
|
|
killTerminal(id: string): void;
|
|
listDirectories(): string[];
|
|
killAll(): void;
|
|
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void;
|
|
}
|
|
|
|
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>>();
|
|
|
|
function assertAbsolutePath(cwd: string): void {
|
|
if (!posix.isAbsolute(cwd) && !win32.isAbsolute(cwd)) {
|
|
throw new Error("cwd must be absolute path");
|
|
}
|
|
}
|
|
|
|
function removeSessionById(id: string, options: { kill: boolean }): void {
|
|
const session = terminalsById.get(id);
|
|
if (!session) {
|
|
return;
|
|
}
|
|
|
|
const unsubscribeExit = terminalExitUnsubscribeById.get(id);
|
|
if (unsubscribeExit) {
|
|
unsubscribeExit();
|
|
terminalExitUnsubscribeById.delete(id);
|
|
}
|
|
const unsubscribeTitle = terminalTitleUnsubscribeById.get(id);
|
|
if (unsubscribeTitle) {
|
|
unsubscribeTitle();
|
|
terminalTitleUnsubscribeById.delete(id);
|
|
}
|
|
|
|
terminalsById.delete(id);
|
|
|
|
const terminals = terminalsByCwd.get(session.cwd);
|
|
if (terminals) {
|
|
const index = terminals.findIndex((terminal) => terminal.id === id);
|
|
if (index !== -1) {
|
|
terminals.splice(index, 1);
|
|
}
|
|
if (terminals.length === 0) {
|
|
terminalsByCwd.delete(session.cwd);
|
|
}
|
|
}
|
|
|
|
if (options.kill) {
|
|
session.kill();
|
|
}
|
|
|
|
emitTerminalsChanged({ cwd: session.cwd });
|
|
}
|
|
|
|
function resolveDefaultEnvForCwd(cwd: string): Record<string, string> | undefined {
|
|
const normalizedCwd = resolve(cwd);
|
|
let bestMatchRoot: string | null = null;
|
|
|
|
for (const rootCwd of defaultEnvByRootCwd.keys()) {
|
|
const matches = normalizedCwd === rootCwd || normalizedCwd.startsWith(`${rootCwd}${sep}`);
|
|
if (!matches) {
|
|
continue;
|
|
}
|
|
if (!bestMatchRoot || rootCwd.length > bestMatchRoot.length) {
|
|
bestMatchRoot = rootCwd;
|
|
}
|
|
}
|
|
|
|
return bestMatchRoot ? defaultEnvByRootCwd.get(bestMatchRoot) : undefined;
|
|
}
|
|
|
|
function registerSession(session: TerminalSession): TerminalSession {
|
|
terminalsById.set(session.id, session);
|
|
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;
|
|
}
|
|
|
|
function toTerminalListItem(input: { session: TerminalSession }): TerminalListItem {
|
|
return {
|
|
id: input.session.id,
|
|
name: input.session.name,
|
|
cwd: input.session.cwd,
|
|
title: input.session.getTitle(),
|
|
};
|
|
}
|
|
|
|
function emitTerminalsChanged(input: { cwd: string }): void {
|
|
if (terminalsChangedListeners.size === 0) {
|
|
return;
|
|
}
|
|
|
|
const terminals = (terminalsByCwd.get(input.cwd) ?? []).map((session) =>
|
|
toTerminalListItem({ session }),
|
|
);
|
|
const event: TerminalsChangedEvent = {
|
|
cwd: input.cwd,
|
|
terminals,
|
|
};
|
|
|
|
for (const listener of terminalsChangedListeners) {
|
|
try {
|
|
listener(event);
|
|
} catch {
|
|
// no-op
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
async getTerminals(cwd: string): Promise<TerminalSession[]> {
|
|
assertAbsolutePath(cwd);
|
|
|
|
return terminalsByCwd.get(cwd) ?? [];
|
|
},
|
|
|
|
async createTerminal(options: {
|
|
id?: string;
|
|
cwd: string;
|
|
name?: string;
|
|
env?: Record<string, string>;
|
|
command?: string;
|
|
args?: string[];
|
|
}): Promise<TerminalSession> {
|
|
assertAbsolutePath(options.cwd);
|
|
|
|
const terminals = terminalsByCwd.get(options.cwd) ?? [];
|
|
const defaultName = `Terminal ${terminals.length + 1}`;
|
|
const inheritedEnv = resolveDefaultEnvForCwd(options.cwd);
|
|
const mergedEnv =
|
|
inheritedEnv || options.env
|
|
? { ...(inheritedEnv ?? {}), ...(options.env ?? {}) }
|
|
: 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 } : {}),
|
|
}),
|
|
);
|
|
|
|
terminals.push(session);
|
|
terminalsByCwd.set(options.cwd, terminals);
|
|
emitTerminalsChanged({ cwd: options.cwd });
|
|
|
|
return session;
|
|
},
|
|
|
|
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void {
|
|
assertAbsolutePath(options.cwd);
|
|
defaultEnvByRootCwd.set(resolve(options.cwd), { ...options.env });
|
|
},
|
|
|
|
getTerminal(id: string): TerminalSession | undefined {
|
|
return terminalsById.get(id);
|
|
},
|
|
|
|
killTerminal(id: string): void {
|
|
removeSessionById(id, { kill: true });
|
|
},
|
|
|
|
listDirectories(): string[] {
|
|
return Array.from(terminalsByCwd.keys());
|
|
},
|
|
|
|
killAll(): void {
|
|
for (const id of Array.from(terminalsById.keys())) {
|
|
removeSessionById(id, { kill: true });
|
|
}
|
|
},
|
|
|
|
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void {
|
|
terminalsChangedListeners.add(listener);
|
|
return () => {
|
|
terminalsChangedListeners.delete(listener);
|
|
};
|
|
},
|
|
};
|
|
}
|