Skip to content

Commit 00901e0

Browse files
authored
Change Ollama model selector to filterable dropdown (RooCodeInc#3999)
* use dropdown for Ollama model list when possible Code changes by Qwen3 30B A3B, based on OpenRouterModelPicker * Document libasound2 and libnss3 test dependencies, sort list * add test for OllamaModelPicker Code by Claude Sonnet 3.7 * Add changeset
1 parent 4a3f5c4 commit 00901e0

File tree

6 files changed

+530
-42
lines changed

6 files changed

+530
-42
lines changed

.changeset/cool-comics-change.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"claude-dev": minor
3+
---
4+
5+
Ollama: Use a filterable dropdown instead of radio selection

CONTRIBUTING.md

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,18 +34,20 @@ If you're planning to work on a bigger feature, please create a [feature request
3434
3. **Linux-specific Setup**
3535
VS Code extension tests on Linux require the following system libraries:
3636

37-
- `libatk1.0-0`
37+
- `dbus`
38+
- `libasound2`
3839
- `libatk-bridge2.0-0`
39-
- `libxkbfile1`
40+
- `libatk1.0-0`
41+
- `libdrm2`
42+
- `libgbm1`
43+
- `libgtk-3-0`
44+
- `libnss3`
4045
- `libx11-xcb1`
4146
- `libxcomposite1`
4247
- `libxdamage1`
4348
- `libxfixes3`
49+
- `libxkbfile1`
4450
- `libxrandr2`
45-
- `libgbm1`
46-
- `libdrm2`
47-
- `libgtk-3-0`
48-
- `dbus`
4951
- `xvfb`
5052

5153
These libraries provide necessary GUI components and system services for the test environment.
@@ -54,9 +56,21 @@ If you're planning to work on a bigger feature, please create a [feature request
5456
```bash
5557
sudo apt update
5658
sudo apt install -y \
57-
libatk1.0-0 libatk-bridge2.0-0 libxkbfile1 libx11-xcb1 \
58-
libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 \
59-
libdrm2 libgtk-3-0 dbus xvfb
59+
dbus \
60+
libasound2 \
61+
libatk-bridge2.0-0 \
62+
libatk1.0-0 \
63+
libdrm2 \
64+
libgbm1 \
65+
libgtk-3-0 \
66+
libnss3 \
67+
libx11-xcb1 \
68+
libxcomposite1 \
69+
libxdamage1 \
70+
libxfixes3 \
71+
libxkbfile1 \
72+
libxrandr2 \
73+
xvfb
6074
```
6175

6276
- Run `npm run test:ci` to run tests locally

src/core/controller/models/getOllamaModels.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export async function getOllamaModels(controller: Controller, request: StringReq
1818

1919
const response = await axios.get(`${baseUrl}/api/tags`)
2020
const modelsArray = response.data?.models?.map((model: any) => model.name) || []
21-
const models = [...new Set<string>(modelsArray)]
21+
const models = [...new Set<string>(modelsArray)].sort()
2222

2323
return StringArray.create({ values: models })
2424
} catch (error) {

webview-ui/src/components/settings/ApiOptions.tsx

Lines changed: 34 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import styled from "styled-components"
6565
import * as vscodemodels from "vscode"
6666
import { useOpenRouterKeyInfo } from "../ui/hooks/useOpenRouterKeyInfo"
6767
import { ClineAccountInfoCard } from "./ClineAccountInfoCard"
68+
import OllamaModelPicker from "./OllamaModelPicker"
6869
import OpenRouterModelPicker, { ModelDescriptionMarkdown, OPENROUTER_MODEL_PICKER_Z_INDEX } from "./OpenRouterModelPicker"
6970
import RequestyModelPicker from "./RequestyModelPicker"
7071
import ThinkingBudgetSlider from "./ThinkingBudgetSlider"
@@ -1845,56 +1846,57 @@ const ApiOptions = ({
18451846
placeholder={"Default: http://localhost:11434"}>
18461847
<span style={{ fontWeight: 500 }}>Base URL (optional)</span>
18471848
</VSCodeTextField>
1848-
<VSCodeTextField
1849-
value={apiConfiguration?.ollamaModelId || ""}
1850-
style={{ width: "100%" }}
1851-
onInput={handleInputChange("ollamaModelId")}
1852-
placeholder={"e.g. llama3.1"}>
1853-
<span style={{ fontWeight: 500 }}>Model ID</span>
1854-
</VSCodeTextField>
1849+
1850+
{/* Model selection - use filterable picker */}
1851+
<label htmlFor="ollama-model-selection">
1852+
<span style={{ fontWeight: 500 }}>Model</span>
1853+
</label>
1854+
<OllamaModelPicker
1855+
ollamaModels={ollamaModels}
1856+
selectedModelId={apiConfiguration?.ollamaModelId || ""}
1857+
onModelChange={(modelId) => {
1858+
setApiConfiguration({
1859+
...apiConfiguration,
1860+
ollamaModelId: modelId,
1861+
})
1862+
}}
1863+
placeholder={ollamaModels.length > 0 ? "Search and select a model..." : "e.g. llama3.1"}
1864+
/>
1865+
1866+
{/* Show status message based on model availability */}
1867+
{ollamaModels.length === 0 && (
1868+
<p
1869+
style={{
1870+
fontSize: "12px",
1871+
marginTop: "3px",
1872+
color: "var(--vscode-descriptionForeground)",
1873+
fontStyle: "italic",
1874+
}}>
1875+
Unable to fetch models from Ollama server. Please ensure Ollama is running and accessible, or enter
1876+
the model ID manually above.
1877+
</p>
1878+
)}
1879+
18551880
<VSCodeTextField
18561881
value={apiConfiguration?.ollamaApiOptionsCtxNum || "32768"}
18571882
style={{ width: "100%" }}
18581883
onInput={handleInputChange("ollamaApiOptionsCtxNum")}
18591884
placeholder={"e.g. 32768"}>
18601885
<span style={{ fontWeight: 500 }}>Model Context Window</span>
18611886
</VSCodeTextField>
1862-
{ollamaModels.length > 0 && (
1863-
<VSCodeRadioGroup
1864-
value={
1865-
ollamaModels.includes(apiConfiguration?.ollamaModelId || "")
1866-
? apiConfiguration?.ollamaModelId
1867-
: ""
1868-
}
1869-
onChange={(e) => {
1870-
const value = (e.target as HTMLInputElement)?.value
1871-
// need to check value first since radio group returns empty string sometimes
1872-
if (value) {
1873-
handleInputChange("ollamaModelId")({
1874-
target: { value },
1875-
})
1876-
}
1877-
}}>
1878-
{ollamaModels.map((model) => (
1879-
<VSCodeRadio key={model} value={model} checked={apiConfiguration?.ollamaModelId === model}>
1880-
{model}
1881-
</VSCodeRadio>
1882-
))}
1883-
</VSCodeRadioGroup>
1884-
)}
18851887
<p
18861888
style={{
18871889
fontSize: "12px",
18881890
marginTop: "5px",
18891891
color: "var(--vscode-descriptionForeground)",
18901892
}}>
18911893
Ollama allows you to run models locally on your computer. For instructions on how to get started, see
1892-
their
1894+
their{" "}
18931895
<VSCodeLink
18941896
href="https://github.com/ollama/ollama/blob/main/README.md"
18951897
style={{ display: "inline", fontSize: "inherit" }}>
18961898
quickstart guide.
1897-
</VSCodeLink>
1899+
</VSCodeLink>{" "}
18981900
<span style={{ color: "var(--vscode-errorForeground)" }}>
18991901
(<span style={{ fontWeight: 500 }}>Note:</span> Cline uses complex prompts and works best with Claude
19001902
models. Less capable models may not work as expected.)
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import { useExtensionState } from "@/context/ExtensionStateContext"
2+
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
3+
import Fuse from "fuse.js"
4+
import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
5+
import styled from "styled-components"
6+
import { highlight } from "../history/HistoryView"
7+
8+
export const OLLAMA_MODEL_PICKER_Z_INDEX = 1_000
9+
10+
export interface OllamaModelPickerProps {
11+
ollamaModels: string[]
12+
selectedModelId: string
13+
onModelChange: (modelId: string) => void
14+
placeholder?: string
15+
}
16+
17+
const OllamaModelPicker: React.FC<OllamaModelPickerProps> = ({
18+
ollamaModels,
19+
selectedModelId,
20+
onModelChange,
21+
placeholder = "Search and select a model...",
22+
}) => {
23+
const [searchTerm, setSearchTerm] = useState(selectedModelId || "")
24+
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
25+
const [selectedIndex, setSelectedIndex] = useState(-1)
26+
const dropdownRef = useRef<HTMLDivElement>(null)
27+
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
28+
const dropdownListRef = useRef<HTMLDivElement>(null)
29+
30+
const handleModelChange = (newModelId: string) => {
31+
onModelChange(newModelId)
32+
setSearchTerm(newModelId)
33+
}
34+
35+
useEffect(() => {
36+
const handleClickOutside = (event: MouseEvent) => {
37+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
38+
setIsDropdownVisible(false)
39+
}
40+
}
41+
42+
document.addEventListener("mousedown", handleClickOutside)
43+
return () => {
44+
document.removeEventListener("mousedown", handleClickOutside)
45+
}
46+
}, [])
47+
48+
const searchableItems = useMemo(() => {
49+
return ollamaModels.map((id) => ({
50+
id,
51+
html: id,
52+
}))
53+
}, [ollamaModels])
54+
55+
const fuse = useMemo(() => {
56+
return new Fuse(searchableItems, {
57+
keys: ["html"],
58+
threshold: 0.6,
59+
shouldSort: true,
60+
isCaseSensitive: false,
61+
ignoreLocation: false,
62+
includeMatches: true,
63+
minMatchCharLength: 1,
64+
})
65+
}, [searchableItems])
66+
67+
const modelSearchResults = useMemo(() => {
68+
return searchTerm ? highlight(fuse.search(searchTerm), "ollama-model-item-highlight") : searchableItems
69+
}, [searchableItems, searchTerm, fuse])
70+
71+
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
72+
if (!isDropdownVisible) return
73+
74+
switch (event.key) {
75+
case "ArrowDown":
76+
event.preventDefault()
77+
setSelectedIndex((prev) => (prev < modelSearchResults.length - 1 ? prev + 1 : prev))
78+
break
79+
case "ArrowUp":
80+
event.preventDefault()
81+
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev))
82+
break
83+
case "Enter":
84+
event.preventDefault()
85+
if (selectedIndex >= 0 && selectedIndex < modelSearchResults.length) {
86+
handleModelChange(modelSearchResults[selectedIndex].id)
87+
setIsDropdownVisible(false)
88+
}
89+
break
90+
case "Escape":
91+
setIsDropdownVisible(false)
92+
setSelectedIndex(-1)
93+
break
94+
}
95+
}
96+
97+
useEffect(() => {
98+
setSelectedIndex(-1)
99+
if (dropdownListRef.current) {
100+
dropdownListRef.current.scrollTop = 0
101+
}
102+
}, [searchTerm])
103+
104+
useEffect(() => {
105+
if (selectedIndex >= 0 && itemRefs.current[selectedIndex]) {
106+
itemRefs.current[selectedIndex]?.scrollIntoView({
107+
block: "nearest",
108+
behavior: "smooth",
109+
})
110+
}
111+
}, [selectedIndex])
112+
113+
// Update search term when selectedModelId changes externally
114+
useEffect(() => {
115+
if (selectedModelId !== searchTerm) {
116+
setSearchTerm(selectedModelId || "")
117+
}
118+
}, [selectedModelId])
119+
120+
return (
121+
<div style={{ width: "100%" }}>
122+
<style>
123+
{`
124+
.ollama-model-item-highlight {
125+
background-color: var(--vscode-editor-findMatchHighlightBackground);
126+
color: inherit;
127+
}
128+
`}
129+
</style>
130+
<DropdownWrapper ref={dropdownRef}>
131+
<VSCodeTextField
132+
id="ollama-model-search"
133+
placeholder={placeholder}
134+
value={searchTerm}
135+
onInput={(e) => {
136+
const value = (e.target as HTMLInputElement)?.value || ""
137+
handleModelChange(value)
138+
setIsDropdownVisible(true)
139+
}}
140+
onFocus={() => setIsDropdownVisible(true)}
141+
onKeyDown={handleKeyDown}
142+
style={{
143+
width: "100%",
144+
zIndex: OLLAMA_MODEL_PICKER_Z_INDEX,
145+
position: "relative",
146+
}}>
147+
{searchTerm && (
148+
<div
149+
className="input-icon-button codicon codicon-close"
150+
aria-label="Clear search"
151+
onClick={() => {
152+
handleModelChange("")
153+
setIsDropdownVisible(true)
154+
}}
155+
slot="end"
156+
style={{
157+
display: "flex",
158+
justifyContent: "center",
159+
alignItems: "center",
160+
height: "100%",
161+
}}
162+
/>
163+
)}
164+
</VSCodeTextField>
165+
{isDropdownVisible && modelSearchResults.length > 0 && (
166+
<DropdownList ref={dropdownListRef}>
167+
{modelSearchResults.map((item, index) => (
168+
<DropdownItem
169+
key={item.id}
170+
ref={(el) => (itemRefs.current[index] = el)}
171+
isSelected={index === selectedIndex}
172+
onMouseEnter={() => setSelectedIndex(index)}
173+
onClick={() => {
174+
handleModelChange(item.id)
175+
setIsDropdownVisible(false)
176+
}}>
177+
<span dangerouslySetInnerHTML={{ __html: item.html }} />
178+
</DropdownItem>
179+
))}
180+
</DropdownList>
181+
)}
182+
</DropdownWrapper>
183+
</div>
184+
)
185+
}
186+
187+
export default memo(OllamaModelPicker)
188+
189+
// Dropdown styling
190+
191+
const DropdownWrapper = styled.div`
192+
position: relative;
193+
width: 100%;
194+
`
195+
196+
const DropdownList = styled.div`
197+
position: absolute;
198+
top: calc(100% - 3px);
199+
left: 0;
200+
width: calc(100% - 2px);
201+
max-height: 200px;
202+
overflow-y: auto;
203+
background-color: var(--vscode-dropdown-background);
204+
border: 1px solid var(--vscode-list-activeSelectionBackground);
205+
z-index: ${OLLAMA_MODEL_PICKER_Z_INDEX - 1};
206+
border-bottom-left-radius: 3px;
207+
border-bottom-right-radius: 3px;
208+
`
209+
210+
const DropdownItem = styled.div<{ isSelected: boolean }>`
211+
padding: 5px 10px;
212+
cursor: pointer;
213+
word-break: break-all;
214+
white-space: normal;
215+
216+
background-color: ${({ isSelected }) => (isSelected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")};
217+
218+
&:hover {
219+
background-color: var(--vscode-list-activeSelectionBackground);
220+
}
221+
`

0 commit comments

Comments
 (0)