Skip to content

feat: in-app model picker with hardened selection pipeline#103

Merged
quiet-node merged 42 commits intomainfrom
feat-model-picker-popup
Apr 26, 2026
Merged

feat: in-app model picker with hardened selection pipeline#103
quiet-node merged 42 commits intomainfrom
feat-model-picker-popup

Conversation

@quiet-node
Copy link
Copy Markdown
Owner

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/tags inventory.

The drawer/dropdown shares a single ModelPickerPanel component 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 both Content-Length and post-read), and the get_model_picker_state / set_active_model Tauri commands.
  • Startup seed trusts the persisted slug unconditionally so a valid user choice survives restart even before the first reconciliation against /api/tags.
  • get_model_picker_state 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 hazard.
  • set_active_model validates length (<= 256) + charset ([A-Za-z0-9:._/-]) before any network or database work.
  • MODEL_NOT_INSTALLED_ERR_PREFIX exported as a stable shared constant.
  • ModelConfig reduced to its single live field (bootstrap active); the unused all was removed.

Frontend (src/)

  • ModelPickerPanel, ModelPicker (chip), WindowControls model pill (chat-mode header), and useModelSelection hook.
  • 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_active_model triggers refreshModels() so the chip and picker resync with reality.
  • Message.model_name threaded from picker to assistant bubble; persisted per message; rendered as a small attribution chip with auto-escaped JSX (no markdown / no XSS).
  • New types ModelPickerState, Message.modelName.

Sandbox / docs

  • README and docs/configurations.md updated to describe THUKI_SUPPORTED_AI_MODELS as 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)
  • Manual: open overlay, click pill, switch model, verify chip + persistence across restart
  • Manual: keyboard nav (Arrow/Home/End/Enter/Escape) inside picker
  • Manual: click outside picker in ask-bar mode (drawer dismisses)
  • Manual: pick a model, immediately uninstall it via ollama rm, pick again (UI resyncs via refreshModels)

quiet-node and others added 22 commits April 24, 2026 19:33
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>
@quiet-node quiet-node force-pushed the feat-model-picker-popup branch from 03e7acb to 9def682 Compare April 25, 2026 03:15
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>
@quiet-node quiet-node merged commit d6cf4fb into main Apr 26, 2026
3 checks passed
@quiet-node quiet-node deleted the feat-model-picker-popup branch April 26, 2026 17:01
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant