diff --git a/assets/magic-context.schema.json b/assets/magic-context.schema.json index c3ec554..b512252 100644 --- a/assets/magic-context.schema.json +++ b/assets/magic-context.schema.json @@ -778,6 +778,69 @@ "additionalProperties": false, "description": "Cross-session memory configuration" }, + "tui": { + "description": "TUI sidebar and status dialog configuration", + "type": "object", + "properties": { + "sidebar": { + "description": "Sidebar panel defaults (manual toggle state is persisted via KV and overrides these)", + "type": "object", + "properties": { + "collapse_default": { + "description": "Start with the sidebar collapsed on new sessions. The manual toggle is still persisted across restarts via KV, so once a user toggles, that state is respected regardless of this default.", + "type": "boolean", + "default": false + } + }, + "additionalProperties": false, + "default": { + "collapse_default": false + } + }, + "compact_bar": { + "description": "Token usage bar in collapsed sidebar mode", + "type": "object", + "properties": { + "label_threshold": { + "description": "Minimum segment share (0-1) to show the token-count label on a non-free segment. Higher values reduce label clutter on narrow segments. At the default 0.10 (10%), 4-char labels like '351K' fit a 4+ wide segment.", + "type": "number", + "minimum": 0.05, + "maximum": 0.50, + "default": 0.10 + }, + "free_label_threshold": { + "description": "Minimum segment share (0-1) to show the full 'XXK Free' label on the last (free-context) segment. Below this, the short number-only label is shown instead. The default 0.25 (25%) gives room for the longest possible label (9 chars, e.g. '351K Free') on a 36+ char sidebar. Narrower sidebars show just '351K'.", + "type": "number", + "minimum": 0.10, + "maximum": 0.50, + "default": 0.25 + }, + "show_free_label": { + "description": "Whether to append ' Free' to the last segment label. When false, only the token count is shown on the free segment regardless of segment width.", + "type": "boolean", + "default": true + } + }, + "additionalProperties": false, + "default": { + "label_threshold": 0.10, + "free_label_threshold": 0.25, + "show_free_label": true + } + } + }, + "additionalProperties": false, + "default": { + "sidebar": { + "collapse_default": false + }, + "compact_bar": { + "label_threshold": 0.10, + "free_label_threshold": 0.25, + "show_free_label": true + } + } + }, "sidekick": { "type": "object", "properties": { diff --git a/packages/plugin/src/tui/slots/sidebar-content.tsx b/packages/plugin/src/tui/slots/sidebar-content.tsx index 97feeb8..7ded89e 100644 --- a/packages/plugin/src/tui/slots/sidebar-content.tsx +++ b/packages/plugin/src/tui/slots/sidebar-content.tsx @@ -1,19 +1,15 @@ /** @jsxImportSource @opentui/solid */ -import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" +import { createEffect, createMemo, createSignal, on, onCleanup, Show } from "solid-js" import type { TuiSlotPlugin, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui" import packageJson from "../../../package.json" import { loadSidebarSnapshot, type SidebarSnapshot } from "../data/context-db" -import { formatThresholdPercent } from "../../shared/format-threshold" +import { compactTokens, collapsedStatusLine, formatThresholdPercent, type CompactBarOptions, DEFAULT_COMPACT_BAR_OPTIONS } from "./sidebar-utils" +import { readJsoncFile } from "../../shared/jsonc-parser" +import { getOpenCodeConfigPaths } from "../../shared/opencode-config-dir" const SINGLE_BORDER = { type: "single" } as any const REFRESH_DEBOUNCE_MS = 150 -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` - return String(value) -} - function relativeTime(ms: number): string { const diff = Date.now() - ms if (diff < 60_000) return "just now" @@ -34,6 +30,9 @@ const COLORS = { conversation: "#f87171", // Red toolCalls: "#fb923c", // Orange toolDefs: "#f472b6", // Pink + // Unused / free — dim, distinct from usage segments. + // Appears only in the collapsed bar when contextLimit > inputTokens. + free: "#666666", // Dim gray } interface TokenSegment { @@ -47,7 +46,13 @@ interface TokenSegment { const TokenBreakdown = (props: { theme: TuiThemeCurrent snapshot: SidebarSnapshot + compact?: boolean + compactBarOptions?: CompactBarOptions }) => { + const barOpts = createMemo(() => ({ + ...DEFAULT_COMPACT_BAR_OPTIONS, + ...props.compactBarOptions, + })) // 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 @@ -143,10 +148,28 @@ const TokenBreakdown = (props: { }) } + // Free remaining context — shown only in compact mode so the bar + // fills the full contextLimit width and labels show "64K Free" etc. + if (props.compact && s.contextLimit && s.contextLimit > s.inputTokens) { + result.push({ + key: "free", + tokens: s.contextLimit - s.inputTokens, + color: COLORS.free, + label: "Free", + }) + } + return result }) - const totalTokens = createMemo(() => props.snapshot.inputTokens || 1) + // In compact mode with Free segment, the total is the full context limit + // so the Free segment gets its proportional share of the bar width. + const totalTokens = createMemo(() => { + if (props.compact && props.snapshot.contextLimit && props.snapshot.contextLimit > props.snapshot.inputTokens) { + return props.snapshot.contextLimit + } + return 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 @@ -160,25 +183,92 @@ const TokenBreakdown = (props: { 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. */} + {/* Segmented bar: 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. In compact mode, wide-enough segments + show token-count labels centered over their colored box. */} - {barSegments().map((seg) => ( - - ))} + {(props.compact ? barSegments() : barSegments()).map((seg) => { + // Show label when segment is wide enough. Non-free segments + // show the short token count (3-4 chars e.g. "42K") at the + // labelThreshold. Free segments show just the number between + // labelThreshold and freeLabelThreshold, and the full + // "XXK Free" label at freeLabelThreshold+. + const pct = seg.tokens / totalTokens() + const { labelThreshold, freeLabelThreshold } = barOpts() + const showLabel = props.compact && pct >= labelThreshold && seg.key !== "free" + const showFreeLabel = props.compact && seg.key === "free" && barOpts().showFreeLabel && pct >= freeLabelThreshold + const showFreeShort = props.compact && seg.key === "free" && pct >= labelThreshold && (!barOpts().showFreeLabel || pct < freeLabelThreshold) + + if (showFreeLabel) { + return ( + + {`${compactTokens(seg.tokens)} Free`} + + ) + } + + if (showFreeShort) { + return ( + + {compactTokens(seg.tokens)} + + ) + } + + if (showLabel) { + return ( + + {compactTokens(seg.tokens)} + + ) + } + + return ( + + ) + })} - {/* Legend rows */} + {/* Legend rows — hidden in compact mode */} + {!props.compact && ( {segments().map((seg) => { const pct = ((seg.tokens / totalTokens()) * 100).toFixed(0) @@ -197,6 +287,7 @@ const TokenBreakdown = (props: { ) })} + )} ) } @@ -238,6 +329,7 @@ const SidebarContent = (props: { api: TuiPluginApi sessionID: () => string theme: TuiThemeCurrent + compactBarOptions?: CompactBarOptions }) => { const [snapshot, setSnapshot] = createSignal(null) let refreshTimer: ReturnType | undefined @@ -311,6 +403,19 @@ const SidebarContent = (props: { return props.theme.accent }) + // Collapse state persisted via KV (survives restarts) + const COLLAPSED_KV_KEY = "mc-sidebar-collapsed" + const [collapsed, setCollapsed] = createSignal( + props.api.kv.get(COLLAPSED_KV_KEY, false) as boolean, + ) + createEffect(() => { + props.api.kv.set(COLLAPSED_KV_KEY, collapsed()) + }) + const toggle = () => setCollapsed((x) => !x) + + // Status line for collapsed view (line 3) + const collapsedStatusLineMemo = createMemo(() => collapsedStatusLine(s())) + return ( - {/* Header */} - - - - Magic Context - + {/* Toggle header — collapsed shows compact usage, expanded shows brand */} + + + + ▼ Magic Context + + + v{packageJson.version} - v{packageJson.version} - + }> + 0 && (s()?.contextLimit ?? 0) > 0} fallback={ + + ▶ Magic Context + + }> + + + {s()!.usagePercentage.toFixed(1)}% / {formatThresholdPercent(s()!.executeThreshold)}% + + + {compactTokens(s()!.inputTokens)} / {compactTokens(s()!.contextLimit)} + + + + + + {/* Collapsed: compact bar + status line */} + 0}> + + {collapsedStatusLineMemo()} + + + {/* Expanded: full sidebar content */} + + {/* Token breakdown bar */} + {s() && s()!.inputTokens > 0 && ( + + {(s()?.contextLimit ?? 0) > 0 && ( + + {/* Left: current usage vs the per-model execute + threshold (the value Magic Context compares + against when scheduling historian / drops). + "47.5% / 65%" tells the user how close they + are to the next compaction trigger. */} + + {s()!.usagePercentage.toFixed(1)}% / {formatThresholdPercent(s()!.executeThreshold)}% + + {/* Right: absolute token usage vs the model's + full context window (separate from the + execute threshold so users still know how + much headroom remains beyond compaction). */} + + {compactTokens(s()!.inputTokens)} / {compactTokens(s()!.contextLimit)} + + + )} + + + )} - {/* Token breakdown bar */} - {s() && s()!.inputTokens > 0 && ( - - {(s()?.contextLimit ?? 0) > 0 && ( - - {/* Left: current usage vs the per-model execute - threshold (the value Magic Context compares - against when scheduling historian / drops). - "47.5% / 65%" tells the user how close they - are to the next compaction trigger. */} - - {s()!.usagePercentage.toFixed(1)}% / {formatThresholdPercent(s()!.executeThreshold)}% - - {/* Right: absolute token usage vs the model's - full context window (separate from the - execute threshold so users still know how - much headroom remains beyond compaction). */} - - {compactTokens(s()!.inputTokens)} / {compactTokens(s()!.contextLimit)} - - + {/* Historian section */} + + + Historian + + {s()?.historianRunning ? ( + compacting ⟳ + ) : ( + idle )} - - )} + + - {/* Historian section */} - - - Historian - - {s()?.historianRunning ? ( - compacting ⟳ - ) : ( - idle - )} - - - - - {/* Memory section */} - - - {(s()?.memoryBlockCount ?? 0) > 0 && ( + {/* Memory section */} + - )} + {(s()?.memoryBlockCount ?? 0) > 0 && ( + + )} - {/* Queue & Status */} - {((s()?.pendingOpsCount ?? 0) > 0 || - (s()?.sessionNoteCount ?? 0) > 0 || - (s()?.readySmartNoteCount ?? 0) > 0) && ( - <> - - {(s()?.pendingOpsCount ?? 0) > 0 && ( - - )} - {(s()?.sessionNoteCount ?? 0) > 0 && ( + {/* Queue & Status */} + {((s()?.pendingOpsCount ?? 0) > 0 || + (s()?.sessionNoteCount ?? 0) > 0 || + (s()?.readySmartNoteCount ?? 0) > 0) && ( + <> + + {(s()?.pendingOpsCount ?? 0) > 0 && ( + + )} + {(s()?.sessionNoteCount ?? 0) > 0 && ( + + )} + {(s()?.readySmartNoteCount ?? 0) > 0 && ( + + )} + + )} + + {/* Dreamer */} + {s()?.lastDreamerRunAt && ( + <> + - )} - {(s()?.readySmartNoteCount ?? 0) > 0 && ( + + )} + + {/* 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. */} + {s()?.totalInputTokens != null && ( + <> + - )} - - )} - - {/* Dreamer */} - {s()?.lastDreamerRunAt && ( - <> - - - - )} - - {/* 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. */} - {s()?.totalInputTokens != null && ( - <> - - - - )} + + )} + ) } export function createSidebarContentSlot(api: TuiPluginApi): TuiSlotPlugin { + // Read compact_bar config from magic-context.jsonc (silently falls back to defaults) + const compactBarOptions = (() => { + try { + const cfgPaths = getOpenCodeConfigPaths({ binary: "opencode" }) + const cfg = readJsoncFile>(cfgPaths.omoConfig) + if (!cfg || typeof cfg !== "object") return undefined + const tuiSection = (cfg as Record).tui + if (!tuiSection || typeof tuiSection !== "object") return undefined + const compactBar = (tuiSection as Record).compact_bar + if (!compactBar || typeof compactBar !== "object") return undefined + const cb = compactBar as Record + const opts: CompactBarOptions = {} + if (typeof cb.label_threshold === "number") opts.labelThreshold = cb.label_threshold + if (typeof cb.free_label_threshold === "number") opts.freeLabelThreshold = cb.free_label_threshold + if (typeof cb.show_free_label === "boolean") opts.showFreeLabel = cb.show_free_label + return Object.keys(opts).length > 0 ? opts : undefined + } catch { + return undefined + } + })() return { order: 150, slots: { @@ -474,6 +625,7 @@ export function createSidebarContentSlot(api: TuiPluginApi): TuiSlotPlugin { api={api} sessionID={() => value.session_id} theme={theme()} + compactBarOptions={compactBarOptions} /> ) }, diff --git a/packages/plugin/src/tui/slots/sidebar-utils.test.ts b/packages/plugin/src/tui/slots/sidebar-utils.test.ts new file mode 100644 index 0000000..e679b5b --- /dev/null +++ b/packages/plugin/src/tui/slots/sidebar-utils.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "bun:test" +import { compactTokens, collapsedStatusLine, collapsedUsageLine } from "./sidebar-utils" +import type { SidebarSnapshot } from "../../shared/rpc-types" + +// --------------------------------------------------------------------------- +// compactTokens +// --------------------------------------------------------------------------- +describe("compactTokens", () => { + it("returns the number as-is below 1000", () => { + expect(compactTokens(0)).toBe("0") + expect(compactTokens(1)).toBe("1") + expect(compactTokens(500)).toBe("500") + expect(compactTokens(999)).toBe("999") + }) + + it("formats thousands with K suffix (no decimal)", () => { + expect(compactTokens(1_000)).toBe("1K") + expect(compactTokens(10_000)).toBe("10K") + expect(compactTokens(999_999)).toBe("1000K") // 999999/1000 = 999.999 → "1000K" + }) + + it("formats millions with M suffix (one decimal)", () => { + expect(compactTokens(1_000_000)).toBe("1.0M") + expect(compactTokens(1_200_000)).toBe("1.2M") + expect(compactTokens(100_000_000)).toBe("100.0M") + }) + + it("handles very small values correctly", () => { + // Below 1000 — no suffix + expect(compactTokens(0)).toBe("0") + expect(compactTokens(1)).toBe("1") + expect(compactTokens(99)).toBe("99") + }) + + it("handles boundary between K and M", () => { + // Exactly at the threshold + expect(compactTokens(999_999)).toBe("1000K") // rounds up + expect(compactTokens(1_000_000)).toBe("1.0M") + }) +}) + +// --------------------------------------------------------------------------- +// collapsedStatusLine +// --------------------------------------------------------------------------- +describe("collapsedStatusLine", () => { + const baseSnapshot = (overrides: Partial = {}): SidebarSnapshot => ({ + usagePercentage: 0, + inputTokens: 0, + limitTokens: 0, + executeThreshold: 65, + contextLimit: 200_000, + systemPromptTokens: 0, + compartmentTokens: 0, + factTokens: 0, + memoryTokens: 0, + conversationTokens: 0, + toolCallTokens: 0, + toolDefinitionTokens: 0, + historianRunning: false, + compartmentInProgress: false, + lastDreamerRunAt: null, + pendingOpsCount: 0, + compartmentCount: 3, + factCount: 5, + memoryCount: 5, + memoryBlockCount: 0, + sessionNoteCount: 0, + readySmartNoteCount: 0, + ...overrides, + }) + + it("returns empty string for null snapshot", () => { + expect(collapsedStatusLine(null)).toBe("") + }) + + it("reports historian compacting when historianRunning is true", () => { + const result = collapsedStatusLine(baseSnapshot({ historianRunning: true })) + expect(result).toContain("compacting") + expect(result).toContain("⟳") + }) + + it("reports historian compacting when compartmentInProgress is true", () => { + const result = collapsedStatusLine(baseSnapshot({ compartmentInProgress: true })) + expect(result).toContain("compacting") + expect(result).toContain("⟳") + }) + + it("prefers historian/compaction over dreamer", () => { + // Both active — historian wins + const result = collapsedStatusLine( + baseSnapshot({ + historianRunning: true, + lastDreamerRunAt: Date.now() - 10_000, + }), + ) + expect(result).toContain("compacting") + }) + + it("reports dreamer active when recently run", () => { + const result = collapsedStatusLine( + baseSnapshot({ lastDreamerRunAt: Date.now() - 30_000 }), + ) + expect(result).toContain("Dreamer") + expect(result).toContain("⟳") + }) + + it("reports pending queue when ops are waiting", () => { + const result = collapsedStatusLine(baseSnapshot({ pendingOpsCount: 3 })) + expect(result).toContain("Queue") + expect(result).toContain("3 pending") + }) + + it("shows static counts when nothing is active", () => { + const result = collapsedStatusLine(baseSnapshot()) + expect(result).toBe("3 Comp · 5 Fact · 5 Memory") + }) + + it("shows zero counts correctly", () => { + const result = collapsedStatusLine( + baseSnapshot({ + historianRunning: false, + lastDreamerRunAt: null, + pendingOpsCount: 0, + compartmentCount: 0, + factCount: 0, + memoryCount: 0, + }), + ) + expect(result).toBe("0 Comp · 0 Fact · 0 Memory") + }) +}) + +// --------------------------------------------------------------------------- +// collapsedUsageLine +// --------------------------------------------------------------------------- +describe("collapsedUsageLine", () => { + it("renders integer threshold without decimals", () => { + const line = collapsedUsageLine(47.5, 65, 111_000, 180_000) + expect(line).toBe("47.5% / 65% 111K / 180K") + }) + + it("renders fractional threshold with one decimal", () => { + const line = collapsedUsageLine(47.5, 14.099, 111_000, 180_000) + expect(line).toBe("47.5% / 14% 111K / 180K") + }) + + it("shows em-dash for missing threshold", () => { + const line = collapsedUsageLine(10, null, 1000, 2000) + expect(line).toBe("10.0% / —% 1K / 2K") + }) + + it("shows em-dash for missing context limit", () => { + const line = collapsedUsageLine(10, 65, 1000, 0) + expect(line).toBe("10.0% / 65% 1K / —") + }) + + it("shows em-dash when both threshold and limit are missing", () => { + const line = collapsedUsageLine(0, undefined, 0, null) + expect(line).toBe("0.0% / —% 0 / —") + }) + + it("handles small token counts without suffix", () => { + const line = collapsedUsageLine(0.5, 65, 500, 2000) + expect(line).toBe("0.5% / 65% 500 / 2K") + }) + + it("accepts a custom compactTokens function", () => { + const customCompact = (v: number) => `[${v}]` + const line = collapsedUsageLine(50, 65, 1000, 2000, customCompact) + expect(line).toBe("50.0% / 65% [1000] / [2000]") + }) +}) diff --git a/packages/plugin/src/tui/slots/sidebar-utils.ts b/packages/plugin/src/tui/slots/sidebar-utils.ts new file mode 100644 index 0000000..830d5c1 --- /dev/null +++ b/packages/plugin/src/tui/slots/sidebar-utils.ts @@ -0,0 +1,82 @@ +import type { SidebarSnapshot } from "../../shared/rpc-types" + +// --------------------------------------------------------------------------- +// Compact bar configuration (from magic-context.jsonc → compact_bar) +// --------------------------------------------------------------------------- + +/** User-configurable options for the collapsed sidebar token usage bar. */ +export interface CompactBarOptions { + /** Minimum segment share (0-1) to show the short token-count label on + * non-Free segments. Higher values reduce label clutter on narrow bars. + * Default: 0.10 */ + labelThreshold?: number + /** Minimum segment share (0-1) to show the full "XXK Free" label on the + * last (free-context) segment. Below this threshold only the number is + * shown. Default: 0.25 */ + freeLabelThreshold?: number + /** Whether to append " Free" to the last segment's label. When false, + * only the token count is shown regardless of segment width. + * Default: true */ + showFreeLabel?: boolean +} + +export const DEFAULT_COMPACT_BAR_OPTIONS: Required = { + labelThreshold: 0.10, + freeLabelThreshold: 0.25, + showFreeLabel: true, +} + +/** + * Compact byte/token count to a human-readable string. + * Examples: 999 → "999", 1000 → "1K", 15300 → "15K", 1_200_000 → "1.2M" + */ +export 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` + return String(value) +} + +/** + * Build a one-line status summary for the collapsed sidebar view. + * Prioritises active operations (historian, dreamer, pending queue) + * over static counts. + */ +export function collapsedStatusLine(snap: SidebarSnapshot | null): string { + if (!snap) return "" + if (snap.historianRunning || snap.compartmentInProgress) { + return "Historian compacting ⟳" + } + if (snap.lastDreamerRunAt && Date.now() - snap.lastDreamerRunAt < 60_000) { + return "Dreamer active ⟳" + } + if (snap.pendingOpsCount > 0) { + return `Queue: ${snap.pendingOpsCount} pending` + } + return `${snap.compartmentCount} Comp · ${snap.factCount} Fact · ${snap.memoryCount} Memory` +} + +/** + * Summary usage string for the collapsed header line. + * Returns something like "47.5% / 65% 111K / 180K" + */ +export function collapsedUsageLine( + usagePercentage: number, + executeThreshold: number | undefined | null, + inputTokens: number, + contextLimit: number | undefined | null, + compactTokensFn: (v: number) => string = compactTokens, +): string { + const pct = usagePercentage.toFixed(1) + const thresh = + typeof executeThreshold === "number" && Number.isFinite(executeThreshold) + ? Math.round(executeThreshold).toString() + : "—" + const used = compactTokensFn(inputTokens) + const limit = + typeof contextLimit === "number" && contextLimit > 0 + ? compactTokensFn(contextLimit) + : "—" + return `${pct}% / ${thresh}% ${used} / ${limit}` +} + +export { formatThresholdPercent } from "../../shared/format-threshold"