mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-22 05:11:06 -05:00
Add new vscode shell integration to run commands right in terminal
This commit is contained in:
328
src/integrations/TerminalManager.ts
Normal file
328
src/integrations/TerminalManager.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import * as vscode from "vscode"
|
||||
import { EventEmitter } from "events"
|
||||
import delay from "delay"
|
||||
|
||||
/*
|
||||
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
|
||||
|
||||
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);
|
||||
*/
|
||||
|
||||
export class TerminalManager {
|
||||
private static readonly TERMINAL_NAME = "Claude Dev"
|
||||
private terminals: TerminalInfo[] = []
|
||||
private processes: Map<number, TerminalProcess> = new Map()
|
||||
private context: vscode.ExtensionContext
|
||||
private nextTerminalId = 1
|
||||
|
||||
constructor(context: vscode.ExtensionContext) {
|
||||
this.context = context
|
||||
this.setupListeners()
|
||||
}
|
||||
|
||||
private setupListeners() {
|
||||
// todo: make sure we do this check everywhere we use the new terminal APIs
|
||||
if (hasShellIntegrationApis()) {
|
||||
this.context.subscriptions.push(
|
||||
vscode.window.onDidOpenTerminal(this.handleOpenTerminal.bind(this)),
|
||||
vscode.window.onDidCloseTerminal(this.handleClosedTerminal.bind(this)),
|
||||
vscode.window.onDidChangeTerminalShellIntegration(this.handleShellIntegrationChange.bind(this)),
|
||||
vscode.window.onDidStartTerminalShellExecution(this.handleShellExecutionStart.bind(this)),
|
||||
vscode.window.onDidEndTerminalShellExecution(this.handleShellExecutionEnd.bind(this))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
runCommand(terminalInfo: TerminalInfo, command: string, cwd: string): TerminalProcessResultPromise {
|
||||
terminalInfo.busy = true
|
||||
terminalInfo.lastCommand = command
|
||||
|
||||
const process = new TerminalProcess(terminalInfo, command)
|
||||
|
||||
this.processes.set(terminalInfo.id, process)
|
||||
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
process.once(CONTINUE_EVENT, () => {
|
||||
console.log("2")
|
||||
resolve()
|
||||
})
|
||||
process.once("error", reject)
|
||||
})
|
||||
|
||||
// if shell integration is already active, run the command immediately
|
||||
if (terminalInfo.terminal.shellIntegration) {
|
||||
process.waitForShellIntegration = false
|
||||
process.run()
|
||||
}
|
||||
|
||||
if (hasShellIntegrationApis()) {
|
||||
// Fallback to sendText if there is no shell integration within 3 seconds of launching (could be because the user is not running one of the supported shells)
|
||||
setTimeout(() => {
|
||||
if (!terminalInfo.terminal.shellIntegration) {
|
||||
process.waitForShellIntegration = false
|
||||
process.run()
|
||||
// Without shell integration, we can't know when the command has finished or what the
|
||||
// exit code was.
|
||||
}
|
||||
}, 3000)
|
||||
} else {
|
||||
// User doesn't have shell integration API available, run command the old way
|
||||
process.waitForShellIntegration = false
|
||||
process.run()
|
||||
}
|
||||
|
||||
// Merge the process and promise
|
||||
return mergePromise(process, promise)
|
||||
}
|
||||
|
||||
async getOrCreateTerminal(cwd: string): Promise<TerminalInfo> {
|
||||
const availableTerminal = this.terminals.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)
|
||||
return availableTerminal
|
||||
}
|
||||
|
||||
const newTerminal = vscode.window.createTerminal({
|
||||
name: `${TerminalManager.TERMINAL_NAME} ${this.nextTerminalId}`,
|
||||
cwd: cwd,
|
||||
})
|
||||
const newTerminalInfo: TerminalInfo = {
|
||||
terminal: newTerminal,
|
||||
busy: false,
|
||||
lastCommand: "",
|
||||
id: this.nextTerminalId++,
|
||||
}
|
||||
this.terminals.push(newTerminalInfo)
|
||||
return newTerminalInfo
|
||||
}
|
||||
|
||||
private handleOpenTerminal(terminal: vscode.Terminal) {
|
||||
console.log(`Terminal opened: ${terminal.name}`)
|
||||
}
|
||||
|
||||
private handleClosedTerminal(terminal: vscode.Terminal) {
|
||||
const index = this.terminals.findIndex((t) => t.terminal === terminal)
|
||||
if (index !== -1) {
|
||||
const terminalInfo = this.terminals[index]
|
||||
this.terminals.splice(index, 1)
|
||||
this.processes.delete(terminalInfo.id)
|
||||
}
|
||||
console.log(`Terminal closed: ${terminal.name}`)
|
||||
}
|
||||
|
||||
private handleShellIntegrationChange(e: vscode.TerminalShellIntegrationChangeEvent) {
|
||||
const terminalInfo = this.terminals.find((t) => t.terminal === e.terminal)
|
||||
if (terminalInfo) {
|
||||
const process = this.processes.get(terminalInfo.id)
|
||||
if (process && process.waitForShellIntegration) {
|
||||
process.waitForShellIntegration = false
|
||||
process.run()
|
||||
}
|
||||
console.log(`Shell integration activated for terminal: ${e.terminal.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
private handleShellExecutionStart(e: vscode.TerminalShellExecutionStartEvent) {
|
||||
const terminalInfo = this.terminals.find((t) => t.terminal === e.terminal)
|
||||
if (terminalInfo) {
|
||||
terminalInfo.busy = true
|
||||
terminalInfo.lastCommand = e.execution.commandLine.value
|
||||
console.log(`Command started in terminal ${terminalInfo.id}: ${terminalInfo.lastCommand}`)
|
||||
}
|
||||
}
|
||||
|
||||
private handleShellExecutionEnd(e: vscode.TerminalShellExecutionEndEvent) {
|
||||
const terminalInfo = this.terminals.find((t) => t.terminal === e.terminal)
|
||||
if (terminalInfo) {
|
||||
this.handleCommandCompletion(terminalInfo, e.exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
private handleCommandCompletion(terminalInfo: TerminalInfo, exitCode?: number | undefined) {
|
||||
terminalInfo.busy = false
|
||||
console.log(
|
||||
`Command "${terminalInfo.lastCommand}" in terminal ${terminalInfo.id} completed with exit code: ${exitCode}`
|
||||
)
|
||||
}
|
||||
|
||||
getBusyTerminals(): { id: number; lastCommand: string }[] {
|
||||
return this.terminals.filter((t) => t.busy).map((t) => ({ id: t.id, lastCommand: t.lastCommand }))
|
||||
}
|
||||
|
||||
hasBusyTerminals(): boolean {
|
||||
return this.terminals.some((t) => t.busy)
|
||||
}
|
||||
|
||||
getUnretrievedOutput(terminalId: number): string {
|
||||
const process = this.processes.get(terminalId)
|
||||
if (!process) {
|
||||
return ""
|
||||
}
|
||||
return process.getUnretrievedOutput()
|
||||
}
|
||||
|
||||
disposeAll() {
|
||||
for (const info of this.terminals) {
|
||||
info.terminal.dispose() // todo do we want to do this? test with tab view closing it
|
||||
}
|
||||
this.terminals = []
|
||||
this.processes.clear()
|
||||
}
|
||||
}
|
||||
|
||||
function hasShellIntegrationApis(): boolean {
|
||||
const [major, minor] = vscode.version.split(".").map(Number)
|
||||
return major > 1 || (major === 1 && minor >= 93)
|
||||
}
|
||||
|
||||
interface TerminalInfo {
|
||||
terminal: vscode.Terminal
|
||||
busy: boolean
|
||||
lastCommand: string
|
||||
id: number
|
||||
}
|
||||
|
||||
const CONTINUE_EVENT = "CONTINUE_EVENT"
|
||||
|
||||
export class TerminalProcess extends EventEmitter {
|
||||
waitForShellIntegration: boolean = true
|
||||
private isListening: boolean = true
|
||||
private buffer: string = ""
|
||||
private execution?: vscode.TerminalShellExecution
|
||||
private stream?: AsyncIterable<string>
|
||||
private fullOutput: string = ""
|
||||
private lastRetrievedIndex: number = 0
|
||||
|
||||
constructor(public terminalInfo: TerminalInfo, private command: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
async run() {
|
||||
if (this.terminalInfo.terminal.shellIntegration) {
|
||||
this.execution = this.terminalInfo.terminal.shellIntegration.executeCommand(this.command)
|
||||
this.stream = this.execution.read()
|
||||
// todo: need to handle errors
|
||||
let isFirstChunk = true // ignore first chunk since it's vscode shell integration marker
|
||||
for await (const data of this.stream) {
|
||||
console.log("data", data)
|
||||
if (!isFirstChunk) {
|
||||
this.fullOutput += data
|
||||
if (this.isListening) {
|
||||
this.emitIfEol(data)
|
||||
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
|
||||
}
|
||||
} else {
|
||||
isFirstChunk = false
|
||||
}
|
||||
}
|
||||
|
||||
// Emit any remaining content in the buffer
|
||||
if (this.buffer && this.isListening) {
|
||||
this.emit("line", this.buffer.trim())
|
||||
this.buffer = ""
|
||||
this.lastRetrievedIndex = this.fullOutput.length
|
||||
}
|
||||
|
||||
this.emit(CONTINUE_EVENT)
|
||||
} else {
|
||||
this.terminalInfo.terminal.sendText(this.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
|
||||
setTimeout(() => {
|
||||
this.emit(CONTINUE_EVENT)
|
||||
}, 2000) // 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()
|
||||
// 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() {
|
||||
this.isListening = false
|
||||
this.removeAllListeners("line")
|
||||
this.emit(CONTINUE_EVENT)
|
||||
}
|
||||
|
||||
isStillListening() {
|
||||
return this.isListening
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user