Browse Source

fix(preset): do not refresh sidebar snapshot on apply

Applying a preset now persists only the preset name to the config file;
the on-disk TUI snapshot is no longer touched. The sidebar keeps showing
the running agents' models until the next conversation/reload, when
loadPluginConfig re-reads the config and merges the preset into
config.agents.

Previously the sidebar refreshed mid-session while the agent registry was
unchanged — users saw new models against running agents, which was
confusing ("applying a preset updates the sidebar snapshot immediately"
was the reported misleading phrase).

Behavior change:
- Remove applyPresetToTuiSnapshot() and its call site in switchPresetOnDisk.
- Drop the post-apply readTuiSnapshot + requestRender in tui-preset.ts.
- Apply success toast now says: start a new conversation (or reload
  OpenCode) to use the preset.

Agent-tree stability rationale (now stated in code, codemap, and docs):
hot-swapping the agent tree mid-conversation could truncate context (a
new model may have a smaller window), drift prior assistant turns under a
changed system prompt, leave running subagents referencing stale agent
definitions, or shift tool/skill availability. A future path to true
in-session switching without reset requires upgrading @opencode-ai/plugin
(tracked in #799).

Tests: drop the two tests that asserted snapshot writes after a switch;
narrow the legacy-alias and mixed-preset tests to summary-only assertions.
Qesire 2 weeks ago
parent
commit
4408f33389
5 changed files with 45 additions and 122 deletions
  1. 9 6
      docs/preset-switching.md
  2. 1 1
      src/tools/codemap.md
  3. 5 58
      src/tools/preset-switch.test.ts
  4. 13 42
      src/tools/preset-switch.ts
  5. 17 15
      src/tui-preset.ts

+ 9 - 6
docs/preset-switching.md

@@ -22,16 +22,19 @@ the built-in `/models`, so it triggers no LLM turn.
 1. Define named presets in `oh-my-opencode-slim.jsonc` under the `presets`
    field, or create them interactively from the manager
 2. The manager writes preset changes to the user config file
-3. **Apply** persists the preset name and refreshes the sidebar snapshot
-   (visual only — the sidebar shows the new models)
-4. **Reload OpenCode** for the new preset to take effect on the agent registry
+3. **Apply** persists the preset name to the config file only — the
+   sidebar is NOT refreshed mid-session (the agent registry is unchanged
+   until reload; showing new models against running agents would be
+   misleading)
+4. **Reload OpenCode** (or start a new conversation) for the new preset to
+   take effect on the agent registry
 5. The current session is **not** reloaded — this is deliberate.
    Hot-swapping the agent tree mid-conversation could truncate context (a
    new model may have a smaller window), drift prior assistant turns under
    a changed system prompt, leave running subagents referencing stale agent
-   definitions, or shift tool/skill availability. Reload-on-switch keeps
-   the active session stable. A future path to true in-session switching
-   without reset requires upgrading `@opencode-ai/plugin` (tracked in #799).
+   definitions, or shift tool/skill availability. A future path to true
+   in-session switching without reset requires upgrading
+   `@opencode-ai/plugin` (tracked in #799).
 
 ### Level 3 — model and variant selection
 

+ 1 - 1
src/tools/codemap.md

@@ -43,7 +43,7 @@ Each tool is implemented as a factory function that returns a `ToolDefinition` r
 ### State Management
 
 - **Runtime Presets**: Preset state persists across plugin reloads via `runtime-preset.ts`
-- **TUI Integration**: Preset changes refresh the terminal UI snapshot (sidebar shows new models); the agent registry takes effect on reload, not mid-session — hot-swapping the agent tree during an active conversation risks context truncation, drifted prior turns, and stale subagent references
+- **TUI Integration**: Preset changes persist to the config file only; the sidebar is NOT refreshed mid-session (the agent registry is unchanged until reload) — hot-swapping the agent tree during an active conversation risks context truncation, drifted prior turns, and stale subagent references
 - **Background Jobs**: Task cancellation uses a centralized job board for tracking and cleanup
 
 ## Flow

+ 5 - 58
src/tools/preset-switch.test.ts

@@ -3,7 +3,6 @@ import * as fs from 'node:fs';
 import * as os from 'node:os';
 import * as path from 'node:path';
 import type { PluginConfig } from '../config';
-import { readTuiSnapshot, recordTuiAgentModels } from '../tui-state';
 import {
   buildPresetSummary,
   deletePreset,
@@ -108,64 +107,15 @@ describe('switchPresetOnDisk', () => {
     expect(result.presetName).toBe('cheap');
     expect(result.message).toContain('Saved preset "cheap"');
     expect(result.message).toContain('Reload OpenCode');
-    expect(result.message).toContain('current session was not reloaded');
+    expect(result.message).toContain(
+      'current session keeps its existing agent models',
+    );
     expect(result.summary).toContain(
       'orchestrator → model: anthropic/claude-3.5-haiku',
     );
     expect(result.summary).toContain('explorer → model: openai/gpt-5.6-luna');
   });
 
-  test('updates the TUI snapshot after a successful switch', () => {
-    recordTuiAgentModels(
-      {
-        agentModels: {
-          explorer: 'openai/gpt-5.6-luna',
-          fixer: 'openai/gpt-5.6-luna',
-        },
-      },
-      tempDir,
-    );
-
-    const config: PluginConfig = {
-      presets: {
-        cheap: {
-          orchestrator: { model: 'anthropic/claude-3.5-haiku' },
-          explorer: { model: 'openai/gpt-5.6' },
-        },
-      },
-    };
-
-    switchPresetOnDisk(tempDir, 'cheap', config);
-
-    expect(readTuiSnapshot(tempDir).agentModels).toEqual({
-      explorer: 'openai/gpt-5.6',
-      fixer: 'openai/gpt-5.6-luna',
-      orchestrator: 'anthropic/claude-3.5-haiku',
-    });
-  });
-
-  test('clears stale variant when switching to a preset without one', () => {
-    recordTuiAgentModels(
-      {
-        agentModels: { oracle: 'old-model' },
-        agentVariants: { oracle: 'thinking' },
-      },
-      tempDir,
-    );
-
-    const config: PluginConfig = {
-      presets: {
-        plain: { oracle: { model: 'new-model' } },
-      },
-    };
-
-    switchPresetOnDisk(tempDir, 'plain', config);
-
-    const snapshot = readTuiSnapshot(tempDir);
-    expect(snapshot.agentModels.oracle).toBe('new-model');
-    expect(snapshot.agentVariants.oracle).toBeUndefined();
-  });
-
   test('persists preset name to a JSONC user config file', () => {
     const configDir = path.join(tempDir, 'opencode-config');
     fs.mkdirSync(configDir, { recursive: true });
@@ -212,9 +162,6 @@ describe('switchPresetOnDisk', () => {
 
     expect(result.ok).toBe(true);
     expect(result.summary.some((l) => l.startsWith('explorer →'))).toBe(true);
-    expect(readTuiSnapshot(tempDir).agentModels.explorer).toBe(
-      'openai/gpt-5.6-luna',
-    );
   });
 
   test('skips agents with empty overrides in a mixed preset', () => {
@@ -235,8 +182,8 @@ describe('switchPresetOnDisk', () => {
       true,
     );
     expect(result.summary.some((l) => l.startsWith('oracle →'))).toBe(true);
-    // explorer has no usable override and must not appear in the snapshot
-    expect(readTuiSnapshot(tempDir).agentModels.explorer).toBeUndefined();
+    // explorer has no usable override and must not appear in the summary
+    expect(result.summary.some((l) => l.startsWith('explorer →'))).toBe(false);
   });
 
   test('resolves array-form model to the first string entry', () => {

+ 13 - 42
src/tools/preset-switch.ts

@@ -8,7 +8,6 @@ import type {
 } from '../config';
 import { AGENT_ALIASES } from '../config/constants';
 import { findPluginConfigPaths } from '../config/loader';
-import { readTuiSnapshot, recordTuiAgentModels } from '../tui-state';
 
 /**
  * Result of a preset switch attempt. `message` is user-facing and intended for
@@ -32,16 +31,17 @@ export interface AgentUpdate {
 
 /**
  * Switch the active preset purely through on-disk state: persist the preset
- * name to the user config file and update the TUI snapshot that the sidebar
- * polls.
+ * name to the user config file. The sidebar snapshot is deliberately NOT
+ * touched — the new preset applies on the next reload/restart, when
+ * `loadPluginConfig` re-reads the config file and merges the preset into
+ * `config.agents`. Refreshing the sidebar mid-session while the agent
+ * registry is unchanged would show models that don't match the running
+ * agents, which is confusing.
  *
  * This is the shared core used by the TUI `/preset` slash command. It
- * deliberately does NOT touch OpenCode's in-memory agent registry: per the
- * existing user contract, the new preset applies on the next reload/restart
- * (when `loadPluginConfig` re-reads the config file and merges the preset into
- * `config.agents`). It also does not set the server-side runtime-preset
- * singleton, because the TUI runs in a separate process from the server and
- * cannot reach that state.
+ * deliberately does NOT touch OpenCode's in-memory agent registry. It also
+ * does not set the server-side runtime-preset singleton, because the TUI
+ * runs in a separate process from the server and cannot reach that state.
  */
 export function switchPresetOnDisk(
   directory: string,
@@ -76,12 +76,11 @@ export function switchPresetOnDisk(
   }
 
   persistPresetName(directory, presetName);
-  applyPresetToTuiSnapshot(directory, agentUpdates);
 
   return {
     ok: true,
     presetName,
-    message: `Saved preset "${presetName}". Reload OpenCode to apply it to agent configuration. The current session was not reloaded to avoid interrupting the active conversation and destabilizing running subagents.`,
+    message: `Saved preset "${presetName}". Reload OpenCode (or start a new conversation) for it to take effect. The current session keeps its existing agent models to avoid truncating context, drifting prior turns, or destabilizing running subagents.`,
     summary: buildPresetSummary(agentUpdates),
   };
 }
@@ -226,9 +225,7 @@ function resolveFirstModel(
 /**
  * Persist the preset name to the user-level config file so it survives
  * restarts. Best-effort: a failure must not abort the switch, because the
- * TUI snapshot refresh is the user-visible effect (the sidebar shows the
- * new models); the agent registry itself only picks up the change on
- * reload.
+ * persisted name is what makes the next reload pick up the new preset.
  *
  * Note: this rewrites the file as plain JSON (JSONC comments are not
  * preserved), matching the prior server-side behavior.
@@ -245,37 +242,11 @@ function persistPresetName(directory: string, presetName: string): void {
     persisted.preset = presetName;
     fs.writeFileSync(userConfigPath, `${JSON.stringify(persisted, null, 2)}\n`);
   } catch {
-    // Non-critical: the TUI snapshot refresh still proceeds, so the
-    // sidebar still reflects the new models.
+    // Non-critical: the preset name is also returned in the switch result
+    // so the caller can surface it to the user.
   }
 }
 
-/**
- * Merge the preset's model/variant overrides into the on-disk TUI snapshot
- * so the sidebar reflects the new models on its next poll. This is a
- * visual refresh only — the agent registry is not hot-swapped; reload
- * OpenCode for the new models to take effect on the running agents.
- */
-function applyPresetToTuiSnapshot(
-  directory: string,
-  agentUpdates: Record<string, AgentUpdate>,
-): void {
-  const snapshot = readTuiSnapshot(directory);
-  const agentModels = { ...snapshot.agentModels };
-  const agentVariants = { ...snapshot.agentVariants };
-  for (const [agentName, agentConfig] of Object.entries(agentUpdates)) {
-    if (typeof agentConfig.model === 'string') {
-      agentModels[agentName] = agentConfig.model;
-    }
-    if (typeof agentConfig.variant === 'string') {
-      agentVariants[agentName] = agentConfig.variant;
-    } else {
-      delete agentVariants[agentName];
-    }
-  }
-  recordTuiAgentModels({ agentModels, agentVariants }, directory);
-}
-
 /**
  * Read the user-level config file as a parsed object. Returns null if the
  * file is absent or unreadable.

+ 17 - 15
src/tui-preset.ts

@@ -11,15 +11,17 @@
  *
  * All preset mutations are written to the user-level config file
  * (`oh-my-opencode-slim.json[c]`). Applying a preset persists the preset
- * name and refreshes the on-disk TUI snapshot so the sidebar shows the new
- * models. The agent registry is NOT hot-swapped mid-session — a reload is
- * required. This is deliberate: hot-swapping the agent tree during an
- * active conversation could truncate context (a new model may have a
- * smaller window), drift prior assistant turns under a changed system
- * prompt, leave running subagents referencing stale agent definitions, or
- * shift tool/skill availability underfoot. Reload-on-switch keeps the
- * active session stable. A future path to true in-session switching
- * without reset requires upgrading `@opencode-ai/plugin` (tracked in #799).
+ * name only — the sidebar is NOT refreshed mid-session, because the agent
+ * registry is unchanged and showing new models against running agents
+ * would be misleading. The new preset takes effect on the next
+ * conversation/reload, when `loadPluginConfig` re-reads the config and
+ * merges the preset into `config.agents`. This is deliberate: hot-swapping
+ * the agent tree during an active conversation could truncate context (a
+ * new model may have a smaller window), drift prior assistant turns under a
+ * changed system prompt, leave running subagents referencing stale agent
+ * definitions, or shift tool/skill availability underfoot. A future path
+ * to true in-session switching without reset requires upgrading
+ * `@opencode-ai/plugin` (tracked in #799).
  */
 import type {
   TuiDialogSelectOption,
@@ -38,7 +40,6 @@ import {
   writePreset,
 } from './tools/preset-switch';
 import type { TuiSnapshot } from './tui-state';
-import { readTuiSnapshot } from './tui-state';
 
 /** Build a `<text>` JSX element — required for DialogPrompt.description(). */
 function desc(text: string): JSX.Element {
@@ -154,6 +155,11 @@ function applyPreset(state: ManagerState, presetName: string): void {
 /**
  * Apply a preset and show a combined toast. `title` lets Save & Apply show
  * a single distinct message instead of two separate toasts.
+ *
+ * The new preset takes effect on the next conversation/reload — the
+ * sidebar is NOT refreshed mid-session, because the agent registry is
+ * unchanged and showing new models against running agents would be
+ * misleading.
  */
 function applyPresetWithMessage(
   state: ManagerState,
@@ -167,13 +173,9 @@ function applyPresetWithMessage(
     variant: result.ok ? 'success' : 'warning',
     title: result.ok ? title : 'Preset switch failed',
     message: result.ok
-      ? `Saved preset "${presetName}". Reload OpenCode to apply it to the agent registry. ${result.summary.join('; ')}`
+      ? `Saved preset "${presetName}". Start a new conversation (or reload OpenCode) to use it. ${result.summary.join('; ')}`
       : result.message,
   });
-  if (result.ok) {
-    state.snapshotRef.snapshot = readTuiSnapshot(state.directory);
-    state.api.renderer.requestRender();
-  }
 }
 
 function confirmDeletePreset(state: ManagerState, presetName: string): void {