Skip to content

fix(ai-settings): real model dropdown + Ollama auto-pick installed model#55

Merged
matiaspalmac merged 34 commits into
mainfrom
fix/ai-settings-ollama-dropdown
May 7, 2026
Merged

fix(ai-settings): real model dropdown + Ollama auto-pick installed model#55
matiaspalmac merged 34 commits into
mainfrom
fix/ai-settings-ollama-dropdown

Conversation

@matiaspalmac
Copy link
Copy Markdown
Owner

Summary

  • Replace <input list>+<datalist> with a Radix Select populated from live catalog merged with installed Ollama tags. WebView2 filtered the datalist by the input value, so the dropdown looked empty and unclickable when ai.model didn't substring-match an installed tag.
  • On Ollama provider switch and on base-URL change, probe /api/tags and repoint ai.model to the best installed tag via pickBestModel. The static default qwen3-coder is rarely installed, so /api/chat returned 404 on test-connection.
  • Auto-fetch the provider catalog on switch when implementable (skip cloud providers without a key) so the dropdown is populated without clicking "Fetch models".

Test plan

  • pnpm typecheck
  • pnpm test --run AiSettings (4 passed)
  • Manual: switch to Ollama with no key → model auto-set to installed tag, dropdown lists installed tags, "Test connection" succeeds
  • Manual: switch to Anthropic with key configured → catalog auto-fetched, dropdown lists API models
  • Manual: type a custom model id in the text input → still works

The model picker used an `<input list>` + `<datalist>` combo, which WebView2
filters by the current input value — when ai.model didn't substring-match an
installed tag, the dropdown appeared empty and unclickable. Replace with a
Radix Select populated from the live catalog merged with installed Ollama
tags, keeping the text input as a fallback for custom ids.

Also fix test-connection always failing on first Ollama use: the static
default model `qwen3-coder` is rarely installed, so /api/chat returned 404.
On provider switch and on URL change, probe Ollama and repoint ai.model to
the best installed tag via pickBestModel before any test runs.

Auto-fetch the provider catalog on switch (when no key is required or one
is configured) so the dropdown is populated without needing to click
"Fetch models" first.
Copilot AI review requested due to automatic review settings May 6, 2026 16:32
…render <em> in hint

The Trans component only registered <code/>, so the <em> tag in the model hint
leaked through as escaped entities. Adding em to the components map renders it
as italics again.

The Select and the text input were two siblings of the .daisu-field flex row,
which split the row into three columns and squeezed the label into one-word-
per-line. Wrap them together in a constrained flex column so the right side
behaves as a single field cell.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

…l modal

The Radix Dialog blocked the whole IDE every time the agent asked to run a
tool, which broke the user's reading flow and obscured the conversation that
triggered the request. Move the permission UI inline into the agent panel,
anchored just above the composer, so the request sits next to the chat it
relates to and the user can still read messages while deciding.

PermissionModal.tsx now hosts two exports: the original PermissionModal
(listener only — registers the Tauri event handler, returns null) and a new
PermissionInline that reads the same store and renders the styled card.
Slow local models (qwen2.5-coder:1.5b on first load, large context windows)
can keep generating server-side after the HTTP connection drops, so the
backend Cancelled event arrives seconds late or not at all. Cancel now
flips isStreaming/runId locally and marks the pending message as cancelled
before calling cancelRun, so the user is never stuck staring at a
non-responsive Stop button. The Tauri call is best-effort — if the run
already finished, the swallow is harmless.
…-as-text tool calls

Two bugs the chat panel surfaced when running against Ollama with
qwen2.5-coder:1.5b:

1. The StreamPayload enum carried snake_case fields (run_id, conversation_id,
   message_id) because serde's enum-level rename_all only renames variants,
   not their fields. The frontend listener typed everything as camelCase, so
   payload.runId was undefined for every event. The runId-mismatch filter then
   silently dropped every delta after the first one and the UI sat on the
   pending placeholder forever even though tokens were streaming. Add
   rename_all_fields = camelCase so variant fields get renamed too.

2. Small Ollama models (1.5b/0.5b families) ignore the wire-level tools field
   and instead emit a JSON object describing the tool call inside a fenced
   code block. The agent loop saw no tool_calls, treated the turn as terminal,
   and the user got the raw JSON instead of an action. Parse fenced JSON
   bodies after the stream finishes and synthesise the same UI events the
   provider would have, validating the tool name against the registry so we
   never invent dispatches the user didn't ask for.
…system prompt

Three fixes that surfaced testing the agent against small Ollama models:

