opencode-context-indicator 1.7.0 → 1.8.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.
@@ -1,713 +1,779 @@
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, toolOutEntries, 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
- // Cheap change detector for the live message list. The host hands back a fresh
57
- // array on every poll, so identity cannot be used as a memo key, while walking
58
- // the whole list (per-character estimates + JSON.stringify of tool args) once
59
- // per 1000 ms tick is exactly the cost we want to avoid. Length + last message
60
- // id + created stamp + last message payload size covers appends, streaming and
61
- // tool completion; an in-place edit of an OLDER message lands one tick later,
62
- // when the tail changes. Known blind spot by design: the size is a SUM, so a
63
- // same-length substitution inside the last message is not detected.
64
- function listSignature(list: unknown[]): string {
65
- const n = list.length
66
- if (n === 0) return "0"
67
- const last: unknown = list[n - 1]
68
- if (last == null || typeof last !== "object") return `${n}:?`
69
- const rec = last as Record<string, unknown>
70
- const id = typeof rec.id === "string" ? rec.id : ""
71
- const time = rec.time
72
- const created =
73
- time != null && typeof time === "object"
74
- ? String((time as Record<string, unknown>).created ?? "")
75
- : ""
76
- let size = typeof rec.text === "string" ? rec.text.length : 0
77
- const parts: unknown[] = Array.isArray(rec.content) ? rec.content : []
78
- for (const raw of parts) {
79
- if (raw == null || typeof raw !== "object") continue
80
- const part = raw as Record<string, unknown>
81
- if (typeof part.text === "string") {
82
- size += part.text.length
83
- continue
84
- }
85
- const state = part.state
86
- if (state == null || typeof state !== "object") continue
87
- const st = state as Record<string, unknown>
88
- if (typeof st.input === "string") size += st.input.length
89
- else if (st.input != null) {
90
- try {
91
- size += JSON.stringify(st.input).length
92
- } catch {
93
- /* unstringifiable input contributes no size */
94
- }
95
- }
96
- // Tool RESULTS matter as much as args: measureContributors reads
97
- // state.content, and a call that finishes between two ticks changes nothing
98
- // else on the message. Without this the measured section would freeze for
99
- // the whole remainder of the turn.
100
- const content = st.content
101
- if (typeof content === "string") {
102
- size += content.length
103
- } else if (Array.isArray(content)) {
104
- for (const rawItem of content) {
105
- if (rawItem == null || typeof rawItem !== "object") continue
106
- const item = rawItem as Record<string, unknown>
107
- if (typeof item.text === "string") {
108
- size += item.text.length
109
- } else {
110
- // Non-text parts (e.g. file refs with long/base64 URIs) are not
111
- // measured by measureContributors, so their marker is enough: never
112
- // JSON.stringify a potentially megabyte-sized reference per tick.
113
- size += 1
114
- }
115
- }
116
- }
117
- // Status transitions (pending -> completed/error) are cheap and always
118
- // accompany a visible change.
119
- if (typeof st.status === "string") size += st.status.length
120
- }
121
- return `${n}:${id}:${created}:${size}`
122
- }
123
-
124
- // Panel column layout (monospace), derived from the host-provided panel width
125
- // so cells never collide on narrow terminals. Gaps between columns keep a
126
- // truncated cell visually separated from its neighbour.
127
- const GUTTER = 2
128
- const CTX_WIDTH = 12
129
- const COST_WIDTH = 8
130
-
131
- function panelLayout(panelWidth: number): {
132
- roleW: number
133
- modelW: number
134
- ctxW: number
135
- costW: number
136
- updW: number
137
- gutter: string
138
- } {
139
- const w = num(panelWidth) > 0 ? num(panelWidth) : 60
140
- const ctxW = CTX_WIDTH
141
- const costW = COST_WIDTH
142
- const updW = Math.max(5, Math.min(8, w - ctxW - 4 * GUTTER - 24))
143
- const roleW = Math.max(12, Math.min(20, Math.floor(w * 0.28)))
144
- const modelW = Math.max(8, w - roleW - ctxW - costW - updW - 4 * GUTTER)
145
- return { roleW, modelW, ctxW, costW, updW, gutter: " ".repeat(GUTTER) }
146
- }
147
-
148
- // Dollar formatting for the cost column: compact, always `$`-prefixed, `-`
149
- // when the host data layer has no cost for the session.
150
- function money(n: number | null): string {
151
- if (n == null || !Number.isFinite(n)) return "-"
152
- const a = Math.abs(n)
153
- if (a >= 1000) return `$${(n / 1000).toFixed(1)}k`
154
- if (a >= 100) return `$${n.toFixed(1)}`
155
- return `$${n.toFixed(2)}`
156
- }
157
-
158
- // The topmost ancestor of the panel's session: `/cx` opened from a subagent
159
- // session view must show the WHOLE family (rooted at the main session), not
160
- // just the child subtree. Walks parentID upward over the snapshot; `seen`
161
- // guards against parentID loops.
162
- function resolveRoot(
163
- snapshot: Record<string, StateEntry>,
164
- sessionID: string,
165
- ): string {
166
- let current = sessionID
167
- const seen = new Set<string>([current])
168
- for (;;) {
169
- const entry = Object.prototype.hasOwnProperty.call(snapshot, current)
170
- ? snapshot[current]
171
- : undefined
172
- const parent = entry?.parentID
173
- if (
174
- !parent ||
175
- typeof parent !== "string" ||
176
- !Object.prototype.hasOwnProperty.call(snapshot, parent) ||
177
- seen.has(parent)
178
- ) {
179
- break
180
- }
181
- seen.add(parent)
182
- current = parent
183
- }
184
- return current
185
- }
186
-
187
- // Full session panel: header + one row per session in the family (root plus
188
- // subagent descendants). It reads the same state.json bridge as the sidebar via
189
- // the shared 1 s tick, so both surfaces refresh on ONE poll. The family is
190
- // rooted at the panel session's TOPMOST ancestor, so opening the panel from a
191
- // subagent view shows the whole family. `cx` opens the panel (registered from
192
- // the "app" slot in tui.tsx; also reachable from the command palette, since
193
- // subagent views have no prompt input).
194
- export function BreakdownPanel(props: {
195
- panel: PanelInput
196
- snapshot: () => Record<string, StateEntry>
197
- refresh: () => void
198
- }) {
199
- const context = usePlugin()
200
- const base = () => token(context?.theme?.text?.base, "white")
201
- const muted = () => token(context?.theme?.text?.muted, base())
202
-
203
- // The host session data layer, re-read per call: individual accessors
204
- // (family/get/status/cost) are optional across host versions and every use
205
- // site guards them separately.
206
- const dataApiSession = () => context?.data?.session
207
-
208
- // Set while the picker dialog is on screen: the panel-local `s` key must not
209
- // re-trigger the picker (a printable key typed into the dialog search would
210
- // otherwise stack dialogs — the guard is belt-and-braces for hosts where the
211
- // panel keeps focus while a dialog is open).
212
- let pickerOpen = false
213
-
214
- // Per-session cost from the host data layer (read under the shared poll tick,
215
- // so it refreshes reactively). For a ROOT session the host AGGREGATES the
216
- // whole family; for a child it is that session's own cost — exactly the
217
- // per-subagent spend visibility the community asked for (upstream #45417).
218
- // Guarded: older hosts may not expose the accessor.
219
- const costOf = (sessionID: string | undefined): number | null => {
220
- if (!sessionID) return null
221
- try {
222
- const cost = context?.data?.session?.cost?.(sessionID)
223
- return typeof cost === "number" && Number.isFinite(cost) ? cost : null
224
- } catch {
225
- return null
226
- }
227
- }
228
-
229
- // Panel-local keys. The layer is owned by this component and disposed with it.
230
- // mode "global" + an explicit focused check is the portable fallback when no
231
- // target renderable is available: the keys act only while the panel owns input.
232
- // The capability guard keeps an older host (no keymap API) from throwing.
233
- const keymap = context?.keymap
234
- if (keymap && typeof keymap.layer === "function") {
235
- keymap.layer(() => ({
236
- mode: "global",
237
- priority: -1,
238
- commands: [
239
- {
240
- id: "context-indicator.breakdown.close",
241
- title: "Close the context breakdown panel",
242
- bind: "escape",
243
- enabled: () => props.panel.focused,
244
- run: () => {
245
- if (props.panel.focused) props.panel.close()
246
- },
247
- },
248
- {
249
- id: "context-indicator.breakdown.refresh",
250
- title: "Refresh the context breakdown panel",
251
- bind: "r",
252
- enabled: () => props.panel.focused,
253
- run: () => {
254
- if (props.panel.focused) props.refresh()
255
- },
256
- },
257
- {
258
- id: "context-indicator.breakdown.export",
259
- title: "Export a redacted breakdown snapshot",
260
- bind: "e",
261
- enabled: () => props.panel.focused,
262
- run: () => {
263
- if (props.panel.focused) exportRedacted()
264
- },
265
- },
266
- {
267
- id: "context-indicator.breakdown.pick",
268
- title: "Pick a family session",
269
- bind: "s",
270
- enabled: () => props.panel.focused && !pickerOpen,
271
- run: () => {
272
- if (props.panel.focused && !pickerOpen) openPicker()
273
- },
274
- },
275
- ],
276
- }))
277
- }
278
-
279
- // One snapshot read per poll tick (props.snapshot tracks the shared tick), so
280
- // the memo recomputes together with the sidebar instead of polling separately.
281
- // The family is rooted at the topmost ancestor (resolveRoot): from a subagent
282
- // view the whole family is shown, not just the child subtree.
283
- const data = createMemo(() => {
284
- const snap = props.snapshot()
285
- const rootID = resolveRoot(snap, props.panel.sessionID)
286
- return {
287
- root: snap[rootID],
288
- rows: collectFamily(snap, rootID),
289
- }
290
- })
291
-
292
- // Live message list from the TUI data layer (in-process, no LLM call). The
293
- // shared `props.snapshot()` tick is read FIRST so this re-reads every poll.
294
- // `available: false` means the API is absent/errored -> the WHOLE measured
295
- // section is hidden (and a one-time hint was logged).
296
- const messages = createMemo<{ available: boolean; list: unknown[] }>(() => {
297
- props.snapshot()
298
- try {
299
- const api = context?.data?.session?.message
300
- if (!api || typeof api.list !== "function") {
301
- if (!warnedMessagesUnavailable) {
302
- warnedMessagesUnavailable = true
303
- console.error(
304
- "[context-indicator] TUI message data API unavailable; " +
305
- "measured contributors section hidden",
306
- )
307
- }
308
- return { available: false, list: [] }
309
- }
310
- const v = api.list(props.panel.sessionID)
311
- return { available: true, list: Array.isArray(v) ? v : [] }
312
- } catch {
313
- if (!warnedMessagesUnavailable) {
314
- warnedMessagesUnavailable = true
315
- console.error(
316
- "[context-indicator] TUI message read failed; " +
317
- "measured contributors section hidden",
318
- )
319
- }
320
- return { available: false, list: [] }
321
- }
322
- })
323
-
324
- // Memo for the measured contributors section: the signature/key pair keeps
325
- // the expensive walk out of the per-tick path (see listSignature).
326
- let contributorKey: string | null = null
327
- let contributorValue: Contributor[] = []
328
-
329
- const contributors = createMemo<Contributor[]>(() => {
330
- const m = messages()
331
- if (!m.available) {
332
- contributorKey = null
333
- contributorValue = []
334
- return contributorValue
335
- }
336
- // Re-measure ONLY when the message list actually changed. The host rebuilds
337
- // the array (and `props.snapshot()` ticks) every second, so keying on array
338
- // identity would re-walk every message — per-character token estimates plus
339
- // JSON.stringify of tool args — once per tick on the render thread.
340
- let key: string
341
- try {
342
- key = listSignature(m.list)
343
- } catch {
344
- key = ""
345
- }
346
- if (key && key === contributorKey) return contributorValue
347
- try {
348
- contributorValue = measureContributors(m.list)
349
- contributorKey = key || contributorKey
350
- } catch {
351
- // Transient failure: leave the key unset so the next tick retries.
352
- contributorValue = []
353
- contributorKey = null
354
- }
355
- return contributorValue
356
- })
357
-
358
- // Redacted export (key `e`). No message text, no full session id, no tool
359
- // output content ever leaves this function. Never throws: a failure logs and
360
- // shows an error toast instead.
361
- function exportRedacted(): void {
362
- let exportedName: string | null = null
363
- try {
364
- const now = new Date()
365
- const name = exportFileName(props.panel.sessionID, now)
366
- const payload = buildRedactedPayload({
367
- sessionID: props.panel.sessionID,
368
- root: data().root,
369
- rows: data().rows,
370
- contributors: contributors(),
371
- now,
372
- panelDenom,
373
- panelRole,
374
- num,
375
- })
376
- const dir = join(tmpdir(), "opencode-context-indicator", "exports")
377
- const file = join(dir, name)
378
- mkdirSync(dir, { recursive: true })
379
- writeFileSync(file, JSON.stringify(payload, null, 2), "utf8")
380
- exportedName = basename(file)
381
- } catch (err) {
382
- console.error("[context-indicator] redacted export failed:", err)
383
- try {
384
- context?.ui?.toast?.show({
385
- message: "redacted export failed",
386
- variant: "error",
387
- })
388
- } catch {
389
- /* toast is best-effort */
390
- }
391
- return
392
- }
393
- // Outside the write try: a toast failure must not report the export as
394
- // failed when the file is already on disk.
395
- try {
396
- context?.ui?.toast?.show({
397
- message: `exported ${exportedName}`,
398
- variant: "success",
399
- })
400
- } catch {
401
- /* toast is best-effort */
402
- }
403
- }
404
-
405
- // Navigate to a family session: root sessions focus their tab (tab-aware,
406
- // opens one when needed); child sessions navigate the route directly (tabs
407
- // are root-session scoped). Falls back with a toast when the host exposes
408
- // neither mechanism.
409
- function navigateTo(sessionID: string): void {
410
- try {
411
- const ui = context?.ui
412
- // Child detection is three-sourced: the host session info, OUR snapshot
413
- // (state.json parentID), and the host root() accessor — get() alone can
414
- // miss a session that has not been synced into the client store yet, and
415
- // tabs.focus() on a child ID would target the wrong (root) tab.
416
- let isChild = false
417
- try {
418
- const info = context?.data?.session?.get?.(sessionID)
419
- isChild = !!(
420
- info &&
421
- typeof info === "object" &&
422
- (info as Record<string, unknown>).parentID
423
- )
424
- } catch {
425
- /* other sources below */
426
- }
427
- if (!isChild) {
428
- const snapParent = props.snapshot()[sessionID]?.parentID
429
- if (typeof snapParent === "string" && snapParent.length > 0) isChild = true
430
- }
431
- if (!isChild && typeof context?.data?.session?.root === "function") {
432
- try {
433
- if (context.data.session.root(sessionID) !== sessionID) isChild = true
434
- } catch {
435
- /* treat as root */
436
- }
437
- }
438
- if (!isChild && ui?.tabs && typeof ui.tabs.focus === "function") {
439
- if (ui.tabs.focus(sessionID)) return
440
- }
441
- if (ui?.router && typeof ui.router.navigate === "function") {
442
- ui.router.navigate({ type: "session", sessionID })
443
- return
444
- }
445
- try {
446
- context?.ui?.toast?.show({
447
- message: "session navigation not supported by this host",
448
- variant: "warning",
449
- })
450
- } catch {
451
- /* toast is best-effort */
452
- }
453
- } catch (err) {
454
- console.error("[context-indicator] session navigation failed:", err)
455
- }
456
- }
457
-
458
- // Family-session picker (key `s`): a select dialog over the host data layer's
459
- // family rooted at the TOPMOST ancestor (matches the table; authoritative —
460
- // includes registered sessions that never served a step; falls back to our
461
- // state.json walk), searchable by title/model, each option carrying its own
462
- // cost and a running marker. Enter navigates.
463
- function openPicker(): void {
464
- if (pickerOpen) return
465
- const dialog = context?.ui?.dialog
466
- if (!dialog || typeof dialog.select !== "function") {
467
- try {
468
- context?.ui?.toast?.show({
469
- message: "session picker requires opencode 2.0.23+",
470
- variant: "warning",
471
- })
472
- } catch {
473
- /* toast is best-effort */
474
- }
475
- return
476
- }
477
-
478
- const rootID = resolveRoot(props.snapshot(), props.panel.sessionID)
479
- let ids: string[] = []
480
- try {
481
- const family = dataApiSession()?.family?.(rootID)
482
- if (Array.isArray(family)) ids = family.filter((id) => typeof id === "string")
483
- } catch {
484
- ids = []
485
- }
486
- if (ids.length === 0) {
487
- // Host family unavailable: fall back to our state.json family walk.
488
- ids = collectFamily(props.snapshot(), rootID)
489
- .map((e) => e.sessionID)
490
- .filter((id): id is string => typeof id === "string" && id.length > 0)
491
- }
492
-
493
- const labels = new Map<string, string>()
494
- const options = ids.map((id) => {
495
- let title = "(untitled)"
496
- let model = ""
497
- let running = false
498
- try {
499
- const info = dataApiSession()?.get?.(id)
500
- if (info && typeof info === "object") {
501
- const raw = info as Record<string, unknown>
502
- title = safeText(raw.title) || title
503
- const m = raw.model as { modelID?: unknown } | string | undefined
504
- model =
505
- typeof m === "string"
506
- ? safeText(m)
507
- : m && typeof m.modelID === "string"
508
- ? safeText(m.modelID)
509
- : ""
510
- running = dataApiSession()?.status?.(id) === "running"
511
- }
512
- } catch {
513
- /* defensive: host data layers may lack get/status */
514
- }
515
- if (!model) {
516
- const fromSnapshot = props.snapshot()[id]?.model
517
- if (typeof fromSnapshot === "string" && fromSnapshot !== "unknown") model = safeText(fromSnapshot)
518
- }
519
- const parts = [model, title, money(costOf(id))]
520
- if (running) parts.push("running")
521
- const label = parts.filter((p) => p.length > 0).join(" · ")
522
- labels.set(id, label)
523
- return { title: label, value: id }
524
- })
525
-
526
- if (options.length <= 1) {
527
- try {
528
- context?.ui?.toast?.show({
529
- message: "no other family sessions",
530
- variant: "info",
531
- })
532
- } catch {
533
- /* toast is best-effort */
534
- }
535
- return
536
- }
537
-
538
- pickerOpen = true
539
- try {
540
- void dialog
541
- .select({
542
- title: "Family sessions",
543
- options,
544
- search: (query, opts) => {
545
- const q = query.trim().toLowerCase()
546
- if (!q) return opts
547
- return opts.filter((o) => (labels.get(o.value) ?? o.title).toLowerCase().includes(q))
548
- },
549
- })
550
- .then((picked) => {
551
- pickerOpen = false
552
- if (typeof picked === "string" && picked.length > 0) navigateTo(picked)
553
- })
554
- .catch(() => {
555
- pickerOpen = false
556
- /* dialog settle races are host-managed; nothing to recover */
557
- })
558
- } catch (err) {
559
- pickerOpen = false
560
- console.error("[context-indicator] session picker failed:", err)
561
- }
562
- }
563
-
564
- const header = (): string => {
565
- const r = data().root
566
- if (!r) return "Context breakdown"
567
- const model = safeText(r.model && r.model !== "unknown" ? r.model : "model?")
568
- const ctx = fmt(num(r.ctx))
569
- const d = panelDenom(r)
570
- const base =
571
- d > 0
572
- ? `Context breakdown \u2014 ${model} ${ctx}/${fmt(d)} (${Math.round(
573
- (num(r.ctx) / d) * 100,
574
- )}%)`
575
- : `Context breakdown \u2014 ${model} ${ctx}`
576
- return r.compactStale === true ? `${base} (stale — compacted)` : base
577
- }
578
-
579
- const layout = (): {
580
- roleW: number
581
- modelW: number
582
- ctxW: number
583
- costW: number
584
- updW: number
585
- gutter: string
586
- } => panelLayout(props.panel.width)
587
-
588
- const columns = (): string => {
589
- const L = layout()
590
- return (
591
- fit("role", L.roleW) +
592
- L.gutter +
593
- fit("model", L.modelW) +
594
- L.gutter +
595
- fit("ctx", L.ctxW) +
596
- L.gutter +
597
- fit("cost", L.costW) +
598
- L.gutter +
599
- "updated"
600
- )
601
- }
602
-
603
- const row = (e: StateEntry, isMain: boolean): string => {
604
- const model = safeText(e.model && e.model !== "unknown" ? e.model : "model?")
605
- const d = panelDenom(e)
606
- const ctx =
607
- d > 0
608
- ? `${fmt(num(e.ctx))} (${Math.round((num(e.ctx) / d) * 100)}%)`
609
- : fmt(num(e.ctx))
610
- const L = layout()
611
- return (
612
- fit(panelRole(e, isMain), L.roleW) +
613
- L.gutter +
614
- fit(model, L.modelW) +
615
- L.gutter +
616
- fit(ctx, L.ctxW) +
617
- L.gutter +
618
- fit(money(costOf(e.sessionID)), L.costW) +
619
- L.gutter +
620
- updatedAt(e.updatedAt) +
621
- (e.compactStale === true ? " (stale)" : "")
622
- )
623
- }
624
-
625
- // One compact line: the main session's tool results split by tool name
626
- // (server-side aggregate; the per-call "largest contributors" block above is
627
- // the measured, client-side view).
628
- const byToolLine = (): string => {
629
- const all = data().root?.toolOutByTool
630
- const entries = toolOutEntries(all, 5)
631
- if (entries.length === 0) return ""
632
- const total =
633
- all && typeof all === "object"
634
- ? Object.values(all).filter((v) => num(v) > 0).length
635
- : 0
636
- const parts = entries.map(([name, val]) => `${safeText(name)} ${fmt(val)}`)
637
- if (total > entries.length) parts.push(`+${total - entries.length} more`)
638
- return parts.join(" \u00b7 ")
639
- }
640
-
641
- return (
642
- <box flexDirection="column">
643
- <text fg={base()}>{header()}</text>
644
- <Show
645
- when={data().root}
646
- fallback={<text fg={muted()}>{" no data yet"}</text>}
647
- >
648
- <text fg={muted()}>{columns()}</text>
649
- {data().rows.map((e, i) => (
650
- <text fg={muted()}>{row(e, i === 0)}</text>
651
- ))}
652
- <Show
653
- when={
654
- (data().root?.usable ?? 0) > 0
655
- }
656
- >
657
- <text fg={muted()}>
658
- {" compact at " +
659
- fmt(num(data().root?.usable)) +
660
- (num(data().root?.reserve) > 0
661
- ? " (" + fmt(num(data().root?.reserve)) + " reserve)"
662
- : "")}
663
- </text>
664
- </Show>
665
- <Show
666
- when={
667
- num(data().root?.cacheRead) > 0 ||
668
- num(data().root?.cacheWrite) > 0
669
- }
670
- >
671
- <text fg={muted()}>
672
- {" prompt cache r " +
673
- fmt(num(data().root?.cacheRead)) +
674
- " / w " +
675
- fmt(num(data().root?.cacheWrite)) +
676
- " (provider-reported)"}
677
- </text>
678
- </Show>
679
- <Show when={num(data().root?.reclaim?.tokens) > 0}>
680
- <text fg={muted()}>
681
- {" reclaim ~" +
682
- fmt(num(data().root?.reclaim?.tokens)) +
683
- " redundant tool output (" +
684
- num(data().root?.reclaim?.count) +
685
- " dup)"}
686
- </text>
687
- </Show>
688
- <Show when={messages().available}>
689
- <text fg={base()}>{"largest contributors (measured)"}</text>
690
- <Show
691
- when={contributors().length > 0}
692
- fallback={<text fg={muted()}>{" no messages yet"}</text>}
693
- >
694
- {contributors()
695
- .slice(0, 5)
696
- .map((c, i) => (
697
- <text fg={muted()}>{contributorLine(c, i + 1)}</text>
698
- ))}
699
- </Show>
700
- <text fg={muted()}>
701
- {
702
- "measured != server categories: contributors attribute outputs per tool call; the server's toolOut category only totals them"
703
- }
704
- </text>
705
- </Show>
706
- <Show when={byToolLine().length > 0}>
707
- <text fg={base()}>{"tool results by tool"}</text>
708
- <text fg={muted()}>{byToolLine()}</text>
709
- </Show>
710
- </Show>
711
- </box>
712
- )
713
- }
1
+ // lib/tui-panel.ts — the `session.panel` component (BreakdownPanel).
2
+ //
3
+ // No JSX and no `@jsxImportSource` pragma anywhere in this file: see the long
4
+ // explanation at the top of lib/tui-h.ts. Every dynamic prop is a getter, which
5
+ // is exactly what the Solid compiler would have emitted for `prop={expr}`.
6
+
7
+ import { usePlugin } from "@opencode/plugin/tui"
8
+ import { createMemo, Show } from "solid-js"
9
+ import { mkdirSync, writeFileSync } from "node:fs"
10
+ import { tmpdir } from "node:os"
11
+ import { basename, join } from "node:path"
12
+ import type { StateEntry } from "./tui-data.js"
13
+ import { collectFamily, fit, fmt, num, toolOutEntries, updatedAt } from "./tui-data.js"
14
+ import { token } from "./tui-theme.js"
15
+ import { c as comp, h } from "./tui-h.ts"
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
+ // Cheap change detector for the live message list. The host hands back a fresh
57
+ // array on every poll, so identity cannot be used as a memo key, while walking
58
+ // the whole list (per-character estimates + JSON.stringify of tool args) once
59
+ // per 1000 ms tick is exactly the cost we want to avoid. Length + last message
60
+ // id + created stamp + last message payload size covers appends, streaming and
61
+ // tool completion; an in-place edit of an OLDER message lands one tick later,
62
+ // when the tail changes. Known blind spot by design: the size is a SUM, so a
63
+ // same-length substitution inside the last message is not detected.
64
+ function listSignature(list: unknown[]): string {
65
+ const n = list.length
66
+ if (n === 0) return "0"
67
+ const last: unknown = list[n - 1]
68
+ if (last == null || typeof last !== "object") return `${n}:?`
69
+ const rec = last as Record<string, unknown>
70
+ const id = typeof rec.id === "string" ? rec.id : ""
71
+ const time = rec.time
72
+ const created =
73
+ time != null && typeof time === "object"
74
+ ? String((time as Record<string, unknown>).created ?? "")
75
+ : ""
76
+ let size = typeof rec.text === "string" ? rec.text.length : 0
77
+ const parts: unknown[] = Array.isArray(rec.content) ? rec.content : []
78
+ for (const raw of parts) {
79
+ if (raw == null || typeof raw !== "object") continue
80
+ const part = raw as Record<string, unknown>
81
+ if (typeof part.text === "string") {
82
+ size += part.text.length
83
+ continue
84
+ }
85
+ const state = part.state
86
+ if (state == null || typeof state !== "object") continue
87
+ const st = state as Record<string, unknown>
88
+ if (typeof st.input === "string") size += st.input.length
89
+ else if (st.input != null) {
90
+ try {
91
+ size += JSON.stringify(st.input).length
92
+ } catch {
93
+ /* unstringifiable input contributes no size */
94
+ }
95
+ }
96
+ // Tool RESULTS matter as much as args: measureContributors reads
97
+ // state.content, and a call that finishes between two ticks changes nothing
98
+ // else on the message. Without this the measured section would freeze for
99
+ // the whole remainder of the turn.
100
+ const content = st.content
101
+ if (typeof content === "string") {
102
+ size += content.length
103
+ } else if (Array.isArray(content)) {
104
+ for (const rawItem of content) {
105
+ if (rawItem == null || typeof rawItem !== "object") continue
106
+ const item = rawItem as Record<string, unknown>
107
+ if (typeof item.text === "string") {
108
+ size += item.text.length
109
+ } else {
110
+ // Non-text parts (e.g. file refs with long/base64 URIs) are not
111
+ // measured by measureContributors, so their marker is enough: never
112
+ // JSON.stringify a potentially megabyte-sized reference per tick.
113
+ size += 1
114
+ }
115
+ }
116
+ }
117
+ // Status transitions (pending -> completed/error) are cheap and always
118
+ // accompany a visible change.
119
+ if (typeof st.status === "string") size += st.status.length
120
+ }
121
+ return `${n}:${id}:${created}:${size}`
122
+ }
123
+
124
+ // Panel column layout (monospace), derived from the host-provided panel width
125
+ // so cells never collide on narrow terminals. Gaps between columns keep a
126
+ // truncated cell visually separated from its neighbour.
127
+ const GUTTER = 2
128
+ const CTX_WIDTH = 12
129
+ const COST_WIDTH = 8
130
+
131
+ function panelLayout(panelWidth: number): {
132
+ roleW: number
133
+ modelW: number
134
+ ctxW: number
135
+ costW: number
136
+ updW: number
137
+ gutter: string
138
+ } {
139
+ const w = num(panelWidth) > 0 ? num(panelWidth) : 60
140
+ const ctxW = CTX_WIDTH
141
+ const costW = COST_WIDTH
142
+ const updW = Math.max(5, Math.min(8, w - ctxW - 4 * GUTTER - 24))
143
+ const roleW = Math.max(12, Math.min(20, Math.floor(w * 0.28)))
144
+ const modelW = Math.max(8, w - roleW - ctxW - costW - updW - 4 * GUTTER)
145
+ return { roleW, modelW, ctxW, costW, updW, gutter: " ".repeat(GUTTER) }
146
+ }
147
+
148
+ // Dollar formatting for the cost column: compact, always `$`-prefixed, `-`
149
+ // when the host data layer has no cost for the session.
150
+ function money(n: number | null): string {
151
+ if (n == null || !Number.isFinite(n)) return "-"
152
+ const a = Math.abs(n)
153
+ if (a >= 1000) return `$${(n / 1000).toFixed(1)}k`
154
+ if (a >= 100) return `$${n.toFixed(1)}`
155
+ return `$${n.toFixed(2)}`
156
+ }
157
+
158
+ // The topmost ancestor of the panel's session: `/cx` opened from a subagent
159
+ // session view must show the WHOLE family (rooted at the main session), not
160
+ // just the child subtree. Walks parentID upward over the snapshot; `seen`
161
+ // guards against parentID loops.
162
+ function resolveRoot(
163
+ snapshot: Record<string, StateEntry>,
164
+ sessionID: string,
165
+ ): string {
166
+ let current = sessionID
167
+ const seen = new Set<string>([current])
168
+ for (;;) {
169
+ const entry = Object.prototype.hasOwnProperty.call(snapshot, current)
170
+ ? snapshot[current]
171
+ : undefined
172
+ const parent = entry?.parentID
173
+ if (
174
+ !parent ||
175
+ typeof parent !== "string" ||
176
+ !Object.prototype.hasOwnProperty.call(snapshot, parent) ||
177
+ seen.has(parent)
178
+ ) {
179
+ break
180
+ }
181
+ seen.add(parent)
182
+ current = parent
183
+ }
184
+ return current
185
+ }
186
+
187
+ // Full session panel: header + one row per session in the family (root plus
188
+ // subagent descendants). It reads the same state.json bridge as the sidebar via
189
+ // the shared 1 s tick, so both surfaces refresh on ONE poll. The family is
190
+ // rooted at the panel session's TOPMOST ancestor, so opening the panel from a
191
+ // subagent view shows the whole family. `cx` opens the panel (registered from
192
+ // the "app" slot in tui.ts; also reachable from the command palette, since
193
+ // subagent views have no prompt input).
194
+ export function BreakdownPanel(props: {
195
+ panel: PanelInput
196
+ snapshot: () => Record<string, StateEntry>
197
+ refresh: () => void
198
+ }) {
199
+ const context = usePlugin()
200
+ const base = () => token(context?.theme?.text?.base, "white")
201
+ const muted = () => token(context?.theme?.text?.muted, base())
202
+
203
+ // The host session data layer, re-read per call: individual accessors
204
+ // (family/get/status/cost) are optional across host versions and every use
205
+ // site guards them separately.
206
+ const dataApiSession = () => context?.data?.session
207
+
208
+ // Set while the picker dialog is on screen: the panel-local `s` key must not
209
+ // re-trigger the picker (a printable key typed into the dialog search would
210
+ // otherwise stack dialogs — the guard is belt-and-braces for hosts where the
211
+ // panel keeps focus while a dialog is open).
212
+ let pickerOpen = false
213
+
214
+ // Per-session cost from the host data layer (read under the shared poll tick,
215
+ // so it refreshes reactively). For a ROOT session the host AGGREGATES the
216
+ // whole family; for a child it is that session's own cost — exactly the
217
+ // per-subagent spend visibility the community asked for (upstream #45417).
218
+ // Guarded: older hosts may not expose the accessor.
219
+ const costOf = (sessionID: string | undefined): number | null => {
220
+ if (!sessionID) return null
221
+ try {
222
+ const cost = context?.data?.session?.cost?.(sessionID)
223
+ return typeof cost === "number" && Number.isFinite(cost) ? cost : null
224
+ } catch {
225
+ return null
226
+ }
227
+ }
228
+
229
+ // Panel-local keys. The layer is owned by this component and disposed with it.
230
+ // mode "global" + an explicit focused check is the portable fallback when no
231
+ // target renderable is available: the keys act only while the panel owns input.
232
+ // The capability guard keeps an older host (no keymap API) from throwing.
233
+ const keymap = context?.keymap
234
+ if (keymap && typeof keymap.layer === "function") {
235
+ keymap.layer(() => ({
236
+ mode: "global",
237
+ priority: -1,
238
+ commands: [
239
+ {
240
+ id: "context-indicator.breakdown.close",
241
+ title: "Close the context breakdown panel",
242
+ bind: "escape",
243
+ enabled: () => props.panel.focused,
244
+ run: () => {
245
+ if (props.panel.focused) props.panel.close()
246
+ },
247
+ },
248
+ {
249
+ id: "context-indicator.breakdown.refresh",
250
+ title: "Refresh the context breakdown panel",
251
+ bind: "r",
252
+ enabled: () => props.panel.focused,
253
+ run: () => {
254
+ if (props.panel.focused) props.refresh()
255
+ },
256
+ },
257
+ {
258
+ id: "context-indicator.breakdown.export",
259
+ title: "Export a redacted breakdown snapshot",
260
+ bind: "e",
261
+ enabled: () => props.panel.focused,
262
+ run: () => {
263
+ if (props.panel.focused) exportRedacted()
264
+ },
265
+ },
266
+ {
267
+ id: "context-indicator.breakdown.pick",
268
+ title: "Pick a family session",
269
+ bind: "s",
270
+ enabled: () => props.panel.focused && !pickerOpen,
271
+ run: () => {
272
+ if (props.panel.focused && !pickerOpen) openPicker()
273
+ },
274
+ },
275
+ ],
276
+ }))
277
+ }
278
+
279
+ // One snapshot read per poll tick (props.snapshot tracks the shared tick), so
280
+ // the memo recomputes together with the sidebar instead of polling separately.
281
+ // The family is rooted at the topmost ancestor (resolveRoot): from a subagent
282
+ // view the whole family is shown, not just the child subtree.
283
+ const data = createMemo(() => {
284
+ const snap = props.snapshot()
285
+ const rootID = resolveRoot(snap, props.panel.sessionID)
286
+ return {
287
+ root: snap[rootID],
288
+ rows: collectFamily(snap, rootID),
289
+ }
290
+ })
291
+
292
+ // Live message list from the TUI data layer (in-process, no LLM call). The
293
+ // shared `props.snapshot()` tick is read FIRST so this re-reads every poll.
294
+ // `available: false` means the API is absent/errored -> the WHOLE measured
295
+ // section is hidden (and a one-time hint was logged).
296
+ const messages = createMemo<{ available: boolean; list: unknown[] }>(() => {
297
+ props.snapshot()
298
+ try {
299
+ const api = context?.data?.session?.message
300
+ if (!api || typeof api.list !== "function") {
301
+ if (!warnedMessagesUnavailable) {
302
+ warnedMessagesUnavailable = true
303
+ console.error(
304
+ "[context-indicator] TUI message data API unavailable; " +
305
+ "measured contributors section hidden",
306
+ )
307
+ }
308
+ return { available: false, list: [] }
309
+ }
310
+ const v = api.list(props.panel.sessionID)
311
+ return { available: true, list: Array.isArray(v) ? v : [] }
312
+ } catch {
313
+ if (!warnedMessagesUnavailable) {
314
+ warnedMessagesUnavailable = true
315
+ console.error(
316
+ "[context-indicator] TUI message read failed; " +
317
+ "measured contributors section hidden",
318
+ )
319
+ }
320
+ return { available: false, list: [] }
321
+ }
322
+ })
323
+
324
+ // Memo for the measured contributors section: the signature/key pair keeps
325
+ // the expensive walk out of the per-tick path (see listSignature).
326
+ let contributorKey: string | null = null
327
+ let contributorValue: Contributor[] = []
328
+
329
+ const contributors = createMemo<Contributor[]>(() => {
330
+ const m = messages()
331
+ if (!m.available) {
332
+ contributorKey = null
333
+ contributorValue = []
334
+ return contributorValue
335
+ }
336
+ // Re-measure ONLY when the message list actually changed. The host rebuilds
337
+ // the array (and `props.snapshot()` ticks) every second, so keying on array
338
+ // identity would re-walk every message — per-character token estimates plus
339
+ // JSON.stringify of tool args — once per tick on the render thread.
340
+ let key: string
341
+ try {
342
+ key = listSignature(m.list)
343
+ } catch {
344
+ key = ""
345
+ }
346
+ if (key && key === contributorKey) return contributorValue
347
+ try {
348
+ contributorValue = measureContributors(m.list)
349
+ contributorKey = key || contributorKey
350
+ } catch {
351
+ // Transient failure: leave the key unset so the next tick retries.
352
+ contributorValue = []
353
+ contributorKey = null
354
+ }
355
+ return contributorValue
356
+ })
357
+
358
+ // Redacted export (key `e`). No message text, no full session id, no tool
359
+ // output content ever leaves this function. Never throws: a failure logs and
360
+ // shows an error toast instead.
361
+ function exportRedacted(): void {
362
+ let exportedName: string | null = null
363
+ try {
364
+ const now = new Date()
365
+ const name = exportFileName(props.panel.sessionID, now)
366
+ const payload = buildRedactedPayload({
367
+ sessionID: props.panel.sessionID,
368
+ root: data().root,
369
+ rows: data().rows,
370
+ contributors: contributors(),
371
+ now,
372
+ panelDenom,
373
+ panelRole,
374
+ num,
375
+ })
376
+ const dir = join(tmpdir(), "opencode-context-indicator", "exports")
377
+ const file = join(dir, name)
378
+ mkdirSync(dir, { recursive: true })
379
+ writeFileSync(file, JSON.stringify(payload, null, 2), "utf8")
380
+ exportedName = basename(file)
381
+ } catch (err) {
382
+ console.error("[context-indicator] redacted export failed:", err)
383
+ try {
384
+ context?.ui?.toast?.show({
385
+ message: "redacted export failed",
386
+ variant: "error",
387
+ })
388
+ } catch {
389
+ /* toast is best-effort */
390
+ }
391
+ return
392
+ }
393
+ // Outside the write try: a toast failure must not report the export as
394
+ // failed when the file is already on disk.
395
+ try {
396
+ context?.ui?.toast?.show({
397
+ message: `exported ${exportedName}`,
398
+ variant: "success",
399
+ })
400
+ } catch {
401
+ /* toast is best-effort */
402
+ }
403
+ }
404
+
405
+ // Navigate to a family session: root sessions focus their tab (tab-aware,
406
+ // opens one when needed); child sessions navigate the route directly (tabs
407
+ // are root-session scoped). Falls back with a toast when the host exposes
408
+ // neither mechanism.
409
+ function navigateTo(sessionID: string): void {
410
+ try {
411
+ const ui = context?.ui
412
+ // Child detection is three-sourced: the host session info, OUR snapshot
413
+ // (state.json parentID), and the host root() accessor — get() alone can
414
+ // miss a session that has not been synced into the client store yet, and
415
+ // tabs.focus() on a child ID would target the wrong (root) tab.
416
+ let isChild = false
417
+ try {
418
+ const info = context?.data?.session?.get?.(sessionID)
419
+ isChild = !!(
420
+ info &&
421
+ typeof info === "object" &&
422
+ (info as Record<string, unknown>).parentID
423
+ )
424
+ } catch {
425
+ /* other sources below */
426
+ }
427
+ if (!isChild) {
428
+ const snapParent = props.snapshot()[sessionID]?.parentID
429
+ if (typeof snapParent === "string" && snapParent.length > 0) isChild = true
430
+ }
431
+ if (!isChild && typeof context?.data?.session?.root === "function") {
432
+ try {
433
+ if (context.data.session.root(sessionID) !== sessionID) isChild = true
434
+ } catch {
435
+ /* treat as root */
436
+ }
437
+ }
438
+ if (!isChild && ui?.tabs && typeof ui.tabs.focus === "function") {
439
+ if (ui.tabs.focus(sessionID)) return
440
+ }
441
+ if (ui?.router && typeof ui.router.navigate === "function") {
442
+ ui.router.navigate({ type: "session", sessionID })
443
+ return
444
+ }
445
+ try {
446
+ context?.ui?.toast?.show({
447
+ message: "session navigation not supported by this host",
448
+ variant: "warning",
449
+ })
450
+ } catch {
451
+ /* toast is best-effort */
452
+ }
453
+ } catch (err) {
454
+ console.error("[context-indicator] session navigation failed:", err)
455
+ }
456
+ }
457
+
458
+ // Family-session picker (key `s`): a select dialog over the host data layer's
459
+ // family rooted at the TOPMOST ancestor (matches the table; authoritative —
460
+ // includes registered sessions that never served a step; falls back to our
461
+ // state.json walk), searchable by title/model, each option carrying its own
462
+ // cost and a running marker. Enter navigates.
463
+ function openPicker(): void {
464
+ if (pickerOpen) return
465
+ const dialog = context?.ui?.dialog
466
+ if (!dialog || typeof dialog.select !== "function") {
467
+ try {
468
+ context?.ui?.toast?.show({
469
+ message: "session picker requires opencode 2.0.23+",
470
+ variant: "warning",
471
+ })
472
+ } catch {
473
+ /* toast is best-effort */
474
+ }
475
+ return
476
+ }
477
+
478
+ const rootID = resolveRoot(props.snapshot(), props.panel.sessionID)
479
+ let ids: string[] = []
480
+ try {
481
+ const family = dataApiSession()?.family?.(rootID)
482
+ if (Array.isArray(family)) ids = family.filter((id) => typeof id === "string")
483
+ } catch {
484
+ ids = []
485
+ }
486
+ if (ids.length === 0) {
487
+ // Host family unavailable: fall back to our state.json family walk.
488
+ ids = collectFamily(props.snapshot(), rootID)
489
+ .map((e) => e.sessionID)
490
+ .filter((id): id is string => typeof id === "string" && id.length > 0)
491
+ }
492
+
493
+ const labels = new Map<string, string>()
494
+ const options = ids.map((id) => {
495
+ let title = "(untitled)"
496
+ let model = ""
497
+ let running = false
498
+ try {
499
+ const info = dataApiSession()?.get?.(id)
500
+ if (info && typeof info === "object") {
501
+ const raw = info as Record<string, unknown>
502
+ title = safeText(raw.title) || title
503
+ const m = raw.model as { modelID?: unknown } | string | undefined
504
+ model =
505
+ typeof m === "string"
506
+ ? safeText(m)
507
+ : m && typeof m.modelID === "string"
508
+ ? safeText(m.modelID)
509
+ : ""
510
+ running = dataApiSession()?.status?.(id) === "running"
511
+ }
512
+ } catch {
513
+ /* defensive: host data layers may lack get/status */
514
+ }
515
+ if (!model) {
516
+ const fromSnapshot = props.snapshot()[id]?.model
517
+ if (typeof fromSnapshot === "string" && fromSnapshot !== "unknown") model = safeText(fromSnapshot)
518
+ }
519
+ const parts = [model, title, money(costOf(id))]
520
+ if (running) parts.push("running")
521
+ const label = parts.filter((p) => p.length > 0).join(" · ")
522
+ labels.set(id, label)
523
+ return { title: label, value: id }
524
+ })
525
+
526
+ if (options.length <= 1) {
527
+ try {
528
+ context?.ui?.toast?.show({
529
+ message: "no other family sessions",
530
+ variant: "info",
531
+ })
532
+ } catch {
533
+ /* toast is best-effort */
534
+ }
535
+ return
536
+ }
537
+
538
+ pickerOpen = true
539
+ try {
540
+ void dialog
541
+ .select({
542
+ title: "Family sessions",
543
+ options,
544
+ search: (query, opts) => {
545
+ const q = query.trim().toLowerCase()
546
+ if (!q) return opts
547
+ return opts.filter((o) => (labels.get(o.value) ?? o.title).toLowerCase().includes(q))
548
+ },
549
+ })
550
+ .then((picked) => {
551
+ pickerOpen = false
552
+ if (typeof picked === "string" && picked.length > 0) navigateTo(picked)
553
+ })
554
+ .catch(() => {
555
+ pickerOpen = false
556
+ /* dialog settle races are host-managed; nothing to recover */
557
+ })
558
+ } catch (err) {
559
+ pickerOpen = false
560
+ console.error("[context-indicator] session picker failed:", err)
561
+ }
562
+ }
563
+
564
+ const header = (): string => {
565
+ const r = data().root
566
+ if (!r) return "Context breakdown"
567
+ const model = safeText(r.model && r.model !== "unknown" ? r.model : "model?")
568
+ const ctx = fmt(num(r.ctx))
569
+ const d = panelDenom(r)
570
+ const base =
571
+ d > 0
572
+ ? `Context breakdown \u2014 ${model} ${ctx}/${fmt(d)} (${Math.round(
573
+ (num(r.ctx) / d) * 100,
574
+ )}%)`
575
+ : `Context breakdown \u2014 ${model} ${ctx}`
576
+ return r.compactStale === true ? `${base} (stale — compacted)` : base
577
+ }
578
+
579
+ const layout = (): {
580
+ roleW: number
581
+ modelW: number
582
+ ctxW: number
583
+ costW: number
584
+ updW: number
585
+ gutter: string
586
+ } => panelLayout(props.panel.width)
587
+
588
+ const columns = (): string => {
589
+ const L = layout()
590
+ return (
591
+ fit("role", L.roleW) +
592
+ L.gutter +
593
+ fit("model", L.modelW) +
594
+ L.gutter +
595
+ fit("ctx", L.ctxW) +
596
+ L.gutter +
597
+ fit("cost", L.costW) +
598
+ L.gutter +
599
+ "updated"
600
+ )
601
+ }
602
+
603
+ const row = (e: StateEntry, isMain: boolean): string => {
604
+ const model = safeText(e.model && e.model !== "unknown" ? e.model : "model?")
605
+ const d = panelDenom(e)
606
+ const ctx =
607
+ d > 0
608
+ ? `${fmt(num(e.ctx))} (${Math.round((num(e.ctx) / d) * 100)}%)`
609
+ : fmt(num(e.ctx))
610
+ const L = layout()
611
+ return (
612
+ fit(panelRole(e, isMain), L.roleW) +
613
+ L.gutter +
614
+ fit(model, L.modelW) +
615
+ L.gutter +
616
+ fit(ctx, L.ctxW) +
617
+ L.gutter +
618
+ fit(money(costOf(e.sessionID)), L.costW) +
619
+ L.gutter +
620
+ updatedAt(e.updatedAt) +
621
+ (e.compactStale === true ? " (stale)" : "")
622
+ )
623
+ }
624
+
625
+ // One compact line: the main session's tool results split by tool name
626
+ // (server-side aggregate; the per-call "largest contributors" block above is
627
+ // the measured, client-side view).
628
+ const byToolLine = (): string => {
629
+ const all = data().root?.toolOutByTool
630
+ const entries = toolOutEntries(all, 5)
631
+ if (entries.length === 0) return ""
632
+ const total =
633
+ all && typeof all === "object"
634
+ ? Object.values(all).filter((v) => num(v) > 0).length
635
+ : 0
636
+ const parts = entries.map(([name, val]) => `${safeText(name)} ${fmt(val)}`)
637
+ if (total > entries.length) parts.push(`+${total - entries.length} more`)
638
+ return parts.join(" \u00b7 ")
639
+ }
640
+
641
+ // Muted / highlighted single-line text node. The content accessor is read
642
+ // inside the getter, so the node updates in place on every poll instead of
643
+ // being recreated (same semantics as `<text>{expr}</text>` in JSX).
644
+ const mutedText = (value: () => string) =>
645
+ h("text", {
646
+ get fg() {
647
+ return muted()
648
+ },
649
+ get children() {
650
+ return value()
651
+ },
652
+ })
653
+
654
+ const baseText = (value: () => string) =>
655
+ h("text", {
656
+ get fg() {
657
+ return base()
658
+ },
659
+ get children() {
660
+ return value()
661
+ },
662
+ })
663
+
664
+ const compactLine = (): string =>
665
+ " compact at " +
666
+ fmt(num(data().root?.usable)) +
667
+ (num(data().root?.reserve) > 0
668
+ ? " (" + fmt(num(data().root?.reserve)) + " reserve)"
669
+ : "")
670
+
671
+ const cacheLine = (): string =>
672
+ " prompt cache r " +
673
+ fmt(num(data().root?.cacheRead)) +
674
+ " / w " +
675
+ fmt(num(data().root?.cacheWrite)) +
676
+ " (provider-reported)"
677
+
678
+ const reclaimLine = (): string =>
679
+ " reclaim ~" +
680
+ fmt(num(data().root?.reclaim?.tokens)) +
681
+ " redundant tool output (" +
682
+ num(data().root?.reclaim?.count) +
683
+ " dup)"
684
+
685
+ return h("box", {
686
+ flexDirection: "column",
687
+ get children() {
688
+ return [
689
+ baseText(() => header()),
690
+ comp(Show, {
691
+ get when() {
692
+ return data().root
693
+ },
694
+ get fallback() {
695
+ return mutedText(() => " no data yet")
696
+ },
697
+ get children() {
698
+ return [
699
+ mutedText(() => columns()),
700
+ data()
701
+ .rows.map((e, i) =>
702
+ mutedText(() => row(e, i === 0)),
703
+ ),
704
+ comp(Show, {
705
+ get when() {
706
+ return (data().root?.usable ?? 0) > 0
707
+ },
708
+ get children() {
709
+ return mutedText(() => compactLine())
710
+ },
711
+ }),
712
+ comp(Show, {
713
+ get when() {
714
+ return (
715
+ num(data().root?.cacheRead) > 0 ||
716
+ num(data().root?.cacheWrite) > 0
717
+ )
718
+ },
719
+ get children() {
720
+ return mutedText(() => cacheLine())
721
+ },
722
+ }),
723
+ comp(Show, {
724
+ get when() {
725
+ return num(data().root?.reclaim?.tokens) > 0
726
+ },
727
+ get children() {
728
+ return mutedText(() => reclaimLine())
729
+ },
730
+ }),
731
+ comp(Show, {
732
+ get when() {
733
+ return messages().available
734
+ },
735
+ get children() {
736
+ return [
737
+ baseText(() => "largest contributors (measured)"),
738
+ comp(Show, {
739
+ get when() {
740
+ return contributors().length > 0
741
+ },
742
+ get fallback() {
743
+ return mutedText(() => " no messages yet")
744
+ },
745
+ get children() {
746
+ return contributors()
747
+ .slice(0, 5)
748
+ .map((contributor, i) =>
749
+ mutedText(() => contributorLine(contributor, i + 1)),
750
+ )
751
+ },
752
+ }),
753
+ mutedText(
754
+ () =>
755
+ "measured != server categories: contributors attribute " +
756
+ "outputs per tool call; the server's toolOut category " +
757
+ "only totals them",
758
+ ),
759
+ ]
760
+ },
761
+ }),
762
+ comp(Show, {
763
+ get when() {
764
+ return byToolLine().length > 0
765
+ },
766
+ get children() {
767
+ return [
768
+ baseText(() => "tool results by tool"),
769
+ mutedText(() => byToolLine()),
770
+ ]
771
+ },
772
+ }),
773
+ ]
774
+ },
775
+ }),
776
+ ]
777
+ },
778
+ })
779
+ }