From b99e02c76af2f45768e6eb548ce7cc8f4eec49e9 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:39:18 +0000 Subject: [PATCH 1/8] feat(sidebar): expose option B data and bound refreshes Keep historian observations in bounded session memory and use indexed cache-delta counts. Share the refresh scheduler across OpenCode sidebar paths without changing their layout. --- .../magic-context/memory/storage-memory.ts | 25 ++ .../compartment-runner-historian.test.ts | 124 ++++++++++ .../compartment-runner-historian.ts | 173 ++++++++++--- .../compartment-runner-incremental.ts | 4 + .../compartment-runner-partial-recomp.ts | 4 + .../compartment-runner-recomp.ts | 5 + .../magic-context/compartment-runner-types.ts | 10 +- .../magic-context/compartment-runner.test.ts | 8 + .../magic-context/compartment-trigger.test.ts | 2 + .../magic-context/compartment-trigger.ts | 7 + .../hooks/magic-context/event-handler.test.ts | 3 + .../src/hooks/magic-context/event-handler.ts | 2 + .../sidebar-observations.test.ts | 59 +++++ .../magic-context/sidebar-observations.ts | 53 ++++ .../rpc-handlers-sidebar-option-b.test.ts | 148 +++++++++++ .../plugin/src/plugin/rpc-handlers.test.ts | 26 ++ packages/plugin/src/plugin/rpc-handlers.ts | 62 ++++- packages/plugin/src/shared/rpc-types.ts | 24 +- .../src/tui-compiled/data/context-db.ts | 11 +- .../tui-compiled/data/refresh-scheduler.ts | 97 ++++++++ .../tui-compiled/slots/sidebar-content.tsx | 195 ++------------- .../plugin/src/tui/data/context-db.test.ts | 19 ++ packages/plugin/src/tui/data/context-db.ts | 11 +- .../src/tui/data/refresh-scheduler.test.ts | 149 +++++++++++ .../plugin/src/tui/data/refresh-scheduler.ts | 97 ++++++++ .../plugin/src/tui/slots/sidebar-content.tsx | 233 +++--------------- packages/plugin/src/v2/hooks/context.ts | 2 + .../plugin/src/v2/hooks/event-loop.test.ts | 6 + packages/plugin/src/v2/tui/index.ts | 68 +++-- .../src/v2/tui/sidebar-fallback.test.ts | 96 +++++++- 30 files changed, 1270 insertions(+), 453 deletions(-) create mode 100644 packages/plugin/src/hooks/magic-context/sidebar-observations.test.ts create mode 100644 packages/plugin/src/hooks/magic-context/sidebar-observations.ts create mode 100644 packages/plugin/src/plugin/rpc-handlers-sidebar-option-b.test.ts create mode 100644 packages/plugin/src/tui-compiled/data/refresh-scheduler.ts create mode 100644 packages/plugin/src/tui/data/refresh-scheduler.test.ts create mode 100644 packages/plugin/src/tui/data/refresh-scheduler.ts diff --git a/packages/plugin/src/features/magic-context/memory/storage-memory.ts b/packages/plugin/src/features/magic-context/memory/storage-memory.ts index e8fc82bb62..6d3b9c94a0 100644 --- a/packages/plugin/src/features/magic-context/memory/storage-memory.ts +++ b/packages/plugin/src/features/magic-context/memory/storage-memory.ts @@ -943,6 +943,31 @@ export function getMaxMemoryIdForProjects( return typeof row?.max_id === "number" ? row.max_id : 0; } +export function countNewMemoriesForM1Union( + db: Database, + projectPaths: readonly string[], + afterId: number, + expiryCutoff: number, + ownIdentities?: readonly string[], + shareCategories?: readonly string[] | null, +): number { + const identities = uniqueValues(projectPaths); + if (identities.length === 0) return 0; + const sharingFilter = buildWorkspaceMemorySqlFilter({ + identities, + ownIdentities, + shareCategories, + includeClassificationFields: hasMemoryShareableColumn(db) && hasMemoryScopeColumn(db), + }); + const row = db + .prepare(`SELECT COUNT(*) AS count FROM memories + WHERE project_path IN (${sqlPlaceholders(identities)}) AND id > ? + AND status IN ('active', 'permanent') + AND (expires_at IS NULL OR expires_at > ?)${sharingFilter.clause}`) + .get(...identities, afterId, expiryCutoff, ...sharingFilter.params) as { count: number }; + return row.count; +} + export function readNewMemoriesForM1Union( db: Database, projectPaths: readonly string[], diff --git a/packages/plugin/src/hooks/magic-context/compartment-runner-historian.test.ts b/packages/plugin/src/hooks/magic-context/compartment-runner-historian.test.ts index d322ba0fff..6d0b8aff01 100644 --- a/packages/plugin/src/hooks/magic-context/compartment-runner-historian.test.ts +++ b/packages/plugin/src/hooks/magic-context/compartment-runner-historian.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { closeDatabase, openDatabase } from "../../features/magic-context/storage"; import { getSubagentInvocations } from "../../features/magic-context/storage-subagent-invocations"; +import { buildSidebarSnapshot } from "../../plugin/rpc-handlers"; import type { PluginContext } from "../../plugin/types"; import { clearModelsDevCache, refreshModelLimitsFromApi } from "../../shared/models-dev-cache"; import { recordPromptSessionError } from "../../shared/prompt-async-transport"; @@ -13,10 +14,90 @@ import { runValidatedHistorianPass, } from "./compartment-runner-historian"; import type { HiddenCompletionExecutor } from "./compartment-runner-types"; +import { clearSidebarObservations } from "./sidebar-observations"; const tempDirs: string[] = []; const originalXdgDataHome = process.env.XDG_DATA_HOME; +test("N1 primary rejected twice then fallback publishes; only a clean primary resets the counters", async () => { + const directory = createTestTempDirFromPath(join(tmpdir(), "mc-sidebar-historian-")); + tempDirs.push(directory); + process.env.XDG_DATA_HOME = directory; + const db = openDatabase(); + let attempt = 0; + let clean = false; + const executor: HiddenCompletionExecutor = { + capabilities: { tools: false, harness: "opencode2" }, + open: async () => ({ id: `sidebar-child-${++attempt}` }), + attempt: async () => {}, + collect: async () => ({ + text: + clean || attempt % 3 === 0 + ? 'Keep this.' + : "not usable compartments", + reasoning: null, + lengthCapped: false, + providerId: "actual", + modelId: clean ? "primary" : attempt % 3 === 0 ? "fallback" : "primary", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }), + close: async () => {}, + }; + const run = () => + runValidatedHistorianPass({ + client: undefined, + hiddenCompletionExecutor: executor, + db, + parentSessionId: "sidebar-historian", + sessionDirectory: directory, + prompt: "Messages 1-1:\n1: U: Keep this.", + chunk: { startIndex: 1, endIndex: 1, lines: [{ ordinal: 1, messageId: "m1" }] }, + priorCompartments: [], + sequenceOffset: 0, + dumpLabelBase: "sidebar", + model: "configured/primary", + fallbackModels: ["configured/fallback"], + }); + for (const runs of [1, 2]) { + const result = await run(); + expect(result.ok).toBe(true); + result.finishSidebarRun?.(true); + const snapshot = buildSidebarSnapshot(db, "sidebar-historian", process.cwd()); + expect(snapshot.historianRun).toMatchObject({ + outcome: "fallback", + model: "actual/fallback", + fallback: true, + primaryRejections: 2, + primaryAttempts: 2, + sinceLastClean: { + primaryRejections: runs * 2, + primaryAttempts: runs * 2, + fallbackRuns: runs, + }, + }); + expect(snapshot.historianRun?.finishedAt).toBeGreaterThan(0); + } + clean = true; + const result = await run(); + result.finishSidebarRun?.(true); + expect(buildSidebarSnapshot(db, "sidebar-historian", process.cwd()).historianRun).toMatchObject( + { + outcome: "ok", + model: "actual/primary", + fallback: false, + primaryRejections: 0, + primaryAttempts: 1, + sinceLastClean: { primaryRejections: 0, primaryAttempts: 0, fallbackRuns: 0 }, + }, + ); + const deletedRun = await run(); + clearSidebarObservations("sidebar-historian"); + deletedRun.finishSidebarRun?.(true); + expect( + buildSidebarSnapshot(db, "sidebar-historian", process.cwd()).historianRun, + ).toBeUndefined(); +}); + afterEach(() => { clearModelsDevCache(); closeDatabase(); @@ -41,6 +122,49 @@ test("missing v2 hidden executor names the historian or dream entry point", () = ); }); +test("N1 recognizes a configured fallback selected inside the host transport", async () => { + const directory = createTestTempDirFromPath(join(tmpdir(), "mc-sidebar-host-fallback-")); + tempDirs.push(directory); + process.env.XDG_DATA_HOME = directory; + const db = openDatabase(); + const executor: HiddenCompletionExecutor = { + capabilities: { tools: false, harness: "opencode2" }, + open: async () => ({ id: "host-fallback" }), + attempt: async () => {}, + close: async () => {}, + collect: async () => ({ + text: 'Keep this.', + reasoning: null, + lengthCapped: false, + providerId: "actual", + modelId: "fallback", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }), + }; + const result = await runValidatedHistorianPass({ + client: undefined, + hiddenCompletionExecutor: executor, + db, + parentSessionId: "host-fallback-parent", + sessionDirectory: directory, + prompt: "Messages 1-1:\n1: U: Keep this.", + chunk: { startIndex: 1, endIndex: 1, lines: [{ ordinal: 1, messageId: "m1" }] }, + priorCompartments: [], + sequenceOffset: 0, + dumpLabelBase: "host-fallback", + fallbackModels: ["actual/fallback"], + }); + result.finishSidebarRun?.(true); + expect( + buildSidebarSnapshot(db, "host-fallback-parent", process.cwd()).historianRun, + ).toMatchObject({ + outcome: "fallback", + fallback: true, + model: "actual/fallback", + sinceLastClean: { fallbackRuns: 1 }, + }); +}); + test("historian ledger distinguishes empty, reasoning-only, length-capped and valid output", async () => { const directory = createTestTempDirFromPath(join(tmpdir(), "mc-historian-ledger-")); tempDirs.push(directory); diff --git a/packages/plugin/src/hooks/magic-context/compartment-runner-historian.ts b/packages/plugin/src/hooks/magic-context/compartment-runner-historian.ts index bd9e98befd..3ce5473ff6 100644 --- a/packages/plugin/src/hooks/magic-context/compartment-runner-historian.ts +++ b/packages/plugin/src/hooks/magic-context/compartment-runner-historian.ts @@ -70,6 +70,7 @@ import { producerPromptFailureReason, } from "./producer-window-guard"; import { estimateTokens } from "./read-session-formatting"; +import { beginSidebarHistorianRun } from "./sidebar-observations"; // Intentionally kept: historian validation failure dumps are preserved for // debugging. They land in the project-local historian dir @@ -317,6 +318,8 @@ export function createV1HiddenCompletionExecutor( return { messages, text, + providerId: typeof info.providerID === "string" ? info.providerID : undefined, + modelId: typeof info.modelID === "string" ? info.modelID : undefined, reasoning: text ? null : extractLatestHistorianReasoning(messages), lengthCapped: hasLengthCappedOutput(messages), usage: sumTokensFromChildMessages(messages), @@ -356,44 +359,87 @@ export function historianReasoningBudgetDiagnostic( return `historian ran out of output budget while reasoning (length-capped at ${outputTokens} tokens, no text${tokens ? `; ${formatRunTokenLog(tokens)}` : ""}) — set historian.maxTokens or route historian.model to a low-reasoning lane/variant`; } -export async function runValidatedHistorianPass(args: { - client: PluginContext["client"] | undefined; - hiddenCompletionExecutor?: HiddenCompletionExecutor; - db: Database; - parentSessionId: string; - sessionDirectory: string; - prompt: string; - /** Use `write` to retain message-block cache markers for a later conversational turn. */ - cacheTail?: import("../../shared/historian-cache").HistorianCacheTail; - chunk: HistorianValidationChunk; - priorCompartments: StoredCompartmentRange[]; - sequenceOffset: number; - dumpLabelBase: string; - timeoutMs?: number; - maxOutputTokens?: number; - /** Active OpenCode historian entry, including its outbound request variant. */ - model?: ModelInput; - fallbackModelId?: string; - /** - * Resolved historian fallback chain ("provider/modelID" entries). When the - * primary historian model fails (auth, model-not-found, transient network), - * each fallback is tried in order. The active session model is eligible - * only when no historian primary was configured. - */ - fallbackModels?: readonly ModelInput[]; - callbacks?: HistorianProgressCallbacks; - /** When true, run a second editor pass after successful historian output - * to clean low-signal U: lines and cross-compartment duplicates. If editor - * validation fails, falls back to the draft (first-pass) result. */ - twoPass?: boolean; - subagentKind?: SubagentKind; - agentId?: string; - language?: string; -}): Promise { +export async function runValidatedHistorianPass( + args: Parameters[0], +): Promise { const selected = selectHiddenModelStart(args.model, args.fallbackModels, (message) => shared.sessionLog(args.parentSessionId, message), ); args = { ...args, model: selected.primary, fallbackModels: selected.fallbacks }; + const attempts = { primaryAttempts: 0, primaryRejections: 0 }; + const finishObservation = beginSidebarHistorianRun(args.parentSessionId); + // The sidebar names the model the run started with, after the start selection. + const primaryModel = toModelEntry(args.model)?.model ?? null; + const result = await runValidatedHistorianPassImpl(args, attempts).catch((error: unknown) => { + finishObservation({ + ...attempts, + primaryModel, + outcome: "failed", + model: null, + fallback: false, + }); + throw error; + }); + const usedFallback = + result.usedFallback === true || + (result.producingModel !== primaryModel && + (args.fallbackModels ?? []).some( + (candidate) => toModelEntry(candidate)?.model === result.producingModel, + )); + let finished = false; + const finishSidebarRun = (published: boolean) => { + if (finished) return; + finished = true; + finishObservation({ + ...attempts, + primaryModel, + outcome: published && result.ok ? (usedFallback ? "fallback" : "ok") : "failed", + model: published && result.ok ? (result.producingModel ?? null) : null, + fallback: published && result.ok && usedFallback, + }); + }; + if (!result.ok) finishSidebarRun(false); + return { ...result, finishSidebarRun }; +} + +async function runValidatedHistorianPassImpl( + args: { + client: PluginContext["client"] | undefined; + hiddenCompletionExecutor?: HiddenCompletionExecutor; + db: Database; + parentSessionId: string; + sessionDirectory: string; + prompt: string; + /** Use `write` to retain message-block cache markers for a later conversational turn. */ + cacheTail?: import("../../shared/historian-cache").HistorianCacheTail; + chunk: HistorianValidationChunk; + priorCompartments: StoredCompartmentRange[]; + sequenceOffset: number; + dumpLabelBase: string; + timeoutMs?: number; + maxOutputTokens?: number; + /** Active OpenCode historian entry, including its outbound request variant. */ + model?: ModelInput; + fallbackModelId?: string; + /** + * Resolved historian fallback chain ("provider/modelID" entries). When the + * primary historian model fails (auth, model-not-found, transient network), + * each fallback is tried in order. The active session model is eligible + * only when no historian primary was configured. + */ + fallbackModels?: readonly ModelInput[]; + callbacks?: HistorianProgressCallbacks; + /** When true, run a second editor pass after successful historian output + * to clean low-signal U: lines and cross-compartment duplicates. If editor + * validation fails, falls back to the draft (first-pass) result. */ + twoPass?: boolean; + subagentKind?: SubagentKind; + agentId?: string; + language?: string; + }, + attempts: { primaryAttempts: number; primaryRejections: number }, +): Promise { + attempts.primaryAttempts += 1; const firstRun = await runHistorianPrompt({ ...args, dumpLabel: `${args.dumpLabelBase}-initial`, @@ -401,6 +447,7 @@ export async function runValidatedHistorianPass(args: { agentId: args.agentId, }); if (!firstRun.ok || !firstRun.result) { + attempts.primaryRejections += 1; if (firstRun.refusal?.terminal) return { ok: false, @@ -431,12 +478,18 @@ export async function runValidatedHistorianPass(args: { draftValidation: firstValidation, draftDumpPath: firstRun.dumpPath, draftInvocationId: firstRun.invocationId ?? null, + draftProducingModel: firstRun.producingModel, }) - : { ...firstValidation, invocationId: firstRun.invocationId ?? null }; + : { + ...firstValidation, + invocationId: firstRun.invocationId ?? null, + producingModel: firstRun.producingModel, + }; cleanupHistorianDump(args.parentSessionId, firstRun.dumpPath); return finalResult; } + attempts.primaryRejections += 1; await args.callbacks?.onRepairRetry?.(firstValidation.error ?? "invalid compartment output"); const repairPrompt = buildHistorianRepairPrompt( args.prompt, @@ -444,6 +497,7 @@ export async function runValidatedHistorianPass(args: { firstValidation.error ?? "invalid compartment output", args.language, ); + attempts.primaryAttempts += 1; const repairRun = await runHistorianPrompt({ ...args, prompt: repairPrompt, @@ -452,6 +506,7 @@ export async function runValidatedHistorianPass(args: { agentId: args.agentId, }); if (!repairRun.ok || !repairRun.result) { + attempts.primaryRejections += 1; if (repairRun.refusal?.terminal) return { ok: false, @@ -482,14 +537,20 @@ export async function runValidatedHistorianPass(args: { draftValidation: repairValidation, draftDumpPath: repairRun.dumpPath, draftInvocationId: repairRun.invocationId ?? null, + draftProducingModel: repairRun.producingModel, }) - : { ...repairValidation, invocationId: repairRun.invocationId ?? null }; + : { + ...repairValidation, + invocationId: repairRun.invocationId ?? null, + producingModel: repairRun.producingModel, + }; // Keep firstRun.dumpPath (initial failure) for debugging. // Only cleanup the successful repair run's dump. cleanupHistorianDump(args.parentSessionId, repairRun.dumpPath); return finalResult; } + attempts.primaryRejections += 1; return runFallbackHistorianPass({ ...args, prompt: repairPrompt, @@ -536,6 +597,7 @@ async function runEditorPassOrFallback(args: { draftValidation: ValidatedHistorianPassResult; draftDumpPath?: string; draftInvocationId?: number | null; + draftProducingModel?: string | null; model?: ModelInput; }): Promise { shared.sessionLog(args.parentSessionId, "historian two-pass: running editor on draft"); @@ -560,7 +622,11 @@ async function runEditorPassOrFallback(args: { error: editorRun.error, }); // Editor failed → keep the validated draft; FK links to the draft run. - return { ...args.draftValidation, invocationId: args.draftInvocationId ?? null }; + return { + ...args.draftValidation, + invocationId: args.draftInvocationId ?? null, + producingModel: args.draftProducingModel, + }; } const editorValidation = validateHistorianOutput( @@ -577,12 +643,20 @@ async function runEditorPassOrFallback(args: { { error: editorValidation.error }, ); // Editor output was bad — keep editor dump for debugging. - return { ...args.draftValidation, invocationId: args.draftInvocationId ?? null }; + return { + ...args.draftValidation, + invocationId: args.draftInvocationId ?? null, + producingModel: args.draftProducingModel, + }; } cleanupHistorianDump(args.parentSessionId, editorRun.dumpPath); shared.sessionLog(args.parentSessionId, "historian two-pass: editor accepted"); - return { ...editorValidation, invocationId: editorRun.invocationId ?? null }; + return { + ...editorValidation, + invocationId: editorRun.invocationId ?? null, + producingModel: editorRun.producingModel, + }; } async function runHistorianPrompt(args: { @@ -894,7 +968,19 @@ async function runHistorianPrompt(args: { dumpLabel ?? "historian-response", result, ); - return { ok: true, result, dumpPath, invocationId: invocationId ?? undefined }; + return { + ok: true, + result, + dumpPath, + invocationId: invocationId ?? undefined, + producingModel: + completion.providerId && completion.modelId + ? `${completion.providerId}/${completion.modelId}` + : (completion.attemptedModel ?? + handle.attemptedModel ?? + toModelEntry(modelOverride)?.model ?? + null), + }; } catch (modelError: unknown) { hiddenModelCooldowns.record( handle?.attemptedModel ?? toModelEntry(modelOverride)?.model, @@ -1052,7 +1138,12 @@ async function runFallbackHistorianPass(args: { // Only cleanup the successful run's dump. Prior failed dumps // (args.dumpPaths + earlier chain attempts) are kept for debugging. cleanupHistorianDump(args.parentSessionId, fallbackRun.dumpPath); - return { ...fallbackValidation, invocationId: fallbackRun.invocationId ?? null }; + return { + ...fallbackValidation, + invocationId: fallbackRun.invocationId ?? null, + producingModel: fallbackRun.producingModel, + usedFallback: true, + }; } lastError = fallbackValidation.error ?? lastError; lastFailureReason = fallbackValidation.error ?? "parse_failed"; diff --git a/packages/plugin/src/hooks/magic-context/compartment-runner-incremental.ts b/packages/plugin/src/hooks/magic-context/compartment-runner-incremental.ts index ec7d7281bc..b364dc918b 100644 --- a/packages/plugin/src/hooks/magic-context/compartment-runner-incremental.ts +++ b/packages/plugin/src/hooks/magic-context/compartment-runner-incremental.ts @@ -341,6 +341,7 @@ export async function runCompartmentAgent(deps: HiddenCompartmentRunnerDeps): Pr // durable and already signaled, so a later throw is a failed side step, not a // failed historian run. let publishCommitted = false; + let finishSidebarRun: ((published: boolean) => void) | undefined; let retainDrainReservationForRetryThrottle = false; let issueNotified = false; let drainReservation: ReturnType["reservation"] = null; @@ -1008,6 +1009,7 @@ export async function runCompartmentAgent(deps: HiddenCompartmentRunnerDeps): Pr twoPass: deps.historianTwoPass, language: deps.language, }); + finishSidebarRun = validatedPass.finishSidebarRun; // A window refusal here comes from a fallback too small to hold even a // minimal prompt (every other chain model was fitted above). Treat it like // any other failed pass: count it, notify, and keep @@ -1414,6 +1416,7 @@ export async function runCompartmentAgent(deps: HiddenCompartmentRunnerDeps): Pr // drop queue) is already in the transaction above; embedding registration // and provider calls below are post-commit best-effort and must never leave // a committed publish marked failed or unsignaled. + finishSidebarRun?.(true); deps.onCompartmentStatePublished?.(sessionId); // Inject compaction marker into OpenCode's DB. @@ -1657,6 +1660,7 @@ export async function runCompartmentAgent(deps: HiddenCompartmentRunnerDeps): Pr await notifyHistorianIssue(buildHistorianFailureNotice(failCount, desc.brief)); } } finally { + finishSidebarRun?.(publishCommitted); if (!completedSuccessfully) { if (!retainDrainReservationForRetryThrottle) { rollbackDrainReservation(); diff --git a/packages/plugin/src/hooks/magic-context/compartment-runner-partial-recomp.ts b/packages/plugin/src/hooks/magic-context/compartment-runner-partial-recomp.ts index 0ee495898f..78bab22f8f 100644 --- a/packages/plugin/src/hooks/magic-context/compartment-runner-partial-recomp.ts +++ b/packages/plugin/src/hooks/magic-context/compartment-runner-partial-recomp.ts @@ -153,6 +153,7 @@ export async function executePartialRecompInternal( } const leaseHolderId = holderId; updateSessionMeta(db, sessionId, { compartmentInProgress: true }); + const sidebarRuns: ((published: boolean) => void)[] = []; try { // ── Snap to compartment boundaries ───────────────────────────────── @@ -328,6 +329,7 @@ export async function executePartialRecompInternal( } // Clear partial-range marker — staging is now empty. + for (const finish of sidebarRuns.splice(0)) finish(true); setRecompPartialRange(db, sessionId, null); // Reset depth counters for rebuilt range so fresh compartments start // at depth 0. Prior/tail depth is preserved. @@ -514,6 +516,7 @@ export async function executePartialRecompInternal( return `## Magic Recomp — Failed\n\n${renderUserFacingFailure("recomp_unavailable")}`; } + if (validatedPass.finishSidebarRun) sidebarRuns.push(validatedPass.finishSidebarRun); candidateCompartments = [ ...candidateCompartments, ...(validatedPass.compartments ?? []), @@ -558,6 +561,7 @@ export async function executePartialRecompInternal( ); return `## Magic Recomp — Failed\n\n${renderUserFacingFailure("recomp_unavailable")}`; } finally { + for (const finish of sidebarRuns) finish(false); updateSessionMeta(db, sessionId, { compartmentInProgress: false }); // Best-effort cleanup: if staging is somehow left over without a matching // range marker, clear it. Normal success already cleared via promoteFinal. diff --git a/packages/plugin/src/hooks/magic-context/compartment-runner-recomp.ts b/packages/plugin/src/hooks/magic-context/compartment-runner-recomp.ts index 3e903e2989..8d949dbb73 100644 --- a/packages/plugin/src/hooks/magic-context/compartment-runner-recomp.ts +++ b/packages/plugin/src/hooks/magic-context/compartment-runner-recomp.ts @@ -200,6 +200,7 @@ export async function executeContextRecompInternal(deps: CompartmentRunnerDeps): }; // State file for the current pass — hoisted to be accessible in finally{} updateSessionMeta(db, sessionId, { compartmentInProgress: true }); + const sidebarRuns: ((published: boolean) => void)[] = []; try { const rawMessageCount = getRawSessionMessageCount(sessionId); @@ -303,6 +304,7 @@ export async function executeContextRecompInternal(deps: CompartmentRunnerDeps): const promoted = promoteRecompStagingWithM0Mutation(db, sessionId, leaseHolderId); if (!promoted) return null; + for (const finish of sidebarRuns.splice(0)) finish(true); // Full recomp rebuilds every compartment from message 1 onward, so // all pre-existing compression-depth rows are stale — the compressor @@ -599,6 +601,7 @@ export async function executeContextRecompInternal(deps: CompartmentRunnerDeps): return `## Magic Recomp — Failed\n\n${renderUserFacingFailure("recomp_unavailable")}`; } + if (validatedPass.finishSidebarRun) sidebarRuns.push(validatedPass.finishSidebarRun); // historian_runs telemetry: one row per SUCCESSFUL recomp pass. Failure // early-returns above are already captured in subagent_invocations; we // keep recomp instrumentation to the clean per-pass success point to @@ -683,6 +686,7 @@ export async function executeContextRecompInternal(deps: CompartmentRunnerDeps): sessionLog(sessionId, "recomp publish skipped: compartment lease no longer held"); return "## Magic Recomp — Skipped\n\nAnother process acquired the compartment-state lease before recomp could publish. No state was written."; } + for (const finish of sidebarRuns.splice(0)) finish(true); // Full recomp rebuilds every compartment, so all pre-existing depth // rows are stale. Matches partial recomp's behavior for rebuilt ranges. afterPublish("compression-depth", () => clearCompressionDepth(db, sessionId)); @@ -776,6 +780,7 @@ export async function executeContextRecompInternal(deps: CompartmentRunnerDeps): ); return `## Magic Recomp — Failed\n\n${renderUserFacingFailure("recomp_unavailable")}`; } finally { + for (const finish of sidebarRuns) finish(false); updateSessionMeta(db, sessionId, { compartmentInProgress: false }); } } diff --git a/packages/plugin/src/hooks/magic-context/compartment-runner-types.ts b/packages/plugin/src/hooks/magic-context/compartment-runner-types.ts index caefe5f6a0..a29d83f613 100644 --- a/packages/plugin/src/hooks/magic-context/compartment-runner-types.ts +++ b/packages/plugin/src/hooks/magic-context/compartment-runner-types.ts @@ -280,9 +280,14 @@ export interface HistorianRunResult { failureReason?: string; dumpPath?: string; invocationId?: number; + producingModel?: string | null; } -export type ValidatedHistorianPassResult = +export type ValidatedHistorianPassResult = { + producingModel?: string | null; + usedFallback?: boolean; + finishSidebarRun?: (published: boolean) => void; +} & ( | { ok: true; compartments: CandidateCompartment[]; @@ -312,7 +317,8 @@ export type ValidatedHistorianPassResult = error: string; failureReason?: string; invocationId?: number | null; - }; + } +); export interface StoredCompartmentRange { startMessage: number; diff --git a/packages/plugin/src/hooks/magic-context/compartment-runner.test.ts b/packages/plugin/src/hooks/magic-context/compartment-runner.test.ts index 56c17f9612..7f3b3e2e91 100644 --- a/packages/plugin/src/hooks/magic-context/compartment-runner.test.ts +++ b/packages/plugin/src/hooks/magic-context/compartment-runner.test.ts @@ -46,6 +46,7 @@ import { } from "../../features/magic-context/storage-meta-persisted"; import { rebaseSessionCoordinates } from "../../features/magic-context/store-generation-rebase"; import { createTagger } from "../../features/magic-context/tagger"; +import { buildSidebarSnapshot } from "../../plugin/rpc-handlers"; import type { PluginContext } from "../../plugin/types"; import * as shared from "../../shared"; import { resolveHistorianModel } from "../../shared/model-resolution"; @@ -1597,6 +1598,7 @@ describe("runCompartmentAgent", () => { const meta = getOrCreateSessionMeta(db, "ses-1"); expect(meta.compartmentInProgress).toBe(false); + expect(buildSidebarSnapshot(db, "ses-1", process.cwd()).historianRun?.outcome).toBe("ok"); const compartments = getCompartments(db, "ses-1"); expect(compartments).toHaveLength(1); expect(compartments[0]?.startMessageId).toBe("m-1"); @@ -2018,6 +2020,12 @@ describe("runCompartmentAgent", () => { ).toEqual([]); expect(loadProtectedTailMeta(db, "ses-fact-insert-rollback").priorBoundaryOrdinal).toBe(1); expect(onPublished).not.toHaveBeenCalled(); + expect( + buildSidebarSnapshot(db, "ses-fact-insert-rollback", process.cwd()).historianRun, + ).toMatchObject({ + outcome: "failed", + model: null, + }); expect( db .prepare( diff --git a/packages/plugin/src/hooks/magic-context/compartment-trigger.test.ts b/packages/plugin/src/hooks/magic-context/compartment-trigger.test.ts index e4ea46bc1c..b0a8710009 100644 --- a/packages/plugin/src/hooks/magic-context/compartment-trigger.test.ts +++ b/packages/plugin/src/hooks/magic-context/compartment-trigger.test.ts @@ -18,6 +18,7 @@ import { } from "../../features/magic-context/storage"; import { recordOverflowDetected } from "../../features/magic-context/storage-meta-persisted"; import type { SessionMeta } from "../../features/magic-context/types"; +import { buildSidebarSnapshot } from "../../plugin/rpc-handlers"; import * as logger from "../../shared/logger"; import { Database } from "../../shared/sqlite"; import { closeQuietly } from "../../shared/sqlite-helpers"; @@ -109,6 +110,7 @@ it("does not fire at 88.3% for a tiny capped head even when later eligible histo expect(result.shouldFire).toBe(false); } expect(getHistorianFailureState(db, sessionId).failureCount).toBe(0); + expect(buildSidebarSnapshot(db, sessionId, process.cwd()).waitingMessages).toBe(1); }); } finally { if (oldNodeEnv === undefined) delete process.env.NODE_ENV; diff --git a/packages/plugin/src/hooks/magic-context/compartment-trigger.ts b/packages/plugin/src/hooks/magic-context/compartment-trigger.ts index 20a0e4e145..e634950ea0 100644 --- a/packages/plugin/src/hooks/magic-context/compartment-trigger.ts +++ b/packages/plugin/src/hooks/magic-context/compartment-trigger.ts @@ -48,6 +48,7 @@ import { import { estimateTrueRawMessageTokens } from "./read-session-true-raw-tokens"; import { reasoningBudgetCutoff } from "./reasoning-budget"; import { modelAcceptsEmptyContent } from "./sentinel"; +import { rememberWaitingMessages } from "./sidebar-observations"; const PROACTIVE_TRIGGER_OFFSET_PERCENTAGE = 2; const POST_DROP_TARGET_RATIO = 0.75; @@ -398,6 +399,7 @@ function getUnsummarizedTailInfo( const rawEligibility = getRawHistoryEligibility(db, sessionId); if (!rawEligibility.hasRawBeyondLastCompartment) { + rememberWaitingMessages(sessionId, 0); return { ...TAIL_INFO_DEFAULTS, nextStartOrdinal: rawEligibility.offset }; } @@ -418,6 +420,11 @@ function getUnsummarizedTailInfo( const hasProtectedEligibleHead = boundary.offset < Math.min(boundary.protectedTailStart, boundary.eligibleEndOrdinal); + rememberWaitingMessages( + sessionId, + Math.min(boundary.protectedTailStart, boundary.eligibleEndOrdinal) - + boundary.offset, + ); if (!hasProtectedEligibleHead) { return { diff --git a/packages/plugin/src/hooks/magic-context/event-handler.test.ts b/packages/plugin/src/hooks/magic-context/event-handler.test.ts index 7e3dae32d4..f63c2d7a91 100644 --- a/packages/plugin/src/hooks/magic-context/event-handler.test.ts +++ b/packages/plugin/src/hooks/magic-context/event-handler.test.ts @@ -1,6 +1,7 @@ import { promptAsyncAndWaitForIdle } from "../../shared/prompt-async-transport"; import { drainNotifications } from "../../shared/rpc-notifications"; import { createTestTempDirFromPath } from "../../shared/test-temp-dir"; +import { rememberWaitingMessages, sidebarObservations } from "./sidebar-observations"; /// import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; @@ -1473,6 +1474,7 @@ describe("createEventHandler", () => { useTempDataHome("context-event-explicit-delete-"); const deps = createDeps(new Map()); const sessionId = "ses-explicit-delete"; + rememberWaitingMessages(sessionId, 12); seedRetainedSession(deps.db, sessionId, Date.now() - 25 * 60 * 60 * 1000); const before = snapshotSessionRows(deps.db, sessionId); expect(before.compartments).toHaveLength(40); @@ -1486,6 +1488,7 @@ describe("createEventHandler", () => { for (const [table, rows] of Object.entries(snapshotSessionRows(deps.db, sessionId))) { expect(rows, `${table} retained deleted session data`).toEqual([]); } + expect(sidebarObservations(sessionId).waitingMessages).toBeUndefined(); }); it("retries a failed deleted-session cleanup from its durable marker", async () => { diff --git a/packages/plugin/src/hooks/magic-context/event-handler.ts b/packages/plugin/src/hooks/magic-context/event-handler.ts index eebec19ce1..f10f4cb54a 100644 --- a/packages/plugin/src/hooks/magic-context/event-handler.ts +++ b/packages/plugin/src/hooks/magic-context/event-handler.ts @@ -97,6 +97,7 @@ import { forgetRawSessionOrdinalWatermark } from "./read-session-raw"; import { invalidateTrueRawTokenCache } from "./read-session-true-raw-tokens"; import { type NotificationParams, sendStatusNotification } from "./send-session-notification"; import { isAnthropicFamilyRoute } from "./sentinel"; +import { clearSidebarObservations } from "./sidebar-observations"; import { clearMessageTokensCache } from "./transform"; import { resetDegradedCacheCount } from "./transform-postprocess-phase"; @@ -1278,6 +1279,7 @@ export function createEventHandler(deps: EventHandlerDeps) { return; } + clearSidebarObservations(sessionId); dropSlot(sessionId, "session.deleted"); clearLkgMeasuredRequest(sessionId); clearTrackedOpenCodeSession(sessionId); diff --git a/packages/plugin/src/hooks/magic-context/sidebar-observations.test.ts b/packages/plugin/src/hooks/magic-context/sidebar-observations.test.ts new file mode 100644 index 0000000000..2fecf016d7 --- /dev/null +++ b/packages/plugin/src/hooks/magic-context/sidebar-observations.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test"; +import { + beginSidebarHistorianRun, + clearSidebarObservations, + rememberWaitingMessages, + sidebarObservations, +} from "./sidebar-observations"; + +test("sidebar observations evict old sessions and deletion clears both readings", () => { + for (let i = 0; i < 257; i++) { + const id = `bounded-sidebar-${i}`; + rememberWaitingMessages(id, i); + beginSidebarHistorianRun(id)({ + outcome: "failed", + model: null, + primaryModel: "primary", + fallback: false, + primaryAttempts: 2, + primaryRejections: 2, + }); + } + expect(sidebarObservations("bounded-sidebar-0")).toEqual({ + historianRun: undefined, + waitingMessages: undefined, + }); + expect(sidebarObservations("bounded-sidebar-256").waitingMessages).toBe(256); + clearSidebarObservations("bounded-sidebar-256"); + expect(sidebarObservations("bounded-sidebar-256")).toEqual({ + historianRun: undefined, + waitingMessages: undefined, + }); + for (let i = 0; i < 257; i++) clearSidebarObservations(`bounded-sidebar-${i}`); +}); + +test("a failed run cannot reset degraded counters even when its primary output had no rejection", () => { + const id = "sidebar-failed-not-clean"; + beginSidebarHistorianRun(id)({ + outcome: "fallback", + model: "fallback", + primaryModel: "primary", + fallback: true, + primaryAttempts: 2, + primaryRejections: 2, + }); + beginSidebarHistorianRun(id)({ + outcome: "failed", + model: null, + primaryModel: "primary", + fallback: false, + primaryAttempts: 1, + primaryRejections: 0, + }); + expect(sidebarObservations(id).historianRun).toMatchObject({ + outcome: "failed", + model: null, + sinceLastClean: { primaryAttempts: 3, primaryRejections: 2, fallbackRuns: 1 }, + }); + clearSidebarObservations(id); +}); diff --git a/packages/plugin/src/hooks/magic-context/sidebar-observations.ts b/packages/plugin/src/hooks/magic-context/sidebar-observations.ts new file mode 100644 index 0000000000..f4e4bf64e7 --- /dev/null +++ b/packages/plugin/src/hooks/magic-context/sidebar-observations.ts @@ -0,0 +1,53 @@ +import { BoundedSessionMap } from "../../shared/bounded-session-map"; +import type { SidebarHistorianRun } from "../../shared/rpc-types"; + +const historianRuns = new BoundedSessionMap(256); +const waitingMessages = new BoundedSessionMap(256); +const generations = new BoundedSessionMap(256); + +export function sidebarObservations(sessionId: string) { + return { + historianRun: historianRuns.peek(sessionId), + waitingMessages: waitingMessages.peek(sessionId), + }; +} + +export function rememberWaitingMessages(sessionId: string, count: number): void { + waitingMessages.set(sessionId, Math.max(0, count)); +} + +export function beginSidebarHistorianRun(sessionId: string) { + const generation = generations.get(sessionId) ?? {}; + generations.set(sessionId, generation); + return (run: Omit) => { + // Session deletion or eviction must also invalidate unfinished runs. + if (generations.peek(sessionId) !== generation) return; + finishSidebarHistorianRun(sessionId, run); + }; +} + +function finishSidebarHistorianRun( + sessionId: string, + run: Omit, +): void { + const previous = historianRuns.peek(sessionId)?.sinceLastClean; + const clean = run.outcome === "ok" && !run.fallback && run.primaryRejections === 0; + historianRuns.set(sessionId, { + ...run, + finishedAt: Date.now(), + sinceLastClean: clean + ? { primaryRejections: 0, primaryAttempts: 0, fallbackRuns: 0 } + : { + primaryRejections: (previous?.primaryRejections ?? 0) + run.primaryRejections, + primaryAttempts: (previous?.primaryAttempts ?? 0) + run.primaryAttempts, + fallbackRuns: + (previous?.fallbackRuns ?? 0) + (run.outcome === "fallback" ? 1 : 0), + }, + }); +} + +export function clearSidebarObservations(sessionId: string): void { + historianRuns.delete(sessionId); + waitingMessages.delete(sessionId); + generations.delete(sessionId); +} diff --git a/packages/plugin/src/plugin/rpc-handlers-sidebar-option-b.test.ts b/packages/plugin/src/plugin/rpc-handlers-sidebar-option-b.test.ts new file mode 100644 index 0000000000..66db8e6f71 --- /dev/null +++ b/packages/plugin/src/plugin/rpc-handlers-sidebar-option-b.test.ts @@ -0,0 +1,148 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import { appendCompartments } from "../features/magic-context/compartment-storage"; +import { resolveProjectIdentity } from "../features/magic-context/memory/project-identity"; +import { runMigrations } from "../features/magic-context/migrations"; +import { initializeDatabase } from "../features/magic-context/storage-db"; +import { Database } from "../shared/sqlite"; +import { buildSidebarSnapshot } from "./rpc-handlers"; + +const databases: Database[] = []; +afterEach(() => { + for (const db of databases.splice(0)) db.close(); +}); + +function fixture() { + const db = new Database(":memory:"); + databases.push(db); + initializeDatabase(db); + runMigrations(db); + const directory = process.cwd(); + const project = resolveProjectIdentity(directory); + const session = "sidebar-option-b"; + db.prepare("INSERT INTO session_meta(session_id) VALUES (?)").run(session); + return { db, directory, project, session }; +} + +test("N3 exposes the proactive floor beside the resolved execute threshold", () => { + const { db, directory, session } = fixture(); + const snapshot = buildSidebarSnapshot(db, session, directory, undefined, undefined, { + execute_threshold_percentage: 65, + }); + expect(snapshot.executeThreshold).toBe(65); + expect(snapshot.proactiveFloor).toBe(63); +}); + +test("N4 sums only this session's queued drop tokens with indexed lookups", () => { + const { db, directory, session } = fixture(); + const insert = db.prepare( + "INSERT INTO tags(session_id, message_id, tag_number, type, token_count) VALUES (?, ?, ?, 'text', ?)", + ); + const first = Number(insert.run(session, "m1", 1, 123).lastInsertRowid); + const second = Number(insert.run(session, "m2", 2, 456).lastInsertRowid); + db.prepare("INSERT INTO pending_ops(session_id, tag_id, operation) VALUES (?, ?, 'drop')").run( + session, + first, + ); + db.prepare("INSERT INTO pending_ops(session_id, tag_id, operation) VALUES (?, ?, 'keep')").run( + session, + second, + ); + db.prepare( + "INSERT INTO pending_ops(session_id, tag_id, operation) VALUES ('other', ?, 'drop')", + ).run(second); + const prepared = spyOn(db, "prepare"); + const snapshot = buildSidebarSnapshot(db, session, directory); + expect(snapshot.queuedDropTokens).toBe(123); + const sql = prepared.mock.calls + .map(([sql]) => sql) + .find((sql) => /SUM\(.*token_count/.test(sql)); + expect(sql).toBeDefined(); + prepared.mockRestore(); + const plan = db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(session) as { detail: string }[]; + console.log( + "N4", + plan.map((row) => row.detail), + ); + expect(plan.every((row) => !/SCAN (?:p|t|pending_ops|tags)\b/.test(row.detail))).toBe(true); + expect(plan.some((row) => /idx_pending_ops_session_tag_id/.test(row.detail))).toBe(true); +}); + +test("N5 counts post-cache compartments and visible unexpired active or permanent memories using indexes", () => { + const { db, directory, project, session } = fixture(); + appendCompartments( + db, + session, + [0, 1, 2].map((sequence) => ({ + sequence, + startMessage: sequence + 1, + endMessage: sequence + 1, + title: "History", + content: "History", + })), + ); + db.prepare(`UPDATE session_meta SET cached_m0_materialized_at = 1234, + cached_m0_max_compartment_seq = 0, cached_m0_max_memory_id = 1 WHERE session_id = ?`).run( + session, + ); + const insert = + db.prepare(`INSERT INTO memories(project_path, category, content, normalized_hash, + status, expires_at, first_seen_at, last_seen_at, created_at, updated_at) VALUES (?, 'CONSTRAINTS', 'memory', ?, ?, ?, 1, 1, 1, 1)`); + insert.run(project, "old", "active", null); + insert.run(project, "new", "active", null); + insert.run(project, "permanent", "permanent", null); + insert.run(project, "expired", "active", 1); + insert.run(project, "archived", "archived", null); + insert.run("other", "foreign", "active", null); + const prepared = spyOn(db, "prepare"); + const snapshot = buildSidebarSnapshot(db, session, directory); + expect(snapshot.cachedM0MaterializedAt).toBe(1234); + expect(snapshot.summariesSinceCache).toBe(2); + expect(snapshot.memoriesSinceCache).toBe(2); + const queries = prepared.mock.calls.map(([sql]) => sql); + prepared.mockRestore(); + for (const [label, pattern, params] of [ + ["N5 summaries", /COUNT\(\*\).*compartments.*sequence\s*>/s, [session, 0]], + ["N5 memories", /COUNT\(\*\).*memories.*id\s*>/s, [project, 1, Date.now()]], + ] as const) { + const sql = queries.find((sql) => pattern.test(sql)); + expect(sql).toBeDefined(); + const plan = db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...params) as { detail: string }[]; + console.log( + label, + plan.map((row) => row.detail), + ); + expect(plan.some((row) => /SEARCH .*USING (?:COVERING )?INDEX/.test(row.detail))).toBe( + true, + ); + expect(plan.every((row) => !/SCAN (?:compartments|memories)\b/.test(row.detail))).toBe( + true, + ); + } +}); + +test("N5 uses workspace sharing and classification from the render policy", () => { + const { db, directory, project, session } = fixture(); + const workspace = Number( + db + .prepare( + "INSERT INTO workspaces(name, created_at, updated_at) VALUES ('sidebar', 1, 1)", + ) + .run().lastInsertRowid, + ); + const member = db.prepare( + "INSERT INTO workspace_members(workspace_id, project_path, display_name, display_path, added_at) VALUES (?, ?, ?, ?, 1)", + ); + member.run(workspace, project, "own", directory); + member.run(workspace, "git:foreign", "foreign", "foreign"); + const insert = + db.prepare(`INSERT INTO memories(project_path, category, content, normalized_hash, + status, shareable, scope, first_seen_at, last_seen_at, created_at, updated_at) + VALUES (?, ?, 'memory', ?, ?, ?, ?, 1, 1, 1, 1)`); + insert.run(project, "CODE", "own", "active", 0, "project"); + insert.run("git:foreign", "CONSTRAINTS", "shared", "active", 1, "ecosystem"); + insert.run("git:foreign", "CONSTRAINTS", "permanent", "permanent", 1, "universe"); + insert.run("git:foreign", "CONSTRAINTS", "private", "active", 0, "project"); + insert.run("git:foreign", "CODE", "not-shared", "active", 1, "ecosystem"); + insert.run("git:foreign", "CONSTRAINTS", "session-only", "active", 1, "session"); + expect(buildSidebarSnapshot(db, session, directory).memoriesSinceCache).toBe(3); +}); diff --git a/packages/plugin/src/plugin/rpc-handlers.test.ts b/packages/plugin/src/plugin/rpc-handlers.test.ts index e58bdf3907..44ee9ed2c4 100644 --- a/packages/plugin/src/plugin/rpc-handlers.test.ts +++ b/packages/plugin/src/plugin/rpc-handlers.test.ts @@ -264,6 +264,13 @@ async function exerciseManagedOutputCap(args: { caps.every((cap) => cap === args.maxTokens), `${args.surface} ${args.method}`, ).toBe(true); + expect( + buildSidebarSnapshot(db, sessionId, liveConfig.directory).historianRun, + ).toMatchObject({ + outcome: "ok", + model: "test/rpc-historian", + fallback: false, + }); } finally { unregisterProvider(); closeQuietly(db); @@ -285,6 +292,25 @@ describe("manual historian cap reaches the OpenCode historian executor", () => { } }); +test("sidebar copies the live config reload failure carried by status detail", () => { + const liveConfig = createLiveHistorianConfig(4096); + const db = createTestDb(); + try { + const configPath = join(liveConfig.root, "config", "cortexkit", "magic-context.jsonc"); + writeFileSync(configPath, "{ invalid config"); + pluginConfigReader(liveConfig.directory, liveConfig.bootConfig).poll(); + const sidebar = buildSidebarSnapshot(db, "config-health", liveConfig.directory); + const status = buildStatusDetail(db, "config-health", liveConfig.directory); + expect(sidebar.configReloadFailure?.path).toBe(configPath); + expect(sidebar.configReloadFailure?.message).toBeTruthy(); + expect(sidebar.configReloadFailure).toEqual(status.configReloadFailure); + } finally { + closeQuietly(db); + liveConfig.restoreEnvironment(); + rmSync(liveConfig.root, { recursive: true, force: true }); + } +}); + afterEach(() => { resetSidebarSnapshotCache(); clearModelsDevCache(); diff --git a/packages/plugin/src/plugin/rpc-handlers.ts b/packages/plugin/src/plugin/rpc-handlers.ts index e0abc7bd41..a9d7b74067 100644 --- a/packages/plugin/src/plugin/rpc-handlers.ts +++ b/packages/plugin/src/plugin/rpc-handlers.ts @@ -4,7 +4,7 @@ */ import { randomUUID } from "node:crypto"; import { once } from "node:events"; -import { closeSync, rmSync } from "node:fs"; +import { rmSync } from "node:fs"; import { join } from "node:path"; import { COMPACTION_ENABLED_PATH, isCompactionEnabled } from "../config/agent-disable"; @@ -38,6 +38,7 @@ import { resolveProjectIdentityForSession, shouldSkipHomeProjectMemory, } from "../features/magic-context/memory/project-identity"; +import { countNewMemoriesForM1Union } from "../features/magic-context/memory/storage-memory"; import { getMessageIndexQueueHeapStats } from "../features/magic-context/message-index-async"; import { getMural } from "../features/magic-context/mural/storage-mural"; import { getEmbeddingCoverageStatus } from "../features/magic-context/project-embedding-registry"; @@ -67,7 +68,13 @@ import { readSessionWorkMetricsCarry, setSessionWorkMetricsCarry, } from "../features/magic-context/work-metrics-carry-storage"; +import { + expandWorkspaceIdentitySetWithAliases, + resolveWorkspaceIdentitySet, + resolveWorkspaceShareCategories, +} from "../features/magic-context/workspaces"; import type { HiddenCompletionExecutor } from "../hooks/magic-context/compartment-runner-types"; +import { getProactiveCompartmentTriggerPercentage } from "../hooks/magic-context/compartment-trigger"; import { type EmbedHistoryDeps, pauseEmbedHistoryDrain, @@ -86,7 +93,6 @@ import { getLiveNotificationParams } from "../hooks/magic-context/hook-handlers" import type { LiveSessionState } from "../hooks/magic-context/live-session-state"; import { getLkgSlotHeapStats } from "../hooks/magic-context/lkg-slot"; import { computeM0BlockTokens } from "../hooks/magic-context/m0-token-breakdown"; - import { findLastAssistantModelFromOpenCodeDb, openCodeDbExists, @@ -95,6 +101,7 @@ import { import { getTokenizerNativeMemoryStats } from "../hooks/magic-context/read-session-formatting"; import type { ManagedRecompContext } from "../hooks/magic-context/recomp-orchestrator"; import type { RustModeModuleClient } from "../hooks/magic-context/rust-mode-transform"; +import { sidebarObservations } from "../hooks/magic-context/sidebar-observations"; import { calibrateBuckets, resolveModelCalibration, @@ -445,13 +452,18 @@ export function buildSidebarSnapshot( } let pendingOpsCount = 0; + let queuedDropTokens = 0; try { const pendingRow = db - .prepare<[string], { count: number }>( - "SELECT COUNT(*) as count FROM pending_ops WHERE session_id = ?", + .prepare<[string], { count: number; tokens: number }>( + `SELECT COUNT(*) AS count, + COALESCE(SUM(CASE WHEN p.operation = 'drop' THEN t.token_count ELSE 0 END), 0) AS tokens + FROM pending_ops p LEFT JOIN tags t ON t.id = p.tag_id AND t.session_id = p.session_id + WHERE p.session_id = ?`, ) .get(sessionId); pendingOpsCount = pendingRow?.count ?? 0; + queuedDropTokens = pendingRow?.tokens ?? 0; } catch { // pending_ops table may not exist } @@ -688,6 +700,39 @@ export function buildSidebarSnapshot( calibration, }); + const cachedM0MaterializedAt = + typeof meta?.cached_m0_materialized_at === "number" + ? meta.cached_m0_materialized_at + : null; + const summariesSinceCache = + db + .prepare<[string, number], { count: number }>( + "SELECT COUNT(*) AS count FROM compartments WHERE session_id = ? AND sequence > ?", + ) + .get(sessionId, Number(meta?.cached_m0_max_compartment_seq ?? -1))?.count ?? 0; + let memoriesSinceCache = 0; + if (projectIdentity) { + const identitySet = resolveWorkspaceIdentitySet(db, projectIdentity); + const workspaced = identitySet.identities.length > 1; + const expanded = workspaced + ? expandWorkspaceIdentitySetWithAliases(db, identitySet.identities) + : null; + const identities = expanded?.expandedIdentities ?? identitySet.identities; + const ownIdentities = expanded + ? identities.filter( + (id) => expanded.canonicalIdentityByStoredPath.get(id) === projectIdentity, + ) + : [projectIdentity]; + memoriesSinceCache = countNewMemoriesForM1Union( + db, + identities, + Number(meta?.cached_m0_max_memory_id ?? 0), + Date.now(), + ownIdentities, + workspaced ? resolveWorkspaceShareCategories(db, projectIdentity) : null, + ); + } + const liveFailure = currentPluginConfigReader(directory)?.lastFailure(); const fresh: SidebarSnapshot = { sessionId, usagePercentage: effectiveUsagePercentage, @@ -701,6 +746,15 @@ export function buildSidebarSnapshot( memoryCount, memoryBlockCount, pendingOpsCount, + queuedDropTokens, + ...(config?.transform_mode === "rust" ? {} : sidebarObservations(sessionId)), + proactiveFloor: getProactiveCompartmentTriggerPercentage(executeThreshold), + cachedM0MaterializedAt, + summariesSinceCache, + memoriesSinceCache, + configReloadFailure: liveFailure + ? { path: liveFailure.path, message: liveFailure.message } + : undefined, historianRunning: moduleStatus?.wrapup_active === true || compartmentInProgress, compartmentInProgress: moduleStatus?.wrapup_active === true || compartmentInProgress, sessionNoteCount, diff --git a/packages/plugin/src/shared/rpc-types.ts b/packages/plugin/src/shared/rpc-types.ts index fe97b06ebe..ab3de4602c 100644 --- a/packages/plugin/src/shared/rpc-types.ts +++ b/packages/plugin/src/shared/rpc-types.ts @@ -32,6 +32,21 @@ export interface TailHygieneStatus { computedAt: number; } +export interface SidebarHistorianRun { + finishedAt: number; + outcome: "ok" | "fallback" | "failed"; + model: string | null; + primaryModel: string | null; + fallback: boolean; + primaryRejections: number; + primaryAttempts: number; + sinceLastClean: { + primaryRejections: number; + primaryAttempts: number; + fallbackRuns: number; + }; +} + export interface SidebarSnapshot { sessionId: string; usagePercentage: number; @@ -55,6 +70,14 @@ export interface SidebarSnapshot { memoryCount: number; memoryBlockCount: number; pendingOpsCount: number; + queuedDropTokens?: number; + historianRun?: SidebarHistorianRun; + waitingMessages?: number; + proactiveFloor?: number; + cachedM0MaterializedAt?: number | null; + summariesSinceCache?: number; + memoriesSinceCache?: number; + configReloadFailure?: { path: string; message: string }; historianRunning: boolean; compartmentInProgress: boolean; sessionNoteCount: number; @@ -255,7 +278,6 @@ export interface StatusDetail extends SidebarSnapshot { activeProfile: string | null; configGeneration?: number; configAdoptedAt?: number; - configReloadFailure?: { path: string; message: string }; tagCounter: number; activeTags: number; droppedTags: number; diff --git a/packages/plugin/src/tui-compiled/data/context-db.ts b/packages/plugin/src/tui-compiled/data/context-db.ts index 524756f287..ecd5e48349 100644 --- a/packages/plugin/src/tui-compiled/data/context-db.ts +++ b/packages/plugin/src/tui-compiled/data/context-db.ts @@ -277,9 +277,13 @@ function recallSidebarSnapshot(sessionId: string, fallback: SidebarSnapshot): Si export async function loadSidebarSnapshot( sessionId: string, directory: string, + options?: { throwOnFailure?: boolean }, ): Promise { const empty: SidebarSnapshot = { ...EMPTY_SNAPSHOT, sessionId }; - if (!rpcClient) return recallSidebarSnapshot(sessionId, empty); + if (!rpcClient) { + if (options?.throwOnFailure) throw new Error("RPC client is not initialized"); + return recallSidebarSnapshot(sessionId, empty); + } try { const result = await callSessionRpc( directory, @@ -291,6 +295,8 @@ export async function loadSidebarSnapshot( }, ); if ((result as unknown as Record).error) { + if (options?.throwOnFailure) + throw new Error(String((result as unknown as Record).error)); // Snapshot-build errors are explicit failure envelopes, equivalent to // a transport failure: retain the last known-good client snapshot. return recallSidebarSnapshot(sessionId, empty); @@ -309,7 +315,8 @@ export async function loadSidebarSnapshot( // owned exclusively by the server side. rememberSidebarSnapshot(result); return result; - } catch { + } catch (error) { + if (options?.throwOnFailure) throw error; return recallSidebarSnapshot(sessionId, empty); } } diff --git a/packages/plugin/src/tui-compiled/data/refresh-scheduler.ts b/packages/plugin/src/tui-compiled/data/refresh-scheduler.ts new file mode 100644 index 0000000000..d849d0a282 --- /dev/null +++ b/packages/plugin/src/tui-compiled/data/refresh-scheduler.ts @@ -0,0 +1,97 @@ +export interface RefreshClock { + now(): number; + setTimeout(callback: () => void, delay: number): unknown; + clearTimeout(handle: unknown): void; +} + +export function createRefreshScheduler(options: { + read(): Promise; + onSnapshot(snapshot: T): void; + isRunning(snapshot: T): boolean; + clock?: RefreshClock; +}) { + const clock: RefreshClock = options.clock ?? { + now: () => Date.now(), + setTimeout: (callback, delay) => setTimeout(callback, delay), + clearTimeout: (handle) => clearTimeout(handle as ReturnType), + }; + let timer: unknown; + let disposed = false; + let inFlight = false; + let generation = 0; + let lastStarted = -Infinity; + let blockedUntil = 0; + let requestedAt = Infinity; + let pollAt = Infinity; + let burstStarted: number | undefined; + + function arm() { + if (timer !== undefined) clock.clearTimeout(timer); + timer = undefined; + if (disposed || inFlight) return; + const due = Math.max(Math.min(requestedAt, pollAt), lastStarted + 1000, blockedUntil); + if (!Number.isFinite(due)) return; + timer = clock.setTimeout( + () => { + timer = undefined; + void read(); + }, + Math.max(0, due - clock.now()), + ); + } + + async function read() { + if (disposed || inFlight) return; + const currentGeneration = generation; + inFlight = true; + lastStarted = clock.now(); + requestedAt = Infinity; + pollAt = Infinity; + burstStarted = undefined; + try { + const snapshot = await options.read(); + if (disposed || currentGeneration !== generation) return; + blockedUntil = 0; + options.onSnapshot(snapshot); + pollAt = options.isRunning(snapshot) ? clock.now() + 2000 : Infinity; + } catch { + if (disposed || currentGeneration !== generation) return; + blockedUntil = clock.now() + 30000; + requestedAt = blockedUntil; + } finally { + inFlight = false; + arm(); + } + } + + return { + event() { + if (disposed) return; + const now = clock.now(); + burstStarted ??= now; + // A continuous stream must not postpone the trailing read without limit. + requestedAt = Math.min(now + 200, burstStarted + 1000); + arm(); + }, + refresh() { + if (disposed) return; + requestedAt = Math.min(requestedAt, clock.now()); + arm(); + }, + reset() { + if (disposed) return; + generation += 1; + pollAt = Infinity; + blockedUntil = 0; + burstStarted = undefined; + requestedAt = clock.now(); + arm(); + }, + dispose() { + disposed = true; + generation += 1; + if (timer !== undefined) clock.clearTimeout(timer); + timer = undefined; + }, + }; +} diff --git a/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx b/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx index fbeadb25c4..583b3e8240 100644 --- a/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx +++ b/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx @@ -12,15 +12,13 @@ import packageJson from "../../../package.json"; import { badgeTextColor } from "../badge-contrast"; import { loadSidebarSnapshot } from "../data/context-db"; import { directoryForSession } from "../data/session-directory"; +import { createRefreshScheduler } from "../data/refresh-scheduler"; import { formatThresholdPercent } from "../../shared/format-threshold"; import { renderUserFacingFailure } from "../../shared/user-facing-codes"; import { compactionOffSidebarRows, nativeCompactionContextLabel } from "../compaction-off"; import { computeEffectiveOrder, DEFAULT_SLOT_ORDER, PLUGIN_KEY, queueTuiPreferenceUpdate, readTuiPreferencesFile, readTuiPreferencesFileSync, resolveMagicContextPrefs, watchTuiPreferences } from "../../shared/tui-preferences"; -// Module-level hook so the upgrade/recomp dialog can kick the sidebar into its -// fast recomp self-poll the INSTANT the user confirms — without waiting for a -// parent-session message event (the RPC upgrade/recomp call fires none). The -// mounted SidebarContent registers its refresh here. +// Recomp commands do not send parent-session message events. let activeRecompPollKick = null; let activeSidebarRefresh = null; export function kickRecompProgressRefresh() { @@ -34,7 +32,6 @@ export function refreshSidebarSnapshot() { const SINGLE_BORDER = { type: "single" }; -const REFRESH_DEBOUNCE_MS = 150; // The TUI may unmount and remount sidebar_content when the user switches views // (main -> subagent -> main). A remount re-runs the component body, so a signal // created inside the component would reset to its seed. The controller lives in @@ -526,187 +523,39 @@ const SidebarContent = props => { const collapsed = props.controller.collapsed; const sections = () => props.controller.prefs().sections; const headerLabel = () => props.controller.prefs().header.label; - let refreshTimer; - // Self-sustaining poll while a recomp/upgrade is running. Recomp work - // happens in CHILD sessions whose message events are filtered out of the - // subscription below, so without this the progress bar would freeze until - // the next parent-session message. Active only during recomp/migration; - // stops itself once the phase goes terminal/absent (dogfood 2026-05-30). - let recompPollTimer; - const RECOMP_POLL_MS = 1200; - // Robust recomp poll state. The loop MUST survive a failed/slow snapshot - // fetch — the server is busy doing the historian LLM call during a recomp, - // so a poll can reject or return a stale (pre-recomp) cached snapshot. The - // OLD loop reattached the next timer only inside `.then()`, so any rejection - // killed it and the bar froze mid-pass (dogfood 2026-05-30). This version - // reschedules on BOTH success and failure, keyed on `recompActive`, and only - // stops on a terminal phase, a bounded "never started" probe window, or the - // entry vanishing after we'd seen it active. - let recompActive = false; - let recompSawPhase = false; - let recompPollCount = 0; - let recompConsecutiveAbsent = 0; - let recompSessionId = null; - let snapshotRequestSequence = 0; - const RECOMP_PROBE_MAX = 12; // ~15s for the server's "Starting…" to land - // After we've SEEN an active phase, a momentarily absent snapshot is almost - // always transient — the server's sticky cache serves a pre-recomp snapshot - // (no recompProgress) during the token-quiet recomp window, or a concurrent - // BEGIN-IMMEDIATE publish makes the snapshot DB read throw → bare empty. The - // entry is held until terminal + a 30s grace, so we keep polling through many - // absents and only give up after a long run of them (entry truly gone but we - // somehow missed "done"). This was the freeze: the old logic stopped on the - // FIRST absent-after-active (dogfood 2026-05-30). - const RECOMP_ABSENT_GIVEUP = 40; // ~48s of continuous absence → stop - const RECOMP_MAX_POLLS = 1500; // ~30min absolute safety cap - - const refresh = () => { - const sid = props.sessionID(); - if (!sid) return; - const sequence = ++snapshotRequestSequence; - const directory = directoryForSession(props.api.state.session?.get?.(sid)?.directory, props.api.state.path.directory ?? ""); - void loadSidebarSnapshot(sid, directory).then(data => { - // Guard against a session switch while this load was in flight: - // painting session A's snapshot into the now-active session B shows - // the wrong session's numbers until B's own refresh resolves. - if (props.sessionID() !== sid || sequence !== snapshotRequestSequence) return; + const scheduler = createRefreshScheduler({ + read: async () => { + const sid = props.sessionID(); + if (!sid) return null; + const directory = directoryForSession(props.api.state.session?.get?.(sid)?.directory, props.api.state.path.directory ?? ""); + return loadSidebarSnapshot(sid, directory, { + throwOnFailure: true + }); + }, + onSnapshot: data => { setSnapshot(data); try { props.api.renderer.requestRender(); } catch { - // Ignore render errors - } - // If a recomp/upgrade is running (detected via any refresh, e.g. - // a /ctx-recomp command not started from the dialog), make sure - // the dedicated poll loop is running. - const phase = data?.recompProgress?.phase; - if ((phase === "recomp" || phase === "migration") && !recompActive) { - kickRecompPoll(); - } else if (recompActive && recompSessionId === sid) { - scheduleRecompTick(); + // The renderer can close before the request completes. } - }).catch(() => { - // A one-shot refresh failure is non-fatal. If it superseded a fast - // poll request, keep that poll moving for the captured session. - if (recompActive && recompSessionId === sid && props.sessionID() === sid) { - scheduleRecompTick(); - } - }); - }; - const scheduleRefresh = () => { - if (refreshTimer) clearTimeout(refreshTimer); - refreshTimer = setTimeout(() => { - refreshTimer = undefined; - refresh(); - }, REFRESH_DEBOUNCE_MS); - }; - const stopRecompPoll = () => { - recompActive = false; - recompSessionId = null; - snapshotRequestSequence += 1; - if (recompPollTimer) clearTimeout(recompPollTimer); - recompPollTimer = undefined; - }; - const scheduleRecompTick = () => { - if (!recompActive) return; - if (recompPollTimer) clearTimeout(recompPollTimer); - recompPollTimer = setTimeout(recompTick, RECOMP_POLL_MS); - }; - function recompTick() { - const sid = recompSessionId; - if (!recompActive || !sid || props.sessionID() !== sid) { - stopRecompPoll(); - return; - } - recompPollCount += 1; - if (recompPollCount > RECOMP_MAX_POLLS) { - stopRecompPoll(); - return; - } - const sequence = ++snapshotRequestSequence; - const directory = directoryForSession(props.api.state.session?.get?.(sid)?.directory, props.api.state.path.directory ?? ""); - void loadSidebarSnapshot(sid, directory).then(data => { - if (!recompActive || recompSessionId !== sid || props.sessionID() !== sid || sequence !== snapshotRequestSequence) return; - const phase = data?.recompProgress?.phase; - // While a recomp is known-active, a transient snapshot that lost - // recompProgress (sticky cache / busy-DB empty) must NOT wipe the - // visible bar — carry the last good progress forward so it stays - // stable until a real update or the terminal state lands. - const prevProgress = snapshot()?.recompProgress; - const merged = !phase && recompSawPhase && prevProgress ? { - ...data, - recompProgress: prevProgress - } : data; - setSnapshot(merged); - try { - props.api.renderer.requestRender(); - } catch { - // ignore render errors - } - if (phase === "recomp" || phase === "migration") { - recompSawPhase = true; - recompConsecutiveAbsent = 0; - scheduleRecompTick(); - } else if (phase === "done" || phase === "failed" || phase === "skipped") { - // Terminal state rendered — stop. The server keeps "done"/ - // "skipped" for a grace window and "failed" until the next run, - // so the outcome stays visible without further polling. - stopRecompPoll(); - } else { - // Phase absent this poll. - recompConsecutiveAbsent += 1; - if (!recompSawPhase) { - // Still waiting for the server's first "Starting…". - if (recompPollCount < RECOMP_PROBE_MAX) scheduleRecompTick();else { - stopRecompPoll(); - } - } else if (recompConsecutiveAbsent < RECOMP_ABSENT_GIVEUP) { - // Seen it active — absent is almost certainly the sticky - // cache / a transient snapshot read. Keep polling so we - // still catch the terminal state. DON'T overwrite the - // last good progress snapshot with this transient empty. - scheduleRecompTick(); - } else { - // Long continuous absence — the entry is genuinely gone. - stopRecompPoll(); - } - } - }).catch(() => { - // A failed fetch must not kill the loop, but a response from a - // superseded session or sequence must not restart it either. - if (recompActive && recompSessionId === sid && props.sessionID() === sid && sequence === snapshotRequestSequence) scheduleRecompTick(); - }); - } - - // Kick the resilient recomp poll loop on dialog confirm (or when a refresh - // first detects an active recomp). The server emits an immediate "Starting…" - // entry; the probe window covers the brief RPC race before it lands. - function kickRecompPoll() { - const sid = props.sessionID(); - if (!sid) return; - if (recompActive && recompSessionId === sid) return; - stopRecompPoll(); - recompActive = true; - recompSessionId = sid; - recompSawPhase = false; - recompPollCount = 0; - recompConsecutiveAbsent = 0; - recompTick(); - } - activeRecompPollKick = kickRecompPoll; + }, + isRunning: data => Boolean(data?.historianRunning || data?.dreamerProgress || data?.recompProgress?.phase === "recomp" || data?.recompProgress?.phase === "migration") + }); + const refresh = () => scheduler.refresh(); + const scheduleRefresh = () => scheduler.event(); + activeRecompPollKick = refresh; activeSidebarRefresh = refresh; onCleanup(() => { - if (refreshTimer) clearTimeout(refreshTimer); - stopRecompPoll(); - if (activeRecompPollKick === kickRecompPoll) activeRecompPollKick = null; + scheduler.dispose(); + if (activeRecompPollKick === refresh) activeRecompPollKick = null; if (activeSidebarRefresh === refresh) activeSidebarRefresh = null; }); // Refresh on session change createEffect(on(props.sessionID, () => { - stopRecompPoll(); setSnapshot(null); - refresh(); + scheduler.reset(); })); // Subscribe to events for live updates diff --git a/packages/plugin/src/tui/data/context-db.test.ts b/packages/plugin/src/tui/data/context-db.test.ts index 6050c8abd3..7e0d5df73b 100644 --- a/packages/plugin/src/tui/data/context-db.test.ts +++ b/packages/plugin/src/tui/data/context-db.test.ts @@ -91,6 +91,25 @@ async function startServer( } describe("TUI context RPC data", () => { + test("scheduled reads expose RPC failures instead of hiding them behind a cached snapshot", async () => { + const dataHome = makeDataHome(); + const directory = "/repo-scheduled"; + const sessionId = "ses_scheduled"; + let response: Record = snapshot(sessionId, 250) as unknown as Record< + string, + unknown + >; + await startServer(dataHome, directory, () => response); + initRpcClient(directory); + expect( + (await loadSidebarSnapshot(sessionId, directory, { throwOnFailure: true })).inputTokens, + ).toBe(250); + response = { error: "database busy" }; + await expect( + loadSidebarSnapshot(sessionId, directory, { throwOnFailure: true }), + ).rejects.toThrow("database busy"); + expect((await loadSidebarSnapshot(sessionId, directory)).inputTokens).toBe(250); + }); test("uses an unexpired sticky snapshot only for failed responses", async () => { const dataHome = makeDataHome(); const directory = "/repo-sticky"; diff --git a/packages/plugin/src/tui/data/context-db.ts b/packages/plugin/src/tui/data/context-db.ts index 524756f287..ecd5e48349 100644 --- a/packages/plugin/src/tui/data/context-db.ts +++ b/packages/plugin/src/tui/data/context-db.ts @@ -277,9 +277,13 @@ function recallSidebarSnapshot(sessionId: string, fallback: SidebarSnapshot): Si export async function loadSidebarSnapshot( sessionId: string, directory: string, + options?: { throwOnFailure?: boolean }, ): Promise { const empty: SidebarSnapshot = { ...EMPTY_SNAPSHOT, sessionId }; - if (!rpcClient) return recallSidebarSnapshot(sessionId, empty); + if (!rpcClient) { + if (options?.throwOnFailure) throw new Error("RPC client is not initialized"); + return recallSidebarSnapshot(sessionId, empty); + } try { const result = await callSessionRpc( directory, @@ -291,6 +295,8 @@ export async function loadSidebarSnapshot( }, ); if ((result as unknown as Record).error) { + if (options?.throwOnFailure) + throw new Error(String((result as unknown as Record).error)); // Snapshot-build errors are explicit failure envelopes, equivalent to // a transport failure: retain the last known-good client snapshot. return recallSidebarSnapshot(sessionId, empty); @@ -309,7 +315,8 @@ export async function loadSidebarSnapshot( // owned exclusively by the server side. rememberSidebarSnapshot(result); return result; - } catch { + } catch (error) { + if (options?.throwOnFailure) throw error; return recallSidebarSnapshot(sessionId, empty); } } diff --git a/packages/plugin/src/tui/data/refresh-scheduler.test.ts b/packages/plugin/src/tui/data/refresh-scheduler.test.ts new file mode 100644 index 0000000000..0bcdc338a5 --- /dev/null +++ b/packages/plugin/src/tui/data/refresh-scheduler.test.ts @@ -0,0 +1,149 @@ +import { expect, test } from "bun:test"; +import { createRefreshScheduler, type RefreshClock } from "./refresh-scheduler"; + +function fakeClock() { + let now = 0; + let next = 0; + const timers = new Map void }>(); + const clock: RefreshClock = { + now: () => now, + setTimeout(callback, delay) { + const id = ++next; + timers.set(id, { at: now + delay, callback }); + return id; + }, + clearTimeout(handle) { + timers.delete(handle as number); + }, + }; + const advance = async (ms: number) => { + const end = now + ms; + for (;;) { + await Promise.resolve(); + await Promise.resolve(); + const due = [...timers.entries()] + .filter(([, t]) => t.at <= end) + .sort((a, b) => a[1].at - b[1].at)[0]; + if (!due) break; + now = due[1].at; + timers.delete(due[0]); + due[1].callback(); + } + now = end; + await Promise.resolve(); + await Promise.resolve(); + }; + return { clock, advance, timers }; +} + +test("debounces host events by 200ms and limits a stream to one read per second without starvation", async () => { + const { clock, advance } = fakeClock(); + const starts: number[] = []; + const scheduler = createRefreshScheduler({ + clock, + read: async () => { + starts.push(clock.now()); + return false; + }, + onSnapshot: () => {}, + isRunning: () => false, + }); + scheduler.event(); + await advance(100); + scheduler.event(); + await advance(199); + expect(starts).toEqual([]); + await advance(1); + expect(starts).toEqual([300]); + for (let i = 0; i < 30; i++) { + scheduler.event(); + await advance(100); + } + expect(starts.length).toBeGreaterThanOrEqual(3); + expect(starts.slice(1).every((time, i) => time - starts[i] >= 1000)).toBe(true); + scheduler.dispose(); +}); + +test("never overlaps reads, coalesces in-flight requests and ignores stale session results", async () => { + const { clock, advance } = fakeClock(); + const releases: ((value: number) => void)[] = []; + const snapshots: number[] = []; + const scheduler = createRefreshScheduler({ + clock, + read: () => new Promise((resolve) => releases.push(resolve)), + onSnapshot: (value) => snapshots.push(value), + isRunning: () => false, + }); + scheduler.refresh(); + await advance(0); + scheduler.event(); + scheduler.refresh(); + scheduler.reset(); + await advance(5000); + expect(releases).toHaveLength(1); + releases[0](1); + await advance(1000); + expect(snapshots).toEqual([]); + expect(releases).toHaveLength(2); + releases[1](2); + await advance(0); + expect(snapshots).toEqual([2]); + scheduler.dispose(); +}); + +test("failed reads keep the last good snapshot and block all attempts for 30 seconds", async () => { + const { clock, advance } = fakeClock(); + const snapshots: number[] = []; + let attempts = 0; + const scheduler = createRefreshScheduler({ + clock, + read: async () => { + if (++attempts === 2) throw new Error("offline"); + return attempts; + }, + onSnapshot: (value) => snapshots.push(value), + isRunning: () => false, + }); + scheduler.refresh(); + await advance(1000); + scheduler.refresh(); + await advance(0); + expect(attempts).toBe(2); + scheduler.event(); + scheduler.refresh(); + await advance(29999); + expect(attempts).toBe(2); + expect(snapshots).toEqual([1]); + await advance(1); + expect(attempts).toBe(3); + expect(snapshots).toEqual([1, 3]); + scheduler.dispose(); +}); + +test("polls every two seconds only while a background worker runs and stops on idle or disposal", async () => { + const { clock, advance, timers } = fakeClock(); + let reads = 0; + const scheduler = createRefreshScheduler({ + clock, + read: async () => ++reads < 3, + onSnapshot: () => {}, + isRunning: (running) => running, + }); + scheduler.refresh(); + await advance(0); + expect(reads).toBe(1); + await advance(1999); + expect(reads).toBe(1); + await advance(1); + expect(reads).toBe(2); + await advance(2000); + expect(reads).toBe(3); + await advance(20000); + expect(reads).toBe(3); + expect(timers.size).toBe(0); + scheduler.dispose(); + scheduler.event(); + scheduler.refresh(); + await advance(30000); + expect(reads).toBe(3); +}); diff --git a/packages/plugin/src/tui/data/refresh-scheduler.ts b/packages/plugin/src/tui/data/refresh-scheduler.ts new file mode 100644 index 0000000000..d849d0a282 --- /dev/null +++ b/packages/plugin/src/tui/data/refresh-scheduler.ts @@ -0,0 +1,97 @@ +export interface RefreshClock { + now(): number; + setTimeout(callback: () => void, delay: number): unknown; + clearTimeout(handle: unknown): void; +} + +export function createRefreshScheduler(options: { + read(): Promise; + onSnapshot(snapshot: T): void; + isRunning(snapshot: T): boolean; + clock?: RefreshClock; +}) { + const clock: RefreshClock = options.clock ?? { + now: () => Date.now(), + setTimeout: (callback, delay) => setTimeout(callback, delay), + clearTimeout: (handle) => clearTimeout(handle as ReturnType), + }; + let timer: unknown; + let disposed = false; + let inFlight = false; + let generation = 0; + let lastStarted = -Infinity; + let blockedUntil = 0; + let requestedAt = Infinity; + let pollAt = Infinity; + let burstStarted: number | undefined; + + function arm() { + if (timer !== undefined) clock.clearTimeout(timer); + timer = undefined; + if (disposed || inFlight) return; + const due = Math.max(Math.min(requestedAt, pollAt), lastStarted + 1000, blockedUntil); + if (!Number.isFinite(due)) return; + timer = clock.setTimeout( + () => { + timer = undefined; + void read(); + }, + Math.max(0, due - clock.now()), + ); + } + + async function read() { + if (disposed || inFlight) return; + const currentGeneration = generation; + inFlight = true; + lastStarted = clock.now(); + requestedAt = Infinity; + pollAt = Infinity; + burstStarted = undefined; + try { + const snapshot = await options.read(); + if (disposed || currentGeneration !== generation) return; + blockedUntil = 0; + options.onSnapshot(snapshot); + pollAt = options.isRunning(snapshot) ? clock.now() + 2000 : Infinity; + } catch { + if (disposed || currentGeneration !== generation) return; + blockedUntil = clock.now() + 30000; + requestedAt = blockedUntil; + } finally { + inFlight = false; + arm(); + } + } + + return { + event() { + if (disposed) return; + const now = clock.now(); + burstStarted ??= now; + // A continuous stream must not postpone the trailing read without limit. + requestedAt = Math.min(now + 200, burstStarted + 1000); + arm(); + }, + refresh() { + if (disposed) return; + requestedAt = Math.min(requestedAt, clock.now()); + arm(); + }, + reset() { + if (disposed) return; + generation += 1; + pollAt = Infinity; + blockedUntil = 0; + burstStarted = undefined; + requestedAt = clock.now(); + arm(); + }, + dispose() { + disposed = true; + generation += 1; + if (timer !== undefined) clock.clearTimeout(timer); + timer = undefined; + }, + }; +} diff --git a/packages/plugin/src/tui/slots/sidebar-content.tsx b/packages/plugin/src/tui/slots/sidebar-content.tsx index d27bab85d8..bed90777a1 100644 --- a/packages/plugin/src/tui/slots/sidebar-content.tsx +++ b/packages/plugin/src/tui/slots/sidebar-content.tsx @@ -5,6 +5,7 @@ import packageJson from "../../../package.json" import { badgeTextColor } from '../badge-contrast'; import { loadSidebarSnapshot, type SidebarSnapshot } from "../data/context-db" import { directoryForSession } from "../data/session-directory" +import { createRefreshScheduler } from "../data/refresh-scheduler" import { formatThresholdPercent } from "../../shared/format-threshold" import { renderUserFacingFailure } from "../../shared/user-facing-codes" import { compactionOffSidebarRows, nativeCompactionContextLabel } from "../compaction-off" @@ -20,10 +21,7 @@ import { watchTuiPreferences, } from "../../shared/tui-preferences" -// Module-level hook so the upgrade/recomp dialog can kick the sidebar into its -// fast recomp self-poll the INSTANT the user confirms — without waiting for a -// parent-session message event (the RPC upgrade/recomp call fires none). The -// mounted SidebarContent registers its refresh here. +// Recomp commands do not send parent-session message events. let activeRecompPollKick: (() => void) | null = null let activeSidebarRefresh: (() => void) | null = null export function kickRecompProgressRefresh(): void { @@ -36,7 +34,6 @@ export function refreshSidebarSnapshot(): void { } const SINGLE_BORDER = { type: "single" } as any -const REFRESH_DEBOUNCE_MS = 150 export interface SidebarController { prefs: () => MagicContextTuiPrefs @@ -497,215 +494,43 @@ const SidebarContent = (props: { const collapsed = props.controller.collapsed const sections = () => props.controller.prefs().sections const headerLabel = () => props.controller.prefs().header.label - let refreshTimer: ReturnType | undefined - // Self-sustaining poll while a recomp/upgrade is running. Recomp work - // happens in CHILD sessions whose message events are filtered out of the - // subscription below, so without this the progress bar would freeze until - // the next parent-session message. Active only during recomp/migration; - // stops itself once the phase goes terminal/absent (dogfood 2026-05-30). - let recompPollTimer: ReturnType | undefined - const RECOMP_POLL_MS = 1200 - // Robust recomp poll state. The loop MUST survive a failed/slow snapshot - // fetch — the server is busy doing the historian LLM call during a recomp, - // so a poll can reject or return a stale (pre-recomp) cached snapshot. The - // OLD loop reattached the next timer only inside `.then()`, so any rejection - // killed it and the bar froze mid-pass (dogfood 2026-05-30). This version - // reschedules on BOTH success and failure, keyed on `recompActive`, and only - // stops on a terminal phase, a bounded "never started" probe window, or the - // entry vanishing after we'd seen it active. - let recompActive = false - let recompSawPhase = false - let recompPollCount = 0 - let recompConsecutiveAbsent = 0 - let recompSessionId: string | null = null - let snapshotRequestSequence = 0 - const RECOMP_PROBE_MAX = 12 // ~15s for the server's "Starting…" to land - // After we've SEEN an active phase, a momentarily absent snapshot is almost - // always transient — the server's sticky cache serves a pre-recomp snapshot - // (no recompProgress) during the token-quiet recomp window, or a concurrent - // BEGIN-IMMEDIATE publish makes the snapshot DB read throw → bare empty. The - // entry is held until terminal + a 30s grace, so we keep polling through many - // absents and only give up after a long run of them (entry truly gone but we - // somehow missed "done"). This was the freeze: the old logic stopped on the - // FIRST absent-after-active (dogfood 2026-05-30). - const RECOMP_ABSENT_GIVEUP = 40 // ~48s of continuous absence → stop - const RECOMP_MAX_POLLS = 1500 // ~30min absolute safety cap - - const refresh = () => { - const sid = props.sessionID() - if (!sid) return - const sequence = ++snapshotRequestSequence - const directory = directoryForSession( - props.api.state.session?.get?.(sid)?.directory, - props.api.state.path.directory ?? "", - ) - void loadSidebarSnapshot(sid, directory) - .then((data) => { - // Guard against a session switch while this load was in flight: - // painting session A's snapshot into the now-active session B shows - // the wrong session's numbers until B's own refresh resolves. - if (props.sessionID() !== sid || sequence !== snapshotRequestSequence) return - setSnapshot(data) - try { - props.api.renderer.requestRender() - } catch { - // Ignore render errors - } - // If a recomp/upgrade is running (detected via any refresh, e.g. - // a /ctx-recomp command not started from the dialog), make sure - // the dedicated poll loop is running. - const phase = data?.recompProgress?.phase - if ((phase === "recomp" || phase === "migration") && !recompActive) { - kickRecompPoll() - } else if (recompActive && recompSessionId === sid) { - scheduleRecompTick() - } - }) - .catch(() => { - // A one-shot refresh failure is non-fatal. If it superseded a fast - // poll request, keep that poll moving for the captured session. - if (recompActive && recompSessionId === sid && props.sessionID() === sid) { - scheduleRecompTick() - } - }) - } - - const scheduleRefresh = () => { - if (refreshTimer) clearTimeout(refreshTimer) - refreshTimer = setTimeout(() => { - refreshTimer = undefined - refresh() - }, REFRESH_DEBOUNCE_MS) - } - - const stopRecompPoll = () => { - recompActive = false - recompSessionId = null - snapshotRequestSequence += 1 - if (recompPollTimer) clearTimeout(recompPollTimer) - recompPollTimer = undefined - } - - const scheduleRecompTick = () => { - if (!recompActive) return - if (recompPollTimer) clearTimeout(recompPollTimer) - recompPollTimer = setTimeout(recompTick, RECOMP_POLL_MS) - } - - function recompTick(): void { - const sid = recompSessionId - if (!recompActive || !sid || props.sessionID() !== sid) { - stopRecompPoll() - return - } - recompPollCount += 1 - if (recompPollCount > RECOMP_MAX_POLLS) { - stopRecompPoll() - return - } - const sequence = ++snapshotRequestSequence - const directory = directoryForSession( - props.api.state.session?.get?.(sid)?.directory, - props.api.state.path.directory ?? "", - ) - void loadSidebarSnapshot(sid, directory) - .then((data) => { - if ( - !recompActive || - recompSessionId !== sid || - props.sessionID() !== sid || - sequence !== snapshotRequestSequence - ) return - const phase = data?.recompProgress?.phase - // While a recomp is known-active, a transient snapshot that lost - // recompProgress (sticky cache / busy-DB empty) must NOT wipe the - // visible bar — carry the last good progress forward so it stays - // stable until a real update or the terminal state lands. - const prevProgress = snapshot()?.recompProgress - const merged = - !phase && recompSawPhase && prevProgress - ? { ...data, recompProgress: prevProgress } - : data - setSnapshot(merged) - try { - props.api.renderer.requestRender() - } catch { - // ignore render errors - } - if (phase === "recomp" || phase === "migration") { - recompSawPhase = true - recompConsecutiveAbsent = 0 - scheduleRecompTick() - } else if (phase === "done" || phase === "failed" || phase === "skipped") { - // Terminal state rendered — stop. The server keeps "done"/ - // "skipped" for a grace window and "failed" until the next run, - // so the outcome stays visible without further polling. - stopRecompPoll() - } else { - // Phase absent this poll. - recompConsecutiveAbsent += 1 - if (!recompSawPhase) { - // Still waiting for the server's first "Starting…". - if (recompPollCount < RECOMP_PROBE_MAX) scheduleRecompTick() - else { - stopRecompPoll() - } - } else if (recompConsecutiveAbsent < RECOMP_ABSENT_GIVEUP) { - // Seen it active — absent is almost certainly the sticky - // cache / a transient snapshot read. Keep polling so we - // still catch the terminal state. DON'T overwrite the - // last good progress snapshot with this transient empty. - scheduleRecompTick() - } else { - // Long continuous absence — the entry is genuinely gone. - stopRecompPoll() - } - } - }) - .catch(() => { - // A failed fetch must not kill the loop, but a response from a - // superseded session or sequence must not restart it either. - if ( - recompActive && - recompSessionId === sid && - props.sessionID() === sid && - sequence === snapshotRequestSequence - ) scheduleRecompTick() - }) - } - - // Kick the resilient recomp poll loop on dialog confirm (or when a refresh - // first detects an active recomp). The server emits an immediate "Starting…" - // entry; the probe window covers the brief RPC race before it lands. - function kickRecompPoll(): void { - const sid = props.sessionID() - if (!sid) return - if (recompActive && recompSessionId === sid) return - stopRecompPoll() - recompActive = true - recompSessionId = sid - recompSawPhase = false - recompPollCount = 0 - recompConsecutiveAbsent = 0 - recompTick() - } - - activeRecompPollKick = kickRecompPoll + const scheduler = createRefreshScheduler({ + read: async () => { + const sid = props.sessionID() + if (!sid) return null + const directory = directoryForSession( + props.api.state.session?.get?.(sid)?.directory, + props.api.state.path.directory ?? "", + ) + return loadSidebarSnapshot(sid, directory, { throwOnFailure: true }) + }, + onSnapshot: (data) => { + setSnapshot(data) + try { + props.api.renderer.requestRender() + } catch { + // The renderer can close before the request completes. + } + }, + isRunning: (data) => Boolean(data?.historianRunning || data?.dreamerProgress || + data?.recompProgress?.phase === "recomp" || data?.recompProgress?.phase === "migration"), + }) + const refresh = () => scheduler.refresh() + const scheduleRefresh = () => scheduler.event() + activeRecompPollKick = refresh activeSidebarRefresh = refresh onCleanup(() => { - if (refreshTimer) clearTimeout(refreshTimer) - stopRecompPoll() - if (activeRecompPollKick === kickRecompPoll) activeRecompPollKick = null + scheduler.dispose() + if (activeRecompPollKick === refresh) activeRecompPollKick = null if (activeSidebarRefresh === refresh) activeSidebarRefresh = null }) // Refresh on session change createEffect( on(props.sessionID, () => { - stopRecompPoll() setSnapshot(null) - refresh() + scheduler.reset() }), ) diff --git a/packages/plugin/src/v2/hooks/context.ts b/packages/plugin/src/v2/hooks/context.ts index 4771de4ec8..b32ad25f63 100644 --- a/packages/plugin/src/v2/hooks/context.ts +++ b/packages/plugin/src/v2/hooks/context.ts @@ -102,6 +102,7 @@ import { } from "../../hooks/magic-context/read-session-formatting"; import { createM0RenderOptions } from "../../hooks/magic-context/render-options"; import { servedModuleM0Text } from "../../hooks/magic-context/rust-served-m0"; +import { clearSidebarObservations } from "../../hooks/magic-context/sidebar-observations"; import { STORAGE_BUSY_MESSAGE, StorageBusyRefusalError, @@ -1815,6 +1816,7 @@ export async function registerContext(context: V2Context) { continue; } if (event.type === "session.deleted") { + clearSidebarObservations(sessionID); overflowRecovery.forget(sessionID); hostFoldIdle?.active(sessionID); clearLkgMeasuredRequest(sessionID); diff --git a/packages/plugin/src/v2/hooks/event-loop.test.ts b/packages/plugin/src/v2/hooks/event-loop.test.ts index 2e14c914bf..3cbb6ba26c 100644 --- a/packages/plugin/src/v2/hooks/event-loop.test.ts +++ b/packages/plugin/src/v2/hooks/event-loop.test.ts @@ -22,6 +22,10 @@ import { import { autoSearchTestSnapshot } from "../../hooks/magic-context/auto-search-snapshot.fixture"; import { queueAutoSearchBackfill } from "../../hooks/magic-context/auto-search-worker-client"; import { resetCtxReduceRegisteredGloballyForTest } from "../../hooks/magic-context/ctx-reduce-availability"; +import { + rememberWaitingMessages, + sidebarObservations, +} from "../../hooks/magic-context/sidebar-observations"; import * as transformModule from "../../hooks/magic-context/transform"; import { _resetDreamTimerForTests } from "../../plugin/dream-timer"; import * as logger from "../../shared/logger"; @@ -271,6 +275,7 @@ test("v2 session.deleted removes summaries, raw history, tag revisions and every consumed = true; }); const id = deleted.data.sessionID; + rememberWaitingMessages(id, 12); const epochs = () => f.db .prepare( @@ -285,6 +290,7 @@ test("v2 session.deleted removes summaries, raw history, tag revisions and every ready.resolve(); await until(() => consumed); expect(epochs()).toEqual([]); + expect(sidebarObservations(id).waitingMessages).toBeUndefined(); for (const [table, rows] of Object.entries(snapshotSessionRows(f.db, id))) { expect(rows, `${table} retained deleted session data`).toEqual([]); } diff --git a/packages/plugin/src/v2/tui/index.ts b/packages/plugin/src/v2/tui/index.ts index c5853f3297..da5511b1b1 100644 --- a/packages/plugin/src/v2/tui/index.ts +++ b/packages/plugin/src/v2/tui/index.ts @@ -20,16 +20,13 @@ import { startNotificationSocket, stopNotificationSocket, } from "../../tui/data/notification-socket"; +import { createRefreshScheduler, type RefreshClock } from "../../tui/data/refresh-scheduler"; import { directoryForSession } from "../../tui/data/session-directory"; import { eventSessionID } from "./events"; import { mountV1Sidebar, sessionDirectory, type V1SidebarMount } from "./sidebar-mount"; import { mountV1StatusDialog, type V1StatusDialogMount } from "./status-dialog-mount"; import type { V2SidebarState, V2TuiContext } from "./types"; -const SIDEBAR_REFRESH_MS = 1_000; -const inflight = new Set(); -const refreshedAt = new Map(); - function compactTokens(value: number): string { if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; if (value >= 1_000) return `${Math.round(value / 1_000)}K`; @@ -167,7 +164,11 @@ function currentSessionID(context: V2TuiContext): string | null { type JsxFactory = (type: string, props: Record) => unknown; -export async function setupWithJsx(context: V2TuiContext, jsx: JsxFactory): Promise<() => void> { +export async function setupWithJsx( + context: V2TuiContext, + jsx: JsxFactory, + clock?: RefreshClock, +): Promise<() => void> { const directory = context.location?.directory ?? context.data.location.default().directory; // The server command registry is the single /ctx-* registration for OpenCode 2. // This TUI handles its notifications; a keymap slash layer would duplicate the @@ -183,21 +184,35 @@ export async function setupWithJsx(context: V2TuiContext, jsx: JsxFactory): Prom const directoryOf = (sessionID: string): string => directoryForSession(sessionDirectory(context, sessionID), directory); - const refresh = async (sessionID: string, force = false): Promise => { - if (!sessionID || inflight.has(sessionID)) return; - const now = Date.now(); - if (!force && now - (refreshedAt.get(sessionID) ?? 0) < SIDEBAR_REFRESH_MS) return; - refreshedAt.set(sessionID, now); - inflight.add(sessionID); - try { - const snapshot = await loadSidebarSnapshot(sessionID, directoryOf(sessionID)); + let sidebarSessionID = currentSessionID(context); + const scheduler = createRefreshScheduler({ + clock, + read: async () => + sidebarSessionID + ? loadSidebarSnapshot(sidebarSessionID, directoryOf(sidebarSessionID), { + throwOnFailure: true, + }) + : null, + onSnapshot: (snapshot) => { + if (!snapshot) return; updateSidebar((draft) => { - draft.snapshots[sessionID] = snapshot; + draft.snapshots[snapshot.sessionId] = snapshot; }); context.renderer.requestRender(); - } finally { - inflight.delete(sessionID); - } + }, + isRunning: (snapshot) => + Boolean( + snapshot?.historianRunning || + snapshot?.dreamerProgress || + snapshot?.recompProgress?.phase === "recomp" || + snapshot?.recompProgress?.phase === "migration", + ), + }); + const refresh = (sessionID: string): void => { + if (sidebarSessionID !== sessionID) { + sidebarSessionID = sessionID; + scheduler.reset(); + } else scheduler.refresh(); }; const showStatus = async (target = currentSessionID(context)) => { @@ -258,7 +273,10 @@ export async function setupWithJsx(context: V2TuiContext, jsx: JsxFactory): Prom : "Recomp request failed", variant: requested ? "info" : "error", }); - if (requested) void refresh(target, true); + if (requested) { + if (mountedSidebar) mountedSidebar.refresh(); + else refresh(target); + } return requested; }; @@ -285,10 +303,14 @@ export async function setupWithJsx(context: V2TuiContext, jsx: JsxFactory): Prom // the rest of this TUI session and say so once. mountedSidebar = null; mounted.dispose(); + scheduler.refresh(); log("[magic-context] v2 sidebar component failed; using text", error); } } - void refresh(input.sessionID); + if (sidebarSessionID !== input.sessionID) { + sidebarSessionID = input.sessionID; + scheduler.reset(); + } return jsx("text", { children: sidebarText(sidebar.snapshots[input.sessionID]) }); }, }); @@ -299,8 +321,9 @@ export async function setupWithJsx(context: V2TuiContext, jsx: JsxFactory): Prom const stopListening = context.data.listen(({ details }) => { if (mountedSidebar) return; const sessionID = eventSessionID(details); - if (sessionID) void refresh(sessionID, true); + if (sessionID && sessionID === sidebarSessionID) scheduler.event(); }); + if (!mountedSidebar && sidebarSessionID) scheduler.refresh(); const handleNotification = async (notification: SocketNotification): Promise => { const target = notification.sessionId ?? currentSessionID(context); @@ -328,7 +351,7 @@ export async function setupWithJsx(context: V2TuiContext, jsx: JsxFactory): Prom if (notification.payload.action === "show-recomp-dialog") return showRecomp(target); if (notification.payload.action === "refresh-sidebar" && target) { if (mountedSidebar) mountedSidebar.refresh(); - else await refresh(target, true); + else refresh(target); log(`[magic-context] sidebar refresh requested for session ${target}`); return true; } @@ -361,8 +384,7 @@ export async function setupWithJsx(context: V2TuiContext, jsx: JsxFactory): Prom stopListening(); stopNotificationSocket(); closeRpc(); - inflight.clear(); - refreshedAt.clear(); + scheduler.dispose(); }; } diff --git a/packages/plugin/src/v2/tui/sidebar-fallback.test.ts b/packages/plugin/src/v2/tui/sidebar-fallback.test.ts index ed7e0f21d5..4f146c876d 100644 --- a/packages/plugin/src/v2/tui/sidebar-fallback.test.ts +++ b/packages/plugin/src/v2/tui/sidebar-fallback.test.ts @@ -10,7 +10,10 @@ * `opentui:runtime-module:*` virtual modules, so the compiled component's * imports do not resolve here. */ -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; +import type { SidebarSnapshot } from "../../shared/rpc-types"; +import * as contextDb from "../../tui/data/context-db"; +import type { RefreshClock } from "../../tui/data/refresh-scheduler"; import { setupWithJsx, sidebarText } from "./index"; import { loadCompiledSidebar } from "./load-compiled-sidebar.mjs"; import { isMissingOpenTuiRuntime, mountV1Sidebar } from "./sidebar-mount"; @@ -98,3 +101,94 @@ test("the sidebar.content claim renders the text projection when the component c cleanup(); } }); + +test("v2 fallback render does no reads; scheduled reads do not overlap and back off after failure", async () => { + const { context, claims } = fixture(); + let now = 0; + let nextId = 0; + const timers = new Map void }>(); + const clock: RefreshClock = { + now: () => now, + setTimeout: (run, delay) => { + const id = ++nextId; + timers.set(id, { at: now + delay, run }); + return id; + }, + clearTimeout: (id) => { + timers.delete(id as number); + }, + }; + const advance = async (ms: number) => { + const end = now + ms; + for (;;) { + await Promise.resolve(); + await Promise.resolve(); + const due = [...timers.entries()] + .filter(([, t]) => t.at <= end) + .sort((a, b) => a[1].at - b[1].at)[0]; + if (!due) break; + now = due[1].at; + timers.delete(due[0]); + due[1].run(); + } + now = end; + await Promise.resolve(); + await Promise.resolve(); + }; + const releases: { + resolve: (snapshot: SidebarSnapshot) => void; + reject: (error: Error) => void; + }[] = []; + const read = spyOn(contextDb, "loadSidebarSnapshot").mockImplementation( + () => new Promise((resolve, reject) => releases.push({ resolve, reject })), + ); + let listener: ((event: { details: unknown }) => void) | undefined; + const testContext: V2TuiContext = { + ...context, + data: { + ...context.data, + listen: (callback) => { + listener = callback; + return () => {}; + }, + }, + }; + const cleanup = await setupWithJsx(testContext, (type, props) => ({ type, props }), clock); + try { + const claim = claims.find((entry) => entry.append === "sidebar.content"); + if (claim?.append !== "sidebar.content") throw new Error("expected sidebar claim"); + const before = read.mock.calls.length; + claim.render({ sessionID: "ses-fallback" }); + claim.render({ sessionID: "ses-fallback" }); + expect(read.mock.calls.length).toBe(before); + await advance(0); + expect(read).toHaveBeenCalledTimes(1); + listener?.({ details: { type: "message.updated", data: { sessionID: "ses-fallback" } } }); + await advance(5000); + expect(read).toHaveBeenCalledTimes(1); + releases[0].resolve({ + sessionId: "ses-fallback", + historianRunning: false, + inputTokens: 7, + usagePercentage: 1, + } as SidebarSnapshot); + await advance(1000); + expect(read).toHaveBeenCalledTimes(2); + const good = claim.render({ sessionID: "ses-fallback" }); + releases[1].reject(new Error("offline")); + await advance(0); + listener?.({ details: { type: "message.updated", data: { sessionID: "ses-fallback" } } }); + await advance(29999); + expect(read).toHaveBeenCalledTimes(2); + expect(claim.render({ sessionID: "ses-fallback" })).toEqual(good); + await advance(1); + expect(read).toHaveBeenCalledTimes(3); + releases[2].resolve({ + sessionId: "ses-fallback", + historianRunning: false, + } as SidebarSnapshot); + } finally { + cleanup(); + read.mockRestore(); + } +}); From e161ef245a5a701f25aae2aabd7e16118b369120 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:39:18 +0000 Subject: [PATCH 2/8] fix(sidebar): attribute historian primary rejections --- .../compartment-runner-historian.test.ts | 121 +++++++++++++++++- .../compartment-runner-historian.ts | 15 ++- 2 files changed, 131 insertions(+), 5 deletions(-) diff --git a/packages/plugin/src/hooks/magic-context/compartment-runner-historian.test.ts b/packages/plugin/src/hooks/magic-context/compartment-runner-historian.test.ts index 6d0b8aff01..d5b41dcacb 100644 --- a/packages/plugin/src/hooks/magic-context/compartment-runner-historian.test.ts +++ b/packages/plugin/src/hooks/magic-context/compartment-runner-historian.test.ts @@ -55,7 +55,7 @@ test("N1 primary rejected twice then fallback publishes; only a clean primary re priorCompartments: [], sequenceOffset: 0, dumpLabelBase: "sidebar", - model: "configured/primary", + model: "actual/primary", fallbackModels: ["configured/fallback"], }); for (const runs of [1, 2]) { @@ -165,6 +165,125 @@ test("N1 recognizes a configured fallback selected inside the host transport", a }); }); +for (const scenario of [ + { + name: "N1 counts an unavailable primary once when the host fallback output is valid", + outputs: [{ model: "fallback", valid: true }], + outcome: "fallback", + primaryAttempts: 1, + primaryRejections: 1, + }, + { + name: "N1 keeps a rejected primary attempt after a valid primary repair", + outputs: [ + { model: "primary", valid: false }, + { model: "primary", valid: true }, + ], + outcome: "ok", + primaryAttempts: 2, + primaryRejections: 1, + }, + { + name: "N1 excludes explicit fallback attempts after two rejected primary outputs", + outputs: [ + { model: "primary", valid: false }, + { model: "primary", valid: false }, + { model: "fallback", valid: true }, + ], + outcome: "fallback", + primaryAttempts: 2, + primaryRejections: 2, + }, + { + name: "N1 does not charge invalid host fallback output twice against the primary", + outputs: [ + { model: "fallback", valid: false }, + { model: "primary", valid: true }, + ], + outcome: "ok", + primaryAttempts: 2, + primaryRejections: 1, + }, + { + name: "N1 counts a repair-time host fallback as a second primary rejection", + outputs: [ + { model: "primary", valid: false }, + { model: "fallback", valid: true }, + ], + outcome: "fallback", + primaryAttempts: 2, + primaryRejections: 2, + }, + { + name: "N1 attributes unknown producer metadata to the requested primary", + outputs: [{ model: null, valid: true }], + outcome: "ok", + primaryAttempts: 1, + primaryRejections: 0, + }, +] as const) { + test(scenario.name, async () => { + const directory = createTestTempDirFromPath(join(tmpdir(), "mc-sidebar-attribution-")); + tempDirs.push(directory); + process.env.XDG_DATA_HOME = directory; + const db = openDatabase(); + const sessionId = scenario.name; + let opened = 0; + const executor: HiddenCompletionExecutor = { + capabilities: { tools: false, harness: "opencode2" }, + open: async () => ({ id: `attribution-${++opened}` }), + attempt: async () => {}, + close: async () => {}, + collect: async () => { + const output = scenario.outputs[opened - 1]; + if (!output) throw new Error("Unexpected historian attempt"); + return { + text: output.valid + ? 'Keep this.' + : "invalid compartment output", + reasoning: null, + lengthCapped: false, + ...(output.model ? { providerId: "actual", modelId: output.model } : {}), + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }; + }, + }; + try { + const result = await runValidatedHistorianPass({ + client: undefined, + hiddenCompletionExecutor: executor, + db, + parentSessionId: sessionId, + sessionDirectory: directory, + prompt: "Messages 1-1:\n1: U: Keep this.", + chunk: { startIndex: 1, endIndex: 1, lines: [{ ordinal: 1, messageId: "m1" }] }, + priorCompartments: [], + sequenceOffset: 0, + dumpLabelBase: "attribution", + model: "actual/primary", + fallbackModels: ["actual/fallback"], + }); + expect(result.ok).toBe(true); + expect(opened).toBe(scenario.outputs.length); + result.finishSidebarRun?.(true); + const clean = scenario.primaryRejections === 0 && scenario.outcome === "ok"; + expect(buildSidebarSnapshot(db, sessionId, process.cwd()).historianRun).toMatchObject({ + outcome: scenario.outcome, + model: scenario.outcome === "fallback" ? "actual/fallback" : "actual/primary", + primaryAttempts: scenario.primaryAttempts, + primaryRejections: scenario.primaryRejections, + sinceLastClean: { + primaryAttempts: clean ? 0 : scenario.primaryAttempts, + primaryRejections: scenario.primaryRejections, + fallbackRuns: scenario.outcome === "fallback" ? 1 : 0, + }, + }); + } finally { + clearSidebarObservations(sessionId); + } + }); +} + test("historian ledger distinguishes empty, reasoning-only, length-capped and valid output", async () => { const directory = createTestTempDirFromPath(join(tmpdir(), "mc-historian-ledger-")); tempDirs.push(directory); diff --git a/packages/plugin/src/hooks/magic-context/compartment-runner-historian.ts b/packages/plugin/src/hooks/magic-context/compartment-runner-historian.ts index 3ce5473ff6..3b62ce9f04 100644 --- a/packages/plugin/src/hooks/magic-context/compartment-runner-historian.ts +++ b/packages/plugin/src/hooks/magic-context/compartment-runner-historian.ts @@ -439,6 +439,7 @@ async function runValidatedHistorianPassImpl( }, attempts: { primaryAttempts: number; primaryRejections: number }, ): Promise { + const primaryModel = toModelEntry(args.model)?.model ?? null; attempts.primaryAttempts += 1; const firstRun = await runHistorianPrompt({ ...args, @@ -446,8 +447,11 @@ async function runValidatedHistorianPassImpl( modelOverride: args.model, agentId: args.agentId, }); + const firstRunSwitchedModel = + primaryModel !== null && (firstRun.producingModel ?? primaryModel) !== primaryModel; + if (firstRunSwitchedModel) attempts.primaryRejections += 1; if (!firstRun.ok || !firstRun.result) { - attempts.primaryRejections += 1; + if (!firstRunSwitchedModel) attempts.primaryRejections += 1; if (firstRun.refusal?.terminal) return { ok: false, @@ -489,7 +493,7 @@ async function runValidatedHistorianPassImpl( return finalResult; } - attempts.primaryRejections += 1; + if (!firstRunSwitchedModel) attempts.primaryRejections += 1; await args.callbacks?.onRepairRetry?.(firstValidation.error ?? "invalid compartment output"); const repairPrompt = buildHistorianRepairPrompt( args.prompt, @@ -505,8 +509,11 @@ async function runValidatedHistorianPassImpl( modelOverride: args.model, agentId: args.agentId, }); + const repairRunSwitchedModel = + primaryModel !== null && (repairRun.producingModel ?? primaryModel) !== primaryModel; + if (repairRunSwitchedModel) attempts.primaryRejections += 1; if (!repairRun.ok || !repairRun.result) { - attempts.primaryRejections += 1; + if (!repairRunSwitchedModel) attempts.primaryRejections += 1; if (repairRun.refusal?.terminal) return { ok: false, @@ -550,7 +557,7 @@ async function runValidatedHistorianPassImpl( return finalResult; } - attempts.primaryRejections += 1; + if (!repairRunSwitchedModel) attempts.primaryRejections += 1; return runFallbackHistorianPass({ ...args, prompt: repairPrompt, From 2fcae032e48a1ca483ea2dd9251e65ba2b7105b1 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:39:18 +0000 Subject: [PATCH 3/8] build(plugin): add @cortexkit/common-auth for the shared sidebar frame --- bun.lock | 11 ++++++++--- packages/plugin/package.json | 1 + 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index fb07f277cd..3b8df9ebbd 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": {}, "packages/cli": { "name": "@cortexkit/magic-context", - "version": "0.49.20", + "version": "0.49.35", "bin": { "magic-context": "dist/index.js", }, @@ -77,7 +77,7 @@ }, "packages/pi-plugin": { "name": "@cortexkit/pi-magic-context", - "version": "0.49.20", + "version": "0.49.35", "dependencies": { "@cortexkit/subc-client": "0.11.1", "@jitl/quickjs-singlefile-cjs-release-asyncify": "0.32.0", @@ -137,8 +137,9 @@ }, "packages/plugin": { "name": "@cortexkit/opencode-magic-context", - "version": "0.49.20", + "version": "0.49.35", "dependencies": { + "@cortexkit/common-auth": "^0.38.0", "@cortexkit/subc-client": "0.11.1", "@jitl/quickjs-singlefile-cjs-release-asyncify": "0.32.0", "@opencode-ai/plugin": "^1.17.11", @@ -439,6 +440,8 @@ "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@0.5.0", "", { "dependencies": { "@cortexkit/subc-client": "^0.16.0" } }, "sha512-+mRG/h3YZufrEs1p12W+Y+NSnWIUiBcpppMTgFA5tAQ7t8WIWoMEmn0Mf7Cp8251SKChW8dN3xOZqEM7N6gyAg=="], + "@cortexkit/common-auth": ["@cortexkit/common-auth@0.38.0", "", { "dependencies": { "@cortexkit/private-files": "^0.2.2", "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.6.2", "@cortexkit/subc-client": "^0.16.0 || ^0.22.0", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.4.5", "@opentui/solid": ">=0.4.5", "solid-js": ">=1.9.12 <2" }, "optionalPeers": ["@cortexkit/claustrum-client", "@cortexkit/subc-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-YDXtwuuj/gz4h/bPO+UAVItjo3ZMYw9DhccEf4DWAk8dr+Rk/uo/cVUeTRbC4xXE7FXTeBr1HaRnyG7drZIg3w=="], + "@cortexkit/magic-context": ["@cortexkit/magic-context@workspace:packages/cli"], "@cortexkit/magic-context-dashboard": ["@cortexkit/magic-context-dashboard@workspace:packages/dashboard"], @@ -449,6 +452,8 @@ "@cortexkit/pi-magic-context": ["@cortexkit/pi-magic-context@workspace:packages/pi-plugin"], + "@cortexkit/private-files": ["@cortexkit/private-files@0.2.2", "", {}, "sha512-BdRxi4mzPkTUiZxKTlQV/RsESk5f34+sLk0qQLv1QlBQdkq2zw8yc0b3dm1qmZ5RfeChiBKEGgL6Qw31RCSaeA=="], + "@cortexkit/retina-local-fs": ["@cortexkit/retina-local-fs@workspace:packages/retina-local-fs"], "@cortexkit/subc-client": ["@cortexkit/subc-client@0.11.1", "", {}, "sha512-+T2GzdIHMJKtvS5uPG3FydyGbdvREla7N3B3inLgnVWxsO8v77rzMbmPX63ZD9OcJtnkDZjO5eFbfeIF+RUFWA=="], diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 1fc178704b..d39bd7afe6 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -57,6 +57,7 @@ "//check:tui-compiled": "Check drift on output already generated by build or build:tui; this check never regenerates files. CI and prepublishOnly build first.", "//test": "Unit shards run small behavioral fixtures. The nightly/check-branch slow-unit lane runs full scale and latency gates in fresh processes once per mode, not once per shard. JSC's optimizing WebAssembly tier stays disabled in tests; production host options are unchanged.", "dependencies": { + "@cortexkit/common-auth": "^0.38.0", "@cortexkit/subc-client": "0.11.1", "@jitl/quickjs-singlefile-cjs-release-asyncify": "0.32.0", "@opencode-ai/plugin": "^1.17.11", From f9e2bb14e05d7c6dbb03ef487703804f7ee11334 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:39:18 +0000 Subject: [PATCH 4/8] feat(sidebar): adopt shared option B expanded layout --- packages/plugin/scripts/build-tui.ts | 74 +- .../plugin/scripts/render-compiled-sidebar.ts | 156 ++ .../src/tui-compiled/quota-sidebar.d.ts | 6 + .../plugin/src/tui-compiled/quota-sidebar.js | 909 +++++++++++ .../tui-compiled/slots/sidebar-content.tsx | 1407 +++++------------ packages/plugin/src/tui/quota-sidebar.d.ts | 6 + packages/plugin/src/tui/quota-sidebar.js | 909 +++++++++++ .../plugin/src/tui/sidebar-render.test.ts | 297 ++++ .../plugin/src/tui/slots/sidebar-content.tsx | 1233 +++++---------- .../tui/tui-compiled-runtime-imports.test.ts | 2 +- 10 files changed, 3213 insertions(+), 1786 deletions(-) create mode 100644 packages/plugin/scripts/render-compiled-sidebar.ts create mode 100644 packages/plugin/src/tui-compiled/quota-sidebar.d.ts create mode 100644 packages/plugin/src/tui-compiled/quota-sidebar.js create mode 100644 packages/plugin/src/tui/quota-sidebar.d.ts create mode 100644 packages/plugin/src/tui/quota-sidebar.js create mode 100644 packages/plugin/src/tui/sidebar-render.test.ts diff --git a/packages/plugin/scripts/build-tui.ts b/packages/plugin/scripts/build-tui.ts index 4865ec72e5..f2fbd2d1fa 100644 --- a/packages/plugin/scripts/build-tui.ts +++ b/packages/plugin/scripts/build-tui.ts @@ -9,6 +9,7 @@ const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const sourceRoot = join(pluginRoot, "src/tui"); const outputRoot = join(pluginRoot, "src/tui-compiled"); const runtimeSpecifiers: Set = new Set(TUI_RUNTIME_SPECIFIERS); +const sidebarBundleName = "quota-sidebar.js"; type TransformSolidSource = ( code: string, @@ -23,7 +24,6 @@ type SolidTransformModule = { transformSolidSource?: TransformSolidSource; }; - /** * The Solid transform emits every runtime helper AND every control-flow builtin * (`For`, `Show`, `Index`, `Switch`, `Match`, `ErrorBoundary`, `Suspense`, ...) @@ -103,7 +103,9 @@ function redirectBuiltinImports( function asTransformSolidSource(mod: SolidTransformModule, from: string): TransformSolidSource { if (typeof mod.transformSolidSource !== "function") { - throw new Error(`@opentui/solid transform loaded from ${from} without transformSolidSource`); + throw new Error( + `@opentui/solid transform loaded from ${from} without transformSolidSource`, + ); } return mod.transformSolidSource; } @@ -120,7 +122,9 @@ async function resolveSolidTransformPath(): Promise { const packageJsonUrl = import.meta.resolve(packageJsonSpecifier); return join(dirname(fileURLToPath(packageJsonUrl)), "scripts/solid-transform.js"); } catch (error) { - errors.push(`import.meta.resolve: ${error instanceof Error ? error.message : String(error)}`); + errors.push( + `import.meta.resolve: ${error instanceof Error ? error.message : String(error)}`, + ); } try { @@ -196,6 +200,61 @@ const transformSolidSource = await loadTransformSolidSource(); const runtimeExportSets = await loadRuntimeExportSets(); const files = await listSourceFiles(sourceRoot); +// OpenTUI does not transform dependency TSX. The two entry paths must ship the +// frame without a second copy of the host's Solid or OpenTUI runtime. +async function bundleSidebar(hostRuntime: boolean): Promise { + const outputFile = join(hostRuntime ? outputRoot : sourceRoot, sidebarBundleName); + const result = await Bun.build({ + entrypoints: [fileURLToPath(import.meta.resolve("@cortexkit/common-auth/quota-sidebar"))], + target: "bun", + format: "esm", + external: [...runtimeSpecifiers], + plugins: [ + { + name: "quota-sidebar-solid", + setup(build) { + build.onResolve({ filter: /^opentui:runtime-module:/ }, (args) => ({ + path: args.path, + external: true, + })); + build.onLoad({ filter: /\.tsx$/ }, async (args) => { + const compiled = await transformSolidSource( + await readFile(args.path, "utf8"), + { + filename: args.path, + moduleName: runtimeModuleId("@opentui/solid"), + resolvePath: (specifier) => + runtimeSpecifiers.has(specifier) + ? runtimeModuleId(specifier) + : null, + }, + ); + const redirected = redirectBuiltinImports( + compiled, + runtimeExportSets, + args.path, + ); + return { + contents: hostRuntime + ? redirected + : redirected.replace( + /opentui:runtime-module:([^"']+)/g, + (_, id: string) => decodeURIComponent(id), + ), + loader: "ts", + }; + }); + }, + }, + ], + }); + if (!result.success) throw new AggregateError(result.logs, "Shared sidebar build failed"); + const output = result.outputs[0]; + if (result.outputs.length !== 1 || !output) + throw new Error("Expected one shared sidebar bundle"); + return writeIfChanged(outputFile, await output.text()); +} + // Regenerate in place rather than removing the output tree first. Consumers // read this directory live: a Parallels VM sharing the checkout caches // directory lookups, and an rm-then-recreate left it holding a stale negative @@ -204,6 +263,10 @@ const files = await listSourceFiles(sourceRoot); // inode; only outputs whose source is gone are removed. const expectedOutputs = new Set(); let written = 0; +const sidebarOutput = join(outputRoot, sidebarBundleName); +expectedOutputs.add(sidebarOutput); +if (await bundleSidebar(true)) written++; +const rawSidebarWritten = await bundleSidebar(false); for (const sourceFile of files) { const relativePath = relative(sourceRoot, sourceFile); @@ -217,7 +280,8 @@ for (const sourceFile of files) { // the sidebar freezes on its first paint. The virtual ids are required so // the compiled package binds the host process's single OpenTUI/Solid // runtime instead of loading a second copy from the plugin package. - if (await compileTsx(transformSolidSource, sourceFile, outputFile, runtimeExportSets)) written++; + if (await compileTsx(transformSolidSource, sourceFile, outputFile, runtimeExportSets)) + written++; } else { if (await copyPlainTypeScript(sourceFile, outputFile)) written++; } @@ -231,5 +295,5 @@ for (const existing of await listSourceFiles(outputRoot)) { } console.log( - `build-tui: checked ${files.length} file(s), wrote ${written}, unchanged ${files.length - written} in ${relative(pluginRoot, outputRoot)}${stale ? ` (removed ${stale} stale)` : ""}`, + `build-tui: checked ${files.length + 1} file(s), wrote ${written}, unchanged ${files.length + 1 - written} in ${relative(pluginRoot, outputRoot)}; raw sidebar wrote ${Number(rawSidebarWritten)}${stale ? ` (removed ${stale} stale)` : ""}`, ); diff --git a/packages/plugin/scripts/render-compiled-sidebar.ts b/packages/plugin/scripts/render-compiled-sidebar.ts new file mode 100644 index 0000000000..52fec6887a --- /dev/null +++ b/packages/plugin/scripts/render-compiled-sidebar.ts @@ -0,0 +1,156 @@ +import { mock } from "bun:test"; +import type { TuiPluginApi } from "@opencode-ai/plugin/tui"; +import { RGBA, rgbToHex } from "@opentui/core"; +import { plugin } from "bun"; +import { createSignal } from "solid-js"; +import type { SidebarSnapshot } from "../src/shared/rpc-types"; +import { resolveMagicContextPrefs } from "../src/shared/tui-preferences"; +import { runtimeModuleId, TUI_RUNTIME_SPECIFIERS } from "../src/shared/tui-runtime-specifiers"; + +type RenderCase = { + snapshot: SidebarSnapshot | null; + width?: number; + preferences?: Record; + changeTheme?: boolean; + matchingBadgeColors?: boolean; +}; + +const loaded = new Map>(); +for (const specifier of TUI_RUNTIME_SPECIFIERS) loaded.set(specifier, await import(specifier)); +plugin({ + name: "sidebar-render-runtime", + setup(build) { + for (const specifier of TUI_RUNTIME_SPECIFIERS) { + build.module(runtimeModuleId(specifier), () => ({ + exports: loaded.get(specifier) ?? {}, + loader: "object", + })); + } + }, +}); + +const theme = { + primary: RGBA.fromHex("#ffcc00"), + secondary: RGBA.fromHex("#ffcc00"), + accent: RGBA.fromHex("#ffcc00"), + error: RGBA.fromHex("#ff0000"), + warning: RGBA.fromHex("#ff8800"), + success: RGBA.fromHex("#00ff00"), + info: RGBA.fromHex("#0088ff"), + text: RGBA.fromHex("#ffffff"), + textMuted: RGBA.fromHex("#888888"), + background: RGBA.fromHex("#000000"), + backgroundPanel: RGBA.fromHex("#000000"), + backgroundElement: RGBA.fromHex("#000000"), + backgroundMenu: RGBA.fromHex("#000000"), + border: RGBA.fromHex("#888888"), + borderActive: RGBA.fromHex("#ffffff"), + borderSubtle: RGBA.fromHex("#888888"), +}; + +let snapshot: SidebarSnapshot | null = null; +let preferences: Record = {}; +// Replace only transport and preference I/O. The shipped slot owns rendering. +mock.module(new URL("../src/tui-compiled/data/context-db.ts", import.meta.url).pathname, () => ({ + loadSidebarSnapshot: async () => snapshot, +})); +const prefsUrl = new URL("../src/shared/tui-preferences.ts", import.meta.url).pathname; +const actualPrefs = await import(prefsUrl); +mock.module(prefsUrl, () => ({ + ...actualPrefs, + readTuiPreferencesFileSync: () => preferences, + watchTuiPreferences: () => () => {}, +})); +if (process.argv.includes("--raw-frame")) { + const rawFrame = await import(new URL("../src/tui/quota-sidebar.js", import.meta.url).href); + mock.module( + new URL("../src/tui-compiled/quota-sidebar.js", import.meta.url).pathname, + () => rawFrame, + ); +} +const sidebarUrl = new URL("../src/tui-compiled/slots/sidebar-content.tsx", import.meta.url).href; +const { createSidebarContentSlot } = await import(sidebarUrl); +const { testRender } = await import("@opentui/solid"); +const inputs = JSON.parse(await Bun.stdin.text()) as RenderCase[]; +const results = []; +for (const entry of inputs) { + snapshot = entry.snapshot; + preferences = entry.preferences ?? {}; + const prefs = resolveMagicContextPrefs(preferences); + const published = Promise.withResolvers(); + const [currentTheme, setTheme] = createSignal( + entry.matchingBadgeColors ? { ...theme, background: theme.accent } : theme, + ); + const slot = createSidebarContentSlot({ + state: { + path: { directory: "fixture" }, + session: { get: () => ({ directory: "fixture" }) }, + }, + renderer: { + requestRender() { + published.resolve(); + }, + }, + event: { on: () => () => {} }, + } as unknown as TuiPluginApi); + try { + const setup = await testRender( + () => + slot.slots.sidebar_content( + { + theme: { + get current() { + return currentTheme(); + }, + }, + }, + { session_id: "fixture" }, + ), + { width: entry.width ?? 42, height: 60 }, + ); + await Promise.race([ + published.promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("snapshot was not published")), 5000), + ), + ]); + for (let pass = 0; pass < 4; pass++) await setup.renderOnce(); + if (entry.changeTheme) { + setTheme({ ...theme, success: RGBA.fromHex("#00aaff") }); + for (let pass = 0; pass < 3; pass++) await setup.renderOnce(); + } + const spans = setup.captureSpans(); + results.push({ + frame: setup.captureCharFrame(), + lines: spans.lines.map((line) => + line.spans.map((span) => ({ + text: span.text, + fg: rgbToHex(span.fg), + attributes: span.attributes, + })), + ), + collapsed: prefs.collapsed, + }); + setup.renderer.destroy(); + } catch (error) { + results.push({ error: error instanceof Error ? error.stack : String(error) }); + } finally { + slot.dispose(); + } +} +process.stdout.write( + process.argv.includes("--frames") + ? results + .map((result) => + result.frame !== undefined + ? result.frame + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .trimEnd() + : result.error, + ) + .join("\n\n") + : JSON.stringify(results), +); +process.exit(0); diff --git a/packages/plugin/src/tui-compiled/quota-sidebar.d.ts b/packages/plugin/src/tui-compiled/quota-sidebar.d.ts new file mode 100644 index 0000000000..9d3aa23d07 --- /dev/null +++ b/packages/plugin/src/tui-compiled/quota-sidebar.d.ts @@ -0,0 +1,6 @@ +export { + SidebarFrame, + SidebarSection, + SidebarStatRow, + SidebarTextRow, +} from "@cortexkit/common-auth/quota-sidebar"; diff --git a/packages/plugin/src/tui-compiled/quota-sidebar.js b/packages/plugin/src/tui-compiled/quota-sidebar.js new file mode 100644 index 0000000000..8c0b60916f --- /dev/null +++ b/packages/plugin/src/tui-compiled/quota-sidebar.js @@ -0,0 +1,909 @@ +// @bun +// ../../node_modules/.bun/@cortexkit+common-auth@0.38.0+79ed038a4f852143/node_modules/@cortexkit/common-auth/src/quota-sidebar/index.tsx +import { createTextNode as _$createTextNode } from "opentui:runtime-module:%40opentui%2Fsolid"; +import { createComponent as _$createComponent } from "opentui:runtime-module:%40opentui%2Fsolid"; +import { use as _$use } from "opentui:runtime-module:%40opentui%2Fsolid"; +import { memo as _$memo } from "opentui:runtime-module:%40opentui%2Fsolid"; +import { effect as _$effect } from "opentui:runtime-module:%40opentui%2Fsolid"; +import { insertNode as _$insertNode } from "opentui:runtime-module:%40opentui%2Fsolid"; +import { insert as _$insert } from "opentui:runtime-module:%40opentui%2Fsolid"; +import { setProp as _$setProp } from "opentui:runtime-module:%40opentui%2Fsolid"; +import { createElement as _$createElement } from "opentui:runtime-module:%40opentui%2Fsolid"; +import { createContext, createMemo, createSignal, For, Index, mergeProps, Show, useContext } from "opentui:runtime-module:solid-js"; + +// ../../node_modules/.bun/@cortexkit+common-auth@0.38.0+79ed038a4f852143/node_modules/@cortexkit/common-auth/src/quota/map.ts +var ALL_SCOPE = "all"; +var DEFAULT_REQUIRED_LABELS = Object.freeze([ + "primary" +]); + +// ../../node_modules/.bun/@cortexkit+common-auth@0.38.0+79ed038a4f852143/node_modules/@cortexkit/common-auth/src/quota/format.ts +var QUOTA_STALE_AFTER_MS = 15 * 60000; +var NO_QUOTA_READING = "no quota reading yet"; +var NO_LIMITS_REPORTED = "no limits reported"; +function shortSpan(ms) { + const minutes = Math.max(1, Math.floor(ms / 60000)); + if (minutes < 60) + return `${minutes}m`; + if (minutes < 1440) { + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + return rest === 0 ? `${hours}h` : `${hours}h${rest}m`; + } + const days = Math.floor(minutes / 1440); + const hours = Math.floor(minutes % 1440 / 60); + return hours === 0 ? `${days}d` : `${days}d${hours}h`; +} +function lengthName(minutes) { + if (minutes % 1440 === 0) + return `${minutes / 1440}d`; + if (minutes % 60 === 0) + return `${minutes / 60}h`; + return `${minutes}m`; +} +function quotaWindowName(limit) { + if (limit.scope !== ALL_SCOPE || limit.windowMinutes === undefined) + return limit.label; + return lengthName(limit.windowMinutes); +} +function clampPercent(remaining) { + return Math.min(100, Math.max(0, remaining)); +} +function wholePercent(remaining) { + return Math.round(clampPercent(remaining)); +} +function percentLeft(remaining) { + return `${wholePercent(remaining)}% left`; +} +function resetDetails(resetsAt, checkedAt, now) { + const at = resetsAt === undefined ? Number.NaN : Date.parse(resetsAt); + return { + ...Number.isFinite(at) && at > now ? { resetIn: shortSpan(at - now) } : {}, + resetSinceReading: Number.isFinite(at) && at > checkedAt && at <= now + }; +} +function displayOrder(left, right) { + const l = left.windowMinutes; + const r = right.windowMinutes; + if (l !== undefined && r !== undefined && l !== r) + return l - r; + if (l === undefined !== (r === undefined)) + return l === undefined ? 1 : -1; + if (left.scope !== right.scope) { + if (left.scope === ALL_SCOPE) + return -1; + if (right.scope === ALL_SCOPE) + return 1; + return left.scope < right.scope ? -1 : 1; + } + return left.label < right.label ? -1 : left.label > right.label ? 1 : 0; +} +function quotaWindowParts(quota, options) { + const { windows, age } = scopedWindowParts(quota, options, true); + const parts = windows.map(({ part }) => part); + return age === undefined ? { windows: parts } : { windows: parts, age }; +} +function withShortNames(windows) { + const abbreviations = scopeAbbreviations(windows.filter(({ part, scope }) => part.kind === "window" && scope !== ALL_SCOPE).map(({ scope }) => scope)); + for (const window of windows) { + window.part.short = window.part.kind === "budget" ? "cr" : window.scope === ALL_SCOPE ? window.part.name : abbreviations.get(window.scope) ?? window.scope; + } + return windows; +} +function hasWindowReading(limit) { + return Number.isFinite(limit.remainingPercent) && Number.isFinite(limit.checkedAt); +} +function scopedWindowParts(quota, options, includeUnknown = false) { + if (!quota) + return { windows: [] }; + const { now } = options; + const readings = quota.limits.filter((limit) => limit.kind === "reading" && (includeUnknown || hasWindowReading(limit))).sort(displayOrder); + const windows = readings.map((limit) => { + const part = hasWindowReading(limit) ? { + kind: "window", + reading: "known", + name: quotaWindowName(limit), + short: "", + figure: percentLeft(limit.remainingPercent), + remainingPercent: clampPercent(limit.remainingPercent), + checkedAt: limit.checkedAt, + ...resetDetails(limit.resetsAt, limit.checkedAt, now) + } : { + kind: "window", + reading: "unknown", + name: quotaWindowName(limit), + short: "", + figure: "no reading", + resetSinceReading: false + }; + return { + scope: limit.scope, + ...limit.windowMinutes === undefined ? {} : { windowMinutes: limit.windowMinutes }, + part + }; + }); + const budget = quota.budget; + if (budget) { + const remainingPercent = budget.remainingPercent; + const figure = budget.reached ? "credits spent" : remainingPercent !== undefined ? `credits ${percentLeft(remainingPercent)}` : "credits available"; + windows.push({ + scope: ALL_SCOPE, + spent: budget.reached, + part: { + kind: "budget", + reading: "known", + name: "credits", + short: "", + figure, + ...remainingPercent === undefined ? {} : { remainingPercent: clampPercent(remainingPercent) }, + checkedAt: budget.checkedAt, + ...resetDetails(budget.resetsAt, budget.checkedAt, now) + } + }); + } + withShortNames(windows); + const readingTimes = windows.flatMap(({ part }) => part.reading === "known" && part.checkedAt !== undefined ? [part.checkedAt] : []); + if (readingTimes.length > 0) { + const age = now - Math.min(...readingTimes); + if (age > (options.staleAfterMs ?? QUOTA_STALE_AFTER_MS)) { + return { windows, age: `checked ${shortSpan(age)} ago` }; + } + } + return { windows }; +} +function scopeDisplayName(scope) { + const model = scope.slice(scope.indexOf(":") + 1); + return model.charAt(0).toUpperCase() + model.slice(1); +} +function scopeAbbreviations(scopes) { + const names = [...new Set(scopes.map(scopeDisplayName))]; + const lengths = new Map(names.map((name) => [name, 2])); + const short = (name) => name.slice(0, lengths.get(name)); + for (;; ) { + const byShort = new Map; + for (const name of names) { + byShort.set(short(name), [...byShort.get(short(name)) ?? [], name]); + } + let extended = false; + for (const group of byShort.values()) { + if (group.length < 2) + continue; + for (const name of group) { + const length = lengths.get(name) ?? 2; + if (length < name.length) { + lengths.set(name, length + 1); + extended = true; + } + } + } + if (!extended) + break; + } + return new Map(scopes.map((scope) => [scope, short(scopeDisplayName(scope))])); +} +var NARROW_GROUP_LIMIT = 2; +function groupShortNames(groups) { + const labels = groups.map((group) => group.label.trim() || group.scope); + const short = scopeAbbreviations(labels); + return labels.map((label) => short.get(label) ?? label); +} +function groupedNarrowPieces(groups, windows) { + if (!groups || groups.length === 0) + return; + const reported = windows.filter(({ part }) => part.kind === "window"); + if (reported.some(({ scope }) => scope === ALL_SCOPE)) + return; + const candidates = groups.flatMap((group, listed) => { + const inGroup = reported.filter(({ scope }) => scope === group.scope); + const groupTightest = inGroup.reduce((least, window) => least === undefined || (window.part.remainingPercent ?? 0) < (least.part.remainingPercent ?? 0) ? window : least, undefined); + return groupTightest ? [{ group, listed, window: groupTightest }] : []; + }); + if (candidates.length === 0) + return; + const names = groupShortNames(groups); + const left = (candidate) => candidate.window.part.remainingPercent ?? 0; + const shown = [...candidates].sort((a, b) => left(a) - left(b) || a.listed - b.listed).slice(0, NARROW_GROUP_LIMIT).sort((a, b) => a.listed - b.listed); + return shown.map((candidate, index) => { + const { windowMinutes } = candidate.window; + const piece = [ + names[candidate.listed] ?? candidate.group.scope, + ...windowMinutes === undefined ? [] : [lengthName(windowMinutes)], + `${wholePercent(left(candidate))}%` + ].join(" "); + return index === shown.length - 1 ? `${piece} left` : piece; + }); +} +function narrowTextParts(quota, options) { + const { windows } = scopedWindowParts(quota, options); + const grouped = groupedNarrowPieces(quota?.groups, windows); + if (grouped) + return grouped; + const general = windows.filter(({ part, scope }) => part.kind === "window" && scope === ALL_SCOPE); + const scoped = windows.filter(({ part, scope }) => part.kind === "window" && scope !== ALL_SCOPE); + const budget = windows.find(({ part }) => part.kind === "budget"); + const budgetLeft = budget?.spent ? 0 : budget?.part.remainingPercent; + const extras = [ + ...scoped.map(({ part }) => ({ + label: part.short, + remaining: part.remainingPercent ?? 0 + })), + ...budgetLeft === undefined || budget === undefined ? [] : [{ label: budget.part.short, remaining: budgetLeft }] + ]; + const tightest = extras.reduce((best, item) => best === undefined || item.remaining < best.remaining ? item : best, undefined); + const pieces = general.map(({ part }) => `${part.name} ${wholePercent(part.remainingPercent ?? 0)}%`); + if (tightest) { + pieces.push(`${tightest.label} ${wholePercent(tightest.remaining)}%`); + } + if (pieces.length === 0) + return windows.map(({ part }) => part.figure); + return pieces.map((piece, index) => index === pieces.length - 1 ? `${piece} left` : piece); +} +function quotaTextParts(quota, options) { + if (options.form === "narrow") + return narrowTextParts(quota, options); + const { windows, age } = scopedWindowParts(quota, options); + const full = (options.form ?? "full") === "full"; + const text = windows.map(({ part }) => { + const reset = full && part.resetIn ? `, resets ${part.resetIn}` : ""; + return `${part.kind === "budget" ? "" : `${part.name} `}${part.figure}${reset}`; + }); + return age === undefined ? text : [...text, age]; +} +function formatQuota(quota, options) { + const parts = quotaTextParts(quota, options); + if (parts.length > 0) + return parts.join(" \xB7 "); + return quota && quota.limits.length > 0 ? NO_LIMITS_REPORTED : NO_QUOTA_READING; +} + +// ../../node_modules/.bun/@cortexkit+common-auth@0.38.0+79ed038a4f852143/node_modules/@cortexkit/common-auth/src/quota-sidebar/index.tsx +var FrameContext = createContext(); +function frameContext() { + const context = useContext(FrameContext); + if (!context) + throw new Error("Sidebar primitives must be inside SidebarFrame"); + return context; +} +function displayWidth(text) { + const bun = globalThis.Bun; + return bun?.stringWidth ? bun.stringWidth(text) : Array.from(text).length; +} +function fitText(text, columns) { + if (displayWidth(text) <= columns) + return text; + if (columns < 1) + return ""; + let kept = ""; + let used = 0; + for (const char of text) { + const width = displayWidth(char); + if (used + width > columns - 1) + break; + kept += char; + used += width; + } + return `${kept}\u2026`; +} +function SidebarSection(props) { + const context = frameContext(); + return [(() => { + var _el$ = _$createElement("box"), _el$2 = _$createElement("text"), _el$3 = _$createElement("b"); + _$insertNode(_el$, _el$2); + _$setProp(_el$, "width", "100%"); + _$setProp(_el$, "marginTop", 1); + _$insertNode(_el$2, _el$3); + _$insert(_el$3, () => props.title); + _$effect((_$p) => _$setProp(_el$2, "fg", context.theme.text, _$p)); + return _el$; + })(), _$memo(() => props.children)]; +} +function SidebarStatRow(props) { + const context = frameContext(); + const [width, setWidth] = createSignal(0); + let row; + const value = () => width() <= 0 ? props.value : fitText(props.value, width() - displayWidth(props.label) - 1); + return (() => { + var _el$4 = _$createElement("box"), _el$5 = _$createElement("text"), _el$6 = _$createElement("text"), _el$7 = _$createElement("b"); + _$insertNode(_el$4, _el$5); + _$insertNode(_el$4, _el$6); + _$use((element) => { + row = element; + }, _el$4); + _$setProp(_el$4, "onSizeChange", () => setWidth(row?.width ?? 0)); + _$setProp(_el$4, "width", "100%"); + _$setProp(_el$4, "flexDirection", "row"); + _$setProp(_el$4, "justifyContent", "space-between"); + _$setProp(_el$4, "columnGap", 1); + _$setProp(_el$5, "wrapMode", "none"); + _$setProp(_el$5, "flexShrink", 0); + _$insert(_el$5, () => props.label); + _$insertNode(_el$6, _el$7); + _$setProp(_el$6, "wrapMode", "none"); + _$setProp(_el$6, "flexShrink", 1); + _$insert(_el$7, value); + _$effect((_p$) => { + var _v$ = context.theme.textMuted, _v$2 = context.theme[props.tone ?? "text"]; + _v$ !== _p$.e && (_p$.e = _$setProp(_el$5, "fg", _v$, _p$.e)); + _v$2 !== _p$.t && (_p$.t = _$setProp(_el$6, "fg", _v$2, _p$.t)); + return _p$; + }, { + e: undefined, + t: undefined + }); + return _el$4; + })(); +} +function SidebarTextRow(props) { + const context = frameContext(); + return (() => { + var _el$8 = _$createElement("text"); + _$insert(_el$8, () => props.text); + _$effect((_p$) => { + var _v$3 = context.theme[props.tone ?? "textMuted"], _v$4 = props.wrapMode; + _v$3 !== _p$.e && (_p$.e = _$setProp(_el$8, "fg", _v$3, _p$.e)); + _v$4 !== _p$.t && (_p$.t = _$setProp(_el$8, "wrapMode", _v$4, _p$.t)); + return _p$; + }, { + e: undefined, + t: undefined + }); + return _el$8; + })(); +} +function usageTone(used, appearance) { + if (used >= appearance.errorThreshold) + return "error"; + if (used >= appearance.warnThreshold) + return "warning"; + return "success"; +} +function barSegments(used, appearance, pace) { + const width = appearance.barWidth; + const cells = (pct) => Math.max(0, Math.min(Math.round(pct / 100 * width), width)); + const usedCells = cells(used); + const tone = usageTone(used, appearance); + const plain = [{ + text: appearance.barFilledChar.repeat(usedCells), + tone + }, { + text: "", + tone + }, { + text: appearance.barEmptyChar.repeat(width - usedCells), + tone + }]; + const overlay = pace?.overlay; + if (!overlay) + return plain; + const paceCells = cells(overlay.percent); + const lo = Math.min(usedCells, paceCells); + const hi = Math.max(usedCells, paceCells); + if (lo === hi) + return plain; + return [{ + text: appearance.barFilledChar.repeat(lo), + tone + }, { + text: (overlay.kind === "deficit" ? "\u2593" : "\u2592").repeat(hi - lo), + tone: overlay.kind === "deficit" ? "error" : "success" + }, { + text: appearance.barEmptyChar.repeat(width - hi), + tone + }]; +} +function QuotaRow(props) { + const context = frameContext(); + const [columns, setColumns] = createSignal(0); + let row; + const label = () => props.part.name + " ".repeat(Math.max(0, props.labelWidth - displayWidth(props.part.name))); + const figure = () => ` ${props.part.kind === "budget" && props.part.figure.startsWith(`${props.part.name} `) ? props.part.figure.slice(props.part.name.length + 1) : props.part.figure}`; + const hasReading = () => props.part.reading === "known"; + const used = () => 100 - (props.part.remainingPercent ?? 100); + const segments = createMemo(() => hasReading() ? barSegments(used(), { + ...context.appearance, + barWidth: props.barCells + }, props.pace) : [], undefined, { + equals: (previous, next) => previous.length === next.length && previous.every((segment, index) => segment.text === next[index]?.text && segment.tone === next[index]?.tone) + }); + const paceLine = () => { + const text = props.pace?.text; + if (!text || columns() <= 0) + return text; + return fitText(text, columns() - props.labelWidth); + }; + return [(() => { + var _el$9 = _$createElement("box"), _el$0 = _$createElement("box"); + _$insertNode(_el$9, _el$0); + _$use((element) => { + row = element; + }, _el$9); + _$setProp(_el$9, "onSizeChange", () => setColumns(row?.width ?? 0)); + _$setProp(_el$9, "width", "100%"); + _$setProp(_el$9, "flexDirection", "row"); + _$setProp(_el$9, "justifyContent", "space-between"); + _$setProp(_el$0, "flexDirection", "row"); + _$insert(_el$0, _$createComponent(Show, { + get when() { + return props.labelWidth > 0; + }, + get children() { + var _el$1 = _$createElement("text"); + _$setProp(_el$1, "wrapMode", "none"); + _$setProp(_el$1, "flexShrink", 0); + _$insert(_el$1, label); + _$effect((_$p) => _$setProp(_el$1, "fg", context.theme.textMuted, _$p)); + return _el$1; + } + }), null); + _$insert(_el$0, _$createComponent(Show, { + get when() { + return hasReading(); + }, + get fallback() { + return (() => { + var _el$13 = _$createElement("text"); + _$insertNode(_el$13, _$createTextNode(`no reading`)); + _$setProp(_el$13, "wrapMode", "none"); + _$effect((_$p) => _$setProp(_el$13, "fg", context.theme.textMuted, _$p)); + return _el$13; + })(); + }, + get children() { + return _$createComponent(Show, { + get when() { + return !props.part.resetSinceReading; + }, + get fallback() { + return (() => { + var _el$15 = _$createElement("text"); + _$insertNode(_el$15, _$createTextNode(`reset since last check`)); + _$setProp(_el$15, "wrapMode", "none"); + _$effect((_$p) => _$setProp(_el$15, "fg", context.theme.textMuted, _$p)); + return _el$15; + })(); + }, + get children() { + return [_$createComponent(Show, { + get when() { + return props.barCells > 0; + }, + get children() { + return _$createComponent(Index, { + get each() { + return segments(); + }, + children: (segment) => (() => { + var _el$17 = _$createElement("text"); + _$setProp(_el$17, "wrapMode", "none"); + _$insert(_el$17, () => segment().text); + _$effect((_p$) => { + var _v$5 = context.theme[segment().tone], _v$6 = segment().text ? undefined : 0; + _v$5 !== _p$.e && (_p$.e = _$setProp(_el$17, "fg", _v$5, _p$.e)); + _v$6 !== _p$.t && (_p$.t = _$setProp(_el$17, "width", _v$6, _p$.t)); + return _p$; + }, { + e: undefined, + t: undefined + }); + return _el$17; + })() + }); + } + }), (() => { + var _el$10 = _$createElement("text"); + _$setProp(_el$10, "wrapMode", "none"); + _$insert(_el$10, figure); + _$effect((_$p) => _$setProp(_el$10, "fg", context.theme[usageTone(used(), context.appearance)], _$p)); + return _el$10; + })()]; + } + }); + } + }), null); + _$insert(_el$9, _$createComponent(Show, { + get when() { + return _$memo(() => !!(hasReading() && props.showReset))() && !props.part.resetSinceReading; + }, + get children() { + var _el$11 = _$createElement("box"); + _$setProp(_el$11, "flexShrink", 0); + _$setProp(_el$11, "flexDirection", "row"); + _$setProp(_el$11, "justifyContent", "flex-end"); + _$insert(_el$11, _$createComponent(Show, { + get when() { + return props.part.resetIn; + }, + get children() { + var _el$12 = _$createElement("text"); + _$setProp(_el$12, "wrapMode", "none"); + _$insert(_el$12, () => props.part.resetIn); + _$effect((_$p) => _$setProp(_el$12, "fg", context.theme.textMuted, _$p)); + return _el$12; + } + })); + _$effect((_$p) => _$setProp(_el$11, "width", props.resetWidth, _$p)); + return _el$11; + } + }), null); + _$effect((_$p) => _$setProp(_el$0, "flexShrink", props.part.resetSinceReading ? 1 : 0, _$p)); + return _el$9; + })(), _$createComponent(Show, { + get when() { + return _$memo(() => !!(hasReading() && !props.part.resetSinceReading))() && paceLine(); + }, + keyed: true, + children: (line) => (() => { + var _el$18 = _$createElement("box"), _el$20 = _$createElement("text"); + _$insertNode(_el$18, _el$20); + _$setProp(_el$18, "width", "100%"); + _$setProp(_el$18, "flexDirection", "row"); + _$insert(_el$18, _$createComponent(Show, { + get when() { + return props.labelWidth > 0; + }, + get children() { + var _el$19 = _$createElement("text"); + _$insert(_el$19, () => "".padEnd(props.labelWidth)); + _$effect((_$p) => _$setProp(_el$19, "fg", context.theme.textMuted, _$p)); + return _el$19; + } + }), _el$20); + _$setProp(_el$20, "wrapMode", "none"); + _$insert(_el$20, line); + _$effect((_$p) => _$setProp(_el$20, "fg", context.theme[props.pace?.tone ?? "textMuted"], _$p)); + return _el$18; + })() + })]; +} +function CollapsedAccountRow(props) { + const context = frameContext(); + const [width, setWidth] = createSignal(0); + let row; + const hasQuota = () => quotaWindowParts(props.account.quota, { + now: context.now + }).windows.some((part) => part.reading === "known"); + const text = () => hasQuota() ? formatQuota(props.account.quota, { + now: context.now, + form: "narrow" + }) : props.account.emptyQuotaText?.collapsed ?? "\u2014"; + const showQuota = () => width() <= 0 || displayWidth(text()) + 4 <= width(); + const label = () => { + if (width() <= 0) + return props.account.label; + return fitText(props.account.label, width() - (showQuota() ? displayWidth(text()) + 3 : 2)); + }; + const muted = () => props.account.collapsed?.muted ?? false; + const quotaTone = () => muted() ? "textMuted" : props.account.collapsed?.quotaTone ?? "success"; + return (() => { + var _el$21 = _$createElement("box"), _el$23 = _$createElement("box"), _el$26 = _$createElement("text"); + _$insertNode(_el$21, _el$23); + _$use((element) => { + row = element; + }, _el$21); + _$setProp(_el$21, "onSizeChange", () => setWidth(row?.width ?? 0)); + _$setProp(_el$21, "width", "100%"); + _$setProp(_el$21, "flexDirection", "row"); + _$setProp(_el$21, "justifyContent", "space-between"); + _$insert(_el$21, _$createComponent(Show, { + get when() { + return label(); + }, + get children() { + var _el$22 = _$createElement("text"); + _$setProp(_el$22, "wrapMode", "none"); + _$setProp(_el$22, "flexShrink", 1); + _$insert(_el$22, label); + _$effect((_$p) => _$setProp(_el$22, "fg", context.theme[muted() ? "textMuted" : "text"], _$p)); + return _el$22; + } + }), _el$23); + _$insertNode(_el$23, _el$26); + _$setProp(_el$23, "flexDirection", "row"); + _$setProp(_el$23, "flexShrink", 0); + _$insert(_el$23, _$createComponent(Show, { + get when() { + return showQuota(); + }, + get children() { + return _$createComponent(Show, { + get when() { + return _$memo(() => !!hasQuota())() && !muted(); + }, + get fallback() { + return (() => { + var _el$27 = _$createElement("text"); + _$insert(_el$27, text); + _$effect((_$p) => _$setProp(_el$27, "fg", context.theme.textMuted, _$p)); + return _el$27; + })(); + }, + get children() { + var _el$24 = _$createElement("text"), _el$25 = _$createElement("b"); + _$insertNode(_el$24, _el$25); + _$insert(_el$25, text); + _$effect((_$p) => _$setProp(_el$24, "fg", context.theme[quotaTone()], _$p)); + return _el$24; + } + }); + } + }), _el$26); + _$insert(_el$26, () => showQuota() ? " " : "", null); + _$insert(_el$26, () => props.account.blocked ? "\u2298" : "\u25CF", null); + _$effect((_p$) => { + var _v$7 = label() ? 1 : 0, _v$8 = context.theme[props.account.indicatorTone]; + _v$7 !== _p$.e && (_p$.e = _$setProp(_el$21, "columnGap", _v$7, _p$.e)); + _v$8 !== _p$.t && (_p$.t = _$setProp(_el$26, "fg", _v$8, _p$.t)); + return _p$; + }, { + e: undefined, + t: undefined + }); + return _el$21; + })(); +} +function AccountBlock(props) { + const context = frameContext(); + const [columns, setColumns] = createSignal(0); + let block; + const rows = () => props.account.groups.flatMap((group) => group.rows); + const labelWidth = () => Math.max(0, ...rows().map(({ + part + }) => part.name ? Math.max(3, displayWidth(part.name) + 1) : 0)); + const resetWidth = () => Math.max(0, ...rows().map(({ + part + }) => part.reading === "unknown" || part.resetSinceReading ? 0 : displayWidth(part.resetIn ?? ""))); + const figureWidth = createMemo(() => Math.max(10, ...rows().filter(({ + part + }) => part.reading === "known" && !part.resetSinceReading).map(({ + part + }) => displayWidth(` ${part.kind === "budget" && part.figure.startsWith(`${part.name} `) ? part.figure.slice(part.name.length + 1) : part.figure}`)))); + const fit = (rowLabelWidth) => { + const desired = context.appearance.barWidth; + const minimum = Math.min(4, desired); + if (columns() <= 0) + return { + barCells: desired, + showReset: resetWidth() > 0 + }; + const room = columns() - rowLabelWidth - figureWidth(); + const withReset = room - resetWidth() - 1; + const showReset = resetWidth() > 0 && withReset >= minimum; + const available = showReset ? withReset : room; + return { + barCells: available >= minimum ? Math.min(desired, available) : 0, + showReset + }; + }; + const label = () => { + const full = `${props.account.label}${props.account.titleSuffix ? ` \xB7 ${props.account.titleSuffix}` : ""}`; + return columns() <= 0 ? full : fitText(full, columns() - displayWidth(props.account.status.text) - 1); + }; + const age = () => quotaWindowParts(props.account.quota, { + now: context.now + }).age; + const hasRows = () => props.account.groups.some((group) => group.rows.length > 0); + return (() => { + var _el$28 = _$createElement("box"), _el$29 = _$createElement("box"), _el$30 = _$createElement("text"), _el$31 = _$createElement("b"), _el$32 = _$createElement("text"), _el$33 = _$createElement("b"); + _$insertNode(_el$28, _el$29); + _$use((element) => { + block = element; + }, _el$28); + _$setProp(_el$28, "onSizeChange", () => setColumns(block?.width ?? 0)); + _$setProp(_el$28, "width", "100%"); + _$setProp(_el$28, "flexDirection", "column"); + _$insertNode(_el$29, _el$30); + _$insertNode(_el$29, _el$32); + _$setProp(_el$29, "width", "100%"); + _$setProp(_el$29, "flexDirection", "row"); + _$setProp(_el$29, "justifyContent", "space-between"); + _$setProp(_el$29, "columnGap", 1); + _$insertNode(_el$30, _el$31); + _$setProp(_el$30, "wrapMode", "none"); + _$setProp(_el$30, "flexShrink", 1); + _$insert(_el$31, label); + _$insertNode(_el$32, _el$33); + _$setProp(_el$32, "wrapMode", "none"); + _$setProp(_el$32, "flexShrink", 0); + _$insert(_el$33, () => props.account.status.text); + _$insert(_el$28, () => context.beforeQuota?.(props.account), null); + _$insert(_el$28, _$createComponent(Show, { + get when() { + return hasRows(); + }, + get fallback() { + return _$createComponent(SidebarTextRow, { + get text() { + return props.account.emptyQuotaText?.expanded ?? "\u2014"; + }, + wrapMode: "none" + }); + }, + get children() { + return _$createComponent(For, { + get each() { + return props.account.groups; + }, + children: (group) => [_$createComponent(Show, { + get when() { + return group.title; + }, + get children() { + return _$createComponent(SidebarTextRow, { + get text() { + return group.title ?? ""; + }, + tone: "text", + wrapMode: "none" + }); + } + }), _$createComponent(For, { + get each() { + return group.rows; + }, + children: (row) => { + const rowLabelWidth = () => row.part.name ? labelWidth() : 0; + const rowFit = createMemo(() => fit(rowLabelWidth())); + return _$createComponent(QuotaRow, { + get part() { + return row.part; + }, + get pace() { + return row.pace; + }, + get labelWidth() { + return rowLabelWidth(); + }, + get barCells() { + return rowFit().barCells; + }, + get resetWidth() { + return resetWidth(); + }, + get showReset() { + return rowFit().showReset; + } + }); + } + })] + }); + } + }), null); + _$insert(_el$28, _$createComponent(Show, { + get when() { + return age(); + }, + children: (text) => _$createComponent(SidebarTextRow, { + get text() { + return text(); + }, + wrapMode: "none" + }) + }), null); + _$insert(_el$28, () => context.afterQuota?.(props.account), null); + _$effect((_p$) => { + var _v$9 = props.marginTop, _v$0 = context.theme.text, _v$1 = context.theme[props.account.status.tone]; + _v$9 !== _p$.e && (_p$.e = _$setProp(_el$28, "marginTop", _v$9, _p$.e)); + _v$0 !== _p$.t && (_p$.t = _$setProp(_el$30, "fg", _v$0, _p$.t)); + _v$1 !== _p$.a && (_p$.a = _$setProp(_el$32, "fg", _v$1, _p$.a)); + return _p$; + }, { + e: undefined, + t: undefined, + a: undefined + }); + return _el$28; + })(); +} +function SidebarFrame(props) { + const hasData = () => props.accounts.length > 0; + const headerLabel = () => !hasData() ? props.header.badge : `${props.collapsed ? "\u25B6" : "\u25BC"} ${props.header.badge}`; + const context = mergeProps(props, { + get theme() { + const theme = props.theme; + return { + ...theme, + textMuted: theme?.textMuted ?? theme?.text, + accent: theme?.accent ?? theme?.text, + success: theme?.success ?? theme?.text, + warning: theme?.warning ?? theme?.text, + error: theme?.error ?? theme?.text + }; + } + }); + const invertBadge = () => props.theme?.accent !== undefined && props.theme?.background !== undefined; + return _$createComponent(FrameContext.Provider, { + value: context, + get children() { + var _el$34 = _$createElement("box"), _el$35 = _$createElement("box"), _el$36 = _$createElement("box"), _el$37 = _$createElement("text"), _el$38 = _$createElement("b"); + _$insertNode(_el$34, _el$35); + _$setProp(_el$34, "width", "100%"); + _$setProp(_el$34, "flexDirection", "column"); + _$insertNode(_el$35, _el$36); + _$setProp(_el$35, "width", "100%"); + _$setProp(_el$35, "flexDirection", "row"); + _$setProp(_el$35, "justifyContent", "space-between"); + _$setProp(_el$35, "alignItems", "center"); + _$setProp(_el$35, "onMouseDown", () => { + if (hasData()) + props.onToggle(); + }); + _$insertNode(_el$36, _el$37); + _$setProp(_el$36, "paddingLeft", 1); + _$setProp(_el$36, "paddingRight", 1); + _$insertNode(_el$37, _el$38); + _$insert(_el$38, headerLabel); + _$insert(_el$35, _$createComponent(Show, { + get when() { + return props.header.version; + }, + get children() { + var _el$39 = _$createElement("text"); + _$insert(_el$39, () => props.header.version); + _$effect((_$p) => _$setProp(_el$39, "fg", context.theme.textMuted, _$p)); + return _el$39; + } + }), null); + _$insert(_el$34, _$createComponent(Show, { + get when() { + return _$memo(() => !!props.collapsed)() && hasData(); + }, + get children() { + return [_$createComponent(For, { + get each() { + return props.accounts; + }, + children: (account) => _$createComponent(CollapsedAccountRow, { + account + }) + }), _$memo(() => props.collapsedSections?.())]; + } + }), null); + _$insert(_el$34, _$createComponent(Show, { + get when() { + return !props.collapsed || !hasData(); + }, + get children() { + return [_$createComponent(Show, { + get when() { + return _$memo(() => !!props.showQuota)() && hasData(); + }, + get children() { + return _$createComponent(SidebarSection, { + title: "Quota", + get children() { + return _$createComponent(For, { + get each() { + return props.accounts; + }, + children: (account, index) => _$createComponent(AccountBlock, { + account, + get marginTop() { + return index() === 0 ? 0 : 1; + } + }) + }); + } + }); + } + }), _$memo(() => props.sections?.())]; + } + }), null); + _$effect((_p$) => { + var _v$10 = invertBadge() ? props.theme?.accent : undefined, _v$11 = invertBadge() ? props.theme?.background : context.theme.accent; + _v$10 !== _p$.e && (_p$.e = _$setProp(_el$36, "backgroundColor", _v$10, _p$.e)); + _v$11 !== _p$.t && (_p$.t = _$setProp(_el$37, "fg", _v$11, _p$.t)); + return _p$; + }, { + e: undefined, + t: undefined + }); + return _el$34; + } + }); +} +export { + SidebarFrame, + SidebarSection, + SidebarStatRow, + SidebarTextRow +}; diff --git a/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx b/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx index 583b3e8240..64e73e3101 100644 --- a/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx +++ b/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx @@ -1,22 +1,24 @@ import { createComponent as _$createComponent } from "opentui:runtime-module:%40opentui%2Fsolid"; -import { createTextNode as _$createTextNode } from "opentui:runtime-module:%40opentui%2Fsolid"; import { effect as _$effect } from "opentui:runtime-module:%40opentui%2Fsolid"; -import { insertNode as _$insertNode } from "opentui:runtime-module:%40opentui%2Fsolid"; import { memo as _$memo } from "opentui:runtime-module:%40opentui%2Fsolid"; +import { createTextNode as _$createTextNode } from "opentui:runtime-module:%40opentui%2Fsolid"; +import { insertNode as _$insertNode } from "opentui:runtime-module:%40opentui%2Fsolid"; import { insert as _$insert } from "opentui:runtime-module:%40opentui%2Fsolid"; import { setProp as _$setProp } from "opentui:runtime-module:%40opentui%2Fsolid"; import { createElement as _$createElement } from "opentui:runtime-module:%40opentui%2Fsolid"; /** @jsxImportSource @opentui/solid */ -import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "opentui:runtime-module:solid-js"; + +import { createContext, createEffect, createMemo, createSignal, For, on, onCleanup, Show, useContext } from "opentui:runtime-module:solid-js"; import packageJson from "../../../package.json"; +import { formatThresholdPercent } from "../../shared/format-threshold"; +import { computeEffectiveOrder, DEFAULT_PREFS, DEFAULT_SLOT_ORDER, PLUGIN_KEY, queueTuiPreferenceUpdate, readTuiPreferencesFile, readTuiPreferencesFileSync, resolveMagicContextPrefs, watchTuiPreferences } from "../../shared/tui-preferences"; +import { renderUserFacingFailure } from "../../shared/user-facing-codes"; import { badgeTextColor } from "../badge-contrast"; +import { compactionOffSidebarRows } from "../compaction-off"; import { loadSidebarSnapshot } from "../data/context-db"; -import { directoryForSession } from "../data/session-directory"; import { createRefreshScheduler } from "../data/refresh-scheduler"; -import { formatThresholdPercent } from "../../shared/format-threshold"; -import { renderUserFacingFailure } from "../../shared/user-facing-codes"; -import { compactionOffSidebarRows, nativeCompactionContextLabel } from "../compaction-off"; -import { computeEffectiveOrder, DEFAULT_SLOT_ORDER, PLUGIN_KEY, queueTuiPreferenceUpdate, readTuiPreferencesFile, readTuiPreferencesFileSync, resolveMagicContextPrefs, watchTuiPreferences } from "../../shared/tui-preferences"; +import { directoryForSession } from "../data/session-directory"; +import { SidebarFrame, SidebarSection, SidebarStatRow, SidebarTextRow } from "../quota-sidebar.js"; // Recomp commands do not send parent-session message events. let activeRecompPollKick = null; @@ -29,26 +31,14 @@ export function kickRecompProgressRefresh() { export function refreshSidebarSnapshot() { activeSidebarRefresh?.(); } -const SINGLE_BORDER = { - type: "single" -}; -// The TUI may unmount and remount sidebar_content when the user switches views -// (main -> subagent -> main). A remount re-runs the component body, so a signal -// created inside the component would reset to its seed. The controller lives in -// the slot-factory closure (plugin/process lifetime) and owns the durable -// prefs/collapse signals plus the single shared file watcher, so collapse state -// and live pref reloads survive remounts. No Solid effects/memos here — those -// need an owner; the poll-interval effect stays inside the component. +// The controller outlives slot remounts, so preference changes survive a switch +// from the main session to a subagent view and back. function createSidebarController(initialPrefs) { const [prefs, setPrefs] = createSignal(initialPrefs); const seedCollapsed = initialPrefs.rememberCollapsed && initialPrefs.collapsed != null ? initialPrefs.collapsed : initialPrefs.startCollapsed; const [collapsed, setCollapsed] = createSignal(seedCollapsed); let lastPersistedCollapsed = initialPrefs.collapsed; let lastApplied = JSON.stringify(initialPrefs); - - // Collapse echo guard: lastPersistedCollapsed advances only once our own - // write lands, so a watcher echo of the value we just wrote is rejected by - // the `!==` check and cannot revert a user click. const stopWatchingPreferences = watchTuiPreferences(() => { void (async () => { const next = resolveMagicContextPrefs(await readTuiPreferencesFile()); @@ -56,6 +46,7 @@ function createSidebarController(initialPrefs) { if (serialized === lastApplied) return; lastApplied = serialized; setPrefs(next); + // Ignore the file watcher's echo after a local preference write. if (next.rememberCollapsed && next.collapsed != null && next.collapsed !== lastPersistedCollapsed) { lastPersistedCollapsed = next.collapsed; setCollapsed(next.collapsed); @@ -81,448 +72,93 @@ function createSidebarController(initialPrefs) { function compactTokens(value) { if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; if (value >= 1_000) return `${(value / 1_000).toFixed(0)}K`; - // Token counts are whole numbers to the reader even when the tokenizer - // calibration leaves them fractional; a raw `522.4` beside a `63K` reads as - // a measurement error rather than as precision. return String(Math.round(value)); } - -/** - * Sidebar form of the tail-hygiene reading: the reclaimable share and the - * two masses it is computed from, in the same compact token unit as the - * breakdown rows so the value fits beside its label at sidebar width. The - * long, unit-spelled form stays in the status dialog. - */ -function hygieneValue(status) { - return `${(status.severity * 100).toFixed(1)}% · ${compactTokens(status.u)}/${compactTokens(status.t)}`; -} function relativeTime(ms) { - const diff = Date.now() - ms; - if (diff < 60_000) return "just now"; - if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; - if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; - return `${Math.floor(diff / 86_400_000)}d ago`; + const minutes = Math.max(0, Math.floor((Date.now() - ms) / 60_000)); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h${minutes % 60 ? `${minutes % 60}m` : ""} ago`; + return `${hours}h ago`; } - -// Text progress bar, e.g. [██████░░░░] for the recomp/upgrade live indicator. -function progressBar(fraction, width = 14) { - const clamped = Math.max(0, Math.min(1, fraction)); - const filled = Math.round(clamped * width); - return `[${"█".repeat(filled)}${"░".repeat(width - filled)}]`; +function modelName(model) { + if (!model) return "—"; + const id = model.split("/").pop() ?? model; + return id.replace(/-(\d{8})$/, "").split("-").map(word => { + if (/^deepseek$/i.test(word)) return "DeepSeek"; + if (/^gpt$/i.test(word)) return "GPT"; + return word.charAt(0).toUpperCase() + word.slice(1); + }).join(" "); +} +function shortText(text) { + const line = text.replace(/\s+/g, " ").trim(); + return line.length > 32 ? `${line.slice(0, 31)}…` : line; +} +function present(value) { + return typeof value === "number" && Number.isFinite(value); } -// Token breakdown segment colors (hardcoded hex values) -const COLORS = { - // Cool / structured — injected by the plugin into message[0] - system: "#c084fc", - // Purple - docs: "#22d3ee", - // Cyan — - compartments: "#60a5fa", - // Blue - facts: "#fbbf24", - // Yellow/orange - memories: "#34d399", - // Green - profile: "#a3e635", - // Lime — - // Warm / user-facing — regular chat and tool traffic. Grouped visually - // by hue family so the user reads them as a related block. - conversation: "#f87171", - // Red - toolCalls: "#fb923c", - // Orange - toolDefs: "#f472b6" // Pink -}; -// Segmented token breakdown bar with legend -const TokenBreakdown = props => { - // The bar is rendered as a flex row of colored boxes, each with - // flexGrow=tokens and flexBasis=0. opentui distributes the parent - // container's full width proportionally, so the bar always fills the - // sidebar regardless of terminal size. No hardcoded width is needed — - // this fixes both the over-wide bar that wrapped onto a second line on - // narrow sidebars (issue #90) and the under-wide bar that left empty - // space on the right on wide sidebars. - const segments = createMemo(() => { - const s = props.snapshot; - const total = s.inputTokens || 1; - const result = []; - - // System Prompt (purple) - if (s.systemPromptTokens > 0) { - result.push({ - key: "sys", - tokens: s.systemPromptTokens, - color: COLORS.system, - label: "System" - }); - } - - // Docs (cyan) — injected block (ARCHITECTURE/STRUCTURE) - if (s.docsTokens > 0) { - result.push({ - key: "docs", - tokens: s.docsTokens, - color: COLORS.docs, - label: "Docs" - }); - } - - // Compartments (blue) - if (s.compartmentTokens > 0) { - result.push({ - key: "comp", - tokens: s.compartmentTokens, - color: COLORS.compartments, - label: "Compartments" - }); - } - - // Facts (yellow/orange) - if (s.factTokens > 0) { - result.push({ - key: "fact", - tokens: s.factTokens, - color: COLORS.facts, - label: "Facts" - }); - } - - // Memories (green) - if (s.memoryTokens > 0) { - result.push({ - key: "mem", - tokens: s.memoryTokens, - color: COLORS.memories, - label: "Memories" - }); - } - - // User Profile (lime) — injected block (promoted user memories) - if (s.profileTokens > 0) { - result.push({ - key: "profile", - tokens: s.profileTokens, - color: COLORS.profile, - label: "User Profile" - }); - } - - // Conversation = real user/assistant text/reasoning/images - // (excludes injected session-history and excludes tool call I/O). - // - // Always show this row even when conversationTokens === 0. The - // calibrator's residual-distribution math (tokenizer-calibration.ts) - // can round it down to zero when toolCallsLocal massively dominates - // conversationLocal — that's a calibration artifact, not a real - // "zero conversation". Suppressing the row leaves the legend looking - // truncated, which is more confusing than showing a 0% line. The - // segment is also skipped in the bar at 0 width because the segment - // builder uses `Math.max(1, ...)` only when tokens > 0 (see - // segmentWidths), so the visual bar stays correct either way. - result.push({ - key: "conv", - tokens: s.conversationTokens, - color: COLORS.conversation, - label: "Conversation" - }); - - // Tool Calls = tool_use/tool_result/tool/tool-invocation parts in messages - // (actionable — users can reduce via ctx_reduce) - if (s.toolCallTokens > 0) { - result.push({ - key: "tool-calls", - tokens: s.toolCallTokens, - color: COLORS.toolCalls, - label: "Tool Calls" - }); - } - - // Tool Definitions = measured description + JSON-schema parameters for - // each tool OpenCode sends in the `tools` request parameter, populated - // by the `tool.definition` plugin hook keyed by {provider, model, agent}. - // Zero until the first turn measures the active agent's tool set. - if (s.toolDefinitionTokens > 0) { - result.push({ - key: "tool-defs", - tokens: s.toolDefinitionTokens, - color: COLORS.toolDefs, - label: "Tool Defs" - }); - } - return result; - }); - const totalTokens = createMemo(() => props.snapshot.inputTokens || 1); - - // Render-time segments for the bar. Zero-token segments are filtered out - // entirely (no flex weight, no rendered box) so they don't claim any - // width. Non-zero segments still get a Math.max(1, ...) floor on - // flexGrow so very small contributions remain visible as a thin sliver. - // The legend rows below show every segment (including zeros) for table - // stability — only the bar prunes them. - const barSegments = createMemo(() => segments().filter(seg => seg.tokens > 0)); +// The frame does not export SidebarGaugeRow (common-auth#12). +const GaugeTheme = createContext(); +function SidebarGaugeRow(props) { + const theme = useContext(GaugeTheme); + const known = () => present(props.used); + const used = () => Math.max(0, Math.min(100, props.used ?? 0)); + const filled = () => Math.round(used() / 10); + const tone = () => !known() ? theme?.().textMuted : used() >= (props.errorAt ?? 80) ? theme?.().error : used() >= (props.warnAt ?? 50) ? theme?.().warning : theme?.().success; return (() => { var _el$ = _$createElement("box"), - _el$2 = _$createElement("box"); + _el$2 = _$createElement("box"), + _el$3 = _$createElement("text"), + _el$4 = _$createTextNode(` `), + _el$5 = _$createElement("text"), + _el$6 = _$createElement("text"), + _el$7 = _$createElement("b"); _$insertNode(_el$, _el$2); + _$insertNode(_el$, _el$6); _$setProp(_el$, "width", "100%"); - _$setProp(_el$, "flexDirection", "column"); - _$setProp(_el$2, "width", "100%"); + _$setProp(_el$, "flexDirection", "row"); + _$setProp(_el$, "justifyContent", "space-between"); + _$setProp(_el$, "columnGap", 1); + _$insertNode(_el$2, _el$3); + _$insertNode(_el$2, _el$5); _$setProp(_el$2, "flexDirection", "row"); - _$setProp(_el$2, "height", 1); - _$insert(_el$2, () => barSegments().map(seg => (() => { - var _el$3 = _$createElement("box"); - _$setProp(_el$3, "flexBasis", 0); - _$setProp(_el$3, "height", 1); - _$effect(_p$ => { - var _v$ = seg.key, - _v$2 = Math.max(1, seg.tokens), - _v$3 = seg.color; - _v$ !== _p$.e && (_p$.e = _$setProp(_el$3, "key", _v$, _p$.e)); - _v$2 !== _p$.t && (_p$.t = _$setProp(_el$3, "flexGrow", _v$2, _p$.t)); - _v$3 !== _p$.a && (_p$.a = _$setProp(_el$3, "backgroundColor", _v$3, _p$.a)); - return _p$; - }, { - e: undefined, - t: undefined, - a: undefined - }); - return _el$3; - })())); - _$insert(_el$, (() => { - var _c$ = _$memo(() => !!!props.collapsed); - return () => _c$() && (() => { - var _el$4 = _$createElement("box"); - _$setProp(_el$4, "flexDirection", "column"); - _$setProp(_el$4, "marginTop", 0); - _$insert(_el$4, () => segments().map(seg => { - const pct = (seg.tokens / totalTokens() * 100).toFixed(0); - return (() => { - var _el$5 = _$createElement("box"), - _el$6 = _$createElement("text"), - _el$7 = _$createElement("text"), - _el$8 = _$createTextNode(` (`), - _el$9 = _$createTextNode(`%)`); - _$insertNode(_el$5, _el$6); - _$insertNode(_el$5, _el$7); - _$setProp(_el$5, "width", "100%"); - _$setProp(_el$5, "flexDirection", "row"); - _$setProp(_el$5, "justifyContent", "space-between"); - _$insert(_el$6, () => seg.label); - _$insertNode(_el$7, _el$8); - _$insertNode(_el$7, _el$9); - _$insert(_el$7, () => compactTokens(seg.tokens), _el$8); - _$insert(_el$7, pct, _el$9); - _$effect(_p$ => { - var _v$4 = seg.key, - _v$5 = seg.color, - _v$6 = props.theme.textMuted; - _v$4 !== _p$.e && (_p$.e = _$setProp(_el$5, "key", _v$4, _p$.e)); - _v$5 !== _p$.t && (_p$.t = _$setProp(_el$6, "fg", _v$5, _p$.t)); - _v$6 !== _p$.a && (_p$.a = _$setProp(_el$7, "fg", _v$6, _p$.a)); - return _p$; - }, { - e: undefined, - t: undefined, - a: undefined - }); - return _el$5; - })(); - })); - return _el$4; - })(); - })(), null); - return _el$; - })(); -}; -const StatRow = props => { - const fg = createMemo(() => { - if (props.warning) return props.theme.warning; - if (props.accent) return props.theme.accent; - if (props.dim) return props.theme.textMuted; - return props.theme.text; - }); - return (() => { - var _el$0 = _$createElement("box"), - _el$1 = _$createElement("text"), - _el$10 = _$createElement("text"), - _el$11 = _$createElement("b"); - _$insertNode(_el$0, _el$1); - _$insertNode(_el$0, _el$10); - _$setProp(_el$0, "width", "100%"); - _$setProp(_el$0, "flexDirection", "row"); - _$setProp(_el$0, "justifyContent", "space-between"); - _$insert(_el$1, () => props.label); - _$insertNode(_el$10, _el$11); - _$insert(_el$11, () => props.value); + _$setProp(_el$2, "flexShrink", 0); + _$insertNode(_el$3, _el$4); + _$insert(_el$3, () => props.label, _el$4); + _$setProp(_el$5, "wrapMode", "none"); + _$insert(_el$5, (() => { + var _c$ = _$memo(() => !!known()); + return () => _c$() ? `${"█".repeat(filled())}${"░".repeat(10 - filled())} ${Math.round(100 - used())}% left` : "no reading"; + })()); + _$insertNode(_el$6, _el$7); + _$setProp(_el$6, "wrapMode", "none"); + _$setProp(_el$6, "flexShrink", 1); + _$insert(_el$7, () => props.value ?? "—"); _$effect(_p$ => { - var _v$7 = props.theme.textMuted, - _v$8 = fg(); - _v$7 !== _p$.e && (_p$.e = _$setProp(_el$1, "fg", _v$7, _p$.e)); - _v$8 !== _p$.t && (_p$.t = _$setProp(_el$10, "fg", _v$8, _p$.t)); + var _v$ = theme?.().textMuted, + _v$2 = tone(), + _v$3 = theme?.().text; + _v$ !== _p$.e && (_p$.e = _$setProp(_el$3, "fg", _v$, _p$.e)); + _v$2 !== _p$.t && (_p$.t = _$setProp(_el$5, "fg", _v$2, _p$.t)); + _v$3 !== _p$.a && (_p$.a = _$setProp(_el$6, "fg", _v$3, _p$.a)); return _p$; }, { e: undefined, - t: undefined + t: undefined, + a: undefined }); - return _el$0; + return _el$; })(); -}; -const SectionHeader = props => (() => { - var _el$12 = _$createElement("box"), - _el$13 = _$createElement("text"), - _el$14 = _$createElement("b"); - _$insertNode(_el$12, _el$13); - _$setProp(_el$12, "width", "100%"); - _$setProp(_el$12, "marginTop", 1); - _$insertNode(_el$13, _el$14); - _$insert(_el$14, () => props.title); - _$effect(_$p => _$setProp(_el$13, "fg", props.theme.text, _$p)); - return _el$12; -})(); - -// Live recomp progress. Renders while a rebuild runs (and briefly after it -// finishes) so a multi-minute rebuild is visible instead of a single missed -// toast (dogfood 2026-05-30). -const RecompProgressSection = props => { - // CRITICAL: read `props.progress` reactively on every access — do NOT - // destructure it into a local `const p = props.progress` at creation time. - // The parent keeps THIS component instance mounted as the phase advances - // (recomp → migration → done), so a frozen `p` would render the - // creation-time phase forever — the sidebar stuck on "upgrading / Running - // historian (pass 1)…" even though the upgrade finished. Each accessor below - // tracks the parent signal so the label/bar/note update live (root cause of - // the dogfood 2026-05-30 "recomp upgrading stays" freeze). - const phase = () => props.progress.phase; - const fraction = () => props.progress.totalMessages > 0 ? props.progress.processedMessages / props.progress.totalMessages : 0; - const pct = () => Math.round(fraction() * 100); - - // "Recomp" vs "Upgrade" vs "Embed" wording follows the flow that started this - // run, so a plain /ctx-recomp never renders as an "Upgrade" (dogfood 2026-06-04). - const verb = () => props.progress.kind === "upgrade" ? "Upgrade" : props.progress.kind === "embed" ? "Embed" : props.progress.kind === "wrapup" ? "Wrapup" : "Recomp"; - const activeText = () => props.progress.kind === "upgrade" ? "upgrading ⟳" : props.progress.kind === "embed" ? "embedding ⟳" : props.progress.kind === "wrapup" ? "wrapping ⟳" : "comparting ⟳"; - const label = createMemo(() => { - switch (props.progress.phase) { - case "recomp": - return { - text: activeText(), - color: props.theme.warning - }; - case "migration": - return { - text: "Migrating memories ⟳", - color: props.theme.warning - }; - case "done": - return { - text: `✓ ${verb()} complete`, - color: props.theme.success ?? props.theme.accent - }; - case "skipped": - // Neutral terse status next to the bold verb header; the full, - // self-contained reason (lease-busy "try again shortly" vs a - // partial-stall "run /ctx-embed start again") renders on its own - // line below. Don't re-prepend verb here (it's already the bold - // header — doing so read as "EmbedEmbed"), and don't hardcode - // "retry shortly" (wrong for a partial stall). - return { - text: "stopped", - color: props.theme.textMuted - }; - case "failed": - return { - text: `✗ ${verb()} failed`, - color: props.theme.error - }; - } - }); - return [(() => { - var _el$15 = _$createElement("box"), - _el$16 = _$createElement("text"), - _el$17 = _$createElement("b"), - _el$18 = _$createElement("text"); - _$insertNode(_el$15, _el$16); - _$insertNode(_el$15, _el$18); - _$setProp(_el$15, "width", "100%"); - _$setProp(_el$15, "marginTop", 1); - _$setProp(_el$15, "flexDirection", "row"); - _$setProp(_el$15, "justifyContent", "space-between"); - _$insertNode(_el$16, _el$17); - _$insert(_el$17, verb); - _$insert(_el$18, () => label().text); - _$effect(_p$ => { - var _v$9 = props.theme.text, - _v$0 = label().color; - _v$9 !== _p$.e && (_p$.e = _$setProp(_el$16, "fg", _v$9, _p$.e)); - _v$0 !== _p$.t && (_p$.t = _$setProp(_el$18, "fg", _v$0, _p$.t)); - return _p$; - }, { - e: undefined, - t: undefined - }); - return _el$15; - })(), _$memo(() => _$memo(() => !!(phase() === "recomp" && props.progress.totalMessages > 0))() && (() => { - var _el$19 = _$createElement("box"), - _el$20 = _$createElement("text"), - _el$21 = _$createElement("text"), - _el$22 = _$createTextNode(`%`); - _$insertNode(_el$19, _el$20); - _$insertNode(_el$19, _el$21); - _$setProp(_el$19, "width", "100%"); - _$setProp(_el$19, "flexDirection", "row"); - _$setProp(_el$19, "justifyContent", "space-between"); - _$insert(_el$20, () => progressBar(fraction())); - _$insertNode(_el$21, _el$22); - _$insert(_el$21, pct, _el$22); - _$effect(_p$ => { - var _v$1 = props.theme.accent, - _v$10 = props.theme.textMuted; - _v$1 !== _p$.e && (_p$.e = _$setProp(_el$20, "fg", _v$1, _p$.e)); - _v$10 !== _p$.t && (_p$.t = _$setProp(_el$21, "fg", _v$10, _p$.t)); - return _p$; - }, { - e: undefined, - t: undefined - }); - return _el$19; - })()), _$memo(() => _$memo(() => !!((phase() === "recomp" || phase() === "migration") && props.progress.note))() && (() => { - var _el$23 = _$createElement("text"); - _$insert(_el$23, () => props.progress.note); - _$effect(_$p => _$setProp(_el$23, "fg", props.theme.textMuted, _$p)); - return _el$23; - })()), _$memo(() => _$memo(() => !!(phase() === "recomp" && props.progress.kind !== "embed"))() && _$createComponent(StatRow, { - get theme() { - return props.theme; - }, - label: "Compartments", - get value() { - return `${props.progress.compartmentsCreated} (${props.progress.passCount} pass${props.progress.passCount === 1 ? "" : "es"})`; - }, - dim: true - })), _$memo(() => _$memo(() => !!(phase() === "recomp" && props.progress.kind === "embed"))() && _$createComponent(StatRow, { - get theme() { - return props.theme; - }, - label: "Compartments", - get value() { - return `${props.progress.processedMessages}/${props.progress.totalMessages} embedded`; - }, - dim: true - })), _$memo(() => _$memo(() => !!((phase() === "failed" || phase() === "skipped") && props.progress.message))() && (() => { - var _el$24 = _$createElement("text"); - _$insert(_el$24, () => props.progress.message); - _$effect(_$p => _$setProp(_el$24, "fg", props.theme.textMuted, _$p)); - return _el$24; - })())]; -}; +} const SidebarContent = props => { const [snapshot, setSnapshot] = createSignal(null); - // Collapse state + section visibility prefs live in the controller (plugin - // closure), so they survive view-switch remounts and persist across restarts - // via ~/.config/opencode/tui-preferences.jsonc. Read reactively. - const collapsed = props.controller.collapsed; const sections = () => props.controller.prefs().sections; - const headerLabel = () => props.controller.prefs().header.label; + const headerLabel = () => { + const label = props.controller.prefs().header.label; + return label === DEFAULT_PREFS.header.label ? "MAGIC CONTEXT" : label; + }; const scheduler = createRefreshScheduler({ read: async () => { const sid = props.sessionID(); @@ -551,24 +187,17 @@ const SidebarContent = props => { if (activeRecompPollKick === refresh) activeRecompPollKick = null; if (activeSidebarRefresh === refresh) activeSidebarRefresh = null; }); - - // Refresh on session change createEffect(on(props.sessionID, () => { setSnapshot(null); scheduler.reset(); })); - - // Subscribe to events for live updates createEffect(on(props.sessionID, sessionID => { const unsubs = [props.api.event.on("message.updated", event => { - if (event.properties.info.sessionID !== sessionID) return; - scheduleRefresh(); + if (event.properties.info.sessionID === sessionID) scheduleRefresh(); }), props.api.event.on("session.updated", event => { - if (event.properties.info.id !== sessionID) return; - scheduleRefresh(); + if (event.properties.info.id === sessionID) scheduleRefresh(); }), props.api.event.on("message.removed", event => { - if (event.properties.sessionID !== sessionID) return; - scheduleRefresh(); + if (event.properties.sessionID === sessionID) scheduleRefresh(); })]; onCleanup(() => { for (const unsub of unsubs) unsub(); @@ -576,543 +205,367 @@ const SidebarContent = props => { }, { defer: false })); - const s = createMemo(() => snapshot()); + const s = snapshot; const compactionOff = () => s()?.compaction_enabled === false; - const contextSummaryColor = createMemo(() => { - if (compactionOff()) return props.theme.accent; - const usage = s()?.usagePercentage ?? 0; - if (usage >= 80) return props.theme.error; - if (usage >= 65) return props.theme.warning; - return props.theme.accent; + const tokenValue = () => { + const data = s(); + return data && present(data.inputTokens) && data.contextLimit > 0 ? `${compactTokens(data.inputTokens)}/${compactTokens(data.contextLimit)}` : "—"; + }; + const contextUsage = () => { + const data = s(); + if (!data || data.contextLimit <= 0) return undefined; + return compactionOff() ? data.inputTokens / data.contextLimit * 100 : present(data.usagePercentage) ? data.usagePercentage : undefined; + }; + const nextSummary = () => { + const data = s(); + if (!data || !present(data.proactiveFloor) || !present(data.usagePercentage) || !present(data.inputTokens) || data.contextLimit <= 0) return "—"; + if (data.usagePercentage >= data.proactiveFloor) return "due"; + const remaining = Math.max(0, data.contextLimit * data.proactiveFloor / 100 - data.inputTokens); + return `at ${formatThresholdPercent(data.proactiveFloor)}% · +${compactTokens(remaining)}`; + }; + const values = createMemo(() => { + const data = s(); + const run = data?.historianRun; + const cacheAt = data?.cachedM0MaterializedAt; + const drops = data?.queuedDropTokens; + const lastDream = data?.lastDreamerRunAt; + return { + lastRun: run ? `${relativeTime(run.finishedAt)} · ${run.outcome}` : "—", + model: modelName(run?.model), + waiting: present(data?.waitingMessages) ? `${data?.waitingMessages} msgs` : "—", + cacheBuilt: present(cacheAt) ? relativeTime(cacheAt) : "—", + sinceThen: present(data?.summariesSinceCache) && present(data?.memoriesSinceCache) ? `${data?.summariesSinceCache} summaries · ${data?.memoriesSinceCache} memories` : "—", + drops: present(data?.pendingOpsCount) && present(drops) ? `${data?.pendingOpsCount} · ≈${compactTokens(drops)} at rebuild` : "—", + memories: present(data?.memoryCount) ? `${data?.memoryCount} · ${present(data?.memoriesSinceCache) ? data?.memoriesSinceCache : "—"} new` : "—", + notes: present(data?.sessionNoteCount) && present(data?.readySmartNoteCount) ? `${data?.sessionNoteCount} · ${data?.readySmartNoteCount} smart waiting` : "—", + dreamerLastRun: present(lastDream) ? `${relativeTime(lastDream)} · ${data?.dreamerFailures?.length ? "failed" : "ok"}` : "—", + nativeRows: data ? compactionOffSidebarRows(data).filter(row => row.label === "Memories" ? sections().memory : sections().status) : [] + }; }); - return (() => { - var _el$25 = _$createElement("box"), - _el$26 = _$createElement("box"), - _el$27 = _$createElement("box"), - _el$28 = _$createElement("text"), - _el$29 = _$createElement("b"), - _el$30 = _$createElement("text"), - _el$31 = _$createTextNode(`v`); - _$insertNode(_el$25, _el$26); - _$setProp(_el$25, "width", "100%"); - _$setProp(_el$25, "flexDirection", "column"); - _$setProp(_el$25, "border", SINGLE_BORDER); - _$setProp(_el$25, "paddingTop", 1); - _$setProp(_el$25, "paddingBottom", 1); - _$setProp(_el$25, "paddingLeft", 1); - _$setProp(_el$25, "paddingRight", 1); - _$insertNode(_el$26, _el$27); - _$insertNode(_el$26, _el$30); - _$setProp(_el$26, "flexDirection", "row"); - _$setProp(_el$26, "justifyContent", "space-between"); - _$setProp(_el$26, "alignItems", "center"); - _$setProp(_el$26, "onMouseDown", () => props.controller.toggleCollapsed()); - _$insertNode(_el$27, _el$28); - _$setProp(_el$27, "paddingLeft", 1); - _$setProp(_el$27, "paddingRight", 1); - _$insertNode(_el$28, _el$29); - _$insert(_el$29, () => collapsed() ? "▶ " : "▼ ", null); - _$insert(_el$29, headerLabel, null); - _$insertNode(_el$30, _el$31); - _$insert(_el$30, () => packageJson.version, null); - _$insert(_el$25, (() => { - var _c$2 = _$memo(() => !!s()?.lastTransformError); - return () => _c$2() && (() => { - var _el$32 = _$createElement("box"), - _el$33 = _$createElement("text"), - _el$34 = _$createTextNode(`⚠ `); - _$insertNode(_el$32, _el$33); - _$setProp(_el$32, "marginTop", 1); - _$setProp(_el$32, "width", "100%"); - _$insertNode(_el$33, _el$34); - _$insert(_el$33, () => s().lastTransformError, null); - _$effect(_$p => _$setProp(_el$33, "fg", props.theme.error, _$p)); - return _el$32; - })(); - })(), null); - _$insert(_el$25, _$createComponent(For, { - get each() { - return s()?.hostLimitations ?? []; - }, - children: limitation => (() => { - var _el$35 = _$createElement("box"), - _el$36 = _$createElement("text"), - _el$37 = _$createTextNode(`⚠ `); - _$insertNode(_el$35, _el$36); - _$setProp(_el$35, "marginTop", 1); - _$setProp(_el$35, "width", "100%"); - _$insertNode(_el$36, _el$37); - _$insert(_el$36, () => renderUserFacingFailure(limitation, "plain"), null); - _$effect(_$p => _$setProp(_el$36, "fg", props.theme.warning, _$p)); - return _el$35; - })() - }), null); - _$insert(_el$25, (() => { - var _c$3 = _$memo(() => !!s()?.dreamerProgress); - return () => _c$3() && (() => { - var _el$38 = _$createElement("box"), - _el$39 = _$createElement("text"), - _el$40 = _$createTextNode(`Dreamer `), - _el$41 = _$createTextNode(`: `), - _el$42 = _$createTextNode(`/`), - _el$43 = _$createTextNode(` processed`); - _$insertNode(_el$38, _el$39); - _$setProp(_el$38, "marginTop", 1); - _$setProp(_el$38, "width", "100%"); - _$insertNode(_el$39, _el$40); - _$insertNode(_el$39, _el$41); - _$insertNode(_el$39, _el$42); - _$insertNode(_el$39, _el$43); - _$insert(_el$39, () => s().dreamerProgress.task, _el$41); - _$insert(_el$39, () => s().dreamerProgress.processed, _el$42); - _$insert(_el$39, () => s().dreamerProgress.total, _el$43); - _$effect(_$p => _$setProp(_el$39, "fg", props.theme.warning, _$p)); - return _el$38; - })(); - })(), null); - _$insert(_el$25, (() => { - var _c$4 = _$memo(() => !!(s() && s().inputTokens > 0)); - return () => _c$4() && (() => { - var _el$44 = _$createElement("box"); - _$setProp(_el$44, "flexDirection", "column"); - _$insert(_el$44, (() => { - var _c$7 = _$memo(() => (s()?.contextLimit ?? 0) > 0); - return () => _c$7() && (() => { - var _el$45 = _$createElement("box"), - _el$46 = _$createElement("text"), - _el$47 = _$createTextNode(` / `); - _$insertNode(_el$45, _el$46); - _$setProp(_el$45, "width", "100%"); - _$setProp(_el$45, "flexDirection", "row"); - _$setProp(_el$45, "justifyContent", "space-between"); - _$insert(_el$45, (() => { - var _c$9 = _$memo(() => !!compactionOff()); - return () => _c$9() ? (() => { - var _el$48 = _$createElement("text"), - _el$49 = _$createElement("b"); - _$insertNode(_el$48, _el$49); - _$insert(_el$49, () => nativeCompactionContextLabel(s())); - _$effect(_$p => _$setProp(_el$48, "fg", contextSummaryColor(), _$p)); - return _el$48; - })() : (() => { - var _el$50 = _$createElement("text"), - _el$51 = _$createElement("b"), - _el$52 = _$createTextNode(`%`), - _el$53 = _$createTextNode(` / `), - _el$54 = _$createTextNode(`%`); - _$insertNode(_el$50, _el$51); - _$insertNode(_el$50, _el$53); - _$insertNode(_el$50, _el$54); - _$insertNode(_el$51, _el$52); - _$insert(_el$51, () => s().usagePercentage.toFixed(1), _el$52); - _$insert(_el$50, () => formatThresholdPercent(s().executeThreshold), _el$54); - _$insert(_el$50, () => s().executeThresholdClamped ? "*" : "", null); - _$effect(_$p => _$setProp(_el$50, "fg", contextSummaryColor(), _$p)); - return _el$50; - })(); - })(), _el$46); - _$insertNode(_el$46, _el$47); - _$insert(_el$46, () => compactTokens(s().inputTokens), _el$47); - _$insert(_el$46, () => compactTokens(s().contextLimit), null); - _$effect(_$p => _$setProp(_el$46, "fg", contextSummaryColor(), _$p)); - return _el$45; - })(); - })(), null); - _$insert(_el$44, _$createComponent(TokenBreakdown, { - get theme() { - return props.theme; - }, - get snapshot() { - return s(); - }, - get collapsed() { - return collapsed(); - } - }), null); - _$insert(_el$44, (() => { - var _c$8 = _$memo(() => s().tailHygiene !== undefined); - return () => _c$8() && _$createComponent(StatRow, { - get theme() { - return props.theme; - }, - label: "Hygiene", - get value() { - return hygieneValue(s().tailHygiene); - }, - get warning() { - return !s().tailHygiene.evaluable; - } - }); - })(), null); - _$effect(_$p => _$setProp(_el$44, "marginTop", collapsed() ? 0 : 1, _$p)); - return _el$44; - })(); - })(), null); - _$insert(_el$25, (() => { - var _c$5 = _$memo(() => !!collapsed()); - return () => _c$5() && (() => { - var _el$55 = _$createElement("box"); - _$setProp(_el$55, "width", "100%"); - _$setProp(_el$55, "flexDirection", "column"); - _$insert(_el$55, (() => { - var _c$0 = _$memo(() => !!compactionOff()); - return () => _c$0() ? compactionOffSidebarRows(s()).map(row => _$createComponent(StatRow, { - get theme() { - return props.theme; - }, - get label() { - return row.label; - }, - get value() { - return row.value; - }, - get accent() { - return row.label === "Memories"; - }, - get dim() { - return row.label !== "Memories"; - } - })) : [(() => { - var _el$56 = _$createElement("box"), - _el$57 = _$createElement("text"); - _$insertNode(_el$56, _el$57); - _$setProp(_el$56, "width", "100%"); - _$setProp(_el$56, "flexDirection", "row"); - _$setProp(_el$56, "justifyContent", "space-between"); - _$insertNode(_el$57, _$createTextNode(`Historian`)); - _$insert(_el$56, (() => { - var _c$1 = _$memo(() => !!s()?.historianRunning); - return () => _c$1() ? (() => { - var _el$70 = _$createElement("text"); - _$insertNode(_el$70, _$createTextNode(`comparting ⟳`)); - _$effect(_$p => _$setProp(_el$70, "fg", props.theme.warning, _$p)); - return _el$70; - })() : (() => { - var _el$72 = _$createElement("text"); - _$insertNode(_el$72, _$createTextNode(`idle`)); - _$effect(_$p => _$setProp(_el$72, "fg", props.theme.textMuted, _$p)); - return _el$72; - })(); - })(), null); - _$effect(_$p => _$setProp(_el$57, "fg", props.theme.textMuted, _$p)); - return _el$56; - })(), _$createComponent(Show, { - get when() { - return s()?.dreamerProgress; - }, - children: progress => (() => { - var _el$74 = _$createElement("box"), - _el$75 = _$createElement("text"), - _el$77 = _$createElement("text"), - _el$78 = _$createTextNode(` `), - _el$79 = _$createTextNode(`/`); - _$insertNode(_el$74, _el$75); - _$insertNode(_el$74, _el$77); - _$setProp(_el$74, "width", "100%"); - _$setProp(_el$74, "flexDirection", "row"); - _$setProp(_el$74, "justifyContent", "space-between"); - _$insertNode(_el$75, _$createTextNode(`Dreamer`)); - _$insertNode(_el$77, _el$78); - _$insertNode(_el$77, _el$79); - _$insert(_el$77, () => progress().task, _el$78); - _$insert(_el$77, () => progress().processed, _el$79); - _$insert(_el$77, () => progress().total, null); - _$effect(_p$ => { - var _v$19 = props.theme.textMuted, - _v$20 = props.theme.warning; - _v$19 !== _p$.e && (_p$.e = _$setProp(_el$75, "fg", _v$19, _p$.e)); - _v$20 !== _p$.t && (_p$.t = _$setProp(_el$77, "fg", _v$20, _p$.t)); - return _p$; - }, { - e: undefined, - t: undefined - }); - return _el$74; - })() - }), (() => { - var _el$59 = _$createElement("box"), - _el$60 = _$createElement("text"), - _el$62 = _$createElement("text"); - _$insertNode(_el$59, _el$60); - _$insertNode(_el$59, _el$62); - _$setProp(_el$59, "width", "100%"); - _$setProp(_el$59, "flexDirection", "row"); - _$setProp(_el$59, "justifyContent", "space-between"); - _$insertNode(_el$60, _$createTextNode(`Memories`)); - _$insert(_el$62, (() => { - var _c$10 = _$memo(() => (s()?.memoryBlockCount ?? 0) > 0); - return () => _c$10() ? `${s().memoryBlockCount}/${s()?.memoryCount ?? 0}` : String(s()?.memoryCount ?? 0); - })()); - _$effect(_p$ => { - var _v$15 = props.theme.textMuted, - _v$16 = props.theme.textMuted; - _v$15 !== _p$.e && (_p$.e = _$setProp(_el$60, "fg", _v$15, _p$.e)); - _v$16 !== _p$.t && (_p$.t = _$setProp(_el$62, "fg", _v$16, _p$.t)); - return _p$; - }, { - e: undefined, - t: undefined - }); - return _el$59; - })(), (() => { - var _el$63 = _$createElement("box"), - _el$64 = _$createElement("text"), - _el$66 = _$createElement("text"), - _el$67 = _$createTextNode(`C:`), - _el$68 = _$createTextNode(` Q:`), - _el$69 = _$createTextNode(` N:`); - _$insertNode(_el$63, _el$64); - _$insertNode(_el$63, _el$66); - _$setProp(_el$63, "width", "100%"); - _$setProp(_el$63, "flexDirection", "row"); - _$setProp(_el$63, "justifyContent", "space-between"); - _$insertNode(_el$64, _$createTextNode(`Status`)); - _$insertNode(_el$66, _el$67); - _$insertNode(_el$66, _el$68); - _$insertNode(_el$66, _el$69); - _$insert(_el$66, () => s()?.compartmentCount ?? 0, _el$68); - _$insert(_el$66, () => s()?.pendingOpsCount ?? 0, _el$69); - _$insert(_el$66, () => s()?.sessionNoteCount ?? 0, null); - _$effect(_p$ => { - var _v$17 = props.theme.textMuted, - _v$18 = props.theme.textMuted; - _v$17 !== _p$.e && (_p$.e = _$setProp(_el$64, "fg", _v$17, _p$.e)); - _v$18 !== _p$.t && (_p$.t = _$setProp(_el$66, "fg", _v$18, _p$.t)); - return _p$; - }, { - e: undefined, - t: undefined - }); - return _el$63; - })(), _$createComponent(Show, { - get when() { - return s()?.recompProgress; - }, - children: progress => _$createComponent(RecompProgressSection, { - get theme() { - return props.theme; - }, - get progress() { - return progress(); - } - }) - })]; - })()); - return _el$55; - })(); - })(), null); - _$insert(_el$25, (() => { - var _c$6 = _$memo(() => !!!collapsed()); - return () => _c$6() && [_$memo(() => _$memo(() => !!(!compactionOff() && sections().historian))() && [(() => { - var _el$80 = _$createElement("box"), - _el$81 = _$createElement("text"), - _el$82 = _$createElement("b"); - _$insertNode(_el$80, _el$81); - _$setProp(_el$80, "width", "100%"); - _$setProp(_el$80, "marginTop", 1); - _$setProp(_el$80, "flexDirection", "row"); - _$setProp(_el$80, "justifyContent", "space-between"); - _$insertNode(_el$81, _el$82); - _$insertNode(_el$82, _$createTextNode(`Historian`)); - _$insert(_el$80, (() => { - var _c$11 = _$memo(() => !!s()?.historianRunning); - return () => _c$11() ? (() => { - var _el$84 = _$createElement("text"); - _$insertNode(_el$84, _$createTextNode(`comparting ⟳`)); - _$effect(_$p => _$setProp(_el$84, "fg", props.theme.warning, _$p)); - return _el$84; - })() : (() => { - var _el$86 = _$createElement("text"); - _$insertNode(_el$86, _$createTextNode(`idle`)); - _$effect(_$p => _$setProp(_el$86, "fg", props.theme.textMuted, _$p)); - return _el$86; - })(); - })(), null); - _$effect(_$p => _$setProp(_el$81, "fg", props.theme.text, _$p)); - return _el$80; - })(), _$createComponent(StatRow, { - get theme() { - return props.theme; - }, - label: "Compartments", - get value() { - return String(s()?.compartmentCount ?? 0); - } - }), _$createComponent(Show, { - get when() { - return s()?.recompProgress; - }, - children: progress => _$createComponent(RecompProgressSection, { - get theme() { - return props.theme; - }, - get progress() { - return progress(); - } - }) - })]), _$memo(() => _$memo(() => !!sections().memory)() && [_$createComponent(SectionHeader, { - get theme() { - return props.theme; - }, - title: "Memory" - }), _$memo(() => _$memo(() => !!compactionOff())() ? compactionOffSidebarRows(s()).filter(row => row.label === "Memories").map(row => _$createComponent(StatRow, { - get theme() { - return props.theme; - }, - get label() { - return row.label; - }, - get value() { - return row.value; - }, - accent: true - })) : [_$createComponent(StatRow, { - get theme() { - return props.theme; - }, - label: "Memories", - get value() { - return String(s()?.memoryCount ?? 0); - }, - accent: true - }), _$memo(() => _$memo(() => (s()?.memoryBlockCount ?? 0) > 0)() && _$createComponent(StatRow, { - get theme() { - return props.theme; - }, - label: "Injected", - get value() { - return String(s().memoryBlockCount); - }, - dim: true - }))])]), _$memo(() => _$memo(() => !!(sections().status && (compactionOff() || (s()?.pendingOpsCount ?? 0) > 0 || (s()?.sessionNoteCount ?? 0) > 0 || (s()?.readySmartNoteCount ?? 0) > 0)))() && [_$createComponent(SectionHeader, { - get theme() { - return props.theme; - }, - title: "Status" - }), _$memo(() => _$memo(() => !!compactionOff())() ? compactionOffSidebarRows(s()).filter(row => row.label !== "Memories").map(row => _$createComponent(StatRow, { - get theme() { - return props.theme; - }, - get label() { - return row.label; - }, - get value() { - return row.value; - }, - dim: true - })) : [_$memo(() => _$memo(() => (s()?.pendingOpsCount ?? 0) > 0)() && _$createComponent(StatRow, { - get theme() { - return props.theme; - }, - label: "Queue", - get value() { - return `${s().pendingOpsCount} pending`; - }, - warning: true - })), _$memo(() => _$memo(() => (s()?.sessionNoteCount ?? 0) > 0)() && _$createComponent(StatRow, { - get theme() { - return props.theme; + const progress = () => { + const p = s()?.recompProgress; + return p?.phase === "recomp" || p?.phase === "migration" ? p : undefined; + }; + const progressLabel = () => ({ + recomp: "Recomp", + upgrade: "Upgrade", + embed: "Embed", + wrapup: "Wrap-up" + })[progress()?.kind ?? "recomp"]; + const progressValue = () => { + const p = progress(); + return p && p.totalMessages > 0 ? `${Math.round(Math.max(0, Math.min(1, p.processedMessages / p.totalMessages)) * 100)}%` : "no reading"; + }; + const healthRows = createMemo(() => { + const data = s(); + const rows = []; + if (!data) return rows; + const historian = data.historianRun; + if (!compactionOff()) { + const counts = historian?.sinceLastClean; + if (counts && counts.primaryRejections > 0) rows.push({ + label: "Historian", + value: `${modelName(historian?.primaryModel).split(" ")[0]} ${counts.primaryRejections}/${counts.primaryAttempts} rejected`, + tone: "warning" + }); + if (counts && counts.fallbackRuns > 0) rows.push({ + label: "Fallback", + value: `${modelName(historian?.model).split(" ")[0]} finished ${counts.fallbackRuns} runs`, + tone: "warning" + }); + if (historian?.outcome === "failed" || (data.historianFailureCount ?? 0) > 0) rows.push({ + label: "Historian", + value: "last run failed", + tone: "error" + }); + } + if (data.dreamerFailures?.length) rows.push({ + label: "Dreamer", + value: `${data.dreamerFailures.length} tasks failed`, + tone: "warning" + }); + if (data.lastTransformError) rows.push({ + label: "Transform", + value: shortText(data.lastTransformError), + tone: "error" + }); + for (const limitation of data.hostLimitations ?? []) rows.push({ + label: "Host", + value: shortText(renderUserFacingFailure(limitation, "plain")), + tone: "warning" + }); + if (data.configReloadFailure) rows.push({ + label: "Config", + value: "reload failed", + tone: "warning" + }); + return rows; + }); + return _$createComponent(GaugeTheme.Provider, { + value: () => props.theme, + get children() { + return _$createComponent(SidebarFrame, { + get header() { + return { + badge: headerLabel(), + version: `v${packageJson.version}` + }; }, - label: "Notes", - get value() { - return String(s().sessionNoteCount); - } - })), _$memo(() => _$memo(() => (s()?.readySmartNoteCount ?? 0) > 0)() && _$createComponent(StatRow, { + accounts: [], + showQuota: false, get theme() { - return props.theme; + return { + text: props.theme.text, + textMuted: props.theme.textMuted, + accent: props.theme.accent, + success: props.theme.success, + warning: props.theme.warning, + error: props.theme.error, + background: badgeTextColor(props.theme.accent, props.theme.background), + borderActive: props.theme.borderActive + }; }, - label: "Smart Notes", - get value() { - return `${s().readySmartNoteCount} ready`; - }, - accent: true - }))])]), _$memo(() => _$memo(() => !!(sections().dreamer && (s()?.lastDreamerRunAt || s()?.dreamerProgress)))() && [_$createComponent(SectionHeader, { - get theme() { - return props.theme; + appearance: { + barWidth: 10, + barFilledChar: "█", + barEmptyChar: "░", + warnThreshold: 65, + errorThreshold: 85 }, - title: "Dreamer" - }), _$createComponent(Show, { - get when() { - return s()?.dreamerProgress; + get now() { + return Date.now(); }, - children: progress => _$createComponent(StatRow, { - get theme() { - return props.theme; - }, - label: "Current", - get value() { - return `${progress().task} ${progress().processed}/${progress().total}`; + collapsed: false, + onToggle: () => {}, + sections: () => [_$createComponent(SidebarSection, { + title: "Context", + get children() { + return [_$createComponent(SidebarGaugeRow, { + label: "ctx", + get used() { + return contextUsage(); + }, + get value() { + return tokenValue(); + }, + get warnAt() { + return _$memo(() => !!compactionOff())() ? 100 : s()?.executeThreshold; + }, + get errorAt() { + return compactionOff() ? 100 : 85; + } + }), _$createComponent(Show, { + get when() { + return !compactionOff(); + }, + get fallback() { + return _$createComponent(SidebarTextRow, { + text: "native compaction" + }); + }, + get children() { + return _$createComponent(SidebarStatRow, { + label: "Next summary", + get value() { + return nextSummary(); + } + }); + } + })]; + } + }), _$createComponent(Show, { + get when() { + return _$memo(() => !!!compactionOff())() && sections().historian; }, - warning: true - }) - }), _$createComponent(Show, { - get when() { - return s()?.lastDreamerRunAt; - }, - children: lastRunAt => _$createComponent(StatRow, { - get theme() { - return props.theme; + get children() { + return _$createComponent(SidebarSection, { + title: "History", + get children() { + return [_$createComponent(SidebarStatRow, { + label: "Historian", + get value() { + return _$memo(() => !!s())() ? s()?.historianRunning || s()?.compartmentInProgress ? "summarizing" : "idle" : "—"; + } + }), _$createComponent(SidebarStatRow, { + label: "Last run", + get value() { + return values().lastRun; + } + }), _$createComponent(SidebarStatRow, { + label: "Model", + get value() { + return values().model; + } + }), _$createComponent(SidebarStatRow, { + label: "Waiting", + get value() { + return values().waiting; + } + }), _$createComponent(Show, { + get when() { + return progress(); + }, + get children() { + return _$createComponent(SidebarStatRow, { + get label() { + return progressLabel(); + }, + get value() { + return progressValue(); + } + }); + } + })]; + } + }); + } + }), _$createComponent(Show, { + get when() { + return _$memo(() => !!!compactionOff())() && sections().status; }, - label: "Last run", - get value() { - return relativeTime(lastRunAt()); + get children() { + return _$createComponent(SidebarSection, { + title: "Prompt cache", + get children() { + return [_$createComponent(SidebarStatRow, { + label: "Cache built", + get value() { + return values().cacheBuilt; + } + }), _$createComponent(SidebarStatRow, { + label: "Since then", + get value() { + return values().sinceThen; + } + }), _$createComponent(SidebarStatRow, { + label: "Queued drops", + get value() { + return values().drops; + } + })]; + } + }); + } + }), _$createComponent(Show, { + get when() { + return sections().memory || sections().status; }, - dim: true - }) - }), _$createComponent(For, { - get each() { - return Object.entries(s()?.dreamerBacklog ?? {}); - }, - children: ([task, backlog]) => _$createComponent(StatRow, { - get theme() { - return props.theme; + get children() { + return _$createComponent(SidebarSection, { + title: "Knowledge", + get children() { + return _$createComponent(Show, { + get when() { + return compactionOff(); + }, + get fallback() { + return [_$createComponent(Show, { + get when() { + return sections().memory; + }, + get children() { + return _$createComponent(SidebarStatRow, { + label: "Memories", + get value() { + return values().memories; + } + }); + } + }), _$createComponent(Show, { + get when() { + return sections().status; + }, + get children() { + return _$createComponent(SidebarStatRow, { + label: "Notes", + get value() { + return values().notes; + } + }); + } + })]; + }, + get children() { + return _$createComponent(For, { + get each() { + return values().nativeRows; + }, + children: row => _$createComponent(SidebarStatRow, { + get label() { + return row.label; + }, + get value() { + return row.value; + } + }) + }); + } + }); + } + }); + } + }), _$createComponent(Show, { + get when() { + return sections().dreamer; }, - label: task, - get value() { - return `${backlog.pending}/${backlog.total}`; + get children() { + return _$createComponent(SidebarSection, { + title: "Dreamer", + get children() { + return [_$createComponent(SidebarStatRow, { + label: "Last run", + get value() { + return values().dreamerLastRun; + } + }), _$createComponent(Show, { + get when() { + return s()?.dreamerProgress; + }, + children: p => _$createComponent(SidebarStatRow, { + label: "Current", + get value() { + return `${p().task} ${p().processed}/${p().total}`; + } + }) + })]; + } + }); + } + }), _$createComponent(Show, { + get when() { + return healthRows().length > 0; }, - dim: true - }) - })]), _$memo(() => _$memo(() => !!(sections().stats && s()?.totalInputTokens != null))() && [_$createComponent(SectionHeader, { - get theme() { - return props.theme; - }, - title: "Stats" - }), _$createComponent(StatRow, { - get theme() { - return props.theme; - }, - label: "Total tokens", - get value() { - return compactTokens(s().totalInputTokens ?? 0); - }, - dim: true - })])]; - })(), null); - _$effect(_p$ => { - var _v$11 = props.theme.borderActive, - _v$12 = props.theme.accent, - _v$13 = badgeTextColor(props.theme.accent, props.theme.background), - _v$14 = props.theme.textMuted; - _v$11 !== _p$.e && (_p$.e = _$setProp(_el$25, "borderColor", _v$11, _p$.e)); - _v$12 !== _p$.t && (_p$.t = _$setProp(_el$27, "backgroundColor", _v$12, _p$.t)); - _v$13 !== _p$.a && (_p$.a = _$setProp(_el$28, "fg", _v$13, _p$.a)); - _v$14 !== _p$.o && (_p$.o = _$setProp(_el$30, "fg", _v$14, _p$.o)); - return _p$; - }, { - e: undefined, - t: undefined, - a: undefined, - o: undefined - }); - return _el$25; - })(); + get children() { + return _$createComponent(SidebarSection, { + title: "Health", + get children() { + return [_$createComponent(For, { + get each() { + return healthRows(); + }, + children: row => _$createComponent(SidebarStatRow, { + get label() { + return row.label; + }, + get value() { + return row.value; + }, + get tone() { + return row.tone; + } + }) + }), _$createComponent(SidebarTextRow, { + text: "/ctx-status shows details" + })]; + } + }); + } + })] + }); + } + }); }; export function createSidebarContentSlot(api) { - // Seed synchronously at slot construction so the sidebar renders at its - // final collapse state + order on the first paint (no async flicker). The - // controller lives here in the factory closure for the plugin lifetime, so - // collapse state and live pref reloads survive sidebar_content remounts. const seedRoot = readTuiPreferencesFileSync(); const controller = createSidebarController(resolveMagicContextPrefs(seedRoot)); const effectiveOrder = computeEffectiveOrder(seedRoot, PLUGIN_KEY, DEFAULT_SLOT_ORDER); diff --git a/packages/plugin/src/tui/quota-sidebar.d.ts b/packages/plugin/src/tui/quota-sidebar.d.ts new file mode 100644 index 0000000000..9d3aa23d07 --- /dev/null +++ b/packages/plugin/src/tui/quota-sidebar.d.ts @@ -0,0 +1,6 @@ +export { + SidebarFrame, + SidebarSection, + SidebarStatRow, + SidebarTextRow, +} from "@cortexkit/common-auth/quota-sidebar"; diff --git a/packages/plugin/src/tui/quota-sidebar.js b/packages/plugin/src/tui/quota-sidebar.js new file mode 100644 index 0000000000..4265265f77 --- /dev/null +++ b/packages/plugin/src/tui/quota-sidebar.js @@ -0,0 +1,909 @@ +// @bun +// ../../node_modules/.bun/@cortexkit+common-auth@0.38.0+79ed038a4f852143/node_modules/@cortexkit/common-auth/src/quota-sidebar/index.tsx +import { createTextNode as _$createTextNode } from "@opentui/solid"; +import { createComponent as _$createComponent } from "@opentui/solid"; +import { use as _$use } from "@opentui/solid"; +import { memo as _$memo } from "@opentui/solid"; +import { effect as _$effect } from "@opentui/solid"; +import { insertNode as _$insertNode } from "@opentui/solid"; +import { insert as _$insert } from "@opentui/solid"; +import { setProp as _$setProp } from "@opentui/solid"; +import { createElement as _$createElement } from "@opentui/solid"; +import { createContext, createMemo, createSignal, For, Index, mergeProps, Show, useContext } from "solid-js"; + +// ../../node_modules/.bun/@cortexkit+common-auth@0.38.0+79ed038a4f852143/node_modules/@cortexkit/common-auth/src/quota/map.ts +var ALL_SCOPE = "all"; +var DEFAULT_REQUIRED_LABELS = Object.freeze([ + "primary" +]); + +// ../../node_modules/.bun/@cortexkit+common-auth@0.38.0+79ed038a4f852143/node_modules/@cortexkit/common-auth/src/quota/format.ts +var QUOTA_STALE_AFTER_MS = 15 * 60000; +var NO_QUOTA_READING = "no quota reading yet"; +var NO_LIMITS_REPORTED = "no limits reported"; +function shortSpan(ms) { + const minutes = Math.max(1, Math.floor(ms / 60000)); + if (minutes < 60) + return `${minutes}m`; + if (minutes < 1440) { + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + return rest === 0 ? `${hours}h` : `${hours}h${rest}m`; + } + const days = Math.floor(minutes / 1440); + const hours = Math.floor(minutes % 1440 / 60); + return hours === 0 ? `${days}d` : `${days}d${hours}h`; +} +function lengthName(minutes) { + if (minutes % 1440 === 0) + return `${minutes / 1440}d`; + if (minutes % 60 === 0) + return `${minutes / 60}h`; + return `${minutes}m`; +} +function quotaWindowName(limit) { + if (limit.scope !== ALL_SCOPE || limit.windowMinutes === undefined) + return limit.label; + return lengthName(limit.windowMinutes); +} +function clampPercent(remaining) { + return Math.min(100, Math.max(0, remaining)); +} +function wholePercent(remaining) { + return Math.round(clampPercent(remaining)); +} +function percentLeft(remaining) { + return `${wholePercent(remaining)}% left`; +} +function resetDetails(resetsAt, checkedAt, now) { + const at = resetsAt === undefined ? Number.NaN : Date.parse(resetsAt); + return { + ...Number.isFinite(at) && at > now ? { resetIn: shortSpan(at - now) } : {}, + resetSinceReading: Number.isFinite(at) && at > checkedAt && at <= now + }; +} +function displayOrder(left, right) { + const l = left.windowMinutes; + const r = right.windowMinutes; + if (l !== undefined && r !== undefined && l !== r) + return l - r; + if (l === undefined !== (r === undefined)) + return l === undefined ? 1 : -1; + if (left.scope !== right.scope) { + if (left.scope === ALL_SCOPE) + return -1; + if (right.scope === ALL_SCOPE) + return 1; + return left.scope < right.scope ? -1 : 1; + } + return left.label < right.label ? -1 : left.label > right.label ? 1 : 0; +} +function quotaWindowParts(quota, options) { + const { windows, age } = scopedWindowParts(quota, options, true); + const parts = windows.map(({ part }) => part); + return age === undefined ? { windows: parts } : { windows: parts, age }; +} +function withShortNames(windows) { + const abbreviations = scopeAbbreviations(windows.filter(({ part, scope }) => part.kind === "window" && scope !== ALL_SCOPE).map(({ scope }) => scope)); + for (const window of windows) { + window.part.short = window.part.kind === "budget" ? "cr" : window.scope === ALL_SCOPE ? window.part.name : abbreviations.get(window.scope) ?? window.scope; + } + return windows; +} +function hasWindowReading(limit) { + return Number.isFinite(limit.remainingPercent) && Number.isFinite(limit.checkedAt); +} +function scopedWindowParts(quota, options, includeUnknown = false) { + if (!quota) + return { windows: [] }; + const { now } = options; + const readings = quota.limits.filter((limit) => limit.kind === "reading" && (includeUnknown || hasWindowReading(limit))).sort(displayOrder); + const windows = readings.map((limit) => { + const part = hasWindowReading(limit) ? { + kind: "window", + reading: "known", + name: quotaWindowName(limit), + short: "", + figure: percentLeft(limit.remainingPercent), + remainingPercent: clampPercent(limit.remainingPercent), + checkedAt: limit.checkedAt, + ...resetDetails(limit.resetsAt, limit.checkedAt, now) + } : { + kind: "window", + reading: "unknown", + name: quotaWindowName(limit), + short: "", + figure: "no reading", + resetSinceReading: false + }; + return { + scope: limit.scope, + ...limit.windowMinutes === undefined ? {} : { windowMinutes: limit.windowMinutes }, + part + }; + }); + const budget = quota.budget; + if (budget) { + const remainingPercent = budget.remainingPercent; + const figure = budget.reached ? "credits spent" : remainingPercent !== undefined ? `credits ${percentLeft(remainingPercent)}` : "credits available"; + windows.push({ + scope: ALL_SCOPE, + spent: budget.reached, + part: { + kind: "budget", + reading: "known", + name: "credits", + short: "", + figure, + ...remainingPercent === undefined ? {} : { remainingPercent: clampPercent(remainingPercent) }, + checkedAt: budget.checkedAt, + ...resetDetails(budget.resetsAt, budget.checkedAt, now) + } + }); + } + withShortNames(windows); + const readingTimes = windows.flatMap(({ part }) => part.reading === "known" && part.checkedAt !== undefined ? [part.checkedAt] : []); + if (readingTimes.length > 0) { + const age = now - Math.min(...readingTimes); + if (age > (options.staleAfterMs ?? QUOTA_STALE_AFTER_MS)) { + return { windows, age: `checked ${shortSpan(age)} ago` }; + } + } + return { windows }; +} +function scopeDisplayName(scope) { + const model = scope.slice(scope.indexOf(":") + 1); + return model.charAt(0).toUpperCase() + model.slice(1); +} +function scopeAbbreviations(scopes) { + const names = [...new Set(scopes.map(scopeDisplayName))]; + const lengths = new Map(names.map((name) => [name, 2])); + const short = (name) => name.slice(0, lengths.get(name)); + for (;; ) { + const byShort = new Map; + for (const name of names) { + byShort.set(short(name), [...byShort.get(short(name)) ?? [], name]); + } + let extended = false; + for (const group of byShort.values()) { + if (group.length < 2) + continue; + for (const name of group) { + const length = lengths.get(name) ?? 2; + if (length < name.length) { + lengths.set(name, length + 1); + extended = true; + } + } + } + if (!extended) + break; + } + return new Map(scopes.map((scope) => [scope, short(scopeDisplayName(scope))])); +} +var NARROW_GROUP_LIMIT = 2; +function groupShortNames(groups) { + const labels = groups.map((group) => group.label.trim() || group.scope); + const short = scopeAbbreviations(labels); + return labels.map((label) => short.get(label) ?? label); +} +function groupedNarrowPieces(groups, windows) { + if (!groups || groups.length === 0) + return; + const reported = windows.filter(({ part }) => part.kind === "window"); + if (reported.some(({ scope }) => scope === ALL_SCOPE)) + return; + const candidates = groups.flatMap((group, listed) => { + const inGroup = reported.filter(({ scope }) => scope === group.scope); + const groupTightest = inGroup.reduce((least, window) => least === undefined || (window.part.remainingPercent ?? 0) < (least.part.remainingPercent ?? 0) ? window : least, undefined); + return groupTightest ? [{ group, listed, window: groupTightest }] : []; + }); + if (candidates.length === 0) + return; + const names = groupShortNames(groups); + const left = (candidate) => candidate.window.part.remainingPercent ?? 0; + const shown = [...candidates].sort((a, b) => left(a) - left(b) || a.listed - b.listed).slice(0, NARROW_GROUP_LIMIT).sort((a, b) => a.listed - b.listed); + return shown.map((candidate, index) => { + const { windowMinutes } = candidate.window; + const piece = [ + names[candidate.listed] ?? candidate.group.scope, + ...windowMinutes === undefined ? [] : [lengthName(windowMinutes)], + `${wholePercent(left(candidate))}%` + ].join(" "); + return index === shown.length - 1 ? `${piece} left` : piece; + }); +} +function narrowTextParts(quota, options) { + const { windows } = scopedWindowParts(quota, options); + const grouped = groupedNarrowPieces(quota?.groups, windows); + if (grouped) + return grouped; + const general = windows.filter(({ part, scope }) => part.kind === "window" && scope === ALL_SCOPE); + const scoped = windows.filter(({ part, scope }) => part.kind === "window" && scope !== ALL_SCOPE); + const budget = windows.find(({ part }) => part.kind === "budget"); + const budgetLeft = budget?.spent ? 0 : budget?.part.remainingPercent; + const extras = [ + ...scoped.map(({ part }) => ({ + label: part.short, + remaining: part.remainingPercent ?? 0 + })), + ...budgetLeft === undefined || budget === undefined ? [] : [{ label: budget.part.short, remaining: budgetLeft }] + ]; + const tightest = extras.reduce((best, item) => best === undefined || item.remaining < best.remaining ? item : best, undefined); + const pieces = general.map(({ part }) => `${part.name} ${wholePercent(part.remainingPercent ?? 0)}%`); + if (tightest) { + pieces.push(`${tightest.label} ${wholePercent(tightest.remaining)}%`); + } + if (pieces.length === 0) + return windows.map(({ part }) => part.figure); + return pieces.map((piece, index) => index === pieces.length - 1 ? `${piece} left` : piece); +} +function quotaTextParts(quota, options) { + if (options.form === "narrow") + return narrowTextParts(quota, options); + const { windows, age } = scopedWindowParts(quota, options); + const full = (options.form ?? "full") === "full"; + const text = windows.map(({ part }) => { + const reset = full && part.resetIn ? `, resets ${part.resetIn}` : ""; + return `${part.kind === "budget" ? "" : `${part.name} `}${part.figure}${reset}`; + }); + return age === undefined ? text : [...text, age]; +} +function formatQuota(quota, options) { + const parts = quotaTextParts(quota, options); + if (parts.length > 0) + return parts.join(" \xB7 "); + return quota && quota.limits.length > 0 ? NO_LIMITS_REPORTED : NO_QUOTA_READING; +} + +// ../../node_modules/.bun/@cortexkit+common-auth@0.38.0+79ed038a4f852143/node_modules/@cortexkit/common-auth/src/quota-sidebar/index.tsx +var FrameContext = createContext(); +function frameContext() { + const context = useContext(FrameContext); + if (!context) + throw new Error("Sidebar primitives must be inside SidebarFrame"); + return context; +} +function displayWidth(text) { + const bun = globalThis.Bun; + return bun?.stringWidth ? bun.stringWidth(text) : Array.from(text).length; +} +function fitText(text, columns) { + if (displayWidth(text) <= columns) + return text; + if (columns < 1) + return ""; + let kept = ""; + let used = 0; + for (const char of text) { + const width = displayWidth(char); + if (used + width > columns - 1) + break; + kept += char; + used += width; + } + return `${kept}\u2026`; +} +function SidebarSection(props) { + const context = frameContext(); + return [(() => { + var _el$ = _$createElement("box"), _el$2 = _$createElement("text"), _el$3 = _$createElement("b"); + _$insertNode(_el$, _el$2); + _$setProp(_el$, "width", "100%"); + _$setProp(_el$, "marginTop", 1); + _$insertNode(_el$2, _el$3); + _$insert(_el$3, () => props.title); + _$effect((_$p) => _$setProp(_el$2, "fg", context.theme.text, _$p)); + return _el$; + })(), _$memo(() => props.children)]; +} +function SidebarStatRow(props) { + const context = frameContext(); + const [width, setWidth] = createSignal(0); + let row; + const value = () => width() <= 0 ? props.value : fitText(props.value, width() - displayWidth(props.label) - 1); + return (() => { + var _el$4 = _$createElement("box"), _el$5 = _$createElement("text"), _el$6 = _$createElement("text"), _el$7 = _$createElement("b"); + _$insertNode(_el$4, _el$5); + _$insertNode(_el$4, _el$6); + _$use((element) => { + row = element; + }, _el$4); + _$setProp(_el$4, "onSizeChange", () => setWidth(row?.width ?? 0)); + _$setProp(_el$4, "width", "100%"); + _$setProp(_el$4, "flexDirection", "row"); + _$setProp(_el$4, "justifyContent", "space-between"); + _$setProp(_el$4, "columnGap", 1); + _$setProp(_el$5, "wrapMode", "none"); + _$setProp(_el$5, "flexShrink", 0); + _$insert(_el$5, () => props.label); + _$insertNode(_el$6, _el$7); + _$setProp(_el$6, "wrapMode", "none"); + _$setProp(_el$6, "flexShrink", 1); + _$insert(_el$7, value); + _$effect((_p$) => { + var _v$ = context.theme.textMuted, _v$2 = context.theme[props.tone ?? "text"]; + _v$ !== _p$.e && (_p$.e = _$setProp(_el$5, "fg", _v$, _p$.e)); + _v$2 !== _p$.t && (_p$.t = _$setProp(_el$6, "fg", _v$2, _p$.t)); + return _p$; + }, { + e: undefined, + t: undefined + }); + return _el$4; + })(); +} +function SidebarTextRow(props) { + const context = frameContext(); + return (() => { + var _el$8 = _$createElement("text"); + _$insert(_el$8, () => props.text); + _$effect((_p$) => { + var _v$3 = context.theme[props.tone ?? "textMuted"], _v$4 = props.wrapMode; + _v$3 !== _p$.e && (_p$.e = _$setProp(_el$8, "fg", _v$3, _p$.e)); + _v$4 !== _p$.t && (_p$.t = _$setProp(_el$8, "wrapMode", _v$4, _p$.t)); + return _p$; + }, { + e: undefined, + t: undefined + }); + return _el$8; + })(); +} +function usageTone(used, appearance) { + if (used >= appearance.errorThreshold) + return "error"; + if (used >= appearance.warnThreshold) + return "warning"; + return "success"; +} +function barSegments(used, appearance, pace) { + const width = appearance.barWidth; + const cells = (pct) => Math.max(0, Math.min(Math.round(pct / 100 * width), width)); + const usedCells = cells(used); + const tone = usageTone(used, appearance); + const plain = [{ + text: appearance.barFilledChar.repeat(usedCells), + tone + }, { + text: "", + tone + }, { + text: appearance.barEmptyChar.repeat(width - usedCells), + tone + }]; + const overlay = pace?.overlay; + if (!overlay) + return plain; + const paceCells = cells(overlay.percent); + const lo = Math.min(usedCells, paceCells); + const hi = Math.max(usedCells, paceCells); + if (lo === hi) + return plain; + return [{ + text: appearance.barFilledChar.repeat(lo), + tone + }, { + text: (overlay.kind === "deficit" ? "\u2593" : "\u2592").repeat(hi - lo), + tone: overlay.kind === "deficit" ? "error" : "success" + }, { + text: appearance.barEmptyChar.repeat(width - hi), + tone + }]; +} +function QuotaRow(props) { + const context = frameContext(); + const [columns, setColumns] = createSignal(0); + let row; + const label = () => props.part.name + " ".repeat(Math.max(0, props.labelWidth - displayWidth(props.part.name))); + const figure = () => ` ${props.part.kind === "budget" && props.part.figure.startsWith(`${props.part.name} `) ? props.part.figure.slice(props.part.name.length + 1) : props.part.figure}`; + const hasReading = () => props.part.reading === "known"; + const used = () => 100 - (props.part.remainingPercent ?? 100); + const segments = createMemo(() => hasReading() ? barSegments(used(), { + ...context.appearance, + barWidth: props.barCells + }, props.pace) : [], undefined, { + equals: (previous, next) => previous.length === next.length && previous.every((segment, index) => segment.text === next[index]?.text && segment.tone === next[index]?.tone) + }); + const paceLine = () => { + const text = props.pace?.text; + if (!text || columns() <= 0) + return text; + return fitText(text, columns() - props.labelWidth); + }; + return [(() => { + var _el$9 = _$createElement("box"), _el$0 = _$createElement("box"); + _$insertNode(_el$9, _el$0); + _$use((element) => { + row = element; + }, _el$9); + _$setProp(_el$9, "onSizeChange", () => setColumns(row?.width ?? 0)); + _$setProp(_el$9, "width", "100%"); + _$setProp(_el$9, "flexDirection", "row"); + _$setProp(_el$9, "justifyContent", "space-between"); + _$setProp(_el$0, "flexDirection", "row"); + _$insert(_el$0, _$createComponent(Show, { + get when() { + return props.labelWidth > 0; + }, + get children() { + var _el$1 = _$createElement("text"); + _$setProp(_el$1, "wrapMode", "none"); + _$setProp(_el$1, "flexShrink", 0); + _$insert(_el$1, label); + _$effect((_$p) => _$setProp(_el$1, "fg", context.theme.textMuted, _$p)); + return _el$1; + } + }), null); + _$insert(_el$0, _$createComponent(Show, { + get when() { + return hasReading(); + }, + get fallback() { + return (() => { + var _el$13 = _$createElement("text"); + _$insertNode(_el$13, _$createTextNode(`no reading`)); + _$setProp(_el$13, "wrapMode", "none"); + _$effect((_$p) => _$setProp(_el$13, "fg", context.theme.textMuted, _$p)); + return _el$13; + })(); + }, + get children() { + return _$createComponent(Show, { + get when() { + return !props.part.resetSinceReading; + }, + get fallback() { + return (() => { + var _el$15 = _$createElement("text"); + _$insertNode(_el$15, _$createTextNode(`reset since last check`)); + _$setProp(_el$15, "wrapMode", "none"); + _$effect((_$p) => _$setProp(_el$15, "fg", context.theme.textMuted, _$p)); + return _el$15; + })(); + }, + get children() { + return [_$createComponent(Show, { + get when() { + return props.barCells > 0; + }, + get children() { + return _$createComponent(Index, { + get each() { + return segments(); + }, + children: (segment) => (() => { + var _el$17 = _$createElement("text"); + _$setProp(_el$17, "wrapMode", "none"); + _$insert(_el$17, () => segment().text); + _$effect((_p$) => { + var _v$5 = context.theme[segment().tone], _v$6 = segment().text ? undefined : 0; + _v$5 !== _p$.e && (_p$.e = _$setProp(_el$17, "fg", _v$5, _p$.e)); + _v$6 !== _p$.t && (_p$.t = _$setProp(_el$17, "width", _v$6, _p$.t)); + return _p$; + }, { + e: undefined, + t: undefined + }); + return _el$17; + })() + }); + } + }), (() => { + var _el$10 = _$createElement("text"); + _$setProp(_el$10, "wrapMode", "none"); + _$insert(_el$10, figure); + _$effect((_$p) => _$setProp(_el$10, "fg", context.theme[usageTone(used(), context.appearance)], _$p)); + return _el$10; + })()]; + } + }); + } + }), null); + _$insert(_el$9, _$createComponent(Show, { + get when() { + return _$memo(() => !!(hasReading() && props.showReset))() && !props.part.resetSinceReading; + }, + get children() { + var _el$11 = _$createElement("box"); + _$setProp(_el$11, "flexShrink", 0); + _$setProp(_el$11, "flexDirection", "row"); + _$setProp(_el$11, "justifyContent", "flex-end"); + _$insert(_el$11, _$createComponent(Show, { + get when() { + return props.part.resetIn; + }, + get children() { + var _el$12 = _$createElement("text"); + _$setProp(_el$12, "wrapMode", "none"); + _$insert(_el$12, () => props.part.resetIn); + _$effect((_$p) => _$setProp(_el$12, "fg", context.theme.textMuted, _$p)); + return _el$12; + } + })); + _$effect((_$p) => _$setProp(_el$11, "width", props.resetWidth, _$p)); + return _el$11; + } + }), null); + _$effect((_$p) => _$setProp(_el$0, "flexShrink", props.part.resetSinceReading ? 1 : 0, _$p)); + return _el$9; + })(), _$createComponent(Show, { + get when() { + return _$memo(() => !!(hasReading() && !props.part.resetSinceReading))() && paceLine(); + }, + keyed: true, + children: (line) => (() => { + var _el$18 = _$createElement("box"), _el$20 = _$createElement("text"); + _$insertNode(_el$18, _el$20); + _$setProp(_el$18, "width", "100%"); + _$setProp(_el$18, "flexDirection", "row"); + _$insert(_el$18, _$createComponent(Show, { + get when() { + return props.labelWidth > 0; + }, + get children() { + var _el$19 = _$createElement("text"); + _$insert(_el$19, () => "".padEnd(props.labelWidth)); + _$effect((_$p) => _$setProp(_el$19, "fg", context.theme.textMuted, _$p)); + return _el$19; + } + }), _el$20); + _$setProp(_el$20, "wrapMode", "none"); + _$insert(_el$20, line); + _$effect((_$p) => _$setProp(_el$20, "fg", context.theme[props.pace?.tone ?? "textMuted"], _$p)); + return _el$18; + })() + })]; +} +function CollapsedAccountRow(props) { + const context = frameContext(); + const [width, setWidth] = createSignal(0); + let row; + const hasQuota = () => quotaWindowParts(props.account.quota, { + now: context.now + }).windows.some((part) => part.reading === "known"); + const text = () => hasQuota() ? formatQuota(props.account.quota, { + now: context.now, + form: "narrow" + }) : props.account.emptyQuotaText?.collapsed ?? "\u2014"; + const showQuota = () => width() <= 0 || displayWidth(text()) + 4 <= width(); + const label = () => { + if (width() <= 0) + return props.account.label; + return fitText(props.account.label, width() - (showQuota() ? displayWidth(text()) + 3 : 2)); + }; + const muted = () => props.account.collapsed?.muted ?? false; + const quotaTone = () => muted() ? "textMuted" : props.account.collapsed?.quotaTone ?? "success"; + return (() => { + var _el$21 = _$createElement("box"), _el$23 = _$createElement("box"), _el$26 = _$createElement("text"); + _$insertNode(_el$21, _el$23); + _$use((element) => { + row = element; + }, _el$21); + _$setProp(_el$21, "onSizeChange", () => setWidth(row?.width ?? 0)); + _$setProp(_el$21, "width", "100%"); + _$setProp(_el$21, "flexDirection", "row"); + _$setProp(_el$21, "justifyContent", "space-between"); + _$insert(_el$21, _$createComponent(Show, { + get when() { + return label(); + }, + get children() { + var _el$22 = _$createElement("text"); + _$setProp(_el$22, "wrapMode", "none"); + _$setProp(_el$22, "flexShrink", 1); + _$insert(_el$22, label); + _$effect((_$p) => _$setProp(_el$22, "fg", context.theme[muted() ? "textMuted" : "text"], _$p)); + return _el$22; + } + }), _el$23); + _$insertNode(_el$23, _el$26); + _$setProp(_el$23, "flexDirection", "row"); + _$setProp(_el$23, "flexShrink", 0); + _$insert(_el$23, _$createComponent(Show, { + get when() { + return showQuota(); + }, + get children() { + return _$createComponent(Show, { + get when() { + return _$memo(() => !!hasQuota())() && !muted(); + }, + get fallback() { + return (() => { + var _el$27 = _$createElement("text"); + _$insert(_el$27, text); + _$effect((_$p) => _$setProp(_el$27, "fg", context.theme.textMuted, _$p)); + return _el$27; + })(); + }, + get children() { + var _el$24 = _$createElement("text"), _el$25 = _$createElement("b"); + _$insertNode(_el$24, _el$25); + _$insert(_el$25, text); + _$effect((_$p) => _$setProp(_el$24, "fg", context.theme[quotaTone()], _$p)); + return _el$24; + } + }); + } + }), _el$26); + _$insert(_el$26, () => showQuota() ? " " : "", null); + _$insert(_el$26, () => props.account.blocked ? "\u2298" : "\u25CF", null); + _$effect((_p$) => { + var _v$7 = label() ? 1 : 0, _v$8 = context.theme[props.account.indicatorTone]; + _v$7 !== _p$.e && (_p$.e = _$setProp(_el$21, "columnGap", _v$7, _p$.e)); + _v$8 !== _p$.t && (_p$.t = _$setProp(_el$26, "fg", _v$8, _p$.t)); + return _p$; + }, { + e: undefined, + t: undefined + }); + return _el$21; + })(); +} +function AccountBlock(props) { + const context = frameContext(); + const [columns, setColumns] = createSignal(0); + let block; + const rows = () => props.account.groups.flatMap((group) => group.rows); + const labelWidth = () => Math.max(0, ...rows().map(({ + part + }) => part.name ? Math.max(3, displayWidth(part.name) + 1) : 0)); + const resetWidth = () => Math.max(0, ...rows().map(({ + part + }) => part.reading === "unknown" || part.resetSinceReading ? 0 : displayWidth(part.resetIn ?? ""))); + const figureWidth = createMemo(() => Math.max(10, ...rows().filter(({ + part + }) => part.reading === "known" && !part.resetSinceReading).map(({ + part + }) => displayWidth(` ${part.kind === "budget" && part.figure.startsWith(`${part.name} `) ? part.figure.slice(part.name.length + 1) : part.figure}`)))); + const fit = (rowLabelWidth) => { + const desired = context.appearance.barWidth; + const minimum = Math.min(4, desired); + if (columns() <= 0) + return { + barCells: desired, + showReset: resetWidth() > 0 + }; + const room = columns() - rowLabelWidth - figureWidth(); + const withReset = room - resetWidth() - 1; + const showReset = resetWidth() > 0 && withReset >= minimum; + const available = showReset ? withReset : room; + return { + barCells: available >= minimum ? Math.min(desired, available) : 0, + showReset + }; + }; + const label = () => { + const full = `${props.account.label}${props.account.titleSuffix ? ` \xB7 ${props.account.titleSuffix}` : ""}`; + return columns() <= 0 ? full : fitText(full, columns() - displayWidth(props.account.status.text) - 1); + }; + const age = () => quotaWindowParts(props.account.quota, { + now: context.now + }).age; + const hasRows = () => props.account.groups.some((group) => group.rows.length > 0); + return (() => { + var _el$28 = _$createElement("box"), _el$29 = _$createElement("box"), _el$30 = _$createElement("text"), _el$31 = _$createElement("b"), _el$32 = _$createElement("text"), _el$33 = _$createElement("b"); + _$insertNode(_el$28, _el$29); + _$use((element) => { + block = element; + }, _el$28); + _$setProp(_el$28, "onSizeChange", () => setColumns(block?.width ?? 0)); + _$setProp(_el$28, "width", "100%"); + _$setProp(_el$28, "flexDirection", "column"); + _$insertNode(_el$29, _el$30); + _$insertNode(_el$29, _el$32); + _$setProp(_el$29, "width", "100%"); + _$setProp(_el$29, "flexDirection", "row"); + _$setProp(_el$29, "justifyContent", "space-between"); + _$setProp(_el$29, "columnGap", 1); + _$insertNode(_el$30, _el$31); + _$setProp(_el$30, "wrapMode", "none"); + _$setProp(_el$30, "flexShrink", 1); + _$insert(_el$31, label); + _$insertNode(_el$32, _el$33); + _$setProp(_el$32, "wrapMode", "none"); + _$setProp(_el$32, "flexShrink", 0); + _$insert(_el$33, () => props.account.status.text); + _$insert(_el$28, () => context.beforeQuota?.(props.account), null); + _$insert(_el$28, _$createComponent(Show, { + get when() { + return hasRows(); + }, + get fallback() { + return _$createComponent(SidebarTextRow, { + get text() { + return props.account.emptyQuotaText?.expanded ?? "\u2014"; + }, + wrapMode: "none" + }); + }, + get children() { + return _$createComponent(For, { + get each() { + return props.account.groups; + }, + children: (group) => [_$createComponent(Show, { + get when() { + return group.title; + }, + get children() { + return _$createComponent(SidebarTextRow, { + get text() { + return group.title ?? ""; + }, + tone: "text", + wrapMode: "none" + }); + } + }), _$createComponent(For, { + get each() { + return group.rows; + }, + children: (row) => { + const rowLabelWidth = () => row.part.name ? labelWidth() : 0; + const rowFit = createMemo(() => fit(rowLabelWidth())); + return _$createComponent(QuotaRow, { + get part() { + return row.part; + }, + get pace() { + return row.pace; + }, + get labelWidth() { + return rowLabelWidth(); + }, + get barCells() { + return rowFit().barCells; + }, + get resetWidth() { + return resetWidth(); + }, + get showReset() { + return rowFit().showReset; + } + }); + } + })] + }); + } + }), null); + _$insert(_el$28, _$createComponent(Show, { + get when() { + return age(); + }, + children: (text) => _$createComponent(SidebarTextRow, { + get text() { + return text(); + }, + wrapMode: "none" + }) + }), null); + _$insert(_el$28, () => context.afterQuota?.(props.account), null); + _$effect((_p$) => { + var _v$9 = props.marginTop, _v$0 = context.theme.text, _v$1 = context.theme[props.account.status.tone]; + _v$9 !== _p$.e && (_p$.e = _$setProp(_el$28, "marginTop", _v$9, _p$.e)); + _v$0 !== _p$.t && (_p$.t = _$setProp(_el$30, "fg", _v$0, _p$.t)); + _v$1 !== _p$.a && (_p$.a = _$setProp(_el$32, "fg", _v$1, _p$.a)); + return _p$; + }, { + e: undefined, + t: undefined, + a: undefined + }); + return _el$28; + })(); +} +function SidebarFrame(props) { + const hasData = () => props.accounts.length > 0; + const headerLabel = () => !hasData() ? props.header.badge : `${props.collapsed ? "\u25B6" : "\u25BC"} ${props.header.badge}`; + const context = mergeProps(props, { + get theme() { + const theme = props.theme; + return { + ...theme, + textMuted: theme?.textMuted ?? theme?.text, + accent: theme?.accent ?? theme?.text, + success: theme?.success ?? theme?.text, + warning: theme?.warning ?? theme?.text, + error: theme?.error ?? theme?.text + }; + } + }); + const invertBadge = () => props.theme?.accent !== undefined && props.theme?.background !== undefined; + return _$createComponent(FrameContext.Provider, { + value: context, + get children() { + var _el$34 = _$createElement("box"), _el$35 = _$createElement("box"), _el$36 = _$createElement("box"), _el$37 = _$createElement("text"), _el$38 = _$createElement("b"); + _$insertNode(_el$34, _el$35); + _$setProp(_el$34, "width", "100%"); + _$setProp(_el$34, "flexDirection", "column"); + _$insertNode(_el$35, _el$36); + _$setProp(_el$35, "width", "100%"); + _$setProp(_el$35, "flexDirection", "row"); + _$setProp(_el$35, "justifyContent", "space-between"); + _$setProp(_el$35, "alignItems", "center"); + _$setProp(_el$35, "onMouseDown", () => { + if (hasData()) + props.onToggle(); + }); + _$insertNode(_el$36, _el$37); + _$setProp(_el$36, "paddingLeft", 1); + _$setProp(_el$36, "paddingRight", 1); + _$insertNode(_el$37, _el$38); + _$insert(_el$38, headerLabel); + _$insert(_el$35, _$createComponent(Show, { + get when() { + return props.header.version; + }, + get children() { + var _el$39 = _$createElement("text"); + _$insert(_el$39, () => props.header.version); + _$effect((_$p) => _$setProp(_el$39, "fg", context.theme.textMuted, _$p)); + return _el$39; + } + }), null); + _$insert(_el$34, _$createComponent(Show, { + get when() { + return _$memo(() => !!props.collapsed)() && hasData(); + }, + get children() { + return [_$createComponent(For, { + get each() { + return props.accounts; + }, + children: (account) => _$createComponent(CollapsedAccountRow, { + account + }) + }), _$memo(() => props.collapsedSections?.())]; + } + }), null); + _$insert(_el$34, _$createComponent(Show, { + get when() { + return !props.collapsed || !hasData(); + }, + get children() { + return [_$createComponent(Show, { + get when() { + return _$memo(() => !!props.showQuota)() && hasData(); + }, + get children() { + return _$createComponent(SidebarSection, { + title: "Quota", + get children() { + return _$createComponent(For, { + get each() { + return props.accounts; + }, + children: (account, index) => _$createComponent(AccountBlock, { + account, + get marginTop() { + return index() === 0 ? 0 : 1; + } + }) + }); + } + }); + } + }), _$memo(() => props.sections?.())]; + } + }), null); + _$effect((_p$) => { + var _v$10 = invertBadge() ? props.theme?.accent : undefined, _v$11 = invertBadge() ? props.theme?.background : context.theme.accent; + _v$10 !== _p$.e && (_p$.e = _$setProp(_el$36, "backgroundColor", _v$10, _p$.e)); + _v$11 !== _p$.t && (_p$.t = _$setProp(_el$37, "fg", _v$11, _p$.t)); + return _p$; + }, { + e: undefined, + t: undefined + }); + return _el$34; + } + }); +} +export { + SidebarFrame, + SidebarSection, + SidebarStatRow, + SidebarTextRow +}; diff --git a/packages/plugin/src/tui/sidebar-render.test.ts b/packages/plugin/src/tui/sidebar-render.test.ts new file mode 100644 index 0000000000..708e269721 --- /dev/null +++ b/packages/plugin/src/tui/sidebar-render.test.ts @@ -0,0 +1,297 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import type { SidebarSnapshot } from "../shared/rpc-types"; + +const now = Date.now(); +const older: SidebarSnapshot = { + sessionId: "fixture", + usagePercentage: 55, + inputTokens: 483_000, + contextLimit: 872_000, + executeThreshold: 65, + systemPromptTokens: 0, + docsTokens: 0, + compartmentCount: 491, + memoryCount: 225, + memoryBlockCount: 10, + pendingOpsCount: 151, + historianRunning: false, + compartmentInProgress: false, + sessionNoteCount: 4, + readySmartNoteCount: 1, + cacheTtl: "5m", + lastTransformError: null, + lastDreamerRunAt: now - 9_000_000, + projectIdentity: null, + compartmentTokens: 0, + factTokens: 0, + memoryTokens: 0, + profileTokens: 0, + conversationTokens: 0, + toolCallTokens: 0, + toolDefinitionTokens: 0, +}; +const healthy: SidebarSnapshot = { + ...older, + proactiveFloor: 61, + waitingMessages: 140, + cachedM0MaterializedAt: now - 104_400_000, + summariesSinceCache: 6, + memoriesSinceCache: 7, + queuedDropTokens: 49_000, + historianRun: { + finishedAt: now - 6_900_000, + outcome: "ok", + model: "google/gemini-3.8-flash", + primaryModel: "google/gemini-3.8-flash", + fallback: false, + primaryRejections: 0, + primaryAttempts: 1, + sinceLastClean: { primaryRejections: 0, primaryAttempts: 0, fallbackRuns: 0 }, + }, +}; +const incident: SidebarSnapshot = { + ...healthy, + historianRunning: true, + waitingMessages: 28, + historianRun: { + ...healthy.historianRun!, + finishedAt: now - 60_000, + outcome: "fallback", + model: "deepseek/deepseek-v4.1-flash", + fallback: true, + primaryRejections: 2, + primaryAttempts: 2, + sinceLastClean: { primaryRejections: 4, primaryAttempts: 4, fallbackRuns: 2 }, + }, +}; +const off = { + ...incident, + usagePercentage: 30, + compaction_enabled: false, + archivedCompartmentCount: 491, +}; +const hidden = { + "magic-context": { + header: { label: "CUSTOM" }, + collapsed: true, + sections: { historian: false, memory: false, status: false, dreamer: false }, + }, +}; +const cases = [ + { snapshot: healthy }, + { snapshot: incident }, + { snapshot: older }, + { snapshot: off }, + { snapshot: healthy, preferences: hidden }, + { snapshot: null }, + { snapshot: { ...healthy, usagePercentage: undefined, contextLimit: 0 } }, + { snapshot: { ...healthy, usagePercentage: 61, inputTokens: 531_920 } }, + { + snapshot: { + ...healthy, + recompProgress: { + kind: "upgrade", + phase: "recomp", + processedMessages: 25, + totalMessages: 100, + passCount: 1, + compartmentsCreated: 2, + }, + }, + }, + { + snapshot: { + ...healthy, + historianFailureCount: 1, + lastTransformError: "Fixture transform failure", + configReloadFailure: { path: "fixture", message: "bad config" }, + hostLimitations: ["dream_task_needs_tool_loop"], + dreamerFailures: [ + { task: "fixture", error: "Fixture failure", lastSucceededAt: null, retryCount: 1 }, + { + task: "fixture-two", + error: "Fixture failure", + lastSucceededAt: null, + retryCount: 1, + }, + ], + }, + }, + { snapshot: { ...healthy, usagePercentage: 65 } }, + { snapshot: { ...healthy, usagePercentage: 85 } }, + { snapshot: { ...healthy, compartmentInProgress: true }, width: 40 }, + { snapshot: healthy, changeTheme: true }, + { snapshot: { ...healthy, historianRun: { ...healthy.historianRun!, outcome: "failed" } } }, + { snapshot: healthy, matchingBadgeColors: true }, +]; +type Result = { + frame: string; + error?: string; + collapsed?: boolean; + lines: Array>; +}; +let results: Result[]; +let rawResult: Result; +beforeAll(() => { + const child = Bun.spawnSync( + [ + process.execPath, + "--conditions=browser", + join(import.meta.dir, "../../scripts/render-compiled-sidebar.ts"), + ], + { + stdin: new TextEncoder().encode(JSON.stringify(cases)), + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, BUN_BE_BUN: "1" }, + }, + ); + if (child.exitCode !== 0) throw new Error(child.stderr.toString()); + results = JSON.parse(child.stdout.toString()); + for (const result of results) expect(result.error).toBeUndefined(); + const raw = Bun.spawnSync( + [ + process.execPath, + "--conditions=browser", + join(import.meta.dir, "../../scripts/render-compiled-sidebar.ts"), + "--raw-frame", + ], + { + stdin: new TextEncoder().encode(JSON.stringify([{ snapshot: healthy }])), + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, BUN_BE_BUN: "1" }, + }, + ); + if (raw.exitCode !== 0) throw new Error(raw.stderr.toString()); + rawResult = JSON.parse(raw.stdout.toString())[0]; + expect(rawResult.error).toBeUndefined(); +}, 60_000); + +function row(index: number, label: string, value: string) { + expect( + results[index]!.frame.split("\n").some( + (line) => + line.trim() === + `${label}${" ".repeat(Math.max(1, 42 - label.length - value.length))}${value}`, + ), + ).toBe(true); +} +function tone(index: number, text: string, color: string) { + const spans = results[index]!.lines.flat().filter((span) => span.text.includes(text)); + expect(spans.length).toBeGreaterThan(0); + for (const span of spans) expect(span.fg).toBe(color); +} + +describe("option B sidebar", () => { + test("draws the healthy expanded sections with actionable snapshot values", () => { + for (const title of [ + "MAGIC CONTEXT", + "Context", + "History", + "Prompt cache", + "Knowledge", + "Dreamer", + ]) + expect(results[0]!.frame).toContain(title); + row(0, "Next summary", "at 61% · +49K"); + row(0, "Historian", "idle"); + row(0, "Last run", "1h55m ago · ok"); + row(0, "Model", "Gemini 3.8 Flash"); + row(0, "Waiting", "140 msgs"); + row(0, "Cache built", "29h ago"); + row(0, "Since then", "6 summaries · 7 memories"); + row(0, "Queued drops", "151 · ≈49K at rebuild"); + row(0, "Memories", "225 · 7 new"); + row(0, "Notes", "4 · 1 smart waiting"); + row(0, "Last run", "2h30m ago · ok"); + expect(results[0]!.frame).toContain("ctx ██████░░░░ 45% left"); + expect(results[0]!.frame).toContain("483K/872K"); + expect(results[0]!.frame).not.toMatch(/Health|Hygiene|Total tokens|Conversation|⚠/); + tone(0, "idle", "#ffffff"); + tone(0, "Historian", "#888888"); + }); + test("shows primary rejections and the producing fallback in one Health section", () => { + row(1, "Historian", "summarizing"); + row(1, "Last run", "1m ago · fallback"); + row(1, "Model", "DeepSeek V4.1 Flash"); + row(1, "Historian", "Gemini 4/4 rejected"); + row(1, "Fallback", "DeepSeek finished 2 runs"); + expect(results[1]!.frame).toContain("Health"); + expect(results[1]!.frame.match(/\/ctx-status shows details/g)).toHaveLength(1); + tone(1, "4/4 rejected", "#ff8800"); + tone(1, "finished 2 runs", "#ff8800"); + tone(1, "/ctx-status shows details", "#888888"); + }); + test("uses dashes rather than fabricated values for older snapshots and the first read", () => { + for (const index of [2, 5]) { + for (const label of [ + "Next summary", + "Last run", + "Model", + "Waiting", + "Cache built", + "Since then", + ]) + row(index, label, "—"); + expect(results[index]!.frame).not.toContain("Health"); + } + }); + test("keeps native context, memories, notes and archives when MC compaction is off", () => { + expect(results[3]!.frame).toContain("native compaction"); + expect(results[3]!.frame).toContain("45% left"); + row(3, "Memories", "225"); + row(3, "Notes", "4"); + row(3, "Archived compartments", "491"); + expect(results[3]!.frame).not.toMatch( + /History|Prompt cache|Next summary|4\/4 rejected|Fallback/, + ); + }); + test("maps existing visibility preferences without using the stored collapse value", () => { + expect(results[4]!.frame).toContain("CUSTOM"); + expect(results[4]!.frame).toContain("Context"); + expect(results[4]!.frame).not.toMatch(/History|Prompt cache|Knowledge|Dreamer/); + expect(results[4]!.collapsed).toBe(true); + }); + test("unknown usage says no reading and does not show 100% left", () => { + expect(results[6]!.frame).toContain("no reading"); + expect(results[5]!.frame).toContain("no reading"); + expect(results[6]!.frame).not.toContain("100% left"); + }); + test("shows due at the proactive floor and one active rebuild row", () => { + row(7, "Next summary", "due"); + row(8, "Upgrade", "25%"); + expect(results[8]!.frame.match(/Upgrade/g)).toHaveLength(1); + }); + test("renders errors and other warnings with only one muted hint", () => { + row(9, "Historian", "last run failed"); + row(9, "Dreamer", "2 tasks failed"); + row(9, "Config", "reload failed"); + row(9, "Last run", "2h30m ago · failed"); + row(14, "Historian", "last run failed"); + tone(9, "last run failed", "#ff0000"); + tone(9, "Fixture transform failure", "#ff0000"); + tone(9, "2 tasks failed", "#ff8800"); + tone(9, "reload failed", "#ff8800"); + expect(results[9]!.frame).toContain("Host"); + expect(results[9]!.frame.match(/\/ctx-status shows details/g)).toHaveLength(1); + expect(results[9]!.frame).not.toContain("⚠"); + }); + test("uses the execute threshold and force band for the gauge tone", () => { + tone(0, "45% left", "#00ff00"); + tone(10, "35% left", "#ff8800"); + tone(11, "15% left", "#ff0000"); + expect(results[12]!.frame).toContain("summarizing"); + }); + test("updates the gauge tone when the host replaces its theme", () => { + tone(13, "45% left", "#00aaff"); + }); + test("the raw-source frame bundle draws the same expanded view", () => { + expect(rawResult.frame).toBe(results[0]!.frame); + expect(rawResult.lines).toEqual(results[0]!.lines); + }); + test("keeps the badge readable when the host background matches its accent", () => { + tone(15, "MAGIC CONTEXT", "#000000"); + }); +}); diff --git a/packages/plugin/src/tui/slots/sidebar-content.tsx b/packages/plugin/src/tui/slots/sidebar-content.tsx index bed90777a1..f5065e5271 100644 --- a/packages/plugin/src/tui/slots/sidebar-content.tsx +++ b/packages/plugin/src/tui/slots/sidebar-content.tsx @@ -1,16 +1,22 @@ /** @jsxImportSource @opentui/solid */ -import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" -import type { TuiSlotPlugin, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui" -import packageJson from "../../../package.json" -import { badgeTextColor } from '../badge-contrast'; -import { loadSidebarSnapshot, type SidebarSnapshot } from "../data/context-db" -import { directoryForSession } from "../data/session-directory" -import { createRefreshScheduler } from "../data/refresh-scheduler" -import { formatThresholdPercent } from "../../shared/format-threshold" -import { renderUserFacingFailure } from "../../shared/user-facing-codes" -import { compactionOffSidebarRows, nativeCompactionContextLabel } from "../compaction-off" + +import type { TuiPluginApi, TuiSlotPlugin, TuiThemeCurrent } from "@opencode-ai/plugin/tui"; +import { + createContext, + createEffect, + createMemo, + createSignal, + For, + on, + onCleanup, + Show, + useContext, +} from "solid-js"; +import packageJson from "../../../package.json"; +import { formatThresholdPercent } from "../../shared/format-threshold"; import { computeEffectiveOrder, + DEFAULT_PREFS, DEFAULT_SLOT_ORDER, type MagicContextTuiPrefs, PLUGIN_KEY, @@ -19,896 +25,517 @@ import { readTuiPreferencesFileSync, resolveMagicContextPrefs, watchTuiPreferences, -} from "../../shared/tui-preferences" +} from "../../shared/tui-preferences"; +import { renderUserFacingFailure } from "../../shared/user-facing-codes"; +import { badgeTextColor } from "../badge-contrast"; +import { compactionOffSidebarRows } from "../compaction-off"; +import { loadSidebarSnapshot, type SidebarSnapshot } from "../data/context-db"; +import { createRefreshScheduler } from "../data/refresh-scheduler"; +import { directoryForSession } from "../data/session-directory"; +import { SidebarFrame, SidebarSection, SidebarStatRow, SidebarTextRow } from "../quota-sidebar.js"; // Recomp commands do not send parent-session message events. -let activeRecompPollKick: (() => void) | null = null -let activeSidebarRefresh: (() => void) | null = null +let activeRecompPollKick: (() => void) | null = null; +let activeSidebarRefresh: (() => void) | null = null; export function kickRecompProgressRefresh(): void { - activeRecompPollKick?.() + activeRecompPollKick?.(); } /** Ask the mounted sidebar to fetch an out-of-band status update now. */ export function refreshSidebarSnapshot(): void { - activeSidebarRefresh?.() + activeSidebarRefresh?.(); } -const SINGLE_BORDER = { type: "single" } as any - export interface SidebarController { - prefs: () => MagicContextTuiPrefs - collapsed: () => boolean - toggleCollapsed: () => void - dispose: () => void + prefs: () => MagicContextTuiPrefs; + collapsed: () => boolean; + toggleCollapsed: () => void; + dispose: () => void; } -// The TUI may unmount and remount sidebar_content when the user switches views -// (main -> subagent -> main). A remount re-runs the component body, so a signal -// created inside the component would reset to its seed. The controller lives in -// the slot-factory closure (plugin/process lifetime) and owns the durable -// prefs/collapse signals plus the single shared file watcher, so collapse state -// and live pref reloads survive remounts. No Solid effects/memos here — those -// need an owner; the poll-interval effect stays inside the component. +// The controller outlives slot remounts, so preference changes survive a switch +// from the main session to a subagent view and back. function createSidebarController(initialPrefs: MagicContextTuiPrefs): SidebarController { - const [prefs, setPrefs] = createSignal(initialPrefs) + const [prefs, setPrefs] = createSignal(initialPrefs); const seedCollapsed = initialPrefs.rememberCollapsed && initialPrefs.collapsed != null ? initialPrefs.collapsed - : initialPrefs.startCollapsed - const [collapsed, setCollapsed] = createSignal(seedCollapsed) - let lastPersistedCollapsed: boolean | null = initialPrefs.collapsed - let lastApplied = JSON.stringify(initialPrefs) + : initialPrefs.startCollapsed; + const [collapsed, setCollapsed] = createSignal(seedCollapsed); + let lastPersistedCollapsed: boolean | null = initialPrefs.collapsed; + let lastApplied = JSON.stringify(initialPrefs); - // Collapse echo guard: lastPersistedCollapsed advances only once our own - // write lands, so a watcher echo of the value we just wrote is rejected by - // the `!==` check and cannot revert a user click. const stopWatchingPreferences = watchTuiPreferences(() => { void (async () => { - const next = resolveMagicContextPrefs(await readTuiPreferencesFile()) - const serialized = JSON.stringify(next) - if (serialized === lastApplied) return - lastApplied = serialized - setPrefs(next) + const next = resolveMagicContextPrefs(await readTuiPreferencesFile()); + const serialized = JSON.stringify(next); + if (serialized === lastApplied) return; + lastApplied = serialized; + setPrefs(next); + // Ignore the file watcher's echo after a local preference write. if ( next.rememberCollapsed && next.collapsed != null && next.collapsed !== lastPersistedCollapsed ) { - lastPersistedCollapsed = next.collapsed - setCollapsed(next.collapsed) + lastPersistedCollapsed = next.collapsed; + setCollapsed(next.collapsed); } - })() - }) + })(); + }); function toggleCollapsed() { - const next = !collapsed() - setCollapsed(next) + const next = !collapsed(); + setCollapsed(next); if (prefs().rememberCollapsed) { void queueTuiPreferenceUpdate(PLUGIN_KEY, ["collapsed"], next).then(() => { - lastPersistedCollapsed = next - }) + lastPersistedCollapsed = next; + }); } } - return { - prefs, - collapsed, - toggleCollapsed, - dispose: stopWatchingPreferences, - } + return { prefs, collapsed, toggleCollapsed, dispose: stopWatchingPreferences }; } function compactTokens(value: number): string { - if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M` - if (value >= 1_000) return `${(value / 1_000).toFixed(0)}K` - // Token counts are whole numbers to the reader even when the tokenizer - // calibration leaves them fractional; a raw `522.4` beside a `63K` reads as - // a measurement error rather than as precision. - return String(Math.round(value)) -} - -/** - * Sidebar form of the tail-hygiene reading: the reclaimable share and the - * two masses it is computed from, in the same compact token unit as the - * breakdown rows so the value fits beside its label at sidebar width. The - * long, unit-spelled form stays in the status dialog. - */ -function hygieneValue(status: { severity: number; u: number; t: number }): string { - return `${(status.severity * 100).toFixed(1)}% · ${compactTokens(status.u)}/${compactTokens(status.t)}` + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(0)}K`; + return String(Math.round(value)); } function relativeTime(ms: number): string { - const diff = Date.now() - ms - if (diff < 60_000) return "just now" - if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago` - if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago` - return `${Math.floor(diff / 86_400_000)}d ago` + const minutes = Math.max(0, Math.floor((Date.now() - ms) / 60_000)); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h${minutes % 60 ? `${minutes % 60}m` : ""} ago`; + return `${hours}h ago`; } -// Text progress bar, e.g. [██████░░░░] for the recomp/upgrade live indicator. -function progressBar(fraction: number, width = 14): string { - const clamped = Math.max(0, Math.min(1, fraction)) - const filled = Math.round(clamped * width) - return `[${"█".repeat(filled)}${"░".repeat(width - filled)}]` +function modelName(model: string | null | undefined): string { + if (!model) return "—"; + const id = model.split("/").pop() ?? model; + return id + .replace(/-(\d{8})$/, "") + .split("-") + .map((word) => { + if (/^deepseek$/i.test(word)) return "DeepSeek"; + if (/^gpt$/i.test(word)) return "GPT"; + return word.charAt(0).toUpperCase() + word.slice(1); + }) + .join(" "); } -// Token breakdown segment colors (hardcoded hex values) -const COLORS = { - // Cool / structured — injected by the plugin into message[0] - system: "#c084fc", // Purple - docs: "#22d3ee", // Cyan — - compartments: "#60a5fa", // Blue - facts: "#fbbf24", // Yellow/orange - memories: "#34d399", // Green - profile: "#a3e635", // Lime — - // Warm / user-facing — regular chat and tool traffic. Grouped visually - // by hue family so the user reads them as a related block. - conversation: "#f87171", // Red - toolCalls: "#fb923c", // Orange - toolDefs: "#f472b6", // Pink +function shortText(text: string): string { + const line = text.replace(/\s+/g, " ").trim(); + return line.length > 32 ? `${line.slice(0, 31)}…` : line; } -interface TokenSegment { - key: string - tokens: number - color: string - label: string +function present(value: number | null | undefined): value is number { + return typeof value === "number" && Number.isFinite(value); } -// Segmented token breakdown bar with legend -const TokenBreakdown = (props: { - theme: TuiThemeCurrent - snapshot: SidebarSnapshot - // Collapsed mode renders only the proportional bar (no per-category legend - // rows) so the sidebar shrinks to the progress bar + a few summary lines. - collapsed?: boolean -}) => { - // The bar is rendered as a flex row of colored boxes, each with - // flexGrow=tokens and flexBasis=0. opentui distributes the parent - // container's full width proportionally, so the bar always fills the - // sidebar regardless of terminal size. No hardcoded width is needed — - // this fixes both the over-wide bar that wrapped onto a second line on - // narrow sidebars (issue #90) and the under-wide bar that left empty - // space on the right on wide sidebars. - const segments = createMemo(() => { - const s = props.snapshot - const total = s.inputTokens || 1 - const result: TokenSegment[] = [] - - // System Prompt (purple) - if (s.systemPromptTokens > 0) { - result.push({ - key: "sys", - tokens: s.systemPromptTokens, - color: COLORS.system, - label: "System", - }) - } - - // Docs (cyan) — injected block (ARCHITECTURE/STRUCTURE) - if (s.docsTokens > 0) { - result.push({ - key: "docs", - tokens: s.docsTokens, - color: COLORS.docs, - label: "Docs", - }) - } - - // Compartments (blue) - if (s.compartmentTokens > 0) { - result.push({ - key: "comp", - tokens: s.compartmentTokens, - color: COLORS.compartments, - label: "Compartments", - }) - } - - // Facts (yellow/orange) - if (s.factTokens > 0) { - result.push({ - key: "fact", - tokens: s.factTokens, - color: COLORS.facts, - label: "Facts", - }) - } - - // Memories (green) - if (s.memoryTokens > 0) { - result.push({ - key: "mem", - tokens: s.memoryTokens, - color: COLORS.memories, - label: "Memories", - }) - } - - // User Profile (lime) — injected block (promoted user memories) - if (s.profileTokens > 0) { - result.push({ - key: "profile", - tokens: s.profileTokens, - color: COLORS.profile, - label: "User Profile", - }) - } - - // Conversation = real user/assistant text/reasoning/images - // (excludes injected session-history and excludes tool call I/O). - // - // Always show this row even when conversationTokens === 0. The - // calibrator's residual-distribution math (tokenizer-calibration.ts) - // can round it down to zero when toolCallsLocal massively dominates - // conversationLocal — that's a calibration artifact, not a real - // "zero conversation". Suppressing the row leaves the legend looking - // truncated, which is more confusing than showing a 0% line. The - // segment is also skipped in the bar at 0 width because the segment - // builder uses `Math.max(1, ...)` only when tokens > 0 (see - // segmentWidths), so the visual bar stays correct either way. - result.push({ - key: "conv", - tokens: s.conversationTokens, - color: COLORS.conversation, - label: "Conversation", - }) - - // Tool Calls = tool_use/tool_result/tool/tool-invocation parts in messages - // (actionable — users can reduce via ctx_reduce) - if (s.toolCallTokens > 0) { - result.push({ - key: "tool-calls", - tokens: s.toolCallTokens, - color: COLORS.toolCalls, - label: "Tool Calls", - }) - } - - // Tool Definitions = measured description + JSON-schema parameters for - // each tool OpenCode sends in the `tools` request parameter, populated - // by the `tool.definition` plugin hook keyed by {provider, model, agent}. - // Zero until the first turn measures the active agent's tool set. - if (s.toolDefinitionTokens > 0) { - result.push({ - key: "tool-defs", - tokens: s.toolDefinitionTokens, - color: COLORS.toolDefs, - label: "Tool Defs", - }) - } - - return result - }) - - const totalTokens = createMemo(() => props.snapshot.inputTokens || 1) - - // Render-time segments for the bar. Zero-token segments are filtered out - // entirely (no flex weight, no rendered box) so they don't claim any - // width. Non-zero segments still get a Math.max(1, ...) floor on - // flexGrow so very small contributions remain visible as a thin sliver. - // The legend rows below show every segment (including zeros) for table - // stability — only the bar prunes them. - const barSegments = createMemo(() => - segments().filter((seg) => seg.tokens > 0), - ) - +// The frame does not export SidebarGaugeRow (common-auth#12). +const GaugeTheme = + createContext< + () => Pick + >(); + +function SidebarGaugeRow(props: { + label: string; + used: number | undefined; + value?: string; + warnAt?: number; + errorAt?: number; +}) { + const theme = useContext(GaugeTheme); + const known = () => present(props.used); + const used = () => Math.max(0, Math.min(100, props.used ?? 0)); + const filled = () => Math.round(used() / 10); + const tone = () => + !known() + ? theme?.().textMuted + : used() >= (props.errorAt ?? 80) + ? theme?.().error + : used() >= (props.warnAt ?? 50) + ? theme?.().warning + : theme?.().success; return ( - - {/* Segmented bar: a width="100%" flex row of colored boxes, - each with flexGrow proportional to its token count and - flexBasis=0. opentui distributes the parent's full width - proportionally, so the bar always fills the sidebar - regardless of terminal size. Height is fixed at 1 row; - backgroundColor renders the colored bar. */} - - {barSegments().map((seg) => ( - - ))} + + + {props.label} + + {known() + ? `${"█".repeat(filled())}${"░".repeat(10 - filled())} ${Math.round(100 - used())}% left` + : "no reading"} + - - {/* Legend rows — suppressed in collapsed mode (bar only) */} - {!props.collapsed && ( - - {segments().map((seg) => { - const pct = ((seg.tokens / totalTokens()) * 100).toFixed(0) - return ( - - {seg.label} - - {compactTokens(seg.tokens)} ({pct}%) - - - ) - })} - - )} - - ) -} - -const StatRow = (props: { - theme: TuiThemeCurrent - label: string - value: string - accent?: boolean - warning?: boolean - dim?: boolean -}) => { - const fg = createMemo(() => { - if (props.warning) return props.theme.warning - if (props.accent) return props.theme.accent - if (props.dim) return props.theme.textMuted - return props.theme.text - }) - - return ( - - {props.label} - - {props.value} + + {props.value ?? "—"} - ) -} - -const SectionHeader = (props: { theme: TuiThemeCurrent; title: string }) => ( - - - {props.title} - - -) - -// Live recomp progress. Renders while a rebuild runs (and briefly after it -// finishes) so a multi-minute rebuild is visible instead of a single missed -// toast (dogfood 2026-05-30). -const RecompProgressSection = (props: { - theme: TuiThemeCurrent - progress: NonNullable -}) => { - // CRITICAL: read `props.progress` reactively on every access — do NOT - // destructure it into a local `const p = props.progress` at creation time. - // The parent keeps THIS component instance mounted as the phase advances - // (recomp → migration → done), so a frozen `p` would render the - // creation-time phase forever — the sidebar stuck on "upgrading / Running - // historian (pass 1)…" even though the upgrade finished. Each accessor below - // tracks the parent signal so the label/bar/note update live (root cause of - // the dogfood 2026-05-30 "recomp upgrading stays" freeze). - const phase = () => props.progress.phase - const fraction = () => - props.progress.totalMessages > 0 - ? props.progress.processedMessages / props.progress.totalMessages - : 0 - const pct = () => Math.round(fraction() * 100) - - // "Recomp" vs "Upgrade" vs "Embed" wording follows the flow that started this - // run, so a plain /ctx-recomp never renders as an "Upgrade" (dogfood 2026-06-04). - const verb = () => - props.progress.kind === "upgrade" - ? "Upgrade" - : props.progress.kind === "embed" - ? "Embed" - : props.progress.kind === "wrapup" - ? "Wrapup" - : "Recomp" - const activeText = () => - props.progress.kind === "upgrade" - ? "upgrading ⟳" - : props.progress.kind === "embed" - ? "embedding ⟳" - : props.progress.kind === "wrapup" - ? "wrapping ⟳" - : "comparting ⟳" - const label = createMemo(() => { - switch (props.progress.phase) { - case "recomp": - return { - text: activeText(), - color: props.theme.warning, - } - case "migration": - return { text: "Migrating memories ⟳", color: props.theme.warning } - case "done": - return { text: `✓ ${verb()} complete`, color: props.theme.success ?? props.theme.accent } - case "skipped": - // Neutral terse status next to the bold verb header; the full, - // self-contained reason (lease-busy "try again shortly" vs a - // partial-stall "run /ctx-embed start again") renders on its own - // line below. Don't re-prepend verb here (it's already the bold - // header — doing so read as "EmbedEmbed"), and don't hardcode - // "retry shortly" (wrong for a partial stall). - return { text: "stopped", color: props.theme.textMuted } - case "failed": - return { text: `✗ ${verb()} failed`, color: props.theme.error } - } - }) - - return ( - <> - - - {verb()} - - {label().text} - - {/* Determinate bar during the compartment-rebuild phase. */} - {phase() === "recomp" && props.progress.totalMessages > 0 && ( - - {progressBar(fraction())} - {pct()}% - - )} - {/* Transient status note (e.g. "Starting…", "Trying fallback - sonnet-4-6…", "Repair retry…") — surfaces live activity during a - long pass, including before the determinate range is known. */} - {(phase() === "recomp" || phase() === "migration") && props.progress.note && ( - {props.progress.note} - )} - {phase() === "recomp" && props.progress.kind !== "embed" && ( - - )} - {phase() === "recomp" && props.progress.kind === "embed" && ( - - )} - {/* Terminal reason (failed/skipped) — kept visible so the user sees - WHY (a failure, or the transient "retry shortly" skip cause). */} - {(phase() === "failed" || phase() === "skipped") && props.progress.message && ( - {props.progress.message} - )} - - ) + ); } const SidebarContent = (props: { - api: TuiPluginApi - sessionID: () => string - theme: TuiThemeCurrent - controller: SidebarController + api: TuiPluginApi; + sessionID: () => string; + theme: TuiThemeCurrent; + controller: SidebarController; }) => { - const [snapshot, setSnapshot] = createSignal(null) - // Collapse state + section visibility prefs live in the controller (plugin - // closure), so they survive view-switch remounts and persist across restarts - // via ~/.config/opencode/tui-preferences.jsonc. Read reactively. - const collapsed = props.controller.collapsed - const sections = () => props.controller.prefs().sections - const headerLabel = () => props.controller.prefs().header.label + const [snapshot, setSnapshot] = createSignal(null); + const sections = () => props.controller.prefs().sections; + const headerLabel = () => { + const label = props.controller.prefs().header.label; + return label === DEFAULT_PREFS.header.label ? "MAGIC CONTEXT" : label; + }; const scheduler = createRefreshScheduler({ read: async () => { - const sid = props.sessionID() - if (!sid) return null + const sid = props.sessionID(); + if (!sid) return null; const directory = directoryForSession( props.api.state.session?.get?.(sid)?.directory, props.api.state.path.directory ?? "", - ) - return loadSidebarSnapshot(sid, directory, { throwOnFailure: true }) + ); + return loadSidebarSnapshot(sid, directory, { throwOnFailure: true }); }, onSnapshot: (data) => { - setSnapshot(data) + setSnapshot(data); try { - props.api.renderer.requestRender() + props.api.renderer.requestRender(); } catch { // The renderer can close before the request completes. } }, - isRunning: (data) => Boolean(data?.historianRunning || data?.dreamerProgress || - data?.recompProgress?.phase === "recomp" || data?.recompProgress?.phase === "migration"), - }) - const refresh = () => scheduler.refresh() - const scheduleRefresh = () => scheduler.event() - activeRecompPollKick = refresh - activeSidebarRefresh = refresh + isRunning: (data) => + Boolean( + data?.historianRunning || + data?.dreamerProgress || + data?.recompProgress?.phase === "recomp" || + data?.recompProgress?.phase === "migration", + ), + }); + const refresh = () => scheduler.refresh(); + const scheduleRefresh = () => scheduler.event(); + activeRecompPollKick = refresh; + activeSidebarRefresh = refresh; onCleanup(() => { - scheduler.dispose() - if (activeRecompPollKick === refresh) activeRecompPollKick = null - if (activeSidebarRefresh === refresh) activeSidebarRefresh = null - }) + scheduler.dispose(); + if (activeRecompPollKick === refresh) activeRecompPollKick = null; + if (activeSidebarRefresh === refresh) activeSidebarRefresh = null; + }); - // Refresh on session change createEffect( on(props.sessionID, () => { - setSnapshot(null) - scheduler.reset() + setSnapshot(null); + scheduler.reset(); }), - ) - - // Subscribe to events for live updates + ); createEffect( on( props.sessionID, (sessionID) => { const unsubs = [ props.api.event.on("message.updated", (event) => { - if (event.properties.info.sessionID !== sessionID) return - scheduleRefresh() + if (event.properties.info.sessionID === sessionID) scheduleRefresh(); }), props.api.event.on("session.updated", (event) => { - if (event.properties.info.id !== sessionID) return - scheduleRefresh() + if (event.properties.info.id === sessionID) scheduleRefresh(); }), props.api.event.on("message.removed", (event) => { - if (event.properties.sessionID !== sessionID) return - scheduleRefresh() + if (event.properties.sessionID === sessionID) scheduleRefresh(); }), - ] - + ]; onCleanup(() => { - for (const unsub of unsubs) unsub() - }) + for (const unsub of unsubs) unsub(); + }); }, { defer: false }, ), - ) - - const s = createMemo(() => snapshot()) - const compactionOff = () => s()?.compaction_enabled === false - const contextSummaryColor = createMemo(() => { - if (compactionOff()) return props.theme.accent - const usage = s()?.usagePercentage ?? 0 - if (usage >= 80) return props.theme.error - if (usage >= 65) return props.theme.warning - return props.theme.accent - }) + ); + + const s = snapshot; + const compactionOff = () => s()?.compaction_enabled === false; + const tokenValue = () => { + const data = s(); + return data && present(data.inputTokens) && data.contextLimit > 0 + ? `${compactTokens(data.inputTokens)}/${compactTokens(data.contextLimit)}` + : "—"; + }; + const contextUsage = () => { + const data = s(); + if (!data || data.contextLimit <= 0) return undefined; + return compactionOff() + ? (data.inputTokens / data.contextLimit) * 100 + : present(data.usagePercentage) + ? data.usagePercentage + : undefined; + }; + const nextSummary = () => { + const data = s(); + if ( + !data || + !present(data.proactiveFloor) || + !present(data.usagePercentage) || + !present(data.inputTokens) || + data.contextLimit <= 0 + ) + return "—"; + if (data.usagePercentage >= data.proactiveFloor) return "due"; + const remaining = Math.max( + 0, + (data.contextLimit * data.proactiveFloor) / 100 - data.inputTokens, + ); + return `at ${formatThresholdPercent(data.proactiveFloor)}% · +${compactTokens(remaining)}`; + }; + const values = createMemo(() => { + const data = s(); + const run = data?.historianRun; + const cacheAt = data?.cachedM0MaterializedAt; + const drops = data?.queuedDropTokens; + const lastDream = data?.lastDreamerRunAt; + return { + lastRun: run ? `${relativeTime(run.finishedAt)} · ${run.outcome}` : "—", + model: modelName(run?.model), + waiting: present(data?.waitingMessages) ? `${data?.waitingMessages} msgs` : "—", + cacheBuilt: present(cacheAt) ? relativeTime(cacheAt) : "—", + sinceThen: + present(data?.summariesSinceCache) && present(data?.memoriesSinceCache) + ? `${data?.summariesSinceCache} summaries · ${data?.memoriesSinceCache} memories` + : "—", + drops: + present(data?.pendingOpsCount) && present(drops) + ? `${data?.pendingOpsCount} · ≈${compactTokens(drops)} at rebuild` + : "—", + memories: present(data?.memoryCount) + ? `${data?.memoryCount} · ${present(data?.memoriesSinceCache) ? data?.memoriesSinceCache : "—"} new` + : "—", + notes: + present(data?.sessionNoteCount) && present(data?.readySmartNoteCount) + ? `${data?.sessionNoteCount} · ${data?.readySmartNoteCount} smart waiting` + : "—", + dreamerLastRun: present(lastDream) + ? `${relativeTime(lastDream)} · ${data?.dreamerFailures?.length ? "failed" : "ok"}` + : "—", + nativeRows: data + ? compactionOffSidebarRows(data).filter((row) => + row.label === "Memories" ? sections().memory : sections().status, + ) + : [], + }; + }); + const progress = () => { + const p = s()?.recompProgress; + return p?.phase === "recomp" || p?.phase === "migration" ? p : undefined; + }; + const progressLabel = () => + ({ recomp: "Recomp", upgrade: "Upgrade", embed: "Embed", wrapup: "Wrap-up" })[ + progress()?.kind ?? "recomp" + ]; + const progressValue = () => { + const p = progress(); + return p && p.totalMessages > 0 + ? `${Math.round(Math.max(0, Math.min(1, p.processedMessages / p.totalMessages)) * 100)}%` + : "no reading"; + }; + const healthRows = createMemo(() => { + const data = s(); + const rows: Array<{ label: string; value: string; tone: "warning" | "error" }> = []; + if (!data) return rows; + const historian = data.historianRun; + if (!compactionOff()) { + const counts = historian?.sinceLastClean; + if (counts && counts.primaryRejections > 0) + rows.push({ + label: "Historian", + value: `${modelName(historian?.primaryModel).split(" ")[0]} ${counts.primaryRejections}/${counts.primaryAttempts} rejected`, + tone: "warning", + }); + if (counts && counts.fallbackRuns > 0) + rows.push({ + label: "Fallback", + value: `${modelName(historian?.model).split(" ")[0]} finished ${counts.fallbackRuns} runs`, + tone: "warning", + }); + if (historian?.outcome === "failed" || (data.historianFailureCount ?? 0) > 0) + rows.push({ + label: "Historian", + value: "last run failed", + tone: "error", + }); + } + if (data.dreamerFailures?.length) + rows.push({ + label: "Dreamer", + value: `${data.dreamerFailures.length} tasks failed`, + tone: "warning", + }); + if (data.lastTransformError) + rows.push({ + label: "Transform", + value: shortText(data.lastTransformError), + tone: "error", + }); + for (const limitation of data.hostLimitations ?? []) + rows.push({ + label: "Host", + value: shortText(renderUserFacingFailure(limitation, "plain")), + tone: "warning", + }); + if (data.configReloadFailure) + rows.push({ label: "Config", value: "reload failed", tone: "warning" }); + return rows; + }); return ( - - {/* Header: triangle toggle + badge + version. Clicking the row - collapses/expands the panel (mirrors OpenCode's native MCP - sidebar section and AFT's sidebar). */} - props.controller.toggleCollapsed()} - > - - - {collapsed() ? "▶ " : "▼ "}{headerLabel()} - - - v{packageJson.version} - - - {/* The fence probe writes the failure into the server-owned snapshot, - so this survives sidebar refreshes and is visible independently of - the one-shot toast. */} - {s()?.lastTransformError && ( - - ⚠ {s()!.lastTransformError} - - )} - - {/* Named limitations of the host itself (for example an experimental - mode this OpenCode version cannot run). They stay for as long as - the process runs, so they are drawn like the transform error - rather than as a one-shot toast. */} - - {(limitation) => ( - - ⚠ {renderUserFacingFailure(limitation, "plain")} - - )} - - - {s()?.dreamerProgress && ( - - - Dreamer {s()!.dreamerProgress!.task}: {s()!.dreamerProgress!.processed}/{s()!.dreamerProgress!.total} processed - - - )} - - {/* Token breakdown bar. In collapsed mode the header, bar and the - 3 summary rows stack with no vertical padding for a compact look; - expanded mode keeps the 1-row gap above the bar. */} - {s() && s()!.inputTokens > 0 && ( - - {(s()?.contextLimit ?? 0) > 0 && ( - - {compactionOff() ? ( - - {nativeCompactionContextLabel(s()!)} - - ) : ( - - {s()!.usagePercentage.toFixed(1)}% / {formatThresholdPercent(s()!.executeThreshold)}%{s()!.executeThresholdClamped ? "*" : ""} - - )} - {/* Right: absolute token usage against the usable - scheduler window — the same denominator as the - percentage and nudge/trigger scheduling. */} - - {compactTokens(s()!.inputTokens)} / {compactTokens(s()!.contextLimit)} - - - )} - - {s()!.tailHygiene !== undefined && ( - - )} - - )} - - {/* Collapsed view — progress bar (above) + 3 summary lines: - Historian (with compartment count), Memories (injected/total), - Status (Q=queued ops, N=session notes). */} - {collapsed() && ( - - {compactionOff() ? ( - compactionOffSidebarRows(s()!).map((row) => ( - props.theme}> + {}} + sections={() => ( + <> + + - )) - ) : ( - <> - - Historian - {s()?.historianRunning ? ( - comparting ⟳ - ) : ( - idle - )} - - - {(progress) => ( - - Dreamer - - {progress().task} {progress().processed}/{progress().total} - - - )} - - - Memories - - {(s()?.memoryBlockCount ?? 0) > 0 - ? `${s()!.memoryBlockCount}/${s()?.memoryCount ?? 0}` - : String(s()?.memoryCount ?? 0)} - - - - Status - - C:{s()?.compartmentCount ?? 0} Q:{s()?.pendingOpsCount ?? 0} N:{s()?.sessionNoteCount ?? 0} - - - - {(progress) => ( - - )} + } + > + - - )} - - )} - - {/* Expanded view — full section grid. */} - {!collapsed() && ( - <> - {/* Historian section */} - {!compactionOff() && sections().historian && ( - <> - - - Historian - - {s()?.historianRunning ? ( - comparting ⟳ - ) : ( - idle - )} - - - - {/* Recomp live progress */} - - {(progress) => ( - - )} - - - )} - - {/* Memory section */} - {sections().memory && ( - <> - - {compactionOff() ? ( - compactionOffSidebarRows(s()!) - .filter((row) => row.label === "Memories") - .map((row) => ( - - )) - ) : ( - <> - - {(s()?.memoryBlockCount ?? 0) > 0 && ( - - )} - - )} - - )} - - {/* Queue & Status */} - {sections().status && - (compactionOff() || - (s()?.pendingOpsCount ?? 0) > 0 || - (s()?.sessionNoteCount ?? 0) > 0 || - (s()?.readySmartNoteCount ?? 0) > 0) && ( - <> - - {compactionOff() ? ( - compactionOffSidebarRows(s()!) - .filter((row) => row.label !== "Memories") - .map((row) => ( - - )) - ) : ( - <> - {(s()?.pendingOpsCount ?? 0) > 0 && ( - - )} - {(s()?.sessionNoteCount ?? 0) > 0 && ( - - )} - {(s()?.readySmartNoteCount ?? 0) > 0 && ( - + + + + + + + + - )} - - )} + + + + + + + + + + + + + + + + + + + + + } + > + + {(row) => ( + + )} + + + + + + + + + {(p: () => NonNullable) => ( + + )} + + + + 0}> + + + {(row) => ( + + )} + + + + )} - - {/* Dreamer */} - {sections().dreamer && (s()?.lastDreamerRunAt || s()?.dreamerProgress) && ( - <> - - - {(progress) => ( - - )} - - - {(lastRunAt) => ( - - )} - - - {([task, backlog]) => ( - - )} - - - )} - - {/* Stats — v0.21.8 ships a single "Total tokens" number while we - figure out how to present the new-work / reprocessed - categorization without confusing users. The underlying - snapshot fields (newWorkTokens, totalInputTokens) and the - session_meta columns are still populated; only the UI is - simplified for now. */} - {sections().stats && s()?.totalInputTokens != null && ( - <> - - - - )} - - )} - - ) -} - -export function createSidebarContentSlot(api: TuiPluginApi): TuiSlotPlugin & { dispose: () => void } { - // Seed synchronously at slot construction so the sidebar renders at its - // final collapse state + order on the first paint (no async flicker). The - // controller lives here in the factory closure for the plugin lifetime, so - // collapse state and live pref reloads survive sidebar_content remounts. - const seedRoot = readTuiPreferencesFileSync() - const controller = createSidebarController(resolveMagicContextPrefs(seedRoot)) - const effectiveOrder = computeEffectiveOrder(seedRoot, PLUGIN_KEY, DEFAULT_SLOT_ORDER) + /> + + ); +}; + +export function createSidebarContentSlot( + api: TuiPluginApi, +): TuiSlotPlugin & { dispose: () => void } { + const seedRoot = readTuiPreferencesFileSync(); + const controller = createSidebarController(resolveMagicContextPrefs(seedRoot)); + const effectiveOrder = computeEffectiveOrder(seedRoot, PLUGIN_KEY, DEFAULT_SLOT_ORDER); return { order: effectiveOrder, dispose: controller.dispose, slots: { sidebar_content: (ctx, value) => { - const theme = createMemo(() => ctx.theme.current) + const theme = createMemo(() => ctx.theme.current); return ( - ) + ); }, }, - } + }; } diff --git a/packages/plugin/src/tui/tui-compiled-runtime-imports.test.ts b/packages/plugin/src/tui/tui-compiled-runtime-imports.test.ts index 2a81f672b8..c8294c299d 100644 --- a/packages/plugin/src/tui/tui-compiled-runtime-imports.test.ts +++ b/packages/plugin/src/tui/tui-compiled-runtime-imports.test.ts @@ -38,7 +38,7 @@ describe("compiled TUI runtime imports", () => { const full = join(dir, entry); if (statSync(full).isDirectory()) { found.push(...compiledFiles(full)); - } else if (entry.endsWith(".ts") || entry.endsWith(".tsx")) { + } else if (entry.endsWith(".ts") || entry.endsWith(".tsx") || entry.endsWith(".js")) { found.push(full); } } From 69cf37b8380adcf61c35b059d46db99adf6c06f9 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:39:18 +0000 Subject: [PATCH 5/8] test(sidebar): hide the render helper console on Windows --- packages/plugin/src/tui/sidebar-render.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/plugin/src/tui/sidebar-render.test.ts b/packages/plugin/src/tui/sidebar-render.test.ts index 708e269721..56dd5c2523 100644 --- a/packages/plugin/src/tui/sidebar-render.test.ts +++ b/packages/plugin/src/tui/sidebar-render.test.ts @@ -145,6 +145,7 @@ beforeAll(() => { stdout: "pipe", stderr: "pipe", env: { ...process.env, BUN_BE_BUN: "1" }, + windowsHide: true, }, ); if (child.exitCode !== 0) throw new Error(child.stderr.toString()); @@ -162,6 +163,7 @@ beforeAll(() => { stdout: "pipe", stderr: "pipe", env: { ...process.env, BUN_BE_BUN: "1" }, + windowsHide: true, }, ); if (raw.exitCode !== 0) throw new Error(raw.stderr.toString()); From 0f27af91a2ec1ac5d065e93b94f91fc75d6d3306 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 12 Nov 2026 06:51:37 +0000 Subject: [PATCH 6/8] feat(tui): adopt shared sidebar and collapsed view Move the sidebar frame from @cortexkit/common-auth/quota-sidebar to @cortexkit/opencode-sidebar 0.2.0. Use its SidebarGaugeRow for the context gauge in place of the local gauge, and its collapsible prop to collapse the panel to one row for each section (Context, Historian, Cache, and Health only when something is wrong). The render snapshots store each row as an exact string, so trailing cells are compared and no line ends in whitespace. --- bun.lock | 14 +- packages/plugin/package.json | 2 +- packages/plugin/scripts/build-tui.ts | 2 +- .../plugin/scripts/render-compiled-sidebar.ts | 8 +- .../src/tui-compiled/quota-sidebar.d.ts | 3 +- .../plugin/src/tui-compiled/quota-sidebar.js | 701 ++++----- .../tui-compiled/slots/sidebar-content.tsx | 560 ++++--- .../__snapshots__/sidebar-render.test.ts.snap | 1372 +++++++++++++++++ packages/plugin/src/tui/quota-sidebar.d.ts | 3 +- packages/plugin/src/tui/quota-sidebar.js | 701 ++++----- .../plugin/src/tui/sidebar-render.test.ts | 39 +- .../plugin/src/tui/slots/sidebar-content.tsx | 353 +++-- 12 files changed, 2436 insertions(+), 1322 deletions(-) create mode 100644 packages/plugin/src/tui/__snapshots__/sidebar-render.test.ts.snap diff --git a/bun.lock b/bun.lock index 3b8df9ebbd..7c5b969a15 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": {}, "packages/cli": { "name": "@cortexkit/magic-context", - "version": "0.49.35", + "version": "0.49.52", "bin": { "magic-context": "dist/index.js", }, @@ -77,7 +77,7 @@ }, "packages/pi-plugin": { "name": "@cortexkit/pi-magic-context", - "version": "0.49.35", + "version": "0.49.52", "dependencies": { "@cortexkit/subc-client": "0.11.1", "@jitl/quickjs-singlefile-cjs-release-asyncify": "0.32.0", @@ -137,9 +137,9 @@ }, "packages/plugin": { "name": "@cortexkit/opencode-magic-context", - "version": "0.49.35", + "version": "0.49.52", "dependencies": { - "@cortexkit/common-auth": "^0.38.0", + "@cortexkit/opencode-sidebar": "^0.2.0", "@cortexkit/subc-client": "0.11.1", "@jitl/quickjs-singlefile-cjs-release-asyncify": "0.32.0", "@opencode-ai/plugin": "^1.17.11", @@ -440,8 +440,6 @@ "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@0.5.0", "", { "dependencies": { "@cortexkit/subc-client": "^0.16.0" } }, "sha512-+mRG/h3YZufrEs1p12W+Y+NSnWIUiBcpppMTgFA5tAQ7t8WIWoMEmn0Mf7Cp8251SKChW8dN3xOZqEM7N6gyAg=="], - "@cortexkit/common-auth": ["@cortexkit/common-auth@0.38.0", "", { "dependencies": { "@cortexkit/private-files": "^0.2.2", "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.6.2", "@cortexkit/subc-client": "^0.16.0 || ^0.22.0", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.4.5", "@opentui/solid": ">=0.4.5", "solid-js": ">=1.9.12 <2" }, "optionalPeers": ["@cortexkit/claustrum-client", "@cortexkit/subc-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-YDXtwuuj/gz4h/bPO+UAVItjo3ZMYw9DhccEf4DWAk8dr+Rk/uo/cVUeTRbC4xXE7FXTeBr1HaRnyG7drZIg3w=="], - "@cortexkit/magic-context": ["@cortexkit/magic-context@workspace:packages/cli"], "@cortexkit/magic-context-dashboard": ["@cortexkit/magic-context-dashboard@workspace:packages/dashboard"], @@ -450,9 +448,9 @@ "@cortexkit/opencode-magic-context-e2e": ["@cortexkit/opencode-magic-context-e2e@workspace:packages/e2e-tests"], - "@cortexkit/pi-magic-context": ["@cortexkit/pi-magic-context@workspace:packages/pi-plugin"], + "@cortexkit/opencode-sidebar": ["@cortexkit/opencode-sidebar@0.2.0", "", { "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/solid": ">=0.4.5", "solid-js": ">=1.9.12 <2" } }, "sha512-jSLsnLbdBPYQgJZCJSN4LbzxYh8wZv4gAaM4D1mm3pqF0QaBvq7CKpYH7VeUn1u3zxxuHbzRkXC46DR/b7W/+g=="], - "@cortexkit/private-files": ["@cortexkit/private-files@0.2.2", "", {}, "sha512-BdRxi4mzPkTUiZxKTlQV/RsESk5f34+sLk0qQLv1QlBQdkq2zw8yc0b3dm1qmZ5RfeChiBKEGgL6Qw31RCSaeA=="], + "@cortexkit/pi-magic-context": ["@cortexkit/pi-magic-context@workspace:packages/pi-plugin"], "@cortexkit/retina-local-fs": ["@cortexkit/retina-local-fs@workspace:packages/retina-local-fs"], diff --git a/packages/plugin/package.json b/packages/plugin/package.json index d39bd7afe6..3b357e4c36 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -57,7 +57,7 @@ "//check:tui-compiled": "Check drift on output already generated by build or build:tui; this check never regenerates files. CI and prepublishOnly build first.", "//test": "Unit shards run small behavioral fixtures. The nightly/check-branch slow-unit lane runs full scale and latency gates in fresh processes once per mode, not once per shard. JSC's optimizing WebAssembly tier stays disabled in tests; production host options are unchanged.", "dependencies": { - "@cortexkit/common-auth": "^0.38.0", + "@cortexkit/opencode-sidebar": "^0.2.0", "@cortexkit/subc-client": "0.11.1", "@jitl/quickjs-singlefile-cjs-release-asyncify": "0.32.0", "@opencode-ai/plugin": "^1.17.11", diff --git a/packages/plugin/scripts/build-tui.ts b/packages/plugin/scripts/build-tui.ts index f2fbd2d1fa..e5c43f3b10 100644 --- a/packages/plugin/scripts/build-tui.ts +++ b/packages/plugin/scripts/build-tui.ts @@ -205,7 +205,7 @@ const files = await listSourceFiles(sourceRoot); async function bundleSidebar(hostRuntime: boolean): Promise { const outputFile = join(hostRuntime ? outputRoot : sourceRoot, sidebarBundleName); const result = await Bun.build({ - entrypoints: [fileURLToPath(import.meta.resolve("@cortexkit/common-auth/quota-sidebar"))], + entrypoints: [fileURLToPath(import.meta.resolve("@cortexkit/opencode-sidebar"))], target: "bun", format: "esm", external: [...runtimeSpecifiers], diff --git a/packages/plugin/scripts/render-compiled-sidebar.ts b/packages/plugin/scripts/render-compiled-sidebar.ts index 52fec6887a..0110912601 100644 --- a/packages/plugin/scripts/render-compiled-sidebar.ts +++ b/packages/plugin/scripts/render-compiled-sidebar.ts @@ -11,7 +11,7 @@ type RenderCase = { snapshot: SidebarSnapshot | null; width?: number; preferences?: Record; - changeTheme?: boolean; + toggleHeader?: boolean; matchingBadgeColors?: boolean; }; @@ -78,7 +78,7 @@ for (const entry of inputs) { preferences = entry.preferences ?? {}; const prefs = resolveMagicContextPrefs(preferences); const published = Promise.withResolvers(); - const [currentTheme, setTheme] = createSignal( + const [currentTheme] = createSignal( entry.matchingBadgeColors ? { ...theme, background: theme.accent } : theme, ); const slot = createSidebarContentSlot({ @@ -115,8 +115,8 @@ for (const entry of inputs) { ), ]); for (let pass = 0; pass < 4; pass++) await setup.renderOnce(); - if (entry.changeTheme) { - setTheme({ ...theme, success: RGBA.fromHex("#00aaff") }); + if (entry.toggleHeader) { + await setup.mockMouse.click(2, 0); for (let pass = 0; pass < 3; pass++) await setup.renderOnce(); } const spans = setup.captureSpans(); diff --git a/packages/plugin/src/tui-compiled/quota-sidebar.d.ts b/packages/plugin/src/tui-compiled/quota-sidebar.d.ts index 9d3aa23d07..b6478d7b9e 100644 --- a/packages/plugin/src/tui-compiled/quota-sidebar.d.ts +++ b/packages/plugin/src/tui-compiled/quota-sidebar.d.ts @@ -1,6 +1,7 @@ export { SidebarFrame, + SidebarGaugeRow, SidebarSection, SidebarStatRow, SidebarTextRow, -} from "@cortexkit/common-auth/quota-sidebar"; +} from "@cortexkit/opencode-sidebar"; diff --git a/packages/plugin/src/tui-compiled/quota-sidebar.js b/packages/plugin/src/tui-compiled/quota-sidebar.js index 8c0b60916f..e0e2355ba0 100644 --- a/packages/plugin/src/tui-compiled/quota-sidebar.js +++ b/packages/plugin/src/tui-compiled/quota-sidebar.js @@ -1,7 +1,7 @@ // @bun -// ../../node_modules/.bun/@cortexkit+common-auth@0.38.0+79ed038a4f852143/node_modules/@cortexkit/common-auth/src/quota-sidebar/index.tsx -import { createTextNode as _$createTextNode } from "opentui:runtime-module:%40opentui%2Fsolid"; +// ../../node_modules/.bun/@cortexkit+opencode-sidebar@0.2.0+4f25175b99662f85/node_modules/@cortexkit/opencode-sidebar/src/index.tsx import { createComponent as _$createComponent } from "opentui:runtime-module:%40opentui%2Fsolid"; +import { createTextNode as _$createTextNode } from "opentui:runtime-module:%40opentui%2Fsolid"; import { use as _$use } from "opentui:runtime-module:%40opentui%2Fsolid"; import { memo as _$memo } from "opentui:runtime-module:%40opentui%2Fsolid"; import { effect as _$effect } from "opentui:runtime-module:%40opentui%2Fsolid"; @@ -10,253 +10,6 @@ import { insert as _$insert } from "opentui:runtime-module:%40opentui%2Fsolid"; import { setProp as _$setProp } from "opentui:runtime-module:%40opentui%2Fsolid"; import { createElement as _$createElement } from "opentui:runtime-module:%40opentui%2Fsolid"; import { createContext, createMemo, createSignal, For, Index, mergeProps, Show, useContext } from "opentui:runtime-module:solid-js"; - -// ../../node_modules/.bun/@cortexkit+common-auth@0.38.0+79ed038a4f852143/node_modules/@cortexkit/common-auth/src/quota/map.ts -var ALL_SCOPE = "all"; -var DEFAULT_REQUIRED_LABELS = Object.freeze([ - "primary" -]); - -// ../../node_modules/.bun/@cortexkit+common-auth@0.38.0+79ed038a4f852143/node_modules/@cortexkit/common-auth/src/quota/format.ts -var QUOTA_STALE_AFTER_MS = 15 * 60000; -var NO_QUOTA_READING = "no quota reading yet"; -var NO_LIMITS_REPORTED = "no limits reported"; -function shortSpan(ms) { - const minutes = Math.max(1, Math.floor(ms / 60000)); - if (minutes < 60) - return `${minutes}m`; - if (minutes < 1440) { - const hours = Math.floor(minutes / 60); - const rest = minutes % 60; - return rest === 0 ? `${hours}h` : `${hours}h${rest}m`; - } - const days = Math.floor(minutes / 1440); - const hours = Math.floor(minutes % 1440 / 60); - return hours === 0 ? `${days}d` : `${days}d${hours}h`; -} -function lengthName(minutes) { - if (minutes % 1440 === 0) - return `${minutes / 1440}d`; - if (minutes % 60 === 0) - return `${minutes / 60}h`; - return `${minutes}m`; -} -function quotaWindowName(limit) { - if (limit.scope !== ALL_SCOPE || limit.windowMinutes === undefined) - return limit.label; - return lengthName(limit.windowMinutes); -} -function clampPercent(remaining) { - return Math.min(100, Math.max(0, remaining)); -} -function wholePercent(remaining) { - return Math.round(clampPercent(remaining)); -} -function percentLeft(remaining) { - return `${wholePercent(remaining)}% left`; -} -function resetDetails(resetsAt, checkedAt, now) { - const at = resetsAt === undefined ? Number.NaN : Date.parse(resetsAt); - return { - ...Number.isFinite(at) && at > now ? { resetIn: shortSpan(at - now) } : {}, - resetSinceReading: Number.isFinite(at) && at > checkedAt && at <= now - }; -} -function displayOrder(left, right) { - const l = left.windowMinutes; - const r = right.windowMinutes; - if (l !== undefined && r !== undefined && l !== r) - return l - r; - if (l === undefined !== (r === undefined)) - return l === undefined ? 1 : -1; - if (left.scope !== right.scope) { - if (left.scope === ALL_SCOPE) - return -1; - if (right.scope === ALL_SCOPE) - return 1; - return left.scope < right.scope ? -1 : 1; - } - return left.label < right.label ? -1 : left.label > right.label ? 1 : 0; -} -function quotaWindowParts(quota, options) { - const { windows, age } = scopedWindowParts(quota, options, true); - const parts = windows.map(({ part }) => part); - return age === undefined ? { windows: parts } : { windows: parts, age }; -} -function withShortNames(windows) { - const abbreviations = scopeAbbreviations(windows.filter(({ part, scope }) => part.kind === "window" && scope !== ALL_SCOPE).map(({ scope }) => scope)); - for (const window of windows) { - window.part.short = window.part.kind === "budget" ? "cr" : window.scope === ALL_SCOPE ? window.part.name : abbreviations.get(window.scope) ?? window.scope; - } - return windows; -} -function hasWindowReading(limit) { - return Number.isFinite(limit.remainingPercent) && Number.isFinite(limit.checkedAt); -} -function scopedWindowParts(quota, options, includeUnknown = false) { - if (!quota) - return { windows: [] }; - const { now } = options; - const readings = quota.limits.filter((limit) => limit.kind === "reading" && (includeUnknown || hasWindowReading(limit))).sort(displayOrder); - const windows = readings.map((limit) => { - const part = hasWindowReading(limit) ? { - kind: "window", - reading: "known", - name: quotaWindowName(limit), - short: "", - figure: percentLeft(limit.remainingPercent), - remainingPercent: clampPercent(limit.remainingPercent), - checkedAt: limit.checkedAt, - ...resetDetails(limit.resetsAt, limit.checkedAt, now) - } : { - kind: "window", - reading: "unknown", - name: quotaWindowName(limit), - short: "", - figure: "no reading", - resetSinceReading: false - }; - return { - scope: limit.scope, - ...limit.windowMinutes === undefined ? {} : { windowMinutes: limit.windowMinutes }, - part - }; - }); - const budget = quota.budget; - if (budget) { - const remainingPercent = budget.remainingPercent; - const figure = budget.reached ? "credits spent" : remainingPercent !== undefined ? `credits ${percentLeft(remainingPercent)}` : "credits available"; - windows.push({ - scope: ALL_SCOPE, - spent: budget.reached, - part: { - kind: "budget", - reading: "known", - name: "credits", - short: "", - figure, - ...remainingPercent === undefined ? {} : { remainingPercent: clampPercent(remainingPercent) }, - checkedAt: budget.checkedAt, - ...resetDetails(budget.resetsAt, budget.checkedAt, now) - } - }); - } - withShortNames(windows); - const readingTimes = windows.flatMap(({ part }) => part.reading === "known" && part.checkedAt !== undefined ? [part.checkedAt] : []); - if (readingTimes.length > 0) { - const age = now - Math.min(...readingTimes); - if (age > (options.staleAfterMs ?? QUOTA_STALE_AFTER_MS)) { - return { windows, age: `checked ${shortSpan(age)} ago` }; - } - } - return { windows }; -} -function scopeDisplayName(scope) { - const model = scope.slice(scope.indexOf(":") + 1); - return model.charAt(0).toUpperCase() + model.slice(1); -} -function scopeAbbreviations(scopes) { - const names = [...new Set(scopes.map(scopeDisplayName))]; - const lengths = new Map(names.map((name) => [name, 2])); - const short = (name) => name.slice(0, lengths.get(name)); - for (;; ) { - const byShort = new Map; - for (const name of names) { - byShort.set(short(name), [...byShort.get(short(name)) ?? [], name]); - } - let extended = false; - for (const group of byShort.values()) { - if (group.length < 2) - continue; - for (const name of group) { - const length = lengths.get(name) ?? 2; - if (length < name.length) { - lengths.set(name, length + 1); - extended = true; - } - } - } - if (!extended) - break; - } - return new Map(scopes.map((scope) => [scope, short(scopeDisplayName(scope))])); -} -var NARROW_GROUP_LIMIT = 2; -function groupShortNames(groups) { - const labels = groups.map((group) => group.label.trim() || group.scope); - const short = scopeAbbreviations(labels); - return labels.map((label) => short.get(label) ?? label); -} -function groupedNarrowPieces(groups, windows) { - if (!groups || groups.length === 0) - return; - const reported = windows.filter(({ part }) => part.kind === "window"); - if (reported.some(({ scope }) => scope === ALL_SCOPE)) - return; - const candidates = groups.flatMap((group, listed) => { - const inGroup = reported.filter(({ scope }) => scope === group.scope); - const groupTightest = inGroup.reduce((least, window) => least === undefined || (window.part.remainingPercent ?? 0) < (least.part.remainingPercent ?? 0) ? window : least, undefined); - return groupTightest ? [{ group, listed, window: groupTightest }] : []; - }); - if (candidates.length === 0) - return; - const names = groupShortNames(groups); - const left = (candidate) => candidate.window.part.remainingPercent ?? 0; - const shown = [...candidates].sort((a, b) => left(a) - left(b) || a.listed - b.listed).slice(0, NARROW_GROUP_LIMIT).sort((a, b) => a.listed - b.listed); - return shown.map((candidate, index) => { - const { windowMinutes } = candidate.window; - const piece = [ - names[candidate.listed] ?? candidate.group.scope, - ...windowMinutes === undefined ? [] : [lengthName(windowMinutes)], - `${wholePercent(left(candidate))}%` - ].join(" "); - return index === shown.length - 1 ? `${piece} left` : piece; - }); -} -function narrowTextParts(quota, options) { - const { windows } = scopedWindowParts(quota, options); - const grouped = groupedNarrowPieces(quota?.groups, windows); - if (grouped) - return grouped; - const general = windows.filter(({ part, scope }) => part.kind === "window" && scope === ALL_SCOPE); - const scoped = windows.filter(({ part, scope }) => part.kind === "window" && scope !== ALL_SCOPE); - const budget = windows.find(({ part }) => part.kind === "budget"); - const budgetLeft = budget?.spent ? 0 : budget?.part.remainingPercent; - const extras = [ - ...scoped.map(({ part }) => ({ - label: part.short, - remaining: part.remainingPercent ?? 0 - })), - ...budgetLeft === undefined || budget === undefined ? [] : [{ label: budget.part.short, remaining: budgetLeft }] - ]; - const tightest = extras.reduce((best, item) => best === undefined || item.remaining < best.remaining ? item : best, undefined); - const pieces = general.map(({ part }) => `${part.name} ${wholePercent(part.remainingPercent ?? 0)}%`); - if (tightest) { - pieces.push(`${tightest.label} ${wholePercent(tightest.remaining)}%`); - } - if (pieces.length === 0) - return windows.map(({ part }) => part.figure); - return pieces.map((piece, index) => index === pieces.length - 1 ? `${piece} left` : piece); -} -function quotaTextParts(quota, options) { - if (options.form === "narrow") - return narrowTextParts(quota, options); - const { windows, age } = scopedWindowParts(quota, options); - const full = (options.form ?? "full") === "full"; - const text = windows.map(({ part }) => { - const reset = full && part.resetIn ? `, resets ${part.resetIn}` : ""; - return `${part.kind === "budget" ? "" : `${part.name} `}${part.figure}${reset}`; - }); - return age === undefined ? text : [...text, age]; -} -function formatQuota(quota, options) { - const parts = quotaTextParts(quota, options); - if (parts.length > 0) - return parts.join(" \xB7 "); - return quota && quota.limits.length > 0 ? NO_LIMITS_REPORTED : NO_QUOTA_READING; -} - -// ../../node_modules/.bun/@cortexkit+common-auth@0.38.0+79ed038a4f852143/node_modules/@cortexkit/common-auth/src/quota-sidebar/index.tsx var FrameContext = createContext(); function frameContext() { const context = useContext(FrameContext); @@ -391,6 +144,119 @@ function barSegments(used, appearance, pace) { tone }]; } +function SidebarGaugeRow(props) { + const context = frameContext(); + const [columns, setColumns] = createSignal(0); + let row; + const known = () => props.used !== undefined && Number.isFinite(props.used); + const used = () => Math.max(0, Math.min(100, props.used ?? 0)); + const figure = () => known() ? `${Math.round(100 - used())}% left` : "no reading"; + const appearance = () => ({ + ...context.appearance, + warnThreshold: props.warnAt ?? context.appearance.warnThreshold, + errorThreshold: props.errorAt ?? context.appearance.errorThreshold + }); + const label = () => columns() <= 0 ? props.label : fitText(props.label, columns() - displayWidth(figure()) - 1); + const barCells = () => { + if (!known()) + return 0; + const desired = context.appearance.barWidth; + if (columns() <= 0) + return desired; + const room = columns() - displayWidth(label()) - 1 - displayWidth(figure()) - 1; + return room >= Math.min(4, desired) ? Math.min(desired, room) : 0; + }; + const segments = createMemo(() => barSegments(used(), { + ...appearance(), + barWidth: barCells() + }), undefined, { + equals: (previous, next) => previous.every((segment, index) => segment.text === next[index]?.text && segment.tone === next[index]?.tone) + }); + const value = () => { + if (!props.value) + return ""; + if (columns() <= 0) + return props.value; + const room = columns() - displayWidth(label()) - 1 - displayWidth(figure()) - (barCells() > 0 ? barCells() + 1 : 0) - 1; + return room >= 2 ? fitText(props.value, room) : ""; + }; + const tone = () => known() ? usageTone(used(), appearance()) : "textMuted"; + return (() => { + var _el$9 = _$createElement("box"), _el$0 = _$createElement("box"), _el$1 = _$createElement("text"), _el$10 = _$createTextNode(` `), _el$13 = _$createElement("text"); + _$insertNode(_el$9, _el$0); + _$use((element) => { + row = element; + }, _el$9); + _$setProp(_el$9, "onSizeChange", () => setColumns(row?.width ?? 0)); + _$setProp(_el$9, "width", "100%"); + _$setProp(_el$9, "flexDirection", "row"); + _$setProp(_el$9, "justifyContent", "space-between"); + _$insertNode(_el$0, _el$1); + _$insertNode(_el$0, _el$13); + _$setProp(_el$0, "flexDirection", "row"); + _$setProp(_el$0, "flexShrink", 0); + _$insertNode(_el$1, _el$10); + _$setProp(_el$1, "wrapMode", "none"); + _$insert(_el$1, label, _el$10); + _$insert(_el$0, _$createComponent(Show, { + get when() { + return barCells() > 0; + }, + get children() { + return [_$createComponent(Index, { + get each() { + return segments(); + }, + children: (segment) => (() => { + var _el$16 = _$createElement("text"); + _$setProp(_el$16, "wrapMode", "none"); + _$insert(_el$16, () => segment().text); + _$effect((_p$) => { + var _v$7 = context.theme[segment().tone], _v$8 = displayWidth(segment().text); + _v$7 !== _p$.e && (_p$.e = _$setProp(_el$16, "fg", _v$7, _p$.e)); + _v$8 !== _p$.t && (_p$.t = _$setProp(_el$16, "width", _v$8, _p$.t)); + return _p$; + }, { + e: undefined, + t: undefined + }); + return _el$16; + })() + }), (() => { + var _el$11 = _$createElement("text"); + _$insertNode(_el$11, _$createTextNode(` `)); + _$setProp(_el$11, "wrapMode", "none"); + return _el$11; + })()]; + } + }), _el$13); + _$setProp(_el$13, "wrapMode", "none"); + _$insert(_el$13, figure); + _$insert(_el$9, _$createComponent(Show, { + get when() { + return value(); + }, + get children() { + var _el$14 = _$createElement("text"), _el$15 = _$createElement("b"); + _$insertNode(_el$14, _el$15); + _$setProp(_el$14, "wrapMode", "none"); + _$insert(_el$15, value); + _$effect((_$p) => _$setProp(_el$14, "fg", context.theme.text, _$p)); + return _el$14; + } + }), null); + _$effect((_p$) => { + var _v$5 = context.theme.textMuted, _v$6 = context.theme[tone()]; + _v$5 !== _p$.e && (_p$.e = _$setProp(_el$1, "fg", _v$5, _p$.e)); + _v$6 !== _p$.t && (_p$.t = _$setProp(_el$13, "fg", _v$6, _p$.t)); + return _p$; + }, { + e: undefined, + t: undefined + }); + return _el$9; + })(); +} function QuotaRow(props) { const context = frameContext(); const [columns, setColumns] = createSignal(0); @@ -412,40 +278,40 @@ function QuotaRow(props) { return fitText(text, columns() - props.labelWidth); }; return [(() => { - var _el$9 = _$createElement("box"), _el$0 = _$createElement("box"); - _$insertNode(_el$9, _el$0); + var _el$17 = _$createElement("box"), _el$18 = _$createElement("box"); + _$insertNode(_el$17, _el$18); _$use((element) => { row = element; - }, _el$9); - _$setProp(_el$9, "onSizeChange", () => setColumns(row?.width ?? 0)); - _$setProp(_el$9, "width", "100%"); - _$setProp(_el$9, "flexDirection", "row"); - _$setProp(_el$9, "justifyContent", "space-between"); - _$setProp(_el$0, "flexDirection", "row"); - _$insert(_el$0, _$createComponent(Show, { + }, _el$17); + _$setProp(_el$17, "onSizeChange", () => setColumns(row?.width ?? 0)); + _$setProp(_el$17, "width", "100%"); + _$setProp(_el$17, "flexDirection", "row"); + _$setProp(_el$17, "justifyContent", "space-between"); + _$setProp(_el$18, "flexDirection", "row"); + _$insert(_el$18, _$createComponent(Show, { get when() { return props.labelWidth > 0; }, get children() { - var _el$1 = _$createElement("text"); - _$setProp(_el$1, "wrapMode", "none"); - _$setProp(_el$1, "flexShrink", 0); - _$insert(_el$1, label); - _$effect((_$p) => _$setProp(_el$1, "fg", context.theme.textMuted, _$p)); - return _el$1; + var _el$19 = _$createElement("text"); + _$setProp(_el$19, "wrapMode", "none"); + _$setProp(_el$19, "flexShrink", 0); + _$insert(_el$19, label); + _$effect((_$p) => _$setProp(_el$19, "fg", context.theme.textMuted, _$p)); + return _el$19; } }), null); - _$insert(_el$0, _$createComponent(Show, { + _$insert(_el$18, _$createComponent(Show, { get when() { return hasReading(); }, get fallback() { return (() => { - var _el$13 = _$createElement("text"); - _$insertNode(_el$13, _$createTextNode(`no reading`)); - _$setProp(_el$13, "wrapMode", "none"); - _$effect((_$p) => _$setProp(_el$13, "fg", context.theme.textMuted, _$p)); - return _el$13; + var _el$23 = _$createElement("text"); + _$insertNode(_el$23, _$createTextNode(`no reading`)); + _$setProp(_el$23, "wrapMode", "none"); + _$effect((_$p) => _$setProp(_el$23, "fg", context.theme.textMuted, _$p)); + return _el$23; })(); }, get children() { @@ -455,11 +321,11 @@ function QuotaRow(props) { }, get fallback() { return (() => { - var _el$15 = _$createElement("text"); - _$insertNode(_el$15, _$createTextNode(`reset since last check`)); - _$setProp(_el$15, "wrapMode", "none"); - _$effect((_$p) => _$setProp(_el$15, "fg", context.theme.textMuted, _$p)); - return _el$15; + var _el$25 = _$createElement("text"); + _$insertNode(_el$25, _$createTextNode(`reset since last check`)); + _$setProp(_el$25, "wrapMode", "none"); + _$effect((_$p) => _$setProp(_el$25, "fg", context.theme.textMuted, _$p)); + return _el$25; })(); }, get children() { @@ -473,85 +339,85 @@ function QuotaRow(props) { return segments(); }, children: (segment) => (() => { - var _el$17 = _$createElement("text"); - _$setProp(_el$17, "wrapMode", "none"); - _$insert(_el$17, () => segment().text); + var _el$27 = _$createElement("text"); + _$setProp(_el$27, "wrapMode", "none"); + _$insert(_el$27, () => segment().text); _$effect((_p$) => { - var _v$5 = context.theme[segment().tone], _v$6 = segment().text ? undefined : 0; - _v$5 !== _p$.e && (_p$.e = _$setProp(_el$17, "fg", _v$5, _p$.e)); - _v$6 !== _p$.t && (_p$.t = _$setProp(_el$17, "width", _v$6, _p$.t)); + var _v$9 = context.theme[segment().tone], _v$0 = segment().text ? undefined : 0; + _v$9 !== _p$.e && (_p$.e = _$setProp(_el$27, "fg", _v$9, _p$.e)); + _v$0 !== _p$.t && (_p$.t = _$setProp(_el$27, "width", _v$0, _p$.t)); return _p$; }, { e: undefined, t: undefined }); - return _el$17; + return _el$27; })() }); } }), (() => { - var _el$10 = _$createElement("text"); - _$setProp(_el$10, "wrapMode", "none"); - _$insert(_el$10, figure); - _$effect((_$p) => _$setProp(_el$10, "fg", context.theme[usageTone(used(), context.appearance)], _$p)); - return _el$10; + var _el$20 = _$createElement("text"); + _$setProp(_el$20, "wrapMode", "none"); + _$insert(_el$20, figure); + _$effect((_$p) => _$setProp(_el$20, "fg", context.theme[usageTone(used(), context.appearance)], _$p)); + return _el$20; })()]; } }); } }), null); - _$insert(_el$9, _$createComponent(Show, { + _$insert(_el$17, _$createComponent(Show, { get when() { return _$memo(() => !!(hasReading() && props.showReset))() && !props.part.resetSinceReading; }, get children() { - var _el$11 = _$createElement("box"); - _$setProp(_el$11, "flexShrink", 0); - _$setProp(_el$11, "flexDirection", "row"); - _$setProp(_el$11, "justifyContent", "flex-end"); - _$insert(_el$11, _$createComponent(Show, { + var _el$21 = _$createElement("box"); + _$setProp(_el$21, "flexShrink", 0); + _$setProp(_el$21, "flexDirection", "row"); + _$setProp(_el$21, "justifyContent", "flex-end"); + _$insert(_el$21, _$createComponent(Show, { get when() { return props.part.resetIn; }, get children() { - var _el$12 = _$createElement("text"); - _$setProp(_el$12, "wrapMode", "none"); - _$insert(_el$12, () => props.part.resetIn); - _$effect((_$p) => _$setProp(_el$12, "fg", context.theme.textMuted, _$p)); - return _el$12; + var _el$22 = _$createElement("text"); + _$setProp(_el$22, "wrapMode", "none"); + _$insert(_el$22, () => props.part.resetIn); + _$effect((_$p) => _$setProp(_el$22, "fg", context.theme.textMuted, _$p)); + return _el$22; } })); - _$effect((_$p) => _$setProp(_el$11, "width", props.resetWidth, _$p)); - return _el$11; + _$effect((_$p) => _$setProp(_el$21, "width", props.resetWidth, _$p)); + return _el$21; } }), null); - _$effect((_$p) => _$setProp(_el$0, "flexShrink", props.part.resetSinceReading ? 1 : 0, _$p)); - return _el$9; + _$effect((_$p) => _$setProp(_el$18, "flexShrink", props.part.resetSinceReading ? 1 : 0, _$p)); + return _el$17; })(), _$createComponent(Show, { get when() { return _$memo(() => !!(hasReading() && !props.part.resetSinceReading))() && paceLine(); }, keyed: true, children: (line) => (() => { - var _el$18 = _$createElement("box"), _el$20 = _$createElement("text"); - _$insertNode(_el$18, _el$20); - _$setProp(_el$18, "width", "100%"); - _$setProp(_el$18, "flexDirection", "row"); - _$insert(_el$18, _$createComponent(Show, { + var _el$28 = _$createElement("box"), _el$30 = _$createElement("text"); + _$insertNode(_el$28, _el$30); + _$setProp(_el$28, "width", "100%"); + _$setProp(_el$28, "flexDirection", "row"); + _$insert(_el$28, _$createComponent(Show, { get when() { return props.labelWidth > 0; }, get children() { - var _el$19 = _$createElement("text"); - _$insert(_el$19, () => "".padEnd(props.labelWidth)); - _$effect((_$p) => _$setProp(_el$19, "fg", context.theme.textMuted, _$p)); - return _el$19; + var _el$29 = _$createElement("text"); + _$insert(_el$29, () => "".padEnd(props.labelWidth)); + _$effect((_$p) => _$setProp(_el$29, "fg", context.theme.textMuted, _$p)); + return _el$29; } - }), _el$20); - _$setProp(_el$20, "wrapMode", "none"); - _$insert(_el$20, line); - _$effect((_$p) => _$setProp(_el$20, "fg", context.theme[props.pace?.tone ?? "textMuted"], _$p)); - return _el$18; + }), _el$30); + _$setProp(_el$30, "wrapMode", "none"); + _$insert(_el$30, line); + _$effect((_$p) => _$setProp(_el$30, "fg", context.theme[props.pace?.tone ?? "textMuted"], _$p)); + return _el$28; })() })]; } @@ -559,13 +425,8 @@ function CollapsedAccountRow(props) { const context = frameContext(); const [width, setWidth] = createSignal(0); let row; - const hasQuota = () => quotaWindowParts(props.account.quota, { - now: context.now - }).windows.some((part) => part.reading === "known"); - const text = () => hasQuota() ? formatQuota(props.account.quota, { - now: context.now, - form: "narrow" - }) : props.account.emptyQuotaText?.collapsed ?? "\u2014"; + const hasQuota = () => props.account.hasQuota; + const text = () => hasQuota() ? props.account.collapsedQuotaText ?? "" : props.account.emptyQuotaText?.collapsed ?? "\u2014"; const showQuota = () => width() <= 0 || displayWidth(text()) + 4 <= width(); const label = () => { if (width() <= 0) @@ -575,32 +436,32 @@ function CollapsedAccountRow(props) { const muted = () => props.account.collapsed?.muted ?? false; const quotaTone = () => muted() ? "textMuted" : props.account.collapsed?.quotaTone ?? "success"; return (() => { - var _el$21 = _$createElement("box"), _el$23 = _$createElement("box"), _el$26 = _$createElement("text"); - _$insertNode(_el$21, _el$23); + var _el$31 = _$createElement("box"), _el$33 = _$createElement("box"), _el$36 = _$createElement("text"); + _$insertNode(_el$31, _el$33); _$use((element) => { row = element; - }, _el$21); - _$setProp(_el$21, "onSizeChange", () => setWidth(row?.width ?? 0)); - _$setProp(_el$21, "width", "100%"); - _$setProp(_el$21, "flexDirection", "row"); - _$setProp(_el$21, "justifyContent", "space-between"); - _$insert(_el$21, _$createComponent(Show, { + }, _el$31); + _$setProp(_el$31, "onSizeChange", () => setWidth(row?.width ?? 0)); + _$setProp(_el$31, "width", "100%"); + _$setProp(_el$31, "flexDirection", "row"); + _$setProp(_el$31, "justifyContent", "space-between"); + _$insert(_el$31, _$createComponent(Show, { get when() { return label(); }, get children() { - var _el$22 = _$createElement("text"); - _$setProp(_el$22, "wrapMode", "none"); - _$setProp(_el$22, "flexShrink", 1); - _$insert(_el$22, label); - _$effect((_$p) => _$setProp(_el$22, "fg", context.theme[muted() ? "textMuted" : "text"], _$p)); - return _el$22; + var _el$32 = _$createElement("text"); + _$setProp(_el$32, "wrapMode", "none"); + _$setProp(_el$32, "flexShrink", 1); + _$insert(_el$32, label); + _$effect((_$p) => _$setProp(_el$32, "fg", context.theme[muted() ? "textMuted" : "text"], _$p)); + return _el$32; } - }), _el$23); - _$insertNode(_el$23, _el$26); - _$setProp(_el$23, "flexDirection", "row"); - _$setProp(_el$23, "flexShrink", 0); - _$insert(_el$23, _$createComponent(Show, { + }), _el$33); + _$insertNode(_el$33, _el$36); + _$setProp(_el$33, "flexDirection", "row"); + _$setProp(_el$33, "flexShrink", 0); + _$insert(_el$33, _$createComponent(Show, { get when() { return showQuota(); }, @@ -611,34 +472,34 @@ function CollapsedAccountRow(props) { }, get fallback() { return (() => { - var _el$27 = _$createElement("text"); - _$insert(_el$27, text); - _$effect((_$p) => _$setProp(_el$27, "fg", context.theme.textMuted, _$p)); - return _el$27; + var _el$37 = _$createElement("text"); + _$insert(_el$37, text); + _$effect((_$p) => _$setProp(_el$37, "fg", context.theme.textMuted, _$p)); + return _el$37; })(); }, get children() { - var _el$24 = _$createElement("text"), _el$25 = _$createElement("b"); - _$insertNode(_el$24, _el$25); - _$insert(_el$25, text); - _$effect((_$p) => _$setProp(_el$24, "fg", context.theme[quotaTone()], _$p)); - return _el$24; + var _el$34 = _$createElement("text"), _el$35 = _$createElement("b"); + _$insertNode(_el$34, _el$35); + _$insert(_el$35, text); + _$effect((_$p) => _$setProp(_el$34, "fg", context.theme[quotaTone()], _$p)); + return _el$34; } }); } - }), _el$26); - _$insert(_el$26, () => showQuota() ? " " : "", null); - _$insert(_el$26, () => props.account.blocked ? "\u2298" : "\u25CF", null); + }), _el$36); + _$insert(_el$36, () => showQuota() ? " " : "", null); + _$insert(_el$36, () => props.account.blocked ? "\u2298" : "\u25CF", null); _$effect((_p$) => { - var _v$7 = label() ? 1 : 0, _v$8 = context.theme[props.account.indicatorTone]; - _v$7 !== _p$.e && (_p$.e = _$setProp(_el$21, "columnGap", _v$7, _p$.e)); - _v$8 !== _p$.t && (_p$.t = _$setProp(_el$26, "fg", _v$8, _p$.t)); + var _v$1 = label() ? 1 : 0, _v$10 = context.theme[props.account.indicatorTone]; + _v$1 !== _p$.e && (_p$.e = _$setProp(_el$31, "columnGap", _v$1, _p$.e)); + _v$10 !== _p$.t && (_p$.t = _$setProp(_el$36, "fg", _v$10, _p$.t)); return _p$; }, { e: undefined, t: undefined }); - return _el$21; + return _el$31; })(); } function AccountBlock(props) { @@ -678,35 +539,33 @@ function AccountBlock(props) { const full = `${props.account.label}${props.account.titleSuffix ? ` \xB7 ${props.account.titleSuffix}` : ""}`; return columns() <= 0 ? full : fitText(full, columns() - displayWidth(props.account.status.text) - 1); }; - const age = () => quotaWindowParts(props.account.quota, { - now: context.now - }).age; + const age = () => props.account.quotaAge; const hasRows = () => props.account.groups.some((group) => group.rows.length > 0); return (() => { - var _el$28 = _$createElement("box"), _el$29 = _$createElement("box"), _el$30 = _$createElement("text"), _el$31 = _$createElement("b"), _el$32 = _$createElement("text"), _el$33 = _$createElement("b"); - _$insertNode(_el$28, _el$29); + var _el$38 = _$createElement("box"), _el$39 = _$createElement("box"), _el$40 = _$createElement("text"), _el$41 = _$createElement("b"), _el$42 = _$createElement("text"), _el$43 = _$createElement("b"); + _$insertNode(_el$38, _el$39); _$use((element) => { block = element; - }, _el$28); - _$setProp(_el$28, "onSizeChange", () => setColumns(block?.width ?? 0)); - _$setProp(_el$28, "width", "100%"); - _$setProp(_el$28, "flexDirection", "column"); - _$insertNode(_el$29, _el$30); - _$insertNode(_el$29, _el$32); - _$setProp(_el$29, "width", "100%"); - _$setProp(_el$29, "flexDirection", "row"); - _$setProp(_el$29, "justifyContent", "space-between"); - _$setProp(_el$29, "columnGap", 1); - _$insertNode(_el$30, _el$31); - _$setProp(_el$30, "wrapMode", "none"); - _$setProp(_el$30, "flexShrink", 1); - _$insert(_el$31, label); - _$insertNode(_el$32, _el$33); - _$setProp(_el$32, "wrapMode", "none"); - _$setProp(_el$32, "flexShrink", 0); - _$insert(_el$33, () => props.account.status.text); - _$insert(_el$28, () => context.beforeQuota?.(props.account), null); - _$insert(_el$28, _$createComponent(Show, { + }, _el$38); + _$setProp(_el$38, "onSizeChange", () => setColumns(block?.width ?? 0)); + _$setProp(_el$38, "width", "100%"); + _$setProp(_el$38, "flexDirection", "column"); + _$insertNode(_el$39, _el$40); + _$insertNode(_el$39, _el$42); + _$setProp(_el$39, "width", "100%"); + _$setProp(_el$39, "flexDirection", "row"); + _$setProp(_el$39, "justifyContent", "space-between"); + _$setProp(_el$39, "columnGap", 1); + _$insertNode(_el$40, _el$41); + _$setProp(_el$40, "wrapMode", "none"); + _$setProp(_el$40, "flexShrink", 1); + _$insert(_el$41, label); + _$insertNode(_el$42, _el$43); + _$setProp(_el$42, "wrapMode", "none"); + _$setProp(_el$42, "flexShrink", 0); + _$insert(_el$43, () => props.account.status.text); + _$insert(_el$38, () => context.beforeQuota?.(props.account), null); + _$insert(_el$38, _$createComponent(Show, { get when() { return hasRows(); }, @@ -768,7 +627,7 @@ function AccountBlock(props) { }); } }), null); - _$insert(_el$28, _$createComponent(Show, { + _$insert(_el$38, _$createComponent(Show, { get when() { return age(); }, @@ -779,24 +638,25 @@ function AccountBlock(props) { wrapMode: "none" }) }), null); - _$insert(_el$28, () => context.afterQuota?.(props.account), null); + _$insert(_el$38, () => context.afterQuota?.(props.account), null); _$effect((_p$) => { - var _v$9 = props.marginTop, _v$0 = context.theme.text, _v$1 = context.theme[props.account.status.tone]; - _v$9 !== _p$.e && (_p$.e = _$setProp(_el$28, "marginTop", _v$9, _p$.e)); - _v$0 !== _p$.t && (_p$.t = _$setProp(_el$30, "fg", _v$0, _p$.t)); - _v$1 !== _p$.a && (_p$.a = _$setProp(_el$32, "fg", _v$1, _p$.a)); + var _v$11 = props.marginTop, _v$12 = context.theme.text, _v$13 = context.theme[props.account.status.tone]; + _v$11 !== _p$.e && (_p$.e = _$setProp(_el$38, "marginTop", _v$11, _p$.e)); + _v$12 !== _p$.t && (_p$.t = _$setProp(_el$40, "fg", _v$12, _p$.t)); + _v$13 !== _p$.a && (_p$.a = _$setProp(_el$42, "fg", _v$13, _p$.a)); return _p$; }, { e: undefined, t: undefined, a: undefined }); - return _el$28; + return _el$38; })(); } function SidebarFrame(props) { const hasData = () => props.accounts.length > 0; - const headerLabel = () => !hasData() ? props.header.badge : `${props.collapsed ? "\u25B6" : "\u25BC"} ${props.header.badge}`; + const canCollapse = () => props.collapsible ?? hasData(); + const headerLabel = () => !canCollapse() ? props.header.badge : `${props.collapsed ? "\u25B6" : "\u25BC"} ${props.header.badge}`; const context = mergeProps(props, { get theme() { const theme = props.theme; @@ -814,38 +674,38 @@ function SidebarFrame(props) { return _$createComponent(FrameContext.Provider, { value: context, get children() { - var _el$34 = _$createElement("box"), _el$35 = _$createElement("box"), _el$36 = _$createElement("box"), _el$37 = _$createElement("text"), _el$38 = _$createElement("b"); - _$insertNode(_el$34, _el$35); - _$setProp(_el$34, "width", "100%"); - _$setProp(_el$34, "flexDirection", "column"); - _$insertNode(_el$35, _el$36); - _$setProp(_el$35, "width", "100%"); - _$setProp(_el$35, "flexDirection", "row"); - _$setProp(_el$35, "justifyContent", "space-between"); - _$setProp(_el$35, "alignItems", "center"); - _$setProp(_el$35, "onMouseDown", () => { - if (hasData()) + var _el$44 = _$createElement("box"), _el$45 = _$createElement("box"), _el$46 = _$createElement("box"), _el$47 = _$createElement("text"), _el$48 = _$createElement("b"); + _$insertNode(_el$44, _el$45); + _$setProp(_el$44, "width", "100%"); + _$setProp(_el$44, "flexDirection", "column"); + _$insertNode(_el$45, _el$46); + _$setProp(_el$45, "width", "100%"); + _$setProp(_el$45, "flexDirection", "row"); + _$setProp(_el$45, "justifyContent", "space-between"); + _$setProp(_el$45, "alignItems", "center"); + _$setProp(_el$45, "onMouseDown", () => { + if (canCollapse()) props.onToggle(); }); - _$insertNode(_el$36, _el$37); - _$setProp(_el$36, "paddingLeft", 1); - _$setProp(_el$36, "paddingRight", 1); - _$insertNode(_el$37, _el$38); - _$insert(_el$38, headerLabel); - _$insert(_el$35, _$createComponent(Show, { + _$insertNode(_el$46, _el$47); + _$setProp(_el$46, "paddingLeft", 1); + _$setProp(_el$46, "paddingRight", 1); + _$insertNode(_el$47, _el$48); + _$insert(_el$48, headerLabel); + _$insert(_el$45, _$createComponent(Show, { get when() { return props.header.version; }, get children() { - var _el$39 = _$createElement("text"); - _$insert(_el$39, () => props.header.version); - _$effect((_$p) => _$setProp(_el$39, "fg", context.theme.textMuted, _$p)); - return _el$39; + var _el$49 = _$createElement("text"); + _$insert(_el$49, () => props.header.version); + _$effect((_$p) => _$setProp(_el$49, "fg", context.theme.textMuted, _$p)); + return _el$49; } }), null); - _$insert(_el$34, _$createComponent(Show, { + _$insert(_el$44, _$createComponent(Show, { get when() { - return _$memo(() => !!props.collapsed)() && hasData(); + return _$memo(() => !!props.collapsed)() && canCollapse(); }, get children() { return [_$createComponent(For, { @@ -858,9 +718,9 @@ function SidebarFrame(props) { }), _$memo(() => props.collapsedSections?.())]; } }), null); - _$insert(_el$34, _$createComponent(Show, { + _$insert(_el$44, _$createComponent(Show, { get when() { - return !props.collapsed || !hasData(); + return !props.collapsed || !canCollapse(); }, get children() { return [_$createComponent(Show, { @@ -889,20 +749,21 @@ function SidebarFrame(props) { } }), null); _$effect((_p$) => { - var _v$10 = invertBadge() ? props.theme?.accent : undefined, _v$11 = invertBadge() ? props.theme?.background : context.theme.accent; - _v$10 !== _p$.e && (_p$.e = _$setProp(_el$36, "backgroundColor", _v$10, _p$.e)); - _v$11 !== _p$.t && (_p$.t = _$setProp(_el$37, "fg", _v$11, _p$.t)); + var _v$14 = invertBadge() ? props.theme?.accent : undefined, _v$15 = invertBadge() ? props.theme?.background : context.theme.accent; + _v$14 !== _p$.e && (_p$.e = _$setProp(_el$46, "backgroundColor", _v$14, _p$.e)); + _v$15 !== _p$.t && (_p$.t = _$setProp(_el$47, "fg", _v$15, _p$.t)); return _p$; }, { e: undefined, t: undefined }); - return _el$34; + return _el$44; } }); } export { SidebarFrame, + SidebarGaugeRow, SidebarSection, SidebarStatRow, SidebarTextRow diff --git a/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx b/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx index 64e73e3101..09fde4220b 100644 --- a/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx +++ b/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx @@ -1,14 +1,8 @@ -import { createComponent as _$createComponent } from "opentui:runtime-module:%40opentui%2Fsolid"; -import { effect as _$effect } from "opentui:runtime-module:%40opentui%2Fsolid"; import { memo as _$memo } from "opentui:runtime-module:%40opentui%2Fsolid"; -import { createTextNode as _$createTextNode } from "opentui:runtime-module:%40opentui%2Fsolid"; -import { insertNode as _$insertNode } from "opentui:runtime-module:%40opentui%2Fsolid"; -import { insert as _$insert } from "opentui:runtime-module:%40opentui%2Fsolid"; -import { setProp as _$setProp } from "opentui:runtime-module:%40opentui%2Fsolid"; -import { createElement as _$createElement } from "opentui:runtime-module:%40opentui%2Fsolid"; +import { createComponent as _$createComponent } from "opentui:runtime-module:%40opentui%2Fsolid"; /** @jsxImportSource @opentui/solid */ -import { createContext, createEffect, createMemo, createSignal, For, on, onCleanup, Show, useContext } from "opentui:runtime-module:solid-js"; +import { createEffect, createMemo, createSignal, For, on, onCleanup, Show } from "opentui:runtime-module:solid-js"; import packageJson from "../../../package.json"; import { formatThresholdPercent } from "../../shared/format-threshold"; import { computeEffectiveOrder, DEFAULT_PREFS, DEFAULT_SLOT_ORDER, PLUGIN_KEY, queueTuiPreferenceUpdate, readTuiPreferencesFile, readTuiPreferencesFileSync, resolveMagicContextPrefs, watchTuiPreferences } from "../../shared/tui-preferences"; @@ -18,7 +12,7 @@ import { compactionOffSidebarRows } from "../compaction-off"; import { loadSidebarSnapshot } from "../data/context-db"; import { createRefreshScheduler } from "../data/refresh-scheduler"; import { directoryForSession } from "../data/session-directory"; -import { SidebarFrame, SidebarSection, SidebarStatRow, SidebarTextRow } from "../quota-sidebar.js"; +import { SidebarFrame, SidebarGaugeRow, SidebarSection, SidebarStatRow, SidebarTextRow } from "../quota-sidebar.js"; // Recomp commands do not send parent-session message events. let activeRecompPollKick = null; @@ -98,60 +92,6 @@ function shortText(text) { function present(value) { return typeof value === "number" && Number.isFinite(value); } - -// The frame does not export SidebarGaugeRow (common-auth#12). -const GaugeTheme = createContext(); -function SidebarGaugeRow(props) { - const theme = useContext(GaugeTheme); - const known = () => present(props.used); - const used = () => Math.max(0, Math.min(100, props.used ?? 0)); - const filled = () => Math.round(used() / 10); - const tone = () => !known() ? theme?.().textMuted : used() >= (props.errorAt ?? 80) ? theme?.().error : used() >= (props.warnAt ?? 50) ? theme?.().warning : theme?.().success; - return (() => { - var _el$ = _$createElement("box"), - _el$2 = _$createElement("box"), - _el$3 = _$createElement("text"), - _el$4 = _$createTextNode(` `), - _el$5 = _$createElement("text"), - _el$6 = _$createElement("text"), - _el$7 = _$createElement("b"); - _$insertNode(_el$, _el$2); - _$insertNode(_el$, _el$6); - _$setProp(_el$, "width", "100%"); - _$setProp(_el$, "flexDirection", "row"); - _$setProp(_el$, "justifyContent", "space-between"); - _$setProp(_el$, "columnGap", 1); - _$insertNode(_el$2, _el$3); - _$insertNode(_el$2, _el$5); - _$setProp(_el$2, "flexDirection", "row"); - _$setProp(_el$2, "flexShrink", 0); - _$insertNode(_el$3, _el$4); - _$insert(_el$3, () => props.label, _el$4); - _$setProp(_el$5, "wrapMode", "none"); - _$insert(_el$5, (() => { - var _c$ = _$memo(() => !!known()); - return () => _c$() ? `${"█".repeat(filled())}${"░".repeat(10 - filled())} ${Math.round(100 - used())}% left` : "no reading"; - })()); - _$insertNode(_el$6, _el$7); - _$setProp(_el$6, "wrapMode", "none"); - _$setProp(_el$6, "flexShrink", 1); - _$insert(_el$7, () => props.value ?? "—"); - _$effect(_p$ => { - var _v$ = theme?.().textMuted, - _v$2 = tone(), - _v$3 = theme?.().text; - _v$ !== _p$.e && (_p$.e = _$setProp(_el$3, "fg", _v$, _p$.e)); - _v$2 !== _p$.t && (_p$.t = _$setProp(_el$5, "fg", _v$2, _p$.t)); - _v$3 !== _p$.a && (_p$.a = _$setProp(_el$6, "fg", _v$3, _p$.a)); - return _p$; - }, { - e: undefined, - t: undefined, - a: undefined - }); - return _el$; - })(); -} const SidebarContent = props => { const [snapshot, setSnapshot] = createSignal(null); const sections = () => props.controller.prefs().sections; @@ -301,247 +241,252 @@ const SidebarContent = props => { }); return rows; }); - return _$createComponent(GaugeTheme.Provider, { - value: () => props.theme, - get children() { - return _$createComponent(SidebarFrame, { - get header() { - return { - badge: headerLabel(), - version: `v${packageJson.version}` - }; - }, - accounts: [], - showQuota: false, - get theme() { - return { - text: props.theme.text, - textMuted: props.theme.textMuted, - accent: props.theme.accent, - success: props.theme.success, - warning: props.theme.warning, - error: props.theme.error, - background: badgeTextColor(props.theme.accent, props.theme.background), - borderActive: props.theme.borderActive - }; - }, - appearance: { - barWidth: 10, - barFilledChar: "█", - barEmptyChar: "░", - warnThreshold: 65, - errorThreshold: 85 - }, - get now() { - return Date.now(); - }, - collapsed: false, - onToggle: () => {}, - sections: () => [_$createComponent(SidebarSection, { - title: "Context", + const collapsedContext = () => { + const used = contextUsage(); + return present(used) ? `${Math.round(100 - Math.max(0, Math.min(100, used)))}% left ●` : "no reading ●"; + }; + const collapsedHistorian = () => { + const data = s(); + if (!data) return "no reading ●"; + const run = data.historianRun; + const state = run?.outcome === "failed" || run?.outcome === "fallback" ? run.outcome : data.historianRunning || data.compartmentInProgress ? "summarizing" : "idle"; + return `${state}${run ? ` · ${relativeTime(run.finishedAt).replace(/ ago$/, "")}` : ""} ●`; + }; + return _$createComponent(SidebarFrame, { + get header() { + return { + badge: headerLabel(), + version: `v${packageJson.version}` + }; + }, + accounts: [], + showQuota: false, + get theme() { + return { + text: props.theme.text, + textMuted: props.theme.textMuted, + accent: props.theme.accent, + success: props.theme.success, + warning: props.theme.warning, + error: props.theme.error, + background: badgeTextColor(props.theme.accent, props.theme.background), + borderActive: props.theme.borderActive + }; + }, + appearance: { + barWidth: 10, + barFilledChar: "█", + barEmptyChar: "░", + warnThreshold: 65, + errorThreshold: 85 + }, + get now() { + return Date.now(); + }, + collapsible: true, + get collapsed() { + return props.controller.collapsed(); + }, + get onToggle() { + return props.controller.toggleCollapsed; + }, + collapsedSections: () => [_$createComponent(SidebarStatRow, { + label: "Context", + get value() { + return collapsedContext(); + } + }), _$createComponent(Show, { + get when() { + return _$memo(() => !!!compactionOff())() && sections().historian; + }, + get children() { + return _$createComponent(SidebarStatRow, { + label: "Historian", + get value() { + return collapsedHistorian(); + } + }); + } + }), _$createComponent(Show, { + get when() { + return _$memo(() => !!!compactionOff())() && sections().status; + }, + get children() { + return _$createComponent(SidebarStatRow, { + label: "Cache", + get value() { + return _$memo(() => !!present(s()?.pendingOpsCount))() ? `${s()?.pendingOpsCount} drops queued ●` : "no reading ●"; + } + }); + } + }), _$createComponent(Show, { + get when() { + return healthRows().length > 0; + }, + get children() { + return _$createComponent(SidebarStatRow, { + label: "Health", + get value() { + return `${healthRows().length} ${healthRows().length === 1 ? "issue" : "issues"} ●`; + }, + get tone() { + return healthRows().some(row => row.tone === "error") ? "error" : "warning"; + } + }); + } + })], + sections: () => [_$createComponent(SidebarSection, { + title: "Context", + get children() { + return [_$createComponent(SidebarGaugeRow, { + label: "ctx", + get used() { + return contextUsage(); + }, + get value() { + return tokenValue(); + }, + get warnAt() { + return _$memo(() => !!compactionOff())() ? 100 : s()?.executeThreshold; + }, + get errorAt() { + return compactionOff() ? 100 : 85; + } + }), _$createComponent(Show, { + get when() { + return !compactionOff(); + }, + get fallback() { + return _$createComponent(SidebarTextRow, { + text: "native compaction" + }); + }, get children() { - return [_$createComponent(SidebarGaugeRow, { - label: "ctx", - get used() { - return contextUsage(); - }, + return _$createComponent(SidebarStatRow, { + label: "Next summary", get value() { - return tokenValue(); - }, - get warnAt() { - return _$memo(() => !!compactionOff())() ? 100 : s()?.executeThreshold; - }, - get errorAt() { - return compactionOff() ? 100 : 85; + return nextSummary(); + } + }); + } + })]; + } + }), _$createComponent(Show, { + get when() { + return _$memo(() => !!!compactionOff())() && sections().historian; + }, + get children() { + return _$createComponent(SidebarSection, { + title: "History", + get children() { + return [_$createComponent(SidebarStatRow, { + label: "Historian", + get value() { + return _$memo(() => !!s())() ? s()?.historianRunning || s()?.compartmentInProgress ? "summarizing" : "idle" : "—"; + } + }), _$createComponent(SidebarStatRow, { + label: "Last run", + get value() { + return values().lastRun; + } + }), _$createComponent(SidebarStatRow, { + label: "Model", + get value() { + return values().model; + } + }), _$createComponent(SidebarStatRow, { + label: "Waiting", + get value() { + return values().waiting; } }), _$createComponent(Show, { get when() { - return !compactionOff(); - }, - get fallback() { - return _$createComponent(SidebarTextRow, { - text: "native compaction" - }); + return progress(); }, get children() { return _$createComponent(SidebarStatRow, { - label: "Next summary", + get label() { + return progressLabel(); + }, get value() { - return nextSummary(); + return progressValue(); } }); } })]; } - }), _$createComponent(Show, { - get when() { - return _$memo(() => !!!compactionOff())() && sections().historian; - }, + }); + } + }), _$createComponent(Show, { + get when() { + return _$memo(() => !!!compactionOff())() && sections().status; + }, + get children() { + return _$createComponent(SidebarSection, { + title: "Prompt cache", get children() { - return _$createComponent(SidebarSection, { - title: "History", - get children() { - return [_$createComponent(SidebarStatRow, { - label: "Historian", - get value() { - return _$memo(() => !!s())() ? s()?.historianRunning || s()?.compartmentInProgress ? "summarizing" : "idle" : "—"; - } - }), _$createComponent(SidebarStatRow, { - label: "Last run", - get value() { - return values().lastRun; - } - }), _$createComponent(SidebarStatRow, { - label: "Model", - get value() { - return values().model; - } - }), _$createComponent(SidebarStatRow, { - label: "Waiting", - get value() { - return values().waiting; - } - }), _$createComponent(Show, { + return [_$createComponent(SidebarStatRow, { + label: "Cache built", + get value() { + return values().cacheBuilt; + } + }), _$createComponent(SidebarStatRow, { + label: "Since then", + get value() { + return values().sinceThen; + } + }), _$createComponent(SidebarStatRow, { + label: "Queued drops", + get value() { + return values().drops; + } + })]; + } + }); + } + }), _$createComponent(Show, { + get when() { + return sections().memory || sections().status; + }, + get children() { + return _$createComponent(SidebarSection, { + title: "Knowledge", + get children() { + return _$createComponent(Show, { + get when() { + return compactionOff(); + }, + get fallback() { + return [_$createComponent(Show, { get when() { - return progress(); + return sections().memory; }, get children() { return _$createComponent(SidebarStatRow, { - get label() { - return progressLabel(); - }, + label: "Memories", get value() { - return progressValue(); + return values().memories; } }); } - })]; - } - }); - } - }), _$createComponent(Show, { - get when() { - return _$memo(() => !!!compactionOff())() && sections().status; - }, - get children() { - return _$createComponent(SidebarSection, { - title: "Prompt cache", - get children() { - return [_$createComponent(SidebarStatRow, { - label: "Cache built", - get value() { - return values().cacheBuilt; - } - }), _$createComponent(SidebarStatRow, { - label: "Since then", - get value() { - return values().sinceThen; - } - }), _$createComponent(SidebarStatRow, { - label: "Queued drops", - get value() { - return values().drops; - } - })]; - } - }); - } - }), _$createComponent(Show, { - get when() { - return sections().memory || sections().status; - }, - get children() { - return _$createComponent(SidebarSection, { - title: "Knowledge", - get children() { - return _$createComponent(Show, { + }), _$createComponent(Show, { get when() { - return compactionOff(); - }, - get fallback() { - return [_$createComponent(Show, { - get when() { - return sections().memory; - }, - get children() { - return _$createComponent(SidebarStatRow, { - label: "Memories", - get value() { - return values().memories; - } - }); - } - }), _$createComponent(Show, { - get when() { - return sections().status; - }, - get children() { - return _$createComponent(SidebarStatRow, { - label: "Notes", - get value() { - return values().notes; - } - }); - } - })]; + return sections().status; }, get children() { - return _$createComponent(For, { - get each() { - return values().nativeRows; - }, - children: row => _$createComponent(SidebarStatRow, { - get label() { - return row.label; - }, - get value() { - return row.value; - } - }) + return _$createComponent(SidebarStatRow, { + label: "Notes", + get value() { + return values().notes; + } }); } - }); - } - }); - } - }), _$createComponent(Show, { - get when() { - return sections().dreamer; - }, - get children() { - return _$createComponent(SidebarSection, { - title: "Dreamer", - get children() { - return [_$createComponent(SidebarStatRow, { - label: "Last run", - get value() { - return values().dreamerLastRun; - } - }), _$createComponent(Show, { - get when() { - return s()?.dreamerProgress; - }, - children: p => _$createComponent(SidebarStatRow, { - label: "Current", - get value() { - return `${p().task} ${p().processed}/${p().total}`; - } - }) })]; - } - }); - } - }), _$createComponent(Show, { - get when() { - return healthRows().length > 0; - }, - get children() { - return _$createComponent(SidebarSection, { - title: "Health", + }, get children() { - return [_$createComponent(For, { + return _$createComponent(For, { get each() { - return healthRows(); + return values().nativeRows; }, children: row => _$createComponent(SidebarStatRow, { get label() { @@ -549,20 +494,71 @@ const SidebarContent = props => { }, get value() { return row.value; - }, - get tone() { - return row.tone; } }) - }), _$createComponent(SidebarTextRow, { - text: "/ctx-status shows details" - })]; + }); } }); } - })] - }); - } + }); + } + }), _$createComponent(Show, { + get when() { + return sections().dreamer; + }, + get children() { + return _$createComponent(SidebarSection, { + title: "Dreamer", + get children() { + return [_$createComponent(SidebarStatRow, { + label: "Last run", + get value() { + return values().dreamerLastRun; + } + }), _$createComponent(Show, { + get when() { + return s()?.dreamerProgress; + }, + children: p => _$createComponent(SidebarStatRow, { + label: "Current", + get value() { + return `${p().task} ${p().processed}/${p().total}`; + } + }) + })]; + } + }); + } + }), _$createComponent(Show, { + get when() { + return healthRows().length > 0; + }, + get children() { + return _$createComponent(SidebarSection, { + title: "Health", + get children() { + return [_$createComponent(For, { + get each() { + return healthRows(); + }, + children: row => _$createComponent(SidebarStatRow, { + get label() { + return row.label; + }, + get value() { + return row.value; + }, + get tone() { + return row.tone; + } + }) + }), _$createComponent(SidebarTextRow, { + text: "/ctx-status shows details" + })]; + } + }); + } + })] }); }; export function createSidebarContentSlot(api) { diff --git a/packages/plugin/src/tui/__snapshots__/sidebar-render.test.ts.snap b/packages/plugin/src/tui/__snapshots__/sidebar-render.test.ts.snap new file mode 100644 index 0000000000..7db863c92f --- /dev/null +++ b/packages/plugin/src/tui/__snapshots__/sidebar-render.test.ts.snap @@ -0,0 +1,1372 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`option B sidebar preserves the expanded and incident render output 1`] = ` +[ + { + "collapsed": null, + "frame": [ + " ▼ MAGIC CONTEXT v0.49.52", + " ", + "Context ", + "ctx ██████░░░░ 45% left 483K/872K", + "Next summary at 61% · +49K", + " ", + "History ", + "Historian idle", + "Last run 1h55m ago · ok", + "Model Gemini 3.8 Flash", + "Waiting 140 msgs", + " ", + "Prompt cache ", + "Cache built 29h ago", + "Since then 6 summaries · 7 memories", + "Queued drops 151 · ≈49K at rebuild", + " ", + "Knowledge ", + "Memories 225 · 7 new", + "Notes 4 · 1 smart waiting", + " ", + "Dreamer ", + "Last run 2h30m ago · ok", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + "", + ], + "lines": [ + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#000000", + "text": "▼ MAGIC CONTEXT", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 0, + "fg": "#888888", + "text": "v0.49.52", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 1, + "fg": "#ffffff", + "text": "Context", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "ctx ", + }, + { + "attributes": 0, + "fg": "#00ff00", + "text": "██████░░░░", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 0, + "fg": "#00ff00", + "text": "45% left", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "483K/872K", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Next summary", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "at 61% · +49K", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 1, + "fg": "#ffffff", + "text": "History", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Historian", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "idle", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Last run", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "1h55m ago · ok", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Model", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "Gemini 3.8 Flash", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Waiting", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "140 msgs", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 1, + "fg": "#ffffff", + "text": "Prompt cache", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Cache built", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "29h ago", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Since then", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "6 summaries · 7 memories", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Queued drops", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "151 · ≈49K at rebuild", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 1, + "fg": "#ffffff", + "text": "Knowledge", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Memories", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "225 · 7 new", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Notes", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "4 · 1 smart waiting", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 1, + "fg": "#ffffff", + "text": "Dreamer", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Last run", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "2h30m ago · ok", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + ], + }, + { + "collapsed": null, + "frame": [ + " ▼ MAGIC CONTEXT v0.49.52", + " ", + "Context ", + "ctx ██████░░░░ 45% left 483K/872K", + "Next summary at 61% · +49K", + " ", + "History ", + "Historian summarizing", + "Last run 1m ago · fallback", + "Model DeepSeek V4.1 Flash", + "Waiting 28 msgs", + " ", + "Prompt cache ", + "Cache built 29h ago", + "Since then 6 summaries · 7 memories", + "Queued drops 151 · ≈49K at rebuild", + " ", + "Knowledge ", + "Memories 225 · 7 new", + "Notes 4 · 1 smart waiting", + " ", + "Dreamer ", + "Last run 2h30m ago · ok", + " ", + "Health ", + "Historian Gemini 4/4 rejected", + "Fallback DeepSeek finished 2 runs", + "/ctx-status shows details ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + "", + ], + "lines": [ + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#000000", + "text": "▼ MAGIC CONTEXT", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 0, + "fg": "#888888", + "text": "v0.49.52", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 1, + "fg": "#ffffff", + "text": "Context", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "ctx ", + }, + { + "attributes": 0, + "fg": "#00ff00", + "text": "██████░░░░", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 0, + "fg": "#00ff00", + "text": "45% left", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "483K/872K", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Next summary", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "at 61% · +49K", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 1, + "fg": "#ffffff", + "text": "History", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Historian", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "summarizing", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Last run", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "1m ago · fallback", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Model", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "DeepSeek V4.1 Flash", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Waiting", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "28 msgs", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 1, + "fg": "#ffffff", + "text": "Prompt cache", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Cache built", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "29h ago", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Since then", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "6 summaries · 7 memories", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Queued drops", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "151 · ≈49K at rebuild", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 1, + "fg": "#ffffff", + "text": "Knowledge", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Memories", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "225 · 7 new", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Notes", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "4 · 1 smart waiting", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 1, + "fg": "#ffffff", + "text": "Dreamer", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Last run", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ffffff", + "text": "2h30m ago · ok", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 1, + "fg": "#ffffff", + "text": "Health", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Historian", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ff8800", + "text": "Gemini 4/4 rejected", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "Fallback", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + { + "attributes": 1, + "fg": "#ff8800", + "text": "DeepSeek finished 2 runs", + }, + ], + [ + { + "attributes": 0, + "fg": "#888888", + "text": "/ctx-status shows details", + }, + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + [ + { + "attributes": 0, + "fg": "#ffffff", + "text": " ", + }, + ], + ], + }, +] +`; diff --git a/packages/plugin/src/tui/quota-sidebar.d.ts b/packages/plugin/src/tui/quota-sidebar.d.ts index 9d3aa23d07..b6478d7b9e 100644 --- a/packages/plugin/src/tui/quota-sidebar.d.ts +++ b/packages/plugin/src/tui/quota-sidebar.d.ts @@ -1,6 +1,7 @@ export { SidebarFrame, + SidebarGaugeRow, SidebarSection, SidebarStatRow, SidebarTextRow, -} from "@cortexkit/common-auth/quota-sidebar"; +} from "@cortexkit/opencode-sidebar"; diff --git a/packages/plugin/src/tui/quota-sidebar.js b/packages/plugin/src/tui/quota-sidebar.js index 4265265f77..e173284103 100644 --- a/packages/plugin/src/tui/quota-sidebar.js +++ b/packages/plugin/src/tui/quota-sidebar.js @@ -1,7 +1,7 @@ // @bun -// ../../node_modules/.bun/@cortexkit+common-auth@0.38.0+79ed038a4f852143/node_modules/@cortexkit/common-auth/src/quota-sidebar/index.tsx -import { createTextNode as _$createTextNode } from "@opentui/solid"; +// ../../node_modules/.bun/@cortexkit+opencode-sidebar@0.2.0+4f25175b99662f85/node_modules/@cortexkit/opencode-sidebar/src/index.tsx import { createComponent as _$createComponent } from "@opentui/solid"; +import { createTextNode as _$createTextNode } from "@opentui/solid"; import { use as _$use } from "@opentui/solid"; import { memo as _$memo } from "@opentui/solid"; import { effect as _$effect } from "@opentui/solid"; @@ -10,253 +10,6 @@ import { insert as _$insert } from "@opentui/solid"; import { setProp as _$setProp } from "@opentui/solid"; import { createElement as _$createElement } from "@opentui/solid"; import { createContext, createMemo, createSignal, For, Index, mergeProps, Show, useContext } from "solid-js"; - -// ../../node_modules/.bun/@cortexkit+common-auth@0.38.0+79ed038a4f852143/node_modules/@cortexkit/common-auth/src/quota/map.ts -var ALL_SCOPE = "all"; -var DEFAULT_REQUIRED_LABELS = Object.freeze([ - "primary" -]); - -// ../../node_modules/.bun/@cortexkit+common-auth@0.38.0+79ed038a4f852143/node_modules/@cortexkit/common-auth/src/quota/format.ts -var QUOTA_STALE_AFTER_MS = 15 * 60000; -var NO_QUOTA_READING = "no quota reading yet"; -var NO_LIMITS_REPORTED = "no limits reported"; -function shortSpan(ms) { - const minutes = Math.max(1, Math.floor(ms / 60000)); - if (minutes < 60) - return `${minutes}m`; - if (minutes < 1440) { - const hours = Math.floor(minutes / 60); - const rest = minutes % 60; - return rest === 0 ? `${hours}h` : `${hours}h${rest}m`; - } - const days = Math.floor(minutes / 1440); - const hours = Math.floor(minutes % 1440 / 60); - return hours === 0 ? `${days}d` : `${days}d${hours}h`; -} -function lengthName(minutes) { - if (minutes % 1440 === 0) - return `${minutes / 1440}d`; - if (minutes % 60 === 0) - return `${minutes / 60}h`; - return `${minutes}m`; -} -function quotaWindowName(limit) { - if (limit.scope !== ALL_SCOPE || limit.windowMinutes === undefined) - return limit.label; - return lengthName(limit.windowMinutes); -} -function clampPercent(remaining) { - return Math.min(100, Math.max(0, remaining)); -} -function wholePercent(remaining) { - return Math.round(clampPercent(remaining)); -} -function percentLeft(remaining) { - return `${wholePercent(remaining)}% left`; -} -function resetDetails(resetsAt, checkedAt, now) { - const at = resetsAt === undefined ? Number.NaN : Date.parse(resetsAt); - return { - ...Number.isFinite(at) && at > now ? { resetIn: shortSpan(at - now) } : {}, - resetSinceReading: Number.isFinite(at) && at > checkedAt && at <= now - }; -} -function displayOrder(left, right) { - const l = left.windowMinutes; - const r = right.windowMinutes; - if (l !== undefined && r !== undefined && l !== r) - return l - r; - if (l === undefined !== (r === undefined)) - return l === undefined ? 1 : -1; - if (left.scope !== right.scope) { - if (left.scope === ALL_SCOPE) - return -1; - if (right.scope === ALL_SCOPE) - return 1; - return left.scope < right.scope ? -1 : 1; - } - return left.label < right.label ? -1 : left.label > right.label ? 1 : 0; -} -function quotaWindowParts(quota, options) { - const { windows, age } = scopedWindowParts(quota, options, true); - const parts = windows.map(({ part }) => part); - return age === undefined ? { windows: parts } : { windows: parts, age }; -} -function withShortNames(windows) { - const abbreviations = scopeAbbreviations(windows.filter(({ part, scope }) => part.kind === "window" && scope !== ALL_SCOPE).map(({ scope }) => scope)); - for (const window of windows) { - window.part.short = window.part.kind === "budget" ? "cr" : window.scope === ALL_SCOPE ? window.part.name : abbreviations.get(window.scope) ?? window.scope; - } - return windows; -} -function hasWindowReading(limit) { - return Number.isFinite(limit.remainingPercent) && Number.isFinite(limit.checkedAt); -} -function scopedWindowParts(quota, options, includeUnknown = false) { - if (!quota) - return { windows: [] }; - const { now } = options; - const readings = quota.limits.filter((limit) => limit.kind === "reading" && (includeUnknown || hasWindowReading(limit))).sort(displayOrder); - const windows = readings.map((limit) => { - const part = hasWindowReading(limit) ? { - kind: "window", - reading: "known", - name: quotaWindowName(limit), - short: "", - figure: percentLeft(limit.remainingPercent), - remainingPercent: clampPercent(limit.remainingPercent), - checkedAt: limit.checkedAt, - ...resetDetails(limit.resetsAt, limit.checkedAt, now) - } : { - kind: "window", - reading: "unknown", - name: quotaWindowName(limit), - short: "", - figure: "no reading", - resetSinceReading: false - }; - return { - scope: limit.scope, - ...limit.windowMinutes === undefined ? {} : { windowMinutes: limit.windowMinutes }, - part - }; - }); - const budget = quota.budget; - if (budget) { - const remainingPercent = budget.remainingPercent; - const figure = budget.reached ? "credits spent" : remainingPercent !== undefined ? `credits ${percentLeft(remainingPercent)}` : "credits available"; - windows.push({ - scope: ALL_SCOPE, - spent: budget.reached, - part: { - kind: "budget", - reading: "known", - name: "credits", - short: "", - figure, - ...remainingPercent === undefined ? {} : { remainingPercent: clampPercent(remainingPercent) }, - checkedAt: budget.checkedAt, - ...resetDetails(budget.resetsAt, budget.checkedAt, now) - } - }); - } - withShortNames(windows); - const readingTimes = windows.flatMap(({ part }) => part.reading === "known" && part.checkedAt !== undefined ? [part.checkedAt] : []); - if (readingTimes.length > 0) { - const age = now - Math.min(...readingTimes); - if (age > (options.staleAfterMs ?? QUOTA_STALE_AFTER_MS)) { - return { windows, age: `checked ${shortSpan(age)} ago` }; - } - } - return { windows }; -} -function scopeDisplayName(scope) { - const model = scope.slice(scope.indexOf(":") + 1); - return model.charAt(0).toUpperCase() + model.slice(1); -} -function scopeAbbreviations(scopes) { - const names = [...new Set(scopes.map(scopeDisplayName))]; - const lengths = new Map(names.map((name) => [name, 2])); - const short = (name) => name.slice(0, lengths.get(name)); - for (;; ) { - const byShort = new Map; - for (const name of names) { - byShort.set(short(name), [...byShort.get(short(name)) ?? [], name]); - } - let extended = false; - for (const group of byShort.values()) { - if (group.length < 2) - continue; - for (const name of group) { - const length = lengths.get(name) ?? 2; - if (length < name.length) { - lengths.set(name, length + 1); - extended = true; - } - } - } - if (!extended) - break; - } - return new Map(scopes.map((scope) => [scope, short(scopeDisplayName(scope))])); -} -var NARROW_GROUP_LIMIT = 2; -function groupShortNames(groups) { - const labels = groups.map((group) => group.label.trim() || group.scope); - const short = scopeAbbreviations(labels); - return labels.map((label) => short.get(label) ?? label); -} -function groupedNarrowPieces(groups, windows) { - if (!groups || groups.length === 0) - return; - const reported = windows.filter(({ part }) => part.kind === "window"); - if (reported.some(({ scope }) => scope === ALL_SCOPE)) - return; - const candidates = groups.flatMap((group, listed) => { - const inGroup = reported.filter(({ scope }) => scope === group.scope); - const groupTightest = inGroup.reduce((least, window) => least === undefined || (window.part.remainingPercent ?? 0) < (least.part.remainingPercent ?? 0) ? window : least, undefined); - return groupTightest ? [{ group, listed, window: groupTightest }] : []; - }); - if (candidates.length === 0) - return; - const names = groupShortNames(groups); - const left = (candidate) => candidate.window.part.remainingPercent ?? 0; - const shown = [...candidates].sort((a, b) => left(a) - left(b) || a.listed - b.listed).slice(0, NARROW_GROUP_LIMIT).sort((a, b) => a.listed - b.listed); - return shown.map((candidate, index) => { - const { windowMinutes } = candidate.window; - const piece = [ - names[candidate.listed] ?? candidate.group.scope, - ...windowMinutes === undefined ? [] : [lengthName(windowMinutes)], - `${wholePercent(left(candidate))}%` - ].join(" "); - return index === shown.length - 1 ? `${piece} left` : piece; - }); -} -function narrowTextParts(quota, options) { - const { windows } = scopedWindowParts(quota, options); - const grouped = groupedNarrowPieces(quota?.groups, windows); - if (grouped) - return grouped; - const general = windows.filter(({ part, scope }) => part.kind === "window" && scope === ALL_SCOPE); - const scoped = windows.filter(({ part, scope }) => part.kind === "window" && scope !== ALL_SCOPE); - const budget = windows.find(({ part }) => part.kind === "budget"); - const budgetLeft = budget?.spent ? 0 : budget?.part.remainingPercent; - const extras = [ - ...scoped.map(({ part }) => ({ - label: part.short, - remaining: part.remainingPercent ?? 0 - })), - ...budgetLeft === undefined || budget === undefined ? [] : [{ label: budget.part.short, remaining: budgetLeft }] - ]; - const tightest = extras.reduce((best, item) => best === undefined || item.remaining < best.remaining ? item : best, undefined); - const pieces = general.map(({ part }) => `${part.name} ${wholePercent(part.remainingPercent ?? 0)}%`); - if (tightest) { - pieces.push(`${tightest.label} ${wholePercent(tightest.remaining)}%`); - } - if (pieces.length === 0) - return windows.map(({ part }) => part.figure); - return pieces.map((piece, index) => index === pieces.length - 1 ? `${piece} left` : piece); -} -function quotaTextParts(quota, options) { - if (options.form === "narrow") - return narrowTextParts(quota, options); - const { windows, age } = scopedWindowParts(quota, options); - const full = (options.form ?? "full") === "full"; - const text = windows.map(({ part }) => { - const reset = full && part.resetIn ? `, resets ${part.resetIn}` : ""; - return `${part.kind === "budget" ? "" : `${part.name} `}${part.figure}${reset}`; - }); - return age === undefined ? text : [...text, age]; -} -function formatQuota(quota, options) { - const parts = quotaTextParts(quota, options); - if (parts.length > 0) - return parts.join(" \xB7 "); - return quota && quota.limits.length > 0 ? NO_LIMITS_REPORTED : NO_QUOTA_READING; -} - -// ../../node_modules/.bun/@cortexkit+common-auth@0.38.0+79ed038a4f852143/node_modules/@cortexkit/common-auth/src/quota-sidebar/index.tsx var FrameContext = createContext(); function frameContext() { const context = useContext(FrameContext); @@ -391,6 +144,119 @@ function barSegments(used, appearance, pace) { tone }]; } +function SidebarGaugeRow(props) { + const context = frameContext(); + const [columns, setColumns] = createSignal(0); + let row; + const known = () => props.used !== undefined && Number.isFinite(props.used); + const used = () => Math.max(0, Math.min(100, props.used ?? 0)); + const figure = () => known() ? `${Math.round(100 - used())}% left` : "no reading"; + const appearance = () => ({ + ...context.appearance, + warnThreshold: props.warnAt ?? context.appearance.warnThreshold, + errorThreshold: props.errorAt ?? context.appearance.errorThreshold + }); + const label = () => columns() <= 0 ? props.label : fitText(props.label, columns() - displayWidth(figure()) - 1); + const barCells = () => { + if (!known()) + return 0; + const desired = context.appearance.barWidth; + if (columns() <= 0) + return desired; + const room = columns() - displayWidth(label()) - 1 - displayWidth(figure()) - 1; + return room >= Math.min(4, desired) ? Math.min(desired, room) : 0; + }; + const segments = createMemo(() => barSegments(used(), { + ...appearance(), + barWidth: barCells() + }), undefined, { + equals: (previous, next) => previous.every((segment, index) => segment.text === next[index]?.text && segment.tone === next[index]?.tone) + }); + const value = () => { + if (!props.value) + return ""; + if (columns() <= 0) + return props.value; + const room = columns() - displayWidth(label()) - 1 - displayWidth(figure()) - (barCells() > 0 ? barCells() + 1 : 0) - 1; + return room >= 2 ? fitText(props.value, room) : ""; + }; + const tone = () => known() ? usageTone(used(), appearance()) : "textMuted"; + return (() => { + var _el$9 = _$createElement("box"), _el$0 = _$createElement("box"), _el$1 = _$createElement("text"), _el$10 = _$createTextNode(` `), _el$13 = _$createElement("text"); + _$insertNode(_el$9, _el$0); + _$use((element) => { + row = element; + }, _el$9); + _$setProp(_el$9, "onSizeChange", () => setColumns(row?.width ?? 0)); + _$setProp(_el$9, "width", "100%"); + _$setProp(_el$9, "flexDirection", "row"); + _$setProp(_el$9, "justifyContent", "space-between"); + _$insertNode(_el$0, _el$1); + _$insertNode(_el$0, _el$13); + _$setProp(_el$0, "flexDirection", "row"); + _$setProp(_el$0, "flexShrink", 0); + _$insertNode(_el$1, _el$10); + _$setProp(_el$1, "wrapMode", "none"); + _$insert(_el$1, label, _el$10); + _$insert(_el$0, _$createComponent(Show, { + get when() { + return barCells() > 0; + }, + get children() { + return [_$createComponent(Index, { + get each() { + return segments(); + }, + children: (segment) => (() => { + var _el$16 = _$createElement("text"); + _$setProp(_el$16, "wrapMode", "none"); + _$insert(_el$16, () => segment().text); + _$effect((_p$) => { + var _v$7 = context.theme[segment().tone], _v$8 = displayWidth(segment().text); + _v$7 !== _p$.e && (_p$.e = _$setProp(_el$16, "fg", _v$7, _p$.e)); + _v$8 !== _p$.t && (_p$.t = _$setProp(_el$16, "width", _v$8, _p$.t)); + return _p$; + }, { + e: undefined, + t: undefined + }); + return _el$16; + })() + }), (() => { + var _el$11 = _$createElement("text"); + _$insertNode(_el$11, _$createTextNode(` `)); + _$setProp(_el$11, "wrapMode", "none"); + return _el$11; + })()]; + } + }), _el$13); + _$setProp(_el$13, "wrapMode", "none"); + _$insert(_el$13, figure); + _$insert(_el$9, _$createComponent(Show, { + get when() { + return value(); + }, + get children() { + var _el$14 = _$createElement("text"), _el$15 = _$createElement("b"); + _$insertNode(_el$14, _el$15); + _$setProp(_el$14, "wrapMode", "none"); + _$insert(_el$15, value); + _$effect((_$p) => _$setProp(_el$14, "fg", context.theme.text, _$p)); + return _el$14; + } + }), null); + _$effect((_p$) => { + var _v$5 = context.theme.textMuted, _v$6 = context.theme[tone()]; + _v$5 !== _p$.e && (_p$.e = _$setProp(_el$1, "fg", _v$5, _p$.e)); + _v$6 !== _p$.t && (_p$.t = _$setProp(_el$13, "fg", _v$6, _p$.t)); + return _p$; + }, { + e: undefined, + t: undefined + }); + return _el$9; + })(); +} function QuotaRow(props) { const context = frameContext(); const [columns, setColumns] = createSignal(0); @@ -412,40 +278,40 @@ function QuotaRow(props) { return fitText(text, columns() - props.labelWidth); }; return [(() => { - var _el$9 = _$createElement("box"), _el$0 = _$createElement("box"); - _$insertNode(_el$9, _el$0); + var _el$17 = _$createElement("box"), _el$18 = _$createElement("box"); + _$insertNode(_el$17, _el$18); _$use((element) => { row = element; - }, _el$9); - _$setProp(_el$9, "onSizeChange", () => setColumns(row?.width ?? 0)); - _$setProp(_el$9, "width", "100%"); - _$setProp(_el$9, "flexDirection", "row"); - _$setProp(_el$9, "justifyContent", "space-between"); - _$setProp(_el$0, "flexDirection", "row"); - _$insert(_el$0, _$createComponent(Show, { + }, _el$17); + _$setProp(_el$17, "onSizeChange", () => setColumns(row?.width ?? 0)); + _$setProp(_el$17, "width", "100%"); + _$setProp(_el$17, "flexDirection", "row"); + _$setProp(_el$17, "justifyContent", "space-between"); + _$setProp(_el$18, "flexDirection", "row"); + _$insert(_el$18, _$createComponent(Show, { get when() { return props.labelWidth > 0; }, get children() { - var _el$1 = _$createElement("text"); - _$setProp(_el$1, "wrapMode", "none"); - _$setProp(_el$1, "flexShrink", 0); - _$insert(_el$1, label); - _$effect((_$p) => _$setProp(_el$1, "fg", context.theme.textMuted, _$p)); - return _el$1; + var _el$19 = _$createElement("text"); + _$setProp(_el$19, "wrapMode", "none"); + _$setProp(_el$19, "flexShrink", 0); + _$insert(_el$19, label); + _$effect((_$p) => _$setProp(_el$19, "fg", context.theme.textMuted, _$p)); + return _el$19; } }), null); - _$insert(_el$0, _$createComponent(Show, { + _$insert(_el$18, _$createComponent(Show, { get when() { return hasReading(); }, get fallback() { return (() => { - var _el$13 = _$createElement("text"); - _$insertNode(_el$13, _$createTextNode(`no reading`)); - _$setProp(_el$13, "wrapMode", "none"); - _$effect((_$p) => _$setProp(_el$13, "fg", context.theme.textMuted, _$p)); - return _el$13; + var _el$23 = _$createElement("text"); + _$insertNode(_el$23, _$createTextNode(`no reading`)); + _$setProp(_el$23, "wrapMode", "none"); + _$effect((_$p) => _$setProp(_el$23, "fg", context.theme.textMuted, _$p)); + return _el$23; })(); }, get children() { @@ -455,11 +321,11 @@ function QuotaRow(props) { }, get fallback() { return (() => { - var _el$15 = _$createElement("text"); - _$insertNode(_el$15, _$createTextNode(`reset since last check`)); - _$setProp(_el$15, "wrapMode", "none"); - _$effect((_$p) => _$setProp(_el$15, "fg", context.theme.textMuted, _$p)); - return _el$15; + var _el$25 = _$createElement("text"); + _$insertNode(_el$25, _$createTextNode(`reset since last check`)); + _$setProp(_el$25, "wrapMode", "none"); + _$effect((_$p) => _$setProp(_el$25, "fg", context.theme.textMuted, _$p)); + return _el$25; })(); }, get children() { @@ -473,85 +339,85 @@ function QuotaRow(props) { return segments(); }, children: (segment) => (() => { - var _el$17 = _$createElement("text"); - _$setProp(_el$17, "wrapMode", "none"); - _$insert(_el$17, () => segment().text); + var _el$27 = _$createElement("text"); + _$setProp(_el$27, "wrapMode", "none"); + _$insert(_el$27, () => segment().text); _$effect((_p$) => { - var _v$5 = context.theme[segment().tone], _v$6 = segment().text ? undefined : 0; - _v$5 !== _p$.e && (_p$.e = _$setProp(_el$17, "fg", _v$5, _p$.e)); - _v$6 !== _p$.t && (_p$.t = _$setProp(_el$17, "width", _v$6, _p$.t)); + var _v$9 = context.theme[segment().tone], _v$0 = segment().text ? undefined : 0; + _v$9 !== _p$.e && (_p$.e = _$setProp(_el$27, "fg", _v$9, _p$.e)); + _v$0 !== _p$.t && (_p$.t = _$setProp(_el$27, "width", _v$0, _p$.t)); return _p$; }, { e: undefined, t: undefined }); - return _el$17; + return _el$27; })() }); } }), (() => { - var _el$10 = _$createElement("text"); - _$setProp(_el$10, "wrapMode", "none"); - _$insert(_el$10, figure); - _$effect((_$p) => _$setProp(_el$10, "fg", context.theme[usageTone(used(), context.appearance)], _$p)); - return _el$10; + var _el$20 = _$createElement("text"); + _$setProp(_el$20, "wrapMode", "none"); + _$insert(_el$20, figure); + _$effect((_$p) => _$setProp(_el$20, "fg", context.theme[usageTone(used(), context.appearance)], _$p)); + return _el$20; })()]; } }); } }), null); - _$insert(_el$9, _$createComponent(Show, { + _$insert(_el$17, _$createComponent(Show, { get when() { return _$memo(() => !!(hasReading() && props.showReset))() && !props.part.resetSinceReading; }, get children() { - var _el$11 = _$createElement("box"); - _$setProp(_el$11, "flexShrink", 0); - _$setProp(_el$11, "flexDirection", "row"); - _$setProp(_el$11, "justifyContent", "flex-end"); - _$insert(_el$11, _$createComponent(Show, { + var _el$21 = _$createElement("box"); + _$setProp(_el$21, "flexShrink", 0); + _$setProp(_el$21, "flexDirection", "row"); + _$setProp(_el$21, "justifyContent", "flex-end"); + _$insert(_el$21, _$createComponent(Show, { get when() { return props.part.resetIn; }, get children() { - var _el$12 = _$createElement("text"); - _$setProp(_el$12, "wrapMode", "none"); - _$insert(_el$12, () => props.part.resetIn); - _$effect((_$p) => _$setProp(_el$12, "fg", context.theme.textMuted, _$p)); - return _el$12; + var _el$22 = _$createElement("text"); + _$setProp(_el$22, "wrapMode", "none"); + _$insert(_el$22, () => props.part.resetIn); + _$effect((_$p) => _$setProp(_el$22, "fg", context.theme.textMuted, _$p)); + return _el$22; } })); - _$effect((_$p) => _$setProp(_el$11, "width", props.resetWidth, _$p)); - return _el$11; + _$effect((_$p) => _$setProp(_el$21, "width", props.resetWidth, _$p)); + return _el$21; } }), null); - _$effect((_$p) => _$setProp(_el$0, "flexShrink", props.part.resetSinceReading ? 1 : 0, _$p)); - return _el$9; + _$effect((_$p) => _$setProp(_el$18, "flexShrink", props.part.resetSinceReading ? 1 : 0, _$p)); + return _el$17; })(), _$createComponent(Show, { get when() { return _$memo(() => !!(hasReading() && !props.part.resetSinceReading))() && paceLine(); }, keyed: true, children: (line) => (() => { - var _el$18 = _$createElement("box"), _el$20 = _$createElement("text"); - _$insertNode(_el$18, _el$20); - _$setProp(_el$18, "width", "100%"); - _$setProp(_el$18, "flexDirection", "row"); - _$insert(_el$18, _$createComponent(Show, { + var _el$28 = _$createElement("box"), _el$30 = _$createElement("text"); + _$insertNode(_el$28, _el$30); + _$setProp(_el$28, "width", "100%"); + _$setProp(_el$28, "flexDirection", "row"); + _$insert(_el$28, _$createComponent(Show, { get when() { return props.labelWidth > 0; }, get children() { - var _el$19 = _$createElement("text"); - _$insert(_el$19, () => "".padEnd(props.labelWidth)); - _$effect((_$p) => _$setProp(_el$19, "fg", context.theme.textMuted, _$p)); - return _el$19; + var _el$29 = _$createElement("text"); + _$insert(_el$29, () => "".padEnd(props.labelWidth)); + _$effect((_$p) => _$setProp(_el$29, "fg", context.theme.textMuted, _$p)); + return _el$29; } - }), _el$20); - _$setProp(_el$20, "wrapMode", "none"); - _$insert(_el$20, line); - _$effect((_$p) => _$setProp(_el$20, "fg", context.theme[props.pace?.tone ?? "textMuted"], _$p)); - return _el$18; + }), _el$30); + _$setProp(_el$30, "wrapMode", "none"); + _$insert(_el$30, line); + _$effect((_$p) => _$setProp(_el$30, "fg", context.theme[props.pace?.tone ?? "textMuted"], _$p)); + return _el$28; })() })]; } @@ -559,13 +425,8 @@ function CollapsedAccountRow(props) { const context = frameContext(); const [width, setWidth] = createSignal(0); let row; - const hasQuota = () => quotaWindowParts(props.account.quota, { - now: context.now - }).windows.some((part) => part.reading === "known"); - const text = () => hasQuota() ? formatQuota(props.account.quota, { - now: context.now, - form: "narrow" - }) : props.account.emptyQuotaText?.collapsed ?? "\u2014"; + const hasQuota = () => props.account.hasQuota; + const text = () => hasQuota() ? props.account.collapsedQuotaText ?? "" : props.account.emptyQuotaText?.collapsed ?? "\u2014"; const showQuota = () => width() <= 0 || displayWidth(text()) + 4 <= width(); const label = () => { if (width() <= 0) @@ -575,32 +436,32 @@ function CollapsedAccountRow(props) { const muted = () => props.account.collapsed?.muted ?? false; const quotaTone = () => muted() ? "textMuted" : props.account.collapsed?.quotaTone ?? "success"; return (() => { - var _el$21 = _$createElement("box"), _el$23 = _$createElement("box"), _el$26 = _$createElement("text"); - _$insertNode(_el$21, _el$23); + var _el$31 = _$createElement("box"), _el$33 = _$createElement("box"), _el$36 = _$createElement("text"); + _$insertNode(_el$31, _el$33); _$use((element) => { row = element; - }, _el$21); - _$setProp(_el$21, "onSizeChange", () => setWidth(row?.width ?? 0)); - _$setProp(_el$21, "width", "100%"); - _$setProp(_el$21, "flexDirection", "row"); - _$setProp(_el$21, "justifyContent", "space-between"); - _$insert(_el$21, _$createComponent(Show, { + }, _el$31); + _$setProp(_el$31, "onSizeChange", () => setWidth(row?.width ?? 0)); + _$setProp(_el$31, "width", "100%"); + _$setProp(_el$31, "flexDirection", "row"); + _$setProp(_el$31, "justifyContent", "space-between"); + _$insert(_el$31, _$createComponent(Show, { get when() { return label(); }, get children() { - var _el$22 = _$createElement("text"); - _$setProp(_el$22, "wrapMode", "none"); - _$setProp(_el$22, "flexShrink", 1); - _$insert(_el$22, label); - _$effect((_$p) => _$setProp(_el$22, "fg", context.theme[muted() ? "textMuted" : "text"], _$p)); - return _el$22; + var _el$32 = _$createElement("text"); + _$setProp(_el$32, "wrapMode", "none"); + _$setProp(_el$32, "flexShrink", 1); + _$insert(_el$32, label); + _$effect((_$p) => _$setProp(_el$32, "fg", context.theme[muted() ? "textMuted" : "text"], _$p)); + return _el$32; } - }), _el$23); - _$insertNode(_el$23, _el$26); - _$setProp(_el$23, "flexDirection", "row"); - _$setProp(_el$23, "flexShrink", 0); - _$insert(_el$23, _$createComponent(Show, { + }), _el$33); + _$insertNode(_el$33, _el$36); + _$setProp(_el$33, "flexDirection", "row"); + _$setProp(_el$33, "flexShrink", 0); + _$insert(_el$33, _$createComponent(Show, { get when() { return showQuota(); }, @@ -611,34 +472,34 @@ function CollapsedAccountRow(props) { }, get fallback() { return (() => { - var _el$27 = _$createElement("text"); - _$insert(_el$27, text); - _$effect((_$p) => _$setProp(_el$27, "fg", context.theme.textMuted, _$p)); - return _el$27; + var _el$37 = _$createElement("text"); + _$insert(_el$37, text); + _$effect((_$p) => _$setProp(_el$37, "fg", context.theme.textMuted, _$p)); + return _el$37; })(); }, get children() { - var _el$24 = _$createElement("text"), _el$25 = _$createElement("b"); - _$insertNode(_el$24, _el$25); - _$insert(_el$25, text); - _$effect((_$p) => _$setProp(_el$24, "fg", context.theme[quotaTone()], _$p)); - return _el$24; + var _el$34 = _$createElement("text"), _el$35 = _$createElement("b"); + _$insertNode(_el$34, _el$35); + _$insert(_el$35, text); + _$effect((_$p) => _$setProp(_el$34, "fg", context.theme[quotaTone()], _$p)); + return _el$34; } }); } - }), _el$26); - _$insert(_el$26, () => showQuota() ? " " : "", null); - _$insert(_el$26, () => props.account.blocked ? "\u2298" : "\u25CF", null); + }), _el$36); + _$insert(_el$36, () => showQuota() ? " " : "", null); + _$insert(_el$36, () => props.account.blocked ? "\u2298" : "\u25CF", null); _$effect((_p$) => { - var _v$7 = label() ? 1 : 0, _v$8 = context.theme[props.account.indicatorTone]; - _v$7 !== _p$.e && (_p$.e = _$setProp(_el$21, "columnGap", _v$7, _p$.e)); - _v$8 !== _p$.t && (_p$.t = _$setProp(_el$26, "fg", _v$8, _p$.t)); + var _v$1 = label() ? 1 : 0, _v$10 = context.theme[props.account.indicatorTone]; + _v$1 !== _p$.e && (_p$.e = _$setProp(_el$31, "columnGap", _v$1, _p$.e)); + _v$10 !== _p$.t && (_p$.t = _$setProp(_el$36, "fg", _v$10, _p$.t)); return _p$; }, { e: undefined, t: undefined }); - return _el$21; + return _el$31; })(); } function AccountBlock(props) { @@ -678,35 +539,33 @@ function AccountBlock(props) { const full = `${props.account.label}${props.account.titleSuffix ? ` \xB7 ${props.account.titleSuffix}` : ""}`; return columns() <= 0 ? full : fitText(full, columns() - displayWidth(props.account.status.text) - 1); }; - const age = () => quotaWindowParts(props.account.quota, { - now: context.now - }).age; + const age = () => props.account.quotaAge; const hasRows = () => props.account.groups.some((group) => group.rows.length > 0); return (() => { - var _el$28 = _$createElement("box"), _el$29 = _$createElement("box"), _el$30 = _$createElement("text"), _el$31 = _$createElement("b"), _el$32 = _$createElement("text"), _el$33 = _$createElement("b"); - _$insertNode(_el$28, _el$29); + var _el$38 = _$createElement("box"), _el$39 = _$createElement("box"), _el$40 = _$createElement("text"), _el$41 = _$createElement("b"), _el$42 = _$createElement("text"), _el$43 = _$createElement("b"); + _$insertNode(_el$38, _el$39); _$use((element) => { block = element; - }, _el$28); - _$setProp(_el$28, "onSizeChange", () => setColumns(block?.width ?? 0)); - _$setProp(_el$28, "width", "100%"); - _$setProp(_el$28, "flexDirection", "column"); - _$insertNode(_el$29, _el$30); - _$insertNode(_el$29, _el$32); - _$setProp(_el$29, "width", "100%"); - _$setProp(_el$29, "flexDirection", "row"); - _$setProp(_el$29, "justifyContent", "space-between"); - _$setProp(_el$29, "columnGap", 1); - _$insertNode(_el$30, _el$31); - _$setProp(_el$30, "wrapMode", "none"); - _$setProp(_el$30, "flexShrink", 1); - _$insert(_el$31, label); - _$insertNode(_el$32, _el$33); - _$setProp(_el$32, "wrapMode", "none"); - _$setProp(_el$32, "flexShrink", 0); - _$insert(_el$33, () => props.account.status.text); - _$insert(_el$28, () => context.beforeQuota?.(props.account), null); - _$insert(_el$28, _$createComponent(Show, { + }, _el$38); + _$setProp(_el$38, "onSizeChange", () => setColumns(block?.width ?? 0)); + _$setProp(_el$38, "width", "100%"); + _$setProp(_el$38, "flexDirection", "column"); + _$insertNode(_el$39, _el$40); + _$insertNode(_el$39, _el$42); + _$setProp(_el$39, "width", "100%"); + _$setProp(_el$39, "flexDirection", "row"); + _$setProp(_el$39, "justifyContent", "space-between"); + _$setProp(_el$39, "columnGap", 1); + _$insertNode(_el$40, _el$41); + _$setProp(_el$40, "wrapMode", "none"); + _$setProp(_el$40, "flexShrink", 1); + _$insert(_el$41, label); + _$insertNode(_el$42, _el$43); + _$setProp(_el$42, "wrapMode", "none"); + _$setProp(_el$42, "flexShrink", 0); + _$insert(_el$43, () => props.account.status.text); + _$insert(_el$38, () => context.beforeQuota?.(props.account), null); + _$insert(_el$38, _$createComponent(Show, { get when() { return hasRows(); }, @@ -768,7 +627,7 @@ function AccountBlock(props) { }); } }), null); - _$insert(_el$28, _$createComponent(Show, { + _$insert(_el$38, _$createComponent(Show, { get when() { return age(); }, @@ -779,24 +638,25 @@ function AccountBlock(props) { wrapMode: "none" }) }), null); - _$insert(_el$28, () => context.afterQuota?.(props.account), null); + _$insert(_el$38, () => context.afterQuota?.(props.account), null); _$effect((_p$) => { - var _v$9 = props.marginTop, _v$0 = context.theme.text, _v$1 = context.theme[props.account.status.tone]; - _v$9 !== _p$.e && (_p$.e = _$setProp(_el$28, "marginTop", _v$9, _p$.e)); - _v$0 !== _p$.t && (_p$.t = _$setProp(_el$30, "fg", _v$0, _p$.t)); - _v$1 !== _p$.a && (_p$.a = _$setProp(_el$32, "fg", _v$1, _p$.a)); + var _v$11 = props.marginTop, _v$12 = context.theme.text, _v$13 = context.theme[props.account.status.tone]; + _v$11 !== _p$.e && (_p$.e = _$setProp(_el$38, "marginTop", _v$11, _p$.e)); + _v$12 !== _p$.t && (_p$.t = _$setProp(_el$40, "fg", _v$12, _p$.t)); + _v$13 !== _p$.a && (_p$.a = _$setProp(_el$42, "fg", _v$13, _p$.a)); return _p$; }, { e: undefined, t: undefined, a: undefined }); - return _el$28; + return _el$38; })(); } function SidebarFrame(props) { const hasData = () => props.accounts.length > 0; - const headerLabel = () => !hasData() ? props.header.badge : `${props.collapsed ? "\u25B6" : "\u25BC"} ${props.header.badge}`; + const canCollapse = () => props.collapsible ?? hasData(); + const headerLabel = () => !canCollapse() ? props.header.badge : `${props.collapsed ? "\u25B6" : "\u25BC"} ${props.header.badge}`; const context = mergeProps(props, { get theme() { const theme = props.theme; @@ -814,38 +674,38 @@ function SidebarFrame(props) { return _$createComponent(FrameContext.Provider, { value: context, get children() { - var _el$34 = _$createElement("box"), _el$35 = _$createElement("box"), _el$36 = _$createElement("box"), _el$37 = _$createElement("text"), _el$38 = _$createElement("b"); - _$insertNode(_el$34, _el$35); - _$setProp(_el$34, "width", "100%"); - _$setProp(_el$34, "flexDirection", "column"); - _$insertNode(_el$35, _el$36); - _$setProp(_el$35, "width", "100%"); - _$setProp(_el$35, "flexDirection", "row"); - _$setProp(_el$35, "justifyContent", "space-between"); - _$setProp(_el$35, "alignItems", "center"); - _$setProp(_el$35, "onMouseDown", () => { - if (hasData()) + var _el$44 = _$createElement("box"), _el$45 = _$createElement("box"), _el$46 = _$createElement("box"), _el$47 = _$createElement("text"), _el$48 = _$createElement("b"); + _$insertNode(_el$44, _el$45); + _$setProp(_el$44, "width", "100%"); + _$setProp(_el$44, "flexDirection", "column"); + _$insertNode(_el$45, _el$46); + _$setProp(_el$45, "width", "100%"); + _$setProp(_el$45, "flexDirection", "row"); + _$setProp(_el$45, "justifyContent", "space-between"); + _$setProp(_el$45, "alignItems", "center"); + _$setProp(_el$45, "onMouseDown", () => { + if (canCollapse()) props.onToggle(); }); - _$insertNode(_el$36, _el$37); - _$setProp(_el$36, "paddingLeft", 1); - _$setProp(_el$36, "paddingRight", 1); - _$insertNode(_el$37, _el$38); - _$insert(_el$38, headerLabel); - _$insert(_el$35, _$createComponent(Show, { + _$insertNode(_el$46, _el$47); + _$setProp(_el$46, "paddingLeft", 1); + _$setProp(_el$46, "paddingRight", 1); + _$insertNode(_el$47, _el$48); + _$insert(_el$48, headerLabel); + _$insert(_el$45, _$createComponent(Show, { get when() { return props.header.version; }, get children() { - var _el$39 = _$createElement("text"); - _$insert(_el$39, () => props.header.version); - _$effect((_$p) => _$setProp(_el$39, "fg", context.theme.textMuted, _$p)); - return _el$39; + var _el$49 = _$createElement("text"); + _$insert(_el$49, () => props.header.version); + _$effect((_$p) => _$setProp(_el$49, "fg", context.theme.textMuted, _$p)); + return _el$49; } }), null); - _$insert(_el$34, _$createComponent(Show, { + _$insert(_el$44, _$createComponent(Show, { get when() { - return _$memo(() => !!props.collapsed)() && hasData(); + return _$memo(() => !!props.collapsed)() && canCollapse(); }, get children() { return [_$createComponent(For, { @@ -858,9 +718,9 @@ function SidebarFrame(props) { }), _$memo(() => props.collapsedSections?.())]; } }), null); - _$insert(_el$34, _$createComponent(Show, { + _$insert(_el$44, _$createComponent(Show, { get when() { - return !props.collapsed || !hasData(); + return !props.collapsed || !canCollapse(); }, get children() { return [_$createComponent(Show, { @@ -889,20 +749,21 @@ function SidebarFrame(props) { } }), null); _$effect((_p$) => { - var _v$10 = invertBadge() ? props.theme?.accent : undefined, _v$11 = invertBadge() ? props.theme?.background : context.theme.accent; - _v$10 !== _p$.e && (_p$.e = _$setProp(_el$36, "backgroundColor", _v$10, _p$.e)); - _v$11 !== _p$.t && (_p$.t = _$setProp(_el$37, "fg", _v$11, _p$.t)); + var _v$14 = invertBadge() ? props.theme?.accent : undefined, _v$15 = invertBadge() ? props.theme?.background : context.theme.accent; + _v$14 !== _p$.e && (_p$.e = _$setProp(_el$46, "backgroundColor", _v$14, _p$.e)); + _v$15 !== _p$.t && (_p$.t = _$setProp(_el$47, "fg", _v$15, _p$.t)); return _p$; }, { e: undefined, t: undefined }); - return _el$34; + return _el$44; } }); } export { SidebarFrame, + SidebarGaugeRow, SidebarSection, SidebarStatRow, SidebarTextRow diff --git a/packages/plugin/src/tui/sidebar-render.test.ts b/packages/plugin/src/tui/sidebar-render.test.ts index 56dd5c2523..7fe9ddda28 100644 --- a/packages/plugin/src/tui/sidebar-render.test.ts +++ b/packages/plugin/src/tui/sidebar-render.test.ts @@ -121,9 +121,17 @@ const cases = [ { snapshot: { ...healthy, usagePercentage: 65 } }, { snapshot: { ...healthy, usagePercentage: 85 } }, { snapshot: { ...healthy, compartmentInProgress: true }, width: 40 }, - { snapshot: healthy, changeTheme: true }, + { + snapshot: healthy, + toggleHeader: true, + preferences: { "magic-context": { rememberCollapsed: false } }, + }, { snapshot: { ...healthy, historianRun: { ...healthy.historianRun!, outcome: "failed" } } }, { snapshot: healthy, matchingBadgeColors: true }, + { snapshot: healthy, preferences: { "magic-context": { startCollapsed: true } } }, + { snapshot: incident, preferences: { "magic-context": { startCollapsed: true } } }, + { snapshot: null, preferences: { "magic-context": { startCollapsed: true } } }, + { snapshot: off, preferences: { "magic-context": { startCollapsed: true } } }, ]; type Result = { frame: string; @@ -187,6 +195,30 @@ function tone(index: number, text: string, color: string) { } describe("option B sidebar", () => { + test("preserves the expanded and incident render output", () => { + expect( + results.slice(0, 2).map((result) => ({ ...result, frame: result.frame.split("\n") })), + ).toMatchSnapshot(); + }); + test("collapses an account-less panel to the Option B summary rows", () => { + row(16, "Context", "45% left ●"); + row(16, "Historian", "idle · 1h55m ●"); + row(16, "Cache", "151 drops queued ●"); + expect(results[16]!.frame).toContain("▶ MAGIC CONTEXT"); + expect(results[16]!.frame).not.toMatch( + /Health|Prompt cache|Knowledge|Dreamer|Next summary|█/, + ); + expect(results[13]!.frame).toBe(results[16]!.frame); + row(17, "Historian", "fallback · 1m ●"); + row(17, "Health", "2 issues ●"); + tone(17, "2 issues ●", "#ff8800"); + expect(results[17]!.frame).not.toMatch(/4\/4 rejected|shows details|Last run/); + row(18, "Context", "no reading ●"); + row(18, "Historian", "no reading ●"); + row(18, "Cache", "no reading ●"); + row(19, "Context", "45% left ●"); + expect(results[19]!.frame).not.toMatch(/Historian|Cache|Health/); + }); test("draws the healthy expanded sections with actionable snapshot values", () => { for (const title of [ "MAGIC CONTEXT", @@ -250,7 +282,7 @@ describe("option B sidebar", () => { /History|Prompt cache|Next summary|4\/4 rejected|Fallback/, ); }); - test("maps existing visibility preferences without using the stored collapse value", () => { + test("maps visibility preferences and restores the stored collapse value", () => { expect(results[4]!.frame).toContain("CUSTOM"); expect(results[4]!.frame).toContain("Context"); expect(results[4]!.frame).not.toMatch(/History|Prompt cache|Knowledge|Dreamer/); @@ -286,9 +318,6 @@ describe("option B sidebar", () => { tone(11, "15% left", "#ff0000"); expect(results[12]!.frame).toContain("summarizing"); }); - test("updates the gauge tone when the host replaces its theme", () => { - tone(13, "45% left", "#00aaff"); - }); test("the raw-source frame bundle draws the same expanded view", () => { expect(rawResult.frame).toBe(results[0]!.frame); expect(rawResult.lines).toEqual(results[0]!.lines); diff --git a/packages/plugin/src/tui/slots/sidebar-content.tsx b/packages/plugin/src/tui/slots/sidebar-content.tsx index f5065e5271..9d61e7d10e 100644 --- a/packages/plugin/src/tui/slots/sidebar-content.tsx +++ b/packages/plugin/src/tui/slots/sidebar-content.tsx @@ -1,17 +1,7 @@ /** @jsxImportSource @opentui/solid */ import type { TuiPluginApi, TuiSlotPlugin, TuiThemeCurrent } from "@opencode-ai/plugin/tui"; -import { - createContext, - createEffect, - createMemo, - createSignal, - For, - on, - onCleanup, - Show, - useContext, -} from "solid-js"; +import { createEffect, createMemo, createSignal, For, on, onCleanup, Show } from "solid-js"; import packageJson from "../../../package.json"; import { formatThresholdPercent } from "../../shared/format-threshold"; import { @@ -32,7 +22,13 @@ import { compactionOffSidebarRows } from "../compaction-off"; import { loadSidebarSnapshot, type SidebarSnapshot } from "../data/context-db"; import { createRefreshScheduler } from "../data/refresh-scheduler"; import { directoryForSession } from "../data/session-directory"; -import { SidebarFrame, SidebarSection, SidebarStatRow, SidebarTextRow } from "../quota-sidebar.js"; +import { + SidebarFrame, + SidebarGaugeRow, + SidebarSection, + SidebarStatRow, + SidebarTextRow, +} from "../quota-sidebar.js"; // Recomp commands do not send parent-session message events. let activeRecompPollKick: (() => void) | null = null; @@ -135,48 +131,6 @@ function present(value: number | null | undefined): value is number { return typeof value === "number" && Number.isFinite(value); } -// The frame does not export SidebarGaugeRow (common-auth#12). -const GaugeTheme = - createContext< - () => Pick - >(); - -function SidebarGaugeRow(props: { - label: string; - used: number | undefined; - value?: string; - warnAt?: number; - errorAt?: number; -}) { - const theme = useContext(GaugeTheme); - const known = () => present(props.used); - const used = () => Math.max(0, Math.min(100, props.used ?? 0)); - const filled = () => Math.round(used() / 10); - const tone = () => - !known() - ? theme?.().textMuted - : used() >= (props.errorAt ?? 80) - ? theme?.().error - : used() >= (props.warnAt ?? 50) - ? theme?.().warning - : theme?.().success; - return ( - - - {props.label} - - {known() - ? `${"█".repeat(filled())}${"░".repeat(10 - filled())} ${Math.round(100 - used())}% left` - : "no reading"} - - - - {props.value ?? "—"} - - - ); -} - const SidebarContent = (props: { api: TuiPluginApi; sessionID: () => string; @@ -388,139 +342,180 @@ const SidebarContent = (props: { return rows; }); + const collapsedContext = () => { + const used = contextUsage(); + return present(used) + ? `${Math.round(100 - Math.max(0, Math.min(100, used)))}% left ●` + : "no reading ●"; + }; + const collapsedHistorian = () => { + const data = s(); + if (!data) return "no reading ●"; + const run = data.historianRun; + const state = + run?.outcome === "failed" || run?.outcome === "fallback" + ? run.outcome + : data.historianRunning || data.compartmentInProgress + ? "summarizing" + : "idle"; + return `${state}${run ? ` · ${relativeTime(run.finishedAt).replace(/ ago$/, "")}` : ""} ●`; + }; + return ( - props.theme}> - {}} - sections={() => ( - <> - - ( + <> + + + + + + + + 0}> + row.tone === "error") + ? "error" + : "warning" + } + /> + + + )} + sections={() => ( + <> + + + } + > + + + + + + + + + + + + + + + + + + + + + + + } + when={compactionOff()} + fallback={ + <> + + + + + + + + } > - + + {(row) => ( + + )} + - - - - - - - + + + + + + {(p: () => NonNullable) => ( - - - - - - - - - - - - - - - - - - - - - } - > - - {(row) => ( - - )} - - - - - - - - - {(p: () => NonNullable) => ( - - )} - - - - 0}> - - - {(row) => ( - - )} - - - - - - )} - /> - + )} + + + + 0}> + + + {(row) => ( + + )} + + + + + + )} + /> ); }; From 86c8aec0fc529ce4ce372d66f3af19b333b271f9 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 12 Nov 2026 08:38:43 +0000 Subject: [PATCH 7/8] fix(sidebar): correct snapshot counts and review regressions --- bun.lock | 30 +++---- packages/plugin/package.json | 8 +- packages/plugin/scripts/build-tui.test.ts | 50 +++++++++++ packages/plugin/scripts/build-tui.ts | 11 ++- .../plugin/scripts/render-compiled-sidebar.ts | 37 ++++---- .../memory/storage-memory.test.ts | 13 +++ .../magic-context/memory/storage-memory.ts | 19 ++-- .../magic-context/compartment-trigger.test.ts | 20 +++++ .../magic-context/compartment-trigger.ts | 8 +- .../magic-context/sidebar-observations.ts | 4 + .../rpc-handlers-sidebar-option-b.test.ts | 28 +++--- packages/plugin/src/plugin/rpc-handlers.ts | 9 +- packages/plugin/src/shared/rpc-types.ts | 1 + .../plugin/src/tui-compiled/quota-sidebar.js | 2 +- .../tui-compiled/slots/sidebar-content.tsx | 41 ++++++++- .../__snapshots__/sidebar-render.test.ts.snap | 6 +- .../plugin/src/tui/data/context-db.test.ts | 2 +- packages/plugin/src/tui/quota-sidebar.js | 2 +- .../plugin/src/tui/sidebar-render.test.ts | 88 ++++++++++++++++--- .../plugin/src/tui/slots/sidebar-content.tsx | 40 +++++++-- 20 files changed, 322 insertions(+), 97 deletions(-) create mode 100644 packages/plugin/scripts/build-tui.test.ts diff --git a/bun.lock b/bun.lock index 7c5b969a15..9879d68fdd 100644 --- a/bun.lock +++ b/bun.lock @@ -160,8 +160,8 @@ "@opencode/cli": "2.0.22", "@opencode/client": "2.0.22", "@opencode/plugin": "2.0.22", - "@opentui/core": "^0.4.2", - "@opentui/solid": "^0.4.2", + "@opentui/core": "^0.4.5", + "@opentui/solid": "^0.4.5", "@types/better-sqlite3": "^7.6.13", "@types/bun": "^1.3.10", "@types/node": "^22.20.0", @@ -177,8 +177,8 @@ }, "peerDependencies": { "@opencode-ai/plugin": ">=1.15.0", - "@opentui/core": ">=0.4.2", - "@opentui/solid": ">=0.4.2", + "@opentui/core": ">=0.4.5", + "@opentui/solid": ">=0.4.5", "solid-js": "^1.9.12", }, "optionalPeers": [ @@ -830,25 +830,25 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@opentui/core": ["@opentui/core@0.4.2", "", { "dependencies": { "bun-ffi-structs": "0.2.3", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.2", "@opentui/core-darwin-x64": "0.4.2", "@opentui/core-linux-arm64": "0.4.2", "@opentui/core-linux-arm64-musl": "0.4.2", "@opentui/core-linux-x64": "0.4.2", "@opentui/core-linux-x64-musl": "0.4.2", "@opentui/core-win32-arm64": "0.4.2", "@opentui/core-win32-x64": "0.4.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ulx6RMqftf2fm7Itf9e81GcCDMNY6NAhmnKYhllDOMYD+PxYXR+vomy2bxQNV5ow31RE7s8WQFnb7hWTRUbx2g=="], + "@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-is+O+sS/l3E9cZXyM9pRF1WhqnE+hYSPYoZkbseR9CthJcaWPGi3R3jUJa1cLj325252jWgxVupnDqFUtKg36w=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ACi42h81DurSeybUAD1XyKT6xmXZcKeTxS54lZFi0CVZh46w0g99vNj8PlQzIFXvvFLT0e0IlRS//eWSWS2zGQ=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-RjOx2HcjLRtGSy9WrAGSdr5M9SpJuPifPORpImx6Mciovw0ltnE0uoYjIyor82uf6/LExWC7YA2AcAl+YBxayA=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-heNciL2ngPU+kq1h01PHLsxn6Fr8iqTFtbxSdVbhaY3XihuIjkuXyEhFeuoa1lsXY7Bb2gpWnX5EQVWnZsAuDQ=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-9s0s/ooK+AhWP306By3gu+XhzcVEThC2sqKMPK1nQmGDujQhd+xOrtbtfCVcJSx62UzAovC2VNqypvP8vHByOg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cjv6Bv7l3p/KLNJr5RyqCS0FmRlAGJnkA2IK3S+HkHhCOv/O02S1G+DBUY6POnyjp1eNy95vauustApobhdbig=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mfJZrJ0TNPFRZUzXNsxAPe1YdiWsy/vbTl93+yeXGHPI1B8Qnk9V5hpzSxxEyBGhlTHSfGNtgiO+VrrdRC3kZA=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-P2oguG3ng3OMjAdasFSA3GhHaQXtzDUsIRDGbzWFOimpZ/zMemidp+JQ0V8V6XwK6Utk5G0aQ03oBaRCoLyYDw=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="], - "@opentui/solid": ["@opentui/solid@0.4.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zuYXsnrlsMtnXrS7QCYBdPzMtUSonG2LqnJikBR2NjEE2O4zEKvJd48n3eB1igcxjv96tiotTXRNCylYS0SNdQ=="], + "@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="], "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], @@ -1226,7 +1226,7 @@ "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], - "bun-ffi-structs": ["bun-ffi-structs@0.2.3", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-pgJiXP+hEgFo9qG51J6ItfY4ocs3vniwNzJ9WhoakB3QB2GdzQxX2EXssentPYlB2hOfJrTjO6iIQkWYzUodpg=="], + "bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="], "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 3b357e4c36..7462cc2d7d 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -74,8 +74,8 @@ "sharp": "^0.35.0" }, "devDependencies": { - "@opentui/core": "^0.4.2", - "@opentui/solid": "^0.4.2", + "@opentui/core": "^0.4.5", + "@opentui/solid": "^0.4.5", "solid-js": "1.9.12", "@opencode/plugin": "2.0.22", "@opencode/ai": "2.0.22", @@ -109,8 +109,8 @@ ], "peerDependencies": { "@opencode-ai/plugin": ">=1.15.0", - "@opentui/core": ">=0.4.2", - "@opentui/solid": ">=0.4.2", + "@opentui/core": ">=0.4.5", + "@opentui/solid": ">=0.4.5", "solid-js": "^1.9.12" }, "peerDependenciesMeta": { diff --git a/packages/plugin/scripts/build-tui.test.ts b/packages/plugin/scripts/build-tui.test.ts new file mode 100644 index 0000000000..670d839884 --- /dev/null +++ b/packages/plugin/scripts/build-tui.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test"; +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import packageJson from "../package.json"; + +test("declares the OpenTUI minimum required by the shared sidebar", () => { + const sidebar = JSON.parse( + readFileSync( + join( + dirname(Bun.resolveSync("@cortexkit/opencode-sidebar", import.meta.dir)), + "../package.json", + ), + "utf8", + ), + ) as { peerDependencies: Record }; + for (const name of ["@opentui/core", "@opentui/solid"] as const) { + expect(packageJson.peerDependencies[name]).toMatch(/^>=\d+\.\d+\.\d+$/); + expect( + Bun.semver.satisfies( + packageJson.peerDependencies[name].slice(2), + sidebar.peerDependencies[name], + ), + ).toBe(true); + expect( + Bun.semver.satisfies( + packageJson.devDependencies[name].slice(1), + sidebar.peerDependencies[name], + ), + ).toBe(true); + } +}); + +test("removes obsolete generated JavaScript without removing the current sidebar bundle", () => { + const stale = join(import.meta.dir, "../src/tui-compiled/obsolete-sidebar.js"); + writeFileSync(stale, "export {};\n"); + try { + const result = Bun.spawnSync([process.execPath, join(import.meta.dir, "build-tui.ts")], { + env: { ...process.env, BUN_BE_BUN: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + expect(result.exitCode, result.stderr.toString()).toBe(0); + expect(existsSync(stale)).toBe(false); + expect(existsSync(join(import.meta.dir, "../src/tui-compiled/quota-sidebar.js"))).toBe( + true, + ); + } finally { + rmSync(stale, { force: true }); + } +}, 120_000); diff --git a/packages/plugin/scripts/build-tui.ts b/packages/plugin/scripts/build-tui.ts index e5c43f3b10..1f4d8462f6 100644 --- a/packages/plugin/scripts/build-tui.ts +++ b/packages/plugin/scripts/build-tui.ts @@ -159,7 +159,7 @@ function isShippedSourceFile(filePath: string): boolean { return filePath.endsWith(".tsx") || filePath.endsWith(".ts"); } -async function listSourceFiles(dir: string): Promise { +async function listSourceFiles(dir: string, includeJavaScript = false): Promise { const entries = await readdir(dir, { withFileTypes: true }); entries.sort((a, b) => a.name.localeCompare(b.name)); @@ -167,8 +167,11 @@ async function listSourceFiles(dir: string): Promise { for (const entry of entries) { const entryPath = join(dir, entry.name); if (entry.isDirectory()) { - files.push(...(await listSourceFiles(entryPath))); - } else if (entry.isFile() && isShippedSourceFile(entryPath)) { + files.push(...(await listSourceFiles(entryPath, includeJavaScript))); + } else if ( + entry.isFile() && + (isShippedSourceFile(entryPath) || (includeJavaScript && entryPath.endsWith(".js"))) + ) { files.push(entryPath); } } @@ -288,7 +291,7 @@ for (const sourceFile of files) { } let stale = 0; -for (const existing of await listSourceFiles(outputRoot)) { +for (const existing of await listSourceFiles(outputRoot, true)) { if (expectedOutputs.has(existing)) continue; await rm(existing, { force: true }); stale += 1; diff --git a/packages/plugin/scripts/render-compiled-sidebar.ts b/packages/plugin/scripts/render-compiled-sidebar.ts index 0110912601..a1d43375ca 100644 --- a/packages/plugin/scripts/render-compiled-sidebar.ts +++ b/packages/plugin/scripts/render-compiled-sidebar.ts @@ -12,6 +12,7 @@ type RenderCase = { width?: number; preferences?: Record; toggleHeader?: boolean; + now?: number; matchingBadgeColors?: boolean; }; @@ -59,6 +60,7 @@ const actualPrefs = await import(prefsUrl); mock.module(prefsUrl, () => ({ ...actualPrefs, readTuiPreferencesFileSync: () => preferences, + queueTuiPreferenceUpdate: async () => {}, watchTuiPreferences: () => () => {}, })); if (process.argv.includes("--raw-frame")) { @@ -74,6 +76,9 @@ const { testRender } = await import("@opentui/solid"); const inputs = JSON.parse(await Bun.stdin.text()) as RenderCase[]; const results = []; for (const entry of inputs) { + const realNow = Date.now; + const fixtureNow = entry.now; + if (fixtureNow !== undefined) Date.now = () => fixtureNow; snapshot = entry.snapshot; preferences = entry.preferences ?? {}; const prefs = resolveMagicContextPrefs(preferences); @@ -135,22 +140,24 @@ for (const entry of inputs) { } catch (error) { results.push({ error: error instanceof Error ? error.stack : String(error) }); } finally { + Date.now = realNow; slot.dispose(); } } -process.stdout.write( - process.argv.includes("--frames") - ? results - .map((result) => - result.frame !== undefined - ? result.frame - .split("\n") - .map((line) => line.trimEnd()) - .join("\n") - .trimEnd() - : result.error, - ) - .join("\n\n") - : JSON.stringify(results), -); +const output = process.argv.includes("--frames") + ? results + .map((result) => + result.frame !== undefined + ? result.frame + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .trimEnd() + : result.error, + ) + .join("\n\n") + : JSON.stringify(results); +await new Promise((resolve, reject) => { + process.stdout.write(output, (error) => (error ? reject(error) : resolve())); +}); process.exit(0); diff --git a/packages/plugin/src/features/magic-context/memory/storage-memory.test.ts b/packages/plugin/src/features/magic-context/memory/storage-memory.test.ts index b5a9a4ef52..c408a43658 100644 --- a/packages/plugin/src/features/magic-context/memory/storage-memory.test.ts +++ b/packages/plugin/src/features/magic-context/memory/storage-memory.test.ts @@ -30,9 +30,22 @@ import { updateMemoryVerification, } from "./index"; import { computeNormalizedHash } from "./normalize-hash"; +import { countNewMemoriesForM1Union } from "./storage-memory"; let db: Database; +it("counts only post-cache memory rows accepted by the visible-memory reader", () => { + db = makeMemoryDatabase(); + const insert = db.prepare(`INSERT INTO memories + (project_path, category, content, normalized_hash, source_type, + first_seen_at, created_at, updated_at, last_seen_at) + VALUES ('project', 'CONSTRAINTS', 'memory', ?, ?, 1, 1, 1, 1)`); + insert.run("valid", "historian"); + insert.run("invalid", "external-unknown-source"); + expect(readNewMemoriesForM1Union(db, ["project"], 0, 100)).toHaveLength(1); + expect(countNewMemoriesForM1Union(db, ["project"], 0, 100)).toBe(1); +}); + function makeMemoryDatabase(): Database { const database = new Database(":memory:"); database.exec(` diff --git a/packages/plugin/src/features/magic-context/memory/storage-memory.ts b/packages/plugin/src/features/magic-context/memory/storage-memory.ts index 6d3b9c94a0..29bed68582 100644 --- a/packages/plugin/src/features/magic-context/memory/storage-memory.ts +++ b/packages/plugin/src/features/magic-context/memory/storage-memory.ts @@ -951,21 +951,14 @@ export function countNewMemoriesForM1Union( ownIdentities?: readonly string[], shareCategories?: readonly string[] | null, ): number { - const identities = uniqueValues(projectPaths); - if (identities.length === 0) return 0; - const sharingFilter = buildWorkspaceMemorySqlFilter({ - identities, + return readNewMemoriesForM1Union( + db, + projectPaths, + afterId, + expiryCutoff, ownIdentities, shareCategories, - includeClassificationFields: hasMemoryShareableColumn(db) && hasMemoryScopeColumn(db), - }); - const row = db - .prepare(`SELECT COUNT(*) AS count FROM memories - WHERE project_path IN (${sqlPlaceholders(identities)}) AND id > ? - AND status IN ('active', 'permanent') - AND (expires_at IS NULL OR expires_at > ?)${sharingFilter.clause}`) - .get(...identities, afterId, expiryCutoff, ...sharingFilter.params) as { count: number }; - return row.count; + ).length; } export function readNewMemoriesForM1Union( diff --git a/packages/plugin/src/hooks/magic-context/compartment-trigger.test.ts b/packages/plugin/src/hooks/magic-context/compartment-trigger.test.ts index b0a8710009..84bf7d273a 100644 --- a/packages/plugin/src/hooks/magic-context/compartment-trigger.test.ts +++ b/packages/plugin/src/hooks/magic-context/compartment-trigger.test.ts @@ -38,6 +38,7 @@ import { import { resolveOpenCodeProtectedTailBoundary } from "./protected-tail-boundary"; import { withRawMessageProvider } from "./read-session-chunk"; import type { RawMessage } from "./read-session-raw"; +import { rememberWaitingMessages, sidebarObservations } from "./sidebar-observations"; import type { TagTarget } from "./tag-messages"; import { prewarmTagOwners } from "./tag-owner-warmup"; @@ -383,6 +384,25 @@ function insertCoveredMessageTag( } describe("checkCompartmentTrigger", () => { + it("invalidates a stale waiting count when a below-floor small tail cheap-skips", () => { + useTempDataHome("compartment-trigger-waiting-"); + const db = openDatabase(); + const sessionId = "ses-waiting-cheap-skip"; + seedTriggerPolicy(db, sessionId); + insertCoveredMessageTag(db, sessionId, "small", 1, 10); + rememberWaitingMessages(sessionId, 140); + const result = checkCompartmentTrigger( + db, + sessionId, + makeSessionMeta(sessionId, 25), + { percentage: 25, inputTokens: 100 }, + 25, + 65, + 1000, + ); + expect(result.shouldFire).toBe(false); + expect(sidebarObservations(sessionId).waitingMessages).toBeUndefined(); + }); it("cheap-skips the in-memory tail below the proactive floor when persisted plus untagged tokens are under budget", () => { useTempDataHome("compartment-trigger-memory-skip-"); const db = openDatabase(); diff --git a/packages/plugin/src/hooks/magic-context/compartment-trigger.ts b/packages/plugin/src/hooks/magic-context/compartment-trigger.ts index e634950ea0..c5bca6b519 100644 --- a/packages/plugin/src/hooks/magic-context/compartment-trigger.ts +++ b/packages/plugin/src/hooks/magic-context/compartment-trigger.ts @@ -48,7 +48,7 @@ import { import { estimateTrueRawMessageTokens } from "./read-session-true-raw-tokens"; import { reasoningBudgetCutoff } from "./reasoning-budget"; import { modelAcceptsEmptyContent } from "./sentinel"; -import { rememberWaitingMessages } from "./sidebar-observations"; +import { invalidateWaitingMessages, rememberWaitingMessages } from "./sidebar-observations"; const PROACTIVE_TRIGGER_OFFSET_PERCENTAGE = 2; const POST_DROP_TARGET_RATIO = 0.75; @@ -467,6 +467,7 @@ function getUnsummarizedTailInfo( boundarySnapshot: boundary, }; } catch (error) { + invalidateWaitingMessages(sessionId); sessionLog(sessionId, "compartment trigger: raw tail inspection failed:", error); return TAIL_INFO_DEFAULTS; } @@ -515,8 +516,10 @@ export function checkCompartmentTrigger( contextLimit: resolveBoundaryContextLimit(usage, contextLimit), executeThresholdPercentage, }) - ) + ) { + invalidateWaitingMessages(sessionId); return { shouldFire: false }; + } const lazyInMemoryTail = typeof inMemoryTail === "function" ? inMemoryTail : undefined; let resolvedInMemoryTail = typeof inMemoryTail === "function" ? undefined : inMemoryTail; @@ -621,6 +624,7 @@ export function checkCompartmentTrigger( // (commit_clusters). tail_size needs even more. Equality falls // through to preserve the existing conservative < semantics. if (eligibleUpperBound < triggerBudget) { + invalidateWaitingMessages(sessionId); const memorySuffix = resolvedInMemoryTail ? ` (persisted=${persistedBound}, untagged-memory≤${untaggedUpperBound})` : ""; diff --git a/packages/plugin/src/hooks/magic-context/sidebar-observations.ts b/packages/plugin/src/hooks/magic-context/sidebar-observations.ts index f4e4bf64e7..855272c0c7 100644 --- a/packages/plugin/src/hooks/magic-context/sidebar-observations.ts +++ b/packages/plugin/src/hooks/magic-context/sidebar-observations.ts @@ -16,6 +16,10 @@ export function rememberWaitingMessages(sessionId: string, count: number): void waitingMessages.set(sessionId, Math.max(0, count)); } +export function invalidateWaitingMessages(sessionId: string): void { + waitingMessages.delete(sessionId); +} + export function beginSidebarHistorianRun(sessionId: string) { const generation = generations.get(sessionId) ?? {}; generations.set(sessionId, generation); diff --git a/packages/plugin/src/plugin/rpc-handlers-sidebar-option-b.test.ts b/packages/plugin/src/plugin/rpc-handlers-sidebar-option-b.test.ts index 66db8e6f71..eb797edfa5 100644 --- a/packages/plugin/src/plugin/rpc-handlers-sidebar-option-b.test.ts +++ b/packages/plugin/src/plugin/rpc-handlers-sidebar-option-b.test.ts @@ -3,6 +3,8 @@ import { appendCompartments } from "../features/magic-context/compartment-storag import { resolveProjectIdentity } from "../features/magic-context/memory/project-identity"; import { runMigrations } from "../features/magic-context/migrations"; import { initializeDatabase } from "../features/magic-context/storage-db"; +import { queuePendingDropForUnchangedTag } from "../features/magic-context/storage-ops"; +import { getTagsBySession } from "../features/magic-context/storage-tags"; import { Database } from "../shared/sqlite"; import { buildSidebarSnapshot } from "./rpc-handlers"; @@ -35,24 +37,26 @@ test("N3 exposes the proactive floor beside the resolved execute threshold", () test("N4 sums only this session's queued drop tokens with indexed lookups", () => { const { db, directory, session } = fixture(); const insert = db.prepare( - "INSERT INTO tags(session_id, message_id, tag_number, type, token_count) VALUES (?, ?, ?, 'text', ?)", - ); - const first = Number(insert.run(session, "m1", 1, 123).lastInsertRowid); - const second = Number(insert.run(session, "m2", 2, 456).lastInsertRowid); - db.prepare("INSERT INTO pending_ops(session_id, tag_id, operation) VALUES (?, ?, 'drop')").run( - session, - first, + "INSERT INTO tags(session_id, message_id, tag_number, type, byte_size, token_count) VALUES (?, ?, ?, 'message', 10, ?)", ); + insert.run("other", "decoy", 101, 999); + insert.run(session, "m1", 101, 123); + insert.run(session, "m2", 102, 456); + const first = getTagsBySession(db, session).find((tag) => tag.tagNumber === 101)!; + expect(first.id).not.toBe(first.tagNumber); + expect(queuePendingDropForUnchangedTag(db, session, first)).toBe(true); db.prepare("INSERT INTO pending_ops(session_id, tag_id, operation) VALUES (?, ?, 'keep')").run( session, - second, + 102, ); db.prepare( "INSERT INTO pending_ops(session_id, tag_id, operation) VALUES ('other', ?, 'drop')", - ).run(second); + ).run(101); const prepared = spyOn(db, "prepare"); const snapshot = buildSidebarSnapshot(db, session, directory); expect(snapshot.queuedDropTokens).toBe(123); + expect(snapshot.queuedDropCount).toBe(1); + expect(snapshot.pendingOpsCount).toBe(2); const sql = prepared.mock.calls .map(([sql]) => sql) .find((sql) => /SUM\(.*token_count/.test(sql)); @@ -102,7 +106,7 @@ test("N5 counts post-cache compartments and visible unexpired active or permanen prepared.mockRestore(); for (const [label, pattern, params] of [ ["N5 summaries", /COUNT\(\*\).*compartments.*sequence\s*>/s, [session, 0]], - ["N5 memories", /COUNT\(\*\).*memories.*id\s*>/s, [project, 1, Date.now()]], + ["N5 memories", /SELECT.*FROM memories.*id\s*>/s, [project, 1, Date.now()]], ] as const) { const sql = queries.find((sql) => pattern.test(sql)); expect(sql).toBeDefined(); @@ -138,11 +142,11 @@ test("N5 uses workspace sharing and classification from the render policy", () = db.prepare(`INSERT INTO memories(project_path, category, content, normalized_hash, status, shareable, scope, first_seen_at, last_seen_at, created_at, updated_at) VALUES (?, ?, 'memory', ?, ?, ?, ?, 1, 1, 1, 1)`); - insert.run(project, "CODE", "own", "active", 0, "project"); + insert.run(project, "ARCHITECTURE", "own", "active", 0, "project"); insert.run("git:foreign", "CONSTRAINTS", "shared", "active", 1, "ecosystem"); insert.run("git:foreign", "CONSTRAINTS", "permanent", "permanent", 1, "universe"); insert.run("git:foreign", "CONSTRAINTS", "private", "active", 0, "project"); - insert.run("git:foreign", "CODE", "not-shared", "active", 1, "ecosystem"); + insert.run("git:foreign", "ARCHITECTURE", "not-shared", "active", 1, "ecosystem"); insert.run("git:foreign", "CONSTRAINTS", "session-only", "active", 1, "session"); expect(buildSidebarSnapshot(db, session, directory).memoriesSinceCache).toBe(3); }); diff --git a/packages/plugin/src/plugin/rpc-handlers.ts b/packages/plugin/src/plugin/rpc-handlers.ts index a9d7b74067..7cdb19b98e 100644 --- a/packages/plugin/src/plugin/rpc-handlers.ts +++ b/packages/plugin/src/plugin/rpc-handlers.ts @@ -453,22 +453,26 @@ export function buildSidebarSnapshot( let pendingOpsCount = 0; let queuedDropTokens = 0; + let queuedDropCount = 0; try { const pendingRow = db - .prepare<[string], { count: number; tokens: number }>( + .prepare<[string], { count: number; tokens: number; drops: number }>( `SELECT COUNT(*) AS count, + COALESCE(SUM(CASE WHEN p.operation = 'drop' THEN 1 ELSE 0 END), 0) AS drops, COALESCE(SUM(CASE WHEN p.operation = 'drop' THEN t.token_count ELSE 0 END), 0) AS tokens - FROM pending_ops p LEFT JOIN tags t ON t.id = p.tag_id AND t.session_id = p.session_id + FROM pending_ops p LEFT JOIN tags t ON t.tag_number = p.tag_id AND t.session_id = p.session_id WHERE p.session_id = ?`, ) .get(sessionId); pendingOpsCount = pendingRow?.count ?? 0; queuedDropTokens = pendingRow?.tokens ?? 0; + queuedDropCount = pendingRow?.drops ?? 0; } catch { // pending_ops table may not exist } if (typeof moduleStatus?.pending_drop_count === "number") { pendingOpsCount = moduleStatus.pending_drop_count; + queuedDropCount = moduleStatus.pending_drop_count; } let sessionNoteCount = 0; @@ -747,6 +751,7 @@ export function buildSidebarSnapshot( memoryBlockCount, pendingOpsCount, queuedDropTokens, + queuedDropCount, ...(config?.transform_mode === "rust" ? {} : sidebarObservations(sessionId)), proactiveFloor: getProactiveCompartmentTriggerPercentage(executeThreshold), cachedM0MaterializedAt, diff --git a/packages/plugin/src/shared/rpc-types.ts b/packages/plugin/src/shared/rpc-types.ts index ab3de4602c..7a0afb95fb 100644 --- a/packages/plugin/src/shared/rpc-types.ts +++ b/packages/plugin/src/shared/rpc-types.ts @@ -70,6 +70,7 @@ export interface SidebarSnapshot { memoryCount: number; memoryBlockCount: number; pendingOpsCount: number; + queuedDropCount?: number; queuedDropTokens?: number; historianRun?: SidebarHistorianRun; waitingMessages?: number; diff --git a/packages/plugin/src/tui-compiled/quota-sidebar.js b/packages/plugin/src/tui-compiled/quota-sidebar.js index e0e2355ba0..5578369abc 100644 --- a/packages/plugin/src/tui-compiled/quota-sidebar.js +++ b/packages/plugin/src/tui-compiled/quota-sidebar.js @@ -1,5 +1,5 @@ // @bun -// ../../node_modules/.bun/@cortexkit+opencode-sidebar@0.2.0+4f25175b99662f85/node_modules/@cortexkit/opencode-sidebar/src/index.tsx +// ../../node_modules/.bun/@cortexkit+opencode-sidebar@0.2.0+a24dd7ca87f30650/node_modules/@cortexkit/opencode-sidebar/src/index.tsx import { createComponent as _$createComponent } from "opentui:runtime-module:%40opentui%2Fsolid"; import { createTextNode as _$createTextNode } from "opentui:runtime-module:%40opentui%2Fsolid"; import { use as _$use } from "opentui:runtime-module:%40opentui%2Fsolid"; diff --git a/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx b/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx index 09fde4220b..28b8ca38d8 100644 --- a/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx +++ b/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx @@ -153,6 +153,7 @@ const SidebarContent = props => { }; const contextUsage = () => { const data = s(); + if (compactionOff() && present(data?.native_context_usage_percentage)) return data.native_context_usage_percentage; if (!data || data.contextLimit <= 0) return undefined; return compactionOff() ? data.inputTokens / data.contextLimit * 100 : present(data.usagePercentage) ? data.usagePercentage : undefined; }; @@ -172,10 +173,10 @@ const SidebarContent = props => { return { lastRun: run ? `${relativeTime(run.finishedAt)} · ${run.outcome}` : "—", model: modelName(run?.model), - waiting: present(data?.waitingMessages) ? `${data?.waitingMessages} msgs` : "—", + waiting: present(data?.waitingMessages) ? `${data?.waitingMessages} msgs` : "no reading", cacheBuilt: present(cacheAt) ? relativeTime(cacheAt) : "—", sinceThen: present(data?.summariesSinceCache) && present(data?.memoriesSinceCache) ? `${data?.summariesSinceCache} summaries · ${data?.memoriesSinceCache} memories` : "—", - drops: present(data?.pendingOpsCount) && present(drops) ? `${data?.pendingOpsCount} · ≈${compactTokens(drops)} at rebuild` : "—", + drops: present(data?.queuedDropCount) && present(drops) ? `${data?.queuedDropCount} · ≈${compactTokens(drops)} at rebuild` : "—", memories: present(data?.memoryCount) ? `${data?.memoryCount} · ${present(data?.memoriesSinceCache) ? data?.memoriesSinceCache : "—"} new` : "—", notes: present(data?.sessionNoteCount) && present(data?.readySmartNoteCount) ? `${data?.sessionNoteCount} · ${data?.readySmartNoteCount} smart waiting` : "—", dreamerLastRun: present(lastDream) ? `${relativeTime(lastDream)} · ${data?.dreamerFailures?.length ? "failed" : "ok"}` : "—", @@ -210,7 +211,7 @@ const SidebarContent = props => { }); if (counts && counts.fallbackRuns > 0) rows.push({ label: "Fallback", - value: `${modelName(historian?.model).split(" ")[0]} finished ${counts.fallbackRuns} runs`, + value: `finished ${counts.fallbackRuns} runs`, tone: "warning" }); if (historian?.outcome === "failed" || (data.historianFailureCount ?? 0) > 0) rows.push({ @@ -224,6 +225,20 @@ const SidebarContent = props => { value: `${data.dreamerFailures.length} tasks failed`, tone: "warning" }); + const rebuild = data.recompProgress; + if (rebuild?.phase === "failed" || rebuild?.phase === "skipped") { + const verb = { + recomp: "Recomp", + upgrade: "Upgrade", + embed: "Embed", + wrapup: "Wrap-up" + }[rebuild.kind ?? "recomp"]; + rows.push({ + label: verb, + value: rebuild.phase === "failed" ? `✗ ${verb} failed` : "stopped", + tone: rebuild.phase === "failed" ? "error" : "warning" + }); + } if (data.lastTransformError) rows.push({ label: "Transform", value: shortText(data.lastTransformError), @@ -315,7 +330,7 @@ const SidebarContent = props => { return _$createComponent(SidebarStatRow, { label: "Cache", get value() { - return _$memo(() => !!present(s()?.pendingOpsCount))() ? `${s()?.pendingOpsCount} drops queued ●` : "no reading ●"; + return _$memo(() => !!present(s()?.queuedDropCount))() ? `${s()?.queuedDropCount} drops queued ●` : "no reading ●"; } }); } @@ -552,6 +567,24 @@ const SidebarContent = props => { return row.tone; } }) + }), _$createComponent(Show, { + get when() { + return s()?.recompProgress?.phase === "failed" || s()?.recompProgress?.phase === "skipped"; + }, + get children() { + return _$createComponent(Show, { + get when() { + return s()?.recompProgress?.message; + }, + get children() { + return _$createComponent(SidebarTextRow, { + get text() { + return s()?.recompProgress?.message ?? ""; + } + }); + } + }); + } }), _$createComponent(SidebarTextRow, { text: "/ctx-status shows details" })]; diff --git a/packages/plugin/src/tui/__snapshots__/sidebar-render.test.ts.snap b/packages/plugin/src/tui/__snapshots__/sidebar-render.test.ts.snap index 7db863c92f..18ff00d7e4 100644 --- a/packages/plugin/src/tui/__snapshots__/sidebar-render.test.ts.snap +++ b/packages/plugin/src/tui/__snapshots__/sidebar-render.test.ts.snap @@ -699,7 +699,7 @@ exports[`option B sidebar preserves the expanded and incident render output 1`] " ", "Health ", "Historian Gemini 4/4 rejected", - "Fallback DeepSeek finished 2 runs", + "Fallback finished 2 runs", "/ctx-status shows details ", " ", " ", @@ -1122,12 +1122,12 @@ exports[`option B sidebar preserves the expanded and incident render output 1`] { "attributes": 0, "fg": "#ffffff", - "text": " ", + "text": " ", }, { "attributes": 1, "fg": "#ff8800", - "text": "DeepSeek finished 2 runs", + "text": "finished 2 runs", }, ], [ diff --git a/packages/plugin/src/tui/data/context-db.test.ts b/packages/plugin/src/tui/data/context-db.test.ts index 7e0d5df73b..1896238bea 100644 --- a/packages/plugin/src/tui/data/context-db.test.ts +++ b/packages/plugin/src/tui/data/context-db.test.ts @@ -91,7 +91,7 @@ async function startServer( } describe("TUI context RPC data", () => { - test("scheduled reads expose RPC failures instead of hiding them behind a cached snapshot", async () => { + test("loadSidebarSnapshot throwOnFailure exposes RPC failures instead of a cached snapshot", async () => { const dataHome = makeDataHome(); const directory = "/repo-scheduled"; const sessionId = "ses_scheduled"; diff --git a/packages/plugin/src/tui/quota-sidebar.js b/packages/plugin/src/tui/quota-sidebar.js index e173284103..103c1464dc 100644 --- a/packages/plugin/src/tui/quota-sidebar.js +++ b/packages/plugin/src/tui/quota-sidebar.js @@ -1,5 +1,5 @@ // @bun -// ../../node_modules/.bun/@cortexkit+opencode-sidebar@0.2.0+4f25175b99662f85/node_modules/@cortexkit/opencode-sidebar/src/index.tsx +// ../../node_modules/.bun/@cortexkit+opencode-sidebar@0.2.0+a24dd7ca87f30650/node_modules/@cortexkit/opencode-sidebar/src/index.tsx import { createComponent as _$createComponent } from "@opentui/solid"; import { createTextNode as _$createTextNode } from "@opentui/solid"; import { use as _$use } from "@opentui/solid"; diff --git a/packages/plugin/src/tui/sidebar-render.test.ts b/packages/plugin/src/tui/sidebar-render.test.ts index 7fe9ddda28..abf878b76a 100644 --- a/packages/plugin/src/tui/sidebar-render.test.ts +++ b/packages/plugin/src/tui/sidebar-render.test.ts @@ -1,8 +1,10 @@ import { beforeAll, describe, expect, test } from "bun:test"; +import { existsSync, rmSync } from "node:fs"; import { join } from "node:path"; import type { SidebarSnapshot } from "../shared/rpc-types"; +import { createTestTempDir } from "../shared/test-temp-dir"; -const now = Date.now(); +const now = 1_800_000_000_000; const older: SidebarSnapshot = { sessionId: "fixture", usagePercentage: 55, @@ -39,6 +41,7 @@ const healthy: SidebarSnapshot = { summariesSinceCache: 6, memoriesSinceCache: 7, queuedDropTokens: 49_000, + queuedDropCount: 151, historianRun: { finishedAt: now - 6_900_000, outcome: "ok", @@ -132,6 +135,42 @@ const cases = [ { snapshot: incident, preferences: { "magic-context": { startCollapsed: true } } }, { snapshot: null, preferences: { "magic-context": { startCollapsed: true } } }, { snapshot: off, preferences: { "magic-context": { startCollapsed: true } } }, + { snapshot: { ...off, native_context_usage_percentage: 41 } }, + { snapshot: { ...healthy, pendingOpsCount: 999, queuedDropCount: 3 } }, + { + snapshot: { ...healthy, pendingOpsCount: 999, queuedDropCount: 3 }, + preferences: { "magic-context": { startCollapsed: true } }, + }, + { + snapshot: { + ...incident, + historianRun: { + ...incident.historianRun!, + outcome: "ok", + model: "google/gemini-3.8-flash", + fallback: false, + }, + }, + }, + ...(["failed", "skipped"] as const).map((phase) => ({ + snapshot: { + ...healthy, + recompProgress: { + kind: "upgrade" as const, + phase, + processedMessages: 0, + totalMessages: 100, + passCount: 0, + compartmentsCreated: 0, + message: "Rebuild fixture reason", + }, + }, + })), + { + snapshot: healthy, + toggleHeader: true, + preferences: { "magic-context": { rememberCollapsed: true } }, + }, ]; type Result = { frame: string; @@ -141,7 +180,10 @@ type Result = { }; let results: Result[]; let rawResult: Result; +let wrotePreferences = false; beforeAll(() => { + const { dir } = createTestTempDir("sidebar-render-preferences-"); + const preferenceFile = join(dir, "preferences.json"); const child = Bun.spawnSync( [ process.execPath, @@ -149,14 +191,18 @@ beforeAll(() => { join(import.meta.dir, "../../scripts/render-compiled-sidebar.ts"), ], { - stdin: new TextEncoder().encode(JSON.stringify(cases)), + stdin: new TextEncoder().encode( + JSON.stringify(cases.map((entry) => ({ ...entry, now }))), + ), stdout: "pipe", stderr: "pipe", - env: { ...process.env, BUN_BE_BUN: "1" }, + env: { ...process.env, BUN_BE_BUN: "1", OPENCODE_TUI_PREFERENCES_FILE: preferenceFile }, windowsHide: true, }, ); if (child.exitCode !== 0) throw new Error(child.stderr.toString()); + wrotePreferences = existsSync(preferenceFile); + rmSync(dir, { recursive: true, force: true }); results = JSON.parse(child.stdout.toString()); for (const result of results) expect(result.error).toBeUndefined(); const raw = Bun.spawnSync( @@ -167,7 +213,7 @@ beforeAll(() => { "--raw-frame", ], { - stdin: new TextEncoder().encode(JSON.stringify([{ snapshot: healthy }])), + stdin: new TextEncoder().encode(JSON.stringify([{ snapshot: healthy, now }])), stdout: "pipe", stderr: "pipe", env: { ...process.env, BUN_BE_BUN: "1" }, @@ -195,6 +241,26 @@ function tone(index: number, text: string, color: string) { } describe("option B sidebar", () => { + test("render fixtures never persist remembered header toggles", () => { + expect(wrotePreferences).toBe(false); + }); + test("uses native-window pressure when supplied and retains the legacy fallback", () => { + expect(results[20]!.frame).toContain("59% left"); + expect(results[3]!.frame).toContain("45% left"); + }); + test("labels only drop operations as queued drops", () => { + row(21, "Queued drops", "3 · ≈49K at rebuild"); + row(22, "Cache", "3 drops queued ●"); + }); + test("does not credit a later primary model for cumulative fallback runs", () => { + row(23, "Fallback", "finished 2 runs"); + }); + test("keeps failed and stopped rebuilds and their explanations visible", () => { + row(24, "Upgrade", "✗ Upgrade failed"); + row(25, "Upgrade", "stopped"); + for (const index of [24, 25]) + expect(results[index]!.frame).toContain("Rebuild fixture reason"); + }); test("preserves the expanded and incident render output", () => { expect( results.slice(0, 2).map((result) => ({ ...result, frame: result.frame.split("\n") })), @@ -246,12 +312,12 @@ describe("option B sidebar", () => { tone(0, "idle", "#ffffff"); tone(0, "Historian", "#888888"); }); - test("shows primary rejections and the producing fallback in one Health section", () => { + test("shows primary rejections and cumulative fallback runs in one Health section", () => { row(1, "Historian", "summarizing"); row(1, "Last run", "1m ago · fallback"); row(1, "Model", "DeepSeek V4.1 Flash"); row(1, "Historian", "Gemini 4/4 rejected"); - row(1, "Fallback", "DeepSeek finished 2 runs"); + row(1, "Fallback", "finished 2 runs"); expect(results[1]!.frame).toContain("Health"); expect(results[1]!.frame.match(/\/ctx-status shows details/g)).toHaveLength(1); tone(1, "4/4 rejected", "#ff8800"); @@ -260,15 +326,9 @@ describe("option B sidebar", () => { }); test("uses dashes rather than fabricated values for older snapshots and the first read", () => { for (const index of [2, 5]) { - for (const label of [ - "Next summary", - "Last run", - "Model", - "Waiting", - "Cache built", - "Since then", - ]) + for (const label of ["Next summary", "Last run", "Model", "Cache built", "Since then"]) row(index, label, "—"); + row(index, "Waiting", "no reading"); expect(results[index]!.frame).not.toContain("Health"); } }); diff --git a/packages/plugin/src/tui/slots/sidebar-content.tsx b/packages/plugin/src/tui/slots/sidebar-content.tsx index 9d61e7d10e..d18a4b5e6c 100644 --- a/packages/plugin/src/tui/slots/sidebar-content.tsx +++ b/packages/plugin/src/tui/slots/sidebar-content.tsx @@ -219,6 +219,8 @@ const SidebarContent = (props: { }; const contextUsage = () => { const data = s(); + if (compactionOff() && present(data?.native_context_usage_percentage)) + return data.native_context_usage_percentage; if (!data || data.contextLimit <= 0) return undefined; return compactionOff() ? (data.inputTokens / data.contextLimit) * 100 @@ -252,15 +254,17 @@ const SidebarContent = (props: { return { lastRun: run ? `${relativeTime(run.finishedAt)} · ${run.outcome}` : "—", model: modelName(run?.model), - waiting: present(data?.waitingMessages) ? `${data?.waitingMessages} msgs` : "—", + waiting: present(data?.waitingMessages) + ? `${data?.waitingMessages} msgs` + : "no reading", cacheBuilt: present(cacheAt) ? relativeTime(cacheAt) : "—", sinceThen: present(data?.summariesSinceCache) && present(data?.memoriesSinceCache) ? `${data?.summariesSinceCache} summaries · ${data?.memoriesSinceCache} memories` : "—", drops: - present(data?.pendingOpsCount) && present(drops) - ? `${data?.pendingOpsCount} · ≈${compactTokens(drops)} at rebuild` + present(data?.queuedDropCount) && present(drops) + ? `${data?.queuedDropCount} · ≈${compactTokens(drops)} at rebuild` : "—", memories: present(data?.memoryCount) ? `${data?.memoryCount} · ${present(data?.memoriesSinceCache) ? data?.memoriesSinceCache : "—"} new` @@ -309,7 +313,7 @@ const SidebarContent = (props: { if (counts && counts.fallbackRuns > 0) rows.push({ label: "Fallback", - value: `${modelName(historian?.model).split(" ")[0]} finished ${counts.fallbackRuns} runs`, + value: `finished ${counts.fallbackRuns} runs`, tone: "warning", }); if (historian?.outcome === "failed" || (data.historianFailureCount ?? 0) > 0) @@ -325,6 +329,20 @@ const SidebarContent = (props: { value: `${data.dreamerFailures.length} tasks failed`, tone: "warning", }); + const rebuild = data.recompProgress; + if (rebuild?.phase === "failed" || rebuild?.phase === "skipped") { + const verb = { + recomp: "Recomp", + upgrade: "Upgrade", + embed: "Embed", + wrapup: "Wrap-up", + }[rebuild.kind ?? "recomp"]; + rows.push({ + label: verb, + value: rebuild.phase === "failed" ? `✗ ${verb} failed` : "stopped", + tone: rebuild.phase === "failed" ? "error" : "warning", + }); + } if (data.lastTransformError) rows.push({ label: "Transform", @@ -397,8 +415,8 @@ const SidebarContent = (props: { @@ -510,6 +528,16 @@ const SidebarContent = (props: { /> )} + + + + + From 2f140ad3a23de6e30bbd41d97f55e13b7c874b08 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:43:36 +0000 Subject: [PATCH 8/8] fix(sidebar): leave the drop token estimate unknown in Rust mode In Rust transform mode the queued-drop count comes from the module's own drop queue, but the token estimate came from the TypeScript pending_ops table, which describes a different queue. The snapshot now omits the estimate when the module supplies the count, and the sidebar shows the count with "no reading" in place of the estimate. --- packages/plugin/src/plugin/rpc-handlers.test.ts | 4 ++++ packages/plugin/src/plugin/rpc-handlers.ts | 5 ++++- packages/plugin/src/tui-compiled/slots/sidebar-content.tsx | 2 +- packages/plugin/src/tui/sidebar-render.test.ts | 4 ++++ packages/plugin/src/tui/slots/sidebar-content.tsx | 7 ++++--- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/plugin/src/plugin/rpc-handlers.test.ts b/packages/plugin/src/plugin/rpc-handlers.test.ts index 44ee9ed2c4..cc1f4bc921 100644 --- a/packages/plugin/src/plugin/rpc-handlers.test.ts +++ b/packages/plugin/src/plugin/rpc-handlers.test.ts @@ -1525,6 +1525,10 @@ describe("buildSidebarSnapshot — Rust module status merge", () => { expect(snapshot.compartmentCount).toBe(4); expect(snapshot.compartmentTokens).toBe(23); expect(snapshot.pendingOpsCount).toBe(2); + expect(snapshot.queuedDropCount).toBe(2); + // The module reports no token total for its drop queue; a TypeScript-side estimate would describe + // a different queue, so the estimate is absent. + expect(snapshot.queuedDropTokens).toBeUndefined(); expect(snapshot.boundaryPresent).toBe(true); expect(snapshot.coverageOrdinal).toBe(17); expect(buildCompartmentCount(db, sessionId, moduleStatus)).toBe(4); diff --git a/packages/plugin/src/plugin/rpc-handlers.ts b/packages/plugin/src/plugin/rpc-handlers.ts index 7cdb19b98e..3d04a2eee7 100644 --- a/packages/plugin/src/plugin/rpc-handlers.ts +++ b/packages/plugin/src/plugin/rpc-handlers.ts @@ -452,7 +452,7 @@ export function buildSidebarSnapshot( } let pendingOpsCount = 0; - let queuedDropTokens = 0; + let queuedDropTokens: number | undefined = 0; let queuedDropCount = 0; try { const pendingRow = db @@ -473,6 +473,9 @@ export function buildSidebarSnapshot( if (typeof moduleStatus?.pending_drop_count === "number") { pendingOpsCount = moduleStatus.pending_drop_count; queuedDropCount = moduleStatus.pending_drop_count; + // The module's drop queue is not the pending_ops table, and the module reports no token total for + // it, so an estimate from pending_ops would describe a different queue. + queuedDropTokens = undefined; } let sessionNoteCount = 0; diff --git a/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx b/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx index 28b8ca38d8..2fcdac4226 100644 --- a/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx +++ b/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx @@ -176,7 +176,7 @@ const SidebarContent = props => { waiting: present(data?.waitingMessages) ? `${data?.waitingMessages} msgs` : "no reading", cacheBuilt: present(cacheAt) ? relativeTime(cacheAt) : "—", sinceThen: present(data?.summariesSinceCache) && present(data?.memoriesSinceCache) ? `${data?.summariesSinceCache} summaries · ${data?.memoriesSinceCache} memories` : "—", - drops: present(data?.queuedDropCount) && present(drops) ? `${data?.queuedDropCount} · ≈${compactTokens(drops)} at rebuild` : "—", + drops: present(data?.queuedDropCount) ? present(drops) ? `${data?.queuedDropCount} · ≈${compactTokens(drops)} at rebuild` : `${data?.queuedDropCount} · no reading` : "—", memories: present(data?.memoryCount) ? `${data?.memoryCount} · ${present(data?.memoriesSinceCache) ? data?.memoriesSinceCache : "—"} new` : "—", notes: present(data?.sessionNoteCount) && present(data?.readySmartNoteCount) ? `${data?.sessionNoteCount} · ${data?.readySmartNoteCount} smart waiting` : "—", dreamerLastRun: present(lastDream) ? `${relativeTime(lastDream)} · ${data?.dreamerFailures?.length ? "failed" : "ok"}` : "—", diff --git a/packages/plugin/src/tui/sidebar-render.test.ts b/packages/plugin/src/tui/sidebar-render.test.ts index abf878b76a..345cca00ba 100644 --- a/packages/plugin/src/tui/sidebar-render.test.ts +++ b/packages/plugin/src/tui/sidebar-render.test.ts @@ -171,6 +171,7 @@ const cases = [ toggleHeader: true, preferences: { "magic-context": { rememberCollapsed: true } }, }, + { snapshot: { ...healthy, queuedDropCount: 2, queuedDropTokens: undefined } }, ]; type Result = { frame: string; @@ -252,6 +253,9 @@ describe("option B sidebar", () => { row(21, "Queued drops", "3 · ≈49K at rebuild"); row(22, "Cache", "3 drops queued ●"); }); + test("shows the queued-drop count without a token estimate when none is reported", () => { + row(cases.length - 1, "Queued drops", "2 · no reading"); + }); test("does not credit a later primary model for cumulative fallback runs", () => { row(23, "Fallback", "finished 2 runs"); }); diff --git a/packages/plugin/src/tui/slots/sidebar-content.tsx b/packages/plugin/src/tui/slots/sidebar-content.tsx index d18a4b5e6c..dd9d143474 100644 --- a/packages/plugin/src/tui/slots/sidebar-content.tsx +++ b/packages/plugin/src/tui/slots/sidebar-content.tsx @@ -262,10 +262,11 @@ const SidebarContent = (props: { present(data?.summariesSinceCache) && present(data?.memoriesSinceCache) ? `${data?.summariesSinceCache} summaries · ${data?.memoriesSinceCache} memories` : "—", - drops: - present(data?.queuedDropCount) && present(drops) + drops: present(data?.queuedDropCount) + ? present(drops) ? `${data?.queuedDropCount} · ≈${compactTokens(drops)} at rebuild` - : "—", + : `${data?.queuedDropCount} · no reading` + : "—", memories: present(data?.memoryCount) ? `${data?.memoryCount} · ${present(data?.memoriesSinceCache) ? data?.memoriesSinceCache : "—"} new` : "—",