Skip to content

Commit 3452466

Browse files
committed
Support stdin in execa terminal
1 parent 11ed7d7 commit 3452466

File tree

6 files changed

+228
-21
lines changed

6 files changed

+228
-21
lines changed

src/integrations/terminal/BaseTerminal.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,14 @@ export abstract class BaseTerminal implements RooTerminal {
149149
return output
150150
}
151151

152+
public sendInput(_input: string): boolean {
153+
return false
154+
}
155+
156+
public isWaitingForInput(): boolean {
157+
return false
158+
}
159+
152160
public static defaultShellIntegrationTimeout = 5_000
153161
private static shellIntegrationTimeout: number = BaseTerminal.defaultShellIntegrationTimeout
154162
private static shellIntegrationDisabled: boolean = false

src/integrations/terminal/BaseTerminalProcess.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,20 @@ export abstract class BaseTerminalProcess extends EventEmitter<RooTerminalProces
130130
*/
131131
abstract hasUnretrievedOutput(): boolean
132132

133+
/**
134+
* Writes `input` to the subprocess stdin.
135+
* @param input The input string to write
136+
*/
137+
public sendInput(_input: string): void {}
138+
139+
/**
140+
* Checks if the process is waiting for input.
141+
* @returns true if the process is waiting for input
142+
*/
143+
public isWaitingForInput(): boolean {
144+
return false
145+
}
146+
133147
/**
134148
* Returns complete lines with their carriage returns.
135149
* The final line may lack a carriage return if the program didn't send one.

src/integrations/terminal/ExecaTerminal.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export class ExecaTerminal extends BaseTerminal {
2323
this.process = process
2424

2525
process.on("line", (line) => callbacks.onLine(line, process))
26+
process.on("input_required", () => callbacks.onInputRequired?.(process))
2627
process.once("completed", (output) => callbacks.onCompleted(output, process))
2728
process.once("shell_execution_started", (pid) => callbacks.onShellExecutionStarted(pid, process))
2829
process.once("shell_execution_complete", (details) => callbacks.onShellExecutionComplete(details, process))
@@ -35,4 +36,12 @@ export class ExecaTerminal extends BaseTerminal {
3536

3637
return mergePromise(process, promise)
3738
}
39+
40+
public override sendInput(input: string): boolean {
41+
return this.process?.sendInput(input) ?? false
42+
}
43+
44+
public override isWaitingForInput(): boolean {
45+
return this.process?.isWaitingForInput() ?? false
46+
}
3847
}

src/integrations/terminal/ExecaTerminalProcess.ts

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { execa, ExecaError } from "execa"
1+
import { execa, ExecaError, ResultPromise } from "execa"
22
import psTree from "ps-tree"
33
import process from "process"
44

@@ -10,6 +10,17 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
1010
private aborted = false
1111
private pid?: number
1212

13+
private subprocess?: ResultPromise<{
14+
shell: true
15+
cwd: string
16+
all: true
17+
stdin: "pipe"
18+
}>
19+
20+
private waitingForInput = false
21+
private lastOutputAt = 0
22+
private inputDetectionTimer?: NodeJS.Timeout
23+
1324
constructor(terminal: RooTerminal) {
1425
super()
1526

@@ -35,22 +46,22 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
3546

3647
try {
3748
this.isHot = true
49+
this.lastOutputAt = Date.now()
3850

39-
const subprocess = execa({
40-
shell: true,
41-
cwd: this.terminal.getCurrentWorkingDirectory(),
42-
all: true,
43-
})`${command}`
44-
45-
this.pid = subprocess.pid
46-
const stream = subprocess.iterable({ from: "all", preserveNewlines: true })
47-
this.terminal.setActiveStream(stream, subprocess.pid)
51+
const cwd = this.terminal.getCurrentWorkingDirectory()
52+
this.subprocess = execa({ shell: true, cwd, all: true, stdin: "pipe" })`${command}`
53+
this.pid = this.subprocess.pid
54+
const stream = this.subprocess.iterable({ from: "all", preserveNewlines: true })
55+
this.terminal.setActiveStream(stream, this.subprocess.pid)
4856

4957
for await (const line of stream) {
5058
if (this.aborted) {
5159
break
5260
}
5361

62+
this.lastOutputAt = Date.now()
63+
this.waitingForInput = false
64+
5465
this.fullOutput += line
5566

5667
const now = Date.now()
@@ -61,6 +72,7 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
6172
}
6273

6374
this.startHotTimer(line)
75+
this.startInputDetectionTimer()
6476
}
6577

6678
if (this.aborted) {
@@ -69,15 +81,17 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
6981
const kill = new Promise<void>((resolve) => {
7082
timeoutId = setTimeout(() => {
7183
try {
72-
subprocess.kill("SIGKILL")
84+
if (this.subprocess) {
85+
this.subprocess.kill("SIGKILL")
86+
}
7387
} catch (e) {}
7488

7589
resolve()
7690
}, 5_000)
7791
})
7892

7993
try {
80-
await Promise.race([subprocess, kill])
94+
await Promise.race([this.subprocess, kill])
8195
} catch (error) {
8296
console.log(
8397
`[ExecaTerminalProcess] subprocess termination error: ${error instanceof Error ? error.message : String(error)}`,
@@ -105,6 +119,7 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
105119
this.terminal.setActiveStream(undefined)
106120
this.emitRemainingBufferIfListening()
107121
this.stopHotTimer()
122+
this.stopInputDetectionTimer()
108123
this.emit("completed", this.fullOutput)
109124
this.emit("continue")
110125
}
@@ -113,6 +128,7 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
113128
this.isListening = false
114129
this.removeAllListeners("line")
115130
this.emit("continue")
131+
this.stopInputDetectionTimer()
116132
}
117133

118134
public override abort() {
@@ -148,6 +164,8 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
148164
)
149165
}
150166
}
167+
168+
this.stopInputDetectionTimer()
151169
}
152170

153171
public override hasUnretrievedOutput() {
@@ -165,11 +183,6 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
165183
index++
166184
this.lastRetrievedIndex += index
167185

168-
// console.log(
169-
// `[ExecaTerminalProcess#getUnretrievedOutput] fullOutput.length=${this.fullOutput.length} lastRetrievedIndex=${this.lastRetrievedIndex}`,
170-
// output.slice(0, index),
171-
// )
172-
173186
return output.slice(0, index)
174187
}
175188

@@ -184,4 +197,40 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
184197
this.emit("line", output)
185198
}
186199
}
200+
201+
private startInputDetectionTimer() {
202+
this.stopInputDetectionTimer()
203+
204+
this.inputDetectionTimer = setInterval(() => {
205+
const now = Date.now()
206+
207+
if (now - this.lastOutputAt > 500 && this.subprocess && !this.subprocess.killed) {
208+
if (!this.waitingForInput) {
209+
this.waitingForInput = true
210+
this.emit("input_required")
211+
}
212+
}
213+
}, 100)
214+
}
215+
216+
private stopInputDetectionTimer() {
217+
if (this.inputDetectionTimer) {
218+
clearInterval(this.inputDetectionTimer)
219+
this.inputDetectionTimer = undefined
220+
}
221+
}
222+
223+
public override sendInput(input: string): void {
224+
if (this.subprocess && this.subprocess.stdin) {
225+
this.subprocess.stdin.write(input + "\n")
226+
this.waitingForInput = false
227+
this.lastOutputAt = Date.now()
228+
} else {
229+
console.error("[ExecaTerminalProcess] Cannot write to stdin: subprocess or stdin not available")
230+
}
231+
}
232+
233+
public override isWaitingForInput(): boolean {
234+
return this.waitingForInput
235+
}
187236
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// npx vitest run src/integrations/terminal/__tests__/ExecaTerminalStdin.spec.ts
2+
3+
import { vi, describe, beforeEach, afterEach, it, expect } from "vitest"
4+
5+
import { ExecaTerminal } from "../ExecaTerminal"
6+
import { ExecaTerminalProcess } from "../ExecaTerminalProcess"
7+
import { RooTerminalCallbacks } from "../types"
8+
9+
describe("ExecaTerminal stdin handling", () => {
10+
let terminal: ExecaTerminal
11+
let callbacks: RooTerminalCallbacks
12+
let outputLines: string[] = []
13+
let _completedOutput: string | undefined
14+
let _shellExecutionStartedPid: number | undefined
15+
let _shellExecutionCompleteDetails: any
16+
17+
beforeEach(() => {
18+
terminal = new ExecaTerminal(1, process.cwd())
19+
outputLines = []
20+
_completedOutput = undefined
21+
_shellExecutionStartedPid = undefined
22+
_shellExecutionCompleteDetails = undefined
23+
24+
callbacks = {
25+
onLine: (line, _process) => {
26+
outputLines.push(line)
27+
},
28+
onCompleted: (output, _process) => {
29+
_completedOutput = output
30+
},
31+
onShellExecutionStarted: (pid, _process) => {
32+
_shellExecutionStartedPid = pid
33+
},
34+
onShellExecutionComplete: (details, _process) => {
35+
_shellExecutionCompleteDetails = details
36+
},
37+
}
38+
})
39+
40+
afterEach(() => {
41+
// Clean up any running processes
42+
if (terminal.process) {
43+
terminal.process.abort()
44+
}
45+
})
46+
47+
it("should detect when input is required", async () => {
48+
// This is a mock test since we can't reliably test with real password prompts
49+
// in an automated test environment
50+
51+
// Create a spy on the input_required event
52+
const inputRequiredSpy = vi.fn()
53+
54+
// Run a command and get the process
55+
const processPromise = terminal.runCommand("echo 'Testing stdin'", callbacks)
56+
57+
// We know this is an ExecaTerminalProcess because we're using ExecaTerminal
58+
const process = terminal.process as ExecaTerminalProcess
59+
60+
// Add a listener for the input_required event
61+
process.on("input_required", inputRequiredSpy)
62+
63+
// Manually trigger the input detection
64+
// @ts-ignore - Accessing private property for testing
65+
process.waitingForInput = true
66+
process.emit("input_required")
67+
68+
// Wait for the process to complete
69+
await processPromise
70+
71+
// Verify the input_required event was emitted
72+
expect(inputRequiredSpy).toHaveBeenCalled()
73+
})
74+
75+
it("should be able to send input to the process", async () => {
76+
// This is a mock test since we can't reliably test with real password prompts
77+
78+
// Run a command and get the process
79+
const processPromise = terminal.runCommand("echo 'Testing stdin'", callbacks)
80+
81+
// We know this is an ExecaTerminalProcess because we're using ExecaTerminal
82+
const process = terminal.process as ExecaTerminalProcess
83+
84+
// Create a spy on the sendInput method
85+
const sendInputSpy = vi.spyOn(process, "sendInput")
86+
87+
// Send input to the process
88+
terminal.sendInput("test input")
89+
90+
// Wait for the process to complete
91+
await processPromise
92+
93+
// Verify sendInput was called with the correct input
94+
expect(sendInputSpy).toHaveBeenCalledWith("test input")
95+
})
96+
97+
it("isWaitingForInput should return the correct state", async () => {
98+
// Run a command and get the process
99+
const processPromise = terminal.runCommand("echo 'Testing stdin'", callbacks)
100+
101+
// Initially, the process should not be waiting for input
102+
expect(terminal.isWaitingForInput()).toBe(false)
103+
104+
// Manually set the process to be waiting for input
105+
// @ts-ignore - Accessing private property for testing
106+
terminal.process!.waitingForInput = true
107+
108+
// Now it should report that it's waiting for input
109+
expect(terminal.isWaitingForInput()).toBe(true)
110+
111+
// Wait for the process to complete
112+
await processPromise
113+
})
114+
})

src/integrations/terminal/types.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,27 @@ export interface RooTerminal {
99
running: boolean
1010
taskId?: string
1111
process?: RooTerminalProcess
12-
getCurrentWorkingDirectory(): string
13-
isClosed: () => boolean
12+
1413
runCommand: (command: string, callbacks: RooTerminalCallbacks) => RooTerminalProcessResultPromise
14+
sendInput: (input: string) => boolean
15+
1516
setActiveStream(stream: AsyncIterable<string> | undefined, pid?: number): void
16-
shellExecutionComplete(exitDetails: ExitCodeDetails): void
17+
18+
getCurrentWorkingDirectory(): string
19+
getLastCommand(): string
1720
getProcessesWithOutput(): RooTerminalProcess[]
1821
getUnretrievedOutput(): string
19-
getLastCommand(): string
22+
23+
isWaitingForInput: () => boolean
24+
isClosed: () => boolean
25+
26+
shellExecutionComplete(exitDetails: ExitCodeDetails): void
2027
cleanCompletedProcessQueue(): void
2128
}
2229

2330
export interface RooTerminalCallbacks {
2431
onLine: (line: string, process: RooTerminalProcess) => void
32+
onInputRequired?: (process: RooTerminalProcess) => void
2533
onCompleted: (output: string | undefined, process: RooTerminalProcess) => void
2634
onShellExecutionStarted: (pid: number | undefined, process: RooTerminalProcess) => void
2735
onShellExecutionComplete: (details: ExitCodeDetails, process: RooTerminalProcess) => void
@@ -31,17 +39,22 @@ export interface RooTerminalCallbacks {
3139
export interface RooTerminalProcess extends EventEmitter<RooTerminalProcessEvents> {
3240
command: string
3341
isHot: boolean
42+
3443
run: (command: string) => Promise<void>
44+
sendInput: (input: string) => void
3545
continue: () => void
3646
abort: () => void
47+
3748
hasUnretrievedOutput: () => boolean
3849
getUnretrievedOutput: () => string
50+
isWaitingForInput: () => boolean
3951
}
4052

4153
export type RooTerminalProcessResultPromise = RooTerminalProcess & Promise<void>
4254

4355
export interface RooTerminalProcessEvents {
4456
line: [line: string]
57+
input_required: []
4558
continue: []
4659
completed: [output?: string]
4760
stream_available: [stream: AsyncIterable<string>]

0 commit comments

Comments
 (0)