- Implement write_file (Prompt tier, 1MiB cap, creates parent dirs).
- Drop the StubTool registrations for grep/find_files/git_status/git_diff/
  delete_file/run_command. Advertising tools whose dispatch returns
  'not yet implemented' just teaches small models to keep picking them.
  Wave 2 will register real impls; until then the model only sees the
  working subset (read_file, list_dir, write_file, propose_edit).
- ToolRegistry::descriptors() now derives from the actually-registered
  tools instead of the global registry() literal, so callers can never
  advertise a name the dispatcher can't resolve. agent_send_message
  picks up tool defs the same way.
- Inject a default system prompt when the frontend doesn't send one.
  Anchors the model to the workspace, says 'don't call tools for
  greetings', and gives a one-line guide for each tool plus path
  rules so 3b-class models stop passing '/' or absolute paths.
Default 5-minute idle unload made every turn after a pause pay a 2-6s
model-reload tax for 7B-class models. Set keep_alive on every /api/chat
request so the daemon's env var can't undercut us.

Tune Options for code chats: num_ctx 8192 (up from default 2048 which
silently truncates the system prompt + tool defs + history), num_batch 512
(~30% prefill speedup on RTX-class GPUs), top_k 40, top_p 0.9,
repeat_penalty 1.05.

Backed by the May 2026 research pass.
Bundles four related changes that share a single file:

1. Hoist history load out of iteration loop — read SQLite once before the
   while iteration loop and mutate the in-memory buffer in lock-step with
   persistence. Eliminates ~50ms x N redundant SQLite reads per tool chase.

2. Fallback parser robustness — extend extract_fallback_tool_calls to handle:
   - bare unfenced JSON (balanced-brace scan respecting strings)
   - Qwen3-coder <tool_call>{...}</tool_call> XML tag
   - Llama 3.1 <|python_tag|>{...}<|eom_id|> shape
   Returns FallbackParse { calls, cleaned_text } so the chat UI can render
   the prose without the raw payload. New StreamPayload::ReplaceText event
   tells the listener to swap the pending content with the cleaned text.
   Eight unit tests covering each shape, dedup, brace-in-string, unknown
   tools, and text-cleaning.

3. ChatMode (Auto / Chat / Agent / Plan) wired through SendMessageRequest:
   - Chat strips tools entirely + tool_choice=None.
   - Agent forces full catalogue.
   - Plan keeps only read-only tools (read_file, list_dir).
   - Auto preserves the conversational-opener heuristic. ToolChoice::None
     is also set for Auto on conversational openers so the model keeps
     tool awareness for follow-ups (Ollama still strips because it ignores
     tool_choice).

4. Dual-variant default system prompt — long form (~550 tok) for cloud
   providers, short imperative form (~210 tok) for Ollama / LM Studio.
   Per-mode addenda appended at the prompt tail where small models attend
   most strongly. Path rules + tool guide + few-shot included in long
   variant only.

Stubs (grep / git_status / find_files / run_command / delete_file) stay
off the wire; ToolRegistry::descriptors() now derives from registered
tools so we never advertise names the dispatcher can't resolve.
- agentStore.chatMode persisted in localStorage with setChatMode action.
- ChatPanel composer gets a 4-button mode picker (Auto / Chat / Agent /
  Plan) above the textarea with tooltips describing each.
- ModelInlinePicker in panel header so users can swap provider+model
  without opening Settings. Reuses listProviders + listProviderModels +
  probeOllama; auto-fetches the catalog when a key is configured.
- StreamPayload typed with new replaceText variant; listener swaps the
  pending message body wholesale when the backend strips a tool-call
  payload from the streamed text.
- Daisu Nocturne-styled CSS for the new mode row + permission inline +
  composer-side inline picker.
- en/ja i18n keys for mode labels + tooltips + provider/model picker
  labels.
i18n: switched es.json from rioplatense voseo (vos / abri / usa / elegi /
queres / reindexa / ejecuta / cambia / confia / volve) to neutral Spanish
(tu / abre / usa / elige / quieres / reindexa / ejecuta / cambia / confia
/ vuelve). 17 strings normalized.

docs: README gains a Tuning local models (Ollama) section covering the
OLLAMA_FLASH_ATTENTION=1 + OLLAMA_KV_CACHE_TYPE=q8_0 env vars and a
recommended-models table per VRAM tier (3B / 8B / 16B / 20B+). Daisu
already sets keep_alive 30m and num_ctx 8192 on every request so users
don't need to touch those.
…rcion

Hardens every tool dispatch against the most common LLM mistakes:

