diff --git a/bun.lock b/bun.lock index 4da60a30cc..c53dcdee4a 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", @@ -133,8 +133,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", @@ -435,6 +436,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"], @@ -445,6 +448,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 abc8d0bd9e..71d1331f4b 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", 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/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..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 @@ -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: "actual/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,168 @@ 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 }, + }); +}); + +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 761fd29ebd..fe65336a77 100644 --- a/packages/plugin/src/hooks/magic-context/compartment-runner-historian.ts +++ b/packages/plugin/src/hooks/magic-context/compartment-runner-historian.ts @@ -69,6 +69,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 @@ -316,6 +317,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), @@ -355,47 +358,94 @@ 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 attempts = { primaryAttempts: 0, primaryRejections: 0 }; + const finishObservation = beginSidebarHistorianRun(args.parentSessionId); + 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 { + const primaryModel = toModelEntry(args.model)?.model ?? null; + attempts.primaryAttempts += 1; const firstRun = await runHistorianPrompt({ ...args, dumpLabel: `${args.dumpLabelBase}-initial`, modelOverride: args.model, agentId: args.agentId, }); + const firstRunSwitchedModel = + primaryModel !== null && (firstRun.producingModel ?? primaryModel) !== primaryModel; + if (firstRunSwitchedModel) attempts.primaryRejections += 1; if (!firstRun.ok || !firstRun.result) { + if (!firstRunSwitchedModel) attempts.primaryRejections += 1; if (firstRun.refusal?.terminal) return { ok: false, @@ -426,12 +476,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; } + if (!firstRunSwitchedModel) attempts.primaryRejections += 1; await args.callbacks?.onRepairRetry?.(firstValidation.error ?? "invalid compartment output"); const repairPrompt = buildHistorianRepairPrompt( args.prompt, @@ -439,6 +495,7 @@ export async function runValidatedHistorianPass(args: { firstValidation.error ?? "invalid compartment output", args.language, ); + attempts.primaryAttempts += 1; const repairRun = await runHistorianPrompt({ ...args, prompt: repairPrompt, @@ -446,7 +503,11 @@ export async function runValidatedHistorianPass(args: { modelOverride: args.model, agentId: args.agentId, }); + const repairRunSwitchedModel = + primaryModel !== null && (repairRun.producingModel ?? primaryModel) !== primaryModel; + if (repairRunSwitchedModel) attempts.primaryRejections += 1; if (!repairRun.ok || !repairRun.result) { + if (!repairRunSwitchedModel) attempts.primaryRejections += 1; if (repairRun.refusal?.terminal) return { ok: false, @@ -477,14 +538,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; } + if (!repairRunSwitchedModel) attempts.primaryRejections += 1; return runFallbackHistorianPass({ ...args, prompt: repairPrompt, @@ -531,6 +598,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"); @@ -555,7 +623,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( @@ -572,12 +644,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: { @@ -888,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) { const desc = describeError(modelError); shared.sessionLog( @@ -1042,7 +1134,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 1577f7e05b..c2f9f5b216 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; @@ -1002,6 +1003,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 @@ -1406,6 +1408,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. @@ -1649,6 +1652,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 aeb78e757d..7ea58d852d 100644 --- a/packages/plugin/src/hooks/magic-context/compartment-runner.test.ts +++ b/packages/plugin/src/hooks/magic-context/compartment-runner.test.ts @@ -40,6 +40,7 @@ import { } from "../../features/magic-context/storage"; 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"; @@ -1591,6 +1592,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"); @@ -1941,6 +1943,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 6e7eab027b..d8dc58d0be 100644 --- a/packages/plugin/src/hooks/magic-context/compartment-trigger.test.ts +++ b/packages/plugin/src/hooks/magic-context/compartment-trigger.test.ts @@ -17,6 +17,7 @@ import { queuePendingOp, } from "../../features/magic-context/storage"; 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"; @@ -108,6 +109,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 209dfba436..31cea39afb 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 0cbbbfd194..649358cf8c 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"; @@ -1470,6 +1471,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); @@ -1483,6 +1485,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 c15c3aa70a..5abd5653c8 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"; @@ -1279,6 +1280,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 70fcec6548..6c8a3ab065 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, @@ -444,13 +451,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 } @@ -687,6 +699,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, @@ -700,6 +745,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 3a7646c2b3..c2cba7c7f4 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; @@ -254,7 +277,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/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 fbeadb25c4..64e73e3101 100644 --- a/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx +++ b/packages/plugin/src/tui-compiled/slots/sidebar-content.tsx @@ -1,26 +1,26 @@ 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 { createRefreshScheduler } from "../data/refresh-scheduler"; import { directoryForSession } from "../data/session-directory"; -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 { SidebarFrame, SidebarSection, SidebarStatRow, SidebarTextRow } from "../quota-sidebar.js"; -// 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() { @@ -31,27 +31,14 @@ export function kickRecompProgressRefresh() { export function refreshSidebarSnapshot() { activeSidebarRefresh?.(); } -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 -// 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()); @@ -59,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); @@ -84,642 +72,132 @@ 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; - 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; - 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 headerLabel = () => { + const label = props.controller.prefs().header.label; + return label === DEFAULT_PREFS.header.label ? "MAGIC CONTEXT" : label; }; - 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); + 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 (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(); - } + // The renderer can close before the request completes. } - }).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 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(); @@ -727,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`; + appearance: { + barWidth: 10, + barFilledChar: "█", + barEmptyChar: "░", + warnThreshold: 65, + errorThreshold: 85 }, - accent: true - }))])]), _$memo(() => _$memo(() => !!(sections().dreamer && (s()?.lastDreamerRunAt || s()?.dreamerProgress)))() && [_$createComponent(SectionHeader, { - get theme() { - return props.theme; + get now() { + return Date.now(); }, - title: "Dreamer" - }), _$createComponent(Show, { - get when() { - return s()?.dreamerProgress; - }, - 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/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/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..56dd5c2523 --- /dev/null +++ b/packages/plugin/src/tui/sidebar-render.test.ts @@ -0,0 +1,299 @@ +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" }, + windowsHide: true, + }, + ); + 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" }, + windowsHide: true, + }, + ); + 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 d27bab85d8..f5065e5271 100644 --- a/packages/plugin/src/tui/slots/sidebar-content.tsx +++ b/packages/plugin/src/tui/slots/sidebar-content.tsx @@ -1,15 +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 { 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, @@ -18,1072 +25,517 @@ import { 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. -let activeRecompPollKick: (() => void) | null = null -let activeSidebarRefresh: (() => void) | null = null +} 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; 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 -const REFRESH_DEBOUNCE_MS = 150 - 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 - 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 - activeSidebarRefresh = refresh + 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 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 - 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, () => { - stopRecompPoll() - setSnapshot(null) - refresh() + 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); } } diff --git a/packages/plugin/src/v2/hooks/context.ts b/packages/plugin/src/v2/hooks/context.ts index ef19680819..c1c961d5c3 100644 --- a/packages/plugin/src/v2/hooks/context.ts +++ b/packages/plugin/src/v2/hooks/context.ts @@ -93,6 +93,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, @@ -1668,6 +1669,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(); + } +});