Browse Source

feat(preset): replace server /preset command with TUI slash command

The /preset command used createInternalAgentTextPart which sets
synthetic: true. In opencode, synthetic means "send to LLM, hide from
TUI" — the exact opposite of the intended "don't send to LLM, show in
TUI". This caused /preset output to be invisible in the TUI while still
triggering an LLM turn.

Replace the server-side command.execute.before handler with a pure TUI
slash command (same channel as /models). The new /preset opens a
three-level interactive manager:
- Level 1: preset list (Apply / Edit / Create / Delete)
- Level 2: agent arrangement within a preset (Add / Remove / Edit)
- Level 3: per-agent model, variant, temperature, options editor

All preset mutations write to the user config file. Applying a preset
requires a reload (the current session is not interrupted, to avoid
destabilizing running subagents) — matching the original contract.

Headless/ACP clients can still set presets via the config file 'preset'
field or the OH_MY_OPENCODE_SLIM_PRESET environment variable.
Qesire 3 weeks ago
parent
commit
f19f5800d3

+ 36 - 48
docs/preset-switching.md

@@ -1,21 +1,40 @@
 # Preset Switching
 
-Switch agent model presets at runtime without restarting OpenCode using the `/preset` slash command.
+Switch agent model presets at runtime using the `/preset` TUI slash command.
 
 ## Controls
 
-| Command | Description |
-|---------|-------------|
-| `/preset` | List available presets (highlights the active one) |
-| `/preset <name>` | Switch to the named preset immediately |
+`/preset` opens a **three-level preset manager** in the TUI — pure TUI, like
+the built-in `/models`, so it triggers no LLM turn.
+
+| Level | What you do |
+|-------|-------------|
+| 1. Preset list | Apply / Edit / Delete an existing preset, or create a new one |
+| 2. Agent arrangement | Add / remove / edit the agents in a preset, then Save (or Save & Apply) |
+| 3. Edit agent | Pick model → variant (thinking strength) → temperature → options (JSON) |
+
+> `/preset` is a TUI-only slash command (like `/models`). Invoke it via
+> autocomplete selection or a keybind. Typing `/preset` + Enter does not open
+> the manager (same design as `/models`).
 
 ## How It Works
 
-1. Define named presets in `oh-my-opencode-slim.jsonc` under the `presets` field
-2. Run `/preset <name>` to switch. The plugin calls the OpenCode SDK's `config.update()` method, which triggers a server-side cache invalidation
-3. Agents covered by the new preset get the preset's values
-4. Agents that were in the *previous* preset but are *not* in the new one are reset to their config-file baseline values
-5. The next LLM call uses the new models and settings
+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** writes the `preset` field and refreshes the sidebar
+4. **Reload OpenCode** for the new preset to take effect on the agent registry
+5. The current session is **not** reloaded — this is deliberate, to avoid
+   interrupting the active conversation and destabilizing running subagents
+
+### Level 3 — model and variant selection
+
+The model picker lists every model from all connected providers (fetched from
+the server's provider registry). If the chosen model exposes variants (e.g.
+`thinking`, `high`, `low`), a variant picker follows — this is the "thinking
+strength" selector. Temperature is a numeric prompt (0–2 or blank). Options is
+a raw JSON prompt for provider-specific settings (e.g.
+`{"thinking":{"type":"enabled","budgetTokens":10000}}`).
 
 ## Example Configuration
 
