import pWaitFor from "p-wait-for" import * as vscode from "vscode" import { arePathsEqual } from "../../utils/path" import { mergePromise, TerminalProcess, TerminalProcessResultPromise } from "./TerminalProcess" import { TerminalInfo, TerminalRegistry } from "./TerminalRegistry" /* TerminalManager: - Creates/reuses terminals - Runs commands via runCommand(), returning a TerminalProcess - Handles shell integration events TerminalProcess extends EventEmitter and implements Promise: - Emits 'line' events with output while promise is pending - process.continue() resolves promise and stops event emission - Allows real-time output handling or background execution getUnretrievedOutput() fetches latest output for ongoing commands Enables flexible command execution: - Await for completion - Listen to real-time events - Continue execution in background - Retrieve missed output later Notes: - it turns out some shellIntegration APIs are available on cursor, although not on older versions of vscode - "By default, the shell integration script should automatically activate on supported shells launched from VS Code." Supported shells: Linux/macOS: bash, fish, pwsh, zsh Windows: pwsh Example: const terminalManager = new TerminalManager(context); // Run a command const process = terminalManager.runCommand('npm install', '/path/to/project'); process.on('line', (line) => { console.log(line); }); // To wait for the process to complete naturally: await process; // Or to continue execution even if the command is still running: process.continue(); // Later, if you need to get the unretrieved output: const unretrievedOutput = terminalManager.getUnretrievedOutput(terminalId); console.log('Unretrieved output:', unretrievedOutput); Resources: - https://github.com/microsoft/vscode/issues/226655 - https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api - https://code.visualstudio.com/docs/terminal/shell-integration - https://code.visualstudio.com/api/references/vscode-api#Terminal - https://github.com/microsoft/vscode-extension-samples/blob/main/terminal-sample/src/extension.ts - https://github.com/microsoft/vscode-extension-samples/blob/main/shell-integration-sample/src/extension.ts */ /* The new shellIntegration API gives us access to terminal command execution output handling. However, we don't update our VSCode type definitions or engine requirements to maintain compatibility with older VSCode versions. Users on older versions will automatically fall back to using sendText for terminal command execution. Interestingly, some environments like Cursor enable these APIs even without the latest VSCode engine. This approach allows us to leverage advanced features when available while ensuring broad compatibility. */ declare module "vscode" { // https://github.com/microsoft/vscode/blob/f0417069c62e20f3667506f4b7e53ca0004b4e3e/src/vscode-dts/vscode.d.ts#L10794 interface Window { onDidStartTerminalShellExecution?: ( listener: (e: any) => any, thisArgs?: any, disposables?: vscode.Disposable[], ) => vscode.Disposable } } // Extend the Terminal type to include our custom properties type ExtendedTerminal = vscode.Terminal & { shellIntegration?: { cwd?: vscode.Uri executeCommand?: (command: string) => { read: () => AsyncIterable } } } export class TerminalManager { private terminalIds: Set = new Set() private processes: Map = new Map() private disposables: vscode.Disposable[] = [] constructor() { let disposable: vscode.Disposable | undefined try { disposable = (vscode.window as vscode.Window).onDidStartTerminalShellExecution?.(async (e) => { // Creating a read stream here results in a more consistent output. This is most obvious when running the `date` command. e?.execution?.read() }) } catch (error) { // console.error("Error setting up onDidEndTerminalShellExecution", error) } if (disposable) { this.disposables.push(disposable) } } runCommand(terminalInfo: TerminalInfo, command: string): TerminalProcessResultPromise { terminalInfo.busy = true terminalInfo.lastCommand = command const process = new TerminalProcess() this.processes.set(terminalInfo.id, process) process.once("completed", () => { terminalInfo.busy = false }) // if shell integration is not available, remove terminal so it does not get reused as it may be running a long-running process process.once("no_shell_integration", () => { console.log(`no_shell_integration received for terminal ${terminalInfo.id}`) // Remove the terminal so we can't reuse it (in case it's running a long-running process) TerminalRegistry.removeTerminal(terminalInfo.id) this.terminalIds.delete(terminalInfo.id) this.processes.delete(terminalInfo.id) }) const promise = new Promise((resolve, reject) => { process.once("continue", () => { resolve() }) process.once("error", (error) => { console.error(`Error in terminal ${terminalInfo.id}:`, error) reject(error) }) }) // if shell integration is already active, run the command immediately const terminal = terminalInfo.terminal as ExtendedTerminal if (terminal.shellIntegration) { process.waitForShellIntegration = false process.run(terminal, command) } else { // docs recommend waiting 3s for shell integration to activate pWaitFor(() => (terminalInfo.terminal as ExtendedTerminal).shellIntegration !== undefined, { timeout: 4000 }).finally(() => { const existingProcess = this.processes.get(terminalInfo.id) if (existingProcess && existingProcess.waitForShellIntegration) { existingProcess.waitForShellIntegration = false existingProcess.run(terminal, command) } }) } return mergePromise(process, promise) } async getOrCreateTerminal(cwd: string): Promise { // Find available terminal from our pool first (created for this task) const availableTerminal = TerminalRegistry.getAllTerminals().find((t) => { if (t.busy) { return false } const terminal = t.terminal as ExtendedTerminal const terminalCwd = terminal.shellIntegration?.cwd // one of cline's commands could have changed the cwd of the terminal if (!terminalCwd) { return false } return arePathsEqual(vscode.Uri.file(cwd).fsPath, terminalCwd.fsPath) }) if (availableTerminal) { this.terminalIds.add(availableTerminal.id) return availableTerminal } const newTerminalInfo = TerminalRegistry.createTerminal(cwd) this.terminalIds.add(newTerminalInfo.id) return newTerminalInfo } getTerminals(busy: boolean): { id: number; lastCommand: string }[] { return Array.from(this.terminalIds) .map((id) => TerminalRegistry.getTerminal(id)) .filter((t): t is TerminalInfo => t !== undefined && t.busy === busy) .map((t) => ({ id: t.id, lastCommand: t.lastCommand })) } getUnretrievedOutput(terminalId: number): string { if (!this.terminalIds.has(terminalId)) { return "" } const process = this.processes.get(terminalId) return process ? process.getUnretrievedOutput() : "" } isProcessHot(terminalId: number): boolean { const process = this.processes.get(terminalId) return process ? process.isHot : false } disposeAll() { // for (const info of this.terminals) { // //info.terminal.dispose() // dont want to dispose terminals when task is aborted // } this.terminalIds.clear() this.processes.clear() this.disposables.forEach((disposable) => disposable.dispose()) this.disposables = [] } }