feat: in-app model picker with hardened selection pipeline#103
Merged
quiet-node merged 42 commits intomainfrom Apr 26, 2026
Merged
feat: in-app model picker with hardened selection pipeline#103quiet-node merged 42 commits intomainfrom
quiet-node merged 42 commits intomainfrom
Conversation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Introduce a dedicated models module as the single backend source of
truth for the user's chosen Ollama model. The model slug is persisted
in app_config under 'active_model' and resolved at runtime against the
live /api/tags list, falling back to the first installed model, then
the env bootstrap default.
- Add src-tauri/src/models.rs with resolve_active_model,
validate_model_installed, fetch_installed_model_names, and two Tauri
commands (get_model_picker_state, set_active_model). All pure helpers
are unit-tested; the Tauri wrappers are thin delegations excluded
from coverage.
- Seed ActiveModelState at startup from the persisted value (or env
bootstrap) and manage it alongside the existing ModelConfig, which
is kept registered for Task 5 back-compat.
- Rewire ask_ollama, search_pipeline, and generate_title to read the
active slug from ActiveModelState. Mutex guards are scoped and
dropped before any .await point so locks are never held across
suspension.
- set_active_model rejects uninstalled slugs with the exact error copy
'Model is not installed in Ollama: {model}'.
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Two small DoS/clarity fixes flagged in code review: 1. Per-request 5s timeout on `fetch_installed_model_names`. Without it, a hung Ollama socket (TCP accept but no HTTP response) would block `get_model_picker_state` and `set_active_model` indefinitely and wedge the UI at the IPC boundary. Factored the body into a private `_with_timeout` helper so the timeout branch is deterministically testable with a 100ms override against a black-hole TCP listener. 2. Inline comment at the startup `resolve_active_model` call in lib.rs, making the `&[]` installed-list argument's intent explicit: no async runtime here, so we fall through to bootstrap; the frontend's first `get_model_picker_state` reconciles against the live list. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Address Task 3 code-review feedback. - refreshModels now clears both activeModel and availableModels when the backend rejects or returns a malformed payload, so a stale active slug cannot linger alongside an empty list. - Add a test for setActiveModel rejection to confirm errors propagate to the caller and the previously active model stays selected. - Add a test proving a second, malformed refresh clears a previously set active model. - Update JSDoc on refreshModels to spell out the clearing semantics and the single-trigger contract. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Wraps the onModelSelect callback in useCallback so the prop identity is stable across renders, and attaches a noop .catch to suppress unhandled rejection warnings when the backend rejects an uninstalled-slug race. Also drops a one-line comment at the first save() call site explaining the activeModel empty-string fallback window. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Update README and configurations.md to reflect the in-app model picker that now lists live Ollama installs and persists user selection. The `THUKI_SUPPORTED_AI_MODELS` env var is now a bootstrap/fallback list only. Remove the "in-app model switching" bullet from Future Work since it has shipped. Reformat two picker test files to satisfy prettier. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
bg-surface/95 + border-white/10 did not resolve against Thuki's Tailwind v4 @theme (which only defines surface-base, surface-elevated, surface-border). The popup would have rendered without a background. Switch to the same token chain as HistoryPanel and CommandSuggestion (rounded-xl, surface-border, surface-base, shadow-chat) for visual parity with existing Thuki chrome. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Smoke-test feedback uncovered three UX defects: 1. Chip trigger read visually smaller than the adjacent camera and send icons. Redraw the chip SVG so the central body and pins fill ~88% of the 16x16 viewBox, matching the camera icon's presence. 2. Trigger and rows were missing cursor-pointer, making them feel non-interactive next to the other ask-bar buttons. 3. The popup was anchored with 'absolute bottom-10' inside the morphing container's overflow-hidden, so it got clipped upward in ask-bar-only mode with no way to reveal the options. Split ModelPicker into ModelPickerTrigger (chip button) and ModelPickerList (animated list) so AskBarView can render the list in the same DOM-flow slot CommandSuggestion already uses. The morphing container's ResizeObserver then grows the native window upward as the list mounts, giving a smooth expand-and-reveal transition. AskBarView owns the open state, outside-click handling, and auto-close on generation start. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Stamped at creation time, stable across mid-stream model switches. Review feedback from TG4 quality pass. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
- WindowControls.tsx: 13x13 chip icon (matches icon sizes), text-secondary by default, orange on hover via named group/pill to prevent bleed from outer hover - ChatBubble.tsx: attribution chip border rounded-md instead of rounded-full, model slug truncated at max-w-[100px] Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Extracts the model picker content into ModelPickerPanel so both the ask-bar drawer and the chat-mode floating dropdown share layout, filtering, and aria-selected semantics. Adds combobox-style keyboard nav (Arrow/Home/End to move the aria-activedescendant, Enter to commit, Escape to close) and unifies click-outside dismissal across both modes so the drawer closes from the ask bar too. Backend: - resolve_seed_active_model: startup seed trusts the persisted choice unconditionally so a valid user selection survives restart even before /api/tags reconciliation. - should_persist_resolved: get_model_picker_state now coalesces the DB read + conditional write into a single critical section and refuses to persist when Ollama reports an empty inventory, eliminating the TOCTOU / clobber-on-empty-list hazards. - validate_model_slug: length + charset guard in front of set_active_model so adversarial IPC inputs cannot reach the network or database layers. - fetch_installed_model_names: caps Content-Length and read-back body size at 4 MiB to guard against a misbehaving localhost Ollama. - MODEL_NOT_INSTALLED_ERR_PREFIX: exported stable prefix replaces the prose-matched error contract. - ModelConfig: drops the unused `all` field now that /api/tags is the authoritative installed list. Frontend: - useModelSelection: mounted ref + monotonic request token serialize rapid picks and drop resolutions that arrive after unmount or are superseded by a newer call. - handleModelSelect no longer swallows rejection; a failed set triggers a refreshModels() resync so the chip + list match reality. - ModelPicker chip carries data-model-picker-toggle so the unified click-outside handler ignores the ask-bar trigger too. Tests: - 100% line coverage preserved on both sides; adds coverage for keyboard nav, unmount/stale guards, TOCTOU skip, body-size caps, slug validation, and the no-swallow refresh path. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
After rebasing 22 model-picker commits onto main's TOML config rewrite (20abeb0), reconcile the residual textual mismatches that survived per-commit conflict resolution: - App.tsx: drop duplicated useModelSelection() destructure that the conflict resolver inserted after a later commit had already moved the call earlier in the function so useOllama could consume it. - useConversationHistory: thread the active model slug through save() so generate_title runs against the picker selection. The frontend forwards model only into generate_title; save_conversation itself still sources the model backend-side from AppConfig. - useConversationHistory.test.tsx: add the missing MODEL fixture and update every existing save() call site to pass it. The branch's later commits referenced MODEL but the constant declaration was lost during auto-merge of the test header. - App.test.tsx: rewrite the "saves the conversation with the currently selected model" assertion to expect the slug on generate_title (not save_conversation) and wrap the save click in act() so the fire-and-forget generate_title invocation is flushed before the assertion runs. - lib.rs: prettier/cargo-fmt normalisation only. Phase 1 deliberately leaves the active-model persistence layered: SQLite app_config holds the picker selection while AppConfig holds the bootstrap. A follow-up Phase 2 commit will collapse the two into a single TOML-backed source of truth via config::writer. All gates pass: bun run test (800/800), bun run test:backend, bun run validate-build (lint, format, typecheck, release bundle). Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
03e7acb to
9def682
Compare
Active model is dynamic state (whatever the user last picked from the in-app picker, validated against Ollama's live /api/tags inventory), not a static user-tunable. Storing it in TOML duplicated ground truth from Ollama and created a staleness trap: the file would happily reference a model the user had since removed via `ollama rm`. This commit removes the `[model] available` list entirely. The selected slug now lives only in the SQLite `app_config` table under `active_model`, owned by `models::ActiveModelState`. The `[model]` section keeps just `ollama_url`, which IS truly static and tunable. This matches the dominant pattern across desktop AI clients (Ollama-app, Open WebUI, Msty, LM Studio, Chatbox, BoltAI, Cherry Studio, Enchanted, Raycast AI all do the same): treat the active model as runtime UI state, persist in a KV store, query the live list from the backend on demand. Changes: - schema.rs: ModelSection drops `available: Vec<String>` and the `active()` accessor. Only `ollama_url` remains. Doc comment explains why active-model state lives in SQLite. - loader.rs: drops the available-list resolve clause. Only the ollama_url empty-string fallback survives in `[model]`. - lib.rs: drops the bootstrap_active snapshot of TOML's first entry. ActiveModelState now seeds directly from `crate::config::defaults::DEFAULT_MODEL_NAME` when SQLite has no persisted choice. Phase 3 will gate the overlay on a real installed model so this placeholder bootstrap is never streamed to Ollama. - models.rs: get_model_picker_state drops the `app_config: State<...>` parameter. The bootstrap arg to resolve_active_model is now the compile-time DEFAULT_MODEL_NAME const. - history.rs: save_conversation now reads the active slug from ActiveModelState (the picker selection) instead of app_config.model.active() (TOML), so saved conversations are attributed to the model the user actually used, not to whatever TOML's first entry said. - tests.rs: drops 5 tests that asserted on the removed `available` field (model_section_active_falls_back_when_list_empty, model_section_active_returns_first, resolve_empty_available_list_falls_back_to_default_model, resolve_whitespace_only_entries_are_filtered, resolve_entry_whitespace_is_trimmed). Adds `resolve_unknown_model_field_is_ignored` to verify older user files containing a stray `[model] available = [...]` still parse (serde silently drops unknown fields). - docs/configurations.md: rewrites the `[model]` table to describe the SQLite-backed active-model state and the runtime /api/tags discovery. Updates the example TOML to drop `available`. Backward compatibility: existing user config.toml files that contain `[model] available = [...]` continue to load without error; the field is silently ignored. No migration required, no version bump needed. All gates pass: bun run test (800/800), bun run test:backend (474 passed), bun run validate-build (lint, format, typecheck, release bundle). Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Add the Phase 3 onboarding stage that gates the chat overlay on a
working local Ollama setup with at least one installed model. Returning
users with everything in order skip the gate entirely; first-time users
and any user whose Ollama setup degraded since last launch see a single
two-step screen owned by the frontend (ModelCheckStep, next commit).
Onboarding now progresses:
permissions -> model_check -> intro -> complete
Backend changes:
- onboarding::OnboardingStage gains a ModelCheck variant. Serialised in
snake_case to match the TypeScript union the frontend routes on.
Persisted SQLite value uses the same string. Forward-compat: an
unrecognised future stage falls back to Permissions, the safe default
that re-runs the full flow.
- models::ModelSetupState enum (Ollama unreachable | no models installed
| Ready { active_slug, installed }). Internally-tagged on `state` so
the React side discriminates on a single string.
- models::derive_model_setup_state pure helper that maps the result of
probing /api/tags + the persisted active-slug preference into a
ModelSetupState. Pure so all three branches are unit-testable without
HTTP or Tauri runtime; the Tauri command is a thin wrapper.
- models::check_model_setup async Tauri command. Idempotent. On the
Ready arm, persists the resolved active slug through SQLite via
should_persist_resolved (the same TOCTOU-safe gate get_model_picker_state
uses, so a transient empty /api/tags response cannot clobber a valid
saved choice) and mirrors the slug into ActiveModelState so subsequent
ask_ollama / search_pipeline calls see it without an extra DB read.
- lib::notify_frontend_ready routes to the ModelCheck stage whenever
the persisted stage is Permissions or ModelCheck and live macOS
permissions are granted. The actual Ollama probe runs in the frontend
because notify_frontend_ready is invoked synchronously and /api/tags
needs the async runtime; the brief frontend "checking" state is fine
for the rare happy path and unavoidable on the gated paths.
- lib::advance_past_model_check Tauri command. Writes the Intro stage
and re-emits the onboarding event so the parent OnboardingView swaps
to IntroStep without a window flicker. Idempotent: writing intro over
intro is a harmless no-op so a frontend race cannot corrupt state.
- CLAUDE.md documents the GStack design CLI fallback to hand-crafted
HTML wireframes when no OpenAI API key is configured. Used during
Phase 3 design exploration; relevant to every future design skill
invocation on this project.
Tests:
- 4 new onboarding tests covering ModelCheck round-trip, snake_case
serialisation parity with the frontend union, unknown-stage fallback,
and startup routing.
- 8 new models tests covering all 3 branches of derive_model_setup_state
plus the JSON wire format (state-tag discrimination).
- All 486 + 12 = 498 backend tests pass; clippy -D warnings clean;
cargo fmt clean.
Security and resilience invariants preserved:
- /api/tags fetch keeps the existing 5s timeout and 4 MiB body cap.
- Slug validation (charset, length) unchanged.
- No new attack surface: the new command takes no user input and only
reads from localhost:11434 and the existing SQLite app_config table.
- TOCTOU window between fetch + persist gated through the same
should_persist_resolved guard get_model_picker_state already uses.
The frontend ModelCheckStep component lands in the next commit.
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Frontend half of the Phase 3 model-check gate. Renders the unified
two-step "Set up your local AI" screen approved 2026-04-25 in the
design-shotgun board, reusing the StepCard pattern from PermissionsStep
so the user immediately recognises the visual rhythm they just walked
through one screen prior. Solves the "one last thing... wait, ANOTHER
last thing" UX failure of the earlier two-screen exploration.
Frontend changes:
- view/onboarding/_shared.tsx extracts the StepCard + Badge components
out of PermissionsStep so ModelCheckStep can reuse them without
duplication. Token values (active orange glow, done green border,
waiting white border) live in this single file. PermissionsStep now
imports from here.
- view/onboarding/ModelCheckStep.tsx is the new gate. Probes Ollama
via the backend check_model_setup command on mount; the same probe
fires on every Re-check button click (no background polling, the
user is the trigger). Three render paths:
* Probing: panel chrome + "Checking your local Ollama setup…",
step cards hidden so a brief flash never shows the wrong CTA.
* Ollama unreachable: Step 1 active with brew install + open -a
Ollama affordances; Step 2 waiting.
* No models: Step 1 done with green Connected badge; Step 2 active
with the curated 3-model list (gemma4:e2b recommended,
llama3:8b, qwen2.5:7b). Each card has a Copy button that writes
the ollama pull command to the clipboard.
* Ready: never paints the gate. The component fires
advance_past_model_check; the backend re-emits the onboarding
event and OnboardingView swaps to IntroStep.
IPC failures are treated as Ollama unreachable so the user always
sees a recovery path. Clipboard write failures are swallowed silently
because the terminal command remains visible for manual copy.
- view/onboarding/index.tsx adds 'model_check' to the OnboardingStage
union and routes to ModelCheckStep when the backend reports that
stage. The union string matches the Rust enum's snake_case wire
format exactly.
Tests:
- 11 ModelCheckStep tests covering both render branches, the Ready
fast-path advance, IPC failure recovery, Re-check re-probe + state
transition, the in-flight Re-check no-op guard (prevents double
fires), all three clipboard targets (brew install, open Ollama,
ollama pull <model>), silent clipboard failure swallowing, and the
privacy footer.
- 1 OnboardingView test covering the new model_check route.
- All 812 frontend tests pass; eslint, prettier, tsc all clean.
Build:
- bun run validate-build green: lint + format + typecheck + release
bundle. Tauri release build succeeded.
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
… models
Vertical timeline rail spans both steps with numbered nodes. Step 1 hosts
a two-tab install hero (Install Ollama / Already Installed?) with a
fixed-height code box that stays stable across tab switches. Step 2 lists
three multimodal starter models verified against ollama.com/library:
gemma4:e4b (Google), llama3.2-vision:11b (Meta), and phi4:14b (Microsoft).
Other UX polish:
- Step 1 done state shows "Listening on 127.0.0.1:11434" + "live" badge
- Centered helper sub-line under code box ("Paste this in Terminal or
visit Ollama docs")
- Step 2 helper block: "Paste the command in Terminal / or / Browse all
models on ollama.com" linking to ollama.com/search
- Each model slug is a clickable link that opens the model's library
page in the user's default browser
- Icon-only copy button no longer expands on success (border flips green)
- "Re-check setup" renamed to "Verify setup" / "Verifying…"
100% frontend coverage maintained; 831 tests pass.
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
"Before you dive in / You'll get the hang of it quickly" was vague and landed awkwardly after the multi-step setup. Replace with the clearer, more confident "You're all set / Five quick tips and you're chatting in seconds." which earns the Get Started CTA right below. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Replace the hardcoded "Listening on 127.0.0.1:11434" string with the configured `model.ollama_url` value (host:port) read via useConfig(). Custom users running Ollama on a remote host or non-default port now see their actual address. Falls back to the raw URL string when the value isn't parseable so the UI never shows an empty line. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
The mirror div behind the transparent textarea wrapped text using CSS pre-wrap+break-words, which never matched the native textarea's wrap algorithm exactly. Once content exceeded the 144px height cap and the textarea began scrolling, sub-pixel wrap differences accumulated and the caret drifted above the visible text. Drop the mirror+transparent-textarea pattern. Render the textarea with its own visible text so caret and glyphs share one element. Slash command discoverability is preserved by the existing suggestion popover. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Adds per-model capability detection (vision, thinking) via Ollama /api/show, cached in process. The picker now labels every row in a two-line layout with a "text" fallback for plain models. Compose surface stays fully live regardless of model: paperclip, drag, paste, and /screen all work. Mismatched submits (image attached + text-only model) shake the ask bar, surface a toast, and preserve compose state so the user can swap models via the existing picker chip without losing their typing. A passive amber strip appears between the attachments row and the ask bar the moment incompatible content lands. Defense in depth: slugs from /api/tags are re-validated via validate_model_slug before any /api/show POST, the response body is double-bounded (declared content-length + actual byte length), and the runtime IPC payload is shape-checked on the frontend. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Resolves the "Ollama isn't running" mis-error that fired whenever the user's config.toml pointed at a non-default ollama_url: the picker and onboarding gate were probing DEFAULT_OLLAMA_URL while ask_ollama honored config.model.ollama_url, so the picker happily reported a healthy daemon and selected model while every submit hit a refused connection on a different port. All five callers in models.rs now read config.model.ollama_url from State<AppConfig>, making AppConfig the single source of truth and eliminating the cross-command drift. Capability label now leads every row with "text" (the implicit baseline every chat-completion model supports) before appending vision/thinking, so plain rows still read "text" and richer rows read "text · vision", "text · vision · thinking", etc. Capability-mismatch copy softened: the strip and toast now read "<model> reads text only. Try a vision model for images." instead of the prior "can't see images / Switch to a vision model to send" pair. Model-picker chip suppresses the system focus outline (outline-none) so macOS users with a red accent color no longer see a red ring on the chip after /screen capture returns focus to it. Matches the existing focus suppression on the history button. Documents the test:all:coverage rule in CLAUDE.md so the coverage gate is run alongside the test suite by default. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Two related fixes for the "Something went wrong / HTTP 500" dead-end
that fired when sending multiple screenshots to llama3.2-vision.
Surface the real error: stream_ollama_chat now drains the response
body on every non-2xx status and threads it into classify_http_error,
which parses Ollama's {"error":"..."} payload. The toast shows the
concrete reason ("this model only supports one image while more than
one image requested") instead of a bare HTTP code, so the user can
act on it. Empty / non-JSON / blank-error bodies fall back to the
status code, preserving the previous behavior for paths that do not
return a structured payload.
Per-model max-images probe: Capabilities grew an Option<u32>
max_images field, populated by reading general.architecture from
/api/show (with details.family as a fallback for older Ollama
builds). max_images_for_architecture maps mllama -> Some(1) so
llama3.2-vision is recognized as single-image; unknown architectures
fall through to None and trust Ollama's runner. The frontend
capabilityConflicts gate now refuses multi-image submits before the
round-trip with a friendly "<model> accepts one image at a time.
Remove the extras to send." (pluralizes for any future cap). The
/screen command counts as one image toward the cap so a queued
capture is folded in correctly.
The Capabilities struct serializes camelCase (maxImages) so the
TypeScript ModelCapabilities mirror stays idiomatic; the field is
optional in TS so existing test fixtures continue to type-check.
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Mirrors the vision gate so /think on a model that does not emit reasoning tokens now triggers the same compose-time strip and submit-time toast/shake instead of silently falling back to a normal answer (which felt broken: the user typed /think and saw nothing in the ThinkingBlock). ComposeCapabilityState gained a hasThinkCommand flag. App.tsx wires it into both the live-strip useMemo and the submit-time gate. When the active model has thinking=false and /think is in the message, getCapabilityConflict returns "<model> doesn't show reasoning. Try a thinking model for /think.". When both gates would fire (image attached + /think on a text-only, non-thinking model) the vision message wins. Vision is the more fundamental constraint: a text-only model cannot consume the image payload at all, while /think on a non-thinking model is a softer mismatch. Picking the vision message also tends to point the user at the action that satisfies both constraints (most thinking models people pick are also text-capable). Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Surfaces the model-catalog affordance directly in the picker's filter row as a compact pill (variant F from the mockup set). Pill is anchored to the right of the filter input; the input flexes to fill the remaining width so the existing combobox semantics and keyboard behavior are unchanged. Hovering the pill renders the existing multi-line Tooltip with: "Open by design - browse and pull any model on Ollama. Thuki auto-detects it." Click invokes the existing open_url Tauri command to open https://ollama.com/library in the system browser. Pill styling matches Thuki tokens: primary-tinted background, primary border at low alpha, text-secondary baseline that brightens to primary on hover. Compact enough that it does not crowd the filter input even in chip-mode (320px); inline-flex with whitespace-nowrap keeps the icon glued to the label so it never wraps. Both URL and tooltip body are exported module constants so the tests match against a stable surface and any future schedule-side reuse (e.g. an empty-state nudge) reads from one source. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Three small follow-ups to the pill landed in the previous commit.
Match the rest of the app's button corners. The pill was rounded-full
which stood out next to every other secondary button (history chip,
model picker chip, send button). Switch to rounded-lg so it sits in
the same visual family. Tightened horizontal padding from px-2.5 to
px-2 to keep the pill compact at the new corner radius.
Drop the em dash from the tooltip body. Reads "Open by design:
browse and pull any model on Ollama. Thuki auto-detects it." now.
Compact mode for the chip drawer. ModelPickerPanel grew a `compact`
prop. Default false (overlay mode) keeps the full "Browse Ollama"
label. App.tsx passes compact={true} when rendering the picker
inside the chat-mode chip drawer (~224px wide); the pill drops the
"Ollama" word and renders just "Browse" so the filter row no longer
crowds the input. The aria-label still says "Browse Ollama models"
so assistive tech keeps the full meaning, and the tooltip is
unchanged.
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
The tooltip rendered awkwardly inside the chat-mode chip drawer: prose wrapped on the multiline tooltip's auto-width and left an orphan "it." on its own line plus a hyphen-broken "auto-detects". Switch to three forced line breaks so the tooltip renders as a compact stack of short, balanced phrases regardless of the host window width: Open by design. Browse and pull any Ollama model. Thuki auto-detects it. Pure copy/whitespace change; no Tooltip component logic touched. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
… edges The previous estimate-based clamp used a 160px half-width with a max-w-xs (320px) inner box. That was correct for the worst case but pushed the box far to the left whenever the trigger sat near the right edge of a Thuki overlay window (the picker popup is ~600px wide; Browse Ollama pill is anchored at the right). The tooltip ended up visually disconnected from the trigger even though the arrow still pointed at it. Tighten the multiline variant to a fixed 220px width (matches the typical tooltip body, including the new pill copy) and drop the half-width estimate to 110px. With the smaller half-width, the center-anchored box can sit directly below a trigger as close as 118px from either edge of the viewport, which covers every legitimate Thuki layout. Single-line tooltips are unchanged (90px estimate, no explicit width) so existing icon-button tooltips keep their tight one-line presentation. The previous attempt to measure the rendered width with a useLayoutEffect post-paint introduced a feedback loop that converged on absurdly narrow boxes. Reverted that approach in favor of the simpler width pin. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
…ixed width The previous fixed 220px width left dead space on the right when the tooltip body was shorter than the cap (the new pill copy "Browse and pull any model on Ollama. Thuki auto-detects it." comfortably fits in ~190px). Switching to max-width: 220px lets the box shrink to its intrinsic content width when short and still wrap at 220px when long, so there is no trailing whitespace and the box keeps its natural look under the trigger. Also updates the picker tooltip-content constant assertion to match the user's edited copy. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
…ignment Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
A model pulled while Thuki was running (e.g. ollama pull deepseek-r1:1.5b in another terminal) appeared in the picker but without its capability label, because both the installed-model list and the per-model capability map were only fetched on app mount. The new model showed up in the list (refreshModels did fire on overlay show) but its /api/show response was never requested, so the picker fell back to the empty-caps render path. Restarting the app worked because the cold-start fetch picked it up. Hook the picker toggle into both refreshes. handleModelPickerToggle now calls refreshModels and refreshModelCapabilities every time the panel transitions from closed to open. Backend reconcile_capabilities honors its in-process cache for already-known slugs and only POSTs /api/show for genuine misses, so this is cheap on every open and the new slug gets its capabilities resolved on the very next open after the pull finishes. Also picks up two in-flight tweaks: the Tooltip multiline width was nudged from 220 to 225, and the pill copy is now "Browse and pull any model on Ollama. Thuki auto-detects it." Tests updated to match. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Commit b11002f removed the mirror-div pattern that rendered slash command tokens in violet behind a transparent textarea. The fix was correct in spirit (caret drift on multi-line input was real) but the collateral damage was the highlight: every command token rendered as plain white text just like surrounding prose, which broke the visual affordance the user relies on to confirm the command parsed. Bring the mirror-div back, this time with metrics that match the post-fix textarea exactly. The textarea uses leading-5 (the value that resolved the caret drift); the mirror now uses the same leading-5 plus the same py-2, px-1, text-sm, whitespace-pre-wrap and break-words. Caret is rendered via caretColor on the textarea so it stays visible despite the text being transparent. onScroll on the textarea propagates scrollTop / scrollLeft to the mirror so the highlight tracks the caret on long inputs. renderHighlightedText is restored verbatim from the pre-regression implementation: word-boundary aware (so /searching is not matched as /search), and only the first occurrence of each trigger gets the violet utility class so duplicates render plain. The function is exported so the new pure-function tests can drive it directly without a full AskBarView render. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Applies review fixes for PR #103. config: move 4 baked-in pipeline constants to config/defaults.rs (MAX_OLLAMA_TAGS_BODY_BYTES, MAX_OLLAMA_SHOW_BODY_BYTES, DEFAULT_OLLAMA_TAGS_REQUEST_TIMEOUT_SECS, DEFAULT_OLLAMA_SHOW_REQUEST_TIMEOUT_SECS) per the single-source-of-truth rule in CLAUDE.md. Append baked-in rows to docs/configurations.md. models: stream-cap /api/tags and /api/show response reads. The previous implementation buffered the entire body via .bytes().await before checking the size cap, so a chunked response without Content-Length could allocate up to one full body before rejection. Replaced with a bytes_stream() loop that aborts the moment the running total exceeds the cap. Pre-read content_length() fast-path preserved. database: add allowlist guard on ensure_column. table, column, and each whitespace-separated col_type token must match a strict SQL identifier charset before any DDL string is interpolated. Defense-in-depth: every current caller passes a literal so no behavioral change, but the latent injection sink is closed. ui (capabilityConflicts): drop the redundant hasImages boolean from ComposeCapabilityState. It was a derived value of imageCount and the two could legally disagree. Derive needsVision = imageCount > 0 || hasScreenCommand inline. ui (App.tsx): collapse the duplicate getCapabilityConflict call. The submit-time gate now reuses the memoized liveCapabilityConflictMessage in scope. Hook deps adjusted accordingly. ui (ModelPickerPanel): replace the post-render useEffect that clamped highlightedIndex when the filtered list shrank with an inline safeHighlightedIndex derivation. Closes the one-frame ARIA glitch where aria-activedescendant could point to a DOM id that no longer existed. tests: add chunked-oversize early-abort tests for both fetch paths, SQL-identifier guard tests for ensure_column, and real assertions (onSelect not called, focus retained, filter cleared) on the ModelPickerPanel Escape-without-onClose case that was previously assertion-free. All 894 frontend + 532 backend tests pass. 100% coverage holds. validate-build clean (zero warnings, zero errors). Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
6 tasks
quiet-node
added a commit
that referenced
this pull request
Apr 26, 2026
…e model (#106) * refactor(inference): make Ollama the single source of truth for active model Eliminates the DEFAULT_MODEL_NAME fallback that silently downgraded existing users to gemma4:e2b on first launch of the model picker. Active model is now Option<String>: None when no model is installed and no choice is persisted, otherwise the user's persisted slug or the first installed model. Renames [model] config section to [inference] (only field is ollama_url, so the section now describes the inference daemon, not the model). Frontend: capability mismatch strip extended for the no-model state, picker chip stays visible as the recovery affordance, send is gated through the existing shake animation, save short-circuits when active model is null. No backward compatibility for the section rename: pre-#103 was a placeholder default and PR #103 has not shipped to users yet. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * fix(inference): distinguish Ollama-unreachable from no-models, restore picker chip Phase A surfaced two defects in manual smoke test: 1. Picker chip was missing in chat mode when activeModel was null. The chip is the recovery affordance, so it must stay visible whenever the user is in chat mode regardless of model state. 2. The no-model warning strip rendered the same copy whether Ollama was unreachable or simply had zero models installed. The two states need different recovery actions: start Ollama vs pull a model. get_model_picker_state now returns a typed ollamaReachable flag so the frontend can pick the right strip copy. The chip is rendered unconditionally in chat mode and hides only when Ollama is unreachable (nothing to pick from). Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * fix(inference): refine no-model UX from smoke-test feedback - Relax compose-mode chip gate so the picker chip stays visible whenever Ollama is reachable, even with zero models or no active selection. Match chat-mode by routing onModelPickerToggle through ollamaReachable. - Drop the duplicate capability-conflict toast on submit-gate. The persistent strip already surfaces the message; the transient toast added visual clutter and overflowed the window in compose mode. Removed the now-unused Toast component and its tests. - Update no-models strip copy: "Thuki couldn't find any local LLM models. Pull one from Ollama with ollama pull <model>, then come back." Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * refactor: address PR #106 review feedback - Move MAX_MODEL_SLUG_LEN from models.rs to config/defaults.rs so all baked-in pipeline constants live in the single source of truth declared by CLAUDE.md. - Document MAX_MODEL_SLUG_LEN in docs/configurations.md alongside the other Ollama API safety limits. - Switch useOllama.ts modelName coercion from `||` to `??` so an empty slug (malformed state) is no longer silently dropped; only a true null absence becomes undefined. Updated the matching test contract and added an askSearch null-active-model branch test to keep coverage at 100%. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> --------- Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
quiet-node
added a commit
that referenced
this pull request
Apr 27, 2026
…ite commands (#108) * feat(settings): backend foundation for Settings panel Wires the Tauri command surface, config-write pipeline, and tray menu entries the Settings GUI depends on. No frontend yet. Architecture - AppConfig wrapped in parking_lot::RwLock so the GUI can mutate runtime config in-process. Reads (every Ollama call, every search call, every command) take cheap clones via state.read().clone(); writes are rare and use a write guard. Parking_lot avoids std::sync poisoning on writer panic. - Single security boundary: defaults::ALLOWED_FIELDS is the authoritative allowlist of (section, key) pairs the GUI may write. set_config_field rejects any pair not in the list with a typed UnknownField error, preventing the GUI from writing fields that do not exist or are intentionally not user-tunable (activation timing, vision limits, etc). - toml_edit for per-field patches so user comments and key ordering in config.toml survive across GUI saves. Round-tripped through loader::load so clamp/empty-fallback/cross-field-invariant rules apply identically to GUI edits and hand-edits. - Focus-event reload (no file watcher): reload_config_from_disk re-runs loader::load on Settings window focus. Saves ~250 LOC and one critical failure mode versus the watcher approach the eng-review outside voice challenged. - Activation policy switches Accessory <-> Regular while Settings is visible so the Accessory app can take focus on its Settings window. Restored on close. Window hide-on-close preserves React state across reopen. Commands (registered via tauri::generate_handler!) - get_config — read snapshot from RwLock. - set_config_field(section, key, value) — validated per-field write. - reset_config(section?) — restore one section or whole file to defaults. - reload_config_from_disk — focus-event sync. - get_corrupt_marker — read+consume the recovery marker. - reveal_config_in_finder — open Finder with file selected. Errors - ConfigError extended with UnknownSection / UnknownField / TypeMismatch / Parse variants. Serializes as a tagged enum across the IPC boundary so the frontend can render typed inline error pills. Tray menu - Reordered to: Open Thuki / Settings... (Cmd+,) / --- / Reveal config.toml / --- / Quit Thuki (Cmd+Q). Cmd+, is a menu accelerator (fires only while the tray menu is open) — see design doc P5 for the macOS contract reasoning. Tests - 30 new tests in settings_commands::tests covering allowlist enforcement, every JSON->TOML coercion path (integer/float/string/array primitives plus type-mismatch rejection), comment preservation, document I/O errors, and the corrupt-marker consume/cleanup contract. - 463 tests pass; cargo clippy -D warnings clean; cargo fmt clean. - Three pre-existing clippy errors fixed in passing (commands.rs await guard scope, images.rs slice::from_ref, search/config.rs const assert). Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(settings): Settings panel UI implementation Frontend implementation of the Settings panel approved in the design doc. The panel ships in this commit as a working window: bun run validate-build now produces a release .app bundle that exposes Settings... in the tray menu and renders the three-tab UI inside the secondary NSWindow defined in tauri.conf.json. Window architecture - main.tsx routes on getCurrentWindow().label so one bundle serves both windows. Label "settings" mounts <SettingsWindow/>; everything else gets the existing chat overlay tree. No accidental cross-window state coupling. Visual language ("Polished Portal", variant A from /design-shotgun) - src/styles/settings.module.css: complete token set (typography 11/12/13/14/24px, spacing 4/8/12/16/24/32, pill tab 76x52 @ 12px radius, slider 4px track / 14px thumb, 32x32 stepper). All colors reused from src/App.css; no parallel design system. - Filled orange pill tabs with icon-above-label, active = #ff8d5c bg with dark text. Compact 56px rows. Uppercase 0.18em tracking section sub-headings. Helper text below each control. Bottom-right Saved pill (1.5s, aria-live="polite"). Reduced-motion media query. Hooks - useDebouncedSave: per-field 250ms debounce. Skips the seed render so initial mount never fires a save. flushNow() exposes a synchronous save for tab-switch flush. resetTo() re-seeds without scheduling a save (used by parent reload). - useConfigSync: invokes get_config on mount, subscribes to tauri://focus, calls reload_config_from_disk on focus to pick up external hand-edits. Replaces the file watcher subsystem the eng review collapsed. Form primitives (src/settings/components) - SettingRow / Section / SectionHeading layout primitives. - TextField, Textarea, Dropdown, NumberSlider, NumberStepper, OrderedListEditor (up/down/delete + add — no drag-and-drop, YAGNI per design doc). - SavedPill (aria-live="polite"), ConfirmDialog (Esc-to-cancel, destructive variant for resets). - SaveField: render-prop wrapper that binds one form row to one (section, key) tuple via useDebouncedSave. Resync token from parent re-seeds on external changes without scheduling a redundant save. Tabs - GeneralTab: MODEL (model list editor + Ollama URL) / PROMPT (textarea + char count) / WINDOW (4 sliders) / QUOTE (3 steppers). 13 controls, scrolls in the 720x560 fixed window. - SearchTab: SERVICES (URLs + sandbox-isolation warning) / PIPELINE (3 steppers) / TIMEOUTS (5 sliders). 10 controls. - AboutTab: APP (version + links) / ACTIVATION (info-only) / PERMISSIONS (status pills + Open System Settings buttons, refreshed on window focus) / LIMITS (info-only) / FILE (Reveal config.toml, Refresh from disk, Reset all to defaults). Accessibility - ARIA tablist with arrow-key cycling between tabs. - aria-selected/aria-controls/tabpanel role wiring. - Sliders carry aria-valuemin/max/now/text. - Saved pill is aria-live="polite"; row errors are role="alert". - Focus rings via outline (not box-shadow) so they survive transforms. - Reduced-motion media query disables transitions. Resets - Per-tab "Reset to defaults" link triggers the ConfirmDialog (matches mac convention for destructive actions). - About → Reset all opens a separate destructive-styled confirm and invokes reset_config(section: null). Corrupt-config recovery - Mount-time invoke of get_corrupt_marker. If a marker is returned, a dismissible banner appears at the top of the active tab body with "Reveal" (opens Finder via open_url) and "Dismiss" actions. Banner state is per-session in React (consumes the marker on the backend so it does not reappear on next launch). Validation - bun run validate-build passes end-to-end: lint + format + typecheck + full release .app bundle. - 721 existing Vitest tests still pass; 463 cargo tests still pass. Test coverage gap - Vitest tests for the new src/settings/* tree are NOT yet added in this commit. The shipped surface is large (10 components + 2 hooks + 3 tabs + SettingsWindow); covering it to 100% is genuinely a separate half-day of work and was deferred per the user request to land the feature in this worktree first. Backend coverage is at 100% on new code. Test coverage for the frontend is the only known gap before /ship. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(settings): redesign panel to match chat overlay aesthetic Settings now feels native to Thuki instead of a generic macOS prefs clone. Visual vocabulary mirrors the chat overlay (App.css / .morphing-container): warm-orange surface-base + radial aura, top-edge glow hairline, AI-bubble border treatment (1px white-06% with warm top edge), 10px rounded card, native overlay scrollbar. Window chrome: decorations:false + transparent:true, custom in-app top bar via the shared WindowControls component (3 traffic lights, no Thuki Settings title, red x hides via getCurrentWindow().hide()). Resized 720x560 -> 580x720 for an elegant portrait shape. Top region has 8px breathing padding so the controls strip matches the chat view. Tab nav switched to a horizontal CodexBar-style icon row (icon above label, subtle dark inset + warm orange on active). All inputs, textareas, buttons, sliders, steppers, and pills restyled with the chat-bubble vocabulary so the two windows feel like one product. Drag from any non-interactive surface via handleDragStart mirroring App.tsx, walking up the DOM and bailing on form controls. Settings window added to capabilities/default.json with allow-hide, allow-start-dragging, and allow-set-focus so red x and drag actually work (previously scoped to main only). Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(settings): polish panel UX and reach 100% coverage UI polish (single user-facing pass): - Tab restructure: 4 tabs (Model brain / Web globe / Display monitor / About info). Old "General" split into Model (model + ollama_url + prompt) and Display (window + quote). Web tab keeps search. - Per-section reset buttons removed. "Reset all to defaults" stays in About tab. - Settings tooltips now reuse the shared Tooltip component (with new optional `placement: 'top' | 'bottom'` prop) and pull help copy from a single source-of-truth map (`configHelpers.ts`) that mirrors `docs/configurations.md` row-for-row. The `?` info button is keyboard focusable and reveals the tooltip on focus. - About hero redesign: centered layout with Thuki logo, clickable version pill linking to GitHub release notes, two-line tagline ("A floating, local-first AI for macOS." / "No cloud. No clutter. Just answers."), three icon-only social buttons (GitHub / X / Feedback). Activation + Limits sections removed; permissions retain the row layout but lose the inter-row hairline. About tab no longer scrolls. - File row buttons unified to ghost style. "Reveal config.toml in Finder" → "Reveal Thuki app data". "Refresh from disk" → "Refresh config.toml" with explanatory tooltip placed above the trigger. - SavedPill now opaque so it doesn't bleed into rows. NumberStepper sits right-aligned at a sensible width instead of stretching. Save reliability fixes: - `useDebouncedSave` rewritten around a `lastSavedRef` value-equality gate plus an epoch counter. Eliminates the React StrictMode phantom save (pill appearing on tab switch with no edits) and the every-other-save miss after a successful save bumped `isInitialMountRef`. Adds `isMountedRef` guard on post-await setError, unmount-time flush of pending changes (so tab-switch mid-edit doesn't drop the user's last keystroke), and structural array equality for the model list. Drops unused `pending` field from the public handle. - `SaveField` helper prop type narrowed `ReactNode` → `string` to match the tooltip contract. Tests + coverage to 100%: - Backend: extracted `write_field_to_disk` and `reset_section_on_disk` helpers from the Tauri command wrappers so the allowlist guards, document patch, atomic write, and post-write reload are exercised without an `AppHandle`. Added tests for boolean/datetime/inline-table coercion branches, IO-error propagation under read-only parent dirs, and corrupt-marker write failure (squat the marker filename with a directory). Coverage-excluded the five thin Tauri command wrappers per project convention. Removed unreachable defensive branches in loader's `rename_corrupt` and the `Float` coercion path. - Frontend: new test files for `configHelpers`, `types`, `useDebouncedSave` (race + epoch + unmount-flush), `useConfigSync` (focus + reload + cleanup), every primitive in `components/index` (Section, SettingRow, TextField, Textarea, NumberSlider stepper + drag-aware sync, NumberStepper, Dropdown, OrderedListEditor, SavedPill, ConfirmDialog, ResetSectionLink), `SaveField` (resync token + errored prop forwarding), `SettingsWindow` (tab nav, corrupt banner, Cmd+,, drag, savedPill timer), and the four tab smokescreens. `main.tsx` factored to a pure `rootForLabel` helper for testability. - Mock additions: `__emitFocus` / `__resetFocusListeners` / `__setWindowLabel` on the tauri-window mock to drive the new hook + main entry tests. Configuration docs touched indirectly: tooltip strings in `configHelpers.ts` are kept in sync with `docs/configurations.md`; update both files in lockstep when adding a tunable. Validate-build clean (lint zero warnings, prettier clean, typecheck clean, frontend + backend build green). bun run test:all:coverage passes 100% lines / 100% functions / 100% branches / 100% statements on the frontend and 100% lines + 100% functions on the backend. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(tray): drop "Reveal config.toml" item from tray menu The Settings → About tab already exposes "Reveal Thuki app data" with the same affordance, and the tray surface should stay focused on session-level actions (open / settings / quit). The `reveal_config_in_finder` Tauri command itself is unchanged so the About tab button keeps working. Tray menu is now: Open Thuki · Settings… · ─ · Quit Thuki. No tests had to be removed: the tray wiring was driven by an inline match in `lib.rs`'s `setup` closure, never directly tested. The `reveal_config_in_finder` command remains coverage-excluded as a thin FFI wrapper. Validate-build clean. bun run test:all:coverage passes 100% lines / 100% functions / 100% branches / 100% statements on the frontend and 100% lines + 100% functions on the backend. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * fix(settings): reconcile with main's model picker pipeline Adapts the Settings panel to the model-selection redesign that landed in main (#103). Active model state now lives in SQLite via ActiveModelState, not in a TOML field, so the Settings GUI no longer owns active-model selection: the in-app ModelPickerPanel does. Bug fix (models.rs) - All four model-picker Tauri commands took State<'_, AppConfig> but lib.rs registers RwLock<AppConfig> after the worktree's wrapping. Tauri State<T> looks up by type, so the lookup silently failed at runtime: the AskBar chip stayed hidden and the chat-header picker came up empty. Each handler now takes the RwLock-wrapped state and snapshots ollama_url via config.read() before any await. Settings panel reconciliation - Drop the "Active Ollama model" row from ModelTab. Capability moves to App.tsx's ModelPickerPanel; URL + system prompt remain in Settings. - Remove model.available from RawAppConfig (settings + ConfigContext), ALLOWED_FIELDS, configHelpers, all test fixtures. - Delete OrderedListEditor component and its tests (last caller gone). - Drop the now-unused .modelList/.modelItem*/.iconBtn/.modelAddRow CSS. - Update settings_commands tests: ALLOWED_FIELDS count 20 -> 19. - Replace stale config.model.active() assertion in config tests with a model.ollama_url check. Coverage maintenance - Add useModelCapabilities stale-resolution test to mirror the existing stale-rejection test (the isLatest short-circuit path was uncovered after the rebase exposed it). Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * refactor(settings): rename Model tab to AI, section to Ollama The Settings tab labelled "Model" no longer holds a model picker — that moved to the in-app ModelPickerPanel after main #103. Keeping the label misleads users into expecting a model list under it. Two cosmetic renames realign the surface with what it actually tunes: - Tab label "Model" -> "AI" (icon unchanged: brain still maps to both). - Section heading "Model" -> "Ollama". With only the Ollama URL field in this section now, "Ollama" is the honest name; "Model" implied a picker was missing. Pure UI rename: no schema, ALLOWED_FIELDS, or backend changes. Test strings + adjacent doc comments updated to match. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(settings): auto-resize window to active tab content Replaces the fixed 680px Settings window height with a content-aware height that animates between tabs. AI hugs ~520, Display lands at ~720, Web at ~860 — light tabs no longer waste whitespace and heavy tabs no longer force unnecessary scrolling at the default size. Hook: useSettingsAutoResize(contentRef, chromeHeight) - ResizeObserver on the inner content wrapper measures natural scrollHeight whenever the active tab swaps or a textarea grows. - Target window height = content + chrome, clamped to [280, 900]. The body retains overflow-y: auto so content over MAX still scrolls internally rather than pushing the OS window off-screen. - First measurement snaps without animation (no visible settle on open). Subsequent changes interpolate via requestAnimationFrame with ease-out cubic over 220ms, issuing one Tauri setSize per frame so macOS animates the real NSWindow at 60Hz. - Sub-4px deltas are dropped to avoid ResizeObserver chatter. Wiring - SettingsWindow wraps tab panels in a measured div ref'd to the hook. - Chrome height = 148 baked-in (window padding + WindowControls + tab bar + body padding). When the corrupt-config recovery banner is visible, hook receives chrome + 56 so the window grows to fit it. - tauri.conf.json relaxes the settings window's min/max from 680/680/680 to 280/520/900 so the runtime resize is not capped by the configured bounds. Tests - New useSettingsAutoResize.test covers: snap on first observation, rAF interpolation, clamp at MIN and MAX, sub-4px no-op, mid-flight animation re-targeting, observer + rAF cleanup on unmount, no-op when ref is null, and chrome-height update reflected on next resize. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * fix(settings): force window remeasure on tab switch The auto-resize hook only updated the window height on its first mount because ResizeObserver does not fire when React swaps a tab's tree inside the same wrapper element within a single paint frame: the wrapper's box change happens between paints and is not always observed. Adds a `revision` parameter to `useSettingsAutoResize` (passed the active tab id from SettingsWindow) plus a `useLayoutEffect` that synchronously remeasures whenever revision changes. Layout-effect timing guarantees `scrollHeight` reflects the freshly-mounted tab's natural height, so the window animates to the correct target on every switch instead of staying frozen at the first-mount value. Test added for the revision-driven remeasure. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * fix(settings): re-attach auto-resize observer when content mounts The auto-resize hook ran its setup effect once on initial render and returned early because the wrapper element did not exist yet (the SettingsWindow tree gates rendering on \`if (!config) return null\`, so the wrapper is only added on the second render after \`get_config\` resolves). With a plain \`useRef\`, the dependency array stayed identity-stable across renders, so the effect never re-ran when the element finally appeared. Result: ResizeObserver was never attached and \`handleResizeRef\` stayed at its no-op default, so the revision-driven re-measure on tab switch did nothing. Switches to a state-backed callback ref pattern: SettingsWindow stores the wrapper in \`useState\`, the hook accepts the element directly as a parameter, and the setup effect depends on \`el\`. When the element mounts, the state update triggers a re-render, the effect re-runs with the real DOM node, and ResizeObserver attaches correctly. Subsequent tab switches drive the layout-effect re-measure which now reaches a real \`handleResize\` instead of the no-op stub. Tests rewritten around the new signature, with \`act()\` wrapping for state updates so the layout-effect re-measure is exercised correctly. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * refactor(settings): cap window height at 700px so tall tabs scroll The Web tab's full timeouts list pushes natural content past 1500px, which made the panel uncomfortably tall on smaller laptop displays. Drop the auto-resize ceiling from 900 to 700; tabs that exceed the cap continue scrolling inside .body via its existing overflow-y: auto. tauri.conf.json maxHeight matches. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * refactor(settings): drop About-tab special-case overflow override The About tab had its own .bodyNoScroll class (overflow: hidden, scrollbar-gutter: auto) so its content was clipped at the bottom when natural height exceeded the window cap, instead of scrolling like every other tab. Removes the special case so .body's default overflow-y: auto applies uniformly: every tab now scrolls when its content exceeds the cap. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * refactor(settings): drop scrollbar-gutter so short tabs fill width Removing \`scrollbar-gutter: stable\` lets the AI, Display, and About tabs use the full body width when their content fits under MAX_HEIGHT. The Web tab still scrolls when it overflows; macOS overlay scrollbars float over content on that tab without causing layout shift, so reserving a permanent gutter on every tab was just visual noise. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * fix(settings): add chrome-height slack so short tabs do not scroll \`.body\` uses \`overflow-y: auto\`, which paints a scrollbar whenever content is even 1px taller than the body box. The hand-measured chrome height (148) was a touch too tight, so the AI/Display/About tabs ended up with content marginally exceeding the body and a scroll affordance appearing on tabs that should fit cleanly. Bumps CHROME_HEIGHT to 164 to give the body 16px of slack. Short tabs now sit comfortably with no scrollbar; the Web tab still clamps at MAX_HEIGHT and scrolls because its natural height vastly exceeds the cap regardless of slack. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * fix(settings): measure chrome height dynamically The static chrome estimate was systematically off, so the body box ended up a few pixels shorter than the inner content even on tabs that should fit cleanly. Result: AI/Display/About each painted a scrollbar despite the cap not being hit. Wraps WindowControls + banner + tab bar in a measured container, runs a ResizeObserver on it, and uses its real `offsetHeight` plus two CSS-constant offsets (window padding-top 8 + body padding 42) as the chrome offset for the auto-resize hook. The ResizeObserver also catches the banner appearing or disappearing without needing a separate code path. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * refactor(settings): drop reserved scrollbar gutter on body \`scrollbar-gutter: stable\` reserved a vertical strip of empty space on every tab so content would not shift when the macOS overlay scrollbar faded in. The macOS overlay scrollbar already floats on top of content when it appears, so the gutter was just dead margin on the tabs that do not scroll (AI, Display, About). Drop it so short tabs render without any scrollbar artifact and Web still gets the floating scrollbar when its content exceeds the window cap. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * fix(settings): only enable body scroll when content was clamped Previously \`.body\` had \`overflow-y: auto\` unconditionally, so any sub-pixel rounding mismatch between the auto-resize hook's chrome estimate and the real layout produced a stray scrollbar even on short tabs (AI, Display, About) whose content fit inside the auto-sized window. Result: every tab looked scrollable. useSettingsAutoResize now returns an \`isClamped\` boolean that flips true when natural content + chrome exceeded MAX_HEIGHT. SettingsWindow applies a \`bodyScrollable\` class only in that case; the default \`.body\` style is \`overflow: hidden\` so short tabs render with no scrollbar artifact at all. Web (the only tab past the cap) still gets internal scrolling because it trips the clamp branch. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * fix(settings): rename remaining [model] section refs to [inference] in coverage-only tests Post-rebase cleanup. Three .model.ollama_url field accesses and seven "model" string literals in settings_commands::tests and config::tests were only exercised when the cfg(coverage) build drops the #[cfg_attr(coverage_nightly, coverage(off))] gate on the surrounding Tauri-command wrappers, so the regular cargo test passed but test:all:coverage failed to compile. Update them to match main's [model] -> [inference] section rename from #106. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * refactor: remove collapsed height and hide commit delay from window configuration - Removed `collapsed_height` and `hide_commit_delay_ms` from the `WindowSection` struct and related configurations. - Updated the default values and tests to reflect the removal of these properties. - Adjusted the frontend to use fixed values for collapsed height and hide commit delay, ensuring consistent behavior across the application. - Emitted configuration updates to the frontend when changes are made to other window properties. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * fix(settings): add bottom padding to About tab and Cmd+W to hide panel About tab was visually clipped at the bottom because .aboutBody had no padding-bottom, causing the auto-resize window to size exactly to the content edge with no breathing room. Adding 8px padding-bottom matches the spacing visible on other tabs. Cmd+W now hides the settings window to follow the standard macOS panel close shortcut. The existing Cmd+, re-focus handler lives in the same useEffect; both shortcuts share one document listener. Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * fix(settings): patch_document insert-on-missing, RMW lock, stale doc comment Three fixes to settings_commands.rs: 1. patch_document now inserts a new TOML item with inferred type when the key is absent (e.g. user hand-edited it out), instead of returning UnknownField. Type inference: bool, integer, float, string, array-of-strings. Objects and null still return TypeMismatch. Adds json_value_to_toml_item helper for the missing-key path. 2. Hold the RwLock write guard across the full disk read-modify-write in set_config_field, reset_config, and reload_config_from_disk. Prevents a TOCTOU race where two concurrent invocations could read stale file content and last-write-wins on a different field. 3. Correct stale doc comment: State<AppConfig> -> State<RwLock<AppConfig>>. Adds 7 new tests covering insert-on-missing paths (float, string, integer, array, object rejection, array-non-string rejection, bool). Removes the now-incorrect patch_document_unknown_field_errors test (allowlist enforcement is upstream in write_field_to_disk, covered by existing test). Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * fix: tighten comment on u64 precision edge case in json_value_to_toml_item Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * fix(config): update AppConfig doc to reflect RwLock<AppConfig> managed state Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * docs(configurations): add baked-in rows for COLLAPSED_WINDOW_HEIGHT and HIDE_COMMIT_DELAY_MS Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * style: apply cargo fmt to settings_commands Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> --------- Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds an in-app model picker so users can switch the active Ollama model without leaving the overlay, in both ask-bar and chat-mode layouts. Persists the selection across launches, attributes each assistant message to the model that produced it, and validates every selection against the live
/api/tagsinventory.The drawer/dropdown shares a single
ModelPickerPanelcomponent with combobox-style keyboard nav (Arrow/Home/End/Enter/Escape,aria-activedescendant), filter input, and unified click-outside dismissal across both modes.Backend (
src-tauri/)models.rs(new):ActiveModelState,resolve_active_model,resolve_seed_active_model,should_persist_resolved,validate_model_slug,validate_model_installed,fetch_installed_model_names(5s timeout, 4 MiB body cap on bothContent-Lengthand post-read), and theget_model_picker_state/set_active_modelTauri commands./api/tags.get_model_picker_statecoalesces the DB read + conditional write into a single critical section and refuses to persist when Ollama reports an empty inventory, eliminating the TOCTOU / clobber-on-empty-list hazard.set_active_modelvalidates length (<= 256) + charset ([A-Za-z0-9:._/-]) before any network or database work.MODEL_NOT_INSTALLED_ERR_PREFIXexported as a stable shared constant.ModelConfigreduced to its single live field (bootstrapactive); the unusedallwas removed.Frontend (
src/)ModelPickerPanel,ModelPicker(chip),WindowControlsmodel pill (chat-mode header), anduseModelSelectionhook.useModelSelection:mountedref + monotonic request token serialize rapid picks and drop resolutions that arrive after unmount or are superseded by a newer call.handleModelSelectno longer swallows rejection: a failedset_active_modeltriggersrefreshModels()so the chip and picker resync with reality.Message.model_namethreaded from picker to assistant bubble; persisted per message; rendered as a small attribution chip with auto-escaped JSX (no markdown / no XSS).ModelPickerState,Message.modelName.Sandbox / docs
docs/configurations.mdupdated to describeTHUKI_SUPPORTED_AI_MODELSas the bootstrap fallback rather than the runtime authoritative list.Test plan
bun run test(805 frontend tests pass)bun run test:coverage(100% statements / branches / functions / lines)bun run test:backend(437 backend tests pass)bun run test:backend:coverage(100% line coverage with--fail-under-lines 100)bun run validate-build(lint + format + typecheck + Tauri release build, zero warnings)ollama rm, pick again (UI resyncs via refreshModels)