opencode-context-indicator 1.2.1 → 1.3.0

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,575 @@
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
+ const COST_WIDTH = 8
62
+
63
+ function panelLayout(panelWidth: number): {
64
+ roleW: number
65
+ modelW: number
66
+ ctxW: number
67
+ costW: number
68
+ updW: number
69
+ gutter: string
70
+ } {
71
+ const w = num(panelWidth) > 0 ? num(panelWidth) : 60
72
+ const ctxW = CTX_WIDTH
73
+ const costW = COST_WIDTH
74
+ const updW = Math.max(5, Math.min(8, w - ctxW - 4 * GUTTER - 24))
75
+ const roleW = Math.max(12, Math.min(20, Math.floor(w * 0.28)))
76
+ const modelW = Math.max(8, w - roleW - ctxW - costW - updW - 4 * GUTTER)
77
+ return { roleW, modelW, ctxW, costW, updW, gutter: " ".repeat(GUTTER) }
78
+ }
79
+
80
+ // Dollar formatting for the cost column: compact, always `$`-prefixed, `-`
81
+ // when the host data layer has no cost for the session.
82
+ function money(n: number | null): string {
83
+ if (n == null || !Number.isFinite(n)) return "-"
84
+ const a = Math.abs(n)
85
+ if (a >= 1000) return `$${(n / 1000).toFixed(1)}k`
86
+ if (a >= 100) return `$${n.toFixed(1)}`
87
+ return `$${n.toFixed(2)}`
88
+ }
89
+
90
+ // The topmost ancestor of the panel's session: `/cx` opened from a subagent
91
+ // session view must show the WHOLE family (rooted at the main session), not
92
+ // just the child subtree. Walks parentID upward over the snapshot; `seen`
93
+ // guards against parentID loops.
94
+ function resolveRoot(
95
+ snapshot: Record<string, StateEntry>,
96
+ sessionID: string,
97
+ ): string {
98
+ let current = sessionID
99
+ const seen = new Set<string>([current])
100
+ for (;;) {
101
+ const entry = Object.prototype.hasOwnProperty.call(snapshot, current)
102
+ ? snapshot[current]
103
+ : undefined
104
+ const parent = entry?.parentID
105
+ if (
106
+ !parent ||
107
+ typeof parent !== "string" ||
108
+ !Object.prototype.hasOwnProperty.call(snapshot, parent) ||
109
+ seen.has(parent)
110
+ ) {
111
+ break
112
+ }
113
+ seen.add(parent)
114
+ current = parent
115
+ }
116
+ return current
117
+ }
118
+
119
+ // Full session panel: header + one row per session in the family (root plus
120
+ // subagent descendants). It reads the same state.json bridge as the sidebar via
121
+ // the shared 1 s tick, so both surfaces refresh on ONE poll. The family is
122
+ // rooted at the panel session's TOPMOST ancestor, so opening the panel from a
123
+ // subagent view shows the whole family. `cx` opens the panel (registered from
124
+ // the "app" slot in tui.tsx; also reachable from the command palette, since
125
+ // subagent views have no prompt input).
126
+ export function BreakdownPanel(props: {
127
+ panel: PanelInput
128
+ snapshot: () => Record<string, StateEntry>
129
+ refresh: () => void
130
+ }) {
131
+ const context = usePlugin()
132
+ const base = () => token(context?.theme?.text?.base, "white")
133
+ const muted = () => token(context?.theme?.text?.muted, base())
134
+
135
+ // The host session data layer, re-read per call: individual accessors
136
+ // (family/get/status/cost) are optional across host versions and every use
137
+ // site guards them separately.
138
+ const dataApiSession = () => context?.data?.session
139
+
140
+ // Set while the picker dialog is on screen: the panel-local `s` key must not
141
+ // re-trigger the picker (a printable key typed into the dialog search would
142
+ // otherwise stack dialogs — the guard is belt-and-braces for hosts where the
143
+ // panel keeps focus while a dialog is open).
144
+ let pickerOpen = false
145
+
146
+ // Per-session cost from the host data layer (read under the shared poll tick,
147
+ // so it refreshes reactively). For a ROOT session the host AGGREGATES the
148
+ // whole family; for a child it is that session's own cost — exactly the
149
+ // per-subagent spend visibility the community asked for (upstream #45417).
150
+ // Guarded: older hosts may not expose the accessor.
151
+ const costOf = (sessionID: string | undefined): number | null => {
152
+ if (!sessionID) return null
153
+ try {
154
+ const cost = context?.data?.session?.cost?.(sessionID)
155
+ return typeof cost === "number" && Number.isFinite(cost) ? cost : null
156
+ } catch {
157
+ return null
158
+ }
159
+ }
160
+
161
+ // Panel-local keys. The layer is owned by this component and disposed with it.
162
+ // mode "global" + an explicit focused check is the portable fallback when no
163
+ // target renderable is available: the keys act only while the panel owns input.
164
+ // The capability guard keeps an older host (no keymap API) from throwing.
165
+ const keymap = context?.keymap
166
+ if (keymap && typeof keymap.layer === "function") {
167
+ keymap.layer(() => ({
168
+ mode: "global",
169
+ priority: -1,
170
+ commands: [
171
+ {
172
+ id: "context-indicator.breakdown.close",
173
+ title: "Close the context breakdown panel",
174
+ bind: "escape",
175
+ enabled: () => props.panel.focused,
176
+ run: () => {
177
+ if (props.panel.focused) props.panel.close()
178
+ },
179
+ },
180
+ {
181
+ id: "context-indicator.breakdown.refresh",
182
+ title: "Refresh the context breakdown panel",
183
+ bind: "r",
184
+ enabled: () => props.panel.focused,
185
+ run: () => {
186
+ if (props.panel.focused) props.refresh()
187
+ },
188
+ },
189
+ {
190
+ id: "context-indicator.breakdown.export",
191
+ title: "Export a redacted breakdown snapshot",
192
+ bind: "e",
193
+ enabled: () => props.panel.focused,
194
+ run: () => {
195
+ if (props.panel.focused) exportRedacted()
196
+ },
197
+ },
198
+ {
199
+ id: "context-indicator.breakdown.pick",
200
+ title: "Pick a family session",
201
+ bind: "s",
202
+ enabled: () => props.panel.focused && !pickerOpen,
203
+ run: () => {
204
+ if (props.panel.focused && !pickerOpen) openPicker()
205
+ },
206
+ },
207
+ ],
208
+ }))
209
+ }
210
+
211
+ // One snapshot read per poll tick (props.snapshot tracks the shared tick), so
212
+ // the memo recomputes together with the sidebar instead of polling separately.
213
+ // The family is rooted at the topmost ancestor (resolveRoot): from a subagent
214
+ // view the whole family is shown, not just the child subtree.
215
+ const data = createMemo(() => {
216
+ const snap = props.snapshot()
217
+ const rootID = resolveRoot(snap, props.panel.sessionID)
218
+ return {
219
+ root: snap[rootID],
220
+ rows: collectFamily(snap, rootID),
221
+ }
222
+ })
223
+
224
+ // Live message list from the TUI data layer (in-process, no LLM call). The
225
+ // shared `props.snapshot()` tick is read FIRST so this re-reads every poll.
226
+ // `available: false` means the API is absent/errored -> the WHOLE measured
227
+ // section is hidden (and a one-time hint was logged).
228
+ const messages = createMemo<{ available: boolean; list: unknown[] }>(() => {
229
+ props.snapshot()
230
+ try {
231
+ const api = context?.data?.session?.message
232
+ if (!api || typeof api.list !== "function") {
233
+ if (!warnedMessagesUnavailable) {
234
+ warnedMessagesUnavailable = true
235
+ console.error(
236
+ "[context-indicator] TUI message data API unavailable; " +
237
+ "measured contributors section hidden",
238
+ )
239
+ }
240
+ return { available: false, list: [] }
241
+ }
242
+ const v = api.list(props.panel.sessionID)
243
+ return { available: true, list: Array.isArray(v) ? v : [] }
244
+ } catch {
245
+ if (!warnedMessagesUnavailable) {
246
+ warnedMessagesUnavailable = true
247
+ console.error(
248
+ "[context-indicator] TUI message read failed; " +
249
+ "measured contributors section hidden",
250
+ )
251
+ }
252
+ return { available: false, list: [] }
253
+ }
254
+ })
255
+
256
+ const contributors = createMemo<Contributor[]>(() => {
257
+ const m = messages()
258
+ if (!m.available) return []
259
+ try {
260
+ return measureContributors(m.list)
261
+ } catch {
262
+ return []
263
+ }
264
+ })
265
+
266
+ // Redacted export (key `e`). No message text, no full session id, no tool
267
+ // output content ever leaves this function. Never throws: a failure logs and
268
+ // shows an error toast instead.
269
+ function exportRedacted(): void {
270
+ let exportedName: string | null = null
271
+ try {
272
+ const now = new Date()
273
+ const name = exportFileName(props.panel.sessionID, now)
274
+ const payload = buildRedactedPayload({
275
+ sessionID: props.panel.sessionID,
276
+ root: data().root,
277
+ rows: data().rows,
278
+ contributors: contributors(),
279
+ now,
280
+ panelDenom,
281
+ panelRole,
282
+ num,
283
+ })
284
+ const dir = join(tmpdir(), "opencode-context-indicator", "exports")
285
+ const file = join(dir, name)
286
+ mkdirSync(dir, { recursive: true })
287
+ writeFileSync(file, JSON.stringify(payload, null, 2), "utf8")
288
+ exportedName = basename(file)
289
+ } catch (err) {
290
+ console.error("[context-indicator] redacted export failed:", err)
291
+ try {
292
+ context?.ui?.toast?.show({
293
+ message: "redacted export failed",
294
+ variant: "error",
295
+ })
296
+ } catch {
297
+ /* toast is best-effort */
298
+ }
299
+ return
300
+ }
301
+ // Outside the write try: a toast failure must not report the export as
302
+ // failed when the file is already on disk.
303
+ try {
304
+ context?.ui?.toast?.show({
305
+ message: `exported ${exportedName}`,
306
+ variant: "success",
307
+ })
308
+ } catch {
309
+ /* toast is best-effort */
310
+ }
311
+ }
312
+
313
+ // Navigate to a family session: root sessions focus their tab (tab-aware,
314
+ // opens one when needed); child sessions navigate the route directly (tabs
315
+ // are root-session scoped). Falls back with a toast when the host exposes
316
+ // neither mechanism.
317
+ function navigateTo(sessionID: string): void {
318
+ try {
319
+ const ui = context?.ui
320
+ // Child detection is three-sourced: the host session info, OUR snapshot
321
+ // (state.json parentID), and the host root() accessor — get() alone can
322
+ // miss a session that has not been synced into the client store yet, and
323
+ // tabs.focus() on a child ID would target the wrong (root) tab.
324
+ let isChild = false
325
+ try {
326
+ const info = context?.data?.session?.get?.(sessionID)
327
+ isChild = !!(
328
+ info &&
329
+ typeof info === "object" &&
330
+ (info as Record<string, unknown>).parentID
331
+ )
332
+ } catch {
333
+ /* other sources below */
334
+ }
335
+ if (!isChild) {
336
+ const snapParent = props.snapshot()[sessionID]?.parentID
337
+ if (typeof snapParent === "string" && snapParent.length > 0) isChild = true
338
+ }
339
+ if (!isChild && typeof context?.data?.session?.root === "function") {
340
+ try {
341
+ if (context.data.session.root(sessionID) !== sessionID) isChild = true
342
+ } catch {
343
+ /* treat as root */
344
+ }
345
+ }
346
+ if (!isChild && ui?.tabs && typeof ui.tabs.focus === "function") {
347
+ if (ui.tabs.focus(sessionID)) return
348
+ }
349
+ if (ui?.router && typeof ui.router.navigate === "function") {
350
+ ui.router.navigate({ type: "session", sessionID })
351
+ return
352
+ }
353
+ try {
354
+ context?.ui?.toast?.show({
355
+ message: "session navigation not supported by this host",
356
+ variant: "warning",
357
+ })
358
+ } catch {
359
+ /* toast is best-effort */
360
+ }
361
+ } catch (err) {
362
+ console.error("[context-indicator] session navigation failed:", err)
363
+ }
364
+ }
365
+
366
+ // Family-session picker (key `s`): a select dialog over the host data layer's
367
+ // family rooted at the TOPMOST ancestor (matches the table; authoritative —
368
+ // includes registered sessions that never served a step; falls back to our
369
+ // state.json walk), searchable by title/model, each option carrying its own
370
+ // cost and a running marker. Enter navigates.
371
+ function openPicker(): void {
372
+ if (pickerOpen) return
373
+ const dialog = context?.ui?.dialog
374
+ if (!dialog || typeof dialog.select !== "function") {
375
+ try {
376
+ context?.ui?.toast?.show({
377
+ message: "session picker requires opencode 2.0.23+",
378
+ variant: "warning",
379
+ })
380
+ } catch {
381
+ /* toast is best-effort */
382
+ }
383
+ return
384
+ }
385
+
386
+ const rootID = resolveRoot(props.snapshot(), props.panel.sessionID)
387
+ let ids: string[] = []
388
+ try {
389
+ const family = dataApiSession()?.family?.(rootID)
390
+ if (Array.isArray(family)) ids = family.filter((id) => typeof id === "string")
391
+ } catch {
392
+ ids = []
393
+ }
394
+ if (ids.length === 0) {
395
+ // Host family unavailable: fall back to our state.json family walk.
396
+ ids = collectFamily(props.snapshot(), rootID)
397
+ .map((e) => e.sessionID)
398
+ .filter((id): id is string => typeof id === "string" && id.length > 0)
399
+ }
400
+
401
+ const labels = new Map<string, string>()
402
+ const options = ids.map((id) => {
403
+ let title = "(untitled)"
404
+ let model = ""
405
+ let running = false
406
+ try {
407
+ const info = dataApiSession()?.get?.(id)
408
+ if (info && typeof info === "object") {
409
+ const raw = info as Record<string, unknown>
410
+ title = safeText(raw.title) || title
411
+ const m = raw.model as { modelID?: unknown } | string | undefined
412
+ model =
413
+ typeof m === "string"
414
+ ? safeText(m)
415
+ : m && typeof m.modelID === "string"
416
+ ? safeText(m.modelID)
417
+ : ""
418
+ running = dataApiSession()?.status?.(id) === "running"
419
+ }
420
+ } catch {
421
+ /* defensive: host data layers may lack get/status */
422
+ }
423
+ if (!model) {
424
+ const fromSnapshot = props.snapshot()[id]?.model
425
+ if (typeof fromSnapshot === "string" && fromSnapshot !== "unknown") model = safeText(fromSnapshot)
426
+ }
427
+ const parts = [model, title, money(costOf(id))]
428
+ if (running) parts.push("running")
429
+ const label = parts.filter((p) => p.length > 0).join(" · ")
430
+ labels.set(id, label)
431
+ return { title: label, value: id }
432
+ })
433
+
434
+ if (options.length <= 1) {
435
+ try {
436
+ context?.ui?.toast?.show({
437
+ message: "no other family sessions",
438
+ variant: "info",
439
+ })
440
+ } catch {
441
+ /* toast is best-effort */
442
+ }
443
+ return
444
+ }
445
+
446
+ pickerOpen = true
447
+ try {
448
+ void dialog
449
+ .select({
450
+ title: "Family sessions",
451
+ options,
452
+ search: (query, opts) => {
453
+ const q = query.trim().toLowerCase()
454
+ if (!q) return opts
455
+ return opts.filter((o) => (labels.get(o.value) ?? o.title).toLowerCase().includes(q))
456
+ },
457
+ })
458
+ .then((picked) => {
459
+ pickerOpen = false
460
+ if (typeof picked === "string" && picked.length > 0) navigateTo(picked)
461
+ })
462
+ .catch(() => {
463
+ pickerOpen = false
464
+ /* dialog settle races are host-managed; nothing to recover */
465
+ })
466
+ } catch (err) {
467
+ pickerOpen = false
468
+ console.error("[context-indicator] session picker failed:", err)
469
+ }
470
+ }
471
+
472
+ const header = (): string => {
473
+ const r = data().root
474
+ if (!r) return "Context breakdown"
475
+ const model = safeText(r.model && r.model !== "unknown" ? r.model : "model?")
476
+ const ctx = fmt(num(r.ctx))
477
+ const d = panelDenom(r)
478
+ return d > 0
479
+ ? `Context breakdown \u2014 ${model} ${ctx}/${fmt(d)} (${Math.round(
480
+ (num(r.ctx) / d) * 100,
481
+ )}%)`
482
+ : `Context breakdown \u2014 ${model} ${ctx}`
483
+ }
484
+
485
+ const layout = (): {
486
+ roleW: number
487
+ modelW: number
488
+ ctxW: number
489
+ costW: number
490
+ updW: number
491
+ gutter: string
492
+ } => panelLayout(props.panel.width)
493
+
494
+ const columns = (): string => {
495
+ const L = layout()
496
+ return (
497
+ fit("role", L.roleW) +
498
+ L.gutter +
499
+ fit("model", L.modelW) +
500
+ L.gutter +
501
+ fit("ctx", L.ctxW) +
502
+ L.gutter +
503
+ fit("cost", L.costW) +
504
+ L.gutter +
505
+ "updated"
506
+ )
507
+ }
508
+
509
+ const row = (e: StateEntry, isMain: boolean): string => {
510
+ const model = safeText(e.model && e.model !== "unknown" ? e.model : "model?")
511
+ const d = panelDenom(e)
512
+ const ctx =
513
+ d > 0
514
+ ? `${fmt(num(e.ctx))} (${Math.round((num(e.ctx) / d) * 100)}%)`
515
+ : fmt(num(e.ctx))
516
+ const L = layout()
517
+ return (
518
+ fit(panelRole(e, isMain), L.roleW) +
519
+ L.gutter +
520
+ fit(model, L.modelW) +
521
+ L.gutter +
522
+ fit(ctx, L.ctxW) +
523
+ L.gutter +
524
+ fit(money(costOf(e.sessionID)), L.costW) +
525
+ L.gutter +
526
+ updatedAt(e.updatedAt)
527
+ )
528
+ }
529
+
530
+ return (
531
+ <box flexDirection="column">
532
+ <text fg={base()}>{header()}</text>
533
+ <Show
534
+ when={data().root}
535
+ fallback={<text fg={muted()}>{" no data yet"}</text>}
536
+ >
537
+ <text fg={muted()}>{columns()}</text>
538
+ {data().rows.map((e, i) => (
539
+ <text fg={muted()}>{row(e, i === 0)}</text>
540
+ ))}
541
+ <Show
542
+ when={
543
+ (data().root?.usable ?? 0) > 0
544
+ }
545
+ >
546
+ <text fg={muted()}>
547
+ {" compact at " +
548
+ fmt(num(data().root?.usable)) +
549
+ (num(data().root?.reserve) > 0
550
+ ? " (" + fmt(num(data().root?.reserve)) + " reserve)"
551
+ : "")}
552
+ </text>
553
+ </Show>
554
+ <Show when={messages().available}>
555
+ <text fg={base()}>{"largest contributors (measured)"}</text>
556
+ <Show
557
+ when={contributors().length > 0}
558
+ fallback={<text fg={muted()}>{" no messages yet"}</text>}
559
+ >
560
+ {contributors()
561
+ .slice(0, 5)
562
+ .map((c, i) => (
563
+ <text fg={muted()}>{contributorLine(c, i + 1)}</text>
564
+ ))}
565
+ </Show>
566
+ <text fg={muted()}>
567
+ {
568
+ "measured != server categories (tool outputs counted here; server categories exclude them)"
569
+ }
570
+ </text>
571
+ </Show>
572
+ </Show>
573
+ </box>
574
+ )
575
+ }
@@ -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
+ }