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/2] 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/2] 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"