Skip to content

Commit aa0e277

Browse files
authored
Merge pull request #1416 from RooVetGit/cte/handle-outputless-commands
Handle outputless commands
2 parents 2a2ba74 + 710284c commit aa0e277

File tree

4 files changed

+198
-13
lines changed

4 files changed

+198
-13
lines changed

.changeset/wise-bats-perform.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"roo-cline": patch
3+
---
4+
5+
Handle outputless commands

src/core/Cline.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -971,6 +971,12 @@ export class Cline {
971971
await this.say("shell_integration_warning")
972972
})
973973

974+
process.once("stream_stalled", async (id: number) => {
975+
if (id === terminalInfo.id && !didContinue) {
976+
sendCommandOutput("")
977+
}
978+
})
979+
974980
await process
975981

976982
// Wait for a short delay to ensure all messages are sent to the webview.

src/integrations/terminal/TerminalProcess.ts

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,17 @@ export interface TerminalProcessEvents {
4141
error: [error: Error]
4242
no_shell_integration: []
4343
/**
44-
* Emitted when a shell execution completes
44+
* Emitted when a shell execution completes.
4545
* @param id The terminal ID
4646
* @param exitDetails Contains exit code and signal information if process was terminated by signal
4747
*/
4848
shell_execution_complete: [id: number, exitDetails: ExitCodeDetails]
4949
stream_available: [id: number, stream: AsyncIterable<string>]
50+
/**
51+
* Emitted when an execution fails to emit a "line" event for a given period of time.
52+
* @param id The terminal ID
53+
*/
54+
stream_stalled: [id: number]
5055
}
5156

5257
export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
@@ -55,7 +60,7 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
5560

5661
private isListening = true
5762
private terminalInfo: TerminalInfo | undefined
58-
private lastEmitTime_ms = 0
63+
private lastEmitAt = 0
5964
private outputBuilder?: OutputBuilder
6065
private hotTimer: NodeJS.Timeout | null = null
6166

@@ -67,14 +72,18 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
6772
this._isHot = value
6873
}
6974

