Repository navigation
Local model picker ergonomics #31
Description
Proposal
Why
Picking a local model is a four-step search when it should be one or two. The
reported workflow is: open /models, type a provider name (rocky, z4),
squint at interleaved results, then find the model. Four separate defects make
that necessary, and each one is small.
Read from packages/tui/src/component/dialog-model.tsx (the TUI picker — not
packages/app's dialog-select-model.tsx, which already groups by provider,
and not dialog-connect-provider, which is about connecting a provider, not
choosing a model).
1. A provider-name query does not mean "show me this provider". The query
runs through fuzzysort.go(needle, options, { keys: ["title", "category"] })
(dialog-model.tsx:174) where category is the provider. A match on the
provider name scores as one result among many, so typing rocky interleaves
rocky's models with any model whose own name fuzzy-matches those letters,
across every provider. The intent — "I have chosen the host, now show me what
it has" — is expressible and is not expressed.
2. Selecting a provider does not use its default model. llama-skein already
has this: Model.default is in the contract, documented as "True when this
model is the configured default … Listed first in the model list", with
GET/DELETE /api/models/default to read and clear it. The picker ignores the
flag entirely. An operator who has already told the host which model to prefer
still has to restate it on every selection.
3. Sort order is meaningless for local models. sortModelOptions
(dialog-model.tsx:286) sorts by releaseDate then title. Local GGUFs have
no release date, so this degenerates to alphabetical. The operator's actual
ranking criterion for a local host is size — the largest quantisation that fits
is the best one available — and that is now knowable: size_bytes is in the
contract and rides on each model.
4. The metadata line pairs the wrong things. Today the size is a footer
("19G") while the provider label is provenance = `${name} · ${label}`
(dialog-model.tsx:59-62), rendering as rocky · 20/24 GB somewhere else on
the row. So model size and host VRAM — the two numbers a reader compares to
answer will this fit — are separated, while VRAM is glued to a hostname it
has no arithmetic relationship with. 19G/24GB rocky puts the comparison in
one token and leaves the hostname as the label it is.
What Changes
- A provider-name match becomes a provider scope. When the query
unambiguously matches one provider, the picker lists that provider's models,
all of them, rather than ranking them against unrelated fuzzy hits. Typing
more narrows within the provider. - Selecting a provider selects its default model. Where a provider reports
a model withdefault: true, choosing the provider row resolves to that
model directly. Providers with no default keep today's behaviour and expand
to their model list. - Size-first ordering for local providers. Models from a provider that
reportssize_bytessort largest first. Alphabetical remains available and
remains the default for providers without size data — a cloud catalogue
sorted by weight file is nonsense. size/vram hoston one line. The size and the host's VRAM render
together (19G/24GB rocky), so the fit comparison is one glance. VRAM is
omitted rather than faked when the host does not report it.
Capabilities
Modified Capabilities
local-model-picker: provider-scoped querying, default-model resolution,
size ordering, and the combined size/VRAM label.
Non-Goals
- Not a change to
packages/app'sdialog-select-model.tsx. It already
groups by provider; whether it needs the same treatment is a separate
question and a separate reading of its behaviour. - Not fit enforcement. The picker reports size against VRAM; refusing an
oversized selection is llama-skein's/api/fitguard and
flag-under-offloaded-models, not this. - Not a change to llama-skein. Every field this needs —
size_bytes,
default— is already in the contract and already generated. - Not recency ordering for cloud providers.
sortModelOptions'
releaseDatebehaviour is correct where a release date exists and is
untouched.
Open Questions
- What counts as an unambiguous provider match? Exact, case-insensitive
equality on the provider name is the safe bar. Prefix matching is friendlier
(roc→ rocky) but two providers sharing a prefix would silently scope to
one. Leaning exact-or-unique-prefix, with the scope shown so it is never a
silent filter. - Should provider scope be escapable? If typing
rockyscopes to rocky, a
user wanting a model literally named "rocky" on another host has no way
through. An explicit escape, or scoping only when the match is also not a
model-name match, needs deciding. - Does default-model selection need confirmation? Resolving a provider row
straight to a model is a hidden indirection the first time it happens. It may
need to name the model it resolved to, rather than silently loading it. - Size ordering across mixed providers. With no provider scope, a size sort
would rank a 91 GB local model above every cloud model, which have no size at
all. Ordering probably applies within a provider group, not globally.
Impact
packages/tui/src/component/dialog-model.tsx— query handling, ordering, and
the row label.packages/tui/src/local/— readingdefaultandsize_bytesfrom the model
list.- No contract change. No llama-skein change.
Incidental
dialog-model.tsx:88-92 carries a comment saying sizeBytes "isn't in the
generated SDK type yet — read it loosely; regen the SDK to type it properly",
and reads it through (model as { sizeBytes?: number }). The regeneration has
since happened: size_bytes is in packages/tui/src/local/llama-skein/gen/types.gen.ts:178.
The cast and the comment can go.
Disposition (2026-09-18)
Archived — superseded/obsolete. 2.1 shipped (provider-scoped query); the rest folded into models-picker-ux.
Tasks
1. Resolve the open questions first
These change the design, not the implementation, so answer them before writing code.
- 1.1 Decide the provider-match rule (exact / unique-prefix / other) and how the
active scope is shown, so it is never a silent filter. Check the real provider
names in use —rocky,z4,m3,m5,proxmox— for prefix collisions.
Validation: rule written down with the collision cases it rejects. - 1.2 Decide whether provider scope is escapable, and how, for the case of a model
whose own name matches a provider name.
Validation: a stated behaviour for queryrockywhen another provider hosts a
model calledrocky-*. - 1.3 Decide whether resolving a provider to its default model announces the model
it chose. Leaning yes — a hidden indirection is worse than one extra line. - 1.4 Decide whether size ordering applies within a provider group only.
A global size sort ranks every sized local model above every cloud model,
which have nosize_bytesat all.
2. Provider-scoped query
- 2.1 Detect a provider match per 1.1 before the fuzzysort pass in
dialog-model.tsx:174, and when it fires, list that provider's models
unranked-against-others instead of merging into the global result set. - 2.2 Further typing narrows within the scope rather than re-running the global
search. Validation:rockythenq5reaches the Q5 model without leaving scope. - 2.3 The active scope is visible and dismissable per 1.1/1.2.
Validation: a test asserting the scope indicator appears for a provider query.
3. Default-model resolution
- 3.1 Read
defaultfrom the model list (already in the generated types) and
resolve a provider selection to that model.
Validation: with a default set on rocky, selecting rocky loads it without a
second keystroke. - 3.2 A provider with no default expands to its model list — today's behaviour,
unchanged. Validation: test covers both branches. - 3.3 Verify against a live provider that
defaultactually arrives in
/v1/models. llama-skein documents it as "Listed first in the model list";
confirm the flag rather than relying on position.
Validation: recorded response from a provider with a default configured.
4. Ordering
- 4.1 Sort by
size_bytesdescending where the provider reports it; fall back to
the existingsortModelOptionsbehaviour where it does not.
Validation: rocky's list leads with the largest GGUF; a cloud provider's list is
unchanged. - 4.2 Keep alphabetical reachable. Whether that is a toggle or the fallback is a
1.4 outcome. Validation: both orders demonstrated.
5. Row label
- 5.1 Render size and VRAM together as
19G/24GB, with the hostname as the label
(19G/24GB rocky). Replaces the split betweenfooter(size) andprovenance
(${name} · ${vram}) atdialog-model.tsx:59-62,92. - 5.2 Omit the VRAM half rather than substituting a placeholder when the host does
not report it. Validation: a provider with no VRAM reading renders19G rocky. - 5.3 Check the row still fits a narrow terminal; the combined token is longer than
either half was. Validation: rendered at 80 columns.
6. Cleanup
- 6.1 Remove the
(model as { sizeBytes?: number })cast and the stale comment at
dialog-model.tsx:88-92claiming the field is not in the generated type. It is:
packages/tui/src/local/llama-skein/gen/types.gen.ts:178.
Validation: typecheck passes without the cast.
7. Verification
- 7.1
bun run typecheckinpackages/tui— no new errors. Note the repo does not
typecheck clean today; compare against a baseline rather than expecting zero. - 7.2 Exercise against a live provider: scope to rocky, confirm size ordering
matchesskein models list, confirm the default resolves. - 7.3 Confirm nothing changed for a cloud provider — ordering, labels, and search
behaviour identical to before.
Unchecked items above: see Disposition in proposal.md (2026-09-18).
Activity
androidand commentedon Sep 17, 2026
Archived 2026-09-18 — see the change's Disposition section.