opencode-context-indicator 1.2.0 → 1.2.2

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/tui.tsx CHANGED
@@ -10,6 +10,17 @@
10
10
  * must stay the very first line, before any import, and no other pragma-like
11
11
  * token may appear in this file. See the OpenTUI Solid integration guide.
12
12
  *
13
+ * This file is the ENTRY + setup wiring only. The components and the data layer
14
+ * live in lib/ (all of them are transformed by the host exactly like this file,
15
+ * because the Solid transform's sourceFilter covers every plugin file outside
16
+ * node_modules — see @opentui/solid/scripts/solid-plugin.js):
17
+ * - lib/tui-sidebar.tsx the `sidebar.content` slot component
18
+ * - lib/tui-panel.tsx the `session.panel` component (BreakdownPanel)
19
+ * - lib/tui-data.js state.json bridge, family walk, formatting helpers
20
+ * (+ lib/tui-data.d.ts for the shared types)
21
+ * - lib/tui-theme.js theme-token helper
22
+ * - lib/contributors.js measured-contributor computation + redacted export
23
+ *
13
24
  * Exposed as the `./tui` package entry ("beside the main plugin for automatic
14
25
  * loading"). OpenCode loads it only in the terminal TUI; it is NOT loaded in
15
26
  * the Desktop app (Desktop exposes no TUI slot API). It renders the
@@ -22,12 +33,12 @@
22
33
  * %TEMP%/opencode-context-indicator-state.json
23
34
  * (see writeStateFile() in index.js) on every breakdown update. This module
24
35
  * ONLY reads that file and re-polls it once per second; a missing or corrupt
25
- * file renders "no data yet". The main plugin never imports this file, so the
26
- * absence of the OpenTUI peers cannot break the server/V1 path.
36
+ * file renders "no data yet". The main plugin never imports the TUI entry, so
37
+ * the absence of the OpenTUI peers cannot break the server/V1 path.
27
38
  *
28
39
  * Runtime requirements: @opentui/core and solid-js are optional peers resolved
29
40
  * by OpenCode at runtime. @opentui/solid is a pinned direct dependency (exact
30
- * 0.5.14): OpenCode's TUI loader does not expose a host instance of it, so for
41
+ * 0.5.16): OpenCode's TUI loader does not expose a host instance of it, so for
31
42
  * npm-installed plugins the JSX pragma would otherwise fail to resolve
