mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-11-03 07:25:50 +00:00
379 lines
14 KiB
TypeScript
379 lines
14 KiB
TypeScript
import * as vscode from "vscode"
|
|
import { EventEmitter } from "events"
|
|
import pWaitFor from "p-wait-for"
|
|
import stripAnsi from "strip-ansi"
|
|
|
|
/*
|
|
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
|
|
*/
|
|
|
|
// Although vscode.window.terminals provides a list of all open terminals, there's no way to know whether they're busy or not (exitStatus does not provide useful information for most commands). In order to prevent creating too many terminals, we need to keep track of terminals through the life of the extension, as well as session specific terminals for the life of a task (to get latest unretrieved output).
|
|
// Since we have promises keeping track of terminal processes, we get the added benefit of keep track of busy terminals even after a task is closed.
|
|
class TerminalRegistry {
|
|
private static terminals: TerminalInfo[] = []
|
|
private static nextTerminalId = 1
|
|
|
|
static createTerminal(cwd?: string | vscode.Uri | undefined): TerminalInfo {
|
|
const terminal = vscode.window.createTerminal({
|
|
cwd,
|
|
name: "Claude Dev",
|
|
iconPath: new vscode.ThemeIcon("robot"),
|
|
})
|
|
const newInfo: TerminalInfo = {
|
|
terminal,
|
|
busy: false,
|
|
lastCommand: "",
|
|
id: this.nextTerminalId++,
|
|
}
|
|
this.terminals.push(newInfo)
|
|
return newInfo
|
|
}
|
|
|
|
static getTerminal(id: number): TerminalInfo | undefined {
|
|
const terminalInfo = this.terminals.find((t) => t.id === id)
|
|
if (terminalInfo && this.isTerminalClosed(terminalInfo.terminal)) {
|
|
this.removeTerminal(id)
|
|
return undefined
|
|
}
|
|
return terminalInfo
|
|
}
|
|
|
|
static updateTerminal(id: number, updates: Partial<TerminalInfo>) {
|
|
const terminal = this.getTerminal(id)
|
|
if (terminal) {
|
|
Object.assign(terminal, updates)
|
|
}
|
|
}
|
|
|
|
static removeTerminal(id: number) {
|
|
this.terminals = this.terminals.filter((t) => t.id !== id)
|
|
}
|
|
|
|
static getAllTerminals(): TerminalInfo[] {
|
|
this.terminals = this.terminals.filter((t) => !this.isTerminalClosed(t.terminal))
|
|
return this.terminals
|
|
}
|
|
|
|
// The exit status of the terminal will be undefined while the terminal is active. (This value is set when onDidCloseTerminal is fired.)
|
|
private static isTerminalClosed(terminal: vscode.Terminal): boolean {
|
|
return terminal.exitStatus !== undefined
|
|
}
|
|
}
|
|
|
|
export class TerminalManager {
|
|
private terminalIds: Set<number> = new Set()
|
|
private processes: Map<number, TerminalProcess> = new Map()
|
|
|
|
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", () => {
|
|
console.log(`completed received for terminal ${terminalInfo.id}`)
|
|
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)
|
|
console.log(`Removed terminal ${terminalInfo.id} from TerminalManager`)
|
|
})
|
|
|
|
const promise = new Promise<void>((resolve, reject) => {
|
|
process.once("continue", () => {
|
|
console.log(`continue received for terminal ${terminalInfo.id}`)
|
|
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
|
|
if (terminalInfo.terminal.shellIntegration) {
|
|
console.log(`Shell integration active for terminal ${terminalInfo.id}, running command immediately`)
|
|
process.waitForShellIntegration = false
|
|
process.run(terminalInfo.terminal, command)
|
|
} else {
|
|
console.log(`Waiting for shell integration for terminal ${terminalInfo.id}`)
|
|
// docs recommend waiting 3s for shell integration to activate
|
|
pWaitFor(() => terminalInfo.terminal.shellIntegration !== undefined, { timeout: 4000 }).finally(() => {
|
|
console.log(
|
|
`Shell integration ${
|
|
terminalInfo.terminal.shellIntegration ? "activated" : "not activated"
|
|
} for terminal ${terminalInfo.id}`
|
|
)
|
|
|
|
const existingProcess = this.processes.get(terminalInfo.id)
|
|
if (existingProcess && existingProcess.waitForShellIntegration) {
|
|
existingProcess.waitForShellIntegration = false
|
|
existingProcess.run(terminalInfo.terminal, command)
|
|
}
|
|
})
|
|
}
|
|
|
|
return mergePromise(process, promise)
|
|
}
|
|
|
|
async getOrCreateTerminal(cwd: string): Promise<TerminalInfo> {
|
|
// Find available terminal from our pool first (created for this task)
|
|
const availableTerminal = TerminalRegistry.getAllTerminals().find((t) => {
|
|
if (t.busy) {
|
|
return false
|
|
}
|
|
const terminalCwd = t.terminal.shellIntegration?.cwd // one of claude's commands could have changed the cwd of the terminal
|
|
if (!terminalCwd) {
|
|
return false
|
|
}
|
|
return vscode.Uri.file(cwd).fsPath === terminalCwd.fsPath
|
|
})
|
|
if (availableTerminal) {
|
|
console.log("Reusing terminal", availableTerminal.id)
|
|
this.terminalIds.add(availableTerminal.id)
|
|
return availableTerminal
|
|
}
|
|
|
|
const newTerminalInfo = TerminalRegistry.createTerminal(cwd)
|
|
this.terminalIds.add(newTerminalInfo.id)
|
|
console.log("Created new terminal", newTerminalInfo.id)
|
|
return newTerminalInfo
|
|
}
|
|
|
|
getBusyTerminals(): { id: number; lastCommand: string }[] {
|
|
return Array.from(this.terminalIds)
|
|
.map((id) => TerminalRegistry.getTerminal(id))
|
|
.filter((t): t is TerminalInfo => t !== undefined && t.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() : ""
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
interface TerminalInfo {
|
|
terminal: vscode.Terminal
|
|
busy: boolean
|
|
lastCommand: string
|
|
id: number
|
|
}
|
|
|
|
interface TerminalProcessEvents {
|
|
line: [line: string]
|
|
continue: []
|
|
completed: []
|
|
error: [error: Error]
|
|
no_shell_integration: []
|
|
}
|
|
|
|
export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
|
waitForShellIntegration: boolean = true
|
|
private isListening: boolean = true
|
|
private buffer: string = ""
|
|
private fullOutput: string = ""
|
|
private lastRetrievedIndex: number = 0
|
|
|
|
// constructor() {
|
|
// super()
|
|
|
|
async run(terminal: vscode.Terminal, command: string) {
|
|
if (terminal.shellIntegration && terminal.shellIntegration.executeCommand) {
|
|
console.log(`Shell integration available for terminal`)
|
|
const execution = terminal.shellIntegration.executeCommand(command)
|
|
const stream = execution.read()
|
|
// todo: need to handle errors
|
|
let isFirstChunk = true
|
|
for await (let data of stream) {
|
|
if (isFirstChunk) {
|
|
/*
|
|
The first chunk we get from this stream needs to be processed to be more human readable, ie remove vscode's custom escape sequences and identifiers, removing duplicate first char bug, etc.
|
|
*/
|
|
// https://code.visualstudio.com/docs/terminal/shell-integration#_vs-code-custom-sequences-osc-633-st
|
|
const vscodeSequenceRegex = /\x1b\]633;.[^\x07]*\x07/g
|
|
data = stripAnsi(data.replace(vscodeSequenceRegex, ""))
|
|
// Split data by newlines
|
|
let lines = data.split("\n")
|
|
// Remove non-human readable characters from the first line
|
|
if (lines.length > 0) {
|
|
lines[0] = lines[0].replace(/[^\x20-\x7E]/g, "")
|
|
}
|
|
// Check if first two characters are the same, if so remove the first character
|
|
if (lines.length > 0 && lines[0].length >= 2 && lines[0][0] === lines[0][1]) {
|
|
lines[0] = lines[0].slice(1)
|
|
}
|
|
// Process second line: remove everything up to the first alphanumeric character
|
|
if (lines.length > 1) {
|
|
lines[1] = lines[1].replace(/^[^a-zA-Z0-9]*/, "")
|
|
}
|
|
// Remove the first line if it matches the command (case-insensitive)
|
|
if (lines.length > 0 && lines[0].trim().toLowerCase() === command.trim().toLowerCase()) {
|
|
lines.shift()
|
|
}
|
|
// Join lines back
|
|
data = lines.join("\n")
|
|
isFirstChunk = false
|
|
} else {
|
|
data = stripAnsi(data)
|
|
}
|
|
console.log(`Received data chunk for terminal:`, data)
|
|
this.fullOutput += data
|
|
if (this.isListening) {
|
|
console.log(`Emitting data for terminal`)
|
|
this.emitIfEol(data)
|
|
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
|
|
}
|
|
}
|
|
|
|
// Emit any remaining content in the buffer
|
|
if (this.buffer && this.isListening) {
|
|
const remainingBuffer = this.buffer.trim()
|
|
if (remainingBuffer !== "%") {
|
|
// for some reason vscode likes to end stream with %
|
|
console.log(`Emitting remaining buffer for terminal:`, remainingBuffer)
|
|
this.emit("line", remainingBuffer)
|
|
}
|
|
this.buffer = ""
|
|
this.lastRetrievedIndex = this.fullOutput.length
|
|
}
|
|
console.log(`Command execution completed for terminal`)
|
|
this.emit("completed")
|
|
this.emit("continue")
|
|
} else {
|
|
console.log(`Shell integration not available for terminal, falling back to sendText`)
|
|
terminal.sendText(command, true)
|
|
// For terminals without shell integration, we can't know when the command completes
|
|
// So we'll just emit the continue event after a delay
|
|
this.emit("completed")
|
|
this.emit("continue")
|
|
this.emit("no_shell_integration")
|
|
// setTimeout(() => {
|
|
// console.log(`Emitting continue after delay for terminal`)
|
|
// // can't emit completed since we don't if the command actually completed, it could still be running server
|
|
// }, 500) // Adjust this delay as needed
|
|
}
|
|
}
|
|
|
|
// Inspired by https://github.com/sindresorhus/execa/blob/main/lib/transform/split.js
|
|
private emitIfEol(chunk: string) {
|
|
this.buffer += chunk
|
|
let lineEndIndex: number
|
|
while ((lineEndIndex = this.buffer.indexOf("\n")) !== -1) {
|
|
let line = this.buffer.slice(0, lineEndIndex).trim() // removes trailing \r
|
|
// Remove \r if present (for Windows-style line endings)
|
|
// if (line.endsWith("\r")) {
|
|
// line = line.slice(0, -1)
|
|
// }
|
|
this.emit("line", line)
|
|
this.buffer = this.buffer.slice(lineEndIndex + 1)
|
|
}
|
|
}
|
|
|
|
continue() {
|
|
// Emit any remaining content in the buffer
|
|
if (this.buffer && this.isListening) {
|
|
console.log(`Emitting remaining buffer for terminal:`, this.buffer.trim())
|
|
this.emit("line", this.buffer.trim())
|
|
this.buffer = ""
|
|
this.lastRetrievedIndex = this.fullOutput.length
|
|
}
|
|
|
|
this.isListening = false
|
|
this.removeAllListeners("line")
|
|
this.emit("continue")
|
|
}
|
|
|
|
getUnretrievedOutput(): string {
|
|
const unretrieved = this.fullOutput.slice(this.lastRetrievedIndex)
|
|
this.lastRetrievedIndex = this.fullOutput.length
|
|
return unretrieved
|
|
}
|
|
}
|
|
|
|
export type TerminalProcessResultPromise = TerminalProcess & Promise<void>
|
|
|
|
// Similar to execa's ResultPromise, this lets us create a mixin of both a TerminalProcess and a Promise: https://github.com/sindresorhus/execa/blob/main/lib/methods/promise.js
|
|
function mergePromise(process: TerminalProcess, promise: Promise<void>): TerminalProcessResultPromise {
|
|
const nativePromisePrototype = (async () => {})().constructor.prototype
|
|
const descriptors = ["then", "catch", "finally"].map(
|
|
(property) => [property, Reflect.getOwnPropertyDescriptor(nativePromisePrototype, property)] as const
|
|
)
|
|
for (const [property, descriptor] of descriptors) {
|
|
if (descriptor) {
|
|
const value = descriptor.value.bind(promise)
|
|
Reflect.defineProperty(process, property, { ...descriptor, value })
|
|
}
|
|
}
|
|
return process as TerminalProcessResultPromise
|
|
}
|