diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index ed25a0880b47..2fa613c7c0ba 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -443,6 +443,20 @@ it.layer( fs.writeFileString(filePath, contents), ); + const makePythonVenv = Effect.fn("test.makePythonVenv")(function* (venvPath: string) { + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const scriptsDirectory = path.join(venvPath, platform === "win32" ? "Scripts" : "bin"); + yield* makeDirectory(scriptsDirectory); + yield* writeFileString(path.join(venvPath, "pyvenv.cfg"), "home = /usr/bin\n"); + if (platform === "win32") { + yield* writeFileString(path.join(scriptsDirectory, "Activate.ps1"), ""); + yield* writeFileString(path.join(scriptsDirectory, "activate.bat"), ""); + } else { + yield* writeFileString(path.join(scriptsDirectory, "activate"), ""); + } + }); + it.effect("reports a missing cwd without an artificial cause", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -1475,6 +1489,186 @@ it.layer( }), ); + it.effect("silently activates a project-local Python virtual environment", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const { manager, ptyAdapter, baseDir, getEvents } = yield* createManager(5, { + shellResolver: () => (platform === "win32" ? "pwsh.exe" : "/bin/zsh"), + env: { + PATH: platform === "win32" ? "C:\\Windows\\System32" : "/usr/local/bin:/usr/bin:/bin", + PYTHONHOME: "/inherited/python", + }, + }); + const cwd = path.join(baseDir, "python-project"); + const venvPath = path.join(cwd, "venv"); + yield* makePythonVenv(venvPath); + + yield* manager.open(openInput({ cwd })); + + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + expect(spawnInput.env.VIRTUAL_ENV).toBeUndefined(); + expect(spawnInput.env.PYTHONHOME).toBe("/inherited/python"); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + expect(process.writes).toHaveLength(1); + expect(process.writes[0]).toContain(venvPath); + expect(process.writes[0]).not.toMatch(/clear/i); + + const marker = `\x1eT3_VENV_${process.pid}:0\x1f`; + process.emitData(`echoed activation command\r\n${marker.slice(0, 8)}`); + process.emitData(`${marker.slice(8)}(venv) prompt `); + yield* waitFor( + Effect.map(getEvents, (events) => events.some((event) => event.type === "output")), + ); + + const output = (yield* getEvents) + .filter((event) => event.type === "output") + .map((event) => event.data) + .join(""); + expect(output).toBe("(venv) prompt "); + }), + ); + + it.effect("shows a concise warning when automatic virtual environment activation fails", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const { manager, ptyAdapter, baseDir, getEvents } = yield* createManager(); + const cwd = path.join(baseDir, "python-project"); + const venvPath = path.join(cwd, "venv"); + yield* makePythonVenv(venvPath); + yield* manager.open(openInput({ cwd })); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData(`echoed activation command\r\n\x1eT3_VENV_${process.pid}:1\x1fprompt `); + yield* waitFor( + Effect.map(getEvents, (events) => events.some((event) => event.type === "output")), + ); + + const output = (yield* getEvents) + .filter((event) => event.type === "output") + .map((event) => event.data) + .join(""); + expect(output).toContain( + `Automatic Python virtual environment activation failed: ${venvPath}`, + ); + expect(output.endsWith("prompt ")).toBe(true); + expect(output).not.toContain("echoed activation command"); + }), + ); + + it.effect("releases startup output when the activation marker never arrives", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const { manager, ptyAdapter, baseDir, getEvents } = yield* createManager(); + const cwd = path.join(baseDir, "python-project"); + yield* makePythonVenv(path.join(cwd, "venv")); + yield* manager.open(openInput({ cwd })); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + const startupOutput = "x".repeat(65_536); + process.emitData(startupOutput); + yield* waitFor( + Effect.map(getEvents, (events) => events.some((event) => event.type === "output")), + ); + + const output = (yield* getEvents) + .filter((event) => event.type === "output") + .map((event) => event.data) + .join(""); + expect(output).toBe(startupOutput); + }), + ); + + it.effect("prefers .venv and can activate the project environment for a worktree", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const { manager, ptyAdapter, baseDir } = yield* createManager(5, { + shellResolver: () => (platform === "win32" ? "pwsh.exe" : "/bin/zsh"), + env: { PATH: platform === "win32" ? "C:\\Windows\\System32" : "/usr/bin:/bin" }, + }); + const projectRoot = path.join(baseDir, "python-project"); + const worktreePath = path.join(baseDir, "worktree"); + const dotVenvPath = path.join(projectRoot, ".venv"); + const venvPath = path.join(projectRoot, "venv"); + yield* makeDirectory(worktreePath); + yield* makePythonVenv(dotVenvPath); + yield* makePythonVenv(venvPath); + + yield* manager.open( + openInput({ + cwd: worktreePath, + worktreePath, + env: { T3CODE_PROJECT_ROOT: projectRoot }, + }), + ); + + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + expect(ptyAdapter.processes[0]?.writes[0]).toContain(dotVenvPath); + }), + ); + + it.effect("discovers one non-standard virtual environment in a Python project", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const { manager, ptyAdapter, baseDir } = yield* createManager(); + const cwd = path.join(baseDir, "python-project"); + const venvPath = path.join(cwd, "python-3.12-env"); + yield* makeDirectory(cwd); + yield* writeFileString(path.join(cwd, "pyproject.toml"), "[project]\nname = 'example'\n"); + yield* makePythonVenv(venvPath); + + yield* manager.open(openInput({ cwd })); + + expect(ptyAdapter.processes[0]?.writes[0]).toContain(venvPath); + }), + ); + + it.effect("does not guess between multiple non-standard virtual environments", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const { manager, ptyAdapter, baseDir } = yield* createManager(); + const cwd = path.join(baseDir, "python-project"); + yield* makeDirectory(cwd); + yield* writeFileString(path.join(cwd, "requirements.txt"), "pytest\n"); + yield* makePythonVenv(path.join(cwd, "python-env-a")); + yield* makePythonVenv(path.join(cwd, "python-env-b")); + + yield* manager.open(openInput({ cwd })); + + expect(ptyAdapter.processes[0]?.writes).toEqual([]); + }), + ); + + it.effect("leaves non-Python project terminal environments unchanged", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const { manager, ptyAdapter, baseDir } = yield* createManager(5, { + env: { PATH: "/usr/bin:/bin" }, + }); + const cwd = path.join(baseDir, "non-python-project"); + yield* makeDirectory(cwd); + + yield* manager.open(openInput({ cwd })); + + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + expect(spawnInput.env).toEqual({ PATH: "/usr/bin:/bin" }); + expect(ptyAdapter.processes[0]?.writes).toEqual([]); + }), + ); + it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => Effect.gen(function* () { if ((yield* HostProcessPlatform) === "win32") return; diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 6dc9e1892b63..956965605202 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -82,6 +82,15 @@ const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; const DEFAULT_OPEN_COLS = 120; const DEFAULT_OPEN_ROWS = 30; const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); +const PYTHON_VENV_DIRECTORY_NAMES = [".venv", "venv"] as const; +const PYTHON_PROJECT_MARKER_NAMES = new Set([ + "pyproject.toml", + "requirements.txt", + "setup.py", + "setup.cfg", + "Pipfile", +]); +const MAX_PYTHON_VENV_STARTUP_OUTPUT_LENGTH = 65_536; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const MAX_TERMINAL_LABEL_LENGTH = 128; @@ -1167,6 +1176,89 @@ function createTerminalSpawnEnv( return stripAppImageRuntimeEnv(spawnEnv); } +function quotePosixShellArgument(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +interface PythonVenvActivationBootstrap { + readonly venvPath: string; + readonly command: string; + readonly successMarker: string; + readonly failureMarker: string; +} + +function pythonVenvActivationCommand( + venvPath: string, + shellLabel: string, + platform: NodeJS.Platform, + processPid: number, +): PythonVenvActivationBootstrap { + const markerKey = `T3_VENV_${processPid}`; + + if (platform !== "win32") { + const activationScript = quotePosixShellArgument(`${venvPath}/bin/activate`); + return { + venvPath, + command: ` . ${activationScript} && printf '\\036%s:0\\037' '${markerKey}' || printf '\\036%s:1\\037' '${markerKey}'\r`, + successMarker: `\x1e${markerKey}:0\x1f`, + failureMarker: `\x1e${markerKey}:1\x1f`, + }; + } + + if (/powershell|pwsh/i.test(shellLabel)) { + const activationScript = `${venvPath}\\Scripts\\Activate.ps1`.replaceAll("'", "''"); + const markerExpression = (status: 0 | 1) => `"$([char]30)${markerKey}:${status}$([char]31)"`; + return { + venvPath, + command: `. '${activationScript}'; if ($?) { [Console]::Write(${markerExpression(0)}) } else { [Console]::Write(${markerExpression(1)}) }\r`, + successMarker: `\x1e${markerKey}:0\x1f`, + failureMarker: `\x1e${markerKey}:1\x1f`, + }; + } + + return { + venvPath, + command: `call "${venvPath}\\Scripts\\activate.bat" && echo __T3^_VENV_${processPid}_0__ || echo __T3^_VENV_${processPid}_1__\r`, + successMarker: `__T3_VENV_${processPid}_0__`, + failureMarker: `__T3_VENV_${processPid}_1__`, + }; +} + +interface PythonVenvStartupOutputGate { + readonly venvPath: string; + readonly successMarker: string; + readonly failureMarker: string; + bufferedOutput: string; +} + +function filterPythonVenvStartupOutput( + gate: PythonVenvStartupOutputGate, + data: string, +): { readonly output: string; readonly complete: boolean } { + gate.bufferedOutput += data; + const successIndex = gate.bufferedOutput.indexOf(gate.successMarker); + const failureIndex = gate.bufferedOutput.indexOf(gate.failureMarker); + const succeeded = successIndex >= 0 && (failureIndex < 0 || successIndex < failureIndex); + const markerIndex = succeeded ? successIndex : failureIndex; + + if (markerIndex >= 0) { + const marker = succeeded ? gate.successMarker : gate.failureMarker; + const remainingOutput = gate.bufferedOutput.slice(markerIndex + marker.length); + return { + output: succeeded + ? remainingOutput + : `\r\nAutomatic Python virtual environment activation failed: ${gate.venvPath}\r\n${remainingOutput}`, + complete: true, + }; + } + + if (gate.bufferedOutput.length >= MAX_PYTHON_VENV_STARTUP_OUTPUT_LENGTH) { + return { output: gate.bufferedOutput, complete: true }; + } + + return { output: "", complete: false }; +} + function normalizedRuntimeEnv( env: Record | undefined, ): Record | null { @@ -1241,6 +1333,62 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const registerTerminalProcesses = options.registerTerminalProcesses ?? (() => Effect.void); const unregisterTerminal = options.unregisterTerminal ?? (() => Effect.void); + const isPythonVenv = Effect.fn("terminal.isPythonVenv")(function* (venvPath: string) { + const hasConfig = yield* fileSystem + .exists(path.join(venvPath, "pyvenv.cfg")) + .pipe(Effect.orElseSucceed(() => false)); + if (!hasConfig) return false; + + const activationScripts = + platform === "win32" + ? [ + path.join(venvPath, "Scripts", "Activate.ps1"), + path.join(venvPath, "Scripts", "activate.bat"), + ] + : [path.join(venvPath, "bin", "activate")]; + for (const activationScript of activationScripts) { + if (yield* fileSystem.exists(activationScript).pipe(Effect.orElseSucceed(() => false))) { + return true; + } + } + return false; + }); + + const findPythonVenv = Effect.fn("terminal.findPythonVenv")(function* ( + session: TerminalSessionState, + ) { + const projectRoot = session.runtimeEnv?.T3CODE_PROJECT_ROOT; + const searchRoots = [session.cwd, projectRoot].filter( + (root, index, roots): root is string => Boolean(root) && roots.indexOf(root) === index, + ); + + for (const root of searchRoots) { + for (const directoryName of PYTHON_VENV_DIRECTORY_NAMES) { + const venvPath = path.join(root, directoryName); + if (yield* isPythonVenv(venvPath)) return venvPath; + } + } + + const discovered = new Set(); + for (const root of searchRoots) { + const entries = yield* fileSystem + .readDirectory(root) + .pipe(Effect.orElseSucceed(() => [] as Array)); + const isPythonProject = entries.some( + (entry) => PYTHON_PROJECT_MARKER_NAMES.has(entry) || entry.endsWith(".py"), + ); + if (!isPythonProject) continue; + + for (const entry of entries) { + if (PYTHON_VENV_DIRECTORY_NAMES.some((directoryName) => directoryName === entry)) continue; + const candidate = path.join(root, entry); + if (yield* isPythonVenv(candidate)) discovered.add(candidate); + } + } + + return discovered.size === 1 ? (discovered.values().next().value ?? null) : null; + }); + yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie); const managerStateRef = yield* SynchronizedRef.make({ @@ -1922,22 +2070,54 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func Effect.gen(function* () { const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv); + const pythonVenv = yield* findPythonVenv(session); const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); ptyProcess = spawnResult.process; startedShell = spawnResult.shellLabel; const processPid = ptyProcess.pid; + const activationBootstrap = pythonVenv + ? pythonVenvActivationCommand( + pythonVenv, + spawnResult.shellLabel, + platform, + processPid, + ) + : null; + let startupOutputGate: PythonVenvStartupOutputGate | null = activationBootstrap + ? { + venvPath: activationBootstrap.venvPath, + successMarker: activationBootstrap.successMarker, + failureMarker: activationBootstrap.failureMarker, + bufferedOutput: "", + } + : null; const unsubscribeData = ptyProcess.onData((data) => { - if (!enqueueProcessEvent(session, processPid, { type: "output", data })) { + let output = data; + if (startupOutputGate) { + const filtered = filterPythonVenvStartupOutput(startupOutputGate, data); + output = filtered.output; + if (filtered.complete) startupOutputGate = null; + } + if (output.length === 0) return; + if (!enqueueProcessEvent(session, processPid, { type: "output", data: output })) { return; } runFork(drainProcessEvents(session, processPid)); }); const unsubscribeExit = ptyProcess.onExit((event) => { - if (!enqueueProcessEvent(session, processPid, { type: "exit", event })) { - return; + let shouldDrain = false; + if (startupOutputGate) { + const { venvPath } = startupOutputGate; + startupOutputGate = null; + shouldDrain = enqueueProcessEvent(session, processPid, { + type: "output", + data: `\r\nAutomatic Python virtual environment activation did not complete: ${venvPath}\r\n`, + }); } - runFork(drainProcessEvents(session, processPid)); + shouldDrain = + enqueueProcessEvent(session, processPid, { type: "exit", event }) || shouldDrain; + if (shouldDrain) runFork(drainProcessEvents(session, processPid)); }); let eventStamp: ReturnType = { @@ -1954,6 +2134,19 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return [undefined, state] as const; }); + if (activationBootstrap) { + yield* Effect.try({ + try: () => spawnResult.process.write(activationBootstrap.command), + catch: (cause) => + new TerminalWriteError({ + threadId: session.threadId, + terminalId: session.terminalId, + terminalPid: processPid, + cause, + }), + }); + } + yield* publishEvent({ type: eventType, threadId: session.threadId, diff --git a/docs/user/terminals.md b/docs/user/terminals.md new file mode 100644 index 000000000000..99dd0ad67e01 --- /dev/null +++ b/docs/user/terminals.md @@ -0,0 +1,13 @@ +# Terminals + +The terminal drawer opens in the thread's checkout and uses the shell configured on the server +machine. + +For Python projects, T3 Code silently activates a virtual environment named `.venv` or `venv` when +it contains `pyvenv.cfg` and an activation script. `.venv` takes precedence when both exist. +Worktree threads also use a virtual environment from the original project root when the worktree +does not contain one. + +When neither standard name exists, T3 Code activates an immediate child directory if it is the +only virtual environment found in a Python project. It does not guess when multiple non-standard +environments are present.