feat: improve agent workspace flows and directory suggestions

This commit is contained in:
Mohamed Boudra
2026-02-16 23:19:01 +07:00
parent 22da0951d5
commit 170ee27813
49 changed files with 3053 additions and 293 deletions
@@ -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);
},