1. JSON-Schema validation. Compile each tool's input_schema once at
   registry build via jsonschema-rs. Validate args before execute;
   surface the JSON-pointer path of every violation so the model can
   self-correct next turn. Skip silently when a schema fails to compile.

2. Strict-ready schemas. Every in-tree tool now declares
   additionalProperties: false and lists every property in required.
   Prerequisites for OpenAI + Anthropic strict modes (grammar-constrained
   sampling at the API layer).

3. Tool-name normalization. Models emit read_file, readFile, read-file,
   tool.read_file, functions::read_file. Strip namespace prefix, split
   on case boundaries, lowercase, snake_case before registry lookup.

4. Windows path traps. resolve_within rejects verbatim namespace,
   device namespace, UNC paths, and reserved DOS device names
   (CON, PRN, AUX, NUL, COM1-9, LPT1-9). Plus coerce_to_relative
   converts workspace-absolute paths to relative and forward-slash
   to platform sep on Windows.
…epair

Wave-1 provider plumbing for cache + strict mode:

1. ToolDef gains a strict bool. Anthropic and OpenAI tag tools with
   strict: true so the API runs grammar-constrained sampling at the
   token level. Gemini / Ollama / LM Studio ignore the flag silently.
   The runtime defaults strict to true for in-tree tools and false
   for MCP-injected tools whose schemas come from third parties.

2. TokenUsage carries cache_read_tokens + cache_creation_tokens. OpenAI
   parses input_tokens_details.cached_tokens (50% discount on the
   cached prefix, automatic above 1024 tokens). Anthropic parses both
   cache_read_input_tokens + cache_creation_input_tokens. Gemini /
   Ollama / LM Studio default to 0.

3. Anthropic prompt caching with the Continue.dev optimized strategy:
   one breakpoint each on system, last tool def, last 2 user/tool
   messages (4 total, the API max). Skipped under the per-model min
   (Sonnet 4.6 at 2048, Opus 4.7 + Haiku 4.5 at 4096) so we never eat
   a write that gets silently rejected.

4. Lightweight repair for truncated tool args on Anthropic streams.
   When max_tokens cuts the JSON mid-string, balance the open quote
   then the brackets/braces. Falls back to {} with a warning on the
   stream when repair fails. Same path emits a recovery warning when
   it succeeds, so partial argument data still reaches the model.
Two bundled changes that share the runtime file:

1. Imperative error feedback. Tool errors used to land in the model as
   a JSON envelope ({"error": ...}). Small models attend to imperative
   prose far more reliably than to blobs, so dispatch failures now
   ship as TOOL_ERROR (tool): msg with a contextual HINT and the
   instruction Try again with corrected arguments OR explain in plain
   text. Permission denials surface as TOOL_DENIED with a hard rule
   not to retry. repair_hint_for picks tool-specific hints from the
   error text (escapes workspace, no such file, is a directory,
   propose_edit no match, write_file too large).

2. dedupe_file_reads pass before each provider call. When the same
   read_file(path) or list_dir(path) appears multiple times in
   history, every Tool result before the latest is replaced with
   a small NOTE deduplicated marker. Cuts tool-heavy conversation
   history 30-60% on average without losing semantics — the latest
   result is always preserved. Cline pattern; same file-context
   tracker shape.

3. Tool-name normalization helper mirrored from dispatcher so the
   fallback parser (extract_fallback_tool_calls) also coerces
   readFile / read-file / functions.read_file before registry lookup.
Two new modules under daisu-agent::memory:

- tokens: o200k_base BPE estimator wrapping tiktoken-rs. Used as a
  universal proxy across providers (Anthropic + Gemini are also BPE,
  drift ~10% — well inside our budgeting margin). The provider's
  official count_tokens endpoints add 100-300ms per call so they stay
  reserved for offline cost reports. count() / count_message() /
  count_messages() share a Lazy CoreBPE.

- window: token-budget sliding window. Drops oldest until total
  tokens fit 75% of the model context, but always preserves the
  first 2 messages (anchors the original task) and cascades the
  drop to tool_use/tool_result pairs (Anthropic, OpenAI and Gemini
  all reject orphan tool_results). context_window_for() maps Claude/
  GPT-5/Gemini/Ollama to their per-family ceilings.
read_file gains optional offset (0-indexed) + limit (default 2000, hard
cap 2000) args. Output is now an envelope:
  {path, bytes, total_lines, shown_lines: [start, end], truncated, contents}
plus a hint string when truncated. Lines longer than 2000 chars are cut
mid-line so a minified bundle can't drown the model. Mirrors Claude
Code's read tool conventions.

