feat: improve agent workspace flows and directory suggestions
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
async function waitForCondition(
|
||||
predicate: () => boolean,
|
||||
@@ -16,13 +19,34 @@ async function waitForCondition(
|
||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
|
||||
}
|
||||
|
||||
async function withShell<T>(shell: string, run: () => Promise<T>): Promise<T> {
|
||||
const originalShell = process.env.SHELL;
|
||||
process.env.SHELL = shell;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
if (originalShell === undefined) {
|
||||
delete process.env.SHELL;
|
||||
} else {
|
||||
process.env.SHELL = originalShell;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("TerminalManager", () => {
|
||||
let manager: TerminalManager;
|
||||
const temporaryDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
if (manager) {
|
||||
manager.killAll();
|
||||
}
|
||||
while (temporaryDirs.length > 0) {
|
||||
const dir = temporaryDirs.pop();
|
||||
if (dir) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("getTerminals", () => {
|
||||
@@ -94,6 +118,58 @@ describe("TerminalManager", () => {
|
||||
manager = createTerminalManager();
|
||||
await expect(manager.createTerminal({ cwd: "tmp" })).rejects.toThrow("cwd must be absolute path");
|
||||
});
|
||||
|
||||
it("inherits registered env for the worktree root cwd", async () => {
|
||||
await withShell("/bin/sh", async () => {
|
||||
manager = createTerminalManager();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-root-"));
|
||||
temporaryDirs.push(cwd);
|
||||
const markerPath = join(cwd, "root-port.txt");
|
||||
|
||||
manager.registerCwdEnv({
|
||||
cwd,
|
||||
env: { PASEO_WORKTREE_PORT: "45678" },
|
||||
});
|
||||
const session = await manager.createTerminal({ cwd });
|
||||
for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) {
|
||||
session.send({
|
||||
type: "input",
|
||||
data: `printf '%s' \"$PASEO_WORKTREE_PORT\" > ${JSON.stringify(markerPath)}\r`,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
await waitForCondition(() => existsSync(markerPath), 10000);
|
||||
expect(readFileSync(markerPath, "utf8")).toBe("45678");
|
||||
});
|
||||
});
|
||||
|
||||
it("inherits registered env for subdirectories within the worktree", async () => {
|
||||
await withShell("/bin/sh", async () => {
|
||||
manager = createTerminalManager();
|
||||
const rootCwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-subdir-"));
|
||||
const subdirCwd = join(rootCwd, "packages", "app");
|
||||
mkdirSync(subdirCwd, { recursive: true });
|
||||
temporaryDirs.push(rootCwd);
|
||||
const markerPath = join(subdirCwd, "subdir-port.txt");
|
||||
|
||||
manager.registerCwdEnv({
|
||||
cwd: rootCwd,
|
||||
env: { PASEO_WORKTREE_PORT: "45679" },
|
||||
});
|
||||
const session = await manager.createTerminal({ cwd: subdirCwd });
|
||||
for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) {
|
||||
session.send({
|
||||
type: "input",
|
||||
data: `printf '%s' \"$PASEO_WORKTREE_PORT\" > ${JSON.stringify(markerPath)}\r`,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
await waitForCondition(() => existsSync(markerPath), 10000);
|
||||
expect(readFileSync(markerPath, "utf8")).toBe("45679");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTerminal", () => {
|
||||
@@ -156,7 +232,7 @@ describe("TerminalManager", () => {
|
||||
manager = createTerminalManager();
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
const exitedId = terminals[0].id;
|
||||
terminals[0].send({ type: "input", data: "\u0004" });
|
||||
terminals[0].kill();
|
||||
|
||||
await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createTerminal, type TerminalSession } from "./terminal.js";
|
||||
import { resolve, sep } from "node:path";
|
||||
|
||||
export interface TerminalListItem {
|
||||
id: string;
|
||||
@@ -15,7 +16,12 @@ export type TerminalsChangedListener = (input: TerminalsChangedEvent) => void;
|
||||
|
||||
export interface TerminalManager {
|
||||
getTerminals(cwd: string): Promise<TerminalSession[]>;
|
||||
createTerminal(options: { cwd: string; name?: string }): Promise<TerminalSession>;
|
||||
createTerminal(options: {
|
||||
cwd: string;
|
||||
name?: string;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<TerminalSession>;
|
||||
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
|
||||
getTerminal(id: string): TerminalSession | undefined;
|
||||
killTerminal(id: string): void;
|
||||
listDirectories(): string[];
|
||||
@@ -28,6 +34,7 @@ export function createTerminalManager(): TerminalManager {
|
||||
const terminalsById = new Map<string, TerminalSession>();
|
||||
const terminalExitUnsubscribeById = new Map<string, () => void>();
|
||||
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
|
||||
const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
|
||||
|
||||
function assertAbsolutePath(cwd: string): void {
|
||||
if (!cwd.startsWith("/")) {
|
||||
@@ -67,6 +74,23 @@ export function createTerminalManager(): TerminalManager {
|
||||
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(() => {
|
||||
@@ -112,8 +136,13 @@ export function createTerminalManager(): TerminalManager {
|
||||
|
||||
let terminals = terminalsByCwd.get(cwd);
|
||||
if (!terminals || terminals.length === 0) {
|
||||
const inheritedEnv = resolveDefaultEnvForCwd(cwd);
|
||||
const session = registerSession(
|
||||
await createTerminal({ cwd, name: "Terminal 1" })
|
||||
await createTerminal({
|
||||
cwd,
|
||||
name: "Terminal 1",
|
||||
...(inheritedEnv ? { env: inheritedEnv } : {}),
|
||||
})
|
||||
);
|
||||
terminals = [session];
|
||||
terminalsByCwd.set(cwd, terminals);
|
||||
@@ -122,15 +151,25 @@ export function createTerminalManager(): TerminalManager {
|
||||
return terminals;
|
||||
},
|
||||
|
||||
async createTerminal(options: { cwd: string; name?: string }): Promise<TerminalSession> {
|
||||
async createTerminal(options: {
|
||||
cwd: string;
|
||||
name?: string;
|
||||
env?: Record<string, 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({
|
||||
cwd: options.cwd,
|
||||
name: options.name ?? defaultName,
|
||||
...(mergedEnv ? { env: mergedEnv } : {}),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -141,6 +180,11 @@ export function createTerminalManager(): TerminalManager {
|
||||
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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user