opencode-context-indicator 1.7.1 → 1.8.1
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.
- package/CHANGELOG.md +86 -0
- package/README.md +151 -108
- package/index.js +10 -6
- package/lib/commands.js +2 -1
- package/lib/config.js +228 -0
- package/lib/contributors.js +5 -4
- package/lib/limits.js +80 -18
- package/lib/state.js +7 -6
- package/lib/tui-composer.ts +70 -0
- package/lib/tui-data.d.ts +10 -1
- package/lib/tui-data.js +39 -21
- package/lib/tui-h.ts +73 -0
- package/lib/{tui-panel.tsx → tui-panel.ts} +779 -713
- package/lib/tui-sidebar.ts +153 -0
- package/lib/v2.js +21 -4
- package/package.json +10 -12
- package/tui.ts +340 -0
- package/lib/tui-sidebar.tsx +0 -76
- package/tui.tsx +0 -206
|
@@ -1,713 +1,779 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
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.
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
+
}
|