Skip to content

Commit ebf1b24

Browse files
committed
fix: improve command parsing to handle Output: separator correctly
- Fixed parseCommandAndOutput to properly handle the newline + 'Output:' separator - Added test cases for commands with numbers at the start of output lines - Updated existing tests to use template literals for proper newline handling - Fixed test assertions to handle multiple code blocks when output is present This resolves the issue where output lines starting with numbers (like 'wc -l' output) were being incorrectly parsed as the command instead of the actual command text.
1 parent 860631c commit ebf1b24

File tree

3 files changed

+148
-43
lines changed

3 files changed

+148
-43
lines changed

webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@ vi.mock("../../common/CodeBlock", () => ({
2121
default: ({ source }: { source: string }) => <div data-testid="code-block">{source}</div>,
2222
}))
2323

24+
// Mock the commandPatterns module but use the actual implementation
25+
vi.mock("../../../utils/commandPatterns", async () => {
26+
const actual = await vi.importActual<typeof import("../../../utils/commandPatterns")>(
27+
"../../../utils/commandPatterns",
28+
)
29+
return {
30+
...actual,
31+
parseCommandAndOutput: actual.parseCommandAndOutput,
32+
extractCommandPatterns: actual.extractCommandPatterns,
33+
getPatternDescription: actual.getPatternDescription,
34+
}
35+
})
36+
2437
vi.mock("../CommandPatternSelector", () => ({
2538
CommandPatternSelector: ({ patterns, onAllowPatternChange, onDenyPatternChange }: any) => (
2639
<div data-testid="command-pattern-selector">
@@ -66,7 +79,7 @@ describe("CommandExecution", () => {
6679
it("should render command with output", () => {
6780
render(
6881
<ExtensionStateWrapper>
69-
<CommandExecution executionId="test-1" text="npm install\n\nCommand Output:\nInstalling packages..." />
82+
<CommandExecution executionId="test-1" text="npm install\nOutput:\nInstalling packages..." />
7083
</ExtensionStateWrapper>,
7184
)
7285

@@ -166,29 +179,42 @@ describe("CommandExecution", () => {
166179
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: [] })
167180
})
168181

169-
it("should parse command with $ prefix", () => {
182+
it("should parse command with Output: separator", () => {
183+
const commandText = `npm install
184+
Output:
185+
Installing...`
186+
170187
render(
171188
<ExtensionStateWrapper>
172-
<CommandExecution executionId="test-1" text="$ npm install\nInstalling..." />
189+
<CommandExecution executionId="test-1" text={commandText} />
173190
</ExtensionStateWrapper>,
174191
)
175192

176-
expect(screen.getByTestId("code-block")).toHaveTextContent("npm install")
193+
const codeBlocks = screen.getAllByTestId("code-block")
194+
expect(codeBlocks[0]).toHaveTextContent("npm install")
177195
})
178196

179197
it("should parse command with AI suggestions", () => {
198+
const commandText = `npm install
199+
Output:
200+
Suggested patterns: npm, npm install, npm run`
201+
180202
render(
181203
<ExtensionStateWrapper>
182-
<CommandExecution
183-
executionId="test-1"
184-
text="$ npm install\nSuggested patterns: npm, npm install, npm run"
185-
/>
204+
<CommandExecution executionId="test-1" text={commandText} />
186205
</ExtensionStateWrapper>,
187206
)
188207

208+
// First check that the command was parsed correctly
209+
const codeBlocks = screen.getAllByTestId("code-block")
210+
expect(codeBlocks[0]).toHaveTextContent("npm install")
211+
expect(codeBlocks[1]).toHaveTextContent("Suggested patterns: npm, npm install, npm run")
212+
189213
expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument()
190214
// Check that the patterns are present in the mock
191215
expect(screen.getByText("npm")).toBeInTheDocument()
216+
expect(screen.getAllByText("npm install").length).toBeGreaterThan(0)
217+
expect(screen.getByText("npm run")).toBeInTheDocument()
192218
})
193219

194220
it("should handle commands with pipes", () => {
@@ -232,14 +258,20 @@ describe("CommandExecution", () => {
232258
terminalShellIntegrationDisabled: true,
233259
}
234260

261+
const commandText = `npm install
262+
Output:
263+
Output here`
264+
235265
render(
236266
<ExtensionStateContext.Provider value={disabledState as any}>
237-
<CommandExecution executionId="test-1" text="npm install\n\nCommand Output:\nOutput here" />
267+
<CommandExecution executionId="test-1" text={commandText} />
238268
</ExtensionStateContext.Provider>,
239269
)
240270

241271
// Output should be visible when shell integration is disabled
242-
expect(screen.getByText(/Output here/)).toBeInTheDocument()
272+
const codeBlocks = screen.getAllByTestId("code-block")
273+
expect(codeBlocks).toHaveLength(2) // Command and output blocks
274+
expect(codeBlocks[1]).toHaveTextContent("Output here")
243275
})
244276

245277
it("should handle undefined allowedCommands and deniedCommands", () => {

webview-ui/src/utils/__tests__/commandPatterns.spec.ts

Lines changed: 87 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -133,25 +133,28 @@ describe("getPatternDescription", () => {
133133
})
134134

135135
describe("parseCommandAndOutput", () => {
136-
it("should parse command with $ prefix", () => {
136+
it("should handle command with $ prefix without Output: separator", () => {
137137
const text = "$ npm install\nInstalling packages..."
138138
const result = parseCommandAndOutput(text)
139-
expect(result.command).toBe("npm install")
140-
expect(result.output).toBe("Installing packages...")
139+
// Without Output: separator, the entire text is treated as command
140+
expect(result.command).toBe("$ npm install\nInstalling packages...")
141+
expect(result.output).toBe("")
141142
})
142143

143-
it("should parse command with ❯ prefix", () => {
144+
it("should handle command with ❯ prefix without Output: separator", () => {
144145
const text = "❯ git status\nOn branch main"
145146
const result = parseCommandAndOutput(text)
146-
expect(result.command).toBe("git status")
147-
expect(result.output).toBe("On branch main")
147+
// Without Output: separator, the entire text is treated as command
148+
expect(result.command).toBe("❯ git status\nOn branch main")
149+
expect(result.output).toBe("")
148150
})
149151

150-
it("should parse command with > prefix", () => {
152+
it("should handle command with > prefix without Output: separator", () => {
151153
const text = "> echo hello\nhello"
152154
const result = parseCommandAndOutput(text)
153-
expect(result.command).toBe("echo hello")
154-
expect(result.output).toBe("hello")
155+
// Without Output: separator, the entire text is treated as command
156+
expect(result.command).toBe("> echo hello\nhello")
157+
expect(result.output).toBe("")
155158
})
156159

157160
it("should return original text if no command prefix found", () => {
@@ -161,52 +164,60 @@ describe("parseCommandAndOutput", () => {
161164
expect(result.output).toBe("")
162165
})
163166

164-
it("should extract AI suggestions from output", () => {
165-
const text = "$ npm install\nSuggested patterns: npm, npm install, npm run"
167+
it("should extract AI suggestions from output with Output: separator", () => {
168+
const text = "npm install\nOutput:\nSuggested patterns: npm, npm install, npm run"
166169
const result = parseCommandAndOutput(text)
170+
expect(result.command).toBe("npm install")
167171
expect(result.suggestions).toEqual(["npm", "npm install", "npm run"])
168172
})
169173

170174
it("should extract suggestions with different formats", () => {
171-
const text = "$ git push\nCommand patterns: git, git push"
175+
const text = "git push\nOutput:\nCommand patterns: git, git push"
172176
const result = parseCommandAndOutput(text)
177+
expect(result.command).toBe("git push")
173178
expect(result.suggestions).toEqual(["git", "git push"])
174179
})
175180

176181
it('should extract suggestions from "you can allow" format', () => {
177-
const text = "$ docker run\nYou can allow: docker, docker run"
182+
const text = "docker run\nOutput:\nYou can allow: docker, docker run"
178183
const result = parseCommandAndOutput(text)
184+
expect(result.command).toBe("docker run")
179185
expect(result.suggestions).toEqual(["docker", "docker run"])
180186
})
181187

182188
it("should extract suggestions from bullet points", () => {
183-
const text = `$ npm test
189+
const text = `npm test
190+
Output:
184191
Output here...
185192
- npm
186193
- npm test
187194
- npm run`
188195
const result = parseCommandAndOutput(text)
196+
expect(result.command).toBe("npm test")
189197
expect(result.suggestions).toContain("npm")
190198
expect(result.suggestions).toContain("npm test")
191199
expect(result.suggestions).toContain("npm run")
192200
})
193201

194202
it("should extract suggestions from various bullet formats", () => {
195-
const text = `$ command
203+
const text = `command
204+
Output:
196205
• npm
197206
* git
198207
- docker
199208
▪ python`
200209
const result = parseCommandAndOutput(text)
210+
expect(result.command).toBe("command")
201211
expect(result.suggestions).toContain("npm")
202212
expect(result.suggestions).toContain("git")
203213
expect(result.suggestions).toContain("docker")
204214
expect(result.suggestions).toContain("python")
205215
})
206216

207217
it("should extract suggestions with backticks", () => {
208-
const text = "$ npm install\n- `npm`\n- `npm install`"
218+
const text = "npm install\nOutput:\n- `npm`\n- `npm install`"
209219
const result = parseCommandAndOutput(text)
220+
expect(result.command).toBe("npm install")
210221
expect(result.suggestions).toContain("npm")
211222
expect(result.suggestions).toContain("npm install")
212223
})
@@ -218,25 +229,28 @@ Output here...
218229
expect(result.suggestions).toEqual([])
219230
})
220231

221-
it("should handle multiline commands", () => {
232+
it("should handle multiline commands without Output: separator", () => {
222233
const text = `$ npm install \\
223-
express \\
224-
mongoose
234+
express \\
235+
mongoose
225236
Installing...`
226237
const result = parseCommandAndOutput(text)
227-
expect(result.command).toBe("npm install \\")
228-
expect(result.output).toContain("express")
238+
// Without Output: separator, entire text is treated as command
239+
expect(result.command).toBe(text)
240+
expect(result.output).toBe("")
229241
})
230242

231-
it("should include all suggestions from comma-separated list", () => {
232-
const text = "$ test\nSuggested patterns: npm, npm install, npm run"
243+
it("should include all suggestions from comma-separated list with Output: separator", () => {
244+
const text = "test\nOutput:\nSuggested patterns: npm, npm install, npm run"
233245
const result = parseCommandAndOutput(text)
246+
expect(result.command).toBe("test")
234247
expect(result.suggestions).toEqual(["npm", "npm install", "npm run"])
235248
})
236249

237250
it("should handle case variations in suggestion patterns", () => {
238-
const text = "$ test\nSuggested Patterns: npm, git\nCommand Patterns: docker"
251+
const text = "test\nOutput:\nSuggested Patterns: npm, git\nCommand Patterns: docker"
239252
const result = parseCommandAndOutput(text)
253+
expect(result.command).toBe("test")
240254
// Now it should accumulate all suggestions
241255
expect(result.suggestions).toContain("npm")
242256
expect(result.suggestions).toContain("git")
@@ -277,6 +291,55 @@ Installing...`
277291
expect(result.command).toBe('echo "test"')
278292
expect(result.output).toBe("First output\nOutput: Second output")
279293
})
294+
295+
it("should handle output with numbers at the start of lines", () => {
296+
const text = `wc -l *.go *.java
297+
Output:
298+
25 hello_world.go
299+
316 HelloWorld.java
300+
341 total`
301+
const result = parseCommandAndOutput(text)
302+
expect(result.command).toBe("wc -l *.go *.java")
303+
expect(result.output).toBe("25 hello_world.go\n316 HelloWorld.java\n341 total")
304+
expect(result.suggestions).toEqual([])
305+
})
306+
307+
it("should handle edge case where text starts with Output:", () => {
308+
const text = "Output:\nSome output without a command"
309+
const result = parseCommandAndOutput(text)
310+
expect(result.command).toBe("")
311+
expect(result.output).toBe("Some output without a command")
312+
})
313+
314+
it("should not be confused by Output: appearing in the middle of output", () => {
315+
const text = `echo "Output: test"
316+
Output:
317+
Output: test`
318+
const result = parseCommandAndOutput(text)
319+
expect(result.command).toBe('echo "Output: test"')
320+
expect(result.output).toBe("Output: test")
321+
})
322+
323+
it("should handle commands without shell prompt when Output: separator is present", () => {
324+
const text = `npm install
325+
Output:
326+
Installing packages...`
327+
const result = parseCommandAndOutput(text)
328+
expect(result.command).toBe("npm install")
329+
expect(result.output).toBe("Installing packages...")
330+
})
331+
332+
it("should not parse shell prompts from output when Output: separator exists", () => {
333+
const text = `ls -la
334+
Output:
335+
$ total 341
336+
drwxr-xr-x 10 user staff 320 Jan 22 12:00 .
337+
drwxr-xr-x 20 user staff 640 Jan 22 11:00 ..`
338+
const result = parseCommandAndOutput(text)
339+
expect(result.command).toBe("ls -la")
340+
expect(result.output).toContain("$ total 341")
341+
expect(result.output).toContain("drwxr-xr-x")
342+
})
280343
})
281344

282345
describe("detectSecurityIssues", () => {

webview-ui/src/utils/commandPatterns.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -101,20 +101,30 @@ export function parseCommandAndOutput(text: string): {
101101
// First check if the text already has been split by COMMAND_OUTPUT_STRING
102102
// This happens when the command has already been executed and we have the output
103103
const outputSeparator = "Output:"
104-
const outputIndex = text.indexOf(outputSeparator)
104+
const outputIndex = text.indexOf(`\n${outputSeparator}`)
105105

106106
if (outputIndex !== -1) {
107107
// Text is already split into command and output
108+
// The command is everything before the output separator
108109
result.command = text.slice(0, outputIndex).trim()
109-
result.output = text.slice(outputIndex + outputSeparator.length).trim()
110-
} else {
111-
// Try to extract command from the text
112-
// Look for patterns like "$ command" or "❯ command" at the start
113-
const commandMatch = text.match(/^[$>]\s*(.+?)(?:\n|$)/m)
114-
if (commandMatch) {
115-
result.command = commandMatch[1].trim()
116-
result.output = text.substring(commandMatch.index! + commandMatch[0].length).trim()
110+
// The output is everything after the output separator
111+
// We need to skip the newline and "Output:" text
112+
const afterNewline = outputIndex + 1 // Skip the newline
113+
const afterSeparator = afterNewline + outputSeparator.length // Skip "Output:"
114+
// Check if there's a colon and potential space after it
115+
let startOfOutput = afterSeparator
116+
if (text[afterSeparator] === "\n") {
117+
startOfOutput = afterSeparator + 1 // Skip additional newline after "Output:"
117118
}
119+
result.output = text.slice(startOfOutput).trim()
120+
} else if (text.indexOf(outputSeparator) === 0) {
121+
// Edge case: text starts with "Output:" (no command)
122+
result.command = ""
123+
result.output = text.slice(outputSeparator.length).trim()
124+
} else {
125+
// No output separator found, the entire text is the command
126+
result.command = text.trim()
127+
result.output = ""
118128
}
119129

120130
// Look for AI suggestions in the output

0 commit comments

Comments
 (0)