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