Skip to content

Commit 99c86ae

Browse files
committed
feat(chat): add /profiles + /models built-in slash commands with keyboard UX
- register /profiles as built-in commands, including autocomplete coverage and command metadata/tests - add chat ModelSelector popover with sticky per-mode model updates, reuse it for slash commands and toolbar button, and sync i18n copy (“Model Configuration”) - extend ChatTextArea handling so /profiles or /models open selectors via keyboard, reset slash state, and avoid stale filtering when typing - overhaul ApiConfigSelector and ModelSelector popovers for arrow/enter/escape handling, looped navigation, focus restoration, and stable search input - constrain model search to name/ID (ignore descriptions), prevent hover from mutating filters, and keep selected model visible even when filtered out - update tests to cover new popover controls, alias autocomplete, and built-in command registry - update all relevant i18n strings
1 parent 98b8d5b commit 99c86ae

File tree

28 files changed

+1359
-38
lines changed

28 files changed

+1359
-38
lines changed

src/services/command/__tests__/built-in-commands.spec.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ describe("Built-in Commands", () => {
55
it("should return all built-in commands", async () => {
66
const commands = await getBuiltInCommands()
77

8-
expect(commands).toHaveLength(1)
9-
expect(commands.map((cmd) => cmd.name)).toEqual(expect.arrayContaining(["init"]))
8+
expect(commands).toHaveLength(3)
9+
expect(commands.map((cmd) => cmd.name)).toEqual(expect.arrayContaining(["init", "profiles", "models"]))
1010

1111
// Verify all commands have required properties
1212
commands.forEach((command) => {
@@ -31,6 +31,16 @@ describe("Built-in Commands", () => {
3131
expect(initCommand!.description).toBe(
3232
"Analyze codebase and create concise AGENTS.md files for AI assistants",
3333
)
34+
35+
const profilesCommand = commands.find((cmd) => cmd.name === "profiles")
36+
expect(profilesCommand).toBeDefined()
37+
expect(profilesCommand!.content).toContain("/profiles")
38+
expect(profilesCommand!.description).toBe("Open the API configuration profile selector in the chat input")
39+
40+
const modelsCommand = commands.find((cmd) => cmd.name === "models")
41+
expect(modelsCommand).toBeDefined()
42+
expect(modelsCommand!.content).toContain("/models")
43+
expect(modelsCommand!.description).toBe("Open the model picker for the active API configuration profile")
3444
})
3545
})
3646

@@ -48,6 +58,18 @@ describe("Built-in Commands", () => {
4858
)
4959
})
5060

61+
it("should return profiles and models commands", async () => {
62+
const profilesCommand = await getBuiltInCommand("profiles")
63+
expect(profilesCommand).toBeDefined()
64+
expect(profilesCommand!.filePath).toBe("<built-in:profiles>")
65+
expect(profilesCommand!.description).toBe("Open the API configuration profile selector in the chat input")
66+
67+
const modelsCommand = await getBuiltInCommand("models")
68+
expect(modelsCommand).toBeDefined()
69+
expect(modelsCommand!.filePath).toBe("<built-in:models>")
70+
expect(modelsCommand!.description).toBe("Open the model picker for the active API configuration profile")
71+
})
72+
5173
it("should return undefined for non-existent command", async () => {
5274
const nonExistentCommand = await getBuiltInCommand("non-existent")
5375
expect(nonExistentCommand).toBeUndefined()
@@ -63,10 +85,10 @@ describe("Built-in Commands", () => {
6385
it("should return all built-in command names", async () => {
6486
const names = await getBuiltInCommandNames()
6587

66-
expect(names).toHaveLength(1)
67-
expect(names).toEqual(expect.arrayContaining(["init"]))
88+
expect(names).toHaveLength(3)
89+
expect(names).toEqual(expect.arrayContaining(["init", "profiles", "models"]))
6890
// Order doesn't matter since it's based on filesystem order
69-
expect(names.sort()).toEqual(["init"])
91+
expect(names.sort()).toEqual(["init", "models", "profiles"])
7092
})
7193

7294
it("should return array of strings", async () => {
@@ -99,5 +121,18 @@ describe("Built-in Commands", () => {
99121
expect(content).toContain("rules-ask")
100122
expect(content).toContain("rules-architect")
101123
})
124+
125+
it("profiles command should describe UI interaction", async () => {
126+
const command = await getBuiltInCommand("profiles")
127+
expect(command!.content).toContain("open the API configuration profile selector")
128+
expect(command!.content).toContain("/profiles")
129+
})
130+
131+
it("models command should mention alias and profile scope", async () => {
132+
const command = await getBuiltInCommand("models")
133+
const content = command!.content
134+
expect(content).toContain("/models")
135+
expect(content).toContain("active API configuration profile")
136+
})
102137
})
103138
})

src/services/command/built-in-commands.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,18 @@ Please analyze this codebase and create an AGENTS.md file containing:
284284
285285
Remember: The goal is to create documentation that enables AI assistants to be immediately productive in this codebase, focusing on project-specific knowledge that isn't obvious from the code structure alone.`,
286286
},
287+
profiles: {
288+
name: "profiles",
289+
description: "Open the API configuration profile selector in the chat input",
290+
content: `Use the /profiles command in the Roo Code chat input to open the API configuration profile selector.
291+
This built-in command updates the active API configuration profile in the chat UI and does not insert text into the conversation.`,
292+
},
293+
models: {
294+
name: "models",
295+
description: "Open the model picker for the active API configuration profile",
296+
content: `Use /models in the Roo Code chat input to open the model picker for the active API configuration profile.
297+
This command lets you switch the stored model for that profile directly from the chat UI.`,
298+
},
287299
}
288300

289301
/**

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,18 @@ describe("Command Autocomplete", () => {
6565
expect(options[0].type).toBe(ContextMenuOptionType.NoResults)
6666
})
6767

68+
it("should include additional slash commands", () => {
69+
const options = getContextMenuOptions("/models", null, mockQueryItems, [], [], mockCommands, [
70+
{ name: "models", description: "Switch models" },
71+
])
72+
73+
const command = options.find(
74+
(option) => option.type === ContextMenuOptionType.Command && option.value === "models",
75+
)
76+
expect(command).toBeDefined()
77+
expect(command!.slashCommand).toBe("/models")
78+
})
79+
6880
it("should handle no matching commands", () => {
6981
const options = getContextMenuOptions("/nonexistent", null, mockQueryItems, [], [], mockCommands)
7082

webview-ui/src/components/chat/ApiConfigSelector.tsx

Lines changed: 147 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useMemo, useCallback } from "react"
1+
import { useState, useMemo, useCallback, useEffect, useRef } from "react"
22
import { Fzf } from "fzf"
33

44
import { cn } from "@/lib/utils"
@@ -20,6 +20,8 @@ interface ApiConfigSelectorProps {
2020
listApiConfigMeta: Array<{ id: string; name: string; modelId?: string }>
2121
pinnedApiConfigs?: Record<string, boolean>
2222
togglePinnedApiConfig: (id: string) => void
23+
open?: boolean
24+
onOpenChange?: (open: boolean) => void
2325
}
2426

2527
export const ApiConfigSelector = ({
@@ -32,11 +34,36 @@ export const ApiConfigSelector = ({
3234
listApiConfigMeta,
3335
pinnedApiConfigs,
3436
togglePinnedApiConfig,
37+
open,
38+
onOpenChange,
3539
}: ApiConfigSelectorProps) => {
3640
const { t } = useAppTranslation()
37-
const [open, setOpen] = useState(false)
41+
const [internalOpen, setInternalOpen] = useState(false)
3842
const [searchValue, setSearchValue] = useState("")
43+
const [activeIndex, setActiveIndex] = useState(0)
44+
const itemRefs = useRef<Record<string, HTMLDivElement | null>>({})
45+
const contentRef = useRef<HTMLDivElement>(null)
46+
const initializedActiveRef = useRef(false)
47+
const previousValueRef = useRef(value)
3948
const portalContainer = useRooPortal("roo-portal")
49+
const isControlled = typeof open === "boolean"
50+
const currentOpen = isControlled ? open : internalOpen
51+
52+
const setOpen = useCallback(
53+
(isOpen: boolean) => {
54+
if (!isControlled) {
55+
setInternalOpen(isOpen)
56+
}
57+
onOpenChange?.(isOpen)
58+
},
59+
[isControlled, onOpenChange],
60+
)
61+
62+
useEffect(() => {
63+
if (!currentOpen) {
64+
setSearchValue("")
65+
}
66+
}, [currentOpen])
4067

4168
// Create searchable items for fuzzy search.
4269
const searchableItems = useMemo(
@@ -71,35 +98,87 @@ export const ApiConfigSelector = ({
7198
return { pinnedConfigs: pinned, unpinnedConfigs: unpinned }
7299
}, [filteredConfigs, pinnedApiConfigs])
73100

101+
const visibleConfigs = useMemo(() => [...pinnedConfigs, ...unpinnedConfigs], [pinnedConfigs, unpinnedConfigs])
102+
74103
const handleSelect = useCallback(
75104
(configId: string) => {
76105
onChange(configId)
77106
setOpen(false)
78107
setSearchValue("")
79108
},
80-
[onChange],
109+
[onChange, setOpen],
81110
)
82111

112+
useEffect(() => {
113+
if (!currentOpen) {
114+
initializedActiveRef.current = false
115+
previousValueRef.current = value
116+
return
117+
}
118+
119+
const defaultIndex = visibleConfigs.findIndex((config) => config.id === value)
120+
const shouldReset = !initializedActiveRef.current || previousValueRef.current !== value || defaultIndex === -1
121+
if (shouldReset) {
122+
setActiveIndex(defaultIndex >= 0 ? defaultIndex : 0)
123+
initializedActiveRef.current = true
124+
previousValueRef.current = value
125+
}
126+
}, [currentOpen, value, visibleConfigs])
127+
128+
useEffect(() => {
129+
if (!currentOpen) return
130+
131+
const activeConfig = visibleConfigs[activeIndex]
132+
if (activeConfig) {
133+
itemRefs.current[activeConfig.id]?.scrollIntoView({ block: "nearest" })
134+
}
135+
}, [activeIndex, currentOpen, visibleConfigs])
136+
137+
useEffect(() => {
138+
if (!visibleConfigs.length && activeIndex !== 0) {
139+
setActiveIndex(0)
140+
return
141+
}
142+
143+
if (activeIndex >= visibleConfigs.length && visibleConfigs.length > 0) {
144+
setActiveIndex(visibleConfigs.length - 1)
145+
}
146+
}, [activeIndex, visibleConfigs.length])
147+
83148
const handleEditClick = useCallback(() => {
84149
vscode.postMessage({ type: "switchTab", tab: "settings" })
85150
setOpen(false)
86-
}, [])
151+
}, [setOpen])
152+
153+
const registerItemRef = useCallback(
154+
(id: string) => (el: HTMLDivElement | null) => {
155+
itemRefs.current[id] = el
156+
},
157+
[],
158+
)
87159

88160
const renderConfigItem = useCallback(
89-
(config: { id: string; name: string; modelId?: string }, isPinned: boolean) => {
161+
(config: { id: string; name: string; modelId?: string }, isPinned: boolean, itemIndex: number) => {
90162
const isCurrentConfig = config.id === value
163+
const isActive = itemIndex === activeIndex
91164

92165
return (
93166
<div
94167
key={config.id}
95168
onClick={() => handleSelect(config.id)}
169+
onMouseEnter={() => setActiveIndex(itemIndex)}
96170
className={cn(
97171
"px-3 py-1.5 text-sm cursor-pointer flex items-center group",
98-
"hover:bg-vscode-list-hoverBackground",
172+
isActive && !isCurrentConfig && "bg-vscode-list-hoverBackground",
99173
isCurrentConfig &&
100174
"bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground",
101-
)}>
102-
<div className="flex-1 min-w-0 flex items-center gap-1 overflow-hidden">
175+
)}
176+
data-active={isActive ? "true" : undefined}>
177+
<div
178+
ref={registerItemRef(config.id)}
179+
className="flex-1 min-w-0 flex items-center gap-1 overflow-hidden"
180+
role="option"
181+
aria-selected={isCurrentConfig}>
103182
<span className="flex-shrink-0">{config.name}</span>
104183
{config.modelId && (
105184
<>
@@ -138,11 +217,61 @@ export const ApiConfigSelector = ({
138217
</div>
139218
)
140219
},
141-
[value, handleSelect, t, togglePinnedApiConfig],
220+
[value, handleSelect, t, togglePinnedApiConfig, activeIndex, registerItemRef],
221+
)
222+
223+
const handleKeyDown = useCallback(
224+
(event: React.KeyboardEvent) => {
225+
if (!currentOpen) return
226+
227+
if (event.key === "Escape") {
228+
event.preventDefault()
229+
setOpen(false)
230+
return
231+
}
232+
233+
if (!visibleConfigs.length) {
234+
return
235+
}
236+
237+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
238+
event.preventDefault()
239+
setActiveIndex((prevIndex) => {
240+
if (event.key === "ArrowDown") {
241+
return (prevIndex + 1) % visibleConfigs.length
242+
}
243+
return (prevIndex - 1 + visibleConfigs.length) % visibleConfigs.length
244+
})
245+
return
246+
}
247+
248+
if (event.key === "Enter") {
249+
event.preventDefault()
250+
const activeConfig = visibleConfigs[activeIndex]
251+
if (activeConfig) {
252+
handleSelect(activeConfig.id)
253+
}
254+
}
255+
},
256+
[activeIndex, currentOpen, handleSelect, setOpen, visibleConfigs],
142257
)
143258

259+
useEffect(() => {
260+
if (!currentOpen) return
261+
if (listApiConfigMeta.length <= 6) {
262+
contentRef.current?.focus()
263+
}
264+
}, [currentOpen, listApiConfigMeta.length])
265+
266+
const handleSearchChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
267+
setSearchValue(event.target.value)
268+
setActiveIndex(0)
269+
}, [])
270+
271+
let displayIndex = 0
272+
144273
return (
145-
<Popover open={open} onOpenChange={setOpen} data-testid="api-config-selector-root">
274+
<Popover open={currentOpen} onOpenChange={setOpen} data-testid="api-config-selector-root">
146275
<StandardTooltip content={title}>
147276
<PopoverTrigger
148277
disabled={disabled}
@@ -163,16 +292,17 @@ export const ApiConfigSelector = ({
163292
align="start"
164293
sideOffset={4}
165294
container={portalContainer}
295+
tabIndex={-1}
166296
className="p-0 overflow-hidden w-[300px]">
167-
<div className="flex flex-col w-full">
297+
<div ref={contentRef} className="flex flex-col w-full" tabIndex={-1} onKeyDown={handleKeyDown}>
168298
{/* Search input or info blurb */}
169299
{listApiConfigMeta.length > 6 ? (
170300
<div className="relative p-2 border-b border-vscode-dropdown-border">
171301
<input
172-
aria-label={t("common:ui.search_placeholder")}
302+
aria-label={t("chat:apiConfigSelector.searchPlaceholder")}
173303
value={searchValue}
174-
onChange={(e) => setSearchValue(e.target.value)}
175-
placeholder={t("common:ui.search_placeholder")}
304+
onChange={handleSearchChange}
305+
placeholder={t("chat:apiConfigSelector.searchPlaceholder")}
176306
className="w-full h-8 px-2 py-1 text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded focus:outline-0"
177307
autoFocus
178308
/>
@@ -202,15 +332,15 @@ export const ApiConfigSelector = ({
202332
) : (
203333
<div className="py-1">
204334
{/* Pinned configs */}
205-
{pinnedConfigs.map((config) => renderConfigItem(config, true))}
335+
{pinnedConfigs.map((config) => renderConfigItem(config, true, displayIndex++))}
206336

207337
{/* Separator between pinned and unpinned */}
208338
{pinnedConfigs.length > 0 && unpinnedConfigs.length > 0 && (
209339
<div className="mx-1 my-1 h-px bg-vscode-dropdown-foreground/10" />
210340
)}
211341

212342
{/* Unpinned configs */}
213-
{unpinnedConfigs.map((config) => renderConfigItem(config, false))}
343+
{unpinnedConfigs.map((config) => renderConfigItem(config, false, displayIndex++))}
214344
</div>
215345
)}
216346
</div>
@@ -220,9 +350,8 @@ export const ApiConfigSelector = ({
220350
<div className="flex flex-row gap-1">
221351
<IconButton
222352
iconClass="codicon-settings-gear"
223-
title={t("chat:edit")}
353+
title={t("chat:apiConfigSelector.settings")}
224354
onClick={handleEditClick}
225-
tooltip={false}
226355
/>
227356
</div>
228357

0 commit comments

Comments
 (0)