From 1b25e5755039963d2999a15b8da76dba47f9947b Mon Sep 17 00:00:00 2001 From: j4rviscmd Date: Fri, 23 Jan 2026 13:38:42 +0900 Subject: [PATCH 1/4] feat: apply currentMode preset on startup when config drift detected When a user manually edits agent-mode-switcher.json to change currentMode while OpenCode is not running, the plugin now detects the drift on next startup and updates opencode.json and oh-my-opencode.json to match the expected preset values. Toast notification uses fire-and-forget to avoid blocking plugin initialization before UI is ready. Co-Authored-By: Claude Opus 4.5 --- src/modes/manager.test.ts | 199 +++++++++++++++++++++++++++++++++++++- src/modes/manager.ts | 107 ++++++++++++++++++-- 2 files changed, 295 insertions(+), 11 deletions(-) diff --git a/src/modes/manager.test.ts b/src/modes/manager.test.ts index 43aa10d..71854ce 100644 --- a/src/modes/manager.test.ts +++ b/src/modes/manager.test.ts @@ -1,12 +1,22 @@ import { beforeEach, describe, expect, test } from 'bun:test' import type { OpencodeClient } from '@opencode-ai/sdk' import type { + ModePreset, ModeSwitcherConfig, OhMyOpencodeConfig, OpencodeConfig, } from '../config/types.ts' import { createMockOpencodeClient, sampleConfigs } from '../test-utils/mocks.ts' +/** + * Creates a deep copy of the sample plugin config for isolated test use. + */ +function clonePluginConfig(): ModeSwitcherConfig { + return JSON.parse( + JSON.stringify(sampleConfigs.pluginConfig) + ) as ModeSwitcherConfig +} + /** * Mock implementation of ModeManager for testing purposes. * @@ -23,6 +33,9 @@ class MockModeManager { private ohMyConfig: OhMyOpencodeConfig | null = null private client: OpencodeClient + /** Tracks whether a drift-toast was shown during initialize */ + lastDriftToast: string | null = null + constructor(client: OpencodeClient) { this.client = client } @@ -41,11 +54,79 @@ class MockModeManager { async initialize(): Promise { if (!this.config) { - // Deep copy to avoid state sharing between tests - this.config = JSON.parse( - JSON.stringify(sampleConfigs.pluginConfig) - ) as ModeSwitcherConfig + this.config = clonePluginConfig() + } + await this.applyCurrentModeIfNeeded() + } + + private async applyCurrentModeIfNeeded(): Promise { + if (!this.config) { + return + } + + const preset = this.config.presets[this.config.currentMode] + if (!preset) { + return } + + const drifted = this.hasConfigDrift(preset) + if (!drifted) { + return + } + + // Apply preset to in-memory configs + if (this.opencodeConfig) { + if (preset.model) { + this.opencodeConfig.model = preset.model + } + this.opencodeConfig.agent = this.opencodeConfig.agent || {} + for (const [name, p] of Object.entries(preset.opencode)) { + this.opencodeConfig.agent[name] = { + ...this.opencodeConfig.agent[name], + model: p.model, + } + } + } + + if (this.ohMyConfig) { + this.ohMyConfig.agents = this.ohMyConfig.agents || {} + for (const [name, p] of Object.entries(preset['oh-my-opencode'])) { + this.ohMyConfig.agents[name] = { model: p.model } + } + } + + this.lastDriftToast = `Applied "${this.config.currentMode}" mode. Restart opencode to take effect.` + } + + private hasConfigDrift(preset: ModePreset): boolean { + // Check global model + if (preset.model && this.opencodeConfig) { + if (this.opencodeConfig.model !== preset.model) { + return true + } + } + + // Check opencode agent models + if (this.opencodeConfig?.agent) { + for (const [name, p] of Object.entries(preset.opencode)) { + const actual = this.opencodeConfig.agent[name] + if (actual?.model !== p.model) { + return true + } + } + } + + // Check oh-my-opencode agent models + if (this.ohMyConfig?.agents) { + for (const [name, p] of Object.entries(preset['oh-my-opencode'])) { + const actual = this.ohMyConfig.agents[name] + if (actual?.model !== p.model) { + return true + } + } + } + + return false } private async ensureConfig(): Promise { @@ -384,4 +465,114 @@ describe('ModeManager', () => { expect(result).toBe(false) }) }) + + describe('applyCurrentModeIfNeeded', () => { + test('applies preset when opencode.json has drifted', async () => { + // Set currentMode to economy but opencode.json has performance models + const config = clonePluginConfig() + config.currentMode = 'economy' + manager.setConfig(config) + manager.setOpencodeConfig({ + model: 'anthropic/claude-sonnet-4', + agent: { + build: { model: 'anthropic/claude-sonnet-4' }, + plan: { model: 'anthropic/claude-sonnet-4' }, + }, + }) + + await manager.initialize() + + expect(manager.lastDriftToast).toContain('economy') + expect(manager.lastDriftToast).toContain('Restart opencode') + }) + + test('applies preset when oh-my-opencode.json has drifted', async () => { + const config = clonePluginConfig() + config.currentMode = 'economy' + manager.setConfig(config) + manager.setOhMyConfig({ + agents: { + coder: { model: 'anthropic/claude-sonnet-4' }, + }, + }) + + await manager.initialize() + + expect(manager.lastDriftToast).toContain('economy') + }) + + test('does not apply when configs match preset', async () => { + // Set currentMode to economy and configs already match + const config = clonePluginConfig() + config.currentMode = 'economy' + manager.setConfig(config) + manager.setOpencodeConfig({ + model: 'opencode/glm-4.7-free', + agent: { + build: { model: 'opencode/glm-4.7-free' }, + plan: { model: 'opencode/glm-4.7-free' }, + }, + }) + manager.setOhMyConfig({ + agents: { + coder: { model: 'opencode/glm-4.7-free' }, + }, + }) + + await manager.initialize() + + expect(manager.lastDriftToast).toBeNull() + }) + + test('does nothing when preset is not found', async () => { + const config: ModeSwitcherConfig = { + currentMode: 'nonexistent', + showToastOnStartup: true, + presets: { + performance: { + description: 'Test', + opencode: {}, + 'oh-my-opencode': {}, + }, + }, + } + manager.setConfig(config) + manager.setOpencodeConfig({ + model: 'anthropic/claude-sonnet-4', + agent: { build: { model: 'anthropic/claude-sonnet-4' } }, + }) + + await manager.initialize() + + expect(manager.lastDriftToast).toBeNull() + }) + + test('does nothing when no config files exist', async () => { + const config = clonePluginConfig() + config.currentMode = 'economy' + manager.setConfig(config) + // Don't set opencodeConfig or ohMyConfig + + await manager.initialize() + + expect(manager.lastDriftToast).toBeNull() + }) + + test('detects drift on global model mismatch', async () => { + const config = clonePluginConfig() + config.currentMode = 'economy' + manager.setConfig(config) + manager.setOpencodeConfig({ + model: 'anthropic/claude-sonnet-4', // Mismatch + agent: { + build: { model: 'opencode/glm-4.7-free' }, + plan: { model: 'opencode/glm-4.7-free' }, + }, + }) + + await manager.initialize() + + expect(manager.lastDriftToast).not.toBeNull() + }) + }) }) diff --git a/src/modes/manager.ts b/src/modes/manager.ts index 4bb989f..4b81f73 100644 --- a/src/modes/manager.ts +++ b/src/modes/manager.ts @@ -59,6 +59,7 @@ export class ModeManager { */ async initialize(): Promise { this.config = await initializeConfig() + await this.applyCurrentModeIfNeeded() } /** @@ -78,6 +79,100 @@ export class ModeManager { return this.config } + /** + * Checks if actual config files have drifted from the current + * mode preset and applies the preset if needed. + * + * This handles the case where a user manually edits + * `agent-mode-switcher.json` to change `currentMode` while + * OpenCode is not running. On next startup, the actual config + * files are updated to match the expected preset values, + * and a toast notification prompts the user to restart. + * + * @private + */ + private async applyCurrentModeIfNeeded(): Promise { + if (!this.config) { + return + } + + const preset = this.config.presets[this.config.currentMode] + if (!preset) { + return + } + + const drifted = await this.hasConfigDrift(preset) + if (!drifted) { + return + } + + // Apply the preset to actual config files + await this.updateOpencodeConfig(preset.model, preset.opencode) + await this.updateOhMyOpencodeConfig(preset['oh-my-opencode']) + + // Notify user to restart (fire-and-forget to avoid blocking + // plugin initialization when UI is not yet ready) + this.client.tui + .showToast({ + body: { + title: 'Mode Applied', + message: `Applied "${this.config.currentMode}" mode. Restart opencode to take effect.`, + variant: 'warning', + duration: 5000, + }, + }) + .catch(() => { + // Toast might not be available during early initialization + }) + } + + /** + * Compares a mode preset against the actual opencode.json and + * oh-my-opencode.json files to detect configuration drift. + * + * Checks global model and per-agent model values. Returns true + * if any expected value differs from the actual file content. + * + * @param preset - The mode preset to compare against + * @returns True if actual configs differ from the preset + * @private + */ + private async hasConfigDrift(preset: ModePreset): Promise { + const opencodeConfig = await loadOpencodeConfig() + const ohMyConfig = await loadOhMyOpencodeConfig() + + // Check global model in opencode.json + if (preset.model && opencodeConfig) { + if (opencodeConfig.model !== preset.model) { + return true + } + } + + // Check opencode agent models + if (opencodeConfig?.agent) { + for (const [agentName, agentPreset] of Object.entries(preset.opencode)) { + const actual = opencodeConfig.agent[agentName] + if (actual?.model !== agentPreset.model) { + return true + } + } + } + + // Check oh-my-opencode agent models + if (ohMyConfig?.agents) { + for (const [agentName, agentPreset] of Object.entries( + preset['oh-my-opencode'] + )) { + const actual = ohMyConfig.agents[agentName] + if (actual?.model !== agentPreset.model) { + return true + } + } + } + + return false + } + /** * Gets the name of the currently active mode. * @@ -316,13 +411,11 @@ export class ModeManager { } // Update agent section (preserve other settings) - if (Object.keys(agentPresets).length > 0) { - opencodeConfig.agent = opencodeConfig.agent || {} - for (const [agentName, preset] of Object.entries(agentPresets)) { - opencodeConfig.agent[agentName] = { - ...opencodeConfig.agent[agentName], - model: preset.model, - } + opencodeConfig.agent = opencodeConfig.agent || {} + for (const [agentName, preset] of Object.entries(agentPresets)) { + opencodeConfig.agent[agentName] = { + ...opencodeConfig.agent[agentName], + model: preset.model, } } From c134fd33f97d15e47a86bf9045d7cda1225bce51 Mon Sep 17 00:00:00 2001 From: j4rviscmd Date: Fri, 23 Jan 2026 13:40:24 +0900 Subject: [PATCH 2/4] chore: add TODO comment for future toast improvement Co-Authored-By: Claude Opus 4.5 --- src/modes/manager.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/modes/manager.ts b/src/modes/manager.ts index 4b81f73..4bd6e4f 100644 --- a/src/modes/manager.ts +++ b/src/modes/manager.ts @@ -111,7 +111,11 @@ export class ModeManager { await this.updateOhMyOpencodeConfig(preset['oh-my-opencode']) // Notify user to restart (fire-and-forget to avoid blocking - // plugin initialization when UI is not yet ready) + // plugin initialization when UI is not yet ready). + // TODO: Currently toast is likely not displayed because UI is + // not initialized at this point. To reliably show the toast, + // use setTimeout for delayed execution or an onReady lifecycle + // hook if OpenCode adds one in the future. this.client.tui .showToast({ body: { From ea15b19ceb56d1839b71b0e28f26bfa691c75105 Mon Sep 17 00:00:00 2001 From: j4rviscmd <127029311+j4rviscmd@users.noreply.github.com> Date: Fri, 23 Jan 2026 13:44:01 +0900 Subject: [PATCH 3/4] feat: apply currentMode preset on startup when config drift detected When a user manually edits agent-mode-switcher.json to change currentMode while OpenCode is not running, the plugin now detects the drift on next startup and updates opencode.json and oh-my-opencode.json to match the expected preset values. Toast notification uses fire-and-forget to avoid blocking plugin initialization before UI is ready. Co-Authored-By: Claude Opus 4.5 From 410426fb9abbbb90d8b2c74cf9a4fbbd2f6993df Mon Sep 17 00:00:00 2001 From: j4rviscmd Date: Fri, 23 Jan 2026 13:59:55 +0900 Subject: [PATCH 4/4] feat: updated to v0.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b5bd032..69d8747 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-agent-modes", - "version": "0.1.2", + "version": "0.2.0", "description": "OpenCode plugin to switch agent modes between performance and economy presets", "module": "src/index.ts", "main": "dist/index.js",