feat(coding-agent): add Hugging Face llama search

This commit is contained in:
Mario Zechner
2026-07-17 17:13:39 +02:00
parent f1a466b19d
commit 3da591ab74
6 changed files with 458 additions and 25 deletions
+216 -17
View File
@@ -1,6 +1,8 @@
import {
type Component,
Container,
type Focusable,
fuzzyFilter,
Input,
type SelectItem,
SelectList,
@@ -16,6 +18,7 @@ import { DynamicBorder } from "../../modes/interactive/components/dynamic-border
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import type { Theme } from "../../modes/interactive/theme/theme.ts";
import type { LlamaModelInfo, LlamaProgress } from "./client.ts";
import type { HuggingFaceModel } from "./huggingface.ts";
const DOWNLOAD_VALUE = "\0download";
@@ -58,12 +61,7 @@ function selectTheme(theme: Theme) {
};
}
function frame(
theme: Theme,
title: string,
body: Array<Text | Spacer | SelectList | Input>,
footer?: string,
): Container {
function frame(theme: Theme, title: string, body: Component[], footer?: string): Container {
const container = new Container();
container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
@@ -81,15 +79,205 @@ export interface LlamaUi {
select(title: string, options: string[]): Promise<string | undefined>;
confirm(title: string, message: string): Promise<boolean>;
connectionError(serverUrl: string, message: string): Promise<"retry" | "close">;
input(title: string, placeholder: string): Promise<string | undefined>;
searchModels(
search: (query: string, signal: AbortSignal) => Promise<HuggingFaceModel[]>,
): Promise<string | undefined>;
showStatus(title: string, message: string): void;
progress(state: ProgressState): Promise<void>;
updateProgress(state: ProgressState): void;
}
function compactCount(value: number): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(value >= 100_000 ? 0 : 1)}k`;
return String(value);
}
class HuggingFaceSearch extends Container implements Focusable {
private readonly tui: TUI;
private readonly theme: Theme;
private readonly keybindings: KeybindingsManager;
private readonly search: (query: string, signal: AbortSignal) => Promise<HuggingFaceModel[]>;
private readonly cache: Map<string, HuggingFaceModel[]>;
private readonly onSelectModel: (model: string | undefined) => void;
private readonly input = new Input();
private readonly resultsContainer = new Container();
private results: HuggingFaceModel[] = [];
private filteredResults: HuggingFaceModel[] = [];
private selectedIndex = 0;
private query = "";
private status = "Type at least 2 characters";
private debounce: ReturnType<typeof setTimeout> | undefined;
private request: AbortController | undefined;
private closed = false;
private _focused = false;
constructor(
tui: TUI,
theme: Theme,
keybindings: KeybindingsManager,
search: (query: string, signal: AbortSignal) => Promise<HuggingFaceModel[]>,
cache: Map<string, HuggingFaceModel[]>,
onSelectModel: (model: string | undefined) => void,
) {
super();
this.tui = tui;
this.theme = theme;
this.keybindings = keybindings;
this.search = search;
this.cache = cache;
this.onSelectModel = onSelectModel;
this.addChild(new Text(theme.fg("dim", "Model name or owner/repository[:quant]"), 1, 0));
this.addChild(this.input);
this.addChild(new Spacer(1));
this.addChild(this.resultsContainer);
this.updateResults();
}
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.input.focused = value;
}
private updateResults(): void {
this.resultsContainer.clear();
const maxVisible = 10;
const start = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredResults.length - maxVisible),
);
const end = Math.min(start + maxVisible, this.filteredResults.length);
for (let index = start; index < end; index++) {
const model = this.filteredResults[index];
if (!model) continue;
const prefix = index === this.selectedIndex ? "→ " : " ";
const details = `${compactCount(model.downloads)} downloads`;
this.resultsContainer.addChild(
new Text(
index === this.selectedIndex
? this.theme.fg("accent", `${prefix}${model.id} ${details}`)
: `${prefix}${model.id}${this.theme.fg("muted", ` ${details}`)}`,
0,
0,
),
);
}
if (start > 0 || end < this.filteredResults.length) {
this.resultsContainer.addChild(
new Text(this.theme.fg("dim", ` (${this.selectedIndex + 1}/${this.filteredResults.length})`), 0, 0),
);
}
if (this.filteredResults.length === 0) {
this.resultsContainer.addChild(new Text(this.theme.fg("dim", ` ${this.status}`), 0, 0));
} else if (this.status === "Searching Hugging Face…") {
this.resultsContainer.addChild(new Text(this.theme.fg("dim", ` ${this.status}`), 0, 0));
}
this.tui.requestRender();
}
private filterResults(): void {
if (this.query) {
const matches = new Set(fuzzyFilter(this.results, this.query, (model) => model.id).map((model) => model.id));
this.filteredResults = this.results.filter((model) => matches.has(model.id));
} else {
this.filteredResults = this.results;
}
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredResults.length - 1));
this.updateResults();
}
private scheduleSearch(): void {
if (this.debounce) clearTimeout(this.debounce);
this.request?.abort();
this.request = undefined;
if (this.query.length < 2) {
this.status = "Type at least 2 characters";
this.filterResults();
return;
}
const cached = this.cache.get(this.query.toLowerCase());
if (cached) {
this.results = cached;
this.status = cached.length === 0 ? "No GGUF models found" : "";
this.filterResults();
return;
}
this.status = "Searching Hugging Face…";
this.filterResults();
this.debounce = setTimeout(() => void this.runSearch(this.query), 500);
}
private async runSearch(query: string): Promise<void> {
const request = new AbortController();
this.request = request;
try {
const results = await this.search(query, request.signal);
this.cache.set(query.toLowerCase(), results);
if (this.closed || request.signal.aborted || this.query !== query) return;
this.results = results;
this.selectedIndex = 0;
this.status = results.length === 0 ? "No GGUF models found" : "";
this.filterResults();
} catch (error) {
if (this.closed || request.signal.aborted || this.query !== query) return;
this.results = [];
this.status = error instanceof Error ? error.message : String(error);
this.filterResults();
} finally {
if (this.request === request) this.request = undefined;
}
}
private close(model: string | undefined): void {
if (this.closed) return;
this.closed = true;
if (this.debounce) clearTimeout(this.debounce);
this.request?.abort();
this.onSelectModel(model);
}
handleInput(data: string): void {
if (this.keybindings.matches(data, "tui.select.up")) {
if (this.filteredResults.length > 0) {
this.selectedIndex = this.selectedIndex === 0 ? this.filteredResults.length - 1 : this.selectedIndex - 1;
this.updateResults();
}
return;
}
if (this.keybindings.matches(data, "tui.select.down")) {
if (this.filteredResults.length > 0) {
this.selectedIndex = this.selectedIndex === this.filteredResults.length - 1 ? 0 : this.selectedIndex + 1;
this.updateResults();
}
return;
}
if (this.keybindings.matches(data, "tui.select.confirm")) {
const exact = /^[^/\s]+\/[^:\s]+(?::[^\s:]+)?$/u.test(this.query) ? this.query : undefined;
const selected = exact ?? this.filteredResults[this.selectedIndex]?.id;
if (selected) this.close(selected);
return;
}
if (this.keybindings.matches(data, "tui.select.cancel")) {
this.close(undefined);
return;
}
this.input.handleInput(data);
const query = this.input.getValue().trim();
if (query === this.query) return;
this.query = query;
this.scheduleSearch();
}
}
class LlamaView implements LlamaUi, Focusable {
private readonly tui: TUI;
private readonly theme: Theme;
private readonly keybindings: KeybindingsManager;
private readonly searchCache = new Map<string, HuggingFaceModel[]>();
private content: Container;
private inputHandler: { handleInput?(data: string): void } | undefined;
private inputTarget: Focusable | undefined;
@@ -173,7 +361,7 @@ class LlamaView implements LlamaUi, Focusable {
return new Promise((resolve) => {
const list = new SelectList(
options.map((option) => ({ value: option, label: option })),
options.length,
Math.min(options.length, 12),
selectTheme(this.theme),
);
list.onSelect = (item) => resolve(item.value);
@@ -199,24 +387,35 @@ class LlamaView implements LlamaUi, Focusable {
return choice === "Retry" ? "retry" : "close";
}
input(title: string, placeholder: string): Promise<string | undefined> {
searchModels(
search: (query: string, signal: AbortSignal) => Promise<HuggingFaceModel[]>,
): Promise<string | undefined> {
return new Promise((resolve) => {
const input = new Input();
input.onSubmit = (value) => resolve(value);
input.onEscape = () => resolve(undefined);
const component = new HuggingFaceSearch(
this.tui,
this.theme,
this.keybindings,
search,
this.searchCache,
resolve,
);
this.setContent(
frame(
this.theme,
title,
[new Spacer(1), new Text(this.theme.fg("dim", placeholder), 1, 0), input],
`${keyHint("tui.input.submit", "submit")}${keyHint("tui.select.cancel", "cancel")}`,
"Download model",
[new Spacer(1), component],
`${keyHint("tui.select.confirm", "select")}${keyHint("tui.select.cancel", "back")}`,
),
input,
input,
component,
component,
);
});
}
showStatus(title: string, message: string): void {
this.setContent(frame(this.theme, title, [new Spacer(1), new Text(this.theme.fg("muted", message), 1, 0)]));
}
progress(state: ProgressState): Promise<void> {
if (!this.progressPromise) {
this.progressPromise = new Promise((resolve) => {