TerminalProcess.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. import { EventEmitter } from "events"
  2. import stripAnsi from "strip-ansi"
  3. import * as vscode from "vscode"
  4. import { inspect } from "util"
  5. import { ExitCodeDetails } from "./TerminalManager"
  6. import { TerminalInfo, TerminalRegistry } from "./TerminalRegistry"
  7. export interface TerminalProcessEvents {
  8. line: [line: string]
  9. continue: []
  10. completed: [output?: string]
  11. error: [error: Error]
  12. no_shell_integration: []
  13. /**
  14. * Emitted when a shell execution completes
  15. * @param id The terminal ID
  16. * @param exitDetails Contains exit code and signal information if process was terminated by signal
  17. */
  18. shell_execution_complete: [id: number, exitDetails: ExitCodeDetails]
  19. stream_available: [id: number, stream: AsyncIterable<string>]
  20. }
  21. // how long to wait after a process outputs anything before we consider it "cool" again
  22. const PROCESS_HOT_TIMEOUT_NORMAL = 2_000
  23. const PROCESS_HOT_TIMEOUT_COMPILING = 15_000
  24. export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
  25. waitForShellIntegration: boolean = true
  26. private isListening: boolean = true
  27. private terminalInfo: TerminalInfo | undefined
  28. private lastEmitTime_ms: number = 0
  29. private fullOutput: string = ""
  30. private lastRetrievedIndex: number = 0
  31. isHot: boolean = false
  32. private hotTimer: NodeJS.Timeout | null = null
  33. async run(terminal: vscode.Terminal, command: string) {
  34. if (terminal.shellIntegration && terminal.shellIntegration.executeCommand) {
  35. // Get terminal info to access stream
  36. const terminalInfo = TerminalRegistry.getTerminalInfoByTerminal(terminal)
  37. if (!terminalInfo) {
  38. console.error("[TerminalProcess] Terminal not found in registry")
  39. this.emit("no_shell_integration")
  40. this.emit("completed")
  41. this.emit("continue")
  42. return
  43. }
  44. // When executeCommand() is called, onDidStartTerminalShellExecution will fire in TerminalManager
  45. // which creates a new stream via execution.read() and emits 'stream_available'
  46. const streamAvailable = new Promise<AsyncIterable<string>>((resolve) => {
  47. this.once("stream_available", (id: number, stream: AsyncIterable<string>) => {
  48. if (id === terminalInfo.id) {
  49. resolve(stream)
  50. }
  51. })
  52. })
  53. // Create promise that resolves when shell execution completes for this terminal
  54. const shellExecutionComplete = new Promise<ExitCodeDetails>((resolve) => {
  55. this.once("shell_execution_complete", (id: number, exitDetails: ExitCodeDetails) => {
  56. if (id === terminalInfo.id) {
  57. resolve(exitDetails)
  58. }
  59. })
  60. })
  61. // getUnretrievedOutput needs to know if streamClosed, so store this for later
  62. this.terminalInfo = terminalInfo
  63. // Execute command
  64. terminal.shellIntegration.executeCommand(command)
  65. this.isHot = true
  66. // Wait for stream to be available
  67. const stream = await streamAvailable
  68. let preOutput = ""
  69. let commandOutputStarted = false
  70. /*
  71. * Extract clean output from raw accumulated output. FYI:
  72. * ]633 is a custom sequence number used by VSCode shell integration:
  73. * - OSC 633 ; A ST - Mark prompt start
  74. * - OSC 633 ; B ST - Mark prompt end
  75. * - OSC 633 ; C ST - Mark pre-execution (start of command output)
  76. * - OSC 633 ; D [; <exitcode>] ST - Mark execution finished with optional exit code
  77. * - OSC 633 ; E ; <commandline> [; <nonce>] ST - Explicitly set command line with optional nonce
  78. */
  79. // Process stream data
  80. for await (let data of stream) {
  81. // Check for command output start marker
  82. if (!commandOutputStarted) {
  83. preOutput += data
  84. const match = this.matchAfterVsceStartMarkers(data)
  85. if (match !== undefined) {
  86. commandOutputStarted = true
  87. data = match
  88. this.fullOutput = "" // Reset fullOutput when command actually starts
  89. } else {
  90. continue
  91. }
  92. }
  93. // Command output started, accumulate data without filtering.
  94. // notice to future programmers: do not add escape sequence
  95. // filtering here: fullOutput cannot change in length (see getUnretrievedOutput),
  96. // and chunks may not be complete so you cannot rely on detecting or removing escape sequences mid-stream.
  97. this.fullOutput += data
  98. // For non-immediately returning commands we want to show loading spinner
  99. // right away but this wouldnt happen until it emits a line break, so
  100. // as soon as we get any output we emit to let webview know to show spinner
  101. const now = Date.now()
  102. if (this.isListening && (now - this.lastEmitTime_ms > 100 || this.lastEmitTime_ms === 0)) {
  103. this.emitRemainingBufferIfListening()
  104. this.lastEmitTime_ms = now
  105. }
  106. // 2. Set isHot depending on the command.
  107. // This stalls API requests until terminal is cool again.
  108. this.isHot = true
  109. if (this.hotTimer) {
  110. clearTimeout(this.hotTimer)
  111. }
  112. // these markers indicate the command is some kind of local dev server recompiling the app, which we want to wait for output of before sending request to cline
  113. const compilingMarkers = ["compiling", "building", "bundling", "transpiling", "generating", "starting"]
  114. const markerNullifiers = [
  115. "compiled",
  116. "success",
  117. "finish",
  118. "complete",
  119. "succeed",
  120. "done",
  121. "end",
  122. "stop",
  123. "exit",
  124. "terminate",
  125. "error",
  126. "fail",
  127. ]
  128. const isCompiling =
  129. compilingMarkers.some((marker) => data.toLowerCase().includes(marker.toLowerCase())) &&
  130. !markerNullifiers.some((nullifier) => data.toLowerCase().includes(nullifier.toLowerCase()))
  131. this.hotTimer = setTimeout(
  132. () => {
  133. this.isHot = false
  134. },
  135. isCompiling ? PROCESS_HOT_TIMEOUT_COMPILING : PROCESS_HOT_TIMEOUT_NORMAL,
  136. )
  137. }
  138. // Set streamClosed immediately after stream ends
  139. if (this.terminalInfo) {
  140. this.terminalInfo.streamClosed = true
  141. }
  142. // Wait for shell execution to complete and handle exit details
  143. const exitDetails = await shellExecutionComplete
  144. this.isHot = false
  145. if (commandOutputStarted) {
  146. // Emit any remaining output before completing
  147. this.emitRemainingBufferIfListening()
  148. } else {
  149. console.error(
  150. "[Terminal Process] VSCE output start escape sequence (]633;C or ]133;C) not received! VSCE Bug? preOutput: " +
  151. inspect(preOutput, { colors: false, breakLength: Infinity }),
  152. )
  153. }
  154. // console.debug("[Terminal Process] raw output: " + inspect(output, { colors: false, breakLength: Infinity }))
  155. // fullOutput begins after C marker so we only need to trim off D marker
  156. // (if D exists, see VSCode bug# 237208):
  157. const match = this.matchBeforeVsceEndMarkers(this.fullOutput)
  158. if (match !== undefined) {
  159. this.fullOutput = match
  160. }
  161. // console.debug(`[Terminal Process] processed output via ${matchSource}: ` + inspect(output, { colors: false, breakLength: Infinity }))
  162. // for now we don't want this delaying requests since we don't send diagnostics automatically anymore (previous: "even though the command is finished, we still want to consider it 'hot' in case so that api request stalls to let diagnostics catch up")
  163. if (this.hotTimer) {
  164. clearTimeout(this.hotTimer)
  165. }
  166. this.isHot = false
  167. this.emit("completed", this.removeEscapeSequences(this.fullOutput))
  168. this.emit("continue")
  169. } else {
  170. terminal.sendText(command, true)
  171. // For terminals without shell integration, we can't know when the command completes
  172. // So we'll just emit the continue event after a delay
  173. this.emit("completed")
  174. this.emit("continue")
  175. this.emit("no_shell_integration")
  176. // setTimeout(() => {
  177. // console.log(`Emitting continue after delay for terminal`)
  178. // // can't emit completed since we don't if the command actually completed, it could still be running server
  179. // }, 500) // Adjust this delay as needed
  180. }
  181. }
  182. private emitRemainingBufferIfListening() {
  183. if (this.isListening) {
  184. const remainingBuffer = this.getUnretrievedOutput()
  185. if (remainingBuffer !== "") {
  186. this.emit("line", remainingBuffer)
  187. }
  188. }
  189. }
  190. continue() {
  191. this.emitRemainingBufferIfListening()
  192. this.isListening = false
  193. this.removeAllListeners("line")
  194. this.emit("continue")
  195. }
  196. // Returns complete lines with their carriage returns.
  197. // The final line may lack a carriage return if the program didn't send one.
  198. getUnretrievedOutput(): string {
  199. // Get raw unretrieved output
  200. let outputToProcess = this.fullOutput.slice(this.lastRetrievedIndex)
  201. // Check for VSCE command end markers
  202. const index633 = outputToProcess.indexOf("\x1b]633;D")
  203. const index133 = outputToProcess.indexOf("\x1b]133;D")
  204. let endIndex = -1
  205. if (index633 !== -1 && index133 !== -1) {
  206. endIndex = Math.min(index633, index133)
  207. } else if (index633 !== -1) {
  208. endIndex = index633
  209. } else if (index133 !== -1) {
  210. endIndex = index133
  211. }
  212. // If no end markers were found yet (possibly due to VSCode bug#237208):
  213. // For active streams: return only complete lines (up to last \n).
  214. // For closed streams: return all remaining content.
  215. if (endIndex === -1) {
  216. if (!this.terminalInfo?.streamClosed) {
  217. // Stream still running - only process complete lines
  218. endIndex = outputToProcess.lastIndexOf("\n")
  219. if (endIndex === -1) {
  220. // No complete lines
  221. return ""
  222. }
  223. // Include carriage return
  224. endIndex++
  225. } else {
  226. // Stream closed - process all remaining output
  227. endIndex = outputToProcess.length
  228. }
  229. }
  230. // Update index and slice output
  231. this.lastRetrievedIndex += endIndex
  232. outputToProcess = outputToProcess.slice(0, endIndex)
  233. // Clean and return output
  234. return this.removeEscapeSequences(outputToProcess)
  235. }
  236. private stringIndexMatch(
  237. data: string,
  238. prefix?: string,
  239. suffix?: string,
  240. bell: string = "\x07",
  241. ): string | undefined {
  242. let startIndex: number
  243. let endIndex: number
  244. let prefixLength: number
  245. if (prefix === undefined) {
  246. startIndex = 0
  247. prefixLength = 0
  248. } else {
  249. startIndex = data.indexOf(prefix)
  250. if (startIndex === -1) {
  251. return undefined
  252. }
  253. if (bell.length > 0) {
  254. // Find the bell character after the prefix
  255. const bellIndex = data.indexOf(bell, startIndex + prefix.length)
  256. if (bellIndex === -1) {
  257. return undefined
  258. }
  259. const distanceToBell = bellIndex - startIndex
  260. prefixLength = distanceToBell + bell.length
  261. } else {
  262. prefixLength = prefix.length
  263. }
  264. }
  265. const contentStart = startIndex + prefixLength
  266. if (suffix === undefined) {
  267. // When suffix is undefined, match to end
  268. endIndex = data.length
  269. } else {
  270. endIndex = data.indexOf(suffix, contentStart)
  271. if (endIndex === -1) {
  272. return undefined
  273. }
  274. }
  275. return data.slice(contentStart, endIndex)
  276. }
  277. // Removes ANSI escape sequences and VSCode-specific terminal control codes from output.
  278. // While stripAnsi handles most ANSI codes, VSCode's shell integration adds custom
  279. // escape sequences (OSC 633) that need special handling. These sequences control
  280. // terminal features like marking command start/end and setting prompts.
  281. //
  282. // This method could be extended to handle other escape sequences, but any additions
  283. // should be carefully considered to ensure they only remove control codes and don't
  284. // alter the actual content or behavior of the output stream.
  285. private removeEscapeSequences(str: string): string {
  286. return stripAnsi(str.replace(/\x1b\]633;[^\x07]+\x07/gs, "").replace(/\x1b\]133;[^\x07]+\x07/gs, ""))
  287. }
  288. /**
  289. * Helper function to match VSCode shell integration start markers (C).
  290. * Looks for content after ]633;C or ]133;C markers.
  291. * If both exist, takes the content after the last marker found.
  292. */
  293. private matchAfterVsceStartMarkers(data: string): string | undefined {
  294. return this.matchVsceMarkers(data, "\x1b]633;C", "\x1b]133;C", undefined, undefined)
  295. }
  296. /**
  297. * Helper function to match VSCode shell integration end markers (D).
  298. * Looks for content before ]633;D or ]133;D markers.
  299. * If both exist, takes the content before the first marker found.
  300. */
  301. private matchBeforeVsceEndMarkers(data: string): string | undefined {
  302. return this.matchVsceMarkers(data, undefined, undefined, "\x1b]633;D", "\x1b]133;D")
  303. }
  304. /**
  305. * Handles VSCode shell integration markers for command output:
  306. *
  307. * For C (Command Start):
  308. * - Looks for content after ]633;C or ]133;C markers
  309. * - These markers indicate the start of command output
  310. * - If both exist, takes the content after the last marker found
  311. * - This ensures we get the actual command output after any shell integration prefixes
  312. *
  313. * For D (Command End):
  314. * - Looks for content before ]633;D or ]133;D markers
  315. * - These markers indicate command completion
  316. * - If both exist, takes the content before the first marker found
  317. * - This ensures we don't include shell integration suffixes in the output
  318. *
  319. * In both cases, checks 633 first since it's more commonly used in VSCode shell integration
  320. *
  321. * @param data The string to search for markers in
  322. * @param prefix633 The 633 marker to match after (for C markers)
  323. * @param prefix133 The 133 marker to match after (for C markers)
  324. * @param suffix633 The 633 marker to match before (for D markers)
  325. * @param suffix133 The 133 marker to match before (for D markers)
  326. * @returns The content between/after markers, or undefined if no markers found
  327. *
  328. * Note: Always makes exactly 2 calls to stringIndexMatch regardless of match results.
  329. * Using string indexOf matching is ~500x faster than regular expressions, so even
  330. * matching twice is still very efficient comparatively.
  331. */
  332. private matchVsceMarkers(
  333. data: string,
  334. prefix633: string | undefined,
  335. prefix133: string | undefined,
  336. suffix633: string | undefined,
  337. suffix133: string | undefined,
  338. ): string | undefined {
  339. // Support both VSCode shell integration markers (633 and 133)
  340. // Check 633 first since it's more commonly used in VSCode shell integration
  341. let match133: string | undefined
  342. const match633 = this.stringIndexMatch(data, prefix633, suffix633)
  343. // Must check explicitly for undefined because stringIndexMatch can return empty strings
  344. // that are valid matches (e.g., when a marker exists but has no content between markers)
  345. if (match633 !== undefined) {
  346. match133 = this.stringIndexMatch(match633, prefix133, suffix133)
  347. } else {
  348. match133 = this.stringIndexMatch(data, prefix133, suffix133)
  349. }
  350. return match133 !== undefined ? match133 : match633
  351. }
  352. }
  353. export type TerminalProcessResultPromise = TerminalProcess & Promise<void>
  354. // 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
  355. export function mergePromise(process: TerminalProcess, promise: Promise<void>): TerminalProcessResultPromise {
  356. const nativePromisePrototype = (async () => {})().constructor.prototype
  357. const descriptors = ["then", "catch", "finally"].map(
  358. (property) => [property, Reflect.getOwnPropertyDescriptor(nativePromisePrototype, property)] as const,
  359. )
  360. for (const [property, descriptor] of descriptors) {
  361. if (descriptor) {
  362. const value = descriptor.value.bind(promise)
  363. Reflect.defineProperty(process, property, { ...descriptor, value })
  364. }
  365. }
  366. return process as TerminalProcessResultPromise
  367. }