diff --git a/CONFIGURATION.md b/CONFIGURATION.md index eaeb284..06a55a3 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -114,6 +114,7 @@ Higher-tier models with longer cache windows benefit from a longer TTL. Setting | `cache_ttl` | `string` or `object` | `"5m"` | Time after a response before applying pending ops. String or per-model map. | | `protected_tags` | `number` (1–100) | `20` | Last N active tags immune from immediate dropping. | | `nudge_interval_tokens` | `number` | `10000` | Minimum token growth between rolling nudges. | +| `toast_duration_ms` | `number` (1000–60000) | `5000` | TUI toast lifetime for Magic Context notifications in milliseconds. Increase this if toasts disappear too quickly. | | `execute_threshold_percentage` | `number` (20–80) or `object` | `65` | Context usage that forces queued ops to execute. Capped at 80% max for cache safety. Supports per-model map. | | `execute_threshold_tokens` | `object` (per-model map) | — | **Optional absolute-tokens variant of `execute_threshold_percentage`.** Per-model map (e.g. `{ "default": 150000, "github-copilot/gpt-5.2-codex": 40000 }`). When set for a model, overrides the percentage-based threshold for that model. Clamped to `80% × context_limit` with a warn log. Requires a resolvable context limit — falls through to percentage if unavailable. See below. | | `auto_drop_tool_age` | `number` | `100` | Auto-drop tool outputs older than N tags during execution. | @@ -634,6 +635,7 @@ Tier boundaries are hardcoded to keep behavior predictable and prevent cache-bus "protected_tags": 10, "auto_drop_tool_age": 50, "drop_tool_structure": true, + "toast_duration_ms": 12000, "history_budget_percentage": 0.15, "temporal_awareness": true, diff --git a/packages/plugin/src/config/schema/magic-context.ts b/packages/plugin/src/config/schema/magic-context.ts index 39c4fa4..d912857 100644 --- a/packages/plugin/src/config/schema/magic-context.ts +++ b/packages/plugin/src/config/schema/magic-context.ts @@ -236,6 +236,8 @@ export interface MagicContextConfig { dreamer?: DreamerConfig; cache_ttl: string | { default: string; [modelKey: string]: string }; nudge_interval_tokens: number; + /** TUI toast lifetime in milliseconds for Magic Context notifications. Default: 5000. */ + toast_duration_ms?: number; execute_threshold_percentage: number | { default: number; [modelKey: string]: number }; /** Absolute token thresholds per model. When set for a given model (or via `default`), * this overrides `execute_threshold_percentage` for that model. Useful for hard caps @@ -371,6 +373,14 @@ export const MagicContextConfigSchema = z .describe( "Minimum token growth between low-priority rolling nudges (default: DEFAULT_NUDGE_INTERVAL_TOKENS)", ), + toast_duration_ms: z + .number() + .min(1_000) + .max(60_000) + .default(5_000) + .describe( + "TUI toast lifetime in milliseconds for Magic Context notifications (min: 1000, max: 60000, default: 5000)", + ), execute_threshold_percentage: z .union([ z.number().min(20).max(80, EXECUTE_THRESHOLD_CAP_MESSAGE), diff --git a/packages/plugin/src/hooks/magic-context/command-handler.test.ts b/packages/plugin/src/hooks/magic-context/command-handler.test.ts index 32054dd..501863f 100644 --- a/packages/plugin/src/hooks/magic-context/command-handler.test.ts +++ b/packages/plugin/src/hooks/magic-context/command-handler.test.ts @@ -732,13 +732,13 @@ describe("createMagicContextCommandHandler", () => { 1, "ses-dream", "Starting dream run...", - {}, + { toastDurationMs: 5000 }, ); expect(sendNotification).toHaveBeenNthCalledWith( 2, "ses-dream", expect.stringContaining("### Tasks"), - {}, + { toastDurationMs: 5000 }, ); }); @@ -786,7 +786,7 @@ describe("createMagicContextCommandHandler", () => { 3, "ses-dream", "Dream already queued for this project", - {}, + { toastDurationMs: 5000 }, ); expect(executeDream).toHaveBeenCalledTimes(1); }); diff --git a/packages/plugin/src/hooks/magic-context/command-handler.ts b/packages/plugin/src/hooks/magic-context/command-handler.ts index 77ffad7..2ef1cc6 100644 --- a/packages/plugin/src/hooks/magic-context/command-handler.ts +++ b/packages/plugin/src/hooks/magic-context/command-handler.ts @@ -270,6 +270,7 @@ async function executeDreaming( text: string, params: NotificationParams, ) => Promise; + toastDurationMs?: number; dreamer?: { config: DreamerConfig; projectPath: string; @@ -288,11 +289,15 @@ async function executeDreaming( }, sessionId: string, ): Promise { + const dreamNotificationParams: NotificationParams = { + toastDurationMs: deps.toastDurationMs ?? 5000, + }; + if (!deps.dreamer?.config?.tasks?.length) { await deps.sendNotification( sessionId, "## /ctx-dream\n\nDreaming is not configured for this project.", - {}, + dreamNotificationParams, ); throwSentinel("CTX-DREAM"); } @@ -303,11 +308,11 @@ async function executeDreaming( // runner with an unexpired lease is never deleted just because it is older than 2m. const entry = enqueueDream(deps.db, deps.dreamer.projectPath, "manual", true); if (!entry) { - await deps.sendNotification(sessionId, "Dream already queued for this project", {}); + await deps.sendNotification(sessionId, "Dream already queued for this project", dreamNotificationParams); throwSentinel("CTX-DREAM"); } - await deps.sendNotification(sessionId, "Starting dream run...", {}); + await deps.sendNotification(sessionId, "Starting dream run...", dreamNotificationParams); const result = deps.dreamer.executeDream ? await deps.dreamer.executeDream(sessionId) @@ -334,7 +339,7 @@ async function executeDreaming( result ? summarizeDreamResult(result) : "Dream queued, but another worker is already processing the queue.", - {}, + dreamNotificationParams, ); throwSentinel("CTX-DREAM"); } @@ -372,6 +377,8 @@ export function createMagicContextCommandHandler(deps: { text: string, params: NotificationParams, ) => Promise; + /** Configured toast lifetime (ms) forwarded into diagnostics logs. */ + toastDurationMs?: number; sidekick?: { config: SidekickConfig; projectPath: string; @@ -440,8 +447,20 @@ export function createMagicContextCommandHandler(deps: { if (isStatus) { if (isTuiConnected(sessionId)) { // In TUI, push an RPC action so the TUI poller shows a native dialog - pushNotification("action", { action: "show-status-dialog" }, sessionId); - sessionLog(sessionId, "command ctx-status: pushed show-status-dialog to TUI"); + pushNotification( + "action", + { + action: "show-status-dialog", + toast_duration_ms: deps.toastDurationMs ?? 5000, + }, + sessionId, + ); + sessionLog( + sessionId, + `command ctx-status: pushed show-status-dialog to TUI (toast_duration_ms=${String( + deps.toastDurationMs ?? 5000, + )})`, + ); throwSentinel(input.command); } const liveModelKey = deps.getLiveModelKey?.(sessionId); diff --git a/packages/plugin/src/hooks/magic-context/hook-handlers.ts b/packages/plugin/src/hooks/magic-context/hook-handlers.ts index 06438a3..6ed1c04 100644 --- a/packages/plugin/src/hooks/magic-context/hook-handlers.ts +++ b/packages/plugin/src/hooks/magic-context/hook-handlers.ts @@ -122,11 +122,13 @@ export function getLiveNotificationParams( liveModelBySession: LiveModelBySession, variantBySession: VariantBySession, agentBySession?: AgentBySession, + toastDurationMs?: number, ): { agent?: string; variant?: string; providerId?: string; modelId?: string; + toastDurationMs?: number; } { const model = liveModelBySession.get(sessionId); const variant = variantBySession.get(sessionId); @@ -135,6 +137,7 @@ export function getLiveNotificationParams( ...(agent ? { agent } : {}), ...(variant ? { variant } : {}), ...(model ? { providerId: model.providerID, modelId: model.modelID } : {}), + ...(typeof toastDurationMs === "number" ? { toastDurationMs } : {}), }; } diff --git a/packages/plugin/src/hooks/magic-context/hook.ts b/packages/plugin/src/hooks/magic-context/hook.ts index ab34562..55e9480 100644 --- a/packages/plugin/src/hooks/magic-context/hook.ts +++ b/packages/plugin/src/hooks/magic-context/hook.ts @@ -78,6 +78,7 @@ export interface MagicContextDeps { protected_tags: number; ctx_reduce_enabled?: boolean; nudge_interval_tokens?: number; + toast_duration_ms?: number; auto_drop_tool_age?: number; drop_tool_structure?: boolean; clear_reasoning_age?: number; @@ -334,7 +335,13 @@ export function createMagicContextHook(deps: MagicContextDeps) { userMemoriesEnabled: dreamerConfig?.user_memories?.enabled === true, ensureProjectRegistered: ensureProjectRegisteredFromOpenCodeDirectory, getNotificationParams: (sid) => - getLiveNotificationParams(sid, liveModelBySession, variantBySession, agentBySession), + getLiveNotificationParams( + sid, + liveModelBySession, + variantBySession, + agentBySession, + deps.config.toast_duration_ms, + ), }); const sidekickRunnable = isSidekickRunnable(deps.config); const sidekickConfig = sidekickRunnable ? deps.config.sidekick : undefined; @@ -392,6 +399,7 @@ export function createMagicContextHook(deps: MagicContextDeps) { liveModelBySession, variantBySession, agentBySession, + deps.config.toast_duration_ms, ), getModelKey: (sessionId) => { const model = liveModelBySession.get(sessionId); @@ -445,6 +453,7 @@ export function createMagicContextHook(deps: MagicContextDeps) { liveModelBySession, variantBySession, agentBySession, + deps.config.toast_duration_ms, ), nudgePlacements, onSessionCacheInvalidated: (sessionId: string) => { @@ -526,6 +535,7 @@ export function createMagicContextHook(deps: MagicContextDeps) { const commandHandler = createMagicContextCommandHandler({ db, protectedTags: deps.config.protected_tags, + toastDurationMs: deps.config.toast_duration_ms, nudgeIntervalTokens: deps.config.nudge_interval_tokens ?? DEFAULT_NUDGE_INTERVAL_TOKENS, executeThresholdPercentage: deps.config.execute_threshold_percentage ?? 65, executeThresholdTokens: deps.config.execute_threshold_tokens, @@ -577,6 +587,7 @@ export function createMagicContextHook(deps: MagicContextDeps) { liveModelBySession, variantBySession, agentBySession, + deps.config.toast_duration_ms, ), ...params, }); @@ -706,6 +717,7 @@ export function createMagicContextHook(deps: MagicContextDeps) { liveModelBySession, variantBySession, agentBySession, + deps.config.toast_duration_ms, ), isTuiConnected, pushTuiDialogAction: (sid, resume) => diff --git a/packages/plugin/src/hooks/magic-context/send-session-notification.ts b/packages/plugin/src/hooks/magic-context/send-session-notification.ts index e97beb1..f8aea63 100644 --- a/packages/plugin/src/hooks/magic-context/send-session-notification.ts +++ b/packages/plugin/src/hooks/magic-context/send-session-notification.ts @@ -6,6 +6,8 @@ export interface NotificationParams { variant?: string; providerId?: string; modelId?: string; + /** TUI toast lifetime in milliseconds (default: 5000). */ + toastDurationMs?: number; } interface NotificationClient { @@ -75,25 +77,21 @@ export async function sendIgnoredMessage( const { isTuiConnected: checkTui } = await import("../../shared/rpc-notifications"); if (!forcePersist && checkTui(sessionId)) { try { - const c = client as Record; - const tui = c?.tui as Record | undefined; - if (typeof tui?.showToast === "function") { - // Intentional: call via property access to preserve `this` binding on the SDK client. - // The tui object is an SDK-generated client where methods live on the prototype. - const tuiClient = tui as Record Promise>; - await tuiClient.showToast({ - body: { - title: extractToastTitle(text), - message: text.length > 200 ? `${text.slice(0, 200)}…` : text, - variant: inferToastVariant(text), - duration: 5000, - }, - }); - return; - } + const { pushNotification } = await import("../../shared/rpc-notifications"); + pushNotification( + "toast", + { + title: extractToastTitle(text), + message: text.length > 200 ? `${text.slice(0, 200)}…` : text, + variant: inferToastVariant(text), + duration: params.toastDurationMs ?? 5000, + }, + sessionId, + ); + return; } catch { - // showToast failed or tui client is unavailable — fall through to ignored message. - sessionLog(sessionId, "TUI showToast failed, falling back to ignored message"); + // RPC enqueue failed — fall through to ignored message. + sessionLog(sessionId, "TUI RPC toast enqueue failed, falling back to ignored message"); } } const agent = params.agent || undefined; diff --git a/packages/plugin/src/plugin/rpc-handlers.ts b/packages/plugin/src/plugin/rpc-handlers.ts index b2d73a6..059d52d 100644 --- a/packages/plugin/src/plugin/rpc-handlers.ts +++ b/packages/plugin/src/plugin/rpc-handlers.ts @@ -535,6 +535,7 @@ export function buildStatusDetail( historyBlockTokens: 0, compressionBudget: null, compressionUsage: null, + toastDurationMs: 5000, }; try { @@ -629,6 +630,12 @@ export function buildStatusDetail( if (typeof config.history_budget_percentage === "number") { detail.historyBudgetPercentage = config.history_budget_percentage; } + detail.toastDurationMs = resolveConfigValue( + config, + "toast_duration_ms", + modelKey, + 5000, + ); } // Derived values @@ -691,6 +698,7 @@ export function registerRpcHandlers( liveSessionState.liveModelBySession, liveSessionState.variantBySession, liveSessionState.agentBySession, + config.toast_duration_ms, ); const injectionBudgetTokens = config.memory?.injection_budget_tokens; @@ -861,6 +869,11 @@ export function registerRpcHandlers( } }); + rpcServer.handle("toast-duration", async () => { + const resolved = resolveConfigValue(rawConfig, "toast_duration_ms", undefined, 5000); + return { toastDurationMs: resolved }; + }); + rpcServer.handle("pending-notifications", async (params) => { const lastReceivedId = Number(params.lastReceivedId ?? 0); // Scope drain to the TUI's active session so a notification tagged for a diff --git a/packages/plugin/src/shared/rpc-types.ts b/packages/plugin/src/shared/rpc-types.ts index 32e9071..e2a111e 100644 --- a/packages/plugin/src/shared/rpc-types.ts +++ b/packages/plugin/src/shared/rpc-types.ts @@ -124,6 +124,8 @@ export interface StatusDetail extends SidebarSnapshot { historyBlockTokens: number; compressionBudget: number | null; compressionUsage: string | null; + /** Effective configured toast duration in ms after config resolution. */ + toastDurationMs: number; } export interface RpcNotificationMessage { diff --git a/packages/plugin/src/tui/data/context-db.ts b/packages/plugin/src/tui/data/context-db.ts index 1f54644..707c5e7 100644 --- a/packages/plugin/src/tui/data/context-db.ts +++ b/packages/plugin/src/tui/data/context-db.ts @@ -203,6 +203,7 @@ export async function loadStatusDetail( historyBlockTokens: 0, compressionBudget: null, compressionUsage: null, + toastDurationMs: 5000, }; if (!rpcClient) return emptyDetail; @@ -270,6 +271,17 @@ export async function dismissUpgradeReminder(sessionId: string): Promise { + if (!rpcClient) return 5000; + try { + const result = await rpcClient.call<{ toastDurationMs?: number }>("toast-duration", {}); + return typeof result.toastDurationMs === "number" ? result.toastDurationMs : 5000; + } catch { + return 5000; + } +} + export interface TuiMessage { id: number; type: string; diff --git a/packages/plugin/src/tui/index.tsx b/packages/plugin/src/tui/index.tsx index 6232f6f..38b6981 100644 --- a/packages/plugin/src/tui/index.tsx +++ b/packages/plugin/src/tui/index.tsx @@ -6,7 +6,7 @@ import { createMemo } from "solid-js" import type { TuiPlugin, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui" import { createSidebarContentSlot, kickRecompProgressRefresh } from "./slots/sidebar-content" import packageJson from "../../package.json" -import { closeRpc, consumeTuiMessages, dismissUpgradeReminder, getAnnouncement, getCompartmentCount, getRpcGeneration, initRpcClient, loadStatusDetail, markAnnounced, markTuiMessagesHandled, requestRecomp, requestUpgrade, type TuiMessage, type StatusDetail } from "./data/context-db" +import { closeRpc, consumeTuiMessages, dismissUpgradeReminder, getAnnouncement, getCompartmentCount, getRpcGeneration, initRpcClient, loadStatusDetail, loadToastDurationMs, markAnnounced, markTuiMessagesHandled, requestRecomp, requestUpgrade, type TuiMessage, type StatusDetail } from "./data/context-db" import { formatThresholdPercent } from "../shared/format-threshold" import { detectConflicts } from "../shared/conflict-detector" import { fixConflicts } from "../shared/conflict-fixer" @@ -14,6 +14,31 @@ import { readJsoncFile } from "../shared/jsonc-parser" import { getOpenCodeConfigPaths } from "../shared/opencode-config-dir" const PLUGIN_NAME = "@cortexkit/opencode-magic-context" +const DEFAULT_TOAST_DURATION_MS = 5000 +let unifiedToastDurationMs = DEFAULT_TOAST_DURATION_MS + +function getToastDurationMs(): number { + return unifiedToastDurationMs +} + +function showToast( + api: TuiPluginApi, + input: { + message: string + variant: "info" | "warning" | "error" | "success" + durationOverrideMs?: number + }, +): void { + const duration = + typeof input.durationOverrideMs === "number" && Number.isFinite(input.durationOverrideMs) + ? input.durationOverrideMs + : getToastDurationMs() + api.ui.toast({ + message: input.message, + variant: input.variant, + duration, + }) +} function ensureParentDir(filePath: string) { mkdirSync(dirname(filePath), { recursive: true }) @@ -97,14 +122,14 @@ function showConflictDialog(api: TuiPluginApi, directory: string, reasons: strin title="✅ Configuration Fixed" message={`${actionSummary}\n\nPlease restart OpenCode for changes to take effect.`} onConfirm={() => { - api.ui.toast({ message: "Restart OpenCode to enable Magic Context", variant: "warning", duration: 10000 }) + showToast(api, { message: "Restart OpenCode to enable Magic Context", variant: "warning" }) }} /> )) }, 50) }} onCancel={() => { - api.ui.toast({ message: "Magic Context remains disabled. Run: npx @cortexkit/opencode-magic-context@latest doctor", variant: "warning", duration: 5000 }) + showToast(api, { message: "Magic Context remains disabled. Run: npx @cortexkit/opencode-magic-context@latest doctor", variant: "warning" }) }} /> )) @@ -132,7 +157,7 @@ function showTuiSetupDialog(api: TuiPluginApi) { title="❌ Setup Failed" message={'Could not update tui.json automatically. Add the plugin manually:\n\n { "plugin": ["@cortexkit/opencode-magic-context"] }'} onConfirm={() => { - api.ui.toast({ message: "Add plugin to tui.json manually", variant: "warning", duration: 5000 }) + showToast(api, { message: "Add plugin to tui.json manually", variant: "warning" }) }} /> )) @@ -146,14 +171,14 @@ function showTuiSetupDialog(api: TuiPluginApi) { title="✅ Sidebar Enabled" message="tui.json updated with Magic Context plugin.\n\nPlease restart OpenCode to see the sidebar." onConfirm={() => { - api.ui.toast({ message: "Restart OpenCode to see the sidebar", variant: "warning", duration: 10000 }) + showToast(api, { message: "Restart OpenCode to see the sidebar", variant: "warning" }) }} /> )) }, 50) }} onCancel={() => { - api.ui.toast({ message: "You can add the sidebar later via: npx @cortexkit/opencode-magic-context@latest doctor", variant: "info", duration: 5000 }) + showToast(api, { message: "You can add the sidebar later via: npx @cortexkit/opencode-magic-context@latest doctor", variant: "info" }) }} /> )) @@ -451,7 +476,7 @@ function getModelKeyFromMessages(api: TuiPluginApi, sessionId: string): string | async function showRecompDialog(api: TuiPluginApi, targetSessionId = getSessionId(api)): Promise { const sessionId = targetSessionId if (!sessionId) { - api.ui.toast({ message: "No active session", variant: "warning" }) + showToast(api, { message: "No active session", variant: "warning" }) return false } @@ -476,10 +501,10 @@ async function showRecompDialog(api: TuiPluginApi, targetSessionId = getSessionI onConfirm={() => { void requestRecomp(sessionId) kickRecompProgressRefresh() - api.ui.toast({ message: "Recomp requested — historian will start shortly", variant: "info", duration: 5000 }) + showToast(api, { message: "Recomp requested — historian will start shortly", variant: "info" }) }} onCancel={() => { - api.ui.toast({ message: "Recomp cancelled", variant: "info", duration: 3000 }) + showToast(api, { message: "Recomp cancelled", variant: "info", durationOverrideMs: 3000 }) }} /> )) @@ -541,12 +566,11 @@ function showUpgradeDialog( // call fires no message event, so without this the progress // bar wouldn't appear until the upgrade finished. kickRecompProgressRefresh() - api.ui.toast({ + showToast(api, { message: resume ? "Resuming session upgrade — running in the background" : "Session upgrade started — running in the background", variant: "info", - duration: 5000, }) }} onCancel={() => { @@ -555,10 +579,10 @@ function showUpgradeDialog( // never-upgraded session (dogfood 2026-05-30) relies on THIS // being the only place the TUI path stamps. void dismissUpgradeReminder(sessionId) - api.ui.toast({ + showToast(api, { message: "Upgrade skipped — run /ctx-session-upgrade anytime", variant: "info", - duration: 4000, + durationOverrideMs: 4000, }) }} /> @@ -570,7 +594,7 @@ function showUpgradeDialog( async function showStatusDialog(api: TuiPluginApi, targetSessionId = getSessionId(api)): Promise { const sessionId = targetSessionId if (!sessionId) { - api.ui.toast({ message: "No active session", variant: "warning" }) + showToast(api, { message: "No active session", variant: "warning" }) return false } @@ -751,6 +775,7 @@ const tui: TuiPlugin = async (api, _options, meta) => { // Initialize RPC client for server communication const directory = api.state.path.directory ?? "" initRpcClient(directory) + unifiedToastDurationMs = await loadToastDurationMs() // Register sidebar slot api.slots.register(createSidebarContentSlot(api)) @@ -812,10 +837,13 @@ const tui: TuiPlugin = async (api, _options, meta) => { } if (msg.type === "toast") { const p = msg.payload - api.ui.toast({ + showToast(api, { message: String(p.message ?? ""), variant: (p.variant as "info" | "warning" | "error" | "success") ?? "info", - duration: typeof p.duration === "number" ? p.duration : 5000, + durationOverrideMs: + typeof p.duration === "number" && Number.isFinite(p.duration) + ? p.duration + : undefined, }) handledMessageIds.add(msg.id) } else if (msg.type === "action") {