From 528bf98d43904882fb0c0789b239a53e1d16bbf4 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Mon, 10 Aug 2026 17:44:19 -0500 Subject: [PATCH 1/2] feat(server): activate Python venvs in terminals --- apps/server/src/terminal/Manager.test.ts | 87 ++++++++++++++++++++++++ apps/server/src/terminal/Manager.ts | 61 +++++++++++++++++ docs/user/terminals.md | 9 +++ 3 files changed, 157 insertions(+) create mode 100644 docs/user/terminals.md diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index ed25a0880b47..de1b5910b03c 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1475,6 +1475,93 @@ it.layer( }), ); + it.effect("automatically activates a project-local Python virtual environment", () => + 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/local/bin:/usr/bin:/bin", + PYTHONHOME: "/inherited/python", + }, + }); + const cwd = path.join(baseDir, "python-project"); + const venvPath = path.join(cwd, "venv"); + yield* makeDirectory(venvPath); + yield* writeFileString(path.join(venvPath, "pyvenv.cfg"), "home = /usr/bin\n"); + + 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"); + expect(ptyAdapter.processes[0]?.writes).toEqual([ + platform === "win32" + ? `. '${venvPath}\\Scripts\\Activate.ps1'; Clear-Host\r` + : ` . '${venvPath}/bin/activate'; printf '\\033[2J\\033[H'\r`, + ]); + }), + ); + + 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* makeDirectory(dotVenvPath); + yield* makeDirectory(venvPath); + yield* writeFileString(path.join(dotVenvPath, "pyvenv.cfg"), "home = /usr/bin\n"); + yield* writeFileString(path.join(venvPath, "pyvenv.cfg"), "home = /usr/bin\n"); + + 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).toEqual([ + platform === "win32" + ? `. '${dotVenvPath}\\Scripts\\Activate.ps1'; Clear-Host\r` + : ` . '${dotVenvPath}/bin/activate'; printf '\\033[2J\\033[H'\r`, + ]); + }), + ); + + 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..0c7c31933d4c 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -82,6 +82,7 @@ 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 nowIso = Effect.map(DateTime.now, DateTime.formatIso); const MAX_TERMINAL_LABEL_LENGTH = 128; @@ -1167,6 +1168,28 @@ function createTerminalSpawnEnv( return stripAppImageRuntimeEnv(spawnEnv); } +function quotePosixShellArgument(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function pythonVenvActivationCommand( + venvPath: string, + shellLabel: string, + platform: NodeJS.Platform, +): string { + if (platform !== "win32") { + const activationScript = quotePosixShellArgument(`${venvPath}/bin/activate`); + return ` . ${activationScript}; printf '\\033[2J\\033[H'\r`; + } + + if (/powershell|pwsh/i.test(shellLabel)) { + const activationScript = `${venvPath}\\Scripts\\Activate.ps1`.replaceAll("'", "''"); + return `. '${activationScript}'; Clear-Host\r`; + } + + return `call "${venvPath}\\Scripts\\activate.bat" && cls\r`; +} + function normalizedRuntimeEnv( env: Record | undefined, ): Record | null { @@ -1241,6 +1264,27 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const registerTerminalProcesses = options.registerTerminalProcesses ?? (() => Effect.void); const unregisterTerminal = options.unregisterTerminal ?? (() => Effect.void); + 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); + const configPath = path.join(venvPath, "pyvenv.cfg"); + const exists = yield* fileSystem.exists(configPath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) continue; + return venvPath; + } + } + + return null; + }); + yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie); const managerStateRef = yield* SynchronizedRef.make({ @@ -1922,6 +1966,7 @@ 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; @@ -1954,6 +1999,22 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return [undefined, state] as const; }); + if (pythonVenv) { + yield* Effect.try({ + try: () => + spawnResult.process.write( + pythonVenvActivationCommand(pythonVenv, spawnResult.shellLabel, platform), + ), + 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..7abff9190e0c --- /dev/null +++ b/docs/user/terminals.md @@ -0,0 +1,9 @@ +# Terminals + +The terminal drawer opens in the thread's checkout and uses the shell configured on the server +machine. + +For Python projects, T3 Code automatically activates a virtual environment named `.venv` or +`venv` when it contains `pyvenv.cfg`. `.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. From 1f550318c3708107da5504e9dd74f5e6290c2bda Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Mon, 10 Aug 2026 17:50:18 -0500 Subject: [PATCH 2/2] feat(server): silently discover terminal venvs --- apps/server/src/terminal/Manager.test.ts | 143 ++++++++++++++++--- apps/server/src/terminal/Manager.ts | 168 ++++++++++++++++++++--- docs/user/terminals.md | 12 +- 3 files changed, 283 insertions(+), 40 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index de1b5910b03c..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,11 +1489,11 @@ it.layer( }), ); - it.effect("automatically activates a project-local Python virtual environment", () => + 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 } = yield* createManager(5, { + 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", @@ -1488,8 +1502,7 @@ it.layer( }); const cwd = path.join(baseDir, "python-project"); const venvPath = path.join(cwd, "venv"); - yield* makeDirectory(venvPath); - yield* writeFileString(path.join(venvPath, "pyvenv.cfg"), "home = /usr/bin\n"); + yield* makePythonVenv(venvPath); yield* manager.open(openInput({ cwd })); @@ -1498,11 +1511,79 @@ it.layer( if (!spawnInput) return; expect(spawnInput.env.VIRTUAL_ENV).toBeUndefined(); expect(spawnInput.env.PYTHONHOME).toBe("/inherited/python"); - expect(ptyAdapter.processes[0]?.writes).toEqual([ - platform === "win32" - ? `. '${venvPath}\\Scripts\\Activate.ps1'; Clear-Host\r` - : ` . '${venvPath}/bin/activate'; printf '\\033[2J\\033[H'\r`, - ]); + 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); }), ); @@ -1519,10 +1600,8 @@ it.layer( const dotVenvPath = path.join(projectRoot, ".venv"); const venvPath = path.join(projectRoot, "venv"); yield* makeDirectory(worktreePath); - yield* makeDirectory(dotVenvPath); - yield* makeDirectory(venvPath); - yield* writeFileString(path.join(dotVenvPath, "pyvenv.cfg"), "home = /usr/bin\n"); - yield* writeFileString(path.join(venvPath, "pyvenv.cfg"), "home = /usr/bin\n"); + yield* makePythonVenv(dotVenvPath); + yield* makePythonVenv(venvPath); yield* manager.open( openInput({ @@ -1535,11 +1614,39 @@ it.layer( const spawnInput = ptyAdapter.spawnInputs[0]; expect(spawnInput).toBeDefined(); if (!spawnInput) return; - expect(ptyAdapter.processes[0]?.writes).toEqual([ - platform === "win32" - ? `. '${dotVenvPath}\\Scripts\\Activate.ps1'; Clear-Host\r` - : ` . '${dotVenvPath}/bin/activate'; printf '\\033[2J\\033[H'\r`, - ]); + 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([]); }), ); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 0c7c31933d4c..956965605202 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -83,6 +83,14 @@ 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; @@ -1172,22 +1180,83 @@ 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, -): string { + processPid: number, +): PythonVenvActivationBootstrap { + const markerKey = `T3_VENV_${processPid}`; + if (platform !== "win32") { const activationScript = quotePosixShellArgument(`${venvPath}/bin/activate`); - return ` . ${activationScript}; printf '\\033[2J\\033[H'\r`; + 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("'", "''"); - return `. '${activationScript}'; Clear-Host\r`; + 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 `call "${venvPath}\\Scripts\\activate.bat" && cls\r`; + return { output: "", complete: false }; } function normalizedRuntimeEnv( @@ -1264,6 +1333,27 @@ 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, ) { @@ -1275,14 +1365,28 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func for (const root of searchRoots) { for (const directoryName of PYTHON_VENV_DIRECTORY_NAMES) { const venvPath = path.join(root, directoryName); - const configPath = path.join(venvPath, "pyvenv.cfg"); - const exists = yield* fileSystem.exists(configPath).pipe(Effect.orElseSucceed(() => false)); - if (!exists) continue; - return venvPath; + if (yield* isPythonVenv(venvPath)) return venvPath; } } - return null; + 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); @@ -1972,17 +2076,48 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func 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 = { @@ -1999,12 +2134,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return [undefined, state] as const; }); - if (pythonVenv) { + if (activationBootstrap) { yield* Effect.try({ - try: () => - spawnResult.process.write( - pythonVenvActivationCommand(pythonVenv, spawnResult.shellLabel, platform), - ), + try: () => spawnResult.process.write(activationBootstrap.command), catch: (cause) => new TerminalWriteError({ threadId: session.threadId, diff --git a/docs/user/terminals.md b/docs/user/terminals.md index 7abff9190e0c..99dd0ad67e01 100644 --- a/docs/user/terminals.md +++ b/docs/user/terminals.md @@ -3,7 +3,11 @@ The terminal drawer opens in the thread's checkout and uses the shell configured on the server machine. -For Python projects, T3 Code automatically activates a virtual environment named `.venv` or -`venv` when it contains `pyvenv.cfg`. `.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. +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.