Skip to content

Commit 5032f9d

Browse files
committed
Add support for command argument hints
1 parent 9baac07 commit 5032f9d

File tree

7 files changed

+233
-5
lines changed

7 files changed

+233
-5
lines changed

src/core/webview/webviewMessageHandler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2375,6 +2375,7 @@ export const webviewMessageHandler = async (
23752375
source: command.source,
23762376
filePath: command.filePath,
23772377
description: command.description,
2378+
argumentHint: command.argumentHint,
23782379
}))
23792380

23802381
await provider.postMessageToWebview({

src/services/command/__tests__/frontmatter-commands.spec.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ npm run build
4343
source: "project",
4444
filePath: path.join("/test/cwd", ".roo", "commands", "setup.md"),
4545
description: "Sets up the development environment",
46+
argumentHint: undefined,
4647
})
4748
})
4849

@@ -66,6 +67,7 @@ npm run build
6667
source: "project",
6768
filePath: path.join("/test/cwd", ".roo", "commands", "setup.md"),
6869
description: undefined,
70+
argumentHint: undefined,
6971
})
7072
})
7173

@@ -108,6 +110,7 @@ Command content here.`
108110
source: "project",
109111
filePath: path.join("/test/cwd", ".roo", "commands", "setup.md"),
110112
description: undefined,
113+
argumentHint: undefined,
111114
})
112115
})
113116

@@ -142,6 +145,7 @@ Global setup instructions.`
142145
source: "project",
143146
filePath: path.join("/test/cwd", ".roo", "commands", "setup.md"),
144147
description: "Project-specific setup",
148+
argumentHint: undefined,
145149
})
146150
})
147151

