From c4e9af85a69099e9fe131a014196145cbf78fd6c Mon Sep 17 00:00:00 2001
From: Zireael <3856578+Zireael@users.noreply.github.com>
Date: Fri, 22 May 2026 08:24:04 +0200
Subject: [PATCH 1/3] feat(tui): collapsible sidebar with compact color-coded
token bar
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add toggle collapse for the Magic Context sidebar panel, persisted via KV
store so the state survives session restarts and window reloads.
Collapsed view shows a compact three-line summary:
- Usage header: toggle indicator + percentage/token counts
- Color-coded bar with inline token-count labels on segments ≥3 chars wide
- Historian status line (running/idle + compartment/fact counts)
The compact bar includes a dim "Free" segment proportional to remaining
context capacity, with adaptive labels: "64K Free" when wide enough, "64K"
at minimum width, or dim fill when too narrow.
Expanded view is unchanged — full sidebar with legend, historian, memory,
status, and dreamer sections.
Utilities moved to sidebar-utils.ts with full test coverage (20 tests).
---
.../plugin/src/tui/slots/sidebar-content.tsx | 393 +++++++++++-------
.../src/tui/slots/sidebar-utils.test.ts | 172 ++++++++
.../plugin/src/tui/slots/sidebar-utils.ts | 56 +++
3 files changed, 474 insertions(+), 147 deletions(-)
create mode 100644 packages/plugin/src/tui/slots/sidebar-utils.test.ts
create mode 100644 packages/plugin/src/tui/slots/sidebar-utils.ts
diff --git a/packages/plugin/src/tui/slots/sidebar-content.tsx b/packages/plugin/src/tui/slots/sidebar-content.tsx
index 97feeb8..6ae1e62 100644
--- a/packages/plugin/src/tui/slots/sidebar-content.tsx
+++ b/packages/plugin/src/tui/slots/sidebar-content.tsx
@@ -1,19 +1,13 @@
/** @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 } from "./sidebar-utils"
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 +28,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,6 +44,7 @@ interface TokenSegment {
const TokenBreakdown = (props: {
theme: TuiThemeCurrent
snapshot: SidebarSnapshot
+ compact?: boolean
}) => {
// The bar is rendered as a flex row of colored boxes, each with
// flexGrow=tokens and flexBasis=0. opentui distributes the parent
@@ -143,10 +141,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 +176,68 @@ 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) => {
+ // In compact mode, overlay a label when the segment is
+ // wide enough (≥8% of the total). Free segments get the
+ // "XXK Free" label at ≥12% to accommodate the longer text.
+ const pct = seg.tokens / totalTokens()
+ const showLabel = props.compact && pct >= 0.08 && seg.key !== "free"
+ const showFreeLabel = props.compact && seg.key === "free" && pct >= 0.12
+
+ if (showFreeLabel) {
+ return (
+
+ {`${compactTokens(seg.tokens)} Free`}
+
+ )
+ }
+
+ 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 +256,7 @@ const TokenBreakdown = (props: {
)
})}
+ )}
)
}
@@ -311,6 +371,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 && (
- <>
-
-
- >
- )}
+ >
+ )}
+
)
}
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..da5c52c
--- /dev/null
+++ b/packages/plugin/src/tui/slots/sidebar-utils.ts
@@ -0,0 +1,56 @@
+import type { SidebarSnapshot } from "../../shared/rpc-types"
+
+/**
+ * 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"
From af386db33945bc29b8bed0ca2d389fb63114d3e1 Mon Sep 17 00:00:00 2001
From: Zireael <3856578+Zireael@users.noreply.github.com>
Date: Sat, 23 May 2026 05:20:07 +0200
Subject: [PATCH 2/3] config(tui): wire compact_bar thresholds from
magic-context.jsonc into collapsed sidebar
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds three user-configurable settings under tui.compact_bar in
magic-context.jsonc to control collapsed sidebar bar segment labels:
- label_threshold (0.10): min share to show short token-count label
- free_label_threshold (0.25): min share to show full "XXK Free" label
- show_free_label (true): toggle the "Free" suffix on the free segment
Layout:
sidebar-utils.ts — CompactBarOptions interface + DEFAULT_COMPACT_BAR_OPTIONS
sidebar-content.tsx — reads magic-context.jsonc via shared utilities,
threads compactBarOptions → SidebarContent → TokenBreakdown, replaces
hardcoded 0.08/0.12 thresholds with config-driven barOpts()
magic-context.schema.json — tui.* schema sections with defaults
Also adds the missing showFreeShort render tier so the free-segment number
label appears between label_threshold and free_label_threshold, and applies
overflow="hidden" to all label segments to prevent text bleed.
Co-authored-by: CommandCodeBot
---
assets/magic-context.schema.json | 63 +++++++++++++++++
.../plugin/src/tui/slots/sidebar-content.tsx | 67 +++++++++++++++++--
.../plugin/src/tui/slots/sidebar-utils.ts | 26 +++++++
3 files changed, 149 insertions(+), 7 deletions(-)
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 6ae1e62..7ded89e 100644
--- a/packages/plugin/src/tui/slots/sidebar-content.tsx
+++ b/packages/plugin/src/tui/slots/sidebar-content.tsx
@@ -3,7 +3,9 @@ import { createEffect, createMemo, createSignal, on, onCleanup, Show } from "sol
import type { TuiSlotPlugin, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
import packageJson from "../../../package.json"
import { loadSidebarSnapshot, type SidebarSnapshot } from "../data/context-db"
-import { compactTokens, collapsedStatusLine, formatThresholdPercent } from "./sidebar-utils"
+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
@@ -45,7 +47,12 @@ 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
@@ -183,12 +190,16 @@ const TokenBreakdown = (props: {
show token-count labels centered over their colored box. */}
{(props.compact ? barSegments() : barSegments()).map((seg) => {
- // In compact mode, overlay a label when the segment is
- // wide enough (≥8% of the total). Free segments get the
- // "XXK Free" label at ≥12% to accommodate the longer text.
+ // 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 showLabel = props.compact && pct >= 0.08 && seg.key !== "free"
- const showFreeLabel = props.compact && seg.key === "free" && pct >= 0.12
+ 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 (
@@ -201,12 +212,31 @@ const TokenBreakdown = (props: {
flexDirection="row"
alignItems="center"
justifyContent="center"
+ overflow="hidden"
>
{`${compactTokens(seg.tokens)} Free`}
)
}
+ if (showFreeShort) {
+ return (
+
+ {compactTokens(seg.tokens)}
+
+ )
+ }
+
if (showLabel) {
return (
{compactTokens(seg.tokens)}
@@ -298,6 +329,7 @@ const SidebarContent = (props: {
api: TuiPluginApi
sessionID: () => string
theme: TuiThemeCurrent
+ compactBarOptions?: CompactBarOptions
}) => {
const [snapshot, setSnapshot] = createSignal(null)
let refreshTimer: ReturnType | undefined
@@ -424,7 +456,7 @@ const SidebarContent = (props: {
{/* Collapsed: compact bar + status line */}
0}>
-
+
{collapsedStatusLineMemo()}
@@ -563,6 +595,26 @@ const SidebarContent = (props: {
}
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: {
@@ -573,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.ts b/packages/plugin/src/tui/slots/sidebar-utils.ts
index da5c52c..830d5c1 100644
--- a/packages/plugin/src/tui/slots/sidebar-utils.ts
+++ b/packages/plugin/src/tui/slots/sidebar-utils.ts
@@ -1,5 +1,31 @@
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"
From c6b425e1ff9c01d80dbe48620ed4a2c74e20bbcb Mon Sep 17 00:00:00 2001
From: zirdev <3856578+Zireael@users.noreply.github.com>
Date: Fri, 29 May 2026 06:42:49 +0200
Subject: [PATCH 3/3] fix(tui): address sidebar collapse review findings
- Wire tui.sidebar.collapse_default via KV user-set flag (config until first toggle)
- Use collapsedUsageLine helpers with formatThresholdPercent in collapsed header
- Show status line when inputTokens is 0; surface notes in collapsed status
- Parse and clamp tui config in sidebar-utils; add KV/config unit tests
---
.../plugin/src/tui/slots/sidebar-content.tsx | 86 +++++++-----
.../src/tui/slots/sidebar-utils.test.ts | 128 ++++++++++++++++--
.../plugin/src/tui/slots/sidebar-utils.ts | 124 +++++++++++++++--
3 files changed, 280 insertions(+), 58 deletions(-)
diff --git a/packages/plugin/src/tui/slots/sidebar-content.tsx b/packages/plugin/src/tui/slots/sidebar-content.tsx
index 7ded89e..76e52fc 100644
--- a/packages/plugin/src/tui/slots/sidebar-content.tsx
+++ b/packages/plugin/src/tui/slots/sidebar-content.tsx
@@ -3,7 +3,19 @@ import { createEffect, createMemo, createSignal, on, onCleanup, Show } from "sol
import type { TuiSlotPlugin, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
import packageJson from "../../../package.json"
import { loadSidebarSnapshot, type SidebarSnapshot } from "../data/context-db"
-import { compactTokens, collapsedStatusLine, formatThresholdPercent, type CompactBarOptions, DEFAULT_COMPACT_BAR_OPTIONS } from "./sidebar-utils"
+import {
+ collapsedStatusLine,
+ collapsedUsagePercentSegment,
+ collapsedUsageTokensSegment,
+ compactTokens,
+ formatThresholdPercent,
+ parseTuiSidebarSettings,
+ persistSidebarCollapsed,
+ resolveInitialSidebarCollapsed,
+ type CompactBarOptions,
+ DEFAULT_COMPACT_BAR_OPTIONS,
+ type TuiSidebarSettings,
+} from "./sidebar-utils"
import { readJsoncFile } from "../../shared/jsonc-parser"
import { getOpenCodeConfigPaths } from "../../shared/opencode-config-dir"
@@ -189,7 +201,7 @@ const TokenBreakdown = (props: {
always fills the sidebar. In compact mode, wide-enough segments
show token-count labels centered over their colored box. */}
- {(props.compact ? barSegments() : barSegments()).map((seg) => {
+ {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
@@ -329,7 +341,7 @@ const SidebarContent = (props: {
api: TuiPluginApi
sessionID: () => string
theme: TuiThemeCurrent
- compactBarOptions?: CompactBarOptions
+ sidebarSettings: TuiSidebarSettings
}) => {
const [snapshot, setSnapshot] = createSignal(null)
let refreshTimer: ReturnType | undefined
@@ -403,15 +415,17 @@ const SidebarContent = (props: {
return props.theme.accent
})
- // Collapse state persisted via KV (survives restarts)
- const COLLAPSED_KV_KEY = "mc-sidebar-collapsed"
+ // Collapse state: config default until the user toggles, then KV wins.
const [collapsed, setCollapsed] = createSignal(
- props.api.kv.get(COLLAPSED_KV_KEY, false) as boolean,
+ resolveInitialSidebarCollapsed(props.api.kv, props.sidebarSettings.collapseDefault),
)
- createEffect(() => {
- props.api.kv.set(COLLAPSED_KV_KEY, collapsed())
- })
- const toggle = () => setCollapsed((x) => !x)
+ const toggle = () => {
+ setCollapsed((prev) => {
+ const next = !prev
+ persistSidebarCollapsed(props.api.kv, next)
+ return next
+ })
+ }
// Status line for collapsed view (line 3)
const collapsedStatusLineMemo = createMemo(() => collapsedStatusLine(s()))
@@ -438,25 +452,32 @@ const SidebarContent = (props: {
v{packageJson.version}
}>
- 0 && (s()?.contextLimit ?? 0) > 0} fallback={
+ 0} fallback={
▶ Magic Context
}>
- ▶ {s()!.usagePercentage.toFixed(1)}% / {formatThresholdPercent(s()!.executeThreshold)}%
+ ▶ {collapsedUsagePercentSegment(s()!.usagePercentage, s()!.executeThreshold)}
- {compactTokens(s()!.inputTokens)} / {compactTokens(s()!.contextLimit)}
+ {collapsedUsageTokensSegment(s()!.inputTokens, s()!.contextLimit)}
- {/* Collapsed: compact bar + status line */}
- 0}>
-
+ {/* Collapsed: compact bar (when usage exists) + status line (always when snapshot loaded) */}
+
+ 0}>
+
+
{collapsedStatusLineMemo()}
@@ -594,27 +615,18 @@ const SidebarContent = (props: {
)
}
+function loadTuiSidebarSettings(): TuiSidebarSettings {
+ try {
+ const cfgPaths = getOpenCodeConfigPaths({ binary: "opencode" })
+ const cfg = readJsoncFile>(cfgPaths.omoConfig)
+ return parseTuiSidebarSettings(cfg)
+ } catch {
+ return { collapseDefault: false }
+ }
+}
+
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
- }
- })()
+ const sidebarSettings = loadTuiSidebarSettings()
return {
order: 150,
slots: {
@@ -625,7 +637,7 @@ export function createSidebarContentSlot(api: TuiPluginApi): TuiSlotPlugin {
api={api}
sessionID={() => value.session_id}
theme={theme()}
- compactBarOptions={compactBarOptions}
+ sidebarSettings={sidebarSettings}
/>
)
},
diff --git a/packages/plugin/src/tui/slots/sidebar-utils.test.ts b/packages/plugin/src/tui/slots/sidebar-utils.test.ts
index e679b5b..2242e7b 100644
--- a/packages/plugin/src/tui/slots/sidebar-utils.test.ts
+++ b/packages/plugin/src/tui/slots/sidebar-utils.test.ts
@@ -1,5 +1,16 @@
import { describe, expect, it } from "bun:test"
-import { compactTokens, collapsedStatusLine, collapsedUsageLine } from "./sidebar-utils"
+import {
+ COLLAPSED_KV_KEY,
+ COLLAPSED_USER_SET_KV_KEY,
+ compactTokens,
+ collapsedStatusLine,
+ collapsedUsageLine,
+ collapsedUsagePercentSegment,
+ collapsedUsageTokensSegment,
+ parseTuiSidebarSettings,
+ persistSidebarCollapsed,
+ resolveInitialSidebarCollapsed,
+} from "./sidebar-utils"
import type { SidebarSnapshot } from "../../shared/rpc-types"
// ---------------------------------------------------------------------------
@@ -16,7 +27,7 @@ describe("compactTokens", () => {
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"
+ expect(compactTokens(999_999)).toBe("999K")
})
it("formats millions with M suffix (one decimal)", () => {
@@ -26,19 +37,102 @@ describe("compactTokens", () => {
})
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(999_999)).toBe("999K")
expect(compactTokens(1_000_000)).toBe("1.0M")
})
})
+// ---------------------------------------------------------------------------
+// parseTuiSidebarSettings
+// ---------------------------------------------------------------------------
+describe("parseTuiSidebarSettings", () => {
+ it("returns defaults for missing config", () => {
+ expect(parseTuiSidebarSettings(null)).toEqual({ collapseDefault: false })
+ expect(parseTuiSidebarSettings({})).toEqual({ collapseDefault: false })
+ })
+
+ it("reads collapse_default from tui.sidebar", () => {
+ expect(
+ parseTuiSidebarSettings({
+ tui: { sidebar: { collapse_default: true } },
+ }),
+ ).toEqual({ collapseDefault: true })
+ })
+
+ it("reads and clamps compact_bar thresholds", () => {
+ expect(
+ parseTuiSidebarSettings({
+ tui: {
+ compact_bar: {
+ label_threshold: 0.99,
+ free_label_threshold: 0.01,
+ show_free_label: false,
+ },
+ },
+ }),
+ ).toEqual({
+ collapseDefault: false,
+ compactBarOptions: {
+ labelThreshold: 0.5,
+ freeLabelThreshold: 0.1,
+ showFreeLabel: false,
+ },
+ })
+ })
+})
+
+// ---------------------------------------------------------------------------
+// resolveInitialSidebarCollapsed
+// ---------------------------------------------------------------------------
+describe("resolveInitialSidebarCollapsed", () => {
+ it("uses collapse_default when user has not toggled", () => {
+ const kv = new Map()
+ const api = {
+ get: (key: string, def: unknown) => kv.get(key) ?? def,
+ set: (key: string, val: unknown) => {
+ kv.set(key, val)
+ },
+ }
+ expect(resolveInitialSidebarCollapsed(api, true)).toBe(true)
+ expect(resolveInitialSidebarCollapsed(api, false)).toBe(false)
+ expect(kv.has(COLLAPSED_KV_KEY)).toBe(false)
+ })
+
+ it("uses KV when user has toggled", () => {
+ const kv = new Map([
+ [COLLAPSED_USER_SET_KV_KEY, true],
+ [COLLAPSED_KV_KEY, false],
+ ])
+ const api = {
+ get: (key: string, def: unknown) => kv.get(key) ?? def,
+ set: (key: string, val: unknown) => {
+ kv.set(key, val)
+ },
+ }
+ expect(resolveInitialSidebarCollapsed(api, true)).toBe(false)
+ })
+
+ it("persistSidebarCollapsed marks user-set and stores value", () => {
+ const kv = new Map()
+ const api = {
+ get: (key: string, def: unknown) => kv.get(key) ?? def,
+ set: (key: string, val: unknown) => {
+ kv.set(key, val)
+ },
+ }
+ persistSidebarCollapsed(api, true)
+ expect(kv.get(COLLAPSED_KV_KEY)).toBe(true)
+ expect(kv.get(COLLAPSED_USER_SET_KV_KEY)).toBe(true)
+ expect(resolveInitialSidebarCollapsed(api, false)).toBe(true)
+ })
+})
+
// ---------------------------------------------------------------------------
// collapsedStatusLine
// ---------------------------------------------------------------------------
@@ -86,7 +180,6 @@ describe("collapsedStatusLine", () => {
})
it("prefers historian/compaction over dreamer", () => {
- // Both active — historian wins
const result = collapsedStatusLine(
baseSnapshot({
historianRunning: true,
@@ -110,6 +203,13 @@ describe("collapsedStatusLine", () => {
expect(result).toContain("3 pending")
})
+ it("reports session notes when idle but notes exist", () => {
+ const result = collapsedStatusLine(
+ baseSnapshot({ sessionNoteCount: 2, readySmartNoteCount: 1 }),
+ )
+ expect(result).toBe("2 Notes · 1 Smart")
+ })
+
it("shows static counts when nothing is active", () => {
const result = collapsedStatusLine(baseSnapshot())
expect(result).toBe("3 Comp · 5 Fact · 5 Memory")
@@ -139,9 +239,9 @@ describe("collapsedUsageLine", () => {
expect(line).toBe("47.5% / 65% 111K / 180K")
})
- it("renders fractional threshold with one decimal", () => {
+ it("renders fractional threshold with one decimal via formatThresholdPercent", () => {
const line = collapsedUsageLine(47.5, 14.099, 111_000, 180_000)
- expect(line).toBe("47.5% / 14% 111K / 180K")
+ expect(line).toBe("47.5% / 14.1% 111K / 180K")
})
it("shows em-dash for missing threshold", () => {
@@ -170,3 +270,15 @@ describe("collapsedUsageLine", () => {
expect(line).toBe("50.0% / 65% [1000] / [2000]")
})
})
+
+describe("collapsedUsagePercentSegment", () => {
+ it("matches formatThresholdPercent for fractional thresholds", () => {
+ expect(collapsedUsagePercentSegment(47.5, 14.099)).toBe("47.5% / 14.1%")
+ })
+})
+
+describe("collapsedUsageTokensSegment", () => {
+ it("formats token pair", () => {
+ expect(collapsedUsageTokensSegment(111_000, 180_000)).toBe("111K / 180K")
+ })
+})
diff --git a/packages/plugin/src/tui/slots/sidebar-utils.ts b/packages/plugin/src/tui/slots/sidebar-utils.ts
index 830d5c1..83701f9 100644
--- a/packages/plugin/src/tui/slots/sidebar-utils.ts
+++ b/packages/plugin/src/tui/slots/sidebar-utils.ts
@@ -1,4 +1,5 @@
import type { SidebarSnapshot } from "../../shared/rpc-types"
+import { formatThresholdPercent } from "../../shared/format-threshold"
// ---------------------------------------------------------------------------
// Compact bar configuration (from magic-context.jsonc → compact_bar)
@@ -26,19 +27,92 @@ export const DEFAULT_COMPACT_BAR_OPTIONS: Required = {
showFreeLabel: true,
}
+export interface TuiSidebarSettings {
+ collapseDefault: boolean
+ compactBarOptions?: CompactBarOptions
+}
+
+export const COLLAPSED_KV_KEY = "mc-sidebar-collapsed"
+export const COLLAPSED_USER_SET_KV_KEY = "mc-sidebar-collapsed-user-set"
+
+type SidebarKv = {
+ get: (key: string, defaultValue: unknown) => unknown
+ set: (key: string, value: unknown) => void
+}
+
+function clampThreshold(value: number, min: number, max: number): number {
+ if (!Number.isFinite(value)) return min
+ return Math.min(max, Math.max(min, value))
+}
+
+/** Parse `tui` section from magic-context.jsonc (pure — no file I/O). */
+export function parseTuiSidebarSettings(cfg: Record | null | undefined): TuiSidebarSettings {
+ const result: TuiSidebarSettings = { collapseDefault: false }
+ if (!cfg || typeof cfg !== "object") return result
+
+ const tuiSection = cfg.tui
+ if (!tuiSection || typeof tuiSection !== "object") return result
+
+ const tui = tuiSection as Record
+
+ const sidebar = tui.sidebar
+ if (sidebar && typeof sidebar === "object") {
+ const collapseDefault = (sidebar as Record).collapse_default
+ if (typeof collapseDefault === "boolean") {
+ result.collapseDefault = collapseDefault
+ }
+ }
+
+ const compactBar = tui.compact_bar
+ if (compactBar && typeof compactBar === "object") {
+ const cb = compactBar as Record
+ const opts: CompactBarOptions = {}
+ if (typeof cb.label_threshold === "number") {
+ opts.labelThreshold = clampThreshold(cb.label_threshold, 0.05, 0.5)
+ }
+ if (typeof cb.free_label_threshold === "number") {
+ opts.freeLabelThreshold = clampThreshold(cb.free_label_threshold, 0.1, 0.5)
+ }
+ if (typeof cb.show_free_label === "boolean") {
+ opts.showFreeLabel = cb.show_free_label
+ }
+ if (Object.keys(opts).length > 0) {
+ result.compactBarOptions = opts
+ }
+ }
+
+ return result
+}
+
+/** Initial collapse: KV after explicit user toggle, else `collapse_default` from config. */
+export function resolveInitialSidebarCollapsed(
+ kv: SidebarKv,
+ collapseDefault: boolean,
+): boolean {
+ if (kv.get(COLLAPSED_USER_SET_KV_KEY, false) === true) {
+ return kv.get(COLLAPSED_KV_KEY, false) === true
+ }
+ return collapseDefault
+}
+
+export function persistSidebarCollapsed(kv: SidebarKv, collapsed: boolean): void {
+ kv.set(COLLAPSED_KV_KEY, collapsed)
+ kv.set(COLLAPSED_USER_SET_KV_KEY, 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`
+ if (value >= 1_000) return `${Math.floor(value / 1_000)}K`
return String(value)
}
/**
* Build a one-line status summary for the collapsed sidebar view.
- * Prioritises active operations (historian, dreamer, pending queue)
+ * Prioritises active operations (historian, dreamer, pending queue, notes)
* over static counts.
*/
export function collapsedStatusLine(snap: SidebarSnapshot | null): string {
@@ -52,31 +126,55 @@ export function collapsedStatusLine(snap: SidebarSnapshot | null): string {
if (snap.pendingOpsCount > 0) {
return `Queue: ${snap.pendingOpsCount} pending`
}
+ const noteParts: string[] = []
+ if (snap.sessionNoteCount > 0) {
+ noteParts.push(`${snap.sessionNoteCount} Notes`)
+ }
+ if (snap.readySmartNoteCount > 0) {
+ noteParts.push(`${snap.readySmartNoteCount} Smart`)
+ }
+ if (noteParts.length > 0) {
+ return noteParts.join(" · ")
+ }
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(
+/** Left segment of the collapsed usage header, e.g. `47.5% / 65%`. */
+export function collapsedUsagePercentSegment(
usagePercentage: number,
executeThreshold: number | undefined | null,
+): string {
+ return `${usagePercentage.toFixed(1)}% / ${formatThresholdPercent(executeThreshold)}%`
+}
+
+/** Right segment of the collapsed usage header, e.g. `111K / 180K`. */
+export function collapsedUsageTokensSegment(
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}`
+ return `${used} / ${limit}`
+}
+
+/**
+ * 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 left = collapsedUsagePercentSegment(usagePercentage, executeThreshold)
+ const right = collapsedUsageTokensSegment(inputTokens, contextLimit, compactTokensFn)
+ return `${left} ${right}`
}
export { formatThresholdPercent } from "../../shared/format-threshold"