diff --git a/.claude/skills/nexus-testing/refinement-log.md b/.claude/skills/nexus-testing/refinement-log.md index b1109bd15..23593c1d5 100644 --- a/.claude/skills/nexus-testing/refinement-log.md +++ b/.claude/skills/nexus-testing/refinement-log.md @@ -223,3 +223,11 @@ Used the existing loader suite with a fake clock to check production text update ### 2026-10-13 — Whole-turn header activity Expanded the actual store/reducer lifecycle test to cover reasoning completion, tool execution and answer streaming before terminal settlement. The existing selector failed the new expectation before the fix. The mock does not decide phases. No protocol change needed. + +### 2026-10-14 — Slider readout settled statically (defaults redesign, Phase 1 step 1a) + +The plan asked for an in-app `obsidian eval` check of slider markup. It was settled without touching a vault by reading the installed app bundle (`obsidian.asar` → `app.js`, Obsidian 1.14.4): `SliderComponent` creates its own `span.slider-value` before the range input, refreshes it on every `input` event through `updateDisplay()`, and formats it with `setDisplayFormat(fn)` (absent from the 1.12.3 typings). It also fires the change callback from `setValue` when the value changes, so register `onChange` after `setValue`. The duplicate "High 2" / "1,000,000 tokens 1000000" readouts were the plugin's own span next to that native one. `bindSliderReadout` now writes into the native element and adds one span only when none exists. The SliderComponent mock reproduces the native element in FakeElement mode, so a reintroduced second readout fails `SliderReadout.test.ts`. Still worth one in-app glance on 1.11/1.12, where the native element may be missing. No protocol change. + +### 2026-10-14 — `plugin:reload … vault=` reloaded the focused vault + +Linux, four vaults open. `obsidian plugin:reload id=nexus vault="Nexus Redesign Test"` did not reload the named vault: its plugin instance's `lifecycleManager.getInitializationStatus().startTime` did not move, while the focused Synaptic-Labs vault's did. The first call printed `Reloaded: nexus`, later ones printed nothing; neither output says which vault. What worked and is provably targeted: one guarded `eval` in the named vault that asserts `app.vault.getName()`, then runs `app.plugins.disablePlugin('nexus').then(() => app.plugins.enablePlugin('nexus'))` into a `window` status object, polled separately; confirm the new instance by its startTime. Here `vault=` came after the command, the shape step 3 of `live-loop.md` shows; the 2026-10-13 entry above found that putting it before the command targeted correctly, which was not retried. Never verify a reload by its command output. Prefer that in-band reload whenever more than one vault is open. diff --git a/.cline/skills/nexus-testing/refinement-log.md b/.cline/skills/nexus-testing/refinement-log.md index b1109bd15..23593c1d5 100644 --- a/.cline/skills/nexus-testing/refinement-log.md +++ b/.cline/skills/nexus-testing/refinement-log.md @@ -223,3 +223,11 @@ Used the existing loader suite with a fake clock to check production text update ### 2026-10-13 — Whole-turn header activity Expanded the actual store/reducer lifecycle test to cover reasoning completion, tool execution and answer streaming before terminal settlement. The existing selector failed the new expectation before the fix. The mock does not decide phases. No protocol change needed. + +### 2026-10-14 — Slider readout settled statically (defaults redesign, Phase 1 step 1a) + +The plan asked for an in-app `obsidian eval` check of slider markup. It was settled without touching a vault by reading the installed app bundle (`obsidian.asar` → `app.js`, Obsidian 1.14.4): `SliderComponent` creates its own `span.slider-value` before the range input, refreshes it on every `input` event through `updateDisplay()`, and formats it with `setDisplayFormat(fn)` (absent from the 1.12.3 typings). It also fires the change callback from `setValue` when the value changes, so register `onChange` after `setValue`. The duplicate "High 2" / "1,000,000 tokens 1000000" readouts were the plugin's own span next to that native one. `bindSliderReadout` now writes into the native element and adds one span only when none exists. The SliderComponent mock reproduces the native element in FakeElement mode, so a reintroduced second readout fails `SliderReadout.test.ts`. Still worth one in-app glance on 1.11/1.12, where the native element may be missing. No protocol change. + +### 2026-10-14 — `plugin:reload … vault=` reloaded the focused vault + +Linux, four vaults open. `obsidian plugin:reload id=nexus vault="Nexus Redesign Test"` did not reload the named vault: its plugin instance's `lifecycleManager.getInitializationStatus().startTime` did not move, while the focused Synaptic-Labs vault's did. The first call printed `Reloaded: nexus`, later ones printed nothing; neither output says which vault. What worked and is provably targeted: one guarded `eval` in the named vault that asserts `app.vault.getName()`, then runs `app.plugins.disablePlugin('nexus').then(() => app.plugins.enablePlugin('nexus'))` into a `window` status object, polled separately; confirm the new instance by its startTime. Here `vault=` came after the command, the shape step 3 of `live-loop.md` shows; the 2026-10-13 entry above found that putting it before the command targeted correctly, which was not retried. Never verify a reload by its command output. Prefer that in-band reload whenever more than one vault is open. diff --git a/.codex/skills/nexus-testing/refinement-log.md b/.codex/skills/nexus-testing/refinement-log.md index b1109bd15..23593c1d5 100644 --- a/.codex/skills/nexus-testing/refinement-log.md +++ b/.codex/skills/nexus-testing/refinement-log.md @@ -223,3 +223,11 @@ Used the existing loader suite with a fake clock to check production text update ### 2026-10-13 — Whole-turn header activity Expanded the actual store/reducer lifecycle test to cover reasoning completion, tool execution and answer streaming before terminal settlement. The existing selector failed the new expectation before the fix. The mock does not decide phases. No protocol change needed. + +### 2026-10-14 — Slider readout settled statically (defaults redesign, Phase 1 step 1a) + +The plan asked for an in-app `obsidian eval` check of slider markup. It was settled without touching a vault by reading the installed app bundle (`obsidian.asar` → `app.js`, Obsidian 1.14.4): `SliderComponent` creates its own `span.slider-value` before the range input, refreshes it on every `input` event through `updateDisplay()`, and formats it with `setDisplayFormat(fn)` (absent from the 1.12.3 typings). It also fires the change callback from `setValue` when the value changes, so register `onChange` after `setValue`. The duplicate "High 2" / "1,000,000 tokens 1000000" readouts were the plugin's own span next to that native one. `bindSliderReadout` now writes into the native element and adds one span only when none exists. The SliderComponent mock reproduces the native element in FakeElement mode, so a reintroduced second readout fails `SliderReadout.test.ts`. Still worth one in-app glance on 1.11/1.12, where the native element may be missing. No protocol change. + +### 2026-10-14 — `plugin:reload … vault=` reloaded the focused vault + +Linux, four vaults open. `obsidian plugin:reload id=nexus vault="Nexus Redesign Test"` did not reload the named vault: its plugin instance's `lifecycleManager.getInitializationStatus().startTime` did not move, while the focused Synaptic-Labs vault's did. The first call printed `Reloaded: nexus`, later ones printed nothing; neither output says which vault. What worked and is provably targeted: one guarded `eval` in the named vault that asserts `app.vault.getName()`, then runs `app.plugins.disablePlugin('nexus').then(() => app.plugins.enablePlugin('nexus'))` into a `window` status object, polled separately; confirm the new instance by its startTime. Here `vault=` came after the command, the shape step 3 of `live-loop.md` shows; the 2026-10-13 entry above found that putting it before the command targeted correctly, which was not retried. Never verify a reload by its command output. Prefer that in-band reload whenever more than one vault is open. diff --git a/.gitignore b/.gitignore index 02bc51919..10451e51a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -node_modules/ +node_modules .jest-cache/ # Runtime-vendored engine assets (downloaded once at runtime from the pinned CDN, diff --git a/.skills/nexus-testing/refinement-log.md b/.skills/nexus-testing/refinement-log.md index b1109bd15..23593c1d5 100644 --- a/.skills/nexus-testing/refinement-log.md +++ b/.skills/nexus-testing/refinement-log.md @@ -223,3 +223,11 @@ Used the existing loader suite with a fake clock to check production text update ### 2026-10-13 — Whole-turn header activity Expanded the actual store/reducer lifecycle test to cover reasoning completion, tool execution and answer streaming before terminal settlement. The existing selector failed the new expectation before the fix. The mock does not decide phases. No protocol change needed. + +### 2026-10-14 — Slider readout settled statically (defaults redesign, Phase 1 step 1a) + +The plan asked for an in-app `obsidian eval` check of slider markup. It was settled without touching a vault by reading the installed app bundle (`obsidian.asar` → `app.js`, Obsidian 1.14.4): `SliderComponent` creates its own `span.slider-value` before the range input, refreshes it on every `input` event through `updateDisplay()`, and formats it with `setDisplayFormat(fn)` (absent from the 1.12.3 typings). It also fires the change callback from `setValue` when the value changes, so register `onChange` after `setValue`. The duplicate "High 2" / "1,000,000 tokens 1000000" readouts were the plugin's own span next to that native one. `bindSliderReadout` now writes into the native element and adds one span only when none exists. The SliderComponent mock reproduces the native element in FakeElement mode, so a reintroduced second readout fails `SliderReadout.test.ts`. Still worth one in-app glance on 1.11/1.12, where the native element may be missing. No protocol change. + +### 2026-10-14 — `plugin:reload … vault=` reloaded the focused vault + +Linux, four vaults open. `obsidian plugin:reload id=nexus vault="Nexus Redesign Test"` did not reload the named vault: its plugin instance's `lifecycleManager.getInitializationStatus().startTime` did not move, while the focused Synaptic-Labs vault's did. The first call printed `Reloaded: nexus`, later ones printed nothing; neither output says which vault. What worked and is provably targeted: one guarded `eval` in the named vault that asserts `app.vault.getName()`, then runs `app.plugins.disablePlugin('nexus').then(() => app.plugins.enablePlugin('nexus'))` into a `window` status object, polled separately; confirm the new instance by its startTime. Here `vault=` came after the command, the shape step 3 of `live-loop.md` shows; the 2026-10-13 entry above found that putting it before the command targeted correctly, which was not retried. Never verify a reload by its command output. Prefer that in-band reload whenever more than one vault is open. diff --git a/README.md b/README.md index 1f7dc23ae..ef2970597 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Release](https://img.shields.io/github/v/release/ProfSynapse/claudesidian-mcp?label=release)](https://github.com/ProfSynapse/claudesidian-mcp/releases) [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -[![Obsidian](https://img.shields.io/badge/Obsidian-1.10%2B-6f42c1)](https://obsidian.md/plugins) +[![Obsidian](https://img.shields.io/badge/Obsidian-1.11%2B-6f42c1)](https://obsidian.md/plugins) [![Node](https://img.shields.io/badge/node-%3E%3D18-43853d)](package.json) # Nexus MCP for Obsidian @@ -13,7 +13,7 @@ Nexus gives AI agents and built-in chat access to your Obsidian vault so you can Nexus can be used in two ways: - Inside Obsidian with native chat (hook up to your favorite provider or agentic platform!) -- From external agents like Claude Desktop, Claude Code, Codex CLI, Gemini CLI, Cursor, Cline, and other MCP clients +- From external agents like Claude Desktop, Claude Code, Codex CLI, Antigravity, Cursor, Cline, and other MCP clients > Nexus is the successor to Claudesidian. Legacy installs in `.obsidian/plugins/claudesidian-mcp/` still work. @@ -81,7 +81,7 @@ Ollama and LM Studio talk to your own machine and leave it entirely. | If you want to... | Start here | |---|---| -| Connect Claude Desktop, Codex CLI, Gemini CLI, Cursor, Cline, or another MCP client | [MCP setup](guide/mcp-setup.md) | +| Connect Claude Desktop, Codex CLI, Antigravity, Cursor, Cline, or another MCP client | [MCP setup](guide/mcp-setup.md) | | Drive your vault from the shell with Claude Code, Cursor, or Codex — no MCP config | [Nexus CLI](guide/nexus-cli.md) | | Configure built-in chat providers inside Obsidian | [Provider setup](guide/provider-setup.md) | | Use live voice, read notes aloud, or save spoken audio back into your notes | [Native chat](guide/native-chat.md) | diff --git a/docs/changelog.md b/docs/changelog.md index 7eeefdcd6..4d5cc8e84 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,45 @@ ## October 2026 +**Unreleased** — Redesigned Defaults and Chat settings, Get started pages, subagent settings, and semantic search you can turn on and off + +> Draft for the next release. Version number and PR links are added when it is cut. + +**Requires Obsidian 1.11.0** +- Nexus now needs Obsidian 1.11.0 or later, because the redesigned settings use Obsidian's setting groups. Obsidian 1.10 keeps offering Nexus 5.20.x. + +**Redesigned Defaults tab and Chat settings** +- Defaults and Chat settings share one layout: Provider, Model and Thinking at the top, then collapsed sections (Chat options, Context, Subagents, Images, Voice, and on Defaults also Video, File conversion and Semantic search). Each section summarizes its settings in one line, and Nexus remembers which ones you left open on each device. +- The rows follow the selected model. **Thinking** lists only the levels the model distinguishes, and thinking is not sent to models without reasoning support. **Creativity** is disabled for models that set their own temperature. **Context window** snaps to preset sizes (32K up to 1M) below the model's maximum, plus the maximum itself. +- The **Context window** in Chat settings now applies only to that conversation and no longer changes your Defaults. Changing Defaults no longer alters chats that are already open; new chats use them. Chat settings saved before any chat is open carry into the next new chat. +- Choosing a workspace on Defaults selects the prompt bound to that workspace. Switching models on Defaults keeps each model's saved context window. +- With no provider set up, **Open settings** on the chat welcome screen opens the Providers tab. + +**Subagents use their own model and settings** +- Subagents and `executePrompt` runs use the configured subagent model even when the chat model is a cloud model. Before, they inherited the chat model. Without a subagent model, they fall back to the cloud chat model, or to the first cloud provider with a default model when the chat model is local. +- The Subagents section has its own **Creativity** and **Context window**. Set them on Defaults, or for one conversation in Chat settings. The context window also limits how much workspace data and context files a subagent receives. Items that do not fit are dropped and listed in its prompt. + +**Get started** +- **Get started** is a row at the top of Nexus settings that opens setup pages inside the tab: **Chat inside Obsidian** and, on desktop, **Connect an external agent**. It opens by itself the first time settings are shown with nothing set up. +- **Connect an external agent** checks for Node.js first and shows the version and path it found, warns when the path belongs to nvm, and offers **Install Node.js** and **Check again** when it is missing. +- Claude Desktop, Claude Code, Codex and Antigravity connect with one click. Claude Code is added through `claude mcp add-json --scope user`, and Antigravity's config is written to `~/.gemini/config/mcp_config.json`. **Reconnect** repairs a connection whose Node.js or connector path has moved and keeps your other settings in that entry. An unreadable config is backed up before it is replaced, and the notice names the backup. Claude Desktop shows **Not found** with a download link when it is not installed. +- **Other agents** walks through four steps (create the connector, copy the config, add it to your app, restart) with paths for your computer. +- The Google sign-in provider and MCP client are called **Antigravity** everywhere in the app and guides; the old "Gemini CLI" name is gone. + +**Semantic search** +- Turning semantic search on or off takes effect immediately; no restart. Turning it off asks for confirmation first. A change synced from another device is also applied without a reload. +- While semantic search is on, the Semantic search section shows note and chat counts and indexing progress with an estimate and pause/resume. While it is off, **Delete index** removes the stored vectors and shrinks the cache file. Your notes and conversations are not touched. +- Each indexing pass removes entries for notes that were deleted or renamed and chats that were deleted while semantic search was off. +- With semantic search off, a semantic content search returns keyword results with a note that semantic search is turned off in Nexus settings, and the tool description stops advertising it. +- A model that fails to load is reported as an indexing error instead of finishing "complete" over an empty index. + +**Fixes** +- The semantic search model never finished loading when Settings or a popout window had focus at the moment it started, which is usually the case when you turn semantic search on. Initialization timed out, semantic search switched itself off, and indexing reported success with nothing stored. This also affects 5.20.4. Turning semantic search off no longer waits 30 seconds for the model to shut down. +- Semantic search of past conversations, semantic `[[` note suggestions in the chat composer, and semantic note suggestions when linking notes to tasks never used the index. They looked for an embedding service that was never registered and always fell back to keyword matching. They now reach the live index and pick up semantic search being turned on or off without reopening. +- Cache saves no longer fail after you close a popout or the Settings window that had focus when Nexus loaded (for example, when you enable Nexus from Settings). + +--- + **v5.20.4** — Gemini 4 Argon and a unified thinking accordion - Thinking appears in one accordion with typed words, cycling dots, a pulsing icon and a silver sweep. The header stays animated through tool use and answer streaming, then settles to a brain icon and **Reasoning** when the response finishes. Tool activity remains in the status bar ([#413](https://github.com/ProfSynapse/nexus/pull/413), [#415](https://github.com/ProfSynapse/nexus/pull/415)). diff --git a/docs/mockups/defaults-settings-essentials.css b/docs/mockups/defaults-settings-essentials.css new file mode 100644 index 000000000..8d2169a2d --- /dev/null +++ b/docs/mockups/defaults-settings-essentials.css @@ -0,0 +1,309 @@ +/* Obsidian default-theme approximations. Setting-group tokens and rules mirror app.css (1.13+), + captured from the running app; values are approximated for the default theme. */ +:root { + --background-primary: #1e1e1e; + --background-primary-alt: #242424; + --background-secondary: #262626; + --background-secondary-alt: #2e2e2e; + --background-modifier-border: #363636; + --background-modifier-border-hover: #4a4a4a; + --background-modifier-hover: rgba(255, 255, 255, 0.075); + --background-modifier-form-field: #2a2a2a; + --background-modifier-error: #d04255; + --interactive-normal: #363636; + --interactive-hover: #3f3f3f; + --interactive-accent: #7f6df2; + --interactive-accent-hover: #8875ff; + --interactive-accent-rgb: 127, 109, 242; + --text-normal: #dadada; + --text-muted: #a3a3a3; + --text-faint: #7a7a7a; + --text-accent: #a68af9; + --text-on-accent: #ffffff; + --text-warning: #e9973f; + --text-success: #44cf6e; + --text-error: #fb464c; + --font-interface: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Ubuntu, sans-serif; + --font-monospace: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --font-ui-smaller: 12px; + --font-ui-small: 13px; + --font-ui-medium: 15px; + --radius-s: 4px; + --radius-m: 8px; + --radius-l: 12px; + --shadow-l: 0 14px 40px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.3); + --input-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.09), 0 2px 3px rgba(0, 0, 0, 0.2); + --setting-items-background: #262626; + --setting-items-radius: 12px; + --setting-items-padding-x: 18px; + --setting-items-padding-y: 14px; + --setting-items-divider-color: #363636; + --setting-group-heading-size: 16px; + --setting-group-max-width: 700px; + --space-xxs: 2px; + --space-xs: 4px; + --space-s: 8px; + --space-m: 12px; + --space-l: 16px; + --space-xl: 24px; + --space-xxl: 32px; + --mock-backdrop: rgba(0, 0, 0, 0.55); + --mock-annotate: #3fb6c9; +} +body.theme-light { + --background-primary: #ffffff; + --background-primary-alt: #fafafa; + --background-secondary: #f6f6f6; + --background-secondary-alt: #ececec; + --background-modifier-border: #e0e0e0; + --background-modifier-border-hover: #c8c8c8; + --background-modifier-hover: rgba(0, 0, 0, 0.05); + --background-modifier-form-field: #ffffff; + --interactive-normal: #ffffff; + --interactive-hover: #f2f2f2; + --interactive-accent: #7852ee; + --interactive-accent-hover: #6d45e6; + --interactive-accent-rgb: 120, 82, 238; + --text-normal: #222222; + --text-muted: #5c5c5c; + --text-faint: #8a8a8a; + --text-accent: #6c3fe6; + --text-warning: #c46a00; + --text-success: #08a540; + --shadow-l: 0 14px 40px rgba(0, 0, 0, 0.14), 0 2px 8px rgba(0, 0, 0, 0.08); + --input-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.06); + --setting-items-background: #f4f4f4; + --setting-items-divider-color: #e3e3e3; + --mock-backdrop: rgba(0, 0, 0, 0.25); + --mock-annotate: #0b7f92; +} + +* { box-sizing: border-box; } +body { margin: 0; background: var(--background-secondary); color: var(--text-normal); font: 14px/1.5 var(--font-interface); } +button, select, input { font: inherit; color: inherit; } +button { cursor: pointer; } +:focus-visible { outline: 2px solid var(--interactive-accent); outline-offset: 2px; } +[hidden] { display: none !important; } +.nexus-icon { display: inline-grid; place-items: center; flex: 0 0 auto; } +svg { width: 16px; height: 16px; display: block; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } + +/* ---------- mock chrome ---------- */ +.mock-page { max-width: 1320px; margin: auto; padding: 48px var(--space-xxl) var(--space-xl); } +.mock-eyebrow { font: 11px var(--font-monospace); letter-spacing: .12em; color: var(--text-accent); } +.mock-intro h1 { font-size: 34px; line-height: 1.15; font-weight: 550; letter-spacing: -.03em; margin: var(--space-l) 0 var(--space-m); } +.mock-intro p { margin: 0; max-width: 760px; color: var(--text-muted); } +.mock-intro strong { color: var(--text-normal); font-weight: 550; } +.mock-toolbar { position: sticky; top: 0; z-index: 20; margin: var(--space-xl) 0; padding: var(--space-s) 0; background: var(--background-secondary); display: flex; gap: var(--space-s); align-items: center; flex-wrap: wrap; } +.mock-toolbar label { display: flex; align-items: center; gap: var(--space-s); color: var(--text-muted); font-size: 13px; } +.mock-toolbar > button, .mock-toolbar select, .mock-seg { border: 1px solid var(--background-modifier-border); border-radius: var(--radius-m); background: var(--background-primary); padding: var(--space-s) var(--space-m); min-height: 40px; font-size: 13px; } +.mock-toolbar > button:hover, .mock-toolbar > button[aria-pressed="true"] { border-color: var(--interactive-accent); color: var(--text-accent); } +.mock-seg { display: flex; padding: 3px; gap: 2px; } +.mock-seg button { border: 0; background: transparent; padding: 0 var(--space-m); border-radius: 5px; color: var(--text-muted); font-size: 13px; } +.mock-seg button[aria-checked="true"] { background: var(--interactive-accent); color: var(--text-on-accent); } +.mock-toolbar-spacer { flex: 1; } +.mock-link { color: var(--text-muted); font-size: 13px; padding: 0 var(--space-s); } +.mock-link:hover { color: var(--text-accent); } +.mock-layout { display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 40px; align-items: start; } +.mock-context { font-size: 13px; color: var(--text-muted); position: sticky; top: 72px; max-height: calc(100vh - 90px); overflow-y: auto; } +.mock-context h2 { font-size: 11px; font-weight: 600; color: var(--text-normal); text-transform: uppercase; letter-spacing: .06em; margin: 0 0 var(--space-s); } +.mock-context ul { padding-left: var(--space-l); margin: 0 0 var(--space-xl); } +.mock-context li { margin-bottom: var(--space-s); } +.mock-context strong { color: var(--text-normal); font-weight: 550; } +.mock-context code { font: 11px var(--font-monospace); color: var(--text-accent); } +.mock-p { margin: 0 0 var(--space-s); } +.mock-warn li::marker { color: var(--text-warning); } +.mock-caption { color: var(--text-faint); font-size: 12px; } +.mock-flag { display: inline-block; font: 10px var(--font-monospace); letter-spacing: .04em; color: var(--text-warning); border: 1px solid currentColor; border-radius: var(--radius-s); padding: 0 var(--space-xs); vertical-align: middle; white-space: nowrap; } +.mock-footer { margin-top: var(--space-xxl); padding-top: var(--space-l); border-top: 1px solid var(--background-modifier-border); font: 10px var(--font-monospace); color: var(--text-faint); display: flex; gap: var(--space-m); justify-content: space-between; } + +/* frames */ +.mock-settings-window { position: relative; height: 780px; border: 1px solid var(--background-modifier-border); border-radius: var(--radius-l); overflow: hidden; background: var(--background-primary); box-shadow: var(--shadow-l); } +.vertical-tab-content { height: 100%; overflow-y: auto; padding: var(--space-xl) 40px var(--space-xxl); } +.mock-unchanged { max-width: var(--setting-group-max-width); margin: 0 auto var(--space-xl); display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-xs); padding-bottom: var(--space-m); border-bottom: 1px dashed var(--background-modifier-border); opacity: .55; font-size: 12px; } +.mock-unchanged span { padding: 3px var(--space-s); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-s); } +.mock-unchanged .is-active { border-color: var(--interactive-accent); color: var(--text-accent); } +.mock-unchanged .mock-unchanged-label { border: 0; font: 10px var(--font-monospace); text-transform: uppercase; letter-spacing: .08em; color: var(--text-faint); padding-left: 0; } +.mock-chat-window { position: relative; width: 600px; height: 780px; margin: 0 auto; border: 1px solid var(--background-modifier-border); border-radius: var(--radius-l); overflow: hidden; background: var(--background-primary); box-shadow: var(--shadow-l); display: flex; align-items: center; justify-content: center; } +.mock-chat-behind { position: absolute; inset: 0; padding: var(--space-xl); display: flex; flex-direction: column; gap: var(--space-l); } +.mock-chat-behind div { height: 48px; border-radius: var(--radius-l); background: var(--background-secondary-alt); } +.mock-chat-behind div:nth-child(1) { width: 60%; margin-left: auto; } +.mock-chat-behind div:nth-child(2) { height: 120px; width: 90%; } +.mock-chat-behind::after { content: ""; position: absolute; inset: 0; background: var(--mock-backdrop); } +.mock-modal-layer { position: absolute; inset: 0; z-index: 5; background: var(--mock-backdrop); display: flex; align-items: center; justify-content: center; } + +/* ---------- Obsidian: Modal ---------- */ +.modal { position: relative; z-index: 1; width: calc(100% - 40px); max-width: 560px; max-height: calc(100% - 40px); display: flex; flex-direction: column; padding: var(--space-l) var(--space-l) var(--space-l); background: var(--background-primary); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-l); box-shadow: var(--shadow-l); } +.modal.mod-confirm { max-width: 440px; } +.modal-close-button { position: absolute; top: 12px; right: 12px; color: var(--text-faint); padding: var(--space-xs); border-radius: var(--radius-s); cursor: pointer; } +.modal-close-button:hover { background: var(--background-modifier-hover); color: var(--text-normal); } +.modal-title { font-size: 18px; font-weight: 600; margin: 0 0 var(--space-m); padding-right: 32px; } +.modal-content { overflow-y: auto; font-size: 14px; } +.modal-content p { margin: 0 0 var(--space-s); } +.modal-content ul { margin: 0 0 var(--space-s); padding-left: 20px; color: var(--text-muted); font-size: var(--font-ui-small); } +.modal-content li { margin-bottom: var(--space-xs); } +.modal-content .mod-muted { color: var(--text-muted); font-size: var(--font-ui-small); } +.chat-settings-renderer { margin: 0 calc(var(--space-l) * -1); padding: 0 var(--space-l); } +.modal-button-container { display: flex; justify-content: flex-end; gap: var(--space-s); margin-top: var(--space-l); } + +/* ---------- Obsidian: SettingGroup + Setting ---------- */ +.setting-group { width: 100%; max-width: var(--setting-group-max-width); margin-inline: auto; } +.setting-group + .setting-group { margin-top: var(--space-l); } +.modal .setting-group + .setting-group { margin-top: var(--space-m); } +.setting-group .setting-items { background-color: var(--setting-items-background); border-radius: var(--setting-items-radius); } +.setting-item { position: relative; display: flex; align-items: center; gap: var(--space-l); } +.setting-group .setting-item:not(.setting-item-heading) { padding: var(--setting-items-padding-y) var(--setting-items-padding-x); } +.setting-group .setting-item:not(.setting-item-heading)::before { content: ""; position: absolute; top: 0; left: var(--setting-items-padding-x); right: var(--setting-items-padding-x); border-top: 1px solid var(--setting-items-divider-color); } +.setting-group .setting-item:not(.setting-item-heading):first-child::before { content: none; } +.setting-item-heading { padding: 0 var(--space-l); margin-bottom: var(--space-s); } +.setting-item-heading .setting-item-name { font-size: var(--setting-group-heading-size); font-weight: 600; } +.setting-item-heading .setting-item-description { font-size: var(--font-ui-smaller); } +.setting-item-info { flex: 1 1 auto; min-width: 0; } +.setting-item-name { font-size: var(--font-ui-medium); line-height: 1.3; display: flex; align-items: center; gap: var(--space-s); } +.setting-item-description { color: var(--text-muted); font-size: var(--font-ui-smaller); padding-top: var(--space-xs); line-height: 1.35; } +.setting-item-description:empty { display: none; } +.setting-item-control { flex: 0 1 auto; display: flex; justify-content: flex-end; align-items: center; gap: var(--space-s); } +.setting-item-control:empty { display: none; } +.setting-item.is-disabled .setting-item-name { color: var(--text-muted); } +.setting-item-value { color: var(--text-muted); font-size: var(--font-ui-small); white-space: nowrap; } + +/* Accordion: the plugin's shared component, restyled. Closed = a quiet row; open = one grey card + holding the header and its settings. */ +.nexus-accordion { max-width: var(--setting-group-max-width); margin: var(--space-m) auto 0; border-radius: var(--setting-items-radius); } +.nexus-defaults > .setting-group + .nexus-accordion, .chat-settings-renderer > .setting-group + .nexus-accordion { margin-top: var(--space-l); } +.nexus-accordion.is-open { background: var(--setting-items-background); } +.nexus-accordion-toggle { display: flex; align-items: center; gap: var(--space-m); width: 100%; padding: var(--space-m) var(--setting-items-padding-x); border: 0; background: transparent; border-radius: var(--setting-items-radius); text-align: left; cursor: pointer; } +.nexus-accordion:not(.is-open) .nexus-accordion-toggle:hover { background: var(--background-modifier-hover); } +.nexus-accordion-icon { color: var(--text-accent); } +.nexus-accordion-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.nexus-accordion-title { font-size: var(--setting-group-heading-size); font-weight: 600; } +.nexus-accordion-summary { font-size: var(--font-ui-smaller); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.nexus-accordion-chevron { color: var(--text-faint); } +.nexus-accordion-content { max-width: none; } +.nexus-accordion-content .setting-items { background: transparent; border-radius: 0 0 var(--setting-items-radius) var(--setting-items-radius); padding-bottom: var(--space-xs); } +.nexus-accordion-content .setting-items > .setting-item:first-child::before { content: "" !important; } +.modal .nexus-accordion { margin-top: var(--space-s); } + +.mock-preview-label { max-width: var(--setting-group-max-width); margin: 0 auto var(--space-xs); padding-left: var(--setting-items-padding-x); font: 10px var(--font-monospace); text-transform: uppercase; letter-spacing: .08em; color: var(--text-faint); } +.mock-unchanged-after { margin-top: var(--space-l); } +.mock-preview-label + .nexus-accordion { margin-top: 0; outline: 1px dashed var(--background-modifier-border); } + +.nexus-subhead { padding-bottom: 0 !important; } +.nexus-subhead .setting-item-name { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); } +.nexus-subhead + .setting-item::before { content: none !important; } +.nexus-note-row { padding-top: 6px !important; padding-bottom: 6px !important; } +.nexus-note-row::before { content: none !important; } +.nexus-note-row .setting-item-name { font-size: var(--font-ui-small); color: var(--text-muted); padding-left: var(--space-m); } +.nexus-note-icon svg { width: 14px; height: 14px; color: var(--text-faint); } +.nexus-note .setting-item-name { font-size: var(--font-ui-small); color: var(--text-muted); } +.nexus-note { padding-bottom: 0 !important; } +.nexus-note + .setting-item::before { content: none !important; } +.nexus-mock-flag-row { padding: 0 var(--setting-items-padding-x) var(--setting-items-padding-y); font-size: 11px; color: var(--text-faint); } + +/* ---------- Obsidian: controls ---------- */ +select.dropdown { appearance: none; -webkit-appearance: none; height: 30px; padding: 0 28px 0 12px; border: 0; border-radius: var(--radius-s); background-color: var(--interactive-normal); box-shadow: var(--input-shadow); font-size: var(--font-ui-small); max-width: 260px; cursor: pointer; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m7 15 5 5 5-5'/%3E%3Cpath d='m7 9 5-5 5 5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; background-position: right 10px center; } +select.dropdown:hover { background-color: var(--interactive-hover); } +select.dropdown:disabled { opacity: .5; cursor: default; } +select.dropdown option { background: var(--background-primary); color: var(--text-normal); } +.setting-item-control input[type="text"] { height: 30px; width: 170px; padding: 0 var(--space-s); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-s); background: var(--background-modifier-form-field); font-size: var(--font-ui-small); } +.setting-item-control button, .modal-button-container button { height: 30px; padding: 0 14px; border: 0; border-radius: var(--radius-s); background: var(--interactive-normal); box-shadow: var(--input-shadow); font-size: var(--font-ui-small); white-space: nowrap; } +.setting-item-control button:hover, .modal-button-container button:hover { background: var(--interactive-hover); } +button.mod-cta { background: var(--interactive-accent) !important; color: var(--text-on-accent); box-shadow: none; } +button.mod-warning { background: var(--background-modifier-error) !important; color: var(--text-on-accent); box-shadow: none; } +.clickable-icon { display: grid; place-items: center; padding: var(--space-xs); border-radius: var(--radius-s); color: var(--text-faint); cursor: pointer; min-width: 26px; min-height: 26px; } +.clickable-icon:hover { background: var(--background-modifier-hover); color: var(--text-normal); } +.clickable-icon[aria-disabled="true"] { opacity: .35; cursor: default; } +.clickable-icon[aria-disabled="true"]:hover { background: transparent; } +.checkbox-container { position: relative; display: inline-block; width: 42px; height: 24px; border-radius: 12px; background: var(--background-modifier-border-hover); cursor: pointer; transition: background 150ms ease; flex: 0 0 auto; } +.checkbox-container input { position: absolute; opacity: 0; pointer-events: none; } +.checkbox-container::after { content: ""; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: var(--text-on-accent); transition: transform 150ms ease; box-shadow: 0 1px 2px rgba(0, 0, 0, .3); } +.checkbox-container.is-enabled { background: var(--interactive-accent); } +.checkbox-container.is-enabled::after { transform: translateX(18px); } +.slider-value { font-size: var(--font-ui-small); color: var(--text-muted); min-width: 30px; text-align: right; } +input.slider { -webkit-appearance: none; appearance: none; width: 120px; height: 4px; border-radius: 2px; background: var(--background-modifier-border-hover); cursor: pointer; } +input.slider::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: var(--text-on-accent); box-shadow: 0 1px 3px rgba(0, 0, 0, .4); } +input.slider::-moz-range-thumb { width: 16px; height: 16px; border: 0; border-radius: 50%; background: var(--text-on-accent); } +input.slider:disabled { opacity: .35; cursor: default; } +.setting-progress-bar { width: 140px; height: 6px; border-radius: 3px; background: var(--background-modifier-border-hover); overflow: hidden; } +.setting-progress-bar-inner { height: 100%; background: var(--interactive-accent); } + +/* ---------- component labels (mock-only) ---------- */ +.mock-comp-tag { display: none; } +body.mock-annotate .mock-comp-tag { display: inline-block; font: 9px/1.4 var(--font-monospace); color: var(--background-primary); background: var(--mock-annotate); border-radius: 3px; padding: 0 4px; white-space: nowrap; } +body.mock-annotate .mock-comp-tag.is-group { position: absolute; top: -9px; right: 10px; z-index: 2; } +body.mock-annotate .nexus-accordion, body.mock-annotate .setting-group:not(.nexus-accordion-content) { position: relative; outline: 1px dashed var(--mock-annotate); outline-offset: 4px; border-radius: var(--setting-items-radius); } +body.mock-annotate .setting-item-control [data-component] { outline: 1px dashed var(--mock-annotate); outline-offset: 2px; } +body.mock-annotate .modal[data-component] { outline: 1px dashed var(--mock-annotate); outline-offset: 4px; } + +/* ---------- phone width ---------- */ +body.is-mobile .mock-stage .mock-settings-window, body.is-mobile .mock-stage .mock-chat-window { width: 390px; margin: 0 auto; } +body.is-mobile .mock-stage .vertical-tab-content { padding: var(--space-l) var(--space-m); } +body.is-mobile .mock-stage .mock-unchanged { display: none; } +body.is-mobile .mock-stage .chat-settings-modal { width: 100%; max-height: 100%; height: 100%; border-radius: 0; border: 0; } +body.is-mobile .mock-stage .setting-item { min-height: 52px; } +body.is-mobile .mock-stage select.dropdown { max-width: 160px; } +body.is-mobile .mock-stage .clickable-icon { min-width: 40px; min-height: 40px; } +body.is-mobile .mock-stage input.slider { width: 90px; } +body.is-mobile .mock-stage .setting-progress-bar { width: 70px; } +body.is-mobile .mock-stage .nexus-accordion-toggle { min-height: 52px; } + +@media (max-width: 960px) { + .mock-layout { display: flex; flex-direction: column-reverse; } + .mock-context { position: static; max-height: none; } +} +@media (max-width: 600px) { + .mock-page { padding: var(--space-xl) var(--space-m); } + .mock-stage .mock-chat-window, .mock-stage .mock-settings-window { width: 100%; } + .vertical-tab-content { padding: var(--space-l) var(--space-m); } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition: none !important; } +} +.nexus-ctx-value { min-width: 40px; } + +/* Get started: button row + pages */ +.nexus-get-started { margin-bottom: var(--space-l); } +.nexus-get-started .setting-item-name .nexus-icon { margin-right: 2px; } +.nexus-page-head { max-width: var(--setting-group-max-width); margin: 0 auto var(--space-m); display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-xs); } +.nexus-back-button { display: inline-flex; align-items: center; gap: var(--space-xs); border: 0; background: transparent; padding: 4px 8px 4px 4px; margin-left: -4px; font-size: var(--font-ui-small); color: var(--text-muted); } +.nexus-back-button svg { width: 16px; height: 16px; } +.nexus-page-title { margin: 0; font-size: 22px; font-weight: 600; letter-spacing: -.01em; } +.nexus-page-lede { max-width: var(--setting-group-max-width); margin: 0 auto var(--space-l); color: var(--text-muted); font-size: var(--font-ui-small); } +.nexus-can { max-width: var(--setting-group-max-width); margin: 0 auto var(--space-xl); padding: 0; list-style: none; display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-s) var(--space-xl); font-size: var(--font-ui-small); } +.nexus-can li { display: flex; align-items: center; gap: var(--space-s); } +.nexus-can .nexus-icon { color: var(--text-accent); } +.nexus-gs-heading { max-width: var(--setting-group-max-width); margin: 0 auto var(--space-s); flex-wrap: wrap; } +.setting-item.mod-navigable { cursor: pointer; } +.setting-item.mod-navigable:hover { background: var(--background-modifier-hover); } +.setting-item.mod-navigable:first-child:hover { border-radius: var(--setting-items-radius) var(--setting-items-radius) 0 0; } +.setting-item.mod-navigable:last-child:hover { border-radius: 0 0 var(--setting-items-radius) var(--setting-items-radius); } +.setting-item-chevron { display: flex; align-items: center; color: var(--text-faint); } +.nexus-status { display: inline-flex; align-items: center; gap: 4px; } +.nexus-status.is-ok { color: var(--text-success); } +.nexus-status svg { width: 14px; height: 14px; } +.nexus-step { display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; background: rgba(var(--interactive-accent-rgb), .18); color: var(--text-accent); font-size: 12px; font-weight: 600; margin-right: 2px; } +.nexus-gs-cli { margin-top: var(--space-m); } +.nexus-child-row .setting-item-name { font-size: var(--font-ui-small); color: var(--text-muted); padding-left: var(--space-l); } +.nexus-child-row .setting-item-description { padding-left: var(--space-l); } +.nexus-path-row .setting-item-description { font-family: var(--font-monospace); font-size: 11px; } +.nexus-child-row::before { left: calc(var(--setting-items-padding-x) + var(--space-l)) !important; } +body.is-mobile .mock-stage .nexus-can { grid-template-columns: 1fr; } +.nexus-code { margin: var(--space-s) 0 0; padding: var(--space-s) var(--space-m); border-radius: var(--radius-s); background: var(--background-primary); color: var(--text-normal); font: 11px/1.5 var(--font-monospace); white-space: pre-wrap; overflow-wrap: anywhere; } +.nexus-prompt { font-family: var(--font-interface); font-size: var(--font-ui-smaller); } +.setting-item:has(.nexus-code) { align-items: flex-start; } +.setting-item:has(.nexus-code) .setting-item-control { padding-top: 2px; } +.setting-group + .nexus-gs-heading { margin-top: var(--space-xl); } +.nexus-gs-apps { margin-top: var(--space-m); } +.setting-item-description code { font: 11px var(--font-monospace); color: var(--text-normal); overflow-wrap: anywhere; } +.nexus-node-missing .setting-item-name { color: var(--text-warning); } +.setting-item-control button:disabled { opacity: .45; cursor: default; } +.nexus-other-row { cursor: pointer; } +.nexus-other-row:hover { background: var(--background-modifier-hover); } +.nexus-other-row:last-child:hover { border-radius: 0 0 var(--setting-items-radius) var(--setting-items-radius); } +.nexus-other-steps { margin: 0 var(--setting-items-padding-x) var(--space-s) calc(var(--setting-items-padding-x) + var(--space-s)); padding-left: var(--space-m); border-left: 2px solid var(--setting-items-divider-color); } +.nexus-other-steps .setting-item { padding-left: 0 !important; padding-right: 0 !important; } +.nexus-other-steps .setting-item::before { left: 0 !important; right: 0 !important; } +.nexus-other-steps .setting-item:first-child::before { content: none !important; } +.nexus-code { white-space: pre; overflow-x: auto; overflow-wrap: normal; } +.nexus-prompt { white-space: pre-wrap; overflow-wrap: anywhere; } diff --git a/docs/mockups/defaults-settings-essentials.html b/docs/mockups/defaults-settings-essentials.html new file mode 100644 index 000000000..56743015b --- /dev/null +++ b/docs/mockups/defaults-settings-essentials.html @@ -0,0 +1,47 @@ + + + + + + Nexus · Defaults and chat settings: essentials + folds + + + + +
+
+ NEXUS / DEFAULTS + CHAT SETTINGS · OPTION 2, ROUND 3 +

Essentials + folds, built from Obsidian parts

+

Every control is an Obsidian component, drawn the way Obsidian 1.13+ renders it. Turn on Label components to see which component each control is. The only custom code is the fold: a chevron button that hides a group’s card.

+
+ +
+
+ + +
+ + + + + +
+ All 10 options + + +
+ +
+ +
+
+ +
DRAFT · 14 OCT 2026 Proposed layout, not implemented. Values and index counts are sample data; nothing is saved.
+
+ + diff --git a/docs/mockups/defaults-settings-essentials.js b/docs/mockups/defaults-settings-essentials.js new file mode 100644 index 000000000..74587b846 --- /dev/null +++ b/docs/mockups/defaults-settings-essentials.js @@ -0,0 +1,439 @@ +// Standalone simulation only. No persistence, provider calls, or plugin integration. +// Markup mirrors what Obsidian 1.13+ renders for Setting / SettingGroup and the control components, +// captured from the running app. Model capabilities mirror the registry (supportsThinking, +// supportsSamplingParams, contextWindow). + +const paths = { + 'chevron-down': '', + 'chevron-up': '', + 'chevron-left': '', + 'chevron-right': '', + 'message-square': '', + plug: '', + 'pencil-line': '', + search: '', + folder: '', + 'wifi-off': '', + terminal: '', + 'circle-check': '', + rocket: '', + x: '', + 'rotate-ccw': '', + pause: '', + 'file-text': '', + 'sliders-horizontal': '', + layers: '', + bot: '', + image: '', + film: '', + mic: '', + 'file-input': '', + 'scan-search': '', +}; +const ic = (name, cls = '') => ``; + +/* ---------------- model registry sample ---------------- */ +const MODELS = { + Anthropic: [ + { name: 'Claude Opus 5.5', think: true, temp: false, ctx: 1000000 }, + { name: 'Claude Sonnet 5.5', think: true, temp: false, ctx: 1000000 }, + { name: 'Claude Haiku 5.5', think: true, temp: false, ctx: 1000000 }, + { name: 'Claude Opus 4.6', think: true, temp: true, ctx: 200000 }, + ], + 'Mistral AI': [ + { name: 'Mistral Large Latest', think: false, temp: true, ctx: 128000 }, + { name: 'Magistral Medium', think: true, temp: true, ctx: 40000 }, + ], +}; +const THINK = ['Off', 'Low', 'Medium', 'High', 'Extra high', 'Max']; +const WORKSPACES = ['None', 'Daily Notes & Workflow', 'On Networks', 'Personal Operations', 'House Weatherization', 'Job Search Command Center']; +const PROMPTS = ['None', 'meditations-on-alignment', 'job-application-materials']; + +const st = { + surface: 'defaults', + open: new Set(), + semantic: 'on', + connector: false, + other: false, + os: 'mac', + node: true, + page: null, // null | 'gs' | 'gs-chat' | 'gs-agents' + chat: { provider: 'Anthropic', model: 'Claude Opus 5.5', think: 'High', temp: 0.5, ctx: 1000000 }, + agent: { provider: 'Anthropic', model: 'Claude Haiku 5.5', think: 'Off', temp: 0.5, ctx: 1000000 }, + web: false, + workspace: { defaults: 'None', chat: 'On Networks' }, + workflow: 'None', + prompt: 'None', + pdf: true, + pdfMode: 'Text extraction', + pdfAuto: false, +}; +const spec = m => MODELS[m.provider].find(x => x.name === m.model); +const CTX_PRESETS = [32000, 64000, 128000, 200000, 350000, 750000, 1000000]; +// Presets up to the model's limit; the limit itself is always offered (covers small local models). +const ctxChoices = max => [...new Set([...CTX_PRESETS.filter(n => n < max), max])]; +const fmtCtx = n => n >= 1000000 ? `${n / 1000000}M` : `${Math.round(n / 1000)}K`; +const isChat = () => st.surface === 'chat'; + +/* ---------------- Obsidian component markup ---------------- */ +const opts = (arr, v) => arr.map(o => `${o}`).join(''); +const dropdown = (key, arr, v, disabled = false) => ``; +const toggle = (key, on) => ``; +const extraButton = (icon, label, attrs = '') => `
${ic(icon)}
`; +const button = (text, cls = '', attrs = '') => ``; +const slider = (key, value, label, min, max, step, { disabled = false } = {}) => + `${label}`; +function setting(name, desc, control, mods = '') { + return `
${name}
${desc}
${control}
`; +} +// Plain block: an Obsidian SettingGroup with no heading. +// Folds: the plugin's shared Accordion (src/components/Accordion.ts), restyled. The same component +// is used for Get started, so every accordion in Nexus looks and behaves the same. +function group(id, { icon, title, summary, body, collapsible = true }) { + if (!collapsible) return `
${body}
`; + const open = st.open.has(id); + return `
+ +
${body}
+
`; +} +const subhead = t => `
${t}
`; + +/* ---------------- model rows driven by capabilities ---------------- */ +function coreRows(key) { + const m = st[key]; + const s = spec(m); + return setting('Provider', '', dropdown(`${key}.provider`, Object.keys(MODELS), m.provider)) + + setting('Model', '', dropdown(`${key}.model`, MODELS[m.provider].map(x => x.name), m.model)) + + (s.think ? setting('Thinking', '', dropdown(`${key}.think`, THINK, m.think)) : ''); +} +function tuningRows(key) { + const m = st[key]; + const s = spec(m); + const ctx = Math.min(m.ctx, s.ctx); + const temp = s.temp + ? setting('Creativity', 'Lower is more focused, higher is more creative.', slider(`${key}.temp`, m.temp, m.temp.toFixed(1), 0, 1, 0.1)) + : setting('Creativity', `${m.model} sets its own temperature.`, slider(`${key}.temp`, 0.5, '—', 0, 1, 0.1, { disabled: true }), 'is-disabled'); + // SliderComponent over preset steps: setLimits(0, steps - 1, 1); the readout shows the preset size. + const steps = ctxChoices(s.ctx); + const idx = Math.max(0, steps.indexOf(ctx)); + return temp + setting('Context window', 'Older messages are summarized past this size.', + `${fmtCtx(ctx)}`); +} +const tuningSummary = key => { + const m = st[key]; const s = spec(m); + return [s.temp ? `Creativity ${m.temp.toFixed(1)}` : 'Fixed temperature', `${fmtCtx(Math.min(m.ctx, s.ctx))} context`]; +}; + +/* ---------------- semantic search states ---------------- */ +function semanticGroup() { + const on = st.semantic !== 'off'; + let body = setting('Semantic search', 'Find notes and past chats by meaning, not just exact words. Runs on this device.', toggle('semantic', on), 'mod-toggle'); + if (st.semantic === 'on' || st.semantic === 'confirm') { + body += setting('Index', 'Kept up to date as notes and chats change.', '
4,512 notes · 318 chats
'); + } else if (st.semantic === 'indexing') { + body += setting('Indexing', 'About 4 minutes left. You can keep working.', + `
1,904 / 4,512
${extraButton('pause', 'Pause indexing')}`); + } else { + body += setting('Index', 'Kept on this device, so turning search back on only re-indexes notes that changed.', '
Paused · 4,512 notes
') + + setting('Delete index', 'Frees the space it uses. Turning search back on re-indexes the whole vault.', button('Delete index', 'mod-warning')) + + `
NEEDS CODE There is no delete path today. Vectors stay in SQLite until “Rebuild cache”.
`; + } + const summary = { on: 'On · 4,512 notes', indexing: 'Indexing · 42%', confirm: 'On · 4,512 notes', off: 'Off · index kept' }[st.semantic]; + return group('semantic', { icon: 'scan-search', title: 'Semantic search', summary, body }); +} +function confirmModal() { + return `
`; +} + +const OS = { + mac: { node: '/opt/homebrew/bin/node', connector: '/Users/you/Documents/Vault/.obsidian/plugins/nexus/connector.js', version: 'v24.11.0' }, + win: { node: 'C:\\Program Files\\nodejs\\node.exe', connector: 'C:\\Users\\you\\Documents\\Vault\\.obsidian\\plugins\\nexus\\connector.js', version: 'v24.11.0' }, + linux: { node: '/usr/bin/node', connector: '/home/you/Vault/.obsidian/plugins/nexus/connector.js', version: 'v24.11.0' }, +}; +const esc = t => t.replace(/&/g, '&').replace(/ JSON.stringify({ mcpServers: { 'nexus-professor-synapse': { command: OS[st.os].node, args: [OS[st.os].connector] } } }, null, 2); + +/* ---------------- Get started pages (in place of the tab, with a back button) ---------------- */ +const backButton = (label, to) => ``; +const navRow = (icon, name, desc, to, value = '') => `
${ic(icon, 'nexus-accordion-icon')}${name}
${desc}
${value ? `
${value}
` : ''}
${ic('chevron-right')}
`; +const status = (ok, text) => `
${ok ? ic('circle-check') : ''}${text}
`; +const step = (n, name, desc, control) => setting(`${n}${name}`, desc, control); +function getStartedPage() { + const head = (back, backTo, title) => `
${backButton(back, backTo)}

${title}

`; + if (st.page === 'gs-chat') { + return head('Get started', 'gs', 'Chat inside Obsidian') + + `
` + + step(1, 'Connect a model provider', 'An API key, a sign-in, or a local model such as Ollama.', status(true, 'Connected') + button('Open providers')) + + step(2, 'Open the chat', 'From the ribbon icon or the “Nexus: Open chat” command.', button('Open chat', 'mod-cta')) + + step(3, 'Start chatting', 'Ask about your notes. Nexus can read, search and edit them.', '') + + `
`; + } + if (st.page === 'gs-agents') { + const o = OS[st.os]; + const need = st.node ? '' : ' disabled'; + const blocked = st.node ? '' : status(false, 'Needs Node.js'); + return head('Get started', 'gs', 'Connect an external agent') + + `

Let apps outside Obsidian use your vault through Nexus.

` + + `
` + + (st.node + ? setting('Node.js', `Runs the connector. Found ${o.version} at ${esc(o.node)}.`, status(true, 'Found')) + : setting('Node.js', 'Every agent connection runs through Node.js, and it isn’t installed or Nexus can’t find it. Install it, then check again.', button('Install Node.js', 'mod-cta') + button('Check again', '', 'data-node-check'), 'nexus-node-missing')) + + `
` + + `
` + + setting('Claude Desktop', '', st.node ? status(true, 'Connected') + button('Open config') : blocked) + + setting('Codex', '', (st.node ? status(false, 'Ready') : blocked) + button('Connect', 'mod-cta', need)) + + setting('Antigravity', '', (st.node ? status(false, 'Ready') : blocked) + button('Connect', 'mod-cta', need)) + + `
Other agents
Cursor, Cline, Copilot, Windsurf and others. Four steps.
${extraButton(st.other ? 'chevron-up' : 'chevron-down', st.other ? 'Hide steps' : 'Show steps')}
` + + (!st.other ? '' : `
` + + step(1, 'Create the connector file', 'connector.js lets the app talk to this vault. It goes in the Nexus plugin folder.', status(st.connector, st.connector ? 'Created' : '') + (st.connector ? '' : button('Create', 'mod-cta', 'data-connector' + need))) + + step(2, 'Copy the config', `The Nexus entry for the app’s MCP settings, with this computer’s paths.
${st.node ? esc(configJson()) : 'Install Node.js first. The config needs its location.'}
`, button('Copy config', '', need)) + + step(3, 'Add it to your app', `Paste it into the app’s MCP settings yourself, or paste this prompt into the agent and let it make the change.
${st.node ? esc(`Add an MCP server to your configuration. Name: nexus-professor-synapse. Command: ${o.node}. Args: ["${o.connector}"]. Use these paths exactly. Merge it into the existing mcpServers and keep any servers already there. Then tell me where you saved it.`) : 'Install Node.js first.'}
`, button('Copy prompt', '', need)) + + step(4, 'Restart the app', 'Quit and reopen it so it loads Nexus. Then ask it to list your vault’s folders to check.', '') + + `
`) + + `
` + + `
` + + setting(`${ic('terminal', 'nexus-accordion-icon')}Command line`, 'Lets Claude Code, Cursor and Codex use Nexus without MCP.', status(false, 'Not installed') + button('Install')) + + setting('Claude Code skills', '~/.claude/skills', toggle('x', true), 'mod-toggle nexus-child-row nexus-path-row') + + setting('Cursor skills', '~/.cursor/skills', toggle('x', false), 'mod-toggle nexus-child-row nexus-path-row') + + setting('Codex AGENTS.md', '~/.codex/AGENTS.md', toggle('x', true), 'mod-toggle nexus-child-row nexus-path-row') + + `
`; + } + const can = [['pencil-line', 'Read, create and edit notes'], ['search', 'Search by keyword or by meaning'], ['layers', 'Remember context with workspaces'], ['folder', 'Organize files and folders'], ['bot', 'Run saved prompts and subagents'], ['wifi-off', 'Work offline with local models']]; + return head('Settings', '', 'Get started') + + `

Nexus is an assistant that lives in your vault. It reads and writes your notes, searches them, and remembers context between conversations. Your data stays in your vault.

` + + `
    ${can.map(([i, t]) => `
  • ${ic(i)}${t}
  • `).join('')}
` + + `
Choose your setup
Pick one, or use both.
` + + `
` + + navRow('message-square', 'Chat inside Obsidian', 'Connect a model provider and chat in a side panel.', 'gs-chat', 'Ready') + + navRow('plug', 'Connect an external agent', 'Claude Desktop, Codex, Cursor and more use your vault.', 'gs-agents', '2 connected') + + `
`; +} + +/* ---------------- the surface ---------------- */ +function body() { + const chat = isChat(); + const ws = st.workspace[st.surface]; + const notes = chat ? ['Episode 05 outline', 'Vault Map of Content', 'AGENTS'] : ['Vault Map of Content', 'AGENTS']; + let h = group('main', { body: coreRows('chat'), collapsible: false }); + + h += group('chat-options', { + icon: 'sliders-horizontal', title: 'Chat options', + summary: [...tuningSummary('chat'), ...(chat ? [`Web search ${st.web ? 'on' : 'off'}`] : [])].join(' · '), + body: tuningRows('chat') + (chat ? setting('Web search', 'Let the model search the web when needed.', toggle('web', st.web), 'mod-toggle') : ''), + }); + + h += group('context', { + icon: 'layers', title: 'Context', + summary: [ws === 'None' ? 'No workspace' : ws, st.prompt === 'None' ? 'No prompt' : st.prompt, `${notes.length} notes`].join(' · '), + body: setting('Workspace', '', dropdown('workspace', WORKSPACES, ws)) + + (chat ? setting('Workflow', 'Loads its instructions and tools. Steps run only when you ask.', dropdown('workflow', ['None', 'Episode prep'], st.workflow)) : '') + + setting('Prompt', '', dropdown('prompt', PROMPTS, st.prompt)) + + setting('Context notes', '', button('Add note')) + + notes.map(n => setting(`${ic('file-text', 'nexus-note-icon')}${n}`, '', extraButton('x', `Remove ${n}`), 'nexus-note-row')).join(''), + }); + + const a = st.agent; const as = spec(a); + h += group('subagents', { + icon: 'bot', title: 'Subagents', + summary: [a.model, as.think ? (a.think === 'Off' ? 'Thinking off' : `${a.think} thinking`) : null, ...tuningSummary('agent')].filter(Boolean).join(' · '), + body: setting('Used for', 'Subagents, saved prompts and automations. Must be a cloud model.', '', 'nexus-note') + + coreRows('agent') + tuningRows('agent'), + }); + + h += group('images', { icon: 'image', title: 'Images', summary: 'Gemini 3.1 Flash Image', + body: setting('Provider', '', dropdown('x', ['Google AI', 'OpenAI', 'OpenRouter'], 'Google AI')) + setting('Model', '', dropdown('x', ['Gemini 3.1 Flash Image'], 'Gemini 3.1 Flash Image')) }); + + if (!chat) { + h += group('video', { icon: 'film', title: 'Video', summary: 'Veo 3.1 · 16:9 · 720p', + body: setting('Provider', '', dropdown('x', ['Google AI'], 'Google AI')) + setting('Model', '', dropdown('x', ['Veo 3.1'], 'Veo 3.1')) + + setting('Aspect ratio', '', dropdown('x', ['16:9', '9:16'], '16:9')) + setting('Resolution', '', dropdown('x', ['720p', '1080p'], '720p')) }); + } + + h += group('voice', { icon: 'mic', title: 'Voice', summary: 'Deepgram Nova-3 · HeyGen Voice (Alloy)', + body: subhead('Transcription') + + setting('Provider', 'Chat microphone and audio files.', dropdown('x', ['OpenRouter', 'Deepgram', 'AssemblyAI'], 'OpenRouter')) + setting('Model', '', dropdown('x', ['Deepgram Nova-3'], 'Deepgram Nova-3')) + + subhead('Read aloud') + + setting('Provider', '', dropdown('x', ['OpenRouter'], 'OpenRouter')) + setting('Model', '', dropdown('x', ['HeyGen Voice'], 'HeyGen Voice')) + setting('Voice', '', dropdown('x', ['Provider default', 'Alloy', 'Verse'], 'Alloy')) + + (chat ? '' : setting('Saved audio folder', 'Inside the Nexus data folder.', '')) + + subhead('Live voice') + + setting('Provider', 'None of your providers offer live voice.', dropdown('x', ['None available'], 'None available', true), 'is-disabled') + + setting('Model', '', dropdown('x', ['—'], '—', true), 'is-disabled') + setting('Output', '', dropdown('x', ['Provider default'], 'Provider default', true), 'is-disabled') }); + + if (!chat) { + const vision = st.pdfMode === 'Vision'; + h += group('file-conversion', { icon: 'file-input', title: 'File conversion', + summary: st.pdf ? `PDF import on · ${st.pdfMode}${st.pdfAuto ? ' · auto-convert' : ''}` : 'PDF import off', + body: setting('PDF import', 'Drop PDFs into chat to turn them into notes.', toggle('pdf', st.pdf), 'mod-toggle') + + (st.pdf ? setting('Reading method', 'Text is free. Vision reads scanned pages with a model.', dropdown('pdfMode', ['Text extraction', 'Vision'], st.pdfMode)) + + (vision ? setting('Vision provider', '', dropdown('x', ['Google AI', 'OpenRouter'], 'Google AI')) + setting('Vision model', '', dropdown('x', ['Gemini 3 Flash'], 'Gemini 3 Flash')) : '') + + setting('Convert new PDFs automatically', 'Adds a Markdown note next to each PDF added to the vault.', toggle('pdfAuto', st.pdfAuto), 'mod-toggle') : '') }); + h += semanticGroup(); + } + return h; +} + +function frame(inner) { + if (!isChat()) { + return `
+ ${st.page ? getStartedPage() : `
Get started: replaces the accordion above the tabs
+
${navRow('rocket', 'Get started', 'Set up chat in Obsidian, or connect Claude Desktop, Codex and other agents.', 'gs')}
+ +
${inner}
`}
${st.semantic === 'confirm' ? confirmModal() : ''}
`; + } + return `
+
`; +} + +/* ---------------- side notes ---------------- */ +function context() { + const common = `

Built from Obsidian parts

    +
  • SettingGroup for every block: a rounded card with its heading outside, like Obsidian’s own Appearance tab.
  • +
  • Setting rows with DropdownComponent, ToggleComponent, SliderComponent, TextComponent, ButtonComponent and ExtraButtonComponent for reset and remove.
  • +
  • Context window is a SliderComponent that snaps to set sizes: 32K, 64K, 128K, 200K, 350K, 750K, 1M. Sizes above the model’s limit are left out, and the model’s own limit is always the last stop. Small local models (LM Studio, WebLLM), which can be under 32K, get a single stop and the slider is disabled.
  • +
  • Folds use the plugin’s existing Accordion (src/components/Accordion.ts), restyled, so every accordion in Nexus matches.
  • +
  • Node.js comes first on the external agent page. It uses the existing lookup (which, or where on Windows). If Node is missing, every Connect, Create and Copy is disabled with “Needs Node.js”. Today the copyable config falls back to a bare node, which often fails on macOS because apps opened from the Dock don’t see the terminal’s PATH.
  • +
  • Config, prompt and paths follow the OS. They use the absolute Node and connector paths for this computer, written with JSON.stringify so Windows backslashes are escaped. The copied prompt says “use these paths exactly”. Use the OS and Node.js switches above to check each case.
  • +
  • Get started is one row above the tabs; the whole row opens a page in place of the tab, with the plugin’s existing BackButton: intro → choose a setup → Chat inside Obsidian or Connect an external agent. Each step back goes up one level.
  • +
  • Chat settings stays an Obsidian Modal with the standard close button and Cancel / Save.
  • +
`; + const semantic = isChat() ? '' : `

Turning off semantic search

+

It’s on by default and almost everyone has it on, so turning it off should be deliberate and easy to undo.

+
    +
  • Ask first. A confirmation modal explains what gets worse. Switch the state above to see it.
  • +
  • Keep the index. Turning it back on only re-indexes notes that changed, which is how re-enabling works today.
  • +
  • Deleting the index is a separate, explicit button. NEEDS CODE
  • +
  • Apply right away. Stop the watchers and unload the model without a restart. NEEDS CODE Today it only takes effect after restarting Obsidian.
  • +
  • Show index status using getStats() and the indexing queue’s progress, which already exist but no screen uses.
  • +
+

Problems this surfaced

+
    +
  • When it’s off, search tools still offer semantic search to agents, and the error they get back says it is “still initializing”.
  • +
  • Index entries for notes deleted while it was off are never removed.
  • +
  • Five places look up an embeddingService that nothing registers, so searching past chats and semantic note suggestions may never work, even when it’s on. Worth checking before anything else.
  • +
`; + document.getElementById('mock-context').innerHTML = common + semantic + + '

Model capabilities come from the registry: Opus, Sonnet and Haiku 5.5 reject temperature, Opus 4.6 accepts it, Mistral Large can’t reason, and Magistral Medium has a 40K context.

'; +} + +/* ---------------- wiring ---------------- */ +const stage = document.getElementById('mock-stage'); +function render() { + const scroller = stage.querySelector('.vertical-tab-content, .modal-content'); + const top = scroller ? scroller.scrollTop : 0; + stage.innerHTML = frame(body()); + // Mock-only labels naming the Obsidian component behind each control (shown with "Label components"). + stage.querySelectorAll('[data-component]').forEach(el => { + const name = el.dataset.component; + if (name === 'Setting') return; + const tag = document.createElement('span'); + tag.className = 'mock-comp-tag'; + tag.textContent = name; + if (name === 'SettingGroup' || name === 'Modal' || name === 'Accordion') { tag.classList.add('is-group'); el.prepend(tag); } else el.before(tag); + }); + const next = stage.querySelector('.vertical-tab-content, .chat-settings-renderer'); + if (next) next.scrollTop = top; + document.querySelectorAll('[data-surface]').forEach(b => b.setAttribute('aria-checked', String(b.dataset.surface === st.surface))); + document.getElementById('mock-semantic-wrap').hidden = isChat(); + document.getElementById('mock-semantic').value = st.semantic; + document.getElementById('mock-os').value = st.os; + document.getElementById('mock-node').value = st.node ? 'found' : 'missing'; + context(); +} + +stage.addEventListener('change', e => { + const key = e.target.dataset.key; + if (!key || key === 'x') return; + const [a, b] = key.split('.'); + if (b) { + st[a][b] = e.target.value; + if (b === 'provider' || b === 'model') { if (b === 'provider') st[a].model = MODELS[st[a].provider][0].name; st[a].ctx = spec(st[a]).ctx; } + } else if (a === 'workspace') st.workspace[st.surface] = e.target.value; + else st[a] = e.target.value; + render(); +}); +stage.addEventListener('input', e => { + const c = e.target.dataset.ctx; + if (c) { + st[c].ctx = ctxChoices(spec(st[c]).ctx)[Number(e.target.value)]; + e.target.closest('.setting-item-control').querySelector('.slider-value').textContent = fmtCtx(st[c].ctx); + e.target.setAttribute('aria-valuetext', `${fmtCtx(st[c].ctx)} tokens`); + return; + } + const r = e.target.dataset.range; + if (!r) return; + const [a, b] = r.split('.'); + st[a].temp = Number(e.target.value); + e.target.closest('.setting-item-control').querySelector('.slider-value').textContent = st[a].temp.toFixed(1); +}); +stage.addEventListener('click', e => { + const fold = e.target.closest('[data-fold]'); + if (fold) { const id = fold.dataset.fold; st.open.has(id) ? st.open.delete(id) : st.open.add(id); render(); return; } + if (e.target.closest('[data-other]')) { st.other = !st.other; render(); return; } + if (e.target.closest('[data-node-check]')) { st.node = true; render(); return; } + if (e.target.closest('[data-connector]')) { st.connector = true; render(); return; } + const pg = e.target.closest('[data-page]'); + if (pg) { st.page = pg.dataset.page || null; stage.innerHTML = ''; render(); return; } + const set = e.target.closest('[data-semantic-set]'); + if (set) { st.semantic = set.dataset.semanticSet; render(); return; } + const t = e.target.closest('[data-toggle]'); + if (!t) return; + e.preventDefault(); + const key = t.dataset.toggle; + if (key === 'x') { t.classList.toggle('is-enabled'); return; } + if (key === 'semantic') st.semantic = st.semantic === 'off' ? 'indexing' : 'confirm'; + else st[key] = !st[key]; + render(); +}); +document.addEventListener('keydown', e => { + if ((e.key === 'Enter' || e.key === ' ') && e.target.matches('.checkbox-container, [role="button"]')) { e.preventDefault(); e.target.click(); } + if (e.key === 'Escape' && st.semantic === 'confirm') { st.semantic = 'on'; render(); } +}); +document.querySelectorAll('[data-surface]').forEach(b => b.addEventListener('click', () => { st.surface = b.dataset.surface; stage.innerHTML = ''; render(); })); +document.getElementById('mock-os').addEventListener('change', e => { st.os = e.target.value; st.page = 'gs-agents'; render(); }); +document.getElementById('mock-node').addEventListener('change', e => { st.node = e.target.value === 'found'; st.page = 'gs-agents'; render(); }); +document.getElementById('mock-semantic').addEventListener('change', e => { st.semantic = e.target.value; st.open.add('semantic'); render(); }); +document.getElementById('mock-expand').addEventListener('click', e => { + const all = ['chat-options', 'context', 'subagents', 'images', 'video', 'voice', 'file-conversion', 'semantic']; + const opening = st.open.size < 3; + st.open = opening ? new Set(all) : new Set(); + e.currentTarget.textContent = opening ? 'Close all folds' : 'Open all folds'; + render(); +}); +document.getElementById('mock-annotate').addEventListener('click', e => { + const on = document.body.classList.toggle('mock-annotate'); + e.currentTarget.setAttribute('aria-pressed', String(on)); +}); +document.getElementById('mock-theme').addEventListener('click', e => { + const light = document.body.classList.toggle('theme-light'); + document.body.classList.toggle('theme-dark', !light); + e.currentTarget.setAttribute('aria-pressed', String(light)); +}); +document.getElementById('mock-phone').addEventListener('click', e => { + const on = document.body.classList.toggle('is-mobile'); + e.currentTarget.setAttribute('aria-pressed', String(on)); +}); + +render(); diff --git a/docs/mockups/defaults-settings-options.css b/docs/mockups/defaults-settings-options.css new file mode 100644 index 000000000..0b57bac02 --- /dev/null +++ b/docs/mockups/defaults-settings-options.css @@ -0,0 +1,295 @@ +/* Obsidian default-theme approximations (inherited tokens) + Nexus spacing scale. */ +:root { + --background-primary: #1e1e1e; + --background-primary-alt: #242424; + --background-secondary: #262626; + --background-secondary-alt: #2e2e2e; + --background-modifier-border: #363636; + --background-modifier-border-hover: #4a4a4a; + --background-modifier-hover: rgba(255, 255, 255, 0.075); + --background-modifier-form-field: #2a2a2a; + --interactive-normal: #2f2f2f; + --interactive-hover: #3a3a3a; + --interactive-accent: #7f6df2; + --interactive-accent-hover: #8875ff; + --interactive-accent-rgb: 127, 109, 242; + --text-normal: #dadada; + --text-muted: #a3a3a3; + --text-faint: #7a7a7a; + --text-accent: #a68af9; + --text-on-accent: #ffffff; + --text-warning: #e9973f; + --font-interface: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Ubuntu, sans-serif; + --font-monospace: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --font-ui-smaller: 12px; + --font-ui-small: 13px; + --font-ui-medium: 15px; + --radius-s: 4px; + --radius-m: 8px; + --radius-l: 12px; + --shadow-l: 0 14px 40px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.3); + --space-xxs: 2px; + --space-xs: 4px; + --space-s: 8px; + --space-m: 12px; + --space-l: 16px; + --space-xl: 24px; + --space-xxl: 32px; + --mock-backdrop: rgba(0, 0, 0, 0.55); +} +body.theme-light { + --background-primary: #ffffff; + --background-primary-alt: #fafafa; + --background-secondary: #f6f6f6; + --background-secondary-alt: #ececec; + --background-modifier-border: #e0e0e0; + --background-modifier-border-hover: #c8c8c8; + --background-modifier-hover: rgba(0, 0, 0, 0.05); + --background-modifier-form-field: #ffffff; + --interactive-normal: #ffffff; + --interactive-hover: #f0f0f0; + --interactive-accent: #7852ee; + --interactive-accent-hover: #6d45e6; + --interactive-accent-rgb: 120, 82, 238; + --text-normal: #222222; + --text-muted: #5c5c5c; + --text-faint: #8a8a8a; + --text-accent: #6c3fe6; + --text-warning: #c46a00; + --shadow-l: 0 14px 40px rgba(0, 0, 0, 0.14), 0 2px 8px rgba(0, 0, 0, 0.08); + --mock-backdrop: rgba(0, 0, 0, 0.25); +} + +* { box-sizing: border-box; } +body { margin: 0; background: var(--background-secondary); color: var(--text-normal); font: 14px/1.5 var(--font-interface); } +button, select, input { font: inherit; color: inherit; } +button { cursor: pointer; } +:focus-visible { outline: 2px solid var(--interactive-accent); outline-offset: 2px; } +.nexus-icon { display: inline-grid; place-items: center; flex: 0 0 auto; } +svg { width: 16px; height: 16px; display: block; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +[hidden] { display: none !important; } + +/* ---------- mock chrome ---------- */ +.mock-page { max-width: 1320px; margin: auto; padding: 48px var(--space-xxl) var(--space-xl); } +.mock-eyebrow { font: 11px var(--font-monospace); letter-spacing: .12em; color: var(--text-accent); } +.mock-intro h1 { font-size: 34px; line-height: 1.15; font-weight: 550; letter-spacing: -.03em; margin: var(--space-l) 0 var(--space-m); } +.mock-intro p { margin: 0; max-width: 720px; color: var(--text-muted); } +.mock-intro strong { color: var(--text-normal); font-weight: 550; } +.mock-toolbar { position: sticky; top: 0; z-index: 10; margin: var(--space-xl) 0; padding: var(--space-s) 0; background: var(--background-secondary); display: flex; gap: var(--space-s); align-items: center; flex-wrap: wrap; } +.mock-toolbar label { display: flex; align-items: center; gap: var(--space-s); color: var(--text-muted); font-size: 13px; } +.mock-toolbar > button, .mock-toolbar select, .mock-seg { border: 1px solid var(--background-modifier-border); border-radius: var(--radius-m); background: var(--background-primary); padding: var(--space-s) var(--space-m); min-height: 40px; font-size: 13px; } +.mock-toolbar > button:hover, .mock-toolbar > button[aria-pressed="true"] { border-color: var(--interactive-accent); color: var(--text-accent); } +.mock-seg { display: flex; padding: 3px; gap: 2px; } +.mock-seg button { border: 0; background: transparent; padding: 0 var(--space-m); border-radius: 5px; color: var(--text-muted); font-size: 13px; } +.mock-seg button[aria-checked="true"] { background: var(--interactive-accent); color: var(--text-on-accent); } +.mock-toolbar-spacer, .mock-spacer { flex: 1; } + +.mock-overview { display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); gap: var(--space-l); } +.mock-thumb { cursor: pointer; display: flex; flex-direction: column; padding: 0; text-align: left; border: 1px solid var(--background-modifier-border); border-radius: var(--radius-l); background: var(--background-primary); overflow: hidden; } +.mock-thumb:hover { border-color: var(--interactive-accent); } +.mock-thumb-view { height: 300px; overflow: hidden; position: relative; pointer-events: none; border-bottom: 1px solid var(--background-modifier-border); } +.mock-thumb-inner { width: 820px; transform: scale(.36); transform-origin: 0 0; position: absolute; left: 0; top: 0; } +.mock-thumb-inner .mock-settings-window, .mock-thumb-inner .mock-chat-window { height: 830px; box-shadow: none; border: 0; border-radius: 0; } +.mock-thumb-inner .mock-chat-window { width: 820px; } +.mock-thumb-meta { display: grid; grid-template-columns: auto 1fr; column-gap: var(--space-s); padding: var(--space-m) var(--space-l); } +.mock-thumb-meta strong { font-weight: 600; } +.mock-thumb-meta span:last-child { grid-column: 2; color: var(--text-muted); font-size: 12px; } +.mock-thumb-num { grid-row: span 2; width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: rgba(var(--interactive-accent-rgb), .18); color: var(--text-accent); font: 600 12px var(--font-monospace); } + +.mock-layout { display: grid; grid-template-columns: 290px minmax(0, 1fr); gap: 40px; align-items: start; } +.mock-context { font-size: 13px; color: var(--text-muted); position: sticky; top: 72px; } +.mock-variant-num { font: 11px var(--font-monospace); letter-spacing: .08em; color: var(--text-faint); text-transform: uppercase; } +.mock-context h2 { font-size: 22px; font-weight: 600; color: var(--text-normal); margin: var(--space-xs) 0 var(--space-xxs); letter-spacing: -.02em; } +.mock-variant-tag { display: inline-block; font-size: 11px; color: var(--text-accent); margin-bottom: var(--space-m); } +.mock-context h3 { font-size: 11px; font-weight: 600; color: var(--text-normal); margin: var(--space-l) 0 var(--space-xs); text-transform: uppercase; letter-spacing: .06em; } +.mock-context p { margin: 0; } +.mock-context ul { padding-left: var(--space-l); margin: 0; } +.mock-context li { margin-bottom: var(--space-xs); } +.mock-build { font-size: 12px; color: var(--text-faint); } +.mock-nav { display: flex; gap: var(--space-s); margin-top: var(--space-xl); } +.mock-nav button { flex: 1; border: 1px solid var(--background-modifier-border); border-radius: var(--radius-m); background: var(--background-primary); padding: var(--space-s); font-size: 12px; } +.mock-nav button:hover { border-color: var(--interactive-accent); color: var(--text-accent); } +.mock-footer { margin-top: var(--space-xxl); padding-top: var(--space-l); border-top: 1px solid var(--background-modifier-border); font: 10px var(--font-monospace); color: var(--text-faint); display: flex; gap: var(--space-m); justify-content: space-between; } + +/* Defaults frame: Obsidian settings pane, with today's tab strip shown as unchanged context */ +.mock-settings-window { height: 760px; border: 1px solid var(--background-modifier-border); border-radius: var(--radius-l); overflow: hidden; background: var(--background-primary); box-shadow: var(--shadow-l); } +.vertical-tab-content { height: 100%; overflow-y: auto; padding: var(--space-xl) 40px var(--space-xxl); } +.mock-unchanged { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-xs); padding-bottom: var(--space-m); margin-bottom: var(--space-s); border-bottom: 1px dashed var(--background-modifier-border); opacity: .55; font-size: 12px; } +.mock-unchanged span { padding: 3px var(--space-s); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-s); } +.mock-unchanged .is-active { border-color: var(--interactive-accent); color: var(--text-accent); } +.mock-unchanged .mock-unchanged-label { border: 0; font: 10px var(--font-monospace); text-transform: uppercase; letter-spacing: .08em; color: var(--text-faint); padding-left: 0; } + +/* Chat frame: ChatSettingsModal over a dimmed chat view */ +.mock-chat-window { position: relative; width: 560px; height: 760px; margin: 0 auto; border: 1px solid var(--background-modifier-border); border-radius: var(--radius-l); overflow: hidden; background: var(--background-primary); box-shadow: var(--shadow-l); display: flex; align-items: center; justify-content: center; } +.mock-chat-behind { position: absolute; inset: 0; padding: var(--space-xl); display: flex; flex-direction: column; gap: var(--space-l); } +.mock-chat-behind div { height: 48px; border-radius: var(--radius-l); background: var(--background-secondary-alt); } +.mock-chat-behind div:nth-child(1) { width: 60%; margin-left: auto; } +.mock-chat-behind div:nth-child(2) { height: 120px; width: 90%; } +.mock-chat-behind::after { content: ""; position: absolute; inset: 0; background: var(--mock-backdrop); } +.modal { position: relative; z-index: 1; width: calc(100% - 40px); max-height: calc(100% - 40px); display: flex; flex-direction: column; background: var(--background-primary); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-l); box-shadow: var(--shadow-l); } +.modal-header { display: flex; align-items: center; padding: var(--space-l) var(--space-l) var(--space-xs); } +.modal-title { flex: 1; font-size: 17px; font-weight: 600; } +.modal-content { overflow-y: auto; padding: 0 var(--space-l) var(--space-s); } +.modal-content .setting-item-name { font-size: var(--font-ui-small); } +.modal-content select.dropdown { max-width: 190px; } +.modal-button-container { display: flex; gap: var(--space-s); padding: var(--space-m) var(--space-l); border-top: 1px solid var(--background-modifier-border); } + +/* ---------- Obsidian primitives ---------- */ +.setting-item { display: flex; align-items: center; gap: var(--space-l); padding: 0.7em 0; border-top: 1px solid var(--background-modifier-border); } +.setting-item-heading { border-top: none; padding-top: 1.6em; padding-bottom: var(--space-xs); } +.setting-item-heading + .setting-item, .nexus-fold-body > .setting-item:first-child, .nexus-side-pane > .setting-item-heading:first-child { border-top: none; } +.nexus-side-pane > .setting-item-heading:first-child, .nexus-tile-panel > .setting-item-heading:first-child { padding-top: 0; } +.setting-item-heading .setting-item-name { font-size: var(--font-ui-medium); font-weight: 600; } +.setting-item-info { flex: 1 1 auto; min-width: 0; } +.setting-item-name { color: var(--text-normal); font-size: var(--font-ui-medium); display: flex; align-items: center; gap: var(--space-s); } +.setting-item-description { color: var(--text-muted); font-size: var(--font-ui-smaller); padding-top: var(--space-xxs); line-height: 1.4; } +.setting-item-control { flex: 0 0 auto; display: flex; align-items: center; justify-content: flex-end; gap: var(--space-s); } +.setting-item.is-disabled { opacity: .55; } +.setting-item.nexus-child { margin-left: var(--space-l); padding-left: var(--space-m); border-left: 2px solid var(--background-modifier-border); } +.nexus-note-item { padding: 0.35em 0 0.35em var(--space-m); } +.nexus-note-item .setting-item-name { font-size: var(--font-ui-small); color: var(--text-muted); } +.setting-item-control button:not(.clickable-icon), .modal-button-container button, .nexus-model-button { background: var(--interactive-normal); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-s); padding: 4px 12px; font-size: var(--font-ui-small); box-shadow: 0 1px 1px rgba(0, 0, 0, .1); } +button.mod-cta { background: var(--interactive-accent) !important; border-color: transparent !important; color: var(--text-on-accent); } +button.mod-muted { background: transparent !important; border-color: transparent !important; box-shadow: none !important; color: var(--text-muted); } +.clickable-icon { display: grid; place-items: center; background: transparent; border: 0; padding: var(--space-xs); border-radius: var(--radius-s); color: var(--text-muted); min-width: 28px; min-height: 28px; } +.clickable-icon:hover { background: var(--background-modifier-hover); color: var(--text-normal); } +select.dropdown, .setting-item-control input[type="text"] { background: var(--interactive-normal); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-s); padding: 4px var(--space-s); font-size: var(--font-ui-small); max-width: 230px; } +.setting-item-control input[type="text"] { background: var(--background-modifier-form-field); width: 160px; } +.checkbox-container { width: 40px; height: 22px; border-radius: 11px; background: var(--background-modifier-border-hover); position: relative; cursor: pointer; transition: background 150ms ease; flex: 0 0 auto; } +.checkbox-container::after { content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: var(--text-on-accent); transition: transform 150ms ease; box-shadow: 0 1px 2px rgba(0, 0, 0, .3); } +.checkbox-container.is-enabled { background: var(--interactive-accent); } +.checkbox-container.is-enabled::after { transform: translateX(18px); } +input.slider { width: 130px; accent-color: var(--interactive-accent); } +.nexus-model-button { display: inline-flex; align-items: center; gap: var(--space-s); } +.nexus-model-button svg { width: 13px; height: 13px; color: var(--text-faint); } +.nexus-model-button:disabled { opacity: .6; cursor: default; } +.nexus-slider-value { font: var(--font-ui-smaller) var(--font-monospace); color: var(--text-muted); min-width: 70px; text-align: right; } +.nexus-defaults-lede { color: var(--text-muted); font-size: var(--font-ui-small); margin: var(--space-xs) 0 0; } +.nexus-empty { padding: var(--space-l); text-align: center; color: var(--text-faint); font-size: var(--font-ui-small); } + +/* 2 + 6: folds */ +.nexus-fold { border-top: 1px solid var(--background-modifier-border); } +.nexus-fold summary { list-style: none; display: flex; align-items: center; gap: var(--space-s); padding: var(--space-m) 0; cursor: pointer; } +.nexus-fold summary::-webkit-details-marker { display: none; } +.nexus-fold summary .nexus-icon { color: var(--text-faint); transition: transform 150ms ease; } +.nexus-fold[open] summary .nexus-icon { transform: rotate(90deg); } +.nexus-fold-name { font-size: var(--font-ui-medium); font-weight: 550; flex: 0 0 auto; } +.nexus-fold-summary { flex: 1; min-width: 0; text-align: right; color: var(--text-faint); font-size: var(--font-ui-smaller); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.nexus-fold-body { padding: 0 0 var(--space-s) 24px; } +.nexus-fold-body .setting-item-heading { padding-top: var(--space-m); } + +/* 3: drill-in */ +.nexus-nav-row { cursor: pointer; } +.nexus-nav-row:hover { background: var(--background-modifier-hover); box-shadow: -8px 0 0 var(--background-modifier-hover), 8px 0 0 var(--background-modifier-hover); } +.nexus-nav-row .setting-item-name .nexus-icon { color: var(--text-accent); } +.nexus-nav-row .setting-item-control { color: var(--text-faint); } +.nexus-page-header { display: flex; align-items: center; gap: var(--space-xs); margin: var(--space-xs) 0 var(--space-xs) -6px; } +.nexus-page-header h3 { margin: 0; font-size: 18px; font-weight: 600; } +.nexus-page-crumb, .nexus-page-sep { color: var(--text-faint); font-size: 18px; } + +/* 4: sidebar */ +.nexus-side-layout { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: var(--space-xl); margin-top: var(--space-m); } +.nexus-side-nav { display: flex; flex-direction: column; gap: 2px; border-right: 1px solid var(--background-modifier-border); padding-right: var(--space-m); } +.nexus-side-item { display: flex; align-items: center; gap: var(--space-s); border: 0; background: transparent; text-align: left; padding: 6px var(--space-s); border-radius: var(--radius-s); color: var(--text-muted); font-size: var(--font-ui-small); } +.nexus-side-item:hover { background: var(--background-modifier-hover); color: var(--text-normal); } +.nexus-side-item.is-active { background: rgba(var(--interactive-accent-rgb), .16); color: var(--text-normal); } +.nexus-side-item.is-active .nexus-icon { color: var(--text-accent); } +.nexus-side-layout.is-tabs { grid-template-columns: 1fr; gap: var(--space-s); } +.nexus-side-layout.is-tabs .nexus-side-nav { flex-direction: row; border-right: 0; border-bottom: 1px solid var(--background-modifier-border); padding: 0 0 var(--space-s); overflow-x: auto; } +.nexus-side-layout.is-tabs .nexus-side-item { flex: 0 0 auto; } + +/* 5: routing table */ +.nexus-jobs { width: 100%; border-collapse: collapse; margin-top: var(--space-l); font-size: var(--font-ui-small); } +.nexus-jobs th { text-align: left; font-size: 11px; font-weight: 600; color: var(--text-faint); text-transform: uppercase; letter-spacing: .05em; padding: 0 var(--space-s) var(--space-s); border-bottom: 1px solid var(--background-modifier-border); } +.nexus-jobs td { padding: var(--space-s); border-bottom: 1px solid var(--background-modifier-border); vertical-align: middle; } +.nexus-jobs tr.is-disabled { opacity: .55; } +.nexus-jobs-job { font-weight: 550; white-space: nowrap; } +.nexus-jobs-provider { font-size: 11px; color: var(--text-faint); margin-top: 2px; padding-left: 2px; } +.nexus-jobs-opts { display: flex; flex-wrap: wrap; gap: var(--space-s); } +.nexus-jobs-opts label { display: flex; align-items: center; gap: var(--space-xs); color: var(--text-faint); font-size: 11px; } +.nexus-jobs-none { color: var(--text-faint); } +.nexus-jobs td:first-child, .nexus-jobs th:first-child { padding-left: 0; } + +/* 6: presets */ +.nexus-presets { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-s); margin: var(--space-l) 0; } +.nexus-preset { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; text-align: left; padding: var(--space-m); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-m); background: var(--background-primary-alt); } +.nexus-preset .nexus-icon { color: var(--text-muted); margin-bottom: var(--space-xs); } +.nexus-preset strong { font-weight: 600; } +.nexus-preset-model { font-size: var(--font-ui-smaller); color: var(--text-normal); } +.nexus-preset-desc { font-size: 11px; color: var(--text-faint); } +.nexus-preset:hover { border-color: var(--background-modifier-border-hover); } +.nexus-preset.is-selected { border-color: var(--interactive-accent); background: rgba(var(--interactive-accent-rgb), .1); box-shadow: inset 0 0 0 1px var(--interactive-accent); } +.nexus-preset.is-selected .nexus-icon { color: var(--text-accent); } + +/* 7: sentences */ +.nexus-sentences { margin: var(--space-l) 0 var(--space-s); font-size: 17px; line-height: 2.1; color: var(--text-muted); } +.nexus-sentences p { margin: 0 0 var(--space-xs); } +.nexus-token { field-sizing: content; appearance: none; -webkit-appearance: none; background: transparent; border: 0; border-bottom: 1.5px dashed var(--text-accent); border-radius: 0; color: var(--text-normal); font-weight: 550; padding: 0 2px; font-size: inherit; line-height: 1.3; cursor: pointer; } +.nexus-token:hover { background: rgba(var(--interactive-accent-rgb), .12); } +select.nexus-token option { color: var(--text-normal); background: var(--background-primary); } + +/* 8: tiles */ +.nexus-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: var(--space-s); margin-top: var(--space-l); } +.nexus-tile { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; padding: var(--space-m); text-align: left; border: 1px solid var(--background-modifier-border); border-radius: var(--radius-m); background: var(--background-primary-alt); min-width: 0; } +.nexus-tile:hover { border-color: var(--background-modifier-border-hover); } +.nexus-tile.is-active { border-color: var(--interactive-accent); box-shadow: inset 0 0 0 1px var(--interactive-accent); } +.nexus-tile-head { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-faint); text-transform: uppercase; letter-spacing: .05em; font-weight: 600; } +.nexus-tile-head .nexus-icon { color: var(--text-accent); } +.nexus-tile-value { font-size: 15px; font-weight: 600; margin-top: var(--space-xs); max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.nexus-tile-sub { font-size: 11px; color: var(--text-muted); max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.nexus-tile-panel { margin-top: var(--space-m); padding: var(--space-m) var(--space-l); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-m); background: var(--background-primary-alt); } + +/* 9: search */ +.nexus-search { display: flex; align-items: center; gap: var(--space-s); margin-top: var(--space-l); padding: 6px var(--space-m); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-m); background: var(--background-modifier-form-field); color: var(--text-faint); } +.nexus-search input { flex: 1; border: 0; background: transparent; outline: none; font-size: 14px; color: var(--text-normal); } +.nexus-chips { display: flex; flex-wrap: wrap; gap: var(--space-xs); margin: var(--space-s) 0 var(--space-xs); } +.nexus-chip { border: 1px solid var(--background-modifier-border); background: transparent; border-radius: 12px; padding: 1px 10px; font-size: 12px; color: var(--text-muted); } +.nexus-chip.is-active { background: var(--interactive-accent); border-color: transparent; color: var(--text-on-accent); } +.nexus-tag { font-size: 10px; font-weight: 600; color: var(--text-faint); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-s); padding: 0 5px; text-transform: uppercase; letter-spacing: .04em; } + +/* 10: scope */ +.nexus-scope-note { display: flex; align-items: center; gap: var(--space-s); padding: var(--space-s) var(--space-m); border-radius: var(--radius-m); background: rgba(var(--interactive-accent-rgb), .1); color: var(--text-muted); font-size: var(--font-ui-smaller); margin-top: var(--space-xs); } +.nexus-scope-note .nexus-icon { color: var(--text-accent); } +.setting-item.is-override { box-shadow: inset 3px 0 0 var(--interactive-accent); padding-left: var(--space-m); } +.nexus-inherited { display: grid; grid-template-columns: 1fr 1fr; gap: 2px var(--space-l); margin-top: var(--space-xs); } +.nexus-inherited-row { display: flex; align-items: center; gap: var(--space-s); border: 0; background: transparent; padding: 5px var(--space-xs); border-radius: var(--radius-s); text-align: left; font-size: var(--font-ui-smaller); min-width: 0; } +.nexus-inherited-row span:first-child { color: var(--text-muted); flex: 0 0 auto; } +.nexus-inherited-row span:nth-child(2) { flex: 1; text-align: right; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.nexus-inherited-row .nexus-icon { color: transparent; } +.nexus-inherited-row:hover { background: var(--background-modifier-hover); } +.nexus-inherited-row:hover .nexus-icon { color: var(--text-accent); } + +/* ---------- phone width ---------- */ +body.is-mobile .mock-stage .mock-settings-window, body.is-mobile .mock-stage .mock-chat-window { width: 390px; margin: 0 auto; } +body.is-mobile .mock-stage .vertical-tab-content { padding: var(--space-l); } +body.is-mobile .mock-stage .mock-unchanged { display: none; } +body.is-mobile .mock-stage .modal { width: 100%; max-height: 100%; height: 100%; border-radius: 0; border: 0; } +body.is-mobile .mock-stage .setting-item { min-height: 52px; } +body.is-mobile .mock-stage select.dropdown { max-width: 140px; } +body.is-mobile .mock-stage .clickable-icon { min-width: 44px; min-height: 44px; } +body.is-mobile .mock-stage .nexus-side-layout { grid-template-columns: 1fr; gap: var(--space-s); } +body.is-mobile .mock-stage .nexus-side-nav { flex-direction: row; border-right: 0; border-bottom: 1px solid var(--background-modifier-border); padding: 0 0 var(--space-s); overflow-x: auto; } +body.is-mobile .mock-stage .nexus-side-item { flex: 0 0 auto; min-height: 40px; } +body.is-mobile .mock-stage .nexus-presets { grid-template-columns: 1fr 1fr; } +body.is-mobile .mock-stage .nexus-tiles { grid-template-columns: 1fr 1fr; } +body.is-mobile .mock-stage .nexus-inherited { grid-template-columns: 1fr; } +body.is-mobile .mock-stage .nexus-fold-summary { display: none; } +body.is-mobile .mock-stage .nexus-sentences { font-size: 16px; } +body.is-mobile .mock-stage .nexus-jobs thead { display: none; } +body.is-mobile .mock-stage .nexus-jobs, body.is-mobile .mock-stage .nexus-jobs tbody, body.is-mobile .mock-stage .nexus-jobs tr, body.is-mobile .mock-stage .nexus-jobs td { display: block; width: 100%; } +body.is-mobile .mock-stage .nexus-jobs tr { padding: var(--space-s) 0; border-bottom: 1px solid var(--background-modifier-border); } +body.is-mobile .mock-stage .nexus-jobs td { border: 0; padding: 2px 0; } + +@media (max-width: 960px) { + .mock-layout { display: flex; flex-direction: column-reverse; } + .mock-context { position: static; } +} +@media (max-width: 600px) { + .mock-page { padding: var(--space-xl) var(--space-m); } + .mock-stage .mock-chat-window, .mock-stage .mock-settings-window { width: 100%; } + .vertical-tab-content { padding: var(--space-l); } + .nexus-presets { grid-template-columns: 1fr 1fr; } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition: none !important; } +} diff --git a/docs/mockups/defaults-settings-options.html b/docs/mockups/defaults-settings-options.html new file mode 100644 index 000000000..19b542ccd --- /dev/null +++ b/docs/mockups/defaults-settings-options.html @@ -0,0 +1,40 @@ + + + + + + Nexus · Defaults tab and chat settings: 10 options + + + + +
+
+ NEXUS / DEFAULTS + CHAT SETTINGS · 10 OPTIONS +

Same settings. Ten ways to lay them out.

+

Each option redraws two surfaces: Settings → Nexus → Defaults and the Chat settings modal behind the chat gear. Every field that exists today is still there. Nothing else in the plugin changes.

+
+ +
+ + +
+ + +
+
+ + +
+ +
+ + + +
DRAFT · 14 OCT 2026 Proposed layouts, not implemented. Values are sample data, and nothing is saved.
+
+ + diff --git a/docs/mockups/defaults-settings-options.js b/docs/mockups/defaults-settings-options.js new file mode 100644 index 000000000..4c64ee966 --- /dev/null +++ b/docs/mockups/defaults-settings-options.js @@ -0,0 +1,432 @@ +// Standalone simulation only. No persistence, provider calls, or plugin integration. +// Every option renders the same field inventory: the Defaults tab and ChatSettingsModal as they ship today. + +const paths = { + 'chevron-right': '', + 'chevron-left': '', + 'chevrons-up-down': '', + search: '', + x: '', + check: '', + plus: '', + 'rotate-ccw': '', + 'file-text': '', + sparkles: '', + bot: '', + box: '', + image: '', + film: '', + mic: '', + file: '', + zap: '', + scale: '', + brain: '', + 'sliders-horizontal': '', + globe: '', +}; +const ic = name => ``; + +/* ---------------- field inventory (matches what ships) ---------------- */ +const THINK = ['Off', 'Low', 'Medium', 'High', 'Extra high', 'Max']; +const WORKSPACES = ['None', 'Daily Notes & Workflow', 'On Networks', 'Personal Operations', 'House Weatherization', 'Job Search Command Center']; +const PROMPTS = ['None', 'meditations-on-alignment', 'job-application-materials']; +const PROVIDERS = ['Anthropic', 'OpenRouter', 'Google AI']; + +const sections = [ + { id: 'chat', name: 'Chat model', short: 'Chat', icon: 'sparkles' }, + { id: 'context', name: 'Context', short: 'Context', icon: 'box' }, + { id: 'agent', name: 'Subagents', short: 'Subagents', icon: 'bot', desc: 'Model for subagents, saved prompts and automations. Must be a cloud model.' }, + { id: 'image', name: 'Images', short: 'Images', icon: 'image' }, + { id: 'video', name: 'Video', short: 'Video', icon: 'film', only: 'defaults' }, + { id: 'voice', name: 'Voice', short: 'Voice', icon: 'mic' }, + { id: 'files', name: 'Files and search', short: 'Files', icon: 'file', only: 'defaults' }, +]; + +const fields = [ + { id: 'chat-model', sec: 'chat', name: 'Model', kind: 'model', value: 'Claude Opus 5.5', chatValue: 'Claude Sonnet 5.5', provider: 'Anthropic', common: true }, + { id: 'chat-thinking', sec: 'chat', name: 'Thinking', kind: 'select', opts: THINK, value: 'High', common: true, desc: 'Off turns reasoning off. Hidden for models that can’t reason.' }, + { id: 'creativity', sec: 'chat', name: 'Creativity', kind: 'slider', value: '0.5', min: 0, max: 1, step: 0.1, desc: 'Lower is more focused, higher is more creative.' }, + { id: 'chat-context', sec: 'chat', name: 'Context window', kind: 'slider', value: '1M tokens', min: 0, max: 100, step: 1, desc: 'Older messages are summarized past this size.' }, + { id: 'web-search', sec: 'chat', name: 'Web search', kind: 'toggle', value: false, only: 'chat', desc: 'Let the model search the web when needed.' }, + { id: 'workspace', sec: 'context', name: 'Workspace', kind: 'select', opts: WORKSPACES, value: 'None', chatValue: 'On Networks', common: true }, + { id: 'workflow', sec: 'context', name: 'Workflow', kind: 'select', opts: ['None', 'Episode prep'], value: 'None', only: 'chat', desc: 'Loads its instructions and tools. Steps run only when you ask.' }, + { id: 'prompt', sec: 'context', name: 'Prompt', kind: 'select', opts: PROMPTS, value: 'None', common: true }, + { id: 'notes', sec: 'context', name: 'Context notes', kind: 'notes', value: ['Vault Map of Content', 'AGENTS'] }, + { id: 'agent-model', sec: 'agent', name: 'Model', kind: 'model', value: 'Claude Haiku 5.5', provider: 'OpenRouter' }, + { id: 'agent-thinking', sec: 'agent', name: 'Thinking', kind: 'select', opts: THINK, value: 'Off' }, + { id: 'image-model', sec: 'image', name: 'Image model', kind: 'model', value: 'Gemini 3.1 Flash Image', provider: 'Google AI' }, + { id: 'video-model', sec: 'video', name: 'Video model', kind: 'model', value: 'Veo 3.1', provider: 'Google AI' }, + { id: 'video-aspect', sec: 'video', name: 'Aspect ratio', kind: 'select', opts: ['16:9', '9:16'], value: '16:9' }, + { id: 'video-res', sec: 'video', name: 'Resolution', kind: 'select', opts: ['720p', '1080p'], value: '720p' }, + { id: 'stt-model', sec: 'voice', name: 'Transcription', kind: 'model', value: 'Deepgram Nova-3', provider: 'OpenRouter', desc: 'Chat microphone and audio files.' }, + { id: 'tts-model', sec: 'voice', name: 'Read-aloud model', kind: 'model', value: 'HeyGen Voice', provider: 'OpenRouter' }, + { id: 'tts-voice', sec: 'voice', name: 'Read-aloud voice', kind: 'select', opts: ['Provider default', 'Alloy', 'Verse'], value: 'Alloy' }, + { id: 'audio-folder', sec: 'voice', name: 'Saved audio folder', kind: 'text', value: 'audio', only: 'defaults', desc: 'Inside the Nexus data folder.' }, + { id: 'live-model', sec: 'voice', name: 'Live voice model', kind: 'model', value: 'None available', provider: '', disabled: true, desc: 'None of your providers offer live voice.' }, + { id: 'live-output', sec: 'voice', name: 'Live voice output', kind: 'select', opts: ['Provider default'], value: 'Provider default', disabled: true }, + { id: 'pdf-enable', sec: 'files', name: 'PDF import', kind: 'toggle', value: true, only: 'defaults', desc: 'Drop PDFs into chat to turn them into notes.' }, + { id: 'pdf-mode', sec: 'files', name: 'Reading method', kind: 'select', opts: ['Text extraction', 'Vision'], value: 'Text extraction', only: 'defaults', desc: 'Text is free. Vision reads scanned pages with a model.', parent: 'pdf-enable' }, + { id: 'pdf-ocr', sec: 'files', name: 'Vision model', kind: 'model', value: 'Gemini 3 Flash', provider: 'Google AI', only: 'defaults', desc: 'Only used with Vision.', parent: 'pdf-enable' }, + { id: 'pdf-auto', sec: 'files', name: 'Convert new PDFs automatically', kind: 'toggle', value: false, only: 'defaults', desc: 'Adds a Markdown note next to each new PDF.', parent: 'pdf-enable' }, + { id: 'embeddings', sec: 'files', name: 'Semantic search', kind: 'toggle', value: true, only: 'defaults', desc: 'Local embeddings, about 23 MB. Applies after restart.' }, +]; +const F = Object.fromEntries(fields.map(f => [f.id, f])); +const secOnly = id => sections.find(x => x.id === id)?.only; +const onlyOf = x => x.only || (x.sec ? secOnly(x.sec) : undefined); +const inSurface = (x, s) => !onlyOf(x) || onlyOf(x) === s; +const secsFor = s => sections.filter(x => inSurface(x, s)); +const fieldsFor = (s, sec) => fields.filter(f => inSurface(f, s) && (!sec || f.sec === sec)); +const val = (f, s) => (s === 'chat' && f.chatValue) ? f.chatValue : f.value; +const show = (f, s) => { + const v = val(f, s); + if (f.kind === 'toggle') return v ? 'On' : 'Off'; + if (f.kind === 'notes') return `${v.length} notes`; + return v; +}; +const summary = (secId, s, n = 3) => fieldsFor(s, secId).filter(f => !f.disabled).slice(0, n).map(f => { + if (f.id.endsWith('thinking')) return `${show(f, s)} thinking`; + if (f.kind === 'toggle') return `${f.name} ${show(f, s).toLowerCase()}`; + return show(f, s); +}).join(' · '); + +/* ---------------- control + row primitives (Obsidian Setting shape) ---------------- */ +const opts = (arr, v) => arr.map(o => `${o}`).join(''); +function control(f, s, o = {}) { + const v = val(f, s); + const dis = f.disabled ? ' disabled' : ''; + switch (f.kind) { + case 'model': return ``; + case 'select': return ``; + case 'toggle': return `
`; + case 'slider': return `${v}`; + case 'text': return ``; + case 'notes': return ``; + } + return ''; +} +function row(f, s, o = {}) { + const desc = o.desc !== undefined ? o.desc : (f.kind === 'model' && f.provider ? f.provider : (f.desc || '')); + const name = o.name || f.name; + const cls = ['setting-item', f.disabled ? 'is-disabled' : '', f.parent ? 'nexus-child' : '', o.cls || ''].join(' '); + let html = `
+
${name}
${desc ? `
${desc}
` : ''}
+
${o.control !== undefined ? o.control : control(f, s, o)}
`; + if (f.kind === 'notes' && !o.compact) { + const list = s === 'chat' ? ['Episode 05 outline', ...f.value] : f.value; + html += list.map(n => `
${ic('file-text')}${n}
`).join(''); + } + return html; +} +function splitModelRows(f, s) { + return row(f, s, { name: f.sec === 'chat' || f.sec === 'agent' ? 'Provider' : `${f.name.replace(/ model$/, '')} provider`, desc: '', control: `` }) + + row(f, s, { desc: '', control: `` }); +} +const heading = (name, desc = '', ctl = '') => `
${name}
${desc ? `
${desc}
` : ''}
${ctl ? `
${ctl}
` : ''}
`; +const lede = s => s === 'defaults' + ? '

Used when you start a new chat.

' + : ''; + +/* ---------------- state ---------------- */ +const st = { variant: 0, surface: 'defaults', overview: true, v3: null, v4: 'chat', v8: 'chat', preset: 'deep', v9: '', v9sec: 'all', v10: ['chat-model', 'workspace'] }; + +/* ---------------- the ten options ---------------- */ +const variants = [ + { + name: 'Tidy list', + tag: 'Smallest change', + idea: 'Today’s single page with the rough edges fixed. The sections are ordered by how often they’re used, and each uses an Obsidian heading instead of a boxed panel.', + good: ['Almost a pure restyle, with no new navigation to learn.', 'Matches every other Obsidian plugin’s settings page.'], + cost: ['Still one long page with about 27 rows in Defaults.', 'Provider and model stay as separate dropdowns.'], + build: 'Plain Setting rows with setHeading(). Merges the Reasoning toggle and Effort slider into one Thinking dropdown, and shows each slider value once instead of twice.', + render(s) { + return lede(s) + secsFor(s).map(sec => heading(sec.name, sec.desc) + fieldsFor(s, sec.id).map(f => f.kind === 'model' ? splitModelRows(f, s) : row(f, s)).join('')).join(''); + }, + }, + { + name: 'Essentials + folds', + tag: 'Progressive disclosure', + idea: 'The four settings people actually change sit at the top. Every other section is folded, and its summary line shows the current values, so you can check them without opening it.', + good: ['Short on first open. The common path takes no scrolling.', 'The summary lines make a quick audit possible.'], + cost: ['Two places to look for chat settings (the top block and the Chat model fold).', 'Folded content is hidden from Ctrl+F.'], + build: 'Setting rows inside
elements. Remembers which folds are open, per NN/g guidance. One merged model picker per job.', + render(s) { + const common = fields.filter(f => f.common && inSurface(f, s)); + let h = lede(s) + heading(s === 'chat' ? 'This chat' : 'New chats') + common.map(f => row(f, s)).join(''); + h += secsFor(s).map(sec => { + const rest = fieldsFor(s, sec.id).filter(f => !f.common); + if (!rest.length) return ''; + const label = sec.id === 'chat' ? 'More chat options' : sec.id === 'context' ? 'More context' : sec.name; + return `
${ic('chevron-right')}${label}${rest.slice(0, 3).map(f => show(f, s)).join(' · ')}
${rest.map(f => row(f, s)).join('')}
`; + }).join(''); + return h; + }, + }, + { + name: 'Drill-in pages', + tag: 'Native sub-pages', + idea: 'A short index with one row per section. Each row shows its current values. Clicking a row opens that section on its own page.', + good: ['The index fits on one screen.', 'Fits Obsidian 1.13 settings pages, so each setting shows up in Obsidian’s settings search.'], + cost: ['Changing anything takes a click to reach the page first.', 'Inside the chat modal, pages feel heavier than in Settings.'], + build: 'Uses Obsidian 1.13’s declarative page type in Settings (raises minAppVersion from 1.10) and an in-modal back stack in Chat settings.', + render(s) { + if (st.v3) { + const sec = sections.find(x => x.id === st.v3); + return `
${s === 'chat' ? 'Chat settings' : 'Defaults'}/

${sec.name}

` + + (sec.desc ? `

${sec.desc}

` : '') + + fieldsFor(s, sec.id).map(f => row(f, s)).join(''); + } + return lede(s) + secsFor(s).map(sec => `
+
${ic(sec.icon)}${sec.name}
${summary(sec.id, s)}
+
${ic('chevron-right')}
`).join(''); + }, + }, + { + name: 'Section sidebar', + tag: 'Master–detail', + idea: 'A small section list on the left and the chosen section on the right. In the chat modal the list becomes a row of tabs.', + good: ['Everything is one click away, and the section list never scrolls away.', 'Each pane is short.'], + cost: ['It’s a second sidebar inside Obsidian’s own settings sidebar.', 'Narrow panes on small windows. Phone width falls back to tabs.'], + build: 'A custom two-column layout inside the tab with Setting rows in the pane. No new primitives needed.', + render(s) { + const list = secsFor(s); + if (!list.find(x => x.id === st.v4)) st.v4 = 'chat'; + const nav = list.map(sec => ``).join(''); + const sec = list.find(x => x.id === st.v4); + return `
${heading(sec.name, sec.desc)}${fieldsFor(s, sec.id).map(f => row(f, s)).join('')}
`; + }, + }, + { + name: 'Model routing table', + tag: 'Everything at a glance', + idea: 'Nexus uses up to eight different models, each for a different job. One table lists each job, the model doing it, and that job’s options. Chat behaviour, context and file settings go underneath.', + good: ['Every model choice is visible at once, so it’s obvious which jobs aren’t set up.', 'Matches how power users think about it: one model per job.'], + cost: ['Dense. Can look technical to new users.', 'The table turns into stacked cards on phones.'], + build: 'A custom table. Model cells open a searchable SuggestModal, and option cells are compact dropdowns.', + render(s) { + const jobs = [ + ['Chat', 'chat-model', ['chat-thinking']], ['Subagents', 'agent-model', ['agent-thinking']], ['Images', 'image-model', []], + ['Video', 'video-model', ['video-aspect', 'video-res']], ['Transcription', 'stt-model', []], ['Read aloud', 'tts-model', ['tts-voice']], + ['Live voice', 'live-model', ['live-output']], ['PDF vision', 'pdf-ocr', []], + ].filter(([, m]) => inSurface(F[m], s)); + const table = `${jobs.map(([job, m, o]) => { + const f = F[m]; + return ``; + }).join('')}
JobModelOptions
${job}${f.provider ? `
${f.provider}
` : ''}
${o.map(id => { const x = F[id]; return ``; }).join('') || '—'}
`; + const behavior = ['creativity', 'chat-context', 'web-search'].map(id => F[id]).filter(f => inSurface(f, s)); + const files = fieldsFor(s, 'files').filter(f => f.id !== 'pdf-ocr').concat(inSurface(F['audio-folder'], s) ? [F['audio-folder']] : []); + return lede(s) + table + heading('Chat behaviour') + behavior.map(f => row(f, s)).join('') + + heading('Context') + fieldsFor(s, 'context').map(f => row(f, s)).join('') + + (files.length ? heading('Files and storage') + files.map(f => row(f, s)).join('') : ''); + }, + }, + { + name: 'Presets', + tag: 'Pick a mode', + idea: 'Three presets set the chat model, thinking and creativity in one click. "Custom" opens every setting. Context and the other models sit below the presets.', + good: ['Newcomers decide in one click without learning what "effort" means.', 'Power users still get every setting under Custom.'], + cost: ['Presets are a new idea that has to be named and maintained, and the models in them go out of date.', 'Changing any single value quietly turns the preset into "Custom".'], + build: 'Presets only write existing settings keys; nothing new is stored. The active preset is worked out by matching the current values.', + render(s) { + const ps = [ + ['quick', 'Quick', 'zap', 'Claude Haiku 5.5', 'Thinking off · creativity 0.3'], + ['balanced', 'Balanced', 'scale', 'Claude Sonnet 5.5', 'Medium thinking · creativity 0.5'], + ['deep', 'Deep', 'brain', 'Claude Opus 5.5', 'High thinking · creativity 0.5'], + ['custom', 'Custom', 'sliders-horizontal', 'Your choice', 'Set everything yourself'], + ]; + const cards = `
${ps.map(([id, n, i, m, d]) => ``).join('')}
`; + const chatRows = fieldsFor(s, 'chat').filter(f => f.id !== 'web-search').map(f => row(f, s)).join(''); + const others = ['agent', 'image', 'video', 'voice'].filter(id => inSurface(sections.find(x => x.id === id), s)); + return lede(s) + cards + + `
${ic('chevron-right')}Customize${summary('chat', s, 4)}
${chatRows}
` + + (s === 'chat' ? row(F['web-search'], s) : '') + + heading('Context') + fieldsFor(s, 'context').map(f => row(f, s)).join('') + + `
${ic('chevron-right')}Other modelsSubagents, images${s === 'defaults' ? ', video' : ''}, voice
${others.map(id => heading(sections.find(x => x.id === id).name) + fieldsFor(s, id).map(f => row(f, s)).join('')).join('')}
` + + (s === 'defaults' ? heading('Files and search') + fieldsFor(s, 'files').map(f => row(f, s)).join('') : ''); + }, + }, + { + name: 'Plain sentences', + tag: 'Reads like English', + idea: 'The settings are written as sentences, with each choice an inline dropdown. Anything that doesn’t fit a sentence goes in a short "Details" list below.', + good: ['Very approachable. Explains what each setting does as you read it.', 'Distinctive without being decorative.'], + cost: ['Harder to scan for one specific setting.', 'Inline dropdowns wrap awkwardly at narrow widths, and the sentences need care to translate.'], + build: 'Custom inline tokens: native selects styled as underlined text, and buttons for the model pickers. A details list uses Setting rows.', + render(s) { + const t = id => { const f = F[id]; const v = val(f, s); return f.kind === 'model' ? `` : ``; }; + const onoff = (id, a, b) => ``; + const p = []; + p.push(`${s === 'chat' ? 'This chat uses' : 'New chats use'} ${t('chat-model')} with ${t('chat-thinking')} thinking.`); + p.push(`${s === 'chat' ? 'It works' : 'They start'} in ${t('workspace')}${s === 'chat' ? ` with the ${t('workflow')} workflow,` : ''} using ${t('prompt')} as the prompt.`); + if (s === 'chat') p.push(`Web search is ${onoff('web-search', 'allowed', 'off')}.`); + p.push(`Subagents run on ${t('agent-model')} with ${t('agent-thinking')} thinking.`); + p.push(`Images come from ${t('image-model')}${s === 'defaults' ? `, and video from ${t('video-model')} at ${t('video-aspect')} ${t('video-res')}` : ''}.`); + p.push(`Dictation uses ${t('stt-model')}. Read aloud uses ${t('tts-model')} with the ${t('tts-voice')} voice.`); + if (s === 'defaults') p.push(`Dropped PDFs are ${onoff('pdf-enable', 'imported', 'ignored')} using ${t('pdf-mode')}, and new PDFs ${onoff('pdf-auto', 'are', 'aren’t')} converted automatically.`); + const details = ['creativity', 'chat-context', 'notes', 'live-model', 'live-output', 'pdf-ocr', 'audio-folder', 'embeddings'].map(id => F[id]).filter(f => inSurface(f, s)); + return lede(s) + `
${p.map(x => `

${x}

`).join('')}
` + heading('Details') + details.map(f => row(f, s)).join(''); + }, + }, + { + name: 'Tiles', + tag: 'Visual dashboard', + idea: 'Each section is a tile showing its main value large. Clicking a tile opens its settings in a panel under the grid.', + good: ['Friendly and easy to scan; the most "designed" option.', 'Easy to show a warning on a tile, such as "No model available".'], + cost: ['Takes more space for the same information.', 'Opening a panel under the grid moves the content below it.'], + build: 'A custom CSS grid of buttons, with an editor panel of Setting rows. On phones, the tiles stack into two columns.', + render(s) { + const list = secsFor(s); + if (!list.find(x => x.id === st.v8)) st.v8 = 'chat'; + const main = { chat: 'chat-model', context: 'workspace', agent: 'agent-model', image: 'image-model', video: 'video-model', voice: 'stt-model', files: 'pdf-mode' }; + const tiles = list.map(sec => { const f = F[main[sec.id]]; const sub = summary(sec.id, s).split(' · ').slice(1, 3).join(' · '); + return ``; }).join(''); + const sec = list.find(x => x.id === st.v8); + return lede(s) + `
${tiles}
${heading(sec.name, sec.desc, ``)}${fieldsFor(s, sec.id).map(f => row(f, s)).join('')}
`; + }, + }, + { + name: 'Search first', + tag: 'Find, don’t browse', + idea: 'A search box and section filter chips over one flat list. Each row carries a section tag, so the list stays readable while filtered.', + good: ['The fastest way to reach a setting if you know roughly what it’s called.', 'Scales as more settings are added.'], + cost: ['No sense of hierarchy, and every row looks equally important.', 'Duplicates Obsidian’s own settings search (1.13+).'], + build: 'A text input and chips filter Setting rows on the client. Each row carries data-section and searchable text.', + render(s) { + const chips = [['all', 'All']].concat(secsFor(s).map(x => [x.id, x.short])).map(([id, n]) => ``).join(''); + const items = fieldsFor(s).map(f => { const sec = sections.find(x => x.id === f.sec); + return row(f, s, { name: `${f.name}${sec.short}` }); }).join(''); + return lede(s) + `
${chips}
${items}
`; + }, + after() { applySearch(); }, + }, + { + name: 'Defaults vs this chat', + tag: 'Scope made explicit', + idea: 'Defaults splits what each chat can change from what applies everywhere. Chat settings lists only what this chat changed, each with a reset. Everything else sits in a quiet "using defaults" list, where one click overrides a setting.', + good: ['Removes the confusion over whether a change affects only this chat or every chat.', 'The chat modal is usually two or three rows long.'], + cost: ['A new idea, overrides, that needs one clear sentence of explanation.', 'Defaults gains a second grouping on top of the sections.'], + build: 'Uses the per-conversation settings that already exist; an override is any value that differs from the global default. Changes apply as soon as they’re made, with a Done button.', + render(s) { + if (s === 'defaults') { + const perChat = fields.filter(f => inSurface(f, 'defaults') && inSurface(f, 'chat')); + const global = fields.filter(f => onlyOf(f) === 'defaults'); + return lede(s) + + heading('New chats start with', 'Any chat can change these from its own settings.') + perChat.map(f => row(f, s)).join('') + + heading('Everywhere', 'These apply to every chat and can’t be changed per chat.') + global.map(f => row(f, s)).join(''); + } + const changed = st.v10.map(id => F[id]); + const rest = fieldsFor('chat').filter(f => !st.v10.includes(f.id)); + const defVal = { 'chat-model': 'Claude Opus 5.5', workspace: 'None' }; + return `
${ic('sparkles')}Changes apply right away, to this chat only.
` + + heading('Changed for this chat') + + changed.map(f => row(f, 'chat', { desc: `Default: ${defVal[f.id] ?? show(f, 'defaults')}`, cls: 'is-override', control: control(f, 'chat') + `` })).join('') + + heading('Using your defaults', 'Click one to change it for this chat.') + + `
${rest.map(f => ``).join('')}
`; + }, + chatFooter: '', + }, +]; + +/* ---------------- surface frames ---------------- */ +function frame(v, s, body) { + if (s === 'defaults') { + return `
+ +
${body}
`; + } + const footer = v.chatFooter || ''; + return `
+
`; +} + +/* ---------------- page wiring ---------------- */ +const $ = id => document.getElementById(id); +const variantSel = $('mock-variant'); +variantSel.innerHTML = variants.map((v, i) => ``).join(''); + +function renderContext(v, i) { + $('mock-context').innerHTML = `
Option ${i + 1} of 10

${v.name}

${v.tag}
+

${v.idea}

Good

    ${v.good.map(x => `
  • ${x}
  • `).join('')}
+

Trade-offs

    ${v.cost.map(x => `
  • ${x}
  • `).join('')}

Build notes

${v.build}

+
`; +} +function renderStage() { + const v = variants[st.variant]; + $('mock-stage').innerHTML = frame(v, st.surface, v.render(st.surface)); + renderContext(v, st.variant); + v.after?.(); +} +function renderOverview() { + $('mock-overview-grid').innerHTML = variants.map((v, i) => `
+
${frame(v, st.surface, v.render(st.surface))}
+
${i + 1}${v.name}${v.tag}
`).join(''); +} +function render() { + $('mock-overview-grid').hidden = !st.overview; + $('mock-detail').hidden = st.overview; + $('mock-overview').setAttribute('aria-pressed', String(st.overview)); + variantSel.value = String(st.variant); + document.querySelectorAll('[data-surface]').forEach(b => b.setAttribute('aria-checked', String(b.dataset.surface === st.surface))); + if (st.overview) renderOverview(); else renderStage(); +} +function applySearch() { + const q = st.v9.trim().toLowerCase(); + let shown = 0; + document.querySelectorAll('.nexus-search-results > .setting-item').forEach(el => { + const ok = (st.v9sec === 'all' || el.dataset.sec === st.v9sec) && (!q || el.dataset.text.includes(q)); + el.hidden = !ok; if (ok) shown++; + }); + const empty = document.querySelector('[data-v9-empty]'); + if (empty) empty.hidden = shown > 0; +} + +document.addEventListener('click', e => { + const t = e.target; + const open = t.closest('[data-open]'); + if (open) { st.variant = Number(open.dataset.open); st.overview = false; render(); window.scrollTo({ top: $('mock-detail').offsetTop - 16 }); return; } + if (t.closest('#mock-overview')) { st.overview = !st.overview; render(); return; } + const surf = t.closest('[data-surface]'); + if (surf) { st.surface = surf.dataset.surface; st.v3 = null; render(); return; } + const step = t.closest('[data-step]'); + if (step) { st.variant = (st.variant + Number(step.dataset.step) + 10) % 10; st.v3 = null; render(); return; } + const v3 = t.closest('[data-v3]'); if (v3) { st.v3 = v3.dataset.v3 || null; renderStage(); return; } + const v4 = t.closest('[data-v4]'); if (v4) { st.v4 = v4.dataset.v4; renderStage(); return; } + const v8 = t.closest('[data-v8]'); + if (v8) { st.v8 = v8.dataset.v8 || (st.v8 ? '' : 'chat'); if (!st.v8) { document.querySelector('.nexus-tile-panel').hidden = true; document.querySelectorAll('.nexus-tile').forEach(x => x.classList.remove('is-active')); } else renderStage(); return; } + const preset = t.closest('[data-preset]'); if (preset) { st.preset = preset.dataset.preset; renderStage(); return; } + const v9sec = t.closest('[data-v9sec]'); if (v9sec) { st.v9sec = v9sec.dataset.v9sec; document.querySelectorAll('[data-v9sec]').forEach(c => c.classList.toggle('is-active', c === v9sec)); applySearch(); return; } + const add = t.closest('[data-v10-add]'); if (add) { st.v10.push(add.dataset.v10Add); renderStage(); return; } + const reset = t.closest('[data-v10-reset]'); if (reset) { st.v10 = st.v10.filter(id => id !== reset.dataset.v10Reset); renderStage(); return; } + const tg = t.closest('.checkbox-container'); + if (tg && !t.closest('.mock-thumb')) { + const on = !tg.classList.contains('is-enabled'); + tg.classList.toggle('is-enabled', on); tg.setAttribute('aria-checked', String(on)); + if (tg.dataset.toggle === 'pdf-enable') document.querySelectorAll('.mock-stage [data-field].nexus-child').forEach(r => { if (F[r.dataset.field]?.parent === 'pdf-enable') r.hidden = !on; }); + } +}); +document.addEventListener('input', e => { + if (e.target.matches('[data-v9]')) { st.v9 = e.target.value; applySearch(); } + if (e.target.matches('[data-slider]')) { + const out = e.target.previousElementSibling; + out.textContent = e.target.dataset.slider === 'creativity' ? Number(e.target.value).toFixed(1) : (e.target.value >= 100 ? '1M tokens' : `${Math.max(8, Math.round(e.target.value * 10))}K tokens`); + } +}); +document.addEventListener('keydown', e => { + if ((e.key === 'Enter' || e.key === ' ') && e.target.matches('[role="button"], .checkbox-container')) { e.preventDefault(); e.target.click(); } + if (!st.overview && !e.target.matches('input, select') && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) { st.variant = (st.variant + (e.key === 'ArrowRight' ? 1 : -1) + 10) % 10; st.v3 = null; render(); } +}); +variantSel.addEventListener('change', () => { st.variant = Number(variantSel.value); st.overview = false; st.v3 = null; render(); }); +$('mock-theme').addEventListener('click', e => { + const light = document.body.classList.toggle('theme-light'); + document.body.classList.toggle('theme-dark', !light); + e.currentTarget.setAttribute('aria-pressed', String(light)); +}); +$('mock-phone').addEventListener('click', e => { + const on = document.body.classList.toggle('is-mobile'); + e.currentTarget.setAttribute('aria-pressed', String(on)); +}); + +render(); diff --git a/docs/plans/defaults-settings-redesign-plan.md b/docs/plans/defaults-settings-redesign-plan.md new file mode 100644 index 000000000..fd392947d --- /dev/null +++ b/docs/plans/defaults-settings-redesign-plan.md @@ -0,0 +1,679 @@ +# Implementation Plan: Defaults, Chat Settings and Get Started Redesign + +> Analysis date: 2026-10-14 +> Source baseline: `daa9abd5` (Nexus 5.20.4) +> Status: proposed. Nothing under `src/`, `styles.css` or `tests/` has changed. Only this document was written. +> Visual contract: `docs/mockups/defaults-settings-essentials.html` and its same-stem CSS/JS, accepted by the owner on 2026-10-14 against plugin version 5.20.4 ("Essentials + folds", option 2, round 3). `docs/mockups/defaults-settings-options.html` (+ CSS/JS) is the 10-option exploration and is reference only. + +Cite the mockup by file, never by line number. Copy strings that matter are lifted into the [copy appendix](#appendix-a--copy-lifted-from-the-mockup) so tests can hold them as constants without pointing at the mockup. + +## Outcome + +Three surfaces change: the Settings → Nexus → **Defaults** tab, the in-chat **Chat settings** modal and the **Get started** entry above the tab strip. Semantic search gets real settings behaviour as well. The other settings tabs keep their current behaviour. + +- **Defaults and Chat settings** keep their shared renderer and show the "Essentials + folds" layout. There is no intro line and no heading above the top block. The top block is always open and holds Provider, Model and Thinking. Below it are icon folds, collapsed by default, each showing a one-line summary: Chat options, Context, Subagents, Images, Video, Voice, File conversion and Semantic search. The selected model's capabilities decide which controls appear and whether they are enabled. +- **Get started** becomes one navigable row above the tab strip. It opens in-tab pages (Get started → Chat inside Obsidian / Connect an external agent) that use the existing `BackButton` and `SettingsRouter`. The external-agent page checks for Node.js first, adds Antigravity next to Claude Desktop and Codex, and replaces the "Other agents" manual flow with four numbered steps. +- **Semantic search** stays on by default. It shows its index status. Turning it off asks for confirmation and takes effect immediately, with no restart. The index is kept, and a separate **Delete index** button appears only while search is off. Stale vectors are pruned. When search is off, agents are not offered semantic search. +- Bug fixes, the high-priority one first: five call sites ask the service registry for an `embeddingService` that nothing registers. + +## What the code actually does + +Line numbers are from the baseline above and come from reading the code for this plan. + +**Rendering** + +- `ChatSettingsRenderer.render()` empties its container (`src/components/shared/ChatSettingsRenderer.ts:210`) and rebuilds the whole form whenever any of these changes: + - LLM settings (`:194`) + - chat or subagent provider/model (`ModelDropdownRenderer.ts:188`, `:287` via `reRender`) + - image provider (`:682`) + - workspace when workflows are shown (`:1270`) + - prompt sync (`:1327`) + + Fold state and scroll position are lost on every redraw. +- The section order today is chat model (with Reasoning, Effort, Context window, Web search), Subagent model, Image model, the Defaults-only `renderAfterImageSection` hook (Video, Voice), Voice/Transcription, Temperature, Context (`:206-219`). Thinking is a Reasoning toggle plus an Effort slider (`:344-418`). Both readouts are hand-made spans (`csr-effort-value`, `csr-context-window-value`). The second span has **no CSS rule** (`styles.css` only defines `.csr-effort-value` at 4473 and `.csr-temp-value` at 4482). +- Context window is a 1-token-step slider from 1,024 to the model maximum (`:452-479`). Keys are `getContextWindowOverrideKey(provider, model)` and values are clamped by `resolveContextWindowLimit` (`src/ui/chat/utils/ContextWindowSettings.ts`). +- Creativity is always shown, range 0–1 (`:600-623`). `StaticModelsService.ModelWithProvider.capabilities` does not carry `supportsSamplingParams` (`src/services/StaticModelsService.ts:23-40`, `:210-222`), so the renderer cannot know that Claude Opus/Sonnet/Haiku 5.5, Fable 5/5.1 and others reject temperature (`AnthropicModels.ts`, 10 `supportsSamplingParams: false` entries). The adapters already drop the parameter (`adapters/shared/SamplingParams.ts`). +- Voice is implemented twice: + - `DefaultsTab.renderVoiceSection` / `renderSpeechSettings` / `renderRealtimeVoiceSettings` (`src/settings/tabs/DefaultsTab.ts:473-982`) writes straight to `llmProviderSettings` and calls `saveSettings()` outside the tab's save queue. + - `ChatSettingsRenderer.renderVoiceSection` (`:777-1240`) writes to the pending settings object. + + The labels differ ("Voice input" vs "Transcription", "Speech provider", "Live voice provider"). +- Context notes use raw `onclick` assignments (`:1310`, `:1346`), which breaks the `registerDomEvent` rule. +- `Accordion` (`src/components/Accordion.ts`) is used only by `SettingsView` for Get started. `getContentEl()` creates a **new** child div every time it is called (`:91-94`). + +**Subagent settings mostly have no effect** + +- `LLMService.getAgentModel()` returns the chat default model unless that model is local (`src/services/llm/core/LLMService.ts:126-151`). The saved subagent model therefore only matters for `executePrompt` when the chat model is WebLLM, Ollama or LM Studio. +- The `subagent` tool inherits the parent chat's provider, model and thinking (`src/agents/promptManager/tools/subagent.ts:232-243`, built by `SubagentController.buildSubagentContext` at `src/ui/chat/controllers/SubagentController.ts:455-483`). It never reads `agentModel` or `agentThinking`. +- `SubagentExecutor` receives `thinkingEnabled`/`thinkingEffort` but does not forward them to its streaming generator (`src/services/chat/SubagentExecutor.ts:367-375`). The generator's options type has no thinking or temperature fields (`SubagentController.ts:315-358`). +- `PromptExecutor` sends no temperature and no thinking (`src/agents/promptManager/tools/executePrompts/services/PromptExecutor.ts:89-97`). +- `agentThinking` is saved and restored (`ModelAgentDefaultsResolver.ts:89-92`, `ModelAgentManager.ts:309-313`) but no execution path reads it. + +**Thinking state** + +`renderReasoningControls` returns early for models without `supportsThinking` (`CSR:352`). `thinking.enabled` stays `true` but hidden, and `ModelAgentPromptContextAssembler.buildMessageOptions` still sends `enableThinking: true` (`:120`, `:126`). Some adapters gate on capability (e.g. `AnthropicAdapter.ts:185`, `:428`). Not every adapter was checked. + +**Context window ownership** + +- Each conversation already has its own budget: `committedContextWindow`, `getEffectiveContextWindow()` and `effectiveContextWindow` in the conversation metadata (`ModelAgentManager.ts:687-698`, `:1234`). +- The Chat settings modal *additionally* merges the edited value into the global `contextWindowOverrides` and saves it (`src/ui/chat/components/ChatSettingsModal.ts:234-258`). PR #410 did this on purpose, and `tests/unit/ChatSettingsModalContextHandoff.test.ts` locks it in. +- In the other direction, a Defaults edit is pushed to the active chat when its model matches (`DefaultsTab.ts:154-161` → `ChatView.applyContextWindowDefault`, `src/ui/chat/ChatView.ts:1145-1155`). This is also deliberate and tested (`tests/unit/DefaultsTabContextHandoff.test.ts`). + +**Embeddings** + +- `enableEmbeddings` is read once, after storage is ready (`src/core/PluginLifecycleManager.ts:362-371`). `EmbeddingManager.initialize()` returns early when disabled (`src/services/embeddings/EmbeddingManager.ts:91`). Nothing listens for later changes. +- The dream cycle uses `registerInterval` plus `addCommand` (`:301-336`). Neither can be undone today. +- `getStats()` (`:265-294`) and the `IndexingQueue` `progress` events (`IndexingQueue.ts:39-46`, `:587`, with pause/resume at `:288-325`) exist, but only the status bar reads them. +- Re-enabling is incremental. `filterUnindexedNotes` compares `contentHash` (`IndexingQueue.ts:392-414`). +- Stale vectors are never pruned. That pass walks only existing files, and `NoteEmbeddingService.embedNote` removes a vector only when it is asked to embed a path that has gone (`NoteEmbeddingService.ts:127-133`). +- **"Rebuild cache" does not delete note or trace vectors.** `SQLiteMaintenanceService.clearAllData()` drops and recreates only `conversation_embeddings` and clears `conversation_embedding_metadata` and `embedding_backfill_state` (`src/database/storage/SQLiteMaintenanceService.ts:93-118`). `note_embeddings`, `embedding_metadata`, `trace_embeddings` and `trace_embedding_metadata` survive. Nothing deletes them. + +**Settings sync** + +`Settings.reconcileExternalSettings` treats a synced change to any non-`llmProviders` key as "needs reload" (`src/settings.ts:182-201`). Two consequences: +- `enableEmbeddings` is synced, and a change made on another device currently forces a reload. +- Any UI state stored in `data.json`, for example the old `getStartedOpen`, has the same effect. + +**Ingestion** + +`enableIngestion` gates audio conversion as well as PDF (`src/core/ingest/VaultIngestionManager.ts:189-195`, `:261`) and decides whether ingestion models are listed (`GenericProviderModal.ts:445-446`). + +## Bug verification + +| # | Claim | Verdict | Evidence | +|---|---|---|---| +| 1 | Nothing registers `'embeddingService'`, so `getServiceIfReady('embeddingService')` always returns null | **CONFIRMED** | Five callers: `src/agents/searchManager/services/ServiceAccessors.ts:77`, `src/agents/searchManager/searchManager.ts:114` (the result is cached and passed to the tool at `:140-141`), `src/ui/chat/components/suggesters/initializeSuggesters.ts:47`, `src/ui/tasks/TaskBoardView.ts:61`, `src/components/workspace/ProjectsManagerView.ts:146`. The registry is `CORE_SERVICE_DEFINITIONS` (no entry with that name; `grep "name: '"` over `src/core/services/ServiceDefinitions.ts`) plus `ADDITIONAL_SERVICE_FACTORIES`, which is empty (`:730-733`). `ServiceContainer.getIfReady` reads only already-created instances (`src/core/ServiceContainer.ts:298-300`). There are no string aliases, and the only other `register` calls are logger/pathManager/dataManager/vaultOperations in `src/core/index.ts:111-114`. `EmbeddingManager` is never in the container; it is attached as `plugin.embeddingManager` (`PluginLifecycleManager.ts:371`). Only `SearchContentTool` reads it there (`searchContent.ts:257-279`). The registration was dropped in commit `e1eada3a` ("removing embeddings"). Effects: `ConversationSearchStrategy.search` returns `[]` (`ConversationSearchStrategy.ts:69-71`), so **semantic search of past chats never runs**; `[[` note suggestions are always fuzzy (`noteSearch.ts:24`); task and project note linking never uses embeddings. `ChatTraceService` gets its service by direct injection (`PluginLifecycleManager.ts:390-397`) and works. Every existing test mocks around the gap (`tests/unit/SearchManagerAgent.test.ts:19-21` answers only `hybridStorageAdapter`). | +| 2 | Ingestion and Embeddings vanish after a redraw on the Defaults tab | **CONFIRMED** | They are appended to `rendererContainer` after `renderer.render()` (`DefaultsTab.ts:259`, `:281`). `render()` empties that container (`CSR:210`) on every redraw trigger listed above. Video and Voice survive only because they run inside `renderAfterImageSection`. | +| 3 | Ingestion rows sit above their toggle; Auto-convert is visible while off; the copy says "the defaults below" | **CONFIRMED** | `ingestionSettingsContainer = content.createDiv()` is created at `DefaultsTab.ts:1075`, before the "Enable ingestion" `Setting` at `:1078-1079`, so the PDF mode and OCR rows render first. "Auto-convert new files" is added to `content`, not the hidden container (`:1095-1106`). The copy at `:1097` says "using the defaults below". | +| 4 | Chat welcome "Open settings" opens Chat settings even with no provider | **CONFIRMED** | The button appears only in the "no providers" branch (`src/ui/chat/controllers/UIStateController.ts:93-114`). Its callback calls `openChatSettingsModal()` (`ChatView.ts:647-649`). | +| 5 | The header has a stray `;` and no title; `StatusBadge.ts` is unused | **CONFIRMED** | `SettingsView.ts:251-252` (comment "Title and description" followed by a bare `;`). `StatusBadge` has no importer in `src/` or `tests/`; its CSS is at `styles.css:4736-4754`. | +| 6 | `WorkspaceFormRenderer` saves top-level `dedicatedAgentId` while `syncWorkspacePrompt` reads `context.dedicatedAgent.agentId` | **CONFIRMED** | Writer: `src/components/workspace/WorkspaceFormRenderer.ts:165-175`. Reader: `CSR:1316-1330`. The canonical resolver prefers the top-level field and treats `context.dedicatedAgent` as deprecated (`src/agents/memoryManager/services/WorkspacePromptResolver.ts:83-97`). It is worse on Defaults: `DefaultsTab.loadWorkspaces` passes only `{id, name}` (`:91-99`), so the sync never fires there. | +| 7 | The modal does not persist without an open conversation; web search cannot be a default | **CONFIRMED (note only)** | `ChatSettingsModal.ts:326-328` saves only when `conversationId` is set. The values stay in `ModelAgentManager` memory. `initializeDefaults` resets `webSearch = false` (`ModelAgentManager.ts:394`), and it is restored per conversation (`:351`). | +| 8 | Temperature range is 0–1 | **CONFIRMED (note only)** | `CSR:613` `setLimits(0, 1, 0.1)`, clamped at `ModelAgentManager.ts:355`, `:993`. | +| — | "High 2" and "1,000,000 tokens 1000000" duplicate readouts | **NOT CONFIRMED (cause)** | The renderer adds its own value span next to each slider (`CSR:400`, `:462`). The second number is most likely a value label Obsidian 1.13 draws itself; the mockup header says its slider markup was "captured from the running app" with a `.slider-value`. I could not confirm this from `obsidian.d.ts` 1.12.3, which has no value-label API. Phase 1 starts by checking it in the app. | +| — | The CLI copy claims a Ctrl/Cmd+Shift+C hotkey | **CONFIRMED** | `GetStartedTab.ts:233`. The `open-chat` command has no `hotkeys` (`src/core/ui/ChatUIManager.ts:106-112`). | +| — | Bare `'node'` fallback in the copyable config | **CONFIRMED** | `GetStartedTab.ts:979`, `:992`. | +| — | "MCP setup guide" link uses `href: '#'` | **CONFIRMED** | `GetStartedTab.ts:518`. It opens the guide only when a `component` is supplied. The target `guide/mcp-setup.md` exists. | +| — | "Gemini CLI" in user-facing copy | **CONFIRMED** | `GetStartedTab.ts:500` (Other agents heading), `BuiltinProviderDrivers.ts:278` (`displayName`), `src/utils/platform.ts:159` (mobile-unavailable list), `GoogleGeminiCliAdapter.ts:244` (error text), `README.md:16`. Elsewhere the UI already says "Antigravity CLI" (`ProvidersTab.ts:115`, `ModelDropdownRenderer.ts:24`). | + +## Decisions + +| Topic | Decision | Why | +|---|---|---| +| `SettingGroup` (API 1.11) vs `minAppVersion` 1.10.0 | **Raise `minAppVersion` to `1.11.0`** in `manifest.json` and add the `versions.json` entry through the `nexus-release` machinery. No fallback shim. | `SettingGroup`, `Setting.addComponent` and the core `.setting-group .setting-items` CSS that the accordion content reuses all arrived in 1.11. A shim would need a second, unstyled code path that nobody tests. The owner's machines run 1.12.7–1.14.4. Users on 1.10 remain on 5.20.x. | +| Fold open/closed memory | **Device-local**: `app.saveLocalStorage('nexus-settings-folds', { v: 1, defaults: string[], chat: string[] })`, kept separately for each surface. | A synced `data.json` key would mark every other device "needs reload" (`settings.ts:182-201`) whenever a fold is toggled. `loadLocalStorage` is already scoped to the vault and is used for device IDs (`JSONLWriter.ts:174-180`). | +| Chat modal context window | **Applies to this conversation only.** Remove the global merge-and-save (`ChatSettingsModal.ts:234-258`). | This reverses part of PR #410 (see open question 2). Defaults stay the source for *new* chats. The modal already commits through `requestContextChange`. | +| Defaults → active chat context push | **Keep** (`applyContextWindowDefault`). | It is deliberate and tested. Changing it is out of scope (open question 3). | +| Web search default | **Stays per chat.** It is shown only in the Chat settings modal, as in the mockup. | Matches the mockup. A default is a new feature. | +| No conversation open | **Keep the in-memory behaviour.** The values carry into the next chat. Phase 0 adds a test proving that the first conversation created afterwards persists them. | No copy for this case exists in the mockup. | +| Thinking on non-reasoning models | **Keep the stored preference and gate it at the request boundary.** A new `resolveThinkingForModel(provider, model, thinking)` returns `{enabled: false}` when the model lacks `supportsThinking`. It is used by `ModelAgentPromptContextAssembler`, the subagent path and the prompt-execution path. | Switching to a non-reasoning model and back keeps the user's level. The hidden value can no longer be sent. | +| Thinking control | One `DropdownComponent`: Off, Low, Medium, High, Extra high, Max. Off is `{enabled:false, effort: }`; any other value is `{enabled:true, effort}`. | Uses the existing `DefaultThinkingSettings` storage, so nothing needs migrating. | +| Context window control | `SliderComponent` over indices `0..n-1` of `contextWindowStops(max)` = presets 32K, 64K, 128K, 200K, 350K, 750K, 1M that are **strictly below** `max`, followed by `max` itself. A single stop disables the slider. The readout shows `formatContextWindow` ("200K", "1M"). Changing provider or model clears the pending override for the newly selected key, so the readout shows that model's maximum. | Matches the mockup's `ctxChoices`. Storage stays `contextWindowOverrides[provider::model]`, and the maximum is still stored as "no override". A legacy value that is not a preset (e.g. 150,000) is shown as-is in the readout, with the thumb at the nearest lower stop, until the user moves it. No migration. | +| Creativity | Shown when `supportsSamplingParams !== false`. Otherwise the row is disabled with the readout "—" and the description ` sets its own temperature.`. | Matches the mockup. It reads the registry flag through `StaticModelsService`. | +| Subagent settings take effect | Precedence for subagents and `executePrompt`: an explicit tool parameter, then the conversation's `agentProvider`/`agentModel`, then the default `agentModel`, then the chat model if it is a cloud model (today's behaviour), then the first cloud provider (today's local fallback). The new subagent Creativity and Context window settings travel with the agent model. | The mockup says "Used for: Subagents, saved prompts and automations". Today almost none of it applies (see findings). Users who never set a subagent model see no change. | +| Get started auto-open | Replace `getStartedOpen` and `shouldOpenGetStarted` with this rule: on the **first** `display()` in a plugin session, if `!hasConfiguredProviders && !hasExternalAgentSetup`, open the Get started page instead of the tab list. Back goes to Settings. Nothing is persisted. | First-run users still land on guidance. Saving a preference would cause the sync-reload problem. The old key is left inert in `data.json` and is no longer read. Deleting it would itself create a synced change. | +| Get started on mobile | Show the row and the "Chat inside Obsidian" path on every platform. Hide "Connect an external agent" (desktop only). | Today mobile has no Get started at all (`SettingsView.ts:535`). The chat path does not depend on Node. | +| `embeddingService` fix | Add one accessor, `resolveEmbeddingService(plugin)`, that reads `plugin.embeddingManager` and returns its service only while the manager is enabled and initialized. Replace all five string lookups with it. Callers resolve it on every use and never cache it. | Registering a container entry would need a factory for an optional, late, toggleable service. Hot-apply also means the instance changes during a session, so caching it (`searchManager.ts:113`, `searchContent.ts:269`, `initializeSuggesters.ts:47`) becomes a bug of its own. | +| Semantic toggle when off | `searchContent` keeps the `semantic` property, with a description saying it is unavailable, and drops the semantic pitch from the tool description. `semantic=true` falls back to keyword search and the result carries a `note` saying semantic search is turned off in Nexus settings. | The mockup copy says "agents stop being offered semantic search". The property stays because CLI flag parsing is driven by the schema (check this in the nexus-agents verify step). Removing `--semantic` could turn a harmless request into a parse error. | +| Delete index | Shown only while search is off. It runs right away (mod-warning), with no second confirmation, exactly as in the mockup. | Vectors are derived cache data, so deleting them is legal under the storage invariants. Turning search back on rebuilds them. | +| `enableEmbeddings` scope | Stays synced. Synced changes are hot-applied instead of forcing a reload. | Lowest-change option. Device-local is open question 5. | + +## Settings keys + +All new keys are optional. **No migration**: an absent key falls back as listed. + +| Key | Type | Default when absent | Phase | Notes | +|---|---|---|---|---| +| `llmProviders.agentTemperature` | `number` (0–1) | `llmProviders.defaultTemperature ?? 0.5` | 2 | Subagent Creativity. Ignored when the agent model has `supportsSamplingParams === false`. | +| `llmProviders.agentContextWindowOverrides` | `Record` keyed by `getContextWindowOverrideKey` | the agent model's advertised window | 2 | A separate map, so the chat and subagent sliders for the same model never write the same key. | +| Conversation `chatSettings.agentTemperature` | `number` | conversation absent → defaults above | 2 | Extends `ModelAgentConversationSettingsStore` and the `chatSettings` metadata (`src/types/chat/ChatTypes.ts:175-180`). The JSONL metadata event already carries `chatSettings` as an object. Check with `nexus-storage` `persist-new-data.md` that the field survives a cache rebuild. | +| Conversation `chatSettings.agentContextWindow` | `number` | resolved from defaults | 2 | Same as above. | +| `getStartedOpen` (`PluginTypes.ts:84`) | — | — | 3 | **Retired.** No longer read or written. Remove it from the type and leave it inert on disk. | +| Device-local `nexus-settings-folds` | `{ v: 1; defaults: string[]; chat: string[] }` | everything collapsed | 1 | Not in `data.json`. | + +Existing keys keep their meaning: `defaultThinking`, `agentThinking`, `defaultTemperature`, `contextWindowOverrides`, `agentModel`, `enableIngestion`, `autoIngestion`, `defaultPdfMode`, `defaultOcrModel`, `enableEmbeddings`. + +--- + +## Phase 0 — Correctness fixes (can ship alone) + +**Goal:** fix the confirmed bugs that do not need the new layout. + +### Files + +| Path | Change | Kind | +|---|---|---| +| `src/services/embeddings/embeddingAccess.ts` | `resolveEmbeddingService(plugin: unknown): EmbeddingService \| null`. It reads `(plugin as {embeddingManager?}).embeddingManager` and returns `getService()` only when `isEmbeddingEnabled()`. No top-level Node imports; it only pulls types from `EmbeddingService`. | new | +| `src/agents/searchManager/services/ServiceAccessors.ts` | `getEmbeddingService()` uses the accessor (`:72-83`). | modified | +| `src/agents/searchManager/searchManager.ts` | Remove the cached `embeddingService` field and the lookup at `:113-115`. Stop calling `setEmbeddingService` at `:140-141`; the tool resolves on use. | modified | +| `src/agents/searchManager/tools/searchContent.ts` | `getEmbeddingService()` (`:257-279`) delegates to the accessor with no caching. Remove `setEmbeddingService` or make it test-only. | modified | +| `src/ui/chat/components/suggesters/initializeSuggesters.ts` | Pass a resolver `() => resolveEmbeddingService(plugin)` instead of a captured instance (`:44-50`). | modified | +| `src/ui/chat/components/suggesters/TextAreaNoteSuggester.ts`, `src/utils/noteSearch.ts` | Accept `EmbeddingService \| null \| (() => EmbeddingService \| null)`. Resolve per query. | modified | +| `src/ui/tasks/TaskBoardView.ts` (`:61`), `src/components/workspace/ProjectsManagerView.ts` (`:146`) | Use the accessor. | modified | +| `src/ui/chat/controllers/UIStateController.ts`, `src/ui/chat/ChatView.ts` | The welcome "Open settings" button (`UIStateController.ts:102-113`) calls a new `onOpenProviderSettings` callback. `ChatView` (`:647`) wires it to `openNexusSettings({ tab: 'providers' })`. The "Configure a provider to start chatting." hint stays. | modified | +| `src/core/settings/SettingsTabManager.ts` | Add `openNexusSettings(route: Partial)`. It stores the pending route on the `SettingsView`, then calls `app.setting.open()` and `app.setting.openTabById(manifest.id)`. The private `app.setting` typing follows the shape already used in `CommandDefinitions.ts:11`. | modified | +| `src/settings/SettingsView.ts` | Add `openTo(route)`, consumed by `display()`. Delete the stray `;` and the dead comment (`:251-252`). The header title is still out of scope (the mockup marks the header as unchanged), so only the noise goes. | modified | +| `src/settings/components/StatusBadge.ts` | Delete. | deleted | +| `src/components/shared/ChatSettingsRenderer.ts` | `syncWorkspacePrompt` (`:1316-1330`) uses the same precedence as `WorkspacePromptResolver`: top-level `dedicatedAgentId` first, then `context.dedicatedAgent.agentId`. The `ChatSettingsOptions.workspaces` type gains `dedicatedAgentId?: string`. Replace the `onclick` assignments (`:1310`, `:1346`) with `registerDomEvent` on the renderer's `Component`. | modified | +| `src/settings/tabs/DefaultsTab.ts` | `loadWorkspaces` (`:91-99`) passes `dedicatedAgentId` and `context` as the modal does. Move Ingestion and Embeddings into the renderer's `renderAfterImageSection` hook so a redraw cannot drop them (an interim fix; Phase 1 replaces the hook). In Ingestion: create the toggle first, put "Auto-convert" inside the gated container, and fix the copy (see Phase 1 for the final copy). | modified | +| `src/ui/chat/components/ChatSettingsModal.ts` | Remove the global context-window merge (`:234-258`). | modified | + +### CSS + +Delete `.nexus-status-badge*` (`styles.css:4736-4754`). Nothing else changes. + +### Mobile + +The accessor has no Node imports. `openNexusSettings` uses only Obsidian APIs. Run `npm run lint:mobile`. + +### Tests (mocked Jest lane, `tests/unit/`, per nexus-testing `write-a-test.md`) + +| Test | Asserts | Why it fails today | +|---|---|---| +| `ServiceNameRegistryContract.test.ts` | Statically collects every string literal passed to `getServiceIfReady` / `getService` under `src/`. Every name must be in `CORE_SERVICE_DEFINITIONS`, `ADDITIONAL_SERVICE_FACTORIES` or the `src/core/index.ts` registrations. | `'embeddingService'` is in none of them. Keeps the class of bug from coming back. | +| `ConversationMemorySearchEmbeddingAccess.test.ts` | Builds `MemorySearchProcessor` with a plugin stub whose `embeddingManager` is enabled and whose service's `semanticConversationSearch` is a spy, and **no** `embeddingService` registry entry. A conversation memory search must call the spy. | Today the accessor asks the registry, gets null and returns `[]`. | +| `SearchContentEmbeddingResolution.test.ts` | Manager disabled then re-enabled: the tool must use the new service instance, not a cached old one. | Today the tool caches forever (`searchContent.ts:269`). | +| `NoteSuggesterSemantic.test.ts` | `searchNotes` with a resolver that returns an enabled service calls `semanticSearch`. | The captured null means it never does. | +| `ChatWelcomeOpenSettings.test.ts` | The no-provider welcome button invokes the provider-settings callback, not the chat-settings modal. | It opens the modal today. | +| `ChatSettingsRendererWorkspacePrompt.test.ts` | Selecting a workspace whose **top-level** `dedicatedAgentId` names a prompt sets `promptId`. | It reads only the deprecated path today. | +| `DefaultsTabIngestionOrder.test.ts` | Uses a DOM-recording `Setting` mock (see the Phase 1 mock work). Asserts the order Enable → Auto-convert → mode, that Auto-convert is hidden while off, and that the rows survive `renderer.render()` being called twice. | All three fail today. | +| Update `ChatSettingsModalContextHandoff.test.ts` | Rename "saves defaults only after commit" to "never writes global defaults". Delete "merges only the edited model budget…". Keep the stale-handoff and cancel cases. | They encode the behaviour being removed. | +| `ChatSettingsWithoutConversation.test.ts` | Save with `conversationId = null`, then create a conversation. Its `chatSettings` must hold the saved temperature and thinking. | Expected to pass. It proves decision 7 rather than fixing anything; if it fails, that is a new bug. | + +Make each new assertion fail once against the baseline before writing the fix (`mock-honesty.md`). + +### In-app verification (nexus-testing `live-loop.md`, test vault only) + +1. `npm run build`, then `obsidian plugin:reload id=nexus vault=`, assert `app.vault.getName()`, then `obsidian dev:errors`. +2. Wait for indexing to finish, then: `nexus --vault use --memory "Verify chat memory search" --goal "semantic conversation hits" -- search memory `. Expect conversation results with similarity scores. +3. `obsidian eval` on the chat view: type `[[concept` in the composer and confirm the suggestions differ from basename fuzzy matching for a conceptual query. +4. Remove all providers in the test vault, open chat, click "Open settings", and confirm the Providers tab opens. + +### Acceptance + +- Conversation semantic search returns results in the live vault. +- No `getServiceIfReady('embeddingService')` remains (`grep`). +- The welcome button lands on Providers. +- Ingestion survives provider and model changes on Defaults. + +--- + +## Phase 1 — Shared accordion and the Essentials + folds layout + +**Goal:** both surfaces render the mockup's structure with Obsidian components. Capability rules are partly in place here and finished in Phase 2. + +### Step 1a — settle the slider readout (in-app, before any code) + +In a test vault, run `obsidian eval` against a settings tab's `containerEl` and collect the markup of a plain `Setting().addSlider()`. If Obsidian renders its own value element, the readout helper writes the formatted text into that element. Otherwise it adds exactly one `span.nexus-slider-value`. Either way each value appears once. Record the finding in the testing skill's `refinement-log.md`. + +### Files + +| Path | Change | Kind | +|---|---|---| +| `src/components/Accordion.ts` | Rewrite to `new Accordion(containerEl, { id, title, icon?, summary?, open?, onToggle? })`. Markup:
• root `div.nexus-accordion` with `is-open`
• `button.nexus-accordion-toggle` with `aria-expanded` and `aria-controls`, holding `setIcon` icon, `.nexus-accordion-text` (title plus summary, summary hidden while open) and a chevron (`chevron-down` / `chevron-up`)
• `div.nexus-accordion-content.setting-group` with `id` and `hidden`, containing one stable `div.setting-items` exposed as `itemsEl`
API: `setSummary()`, `setOpen(open, {silent})`, `isOpen`, `itemsEl`. Events use `registerDomEvent`, and Enter/Space come from the native button. Remove `getContentEl()`, which created a new div per call. | modified | +| `src/settings/SettingsView.ts` | Interim: move the Get started accordion to the options object (`title: 'Get started'`, `icon: 'rocket'`). Phase 3 deletes this use. | modified | +| `src/components/shared/settings/FoldStateStore.ts` | Wraps `app.loadLocalStorage` / `saveLocalStorage` for `nexus-settings-folds` as `isOpen(surface, id)` and `setOpen(surface, id, open)`. Tolerates malformed stored values. | new | +| `src/components/shared/settings/settingGroup.ts` | `createSettingGroup(container, cls?)` returns `new SettingGroup(container)`. Kept in one place for the mock and for a future API change. | new | +| `src/components/shared/settings/sliderReadout.ts` | `bindSliderReadout(setting, slider, format)`: one readout per the step 1a finding, live `input` updates through the owning `Component.registerDomEvent`, and `aria-valuetext`. | new | +| `src/components/shared/ChatSettingsRenderer.ts` | Restructure (details below). | modified | +| `src/components/shared/ModelDropdownRenderer.ts` | Split into `renderProviderSetting(itemsEl, cfg)` and `renderModelSetting(itemsEl, cfg)` that add rows to a provided `setting-items` (no `csr-section` shell, no section title, no info icon). `reRender` becomes `onSelectionChanged(kind)`, so the caller redraws only the affected group. | modified | +| `src/components/shared/chatSettings/VoiceSettings.ts` | **The single voice implementation.** It merges `CSR.renderVoiceSection/renderSpeechSettings/renderRealtimeVoiceSettings/renderTranscriptionControls/getVoiceWarningMessages` with the Defaults-only bits: `renderAudioSaveSettings` and the `buildSpeechDefault` / `buildRealtimeDefault` source/auto bookkeeping (`DefaultsTab.ts:566-600`, `:984-1006`). Subheads: Transcription, Read aloud, Live voice. It writes only to the renderer's settings object, and Defaults persists it through its save queue (see DefaultsTab). Warning messages become the description of the affected Provider row. | new | +| `src/components/shared/chatSettings/sectionSummaries.ts` | Pure functions that build each fold's collapsed summary from settings plus capabilities (copy appendix). | new | +| `src/settings/tabs/defaults/VideoSettings.ts` | Moves `renderVideoSection` (`DefaultsTab.ts:288-470`) into fold rows: Provider, Model, Aspect ratio, Resolution. | new | +| `src/settings/tabs/defaults/FileConversionSettings.ts` | Rows in order: PDF import toggle (`enableIngestion`), then, only when on, Reading method (`defaultPdfMode`: Text extraction / Vision), Vision provider and Vision model (only when Vision, via `renderIngestModelDropdowns` with the new names) and Convert new PDFs automatically (`autoIngestion`). The section redraws its own rows when the toggle or mode changes. | new | +| `src/settings/tabs/defaults/SemanticSearchSettings.ts` | Phase 1: the toggle row only, keeping today's restart Notice, so the fold exists. Phase 4 completes it. Desktop only. | new | +| `src/settings/tabs/DefaultsTab.ts` | Delete the header and intro (`:221-229`), the voice, video and ingestion implementations, the Embeddings section and the `renderAfterImageSection` use. Construct the renderer with `surface: 'defaults'` and `extraFolds` = Video, File conversion and Semantic search. Extend `getCurrentSettings`/`saveSettings` to persist `speech*`, `realtimeVoice*` and the video fields through the save queue, so nothing writes `llmProviderSettings` outside it. | modified (large reduction) | +| `src/ui/chat/components/ChatSettingsModal.ts` | Use `this.setTitle('Chat settings')` and put Cancel / Save in a bottom `div.modal-button-container` (replacing the `chat-settings-header` block at `:55-72`). Keep the `role=status` save text there. `surface: 'chat'`. | modified | +| `tests/mocks/obsidian/components.ts`, `tests/mocks/obsidian/index.ts` | Add `SettingGroup`, `ExtraButtonComponent`, `ProgressBarComponent`, `Setting.addExtraButton`, `Setting.addProgressBar`, `Setting.setDisabled`, `SliderComponent.setDisabled` and `DropdownComponent.setDisabled`. Make the `Setting` mock **append** `settingEl` to the container passed in and record its name, so tests can assert order. Today it appends nothing and drops the name (`components.ts:8-28`). | modified | + +**Renderer structure** (`ChatSettingsRendererConfig` gains `surface: 'defaults' | 'chat'`, `foldStore` and `extraFolds?: FoldDefinition[]`; `showWorkflowSelection`, `showVoiceSection`, `showTranscriptionSection`, `showWebSearch` and `renderAfterImageSection` are removed): + +1. `render()` builds a **stable shell** once: the top `SettingGroup` (no heading) and one `Accordion` per fold, in mockup order. Order and icons: + - Chat options — `sliders-horizontal` + - Context — `layers` + - Subagents — `bot` + - Images — `image` + - Video — `film` (Defaults only) + - Voice — `mic` + - File conversion — `file-input` (Defaults only) + - Semantic search — `scan-search` (Defaults only, desktop only) +2. Each group has its own `renderX(itemsEl)` that empties and refills only its own `itemsEl`, then calls `accordion.setSummary(...)`. Redraw map: + + | Change | Redraws | + |---|---| + | chat provider/model | top block, Chat options, Subagents summary (inherits) | + | subagent provider/model | Subagents | + | image provider | Images | + | workspace | Context | + | `LLMSettingsNotifier` | all groups, but **not** the shell | + + Because accordions are never rebuilt, fold state and scroll position survive. For a full redraw (`destroy()` and re-render on surface switch), capture `scrollTop` of the scroll parent (modal `contentEl` or the settings `.vertical-tab-content`) and restore it after. +3. Top block: Provider and Model as separate dropdowns, then the Thinking dropdown. Thinking is present only when `supportsThinking`. +4. Chat options: Creativity (disabled rule in Phase 2; in Phase 1 always enabled, with the new description), Context window (preset slider), and Web search only on `surface === 'chat'`, using the existing provider gating (`CSR:483-501`) and the new description. +5. Context: Workspace, Workflow (chat only, new description), Prompt, then a "Context notes" `Setting` with a `ButtonComponent` "Add note" and one row per note. Each note row shows a `file-text` icon and the basename, plus an `ExtraButtonComponent` `x` with tooltip `Remove `. +6. Subagents: a "Used for" note row, then the same row builders as the top block and Chat options with `variant: 'agent'`. Creativity and Context window for subagents arrive in Phase 2. Until then the fold shows Provider, Model and Thinking. +7. Images: Provider and Model (existing logic, `CSR:627-707`). +8. Voice: `VoiceSettings` with `surface`. +9. Extra folds supplied by Defaults. + +`ChatSettings` gains nothing in Phase 1 apart from moving the voice and video fields that Defaults now persists through the queue. + +### Settings keys + +Only the device-local fold store. + +### CSS (`styles.css`) + +**Add** a "Settings: shared accordion" section that replaces `ORIGINAL ACCORDION STYLES` (`:1754-1866`). Port these rules from the mockup's CSS, using theme variables only: +- `.nexus-accordion` (max-width `var(--setting-group-max-width)`, margin, radius) +- `.nexus-accordion.is-open { background: var(--setting-items-background) }`, so the whole open fold, header included, is one lighter card +- `.nexus-accordion-toggle` (flex, padding `var(--setting-items-padding-x)`, hover only while closed) +- `.nexus-accordion-icon` (accent) +- `.nexus-accordion-title` (`--setting-group-heading-size`, 600) +- `.nexus-accordion-summary` (muted, single line, ellipsis) +- `.nexus-accordion-chevron` +- `.nexus-accordion-content .setting-items { background: transparent }` +- the first-row divider rule +- spacing between folds, more than between rows +- the modal variant +- `.nexus-subhead`, `.nexus-note-row`, `.nexus-note`, `.nexus-ctx-value` (or `.nexus-slider-value`) +- `is-mobile` tap targets of at least 44 px on the toggle + +**Delete:** +- `.mcp-accordion*` (`:1754-1866`) +- `.csr-effort-row`, `.csr-effort-value`, `.csr-temp-value`, `.csr-provider-warning*`, `.csr-notes-*`, `.csr-note-*`, `.csr-add-btn`, `.csr-info-icon` (`:4408-4417`, `:4466-4584`) +- `.nexus-voice-*` (`:4435-4460`) +- `.thinking-settings-section`, `.thinking-effort-value` (`:4345-4361`), if `grep` shows no remaining users +- the `.chat-settings-header` / `.chat-settings-buttons` rules (`:3900-3910`, `:4255-4270`, after checking each selector) + +**Keep** `.csr-section`, `.csr-section-header`, `.csr-section-content` and `.csr-section-desc`, because `DataTab.ts:76-134` still uses them. Leave a one-line comment naming DataTab as the remaining consumer. **Keep** `.nexus-ingest-confirm-hidden` (`:8555`), which `IngestConfirmModal.ts:145-157` uses. Do not reference the mockup by file or line in CSS comments; name the plan. + +The text-only provider warning (`CSR:546-596`) becomes the Model row's description (`setDesc`), with its title in bold through a `DocumentFragment`. No dedicated CSS. + +### Mobile + +The renderer stays mobile-safe: no Node imports, and the Semantic search fold is constructed only when `Platform.isDesktop`. The Video fold follows today's provider availability. Verify `is-mobile` layout with `obsidian dev:mobile on` (layout only; it is not evidence of import safety) and run `npm run lint:mobile`. + +### Tests + +| Test | Lane | Asserts | Fails today because | +|---|---|---|---| +| `Accordion.test.ts` | unit | Collapsed by default. Toggle flips `aria-expanded`, `hidden` and `is-open`. The summary is hidden while open. `itemsEl` is the same node across calls. `onToggle` fires once. | `getContentEl()` returns a new node each call, and there is no summary or icon API. | +| `FoldStateStore.test.ts` | unit | Round-trip per surface. Malformed stored JSON gives all closed. Nothing touches `saveSettings`. | New. | +| `ChatSettingsRendererLayout.test.ts` | unit, DOM-recording mocks | Defaults order: top group (Provider, Model, Thinking) then folds Chat options, Context, Subagents, Images, Video, Voice, File conversion, Semantic search. Chat order omits Video, File conversion and Semantic search. Chat options includes Web search only in chat. Context includes Workflow only in chat. No element with the old intro text. | The current order and section names differ. | +| `ChatSettingsRendererRedraw.test.ts` | unit | Open Context, change the chat model: Context is still open and the Context `itemsEl` was not rebuilt. Change the workspace: only Context rebuilt. | `render()` empties everything. | +| `ThinkingDropdown.test.ts` | unit | Mapping Off↔`{enabled:false}` and level↔`{enabled:true, effort}`. Hidden for a non-thinking model. Off keeps the stored effort. | The toggle and slider exist today. | +| `ContextWindowStops.test.ts` | unit | `contextWindowStops(1_000_000)` = 7 stops ending at 1M. `(200_000)` = [32K, 64K, 128K, 200K]. `(40_000)` = [32K, 40K]. `(4096)` = [4096] and the slider is disabled. A legacy 150,000 keeps the readout "150K" and puts the thumb on 128K. A provider/model change shows the new model's maximum. | 1-token slider today. | +| `SettingsSectionSummaries.test.ts` | unit | Each summary string, using the copy constants in Appendix A. | New. | +| `VoiceSettingsSingleImplementation.test.ts` | unit | Defaults and chat produce the same row labels. The Defaults-only "Saved audio folder" row appears only on Defaults. Defaults persists `defaultSpeechModel.source = 'user'` on a manual pick. | Two implementations with different labels. | +| `FileConversionSettings.test.ts` | unit | Toggle first. Dependent rows only when on. Vision rows only in Vision mode. The copy matches Appendix A. | Bug 3. | +| Rewrite `ChatSettingsRendererEffort.test.ts`, `ChatSettingsRendererContextWindow.test.ts` | unit | Retarget them at the dropdown and the preset slider. | Old controls removed. | +| Keep `ChatSettingsRendererTextOnlyWarning.test.ts` | unit | Update it to assert the Model row description. | Location moves. | + +### In-app verification + +Build and reload, then use `obsidian eval` on `app.setting.activeTab.containerEl` (settings may be a separate window, per `live-loop.md`) to dump each group's title and its row names. + +1. Defaults: open Context, switch the chat model, and check that Context is still open and the scroll position is unchanged. +2. Close and reopen settings: the fold state is remembered. Restart Obsidian: still remembered. +3. Chat settings modal: same checks, then Cancel discards and Save persists. +4. Light and dark themes, and narrow width with `dev:mobile on`. +5. `dev:screenshot` both surfaces for the owner, next to the mockup. + +### Acceptance (against the mockup) + +- No intro line and no heading above the top block. +- The top block is a `SettingGroup` with Provider, Model and Thinking. +- Folds are collapsed by default, in mockup order with mockup icons, and show a one-line summary while collapsed. +- An open fold is a single lighter card, header included. +- Every value appears once. +- No "restore default" buttons. +- The only custom component is the accordion. `grep -n "createEl('button'" src/components/shared/ChatSettingsRenderer.ts` finds nothing. + +--- + +## Phase 2 — Capability-driven controls and subagent settings that take effect + +### Files + +| Path | Change | Kind | +|---|---|---| +| `src/services/StaticModelsService.ts` | `ModelWithProvider` gains `supportsSamplingParams?: boolean` (mapped at `:210-222`). | modified | +| `src/components/shared/chatSettings/modelCapabilities.ts` | `getModelCapabilities(staticModels, providerManager, provider, model)` returns `{ supportsThinking, supportsSamplingParams, contextWindow }`. Static models answer synchronously; discovered windows (Ollama, LM Studio) come from `providerManager.getModelsForProvider`, as `CSR:430-449` does today. Merged provider IDs (`openai-codex` → `openai`, `anthropic-claude-code` → `anthropic`, `google-gemini-cli` → `google`) are handled like `ModelDropdownRenderer.ts:199-206`. | new | +| `src/services/llm/utils/resolveThinkingForModel.ts` | Shared gating helper (see Decisions). | new | +| `src/components/shared/ChatSettingsRenderer.ts` | The Creativity row follows `supportsSamplingParams`: disabled `Setting` and slider, readout "—", description ` sets its own temperature.`. The Subagents fold gets Creativity and Context window rows. On agent provider/model change, clear the pending `agentContextWindowOverrides[key]`. The "Used for" row is always present. | modified | +| `src/types/llm/ProviderTypes.ts` | Add `agentTemperature?`, `agentContextWindowOverrides?` to `LLMProviderSettings` (`:128-152`), with comments. | modified | +| `src/components/shared/ChatSettingsRenderer.ts` (`ChatSettings`) | Add `agentTemperature?: number` and `agentContextWindowOverrides?: Record`. | modified | +| `src/settings/tabs/DefaultsTab.ts` | Load and save the two new keys. | modified | +| `src/ui/chat/services/ModelAgentDefaultsResolver.ts`, `ModelAgentConversationSettingsStore.ts`, `ModelAgentManager.ts` | Carry `agentTemperature` and `agentContextWindow` through defaults, conversation restore (`:303-313`), `buildChatSettingsMetadata` (`:1229-1255`) and new getters/setters. | modified | +| `src/types/chat/ChatTypes.ts` | `chatSettings` gains the two fields (`:175-180`). | modified | +| `src/ui/chat/components/ChatSettingsModal.ts` | Read and write the new agent fields. | modified | +| `src/ui/chat/services/ModelAgentPromptContextAssembler.ts` | `enableThinking` and `thinkingEffort` go through `resolveThinkingForModel` (`:120`, `:126`). | modified | +| `src/ui/chat/controllers/SubagentController.ts` | `buildSubagentContext` (`:455-483`) resolves provider, model, thinking, temperature and context window using the agent precedence in Decisions, via a new `SubagentModelResolver`. `createStreamingGenerator` (`:315-358`) accepts and forwards `enableThinking`, `thinkingEffort` and `temperature`. | modified | +| `src/ui/chat/services/SubagentModelResolver.ts` | Pure precedence function, with the `LOCAL_PROVIDERS` list shared with `LLMService`. | new | +| `src/agents/promptManager/tools/subagent.ts` | The context type gains `temperature` and `contextWindow`. Pass them into `executeSubagent` and to remote jobs where supported (`:200-206`). | modified | +| `src/types/branch/BranchTypes.ts`, `src/services/chat/SubagentExecutor.ts` | Params gain `temperature` and `contextWindow`. Forward `thinkingEnabled`, `thinkingEffort` and `temperature` to `streamingGenerator` (`:367-375`). Use `contextWindow` as the budget for inherited `contextFiles`/`workspaceData` assembly: drop whole files beyond the budget and add one line saying which were omitted. No mid-run compaction. | modified | +| `src/services/llm/core/LLMService.ts` | `getAgentModel()` (`:126-151`) honours a configured `agentModel` whatever the chat model is. The precedence lives in `SubagentModelResolver`, imported here. | modified | +| `src/agents/promptManager/tools/executePrompts/services/PromptExecutor.ts` | Pass the agent temperature, unless sampling is unsupported, and agent thinking through `resolveThinkingForModel` into `executeParams` (`:89-97`). `LLMExecutionOptions` already extends `GenerateOptions`, which has `temperature`, `enableThinking` and `thinkingEffort`. | modified | + +### CSS + +None beyond Phase 1. `.setting-item.is-disabled` comes from Obsidian. + +### Mobile + +No new imports. Local providers stay excluded from the Subagents provider list (`CSR` `LOCAL_PROVIDERS`). + +### Tests + +| Test | Asserts | Fails today because | +|---|---|---| +| `CreativityCapability.test.ts` | Claude Opus 5.5: row disabled, description `Claude Opus 5.5 sets its own temperature.`. Claude Opus 4.6: enabled. A model missing from the registry: enabled. | The flag is not mapped and the slider is always enabled. | +| `ResolveThinkingForModel.test.ts` | Non-thinking model gives `{enabled:false}`, the stored preference is untouched, and a thinking model passes through. | New. | +| `PromptContextThinkingGate.test.ts` | Stored `enabled:true` with Mistral Large Latest gives `buildMessageOptions().enableThinking === false`. | It sends `true` today. | +| `SubagentModelResolver.test.ts` | Precedence table, including "no agent model set and chat model is cloud → chat model", which is today's behaviour. | New. | +| `SubagentControllerForwardsSettings.test.ts` | With agent Haiku 5.5, thinking High and temperature 0.3, the generator calls `llmService.generateResponseStream` with the agent provider and model, `enableThinking: true`, `thinkingEffort: 'high'` and **no** temperature (Haiku rejects sampling). With Opus 4.6, temperature 0.3 is passed. | Today it inherits the chat model and drops thinking and temperature. | +| `PromptExecutorAgentSettings.test.ts` | `executePrompt` receives the agent temperature and thinking. | Not passed today. | +| `LLMServiceAgentModel.test.ts` | A configured `agentModel` wins even when the chat model is cloud. | Returns the chat default today. | +| `SubagentContextBudget.test.ts` | Context files beyond `contextWindow` are omitted, and the prompt says which. | No budget today. | + +Gated live smoke (`nexus-testing` `run-gated-lanes.md`): one subagent run against a real provider, confirming the provider and model that the adapter receives. Optional and not in CI. + +### In-app verification + +1. In Defaults set Subagents to Claude Haiku 5.5 with Thinking Off; set the chat model to Opus 5.5. +2. Ask the chat to spawn a subagent. +3. With `obsidian eval`, read the subagent branch's message metadata (provider/model). Expect Haiku. +4. Switch the chat model to Mistral Large Latest and send a message. Its request carries no thinking, confirmed by temporary console logging in a dev build only, or by adapter tests. + +### Acceptance + +- Creativity and Thinking rows follow the model, exactly as the mockup's caption describes ("Opus, Sonnet and Haiku 5.5 reject temperature, Opus 4.6 accepts it, Mistral Large can't reason, and Magistral Medium has a 40K context"). +- Every Subagents value demonstrably reaches the execution path. + +--- + +## Phase 3 — Get started pages, Node.js and Antigravity + +### Files + +| Path | Change | Kind | +|---|---|---| +| `src/settings/SettingsRouter.ts` | `RouterState` gains `page?: 'get-started' \| 'get-started-chat' \| 'get-started-agents'`. Add `openPage(page)` and `closePage()`. `setTab()` clears `page`. `back()` from a page goes up one level: agents or chat → get-started → none. | modified | +| `src/settings/SettingsView.ts` | Above the tab strip, a `SettingGroup` (class `nexus-get-started`) with one navigable `Setting`. Its name is the `rocket` icon plus "Get started", it has the description from Appendix A and a `chevron-right` control, and the whole row is clickable (`registerDomEvent` on `settingEl`, `role=button`, `tabindex=0`, Enter/Space). While `state.page` is set, `display()`/`renderTabContent` hides the row, the tab strip and the panes, and renders the page into a page container. Apply the first-run auto-open rule. Delete `renderGetStartedAccordion` and the `getStartedAccordion` field (`:534-597`). The external-agent page still loads through `await import(...)`. | modified | +| `src/settings/getStarted/GetStartedPage.ts` | Mobile-safe. Page head: `BackButton` ("Settings") and title. Lede, the six capability bullets (Lucide icons via `setIcon`), heading "Choose your setup" with description "Pick one, or use both.", and a `SettingGroup` with two navigable rows. "Chat inside Obsidian" has value `Ready` / `Not set up` from `hasConfiguredProviders`. "Connect an external agent" (desktop only) has value `N connected`, where N counts Claude Desktop, Codex and Antigravity configs that contain a Nexus server key plus the command line when installed. | new | +| `src/settings/getStarted/ChatSetupPage.ts` | Mobile-safe. Back: "Get started". Three numbered steps (`.nexus-step` badge):
1. Status "Connected" (circle-check) only when a provider is configured, plus a "Open providers" button that calls `router.setTab('providers')`.
2. "Open chat" (mod-cta) runs `app.commands.executeCommandById(':open-chat')` and closes settings.
3. Text only. | new | +| `src/settings/getStarted/ExternalAgentsPage.ts` | Desktop only. **Dynamically imported.** Built from the reusable parts of `GetStartedTab.ts` (config paths, `ensureConnectorFile`, `autoConfigureNexus`, `autoConfigureCodex`, `openConfigFile`, the Local CLI install, PATH fix and providers logic). Layout as in the list after this table. | new | +| `src/settings/tabs/GetStartedTab.ts` | Deleted once its logic is moved. | deleted | +| `src/utils/nodeRuntime.ts` | `resolveNodeRuntime(): { path, version, manager: 'nvm' \| 'other' } \| null`. It uses `resolveDesktopBinaryPath('node')` and then runs `execFileSync(path, ['--version'], { timeout: 3000 })` through `desktopRequire`. `manager` is `'nvm'` when the path contains `/.nvm/versions/node/` (or `\nvm\` on Windows nvm-windows). No top-level Node imports. | new | +| `src/utils/mcpClientConfigs.ts` | Path and status helpers: `getAntigravityConfigPath()` (`~/.gemini/config/mcp_config.json`, using `%USERPROFILE%` on Windows); `getAntigravityLegacyConfigPath()` (`~/.gemini/antigravity/mcp_config.json`); `getMcpJsonStatus(path, serverKeys)`. These are shared by Claude Desktop and Antigravity, since both use the `mcpServers {command,args}` shape. Also `buildMcpServerEntry(nodePath, connectorPath)`, which **requires** a node path; there is no `'node'` fallback. | new | +| `src/settings/getStartedStatus.ts` | Remove `shouldOpenGetStarted` and the `getStartedOpen` branch. `hasExternalAgentSetup` also checks Antigravity. Export a `countExternalConnections(app)`. | modified | +| `src/types/plugin/PluginTypes.ts` | Remove `getStartedOpen` (`:84`). | modified | +| `src/settings/components/BackButton.ts` | Make `component` required, removing the `addEventListener` fallback at `:28-30`. All callers already pass one or can. | modified | +| `src/services/llm/providers/BuiltinProviderDrivers.ts` (`:278`), `src/utils/platform.ts` (`:159`), `src/services/llm/adapters/google-gemini-cli/GoogleGeminiCliAdapter.ts` (`:244`), `README.md` (`:16`) | Replace "Gemini CLI" in user-facing strings with "Antigravity CLI", or "Antigravity" for the MCP client in README. Internal IDs (`google-gemini-cli`), class names and code comments stay. Update `tests/unit/GenericProviderModalSaveBehavior.test.ts:131-149` only if the notice label source changes (it is `oauthConfig.providerLabel`, already "Antigravity CLI" at `ProvidersTab.ts:717-721`, so check before editing). | modified | + +**External-agent page layout:** + +- Lede: "Let apps outside Obsidian use your vault through Nexus." +- **Node.js group** + - Found: description "Runs the connector. Found `` at ``." with the status "Found". If `manager === 'nvm'`, add a warning line: "This path changes when you switch Node versions with nvm. Reconnect your apps after switching, or install Node.js from nodejs.org for a stable path." + - Missing: the Appendix A copy with "Install Node.js" (mod-cta, opens `https://nodejs.org/`) and "Check again", which re-resolves and redraws. +- **Apps group** (one `SettingGroup`, no descriptions under the names): + - Claude Desktop: status, plus "Open config" when connected or "Connect". + - Codex: status plus "Connect". + - Antigravity: status plus "Connect". **New**: writes the JSON entry to `getAntigravityConfigPath()`, merging into existing `mcpServers` with the same code path as Claude Desktop. + - Every Connect calls `ensureConnectorFile` first. + - Stale detection: if a config's `command` path no longer exists (the nvm switch case), the status reads "Reconnect". + - Last row, "Other agents": description from Appendix A, with a chevron `ExtraButtonComponent` ("Show steps" / "Hide steps"). It expands inline into four numbered steps: + 1. Create → "Created". + 2. A `pre.nexus-code` preview of `JSON.stringify({mcpServers:{[serverKey]:{command, args:[connector]}}}, null, 2)` and "Copy config". + 3. Prompt preview and "Copy prompt". + 4. Text only. + - When Node is missing, every Connect, Create and Copy is disabled, the status reads "Needs Node.js", and the previews read "Install Node.js first." (step 2 uses the long form from the mockup). +- **Command line group** + - Name `terminal` icon plus "Command line", description from Appendix A, status (the existing `installer.status()` wording, shortened to "Installed" / "Not installed" / "Not on your PATH"), and Install or Uninstall. + - Three `ToggleComponent` child rows: "Claude Code skills" `~/.claude/skills`, "Cursor skills" `~/.cursor/skills`, "Codex AGENTS.md" `~/.codex/AGENTS.md`. + - The toggles behave as the checkboxes do today: they pick targets before install and wire immediately after install (`GetStartedTab.ts:660-695`). + - "Add to path" and the PATH-fix panel keep their behaviour, as extra rows when applicable. +- Fix the guide link: `createEl('a', { href: 'https://github.com/ProfSynapse/nexus/blob/main/guide/mcp-setup.md' })`, with the click going through `registerDomEvent` + `window.open`. The link sits under the Other agents steps. + +### CSS + +**Add** a "Settings: Get started pages" section with: +- `.nexus-page-head`, `.nexus-page-title`, `.nexus-page-lede` +- `.nexus-can` (icon list) +- `.nexus-gs-heading` +- `.setting-item.mod-navigable` (cursor, hover) +- `.nexus-step` (number badge) +- `.nexus-status` and `.nexus-status.is-ok` (circle-check) +- `.nexus-code` and `.nexus-prompt` (monospace, wrapping, max-height with scroll) +- `.nexus-other-steps`, `.nexus-child-row`, `.nexus-path-row`, `.nexus-node-missing` +- 44 px tap targets on `is-mobile` + +**Delete** the old Get started CSS: +- `.nexus-intro*`, `.nexus-capabilit*`, `.nexus-cap-icon`, `.nexus-divider`, `.nexus-setup-*` (`:4773-4925`) +- `.nexus-mcp-*`, `.nexus-cli-*`, `.nexus-manual-*`, `.nexus-config-code`, `.nx-*` (`:4869-5280`) +- `.nexus-settings-accordion*` (`:2210-2216`) + +Check each selector with `grep -rn` across `src/` first; some `nexus-mcp-*` names may be shared. + +### Mobile + +`GetStartedPage` and `ChatSetupPage` import only `obsidian` and `getStartedStatus` (already imported at init). `ExternalAgentsPage`, `nodeRuntime` and `mcpClientConfigs` are reached only through `await import()` behind `Platform.isDesktop && supportsMCPBridge()`. Run `node scripts/check-mobile-imports.mjs . --trace src/settings/getStarted/ExternalAgentsPage.ts` to confirm it is not on the init path. `src/settings/components/**` stays under the ESLint Node ban. + +### Tests + +| Test | Asserts | Fails today because | +|---|---|---| +| `SettingsRouterPages.test.ts` | `openPage`/`back` hierarchy; `setTab` clears the page. | No pages. | +| `GetStartedFirstRun.test.ts` | No provider and no external setup: the first `display()` shows the Get started page and the second does not. A configured provider shows the tab list. | The accordion and `getStartedOpen` exist today. | +| `GetStartedChatPage.test.ts` | Step 1 shows "Connected" only when configured and never lists provider names. "Open providers" switches to the providers tab. Copy has no "Ctrl/Cmd". | Hotkey copy today. | +| `ExternalAgentsNodeMissing.test.ts` | `resolveNodeRuntime` returns null: every Connect, Create and Copy is disabled, status "Needs Node.js", the preview says "Install Node.js first.", and no config string contains `"node"` as a bare command. | Bare `'node'` fallback. | +| `McpServerEntryWindows.test.ts` | With Windows paths the copied JSON escapes backslashes and `JSON.parse` round-trips to the exact paths. | New. | +| `AntigravityConnect.test.ts` | With a mocked `desktopRequire` fs: creates `connector.js` first, creates the file if missing, merges into existing `mcpServers` and preserves other servers. | New. | +| `NodeRuntimeNvm.test.ts` | Detects an nvm path; parses `v24.11.0`. | New. | +| `NoGeminiCliCopy.test.ts` | Scans `src/**/*.ts` string literals in UI and notice contexts for "Gemini CLI". The allowlist is limited to comments and IDs. | Five user-facing hits. | +| Update `GetStartedStatus.test.ts`, `SettingsViewDataTab.test.ts:164-199` | Remove the `getStartedOpen` cases. | The key is retired. | +| `BackButton.test.ts` | Update for the required `component`. | Signature change. | + +### In-app verification (desktop test vault) + +1. With providers configured, open Settings → Nexus. The row sits above the tabs and the whole row opens the page. Back returns to the tab list on the same tab. +2. "Chat inside Obsidian": "Open providers" lands on Providers; "Open chat" opens the chat view. +3. External agents: the Node row shows version and path. Temporarily point `PATH` to exclude node and use a dev build that forces `resolveNodeRuntime` to null; check the disabled state. +4. Click Antigravity Connect on a machine with Antigravity (see Not confirmed). Check that the file is written, then reload MCP servers with `/mcp` or Refresh, then ask Antigravity to list vault folders. +5. Copy config and check the clipboard content with `obsidian eval` (`navigator.clipboard.readText()`). +6. On mobile (a real device, not emulation), Get started shows only the chat path. + +### Acceptance + +The pages, the row, copy, order and states match the mockup: Get started intro, Chat inside Obsidian, Connect an external agent with OS-specific paths and the Node found/missing states, and the Other agents four steps. There is no emoji, no "Gemini CLI" and no `href="#"`. + +--- + +## Phase 4 — Semantic search: status, hot-apply, delete, prune, tool schema + +### Files + +| Path | Change | Kind | +|---|---|---| +| `src/services/embeddings/EmbeddingManager.ts` | Separate **disable** from **unload**:
• `setEnabled(enabled): Promise`. Enabling sets `isEnabled` and calls `initialize()`, which builds fresh components. Disabling calls `stop()`.
• `stop()`: cancel the timer, `queue.destroy()`, `watcher.stop()`, `conversationWatcher.shutdown()`, `statusBar.destroy()`, await `backgroundIndexingWork`, `engine.dispose()` (unloads the model), clear the dream interval (store its ID; it is currently anonymous at `:322-325`), `plugin.removeCommand('consolidate-retrieval-memory')` (API 1.7.2), null all components, `isInitialized = false`.
• `shutdown()` keeps its permanent `shuttingDown` fence and calls the shared teardown.
• Add `onStateChange(listener)` (Obsidian `Events`), emitted on start, stop and every queue `progress` event.
• `getStats()` adds `conversationCount` (`SELECT COUNT(DISTINCT conversationId) FROM conversation_embedding_metadata`) and returns stored counts even while disabled, so the off state can show "Paused · N notes". It reads the DB directly when `service` is null. | modified | +| `src/services/embeddings/IndexingQueue.ts` | Add `pruneMissingNotes()`: `SELECT notePath FROM embedding_metadata`, and for every path where `app.vault.getAbstractFileByPath(path)` is null, call `embeddingService.removeEmbedding`. Add `pruneMissingConversations()` for `conversation_embedding_metadata` rows whose conversation no longer exists. Expose `getProgress()` for initial paint. | modified | +| `src/services/embeddings/EmbeddingManager.ts` (`runIndexingPhases`, `:379-411`) | Run the prune passes before Phase 1. This covers startup, re-enable and rebuild. | modified | +| `src/database/storage/SQLiteMaintenanceService.ts`, `SQLiteCacheManager.ts` | Add `clearEmbeddingIndex()` in a transaction: drop and recreate `note_embeddings`, `trace_embeddings` and `conversation_embeddings` (`USING vec0(embedding float[384])`, matching `schema.ts:305-367`), `DELETE FROM` `embedding_metadata`, `trace_embedding_metadata`, `conversation_embedding_metadata` and `embedding_backfill_state`, then save. No schema change. Still run `check_schema_consistency.py`, since the DDL strings must match. | modified | +| `src/services/embeddings/EmbeddingManager.ts` | `deleteIndex()` is allowed only while disabled; it throws otherwise. | modified | +| `src/core/PluginLifecycleManager.ts` | Keep constructing `EmbeddingManager` on desktop whatever the setting (it already does, `:363-371`). Subscribe to `onStateChange`, so `chatTraceService.setEmbeddingService(service \| null)` follows the state (`:389-397`). Expose `setEmbeddingsEnabled(value)`. | modified | +| `src/services/chat/ChatTraceService.ts` | `setEmbeddingService(service: EmbeddingService \| null)` (`:56`). | modified | +| `src/settings.ts` | `reloadSettings` (`:195-201`) excludes `enableEmbeddings`. `reconcileExternalSettings` returns a flag so the plugin can hot-apply a synced change. | modified | +| `src/main.ts` | In `onExternalSettingsChange`, when `enableEmbeddings` changed, call `lifecycle.setEmbeddingsEnabled`. | modified | +| `src/settings/tabs/defaults/SemanticSearchSettings.ts` | Full fold:
• Toggle row.
• On: status row "Index" (`4,512 notes · 318 chats`).
• Indexing: a "Indexing" row with `ProgressBarComponent`, "1,904 / 4,512", ETA text from `estimatedTimeRemaining`, and a pause `ExtraButtonComponent` (`pause` / "Pause indexing"; `play` / "Resume indexing" while paused).
• Off: "Index" (`Paused · N notes`) plus a "Delete index" `ButtonComponent` (`mod-warning`). After deletion it shows "No index on this device".
• Turning off opens the confirmation modal; Cancel reverts the toggle and Escape cancels.
• Subscribes to `onStateChange` while mounted and unsubscribes on destroy.
• Summary: `On · N notes` / `Indexing · P%` / `Off · index kept`. | modified | +| `src/settings/tabs/defaults/SemanticSearchOffModal.ts` | An Obsidian `Modal` with the Appendix A copy, `mod-confirm` and the buttons Cancel / Turn off (`mod-warning`). | new | +| `src/agents/searchManager/tools/searchContent.ts` | Constructor takes `getSemanticState: () => 'available' \| 'off' \| 'unsupported'`, derived from `settings.enableEmbeddings`, `Platform` and the manager state. `refreshDescription()` reassigns `this.description` (BaseTool's `description` is a field, so a getter override would clash in TS). The constructor calls it, and so does an `onStateChange` subscription made in `SearchManagerAgent`. `getParameterSchema()` (`:666-709`) picks the `semantic` property description by state. In `execute()`, `semantic === true` while off runs keyword search and adds `note: 'Semantic search is turned off in Nexus settings, so this used keyword search.'`. While unsupported (mobile) it does the same with a "not available on this device" note. "Still initializing" (`:321-322`) is used only when enabled but not yet initialized. | modified | +| `src/agents/searchManager/searchManager.ts` | Wire `getSemanticState` and the refresh subscription. | modified | +| `src/agents/searchManager/types` (result type) | `ContentSearchResult` gains an optional `note?: string`. | modified | +| `cli-first-tool-schemas.json` and `docs/generated/*` | Regenerate with `npm run schemas:tools -- --output cli-first-tool-schemas.json` per `nexus-tool-schemas` `refresh-catalog.md`, then `check_catalog.py`. The headless exporter has no plugin settings, so the default state **must** be `'available'` to keep the committed description stable. | regenerated | + +### CSS + +Add `.nexus-semantic-progress` (progress bar width 140 px, as in the mockup) and `.nexus-semantic-value`. Nothing else is needed: the modal uses Obsidian's `.modal` and `mod-confirm`. + +### Mobile + +The fold is not constructed on mobile. `EmbeddingManager` already disables itself (`:83`). The tool's `'unsupported'` state reports keyword fallback. `SemanticSearchOffModal` is a plain `Modal`. + +### Tests + +| Test | Lane | Asserts | Fails today because | +|---|---|---|---| +| `EmbeddingManagerHotToggle.test.ts` | unit | `setEnabled(false)`: watcher stopped, queue destroyed, status bar destroyed, engine disposed, interval cleared, `removeCommand` called, `getService()` null, and a state event emitted. `setEnabled(true)`: new components and indexing scheduled. A later `shutdown()` does not throw. | No runtime toggle; `initialize` refuses after shutdown. | +| `EmbeddingPrune.test.ts` | unit | Rows for missing paths are removed; existing ones are kept. Conversations likewise. | No prune pass. | +| `ClearEmbeddingIndex.test.ts` | integration (sql.js, as other SQLite tests do) | After `clearEmbeddingIndex()` all seven embedding tables are empty, non-embedding tables are untouched, and the vec0 tables accept inserts. | No delete path. | +| `SemanticSearchSettings.test.ts` | unit | The off flow needs the modal; Cancel keeps it on; the off state shows Delete index; indexing shows the progress bar and pause; the summaries match. | New. | +| `SearchContentSemanticOff.test.ts` | unit | With state `off`: the description has no "Set semantic=true for AI-powered" pitch; `semantic=true` returns success, keyword results and the note. With state `available` and not initialized: "still initializing". | Misleading error today (`:321-322`). | +| `SettingsSyncEmbeddingsHotApply.test.ts` | unit | A synced `enableEmbeddings` change returns `requiresReload === false` and triggers hot-apply. | It requires a reload today. | +| `shippedGuidanceCommands` / `ToolManagerCliSyntax` | existing | Stay green after the catalog regenerates (`fix-shipped-docs-drift.md` if not). | — | + +### In-app verification + +1. In a test vault with notes, open Defaults → Semantic search. Check the status counts against `obsidian eval "app.plugins.plugins.nexus.embeddingManager.getStats().then(JSON.stringify)"`. +2. Turn it off and confirm the modal copy. After confirming: the status bar item has gone and `getService()` is null, all without a restart. +3. Run `nexus ... -- search content "x" --semantic`. Expect success, keyword results and the note. +4. Delete two notes, turn search on, and confirm their `embedding_metadata` rows are gone (eval query) and only changed notes are re-indexed (progress total). +5. Turn it off and click Delete index. Table counts read 0. Turn it on and a full re-index runs. +6. Escalate to disable/enable the plugin (`live-loop.md` step 4) to confirm no orphan intervals or commands remain (`app.commands.commands`). + +### Acceptance + +The semantic fold states match the mockup's "On, indexed / On, indexing / Turning off (confirm) / Off" states. Hot-apply takes effect with no restart. The tool no longer claims it is "initializing" when search is turned off. + +--- + +## Phase 5 — Follow-ups (not core) + +- One-click **"Add to"** install links in the Other agents card: + - Cursor: `cursor://anysphere.cursor-deeplink/mcp/install?name=&config=` + - VS Code / Copilot: `vscode:mcp/install?` or `code --add-mcp` + - LM Studio: `lmstudio://add_mcp`. **Check the spelling against LM Studio docs**; it is unconfirmed here. + - Windsurf: write `~/.codeium/windsurf/mcp_config.json`. + + Each needs the same Node.js gate and an absolute-path entry. +- Temperature range 0–2 for providers that accept it (Bug 8). This needs a per-provider maximum on `ModelSpec`. +- Web search as a default (Bug 7), if wanted. +- Migrate `DataTab` off `csr-section`, so the remaining `csr-*` CSS can be deleted. +- Decide whether Defaults should keep pushing context-window changes to the active chat (open question 3). + +--- + +## Appendix A — Copy lifted from the mockup + +Hold these as constants in `src/components/shared/chatSettings/copy.ts` and `src/settings/getStarted/copy.ts`. Tests import the constants and never point at the mockup. + +**Defaults and Chat settings** +- Creativity: "Lower is more focused, higher is more creative." / disabled: "`` sets its own temperature." Readout "—". +- Context window: "Older messages are summarized past this size." Readout examples "32K", "200K", "1M"; `aria-valuetext` "`` tokens". +- Web search (chat only): "Let the model search the web when needed." +- Workflow (chat only): "Loads its instructions and tools. Steps run only when you ask." +- Context notes: button "Add note"; remove tooltip "Remove ``". +- Subagents note row: name "Used for", description "Subagents, saved prompts and automations. Must be a cloud model." +- Voice: subheads "Transcription", "Read aloud", "Live voice". + - Transcription Provider description: "Chat microphone and audio files." + - "Saved audio folder" (Defaults only): "Inside the Nexus data folder." + - Live voice with none available: "None of your providers offer live voice." +- File conversion: + - "PDF import": "Drop PDFs into chat to turn them into notes." + - "Reading method": "Text is free. Vision reads scanned pages with a model." Options "Text extraction", "Vision". + - "Vision provider", "Vision model". + - "Convert new PDFs automatically": "Adds a Markdown note next to each PDF added to the vault." +- Summaries: + - Chat options: "Creativity 0.5 · 1M context" or "Fixed temperature · 1M context"; chat adds " · Web search on|off". + - Context: "``|No workspace · ``|No prompt · N notes". + - Subagents: "`` · Thinking off|`` thinking · ``". + - Images: "``". + - Video: "`` · `` · ``". + - Voice: "`` · `` (``)". + - File conversion: "PDF import on · ``[ · auto-convert]" / "PDF import off". + - Semantic: "On · N notes" / "Indexing · P%" / "Off · index kept". +- Semantic search: + - Toggle: "Semantic search" — "Find notes and past chats by meaning, not just exact words. Runs on this device." + - On: "Index" — "Kept up to date as notes and chats change." value "N notes · M chats". + - Indexing: "Indexing" — "About N minutes left. You can keep working." value "x / y"; "Pause indexing". + - Off: "Index" — "Kept on this device, so turning search back on only re-indexes notes that changed." value "Paused · N notes". + - "Delete index" — "Frees the space it uses. Turning search back on re-indexes the whole vault." Button "Delete index". +- Off modal: title "Turn off semantic search?"; "Nexus will match exact words only."; bullets "Vault search falls back to keyword matching, and agents stop being offered semantic search." / "Searching past chats by meaning stops working." / "Note suggestions in chat use file names only."; muted "Your index stays on this device, so turning it back on is quick."; buttons "Cancel", "Turn off". + +**Get started** +- Row: "Get started" — "Set up chat in Obsidian, or connect Claude Desktop, Codex and other agents." +- Intro lede: "Nexus is an assistant that lives in your vault. It reads and writes your notes, searches them, and remembers context between conversations. Your data stays in your vault." +- Capabilities, each with its icon: + - pencil-line: "Read, create and edit notes" + - search: "Search by keyword or by meaning" + - layers: "Remember context with workspaces" + - folder: "Organize files and folders" + - bot: "Run saved prompts and subagents" + - wifi-off: "Work offline with local models" +- "Choose your setup" / "Pick one, or use both." +- "Chat inside Obsidian" — "Connect a model provider and chat in a side panel." (value "Ready"). +- "Connect an external agent" — "Claude Desktop, Codex, Cursor and more use your vault." (value "N connected"). +- Chat page: + 1. "Connect a model provider" — "An API key, a sign-in, or a local model such as Ollama." Status "Connected", button "Open providers". + 2. "Open the chat" — "From the ribbon icon or the “Nexus: Open chat” command." Button "Open chat". + 3. "Start chatting" — "Ask about your notes. Nexus can read, search and edit them." +- Agents page: + - Lede "Let apps outside Obsidian use your vault through Nexus." + - Node.js found: "Runs the connector. Found `` at ``." status "Found". + - Node.js missing: "Every agent connection runs through Node.js, and it isn’t installed or Nexus can’t find it. Install it, then check again." Buttons "Install Node.js", "Check again". Blocked status "Needs Node.js". + - "Other agents" — "Cursor, Cline, Copilot, Windsurf and others. Four steps." + - Steps: + 1. "Create the connector file" — "connector.js lets the app talk to this vault. It goes in the Nexus plugin folder." Button "Create"; status "Created". + 2. "Copy the config" — "The Nexus entry for the app’s MCP settings, with this computer’s paths." Button "Copy config". Missing Node: "Install Node.js first. The config needs its location." + 3. "Add it to your app" — "Paste it into the app’s MCP settings yourself, or paste this prompt into the agent and let it make the change." Button "Copy prompt". Prompt: "Add an MCP server to your configuration. Name: ``. Command: ``. Args: ["``"]. Use these paths exactly. Merge it into the existing mcpServers and keep any servers already there. Then tell me where you saved it." Missing Node: "Install Node.js first." + 4. "Restart the app" — "Quit and reopen it so it loads Nexus. Then ask it to list your vault’s folders to check." + - "Command line" — "Lets Claude Code, Cursor and Codex use Nexus without MCP." Status "Not installed", button "Install". Child toggles "Claude Code skills", "Cursor skills", "Codex AGENTS.md" with their paths. + +In the prompt, `` must be JSON-escaped (build it with `JSON.stringify(path)`), because the mockup's template literal would not escape Windows backslashes inside `[" "]`. + +## Risks + +- **Fixing `embeddingService` changes live behaviour.** `[[` suggestions switch from fuzzy to semantic-first (`noteSearch.ts:24-33`). Semantic ranking can be worse for a typed filename prefix. Check this in the app. If it regresses, put exact and prefix basename matches ahead of semantic hits. Conversation memory search starts returning results, which changes agent behaviour. +- **Subagent precedence is a behaviour change** for anyone who has set a subagent model: subagents move from the chat model to that model. Call it out in the changelog. +- **Hot-apply teardown order.** Stopping must await the in-flight indexing phase before disposing the engine, the same constraint `shutdown()` documents at `:178-195`. A race leaves a write after the DB is closed. `deleteIndex()` while disabled avoids racing with the queue. +- **Settings in a separate window.** `live-loop.md` notes that `document` targets the main window. DOM assertions must go through the setting tab's `containerEl`. +- **The `Setting` mock does not render.** Order assertions need the mock change in Phase 1. Until then a green test proves nothing about order (`mock-honesty.md`). +- **The `minAppVersion` bump** strands 1.10 users on 5.20.x. This is acceptable but must be noted in the release. +- **Tool description churn.** Agents connected over MCP cache `getTools` results, so a runtime description change reaches them only on their next discovery. + +## Owner decisions (2026-10-14) + +These answer the former open questions. They override the Decisions table above wherever the two disagree. + +1. **Subagent default.** When no subagent model is set, subagents keep using the chat model if it is a cloud model (as proposed). +2. **Chat modal context window applies to this conversation only.** This reverses PR #410's save-to-defaults. Confirmed. +3. **Changing Defaults does not change the open chat.** Remove the Defaults → active chat context push (`applyContextWindowDefault`) and update its tests. Defaults apply to new chats only. *This overrides the Decisions table's "Keep".* +4. **The toggle is labelled "File import"**, with the description "Drop PDFs or audio into chat to turn them into notes.", because `enableIngestion` gates audio as well. The fold is still called "File conversion". +5. **`enableEmbeddings` stays synced** (hot-applied). The index stays per device. +6. **Delete index asks for confirmation** in an Obsidian `Modal`: title "Delete the search index?", buttons Cancel / Delete (mod-warning). It deletes vectors only and keeps the learned retrieval adapter (`AdapterStore`). *This overrides the Decisions table's "no second confirmation".* +7. **Get started opens automatically** on the first `display()` of each session while nothing is configured (as proposed). +8. **The Thinking dropdown hides levels the model or provider does not support.** If no capability data exists, show all six. + +## Could not confirm + +- **Antigravity config path and reload.** Antigravity's MCP docs (fetched 2026-10-14) list the global config as `~/.gemini/config/mcp_config.json` (workspace: `.agents/mcp_config.json`) with `mcpServers {command, args, env}`. Reload is `/mcp` in the CLI or Refresh in the app. I have not confirmed this against an installed Antigravity. It remains **must-verify** on a machine that has it, along with whether older builds read `~/.gemini/antigravity/mcp_config.json` and whether the desktop app manages servers only through its own Settings UI. If both paths exist, write the one the installed build reads, and do not write both. +- **The native slider value label** in Obsidian 1.13 (the cause of "High 2" and "1,000,000 tokens 1000000"). Phase 1 step 1a settles it. I did not run `obsidian eval` against the owner's vaults. +- **Whether `ToolCliNormalizer` rejects a flag missing from the schema.** This decides whether `semantic` could ever be removed rather than re-described. Check it in the nexus-agents verify step. +- **The LM Studio deep-link spelling** (`lmstudio://add_mcp`) and the other Phase 5 link formats. +- **Whether every thinking-capable adapter ignores `enableThinking` for non-thinking models.** Only Anthropic was checked. The new gate makes this moot for requests built by Nexus. +- **Whether the conversation `chatSettings` JSONL payload keeps unknown fields** through a cache rebuild (Phase 2 keys). Check with `nexus-storage` `persist-new-data.md` before relying on it. + +## Analysis completion + +Analysis only. No production files, styles, tests, the running plugin or any vault were changed or exercised. The file counts below are planned, not executed. + +| Phase | New | Modified | Deleted | +|---|---|---|---| +| 0 | 1 | ~15 | 1 | +| 1 | 9 | ~8 | 0 | +| 2 | 3 | ~16 | 0 | +| 3 | 6 | ~12 | 1 | +| 4 | 1 | ~14 | 0 (plus regenerated catalogs) | + +Tests and `styles.css` are not counted. diff --git a/guide/mcp-setup.md b/guide/mcp-setup.md index a57994ffc..9730bc528 100644 --- a/guide/mcp-setup.md +++ b/guide/mcp-setup.md @@ -4,7 +4,7 @@ Nexus works with any tool that supports the Model Context Protocol (MCP). Below **Requires**: [Node.js](https://nodejs.org/) v18+ installed on your machine. -Create or refresh `connector.js` from **Nexus settings -> Get started -> MCP integration** before adding Nexus to an external agent. Obsidian community releases only ship `main.js`, `manifest.json`, and `styles.css`, so `connector.js` is generated locally when you explicitly set up MCP. +Claude Desktop, Claude Code, Codex and Antigravity can be connected in one click from **Nexus settings -> Get started -> Connect an external agent**. For any other agent, create or refresh `connector.js` from the **Other agents** steps on that same page before adding Nexus to its config. Obsidian community releases only ship `main.js`, `manifest.json`, and `styles.css`, so `connector.js` is generated locally when you explicitly set up MCP. **The server entry** you'll be adding everywhere: - **Command**: `node` @@ -21,7 +21,7 @@ Replace `/path/to/Vault/` with the actual path to your Obsidian vault. | Claude Desktop | `claude_desktop_config.json` | `mcpServers` | JSON | Yes (full quit) | | Claude Code (CLI) | `.mcp.json` | `mcpServers` | JSON | No (auto) | | Codex CLI | `~/.codex/config.toml` | `[mcp_servers.name]` | **TOML** | No | -| Gemini CLI | `~/.gemini/settings.json` | `mcpServers` | JSON | No | +| Antigravity | `~/.gemini/config/mcp_config.json` | `mcpServers` | JSON | Yes | | GitHub Copilot (VS Code) | `.vscode/mcp.json` | **`servers`** | JSON | Maybe | | Cline | `cline_mcp_settings.json` | `mcpServers` | JSON | No (auto) | | Roo Code | `cline_mcp_settings.json` or `.roo/mcp.json` | `mcpServers` | JSON | No | @@ -126,9 +126,11 @@ args = ["/path/to/Vault/.obsidian/plugins/nexus/connector.js"] --- -## Gemini CLI +## Antigravity -**Config file**: `~/.gemini/settings.json` (user-level) or `.gemini/settings.json` (project-level) +**Config file**: `~/.gemini/config/mcp_config.json` (on Windows, `%USERPROFILE%\.gemini\config\mcp_config.json`). Older Antigravity builds used `~/.gemini/antigravity/mcp_config.json`; Nexus still recognizes that file when it shows connection status, but writes the new location. + +**One click**: **Nexus settings -> Get started -> Connect an external agent -> Antigravity -> Connect** writes this entry for you. **First MCP server**: ```json @@ -142,7 +144,7 @@ args = ["/path/to/Vault/.obsidian/plugins/nexus/connector.js"] } ``` -**Adding to existing** — add inside the existing `mcpServers` object. +**Adding to existing** — add inside the existing `mcpServers` object, then restart Antigravity so it loads Nexus. --- diff --git a/manifest.json b/manifest.json index 8964bf10a..cfd52d7a6 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "id": "nexus", "name": "Nexus", "version": "5.20.4", - "minAppVersion": "1.10.0", + "minAppVersion": "1.11.0", "description": "Agentic AI for your vault. Use Claude, ChatGPT, Gemini, and local models to chat, search, create, and manage your notes with semantic memory, image generation, and MCP server integration.", "author": "Synaptic Labs", "authorUrl": "https://github.com/ProfSynapse", diff --git a/src/agents/baseManager/services/BaseQueryRunner.ts b/src/agents/baseManager/services/BaseQueryRunner.ts index f456212c4..b7eb62e1e 100644 --- a/src/agents/baseManager/services/BaseQueryRunner.ts +++ b/src/agents/baseManager/services/BaseQueryRunner.ts @@ -150,7 +150,13 @@ export class BaseQueryRunner { const started = Date.now(); const rendezvous = awaitAnalyzeView(scratchViewName); const component = new Component(); - const host = window.activeDocument.body.createDiv({ cls: HOST_CLASS }); + // The main window, never window.activeDocument. A query is started by a + // tool call, not a click, so the focused window is arbitrary: a popout or + // the Settings window (its own window since Obsidian 1.14). If that window + // closes mid-query the host is detached, the controller's isShown() gate + // stays false, and the call times out with no rows. Nothing is shown to + // the user, so the main window is always the right host. + const host = window.document.body.createDiv({ cls: HOST_CLASS }); let created = false; try { diff --git a/src/agents/promptManager/promptManager.ts b/src/agents/promptManager/promptManager.ts index 474f20dea..bd6e20e6a 100644 --- a/src/agents/promptManager/promptManager.ts +++ b/src/agents/promptManager/promptManager.ts @@ -87,6 +87,11 @@ export class PromptManagerAgent extends BaseAgent { */ private subagentTool: SubagentTool; + /** + * Prompt execution tool - stored so the chat view can supply its subagent settings + */ + private executePromptsTool: ExecutePromptsTool; + /** * Create a new PromptManagerAgent with dependency injection * @param settings Settings instance for prompt storage @@ -163,13 +168,14 @@ export class PromptManagerAgent extends BaseAgent { }); // Register unified prompt execution tool (handles single and batch) - eager (complex dependencies) - this.registerTool(new ExecutePromptsTool( + this.executePromptsTool = new ExecutePromptsTool( this.app, this.providerManager.getLLMService(), this.providerManager, this.parentAgentManager, this.storageService - )); + ); + this.registerTool(this.executePromptsTool); // Register image generation tool only if an image-capable provider key is configured const llmProviders = settings.settings.llmProviders; @@ -452,6 +458,8 @@ export class PromptManagerAgent extends BaseAgent { ): void { this.subagentTool.setSubagentExecutor(executor); this.subagentTool.setContextProvider(contextProvider); + // Saved prompts run from this chat use the same subagent settings. + this.executePromptsTool.setAgentContextProvider(() => contextProvider()); } /** diff --git a/src/agents/promptManager/tools/executePrompts/executePrompts.ts b/src/agents/promptManager/tools/executePrompts/executePrompts.ts index e8033732e..37642a55e 100644 --- a/src/agents/promptManager/tools/executePrompts/executePrompts.ts +++ b/src/agents/promptManager/tools/executePrompts/executePrompts.ts @@ -1,4 +1,6 @@ import { JSONSchema } from '../../../../types/schema/JSONSchemaTypes'; +import type { ToolRuntimeContext } from '../../../../types/tools/ToolRuntimeContext'; +import type { ConversationAgentSettings } from '../../../../services/llm/utils/SubagentModelResolver'; import { App } from 'obsidian'; import { BaseTool } from '../../../baseTool'; import { getErrorMessage } from '../../../../utils/errorUtils'; @@ -52,7 +54,16 @@ function addBatchRecommendations( * - Coordinate specialized services * - Handle high-level error management */ +/** The chat view's current conversation and its subagent settings. */ +export type AgentConversationContextProvider = () => { + conversationId: string; + conversationAgent?: ConversationAgentSettings; +} | null | undefined; + export class ExecutePromptsTool extends BaseTool { + /** Receives the caller's runtime identity so a chat's subagent settings apply. */ + readonly acceptsToolRuntimeContext = true as const; + private agentContextProvider: AgentConversationContextProvider | null = null; // Core services (injected) private obsidianApp: App | null = null; private llmService: LLMService | null = null; @@ -167,7 +178,23 @@ export class ExecutePromptsTool extends BaseTool { + /** + * Supply the chat view's subagent settings. They are used only for calls + * whose runtime identity is that same conversation in internal chat. + */ + setAgentContextProvider(provider: AgentConversationContextProvider): void { + this.agentContextProvider = provider; + } + + private resolveConversationAgentSettings(runtimeContext?: ToolRuntimeContext): ConversationAgentSettings | undefined { + if (!runtimeContext || runtimeContext.source !== 'internal' || !runtimeContext.conversationId) { + return undefined; + } + const view = this.agentContextProvider?.(); + return view && view.conversationId === runtimeContext.conversationId ? view.conversationAgent : undefined; + } + + async execute(params: BatchExecutePromptParams, runtimeContext?: ToolRuntimeContext): Promise { try { // Try to wait for dependencies if not available if (!this.llmService || !this.providerManager || !this.promptStorage) { @@ -219,6 +246,7 @@ export class ExecutePromptsTool extends BaseTool; allResults: InternalExecutionResult[]; } @@ -163,6 +171,11 @@ export interface PromptExecutionParams { model?: string; workspace?: string; sessionId?: string; + /** Agent Creativity; omitted for models that reject sampling parameters. */ + temperature?: number; + /** Agent thinking, already gated for models that cannot think. */ + enableThinking?: boolean; + thinkingEffort?: ThinkingEffort; } /** diff --git a/src/agents/promptManager/tools/subagent.ts b/src/agents/promptManager/tools/subagent.ts index daccc8e6b..bd25adbd7 100644 --- a/src/agents/promptManager/tools/subagent.ts +++ b/src/agents/promptManager/tools/subagent.ts @@ -16,6 +16,7 @@ import { getCommonResultSchema, createResult } from '../../../utils/schemaUtils' import { CommonParameters, CommonResult } from '../../../types'; import type { RemoteAgentJobService } from '../../../services/remoteAgents/RemoteAgentJobService'; import type { SubagentExecutor } from '../../../services/chat/SubagentExecutor'; +import type { ConversationAgentSettings } from '../../../services/llm/utils/SubagentModelResolver'; export interface SubagentToolParams extends CommonParameters { /** Clear description of what the subagent should accomplish */ @@ -64,7 +65,8 @@ export interface SubagentToolContext { sessionId?: string; source?: 'internal' | 'mcp'; isSubagentBranch?: boolean; - // Inherited model settings + // Model for the subagent run, resolved with the agent precedence + // (this chat's subagent model, the default, then a cloud chat model) provider?: string; model?: string; // Inherited agent settings @@ -74,9 +76,15 @@ export interface SubagentToolContext { workspaceData?: Record; // Full comprehensive workspace data (sessions, states, files, etc.) // Inherited context notes (file paths - subagent will read content) contextNotes?: string[]; - // Inherited thinking settings + // Subagent thinking, already gated for the resolved model thinkingEnabled?: boolean; thinkingEffort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max'; + /** Subagent Creativity; undefined when the model rejects sampling parameters. */ + temperature?: number; + /** Token budget for the context the subagent inherits (notes, workspace). */ + contextWindow?: number; + /** This chat's raw subagent settings, for other agent runs such as executePrompt. */ + conversationAgent?: ConversationAgentSettings; } export class SubagentTool extends BaseTool { @@ -229,7 +237,7 @@ To continue a subagent that hit max iterations, provide continueBranchId.`, sessionId: context.sessionId, maxIterations: params.maxIterations, continueBranchId: params.continueBranchId, - // Inherit parent's model settings + // Subagent model settings (resolved by the chat view) provider: context.provider, model: context.model, // Inherit parent's agent settings @@ -238,9 +246,11 @@ To continue a subagent that hit max iterations, provide continueBranchId.`, // Inherit parent's workspace data workspaceData: context.workspaceData, // Context notes already merged above via contextFiles - // Inherit parent's thinking settings + // Subagent thinking, Creativity and context budget thinkingEnabled: context.thinkingEnabled, thinkingEffort: context.thinkingEffort, + temperature: context.temperature, + contextWindow: context.contextWindow, }); const isContinuing = !!params.continueBranchId; diff --git a/src/agents/searchManager/searchManager.ts b/src/agents/searchManager/searchManager.ts index ad285fbc8..a4b3ebb3d 100644 --- a/src/agents/searchManager/searchManager.ts +++ b/src/agents/searchManager/searchManager.ts @@ -11,10 +11,10 @@ import { MemorySettings, DEFAULT_MEMORY_SETTINGS } from '../../types'; import { MemoryService } from "../memoryManager/services/MemoryService"; import { WorkspaceService } from '../../services/WorkspaceService'; import { IStorageAdapter } from '../../database/interfaces/IStorageAdapter'; -import { EmbeddingService } from '../../services/embeddings/EmbeddingService'; import { getNexusPlugin } from '../../utils/pluginLocator'; import { ServiceManager } from '../../core/ServiceManager'; import type { StorageAdapterResolver } from './services/ServiceAccessors'; +import { resolveSemanticSearchState } from '../../services/embeddings/embeddingAccess'; const noop = (): void => { return; @@ -54,7 +54,6 @@ export class SearchManagerAgent extends BaseAgent { private memoryService: MemoryService | null = null; private workspaceService: WorkspaceService | null = null; private storageAdapterResolver: (() => IStorageAdapter | undefined) | null = null; - private embeddingService: EmbeddingService | null = null; private searchContentTool: SearchContentTool | null = null; private settings: MemorySettings; @@ -110,9 +109,6 @@ export class SearchManagerAgent extends BaseAgent { if (!this.storageAdapterResolver) { this.storageAdapterResolver = () => serviceContainer.getServiceIfReady('hybridStorageAdapter') || undefined; } - if (!this.embeddingService) { - this.embeddingService = serviceContainer.getServiceIfReady('embeddingService'); - } } } } @@ -134,12 +130,14 @@ export class SearchManagerAgent extends BaseAgent { // Tools accept Plugin interface which only requires app property const pluginOrFallback: Plugin = pluginRef || this.createMinimalPlugin(app); - // Register ContentSearchTool (fuzzy + keyword + semantic search) - this.searchContentTool = new SearchContentTool(pluginOrFallback); - // Wire up EmbeddingService for semantic search if available - if (this.embeddingService) { - this.searchContentTool.setEmbeddingService(this.embeddingService); - } + // Register ContentSearchTool (fuzzy + keyword + semantic search). The tool + // resolves the embedding service from the plugin on every semantic call, + // and reads the semantic search setting whenever it describes itself, so + // turning semantic search off stops it being offered without a reload. + this.searchContentTool = new SearchContentTool( + pluginOrFallback, + () => resolveSemanticSearchState(pluginOrFallback) + ); this.registerTool(this.searchContentTool); // Register focused search tools - lazy loaded diff --git a/src/agents/searchManager/services/ServiceAccessors.ts b/src/agents/searchManager/services/ServiceAccessors.ts index d12e4d0a6..59376174c 100644 --- a/src/agents/searchManager/services/ServiceAccessors.ts +++ b/src/agents/searchManager/services/ServiceAccessors.ts @@ -13,6 +13,7 @@ import type NexusPlugin from '../../../main'; import type { MemoryService } from '../../memoryManager/services/MemoryService'; import type { WorkspaceService } from '../../../services/WorkspaceService'; import type { EmbeddingService } from '../../../services/embeddings/EmbeddingService'; +import { resolveEmbeddingService } from '../../../services/embeddings/embeddingAccess'; import type { IMessageRepository } from '../../../database/repositories/interfaces/IMessageRepository'; import type { IStorageAdapter } from '../../../database/interfaces/IStorageAdapter'; @@ -21,9 +22,10 @@ export type StorageAdapterResolver = IStorageAdapter | (() => IStorageAdapter | /** * Provides runtime service resolution for the search subsystem. * - * Services are resolved lazily via the plugin's `getServiceIfReady` API, - * returning `undefined` when a service has not yet initialised or is - * unavailable (e.g. embeddings on mobile). + * Services are resolved lazily on every call, returning `undefined` when a + * service has not yet initialised or is unavailable (e.g. embeddings on mobile + * or turned off). Registry services go through `getServiceIfReady`; the + * embedding service goes through `resolveEmbeddingService`. */ export class ServiceAccessors { private plugin: Plugin; @@ -67,16 +69,17 @@ export class ServiceAccessors { } /** - * Resolve the EmbeddingService from the running plugin instance. + * Resolve the live EmbeddingService from the plugin's embedding manager. + * + * The embedding system is not a registry entry, so this goes through + * `resolveEmbeddingService` and is re-read on every call: the manager can be + * turned off or rebuilt during a session. */ getEmbeddingService(): EmbeddingService | undefined { try { const app: App = this.plugin.app; - const plugin = getNexusPlugin(app); - if (plugin) { - return plugin.getServiceIfReady('embeddingService') || undefined; - } - return undefined; + const plugin = getNexusPlugin(app) ?? this.plugin; + return resolveEmbeddingService(plugin) ?? undefined; } catch { return undefined; } diff --git a/src/agents/searchManager/tools/searchContent.ts b/src/agents/searchManager/tools/searchContent.ts index c693ff9ef..6390af1e4 100644 --- a/src/agents/searchManager/tools/searchContent.ts +++ b/src/agents/searchManager/tools/searchContent.ts @@ -3,21 +3,18 @@ import { BaseTool } from '../../baseTool'; import { getErrorMessage } from '../../../utils/errorUtils'; import { BRAND_NAME } from '../../../constants/branding'; import { isGlobPattern, globToRegex, normalizePath, isWithinPathScope } from '../../../utils/pathUtils'; -import { EmbeddingService } from '../../../services/embeddings/EmbeddingService'; -import { EmbeddingManager } from '../../../services/embeddings/EmbeddingManager'; +import type { EmbeddingService } from '../../../services/embeddings/EmbeddingService'; +import { + resolveEmbeddingService, + resolveSemanticSearchState, + type SemanticSearchState +} from '../../../services/embeddings/embeddingAccess'; import { CommonParameters } from '../../../types'; import type { ToolStatusTense } from '../../interfaces/ITool'; import { labelQuery, verbs } from '../../utils/toolStatusLabels'; type SearchContentSchema = ReturnType['getMergedSchema']>; -/** - * Extended plugin interface that includes optional embedding manager - */ -interface PluginWithEmbeddings extends Plugin { - embeddingManager?: EmbeddingManager; -} - /** * How a result was matched. * @@ -60,9 +57,33 @@ export interface ContentSearchResult { content?: string; // Keyword search only matchType: SearchMatchType; }>; + /** Set when the search ran differently from what was asked, e.g. keyword instead of semantic. */ + note?: string; error?: string; } +/** Tool description while semantic search is available. Kept verbatim: the committed catalog holds it. */ +const DESCRIPTION_AVAILABLE = 'Search vault files. Set semantic=true for AI-powered conceptual search using local embeddings (best for concepts/related ideas), or semantic=false for keyword/fuzzy search (best for exact matches). Semantic search is desktop-only and becomes available once the embedding system initializes in the background (first run may take longer while the model downloads).'; + +/** Tool descriptions while semantic search cannot be used: no pitch for it. */ +const DESCRIPTION_KEYWORD_ONLY: Record, string> = { + off: 'Search vault files by keyword, with fuzzy filename matching (best for exact words and names). Semantic search is turned off in Nexus settings, so semantic=true also runs keyword search.', + unsupported: 'Search vault files by keyword, with fuzzy filename matching (best for exact words and names). Semantic search is not available on this device, so semantic=true also runs keyword search.' +}; + +/** The `semantic` property description for each state. The property itself always stays, so `--semantic` never becomes an unknown flag. */ +const SEMANTIC_PARAM_DESCRIPTION: Record = { + available: 'true = AI-powered conceptual search (desktop only, best for concepts/related ideas). false = keyword/fuzzy search (default, best for exact matches).', + off: 'Unavailable: semantic search is turned off in Nexus settings. true runs keyword search instead.', + unsupported: 'Unavailable on this device. true runs keyword search instead.' +}; + +/** The `note` returned when semantic=true fell back to keyword search. */ +export const SEMANTIC_FALLBACK_NOTES: Record, string> = { + off: 'Semantic search is turned off in Nexus settings, so this used keyword search.', + unsupported: 'Semantic search is not available on this device, so this used keyword search.' +}; + /** Score for a verbatim occurrence of the whole query. */ const EXACT_PHRASE_SCORE = 0.9; /** @@ -227,54 +248,57 @@ function scoreTextMatch( */ export class SearchContentTool extends BaseTool { private plugin: Plugin; - private embeddingService: EmbeddingService | null = null; - - constructor(plugin: Plugin) { - super( - 'content', - 'Content Search', - 'Search vault files. Set semantic=true for AI-powered conceptual search using local embeddings (best for concepts/related ideas), or semantic=false for keyword/fuzzy search (best for exact matches). Semantic search is desktop-only and becomes available once the embedding system initializes in the background (first run may take longer while the model downloads).', - '2.0.0' - ); + private readonly getSemanticState: () => SemanticSearchState; + + /** + * @param plugin The Nexus plugin (or a minimal stand-in carrying `app`). + * @param getSemanticState Reports whether semantic search can be offered. + * Defaults to reading the plugin's settings and platform. + */ + constructor(plugin: Plugin, getSemanticState?: () => SemanticSearchState) { + super('content', 'Content Search', DESCRIPTION_AVAILABLE, '2.0.0'); this.plugin = plugin; + this.getSemanticState = getSemanticState ?? (() => resolveSemanticSearchState(plugin)); + + // The description follows the semantic search setting, which can change + // at any time (settings tab, or synced from another device). BaseTool + // declares `description` as a field, so it is turned into an accessor + // here: discovery reads it directly, sometimes without calling + // getParameterSchema(), and it must never advertise a turned-off feature. + Object.defineProperty(this, 'description', { + get: (): string => this.describe(), + set: (): void => undefined, + enumerable: true, + configurable: true + }); } - getStatusLabel(params: Record | undefined, tense: ToolStatusTense): string | undefined { - return labelQuery(verbs('Searching notes', 'Searched notes', 'Failed to search notes'), params, tense); + /** Current semantic state; a failing provider reads as available. */ + private semanticState(): SemanticSearchState { + try { + return this.getSemanticState(); + } catch { + return 'available'; + } } - /** - * Set the embedding service for semantic search - */ - setEmbeddingService(service: EmbeddingService): void { - this.embeddingService = service; + private describe(): string { + const state = this.semanticState(); + return state === 'available' ? DESCRIPTION_AVAILABLE : DESCRIPTION_KEYWORD_ONLY[state]; + } + + getStatusLabel(params: Record | undefined, tense: ToolStatusTense): string | undefined { + return labelQuery(verbs('Searching notes', 'Searched notes', 'Failed to search notes'), params, tense); } /** - * Lazily get the embedding service from the plugin - * This handles the timing issue where EmbeddingManager initializes after VaultLibrarian + * Resolve the live embedding service from the plugin's embedding manager. + * + * Never cached: the manager can be turned off or rebuilt during a session, + * and each start builds a new service instance. */ private getEmbeddingService(): EmbeddingService | null { - // Return cached service if available - if (this.embeddingService) { - return this.embeddingService; - } - - // Try to get from plugin's embeddingManager - try { - const pluginWithEmbeddings = this.plugin as PluginWithEmbeddings; - if (pluginWithEmbeddings.embeddingManager) { - const service = pluginWithEmbeddings.embeddingManager.getService(); - if (service) { - this.embeddingService = service; // Cache for future use - return service; - } - } - } catch (error) { - void error; - } - - return null; + return resolveEmbeddingService(this.plugin); } async execute(params: ContentSearchParams): Promise { @@ -296,6 +320,15 @@ export class SearchContentTool extends BaseTool }).data ?? {}; + return this.prepareResult(true, { ...data, note }); + } + /** * Perform semantic (vector) search using embeddings */ @@ -319,7 +358,9 @@ export class SearchContentTool extends BaseTool(); + for (const result of filteredResults) { + if (results.length >= searchParams.limit) break; + if (seen.has(result.notePath)) continue; + seen.add(result.notePath); const file = this.plugin.app.vault.getAbstractFileByPath(result.notePath); if (file instanceof TFile) { // Get frontmatter only @@ -664,10 +713,13 @@ export class SearchContentTool extends BaseTool, runtimeContext: ToolRuntimeContext): Promise; +}; + /** * The slice of NexusPlugin this service needs. Declared structurally to avoid * importing the plugin class (circular) — but unlike an inline cast it names @@ -465,16 +471,19 @@ export class ToolBatchExecutionService { } try { + const runtimeContext: ToolRuntimeContext = { + conversationId: options.conversationId || '', + messageId: options.messageId || '', + workspaceId: context.workspaceId, + sessionId: context.sessionId, + source: options.operationOrigin === 'external-mcp' || !options.operationOrigin ? 'mcp' : 'internal', + isSubagentBranch: options.operationOrigin === 'subagent', + }; const toolResult = toolInstance instanceof SubagentTool - ? await toolInstance.execute(params as unknown as SubagentToolParams, { - conversationId: options.conversationId || '', - messageId: options.messageId || '', - workspaceId: context.workspaceId, - sessionId: context.sessionId, - source: options.operationOrigin === 'external-mcp' || !options.operationOrigin ? 'mcp' : 'internal', - isSubagentBranch: options.operationOrigin === 'subagent', - }) - : await toolInstance.execute(params || {}) as CommonResult; + ? await toolInstance.execute(params as unknown as SubagentToolParams, runtimeContext) + : acceptsToolRuntimeContext(toolInstance) + ? await (toolInstance as unknown as RuntimeContextTool).execute(params || {}, runtimeContext) + : await toolInstance.execute(params || {}) as CommonResult; const result: ToolCallResult = { agent: agentName, diff --git a/src/components/Accordion.ts b/src/components/Accordion.ts index ca1f4c474..cd84758b8 100644 --- a/src/components/Accordion.ts +++ b/src/components/Accordion.ts @@ -1,95 +1,139 @@ -import { Component } from 'obsidian'; +import { Component, setIcon } from 'obsidian'; /** - * A collapsible accordion component for Obsidian plugin settings - * Provides smooth animations and mobile-friendly interaction + * Options for a shared settings accordion ("fold"). + */ +export interface AccordionOptions { + /** Stable fold id; also the basis of the content element's DOM id. */ + id: string; + title: string; + /** Lucide icon name shown before the title. */ + icon?: string; + /** One-line summary shown under the title while the fold is closed. */ + summary?: string; + /** aria-level of the heading that wraps the toggle. Defaults to 3. */ + headingLevel?: number; + /** Open on creation. Defaults to closed. */ + open?: boolean; + /** Called once per user toggle (and for non-silent setOpen calls that change state). */ + onToggle?: (open: boolean) => void; +} + +let accordionSequence = 0; + +/** + * The one collapsible used across Nexus settings. + * + * Markup: + * div.nexus-accordion[.is-open] + * div.nexus-accordion-heading[role=heading][aria-level] + * button.nexus-accordion-toggle[id][aria-expanded][aria-controls] + * span.nexus-accordion-icon + * span.nexus-accordion-text > span.nexus-accordion-title + span.nexus-accordion-summary + * span.nexus-accordion-chevron + * div.nexus-accordion-content.setting-group[id][role=region][aria-labelledby][hidden] + * div.setting-items (exposed as itemsEl; the same node for the accordion's lifetime) + * + * The content reuses Obsidian's setting-group markup so rows added with + * `new Setting(accordion.itemsEl)` get the core 1.11+ group styling. The toggle + * is a native button, so Enter and Space work without extra key handling, and + * it sits in a heading (the WAI-ARIA accordion pattern) so screen-reader + * heading navigation reaches each fold. The accordion loads itself, so + * `unload()` really removes its listener (Obsidian's unload is a no-op on a + * component that was never loaded). */ export class Accordion extends Component { - containerEl: HTMLElement; - rootEl: HTMLElement; - contentEl: HTMLElement; - isOpen: boolean; + readonly containerEl: HTMLElement; + readonly rootEl: HTMLElement; + readonly toggleEl: HTMLButtonElement; + readonly titleEl: HTMLElement; + readonly summaryEl: HTMLElement; + readonly chevronEl: HTMLElement; + readonly contentEl: HTMLElement; + readonly itemsEl: HTMLElement; + private open: boolean; + private summary: string; + private readonly onToggle?: (open: boolean) => void; - /** - * Create a new accordion - * @param containerEl Parent container element - * @param title Accordion header title - * @param defaultOpen Whether accordion is open by default - */ - constructor(containerEl: HTMLElement, title: string, defaultOpen = false, private onToggle?: (isOpen: boolean) => void) { + constructor(containerEl: HTMLElement, options: AccordionOptions) { super(); - this.containerEl = containerEl; - this.isOpen = defaultOpen; - - // Create main accordion container - const accordionEl = this.containerEl.createDiv({ - cls: 'mcp-accordion' - }); - this.rootEl = accordionEl; + this.open = options.open === true; + this.summary = options.summary ?? ''; + this.onToggle = options.onToggle; - // Create container to scope selectors - const accordionContainer = accordionEl.createDiv({ - cls: 'mcp-accordion-container' - }); + const baseId = `nexus-accordion-${options.id}-${++accordionSequence}`; + const contentId = baseId; + const toggleId = `${baseId}-toggle`; - // Create header with toggle button - const headerEl = accordionContainer.createDiv({ - cls: 'mcp-accordion-header' - }); + this.rootEl = containerEl.createDiv({ cls: 'nexus-accordion' }); + this.rootEl.setAttribute('data-fold', options.id); - const toggleButton = headerEl.createEl('button', { - cls: 'mcp-accordion-toggle' + const headingEl = this.rootEl.createDiv({ + cls: 'nexus-accordion-heading', + attr: { role: 'heading', 'aria-level': String(options.headingLevel ?? 3) } }); - - // Add title - toggleButton.createSpan({ - text: title, - cls: 'mcp-accordion-title' + this.toggleEl = headingEl.createEl('button', { + cls: 'nexus-accordion-toggle', + attr: { type: 'button', id: toggleId, 'aria-controls': contentId } }); - // Add expand/collapse icon - toggleButton.createSpan({ - cls: `mcp-accordion-icon ${this.isOpen ? 'is-open' : ''}` - }); + if (options.icon) { + const iconEl = this.toggleEl.createSpan({ cls: 'nexus-accordion-icon' }); + setIcon(iconEl, options.icon); + } - // Create content container - this.contentEl = accordionContainer.createDiv({ - cls: `mcp-accordion-content ${this.isOpen ? 'is-open' : ''}` - }); + const textEl = this.toggleEl.createSpan({ cls: 'nexus-accordion-text' }); + this.titleEl = textEl.createSpan({ cls: 'nexus-accordion-title', text: options.title }); + this.summaryEl = textEl.createSpan({ cls: 'nexus-accordion-summary' }); + this.chevronEl = this.toggleEl.createSpan({ cls: 'nexus-accordion-chevron' }); - // Toggle on click - using registerDomEvent for auto-cleanup - this.registerDomEvent(toggleButton, 'click', () => { - this.toggle(accordionContainer); + this.contentEl = this.rootEl.createDiv({ + cls: ['nexus-accordion-content', 'setting-group'], + attr: { role: 'region', 'aria-labelledby': toggleId } }); + this.contentEl.id = contentId; + this.itemsEl = this.contentEl.createDiv({ cls: 'setting-items' }); + + this.load(); + this.registerDomEvent(this.toggleEl, 'click', () => this.toggle()); + this.applyState(); } - /** - * Toggle accordion open/closed state - * @param container The accordion container element to toggle - */ - toggle(container: HTMLElement): void { - this.isOpen = !this.isOpen; - - // Update icon - scoped to this container - const iconEl = container.querySelector('.mcp-accordion-icon'); - if (iconEl) { - iconEl.classList.toggle('is-open'); - } - - // Update content - scoped to this container - const contentEl = container.querySelector('.mcp-accordion-content'); - if (contentEl) { - contentEl.classList.toggle('is-open'); - } - this.onToggle?.(this.isOpen); + get isOpen(): boolean { + return this.open; + } + + /** Replace the collapsed summary line. Hidden while the fold is open. */ + setSummary(summary: string): this { + this.summary = summary; + this.summaryEl.setText(summary); + this.summaryEl.hidden = this.open || summary.length === 0; + return this; } /** - * Get the content element to add children to + * Open or close the fold. `silent` updates the DOM without calling onToggle, + * for restoring remembered state. */ - getContentEl(): HTMLElement { - const innerContent = this.contentEl.createDiv(); - return innerContent; + setOpen(open: boolean, options: { silent?: boolean } = {}): this { + if (open === this.open) return this; + this.open = open; + this.applyState(); + if (!options.silent) this.onToggle?.(open); + return this; + } + + toggle(): void { + this.setOpen(!this.open); + } + + private applyState(): void { + this.rootEl.toggleClass('is-open', this.open); + this.toggleEl.setAttribute('aria-expanded', String(this.open)); + this.contentEl.hidden = !this.open; + this.chevronEl.empty(); + setIcon(this.chevronEl, this.open ? 'chevron-up' : 'chevron-down'); + this.setSummary(this.summary); } } diff --git a/src/components/ConfigModal.ts b/src/components/ConfigModal.ts index 93284b4f7..f5847fb9b 100644 --- a/src/components/ConfigModal.ts +++ b/src/components/ConfigModal.ts @@ -439,7 +439,7 @@ export class ConfigModal extends Modal { throw new Error(`${moduleName} is only available on desktop.`); } - const maybeRequire = (window.activeWindow as Window & { + const maybeRequire = (window as Window & { require?: (moduleId: string) => unknown; }).require; diff --git a/src/components/shared/ChatSettingsRenderer.ts b/src/components/shared/ChatSettingsRenderer.ts index 02245877a..7c6877b04 100644 --- a/src/components/shared/ChatSettingsRenderer.ts +++ b/src/components/shared/ChatSettingsRenderer.ts @@ -1,117 +1,86 @@ /** - * ChatSettingsRenderer - Shared settings UI for DefaultsTab and ChatSettingsModal + * ChatSettingsRenderer - Shared settings UI for the Defaults tab and the Chat settings modal. * - * Renders identical UI in both places: - * - Chat provider + model - * - Reasoning toggle + Effort slider - * - Image generation settings - * - Transcription settings - * - Workspace + prompt - * - Context notes + * Layout ("Essentials + folds"): + * - an always-open top block (an Obsidian SettingGroup with no heading) holding + * Provider, Model and Thinking; + * - collapsible folds (the shared Accordion), each with an icon and a one-line + * summary while closed: Chat options, Context, Subagents, Images, Voice, plus + * any folds the host adds (Video, File conversion, Semantic search on Defaults). * - * The difference is only WHERE data is saved (via callbacks). + * The shell (top block and accordions) is built once. Each group empties and + * refills only its own `setting-items` list, so a provider change, a workspace + * change or a synced settings update never collapses a fold or loses the scroll + * position. Fold open/closed state is remembered per device and per surface. + * + * The surfaces differ only in which rows appear (Web search and Workflow are + * chat-only) and where data is saved (via callbacks). + * + * The selected model decides its rows: Thinking appears only for models that + * reason and offers only the levels the model distinguishes; Creativity is + * disabled for models that set their own temperature; Context window snaps to + * preset sizes up to the model's maximum, and a newly picked model starts at + * that maximum. The Subagents fold applies the same rules to the subagent + * model (or, when none is set, the cloud chat model subagents fall back to). + * + * A chat model counts only when its provider can be used: a fresh install + * carries a default model for a provider nobody has set up, and the rows and + * summaries must not describe it. Without a model, Thinking is hidden and + * Creativity and Context window show a disabled placeholder. */ -import { App, Component, Setting, EventRef } from 'obsidian'; +import { App, Component, EventRef, Setting, setIcon } from 'obsidian'; import { LLMProviderManager } from '../../services/llm/providers/ProviderManager'; import { StaticModelsService } from '../../services/StaticModelsService'; import { ImageGenerationService } from '../../services/llm/ImageGenerationService'; -import { - buildSpeechProviderAvailability, - getSpeechModel, - resolveDefaultSpeechSelection, - type SpeechProvider, - type SpeechProviderAvailability, -} from '../../services/llm/types/SpeechTypes'; -import { - buildRealtimeVoiceProviderAvailability, - getRealtimeVoiceModel, - resolveDefaultRealtimeVoiceSelection, - type RealtimeVoiceProviderAvailability, -} from '../../services/llm/types/RealtimeVoiceTypes'; -import { - DefaultRealtimeVoiceModelSettings, - DefaultSpeechModelSettings, - LLMProviderSettings, - ThinkingEffort -} from '../../types/llm/ProviderTypes'; +import { getImageModelDisplayName } from '../../services/llm/imageModelNames'; +import { LLMProviderSettings } from '../../types/llm/ProviderTypes'; import type { AppsSettings } from '../../types/apps/AppTypes'; import { isTextOnlyProvider } from '../../services/llm/utils/ToolSchemaSupport'; import { FilePickerRenderer } from '../workspace/FilePickerRenderer'; import { isProviderCompatible } from '../../utils/platform'; import { LLMSettingsNotifier } from '../../services/llm/LLMSettingsNotifier'; -import { renderModelDropdownSection } from './ModelDropdownRenderer'; -import { - getIngestCapabilityOptions, - normalizeIngestSelection -} from '../../agents/ingestManager/tools/services/IngestCapabilityService'; -import { renderIngestModelDropdowns } from './IngestModelDropdownRenderer'; -import { VoiceCatalogService } from '../../services/readAloud/VoiceCatalogService'; -import { SpeechModelCatalogService } from '../../services/readAloud/SpeechModelCatalogService'; +import { renderModelSetting, renderProviderSetting, toDisplayProvider, type ModelDropdownConfig } from './ModelDropdownRenderer'; import { ProviderUtils } from '../../ui/chat/utils/ProviderUtils'; import { getNexusPlugin } from '../../utils/pluginLocator'; import { sortModelsNewestFirst } from '../../utils/modelOrdering'; import { getContextWindowOverrideKey, resolveContextWindowLimit } from '../../ui/chat/utils/ContextWindowSettings'; +import { normalizeThinkingEffortForModel } from '../../services/llm/utils/thinkingLevels'; +import { contextWindowStopIndex, contextWindowStops } from './chatSettings/contextWindowStops'; +import { resolveSubagentTarget } from './chatSettings/subagentTarget'; +import { isLocalProvider } from '../../services/llm/utils/SubagentModelResolver'; +import { ModelCapabilityResolver, type ModelCapabilities } from './chatSettings/modelCapabilities'; +import { Accordion } from '../Accordion'; +import { FoldStateStore } from './settings/FoldStateStore'; +import { createSettingGroup, type SettingGroupHandle } from './settings/settingGroup'; +import { bindSliderReadout } from './settings/sliderReadout'; +import { COPY, FOLD_ICONS, FOLD_IDS, FOLD_TITLES } from './chatSettings/copy'; +import { + chatOptionsSummary, + contextSummary, + formatContextWindow, + formatTemperature, + imagesSummary, + subagentsSummary, + tuningSummary +} from './chatSettings/sectionSummaries'; +import { + getThinkingLevels, + optionToThinking, + THINKING_LABELS, + thinkingToOption +} from './chatSettings/thinkingOptions'; +import { VoiceSettings, type VoiceAudioFolderOption } from './chatSettings/VoiceSettings'; +import type { + ChatSettings, + ChatSettingsOptions, + FoldDefinition, + FoldHost, + SettingsSurface +} from './chatSettings/types'; + +export type { ChatSettings, ChatSettingsOptions, FoldDefinition, FoldHost, SettingsSurface } from './chatSettings/types'; -/** - * Current settings state - */ -export interface ChatSettings { - provider: string; - model: string; - contextWindowOverrides?: Record; - webSearch?: boolean; - // Subagent model - used for executePrompt when chat model is local - agentProvider?: string; - agentModel?: string; - thinking: { - enabled: boolean; - effort: ThinkingEffort; - }; - // Subagent model thinking settings (separate from chat model) - agentThinking?: { - enabled: boolean; - effort: ThinkingEffort; - }; - temperature: number; // 0.0-1.0, controls randomness - imageProvider: 'google' | 'openrouter' | 'openai'; - imageModel: string; - speechProvider?: DefaultSpeechModelSettings['provider']; - speechModel?: DefaultSpeechModelSettings['model']; - speechVoice?: DefaultSpeechModelSettings['voice']; - realtimeVoiceProvider?: DefaultRealtimeVoiceModelSettings['provider']; - realtimeVoiceModel?: DefaultRealtimeVoiceModelSettings['model']; - realtimeVoiceVoice?: DefaultRealtimeVoiceModelSettings['voice']; - transcriptionProvider?: string; - transcriptionModel?: string; - workspaceId: string | null; - workflowId?: string | null; - promptId: string | null; - contextNotes: string[]; -} - -/** - * Local providers that can't be used for executePrompt - */ -const LOCAL_PROVIDERS = ['webllm', 'ollama', 'lmstudio']; - -/** - * Available options for dropdowns - */ -export interface ChatSettingsOptions { - workspaces: Array<{ - id: string; - name: string; - context?: { - workflows?: Array<{ id: string; name: string; when?: string }>; - dedicatedAgent?: { - agentId: string; - agentName: string; - }; - }; - }>; - prompts: Array<{ id: string; name: string }>; -} /** * Callbacks for when settings change @@ -129,11 +98,14 @@ export interface ChatSettingsRendererConfig { initialSettings: ChatSettings; options: ChatSettingsOptions; callbacks: ChatSettingsCallbacks; - showWorkflowSelection?: boolean; - showVoiceSection?: boolean; - showTranscriptionSection?: boolean; - showWebSearch?: boolean; - renderAfterImageSection?: (parent: HTMLElement) => void; + /** Which screen this is: decides chat-only rows and where fold state is remembered. */ + surface: SettingsSurface; + /** Device-local fold memory; defaults to one backed by `app`'s local storage. */ + foldStore?: FoldStateStore; + /** Host-specific folds (Video, File conversion, Semantic search on Defaults). */ + extraFolds?: Array; + /** Defaults only: the saved read-aloud audio folder row in the Voice fold. */ + voiceAudioFolder?: VoiceAudioFolderOption; } interface PluginWithAppSettings { @@ -144,14 +116,23 @@ interface PluginWithAppSettings { }; } -const EFFORT_LEVELS: ThinkingEffort[] = ['low', 'medium', 'high', 'xhigh', 'max']; -const EFFORT_LABELS: Record = { - low: 'Low', - medium: 'Medium', - high: 'High', - xhigh: 'Extra high', - max: 'Max' -}; +const TOP_GROUP = 'top'; + +/** The chat model (top block, Chat options) or the subagent model (Subagents fold). */ +type ModelVariant = 'chat' | 'agent'; + +interface ModelTarget { + provider?: string; + model?: string; +} + +interface FoldRecord { + id: string; + accordion: Accordion; + render: (itemsEl: HTMLElement) => void; + summary?: () => string; + definition?: FoldDefinition; +} export class ChatSettingsRenderer { private container: HTMLElement; @@ -159,26 +140,36 @@ export class ChatSettingsRenderer { private providerManager: LLMProviderManager; private staticModelsService: StaticModelsService; private settings: ChatSettings; - - // UI references - private effortSection?: HTMLElement; - private agentEffortSection?: HTMLElement; - private contextNotesListEl?: HTMLElement; private settingsEventRef?: EventRef; - private sliderEvents?: Component; + private imageService: ImageGenerationService; + private foldStore: FoldStateStore; + private voice: VoiceSettings; + + // Stable shell: built once, never rebuilt by a redraw. + private topGroup: SettingGroupHandle | null = null; + private folds = new Map(); + // One Component per group owns that group's DOM listeners; unloaded when the group redraws. + private groupEvents = new Map(); + // Maps dropdown option value -> actual { provider, modelId } for merged model lists private modelOptionMap: Map = new Map(); private agentModelOptionMap: Map = new Map(); - private imageService: ImageGenerationService; + private modelNames: { chat?: string; agent?: string; image?: string } = {}; + // Registry capabilities plus context windows discovered at runtime (Ollama, LM Studio). + private capabilities: ModelCapabilityResolver; constructor(container: HTMLElement, config: ChatSettingsRendererConfig) { this.container = container; this.config = config; this.settings = { ...config.initialSettings, - contextWindowOverrides: { ...(config.initialSettings.contextWindowOverrides ?? config.llmProviderSettings.contextWindowOverrides ?? {}) } + contextNotes: [...(config.initialSettings.contextNotes ?? [])], + contextWindowOverrides: { ...(config.initialSettings.contextWindowOverrides ?? config.llmProviderSettings.contextWindowOverrides ?? {}) }, + agentContextWindowOverrides: { ...(config.initialSettings.agentContextWindowOverrides ?? config.llmProviderSettings.agentContextWindowOverrides ?? {}) } }; this.staticModelsService = StaticModelsService.getInstance(); + this.capabilities = new ModelCapabilityResolver(this.staticModelsService, () => this.providerManager); + this.foldStore = config.foldStore ?? new FoldStateStore(config.app); this.providerManager = new LLMProviderManager( config.llmProviderSettings, @@ -187,6 +178,22 @@ export class ChatSettingsRenderer { this.imageService = new ImageGenerationService(config.app.vault, config.llmProviderSettings); + const settings = (): ChatSettings => this.settings; + const llmProviderSettings = (): LLMProviderSettings => this.config.llmProviderSettings; + const providerManager = (): LLMProviderManager => this.providerManager; + this.voice = new VoiceSettings({ + surface: config.surface, + get settings() { return settings(); }, + get llmProviderSettings() { return llmProviderSettings(); }, + get providerManager() { return providerManager(); }, + notifyChange: () => this.notifyChange(), + setSummary: summary => this.folds.get(FOLD_IDS.voice)?.accordion.setSummary(summary), + redraw: () => this.redrawGroups([FOLD_IDS.voice]), + getProviderDisplayName: id => this.getProviderDisplayName(id), + getAppsSettings: () => this.getAppsSettings(), + audioFolder: config.voiceAudioFolder + }); + this.settingsEventRef = LLMSettingsNotifier.onSettingsChanged((newSettings) => { this.config.llmProviderSettings = newSettings; this.providerManager.updateSettings(newSettings); @@ -196,94 +203,184 @@ export class ChatSettingsRenderer { } destroy(): void { - this.sliderEvents?.unload(); - this.sliderEvents = undefined; + for (const events of this.groupEvents.values()) events.unload(); + this.groupEvents.clear(); + for (const fold of this.folds.values()) { + fold.definition?.destroy?.(); + fold.accordion.unload(); + } if (this.settingsEventRef) { LLMSettingsNotifier.unsubscribe(this.settingsEventRef); this.settingsEventRef = undefined; } } + /** + * Draw the form. The first call builds the shell; later calls (synced + * settings, provider changes elsewhere) refill every group in place. + */ render(): void { - this.sliderEvents?.unload(); - this.sliderEvents = undefined; - this.container.empty(); - this.container.addClass('chat-settings-renderer'); + this.preserveScroll(() => { + if (!this.topGroup) this.buildShell(); + this.renderGroups([TOP_GROUP, ...this.folds.keys()]); + }); + } - // Vertical layout - order: Chat (with Reasoning), Agent, Image, Transcription, Temp, Context - this.renderModelSection(this.container); - this.renderAgentModelSection(this.container); - this.renderImageSection(this.container); - this.config.renderAfterImageSection?.(this.container); - if (this.config.showVoiceSection !== false) { - this.renderVoiceSection(this.container); - } else if (this.config.showTranscriptionSection !== false) { - this.renderTranscriptionSection(this.container); - } - this.renderTemperatureSection(this.container); - this.renderContextSection(this.container); + private get surface(): SettingsSurface { + return this.config.surface; } private notifyChange(): void { this.config.callbacks.onSettingsChange({ ...this.settings }); + this.updateSummaries(); } - private getEnabledProviders(): string[] { - const llmSettings = this.config.llmProviderSettings; - const providers = new Set(); + // ========== SHELL ========== - for (const id of Object.keys(llmSettings.providers)) { - if (id === 'openai-codex') { - const config = llmSettings.providers[id]; - if (config?.enabled && config?.oauth?.connected && config?.apiKey) { - providers.add('openai'); - } + private buildShell(): void { + this.container.empty(); + this.container.addClass('chat-settings-renderer'); + this.container.addClass('nexus-settings-folds'); + this.topGroup = createSettingGroup(this.container, 'nexus-settings-essentials'); + + const shared: FoldRecord[] = [ + this.createFold(FOLD_IDS.chatOptions, FOLD_TITLES.chatOptions, FOLD_ICONS.chatOptions, + el => this.renderChatOptions(el), () => this.chatOptionsSummary()), + this.createFold(FOLD_IDS.context, FOLD_TITLES.context, FOLD_ICONS.context, + el => this.renderContext(el), () => this.contextSummary()), + this.createFold(FOLD_IDS.subagents, FOLD_TITLES.subagents, FOLD_ICONS.subagents, + el => this.renderSubagents(el), () => this.subagentsSummary()), + this.createFold(FOLD_IDS.images, FOLD_TITLES.images, FOLD_ICONS.images, + el => this.renderImages(el), () => imagesSummary(this.imageModelName())), + this.createFold(FOLD_IDS.voice, FOLD_TITLES.voice, FOLD_ICONS.voice, + el => this.voice.render(el), () => this.voice.getSummary()) + ]; + + // Host folds go after the shared fold they name, otherwise at the end. + const order = shared.map(fold => fold.id); + const extras = this.config.extraFolds ?? []; + for (const extra of extras) { + const anchor = extra.after ? order.indexOf(extra.after) : -1; + const index = anchor >= 0 ? anchor + 1 : order.length; + order.splice(index, 0, extra.id); + } + const byId = new Map(shared.map(fold => [fold.id, fold])); + for (const id of order) { + const sharedFold = byId.get(id); + if (sharedFold) { + this.mountFold(sharedFold); continue; } + const extra = extras.find(item => item.id === id); + if (!extra) continue; + const record = this.createFold(extra.id, extra.title, extra.icon, + el => extra.render(el, this.createFoldHost(extra.id))); + record.definition = extra; + this.mountFold(record); + } + } - if (id === 'anthropic-claude-code') { - const config = llmSettings.providers[id]; - if (config?.enabled && config?.oauth?.connected) { - providers.add('anthropic'); - } - continue; - } + /** Accordions are created detached from order; mountFold appends them in final order. */ + private createFold( + id: string, + title: string, + icon: string, + render: (itemsEl: HTMLElement) => void, + summary?: () => string + ): FoldRecord { + const accordion = new Accordion(this.container, { + id, + title, + icon, + open: this.foldStore.isOpen(this.surface, id), + onToggle: open => this.foldStore.setOpen(this.surface, id, open) + }); + return { id, accordion, render, summary }; + } - if (id === 'google-gemini-cli') { - const config = llmSettings.providers[id]; - if (config?.enabled && config?.oauth?.connected) { - providers.add('google'); - } - continue; - } + private mountFold(record: FoldRecord): void { + // Re-append so the DOM order matches the fold order. + this.container.appendChild(record.accordion.rootEl); + this.folds.set(record.id, record); + } - if (id === 'github-copilot') { - const config = llmSettings.providers[id]; - if (config?.enabled && config?.oauth?.connected && config?.apiKey) { - providers.add('github-copilot'); - } - continue; - } + private createFoldHost(id: string): FoldHost { + const settings = (): ChatSettings => this.settings; + const llmProviderSettings = (): LLMProviderSettings => this.config.llmProviderSettings; + return { + get settings() { return settings(); }, + get llmProviderSettings() { return llmProviderSettings(); }, + surface: this.surface, + notifyChange: () => this.notifyChange(), + setSummary: summary => this.folds.get(id)?.accordion.setSummary(summary), + redraw: () => this.redrawGroups([id]), + registerDomEvent: (el, type, callback) => this.events(id).registerDomEvent(el, type, callback) + }; + } - const config = llmSettings.providers[id]; - if (!config?.enabled) continue; - if (!isProviderCompatible(config.driverKind ?? id)) continue; + /** A fresh listener owner for a group, replacing (and unloading) the previous one. */ + private resetEvents(groupId: string): Component { + this.groupEvents.get(groupId)?.unload(); + const events = new Component(); + events.load(); + this.groupEvents.set(groupId, events); + return events; + } - if (config.driverKind === 'openai-compatible') { - if (config.openaiCompatible?.baseUrl.trim()) providers.add(id); - continue; - } + private events(groupId: string): Component { + return this.groupEvents.get(groupId) ?? this.resetEvents(groupId); + } - if (id === 'webllm') { - providers.add(id); + /** Redraw some groups, keeping the scroll position. */ + private redrawGroups(ids: string[]): void { + this.preserveScroll(() => this.renderGroups(ids)); + } + + private renderGroups(ids: string[]): void { + for (const id of ids) { + if (id === TOP_GROUP) { + if (!this.topGroup) continue; + this.resetEvents(TOP_GROUP); + this.topGroup.itemsEl.empty(); + this.renderTop(this.topGroup.itemsEl); continue; } + const fold = this.folds.get(id); + if (!fold) continue; + this.resetEvents(id); + fold.accordion.itemsEl.empty(); + fold.render(fold.accordion.itemsEl); + if (fold.summary) fold.accordion.setSummary(fold.summary()); + } + } - if (config.apiKey) { - providers.add(id); - } + private updateSummaries(): void { + for (const fold of this.folds.values()) { + if (fold.summary) fold.accordion.setSummary(fold.summary()); + } + } + + private preserveScroll(work: () => void): void { + const scroller = this.container.closest?.('.vertical-tab-content, .modal-content') as HTMLElement | null | undefined; + const top = scroller?.scrollTop; + work(); + if (scroller && typeof top === 'number' && scroller.scrollTop !== top) { + scroller.scrollTop = top; } + } + // ========== PROVIDERS ========== + + /** + * Providers shown in the Provider dropdowns. Merged sign-in providers + * (ChatGPT, Claude Code, Antigravity CLI) appear under their API provider. + */ + private getEnabledProviders(): string[] { + const settings = this.config.llmProviderSettings; + const providers = new Set(); + for (const id of Object.keys(settings.providers)) { + if (isProviderUsable(settings, id)) providers.add(toDisplayProvider(id) ?? id); + } return Array.from(providers); } @@ -302,25 +399,35 @@ export class ChatSettingsRenderer { return !!geminiCliConfig?.oauth?.connected; } - // ========== MODEL SECTION ========== - - private renderModelSection(parent: HTMLElement): void { - renderModelDropdownSection(parent, { - sectionTitle: 'Chat model', - getProviders: () => this.getEnabledProviders(), - getCurrentProvider: () => this.settings.provider, - getCurrentModel: () => this.settings.model, + private modelDropdownConfig(variant: ModelVariant): ModelDropdownConfig { + const isAgent = variant === 'agent'; + return { + getProviders: () => isAgent + ? this.getEnabledProviders().filter(id => !isLocalProvider(id)) + : this.getEnabledProviders(), + getCurrentProvider: () => isAgent ? this.settings.agentProvider : this.settings.provider, + getCurrentModel: () => isAgent ? this.settings.agentModel : this.settings.model, onProviderChange: (provider) => { - this.settings.provider = provider ?? ''; + if (isAgent) this.settings.agentProvider = provider; + else this.settings.provider = provider ?? ''; }, onModelChange: (model, provider) => { - this.settings.model = model ?? ''; - if (provider !== undefined) { - this.settings.provider = provider; + if (isAgent) { + this.settings.agentModel = model; + if (provider !== undefined) this.settings.agentProvider = provider; + } else { + this.settings.model = model ?? ''; + if (provider !== undefined) this.settings.provider = provider; } + // In a chat, a newly picked model starts at its full window. On + // Defaults every per-model limit is already saved and is that model's + // own preference, so picking a model keeps it. + if (this.surface !== 'defaults') this.clearContextWindowOverride(variant); }, - noProvidersText: 'No providers enabled', - modelOptionMap: this.modelOptionMap, + noProvidersText: isAgent ? COPY.noCloudProviders : COPY.noProviders, + inheritOptionText: isAgent && this.subagentsCanInheritChat() ? COPY.sameAsChat : undefined, + getUnsetModelText: isAgent ? () => this.inheritedSubagentModelText() : undefined, + modelOptionMap: isAgent ? this.agentModelOptionMap : this.modelOptionMap, providerManager: this.providerManager, getProviderDisplayName: (id) => this.config.llmProviderSettings.providers[id]?.driverKind === 'openai-compatible' ? this.getProviderDisplayName(id) : undefined, @@ -329,171 +436,294 @@ export class ChatSettingsRenderer { isGeminiCliConnected: () => this.isGeminiCliConnected(), getDefaultModelForProvider: (id) => this.getDefaultModelForProvider(id), notifyChange: () => this.notifyChange(), - reRender: () => this.render(), - onAfterRender: (content) => { - this.renderReasoningControls(content); - this.renderContextWindowControl(content); - this.renderWebSearchControl(content); - this.renderTextOnlyProviderWarning(content, 'chat'); + onSelectionChanged: () => { + if (isAgent) this.redrawGroups([FOLD_IDS.subagents]); + // The chat model decides the top block, the chat options and (when no + // subagent model is set) what the Subagents summary reports. + else this.redrawGroups([TOP_GROUP, FOLD_IDS.chatOptions, FOLD_IDS.subagents]); }, + onModelNameResolved: (name) => { + if (isAgent) this.modelNames.agent = name; + else this.modelNames.chat = name; + this.updateSummaries(); + } + }; + } + + // ========== TOP BLOCK ========== + + private renderTop(itemsEl: HTMLElement): void { + renderProviderSetting(itemsEl, this.modelDropdownConfig('chat')); + const modelSetting = renderModelSetting(itemsEl, this.modelDropdownConfig('chat')); + this.applyTextOnlyProviderWarning(modelSetting, 'chat'); + this.renderThinkingSetting(itemsEl, 'chat'); + } + + // ========== MODEL CAPABILITIES ========== + + /** + * The model a variant's tuning rows describe. Subagents use their own model + * when one is set; otherwise they run on the chat model when it is a cloud + * model (the runtime precedence), so the rows and summary describe that one. + */ + private modelTarget(variant: ModelVariant): ModelTarget { + const chat = this.chatTarget(); + if (variant === 'chat') return chat; + const { provider, model } = resolveSubagentTarget({ + ...this.settings, + provider: chat.provider ?? '', + model: chat.model ?? '' }); + return { provider, model }; } /** - * Render reasoning controls (toggle + effort slider) parameterized for chat or agent. + * The chat model, or nothing when none is picked or its provider cannot be + * used (no key, turned off). The runtime skips such a model too. */ - private renderReasoningControls(content: HTMLElement, variant: 'chat' | 'agent' = 'chat'): void { - const isAgent = variant === 'agent'; + private chatTarget(): ModelTarget { + const { provider, model } = this.settings; + return provider && model && isProviderUsable(this.config.llmProviderSettings, provider) ? { provider, model } : {}; + } - // Check model support - const provider = isAgent ? this.settings.agentProvider : this.settings.provider; - const model = isAgent ? this.settings.agentModel : this.settings.model; - if (!provider || !model) return; - const modelDef = this.staticModelsService.findModel(provider, model); - if (!modelDef?.capabilities?.supportsThinking) return; + private hasModel(variant: ModelVariant): boolean { + const { provider, model } = this.modelTarget(variant); + return !!(provider && model); + } - // Ensure agent thinking state is initialized - if (isAgent && !this.settings.agentThinking) { - this.settings.agentThinking = { enabled: false, effort: 'medium' }; - } + private hasAgentModel(): boolean { + return !!(this.settings.agentProvider && this.settings.agentModel); + } - const getThinking = () => { - if (isAgent) { - if (!this.settings.agentThinking) { - this.settings.agentThinking = { enabled: false, effort: 'medium' }; - } - return this.settings.agentThinking; - } + private capabilitiesFor(variant: ModelVariant): ModelCapabilities { + const { provider, model } = this.modelTarget(variant); + return this.capabilities.get(provider, model, () => + this.redrawGroups([FOLD_IDS.chatOptions, FOLD_IDS.subagents])); + } - return this.settings.thinking; - }; + private modelNameFor(variant: ModelVariant): string | undefined { + const { provider, model } = this.modelTarget(variant); + const loaded = variant === 'agent' && this.hasAgentModel() ? this.modelNames.agent : this.modelNames.chat; + return this.modelDisplayName(provider, model, loaded); + } - // Reasoning toggle - new Setting(content) - .setName('Reasoning') - .setDesc('Think step-by-step') - .addToggle(toggle => toggle - .setValue(getThinking().enabled) - .onChange(value => { - if (isAgent && !this.settings.agentThinking) { - this.settings.agentThinking = { enabled: false, effort: 'medium' }; - } - getThinking().enabled = value; + private thinkingFor(variant: ModelVariant): ChatSettings['thinking'] { + if (variant === 'chat') return this.settings.thinking; + return this.settings.agentThinking ?? { enabled: false, effort: 'medium' }; + } + + /** + * The Thinking dropdown (Off plus the levels this model distinguishes). + * Present only when the model can reason; Off keeps the stored effort so + * switching back restores it. + */ + private renderThinkingSetting(itemsEl: HTMLElement, variant: ModelVariant): void { + const isAgent = variant === 'agent'; + const { provider, model } = this.modelTarget(variant); + if (!this.capabilitiesFor(variant).supportsThinking) return; + + const current = this.thinkingFor(variant); + new Setting(itemsEl) + .setName(COPY.thinking) + .addDropdown(dropdown => { + dropdown.addOption('off', THINKING_LABELS.off); + for (const level of getThinkingLevels(provider, model)) { + dropdown.addOption(level, THINKING_LABELS[level]); + } + dropdown.setValue(thinkingToOption(current, provider, model)); + dropdown.onChange(value => { + const next = optionToThinking(value, isAgent ? this.settings.agentThinking : this.settings.thinking); + if (isAgent) this.settings.agentThinking = next; + else this.settings.thinking = next; this.notifyChange(); - this.updateEffortVisibility(variant); - })); + }); + }); + } - // Effort slider - const effortEl = content.createDiv('csr-effort-row'); - if (isAgent) { - this.agentEffortSection = effortEl; - } else { - this.effortSection = effortEl; - } - if (!getThinking().enabled) { - effortEl.addClass('is-hidden'); - } + // ========== CHAT OPTIONS ========== - const effortSetting = new Setting(effortEl) - .setName('Effort'); + private renderChatOptions(itemsEl: HTMLElement): void { + this.renderCreativitySetting(itemsEl, 'chat'); + this.renderContextWindowSetting(itemsEl, 'chat'); + if (this.surface === 'chat') this.renderWebSearchSetting(itemsEl); + } - const valueDisplay = effortSetting.controlEl.createSpan({ cls: 'csr-effort-value' }); - valueDisplay.setText(EFFORT_LABELS[getThinking().effort]); + /** The fold whose listeners own a variant's tuning rows. */ + private tuningGroup(variant: ModelVariant): string { + return variant === 'agent' ? FOLD_IDS.subagents : FOLD_IDS.chatOptions; + } - effortSetting.addSlider(slider => { - slider - .setLimits(0, EFFORT_LEVELS.length - 1, 1) - .setValue(EFFORT_LEVELS.indexOf(getThinking().effort)) - .onChange((value: number) => { - if (isAgent && !this.settings.agentThinking) { - this.settings.agentThinking = { enabled: false, effort: 'medium' }; - } - getThinking().effort = EFFORT_LEVELS[value]; - valueDisplay.setText(EFFORT_LABELS[getThinking().effort]); - this.notifyChange(); + private temperatureFor(variant: ModelVariant): number { + if (variant === 'agent') return this.settings.agentTemperature ?? this.settings.temperature; + return this.settings.temperature; + } + + /** + * Creativity follows the model: a model that rejects temperature gets a + * disabled row saying so, with "—" for the value. The stored value is kept + * for the next model that accepts it. + */ + private renderCreativitySetting(itemsEl: HTMLElement, variant: ModelVariant): void { + if (!this.hasModel(variant)) { + this.renderNoModelTuningRow(itemsEl, COPY.creativity, variant); + return; + } + const events = this.events(this.tuningGroup(variant)); + const supportsTemperature = this.capabilitiesFor(variant).supportsSamplingParams; + const modelName = this.modelNameFor(variant); + const setting = new Setting(itemsEl).setName(COPY.creativity); + + if (!supportsTemperature) { + setting.setDesc(COPY.creativityFixedDesc(modelName ?? 'This model')); + setting.setDisabled(true); + setting.addSlider(slider => { + slider + .setLimits(0, 1, 0.1) + .setValue(this.temperatureFor(variant)) + .setDisabled(true); + bindSliderReadout(events, setting, slider, () => COPY.creativityFixedReadout, { + ariaValueText: () => COPY.fixedTemperature }); - this.registerLiveSliderInput(slider.sliderEl, () => { - const index = Number(slider.sliderEl.value); - const effort = EFFORT_LEVELS[index]; - if (effort) valueDisplay.setText(EFFORT_LABELS[effort]); + return slider; + }); + return; + } + + setting.setDesc(COPY.creativityDesc); + setting.addSlider(slider => { + slider + .setLimits(0, 1, 0.1) + .setValue(this.temperatureFor(variant)); + bindSliderReadout(events, setting, slider, formatTemperature); + slider.onChange((value: number) => { + if (variant === 'agent') this.settings.agentTemperature = value; + else this.settings.temperature = value; + this.notifyChange(); + }); + return slider; + }); + } + + /** + * Creativity or Context window before a model is picked: nothing to tune + * and no value to show, so a disabled slider reading "—". + */ + private renderNoModelTuningRow(itemsEl: HTMLElement, name: string, variant: ModelVariant): void { + const setting = new Setting(itemsEl) + .setName(name) + .setDesc(COPY.noModelTuningDesc); + setting.setDisabled(true); + setting.addSlider(slider => { + slider.setLimits(0, 1, 1).setValue(0).setDisabled(true); + bindSliderReadout(this.events(this.tuningGroup(variant)), setting, slider, () => COPY.noValueReadout, { + ariaValueText: () => COPY.noModelValueText }); return slider; }); } - private registerLiveSliderInput(element: HTMLInputElement, update: () => void): void { - if (!this.sliderEvents) { - this.sliderEvents = new Component(); - this.sliderEvents.load(); + private overridesFor(variant: ModelVariant): Record { + if (variant === 'agent') { + if (!this.settings.agentContextWindowOverrides) this.settings.agentContextWindowOverrides = {}; + return this.settings.agentContextWindowOverrides; } - this.sliderEvents.registerDomEvent(element, 'input', update); + if (!this.settings.contextWindowOverrides) this.settings.contextWindowOverrides = {}; + return this.settings.contextWindowOverrides; } - private renderContextWindowControl(content: HTMLElement): void { - const provider = this.settings.provider; - const model = this.settings.model; + /** Forget the pending limit for the variant's selected model, so it shows its maximum. */ + private clearContextWindowOverride(variant: ModelVariant): void { + const provider = variant === 'agent' ? this.settings.agentProvider : this.settings.provider; + const model = variant === 'agent' ? this.settings.agentModel : this.settings.model; if (!provider || !model) return; + delete this.overridesFor(variant)[getContextWindowOverrideKey(provider, model)]; + } - const staticWindow = this.staticModelsService.findModel(provider, model)?.contextWindow; - if (staticWindow && staticWindow > 0) { - this.addContextWindowSlider(content, provider, model, staticWindow); - return; - } - - void this.providerManager.getModelsForProvider(provider).then(models => { - if (content.isConnected === false || provider !== this.settings.provider || model !== this.settings.model) return; - const discoveredWindow = models.find(candidate => candidate.id === model)?.contextWindow; - if (discoveredWindow && discoveredWindow > 0) { - this.addContextWindowSlider(content, provider, model, discoveredWindow); - } - }).catch(() => { - // An unavailable provider has no reliable advertised limit to adjust. - }); + /** The budget a variant will use: the override when set, else the model maximum. */ + private getContextBudget(variant: ModelVariant): number | undefined { + const advertised = this.capabilitiesFor(variant).contextWindow; + if (!advertised) return undefined; + const { provider, model } = this.modelTarget(variant); + if (!provider || !model) return undefined; + const maximum = resolveContextWindowLimit(advertised); + return resolveContextWindowLimit(maximum, this.overridesFor(variant)[getContextWindowOverrideKey(provider, model)]); } - private addContextWindowSlider(content: HTMLElement, provider: string, model: string, advertised: number): void { + /** + * Context window snaps to preset sizes below the model's maximum plus the + * maximum itself. A model with one stop gets a disabled slider. A saved + * size between stops (older settings) is shown as saved until moved. + */ + private renderContextWindowSetting(itemsEl: HTMLElement, variant: ModelVariant): void { + if (!this.hasModel(variant)) { + this.renderNoModelTuningRow(itemsEl, COPY.contextWindow, variant); + return; + } + const advertised = this.capabilitiesFor(variant).contextWindow; + const { provider, model } = this.modelTarget(variant); + if (!advertised || !provider || !model) return; const maximum = resolveContextWindowLimit(advertised); if (maximum <= 0) return; + + const stops = contextWindowStops(maximum); const key = getContextWindowOverrideKey(provider, model); - const selected = resolveContextWindowLimit(maximum, this.settings.contextWindowOverrides?.[key]); - const setting = new Setting(content) - .setName('Context window') - .setDesc('Set the conversation budget used by the context meter and automatic compaction.'); - const valueDisplay = setting.controlEl.createSpan({ cls: 'csr-context-window-value' }); - valueDisplay.setText(`${selected.toLocaleString()} tokens`); + const selected = resolveContextWindowLimit(maximum, this.overridesFor(variant)[key]); + const onStop = stops.includes(selected); + const setting = new Setting(itemsEl) + .setName(COPY.contextWindow) + .setDesc(COPY.contextWindowDesc); setting.addSlider(slider => { slider - .setLimits(Math.min(1024, maximum), maximum, 1) - .setValue(selected) - .onChange(value => { - const limit = resolveContextWindowLimit(maximum, value); - if (!this.settings.contextWindowOverrides) this.settings.contextWindowOverrides = {}; - if (limit === maximum) delete this.settings.contextWindowOverrides[key]; - else this.settings.contextWindowOverrides[key] = limit; - valueDisplay.setText(`${limit.toLocaleString()} tokens`); - this.notifyChange(); - }); - this.registerLiveSliderInput(slider.sliderEl, () => { - const limit = resolveContextWindowLimit(maximum, Number(slider.sliderEl.value)); - valueDisplay.setText(`${limit.toLocaleString()} tokens`); + .setLimits(0, stops.length - 1, 1) + .setValue(contextWindowStopIndex(stops, selected)); + if (stops.length < 2) slider.setDisabled(true); + const stopAt = (index: number): number => stops[Math.max(0, Math.min(stops.length - 1, Math.round(index)))]; + const format = (index: number): string => formatContextWindow(stopAt(index)); + bindSliderReadout(this.events(this.tuningGroup(variant)), setting, slider, format, { + ariaValueText: index => COPY.contextWindowValueText(format(index)), + initialText: onStop ? undefined : formatContextWindow(selected), + cls: 'nexus-ctx-value' + }); + if (!onStop) { + slider.sliderEl.setAttribute('aria-valuetext', COPY.contextWindowValueText(formatContextWindow(selected))); + } + slider.onChange(index => { + const limit = stopAt(index); + const overrides = this.overridesFor(variant); + if (limit === maximum) delete overrides[key]; + else overrides[key] = limit; + this.notifyChange(); }); return slider; }); } - private renderWebSearchControl(content: HTMLElement): void { - if (this.config.showWebSearch === false) return; - if (!['anthropic', 'openai', 'google', 'openrouter'].includes(this.settings.provider)) return; - if (this.settings.provider === 'google' && !this.settings.model.startsWith('gemini-3')) { - new Setting(content) - .setName('Web search') - .setDesc('Available with Gemini 3 or newer when Nexus tools are enabled.'); + private supportsWebSearch(): boolean { + return ['anthropic', 'openai', 'google', 'openrouter'].includes(this.settings.provider); + } + + /** Google models before Gemini 3 get an info row instead of a toggle. */ + private isGeminiWithoutWebSearch(): boolean { + return this.settings.provider === 'google' && !this.settings.model.startsWith('gemini-3'); + } + + /** The Web search toggle is offered: only then does the summary report it. */ + private hasWebSearchToggle(): boolean { + return this.surface === 'chat' && this.supportsWebSearch() && !this.isGeminiWithoutWebSearch(); + } + + private renderWebSearchSetting(itemsEl: HTMLElement): void { + if (!this.supportsWebSearch()) return; + if (this.isGeminiWithoutWebSearch()) { + new Setting(itemsEl) + .setName(COPY.webSearch) + .setDesc(COPY.webSearchGeminiDesc); return; } - new Setting(content) - .setName('Web search') - .setDesc('Allow this chat model to search the web when needed.') + new Setting(itemsEl) + .setName(COPY.webSearch) + .setDesc(COPY.webSearchDesc) .addToggle(toggle => toggle .setValue(this.settings.webSearch ?? false) .onChange(value => { @@ -502,68 +732,109 @@ export class ChatSettingsRenderer { })); } - // ========== SUBAGENT MODEL SECTION ========== + private chatOptionsSummary(): string { + // No model, no capabilities: say what is missing rather than invent them. + if (!this.hasModel('chat')) return COPY.pickModelFirst; + return chatOptionsSummary({ + temperature: this.settings.temperature, + supportsTemperature: this.capabilitiesFor('chat').supportsSamplingParams, + contextWindow: this.getContextBudget('chat'), + webSearch: this.hasWebSearchToggle() ? this.settings.webSearch === true : undefined + }); + } + + // ========== SUBAGENTS ========== /** - * Render Subagent model section - always shown, excludes local providers. - * This model is used for executePrompt and other API-dependent operations. + * Subagents, saved prompts and automations. Always shown; local providers + * are excluded because these run without the chat open. */ - private renderAgentModelSection(parent: HTMLElement): void { - renderModelDropdownSection(parent, { - sectionTitle: 'Subagent model', - description: { - text: 'Cloud model for AI actions', - infoTooltip: 'Saved prompts and automations require a cloud API.', - }, - getProviders: () => this.getEnabledProviders().filter(id => !LOCAL_PROVIDERS.includes(id)), - getCurrentProvider: () => this.settings.agentProvider, - getCurrentModel: () => this.settings.agentModel, - onProviderChange: (provider) => { - this.settings.agentProvider = provider; - }, - onModelChange: (model, provider) => { - this.settings.agentModel = model; - if (provider !== undefined) { - this.settings.agentProvider = provider; - } - }, - noProvidersText: 'No cloud providers enabled', - modelOptionMap: this.agentModelOptionMap, - providerManager: this.providerManager, - getProviderDisplayName: (id) => this.config.llmProviderSettings.providers[id]?.driverKind === 'openai-compatible' - ? this.getProviderDisplayName(id) : undefined, - isCodexConnected: () => this.isCodexConnected(), - isClaudeCodeConnected: () => this.isClaudeCodeConnected(), - isGeminiCliConnected: () => this.isGeminiCliConnected(), - getDefaultModelForProvider: (id) => this.getDefaultModelForProvider(id), - notifyChange: () => this.notifyChange(), - reRender: () => this.render(), - onAfterRender: (content) => { - this.renderReasoningControls(content, 'agent'); - this.renderTextOnlyProviderWarning(content, 'agent'); - }, - }); - } + private renderSubagents(itemsEl: HTMLElement): void { + new Setting(itemsEl) + .setName(COPY.usedFor) + .setDesc(COPY.usedForDesc) + .setClass('nexus-note'); - private renderTextOnlyProviderWarning(content: HTMLElement, variant: 'chat' | 'agent'): void { - const provider = variant === 'agent' ? this.settings.agentProvider : this.settings.provider; - if (!isTextOnlyProvider(provider)) { - return; + if (!this.settings.agentThinking) { + this.settings.agentThinking = { enabled: false, effort: 'medium' }; } - const { title, message } = this.getTextOnlyProviderWarningCopy(provider, variant); + renderProviderSetting(itemsEl, this.modelDropdownConfig('agent')); + const modelSetting = renderModelSetting(itemsEl, this.modelDropdownConfig('agent')); + this.applyTextOnlyProviderWarning(modelSetting, 'agent'); + this.renderThinkingSetting(itemsEl, 'agent'); + this.renderCreativitySetting(itemsEl, 'agent'); + this.renderContextWindowSetting(itemsEl, 'agent'); + } - const warningEl = content.createDiv({ cls: 'csr-provider-warning' }); - warningEl.createDiv({ - cls: 'csr-provider-warning-title', - text: title - }); - warningEl.createDiv({ - cls: 'csr-provider-warning-text', - text: message + /** + * Whether an empty subagent provider means "run on the chat model". On + * Defaults it always does. In a chat it means "use the default subagent + * model", which is the chat model only when no default is saved. + */ + private subagentsCanInheritChat(): boolean { + if (this.surface === 'defaults') return true; + const saved = this.config.llmProviderSettings.agentModel; + return !(saved?.provider && saved.model); + } + + /** The subagent Model row while "Same as chat" is selected. */ + private inheritedSubagentModelText(): string | undefined { + if (!this.subagentsCanInheritChat()) return undefined; + const name = this.modelNameFor('agent'); + return name ? COPY.sameAsChatModel(name) : undefined; + } + + /** " · · · " for the model subagents will run on. */ + private subagentsSummary(): string { + const { provider, model } = this.modelTarget('agent'); + if (!provider || !model) return this.subagentsWithoutModelSummary(); + const capabilities = this.capabilitiesFor('agent'); + const thinking = this.thinkingFor('agent'); + return subagentsSummary({ + modelName: this.modelNameFor('agent'), + // The level that is really sent, when the model folds the stored one onto a neighbour. + thinking: capabilities.supportsThinking + ? { enabled: thinking.enabled, effort: normalizeThinkingEffortForModel(provider, model, thinking.effort) } + : null, + tuning: provider && model + ? tuningSummary({ + temperature: this.temperatureFor('agent'), + supportsTemperature: capabilities.supportsSamplingParams, + contextWindow: this.getContextBudget('agent') + }) + : [] }); } + /** + * No subagent model, and no usable cloud chat model to run on. Without any + * cloud provider nothing can run them; otherwise the runtime picks a cloud + * provider. Either way, never name a model nobody configured. + */ + private subagentsWithoutModelSummary(): string { + const hasCloudProvider = this.getEnabledProviders().some(id => !isLocalProvider(id)); + return hasCloudProvider ? COPY.notSet : COPY.noModel; + } + + private modelDisplayName(provider: string | undefined, model: string | undefined, loadedName?: string): string | undefined { + if (!provider || !model) return undefined; + return this.capabilities.get(provider, model).name ?? loadedName ?? model; + } + + /** + * A text-completion-only provider is explained in the Model row's + * description, title in bold, rather than in a separate warning box. + */ + private applyTextOnlyProviderWarning(setting: Setting, variant: ModelVariant): void { + const provider = variant === 'agent' ? this.settings.agentProvider : this.settings.provider; + if (!isTextOnlyProvider(provider)) return; + const { title, message } = this.getTextOnlyProviderWarningCopy(provider, variant); + setting.descEl.empty(); + setting.descEl.createEl('strong', { text: title }); + setting.descEl.createSpan({ text: ` ${message}` }); + } + /** * Calm, factual copy for a text-completion-only provider. Antigravity mentions * BOTH limitations (no tools/agents AND no streaming); Perplexity keeps its @@ -591,62 +862,19 @@ export class ChatSettingsRenderer { }; } - // ========== TEMPERATURE SECTION ========== - - private renderTemperatureSection(parent: HTMLElement): void { - const section = parent.createDiv('csr-section'); - section.createDiv('csr-section-header').setText('Temperature'); - const content = section.createDiv('csr-section-content'); - - // Create container for slider row with value display - const tempSetting = new Setting(content) - .setName('Creativity') - .setDesc('Lower is more focused, higher is more creative.'); - - // Add value display span - const valueDisplay = tempSetting.controlEl.createSpan({ cls: 'csr-temp-value' }); - valueDisplay.setText(this.settings.temperature.toFixed(1)); - - // Add Obsidian slider component - tempSetting.addSlider(slider => { - slider - .setLimits(0, 1, 0.1) - .setValue(this.settings.temperature) - .setDynamicTooltip() - .onChange((value: number) => { - this.settings.temperature = value; - valueDisplay.setText(value.toFixed(1)); - this.notifyChange(); - }); - return slider; - }); - } - - private updateEffortVisibility(variant: 'chat' | 'agent' = 'chat'): void { - const section = variant === 'agent' ? this.agentEffortSection : this.effortSection; - if (!section) return; + // ========== IMAGES ========== - const enabled = variant === 'agent' - ? this.settings.agentThinking?.enabled - : this.settings.thinking.enabled; - - if (enabled) { - section.removeClass('is-hidden'); - } else { - section.addClass('is-hidden'); - } + /** The image model's name: as listed once loaded, else from the catalog, else its id. */ + private imageModelName(): string | undefined { + const { imageProvider, imageModel } = this.settings; + return this.modelNames.image + ?? getImageModelDisplayName(imageProvider, imageModel) + ?? (imageModel || undefined); } - // ========== IMAGE SECTION ========== - - private renderImageSection(parent: HTMLElement): void { - const section = parent.createDiv('csr-section'); - section.createDiv('csr-section-header').setText('Image model'); - const content = section.createDiv('csr-section-content'); - - // Provider - new Setting(content) - .setName('Provider') + private renderImages(itemsEl: HTMLElement): void { + new Setting(itemsEl) + .setName(COPY.provider) .addDropdown(dropdown => { // Every image adapter speaks requestUrl, so the list is the same on // desktop and mobile. @@ -660,13 +888,6 @@ export class ChatSettingsRenderer { if (!providers.some(p => p.id === this.settings.imageProvider)) { this.settings.imageProvider = providers[0].id; this.settings.imageModel = ''; - // Async: pick the first model from the new provider - void this.imageService.getModelsForProvider(this.settings.imageProvider).then(models => { - if (models.length > 0) { - this.settings.imageModel = models[0].id; - this.notifyChange(); - } - }); } for (const provider of providers) { @@ -675,609 +896,81 @@ export class ChatSettingsRenderer { dropdown.setValue(this.settings.imageProvider); dropdown.onChange((value) => { - this.settings.imageProvider = value as 'google' | 'openrouter' | 'openai'; - void this.imageService.getModelsForProvider(value as 'google' | 'openrouter' | 'openai').then(models => { + const provider = value as 'google' | 'openrouter' | 'openai'; + this.settings.imageProvider = provider; + void this.imageService.getModelsForProvider(provider).then(models => { + if (provider !== this.settings.imageProvider) return; this.settings.imageModel = models[0]?.id || ''; + this.modelNames.image = models[0]?.name; this.notifyChange(); - this.render(); + this.redrawGroups([FOLD_IDS.images]); }); }); }); // Model (async — populate from adapter) - new Setting(content) - .setName('Model') + const provider = this.settings.imageProvider; + new Setting(itemsEl) + .setName(COPY.model) .addDropdown(async dropdown => { - const models = await this.imageService.getModelsForProvider(this.settings.imageProvider); + const models = await this.imageService.getModelsForProvider(provider); + if (provider !== this.settings.imageProvider) return; if (models.length === 0) { dropdown.addOption('', 'No models available'); } else { - sortModelsNewestFirst(models, m => ({ provider: this.settings.imageProvider, id: m.id })).forEach(m => { + sortModelsNewestFirst(models, m => ({ provider, id: m.id })).forEach(m => { dropdown.addOption(m.id, m.name); }); - const exists = models.some(m => m.id === this.settings.imageModel); - if (exists) { - dropdown.setValue(this.settings.imageModel); - } else if (models.length > 0) { + // A missing model falls back to the first; saved with the next real edit. + if (!models.some(m => m.id === this.settings.imageModel)) { this.settings.imageModel = models[0].id; - dropdown.setValue(this.settings.imageModel); } + dropdown.setValue(this.settings.imageModel); + this.modelNames.image = models.find(m => m.id === this.settings.imageModel)?.name; + this.updateSummaries(); } dropdown.onChange((value) => { this.settings.imageModel = value; + this.modelNames.image = models.find(m => m.id === value)?.name; this.notifyChange(); }); }); } - // ========== TRANSCRIPTION SECTION ========== - - private renderTranscriptionSection(parent: HTMLElement): void { - const section = parent.createDiv('csr-section'); - section.createDiv('csr-section-header').setText('Transcription model'); - const content = section.createDiv('csr-section-content'); - this.renderTranscriptionControls(content); - } - - private renderTranscriptionControls(parent: HTMLElement): void { - const content = parent.createDiv('csr-transcription-controls'); - content.createDiv({ - cls: 'setting-item-description', - text: 'Loading transcription models...' - }); - - void getIngestCapabilityOptions(this.providerManager).then(capabilities => { - content.empty(); - const normalizedSelection = normalizeIngestSelection( - capabilities.transcriptionProviders, - this.settings.transcriptionProvider, - this.settings.transcriptionModel - ); - - const changed = normalizedSelection.provider !== this.settings.transcriptionProvider - || normalizedSelection.model !== this.settings.transcriptionModel; - - this.settings.transcriptionProvider = normalizedSelection.provider; - this.settings.transcriptionModel = normalizedSelection.model; - - if (changed) { - this.notifyChange(); - } - - renderIngestModelDropdowns(content, { - labelPrefix: 'Transcription', - description: 'Model for audio transcription.', - providers: capabilities.transcriptionProviders, - getSelection: () => this.settings.transcriptionProvider && this.settings.transcriptionModel - ? { - provider: this.settings.transcriptionProvider, - model: this.settings.transcriptionModel - } - : undefined, - onChange: (provider, model) => { - this.settings.transcriptionProvider = provider; - this.settings.transcriptionModel = model; - this.notifyChange(); - }, - providerSettingName: 'Provider', - modelSettingName: 'Model' - }); - }).catch(() => { - content.empty(); - content.createDiv({ - cls: 'setting-item-description', - text: 'Transcription models are not available.' - }); - }); - } - - private renderVoiceSection(parent: HTMLElement): void { - const section = parent.createDiv('csr-section'); - section.createDiv('csr-section-header').setText('Voice'); - const content = section.createDiv('csr-section-content'); - content.createDiv({ - cls: 'csr-section-desc nexus-voice-section-desc', - text: 'Defaults for microphone input, read aloud, and live voice in this chat.' - }); - - let updateVoiceWarning = (): void => undefined; - - this.renderVoiceGroupLabel(content, 'Voice input'); - this.renderTranscriptionControls(content); - - this.renderVoiceGroupLabel(content, 'Read aloud'); - this.renderSpeechSettings(content, () => updateVoiceWarning()); - - this.renderVoiceGroupLabel(content, 'Live voice'); - this.renderRealtimeVoiceSettings(content, () => updateVoiceWarning()); - - const warningEl = content.createDiv({ cls: 'nexus-voice-warning is-hidden' }); - updateVoiceWarning = (): void => { - const messages = this.getVoiceWarningMessages(); - warningEl.empty(); - warningEl.toggleClass('is-hidden', messages.length === 0); - messages.forEach(message => { - warningEl.createDiv({ text: message }); - }); - }; - updateVoiceWarning(); - } - - private renderVoiceGroupLabel(parentEl: HTMLElement, label: string): void { - parentEl.createDiv({ cls: 'nexus-voice-group-label', text: label }); - } - - private renderSpeechSettings(parent: HTMLElement, onSelectionChange: () => void): void { - let modelDropdown: HTMLSelectElement | null = null; - let voiceDropdown: HTMLSelectElement | null = null; - let modelRequestId = 0; - let voiceRequestId = 0; - const voiceCatalogService = new VoiceCatalogService(); - const speechModelCatalogService = new SpeechModelCatalogService(); - - const getAvailability = (): SpeechProviderAvailability[] => - buildSpeechProviderAvailability(this.config.llmProviderSettings, this.getSpeechAppStates()); - - const getSelection = (): DefaultSpeechModelSettings => { - const availability = getAvailability(); - const configuredProvider = this.settings.speechProvider; - const provider = configuredProvider - || availability.find(item => item.enabled && item.configured && (item.models?.length ?? 0) > 0)?.provider; - const providerAvailability = availability.find(item => item.provider === provider); - const model = this.settings.speechModel && providerAvailability?.models?.some(candidate => candidate.id === this.settings.speechModel) - ? this.settings.speechModel - : providerAvailability?.models?.[0]?.id; - - return { - provider, - model, - voice: this.settings.speechVoice, - source: 'user' - }; - }; - - const updateVoiceOptions = async (): Promise => { - if (!voiceDropdown) return; - const requestId = ++voiceRequestId; - const selection = getSelection(); - - voiceDropdown.empty(); - if (selection.provider === 'elevenlabs') { - voiceDropdown.createEl('option', { value: '', text: 'Loading voices...' }); - voiceDropdown.disabled = true; - } - - const voices = await voiceCatalogService.getVoices(selection.provider, selection.model, { - llmSettings: this.config.llmProviderSettings, - appsSettings: this.getAppsSettings() - }).catch(() => []); - - if (requestId !== voiceRequestId || !voiceDropdown) { - return; - } - - voiceDropdown.empty(); - voiceDropdown.createEl('option', { value: '', text: 'Provider default' }); - voices.forEach(voice => { - voiceDropdown?.createEl('option', { value: voice.id, text: voice.name }); - }); - - if (selection.voice && !voices.some(voice => voice.id === selection.voice)) { - voiceDropdown.createEl('option', { - value: selection.voice, - text: `${selection.voice} (unavailable)` - }); - } - - voiceDropdown.disabled = !selection.provider || !selection.model; - voiceDropdown.value = selection.voice || ''; - }; - - const updateModelOptions = async (): Promise => { - if (!modelDropdown) return; - const requestId = ++modelRequestId; - const selection = getSelection(); - const availability = getAvailability(); - const providerAvailability = availability.find(item => item.provider === selection.provider); - const providerUsable = providerAvailability?.enabled === true && providerAvailability.configured === true; - - modelDropdown.empty(); - if (!selection.provider) { - modelDropdown.createEl('option', { - value: '', - text: 'Select a speech provider first' - }); - modelDropdown.disabled = true; - void updateVoiceOptions(); - return; - } - - if (selection.provider === 'openrouter') { - modelDropdown.createEl('option', { value: '', text: 'Loading OpenRouter speech models...' }); - modelDropdown.disabled = true; - } - - const models = await speechModelCatalogService.getModels( - selection.provider as SpeechProvider, - this.config.llmProviderSettings - ).catch(() => providerAvailability?.models ?? []); - - if (requestId !== modelRequestId || !modelDropdown) { - return; - } - - modelDropdown.empty(); - if (models.length === 0) { - modelDropdown.createEl('option', { - value: '', - text: 'No speech models available' - }); - modelDropdown.disabled = true; - void updateVoiceOptions(); - return; - } - - sortModelsNewestFirst(models, model => ({ provider: selection.provider || '', id: model.id })).forEach(model => { - modelDropdown?.createEl('option', { value: model.id, text: model.name }); - }); - - if (selection.model && !models.some(model => model.id === selection.model)) { - modelDropdown.createEl('option', { - value: selection.model, - text: `${selection.model} (unavailable)` - }); - } - - const selectedModelExists = models.some(model => model.id === selection.model); - const nextModel = selection.model && selectedModelExists - ? selection.model - : models[0]?.id || ''; - - this.settings.speechProvider = selection.provider; - this.settings.speechModel = nextModel || undefined; - modelDropdown.disabled = !providerUsable; - modelDropdown.value = nextModel; - this.notifyChange(); - void updateVoiceOptions(); - }; - - new Setting(parent) - .setName('Speech provider') - .setDesc('Normal text-to-speech models for reading chat content aloud.') - .addDropdown(dropdown => { - const availability = getAvailability(); - const selection = getSelection(); - const usableProviders = availability.filter(item => item.enabled && item.configured && (item.models?.length ?? 0) > 0); - - if (usableProviders.length === 0 && !selection.provider) { - dropdown.addOption('', 'No speech providers available'); - dropdown.setDisabled(true); - return; - } - - usableProviders.forEach(provider => { - dropdown.addOption(provider.provider, this.getProviderDisplayName(provider.provider)); - }); - - if (selection.provider && !usableProviders.some(provider => provider.provider === selection.provider)) { - dropdown.addOption( - selection.provider, - `${this.getProviderDisplayName(selection.provider)} (unavailable)` - ); - } - - dropdown.setValue(selection.provider || usableProviders[0]?.provider || ''); - dropdown.onChange((provider) => { - const providerAvailability = getAvailability().find(item => item.provider === provider); - const model = providerAvailability?.models?.[0]; - this.settings.speechProvider = provider || undefined; - this.settings.speechModel = model?.id; - this.settings.speechVoice = model?.defaultVoice; - this.notifyChange(); - void updateModelOptions().then(onSelectionChange); - }); - }); - - new Setting(parent) - .setName('Speech model') - .addDropdown(dropdown => { - modelDropdown = dropdown.selectEl; - void updateModelOptions(); - dropdown.onChange((modelId) => { - const selection = getSelection(); - const model = getSpeechModel(selection.provider, modelId); - this.settings.speechModel = modelId || undefined; - if (model) { - this.settings.speechVoice = this.settings.speechVoice || model.defaultVoice; - } - this.notifyChange(); - void updateVoiceOptions().then(onSelectionChange); - }); - }); - - new Setting(parent) - .setName('Speech voice') - .addDropdown(dropdown => { - voiceDropdown = dropdown.selectEl; - void updateVoiceOptions(); - dropdown.onChange((voice) => { - this.settings.speechVoice = voice || undefined; - this.notifyChange(); - onSelectionChange(); - }); - }); - } - - private renderRealtimeVoiceSettings(parent: HTMLElement, onSelectionChange: () => void): void { - let modelDropdown: HTMLSelectElement | null = null; - let voiceDropdown: HTMLSelectElement | null = null; - - const getAvailability = (): RealtimeVoiceProviderAvailability[] => - buildRealtimeVoiceProviderAvailability(this.config.llmProviderSettings, this.getRealtimeAppStates()); - - const getSelection = (): DefaultRealtimeVoiceModelSettings => { - const availability = getAvailability(); - const configuredProvider = this.settings.realtimeVoiceProvider; - const provider = configuredProvider - || availability.find(item => item.enabled && item.configured && (item.models?.length ?? 0) > 0)?.provider; - const providerAvailability = availability.find(item => item.provider === provider); - const model = this.settings.realtimeVoiceModel && providerAvailability?.models?.some(candidate => candidate.id === this.settings.realtimeVoiceModel) - ? this.settings.realtimeVoiceModel - : providerAvailability?.models?.[0]?.id; - - return { - provider, - model, - voice: this.settings.realtimeVoiceVoice, - source: 'user' - }; - }; - - const updateVoiceOptions = (): void => { - if (!voiceDropdown) return; - const selection = getSelection(); - const model = getRealtimeVoiceModel(selection.provider, selection.model); - const voices = model?.voices ?? []; - const usesSpeechDefault = model?.execution === 'transcription-pipeline'; - - voiceDropdown.empty(); - voiceDropdown.createEl('option', { - value: '', - text: usesSpeechDefault ? 'Uses speech default' : 'Provider default' - }); - voices.forEach(voice => { - voiceDropdown?.createEl('option', { value: voice.id, text: voice.name }); - }); - - if (selection.voice && !voices.some(voice => voice.id === selection.voice)) { - voiceDropdown.createEl('option', { - value: selection.voice, - text: `${selection.voice} (unavailable)` - }); - } - - voiceDropdown.disabled = usesSpeechDefault || !selection.provider || !selection.model; - voiceDropdown.value = usesSpeechDefault ? '' : selection.voice || ''; - }; - - const updateModelOptions = (): void => { - if (!modelDropdown) return; - const selection = getSelection(); - const availability = getAvailability(); - const providerAvailability = availability.find(item => item.provider === selection.provider); - const providerUsable = providerAvailability?.enabled === true && providerAvailability.configured === true; - const models = providerAvailability?.models ?? []; - - modelDropdown.empty(); - if (!selection.provider || models.length === 0) { - modelDropdown.createEl('option', { - value: '', - text: 'Select a live voice provider first' - }); - modelDropdown.disabled = true; - updateVoiceOptions(); - return; - } - - sortModelsNewestFirst(models, model => ({ provider: selection.provider || '', id: model.id })).forEach(model => { - modelDropdown?.createEl('option', { value: model.id, text: model.name }); - }); - - if (selection.model && !models.some(model => model.id === selection.model)) { - modelDropdown.createEl('option', { - value: selection.model, - text: `${selection.model} (unavailable)` - }); - } - - const selectedModelExists = models.some(model => model.id === selection.model); - const nextModel = selection.model && selectedModelExists - ? selection.model - : models[0]?.id || ''; - - this.settings.realtimeVoiceProvider = selection.provider; - this.settings.realtimeVoiceModel = nextModel || undefined; - modelDropdown.disabled = !providerUsable; - modelDropdown.value = nextModel; - this.notifyChange(); - updateVoiceOptions(); - }; - - new Setting(parent) - .setName('Live voice provider') - .setDesc('Native voice agents and realtime transcription pipelines appear here.') - .addDropdown(dropdown => { - const availability = getAvailability(); - const selection = getSelection(); - const usableProviders = availability.filter(item => item.enabled && item.configured && (item.models?.length ?? 0) > 0); - - if (usableProviders.length === 0 && !selection.provider) { - dropdown.addOption('', 'No live voice providers available'); - dropdown.setDisabled(true); - return; - } - - usableProviders.forEach(provider => { - dropdown.addOption(provider.provider, this.getProviderDisplayName(provider.provider)); - }); - - if (selection.provider && !usableProviders.some(provider => provider.provider === selection.provider)) { - dropdown.addOption( - selection.provider, - `${this.getProviderDisplayName(selection.provider)} (unavailable)` - ); - } - - dropdown.setValue(selection.provider || usableProviders[0]?.provider || ''); - dropdown.onChange((provider) => { - const providerAvailability = getAvailability().find(item => item.provider === provider); - const model = providerAvailability?.models?.[0]; - this.settings.realtimeVoiceProvider = provider || undefined; - this.settings.realtimeVoiceModel = model?.id; - this.settings.realtimeVoiceVoice = model?.defaultVoice; - this.notifyChange(); - updateModelOptions(); - onSelectionChange(); - }); - }); - - new Setting(parent) - .setName('Live voice model') - .addDropdown(dropdown => { - modelDropdown = dropdown.selectEl; - updateModelOptions(); - dropdown.onChange((modelId) => { - const selection = getSelection(); - const model = getRealtimeVoiceModel(selection.provider, modelId); - this.settings.realtimeVoiceModel = modelId || undefined; - if (model) { - this.settings.realtimeVoiceVoice = this.settings.realtimeVoiceVoice || model.defaultVoice; - } - this.notifyChange(); - updateVoiceOptions(); - onSelectionChange(); - }); - }); - - new Setting(parent) - .setName('Live voice output') - .addDropdown(dropdown => { - voiceDropdown = dropdown.selectEl; - updateVoiceOptions(); - dropdown.onChange((voice) => { - this.settings.realtimeVoiceVoice = voice || undefined; - this.notifyChange(); - onSelectionChange(); - }); - }); - } - - private getSpeechAppStates(): Partial> { - const elevenLabs = this.getAppsSettings()?.apps.elevenlabs; - return elevenLabs - ? { elevenlabs: { enabled: elevenLabs.enabled, configured: !!elevenLabs.credentials.apiKey?.trim() } } - : {}; - } - - private getRealtimeAppStates(): Partial> { - const elevenLabs = this.getAppsSettings()?.apps.elevenlabs; - return elevenLabs - ? { elevenlabs: { enabled: elevenLabs.enabled, configured: !!elevenLabs.credentials.apiKey?.trim() } } - : {}; - } - - private getVoiceWarningMessages(): string[] { - const messages: string[] = []; - const speechDefault = this.settings.speechProvider || this.settings.speechModel || this.settings.speechVoice - ? { - provider: this.settings.speechProvider, - model: this.settings.speechModel, - voice: this.settings.speechVoice, - source: 'user' as const - } - : undefined; - const realtimeDefault = this.settings.realtimeVoiceProvider || this.settings.realtimeVoiceModel || this.settings.realtimeVoiceVoice - ? { - provider: this.settings.realtimeVoiceProvider, - model: this.settings.realtimeVoiceModel, - voice: this.settings.realtimeVoiceVoice, - source: 'user' as const - } - : undefined; - const speechSelection = resolveDefaultSpeechSelection( - { - ...this.config.llmProviderSettings, - defaultSpeechModel: speechDefault - }, - buildSpeechProviderAvailability(this.config.llmProviderSettings, this.getSpeechAppStates()) - ); - const realtimeSelection = resolveDefaultRealtimeVoiceSelection( - { - ...this.config.llmProviderSettings, - defaultRealtimeVoiceModel: realtimeDefault - }, - buildRealtimeVoiceProviderAvailability(this.config.llmProviderSettings, this.getRealtimeAppStates()) - ); - - if (speechSelection.status === 'invalid' && speechSelection.reason) { - messages.push(speechSelection.reason); - } - - if (realtimeSelection.status === 'invalid' && realtimeSelection.reason) { - messages.push(realtimeSelection.reason); - } - - return messages; - } - - private getAppsSettings(): AppsSettings | undefined { - const plugin = getNexusPlugin(this.config.app) as PluginWithAppSettings | null; - return plugin?.settings?.settings?.apps; - } - - private getProviderDisplayName(providerId: string): string { - const config = this.config.llmProviderSettings.providers[providerId]; - if (config?.driverKind === 'openai-compatible') { - return config.openaiCompatible?.displayName || 'OpenAI-compatible'; - } - return ProviderUtils.getProviderDisplayName(providerId); - } - - // ========== CONTEXT SECTION ========== + // ========== CONTEXT ========== - private renderContextSection(parent: HTMLElement): void { - const section = parent.createDiv('csr-section'); - section.createDiv('csr-section-header').setText('Context'); - const content = section.createDiv('csr-section-content'); - - // Workspace - new Setting(content) - .setName('Workspace') + private renderContext(itemsEl: HTMLElement): void { + new Setting(itemsEl) + .setName(COPY.workspace) .addDropdown(dropdown => { - dropdown.addOption('', 'None'); - + dropdown.addOption('', COPY.none); this.config.options.workspaces.forEach(w => { dropdown.addOption(w.id, w.name); }); - dropdown.setValue(this.settings.workspaceId || ''); dropdown.onChange((value) => { this.settings.workspaceId = value || null; + if (this.surface === 'chat') { + // A workflow belongs to its workspace, so a new workspace starts with none. + this.settings.workflowId = null; + this.notifyChange(); + this.redrawGroups([FOLD_IDS.context]); + return; + } this.notifyChange(); - if (this.config.showWorkflowSelection) { this.settings.workflowId = null; this.render(); } - else void this.syncWorkspacePrompt(value); + this.syncWorkspacePrompt(value); }); }); - if (this.config.showWorkflowSelection) { - new Setting(content) - .setName('Workflow') - .setDesc('Preload instructions and tools for this chat. Steps run only when you ask.') + if (this.surface === 'chat') { + new Setting(itemsEl) + .setName(COPY.workflow) + .setDesc(COPY.workflowDesc) .addDropdown(dropdown => { - dropdown.addOption('', 'None'); + dropdown.addOption('', COPY.none); const workspace = this.config.options.workspaces.find(item => item.id === this.settings.workspaceId); for (const workflow of workspace?.context?.workflows ?? []) dropdown.addOption(workflow.id, workflow.name); dropdown.setValue(this.settings.workflowId || ''); @@ -1285,16 +978,13 @@ export class ChatSettingsRenderer { }); } - // Prompt - new Setting(content) - .setName('Prompt') + new Setting(itemsEl) + .setName(COPY.prompt) .addDropdown(dropdown => { - dropdown.addOption('', 'None'); - + dropdown.addOption('', COPY.none); this.config.options.prompts.forEach(p => { dropdown.addOption(p.id, p.name); }); - dropdown.setValue(this.settings.promptId || ''); dropdown.onChange((value) => { this.settings.promptId = value || null; @@ -1302,52 +992,53 @@ export class ChatSettingsRenderer { }); }); - // Context Notes header with Add button - const notesHeader = content.createDiv('csr-notes-header'); - notesHeader.createSpan().setText('Context notes'); - const addBtn = notesHeader.createEl('button', { cls: 'csr-add-btn' }); - addBtn.setText('Add'); - addBtn.onclick = () => this.openNotePicker(); + new Setting(itemsEl) + .setName(COPY.contextNotes) + .addButton(button => button + .setButtonText(COPY.addNote) + .onClick(() => { void this.openNotePicker(); })); - this.contextNotesListEl = content.createDiv('csr-notes-list'); - this.renderContextNotesList(); + this.settings.contextNotes.forEach((notePath, index) => { + const name = noteBasename(notePath); + const row = new Setting(itemsEl).setClass('nexus-note-row'); + row.nameEl.empty(); + setIcon(row.nameEl.createSpan({ cls: 'nexus-note-icon' }), 'file-text'); + row.nameEl.createSpan({ text: name }); + row.nameEl.setAttribute('title', notePath); + row.addExtraButton(button => button + .setIcon('x') + .setTooltip(COPY.removeNote(name)) + .onClick(() => { + this.settings.contextNotes.splice(index, 1); + this.notifyChange(); + this.redrawGroups([FOLD_IDS.context]); + })); + }); } private syncWorkspacePrompt(workspaceId: string | null): void { if (!workspaceId) return; const workspace = this.config.options.workspaces.find(w => w.id === workspaceId); - // dedicatedAgent field stored for backward compat, but contains prompt info - if (workspace?.context?.dedicatedAgent?.agentId) { - const promptId = workspace.context.dedicatedAgent.agentId; - const prompt = this.config.options.prompts.find(p => p.id === promptId || p.name === promptId); - if (prompt) { - this.settings.promptId = prompt.id; - this.notifyChange(); - this.render(); - } + // Same precedence as WorkspacePromptResolver: the top-level binding first, + // then the deprecated context.dedicatedAgent. Either holds a prompt name or ID. + const promptId = workspace?.dedicatedAgentId || workspace?.context?.dedicatedAgent?.agentId; + if (!promptId) return; + const prompt = this.config.options.prompts.find(p => p.id === promptId || p.name === promptId); + if (prompt) { + this.settings.promptId = prompt.id; + this.notifyChange(); + this.redrawGroups([FOLD_IDS.context]); } } - private renderContextNotesList(): void { - const contextNotesListEl = this.contextNotesListEl; - if (!contextNotesListEl) return; - contextNotesListEl.empty(); - - if (this.settings.contextNotes.length === 0) { - contextNotesListEl.createDiv({ cls: 'csr-notes-empty', text: 'No files added' }); - return; - } - - this.settings.contextNotes.forEach((notePath, index) => { - const item = contextNotesListEl.createDiv('csr-note-item'); - item.createSpan({ cls: 'csr-note-path', text: notePath }); - const removeBtn = item.createEl('button', { cls: 'csr-note-remove', text: '×' }); - removeBtn.onclick = () => { - this.settings.contextNotes.splice(index, 1); - this.notifyChange(); - this.renderContextNotesList(); - }; + private contextSummary(): string { + const workspace = this.config.options.workspaces.find(w => w.id === this.settings.workspaceId); + const prompt = this.config.options.prompts.find(p => p.id === this.settings.promptId || p.name === this.settings.promptId); + return contextSummary({ + workspaceName: workspace?.name ?? null, + promptName: prompt?.name ?? this.settings.promptId, + noteCount: this.settings.contextNotes.length }); } @@ -1360,12 +1051,25 @@ export class ChatSettingsRenderer { if (selectedPaths.length > 0) { this.settings.contextNotes.push(...selectedPaths); this.notifyChange(); - this.renderContextNotesList(); + this.redrawGroups([FOLD_IDS.context]); } } // ========== HELPERS ========== + private getAppsSettings(): AppsSettings | undefined { + const plugin = getNexusPlugin(this.config.app) as PluginWithAppSettings | null; + return plugin?.settings?.settings?.apps; + } + + private getProviderDisplayName(providerId: string): string { + const config = this.config.llmProviderSettings.providers[providerId]; + if (config?.driverKind === 'openai-compatible') { + return config.openaiCompatible?.displayName || 'OpenAI-compatible'; + } + return ProviderUtils.getProviderDisplayName(providerId); + } + private async getDefaultModelForProvider(providerId: string): Promise { try { const models = await this.providerManager.getModelsForProvider(providerId); @@ -1376,6 +1080,29 @@ export class ChatSettingsRenderer { } getSettings(): ChatSettings { - return { ...this.settings }; + return { ...this.settings, contextNotes: [...this.settings.contextNotes] }; + } +} + +/** Whether a provider, by its own id, is set up well enough to send a request. */ +function isProviderUsable(settings: LLMProviderSettings, id: string): boolean { + const config = settings.providers[id]; + if (!config?.enabled) return false; + + if (id === 'openai-codex' || id === 'github-copilot') { + return !!(config.oauth?.connected && config.apiKey); + } + if (id === 'anthropic-claude-code' || id === 'google-gemini-cli') { + return !!config.oauth?.connected; } + + if (!isProviderCompatible(config.driverKind ?? id)) return false; + if (config.driverKind === 'openai-compatible') return !!config.openaiCompatible?.baseUrl.trim(); + if (id === 'webllm') return true; + return !!config.apiKey; +} + +function noteBasename(path: string): string { + const file = path.split('/').pop() || path; + return file.replace(/\.md$/i, ''); } diff --git a/src/components/shared/ModelDropdownRenderer.ts b/src/components/shared/ModelDropdownRenderer.ts index 2949237a9..2122e5a82 100644 --- a/src/components/shared/ModelDropdownRenderer.ts +++ b/src/components/shared/ModelDropdownRenderer.ts @@ -1,9 +1,9 @@ /** * ModelDropdownRenderer - Shared provider + model dropdown rendering * - * Extracts the duplicated pattern from ChatSettingsRenderer's - * renderModelSection() and renderAgentModelSection() into a - * parameterized utility. + * Provider and Model rows shared by the chat model (top block) and the + * Subagents fold. Each function adds one Setting to a caller-owned + * `setting-items` list and returns it. */ import { Setting } from 'obsidian'; @@ -37,15 +37,6 @@ const PROVIDER_NAMES: Record = { export { PROVIDER_NAMES }; export interface ModelDropdownConfig { - /** Section header text (e.g., "Chat model", "Subagent model") */ - sectionTitle: string; - - /** Optional description text below the header */ - description?: { - text: string; - infoTooltip?: string; - }; - /** Get the list of enabled providers to show */ getProviders: () => string[]; @@ -64,6 +55,16 @@ export interface ModelDropdownConfig { /** Empty-state text for provider dropdown when none available */ noProvidersText: string; + /** + * Label of a first, empty-valued Provider option meaning "no model of its + * own" (the Subagents fold: "Same as chat"). Selected while no provider is + * set, so the dropdown never shows a provider that is not the setting. + */ + inheritOptionText?: string; + + /** Model row text while no provider is set; defaults to "Select a provider first". */ + getUnsetModelText?: () => string | undefined; + /** The model option map to populate (maps option key -> { provider, modelId }) */ modelOptionMap: Map; @@ -87,11 +88,14 @@ export interface ModelDropdownConfig { /** Notify that settings changed */ notifyChange: () => void; - /** Trigger a full re-render */ - reRender: () => void; + /** + * The user picked a provider or model. The caller redraws only the rows that + * depend on the selection; the dropdowns themselves are not rebuilt here. + */ + onSelectionChanged: (kind: 'provider' | 'model') => void; - /** Called after provider+model dropdowns are rendered, receives the content element */ - onAfterRender?: (contentEl: HTMLElement) => void; + /** Called with the model's display name once the model list has loaded. */ + onModelNameResolved?: (name: string | undefined) => void; } /** @@ -102,41 +106,25 @@ function buildModelOptionKey(provider: string, modelId: string): string { return `${provider}::${modelId}`; } +/** The provider shown in the dropdown: merged sign-in providers appear under their API provider. */ +export function toDisplayProvider(provider: string | undefined): string | undefined { + if (provider === 'openai-codex') return 'openai'; + if (provider === 'anthropic-claude-code') return 'anthropic'; + if (provider === 'google-gemini-cli') return 'google'; + return provider; +} + /** - * Renders a provider + model dropdown section. - * - * Creates the standard section structure: - * div.csr-section > div.csr-section-header + div.csr-section-content - * Setting(Provider) + Setting(Model) + * Adds the Provider row to a settings list (`setting-items`). No section shell, + * heading or description; the caller owns the surrounding group. */ -export function renderModelDropdownSection( - parent: HTMLElement, - config: ModelDropdownConfig -): void { - const section = parent.createDiv('csr-section'); - section.createDiv('csr-section-header').setText(config.sectionTitle); - - // Optional description - if (config.description) { - const desc = section.createDiv('csr-section-desc'); - const descText = desc.createSpan(); - descText.setText(config.description.text); - if (config.description.infoTooltip) { - const infoIcon = desc.createSpan({ cls: 'csr-info-icon' }); - infoIcon.setText(' \u24D8'); - infoIcon.setAttribute('aria-label', config.description.infoTooltip); - infoIcon.addClass('clickable-icon'); - } - } - - const content = section.createDiv('csr-section-content'); - - renderProviderDropdown(content, config); - renderModelDropdown(content, config); - - if (config.onAfterRender) { - config.onAfterRender(content); - } +export function renderProviderSetting(itemsEl: HTMLElement, config: ModelDropdownConfig): Setting { + return renderProviderDropdown(itemsEl, config); +} + +/** Adds the Model row (dropdown, loaded asynchronously) to a settings list. */ +export function renderModelSetting(itemsEl: HTMLElement, config: ModelDropdownConfig): Setting { + return renderModelDropdown(itemsEl, config); } /** @@ -145,18 +133,11 @@ export function renderModelDropdownSection( function renderProviderDropdown( content: HTMLElement, config: ModelDropdownConfig -): void { +): Setting { const providers = config.getProviders(); - const currentProvider = config.getCurrentProvider(); - const displayProvider = currentProvider === 'openai-codex' - ? 'openai' - : currentProvider === 'anthropic-claude-code' - ? 'anthropic' - : currentProvider === 'google-gemini-cli' - ? 'google' - : currentProvider; - - new Setting(content) + const displayProvider = toDisplayProvider(config.getCurrentProvider()); + + return new Setting(content) .setName('Provider') .addDropdown(dropdown => { if (providers.length === 0) { @@ -169,6 +150,10 @@ function renderProviderDropdown( ); } + if (config.inheritOptionText) { + dropdown.addOption('', config.inheritOptionText); + } + providers.forEach(id => { dropdown.addOption(id, config.getProviderDisplayName?.(id) || PROVIDER_NAMES[id] || id); }); @@ -185,7 +170,7 @@ function renderProviderDropdown( config.onModelChange(undefined, undefined); } config.notifyChange(); - config.reRender(); + config.onSelectionChanged('provider'); }); }); } @@ -196,21 +181,14 @@ function renderProviderDropdown( function renderModelDropdown( content: HTMLElement, config: ModelDropdownConfig -): void { - const currentProvider = config.getCurrentProvider(); - const modelProviderId = currentProvider === 'openai-codex' - ? 'openai' - : currentProvider === 'anthropic-claude-code' - ? 'anthropic' - : currentProvider === 'google-gemini-cli' - ? 'google' - : currentProvider; - - new Setting(content) +): Setting { + const modelProviderId = toDisplayProvider(config.getCurrentProvider()); + + return new Setting(content) .setName('Model') .addDropdown(async dropdown => { if (!modelProviderId) { - dropdown.addOption('', 'Select a provider first'); + dropdown.addOption('', config.getUnsetModelText?.() || 'Select a provider first'); return; } @@ -265,15 +243,19 @@ function renderModelDropdown( const exists = selectedOptionKey ? config.modelOptionMap.has(selectedOptionKey) : false; if (exists) { dropdown.setValue(selectedOptionKey); + config.onModelNameResolved?.(models.find(model => + buildModelOptionKey(model.provider, model.id) === selectedOptionKey)?.name); } else if (currentProvider && currentModel) { dropdown.addOption(selectedOptionKey, `${currentModel} (Unavailable)`); dropdown.setValue(selectedOptionKey); + config.onModelNameResolved?.(currentModel); } else if (originalFirstModel) { const firstOptionKey = buildModelOptionKey(originalFirstModel.provider, originalFirstModel.id); const firstEntry = config.modelOptionMap.get(firstOptionKey); config.onModelChange(originalFirstModel.id, firstEntry?.provider); config.notifyChange(); dropdown.setValue(firstOptionKey); + config.onModelNameResolved?.(originalFirstModel.name); } } @@ -283,8 +265,10 @@ function renderModelDropdown( entry?.modelId ?? value, entry?.provider ?? modelProviderId ); + config.onModelNameResolved?.(models.find(model => + buildModelOptionKey(model.provider, model.id) === value)?.name); config.notifyChange(); - config.reRender(); + config.onSelectionChanged('model'); }); } catch { dropdown.addOption('', 'Error loading models'); diff --git a/src/components/shared/chatSettings/VoiceSettings.ts b/src/components/shared/chatSettings/VoiceSettings.ts new file mode 100644 index 000000000..7d1994822 --- /dev/null +++ b/src/components/shared/chatSettings/VoiceSettings.ts @@ -0,0 +1,629 @@ +/** + * The Voice fold: transcription, read aloud and live voice. + * + * This is the only voice settings implementation. Defaults and Chat settings + * both render it through ChatSettingsRenderer; it edits only the renderer's + * settings object, and each surface decides how to persist that (Defaults + * through its save queue, Chat settings on Save). The Defaults-only "Saved + * audio folder" row is supplied by the host. + */ + +import { Setting } from 'obsidian'; +import type { LLMProviderManager } from '../../../services/llm/providers/ProviderManager'; +import { + buildSpeechProviderAvailability, + getSpeechModel, + resolveDefaultSpeechSelection, + type SpeechAppCapabilityStates, + type SpeechProvider, + type SpeechProviderAvailability, +} from '../../../services/llm/types/SpeechTypes'; +import { + buildRealtimeVoiceProviderAvailability, + getRealtimeVoiceModel, + resolveDefaultRealtimeVoiceSelection, + type RealtimeAppCapabilityStates, + type RealtimeVoiceProviderAvailability, +} from '../../../services/llm/types/RealtimeVoiceTypes'; +import type { LLMProviderSettings } from '../../../types/llm/ProviderTypes'; +import type { AppsSettings } from '../../../types/apps/AppTypes'; +import { + getIngestCapabilityOptions, + normalizeIngestSelection, + type IngestProviderOption, +} from '../../../agents/ingestManager/tools/services/IngestCapabilityService'; +import { renderIngestModelDropdowns } from '../IngestModelDropdownRenderer'; +import { getTranscriptionModel } from '../../../services/llm/types/VoiceTypes'; +import { VoiceCatalogService } from '../../../services/readAloud/VoiceCatalogService'; +import { SpeechModelCatalogService } from '../../../services/readAloud/SpeechModelCatalogService'; +import { sortModelsNewestFirst } from '../../../utils/modelOrdering'; +import { COPY, VOICE_COPY } from './copy'; +import { voiceSummary } from './sectionSummaries'; +import type { ChatSettings, SettingsSurface } from './types'; + +/** The Defaults-only saved-audio folder row. */ +export interface VoiceAudioFolderOption { + getValue(): string; + placeholder: string; + onChange(value: string): void; +} + +export interface VoiceSettingsHost { + readonly surface: SettingsSurface; + readonly settings: ChatSettings; + readonly llmProviderSettings: LLMProviderSettings; + readonly providerManager: LLMProviderManager | null; + notifyChange(): void; + setSummary(summary: string): void; + /** Re-render the Voice fold (used once transcription options load). */ + redraw(): void; + getProviderDisplayName(providerId: string): string; + getAppsSettings(): AppsSettings | undefined; + audioFolder?: VoiceAudioFolderOption; +} + +type CapabilityState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'ready'; providers: IngestProviderOption[] } + | { status: 'failed' }; + +/** A Setting used as a small uppercase subheading inside a fold. */ +export function addSubhead(itemsEl: HTMLElement, text: string): Setting { + return new Setting(itemsEl).setName(text).setClass('nexus-subhead'); +} + +export class VoiceSettings { + private transcription: CapabilityState = { status: 'idle' }; + private readonly voiceCatalog = new VoiceCatalogService(); + private readonly speechModelCatalog = new SpeechModelCatalogService(); + private renderToken = 0; + private names: { transcriptionModel?: string; speechModel?: string; speechVoice?: string } = {}; + private speechProviderSetting: Setting | null = null; + private realtimeProviderSetting: Setting | null = null; + private realtimeAvailable = false; + + constructor( + private readonly host: VoiceSettingsHost, + private readonly loadTranscriptionProviders: () => Promise = async () => + (await getIngestCapabilityOptions(host.providerManager)).transcriptionProviders + ) {} + + private get settings(): ChatSettings { + return this.host.settings; + } + + render(itemsEl: HTMLElement): void { + const token = ++this.renderToken; + this.speechProviderSetting = null; + this.realtimeProviderSetting = null; + + addSubhead(itemsEl, VOICE_COPY.transcription); + this.renderTranscription(itemsEl); + + addSubhead(itemsEl, VOICE_COPY.readAloud); + this.renderSpeech(itemsEl, token); + if (this.host.audioFolder) this.renderAudioFolder(itemsEl, this.host.audioFolder); + + addSubhead(itemsEl, VOICE_COPY.liveVoice); + this.renderRealtime(itemsEl); + + this.updateWarnings(); + this.updateSummary(); + } + + /** + * Model names as listed once loaded; before that, from the static + * catalogs, and the raw id only for a model neither knows. + */ + getSummary(): string { + const { transcriptionProvider, transcriptionModel, speechProvider, speechModel } = this.settings; + return voiceSummary({ + transcriptionModel: this.names.transcriptionModel + ?? (transcriptionProvider && transcriptionModel + ? getTranscriptionModel(transcriptionProvider, transcriptionModel)?.name + : undefined) + ?? transcriptionModel, + readAloudModel: this.names.speechModel + ?? getSpeechModel(speechProvider, speechModel)?.name + ?? speechModel, + voice: this.names.speechVoice + }); + } + + private updateSummary(): void { + this.host.setSummary(this.getSummary()); + } + + // ---------- Transcription ---------- + + private renderTranscription(itemsEl: HTMLElement): void { + const state = this.transcription; + if (state.status === 'idle' || state.status === 'loading') { + new Setting(itemsEl).setName(COPY.provider).setDesc(VOICE_COPY.transcriptionLoading); + if (state.status === 'idle') this.loadTranscription(); + return; + } + if (state.status === 'failed') { + new Setting(itemsEl).setName(COPY.provider).setDesc(VOICE_COPY.transcriptionUnavailable); + return; + } + + const providers = state.providers; + const normalized = normalizeIngestSelection( + providers, + this.settings.transcriptionProvider, + this.settings.transcriptionModel + ); + // Automatic corrections update the edited settings without counting as a + // change: they are saved with the next real edit (or on Save in a chat), + // so merely opening settings never writes data.json. + this.settings.transcriptionProvider = normalized.provider; + this.settings.transcriptionModel = normalized.model; + this.names.transcriptionModel = this.transcriptionModelName(providers); + + renderIngestModelDropdowns(itemsEl, { + labelPrefix: VOICE_COPY.transcription, + description: VOICE_COPY.transcriptionProviderDesc, + providers, + getSelection: () => this.settings.transcriptionProvider && this.settings.transcriptionModel + ? { provider: this.settings.transcriptionProvider, model: this.settings.transcriptionModel } + : undefined, + onChange: (provider, model) => { + this.settings.transcriptionProvider = provider; + this.settings.transcriptionModel = model; + this.names.transcriptionModel = this.transcriptionModelName(providers); + this.host.notifyChange(); + this.updateSummary(); + }, + providerSettingName: COPY.provider, + modelSettingName: COPY.model + }); + } + + private transcriptionModelName(providers: IngestProviderOption[]): string | undefined { + const provider = providers.find(item => item.id === this.settings.transcriptionProvider); + const model = provider?.models.find(item => item.id === this.settings.transcriptionModel); + return model?.name ?? this.settings.transcriptionModel; + } + + private loadTranscription(): void { + this.transcription = { status: 'loading' }; + void this.loadTranscriptionProviders().then(providers => { + this.transcription = { status: 'ready', providers }; + }).catch(() => { + this.transcription = { status: 'failed' }; + }).then(() => { + this.host.redraw(); + }); + } + + // ---------- Read aloud ---------- + + private getSpeechAppStates(): SpeechAppCapabilityStates { + const elevenLabs = this.host.getAppsSettings()?.apps.elevenlabs; + return elevenLabs + ? { elevenlabs: { enabled: elevenLabs.enabled, configured: !!elevenLabs.credentials.apiKey?.trim() } } + : {}; + } + + private getRealtimeAppStates(): RealtimeAppCapabilityStates { + const elevenLabs = this.host.getAppsSettings()?.apps.elevenlabs; + return elevenLabs + ? { elevenlabs: { enabled: elevenLabs.enabled, configured: !!elevenLabs.credentials.apiKey?.trim() } } + : {}; + } + + private speechAvailability(): SpeechProviderAvailability[] { + return buildSpeechProviderAvailability(this.host.llmProviderSettings, this.getSpeechAppStates()); + } + + /** The settings' pick, or the same automatic pick read aloud makes when none is set. */ + private speechSelection(): { provider?: string; model?: string; voice?: string } { + if (this.settings.speechProvider) { + const availability = this.speechAvailability().find(item => item.provider === this.settings.speechProvider); + return { + provider: this.settings.speechProvider, + model: this.settings.speechModel || availability?.models?.[0]?.id, + voice: this.settings.speechVoice + }; + } + const auto = resolveDefaultSpeechSelection( + { ...this.host.llmProviderSettings, defaultSpeechModel: undefined }, + this.speechAvailability() + ); + return { provider: auto.provider, model: auto.model, voice: this.settings.speechVoice ?? auto.voice }; + } + + private renderSpeech(itemsEl: HTMLElement, token: number): void { + let modelDropdown: HTMLSelectElement | null = null; + let voiceDropdown: HTMLSelectElement | null = null; + let modelRequestId = 0; + let voiceRequestId = 0; + const stale = (): boolean => token !== this.renderToken; + + const markUser = (): void => { + this.settings.speechSource = 'user'; + }; + + const updateVoiceOptions = async (): Promise => { + if (!voiceDropdown) return; + const requestId = ++voiceRequestId; + const selection = this.speechSelection(); + + voiceDropdown.empty(); + if (selection.provider === 'elevenlabs') { + voiceDropdown.createEl('option', { value: '', text: 'Loading voices...' }); + voiceDropdown.disabled = true; + } + + const voices = await this.voiceCatalog.getVoices(selection.provider, selection.model, { + llmSettings: this.host.llmProviderSettings, + appsSettings: this.host.getAppsSettings() + }).catch(() => []); + + if (requestId !== voiceRequestId || !voiceDropdown || stale()) return; + + voiceDropdown.empty(); + voiceDropdown.createEl('option', { value: '', text: 'Provider default' }); + voices.forEach(voice => { + voiceDropdown?.createEl('option', { value: voice.id, text: voice.name }); + }); + if (selection.voice && !voices.some(voice => voice.id === selection.voice)) { + voiceDropdown.createEl('option', { value: selection.voice, text: `${selection.voice} (unavailable)` }); + } + voiceDropdown.disabled = !selection.provider || !selection.model; + voiceDropdown.value = selection.voice || ''; + this.names.speechVoice = selection.voice + ? voices.find(voice => voice.id === selection.voice)?.name ?? selection.voice + : undefined; + this.updateSummary(); + }; + + const updateModelOptions = async (): Promise => { + if (!modelDropdown) return; + const requestId = ++modelRequestId; + const selection = this.speechSelection(); + const providerAvailability = this.speechAvailability().find(item => item.provider === selection.provider); + const providerUsable = providerAvailability?.enabled === true && providerAvailability.configured === true; + + modelDropdown.empty(); + if (!selection.provider) { + modelDropdown.createEl('option', { value: '', text: 'Select a speech provider first' }); + modelDropdown.disabled = true; + void updateVoiceOptions(); + return; + } + if (selection.provider === 'openrouter') { + modelDropdown.createEl('option', { value: '', text: 'Loading OpenRouter speech models...' }); + modelDropdown.disabled = true; + } + + const models = await this.speechModelCatalog.getModels( + selection.provider as SpeechProvider, + this.host.llmProviderSettings + ).catch(() => providerAvailability?.models ?? []); + + if (requestId !== modelRequestId || !modelDropdown || stale()) return; + + modelDropdown.empty(); + if (models.length === 0) { + modelDropdown.createEl('option', { value: '', text: 'No speech models available' }); + modelDropdown.disabled = true; + void updateVoiceOptions(); + return; + } + + sortModelsNewestFirst(models, model => ({ provider: selection.provider || '', id: model.id })).forEach(model => { + modelDropdown?.createEl('option', { value: model.id, text: model.name }); + }); + if (selection.model && !models.some(model => model.id === selection.model)) { + modelDropdown.createEl('option', { value: selection.model, text: `${selection.model} (unavailable)` }); + } + + const nextModel = selection.model && models.some(model => model.id === selection.model) + ? selection.model + : models[0]?.id || ''; + modelDropdown.disabled = !providerUsable; + modelDropdown.value = nextModel; + this.names.speechModel = models.find(model => model.id === nextModel)?.name ?? (nextModel || undefined); + + this.settings.speechProvider = selection.provider; + this.settings.speechModel = nextModel || undefined; + this.updateWarnings(); + this.updateSummary(); + void updateVoiceOptions(); + }; + + this.speechProviderSetting = new Setting(itemsEl) + .setName(COPY.provider) + .addDropdown(dropdown => { + const selection = this.speechSelection(); + const usable = this.speechAvailability().filter(item => item.enabled && item.configured && (item.models?.length ?? 0) > 0); + + if (usable.length === 0 && !selection.provider) { + dropdown.addOption('', VOICE_COPY.noSpeech); + dropdown.setDisabled(true); + return; + } + usable.forEach(provider => { + dropdown.addOption(provider.provider, this.host.getProviderDisplayName(provider.provider)); + }); + if (selection.provider && !usable.some(provider => provider.provider === selection.provider)) { + dropdown.addOption(selection.provider, `${this.host.getProviderDisplayName(selection.provider)} (unavailable)`); + } + dropdown.setValue(selection.provider || usable[0]?.provider || ''); + dropdown.onChange(provider => { + const model = this.speechAvailability().find(item => item.provider === provider)?.models?.[0]; + this.settings.speechProvider = provider || undefined; + this.settings.speechModel = model?.id; + this.settings.speechVoice = model?.defaultVoice; + markUser(); + this.host.notifyChange(); + this.updateWarnings(); + void updateModelOptions(); + }); + }); + + new Setting(itemsEl) + .setName(COPY.model) + .addDropdown(dropdown => { + modelDropdown = dropdown.selectEl; + void updateModelOptions(); + dropdown.onChange(modelId => { + const selection = this.speechSelection(); + const model = getSpeechModel(selection.provider, modelId); + this.settings.speechProvider = selection.provider; + this.settings.speechModel = modelId || undefined; + if (model) this.settings.speechVoice = this.settings.speechVoice || model.defaultVoice; + this.names.speechModel = modelDropdown?.selectedOptions?.[0]?.text ?? model?.name ?? modelId; + markUser(); + this.host.notifyChange(); + this.updateWarnings(); + this.updateSummary(); + void updateVoiceOptions(); + }); + }); + + new Setting(itemsEl) + .setName(VOICE_COPY.voice) + .addDropdown(dropdown => { + voiceDropdown = dropdown.selectEl; + void updateVoiceOptions(); + dropdown.onChange(voice => { + const selection = this.speechSelection(); + this.settings.speechProvider = selection.provider; + this.settings.speechModel = selection.model; + this.settings.speechVoice = voice || undefined; + this.names.speechVoice = voice ? voiceDropdown?.selectedOptions?.[0]?.text ?? voice : undefined; + markUser(); + this.host.notifyChange(); + this.updateWarnings(); + this.updateSummary(); + }); + }); + } + + private renderAudioFolder(itemsEl: HTMLElement, option: VoiceAudioFolderOption): void { + new Setting(itemsEl) + .setName(VOICE_COPY.savedAudioFolder) + .setDesc(VOICE_COPY.savedAudioFolderDesc) + .addText(text => { + text + .setPlaceholder(option.placeholder) + .setValue(option.getValue()) + .onChange(value => option.onChange(value)); + }); + } + + // ---------- Live voice ---------- + + private realtimeAvailability(): RealtimeVoiceProviderAvailability[] { + return buildRealtimeVoiceProviderAvailability(this.host.llmProviderSettings, this.getRealtimeAppStates()); + } + + private realtimeSelection(): { provider?: string; model?: string; voice?: string } { + const availability = this.realtimeAvailability(); + const provider = this.settings.realtimeVoiceProvider + || availability.find(item => item.enabled && item.configured && (item.models?.length ?? 0) > 0)?.provider; + const providerAvailability = availability.find(item => item.provider === provider); + const model = this.settings.realtimeVoiceModel && providerAvailability?.models?.some(candidate => candidate.id === this.settings.realtimeVoiceModel) + ? this.settings.realtimeVoiceModel + : providerAvailability?.models?.[0]?.id; + return { provider, model, voice: this.settings.realtimeVoiceVoice }; + } + + private renderRealtime(itemsEl: HTMLElement): void { + const usable = this.realtimeAvailability().filter(item => item.enabled && item.configured && (item.models?.length ?? 0) > 0); + const initial = this.realtimeSelection(); + this.realtimeAvailable = usable.length > 0 || !!initial.provider; + + if (!this.realtimeAvailable) { + new Setting(itemsEl) + .setName(COPY.provider) + .setDesc(VOICE_COPY.noLiveVoice) + .setDisabled(true) + .addDropdown(dropdown => { + dropdown.addOption('', VOICE_COPY.noLiveVoiceOption); + dropdown.setDisabled(true); + }); + new Setting(itemsEl) + .setName(COPY.model) + .setDisabled(true) + .addDropdown(dropdown => { + dropdown.addOption('', '—'); + dropdown.setDisabled(true); + }); + new Setting(itemsEl) + .setName(VOICE_COPY.output) + .setDisabled(true) + .addDropdown(dropdown => { + dropdown.addOption('', 'Provider default'); + dropdown.setDisabled(true); + }); + return; + } + + let modelDropdown: HTMLSelectElement | null = null; + let voiceDropdown: HTMLSelectElement | null = null; + const markUser = (): void => { + this.settings.realtimeVoiceSource = 'user'; + }; + + const updateVoiceOptions = (): void => { + if (!voiceDropdown) return; + const selection = this.realtimeSelection(); + const model = getRealtimeVoiceModel(selection.provider, selection.model); + const voices = model?.voices ?? []; + const usesSpeechDefault = model?.execution === 'transcription-pipeline'; + + voiceDropdown.empty(); + voiceDropdown.createEl('option', { value: '', text: usesSpeechDefault ? 'Uses speech default' : 'Provider default' }); + voices.forEach(voice => { + voiceDropdown?.createEl('option', { value: voice.id, text: voice.name }); + }); + if (selection.voice && !voices.some(voice => voice.id === selection.voice)) { + voiceDropdown.createEl('option', { value: selection.voice, text: `${selection.voice} (unavailable)` }); + } + voiceDropdown.disabled = usesSpeechDefault || !selection.provider || !selection.model; + voiceDropdown.value = usesSpeechDefault ? '' : selection.voice || ''; + }; + + const updateModelOptions = (): void => { + if (!modelDropdown) return; + const selection = this.realtimeSelection(); + const providerAvailability = this.realtimeAvailability().find(item => item.provider === selection.provider); + const providerUsable = providerAvailability?.enabled === true && providerAvailability.configured === true; + const models = providerAvailability?.models ?? []; + + modelDropdown.empty(); + if (!selection.provider || models.length === 0) { + modelDropdown.createEl('option', { value: '', text: 'Select a live voice provider first' }); + modelDropdown.disabled = true; + updateVoiceOptions(); + return; + } + + sortModelsNewestFirst(models, model => ({ provider: selection.provider || '', id: model.id })).forEach(model => { + modelDropdown?.createEl('option', { value: model.id, text: model.name }); + }); + if (selection.model && !models.some(model => model.id === selection.model)) { + modelDropdown.createEl('option', { value: selection.model, text: `${selection.model} (unavailable)` }); + } + + const nextModel = selection.model && models.some(model => model.id === selection.model) + ? selection.model + : models[0]?.id || ''; + modelDropdown.disabled = !providerUsable; + modelDropdown.value = nextModel; + this.settings.realtimeVoiceProvider = selection.provider; + this.settings.realtimeVoiceModel = nextModel || undefined; + updateVoiceOptions(); + }; + + this.realtimeProviderSetting = new Setting(itemsEl) + .setName(COPY.provider) + .addDropdown(dropdown => { + usable.forEach(provider => { + dropdown.addOption(provider.provider, this.host.getProviderDisplayName(provider.provider)); + }); + if (initial.provider && !usable.some(provider => provider.provider === initial.provider)) { + dropdown.addOption(initial.provider, `${this.host.getProviderDisplayName(initial.provider)} (unavailable)`); + } + dropdown.setValue(initial.provider || usable[0]?.provider || ''); + dropdown.onChange(provider => { + const model = this.realtimeAvailability().find(item => item.provider === provider)?.models?.[0]; + this.settings.realtimeVoiceProvider = provider || undefined; + this.settings.realtimeVoiceModel = model?.id; + this.settings.realtimeVoiceVoice = model?.defaultVoice; + markUser(); + this.host.notifyChange(); + updateModelOptions(); + this.updateWarnings(); + }); + }); + + new Setting(itemsEl) + .setName(COPY.model) + .addDropdown(dropdown => { + modelDropdown = dropdown.selectEl; + updateModelOptions(); + dropdown.onChange(modelId => { + const selection = this.realtimeSelection(); + const model = getRealtimeVoiceModel(selection.provider, modelId); + this.settings.realtimeVoiceProvider = selection.provider; + this.settings.realtimeVoiceModel = modelId || undefined; + if (model) this.settings.realtimeVoiceVoice = this.settings.realtimeVoiceVoice || model.defaultVoice; + markUser(); + this.host.notifyChange(); + updateVoiceOptions(); + this.updateWarnings(); + }); + }); + + new Setting(itemsEl) + .setName(VOICE_COPY.output) + .addDropdown(dropdown => { + voiceDropdown = dropdown.selectEl; + updateVoiceOptions(); + dropdown.onChange(voice => { + const selection = this.realtimeSelection(); + this.settings.realtimeVoiceProvider = selection.provider; + this.settings.realtimeVoiceModel = selection.model; + this.settings.realtimeVoiceVoice = voice || undefined; + markUser(); + this.host.notifyChange(); + this.updateWarnings(); + }); + }); + } + + // ---------- Warnings ---------- + + /** A saved pick that no longer resolves is explained on its Provider row. */ + private updateWarnings(): void { + const { speech, realtime } = this.getWarnings(); + this.speechProviderSetting?.setDesc(speech ?? ''); + if (this.realtimeAvailable) this.realtimeProviderSetting?.setDesc(realtime ?? ''); + } + + getWarnings(): { speech?: string; realtime?: string } { + const llm = this.host.llmProviderSettings; + const speechSet = this.settings.speechProvider || this.settings.speechModel || this.settings.speechVoice; + const realtimeSet = this.settings.realtimeVoiceProvider || this.settings.realtimeVoiceModel || this.settings.realtimeVoiceVoice; + + const speech = speechSet && this.settings.speechSource !== 'auto' + ? resolveDefaultSpeechSelection( + { + ...llm, + defaultSpeechModel: { + provider: this.settings.speechProvider, + model: this.settings.speechModel, + voice: this.settings.speechVoice, + source: 'user' + } + }, + this.speechAvailability() + ) + : undefined; + const realtime = realtimeSet && this.settings.realtimeVoiceSource !== 'auto' + ? resolveDefaultRealtimeVoiceSelection( + { + ...llm, + defaultRealtimeVoiceModel: { + provider: this.settings.realtimeVoiceProvider, + model: this.settings.realtimeVoiceModel, + voice: this.settings.realtimeVoiceVoice, + source: 'user' + } + }, + this.realtimeAvailability() + ) + : undefined; + + return { + speech: speech?.status === 'invalid' ? speech.reason : undefined, + realtime: realtime?.status === 'invalid' ? realtime.reason : undefined + }; + } +} diff --git a/src/components/shared/chatSettings/contextWindowStops.ts b/src/components/shared/chatSettings/contextWindowStops.ts new file mode 100644 index 000000000..fa00e77ba --- /dev/null +++ b/src/components/shared/chatSettings/contextWindowStops.ts @@ -0,0 +1,40 @@ +/** + * The Context window slider snaps to a fixed set of sizes instead of moving one + * token at a time. The stops are the presets strictly below the model's + * maximum, followed by the maximum itself, so every model offers its full + * window and small local models (under 32K) get a single stop. + * + * Storage is unchanged: `contextWindowOverrides[provider::model]` holds the + * chosen size, and the maximum is stored as "no override". A saved size that is + * not a stop (older settings, e.g. 150,000) keeps its value until the user + * moves the slider; the thumb sits on the nearest stop below it. + */ + +export const CONTEXT_WINDOW_PRESETS: readonly number[] = [ + 32_000, + 64_000, + 128_000, + 200_000, + 350_000, + 750_000, + 1_000_000 +]; + +/** Slider stops for a model whose window is `maximum` tokens, smallest first. */ +export function contextWindowStops(maximum: number): number[] { + if (!Number.isFinite(maximum) || maximum <= 0) return []; + const max = Math.floor(maximum); + return [...CONTEXT_WINDOW_PRESETS.filter(size => size < max), max]; +} + +/** + * The slider index for a budget: the exact stop when there is one, otherwise + * the nearest stop below it (the first stop when the budget is below them all). + */ +export function contextWindowStopIndex(stops: readonly number[], budget: number): number { + let index = 0; + for (let i = 0; i < stops.length; i++) { + if (stops[i] <= budget) index = i; + } + return index; +} diff --git a/src/components/shared/chatSettings/copy.ts b/src/components/shared/chatSettings/copy.ts new file mode 100644 index 000000000..6a2f6d9a4 --- /dev/null +++ b/src/components/shared/chatSettings/copy.ts @@ -0,0 +1,192 @@ +/** + * User-facing copy for Defaults and Chat settings, held as constants so tests + * can assert against the same strings the UI renders. Source: the "Essentials + + * folds" design in docs/plans/defaults-settings-redesign-plan.md (Appendix A and + * the owner decisions). + */ + +export const FOLD_TITLES = { + chatOptions: 'Chat options', + context: 'Context', + subagents: 'Subagents', + images: 'Images', + video: 'Video', + voice: 'Voice', + fileConversion: 'File conversion', + semanticSearch: 'Semantic search' +} as const; + +export const FOLD_ICONS = { + chatOptions: 'sliders-horizontal', + context: 'layers', + subagents: 'bot', + images: 'image', + video: 'film', + voice: 'mic', + fileConversion: 'file-input', + semanticSearch: 'scan-search' +} as const; + +/** Stable fold ids; also the keys remembered by FoldStateStore. */ +export const FOLD_IDS = { + chatOptions: 'chat-options', + context: 'context', + subagents: 'subagents', + images: 'images', + video: 'video', + voice: 'voice', + fileConversion: 'file-conversion', + semanticSearch: 'semantic' +} as const; + +export const COPY = { + provider: 'Provider', + model: 'Model', + thinking: 'Thinking', + creativity: 'Creativity', + creativityDesc: 'Lower is more focused, higher is more creative.', + /** Creativity row description for a model that rejects temperature. */ + creativityFixedDesc: (modelName: string): string => `${modelName} sets its own temperature.`, + /** Creativity readout while the model sets its own temperature. */ + creativityFixedReadout: '—', + fixedTemperature: 'Fixed temperature', + contextWindow: 'Context window', + contextWindowDesc: 'Older messages are summarized past this size.', + /** Screen-reader text for a Context window readout such as "200K". */ + contextWindowValueText: (readout: string): string => `${readout} tokens`, + webSearch: 'Web search', + webSearchDesc: 'Let the model search the web when needed.', + webSearchGeminiDesc: 'Available with Gemini 3 or newer when Nexus tools are enabled.', + workspace: 'Workspace', + workflow: 'Workflow', + workflowDesc: 'Loads its instructions and tools. Steps run only when you ask.', + prompt: 'Prompt', + contextNotes: 'Context notes', + addNote: 'Add note', + removeNote: (name: string): string => `Remove ${name}`, + usedFor: 'Used for', + usedForDesc: 'Subagents, saved prompts and automations. Must be a cloud model.', + /** Chat options summary while no chat model is picked. */ + pickModelFirst: 'Pick a model first', + /** Creativity and Context window rows while no model is picked. */ + noModelTuningDesc: 'Pick a model to adjust this.', + noValueReadout: '—', + noModelValueText: 'No model selected', + /** Subagent Provider option: no model of their own, run on the chat model. */ + sameAsChat: 'Same as chat', + sameAsChatModel: (modelName: string): string => `Same as chat (${modelName})`, + /** Subagents summary: no cloud provider, so nothing they could run on. */ + noModel: 'No model', + /** Subagents summary: no subagent model and no cloud chat model to describe. */ + notSet: 'Not set', + noProviders: 'No providers enabled', + noCloudProviders: 'No cloud providers enabled', + none: 'None' +} as const; + +export const VOICE_COPY = { + transcription: 'Transcription', + readAloud: 'Read aloud', + liveVoice: 'Live voice', + transcriptionProviderDesc: 'Chat microphone and audio files.', + voice: 'Voice', + output: 'Output', + savedAudioFolder: 'Saved audio folder', + savedAudioFolderDesc: 'Inside the Nexus data folder.', + noLiveVoice: 'None of your providers offer live voice.', + noLiveVoiceOption: 'None available', + noSpeech: 'No speech providers available', + transcriptionLoading: 'Loading transcription models...', + transcriptionUnavailable: 'Transcription models are not available.' +} as const; + +export const FILE_CONVERSION_COPY = { + fileImport: 'File import', + fileImportDesc: 'Drop PDFs or audio into chat to turn them into notes.', + readingMethod: 'Reading method', + readingMethodDesc: 'Text is free. Vision reads scanned pages with a model.', + textExtraction: 'Text extraction', + vision: 'Vision', + visionProvider: 'Vision provider', + visionModel: 'Vision model', + autoConvert: 'Convert new files automatically', + autoConvertDesc: 'Adds a Markdown note next to each PDF, document or audio file added to the vault.', + visionUnavailable: 'Vision models could not be loaded.' +} as const; + +/** "1 note", "4,512 notes". */ +function countOf(count: number, noun: string): string { + return `${count.toLocaleString()} ${noun}${count === 1 ? '' : 's'}`; +} + +export const SEMANTIC_COPY = { + toggle: 'Semantic search', + toggleDesc: 'Find notes and past chats by meaning, not just exact words. Runs on this device.', + index: 'Index', + indexOnDesc: 'Kept up to date as notes and chats change.', + indexOffDesc: 'Kept on this device, so turning search back on only re-indexes notes that changed.', + indexCounts: (notes: number, chats: number): string => `${countOf(notes, 'note')} · ${countOf(chats, 'chat')}`, + indexPaused: (notes: number): string => `Paused · ${countOf(notes, 'note')}`, + indexPausedUnknown: 'Paused', + noIndex: 'No index on this device', + starting: 'Starting', + indexing: 'Indexing', + /** Description of the Indexing row, from the queue's estimate in seconds. */ + indexingDesc: (secondsLeft: number | null): string => { + if (secondsLeft === null || !Number.isFinite(secondsLeft) || secondsLeft <= 0) return 'You can keep working.'; + if (secondsLeft < 60) return 'Less than a minute left. You can keep working.'; + const minutes = Math.round(secondsLeft / 60); + return `About ${minutes} minute${minutes === 1 ? '' : 's'} left. You can keep working.`; + }, + indexingPausedDesc: 'Paused. Resume indexing whenever you like.', + /** "1,904 / 4,512" for notes; chats and memory say what they count. */ + indexingProgress: (done: number, total: number, scope?: 'notes' | 'conversations' | 'traces'): string => { + const counts = `${done.toLocaleString()} / ${total.toLocaleString()}`; + if (scope === 'conversations') return `${counts} chats`; + if (scope === 'traces') return `${counts} memories`; + return counts; + }, + checking: 'Checking for changes', + checkingDesc: 'Comparing notes with the index. Only notes that changed are re-indexed.', + pauseIndexing: 'Pause indexing', + resumeIndexing: 'Resume indexing', + deleteIndex: 'Delete index', + deleteIndexDesc: 'Frees the space it uses. Turning search back on re-indexes the whole vault.', + deleteFailed: 'The search index could not be deleted.', + loadFailed: "Couldn't load the search model", + /** Description of the load-failure row when the queue gave no detail. */ + loadFailedDesc: 'Semantic search is on, but nothing can be indexed or searched until the model loads.', + tryAgain: 'Try again', + retryFailed: 'Semantic search could not start. Restart Obsidian to try again.', + applyFailed: 'The semantic search setting could not be applied. Restart Obsidian to apply it.' +} as const; + +/** The confirmation shown before semantic search is turned off. */ +export const SEMANTIC_OFF_MODAL_COPY = { + title: 'Turn off semantic search?', + lede: 'Nexus will match exact words only.', + bullets: [ + 'Vault search falls back to keyword matching, and agents stop being offered semantic search.', + 'Searching past chats by meaning stops working.', + 'Note suggestions in chat use file names only.' + ], + note: 'Your index stays on this device, so turning it back on is quick.', + cancel: 'Cancel', + confirm: 'Turn off' +} as const; + +/** The confirmation shown before the search index is deleted (owner decision 6). */ +export const SEMANTIC_DELETE_MODAL_COPY = { + title: 'Delete the search index?', + body: SEMANTIC_COPY.deleteIndexDesc, + cancel: 'Cancel', + confirm: 'Delete' +} as const; + +export const VIDEO_COPY = { + aspectRatio: 'Aspect ratio', + resolution: 'Resolution', + noProviders: 'No video providers available', + selectProvider: 'Select a video provider first', + selectModel: 'Select a video model first' +} as const; diff --git a/src/components/shared/chatSettings/modelCapabilities.ts b/src/components/shared/chatSettings/modelCapabilities.ts new file mode 100644 index 000000000..1e093b781 --- /dev/null +++ b/src/components/shared/chatSettings/modelCapabilities.ts @@ -0,0 +1,122 @@ +/** + * What the selected model can do, as far as the settings UI needs to know: + * whether it reasons (Thinking row), whether it accepts a temperature + * (Creativity row) and how large its context window is (Context window row). + * + * Registry models answer synchronously from StaticModelsService. Models the + * registry does not know (Ollama, LM Studio, OpenAI-compatible endpoints) + * report their window only once the provider has listed them, so the first + * call starts a lookup and `onDiscovered` fires when it lands. + * + * Merged sign-in providers (ChatGPT, Claude Code, Antigravity CLI) are looked + * up under their own id first and then under the API provider they are shown + * with, matching the merged model dropdown. + * + * Mobile-safe: no Node imports. + */ + +import type { ModelWithProvider } from '../../../services/StaticModelsService'; +import { getContextWindowOverrideKey } from '../../../ui/chat/utils/ContextWindowSettings'; + +export interface ModelCapabilities { + /** False when nothing is known; the Thinking row is shown only for models known to reason. */ + supportsThinking: boolean; + /** Only an explicit registry `false` turns Creativity off; unknown models accept it. */ + supportsSamplingParams: boolean; + /** Advertised window in tokens, when known. */ + contextWindow?: number; + /** Registry display name, when the registry knows the model. */ + name?: string; +} + +export interface StaticModelLookup { + findModel(provider: string, modelId: string): ModelWithProvider | undefined; +} + +export interface DiscoveredModelLookup { + getModelsForProvider(provider: string): Promise>; +} + +const NO_CAPABILITIES: ModelCapabilities = { supportsThinking: false, supportsSamplingParams: true }; + +const MERGED_PROVIDERS: Record = { + 'openai-codex': 'openai', + 'anthropic-claude-code': 'anthropic', + 'google-gemini-cli': 'google' +}; + +export function findStaticModel( + staticModels: StaticModelLookup, + provider: string | null | undefined, + model: string | null | undefined +): ModelWithProvider | undefined { + if (!provider || !model) return undefined; + const direct = staticModels.findModel(provider, model); + if (direct) return direct; + const merged = MERGED_PROVIDERS[provider]; + return merged ? staticModels.findModel(merged, model) : undefined; +} + +/** Registry-only answer; `undefined` when the registry does not know the model. */ +export function getStaticModelCapabilities( + staticModels: StaticModelLookup, + provider: string | null | undefined, + model: string | null | undefined +): ModelCapabilities | undefined { + const spec = findStaticModel(staticModels, provider, model); + if (!spec) return undefined; + return { + supportsThinking: spec.capabilities?.supportsThinking === true, + supportsSamplingParams: spec.supportsSamplingParams !== false, + contextWindow: spec.contextWindow > 0 ? spec.contextWindow : undefined, + name: spec.name + }; +} + +/** + * Capability lookups for one settings renderer, remembering windows that + * providers report at runtime so a redraw does not ask again. + */ +export class ModelCapabilityResolver { + private discoveredWindows = new Map(); + private pending = new Set(); + + constructor( + private readonly staticModels: StaticModelLookup, + private readonly getDiscoveredLookup: () => DiscoveredModelLookup + ) {} + + /** + * Capabilities for a provider/model. When the window is unknown, a lookup + * starts (once per model) and `onDiscovered` is called if it finds one. + */ + get( + provider: string | null | undefined, + model: string | null | undefined, + onDiscovered?: () => void + ): ModelCapabilities { + if (!provider || !model) return { ...NO_CAPABILITIES }; + const known = getStaticModelCapabilities(this.staticModels, provider, model) ?? { ...NO_CAPABILITIES }; + if (known.contextWindow) return known; + + const key = getContextWindowOverrideKey(provider, model); + const discovered = this.discoveredWindows.get(key); + if (discovered) return { ...known, contextWindow: discovered }; + if (onDiscovered) this.lookupContextWindow(provider, model, onDiscovered); + return known; + } + + private lookupContextWindow(provider: string, model: string, onDiscovered: () => void): void { + const key = getContextWindowOverrideKey(provider, model); + if (this.pending.has(key)) return; + this.pending.add(key); + void this.getDiscoveredLookup().getModelsForProvider(provider).then(models => { + const window = models.find(candidate => candidate.id === model)?.contextWindow; + if (!window || window <= 0) return; + this.discoveredWindows.set(key, window); + onDiscovered(); + }).catch(() => { + // An unavailable provider has no reliable advertised limit to adjust. + }); + } +} diff --git a/src/components/shared/chatSettings/sectionSummaries.ts b/src/components/shared/chatSettings/sectionSummaries.ts new file mode 100644 index 000000000..9dcb3bdb0 --- /dev/null +++ b/src/components/shared/chatSettings/sectionSummaries.ts @@ -0,0 +1,129 @@ +/** + * One-line summaries shown under each closed settings fold. Pure functions of + * the settings plus whatever names have loaded, so they are cheap to recompute + * after every change. + */ + +import type { ThinkingState } from './thinkingOptions'; +import { THINKING_LABELS } from './thinkingOptions'; +import { COPY } from './copy'; + +const SEPARATOR = ' · '; + +function join(parts: Array): string { + return parts.filter((part): part is string => typeof part === 'string' && part.length > 0).join(SEPARATOR); +} + +/** "32K", "200K", "1M", "1.05M"; below 1,000 the raw count. */ +export function formatContextWindow(tokens: number): string { + if (!Number.isFinite(tokens) || tokens <= 0) return '0'; + if (tokens >= 1_000_000) { + const millions = Math.round((tokens / 1_000_000) * 100) / 100; + return `${millions}M`; + } + if (tokens >= 1000) return `${Math.round(tokens / 1000)}K`; + return String(Math.round(tokens)); +} + +export function formatTemperature(value: number): string { + return value.toFixed(1); +} + +export interface TuningSummaryInput { + temperature: number; + /** False when the model sets its own temperature. */ + supportsTemperature?: boolean; + /** The context budget in tokens, when the model's window is known. */ + contextWindow?: number; +} + +export interface ChatOptionsSummaryInput extends TuningSummaryInput { + /** Chat settings only; omit on Defaults. */ + webSearch?: boolean; +} + +/** "Creativity 0.5" or "Fixed temperature", then "1M context" when the window is known. */ +export function tuningSummary(input: TuningSummaryInput): string[] { + const parts = [ + input.supportsTemperature === false ? COPY.fixedTemperature : `Creativity ${formatTemperature(input.temperature)}` + ]; + if (input.contextWindow) parts.push(`${formatContextWindow(input.contextWindow)} context`); + return parts; +} + +export function chatOptionsSummary(input: ChatOptionsSummaryInput): string { + return join([ + ...tuningSummary(input), + input.webSearch === undefined ? null : `Web search ${input.webSearch ? 'on' : 'off'}` + ]); +} + +export function countLabel(count: number, singular: string, plural = `${singular}s`): string { + return `${count} ${count === 1 ? singular : plural}`; +} + +export function contextSummary(input: { workspaceName?: string | null; promptName?: string | null; noteCount: number }): string { + return join([ + input.workspaceName || 'No workspace', + input.promptName || 'No prompt', + countLabel(input.noteCount, 'note') + ]); +} + +export function thinkingSummary(thinking: ThinkingState | undefined): string { + if (!thinking?.enabled) return 'Thinking off'; + return `${THINKING_LABELS[thinking.effort]} thinking`; +} + +export function subagentsSummary(input: { + modelName?: string | null; + /** Null when the model cannot reason, so no thinking state applies. */ + thinking?: ThinkingState | null; + tuning?: string[]; +}): string { + return join([ + input.modelName || 'Not set', + input.thinking === null || input.thinking === undefined ? null : thinkingSummary(input.thinking), + ...(input.tuning ?? []) + ]); +} + +export function imagesSummary(modelName?: string | null): string { + return modelName || 'Not set'; +} + +export function videoSummary(input: { modelName?: string | null; aspectRatio?: string | null; resolution?: string | null }): string { + return join([input.modelName || 'Not set', input.aspectRatio, input.resolution]); +} + +export function voiceSummary(input: { transcriptionModel?: string | null; readAloudModel?: string | null; voice?: string | null }): string { + const readAloud = input.readAloudModel + ? `${input.readAloudModel}${input.voice ? ` (${input.voice})` : ''}` + : null; + return join([input.transcriptionModel, readAloud]) || 'Not set'; +} + +export function fileConversionSummary(input: { enabled: boolean; vision: boolean; autoConvert: boolean }): string { + if (!input.enabled) return 'File import off'; + return join(['File import on', input.vision ? 'Vision' : 'Text extraction', input.autoConvert ? 'auto-convert' : null]); +} + +export function semanticSearchSummary(input: { + enabled: boolean; + noteCount?: number; + indexingPercent?: number; + /** With indexingPercent: the pass is comparing notes, not embedding them. */ + checking?: boolean; + /** While off: false once the index was deleted. Unknown counts as kept. */ + indexKept?: boolean; + /** While on: the embedding model failed to load, so nothing is indexed. */ + loadFailed?: boolean; +}): string { + if (!input.enabled) return input.indexKept === false ? 'Off · no index' : 'Off · index kept'; + if (input.loadFailed) return "On · couldn't load"; + if (typeof input.indexingPercent === 'number') { + return `${input.checking ? 'Checking' : 'Indexing'} · ${Math.round(input.indexingPercent)}%`; + } + if (typeof input.noteCount === 'number') return `On · ${input.noteCount.toLocaleString()} notes`; + return 'On'; +} diff --git a/src/components/shared/chatSettings/subagentTarget.ts b/src/components/shared/chatSettings/subagentTarget.ts new file mode 100644 index 000000000..79d8d8643 --- /dev/null +++ b/src/components/shared/chatSettings/subagentTarget.ts @@ -0,0 +1,30 @@ +/** + * The model the Subagents settings describe. A configured subagent model wins; + * without one, subagents run on the chat model when it is a cloud model (the + * runtime precedence in SubagentModelResolver), so the Creativity and Context + * window rows, and their saved limit key, follow that model. + * + * Pure; mobile-safe. + */ + +import { isLocalProvider } from '../../../services/llm/utils/SubagentModelResolver'; +import type { ChatSettings } from './types'; + +export interface SubagentTarget { + provider?: string; + model?: string; + /** True when no subagent model is set and the chat model is used. */ + inherited: boolean; +} + +export function resolveSubagentTarget( + settings: Pick +): SubagentTarget { + if (settings.agentProvider && settings.agentModel) { + return { provider: settings.agentProvider, model: settings.agentModel, inherited: false }; + } + if (settings.provider && settings.model && !isLocalProvider(settings.provider)) { + return { provider: settings.provider, model: settings.model, inherited: true }; + } + return { inherited: true }; +} diff --git a/src/components/shared/chatSettings/thinkingOptions.ts b/src/components/shared/chatSettings/thinkingOptions.ts new file mode 100644 index 000000000..d68091d56 --- /dev/null +++ b/src/components/shared/chatSettings/thinkingOptions.ts @@ -0,0 +1,62 @@ +import type { ThinkingEffort } from '../../../types/llm/ProviderTypes'; +import { + getSupportedThinkingEfforts, + normalizeThinkingEffortForModel +} from '../../../services/llm/utils/thinkingLevels'; + +/** + * The Thinking dropdown: Off plus one entry per effort level. It reads and + * writes the existing `{ enabled, effort }` storage, so nothing migrates: + * Off keeps the stored effort and only clears `enabled`. + */ + +export type ThinkingOption = 'off' | ThinkingEffort; + +export interface ThinkingState { + enabled: boolean; + effort: ThinkingEffort; +} + +export const THINKING_LEVELS: readonly ThinkingEffort[] = ['low', 'medium', 'high', 'xhigh', 'max']; + +export const THINKING_LABELS: Record = { + off: 'Off', + low: 'Low', + medium: 'Medium', + high: 'High', + xhigh: 'Extra high', + max: 'Max' +}; + +/** + * Levels offered for a model: only those the provider/model treats + * differently, so the dropdown never offers two choices that send the same + * request. Every level when Nexus has no capability data for the model. + */ +export function getThinkingLevels(provider: string | undefined, model: string | undefined): ThinkingEffort[] { + return getSupportedThinkingEfforts(provider, model); +} + +/** + * The dropdown value for a stored preference. With a provider/model, an effort + * the model folds onto a neighbour shows as that neighbour (the level that is + * really sent); the stored preference itself is left alone. + */ +export function thinkingToOption( + thinking: ThinkingState | undefined, + provider?: string, + model?: string +): ThinkingOption { + if (!thinking?.enabled) return 'off'; + if (provider === undefined && model === undefined) return thinking.effort; + return normalizeThinkingEffortForModel(provider, model, thinking.effort); +} + +export function optionToThinking(option: string, current: ThinkingState | undefined): ThinkingState { + const effort = current?.effort ?? 'medium'; + if (option === 'off') return { enabled: false, effort }; + if ((THINKING_LEVELS as readonly string[]).includes(option)) { + return { enabled: true, effort: option as ThinkingEffort }; + } + return { enabled: current?.enabled ?? false, effort }; +} diff --git a/src/components/shared/chatSettings/types.ts b/src/components/shared/chatSettings/types.ts new file mode 100644 index 000000000..41a16df95 --- /dev/null +++ b/src/components/shared/chatSettings/types.ts @@ -0,0 +1,115 @@ +import type { + DefaultRealtimeVoiceModelSettings, + DefaultSpeechModelSettings, + LLMProviderSettings, + ThinkingEffort, + VoiceDefaultSelectionSource +} from '../../../types/llm/ProviderTypes'; +import type { VideoAspectRatio, VideoResolution } from '../../../services/llm/types/VideoTypes'; + +/** Which screen the shared renderer is drawing. */ +export type SettingsSurface = 'defaults' | 'chat'; + +/** + * Current settings state edited by the shared renderer. Defaults persists it to + * plugin settings; Chat settings applies it to the open conversation. + */ +export interface ChatSettings { + provider: string; + model: string; + contextWindowOverrides?: Record; + webSearch?: boolean; + // Subagent model - used for subagents, saved prompts and automations + agentProvider?: string; + agentModel?: string; + thinking: { + enabled: boolean; + effort: ThinkingEffort; + }; + // Subagent model thinking settings (separate from chat model) + agentThinking?: { + enabled: boolean; + effort: ThinkingEffort; + }; + temperature: number; // 0.0-1.0, controls randomness + /** Subagent Creativity (0.0-1.0). Absent inherits `temperature`. */ + agentTemperature?: number; + /** + * Subagent context limits keyed by `getContextWindowOverrideKey(provider, model)`, + * kept apart from `contextWindowOverrides` so the chat and subagent sliders + * for the same model never share a value. Absent key means the model maximum. + */ + agentContextWindowOverrides?: Record; + imageProvider: 'google' | 'openrouter' | 'openai'; + imageModel: string; + speechProvider?: DefaultSpeechModelSettings['provider']; + speechModel?: DefaultSpeechModelSettings['model']; + speechVoice?: DefaultSpeechModelSettings['voice']; + /** 'user' once the user picks read-aloud settings by hand; Defaults persists only then. */ + speechSource?: VoiceDefaultSelectionSource; + realtimeVoiceProvider?: DefaultRealtimeVoiceModelSettings['provider']; + realtimeVoiceModel?: DefaultRealtimeVoiceModelSettings['model']; + realtimeVoiceVoice?: DefaultRealtimeVoiceModelSettings['voice']; + /** 'user' once the user picks live voice settings by hand; Defaults persists only then. */ + realtimeVoiceSource?: VoiceDefaultSelectionSource; + transcriptionProvider?: string; + transcriptionModel?: string; + /** Defaults only: text-to-video default, persisted through the Defaults save queue. */ + videoProvider?: 'google' | 'openrouter'; + videoModel?: string; + videoAspectRatio?: VideoAspectRatio; + videoResolution?: VideoResolution; + workspaceId: string | null; + workflowId?: string | null; + promptId: string | null; + contextNotes: string[]; +} + +/** + * Available options for dropdowns + */ +export interface ChatSettingsOptions { + workspaces: Array<{ + id: string; + name: string; + /** Canonical prompt binding (name or ID); preferred over context.dedicatedAgent. */ + dedicatedAgentId?: string; + context?: { + workflows?: Array<{ id: string; name: string; when?: string }>; + dedicatedAgent?: { + agentId: string; + agentName: string; + }; + }; + }>; + prompts: Array<{ id: string; name: string }>; +} + +/** + * What a fold supplied by the host surface (Video, File conversion, Semantic + * search on Defaults) can reach. Everything is live: `settings` is the object + * the renderer edits, and `redraw()` re-renders only this fold's rows. + */ +export interface FoldHost { + readonly settings: ChatSettings; + readonly llmProviderSettings: LLMProviderSettings; + readonly surface: SettingsSurface; + notifyChange(): void; + setSummary(summary: string): void; + redraw(): void; + registerDomEvent( + el: HTMLElement, + type: K, + callback: (event: HTMLElementEventMap[K]) => unknown + ): void; +} + +/** A fold the host surface adds after the shared ones. */ +export interface FoldDefinition { + id: string; + title: string; + icon: string; + /** Fill the fold's (already emptied) `setting-items` list and set its summary. */ + render(itemsEl: HTMLElement, host: FoldHost): void; + destroy?(): void; +} diff --git a/src/components/shared/settings/FoldStateStore.ts b/src/components/shared/settings/FoldStateStore.ts new file mode 100644 index 000000000..566cdd366 --- /dev/null +++ b/src/components/shared/settings/FoldStateStore.ts @@ -0,0 +1,73 @@ +/** + * Remembers which settings folds are open, per device. + * + * Stored with `app.saveLocalStorage` (vault-scoped, never synced) rather than in + * data.json: a synced key would make every other device treat each fold toggle + * as a settings change that needs a reload. Defaults and Chat settings keep + * separate lists so opening a fold in one does not open it in the other. + */ + +export type FoldSurface = 'defaults' | 'chat'; + +interface StoredFoldState { + v: 1; + defaults: string[]; + chat: string[]; +} + +/** The two App methods this store needs; keeps tests free of a full App. */ +export interface LocalStorageHost { + loadLocalStorage?(key: string): unknown; + saveLocalStorage?(key: string, data: unknown): void; +} + +export const FOLD_STATE_STORAGE_KEY = 'nexus-settings-folds'; + +function emptyState(): StoredFoldState { + return { v: 1, defaults: [], chat: [] }; +} + +function stringList(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; +} + +export class FoldStateStore { + constructor(private readonly host: LocalStorageHost) {} + + isOpen(surface: FoldSurface, id: string): boolean { + return this.read()[surface].includes(id); + } + + setOpen(surface: FoldSurface, id: string, open: boolean): void { + const state = this.read(); + const ids = state[surface].filter(item => item !== id); + if (open) ids.push(id); + state[surface] = ids; + try { + this.host.saveLocalStorage?.(FOLD_STATE_STORAGE_KEY, state); + } catch (error) { + // Losing remembered fold state is harmless; never break the settings UI over it. + console.warn('[FoldStateStore] Could not save fold state:', error); + } + } + + private read(): StoredFoldState { + let raw: unknown; + try { + raw = this.host.loadLocalStorage?.(FOLD_STATE_STORAGE_KEY); + } catch { + return emptyState(); + } + if (typeof raw === 'string') { + try { + raw = JSON.parse(raw); + } catch { + return emptyState(); + } + } + if (!raw || typeof raw !== 'object') return emptyState(); + const candidate = raw as Partial; + if (candidate.v !== 1) return emptyState(); + return { v: 1, defaults: stringList(candidate.defaults), chat: stringList(candidate.chat) }; + } +} diff --git a/src/components/shared/settings/settingGroup.ts b/src/components/shared/settings/settingGroup.ts new file mode 100644 index 000000000..258dac956 --- /dev/null +++ b/src/components/shared/settings/settingGroup.ts @@ -0,0 +1,31 @@ +import { SettingGroup } from 'obsidian'; + +/** + * A heading-less Obsidian SettingGroup plus the element its rows live in. + * + * SettingGroup (1.11+) only exposes `addSetting`, but a settings block that + * redraws its rows needs to empty and refill the row list. The group's own + * `groupEl`/`listEl` fields are read here, in one place, so the mock and any + * future API change touch a single function. + */ +export interface SettingGroupHandle { + group: SettingGroup; + groupEl: HTMLElement; + itemsEl: HTMLElement; +} + +interface SettingGroupInternals { + groupEl?: HTMLElement; + listEl?: HTMLElement; +} + +export function createSettingGroup(container: HTMLElement, cls?: string): SettingGroupHandle { + const group = new SettingGroup(container); + if (cls) group.addClass(cls); + const internals = group as unknown as SettingGroupInternals; + const groupEl = internals.groupEl ?? container.lastElementChild as HTMLElement | null ?? container.createDiv({ cls: 'setting-group' }); + const itemsEl = internals.listEl + ?? groupEl.querySelector('.setting-items') + ?? groupEl.createDiv({ cls: 'setting-items' }); + return { group, groupEl, itemsEl }; +} diff --git a/src/components/shared/settings/sliderReadout.ts b/src/components/shared/settings/sliderReadout.ts new file mode 100644 index 000000000..b8ce5509c --- /dev/null +++ b/src/components/shared/settings/sliderReadout.ts @@ -0,0 +1,76 @@ +import type { Setting, SliderComponent } from 'obsidian'; + +/** + * One value readout per slider. + * + * Obsidian 1.13+ draws its own `span.slider-value` in front of every + * SliderComponent and keeps it current while the thumb moves, formatted by + * `setDisplayFormat` when one is set (confirmed by reading the 1.14.4 app + * bundle). Settings that also added their own span showed every value twice: + * "High 2", "1,000,000 tokens 1000000". This helper writes the formatted text + * into the native element when it exists and adds exactly one + * `span.slider-value.nexus-slider-value` only on older builds that have none. + * Either way it keeps `aria-valuetext` in step, since a bare index ("2") means + * nothing to a screen reader. + */ + +export interface DomEventRegistrar { + registerDomEvent(el: HTMLElement, type: 'input', callback: (event: Event) => unknown): void; +} + +export interface SliderReadout { + readonly el: HTMLElement; + /** Re-read the slider, or show `text` until the next drag (for values between stops). */ + update(text?: string): void; +} + +type SliderWithDisplayFormat = SliderComponent & { + setDisplayFormat?: (format: (value: number) => string) => unknown; +}; + +export interface SliderReadoutOptions { + /** Screen-reader text for a value; defaults to `format`. */ + ariaValueText?: (value: number) => string; + /** Text to show initially instead of `format(value)`. */ + initialText?: string; + /** Extra class on the readout, e.g. for a minimum width. */ + cls?: string; +} + +export function bindSliderReadout( + events: DomEventRegistrar, + setting: Setting, + slider: SliderComponent, + format: (value: number) => string, + options: SliderReadoutOptions = {} +): SliderReadout { + const native = slider as SliderWithDisplayFormat; + if (typeof native.setDisplayFormat === 'function') { + native.setDisplayFormat(format); + } + + const existing = setting.controlEl.querySelector?.('.slider-value') as HTMLElement | null | undefined; + let el: HTMLElement; + if (existing) { + el = existing; + } else { + el = setting.controlEl.createSpan({ cls: ['slider-value', 'nexus-slider-value'] }); + if (slider.sliderEl.parentElement === setting.controlEl) { + setting.controlEl.insertBefore(el, slider.sliderEl); + } + } + if (options.cls) el.addClass(options.cls); + + const ariaText = options.ariaValueText ?? format; + const currentValue = (): number => Number(slider.sliderEl.value); + + const update = (text?: string): void => { + const value = currentValue(); + el.setText(text ?? format(value)); + slider.sliderEl.setAttribute('aria-valuetext', text ?? ariaText(value)); + }; + + events.registerDomEvent(slider.sliderEl, 'input', () => update()); + update(options.initialText); + return { el, update }; +} diff --git a/src/components/workspace/ProjectsManagerView.ts b/src/components/workspace/ProjectsManagerView.ts index 77a30c015..3d7fb8b40 100644 --- a/src/components/workspace/ProjectsManagerView.ts +++ b/src/components/workspace/ProjectsManagerView.ts @@ -11,7 +11,8 @@ import { ProjectWorkspace } from '../../database/workspace-types'; import { TaskService } from '../../agents/taskManager/services/TaskService'; import { DAGService } from '../../agents/taskManager/services/DAGService'; import type { ServiceManager } from '../../core/ServiceManager'; -import type { EmbeddingService } from '../../services/embeddings/EmbeddingService'; +import { resolveEmbeddingService } from '../../services/embeddings/embeddingAccess'; +import { getNexusPlugin } from '../../utils/pluginLocator'; import type { HybridStorageAdapter } from '../../database/adapters/HybridStorageAdapter'; import type { ProjectMetadata } from '../../database/repositories/interfaces/IProjectRepository'; import type { LinkType, NoteLink, TaskMetadata, TaskPriority, TaskStatus } from '../../database/repositories/interfaces/ITaskRepository'; @@ -143,7 +144,7 @@ export class ProjectsManagerView { onUnlinkNote: (taskId, notePath) => this.unlinkNote(taskId, notePath), getApp: () => this.callbacks.getApp(), getEmbeddingService: () => - this.serviceManager?.getServiceIfReady('embeddingService') ?? null + resolveEmbeddingService(getNexusPlugin(this.callbacks.getApp())) }; } diff --git a/src/components/workspace/TaskDetailRenderer.ts b/src/components/workspace/TaskDetailRenderer.ts index 27f6528f5..58ec36df9 100644 --- a/src/components/workspace/TaskDetailRenderer.ts +++ b/src/components/workspace/TaskDetailRenderer.ts @@ -420,7 +420,9 @@ export class TaskDetailRenderer { const suggester = new NoteInputSuggester( this.app, pathInput.inputEl, - callbacks.getEmbeddingService?.() ?? null, + // A resolver, not an instance: embeddings can be turned on, off or + // rebuilt while this view stays open. + () => callbacks.getEmbeddingService?.() ?? null, (file: TFile) => { pathInput.setValue(file.path); } ); this.noteSuggesters.push(suggester); diff --git a/src/core/PluginLifecycleManager.ts b/src/core/PluginLifecycleManager.ts index 36ba3dd93..92885b067 100644 --- a/src/core/PluginLifecycleManager.ts +++ b/src/core/PluginLifecycleManager.ts @@ -24,15 +24,11 @@ import { VaultIngestionManager } from './ingest/VaultIngestionManager'; import type { ServiceCreationContext } from './services/ServiceDefinitions'; import type { HybridStorageAdapter } from '../database/adapters/HybridStorageAdapter'; import type { ChatTraceService } from '../services/chat/ChatTraceService'; +import type { RouterState } from '../settings/SettingsRouter'; // Type-only import to avoid bundling Node.js dependencies on mobile type MCPConnectorType = import('../connector').MCPConnector; -// Interface for services with storage state management -interface StateManager { - saveState(): Promise; -} - /** * Extended Plugin interface with required service methods * Used for proper typing when passing plugin to child managers @@ -77,6 +73,7 @@ export class PluginLifecycleManager { private readAloudCommandManager: ReadAloudCommandManager; private vaultIngestionManager: VaultIngestionManager; private embeddingManager: EmbeddingManager | null = null; + private embeddingStateUnsubscribe: (() => void) | null = null; // Pending timer handles for cleanup on shutdown private pendingTimers: number[] = []; @@ -334,6 +331,11 @@ export class PluginLifecycleManager { // Configuration reloading handled by individual services } + /** Open Settings on the Nexus tab at the requested route. */ + openNexusSettings(route: Partial): boolean { + return this.settingsTabManager.openNexusSettings(route); + } + /** * Get initialization status */ @@ -386,20 +388,35 @@ export class PluginLifecycleManager { }) ); - // Wire embedding service into ChatTraceService - const embeddingService = this.embeddingManager.getService(); - if (embeddingService) { - const chatTraceService = await this.serviceRegistrar.getService('chatTraceService'); - if (this.shuttingDown) return; - if (chatTraceService && typeof chatTraceService.setEmbeddingService === 'function') { - chatTraceService.setEmbeddingService(embeddingService); - } + // Wire the embedding service into ChatTraceService, and keep it + // wired as semantic search is turned off and on: each start builds + // a new service, and a stop must not leave the old one in use. + const chatTraceService = await this.serviceRegistrar.getService('chatTraceService'); + if (this.shuttingDown) return; + if (chatTraceService && typeof chatTraceService.setEmbeddingService === 'function') { + const syncTraceEmbeddings = (): void => { + chatTraceService.setEmbeddingService(manager.getService()); + }; + syncTraceEmbeddings(); + this.embeddingStateUnsubscribe = manager.onStateChange(syncTraceEmbeddings); } } catch (error) { console.error('[PluginLifecycleManager] Background embedding initialization failed:', error); } } + /** + * Apply the semantic search setting at runtime, without a restart. + * + * Called by the settings UI and for a change synced from another device. + * Before the embedding manager exists (the first seconds after load) this + * does nothing: the manager reads the setting when it is created. + */ + async setEmbeddingsEnabled(enabled: boolean): Promise { + if (this.shuttingDown || !this.embeddingManager) return; + await this.embeddingManager.setEnabled(enabled); + } + /** * Shutdown and cleanup */ @@ -449,6 +466,8 @@ export class PluginLifecycleManager { } // Shutdown embedding system first (before database closes) + this.embeddingStateUnsubscribe?.(); + this.embeddingStateUnsubscribe = null; if (this.embeddingManager) { try { await this.embeddingManager.shutdown(); @@ -457,16 +476,6 @@ export class PluginLifecycleManager { } } - // Save processed files state before cleanup - const stateManager = this.config.serviceManager?.getServiceIfReady('stateManager'); - if (stateManager && typeof stateManager.saveState === 'function') { - try { - await stateManager.saveState(); - } catch (error) { - console.error('[PluginLifecycleManager] Failed to save state during shutdown:', error); - } - } - // Detach the notes index BEFORE the database it writes through is // closed. Its metadataCache/vault subscriptions are raw `on()` // registrations that survive unload, and a bulk import keeps them diff --git a/src/core/settings/SettingsTabManager.ts b/src/core/settings/SettingsTabManager.ts index 5bf0a0f96..ca20cf83e 100644 --- a/src/core/settings/SettingsTabManager.ts +++ b/src/core/settings/SettingsTabManager.ts @@ -11,6 +11,7 @@ import type { App, Plugin } from 'obsidian'; import type { Settings } from '../../settings'; // Use new SettingsView (tab-based UI) instead of old SettingsTab (accordion-based) import { SettingsView } from '../../settings/SettingsView'; +import type { RouterState } from '../../settings/SettingsRouter'; import type { BackgroundProcessor } from '../background/BackgroundProcessor'; import type { PluginLifecycleManager } from '../PluginLifecycleManager'; import type { ServiceManager } from '../ServiceManager'; @@ -18,6 +19,14 @@ import type { ServiceManager } from '../ServiceManager'; // Type-only import to avoid bundling Node.js dependencies on mobile type MCPConnectorType = import('../../connector').MCPConnector; +/** Obsidian's settings modal is not part of the public App typings. */ +interface AppWithSettingModal { + setting?: { + open(): void; + openTabById(id: string): void; + }; +} + export interface SettingsTabManagerConfig { plugin: Plugin; app: App; @@ -96,6 +105,19 @@ export class SettingsTabManager { return this.settingsTab; } + /** + * Open Settings on the Nexus tab, at the requested route (for example the + * Providers tab). Returns false when Obsidian's settings modal is unavailable. + */ + openNexusSettings(route: Partial): boolean { + const setting = (this.config.app as unknown as AppWithSettingModal).setting; + if (!setting) return false; + this.settingsTab?.openTo(route); + setting.open(); + setting.openTabById(this.config.plugin.manifest.id); + return true; + } + /** * Check if settings tab is initialized */ diff --git a/src/database/repositories/WorkspaceRepository.ts b/src/database/repositories/WorkspaceRepository.ts index 3cc0d2de1..7c8c7c23a 100644 --- a/src/database/repositories/WorkspaceRepository.ts +++ b/src/database/repositories/WorkspaceRepository.ts @@ -23,7 +23,8 @@ import { DatabaseRow } from './base/BaseRepository'; import { IWorkspaceRepository, CreateWorkspaceData, - UpdateWorkspaceData + UpdateWorkspaceData, + WorkspaceConflictError } from './interfaces/IWorkspaceRepository'; import { WorkspaceMetadata } from '../../types/storage/HybridStorageTypes'; import { @@ -100,6 +101,16 @@ export class WorkspaceRepository try { await this.transaction(async () => { + // 0. Refuse a create SQLite would reject, before anything is written. + // The event append below cannot be rolled back with the transaction, + // so letting the INSERT be the uniqueness check leaves a + // `workspace_created` for a workspace that was never created — and + // replay applies it with INSERT OR REPLACE, overwriting (same id) or + // deleting (same name) the real one on the next cache rebuild. + // Inside the transaction, so concurrent creates are serialized + // against this check. + await this.assertNoConflict(id, data.name); + // 1. Write event to JSONL await this.writeEvent( this.jsonlPath(id), @@ -145,11 +156,25 @@ export class WorkspaceRepository return id; } catch (error) { - this.logError('create', error); + // A conflict is an answer for the caller to handle (WorkspaceService + // reuses the existing default workspace), not a storage failure. + if (!(error instanceof WorkspaceConflictError)) { + this.logError('create', error); + } throw error; } } + private async assertNoConflict(id: string, name: string): Promise { + const existing = await this.sqliteCache.queryOne<{ id: string; name: string }>( + 'SELECT id, name FROM workspaces WHERE id = ? OR name = ? LIMIT 1', + [id, name] + ); + if (existing) { + throw new WorkspaceConflictError(existing.id === id ? 'id' : 'name', existing.id, name); + } + } + async update(id: string, data: UpdateWorkspaceData): Promise { try { await this.transaction(async () => { diff --git a/src/database/repositories/interfaces/IWorkspaceRepository.ts b/src/database/repositories/interfaces/IWorkspaceRepository.ts index d7ac8428d..25f4c9d52 100644 --- a/src/database/repositories/interfaces/IWorkspaceRepository.ts +++ b/src/database/repositories/interfaces/IWorkspaceRepository.ts @@ -75,3 +75,25 @@ export interface IWorkspaceRepository extends IRepository { */ search(query: string): Promise; } + +/** + * `create` was asked for a workspace whose id or name is already taken. + * + * Thrown BEFORE the `workspace_created` event is appended, so a rejected create + * leaves nothing in the event store. The message keeps SQLite's + * `UNIQUE constraint failed: workspaces.` wording for callers that + * still match on it. + */ +export class WorkspaceConflictError extends Error { + constructor( + readonly column: 'id' | 'name', + readonly existingId: string, + readonly requestedName: string + ) { + super( + `Workspace ${column === 'id' ? `id "${existingId}"` : `name "${requestedName}"`} already exists ` + + `(UNIQUE constraint failed: workspaces.${column})` + ); + this.name = 'WorkspaceConflictError'; + } +} diff --git a/src/database/storage/IndexedDBCacheBlobStore.ts b/src/database/storage/IndexedDBCacheBlobStore.ts index 3eb6b690d..d26936003 100644 --- a/src/database/storage/IndexedDBCacheBlobStore.ts +++ b/src/database/storage/IndexedDBCacheBlobStore.ts @@ -3,7 +3,7 @@ import type { CacheBlobMetadata, CacheBlobStore } from './CacheBlobStore'; interface IndexedDBCacheBlobStoreOptions { /** Stable per-vault, per-plugin-install key for the blob entry. */ idbKey: string; - /** Optional override for tests; defaults to window.activeWindow.indexedDB. */ + /** Optional override for tests; defaults to the main window's indexedDB. */ factory?: IDBFactory; } @@ -36,7 +36,12 @@ export class IndexedDBCacheBlobStore implements CacheBlobStore { constructor(opts: IndexedDBCacheBlobStoreOptions) { this.idbKey = opts.idbKey; - const fallback = (window.activeWindow as { indexedDB?: IDBFactory }).indexedDB; + // The main window's factory, never activeWindow's. This store lives as + // long as the plugin, and a factory taken from whichever window had focus + // at load (a popout, or the Settings window since Obsidian 1.14) stops + // working when that window closes: open() then returns null and every + // cache save throws. Same origin, so it is the same database either way. + const fallback = (window as { indexedDB?: IDBFactory }).indexedDB; const factory = Object.prototype.hasOwnProperty.call(opts, 'factory') ? opts.factory : fallback; if (!factory) { throw new Error('[IndexedDBCacheBlobStore] indexedDB is not available in this environment'); @@ -192,7 +197,7 @@ export class IndexedDBCacheBlobStore implements CacheBlobStore { private requestPersistOnce(): void { if (this.persistRequested) return; this.persistRequested = true; - const nav = (window.activeWindow as { navigator?: { storage?: { persist?: () => Promise } } }).navigator; + const nav = (window as { navigator?: { storage?: { persist?: () => Promise } } }).navigator; const storage = nav?.storage; if (!storage || typeof storage.persist !== 'function') return; try { diff --git a/src/database/storage/SQLiteCacheManager.ts b/src/database/storage/SQLiteCacheManager.ts index c74fbd543..51cdd0bfd 100644 --- a/src/database/storage/SQLiteCacheManager.ts +++ b/src/database/storage/SQLiteCacheManager.ts @@ -752,12 +752,33 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager await this.getMaintenanceService().clearAllData(); } + /** + * Delete all embedding vectors (notes, traces, conversations) and save. + * Used by "Delete index" while semantic search is turned off. + */ + /** + * Delete the embedding vectors, then VACUUM so the saved cache actually + * shrinks: dropped tables only move their pages to SQLite's freelist, and + * the exported blob would otherwise keep them. + */ + async clearEmbeddingIndex(): Promise { + await this.getMaintenanceService().clearEmbeddingIndex(); + try { + await this.vacuum(); + } catch (error) { + // The vectors are gone either way; only the space stays allocated. + console.warn('[SQLiteCacheManager] Could not compact the cache after deleting the index:', error); + } + await this.save(); + } + async rebuildFTSIndexes(): Promise { await this.getMaintenanceService().rebuildFTSIndexes(); } + /** VACUUM cannot run inside a transaction, so it waits for any open one to finish. */ async vacuum(): Promise { - await this.getMaintenanceService().vacuum(); + await this.transactionCoordinator.exclusive(() => this.getMaintenanceService().vacuum()); this.markDirty(); } diff --git a/src/database/storage/SQLiteMaintenanceService.ts b/src/database/storage/SQLiteMaintenanceService.ts index 7ef639658..1adc1149e 100644 --- a/src/database/storage/SQLiteMaintenanceService.ts +++ b/src/database/storage/SQLiteMaintenanceService.ts @@ -67,6 +67,24 @@ interface SQLiteMaintenanceServiceOptions { blobStore?: CacheBlobStore; } +/** + * The vec0 tables that hold embedding vectors. Each is created in + * schema.ts as `USING vec0(embedding float[384])`; a test holds the two in step. + */ +export const EMBEDDING_VECTOR_TABLES = [ + 'note_embeddings', + 'trace_embeddings', + 'conversation_embeddings' +] as const; + +/** Row tables that describe the vectors, plus the conversation backfill cursor. */ +export const EMBEDDING_METADATA_TABLES = [ + 'embedding_metadata', + 'trace_embedding_metadata', + 'conversation_embedding_metadata', + 'embedding_backfill_state' +] as const; + export class SQLiteMaintenanceService { private readonly app: App; private dbPath: string; @@ -117,6 +135,33 @@ export class SQLiteMaintenanceService { }); } + /** + * Delete every stored embedding vector and its metadata, and nothing else. + * + * Vectors are derived cache data: turning semantic search back on rebuilds + * them from the vault and the conversation history. The three vec0 tables + * are dropped and recreated rather than DELETEd, so the space they held is + * released, with the same DDL the schema uses (EMBEDDING_VECTOR_TABLES). + * The metadata tables keep their schema and lose their rows, and the + * conversation backfill cursor is cleared so the next pass starts over. + * + * Conversations, messages, traces, the notes index and the learned retrieval + * adapter (a file, not a table) are untouched. + */ + async clearEmbeddingIndex(): Promise { + await this.transaction(() => { + const db = this.getDb(); + for (const table of EMBEDDING_VECTOR_TABLES) { + this.bridge.exec(db, `DROP TABLE IF EXISTS ${table}`); + this.bridge.exec(db, `CREATE VIRTUAL TABLE IF NOT EXISTS ${table} USING vec0(embedding float[384])`); + } + for (const table of EMBEDDING_METADATA_TABLES) { + this.bridge.exec(db, `DELETE FROM ${table}`); + } + return Promise.resolve(); + }); + } + async rebuildFTSIndexes(): Promise { await this.transaction(() => { const db = this.getDb(); diff --git a/src/database/storage/SQLiteTransactionCoordinator.ts b/src/database/storage/SQLiteTransactionCoordinator.ts index 5eb59e313..884eca46f 100644 --- a/src/database/storage/SQLiteTransactionCoordinator.ts +++ b/src/database/storage/SQLiteTransactionCoordinator.ts @@ -7,14 +7,7 @@ export class SQLiteTransactionCoordinator { rollbackTransaction: () => Promise, fn: () => Promise ): Promise { - let releaseLock: (() => void) | undefined; - const previousLock = this.transactionLock; - this.transactionLock = new Promise(resolve => { - releaseLock = resolve; - }); - - try { - await previousLock; + return this.exclusive(async () => { await beginTransaction(); try { @@ -25,6 +18,23 @@ export class SQLiteTransactionCoordinator { await rollbackTransaction(); throw error; } + }); + } + + /** + * Run `fn` with no transaction open and none able to start until it ends. + * For statements SQLite refuses inside a transaction, such as VACUUM. + */ + async exclusive(fn: () => Promise): Promise { + let releaseLock: (() => void) | undefined; + const previousLock = this.transactionLock; + this.transactionLock = new Promise(resolve => { + releaseLock = resolve; + }); + + try { + await previousLock; + return await fn(); } finally { releaseLock?.(); } diff --git a/src/database/sync/WorkspaceEventApplier.ts b/src/database/sync/WorkspaceEventApplier.ts index 5bf212319..76c776439 100644 --- a/src/database/sync/WorkspaceEventApplier.ts +++ b/src/database/sync/WorkspaceEventApplier.ts @@ -99,10 +99,19 @@ export class WorkspaceEventApplier { return; } + // A create for an id that already has a row is a duplicate, not a reset. + // Plugin builds 5.17.0-5.20.4 appended a fresh `workspace_created` for the + // default workspace on every load (DefaultWorkspaceManager raced startup), + // so those streams interleave duplicates with real `workspace_updated` + // events. Replaying a duplicate as INSERT OR REPLACE reverted every edit + // made before it. First create wins; later ones are no-ops. A create after + // `workspace_deleted` still lands, because the delete purged the row. + // OR REPLACE is kept for the name constraint only, as before. await this.sqliteCache.run( `INSERT OR REPLACE INTO workspaces (id, name, description, rootFolder, created, lastAccessed, isActive, isArchived, contextJson, dedicatedAgentId) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + WHERE NOT EXISTS (SELECT 1 FROM workspaces WHERE id = ?)`, [ event.data.id, event.data.name, @@ -114,7 +123,8 @@ export class WorkspaceEventApplier { event.data.isActive !== undefined ? (event.data.isActive ? 1 : 0) : 1, event.data.isArchived !== undefined ? (event.data.isArchived ? 1 : 0) : 0, event.data.contextJson ?? null, - event.data.dedicatedAgentId ?? null + event.data.dedicatedAgentId ?? null, + event.data.id ] ); } diff --git a/src/main.ts b/src/main.ts index 524811e9c..a405a86bf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ import { BRAND_NAME } from './constants/branding'; import { supportsMCPBridge } from './utils/platform'; import { WasmEnsurer } from './utils/WasmEnsurer'; import type { PluginServices } from './types/plugin/PluginTypes'; +import type { RouterState } from './settings/SettingsRouter'; // MCPConnector type for desktop-only dynamic import type MCPConnectorType = import('./connector').MCPConnector; @@ -72,6 +73,18 @@ export default class NexusPlugin extends Plugin { if (this.unloading || !this.settings) return; try { const needsReload = await this.settings.reconcileExternalSettings(this.settingsSyncAbort.signal); + if (this.unloading) return; + // Semantic search is applied live rather than by reload. The call is + // idempotent, so it also catches a value adopted by an earlier read + // (the workflow scheduler reconciles synced settings too). + if (this.lifecycleManager) { + try { + await this.lifecycleManager.setEmbeddingsEnabled(this.settings.settings.enableEmbeddings ?? true); + } catch (error) { + // The settings were adopted; only starting or stopping the index failed. + console.error(`[${BRAND_NAME}] Could not apply the synced semantic search setting:`, error); + } + } if (needsReload && !this.unloading && !this.syncedSettingsNeedReload) { this.syncedSettingsNeedReload = true; new Notice(`${BRAND_NAME}: Settings changed on another device. Reload Nexus to apply changes outside AI providers.`); @@ -211,6 +224,14 @@ export default class NexusPlugin extends Plugin { // Service manager cleanup handled by lifecycle manager } + /** + * Open Settings on the Nexus tab at the requested route, e.g. `{ tab: 'providers' }`. + * Returns false when settings cannot be opened yet. + */ + public openNexusSettings(route: Partial): boolean { + return this.lifecycleManager?.openNexusSettings(route) ?? false; + } + /** * Get service manager for direct access if needed */ diff --git a/src/services/StaticModelsService.ts b/src/services/StaticModelsService.ts index 76fdd8ea8..d5735cb07 100644 --- a/src/services/StaticModelsService.ts +++ b/src/services/StaticModelsService.ts @@ -38,6 +38,11 @@ export interface ModelWithProvider { supportsStreaming: boolean; supportsThinking: boolean; }; + /** + * False when the model rejects temperature and other sampling parameters + * (the registry's `supportsSamplingParams`). Absent means it accepts them. + */ + supportsSamplingParams?: boolean; userDescription?: string; // User-defined description isDefault?: boolean; } @@ -220,7 +225,8 @@ export class StaticModelsService { supportsFunctions: model.capabilities.supportsFunctions, supportsStreaming: model.capabilities.supportsStreaming, supportsThinking: model.capabilities.supportsThinking - } + }, + ...(model.supportsSamplingParams !== undefined ? { supportsSamplingParams: model.supportsSamplingParams } : {}) }; } diff --git a/src/services/WorkspaceService.ts b/src/services/WorkspaceService.ts index c2ba19c5e..113fb7029 100644 --- a/src/services/WorkspaceService.ts +++ b/src/services/WorkspaceService.ts @@ -10,6 +10,7 @@ import { FileSystemService } from './storage/FileSystemService'; import { IndexManager } from './storage/IndexManager'; import { IndividualWorkspace, WorkspaceMetadata, SessionData, MemoryTrace, StateData } from '../types/storage/StorageTypes'; import { IStorageAdapter } from '../database/interfaces/IStorageAdapter'; +import { WorkspaceConflictError } from '../database/repositories/interfaces/IWorkspaceRepository'; import * as HybridTypes from '../types/storage/HybridStorageTypes'; import type { VaultOperations } from '../core/VaultOperations'; import type { MCPSettings } from '../types/plugin/PluginTypes'; @@ -141,7 +142,10 @@ export class WorkspaceService { return withTimeout(adapter.waitForQueryReady(), QUERY_READY_WAIT_MS, false); } - private isWorkspaceNameUniqueConstraint(error: unknown): boolean { + private isWorkspaceUniqueConflict(error: unknown): boolean { + if (error instanceof WorkspaceConflictError) { + return true; + } const message = error instanceof Error ? error.message : String(error); return message.includes('UNIQUE constraint failed: workspaces.name'); } @@ -335,6 +339,79 @@ export class WorkspaceService { ); } + private toIndividualWorkspace(metadata: HybridTypes.WorkspaceMetadata): IndividualWorkspace { + return { + id: metadata.id, + name: metadata.name, + description: metadata.description, + rootFolder: metadata.rootFolder, + created: metadata.created, + lastAccessed: metadata.lastAccessed, + isActive: metadata.isActive, + isArchived: metadata.isArchived, + dedicatedAgentId: metadata.dedicatedAgentId, + context: metadata.context ? normalizeWorkspaceContext(metadata.context).context : metadata.context, + sessions: {} + }; + } + + /** + * Return the workspace with `data.id` — or, failing that, the one named + * `data.name` — creating it only when the store that will hold it has + * neither. For well-known workspaces that code provisions on its own + * (the default workspace), never for user-initiated creates. + * + * Do not rebuild this out of `getWorkspace` + `createWorkspace`. While + * storage is starting those two consult different stores: the read falls + * back to the legacy `.workspaces` index the moment the hybrid adapter is + * not ready, while the create waits for the adapter and writes through it. + * On a vault-root install the legacy index never holds hybrid workspaces, + * so the check said "missing" on every plugin load and the create appended + * one more `workspace_created` event before SQLite rejected the row. Here + * the existence check and the write go to the same store, after it has + * finished replaying. + */ + async ensureWorkspace( + data: Partial & { id: string; name: string } + ): Promise { + const adapter = await resolveWritableAdapter(this.storageAdapterOrGetter); + if (!adapter) { + // No hybrid adapter in play: the legacy store is the store of record, + // and that is what both getWorkspace and createWorkspace use. + const existing = await this.getWorkspace(data.id) ?? await this.getWorkspaceByNameOrId(data.name); + return existing ?? this.createWorkspace(data); + } + + // A cache that is still replaying JSONL answers "missing" for rows it + // has not reached yet, and acting on that answer is the duplicate. Wait + // on the adapter's own readiness (bounded by its hydration watchdog), not + // the short service cap: nobody is waiting on this, and guessing is + // exactly the failure being prevented. + const settled = typeof adapter.waitForQueryReady === 'function' + ? await adapter.waitForQueryReady() + : adapter.isReady(); + if (!settled) { + throw new Error( + `Workspace storage did not finish loading; cannot tell whether workspace "${data.name}" already exists.` + ); + } + + const byId = await adapter.getWorkspace(data.id); + if (byId) { + return this.toIndividualWorkspace(byId); + } + const byName = await adapter.getWorkspaces({ search: data.name, pageSize: 100 }); + // Exact first (that is what UNIQUE(name) compares); then case-folded, so a + // legacy default saved as "default workspace" is adopted, not duplicated. + const nameMatch = byName.items.find(ws => ws.name === data.name) + ?? byName.items.find(ws => ws.name.toLowerCase() === data.name.toLowerCase()); + if (nameMatch) { + return this.toIndividualWorkspace(nameMatch); + } + + return this.createWorkspace(data); + } + /** * Get full workspace with sessions and traces (loads individual file) * NOTE: When using IStorageAdapter, this only returns metadata. @@ -349,22 +426,7 @@ export class WorkspaceService { this.storageAdapterOrGetter, async (adapter) => { const metadata = await adapter.getWorkspace(id); - if (!metadata) { - return null; - } - return { - id: metadata.id, - name: metadata.name, - description: metadata.description, - rootFolder: metadata.rootFolder, - created: metadata.created, - lastAccessed: metadata.lastAccessed, - isActive: metadata.isActive, - isArchived: metadata.isArchived, - dedicatedAgentId: metadata.dedicatedAgentId, - context: metadata.context ? normalizeWorkspaceContext(metadata.context).context : metadata.context, - sessions: {} - }; + return metadata ? this.toIndividualWorkspace(metadata) : null; }, async () => { const workspace = await this.fileSystem.readWorkspace(id); @@ -475,7 +537,7 @@ export class WorkspaceService { const isDefaultWorkspace = hybridData.id === GLOBAL_WORKSPACE_ID || hybridData.name === DEFAULT_WORKSPACE_NAME; - if (isDefaultWorkspace && this.isWorkspaceNameUniqueConstraint(error)) { + if (isDefaultWorkspace && this.isWorkspaceUniqueConflict(error)) { const existingWorkspace = await this.reuseExistingWorkspaceAfterUniqueError(hybridData); if (existingWorkspace) { return existingWorkspace; diff --git a/src/services/chat/ChatTraceService.ts b/src/services/chat/ChatTraceService.ts index 9df929d68..15431a5b8 100644 --- a/src/services/chat/ChatTraceService.ts +++ b/src/services/chat/ChatTraceService.ts @@ -51,10 +51,12 @@ export class ChatTraceService { } /** - * Set the embedding service (can be set after construction) + * Set the embedding service (can be set after construction). Null clears it + * when semantic search is turned off, so traces stop being embedded through + * a stopped service. */ - setEmbeddingService(embeddingService: EmbeddingService): void { - this.embeddingService = embeddingService; + setEmbeddingService(embeddingService: EmbeddingService | null): void { + this.embeddingService = embeddingService ?? undefined; } /** @@ -71,19 +73,18 @@ export class ChatTraceService { throw new Error(`Workspace ${workspaceId} not found`); } - const legacyDefaultWorkspace = await this.workspaceService.getWorkspaceByNameOrId(DEFAULT_WORKSPACE_NAME); - if (legacyDefaultWorkspace) { - return legacyDefaultWorkspace.id; - } - - const createdWorkspace = await this.workspaceService.createWorkspace({ + // The lookup above can miss a default that exists (during startup it reads + // the legacy index, not the event store), so it must not decide a create. + // ensureWorkspace re-checks the store it writes to, and adopts a legacy + // default found by name. + const defaultWorkspace = await this.workspaceService.ensureWorkspace({ id: DEFAULT_WORKSPACE_ID, name: DEFAULT_WORKSPACE_NAME, description: DEFAULT_WORKSPACE_DESCRIPTION, rootFolder: '/' }); - return createdWorkspace.id; + return defaultWorkspace.id; } /** diff --git a/src/services/chat/SubagentExecutor.ts b/src/services/chat/SubagentExecutor.ts index 2013d59f0..fa9b76fe2 100644 --- a/src/services/chat/SubagentExecutor.ts +++ b/src/services/chat/SubagentExecutor.ts @@ -33,6 +33,12 @@ import type { DirectToolExecutor } from './DirectToolExecutor'; import { formatWorkspaceDataForPrompt } from '../../utils/WorkspaceDataFormatter'; import { isTextOnlyProvider } from '../llm/utils/ToolSchemaSupport'; import type { ChatRuntimeEvent } from '../llm/runtime/ChatRuntimeEvent'; +import type { ThinkingEffort } from '../../types/llm/ProviderTypes'; +import { + fitInheritedContext, + formatOmittedContextLine, + type InheritedContextItem +} from './subagentContextBudget'; import { createInitialChatTurnState, reduceChatTurn, @@ -54,6 +60,9 @@ export interface SubagentExecutorDependencies { sessionId?: string; operationOrigin?: import('../../types/tools/ToolOperationTypes').ToolExecutionOrigin; operationScopeId?: string; + enableThinking?: boolean; + thinkingEffort?: ThinkingEffort; + temperature?: number; } ) => AsyncGenerator; // Tool list service for pre-fetching schemas @@ -259,15 +268,18 @@ export class SubagentExecutor { // 2. Read context files if provided (inherited from parent + tool params) // These go into the SYSTEM PROMPT so the subagent has full context - let contextFilesContent = ''; - if (params.contextFiles?.length) { - contextFilesContent = await this.readContextFiles(params.contextFiles); - } + const contextFiles = params.contextFiles?.length + ? await this.readContextFiles(params.contextFiles) + : []; + + // 3. Fit the inherited context (workspace data, then files) into the + // subagent's context window, dropping whole items that do not fit. + const inheritedContext = this.assembleInheritedContext(params, contextFiles); - // 3. Build system prompt WITH tool schemas + context files included - const systemPrompt = this.buildSystemPrompt(params, toolSchemasText, contextFilesContent); + // 4. Build system prompt WITH tool schemas + inherited context included + const systemPrompt = this.buildSystemPrompt(params, toolSchemasText, inheritedContext); - // 4. Build initial user message (just task + any additional context string) + // 5. Build initial user message (just task + any additional context string) const initialMessage = this.buildInitialMessage(params.task, params.context || ''); // 5. Add initial messages to branch @@ -370,6 +382,9 @@ export class SubagentExecutor { sessionId: params.sessionId, provider: params.provider, model: params.model, + enableThinking: params.thinkingEnabled, + thinkingEffort: params.thinkingEffort, + temperature: params.temperature, operationOrigin: 'subagent', operationScopeId: branchId, })) { @@ -500,16 +515,43 @@ export class SubagentExecutor { }; } + /** + * Decide which inherited context goes into the subagent's system prompt. + * Without a context window everything is kept, as before. + */ + private assembleInheritedContext( + params: SubagentParams, + contextFiles: InheritedContextItem[] + ): { workspaceContext?: string; contextFilesContent?: string; omittedLine?: string } { + const items: InheritedContextItem[] = []; + const workspaceText = params.workspaceData ? this.formatWorkspaceData(params.workspaceData) : ''; + const workspaceItem: InheritedContextItem | null = workspaceText + ? { label: 'workspace context', text: workspaceText } + : null; + if (workspaceItem) items.push(workspaceItem); + items.push(...contextFiles); + + const fitted = fitInheritedContext(items, params.contextWindow); + const includedFiles = fitted.included.filter(item => item !== workspaceItem); + return { + workspaceContext: workspaceItem && fitted.included.includes(workspaceItem) ? workspaceItem.text : undefined, + contextFilesContent: includedFiles.length > 0 ? includedFiles.map(item => item.text).join('\n\n') : undefined, + omittedLine: fitted.omitted.length > 0 && params.contextWindow !== undefined + ? formatOmittedContextLine(fitted.omitted, params.contextWindow) + : undefined + }; + } + /** * Build system prompt for subagent * @param params Subagent parameters * @param toolSchemas Pre-fetched tool schemas to include (optional) - * @param contextFilesContent Content from context files to include (optional) + * @param inherited Workspace context, context files and the omitted-items line, already budgeted */ private buildSystemPrompt( params: SubagentParams, toolSchemas?: string, - contextFilesContent?: string + inherited: { workspaceContext?: string; contextFilesContent?: string; omittedLine?: string } = {} ): string { // Determine if tools were pre-loaded by parent const hasPreloadedTools = toolSchemas && toolSchemas.length > 0; @@ -549,17 +591,16 @@ ${params.task} 5. **Use thinking/reasoning internally** - Deliberate in your thinking, not in your text output.`); - // 3. Add workspace context if available - if (params.workspaceData) { - const workspaceContext = this.formatWorkspaceData(params.workspaceData); - if (workspaceContext) { - promptParts.push(`## Workspace Context\n${workspaceContext}`); - } + // 3. Add workspace context if available (and within the context budget) + if (inherited.workspaceContext) { + promptParts.push(`## Workspace Context\n${inherited.workspaceContext}`); } // 4. Add context files content if available (from parent's notes + tool params) - if (contextFilesContent) { - promptParts.push(`## Reference Files\nThe following files have been provided as context:\n\n${contextFilesContent}`); + if (inherited.contextFilesContent) { + promptParts.push(`## Reference Files\nThe following files have been provided as context:\n\n${inherited.contextFilesContent}${inherited.omittedLine ? `\n\n${inherited.omittedLine}` : ''}`); + } else if (inherited.omittedLine) { + promptParts.push(`## Reference Files\n${inherited.omittedLine}`); } // 5. Add tool section based on whether tools were pre-loaded @@ -668,8 +709,8 @@ BEGIN - Start by calling getTools to discover available tools.`); /** * Read context files */ - private async readContextFiles(files: string[]): Promise { - const contents: string[] = []; + private async readContextFiles(files: string[]): Promise { + const contents: InheritedContextItem[] = []; for (const file of files) { try { @@ -683,16 +724,16 @@ BEGIN - Start by calling getTools to discover available tools.`); const result = results[0]; if (result?.success && (result.result as { content?: string })?.content) { - contents.push(`--- ${file} ---\n${(result.result as { content: string }).content}`); + contents.push({ label: file, text: `--- ${file} ---\n${(result.result as { content: string }).content}` }); } else { - contents.push(`--- ${file} --- (failed to read)`); + contents.push({ label: file, text: `--- ${file} --- (failed to read)` }); } } catch { - contents.push(`--- ${file} --- (error reading file)`); + contents.push({ label: file, text: `--- ${file} --- (error reading file)` }); } } - return contents.join('\n\n'); + return contents; } /** diff --git a/src/services/chat/subagentContextBudget.ts b/src/services/chat/subagentContextBudget.ts new file mode 100644 index 000000000..3d9f50321 --- /dev/null +++ b/src/services/chat/subagentContextBudget.ts @@ -0,0 +1,84 @@ +/** + * Fit a subagent's inherited context into its context window. + * + * A subagent inherits the parent chat's workspace data and context notes, and + * those go into its system prompt in full. With a subagent context window set + * (Settings → Defaults → Subagents, or per chat), that inherited context must + * not crowd out the task: whole items are dropped once the budget is spent and + * the prompt says which ones, so the subagent knows to read them itself if it + * needs them. Items are never cut in half. There is no mid-run compaction. + */ + +export interface InheritedContextItem { + /** Shown in the omitted line, e.g. a note path or "workspace context". */ + label: string; + text: string; +} + +export interface FittedInheritedContext { + included: InheritedContextItem[]; + omitted: string[]; + budgetTokens: number; +} + +/** + * Share of the window kept free for the subagent's instructions, tool + * schemas, its own tool results and the reply. + */ +const RESERVED_SHARE = 0.25; +const MIN_RESERVED_TOKENS = 4096; + +/** Same estimate the context budget service uses: about four characters a token. */ +export function estimateInheritedTokens(text: string): number { + return text ? Math.ceil(text.length / 4) : 0; +} + +/** Tokens the inherited context may use inside a window of `contextWindow`. */ +export function inheritedContextBudget(contextWindow: number): number { + if (!Number.isFinite(contextWindow) || contextWindow <= 0) return 0; + const reserved = Math.max(MIN_RESERVED_TOKENS, Math.floor(contextWindow * RESERVED_SHARE)); + return Math.max(0, Math.floor(contextWindow) - reserved); +} + +/** + * Keep items in order while they fit; skip any item that does not, and keep + * trying the later (possibly smaller) ones. `contextWindow` undefined means + * no budget, so everything is kept. + */ +export function fitInheritedContext( + items: InheritedContextItem[], + contextWindow: number | undefined +): FittedInheritedContext { + if (contextWindow === undefined) { + return { included: [...items], omitted: [], budgetTokens: Number.POSITIVE_INFINITY }; + } + + const budgetTokens = inheritedContextBudget(contextWindow); + let remaining = budgetTokens; + const included: InheritedContextItem[] = []; + const omitted: string[] = []; + for (const item of items) { + const cost = estimateInheritedTokens(item.text); + if (cost <= remaining) { + included.push(item); + remaining -= cost; + } else { + omitted.push(item.label); + } + } + return { included, omitted, budgetTokens }; +} + +/** "32K", "200K", "1M" — the same short form the settings readout uses. */ +export function formatTokenCount(tokens: number): string { + if (tokens >= 1_000_000) { + return `${Number((tokens / 1_000_000).toFixed(1))}M`; + } + if (tokens >= 1000) return `${Math.round(tokens / 1000)}K`; + return String(tokens); +} + +/** The single line that tells the subagent what was left out. */ +export function formatOmittedContextLine(omitted: string[], contextWindow: number): string { + return `Omitted to fit this subagent's ${formatTokenCount(contextWindow)} context window: ${omitted.join(', ')}. Read them with tools if the task needs them.`; +} diff --git a/src/services/embeddings/ConversationIndexer.ts b/src/services/embeddings/ConversationIndexer.ts index 9d33bf52a..235155c97 100644 --- a/src/services/embeddings/ConversationIndexer.ts +++ b/src/services/embeddings/ConversationIndexer.ts @@ -42,6 +42,29 @@ interface BackfillStateRow { /** Primary key used in the embedding_backfill_state table */ const CONVERSATION_BACKFILL_ID = 'conversation_backfill'; +/** The conversation columns the backfill reads. */ +interface ConversationRow { + id: string; + metadataJson: string | null; + workspaceId: string | null; + sessionId: string | null; +} + +/** + * Branch conversations (metadata carries parentConversationId) are variants of + * their parent and are not indexed on their own. Malformed metadata counts as + * a non-branch. + */ +function isNotBranch(conv: ConversationRow): boolean { + if (!conv.metadataJson) return true; + try { + const metadata = JSON.parse(conv.metadataJson) as Record; + return !metadata.parentConversationId; + } catch { + return true; + } +} + /** * Progress callback signature emitted by the indexer to the owning queue. */ @@ -140,39 +163,30 @@ export class ConversationIndexer { checkpointProcessed = existingState?.processedConversations ?? 0; checkpointTotal = existingState?.totalConversations ?? 0; - // If already completed, nothing to do + // A completed backfill is not the end of the story. Conversations can + // reach this cache after the backfill finished: replayed from the event + // store after a cache rebuild, arrived by sync from another device, or + // simply not yet replayed when the backfill ran. Index exactly those, + // found by an anti-join, rather than walking the whole history again. if (existingState && existingState.status === 'completed') { - return { total: 0, processed: 0 }; + return await this.catchUp(existingState, saveCadence, yieldInterval); } // Get all non-branch conversations, newest first - const allConversations = await this.db.query<{ - id: string; - metadataJson: string | null; - workspaceId: string | null; - sessionId: string | null; - }>( + const allConversations = await this.db.query( 'SELECT id, metadataJson, workspaceId, sessionId FROM conversations ORDER BY created DESC' ); // Filter out branch conversations (those with parentConversationId) - const nonBranchConversations = allConversations.filter(conv => { - if (!conv.metadataJson) return true; - try { - const metadata = JSON.parse(conv.metadataJson) as Record; - return !metadata.parentConversationId; - } catch { - return true; - } - }); - + const nonBranchConversations = allConversations.filter(isNotBranch); + + // An empty table is not a finished backfill. It is far more often a cache + // whose conversations have not been replayed yet (a fresh or rebuilding + // cache) than a vault with no chats at all. Recording 'completed' here + // used to block history from ever being indexed: every later start saw + // the completed row and returned before looking at a single + // conversation. Leave the state as it is and look again next time. if (nonBranchConversations.length === 0) { - await this.updateBackfillState({ - status: 'completed', - totalConversations: 0, - processedConversations: 0, - lastProcessedConversationId: null, - }); return { total: 0, processed: 0 }; } @@ -296,6 +310,90 @@ export class ConversationIndexer { } } + /** + * Index the conversations a completed backfill never saw. + * + * Candidates are non-branch conversations that have at least one message + * and no row at all in conversation_embedding_metadata. That is one indexed + * anti-join, so a start with nothing to do costs a single query and writes + * nothing. Embedding stays idempotent per QA pair, so a conversation the + * live watcher already covered in part is never re-embedded at full cost. + * + * Resuming needs no checkpoint: a conversation that was indexed before an + * interruption has rows and drops out of the next run's anti-join. A + * conversation that yields no QA pairs (a question with no reply yet) stays + * a candidate, which costs one message read per start and no embedding. + */ + private async catchUp( + existingState: BackfillStateRow, + saveCadence: SaveCadence, + yieldInterval: number + ): Promise<{ total: number; processed: number }> { + const missing = await this.db.query( + `SELECT c.id, c.metadataJson, c.workspaceId, c.sessionId + FROM conversations c + WHERE NOT EXISTS ( + SELECT 1 FROM conversation_embedding_metadata m + WHERE m.conversationId = c.id + ) + AND EXISTS ( + SELECT 1 FROM messages msg + WHERE msg.conversationId = c.id + ) + ORDER BY c.created DESC` + ); + const candidates = missing.filter(isNotBranch); + if (candidates.length === 0) { + return { total: 0, processed: 0 }; + } + + this.isRunning = true; + const total = candidates.length; + let processed = 0; + this.onProgress({ totalConversations: total, processedConversations: 0 }); + + for (let i = 0; i < total; i++) { + if (this.abortSignal?.aborted) { + break; + } + const conv = candidates[i]; + try { + await this.backfillConversation( + conv.id, + conv.workspaceId ?? undefined, + conv.sessionId ?? undefined + ); + } catch (error) { + console.error( + `[ConversationIndexer] Failed to backfill conversation ${conv.id}:`, + error + ); + } + processed++; + saveCadence.recordItem(); + this.onProgress({ totalConversations: total, processedConversations: processed }); + + if (saveCadence.shouldSave()) { + await this.persistCache(saveCadence, 'periodic'); + } + if (i > 0 && i % yieldInterval === 0) { + await new Promise(r => window.setTimeout(r, 0)); + } + } + + // The row stays 'completed' and keeps its checkpoint; only the counts + // move, so they describe how much history the backfill has covered. + await this.updateBackfillState({ + status: 'completed', + totalConversations: (existingState.totalConversations ?? 0) + processed, + processedConversations: (existingState.processedConversations ?? 0) + processed, + lastProcessedConversationId: existingState.lastProcessedConversationId, + }); + await this.persistCache(saveCadence, 'final'); + + return { total, processed }; + } + /** * Write a cache snapshot. Never throws, and names persistence and a byte * count when it fails. Same contract as IndexingQueue.persistCache. diff --git a/src/services/embeddings/EmbeddingEngine.ts b/src/services/embeddings/EmbeddingEngine.ts index af8668be9..63370ae64 100644 --- a/src/services/embeddings/EmbeddingEngine.ts +++ b/src/services/embeddings/EmbeddingEngine.ts @@ -20,6 +20,8 @@ import { EmbeddingIframe } from './EmbeddingIframe'; */ export class EmbeddingEngine { private iframe: EmbeddingIframe | null = null; + /** Set by dispose(). A disposed engine never loads the model again. */ + private disposed = false; private readonly MODEL_ID = 'Xenova/all-MiniLM-L6-v2'; private readonly DIMENSIONS = 384; @@ -28,6 +30,7 @@ export class EmbeddingEngine { * Downloads model on first use (~23MB, cached in IndexedDB) */ async initialize(): Promise { + this.assertNotDisposed(); if (this.iframe?.ready()) { return; } @@ -43,6 +46,7 @@ export class EmbeddingEngine { * @returns Float32Array of 384 dimensions */ async generateEmbedding(text: string): Promise { + this.assertNotDisposed(); if (!this.iframe) { await this.initialize(); } @@ -62,6 +66,7 @@ export class EmbeddingEngine { * @returns Array of Float32Array embeddings */ async generateEmbeddings(texts: string[]): Promise { + this.assertNotDisposed(); if (!this.iframe) { await this.initialize(); } @@ -78,12 +83,23 @@ export class EmbeddingEngine { * Dispose of the engine to free memory */ async dispose(): Promise { + this.disposed = true; if (this.iframe) { await this.iframe.dispose(); this.iframe = null; } } + /** + * A caller that kept a reference past dispose() gets an error instead of a + * fresh hidden iframe with the model loaded that nothing would dispose. + */ + private assertNotDisposed(): void { + if (this.disposed) { + throw new Error('Embedding engine was disposed'); + } + } + /** * Check if engine is initialized */ diff --git a/src/services/embeddings/EmbeddingIframe.ts b/src/services/embeddings/EmbeddingIframe.ts index c0b55195a..11b4b72a6 100644 --- a/src/services/embeddings/EmbeddingIframe.ts +++ b/src/services/embeddings/EmbeddingIframe.ts @@ -79,6 +79,15 @@ export class EmbeddingIframe { } private async doInitialize(): Promise { + // The iframe and its message listener live in the main window, never in + // window.activeDocument. The iframe posts to its own `parent`, so the two + // must share a window: appended to a focused popout or the Settings window + // (its own window since Obsidian 1.14), the ready message reaches that + // window, the listener below never sees it, and initialization times out. + // It would also die with that window when it closes. Nothing here is + // rendered, so the main window is always the right host. + const host = window; + // Create the iframe HTML that will load transformers.js const iframeHtml = this.createIframeHtml(); @@ -98,35 +107,42 @@ export class EmbeddingIframe { this.handleMessage(event.data); } }; - window.addEventListener('message', this.messageHandler); - - // Wait for iframe to load and initialize - await new Promise((resolve, reject) => { - const timeout = window.setTimeout(() => { - reject(new Error('Iframe initialization timeout (60s)')); - }, 60000); - - // Store the resolve for when we get the ready message - this.pendingRequests.set(-1, { - resolve: () => { - window.clearTimeout(timeout); - resolve(); - }, - reject: (err: Error) => { - window.clearTimeout(timeout); - reject(err); - } - }); + host.addEventListener('message', this.messageHandler); - const iframe = this.iframe; - const blobUrl = this.blobUrl; - if (!iframe || !blobUrl) { - throw new Error('Iframe initialization failed'); - } + try { + // Wait for iframe to load and initialize + await new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + reject(new Error('Iframe initialization timeout (60s)')); + }, 60000); + + // Store the resolve for when we get the ready message + this.pendingRequests.set(-1, { + resolve: () => { + window.clearTimeout(timeout); + resolve(); + }, + reject: (err: Error) => { + window.clearTimeout(timeout); + reject(err); + } + }); + + const iframe = this.iframe; + const blobUrl = this.blobUrl; + if (!iframe || !blobUrl) { + throw new Error('Iframe initialization failed'); + } - iframe.src = blobUrl; - window.activeDocument.body.appendChild(iframe); - }); + iframe.src = blobUrl; + host.document.body.appendChild(iframe); + }); + } catch (error) { + // Drop the half-started iframe, so a retry starts clean instead of + // stacking a second hidden iframe and listener on top of this one. + this.releaseFrame(); + throw error; + } } /** @@ -323,18 +339,27 @@ export class EmbeddingIframe { * Dispose of the iframe */ async dispose(): Promise { - // Remove window message listener + // Only a ready iframe can answer. Asking one that never finished loading + // waits out the 30s request timeout, which held up turning search off. + if (this.iframe && this.isReady) { + try { + await this.sendRequest({ method: 'dispose' }); + } catch { + // Ignore errors during disposal + } + } + + this.releaseFrame(); + } + + /** Remove the listener and the iframe, revoke the blob URL, forget requests. */ + private releaseFrame(): void { if (this.messageHandler) { window.removeEventListener('message', this.messageHandler); this.messageHandler = null; } if (this.iframe) { - try { - await this.sendRequest({ method: 'dispose' }); - } catch { - // Ignore errors during disposal - } this.iframe.remove(); this.iframe = null; } @@ -346,7 +371,14 @@ export class EmbeddingIframe { } this.isReady = false; + + // Settle anything still waiting, rather than leaving it to a timeout that + // no longer finds its entry and so never rejects. + const pending = Array.from(this.pendingRequests.values()); this.pendingRequests.clear(); + for (const request of pending) { + request.reject(new Error('Embedding iframe was disposed')); + } } /** diff --git a/src/services/embeddings/EmbeddingManager.ts b/src/services/embeddings/EmbeddingManager.ts index 8cf198677..8877010bc 100644 --- a/src/services/embeddings/EmbeddingManager.ts +++ b/src/services/embeddings/EmbeddingManager.ts @@ -7,19 +7,22 @@ * - Lazy initialization (3-second delay on startup) * - Coordinates EmbeddingEngine, EmbeddingService, EmbeddingWatcher, * ConversationEmbeddingWatcher, IndexingQueue, and StatusBar + * - Can be turned off and on at runtime (setEnabled) without a restart + * - Publishes its status and indexing progress (onStateChange, getStatus) + * - Deletes the stored vectors on request while turned off (deleteIndex) * - Graceful shutdown with cleanup * * Relationships: - * - Called by PluginLifecycleManager for initialization + * - Called by PluginLifecycleManager for initialization and hot-apply * - Manages all embedding system components */ -import { App, Plugin, Platform, Notice } from 'obsidian'; +import { App, Plugin, Platform, Notice, Events, type EventRef } from 'obsidian'; import { EmbeddingEngine } from './EmbeddingEngine'; import { EmbeddingService } from './EmbeddingService'; import { EmbeddingWatcher } from './EmbeddingWatcher'; import { ConversationEmbeddingWatcher } from './ConversationEmbeddingWatcher'; -import { IndexingQueue } from './IndexingQueue'; +import { IndexingQueue, type IndexingProgress } from './IndexingQueue'; import { EmbeddingStatusBar } from './EmbeddingStatusBar'; import { createRetrievalDreamService, RetrievalDream } from './adapter/createRetrievalDreamService'; import type { SQLiteCacheManager } from '../../database/storage/SQLiteCacheManager'; @@ -36,6 +39,43 @@ interface PluginWithSettings { /** How often a dream-consolidation cycle runs (ms). */ const DREAM_INTERVAL_MS = 45 * 60 * 1000; +/** Command id of the manual "dream now" command; removed again on stop. */ +const DREAM_COMMAND_ID = 'consolidate-retrieval-memory'; + +/** Event name used on the manager's private Events bus. */ +const STATE_EVENT = 'state-change'; + +/** + * Snapshot of the embedding runtime, for status surfaces. + * + * `enabled` is whether semantic search is turned on for this device (always + * false on mobile). `initialized` is whether the engine, watchers and queue are + * running. `progress` is the latest indexing progress event, or null when + * nothing has been reported since the last start. + */ +export interface EmbeddingRuntimeStatus { + enabled: boolean; + initialized: boolean; + indexing: boolean; + paused: boolean; + progress: IndexingProgress | null; +} + +/** Stored index sizes plus runtime state. Counts are read even while turned off. */ +export interface EmbeddingStats { + enabled: boolean; + initialized: boolean; + noteCount: number; + traceCount: number; + conversationChunkCount: number; + /** Distinct conversations that have at least one embedded chunk. */ + conversationCount: number; + indexingInProgress: boolean; + indexingPaused: boolean; +} + +export type EmbeddingStateListener = (status: EmbeddingRuntimeStatus) => void; + /** * Embedding system manager * @@ -67,6 +107,28 @@ export class EmbeddingManager { private backgroundIndexingWork: Promise | null = null; private shuttingDown = false; + /** + * The periodic dream-cycle interval. Kept so a runtime stop can clear it; + * registerInterval alone only clears it at plugin unload. + */ + private dreamInterval: number | null = null; + private dreamCommandRegistered = false; + + /** + * Bumped by every teardown. A background indexing chain captures the value + * it started under and stops between phases once it changes, so turning + * semantic search off cannot let the next phase start against a torn-down + * queue. + */ + private runGeneration = 0; + + /** Serializes setEnabled/deleteIndex so rapid toggles apply in order. */ + private transition: Promise = Promise.resolve(); + + private readonly events = new Events(); + private queueProgressRef: EventRef | null = null; + private lastProgress: IndexingProgress | null = null; + constructor( app: App, plugin: Plugin, @@ -99,6 +161,13 @@ export class EmbeddingManager { this.watcher = new EmbeddingWatcher(this.app, this.service); this.queue = new IndexingQueue(this.app, this.service, this.db); this.statusBar = new EmbeddingStatusBar(this.plugin, this.queue); + this.lastProgress = null; + + // Relay queue progress to status subscribers (the settings fold). + this.queueProgressRef = this.queue.on('progress', (...data: unknown[]) => { + this.lastProgress = (data[0] as IndexingProgress | undefined) ?? null; + this.emitState(); + }); // Initialize status bar (desktop only) this.statusBar.init(); @@ -134,6 +203,112 @@ export class EmbeddingManager { console.error('[EmbeddingManager] Initialization failed:', error); // Don't throw - embeddings are optional functionality } + + this.emitState(); + } + + /** + * Turn semantic search on or off for this session, without a restart. + * + * Turning on builds fresh components and schedules indexing; the index kept + * on this device means only notes that changed are re-embedded, and the + * prune pass drops vectors of notes deleted or renamed while it was off. + * Turning off stops the watchers, the queue, the status bar, the dream cycle + * and the engine, and leaves the stored vectors in place (see deleteIndex). + * + * Idempotent, a no-op on mobile and after shutdown(), and serialized: two + * quick toggles apply in the order they were made. Turning on while on but + * not running (an earlier start failed) tries again. + * + * @throws when the change could not be applied, so the caller can say so. + * Later toggles still run: the queue keeps a settled copy. + */ + setEnabled(enabled: boolean): Promise { + const next = this.transition.then(async () => { + if (this.shuttingDown || Platform.isMobile) { + return; + } + + if (enabled) { + if (this.isEnabled && this.isInitialized) { + return; + } + this.isEnabled = true; + this.initialize(); + if (!this.isInitialized) { + // initialize() logs and swallows its own error. Drop whatever it + // built before failing, so a retry starts clean. + await this.teardown(); + this.emitState(); + throw new Error('Semantic search could not start.'); + } + return; + } + + if (!this.isEnabled) { + return; + } + this.isEnabled = false; + await this.teardown(); + this.emitState(); + }); + this.transition = next.catch((error) => { + console.error('[EmbeddingManager] Failed to apply the semantic search setting:', error); + }); + return next; + } + + /** + * Start semantic search over, from fresh components, while it stays turned + * on. For a pass that ended with phase 'error' (the embedding model did not + * load): the manager still counts as running then, so setEnabled(true) is a + * no-op, and the service has turned itself off for good. + * + * A no-op while turned off, on mobile and after shutdown(). Serialized with + * setEnabled and deleteIndex. + * + * @throws when the components could not be started again. + */ + retry(): Promise { + const next = this.transition.then(async () => { + if (this.shuttingDown || Platform.isMobile || !this.isEnabled) { + return; + } + await this.teardown(); + this.initialize(); + if (!this.isInitialized) { + await this.teardown(); + this.emitState(); + throw new Error('Semantic search could not start.'); + } + }); + this.transition = next.catch((error) => { + console.error('[EmbeddingManager] Failed to start semantic search again:', error); + }); + return next; + } + + /** + * Delete the stored note, trace and conversation vectors on this device. + * + * Allowed only while semantic search is turned off, so it can never race the + * indexing queue. The learned retrieval adapter (AdapterStore) is kept: it is + * a separate file, and it stays valid for the same embedding model. + * + * @throws Error when semantic search is on. + */ + async deleteIndex(): Promise { + const run = this.transition.then(async () => { + if (this.isEnabled || this.isInitialized) { + throw new Error('Turn semantic search off before deleting its index.'); + } + await this.db.clearEmbeddingIndex(); + this.lastProgress = null; + }); + // A failed delete must not wedge later toggles behind a rejected promise. + this.transition = run.catch(() => undefined); + await run; + this.emitState(); } /** @@ -142,21 +317,58 @@ export class EmbeddingManager { */ async shutdown(): Promise { this.shuttingDown = true; - // Cleared before the isEnabled bail-out on purpose: this timer is the one - // thing that can still fire after shutdown() returns, so it is cancelled + // Cleared before anything else on purpose: this timer is the one thing + // that can still fire after shutdown() returns, so it is cancelled // unconditionally rather than behind a flag that may have changed. if (this.backgroundIndexingTimer !== null) { window.clearTimeout(this.backgroundIndexingTimer); this.backgroundIndexingTimer = null; } - if (!this.isEnabled) { - return; + try { + // Let an in-flight toggle or delete finish before tearing down, so the + // two never interleave against the same components. + await this.transition; + await this.teardown(); + } catch (error) { + console.error('[EmbeddingManager] Shutdown failed:', error); + } finally { + // Even a failing watcher or engine cleanup must not leave the accepted + // background phase writing after the lifecycle closes its cache. + await this.backgroundIndexingWork; } + } + + /** + * Stop every running component and drop it. Shared by shutdown() and by + * turning semantic search off. Safe to call when nothing is running. + * + * Order matters: cancel and unsubscribe first, then wait for the indexing + * phase that was already running to persist its final progress, and only + * then dispose the engine it may still be using. + */ + private async teardown(): Promise { + this.runGeneration++; + + // Anything still holding this service (an open task modal, a dream cycle or + // a search already under way) must get the disabled answers from here on, + // not wake the engine back up after it is disposed below. + this.service?.disable(); + + if (this.backgroundIndexingTimer !== null) { + window.clearTimeout(this.backgroundIndexingTimer); + this.backgroundIndexingTimer = null; + } + + this.stopRetrievalLearning(); try { // Cancel indexing and remove all listeners if (this.queue) { + if (this.queueProgressRef) { + this.queue.offref(this.queueProgressRef); + this.queueProgressRef = null; + } this.queue.destroy(); } @@ -170,7 +382,7 @@ export class EmbeddingManager { await this.conversationWatcher.shutdown(); } - // Clean up status bar (removes progress listener) + // Clean up status bar (removes progress listener and the item itself) if (this.statusBar) { this.statusBar.destroy(); } @@ -183,15 +395,15 @@ export class EmbeddingManager { if (this.engine) { await this.engine.dispose(); } - - this.isInitialized = false; - - } catch (error) { - console.error('[EmbeddingManager] Shutdown failed:', error); } finally { - // Even a failing watcher or engine cleanup must not leave the accepted - // background phase writing after the lifecycle closes its cache. - await this.backgroundIndexingWork; + this.engine = null; + this.service = null; + this.watcher = null; + this.conversationWatcher = null; + this.queue = null; + this.statusBar = null; + this.isInitialized = false; + this.lastProgress = null; } } @@ -260,39 +472,105 @@ export class EmbeddingManager { } /** - * Get statistics about the embedding system + * Whether semantic search is turned on for this device, whether or not the + * engine has finished starting. Always false on mobile. + */ + isTurnedOn(): boolean { + return this.isEnabled; + } + + /** Synchronous snapshot for a first paint; subscribe for updates. */ + getStatus(): EmbeddingRuntimeStatus { + return { + enabled: this.isEnabled, + initialized: this.isInitialized, + indexing: this.queue?.isIndexing() ?? false, + paused: this.queue?.isIndexingPaused() ?? false, + progress: this.lastProgress + }; + } + + /** + * Current indexing progress: the queue's own view while it is running, + * otherwise the last event it reported, otherwise null. + */ + getProgress(): IndexingProgress | null { + if (this.queue?.isIndexing()) { + return this.queue.getProgress(); + } + return this.lastProgress; + } + + /** Pause the running indexing pass. A no-op when nothing is indexing. */ + pauseIndexing(): void { + this.queue?.pause(); + this.emitState(); + } + + /** Resume a paused indexing pass. */ + resumeIndexing(): void { + this.queue?.resume(); + this.emitState(); + } + + /** + * Subscribe to state changes: start, stop, index deletion and every + * indexing progress event. + * + * @returns A function that removes the listener. */ - async getStats(): Promise<{ - enabled: boolean; - initialized: boolean; - noteCount: number; - traceCount: number; - conversationChunkCount: number; - indexingInProgress: boolean; - }> { - if (!this.isEnabled || !this.service) { - return { - enabled: false, - initialized: false, - noteCount: 0, - traceCount: 0, - conversationChunkCount: 0, - indexingInProgress: false - }; + onStateChange(listener: EmbeddingStateListener): () => void { + const ref = this.events.on(STATE_EVENT, (...data: unknown[]) => { + listener(data[0] as EmbeddingRuntimeStatus); + }); + return () => this.events.offref(ref); + } + + private emitState(): void { + try { + this.events.trigger(STATE_EVENT, this.getStatus()); + } catch (error) { + console.error('[EmbeddingManager] A state listener failed:', error); } + } - const stats = await this.service.getStats(); + /** + * Get statistics about the embedding system. + * + * The counts come straight from the cache database, so they are reported + * while semantic search is turned off as well: the off state shows how much + * index is being kept. Each count answers 0 on its own when its table cannot + * be read. + */ + async getStats(): Promise { + const [noteCount, traceCount, conversationChunkCount, conversationCount] = await Promise.all([ + this.countOrZero('SELECT COUNT(*) as count FROM embedding_metadata'), + this.countOrZero('SELECT COUNT(*) as count FROM trace_embedding_metadata'), + this.countOrZero('SELECT COUNT(*) as count FROM conversation_embedding_metadata'), + this.countOrZero('SELECT COUNT(DISTINCT conversationId) as count FROM conversation_embedding_metadata') + ]); return { enabled: this.isEnabled, initialized: this.isInitialized, - noteCount: stats.noteCount, - traceCount: stats.traceCount, - conversationChunkCount: stats.conversationChunkCount, - indexingInProgress: this.queue?.isIndexing() ?? false + noteCount, + traceCount, + conversationChunkCount, + conversationCount, + indexingInProgress: this.queue?.isIndexing() ?? false, + indexingPaused: this.queue?.isIndexingPaused() ?? false }; } + private async countOrZero(sql: string): Promise { + try { + const row = await this.db.queryOne<{ count: number }>(sql); + return Number(row?.count ?? 0) || 0; + } catch { + return 0; + } + } + /** * Set up the self-improving retrieval adapter: load any persisted adapter, * schedule periodic dream cycles, and register a manual "dream now" command. @@ -317,24 +595,51 @@ export class EmbeddingManager { console.error('[EmbeddingManager] Failed to load retrieval adapter:', error); }); - // Periodic consolidation, unless explicitly disabled. + // Periodic consolidation, unless explicitly disabled. The id is kept so + // turning semantic search off can clear it; registerInterval still + // covers plugin unload. if (pluginSettings?.embeddings?.retrievalLearning !== false) { const interval = window.setInterval(() => { void this.runDreamCycle(false); }, DREAM_INTERVAL_MS); + this.dreamInterval = interval; this.plugin.registerInterval(interval); } this.plugin.addCommand({ - id: 'consolidate-retrieval-memory', + id: DREAM_COMMAND_ID, name: 'Consolidate retrieval memory (dream now)', callback: () => { void this.runDreamCycle(true); } }); + this.dreamCommandRegistered = true; } catch (error) { console.error('[EmbeddingManager] Failed to initialize retrieval learning:', error); } } + /** + * Undo initRetrievalLearning: clear the dream interval and remove the + * command, so a stopped manager leaves nothing scheduled or advertised. + * The persisted adapter file is not touched. + */ + private stopRetrievalLearning(): void { + if (this.dreamInterval !== null) { + window.clearInterval(this.dreamInterval); + this.dreamInterval = null; + } + + if (this.dreamCommandRegistered) { + this.dreamCommandRegistered = false; + try { + this.plugin.removeCommand(DREAM_COMMAND_ID); + } catch (error) { + console.error('[EmbeddingManager] Failed to remove the dream command:', error); + } + } + + this.retrievalDream = null; + } + /** * Run a single dream-consolidation cycle. Notifies the user only when an * improved adapter is actually promoted (or on explicit manual trigger). @@ -377,11 +682,23 @@ export class EmbeddingManager { } private async runIndexingPhases(): Promise { - if (!this.queue) { + const queue = this.queue; + if (!queue) { return; } + const generation = this.runGeneration; + const stopped = (): boolean => this.shuttingDown || generation !== this.runGeneration; try { + // Prune first. Vectors of notes deleted or renamed while semantic search + // was off, and of conversations deleted meanwhile, are otherwise never + // removed: the passes below only walk what still exists. Running it on + // every pass covers startup, turning search back on and a cache rebuild. + await queue.pruneMissingNotes(); + if (stopped()) return; + await queue.pruneMissingConversations(); + if (stopped()) return; + // Cheapest-first, and deliberately so. // // The three phases are independent - conversations read conversations/ @@ -396,15 +713,15 @@ export class EmbeddingManager { // in seconds and the long vault walk runs behind them. // Phase 1: Backfill existing conversations (idempotent, resumable) - await this.queue.startConversationIndex(); - if (this.shuttingDown) return; + await queue.startConversationIndex(); + if (stopped()) return; // Phase 2: Backfill existing traces (from migration) - await this.queue.startTraceIndex(); - if (this.shuttingDown) return; + await queue.startTraceIndex(); + if (stopped()) return; // Phase 3: Index all notes - longest by far, so it goes last - await this.queue.startFullIndex(); + await queue.startFullIndex(); } catch (error) { console.error('[EmbeddingManager] Background indexing failed:', error); } diff --git a/src/services/embeddings/EmbeddingService.ts b/src/services/embeddings/EmbeddingService.ts index f8b00a232..df5b529c9 100644 --- a/src/services/embeddings/EmbeddingService.ts +++ b/src/services/embeddings/EmbeddingService.ts @@ -20,7 +20,7 @@ * - Used by ChatTraceService for trace embedding */ -import { App, Notice, Platform } from 'obsidian'; +import { App, Platform } from 'obsidian'; import { EmbeddingEngine } from './EmbeddingEngine'; import { NoteEmbeddingService } from './NoteEmbeddingService'; import { TraceEmbeddingService } from './TraceEmbeddingService'; @@ -99,8 +99,9 @@ export class EmbeddingService { try { await this.engine.initialize(); } catch (error) { + // No notice here: the indexing queue, the only caller, ends its pass + // with phase 'error' and the status bar reports that once. console.error('[EmbeddingService] Initialization failed:', error); - new Notice('Failed to load embedding model. Vector search will be unavailable.'); this.isEnabled = false; } } @@ -210,6 +211,15 @@ export class EmbeddingService { // ==================== UTILITIES ==================== + /** + * Turn this instance off for good. Called when semantic search is turned off + * or the plugin unloads: every method then returns its empty answer, so a + * caller that kept a reference never reaches the disposed engine. + */ + disable(): void { + this.isEnabled = false; + } + /** * Check if service is enabled */ diff --git a/src/services/embeddings/EmbeddingStatusBar.ts b/src/services/embeddings/EmbeddingStatusBar.ts index ff5fb3bd3..e0a099538 100644 --- a/src/services/embeddings/EmbeddingStatusBar.ts +++ b/src/services/embeddings/EmbeddingStatusBar.ts @@ -14,7 +14,41 @@ */ import { Plugin, Notice, Platform, setIcon } from 'obsidian'; -import { IndexingQueue, IndexingProgress } from './IndexingQueue'; +import { IndexingQueue, IndexingProgress, IndexingScope } from './IndexingQueue'; + +/** What the status bar calls each pass, so a trace backfill does not read as notes. */ +const SCOPE_LABEL: Record = { + notes: 'notes', + conversations: 'chats', + traces: 'memory' +}; + +/** The noun in the completion notice: "12 notes indexed", "3 chats indexed". */ +const SCOPE_NOUN: Record = { + notes: ['note', 'notes'], + conversations: ['chat', 'chats'], + traces: ['memory trace', 'memory traces'] +}; + +/** "Indexing notes: 3/117", or "Indexing: 3/117" for an event without a scope. */ +export function describeProgress(progress: IndexingProgress): string { + const counts = `${progress.processedNotes}/${progress.totalNotes}`; + const scope = progress.scope ? ` ${SCOPE_LABEL[progress.scope]}` : ''; + switch (progress.phase) { + case 'checking': + return `Checking${scope || ' notes'}: ${counts}`; + case 'paused': + return `Paused: ${counts}`; + default: + return `Indexing${scope}: ${counts}`; + } +} + +/** "Embedding complete! 117 notes indexed." */ +export function describeCompletion(progress: IndexingProgress): string { + const [one, many] = SCOPE_NOUN[progress.scope ?? 'notes']; + return `Embedding complete! ${progress.processedNotes} ${progress.processedNotes === 1 ? one : many} indexed.`; +} /** * Status bar for embedding progress @@ -93,25 +127,22 @@ export class EmbeddingStatusBar { this.setControlIcon(''); break; + case 'checking': case 'indexing': this.show(); - this.setText( - `Indexing: ${progress.processedNotes}/${progress.totalNotes}` - ); + this.setText(describeProgress(progress)); this.setControlIcon('pause', () => this.indexingQueue.pause()); break; case 'paused': this.show(); - this.setText( - `Paused: ${progress.processedNotes}/${progress.totalNotes}` - ); + this.setText(describeProgress(progress)); this.setControlIcon('play', () => this.indexingQueue.resume()); break; case 'complete': if (progress.processedNotes > 0) { - new Notice(`Embedding complete! ${progress.processedNotes} notes indexed.`); + new Notice(describeCompletion(progress)); } this.hide(); break; diff --git a/src/services/embeddings/IndexingQueue.ts b/src/services/embeddings/IndexingQueue.ts index 516927277..01b45afaa 100644 --- a/src/services/embeddings/IndexingQueue.ts +++ b/src/services/embeddings/IndexingQueue.ts @@ -36,12 +36,21 @@ import { type CacheSaveStage } from './CacheSavePolicy'; +/** What a pass is working through. Absent on events that predate it. */ +export type IndexingScope = 'notes' | 'conversations' | 'traces'; + export interface IndexingProgress { - phase: 'idle' | 'loading_model' | 'indexing' | 'complete' | 'paused' | 'error'; + /** + * 'checking' is the note pass comparing every note's content hash with the + * index before it knows what needs embedding: processed/total count notes + * compared, not notes embedded. 'indexing' counts only real work. + */ + phase: 'idle' | 'loading_model' | 'checking' | 'indexing' | 'complete' | 'paused' | 'error'; totalNotes: number; processedNotes: number; currentNote: string | null; estimatedTimeRemaining: number | null; // seconds + scope?: IndexingScope; error?: string; } @@ -69,8 +78,8 @@ export class IndexingQueue extends Events { * awaits three phases in sequence and each one mints a fresh AbortController, * so aborting phase 1 does not stop phases 2 and 3 - they start *after* the * unload and iterate against a SQLiteCacheManager whose handle close() already - * nulled. `isRunning` is also false during filterUnindexedNotes(), which is one - * db.queryOne per markdown file. The result is the "Database not initialized" + * nulled. filterUnindexedNotes() is one db.queryOne per markdown file and + * checks this flag as well as its controller. The result is the "Database not initialized" * burst that grows with every reload, exactly as NotesIndexBuilder.stop() * documents for the notes index. */ @@ -90,11 +99,16 @@ export class IndexingQueue extends Events { */ private readonly MIN_SAVE_ITEMS = 10; private readonly CONVERSATION_YIELD_INTERVAL = 5; // Yield every N conversations during backfill + /** Report the note hash check every N notes; one event per note is noise. */ + private readonly CHECK_PROGRESS_INTERVAL = 100; private processedCount = 0; private totalCount = 0; private startTime = 0; private processingTimes: number[] = []; // Rolling average for ETA + /** The pass in flight, so getProgress() and pause() describe it honestly. */ + private scope: IndexingScope = 'notes'; + private checking = false; // Domain indexers (created lazily in their start methods) private traceIndexer: TraceIndexer | null = null; @@ -132,8 +146,32 @@ export class IndexingQueue extends Events { const allNotes = this.app.vault.getMarkdownFiles(); - // Filter to notes not already indexed (or with changed content) - const needsIndexing = await this.filterUnindexedNotes(allNotes); + // Filter to notes not already indexed (or with changed content). On a + // large vault this takes minutes, so it runs as a pass of its own: it + // reports 'checking' with its own counts, and it can be paused and + // cancelled like the embedding that follows. + this.isRunning = true; + this.scope = 'notes'; + this.abortController = this.createAbortController(); + let needsIndexing: TFile[]; + try { + needsIndexing = await this.filterUnindexedNotes(allNotes); + } finally { + this.checking = false; + this.isRunning = false; + } + + if (this.destroyed || this.abortController.signal.aborted) { + this.emitProgress({ + phase: 'idle', + totalNotes: 0, + processedNotes: 0, + currentNote: null, + estimatedTimeRemaining: null, + scope: 'notes' + }); + return; + } if (needsIndexing.length === 0) { this.emitProgress({ @@ -141,7 +179,8 @@ export class IndexingQueue extends Events { totalNotes: 0, processedNotes: 0, currentNote: null, - estimatedTimeRemaining: null + estimatedTimeRemaining: null, + scope: 'notes' }); return; } @@ -151,7 +190,6 @@ export class IndexingQueue extends Events { this.processedCount = 0; this.startTime = Date.now(); this.processingTimes = []; - this.abortController = this.createAbortController(); await this.processQueue(); } @@ -170,6 +208,9 @@ export class IndexingQueue extends Events { } this.isRunning = true; + this.scope = 'traces'; + this.totalCount = 0; + this.processedCount = 0; this.abortController = this.createAbortController(); this.traceIndexer = new TraceIndexer( @@ -183,7 +224,8 @@ export class IndexingQueue extends Events { totalNotes: progress.totalTraces, processedNotes: progress.processedTraces, currentNote: 'traces', - estimatedTimeRemaining: null + estimatedTimeRemaining: null, + scope: 'traces' }); }, this.MIN_SAVE_ITEMS, @@ -195,7 +237,8 @@ export class IndexingQueue extends Events { totalNotes: 0, processedNotes: 0, currentNote: 'traces', - estimatedTimeRemaining: null + estimatedTimeRemaining: null, + scope: 'traces' }); try { @@ -210,7 +253,8 @@ export class IndexingQueue extends Events { totalNotes: result.total, processedNotes: result.processed, currentNote: null, - estimatedTimeRemaining: null + estimatedTimeRemaining: null, + scope: 'traces' }); } finally { this.isRunning = false; @@ -232,6 +276,9 @@ export class IndexingQueue extends Events { } this.isRunning = true; + this.scope = 'conversations'; + this.totalCount = 0; + this.processedCount = 0; this.abortController = this.createAbortController(); this.conversationIndexer = new ConversationIndexer( @@ -245,7 +292,8 @@ export class IndexingQueue extends Events { totalNotes: progress.totalConversations, processedNotes: progress.processedConversations, currentNote: 'conversations', - estimatedTimeRemaining: null + estimatedTimeRemaining: null, + scope: 'conversations' }); }, this.MIN_SAVE_ITEMS @@ -256,7 +304,8 @@ export class IndexingQueue extends Events { totalNotes: 0, processedNotes: 0, currentNote: 'conversations', - estimatedTimeRemaining: null + estimatedTimeRemaining: null, + scope: 'conversations' }); try { @@ -270,7 +319,8 @@ export class IndexingQueue extends Events { totalNotes: result.total, processedNotes: result.processed, currentNote: null, - estimatedTimeRemaining: null + estimatedTimeRemaining: null, + scope: 'conversations' }); } finally { this.isRunning = false; @@ -278,6 +328,108 @@ export class IndexingQueue extends Events { } } + // --------------------------------------------------------------------------- + // Prune passes + // --------------------------------------------------------------------------- + + /** + * Remove the vectors of notes that no longer exist in the vault. + * + * The watcher removes a vector when a note is deleted or renamed, but only + * while semantic search is running. A note deleted or renamed while it was + * off keeps its old vector forever otherwise: the note walk only visits + * files that exist, so nothing ever asks about the old path again. + * + * Refuses to prune when the vault reports no markdown files at all. That + * is far more likely to be a vault that has not finished loading than one + * whose every note was deleted, and pruning then would throw away the + * whole index. + * + * @returns The number of note vectors removed. + */ + async pruneMissingNotes(): Promise { + if (this.destroyed || !this.embeddingService.isServiceEnabled()) { + return 0; + } + + const rows = await this.db.query<{ notePath: string }>( + 'SELECT notePath FROM embedding_metadata' + ); + if (rows.length === 0 || this.app.vault.getMarkdownFiles().length === 0) { + return 0; + } + + let removed = 0; + for (const row of rows) { + if (this.destroyed) break; + if (this.app.vault.getAbstractFileByPath(row.notePath)) continue; + try { + await this.embeddingService.removeEmbedding(row.notePath); + removed++; + } catch (error) { + console.error(`[IndexingQueue] Failed to prune the vector for ${row.notePath}:`, error); + } + } + + if (removed > 0) { + await this.saveAfterPrune('notes'); + } + return removed; + } + + /** + * Remove the vectors of conversations that no longer exist in the cache. + * + * Same reasoning as pruneMissingNotes: a conversation deleted while + * semantic search was off never reached onConversationDeleted. Skipped when + * the conversations table is empty, which means the cache has not been + * replayed yet rather than that every chat was deleted. + * + * @returns The number of conversations whose vectors were removed. + */ + async pruneMissingConversations(): Promise { + if (this.destroyed || !this.embeddingService.isServiceEnabled()) { + return 0; + } + + const known = await this.db.queryOne<{ count: number }>( + 'SELECT COUNT(*) as count FROM conversations' + ); + if (!known || Number(known.count) === 0) { + return 0; + } + + const rows = await this.db.query<{ conversationId: string }>( + `SELECT DISTINCT conversationId FROM conversation_embedding_metadata + WHERE conversationId NOT IN (SELECT id FROM conversations)` + ); + + let removed = 0; + for (const row of rows) { + if (this.destroyed) break; + try { + await this.embeddingService.removeConversationEmbeddings(row.conversationId); + removed++; + } catch (error) { + console.error(`[IndexingQueue] Failed to prune vectors for conversation ${row.conversationId}:`, error); + } + } + + if (removed > 0) { + await this.saveAfterPrune('conversations'); + } + return removed; + } + + /** Persist a prune. Never throws: the rows are gone from memory either way. */ + private async saveAfterPrune(kind: 'notes' | 'conversations'): Promise { + try { + await this.db.save(); + } catch (error) { + console.error(`[IndexingQueue] Saving after pruning ${kind} failed:`, error); + } + } + // --------------------------------------------------------------------------- // Queue controls // --------------------------------------------------------------------------- @@ -294,7 +446,8 @@ export class IndexingQueue extends Events { totalNotes: this.totalCount, processedNotes: this.processedCount, currentNote: null, - estimatedTimeRemaining: null + estimatedTimeRemaining: null, + scope: this.scope }); } @@ -325,9 +478,10 @@ export class IndexingQueue extends Events { } cancel(): void { - // Deliberately NOT gated on isRunning: the gap between phases, and the whole - // of filterUnindexedNotes(), run with isRunning === false, and those are - // precisely the windows an unload has to be able to interrupt. + // Deliberately NOT gated on isRunning: the gap between phases runs with + // isRunning === false, and that is precisely a window an unload has to be + // able to interrupt. The note hash check is a running pass of its own and + // stops on this abort. this.abortController?.abort(); this.queue = []; } @@ -373,12 +527,24 @@ export class IndexingQueue extends Events { }; } + if (this.checking) { + return { + phase: this.isPaused ? 'paused' : 'checking', + totalNotes: this.totalCount, + processedNotes: this.processedCount, + currentNote: null, + estimatedTimeRemaining: null, + scope: this.scope + }; + } + return { phase: this.isPaused ? 'paused' : 'indexing', totalNotes: this.totalCount, processedNotes: this.processedCount, currentNote: this.queue.length > 0 ? this.queue[0] : null, - estimatedTimeRemaining: this.calculateETA() + estimatedTimeRemaining: this.calculateETA(), + scope: this.scope }; } @@ -387,13 +553,30 @@ export class IndexingQueue extends Events { // --------------------------------------------------------------------------- /** - * Filter to only notes that need (re)indexing + * Filter to only notes that need (re)indexing. + * + * Reports phase 'checking' with notes compared so far over the vault's note + * count, every CHECK_PROGRESS_INTERVAL notes. Without it the status bar + * showed nothing (or a stale "Indexing: 0/N") for the minutes this takes on + * a large vault, then jumped to a much smaller count of notes that changed. */ private async filterUnindexedNotes(notes: TFile[]): Promise { const needsIndexing: TFile[] = []; + this.checking = true; + this.totalCount = notes.length; + this.processedCount = 0; + this.emitCheckingProgress(); for (const note of notes) { - if (this.destroyed) break; + if (this.destroyed || this.abortController?.signal.aborted) break; + if (this.isPaused) { + await this.waitForResume(); + if (this.abortController?.signal.aborted) break; + } + this.processedCount++; + if (this.processedCount % this.CHECK_PROGRESS_INTERVAL === 0) { + this.emitCheckingProgress(); + } try { const content = await this.app.vault.cachedRead(note); const contentHash = hashContent(preprocessContent(content) ?? ''); @@ -414,6 +597,17 @@ export class IndexingQueue extends Events { return needsIndexing; } + private emitCheckingProgress(): void { + this.emitProgress({ + phase: 'checking', + totalNotes: this.totalCount, + processedNotes: this.processedCount, + currentNote: null, + estimatedTimeRemaining: null, + scope: 'notes' + }); + } + /** * Process the note queue with memory-conscious batching */ @@ -424,18 +618,29 @@ export class IndexingQueue extends Events { totalNotes: this.totalCount, processedNotes: 0, currentNote: null, - estimatedTimeRemaining: null + estimatedTimeRemaining: null, + scope: 'notes' }); try { await this.embeddingService.initialize(); + // initialize() swallows a model that failed to load and turns the + // service off. Every embedNote() after that is a silent no-op, so + // walking the queue anyway reported "complete, N of N" over an empty + // index. Say what happened instead. + if (!this.embeddingService.isServiceEnabled()) { + this.queue = []; + throw new Error('The embedding model could not be loaded'); + } + this.emitProgress({ phase: 'indexing', totalNotes: this.totalCount, processedNotes: 0, currentNote: null, - estimatedTimeRemaining: null + estimatedTimeRemaining: null, + scope: 'notes' }); const saveCadence = new SaveCadence({ minItems: this.MIN_SAVE_ITEMS, db: this.db }); @@ -447,7 +652,8 @@ export class IndexingQueue extends Events { totalNotes: this.totalCount, processedNotes: this.processedCount, currentNote: null, - estimatedTimeRemaining: null + estimatedTimeRemaining: null, + scope: 'notes' }); break; } @@ -469,7 +675,8 @@ export class IndexingQueue extends Events { totalNotes: this.totalCount, processedNotes: this.processedCount, currentNote: notePath, - estimatedTimeRemaining: this.calculateETA() + estimatedTimeRemaining: this.calculateETA(), + scope: 'notes' }); await this.embeddingService.embedNote(notePath); @@ -510,7 +717,8 @@ export class IndexingQueue extends Events { totalNotes: this.totalCount, processedNotes: this.processedCount, currentNote: null, - estimatedTimeRemaining: null + estimatedTimeRemaining: null, + scope: 'notes' }); } catch (error: unknown) { @@ -521,6 +729,7 @@ export class IndexingQueue extends Events { processedNotes: this.processedCount, currentNote: null, estimatedTimeRemaining: null, + scope: 'notes', error: error instanceof Error ? error.message : String(error) }); } finally { diff --git a/src/services/embeddings/TraceIndexer.ts b/src/services/embeddings/TraceIndexer.ts index d6783bbb5..52c6182e4 100644 --- a/src/services/embeddings/TraceIndexer.ts +++ b/src/services/embeddings/TraceIndexer.ts @@ -13,6 +13,7 @@ */ import { EmbeddingService } from './EmbeddingService'; +import { preprocessContent } from './EmbeddingUtils'; import type { SQLiteCacheManager } from '../../database/storage/SQLiteCacheManager'; import { SaveCadence, @@ -32,8 +33,9 @@ export interface TraceIndexerProgress { /** * Handles backfill indexing for existing memory traces. * - * Queries all traces from the database, filters out those already embedded, - * then processes each one. Embedding is idempotent -- re-running is safe. + * Queries all traces from the database, filters out those already embedded + * and those too short to embed, then processes each one, reporting progress + * after each. Embedding is idempotent -- re-running is safe. */ export class TraceIndexer { private db: SQLiteCacheManager; @@ -104,8 +106,18 @@ export class TraceIndexer { ); const embeddedIds = new Set(embeddedRows.map(r => r.traceId)); - // Filter to traces not already embedded - const needsIndexing = allTraces.filter(t => !embeddedIds.has(t.id)); + // Filter to traces not already embedded, and that can be embedded at all. + // + // embedTrace() skips content that preprocesses to nothing (under ten + // characters), so such a trace never gets a row and used to be counted as + // work on every start. Real vaults are full of them: tool-call traces whose + // content is just "Used tool". One vault had 6,660 of 6,681 un-embedded + // traces in that state, and walking them (with a 50 ms yield each) showed + // "Indexing: 0/6644" for about six minutes per start while embedding + // nothing. + const needsIndexing = allTraces.filter( + t => !embeddedIds.has(t.id) && preprocessContent(t.content ?? '') !== null + ); if (needsIndexing.length === 0) { return { total: 0, processed: 0 }; @@ -139,6 +151,7 @@ export class TraceIndexer { ); processedCount++; saveCadence.recordItem(); + this.onProgress({ totalTraces: totalCount, processedTraces: processedCount }); } catch (error) { // embedTrace() catches its own failures and never rethrows, so this // is now a genuine surprise rather than the periodic save, which diff --git a/src/services/embeddings/embeddingAccess.ts b/src/services/embeddings/embeddingAccess.ts new file mode 100644 index 000000000..8353888de --- /dev/null +++ b/src/services/embeddings/embeddingAccess.ts @@ -0,0 +1,91 @@ +/** + * Location: src/services/embeddings/embeddingAccess.ts + * Purpose: The one way to reach the live EmbeddingService. + * + * The embedding system is not a service-container entry. PluginLifecycleManager + * attaches an EmbeddingManager to the plugin as `plugin.embeddingManager`, and + * the manager owns the EmbeddingService it creates. Asking the service registry + * for `'embeddingService'` therefore always returned null, which silently turned + * off semantic conversation search, semantic `[[` note suggestions and semantic + * note linking for tasks and projects. + * + * Callers must resolve on every use and never keep the result: the manager can + * be enabled, disabled or rebuilt during a session, and each start builds a + * fresh service instance. + * + * Mobile-safe: only types are imported from the embedding modules, so loading + * this file never pulls in the embedding engine or any Node built-in. + */ + +import { Platform } from 'obsidian'; +import type { EmbeddingService } from './EmbeddingService'; + +/** + * Whether agents can be offered semantic search, as configured. + * + * - `available`: turned on, on a platform that supports it. The engine may + * still be starting; callers check resolveEmbeddingService for that. + * - `off`: turned off in Nexus settings. + * - `unsupported`: this device cannot run it (mobile). + */ +export type SemanticSearchState = 'available' | 'off' | 'unsupported'; + +/** The parts of EmbeddingManager this accessor relies on. */ +interface EmbeddingManagerView { + isEmbeddingEnabled(): boolean; + getService(): EmbeddingService | null; +} + +function isEmbeddingManagerView(value: unknown): value is EmbeddingManagerView { + if (value === null || typeof value !== 'object') { + return false; + } + const candidate = value as Partial; + return typeof candidate.isEmbeddingEnabled === 'function' + && typeof candidate.getService === 'function'; +} + +/** + * Return the plugin's live EmbeddingService, or null when semantic search is + * unavailable: no manager (mobile, or before storage is ready), embeddings + * turned off, or the manager not yet initialized. + * + * @param plugin The Nexus plugin instance (or anything carrying `embeddingManager`). + */ +export function resolveEmbeddingService(plugin: unknown): EmbeddingService | null { + if (plugin === null || typeof plugin !== 'object') { + return null; + } + + try { + const manager = (plugin as { embeddingManager?: unknown }).embeddingManager; + if (!isEmbeddingManagerView(manager) || !manager.isEmbeddingEnabled()) { + return null; + } + return manager.getService() ?? null; + } catch { + return null; + } +} + +/** + * Derive the semantic search state from the plugin's settings and platform. + * + * Reads `plugin.settings.settings.enableEmbeddings`, which defaults to on. + * Anything that is not a Nexus plugin (the headless schema exporter, test + * stubs) therefore reads as `available`, which keeps generated tool + * descriptions stable. + */ +export function resolveSemanticSearchState(plugin: unknown): SemanticSearchState { + if (Platform.isMobile) { + return 'unsupported'; + } + + try { + const settings = (plugin as { settings?: { settings?: { enableEmbeddings?: unknown } } } | null) + ?.settings?.settings; + return settings?.enableEmbeddings === false ? 'off' : 'available'; + } catch { + return 'available'; + } +} diff --git a/src/services/embeddings/index.ts b/src/services/embeddings/index.ts index e8113b70a..86451f8de 100644 --- a/src/services/embeddings/index.ts +++ b/src/services/embeddings/index.ts @@ -22,7 +22,7 @@ export { preprocessContent, extractWikiLinks } from './EmbeddingUtils'; export type { SimilarNote } from './NoteEmbeddingService'; export type { TraceSearchResult } from './TraceEmbeddingService'; export type { ConversationSearchResult } from './ConversationEmbeddingService'; -export type { IndexingProgress } from './IndexingQueue'; +export type { IndexingProgress, IndexingScope } from './IndexingQueue'; export type { ChunkOptions, ContentChunk } from './ContentChunker'; export type { QAPair } from './QAPairBuilder'; export type { WindowOptions, MessageWindow } from './ConversationWindowRetriever'; diff --git a/src/services/external/ClaudeHeadlessService.ts b/src/services/external/ClaudeHeadlessService.ts index fc2307a54..4b15de142 100644 --- a/src/services/external/ClaudeHeadlessService.ts +++ b/src/services/external/ClaudeHeadlessService.ts @@ -333,7 +333,7 @@ export class ClaudeHeadlessService { throw new Error(`${moduleName} is only available on desktop.`); } - const maybeRequire = (window.activeWindow as Window & { + const maybeRequire = (window as Window & { require?: (moduleId: string) => unknown; }).require; diff --git a/src/services/llm/adapters/BaseImageAdapter.ts b/src/services/llm/adapters/BaseImageAdapter.ts index fdc7c51de..0cd9a2e56 100644 --- a/src/services/llm/adapters/BaseImageAdapter.ts +++ b/src/services/llm/adapters/BaseImageAdapter.ts @@ -90,10 +90,17 @@ export abstract class BaseImageAdapter extends BaseAdapter { const finalParams = { ...params, ...validation.adjustedParams }; // Generate the image with timeout - const response = await Promise.race([ - this.generateImage(finalParams), - this.createTimeoutPromise(120000) // 2 minute timeout - ]); + const timeout = this.createTimeout(120000); // 2 minute timeout + let response: ImageGenerationResponse; + try { + response = await Promise.race([ + this.generateImage(finalParams), + timeout.promise + ]); + } finally { + // Without this the timer outlives a finished generation by up to two minutes. + timeout.cancel(); + } const generationTime = Date.now() - startTime; @@ -155,14 +162,20 @@ export abstract class BaseImageAdapter extends BaseAdapter { } /** - * Create a timeout promise that rejects after specified milliseconds + * Create a promise that rejects after the given milliseconds, and a cancel + * that clears its timer once the race it guards has settled. */ - private createTimeoutPromise(timeoutMs: number): Promise { - return new Promise((_, reject) => { - window.setTimeout(() => { + private createTimeout(timeoutMs: number): { promise: Promise; cancel: () => void } { + let timer: number | undefined; + const promise = new Promise((_, reject) => { + timer = window.setTimeout(() => { reject(new Error('Image generation timed out')); }, timeoutMs); }); + return { + promise, + cancel: () => window.clearTimeout(timer) + }; } /** diff --git a/src/services/llm/adapters/anthropic-claude-code/AnthropicClaudeCodeAdapter.ts b/src/services/llm/adapters/anthropic-claude-code/AnthropicClaudeCodeAdapter.ts index fbfa8e62e..fd0cdc51d 100644 --- a/src/services/llm/adapters/anthropic-claude-code/AnthropicClaudeCodeAdapter.ts +++ b/src/services/llm/adapters/anthropic-claude-code/AnthropicClaudeCodeAdapter.ts @@ -320,7 +320,7 @@ export class AnthropicClaudeCodeAdapter extends BaseAdapter { throw new Error(`${moduleName} is only available on desktop.`); } - const maybeRequire = (window.activeWindow as Window & { + const maybeRequire = (window as Window & { require?: (moduleId: string) => unknown; }).require; diff --git a/src/services/llm/adapters/anthropic/AnthropicAdapter.ts b/src/services/llm/adapters/anthropic/AnthropicAdapter.ts index ee6f4d869..8d290248e 100644 --- a/src/services/llm/adapters/anthropic/AnthropicAdapter.ts +++ b/src/services/llm/adapters/anthropic/AnthropicAdapter.ts @@ -19,7 +19,7 @@ import { import { WebSearchUtils } from '../../utils/WebSearchUtils'; import { extractStreamErrorMessage } from '../../streaming/streamErrorFrames'; import { ANTHROPIC_MODELS, ANTHROPIC_DEFAULT_MODEL } from './AnthropicModels'; -import { ThinkingEffortMapper, mapAnthropicAdaptiveEffort } from '../../utils/ThinkingEffortMapper'; +import { ThinkingEffortMapper, isAnthropicAdaptiveThinkingModel, mapAnthropicAdaptiveEffort } from '../../utils/ThinkingEffortMapper'; import { staticModelToModelInfo, getStaticModelPricing } from '../shared/StaticModelHelpers'; import type { AnthropicThinkingBlock, ThinkingEffort } from '../../../../types/llm/ProviderTypes'; import { TokenUsageExtractor } from '../../utils/TokenUsageExtractor'; @@ -569,8 +569,7 @@ export class AnthropicAdapter extends BaseAdapter { } private supportsAdaptiveThinking(modelId: string): boolean { - const normalized = this.normalizeModelId(modelId); - return /^claude-(?:(?:fable|mythos|opus|sonnet)-(?:5(?:-|$)|4-(?:6|7|8)(?:-|$))|haiku-5-5(?:-|$))/.test(normalized); + return isAnthropicAdaptiveThinkingModel(this.normalizeModelId(modelId)); } private captureThinkingBlock( diff --git a/src/services/llm/adapters/google-gemini-cli/GoogleGeminiCliAdapter.ts b/src/services/llm/adapters/google-gemini-cli/GoogleGeminiCliAdapter.ts index e4470e286..b83e5f660 100644 --- a/src/services/llm/adapters/google-gemini-cli/GoogleGeminiCliAdapter.ts +++ b/src/services/llm/adapters/google-gemini-cli/GoogleGeminiCliAdapter.ts @@ -241,7 +241,7 @@ export class GoogleGeminiCliAdapter extends BaseAdapter { if (result.errorCode === PROVIDER_TIMEOUT_ERROR_CODE) { return new LLMProviderError( - result.stderr.trim() || 'Gemini CLI stopped responding and was terminated. Please try again.', + result.stderr.trim() || 'Antigravity CLI stopped responding and was terminated. Please try again.', this.name, PROVIDER_TIMEOUT_ERROR_CODE ); diff --git a/src/services/llm/adapters/google/GeminiImageAdapter.ts b/src/services/llm/adapters/google/GeminiImageAdapter.ts index 7db667940..36b0f26d0 100644 --- a/src/services/llm/adapters/google/GeminiImageAdapter.ts +++ b/src/services/llm/adapters/google/GeminiImageAdapter.ts @@ -36,7 +36,7 @@ import { readImageDimensions, sniffImageFormat } from '../shared/imageDimensions type OutputFormat = 'png' | 'jpeg' | 'webp'; type GeminiImageSize = '512' | '1K' | '2K' | '4K'; -interface GeminiImageModelSpec { +export interface GeminiImageModelSpec { displayName: string; /** USD for one 1K image (1120 output image tokens; 1290 for 2.5). */ costPerImage: number; @@ -127,6 +127,71 @@ const ASPECT_RATIO_TO_DIMENSIONS: Record = { '8:1': [1024, 128] }; +export const GEMINI_IMAGE_MODEL_SPECS: Readonly> = { + 'gemini-3.1-flash-lite-image': { + displayName: 'Nano Banana 2 Lite', + costPerImage: 0.0336, + pricingNote: '1120 image tokens at $30/M; 1K only', + imageTokenPricePerMillion: 30, + maxReferenceImages: 14, + imageSizes: ['1K'], + contextWindow: 65536, + lastUpdated: '2026-09-02' + }, + 'gemini-3.1-flash-image': { + displayName: 'Nano Banana 2', + costPerImage: 0.067, + pricingNote: '1120 image tokens at $60/M for 1K; 512 is $0.045, 2K $0.101, 4K $0.151', + imageTokenPricePerMillion: 60, + maxReferenceImages: 14, + imageSizes: ['512', '1K', '2K', '4K'], + contextWindow: 65536, + lastUpdated: '2026-09-02' + }, + 'gemini-3-pro-image': { + displayName: 'Nano Banana Pro', + costPerImage: 0.134, + pricingNote: '1120 image tokens at $120/M for 1K or 2K; 4K is $0.24', + imageTokenPricePerMillion: 120, + maxReferenceImages: 14, + imageSizes: ['1K', '2K', '4K'], + contextWindow: 131072, + lastUpdated: '2026-09-02' + }, + // Preview ids. Google's deprecation page lists a 2026-06-25 shutdown for + // both, but the API still serves them; kept so saved settings keep working. + 'gemini-3.1-flash-image-preview': { + displayName: 'Nano Banana 2 preview', + costPerImage: 0.067, + pricingNote: 'Same pricing as gemini-3.1-flash-image', + imageTokenPricePerMillion: 60, + maxReferenceImages: 14, + imageSizes: ['512', '1K', '2K', '4K'], + contextWindow: 65536, + lastUpdated: '2026-09-02' + }, + 'gemini-3-pro-image-preview': { + displayName: 'Nano Banana Pro preview', + costPerImage: 0.134, + pricingNote: 'Same pricing as gemini-3-pro-image', + imageTokenPricePerMillion: 120, + maxReferenceImages: 14, + imageSizes: ['1K', '2K', '4K'], + contextWindow: 131072, + lastUpdated: '2026-09-02' + }, + 'gemini-2.5-flash-image': { + displayName: 'Nano Banana legacy, shuts down 2026-10-02', + costPerImage: 0.039, + pricingNote: '1290 image tokens at $30/M; no imageSize parameter', + imageTokenPricePerMillion: 30, + maxReferenceImages: 3, + imageSizes: null, + contextWindow: 32768, + lastUpdated: '2026-09-02' + } +}; + export class GeminiImageAdapter extends BaseImageAdapter { // Image adapters don't support streaming in the same way as text @@ -139,70 +204,7 @@ export class GeminiImageAdapter extends BaseImageAdapter { readonly name = 'gemini-image'; readonly baseUrl = 'https://generativelanguage.googleapis.com/v1beta'; - private readonly modelSpecs: Record = { - 'gemini-3.1-flash-lite-image': { - displayName: 'Nano Banana 2 Lite', - costPerImage: 0.0336, - pricingNote: '1120 image tokens at $30/M; 1K only', - imageTokenPricePerMillion: 30, - maxReferenceImages: 14, - imageSizes: ['1K'], - contextWindow: 65536, - lastUpdated: '2026-09-02' - }, - 'gemini-3.1-flash-image': { - displayName: 'Nano Banana 2', - costPerImage: 0.067, - pricingNote: '1120 image tokens at $60/M for 1K; 512 is $0.045, 2K $0.101, 4K $0.151', - imageTokenPricePerMillion: 60, - maxReferenceImages: 14, - imageSizes: ['512', '1K', '2K', '4K'], - contextWindow: 65536, - lastUpdated: '2026-09-02' - }, - 'gemini-3-pro-image': { - displayName: 'Nano Banana Pro', - costPerImage: 0.134, - pricingNote: '1120 image tokens at $120/M for 1K or 2K; 4K is $0.24', - imageTokenPricePerMillion: 120, - maxReferenceImages: 14, - imageSizes: ['1K', '2K', '4K'], - contextWindow: 131072, - lastUpdated: '2026-09-02' - }, - // Preview ids. Google's deprecation page lists a 2026-06-25 shutdown for - // both, but the API still serves them; kept so saved settings keep working. - 'gemini-3.1-flash-image-preview': { - displayName: 'Nano Banana 2 preview', - costPerImage: 0.067, - pricingNote: 'Same pricing as gemini-3.1-flash-image', - imageTokenPricePerMillion: 60, - maxReferenceImages: 14, - imageSizes: ['512', '1K', '2K', '4K'], - contextWindow: 65536, - lastUpdated: '2026-09-02' - }, - 'gemini-3-pro-image-preview': { - displayName: 'Nano Banana Pro preview', - costPerImage: 0.134, - pricingNote: 'Same pricing as gemini-3-pro-image', - imageTokenPricePerMillion: 120, - maxReferenceImages: 14, - imageSizes: ['1K', '2K', '4K'], - contextWindow: 131072, - lastUpdated: '2026-09-02' - }, - 'gemini-2.5-flash-image': { - displayName: 'Nano Banana legacy, shuts down 2026-10-02', - costPerImage: 0.039, - pricingNote: '1290 image tokens at $30/M; no imageSize parameter', - imageTokenPricePerMillion: 30, - maxReferenceImages: 3, - imageSizes: null, - contextWindow: 32768, - lastUpdated: '2026-09-02' - } - }; + private readonly modelSpecs = GEMINI_IMAGE_MODEL_SPECS; readonly supportedModels: ImageModel[] = Object.keys(this.modelSpecs) as ImageModel[]; readonly supportedSizes: string[] = ['1024x1024', '1536x1024', '1024x1536', '1792x1024', '1024x1792']; diff --git a/src/services/llm/adapters/openai/OpenAIImageAdapter.ts b/src/services/llm/adapters/openai/OpenAIImageAdapter.ts index fb3478f30..ff460b79c 100644 --- a/src/services/llm/adapters/openai/OpenAIImageAdapter.ts +++ b/src/services/llm/adapters/openai/OpenAIImageAdapter.ts @@ -40,7 +40,7 @@ type OutputFormat = 'png' | 'jpeg' | 'webp'; * the OpenAI pricing page; `costPerImage` is a measured 1024x1024 image at * `medium` quality, since `auto` resolves to a different quality per model. */ -interface OpenAIImageModelSpec { +export interface OpenAIImageModelSpec { displayName: string; /** USD for one 1024x1024 image at medium quality. */ costPerImage: number; @@ -130,6 +130,70 @@ const ASPECT_RATIO_TO_SIZE: Record = { const PORTRAIT_RATIOS = new Set(['2:3', '3:4', '4:5', '9:16', '1:4', '1:8']); const LANDSCAPE_RATIOS = new Set(['3:2', '4:3', '5:4', '16:9', '21:9', '4:1', '8:1']); +export const OPENAI_IMAGE_MODEL_SPECS: Readonly> = { + 'gpt-image-2': { + displayName: 'GPT Image 2', + costPerImage: 0.053, + pricingNote: '1756 output tokens at $30/M for 1024x1024 medium; auto picked low (196 tokens) on a simple prompt', + inputTokenPricePerMillion: 8, + outputTokenPricePerMillion: 30, + supportsArbitrarySize: true, + lastUpdated: '2026-09-02' + }, + // https://developers.openai.com/api/docs/guides/image-generation + 'gpt-image-2.5-sunburst': { + displayName: 'GPT Image 2.5 Sunburst', + costPerImage: 0.01317, + pricingNote: 'Measured 439 output tokens at $30/M for 1024x1024 medium; input tokens billed separately', + inputTokenPricePerMillion: 8, + textInputTokenPricePerMillion: 5, + outputTokenPricePerMillion: 30, + defaultQuality: 'medium', + strictSizeLimits: true, + supportsArbitrarySize: true, + lastUpdated: '2026-09-08' + }, + 'gpt-image-2.5-flare': { + displayName: 'GPT Image 2.5 Flare', + costPerImage: 0.01317, + pricingNote: 'Measured 439 output tokens at $30/M for 1024x1024 medium; input tokens billed separately', + inputTokenPricePerMillion: 8, + textInputTokenPricePerMillion: 5, + outputTokenPricePerMillion: 30, + defaultQuality: 'medium', + strictSizeLimits: true, + supportsArbitrarySize: true, + lastUpdated: '2026-09-08' + }, + 'gpt-image-1.5': { + displayName: 'GPT Image 1.5', + costPerImage: 0.034, + pricingNote: '1056 output tokens at $32/M for 1024x1024 medium; auto picks high (4160 tokens, $0.133)', + inputTokenPricePerMillion: 8, + outputTokenPricePerMillion: 32, + supportsArbitrarySize: false, + lastUpdated: '2026-09-02' + }, + 'gpt-image-1': { + displayName: 'GPT Image 1', + costPerImage: 0.042, + pricingNote: '1056 output tokens at $40/M for 1024x1024 medium', + inputTokenPricePerMillion: 10, + outputTokenPricePerMillion: 40, + supportsArbitrarySize: false, + lastUpdated: '2026-09-02' + }, + 'gpt-image-1-mini': { + displayName: 'GPT Image 1 Mini', + costPerImage: 0.008, + pricingNote: '1056 output tokens at $8/M for 1024x1024 medium', + inputTokenPricePerMillion: 2.5, + outputTokenPricePerMillion: 8, + supportsArbitrarySize: false, + lastUpdated: '2026-09-02' + } +}; + export class OpenAIImageAdapter extends BaseImageAdapter { // Image adapters don't support streaming in the same way as text @@ -142,69 +206,7 @@ export class OpenAIImageAdapter extends BaseImageAdapter { readonly name = 'openai-image'; readonly baseUrl = 'https://api.openai.com/v1'; - private readonly modelSpecs: Record = { - 'gpt-image-2': { - displayName: 'GPT Image 2', - costPerImage: 0.053, - pricingNote: '1756 output tokens at $30/M for 1024x1024 medium; auto picked low (196 tokens) on a simple prompt', - inputTokenPricePerMillion: 8, - outputTokenPricePerMillion: 30, - supportsArbitrarySize: true, - lastUpdated: '2026-09-02' - }, - // https://developers.openai.com/api/docs/guides/image-generation - 'gpt-image-2.5-sunburst': { - displayName: 'GPT Image 2.5 Sunburst', - costPerImage: 0.01317, - pricingNote: 'Measured 439 output tokens at $30/M for 1024x1024 medium; input tokens billed separately', - inputTokenPricePerMillion: 8, - textInputTokenPricePerMillion: 5, - outputTokenPricePerMillion: 30, - defaultQuality: 'medium', - strictSizeLimits: true, - supportsArbitrarySize: true, - lastUpdated: '2026-09-08' - }, - 'gpt-image-2.5-flare': { - displayName: 'GPT Image 2.5 Flare', - costPerImage: 0.01317, - pricingNote: 'Measured 439 output tokens at $30/M for 1024x1024 medium; input tokens billed separately', - inputTokenPricePerMillion: 8, - textInputTokenPricePerMillion: 5, - outputTokenPricePerMillion: 30, - defaultQuality: 'medium', - strictSizeLimits: true, - supportsArbitrarySize: true, - lastUpdated: '2026-09-08' - }, - 'gpt-image-1.5': { - displayName: 'GPT Image 1.5', - costPerImage: 0.034, - pricingNote: '1056 output tokens at $32/M for 1024x1024 medium; auto picks high (4160 tokens, $0.133)', - inputTokenPricePerMillion: 8, - outputTokenPricePerMillion: 32, - supportsArbitrarySize: false, - lastUpdated: '2026-09-02' - }, - 'gpt-image-1': { - displayName: 'GPT Image 1', - costPerImage: 0.042, - pricingNote: '1056 output tokens at $40/M for 1024x1024 medium', - inputTokenPricePerMillion: 10, - outputTokenPricePerMillion: 40, - supportsArbitrarySize: false, - lastUpdated: '2026-09-02' - }, - 'gpt-image-1-mini': { - displayName: 'GPT Image 1 Mini', - costPerImage: 0.008, - pricingNote: '1056 output tokens at $8/M for 1024x1024 medium', - inputTokenPricePerMillion: 2.5, - outputTokenPricePerMillion: 8, - supportsArbitrarySize: false, - lastUpdated: '2026-09-02' - } - }; + private readonly modelSpecs = OPENAI_IMAGE_MODEL_SPECS; readonly supportedModels: ImageModel[] = Object.keys(this.modelSpecs) as ImageModel[]; readonly supportedSizes: string[] = [...FIXED_SIZES]; diff --git a/src/services/llm/adapters/openrouter/OpenRouterImageAdapter.ts b/src/services/llm/adapters/openrouter/OpenRouterImageAdapter.ts index 5a4bed195..0abf11818 100644 --- a/src/services/llm/adapters/openrouter/OpenRouterImageAdapter.ts +++ b/src/services/llm/adapters/openrouter/OpenRouterImageAdapter.ts @@ -43,7 +43,7 @@ type OpenRouterResolution = '512' | '1K' | '2K' | '4K'; * /api/v1/images/models//endpoints. `resolutions` is null when the * endpoint has no `resolution` parameter — sending one is a hard 400. */ -interface OpenRouterImageModelSpec { +export interface OpenRouterImageModelSpec { openRouterId: string; displayName: string; /** USD for one image at the default resolution (1K / 1 megapixel). */ @@ -118,6 +118,172 @@ const ASPECT_RATIO_TO_DIMENSIONS: Record = { '8:1': [1024, 128] }; +/** + * Model catalog. Every value comes from the model's own + * /api/v1/images/models//endpoints entry. Gemini bills per output token + * (a 1K image is 1120 tokens); FLUX bills per megapixel; OpenAI bills per + * output token at the provider's default (high) quality. + */ +export const OPENROUTER_IMAGE_MODEL_SPECS: Readonly> = { + 'gemini-2.5-flash-image': { + // The earlier `google/gemini-2.5-flash-image-preview` id was retired and + // now answers "No endpoints found". + openRouterId: 'google/gemini-2.5-flash-image', + displayName: 'Nano Banana legacy, shuts down 2026-10-02 (via OpenRouter)', + costPerImage: 0.039, + pricingNote: '1290 output tokens at $30/M', + maxReferenceImages: 3, + resolutions: null, + contextWindow: 32768, + lastUpdated: '2026-09-02' + }, + 'gemini-3-pro-image-preview': { + openRouterId: 'google/gemini-3-pro-image-preview', + displayName: 'Nano Banana Pro preview (via OpenRouter)', + costPerImage: 0.134, + pricingNote: '1120 output tokens at $120/M for 1K or 2K; 4K is double', + maxReferenceImages: 14, + resolutions: ['1K', '2K', '4K'], + contextWindow: 65536, + lastUpdated: '2026-09-02' + }, + 'gemini-3.1-flash-image-preview': { + openRouterId: 'google/gemini-3.1-flash-image-preview', + displayName: 'Nano Banana 2 preview (via OpenRouter)', + costPerImage: 0.067, + pricingNote: '1120 output tokens at $60/M for 1K or 2K; 4K is double', + maxReferenceImages: 14, + resolutions: ['512', '1K', '2K', '4K'], + contextWindow: 65536, + lastUpdated: '2026-09-02' + }, + 'gpt-5-image': { + openRouterId: 'openai/gpt-5-image', + displayName: 'GPT-5 Image (via OpenRouter)', + costPerImage: 0.167, + pricingNote: '4160 output tokens at $40/M at the default high quality', + maxReferenceImages: 16, + resolutions: null, + contextWindow: 400000, + lastUpdated: '2026-09-02' + }, + 'gpt-5.4-image-2': { + openRouterId: 'openai/gpt-5.4-image-2', + displayName: 'GPT-5.4 Image 2 (via OpenRouter)', + costPerImage: 0.125, + pricingNote: '4160 output tokens at $30/M at the default high quality', + maxReferenceImages: 16, + resolutions: null, + contextWindow: 272000, + lastUpdated: '2026-09-02' + }, + 'flux-2-pro': { + openRouterId: 'black-forest-labs/flux.2-pro', + displayName: 'FLUX.2 Pro (via OpenRouter)', + costPerImage: 0.03, + pricingNote: '$0.03 per output megapixel', + maxReferenceImages: 8, + resolutions: null, + contextWindow: 4096, + lastUpdated: '2026-09-02' + }, + 'flux-2-flex': { + openRouterId: 'black-forest-labs/flux.2-flex', + displayName: 'FLUX.2 Flex (via OpenRouter)', + costPerImage: 0.06, + pricingNote: '$0.06 per output megapixel, plus $0.06 per input megapixel', + maxReferenceImages: 8, + resolutions: null, + contextWindow: 4096, + lastUpdated: '2026-09-02' + }, + 'flux-2-klein-4b': { + openRouterId: 'black-forest-labs/flux.2-klein-4b', + displayName: 'FLUX.2 Klein 4B (via OpenRouter)', + costPerImage: 0.014, + pricingNote: '$0.014 per output megapixel; exactly one image per request', + maxReferenceImages: 4, + resolutions: null, + contextWindow: 4096, + lastUpdated: '2026-09-02' + }, + // GA ids for the Gemini image family. The -preview ids above still resolve + // but Google retires previews (2.5's is already gone), so prefer these. + 'gemini-3.1-flash-image': { + openRouterId: 'google/gemini-3.1-flash-image', + displayName: 'Nano Banana 2 (via OpenRouter)', + costPerImage: 0.067, + pricingNote: '1120 output tokens at $60/M for 1K or 2K; 4K is double', + maxReferenceImages: 14, + resolutions: ['512', '1K', '2K', '4K'], + contextWindow: 65536, + lastUpdated: '2026-09-02' + }, + 'gemini-3.1-flash-lite-image': { + openRouterId: 'google/gemini-3.1-flash-lite-image', + displayName: 'Nano Banana 2 Lite (via OpenRouter)', + costPerImage: 0.034, + pricingNote: '1120 output tokens at $30/M; 1K only', + maxReferenceImages: 14, + resolutions: ['1K'], + contextWindow: 65536, + lastUpdated: '2026-09-02' + }, + 'gemini-3-pro-image': { + openRouterId: 'google/gemini-3-pro-image', + displayName: 'Nano Banana Pro (via OpenRouter)', + costPerImage: 0.134, + pricingNote: '1120 output tokens at $120/M for 1K or 2K; 4K is double', + maxReferenceImages: 14, + resolutions: ['1K', '2K', '4K'], + contextWindow: 65536, + lastUpdated: '2026-09-02' + }, + 'gpt-5-image-mini': { + openRouterId: 'openai/gpt-5-image-mini', + displayName: 'GPT-5 Image Mini (via OpenRouter)', + costPerImage: 0.033, + pricingNote: '4160 output tokens at $8/M at the default high quality', + maxReferenceImages: 16, + resolutions: null, + contextWindow: 400000, + lastUpdated: '2026-09-02' + }, + 'gpt-image-2': { + // OpenRouter's recommended replacement for the gpt-5-image chat models. + openRouterId: 'openai/gpt-image-2', + displayName: 'GPT Image 2 (via OpenRouter)', + costPerImage: 0.125, + pricingNote: '4160 output tokens at $30/M at the default high quality', + maxReferenceImages: 16, + resolutions: null, + contextWindow: 32000, + lastUpdated: '2026-09-02' + }, + 'seedream-4.5': { + openRouterId: 'bytedance-seed/seedream-4.5', + displayName: 'Seedream 4.5 (via OpenRouter)', + costPerImage: 0.04, + pricingNote: '$0.04 per image at any resolution', + maxReferenceImages: 14, + // The endpoint lists 1K, but OpenRouter rejects it: the model needs at + // least 3,686,400 output pixels, so 2K is the real minimum. + resolutions: ['2K', '4K'], + contextWindow: 32000, + lastUpdated: '2026-09-02' + }, + 'seedream-5-lite': { + openRouterId: 'bytedance-seed/seedream-5-0-lite', + displayName: 'Seedream 5.0 Lite (via OpenRouter)', + costPerImage: 0.035, + pricingNote: '$0.035 per image; 2K and 4K only', + maxReferenceImages: 14, + resolutions: ['2K', '4K'], + contextWindow: 32000, + lastUpdated: '2026-09-02' + } +}; + export class OpenRouterImageAdapter extends BaseImageAdapter { async* generateStreamAsync(_prompt: string, _options?: GenerateOptions): AsyncGenerator { @@ -129,171 +295,7 @@ export class OpenRouterImageAdapter extends BaseImageAdapter { readonly name = 'openrouter-image'; readonly baseUrl = 'https://openrouter.ai/api/v1'; - /** - * Model catalog. Every value comes from the model's own - * /api/v1/images/models//endpoints entry. Gemini bills per output token - * (a 1K image is 1120 tokens); FLUX bills per megapixel; OpenAI bills per - * output token at the provider's default (high) quality. - */ - private readonly modelSpecs: Record = { - 'gemini-2.5-flash-image': { - // The earlier `google/gemini-2.5-flash-image-preview` id was retired and - // now answers "No endpoints found". - openRouterId: 'google/gemini-2.5-flash-image', - displayName: 'Nano Banana legacy, shuts down 2026-10-02 (via OpenRouter)', - costPerImage: 0.039, - pricingNote: '1290 output tokens at $30/M', - maxReferenceImages: 3, - resolutions: null, - contextWindow: 32768, - lastUpdated: '2026-09-02' - }, - 'gemini-3-pro-image-preview': { - openRouterId: 'google/gemini-3-pro-image-preview', - displayName: 'Nano Banana Pro preview (via OpenRouter)', - costPerImage: 0.134, - pricingNote: '1120 output tokens at $120/M for 1K or 2K; 4K is double', - maxReferenceImages: 14, - resolutions: ['1K', '2K', '4K'], - contextWindow: 65536, - lastUpdated: '2026-09-02' - }, - 'gemini-3.1-flash-image-preview': { - openRouterId: 'google/gemini-3.1-flash-image-preview', - displayName: 'Nano Banana 2 preview (via OpenRouter)', - costPerImage: 0.067, - pricingNote: '1120 output tokens at $60/M for 1K or 2K; 4K is double', - maxReferenceImages: 14, - resolutions: ['512', '1K', '2K', '4K'], - contextWindow: 65536, - lastUpdated: '2026-09-02' - }, - 'gpt-5-image': { - openRouterId: 'openai/gpt-5-image', - displayName: 'GPT-5 Image (via OpenRouter)', - costPerImage: 0.167, - pricingNote: '4160 output tokens at $40/M at the default high quality', - maxReferenceImages: 16, - resolutions: null, - contextWindow: 400000, - lastUpdated: '2026-09-02' - }, - 'gpt-5.4-image-2': { - openRouterId: 'openai/gpt-5.4-image-2', - displayName: 'GPT-5.4 Image 2 (via OpenRouter)', - costPerImage: 0.125, - pricingNote: '4160 output tokens at $30/M at the default high quality', - maxReferenceImages: 16, - resolutions: null, - contextWindow: 272000, - lastUpdated: '2026-09-02' - }, - 'flux-2-pro': { - openRouterId: 'black-forest-labs/flux.2-pro', - displayName: 'FLUX.2 Pro (via OpenRouter)', - costPerImage: 0.03, - pricingNote: '$0.03 per output megapixel', - maxReferenceImages: 8, - resolutions: null, - contextWindow: 4096, - lastUpdated: '2026-09-02' - }, - 'flux-2-flex': { - openRouterId: 'black-forest-labs/flux.2-flex', - displayName: 'FLUX.2 Flex (via OpenRouter)', - costPerImage: 0.06, - pricingNote: '$0.06 per output megapixel, plus $0.06 per input megapixel', - maxReferenceImages: 8, - resolutions: null, - contextWindow: 4096, - lastUpdated: '2026-09-02' - }, - 'flux-2-klein-4b': { - openRouterId: 'black-forest-labs/flux.2-klein-4b', - displayName: 'FLUX.2 Klein 4B (via OpenRouter)', - costPerImage: 0.014, - pricingNote: '$0.014 per output megapixel; exactly one image per request', - maxReferenceImages: 4, - resolutions: null, - contextWindow: 4096, - lastUpdated: '2026-09-02' - }, - // GA ids for the Gemini image family. The -preview ids above still resolve - // but Google retires previews (2.5's is already gone), so prefer these. - 'gemini-3.1-flash-image': { - openRouterId: 'google/gemini-3.1-flash-image', - displayName: 'Nano Banana 2 (via OpenRouter)', - costPerImage: 0.067, - pricingNote: '1120 output tokens at $60/M for 1K or 2K; 4K is double', - maxReferenceImages: 14, - resolutions: ['512', '1K', '2K', '4K'], - contextWindow: 65536, - lastUpdated: '2026-09-02' - }, - 'gemini-3.1-flash-lite-image': { - openRouterId: 'google/gemini-3.1-flash-lite-image', - displayName: 'Nano Banana 2 Lite (via OpenRouter)', - costPerImage: 0.034, - pricingNote: '1120 output tokens at $30/M; 1K only', - maxReferenceImages: 14, - resolutions: ['1K'], - contextWindow: 65536, - lastUpdated: '2026-09-02' - }, - 'gemini-3-pro-image': { - openRouterId: 'google/gemini-3-pro-image', - displayName: 'Nano Banana Pro (via OpenRouter)', - costPerImage: 0.134, - pricingNote: '1120 output tokens at $120/M for 1K or 2K; 4K is double', - maxReferenceImages: 14, - resolutions: ['1K', '2K', '4K'], - contextWindow: 65536, - lastUpdated: '2026-09-02' - }, - 'gpt-5-image-mini': { - openRouterId: 'openai/gpt-5-image-mini', - displayName: 'GPT-5 Image Mini (via OpenRouter)', - costPerImage: 0.033, - pricingNote: '4160 output tokens at $8/M at the default high quality', - maxReferenceImages: 16, - resolutions: null, - contextWindow: 400000, - lastUpdated: '2026-09-02' - }, - 'gpt-image-2': { - // OpenRouter's recommended replacement for the gpt-5-image chat models. - openRouterId: 'openai/gpt-image-2', - displayName: 'GPT Image 2 (via OpenRouter)', - costPerImage: 0.125, - pricingNote: '4160 output tokens at $30/M at the default high quality', - maxReferenceImages: 16, - resolutions: null, - contextWindow: 32000, - lastUpdated: '2026-09-02' - }, - 'seedream-4.5': { - openRouterId: 'bytedance-seed/seedream-4.5', - displayName: 'Seedream 4.5 (via OpenRouter)', - costPerImage: 0.04, - pricingNote: '$0.04 per image at any resolution', - maxReferenceImages: 14, - // The endpoint lists 1K, but OpenRouter rejects it: the model needs at - // least 3,686,400 output pixels, so 2K is the real minimum. - resolutions: ['2K', '4K'], - contextWindow: 32000, - lastUpdated: '2026-09-02' - }, - 'seedream-5-lite': { - openRouterId: 'bytedance-seed/seedream-5-0-lite', - displayName: 'Seedream 5.0 Lite (via OpenRouter)', - costPerImage: 0.035, - pricingNote: '$0.035 per image; 2K and 4K only', - maxReferenceImages: 14, - resolutions: ['2K', '4K'], - contextWindow: 32000, - lastUpdated: '2026-09-02' - } - }; + private readonly modelSpecs = OPENROUTER_IMAGE_MODEL_SPECS; readonly supportedModels: ImageModel[] = Object.keys(this.modelSpecs) as ImageModel[]; readonly supportedSizes: string[] = ['1024x1024', '1536x1024', '1024x1536', '1792x1024', '1024x1792']; diff --git a/src/services/llm/core/LLMService.ts b/src/services/llm/core/LLMService.ts index 588a5227d..d6e8c671b 100644 --- a/src/services/llm/core/LLMService.ts +++ b/src/services/llm/core/LLMService.ts @@ -16,6 +16,7 @@ import { VaultOperations } from '../../../core/VaultOperations'; import { CacheManager, VaultAdapter } from '../utils/CacheManager'; import { Logger } from '../utils/Logger'; import { LLMSettingsNotifier } from '../LLMSettingsNotifier'; +import { resolveAgentModel, type AgentDefaults } from '../utils/SubagentModelResolver'; export interface LLMExecutionOptions extends GenerateOptions { provider?: string; @@ -119,36 +120,31 @@ export class LLMService { } /** - * Get the model to use for agent operations (executePrompt). - * If the default model is a local provider (webllm, ollama, lmstudio), - * returns the configured agentModel or falls back to first available API provider. + * Get the model to use for agent runs (subagents and executePrompt). + * + * A configured `agentModel` wins whatever the chat model is. Without one, + * agent runs use the chat model if it is a cloud model, otherwise the first + * available cloud provider (model left empty for the caller to pick). The + * precedence lives in `resolveAgentModel` so every agent path agrees. */ getAgentModel(): { provider: string; model: string } { - const LOCAL_PROVIDERS = ['webllm', 'ollama', 'lmstudio']; - const defaultModel = this.settings.defaultModel; - - // If default is not a local provider, use it - if (!LOCAL_PROVIDERS.includes(defaultModel.provider)) { - return defaultModel; - } - - // If agentModel is configured, use it - if (this.settings.agentModel?.provider && this.settings.agentModel?.model) { - return this.settings.agentModel; - } - - // Fall back to first available API provider - const availableProviders = this.getAvailableProviders(); - const apiProviders = availableProviders.filter(p => !LOCAL_PROVIDERS.includes(p)); - - if (apiProviders.length > 0) { - // Return first API provider with its default model - // This is a best-effort fallback - the caller should handle model selection - return { provider: apiProviders[0], model: '' }; - } + const resolved = resolveAgentModel({ + defaultAgent: this.settings.agentModel, + chat: this.settings.defaultModel, + availableProviders: this.getAvailableProviders() + }); + return { provider: resolved.provider, model: resolved.model }; + } - // No API providers available - return default anyway (will likely fail) - return defaultModel; + /** Plugin-wide defaults for agent runs (Settings → Defaults → Subagents). */ + getAgentDefaults(): AgentDefaults { + return { + model: this.settings.agentModel, + thinking: this.settings.agentThinking, + agentTemperature: this.settings.agentTemperature, + defaultTemperature: this.settings.defaultTemperature, + contextWindowOverrides: this.settings.agentContextWindowOverrides + }; } /** Execute a prompt with the specified or default provider/model */ @@ -219,7 +215,9 @@ export class LLMService { frequencyPenalty: options.frequencyPenalty, presencePenalty: options.presencePenalty, stopSequences: options.stopSequences, - webSearch: options.webSearch + webSearch: options.webSearch, + enableThinking: options.enableThinking, + thinkingEffort: options.thinkingEffort }; const result: LLMResponse = await adapter.generate(fullPrompt, generateOptions); diff --git a/src/services/llm/imageModelNames.ts b/src/services/llm/imageModelNames.ts new file mode 100644 index 000000000..f2616f773 --- /dev/null +++ b/src/services/llm/imageModelNames.ts @@ -0,0 +1,28 @@ +/** + * Display names of the image generation models, read from each image + * adapter's model catalog. No adapter is constructed, so this answers before + * (or without) an API key: the settings summary can say "Nano Banana 2" + * rather than the raw id `gemini-3.1-flash-image`. + */ + +import { GEMINI_IMAGE_MODEL_SPECS } from './adapters/google/GeminiImageAdapter'; +import { OPENAI_IMAGE_MODEL_SPECS } from './adapters/openai/OpenAIImageAdapter'; +import { OPENROUTER_IMAGE_MODEL_SPECS } from './adapters/openrouter/OpenRouterImageAdapter'; +import type { ImageProvider } from './types/ImageTypes'; + +const CATALOGS: Record>> = { + google: GEMINI_IMAGE_MODEL_SPECS, + openai: OPENAI_IMAGE_MODEL_SPECS, + openrouter: OPENROUTER_IMAGE_MODEL_SPECS +}; + +/** The catalog name of an image model, or undefined when the provider or model is unknown. */ +export function getImageModelDisplayName( + provider: string | null | undefined, + model: string | null | undefined +): string | undefined { + if (!provider || !model) return undefined; + if (!Object.prototype.hasOwnProperty.call(CATALOGS, provider)) return undefined; + const catalog = CATALOGS[provider as ImageProvider]; + return Object.prototype.hasOwnProperty.call(catalog, model) ? catalog[model].displayName : undefined; +} diff --git a/src/services/llm/providers/BuiltinProviderDrivers.ts b/src/services/llm/providers/BuiltinProviderDrivers.ts index 576ddaa28..630360550 100644 --- a/src/services/llm/providers/BuiltinProviderDrivers.ts +++ b/src/services/llm/providers/BuiltinProviderDrivers.ts @@ -275,11 +275,11 @@ export function createBuiltinProviderDrivers(): BuiltinProviderDriverRegistratio }), defineDriver({ kind: 'google-gemini-cli', - displayName: 'Gemini CLI', + displayName: 'Antigravity CLI', compatibility: 'desktop-only', shouldInitialize: (config, vault) => Boolean(config?.enabled && config.oauth?.connected && vault), createAdapter: async ({ vault }) => { - if (!vault) throw new Error('Gemini CLI requires a vault'); + if (!vault) throw new Error('Antigravity CLI requires a vault'); const { GoogleGeminiCliAdapter } = await import('../adapters/google-gemini-cli/GoogleGeminiCliAdapter'); return new GoogleGeminiCliAdapter(vault); }, diff --git a/src/services/llm/utils/SubagentModelResolver.ts b/src/services/llm/utils/SubagentModelResolver.ts new file mode 100644 index 000000000..879e46439 --- /dev/null +++ b/src/services/llm/utils/SubagentModelResolver.ts @@ -0,0 +1,216 @@ +/** + * Which model, thinking, temperature and context budget an agent run uses. + * + * "Agent runs" are subagents and executePrompt (saved prompts and + * automations). Their settings live under Settings → Defaults → Subagents and + * in each chat's settings, and this module is the single place that decides + * how those layers combine. LLMService, the subagent path and PromptExecutor + * all call it, so the three cannot disagree. + * + * Model precedence (first match wins): + * 1. an explicit tool parameter (`explicit`) + * 2. the conversation's subagent provider/model (`conversation`) + * 3. the default subagent model (`llmProviders.agentModel`) + * 4. the chat model, if it is a cloud model (behaviour before this existed) + * 5. the first available cloud provider (the old local-model fallback) + * + * A configured agent model (levels 2 and 3) on a provider that is no longer + * available is skipped rather than sent to a request that cannot succeed. + * + * Pure: no Node or Obsidian imports. + */ + +import type { ThinkingSettings } from '../../../types/llm/ProviderTypes'; +import { getContextWindowOverrideKey, resolveContextWindowLimit } from '../../../ui/chat/utils/ContextWindowSettings'; +import { getRegistryContextWindow, modelAcceptsSamplingParams } from './modelCapabilityLookup'; +import { resolveThinkingForModel, type ThinkingPreference } from './resolveThinkingForModel'; + +/** Providers that run on this device. Agent runs prefer cloud models. */ +export const LOCAL_PROVIDERS: readonly string[] = ['webllm', 'ollama', 'lmstudio']; + +export function isLocalProvider(provider: string | null | undefined): boolean { + return !!provider && LOCAL_PROVIDERS.includes(provider); +} + +export interface ModelRef { + provider?: string | null; + model?: string | null; +} + +export type AgentModelSource = + | 'explicit' + | 'conversation' + | 'default-agent' + | 'chat' + | 'cloud-fallback' + | 'unresolved'; + +export interface ResolveAgentModelInput { + explicit?: ModelRef | null; + conversation?: ModelRef | null; + defaultAgent?: ModelRef | null; + chat?: ModelRef | null; + /** + * Providers that can serve a request right now. When given, configured + * agent models and the chat model on other providers are skipped, and the + * cloud fallback picks from this list. When omitted nothing is skipped. + */ + availableProviders?: readonly string[]; + /** + * Model to use with a cloud-fallback provider. Without it the fallback + * leaves the model empty and the caller picks one. With it, the fallback + * takes the first cloud provider that has a default model, and when none + * has one (custom OpenAI-compatible endpoints, say) it stays on the chat + * model: a provider must never be paired with another provider's model. + */ + defaultModelFor?: (provider: string) => string | undefined; +} + +export interface ResolvedAgentModel { + provider: string; + /** Empty when only the provider is known; the caller picks a model. */ + model: string; + source: AgentModelSource; +} + +function isComplete(ref: ModelRef | null | undefined): ref is { provider: string; model: string } { + return !!ref?.provider && !!ref.model; +} + +export function resolveAgentModel(input: ResolveAgentModelInput): ResolvedAgentModel { + const available = input.availableProviders; + const isAvailable = (provider: string): boolean => !available || available.includes(provider); + + const explicit = input.explicit; + if (explicit?.provider) { + // A provider without a model borrows the model of a lower layer on the + // same provider, as executePrompt always has. + const sameProvider = [input.conversation, input.defaultAgent, input.chat] + .find(ref => isComplete(ref) && ref.provider === explicit.provider); + return { + provider: explicit.provider, + model: explicit.model || sameProvider?.model || '', + source: 'explicit' + }; + } + + if (isComplete(input.conversation) && isAvailable(input.conversation.provider)) { + return { provider: input.conversation.provider, model: input.conversation.model, source: 'conversation' }; + } + + if (isComplete(input.defaultAgent) && isAvailable(input.defaultAgent.provider)) { + return { provider: input.defaultAgent.provider, model: input.defaultAgent.model, source: 'default-agent' }; + } + + const chat = input.chat; + if (isComplete(chat) && !isLocalProvider(chat.provider) && isAvailable(chat.provider)) { + return { provider: chat.provider, model: chat.model, source: 'chat' }; + } + + const cloudProviders = available?.filter(provider => !isLocalProvider(provider)) ?? []; + const defaultModelFor = input.defaultModelFor; + if (!defaultModelFor) { + if (cloudProviders.length > 0) { + return { provider: cloudProviders[0], model: '', source: 'cloud-fallback' }; + } + } else { + for (const provider of cloudProviders) { + const model = defaultModelFor(provider); + if (model) { + return { provider, model, source: 'cloud-fallback' }; + } + } + } + + // Nothing usable (or no cloud provider with a known model). Return the + // chat model: it runs, or the caller reports a real error. + return { provider: chat?.provider || '', model: chat?.model || '', source: 'unresolved' }; +} + +/** Per-conversation agent settings, as stored in chat settings. */ +export interface ConversationAgentSettings { + provider?: string | null; + model?: string | null; + thinking?: ThinkingPreference | null; + /** Creativity for agent runs in this chat. Null or absent inherits the default. */ + temperature?: number | null; + /** Context budget for agent runs in this chat. Null or absent inherits the default. */ + contextWindow?: number | null; +} + +/** The plugin-wide defaults that agent runs fall back to. */ +export interface AgentDefaults { + model?: ModelRef | null; + thinking?: ThinkingPreference | null; + /** `llmProviders.agentTemperature`. */ + agentTemperature?: number; + /** `llmProviders.defaultTemperature`, used when no agent temperature is set. */ + defaultTemperature?: number; + /** `llmProviders.agentContextWindowOverrides`. */ + contextWindowOverrides?: Record; +} + +export interface AgentRunSettingsInput { + provider: string; + model: string; + conversation?: ConversationAgentSettings | null; + defaults?: AgentDefaults | null; + /** A window the caller already knows (e.g. a discovered local model). */ + advertisedContextWindow?: number; +} + +export interface AgentRunSettings { + /** Already gated: `enabled` is false for models that cannot think. */ + thinking: ThinkingSettings; + /** Undefined when the model rejects sampling parameters. */ + temperature?: number; + /** Undefined when neither a limit nor the model's window is known. */ + contextWindow?: number; +} + +export const DEFAULT_AGENT_TEMPERATURE = 0.5; + +function finiteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +/** The Creativity an agent run would use before model gating. */ +export function resolveAgentTemperature( + conversation?: Pick | null, + defaults?: Pick | null +): number { + const value = finiteNumber(conversation?.temperature) + ?? finiteNumber(defaults?.agentTemperature) + ?? finiteNumber(defaults?.defaultTemperature) + ?? DEFAULT_AGENT_TEMPERATURE; + return Math.max(0, Math.min(1, value)); +} + +export function resolveAgentRunSettings(input: AgentRunSettingsInput): AgentRunSettings { + const { provider, model, conversation, defaults } = input; + + const thinking = resolveThinkingForModel( + provider, + model, + conversation?.thinking ?? defaults?.thinking ?? { enabled: false } + ); + + const temperature = modelAcceptsSamplingParams(provider, model) + ? resolveAgentTemperature(conversation, defaults) + : undefined; + + const advertised = finiteNumber(input.advertisedContextWindow) ?? getRegistryContextWindow(provider, model); + const override = finiteNumber(conversation?.contextWindow) + ?? (provider && model + ? finiteNumber(defaults?.contextWindowOverrides?.[getContextWindowOverrideKey(provider, model)]) + : undefined); + + let contextWindow: number | undefined; + if (advertised && advertised > 0) { + contextWindow = resolveContextWindowLimit(advertised, override); + } else if (override !== undefined && override > 0) { + contextWindow = Math.floor(override); + } + + return { thinking, temperature, contextWindow }; +} diff --git a/src/services/llm/utils/ThinkingEffortMapper.ts b/src/services/llm/utils/ThinkingEffortMapper.ts index 74b4a210d..af398f25e 100644 --- a/src/services/llm/utils/ThinkingEffortMapper.ts +++ b/src/services/llm/utils/ThinkingEffortMapper.ts @@ -66,6 +66,15 @@ export function mapOpenAIThinkingEffort(effort: ThinkingEffort, modelId: string) return 'high'; } +/** + * Claude 4.6+ (and Haiku 5.5) take `thinking: adaptive` with an effort level; + * older Claude models take a manual token budget instead. + */ +export function isAnthropicAdaptiveThinkingModel(modelId: string): boolean { + const normalized = modelId.replace(':1m', ''); + return /^claude-(?:(?:fable|mythos|opus|sonnet)-(?:5(?:-|$)|4-(?:6|7|8)(?:-|$))|haiku-5-5(?:-|$))/.test(normalized); +} + export function mapAnthropicAdaptiveEffort(effort: ThinkingEffort, modelId: string): ThinkingEffort { if (effort !== 'xhigh' && effort !== 'max') return effort; const model = modelId.replace(':1m', ''); diff --git a/src/services/llm/utils/modelCapabilityLookup.ts b/src/services/llm/utils/modelCapabilityLookup.ts new file mode 100644 index 000000000..0721be6fe --- /dev/null +++ b/src/services/llm/utils/modelCapabilityLookup.ts @@ -0,0 +1,56 @@ +/** + * Registry-backed capability lookups for request boundaries. + * + * Every answer comes from the static model registry. A model that is not in + * the registry (Ollama, LM Studio, OpenAI-compatible endpoints, ids typed by + * hand) has no capability data, and each helper says so explicitly rather than + * guessing: `undefined` for tri-state answers, and the permissive answer for + * booleans that only ever restrict a request. + * + * Pure data access — no Node or Obsidian imports — so it is safe on mobile and + * in settings UI as well as the runtime paths. + */ + +import { ModelRegistry } from '../adapters/ModelRegistry'; +import type { ModelSpec } from '../adapters/modelTypes'; + +/** The registry entry for a provider/model pair, if one exists. */ +export function findRegistryModelSpec( + provider: string | null | undefined, + model: string | null | undefined +): ModelSpec | undefined { + if (!provider || !model) return undefined; + return ModelRegistry.findModel(provider, model); +} + +/** + * Whether the registry says this model can think. + * `undefined` means there is no registry entry, so nothing is known. + */ +export function registryModelSupportsThinking( + provider: string | null | undefined, + model: string | null | undefined +): boolean | undefined { + return findRegistryModelSpec(provider, model)?.capabilities.supportsThinking; +} + +/** + * Whether a request for this model may carry `temperature`. + * Only an explicit `supportsSamplingParams: false` in the registry says no; + * models without an entry keep the parameter, matching the adapters. + */ +export function modelAcceptsSamplingParams( + provider: string | null | undefined, + model: string | null | undefined +): boolean { + return findRegistryModelSpec(provider, model)?.supportsSamplingParams !== false; +} + +/** The advertised context window, when the registry knows the model. */ +export function getRegistryContextWindow( + provider: string | null | undefined, + model: string | null | undefined +): number | undefined { + const window = findRegistryModelSpec(provider, model)?.contextWindow; + return typeof window === 'number' && window > 0 ? window : undefined; +} diff --git a/src/services/llm/utils/resolveThinkingForModel.ts b/src/services/llm/utils/resolveThinkingForModel.ts new file mode 100644 index 000000000..ce5eda4e1 --- /dev/null +++ b/src/services/llm/utils/resolveThinkingForModel.ts @@ -0,0 +1,50 @@ +/** + * Gate a stored thinking preference at the request boundary. + * + * The user's thinking choice is stored per chat and per default, and it stays + * stored when they switch to a model that cannot reason — so switching back + * restores their level. What must not happen is that hidden preference being + * sent: a request for a non-reasoning model always goes out with thinking off. + * + * The answer is driven by the registry's `supportsThinking` flag. A model with + * no registry entry (discovered or custom models) has no capability data, so + * its preference passes through unchanged and the adapter decides, which is + * what every request did before this gate existed. + */ + +import type { ThinkingEffort, ThinkingSettings } from '../../../types/llm/ProviderTypes'; +import { registryModelSupportsThinking } from './modelCapabilityLookup'; + +export interface ThinkingPreference { + enabled?: boolean; + effort?: ThinkingEffort; +} + +export interface ResolveThinkingOptions { + /** + * Capability supplied by the caller when it knows better than the static + * registry. `undefined` falls back to the registry lookup. + */ + supportsThinking?: boolean; +} + +const DEFAULT_EFFORT: ThinkingEffort = 'medium'; + +/** + * Returns the thinking settings a request for `provider`/`model` may carry. + * The input object is never modified. + */ +export function resolveThinkingForModel( + provider: string | null | undefined, + model: string | null | undefined, + thinking: ThinkingPreference | null | undefined, + options: ResolveThinkingOptions = {} +): ThinkingSettings { + const effort = thinking?.effort ?? DEFAULT_EFFORT; + const enabled = thinking?.enabled === true; + if (!enabled) return { enabled: false, effort }; + + const supportsThinking = options.supportsThinking ?? registryModelSupportsThinking(provider, model); + if (supportsThinking === false) return { enabled: false, effort }; + return { enabled: true, effort }; +} diff --git a/src/services/llm/utils/thinkingLevels.ts b/src/services/llm/utils/thinkingLevels.ts new file mode 100644 index 000000000..c288391ea --- /dev/null +++ b/src/services/llm/utils/thinkingLevels.ts @@ -0,0 +1,114 @@ +/** + * Which thinking levels a provider/model actually distinguishes. + * + * Nexus stores one of five efforts (Low, Medium, High, Extra high, Max), but + * most providers accept fewer and the adapters fold the rest onto a neighbour: + * Gemini stops at High, older Claude models treat Extra high and Max as High, + * GPT-5.2 to 5.5 treat Max as Extra high, and so on. A Thinking dropdown that + * offers every level would show choices that silently do the same thing. + * + * `getSupportedThinkingEfforts` returns only the levels that behave + * differently, using the same mapping functions the adapters use, so this + * list cannot drift from what is sent. When Nexus has no capability data for + * a provider or model it returns all five levels (owner decision: "if no + * capability data exists, show all six" — Off plus five levels). + * + * This answers "which levels", not "can it think at all": callers that also + * need the on/off capability use `registryModelSupportsThinking` or + * `resolveThinkingForModel`. + * + * Pure: no Node or Obsidian imports, safe for settings UI on mobile. + */ + +import type { ThinkingEffort } from '../../../types/llm/ProviderTypes'; +import { + clampThinkingEffortToHigh, + isAnthropicAdaptiveThinkingModel, + mapAnthropicAdaptiveEffort, + mapOpenAIThinkingEffort +} from './ThinkingEffortMapper'; + +/** Every effort Nexus can store, lowest first. */ +export const THINKING_EFFORTS: readonly ThinkingEffort[] = ['low', 'medium', 'high', 'xhigh', 'max']; + +/** For one provider/model, the level that a stored effort behaves as. */ +type EffortBehaviour = (effort: ThinkingEffort, model: string) => ThinkingEffort; + +const ONLY_HIGH: EffortBehaviour = () => 'high'; + +/** Mistral Large 4 takes a binary reasoning switch, sent as `high`. */ +function isMistralLarge4(model: string): boolean { + return model === 'mistral-large-4' || model === 'mistral-large-4-0' || model === 'mistral/mistral-large-4'; +} + +function getEffortBehaviour(provider: string, model: string): EffortBehaviour | null { + switch (provider) { + case 'anthropic': + // Manual-budget models (Claude 4.5 and older) lift Extra high and Max + // above High through the token budget, so every level is distinct. + return isAnthropicAdaptiveThinkingModel(model) ? mapAnthropicAdaptiveEffort : null; + case 'anthropic-claude-code': + // The CLI always passes `--effort` through the adaptive mapping. + return mapAnthropicAdaptiveEffort; + case 'openai': + case 'openai-codex': + return mapOpenAIThinkingEffort; + case 'google': + // Gemini 3 thinking levels and the legacy token budgets both stop at + // High; Gemini 3 Pro Preview has no Medium level. + return model.startsWith('gemini-3-pro-preview') + ? (effort: ThinkingEffort) => { + const level = clampThinkingEffortToHigh(effort); + return level === 'medium' ? 'high' : level; + } + : clampThinkingEffortToHigh; + case 'google-gemini-cli': + case 'groq': + return clampThinkingEffortToHigh; + case 'deepseek': + // DeepSeek accepts `high` and `max`. Low and Medium send `high`; High + // and above send `max`. Offer one level per behaviour: Medium and Max. + return (effort: ThinkingEffort) => (effort === 'low' || effort === 'medium' ? 'medium' : 'max'); + case 'mistral': + case 'requesty': + return isMistralLarge4(model) ? ONLY_HIGH : null; + case 'openrouter': + return model.replace(/:online$/, '') === 'mistralai/mistral-large-4-0' ? ONLY_HIGH : null; + default: + return null; + } +} + +/** + * The thinking levels this provider/model distinguishes, lowest first. + * All five when there is no capability data. + */ +export function getSupportedThinkingEfforts( + provider: string | null | undefined, + model: string | null | undefined +): ThinkingEffort[] { + const behaviour = provider && model ? getEffortBehaviour(provider, model) : null; + if (!behaviour || !model) return [...THINKING_EFFORTS]; + + const supported: ThinkingEffort[] = []; + for (const effort of THINKING_EFFORTS) { + const behavesAs = behaviour(effort, model); + if (!supported.includes(behavesAs)) supported.push(behavesAs); + } + return THINKING_EFFORTS.filter(effort => supported.includes(effort)); +} + +/** + * The supported level a stored effort behaves as for this provider/model, so a + * dropdown can show the value that will really be used without rewriting the + * stored preference. Unknown models return the effort unchanged. + */ +export function normalizeThinkingEffortForModel( + provider: string | null | undefined, + model: string | null | undefined, + effort: ThinkingEffort +): ThinkingEffort { + const behaviour = provider && model ? getEffortBehaviour(provider, model) : null; + if (!behaviour || !model) return effort; + return behaviour(effort, model); +} diff --git a/src/services/oauth/OAuthService.ts b/src/services/oauth/OAuthService.ts index 45475bfba..577036831 100644 --- a/src/services/oauth/OAuthService.ts +++ b/src/services/oauth/OAuthService.ts @@ -34,7 +34,7 @@ function loadDesktopModule unknown; }).require; diff --git a/src/services/oauth/providers/GithubCopilotOAuthProvider.ts b/src/services/oauth/providers/GithubCopilotOAuthProvider.ts index 328203740..9c4cf7b77 100644 --- a/src/services/oauth/providers/GithubCopilotOAuthProvider.ts +++ b/src/services/oauth/providers/GithubCopilotOAuthProvider.ts @@ -33,7 +33,7 @@ function loadDesktopModule unknown; }).require; diff --git a/src/services/realtimeVoice/OpenAIRealtimeVoiceSession.ts b/src/services/realtimeVoice/OpenAIRealtimeVoiceSession.ts index 45c294d5b..cd1aad6c3 100644 --- a/src/services/realtimeVoice/OpenAIRealtimeVoiceSession.ts +++ b/src/services/realtimeVoice/OpenAIRealtimeVoiceSession.ts @@ -192,7 +192,12 @@ export class OpenAIRealtimeVoiceSession implements RealtimeVoiceSession { const outputAudio = createEl('audio'); outputAudio.autoplay = true; outputAudio.addClass('chat-live-voice-output'); - window.activeDocument.body.appendChild(outputAudio); + // The main window, never window.activeDocument. The element is hidden and + // lives for the whole session, but activeDocument is whichever window has + // focus when the call connects: after the awaited client-secret request + // that can be a popout or the Settings window (its own window since + // Obsidian 1.14), and closing it would silently kill the assistant's audio. + window.document.body.appendChild(outputAudio); this.outputAudio = outputAudio; peerConnection.ontrack = (event) => { diff --git a/src/services/workflows/WorkflowScheduleService.ts b/src/services/workflows/WorkflowScheduleService.ts index 2590b8472..b0e160f22 100644 --- a/src/services/workflows/WorkflowScheduleService.ts +++ b/src/services/workflows/WorkflowScheduleService.ts @@ -65,7 +65,7 @@ export class WorkflowScheduleService { try { const workspaces = await this.deps.workspaceService.getAllWorkspaces(); if (this.stopped) return; - const lastCheckAt = this.deps.settings.settings.workflowScheduler?.lastCheckAt; + const lastCheckAt = this.deps.settings.getWorkflowSchedulerWatermark(); for (const workspace of workspaces) { if (workspace.isActive === false) { diff --git a/src/services/workspace/DefaultWorkspaceManager.ts b/src/services/workspace/DefaultWorkspaceManager.ts index 17597ae42..c053c1c60 100644 --- a/src/services/workspace/DefaultWorkspaceManager.ts +++ b/src/services/workspace/DefaultWorkspaceManager.ts @@ -125,21 +125,10 @@ export class DefaultWorkspaceManager { } try { - // Check if default workspace JSON already exists - const existingWorkspace = await this.workspaceService.getWorkspace(this.defaultWorkspaceId); - - if (existingWorkspace) { - return; - } - - const legacyDefaultWorkspace = await this.workspaceService.getWorkspaceByNameOrId(this.defaultConfig.name); - if (legacyDefaultWorkspace) { - this.setDefaultWorkspaceId(legacyDefaultWorkspace.id); - return; - } - - // Create default workspace JSON - await this.workspaceService.createWorkspace({ + // ensureWorkspace, not getWorkspace + createWorkspace: this runs during + // plugin load, when that pair checks the legacy index but writes to the + // event store, and re-created the default workspace on every load. + const workspace = await this.workspaceService.ensureWorkspace({ id: this.defaultWorkspaceId, name: this.defaultConfig.name, description: this.defaultConfig.description, @@ -150,6 +139,10 @@ export class DefaultWorkspaceManager { sessions: {} }); + // A legacy default saved under a generated id is adopted, not duplicated. + if (workspace.id !== this.defaultWorkspaceId) { + this.setDefaultWorkspaceId(workspace.id); + } } catch (error) { console.error('[DefaultWorkspaceManager] Failed to create default workspace:', error); // Don't throw - allow plugin to continue even if workspace creation fails diff --git a/src/settings.ts b/src/settings.ts index 219de1800..d34e86e95 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -12,10 +12,22 @@ import { stripSecretsForPersist } from './services/secrets/SettingsSecrets'; +/** Assign an optional key, removing it rather than storing an explicit undefined. */ +function setOptionalKey(target: MCPSettings, key: K, value: MCPSettings[K]): void { + if (value === undefined) { + delete target[key]; + } else { + target[key] = value; + } +} + /** * Settings manager * Handles loading and saving plugin settings */ +/** Vault-scoped localStorage key for the workflow scheduler's device-local watermark. */ +const WORKFLOW_SCHEDULER_STORAGE_KEY = 'nexus-workflow-scheduler'; + export class Settings { private plugin: Plugin; private secretStore: SecretStore; @@ -163,7 +175,13 @@ export class Settings { }); } - /** Refresh live providers only; storage roots and other service configuration require reload. */ + /** + * Refresh live providers and the semantic search switch; storage roots and + * other service configuration require reload. Returns whether a reload is + * needed. A synced `enableEmbeddings` change is adopted here and applied by + * the plugin at runtime (see NexusPlugin.onExternalSettingsChange), so it + * never asks for a reload. + */ async reconcileExternalSettings(signal?: AbortSignal): Promise { if (!this.loaded || signal?.aborted) return false; return pluginDataLock.acquire(async () => { @@ -179,8 +197,10 @@ export class Settings { const merged = mergeSettingsChanges(this.baseline, cloneSettings(this.settings), normalized); const next = cloneSettings(this.settings); next.llmProviders = merged.llmProviders; + setOptionalKey(next, 'enableEmbeddings', merged.enableEmbeddings); const requiresReload = !settingsEqual(this.reloadSettings(this.baseline), this.reloadSettings(normalized)); this.baseline.llmProviders = cloneSettings(normalized.llmProviders); + setOptionalKey(this.baseline, 'enableEmbeddings', normalized.enableEmbeddings); this.adoptSettings(next); return requiresReload; } @@ -192,11 +212,13 @@ export class Settings { return incoming; } - private reloadSettings(settings: MCPSettings): Omit { + /** The settings whose synced change needs a reload: everything not applied live. */ + private reloadSettings(settings: MCPSettings): Omit { const other = cloneSettings(settings); delete other.llmProviders; delete other.workflowScheduler; delete other.pluginStorage; + delete other.enableEmbeddings; return other; } @@ -210,30 +232,38 @@ export class Settings { if (providersChanged && this.settings.llmProviders) LLMSettingsNotifier.notify(this.settings.llmProviders); } - /** Persist only the scheduler watermark; a background tick must not upload stale provider settings. */ - async saveWorkflowSchedulerState(lastCheckAt: number, signal?: AbortSignal): Promise { - await pluginDataLock.acquire(async () => { - if (signal?.aborted) return; - const loadedData: unknown = await this.plugin.loadData(); - if (signal?.aborted) return; - const persisted = loadedData && typeof loadedData === 'object' - ? loadedData as Record - : {}; - const scheduler = persisted.workflowScheduler && typeof persisted.workflowScheduler === 'object' - ? persisted.workflowScheduler as Record - : {}; - const previousCheck = scheduler.lastCheckAt; - const nextCheck = typeof previousCheck === 'number' ? Math.max(previousCheck, lastCheckAt) : lastCheckAt; - if (nextCheck !== previousCheck) { - await this.plugin.saveData({ ...persisted, workflowScheduler: { ...scheduler, lastCheckAt: nextCheck } }); - } - if (signal?.aborted) return; - // loadData can acknowledge Obsidian's external-change timestamp. - // Reconcile providers here too, in case this read precedes its callback. - this.reconcileProviders(this.readIncoming(loadedData)); - this.settings.workflowScheduler = { ...this.settings.workflowScheduler, lastCheckAt: nextCheck }; - this.baseline.workflowScheduler = { ...this.baseline.workflowScheduler, lastCheckAt: nextCheck }; - }); + /** + * The workflow scheduler's watermark: when this device last checked for due + * runs. It is device-local (vault-scoped localStorage), never data.json: the + * scheduler ticks every minute on every device, and writing a synced file that + * often made Obsidian Sync upload data.json continuously and every other + * device reload it. Duplicate runs across devices are prevented by each run's + * runKey, not by this value. A watermark still saved in data.json by older + * versions is honoured as a floor until the local one passes it. + */ + getWorkflowSchedulerWatermark(): number | undefined { + const local = this.readLocalSchedulerWatermark(); + const legacy = this.settings.workflowScheduler?.lastCheckAt; + const candidates = [local, legacy].filter((value): value is number => typeof value === 'number' && Number.isFinite(value)); + return candidates.length ? Math.max(...candidates) : undefined; + } + + /** Advance the device-local scheduler watermark. Never touches data.json. */ + saveWorkflowSchedulerState(lastCheckAt: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.resolve(); + const previous = this.getWorkflowSchedulerWatermark(); + const next = typeof previous === 'number' ? Math.max(previous, lastCheckAt) : lastCheckAt; + if (next !== this.readLocalSchedulerWatermark()) { + this.plugin.app?.saveLocalStorage?.(WORKFLOW_SCHEDULER_STORAGE_KEY, { v: 1, lastCheckAt: next }); + } + return Promise.resolve(); + } + + private readLocalSchedulerWatermark(): number | undefined { + const stored: unknown = this.plugin.app?.loadLocalStorage?.(WORKFLOW_SCHEDULER_STORAGE_KEY); + if (!stored || typeof stored !== 'object') return undefined; + const value = (stored as { lastCheckAt?: unknown }).lastCheckAt; + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } /** diff --git a/src/settings/SettingsRouter.ts b/src/settings/SettingsRouter.ts index 67ed4444f..edb253b52 100644 --- a/src/settings/SettingsRouter.ts +++ b/src/settings/SettingsRouter.ts @@ -5,13 +5,22 @@ export type SettingsTab = 'defaults' | 'workspaces' | 'instructions' | 'prompts' | 'providers' | 'remote-agents' | 'apps' | 'data'; export type SettingsView = 'list' | 'detail'; +/** In-tab pages that replace the Get started row, tab strip and panes while open. */ +export type SettingsPage = 'get-started' | 'get-started-chat' | 'get-started-agents'; export interface RouterState { tab: SettingsTab; view: SettingsView; detailId?: string; // workspace/prompt/provider ID when in detail view + page?: SettingsPage; } +/** Each page's parent; a page with no entry goes back to the tab list. */ +const PAGE_PARENT: Partial> = { + 'get-started-chat': 'get-started', + 'get-started-agents': 'get-started' +}; + export class SettingsRouter { private state: RouterState = { tab: 'defaults', view: 'list' }; private listeners: Set<(state: RouterState) => void> = new Set(); @@ -25,10 +34,24 @@ export class SettingsRouter { } /** - * Switch to a different tab (resets to list view) + * Switch to a different tab (resets to list view and closes any page) */ setTab(tab: SettingsTab): void { - this.navigate({ tab: tab === 'prompts' ? 'instructions' : tab, view: 'list', detailId: undefined }); + this.navigate({ tab: tab === 'prompts' ? 'instructions' : tab, view: 'list', detailId: undefined, page: undefined }); + } + + /** Open an in-tab page. The tab underneath is kept for when the page closes. */ + openPage(page: SettingsPage): void { + this.navigate({ ...this.state, page }); + } + + /** Close any open page and return to the tab list on the same tab. */ + closePage(): void { + this.navigate({ ...this.state, page: undefined }); + } + + isPageOpen(): boolean { + return this.state.page !== undefined; } /** @@ -43,9 +66,14 @@ export class SettingsRouter { } /** - * Go back to list view (from detail view) + * Go back one level: from a page to its parent page (or the tab list), + * otherwise from detail view to list view. */ back(): void { + if (this.state.page) { + this.navigate({ ...this.state, page: PAGE_PARENT[this.state.page] }); + return; + } this.navigate({ ...this.state, view: 'list', diff --git a/src/settings/SettingsView.ts b/src/settings/SettingsView.ts index 25b872750..3787f4d9b 100644 --- a/src/settings/SettingsView.ts +++ b/src/settings/SettingsView.ts @@ -1,11 +1,12 @@ -import { App, Plugin, PluginSettingTab, Notice, ButtonComponent, FileSystemAdapter } from 'obsidian'; +import { App, Plugin, PluginSettingTab, Notice, ButtonComponent, Component, FileSystemAdapter, Platform } from 'obsidian'; import { Settings } from '../settings'; import { UnifiedTabs, UnifiedTabConfig } from '../components/UnifiedTabs'; -import { SettingsRouter, RouterState, SettingsTab } from './SettingsRouter'; +import { SettingsRouter, RouterState, SettingsPage, SettingsTab } from './SettingsRouter'; import { UpdateManager } from '../utils/UpdateManager'; import { supportsMCPBridge } from '../utils/platform'; -import { Accordion } from '../components/Accordion'; -import { shouldOpenGetStarted } from './getStartedStatus'; +import { hasConfiguredProviders, hasExternalAgentSetup } from './getStartedStatus'; +import { renderGetStartedPage, renderGetStartedRow } from './getStarted/GetStartedPage'; +import { renderChatSetupPage } from './getStarted/ChatSetupPage'; // Services import { WorkspaceService } from '../services/WorkspaceService'; @@ -22,6 +23,7 @@ import type { AppManager } from '../services/apps/AppManager'; // Tab implementations import { DefaultsTab } from './tabs/DefaultsTab'; +import { resolveSemanticSearchRuntime } from './tabs/defaults/SemanticSearchSettings'; import { WorkspacesTab } from './tabs/WorkspacesTab'; import { InstructionsTab } from './tabs/InstructionsTab'; import type { InstructionLibraryService } from '../services/instructions/InstructionLibraryService'; @@ -29,8 +31,11 @@ import { ProvidersTab } from './tabs/ProvidersTab'; import { RemoteAgentsTab } from './tabs/RemoteAgentsTab'; import { AppsTab } from './tabs/AppsTab'; import { DataTab } from './tabs/DataTab'; -// GetStartedTab is dynamically imported (desktop-only, requires Node.js) -type GetStartedTabType = import('./tabs/GetStartedTab').GetStartedTab; + +/** Obsidian's settings modal is not part of the public App typings. */ +interface AppWithSettingModal { + setting?: { close(): void }; +} /** * SettingsView - New unified settings interface with tab-based navigation @@ -67,13 +72,23 @@ export class SettingsView extends PluginSettingTab { private remoteAgentsTab: RemoteAgentsTab | undefined; private appsTab: AppsTab | undefined; private dataTab: DataTab | undefined; - private getStartedTab: GetStartedTabType | undefined; - private getStartedAccordion: Accordion | undefined; + + // Body below the header: the Get started row, tab strip and panes, or a page. + private bodyEl: HTMLElement | undefined; + // Owns the DOM listeners of whatever the body shows; replaced on every body render. + private bodyComponent: Component | undefined; + private renderedPage: SettingsPage | undefined; + private bodyRenderToken = 0; + // Get started opens by itself only on the first display() of a plugin session. + private firstRunChecked = false; // Prefetched data cache private prefetchedWorkspaces: IndividualWorkspace[] | null = null; private isPrefetching = false; + // Route requested from outside settings, applied on the next display() + private pendingRoute: Partial | undefined; + constructor( app: App, plugin: Plugin, @@ -121,8 +136,12 @@ export class SettingsView extends PluginSettingTab { this.memoryService = services.memoryService; this.workspaceService = services.workspaceService; - // Refresh the UI - this.display(); + // Refresh the UI only while it is on screen. display() reads the current + // services anyway, and a render nobody sees must not use up the + // first-run Get started check. + if (this.containerEl.isConnected) { + this.display(); + } } /** @@ -144,8 +163,7 @@ export class SettingsView extends PluginSettingTab { this.remoteAgentsTab?.destroy(); this.appsTab?.destroy(); this.dataTab?.destroy(); - this.getStartedTab?.destroy(); - this.getStartedAccordion?.unload(); + this.unloadBody(); // Clear prefetch cache this.prefetchedWorkspaces = null; } @@ -188,6 +206,47 @@ export class SettingsView extends PluginSettingTab { } } + /** + * Ask settings to show a particular tab (and optionally a detail view). + * Applied immediately when this tab is already on screen, otherwise on + * the next display(), which Obsidian runs when the tab is opened. + */ + openTo(route: Partial): void { + if (this.tabs && this.containerEl.isConnected) { + this.applyRoute(route); + return; + } + this.pendingRoute = route; + } + + private applyRoute(route: Partial): void { + if (route.tab) this.router.setTab(route.tab); + if (route.view === 'detail' && route.detailId) this.router.showDetail(route.detailId); + if (route.page) this.router.openPage(route.page); + } + + /** + * Settings closed: pages are transient, so the next opening starts on the + * tab list (on the same tab). + */ + hide(): void { + this.unsubscribeRouter?.(); + this.unsubscribeRouter = undefined; + if (this.router.isPageOpen()) this.router.closePage(); + this.unloadBody(); + super.hide(); + } + + /** Nothing is set up yet: no chat provider and no external agent. */ + private needsGetStarted(): boolean { + try { + return !hasConfiguredProviders(this.settingsManager.settings.llmProviders) && !hasExternalAgentSetup(this.app); + } catch (error) { + console.error('[SettingsView] Could not check setup state:', error); + return false; + } + } + /** * Main display method - renders the settings UI */ @@ -196,8 +255,23 @@ export class SettingsView extends PluginSettingTab { containerEl.empty(); containerEl.addClass('nexus-settings'); - this.getStartedTab?.destroy(); - this.getStartedAccordion?.unload(); + // Navigate before the body exists, so no stale listener renders the old panes. + this.unsubscribeRouter?.(); + this.unsubscribeRouter = undefined; + + const pendingRoute = this.pendingRoute; + this.pendingRoute = undefined; + if (pendingRoute) { + this.applyRoute(pendingRoute); + } + + if (!this.firstRunChecked) { + this.firstRunChecked = true; + // A route asked for by other code (e.g. the chat's "Open settings") wins. + if (!pendingRoute && this.needsGetStarted()) { + this.router.openPage('get-started'); + } + } // Start prefetching workspaces in background (non-blocking) void this.prefetchWorkspaces(); @@ -205,10 +279,57 @@ export class SettingsView extends PluginSettingTab { // 1. Render header (About + Update button) this.renderHeader(containerEl); - // 2. Render get started accordion above the tabs - this.renderGetStartedAccordion(containerEl); + // 2. The body: Get started row + tabs, or the open page + this.bodyEl = containerEl.createDiv({ cls: 'nexus-settings-body' }); + this.renderBody(this.router.getState()); + + this.unsubscribeRouter = this.router.onNavigate((state) => { + if (state.page || this.renderedPage) { + this.renderBody(state); + return; + } + this.tabs?.activateTab(state.tab, false); + this.renderTabContent(state); + }); + } + + /** + * Tear down what the body holds. The Defaults tab subscribes to provider + * and indexing events, so it goes with the body; renderBody() builds a new + * one. (Tabs that may hold an unsaved edit, such as Workspaces, keep their + * existing lifetime.) + */ + private unloadBody(): void { + this.bodyRenderToken++; + this.defaultsTab?.destroy(); + this.defaultsTab = undefined; + this.bodyComponent?.unload(); + this.bodyComponent = undefined; + } + + /** Render the Get started row and the tabs, or the page the router has open. */ + private renderBody(state: RouterState): void { + const bodyEl = this.bodyEl; + if (!bodyEl) return; + + this.unloadBody(); + this.tabs?.destroy(); + this.tabs = undefined; + bodyEl.empty(); + + const component = new Component(); + component.load(); + this.bodyComponent = component; + this.renderedPage = state.page; + + if (state.page) { + const pageEl = bodyEl.createDiv({ cls: 'nexus-gs-page' }); + this.renderPage(pageEl, state.page, component); + return; + } + + renderGetStartedRow(bodyEl, this.router, component); - // 3. Create tabs const tabConfigs: UnifiedTabConfig[] = [ { key: 'defaults', label: 'Defaults' }, { key: 'workspaces', label: 'Workspaces' }, @@ -220,26 +341,70 @@ export class SettingsView extends PluginSettingTab { ]; this.tabs = new UnifiedTabs({ - containerEl, + containerEl: bodyEl, tabs: tabConfigs, defaultTab: this.router.getState().tab, onTabChange: (tabKey) => { this.router.setTab(tabKey as SettingsTab); }, - component: this.plugin + // The body's own component, so the tab listeners go when the body does. + component }); - // 4. Subscribe to router changes - if (this.unsubscribeRouter) { - this.unsubscribeRouter(); + this.renderTabContent(state); + } + + /** Whether the external-agent path (Node.js, desktop only) is offered. */ + private externalAgentsAvailable(): boolean { + return Platform.isDesktop && supportsMCPBridge(); + } + + private renderPage(pageEl: HTMLElement, page: SettingsPage, component: Component): void { + const llmProviders = this.settingsManager.settings.llmProviders; + if (page === 'get-started-chat') { + renderChatSetupPage(pageEl, { + app: this.app, + router: this.router, + component, + llmProviders, + pluginId: this.plugin.manifest.id, + closeSettings: () => (this.app as unknown as AppWithSettingModal).setting?.close() + }); + return; } - this.unsubscribeRouter = this.router.onNavigate((state) => { - this.tabs?.activateTab(state.tab, false); - this.renderTabContent(state); + if (page === 'get-started-agents' && this.externalAgentsAvailable()) { + void this.renderExternalAgentsPage(pageEl, component); + return; + } + // The Get started page; also stands in for the desktop-only agents page elsewhere. + renderGetStartedPage(pageEl, { + app: this.app, + router: this.router, + component, + llmProviders, + externalAgentsAvailable: this.externalAgentsAvailable() }); + } - // 5. Render initial content - this.renderTabContent(this.router.getState()); + /** + * The external-agent page loads Node-backed helpers, so it is imported on + * demand and never on mobile. + */ + private async renderExternalAgentsPage(pageEl: HTMLElement, component: Component): Promise { + const token = this.bodyRenderToken; + try { + const { ExternalAgentsPage } = await import('./getStarted/ExternalAgentsPage'); + if (token !== this.bodyRenderToken) return; + new ExternalAgentsPage(pageEl, { + app: this.app, + router: this.router, + component, + pluginPath: this.getPluginPath() + }); + } catch (error) { + console.error('[SettingsView] Could not load the external agent setup:', error); + new Notice('Could not load the external agent setup.'); + } } /** @@ -248,8 +413,6 @@ export class SettingsView extends PluginSettingTab { private renderHeader(containerEl: HTMLElement): void { const header = containerEl.createDiv('nexus-settings-header'); - // Title and description - ; header.createEl('p', { text: 'An assistant for your vault', cls: 'nexus-settings-desc' @@ -426,7 +589,17 @@ export class SettingsView extends PluginSettingTab { llmProviderSettings: this.settingsManager.settings.llmProviders, workspaceService: services.workspaceService, customPromptStorage: services.customPromptStorage, - appManager: this.appManager + appManager: this.appManager, + semanticSearch: { + getRuntime: () => resolveSemanticSearchRuntime(this.plugin), + applyEnabled: async (enabled: boolean) => { + if (this.pluginLifecycleManager) { + await this.pluginLifecycleManager.setEmbeddingsEnabled(enabled); + } else { + await resolveSemanticSearchRuntime(this.plugin)?.setEnabled(enabled); + } + } + } } ); } @@ -531,69 +704,15 @@ export class SettingsView extends PluginSettingTab { this.dataTab.render(); } - private renderGetStartedAccordion(containerEl: HTMLElement): void { - if (!supportsMCPBridge()) { - return; - } - - this.getStartedAccordion = new Accordion( - containerEl, - 'Get started', - shouldOpenGetStarted(this.app, this.settingsManager.settings), - (isOpen) => { - this.settingsManager.settings.getStartedOpen = isOpen; - void this.settingsManager.saveSettings().catch((error: unknown) => { - console.error('[SettingsView] Failed to save get started preference:', error); - new Notice('Could not save the get started preference.'); - }); - } - ); - this.getStartedAccordion.rootEl.addClass('nexus-settings-accordion'); - - const accordionContent = this.getStartedAccordion.getContentEl(); - accordionContent.addClass('nexus-settings-accordion-content'); - - void this.renderGetStartedContent(accordionContent); - } - - /** - * Render Get Started accordion content - * Uses dynamic import to avoid loading Node.js modules on mobile - */ - private async renderGetStartedContent(container: HTMLElement): Promise { - // Destroy previous tab instance if exists - this.getStartedTab?.destroy(); - - // Get plugin path for MCP config + /** Absolute path of this plugin's folder, or '' when the vault is not on the file system. */ + private getPluginPath(): string { const vaultBasePath = this.getVaultBasePath(); const pluginDir = this.plugin.manifest.dir; - // Extract just the folder name in case manifest.dir contains a full path - // (e.g., ".obsidian/plugins/claudesidian-mcp" instead of just "claudesidian-mcp") + // manifest.dir may be a vault-relative path (".obsidian/plugins/nexus"); keep the folder name. const pluginFolderName = pluginDir ? pluginDir.split('/').pop() || pluginDir : ''; - const pluginPath = vaultBasePath && pluginFolderName + return vaultBasePath && pluginFolderName ? `${vaultBasePath}/${this.app.vault.configDir}/plugins/${pluginFolderName}` : ''; - const vaultPath = vaultBasePath || ''; - - // Dynamic import to avoid loading Node.js modules on mobile - const { GetStartedTab } = await import('./tabs/GetStartedTab'); - - // Create new GetStartedTab - this.getStartedTab = new GetStartedTab( - container, - { - app: this.app, - pluginPath, - vaultPath, - onOpenProviders: () => { - this.router.setTab('providers'); - if (this.tabs) { - this.tabs.activateTab('providers'); - } - }, - component: this.getStartedAccordion - } - ); } /** diff --git a/src/settings/components/BackButton.ts b/src/settings/components/BackButton.ts index 8c4427e9e..b7f020f82 100644 --- a/src/settings/components/BackButton.ts +++ b/src/settings/components/BackButton.ts @@ -13,9 +13,9 @@ export class BackButton { * @param container Parent element to attach to * @param label Text to display (e.g., "Back to Workspaces") * @param onClick Callback when clicked - * @param component Optional Component for registerDomEvent + * @param component Component that owns the click listener */ - constructor(container: HTMLElement, label: string, onClick: () => void, component?: Component) { + constructor(container: HTMLElement, label: string, onClick: () => void, component: Component) { this.element = container.createEl('button', { cls: 'clickable-icon nexus-back-button' }); const iconSpan = this.element.createSpan({ cls: 'nexus-back-button-icon' }); @@ -24,12 +24,7 @@ export class BackButton { // Label text this.element.createSpan({ text: label }); - // Click handler - use component.registerDomEvent if available, otherwise addEventListener - if (component) { - component.registerDomEvent(this.element, 'click', onClick); - } else { - this.element.addEventListener('click', onClick); - } + component.registerDomEvent(this.element, 'click', onClick); } /** diff --git a/src/settings/components/StatusBadge.ts b/src/settings/components/StatusBadge.ts deleted file mode 100644 index 56e478f38..000000000 --- a/src/settings/components/StatusBadge.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * StatusBadge - Shows configured/not configured status indicator - * Uses Obsidian-native styling with CSS classes - */ - -export class StatusBadge { - /** - * Render a status badge - * @param container Parent element to attach to - * @param configured Whether the item is configured - * @returns The created badge element - */ - static render(container: HTMLElement, configured: boolean): HTMLElement { - const badge = container.createSpan('nexus-status-badge'); - badge.addClass(configured ? 'configured' : 'not-configured'); - badge.setText(configured ? '✓' : '○'); - return badge; - } - - /** - * Render a status badge with custom text - * @param container Parent element to attach to - * @param configured Whether the item is configured - * @param configuredText Text to show when configured - * @param notConfiguredText Text to show when not configured - */ - static renderWithText( - container: HTMLElement, - configured: boolean, - configuredText = 'Configured', - notConfiguredText = 'Not configured' - ): HTMLElement { - const badge = container.createSpan('nexus-status-badge'); - badge.addClass(configured ? 'configured' : 'not-configured'); - badge.setText(configured ? configuredText : notConfiguredText); - return badge; - } - - /** - * Update an existing badge's status - * @param badge The badge element to update - * @param configured New configuration status - */ - static update(badge: HTMLElement, configured: boolean): void { - badge.removeClass('configured', 'not-configured'); - badge.addClass(configured ? 'configured' : 'not-configured'); - badge.setText(configured ? '✓' : '○'); - } -} diff --git a/src/settings/getStarted/ChatSetupPage.ts b/src/settings/getStarted/ChatSetupPage.ts new file mode 100644 index 000000000..3a120a0e0 --- /dev/null +++ b/src/settings/getStarted/ChatSetupPage.ts @@ -0,0 +1,61 @@ +/** + * Get started → Chat inside Obsidian: three numbered steps. Mobile-safe. + */ +import { App, Component, Notice, Setting } from 'obsidian'; +import type { SettingsRouter } from '../SettingsRouter'; +import type { LLMProviderSettings } from '../../types/llm/ProviderTypes'; +import { createSettingGroup } from '../../components/shared/settings/settingGroup'; +import { hasConfiguredProviders } from '../getStartedStatus'; +import { BACK_TO_GET_STARTED, CHAT_PAGE } from './copy'; +import { addStatus, renderPageHead, setStepName } from './pageParts'; + +export interface ChatSetupPageContext { + app: App; + router: SettingsRouter; + component: Component; + llmProviders?: LLMProviderSettings; + /** The plugin's manifest id; the chat command is `:open-chat`. */ + pluginId: string; + /** Close the settings window so the chat is visible. */ + closeSettings: () => void; +} + +export function renderChatSetupPage(container: HTMLElement, context: ChatSetupPageContext): void { + const { router, component } = context; + renderPageHead(container, { + backLabel: BACK_TO_GET_STARTED, + onBack: () => router.back(), + title: CHAT_PAGE.title + }, component); + + const { itemsEl } = createSettingGroup(container); + const configured = hasConfiguredProviders(context.llmProviders); + + const provider = new Setting(itemsEl).setDesc(CHAT_PAGE.providerDesc); + setStepName(provider, 1, CHAT_PAGE.providerName); + if (configured) { + addStatus(provider, CHAT_PAGE.connected, true); + } + provider.addButton(button => { + button.setButtonText(CHAT_PAGE.openProviders).onClick(() => router.setTab('providers')); + if (!configured) button.setCta(); + }); + + const openChat = new Setting(itemsEl).setDesc(CHAT_PAGE.openChatDesc); + setStepName(openChat, 2, CHAT_PAGE.openChatName); + openChat.addButton(button => button + .setButtonText(CHAT_PAGE.openChat) + .setCta() + .onClick(async () => { + try { + await context.app.commands.executeCommandById(`${context.pluginId}:open-chat`); + context.closeSettings(); + } catch (error) { + console.error('[ChatSetupPage] Could not open the chat:', error); + new Notice('Could not open the chat.'); + } + })); + + const start = new Setting(itemsEl).setDesc(CHAT_PAGE.startDesc); + setStepName(start, 3, CHAT_PAGE.startName); +} diff --git a/src/settings/getStarted/ExternalAgentsPage.ts b/src/settings/getStarted/ExternalAgentsPage.ts new file mode 100644 index 000000000..1f4fb183a --- /dev/null +++ b/src/settings/getStarted/ExternalAgentsPage.ts @@ -0,0 +1,520 @@ +/** + * Get started → Connect an external agent. + * + * Desktop only, and reached only through `await import()` behind the MCP bridge + * check, so the Node-backed helpers it uses never load on mobile. Layout: + * Node.js first, then one card of apps (Claude Desktop, Codex, Antigravity and + * the expandable "Other agents" steps), then the command line. + */ +import { App, Component, Notice, Platform, Setting, setIcon } from 'obsidian'; +import type { SettingsRouter } from '../SettingsRouter'; +import { createSettingGroup } from '../../components/shared/settings/settingGroup'; +import { getPrimaryServerKey, getServerKeyCandidates } from '../../constants/branding'; +import { desktopRequire } from '../../utils/desktopRequire'; +import { NodeRuntime, resolveNodeRuntime } from '../../utils/nodeRuntime'; +import { + buildMcpConfigJson, + buildMcpServerEntry, + ensureConnectorFile, + getConnectorPath, + isConnectorCurrent, + writeClaudeCodeServer, + writeCodexServer, + writeMcpJsonServer +} from '../../utils/mcpClientConfigs'; +import { resolveDesktopBinaryPath } from '../../utils/binaryDiscovery'; +import { ExternalClientStatus, getExternalClientStatuses } from '../getStartedStatus'; +import { CliInstallStatus, CliInstallTargets, CliPathFix, CliProviderId, LocalCliInstaller } from '../../services/cli/LocalCliInstaller'; +import { + AGENTS_PAGE, + BACK_TO_GET_STARTED, + CLAUDE_CODE_INSTALL_URL, + CLAUDE_DESKTOP_DOWNLOAD_URL, + MCP_SETUP_GUIDE_URL, + NODE_DOWNLOAD_URL, + buildAgentSetupPrompt +} from './copy'; +import { addStatus, makeRowActivatable, renderPageHead, setIconName, setStepName } from './pageParts'; + +export interface ExternalAgentsPageContext { + app: App; + router: SettingsRouter; + component: Component; + /** Absolute path of this plugin's folder; connector.js lives there. */ + pluginPath: string; +} + +interface ElectronShell { + openPath(path: string): Promise | string; + showItemInFolder(path: string): void; +} + +const CLI_TARGETS: Array<{ id: CliProviderId; label: string; path: string }> = [ + { id: 'claudeCode', label: 'Claude Code skills', path: '~/.claude/skills' }, + { id: 'cursor', label: 'Cursor skills', path: '~/.cursor/skills' }, + { id: 'codex', label: 'Codex AGENTS.md', path: '~/.codex/AGENTS.md' } +]; + +const OTHER_STEPS_ID = 'nexus-other-agents-steps'; + +export class ExternalAgentsPage { + private node: NodeRuntime | null; + private otherOpen = false; + private cliTargets: Partial | null = null; + + constructor( + private readonly container: HTMLElement, + private readonly context: ExternalAgentsPageContext, + /** Injected for tests; defaults to the real lookup. */ + private readonly findNode: () => NodeRuntime | null = resolveNodeRuntime + ) { + this.node = this.findNode(); + this.render(); + } + + render(): void { + const { container, context } = this; + container.empty(); + + renderPageHead(container, { + backLabel: BACK_TO_GET_STARTED, + onBack: () => context.router.back(), + title: AGENTS_PAGE.title + }, context.component); + container.createEl('p', { cls: 'nexus-page-lede', text: AGENTS_PAGE.lede }); + + this.renderNodeGroup(); + this.renderAppsGroup(); + this.renderCommandLineGroup(); + } + + // ---- Node.js ---- + + private renderNodeGroup(): void { + const { itemsEl } = createSettingGroup(this.container); + const row = new Setting(itemsEl).setName(AGENTS_PAGE.nodeName); + const node = this.node; + + if (node) { + const text = AGENTS_PAGE.nodeFound(node.version, node.path); + const at = text.lastIndexOf(node.path); + row.descEl.empty(); + row.descEl.appendText(text.slice(0, at)); + row.descEl.createEl('code', { text: node.path }); + row.descEl.appendText(text.slice(at + node.path.length)); + if (node.manager === 'nvm') { + row.descEl.createDiv({ cls: 'nexus-node-warning', text: AGENTS_PAGE.nodeNvmWarning }); + } + addStatus(row, AGENTS_PAGE.nodeFoundStatus, true); + return; + } + + row.setClass('nexus-node-missing'); + row.setDesc(AGENTS_PAGE.nodeMissing); + row.addButton(button => button + .setButtonText(AGENTS_PAGE.installNode) + .setCta() + .onClick(() => { window.open(NODE_DOWNLOAD_URL, '_blank'); })); + row.addButton(button => button + .setButtonText(AGENTS_PAGE.checkAgain) + .onClick(() => { + this.node = this.findNode(); + this.render(); + })); + } + + // ---- Apps ---- + + private renderAppsGroup(): void { + const { itemsEl } = createSettingGroup(this.container, 'nexus-gs-apps'); + for (const client of getExternalClientStatuses(this.context.app)) { + this.renderClientRow(itemsEl, client); + } + this.renderOtherAgents(itemsEl); + } + + private renderClientRow(itemsEl: HTMLElement, client: ExternalClientStatus): void { + const row = new Setting(itemsEl).setName(client.label); + const { state, stale } = client.status; + const configured = state === 'nexus-configured'; + + if (!this.node) { + addStatus(row, AGENTS_PAGE.needsNode); + if (!configured) { + row.addButton(button => button.setButtonText(AGENTS_PAGE.connect).setCta().setDisabled(true)); + } + return; + } + + if (configured && !stale) { + addStatus(row, AGENTS_PAGE.connected, true); + row.addButton(button => button + .setButtonText(AGENTS_PAGE.openConfig) + .onClick(() => this.openPath(client.statusPath))); + return; + } + + // No Claude folder means the app is not installed (or never opened). Writing + // a config there would report a connection to an app that does not exist. + if (client.id === 'claude-desktop' && state === 'no-config-dir') { + addStatus(row, AGENTS_PAGE.notFound); + row.addButton(button => button + .setButtonText(AGENTS_PAGE.download) + .onClick(() => { window.open(CLAUDE_DESKTOP_DOWNLOAD_URL, '_blank'); })); + row.addButton(button => button + .setButtonText(AGENTS_PAGE.checkAgain) + .onClick(() => this.render())); + return; + } + + // Claude Code is set up through its own CLI, so Connect needs the `claude` command. + if (client.id === 'claude-code' && !resolveDesktopBinaryPath('claude')) { + addStatus(row, AGENTS_PAGE.notFound); + row.addButton(button => button + .setButtonText(AGENTS_PAGE.install) + .onClick(() => { window.open(CLAUDE_CODE_INSTALL_URL, '_blank'); })); + row.addButton(button => button + .setButtonText(AGENTS_PAGE.checkAgain) + .onClick(() => this.render())); + return; + } + + if (configured) { + addStatus(row, AGENTS_PAGE.reconnect); + } else if (state === 'invalid-config') { + addStatus(row, AGENTS_PAGE.invalidConfig); + row.addButton(button => button + .setButtonText(AGENTS_PAGE.openConfig) + .onClick(() => this.openPath(client.statusPath))); + } else { + addStatus(row, AGENTS_PAGE.ready); + } + row.addButton(button => button + .setButtonText(AGENTS_PAGE.connect) + .setCta() + .onClick(() => this.connect(client))); + } + + private connect(client: ExternalClientStatus): void { + const node = this.node; + if (!node) return; + + try { + const connectorPath = ensureConnectorFile(this.context.pluginPath); + const entry = buildMcpServerEntry(node.path, connectorPath); + // Current key first; an entry left under a legacy key is updated in place. + const serverKeys = getServerKeyCandidates(this.context.app.vault.getName()); + const claudeBin = client.id === 'claude-code' ? resolveDesktopBinaryPath('claude') : null; + if (client.id === 'claude-code' && !claudeBin) { + throw new Error('The claude command was not found.'); + } + const result = client.id === 'codex' + ? writeCodexServer(serverKeys, entry) + : client.id === 'claude-code' && claudeBin + ? writeClaudeCodeServer(claudeBin, serverKeys, entry) + : writeMcpJsonServer(client.configPath, serverKeys, entry); + const backup = result.replacedInvalid ? ` The old file was not valid JSON and was saved as ${result.backupPath}.` : ''; + const restart = client.id === 'claude-code' ? 'Start a new Claude Code session to load it.' : 'Restart it to load Nexus.'; + new Notice(`Nexus added to ${client.label}. ${restart}${backup}`); + } catch (error) { + console.error(`[ExternalAgentsPage] Could not connect ${client.label}:`, error); + new Notice(`Could not connect ${client.label}: ${(error as Error).message}`); + } + this.render(); + } + + private renderOtherAgents(itemsEl: HTMLElement): void { + const row = new Setting(itemsEl) + .setName(AGENTS_PAGE.otherName) + .setDesc(AGENTS_PAGE.otherDesc) + .setClass('nexus-other-row'); + // The whole row is the one control. The chevron is decoration, not a second + // tab stop, and the steps open in place so focus stays on the row. + const chevron = row.controlEl.createDiv({ cls: 'setting-item-chevron', attr: { 'aria-hidden': 'true' } }); + const steps = itemsEl.createDiv({ cls: 'nexus-other-steps', attr: { id: OTHER_STEPS_ID } }); + row.settingEl.setAttribute('aria-controls', OTHER_STEPS_ID); + const sync = (): void => { + const open = this.otherOpen; + setIcon(chevron, open ? 'chevron-up' : 'chevron-down'); + row.settingEl.setAttribute('aria-expanded', String(open)); + steps.hidden = !open; + }; + sync(); + makeRowActivatable(row.settingEl, () => { + this.otherOpen = !this.otherOpen; + sync(); + }, this.context.component); + + const node = this.node; + const connectorPath = this.connectorPath(); + const ready = Boolean(node && connectorPath); + + const create = new Setting(steps).setDesc(AGENTS_PAGE.step1Desc); + setStepName(create, 1, AGENTS_PAGE.step1Name); + if (isConnectorCurrent(this.context.pluginPath)) { + addStatus(create, AGENTS_PAGE.created, true); + } else { + create.addButton(button => button + .setButtonText(AGENTS_PAGE.create) + .setCta() + .setDisabled(!ready) + .onClick(() => this.createConnector())); + } + + const config = ready && node && connectorPath ? buildMcpConfigJson(this.serverKey(), node.path, connectorPath) : ''; + const copyConfig = new Setting(steps).setDesc(AGENTS_PAGE.step2Desc); + setStepName(copyConfig, 2, AGENTS_PAGE.step2Name); + copyConfig.descEl.createEl('pre', { cls: 'nexus-code', text: config || AGENTS_PAGE.step2NeedsNode }); + copyConfig.addButton(button => button + .setButtonText(AGENTS_PAGE.copyConfig) + .setDisabled(!config) + .onClick(() => this.copy(config, 'Config copied.'))); + + const prompt = ready && node && connectorPath ? buildAgentSetupPrompt(this.serverKey(), node.path, connectorPath) : ''; + const copyPrompt = new Setting(steps).setDesc(AGENTS_PAGE.step3Desc); + setStepName(copyPrompt, 3, AGENTS_PAGE.step3Name); + copyPrompt.descEl.createEl('pre', { cls: 'nexus-code nexus-prompt', text: prompt || AGENTS_PAGE.step3NeedsNode }); + copyPrompt.addButton(button => button + .setButtonText(AGENTS_PAGE.copyPrompt) + .setDisabled(!prompt) + .onClick(() => this.copy(prompt, 'Prompt copied.'))); + + const restart = new Setting(steps).setDesc(AGENTS_PAGE.step4Desc); + setStepName(restart, 4, AGENTS_PAGE.step4Name); + + const guide = steps.createEl('p', { cls: 'nexus-other-guide' }); + guide.appendText(AGENTS_PAGE.guideLead); + const link = guide.createEl('a', { text: AGENTS_PAGE.guideLink, href: MCP_SETUP_GUIDE_URL }); + this.context.component.registerDomEvent(link, 'click', (event: MouseEvent) => { + event.preventDefault(); + window.open(MCP_SETUP_GUIDE_URL, '_blank'); + }); + guide.appendText(AGENTS_PAGE.guideTail); + } + + private createConnector(): void { + try { + const connectorPath = ensureConnectorFile(this.context.pluginPath); + new Notice(`Connector file created: ${connectorPath}`); + } catch (error) { + console.error('[ExternalAgentsPage] Could not create the connector file:', error); + new Notice(`Could not create the connector file: ${(error as Error).message}`); + } + this.render(); + } + + // ---- Command line ---- + + private renderCommandLineGroup(): void { + const { itemsEl } = createSettingGroup(this.container, 'nexus-gs-cli'); + const row = new Setting(itemsEl).setDesc(AGENTS_PAGE.cliDesc); + setIconName(row, 'terminal', AGENTS_PAGE.cliName); + + const installer = new LocalCliInstaller(); + if (!installer.isSupported()) { + addStatus(row, AGENTS_PAGE.cliUnavailable); + return; + } + + let status: CliInstallStatus; + try { + // Keep an already-installed copy in sync with this plugin build. + installer.reconcile(); + status = installer.status(); + } catch (error) { + console.error('[ExternalAgentsPage] Could not read the command line status:', error); + addStatus(row, AGENTS_PAGE.cliUnavailable); + return; + } + + addStatus(row, cliStatusText(status), status.installed && status.runtimeReady && status.onPath); + + if (!status.installed) { + row.addButton(button => button + .setButtonText(AGENTS_PAGE.install) + .onClick(() => this.installCli(installer))); + } else { + if (Platform.isWin && !status.pathConfigured) { + row.addButton(button => button + .setButtonText(AGENTS_PAGE.addToPath) + .setCta() + .onClick(() => this.addCliToPath(installer))); + } + row.addExtraButton(button => button + .setIcon('folder-open') + .setTooltip(revealLabel()) + .onClick(() => this.reveal(status.paths.cliJsPath))); + row.addButton(button => button + .setButtonText(AGENTS_PAGE.uninstall) + .onClick(() => this.uninstallCli(installer))); + } + + // Before install the toggles pick targets; after install each one wires or unwires at once. + const targets = this.cliTargets ?? (this.cliTargets = { ...status.detected }); + for (const target of CLI_TARGETS) { + const linked = target.id === 'claudeCode' ? status.skillLinked + : target.id === 'cursor' ? status.cursorLinked + : status.codexLinked; + new Setting(itemsEl) + .setName(target.label) + .setDesc(target.path) + .setClass('nexus-child-row') + .setClass('nexus-path-row') + .addToggle(toggle => toggle + .setValue(status.installed ? linked : Boolean(targets[target.id])) + .onChange(value => { + if (status.installed) { + this.setCliProvider(installer, target.id, target.label, value); + } else { + targets[target.id] = value; + } + })); + } + + // Installed but the shell can't find it: hand over the exact edit. + if (status.installed && status.runtimeReady && !status.onPath && status.pathFix) { + this.renderCliPathFix(itemsEl, status.pathFix, installer); + } + } + + private renderCliPathFix(itemsEl: HTMLElement, fix: CliPathFix, installer: LocalCliInstaller): void { + const row = new Setting(itemsEl) + .setName('Shell setup') + .setDesc(`The nexus command is installed, but your ${fix.shell} shell can’t find it yet. Add this line to ${fix.profilePath}, then run ${fix.reloadCommand} or open a new terminal.`) + .setClass('nexus-child-row'); + row.descEl.createEl('pre', { cls: 'nexus-code', text: fix.exportLine }); + row.addButton(button => button + .setButtonText('Copy') + .onClick(() => this.copy(fix.exportLine, 'Copied. Paste it into your shell profile.'))); + row.addButton(button => button + .setButtonText('Check again') + .onClick(() => { + new Notice(installer.status().onPath + ? 'The nexus command works in your shell now.' + : 'Still not found. Make sure the line was saved, then open a new terminal.'); + this.render(); + })); + } + + private installCli(installer: LocalCliInstaller): void { + try { + const result = installer.enable(this.cliTargets ?? undefined); + for (const warning of result.warnings) console.warn('[ExternalAgentsPage] Local CLI:', warning); + const hint = Platform.isWin ? 'Open a new terminal, then run `nexus vaults`.' : 'Try running `nexus vaults` in your terminal.'; + new Notice(result.warnings.length + ? 'Nexus CLI installed, but some requested wiring was preserved or unavailable. See the developer console for details.' + : `Nexus CLI installed. ${hint}`); + } catch (error) { + console.error('[ExternalAgentsPage] Could not install the local CLI:', error); + new Notice(`Could not install the command line tool: ${(error as Error).message}`); + } + this.cliTargets = null; + this.render(); + } + + private addCliToPath(installer: LocalCliInstaller): void { + try { + const result = installer.addToWindowsUserPath(); + for (const warning of result.warnings) console.warn('[ExternalAgentsPage] Local CLI:', warning); + new Notice(result.warnings.length + ? 'Could not add the command line tool to PATH. See the developer console for details.' + : 'Added to your user PATH. Open a new terminal to use it.'); + } catch (error) { + console.error('[ExternalAgentsPage] Could not add the local CLI to PATH:', error); + new Notice(`Could not add the command line tool to PATH: ${(error as Error).message}`); + } + this.render(); + } + + private setCliProvider(installer: LocalCliInstaller, id: CliProviderId, label: string, enabled: boolean): void { + try { + const result = installer.setProvider(id, enabled); + for (const warning of result.warnings) console.warn('[ExternalAgentsPage] Local CLI:', warning); + new Notice(result.warnings.length + ? `Could not update ${label}. See the developer console for details.` + : enabled ? `Turned on ${label}.` : `Turned off ${label}.`); + } catch (error) { + console.error('[ExternalAgentsPage] Could not update a CLI provider:', error); + new Notice(`Could not update ${label}: ${(error as Error).message}`); + } + this.render(); + } + + private uninstallCli(installer: LocalCliInstaller): void { + try { + const result = installer.uninstall(); + for (const warning of result.warnings) console.warn('[ExternalAgentsPage] Local CLI:', warning); + new Notice(result.warnings.length + ? 'Command line cleanup was incomplete. See the developer console for details.' + : 'Nexus CLI uninstalled.'); + } catch (error) { + console.error('[ExternalAgentsPage] Could not uninstall the local CLI:', error); + new Notice(`Could not uninstall the command line tool: ${(error as Error).message}`); + } + this.cliTargets = null; + this.render(); + } + + // ---- helpers ---- + + private serverKey(): string { + return getPrimaryServerKey(this.context.app.vault.getName()); + } + + private connectorPath(): string | null { + try { + return getConnectorPath(this.context.pluginPath); + } catch { + return null; + } + } + + private copy(text: string, done: string): void { + if (!text) return; + void navigator.clipboard.writeText(text) + .then(() => { new Notice(done); }) + .catch(() => { new Notice('Could not copy. Select the text and copy it yourself.'); }); + } + + private shell(): ElectronShell | null { + try { + return desktopRequire<{ shell: ElectronShell }>('electron').shell; + } catch { + return null; + } + } + + private openPath(path: string): void { + const shell = this.shell(); + if (!shell) { + new Notice('Could not open the config file. Open it yourself.'); + return; + } + void Promise.resolve(shell.openPath(path)).catch((error: unknown) => { + console.error('[ExternalAgentsPage] Could not open the config file:', error); + new Notice('Could not open the config file. Open it yourself.'); + }); + } + + private reveal(path: string): void { + const shell = this.shell(); + if (!shell) { + new Notice('Could not show the file. Find it yourself.'); + return; + } + shell.showItemInFolder(path); + } +} + +function cliStatusText(status: CliInstallStatus): string { + if (!status.installed) return AGENTS_PAGE.cliNotInstalled; + if (!status.runtimeReady) return AGENTS_PAGE.needsNode; + if (!status.onPath) return AGENTS_PAGE.cliNotOnPath; + return AGENTS_PAGE.cliInstalled; +} + +function revealLabel(): string { + if (Platform.isWin) return 'Show in Explorer'; + if (Platform.isMacOS) return 'Show in Finder'; + return 'Show in file manager'; +} diff --git a/src/settings/getStarted/GetStartedPage.ts b/src/settings/getStarted/GetStartedPage.ts new file mode 100644 index 000000000..ef8d45730 --- /dev/null +++ b/src/settings/getStarted/GetStartedPage.ts @@ -0,0 +1,75 @@ +/** + * The Get started row above the tab strip and the Get started page it opens. + * Mobile-safe: the external-agent path is offered only on desktop, and its + * status comes from getStartedStatus, which loads Node built-ins lazily. + */ +import { App, Component, Setting, setIcon } from 'obsidian'; +import type { SettingsRouter } from '../SettingsRouter'; +import type { LLMProviderSettings } from '../../types/llm/ProviderTypes'; +import { createSettingGroup } from '../../components/shared/settings/settingGroup'; +import { countExternalConnections, hasConfiguredProviders } from '../getStartedStatus'; +import { BACK_TO_SETTINGS, GET_STARTED_PAGE, GET_STARTED_ROW } from './copy'; +import { addNavigableRow, renderPageHead } from './pageParts'; + +export interface GetStartedPageContext { + app: App; + router: SettingsRouter; + component: Component; + llmProviders?: LLMProviderSettings; + /** Desktop with the MCP bridge: the external-agent path is offered. */ + externalAgentsAvailable: boolean; +} + +/** The single navigable row above the tab strip. */ +export function renderGetStartedRow(container: HTMLElement, router: SettingsRouter, component: Component): void { + const { itemsEl } = createSettingGroup(container, 'nexus-get-started'); + addNavigableRow(itemsEl, { + icon: 'rocket', + name: GET_STARTED_ROW.name, + desc: GET_STARTED_ROW.desc, + onOpen: () => router.openPage('get-started') + }, component); +} + +export function renderGetStartedPage(container: HTMLElement, context: GetStartedPageContext): void { + const { router, component } = context; + renderPageHead(container, { + backLabel: BACK_TO_SETTINGS, + onBack: () => router.back(), + title: GET_STARTED_PAGE.title + }, component); + + container.createEl('p', { cls: 'nexus-page-lede', text: GET_STARTED_PAGE.lede }); + + const list = container.createEl('ul', { cls: 'nexus-can' }); + for (const capability of GET_STARTED_PAGE.capabilities) { + const item = list.createEl('li'); + setIcon(item.createSpan({ cls: 'nexus-can-icon' }), capability.icon); + item.createSpan({ text: capability.text }); + } + + new Setting(container) + .setHeading() + .setName(GET_STARTED_PAGE.setupHeading) + .setDesc(GET_STARTED_PAGE.setupDesc) + .setClass('nexus-gs-heading'); + + const { itemsEl } = createSettingGroup(container); + addNavigableRow(itemsEl, { + icon: 'message-square', + name: GET_STARTED_PAGE.chatName, + desc: GET_STARTED_PAGE.chatDesc, + value: hasConfiguredProviders(context.llmProviders) ? GET_STARTED_PAGE.chatReady : GET_STARTED_PAGE.chatNotSetUp, + onOpen: () => router.openPage('get-started-chat') + }, component); + + if (context.externalAgentsAvailable) { + addNavigableRow(itemsEl, { + icon: 'plug', + name: GET_STARTED_PAGE.agentsName, + desc: GET_STARTED_PAGE.agentsDesc, + value: GET_STARTED_PAGE.connectedCount(countExternalConnections(context.app)), + onOpen: () => router.openPage('get-started-agents') + }, component); + } +} diff --git a/src/settings/getStarted/copy.ts b/src/settings/getStarted/copy.ts new file mode 100644 index 000000000..a209cd168 --- /dev/null +++ b/src/settings/getStarted/copy.ts @@ -0,0 +1,117 @@ +/** + * User-facing copy for the Get started row and its pages, lifted from the + * blessed mockup (docs/mockups/defaults-settings-essentials.*). Tests import + * these constants instead of repeating the strings. + */ + +export const GET_STARTED_ROW = { + name: 'Get started', + desc: 'Set up chat in Obsidian, or connect Claude Desktop, Codex and other agents.' +} as const; + +export const BACK_TO_SETTINGS = 'Settings'; +export const BACK_TO_GET_STARTED = 'Get started'; + +export const GET_STARTED_PAGE = { + title: 'Get started', + lede: 'Nexus is an assistant that lives in your vault. It reads and writes your notes, searches them, and remembers context between conversations. Your data stays in your vault.', + capabilities: [ + { icon: 'pencil-line', text: 'Read, create and edit notes' }, + { icon: 'search', text: 'Search by keyword or by meaning' }, + { icon: 'layers', text: 'Remember context with workspaces' }, + { icon: 'folder', text: 'Organize files and folders' }, + { icon: 'bot', text: 'Run saved prompts and subagents' }, + { icon: 'wifi-off', text: 'Work offline with local models' } + ], + setupHeading: 'Choose your setup', + setupDesc: 'Pick one, or use both.', + chatName: 'Chat inside Obsidian', + chatDesc: 'Connect a model provider and chat in a side panel.', + chatReady: 'Ready', + chatNotSetUp: 'Not set up', + agentsName: 'Connect an external agent', + agentsDesc: 'Claude Desktop, Codex, Cursor and more use your vault.', + connectedCount: (count: number): string => `${count} connected` +} as const; + +export const CHAT_PAGE = { + title: 'Chat inside Obsidian', + providerName: 'Connect a model provider', + providerDesc: 'An API key, a sign-in, or a local model such as Ollama.', + connected: 'Connected', + openProviders: 'Open providers', + openChatName: 'Open the chat', + openChatDesc: 'From the ribbon icon or the “Nexus: Open chat” command.', + openChat: 'Open chat', + startName: 'Start chatting', + startDesc: 'Ask about your notes. Nexus can read, search and edit them.' +} as const; + +export const AGENTS_PAGE = { + title: 'Connect an external agent', + lede: 'Let apps outside Obsidian use your vault through Nexus.', + nodeName: 'Node.js', + nodeFound: (version: string, path: string): string => + version ? `Runs the connector. Found ${version} at ${path}.` : `Runs the connector. Found at ${path}.`, + nodeFoundStatus: 'Found', + nodeNvmWarning: 'This path changes when you switch Node versions with nvm. Reconnect your apps after switching, or install Node.js from nodejs.org for a stable path.', + nodeMissing: 'Every agent connection runs through Node.js, and it isn’t installed or Nexus can’t find it. Install it, then check again.', + installNode: 'Install Node.js', + checkAgain: 'Check again', + needsNode: 'Needs Node.js', + connected: 'Connected', + ready: 'Ready', + notFound: 'Not found', + download: 'Download', + invalidConfig: 'Invalid config', + reconnect: 'Reconnect', + connect: 'Connect', + openConfig: 'Open config', + otherName: 'Other agents', + otherDesc: 'Cursor, Cline, Copilot, Windsurf and others. Four steps.', + step1Name: 'Create the connector file', + step1Desc: 'connector.js lets the app talk to this vault. It goes in the Nexus plugin folder.', + create: 'Create', + created: 'Created', + step2Name: 'Copy the config', + step2Desc: 'The Nexus entry for the app’s MCP settings, with this computer’s paths.', + copyConfig: 'Copy config', + step2NeedsNode: 'Install Node.js first. The config needs its location.', + step3Name: 'Add it to your app', + step3Desc: 'Paste it into the app’s MCP settings yourself, or paste this prompt into the agent and let it make the change.', + copyPrompt: 'Copy prompt', + step3NeedsNode: 'Install Node.js first.', + step4Name: 'Restart the app', + step4Desc: 'Quit and reopen it so it loads Nexus. Then ask it to list your vault’s folders to check.', + guideLead: 'Exact config locations for each app are in the ', + guideLink: 'MCP setup guide', + guideTail: '.', + cliName: 'Command line', + cliDesc: 'Lets Claude Code, Cursor and Codex use Nexus without MCP.', + cliInstalled: 'Installed', + cliNotInstalled: 'Not installed', + cliNotOnPath: 'Not on your PATH', + cliUnavailable: 'Not available', + install: 'Install', + uninstall: 'Uninstall', + addToPath: 'Add to path' +} as const; + +/** Claude Desktop's download page, offered when the app is not found. */ +export const CLAUDE_DESKTOP_DOWNLOAD_URL = 'https://claude.ai/download'; + +/** Claude Code's install guide, offered when the `claude` command is not found. */ +export const CLAUDE_CODE_INSTALL_URL = 'https://docs.claude.com/en/docs/claude-code/setup'; + +/** Install page for Node.js, opened by the "Install Node.js" button. */ +export const NODE_DOWNLOAD_URL = 'https://nodejs.org/'; + +/** The MCP setup guide, linked under the Other agents steps. */ +export const MCP_SETUP_GUIDE_URL = 'https://github.com/ProfSynapse/nexus/blob/main/guide/mcp-setup.md'; + +/** The prompt an agent can follow to add the Nexus server to its own config. */ +export function buildAgentSetupPrompt(serverKey: string, nodePath: string, connectorPath: string): string { + // The connector path sits inside a JSON array, so it is JSON-escaped; this + // keeps Windows backslashes intact when the agent copies it. + return `Add an MCP server to your configuration. Name: ${serverKey}. Command: ${nodePath}. Args: [${JSON.stringify(connectorPath)}]. Use these paths exactly. Merge it into the existing mcpServers and keep any servers already there. Then tell me where you saved it.`; +} diff --git a/src/settings/getStarted/pageParts.ts b/src/settings/getStarted/pageParts.ts new file mode 100644 index 000000000..180c57296 --- /dev/null +++ b/src/settings/getStarted/pageParts.ts @@ -0,0 +1,86 @@ +/** + * Building blocks shared by the Get started row and pages: the page head with + * the BackButton, navigable rows, numbered steps and status values. Mobile-safe: + * imports only `obsidian` and other DOM-only helpers. + */ +import { Component, Setting, setIcon } from 'obsidian'; +import { BackButton } from '../components/BackButton'; + +export function renderPageHead( + container: HTMLElement, + options: { backLabel: string; onBack: () => void; title: string }, + component: Component +): HTMLElement { + const head = container.createDiv({ cls: 'nexus-page-head' }); + new BackButton(head, options.backLabel, options.onBack, component); + head.createEl('h2', { cls: 'nexus-page-title', text: options.title }); + return head; +} + +/** Replace a row name with ``, keeping the text as its own node. */ +function fillName(nameEl: HTMLElement, text: string, prefix?: (nameEl: HTMLElement) => void): void { + nameEl.empty(); + prefix?.(nameEl); + nameEl.appendText(text); +} + +/** A row name preceded by a Lucide icon. */ +export function setIconName(setting: Setting, icon: string, text: string): void { + fillName(setting.nameEl, text, nameEl => setIcon(nameEl.createSpan({ cls: 'nexus-row-icon' }), icon)); +} + +/** A row name preceded by a numbered step badge. */ +export function setStepName(setting: Setting, step: number, text: string): void { + fillName(setting.nameEl, text, nameEl => { + nameEl.createSpan({ cls: 'nexus-step', text: String(step) }); + }); +} + +/** A status value in a row's control area, with a check mark when it is good news. */ +export function addStatus(setting: Setting, text: string, ok = false): HTMLElement { + const statusEl = setting.controlEl.createDiv({ cls: 'setting-item-value nexus-status' }); + if (ok) { + statusEl.addClass('is-ok'); + setIcon(statusEl.createSpan({ cls: 'nexus-status-check' }), 'circle-check'); + } + statusEl.appendText(text); + return statusEl; +} + +/** Make a whole row act as a button: click, Enter and Space all run `onActivate`. */ +export function makeRowActivatable(settingEl: HTMLElement, onActivate: () => void, component: Component): void { + settingEl.setAttribute('role', 'button'); + settingEl.setAttribute('tabindex', '0'); + component.registerDomEvent(settingEl, 'click', () => onActivate()); + component.registerDomEvent(settingEl, 'keydown', (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onActivate(); + } + }); +} + +export interface NavigableRowOptions { + icon?: string; + name: string; + desc: string; + value?: string; + onOpen: () => void; +} + +/** A row that opens another page: name (with icon), description, optional value and a chevron. */ +export function addNavigableRow(container: HTMLElement, options: NavigableRowOptions, component: Component): Setting { + const setting = new Setting(container).setDesc(options.desc); + setting.settingEl.addClass('mod-navigable'); + if (options.icon) { + setIconName(setting, options.icon, options.name); + } else { + fillName(setting.nameEl, options.name); + } + if (options.value) { + setting.controlEl.createDiv({ cls: 'setting-item-value', text: options.value }); + } + setIcon(setting.controlEl.createDiv({ cls: 'setting-item-chevron' }), 'chevron-right'); + makeRowActivatable(setting.settingEl, options.onOpen, component); + return setting; +} diff --git a/src/settings/getStartedStatus.ts b/src/settings/getStartedStatus.ts index fef84c3e7..6ed90b63f 100644 --- a/src/settings/getStartedStatus.ts +++ b/src/settings/getStartedStatus.ts @@ -3,17 +3,16 @@ import type { LLMProviderConfig, LLMProviderSettings } from '../types/llm/Provid import { getServerKeyCandidates } from '../constants/branding'; import { supportsMCPBridge } from '../utils/platform'; import { desktopRequire } from '../utils/desktopRequire'; -import type { MCPSettings } from '../types/plugin/PluginTypes'; -import { hasCodexMcpServerConfig } from '../utils/codexMcpConfig'; import { LocalCliInstaller } from '../services/cli/LocalCliInstaller'; - -export type ConfigStatus = - | 'unsupported' - | 'no-claude-folder' - | 'no-config-file' - | 'nexus-configured' - | 'config-exists' - | 'invalid-config'; +import { + type McpConfigStatus, + getAntigravityConfigPath, + getAntigravityLegacyConfigPath, + getClaudeCodeConfigPath, + getCodexConfigPath, + getCodexStatus, + getMcpJsonStatus +} from '../utils/mcpClientConfigs'; export function isProviderConfigured(providerId: string, config?: LLMProviderConfig): boolean { if (!config?.enabled) { @@ -74,99 +73,112 @@ export function getClaudeDesktopConfigPath(): string | null { return joinPath(process.env.HOME || '', '.config', 'Claude', 'claude_desktop_config.json'); } -export function getConfigStatus(app: App): ConfigStatus { - if (!Platform.isDesktop || !supportsMCPBridge()) { - return 'unsupported'; - } +export type ExternalClientId = 'claude-desktop' | 'claude-code' | 'codex' | 'antigravity'; - const configPath = getClaudeDesktopConfigPath(); - if (!configPath) { - return 'unsupported'; - } +export interface ExternalClientStatus { + id: ExternalClientId; + label: string; + /** The file Connect writes. */ + configPath: string; + /** The file that produced `status`, which Open config opens. Usually `configPath`. */ + statusPath: string; + status: McpConfigStatus; +} - const configDir = configPath.replace(/[\\/][^\\/]+$/, ''); - const nodeFs = desktopRequire('node:fs'); +/** + * One-click MCP clients in display order, with the state of their configs. + * Empty on mobile and wherever the MCP bridge is unavailable. + */ +export function getExternalClientStatuses(app: App): ExternalClientStatus[] { + if (!Platform.isDesktop || !supportsMCPBridge()) { + return []; + } + + const keys = getServerKeyCandidates(app.vault.getName()); + const statuses: ExternalClientStatus[] = []; + /** `read` names `statusPath` when another file than `configPath` produced the status. */ + type Read = () => McpConfigStatus | { status: McpConfigStatus; statusPath: string }; + const add = (id: ExternalClientId, label: string, configPath: string, read: Read): void => { + let status: McpConfigStatus; + let path = configPath; + try { + const result = read(); + if ('status' in result) { + status = result.status; + path = result.statusPath; + } else { + status = result; + } + } catch (error) { + console.error(`[getStartedStatus] Could not read the ${label} config:`, error); + status = { state: 'invalid-config', stale: false }; + } + statuses.push({ id, label, configPath, statusPath: path, status }); + }; - if (!nodeFs.existsSync(configDir)) { - return 'no-claude-folder'; + const claudePath = getClaudeDesktopConfigPath(); + if (claudePath) { + add('claude-desktop', 'Claude Desktop', claudePath, () => getMcpJsonStatus(claudePath, keys)); } - if (!nodeFs.existsSync(configPath)) { - return 'no-config-file'; + try { + const claudeCodePath = getClaudeCodeConfigPath(); + add('claude-code', 'Claude Code', claudeCodePath, () => getMcpJsonStatus(claudeCodePath, keys)); + } catch (error) { + console.error('[getStartedStatus] Could not resolve the Claude Code config path:', error); } try { - const content = nodeFs.readFileSync(configPath, 'utf-8'); - if (!content.trim()) { - return 'invalid-config'; - } - - const config = JSON.parse(content) as unknown; - if (isRecord(config) && isRecord(config.mcpServers)) { - const servers = config.mcpServers; - if (getServerKeyCandidates(app.vault.getName()).some(key => Boolean(servers[key]))) { - return 'nexus-configured'; - } - } + const codexPath = getCodexConfigPath(); + add('codex', 'Codex', codexPath, () => getCodexStatus(keys)); + } catch (error) { + console.error('[getStartedStatus] Could not resolve the Codex config path:', error); + } - return 'config-exists'; + try { + const antigravityPath = getAntigravityConfigPath(); + add('antigravity', 'Antigravity', antigravityPath, () => { + const current = getMcpJsonStatus(antigravityPath, keys); + if (current.state === 'nexus-configured') return current; + // Older builds kept the file elsewhere. When only that copy names Nexus, + // let Open config open it; Connect still writes the current file. + const legacyPath = getAntigravityLegacyConfigPath(); + const legacy = getMcpJsonStatus(legacyPath, keys); + return legacy.state === 'nexus-configured' ? { status: legacy, statusPath: legacyPath } : current; + }); } catch (error) { - console.error('[getStartedStatus] Error parsing config:', error); - return 'invalid-config'; + console.error('[getStartedStatus] Could not resolve the Antigravity config path:', error); } -} -export function isMCPConfigured(app: App): boolean { - return getConfigStatus(app) === 'nexus-configured'; + return statuses; } -/** Setup can use internal chat OR external agents; neither path requires the other. */ -export function shouldOpenGetStarted(app: App, settings: MCPSettings): boolean { - if (typeof settings.getStartedOpen === 'boolean') { - return settings.getStartedOpen; - } - return !hasConfiguredProviders(settings.llmProviders) && !hasExternalAgentSetup(app); +export function isMCPConfigured(app: App): boolean { + return getExternalClientStatuses(app).some(client => client.status.state === 'nexus-configured'); } -export function hasExternalAgentSetup(app: App): boolean { +/** Installation is enough for onboarding. status() probes shells and runtimes, so it is avoided here. */ +function isLocalCliInstalled(): boolean { if (!Platform.isDesktop || !supportsMCPBridge()) { return false; } - - // Installation is enough for onboarding. Avoid status(), which probes shells - // and runtime versions synchronously; those diagnostics belong in setup. try { const nodeFs = desktopRequire('node:fs'); - if (nodeFs.existsSync(new LocalCliInstaller().getPaths().cliJsPath)) { - return true; - } + return nodeFs.existsSync(new LocalCliInstaller().getPaths().cliJsPath); } catch { - // An unavailable CLI path must not hide another configured setup path. + return false; } +} - try { - const nodeFs = desktopRequire('node:fs'); - const path = desktopRequire('node:path'); - const home = Platform.isWin ? process.env.USERPROFILE || process.env.HOME || '' : process.env.HOME || ''; - const codexHome = process.env.CODEX_HOME || path.join(home, '.codex'); - const configPath = path.join(codexHome, 'config.toml'); - if (nodeFs.existsSync(configPath)) { - const content = nodeFs.readFileSync(configPath, 'utf-8'); - if (getServerKeyCandidates(app.vault.getName()).some(key => hasCodexMcpServerConfig(content, key))) { - return true; - } - } - } catch { - // Missing or unreadable client configuration is not a settings-render error. - } +/** Claude Desktop, Claude Code, Codex and Antigravity configs that name this vault, plus the command line when installed. */ +export function countExternalConnections(app: App): number { + const clients = getExternalClientStatuses(app).filter(client => client.status.state === 'nexus-configured').length; + return clients + (isLocalCliInstalled() ? 1 : 0); +} - try { - return isMCPConfigured(app); - } catch { +export function hasExternalAgentSetup(app: App): boolean { + if (!Platform.isDesktop || !supportsMCPBridge()) { return false; } -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); + return isLocalCliInstalled() || isMCPConfigured(app); } diff --git a/src/settings/tabs/DefaultsTab.ts b/src/settings/tabs/DefaultsTab.ts index 84d6a4daa..9d47d53da 100644 --- a/src/settings/tabs/DefaultsTab.ts +++ b/src/settings/tabs/DefaultsTab.ts @@ -1,57 +1,30 @@ /** * DefaultsTab - Default settings for new chats * - * Uses ChatSettingsRenderer for identical UI to ChatSettingsModal. - * Saves to plugin settings (defaults for all new chats). + * Uses ChatSettingsRenderer for the same layout as the Chat settings modal and + * adds the Defaults-only folds (Video, File conversion, Semantic search). + * Every change is persisted through one save queue, so no two writes of the + * plugin settings race each other. */ -import { App, Notice, Platform, Setting } from 'obsidian'; -import { - DefaultRealtimeVoiceModelSettings, - DefaultSpeechModelSettings, - LLMProviderSettings -} from '../../types/llm/ProviderTypes'; +import { App, Notice, Platform } from 'obsidian'; +import { LLMProviderSettings } from '../../types/llm/ProviderTypes'; import { Settings } from '../../settings'; import { DEFAULT_STORAGE_SETTINGS } from '../../types/plugin/PluginTypes'; import { WorkspaceService } from '../../services/WorkspaceService'; import { CustomPromptStorageService } from '../../agents/promptManager/services/CustomPromptStorageService'; -import { ChatSettingsRenderer, ChatSettings } from '../../components/shared/ChatSettingsRenderer'; +import { ChatSettingsRenderer, ChatSettings, ChatSettingsOptions } from '../../components/shared/ChatSettingsRenderer'; +import type { FoldDefinition } from '../../components/shared/chatSettings/types'; import { LLMProviderManager } from '../../services/llm/providers/ProviderManager'; -import { - getIngestCapabilityOptions, - normalizeIngestSelection, -} from '../../agents/ingestManager/tools/services/IngestCapabilityService'; -import { renderIngestModelDropdowns } from '../../components/shared/IngestModelDropdownRenderer'; +import { getIngestCapabilityOptions } from '../../agents/ingestManager/tools/services/IngestCapabilityService'; import { AppManager } from '../../services/apps/AppManager'; -import { ProviderUtils } from '../../ui/chat/utils/ProviderUtils'; -import { - buildSpeechProviderAvailability, - getSpeechModel, - resolveDefaultSpeechSelection, - type SpeechProvider, - SpeechAppCapabilityStates, - SpeechModelDeclaration, - SpeechProviderAvailability, -} from '../../services/llm/types/SpeechTypes'; -import { - buildRealtimeVoiceProviderAvailability, - getRealtimeVoiceModel, - RealtimeAppCapabilityStates, - RealtimeVoiceModelDeclaration, - RealtimeVoiceProviderAvailability, - resolveDefaultRealtimeVoiceSelection, -} from '../../services/llm/types/RealtimeVoiceTypes'; -import { VoiceCatalogService } from '../../services/readAloud/VoiceCatalogService'; -import { - buildVideoProviderAvailability, - getVideoModel, - type VideoAspectRatio, - type VideoModelDeclaration, - type VideoResolution, -} from '../../services/llm/types/VideoTypes'; -import { SpeechModelCatalogService } from '../../services/readAloud/SpeechModelCatalogService'; -import { sortModelsNewestFirst } from '../../utils/modelOrdering'; -import { ChatView } from '../../ui/chat/ChatView'; +import { FoldStateStore } from '../../components/shared/settings/FoldStateStore'; +import { VideoSettings } from './defaults/VideoSettings'; +import { FileConversionSettings } from './defaults/FileConversionSettings'; +import { SemanticSearchSettings, type SemanticSearchRuntime } from './defaults/SemanticSearchSettings'; +import { SemanticSearchOffModal } from './defaults/SemanticSearchOffModal'; +import { ConfirmModal } from '../components/ConfirmModal'; +import { SEMANTIC_DELETE_MODAL_COPY } from '../../components/shared/chatSettings/copy'; export interface DefaultsTabServices { app: App; @@ -60,6 +33,29 @@ export interface DefaultsTabServices { workspaceService?: WorkspaceService; customPromptStorage?: CustomPromptStorageService; appManager?: AppManager; + /** + * Desktop semantic search runtime, for the Semantic search fold's status, + * hot-apply and index deletion. Without it the fold shows only the switch + * state and saves the setting. + */ + semanticSearch?: { + getRuntime(): SemanticSearchRuntime | null; + applyEnabled(enabled: boolean): Promise; + }; +} + +/** + * Reduce free-text input to a safe single folder NAME: trim, collapse path + * separators and leading dots so the value can never escape the storage root + * or become a nested path. Returns '' for empty/invalid input so the caller + * can fall back to the default. + */ +export function sanitizeSubfolderName(value: string): string { + return value + .trim() + .replace(/[\\/]+/g, '') + .replace(/^\.+/, '') + .trim(); } export class DefaultsTab { @@ -85,15 +81,16 @@ export class DefaultsTab { const workspaces = await this.loadWorkspaces(); const prompts = this.loadPrompts(); - await this.render(workspaces, prompts); + this.render(workspaces, prompts); } - private async loadWorkspaces(): Promise> { + private async loadWorkspaces(): Promise { if (!this.services.workspaceService) return []; try { const workspaces = await this.services.workspaceService.getAllWorkspaces(); - return workspaces.map(w => ({ id: w.id, name: w.name })); + // The prompt binding travels with each workspace so choosing one can preselect its prompt. + return workspaces.map(w => ({ id: w.id, name: w.name, dedicatedAgentId: w.dedicatedAgentId, context: w.context })); } catch { return []; } @@ -116,8 +113,15 @@ export class DefaultsTab { private getCurrentSettings(): ChatSettings { const llmSettings = this.services.llmProviderSettings; const pluginSettings = this.services.settings.settings; + // A read-aloud or live voice default counts as chosen once it has a source + // of 'user' or a provider/model; otherwise the automatic pick applies. + const speech = llmSettings?.defaultSpeechModel; + const speechChosen = speech?.source === 'user' || !!speech?.provider || !!speech?.model; + const realtime = llmSettings?.defaultRealtimeVoiceModel; + const realtimeChosen = realtime?.source === 'user' || !!realtime?.provider || !!realtime?.model; + const video = llmSettings?.defaultVideoModel; - const result = { + return { provider: llmSettings?.defaultModel?.provider || '', model: llmSettings?.defaultModel?.model || '', contextWindowOverrides: { ...(llmSettings?.contextWindowOverrides ?? {}) }, @@ -132,15 +136,28 @@ export class DefaultsTab { effort: llmSettings.agentThinking.effort ?? 'medium' } : undefined, temperature: llmSettings?.defaultTemperature ?? 0.5, + agentTemperature: llmSettings?.agentTemperature, + agentContextWindowOverrides: { ...(llmSettings?.agentContextWindowOverrides ?? {}) }, imageProvider: llmSettings?.defaultImageModel?.provider || 'google', imageModel: llmSettings?.defaultImageModel?.model || 'gemini-3.1-flash-image', + speechProvider: speechChosen ? speech?.provider : undefined, + speechModel: speechChosen ? speech?.model : undefined, + speechVoice: speechChosen ? speech?.voice : undefined, + speechSource: speechChosen ? speech?.source : undefined, + realtimeVoiceProvider: realtimeChosen ? realtime?.provider : undefined, + realtimeVoiceModel: realtimeChosen ? realtime?.model : undefined, + realtimeVoiceVoice: realtimeChosen ? realtime?.voice : undefined, + realtimeVoiceSource: realtimeChosen ? realtime?.source : undefined, transcriptionProvider: llmSettings?.defaultTranscriptionModel?.provider, transcriptionModel: llmSettings?.defaultTranscriptionModel?.model, + videoProvider: video?.provider, + videoModel: video?.model, + videoAspectRatio: video?.aspectRatio, + videoResolution: video?.resolution, workspaceId: pluginSettings.defaultWorkspaceId || null, promptId: pluginSettings.defaultPromptId || null, - contextNotes: pluginSettings.defaultContextNotes || [] + contextNotes: [...(pluginSettings.defaultContextNotes || [])] }; - return result; } /** @@ -150,15 +167,8 @@ export class DefaultsTab { const llmSettings = this.services.llmProviderSettings; const pluginSettings = this.services.settings.settings; + // Defaults apply to new chats only; an open chat keeps its own context budget. if (llmSettings) { - const previousOverrides = llmSettings.contextWindowOverrides ?? {}; - const nextOverrides = settings.contextWindowOverrides ?? {}; - const changedKeys = new Set([...Object.keys(previousOverrides), ...Object.keys(nextOverrides)] - .filter(key => previousOverrides[key] !== nextOverrides[key])); - if (changedKeys.size > 0) { - const activeChat = this.services.app.workspace.getActiveViewOfType(ChatView); - if (activeChat && !await activeChat.applyContextWindowDefault(nextOverrides, changedKeys)) return; - } llmSettings.defaultModel = { provider: settings.provider, model: settings.model @@ -187,6 +197,14 @@ export class DefaultsTab { llmSettings.agentThinking = undefined; } llmSettings.defaultTemperature = settings.temperature; + // Subagent Creativity is written only once set; until then it follows the chat default. + if (typeof settings.agentTemperature === 'number') { + llmSettings.agentTemperature = settings.agentTemperature; + } + const agentOverrides = { ...(settings.agentContextWindowOverrides ?? {}) }; + if (Object.keys(agentOverrides).length > 0 || llmSettings.agentContextWindowOverrides) { + llmSettings.agentContextWindowOverrides = agentOverrides; + } llmSettings.defaultImageModel = { provider: settings.imageProvider, model: settings.imageModel @@ -197,56 +215,142 @@ export class DefaultsTab { model: settings.transcriptionModel } : undefined; + // Voice defaults are written only once the user has picked them; until + // then the automatic pick stays automatic. + if (settings.speechSource === 'user') { + llmSettings.defaultSpeechModel = settings.speechProvider + ? { provider: settings.speechProvider, model: settings.speechModel, voice: settings.speechVoice, source: 'user' } + : undefined; + } + if (settings.realtimeVoiceSource === 'user') { + llmSettings.defaultRealtimeVoiceModel = settings.realtimeVoiceProvider + ? { provider: settings.realtimeVoiceProvider, model: settings.realtimeVoiceModel, voice: settings.realtimeVoiceVoice, source: 'user' } + : undefined; + } + if (settings.videoProvider && settings.videoModel) { + llmSettings.defaultVideoModel = { + provider: settings.videoProvider, + model: settings.videoModel, + aspectRatio: settings.videoAspectRatio, + resolution: settings.videoResolution + }; + } pluginSettings.llmProviders = llmSettings; } pluginSettings.defaultWorkspaceId = settings.workspaceId || undefined; pluginSettings.defaultPromptId = settings.promptId || undefined; - pluginSettings.defaultContextNotes = settings.contextNotes; + pluginSettings.defaultContextNotes = [...settings.contextNotes]; await this.services.settings.saveSettings(); } + /** + * Apply a change and queue a save behind any save already in flight. Every + * write from this tab goes through here. + */ + private enqueueSave(mutate?: () => void | Promise): void { + this.settingsSaveQueue = this.settingsSaveQueue + .then(async () => { + await mutate?.(); + await this.services.settings.saveSettings(); + }) + .catch(error => { + new Notice(error instanceof Error ? error.message : 'Defaults could not be saved'); + }); + } + + private buildExtraFolds(llmSettings: LLMProviderSettings): FoldDefinition[] { + const pluginSettings = () => this.services.settings.settings; + const folds: FoldDefinition[] = [ + new VideoSettings(), + new FileConversionSettings({ + getPluginSettings: pluginSettings, + getLlmSettings: () => llmSettings, + save: mutate => { + // Apply now so the redrawn rows read the new value; persist in order. + mutate(); + this.enqueueSave(); + }, + loadOcrProviders: async () => + (await getIngestCapabilityOptions(new LLMProviderManager(llmSettings, this.services.app.vault))).ocrProviders + }) + ]; + if (Platform.isDesktop) { + const semantic = this.services.semanticSearch; + const app = this.services.app; + folds.push(new SemanticSearchSettings({ + isEnabled: () => pluginSettings().enableEmbeddings ?? true, + setEnabled: enabled => { + pluginSettings().enableEmbeddings = enabled; + this.enqueueSave(); + }, + applyEnabled: enabled => semantic?.applyEnabled(enabled) ?? Promise.resolve(), + getRuntime: () => semantic?.getRuntime() ?? null, + confirmTurnOff: () => SemanticSearchOffModal.confirm(app), + confirmDeleteIndex: () => ConfirmModal.confirm(app, { + variant: 'delete', + title: SEMANTIC_DELETE_MODAL_COPY.title, + body: SEMANTIC_DELETE_MODAL_COPY.body, + cancelLabel: SEMANTIC_DELETE_MODAL_COPY.cancel, + ctaLabel: SEMANTIC_DELETE_MODAL_COPY.confirm + }) + })); + } + return folds; + } + /** * Main render method */ - private async render( - workspaces: Array<{ id: string; name: string }>, + private render( + workspaces: ChatSettingsOptions['workspaces'], prompts: Array<{ id: string; name: string }> - ): Promise { + ): void { + this.renderer?.destroy(); + this.renderer = null; this.container.empty(); - if (!this.services.llmProviderSettings) { + const llmSettings = this.services.llmProviderSettings; + if (!llmSettings) { this.container.createEl('p', { text: 'Settings not available' }); return; } - // Header - this.container.createEl('h2', { text: 'Defaults' }); - this.container.createEl('p', { - text: 'These settings are used when starting a new chat.', - cls: 'setting-item-description' - }); - - // Shared renderer - const rendererContainer = this.container.createDiv('defaults-renderer'); + const rendererContainer = this.container.createDiv(); + const pluginSettings = this.services.settings.settings; + const defaultSubfolder = DEFAULT_STORAGE_SETTINGS.audioSubfolder ?? 'audio'; this.renderer = new ChatSettingsRenderer(rendererContainer, { app: this.services.app, - llmProviderSettings: this.services.llmProviderSettings, + surface: 'defaults', + foldStore: new FoldStateStore(this.services.app), + llmProviderSettings: llmSettings, initialSettings: this.getCurrentSettings(), options: { workspaces, prompts }, - showVoiceSection: false, - showTranscriptionSection: false, - showWebSearch: false, - renderAfterImageSection: (parent) => { - this.renderVideoSection(parent); - void this.renderVoiceSection(parent); + extraFolds: this.buildExtraFolds(llmSettings), + // The folder NAME under the configured storage root where saved read-aloud + // audio is written; never a path, never a hardcoded root. + voiceAudioFolder: { + placeholder: defaultSubfolder, + getValue: () => pluginSettings.storage?.audioSubfolder ?? defaultSubfolder, + onChange: value => { + this.enqueueSave(() => { + const storage = pluginSettings.storage ?? { ...DEFAULT_STORAGE_SETTINGS }; + storage.audioSubfolder = sanitizeSubfolderName(value) || defaultSubfolder; + pluginSettings.storage = storage; + }); + } }, callbacks: { onSettingsChange: (settings) => { - // Serialize committed slider changes; input previews never enqueue a save. - const snapshot = { ...settings, contextWindowOverrides: { ...settings.contextWindowOverrides } }; + // Serialize committed changes; slider drags never enqueue a save. + const snapshot = { + ...settings, + contextNotes: [...settings.contextNotes], + contextWindowOverrides: { ...settings.contextWindowOverrides }, + agentContextWindowOverrides: { ...settings.agentContextWindowOverrides } + }; this.settingsSaveQueue = this.settingsSaveQueue.then(() => this.saveSettings(snapshot)).catch(error => { new Notice(error instanceof Error ? error.message : 'Defaults could not be saved'); }); @@ -255,907 +359,6 @@ export class DefaultsTab { }); this.renderer.render(); - - await this.renderIngestionSection(rendererContainer); - - // Embeddings section (desktop only) - if (!Platform.isMobile) { - const embeddingsSection = createDiv({ cls: 'csr-section' }); - const embeddingsHeader = embeddingsSection.createDiv({ cls: 'csr-section-header' }); - embeddingsHeader.setText('Embeddings'); - const embeddingsContent = embeddingsSection.createDiv({ cls: 'csr-section-content' }); - - new Setting(embeddingsContent) - .setName('Enable') - .setDesc('Local embeddings for semantic search (~23 megabytes download). Restart to apply.') - .addToggle(toggle => { - toggle - .setValue(this.services.settings.settings.enableEmbeddings ?? true) - .onChange(async (value) => { - this.services.settings.settings.enableEmbeddings = value; - await this.services.settings.saveSettings(); - new Notice(`Embeddings ${value ? 'enabled' : 'disabled'}. Restart Obsidian to apply.`); - }); - }); - - rendererContainer.appendChild(embeddingsSection); - } - } - - /** - * Render text-to-video defaults. - */ - private renderVideoSection(parentEl: HTMLElement): void { - const llmSettings = this.services.llmProviderSettings; - if (!llmSettings) return; - - const section = createDiv({ cls: 'csr-section nexus-video-section' }); - section.createDiv({ cls: 'csr-section-header', text: 'Video generation' }); - const content = section.createDiv({ cls: 'csr-section-content' }); - content.createDiv({ - cls: 'csr-section-desc', - text: 'Defaults for generated MP4 files created by PromptManager video tools.' - }); - parentEl.appendChild(section); - - let modelDropdown: HTMLSelectElement | null = null; - let aspectRatioDropdown: HTMLSelectElement | null = null; - let resolutionDropdown: HTMLSelectElement | null = null; - - const getAvailability = () => buildVideoProviderAvailability(llmSettings); - const getSelection = () => { - const configured = llmSettings.defaultVideoModel; - const availability = getAvailability(); - const provider = configured?.provider || availability.find(item => item.enabled && item.configured && item.models.length > 0)?.provider; - const providerAvailability = availability.find(item => item.provider === provider); - const model = configured?.model && providerAvailability?.models.some(candidate => candidate.id === configured.model) - ? configured.model - : providerAvailability?.models[0]?.id; - const modelDeclaration = getVideoModel(provider, model) || providerAvailability?.models.find(candidate => candidate.id === model); - return { - provider, - model, - modelDeclaration, - aspectRatio: configured?.aspectRatio || modelDeclaration?.defaultAspectRatio, - resolution: configured?.resolution || modelDeclaration?.defaultResolution, - }; - }; - - const saveVideoDefault = async ( - model: VideoModelDeclaration, - aspectRatio?: VideoAspectRatio, - resolution?: VideoResolution - ): Promise => { - llmSettings.defaultVideoModel = { - provider: model.provider, - model: model.id, - aspectRatio: aspectRatio || model.defaultAspectRatio, - resolution: resolution || model.defaultResolution, - }; - await this.services.settings.saveSettings(); - }; - - const updateOptionDropdowns = (): void => { - const selection = getSelection(); - const model = selection.modelDeclaration; - - if (aspectRatioDropdown) { - aspectRatioDropdown.empty(); - if (!model) { - aspectRatioDropdown.createEl('option', { value: '', text: 'Select a video model first' }); - aspectRatioDropdown.disabled = true; - } else { - model.aspectRatios.forEach(value => { - aspectRatioDropdown?.createEl('option', { value, text: value }); - }); - aspectRatioDropdown.disabled = false; - aspectRatioDropdown.value = selection.aspectRatio || model.defaultAspectRatio; - } - } - - if (resolutionDropdown) { - resolutionDropdown.empty(); - if (!model) { - resolutionDropdown.createEl('option', { value: '', text: 'Select a video model first' }); - resolutionDropdown.disabled = true; - } else { - model.resolutions.forEach(value => { - resolutionDropdown?.createEl('option', { value, text: value }); - }); - resolutionDropdown.disabled = false; - resolutionDropdown.value = selection.resolution || model.defaultResolution; - } - } - }; - - const updateModelOptions = (): void => { - if (!modelDropdown) return; - const selection = getSelection(); - const providerAvailability = getAvailability().find(item => item.provider === selection.provider); - const models = providerAvailability?.models ?? []; - - modelDropdown.empty(); - if (!selection.provider || models.length === 0) { - modelDropdown.createEl('option', { - value: '', - text: 'Select a video provider first' - }); - modelDropdown.disabled = true; - updateOptionDropdowns(); - return; - } - - sortModelsNewestFirst(models, model => ({ provider: selection.provider || '', id: model.id })).forEach(model => { - modelDropdown?.createEl('option', { value: model.id, text: model.name }); - }); - - modelDropdown.disabled = providerAvailability?.enabled !== true || providerAvailability.configured !== true; - modelDropdown.value = selection.model || models[0]?.id || ''; - updateOptionDropdowns(); - }; - - new Setting(content) - .setName('Video provider') - .setDesc('Used by the generateVideo tool.') - .addDropdown(dropdown => { - const availability = getAvailability(); - const selection = getSelection(); - const usableProviders = availability.filter(item => item.enabled && item.configured && item.models.length > 0); - - if (usableProviders.length === 0 && !selection.provider) { - dropdown.addOption('', 'No video providers available'); - dropdown.setDisabled(true); - return; - } - - usableProviders.forEach(provider => { - dropdown.addOption(provider.provider, this.getProviderDisplayName(provider.provider)); - }); - - if (selection.provider && !usableProviders.some(provider => provider.provider === selection.provider)) { - dropdown.addOption(selection.provider, `${this.getProviderDisplayName(selection.provider)} (unavailable)`); - } - - dropdown.setValue(selection.provider || usableProviders[0]?.provider || ''); - dropdown.onChange((provider) => { - const providerAvailability = getAvailability().find(item => item.provider === provider); - const model = providerAvailability?.models[0]; - if (model) { - void saveVideoDefault(model).then(updateModelOptions); - } - }); - }); - - new Setting(content) - .setName('Video model') - .addDropdown(dropdown => { - modelDropdown = dropdown.selectEl; - updateModelOptions(); - dropdown.onChange((modelId) => { - const selection = getSelection(); - const model = getVideoModel(selection.provider, modelId); - if (model) { - void saveVideoDefault(model, selection.aspectRatio, selection.resolution).then(updateOptionDropdowns); - } - }); - }); - - new Setting(content) - .setName('Aspect ratio') - .addDropdown(dropdown => { - aspectRatioDropdown = dropdown.selectEl; - updateOptionDropdowns(); - dropdown.onChange((aspectRatio) => { - const selection = getSelection(); - if (selection.modelDeclaration) { - void saveVideoDefault(selection.modelDeclaration, aspectRatio as VideoAspectRatio, selection.resolution); - } - }); - }); - - new Setting(content) - .setName('Resolution') - .addDropdown(dropdown => { - resolutionDropdown = dropdown.selectEl; - updateOptionDropdowns(); - dropdown.onChange((resolution) => { - const selection = getSelection(); - if (selection.modelDeclaration) { - void saveVideoDefault(selection.modelDeclaration, selection.aspectRatio, resolution as VideoResolution); - } - }); - }); - } - - /** - * Render voice defaults: transcription, read-aloud speech, and realtime live voice. - */ - private async renderVoiceSection(parentEl: HTMLElement): Promise { - const llmSettings = this.services.llmProviderSettings; - if (!llmSettings) return; - - const section = createDiv({ cls: 'csr-section nexus-voice-section' }); - const header = section.createDiv({ cls: 'csr-section-header' }); - header.setText('Voice'); - const content = section.createDiv({ cls: 'csr-section-content' }); - content.createDiv({ - cls: 'setting-item-description', - text: 'Loading voice models...' - }); - parentEl.appendChild(section); - - const providerManager = new LLMProviderManager(llmSettings, this.services.app.vault); - const capabilities = await getIngestCapabilityOptions(providerManager).catch(() => undefined); - - if (capabilities) { - const normalizedTranscriptionSelection = normalizeIngestSelection( - capabilities.transcriptionProviders, - llmSettings.defaultTranscriptionModel?.provider, - llmSettings.defaultTranscriptionModel?.model - ); - - if (normalizedTranscriptionSelection.provider && normalizedTranscriptionSelection.model) { - llmSettings.defaultTranscriptionModel = { - provider: normalizedTranscriptionSelection.provider, - model: normalizedTranscriptionSelection.model - }; - } - } - - content.empty(); - content.createDiv({ - cls: 'csr-section-desc nexus-voice-section-desc', - text: 'Defaults for microphone input, note read-aloud, and future live voice chat.' - }); - - let updateVoiceWarning = (): void => undefined; - - this.renderVoiceGroupLabel(content, 'Voice input'); - if (capabilities) { - renderIngestModelDropdowns(content, { - labelPrefix: 'Transcription', - description: 'Used for chat microphone input and audio-file transcription.', - providers: capabilities.transcriptionProviders, - getSelection: () => llmSettings.defaultTranscriptionModel, - onChange: async (provider, model) => { - llmSettings.defaultTranscriptionModel = provider && model - ? { provider, model } - : undefined; - await this.services.settings.saveSettings(); - }, - providerSettingName: 'Transcription provider', - modelSettingName: 'Transcription model' - }); - } else { - content.createDiv({ - cls: 'setting-item-description', - text: 'Transcription models are not available.' - }); - } - - this.renderVoiceGroupLabel(content, 'Read aloud'); - this.renderSpeechSettings(content, llmSettings, () => updateVoiceWarning()); - this.renderAudioSaveSettings(content); - - this.renderVoiceGroupLabel(content, 'Live voice'); - this.renderRealtimeVoiceSettings(content, llmSettings, () => updateVoiceWarning()); - - const warningEl = content.createDiv({ cls: 'nexus-voice-warning is-hidden' }); - updateVoiceWarning = (): void => { - const messages = this.getVoiceWarningMessages(llmSettings); - warningEl.empty(); - warningEl.toggleClass('is-hidden', messages.length === 0); - messages.forEach(message => { - warningEl.createDiv({ text: message }); - }); - }; - updateVoiceWarning(); - } - - private renderVoiceGroupLabel(parentEl: HTMLElement, label: string): void { - parentEl.createDiv({ cls: 'nexus-voice-group-label', text: label }); - } - - /** - * Render the "Save as audio" subfolder field. Controls the subfolder under - * the storage root where saved read-aloud audio is written. The value is a - * plain folder NAME (not a path) joined under the configured storage root by - * the backend, so changing the storage root relocates saved audio. Never a - * hardcoded root. - */ - private renderAudioSaveSettings(parentEl: HTMLElement): void { - const pluginSettings = this.services.settings.settings; - const defaultSubfolder = DEFAULT_STORAGE_SETTINGS.audioSubfolder ?? 'audio'; - - new Setting(parentEl) - .setName('Saved audio subfolder') - .setDesc('Folder name, under your storage root, where saved read-aloud audio (mp3/wav) is written.') - .addText(text => { - const current = pluginSettings.storage?.audioSubfolder ?? defaultSubfolder; - text - .setPlaceholder(defaultSubfolder) - .setValue(current) - .onChange(async (value) => { - const sanitized = this.sanitizeSubfolderName(value); - const storage = pluginSettings.storage ?? { ...DEFAULT_STORAGE_SETTINGS }; - storage.audioSubfolder = sanitized || defaultSubfolder; - pluginSettings.storage = storage; - await this.services.settings.saveSettings(); - }); - }); - } - - /** - * Reduce free-text input to a safe single folder NAME: trim, collapse path - * separators and leading dots so the value can never escape the storage root - * or become a nested path. Returns '' for empty/invalid input so the caller - * can fall back to the default. - */ - private sanitizeSubfolderName(value: string): string { - return value - .trim() - .replace(/[\\/]+/g, '') - .replace(/^\.+/, '') - .trim(); - } - - private renderSpeechSettings( - parentEl: HTMLElement, - llmSettings: LLMProviderSettings, - onSelectionChange: () => void - ): void { - let modelDropdown: HTMLSelectElement | null = null; - let voiceDropdown: HTMLSelectElement | null = null; - let modelRequestId = 0; - let voiceRequestId = 0; - const voiceCatalogService = new VoiceCatalogService(); - const speechModelCatalogService = new SpeechModelCatalogService(); - - const getAvailability = (): SpeechProviderAvailability[] => - buildSpeechProviderAvailability(llmSettings, this.getSpeechAppStates()); - - const getSelection = (): DefaultSpeechModelSettings => { - const configured = llmSettings.defaultSpeechModel; - if (configured?.source === 'user' || configured?.provider || configured?.model) { - return configured; - } - - const resolved = resolveDefaultSpeechSelection(llmSettings, getAvailability()); - return { - provider: resolved.provider, - model: resolved.model, - voice: resolved.voice, - source: 'auto' - }; - }; - - const updateVoiceOptions = async (): Promise => { - if (!voiceDropdown) return; - const requestId = ++voiceRequestId; - const selection = getSelection(); - - voiceDropdown.empty(); - if (selection.provider === 'elevenlabs') { - voiceDropdown.createEl('option', { value: '', text: 'Loading voices...' }); - voiceDropdown.disabled = true; - } - - const voices = await voiceCatalogService.getVoices(selection.provider, selection.model, { - llmSettings, - appsSettings: this.services.appManager?.getAppsSettings() ?? this.services.settings.settings.apps - }).catch(() => []); - - if (requestId !== voiceRequestId || !voiceDropdown) { - return; - } - - voiceDropdown.empty(); - voiceDropdown.createEl('option', { value: '', text: 'Provider default' }); - voices.forEach(voice => { - voiceDropdown?.createEl('option', { value: voice.id, text: voice.name }); - }); - - if (selection.voice && !voices.some(voice => voice.id === selection.voice)) { - voiceDropdown.createEl('option', { - value: selection.voice, - text: `${selection.voice} (unavailable)` - }); - } - - voiceDropdown.disabled = !selection.provider || !selection.model; - voiceDropdown.value = selection.voice || ''; - }; - - const updateModelOptions = async (): Promise => { - if (!modelDropdown) return; - const requestId = ++modelRequestId; - const selection = getSelection(); - const availability = getAvailability(); - const providerAvailability = availability.find(item => item.provider === selection.provider); - const providerUsable = providerAvailability?.enabled === true && providerAvailability.configured === true; - - modelDropdown.empty(); - if (!selection.provider) { - modelDropdown.createEl('option', { - value: '', - text: 'Select a speech provider first' - }); - modelDropdown.disabled = true; - void updateVoiceOptions(); - return; - } - - if (selection.provider === 'openrouter') { - modelDropdown.createEl('option', { value: '', text: 'Loading OpenRouter speech models...' }); - modelDropdown.disabled = true; - } - - const models = await speechModelCatalogService.getModels( - selection.provider as SpeechProvider, - llmSettings - ).catch(() => providerAvailability?.models ?? []); - - if (requestId !== modelRequestId || !modelDropdown) { - return; - } - - modelDropdown.empty(); - if (models.length === 0) { - modelDropdown.createEl('option', { - value: '', - text: 'No speech models available' - }); - modelDropdown.disabled = true; - void updateVoiceOptions(); - return; - } - - sortModelsNewestFirst(models, model => ({ provider: selection.provider || '', id: model.id })).forEach(model => { - modelDropdown?.createEl('option', { value: model.id, text: model.name }); - }); - - if (selection.model && !models.some(model => model.id === selection.model)) { - modelDropdown.createEl('option', { - value: selection.model, - text: `${selection.model} (unavailable)` - }); - } - - const selectedModelExists = models.some(model => model.id === selection.model); - modelDropdown.disabled = !providerUsable; - modelDropdown.value = selection.model && selectedModelExists - ? selection.model - : models[0]?.id || ''; - void updateVoiceOptions(); - }; - - new Setting(parentEl) - .setName('Speech provider') - .setDesc('Normal text-to-speech models for reading notes and selected text.') - .addDropdown(dropdown => { - const availability = getAvailability(); - const selection = getSelection(); - const usableProviders = availability.filter(item => item.enabled && item.configured && (item.models?.length ?? 0) > 0); - - if (usableProviders.length === 0 && !selection.provider) { - dropdown.addOption('', 'No speech providers available'); - dropdown.setDisabled(true); - return; - } - - usableProviders.forEach(provider => { - dropdown.addOption(provider.provider, this.getProviderDisplayName(provider.provider)); - }); - - if (selection.provider && !usableProviders.some(provider => provider.provider === selection.provider)) { - dropdown.addOption( - selection.provider, - `${this.getProviderDisplayName(selection.provider)} (unavailable)` - ); - } - - dropdown.setValue(selection.provider || usableProviders[0]?.provider || ''); - dropdown.onChange((provider) => { - const providerAvailability = getAvailability().find(item => item.provider === provider); - const model = providerAvailability?.models?.[0]; - llmSettings.defaultSpeechModel = provider && model - ? this.buildSpeechDefault(model, undefined) - : undefined; - void this.services.settings.saveSettings().then(() => { - void updateModelOptions(); - onSelectionChange(); - }); - }); - }); - - new Setting(parentEl) - .setName('Speech model') - .addDropdown(dropdown => { - modelDropdown = dropdown.selectEl; - void updateModelOptions(); - dropdown.onChange((modelId) => { - const selection = getSelection(); - const model = getSpeechModel(selection.provider, modelId); - if (model || (selection.provider === 'openrouter' && modelId)) { - llmSettings.defaultSpeechModel = model - ? this.buildSpeechDefault(model, selection.voice) - : { - provider: selection.provider, - model: modelId, - voice: selection.voice, - source: 'user' - }; - } - void this.services.settings.saveSettings().then(() => { - void updateVoiceOptions(); - onSelectionChange(); - }); - }); - }); - - new Setting(parentEl) - .setName('Speech voice') - .addDropdown(dropdown => { - voiceDropdown = dropdown.selectEl; - void updateVoiceOptions(); - dropdown.onChange((voice) => { - const selection = getSelection(); - if (selection.provider && selection.model) { - llmSettings.defaultSpeechModel = { - provider: selection.provider, - model: selection.model, - voice: voice || undefined, - source: 'user' - }; - } - void this.services.settings.saveSettings().then(onSelectionChange); - }); - }); - } - - private renderRealtimeVoiceSettings( - parentEl: HTMLElement, - llmSettings: LLMProviderSettings, - onSelectionChange: () => void - ): void { - let modelDropdown: HTMLSelectElement | null = null; - let voiceDropdown: HTMLSelectElement | null = null; - - const getAvailability = (): RealtimeVoiceProviderAvailability[] => - buildRealtimeVoiceProviderAvailability(llmSettings, this.getRealtimeAppStates()); - - const getSelection = (): DefaultRealtimeVoiceModelSettings => { - const configured = llmSettings.defaultRealtimeVoiceModel; - if (configured?.source === 'user' || configured?.provider || configured?.model) { - return configured; - } - - const resolved = resolveDefaultRealtimeVoiceSelection(llmSettings, getAvailability()); - return { - provider: resolved.provider, - model: resolved.model, - voice: resolved.voice, - source: 'auto' - }; - }; - - const updateVoiceOptions = (): void => { - if (!voiceDropdown) return; - const selection = getSelection(); - const model = getRealtimeVoiceModel(selection.provider, selection.model); - const voices = model?.voices ?? []; - const usesSpeechDefault = model?.execution === 'transcription-pipeline'; - - voiceDropdown.empty(); - voiceDropdown.createEl('option', { - value: '', - text: usesSpeechDefault ? 'Uses speech default' : 'Provider default' - }); - voices.forEach(voice => { - voiceDropdown?.createEl('option', { value: voice.id, text: voice.name }); - }); - - if (selection.voice && !voices.some(voice => voice.id === selection.voice)) { - voiceDropdown.createEl('option', { - value: selection.voice, - text: `${selection.voice} (unavailable)` - }); - } - - voiceDropdown.disabled = usesSpeechDefault || !selection.provider || !selection.model; - voiceDropdown.value = usesSpeechDefault ? '' : selection.voice || ''; - }; - - const updateModelOptions = (): void => { - if (!modelDropdown) return; - const selection = getSelection(); - const availability = getAvailability(); - const providerAvailability = availability.find(item => item.provider === selection.provider); - const providerUsable = providerAvailability?.enabled === true && providerAvailability.configured === true; - const models = providerAvailability?.models ?? []; - - modelDropdown.empty(); - if (!selection.provider || models.length === 0) { - modelDropdown.createEl('option', { - value: '', - text: 'Select a live voice provider first' - }); - modelDropdown.disabled = true; - updateVoiceOptions(); - return; - } - - sortModelsNewestFirst(models, model => ({ provider: selection.provider || '', id: model.id })).forEach(model => { - modelDropdown?.createEl('option', { value: model.id, text: model.name }); - }); - - if (selection.model && !models.some(model => model.id === selection.model)) { - modelDropdown.createEl('option', { - value: selection.model, - text: `${selection.model} (unavailable)` - }); - } - - const selectedModelExists = models.some(model => model.id === selection.model); - modelDropdown.disabled = !providerUsable; - modelDropdown.value = selection.model && selectedModelExists - ? selection.model - : models[0]?.id || ''; - updateVoiceOptions(); - }; - - new Setting(parentEl) - .setName('Live voice provider') - .setDesc('Native voice agents and realtime transcription pipelines appear here.') - .addDropdown(dropdown => { - const availability = getAvailability(); - const selection = getSelection(); - const usableProviders = availability.filter(item => item.enabled && item.configured && (item.models?.length ?? 0) > 0); - - if (usableProviders.length === 0 && !selection.provider) { - dropdown.addOption('', 'No live voice providers available'); - dropdown.setDisabled(true); - return; - } - - usableProviders.forEach(provider => { - dropdown.addOption(provider.provider, this.getProviderDisplayName(provider.provider)); - }); - - if (selection.provider && !usableProviders.some(provider => provider.provider === selection.provider)) { - dropdown.addOption( - selection.provider, - `${this.getProviderDisplayName(selection.provider)} (unavailable)` - ); - } - - dropdown.setValue(selection.provider || usableProviders[0]?.provider || ''); - dropdown.onChange((provider) => { - const providerAvailability = getAvailability().find(item => item.provider === provider); - const model = providerAvailability?.models?.[0]; - llmSettings.defaultRealtimeVoiceModel = provider && model - ? this.buildRealtimeDefault(model, undefined) - : undefined; - void this.services.settings.saveSettings().then(() => { - updateModelOptions(); - onSelectionChange(); - }); - }); - }); - - new Setting(parentEl) - .setName('Live voice model') - .addDropdown(dropdown => { - modelDropdown = dropdown.selectEl; - updateModelOptions(); - dropdown.onChange((modelId) => { - const selection = getSelection(); - const model = getRealtimeVoiceModel(selection.provider, modelId); - if (model) { - llmSettings.defaultRealtimeVoiceModel = this.buildRealtimeDefault(model, selection.voice); - } - void this.services.settings.saveSettings().then(() => { - updateVoiceOptions(); - onSelectionChange(); - }); - }); - }); - - new Setting(parentEl) - .setName('Live voice output') - .addDropdown(dropdown => { - voiceDropdown = dropdown.selectEl; - updateVoiceOptions(); - dropdown.onChange((voice) => { - const selection = getSelection(); - if (selection.provider && selection.model) { - llmSettings.defaultRealtimeVoiceModel = { - provider: selection.provider, - model: selection.model, - voice: voice || undefined, - source: 'user' - }; - } - void this.services.settings.saveSettings().then(onSelectionChange); - }); - }); - } - - private buildSpeechDefault( - model: SpeechModelDeclaration, - voice: string | undefined - ): DefaultSpeechModelSettings { - return { - provider: model.provider, - model: model.id, - voice: voice || model.defaultVoice, - source: 'user' - }; - } - - private buildRealtimeDefault( - model: RealtimeVoiceModelDeclaration, - voice: string | undefined - ): DefaultRealtimeVoiceModelSettings { - return { - provider: model.provider, - model: model.id, - voice: voice || model.defaultVoice, - source: 'user' - }; - } - - private getSpeechAppStates(): SpeechAppCapabilityStates { - const elevenLabs = this.services.appManager?.getAvailableApps().find(app => app.id === 'elevenlabs'); - return elevenLabs - ? { elevenlabs: { enabled: elevenLabs.enabled, configured: elevenLabs.configured } } - : {}; - } - - private getRealtimeAppStates(): RealtimeAppCapabilityStates { - const elevenLabs = this.services.appManager?.getAvailableApps().find(app => app.id === 'elevenlabs'); - return elevenLabs - ? { elevenlabs: { enabled: elevenLabs.enabled, configured: elevenLabs.configured } } - : {}; - } - - private getVoiceWarningMessages(llmSettings: LLMProviderSettings): string[] { - const messages: string[] = []; - const speechSelection = resolveDefaultSpeechSelection( - llmSettings, - buildSpeechProviderAvailability(llmSettings, this.getSpeechAppStates()) - ); - const realtimeSelection = resolveDefaultRealtimeVoiceSelection( - llmSettings, - buildRealtimeVoiceProviderAvailability(llmSettings, this.getRealtimeAppStates()) - ); - - if (speechSelection.status === 'invalid' && speechSelection.reason) { - messages.push(speechSelection.reason); - } - - if (realtimeSelection.status === 'invalid' && realtimeSelection.reason) { - messages.push(realtimeSelection.reason); - } - - return messages; - } - - private getProviderDisplayName(providerId: string): string { - return ProviderUtils.getProviderDisplayName(providerId); - } - - /** - * Render the ingestion defaults section - */ - private async renderIngestionSection(parentEl: HTMLElement): Promise { - const llmSettings = this.services.llmProviderSettings; - if (!llmSettings) return; - const pluginSettings = this.services.settings.settings; - - const providerManager = new LLMProviderManager(llmSettings, this.services.app.vault); - const capabilities = await getIngestCapabilityOptions(providerManager); - const normalizedOcrSelection = normalizeIngestSelection( - capabilities.ocrProviders, - llmSettings.defaultOcrModel?.provider, - llmSettings.defaultOcrModel?.model - ); - - if (normalizedOcrSelection.provider && normalizedOcrSelection.model) { - llmSettings.defaultOcrModel = { - provider: normalizedOcrSelection.provider, - model: normalizedOcrSelection.model - }; - } - - const section = createDiv({ cls: 'csr-section' }); - const header = section.createDiv({ cls: 'csr-section-header' }); - header.setText('Ingestion'); - const content = section.createDiv({ cls: 'csr-section-content' }); - const ingestionSettingsContainer = content.createDiv(); - const isEnabled = pluginSettings.enableIngestion !== false; - - new Setting(content) - .setName('Enable ingestion') - .setDesc('Show PDF ingestion settings and enable drag-and-drop ingestion in chat.') - .addToggle(toggle => { - toggle - .setValue(isEnabled) - .onChange(async (value) => { - pluginSettings.enableIngestion = value; - if (value) { - ingestionSettingsContainer.removeClass('nexus-ingest-confirm-hidden'); - } else { - ingestionSettingsContainer.addClass('nexus-ingest-confirm-hidden'); - } - await this.services.settings.saveSettings(); - }); - }); - - new Setting(content) - .setName('Auto-convert new files') - .setDesc('When supported PDF files are added to the vault, automatically convert them to sibling Markdown files using the defaults below.') - .addToggle(toggle => { - toggle - .setValue(pluginSettings.autoIngestion === true) - .onChange(async (value) => { - pluginSettings.autoIngestion = value; - await this.services.settings.saveSettings(); - }); - }); - - if (!isEnabled) { - ingestionSettingsContainer.addClass('nexus-ingest-confirm-hidden'); - } - - // PDF processing mode - let ocrSettingsContainer: HTMLElement | null = null; - - new Setting(ingestionSettingsContainer) - .setName('Default PDF mode') - .setDesc('Text extraction is free. Vision scan uses a model for scanned documents.') - .addDropdown(dropdown => { - dropdown - .addOption('text', 'Text extraction') - .addOption('vision', 'Vision scan') - .setValue(llmSettings.defaultPdfMode || 'text') - .onChange(async (value) => { - llmSettings.defaultPdfMode = value as 'text' | 'vision'; - await this.services.settings.saveSettings(); - // Toggle OCR provider visibility - if (ocrSettingsContainer) { - if (value === 'vision') { - ocrSettingsContainer.removeClass('nexus-ingest-confirm-hidden'); - } else { - ocrSettingsContainer.addClass('nexus-ingest-confirm-hidden'); - } - } - }); - }); - - // OCR provider/model (conditionally shown) - ocrSettingsContainer = ingestionSettingsContainer.createDiv(); - if (llmSettings.defaultPdfMode !== 'vision') { - ocrSettingsContainer.addClass('nexus-ingest-confirm-hidden'); - } - - renderIngestModelDropdowns( - ocrSettingsContainer, - { - labelPrefix: 'Default OCR', - description: 'Model for vision OCR when using vision mode.', - providers: capabilities.ocrProviders, - getSelection: () => llmSettings.defaultOcrModel, - onChange: async (provider, model) => { - llmSettings.defaultOcrModel = provider && model - ? { provider, model } - : undefined; - await this.services.settings.saveSettings(); - } - } - ); - - parentEl.appendChild(section); } /** diff --git a/src/settings/tabs/GetStartedTab.ts b/src/settings/tabs/GetStartedTab.ts deleted file mode 100644 index 4808b79ef..000000000 --- a/src/settings/tabs/GetStartedTab.ts +++ /dev/null @@ -1,1214 +0,0 @@ -/** - * GetStartedTab - Two setup paths and MCP configuration helper - * - * Features: - * - Two setup paths: Internal Chat and MCP Integration - * - Internal Chat: Configure providers, enable chat view - * - MCP Integration: Zero-friction setup with one-click config - * - Platform-specific config file paths - * - Auto-detect and create Claude config - */ - -import { App, Setting, Notice, Platform, Component } from 'obsidian'; -import { BackButton } from '../components/BackButton'; -import { getPrimaryServerKey } from '../../constants/branding'; -import { ConfigStatus, getClaudeDesktopConfigPath, getConfigStatus } from '../getStartedStatus'; -import { resolveDesktopBinaryPath } from '../../utils/binaryDiscovery'; -import { CONNECTOR_JS_CONTENT } from '../../utils/connectorContent'; -import { LocalCliInstaller, CliProviderId, CliInstallTargets, CliPathFix } from '../../services/cli/LocalCliInstaller'; -import { - appendCodexMcpTomlSnippet, - buildCodexMcpTomlSnippet, - hasCodexMcpServerConfig -} from '../../utils/codexMcpConfig'; - -type GetStartedView = 'paths' | 'internal-chat' | 'mcp-setup'; -type CodexConfigStatus = 'no-config-file' | 'nexus-configured' | 'config-exists'; -type DesktopModuleMap = { - child_process: typeof import('child_process'); - fs: typeof import('fs'); - path: typeof import('path'); - electron: { - shell: { - openExternal(url: string): void | Promise; - openPath(path: string): Promise | string; - showItemInFolder(path: string): void; - }; - }; -}; - -interface ClaudeConfig { - mcpServers: Record; -} - -export interface GetStartedTabServices { - app: App; - pluginPath: string; - vaultPath: string; - onOpenProviders: () => void; - component?: Component; -} - -interface ManualStepOptions { - code: string; - configLabel: string; - configPath: string | null; - known: boolean; - restartText: string; - summaryText?: string; -} - -interface ConnectedCardOptions { - configPath: string; - configFileName: string; - restartText: string; -} - -export class GetStartedTab { - private container: HTMLElement; - private services: GetStartedTabServices; - private currentView: GetStartedView = 'paths'; - private cachedNodePath: string | null = null; - private justConnectedClaude = false; - private justConnectedCodex = false; - - constructor( - container: HTMLElement, - services: GetStartedTabServices - ) { - this.container = container; - this.services = services; - - this.render(); - } - - /** - * Main render method - */ - render(): void { - this.cachedNodePath = null; - this.container.empty(); - - // "Just connected" confirmation cards only persist while on the MCP view. - if (this.currentView !== 'mcp-setup') { - this.justConnectedClaude = false; - this.justConnectedCodex = false; - } - - switch (this.currentView) { - case 'paths': - this.renderPathsView(); - break; - case 'internal-chat': - this.renderInternalChatSetup(); - break; - case 'mcp-setup': - this.renderMCPSetup(); - break; - } - } - - /** - * Render the initial two-path view - */ - private renderPathsView(): void { - // Plugin introduction - const intro = this.container.createDiv('nexus-intro'); - intro.createEl('h3', { text: 'Welcome to Nexus' }); - intro.createEl('p', { - text: 'Nexus is an AI-powered assistant that lives inside your Obsidian vault. It can read and write your notes, search through your content, and maintain long-term memory of your conversations—all while keeping your data local and private.', - cls: 'nexus-intro-desc' - }); - - // Key capabilities - const capabilities = intro.createDiv('nexus-capabilities'); - capabilities.createEl('h4', { text: 'What Nexus can do' }); - - const capList = capabilities.createEl('ul', { cls: 'nexus-capability-list' }); - const capItems = [ - { icon: '📝', text: 'Read, create, and edit notes in your vault' }, - { icon: '🔍', text: 'Search content by keywords or semantic meaning' }, - { icon: '🧠', text: 'Remember context across conversations with workspaces' }, - { icon: '📁', text: 'Organize files and folders' }, - { icon: '🤖', text: 'Run custom prompts and spawn sub-agents' }, - { icon: '🔒', text: 'Work fully offline with local LLMs (Ollama, LM Studio)' } - ]; - - for (const cap of capItems) { - const li = capList.createEl('li'); - li.createSpan({ text: cap.icon, cls: 'nexus-cap-icon' }); - li.createSpan({ text: cap.text }); - } - - // Divider - this.container.createEl('hr', { cls: 'nexus-divider' }); - - // Setup paths header - this.container.createEl('h3', { text: 'Choose your setup' }); - this.container.createEl('p', { - text: 'Nexus works in two ways—pick one or use both:', - cls: 'setting-item-description' - }); - - const paths = this.container.createDiv('nexus-setup-paths'); - - // Path 1: Internal Chat - const chatPath = paths.createDiv('nexus-setup-path'); - chatPath.createDiv('nexus-setup-path-icon').setText('💬'); - chatPath.createDiv('nexus-setup-path-title').setText('Internal chat'); - chatPath.createDiv('nexus-setup-path-desc').setText('Use Nexus directly inside Obsidian'); - const chatClickHandler = () => { - this.currentView = 'internal-chat'; - this.render(); - }; - const component = this.services.component; - if (component) { - component.registerDomEvent(chatPath, 'click', chatClickHandler); - } - - // Path 2: External agents (MCP clients + CLI coding agents) - const mcpPath = paths.createDiv('nexus-setup-path'); - mcpPath.createDiv('nexus-setup-path-icon').setText('🔗'); - mcpPath.createDiv('nexus-setup-path-title').setText('External agents'); - mcpPath.createDiv('nexus-setup-path-desc').setText('Connect Claude Desktop, Codex, Cursor, and more'); - const mcpClickHandler = () => { - this.currentView = 'mcp-setup'; - this.render(); - }; - if (component) { - component.registerDomEvent(mcpPath, 'click', mcpClickHandler); - } - } - - /** - * Render Internal Chat setup view - */ - private renderInternalChatSetup(): void { - new BackButton( - this.container, - 'Back to get started', - () => { - this.currentView = 'paths'; - this.render(); - }, - this.services.component - ); - - this.container.createEl('h3', { text: 'Internal chat setup' }); - this.container.createEl('p', { - text: 'Use Nexus as an AI chat assistant directly in Obsidian.', - cls: 'setting-item-description' - }); - - // Step 1: Configure a provider - const step1 = this.container.createDiv('nexus-setup-step'); - step1.createEl('h4', { text: 'Step 1: configure an LLM provider' }); - step1.createEl('p', { - text: 'You need at least one LLM provider configured to use the chat.', - cls: 'setting-item-description' - }); - - new Setting(step1) - .addButton(btn => btn - .setButtonText('Configure providers') - .setCta() - .onClick(() => { - this.services.onOpenProviders(); - })); - - // Step 2: Open chat view - const step2 = this.container.createDiv('nexus-setup-step'); - step2.createEl('h4', { text: 'Step 2: open the chat view' }); - step2.createEl('p', { - text: 'Once a provider is configured, you can open the chat view:', - cls: 'setting-item-description' - }); - - const instructions = step2.createEl('ul', { cls: 'nexus-setup-instructions' }); - instructions.createEl('li', { text: 'Click the chat icon in the left ribbon' }); - instructions.createEl('li', { text: 'Or use the command palette: "Nexus: open chat"' }); - instructions.createEl('li', { text: 'Or use the hotkey: Ctrl/Cmd + Shift + C' }); - - // Step 3: Start chatting - const step3 = this.container.createDiv('nexus-setup-step'); - step3.createEl('h4', { text: 'Step 3: start chatting!' }); - step3.createEl('p', { - text: 'Your AI assistant has full access to your vault. Ask questions, take notes, and get help with your writing.', - cls: 'setting-item-description' - }); - } - - /** - * Render External agents setup view (MCP clients + CLI coding agents) - */ - private renderMCPSetup(): void { - new BackButton( - this.container, - 'Back to get started', - () => { - this.currentView = 'paths'; - this.render(); - }, - this.services.component - ); - - this.container.createEl('h3', { text: 'External agent setup' }); - - // Setup requires Node.js modules (path, fs, child_process) — desktop only - if (!Platform.isDesktop) { - this.container.createEl('p', { - text: 'External agent setup requires a desktop environment.', - cls: 'setting-item-description' - }); - return; - } - - this.container.createEl('p', { - text: 'Connect Nexus to external AI agents (Claude Desktop, Codex, Cursor, and more) so they can read and write your vault.', - cls: 'setting-item-description' - }); - - this.renderConnectorExplainer(); - - // Check for Node.js availability - const nodePath = this.resolveNodePath(); - if (!nodePath) { - this.renderNodeWarning(); - } - - const configPath = getClaudeDesktopConfigPath(); - if (!configPath) { - this.container.createEl('p', { - text: 'MCP setup is only available on desktop.', - cls: 'setting-item-description' - }); - return; - } - - this.renderClaudeSection(configPath); - - this.container.createEl('hr', { cls: 'nexus-divider' }); - this.renderCodexSetupSection(); - - this.container.createEl('hr', { cls: 'nexus-divider' }); - this.renderGenericAgentsSection(); - - this.container.createEl('hr', { cls: 'nexus-divider' }); - this.renderLocalCliSection(); - } - - /** - * Short explainer for what connector.js is and that one-click handles it. - */ - private renderConnectorExplainer(): void { - const box = this.container.createDiv('nexus-connector-explainer'); - box.createSpan({ text: '🔌', cls: 'nx-icon' }); - const p = box.createEl('p'); - p.createSpan({ text: 'External agents talk to your vault through a small bridge file, ' }); - p.createEl('code', { text: 'connector.js' }); - p.createSpan({ text: ', that Nexus keeps in this plugin’s folder. The one-click buttons below create it and wire it into each agent’s config for you. Prefer to do it by hand? Each agent has a “Set up manually” section with numbered steps.' }); - } - - /** - * Node.js not found warning row. - */ - private renderNodeWarning(): void { - const component = this.services.component; - const nodeWarning = this.container.createDiv('nexus-mcp-row nexus-mcp-node-warning'); - nodeWarning.createSpan({ - text: 'Node.js not found', - cls: 'nexus-mcp-status nexus-mcp-warning' - }); - const actions = nodeWarning.createDiv('nexus-mcp-actions'); - const downloadBtn = actions.createEl('button', { text: 'Install Node.js', cls: 'mod-cta' }); - if (component) { - component.registerDomEvent(downloadBtn, 'click', () => window.open('https://nodejs.org', '_blank')); - } - const refreshBtn = actions.createEl('button', { text: 'Refresh' }); - if (component) { - component.registerDomEvent(refreshBtn, 'click', () => this.render()); - } - this.container.createEl('p', { - text: 'Node.js is required to run the MCP connector. Install it, then click refresh.', - cls: 'nexus-mcp-help' - }); - } - - /** - * Claude Desktop agent block: status-driven, with a manual fallback disclosure. - */ - private renderClaudeSection(configPath: string): void { - const block = this.container.createDiv('nexus-agent-block'); - const heading = block.createEl('h4'); - heading.createSpan({ text: '🟣 ', cls: 'nx-agent-logo' }); - heading.createSpan({ text: 'Claude Desktop' }); - - const configStatus = this.checkConfigStatus(); - const component = this.services.component; - - if (configStatus === 'no-claude-folder') { - const row = block.createDiv('nexus-mcp-row'); - row.createSpan({ - text: '⚠️ Claude Desktop not found', - cls: 'nexus-mcp-status nexus-mcp-warning' - }); - const actions = row.createDiv('nexus-mcp-actions'); - const downloadBtn = actions.createEl('button', { text: 'Download', cls: 'mod-cta' }); - if (component) { - component.registerDomEvent(downloadBtn, 'click', () => window.open('https://claude.ai/download', '_blank')); - } - const refreshBtn = actions.createEl('button', { text: 'Refresh' }); - if (component) { - component.registerDomEvent(refreshBtn, 'click', () => this.render()); - } - block.createEl('p', { - text: 'Install Claude Desktop, open it once, then enable settings → developer → MCP servers and click refresh.', - cls: 'nexus-mcp-help' - }); - return; - } - - if (configStatus === 'nexus-configured') { - if (this.justConnectedClaude) { - this.renderJustConnectedCard(block, { - configPath, - configFileName: 'claude_desktop_config.json', - restartText: 'Fully quit and relaunch Claude Desktop to load it (a window reload isn’t enough).' - }); - } else { - this.renderConnectedCompact(block, configPath, 'Restart Claude Desktop if you haven’t already.'); - } - return; - } - - if (configStatus === 'invalid-config') { - const row = block.createDiv('nexus-mcp-row'); - row.createSpan({ - text: '⚠️ config file is invalid or empty', - cls: 'nexus-mcp-status nexus-mcp-warning' - }); - const actions = row.createDiv('nexus-mcp-actions'); - const fixBtn = actions.createEl('button', { text: 'Fix config', cls: 'mod-cta' }); - if (component) { - component.registerDomEvent(fixBtn, 'click', () => this.autoConfigureNexus(configPath)); - } - const openBtn = actions.createEl('button', { text: 'Open config' }); - if (component) { - component.registerDomEvent(openBtn, 'click', () => this.openConfigFile(configPath)); - } - block.createEl('p', { - text: 'The config file exists but has invalid JSON. Click “fix config” to overwrite it, or set it up manually below.', - cls: 'nexus-mcp-help' - }); - this.appendManualDisclosure(block, { - code: this.getConfigJson(), - configLabel: 'claude_desktop_config.json', - configPath, - known: true, - restartText: 'Fully quit and relaunch Claude Desktop so it loads the new server.' - }); - return; - } - - // Ready to connect (no-config-file / claude-found) - const row = block.createDiv('nexus-mcp-row'); - row.createSpan({ text: 'Ready to connect', cls: 'nexus-mcp-status' }); - const actions = row.createDiv('nexus-mcp-actions'); - const connectBtn = actions.createEl('button', { text: 'Connect Claude Desktop', cls: 'mod-cta' }); - if (component) { - component.registerDomEvent(connectBtn, 'click', () => this.autoConfigureNexus(configPath)); - } - this.appendManualDisclosure(block, { - code: this.getConfigJson(), - configLabel: 'claude_desktop_config.json', - configPath, - known: true, - restartText: 'Fully quit and relaunch Claude Desktop so it loads the new server.' - }); - } - - /** - * Full "just connected" confirmation card, shown once right after a one-click connect. - */ - private renderJustConnectedCard(parent: HTMLElement, opts: ConnectedCardOptions): void { - const component = this.services.component; - const connectorPath = this.getConnectorDisplayPath(); - - const card = parent.createDiv('nexus-connect-result'); - const head = card.createDiv('nx-result-head'); - head.createSpan({ text: '✓ connected', cls: 'nx-result-title' }); - const actions = head.createDiv('nx-result-actions'); - const revealBtn = actions.createEl('button', { text: 'Reveal connector.js' }); - if (component) { - component.registerDomEvent(revealBtn, 'click', () => this.revealInFolder(connectorPath)); - } - const openBtn = actions.createEl('button', { text: 'Open config' }); - if (component) { - component.registerDomEvent(openBtn, 'click', () => this.openConfigFile(opts.configPath)); - } - - const list = card.createEl('ul', { cls: 'nx-checklist' }); - - const li1 = list.createEl('li'); - li1.createSpan({ text: '✓', cls: 'nx-check' }); - const li1body = li1.createSpan(); - li1body.createSpan({ text: 'Created bridge file', cls: 'nx-label' }); - li1body.createEl('br'); - li1body.createEl('code', { text: connectorPath }); - - const li2 = list.createEl('li'); - li2.createSpan({ text: '✓', cls: 'nx-check' }); - const li2body = li2.createSpan(); - li2body.createSpan({ text: 'Added Nexus to ', cls: 'nx-label' }); - li2body.createEl('code', { text: opts.configFileName }); - - const li3 = list.createEl('li'); - li3.createSpan({ text: '→', cls: 'nx-next' }); - li3.createSpan({ text: opts.restartText }); - } - - /** - * Compact connected row, shown on revisits when already configured. - */ - private renderConnectedCompact(parent: HTMLElement, configPath: string, restartText: string): void { - const component = this.services.component; - const row = parent.createDiv('nexus-mcp-row'); - row.createSpan({ text: '✓ connected', cls: 'nexus-mcp-status nexus-mcp-success' }); - const actions = row.createDiv('nexus-mcp-actions'); - const openBtn = actions.createEl('button', { text: 'Open config' }); - if (component) { - component.registerDomEvent(openBtn, 'click', () => this.openConfigFile(configPath)); - } - const revealBtn = actions.createEl('button', { text: this.getRevealButtonText() }); - if (component) { - component.registerDomEvent(revealBtn, 'click', () => this.revealInFolder(configPath)); - } - parent.createEl('p', { text: restartText, cls: 'nexus-mcp-help' }); - } - - /** - * "Other agents" block: create the connector + a copyable entry + link to the guide. - * Nexus does not know these tools' config paths, so no write/reveal is offered. - */ - private renderGenericAgentsSection(): void { - const block = this.container.createDiv('nexus-agent-block'); - const heading = block.createEl('h4'); - heading.createSpan({ text: '🧩 ', cls: 'nx-agent-logo' }); - heading.createSpan({ text: 'Other agents (Cursor, Cline, Gemini CLI, Copilot…)' }); - - block.createEl('p', { - text: 'Nexus works with any MCP client. Create the bridge file once, then paste the same server entry into that tool’s config.', - cls: 'setting-item-description' - }); - - this.appendManualDisclosure(block, { - code: this.getConfigJson(), - configLabel: 'your agent’s MCP config', - configPath: null, - known: false, - restartText: 'Most tools auto-reload MCP config; some need a restart. Check the setup guide if the server doesn’t appear.', - summaryText: 'Show manual steps' - }); - - const helpP = block.createEl('p', { cls: 'nexus-mcp-help' }); - helpP.createSpan({ text: 'Need another tool? See the ' }); - const link = helpP.createEl('a', { text: 'MCP setup guide', href: '#' }); - const component = this.services.component; - if (component) { - component.registerDomEvent(link, 'click', (evt: MouseEvent) => { - evt.preventDefault(); - window.open('https://github.com/ProfSynapse/nexus/blob/main/guide/mcp-setup.md', '_blank'); - }); - } - helpP.createSpan({ text: ' for exact config locations per tool.' }); - } - - /** - * Collapsible "Set up manually" disclosure with numbered steps. - */ - private appendManualDisclosure(parent: HTMLElement, opts: ManualStepOptions): void { - const details = parent.createEl('details', { cls: 'nexus-manual-details' }); - details.createEl('summary', { text: opts.summaryText ?? 'Set up manually instead' }); - const body = details.createDiv('nexus-manual-body'); - this.buildManualSteps(body, opts); - } - - private addManualStep(body: HTMLElement, num: string, title: string): HTMLElement { - const step = body.createDiv('nx-step'); - step.createDiv('nx-step-num').setText(num); - const stepBody = step.createDiv('nx-step-body'); - stepBody.createEl('h5', { text: title }); - return stepBody; - } - - private buildManualSteps(body: HTMLElement, opts: ManualStepOptions): void { - const component = this.services.component; - const connectorPath = this.getConnectorDisplayPath(); - - // Step 1 — create connector.js - const step1 = this.addManualStep(body, '1', 'Create the connector file'); - step1.createEl('p', { - text: 'Generates connector.js in the plugin folder. Do this first — the config below points at it, and the agent fails silently if it’s missing.', - cls: 'setting-item-description' - }); - const createRow = step1.createDiv('nx-pathrow'); - const createBtn = createRow.createEl('button', { text: 'Create connector.js' }); - if (component) { - component.registerDomEvent(createBtn, 'click', () => this.createConnectorFileWithNotice()); - } - const pathRow = step1.createDiv('nx-pathrow'); - pathRow.createSpan({ text: connectorPath, cls: 'nx-inline-path' }); - - // Step 2 — add config - const step2 = this.addManualStep(body, '2', `Add this to ${opts.configLabel}`); - step2.createEl('p', { - text: opts.known - ? 'Paste inside the existing config (merge into mcpServers if you already have servers).' - : 'Paste this into your agent’s MCP config — each tool keeps it in a different place. See the setup guide below for exact locations.', - cls: 'setting-item-description' - }); - const codeBlock = step2.createEl('pre', { cls: 'nexus-config-code' }); - codeBlock.createEl('code', { text: opts.code }); - const copyRow = step2.createDiv('nx-pathrow'); - const copyBtn = copyRow.createEl('button', { text: 'Copy config' }); - if (component) { - component.registerDomEvent(copyBtn, 'click', async () => { - try { - await navigator.clipboard.writeText(opts.code); - copyBtn.textContent = 'Copied!'; - window.setTimeout(() => { copyBtn.textContent = 'Copy config'; }, 2000); - } catch { - new Notice('Failed to copy to clipboard'); - } - }); - } - if (opts.known && opts.configPath) { - const revealBtn = copyRow.createEl('button', { text: 'Reveal config file' }); - const configPath = opts.configPath; - if (component) { - component.registerDomEvent(revealBtn, 'click', () => this.revealInFolder(configPath)); - } - } - - // Step 3 — restart - const step3 = this.addManualStep(body, '3', opts.known ? 'Restart the agent' : 'Restart your agent'); - step3.createEl('p', { text: opts.restartText, cls: 'setting-item-description' }); - } - - /** - * Best-effort display path for connector.js (falls back to the bare filename). - */ - private getConnectorDisplayPath(): string { - try { - const pathMod = this.loadDesktopModule('path'); - return this.getConnectorPath(pathMod); - } catch { - return 'connector.js'; - } - } - - /** - * Local CLI bridge: installs a machine-global `nexus` command + agent skill - * so external coding agents (Claude Code, Codex) can drive the vault with no - * MCP configuration. Everything lives outside the vault and is reversible. - */ - private renderLocalCliSection(): void { - const block = this.container.createDiv('nexus-agent-block'); - const heading = block.createEl('h4'); - heading.createSpan({ text: '⚡ ', cls: 'nx-agent-logo' }); - heading.createSpan({ text: 'Local CLI (no MCP required)' }); - - block.createEl('p', { - text: 'Installs a machine-global command-line tool so external coding agents can discover and run your vault’s tools directly — no MCP server entry to configure. Everything is installed outside your vault and is reversible.', - cls: 'setting-item-description' - }); - - const installer = new LocalCliInstaller(); - if (!installer.isSupported()) { - block.createEl('p', { - text: 'The local CLI is available on desktop only.', - cls: 'setting-item-description' - }); - return; - } - - // Keep an already-installed copy in sync with this plugin build. - installer.reconcile(); - - const status = installer.status(); - const component = this.services.component; - - const row = block.createDiv('nexus-mcp-row'); - const statusText = status.installed - ? (!status.runtimeReady - ? 'Installed (Node.js 18+ is not available on PATH)' - : status.onPath - ? 'Installed and on your PATH' - : Platform.isWin && status.pathConfigured - ? 'Installed (another nexus command takes precedence)' - : 'Installed (not yet on your PATH)') - : 'Not installed'; - row.createSpan({ text: statusText, cls: 'nexus-mcp-status' }); - - const actions = row.createDiv('nexus-mcp-actions'); - - // Provider picker — which agents to wire the nexus CLI into. Claude Code and - // Cursor use the same skills mechanism (a symlink into their skills dir); - // Codex gets an AGENTS.md pointer block. - const providers: Array<{ id: CliProviderId; label: string; hint: string }> = [ - { id: 'claudeCode', label: 'Claude Code', hint: '~/.claude/skills' }, - { id: 'cursor', label: 'Cursor', hint: '~/.cursor/skills' }, - { id: 'codex', label: 'Codex', hint: '~/.codex/AGENTS.md' }, - ]; - const isLinked = (id: CliProviderId): boolean => - id === 'claudeCode' ? status.skillLinked - : id === 'cursor' ? status.cursorLinked - : status.codexLinked; - - const picker = block.createDiv('nexus-cli-providers'); - picker.createEl('p', { - text: status.installed - ? 'Wire the nexus CLI into these agents — toggle any time:' - : 'Choose which agents to wire the nexus CLI into:', - cls: 'setting-item-description' - }); - const checkboxes = new Map(); - for (const prov of providers) { - const detectedHere = status.detected[prov.id]; - const rowEl = picker.createDiv('nexus-cli-provider-row'); - const cb = rowEl.createEl('input', { attr: { type: 'checkbox' } }); - cb.checked = status.installed ? isLinked(prov.id) : detectedHere; - const label = rowEl.createEl('label'); - label.createSpan({ text: prov.label }); - label.createSpan({ - text: detectedHere ? ` — ${prov.hint}` : ` — ${prov.hint} (not detected)`, - cls: 'nexus-cli-provider-hint' - }); - checkboxes.set(prov.id, cb); - if (status.installed && component) { - // Post-install: each toggle wires/unwires that provider immediately. - component.registerDomEvent(cb, 'change', () => this.setCliProvider(installer, prov.id, prov.label, cb.checked)); - } - } - - if (!status.installed) { - const enableBtn = actions.createEl('button', { text: 'Install CLI', cls: 'mod-cta' }); - if (component) { - component.registerDomEvent(enableBtn, 'click', () => { - const targets: Partial = {}; - for (const [id, cb] of checkboxes) targets[id] = cb.checked; - this.enableLocalCli(installer, targets); - }); - } - } else { - if (Platform.isWin && !status.pathConfigured) { - const pathBtn = actions.createEl('button', { text: 'Add to path', cls: 'mod-cta' }); - if (component) { - component.registerDomEvent(pathBtn, 'click', () => this.addLocalCliToPath(installer)); - } - } - const revealBtn = actions.createEl('button', { text: this.getRevealButtonText() }); - if (component) { - component.registerDomEvent(revealBtn, 'click', () => this.revealInFolder(status.paths.cliJsPath)); - } - const uninstallBtn = actions.createEl('button', { text: 'Uninstall' }); - if (component) { - component.registerDomEvent(uninstallBtn, 'click', () => this.uninstallLocalCli(installer)); - } - } - - // Installed but the shell can't find it — the one failure mode that looks - // like nothing is wrong. Show the exact edit rather than a bare warning. - if (status.installed && status.runtimeReady && !status.onPath && status.pathFix) { - this.renderCliPathFix(block, status.pathFix, installer); - } - - const details = block.createEl('details', { cls: 'nexus-manual-details' }); - details.createEl('summary', { text: status.installed ? 'What’s installed' : 'What this installs' }); - const disclosureBody = details.createDiv('nexus-manual-body'); - for (const line of installer.describePlan()) { - disclosureBody.createDiv('nx-pathrow').createSpan({ text: line, cls: 'nx-inline-path' }); - } - disclosureBody.createEl('p', { - text: 'All paths are outside your vault (nothing is synced), and everything here is reversible with uninstall.', - cls: 'setting-item-description' - }); - } - - /** - * The `nexus` command exists but the user's shell cannot resolve it. Nexus does - * not edit shell profiles — they are the user's files — so hand over the exact - * line, where it goes, and how to reload, with a copy button. - */ - private renderCliPathFix(block: HTMLElement, fix: CliPathFix, installer: LocalCliInstaller): void { - const component = this.services.component; - const panel = block.createDiv('nexus-cli-pathfix'); - panel.createEl('p', { - text: `The nexus command is installed, but your ${fix.shell} shell can’t find it yet. Add this line to ${fix.profilePath}:`, - cls: 'setting-item-description' - }); - - const row = panel.createDiv('nx-pathrow'); - row.createSpan({ text: fix.exportLine, cls: 'nx-inline-path' }); - const copyBtn = row.createEl('button', { text: 'Copy' }); - if (component) { - component.registerDomEvent(copyBtn, 'click', () => { - void navigator.clipboard.writeText(fix.exportLine) - .then(() => new Notice('Copied. Paste it into your shell profile.')) - .catch(() => new Notice('Could not copy — select the line and copy it manually.')); - }); - } - - panel.createEl('p', { - text: `Then run ${fix.reloadCommand} in any open terminal, or just open a new one.`, - cls: 'setting-item-description' - }); - - const recheckBtn = panel.createEl('button', { text: 'Re-check' }); - if (component) { - component.registerDomEvent(recheckBtn, 'click', () => { - const updated = installer.status(); - new Notice(updated.onPath - ? 'Nexus CLI resolves in your shell now.' - : 'Still not resolving — make sure the line was saved, then open a new terminal.'); - this.render(); - }); - } - } - - private enableLocalCli(installer: LocalCliInstaller, targets?: Partial): void { - try { - const result = installer.enable(targets); - const warn = result.warnings.length - ? ` (${result.warnings.length} note${result.warnings.length === 1 ? '' : 's'})` - : ''; - const terminalHint = Platform.isWin - ? 'Open a new terminal, then run `nexus vaults`.' - : 'Try running `nexus vaults` in your terminal.'; - new Notice(result.warnings.length - ? `Nexus CLI installed${warn}, but some requested wiring was preserved or unavailable. See the developer console for details.` - : `Nexus CLI installed. ${terminalHint}`); - for (const w of result.warnings) console.warn('[GetStartedTab] Local CLI:', w); - this.render(); - } catch (error) { - console.error('[GetStartedTab] Error installing local CLI:', error); - new Notice(`Failed to install local CLI: ${(error as Error).message}`); - } - } - - private addLocalCliToPath(installer: LocalCliInstaller): void { - try { - const result = installer.addToWindowsUserPath(); - for (const w of result.warnings) console.warn('[GetStartedTab] Local CLI:', w); - new Notice(result.warnings.length - ? 'Could not add Nexus CLI to PATH. See the developer console for details.' - : 'Nexus CLI added to your user PATH. Open a new terminal to use it.'); - this.render(); - } catch (error) { - console.error('[GetStartedTab] Error adding local CLI to PATH:', error); - new Notice(`Failed to add Nexus CLI to PATH: ${(error as Error).message}`); - } - } - - private setCliProvider(installer: LocalCliInstaller, id: CliProviderId, label: string, enabled: boolean): void { - try { - const result = installer.setProvider(id, enabled); - for (const w of result.warnings) console.warn('[GetStartedTab] Local CLI:', w); - new Notice(result.warnings.length - ? `Could not ${enabled ? 'wire nexus into' : 'remove nexus from'} ${label}. See the developer console for details.` - : enabled ? `Wired nexus into ${label}.` : `Removed nexus from ${label}.`); - this.render(); - } catch (error) { - console.error('[GetStartedTab] Error updating CLI provider:', error); - new Notice(`Failed to update ${label}: ${(error as Error).message}`); - } - } - - private uninstallLocalCli(installer: LocalCliInstaller): void { - try { - const result = installer.uninstall(); - for (const w of result.warnings) console.warn('[GetStartedTab] Local CLI:', w); - new Notice(result.warnings.length - ? 'Nexus CLI cleanup was incomplete. See the developer console for details.' - : 'Nexus CLI uninstalled.'); - this.render(); - } catch (error) { - console.error('[GetStartedTab] Error uninstalling local CLI:', error); - new Notice(`Failed to uninstall local CLI: ${(error as Error).message}`); - } - } - - private renderCodexSetupSection(): void { - const block = this.container.createDiv('nexus-agent-block'); - const heading = block.createEl('h4'); - heading.createSpan({ text: '⬛ ', cls: 'nx-agent-logo' }); - heading.createSpan({ text: 'Codex' }); - - const nodeFs = this.loadDesktopModule('fs'); - const pathMod = this.loadDesktopModule('path'); - const configPath = this.getCodexConfigPath(pathMod); - const nodePath = this.resolveNodePath(); - const configStatus = this.getCodexConfigStatus(nodeFs, configPath); - const component = this.services.component; - - if (configStatus === 'nexus-configured') { - if (this.justConnectedCodex) { - this.renderJustConnectedCard(block, { - configPath, - configFileName: 'config.toml', - restartText: 'Start a new Codex session to load it.' - }); - } else { - this.renderConnectedCompact(block, configPath, 'Restart Codex or start a new session if Nexus does not appear.'); - } - return; - } - - const row = block.createDiv('nexus-mcp-row'); - row.createSpan({ text: 'Ready to connect', cls: 'nexus-mcp-status' }); - const actions = row.createDiv('nexus-mcp-actions'); - const connectBtn = actions.createEl('button', { text: 'Connect Codex', cls: 'mod-cta' }); - if (!nodePath) { - connectBtn.disabled = true; - } - if (component) { - component.registerDomEvent(connectBtn, 'click', () => this.autoConfigureCodex()); - } - - this.appendManualDisclosure(block, { - code: this.getCodexConfigToml(), - configLabel: '~/.codex/config.toml', - configPath, - known: true, - restartText: 'Start a new Codex session (config is picked up on launch).' - }); - } - - /** - * Resolve the absolute path to the Node.js binary. - * Uses `which` (macOS/Linux) or `where` (Windows) to find node. - * Result is cached per render cycle (cleared on re-render). - */ - private resolveNodePath(): string { - if (this.cachedNodePath !== null) { - return this.cachedNodePath; - } - if (!Platform.isDesktop) { - this.cachedNodePath = ''; - return ''; - } - this.cachedNodePath = resolveDesktopBinaryPath('node') ?? ''; - return this.cachedNodePath; - } - - private loadDesktopModule( - moduleName: TModuleName - ): DesktopModuleMap[TModuleName] { - if (!Platform.isDesktop) { - throw new Error(`${moduleName} is only available on desktop.`); - } - - const maybeRequire = (window.activeWindow as Window & { - require?: (moduleId: string) => unknown; - }).require; - - if (typeof maybeRequire !== 'function') { - throw new Error('Desktop module loader is unavailable.'); - } - - return maybeRequire(moduleName) as DesktopModuleMap[TModuleName]; - } - - private parseJson(text: string): unknown { - const parser = JSON.parse as (value: string) => unknown; - return parser(text); - } - - private isClaudeConfig(value: unknown): value is ClaudeConfig { - if (typeof value !== 'object' || value === null) { - return false; - } - - const maybeConfig = value as { mcpServers?: unknown }; - return typeof maybeConfig.mcpServers === 'object' && maybeConfig.mcpServers !== null; - } - - private getCodexConfigPath(pathMod: DesktopModuleMap['path']): string { - const codexHome = process.env.CODEX_HOME || pathMod.join(this.getUserHome(), '.codex'); - return pathMod.normalize(pathMod.join(codexHome, 'config.toml')); - } - - private getUserHome(): string { - if (Platform.isWin) { - return process.env.USERPROFILE || process.env.HOME || ''; - } - - return process.env.HOME || ''; - } - - private getCodexConfigStatus( - nodeFs: DesktopModuleMap['fs'], - configPath: string - ): CodexConfigStatus { - if (!nodeFs.existsSync(configPath)) { - return 'no-config-file'; - } - - try { - const content = nodeFs.readFileSync(configPath, 'utf-8'); - const serverKey = getPrimaryServerKey(this.services.app.vault.getName()); - - if (hasCodexMcpServerConfig(content, serverKey)) { - return 'nexus-configured'; - } - } catch (error) { - console.error('[GetStartedTab] Error reading Codex config:', error); - } - - return 'config-exists'; - } - - private getCodexConfigToml(): string { - const pathMod = this.loadDesktopModule('path'); - const vaultName = this.services.app.vault.getName(); - const serverKey = getPrimaryServerKey(vaultName); - const connectorPath = pathMod.normalize(pathMod.join(this.services.pluginPath, 'connector.js')); - const nodePath = this.resolveNodePath() || 'node'; - - return buildCodexMcpTomlSnippet(serverKey, nodePath, [connectorPath]); - } - - /** - * Generate the configuration JSON string - */ - private getConfigJson(): string { - const pathMod = this.loadDesktopModule('path'); - const vaultName = this.services.app.vault.getName(); - const serverKey = getPrimaryServerKey(vaultName); - const connectorPath = pathMod.normalize(pathMod.join(this.services.pluginPath, 'connector.js')); - const nodePath = this.resolveNodePath() || 'node'; - - const config = { - mcpServers: { - [serverKey]: { - command: nodePath, - args: [connectorPath] - } - } - }; - - return JSON.stringify(config, null, 2); - } - - /** - * Check the status of the Claude config - */ - private checkConfigStatus(): ConfigStatus { - return getConfigStatus(this.services.app); - } - - /** - * Auto-configure Nexus in Claude Desktop config - */ - private autoConfigureNexus(configPath: string): void { - const nodeFs = this.loadDesktopModule('fs'); - const pathMod = this.loadDesktopModule('path'); - try { - let config: ClaudeConfig = { mcpServers: {} }; - - // Read existing config if it exists - if (nodeFs.existsSync(configPath)) { - const content = nodeFs.readFileSync(configPath, 'utf-8'); - try { - const parsed = this.parseJson(content); - if (this.isClaudeConfig(parsed)) { - config = parsed; - } - if (!config.mcpServers) { - config.mcpServers = {}; - } - } catch { - // Invalid JSON, start fresh but warn user - new Notice('Existing config was invalid JSON. Creating new config.'); - config = { mcpServers: {} }; - } - } - - // Add Nexus server config - const vaultName = this.services.app.vault.getName(); - const serverKey = getPrimaryServerKey(vaultName); - const connectorPath = this.ensureConnectorFile(nodeFs, pathMod); - const nodePath = this.resolveNodePath(); - - if (!nodePath) { - new Notice('Node.js not found. Please install Node.js and try again.'); - return; - } - - config.mcpServers[serverKey] = { - command: nodePath, - args: [connectorPath] - }; - - // Ensure directory exists - const configDir = pathMod.dirname(configPath); - if (!nodeFs.existsSync(configDir)) { - nodeFs.mkdirSync(configDir, { recursive: true }); - } - - // Write config - nodeFs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); - - new Notice('Nexus connector created and added to Claude Desktop config. Please restart Claude Desktop.'); - - // Show the "just connected" confirmation card on the next render. - this.justConnectedClaude = true; - - // Re-render to show updated status - this.render(); - } catch (error) { - console.error('[GetStartedTab] Error auto-configuring:', error); - new Notice(`Failed to configure: ${(error as Error).message}`); - } - } - - private autoConfigureCodex(): void { - const nodeFs = this.loadDesktopModule('fs'); - const pathMod = this.loadDesktopModule('path'); - - try { - const nodePath = this.resolveNodePath(); - - if (!nodePath) { - new Notice('Node.js not found. Please install Node.js and try again.'); - return; - } - - const vaultName = this.services.app.vault.getName(); - const serverKey = getPrimaryServerKey(vaultName); - const connectorPath = this.ensureConnectorFile(nodeFs, pathMod); - const configPath = this.getCodexConfigPath(pathMod); - const configDir = pathMod.dirname(configPath); - const configToml = buildCodexMcpTomlSnippet(serverKey, nodePath, [connectorPath]); - - if (!nodeFs.existsSync(configDir)) { - nodeFs.mkdirSync(configDir, { recursive: true }); - } - - if (!nodeFs.existsSync(configPath)) { - nodeFs.writeFileSync(configPath, `${configToml}\n`, 'utf-8'); - new Notice('Nexus connector added to the app config. Restart the app or start a new session if needed.'); - this.justConnectedCodex = true; - this.render(); - return; - } - - const existingContent = nodeFs.readFileSync(configPath, 'utf-8'); - if (hasCodexMcpServerConfig(existingContent, serverKey)) { - new Notice('Nexus is already configured in the app config.'); - this.render(); - return; - } - - const updatedContent = appendCodexMcpTomlSnippet(existingContent, configToml); - nodeFs.writeFileSync(configPath, updatedContent, 'utf-8'); - new Notice('Nexus connector added to the app config. Restart the app or start a new session if needed.'); - this.justConnectedCodex = true; - this.render(); - } catch (error) { - console.error('[GetStartedTab] Error auto-configuring Codex:', error); - new Notice(`Failed to configure Codex: ${(error as Error).message}`); - } - } - - private getConnectorPath(pathMod: DesktopModuleMap['path']): string { - if (!this.services.pluginPath) { - throw new Error('Plugin path is unavailable.'); - } - return pathMod.normalize(pathMod.join(this.services.pluginPath, 'connector.js')); - } - - private ensureConnectorFile( - nodeFs: DesktopModuleMap['fs'], - pathMod: DesktopModuleMap['path'] - ): string { - const connectorPath = this.getConnectorPath(pathMod); - const pluginDir = pathMod.dirname(connectorPath); - - if (!nodeFs.existsSync(pluginDir)) { - nodeFs.mkdirSync(pluginDir, { recursive: true }); - } - - if (nodeFs.existsSync(connectorPath)) { - const existingContent = nodeFs.readFileSync(connectorPath, 'utf-8'); - if (existingContent === CONNECTOR_JS_CONTENT) { - return connectorPath; - } - } - - nodeFs.writeFileSync(connectorPath, CONNECTOR_JS_CONTENT, 'utf-8'); - return connectorPath; - } - - private createConnectorFileWithNotice(): void { - try { - const nodeFs = this.loadDesktopModule('fs'); - const pathMod = this.loadDesktopModule('path'); - const connectorPath = this.ensureConnectorFile(nodeFs, pathMod); - new Notice(`Connector file created: ${connectorPath}`); - } catch (error) { - console.error('[GetStartedTab] Error creating connector file:', error); - new Notice(`Failed to create connector file: ${(error as Error).message}`); - } - } - - /** - * Open the config file in the default editor - */ - private openConfigFile(configPath: string): void { - try { - // Use Electron's shell to open the file - const { shell } = this.loadDesktopModule('electron'); - void shell.openPath(configPath); - } catch (error) { - console.error('[GetStartedTab] Error opening config file:', error); - new Notice('Failed to open config file. Please open it manually.'); - } - } - - /** - * Reveal the config file in the system file manager - */ - private revealInFolder(configPath: string): void { - try { - const { shell } = this.loadDesktopModule('electron'); - void shell.showItemInFolder(configPath); - } catch (error) { - console.error('[GetStartedTab] Error revealing in folder:', error); - new Notice('Failed to reveal in folder. Please navigate manually.'); - } - } - - /** - * Get OS-specific text for the reveal button - */ - private getRevealButtonText(): string { - if (Platform.isWin) { - return 'Reveal in Explorer'; - } else if (Platform.isMacOS) { - return 'Reveal in Finder'; - } else { - return 'Reveal in Files'; - } - } - - /** - * Cleanup - */ - destroy(): void { - // No resources to clean up - } -} diff --git a/src/settings/tabs/defaults/FileConversionSettings.ts b/src/settings/tabs/defaults/FileConversionSettings.ts new file mode 100644 index 000000000..cc454f415 --- /dev/null +++ b/src/settings/tabs/defaults/FileConversionSettings.ts @@ -0,0 +1,153 @@ +/** + * The File conversion fold on Defaults. + * + * Rows, in order: File import (the `enableIngestion` switch, which gates PDF, + * document and audio conversion), then only while it is on: Reading method, + * Vision provider and Vision model (only for Vision), and Convert new files + * automatically. The fold redraws its own rows when the switch or the method + * changes, so dependent rows never sit above the switch or linger while it is off. + */ + +import { Setting } from 'obsidian'; +import type { LLMProviderSettings } from '../../../types/llm/ProviderTypes'; +import { + normalizeIngestSelection, + type IngestProviderOption, +} from '../../../agents/ingestManager/tools/services/IngestCapabilityService'; +import { renderIngestModelDropdowns } from '../../../components/shared/IngestModelDropdownRenderer'; +import { FILE_CONVERSION_COPY, FOLD_ICONS, FOLD_IDS, FOLD_TITLES } from '../../../components/shared/chatSettings/copy'; +import { fileConversionSummary } from '../../../components/shared/chatSettings/sectionSummaries'; +import type { FoldDefinition, FoldHost } from '../../../components/shared/chatSettings/types'; + +/** The plugin settings this fold reads and writes. */ +export interface FileConversionPluginSettings { + enableIngestion?: boolean; + autoIngestion?: boolean; +} + +export interface FileConversionDeps { + getPluginSettings(): FileConversionPluginSettings; + getLlmSettings(): LLMProviderSettings; + /** Apply a change and queue a save behind any save already in flight. */ + save(mutate: () => void): void; + /** Vision-capable providers for PDF reading. */ + loadOcrProviders(): Promise; +} + +type OcrState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'ready'; providers: IngestProviderOption[] } + | { status: 'failed' }; + +export class FileConversionSettings implements FoldDefinition { + readonly id = FOLD_IDS.fileConversion; + readonly title = FOLD_TITLES.fileConversion; + readonly icon = FOLD_ICONS.fileConversion; + private ocr: OcrState = { status: 'idle' }; + private destroyed = false; + + constructor(private readonly deps: FileConversionDeps) {} + + getSummary(): string { + const plugin = this.deps.getPluginSettings(); + return fileConversionSummary({ + enabled: plugin.enableIngestion !== false, + vision: this.deps.getLlmSettings().defaultPdfMode === 'vision', + autoConvert: plugin.autoIngestion === true + }); + } + + render(itemsEl: HTMLElement, host: FoldHost): void { + const plugin = this.deps.getPluginSettings(); + const llm = this.deps.getLlmSettings(); + const enabled = plugin.enableIngestion !== false; + + new Setting(itemsEl) + .setName(FILE_CONVERSION_COPY.fileImport) + .setDesc(FILE_CONVERSION_COPY.fileImportDesc) + .addToggle(toggle => toggle + .setValue(enabled) + .onChange(value => { + this.deps.save(() => { this.deps.getPluginSettings().enableIngestion = value; }); + host.redraw(); + })); + + if (enabled) { + const vision = llm.defaultPdfMode === 'vision'; + new Setting(itemsEl) + .setName(FILE_CONVERSION_COPY.readingMethod) + .setDesc(FILE_CONVERSION_COPY.readingMethodDesc) + .addDropdown(dropdown => dropdown + .addOption('text', FILE_CONVERSION_COPY.textExtraction) + .addOption('vision', FILE_CONVERSION_COPY.vision) + .setValue(vision ? 'vision' : 'text') + .onChange(value => { + this.deps.save(() => { this.deps.getLlmSettings().defaultPdfMode = value === 'vision' ? 'vision' : 'text'; }); + host.redraw(); + })); + + if (vision) this.renderVisionRows(itemsEl, host); + + new Setting(itemsEl) + .setName(FILE_CONVERSION_COPY.autoConvert) + .setDesc(FILE_CONVERSION_COPY.autoConvertDesc) + .addToggle(toggle => toggle + .setValue(plugin.autoIngestion === true) + .onChange(value => { + this.deps.save(() => { this.deps.getPluginSettings().autoIngestion = value; }); + host.setSummary(this.getSummary()); + })); + } + + host.setSummary(this.getSummary()); + } + + private renderVisionRows(itemsEl: HTMLElement, host: FoldHost): void { + const state = this.ocr; + if (state.status === 'idle' || state.status === 'loading') { + new Setting(itemsEl).setName(FILE_CONVERSION_COPY.visionProvider).setDesc('Loading vision models...'); + if (state.status === 'idle') this.loadOcr(host); + return; + } + if (state.status === 'failed') { + new Setting(itemsEl).setName(FILE_CONVERSION_COPY.visionProvider).setDesc(FILE_CONVERSION_COPY.visionUnavailable); + return; + } + + const llm = this.deps.getLlmSettings(); + const normalized = normalizeIngestSelection(state.providers, llm.defaultOcrModel?.provider, llm.defaultOcrModel?.model); + renderIngestModelDropdowns(itemsEl, { + labelPrefix: FILE_CONVERSION_COPY.vision, + providers: state.providers, + getSelection: () => { + const current = this.deps.getLlmSettings().defaultOcrModel; + if (current?.provider && current.model) return current; + return normalized.provider && normalized.model ? { provider: normalized.provider, model: normalized.model } : undefined; + }, + onChange: (provider, model) => { + this.deps.save(() => { + this.deps.getLlmSettings().defaultOcrModel = provider && model ? { provider, model } : undefined; + }); + }, + providerSettingName: FILE_CONVERSION_COPY.visionProvider, + modelSettingName: FILE_CONVERSION_COPY.visionModel + }); + } + + private loadOcr(host: FoldHost): void { + this.ocr = { status: 'loading' }; + void this.deps.loadOcrProviders().then(providers => { + this.ocr = { status: 'ready', providers }; + }).catch((error: unknown) => { + console.error('[FileConversionSettings] Failed to load vision models:', error); + this.ocr = { status: 'failed' }; + }).then(() => { + if (!this.destroyed) host.redraw(); + }); + } + + destroy(): void { + this.destroyed = true; + } +} diff --git a/src/settings/tabs/defaults/SemanticSearchOffModal.ts b/src/settings/tabs/defaults/SemanticSearchOffModal.ts new file mode 100644 index 000000000..24d5b32ab --- /dev/null +++ b/src/settings/tabs/defaults/SemanticSearchOffModal.ts @@ -0,0 +1,60 @@ +/** + * Confirmation before semantic search is turned off. + * + * Says what stops working and that the index is kept. Turn off resolves true; + * Cancel, the close button and Escape resolve false. A plain Obsidian Modal + * with no Node imports, so it is safe to load on mobile even though the fold + * that opens it exists only on desktop. + */ + +import { App, ButtonComponent, Modal } from 'obsidian'; +import { SEMANTIC_OFF_MODAL_COPY as COPY } from '../../../components/shared/chatSettings/copy'; + +export class SemanticSearchOffModal extends Modal { + private confirmed = false; + private settled = false; + + constructor(app: App, private readonly onResolve: (confirmed: boolean) => void) { + super(app); + } + + /** Open the modal and resolve with the user's choice. */ + static confirm(app: App): Promise { + return new Promise(resolve => { + new SemanticSearchOffModal(app, resolve).open(); + }); + } + + onOpen(): void { + this.setTitle(COPY.title); + this.modalEl.addClass('mod-confirm'); + + const { contentEl } = this; + contentEl.empty(); + contentEl.createEl('p', { text: COPY.lede }); + const list = contentEl.createEl('ul'); + for (const bullet of COPY.bullets) { + list.createEl('li', { text: bullet }); + } + contentEl.createEl('p', { text: COPY.note, cls: 'nexus-semantic-off-note' }); + + const buttons = this.modalEl.createDiv('modal-button-container'); + new ButtonComponent(buttons) + .setButtonText(COPY.cancel) + .onClick(() => this.close()); + new ButtonComponent(buttons) + .setButtonText(COPY.confirm) + .setWarning() + .onClick(() => { + this.confirmed = true; + this.close(); + }); + } + + onClose(): void { + this.contentEl.empty(); + if (this.settled) return; + this.settled = true; + this.onResolve(this.confirmed); + } +} diff --git a/src/settings/tabs/defaults/SemanticSearchSettings.ts b/src/settings/tabs/defaults/SemanticSearchSettings.ts new file mode 100644 index 000000000..ab7222071 --- /dev/null +++ b/src/settings/tabs/defaults/SemanticSearchSettings.ts @@ -0,0 +1,411 @@ +/** + * The Semantic search fold on Defaults (desktop only). + * + * Rows: + * - the `enableEmbeddings` switch, applied at runtime (no restart). Turning it + * off asks first (SemanticSearchOffModal); Cancel or Escape leaves it on. + * - while on: "Index" with the stored note and chat counts, or, while a pass + * runs, "Indexing" with a progress bar, the done / total count, an estimate + * and a pause / resume button. When the last pass ended with phase 'error' + * (the embedding model did not load), a row saying so, with the queue's + * detail and "Try again", which starts the runtime over. + * - while off: "Index" with what is being kept, and "Delete index", which asks + * for confirmation and then deletes the stored vectors only. + * + * Status comes from the embedding runtime (EmbeddingManager) through a narrow + * interface, resolved on every use: the runtime is created only once storage is + * ready, so it can appear after the fold was first drawn. Until it does, the + * fold checks for it once a second and redraws when it arrives. The fold + * subscribes to its state while mounted and unsubscribes on destroy. + */ + +import { Notice, Setting, type ProgressBarComponent } from 'obsidian'; +import { FOLD_ICONS, FOLD_IDS, FOLD_TITLES, SEMANTIC_COPY as COPY } from '../../../components/shared/chatSettings/copy'; +import { semanticSearchSummary } from '../../../components/shared/chatSettings/sectionSummaries'; +import type { FoldDefinition, FoldHost } from '../../../components/shared/chatSettings/types'; +import type { EmbeddingManager } from '../../../services/embeddings/EmbeddingManager'; +import type { IndexingProgress } from '../../../services/embeddings/IndexingQueue'; + +/** The parts of EmbeddingManager the fold uses. */ +export type SemanticSearchRuntime = Pick; + +const RUNTIME_METHODS: Array = [ + 'getStatus', 'getStats', 'onStateChange', 'pauseIndexing', 'resumeIndexing', 'deleteIndex', 'setEnabled', 'retry' +]; + +/** + * The plugin's embedding runtime (`plugin.embeddingManager`), or null when it + * does not exist yet (storage still loading) or on mobile. + */ +export function resolveSemanticSearchRuntime(plugin: unknown): SemanticSearchRuntime | null { + if (plugin === null || typeof plugin !== 'object') return null; + const manager = (plugin as { embeddingManager?: unknown }).embeddingManager; + if (manager === null || typeof manager !== 'object') return null; + const candidate = manager as Record; + return RUNTIME_METHODS.every(method => typeof candidate[method] === 'function') + ? manager as SemanticSearchRuntime + : null; +} + +export interface SemanticSearchDeps { + /** The saved `enableEmbeddings` switch (on when absent). */ + isEnabled(): boolean; + /** Persist the switch (through the Defaults save queue). */ + setEnabled(enabled: boolean): void; + /** Apply the switch to the running plugin, without a restart. */ + applyEnabled(enabled: boolean): Promise; + /** The live runtime, or null before it exists. Resolved on every use. */ + getRuntime(): SemanticSearchRuntime | null; + /** Ask before turning search off. Resolves true to go ahead. */ + confirmTurnOff(): Promise; + /** Ask before deleting the index. Resolves true to go ahead. */ + confirmDeleteIndex(): Promise; +} + +type IndexCounts = { notes: number; chats: number; total: number }; + +type CountsState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'ready'; counts: IndexCounts } + | { status: 'failed' }; + +type FoldView = + | { mode: 'off'; key: string } + | { mode: 'on'; key: string } + | { mode: 'error'; key: string; detail: string | undefined } + | { mode: 'indexing'; key: string; paused: boolean; progress: IndexingProgress }; + +/** Phases in which a pass is running and its progress is worth showing. */ +const ACTIVE_PHASES = new Set(['loading_model', 'checking', 'indexing', 'paused']); + +/** How often a fold drawn before the runtime existed checks for it. */ +const RUNTIME_POLL_MS = 1000; + +function percentOf(progress: IndexingProgress): number { + if (progress.totalNotes <= 0) return 0; + return Math.min(100, Math.max(0, (progress.processedNotes / progress.totalNotes) * 100)); +} + +export class SemanticSearchSettings implements FoldDefinition { + readonly id = FOLD_IDS.semanticSearch; + readonly title = FOLD_TITLES.semanticSearch; + readonly icon = FOLD_ICONS.semanticSearch; + + private host: FoldHost | null = null; + private counts: CountsState = { status: 'idle' }; + /** Bumped whenever the counts go stale, so a slower, older read is dropped. */ + private countsRequest = 0; + private subscription: { runtime: SemanticSearchRuntime; unsubscribe: () => void } | null = null; + /** The view drawn last; a state change to a different one redraws the rows. */ + private drawnKey = ''; + /** The live Indexing row, updated in place on each progress event. */ + private indexingRow: { setting: Setting; bar: ProgressBarComponent | null; valueEl: HTMLElement } | null = null; + private deleting = false; + private retrying = false; + private destroyed = false; + /** Interval that watches for the runtime to appear; null when not waiting. */ + private runtimeWait: number | null = null; + + constructor(private readonly deps: SemanticSearchDeps) {} + + getSummary(): string { + const view = this.view(); + if (view.mode === 'off') { + const counts = this.readyCounts(); + return semanticSearchSummary({ enabled: false, indexKept: counts ? counts.total > 0 : undefined }); + } + if (view.mode === 'indexing') { + return semanticSearchSummary({ + enabled: true, + indexingPercent: percentOf(view.progress), + checking: view.progress.phase === 'checking' + }); + } + if (view.mode === 'error') return semanticSearchSummary({ enabled: true, loadFailed: true }); + return semanticSearchSummary({ enabled: true, noteCount: this.readyCounts()?.notes }); + } + + /** Draw the rows into `itemsEl` (a fold's `setting-items` list). */ + render(itemsEl: HTMLElement, host: FoldHost): void { + this.host = host; + this.indexingRow = null; + this.subscribe(); + this.waitForRuntime(); + + const view = this.view(); + this.drawnKey = view.key; + + new Setting(itemsEl) + .setName(COPY.toggle) + .setDesc(COPY.toggleDesc) + .addToggle(toggle => toggle + .setValue(view.mode !== 'off') + .onChange(value => { void this.onToggle(value); })); + + if (view.mode === 'indexing') { + this.renderIndexing(itemsEl, view.progress, view.paused); + } else if (view.mode === 'error') { + this.renderLoadFailed(itemsEl, view.detail); + } else if (view.mode === 'on') { + this.renderIndexOn(itemsEl); + } else { + this.renderIndexOff(itemsEl); + } + + if (this.counts.status === 'idle') this.loadCounts(); + host.setSummary(this.getSummary()); + } + + destroy(): void { + this.destroyed = true; + this.stopWaitingForRuntime(); + this.subscription?.unsubscribe(); + this.subscription = null; + this.host = null; + this.indexingRow = null; + } + + private view(): FoldView { + if (!this.deps.isEnabled()) return { mode: 'off', key: 'off' }; + const status = this.deps.getRuntime()?.getStatus(); + const progress = status?.progress; + if (status?.indexing && progress && ACTIVE_PHASES.has(progress.phase)) { + const paused = status.paused || progress.phase === 'paused'; + // Checking and indexing are separate rows: the pass that compares note + // hashes counts notes compared, and must not read as notes indexed. + const key = paused ? 'indexing-paused' : progress.phase === 'checking' ? 'checking' : 'indexing'; + return { mode: 'indexing', key, paused, progress }; + } + if (!status?.indexing && progress?.phase === 'error') { + return { mode: 'error', key: 'error', detail: progress.error }; + } + return { mode: 'on', key: 'on' }; + } + + private readyCounts(): IndexCounts | null { + return this.counts.status === 'ready' ? this.counts.counts : null; + } + + private renderIndexOn(itemsEl: HTMLElement): void { + const counts = this.readyCounts(); + const value = counts + ? COPY.indexCounts(counts.notes, counts.chats) + : this.deps.getRuntime() ? '' : COPY.starting; + const setting = new Setting(itemsEl).setName(COPY.index).setDesc(COPY.indexOnDesc); + if (value) setting.controlEl.createDiv({ cls: 'nexus-semantic-value', text: value }); + } + + /** + * The last pass ended with phase 'error': the embedding model did not load, + * so nothing was indexed. "Try again" starts the runtime over. + */ + private renderLoadFailed(itemsEl: HTMLElement, detail: string | undefined): void { + new Setting(itemsEl) + .setName(COPY.loadFailed) + .setDesc(detail || COPY.loadFailedDesc) + .setClass('nexus-semantic-error') + .addButton(button => button + .setButtonText(COPY.tryAgain) + .setDisabled(this.retrying || !this.deps.getRuntime()) + .onClick(() => { void this.onRetry(); })); + } + + private renderIndexing(itemsEl: HTMLElement, progress: IndexingProgress, paused: boolean): void { + const setting = new Setting(itemsEl) + .setName(progress.phase === 'checking' ? COPY.checking : COPY.indexing) + .setDesc(paused ? COPY.indexingPausedDesc : this.progressDesc(progress)) + .setClass('nexus-semantic-progress'); + + let bar: ProgressBarComponent | null = null; + setting.addProgressBar(component => { + bar = component; + component.setValue(percentOf(progress)); + }); + const valueEl = setting.controlEl.createDiv({ + cls: 'nexus-semantic-value', + text: COPY.indexingProgress(progress.processedNotes, progress.totalNotes, progress.scope) + }); + setting.addExtraButton(button => button + .setIcon(paused ? 'play' : 'pause') + .setTooltip(paused ? COPY.resumeIndexing : COPY.pauseIndexing) + .onClick(() => { + const runtime = this.deps.getRuntime(); + if (!runtime) return; + if (paused) runtime.resumeIndexing(); + else runtime.pauseIndexing(); + })); + + this.indexingRow = { setting, bar, valueEl }; + } + + private progressDesc(progress: IndexingProgress): string { + return progress.phase === 'checking' + ? COPY.checkingDesc + : COPY.indexingDesc(progress.estimatedTimeRemaining); + } + + private renderIndexOff(itemsEl: HTMLElement): void { + const counts = this.readyCounts(); + let value: string = COPY.indexPausedUnknown; + if (counts) value = counts.total > 0 ? COPY.indexPaused(counts.notes) : COPY.noIndex; + const index = new Setting(itemsEl).setName(COPY.index).setDesc(COPY.indexOffDesc); + index.controlEl.createDiv({ cls: 'nexus-semantic-value', text: value }); + + const nothingToDelete = counts !== null && counts.total === 0; + new Setting(itemsEl) + .setName(COPY.deleteIndex) + .setDesc(COPY.deleteIndexDesc) + .addButton(button => button + .setButtonText(COPY.deleteIndex) + .setWarning() + .setDisabled(this.deleting || nothingToDelete || !this.deps.getRuntime()) + .onClick(() => { void this.onDeleteIndex(); })); + } + + private async onToggle(enabled: boolean): Promise { + if (!enabled) { + const confirmed = await this.deps.confirmTurnOff(); + if (!confirmed) { + // Redraw from the saved value, which puts the switch back on. + this.redraw(); + return; + } + } + + this.deps.setEnabled(enabled); + this.invalidateCounts(); + this.redraw(); + + try { + await this.deps.applyEnabled(enabled); + } catch (error) { + console.error('[SemanticSearchSettings] Failed to apply semantic search:', error); + new Notice(COPY.applyFailed); + } + this.invalidateCounts(); + this.redraw(); + } + + private async onRetry(): Promise { + const runtime = this.deps.getRuntime(); + if (this.retrying || !runtime) return; + + this.retrying = true; + this.redraw(); + try { + await runtime.retry(); + } catch (error) { + console.error('[SemanticSearchSettings] Failed to start semantic search again:', error); + new Notice(COPY.retryFailed); + } finally { + this.retrying = false; + this.invalidateCounts(); + this.redraw(); + } + } + + private async onDeleteIndex(): Promise { + const runtime = this.deps.getRuntime(); + if (this.deleting || !runtime) return; + if (!(await this.deps.confirmDeleteIndex())) return; + + this.deleting = true; + this.redraw(); + try { + await runtime.deleteIndex(); + } catch (error) { + console.error('[SemanticSearchSettings] Failed to delete the search index:', error); + new Notice(error instanceof Error && error.message ? error.message : COPY.deleteFailed); + } finally { + this.deleting = false; + this.invalidateCounts(); + this.redraw(); + } + } + + private invalidateCounts(): void { + this.countsRequest++; + this.counts = { status: 'idle' }; + } + + private loadCounts(): void { + const runtime = this.deps.getRuntime(); + if (!runtime) return; + const request = ++this.countsRequest; + this.counts = { status: 'loading' }; + void runtime.getStats().then(stats => { + if (request !== this.countsRequest) return; + this.counts = { + status: 'ready', + counts: { + notes: stats.noteCount, + chats: stats.conversationCount, + total: stats.noteCount + stats.traceCount + stats.conversationChunkCount + } + }; + this.redraw(); + }).catch((error: unknown) => { + if (request !== this.countsRequest) return; + console.error('[SemanticSearchSettings] Failed to read index stats:', error); + this.counts = { status: 'failed' }; + this.redraw(); + }); + } + + private subscribe(): void { + const runtime = this.deps.getRuntime(); + if (!runtime || this.subscription?.runtime === runtime) return; + this.subscription?.unsubscribe(); + this.subscription = { runtime, unsubscribe: runtime.onStateChange(() => this.onStateChange()) }; + } + + /** + * The runtime is created in the background once storage is ready, and + * nothing announces it. Without this a fold drawn first would read + * "Starting" (and keep Delete index disabled) until settings were reopened. + */ + private waitForRuntime(): void { + if (this.runtimeWait !== null || this.destroyed || this.deps.getRuntime()) return; + this.runtimeWait = window.setInterval(() => { + if (this.destroyed) { + this.stopWaitingForRuntime(); + return; + } + if (!this.deps.getRuntime()) return; + this.stopWaitingForRuntime(); + // redraw() runs render(), which subscribes and loads the counts. + this.invalidateCounts(); + this.redraw(); + }, RUNTIME_POLL_MS); + } + + private stopWaitingForRuntime(): void { + if (this.runtimeWait === null) return; + window.clearInterval(this.runtimeWait); + this.runtimeWait = null; + } + + private onStateChange(): void { + if (this.destroyed || !this.host) return; + const view = this.view(); + if (view.key !== this.drawnKey) { + // Started, stopped, paused or finished: counts may have moved too. + this.invalidateCounts(); + this.redraw(); + return; + } + if (view.mode === 'indexing' && this.indexingRow) { + const { setting, bar, valueEl } = this.indexingRow; + bar?.setValue(percentOf(view.progress)); + valueEl.setText(COPY.indexingProgress(view.progress.processedNotes, view.progress.totalNotes, view.progress.scope)); + if (!view.paused) setting.setDesc(this.progressDesc(view.progress)); + } + this.host.setSummary(this.getSummary()); + } + + private redraw(): void { + if (!this.destroyed) this.host?.redraw(); + } +} diff --git a/src/settings/tabs/defaults/VideoSettings.ts b/src/settings/tabs/defaults/VideoSettings.ts new file mode 100644 index 000000000..a8a361d94 --- /dev/null +++ b/src/settings/tabs/defaults/VideoSettings.ts @@ -0,0 +1,165 @@ +/** + * The Video fold on Defaults: provider, model, aspect ratio and resolution for + * the generateVideo tool. Edits the shared renderer's settings object; the + * Defaults tab persists the result through its save queue. + */ + +import { Setting } from 'obsidian'; +import { + buildVideoProviderAvailability, + getVideoModel, + type VideoAspectRatio, + type VideoModelDeclaration, + type VideoResolution, +} from '../../../services/llm/types/VideoTypes'; +import { sortModelsNewestFirst } from '../../../utils/modelOrdering'; +import { ProviderUtils } from '../../../ui/chat/utils/ProviderUtils'; +import { COPY, FOLD_ICONS, FOLD_IDS, FOLD_TITLES, VIDEO_COPY } from '../../../components/shared/chatSettings/copy'; +import { videoSummary } from '../../../components/shared/chatSettings/sectionSummaries'; +import type { FoldDefinition, FoldHost } from '../../../components/shared/chatSettings/types'; + +interface VideoSelection { + provider?: 'google' | 'openrouter'; + model?: string; + modelDeclaration?: VideoModelDeclaration; + aspectRatio?: VideoAspectRatio; + resolution?: VideoResolution; +} + +export class VideoSettings implements FoldDefinition { + readonly id = FOLD_IDS.video; + readonly title = FOLD_TITLES.video; + readonly icon = FOLD_ICONS.video; + /** Sits after Images, before Voice. */ + readonly after = FOLD_IDS.images; + + render(itemsEl: HTMLElement, host: FoldHost): void { + const availability = () => buildVideoProviderAvailability(host.llmProviderSettings); + const selection = (): VideoSelection => { + const settings = host.settings; + const all = availability(); + const provider = settings.videoProvider + || all.find(item => item.enabled && item.configured && item.models.length > 0)?.provider; + const providerAvailability = all.find(item => item.provider === provider); + const model = settings.videoModel && providerAvailability?.models.some(candidate => candidate.id === settings.videoModel) + ? settings.videoModel + : providerAvailability?.models[0]?.id; + const modelDeclaration = getVideoModel(provider, model) || providerAvailability?.models.find(candidate => candidate.id === model); + return { + provider, + model, + modelDeclaration, + aspectRatio: settings.videoAspectRatio || modelDeclaration?.defaultAspectRatio, + resolution: settings.videoResolution || modelDeclaration?.defaultResolution, + }; + }; + + const updateSummary = (): void => { + const current = selection(); + host.setSummary(videoSummary({ + modelName: current.modelDeclaration?.name ?? current.model, + aspectRatio: current.aspectRatio, + resolution: current.resolution + })); + }; + + const choose = (model: VideoModelDeclaration, aspectRatio?: VideoAspectRatio, resolution?: VideoResolution): void => { + host.settings.videoProvider = model.provider; + host.settings.videoModel = model.id; + host.settings.videoAspectRatio = aspectRatio || model.defaultAspectRatio; + host.settings.videoResolution = resolution || model.defaultResolution; + host.notifyChange(); + updateSummary(); + }; + + const current = selection(); + const usable = availability().filter(item => item.enabled && item.configured && item.models.length > 0); + + new Setting(itemsEl) + .setName(COPY.provider) + .addDropdown(dropdown => { + if (usable.length === 0 && !current.provider) { + dropdown.addOption('', VIDEO_COPY.noProviders); + dropdown.setDisabled(true); + return; + } + for (const provider of usable) { + dropdown.addOption(provider.provider, ProviderUtils.getProviderDisplayName(provider.provider)); + } + if (current.provider && !usable.some(provider => provider.provider === current.provider)) { + dropdown.addOption(current.provider, `${ProviderUtils.getProviderDisplayName(current.provider)} (unavailable)`); + } + dropdown.setValue(current.provider || usable[0]?.provider || ''); + dropdown.onChange(provider => { + const model = availability().find(item => item.provider === provider)?.models[0]; + if (!model) return; + choose(model); + host.redraw(); + }); + }); + + const providerAvailability = availability().find(item => item.provider === current.provider); + const models = providerAvailability?.models ?? []; + new Setting(itemsEl) + .setName(COPY.model) + .addDropdown(dropdown => { + if (!current.provider || models.length === 0) { + dropdown.addOption('', VIDEO_COPY.selectProvider); + dropdown.setDisabled(true); + return; + } + for (const model of sortModelsNewestFirst(models, item => ({ provider: current.provider || '', id: item.id }))) { + dropdown.addOption(model.id, model.name); + } + dropdown.setValue(current.model || models[0]?.id || ''); + dropdown.setDisabled(providerAvailability?.enabled !== true || providerAvailability.configured !== true); + dropdown.onChange(modelId => { + const before = selection(); + const model = getVideoModel(before.provider, modelId); + if (!model) return; + // Keep the chosen ratio and resolution when the new model supports them. + choose( + model, + before.aspectRatio && model.aspectRatios.includes(before.aspectRatio) ? before.aspectRatio : undefined, + before.resolution && model.resolutions.includes(before.resolution) ? before.resolution : undefined + ); + host.redraw(); + }); + }); + + const model = current.modelDeclaration; + new Setting(itemsEl) + .setName(VIDEO_COPY.aspectRatio) + .addDropdown(dropdown => { + if (!model) { + dropdown.addOption('', VIDEO_COPY.selectModel); + dropdown.setDisabled(true); + return; + } + for (const value of model.aspectRatios) dropdown.addOption(value, value); + dropdown.setValue(current.aspectRatio || model.defaultAspectRatio); + dropdown.onChange(value => { + const latest = selection(); + if (latest.modelDeclaration) choose(latest.modelDeclaration, value as VideoAspectRatio, latest.resolution); + }); + }); + + new Setting(itemsEl) + .setName(VIDEO_COPY.resolution) + .addDropdown(dropdown => { + if (!model) { + dropdown.addOption('', VIDEO_COPY.selectModel); + dropdown.setDisabled(true); + return; + } + for (const value of model.resolutions) dropdown.addOption(value, value); + dropdown.setValue(current.resolution || model.defaultResolution); + dropdown.onChange(value => { + const latest = selection(); + if (latest.modelDeclaration) choose(latest.modelDeclaration, latest.aspectRatio, value as VideoResolution); + }); + }); + + updateSummary(); + } +} diff --git a/src/types/branch/BranchTypes.ts b/src/types/branch/BranchTypes.ts index e83d0fe3e..555f6ab4c 100644 --- a/src/types/branch/BranchTypes.ts +++ b/src/types/branch/BranchTypes.ts @@ -96,9 +96,9 @@ export interface SubagentParams { sessionId?: string; maxIterations?: number; continueBranchId?: string; - // Inherited from parent conversation - Model settings - provider?: string; // LLM provider (inherits parent's model) - model?: string; // LLM model (inherits parent's model) + // Model settings, resolved with the agent precedence (subagent model, then a cloud chat model) + provider?: string; // LLM provider for the subagent run + model?: string; // LLM model for the subagent run // Inherited from parent conversation - Agent settings agentPrompt?: string; // Custom agent's full system prompt (merged into subagent prompt) agentName?: string; // Custom agent name for reference @@ -106,9 +106,16 @@ export interface SubagentParams { workspaceData?: Record; // Full comprehensive workspace data (sessions, states, files, etc.) // Inherited from parent conversation - Context notes (file paths) inheritedContextNotes?: string[]; // Note paths from parent's context notes - // Inherited from parent conversation - Thinking settings + // Subagent thinking settings (already gated for the resolved model) thinkingEnabled?: boolean; thinkingEffort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max'; + /** Subagent Creativity; undefined when the model rejects sampling parameters. */ + temperature?: number; + /** + * Token budget for the inherited context (context files and workspace data) + * assembled into the subagent's system prompt. Undefined means no budget. + */ + contextWindow?: number; } /** diff --git a/src/types/chat/ChatTypes.ts b/src/types/chat/ChatTypes.ts index ddb045aca..e700e872d 100644 --- a/src/types/chat/ChatTypes.ts +++ b/src/types/chat/ChatTypes.ts @@ -177,6 +177,10 @@ export interface Conversation { enabled: boolean; effort: 'low' | 'medium' | 'high' | 'xhigh' | 'max'; }; + /** Subagent Creativity for this chat (0-1). Absent: the defaults apply. */ + agentTemperature?: number; + /** Subagent context budget for this chat. Absent or null: the subagent model's default. */ + agentContextWindow?: number | null; }; promptId?: string; workflowId?: string; diff --git a/src/types/llm/ProviderTypes.ts b/src/types/llm/ProviderTypes.ts index a375be49e..c25ff96dd 100644 --- a/src/types/llm/ProviderTypes.ts +++ b/src/types/llm/ProviderTypes.ts @@ -132,8 +132,26 @@ export interface LLMProviderSettings { defaultModel: DefaultModelSettings; /** User-chosen context limits, keyed by JSON-encoded [provider, model]. */ contextWindowOverrides?: Record; - agentModel?: DefaultModelSettings; // Model for executePrompt (API-only, used when chat model is local) + /** + * Default model for subagents and executePrompt. When set it wins over the + * chat model; when absent, agent runs use the chat model if it is a cloud + * model, otherwise the first available cloud provider. + */ + agentModel?: DefaultModelSettings; agentThinking?: DefaultThinkingSettings; // Thinking settings for agent model (separate from chat model) + /** + * Default Creativity (temperature, 0.0-1.0) for subagents and executePrompt. + * Absent means `defaultTemperature ?? 0.5`. Not sent to models whose + * registry entry has `supportsSamplingParams: false`. + */ + agentTemperature?: number; + /** + * Subagent context limits keyed by `getContextWindowOverrideKey(provider, model)`. + * Kept apart from `contextWindowOverrides` so the chat and subagent sliders + * for the same model never write the same key. Absent means the model's + * advertised window. + */ + agentContextWindowOverrides?: Record; defaultImageModel?: DefaultImageModelSettings; // Default image generation model defaultVideoModel?: DefaultVideoModelSettings; // Default video generation model defaultThinking?: DefaultThinkingSettings; // Default thinking settings for chat model diff --git a/src/types/plugin/PluginTypes.ts b/src/types/plugin/PluginTypes.ts index ce6559f7c..62d72b2db 100644 --- a/src/types/plugin/PluginTypes.ts +++ b/src/types/plugin/PluginTypes.ts @@ -80,8 +80,6 @@ export interface ProcessedFilesData { */ export interface MCPSettings { enabledVault: boolean; - /** Explicit accordion preference; absent means infer from configured setup paths. */ - getStartedOpen?: boolean; enableEmbeddings?: boolean; // Enable/disable local embeddings for semantic search (desktop only) enableIngestion?: boolean; // Enable/disable PDF/audio ingestion UI and ingest-only model settings autoIngestion?: boolean; // Automatically convert newly added supported binary files to Markdown diff --git a/src/types/tools/ToolRuntimeContext.ts b/src/types/tools/ToolRuntimeContext.ts new file mode 100644 index 000000000..fa1b94fef --- /dev/null +++ b/src/types/tools/ToolRuntimeContext.ts @@ -0,0 +1,26 @@ +/** + * Runtime identity of a tool call, supplied by the tool batch executor. + * + * Most tools only see their parameters. A tool that needs to know which + * conversation called it, and whether the call came from internal chat or an + * external MCP client, opts in with `acceptsToolRuntimeContext` and receives + * this object as the second argument to `execute`. + */ +export interface ToolRuntimeContext { + conversationId: string; + messageId: string; + workspaceId?: string; + sessionId?: string; + source: 'internal' | 'mcp'; + isSubagentBranch: boolean; +} + +export interface ToolRuntimeContextConsumer { + readonly acceptsToolRuntimeContext: true; +} + +export function acceptsToolRuntimeContext(tool: unknown): tool is ToolRuntimeContextConsumer { + return typeof tool === 'object' + && tool !== null + && (tool as { acceptsToolRuntimeContext?: unknown }).acceptsToolRuntimeContext === true; +} diff --git a/src/ui/chat/ChatView.ts b/src/ui/chat/ChatView.ts index c1a159abc..12455b7ba 100644 --- a/src/ui/chat/ChatView.ts +++ b/src/ui/chat/ChatView.ts @@ -56,7 +56,6 @@ import { ChatEventBinder } from './utils/ChatEventBinder'; import { CHAT_VIEW_TYPES } from '../../constants/branding'; import { getNexusPlugin } from '../../utils/pluginLocator'; import { generateUUID } from '../../utils/uuid'; -import { getContextWindowOverrideKey } from './utils/ContextWindowSettings'; // Nexus Lifecycle import { getWebLLMLifecycleManager } from '../../services/llm/adapters/webllm/WebLLMLifecycleManager'; @@ -644,8 +643,8 @@ export class ChatView extends ItemView { onSidebarToggled: (_visible) => { /* Sidebar toggled */ } }; this.uiStateController = new UIStateController(this.containerEl, uiStateEvents, this); - this.uiStateController.setOpenSettingsCallback(() => { - void this.openChatSettingsModal(); + this.uiStateController.setOpenProviderSettingsCallback(() => { + this.openProviderSettings(); }); this.streamingController = new StreamingController(this.containerEl, this.app, this); this.nexusLoadingController = new NexusLoadingController(this.containerEl); @@ -1141,17 +1140,12 @@ export class ChatView extends ItemView { }, this).open(); } - /** Apply a changed default only to this active chat, through the normal handoff. */ - async applyContextWindowDefault(overrides: Record, changedKeys: ReadonlySet): Promise { - const model = this.modelAgentManager?.getSelectedModel(); - if (!model) return true; - const key = getContextWindowOverrideKey(model.providerId, model.modelId); - if (!changedKeys.has(key)) return true; - return this.modelAgentManager.requestContextChange({ - providerId: model.providerId, - modelId: model.modelId, - contextWindowOverride: overrides[key] - }); + /** The no-provider welcome sends the user to Settings → Nexus → Providers. */ + private openProviderSettings(): void { + const plugin = getNexusPlugin(this.app); + if (!plugin?.openNexusSettings({ tab: 'providers' })) { + new Notice('Could not open settings. Add a provider in the Nexus settings.'); + } } private async ensurePreservationServiceAndCompact(): Promise { diff --git a/src/ui/chat/components/AgentStatusModal.ts b/src/ui/chat/components/AgentStatusModal.ts index f9ac832b1..592510fad 100644 --- a/src/ui/chat/components/AgentStatusModal.ts +++ b/src/ui/chat/components/AgentStatusModal.ts @@ -118,6 +118,7 @@ export class AgentStatusModal extends Modal { const { contentEl } = this; this.renderEvents?.unload(); this.renderEvents = new Component(); + this.renderEvents.load(); contentEl.empty(); // Get running agents from executor (in-memory, has lastToolUsed) diff --git a/src/ui/chat/components/ChatSettingsModal.ts b/src/ui/chat/components/ChatSettingsModal.ts index e88723e85..a98141b86 100644 --- a/src/ui/chat/components/ChatSettingsModal.ts +++ b/src/ui/chat/components/ChatSettingsModal.ts @@ -12,6 +12,8 @@ import { ChatSettingsRenderer, ChatSettings, ChatSettingsOptions } from '../../. import { getNexusPlugin } from '../../../utils/pluginLocator'; import { Settings } from '../../../settings'; import { getContextWindowOverrideKey } from '../utils/ContextWindowSettings'; +import { resolveSubagentTarget } from '../../../components/shared/chatSettings/subagentTarget'; +import { getRegistryContextWindow } from '../../../services/llm/utils/modelCapabilityLookup'; /** * Type for the NexusPlugin with settings property @@ -27,9 +29,9 @@ export class ChatSettingsModal extends Modal { private conversationId: string | null; private renderer: ChatSettingsRenderer | null = null; private pendingSettings: ChatSettings | null = null; - private displayedContextWindowOverrides: Record | null = null; private saveButton: ButtonComponent | null = null; private saveStatus: HTMLElement | null = null; + private buttonContainer: HTMLElement | null = null; private saving = false; private ownsContextHandoff = false; private closed = false; @@ -50,13 +52,20 @@ export class ChatSettingsModal extends Modal { this.closed = false; const { contentEl } = this; contentEl.empty(); - contentEl.addClass('chat-settings-modal'); + this.setTitle('Chat settings'); + this.modalEl.addClass('chat-settings-modal'); - // Header with buttons - const header = contentEl.createDiv('chat-settings-header'); - header.createEl('h2', { text: 'Chat settings' }); + // The renderer's container comes first so the form sits above the buttons + // even though it fills in asynchronously. + const rendererContainer = contentEl.createDiv('chat-settings-renderer'); - const buttonContainer = header.createDiv('chat-settings-buttons'); + // Cancel / Save sit at the bottom of the modal, outside the scrolling content. + this.buttonContainer?.remove(); + const buttonContainer = this.modalEl.createDiv('modal-button-container'); + this.buttonContainer = buttonContainer; + this.saveStatus = buttonContainer.createEl('p', { text: '', cls: 'chat-settings-save-status' }); + this.saveStatus.setAttribute('role', 'status'); + this.saveStatus.setAttribute('aria-live', 'polite'); new ButtonComponent(buttonContainer) .setButtonText('Cancel') .onClick(() => this.close()); @@ -67,20 +76,17 @@ export class ChatSettingsModal extends Modal { .onClick(() => { void this.handleSave(); }); - this.saveStatus = buttonContainer.createEl('p', { text: '' }); - this.saveStatus.setAttribute('role', 'status'); - this.saveStatus.setAttribute('aria-live', 'polite'); // Load data and render - void this.loadAndRender(contentEl); + void this.loadAndRender(rendererContainer); } - private async loadAndRender(contentEl: HTMLElement): Promise { + private async loadAndRender(rendererContainer: HTMLElement): Promise { const plugin = getNexusPlugin(this.app); const llmProviderSettings = plugin?.settings?.settings?.llmProviders; if (!llmProviderSettings) { - contentEl.createEl('p', { text: 'Settings not available' }); + rendererContainer.createEl('p', { text: 'Settings not available' }); return; } @@ -95,14 +101,11 @@ export class ChatSettingsModal extends Modal { const initialSettings = this.getCurrentSettings(); initialSettings.workflowId = workflowId; - // Create renderer - const rendererContainer = contentEl.createDiv('chat-settings-renderer'); - this.renderer = new ChatSettingsRenderer(rendererContainer, { app: this.app, + surface: 'chat', llmProviderSettings, initialSettings, - showWorkflowSelection: true, options: { workspaces, prompts }, callbacks: { onSettingsChange: (settings) => { @@ -148,13 +151,30 @@ export class ChatSettingsModal extends Modal { const key = getContextWindowOverrideKey(model.providerId, model.modelId); contextWindowOverrides[key] = this.modelAgentManager.getEffectiveContextWindow(); } - this.displayedContextWindowOverrides = { ...contextWindowOverrides }; + + const provider = model?.providerId || llmSettings?.defaultModel?.provider || ''; + const modelId = model?.modelId || llmSettings?.defaultModel?.model || ''; + const agentProvider = this.modelAgentManager.getAgentProvider() || llmSettings?.agentModel?.provider || undefined; + const agentModel = this.modelAgentManager.getAgentModel() || llmSettings?.agentModel?.model || undefined; + + // This chat's subagent limit is one number; the renderer keys it by the + // model the Subagents rows describe. A chat without its own limit shows + // the default limit, which is what its subagents would use. + const agentContextWindowOverrides: Record = {}; + const agentTarget = resolveSubagentTarget({ provider, model: modelId, agentProvider, agentModel }); + if (agentTarget.provider && agentTarget.model) { + const key = getContextWindowOverrideKey(agentTarget.provider, agentTarget.model); + const agentContextWindow = this.modelAgentManager.getAgentContextWindow() + ?? llmSettings?.agentContextWindowOverrides?.[key] + ?? null; + if (agentContextWindow !== null) agentContextWindowOverrides[key] = agentContextWindow; + } return { - provider: model?.providerId || llmSettings?.defaultModel?.provider || '', - model: model?.modelId || llmSettings?.defaultModel?.model || '', - agentProvider: this.modelAgentManager.getAgentProvider() || llmSettings?.agentModel?.provider || undefined, - agentModel: this.modelAgentManager.getAgentModel() || llmSettings?.agentModel?.model || undefined, + provider, + model: modelId, + agentProvider, + agentModel, thinking: { enabled: thinking?.enabled ?? false, effort: thinking?.effort ?? 'medium' @@ -166,6 +186,8 @@ export class ChatSettingsModal extends Modal { effort: agentThinking?.effort ?? 'medium' }, temperature: temperature, + agentTemperature: this.modelAgentManager.getAgentTemperature(), + agentContextWindowOverrides, imageProvider: this.modelAgentManager.getImageProvider() || llmSettings?.defaultImageModel?.provider || 'google', imageModel: this.modelAgentManager.getImageModel() || llmSettings?.defaultImageModel?.model || 'gemini-3.1-flash-image', speechProvider: this.modelAgentManager.getSpeechProvider() || llmSettings?.defaultSpeechModel?.provider, @@ -231,31 +253,7 @@ export class ChatSettingsModal extends Modal { if (!committed) return; } - const plugin = getNexusPlugin(this.app); - const llmProviders = plugin?.settings?.settings?.llmProviders; - if (llmProviders && settings.contextWindowOverrides) { - const displayed = this.displayedContextWindowOverrides ?? {}; - const changedKeys = new Set([...Object.keys(displayed), ...Object.keys(settings.contextWindowOverrides)]); - const oldOverrides = llmProviders.contextWindowOverrides; - const nextOverrides = { ...oldOverrides }; - let changed = false; - for (const key of changedKeys) { - if (displayed[key] === settings.contextWindowOverrides[key]) continue; - changed = true; - if (settings.contextWindowOverrides[key] === undefined) delete nextOverrides[key]; - else nextOverrides[key] = settings.contextWindowOverrides[key]; - } - if (changed) { - llmProviders.contextWindowOverrides = nextOverrides; - try { - await plugin?.settings?.saveSettings(); - } catch (error) { - llmProviders.contextWindowOverrides = oldOverrides; - const reason = error instanceof Error ? error.message : 'Unknown error'; - throw new Error(`Context change was saved, but default context settings could not be saved: ${reason}`); - } - } - } + // The context window applies to this conversation only; Defaults own the value for new chats. if (this.closed) return; // Update prompt @@ -292,6 +290,12 @@ export class ChatSettingsModal extends Modal { this.modelAgentManager.setAgentThinkingSettings(settings.agentThinking); } + // Subagent Creativity and context limit for this chat + if (typeof settings.agentTemperature === 'number') { + this.modelAgentManager.setAgentTemperature(settings.agentTemperature); + } + this.modelAgentManager.setAgentContextWindow(this.resolveAgentContextWindow(settings)); + // Update temperature this.modelAgentManager.setTemperature(settings.temperature); @@ -322,9 +326,11 @@ export class ChatSettingsModal extends Modal { settings.transcriptionModel || null ); - // Save to conversation metadata + // Save to conversation metadata; with no conversation open, the next new one adopts them. if (this.conversationId) { await this.modelAgentManager.saveToConversation(this.conversationId); + } else { + this.modelAgentManager.carrySelectionsIntoNextConversation(); } if (!this.closed) this.close(); @@ -340,13 +346,31 @@ export class ChatSettingsModal extends Modal { } } + /** + * The subagent limit to keep for this chat. The model maximum is stored as + * "no limit", except when Defaults set a lower one for that model: then the + * maximum is kept explicitly so this chat does not fall back to the default. + */ + private resolveAgentContextWindow(settings: ChatSettings): number | null { + const target = resolveSubagentTarget(settings); + if (!target.provider || !target.model) return null; + const key = getContextWindowOverrideKey(target.provider, target.model); + const chosen = settings.agentContextWindowOverrides?.[key]; + if (typeof chosen === 'number') return chosen; + const plugin = getNexusPlugin(this.app); + const defaultLimit = plugin?.settings?.settings?.llmProviders?.agentContextWindowOverrides?.[key]; + if (typeof defaultLimit !== 'number') return null; + return getRegistryContextWindow(target.provider, target.model) ?? null; + } + onClose(): void { this.closed = true; if (this.ownsContextHandoff) this.modelAgentManager.cancelContextHandoff(); this.renderer?.destroy(); this.renderer = null; this.pendingSettings = null; - this.displayedContextWindowOverrides = null; + this.buttonContainer?.remove(); + this.buttonContainer = null; this.contentEl.empty(); } } diff --git a/src/ui/chat/components/ConversationTitleModal.ts b/src/ui/chat/components/ConversationTitleModal.ts index ff6b12568..c6008c397 100644 --- a/src/ui/chat/components/ConversationTitleModal.ts +++ b/src/ui/chat/components/ConversationTitleModal.ts @@ -18,6 +18,7 @@ export class ConversationTitleModal extends Modal { onOpen(): void { this.modalEvents = new Component(); + this.modalEvents.load(); const { contentEl } = this; contentEl.addClass('chat-conversation-title-modal'); diff --git a/src/ui/chat/components/suggesters/ContentEditableSuggester.ts b/src/ui/chat/components/suggesters/ContentEditableSuggester.ts index 7d5fbbbbb..33bcc59d0 100644 --- a/src/ui/chat/components/suggesters/ContentEditableSuggester.ts +++ b/src/ui/chat/components/suggesters/ContentEditableSuggester.ts @@ -20,6 +20,8 @@ export abstract class ContentEditableSuggester { protected isActive = false; protected component?: Component; private clickOutsideHandler?: (e: MouseEvent) => void; + /** The document the click-outside listener was added to, so destroy() removes it from the same one. */ + private clickOutsideDoc: Document | null = null; constructor(app: App, element: HTMLElement, config: SuggesterConfig, component?: Component) { this.app = app; @@ -69,13 +71,24 @@ export abstract class ContentEditableSuggester { window.setTimeout(() => this.closeSuggestions(), 100); } }; + // The input's own document, never window.activeDocument. Suggesters are + // built with the chat view, when focus may be in another window (a chat + // view restored into a popout loads while the main window has focus), so + // activeDocument would listen to the wrong window for the life of the view. + const doc = this.ownerDocument(); + this.clickOutsideDoc = doc; if (this.component) { - this.component.registerDomEvent(window.activeDocument, 'click', this.clickOutsideHandler); + this.component.registerDomEvent(doc, 'click', this.clickOutsideHandler); } else { - window.activeDocument.addEventListener('click', this.clickOutsideHandler); + doc.addEventListener('click', this.clickOutsideHandler); } } + /** The document the input element lives in: the chat view's window. */ + private ownerDocument(): Document { + return this.element.ownerDocument ?? window.document; + } + /** * Handle input event */ @@ -213,7 +226,7 @@ export abstract class ContentEditableSuggester { * Create suggestion container */ private createSuggestionContainer(): void { - this.suggestionContainer = window.activeDocument.body.createDiv('suggester-container'); + this.suggestionContainer = this.ownerDocument().body.createDiv('suggester-container'); this.suggestionContainer.classList.add('suggester-container-positioned', 'suggester-container-hidden'); } @@ -225,7 +238,8 @@ export abstract class ContentEditableSuggester { const rect = this.element.getBoundingClientRect(); this.suggestionContainer.style.left = rect.left + 'px'; - this.suggestionContainer.style.bottom = (window.innerHeight - rect.top + 4) + 'px'; + const viewportHeight = (this.ownerDocument().defaultView ?? window).innerHeight; + this.suggestionContainer.style.bottom = (viewportHeight - rect.top + 4) + 'px'; this.suggestionContainer.style.width = rect.width + 'px'; } @@ -288,9 +302,10 @@ export abstract class ContentEditableSuggester { } // Remove document click listener - if (this.clickOutsideHandler) { - window.activeDocument.removeEventListener('click', this.clickOutsideHandler); + if (this.clickOutsideHandler && this.clickOutsideDoc) { + this.clickOutsideDoc.removeEventListener('click', this.clickOutsideHandler); } + this.clickOutsideDoc = null; if (this.suggestionContainer) { this.suggestionContainer.remove(); diff --git a/src/ui/chat/components/suggesters/TextAreaNoteSuggester.ts b/src/ui/chat/components/suggesters/TextAreaNoteSuggester.ts index f7a7c7745..1a714e754 100644 --- a/src/ui/chat/components/suggesters/TextAreaNoteSuggester.ts +++ b/src/ui/chat/components/suggesters/TextAreaNoteSuggester.ts @@ -12,15 +12,15 @@ import { } from './base/SuggesterInterfaces'; import { MessageEnhancer } from '../../services/MessageEnhancer'; import { TokenCalculator } from '../../utils/TokenCalculator'; -import type { EmbeddingService } from '../../../../services/embeddings/EmbeddingService'; -import { searchNotes } from '../../../../utils/noteSearch'; +import { searchNotes, type EmbeddingServiceSource } from '../../../../utils/noteSearch'; export class TextAreaNoteSuggester extends ContentEditableSuggester { private messageEnhancer: MessageEnhancer; - private embeddingService: EmbeddingService | null; + /** Resolved on every query; see EmbeddingServiceSource. */ + private embeddingSource: EmbeddingServiceSource; private maxTokensPerNote = 10000; - constructor(app: App, element: HTMLElement, messageEnhancer: MessageEnhancer, component?: Component, embeddingService?: EmbeddingService | null) { + constructor(app: App, element: HTMLElement, messageEnhancer: MessageEnhancer, component?: Component, embeddingSource?: EmbeddingServiceSource) { super(app, element, { trigger: /\[\[([^\]]*?)$/, maxSuggestions: 50, @@ -29,11 +29,11 @@ export class TextAreaNoteSuggester extends ContentEditableSuggester[]> { - const files = await searchNotes(this.app, query, this.embeddingService, this.config.maxSuggestions); + const files = await searchNotes(this.app, query, this.embeddingSource, this.config.maxSuggestions); return files.map(file => this.createSuggestion(file, 1.0)); } diff --git a/src/ui/chat/components/suggesters/initializeSuggesters.ts b/src/ui/chat/components/suggesters/initializeSuggesters.ts index 8af33f2ef..773b4fa3c 100644 --- a/src/ui/chat/components/suggesters/initializeSuggesters.ts +++ b/src/ui/chat/components/suggesters/initializeSuggesters.ts @@ -12,7 +12,7 @@ import { CustomPromptStorageService } from '../../../../agents/promptManager/ser import { WorkspaceService } from '../../../../services/WorkspaceService'; import { getNexusPlugin } from '../../../../utils/pluginLocator'; import type { Settings } from '../../../../settings'; -import type { EmbeddingService } from '../../../../services/embeddings/EmbeddingService'; +import { resolveEmbeddingService } from '../../../../services/embeddings/embeddingAccess'; /** * Interface for NexusPlugin with settings and services @@ -21,7 +21,6 @@ interface NexusPluginWithServices extends Plugin { settings?: Settings; services?: Record; workspaceService?: WorkspaceService; - getServiceIfReady?: (name: string) => T | null; } export interface SuggesterInstances { @@ -40,15 +39,22 @@ export function initializeSuggesters( ): SuggesterInstances { const messageEnhancer = new MessageEnhancer(); - // Create suggesters - let embeddingService: EmbeddingService | null = null; + // Create suggesters. The note suggester resolves the embedding service on + // every query, so embeddings turned on or off later in the session apply to + // a composer that is already open. No service means fuzzy-only. + let pluginForEmbeddings: NexusPluginWithServices | null = null; try { - const pluginForEmbeddings = getNexusPlugin(app); - embeddingService = pluginForEmbeddings?.getServiceIfReady?.('embeddingService') ?? null; + pluginForEmbeddings = getNexusPlugin(app); } catch { - // Embedding service unavailable — fuzzy-only + pluginForEmbeddings = null; } - const noteSuggester = new TextAreaNoteSuggester(app, element, messageEnhancer, component, embeddingService); + const noteSuggester = new TextAreaNoteSuggester( + app, + element, + messageEnhancer, + component, + () => resolveEmbeddingService(pluginForEmbeddings) + ); const toolSuggester = new TextAreaToolSuggester(app, element, messageEnhancer, component); // Try to get CustomPromptStorageService for prompt suggester diff --git a/src/ui/chat/controllers/SubagentController.ts b/src/ui/chat/controllers/SubagentController.ts index e2d8e6295..fa86c6cdd 100644 --- a/src/ui/chat/controllers/SubagentController.ts +++ b/src/ui/chat/controllers/SubagentController.ts @@ -32,6 +32,28 @@ import type { ConversationData, ToolCall, ChatMessage } from '../../../types/cha import type { StreamingController } from './StreamingController'; import type { ToolEventCoordinator } from '../coordinators/ToolEventCoordinator'; import { isSubagentMetadata } from '../../../types/branch/BranchTypes'; +import type { ThinkingEffort } from '../../../types/llm/ProviderTypes'; +import { + resolveAgentModel, + resolveAgentRunSettings, + type ConversationAgentSettings +} from '../../../services/llm/utils/SubagentModelResolver'; +import { DEFAULT_MODELS } from '../../../services/llm/adapters/ModelRegistry'; + +/** Options the subagent executor passes to each streaming turn. */ +interface SubagentStreamOptions { + provider?: string; + model?: string; + systemPrompt?: string; + abortSignal?: AbortSignal; + workspaceId?: string; + sessionId?: string; + operationOrigin?: import('../../../types/tools/ToolOperationTypes').ToolExecutionOrigin; + operationScopeId?: string; + enableThinking?: boolean; + thinkingEffort?: ThinkingEffort; + temperature?: number; +} interface SubagentResultPayload { success?: boolean; @@ -60,12 +82,14 @@ export interface SubagentControllerDependencies { */ export interface SubagentContextProvider { getCurrentConversation: () => ConversationData | null; - getSelectedModel: () => { providerId?: string; modelId?: string } | null; + getSelectedModel: () => { providerId?: string; modelId?: string; contextWindow?: number } | null; getSelectedPrompt: () => { name?: string; systemPrompt?: string } | null; getLoadedWorkspaceData: () => Record | null; getContextNotes: () => string[]; getThinkingSettings: () => { enabled?: boolean; effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' } | null; getSelectedWorkspaceId: () => string | null; + /** This chat's subagent settings (model, thinking, Creativity, context window). */ + getAgentSettings?: () => ConversationAgentSettings | null; } /** @@ -208,7 +232,7 @@ export class SubagentController { // Wire up to PromptManagerAgent deps.promptManagerAgent.setSubagentExecutor( this.subagentExecutor, - () => this.buildSubagentContext(contextProvider) + () => this.buildSubagentContext(contextProvider, deps.llmService) ); // Initialize status menu if a container is provided. The optional @@ -317,31 +341,13 @@ export class SubagentController { directToolExecutor: DirectToolExecutor ): ( messages: ChatMessage[], - options: { - provider?: string; - model?: string; - systemPrompt?: string; - abortSignal?: AbortSignal; - workspaceId?: string; - sessionId?: string; - operationOrigin?: import('../../../types/tools/ToolOperationTypes').ToolExecutionOrigin; - operationScopeId?: string; - } + options: SubagentStreamOptions ) => AsyncGenerator { const isOpenAITool = this.isOpenAITool.bind(this); return async function* ( messages: ChatMessage[], - options: { - provider?: string; - model?: string; - systemPrompt?: string; - abortSignal?: AbortSignal; - workspaceId?: string; - sessionId?: string; - operationOrigin?: import('../../../types/tools/ToolOperationTypes').ToolExecutionOrigin; - operationScopeId?: string; - } + options: SubagentStreamOptions ) { try { const tools = shouldPassToolSchemasToProvider(options?.provider) @@ -355,6 +361,9 @@ export class SubagentController { workspaceId: options?.workspaceId, operationOrigin: options?.operationOrigin, operationScopeId: options?.operationScopeId, + enableThinking: options?.enableThinking, + thinkingEffort: options?.thinkingEffort, + temperature: options?.temperature, tools, }; @@ -452,7 +461,7 @@ export class SubagentController { /** * Build context for subagent execution from current state */ - private buildSubagentContext(contextProvider: SubagentContextProvider) { + private buildSubagentContext(contextProvider: SubagentContextProvider, llmService: LLMService) { const currentConversation = contextProvider.getCurrentConversation(); const messages = currentConversation?.messages || []; const lastMessage = messages[messages.length - 1]; @@ -462,7 +471,27 @@ export class SubagentController { const selectedPrompt = contextProvider.getSelectedPrompt(); const workspaceData = contextProvider.getLoadedWorkspaceData(); const contextNotes = contextProvider.getContextNotes() || []; - const thinkingSettings = contextProvider.getThinkingSettings(); + const conversationAgent = contextProvider.getAgentSettings?.() ?? undefined; + const agentDefaults = llmService.getAgentDefaults(); + + // Subagents use the subagent model: this chat's, then the default, then + // the chat model when it is a cloud model, then any cloud provider. + const agentModel = resolveAgentModel({ + conversation: conversationAgent, + defaultAgent: agentDefaults.model, + chat: { provider: selectedModel?.providerId, model: selectedModel?.modelId }, + availableProviders: llmService.getAvailableProviders(), + defaultModelFor: provider => DEFAULT_MODELS[provider] + }); + const isChatModel = agentModel.provider === selectedModel?.providerId + && agentModel.model === selectedModel?.modelId; + const runSettings = resolveAgentRunSettings({ + provider: agentModel.provider, + model: agentModel.model, + conversation: conversationAgent, + defaults: agentDefaults, + advertisedContextWindow: isChatModel ? selectedModel?.contextWindow : undefined + }); return { conversationId: currentConversation?.id || 'unknown', @@ -471,14 +500,17 @@ export class SubagentController { sessionId, source: 'internal' as const, isSubagentBranch: false, - provider: selectedModel?.providerId, - model: selectedModel?.modelId, + provider: agentModel.provider || undefined, + model: agentModel.model || undefined, agentPrompt: selectedPrompt?.systemPrompt, agentName: selectedPrompt?.name, workspaceData: workspaceData ?? undefined, contextNotes, - thinkingEnabled: thinkingSettings?.enabled, - thinkingEffort: thinkingSettings?.effort, + thinkingEnabled: runSettings.thinking.enabled, + thinkingEffort: runSettings.thinking.effort, + temperature: runSettings.temperature, + contextWindow: runSettings.contextWindow, + conversationAgent, }; } diff --git a/src/ui/chat/controllers/UIStateController.ts b/src/ui/chat/controllers/UIStateController.ts index cba064cf7..eced47790 100644 --- a/src/ui/chat/controllers/UIStateController.ts +++ b/src/ui/chat/controllers/UIStateController.ts @@ -11,7 +11,7 @@ export interface UIStateControllerEvents { export class UIStateController { private sidebarVisible = false; - private onOpenSettings?: () => void; + private onOpenProviderSettings?: () => void; private timeouts: ManagedTimeoutTracker; constructor( @@ -27,10 +27,11 @@ export class UIStateController { } /** - * Set callback for opening settings + * Set the callback for the no-provider welcome button, which opens + * Settings → Nexus → Providers (not the per-chat settings modal). */ - setOpenSettingsCallback(callback: () => void): void { - this.onOpenSettings = callback; + setOpenProviderSettingsCallback(callback: () => void): void { + this.onOpenProviderSettings = callback; } /** @@ -107,9 +108,7 @@ export class UIStateController { setIcon(settingsBtnIcon, 'settings'); settingsBtn.insertBefore(settingsBtnIcon, settingsBtn.firstChild); const settingsHandler = () => { - if (this.onOpenSettings) { - this.onOpenSettings(); - } + this.onOpenProviderSettings?.(); }; this.registerClickHandler(settingsBtn, settingsHandler); } diff --git a/src/ui/chat/services/ChatSubagentIntegration.ts b/src/ui/chat/services/ChatSubagentIntegration.ts index c3bb58d43..b92f4d347 100644 --- a/src/ui/chat/services/ChatSubagentIntegration.ts +++ b/src/ui/chat/services/ChatSubagentIntegration.ts @@ -17,6 +17,7 @@ import { type SubagentControllerEvents, } from '../controllers/SubagentController'; import type { ConversationData } from '../../../types/chat/ChatTypes'; +import type { ConversationAgentSettings } from '../../../services/llm/utils/SubagentModelResolver'; interface ConversationManagerLike { getCurrentConversation(): ConversationData | null; @@ -24,12 +25,13 @@ interface ConversationManagerLike { } interface ModelAgentManagerLike { - getSelectedModel(): { providerId?: string; modelId?: string } | null; + getSelectedModel(): { providerId?: string; modelId?: string; contextWindow?: number } | null; getSelectedPrompt(): { name?: string; systemPrompt?: string } | null; getLoadedWorkspaceData(): Record | null; getContextNotes(): string[]; getThinkingSettings(): { enabled?: boolean; effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' } | null; getSelectedWorkspaceId(): string | null; + getAgentSettings?(): ConversationAgentSettings; } interface NavigationTarget { @@ -123,6 +125,7 @@ export class ChatSubagentIntegration { getContextNotes: () => this.deps.getModelAgentManager()?.getContextNotes() || [], getThinkingSettings: () => this.deps.getModelAgentManager()?.getThinkingSettings() ?? null, getSelectedWorkspaceId: () => this.deps.getModelAgentManager()?.getSelectedWorkspaceId() ?? null, + getAgentSettings: () => this.deps.getModelAgentManager()?.getAgentSettings?.() ?? null, }; } diff --git a/src/ui/chat/services/ModelAgentConversationSettingsStore.ts b/src/ui/chat/services/ModelAgentConversationSettingsStore.ts index 95b228f2a..5255edde4 100644 --- a/src/ui/chat/services/ModelAgentConversationSettingsStore.ts +++ b/src/ui/chat/services/ModelAgentConversationSettingsStore.ts @@ -25,6 +25,10 @@ export interface ConversationSettingsMetadata { agentProvider?: string | null; agentModel?: string | null; agentThinking?: ThinkingSettings; + /** Subagent Creativity for this chat (0-1). */ + agentTemperature?: number; + /** Subagent context budget for this chat; null uses the subagent model's default. */ + agentContextWindow?: number | null; imageProvider?: 'google' | 'openrouter' | 'openai'; imageModel?: string; speechProvider?: DefaultSpeechModelSettings['provider'] | null; diff --git a/src/ui/chat/services/ModelAgentDefaultsResolver.ts b/src/ui/chat/services/ModelAgentDefaultsResolver.ts index 95b85bc8a..9275408ef 100644 --- a/src/ui/chat/services/ModelAgentDefaultsResolver.ts +++ b/src/ui/chat/services/ModelAgentDefaultsResolver.ts @@ -12,6 +12,8 @@ import { ModelSelectionUtility } from '../utils/ModelSelectionUtility'; import type { ModelOption, PromptOption } from '../types/SelectionTypes'; import type { ModelAgentWorkspaceContextService, ModelAgentWorkspaceState } from './ModelAgentWorkspaceContextService'; import { buildOpenAICompatibleModels, OPENAI_COMPATIBLE_CONTEXT_WINDOW } from '../../../services/llm/adapters/openai-compatible/OpenAICompatibleConfig'; +import { resolveAgentTemperature } from '../../../services/llm/utils/SubagentModelResolver'; +import { getContextWindowOverrideKey } from '../utils/ContextWindowSettings'; interface PluginWithSettings { settings?: { @@ -22,6 +24,8 @@ interface PluginWithSettings { defaultTemperature?: number; agentModel?: { provider: string; model: string }; agentThinking?: ThinkingSettings; + agentTemperature?: number; + agentContextWindowOverrides?: Record; defaultImageModel?: { provider: 'google' | 'openrouter' | 'openai'; model: string }; defaultTranscriptionModel?: { provider: string; model: string }; defaultSpeechModel?: DefaultSpeechModelSettings; @@ -44,6 +48,10 @@ export interface ModelAgentDefaultState { agentProvider: string | null; agentModel: string | null; agentThinkingSettings: ThinkingSettings; + /** Subagent Creativity a new chat starts with. */ + agentTemperature: number; + /** Subagent context budget a new chat starts with; null uses the model's default. */ + agentContextWindow: number | null; imageProvider: 'google' | 'openrouter' | 'openai'; imageModel: string; speechProvider: DefaultSpeechModelSettings['provider'] | null; @@ -113,6 +121,8 @@ export class ModelAgentDefaultsResolver { agentProvider: llmProviders?.agentModel?.provider || null, agentModel: llmProviders?.agentModel?.model || null, agentThinkingSettings, + agentTemperature: resolveAgentTemperature(null, llmProviders), + agentContextWindow: this.resolveAgentContextWindowDefault(llmProviders), imageProvider: llmProviders?.defaultImageModel?.provider || 'google', imageModel: llmProviders?.defaultImageModel?.model || 'gemini-3.1-flash-image', speechProvider: llmProviders?.defaultSpeechModel?.provider || null, @@ -184,6 +194,16 @@ export class ModelAgentDefaultsResolver { }; } + /** The saved subagent limit for the default subagent model, if any. */ + private resolveAgentContextWindowDefault( + llmProviders: { agentModel?: { provider: string; model: string }; agentContextWindowOverrides?: Record } | undefined + ): number | null { + const agentModel = llmProviders?.agentModel; + if (!agentModel?.provider || !agentModel.model) return null; + const value = llmProviders?.agentContextWindowOverrides?.[getContextWindowOverrideKey(agentModel.provider, agentModel.model)]; + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : null; + } + private getPlugin(): PluginWithSettings | null { if (this.deps.getPlugin) { return this.deps.getPlugin(); diff --git a/src/ui/chat/services/ModelAgentManager.ts b/src/ui/chat/services/ModelAgentManager.ts index 88706dc49..2f66b5592 100644 --- a/src/ui/chat/services/ModelAgentManager.ts +++ b/src/ui/chat/services/ModelAgentManager.ts @@ -49,6 +49,15 @@ import { } from '../../../services/chat/CompactionFrontierService'; import { ConversationData } from '../../../types/chat/ChatTypes'; import type { App, Plugin } from 'obsidian'; +import { + DEFAULT_AGENT_TEMPERATURE, + type ConversationAgentSettings +} from '../../../services/llm/utils/SubagentModelResolver'; + +/** A positive whole-token budget, or null for "use the model's default". */ +function normalizeAgentContextWindow(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : null; +} interface NexusPluginWithLLMSettings extends Plugin { settings?: { settings?: { llmProviders?: LLMProviderSettings } }; @@ -111,6 +120,10 @@ export class ModelAgentManager { private agentProvider: string | null = null; private agentModel: string | null = null; private agentThinkingSettings: ThinkingSettings = { enabled: false, effort: 'medium' }; + /** Subagent Creativity for this chat (0-1). */ + private agentTemperature: number = DEFAULT_AGENT_TEMPERATURE; + /** Subagent context budget for this chat; null uses the subagent model's default. */ + private agentContextWindow: number | null = null; private imageProvider: 'google' | 'openrouter' | 'openai' = 'google'; private imageModel = 'gemini-3.1-flash-image'; private speechProvider: DefaultSpeechModelSettings['provider'] | null = null; @@ -127,6 +140,8 @@ export class ModelAgentManager { private contextTokenTracker: ContextTokenTracker | null = null; // For token-limited models private compactionFrontier: CompactionFrontierRecord[] = []; // Active bounded compaction frontier private compactionFrontierService = new CompactionFrontierService(); + // Settings saved with no conversation open, held for the next new conversation. + private carriedSelections: { contextWindow: number | null } | null = null; constructor( private app: App, // Obsidian App @@ -186,10 +201,23 @@ export class ModelAgentManager { * Call this when no conversation exists (e.g., welcome state) */ async initializeDefaults(): Promise { + this.carriedSelections = null; this.clearCompactionFrontier(); await this.initializeDefaultModel(); } + /** + * Keep the current selections for the next new conversation. Used when chat + * settings are saved while no conversation is open: there is nothing to + * persist them to yet, so the first conversation created afterwards adopts + * and stores them instead of starting from the plugin defaults. + */ + carrySelectionsIntoNextConversation(): void { + this.carriedSelections = this.currentConversationId === null + ? { contextWindow: this.committedContextWindow } + : null; + } + /** * Set the current conversation ID used for session lookups and persistence. */ @@ -207,10 +235,21 @@ export class ModelAgentManager { * Initialize from conversation metadata (if available), otherwise use plugin default */ async initializeFromConversation(conversationId: string): Promise { + const carried = this.carriedSelections; + this.carriedSelections = null; try { const { conversationMetadata, chatSettings } = await this.conversationSettingsStore.load(conversationId); + if (carried && !this.hasSavedSelections(chatSettings)) { + // A brand-new conversation adopts the selections saved while none was open. + this.clearCompactionFrontier(); + this.restoreCompactionFrontierFromMetadata(conversationMetadata); + if (carried.contextWindow !== null) this.committedContextWindow = carried.contextWindow; + await this.saveToConversation(conversationId); + return; + } + this.clearCompactionFrontier(); await this.initializeDefaultModel(); this.restoreCompactionFrontierFromMetadata(conversationMetadata); @@ -225,6 +264,11 @@ export class ModelAgentManager { } } + /** True once chat settings were saved to the conversation, not just its creation metadata. */ + private hasSavedSelections(settings: ConversationSettingsMetadata | undefined): boolean { + return !!settings && ('temperature' in settings || 'thinking' in settings); + } + private hasStoredChatSettings(settings: ConversationSettingsMetadata | undefined): boolean { if (!settings) { return false; @@ -313,6 +357,14 @@ export class ModelAgentManager { }; } + // Restore subagent Creativity and context budget + if ('agentTemperature' in settings && typeof settings.agentTemperature === 'number' && Number.isFinite(settings.agentTemperature)) { + this.agentTemperature = Math.max(0, Math.min(1, settings.agentTemperature)); + } + if ('agentContextWindow' in settings) { + this.agentContextWindow = normalizeAgentContextWindow(settings.agentContextWindow); + } + // Restore image model if ('imageProvider' in settings && settings.imageProvider) { this.imageProvider = settings.imageProvider; @@ -395,6 +447,8 @@ export class ModelAgentManager { this.agentProvider = defaultState.agentProvider; this.agentModel = defaultState.agentModel; this.agentThinkingSettings = { ...defaultState.agentThinkingSettings }; + this.agentTemperature = defaultState.agentTemperature ?? DEFAULT_AGENT_TEMPERATURE; + this.agentContextWindow = normalizeAgentContextWindow(defaultState.agentContextWindow); this.imageProvider = defaultState.imageProvider; this.imageModel = defaultState.imageModel; this.speechProvider = defaultState.speechProvider; @@ -851,6 +905,36 @@ export class ModelAgentManager { this.agentThinkingSettings = { ...settings }; } + /** Subagent Creativity for this chat (0-1). */ + getAgentTemperature(): number { + return this.agentTemperature; + } + + setAgentTemperature(temperature: number): void { + if (!Number.isFinite(temperature)) return; + this.agentTemperature = Math.max(0, Math.min(1, temperature)); + } + + /** Subagent context budget for this chat; null uses the subagent model's default. */ + getAgentContextWindow(): number | null { + return this.agentContextWindow; + } + + setAgentContextWindow(contextWindow: number | null): void { + this.agentContextWindow = normalizeAgentContextWindow(contextWindow); + } + + /** Everything an agent run (subagent, executePrompt) takes from this chat. */ + getAgentSettings(): ConversationAgentSettings { + return { + provider: this.agentProvider, + model: this.agentModel, + thinking: { ...this.agentThinkingSettings }, + temperature: this.agentTemperature, + contextWindow: this.agentContextWindow + }; + } + /** * Get image provider */ @@ -1242,6 +1326,8 @@ export class ModelAgentManager { agentProvider: this.agentProvider, agentModel: this.agentModel, agentThinking: this.agentThinkingSettings, + agentTemperature: this.agentTemperature, + agentContextWindow: this.agentContextWindow, imageProvider: this.imageProvider, imageModel: this.imageModel, speechProvider: this.speechProvider, diff --git a/src/ui/chat/services/ModelAgentPromptContextAssembler.ts b/src/ui/chat/services/ModelAgentPromptContextAssembler.ts index 979e6b49a..3c1f4d643 100644 --- a/src/ui/chat/services/ModelAgentPromptContextAssembler.ts +++ b/src/ui/chat/services/ModelAgentPromptContextAssembler.ts @@ -5,6 +5,7 @@ import type { CompactedContext } from '../../../services/chat/ContextCompactionS import type { CompactionFrontierRecord } from '../../../services/chat/CompactionFrontierService'; import { shouldPassToolSchemasToProvider } from '../../../services/llm/utils/ToolSchemaSupport'; import type { ThinkingSettings } from '../../../types/llm/ProviderTypes'; +import { resolveThinkingForModel } from '../../../services/llm/utils/resolveThinkingForModel'; import type { MessageEnhancement } from '../components/suggesters/base/SuggesterInterfaces'; import type { ModelOption } from '../types/SelectionTypes'; import type { @@ -110,6 +111,13 @@ export class ModelAgentPromptContextAssembler { snapshot: ModelAgentPromptContextSnapshot ): Promise { const sessionId = await this.deps.getSessionId(); + // The stored preference survives a switch to a non-reasoning model; it is + // only the request that must not carry it. + const thinking = resolveThinkingForModel( + snapshot.selectedModel?.providerId, + snapshot.selectedModel?.modelId, + snapshot.thinkingSettings + ); return { provider: snapshot.selectedModel?.providerId, @@ -117,13 +125,13 @@ export class ModelAgentPromptContextAssembler { systemPrompt: await this.buildSystemPrompt(snapshot) || undefined, workspaceId: snapshot.selectedWorkspaceId || undefined, sessionId, - enableThinking: snapshot.thinkingSettings.enabled, + enableThinking: thinking.enabled, webSearch: snapshot.webSearch && ( ['anthropic', 'openai', 'openrouter'].includes(snapshot.selectedModel?.providerId || '') || (snapshot.selectedModel?.providerId === 'google' && snapshot.selectedModel.modelId.startsWith('gemini-3')) ), - thinkingEffort: snapshot.thinkingSettings.effort, + thinkingEffort: thinking.effort, temperature: snapshot.temperature, imageProvider: snapshot.imageProvider, imageModel: snapshot.imageModel, diff --git a/src/ui/readAloud/ReadAloudProgressModal.ts b/src/ui/readAloud/ReadAloudProgressModal.ts index 2eb895080..6829882b1 100644 --- a/src/ui/readAloud/ReadAloudProgressModal.ts +++ b/src/ui/readAloud/ReadAloudProgressModal.ts @@ -35,6 +35,7 @@ export class ReadAloudProgressModal extends Modal { onOpen(): void { this.modalEvents = new Component(); + this.modalEvents.load(); const { contentEl } = this; // Scope the FRAME (not just contentEl) so the modal renders at a sensible // centered width instead of full-screen-wide; the waveform fill rules below diff --git a/src/ui/readAloud/SavePromptModal.ts b/src/ui/readAloud/SavePromptModal.ts index da259cdf9..54558d20a 100644 --- a/src/ui/readAloud/SavePromptModal.ts +++ b/src/ui/readAloud/SavePromptModal.ts @@ -21,6 +21,7 @@ export class SavePromptModal extends Modal { onOpen(): void { this.modalEvents = new Component(); + this.modalEvents.load(); const { contentEl } = this; contentEl.addClass('read-aloud-save-prompt'); diff --git a/src/ui/tasks/NoteInputSuggester.ts b/src/ui/tasks/NoteInputSuggester.ts index 733f508ea..74689b542 100644 --- a/src/ui/tasks/NoteInputSuggester.ts +++ b/src/ui/tasks/NoteInputSuggester.ts @@ -1,25 +1,25 @@ import { App, AbstractInputSuggest, TFile, setIcon } from 'obsidian'; -import type { EmbeddingService } from '../../services/embeddings/EmbeddingService'; -import { searchNotes } from '../../utils/noteSearch'; +import { searchNotes, type EmbeddingServiceSource } from '../../utils/noteSearch'; export class NoteInputSuggester extends AbstractInputSuggest { - private embeddingService: EmbeddingService | null; + /** Resolved on every query; see EmbeddingServiceSource. */ + private embeddingSource: EmbeddingServiceSource; private onSelectCallback: (file: TFile) => void; constructor( app: App, inputEl: HTMLInputElement, - embeddingService: EmbeddingService | null, + embeddingSource: EmbeddingServiceSource, onSelect: (file: TFile) => void ) { super(app, inputEl); - this.embeddingService = embeddingService; + this.embeddingSource = embeddingSource; this.onSelectCallback = onSelect; this.limit = 10; } async getSuggestions(query: string): Promise { - return searchNotes(this.app, query, this.embeddingService, 10); + return searchNotes(this.app, query, this.embeddingSource, 10); } renderSuggestion(file: TFile, el: HTMLElement): void { diff --git a/src/ui/tasks/TaskBoardEditModal.ts b/src/ui/tasks/TaskBoardEditModal.ts index 9cd298db2..432630fb2 100644 --- a/src/ui/tasks/TaskBoardEditModal.ts +++ b/src/ui/tasks/TaskBoardEditModal.ts @@ -1,7 +1,7 @@ import { ButtonComponent, DropdownComponent, Modal, Notice, TextAreaComponent, TextComponent } from 'obsidian'; import type { App, TFile } from 'obsidian'; import type { NoteLink } from '../../database/repositories/interfaces/ITaskRepository'; -import type { EmbeddingService } from '../../services/embeddings/EmbeddingService'; +import type { EmbeddingServiceSource } from '../../utils/noteSearch'; import { NoteInputSuggester } from './NoteInputSuggester'; export interface TaskBoardProjectOption { @@ -34,7 +34,8 @@ interface TaskBoardEditModalOptions { task: TaskBoardEditableTask; projects: TaskBoardProjectOption[]; parentTasks: TaskBoardParentTaskOption[]; - embeddingService?: EmbeddingService; + /** Resolved on every note-link query, so turning embeddings on or off while the modal is open is picked up. */ + embeddingSource?: EmbeddingServiceSource; onSave: (task: TaskBoardEditableTask) => Promise; onClose?: () => void; } @@ -267,7 +268,7 @@ export class TaskBoardEditModal extends Modal { const suggester = new NoteInputSuggester( this.app, input.inputEl, - this.options.embeddingService ?? null, + this.options.embeddingSource, (file: TFile) => { this.draft.noteLinks[index] = { ...this.draft.noteLinks[index], notePath: file.path }; } diff --git a/src/ui/tasks/TaskBoardView.ts b/src/ui/tasks/TaskBoardView.ts index d6ee10d84..5ff769e80 100644 --- a/src/ui/tasks/TaskBoardView.ts +++ b/src/ui/tasks/TaskBoardView.ts @@ -21,6 +21,7 @@ import { TaskBoardFilterController } from './services/TaskBoardFilterController' import { TaskBoardGroupingService } from './services/TaskBoardGroupingService'; import { TaskBoardRenderer } from './services/TaskBoardRenderer'; import { TaskBoardSyncCoordinator } from './services/TaskBoardSyncCoordinator'; +import { resolveEmbeddingService } from '../../services/embeddings/embeddingAccess'; export class TaskBoardView extends ItemView { private dataController: TaskBoardDataController; @@ -58,7 +59,7 @@ export class TaskBoardView extends ItemView { getTaskService: () => this.dataController.getTaskService(), getProjects: () => this.projects, getTasks: () => this.tasks, - getEmbeddingService: () => this.plugin.getServiceIfReady('embeddingService') ?? undefined, + getEmbeddingService: () => resolveEmbeddingService(this.plugin) ?? undefined, reloadBoard: () => this.loadBoardData(), renderBoard: () => this.renderBoard(), onEditModalStateChange: (isOpen) => { diff --git a/src/ui/tasks/services/TaskBoardEditCoordinator.ts b/src/ui/tasks/services/TaskBoardEditCoordinator.ts index f569e0403..703fb2e38 100644 --- a/src/ui/tasks/services/TaskBoardEditCoordinator.ts +++ b/src/ui/tasks/services/TaskBoardEditCoordinator.ts @@ -36,7 +36,7 @@ export class TaskBoardEditCoordinator { task: this.buildEditableTask(task), projects: this.buildProjectOptions(task), parentTasks: this.buildParentTaskOptions(task), - embeddingService: this.deps.getEmbeddingService(), + embeddingSource: () => this.deps.getEmbeddingService(), onSave: async (updatedTask) => { await this.saveTaskChanges(task, updatedTask); }, diff --git a/src/utils/binaryDiscovery.ts b/src/utils/binaryDiscovery.ts index d7b4b05b8..5625fe650 100644 --- a/src/utils/binaryDiscovery.ts +++ b/src/utils/binaryDiscovery.ts @@ -44,7 +44,7 @@ function loadDesktopModule( throw new Error(`${moduleName} is only available on desktop.`); } - const maybeRequire = (window.activeWindow as Window & { + const maybeRequire = (window as Window & { require?: (moduleId: string) => unknown; }).require; diff --git a/src/utils/cliProcessRunner.ts b/src/utils/cliProcessRunner.ts index d892da752..f296d1ef2 100644 --- a/src/utils/cliProcessRunner.ts +++ b/src/utils/cliProcessRunner.ts @@ -18,7 +18,7 @@ function loadDesktopModule unknown; }).require; diff --git a/src/utils/codexMcpConfig.ts b/src/utils/codexMcpConfig.ts index f939bb2dd..4ecb13c8d 100644 --- a/src/utils/codexMcpConfig.ts +++ b/src/utils/codexMcpConfig.ts @@ -33,6 +33,146 @@ export function appendCodexMcpTomlSnippet(content: string, snippet: string): str return `${content}${separator}${trimmedSnippet}\n`; } +/** The command and args of a Nexus `[mcp_servers.]` table, or null when absent. */ +export function readCodexMcpServerConfig(content: string, serverKey: string): { command: string; args: string[] } | null { + const block = findCodexMcpServerBlock(content, serverKey); + if (!block) { + return null; + } + + const body = content.slice(block.start, block.end); + const command = /^\s*command\s*=\s*"((?:[^"\\]|\\.)*)"/m.exec(body); + const argsLine = /^\s*args\s*=\s*\[(.*)\]/m.exec(body); + const args = argsLine + ? Array.from(argsLine[1].matchAll(/"((?:[^"\\]|\\.)*)"/g), match => unescapeTomlBasicString(match[1])) + : []; + + return { command: command ? unescapeTomlBasicString(command[1]) : '', args }; +} + +/** + * Point the Nexus `[mcp_servers.]` table at `command` and `args`, or + * append a fresh table when there is none. + * + * Only the `command` and `args` values change. Every other key in the table + * (env, startup_timeout_sec, tool_timeout_sec, enabled, enabled_tools...) and + * every comment stay exactly as the user wrote them. + */ +export function upsertCodexMcpServerConfig(content: string, serverKey: string, command: string, args: string[]): string { + const block = findCodexMcpServerBlock(content, serverKey); + if (!block) { + return appendCodexMcpTomlSnippet(content, buildCodexMcpTomlSnippet(serverKey, command, args)); + } + + const argsValue = `[${args.map((arg) => `"${escapeTomlBasicString(arg)}"`).join(', ')}]`; + let body = content.slice(block.start, block.end); + // args first, so a missing command is inserted above it. + body = setTomlValue(body, 'args', argsValue); + body = setTomlValue(body, 'command', `"${escapeTomlBasicString(command)}"`); + return `${content.slice(0, block.start)}${body}${content.slice(block.end)}`; +} + +function findCodexMcpServerBlock(content: string, serverKey: string): { start: number; end: number } | null { + const tablePattern = new RegExp( + `^\\s*\\[\\s*mcp_servers\\s*\\.\\s*(?:${escapeRegExp(serverKey)}|"${escapeRegExp(escapeTomlBasicString(serverKey))}")\\s*\\]\\s*$`, + 'm' + ); + const header = tablePattern.exec(content); + if (!header) { + return null; + } + + // The pattern's trailing \s* can run onto the next line; the body starts + // right after the header's closing bracket. + const start = header.index + header[0].trimEnd().length; + const nextTable = /^\s*\[/m.exec(content.slice(start)); + const end = nextTable ? start + nextTable.index : content.length; + return { start, end }; +} + +/** + * Replace the value of `key = ...` in a table body, keeping its indentation + * and any trailing comment, or insert the line right after the header. + */ +function setTomlValue(body: string, key: string, value: string): string { + const line = new RegExp(`^([ \\t]*)${escapeRegExp(key)}[ \\t]*=[ \\t]*`, 'm').exec(body); + if (!line) { + return `\n${key} = ${value}${body}`; + } + const valueStart = line.index + line[0].length; + const valueEnd = tomlValueEnd(body, valueStart); + return `${body.slice(0, valueStart)}${value}${body.slice(valueEnd)}`; +} + +/** + * Where a TOML value that starts at `from` ends: past the closing bracket of + * an array or inline table (which may span lines), otherwise before the end of + * the line or a trailing comment. Strings are skipped so brackets, quotes and + * `#` inside them do not count. + */ +function tomlValueEnd(text: string, from: number): number { + let depth = 0; + let i = from; + while (i < text.length) { + const ch = text[i]; + if (ch === '"' || ch === "'") { + i = skipTomlString(text, i); + continue; + } + if (ch === '#') { + if (depth === 0) { + return trimEndIndex(text, from, i); + } + const newline = text.indexOf('\n', i); + i = newline === -1 ? text.length : newline; + continue; + } + if (ch === '[' || ch === '{') { + depth++; + } else if (ch === ']' || ch === '}') { + depth--; + if (depth <= 0) { + return i + 1; + } + } else if (ch === '\n' && depth === 0) { + return trimEndIndex(text, from, i); + } + i++; + } + return trimEndIndex(text, from, text.length); +} + +/** Index just past a single-line basic ("...") or literal ('...') string that opens at `at`. */ +function skipTomlString(text: string, at: number): number { + const quote = text[at]; + let i = at + 1; + while (i < text.length && text[i] !== quote && text[i] !== '\n') { + i += quote === '"' && text[i] === '\\' ? 2 : 1; + } + return i + 1; +} + +function trimEndIndex(text: string, from: number, end: number): number { + let i = end; + while (i > from && (text[i - 1] === ' ' || text[i - 1] === '\t' || text[i - 1] === '\r')) { + i--; + } + return i; +} + +function unescapeTomlBasicString(value: string): string { + return value.replace(/\\(["\\btnfr])/g, (_match, ch: string) => { + switch (ch) { + case 'b': return BACKSPACE; + case 't': return '\t'; + case 'n': return '\n'; + case 'f': return '\f'; + case 'r': return '\r'; + default: return ch; + } + }); +} + const BACKSPACE = String.fromCharCode(8); function formatTomlKey(key: string): string { diff --git a/src/utils/desktopRequire.ts b/src/utils/desktopRequire.ts index 03a11cb93..84fa2dcde 100644 --- a/src/utils/desktopRequire.ts +++ b/src/utils/desktopRequire.ts @@ -2,8 +2,15 @@ * src/utils/desktopRequire.ts * * Lazy desktop module loader that bypasses ESLint no-require-imports rule. - * Uses window.activeWindow.require (available in Electron/Node but not on mobile). + * Uses the main window's require (available in Electron/Node but not on mobile). * Used by any file that needs Node.js built-ins at runtime on desktop only. + * + * `window`, never `window.activeWindow`: the plugin runs in the main window, + * which is the one window guaranteed to have Node's require. activeWindow is + * whichever window has focus, a popout or the Settings window (its own window + * since Obsidian 1.14), and a module loaded through it, such as the http server + * behind an OAuth callback, would belong to a window that can close under it. + * The desktop module loaders elsewhere in src/ follow the same rule. */ /** @@ -13,7 +20,7 @@ * @param moduleName - The module to require (e.g., 'node:fs', 'node:http') */ export function desktopRequire(moduleName: string): T { - const maybeRequire = (window.activeWindow as Window & { + const maybeRequire = (window as Window & { require?: (moduleId: string) => unknown; }).require; diff --git a/src/utils/geminiCli.ts b/src/utils/geminiCli.ts index 1714774e8..ded1a0d9e 100644 --- a/src/utils/geminiCli.ts +++ b/src/utils/geminiCli.ts @@ -19,7 +19,7 @@ function loadDesktopModule( throw new Error(`${moduleName} is only available on desktop.`); } - const maybeRequire = (window.activeWindow as Window & { + const maybeRequire = (window as Window & { require?: (moduleId: string) => unknown; }).require; diff --git a/src/utils/mcpClientConfigs.ts b/src/utils/mcpClientConfigs.ts new file mode 100644 index 000000000..ddc5a8e18 --- /dev/null +++ b/src/utils/mcpClientConfigs.ts @@ -0,0 +1,331 @@ +/** + * Config file locations and read/write helpers for the MCP clients Nexus can + * connect in one click: Claude Desktop and Antigravity (JSON `mcpServers` + * entries) and Codex (a TOML table). + * + * Desktop only. Node built-ins are loaded lazily through desktopRequire, so + * importing this module never touches them on mobile. + */ +import { Platform } from 'obsidian'; +import { desktopRequire } from './desktopRequire'; +import { CONNECTOR_JS_CONTENT } from './connectorContent'; +import { + readCodexMcpServerConfig, + upsertCodexMcpServerConfig +} from './codexMcpConfig'; + +type FsModule = typeof import('node:fs'); +type PathModule = typeof import('node:path'); + +/** + * Antigravity's global MCP config, relative to the user's home folder. Change + * it here and every reader and writer follows. + */ +export const ANTIGRAVITY_CONFIG_SEGMENTS = ['.gemini', 'config', 'mcp_config.json'] as const; + +/** Where older Antigravity builds kept the same file. Read for status only, never written. */ +export const ANTIGRAVITY_LEGACY_CONFIG_SEGMENTS = ['.gemini', 'antigravity', 'mcp_config.json'] as const; + +export const CONNECTOR_FILE_NAME = 'connector.js'; + +export interface McpServerEntry { + command: string; + args: string[]; +} + +export type McpConfigState = + | 'no-config-dir' + | 'no-config-file' + | 'invalid-config' + | 'config-exists' + | 'nexus-configured'; + +export interface McpConfigStatus { + state: McpConfigState; + /** Configured, but the saved node or connector path no longer exists (for example after an nvm switch). */ + stale: boolean; +} + +export interface WriteResult { + configPath: string; + /** The previous file was not valid JSON; it was copied to `backupPath` and replaced. */ + replacedInvalid: boolean; + /** Where the unreadable previous file was saved, when `replacedInvalid`. */ + backupPath?: string; +} + +function fs(): FsModule { + return desktopRequire('node:fs'); +} + +function pathMod(): PathModule { + return desktopRequire('node:path'); +} + +export function getUserHome(): string { + if (Platform.isWin) { + return process.env.USERPROFILE || process.env.HOME || ''; + } + return process.env.HOME || ''; +} + +export function getAntigravityConfigPath(): string { + return pathMod().join(getUserHome(), ...ANTIGRAVITY_CONFIG_SEGMENTS); +} + +export function getAntigravityLegacyConfigPath(): string { + return pathMod().join(getUserHome(), ...ANTIGRAVITY_LEGACY_CONFIG_SEGMENTS); +} + +/** + * Claude Code keeps user-scope MCP servers in `~/.claude.json` (top-level + * `mcpServers`). Nexus only reads that file; writes go through the `claude` + * CLI because Claude Code rewrites the file itself while it runs. + */ +export function getClaudeCodeConfigPath(): string { + return pathMod().join(getUserHome(), '.claude.json'); +} + +type ChildProcessModule = typeof import('node:child_process'); + +/** + * Add or replace the Nexus server in Claude Code's user scope with + * `claude mcp add-json --scope user`. An entry under any of the vault's keys + * is removed first so a legacy key does not leave a second, broken server. + */ +export function writeClaudeCodeServer(claudeBin: string, serverKeys: string[], entry: McpServerEntry): WriteResult { + const configPath = getClaudeCodeConfigPath(); + const key = serverKeys[0]; + if (!key) { + throw new Error('No server name was given.'); + } + const { execFileSync } = desktopRequire('node:child_process'); + const run = (args: string[]): void => { + execFileSync(claudeBin, args, { encoding: 'utf-8', timeout: 20000, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); + }; + const existing = getMcpJsonStatus(configPath, serverKeys); + if (existing.state === 'nexus-configured') { + for (const candidate of serverKeys) { + try { run(['mcp', 'remove', candidate, '--scope', 'user']); } catch { /* not registered under this key */ } + } + } + run(['mcp', 'add-json', key, JSON.stringify({ type: 'stdio', command: entry.command, args: entry.args }), '--scope', 'user']); + return { configPath, replacedInvalid: false }; +} + +export function getCodexConfigPath(): string { + const path = pathMod(); + const codexHome = process.env.CODEX_HOME || path.join(getUserHome(), '.codex'); + return path.normalize(path.join(codexHome, 'config.toml')); +} + +export function getConnectorPath(pluginPath: string): string { + if (!pluginPath) { + throw new Error('Plugin path is unavailable.'); + } + const path = pathMod(); + return path.normalize(path.join(pluginPath, CONNECTOR_FILE_NAME)); +} + +/** The connector file exists and matches this build. */ +export function isConnectorCurrent(pluginPath: string): boolean { + try { + const connectorPath = getConnectorPath(pluginPath); + const nodeFs = fs(); + return nodeFs.existsSync(connectorPath) && nodeFs.readFileSync(connectorPath, 'utf-8') === CONNECTOR_JS_CONTENT; + } catch { + return false; + } +} + +/** Write connector.js into the plugin folder when it is missing or out of date. Returns its path. */ +export function ensureConnectorFile(pluginPath: string): string { + const connectorPath = getConnectorPath(pluginPath); + const nodeFs = fs(); + const pluginDir = pathMod().dirname(connectorPath); + + if (!nodeFs.existsSync(pluginDir)) { + nodeFs.mkdirSync(pluginDir, { recursive: true }); + } + + if (nodeFs.existsSync(connectorPath) && nodeFs.readFileSync(connectorPath, 'utf-8') === CONNECTOR_JS_CONTENT) { + return connectorPath; + } + + nodeFs.writeFileSync(connectorPath, CONNECTOR_JS_CONTENT, 'utf-8'); + return connectorPath; +} + +/** An `mcpServers` entry. A node path is required: a bare `node` fails in apps opened from the Dock. */ +export function buildMcpServerEntry(nodePath: string, connectorPath: string): McpServerEntry { + if (!nodePath.trim()) { + throw new Error('Node.js was not found, so the config cannot name it.'); + } + return { command: nodePath, args: [connectorPath] }; +} + +/** The copyable config for any MCP client that uses `mcpServers {command, args}`. */ +export function buildMcpConfigJson(serverKey: string, nodePath: string, connectorPath: string): string { + return JSON.stringify({ mcpServers: { [serverKey]: buildMcpServerEntry(nodePath, connectorPath) } }, null, 2); +} + +/** Status of a JSON `mcpServers` config (Claude Desktop, Antigravity) for any of the vault's server keys. */ +export function getMcpJsonStatus(configPath: string, serverKeys: string[]): McpConfigStatus { + const nodeFs = fs(); + const configDir = pathMod().dirname(configPath); + + if (!nodeFs.existsSync(configDir)) { + return { state: 'no-config-dir', stale: false }; + } + if (!nodeFs.existsSync(configPath)) { + return { state: 'no-config-file', stale: false }; + } + + let parsed: unknown; + try { + const content = stripBom(nodeFs.readFileSync(configPath, 'utf-8')); + if (!content.trim()) { + return { state: 'invalid-config', stale: false }; + } + parsed = JSON.parse(content) as unknown; + } catch { + return { state: 'invalid-config', stale: false }; + } + + const servers = isRecord(parsed) && isRecord(parsed.mcpServers) ? parsed.mcpServers : null; + const key = servers ? serverKeys.find(candidate => Boolean(servers[candidate])) : undefined; + if (!servers || !key) { + return { state: 'config-exists', stale: false }; + } + + const entry = servers[key]; + const command = isRecord(entry) && typeof entry.command === 'string' ? entry.command : ''; + const args = isRecord(entry) && Array.isArray(entry.args) ? entry.args.filter((arg): arg is string => typeof arg === 'string') : []; + return { state: 'nexus-configured', stale: hasMissingPath(command, args) }; +} + +/** + * Add or update the Nexus entry in a JSON `mcpServers` config, keeping every + * other server. + * + * `serverKeys` lists the vault's keys, current one first. An existing entry + * under any of them is updated in place (only `command` and `args` change, so + * `env` and other fields stay), which also repairs an entry left under a + * legacy key instead of adding a second server beside it. + */ +export function writeMcpJsonServer(configPath: string, serverKeys: string[], entry: McpServerEntry): WriteResult { + const nodeFs = fs(); + let config: Record = {}; + let replacedInvalid = false; + let backupPath: string | undefined; + + if (nodeFs.existsSync(configPath)) { + const raw = nodeFs.readFileSync(configPath, 'utf-8'); + const content = stripBom(raw); + if (content.trim()) { + try { + const parsed = JSON.parse(content) as unknown; + if (isRecord(parsed)) { + config = parsed; + } else { + throw new Error('not an object'); + } + } catch { + backupPath = uniqueBackupPath(configPath); + nodeFs.writeFileSync(backupPath, raw, 'utf-8'); + replacedInvalid = true; + config = {}; + } + } + } + + const servers = isRecord(config.mcpServers) ? config.mcpServers : {}; + const key = serverKeys.find(candidate => isRecord(servers[candidate])) ?? serverKeys[0]; + if (!key) { + throw new Error('No server name was given.'); + } + const existing = isRecord(servers[key]) ? servers[key] : {}; + config.mcpServers = { ...servers, [key]: { ...existing, command: entry.command, args: entry.args } }; + + const configDir = pathMod().dirname(configPath); + if (!nodeFs.existsSync(configDir)) { + nodeFs.mkdirSync(configDir, { recursive: true }); + } + nodeFs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + return { configPath, replacedInvalid, backupPath }; +} + +/** Status of the Codex `config.toml` for any of the vault's server keys. */ +export function getCodexStatus(serverKeys: string[]): McpConfigStatus { + const nodeFs = fs(); + const configPath = getCodexConfigPath(); + if (!nodeFs.existsSync(configPath)) { + return { state: 'no-config-file', stale: false }; + } + + let content: string; + try { + content = nodeFs.readFileSync(configPath, 'utf-8'); + } catch { + return { state: 'invalid-config', stale: false }; + } + + for (const key of serverKeys) { + const server = readCodexMcpServerConfig(content, key); + if (server) { + return { state: 'nexus-configured', stale: hasMissingPath(server.command, server.args) }; + } + } + return { state: 'config-exists', stale: false }; +} + +/** + * Add the Nexus table to Codex's config.toml, or point an existing one (under + * any of `serverKeys`, current one first) at the new command and args. Other + * keys in that table, such as env and timeouts, are kept. + */ +export function writeCodexServer(serverKeys: string[], entry: McpServerEntry): WriteResult { + const nodeFs = fs(); + const configPath = getCodexConfigPath(); + const configDir = pathMod().dirname(configPath); + + if (!nodeFs.existsSync(configDir)) { + nodeFs.mkdirSync(configDir, { recursive: true }); + } + + const existing = nodeFs.existsSync(configPath) ? nodeFs.readFileSync(configPath, 'utf-8') : ''; + const key = serverKeys.find(candidate => readCodexMcpServerConfig(existing, candidate) !== null) ?? serverKeys[0]; + if (!key) { + throw new Error('No server name was given.'); + } + nodeFs.writeFileSync(configPath, upsertCodexMcpServerConfig(existing, key, entry.command, entry.args), 'utf-8'); + return { configPath, replacedInvalid: false }; +} + +/** An absolute command or connector path that no longer exists on disk. */ +function hasMissingPath(command: string, args: string[]): boolean { + const nodeFs = fs(); + const path = pathMod(); + return [command, args[0] ?? ''] + .filter(candidate => candidate && path.isAbsolute(candidate)) + .some(candidate => !nodeFs.existsSync(candidate)); +} + +/** Windows editors often save JSON with a byte order mark, which JSON.parse rejects. */ +function stripBom(content: string): string { + return content.charCodeAt(0) === 0xFEFF ? content.slice(1) : content; +} + +/** `.bak`, or `..bak` when earlier backups exist, so none is overwritten. */ +function uniqueBackupPath(configPath: string): string { + const nodeFs = fs(); + let candidate = `${configPath}.bak`; + for (let n = 1; nodeFs.existsSync(candidate); n++) { + candidate = `${configPath}.${n}.bak`; + } + return candidate; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/utils/nodeRuntime.ts b/src/utils/nodeRuntime.ts new file mode 100644 index 000000000..bd6632e7c --- /dev/null +++ b/src/utils/nodeRuntime.ts @@ -0,0 +1,68 @@ +/** + * Finds the Node.js binary that external agents will run the connector with. + * + * Desktop only. Node built-ins are loaded lazily through desktopRequire, so + * importing this module never touches them on mobile. + */ +import { Platform } from 'obsidian'; +import { desktopRequire } from './desktopRequire'; +import { resolveDesktopBinaryPath } from './binaryDiscovery'; + +export interface NodeRuntime { + /** Absolute path to the node binary. */ + path: string; + /** `v24.11.0`, or an empty string when `--version` could not be read. */ + version: string; + /** nvm paths change when the user switches versions, which breaks saved configs. */ + manager: 'nvm' | 'other'; +} + +const VERSION_TIMEOUT_MS = 3000; + +export function resolveNodeRuntime(): NodeRuntime | null { + if (!Platform.isDesktop) { + return null; + } + + const path = resolveDesktopBinaryPath('node'); + if (!path) { + return null; + } + + return { + path, + version: readNodeVersion(path), + manager: detectNodeManager(path) + }; +} + +/** `v24.11.0` from `node --version` output, or '' when it is not a version. */ +export function parseNodeVersion(output: string): string { + const match = /^v\d+\.\d+\.\d+\S*/.exec(output.trim()); + return match ? match[0] : ''; +} + +export function detectNodeManager(path: string): 'nvm' | 'other' { + if (path.includes('/.nvm/versions/node/')) { + return 'nvm'; + } + // nvm-windows keeps versions under %APPDATA%\nvm\ (or %NVM_HOME%). + if (/\\nvm\\/i.test(path)) { + return 'nvm'; + } + return 'other'; +} + +function readNodeVersion(path: string): string { + try { + const childProcess = desktopRequire('node:child_process'); + const output = childProcess.execFileSync(path, ['--version'], { + encoding: 'utf8', + timeout: VERSION_TIMEOUT_MS, + windowsHide: true + }); + return parseNodeVersion(String(output)); + } catch { + return ''; + } +} diff --git a/src/utils/noteSearch.ts b/src/utils/noteSearch.ts index cc1fe07ee..184cdd33c 100644 --- a/src/utils/noteSearch.ts +++ b/src/utils/noteSearch.ts @@ -1,6 +1,28 @@ import { App, TFile, prepareFuzzySearch } from 'obsidian'; import type { EmbeddingService } from '../services/embeddings/EmbeddingService'; +/** + * Where `searchNotes` gets its embedding service: an instance, nothing, or a + * resolver called on every query. Long-lived callers should pass a resolver, + * because the embedding manager can be turned off or rebuilt during a session. + */ +export type EmbeddingServiceSource = + | EmbeddingService + | null + | undefined + | (() => EmbeddingService | null | undefined); + +function resolveSource(source: EmbeddingServiceSource): EmbeddingService | null { + if (typeof source === 'function') { + try { + return source() ?? null; + } catch { + return null; + } + } + return source ?? null; +} + /** * Search notes by semantic similarity (with fuzzy fallback). * Primary: EmbeddingService.semanticSearch if enabled. @@ -10,7 +32,7 @@ import type { EmbeddingService } from '../services/embeddings/EmbeddingService'; export async function searchNotes( app: App, query: string, - embeddingService: EmbeddingService | null | undefined, + embeddingSource: EmbeddingServiceSource, limit = 10 ): Promise { const trimmed = query.trim(); @@ -21,6 +43,7 @@ export async function searchNotes( .slice(0, limit); } + const embeddingService = resolveSource(embeddingSource); if (embeddingService?.isServiceEnabled()) { try { const results = await embeddingService.semanticSearch(trimmed, limit); diff --git a/src/utils/platform.ts b/src/utils/platform.ts index a4c8d182e..39c91f41b 100644 --- a/src/utils/platform.ts +++ b/src/utils/platform.ts @@ -156,7 +156,7 @@ export const getMobileUnavailableFeatures = (): string[] => { 'WebLLM/Nexus local models', 'ChatGPT Codex OAuth provider', 'Claude Code subscription provider', - 'Gemini CLI Google-login provider', + 'Antigravity CLI Google-login provider', ]; }; diff --git a/styles.css b/styles.css index 67ed0f73c..ca959f89c 100644 --- a/styles.css +++ b/styles.css @@ -1751,1532 +1751,1467 @@ body.theme-light .message-content { background: transparent; } -/* =========================== */ -/* ORIGINAL ACCORDION STYLES */ -/* =========================== */ +/* =========================================== + Settings: shared accordion + One fold component for the Defaults tab, the Chat settings modal and + Get started (docs/plans/defaults-settings-redesign-plan.md, Phase 1). + Closed: a quiet row with icon, title and a one-line summary. + Open: one lighter card holding the header and its settings. + =========================================== */ -/* Accordion Styles */ -.mcp-accordion { - margin-bottom: 1rem; - border: 1px solid var(--background-modifier-border); - border-radius: 4px; - background-color: var(--background-primary); +.nexus-accordion { + max-width: var(--setting-group-max-width); + margin: var(--space-m) auto 0; + border-radius: var(--setting-items-radius); } -.mcp-accordion-container { +.nexus-accordion.is-open { + background-color: var(--setting-items-background); +} + +.nexus-accordion-toggle { display: flex; - flex-direction: column; + align-items: center; + gap: var(--space-m); width: 100%; + height: auto; + padding: var(--space-m) var(--setting-items-padding-x); + border: 0; + border-radius: var(--setting-items-radius); + background: transparent; + box-shadow: none; + color: var(--text-normal); + font: inherit; + text-align: left; + cursor: pointer; } -.mcp-accordion-header { - order: 1; /* Header first */ - border-bottom: 1px solid var(--background-modifier-border); - background-color: var(--background-secondary); +.nexus-accordion:not(.is-open) .nexus-accordion-toggle:hover { + background-color: var(--background-modifier-hover); } -.mcp-accordion-content { - order: 2; /* Content second */ - max-height: 0; - overflow: hidden; - transition: max-height 0.3s ease-out; +.nexus-accordion-toggle:focus-visible { + box-shadow: 0 0 0 2px var(--background-modifier-border-focus); } -/* Handle border when accordion is open */ -.mcp-accordion-content.is-open { - border-top: 1px solid var(--background-modifier-border); - max-height: 70vh; /* Use viewport height for better responsive behavior */ - overflow-y: auto; /* Enable vertical scrolling when content exceeds height */ - padding: 15px; +.nexus-accordion-icon { + display: inline-flex; + flex: 0 0 auto; + color: var(--text-accent); } -.mcp-accordion-content.is-open ~ .mcp-accordion-header { - border-bottom: none; +.nexus-accordion-text { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 2px; + min-width: 0; } -.mcp-accordion-toggle { - width: 100%; - padding: 1rem; - background: none; - border: none; - cursor: pointer; - display: flex; - align-items: center; - justify-content: space-between; - color: var(--text-normal); - text-align: left; +.nexus-accordion-title { + font-size: var(--setting-group-heading-size); + font-weight: var(--font-semibold); } -.mcp-accordion-toggle:hover { - background-color: var(--background-modifier-hover); +.nexus-accordion-summary { + overflow: hidden; + color: var(--text-muted); + font-size: var(--font-ui-smaller); + text-overflow: ellipsis; + white-space: nowrap; } -.mcp-accordion-title { - font-size: 1.1em; - font-weight: 500; - flex: 1; - text-align: left; +.nexus-accordion-chevron { + display: inline-flex; + flex: 0 0 auto; + color: var(--text-faint); } -.mcp-accordion-icon { - width: 24px; - height: 24px; - position: relative; +.nexus-accordion-content.setting-group { + max-width: none; + margin: 0; } -.mcp-accordion-icon::before, -.mcp-accordion-icon::after { - content: ''; - position: absolute; - background-color: var(--text-normal); - transition: transform 0.25s ease-out; +.nexus-accordion-content .setting-items { + padding-bottom: var(--space-xs); + border-radius: 0 0 var(--setting-items-radius) var(--setting-items-radius); + background-color: transparent; } -.mcp-accordion-icon::before { - top: 11px; - left: 6px; - width: 12px; - height: 2px; +/* Inside an open fold the header sits above the first row, so it gets a divider too. */ +.nexus-accordion-content .setting-items > .setting-item:not(.setting-item-heading):first-child::before { + content: ''; } -.mcp-accordion-icon::after { - top: 6px; - left: 11px; - width: 2px; - height: 12px; +/* The top block (Provider, Model, Thinking) sits a little further from the first fold than folds sit from each other. */ +.nexus-settings-folds > .setting-group + .nexus-accordion { + margin-top: var(--space-l); } -.mcp-accordion-icon.is-open::after { - transform: rotate(90deg); +.modal .nexus-accordion { + margin-top: var(--space-s); } -.mcp-accordion-content > div { - padding: 1rem; +.modal .nexus-settings-folds > .setting-group + .nexus-accordion { + margin-top: var(--space-m); } -/* Mobile optimizations */ -@media (max-width: 768px) { - .mcp-accordion-toggle { - padding: 0.8rem; - } +/* Subheads inside a fold (Voice: Transcription, Read aloud, Live voice). */ +.setting-group .setting-item.nexus-subhead:not(.setting-item-heading) { + padding-bottom: 0; +} - .mcp-accordion-title { - font-size: 1em; - } +.nexus-subhead .setting-item-name { + color: var(--text-faint); + font-size: var(--font-ui-smaller); + font-weight: var(--font-semibold); + letter-spacing: 0.05em; + text-transform: uppercase; } -/* Setup Prompt Styles */ -.mcp-section { - margin-bottom: 1.5rem; +.setting-group .setting-items > .nexus-subhead + .setting-item::before { + content: none; } -.mcp-section pre { - background-color: var(--background-secondary); - padding: 12px; - border-radius: 4px; - margin: 10px 0; - overflow-x: auto; - max-height: 400px; - overflow-y: auto; +/* A note row that explains its fold (Subagents: "Used for"). */ +.setting-group .setting-item.nexus-note:not(.setting-item-heading) { + padding-bottom: 0; } -.mcp-section code { - font-family: var(--font-monospace); - font-size: 0.9em; +.nexus-note .setting-item-name { + color: var(--text-muted); + font-size: var(--font-ui-small); } -.mcp-section button.mod-cta { - margin-top: 8px; - margin-bottom: 16px; - padding: 6px 12px; - background-color: var(--interactive-accent); - color: var(--text-on-accent); - border: none; - border-radius: 4px; - cursor: pointer; - transition: background-color 0.2s ease; +.setting-group .setting-items > .nexus-note + .setting-item::before { + content: none; } -.mcp-section button.mod-cta:hover { - background-color: var(--interactive-accent-hover); +/* Context notes listed under the "Context notes" row. */ +.setting-group .setting-item.nexus-note-row:not(.setting-item-heading) { + padding-top: 6px; + padding-bottom: 6px; } -/* =============================== */ -/* EXPERIMENTAL WARNING STYLES */ -/* =============================== */ +.setting-group .setting-items > .setting-item.nexus-note-row::before { + content: none; +} -/* ChatView Experimental Warning Banner */ -.chat-experimental-warning { - background: var(--background-modifier-error); - border: 1px solid var(--background-modifier-error-border, var(--background-modifier-border)); - border-radius: 6px; - padding: 8px 12px; - margin-bottom: 12px; +.nexus-note-row .setting-item-name { display: flex; align-items: center; - gap: 8px; - font-size: 0.9em; - flex-wrap: wrap; + gap: var(--space-s); + min-width: 0; + padding-left: var(--space-m); + color: var(--text-muted); + font-size: var(--font-ui-small); } -.chat-experimental-warning .warning-icon { - font-size: 1.1em; - color: var(--text-warning); +.nexus-note-icon { + display: inline-flex; + flex: 0 0 auto; + color: var(--text-faint); } -.chat-experimental-warning .warning-text { - color: var(--text-on-accent); +.nexus-note-icon svg { + width: 14px; + height: 14px; } -.chat-experimental-warning .warning-link { - color: var(--text-accent); - text-decoration: underline; - font-weight: 500; +/* A slider readout Nexus adds itself only on Obsidian builds without a native one. */ +.nexus-slider-value { + color: var(--text-muted); + font-size: var(--font-ui-small); + min-width: 30px; + text-align: right; } -.chat-experimental-warning .warning-link:hover { - color: var(--text-accent-hover); +.nexus-ctx-value { + min-width: 40px; } -/* Settings Tab Experimental Warning Styles */ -.experimental-warning-container { - background: var(--background-modifier-error); - border: 1px solid var(--background-modifier-error-border, var(--background-modifier-border)); - border-radius: 8px; - padding: 16px; - margin-bottom: 20px; +/* Semantic search fold: index counts and indexing progress (plan Phase 4). */ +.nexus-semantic-value { + color: var(--text-muted); + font-size: var(--font-ui-small); + font-variant-numeric: tabular-nums; + white-space: nowrap; } -.experimental-warning-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 12px; +.setting-item.nexus-semantic-progress .setting-progress-bar { + width: 140px; } -.experimental-warning-icon { - font-size: 1.2em; - color: var(--text-warning); +body.is-mobile .setting-item.nexus-semantic-progress .setting-progress-bar { + width: 70px; } -.experimental-warning-title { - margin: 0; - color: var(--text-on-accent); - font-size: 1.1em; - font-weight: 600; +.nexus-semantic-off-note { + color: var(--text-muted); } -.experimental-warning-message { - color: var(--text-on-accent); - line-height: 1.4; +/* The embedding model did not load: the row's name reads as an error. */ +.setting-item.nexus-semantic-error .setting-item-name { + color: var(--text-error); } -.experimental-warning-message p { - margin: 0 0 8px 0; +body.is-mobile .nexus-accordion-toggle { + min-height: 44px; } -.experimental-warning-message a { - color: var(--text-accent); - text-decoration: underline; - font-weight: 500; +/* =========================================== + Settings: Get started pages + The Get started row above the tab strip and the in-tab pages it opens + (docs/plans/defaults-settings-redesign-plan.md, Phase 3). + =========================================== */ + +.nexus-get-started { + margin-bottom: var(--space-l); } -.experimental-warning-message a:hover { - color: var(--text-accent-hover); +.nexus-row-icon { + display: inline-flex; + vertical-align: middle; + margin-right: var(--space-xs); + color: var(--text-accent); } -/* ChatView Settings Section */ -.chatview-controls-section { - margin-top: 20px; - padding-top: 16px; - border-top: 1px solid var(--background-modifier-border); +.nexus-row-icon svg { + width: 16px; + height: 16px; } -.chatview-controls-section h4 { - margin: 0 0 16px 0; - color: var(--text-normal); - font-size: 1em; - font-weight: 600; +.setting-item.mod-navigable, +.setting-item.nexus-other-row { + cursor: pointer; } -.acknowledgment-display { - margin-top: 12px; - padding: 8px 12px; - border-radius: 6px; - background: var(--background-secondary); +.setting-item.mod-navigable:hover, +.setting-item.nexus-other-row:hover { + background-color: var(--background-modifier-hover); } -.acknowledgment-status { - display: flex; - align-items: center; - gap: 8px; - font-size: 0.9em; +.setting-item.mod-navigable:first-child:hover { + border-radius: var(--setting-items-radius) var(--setting-items-radius) 0 0; } -.acknowledgment-status.acknowledged { - color: var(--text-success); +.setting-item.mod-navigable:last-child:hover, +.setting-item.nexus-other-row:last-child:hover { + border-radius: 0 0 var(--setting-items-radius) var(--setting-items-radius); } -.acknowledgment-status.not-acknowledged { - color: var(--text-muted); +.setting-item.mod-navigable:only-child:hover { + border-radius: var(--setting-items-radius); } -.acknowledgment-check { - font-weight: bold; - font-size: 1em; +.setting-item.mod-navigable:focus-visible, +.setting-item.nexus-other-row:focus-visible { + box-shadow: 0 0 0 2px var(--background-modifier-border-focus); } -.acknowledgment-pending { - font-weight: bold; - font-size: 1em; - opacity: 0.6; +.setting-item-chevron { + display: flex; + align-items: center; + color: var(--text-faint); } -/* ChatView Info Section */ -.chatview-info-section { - margin-top: 24px; - padding-top: 16px; - border-top: 1px solid var(--background-modifier-border); +/* Nexus settings span the full pane, like the tab strip above them, instead + of Obsidian's 700px setting-group column. */ +.nexus-settings { + --setting-group-max-width: none; } -.chatview-info-section h4, -.chatview-info-section h5 { - margin: 0 0 12px 0; - color: var(--text-normal); - font-weight: 600; +/* Obsidian pads and resizes every h2 inside .vertical-tab-content; the page + title lines up with the back button and the lede instead. */ +.nexus-settings .nexus-page-head .nexus-page-title { + padding: 0; + margin: 0; + font-size: 22px; } -.chatview-info-section h4 { - font-size: 1em; +.nexus-gs-page .setting-item { + align-items: center; } -.chatview-info-section h5 { - font-size: 0.95em; - margin-top: 16px; +.nexus-page-head { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-xs); + max-width: var(--setting-group-max-width); + margin: 0 auto var(--space-m); } -.chatview-info-section p { - margin: 0 0 12px 0; +.nexus-page-head .nexus-back-button { + margin: 0 0 0 -4px; + padding: 4px 8px 4px 4px; + border: 0; + background: transparent; + box-shadow: none; + color: var(--text-muted); + font-size: var(--font-ui-small); + font-weight: var(--font-normal); +} + +.nexus-page-head .nexus-back-button:hover { color: var(--text-normal); - line-height: 1.4; + background: var(--background-modifier-hover); } -.chatview-usage-notes ul { - margin: 8px 0 0 0; - padding-left: 20px; +.nexus-page-head .nexus-back-button svg { + width: 16px; + height: 16px; } -.chatview-usage-notes li { - margin-bottom: 4px; - color: var(--text-normal); - line-height: 1.3; +.nexus-page-title { + margin: 0; + font-size: 22px; + font-weight: var(--font-semibold); + letter-spacing: -0.01em; } -/* Settings Styles */ -.nexus-settings { - padding: 12px; - max-width: 100%; +.nexus-page-lede { + max-width: var(--setting-group-max-width); + margin: 0 auto var(--space-l); + color: var(--text-muted); + font-size: var(--font-ui-small); } -.nexus-settings textarea { - min-height: 100px; - width: 100%; - font-family: var(--font-monospace); +.nexus-can { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-s) var(--space-xl); + max-width: var(--setting-group-max-width); + margin: 0 auto var(--space-xl); + padding: 0; + list-style: none; + font-size: var(--font-ui-small); } -/* Setup instructions */ -.mcp-setup-instructions { - margin-bottom: 20px; - padding: 15px; - background-color: var(--background-secondary); - border-radius: 5px; +.nexus-can li { + display: flex; + align-items: center; + gap: var(--space-s); } -/* Agents grid */ -.mcp-agents-container { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: 15px; - margin-top: 10px; +.nexus-can-icon { + display: inline-flex; + color: var(--text-accent); } -.mcp-agent { - padding: 10px; - background-color: var(--background-secondary); - border-radius: 5px; - transition: transform 0.2s ease; +.nexus-can-icon svg { + width: 16px; + height: 16px; } -.mcp-agent:hover { - transform: translateY(-2px); - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +.setting-item.nexus-gs-heading { + flex-wrap: wrap; + max-width: var(--setting-group-max-width); + margin: 0 auto var(--space-s); } -.mcp-agent-name { - margin: 0 0 5px 0; +.nexus-gs-apps, +.nexus-gs-cli { + margin-top: var(--space-m); +} + +.nexus-step { + display: inline-grid; + place-items: center; + width: 22px; + height: 22px; + margin-right: var(--space-xs); + border-radius: 50%; + background: rgba(var(--interactive-accent-rgb), 0.18); color: var(--text-accent); + font-size: 12px; + font-weight: var(--font-semibold); } -.mcp-agent-description { - margin: 0; - font-size: 0.9em; +.nexus-status { + display: inline-flex; + align-items: center; + gap: 4px; } -/* Template info */ -.template-pack-info { - margin-top: 8px; - padding: 10px; - background-color: var(--background-secondary); - border-radius: 4px; - font-size: 0.9em; +.nexus-status.is-ok { + color: var(--text-success); } -.template-pack-info ul { - margin: 8px 0; - padding-left: 20px; +.nexus-status-check { + display: inline-flex; } -.mcp-config-section { - margin-bottom: 1.5rem; +.nexus-status-check svg { + width: 14px; + height: 14px; } -.mcp-config-section pre { - background-color: var(--background-primary); - padding: 10px; - border-radius: 4px; - margin: 10px 0; +.nexus-node-missing .setting-item-name { + color: var(--text-warning); } -/* Memory settings styles */ -.memory-settings-container { - margin-top: 10px; - display: block; +.nexus-node-warning { + margin-top: var(--space-xs); + color: var(--text-warning); } -.memory-settings-container h3 { - margin-top: 24px; - margin-bottom: 16px; - padding-bottom: 0; +.nexus-code { + max-height: 240px; + margin: var(--space-s) 0 0; + padding: var(--space-s) var(--space-m); + overflow: auto; + border-radius: var(--radius-s); + background: var(--background-primary); + color: var(--text-normal); + font: 11px/1.5 var(--font-monospace); + white-space: pre; + user-select: text; } -.memory-settings-tabs { - display: flex; - border-bottom: 1px solid var(--background-modifier-border); - margin-bottom: 15px; +.nexus-prompt { + font-family: var(--font-interface); + font-size: var(--font-ui-smaller); + white-space: pre-wrap; + overflow-wrap: anywhere; } -.nexus-settings-tabs { - gap: 8px; - flex-wrap: wrap; - border-bottom: none; +.setting-item:has(.nexus-code) { + align-items: flex-start; } -.memory-tab { - padding: 8px 15px; - cursor: pointer; - border-bottom: 2px solid transparent; - margin-right: 5px; - user-select: none; - border-radius: 4px 4px 0 0; +.setting-item:has(.nexus-code) .setting-item-control { + padding-top: 2px; } -.nexus-settings-tab { - flex: 1 1 120px; - min-width: 120px; - margin-right: 0; - text-align: center; - justify-content: center; - display: flex; - align-items: center; - border: 1px solid var(--background-modifier-border); - border-radius: var(--radius-m); - background: var(--background-primary); +.nexus-other-steps { + margin: 0 var(--setting-items-padding-x) var(--space-s) calc(var(--setting-items-padding-x) + var(--space-s)); + padding-left: var(--space-m); + border-left: 2px solid var(--setting-items-divider-color); } -.memory-tab.active { - color: var(--text-accent); - background-color: var(--background-secondary); - border-top: 1px solid var(--background-modifier-border); - border-right: 1px solid var(--background-modifier-border); - border-left: 1px solid var(--background-modifier-border); - border-bottom: none; +.nexus-other-steps .setting-item { + padding-left: 0; + padding-right: 0; } -.nexus-settings-tab.active { - border: 1px solid var(--interactive-accent); - background: var(--background-secondary); +.nexus-other-guide { + margin: var(--space-s) 0; + color: var(--text-muted); + font-size: var(--font-ui-smaller); } -.nexus-settings-accordion { - margin-bottom: 16px; +.nexus-child-row .setting-item-name { + padding-left: var(--space-l); + color: var(--text-muted); + font-size: var(--font-ui-small); } -.nexus-settings-accordion-content { - padding: 0; +.nexus-child-row .setting-item-description { + padding-left: var(--space-l); } -.memory-tab-content { - display: block; +.nexus-path-row .setting-item-description { + font-family: var(--font-monospace); + font-size: 11px; } -.memory-tab-pane { - display: none; - padding: 10px 0; +body.is-mobile .nexus-can { + grid-template-columns: 1fr; } -.memory-tab-pane.active { - display: block; +body.is-mobile .setting-item.mod-navigable, +body.is-mobile .setting-item.nexus-other-row, +body.is-mobile .nexus-page-head .nexus-back-button { + min-height: 44px; } -.memory-settings-textarea { - width: 100%; - background: var(--background-primary); - border: 1px solid var(--background-modifier-border); - border-radius: 4px; - padding: 8px; - font-family: var(--font-monospace); - resize: vertical; +/* Setup Prompt Styles */ +.mcp-section { + margin-bottom: 1.5rem; } -.memory-usage-stats { +.mcp-section pre { background-color: var(--background-secondary); + padding: 12px; border-radius: 4px; - padding: 16px; - margin: 16px 0; - display: block; + margin: 10px 0; + overflow-x: auto; + max-height: 400px; + overflow-y: auto; } -.memory-usage-progress { - width: 100%; - height: 8px; - background-color: var(--background-modifier-border); - border-radius: 4px; - margin: 8px 0 16px 0; - overflow: hidden; +.mcp-section code { + font-family: var(--font-monospace); + font-size: 0.9em; } -.memory-usage-bar { - height: 100%; +.mcp-section button.mod-cta { + margin-top: 8px; + margin-bottom: 16px; + padding: 6px 12px; background-color: var(--interactive-accent); + color: var(--text-on-accent); + border: none; border-radius: 4px; - transition: width 0.3s ease; + cursor: pointer; + transition: background-color 0.2s ease; } -.memory-actions { - display: flex; - gap: 8px; - margin-top: 16px; - align-items: center; +.mcp-section button.mod-cta:hover { + background-color: var(--interactive-accent-hover); } -.memory-model-usage { - margin-top: 10px; - padding: 10px; - background-color: var(--background-primary); - border-radius: 5px; - border: 1px solid var(--background-modifier-border); -} +/* =============================== */ +/* EXPERIMENTAL WARNING STYLES */ +/* =============================== */ -.memory-actions button { - height: 30px; +/* ChatView Experimental Warning Banner */ +.chat-experimental-warning { + background: var(--background-modifier-error); + border: 1px solid var(--background-modifier-error-border, var(--background-modifier-border)); + border-radius: 6px; + padding: 8px 12px; + margin-bottom: 12px; display: flex; align-items: center; - justify-content: center; + gap: 8px; + font-size: 0.9em; + flex-wrap: wrap; } -.memory-settings-desc { - margin-bottom: 8px; - opacity: 0.8; +.chat-experimental-warning .warning-icon { + font-size: 1.1em; + color: var(--text-warning); } -.memory-advanced-toggle { - text-align: right; - margin-top: 16px; - cursor: pointer; - color: var(--text-accent); -} - -.memory-advanced-settings { - display: none; +.chat-experimental-warning .warning-text { + color: var(--text-on-accent); } -.memory-advanced-settings.show { - display: block; +.chat-experimental-warning .warning-link { + color: var(--text-accent); + text-decoration: underline; + font-weight: 500; } -.memory-notice { - padding: 10px; - background-color: var(--background-secondary); - border-left: 3px solid var(--text-accent); - margin-bottom: 15px; +.chat-experimental-warning .warning-link:hover { + color: var(--text-accent-hover); } -/* Memory info notice */ -.memory-info-notice { - background-color: var(--background-secondary); - border-radius: 4px; - padding: 12px; - margin-bottom: 16px; - border-left: 4px solid var(--interactive-accent); +/* Settings Tab Experimental Warning Styles */ +.experimental-warning-container { + background: var(--background-modifier-error); + border: 1px solid var(--background-modifier-error-border, var(--background-modifier-border)); + border-radius: 8px; + padding: 16px; + margin-bottom: 20px; } - -/* Progress indicator for indexing */ -.memory-indexing-progress { +.experimental-warning-header { display: flex; - flex-direction: column; - width: 100%; - margin-top: 12px; + align-items: center; + gap: 8px; margin-bottom: 12px; } -.memory-progress-spinner { - display: inline-block; - width: 16px; - height: 16px; - border: 2px solid rgba(var(--interactive-accent-rgb), 0.3); - border-radius: 50%; - border-top-color: var(--interactive-accent); - animation: memory-spin 1s linear infinite; -} - -@keyframes memory-spin { - to { - transform: rotate(360deg); - } +.experimental-warning-icon { + font-size: 1.2em; + color: var(--text-warning); } -/* Indexing Progress Bar */ -.mcp-progress-container { - display: flex; - flex-direction: column; - background-color: var(--background-primary); - border: 1px solid var(--background-modifier-border); - border-radius: 5px; - padding: 10px; - margin: 8px 0; +.experimental-warning-title { + margin: 0; + color: var(--text-on-accent); + font-size: 1.1em; + font-weight: 600; } -.mcp-progress-info { - display: flex; - justify-content: space-between; - margin-bottom: 5px; +.experimental-warning-message { + color: var(--text-on-accent); + line-height: 1.4; } -.mcp-progress-text { - font-size: 0.9em; - color: var(--text-normal); +.experimental-warning-message p { + margin: 0 0 8px 0; } -.mcp-progress-bar-container { - height: 10px; - background-color: var(--background-modifier-border); - border-radius: 5px; - overflow: hidden; - margin-bottom: 8px; +.experimental-warning-message a { + color: var(--text-accent); + text-decoration: underline; + font-weight: 500; } -.mcp-progress-bar { - height: 100%; - background-color: var(--interactive-accent); - width: 0%; - transition: width 0.5s ease; +.experimental-warning-message a:hover { + color: var(--text-accent-hover); } -.mcp-progress-cancel { - align-self: flex-end; - cursor: pointer; - font-size: 0.8em; - color: var(--text-error); - margin-top: 5px; +/* ChatView Settings Section */ +.chatview-controls-section { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--background-modifier-border); } -.mcp-progress-cancel:hover { - text-decoration: underline; +.chatview-controls-section h4 { + margin: 0 0 16px 0; + color: var(--text-normal); + font-size: 1em; + font-weight: 600; } - - -/* Modal Styles */ -.folder-container { - max-height: 400px; - overflow-y: auto; - margin: 1em 0; - border: 1px solid var(--background-modifier-border); - border-radius: 4px; - padding: 8px; +.acknowledgment-display { + margin-top: 12px; + padding: 8px 12px; + border-radius: 6px; + background: var(--background-secondary); } -.folder-item { +.acknowledgment-status { display: flex; align-items: center; - padding: 4px 0; - cursor: pointer; + gap: 8px; + font-size: 0.9em; } -.folder-checkbox { - margin-right: 8px; +.acknowledgment-status.acknowledged { + color: var(--text-success); } -.folder-name { - flex: 1; +.acknowledgment-status.not-acknowledged { + color: var(--text-muted); } -.modal-button-container { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 1em; +.acknowledgment-check { + font-weight: bold; + font-size: 1em; } -.folder-item:hover { - background-color: var(--background-modifier-hover); +.acknowledgment-pending { + font-weight: bold; + font-size: 1em; + opacity: 0.6; } -.folder-indent { - padding-left: 20px; +/* ChatView Info Section */ +.chatview-info-section { + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid var(--background-modifier-border); } -.nexus-folder-item { - display: flex; - align-items: center; - padding: 4px; - cursor: pointer; +.chatview-info-section h4, +.chatview-info-section h5 { + margin: 0 0 12px 0; + color: var(--text-normal); + font-weight: 600; } -.nexus-checkbox { - margin-right: 8px; +.chatview-info-section h4 { + font-size: 1em; } -.nexus-folder-name { - flex: 1; +.chatview-info-section h5 { + font-size: 0.95em; + margin-top: 16px; } -/* Links */ -a { - color: var(--text-accent); - text-decoration: underline; +.chatview-info-section p { + margin: 0 0 12px 0; + color: var(--text-normal); + line-height: 1.4; } -a:hover { - color: var(--text-accent-hover); +.chatview-usage-notes ul { + margin: 8px 0 0 0; + padding-left: 20px; } - -/* MCP Connection Status */ -.mcp-connector-status { - display: flex; - align-items: center; - gap: 5px; - white-space: nowrap; - font-size: var(--font-ui-smaller); +.chatview-usage-notes li { + margin-bottom: 4px; + color: var(--text-normal); + line-height: 1.3; } -.mcp-connector-status .status-indicator { - width: 8px; - height: 8px; - border-radius: 50%; +/* Settings Styles */ +.nexus-settings { + padding: 12px; + max-width: 100%; } -.mcp-connector-status .status-indicator.connected { - background-color: var(--interactive-success); +.nexus-settings textarea { + min-height: 100px; + width: 100%; + font-family: var(--font-monospace); } -.mcp-connector-status .status-indicator.disconnected { - background-color: var(--text-error); +/* Setup instructions */ +.mcp-setup-instructions { + margin-bottom: 20px; + padding: 15px; + background-color: var(--background-secondary); + border-radius: 5px; } -.mcp-connector-status .status-indicator.starting { - background-color: var(--text-warning); +/* Agents grid */ +.mcp-agents-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 15px; + margin-top: 10px; } -/* Config Modal Styles */ -.mcp-config-toggle { - margin-bottom: 20px; +.mcp-agent { padding: 10px; background-color: var(--background-secondary); border-radius: 5px; + transition: transform 0.2s ease; } -.mcp-config-label { - display: block; - margin-bottom: 8px; - font-weight: bold; +.mcp-agent:hover { + transform: translateY(-2px); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } -.mcp-config-tabs { - margin-bottom: 20px; +.mcp-agent-name { + margin: 0 0 5px 0; + color: var(--text-accent); } -.mcp-tab-buttons { - display: flex; - border-bottom: 1px solid var(--background-modifier-border); - margin-bottom: 10px; +.mcp-agent-description { + margin: 0; + font-size: 0.9em; } -.mcp-tab-button { - padding: 8px 16px; - background: transparent; - border: none; - cursor: pointer; - border-bottom: 2px solid transparent; - margin-right: 10px; +/* Template info */ +.template-pack-info { + margin-top: 8px; + padding: 10px; + background-color: var(--background-secondary); + border-radius: 4px; + font-size: 0.9em; } -.mcp-tab-button:hover { - background-color: var(--background-modifier-hover); +.template-pack-info ul { + margin: 8px 0; + padding-left: 20px; } -.mcp-tab-active { - border-bottom: 2px solid var(--interactive-accent); - font-weight: bold; +.mcp-config-section { + margin-bottom: 1.5rem; } -.mcp-tab-content { - margin-bottom: 20px; +.mcp-config-section pre { + background-color: var(--background-primary); + padding: 10px; + border-radius: 4px; + margin: 10px 0; } -.mcp-tab-content.active { +/* Memory settings styles */ +.memory-settings-container { + margin-top: 10px; display: block; } -.mcp-tab-content.hidden { - display: none; +.memory-settings-container h3 { + margin-top: 24px; + margin-bottom: 16px; + padding-bottom: 0; } -/* Memory Stats Styles */ -.memory-stats-secondary { - color: var(--text-muted); - font-size: 0.9em; - margin-top: 0.25rem; +.memory-settings-tabs { + display: flex; + border-bottom: 1px solid var(--background-modifier-border); + margin-bottom: 15px; } -/* Settings Validation Styles */ -.setting-error { - color: var(--text-error); - font-size: 0.8em; - margin-top: 0.25rem; - font-weight: 500; +.nexus-settings-tabs { + gap: 8px; + flex-wrap: wrap; + border-bottom: none; } -.setting-warning { - color: var(--text-warning); - font-size: 0.8em; - margin-top: 0.25rem; - font-weight: 500; +.memory-tab { + padding: 8px 15px; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-right: 5px; + user-select: none; + border-radius: 4px 4px 0 0; } -/* Ollama Setup Instructions Styling */ -.ollama-setup-instructions { - background: var(--background-secondary); +.nexus-settings-tab { + flex: 1 1 120px; + min-width: 120px; + margin-right: 0; + text-align: center; + justify-content: center; + display: flex; + align-items: center; border: 1px solid var(--background-modifier-border); - border-radius: 4px; - padding: 16px; - margin: 16px 0; -} - -.ollama-step { - margin-bottom: 16px; + border-radius: var(--radius-m); + background: var(--background-primary); } -.ollama-step:last-child { - margin-bottom: 0; +.memory-tab.active { + color: var(--text-accent); + background-color: var(--background-secondary); + border-top: 1px solid var(--background-modifier-border); + border-right: 1px solid var(--background-modifier-border); + border-left: 1px solid var(--background-modifier-border); + border-bottom: none; } -.ollama-step h5 { - color: var(--text-accent); - margin: 0 0 8px 0; - font-weight: 600; +.nexus-settings-tab.active { + border: 1px solid var(--interactive-accent); + background: var(--background-secondary); } -.ollama-step p { - margin: 8px 0; - color: var(--text-normal); +.memory-tab-content { + display: block; } -.ollama-step ul { - margin: 8px 0; - padding-left: 20px; +.memory-tab-pane { + display: none; + padding: 10px 0; } -.ollama-step li { - margin: 4px 0; - color: var(--text-normal); +.memory-tab-pane.active { + display: block; } -.ollama-step code { - background: var(--background-primary-alt); +.memory-settings-textarea { + width: 100%; + background: var(--background-primary); border: 1px solid var(--background-modifier-border); - border-radius: 3px; - padding: 2px 6px; + border-radius: 4px; + padding: 8px; font-family: var(--font-monospace); - font-size: 0.9em; - color: var(--text-normal); -} - -.ollama-step a { - color: var(--text-accent); - text-decoration: none; -} - -.ollama-step a:hover { - text-decoration: underline; + resize: vertical; } -/* Dimension Warning Styles */ -.dimension-info-container { +.memory-usage-stats { background-color: var(--background-secondary); - border: 1px solid var(--interactive-accent); border-radius: 4px; - padding: 12px; + padding: 16px; margin: 16px 0; - border-left: 4px solid var(--interactive-accent); -} - -.dimension-info-text { - color: var(--text-normal); - margin: 0; - font-weight: 500; + display: block; } -.dimension-warning-container { - background-color: var(--background-modifier-error-rgb); - border: 1px solid var(--text-error); +.memory-usage-progress { + width: 100%; + height: 8px; + background-color: var(--background-modifier-border); border-radius: 4px; - padding: 12px; - margin: 16px 0; - border-left: 4px solid var(--text-error); + margin: 8px 0 16px 0; + overflow: hidden; } -.dimension-warning-text { - color: var(--text-error); - margin: 0 0 12px 0; - font-weight: 500; - line-height: 1.4; +.memory-usage-bar { + height: 100%; + background-color: var(--interactive-accent); + border-radius: 4px; + transition: width 0.3s ease; } -/* Searchable Card Manager */ -.searchable-card-manager-search { - margin-bottom: var(--space-m); +.memory-actions { + display: flex; + gap: 8px; + margin-top: 16px; + align-items: center; } -.searchable-card-manager-input { - width: 100%; - padding: var(--space-s) var(--space-m); +.memory-model-usage { + margin-top: 10px; + padding: 10px; + background-color: var(--background-primary); + border-radius: 5px; border: 1px solid var(--background-modifier-border); - border-radius: var(--radius-s); - background: var(--background-primary); - color: var(--text-normal); - font-size: var(--font-ui-small); } -.searchable-card-manager-input:focus { - border-color: var(--interactive-accent); - outline: none; +.memory-actions button { + height: 30px; + display: flex; + align-items: center; + justify-content: center; } -.searchable-card-manager-group { - margin-bottom: var(--space-l); +.memory-settings-desc { + margin-bottom: 8px; + opacity: 0.8; } -.searchable-card-manager-group--hidden { - display: none; +.memory-advanced-toggle { + text-align: right; + margin-top: 16px; + cursor: pointer; + color: var(--text-accent); } -/* Card Manager Styles - Unified for all card-based UIs */ -.card-manager-add-button { - margin-bottom: var(--space-l); +.memory-advanced-settings { + display: none; } -.card-manager-add-button button { - width: 100%; +.memory-advanced-settings.show { + display: block; } -.card-manager-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--space-l); +.memory-notice { + padding: 10px; + background-color: var(--background-secondary); + border-left: 3px solid var(--text-accent); + margin-bottom: 15px; } -.card-manager-empty { - text-align: center; - color: var(--text-muted); - padding: 20px; +/* Memory info notice */ +.memory-info-notice { background-color: var(--background-secondary); - border-radius: 6px; - border: 1px dashed var(--background-modifier-border); + border-radius: 4px; + padding: 12px; + margin-bottom: 16px; + border-left: 4px solid var(--interactive-accent); } -.agent-management-card { - background-color: var(--background-secondary); - border: 1px solid var(--background-modifier-border); - border-radius: 6px; - padding: 16px; - transition: all 0.2s ease; -} -.agent-management-card:hover { - border-color: var(--interactive-accent); - background-color: var(--background-modifier-hover); +/* Progress indicator for indexing */ +.memory-indexing-progress { + display: flex; + flex-direction: column; + width: 100%; + margin-top: 12px; + margin-bottom: 12px; } -.agent-management-card-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px; +.memory-progress-spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid rgba(var(--interactive-accent-rgb), 0.3); + border-radius: 50%; + border-top-color: var(--interactive-accent); + animation: memory-spin 1s linear infinite; } -.agent-management-card-title { - font-weight: 600; - font-size: 1.1em; - color: var(--text-normal); - flex: 1; +@keyframes memory-spin { + to { + transform: rotate(360deg); + } } -.agent-management-card-actions { +/* Indexing Progress Bar */ +.mcp-progress-container { display: flex; - align-items: center; - gap: 8px; + flex-direction: column; + background-color: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 5px; + padding: 10px; + margin: 8px 0; } -.agent-management-toggle { +.mcp-progress-info { display: flex; - align-items: center; + justify-content: space-between; + margin-bottom: 5px; } -.agent-management-delete-btn:hover { - background-color: var(--text-error); - color: var(--text-on-accent); +.mcp-progress-text { + font-size: 0.9em; + color: var(--text-normal); } -.agent-management-card-description { - color: var(--text-muted); +.mcp-progress-bar-container { + height: 10px; + background-color: var(--background-modifier-border); + border-radius: 5px; + overflow: hidden; margin-bottom: 8px; - line-height: 1.4; } -.agent-management-card-status { - font-size: 0.9em; - font-weight: 500; - padding: 4px 8px; - border-radius: 4px; - display: inline-block; +.mcp-progress-bar { + height: 100%; + background-color: var(--interactive-accent); + width: 0%; + transition: width 0.5s ease; } -.agent-management-card-status.enabled { - color: var(--text-success); - background-color: rgba(var(--color-green-rgb), 0.1); +.mcp-progress-cancel { + align-self: flex-end; + cursor: pointer; + font-size: 0.8em; + color: var(--text-error); + margin-top: 5px; } -.agent-management-card-status.disabled { - color: var(--text-muted); - background-color: var(--background-modifier-border); +.mcp-progress-cancel:hover { + text-decoration: underline; } -/* Coming Soon Provider Card */ -.provider-coming-soon { - opacity: 0.6; - pointer-events: none; + + +/* Modal Styles */ +.folder-container { + max-height: 400px; + overflow-y: auto; + margin: 1em 0; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + padding: 8px; } -.provider-coming-soon .agent-management-card-description { - color: var(--text-accent); - font-style: italic; +.folder-item { + display: flex; + align-items: center; + padding: 4px 0; + cursor: pointer; } -/* Modal content styling */ -.modal-content h1 { - margin-bottom: 20px; - font-size: 1.5em; - font-weight: 600; +.folder-checkbox { + margin-right: 8px; } -.modal-content h2 { - margin-top: 20px; - margin-bottom: 8px; - font-size: 1.1em; - font-weight: 600; - color: var(--text-normal); +.folder-name { + flex: 1; } -.modal-content h2:first-of-type { - margin-top: 0; +.modal-button-container { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 1em; } -.agent-modal-input, -.agent-modal-textarea { - width: 100%; - padding: 8px 12px; - border: 1px solid var(--background-modifier-border); - border-radius: 4px; - background-color: var(--background-primary); - color: var(--text-normal); - font-family: var(--font-interface); - font-size: var(--font-ui-medium); - margin-bottom: 4px; +.folder-item:hover { + background-color: var(--background-modifier-hover); } -.agent-modal-textarea { - resize: vertical; - min-height: 120px; - font-family: var(--font-interface); - line-height: 1.5; +.folder-indent { + padding-left: 20px; } -.agent-modal-input:focus, -.agent-modal-textarea:focus { - outline: none; - border-color: var(--interactive-accent); - box-shadow: 0 0 0 2px rgba(var(--interactive-accent-rgb), 0.2); +.nexus-folder-item { + display: flex; + align-items: center; + padding: 4px; + cursor: pointer; } -/* Usage Chart Styles */ -.usage-chart-container { - width: 100%; +.nexus-checkbox { + margin-right: 8px; } -.usage-chart-title { - margin-bottom: 16px; - color: var(--text-normal); +.nexus-folder-name { + flex: 1; } -.usage-budget-section { - margin-bottom: 20px; - padding: 12px; - border: 1px solid var(--background-modifier-border); - border-radius: 6px; - background-color: var(--background-secondary); +/* Links */ +a { + color: var(--text-accent); + text-decoration: underline; } -.usage-budget-header { - margin: 0 0 8px 0; - color: var(--text-normal); +a:hover { + color: var(--text-accent-hover); } -.usage-progress-container { - margin-bottom: 8px; + +/* MCP Connection Status */ +.mcp-connector-status { + display: flex; + align-items: center; + gap: 5px; + white-space: nowrap; + font-size: var(--font-ui-smaller); } -.usage-progress-bar { - width: 100%; +.mcp-connector-status .status-indicator { + width: 8px; height: 8px; - background-color: var(--background-modifier-border); - border-radius: 4px; - overflow: hidden; + border-radius: 50%; } -.usage-progress-fill { - height: 100%; - transition: width 0.3s ease; +.mcp-connector-status .status-indicator.connected { + background-color: var(--interactive-success); } -.usage-progress-fill.success { - background-color: var(--text-success); +.mcp-connector-status .status-indicator.disconnected { + background-color: var(--text-error); } -.usage-progress-fill.warning { +.mcp-connector-status .status-indicator.starting { background-color: var(--text-warning); } -.usage-progress-fill.error { - background-color: var(--text-error); +/* Config Modal Styles */ +.mcp-config-toggle { + margin-bottom: 20px; + padding: 10px; + background-color: var(--background-secondary); + border-radius: 5px; } -.usage-budget-text { - font-size: 0.9em; - color: var(--text-muted); +.mcp-config-label { + display: block; + margin-bottom: 8px; + font-weight: bold; } -.usage-budget-warning { - color: var(--text-error); - font-size: 0.9em; - font-weight: bold; - margin-top: 4px; +.mcp-config-tabs { + margin-bottom: 20px; } -.usage-budget-input { - width: 100%; - margin-top: 8px; - padding: 4px 8px; - border: 1px solid var(--background-modifier-border); - border-radius: 4px; - background-color: var(--background-primary); - color: var(--text-normal); +.mcp-tab-buttons { + display: flex; + border-bottom: 1px solid var(--background-modifier-border); + margin-bottom: 10px; } -.usage-costs-section { - margin-bottom: 16px; +.mcp-tab-button { + padding: 8px 16px; + background: transparent; + border: none; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-right: 10px; } -.usage-section-title { - margin: 0 0 8px 0; - color: var(--text-normal); +.mcp-tab-button:hover { + background-color: var(--background-modifier-hover); } -.usage-total-cost { - font-size: 1.1em; +.mcp-tab-active { + border-bottom: 2px solid var(--interactive-accent); font-weight: bold; - color: var(--text-normal); - margin-bottom: 12px; } -.usage-stacked-progress-container { - width: 100%; - height: 8px; - background-color: var(--background-modifier-border); - border-radius: 4px; - overflow: hidden; - display: flex; - margin-bottom: 8px; +.mcp-tab-content { + margin-bottom: 20px; } -.usage-progress-segment { - height: 100%; - transition: width 0.3s ease; +.mcp-tab-content.active { + display: block; } -.usage-provider-list { - font-size: 0.9em; - margin-top: 8px; +.mcp-tab-content.hidden { + display: none; } -.usage-provider-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 2px 0; +/* Memory Stats Styles */ +.memory-stats-secondary { color: var(--text-muted); + font-size: 0.9em; + margin-top: 0.25rem; } -.usage-provider-left { - display: flex; - align-items: center; - gap: 8px; +/* Settings Validation Styles */ +.setting-error { + color: var(--text-error); + font-size: 0.8em; + margin-top: 0.25rem; + font-weight: 500; } -.usage-provider-color-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; +.setting-warning { + color: var(--text-warning); + font-size: 0.8em; + margin-top: 0.25rem; + font-weight: 500; } -.usage-provider-name { - color: var(--text-normal); +/* Ollama Setup Instructions Styling */ +.ollama-setup-instructions { + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + padding: 16px; + margin: 16px 0; } -.usage-provider-right { - display: flex; - align-items: center; - gap: 8px; +.ollama-step { + margin-bottom: 16px; } -.usage-provider-cost { - font-family: var(--font-monospace); +.ollama-step:last-child { + margin-bottom: 0; } -.usage-provider-percentage { - font-size: 0.8em; - color: var(--text-faint); +.ollama-step h5 { + color: var(--text-accent); + margin: 0 0 8px 0; + font-weight: 600; } -.usage-no-data { - color: var(--text-muted); - font-size: 0.9em; - font-style: italic; +.ollama-step p { + margin: 8px 0; + color: var(--text-normal); } -.usage-actions { - margin-top: 16px; +.ollama-step ul { + margin: 8px 0; + padding-left: 20px; } -.usage-reset-button { - padding: 6px 12px; - background-color: var(--interactive-accent); - color: var(--text-on-accent); - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 0.9em; +.ollama-step li { + margin: 4px 0; + color: var(--text-normal); } -.usage-reset-button:hover { - background-color: var(--interactive-accent-hover); +.ollama-step code { + background: var(--background-primary-alt); + border: 1px solid var(--background-modifier-border); + border-radius: 3px; + padding: 2px 6px; + font-family: var(--font-monospace); + font-size: 0.9em; + color: var(--text-normal); } -/* LLM Usage Tab Styles */ -.llm-usage-main-container { - padding: 16px; +.ollama-step a { + color: var(--text-accent); + text-decoration: none; } -.llm-usage-error { - color: var(--text-error); - padding: 20px; - text-align: center; - border: 1px solid var(--text-error); - border-radius: 6px; - background-color: var(--background-secondary); +.ollama-step a:hover { + text-decoration: underline; } -.llm-usage-error-button { - margin-top: 12px; - padding: 6px 12px; - background-color: var(--interactive-accent); - color: var(--text-on-accent); - border: none; +/* Dimension Warning Styles */ +.dimension-info-container { + background-color: var(--background-secondary); + border: 1px solid var(--interactive-accent); border-radius: 4px; - cursor: pointer; -} - -.llm-usage-button-container { - display: flex; - gap: 10px; - margin-bottom: 16px; -} - -/* LLM Provider Modal Styles */ -.llm-provider-header { - display: flex; - justify-content: space-between; - align-items: center; -} - -/* Remote agent connections; see remote-agents-plan.md. */ -.nexus-remote-agent-cards { - margin-top: var(--space-l); + padding: 12px; + margin: 16px 0; + border-left: 4px solid var(--interactive-accent); } -.nexus-remote-agent-modal .nexus-remote-agent-url { - width: 300px; - max-width: 100%; +.dimension-info-text { + color: var(--text-normal); + margin: 0; + font-weight: 500; } -.nexus-remote-agent-description { - width: 280px; - max-width: 100%; - min-height: 90px; +.dimension-warning-container { + background-color: var(--background-modifier-error-rgb); + border: 1px solid var(--text-error); + border-radius: 4px; + padding: 12px; + margin: 16px 0; + border-left: 4px solid var(--text-error); } -.nexus-remote-agent-feedback { - margin-top: var(--space-m); +.dimension-warning-text { + color: var(--text-error); + margin: 0 0 12px 0; + font-weight: 500; + line-height: 1.4; } -.nexus-remote-agent-address { - overflow-wrap: anywhere; - color: var(--text-faint); - font-size: var(--font-ui-smaller); +/* Searchable Card Manager */ +.searchable-card-manager-search { + margin-bottom: var(--space-m); } -.nexus-remote-agent-status { - margin-top: var(--space-s); - color: var(--text-muted); +.searchable-card-manager-input { + width: 100%; + padding: var(--space-s) var(--space-m); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background: var(--background-primary); + color: var(--text-normal); font-size: var(--font-ui-small); } -.nexus-remote-agent-status.is-available { - color: var(--text-success); +.searchable-card-manager-input:focus { + border-color: var(--interactive-accent); + outline: none; } -.nexus-remote-agent-status.is-unavailable { - color: var(--text-error); +.searchable-card-manager-group { + margin-bottom: var(--space-l); } -.nexus-remote-agent-footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-m); - margin-top: var(--space-l); +.searchable-card-manager-group--hidden { + display: none; } -body.is-mobile .nexus-remote-agent-modal .setting-item { - flex-wrap: wrap; +/* Card Manager Styles - Unified for all card-based UIs */ +.card-manager-add-button { + margin-bottom: var(--space-l); } -body.is-mobile .nexus-remote-agent-modal .setting-item-control { +.card-manager-add-button button { width: 100%; } -body.is-mobile .nexus-remote-agent-modal input, -body.is-mobile .nexus-remote-agent-modal textarea, -body.is-mobile .nexus-remote-agent-modal button { - min-height: 44px; - max-width: 100%; +.card-manager-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-l); } -/* Generic endpoint settings; see openai-compatible-provider-plan.md. */ -.nexus-compatible-modal .setting-item-control { - min-width: 0; +.card-manager-empty { + text-align: center; + color: var(--text-muted); + padding: 20px; + background-color: var(--background-secondary); + border-radius: 6px; + border: 1px dashed var(--background-modifier-border); } -.nexus-compatible-modal .nexus-compatible-url-input { - width: 300px; - max-width: 100%; +.agent-management-card { + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + padding: 16px; + transition: all 0.2s ease; } -.nexus-compatible-endpoint { - margin-top: var(--space-m); +.agent-management-card:hover { + border-color: var(--interactive-accent); + background-color: var(--background-modifier-hover); } -.nexus-compatible-endpoint .setting-item { - padding-top: 0; - padding-bottom: var(--space-s); - border-top: none; +.agent-management-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; } -.nexus-compatible-address { - overflow-wrap: anywhere; +.agent-management-card-title { + font-weight: 600; + font-size: 1.1em; + color: var(--text-normal); + flex: 1; } -.nexus-compatible-feedback { - margin-bottom: var(--space-m); +.agent-management-card-actions { + display: flex; + align-items: center; + gap: 8px; } -.nexus-compatible-manual { - margin-top: var(--space-l); +.agent-management-toggle { + display: flex; + align-items: center; } -.nexus-compatible-manual summary { - cursor: pointer; +.agent-management-delete-btn:hover { + background-color: var(--text-error); + color: var(--text-on-accent); +} + +.agent-management-card-description { color: var(--text-muted); + margin-bottom: 8px; + line-height: 1.4; } -.nexus-compatible-modal .llm-provider-status-container { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-m); - margin-top: var(--space-l); +.agent-management-card-status { + font-size: 0.9em; + font-weight: 500; + padding: 4px 8px; + border-radius: 4px; + display: inline-block; } -body.is-mobile .nexus-compatible-modal > .provider-modal-content > .setting-item { - flex-wrap: wrap; +.agent-management-card-status.enabled { + color: var(--text-success); + background-color: rgba(var(--color-green-rgb), 0.1); } -body.is-mobile .nexus-compatible-modal > .provider-modal-content > .setting-item .setting-item-control { - width: 100%; +.agent-management-card-status.disabled { + color: var(--text-muted); + background-color: var(--background-modifier-border); } -body.is-mobile .nexus-compatible-modal input { - min-height: 44px; - max-width: 100%; +/* Coming Soon Provider Card */ +.provider-coming-soon { + opacity: 0.6; + pointer-events: none; } -body.is-mobile .nexus-compatible-modal button { - min-height: 44px; +.provider-coming-soon .agent-management-card-description { + color: var(--text-accent); + font-style: italic; } -.llm-provider-model-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 10px; +/* Modal content styling */ +.modal-content h1 { + margin-bottom: 20px; + font-size: 1.5em; + font-weight: 600; } -.llm-provider-model-name { - min-width: 150px; - font-weight: bold; +.modal-content h2 { + margin-top: 20px; + margin-bottom: 8px; + font-size: 1.1em; + font-weight: 600; + color: var(--text-normal); } -.llm-provider-description-input { - flex: 1; +.modal-content h2:first-of-type { + margin-top: 0; } -.llm-provider-input { - flex: 1; - padding: 6px 8px; +.agent-modal-input, +.agent-modal-textarea { + width: 100%; + padding: 8px 12px; border: 1px solid var(--background-modifier-border); border-radius: 4px; background-color: var(--background-primary); color: var(--text-normal); + font-family: var(--font-interface); + font-size: var(--font-ui-medium); + margin-bottom: 4px; } -.llm-provider-modal .llm-provider-key-setting .llm-provider-input { - min-width: 0; -} - -.llm-provider-modal .llm-provider-key-setting .setting-item-control > button { - flex: 0 0 auto; - width: auto; -} - -body.is-mobile .llm-provider-modal .llm-provider-key-setting { - flex-direction: column; - align-items: stretch; +.agent-modal-textarea { + resize: vertical; + min-height: 120px; + font-family: var(--font-interface); + line-height: 1.5; } -body.is-mobile .llm-provider-modal .llm-provider-key-setting .setting-item-control { - display: flex; - flex-direction: column; - align-items: stretch; - width: 100%; - gap: var(--space-s); +.agent-modal-input:focus, +.agent-modal-textarea:focus { + outline: none; + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px rgba(var(--interactive-accent-rgb), 0.2); } -body.is-mobile .llm-provider-modal .llm-provider-key-setting .llm-provider-input { - flex: 0 0 auto; - box-sizing: border-box; +/* Usage Chart Styles */ +.usage-chart-container { width: 100%; - max-width: 100%; - min-height: 44px; -} - -body.is-mobile .llm-provider-modal .llm-provider-key-setting .setting-item-control > button { - align-self: flex-start; - max-width: 100%; - min-height: 44px; -} - -.llm-provider-input.validating { - border-color: var(--color-yellow); - background-color: color-mix(in srgb, var(--color-yellow) 20%, transparent); - border-width: 2px; -} - -.llm-provider-input.success { - border-color: var(--color-green); - background-color: color-mix(in srgb, var(--color-green) 10%, transparent); - border-width: 2px; -} - -.llm-provider-input.error { - border-color: var(--color-red); - background-color: color-mix(in srgb, var(--color-red) 10%, transparent); - border-width: 2px; } -.llm-provider-button-container { - display: flex; - gap: 10px; - justify-content: flex-end; - margin-top: 20px; +.usage-chart-title { + margin-bottom: 16px; + color: var(--text-normal); } - -/* Progress Bar Component Styles */ -.progress-bar-container { - display: flex; - align-items: center; - gap: 10px; - padding: 10px; +.usage-budget-section { + margin-bottom: 20px; + padding: 12px; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; background-color: var(--background-secondary); - border-radius: 4px; - margin: 8px 0; } -.progress-bar-content { - flex: 1; +.usage-budget-header { + margin: 0 0 8px 0; + color: var(--text-normal); } -.progress-bar-text { - font-size: 0.9em; - color: var(--text-normal); - margin-bottom: 4px; +.usage-progress-container { + margin-bottom: 8px; } -.progress-bar-track { +.usage-progress-bar { width: 100%; height: 8px; background-color: var(--background-modifier-border); @@ -3284,1998 +3219,1682 @@ body.is-mobile .llm-provider-modal .llm-provider-key-setting .setting-item-contr overflow: hidden; } -.progress-bar-fill { +.usage-progress-fill { height: 100%; - background-color: var(--interactive-accent); transition: width 0.3s ease; } -/* Agent Management Styles */ -.agent-management-button-container { - display: flex; - gap: 10px; - margin-top: 16px; +.usage-progress-fill.success { + background-color: var(--text-success); } - -/* Token Usage Component Styles */ -.token-usage-progress-bar { - /* Dynamic width set via JS */ +.usage-progress-fill.warning { + background-color: var(--text-warning); } -/* Usage Settings Tab Styles */ -.usage-settings-budget-container { - margin-top: 20px; +.usage-progress-fill.error { + background-color: var(--text-error); } -/* Diff styles for main.js compiled code */ -.diff-insert { - background: color-mix(in srgb, var(--color-green) 15%, transparent); +.usage-budget-text { + font-size: 0.9em; + color: var(--text-muted); } -.diff-delete { - background: color-mix(in srgb, var(--color-red) 15%, transparent); +.usage-budget-warning { + color: var(--text-error); + font-size: 0.9em; + font-weight: bold; + margin-top: 4px; } -/* ============================================================================= - CHAT SELECTORS AND PROGRESS - ============================================================================= */ +.usage-budget-input { + width: 100%; + margin-top: 8px; + padding: 4px 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background-color: var(--background-primary); + color: var(--text-normal); +} -/* Model Selector */ -.model-selector { - display: flex; - flex-direction: column; - gap: 4px; +.usage-costs-section { + margin-bottom: 16px; } -.model-selector-label { - font-size: 11px; - color: var(--text-muted); - font-weight: 500; - white-space: nowrap; +.usage-section-title { + margin: 0 0 8px 0; + color: var(--text-normal); } -.model-selector-dropdown { - width: 100%; +.usage-total-cost { + font-size: 1.1em; + font-weight: bold; + color: var(--text-normal); + margin-bottom: 12px; } -.model-selector .model-select { +.usage-stacked-progress-container { width: 100%; - padding: 6px 8px; - border: 1px solid var(--background-modifier-border); + height: 8px; + background-color: var(--background-modifier-border); border-radius: 4px; - background: var(--background-primary); - color: var(--text-normal); - font-size: 12px; - min-width: 0; + overflow: hidden; + display: flex; + margin-bottom: 8px; } -.model-select:focus { - outline: none; - border-color: var(--interactive-accent); +.usage-progress-segment { + height: 100%; + transition: width 0.3s ease; } -/* Agent Selector */ -.agent-selector { - display: flex; - flex-direction: column; - gap: 4px; +.usage-provider-list { + font-size: 0.9em; + margin-top: 8px; } -.agent-selector-label { - font-size: 11px; +.usage-provider-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 2px 0; color: var(--text-muted); - font-weight: 500; - white-space: nowrap; } -.agent-selector-dropdown { - width: 100%; +.usage-provider-left { + display: flex; + align-items: center; + gap: 8px; } -.agent-select { - width: 100%; - padding: 6px 8px; - border: 1px solid var(--background-modifier-border); - border-radius: 4px; - background: var(--background-primary); +.usage-provider-color-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.usage-provider-name { color: var(--text-normal); - font-size: 12px; - min-width: 0; } -.agent-select:focus { - outline: none; - border-color: var(--interactive-accent); +.usage-provider-right { + display: flex; + align-items: center; + gap: 8px; } -.agent-info { - margin-top: 4px; - padding: 4px 6px; - border-radius: 3px; - font-size: 10px; - max-height: 60px; - overflow: hidden; +.usage-provider-cost { + font-family: var(--font-monospace); } -.agent-info-default { +.usage-provider-percentage { + font-size: 0.8em; + color: var(--text-faint); +} + +.usage-no-data { color: var(--text-muted); + font-size: 0.9em; font-style: italic; } -.agent-info-active { - background: var(--background-modifier-form-field); - border: 1px solid var(--background-modifier-border); +.usage-actions { + margin-top: 16px; } -.agent-info-name { - font-weight: 500; - color: var(--text-normal); - margin-bottom: 1px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.usage-reset-button { + padding: 6px 12px; + background-color: var(--interactive-accent); + color: var(--text-on-accent); + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9em; } -.agent-info-description { - color: var(--text-muted); - margin-bottom: 2px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.usage-reset-button:hover { + background-color: var(--interactive-accent-hover); } -.agent-info-prompt { - color: var(--text-faint); - font-size: 9px; - line-height: 1.2; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; +/* LLM Usage Tab Styles */ +.llm-usage-main-container { + padding: 16px; } -.context-warning-critical { - background: rgba(var(--color-red-rgb), 0.1); - color: var(--color-red); - border: 1px solid var(--color-red); +.llm-usage-error { + color: var(--text-error); + padding: 20px; + text-align: center; + border: 1px solid var(--text-error); + border-radius: 6px; + background-color: var(--background-secondary); } -/* ============================================================================= - MODAL DIALOGS FOR CHAT - ============================================================================= */ +.llm-usage-error-button { + margin-top: 12px; + padding: 6px 12px; + background-color: var(--interactive-accent); + color: var(--text-on-accent); + border: none; + border-radius: 4px; + cursor: pointer; +} -.chat-modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.6); - display: flex; - align-items: center; - justify-content: center; - z-index: 9999; - backdrop-filter: blur(2px); +.llm-usage-button-container { + display: flex; + gap: 10px; + margin-bottom: 16px; } -.chat-modal { - background: var(--background-primary); - border: 1px solid var(--background-modifier-border); - border-radius: 8px; - box-shadow: var(--shadow-l); - min-width: 400px; - max-width: 500px; - width: 90vw; +/* LLM Provider Modal Styles */ +.llm-provider-header { + display: flex; + justify-content: space-between; + align-items: center; } -.chat-modal-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px 20px; - border-bottom: 1px solid var(--background-modifier-border); +/* Remote agent connections; see remote-agents-plan.md. */ +.nexus-remote-agent-cards { + margin-top: var(--space-l); } -.chat-modal-header h3 { - margin: 0; - color: var(--text-normal); - font-size: 16px; - font-weight: 600; +.nexus-remote-agent-modal .nexus-remote-agent-url { + width: 300px; + max-width: 100%; } -.chat-modal-close { - background: none; - border: none; - font-size: 20px; - color: var(--text-muted); - cursor: pointer; - padding: 4px 8px; - border-radius: 4px; - transition: background-color 0.2s; +.nexus-remote-agent-description { + width: 280px; + max-width: 100%; + min-height: 90px; } -.chat-modal-close:hover { - background: var(--background-modifier-hover); +.nexus-remote-agent-feedback { + margin-top: var(--space-m); } -.chat-modal-content { - padding: 20px; +.nexus-remote-agent-address { + overflow-wrap: anywhere; + color: var(--text-faint); + font-size: var(--font-ui-smaller); } -.chat-modal-content p { - margin: 0 0 16px 0; - color: var(--text-normal); +.nexus-remote-agent-status { + margin-top: var(--space-s); + color: var(--text-muted); + font-size: var(--font-ui-small); } -.chat-title-input { - width: 100%; - padding: 12px 16px; - border: 1px solid var(--background-modifier-border); - border-radius: 6px; - background: var(--background-primary); - color: var(--text-normal); - font-size: 14px; - transition: border-color 0.2s; +.nexus-remote-agent-status.is-available { + color: var(--text-success); } -.chat-title-input:focus { - outline: none; - border-color: var(--interactive-accent); - box-shadow: 0 0 0 2px var(--interactive-accent-hover); +.nexus-remote-agent-status.is-unavailable { + color: var(--text-error); } -.chat-title-input.chat-input-error { - border-color: var(--color-red); - box-shadow: 0 0 0 2px rgba(var(--color-red-rgb), 0.3); +.nexus-remote-agent-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-m); + margin-top: var(--space-l); } -.chat-modal-actions { - display: flex; - gap: 12px; - justify-content: flex-end; - padding: 16px 20px; - border-top: 1px solid var(--background-modifier-border); +body.is-mobile .nexus-remote-agent-modal .setting-item { + flex-wrap: wrap; } -.chat-btn-primary, -.chat-btn-secondary { - padding: 8px 16px; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - border: none; +body.is-mobile .nexus-remote-agent-modal .setting-item-control { + width: 100%; } -.chat-btn-primary { - background: var(--interactive-accent); - color: var(--text-on-accent); +body.is-mobile .nexus-remote-agent-modal input, +body.is-mobile .nexus-remote-agent-modal textarea, +body.is-mobile .nexus-remote-agent-modal button { + min-height: 44px; + max-width: 100%; } -.chat-btn-primary:hover { - background: var(--interactive-accent-hover); +/* Generic endpoint settings; see openai-compatible-provider-plan.md. */ +.nexus-compatible-modal .setting-item-control { + min-width: 0; } -.chat-btn-secondary { - background: var(--background-secondary); - color: var(--text-normal); - border: 1px solid var(--background-modifier-border); +.nexus-compatible-modal .nexus-compatible-url-input { + width: 300px; + max-width: 100%; } -.chat-btn-secondary:hover { - background: var(--background-modifier-hover); +.nexus-compatible-endpoint { + margin-top: var(--space-m); } -/* Responsive design for cards */ -@media (max-width: 768px) { - .card-manager-grid { - grid-template-columns: 1fr; - gap: 12px; - } - - .agent-management-card-header { - flex-direction: column; - align-items: flex-start; - gap: 8px; - } - - .agent-management-card-actions { - align-self: flex-end; - } - - .chat-modal { - min-width: 300px; - margin: 20px; - } +.nexus-compatible-endpoint .setting-item { + padding-top: 0; + padding-bottom: var(--space-s); + border-top: none; } -/* AI Loading Animation Styles */ -.ai-loading { - display: flex; - align-items: center; - color: var(--text-muted); - font-style: italic; - padding: 8px 0; +.nexus-compatible-address { + overflow-wrap: anywhere; } -/* AI loading in header (inline with icon) */ -.ai-loading-header { - display: inline-flex; - align-items: center; - color: var(--text-muted); - font-style: italic; - margin-left: 8px; - font-size: 0.95em; +.nexus-compatible-feedback { + margin-bottom: var(--space-m); } -.ai-loading .dots, -.ai-loading-header .dots { - display: inline-block; - width: 20px; - text-align: left; - margin-left: 2px; - animation: ai-pulse 1.5s ease-in-out infinite; +.nexus-compatible-manual { + margin-top: var(--space-l); } -@keyframes ai-pulse { - 0%, 100% { - opacity: 0.4; - } - 50% { - opacity: 1; - } +.nexus-compatible-manual summary { + cursor: pointer; + color: var(--text-muted); } -/* Message loading state */ -.message-content .ai-loading { - min-height: 24px; - display: flex; - align-items: center; +.nexus-compatible-modal .llm-provider-status-container { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-m); + margin-top: var(--space-l); } -/* Ensure loading messages have proper styling */ -.message.assistant .ai-loading { - background: var(--background-secondary); - border-radius: 8px; - padding: 12px; - margin: 4px 0; +body.is-mobile .nexus-compatible-modal > .provider-modal-content > .setting-item { + flex-wrap: wrap; } -/* Streaming message styles */ -.streaming-content { - white-space: pre-wrap; - word-wrap: break-word; +body.is-mobile .nexus-compatible-modal > .provider-modal-content > .setting-item .setting-item-control { + width: 100%; } -.streaming-cursor { - display: inline-block; - animation: cursor-blink 1s infinite; - color: var(--interactive-accent); - font-weight: bold; +body.is-mobile .nexus-compatible-modal input { + min-height: 44px; + max-width: 100%; } -@keyframes cursor-blink { - 0%, 50% { - opacity: 1; - } - 51%, 100% { - opacity: 0; - } +body.is-mobile .nexus-compatible-modal button { + min-height: 44px; } -.final-content { - white-space: pre-wrap; - word-wrap: break-word; +.llm-provider-model-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; } -/* =========================== */ -/* BRANCH NAVIGATION STYLES */ -/* =========================== */ - -/* Message-level Branch Navigator - integrated into action pill */ -.message-branch-navigator { - display: flex; - align-items: center; - gap: 2px; - margin-left: -4px; +.llm-provider-model-name { + min-width: 150px; + font-weight: bold; } -.message-branch-navigator-hidden { - display: none; +.llm-provider-description-input { + flex: 1; } -.message-branch-navigator-visible { - display: flex; +.llm-provider-input { + flex: 1; + padding: 6px 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background-color: var(--background-primary); + color: var(--text-normal); } -/* Branch indicator (X/Y counter) */ -.message-branch-indicator { - font-size: 11px; - font-weight: 600; - color: var(--text-muted); - min-width: 24px; - text-align: center; - user-select: none; - letter-spacing: 0.3px; - padding: 0 2px; +.llm-provider-modal .llm-provider-key-setting .llm-provider-input { + min-width: 0; } -/* Branch buttons - extend message-action-btn styles */ -.message-action-btn.message-branch-prev svg, -.message-action-btn.message-branch-next svg { - width: 12px; - height: 12px; - stroke-width: 2.5; +.llm-provider-modal .llm-provider-key-setting .setting-item-control > button { + flex: 0 0 auto; + width: auto; } -/* Subtle hover effect for branch buttons */ -.message-action-btn.message-branch-prev:hover:not(:disabled), -.message-action-btn.message-branch-next:hover:not(:disabled) { - color: var(--text-accent); +body.is-mobile .llm-provider-modal .llm-provider-key-setting { + flex-direction: column; + align-items: stretch; } -/* Disabled state for branch buttons */ -.message-action-btn.message-branch-prev:disabled, -.message-action-btn.message-branch-next:disabled { - opacity: 0.3; - cursor: not-allowed; - color: var(--text-faint); +body.is-mobile .llm-provider-modal .llm-provider-key-setting .setting-item-control { + display: flex; + flex-direction: column; + align-items: stretch; + width: 100%; + gap: var(--space-s); } -/* Branch nav buttons: sized close to the 12px chevron so there's no phantom - padding inside. Chevron edge-aligned toward the indicator (flex-end/start) - eliminates the remaining ~2px wasted space. Mobile bumps to 24 for tap. */ -.message-action-btn.message-branch-prev, -.message-action-btn.message-branch-next { - width: 16px; - height: 16px; -} -/* Extra class repeat outweighs Obsidian's .clickable-icon justify-content - default, which otherwise re-centers the chevron. */ -.message-action-btn.message-action-btn.message-branch-prev { - justify-content: flex-end; -} -.message-action-btn.message-action-btn.message-branch-next { - justify-content: flex-start; -} -/* Mobile touch-target override — restore comfortable tap size but keep the - edge-align so the chevrons still cluster tightly to the indicator. */ -body.is-mobile .message-action-btn.message-branch-prev, -body.is-mobile .message-action-btn.message-branch-next { - width: 24px; - height: 24px; -} - -/* Mobile responsive */ -@media (max-width: 768px) { - .message-branch-navigator { - gap: 2px; - } - - .message-branch-indicator { - font-size: 10px; - min-width: 20px; - } -} - -/* Conversation-level Branch Navigator Container (Legacy) */ -.branch-navigator-container { - margin-left: auto; /* Push to right side of header */ -} - -.branch-navigator { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 8px; - border-radius: 6px; - background: var(--background-secondary); - border: 1px solid var(--background-modifier-border); - transition: all 0.2s ease; -} - -.branch-navigator-hidden { - display: none; -} - -.branch-navigator-visible { - display: flex; - animation: branch-appear 0.3s ease; -} - -/* Branch Navigation Buttons */ -.branch-nav-button { - background: none; - border: none; - color: var(--text-muted); - cursor: pointer; - padding: 4px; - border-radius: 3px; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s ease; - width: 20px; - height: 20px; -} - -.branch-nav-button:hover:not(:disabled) { - background: var(--background-modifier-hover); - color: var(--text-normal); -} - -.branch-nav-button:active:not(:disabled) { - background: var(--background-modifier-active); - transform: scale(0.95); -} - -.branch-nav-button:disabled, -.branch-nav-button.disabled { - opacity: 0.3; - cursor: not-allowed; - color: var(--text-faint); -} - -.branch-nav-button svg { - width: 12px; - height: 12px; - stroke-width: 2.5; -} - -/* Branch Indicator */ -.branch-indicator { - font-size: 11px; - font-weight: 600; - color: var(--text-muted); - min-width: 24px; - text-align: center; - user-select: none; - letter-spacing: 0.5px; -} - -/* Header Integration */ -.chat-header .branch-navigator-container { - opacity: 0; - transform: translateX(10px); - transition: all 0.3s ease; -} - -.chat-header:hover .branch-navigator-container, -.chat-header .branch-navigator-container.branch-navigator-visible { - opacity: 1; - transform: translateX(0); -} - -/* Branch Navigator Animations */ -@keyframes branch-appear { - from { - opacity: 0; - transform: scale(0.8) translateX(10px); - } - to { - opacity: 1; - transform: scale(1) translateX(0); - } -} - -/* Dark Mode Adjustments */ -.theme-dark .branch-navigator { - background: var(--background-primary-alt); - border-color: var(--background-modifier-border-hover); -} - -.theme-dark .branch-nav-button:hover:not(:disabled) { - background: var(--background-modifier-hover); - color: var(--text-accent); -} - -/* Mobile Responsive */ -@media (max-width: 768px) { - .branch-navigator { - gap: 6px; - padding: 3px 6px; - } - - .branch-nav-button { - width: 18px; - height: 18px; - } - - .branch-nav-button svg { - width: 10px; - height: 10px; - } - - .branch-indicator { - font-size: 10px; - min-width: 20px; - } -} -/* =========================== */ -/* CHAT SETTINGS MODAL STYLES */ -/* =========================== */ - -.chat-settings-modal { - padding: 20px; - min-width: 500px; - max-width: 600px; -} - -.chat-settings-modal h2 { - margin-top: 0; - margin-bottom: 20px; - color: var(--text-normal); +body.is-mobile .llm-provider-modal .llm-provider-key-setting .llm-provider-input { + flex: 0 0 auto; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-height: 44px; } -/* =========================== */ -/* MCP SETUP UI STYLES */ -/* =========================== */ - -/* Setup sections */ -.mcp-setup-section { - margin: 20px 0; - padding: 15px; - background: var(--background-secondary); - border-radius: 6px; - border: 1px solid var(--background-modifier-border); +body.is-mobile .llm-provider-modal .llm-provider-key-setting .setting-item-control > button { + align-self: flex-start; + max-width: 100%; + min-height: 44px; } -.mcp-setup-separator { - margin: 20px 0; +.llm-provider-input.validating { + border-color: var(--color-yellow); + background-color: color-mix(in srgb, var(--color-yellow) 20%, transparent); + border-width: 2px; } -.mcp-setup-or { - text-align: center; - margin: 10px 0; - color: var(--text-muted); - font-weight: 500; +.llm-provider-input.success { + border-color: var(--color-green); + background-color: color-mix(in srgb, var(--color-green) 10%, transparent); + border-width: 2px; } -/* Config status */ -.mcp-config-status { - margin: 15px 0; - padding: 12px; - border-radius: 5px; - background-color: var(--background-secondary); - border: 1px solid var(--background-modifier-border); +.llm-provider-input.error { + border-color: var(--color-red); + background-color: color-mix(in srgb, var(--color-red) 10%, transparent); + border-width: 2px; } -.mcp-status { +.llm-provider-button-container { display: flex; - flex-direction: column; - gap: 8px; -} - -.mcp-status span { - font-weight: 500; - font-size: 14px; -} - -.mcp-status-hint { - font-size: 12px; - color: var(--text-muted); - margin: 0; - padding: 0; -} - -.mcp-status-neutral { - color: var(--text-muted); -} - -.mcp-status-warning { - color: var(--text-warning); + gap: 10px; + justify-content: flex-end; + margin-top: 20px; } -.mcp-status-success { - color: var(--text-success); -} -/* Info box */ -.mcp-info-box { - margin: 15px 0; - padding: 12px; - border-left: 3px solid var(--interactive-accent); +/* Progress Bar Component Styles */ +.progress-bar-container { + display: flex; + align-items: center; + gap: 10px; + padding: 10px; background-color: var(--background-secondary); border-radius: 4px; + margin: 8px 0; } -.mcp-info-box strong { - display: block; - margin-bottom: 8px; - color: var(--text-normal); -} - -.mcp-info-box ol, -.mcp-info-box ul { - margin: 8px 0 0 0; - padding-left: 20px; -} - -.mcp-info-box li { - margin: 6px 0; - color: var(--text-normal); - font-size: 13px; -} - -/* Next steps notification */ -.mcp-next-steps { - margin: 15px 0; - padding: 15px; - border-radius: 5px; - background-color: var(--background-primary-alt); - border: 1px solid var(--interactive-accent); - animation: fadeIn 0.3s ease-in; -} - -.mcp-next-steps h5 { - margin: 0 0 12px 0; - color: var(--text-normal); - font-size: 16px; -} - -.mcp-next-steps-info { - display: flex; - flex-direction: column; - gap: 12px; -} - -.mcp-next-steps-info p { - margin: 0; - padding: 0; - font-size: 13px; - color: var(--text-muted); -} - -.mcp-next-steps-info strong { - display: block; - margin-bottom: 6px; - color: var(--text-normal); - font-size: 13px; -} - -.mcp-next-steps-info ul, -.mcp-next-steps-info ol { - margin: 6px 0 0 0; - padding-left: 20px; +.progress-bar-content { + flex: 1; } -.mcp-next-steps-info li { - margin: 4px 0; - font-size: 13px; +.progress-bar-text { + font-size: 0.9em; color: var(--text-normal); + margin-bottom: 4px; } -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -/* =========================== */ -/* SUGGESTER AUTOCOMPLETE */ -/* =========================== */ - -/* Suggester dropdown container */ -.suggester-container { - position: fixed; - background: var(--background-primary); - border: 1px solid var(--background-modifier-border); - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - max-height: 300px; - overflow-y: auto; - z-index: 1000; - padding: 4px; -} - -.theme-dark .suggester-container { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +.progress-bar-track { + width: 100%; + height: 8px; + background-color: var(--background-modifier-border); + border-radius: 4px; + overflow: hidden; } -/* Base suggester item styles */ -.suggester-item { +.progress-bar-fill { + height: 100%; + background-color: var(--interactive-accent); + transition: width 0.3s ease; +} + +/* Agent Management Styles */ +.agent-management-button-container { display: flex; - align-items: center; gap: 10px; - padding: 8px 12px; - cursor: pointer; - transition: background-color 0.2s ease; - border-radius: 4px; - min-height: 48px; + margin-top: 16px; } -.suggester-item:hover { - background-color: var(--background-modifier-hover); -} -.suggester-item.is-selected { - background-color: var(--background-modifier-active-hover); +/* Token Usage Component Styles */ +.token-usage-progress-bar { + /* Dynamic width set via JS */ } -/* Icon container */ -.suggester-icon { - flex-shrink: 0; - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - color: var(--text-muted); +/* Usage Settings Tab Styles */ +.usage-settings-budget-container { + margin-top: 20px; } -/* Content container */ -.suggester-content { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; +/* Diff styles for main.js compiled code */ +.diff-insert { + background: color-mix(in srgb, var(--color-green) 15%, transparent); } -/* Title text */ -.suggester-title { - font-size: 14px; - font-weight: 500; - color: var(--text-normal); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.diff-delete { + background: color-mix(in srgb, var(--color-red) 15%, transparent); } -/* Description text */ -.suggester-description { - font-size: 12px; - color: var(--text-muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} +/* ============================================================================= + CHAT SELECTORS AND PROGRESS + ============================================================================= */ -/* Badge container */ -.suggester-badge-container { +/* Model Selector */ +.model-selector { display: flex; - align-items: center; + flex-direction: column; gap: 4px; - flex-shrink: 0; } -/* Base badge */ -.suggester-badge { - padding: 2px 6px; - border-radius: 3px; +.model-selector-label { font-size: 11px; + color: var(--text-muted); font-weight: 500; white-space: nowrap; } -/* Category badge (for tools) */ -.category-badge { - background-color: var(--interactive-accent); - color: var(--text-on-accent); +.model-selector-dropdown { + width: 100%; } -/* Size badge (for notes) */ -.size-badge { - background-color: var(--background-modifier-border); - color: var(--text-muted); +.model-selector .model-select { + width: 100%; + padding: 6px 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-primary); + color: var(--text-normal); + font-size: 12px; + min-width: 0; } -/* Token info badge */ -.token-info { - background-color: var(--background-secondary-alt); - color: var(--text-muted); +.model-select:focus { + outline: none; + border-color: var(--interactive-accent); } -/* Token warning badges */ -.token-badge { - padding: 2px 6px; - border-radius: 3px; +/* Agent Selector */ +.agent-selector { + display: flex; + flex-direction: column; + gap: 4px; +} + +.agent-selector-label { font-size: 11px; - font-weight: 600; + color: var(--text-muted); + font-weight: 500; white-space: nowrap; } -.token-badge-info { - background-color: var(--color-cyan); - color: white; +.agent-selector-dropdown { + width: 100%; } -.token-badge-warning { - background-color: var(--color-yellow); +.agent-select { + width: 100%; + padding: 6px 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-primary); color: var(--text-normal); + font-size: 12px; + min-width: 0; } -.token-badge-error { - background-color: var(--color-red); - color: white; -} - -/* Specific suggester type styles */ -.note-suggester-item .suggester-icon { - color: var(--color-blue); +.agent-select:focus { + outline: none; + border-color: var(--interactive-accent); } -.agent-suggester-item .suggester-icon { - color: var(--color-purple); +.agent-info { + margin-top: 4px; + padding: 4px 6px; + border-radius: 3px; + font-size: 10px; + max-height: 60px; + overflow: hidden; } -.tool-suggester-item .suggester-icon { - color: var(--color-green); +.agent-info-default { + color: var(--text-muted); + font-style: italic; } -/* Suggester dropdown container */ -.suggestion-container { - background-color: var(--background-primary); +.agent-info-active { + background: var(--background-modifier-form-field); border: 1px solid var(--background-modifier-border); - border-radius: 8px; - box-shadow: var(--shadow-l); - max-height: 300px; - overflow-y: auto; - padding: 4px; } -/* Scrollbar styling for suggester */ -.suggestion-container::-webkit-scrollbar { - width: 8px; +.agent-info-name { + font-weight: 500; + color: var(--text-normal); + margin-bottom: 1px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.suggestion-container::-webkit-scrollbar-track { - background: transparent; +.agent-info-description { + color: var(--text-muted); + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.suggestion-container::-webkit-scrollbar-thumb { - background: var(--background-modifier-border); - border-radius: 4px; +.agent-info-prompt { + color: var(--text-faint); + font-size: 9px; + line-height: 1.2; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } -.suggestion-container::-webkit-scrollbar-thumb:hover { - background: var(--text-muted); +.context-warning-critical { + background: rgba(var(--color-red-rgb), 0.1); + color: var(--color-red); + border: 1px solid var(--color-red); } -/* Empty state */ -.suggester-empty { - padding: 16px; - text-align: center; - color: var(--text-muted); - font-size: 13px; +/* ============================================================================= + MODAL DIALOGS FOR CHAT + ============================================================================= */ + +.chat-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + backdrop-filter: blur(2px); } -/* =========================================== - Chat Settings Modal - Complete Styling - =========================================== */ +.chat-modal { + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + box-shadow: var(--shadow-l); + min-width: 400px; + max-width: 500px; + width: 90vw; +} -/* Header */ -.chat-settings-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 20px; +.chat-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--background-modifier-border); } -.chat-settings-header h2 { - margin: 0; +.chat-modal-header h3 { + margin: 0; + color: var(--text-normal); + font-size: 16px; + font-weight: 600; } -/* Button Containers */ -.chat-settings-buttons { - display: flex; - gap: 8px; +.chat-modal-close { + background: none; + border: none; + font-size: 20px; + color: var(--text-muted); + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + transition: background-color 0.2s; } -/* =========================================== - Note Picker Modal - Complete Styling - =========================================== */ - -/* Header */ -.note-picker-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 20px; +.chat-modal-close:hover { + background: var(--background-modifier-hover); } -.note-picker-header-left { - display: flex; - align-items: center; - gap: 12px; +.chat-modal-content { + padding: 20px; } -.note-picker-actions { - display: flex; - gap: 8px; +.chat-modal-content p { + margin: 0 0 16px 0; + color: var(--text-normal); } -/* Form */ -.note-picker-form { - max-width: 600px; - margin: 0 auto; +.chat-title-input { + width: 100%; + padding: 12px 16px; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + background: var(--background-primary); + color: var(--text-normal); + font-size: 14px; + transition: border-color 0.2s; } -.note-picker-search { - margin-bottom: 12px; +.chat-title-input:focus { + outline: none; + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px var(--interactive-accent-hover); } -/* File List */ -.note-picker-list { - max-height: 400px; - overflow-y: auto; - border: 1px solid var(--background-modifier-border); - border-radius: 4px; - margin-top: 12px; +.chat-title-input.chat-input-error { + border-color: var(--color-red); + box-shadow: 0 0 0 2px rgba(var(--color-red-rgb), 0.3); } -.note-picker-item { - padding: 8px 12px; - cursor: pointer; - border-bottom: 1px solid var(--background-modifier-border); - transition: background-color 0.1s ease; +.chat-modal-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + padding: 16px 20px; + border-top: 1px solid var(--background-modifier-border); } -.note-picker-item:last-child { - border-bottom: none; +.chat-btn-primary, +.chat-btn-secondary { + padding: 8px 16px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + border: none; } -.note-picker-item:hover { - background-color: var(--background-modifier-hover); +.chat-btn-primary { + background: var(--interactive-accent); + color: var(--text-on-accent); } -.note-picker-item.is-selected { - background-color: var(--background-modifier-hover); - font-weight: 600; +.chat-btn-primary:hover { + background: var(--interactive-accent-hover); } -.note-picker-empty { - padding: 20px; - text-align: center; - color: var(--text-muted); +.chat-btn-secondary { + background: var(--background-secondary); + color: var(--text-normal); + border: 1px solid var(--background-modifier-border); } -.note-picker-item.is-disabled { - opacity: 0.5; - cursor: not-allowed; +.chat-btn-secondary:hover { + background: var(--background-modifier-hover); } -/* =========================================== - Thinking Section - New Styling - =========================================== */ +/* Responsive design for cards */ +@media (max-width: 768px) { + .card-manager-grid { + grid-template-columns: 1fr; + gap: 12px; + } + + .agent-management-card-header { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .agent-management-card-actions { + align-self: flex-end; + } -.thinking-settings-section { - margin-top: 16px; - padding-top: 16px; - border-top: 1px solid var(--background-modifier-border); + .chat-modal { + min-width: 300px; + margin: 20px; + } } -/* Effort slider label */ -.thinking-effort-value { - min-width: 60px; - text-align: center; - font-weight: 500; - margin-left: 8px; +/* AI Loading Animation Styles */ +.ai-loading { + display: flex; + align-items: center; + color: var(--text-muted); + font-style: italic; + padding: 8px 0; } -/* =========================================== - Generic Utility Classes - =========================================== */ - -/* Hidden utility - used for conditional visibility */ -.is-hidden.is-hidden { - display: none; +/* AI loading in header (inline with icon) */ +.ai-loading-header { + display: inline-flex; + align-items: center; + color: var(--text-muted); + font-style: italic; + margin-left: 8px; + font-size: 0.95em; } -/* =========================================== - Chat Settings Renderer - Shared Component - =========================================== */ +.ai-loading .dots, +.ai-loading-header .dots { + display: inline-block; + width: 20px; + text-align: left; + margin-left: 2px; + animation: ai-pulse 1.5s ease-in-out infinite; +} -.chat-settings-renderer { - display: flex; - flex-direction: column; - gap: var(--space-m); +@keyframes ai-pulse { + 0%, 100% { + opacity: 0.4; + } + 50% { + opacity: 1; + } } -/* Section boxes */ -.csr-section { - background: var(--background-secondary); - border-radius: var(--radius-m); - overflow: hidden; +/* Message loading state */ +.message-content .ai-loading { + min-height: 24px; + display: flex; + align-items: center; } -.csr-section-header { - background: var(--background-modifier-hover); - padding: var(--space-s) var(--space-m); - font-weight: 600; - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.5px; - color: var(--text-muted); +/* Ensure loading messages have proper styling */ +.message.assistant .ai-loading { + background: var(--background-secondary); + border-radius: 8px; + padding: 12px; + margin: 4px 0; } -.csr-section-desc { - padding: var(--space-xs) var(--space-m) 0; - font-size: 12px; - color: var(--text-muted); - display: flex; - align-items: center; - gap: var(--space-xxs); +/* Streaming message styles */ +.streaming-content { + white-space: pre-wrap; + word-wrap: break-word; } -.csr-info-icon { - cursor: help; - opacity: 0.6; - font-size: 11px; +.streaming-cursor { + display: inline-block; + animation: cursor-blink 1s infinite; + color: var(--interactive-accent); + font-weight: bold; } -.csr-info-icon:hover { +@keyframes cursor-blink { + 0%, 50% { opacity: 1; + } + 51%, 100% { + opacity: 0; + } } -.csr-section-content { - padding: var(--space-s) var(--space-m) var(--space-m); +.final-content { + white-space: pre-wrap; + word-wrap: break-word; } -.csr-section-content .setting-item { - border: none; - padding: var(--space-xs) 0; +/* =========================== */ +/* BRANCH NAVIGATION STYLES */ +/* =========================== */ + +/* Message-level Branch Navigator - integrated into action pill */ +.message-branch-navigator { + display: flex; + align-items: center; + gap: 2px; + margin-left: -4px; +} + +.message-branch-navigator-hidden { + display: none; } -.csr-section-content .setting-item:first-child { - padding-top: var(--space-xs); +.message-branch-navigator-visible { + display: flex; } -.csr-section-content .setting-item:last-child { - padding-bottom: 0; +/* Branch indicator (X/Y counter) */ +.message-branch-indicator { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + min-width: 24px; + text-align: center; + user-select: none; + letter-spacing: 0.3px; + padding: 0 2px; } -.nexus-voice-section-desc { - padding-left: 0; - padding-right: 0; +/* Branch buttons - extend message-action-btn styles */ +.message-action-btn.message-branch-prev svg, +.message-action-btn.message-branch-next svg { + width: 12px; + height: 12px; + stroke-width: 2.5; } -.nexus-voice-group-label { - margin-top: var(--space-m); - padding-top: var(--space-s); - border-top: 1px solid var(--background-modifier-border); - color: var(--text-faint); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.5px; - text-transform: uppercase; +/* Subtle hover effect for branch buttons */ +.message-action-btn.message-branch-prev:hover:not(:disabled), +.message-action-btn.message-branch-next:hover:not(:disabled) { + color: var(--text-accent); } -.nexus-voice-section-desc + .nexus-voice-group-label { - margin-top: var(--space-s); - border-top: none; +/* Disabled state for branch buttons */ +.message-action-btn.message-branch-prev:disabled, +.message-action-btn.message-branch-next:disabled { + opacity: 0.3; + cursor: not-allowed; + color: var(--text-faint); } -.nexus-voice-warning { - margin-top: var(--space-s); - padding: var(--space-s); - border: 1px solid var(--background-modifier-error); - border-radius: var(--radius-s); - background: var(--background-modifier-error); - color: var(--text-error); - font-size: 12px; +/* Branch nav buttons: sized close to the 12px chevron so there's no phantom + padding inside. Chevron edge-aligned toward the indicator (flex-end/start) + eliminates the remaining ~2px wasted space. Mobile bumps to 24 for tap. */ +.message-action-btn.message-branch-prev, +.message-action-btn.message-branch-next { + width: 16px; + height: 16px; } - -/* Effort slider row */ -.csr-effort-row { - padding-left: var(--space-s); - border-left: 2px solid var(--background-modifier-border); - margin-top: var(--space-xs); +/* Extra class repeat outweighs Obsidian's .clickable-icon justify-content + default, which otherwise re-centers the chevron. */ +.message-action-btn.message-action-btn.message-branch-prev { + justify-content: flex-end; +} +.message-action-btn.message-action-btn.message-branch-next { + justify-content: flex-start; +} +/* Mobile touch-target override — restore comfortable tap size but keep the + edge-align so the chevrons still cluster tightly to the indicator. */ +body.is-mobile .message-action-btn.message-branch-prev, +body.is-mobile .message-action-btn.message-branch-next { + width: 24px; + height: 24px; } -.csr-effort-value { - min-width: 50px; - text-align: center; - font-weight: 500; - font-size: 12px; - color: var(--text-muted); - margin-left: var(--space-s); +/* Mobile responsive */ +@media (max-width: 768px) { + .message-branch-navigator { + gap: 2px; + } + + .message-branch-indicator { + font-size: 10px; + min-width: 20px; + } } -.csr-temp-value { - min-width: 30px; - text-align: center; - font-weight: 500; - font-size: 12px; - color: var(--text-muted); - margin-left: var(--space-s); +/* Conversation-level Branch Navigator Container (Legacy) */ +.branch-navigator-container { + margin-left: auto; /* Push to right side of header */ } -.csr-provider-warning { - margin-top: var(--space-s); - padding: var(--space-s) var(--space-m); - border: 1px solid var(--background-modifier-border); - border-left: 3px solid var(--text-warning); - border-radius: var(--radius-s); - background: var(--background-primary); +.branch-navigator { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border-radius: 6px; + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + transition: all 0.2s ease; } -.csr-provider-warning-title { - font-size: 12px; - font-weight: 600; - color: var(--text-warning); - margin-bottom: var(--space-xxs); +.branch-navigator-hidden { + display: none; } -.csr-provider-warning-text { - font-size: 12px; - line-height: 1.4; - color: var(--text-muted); +.branch-navigator-visible { + display: flex; + animation: branch-appear 0.3s ease; } -/* Context notes */ -.csr-notes-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-s) 0 var(--space-xs); - margin-top: var(--space-xs); - border-top: 1px solid var(--background-modifier-border); +/* Branch Navigation Buttons */ +.branch-nav-button { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + width: 20px; + height: 20px; } -.csr-notes-header span { - font-size: 13px; - color: var(--text-normal); +.branch-nav-button:hover:not(:disabled) { + background: var(--background-modifier-hover); + color: var(--text-normal); } -.csr-add-btn { - background: transparent; - border: 1px solid var(--background-modifier-border); - border-radius: var(--radius-s); - padding: var(--space-xxs) var(--space-s); - font-size: 11px; - color: var(--text-muted); - cursor: pointer; +.branch-nav-button:active:not(:disabled) { + background: var(--background-modifier-active); + transform: scale(0.95); } -.csr-add-btn:hover { - background: var(--background-modifier-hover); - color: var(--text-normal); +.branch-nav-button:disabled, +.branch-nav-button.disabled { + opacity: 0.3; + cursor: not-allowed; + color: var(--text-faint); } -.csr-notes-list { - margin-top: var(--space-xs); +.branch-nav-button svg { + width: 12px; + height: 12px; + stroke-width: 2.5; } -.csr-notes-empty { - font-size: 12px; - color: var(--text-faint); - padding: var(--space-xs) 0; +/* Branch Indicator */ +.branch-indicator { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + min-width: 24px; + text-align: center; + user-select: none; + letter-spacing: 0.5px; } -.csr-note-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-xs) var(--space-s); - background: var(--background-primary); - border-radius: var(--radius-s); - margin-bottom: var(--space-xs); +/* Header Integration */ +.chat-header .branch-navigator-container { + opacity: 0; + transform: translateX(10px); + transition: all 0.3s ease; } -.csr-note-path { - font-size: 12px; - color: var(--text-normal); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1; +.chat-header:hover .branch-navigator-container, +.chat-header .branch-navigator-container.branch-navigator-visible { + opacity: 1; + transform: translateX(0); } -.csr-note-remove { - background: transparent; - border: none; - color: var(--text-muted); - cursor: pointer; - padding: 0 var(--space-xs); - font-size: 16px; - line-height: 1; +/* Branch Navigator Animations */ +@keyframes branch-appear { + from { + opacity: 0; + transform: scale(0.8) translateX(10px); + } + to { + opacity: 1; + transform: scale(1) translateX(0); + } } -.csr-note-remove:hover { - color: var(--text-error); +/* Dark Mode Adjustments */ +.theme-dark .branch-navigator { + background: var(--background-primary-alt); + border-color: var(--background-modifier-border-hover); } -/* =========================================== - Nexus Settings UI - Tab-Based Navigation - =========================================== */ +.theme-dark .branch-nav-button:hover:not(:disabled) { + background: var(--background-modifier-hover); + color: var(--text-accent); +} -/* Header section */ -.nexus-settings-header { - padding: 16px 0; - border-bottom: 1px solid var(--background-modifier-border); - margin-bottom: 16px; +/* Mobile Responsive */ +@media (max-width: 768px) { + .branch-navigator { + gap: 6px; + padding: 3px 6px; + } + + .branch-nav-button { + width: 18px; + height: 18px; + } + + .branch-nav-button svg { + width: 10px; + height: 10px; + } + + .branch-indicator { + font-size: 10px; + min-width: 20px; + } } +/* =========================== */ +/* CHAT SETTINGS MODAL STYLES */ +/* =========================== */ -.nexus-settings-header h2 { - margin: 0 0 4px 0; - font-size: 1.5em; +.modal.chat-settings-modal { + width: 600px; + max-width: calc(100vw - 40px); } -.nexus-settings-desc { +.chat-settings-save-status { + flex: 1 1 auto; + margin: 0; color: var(--text-muted); - margin: 0 0 12px 0; + font-size: var(--font-ui-small); } -.nexus-settings-version-row { - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; -} +/* =========================== */ +/* MCP SETUP UI STYLES */ +/* =========================== */ -.nexus-settings-version { - color: var(--text-muted); - font-size: 0.9em; +/* Setup sections */ +.mcp-setup-section { + margin: 20px 0; + padding: 15px; + background: var(--background-secondary); + border-radius: 6px; + border: 1px solid var(--background-modifier-border); } -.nexus-update-badge { - background: var(--interactive-accent); - color: var(--text-on-accent); - padding: 2px 8px; - border-radius: 10px; - font-size: 0.8em; +.mcp-setup-separator { + margin: 20px 0; } -/* Back button */ -.nexus-back-button { - display: inline-flex; - align-items: center; - gap: var(--space-xs); - color: var(--text-normal); - cursor: pointer; - margin-bottom: var(--space-l); - padding: var(--space-s) var(--space-m); - border-radius: var(--radius-s); - background: var(--background-modifier-hover); - border: 1px solid var(--background-modifier-border); - font-size: 0.9em; +.mcp-setup-or { + text-align: center; + margin: 10px 0; + color: var(--text-muted); font-weight: 500; - transition: all 0.15s ease; -} - -.nexus-back-button:hover { - background: var(--background-modifier-border); - border-color: var(--background-modifier-border-hover); } -.nexus-back-button:active { - transform: translateY(1px); +/* Config status */ +.mcp-config-status { + margin: 15px 0; + padding: 12px; + border-radius: 5px; + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); } -.nexus-back-button-icon { +.mcp-status { display: flex; - align-items: center; + flex-direction: column; + gap: 8px; } -.nexus-back-button-icon svg { - display: block; +.mcp-status span { + font-weight: 500; + font-size: 14px; } -/* Skeleton loading cards */ -@keyframes nexus-skeleton-pulse { - 0%, 100% { - opacity: 0.4; - } - 50% { - opacity: 0.7; - } +.mcp-status-hint { + font-size: 12px; + color: var(--text-muted); + margin: 0; + padding: 0; } -.nexus-skeleton-card { - background: var(--background-secondary); - border: 1px solid var(--background-modifier-border); - border-radius: 8px; - padding: 16px; - animation: nexus-skeleton-pulse 1.5s ease-in-out infinite; +.mcp-status-neutral { + color: var(--text-muted); } -.nexus-skeleton-title { - height: 20px; - width: 60%; - background: var(--background-modifier-border); - border-radius: 4px; - margin-bottom: 12px; +.mcp-status-warning { + color: var(--text-warning); } -.nexus-skeleton-description { - height: 14px; - width: 80%; - background: var(--background-modifier-border); - border-radius: 4px; - margin-bottom: 16px; +.mcp-status-success { + color: var(--text-success); } -.nexus-skeleton-actions { - height: 24px; - width: 40%; - background: var(--background-modifier-border); +/* Info box */ +.mcp-info-box { + margin: 15px 0; + padding: 12px; + border-left: 3px solid var(--interactive-accent); + background-color: var(--background-secondary); border-radius: 4px; } -/* List items */ -.nexus-list-item { - display: flex; - align-items: center; - padding: 12px 16px; - border-radius: 6px; - cursor: pointer; - margin-bottom: 4px; - transition: background-color 0.1s ease; -} - -.nexus-list-item:hover { - background: var(--background-modifier-hover); +.mcp-info-box strong { + display: block; + margin-bottom: 8px; + color: var(--text-normal); } -.nexus-list-item-content { - flex: 1; - min-width: 0; +.mcp-info-box ol, +.mcp-info-box ul { + margin: 8px 0 0 0; + padding-left: 20px; } -.nexus-list-item-title { - font-weight: 500; - margin-bottom: 2px; +.mcp-info-box li { + margin: 6px 0; + color: var(--text-normal); + font-size: 13px; } -.nexus-list-item-subtitle { - color: var(--text-muted); - font-size: 0.85em; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +/* Next steps notification */ +.mcp-next-steps { + margin: 15px 0; + padding: 15px; + border-radius: 5px; + background-color: var(--background-primary-alt); + border: 1px solid var(--interactive-accent); + animation: fadeIn 0.3s ease-in; } -/* Status badges */ -.nexus-status-badge { - font-size: 0.7rem; - padding: 2px 8px; - border-radius: 10px; - margin-left: auto; - flex-shrink: 0; +.mcp-next-steps h5 { + margin: 0 0 12px 0; + color: var(--text-normal); + font-size: 16px; } -.nexus-status-badge.configured { - background: var(--background-modifier-success); - color: var(--text-on-accent); +.mcp-next-steps-info { + display: flex; + flex-direction: column; + gap: 12px; } -.nexus-status-badge.not-configured { +.mcp-next-steps-info p { + margin: 0; + padding: 0; + font-size: 13px; color: var(--text-muted); - background: var(--background-modifier-border); } -/* Provider defaults section */ -.nexus-provider-defaults { - background: var(--background-secondary); - padding: 16px; - border-radius: 8px; - margin-bottom: 24px; +.mcp-next-steps-info strong { + display: block; + margin-bottom: 6px; + color: var(--text-normal); + font-size: 13px; } -/* Provider group titles */ -.nexus-provider-group-title { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-muted); - margin: 24px 0 8px 0; - padding: 0 4px; +.mcp-next-steps-info ul, +.mcp-next-steps-info ol { + margin: 6px 0 0 0; + padding-left: 20px; } -/* Get Started intro section */ -.nexus-intro { - margin-bottom: 16px; +.mcp-next-steps-info li { + margin: 4px 0; + font-size: 13px; + color: var(--text-normal); } -.nexus-intro-desc { - color: var(--text-muted); - line-height: 1.6; - margin-bottom: 20px; +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } } -.nexus-capabilities h4 { - margin: 0 0 12px 0; - font-size: 0.95em; -} +/* =========================== */ +/* SUGGESTER AUTOCOMPLETE */ +/* =========================== */ -.nexus-capability-list { - list-style: none; - padding: 0; - margin: 0; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px 16px; +/* Suggester dropdown container */ +.suggester-container { + position: fixed; + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + max-height: 300px; + overflow-y: auto; + z-index: 1000; + padding: 4px; } -@media (max-width: 500px) { - .nexus-capability-list { - grid-template-columns: 1fr; - } +.theme-dark .suggester-container { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); } -.nexus-capability-list li { +/* Base suggester item styles */ +.suggester-item { display: flex; align-items: center; - gap: 8px; - font-size: 0.9em; - color: var(--text-normal); + gap: 10px; + padding: 8px 12px; + cursor: pointer; + transition: background-color 0.2s ease; + border-radius: 4px; + min-height: 48px; } -.nexus-cap-icon { - font-size: 1.1em; - width: 24px; - text-align: center; - flex-shrink: 0; +.suggester-item:hover { + background-color: var(--background-modifier-hover); } -.nexus-divider { - border: none; - border-top: 1px solid var(--background-modifier-border); - margin: 24px 0; +.suggester-item.is-selected { + background-color: var(--background-modifier-active-hover); } -/* Get Started setup paths */ -.nexus-setup-paths { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 16px; - margin: 24px 0; +/* Icon container */ +.suggester-icon { + flex-shrink: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); } -@media (max-width: 500px) { - .nexus-setup-paths { - grid-template-columns: 1fr; - } +/* Content container */ +.suggester-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; } -.nexus-setup-path { - padding: 24px; - border: 1px solid var(--background-modifier-border); - border-radius: 8px; - cursor: pointer; - text-align: center; - transition: all 0.15s ease; +/* Title text */ +.suggester-title { + font-size: 14px; + font-weight: 500; + color: var(--text-normal); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.nexus-setup-path:hover { - border-color: var(--interactive-accent); - background: var(--background-modifier-hover); +/* Description text */ +.suggester-description { + font-size: 12px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.nexus-setup-path-icon { - font-size: 2rem; - margin-bottom: 12px; +/* Badge container */ +.suggester-badge-container { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; } -.nexus-setup-path-title { - font-weight: 600; - margin-bottom: 4px; +/* Base badge */ +.suggester-badge { + padding: 2px 6px; + border-radius: 3px; + font-size: 11px; + font-weight: 500; + white-space: nowrap; } -.nexus-setup-path-desc { - color: var(--text-muted); - font-size: 0.85em; +/* Category badge (for tools) */ +.category-badge { + background-color: var(--interactive-accent); + color: var(--text-on-accent); } -/* MCP config output */ -.nexus-mcp-output { - background: var(--background-primary-alt); - padding: 16px; - border-radius: 6px; - overflow-x: auto; - font-family: var(--font-monospace); - font-size: 0.85em; - white-space: pre-wrap; - word-break: break-all; +/* Size badge (for notes) */ +.size-badge { + background-color: var(--background-modifier-border); + color: var(--text-muted); } -/* MCP config input */ -.nexus-mcp-input { - width: 100%; - font-family: var(--font-monospace); - font-size: 0.85em; +/* Token info badge */ +.token-info { + background-color: var(--background-secondary-alt); + color: var(--text-muted); } -/* Config path display */ -.nexus-config-path { - background: var(--background-primary-alt); - padding: 12px 16px; - border-radius: 6px; - margin-bottom: 12px; +/* Token warning badges */ +.token-badge { + padding: 2px 6px; + border-radius: 3px; + font-size: 11px; + font-weight: 600; + white-space: nowrap; } -.nexus-config-path code { - font-family: var(--font-monospace); - font-size: 0.9em; - word-break: break-all; +.token-badge-info { + background-color: var(--color-cyan); + color: white; } -/* Setup steps */ -.nexus-setup-step { - margin-bottom: 24px; - padding-bottom: 16px; - border-bottom: 1px solid var(--background-modifier-border); +.token-badge-warning { + background-color: var(--color-yellow); + color: var(--text-normal); } -.nexus-setup-step:last-child { - border-bottom: none; +.token-badge-error { + background-color: var(--color-red); + color: white; } -.nexus-setup-step h4 { - margin: 0 0 8px 0; +/* Specific suggester type styles */ +.note-suggester-item .suggester-icon { + color: var(--color-blue); } -/* Setup instructions list */ -.nexus-setup-instructions { - margin: 12px 0; - padding-left: 24px; +.agent-suggester-item .suggester-icon { + color: var(--color-purple); } -.nexus-setup-instructions li { - margin-bottom: 8px; - line-height: 1.5; +.tool-suggester-item .suggester-icon { + color: var(--color-green); } -/* MCP Setup - Compact Row Layout */ -.nexus-mcp-row { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - background: var(--background-secondary); +/* Suggester dropdown container */ +.suggestion-container { + background-color: var(--background-primary); + border: 1px solid var(--background-modifier-border); border-radius: 8px; - margin-top: 12px; + box-shadow: var(--shadow-l); + max-height: 300px; + overflow-y: auto; + padding: 4px; } -.nexus-mcp-status { - font-weight: 500; - color: var(--text-muted); +/* Scrollbar styling for suggester */ +.suggestion-container::-webkit-scrollbar { + width: 8px; } -.nexus-mcp-status.nexus-mcp-warning { - color: var(--text-error); +.suggestion-container::-webkit-scrollbar-track { + background: transparent; } -.nexus-mcp-status.nexus-mcp-success { - color: var(--text-success); +.suggestion-container::-webkit-scrollbar-thumb { + background: var(--background-modifier-border); + border-radius: 4px; } -.nexus-cli-providers { - margin-top: 10px; - padding: 10px 16px; - background: var(--background-secondary); - border-radius: 8px; +.suggestion-container::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); } -.nexus-cli-provider-row { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 0; +/* Empty state */ +.suggester-empty { + padding: 16px; + text-align: center; + color: var(--text-muted); + font-size: 13px; } -.nexus-cli-provider-row input[type="checkbox"] { - margin: 0; -} +/* =========================================== + Note Picker Modal - Complete Styling + =========================================== */ -.nexus-cli-provider-row label { +/* Header */ +.note-picker-header { display: flex; - align-items: baseline; - gap: 4px; - cursor: pointer; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; } -.nexus-cli-provider-hint { - color: var(--text-muted); - font-size: var(--font-smaller); +.note-picker-header-left { + display: flex; + align-items: center; + gap: 12px; } -.nexus-mcp-actions { +.note-picker-actions { display: flex; gap: 8px; } -.nexus-mcp-actions button { - padding: 6px 12px; - border-radius: 4px; - font-size: 0.85em; -} - -.nexus-mcp-help { - color: var(--text-muted); - font-size: 0.85em; - margin: 8px 0 0 0; -} - -.nexus-mcp-node-warning { - border: 1px solid var(--background-modifier-error); - margin-bottom: 4px; -} - -/* Manual Config Section */ -.nexus-manual-config { - margin-top: 16px; +/* Form */ +.note-picker-form { + max-width: 600px; + margin: 0 auto; } -.nexus-manual-config h4 { - margin: 0 0 8px 0; - font-size: 1em; - color: var(--text-normal); +.note-picker-search { + margin-bottom: 12px; } -.nexus-config-code { - background: var(--background-primary-alt); +/* File List */ +.note-picker-list { + max-height: 400px; + overflow-y: auto; border: 1px solid var(--background-modifier-border); - border-radius: var(--radius-s); - padding: 12px; - margin: 8px 0; - overflow-x: auto; - font-size: 0.85em; -} - -.nexus-config-code code { - font-family: var(--font-monospace); - white-space: pre; -} - -.nexus-manual-config .nexus-config-path { + border-radius: 4px; margin-top: 12px; - background: transparent; - padding: 0; - border-radius: 0; } -.nexus-manual-config .nexus-config-path a { - color: var(--text-accent); - text-decoration: none; - font-family: var(--font-monospace); - font-size: 0.85em; +.note-picker-item { + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid var(--background-modifier-border); + transition: background-color 0.1s ease; } -.nexus-manual-config .nexus-config-path a:hover { - text-decoration: underline; +.note-picker-item:last-child { + border-bottom: none; } -/* MCP setup — connector.js explainer */ -.nexus-connector-explainer { - display: flex; - gap: 12px; - padding: 14px 16px; - background: var(--background-secondary); - border: 1px solid var(--background-modifier-border); - border-radius: 8px; - margin: 12px 0 20px; +.note-picker-item:hover { + background-color: var(--background-modifier-hover); } -.nexus-connector-explainer .nx-icon { - font-size: 1.3em; - line-height: 1.2; - flex-shrink: 0; +.note-picker-item.is-selected { + background-color: var(--background-modifier-hover); + font-weight: 600; } -.nexus-connector-explainer p { - margin: 0; - font-size: 0.88em; +.note-picker-empty { + padding: 20px; + text-align: center; color: var(--text-muted); - line-height: 1.5; } -.nexus-connector-explainer code { - font-family: var(--font-monospace); - background: var(--background-primary-alt); - padding: 1px 5px; - border-radius: 3px; - font-size: 0.9em; - color: var(--text-normal); +.note-picker-item.is-disabled { + opacity: 0.5; + cursor: not-allowed; } -/* MCP setup — per-agent block */ -.nexus-agent-block { - margin-bottom: 20px; -} +/* =========================================== + Generic Utility Classes + =========================================== */ -.nexus-agent-block > h4 { - display: flex; - align-items: center; - margin: 0 0 4px; +/* Hidden utility - used for conditional visibility */ +.is-hidden.is-hidden { + display: none; } -.nx-agent-logo { - font-size: 1.1em; -} +/* =========================================== + Section boxes (csr-section) + Remaining consumer: DataTab (Export, Storage, Maintenance). + =========================================== */ -/* MCP setup — "just connected" confirmation card */ -.nexus-connect-result { - border: 1px solid var(--background-modifier-border); - border-left: 3px solid var(--text-success); - border-radius: 8px; - padding: 14px 16px; - margin-top: 8px; +.csr-section { background: var(--background-secondary); + border-radius: var(--radius-m); + overflow: hidden; } -.nexus-connect-result .nx-result-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 10px; -} - -.nexus-connect-result .nx-result-title { +.csr-section-header { + background: var(--background-modifier-hover); + padding: var(--space-s) var(--space-m); font-weight: 600; - color: var(--text-success); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); } -.nx-result-actions { +.csr-section-desc { + padding: var(--space-xs) var(--space-m) 0; + font-size: 12px; + color: var(--text-muted); display: flex; - gap: 8px; - flex-wrap: wrap; + align-items: center; + gap: var(--space-xxs); } -.nx-checklist { - list-style: none; - margin: 0; - padding: 0; +.csr-section-content { + padding: var(--space-s) var(--space-m) var(--space-m); } -.nx-checklist li { - display: flex; - align-items: flex-start; - gap: 8px; - padding: 5px 0; - font-size: 0.88em; - line-height: 1.45; +.csr-section-content .setting-item { + border: none; + padding: var(--space-xs) 0; } -.nx-checklist .nx-check { - color: var(--text-success); - flex-shrink: 0; +.csr-section-content .setting-item:first-child { + padding-top: var(--space-xs); } -.nx-checklist .nx-next { - color: var(--text-accent); - flex-shrink: 0; +.csr-section-content .setting-item:last-child { + padding-bottom: 0; } -.nx-checklist .nx-label { - color: var(--text-muted); -} +/* =========================================== + Nexus Settings UI - Tab-Based Navigation + =========================================== */ -.nx-checklist code { - font-family: var(--font-monospace); - font-size: 0.85em; - background: var(--background-primary-alt); - padding: 1px 5px; - border-radius: 3px; - word-break: break-all; - color: var(--text-normal); +/* Header section */ +.nexus-settings-header { + padding: 16px 0; + border-bottom: 1px solid var(--background-modifier-border); + margin-bottom: 16px; } -/* MCP setup — manual disclosure with numbered steps */ -.nexus-manual-details { - margin-top: 10px; - border: 1px solid var(--background-modifier-border); - border-radius: 8px; - overflow: hidden; +.nexus-settings-header h2 { + margin: 0 0 4px 0; + font-size: 1.5em; } -.nexus-manual-details > summary { - cursor: pointer; - list-style: none; - padding: 10px 14px; - font-size: 0.9em; +.nexus-settings-desc { color: var(--text-muted); - user-select: none; + margin: 0 0 12px 0; +} + +.nexus-settings-version-row { display: flex; align-items: center; - gap: 8px; + gap: 12px; + flex-wrap: wrap; } -.nexus-manual-details > summary::-webkit-details-marker { - display: none; +.nexus-settings-version { + color: var(--text-muted); + font-size: 0.9em; } -.nexus-manual-details > summary::before { - content: "\25B8"; - transition: transform 0.15s ease; +.nexus-update-badge { + background: var(--interactive-accent); + color: var(--text-on-accent); + padding: 2px 8px; + border-radius: 10px; + font-size: 0.8em; } -.nexus-manual-details[open] > summary::before { - transform: rotate(90deg); +/* Back button */ +.nexus-back-button { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + color: var(--text-normal); + cursor: pointer; + margin-bottom: var(--space-l); + padding: var(--space-s) var(--space-m); + border-radius: var(--radius-s); + background: var(--background-modifier-hover); + border: 1px solid var(--background-modifier-border); + font-size: 0.9em; + font-weight: 500; + transition: all 0.15s ease; } -.nexus-manual-details > summary:hover { - color: var(--text-normal); +.nexus-back-button:hover { + background: var(--background-modifier-border); + border-color: var(--background-modifier-border-hover); } -.nexus-manual-body { - padding: 4px 16px 16px; +.nexus-back-button:active { + transform: translateY(1px); } -.nx-step { +.nexus-back-button-icon { display: flex; - gap: 12px; - padding: 12px 0; + align-items: center; } -.nx-step + .nx-step { - border-top: 1px solid var(--background-modifier-border); +.nexus-back-button-icon svg { + display: block; } -.nx-step-num { - flex-shrink: 0; - width: 22px; - height: 22px; - border-radius: 50%; - background: var(--interactive-accent); - color: var(--text-on-accent); - display: flex; - align-items: center; - justify-content: center; - font-size: 0.78em; - font-weight: 600; - margin-top: 1px; +/* Skeleton loading cards */ +@keyframes nexus-skeleton-pulse { + 0%, 100% { + opacity: 0.4; + } + 50% { + opacity: 0.7; + } } -.nx-step-body { - flex: 1; - min-width: 0; +.nexus-skeleton-card { + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + padding: 16px; + animation: nexus-skeleton-pulse 1.5s ease-in-out infinite; +} + +.nexus-skeleton-title { + height: 20px; + width: 60%; + background: var(--background-modifier-border); + border-radius: 4px; + margin-bottom: 12px; } -.nx-step-body h5 { - margin: 0 0 4px; - font-size: 0.92em; +.nexus-skeleton-description { + height: 14px; + width: 80%; + background: var(--background-modifier-border); + border-radius: 4px; + margin-bottom: 16px; } -.nx-step-body p { - margin: 0 0 8px; - font-size: 0.85em; +.nexus-skeleton-actions { + height: 24px; + width: 40%; + background: var(--background-modifier-border); + border-radius: 4px; } -.nx-pathrow { +/* List items */ +.nexus-list-item { display: flex; align-items: center; - gap: 8px; - margin-top: 6px; - flex-wrap: wrap; + padding: 12px 16px; + border-radius: 6px; + cursor: pointer; + margin-bottom: 4px; + transition: background-color 0.1s ease; } -.nx-inline-path { - font-family: var(--font-monospace); - font-size: 0.82em; - color: var(--text-accent); - word-break: break-all; +.nexus-list-item:hover { + background: var(--background-modifier-hover); } -/* Local CLI installed but unresolvable in the user's shell — actionable fix panel */ -.nexus-cli-pathfix { - margin-top: 10px; - padding: 10px 12px; - border: 1px solid var(--background-modifier-border); - border-left: 3px solid var(--text-warning); - border-radius: var(--radius-s); - background-color: var(--background-secondary); +.nexus-list-item-content { + flex: 1; + min-width: 0; } -.nexus-cli-pathfix p { - margin: 0 0 4px 0; +.nexus-list-item-title { + font-weight: 500; + margin-bottom: 2px; } -.nexus-cli-pathfix .nx-inline-path { - user-select: all; +.nexus-list-item-subtitle { + color: var(--text-muted); + font-size: 0.85em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.nexus-cli-pathfix button { - margin-top: 8px; +/* Provider defaults section */ +.nexus-provider-defaults { + background: var(--background-secondary); + padding: 16px; + border-radius: 8px; + margin-bottom: 24px; +} + +/* Provider group titles */ +.nexus-provider-group-title { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin: 24px 0 8px 0; + padding: 0 4px; } /* Reasoning - Compact Inline */ diff --git a/tests/helpers/settingsDom.ts b/tests/helpers/settingsDom.ts new file mode 100644 index 000000000..6c4d7f8d6 --- /dev/null +++ b/tests/helpers/settingsDom.ts @@ -0,0 +1,106 @@ +/** + * Helpers for reading the settings layout out of a FakeElement tree + * (tests/mocks/obsidian/dom.ts). Everything here reads the rendered markup — + * class names, attributes and text — never renderer internals. + */ +import { FakeElement } from '../mocks/obsidian/dom'; + +/** Direct `.setting-item` rows of a `setting-items` list, in order. */ +export function rowsOf(itemsEl: FakeElement): FakeElement[] { + return itemsEl.children.filter(child => child.hasClass('setting-item')); +} + +export function rowName(row: FakeElement): string { + return row.querySelector('.setting-item-name')?.textContent ?? ''; +} + +export function rowDesc(row: FakeElement): string { + return row.querySelector('.setting-item-description')?.textContent ?? ''; +} + +export function rowNames(itemsEl: FakeElement): string[] { + return rowsOf(itemsEl).map(rowName); +} + +/** The heading-less top SettingGroup's list. */ +export function topItems(container: FakeElement): FakeElement { + const group = container.children.find(child => child.hasClass('setting-group')); + const items = group?.querySelector('.setting-items'); + if (!items) throw new Error('No top setting group'); + return items; +} + +export function accordions(container: FakeElement): FakeElement[] { + return container.children.filter(child => child.hasClass('nexus-accordion')); +} + +export function foldTitles(container: FakeElement): string[] { + return accordions(container).map(fold => fold.querySelector('.nexus-accordion-title')?.textContent ?? ''); +} + +export function fold(container: FakeElement, title: string): FakeElement { + const match = accordions(container).find(item => item.querySelector('.nexus-accordion-title')?.textContent === title); + if (!match) throw new Error(`No fold titled ${title}; have ${foldTitles(container).join(', ')}`); + return match; +} + +export function foldItems(container: FakeElement, title: string): FakeElement { + const items = fold(container, title).querySelector('.nexus-accordion-content .setting-items'); + if (!items) throw new Error(`Fold ${title} has no setting-items`); + return items; +} + +export function foldSummary(container: FakeElement, title: string): string { + return fold(container, title).querySelector('.nexus-accordion-summary')?.textContent ?? ''; +} + +export function foldToggle(container: FakeElement, title: string): FakeElement { + const toggle = fold(container, title).querySelector('.nexus-accordion-toggle'); + if (!toggle) throw new Error(`Fold ${title} has no toggle`); + return toggle; +} + +export function isFoldOpen(container: FakeElement, title: string): boolean { + return fold(container, title).hasClass('is-open'); +} + +export function findRow(itemsEl: FakeElement, name: string): FakeElement { + const row = rowsOf(itemsEl).find(item => rowName(item) === name); + if (!row) throw new Error(`No row named ${name}; have ${rowNames(itemsEl).join(', ')}`); + return row; +} + +/** An app stand-in with vault-scoped local storage, as FoldStateStore uses. */ +export function localStorageApp(initial: Record = {}) { + const store = new Map(Object.entries(initial)); + return { + vault: {}, + workspace: {}, + store, + loadLocalStorage: jest.fn((key: string) => (store.has(key) ? JSON.parse(JSON.stringify(store.get(key))) : null)), + saveLocalStorage: jest.fn((key: string, value: unknown) => { store.set(key, JSON.parse(JSON.stringify(value))); }) + }; +} + +export const flush = (): Promise => new Promise(resolve => setImmediate(resolve)); + +/** The control element of a row's first dropdown/toggle/slider/button. */ +export function control(row: FakeElement, selector: 'select' | '.checkbox-container' | 'input.slider' | 'button' | '.extra-setting-button'): FakeElement { + const el = row.querySelector(`.setting-item-control ${selector}`) ?? row.querySelector(selector); + if (!el) throw new Error(`Row ${rowName(row)} has no ${selector}`); + return el; +} + +/** Pick a dropdown value as the user would (a 'change' event). */ +export function choose(select: FakeElement, value: string): void { + select.value = value; + select.dispatch('change'); +} + +export function optionValues(select: FakeElement): string[] { + return select.children.filter(child => child.tagName === 'OPTION').map(option => option.value); +} + +export function optionLabels(select: FakeElement): string[] { + return select.children.filter(child => child.tagName === 'OPTION').map(option => option.textContent); +} diff --git a/tests/helpers/settingsRendererFixtures.ts b/tests/helpers/settingsRendererFixtures.ts new file mode 100644 index 000000000..3b5d87747 --- /dev/null +++ b/tests/helpers/settingsRendererFixtures.ts @@ -0,0 +1,108 @@ +/** + * Fixtures for tests that render the full settings layout. + * + * Test files wire these in with jest.mock factories, for example: + * + * jest.mock('../../src/services/llm/providers/ProviderManager', () => + * jest.requireActual('../helpers/settingsRendererFixtures').providerManagerModule()); + * + * The model table mirrors the registry facts the layout depends on: which + * models reason (Thinking row), which reject temperature (Creativity row), + * their context windows (Context window row and summaries) and display names + * (summaries). Tests about capabilities themselves use the real registry. + */ +import type { ChatSettings } from '../../src/components/shared/ChatSettingsRenderer'; +import type { LLMProviderSettings } from '../../src/types/llm/ProviderTypes'; + +export interface FixtureModel { + provider: string; + id: string; + name: string; + contextWindow: number; + supportsThinking: boolean; + /** False when the registry says the model rejects temperature. */ + supportsSamplingParams?: boolean; +} + +export const FIXTURE_MODELS: FixtureModel[] = [ + { provider: 'anthropic', id: 'claude-opus-5-5', name: 'Claude Opus 5.5', contextWindow: 1_000_000, supportsThinking: true, supportsSamplingParams: false }, + { provider: 'anthropic', id: 'claude-haiku-5-5', name: 'Claude Haiku 5.5', contextWindow: 1_000_000, supportsThinking: true, supportsSamplingParams: false }, + { provider: 'anthropic', id: 'claude-opus-4-6', name: 'Claude Opus 4.6', contextWindow: 200_000, supportsThinking: true }, + { provider: 'mistral', id: 'mistral-large-latest', name: 'Mistral Large Latest', contextWindow: 128_000, supportsThinking: false }, + { provider: 'mistral', id: 'magistral-medium-latest', name: 'Magistral Medium', contextWindow: 40_000, supportsThinking: true }, + // A small local model: below the smallest Context window preset. + { provider: 'lmstudio', id: 'tiny-local', name: 'Tiny Local', contextWindow: 4096, supportsThinking: false } +]; + +export function providerManagerModule() { + return { + LLMProviderManager: jest.fn().mockImplementation(() => ({ + getModelsForProvider: jest.fn(async (provider: string) => FIXTURE_MODELS + .filter(model => model.provider === provider) + .map(model => ({ ...model, capabilities: { supportsThinking: model.supportsThinking } }))), + updateSettings: jest.fn() + })) + }; +} + +export function staticModelsModule() { + const service = { + findModel: jest.fn((provider: string, id: string) => { + const model = FIXTURE_MODELS.find(item => item.provider === provider && item.id === id); + return model ? { ...model, capabilities: { supportsThinking: model.supportsThinking } } : undefined; + }), + getModelsForProvider: jest.fn(() => []) + }; + return { StaticModelsService: { getInstance: jest.fn(() => service) } }; +} + +export function imageServiceModule() { + return { + ImageGenerationService: jest.fn().mockImplementation(() => ({ + getModelsForProvider: jest.fn(async (provider: string) => provider === 'google' + ? [{ id: 'gemini-3.1-flash-image', name: 'Gemini 3.1 Flash Image' }] + : [{ id: `${provider}-image`, name: `${provider} image` }]), + updateSettings: jest.fn() + })) + }; +} + +export function ingestCapabilityModule() { + const actual = jest.requireActual('../../src/agents/ingestManager/tools/services/IngestCapabilityService'); + return { + ...actual, + getIngestCapabilityOptions: jest.fn(async () => ({ + ocrProviders: [{ id: 'google', name: 'Google AI', models: [{ id: 'gemini-3-flash', name: 'Gemini 3 Flash' }] }], + transcriptionProviders: [{ id: 'deepgram', name: 'Deepgram', models: [{ id: 'nova-3', name: 'Deepgram Nova-3' }] }] + })) + }; +} + +export function llmSettings(overrides: Partial = {}): LLMProviderSettings { + return { + providers: { + anthropic: { enabled: true, apiKey: 'test' }, + mistral: { enabled: true, apiKey: 'test' } + }, + defaultModel: { provider: 'anthropic', model: 'claude-opus-5-5' }, + ...overrides + } as LLMProviderSettings; +} + +export function chatSettings(overrides: Partial = {}): ChatSettings { + return { + provider: 'anthropic', + model: 'claude-opus-5-5', + thinking: { enabled: true, effort: 'high' }, + agentProvider: 'anthropic', + agentModel: 'claude-haiku-5-5', + agentThinking: { enabled: false, effort: 'medium' }, + temperature: 0.5, + imageProvider: 'google', + imageModel: 'gemini-3.1-flash-image', + workspaceId: null, + promptId: null, + contextNotes: [], + ...overrides + }; +} diff --git a/tests/mocks/obsidian/components.ts b/tests/mocks/obsidian/components.ts index bf848b840..02006d98a 100644 --- a/tests/mocks/obsidian/components.ts +++ b/tests/mocks/obsidian/components.ts @@ -3,26 +3,61 @@ */ import { createMockElement } from './core'; +import { FakeElement, isFakeElement } from './dom'; -// Setting mock +// Element factory: a FakeElement subtree when the caller renders into a +// FakeElement (tests that observe structure), the call-recording mock otherwise. +function childOf(containerEl: unknown, tag: string, info?: Record | string): HTMLElement { + if (isFakeElement(containerEl)) { + return containerEl.createEl(tag, info as never) as unknown as HTMLElement; + } + return createMockElement(tag); +} + +function asFake(el: unknown): FakeElement | null { + return isFakeElement(el) ? el : null; +} + +// Setting mock. Given a FakeElement container it renders Obsidian's markup +// (setting-item > setting-item-info > name/description, setting-item-control) +// in place, so order, names, descriptions and disabled state can be asserted. export class Setting { settingEl: HTMLElement; + infoEl: HTMLElement; + nameEl: HTMLElement; + descEl: HTMLElement; controlEl: HTMLElement; + components: unknown[] = []; + /** Last value passed to setName/setDesc (strings only), for assertions. */ + name = ''; + desc = ''; - constructor(_containerEl: HTMLElement) { - this.settingEl = createMockElement('div'); - this.controlEl = createMockElement('div'); + constructor(containerEl: HTMLElement) { + this.settingEl = childOf(containerEl, 'div', 'setting-item'); + this.infoEl = childOf(this.settingEl, 'div', 'setting-item-info'); + this.nameEl = childOf(this.infoEl, 'div', 'setting-item-name'); + this.descEl = childOf(this.infoEl, 'div', 'setting-item-description'); + this.controlEl = childOf(this.settingEl, 'div', 'setting-item-control'); } - setClass(_cls: string): this { + setClass(cls: string): this { + asFake(this.settingEl)?.addClass(cls); return this; } - setName(_name: string): this { + setName(name: string | DocumentFragment): this { + if (typeof name === 'string') { + this.name = name; + asFake(this.nameEl)?.setText(name); + } return this; } - setDesc(_desc: string): this { + setDesc(desc: string | DocumentFragment): this { + if (typeof desc === 'string') { + this.desc = desc; + asFake(this.descEl)?.setText(desc); + } return this; } @@ -30,33 +65,82 @@ export class Setting { return this; } - addText(callback: (text: TextComponent) => void): this { - callback(new TextComponent(this.settingEl)); + setHeading(): this { + asFake(this.settingEl)?.addClass('setting-item-heading'); return this; } - addTextArea(callback: (textarea: TextAreaComponent) => void): this { - callback(new TextAreaComponent(this.settingEl)); + setDisabled(disabled: boolean): this { + asFake(this.settingEl)?.toggleClass('is-disabled', disabled); return this; } - addDropdown(callback: (dropdown: DropdownComponent) => void): this { - callback(new DropdownComponent(this.settingEl)); + then(callback: (setting: this) => unknown): this { + callback(this); return this; } - addToggle(callback: (toggle: ToggleComponent) => void): this { - callback(new ToggleComponent(this.settingEl)); + private add(component: T, callback: (component: T) => unknown): this { + this.components.push(component); + callback(component); return this; } + addText(callback: (text: TextComponent) => void): this { + return this.add(new TextComponent(this.controlEl), callback); + } + + addTextArea(callback: (textarea: TextAreaComponent) => void): this { + return this.add(new TextAreaComponent(this.controlEl), callback); + } + + addDropdown(callback: (dropdown: DropdownComponent) => void): this { + return this.add(new DropdownComponent(this.controlEl), callback); + } + + addToggle(callback: (toggle: ToggleComponent) => void): this { + return this.add(new ToggleComponent(this.controlEl), callback); + } + addButton(callback: (button: ButtonComponent) => void): this { - callback(new ButtonComponent(this.settingEl)); - return this; + return this.add(new ButtonComponent(this.controlEl), callback); + } + + addExtraButton(callback: (button: ExtraButtonComponent) => void): this { + return this.add(new ExtraButtonComponent(this.controlEl), callback); } addSlider(callback: (slider: SliderComponent) => void): this { - callback(new SliderComponent(this.settingEl)); + return this.add(new SliderComponent(this.controlEl), callback); + } + + addProgressBar(callback: (bar: ProgressBarComponent) => void): this { + return this.add(new ProgressBarComponent(this.controlEl), callback); + } +} + +// SettingGroup mock (Obsidian 1.11+): setting-group > setting-items, with the +// internal groupEl/listEl fields the real class carries. +export class SettingGroup { + groupEl: HTMLElement; + listEl: HTMLElement; + + constructor(containerEl: HTMLElement) { + this.groupEl = childOf(containerEl, 'div', 'setting-group'); + this.listEl = childOf(this.groupEl, 'div', 'setting-items'); + } + + setHeading(_text: string | DocumentFragment): this { + return this; + } + + addClass(cls: string): this { + asFake(this.groupEl)?.addClass(cls); + return this; + } + + addSetting(callback: (setting: Setting) => void): this { + callback(new Setting(this.listEl)); return this; } } @@ -67,8 +151,8 @@ export class TextComponent { private value = ''; private changeCallback?: (value: string) => void; - constructor(_containerEl: HTMLElement) { - this.inputEl = createMockElement('input') as HTMLInputElement; + constructor(containerEl: HTMLElement) { + this.inputEl = childOf(containerEl, 'input', { type: 'text' }) as HTMLInputElement; } setPlaceholder(_placeholder: string): this { @@ -77,6 +161,8 @@ export class TextComponent { setValue(value: string): this { this.value = value; + const fake = asFake(this.inputEl); + if (fake) fake.value = value; return this; } @@ -84,6 +170,10 @@ export class TextComponent { return this.value; } + setDisabled(_disabled: boolean): this { + return this; + } + onChange(callback: (value: string) => void): this { this.changeCallback = callback; return this; @@ -101,8 +191,8 @@ export class TextAreaComponent { inputEl: HTMLTextAreaElement; private value = ''; - constructor(_containerEl: HTMLElement) { - this.inputEl = createMockElement('textarea') as HTMLTextAreaElement; + constructor(containerEl: HTMLElement) { + this.inputEl = childOf(containerEl, 'textarea') as HTMLTextAreaElement; } setPlaceholder(_placeholder: string): this { @@ -123,44 +213,80 @@ export class TextAreaComponent { } } -// DropdownComponent mock +// DropdownComponent mock. With a FakeElement container, options are real +//