opencode-context-indicator 1.2.2 → 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 +20 -5
- package/lib/tui-panel.tsx +255 -9
- package/package.json +5 -4
- package/tui.tsx +10 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Notable user-facing changes per release. Dates are commit days (UTC).
|
|
4
|
+
The package also ships a dual V1/V2 plugin — see the README for the surface
|
|
5
|
+
matrix; everything below concerns the V2 path unless stated otherwise.
|
|
6
|
+
|
|
7
|
+
## 1.3.0 — unreleased
|
|
8
|
+
|
|
9
|
+
Highlights: per-session cost in `/cx` (family-aggregated for the main session),
|
|
10
|
+
a searchable family-session picker, and `/cx` reachable from subagent session
|
|
11
|
+
views.
|
|
12
|
+
|
|
13
|
+
- `/cx` panel: new **cost** column (upstream #45417). The main session shows
|
|
14
|
+
the family-aggregated spend, each subagent its own; `-` when the host has no
|
|
15
|
+
cost for a session.
|
|
16
|
+
- New panel key `s` — a searchable picker over all family sessions
|
|
17
|
+
(title / model / cost / running marker). `Enter` navigates: root sessions
|
|
18
|
+
focus their tab, subagent sessions switch the route.
|
|
19
|
+
- `/cx` now works from **subagent session views**: those views have no prompt
|
|
20
|
+
input, so the command is also in the command palette and bound to
|
|
21
|
+
`<leader>c` (ctrl+x, then c) and `f4`. The panel family is rooted at the
|
|
22
|
+
topmost ancestor, so the whole family is shown, not just the child subtree.
|
|
23
|
+
- OpenTUI pinned `0.5.17`; `@opencode/plugin` types devDep pinned `2.0.26`
|
|
24
|
+
(drift-watch now tracks both). Tested against opencode **2.0.26**.
|
|
25
|
+
|
|
26
|
+
## 1.2.2 — 2026-10-08
|
|
27
|
+
|
|
28
|
+
- TUI entry split for maintainability: `tui.tsx` down to ~195 lines; the
|
|
29
|
+
components and the data layer moved to `lib/` (`tui-sidebar.tsx`,
|
|
30
|
+
`tui-panel.tsx`, `tui-data.js` + `.d.ts`, `tui-theme.js`). No behavior
|
|
31
|
+
change. The pragma regression gate now covers every `lib/*.tsx`.
|
|
32
|
+
- E2E hardened: deterministic fail-closed plugin-load check (isolated global
|
|
33
|
+
config via `OPENCODE_CONFIG_DIR` + the isolated instance's own log file +
|
|
34
|
+
a live-surface stamp), cleanup of spawned CLI instances, WMI probe timeouts.
|
|
35
|
+
|
|
36
|
+
## 1.2.1 — 2026-10-08
|
|
37
|
+
|
|
38
|
+
- The panel / percentage denominator now equals **opencode's compaction
|
|
39
|
+
ceiling** (v2.0.23+ `calculateCeiling`: window = `limit.input ||
|
|
40
|
+
limit.context`; reserve = max(10% of the window, 16k when the window is at
|
|
41
|
+
least 32k)) — percentages read as "% to compaction", not "% of the raw
|
|
42
|
+
window" (upstream #51271).
|
|
43
|
+
- `state.json`: new `reserve` field; the panel and `/context` show
|
|
44
|
+
`compact at X (N reserve)`.
|
|
45
|
+
- Unit harness extended to 34 checks (measured contributors, redacted-export
|
|
46
|
+
invariants, ceiling formula, hydrate seeding).
|
|
47
|
+
- OpenTUI pinned `0.5.16`.
|
|
48
|
+
|
|
49
|
+
## 1.2.0 — 2026-10-04
|
|
50
|
+
|
|
51
|
+
- New: **zero-LLM `/cx` session panel** — a family table (role / model /
|
|
52
|
+
ctx% / updated) for the current session and all subagent descendants, plus a
|
|
53
|
+
`largest contributors (measured)` section computed from the in-process
|
|
54
|
+
message list (per tool call, with message clock times and bounded previews).
|
|
55
|
+
- Redacted export (`e`): no message text, no full session ids, no tool output
|
|
56
|
+
content — written under `%TEMP%/opencode-context-indicator/exports/`.
|
|
57
|
+
- Keys: `esc` closes, `r` refreshes, `e` exports.
|
|
58
|
+
|
|
59
|
+
## 1.1.6 — 2026-10-04
|
|
60
|
+
|
|
61
|
+
- OpenTUI pinned `0.5.14` (opencode 2.0.21+), optional-peer floor aligned.
|
|
62
|
+
- `index.js` split into `lib/` modules (no behavior change).
|
|
63
|
+
|
|
64
|
+
## 1.1.5 — 2026-10-02
|
|
65
|
+
|
|
66
|
+
- Security hardening: sanitized table metadata, validated `state.json`
|
|
67
|
+
(prototype-poisoning keys dropped), ownership-safe state lock, tmp hygiene.
|
|
68
|
+
|
|
69
|
+
## 1.1.4 — 2026-10-02
|
|
70
|
+
|
|
71
|
+
- Removed a stray `opencode/process` import from the TUI entry; the prepublish
|
|
72
|
+
gate now rejects bare `opencode/*` specifiers (not bridged at runtime).
|
|
73
|
+
|
|
74
|
+
## 1.1.3 — 2026-10-01
|
|
75
|
+
|
|
76
|
+
- Stable context denominator across plugin instances: a transient
|
|
77
|
+
`ctx.model.list()` miss falls back to the shared snapshot instead of
|
|
78
|
+
clobbering persisted windows.
|
|
79
|
+
|
|
80
|
+
## 1.1.2 — 2026-10-01
|
|
81
|
+
|
|
82
|
+
- Graceful sidebar/panel disable on npm installs (upstream #33884: `node_modules`
|
|
83
|
+
plugins skip the host Solid transform, so slots would freeze) — a single hint
|
|
84
|
+
is logged instead; `@opentui/solid` is shipped pinned; `file://` install docs.
|
|
85
|
+
|
|
86
|
+
## 1.1.1 — 2026-10-01
|
|
87
|
+
|
|
88
|
+
- Fixed `Cannot find package 'react'` when loaded from the npm cache: the
|
|
89
|
+
`@jsxImportSource @opentui/solid` pragma must be the file's first line.
|
|
90
|
+
|
|
91
|
+
## 1.1.0 — 2026-10-01
|
|
92
|
+
|
|
93
|
+
- New: `/context` (compact summary) and `/context-breakdown` (full table, main
|
|
94
|
+
+ all subagent sessions) slash commands.
|
|
95
|
+
- Stable per-model window limits; strict subagent discovery.
|
|
96
|
+
|
|
97
|
+
## 1.0.0 — 2026-09-30
|
|
98
|
+
|
|
99
|
+
- Initial release: dual V1/V2 plugin, real-time context indicator, per-category
|
|
100
|
+
token breakdown (system / tool schemas / user / assistant / reasoning / tool
|
|
101
|
+
args / residual), TUI sidebar.
|
package/README.md
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
Real-time **context-window usage** indicator for [OpenCode](https://opencode.ai),
|
|
4
4
|
with a **per-category token breakdown** and an optional **terminal-UI sidebar**.
|
|
5
5
|
|
|
6
|
+
User-facing changes per release: [CHANGELOG.md](./CHANGELOG.md).
|
|
7
|
+
|
|
6
8
|
It tells you how full the current model's context window is, and *what* is
|
|
7
9
|
filling it: system prompt, tool schemas, user/assistant text, reasoning, tool
|
|
8
10
|
arguments and the residual "other" bucket.
|
|
@@ -10,7 +12,7 @@ arguments and the residual "other" bucket.
|
|
|
10
12
|
The package ships a **dual V1 + V2 plugin** (one entry works in both OpenCode
|
|
11
13
|
generations) plus an optional TUI module (`./tui` entry) that renders a **live
|
|
12
14
|
sidebar** and a **zero-LLM `/cx` breakdown panel** with measured contributors
|
|
13
|
-
and a redacted export. The V2 path is **tested against opencode v2.0.
|
|
15
|
+
and a redacted export. The V2 path is **tested against opencode v2.0.26**; the
|
|
14
16
|
V1 path against 1.18.29+.
|
|
15
17
|
|
|
16
18
|
> **V1 is feature-frozen.** It is kept working for compatibility (bug fixes
|
|
@@ -240,7 +242,20 @@ back to a plain notice if needed.
|
|
|
240
242
|
`/cx` opens a full `session.panel` showing the breakdown for the current session
|
|
241
243
|
**and its subagent descendants**, read from the same `state.json` bridge as the
|
|
242
244
|
sidebar. Keys: `/cx` opens, `esc` closes, `r` refreshes (re-read the bridge),
|
|
243
|
-
`e` exports a redacted snapshot.
|
|
245
|
+
`e` exports a redacted snapshot, `s` picks a family session.
|
|
246
|
+
|
|
247
|
+
Every row carries a **cost** column read from the host data layer: a main
|
|
248
|
+
session shows the **family-aggregated** spend, each subagent its own — no more
|
|
249
|
+
invisible subagent spend (upstream #45417). `s` opens a searchable picker over
|
|
250
|
+
all family sessions (title / model / cost / running marker); `Enter` navigates
|
|
251
|
+
there — root sessions focus their tab, subagent sessions switch the route.
|
|
252
|
+
|
|
253
|
+
Works from **subagent session views too**: those views have no prompt input
|
|
254
|
+
(the composer switches to its subagents tab), so `/cx` is also registered in
|
|
255
|
+
the command palette and bound to `<leader>c` / `f4` — both fire from any
|
|
256
|
+
session view (the palette itself is only reachable outside the composer).
|
|
257
|
+
Opened from a subagent view, the family is rooted at the topmost ancestor, so
|
|
258
|
+
the whole family is shown, not just the child subtree.
|
|
244
259
|
|
|
245
260
|
**Zero LLM calls.** Unlike `/context` and `/context-breakdown` (which run a real
|
|
246
261
|
agent turn so the model renders the table), the panel only reads local TUI data
|
|
@@ -437,13 +452,13 @@ the sidebar instead.
|
|
|
437
452
|
gracefully (the breakdown falls back to throttled `session.messages` fetches
|
|
438
453
|
and toasts keep working).
|
|
439
454
|
* **OpenCode ≥ 2.0.16** for the V2 path (built and verified against
|
|
440
|
-
**2.0.
|
|
455
|
+
**2.0.26**).
|
|
441
456
|
* **TUI sidebar**: terminal TUI only, requires the OpenTUI rendering stack that
|
|
442
457
|
ships with OpenCode. `@opentui/core` and `solid-js` are **optional** peer
|
|
443
458
|
dependencies resolved by OpenCode at runtime; the main plugin installs and runs
|
|
444
459
|
fine without them (the sidebar simply is not available). `@opentui/solid` is
|
|
445
|
-
instead shipped as a **pinned direct dependency** (exact `0.5.
|
|
446
|
-
the OpenTUI stack OpenCode 2.0.
|
|
460
|
+
instead shipped as a **pinned direct dependency** (exact `0.5.17`, matching
|
|
461
|
+
the OpenTUI stack OpenCode 2.0.26 ships): OpenCode's
|
|
447
462
|
TUI loader does not expose a host instance of it, so for npm-installed plugins
|
|
448
463
|
the JSX pragma would otherwise fail to resolve `@opentui/solid/jsx-runtime`
|
|
449
464
|
(upstream: opencode issue #33884 — `node_modules` plugins are excluded from the
|
package/lib/tui-panel.tsx
CHANGED
|
@@ -58,27 +58,71 @@ let warnedMessagesUnavailable = false
|
|
|
58
58
|
// truncated cell visually separated from its neighbour.
|
|
59
59
|
const GUTTER = 2
|
|
60
60
|
const CTX_WIDTH = 12
|
|
61
|
+
const COST_WIDTH = 8
|
|
61
62
|
|
|
62
63
|
function panelLayout(panelWidth: number): {
|
|
63
64
|
roleW: number
|
|
64
65
|
modelW: number
|
|
65
66
|
ctxW: number
|
|
67
|
+
costW: number
|
|
66
68
|
updW: number
|
|
67
69
|
gutter: string
|
|
68
70
|
} {
|
|
69
71
|
const w = num(panelWidth) > 0 ? num(panelWidth) : 60
|
|
70
72
|
const ctxW = CTX_WIDTH
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
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
|
|
75
117
|
}
|
|
76
118
|
|
|
77
119
|
// Full session panel: header + one row per session in the family (root plus
|
|
78
120
|
// subagent descendants). It reads the same state.json bridge as the sidebar via
|
|
79
|
-
// the shared 1 s tick, so both surfaces refresh on ONE poll.
|
|
80
|
-
//
|
|
81
|
-
//
|
|
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).
|
|
82
126
|
export function BreakdownPanel(props: {
|
|
83
127
|
panel: PanelInput
|
|
84
128
|
snapshot: () => Record<string, StateEntry>
|
|
@@ -88,6 +132,32 @@ export function BreakdownPanel(props: {
|
|
|
88
132
|
const base = () => token(context?.theme?.text?.base, "white")
|
|
89
133
|
const muted = () => token(context?.theme?.text?.muted, base())
|
|
90
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
|
+
|
|
91
161
|
// Panel-local keys. The layer is owned by this component and disposed with it.
|
|
92
162
|
// mode "global" + an explicit focused check is the portable fallback when no
|
|
93
163
|
// target renderable is available: the keys act only while the panel owns input.
|
|
@@ -125,17 +195,29 @@ export function BreakdownPanel(props: {
|
|
|
125
195
|
if (props.panel.focused) exportRedacted()
|
|
126
196
|
},
|
|
127
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
|
+
},
|
|
128
207
|
],
|
|
129
208
|
}))
|
|
130
209
|
}
|
|
131
210
|
|
|
132
211
|
// One snapshot read per poll tick (props.snapshot tracks the shared tick), so
|
|
133
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.
|
|
134
215
|
const data = createMemo(() => {
|
|
135
216
|
const snap = props.snapshot()
|
|
217
|
+
const rootID = resolveRoot(snap, props.panel.sessionID)
|
|
136
218
|
return {
|
|
137
|
-
root: snap[
|
|
138
|
-
rows: collectFamily(snap,
|
|
219
|
+
root: snap[rootID],
|
|
220
|
+
rows: collectFamily(snap, rootID),
|
|
139
221
|
}
|
|
140
222
|
})
|
|
141
223
|
|
|
@@ -228,6 +310,165 @@ export function BreakdownPanel(props: {
|
|
|
228
310
|
}
|
|
229
311
|
}
|
|
230
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
|
+
|
|
231
472
|
const header = (): string => {
|
|
232
473
|
const r = data().root
|
|
233
474
|
if (!r) return "Context breakdown"
|
|
@@ -245,6 +486,7 @@ export function BreakdownPanel(props: {
|
|
|
245
486
|
roleW: number
|
|
246
487
|
modelW: number
|
|
247
488
|
ctxW: number
|
|
489
|
+
costW: number
|
|
248
490
|
updW: number
|
|
249
491
|
gutter: string
|
|
250
492
|
} => panelLayout(props.panel.width)
|
|
@@ -258,6 +500,8 @@ export function BreakdownPanel(props: {
|
|
|
258
500
|
L.gutter +
|
|
259
501
|
fit("ctx", L.ctxW) +
|
|
260
502
|
L.gutter +
|
|
503
|
+
fit("cost", L.costW) +
|
|
504
|
+
L.gutter +
|
|
261
505
|
"updated"
|
|
262
506
|
)
|
|
263
507
|
}
|
|
@@ -277,6 +521,8 @@ export function BreakdownPanel(props: {
|
|
|
277
521
|
L.gutter +
|
|
278
522
|
fit(ctx, L.ctxW) +
|
|
279
523
|
L.gutter +
|
|
524
|
+
fit(money(costOf(e.sessionID)), L.costW) +
|
|
525
|
+
L.gutter +
|
|
280
526
|
updatedAt(e.updatedAt)
|
|
281
527
|
)
|
|
282
528
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-context-indicator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Real-time OpenCode context-window usage indicator: per-category token breakdown written to a log file, plus an optional TUI sidebar. Dual V1 + V2 plugin entry.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"lib/",
|
|
14
14
|
"tui.tsx",
|
|
15
15
|
"README.md",
|
|
16
|
+
"CHANGELOG.md",
|
|
16
17
|
"LICENSE"
|
|
17
18
|
],
|
|
18
19
|
"engines": {
|
|
@@ -56,11 +57,11 @@
|
|
|
56
57
|
}
|
|
57
58
|
},
|
|
58
59
|
"dependencies": {
|
|
59
|
-
"@opentui/solid": "0.5.
|
|
60
|
+
"@opentui/solid": "0.5.17"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
62
|
-
"@opencode/plugin": "
|
|
63
|
-
"@opentui/core": "0.5.
|
|
63
|
+
"@opencode/plugin": "2.0.26",
|
|
64
|
+
"@opentui/core": "0.5.17",
|
|
64
65
|
"@types/node": "26.6.3",
|
|
65
66
|
"esbuild": "^0.28.2",
|
|
66
67
|
"node-pty": "^1.1.0",
|
package/tui.tsx
CHANGED
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
*
|
|
39
39
|
* Runtime requirements: @opentui/core and solid-js are optional peers resolved
|
|
40
40
|
* by OpenCode at runtime. @opentui/solid is a pinned direct dependency (exact
|
|
41
|
-
* 0.5.
|
|
41
|
+
* 0.5.17): OpenCode's TUI loader does not expose a host instance of it, so for
|
|
42
42
|
* npm-installed plugins the JSX pragma would otherwise fail to resolve
|
|
43
43
|
* `@opentui/solid/jsx-runtime` (upstream opencode issue #33884).
|
|
44
44
|
*
|
|
@@ -138,6 +138,15 @@ export default Plugin.define({
|
|
|
138
138
|
id: "context-indicator.breakdown",
|
|
139
139
|
title: "Context breakdown panel",
|
|
140
140
|
slash: { name: "cx" },
|
|
141
|
+
// Palette-visible AND directly bound: subagent session views
|
|
142
|
+
// force the composer into its "subagents" tab, which pushes
|
|
143
|
+
// keymap mode "composer" — the base-mode palette binding
|
|
144
|
+
// (ctrl+p) is inert there and there is no prompt to type /cx.
|
|
145
|
+
// A bind on this mode:"global" layer fires in every mode
|
|
146
|
+
// (leader matches <leader>b "toggle sidebar"; f4 as a direct
|
|
147
|
+
// single-chord fallback).
|
|
148
|
+
palette: true,
|
|
149
|
+
bind: "<leader>c,f4",
|
|
141
150
|
run: () => {
|
|
142
151
|
const panelApi = context?.ui?.panel
|
|
143
152
|
if (panelApi && typeof panelApi.open === "function") {
|