From 7e1e48d95d2179c2acb5df6e4656f103927153b9 Mon Sep 17 00:00:00 2001 From: Csaba Kertesz Date: Fri, 22 May 2026 05:43:24 +0200 Subject: [PATCH] Add option for the notification interval --- CONFIGURATION.md | 2 + .../plugin/src/config/schema/magic-context.ts | 4 ++ .../hooks/magic-context/command-handler.ts | 28 +++++++++--- .../src/hooks/magic-context/hook-handlers.ts | 3 ++ .../plugin/src/hooks/magic-context/hook.ts | 6 +++ .../send-session-notification.ts | 34 +++++++------- packages/plugin/src/plugin/rpc-handlers.ts | 12 +++++ packages/plugin/src/shared/rpc-types.ts | 2 + packages/plugin/src/tui/data/context-db.ts | 12 +++++ packages/plugin/src/tui/index.tsx | 44 ++++++++++++++----- 10 files changed, 110 insertions(+), 37 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 07919ba6..384b3796 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -118,6 +118,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. | @@ -617,6 +618,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, "compaction_markers": true, "compressor": { diff --git a/packages/plugin/src/config/schema/magic-context.ts b/packages/plugin/src/config/schema/magic-context.ts index e68e202b..26c30408 100644 --- a/packages/plugin/src/config/schema/magic-context.ts +++ b/packages/plugin/src/config/schema/magic-context.ts @@ -190,6 +190,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 @@ -310,6 +312,8 @@ export const MagicContextConfigSchema = z .default("5m"), /** Minimum token growth between low-priority rolling nudges (default: DEFAULT_NUDGE_INTERVAL_TOKENS) */ nudge_interval_tokens: z.number().min(1000).default(DEFAULT_NUDGE_INTERVAL_TOKENS), + /** TUI toast lifetime in milliseconds for Magic Context notifications (min: 1000, max: 60000, default: 5000) */ + toast_duration_ms: z.number().min(1_000).max(60_000).default(5_000), /** Context percentage that forces queued operations to execute. Number or per-model object ({ default: 65, "provider/model": 45 }). Values above 80 are rejected because the runtime caps at 80% for cache safety (MAX_EXECUTE_THRESHOLD). Default: DEFAULT_EXECUTE_THRESHOLD_PERCENTAGE */ execute_threshold_percentage: z .union([ diff --git a/packages/plugin/src/hooks/magic-context/command-handler.ts b/packages/plugin/src/hooks/magic-context/command-handler.ts index 2993ac6f..33502a4a 100644 --- a/packages/plugin/src/hooks/magic-context/command-handler.ts +++ b/packages/plugin/src/hooks/magic-context/command-handler.ts @@ -289,6 +289,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; @@ -351,13 +353,25 @@ export function createMagicContextCommandHandler(deps: { deps.onFlush?.(sessionId); } - if (isStatus) { - if (isTuiConnected()) { - // 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"); - throwSentinel(input.command); - } + if (isStatus) { + if (isTuiConnected()) { + // In TUI, push an RPC action so the TUI poller shows a native dialog + 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); const liveContextLimit = deps.getContextLimit?.(sessionId); const statusOutput = executeStatus( diff --git a/packages/plugin/src/hooks/magic-context/hook-handlers.ts b/packages/plugin/src/hooks/magic-context/hook-handlers.ts index 79125f9f..2fa6ab28 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 fa95b8b6..30ab1bb1 100644 --- a/packages/plugin/src/hooks/magic-context/hook.ts +++ b/packages/plugin/src/hooks/magic-context/hook.ts @@ -74,6 +74,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; @@ -345,6 +346,7 @@ export function createMagicContextHook(deps: MagicContextDeps) { liveModelBySession, variantBySession, agentBySession, + deps.config.toast_duration_ms, ), getModelKey: (sessionId) => { const model = liveModelBySession.get(sessionId); @@ -408,6 +410,7 @@ export function createMagicContextHook(deps: MagicContextDeps) { liveModelBySession, variantBySession, agentBySession, + deps.config.toast_duration_ms, ), nudgePlacements, onSessionCacheInvalidated: (sessionId: string) => { @@ -470,6 +473,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, @@ -521,6 +525,7 @@ export function createMagicContextHook(deps: MagicContextDeps) { liveModelBySession, variantBySession, agentBySession, + deps.config.toast_duration_ms, ), historianTwoPass: deps.config.historian?.two_pass === true, // Issue #44: respect memory feature gates from /ctx-recomp too. @@ -554,6 +559,7 @@ export function createMagicContextHook(deps: MagicContextDeps) { liveModelBySession, variantBySession, agentBySession, + deps.config.toast_duration_ms, ), ...params, }); 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 f020ffd1..895f5d94 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 { @@ -69,25 +71,21 @@ export async function sendIgnoredMessage( const { isTuiConnected: checkTui } = await import("../../shared/rpc-notifications"); if (checkTui()) { 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 e06b63d1..7782b742 100644 --- a/packages/plugin/src/plugin/rpc-handlers.ts +++ b/packages/plugin/src/plugin/rpc-handlers.ts @@ -473,6 +473,7 @@ export function buildStatusDetail( historyBlockTokens: 0, compressionBudget: null, compressionUsage: null, + toastDurationMs: 5000, }; try { @@ -567,6 +568,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 @@ -747,6 +754,11 @@ export function registerRpcHandlers( return { ok: true }; }); + 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); const notifications = drainNotifications( diff --git a/packages/plugin/src/shared/rpc-types.ts b/packages/plugin/src/shared/rpc-types.ts index 45620f26..3a6ad929 100644 --- a/packages/plugin/src/shared/rpc-types.ts +++ b/packages/plugin/src/shared/rpc-types.ts @@ -92,6 +92,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 34fdafe9..17cac4de 100644 --- a/packages/plugin/src/tui/data/context-db.ts +++ b/packages/plugin/src/tui/data/context-db.ts @@ -187,6 +187,7 @@ export async function loadStatusDetail( historyBlockTokens: 0, compressionBudget: null, compressionUsage: null, + toastDurationMs: 5000, }; if (!rpcClient) return emptyDetail; @@ -227,6 +228,17 @@ export async function requestRecomp(sessionId: string): Promise { } } +/** Resolve global toast duration from server config via RPC. */ +export async function loadToastDurationMs(): 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 { type: string; payload: Record; diff --git a/packages/plugin/src/tui/index.tsx b/packages/plugin/src/tui/index.tsx index 84f7be33..738c0f5b 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 } from "./slots/sidebar-content" import packageJson from "../../package.json" -import { closeRpc, consumeTuiMessages, getAnnouncement, getCompartmentCount, initRpcClient, loadStatusDetail, markAnnounced, requestRecomp, type StatusDetail } from "./data/context-db" +import { closeRpc, consumeTuiMessages, getAnnouncement, getCompartmentCount, initRpcClient, loadStatusDetail, loadToastDurationMs, markAnnounced, requestRecomp, 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,26 @@ 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" + }, +): void { + api.ui.toast({ + message: input.message, + variant: input.variant, + duration: getToastDurationMs(), + }) +} function ensureParentDir(filePath: string) { mkdirSync(dirname(filePath), { recursive: true }) @@ -97,14 +117,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 +152,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 +166,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" }) }} /> )) @@ -417,7 +437,7 @@ function getModelKeyFromMessages(api: TuiPluginApi, sessionId: string): string | function showRecompDialog(api: TuiPluginApi) { const sessionId = getSessionId(api) if (!sessionId) { - api.ui.toast({ message: "No active session", variant: "warning" }) + showToast(api, { message: "No active session", variant: "warning" }) return } @@ -435,10 +455,10 @@ function showRecompDialog(api: TuiPluginApi) { ].join("\n")} onConfirm={() => { void requestRecomp(sessionId) - 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" }) }} /> )) @@ -448,7 +468,7 @@ function showRecompDialog(api: TuiPluginApi) { function showStatusDialog(api: TuiPluginApi) { const sessionId = getSessionId(api) if (!sessionId) { - api.ui.toast({ message: "No active session", variant: "warning" }) + showToast(api, { message: "No active session", variant: "warning" }) return } @@ -625,6 +645,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)) @@ -650,10 +671,9 @@ const tui: TuiPlugin = async (api, _options, meta) => { for (const msg of messages) { 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, }) } else if (msg.type === "action") { const action = msg.payload?.action