list_dir caps at 200 entries with truncated:true + total + hint when
exceeded. Entries sorted dirs-first then alphabetical so the model gets
a stable scan-friendly order. The hint nudges the model to narrow the
path or use targeted search instead of paginating forever.
Two compaction passes added to the agent loop:

1. Sliding window: after dedupe_file_reads, slide the message buffer
   to 75% of context_window_for(model) before sending to the provider.
   tool_use/tool_result pairing and the first 2 anchor messages stay
   intact; the rest gets dropped from the head when over budget.

2. Per-turn cache: a HashMap<(name, args_canonical_json), ToolResult>
   wrapping the dispatch loop. Two parallel read_file calls with
   identical args in a single assistant turn now hit disk once. Cache
   resets between turns so tool results stay fresh on the next loop.
Honour the cross-tool AGENTS.md convergence (Codex, Cursor, Zed,
Aider, RooCode, Goose, ~20 others) plus Claude Code's CLAUDE.md
fallback. Reads the first present file from this order, trimmed,
capped at 32 KiB:

  .daisu/AGENTS.md → AGENTS.md → CLAUDE.md → .cursorrules

Appended at the system-prompt tail (after Daisu defaults + mode
addendum) so small local models attend to it most strongly. Full
rules system with frontmatter + globs + watcher + UI lands in M5;
this minimal pass already gives users a one-file escape hatch
without forking the agent.
Three new components feed the upgraded ToolBlockView:

- ToolStatusBadge: pending/running/done/errored/denied pill with
  three-dot pulse, spinner, check, X, prohibit. role=status +
  aria-live=polite so screen readers announce state transitions.

- StreamingJson: renders tool args buffer with a blinking caret while
  the model streams partial_json deltas. JSON.parse on done for pretty
  indentation; falls back to raw on parse failure (truncated stream).

- ToolResultRenderer: per-tool dispatcher.
  - read_file: header with basename + lines + bytes + truncated pill,
    code body with auto-collapse when >20 lines, hint surfaced when
    output is paginated, range label for offset+limit visibility.
  - list_dir: tree grid with Phosphor Folder/File icons, dir/file/total
    counts, hint when capped at 200.
  - write_file: minimal confirmation card with bytes written.
  - errored: red panel with error / denied text extracted from envelope.
  - default: pretty-printed JSON dump.
ChatPanel.tsx integrates the new tool components:

- ToolBlockView replaces inline rendering: status pill, latency footer
  (completedAt - startedAt), one-line argument summary in the header
  (path / pattern / command), auto-expand while running and on errored
  results, auto-collapse on success. User toggle sticks once clicked.
- StreamingJson + ToolResultRenderer wired into the body so result
  envelopes (read_file pagination, list_dir tree, write_file confirm)
  render natively instead of as raw JSON.
- agent-messages container now declares role=log + aria-live=polite +
  aria-relevant=additions so screen readers announce tool progress
  and result additions.
- agentStore.ToolBlock gains startedAt / argsAt / completedAt; the
  stream listener stamps timestamps on toolUseStart / toolUseDone /
  toolResult so latency is computable client-side.
- styles.css: tool-badge with status colours + caret-blink + spin
  keyframes, streaming JSON panel, tool-result tree grid + code body
  with collapse toggle, error variant, hint + range labels.
- i18n: tool status labels, truncated/show-all, and per-tool meta
  formatters for en / es (neutral) / ja.
@matiaspalmac
Copy link
Copy Markdown
Owner Author

@copilot review this entire pr

Copy link
Copy Markdown
Contributor

Copilot AI commented May 6, 2026

@copilot review this entire pr

Here's a full review of the PR. Overall the direction is right and the core fix (Radix Select replacing the broken <datalist>) works well. A few bugs and inconsistencies need attention:


🔴 Bugs

1. Hardcoded Spanish string "Cancelado" in agentStore.ts (lines 245 & 374)
The cancel handler sets warning: "Cancelado" on the pending message, which is then rendered directly in ChatPanel.tsx:284-286. This will show Spanish to all users regardless of locale. agentStore doesn't have a t() hook, but the string can simply be the English fallback "Cancelled" (the i18n t() call belongs at render time in the component, not in the store).

2. Comment/code mismatch in ToolBlockView — no 400ms timer
The comment on line 302 reads "auto-collapse 400ms after a successful result" but there is no setTimeout or useEffect timer in the code. Successful calls collapse immediately when status transitions to "result" with ok === true. The comment is misleading; either add the delay or fix the comment.

