TerminalProcess.ts 14 KB

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