@@ -45,7 +64,7 @@ Switch agent model presets at runtime without restarting OpenCode using the `/pr
 
 ## Supported Fields
 
-The following fields are forwarded to the OpenCode SDK at runtime:
+The following fields are applied when the preset is loaded on restart:
 
 | Field | Description |
 |-------|-------------|
@@ -54,7 +73,7 @@ The following fields are forwarded to the OpenCode SDK at runtime:
 | `variant` | Model variant (e.g. `"thinking"`) |
 | `options` | Provider-specific options (e.g. thinking budget) |
 
-Fields not forwarded (require restart): `prompt`, `skills`, `mcps`, `displayName`.
+Fields not applied at runtime (require restart): `prompt`, `skills`, `mcps`, `displayName`.
 
 ## Startup Preset vs Runtime Switching
 
@@ -63,42 +82,11 @@ There are two ways to activate a preset:
 | Method | How | Persists? |
 |--------|-----|-----------|
 | Config file | Set `"preset": "cheap"` in `oh-my-opencode-slim.jsonc` | Yes, across restarts |
-| `/preset` command | Run `/preset cheap` during a session | Across re-inits, not restarts |
-
-Runtime preset switches persist across plugin re-inits (triggered by config changes, etc.) within the same process, but revert on process restart. On restart, the plugin applies the preset from the config file. To make a runtime switch permanent, update the `"preset"` field in your config file.
-
-## Example Output
-
-```
-/preset
-```
-
-```
-Available presets:
-  cheap ← active
-    orchestrator → anthropic/claude-3.5-haiku
-    explorer → openai/gpt-5.6-luna
-    oracle → anthropic/claude-sonnet-4-6
-  powerful
-    orchestrator → openai/gpt-5.6
-    oracle → anthropic/claude-opus-4-6
-
-Usage: /preset <name> to switch.
-```
-
-```
-/preset powerful
-```
-
-```
-Switched to preset "powerful":
-orchestrator → model: openai/gpt-5.6
-oracle → model: anthropic/claude-opus-4-6
-Reset to baseline: explorer
-```
+| `/preset` TUI command | Select a preset from the picker during a session | Yes — writes to config file |
 
-The "Reset to baseline" line appears when agents from the previous preset
-are not present in the new one. Those agents are reverted to their
-config-file defaults.
+The `/preset` TUI command writes the selected preset name to the config file,
+so the switch persists across restarts. **Reload OpenCode** for the new preset
+to take effect on the agent registry. The current session continues
+uninterrupted with its existing models.
 
 > See [Configuration](configuration.md) for the full preset option reference.

+ 0 - 13
src/index.ts

@@ -55,7 +55,6 @@ import {
   ast_grep_search,
   createAcpRunTool,
   createCancelTaskTool,
-  createPresetManager,
   createWebfetchTool,
 } from './tools';
 import { recordTuiAgentModel, recordTuiAgentModels } from './tui-state';
@@ -172,7 +171,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let taskSessionManagerAfter: (i: unknown, o: unknown) => Promise<void>;
   let backgroundJobBoard: BackgroundJobBoard;
   let interviewManager: ReturnType<typeof createInterviewManager>;
-  let presetManager: ReturnType<typeof createPresetManager>;
   let companionManager: CompanionManager;
   let cancelTaskTools: ReturnType<typeof createCancelTaskTool>;
   let acpRunTools: Record<string, ReturnType<typeof createAcpRunTool>>;
@@ -400,7 +398,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       taskSessionManagerHook['tool.execute.after'](i as never, o as never),
     );
     interviewManager = createInterviewManager(ctx, config);
-    presetManager = createPresetManager(ctx, config);
     companionManager = new CompanionManager(
       `proc_${process.pid}`,
       ctx.directory,
@@ -858,7 +855,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       deepworkCommandHook.registerCommand(opencodeConfig);
       reflectCommandHook.registerCommand(opencodeConfig);
       loopCommandHook.registerCommand(opencodeConfig);
-      presetManager.registerCommand(opencodeConfig);
     },
 
     event: async (input) => {
@@ -1024,15 +1020,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         output as { parts: Array<{ type: string; text?: string }> },
       );
 
-      await presetManager.handleCommandExecuteBefore(
-        input as {
-          command: string;
-          sessionID: string;
-          arguments: string;
-        },
-        output as { parts: Array<{ type: string; text?: string }> },
-      );
-
       await deepworkCommandHook.handleCommandExecuteBefore(
         input as {
           command: string;

+ 0 - 2
src/tools/index.ts

@@ -2,6 +2,4 @@
 export { createAcpRunTool } from './acp-run';
 export { ast_grep_replace, ast_grep_search } from './ast-grep';
 export { createCancelTaskTool } from './cancel-task';
-export type { PresetManager } from './preset-manager';
-export { createPresetManager } from './preset-manager';
 export { createWebfetchTool } from './smartfetch';

+ 0 - 837
src/tools/preset-manager.test.ts

@@ -1,837 +0,0 @@
-import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
-import * as fs from 'node:fs';
-import * as os from 'node:os';
-import * as path from 'node:path';
-import type { PluginConfig } from '../config';
-import {
-  getActiveRuntimePreset,
-  setActiveRuntimePreset,
-} from '../config/runtime-preset';
-import { readTuiSnapshot, recordTuiAgentModels } from '../tui-state';
-import { createPresetManager } from './preset-manager';
-
-function createMockContext() {
-  const configUpdate = mock(async () => ({}));
-  const instanceDispose = mock(async () => ({}));
-  return {
-    client: {
-      config: {
-        update: configUpdate,
-      },
-      instance: {
-        dispose: instanceDispose,
-      },
-    },
-    directory: tempDir,
-  } as any;
-}
-
-function createOutput() {
-  return { parts: [] as Array<{ type: string; text?: string }> };
-}
-
-function getOutputText(output: ReturnType<typeof createOutput>): string {
-  return output.parts
-    .filter((p) => p.type === 'text')
-    .map((p) => p.text ?? '')
-    .join('\n');
-}
-
-let previousXdgDataHome: string | undefined;
-let previousXdgConfigHome: string | undefined;
-let previousOpenCodeConfigDir: string | undefined;
-let tempDir: string;
-
-beforeEach(() => {
-  previousXdgDataHome = process.env.XDG_DATA_HOME;
-  previousXdgConfigHome = process.env.XDG_CONFIG_HOME;
-  previousOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR;
-  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-preset-manager-'));
-  process.env.XDG_DATA_HOME = tempDir;
-  process.env.XDG_CONFIG_HOME = path.join(tempDir, 'xdg-config');
-  delete process.env.OPENCODE_CONFIG_DIR;
-  setActiveRuntimePreset(null);
-});
-
-afterEach(() => {
-  if (previousXdgDataHome === undefined) {
-    delete process.env.XDG_DATA_HOME;
-  } else {
-    process.env.XDG_DATA_HOME = previousXdgDataHome;
-  }
-
-  if (previousXdgConfigHome === undefined) {
-    delete process.env.XDG_CONFIG_HOME;
-  } else {
-    process.env.XDG_CONFIG_HOME = previousXdgConfigHome;
-  }
-
-  if (previousOpenCodeConfigDir === undefined) {
-    delete process.env.OPENCODE_CONFIG_DIR;
-  } else {
-    process.env.OPENCODE_CONFIG_DIR = previousOpenCodeConfigDir;
-  }
-
-  fs.rmSync(tempDir, { recursive: true, force: true });
-  setActiveRuntimePreset(null);
-});
-
-describe('createPresetManager', () => {
-  describe('handleCommandExecuteBefore', () => {
-    test('ignores non-preset commands', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {};
-      const manager = createPresetManager(ctx, config);
-      const output = createOutput();
-
-      await manager.handleCommandExecuteBefore(
-        { command: 'unknown-command', sessionID: 's1', arguments: 'on' },
-        output,
-      );
-
-      expect(output.parts).toHaveLength(0);
-      expect(ctx.client.config.update).not.toHaveBeenCalled();
-    });
-
-    test('lists available presets when no argument given', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        presets: {
-          cheap: {
-            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
-          },
-          powerful: {
-            orchestrator: { model: 'openai/gpt-5.6' },
-          },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-      const output = createOutput();
-
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: '' },
-        output,
-      );
-
-      const text = getOutputText(output);
-      expect(text).toContain('cheap');
-      expect(text).toContain('powerful');
-      expect(ctx.client.config.update).not.toHaveBeenCalled();
-    });
-
-    test('lists presets with active marker when preset is set', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        preset: 'cheap',
-        presets: {
-          cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
-          powerful: { orchestrator: { model: 'openai/gpt-5.6' } },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-      const output = createOutput();
-
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: '' },
-        output,
-      );
-
-      const text = getOutputText(output);
-      expect(text).toContain('← active');
-    });
-
-    test('shows no-presets message when none configured', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {};
-      const manager = createPresetManager(ctx, config);
-      const output = createOutput();
-
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: '' },
-        output,
-      );
-
-      const text = getOutputText(output);
-      expect(text).toContain('No presets configured');
-    });
-
-    test('switches preset state without config.update or instance.dispose', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        presets: {
-          cheap: {
-            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
-            explorer: { model: 'openai/gpt-5.6-luna' },
-          },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-      const output = createOutput();
-
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'cheap' },
-        output,
-      );
-
-      const text = getOutputText(output);
-      expect(text).toContain('Saved preset "cheap"');
-      expect(text).toContain('orchestrator');
-      expect(text).toContain('anthropic/claude-3.5-haiku');
-      expect(text).toContain('explorer');
-      expect(text).toContain('Restart or reload OpenCode');
-      expect(getActiveRuntimePreset()).toBe('cheap');
-      expect(ctx.client.config.update).not.toHaveBeenCalled();
-      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
-    });
-
-    test('updates the TUI snapshot after a successful preset switch', async () => {
-      recordTuiAgentModels(
-        {
-          agentModels: {
-            explorer: 'openai/gpt-5.6-luna',
-            fixer: 'openai/gpt-5.6-luna',
-          },
-        },
-        tempDir,
-      );
-
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        presets: {
-          cheap: {
-            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
-            explorer: { model: 'openai/gpt-5.6' },
-          },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-      const output = createOutput();
-
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'cheap' },
-        output,
-      );
-
-      expect(readTuiSnapshot(tempDir).agentModels).toEqual({
-        explorer: 'openai/gpt-5.6',
-        fixer: 'openai/gpt-5.6-luna',
-        orchestrator: 'anthropic/claude-3.5-haiku',
-      });
-    });
-
-    test('persists preset changes from JSONC user config', async () => {
-      const configDir = path.join(tempDir, 'opencode-config');
-      fs.mkdirSync(configDir, { recursive: true });
-      process.env.OPENCODE_CONFIG_DIR = configDir;
-
-      const configPath = path.join(configDir, 'oh-my-opencode-slim.jsonc');
-      fs.writeFileSync(
-        configPath,
-        `{
-          // User-selected preset should be updated even in JSONC files.
-          "preset": "old",
-          "agents": {
-            "orchestrator": { "model": "old-model" },
-          },
-        }`,
-      );
-
-      const ctx = { ...createMockContext(), directory: tempDir };
-      const config: PluginConfig = {
-        presets: {
-          cheap: {
-            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
-          },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-      const output = createOutput();
-
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'cheap' },
-        output,
-      );
-
-      const persisted = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as {
-        preset?: string;
-        agents?: Record<string, unknown>;
-      };
-      expect(persisted.preset).toBe('cheap');
-      expect(persisted.agents).toEqual({
-        orchestrator: { model: 'old-model' },
-      });
-      expect(ctx.client.config.update).not.toHaveBeenCalled();
-      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
-    });
-
-    test('shows temperature in preset summary without runtime config update', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        presets: {
-          precise: {
-            orchestrator: { model: 'openai/o3', temperature: 0.1 },
-          },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-      const output = createOutput();
-
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'precise' },
-        output,
-      );
-
-      const text = getOutputText(output);
-      expect(text).toContain('orchestrator');
-      expect(text).toContain('model: openai/o3');
-      expect(text).toContain('temp: 0.1');
-      expect(ctx.client.config.update).not.toHaveBeenCalled();
-      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
-    });
-
-    test('shows variant in preset summary without runtime config update', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        presets: {
-          thinker: {
-            oracle: {
-              model: 'anthropic/claude-sonnet-4-6',
-              variant: 'thinking',
-            },
-          },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-      const output = createOutput();
-
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'thinker' },
-        output,
-      );
-
-      const text = getOutputText(output);
-      expect(text).toContain('oracle');
-      expect(text).toContain('model: anthropic/claude-sonnet-4-6');
-      expect(text).toContain('variant: thinking');
-      expect(ctx.client.config.update).not.toHaveBeenCalled();
-      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
-    });
-
-    test('shows error for unknown preset name', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        presets: {
-          cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-      const output = createOutput();
-
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'nonexistent' },
-        output,
-      );
-
-      const text = getOutputText(output);
-      expect(text).toContain('not found');
-      expect(text).toContain('cheap');
-      expect(ctx.client.config.update).not.toHaveBeenCalled();
-      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
-    });
-
-    test('shows error when no presets configured but argument given', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {};
-      const manager = createPresetManager(ctx, config);
-      const output = createOutput();
-
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'cheap' },
-        output,
-      );
-
-      const text = getOutputText(output);
-      expect(text).toContain('not found');
-      expect(text).toContain('No presets configured');
-    });
-
-    test('unknown preset does not change active state or dispose instance', async () => {
-      setActiveRuntimePreset('cheap');
-      recordTuiAgentModels(
-        {
-          agentModels: {
-            explorer: 'openai/gpt-5.6-luna',
-          },
-        },
-        tempDir,
-      );
-
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        presets: {
-          cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-      const output = createOutput();
-
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'nonexistent' },
-        output,
-      );
-
-      const text = getOutputText(output);
-      expect(text).toContain('not found');
-      expect(getActiveRuntimePreset()).toBe('cheap');
-      expect(ctx.client.config.update).not.toHaveBeenCalled();
-      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
-    });
-
-    test('shows empty preset message when preset has no valid overrides', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        presets: {
-          empty: {
-            orchestrator: {},
-          },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-      const output = createOutput();
-
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'empty' },
-        output,
-      );
-
-      const text = getOutputText(output);
-      expect(text).toContain('empty');
-      expect(ctx.client.config.update).not.toHaveBeenCalled();
-    });
-
-    test('shows options in preset summary without runtime config update', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        presets: {
-          thinker: {
-            oracle: {
-              model: 'anthropic/claude-sonnet-4-6',
-              options: {
-                thinking: { type: 'enabled', budgetTokens: 10000 },
-              },
-            },
-          },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-      const output = createOutput();
-
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'thinker' },
-        output,
-      );
-
-      const text = getOutputText(output);
-      expect(text).toContain('oracle');
-      expect(text).toContain('options: yes');
-      expect(ctx.client.config.update).not.toHaveBeenCalled();
-      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
-    });
-
-    test('trims whitespace from preset name argument', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        presets: {
-          cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-      const output = createOutput();
-
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: '  cheap  ' },
-        output,
-      );
-
-      const text = getOutputText(output);
-      expect(text).toContain('Saved preset "cheap"');
-      expect(ctx.client.config.update).not.toHaveBeenCalled();
-      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
-    });
-
-    test('shows suggestion for multi-word arguments', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        presets: {
-          cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-      const output = createOutput();
-
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'cheap powerful' },
-        output,
-      );
-
-      const text = getOutputText(output);
-      expect(text).toContain('cannot contain spaces');
-      expect(text).toContain('/preset cheap');
-      expect(ctx.client.config.update).not.toHaveBeenCalled();
-    });
-
-    test('catches tab-separated arguments', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        presets: {
-          cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-      const output = createOutput();
-
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'cheap\tpowerful' },
-        output,
-      );
-
-      const text = getOutputText(output);
-      expect(text).toContain('cannot contain spaces');
-      expect(ctx.client.config.update).not.toHaveBeenCalled();
-    });
-
-    test('skips agents with empty overrides in mixed preset', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        presets: {
-          mixed: {
-            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
-            explorer: {},
-            oracle: { temperature: 0.3 },
-          },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-      const output = createOutput();
-
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'mixed' },
-        output,
-      );
-
-      const text = getOutputText(output);
-      expect(text).toContain('Saved preset "mixed"');
-      expect(text).toContain('orchestrator');
-      expect(text).toContain('oracle');
-      expect(ctx.client.config.update).not.toHaveBeenCalled();
-      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
-    });
-
-    test('resolves array-form model to first entry', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        presets: {
-          fallback: {
-            orchestrator: {
-              model: ['anthropic/claude-3.5-haiku', 'openai/gpt-5.6'],
-            },
-          },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-      const output = createOutput();
-
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'fallback' },
-        output,
-      );
-
-      const text = getOutputText(output);
-      expect(text).toContain('Saved preset "fallback"');
-      expect(text).toContain('orchestrator');
-      expect(text).toContain('anthropic/claude-3.5-haiku');
-      expect(ctx.client.config.update).not.toHaveBeenCalled();
-      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
-    });
-
-    test('resolves array-form model with object entries', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        presets: {
-          thinker: {
-            oracle: {
-              model: [
-                { id: 'anthropic/claude-sonnet-4-6', variant: 'thinking' },
-                { id: 'openai/o3' },
-              ],
-            },
-          },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-      const output = createOutput();
-
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'thinker' },
-        output,
-      );
-
-      const text = getOutputText(output);
-      expect(text).toContain('Saved preset "thinker"');
-      expect(text).toContain('oracle');
-      expect(text).toContain('variant: thinking');
-      expect(ctx.client.config.update).not.toHaveBeenCalled();
-      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
-    });
-
-    test('shows variant and options in switch summary', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        presets: {
-          thinker: {
-            oracle: {
-              model: 'anthropic/claude-sonnet-4-6',
-              variant: 'thinking',
-              options: { thinking: { type: 'enabled', budgetTokens: 10000 } },
-            },
-          },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-      const output = createOutput();
-
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'thinker' },
-        output,
-      );
-
-      const text = getOutputText(output);
-      expect(text).toContain('variant: thinking');
-      expect(text).toContain('options: yes');
-    });
-
-    test('tracks active preset after switch', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        presets: {
-          cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
-          powerful: { orchestrator: { model: 'openai/gpt-5.6' } },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-
-      // Switch to cheap
-      const output1 = createOutput();
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'cheap' },
-        output1,
-      );
-      expect(getOutputText(output1)).toContain('Saved preset');
-
-      // List presets should now show cheap as active
-      const output2 = createOutput();
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: '' },
-        output2,
-      );
-      expect(getOutputText(output2)).toContain('cheap ← active');
-
-      // Switch to powerful
-      const output3 = createOutput();
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'powerful' },
-        output3,
-      );
-      expect(getOutputText(output3)).toContain('Saved preset "powerful"');
-
-      // List should now show powerful as active
-      const output4 = createOutput();
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: '' },
-        output4,
-      );
-      expect(getOutputText(output4)).toContain('powerful ← active');
-
-      // Cleanup module state
-      setActiveRuntimePreset(null);
-    });
-  });
-
-  describe('registerCommand', () => {
-    test('registers preset command when not present', () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {};
-      const manager = createPresetManager(ctx, config);
-      const opencodeConfig: Record<string, unknown> = {};
-
-      manager.registerCommand(opencodeConfig);
-
-      const command = (opencodeConfig.command as Record<string, unknown>)
-        .preset as { template: string; description: string };
-      expect(command).toBeDefined();
-      expect(command.template).toContain('presets');
-      expect(command.description).toContain('/preset');
-    });
-
-    test('does not overwrite existing preset command', () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {};
-      const manager = createPresetManager(ctx, config);
-      const existing = { template: 'custom', description: 'custom' };
-      const opencodeConfig: Record<string, unknown> = {
-        command: { preset: existing },
-      };
-
-      manager.registerCommand(opencodeConfig);
-
-      expect((opencodeConfig.command as Record<string, unknown>).preset).toBe(
-        existing,
-      );
-    });
-  });
-
-  describe('preset switching stale state', () => {
-    test('switching presets updates active preset without runtime config update', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        presets: {
-          cheap: {
-            oracle: { model: 'cheap-model', temperature: 0.3 },
-          },
-          powerful: {
-            orchestrator: { model: 'powerful-model' },
-          },
-        },
-        agents: {
-          oracle: { model: 'baseline-model' },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-      const output1 = createOutput();
-
-      // Switch to cheap first
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'cheap' },
-        output1,
-      );
-      expect(getOutputText(output1)).toContain('Saved preset "cheap"');
-      expect(ctx.client.config.update).not.toHaveBeenCalled();
-      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
-
-      const output2 = createOutput();
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'powerful' },
-        output2,
-      );
-
-      expect(getOutputText(output2)).toContain('Saved preset "powerful"');
-      expect(getActiveRuntimePreset()).toBe('powerful');
-      expect(ctx.client.config.update).not.toHaveBeenCalled();
-      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
-    });
-
-    test('new preset with same agents still avoids runtime config update', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        presets: {
-          cheap: {
-            oracle: { model: 'a' },
-          },
-          cheaper: {
-            oracle: { model: 'b' },
-          },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-      const output1 = createOutput();
-
-      // Switch to cheap first
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'cheap' },
-        output1,
-      );
-      expect(getOutputText(output1)).toContain('Saved preset "cheap"');
-      expect(ctx.client.config.update).not.toHaveBeenCalled();
-      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
-
-      const output2 = createOutput();
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'cheaper' },
-        output2,
-      );
-
-      expect(getOutputText(output2)).toContain('Saved preset "cheaper"');
-      expect(ctx.client.config.update).not.toHaveBeenCalled();
-      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
-    });
-
-    test('preset state persists across successive switches without runtime update', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        presets: {
-          cheap: {
-            oracle: { model: 'a' },
-          },
-          expensive: {
-            oracle: { model: 'b' },
-          },
-        },
-      };
-      const manager = createPresetManager(ctx, config);
-
-      // Switch to cheap successfully
-      const output1 = createOutput();
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'cheap' },
-        output1,
-      );
-      expect(getActiveRuntimePreset()).toBe('cheap');
-
-      // Try to switch to expensive
-      const output2 = createOutput();
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: 'expensive' },
-        output2,
-      );
-
-      expect(getActiveRuntimePreset()).toBe('expensive');
-      expect(getOutputText(output2)).toContain('Saved preset "expensive"');
-      expect(ctx.client.config.update).not.toHaveBeenCalled();
-      expect(ctx.client.instance.dispose).not.toHaveBeenCalled();
-    });
-
-    test('activePreset syncs from runtime-preset state on factory creation', async () => {
-      // Set runtime preset before creating manager
-      setActiveRuntimePreset('cheap');
-
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        presets: {
-          cheap: {
-            oracle: { model: 'a' },
-          },
-          powerful: {
-            oracle: { model: 'b' },
-          },
-        },
-      };
-
-      // Create manager - should sync from module-level state
-      const manager = createPresetManager(ctx, config);
-
-      // List presets should show cheap as active
-      const output = createOutput();
-      await manager.handleCommandExecuteBefore(
-        { command: 'preset', sessionID: 's1', arguments: '' },
-        output,
-      );
-
-      const text = getOutputText(output);
-      expect(text).toContain('cheap ← active');
-      expect(text).toContain('powerful');
-
-      // Cleanup
-      setActiveRuntimePreset(null);
-    });
-  });
-});

+ 0 - 320
src/tools/preset-manager.ts

@@ -1,320 +0,0 @@
-import * as fs from 'node:fs';
-import type { PluginInput } from '@opencode-ai/plugin';
-import { stripJsonComments } from '../cli/config-io';
-import type {
-  AgentOverrideConfig,
-  ModelEntry,
-  PluginConfig,
-  Preset,
-} from '../config';
-import { AGENT_ALIASES } from '../config/constants';
-import { findPluginConfigPaths } from '../config/loader';
-import {
-  getActiveRuntimePreset,
-  setActiveRuntimePresetWithPrevious,
-} from '../config/runtime-preset';
-import { readTuiSnapshot, recordTuiAgentModels } from '../tui-state';
-import { createInternalAgentTextPart } from '../utils';
-
-const COMMAND_NAME = 'preset';
-
-/**
- * Creates a preset manager for the /preset slash command.
- *
- * Stores the requested runtime preset in plugin memory and updates the
- * plugin-facing TUI snapshot. It deliberately does not call OpenCode's
- * client.config.update() or instance.dispose() from command hooks because
- * OpenCode continues the same command into the prompt loop after
- * command.execute.before, which can break the active conversation while
- * the agent registry is changing.
- */
-export function createPresetManager(ctx: PluginInput, config: PluginConfig) {
-  // Sync from module-level state in case of plugin re-init - the runtime
-  // preset persists across dispose()/re-init cycles.
-  let activePreset: string | null =
-    getActiveRuntimePreset() ?? config.preset ?? null;
-
-  /**
-   * Handle the /preset command from command.execute.before hook.
-   *
-   * - No arguments: list available presets
-   * - With argument: switch to the named preset
-   */
-  async function handleCommandExecuteBefore(
-    input: {
-      command: string;
-      sessionID: string;
-      arguments: string;
-    },
-    output: { parts: Array<{ type: string; text?: string }> },
-  ): Promise<void> {
-    if (input.command !== COMMAND_NAME) {
-      return;
-    }
-
-    // Clear the template so OpenCode doesn't send it to the LLM
-    output.parts.length = 0;
-
-    const arg = input.arguments.trim();
-    const presets = config.presets ?? {};
-
-    if (!arg) {
-      // List available presets
-      output.parts.push(createInternalAgentTextPart(formatPresetList(presets)));
-      return;
-    }
-
-    // Guard against multi-word arguments
-    if (/\s/.test(arg)) {
-      const suggestion = arg.split(/\s+/)[0];
-      output.parts.push(
-        createInternalAgentTextPart(
-          `Preset names cannot contain spaces. Did you mean: /preset ${suggestion}?`,
-        ),
-      );
-      return;
-    }
-
-    // Switch to named preset
-    await switchPreset(arg, presets, output);
-  }
-
-  /**
-   * Register the /preset command in the OpenCode config.
-   */
-  function registerCommand(opencodeConfig: Record<string, unknown>): void {
-    const configCommand = opencodeConfig.command as
-      | Record<string, unknown>
-      | undefined;
-    if (!configCommand?.[COMMAND_NAME]) {
-      if (!opencodeConfig.command) {
-        opencodeConfig.command = {};
-      }
-      (opencodeConfig.command as Record<string, unknown>)[COMMAND_NAME] = {
-        template: 'List available presets and switch between them',
-        description:
-          'Switch agent presets at runtime (e.g., /preset cheap, /preset powerful)',
-      };
-    }
-  }
-
-  /**
-   * Save the given preset name for the plugin runtime without mutating the
-   * active OpenCode instance. The preset is applied by the plugin config path
-   * when OpenCode reloads the plugin in a safe lifecycle boundary.
-   */
-  async function switchPreset(
-    presetName: string,
-    presets: Record<string, Preset>,
-    output: { parts: Array<{ type: string; text?: string }> },
-  ): Promise<void> {
-    const preset = presets[presetName];
-    if (!preset) {
-      const available = Object.keys(presets);
-      const hint =
-        available.length > 0
-          ? `Available presets: ${available.join(', ')}`
-          : 'No presets configured. Define presets in oh-my-opencode-slim.jsonc.';
-      output.parts.push(
-        createInternalAgentTextPart(
-          `Preset "${presetName}" not found. ${hint}`,
-        ),
-      );
-      return;
-    }
-
-    // Build the agent config overrides from the preset.
-    // Each preset value is { agentName: AgentOverrideConfig }.
-    // We need to convert to SDK AgentConfig format:
-    // { agent: { agentName: { model, temperature, ... } } }
-    const agentUpdates: Record<
-      string,
-      {
-        model?: string;
-        temperature?: number;
-        variant?: string;
-        options?: Record<string, unknown>;
-      }
-    > = {};
-    for (const [agentName, override] of Object.entries(preset)) {
-      const resolvedName = AGENT_ALIASES[agentName] ?? agentName;
-      const agentConfig = mapOverrideToAgentConfig(override);
-      if (Object.keys(agentConfig).length > 0) {
-        agentUpdates[resolvedName] = agentConfig;
-      }
-    }
-
-    const hasAgentUpdates = Object.keys(agentUpdates).length > 0;
-    if (!hasAgentUpdates) {
-      output.parts.push(
-        createInternalAgentTextPart(
-          `Preset "${presetName}" is empty (no agent overrides defined).`,
-        ),
-      );
-      return;
-    }
-
-    setActiveRuntimePresetWithPrevious(presetName);
-
-    // Persist preset name to user config file so it survives
-    // process restarts. The file write is best-effort and must not
-    // fail the command.
-    try {
-      const { userConfigPath } = findPluginConfigPaths(ctx.directory);
-      if (userConfigPath) {
-        const raw = fs.readFileSync(userConfigPath, 'utf-8');
-        const persisted = JSON.parse(stripJsonComments(raw)) as Record<
-          string,
-          unknown
-        >;
-        persisted.preset = presetName;
-        fs.writeFileSync(
-          userConfigPath,
-          `${JSON.stringify(persisted, null, 2)}\n`,
-        );
-      }
-    } catch {
-      // Non-critical: runtime state is set regardless
-    }
-
-    const snapshot = readTuiSnapshot(ctx.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 }, ctx.directory);
-
-    activePreset = presetName;
-
-    const summaryParts: string[] = [];
-    for (const [name, cfg] of Object.entries(agentUpdates)) {
-      const parts: string[] = [name];
-      if (cfg.model) parts.push(`model: ${cfg.model}`);
-      if (cfg.variant) parts.push(`variant: ${cfg.variant}`);
-      if (cfg.temperature !== undefined) parts.push(`temp: ${cfg.temperature}`);
-      if (cfg.options) parts.push('options: yes');
-      summaryParts.push(parts.join(' → '));
-    }
-
-    output.parts.push(
-      createInternalAgentTextPart(
-        `Saved preset "${presetName}". Restart or reload OpenCode to apply it to agent configuration. The current session was not reloaded to avoid interrupting the active conversation.\n${summaryParts.join('\n')}`,
-      ),
-    );
-  }
-
-  /**
-   * Map an AgentOverrideConfig (from plugin config) to the subset of
-   * Agent config fields shown in the saved preset summary.
-   *
-   * Excluded fields and why:
-   * - prompt, orchestratorPrompt: require restart (resolved at init by config() hook)
-   * - skills, mcps: plugin-level concern, not part of SDK AgentConfig
-   * - displayName: plugin-level concern, not part of SDK AgentConfig
-   */
-  function mapOverrideToAgentConfig(override: AgentOverrideConfig): {
-    model?: string;
-    temperature?: number;
-    variant?: string;
-    options?: Record<string, unknown>;
-  } {
-    const agentConfig: {
-      model?: string;
-      temperature?: number;
-      variant?: string;
-      options?: Record<string, unknown>;
-    } = {};
-
-    if (typeof override.model === 'string') {
-      agentConfig.model = override.model;
-    } else if (Array.isArray(override.model) && override.model.length > 0) {
-      // Array-form model (fallback chain): pick the first entry.
-      // The full chain resolution only happens at init time via config() hook,
-      // so at runtime we use the primary model from the array.
-      const first = override.model[0];
-      agentConfig.model = typeof first === 'string' ? first : first.id;
-      if (typeof first !== 'string' && first.variant) {
-        agentConfig.variant = first.variant;
-      }
-    }
-
-    if (typeof override.temperature === 'number') {
-      agentConfig.temperature = override.temperature;
-    }
-
-    if (typeof override.variant === 'string') {
-      agentConfig.variant = override.variant;
-    }
-
-    if (
-      override.options &&
-      typeof override.options === 'object' &&
-      !Array.isArray(override.options)
-    ) {
-      agentConfig.options = override.options;
-    }
-
-    return agentConfig;
-  }
-
-  /**
-   * Format the list of available presets with the active one highlighted.
-   */
-  function formatPresetList(presets: Record<string, Preset>): string {
-    const names = Object.keys(presets);
-    if (names.length === 0) {
-      return 'No presets configured. Define presets in oh-my-opencode-slim.jsonc under the "presets" field.';
-    }
-
-    const lines = ['Available presets:'];
-    for (const name of names) {
-      const marker = name === activePreset ? ' ← active' : '';
-      const preset = presets[name];
-      const agentNames = Object.keys(preset);
-      const models = agentNames
-        .map((a) => {
-          const cfg = preset[a];
-          const modelStr =
-            typeof cfg.model === 'string'
-              ? cfg.model
-              : Array.isArray(cfg.model) && cfg.model.length > 0
-                ? resolveFirstModel(cfg.model)
-                : undefined;
-          return modelStr ? `    ${a} → ${modelStr}` : `    ${a}`;
-        })
-        .join('\n');
-      lines.push(`  ${name}${marker}`);
-      lines.push(models);
-    }
-    lines.push('\nUsage: /preset <name> to switch.');
-
-    return lines.join('\n');
-  }
-
-  /**
-   * Resolve the first model from an array-form model entry.
-   */
-  function resolveFirstModel(
-    models: Array<string | ModelEntry>,
-  ): string | undefined {
-    if (models.length === 0) return undefined;
-    const first = models[0];
-    return typeof first === 'string' ? first : first.id;
-  }
-
-  return {
-    handleCommandExecuteBefore,
-    registerCommand,
-  };
-}
-
-export type PresetManager = ReturnType<typeof createPresetManager>;

+ 540 - 0
src/tools/preset-switch.test.ts

@@ -0,0 +1,540 @@
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+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,
+  formatPresetOneLine,
+  removeAgentFromPreset,
+  setAgentOverride,
+  switchPresetOnDisk,
+  writePreset,
+} from './preset-switch';
+
+let previousXdgDataHome: string | undefined;
+let previousXdgConfigHome: string | undefined;
+let previousOpenCodeConfigDir: string | undefined;
+let tempDir: string;
+
+beforeEach(() => {
+  previousXdgDataHome = process.env.XDG_DATA_HOME;
+  previousXdgConfigHome = process.env.XDG_CONFIG_HOME;
+  previousOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR;
+  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-preset-switch-'));
+  process.env.XDG_DATA_HOME = tempDir;
+  process.env.XDG_CONFIG_HOME = path.join(tempDir, 'xdg-config');
+  delete process.env.OPENCODE_CONFIG_DIR;
+});
+
+afterEach(() => {
+  if (previousXdgDataHome === undefined) {
+    delete process.env.XDG_DATA_HOME;
+  } else {
+    process.env.XDG_DATA_HOME = previousXdgDataHome;
+  }
+
+  if (previousXdgConfigHome === undefined) {
+    delete process.env.XDG_CONFIG_HOME;
+  } else {
+    process.env.XDG_CONFIG_HOME = previousXdgConfigHome;
+  }
+
+  if (previousOpenCodeConfigDir === undefined) {
+    delete process.env.OPENCODE_CONFIG_DIR;
+  } else {
+    process.env.OPENCODE_CONFIG_DIR = previousOpenCodeConfigDir;
+  }
+
+  fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+describe('switchPresetOnDisk', () => {
+  test('returns a not-found result for an unknown preset', () => {
+    const config: PluginConfig = {
+      presets: {
+        cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
+      },
+    };
+
+    const result = switchPresetOnDisk(tempDir, 'nonexistent', config);
+
+    expect(result.ok).toBe(false);
+    expect(result.presetName).toBe('nonexistent');
+    expect(result.message).toContain('not found');
+    expect(result.message).toContain('cheap');
+    expect(result.summary).toEqual([]);
+  });
+
+  test('not-found result lists no-presets hint when none configured', () => {
+    const config: PluginConfig = {};
+
+    const result = switchPresetOnDisk(tempDir, 'cheap', config);
+
+    expect(result.ok).toBe(false);
+    expect(result.message).toContain('not found');
+    expect(result.message).toContain('No presets configured');
+  });
+
+  test('returns an empty result when the preset has no valid overrides', () => {
+    const config: PluginConfig = {
+      presets: {
+        empty: { orchestrator: {} },
+      },
+    };
+
+    const result = switchPresetOnDisk(tempDir, 'empty', config);
+
+    expect(result.ok).toBe(false);
+    expect(result.message).toContain('empty');
+    expect(result.message).toContain('no agent overrides');
+  });
+
+  test('switches preset and reports a reload-to-apply message', () => {
+    const config: PluginConfig = {
+      presets: {
+        cheap: {
+          orchestrator: { model: 'anthropic/claude-3.5-haiku' },
+          explorer: { model: 'openai/gpt-5.6-luna' },
+        },
+      },
+    };
+
+    const result = switchPresetOnDisk(tempDir, 'cheap', config);
+
+    expect(result.ok).toBe(true);
+    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.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 });
+    process.env.OPENCODE_CONFIG_DIR = configDir;
+
+    const configPath = path.join(configDir, 'oh-my-opencode-slim.jsonc');
+    fs.writeFileSync(
+      configPath,
+      `{
+        // User-selected preset should be updated even in JSONC files.
+        "preset": "old",
+        "agents": {
+          "orchestrator": { "model": "old-model" },
+        },
+      }`,
+    );
+
+    const config: PluginConfig = {
+      presets: {
+        cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
+      },
+    };
+
+    switchPresetOnDisk(tempDir, 'cheap', config);
+
+    const persisted = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as {
+      preset?: string;
+      agents?: Record<string, unknown>;
+    };
+    expect(persisted.preset).toBe('cheap');
+    expect(persisted.agents).toEqual({
+      orchestrator: { model: 'old-model' },
+    });
+  });
+
+  test('resolves legacy alias keys (explore → explorer)', () => {
+    const config: PluginConfig = {
+      presets: {
+        scout: { explore: { model: 'openai/gpt-5.6-luna' } },
+      },
+    };
+
+    const result = switchPresetOnDisk(tempDir, 'scout', config);
+
+    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', () => {
+    const config: PluginConfig = {
+      presets: {
+        mixed: {
+          orchestrator: { model: 'anthropic/claude-3.5-haiku' },
+          explorer: {},
+          oracle: { temperature: 0.3 },
+        },
+      },
+    };
+
+    const result = switchPresetOnDisk(tempDir, 'mixed', config);
+
+    expect(result.ok).toBe(true);
+    expect(result.summary.some((l) => l.startsWith('orchestrator →'))).toBe(
+      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();
+  });
+
+  test('resolves array-form model to the first string entry', () => {
+    const config: PluginConfig = {
+      presets: {
+        fallback: {
+          orchestrator: {
+            model: ['anthropic/claude-3.5-haiku', 'openai/gpt-5.6'],
+          },
+        },
+      },
+    };
+
+    const result = switchPresetOnDisk(tempDir, 'fallback', config);
+
+    expect(result.ok).toBe(true);
+    expect(result.summary).toContain(
+      'orchestrator → model: anthropic/claude-3.5-haiku',
+    );
+  });
+
+  test('resolves array-form model with object entries and inline variant', () => {
+    const config: PluginConfig = {
+      presets: {
+        thinker: {
+          oracle: {
+            model: [
+              { id: 'anthropic/claude-sonnet-4-6', variant: 'thinking' },
+              { id: 'openai/o3' },
+            ],
+          },
+        },
+      },
+    };
+
+    const result = switchPresetOnDisk(tempDir, 'thinker', config);
+
+    expect(result.ok).toBe(true);
+    expect(result.summary).toContain(
+      'oracle → model: anthropic/claude-sonnet-4-6 → variant: thinking',
+    );
+  });
+
+  test('includes temperature and options in the summary', () => {
+    const config: PluginConfig = {
+      presets: {
+        precise: {
+          orchestrator: {
+            model: 'openai/o3',
+            temperature: 0.1,
+            options: { thinking: { type: 'enabled', budgetTokens: 10000 } },
+          },
+        },
+      },
+    };
+
+    const result = switchPresetOnDisk(tempDir, 'precise', config);
+
+    expect(result.ok).toBe(true);
+    expect(result.summary).toContain(
+      'orchestrator → model: openai/o3 → temp: 0.1 → options: yes',
+    );
+  });
+
+  test('does not throw when the user config file is missing', () => {
+    // No config file on disk; persistPresetName is best-effort.
+    const config: PluginConfig = {
+      presets: {
+        cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
+      },
+    };
+
+    expect(() => switchPresetOnDisk(tempDir, 'cheap', config)).not.toThrow();
+  });
+});
+
+describe('writePreset', () => {
+  test('creates a new preset in the user config file', () => {
+    const configDir = path.join(tempDir, 'opencode-config');
+    fs.mkdirSync(configDir, { recursive: true });
+    process.env.OPENCODE_CONFIG_DIR = configDir;
+    fs.writeFileSync(
+      path.join(configDir, 'oh-my-opencode-slim.json'),
+      '{"preset":"old"}',
+    );
+
+    const ok = writePreset(tempDir, 'scout', {
+      explorer: { model: 'openai/gpt-5.6-luna' },
+    });
+
+    expect(ok).toBe(true);
+    const persisted = JSON.parse(
+      fs.readFileSync(
+        path.join(configDir, 'oh-my-opencode-slim.json'),
+        'utf-8',
+      ),
+    ) as { presets?: Record<string, unknown> };
+    expect(persisted.presets?.scout).toEqual({
+      explorer: { model: 'openai/gpt-5.6-luna' },
+    });
+    // existing fields preserved
+    expect(persisted.preset).toBe('old');
+  });
+
+  test('overwrites an existing preset of the same name', () => {
+    const configDir = path.join(tempDir, 'opencode-config');
+    fs.mkdirSync(configDir, { recursive: true });
+    process.env.OPENCODE_CONFIG_DIR = configDir;
+    fs.writeFileSync(
+      path.join(configDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        presets: { scout: { orchestrator: { model: 'old' } } },
+      }),
+    );
+
+    writePreset(tempDir, 'scout', {
+      oracle: { model: 'new' },
+    });
+
+    const persisted = JSON.parse(
+      fs.readFileSync(
+        path.join(configDir, 'oh-my-opencode-slim.json'),
+        'utf-8',
+      ),
+    ) as { presets?: Record<string, unknown> };
+    expect(persisted.presets?.scout).toEqual({ oracle: { model: 'new' } });
+  });
+
+  test('writes into a freshly empty user config', () => {
+    const configDir = path.join(tempDir, 'opencode-config');
+    fs.mkdirSync(configDir, { recursive: true });
+    process.env.OPENCODE_CONFIG_DIR = configDir;
+    fs.writeFileSync(path.join(configDir, 'oh-my-opencode-slim.json'), '{}');
+
+    const ok = writePreset(tempDir, 'solo', {
+      orchestrator: { model: 'x' },
+    });
+
+    expect(ok).toBe(true);
+    const persisted = JSON.parse(
+      fs.readFileSync(
+        path.join(configDir, 'oh-my-opencode-slim.json'),
+        'utf-8',
+      ),
+    ) as { presets?: Record<string, unknown> };
+    expect(persisted.presets?.solo).toEqual({ orchestrator: { model: 'x' } });
+  });
+});
+
+describe('deletePreset', () => {
+  test('removes a preset and returns true', () => {
+    const configDir = path.join(tempDir, 'opencode-config');
+    fs.mkdirSync(configDir, { recursive: true });
+    process.env.OPENCODE_CONFIG_DIR = configDir;
+    fs.writeFileSync(
+      path.join(configDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        presets: {
+          scout: { orchestrator: { model: 'a' } },
+          keep: { oracle: { model: 'b' } },
+        },
+      }),
+    );
+
+    const ok = deletePreset(tempDir, 'scout');
+
+    expect(ok).toBe(true);
+    const persisted = JSON.parse(
+      fs.readFileSync(
+        path.join(configDir, 'oh-my-opencode-slim.json'),
+        'utf-8',
+      ),
+    ) as { presets?: Record<string, unknown> };
+    expect(persisted.presets).toEqual({ keep: { oracle: { model: 'b' } } });
+  });
+
+  test('clears the active preset field when deleting the active preset', () => {
+    const configDir = path.join(tempDir, 'opencode-config');
+    fs.mkdirSync(configDir, { recursive: true });
+    process.env.OPENCODE_CONFIG_DIR = configDir;
+    fs.writeFileSync(
+      path.join(configDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        preset: 'scout',
+        presets: { scout: { orchestrator: { model: 'a' } } },
+      }),
+    );
+
+    deletePreset(tempDir, 'scout');
+
+    const persisted = JSON.parse(
+      fs.readFileSync(
+        path.join(configDir, 'oh-my-opencode-slim.json'),
+        'utf-8',
+      ),
+    ) as { preset?: string; presets?: Record<string, unknown> };
+    expect(persisted.preset).toBeUndefined();
+    expect(persisted.presets).toEqual({});
+  });
+
+  test('returns false when the preset does not exist', () => {
+    const configDir = path.join(tempDir, 'opencode-config');
+    fs.mkdirSync(configDir, { recursive: true });
+    process.env.OPENCODE_CONFIG_DIR = configDir;
+    fs.writeFileSync(
+      path.join(configDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({ presets: { keep: { orchestrator: { model: 'a' } } } }),
+    );
+
+    expect(deletePreset(tempDir, 'missing')).toBe(false);
+  });
+
+  test('returns false when no config file exists', () => {
+    expect(deletePreset(tempDir, 'anything')).toBe(false);
+  });
+});
+
+describe('setAgentOverride / removeAgentFromPreset', () => {
+  test('setAgentOverride adds a new agent immutably', () => {
+    const preset = { orchestrator: { model: 'a' } };
+    const next = setAgentOverride(preset, 'oracle', { model: 'b' });
+    expect(next).toEqual({
+      orchestrator: { model: 'a' },
+      oracle: { model: 'b' },
+    });
+    expect(preset).toEqual({ orchestrator: { model: 'a' } });
+  });
+
+  test('setAgentOverride replaces an existing agent', () => {
+    const preset = { orchestrator: { model: 'a' } };
+    const next = setAgentOverride(preset, 'orchestrator', {
+      model: 'b',
+      variant: 'thinking',
+    });
+    expect(next).toEqual({
+      orchestrator: { model: 'b', variant: 'thinking' },
+    });
+  });
+
+  test('removeAgentFromPreset removes an agent immutably', () => {
+    const preset = {
+      orchestrator: { model: 'a' },
+      oracle: { model: 'b' },
+    };
+    const next = removeAgentFromPreset(preset, 'oracle');
+    expect(next).toEqual({ orchestrator: { model: 'a' } });
+    expect(preset).toEqual({
+      orchestrator: { model: 'a' },
+      oracle: { model: 'b' },
+    });
+  });
+
+  test('removeAgentFromPreset is a no-op for absent agents', () => {
+    const preset = { orchestrator: { model: 'a' } };
+    expect(removeAgentFromPreset(preset, 'oracle')).toBe(preset);
+  });
+});
+
+describe('formatPresetOneLine', () => {
+  test('joins agent → model pairs', () => {
+    const config: PluginConfig = {
+      presets: {
+        team: {
+          orchestrator: { model: 'ustc/glm-5.2' },
+          oracle: { model: 'ustc/glm-5.2' },
+        },
+      },
+    };
+
+    expect(formatPresetOneLine(config.presets?.team ?? {})).toBe(
+      'orchestrator → ustc/glm-5.2, oracle → ustc/glm-5.2',
+    );
+  });
+
+  test('falls back to agent name when model is absent', () => {
+    const config: PluginConfig = {
+      presets: {
+        bare: { oracle: { temperature: 0.3 } },
+      },
+    };
+
+    expect(formatPresetOneLine(config.presets?.bare ?? {})).toBe('oracle');
+  });
+});
+
+describe('buildPresetSummary', () => {
+  test('orders fields as model, variant, temp, options', () => {
+    const summary = buildPresetSummary({
+      oracle: {
+        model: 'anthropic/claude-sonnet-4-6',
+        variant: 'thinking',
+        temperature: 0.2,
+        options: { thinking: { type: 'enabled' } },
+      },
+    });
+
+    expect(summary).toEqual([
+      'oracle → model: anthropic/claude-sonnet-4-6 → variant: thinking → temp: 0.2 → options: yes',
+    ]);
+  });
+});

+ 364 - 0
src/tools/preset-switch.ts

@@ -0,0 +1,364 @@
+import * as fs from 'node:fs';
+import { stripJsonComments } from '../cli/config-io';
+import type {
+  AgentOverrideConfig,
+  ModelEntry,
+  PluginConfig,
+  Preset,
+} 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
+ * a TUI toast/dialog (it is never injected into the LLM context).
+ */
+export interface PresetSwitchResult {
+  ok: boolean;
+  presetName: string;
+  message: string;
+  /** Per-agent summary lines, e.g. "orchestrator → model: x, variant: y". */
+  summary: string[];
+}
+
+/** A flattened, SDK-shaped agent override derived from a preset entry. */
+export interface AgentUpdate {
+  model?: string;
+  temperature?: number;
+  variant?: string;
+  options?: Record<string, unknown>;
+}
+
+/**
+ * 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.
+ *
+ * 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.
+ */
+export function switchPresetOnDisk(
+  directory: string,
+  presetName: string,
+  config: PluginConfig,
+): PresetSwitchResult {
+  const presets = config.presets ?? {};
+  const preset = presets[presetName];
+
+  if (!preset) {
+    const available = Object.keys(presets);
+    const hint =
+      available.length > 0
+        ? `Available presets: ${available.join(', ')}`
+        : 'No presets configured. Define presets in oh-my-opencode-slim.jsonc.';
+    return {
+      ok: false,
+      presetName,
+      message: `Preset "${presetName}" not found. ${hint}`,
+      summary: [],
+    };
+  }
+
+  const agentUpdates = buildAgentUpdates(preset);
+  if (Object.keys(agentUpdates).length === 0) {
+    return {
+      ok: false,
+      presetName,
+      message: `Preset "${presetName}" is empty (no agent overrides defined).`,
+      summary: [],
+    };
+  }
+
+  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.`,
+    summary: buildPresetSummary(agentUpdates),
+  };
+}
+
+/**
+ * Build the SDK-shaped agent overrides from a preset, resolving legacy alias
+ * keys (e.g. "explore" → "explorer").
+ */
+export function buildAgentUpdates(preset: Preset): Record<string, AgentUpdate> {
+  const agentUpdates: Record<string, AgentUpdate> = {};
+  for (const [agentName, override] of Object.entries(preset)) {
+    const resolvedName = AGENT_ALIASES[agentName] ?? agentName;
+    const agentConfig = mapOverrideToAgentConfig(override);
+    if (Object.keys(agentConfig).length > 0) {
+      agentUpdates[resolvedName] = agentConfig;
+    }
+  }
+  return agentUpdates;
+}
+
+/**
+ * Map an AgentOverrideConfig (from plugin config) to the subset of agent
+ * config fields shown in the saved preset summary.
+ */
+export function mapOverrideToAgentConfig(
+  override: AgentOverrideConfig,
+): AgentUpdate {
+  const agentConfig: AgentUpdate = {};
+
+  if (typeof override.model === 'string') {
+    agentConfig.model = override.model;
+  } else if (Array.isArray(override.model) && override.model.length > 0) {
+    // Array-form model (fallback chain): pick the first entry. Full chain
+    // resolution happens at init time via the config() hook, so at runtime we
+    // use the primary model from the array.
+    const first = override.model[0];
+    agentConfig.model = typeof first === 'string' ? first : first.id;
+    if (typeof first !== 'string' && first.variant) {
+      agentConfig.variant = first.variant;
+    }
+  }
+
+  if (typeof override.temperature === 'number') {
+    agentConfig.temperature = override.temperature;
+  }
+
+  if (typeof override.variant === 'string') {
+    agentConfig.variant = override.variant;
+  }
+
+  if (
+    override.options &&
+    typeof override.options === 'object' &&
+    !Array.isArray(override.options)
+  ) {
+    agentConfig.options = override.options;
+  }
+
+  return agentConfig;
+}
+
+/** Build the per-agent summary lines for a switch result / picker tooltip. */
+export function buildPresetSummary(
+  agentUpdates: Record<string, AgentUpdate>,
+): string[] {
+  const summaryParts: string[] = [];
+  for (const [name, cfg] of Object.entries(agentUpdates)) {
+    const parts: string[] = [name];
+    if (cfg.model) parts.push(`model: ${cfg.model}`);
+    if (cfg.variant) parts.push(`variant: ${cfg.variant}`);
+    if (cfg.temperature !== undefined) parts.push(`temp: ${cfg.temperature}`);
+    if (cfg.options) parts.push('options: yes');
+    summaryParts.push(parts.join(' → '));
+  }
+  return summaryParts;
+}
+
+/**
+ * A single-line description of a preset for the TUI picker, e.g.
+ * "orchestrator → glm-5.2, oracle → glm-5.2".
+ */
+export function formatPresetOneLine(preset: Preset): string {
+  const lines: string[] = [];
+  for (const [agentName, override] of Object.entries(preset)) {
+    const modelStr =
+      typeof override.model === 'string'
+        ? override.model
+        : Array.isArray(override.model) && override.model.length > 0
+          ? resolveFirstModel(override.model)
+          : undefined;
+    lines.push(modelStr ? `${agentName} → ${modelStr}` : agentName);
+  }
+  return lines.join(', ');
+}
+
+/**
+ * Format the full preset list with the active one highlighted. Used by
+ * non-TUI surfaces (e.g. a future headless listing); the TUI uses the picker.
+ */
+export function formatPresetList(
+  presets: Record<string, Preset>,
+  activePreset: string | null,
+): string {
+  const names = Object.keys(presets);
+  if (names.length === 0) {
+    return 'No presets configured. Define presets in oh-my-opencode-slim.jsonc under the "presets" field.';
+  }
+
+  const lines = ['Available presets:'];
+  for (const name of names) {
+    const marker = name === activePreset ? ' ← active' : '';
+    const preset = presets[name];
+    const agentNames = Object.keys(preset);
+    const models = agentNames
+      .map((a) => {
+        const cfg = preset[a];
+        const modelStr =
+          typeof cfg.model === 'string'
+            ? cfg.model
+            : Array.isArray(cfg.model) && cfg.model.length > 0
+              ? resolveFirstModel(cfg.model)
+              : undefined;
+        return modelStr ? `    ${a} → ${modelStr}` : `    ${a}`;
+      })
+      .join('\n');
+    lines.push(`  ${name}${marker}`);
+    lines.push(models);
+  }
+  lines.push('\nUsage: /preset <name> to switch.');
+
+  return lines.join('\n');
+}
+
+function resolveFirstModel(
+  models: Array<string | ModelEntry>,
+): string | undefined {
+  if (models.length === 0) return undefined;
+  const first = models[0];
+  return typeof first === 'string' ? first : first.id;
+}
+
+/**
+ * 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 update is the immediate user-visible effect.
+ *
+ * Note: this rewrites the file as plain JSON (JSONC comments are not
+ * preserved), matching the prior server-side behavior.
+ */
+function persistPresetName(directory: string, presetName: string): void {
+  try {
+    const { userConfigPath } = findPluginConfigPaths(directory);
+    if (!userConfigPath) return;
+    const raw = fs.readFileSync(userConfigPath, 'utf-8');
+    const persisted = JSON.parse(stripJsonComments(raw)) as Record<
+      string,
+      unknown
+    >;
+    persisted.preset = presetName;
+    fs.writeFileSync(userConfigPath, `${JSON.stringify(persisted, null, 2)}\n`);
+  } catch {
+    // Non-critical: the TUI snapshot is updated regardless.
+  }
+}
+
+/**
+ * Merge the preset's model/variant overrides into the on-disk TUI snapshot so
+ * the sidebar reflects the new models on its next poll.
+ */
+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.
+ */
+function readUserConfig(directory: string): Record<string, unknown> | null {
+  try {
+    const { userConfigPath } = findPluginConfigPaths(directory);
+    if (!userConfigPath) return null;
+    const raw = fs.readFileSync(userConfigPath, 'utf-8');
+    return JSON.parse(stripJsonComments(raw)) as Record<string, unknown>;
+  } catch {
+    return null;
+  }
+}
+
+/**
+ * Write the user-level config file (plain JSON; JSONC comments are not
+ * preserved, matching the existing switchPreset behavior). Best-effort.
+ */
+function writeUserConfig(
+  directory: string,
+  config: Record<string, unknown>,
+): boolean {
+  try {
+    const { userConfigPath } = findPluginConfigPaths(directory);
+    if (!userConfigPath) return false;
+    fs.writeFileSync(userConfigPath, `${JSON.stringify(config, null, 2)}\n`);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+/**
+ * Persist a preset (create or overwrite) into the user config's `presets`
+ * object. Returns true on success.
+ */
+export function writePreset(
+  directory: string,
+  name: string,
+  preset: Preset,
+): boolean {
+  const config = readUserConfig(directory) ?? {};
+  const presets = (config.presets as Record<string, Preset> | undefined) ?? {};
+  presets[name] = preset;
+  config.presets = presets;
+  return writeUserConfig(directory, config);
+}
+
+/**
+ * Delete a preset from the user config. Returns true if removed, false if the
+ * preset did not exist or the write failed.
+ */
+export function deletePreset(directory: string, name: string): boolean {
+  const config = readUserConfig(directory);
+  if (!config) return false;
+  const presets = config.presets as Record<string, Preset> | undefined;
+  if (!presets || !(name in presets)) return false;
+  delete presets[name];
+  // If the active preset was deleted, clear the `preset` field too.
+  if (config.preset === name) {
+    delete config.preset;
+  }
+  return writeUserConfig(directory, config);
+}
+
+/**
+ * Set (or replace) an agent override within an in-memory preset. Returns a
+ * new preset object; does not mutate the input.
+ */
+export function setAgentOverride(
+  preset: Preset,
+  agentName: string,
+  override: AgentOverrideConfig,
+): Preset {
+  return { ...preset, [agentName]: override };
+}
+
+/**
+ * Remove an agent from an in-memory preset. Returns a new preset object; does
+ * not mutate the input. If the agent was not present, the preset is unchanged.
+ */
+export function removeAgentFromPreset(
+  preset: Preset,
+  agentName: string,
+): Preset {
+  if (!(agentName in preset)) return preset;
+  const next = { ...preset };
+  delete next[agentName];
+  return next;
+}

+ 726 - 0
src/tui-preset.ts

@@ -0,0 +1,726 @@
+/**
+ * Three-level `/preset` manager for the TUI.
+ *
+ * Level 1 — preset list (Apply / Edit / Create / Delete)
+ * Level 2 — agents in a preset (Add / Edit / Remove / Save)
+ * Level 3 — edit one agent's model, variant, temperature, options
+ *
+ * Pure TUI: uses `api.ui.*` dialog primitives and `api.client.providers()`
+ * for the model list. Never sends a message to the server's `command()` flow,
+ * so it triggers no LLM turn — same channel as the built-in `/models`.
+ *
+ * All preset mutations are written to the user-level config file
+ * (`oh-my-opencode-slim.json[c]`). Applying a preset writes the `preset`
+ * field and the TUI snapshot; a reload is required for the live agent
+ * registry to pick it up (the current session is deliberately not
+ * interrupted, to avoid destabilizing running subagents).
+ */
+import type {
+  TuiDialogSelectOption,
+  TuiPluginApi,
+} from '@opencode-ai/plugin/tui';
+import type { JSX } from '@opentui/solid';
+import { createElement, insert, setProp } from '@opentui/solid';
+import type { AgentOverrideConfig, Preset } from './config';
+import { ALL_AGENT_NAMES } from './config/constants';
+import { loadPluginConfig } from './config/loader';
+import {
+  deletePreset,
+  removeAgentFromPreset,
+  setAgentOverride,
+  switchPresetOnDisk,
+  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 {
+  const node = createElement('text');
+  setProp(node, 'text', text);
+  insert(node, text);
+  return node as JSX.Element;
+}
+
+/** Sentinel option values used to embed actions in `DialogSelect` lists. */
+const ACTION_NEW_PRESET = '__omo_new_preset__';
+const ACTION_ADD_AGENT = '__omo_add_agent__';
+const ACTION_BACK = '__omo_back__';
+
+interface ManagerState {
+  api: TuiPluginApi;
+  directory: string;
+  snapshotRef: { snapshot: TuiSnapshot };
+}
+
+/**
+ * Entry point: open the preset manager at Level 1. Re-reads the config each
+ * time it is opened so newly-edited files are reflected.
+ */
+export function openPresetManager(
+  api: TuiPluginApi,
+  directory: string,
+  snapshotRef: { snapshot: TuiSnapshot },
+): void {
+  showPresetList({ api, directory, snapshotRef });
+}
+
+function showPresetList(state: ManagerState): void {
+  const config = loadPluginConfig(state.directory, { silent: true });
+  const presets = config.presets ?? {};
+  const names = Object.keys(presets);
+  const activePreset = config.preset ?? null;
+
+  if (names.length === 0 && !activePreset) {
+    // No presets at all: jump straight to "create" prompt.
+    promptAndCreatePreset(state, () => showPresetList(state));
+    return;
+  }
+
+  const options: TuiDialogSelectOption<string>[] = names.map((name) => ({
+    title: name === activePreset ? `${name} (active)` : name,
+    value: name,
+    description: describePreset(presets[name]),
+  }));
+  options.push({
+    title: '+ Create new preset',
+    value: ACTION_NEW_PRESET,
+  });
+
+  state.api.ui.dialog.replace(() =>
+    state.api.ui.Dialog({
+      size: 'large',
+      onClose: () => state.api.ui.dialog.clear(),
+      children: state.api.ui.DialogSelect<string>({
+        title: 'Presets',
+        placeholder: 'Select a preset to apply or edit',
+        options,
+        onSelect: (option) => {
+          if (option.value === ACTION_NEW_PRESET) {
+            promptAndCreatePreset(state, () => showPresetList(state));
+            return;
+          }
+          showPresetActions(state, option.value);
+        },
+      }),
+    }),
+  );
+}
+
+function showPresetActions(state: ManagerState, presetName: string): void {
+  const options: TuiDialogSelectOption<string>[] = [
+    { title: 'Apply preset (reload to take effect)', value: 'apply' },
+    { title: 'Edit agents', value: 'edit' },
+    { title: 'Delete preset', value: 'delete' },
+    { title: '← Back', value: ACTION_BACK },
+  ];
+
+  state.api.ui.dialog.replace(() =>
+    state.api.ui.Dialog({
+      size: 'large',
+      onClose: () => state.api.ui.dialog.clear(),
+      children: state.api.ui.DialogSelect<string>({
+        title: `Preset: ${presetName}`,
+        options,
+        onSelect: (option) => {
+          switch (option.value) {
+            case 'apply':
+              applyPreset(state, presetName);
+              break;
+            case 'edit':
+              editPreset(state, presetName);
+              break;
+            case 'delete':
+              confirmDeletePreset(state, presetName);
+              break;
+            default:
+              showPresetList(state);
+          }
+        },
+      }),
+    }),
+  );
+}
+
+function applyPreset(state: ManagerState, presetName: string): void {
+  const config = loadPluginConfig(state.directory, { silent: true });
+  const result = switchPresetOnDisk(state.directory, presetName, config);
+  state.api.ui.dialog.clear();
+  state.api.ui.toast({
+    variant: result.ok ? 'success' : 'warning',
+    title: result.ok ? 'Preset saved' : 'Preset switch failed',
+    message: result.ok
+      ? `Saved preset "${presetName}". Reload OpenCode to apply. ${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 {
+  state.api.ui.dialog.replace(() =>
+    state.api.ui.Dialog({
+      size: 'large',
+      onClose: () => state.api.ui.dialog.clear(),
+      children: state.api.ui.DialogConfirm({
+        title: 'Delete preset',
+        message: `Delete preset "${presetName}"? This cannot be undone.`,
+        onConfirm: () => {
+          const ok = deletePreset(state.directory, presetName);
+          state.api.ui.dialog.clear();
+          state.api.ui.toast({
+            variant: ok ? 'success' : 'warning',
+            title: ok ? 'Preset deleted' : 'Delete failed',
+            message: ok
+              ? `Deleted preset "${presetName}".`
+              : `Could not delete "${presetName}" (it may not exist in the user config file).`,
+          });
+          showPresetList(state);
+        },
+        onCancel: () => showPresetActions(state, presetName),
+      }),
+    }),
+  );
+}
+
+function promptAndCreatePreset(
+  state: ManagerState,
+  onCancel: () => void,
+): void {
+  state.api.ui.dialog.replace(() =>
+    state.api.ui.Dialog({
+      size: 'large',
+      onClose: () => state.api.ui.dialog.clear(),
+      children: state.api.ui.DialogPrompt({
+        title: 'Create new preset',
+        placeholder: 'preset-name',
+        onConfirm: (value) => {
+          const name = value.trim();
+          if (!name) {
+            onCancel();
+            return;
+          }
+          if (/\s/.test(name)) {
+            state.api.ui.toast({
+              variant: 'warning',
+              title: 'Invalid name',
+              message: 'Preset names cannot contain spaces.',
+            });
+            promptAndCreatePreset(state, onCancel);
+            return;
+          }
+          // Start editing an empty preset under this name.
+          editPresetWorkingCopy(state, name, {});
+        },
+        onCancel,
+      }),
+    }),
+  );
+}
+
+function editPreset(state: ManagerState, presetName: string): void {
+  const config = loadPluginConfig(state.directory, { silent: true });
+  const preset = config.presets?.[presetName] ?? {};
+  // Work on a shallow copy so in-memory edits don't mutate the loaded config.
+  editPresetWorkingCopy(state, presetName, { ...preset });
+}
+
+function editPresetWorkingCopy(
+  state: ManagerState,
+  presetName: string,
+  working: Preset,
+): void {
+  const agentNames = Object.keys(working);
+  const options: TuiDialogSelectOption<string>[] = agentNames.map((name) => ({
+    title: name,
+    value: name,
+    description: describeOverride(working[name]),
+  }));
+  options.push({ title: '+ Add agent', value: ACTION_ADD_AGENT });
+  options.push({ title: '− Remove agent', value: '__omo_remove_agent__' });
+  options.push({ title: '💾 Save', value: '__omo_save__' });
+  options.push({
+    title: '💾 Save & Apply',
+    value: '__omo_save_apply__',
+  });
+  options.push({ title: '← Back', value: ACTION_BACK });
+
+  state.api.ui.dialog.replace(() =>
+    state.api.ui.Dialog({
+      size: 'large',
+      onClose: () => state.api.ui.dialog.clear(),
+      children: state.api.ui.DialogSelect<string>({
+        title: `Edit preset: ${presetName}`,
+        options,
+        onSelect: (option) => {
+          switch (option.value) {
+            case ACTION_ADD_AGENT:
+              promptAddAgent(state, presetName, working);
+              break;
+            case '__omo_remove_agent__':
+              promptRemoveAgent(state, presetName, working);
+              break;
+            case '__omo_save__':
+              savePreset(state, presetName, working, false);
+              break;
+            case '__omo_save_apply__': {
+              const saved = savePreset(state, presetName, working, false);
+              if (saved) applyPreset(state, presetName);
+              break;
+            }
+            case ACTION_BACK:
+              showPresetList(state);
+              break;
+            default:
+              // An agent was selected → edit it.
+              editAgent(state, presetName, working, option.value);
+          }
+        },
+      }),
+    }),
+  );
+}
+
+function promptAddAgent(
+  state: ManagerState,
+  presetName: string,
+  working: Preset,
+): void {
+  const present = new Set(Object.keys(working));
+  const available = ALL_AGENT_NAMES.filter((n) => !present.has(n));
+  if (available.length === 0) {
+    state.api.ui.toast({
+      variant: 'info',
+      title: 'No agents left',
+      message: 'All known agents are already in this preset.',
+    });
+    editPresetWorkingCopy(state, presetName, working);
+    return;
+  }
+  const options: TuiDialogSelectOption<string>[] = available.map((n) => ({
+    title: n,
+    value: n,
+  }));
+  options.push({ title: '← Back', value: ACTION_BACK });
+
+  state.api.ui.dialog.replace(() =>
+    state.api.ui.Dialog({
+      size: 'large',
+      onClose: () => state.api.ui.dialog.clear(),
+      children: state.api.ui.DialogSelect<string>({
+        title: 'Add agent',
+        options,
+        onSelect: (option) => {
+          if (option.value === ACTION_BACK) {
+            editPresetWorkingCopy(state, presetName, working);
+            return;
+          }
+          // Add the agent with an empty override, then jump to Level 3.
+          const next = setAgentOverride(working, option.value, {});
+          editAgent(state, presetName, next, option.value);
+        },
+      }),
+    }),
+  );
+}
+
+function promptRemoveAgent(
+  state: ManagerState,
+  presetName: string,
+  working: Preset,
+): void {
+  const agentNames = Object.keys(working);
+  if (agentNames.length === 0) {
+    state.api.ui.toast({
+      variant: 'info',
+      title: 'No agents',
+      message: 'This preset has no agents to remove.',
+    });
+    editPresetWorkingCopy(state, presetName, working);
+    return;
+  }
+  const options: TuiDialogSelectOption<string>[] = agentNames.map((n) => ({
+    title: n,
+    value: n,
+    description: describeOverride(working[n]),
+  }));
+  options.push({ title: '← Back', value: ACTION_BACK });
+
+  state.api.ui.dialog.replace(() =>
+    state.api.ui.Dialog({
+      size: 'large',
+      onClose: () => state.api.ui.dialog.clear(),
+      children: state.api.ui.DialogSelect<string>({
+        title: 'Remove agent',
+        options,
+        onSelect: (option) => {
+          if (option.value === ACTION_BACK) {
+            editPresetWorkingCopy(state, presetName, working);
+            return;
+          }
+          const next = removeAgentFromPreset(working, option.value);
+          state.api.ui.toast({
+            variant: 'success',
+            title: 'Agent removed',
+            message: `Removed ${option.value} from preset.`,
+          });
+          editPresetWorkingCopy(state, presetName, next);
+        },
+      }),
+    }),
+  );
+}
+
+function savePreset(
+  state: ManagerState,
+  presetName: string,
+  working: Preset,
+  returnToList: boolean,
+): boolean {
+  // Strip agents whose override is empty — they add nothing to the preset.
+  const cleaned: Preset = {};
+  for (const [agent, override] of Object.entries(working)) {
+    if (Object.keys(override).length > 0) {
+      cleaned[agent] = override;
+    }
+  }
+  const ok = writePreset(state.directory, presetName, cleaned);
+  state.api.ui.toast({
+    variant: ok ? 'success' : 'warning',
+    title: ok ? 'Preset saved' : 'Save failed',
+    message: ok
+      ? `Saved preset "${presetName}" to config.`
+      : `Could not write preset "${presetName}" to the config file.`,
+  });
+  if (returnToList) {
+    showPresetList(state);
+  }
+  return ok;
+}
+
+/**
+ * Level 3: edit one agent's override. Walks through model → variant →
+ * temperature → options, then commits back into the working preset.
+ */
+function editAgent(
+  state: ManagerState,
+  presetName: string,
+  working: Preset,
+  agentName: string,
+): void {
+  const current = working[agentName] ?? {};
+  pickModel(state, presetName, working, agentName, current);
+}
+
+interface ModelOption {
+  /** Full `providerID/modelID` string used in the preset config. */
+  value: string;
+  title: string;
+  description: string;
+  /** Variant names available for this model, if any. */
+  variants: string[];
+}
+
+async function fetchModelOptions(api: TuiPluginApi): Promise<ModelOption[]> {
+  // Guard: the TUI's client may not expose config.providers in all builds.
+  if (!api.client?.config?.providers) {
+    return [];
+  }
+  const res = (await api.client.config.providers()) as {
+    data?: {
+      providers?: Array<{
+        id: string;
+        models: Record<
+          string,
+          { name?: string; variants?: Record<string, unknown> }
+        >;
+      }>;
+    };
+  };
+  const providers = res.data?.providers ?? [];
+  const options: ModelOption[] = [];
+  for (const provider of providers) {
+    if (!provider?.models) continue;
+    for (const [modelId, model] of Object.entries(provider.models)) {
+      if (!model) continue;
+      options.push({
+        value: `${provider.id}/${modelId}`,
+        title: model.name ?? modelId,
+        description: provider.id,
+        variants: model.variants ? Object.keys(model.variants) : [],
+      });
+    }
+  }
+  return options;
+}
+
+function pickModel(
+  state: ManagerState,
+  presetName: string,
+  working: Preset,
+  agentName: string,
+  current: AgentOverrideConfig,
+): void {
+  // Show a toast while fetching — we keep the current dialog (Level 2)
+  // visible until the model list is ready, then replace. This avoids a
+  // dialog state conflict where a loading dialog's onClose could fire
+  // dialog.clear() while the async callback later calls dialog.replace().
+  state.api.ui.toast({
+    variant: 'info',
+    title: 'Loading models',
+    message: `Fetching available models for ${agentName}…`,
+  });
+
+  void (async () => {
+    let options: ModelOption[];
+    try {
+      options = await fetchModelOptions(state.api);
+    } catch (err) {
+      state.api.ui.toast({
+        variant: 'warning',
+        title: 'Could not load models',
+        message: `Failed to fetch providers: ${String(err)}`,
+      });
+      editPresetWorkingCopy(state, presetName, working);
+      return;
+    }
+
+    if (options.length === 0) {
+      state.api.ui.toast({
+        variant: 'warning',
+        title: 'No models available',
+        message:
+          'Could not retrieve the model list. You can edit the preset config file manually.',
+      });
+      editPresetWorkingCopy(state, presetName, working);
+      return;
+    }
+
+    try {
+      // Only pass `current` if it matches an existing option, to avoid
+      // DialogSelect crashing on a non-existent current value.
+      const currentModel =
+        typeof current.model === 'string'
+          ? options.find((o) => o.value === current.model)?.value
+          : undefined;
+
+      const selectOptions: TuiDialogSelectOption<string>[] = options.map(
+        (o) => ({
+          title: o.title,
+          value: o.value,
+          description: o.description,
+        }),
+      );
+
+      state.api.ui.dialog.replace(() =>
+        state.api.ui.Dialog({
+          size: 'large',
+          onClose: () => state.api.ui.dialog.clear(),
+          children: state.api.ui.DialogSelect<string>({
+            title: `Edit ${agentName} — model`,
+            placeholder: 'Search models',
+            current: currentModel,
+            options: selectOptions,
+            onSelect: (option) => {
+              const chosen = options.find((o) => o.value === option.value);
+              const next: AgentOverrideConfig = {
+                ...current,
+                model: option.value,
+              };
+              pickVariant(
+                state,
+                presetName,
+                working,
+                agentName,
+                next,
+                chosen?.variants ?? [],
+              );
+            },
+          }),
+        }),
+      );
+    } catch (err) {
+      state.api.ui.toast({
+        variant: 'error',
+        title: 'Model picker error',
+        message: String(err),
+      });
+      editPresetWorkingCopy(state, presetName, working);
+    }
+  })();
+}
+
+function pickVariant(
+  state: ManagerState,
+  presetName: string,
+  working: Preset,
+  agentName: string,
+  current: AgentOverrideConfig,
+  availableVariants: string[],
+): void {
+  // No variants for this model → skip to temperature.
+  if (availableVariants.length === 0) {
+    pickTemperature(state, presetName, working, agentName, current);
+    return;
+  }
+
+  const options: TuiDialogSelectOption<string>[] = [
+    { title: 'none', value: '', description: 'no variant' },
+    ...availableVariants.map((v) => ({ title: v, value: v })),
+  ];
+
+  state.api.ui.dialog.replace(() =>
+    state.api.ui.Dialog({
+      size: 'large',
+      onClose: () => state.api.ui.dialog.clear(),
+      children: state.api.ui.DialogSelect<string>({
+        title: `Edit ${agentName} — variant (thinking strength)`,
+        current: typeof current.variant === 'string' ? current.variant : '',
+        options,
+        onSelect: (option) => {
+          const next: AgentOverrideConfig = { ...current };
+          if (option.value) {
+            next.variant = option.value;
+          } else {
+            delete next.variant;
+          }
+          pickTemperature(state, presetName, working, agentName, next);
+        },
+      }),
+    }),
+  );
+}
+
+function pickTemperature(
+  state: ManagerState,
+  presetName: string,
+  working: Preset,
+  agentName: string,
+  current: AgentOverrideConfig,
+): void {
+  state.api.ui.dialog.replace(() =>
+    state.api.ui.Dialog({
+      size: 'large',
+      onClose: () => state.api.ui.dialog.clear(),
+      children: state.api.ui.DialogPrompt({
+        title: `Edit ${agentName} — temperature`,
+        description: () =>
+          desc(
+            'Enter a number 0–2, or leave blank for the provider default (typically 1.0).',
+          ),
+        value:
+          typeof current.temperature === 'number'
+            ? String(current.temperature)
+            : '',
+        placeholder: 'none',
+        onConfirm: (value) => {
+          const trimmed = value.trim();
+          const next: AgentOverrideConfig = { ...current };
+          if (trimmed) {
+            const parsed = Number(trimmed);
+            if (Number.isNaN(parsed)) {
+              state.api.ui.toast({
+                variant: 'warning',
+                title: 'Invalid temperature',
+                message: 'Temperature must be a number.',
+              });
+              pickTemperature(state, presetName, working, agentName, current);
+              return;
+            }
+            next.temperature = parsed;
+          } else {
+            delete next.temperature;
+          }
+          pickOptions(state, presetName, working, agentName, next);
+        },
+        onCancel: () => editPresetWorkingCopy(state, presetName, working),
+      }),
+    }),
+  );
+}
+
+function pickOptions(
+  state: ManagerState,
+  presetName: string,
+  working: Preset,
+  agentName: string,
+  current: AgentOverrideConfig,
+): void {
+  const currentJson =
+    current.options && typeof current.options === 'object'
+      ? JSON.stringify(current.options)
+      : '{}';
+  state.api.ui.dialog.replace(() =>
+    state.api.ui.Dialog({
+      size: 'large',
+      onClose: () => state.api.ui.dialog.clear(),
+      children: state.api.ui.DialogPrompt({
+        title: `Edit ${agentName} — options (JSON)`,
+        description: () =>
+          desc(
+            'Provider-specific options as JSON, e.g. {"thinking":{"type":"enabled","budgetTokens":10000}}. Use {} for none.',
+          ),
+        value: currentJson,
+        placeholder: '{}',
+        onConfirm: (value) => {
+          const trimmed = value.trim() || '{}';
+          let parsed: Record<string, unknown>;
+          try {
+            parsed = JSON.parse(trimmed) as Record<string, unknown>;
+          } catch {
+            state.api.ui.toast({
+              variant: 'warning',
+              title: 'Invalid JSON',
+              message: 'Options must be valid JSON.',
+            });
+            pickOptions(state, presetName, working, agentName, current);
+            return;
+          }
+          const next: AgentOverrideConfig = { ...current };
+          if (Object.keys(parsed).length > 0) {
+            next.options = parsed;
+          } else {
+            delete next.options;
+          }
+          // Commit back into the working preset and return to Level 2.
+          const updated = setAgentOverride(working, agentName, next);
+          state.api.ui.toast({
+            variant: 'success',
+            title: 'Agent updated',
+            message: `${agentName} → ${describeOverride(next)}`,
+          });
+          editPresetWorkingCopy(state, presetName, updated);
+        },
+        onCancel: () => editPresetWorkingCopy(state, presetName, working),
+      }),
+    }),
+  );
+}
+
+// --- formatting helpers (also used by the simple list view if needed) ---
+
+function describePreset(preset: Preset): string {
+  const parts = Object.entries(preset).map(
+    ([agent, override]) => `${agent}: ${describeOverride(override)}`,
+  );
+  return parts.length > 0 ? parts.join(', ') : '(empty)';
+}
+
+function describeOverride(override: AgentOverrideConfig): string {
+  const bits: string[] = [];
+  if (typeof override.model === 'string') {
+    bits.push(override.model);
+  } else if (Array.isArray(override.model) && override.model.length > 0) {
+    const first = override.model[0];
+    bits.push(typeof first === 'string' ? first : first.id);
+  }
+  if (typeof override.variant === 'string')
+    bits.push(`variant=${override.variant}`);
+  if (typeof override.temperature === 'number')
+    bits.push(`temp=${override.temperature}`);
+  if (override.options && Object.keys(override.options).length > 0)
+    bits.push('options');
+  return bits.length > 0 ? bits.join(', ') : '(unset)';
+}

+ 52 - 1
src/tui.ts

@@ -1,8 +1,13 @@
-import type { TuiPluginModule } from '@opencode-ai/plugin/tui';
+import type {
+  TuiCommand,
+  TuiPluginApi,
+  TuiPluginModule,
+} from '@opencode-ai/plugin/tui';
 import type { JSX } from '@opentui/solid';
 import { createElement, insert, setProp } from '@opentui/solid';
 import { DEFAULT_DISABLED_AGENTS, SUBAGENT_NAMES } from './config/constants';
 import { loadPluginConfig } from './config/loader';
+import { openPresetManager } from './tui-preset';
 import {
   readTuiSnapshot,
   readTuiSnapshotAsync,
@@ -244,6 +249,32 @@ export function readCompactSidebar(directory: string): boolean {
   return readConfigState(directory).compactSidebar;
 }
 
+/**
+ * Build the TUI slash command for `/preset`. Registered via the legacy
+ * `api.command` API (still populated in OpenCode 1.18 for v1 plugins). If the
+ * API is unavailable the command is simply not registered and `/preset` is a
+ * no-op.
+ *
+ * The command opens a three-level preset manager (list → edit → agent model)
+ * implemented in `src/tui-preset.ts`. Like the built-in `/models`, it is pure
+ * TUI and triggers no LLM turn.
+ */
+function buildPresetCommand(
+  api: TuiPluginApi,
+  directoryGetter: () => string,
+  snapshotRef: { snapshot: TuiSnapshot },
+): TuiCommand {
+  return {
+    title: 'Switch preset',
+    value: 'preset',
+    description: 'Switch agent presets at runtime (e.g. /preset cheap)',
+    slash: { name: 'preset' },
+    onSelect: () => {
+      openPresetManager(api, directoryGetter(), snapshotRef);
+    },
+  };
+}
+
 const plugin: TuiPluginModule & { id: string } = {
   id: `${PLUGIN_NAME}:tui`,
   tui: async (api, _options, meta) => {
@@ -286,6 +317,26 @@ const plugin: TuiPluginModule & { id: string } = {
         },
       },
     });
+
+    // `/preset` is a pure TUI slash command (like the built-in `/models`):
+    // it opens a picker, switches the preset via on-disk state, and never
+    // sends a message to the server or triggers an LLM turn. The legacy
+    // `api.command` API is still populated in OpenCode 1.18; if it is absent
+    // (e.g. a future v2-only build), registration is skipped gracefully.
+    if (api.command) {
+      const snapshotRef: { snapshot: TuiSnapshot } = {
+        get snapshot() {
+          return snapshot;
+        },
+        set snapshot(value: TuiSnapshot) {
+          snapshot = value;
+        },
+      };
+      const disposeCommands = api.command.register(() => [
+        buildPresetCommand(api, () => configDirectory, snapshotRef),
+      ]);
+      api.lifecycle.onDispose(disposeCommands);
+    }
   },
 };