Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 110 additions & 20 deletions src/modes/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ class MockModeManager {
/** Tracks whether a drift-toast was shown during initialize */
lastDriftToast: string | null = null

/** Tracks whether a no-drift info toast was shown during initialize */
lastInfoToast: string | null = null

constructor(client: OpencodeClient) {
this.client = client
}
Expand Down Expand Up @@ -79,30 +82,35 @@ class MockModeManager {
}

const drifted = this.hasConfigDrift(preset)
if (!drifted) {
return
}

// Apply preset to in-memory configs using recursive merge
if (this.opencodeConfig) {
if (preset.model) {
this.opencodeConfig.model = preset.model
if (drifted) {
// Apply preset to in-memory configs using recursive merge
if (this.opencodeConfig) {
if (preset.model) {
this.opencodeConfig.model = preset.model
}
this.opencodeConfig.agent = this.opencodeConfig.agent || {}
deepMergeModel(
this.opencodeConfig.agent as Record<string, unknown>,
preset.opencode
)
}
this.opencodeConfig.agent = this.opencodeConfig.agent || {}
deepMergeModel(
this.opencodeConfig.agent as Record<string, unknown>,
preset.opencode
)
}

if (this.ohMyConfig && preset['oh-my-opencode']) {
deepMergeModel(
this.ohMyConfig as Record<string, unknown>,
preset['oh-my-opencode']
)
if (this.ohMyConfig && preset['oh-my-opencode']) {
deepMergeModel(
this.ohMyConfig as Record<string, unknown>,
preset['oh-my-opencode']
)
}

this.lastDriftToast = `applied "${this.config.currentMode}" mode.\nrestart opencode to take effect.`
return
}

this.lastDriftToast = `Applied "${this.config.currentMode}" mode. Restart opencode to take effect.`
// No drift: show informational toast if configured
if (this.config.showToastOnStartup) {
this.lastInfoToast = `current mode: ${this.config.currentMode}`
}
}

private hasConfigDrift(preset: ModePreset): boolean {
Expand Down Expand Up @@ -495,7 +503,7 @@ describe('ModeManager', () => {
await manager.initialize()

expect(manager.lastDriftToast).toContain('economy')
expect(manager.lastDriftToast).toContain('Restart opencode')
expect(manager.lastDriftToast).toContain('restart opencode')
})

test('applies preset when oh-my-opencode.json has drifted', async () => {
Expand Down Expand Up @@ -546,6 +554,88 @@ describe('ModeManager', () => {
expect(manager.lastDriftToast).toBeNull()
})

test('shows info toast when no drift and showToastOnStartup is true', async () => {
const config = clonePluginConfig()
config.currentMode = 'economy'
config.showToastOnStartup = true
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: {
sisyphus: { model: 'opencode/glm-4.7-free' },
oracle: { model: 'opencode/glm-4.7-free' },
},
categories: {
'visual-engineering': { model: 'opencode/glm-4.7-free' },
quick: { model: 'opencode/glm-4.7-free' },
},
})

await manager.initialize()

expect(manager.lastDriftToast).toBeNull()
expect(manager.lastInfoToast).toBe('current mode: economy')
})

test('does not show info toast when no drift and showToastOnStartup is false', async () => {
const config = clonePluginConfig()
config.currentMode = 'economy'
config.showToastOnStartup = false
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: {
sisyphus: { model: 'opencode/glm-4.7-free' },
oracle: { model: 'opencode/glm-4.7-free' },
},
categories: {
'visual-engineering': { model: 'opencode/glm-4.7-free' },
quick: { model: 'opencode/glm-4.7-free' },
},
})

await manager.initialize()

expect(manager.lastDriftToast).toBeNull()
expect(manager.lastInfoToast).toBeNull()
})

test('does not show info toast when drift is detected (only drift toast)', async () => {
const config = clonePluginConfig()
config.currentMode = 'economy'
config.showToastOnStartup = true
manager.setConfig(config)

manager.setOpencodeConfig({
model: 'anthropic/claude-sonnet-4', // Mismatch: drift
agent: {
build: { model: 'anthropic/claude-sonnet-4' },
plan: { model: 'anthropic/claude-sonnet-4' },
},
})

await manager.initialize()

expect(manager.lastDriftToast).not.toBeNull()
expect(manager.lastInfoToast).toBeNull()
})

test('does nothing when preset is not found', async () => {
const config: ModeSwitcherConfig = {
currentMode: 'nonexistent',
Expand Down
73 changes: 49 additions & 24 deletions src/modes/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,12 @@ export class ModeManager {
* files are updated to match the expected preset values,
* and a toast notification prompts the user to restart.
*
* When no drift is detected and `showToastOnStartup` is enabled,
* an informational toast displays the current mode name.
*
* Toast notifications are delayed via `setTimeout` to allow the
* OpenCode UI to fully initialize before sending them.
*
* @private
*/
private async applyCurrentModeIfNeeded(): Promise<void> {
Expand All @@ -253,34 +259,53 @@ export class ModeManager {
}

const drifted = await this.hasConfigDrift(preset)
if (!drifted) {

if (drifted) {
// Apply the preset to actual config files
await this.updateOpencodeConfig(preset.model, preset.opencode)
if (preset['oh-my-opencode']) {
await this.updateOhMyOpencodeConfig(preset['oh-my-opencode'])
}

// Delay toast to allow UI to initialize before displaying.
// This is always shown regardless of showToastOnStartup since
// config drift is an important warning the user needs to see.
const modeName = this.config.currentMode
setTimeout(() => {
this.client.tui
.showToast({
body: {
title: 'agent-mode-switcher',
message: `applied "${modeName}" mode.\nrestart opencode to take effect.`,
variant: 'warning',
duration: 5000,
},
})
.catch(() => {
// Toast might not be available if UI failed to initialize
})
}, 3000)
return
}

// Apply the preset to actual config files
await this.updateOpencodeConfig(preset.model, preset.opencode)
if (preset['oh-my-opencode']) {
await this.updateOhMyOpencodeConfig(preset['oh-my-opencode'])
// No drift: show informational toast if configured
if (this.config.showToastOnStartup) {
const modeName = this.config.currentMode
setTimeout(() => {
this.client.tui
.showToast({
body: {
title: 'agent-mode-switcher',
message: `current mode: ${modeName}`,
variant: 'info',
duration: 3000,
},
})
.catch(() => {
// Toast might not be available if UI failed to initialize
})
}, 3000)
}

// Notify user to restart (fire-and-forget to avoid blocking
// 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: {
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
})
}

/**
Expand Down
Loading