TerminalManager.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. import * as vscode from "vscode"
  2. import { EventEmitter } from "events"
  3. import delay from "delay"
  4. /*
  5. TerminalManager:
  6. - Creates/reuses terminals
  7. - Runs commands via runCommand(), returning a TerminalProcess
  8. - Handles shell integration events
  9. TerminalProcess extends EventEmitter and implements Promise:
  10. - Emits 'line' events with output while promise is pending
  11. - process.continue() resolves promise and stops event emission
  12. - Allows real-time output handling or background execution
  13. getUnretrievedOutput() fetches latest output for ongoing commands
  14. Enables flexible command execution:
  15. - Await for completion
  16. - Listen to real-time events
  17. - Continue execution in background
  18. - Retrieve missed output later
  19. Example:
  20. const terminalManager = new TerminalManager(context);
  21. // Run a command
  22. const process = terminalManager.runCommand('npm install', '/path/to/project');
  23. process.on('line', (line) => {
  24. console.log(line);
  25. });
  26. // To wait for the process to complete naturally:
  27. await process;
  28. // Or to continue execution even if the command is still running:
  29. process.continue();
  30. // Later, if you need to get the unretrieved output:
  31. const unretrievedOutput = terminalManager.getUnretrievedOutput(terminalId);
  32. console.log('Unretrieved output:', unretrievedOutput);
  33. */
  34. export class TerminalManager {
  35. private terminals: TerminalInfo[] = []
  36. private processes: Map<number, TerminalProcess> = new Map()
  37. private context: vscode.ExtensionContext
  38. private nextTerminalId = 1
  39. constructor(context: vscode.ExtensionContext) {
  40. this.context = context
  41. this.setupListeners()
  42. }
  43. private setupListeners() {
  44. // todo: make sure we do this check everywhere we use the new terminal APIs
  45. if (hasShellIntegrationApis()) {
  46. this.context.subscriptions.push(
  47. vscode.window.onDidOpenTerminal(this.handleOpenTerminal.bind(this)),
  48. vscode.window.onDidCloseTerminal(this.handleClosedTerminal.bind(this)),
  49. vscode.window.onDidChangeTerminalShellIntegration(this.handleShellIntegrationChange.bind(this)),
  50. vscode.window.onDidStartTerminalShellExecution(this.handleShellExecutionStart.bind(this)),
  51. vscode.window.onDidEndTerminalShellExecution(this.handleShellExecutionEnd.bind(this))
  52. )
  53. }
  54. }
  55. runCommand(terminalInfo: TerminalInfo, command: string, cwd: string): TerminalProcessResultPromise {
  56. terminalInfo.busy = true
  57. terminalInfo.lastCommand = command
  58. const process = new TerminalProcess(terminalInfo, command)
  59. this.processes.set(terminalInfo.id, process)
  60. const promise = new Promise<void>((resolve, reject) => {
  61. process.once(CONTINUE_EVENT, () => {
  62. console.log("2")
  63. resolve()
  64. })
  65. process.once("error", reject)
  66. })
  67. // if shell integration is already active, run the command immediately
  68. if (terminalInfo.terminal.shellIntegration) {
  69. process.waitForShellIntegration = false
  70. process.run()
  71. }
  72. if (hasShellIntegrationApis()) {
  73. // 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)
  74. setTimeout(() => {
  75. if (!terminalInfo.terminal.shellIntegration) {
  76. process.waitForShellIntegration = false
  77. process.run()
  78. // Without shell integration, we can't know when the command has finished or what the
  79. // exit code was.
  80. }
  81. }, 3000)
  82. } else {
  83. // User doesn't have shell integration API available, run command the old way
  84. process.waitForShellIntegration = false
  85. process.run()
  86. }
  87. // Merge the process and promise
  88. return mergePromise(process, promise)
  89. }
  90. async getOrCreateTerminal(cwd: string): Promise<TerminalInfo> {
  91. const availableTerminal = this.terminals.find((t) => {
  92. if (t.busy) {
  93. return false
  94. }
  95. const terminalCwd = t.terminal.shellIntegration?.cwd // one of claude's commands could have changed the cwd of the terminal
  96. if (!terminalCwd) {
  97. return false
  98. }
  99. return vscode.Uri.file(cwd).fsPath === terminalCwd.fsPath
  100. })
  101. if (availableTerminal) {
  102. console.log("reusing terminal", availableTerminal.id)
  103. return availableTerminal
  104. }
  105. const newTerminal = vscode.window.createTerminal({
  106. name: "Claude Dev",
  107. cwd: cwd,
  108. iconPath: new vscode.ThemeIcon("robot"),
  109. })
  110. const newTerminalInfo: TerminalInfo = {
  111. terminal: newTerminal,
  112. busy: false,
  113. lastCommand: "",
  114. id: this.nextTerminalId++,
  115. }
  116. this.terminals.push(newTerminalInfo)
  117. return newTerminalInfo
  118. }
  119. private handleOpenTerminal(terminal: vscode.Terminal) {
  120. console.log(`Terminal opened: ${terminal.name}`)
  121. }
  122. private handleClosedTerminal(terminal: vscode.Terminal) {
  123. const index = this.terminals.findIndex((t) => t.terminal === terminal)
  124. if (index !== -1) {
  125. const terminalInfo = this.terminals[index]
  126. this.terminals.splice(index, 1)
  127. this.processes.delete(terminalInfo.id)
  128. }
  129. console.log(`Terminal closed: ${terminal.name}`)
  130. }
  131. private handleShellIntegrationChange(e: vscode.TerminalShellIntegrationChangeEvent) {
  132. const terminalInfo = this.terminals.find((t) => t.terminal === e.terminal)
  133. if (terminalInfo) {
  134. const process = this.processes.get(terminalInfo.id)
  135. if (process && process.waitForShellIntegration) {
  136. process.waitForShellIntegration = false
  137. process.run()
  138. }
  139. console.log(`Shell integration activated for terminal: ${e.terminal.name}`)
  140. }
  141. }
  142. private handleShellExecutionStart(e: vscode.TerminalShellExecutionStartEvent) {
  143. const terminalInfo = this.terminals.find((t) => t.terminal === e.terminal)
  144. if (terminalInfo) {
  145. terminalInfo.busy = true
  146. terminalInfo.lastCommand = e.execution.commandLine.value
  147. console.log(`Command started in terminal ${terminalInfo.id}: ${terminalInfo.lastCommand}`)
  148. }
  149. }
  150. private handleShellExecutionEnd(e: vscode.TerminalShellExecutionEndEvent) {
  151. const terminalInfo = this.terminals.find((t) => t.terminal === e.terminal)
  152. if (terminalInfo) {
  153. this.handleCommandCompletion(terminalInfo, e.exitCode)
  154. }
  155. }
  156. private handleCommandCompletion(terminalInfo: TerminalInfo, exitCode?: number | undefined) {
  157. terminalInfo.busy = false
  158. console.log(
  159. `Command "${terminalInfo.lastCommand}" in terminal ${terminalInfo.id} completed with exit code: ${exitCode}`
  160. )
  161. }
  162. getBusyTerminals(): { id: number; lastCommand: string }[] {
  163. return this.terminals.filter((t) => t.busy).map((t) => ({ id: t.id, lastCommand: t.lastCommand }))
  164. }
  165. hasBusyTerminals(): boolean {
  166. return this.terminals.some((t) => t.busy)
  167. }
  168. getUnretrievedOutput(terminalId: number): string {
  169. const process = this.processes.get(terminalId)
  170. if (!process) {
  171. return ""
  172. }
  173. return process.getUnretrievedOutput()
  174. }
  175. disposeAll() {
  176. for (const info of this.terminals) {
  177. info.terminal.dispose() // todo do we want to do this? test with tab view closing it
  178. }
  179. this.terminals = []
  180. this.processes.clear()
  181. }
  182. }
  183. function hasShellIntegrationApis(): boolean {
  184. const [major, minor] = vscode.version.split(".").map(Number)
  185. return major > 1 || (major === 1 && minor >= 93)
  186. }
  187. interface TerminalInfo {
  188. terminal: vscode.Terminal
  189. busy: boolean
  190. lastCommand: string
  191. id: number
  192. }
  193. const CONTINUE_EVENT = "CONTINUE_EVENT"
  194. export class TerminalProcess extends EventEmitter {
  195. waitForShellIntegration: boolean = true
  196. private isListening: boolean = true
  197. private buffer: string = ""
  198. private execution?: vscode.TerminalShellExecution
  199. private stream?: AsyncIterable<string>
  200. private fullOutput: string = ""
  201. private lastRetrievedIndex: number = 0
  202. constructor(public terminalInfo: TerminalInfo, private command: string) {
  203. super()
  204. }
  205. async run() {
  206. if (this.terminalInfo.terminal.shellIntegration) {
  207. this.execution = this.terminalInfo.terminal.shellIntegration.executeCommand(this.command)
  208. this.stream = this.execution.read()
  209. // todo: need to handle errors
  210. let isFirstChunk = true // ignore first chunk since it's vscode shell integration marker
  211. for await (const data of this.stream) {
  212. console.log("data", data)
  213. if (!isFirstChunk) {
  214. this.fullOutput += data
  215. if (this.isListening) {
  216. this.emitIfEol(data)
  217. this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
  218. }
  219. } else {
  220. isFirstChunk = false
  221. }
  222. }
  223. // Emit any remaining content in the buffer
  224. if (this.buffer && this.isListening) {
  225. this.emit("line", this.buffer.trim())
  226. this.buffer = ""
  227. this.lastRetrievedIndex = this.fullOutput.length
  228. }
  229. this.emit(CONTINUE_EVENT)
  230. } else {
  231. this.terminalInfo.terminal.sendText(this.command, true)
  232. // For terminals without shell integration, we can't know when the command completes
  233. // So we'll just emit the continue event after a delay
  234. setTimeout(() => {
  235. this.emit(CONTINUE_EVENT)
  236. }, 2000) // Adjust this delay as needed
  237. }
  238. }
  239. // Inspired by https://github.com/sindresorhus/execa/blob/main/lib/transform/split.js
  240. private emitIfEol(chunk: string) {
  241. this.buffer += chunk
  242. let lineEndIndex: number
  243. while ((lineEndIndex = this.buffer.indexOf("\n")) !== -1) {
  244. let line = this.buffer.slice(0, lineEndIndex).trim()
  245. // Remove \r if present (for Windows-style line endings)
  246. // if (line.endsWith("\r")) {
  247. // line = line.slice(0, -1)
  248. // }
  249. this.emit("line", line)
  250. this.buffer = this.buffer.slice(lineEndIndex + 1)
  251. }
  252. }
  253. continue() {
  254. this.isListening = false
  255. this.removeAllListeners("line")
  256. this.emit(CONTINUE_EVENT)
  257. }
  258. isStillListening() {
  259. return this.isListening
  260. }
  261. getUnretrievedOutput(): string {
  262. const unretrieved = this.fullOutput.slice(this.lastRetrievedIndex)
  263. this.lastRetrievedIndex = this.fullOutput.length
  264. return unretrieved
  265. }
  266. }
  267. export type TerminalProcessResultPromise = TerminalProcess & Promise<void>
  268. // 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
  269. function mergePromise(process: TerminalProcess, promise: Promise<void>): TerminalProcessResultPromise {
  270. const nativePromisePrototype = (async () => {})().constructor.prototype
  271. const descriptors = ["then", "catch", "finally"].map(
  272. (property) => [property, Reflect.getOwnPropertyDescriptor(nativePromisePrototype, property)] as const
  273. )
  274. for (const [property, descriptor] of descriptors) {
  275. if (descriptor) {
  276. const value = descriptor.value.bind(promise)
  277. Reflect.defineProperty(process, property, { ...descriptor, value })
  278. }
  279. }
  280. return process as TerminalProcessResultPromise
  281. }