3. Potential re-render loop in AiSettings Ollama probe effect
The effect's dependency array is [ai.id, ai.ollamaBaseUrl, ai.model, setSetting]. When the effect fires and setSetting("aiProvider", { model: best }) is called, ai.model changes, which schedules another run. The guard if (best && best !== ai.model) prevents infinite recursion, but it still fires a full /api/tags HTTP probe on every model change (including ones triggered by other parts of the UI). ai.model should not be in the deps here — the probe is only meaningful when the provider or base-URL changes. The current-model value can be read via a ref or passed directly inside the .then() callback.


🟡 Inconsistencies / Design Issues

4. ModelInlinePicker.handleProviderChange doesn't probe Ollama
AiSettings.handleSelect probes /api/tags and auto-picks the best installed model when switching to Ollama. ModelInlinePicker.handleProviderChange does not — it uses the static defaultModel (likely qwen3-coder). A user switching providers via the inline chat-panel picker will still hit the original 404 issue the PR intends to fix. The probing logic from handleSelect should be mirrored here.

5. Permission UX regression: PermissionInline is only visible inside ChatPanel
The old <Dialog> was mounted at the app root (App.tsx:405) and appeared regardless of active view. PermissionInline is now rendered only inside ChatPanel. If the user has Settings open or any view other than the chat panel, a agent://permission-request event will be received and queued in the store, but no UI will be shown. The agent run will stall indefinitely. Consider either keeping a fallback <Dialog> in PermissionModal for out-of-chat-panel contexts, or routing the request to a toast/notification layer.

6. denyEdit button still calls decide("deny") — identical to the plain Deny button
The inline permission prompt has two buttons — "Deny" and "Deny + edit" — that execute exactly the same code path (void decide("deny")). While this was also true in the old modal, having two indistinguishable buttons in a more prominent inline UI is more confusing. Either wire the distinct behavior or collapse them into one for now.

7. ChatMode type is duplicated
agentStore.ts exports ChatMode as a named type. agent.ts (lib layer) re-declares the same union inline: chatMode?: "auto" | "chat" | "agent" | "plan". agent.ts should import ChatMode from agentStore.ts (or from a shared types file) so there's a single source of truth.


🔵 Minor / Style

8. // eslint-disable-next-line react-hooks/exhaustive-deps at AiSettings.tsx:224
fetchModels is an async function defined in the component body that closes over mutable state but is omitted from the deps array. Wrap it in useCallback (or inline the listProviderModels call) instead of suppressing the lint rule, to avoid stale-closure bugs as the function evolves.

9. IIFE inside JSX for the model dropdown
{(() => { ... })()} in the render body of AiSettings is unusual and reduces readability. Extracting to a named local variable or a small sub-component (<ModelDropdown ... />) would be cleaner.

10. ToolStatus type includes "pending" but ToolBlockView never assigns it
The ToolStatus export lists "pending" as a valid value, but badgeStatus in ToolBlockView is initialized to "running" and never set to "pending". Either remove the "pending" variant from the type or add the assignment path.

11. ToolResultRendererhint text comes from Rust in English and is shown directly to the user
read_file's hint field ("Showed X of Y lines. Re-call with offset/limit to see the rest.") is generated in Rust with a hardcoded English string and rendered verbatim in ReadFileResult. This is fine for model-facing hints but the same field is shown to users in the UI via <p className="daisu-tool-result-hint">{o.hint}</p>. Consider either not surfacing it in the UI or adding a localized equivalent.

Replace hardcoded "Cancelado" warning in agentStore with the i18n key
chat.cancelled (en/es/ja). Without this every user — English or Japanese —
saw a Spanish word the moment they hit Cancel on a streaming run.

Drop the truncated-hint paragraph from ReadFileResult and ListDirResult.
The Rust tools generate the hint as English prose for the model
("Showed X of Y lines. Re-call with offset/limit to see the rest."), it
isn't a user-facing string. The truncated pill plus the localized
"Showing lines X–Y of Z" label already convey the same information in
the user's locale.
…dden

The inline permission prompt only renders inside ChatPanel, so a
permission request that arrived while the chat panel was hidden, in
config mode, or in focus mode would queue forever in the store with no
visible UI — every agent run waiting on permission silently stalled.

Track an inlineMounted flag in permissionStore that PermissionInline
sets on mount and clears on unmount. PermissionModal at the app root
now renders a centered Dialog with the same prompt body whenever a
request is pending and the inline prompt is not mounted, so the user
always sees the prompt regardless of which view they're in.

Drop the duplicate "Deny + edit" button — it called the exact same
decide("deny") path as the plain Deny button. The variant can come
back when there's actual edit-prompt UI behind it.
Drop ai.model from the AiSettings probe useEffect dependency list. The
probe is only meaningful when the provider or base URL changes; reading
the current model via a ref avoids one /api/tags HTTP call per
keystroke in the model field.