32
43
  * `@opentui/solid/jsx-runtime` (upstream opencode issue #33884).
33
44
  *
@@ -40,775 +51,21 @@
40
51
  * `file://` from a path outside any `node_modules` for the live surfaces (see
41
52
  * README → "Live sidebar").
42
53
  */
43
- import {Plugin, usePlugin} from "@opencode/plugin/tui"
44
- import {createMemo, createSignal, Show} from "solid-js"
45
- import type {ColorInput} from "@opentui/core"
46
- import {mkdirSync, readFileSync, writeFileSync} from "node:fs"
47
- import {tmpdir} from "node:os"
48
- import {basename, join} from "node:path"
49
- import {fileURLToPath} from "node:url"
50
-
51
- const STATE_FILE =
52
- process.env.OPENCODE_CONTEXT_INDICATOR_STATE_FILE ||
53
- join(tmpdir(), "opencode-context-indicator-state.json")
54
- const POLL_MS = 1000
55
- const LABEL_WIDTH = 12
54
+ import { Plugin } from "@opencode/plugin/tui"
55
+ import { createSignal, Show } from "solid-js"
56
+ import { mkdirSync, writeFileSync } from "node:fs"
57
+ import { tmpdir } from "node:os"
58
+ import { join } from "node:path"
59
+ import type { StateEntry } from "./lib/tui-data.js"
60
+ import { isNodeModulesInstall, POLL_MS, readSnapshot } from "./lib/tui-data.js"
61
+ import { Sidebar } from "./lib/tui-sidebar"
62
+ import { BreakdownPanel } from "./lib/tui-panel"
56
63
 
57
64
  // Session-panel contribution name. Session-panel names are shared selection
58
65
  // values (not registered claims), so it MUST be plugin-prefixed to avoid
59
66
  // colliding with another plugin's panel.
60
67
  const PANEL_NAME = "context-indicator.breakdown"
61
68
 
62
- // True when this module was loaded from an npm install, i.e. its own file path
63
- // contains a `node_modules` segment (OpenCode's TUI loader skips the host Solid
64
- // transform for such paths → slots mount once but never live-update; see the
65
- // Graceful mode note above). Normalised to forward slashes for Windows paths.
66
- function isNodeModulesInstall(): boolean {
67
- try {
68
- const selfPath = fileURLToPath(import.meta.url).replace(/\\/g, "/").toLowerCase()
69
- return selfPath.includes("/node_modules/")
70
- } catch {
71
- return false
72
- }
73
- }
74
-
75
- type Categories = {
76
- user?: number
77
- assistant?: number
78
- reasoning?: number
79
- toolArgs?: number
80
- system?: number | null
81
- toolSchemas?: number | null
82
- other?: number
83
- }
84
-
85
- type StateEntry = {
86
- sessionID?: string
87
- parentID?: string | null
88
- role?: string
89
- agent?: string | null
90
- model?: string
91
- providerID?: string
92
- ctx?: number
93
- input?: number
94
- usable?: number | null
95
- limit?: number | null
96
- reasoning?: number
97
- categories?: Categories
98
- updatedAt?: string
99
- }
100
-
101
- function fmt(n: number): string {
102
- if (!Number.isFinite(n)) return "0"
103
- return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n))
104
- }
105
-
106
- function num(v: unknown): number {
107
- return typeof v === "number" && Number.isFinite(v) ? v : 0
108
- }
109
-
110
- // Theme tokens are OpenTUI RGBA values (see ResolvedTheme.text.base/.muted);
111
- // pass them straight to the renderer. Fall back to a named color when a token
112
- // is absent so an unexpected theme shape can never blank the sidebar.
113
- // `fg` on <text> is typed as OpenTUI's ColorInput (= string | RGBA); using the
114
- // same alias keeps the JSX assignment type-correct instead of a loose `object`.
115
- type Fg = ColorInput
116
- function token(v: unknown, fallback: Fg): Fg {
117
- return v && typeof v === "object" ? (v as Fg) : fallback
118
- }
119
-
120
- // Reserved object keys must never be treated as session ids: state.json is
121
- // untrusted input (mirrors isSafeSessionKey() in lib/estimate.js).
122
- function isSafeKey(k: string): boolean {
123
- return k !== "__proto__" && k !== "constructor" && k !== "prototype"
124
- }
125
-
126
- // Whole state.json snapshot ({ sessionID: entry }) or {} on any error. The
127
- // sidebar resolves one entry from it and the breakdown panel walks the
128
- // parentID family over it. Never throws — a missing / empty / corrupt
129
- // file simply yields {}.
130
- function readSnapshot(): Record<string, StateEntry> {
131
- try {
132
- const raw = readFileSync(STATE_FILE, "utf8")
133
- const all = JSON.parse(raw) as Record<string, StateEntry>
134
- if (!all || typeof all !== "object" || Array.isArray(all)) return {}
135
- // Rebuild into a fresh plain object: drops prototype-poisoning keys
136
- // (__proto__ / constructor / prototype) and non-object entries in one pass.
137
- const clean: Record<string, StateEntry> = {}
138
- for (const [k, v] of Object.entries(all)) {
139
- if (isSafeKey(k) && v && typeof v === "object") clean[k] = v
140
- }
141
- return clean
142
- } catch {
143
- return {}
144
- }
145
- }
146
-
147
- // True when an entry carries no measured tokens at all (a registered session
148
- // that never served a step). Mirrors isEmptyStateEntry() in lib/commands.js:
149
- // `categories` is always persisted, so the emptiness test SUMS its fields —
150
- // a missing categories object is not the only trigger. `system` / `toolSchemas`
151
- // may legitimately be null, hence the num() coercion.
152
- function isEmptyEntry(e: StateEntry | undefined): boolean {
153
- const c = e?.categories
154
- const sum =
155
- num(c?.user) +
156
- num(c?.assistant) +
157
- num(c?.reasoning) +
158
- num(c?.toolArgs) +
159
- num(c?.system) +
160
- num(c?.toolSchemas) +
161
- num(c?.other)
162
- return sum === 0 && num(e?.ctx) === 0 && num(e?.input) === 0
163
- }
164
-
165
- // The panel row set: the root entry (panel.sessionID) plus every entry whose
166
- // parentID chain reaches it, breadth-first. A small local walk over the snapshot
167
- // — the server's collectDescendantSessionIDs lives in lib/commands.js and is NOT
168
- // imported here (tui.tsx stays independent of the server code by design). A
169
- // phantom node (no tokens, see isEmptyEntry) is not emitted, but traversal still
170
- // descends through it so real grandchildren behind it are found. `seen` guards
171
- // against cycles (parentID loops) and double-listing.
172
- function collectFamily(
173
- snapshot: Record<string, StateEntry>,
174
- rootID: string,
175
- ): StateEntry[] {
176
- const out: StateEntry[] = []
177
- const root = snapshot[rootID]
178
- if (root && typeof root === "object") out.push(root)
179
- const seen = new Set<string>([rootID])
180
- const queue: string[] = [rootID]
181
- // Index the snapshot by parentID once (O(n)) instead of an O(n) scan per node.
182
- const byParent = new Map<string, string[]>()
183
- for (const [id, e] of Object.entries(snapshot)) {
184
- const pid = e?.parentID
185
- if (!pid || typeof pid !== "string") continue
186
- const kids = byParent.get(pid)
187
- if (kids) kids.push(id)
188
- else byParent.set(pid, [id])
189
- }
190
- while (queue.length > 0) {
191
- const cur = queue.shift() as string
192
- const kids = byParent.get(cur)
193
- if (!kids) continue
194
- for (const id of kids) {
195
- if (!id || seen.has(id)) continue
196
- seen.add(id)
197
- const e = snapshot[id]
198
- if (e && !isEmptyEntry(e)) out.push(e)
199
- queue.push(id) // descend through phantom parents too
200
- }
201
- }
202
- return out
203
- }
204
-
205
- // One-line sanitisation for untrusted labels (agent / model) rendered in the
206
- // panel: drop zero-width + bidi control chars, collapse C0/C1 controls (incl.
207
- // CR/LF/TAB) and the line/paragraph separators to spaces, then cap the result at
208
- // 32 code points (code-point safe). Only labels are ever rendered — never
209
- // message content.
210
- function safeText(v: unknown): string {
211
- try {
212
- const s = v == null ? "" : String(v)
213
- const cleaned = s
214
- .replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, "")
215
- .replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g, " ")
216
- const cps = Array.from(cleaned)
217
- return cps.length > 32 ? `${cps.slice(0, 31).join("")}~` : cleaned
218
- } catch {
219
- return ""
220
- }
221
- }
222
-
223
- function updatedAt(iso: string | undefined): string {
224
- if (!iso) return "?"
225
- try {
226
- const d = new Date(iso)
227
- return Number.isNaN(d.getTime()) ? "?" : d.toLocaleTimeString()
228
- } catch {
229
- return "?"
230
- }
231
- }
232
-
233
- // Code-point-safe column fit: truncate on code points (never splitting a
234
- // surrogate pair at a column boundary), mark a truncated cell with a trailing
235
- // "~" so clipping is VISIBLE (a silently clipped cell reads as two columns
236
- // colliding), then pad to the column width.
237
- function fit(s: string, w: number): string {
238
- const cps = Array.from(s)
239
- if (cps.length > w) return `${cps.slice(0, Math.max(0, w - 1)).join("")}~`
240
- return s.padEnd(w)
241
- }
242
-
243
- // ---------------------------------------------------------------------------
244
- // Measured contributors (TUI-only). The TUI data layer already holds the
245
- // session's live message list in-process, so the panel can char-count each
246
- // message part WITHOUT an LLM call and WITHOUT shipping message text anywhere.
247
- // Only the char count, the message ordinal and a kind string leave
248
- // measureContributors(); message text is read, measured and discarded. Tool
249
- // RESULT outputs are counted here — the server's state.json categories never
250
- // see them — which is what makes the residual `other` explicable.
251
- // ---------------------------------------------------------------------------
252
-
253
- type Contributor = {
254
- kind: string
255
- ref: string
256
- chars: number
257
- tokensEstimate: number
258
- // Tool name for the tool-args / tool-result buckets (e.g. "bash"), else "".
259
- tool: string
260
- // First characters of THIS bucket's text, control chars stripped. Rendered
261
- // on the user's own screen only — exportRedacted() never includes it.
262
- preview: string
263
- }
264
-
265
- // Local copy of the token-estimate core (estimateTokens in lib/estimate.js).
266
- // tui.tsx is loaded standalone and must NOT import lib/*, so the ~20-line
267
- // three-density unicode heuristic is duplicated on purpose: Cyrillic ~2.5,
268
- // CJK ~1.5, latin/ASCII ~4 chars/token. Heuristic, not a tokenizer.
269
- function estimateTokensLocal(s: string): number {
270
- let other = 0
271
- let cyr = 0
272
- let cjk = 0
273
- for (let i = 0; i < s.length; i++) {
274
- const cp = s.codePointAt(i) as number
275
- if (cp > 0xffff) i++ // astral pair: consume the low surrogate
276
- if (cp < 0x80) other++
277
- else if (
278
- (cp >= 0x0400 && cp <= 0x04ff) || // Cyrillic
279
- (cp >= 0x0500 && cp <= 0x052f) || // Cyrillic Supplement
280
- (cp >= 0x2de0 && cp <= 0x2dff) || // Cyrillic Extended-A
281
- (cp >= 0xa640 && cp <= 0xa69f) || // Cyrillic Extended-B
282
- (cp >= 0x1c80 && cp <= 0x1c8f) // Cyrillic Extended-C
283
- )
284
- cyr++
285
- else if (
286
- (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
287
- (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
288
- (cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
289
- (cp >= 0x3040 && cp <= 0x30ff) || // Hiragana + Katakana
290
- (cp >= 0xac00 && cp <= 0xd7af) || // Hangul Syllables
291
- (cp >= 0x3000 && cp <= 0x303f) || // CJK Symbols and Punctuation
292
- (cp >= 0xff00 && cp <= 0xffef) // Halfwidth/Fullwidth Forms
293
- )
294
- cjk++
295
- else other++
296
- }
297
- return Math.round(other / 4 + cyr / 2.5 + cjk / 1.5)
298
- }
299
-
300
- // Live message data is untrusted: coerce every field defensively, never throw.
301
- function asRecord(v: unknown): Record<string, unknown> | null {
302
- return v && typeof v === "object" ? (v as Record<string, unknown>) : null
303
- }
304
-
305
- // Char counts per (message, bucket). `asst.text` = assistant text,
306
- // `asst.rsn` = reasoning, `asst.tool` = tool CALL arguments (streaming raw
307
- // string or JSON.stringify of the input object), `asst.result` = tool RESULT
308
- // outputs (text content of completed/error tool states). Sorted descending by
309
- // estimated tokens.
310
- function measureContributors(messages: readonly unknown[]): Contributor[] {
311
- const out: Contributor[] = []
312
- // Single-line, bounded preview of the bucket text: control chars (incl.
313
- // CR/LF/TAB and line separators) collapse to spaces, bidi/zero-width stripped,
314
- // capped at 26 code points with a "~" tail. Local rendering only. An explicit
315
- // override (e.g. "" for raw tool-args JSON) suppresses the preview.
316
- const previewOf = (sample: string): string => {
317
- const cleaned = sample
318
- .replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, "")
319
- .replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]+/g, " ")
320
- .trim()
321
- const cps = Array.from(cleaned)
322
- return cps.length > 26 ? `${cps.slice(0, 25).join("")}~` : cleaned
323
- }
324
- const add = (
325
- kind: string,
326
- ref: string,
327
- sample: string,
328
- tool = "",
329
- previewOverride?: string,
330
- ) => {
331
- if (sample.length === 0) return
332
- out.push({
333
- kind,
334
- ref,
335
- chars: sample.length,
336
- tokensEstimate: estimateTokensLocal(sample),
337
- tool,
338
- preview: previewOverride ?? previewOf(sample),
339
- })
340
- }
341
- // Human clock ref: "21:04" from the message's own creation time — far more
342
- // actionable than an array ordinal. Falls back to "#N" when time is absent.
343
- const hhmm = (ms: unknown): string => {
344
- if (typeof ms !== "number" || !Number.isFinite(ms) || ms <= 0) return ""
345
- try {
346
- const d = new Date(ms)
347
- if (Number.isNaN(d.getTime())) return ""
348
- const pad = (x: number) => String(x).padStart(2, "0")
349
- return `${pad(d.getHours())}:${pad(d.getMinutes())}`
350
- } catch {
351
- return ""
352
- }
353
- }
354
- for (let i = 0; i < messages.length; i++) {
355
- const m = asRecord(messages[i])
356
- if (!m) continue
357
- const timeRec = asRecord(m.time)
358
- const ref = hhmm(timeRec?.created) || `#${i + 1}`
359
- const type = typeof m.type === "string" ? m.type : ""
360
- if (type === "user") {
361
- if (typeof m.text === "string") add("user.text", ref, m.text)
362
- continue
363
- }
364
- if (type !== "assistant") continue
365
- const content = Array.isArray(m.content) ? m.content : []
366
- let text = ""
367
- let rsn = ""
368
- for (const rawPart of content) {
369
- const part = asRecord(rawPart)
370
- if (!part) continue
371
- const pt = typeof part.type === "string" ? part.type : ""
372
- if (pt === "text") {
373
- if (typeof part.text === "string") text += part.text
374
- } else if (pt === "reasoning") {
375
- if (typeof part.text === "string") rsn += part.text
376
- } else if (pt === "tool") {
377
- const state = asRecord(part.state)
378
- if (!state) continue
379
- // Per-tool-part contributors: each tool call carries its own name and
380
- // measured size (one message can hold several tool calls). The raw
381
- // args JSON is measured but NOT previewed — the tool name is the
382
- // useful identifier; result text previews fine.
383
- const toolName =
384
- typeof part.name === "string" && part.name ? safeText(part.name) : ""
385
- let tool = ""
386
- let result = ""
387
- if (typeof state.input === "string") {
388
- tool += state.input
389
- } else if (state.input != null) {
390
- try {
391
- tool += JSON.stringify(state.input) ?? ""
392
- } catch {
393
- /* unstringifiable input: skip its chars */
394
- }
395
- }
396
- const c = state.content
397
- if (Array.isArray(c)) {
398
- for (const rawItem of c) {
399
- const item = asRecord(rawItem)
400
- if (item && item.type === "text" && typeof item.text === "string")
401
- result += item.text
402
- }
403
- }
404
- add("asst.tool", ref, tool, toolName, "") // raw args JSON: no preview
405
- add("asst.result", ref, result, toolName)
406
- }
407
- }
408
- add("asst.text", ref, text)
409
- add("asst.rsn", ref, rsn)
410
- }
411
- return out.sort((a, b) => b.tokensEstimate - a.tokensEstimate)
412
- }
413
-
414
- // Human line for one measured contributor: role in full words, the message's
415
- // own clock time, the bucket (with the tool name for tool buckets), the token
416
- // estimate, and a bounded single-line preview of THIS bucket only. Never
417
- // contains a full session id or file paths.
418
- function contributorLine(c: Contributor, rank: number): string {
419
- const [who, bucket] = c.kind.split(".")
420
- const role = who === "asst" ? "assistant" : who
421
- const what =
422
- bucket === "rsn"
423
- ? "reasoning"
424
- : bucket === "tool"
425
- ? "tool args"
426
- : bucket === "result"
427
- ? "tool result"
428
- : ""
429
- const tool = c.tool ? ` (${c.tool})` : ""
430
- const whatFull = what ? `${what}${tool}` : ""
431
- const head = `${rank}. ${role} ${c.ref}${whatFull ? ` ${whatFull}` : ""} \u2014 ${fmt(c.tokensEstimate)}`
432
- return c.preview ? `${head} "${c.preview}"` : head
433
- }
434
-
435
- // The TUI message data API is optional across host versions. Log a single hint
436
- // (never once per poll) when it is unavailable and hide the whole section.
437
- let warnedMessagesUnavailable = false
438
-
439
- function Sidebar(props: { entry: () => StateEntry | null }) {
440
- const context = usePlugin()
441
- const base = () => token(context?.theme?.text?.base, "white")
442
- const muted = () => token(context?.theme?.text?.muted, base())
443
-
444
- const e = () => props.entry()
445
-
446
- const denom = (): number | null => {
447
- const x = e()
448
- const d = x?.usable ?? x?.limit
449
- return typeof d === "number" && d > 0 ? d : null
450
- }
451
- const pct = (v: number): string => {
452
- const d = denom()
453
- return d ? `${Math.round((v / d) * 100)}%` : ""
454
- }
455
- const line = (label: string, v: unknown): string => {
456
- const n = num(v)
457
- const l = (label + " ".repeat(LABEL_WIDTH)).slice(0, LABEL_WIDTH)
458
- const p = pct(n)
459
- return `${l}${fmt(n).padStart(7)}${p ? ` ${p.padStart(4)}` : ""}`
460
- }
461
- const header = (): string => {
462
- const x = e()
463
- if (!x) return ""
464
- const d = denom()
465
- return `${x.model ?? "?"} · ${fmt(num(x.ctx))}${
466
- d ? ` / ${fmt(d)}` : ""
467
- }${d ? ` (${pct(num(x.ctx))})` : ""}`
468
- }
469
-
470
- return (
471
- <box flexDirection="column">
472
- <text fg={base()}>{"Context"}</text>
473
- <Show when={e()} fallback={<text fg={muted()}>{" no data yet"}</text>}>
474
- <text fg={base()}>{header()}</text>
475
- <text fg={muted()}>{line("user", e()?.categories?.user)}</text>
476
- <text fg={muted()}>{line("assistant", e()?.categories?.assistant)}</text>
477
- <text fg={muted()}>{line("reasoning", e()?.categories?.reasoning)}</text>
478
- <text fg={muted()}>{line("tool args", e()?.categories?.toolArgs)}</text>
479
- <text fg={muted()}>
480
- {e()?.categories?.system == null
481
- ? `${"system".padEnd(LABEL_WIDTH)} n/a`
482
- : line("system", e()?.categories?.system)}
483
- </text>
484
- <text fg={muted()}>
485
- {e()?.categories?.toolSchemas == null
486
- ? `${"tool schemas".padEnd(LABEL_WIDTH)} n/a`
487
- : line("tool schemas", e()?.categories?.toolSchemas)}
488
- </text>
489
- <text fg={muted()}>{line("other", e()?.categories?.other)}</text>
490
- <text fg={muted()}>{`updated ${updatedAt(e()?.updatedAt)}`}</text>
491
- </Show>
492
- </box>
493
- )
494
- }
495
-
496
- // Host-owned session.panel input ({ name, sessionID, width, presentation,
497
- // focused, focus(), close(), toggleFullscreen() }). Typed structurally instead
498
- // of imported so this file stays independent of the host type package; the host
499
- // resolves the panel API at runtime.
500
- type PanelInput = {
501
- readonly name: string
502
- readonly sessionID: string
503
- readonly width: number
504
- readonly presentation: "panel" | "fullscreen"
505
- readonly focused: boolean
506
- readonly focus: () => void
507
- readonly close: () => void
508
- readonly toggleFullscreen: () => void
509
- }
510
-
511
- // Panel column layout (monospace), derived from the host-provided panel width
512
- // so cells never collide on narrow terminals. Gaps between columns keep a
513
- // truncated cell visually separated from its neighbour.
514
- const GUTTER = 2
515
- const CTX_WIDTH = 12
516
-
517
- function panelLayout(panelWidth: number): {
518
- roleW: number
519
- modelW: number
520
- ctxW: number
521
- updW: number
522
- gutter: string
523
- } {
524
- const w = num(panelWidth) > 0 ? num(panelWidth) : 60
525
- const ctxW = CTX_WIDTH
526
- const updW = Math.max(5, Math.min(8, w - ctxW - 3 * GUTTER - 24))
527
- const roleW = Math.max(12, Math.min(20, Math.floor(w * 0.3)))
528
- const modelW = Math.max(10, w - roleW - ctxW - updW - 3 * GUTTER)
529
- return { roleW, modelW, ctxW, updW, gutter: " ".repeat(GUTTER) }
530
- }
531
-
532
- // Per-row denominator with the same fallback cascade as the server table:
533
- // usable, else limit, else none.
534
- function panelDenom(e: StateEntry | undefined): number {
535
- const u = num(e?.usable)
536
- if (u > 0) return u
537
- const l = num(e?.limit)
538
- return l > 0 ? l : 0
539
- }
540
-
541
- function panelRole(e: StateEntry, isMain: boolean): string {
542
- if (isMain) return "main"
543
- return e.agent ? `sub:${safeText(e.agent)}` : "sub"
544
- }
545
-
546
- // Full session panel: header + one row per session in the family (root plus
547
- // subagent descendants). It reads the same state.json bridge as the sidebar via
548
- // the shared 1 s tick, so both surfaces refresh on ONE poll. `panel.sessionID`
549
- // selects the root; `cx` opens the panel (registered from the "app" slot below).
550
- function BreakdownPanel(props: {
551
- panel: PanelInput
552
- snapshot: () => Record<string, StateEntry>
553
- refresh: () => void
554
- }) {
555
- const context = usePlugin()
556
- const base = () => token(context?.theme?.text?.base, "white")
557
- const muted = () => token(context?.theme?.text?.muted, base())
558
-
559
- // Panel-local keys. The layer is owned by this component and disposed with it.
560
- // mode "global" + an explicit focused check is the portable fallback when no
561
- // target renderable is available: the keys act only while the panel owns input.
562
- // The capability guard keeps an older host (no keymap API) from throwing.
563
- const keymap = context?.keymap
564
- if (keymap && typeof keymap.layer === "function") {
565
- keymap.layer(() => ({
566
- mode: "global",
567
- priority: -1,
568
- commands: [
569
- {
570
- id: "context-indicator.breakdown.close",
571
- title: "Close the context breakdown panel",
572
- bind: "escape",
573
- enabled: () => props.panel.focused,
574
- run: () => {
575
- if (props.panel.focused) props.panel.close()
576
- },
577
- },
578
- {
579
- id: "context-indicator.breakdown.refresh",
580
- title: "Refresh the context breakdown panel",
581
- bind: "r",
582
- enabled: () => props.panel.focused,
583
- run: () => {
584
- if (props.panel.focused) props.refresh()
585
- },
586
- },
587
- {
588
- id: "context-indicator.breakdown.export",
589
- title: "Export a redacted breakdown snapshot",
590
- bind: "e",
591
- enabled: () => props.panel.focused,
592
- run: () => {
593
- if (props.panel.focused) exportRedacted()
594
- },
595
- },
596
- ],
597
- }))
598
- }
599
-
600
- // One snapshot read per poll tick (props.snapshot tracks the shared tick), so
601
- // the memo recomputes together with the sidebar instead of polling separately.
602
- const data = createMemo(() => {
603
- const snap = props.snapshot()
604
- return {
605
- root: snap[props.panel.sessionID],
606
- rows: collectFamily(snap, props.panel.sessionID),
607
- }
608
- })
609
-
610
- // Live message list from the TUI data layer (in-process, no LLM call). The
611
- // shared `props.snapshot()` tick is read FIRST so this re-reads every poll.
612
- // `available: false` means the API is absent/errored -> the WHOLE measured
613
- // section is hidden (and a one-time hint was logged).
614
- const messages = createMemo<{ available: boolean; list: unknown[] }>(() => {
615
- props.snapshot()
616
- try {
617
- const api = context?.data?.session?.message
618
- if (!api || typeof api.list !== "function") {
619
- if (!warnedMessagesUnavailable) {
620
- warnedMessagesUnavailable = true
621
- console.error(
622
- "[context-indicator] TUI message data API unavailable; " +
623
- "measured contributors section hidden",
624
- )
625
- }
626
- return { available: false, list: [] }
627
- }
628
- const v = api.list(props.panel.sessionID)
629
- return { available: true, list: Array.isArray(v) ? v : [] }
630
- } catch {
631
- if (!warnedMessagesUnavailable) {
632
- warnedMessagesUnavailable = true
633
- console.error(
634
- "[context-indicator] TUI message read failed; " +
635
- "measured contributors section hidden",
636
- )
637
- }
638
- return { available: false, list: [] }
639
- }
640
- })
641
-
642
- const contributors = createMemo<Contributor[]>(() => {
643
- const m = messages()
644
- if (!m.available) return []
645
- try {
646
- return measureContributors(m.list)
647
- } catch {
648
- return []
649
- }
650
- })
651
-
652
- // Redacted export (key `e`). No message text, no full session id, no tool
653
- // output content ever leaves this function. Never throws: a failure logs and
654
- // shows an error toast instead.
655
- function exportRedacted(): void {
656
- let exportedName: string | null = null
657
- try {
658
- const r = data().root
659
- const id8 = String(props.panel.sessionID).slice(0, 8)
660
- const now = new Date()
661
- const pad = (n: number) => String(n).padStart(2, "0")
662
- const stamp =
663
- `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
664
- `-${pad(now.getHours())}${pad(now.getMinutes())}` +
665
- `${pad(now.getSeconds())}${String(now.getMilliseconds()).padStart(3, "0")}`
666
- const payload = {
667
- generatedAt: now.toISOString(),
668
- session: {
669
- id: `${id8}...`,
670
- model: safeText(r?.model ?? ""),
671
- providerID: safeText(r?.providerID ?? ""),
672
- },
673
- family: data().rows.map((e, i) => {
674
- const d = panelDenom(e)
675
- return {
676
- // Positional main-row detection (index 0), same as the render —
677
- // state.json is untrusted input and a legacy entry may lack
678
- // `sessionID`, which would mislabel main as sub.
679
- role: panelRole(e, i === 0),
680
- model: safeText(e.model ?? ""),
681
- ctx: num(e.ctx),
682
- usable: e.usable == null ? null : num(e.usable),
683
- limit: e.limit == null ? null : num(e.limit),
684
- pct: d > 0 ? Math.round((num(e.ctx) / d) * 100) : null,
685
- updated: safeText(e.updatedAt ?? ""),
686
- }
687
- }),
688
- contributors: contributors().map((c) => ({
689
- kind: c.kind,
690
- tool: c.tool || null,
691
- ref: c.ref,
692
- chars: c.chars,
693
- tokensEstimate: c.tokensEstimate,
694
- })),
695
- }
696
- const dir = join(tmpdir(), "opencode-context-indicator", "exports")
697
- const file = join(dir, `context-${id8}-${stamp}.json`)
698
- mkdirSync(dir, { recursive: true })
699
- writeFileSync(file, JSON.stringify(payload, null, 2), "utf8")
700
- exportedName = basename(file)
701
- } catch (err) {
702
- console.error("[context-indicator] redacted export failed:", err)
703
- try {
704
- context?.ui?.toast?.show({
705
- message: "redacted export failed",
706
- variant: "error",
707
- })
708
- } catch {
709
- /* toast is best-effort */
710
- }
711
- return
712
- }
713
- // Outside the write try: a toast failure must not report the export as
714
- // failed when the file is already on disk.
715
- try {
716
- context?.ui?.toast?.show({
717
- message: `exported ${exportedName}`,
718
- variant: "success",
719
- })
720
- } catch {
721
- /* toast is best-effort */
722
- }
723
- }
724
-
725
- const header = (): string => {
726
- const r = data().root
727
- if (!r) return "Context breakdown"
728
- const model = safeText(r.model && r.model !== "unknown" ? r.model : "model?")
729
- const ctx = fmt(num(r.ctx))
730
- const d = panelDenom(r)
731
- return d > 0
732
- ? `Context breakdown \u2014 ${model} ${ctx}/${fmt(d)} (${Math.round(
733
- (num(r.ctx) / d) * 100,
734
- )}%)`
735
- : `Context breakdown \u2014 ${model} ${ctx}`
736
- }
737
-
738
- const layout = (): {
739
- roleW: number
740
- modelW: number
741
- ctxW: number
742
- updW: number
743
- gutter: string
744
- } => panelLayout(props.panel.width)
745
-
746
- const columns = (): string => {
747
- const L = layout()
748
- return (
749
- fit("role", L.roleW) +
750
- L.gutter +
751
- fit("model", L.modelW) +
752
- L.gutter +
753
- fit("ctx", L.ctxW) +
754
- L.gutter +
755
- "updated"
756
- )
757
- }
758
-
759
- const row = (e: StateEntry, isMain: boolean): string => {
760
- const model = safeText(e.model && e.model !== "unknown" ? e.model : "model?")
761
- const d = panelDenom(e)
762
- const ctx =
763
- d > 0
764
- ? `${fmt(num(e.ctx))} (${Math.round((num(e.ctx) / d) * 100)}%)`
765
- : fmt(num(e.ctx))
766
- const L = layout()
767
- return (
768
- fit(panelRole(e, isMain), L.roleW) +
769
- L.gutter +
770
- fit(model, L.modelW) +
771
- L.gutter +
772
- fit(ctx, L.ctxW) +
773
- L.gutter +
774
- updatedAt(e.updatedAt)
775
- )
776
- }
777
-
778
- return (
779
- <box flexDirection="column">
780
- <text fg={base()}>{header()}</text>
781
- <Show
782
- when={data().root}
783
- fallback={<text fg={muted()}>{" no data yet"}</text>}
784
- >
785
- <text fg={muted()}>{columns()}</text>
786
- {data().rows.map((e, i) => (
787
- <text fg={muted()}>{row(e, i === 0)}</text>
788
- ))}
789
- </Show>
790
- <Show when={messages().available}>
791
- <text fg={base()}>{"largest contributors (measured)"}</text>
792
- <Show
793
- when={contributors().length > 0}
794
- fallback={<text fg={muted()}>{" no messages yet"}</text>}
795
- >
796
- {contributors()
797
- .slice(0, 5)
798
- .map((c, i) => (
799
- <text fg={muted()}>{contributorLine(c, i + 1)}</text>
800
- ))}
801
- </Show>
802
- <text fg={muted()}>
803
- {
804
- "measured != server categories (tool outputs counted here; server categories exclude them)"
805
- }
806
- </text>
807
- </Show>
808
- </box>
809
- )
810
- }
811
-
812
69
  export default Plugin.define({
813
70
  id: "context-indicator.tui",
814
71
  setup(context) {
@@ -895,6 +152,31 @@ export default Plugin.define({
895
152
  },
896
153
  })
897
154
 
155
+ // One-time live-surface marker. The e2e Check A asserts this line from the
156
+ // isolated instance's private log: it proves the ./tui entry not only
157
+ // loaded but REGISTERED its surfaces — a host-transform failure of this
158
+ // file would otherwise die silently between "loading plugin" and here.
159
+ console.log(
160
+ "[context-indicator] live TUI surfaces registered (sidebar + session.panel + keymap)",
161
+ )
162
+
163
+ // E2E instrumentation (only when test/e2e-tui.mjs sets the marker env):
164
+ // same proof as the console line above, but via a FILE — the TUI captures
165
+ // console output, so the log channel cannot carry it. Production runs
166
+ // never set the env and never write the stamp.
167
+ if (process.env.OPENCODE_CONTEXT_INDICATOR_TUI_MARKER) {
168
+ try {
169
+ mkdirSync(join(tmpdir(), "opencode-context-indicator"), { recursive: true })
170
+ writeFileSync(
171
+ join(tmpdir(), "opencode-context-indicator", "tui-live.stamp"),
172
+ new Date().toISOString(),
173
+ "utf8",
174
+ )
175
+ } catch {
176
+ /* marker is best-effort test instrumentation */
177
+ }
178
+ }
179
+
898
180
  return () => {
899
181
  clearInterval(timer)
900
182
  for (const unregister of [