Skip to content

Commit 8c58de9

Browse files
authored
feat: add reliable startup toast notifications with delayed execution (#20)
Use setTimeout(3000) to delay toast display until OpenCode UI has initialized.\nDrift warning toast is always shown; no-drift info toast is controlled by showToastOnStartup config flag.\n\nCloses #19
1 parent 45de331 commit 8c58de9

2 files changed

Lines changed: 159 additions & 44 deletions

File tree

src/modes/manager.test.ts

Lines changed: 110 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ class MockModeManager {
4141
/** Tracks whether a drift-toast was shown during initialize */
4242
lastDriftToast: string | null = null
4343

44+
/** Tracks whether a no-drift info toast was shown during initialize */
45+
lastInfoToast: string | null = null
46+
4447
constructor(client: OpencodeClient) {
4548
this.client = client
4649
}
@@ -79,30 +82,35 @@ class MockModeManager {
7982
}
8083

8184
const drifted = this.hasConfigDrift(preset)
82-
if (!drifted) {
83-
return
84-
}
8585

86-
// Apply preset to in-memory configs using recursive merge
87-
if (this.opencodeConfig) {
88-
if (preset.model) {
89-
this.opencodeConfig.model = preset.model
86+
if (drifted) {
87+
// Apply preset to in-memory configs using recursive merge
88+
if (this.opencodeConfig) {
89+
if (preset.model) {
90+
this.opencodeConfig.model = preset.model
91+
}
92+
this.opencodeConfig.agent = this.opencodeConfig.agent || {}
93+
deepMergeModel(
94+
this.opencodeConfig.agent as Record<string, unknown>,
95+
preset.opencode
96+
)
9097
}
91-
this.opencodeConfig.agent = this.opencodeConfig.agent || {}
92-
deepMergeModel(
93-
this.opencodeConfig.agent as Record<string, unknown>,
94-
preset.opencode
95-
)
96-
}
9798

98-
if (this.ohMyConfig && preset['oh-my-opencode']) {
99-
deepMergeModel(
100-
this.ohMyConfig as Record<string, unknown>,
101-
preset['oh-my-opencode']
102-
)
99+
if (this.ohMyConfig && preset['oh-my-opencode']) {
100+
deepMergeModel(
101+
this.ohMyConfig as Record<string, unknown>,
102+
preset['oh-my-opencode']
103+
)
104+
}
105+
106+
this.lastDriftToast = `applied "${this.config.currentMode}" mode.\nrestart opencode to take effect.`
107+
return
103108
}
104109

105-
this.lastDriftToast = `Applied "${this.config.currentMode}" mode. Restart opencode to take effect.`
110+
// No drift: show informational toast if configured
111+
if (this.config.showToastOnStartup) {
112+
this.lastInfoToast = `current mode: ${this.config.currentMode}`
113+
}
106114
}
107115

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

497505
expect(manager.lastDriftToast).toContain('economy')
498-
expect(manager.lastDriftToast).toContain('Restart opencode')
506+
expect(manager.lastDriftToast).toContain('restart opencode')
499507
})
500508

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

557+
test('shows info toast when no drift and showToastOnStartup is true', async () => {
558+
const config = clonePluginConfig()
559+
config.currentMode = 'economy'
560+
config.showToastOnStartup = true
561+
manager.setConfig(config)
562+
563+
manager.setOpencodeConfig({
564+
model: 'opencode/glm-4.7-free',
565+
agent: {
566+
build: { model: 'opencode/glm-4.7-free' },
567+
plan: { model: 'opencode/glm-4.7-free' },
568+
},
569+
})
570+
571+
manager.setOhMyConfig({
572+
agents: {
573+
sisyphus: { model: 'opencode/glm-4.7-free' },
574+
oracle: { model: 'opencode/glm-4.7-free' },
575+
},
576+
categories: {
577+
'visual-engineering': { model: 'opencode/glm-4.7-free' },
578+
quick: { model: 'opencode/glm-4.7-free' },
579+
},
580+
})
581+
582+
await manager.initialize()
583+
584+
expect(manager.lastDriftToast).toBeNull()
585+
expect(manager.lastInfoToast).toBe('current mode: economy')
586+
})
587+
588+
test('does not show info toast when no drift and showToastOnStartup is false', async () => {
589+
const config = clonePluginConfig()
590+
config.currentMode = 'economy'
591+
config.showToastOnStartup = false
592+
manager.setConfig(config)
593+
594+
manager.setOpencodeConfig({
595+
model: 'opencode/glm-4.7-free',
596+
agent: {
597+
build: { model: 'opencode/glm-4.7-free' },
598+
plan: { model: 'opencode/glm-4.7-free' },
599+
},
600+
})
601+
602+
manager.setOhMyConfig({
603+
agents: {
604+
sisyphus: { model: 'opencode/glm-4.7-free' },
605+
oracle: { model: 'opencode/glm-4.7-free' },
606+
},
607+
categories: {
608+
'visual-engineering': { model: 'opencode/glm-4.7-free' },
609+
quick: { model: 'opencode/glm-4.7-free' },
610+
},
611+
})
612+
613+
await manager.initialize()
614+
615+
expect(manager.lastDriftToast).toBeNull()
616+
expect(manager.lastInfoToast).toBeNull()
617+
})
618+
619+
test('does not show info toast when drift is detected (only drift toast)', async () => {
620+
const config = clonePluginConfig()
621+
config.currentMode = 'economy'
622+
config.showToastOnStartup = true
623+
manager.setConfig(config)
624+
625+
manager.setOpencodeConfig({
626+
model: 'anthropic/claude-sonnet-4', // Mismatch: drift
627+
agent: {
628+
build: { model: 'anthropic/claude-sonnet-4' },
629+
plan: { model: 'anthropic/claude-sonnet-4' },
630+
},
631+
})
632+
633+
await manager.initialize()
634+
635+
expect(manager.lastDriftToast).not.toBeNull()
636+
expect(manager.lastInfoToast).toBeNull()
637+
})
638+
549639
test('does nothing when preset is not found', async () => {
550640
const config: ModeSwitcherConfig = {
551641
currentMode: 'nonexistent',

src/modes/manager.ts

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,12 @@ export class ModeManager {
240240
* files are updated to match the expected preset values,
241241
* and a toast notification prompts the user to restart.
242242
*
243+
* When no drift is detected and `showToastOnStartup` is enabled,
244+
* an informational toast displays the current mode name.
245+
*
246+
* Toast notifications are delayed via `setTimeout` to allow the
247+
* OpenCode UI to fully initialize before sending them.
248+
*
243249
* @private
244250
*/
245251
private async applyCurrentModeIfNeeded(): Promise<void> {
@@ -253,34 +259,53 @@ export class ModeManager {
253259
}
254260

255261
const drifted = await this.hasConfigDrift(preset)
256-
if (!drifted) {
262+
263+
if (drifted) {
264+
// Apply the preset to actual config files
265+
await this.updateOpencodeConfig(preset.model, preset.opencode)
266+
if (preset['oh-my-opencode']) {
267+
await this.updateOhMyOpencodeConfig(preset['oh-my-opencode'])
268+
}
269+
270+
// Delay toast to allow UI to initialize before displaying.
271+
// This is always shown regardless of showToastOnStartup since
272+
// config drift is an important warning the user needs to see.
273+
const modeName = this.config.currentMode
274+
setTimeout(() => {
275+
this.client.tui
276+
.showToast({
277+
body: {
278+
title: 'agent-mode-switcher',
279+
message: `applied "${modeName}" mode.\nrestart opencode to take effect.`,
280+
variant: 'warning',
281+
duration: 5000,
282+
},
283+
})
284+
.catch(() => {
285+
// Toast might not be available if UI failed to initialize
286+
})
287+
}, 3000)
257288
return
258289
}
259290

260-
// Apply the preset to actual config files
261-
await this.updateOpencodeConfig(preset.model, preset.opencode)
262-
if (preset['oh-my-opencode']) {
263-
await this.updateOhMyOpencodeConfig(preset['oh-my-opencode'])
291+
// No drift: show informational toast if configured
292+
if (this.config.showToastOnStartup) {
293+
const modeName = this.config.currentMode
294+
setTimeout(() => {
295+
this.client.tui
296+
.showToast({
297+
body: {
298+
title: 'agent-mode-switcher',
299+
message: `current mode: ${modeName}`,
300+
variant: 'info',
301+
duration: 3000,
302+
},
303+
})
304+
.catch(() => {
305+
// Toast might not be available if UI failed to initialize
306+
})
307+
}, 3000)
264308
}
265-
266-
// Notify user to restart (fire-and-forget to avoid blocking
267-
// plugin initialization when UI is not yet ready).
268-
// TODO: Currently toast is likely not displayed because UI is
269-
// not initialized at this point. To reliably show the toast,
270-
// use setTimeout for delayed execution or an onReady lifecycle
271-
// hook if OpenCode adds one in the future.
272-
this.client.tui
273-
.showToast({
274-
body: {
275-
title: 'Mode Applied',
276-
message: `Applied "${this.config.currentMode}" mode. Restart opencode to take effect.`,
277-
variant: 'warning',
278-
duration: 5000,
279-
},
280-
})
281-
.catch(() => {
282-
// Toast might not be available during early initialization
283-
})
284309
}
285310

286311
/**

0 commit comments

Comments
 (0)