Wrap fetchModels in useCallback so the auto-fetch effect can list it as
a dependency cleanly without disabling react-hooks/exhaustive-deps.

Extract autoPickInstalledOllamaModel in lib/ollama-detect that probes
and falls back to pickBestModel in one round trip. AiSettings.handleSelect
and ModelInlinePicker.handleProviderChange now both go through it, so
switching to Ollama from the inline header picker also lands on the
best installed tag — without this the inline picker reproduced the same
404 the original PR was meant to fix.

Lift the in-render IIFE that builds the model dropdown into a
ModelDropdown sub-component for readability.
…nding

Move the canonical ChatMode union into lib/agent.ts (lower layer) and
re-export from agentStore. Both files used to declare the same
"auto" | "chat" | "agent" | "plan" union inline; one source of truth
keeps SendMessageOptions and the store in lockstep.

Drop "pending" from ToolStatus. ToolBlockView never assigned it — args
streaming is "running", awaiting dispatch is also "running", so the
pulse code path was dead and the only effect of keeping it was a
phantom branch in the badge mapping.

Tidy the badge mapping in ToolBlockView while there: collapse the
running/done branches into one and rewrite the stale "auto-collapse
400ms" comment that promised a timer the code never had.
…ollbars

User report: opening a file tab, jumping to the Home (Inicio) tab, then
clicking the file tab again left the editor permanently black until the
window was resized.

Root cause was the conditional render in EditorArea — clicking Home set
activeTabId to null, which switched the JSX from Editor to WelcomeScreen
and disposed the Monaco instance. The remount on the way back raced
through theme registration / layout / model swap and frequently landed
on a 0×0 viewport that automaticLayout didn't recover from. Tracked in
microsoft/monaco-editor#2057, #2294, #4306.

Switch to the canonical multi-tab pattern: Editor stays mounted, the
WelcomeScreen overlays on top via absolute positioning when no file is
active, and the underlying Editor host gets visibility:hidden so it
keeps a measurable layout. Editor also now forces a layout() call in
the next frame whenever the active tab transitions back to a real file,
so any residual viewport drift is corrected before the next paint.

While there: surface scrollbars permanently when content overflows
(scrollbar.vertical/horizontal: "visible" + matching slider sizes).
Default Monaco fades the scrollbar after a couple of seconds, so users
who clicked elsewhere in the IDE lost their overflow indicator.
Sourced from microsoft/monaco-editor IEditorScrollbarOptions docs +
issue #1686.
User reported a slight lag when moving the mouse fast over the window.
The biggest contributor on Tauri 2 / WebView2 is the
data-tauri-drag-region attribute: every mousemove over a drag area
hits the window plugin's internal_on_mousemove IPC for cursor updates.
With a flex-1 spacer covering most of the title bar, that flood is
hundreds of IPC calls per second of motion.

tauri-apps/tauri#8770, #10767, #12597 track the flood. Even with the
PR-side throttling that landed in Tauri 2.x, eliminating the
attribute on a wide drag surface is still a net win.

Replace the spacer's data-tauri-drag-region attribute with manual
onMouseDown -> startDragging() and onDoubleClick -> toggleMaximize()
handlers. The OS still gets the drag intent (Aero Snap, edge maximize),
but the per-frame IPC chatter goes away — only mousedown / dblclick
fire across the bridge now.
Two regressions from the previous editor commit:

1. The "always-mounted" Editor was rendered with absolute positioning
   inside a flex parent. The .daisu-editor-host class already declares
   flex: 1 + position: relative, so the override interacted badly and
   left a translucent overlay on top of Monaco.

2. alwaysConsumeMouseWheel was flipped to false. The official Monaco
   TypeDoc is clear: that option is purely a propagation switch (calls
   preventDefault + stopPropagation on wheel). With it false, hovering
   the editor without focus lets the wheel event bubble to the nearest
   scrollable ancestor — so after clicking the file explorer, scrolling
   over Monaco did nothing until you clicked into it. VS Code, Cursor
   and every other Monaco-based IDE keep the default true. Refs:
   microsoft/monaco-editor#69, #4599, suren-atoyan/monaco-react#262.

Switch EditorArea to a guarded display:none mount: the Editor lives
inside the standard daisu-editor-host (no absolute positioning) and is
only created the first time a real file becomes active. After that it
stays mounted across Home tab visits via display:none — no remount,
no canvas churn, no overlay artefacts. Editor.tsx already calls
editor.layout() in a rAF whenever activeTabId flips back, so the
display:none → block transition repaints cleanly.

