Skip to content

Commit 5bcd678

Browse files
committed
refactor(terminal): promote no_shell_integration payload to typed object
1 parent 6a55653 commit 5bcd678

3 files changed

Lines changed: 122 additions & 26 deletions

File tree

src/integrations/terminal/TerminalProcess.ts

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,10 @@ export class TerminalProcess extends BaseTerminalProcess {
4949
"[TerminalProcess] Shell integration not available. Command sent without knowledge of response.",
5050
)
5151

52-
this.emit(
53-
"no_shell_integration",
54-
"Command was submitted; output is not available, as shell integration is inactive.",
55-
)
52+
this.emit("no_shell_integration", {
53+
message: "Command was submitted; output is not available, as shell integration is inactive.",
54+
commandSubmitted: true,
55+
})
5656

5757
this.emit(
5858
"completed",
@@ -70,10 +70,10 @@ export class TerminalProcess extends BaseTerminalProcess {
7070
this.removeAllListeners("stream_available")
7171

7272
// Emit no_shell_integration event with descriptive message
73-
this.emit(
74-
"no_shell_integration",
75-
`VSCE shell integration stream did not start within ${Terminal.getShellIntegrationTimeout() / 1000} seconds. Terminal problem?`,
76-
)
73+
this.emit("no_shell_integration", {
74+
message: `VSCE shell integration stream did not start within ${Terminal.getShellIntegrationTimeout() / 1000} seconds. Terminal problem?`,
75+
commandSubmitted: true,
76+
})
7777

7878
// Reject with descriptive error
7979
reject(
@@ -95,10 +95,16 @@ export class TerminalProcess extends BaseTerminalProcess {
9595
this.once("shell_execution_complete", (details: ExitCodeDetails) => resolve(details))
9696
})
9797

98-
// Execute command
99-
const defaultWindowsShellProfile = vscode.workspace
100-
.getConfiguration("terminal.integrated.defaultProfile")
101-
.get("windows")
98+
// Execute command.
99+
// Determine whether the active shell is PowerShell so we can apply the
100+
// PS-specific counter/sleep workarounds. Prefer the Zoo Code profile
101+
// override (if set) over the VS Code default profile. Fix for the wrong
102+
// config API: must be getConfiguration("terminal.integrated").get(
103+
// "defaultProfile.windows"), not the reversed form that always returns null.
104+
const profileOverride = Terminal.getTerminalProfile()
105+
const defaultWindowsShellProfile =
106+
profileOverride ??
107+
vscode.workspace.getConfiguration("terminal.integrated").get<string>("defaultProfile.windows")
102108

103109
const isPowerShell =
104110
process.platform === "win32" &&
@@ -208,26 +214,35 @@ export class TerminalProcess extends BaseTerminalProcess {
208214
// Emit any remaining output before completing
209215
this.emitRemainingBufferIfListening()
210216
} else {
211-
const errorMsg =
212-
"VSCE output start escape sequence (]633;C or ]133;C) not received, but the stream has started. Upstream VSCE Bug?"
213-
214217
const inspectPreOutput = inspect(preOutput, { colors: false, breakLength: Infinity })
218+
219+
// Empty stream (preOutput === '') is a first-run race: VS Code fires
220+
// onDidStartTerminalShellExecution before the shell is fully initialized on
221+
// a freshly-created terminal. The command was never submitted, so this is
222+
// retryable (commandSubmitted: false triggers the execa fallback).
223+
//
224+
// Non-empty preOutput means the stream had data but ]633;C never arrived —
225+
// a genuine shell integration failure after submission (not retryable).
226+
const commandSubmitted = preOutput !== ""
227+
228+
const errorMsg = commandSubmitted
229+
? "VSCE output start escape sequence (]633;C or ]133;C) not received, but the stream has started. Upstream VSCE Bug?"
230+
: "VSCE shell integration stream completed with no output on first command (shell startup race). Command was not submitted."
231+
215232
console.error(`[Terminal Process] ${errorMsg} preOutput: ${inspectPreOutput}`)
216233

217-
// Emit no_shell_integration event
218-
this.emit("no_shell_integration", errorMsg)
234+
this.emit("no_shell_integration", { message: errorMsg, commandSubmitted })
219235

220-
// Emit completed event with error message
221236
this.emit(
222237
"completed",
223-
"<VSCE shell integration markers not found: terminal output and command execution status is unknown>\n" +
224-
`<preOutput>${inspectPreOutput}</preOutput>\n` +
225-
"AI MODEL: You MUST notify the user with the information above so they can open a bug report.",
238+
commandSubmitted
239+
? "<VSCE shell integration markers not found: terminal output and command execution status is unknown>\n" +
240+
`<preOutput>${inspectPreOutput}</preOutput>\n` +
241+
"AI MODEL: You MUST notify the user with the information above so they can open a bug report."
242+
: "<shell integration stream was empty on first execution: command was not submitted>",
226243
)
227244

228245
this.continue()
229-
230-
// Return early since we can't process output without shell integration markers
231246
return
232247
}
233248

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

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,15 @@ describe("TerminalProcess", () => {
114114

115115
// Create new process with the no-shell terminal
116116
const noShellProcess = new TerminalProcess(noShellTerminalInfo)
117+
let commandSubmitted: boolean | undefined
117118

118119
// Set up event listeners to verify events are emitted
119120
const eventPromises = Promise.all([
120121
new Promise<void>((resolve) =>
121-
noShellProcess.once("no_shell_integration", (_message: string) => resolve()),
122+
noShellProcess.once("no_shell_integration", (details) => {
123+
commandSubmitted = details.commandSubmitted
124+
resolve()
125+
}),
122126
),
123127
new Promise<void>((resolve) => noShellProcess.once("completed", (_output?: string) => resolve())),
124128
new Promise<void>((resolve) => noShellProcess.once("continue", resolve)),
@@ -130,11 +134,83 @@ describe("TerminalProcess", () => {
130134

131135
// Verify sendText was called with the command
132136
expect(noShellTerminal.sendText).toHaveBeenCalledWith("test command", true)
137+
expect(commandSubmitted).toBe(true)
133138

134139
// Restore the original console.warn
135140
consoleWarnSpy.mockRestore()
136141
})
137142

143+
it("emits no_shell_integration with commandSubmitted=false when stream is empty (first-run startup race)", async () => {
144+
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
145+
146+
let details: { message: string; commandSubmitted: boolean } | undefined
147+
148+
const eventPromises = Promise.all([
149+
new Promise<void>((resolve) =>
150+
terminalProcess.once("no_shell_integration", (d) => {
151+
details = d
152+
resolve()
153+
}),
154+
),
155+
new Promise<void>((resolve) => terminalProcess.once("completed", (_output?: string) => resolve())),
156+
new Promise<void>((resolve) => terminalProcess.once("continue", resolve)),
157+
])
158+
159+
// Empty stream: simulates VS Code firing onDidStartTerminalShellExecution
160+
// before the shell has fully initialised on a freshly-created terminal.
161+
async function* emptyStream(): AsyncGenerator<string> {
162+
terminalProcess.emit("shell_execution_complete", { exitCode: 0 })
163+
return
164+
yield "" // satisfy require-yield; never reached
165+
}
166+
mockStream = emptyStream()
167+
168+
mockExecution = { read: vi.fn().mockReturnValue(mockStream) }
169+
mockTerminal.shellIntegration.executeCommand.mockReturnValue(mockExecution)
170+
171+
const runPromise = terminalProcess.run("test command")
172+
terminalProcess.emit("stream_available", mockStream)
173+
await runPromise
174+
await eventPromises
175+
176+
expect(details?.commandSubmitted).toBe(false)
177+
consoleErrorSpy.mockRestore()
178+
})
179+
180+
it("emits no_shell_integration with commandSubmitted=true when stream has data but no ]633;C", async () => {
181+
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
182+
183+
let details: { message: string; commandSubmitted: boolean } | undefined
184+
185+
const eventPromises = Promise.all([
186+
new Promise<void>((resolve) =>
187+
terminalProcess.once("no_shell_integration", (d) => {
188+
details = d
189+
resolve()
190+
}),
191+
),
192+
new Promise<void>((resolve) => terminalProcess.once("completed", (_output?: string) => resolve())),
193+
new Promise<void>((resolve) => terminalProcess.once("continue", resolve)),
194+
])
195+
196+
// Stream has output but never emits ]633;C — genuine shell integration failure.
197+
mockStream = (async function* () {
198+
yield "some output without marker\n"
199+
terminalProcess.emit("shell_execution_complete", { exitCode: 0 })
200+
})()
201+
202+
mockExecution = { read: vi.fn().mockReturnValue(mockStream) }
203+
mockTerminal.shellIntegration.executeCommand.mockReturnValue(mockExecution)
204+
205+
const runPromise = terminalProcess.run("test command")
206+
terminalProcess.emit("stream_available", mockStream)
207+
await runPromise
208+
await eventPromises
209+
210+
expect(details?.commandSubmitted).toBe(true)
211+
consoleErrorSpy.mockRestore()
212+
})
213+
138214
it("sets hot state for compiling commands", async () => {
139215
let lines: string[] = []
140216

src/integrations/terminal/types.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@ export interface RooTerminalCallbacks {
2626
onCompleted: (output: string | undefined, process: RooTerminalProcess) => void | Promise<void>
2727
onShellExecutionStarted: (pid: number | undefined, process: RooTerminalProcess) => void
2828
onShellExecutionComplete: (details: ExitCodeDetails, process: RooTerminalProcess) => void
29-
onNoShellIntegration?: (message: string, process: RooTerminalProcess) => void
29+
onNoShellIntegration?: (details: ShellIntegrationErrorDetails, process: RooTerminalProcess) => void
30+
}
31+
32+
export interface ShellIntegrationErrorDetails {
33+
message: string
34+
commandSubmitted: boolean
3035
}
3136

3237
export interface RooTerminalProcess extends EventEmitter<RooTerminalProcessEvents> {
@@ -50,7 +55,7 @@ export interface RooTerminalProcessEvents {
5055
shell_execution_started: [pid: number | undefined]
5156
shell_execution_complete: [exitDetails: ExitCodeDetails]
5257
error: [error: Error]
53-
no_shell_integration: [message: string]
58+
no_shell_integration: [details: ShellIntegrationErrorDetails]
5459
}
5560

5661
export interface ExitCodeDetails {

0 commit comments

Comments
 (0)