70-
constructor(private readonly terminalOutputLimit: number) {
75+
constructor(
76+
private readonly terminalOutputLimit: number,
77+
private readonly stallTimeout: number = 5_000,
78+
) {
7179
super()
7280
}
7381

7482
async run(terminal: vscode.Terminal, command: string) {
7583
if (terminal.shellIntegration && terminal.shellIntegration.executeCommand) {
76-
// Get terminal info to access stream
84+
// Get terminal info to access stream.
7785
const terminalInfo = TerminalRegistry.getTerminalInfoByTerminal(terminal)
86+
7887
if (!terminalInfo) {
7988
console.error("[TerminalProcess] Terminal not found in registry")
8089
this.emit("no_shell_integration")
@@ -127,11 +136,9 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
127136

128137
this.outputBuilder = new OutputBuilder({ maxSize: this.terminalOutputLimit })
129138

130-
/**
131-
* Some commands won't result in output flushing until the command
132-
* completes. This locks the UI. Should we set a timer to prompt
133-
* the user to continue?
134-
*/
139+
let stallTimer: NodeJS.Timeout | null = setTimeout(() => {
140+
this.emit("stream_stalled", terminalInfo.id)
141+
}, this.stallTimeout)
135142

136143
for await (let data of stream) {
137144
// Check for command output start marker.
@@ -158,11 +165,17 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
158165
// right away but this wouldn't happen until it emits a line break, so
159166
// as soon as we get any output we emit to let webview know to show spinner.
160167
const now = Date.now()
161-
const timeSinceLastEmit = now - this.lastEmitTime_ms
168+
const timeSinceLastEmit = now - this.lastEmitAt
162169

163170
if (this.isListening && timeSinceLastEmit > EMIT_INTERVAL) {
164-
this.flushLine()
165-
this.lastEmitTime_ms = now
171+
if (this.flushLine()) {
172+
if (stallTimer) {
173+
clearTimeout(stallTimer)
174+
stallTimer = null
175+
}
176+
177+
this.lastEmitAt = now
178+
}
166179
}
167180

168181
// Set isHot depending on the command.
@@ -258,7 +271,10 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
258271

259272
if (line) {
260273
this.emit("line", line)
274+
return true
261275
}
276+
277+
return false
262278
}
263279

264280
private flushAll() {
@@ -270,7 +286,10 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
270286

271287
if (buffer) {
272288
this.emit("line", buffer)
289+
return true
273290
}
291+
292+
return false
274293
}
275294

276295
private processOutput(outputToProcess: string) {

src/integrations/terminal/__tests__/TerminalProcess.test.ts

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ jest.mock("vscode", () => ({
2020
ThemeIcon: jest.fn(),
2121
}))
2222

23+
const TERMINAL_OUTPUT_LIMIT = 100 * 1024
24+
const STALL_TIMEOUT = 100
25+
2326
describe("TerminalProcess", () => {
2427
let terminalProcess: TerminalProcess
2528
let mockTerminal: jest.Mocked<
@@ -34,7 +37,7 @@ describe("TerminalProcess", () => {
3437
let mockStream: AsyncIterableIterator<string>
3538

3639
beforeEach(() => {
37-
terminalProcess = new TerminalProcess(100 * 1024)
40+
terminalProcess = new TerminalProcess(TERMINAL_OUTPUT_LIMIT, STALL_TIMEOUT)
3841

3942
// Create properly typed mock terminal
4043
mockTerminal = {
@@ -173,4 +176,156 @@ describe("TerminalProcess", () => {
173176
expect(terminalProcess["isListening"]).toBe(false)
174177
})
175178
})
179+
180+
describe("stalled stream handling", () => {
181+
it("emits stream_stalled event when no output is received within timeout", async () => {
182+
// Create a promise that resolves when stream_stalled is emitted
183+
const streamStalledPromise = new Promise<number>((resolve) => {
184+
terminalProcess.once("stream_stalled", (id: number) => {
185+
resolve(id)
186+
})
187+
})
188+
189+
// Create a stream that doesn't emit any data
190+
mockStream = (async function* () {
191+
yield "\x1b]633;C\x07" // Command start sequence
192+
// No data is yielded after this, causing the stall
193+
await new Promise((resolve) => setTimeout(resolve, STALL_TIMEOUT * 2))
194+
// This would normally be yielded, but the stall timer will fire first
195+
yield "Output after stall"
196+
yield "\x1b]633;D\x07" // Command end sequence
197+
terminalProcess.emit("shell_execution_complete", mockTerminalInfo.id, { exitCode: 0 })
198+
})()
199+
200+
mockExecution = {
201+
read: jest.fn().mockReturnValue(mockStream),
202+
}
203+
204+
mockTerminal.shellIntegration.executeCommand.mockReturnValue(mockExecution)
205+
206+
// Start the terminal process
207+
const runPromise = terminalProcess.run(mockTerminal, "test command")
208+
terminalProcess.emit("stream_available", mockTerminalInfo.id, mockStream)
209+
210+
// Wait for the stream_stalled event
211+
const stalledId = await streamStalledPromise
212+
213+
// Verify the event was emitted with the correct terminal ID
214+
expect(stalledId).toBe(mockTerminalInfo.id)
215+
216+
// Complete the run
217+
await runPromise
218+
})
219+
220+
it("clears stall timer when output is received", async () => {
221+
// Spy on the emit method to check if stream_stalled is emitted
222+
const emitSpy = jest.spyOn(terminalProcess, "emit")
223+
224+
// Create a stream that emits data before the stall timeout
225+
mockStream = (async function* () {
226+
yield "\x1b]633;C\x07" // Command start sequence
227+
yield "Initial output\n" // This should clear the stall timer
228+
229+
// Wait longer than the stall timeout
230+
await new Promise((resolve) => setTimeout(resolve, STALL_TIMEOUT * 2))
231+
232+
yield "More output\n"
233+
yield "\x1b]633;D\x07" // Command end sequence
234+
terminalProcess.emit("shell_execution_complete", mockTerminalInfo.id, { exitCode: 0 })
235+
})()
236+
237+
mockExecution = {
238+
read: jest.fn().mockReturnValue(mockStream),
239+
}
240+
241+
mockTerminal.shellIntegration.executeCommand.mockReturnValue(mockExecution)
242+
243+
// Start the terminal process
244+
const runPromise = terminalProcess.run(mockTerminal, "test command")
245+
terminalProcess.emit("stream_available", mockTerminalInfo.id, mockStream)
246+
247+
// Wait for the run to complete
248+
await runPromise
249+
250+
// Wait a bit longer to ensure the stall timer would have fired if not cleared
251+
await new Promise((resolve) => setTimeout(resolve, STALL_TIMEOUT * 2))
252+
253+
// Verify stream_stalled was not emitted
254+
expect(emitSpy).not.toHaveBeenCalledWith("stream_stalled", expect.anything())
255+
})
256+
257+
it("returns true from flushLine when a line is emitted", async () => {
258+
// Create a stream with output
259+
mockStream = (async function* () {
260+
yield "\x1b]633;C\x07" // Command start sequence
261+
yield "Test output\n" // This should be flushed as a line
262+
yield "\x1b]633;D\x07" // Command end sequence
263+
terminalProcess.emit("shell_execution_complete", mockTerminalInfo.id, { exitCode: 0 })
264+
})()
265+
266+
mockExecution = {
267+
read: jest.fn().mockReturnValue(mockStream),
268+
}
269+
270+
mockTerminal.shellIntegration.executeCommand.mockReturnValue(mockExecution)
271+
272+
// Spy on the flushLine method
273+
const flushLineSpy = jest.spyOn(terminalProcess as any, "flushLine")
274+
275+
// Spy on the emit method to check if line is emitted
276+
const emitSpy = jest.spyOn(terminalProcess, "emit")
277+
278+
// Start the terminal process
279+
const runPromise = terminalProcess.run(mockTerminal, "test command")
280+
terminalProcess.emit("stream_available", mockTerminalInfo.id, mockStream)
281+
282+
// Wait for the run to complete
283+
await runPromise
284+
285+
// Verify flushLine was called and returned true
286+
expect(flushLineSpy).toHaveBeenCalled()
287+
expect(flushLineSpy.mock.results.some((result) => result.value === true)).toBe(true)
288+
289+
// Verify line event was emitted
290+
expect(emitSpy).toHaveBeenCalledWith("line", expect.any(String))
291+
})
292+
293+
it("returns false from flushLine when no line is emitted", async () => {
294+
// Create a stream with no complete lines
295+
mockStream = (async function* () {
296+
yield "\x1b]633;C\x07" // Command start sequence
297+
yield "Test output" // No newline, so this won't be flushed as a line yet
298+
yield "\x1b]633;D\x07" // Command end sequence
299+
terminalProcess.emit("shell_execution_complete", mockTerminalInfo.id, { exitCode: 0 })
300+
})()
301+
302+
mockExecution = {
303+
read: jest.fn().mockReturnValue(mockStream),
304+
}
305+
306+
mockTerminal.shellIntegration.executeCommand.mockReturnValue(mockExecution)
307+
308+
// Create a custom implementation to test flushLine directly
309+
const testFlushLine = async () => {
310+
// Create a new instance with the same configuration
311+
const testProcess = new TerminalProcess(TERMINAL_OUTPUT_LIMIT, STALL_TIMEOUT)
312+
313+
// Set up the output builder with content that doesn't have a newline
314+
testProcess["outputBuilder"] = {
315+
readLine: jest.fn().mockReturnValue(""),
316+
append: jest.fn(),
317+
reset: jest.fn(),
318+
content: "Test output",
319+
} as any
320+
321+
// Call flushLine directly
322+
const result = testProcess["flushLine"]()
323+
return result
324+
}
325+
326+
// Test flushLine directly
327+
const flushLineResult = await testFlushLine()
328+
expect(flushLineResult).toBe(false)
329+
})
330+
})
176331
})

0 commit comments

Comments
 (0)