Drop alwaysConsumeMouseWheel from the scrollbar config so it falls
back to Monaco's true default and scrolling works on hover.
…onaco

Reported: after clicking the sidebar/explorer, hovering the editor and
turning the scroll wheel did nothing — the user had to click into Monaco
first. Every other editor (VS Code, Cursor, Sublime, Zed) scrolls on
hover without needing focus.

Root cause is WebView2-specific. Win32 delivers WM_MOUSEWHEEL to the
window with focus, not the window under the cursor. Chromium itself
routes wheel by hit-test, but only after the message reaches it. Inside
WebView2 the input-routing layer between the host HWND and Chromium
re-introduces the Win32 focus dependency, so a wheel event over Monaco
gets dropped while focus lives on the sidebar. VS Code doesn't show the
bug because Electron embeds Chromium directly with its own message
pump. Tracked upstream:
  - MicrosoftEdge/WebView2Feedback#829
  - MicrosoftEdge/WebView2Feedback#3769
  - microsoft/microsoft-ui-xaml#2931
  - tauri-apps/wry#616

Workaround: install a passive pointerenter handler on Monaco's DOM node
that calls editor.focus() — but only when the previously focused
element isn't an editable input/textarea/contenteditable region. That
preserves typing focus in the chat composer, settings forms and
permission prompts while letting the wheel reach Monaco the moment the
mouse crosses into the editor.

The skip-when-already-inside check avoids the secondary nuisance of
re-focusing scrolling to the cursor whenever the mouse re-enters the
viewport.
The previous "always-visible scrollbars" change set the right Monaco
options but the daisu-nocturne theme registered slider colours at
~10x lower alpha than VS Code's defaults (0.039 / 0.078 / 0.20 vs
0.4 / 0.7 / 0.4). Result: the scrollbar was forced visible but
functionally invisible against the dark canvas — users still couldn't
tell where they were in a long file.

Bring slider alphas in line with VS Code Dark+ while keeping the
kintsugi-gold tint on the active state (cited from microsoft/vscode
miscColors.ts). Also bump verticalScrollbarSize from 12 to 14 to match
VS Code's EditorScrollbar defaults, and pin mouseWheelScrollSensitivity
/ fastScrollSensitivity / mouseWheelZoom to their documented defaults
so a future Monaco bump can't silently drift the scroll feel.
Two more sources of mousemove jank surfaced by deeper research:

1. core:window:default in capabilities/default.json brings in the
   internal mousemove IPC handler that fires across the entire
   webview, not only inside data-tauri-drag-region elements
   (tauri-apps/tauri#8770). The previous TitleBar fix removed the
   drag-region attribute but the capability kept the handler armed.
   Replace the bundle with the explicit allow-* permissions Daisu
   actually uses (start-dragging, start-resize-dragging, minimize,
   toggle-maximize, close) — same surface, no flood.

2. WebView2's Edge mouse-gesture recognizer intercepts every move to
   detect gestures we don't use. Disable via additionalBrowserArgs
   (--disable-features=msEdgeMouseGestureSupported,
   msEdgeMouseGestureDefaultEnabled). While there, opt into
   --enable-zero-copy and --enable-gpu-rasterization so paint work
   stays on the GPU instead of the CPU compositor. Refs:
   tauri-apps/tauri#7692, MicrosoftEdge/WebView2Feedback#5072,
   #1469.
Three fixes triggered by a DevTools HAR + Performance trace.

LSP status chip was polling lsp_servers_status every 3 s. The HAR
showed 274 IPC calls in 9 minutes (~30/min) of idle time, eating Tauri
thread budget for transitions the lsp://server-ready and
lsp://workspace-opened listeners already deliver. Drop to 15 s — only
matters as a fallback when the backend exits without firing any event.

DevTools also flagged CLS 0.44 ("poor"), 86 layout shifts in the worst
cluster. The dominant sources were:

  - Cursor "Ln X, Col Y" + tab counters in the status bar; default
    fonts have variable digit widths so siblings slid every keystroke.
    Added font-variant-numeric: tabular-nums on .daisu-statusbar.
  - Editor breadcrumb appearing/disappearing as activeTabId flipped
    between Home and a real file. Pinned min-height + tabular-nums.
  - Pending assistant message growing line-by-line as deltas streamed.
    Reserve ~3 lines of min-height + contain: layout on the message
    body so the scroll viewport doesn't reflow per delta.
…ures

Three zero-risk memory wins:

1. xterm scrollback 5000 -> 2000 lines per terminal. Each line carries
   a parsed cell buffer; halving the cap saves ~3 MB per open terminal
   without affecting realistic re-read windows for build/test output.

2. Disable Monaco's unicodeHighlight scan (ambiguous characters,
   invisible chars, non-basic ASCII). The feature targets security
   review of pasted prose for homoglyph/RTL trojans — useful in code
   review tooling, irrelevant for everyday editing. The decoder caches
   and per-line scans were a measurable share of editor memory.

