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 @@ -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. |
Expand Down Expand Up @@ -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,

Expand Down
10 changes: 10 additions & 0 deletions packages/plugin/src/config/schema/magic-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
);
});

Expand Down Expand Up @@ -786,7 +786,7 @@ describe("createMagicContextCommandHandler", () => {
3,
"ses-dream",
"Dream already queued for this project",
{},
{ toastDurationMs: 5000 },
);
expect(executeDream).toHaveBeenCalledTimes(1);
});
Expand Down
31 changes: 25 additions & 6 deletions packages/plugin/src/hooks/magic-context/command-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ async function executeDreaming(
text: string,
params: NotificationParams,
) => Promise<void>;
toastDurationMs?: number;
dreamer?: {
config: DreamerConfig;
projectPath: string;
Expand All @@ -288,11 +289,15 @@ async function executeDreaming(
},
sessionId: string,
): Promise<never> {
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");
}
Expand All @@ -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)
Expand All @@ -334,7 +339,7 @@ async function executeDreaming(
result
? summarizeDreamResult(result)
: "Dream queued, but another worker is already processing the queue.",
{},
dreamNotificationParams,
);
throwSentinel("CTX-DREAM");
}
Expand Down Expand Up @@ -372,6 +377,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 @@ -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,
)})`,
);
Comment on lines +450 to +463
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 The toast_duration_ms field added to the show-status-dialog action payload is never read by the TUI message handler. When the TUI receives an action message it only reads msg.payload?.action, msg.payload?.resume, etc. — there is no code path that extracts toast_duration_ms from an action payload. The TUI already loads the authoritative duration via loadToastDurationMs() on startup, so the payload field is dead and the log line creates a false impression that the duration propagates per-action.

Suggested change
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,
)})`,
);
pushNotification(
"action",
{
action: "show-status-dialog",
},
sessionId,
);
sessionLog(
sessionId,
"command ctx-status: pushed show-status-dialog to TUI",
);

throwSentinel(input.command);
}
const liveModelKey = deps.getLiveModelKey?.(sessionId);
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
14 changes: 13 additions & 1 deletion packages/plugin/src/hooks/magic-context/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -392,6 +399,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 @@ -445,6 +453,7 @@ export function createMagicContextHook(deps: MagicContextDeps) {
liveModelBySession,
variantBySession,
agentBySession,
deps.config.toast_duration_ms,
),
nudgePlacements,
onSessionCacheInvalidated: (sessionId: string) => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -577,6 +587,7 @@ export function createMagicContextHook(deps: MagicContextDeps) {
liveModelBySession,
variantBySession,
agentBySession,
deps.config.toast_duration_ms,
),
...params,
});
Expand Down Expand Up @@ -706,6 +717,7 @@ export function createMagicContextHook(deps: MagicContextDeps) {
liveModelBySession,
variantBySession,
agentBySession,
deps.config.toast_duration_ms,
),
isTuiConnected,
pushTuiDialogAction: (sid, resume) =>
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 @@ -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<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
13 changes: 13 additions & 0 deletions packages/plugin/src/plugin/rpc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,7 @@ export function buildStatusDetail(
historyBlockTokens: 0,
compressionBudget: null,
compressionUsage: null,
toastDurationMs: 5000,
};

try {
Expand Down Expand Up @@ -629,6 +630,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 @@ -691,6 +698,7 @@ export function registerRpcHandlers(
liveSessionState.liveModelBySession,
liveSessionState.variantBySession,
liveSessionState.agentBySession,
config.toast_duration_ms,
);

const injectionBudgetTokens = config.memory?.injection_budget_tokens;
Expand Down Expand Up @@ -861,6 +869,11 @@ export function registerRpcHandlers(
}
});

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);
// Scope drain to the TUI's active session so a notification tagged for a
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 @@ -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 {
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 @@ -203,6 +203,7 @@ export async function loadStatusDetail(
historyBlockTokens: 0,
compressionBudget: null,
compressionUsage: null,
toastDurationMs: 5000,
};

if (!rpcClient) return emptyDetail;
Expand Down Expand Up @@ -270,6 +271,17 @@ export async function dismissUpgradeReminder(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 {
id: number;
type: string;
Expand Down
Loading