opencode-context-indicator 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ // lib/tui-data.d.ts
2
+ //
3
+ // Types for lib/tui-data.js (the TUI data layer). state.json is written by
4
+ // writeStateFile() in index.js and is untrusted input — every consumer treats
5
+ // the fields as optional.
6
+
7
+ export interface Categories {
8
+ user?: number
9
+ assistant?: number
10
+ reasoning?: number
11
+ toolArgs?: number
12
+ system?: number | null
13
+ toolSchemas?: number | null
14
+ other?: number
15
+ }
16
+
17
+ export interface StateEntry {
18
+ sessionID?: string
19
+ parentID?: string | null
20
+ role?: string
21
+ agent?: string | null
22
+ model?: string
23
+ providerID?: string
24
+ ctx?: number
25
+ input?: number
26
+ usable?: number | null
27
+ reserve?: number | null
28
+ limit?: number | null
29
+ reasoning?: number
30
+ categories?: Categories
31
+ updatedAt?: string
32
+ }
33
+
34
+ export declare const STATE_FILE: string
35
+ export declare const POLL_MS: number
36
+ export declare function isNodeModulesInstall(): boolean
37
+ export declare function readSnapshot(): Record<string, StateEntry>
38
+ export declare function collectFamily(
39
+ snapshot: Record<string, StateEntry>,
40
+ rootID: string,
41
+ ): StateEntry[]
42
+ export declare function fmt(n: number): string
43
+ export declare function num(v: unknown): number
44
+ export declare function fit(s: string, w: number): string
45
+ export declare function updatedAt(iso: string | undefined): string
@@ -0,0 +1,165 @@
1
+ // lib/tui-data.js
2
+ //
3
+ // Data layer for the TUI entry (tui.tsx): the state.json bridge (readSnapshot /
4
+ // isEmptyEntry / collectFamily), the shared formatting helpers (fmt / num / fit
5
+ // / updatedAt) and the npm-install detection. Plain JS with JSDoc — same shape
6
+ // as lib/contributors.js — so tui.tsx stays a thin JSX/setup shell and the node
7
+ // tooling (resolve gate, future unit tests) can import it without a transform.
8
+ //
9
+ // These helpers must stay free of solid-js / @opentui imports: the TUI host
10
+ // bridges those modules only for the JSX entry, and keeping this file pure
11
+ // means it also loads anywhere node does.
12
+ //
13
+ // state.json shape (written by writeStateFile() in index.js, untrusted input):
14
+ // { [sessionID]: { sessionID, parentID, role, agent, model, providerID,
15
+ // ctx, input, usable, reserve, limit, reasoning, categories{...}, updatedAt } }
16
+ // The authoritative TS mirror of the entry shape is the `StateEntry` interface
17
+ // in lib/tui-data.d.ts — keep the two structurally compatible.
18
+
19
+ import { readFileSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ export const STATE_FILE =
25
+ process.env.OPENCODE_CONTEXT_INDICATOR_STATE_FILE ||
26
+ join(tmpdir(), "opencode-context-indicator-state.json");
27
+
28
+ export const POLL_MS = 1000;
29
+
30
+ // True when this package was loaded from an npm install, i.e. its own file
31
+ // path contains a `node_modules` segment (OpenCode's TUI loader skips the host
32
+ // Solid transform for such paths → slots mount once but never live-update; see
33
+ // the Graceful mode note in tui.tsx). Normalised to forward slashes for
34
+ // Windows paths. ANY file of the package yields the same answer, so checking
35
+ // from lib/ is equivalent to checking from the tui.tsx entry.
36
+ export function isNodeModulesInstall() {
37
+ try {
38
+ const selfPath = fileURLToPath(import.meta.url).replace(/\\/g, "/").toLowerCase()
39
+ return selfPath.includes("/node_modules/")
40
+ } catch {
41
+ return false
42
+ }
43
+ }
44
+
45
+ // Reserved object keys must never be treated as session ids: state.json is
46
+ // untrusted input (structural mirror of isSafeSessionKey() in lib/estimate.js,
47
+ // including the string/length requirement).
48
+ function isSafeKey(k) {
49
+ return (
50
+ typeof k === "string" &&
51
+ k.length > 0 &&
52
+ k !== "__proto__" &&
53
+ k !== "constructor" &&
54
+ k !== "prototype"
55
+ )
56
+ }
57
+
58
+ // Whole state.json snapshot ({ sessionID: entry }) or {} on any error. The
59
+ // sidebar resolves one entry from it and the breakdown panel walks the
60
+ // parentID family over it. Never throws — a missing / empty / corrupt
61
+ // file simply yields {}.
62
+ export function readSnapshot() {
63
+ try {
64
+ const raw = readFileSync(STATE_FILE, "utf8")
65
+ const all = JSON.parse(raw)
66
+ if (!all || typeof all !== "object" || Array.isArray(all)) return {}
67
+ // Rebuild into a fresh plain object: drops prototype-poisoning keys
68
+ // (__proto__ / constructor / prototype) and non-object entries in one pass.
69
+ const clean = {}
70
+ for (const [k, v] of Object.entries(all)) {
71
+ if (isSafeKey(k) && v && typeof v === "object") clean[k] = v
72
+ }
73
+ return clean
74
+ } catch {
75
+ return {}
76
+ }
77
+ }
78
+
79
+ // True when an entry carries no measured tokens at all (a registered session
80
+ // that never served a step). Mirrors isEmptyStateEntry() in lib/commands.js:
81
+ // `categories` is always persisted, so the emptiness test SUMS its fields —
82
+ // a missing categories object is not the only trigger. `system` / `toolSchemas`
83
+ // may legitimately be null, hence the num() coercion.
84
+ function isEmptyEntry(e) {
85
+ const c = e?.categories
86
+ const sum =
87
+ num(c?.user) +
88
+ num(c?.assistant) +
89
+ num(c?.reasoning) +
90
+ num(c?.toolArgs) +
91
+ num(c?.system) +
92
+ num(c?.toolSchemas) +
93
+ num(c?.other)
94
+ return sum === 0 && num(e?.ctx) === 0 && num(e?.input) === 0
95
+ }
96
+
97
+ // The panel row set: the root entry (panel.sessionID) plus every entry whose
98
+ // parentID chain reaches it, breadth-first. A small local walk over the snapshot
99
+ // — the server's collectDescendantSessionIDs lives in lib/commands.js and is NOT
100
+ // imported here (the TUI entry stays independent of the server code by design). A
101
+ // phantom node (no tokens, see isEmptyEntry) is not emitted, but traversal still
102
+ // descends through it so real grandchildren behind it are found. `seen` guards
103
+ // against cycles (parentID loops) and double-listing.
104
+ export function collectFamily(snapshot, rootID) {
105
+ const out = []
106
+ // hasOwnProperty guard: a host-supplied rootID of "__proto__"/"constructor"
107
+ // must never resolve through the prototype chain into a phantom row.
108
+ const root = Object.prototype.hasOwnProperty.call(snapshot, rootID)
109
+ ? snapshot[rootID]
110
+ : undefined
111
+ if (root && typeof root === "object") out.push(root)
112
+ const seen = new Set([rootID])
113
+ const queue = [rootID]
114
+ // Index the snapshot by parentID once (O(n)) instead of an O(n) scan per node.
115
+ const byParent = new Map()
116
+ for (const [id, e] of Object.entries(snapshot)) {
117
+ const pid = e?.parentID
118
+ if (!pid || typeof pid !== "string") continue
119
+ const kids = byParent.get(pid)
120
+ if (kids) kids.push(id)
121
+ else byParent.set(pid, [id])
122
+ }
123
+ while (queue.length > 0) {
124
+ const cur = queue.shift()
125
+ const kids = byParent.get(cur)
126
+ if (!kids) continue
127
+ for (const id of kids) {
128
+ if (!id || seen.has(id)) continue
129
+ seen.add(id)
130
+ const e = snapshot[id]
131
+ if (e && !isEmptyEntry(e)) out.push(e)
132
+ queue.push(id) // descend through phantom parents too
133
+ }
134
+ }
135
+ return out
136
+ }
137
+
138
+ export function fmt(n) {
139
+ if (!Number.isFinite(n)) return "0"
140
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n))
141
+ }
142
+
143
+ export function num(v) {
144
+ return typeof v === "number" && Number.isFinite(v) ? v : 0
145
+ }
146
+
147
+ // Code-point-safe column fit: truncate on code points (never splitting a
148
+ // surrogate pair at a column boundary), mark a truncated cell with a trailing
149
+ // "~" so clipping is VISIBLE (a silently clipped cell reads as two columns
150
+ // colliding), then pad to the column width.
151
+ export function fit(s, w) {
152
+ const cps = Array.from(s)
153
+ if (cps.length > w) return `${cps.slice(0, Math.max(0, w - 1)).join("")}~`
154
+ return s.padEnd(w)
155
+ }
156
+
157
+ export function updatedAt(iso) {
158
+ if (!iso) return "?"
159
+ try {
160
+ const d = new Date(iso)
161
+ return Number.isNaN(d.getTime()) ? "?" : d.toLocaleTimeString()
162
+ } catch {
163
+ return "?"
164
+ }
165
+ }
@@ -0,0 +1,329 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ // lib/tui-panel.tsx — the `session.panel` component (BreakdownPanel), moved
3
+ // verbatim from tui.tsx when the TUI entry was split: the host's Solid
4
+ // transform applies to EVERY plugin file outside node_modules
5
+ // (solid-plugin.js sourceFilter in @opentui/solid), so JSX in imported files
6
+ // is compiled the same way as in the entry.
7
+
8
+ import { usePlugin } from "@opencode/plugin/tui"
9
+ import { createMemo, Show } from "solid-js"
10
+ import { mkdirSync, writeFileSync } from "node:fs"
11
+ import { tmpdir } from "node:os"
12
+ import { basename, join } from "node:path"
13
+ import type { StateEntry } from "./tui-data.js"
14
+ import { collectFamily, fit, fmt, num, updatedAt } from "./tui-data.js"
15
+ import { token } from "./tui-theme.js"
16
+ import {
17
+ buildRedactedPayload,
18
+ contributorLine,
19
+ exportFileName,
20
+ measureContributors,
21
+ panelDenom,
22
+ panelRole,
23
+ safeText,
24
+ } from "./contributors.js"
25
+
26
+ // Mirrors the Contributor shape produced by measureContributors()
27
+ // (lib/contributors.js).
28
+ type Contributor = {
29
+ kind: string
30
+ ref: string
31
+ chars: number
32
+ tokensEstimate: number
33
+ tool: string
34
+ preview: string
35
+ }
36
+
37
+ // Host-owned session.panel input ({ name, sessionID, width, presentation,
38
+ // focused, focus(), close(), toggleFullscreen() }). Typed structurally instead
39
+ // of imported so this file stays independent of the host type package; the host
40
+ // resolves the panel API at runtime.
41
+ type PanelInput = {
42
+ readonly name: string
43
+ readonly sessionID: string
44
+ readonly width: number
45
+ readonly presentation: "panel" | "fullscreen"
46
+ readonly focused: boolean
47
+ readonly focus: () => void
48
+ readonly close: () => void
49
+ readonly toggleFullscreen: () => void
50
+ }
51
+
52
+ // The TUI message data API is optional across host versions. Log a single hint
53
+ // (never once per poll) when it is unavailable and hide the whole section.
54
+ let warnedMessagesUnavailable = false
55
+
56
+ // Panel column layout (monospace), derived from the host-provided panel width
57
+ // so cells never collide on narrow terminals. Gaps between columns keep a
58
+ // truncated cell visually separated from its neighbour.
59
+ const GUTTER = 2
60
+ const CTX_WIDTH = 12
61
+
62
+ function panelLayout(panelWidth: number): {
63
+ roleW: number
64
+ modelW: number
65
+ ctxW: number
66
+ updW: number
67
+ gutter: string
68
+ } {
69
+ const w = num(panelWidth) > 0 ? num(panelWidth) : 60
70
+ const ctxW = CTX_WIDTH
71
+ const updW = Math.max(5, Math.min(8, w - ctxW - 3 * GUTTER - 24))
72
+ const roleW = Math.max(12, Math.min(20, Math.floor(w * 0.3)))
73
+ const modelW = Math.max(10, w - roleW - ctxW - updW - 3 * GUTTER)
74
+ return { roleW, modelW, ctxW, updW, gutter: " ".repeat(GUTTER) }
75
+ }
76
+
77
+ // Full session panel: header + one row per session in the family (root plus
78
+ // subagent descendants). It reads the same state.json bridge as the sidebar via
79
+ // the shared 1 s tick, so both surfaces refresh on ONE poll. `panel.sessionID`
80
+ // selects the root; `cx` opens the panel (registered from the "app" slot in
81
+ // tui.tsx).
82
+ export function BreakdownPanel(props: {
83
+ panel: PanelInput
84
+ snapshot: () => Record<string, StateEntry>
85
+ refresh: () => void
86
+ }) {
87
+ const context = usePlugin()
88
+ const base = () => token(context?.theme?.text?.base, "white")
89
+ const muted = () => token(context?.theme?.text?.muted, base())
90
+
91
+ // Panel-local keys. The layer is owned by this component and disposed with it.
92
+ // mode "global" + an explicit focused check is the portable fallback when no
93
+ // target renderable is available: the keys act only while the panel owns input.
94
+ // The capability guard keeps an older host (no keymap API) from throwing.
95
+ const keymap = context?.keymap
96
+ if (keymap && typeof keymap.layer === "function") {
97
+ keymap.layer(() => ({
98
+ mode: "global",
99
+ priority: -1,
100
+ commands: [
101
+ {
102
+ id: "context-indicator.breakdown.close",
103
+ title: "Close the context breakdown panel",
104
+ bind: "escape",
105
+ enabled: () => props.panel.focused,
106
+ run: () => {
107
+ if (props.panel.focused) props.panel.close()
108
+ },
109
+ },
110
+ {
111
+ id: "context-indicator.breakdown.refresh",
112
+ title: "Refresh the context breakdown panel",
113
+ bind: "r",
114
+ enabled: () => props.panel.focused,
115
+ run: () => {
116
+ if (props.panel.focused) props.refresh()
117
+ },
118
+ },
119
+ {
120
+ id: "context-indicator.breakdown.export",
121
+ title: "Export a redacted breakdown snapshot",
122
+ bind: "e",
123
+ enabled: () => props.panel.focused,
124
+ run: () => {
125
+ if (props.panel.focused) exportRedacted()
126
+ },
127
+ },
128
+ ],
129
+ }))
130
+ }
131
+
132
+ // One snapshot read per poll tick (props.snapshot tracks the shared tick), so
133
+ // the memo recomputes together with the sidebar instead of polling separately.
134
+ const data = createMemo(() => {
135
+ const snap = props.snapshot()
136
+ return {
137
+ root: snap[props.panel.sessionID],
138
+ rows: collectFamily(snap, props.panel.sessionID),
139
+ }
140
+ })
141
+
142
+ // Live message list from the TUI data layer (in-process, no LLM call). The
143
+ // shared `props.snapshot()` tick is read FIRST so this re-reads every poll.
144
+ // `available: false` means the API is absent/errored -> the WHOLE measured
145
+ // section is hidden (and a one-time hint was logged).
146
+ const messages = createMemo<{ available: boolean; list: unknown[] }>(() => {
147
+ props.snapshot()
148
+ try {
149
+ const api = context?.data?.session?.message
150
+ if (!api || typeof api.list !== "function") {
151
+ if (!warnedMessagesUnavailable) {
152
+ warnedMessagesUnavailable = true
153
+ console.error(
154
+ "[context-indicator] TUI message data API unavailable; " +
155
+ "measured contributors section hidden",
156
+ )
157
+ }
158
+ return { available: false, list: [] }
159
+ }
160
+ const v = api.list(props.panel.sessionID)
161
+ return { available: true, list: Array.isArray(v) ? v : [] }
162
+ } catch {
163
+ if (!warnedMessagesUnavailable) {
164
+ warnedMessagesUnavailable = true
165
+ console.error(
166
+ "[context-indicator] TUI message read failed; " +
167
+ "measured contributors section hidden",
168
+ )
169
+ }
170
+ return { available: false, list: [] }
171
+ }
172
+ })
173
+
174
+ const contributors = createMemo<Contributor[]>(() => {
175
+ const m = messages()
176
+ if (!m.available) return []
177
+ try {
178
+ return measureContributors(m.list)
179
+ } catch {
180
+ return []
181
+ }
182
+ })
183
+
184
+ // Redacted export (key `e`). No message text, no full session id, no tool
185
+ // output content ever leaves this function. Never throws: a failure logs and
186
+ // shows an error toast instead.
187
+ function exportRedacted(): void {
188
+ let exportedName: string | null = null
189
+ try {
190
+ const now = new Date()
191
+ const name = exportFileName(props.panel.sessionID, now)
192
+ const payload = buildRedactedPayload({
193
+ sessionID: props.panel.sessionID,
194
+ root: data().root,
195
+ rows: data().rows,
196
+ contributors: contributors(),
197
+ now,
198
+ panelDenom,
199
+ panelRole,
200
+ num,
201
+ })
202
+ const dir = join(tmpdir(), "opencode-context-indicator", "exports")
203
+ const file = join(dir, name)
204
+ mkdirSync(dir, { recursive: true })
205
+ writeFileSync(file, JSON.stringify(payload, null, 2), "utf8")
206
+ exportedName = basename(file)
207
+ } catch (err) {
208
+ console.error("[context-indicator] redacted export failed:", err)
209
+ try {
210
+ context?.ui?.toast?.show({
211
+ message: "redacted export failed",
212
+ variant: "error",
213
+ })
214
+ } catch {
215
+ /* toast is best-effort */
216
+ }
217
+ return
218
+ }
219
+ // Outside the write try: a toast failure must not report the export as
220
+ // failed when the file is already on disk.
221
+ try {
222
+ context?.ui?.toast?.show({
223
+ message: `exported ${exportedName}`,
224
+ variant: "success",
225
+ })
226
+ } catch {
227
+ /* toast is best-effort */
228
+ }
229
+ }
230
+
231
+ const header = (): string => {
232
+ const r = data().root
233
+ if (!r) return "Context breakdown"
234
+ const model = safeText(r.model && r.model !== "unknown" ? r.model : "model?")
235
+ const ctx = fmt(num(r.ctx))
236
+ const d = panelDenom(r)
237
+ return d > 0
238
+ ? `Context breakdown \u2014 ${model} ${ctx}/${fmt(d)} (${Math.round(
239
+ (num(r.ctx) / d) * 100,
240
+ )}%)`
241
+ : `Context breakdown \u2014 ${model} ${ctx}`
242
+ }
243
+
244
+ const layout = (): {
245
+ roleW: number
246
+ modelW: number
247
+ ctxW: number
248
+ updW: number
249
+ gutter: string
250
+ } => panelLayout(props.panel.width)
251
+
252
+ const columns = (): string => {
253
+ const L = layout()
254
+ return (
255
+ fit("role", L.roleW) +
256
+ L.gutter +
257
+ fit("model", L.modelW) +
258
+ L.gutter +
259
+ fit("ctx", L.ctxW) +
260
+ L.gutter +
261
+ "updated"
262
+ )
263
+ }
264
+
265
+ const row = (e: StateEntry, isMain: boolean): string => {
266
+ const model = safeText(e.model && e.model !== "unknown" ? e.model : "model?")
267
+ const d = panelDenom(e)
268
+ const ctx =
269
+ d > 0
270
+ ? `${fmt(num(e.ctx))} (${Math.round((num(e.ctx) / d) * 100)}%)`
271
+ : fmt(num(e.ctx))
272
+ const L = layout()
273
+ return (
274
+ fit(panelRole(e, isMain), L.roleW) +
275
+ L.gutter +
276
+ fit(model, L.modelW) +
277
+ L.gutter +
278
+ fit(ctx, L.ctxW) +
279
+ L.gutter +
280
+ updatedAt(e.updatedAt)
281
+ )
282
+ }
283
+
284
+ return (
285
+ <box flexDirection="column">
286
+ <text fg={base()}>{header()}</text>
287
+ <Show
288
+ when={data().root}
289
+ fallback={<text fg={muted()}>{" no data yet"}</text>}
290
+ >
291
+ <text fg={muted()}>{columns()}</text>
292
+ {data().rows.map((e, i) => (
293
+ <text fg={muted()}>{row(e, i === 0)}</text>
294
+ ))}
295
+ <Show
296
+ when={
297
+ (data().root?.usable ?? 0) > 0
298
+ }
299
+ >
300
+ <text fg={muted()}>
301
+ {" compact at " +
302
+ fmt(num(data().root?.usable)) +
303
+ (num(data().root?.reserve) > 0
304
+ ? " (" + fmt(num(data().root?.reserve)) + " reserve)"
305
+ : "")}
306
+ </text>
307
+ </Show>
308
+ <Show when={messages().available}>
309
+ <text fg={base()}>{"largest contributors (measured)"}</text>
310
+ <Show
311
+ when={contributors().length > 0}
312
+ fallback={<text fg={muted()}>{" no messages yet"}</text>}
313
+ >
314
+ {contributors()
315
+ .slice(0, 5)
316
+ .map((c, i) => (
317
+ <text fg={muted()}>{contributorLine(c, i + 1)}</text>
318
+ ))}
319
+ </Show>
320
+ <text fg={muted()}>
321
+ {
322
+ "measured != server categories (tool outputs counted here; server categories exclude them)"
323
+ }
324
+ </text>
325
+ </Show>
326
+ </Show>
327
+ </box>
328
+ )
329
+ }
@@ -0,0 +1,72 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ // lib/tui-sidebar.tsx — the `sidebar.content` slot component. Moved verbatim
3
+ // from tui.tsx when the TUI entry was split: the host's Solid transform
4
+ // applies to EVERY plugin file outside node_modules (solid-plugin.js
5
+ // sourceFilter in @opentui/solid), so JSX in imported files is compiled the
6
+ // same way as in the entry.
7
+
8
+ import { usePlugin } from "@opencode/plugin/tui"
9
+ import { Show } from "solid-js"
10
+ import type { StateEntry } from "./tui-data.js"
11
+ import { fmt, num, updatedAt } from "./tui-data.js"
12
+ import { token } from "./tui-theme.js"
13
+
14
+ // Sidebar category-label column width (monospace), used by the `line()` helper.
15
+ const LABEL_WIDTH = 12
16
+
17
+ export function Sidebar(props: { entry: () => StateEntry | null }) {
18
+ const context = usePlugin()
19
+ const base = () => token(context?.theme?.text?.base, "white")
20
+ const muted = () => token(context?.theme?.text?.muted, base())
21
+
22
+ const e = () => props.entry()
23
+
24
+ const denom = (): number | null => {
25
+ const x = e()
26
+ const d = x?.usable ?? x?.limit
27
+ return typeof d === "number" && d > 0 ? d : null
28
+ }
29
+ const pct = (v: number): string => {
30
+ const d = denom()
31
+ return d ? `${Math.round((v / d) * 100)}%` : ""
32
+ }
33
+ const line = (label: string, v: unknown): string => {
34
+ const n = num(v)
35
+ const l = (label + " ".repeat(LABEL_WIDTH)).slice(0, LABEL_WIDTH)
36
+ const p = pct(n)
37
+ return `${l}${fmt(n).padStart(7)}${p ? ` ${p.padStart(4)}` : ""}`
38
+ }
39
+ const header = (): string => {
40
+ const x = e()
41
+ if (!x) return ""
42
+ const d = denom()
43
+ return `${x.model ?? "?"} · ${fmt(num(x.ctx))}${
44
+ d ? ` / ${fmt(d)}` : ""
45
+ }${d ? ` (${pct(num(x.ctx))})` : ""}`
46
+ }
47
+
48
+ return (
49
+ <box flexDirection="column">
50
+ <text fg={base()}>{"Context"}</text>
51
+ <Show when={e()} fallback={<text fg={muted()}>{" no data yet"}</text>}>
52
+ <text fg={base()}>{header()}</text>
53
+ <text fg={muted()}>{line("user", e()?.categories?.user)}</text>
54
+ <text fg={muted()}>{line("assistant", e()?.categories?.assistant)}</text>
55
+ <text fg={muted()}>{line("reasoning", e()?.categories?.reasoning)}</text>
56
+ <text fg={muted()}>{line("tool args", e()?.categories?.toolArgs)}</text>
57
+ <text fg={muted()}>
58
+ {e()?.categories?.system == null
59
+ ? `${"system".padEnd(LABEL_WIDTH)} n/a`
60
+ : line("system", e()?.categories?.system)}
61
+ </text>
62
+ <text fg={muted()}>
63
+ {e()?.categories?.toolSchemas == null
64
+ ? `${"tool schemas".padEnd(LABEL_WIDTH)} n/a`
65
+ : line("tool schemas", e()?.categories?.toolSchemas)}
66
+ </text>
67
+ <text fg={muted()}>{line("other", e()?.categories?.other)}</text>
68
+ <text fg={muted()}>{`updated ${updatedAt(e()?.updatedAt)}`}</text>
69
+ </Show>
70
+ </box>
71
+ )
72
+ }
@@ -0,0 +1,17 @@
1
+ // lib/tui-theme.js
2
+ //
3
+ // Theme-token helpers shared by the TUI components (sidebar + breakdown
4
+ // panel). Free of JSX and of solid-js imports so the file loads through any
5
+ // loader path.
6
+ //
7
+ // `Fg` mirrors OpenTUI's ColorInput (the type `fg` on <text> accepts); the
8
+ // JSDoc import below is type-only and erased at runtime.
9
+
10
+ /** @typedef {import("@opentui/core").ColorInput} Fg */
11
+
12
+ // Theme tokens are OpenTUI RGBA values (see ResolvedTheme.text.base/.muted);
13
+ // pass them straight to the renderer. Fall back to a named color when a token
14
+ // is absent so an unexpected theme shape can never blank the sidebar.
15
+ export function token(v, fallback) {
16
+ return v && typeof v === "object" ? v : fallback
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-context-indicator",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Real-time OpenCode context-window usage indicator: per-category token breakdown written to a log file, plus an optional TUI sidebar. Dual V1 + V2 plugin entry.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -56,19 +56,22 @@
56
56
  }
57
57
  },
58
58
  "dependencies": {
59
- "@opentui/solid": "0.5.14"
59
+ "@opentui/solid": "0.5.16"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@opencode/plugin": "^2.0.19",
63
- "@opentui/core": "0.5.14",
63
+ "@opentui/core": "0.5.16",
64
+ "@types/node": "26.6.3",
64
65
  "esbuild": "^0.28.2",
65
66
  "node-pty": "^1.1.0",
66
- "solid-js": "^1.9.12"
67
+ "solid-js": "^1.9.12",
68
+ "typescript": "5.9.3"
67
69
  },
68
70
  "scripts": {
69
71
  "test:prepublish": "node test/prepublish.mjs",
70
72
  "test:e2e": "node test/e2e-tui.mjs --full",
71
- "test:e2e:quick": "node test/e2e-tui.mjs --quick"
73
+ "test:e2e:quick": "node test/e2e-tui.mjs --quick",
74
+ "test:drift": "node test/drift-watch.mjs"
72
75
  },
73
76
  "prepublishOnly": "node test/prepublish.mjs && node test/e2e-tui.mjs --full"
74
77
  }