3. Extend additionalBrowserArgs to disable WebView2 Edge features Daisu
   never uses: Translate (page translation), AutofillServerCommunication
   (no remote forms), OptimizationHints (ML UX nudges), MediaRouter
   (cast to TV), InterestFeedContentSuggestions (news feed),
   CalculateNativeWinOcclusion (browser-tab occlusion detection — a
   Tauri window is always foreground when visible).
Three deferral wins surfaced by parallel research on Tauri 2 + Monaco
IDE optimization (refs: tauri-apps/awesome-tauri, microsoft/monaco-
editor#1681 + #1987 + #318, MicrosoftEdge/WebView2Feedback memory
target spec, Sidenai/sidex):

1. Code-split SettingsModal, CommandPalette, SymbolSearchPalette,
   LspWorkspaceSymbolPalette, FileSymbolPalette, PermissionModal and
   InlineEditOverlay behind React.lazy() + a single Suspense boundary.
   None are on the first-paint path; their Radix Dialog/Popover code,
   icon subsets and store hookups stay out of the main bundle until
   the user opens them.

2. Discord RPC handshake now waits for requestIdleCallback (with an 8s
   timeout fallback, and a 5s setTimeout for environments without
   ric). The previous 1.5s setTimeout still fired during the busy
   first-paint window where Monaco workers and LSP transport spawn —
   pushing Discord behind idle frees the main thread for those.

3. Disable Monaco's bracketPairColorization (was on, default in
   standalone Monaco is off). The bracket-pair tree grows per-model
   and is purely cosmetic; LSP diagnostics + tokenization render the
   same without it.
Quality Checks workflow on the PR was failing under Rust 1.95 because
the toolchain rolled in a few new -D-warnings lints that the existing
code tripped. None of them were behavioural bugs — all are style /
modernisation hints — but they block merge.

daisu-agent
- memory/tokens.rs: once_cell::sync::Lazy → std::sync::LazyLock
  (clippy::non_std_lazy_statics).
- memory/window.rs: scope cast lints onto the single u32→f32
  multiplication used to compute the sliding-window target. Range
  stays inside f32's exact-integer band so the precision warning is
  noise.
- provider/anthropic.rs: rewrap the cache-token estimator with
  map_or, mark EnvelopeUsage's Anthropic-mirroring field names
  intentional (they're wire-format names, can't rename without
  breaking serde), and flatten the nested match arm in the
  truncated-tool-args path into an if-let.
- tools/dispatcher.rs: hoist the reserved-DOS-name list to a module
  const + reformat the multi-line doc comment so the second
  bullet/continuation are properly indented.

daisu-app
- commands/agent.rs: backtick OpenAI / camelCase / PascalCase /
  None mentions in doc comments, lift the conversational-opener
  action-prefix list to a module const, switch a couple of
  map(...).unwrap_or(...) calls to map_or, drop a String to_string
  no-op, replace a needless_range_loop with iter_mut().skip,
  swap content = replacement.clone() for clone_into, lift a
  spawn_blocking-await out of a match scrutinee, and drop a
  .clone() on a Copy ToolChoice.

daisu-lsp
- tests/manager_smoke.rs: ResolutionPublic::Found is a struct
  variant since the M4 LSP serde fix; the test pattern still used
  the old tuple form.
The fallback parser strips fenced/bare JSON tool calls out of the
streamed assistant text and replaces them with empty regions. The
post-processing pass kept a single blank line between surrounding
prose, so a message like

    Sure! Here's the file:
    ```json
    {...}
    ```
    Let me know!

cleaned to

    Sure! Here's the file:

    Let me know!

instead of the contiguous paragraph the cleans_text_around_consumed_block
test expects. The CI failure under Rust 1.95 was the first run that
actually executed the test — earlier toolchains failed at clippy, which
ran before cargo test, so the regression went unnoticed.

Tighten collapse_blank_runs so it skips blank lines entirely while
joining non-blank lines with single newlines.
@matiaspalmac matiaspalmac merged commit e9d4fcf into main May 7, 2026
5 checks passed
@matiaspalmac matiaspalmac deleted the fix/ai-settings-ollama-dropdown branch May 7, 2026 04:49
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.

3 participants