feat: Ollama models command, with load/pull/delete and unload (#16)

This commit was merged in pull request #16.
This commit is contained in:
2026-10-05 09:43:00 +00:00
parent 05fd9ff13b
commit 26e05dea5b
22 changed files with 1948 additions and 65 deletions
+438
View File
@@ -0,0 +1,438 @@
import {
Action,
ActionPanel,
Alert,
Form,
Icon,
List,
Toast,
confirmAlert,
openExtensionPreferences,
showToast,
} from "@vicinae/api";
import { useCallback, useEffect, useState } from "react";
import { usePreferences } from "./lib/hooks/usePreferences";
import {
deleteModel,
describePullProgress,
isSelfHosted,
listModels,
listRunningModels,
pullModel,
unloadModel,
type RunningModel,
} from "./lib/ollama";
import { clearModelSelection, selectModel, type ResolvedPreferences } from "./lib/preferences";
import type { ModelInfo } from "./lib/types";
import { errorMessage, formatBytes } from "./lib/utils";
/**
* Browse, pick, and manage the models the configured Ollama host can reach.
*
* Four jobs in one command, because they are four answers to the same question
* ("what models do I have?"): pick one, unload one, delete one, pull a new one.
* Unloading is the odd one out and the reason a row needs two states rather than
* one: a model can be installed without being loaded, and only the loaded ones
* are holding memory. Delete and pull only appear on a host the user runs, since
* there is nothing to pull onto Ollama Cloud and nothing to delete from it.
*
* ## Where a pick is stored
*
* Not in the `Model` preference, because an extension cannot write its own
* preferences. Vicinae reads them once at startup and holds them in memory, so
* even writing its config file directly does nothing until it restarts, which was
* measured rather than assumed (`providers["@adrianbonpin/lam-agent"].preferences`
* in `~/.config/vicinae/settings.json`, edited while a command was then launched:
* the requests still went to the old host).
*
* So a pick goes to LocalStorage and `loadPreferences` layers it over the
* preference. The cost of that arrangement is one thing this screen has to say
* out loud, because it is invisible otherwise: while a pick is active, editing
* the `Model` preference has no effect until the pick is reset. Which is why the
* setup screen shows both values and offers the reset.
*
* ## Why the pull keeps the window open
*
* The download runs inside this command's worker, so closing the launcher cancels
* it. That is a real constraint and the command description says so.
*/
/** Which surface the command is showing. The form is a view rather than a dialog, which Vicinae has no equivalent of. */
type View = "list" | "pull";
export default function ModelsCommand() {
const { preferences, reload } = usePreferences();
// Nothing renders until the model in use is known, because the whole list is
// marked up relative to it: which row is current, and whether picking one
// would change anything.
if (!preferences) {
return <List isLoading navigationTitle="Ollama Models" searchBarPlaceholder="Loading models…" />;
}
return <ModelsScreen preferences={preferences} reloadPreferences={reload} />;
}
interface ModelsScreenProps {
preferences: ResolvedPreferences;
/** Re-reads preferences after this screen changes the model selection. */
reloadPreferences: () => void;
}
function ModelsScreen({ preferences, reloadPreferences }: ModelsScreenProps) {
const [models, setModels] = useState<ModelInfo[] | undefined>(undefined);
/** By name, so a row can ask whether it is the one holding memory. */
const [running, setRunning] = useState<Map<string, RunningModel>>(new Map());
const [error, setError] = useState<string | undefined>(undefined);
const [isLoading, setIsLoading] = useState(true);
const [view, setView] = useState<View>("list");
/** Whether this host's models can be managed at all. */
const selfHosted = isSelfHosted(preferences.host);
const load = useCallback(async () => {
setIsLoading(true);
try {
// Both at once: what is installed and what is loaded are different
// questions, and the second must not hold up the first.
const [installed, loaded] = await Promise.all([
listModels({ host: preferences.host, apiKey: preferences.apiKey }),
listRunningModels({ host: preferences.host, apiKey: preferences.apiKey }),
]);
setModels(installed);
setRunning(new Map(loaded.map((model) => [model.name, model])));
setError(undefined);
} catch (thrown) {
setModels([]);
setRunning(new Map());
setError(errorMessage(thrown));
} finally {
setIsLoading(false);
}
}, [preferences.host, preferences.apiKey]);
useEffect(() => {
void load();
}, [load]);
/**
* Releases a model from memory.
*
* No confirmation, unlike deleting: nothing is removed from disk, and the
* worst case is that the next question pays to load it again. The toast reports
* what was freed, because that is the whole reason to do this.
*/
const unload = useCallback(
async (model: ModelInfo, loaded: RunningModel) => {
try {
await unloadModel({ host: preferences.host, apiKey: preferences.apiKey, model: model.name });
} catch (thrown) {
await showToast({
style: Toast.Style.Failure,
title: `Could not unload ${model.name}`,
message: errorMessage(thrown),
});
return;
}
const freed = formatBytes(loaded.sizeVram);
await showToast({
style: Toast.Style.Success,
title: `Unloaded ${model.name}`,
message: freed ? `${freed} of memory freed` : undefined,
});
void load();
},
[load, preferences.host, preferences.apiKey],
);
const choose = useCallback(
async (name: string) => {
if (name === preferences.model) {
await showToast({ style: Toast.Style.Success, title: `${name} is already in use` });
return;
}
await selectModel(name);
reloadPreferences();
await showToast({
style: Toast.Style.Success,
title: `Lam will use ${name}`,
// Said here because it is the one surprising part of this screen: the
// model is chosen in this command, not in Vicinae's settings.
message: "Chosen here. The Model preference is left alone.",
});
},
[preferences.model, reloadPreferences],
);
const remove = useCallback(
async (model: ModelInfo) => {
const confirmed = await confirmAlert({
title: `Delete ${model.name}?`,
message: model.remoteHost
? `This removes the local reference. The model itself lives at ${model.remoteHost} and is not deleted there.`
: "The weights are removed from this machine, and pulling it again means downloading all of it.",
primaryAction: { title: "Delete", style: Alert.ActionStyle.Destructive },
});
if (!confirmed) return;
try {
await deleteModel({ host: preferences.host, apiKey: preferences.apiKey, model: model.name });
} catch (thrown) {
await showToast({
style: Toast.Style.Failure,
title: `Could not delete ${model.name}`,
message: errorMessage(thrown),
});
return;
}
// The model that just went may be the one in use, in which case the next
// question asked would fail with a 404. A selection can be cleared here;
// a preference cannot, so that case is reported instead of silently
// leaving a dangling name behind.
const wasInUse = model.name === preferences.model;
const selected = preferences.modelSource === "selection";
if (wasInUse && selected) {
await clearModelSelection();
reloadPreferences();
}
await showToast({
style: Toast.Style.Success,
title: `Deleted ${model.name}`,
message: wasInUse
? selected
? "It was the model in use, so that choice has been cleared."
: "It is still what the Model preference points at. Change it in preferences."
: undefined,
});
void load();
},
[load, preferences.host, preferences.apiKey, preferences.model, preferences.modelSource, reloadPreferences],
);
if (view === "pull") {
return (
<PullModelForm
host={preferences.host}
apiKey={preferences.apiKey}
onDone={() => {
setView("list");
void load();
}}
onCancel={() => setView("list")}
/>
);
}
const pullAction = selfHosted ? (
<Action title="Pull a Model…" icon={Icon.Download} onAction={() => setView("pull")} />
) : null;
const listActions = (
<ActionPanel>
<Action title="Refresh" icon={Icon.ArrowClockwise} onAction={() => void load()} />
{pullAction ? <ActionPanel.Section>{pullAction}</ActionPanel.Section> : null}
<ActionPanel.Section>
<Action
title="Open Extension Preferences"
icon={Icon.BulletPoints}
onAction={() => void openExtensionPreferences()}
/>
</ActionPanel.Section>
</ActionPanel>
);
const loaded = models ?? [];
return (
<List
isLoading={isLoading}
navigationTitle="Ollama Models"
searchBarPlaceholder="Search models…"
actions={listActions}
>
{error ? (
<List.EmptyView
icon={Icon.QuestionMarkCircle}
title="Could not reach Ollama"
description={error}
actions={
<ActionPanel>
<Action title="Try Again" icon={Icon.ArrowClockwise} onAction={() => void load()} />
<Action
title="Open Extension Preferences"
icon={Icon.BulletPoints}
onAction={() => void openExtensionPreferences()}
/>
</ActionPanel>
}
/>
) : models !== undefined && loaded.length === 0 ? (
<List.EmptyView
icon={Icon.Box}
title={selfHosted ? "No models on this server" : "No models in this account"}
description={
selfHosted
? "Pull one from the action panel, or with `ollama pull` in a terminal."
: "This host has nothing installed, and models are managed at ollama.com."
}
actions={listActions}
/>
) : null}
{loaded.map((model) => {
const isCurrent = model.name === preferences.model;
const size = model.remoteHost ? undefined : formatBytes(model.size);
const inMemory = running.get(model.name);
return (
<List.Item
key={model.name}
id={model.name}
icon={isCurrent ? Icon.CheckCircle : Icon.Box}
title={model.name}
subtitle={[model.parameterSize, size].filter(Boolean).join(" · ") || undefined}
accessories={[
...(inMemory ? [{ tag: { value: "loaded", color: "green" as const } }] : []),
...(isCurrent ? [{ tag: { value: "current", color: "blue" as const } }] : []),
...(model.capabilities?.includes("vision") ? [{ tag: "vision" }] : []),
{ tag: model.remoteHost ? "cloud" : selfHosted ? "local" : "cloud" },
]}
actions={
<ActionPanel>
<Action
title={isCurrent ? "Current Model" : "Use This Model"}
icon={isCurrent ? Icon.CheckCircle : Icon.Stars}
onAction={() => void choose(model.name)}
/>
{inMemory ? (
<Action
title="Unload Model"
icon={Icon.Eject}
onAction={() => void unload(model, inMemory)}
/>
) : null}
<Action.CopyToClipboard title="Copy Model Name" content={model.name} />
{selfHosted ? (
<Action
title="Delete Model"
icon={Icon.Trash}
style="destructive"
onAction={() => void remove(model)}
/>
) : null}
<ActionPanel.Section>
<Action title="Refresh" icon={Icon.ArrowClockwise} onAction={() => void load()} />
{pullAction}
<Action
title="Open Extension Preferences"
icon={Icon.BulletPoints}
onAction={() => void openExtensionPreferences()}
/>
</ActionPanel.Section>
</ActionPanel>
}
/>
);
})}
</List>
);
}
interface PullModelFormProps {
host: string;
apiKey: string;
onDone: () => void;
onCancel: () => void;
}
/**
* Pull a model by name.
*
* A `Form` rather than a `List`, because the model being pulled is usually not
* one of the models already installed, which is the whole reason for pulling it.
* A picker over the library would be nicer and needs an endpoint this API does
* not expose, so the name is typed and the description carries a few examples.
*/
function PullModelForm({ host, apiKey, onDone, onCancel }: PullModelFormProps) {
const [isPulling, setIsPulling] = useState(false);
const pull = useCallback(
async (values: Form.Values) => {
const name = String(values.model ?? "").trim();
if (!name) {
await showToast({ style: Toast.Style.Failure, title: "Give the model a name" });
return;
}
setIsPulling(true);
const toast = await showToast({ style: Toast.Style.Animated, title: `Pulling ${name}` });
try {
let last = "";
for await (const progress of pullModel({ host, apiKey, model: name })) {
// Ollama sends a tick per chunk, which for a large model is far more
// often than the toast needs updating. Only write when the line
// actually changed, so the percentage is what limits the traffic.
const line = describePullProgress(progress);
if (line !== last) {
last = line;
toast.message = line;
}
}
toast.style = Toast.Style.Success;
toast.title = `Pulled ${name}`;
toast.message = "It is in the list now";
onDone();
} catch (thrown) {
toast.style = Toast.Style.Failure;
toast.title = `Could not pull ${name}`;
toast.message = errorMessage(thrown);
} finally {
setIsPulling(false);
}
},
[apiKey, host, onDone],
);
return (
<Form
isLoading={isPulling}
navigationTitle="Pull a Model"
actions={
<ActionPanel>
<Action.SubmitForm title="Pull Model" icon={Icon.Download} onSubmit={pull} />
<Action title="Cancel" icon={Icon.Xmark} onAction={onCancel} />
</ActionPanel>
}
>
<Form.TextField
id="model"
title="Model"
placeholder="qwen3:8b"
autoFocus
info="The name as it appears in the Ollama library, including the tag. Leave the tag off to get the default one."
/>
<Form.Description
title="Examples"
text={["qwen3:8b", "llama3.2:3b", "gemma3:12b", "deepseek-r1:14b"].join("\n")}
/>
<Form.Description
title="While it pulls"
text="Leave the launcher open. The download runs in this window, so closing it cancels the pull."
/>
</Form>
);
}