Skip to content
Open
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
2 changes: 2 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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": {
Expand Down
4 changes: 4 additions & 0 deletions packages/plugin/src/config/schema/magic-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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([
Expand Down
28 changes: 21 additions & 7 deletions packages/plugin/src/hooks/magic-context/command-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,8 @@ export function createMagicContextCommandHandler(deps: {
text: string,
params: NotificationParams,
) => Promise<void>;
/** Configured toast lifetime (ms) forwarded into diagnostics logs. */
toastDurationMs?: number;
sidekick?: {
config: SidekickConfig;
projectPath: string;
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions packages/plugin/src/hooks/magic-context/hook-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -135,6 +137,7 @@ export function getLiveNotificationParams(
...(agent ? { agent } : {}),
...(variant ? { variant } : {}),
...(model ? { providerId: model.providerID, modelId: model.modelID } : {}),
...(typeof toastDurationMs === "number" ? { toastDurationMs } : {}),
};
}

Expand Down
6 changes: 6 additions & 0 deletions packages/plugin/src/hooks/magic-context/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -345,6 +346,7 @@ export function createMagicContextHook(deps: MagicContextDeps) {
liveModelBySession,
variantBySession,
agentBySession,
deps.config.toast_duration_ms,
),
getModelKey: (sessionId) => {
const model = liveModelBySession.get(sessionId);
Expand Down Expand Up @@ -408,6 +410,7 @@ export function createMagicContextHook(deps: MagicContextDeps) {
liveModelBySession,
variantBySession,
agentBySession,
deps.config.toast_duration_ms,
),
nudgePlacements,
onSessionCacheInvalidated: (sessionId: string) => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -554,6 +559,7 @@ export function createMagicContextHook(deps: MagicContextDeps) {
liveModelBySession,
variantBySession,
agentBySession,
deps.config.toast_duration_ms,
),
...params,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export interface NotificationParams {
variant?: string;
providerId?: string;
modelId?: string;
/** TUI toast lifetime in milliseconds (default: 5000). */
toastDurationMs?: number;
}

interface NotificationClient {
Expand Down Expand Up @@ -69,25 +71,21 @@ export async function sendIgnoredMessage(
const { isTuiConnected: checkTui } = await import("../../shared/rpc-notifications");
if (checkTui()) {
try {
const c = client as Record<string, unknown>;
const tui = c?.tui as Record<string, unknown> | 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<string, (...args: unknown[]) => Promise<unknown>>;
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;
Expand Down
12 changes: 12 additions & 0 deletions packages/plugin/src/plugin/rpc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,7 @@ export function buildStatusDetail(
historyBlockTokens: 0,
compressionBudget: null,
compressionUsage: null,
toastDurationMs: 5000,
};

try {
Expand Down Expand Up @@ -567,6 +568,12 @@ export function buildStatusDetail(
if (typeof config.history_budget_percentage === "number") {
detail.historyBudgetPercentage = config.history_budget_percentage;
}
detail.toastDurationMs = resolveConfigValue<number>(
config,
"toast_duration_ms",
modelKey,
5000,
);
}

// Derived values
Expand Down Expand Up @@ -747,6 +754,11 @@ export function registerRpcHandlers(
return { ok: true };
});

rpcServer.handle("toast-duration", async () => {
const resolved = resolveConfigValue<number>(rawConfig, "toast_duration_ms", undefined, 5000);
return { toastDurationMs: resolved };
});

rpcServer.handle("pending-notifications", async (params) => {
const lastReceivedId = Number(params.lastReceivedId ?? 0);
const notifications = drainNotifications(
Expand Down
2 changes: 2 additions & 0 deletions packages/plugin/src/shared/rpc-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions packages/plugin/src/tui/data/context-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ export async function loadStatusDetail(
historyBlockTokens: 0,
compressionBudget: null,
compressionUsage: null,
toastDurationMs: 5000,
};

if (!rpcClient) return emptyDetail;
Expand Down Expand Up @@ -227,6 +228,17 @@ export async function requestRecomp(sessionId: string): Promise<boolean> {
}
}

/** Resolve global toast duration from server config via RPC. */
export async function loadToastDurationMs(): Promise<number> {
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<string, unknown>;
Expand Down
44 changes: 32 additions & 12 deletions packages/plugin/src/tui/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,34 @@ 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"
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 })
Expand Down Expand Up @@ -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" })
}}
/>
))
Expand Down Expand Up @@ -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" })
}}
/>
))
Expand All @@ -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" })
}}
/>
))
Expand Down Expand Up @@ -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
}

Expand All @@ -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" })
}}
/>
))
Expand All @@ -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
}

Expand Down Expand Up @@ -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))
Expand All @@ -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, {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot May 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Server-sent per-message duration field is silently discarded. Previously the poller honored p.duration from the payload, allowing the server to control how long specific toasts appear. Now all toasts use the unified duration. Consider accepting an optional durationOverride in showToast and falling through to the unified default when absent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/plugin/src/tui/index.tsx, line 674:

<comment>Server-sent per-message `duration` field is silently discarded. Previously the poller honored `p.duration` from the payload, allowing the server to control how long specific toasts appear. Now all toasts use the unified duration. Consider accepting an optional `durationOverride` in `showToast` and falling through to the unified default when absent.</comment>

<file context>
@@ -650,10 +671,9 @@ 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",
</file context>
Fix with Cubic

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
Expand Down