@@ -168,10 +172,118 @@ Global setup instructions.`
168172
source: "global",
169173
filePath: expect.stringContaining(path.join(".roo", "commands", "setup.md")),
170174
description: "Global setup command",
175+
argumentHint: undefined,
171176
})
172177
})
173178
})
174179

180+
describe("argument-hint functionality", () => {
181+
it("should load command with argument-hint from frontmatter", async () => {
182+
const commandContent = `---
183+
description: Create a new release of the Roo Code extension
184+
argument-hint: patch | minor | major
185+
---
186+
187+
# Release Command
188+
189+
Create a new release.`
190+
191+
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
192+
mockFs.readFile = vi.fn().mockResolvedValue(commandContent)
193+
194+
const result = await getCommand("/test/cwd", "release")
195+
196+
expect(result).toEqual({
197+
name: "release",
198+
content: "# Release Command\n\nCreate a new release.",
199+
source: "project",
200+
filePath: path.join("/test/cwd", ".roo", "commands", "release.md"),
201+
description: "Create a new release of the Roo Code extension",
202+
argumentHint: "patch | minor | major",
203+
})
204+
})
205+
206+
it("should handle command with both description and argument-hint", async () => {
207+
const commandContent = `---
208+
description: Deploy application to environment
209+
argument-hint: staging | production
210+
author: DevOps Team
211+
---
212+
213+
# Deploy Command
214+
215+
Deploy the application.`
216+
217+
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
218+
mockFs.readFile = vi.fn().mockResolvedValue(commandContent)
219+
220+
const result = await getCommand("/test/cwd", "deploy")
221+
222+
expect(result).toEqual({
223+
name: "deploy",
224+
content: "# Deploy Command\n\nDeploy the application.",
225+
source: "project",
226+
filePath: path.join("/test/cwd", ".roo", "commands", "deploy.md"),
227+
description: "Deploy application to environment",
228+
argumentHint: "staging | production",
229+
})
230+
})
231+
232+
it("should handle empty argument-hint in frontmatter", async () => {
233+
const commandContent = `---
234+
description: Test command
235+
argument-hint: ""
236+
---
237+
238+
# Test Command
239+
240+
Test content.`
241+
242+
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
243+
mockFs.readFile = vi.fn().mockResolvedValue(commandContent)
244+
245+
const result = await getCommand("/test/cwd", "test")
246+
247+
expect(result?.argumentHint).toBeUndefined()
248+
})
249+
250+
it("should handle whitespace-only argument-hint in frontmatter", async () => {
251+
const commandContent = `---
252+
description: Test command
253+
argument-hint: " "
254+
---
255+
256+
# Test Command
257+
258+
Test content.`
259+
260+
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
261+
mockFs.readFile = vi.fn().mockResolvedValue(commandContent)
262+
263+
const result = await getCommand("/test/cwd", "test")
264+
265+
expect(result?.argumentHint).toBeUndefined()
266+
})
267+
268+
it("should handle non-string argument-hint in frontmatter", async () => {
269+
const commandContent = `---
270+
description: Test command
271+
argument-hint: 123
272+
---
273+
274+
# Test Command
275+
276+
Test content.`
277+
278+
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
279+
mockFs.readFile = vi.fn().mockResolvedValue(commandContent)
280+
281+
const result = await getCommand("/test/cwd", "test")
282+
283+
expect(result?.argumentHint).toBeUndefined()
284+
})
285+
})
286+
175287
describe("getCommands with frontmatter", () => {
176288
it("should load multiple commands with descriptions", async () => {
177289
const setupContent = `---
@@ -215,14 +327,62 @@ Build instructions without frontmatter.`
215327
expect.objectContaining({
216328
name: "setup",
217329
description: "Sets up the development environment",
330+
argumentHint: undefined,
218331
}),
219332
expect.objectContaining({
220333
name: "deploy",
221334
description: "Deploys the application to production",
335+
argumentHint: undefined,
222336
}),
223337
expect.objectContaining({
224338
name: "build",
225339
description: undefined,
340+
argumentHint: undefined,
341+
}),
342+
]),
343+
)
344+
})
345+
346+
it("should load multiple commands with argument hints", async () => {
347+
const releaseContent = `---
348+
description: Create a new release
349+
argument-hint: patch | minor | major
350+
---
351+
352+
# Release Command
353+
354+
Create a release.`
355+
356+
const deployContent = `---
357+
description: Deploy to environment
358+
argument-hint: staging | production
359+
---
360+
361+
# Deploy Command
362+
363+
Deploy the app.`
364+
365+
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
366+
mockFs.readdir = vi.fn().mockResolvedValue([
367+
{ name: "release.md", isFile: () => true },
368+
{ name: "deploy.md", isFile: () => true },
369+
])
370+
mockFs.readFile = vi.fn().mockResolvedValueOnce(releaseContent).mockResolvedValueOnce(deployContent)
371+
372+
const result = await getCommands("/test/cwd")
373+
374+
expect(result).toHaveLength(2)
375+
expect(result).toEqual(
376+
expect.arrayContaining([
377+
expect.objectContaining({
378+
name: "release",
379+
description: "Create a new release",
380+
argumentHint: "patch | minor | major",
381+
}),
382+
expect.objectContaining({
383+
name: "deploy",
384+
description: "Deploy to environment",
385+
argumentHint: "staging | production",
226386
}),
227387
]),
228388
)

src/services/command/commands.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface Command {
99
source: "global" | "project"
1010
filePath: string
1111
description?: string
12+
argumentHint?: string
1213
}
1314

1415
/**
@@ -70,6 +71,7 @@ async function tryLoadCommand(
7071

7172
let parsed
7273
let description: string | undefined
74+
let argumentHint: string | undefined
7375
let commandContent: string
7476

7577
try {
@@ -79,10 +81,15 @@ async function tryLoadCommand(
7981
typeof parsed.data.description === "string" && parsed.data.description.trim()
8082
? parsed.data.description.trim()
8183
: undefined
84+
argumentHint =
85+
typeof parsed.data["argument-hint"] === "string" && parsed.data["argument-hint"].trim()
86+
? parsed.data["argument-hint"].trim()
87+
: undefined
8288
commandContent = parsed.content.trim()
8389
} catch (frontmatterError) {
8490
// If frontmatter parsing fails, treat the entire content as command content
8591
description = undefined
92+
argumentHint = undefined
8693
commandContent = content.trim()
8794
}
8895

@@ -92,6 +99,7 @@ async function tryLoadCommand(
9299
source,
93100
filePath,
94101
description,
102+
argumentHint,
95103
}
96104
} catch (error) {
97105
// File doesn't exist or can't be read
@@ -137,6 +145,7 @@ async function scanCommandDirectory(
137145

138146
let parsed
139147
let description: string | undefined
148+
let argumentHint: string | undefined
140149
let commandContent: string
141150

142151
try {
@@ -146,10 +155,15 @@ async function scanCommandDirectory(
146155
typeof parsed.data.description === "string" && parsed.data.description.trim()
147156
? parsed.data.description.trim()
148157
: undefined
158+
argumentHint =
159+
typeof parsed.data["argument-hint"] === "string" && parsed.data["argument-hint"].trim()
160+
? parsed.data["argument-hint"].trim()
161+
: undefined
149162
commandContent = parsed.content.trim()
150163
} catch (frontmatterError) {
151164
// If frontmatter parsing fails, treat the entire content as command content
152165
description = undefined
166+
argumentHint = undefined
153167
commandContent = content.trim()
154168
}
155169

@@ -161,6 +175,7 @@ async function scanCommandDirectory(
161175
source,
162176
filePath,
163177
description,
178+
argumentHint,
164179
})
165180
}
166181
} catch (error) {

src/shared/ExtensionMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface Command {
2525
source: "global" | "project"
2626
filePath?: string
2727
description?: string
28+
argumentHint?: string
2829
}
2930

3031
// Type for marketplace installed metadata

webview-ui/src/__tests__/command-autocomplete.spec.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ describe("Command Autocomplete", () => {
99
{ name: "deploy", source: "global" },
1010
{ name: "test-suite", source: "project" },
1111
{ name: "cleanup_old", source: "global" },
12+
{ name: "release", source: "project", argumentHint: "patch | minor | major" },
1213
]
1314

1415
const mockQueryItems = [
@@ -20,19 +21,20 @@ describe("Command Autocomplete", () => {
2021
it('should return all commands when query is just "/"', () => {
2122
const options = getContextMenuOptions("/", null, mockQueryItems, [], [], mockCommands)
2223

23-
// Should have 6 items: 1 section header + 5 commands
24-
expect(options).toHaveLength(6)
24+
// Should have 7 items: 1 section header + 6 commands
25+
expect(options).toHaveLength(7)
2526

2627
// Filter out section headers to check commands
2728
const commandOptions = options.filter((option) => option.type === ContextMenuOptionType.Command)
28-
expect(commandOptions).toHaveLength(5)
29+
expect(commandOptions).toHaveLength(6)
2930

3031
const commandNames = commandOptions.map((option) => option.value)
3132
expect(commandNames).toContain("setup")
3233
expect(commandNames).toContain("build")
3334
expect(commandNames).toContain("deploy")
3435
expect(commandNames).toContain("test-suite")
3536
expect(commandNames).toContain("cleanup_old")
37+
expect(commandNames).toContain("release")
3638
})
3739

3840
it("should filter commands based on fuzzy search", () => {
@@ -148,7 +150,7 @@ describe("Command Autocomplete", () => {
148150
const commandOptions = options.filter((option) => option.type === ContextMenuOptionType.Command)
149151

150152
expect(modeOptions.length).toBe(2)
151-
expect(commandOptions.length).toBe(5)
153+
expect(commandOptions.length).toBe(6)
152154
})
153155

154156
it("should filter both modes and commands based on query", () => {
@@ -181,6 +183,42 @@ describe("Command Autocomplete", () => {
181183
})
182184
})
183185

186+
describe("argument hint functionality", () => {
187+
it("should include argumentHint in command options when present", () => {
188+
const options = getContextMenuOptions("/release", null, mockQueryItems, [], [], mockCommands)
189+
190+
const releaseOption = options.find((option) => option.value === "release")
191+
expect(releaseOption).toBeDefined()
192+
expect(releaseOption!.argumentHint).toBe("patch | minor | major")
193+
})
194+
195+
it("should handle commands without argumentHint", () => {
196+
const options = getContextMenuOptions("/setup", null, mockQueryItems, [], [], mockCommands)
197+
198+
const setupOption = options.find((option) => option.value === "setup")
199+
expect(setupOption).toBeDefined()
200+
expect(setupOption!.argumentHint).toBeUndefined()
201+
})
202+
203+
it("should preserve argumentHint through fuzzy search", () => {
204+
const options = getContextMenuOptions("/rel", null, mockQueryItems, [], [], mockCommands)
205+
206+
const releaseOption = options.find((option) => option.value === "release")
207+
expect(releaseOption).toBeDefined()
208+
expect(releaseOption!.argumentHint).toBe("patch | minor | major")
209+
})
210+
211+
it("should handle commands with empty argumentHint", () => {
212+
const commandsWithEmptyHint: Command[] = [{ name: "test-command", source: "project", argumentHint: "" }]
213+
214+
const options = getContextMenuOptions("/test", null, mockQueryItems, [], [], commandsWithEmptyHint)
215+
216+
const testOption = options.find((option) => option.value === "test-command")
217+
expect(testOption).toBeDefined()
218+
expect(testOption!.argumentHint).toBe("")
219+
})
220+
})
221+
184222
describe("edge cases", () => {
185223
it("should handle undefined commands gracefully", () => {
186224
const options = getContextMenuOptions("/setup", null, mockQueryItems, [], [], undefined)

0 commit comments

Comments
 (0)