Add new vscode shell integration to run commands right in terminal

This commit is contained in:
Saoud Rizwan
2024-09-08 10:30:50 -04:00
parent e6d95eaad4
commit 2c91bafe1e
10 changed files with 410 additions and 663 deletions

View 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
}