opencode-context-indicator 1.2.1 → 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/README.md +27 -9
- package/lib/tui-data.d.ts +45 -0
- package/lib/tui-data.js +165 -0
- package/lib/tui-panel.tsx +329 -0
- package/lib/tui-sidebar.tsx +72 -0
- package/lib/tui-theme.js +17 -0
- package/package.json +6 -3
- package/tui.tsx +47 -556
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ For every served request the plugin estimates how the context window is split:
|
|
|
56
56
|
carries a `compaction.buffer` configured in its own settings, the plugin
|
|
57
57
|
cannot read that value (it is not exposed to plugins), so the default
|
|
58
58
|
reserve formula is used — set `OPENCODE_CONTEXT_INDICATOR_COMPACT_BUFFER`
|
|
59
|
-
to override the reserve for testing or custom setups (
|
|
59
|
+
to override the reserve for testing or custom setups (non-negative integer <=
|
|
60
60
|
1e9; invalid values are silently ignored).
|
|
61
61
|
|
|
62
62
|
### Toast (V1 only)
|
|
@@ -505,9 +505,10 @@ caller.
|
|
|
505
505
|
### Running tests
|
|
506
506
|
|
|
507
507
|
```bash
|
|
508
|
-
npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–
|
|
508
|
+
npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–T16i unit checks
|
|
509
509
|
npm run test:e2e # full E2E: isolated plugin load + one "Say OK" round-trip
|
|
510
510
|
npm run test:e2e:quick # E2E load-only, no LLM call
|
|
511
|
+
npm run test:drift # OpenTUI pin vs the latest RELEASED opencode (also in CI)
|
|
511
512
|
```
|
|
512
513
|
|
|
513
514
|
`prepublishOnly` runs **both** gates automatically on `npm publish`
|
|
@@ -524,8 +525,8 @@ STEP 80 s, ~3 min total.
|
|
|
524
525
|
|---|---|---|
|
|
525
526
|
| (a) tarball composition | `package.json` `files[]` expands to the base files plus every `lib/*` module (count asserted dynamically) | no test/node_modules/tsconfig in the tarball |
|
|
526
527
|
| (b) pragma regression | esbuild `--jsx=automatic` → `@opentui/solid/jsx-runtime` present, `react/jsx-runtime` absent | a removed/broken pragma kills the sidebar on npm install |
|
|
527
|
-
| (c) resolve chain | replica install
|
|
528
|
-
| (d) unit harness T1–
|
|
528
|
+
| (c) resolve chain | replica install: host-bridged + peer packages resolve; local tui.tsx relative imports resolve via node from the entry (resolve-bridge.cjs) | broken peer/optional dep or a bad local import silently breaks the sidebar |
|
|
529
|
+
| (d) unit harness T1–T16i | limit/agent/state fix assertions plus measureContributors and redacted-export checks (34 total) | regressions in modelLimits, writeStateFile, hydrate, contributors, export |
|
|
529
530
|
| (e) corporate-identifier scan | no forbidden identifiers in the published surface + test tree | compliance |
|
|
530
531
|
| (f) non-English scan | no non-ASCII letters (`\p{L}` above U+007F) in any repo file | English-only source rule |
|
|
531
532
|
|
|
@@ -533,7 +534,7 @@ STEP 80 s, ~3 min total.
|
|
|
533
534
|
|
|
534
535
|
| Check | What | Why |
|
|
535
536
|
|---|---|---|
|
|
536
|
-
| A — plugin load | spins a private `opencode --standalone` PTY
|
|
537
|
+
| A — plugin load | spins a private `opencode --standalone` PTY against an **isolated global config** (`OPENCODE_CONFIG_DIR` → `%TEMP%/oci-e2e-config`, sole plugin entry = this checkout's `file://` URL), then asserts **from the host opencode log** (atomic lines, immune to PTY interleaving) that the plugin loaded and its `entrypoint` is **this checkout's `index.js`** — both fail-closed | catches a plugin that no longer imports cleanly, or an isolated config the host did not honor |
|
|
537
538
|
| B — live update | one `opencode run "Say OK" --standalone` round-trip; asserts the LLM answered **and** the isolated `state.json` gained a session with `ctx > 0` | catches a regression in the `session.idle` → `writeStateFile` path |
|
|
538
539
|
|
|
539
540
|
The E2E is fully **non-invasive** to your running OpenCode Desktop:
|
|
@@ -542,7 +543,14 @@ The E2E is fully **non-invasive** to your running OpenCode Desktop:
|
|
|
542
543
|
- `OPENCODE_DB` points at a throwaway DB in `%TEMP%` (your sessions are untouched);
|
|
543
544
|
- `OPENCODE_CONTEXT_INDICATOR_STATE_FILE` points at a throwaway state file in `%TEMP%`
|
|
544
545
|
— the live `opencode-context-indicator-state.json` is never read or written;
|
|
546
|
+
- `OPENCODE_CONFIG_DIR` points at a throwaway global config written by the script
|
|
547
|
+
(plugins: this checkout; `update: "disable"`) **for Check A only** — the load
|
|
548
|
+
source is deterministic and fail-closed instead of following your live config;
|
|
549
|
+
Check B uses your real global config read-only (the round-trip needs the
|
|
550
|
+
provider/model defined there). Your real config is never modified;
|
|
545
551
|
- `OPENCODE_DISABLE_PROJECT_CONFIG=1` avoids project-level config;
|
|
552
|
+
- any `opencode-cli` instance the test itself spawned is force-killed after each
|
|
553
|
+
check (`serve --service` — your Desktop background service — is never touched);
|
|
546
554
|
- exactly **one** minimal LLM call (`Say OK`) is made, in the isolated session.
|
|
547
555
|
|
|
548
556
|
The script prints `[e2e] running against ISOLATED opencode instance — your running
|
|
@@ -555,10 +563,20 @@ model.
|
|
|
555
563
|
|
|
556
564
|
### CI
|
|
557
565
|
|
|
558
|
-
`.github/workflows/ci.yml` runs
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
566
|
+
`.github/workflows/ci.yml` runs on every push/PR to `main`, on `workflow_dispatch`,
|
|
567
|
+
and on a daily schedule (GitHub auto-parks scheduled workflows after 60 days of
|
|
568
|
+
repo inactivity — a push or manual run re-enables them):
|
|
569
|
+
|
|
570
|
+
1. `npx --no-install tsc --noEmit` — typecheck of the TUI entry (`tui.tsx` + its
|
|
571
|
+
`lib/*` imports);
|
|
572
|
+
2. `test/prepublish.mjs` — the full fast gate (a)–(f), including the standalone
|
|
573
|
+
esbuild transpile check (pragmas, no React);
|
|
574
|
+
3. `test/drift-watch.mjs` — the OpenTUI pin must match the OpenTUI catalog of the
|
|
575
|
+
**latest released** opencode; red CI means "bump the pin before the next
|
|
576
|
+
release".
|
|
577
|
+
|
|
578
|
+
The Node-pty E2E is not run in CI (requires the OpenCode binary) and is skipped
|
|
579
|
+
gracefully when the CLI is absent (`[SKIP] opencode CLI not found`).
|
|
562
580
|
|
|
563
581
|
---
|
|
564
582
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// lib/tui-data.d.ts
|
|
2
|
+
//
|
|
3
|
+
// Types for lib/tui-data.js (the TUI data layer). state.json is written by
|
|
4
|
+
// writeStateFile() in index.js and is untrusted input — every consumer treats
|
|
5
|
+
// the fields as optional.
|
|
6
|
+
|
|
7
|
+
export interface Categories {
|
|
8
|
+
user?: number
|
|
9
|
+
assistant?: number
|
|
10
|
+
reasoning?: number
|
|
11
|
+
toolArgs?: number
|
|
12
|
+
system?: number | null
|
|
13
|
+
toolSchemas?: number | null
|
|
14
|
+
other?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface StateEntry {
|
|
18
|
+
sessionID?: string
|
|
19
|
+
parentID?: string | null
|
|
20
|
+
role?: string
|
|
21
|
+
agent?: string | null
|
|
22
|
+
model?: string
|
|
23
|
+
providerID?: string
|
|
24
|
+
ctx?: number
|
|
25
|
+
input?: number
|
|
26
|
+
usable?: number | null
|
|
27
|
+
reserve?: number | null
|
|
28
|
+
limit?: number | null
|
|
29
|
+
reasoning?: number
|
|
30
|
+
categories?: Categories
|
|
31
|
+
updatedAt?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export declare const STATE_FILE: string
|
|
35
|
+
export declare const POLL_MS: number
|
|
36
|
+
export declare function isNodeModulesInstall(): boolean
|
|
37
|
+
export declare function readSnapshot(): Record<string, StateEntry>
|
|
38
|
+
export declare function collectFamily(
|
|
39
|
+
snapshot: Record<string, StateEntry>,
|
|
40
|
+
rootID: string,
|
|
41
|
+
): StateEntry[]
|
|
42
|
+
export declare function fmt(n: number): string
|
|
43
|
+
export declare function num(v: unknown): number
|
|
44
|
+
export declare function fit(s: string, w: number): string
|
|
45
|
+
export declare function updatedAt(iso: string | undefined): string
|
package/lib/tui-data.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// lib/tui-data.js
|
|
2
|
+
//
|
|
3
|
+
// Data layer for the TUI entry (tui.tsx): the state.json bridge (readSnapshot /
|
|
4
|
+
// isEmptyEntry / collectFamily), the shared formatting helpers (fmt / num / fit
|
|
5
|
+
// / updatedAt) and the npm-install detection. Plain JS with JSDoc — same shape
|
|
6
|
+
// as lib/contributors.js — so tui.tsx stays a thin JSX/setup shell and the node
|
|
7
|
+
// tooling (resolve gate, future unit tests) can import it without a transform.
|
|
8
|
+
//
|
|
9
|
+
// These helpers must stay free of solid-js / @opentui imports: the TUI host
|
|
10
|
+
// bridges those modules only for the JSX entry, and keeping this file pure
|
|
11
|
+
// means it also loads anywhere node does.
|
|
12
|
+
//
|
|
13
|
+
// state.json shape (written by writeStateFile() in index.js, untrusted input):
|
|
14
|
+
// { [sessionID]: { sessionID, parentID, role, agent, model, providerID,
|
|
15
|
+
// ctx, input, usable, reserve, limit, reasoning, categories{...}, updatedAt } }
|
|
16
|
+
// The authoritative TS mirror of the entry shape is the `StateEntry` interface
|
|
17
|
+
// in lib/tui-data.d.ts — keep the two structurally compatible.
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
export const STATE_FILE =
|
|
25
|
+
process.env.OPENCODE_CONTEXT_INDICATOR_STATE_FILE ||
|
|
26
|
+
join(tmpdir(), "opencode-context-indicator-state.json");
|
|
27
|
+
|
|
28
|
+
export const POLL_MS = 1000;
|
|
29
|
+
|
|
30
|
+
// True when this package was loaded from an npm install, i.e. its own file
|
|
31
|
+
// path contains a `node_modules` segment (OpenCode's TUI loader skips the host
|
|
32
|
+
// Solid transform for such paths → slots mount once but never live-update; see
|
|
33
|
+
// the Graceful mode note in tui.tsx). Normalised to forward slashes for
|
|
34
|
+
// Windows paths. ANY file of the package yields the same answer, so checking
|
|
35
|
+
// from lib/ is equivalent to checking from the tui.tsx entry.
|
|
36
|
+
export function isNodeModulesInstall() {
|
|
37
|
+
try {
|
|
38
|
+
const selfPath = fileURLToPath(import.meta.url).replace(/\\/g, "/").toLowerCase()
|
|
39
|
+
return selfPath.includes("/node_modules/")
|
|
40
|
+
} catch {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Reserved object keys must never be treated as session ids: state.json is
|
|
46
|
+
// untrusted input (structural mirror of isSafeSessionKey() in lib/estimate.js,
|
|
47
|
+
// including the string/length requirement).
|
|
48
|
+
function isSafeKey(k) {
|
|
49
|
+
return (
|
|
50
|
+
typeof k === "string" &&
|
|
51
|
+
k.length > 0 &&
|
|
52
|
+
k !== "__proto__" &&
|
|
53
|
+
k !== "constructor" &&
|
|
54
|
+
k !== "prototype"
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Whole state.json snapshot ({ sessionID: entry }) or {} on any error. The
|
|
59
|
+
// sidebar resolves one entry from it and the breakdown panel walks the
|
|
60
|
+
// parentID family over it. Never throws — a missing / empty / corrupt
|
|
61
|
+
// file simply yields {}.
|
|
62
|
+
export function readSnapshot() {
|
|
63
|
+
try {
|
|
64
|
+
const raw = readFileSync(STATE_FILE, "utf8")
|
|
65
|
+
const all = JSON.parse(raw)
|
|
66
|
+
if (!all || typeof all !== "object" || Array.isArray(all)) return {}
|
|
67
|
+
// Rebuild into a fresh plain object: drops prototype-poisoning keys
|
|
68
|
+
// (__proto__ / constructor / prototype) and non-object entries in one pass.
|
|
69
|
+
const clean = {}
|
|
70
|
+
for (const [k, v] of Object.entries(all)) {
|
|
71
|
+
if (isSafeKey(k) && v && typeof v === "object") clean[k] = v
|
|
72
|
+
}
|
|
73
|
+
return clean
|
|
74
|
+
} catch {
|
|
75
|
+
return {}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// True when an entry carries no measured tokens at all (a registered session
|
|
80
|
+
// that never served a step). Mirrors isEmptyStateEntry() in lib/commands.js:
|
|
81
|
+
// `categories` is always persisted, so the emptiness test SUMS its fields —
|
|
82
|
+
// a missing categories object is not the only trigger. `system` / `toolSchemas`
|
|
83
|
+
// may legitimately be null, hence the num() coercion.
|
|
84
|
+
function isEmptyEntry(e) {
|
|
85
|
+
const c = e?.categories
|
|
86
|
+
const sum =
|
|
87
|
+
num(c?.user) +
|
|
88
|
+
num(c?.assistant) +
|
|
89
|
+
num(c?.reasoning) +
|
|
90
|
+
num(c?.toolArgs) +
|
|
91
|
+
num(c?.system) +
|
|
92
|
+
num(c?.toolSchemas) +
|
|
93
|
+
num(c?.other)
|
|
94
|
+
return sum === 0 && num(e?.ctx) === 0 && num(e?.input) === 0
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The panel row set: the root entry (panel.sessionID) plus every entry whose
|
|
98
|
+
// parentID chain reaches it, breadth-first. A small local walk over the snapshot
|
|
99
|
+
// — the server's collectDescendantSessionIDs lives in lib/commands.js and is NOT
|
|
100
|
+
// imported here (the TUI entry stays independent of the server code by design). A
|
|
101
|
+
// phantom node (no tokens, see isEmptyEntry) is not emitted, but traversal still
|
|
102
|
+
// descends through it so real grandchildren behind it are found. `seen` guards
|
|
103
|
+
// against cycles (parentID loops) and double-listing.
|
|
104
|
+
export function collectFamily(snapshot, rootID) {
|
|
105
|
+
const out = []
|
|
106
|
+
// hasOwnProperty guard: a host-supplied rootID of "__proto__"/"constructor"
|
|
107
|
+
// must never resolve through the prototype chain into a phantom row.
|
|
108
|
+
const root = Object.prototype.hasOwnProperty.call(snapshot, rootID)
|
|
109
|
+
? snapshot[rootID]
|
|
110
|
+
: undefined
|
|
111
|
+
if (root && typeof root === "object") out.push(root)
|
|
112
|
+
const seen = new Set([rootID])
|
|
113
|
+
const queue = [rootID]
|
|
114
|
+
// Index the snapshot by parentID once (O(n)) instead of an O(n) scan per node.
|
|
115
|
+
const byParent = new Map()
|
|
116
|
+
for (const [id, e] of Object.entries(snapshot)) {
|
|
117
|
+
const pid = e?.parentID
|
|
118
|
+
if (!pid || typeof pid !== "string") continue
|
|
119
|
+
const kids = byParent.get(pid)
|
|
120
|
+
if (kids) kids.push(id)
|
|
121
|
+
else byParent.set(pid, [id])
|
|
122
|
+
}
|
|
123
|
+
while (queue.length > 0) {
|
|
124
|
+
const cur = queue.shift()
|
|
125
|
+
const kids = byParent.get(cur)
|
|
126
|
+
if (!kids) continue
|
|
127
|
+
for (const id of kids) {
|
|
128
|
+
if (!id || seen.has(id)) continue
|
|
129
|
+
seen.add(id)
|
|
130
|
+
const e = snapshot[id]
|
|
131
|
+
if (e && !isEmptyEntry(e)) out.push(e)
|
|
132
|
+
queue.push(id) // descend through phantom parents too
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return out
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function fmt(n) {
|
|
139
|
+
if (!Number.isFinite(n)) return "0"
|
|
140
|
+
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function num(v) {
|
|
144
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Code-point-safe column fit: truncate on code points (never splitting a
|
|
148
|
+
// surrogate pair at a column boundary), mark a truncated cell with a trailing
|
|
149
|
+
// "~" so clipping is VISIBLE (a silently clipped cell reads as two columns
|
|
150
|
+
// colliding), then pad to the column width.
|
|
151
|
+
export function fit(s, w) {
|
|
152
|
+
const cps = Array.from(s)
|
|
153
|
+
if (cps.length > w) return `${cps.slice(0, Math.max(0, w - 1)).join("")}~`
|
|
154
|
+
return s.padEnd(w)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function updatedAt(iso) {
|
|
158
|
+
if (!iso) return "?"
|
|
159
|
+
try {
|
|
160
|
+
const d = new Date(iso)
|
|
161
|
+
return Number.isNaN(d.getTime()) ? "?" : d.toLocaleTimeString()
|
|
162
|
+
} catch {
|
|
163
|
+
return "?"
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
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
|
+
|
|
62
|
+
function panelLayout(panelWidth: number): {
|
|
63
|
+
roleW: number
|
|
64
|
+
modelW: number
|
|
65
|
+
ctxW: number
|
|
66
|
+
updW: number
|
|
67
|
+
gutter: string
|
|
68
|
+
} {
|
|
69
|
+
const w = num(panelWidth) > 0 ? num(panelWidth) : 60
|
|
70
|
+
const ctxW = CTX_WIDTH
|
|
71
|
+
const updW = Math.max(5, Math.min(8, w - ctxW - 3 * GUTTER - 24))
|
|
72
|
+
const roleW = Math.max(12, Math.min(20, Math.floor(w * 0.3)))
|
|
73
|
+
const modelW = Math.max(10, w - roleW - ctxW - updW - 3 * GUTTER)
|
|
74
|
+
return { roleW, modelW, ctxW, updW, gutter: " ".repeat(GUTTER) }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Full session panel: header + one row per session in the family (root plus
|
|
78
|
+
// 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. `panel.sessionID`
|
|
80
|
+
// selects the root; `cx` opens the panel (registered from the "app" slot in
|
|
81
|
+
// tui.tsx).
|
|
82
|
+
export function BreakdownPanel(props: {
|
|
83
|
+
panel: PanelInput
|
|
84
|
+
snapshot: () => Record<string, StateEntry>
|
|
85
|
+
refresh: () => void
|
|
86
|
+
}) {
|
|
87
|
+
const context = usePlugin()
|
|
88
|
+
const base = () => token(context?.theme?.text?.base, "white")
|
|
89
|
+
const muted = () => token(context?.theme?.text?.muted, base())
|
|
90
|
+
|
|
91
|
+
// Panel-local keys. The layer is owned by this component and disposed with it.
|
|
92
|
+
// mode "global" + an explicit focused check is the portable fallback when no
|
|
93
|
+
// target renderable is available: the keys act only while the panel owns input.
|
|
94
|
+
// The capability guard keeps an older host (no keymap API) from throwing.
|
|
95
|
+
const keymap = context?.keymap
|
|
96
|
+
if (keymap && typeof keymap.layer === "function") {
|
|
97
|
+
keymap.layer(() => ({
|
|
98
|
+
mode: "global",
|
|
99
|
+
priority: -1,
|
|
100
|
+
commands: [
|
|
101
|
+
{
|
|
102
|
+
id: "context-indicator.breakdown.close",
|
|
103
|
+
title: "Close the context breakdown panel",
|
|
104
|
+
bind: "escape",
|
|
105
|
+
enabled: () => props.panel.focused,
|
|
106
|
+
run: () => {
|
|
107
|
+
if (props.panel.focused) props.panel.close()
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: "context-indicator.breakdown.refresh",
|
|
112
|
+
title: "Refresh the context breakdown panel",
|
|
113
|
+
bind: "r",
|
|
114
|
+
enabled: () => props.panel.focused,
|
|
115
|
+
run: () => {
|
|
116
|
+
if (props.panel.focused) props.refresh()
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: "context-indicator.breakdown.export",
|
|
121
|
+
title: "Export a redacted breakdown snapshot",
|
|
122
|
+
bind: "e",
|
|
123
|
+
enabled: () => props.panel.focused,
|
|
124
|
+
run: () => {
|
|
125
|
+
if (props.panel.focused) exportRedacted()
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
}))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// One snapshot read per poll tick (props.snapshot tracks the shared tick), so
|
|
133
|
+
// the memo recomputes together with the sidebar instead of polling separately.
|
|
134
|
+
const data = createMemo(() => {
|
|
135
|
+
const snap = props.snapshot()
|
|
136
|
+
return {
|
|
137
|
+
root: snap[props.panel.sessionID],
|
|
138
|
+
rows: collectFamily(snap, props.panel.sessionID),
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// Live message list from the TUI data layer (in-process, no LLM call). The
|
|
143
|
+
// shared `props.snapshot()` tick is read FIRST so this re-reads every poll.
|
|
144
|
+
// `available: false` means the API is absent/errored -> the WHOLE measured
|
|
145
|
+
// section is hidden (and a one-time hint was logged).
|
|
146
|
+
const messages = createMemo<{ available: boolean; list: unknown[] }>(() => {
|
|
147
|
+
props.snapshot()
|
|
148
|
+
try {
|
|
149
|
+
const api = context?.data?.session?.message
|
|
150
|
+
if (!api || typeof api.list !== "function") {
|
|
151
|
+
if (!warnedMessagesUnavailable) {
|
|
152
|
+
warnedMessagesUnavailable = true
|
|
153
|
+
console.error(
|
|
154
|
+
"[context-indicator] TUI message data API unavailable; " +
|
|
155
|
+
"measured contributors section hidden",
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
return { available: false, list: [] }
|
|
159
|
+
}
|
|
160
|
+
const v = api.list(props.panel.sessionID)
|
|
161
|
+
return { available: true, list: Array.isArray(v) ? v : [] }
|
|
162
|
+
} catch {
|
|
163
|
+
if (!warnedMessagesUnavailable) {
|
|
164
|
+
warnedMessagesUnavailable = true
|
|
165
|
+
console.error(
|
|
166
|
+
"[context-indicator] TUI message read failed; " +
|
|
167
|
+
"measured contributors section hidden",
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
return { available: false, list: [] }
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const contributors = createMemo<Contributor[]>(() => {
|
|
175
|
+
const m = messages()
|
|
176
|
+
if (!m.available) return []
|
|
177
|
+
try {
|
|
178
|
+
return measureContributors(m.list)
|
|
179
|
+
} catch {
|
|
180
|
+
return []
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// Redacted export (key `e`). No message text, no full session id, no tool
|
|
185
|
+
// output content ever leaves this function. Never throws: a failure logs and
|
|
186
|
+
// shows an error toast instead.
|
|
187
|
+
function exportRedacted(): void {
|
|
188
|
+
let exportedName: string | null = null
|
|
189
|
+
try {
|
|
190
|
+
const now = new Date()
|
|
191
|
+
const name = exportFileName(props.panel.sessionID, now)
|
|
192
|
+
const payload = buildRedactedPayload({
|
|
193
|
+
sessionID: props.panel.sessionID,
|
|
194
|
+
root: data().root,
|
|
195
|
+
rows: data().rows,
|
|
196
|
+
contributors: contributors(),
|
|
197
|
+
now,
|
|
198
|
+
panelDenom,
|
|
199
|
+
panelRole,
|
|
200
|
+
num,
|
|
201
|
+
})
|
|
202
|
+
const dir = join(tmpdir(), "opencode-context-indicator", "exports")
|
|
203
|
+
const file = join(dir, name)
|
|
204
|
+
mkdirSync(dir, { recursive: true })
|
|
205
|
+
writeFileSync(file, JSON.stringify(payload, null, 2), "utf8")
|
|
206
|
+
exportedName = basename(file)
|
|
207
|
+
} catch (err) {
|
|
208
|
+
console.error("[context-indicator] redacted export failed:", err)
|
|
209
|
+
try {
|
|
210
|
+
context?.ui?.toast?.show({
|
|
211
|
+
message: "redacted export failed",
|
|
212
|
+
variant: "error",
|
|
213
|
+
})
|
|
214
|
+
} catch {
|
|
215
|
+
/* toast is best-effort */
|
|
216
|
+
}
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
// Outside the write try: a toast failure must not report the export as
|
|
220
|
+
// failed when the file is already on disk.
|
|
221
|
+
try {
|
|
222
|
+
context?.ui?.toast?.show({
|
|
223
|
+
message: `exported ${exportedName}`,
|
|
224
|
+
variant: "success",
|
|
225
|
+
})
|
|
226
|
+
} catch {
|
|
227
|
+
/* toast is best-effort */
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const header = (): string => {
|
|
232
|
+
const r = data().root
|
|
233
|
+
if (!r) return "Context breakdown"
|
|
234
|
+
const model = safeText(r.model && r.model !== "unknown" ? r.model : "model?")
|
|
235
|
+
const ctx = fmt(num(r.ctx))
|
|
236
|
+
const d = panelDenom(r)
|
|
237
|
+
return d > 0
|
|
238
|
+
? `Context breakdown \u2014 ${model} ${ctx}/${fmt(d)} (${Math.round(
|
|
239
|
+
(num(r.ctx) / d) * 100,
|
|
240
|
+
)}%)`
|
|
241
|
+
: `Context breakdown \u2014 ${model} ${ctx}`
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const layout = (): {
|
|
245
|
+
roleW: number
|
|
246
|
+
modelW: number
|
|
247
|
+
ctxW: number
|
|
248
|
+
updW: number
|
|
249
|
+
gutter: string
|
|
250
|
+
} => panelLayout(props.panel.width)
|
|
251
|
+
|
|
252
|
+
const columns = (): string => {
|
|
253
|
+
const L = layout()
|
|
254
|
+
return (
|
|
255
|
+
fit("role", L.roleW) +
|
|
256
|
+
L.gutter +
|
|
257
|
+
fit("model", L.modelW) +
|
|
258
|
+
L.gutter +
|
|
259
|
+
fit("ctx", L.ctxW) +
|
|
260
|
+
L.gutter +
|
|
261
|
+
"updated"
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const row = (e: StateEntry, isMain: boolean): string => {
|
|
266
|
+
const model = safeText(e.model && e.model !== "unknown" ? e.model : "model?")
|
|
267
|
+
const d = panelDenom(e)
|
|
268
|
+
const ctx =
|
|
269
|
+
d > 0
|
|
270
|
+
? `${fmt(num(e.ctx))} (${Math.round((num(e.ctx) / d) * 100)}%)`
|
|
271
|
+
: fmt(num(e.ctx))
|
|
272
|
+
const L = layout()
|
|
273
|
+
return (
|
|
274
|
+
fit(panelRole(e, isMain), L.roleW) +
|
|
275
|
+
L.gutter +
|
|
276
|
+
fit(model, L.modelW) +
|
|
277
|
+
L.gutter +
|
|
278
|
+
fit(ctx, L.ctxW) +
|
|
279
|
+
L.gutter +
|
|
280
|
+
updatedAt(e.updatedAt)
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<box flexDirection="column">
|
|
286
|
+
<text fg={base()}>{header()}</text>
|
|
287
|
+
<Show
|
|
288
|
+
when={data().root}
|
|
289
|
+
fallback={<text fg={muted()}>{" no data yet"}</text>}
|
|
290
|
+
>
|
|
291
|
+
<text fg={muted()}>{columns()}</text>
|
|
292
|
+
{data().rows.map((e, i) => (
|
|
293
|
+
<text fg={muted()}>{row(e, i === 0)}</text>
|
|
294
|
+
))}
|
|
295
|
+
<Show
|
|
296
|
+
when={
|
|
297
|
+
(data().root?.usable ?? 0) > 0
|
|
298
|
+
}
|
|
299
|
+
>
|
|
300
|
+
<text fg={muted()}>
|
|
301
|
+
{" compact at " +
|
|
302
|
+
fmt(num(data().root?.usable)) +
|
|
303
|
+
(num(data().root?.reserve) > 0
|
|
304
|
+
? " (" + fmt(num(data().root?.reserve)) + " reserve)"
|
|
305
|
+
: "")}
|
|
306
|
+
</text>
|
|
307
|
+
</Show>
|
|
308
|
+
<Show when={messages().available}>
|
|
309
|
+
<text fg={base()}>{"largest contributors (measured)"}</text>
|
|
310
|
+
<Show
|
|
311
|
+
when={contributors().length > 0}
|
|
312
|
+
fallback={<text fg={muted()}>{" no messages yet"}</text>}
|
|
313
|
+
>
|
|
314
|
+
{contributors()
|
|
315
|
+
.slice(0, 5)
|
|
316
|
+
.map((c, i) => (
|
|
317
|
+
<text fg={muted()}>{contributorLine(c, i + 1)}</text>
|
|
318
|
+
))}
|
|
319
|
+
</Show>
|
|
320
|
+
<text fg={muted()}>
|
|
321
|
+
{
|
|
322
|
+
"measured != server categories (tool outputs counted here; server categories exclude them)"
|
|
323
|
+
}
|
|
324
|
+
</text>
|
|
325
|
+
</Show>
|
|
326
|
+
</Show>
|
|
327
|
+
</box>
|
|
328
|
+
)
|
|
329
|
+
}
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-context-indicator",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
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",
|
|
@@ -61,14 +61,17 @@
|
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@opencode/plugin": "^2.0.19",
|
|
63
63
|
"@opentui/core": "0.5.16",
|
|
64
|
+
"@types/node": "26.6.3",
|
|
64
65
|
"esbuild": "^0.28.2",
|
|
65
66
|
"node-pty": "^1.1.0",
|
|
66
|
-
"solid-js": "^1.9.12"
|
|
67
|
+
"solid-js": "^1.9.12",
|
|
68
|
+
"typescript": "5.9.3"
|
|
67
69
|
},
|
|
68
70
|
"scripts": {
|
|
69
71
|
"test:prepublish": "node test/prepublish.mjs",
|
|
70
72
|
"test:e2e": "node test/e2e-tui.mjs --full",
|
|
71
|
-
"test:e2e:quick": "node test/e2e-tui.mjs --quick"
|
|
73
|
+
"test:e2e:quick": "node test/e2e-tui.mjs --quick",
|
|
74
|
+
"test:drift": "node test/drift-watch.mjs"
|
|
72
75
|
},
|
|
73
76
|
"prepublishOnly": "node test/prepublish.mjs && node test/e2e-tui.mjs --full"
|
|
74
77
|
}
|
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,8 +33,8 @@
|
|
|
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
|
|
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
|
|
@@ -40,566 +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
|
|
44
|
-
import {
|
|
45
|
-
import
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
ref: string
|
|
53
|
-
chars: number
|
|
54
|
-
tokensEstimate: number
|
|
55
|
-
tool: string
|
|
56
|
-
preview: string
|
|
57
|
-
}
|
|
58
|
-
import {
|
|
59
|
-
safeText,
|
|
60
|
-
measureContributors,
|
|
61
|
-
contributorLine,
|
|
62
|
-
exportFileName,
|
|
63
|
-
buildRedactedPayload,
|
|
64
|
-
panelDenom,
|
|
65
|
-
panelRole,
|
|
66
|
-
} from "./lib/contributors.js"
|
|
67
|
-
|
|
68
|
-
const STATE_FILE =
|
|
69
|
-
process.env.OPENCODE_CONTEXT_INDICATOR_STATE_FILE ||
|
|
70
|
-
join(tmpdir(), "opencode-context-indicator-state.json")
|
|
71
|
-
const POLL_MS = 1000
|
|
72
|
-
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"
|
|
73
63
|
|
|
74
64
|
// Session-panel contribution name. Session-panel names are shared selection
|
|
75
65
|
// values (not registered claims), so it MUST be plugin-prefixed to avoid
|
|
76
66
|
// colliding with another plugin's panel.
|
|
77
67
|
const PANEL_NAME = "context-indicator.breakdown"
|
|
78
68
|
|
|
79
|
-
// True when this module was loaded from an npm install, i.e. its own file path
|
|
80
|
-
// contains a `node_modules` segment (OpenCode's TUI loader skips the host Solid
|
|
81
|
-
// transform for such paths → slots mount once but never live-update; see the
|
|
82
|
-
// Graceful mode note above). Normalised to forward slashes for Windows paths.
|
|
83
|
-
function isNodeModulesInstall(): boolean {
|
|
84
|
-
try {
|
|
85
|
-
const selfPath = fileURLToPath(import.meta.url).replace(/\\/g, "/").toLowerCase()
|
|
86
|
-
return selfPath.includes("/node_modules/")
|
|
87
|
-
} catch {
|
|
88
|
-
return false
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
type Categories = {
|
|
93
|
-
user?: number
|
|
94
|
-
assistant?: number
|
|
95
|
-
reasoning?: number
|
|
96
|
-
toolArgs?: number
|
|
97
|
-
system?: number | null
|
|
98
|
-
toolSchemas?: number | null
|
|
99
|
-
other?: number
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
type StateEntry = {
|
|
103
|
-
sessionID?: string
|
|
104
|
-
parentID?: string | null
|
|
105
|
-
role?: string
|
|
106
|
-
agent?: string | null
|
|
107
|
-
model?: string
|
|
108
|
-
providerID?: string
|
|
109
|
-
ctx?: number
|
|
110
|
-
input?: number
|
|
111
|
-
usable?: number | null
|
|
112
|
-
reserve?: number | null
|
|
113
|
-
limit?: number | null
|
|
114
|
-
reasoning?: number
|
|
115
|
-
categories?: Categories
|
|
116
|
-
updatedAt?: string
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function fmt(n: number): string {
|
|
120
|
-
if (!Number.isFinite(n)) return "0"
|
|
121
|
-
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n))
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function num(v: unknown): number {
|
|
125
|
-
return typeof v === "number" && Number.isFinite(v) ? v : 0
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Theme tokens are OpenTUI RGBA values (see ResolvedTheme.text.base/.muted);
|
|
129
|
-
// pass them straight to the renderer. Fall back to a named color when a token
|
|
130
|
-
// is absent so an unexpected theme shape can never blank the sidebar.
|
|
131
|
-
// `fg` on <text> is typed as OpenTUI's ColorInput (= string | RGBA); using the
|
|
132
|
-
// same alias keeps the JSX assignment type-correct instead of a loose `object`.
|
|
133
|
-
type Fg = ColorInput
|
|
134
|
-
function token(v: unknown, fallback: Fg): Fg {
|
|
135
|
-
return v && typeof v === "object" ? (v as Fg) : fallback
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Reserved object keys must never be treated as session ids: state.json is
|
|
139
|
-
// untrusted input (mirrors isSafeSessionKey() in lib/estimate.js).
|
|
140
|
-
function isSafeKey(k: string): boolean {
|
|
141
|
-
return k !== "__proto__" && k !== "constructor" && k !== "prototype"
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Whole state.json snapshot ({ sessionID: entry }) or {} on any error. The
|
|
145
|
-
// sidebar resolves one entry from it and the breakdown panel walks the
|
|
146
|
-
// parentID family over it. Never throws — a missing / empty / corrupt
|
|
147
|
-
// file simply yields {}.
|
|
148
|
-
function readSnapshot(): Record<string, StateEntry> {
|
|
149
|
-
try {
|
|
150
|
-
const raw = readFileSync(STATE_FILE, "utf8")
|
|
151
|
-
const all = JSON.parse(raw) as Record<string, StateEntry>
|
|
152
|
-
if (!all || typeof all !== "object" || Array.isArray(all)) return {}
|
|
153
|
-
// Rebuild into a fresh plain object: drops prototype-poisoning keys
|
|
154
|
-
// (__proto__ / constructor / prototype) and non-object entries in one pass.
|
|
155
|
-
const clean: Record<string, StateEntry> = {}
|
|
156
|
-
for (const [k, v] of Object.entries(all)) {
|
|
157
|
-
if (isSafeKey(k) && v && typeof v === "object") clean[k] = v
|
|
158
|
-
}
|
|
159
|
-
return clean
|
|
160
|
-
} catch {
|
|
161
|
-
return {}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// True when an entry carries no measured tokens at all (a registered session
|
|
166
|
-
// that never served a step). Mirrors isEmptyStateEntry() in lib/commands.js:
|
|
167
|
-
// `categories` is always persisted, so the emptiness test SUMS its fields —
|
|
168
|
-
// a missing categories object is not the only trigger. `system` / `toolSchemas`
|
|
169
|
-
// may legitimately be null, hence the num() coercion.
|
|
170
|
-
function isEmptyEntry(e: StateEntry | undefined): boolean {
|
|
171
|
-
const c = e?.categories
|
|
172
|
-
const sum =
|
|
173
|
-
num(c?.user) +
|
|
174
|
-
num(c?.assistant) +
|
|
175
|
-
num(c?.reasoning) +
|
|
176
|
-
num(c?.toolArgs) +
|
|
177
|
-
num(c?.system) +
|
|
178
|
-
num(c?.toolSchemas) +
|
|
179
|
-
num(c?.other)
|
|
180
|
-
return sum === 0 && num(e?.ctx) === 0 && num(e?.input) === 0
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// The panel row set: the root entry (panel.sessionID) plus every entry whose
|
|
184
|
-
// parentID chain reaches it, breadth-first. A small local walk over the snapshot
|
|
185
|
-
// — the server's collectDescendantSessionIDs lives in lib/commands.js and is NOT
|
|
186
|
-
// imported here (tui.tsx stays independent of the server code by design). A
|
|
187
|
-
// phantom node (no tokens, see isEmptyEntry) is not emitted, but traversal still
|
|
188
|
-
// descends through it so real grandchildren behind it are found. `seen` guards
|
|
189
|
-
// against cycles (parentID loops) and double-listing.
|
|
190
|
-
function collectFamily(
|
|
191
|
-
snapshot: Record<string, StateEntry>,
|
|
192
|
-
rootID: string,
|
|
193
|
-
): StateEntry[] {
|
|
194
|
-
const out: StateEntry[] = []
|
|
195
|
-
const root = snapshot[rootID]
|
|
196
|
-
if (root && typeof root === "object") out.push(root)
|
|
197
|
-
const seen = new Set<string>([rootID])
|
|
198
|
-
const queue: string[] = [rootID]
|
|
199
|
-
// Index the snapshot by parentID once (O(n)) instead of an O(n) scan per node.
|
|
200
|
-
const byParent = new Map<string, string[]>()
|
|
201
|
-
for (const [id, e] of Object.entries(snapshot)) {
|
|
202
|
-
const pid = e?.parentID
|
|
203
|
-
if (!pid || typeof pid !== "string") continue
|
|
204
|
-
const kids = byParent.get(pid)
|
|
205
|
-
if (kids) kids.push(id)
|
|
206
|
-
else byParent.set(pid, [id])
|
|
207
|
-
}
|
|
208
|
-
while (queue.length > 0) {
|
|
209
|
-
const cur = queue.shift() as string
|
|
210
|
-
const kids = byParent.get(cur)
|
|
211
|
-
if (!kids) continue
|
|
212
|
-
for (const id of kids) {
|
|
213
|
-
if (!id || seen.has(id)) continue
|
|
214
|
-
seen.add(id)
|
|
215
|
-
const e = snapshot[id]
|
|
216
|
-
if (e && !isEmptyEntry(e)) out.push(e)
|
|
217
|
-
queue.push(id) // descend through phantom parents too
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
return out
|
|
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
|
-
// The TUI message data API is optional across host versions. Log a single hint
|
|
254
|
-
// (never once per poll) when it is unavailable and hide the whole section.
|
|
255
|
-
let warnedMessagesUnavailable = false
|
|
256
|
-
|
|
257
|
-
function Sidebar(props: { entry: () => StateEntry | null }) {
|
|
258
|
-
const context = usePlugin()
|
|
259
|
-
const base = () => token(context?.theme?.text?.base, "white")
|
|
260
|
-
const muted = () => token(context?.theme?.text?.muted, base())
|
|
261
|
-
|
|
262
|
-
const e = () => props.entry()
|
|
263
|
-
|
|
264
|
-
const denom = (): number | null => {
|
|
265
|
-
const x = e()
|
|
266
|
-
const d = x?.usable ?? x?.limit
|
|
267
|
-
return typeof d === "number" && d > 0 ? d : null
|
|
268
|
-
}
|
|
269
|
-
const pct = (v: number): string => {
|
|
270
|
-
const d = denom()
|
|
271
|
-
return d ? `${Math.round((v / d) * 100)}%` : ""
|
|
272
|
-
}
|
|
273
|
-
const line = (label: string, v: unknown): string => {
|
|
274
|
-
const n = num(v)
|
|
275
|
-
const l = (label + " ".repeat(LABEL_WIDTH)).slice(0, LABEL_WIDTH)
|
|
276
|
-
const p = pct(n)
|
|
277
|
-
return `${l}${fmt(n).padStart(7)}${p ? ` ${p.padStart(4)}` : ""}`
|
|
278
|
-
}
|
|
279
|
-
const header = (): string => {
|
|
280
|
-
const x = e()
|
|
281
|
-
if (!x) return ""
|
|
282
|
-
const d = denom()
|
|
283
|
-
return `${x.model ?? "?"} · ${fmt(num(x.ctx))}${
|
|
284
|
-
d ? ` / ${fmt(d)}` : ""
|
|
285
|
-
}${d ? ` (${pct(num(x.ctx))})` : ""}`
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
return (
|
|
289
|
-
<box flexDirection="column">
|
|
290
|
-
<text fg={base()}>{"Context"}</text>
|
|
291
|
-
<Show when={e()} fallback={<text fg={muted()}>{" no data yet"}</text>}>
|
|
292
|
-
<text fg={base()}>{header()}</text>
|
|
293
|
-
<text fg={muted()}>{line("user", e()?.categories?.user)}</text>
|
|
294
|
-
<text fg={muted()}>{line("assistant", e()?.categories?.assistant)}</text>
|
|
295
|
-
<text fg={muted()}>{line("reasoning", e()?.categories?.reasoning)}</text>
|
|
296
|
-
<text fg={muted()}>{line("tool args", e()?.categories?.toolArgs)}</text>
|
|
297
|
-
<text fg={muted()}>
|
|
298
|
-
{e()?.categories?.system == null
|
|
299
|
-
? `${"system".padEnd(LABEL_WIDTH)} n/a`
|
|
300
|
-
: line("system", e()?.categories?.system)}
|
|
301
|
-
</text>
|
|
302
|
-
<text fg={muted()}>
|
|
303
|
-
{e()?.categories?.toolSchemas == null
|
|
304
|
-
? `${"tool schemas".padEnd(LABEL_WIDTH)} n/a`
|
|
305
|
-
: line("tool schemas", e()?.categories?.toolSchemas)}
|
|
306
|
-
</text>
|
|
307
|
-
<text fg={muted()}>{line("other", e()?.categories?.other)}</text>
|
|
308
|
-
<text fg={muted()}>{`updated ${updatedAt(e()?.updatedAt)}`}</text>
|
|
309
|
-
</Show>
|
|
310
|
-
</box>
|
|
311
|
-
)
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// Host-owned session.panel input ({ name, sessionID, width, presentation,
|
|
315
|
-
// focused, focus(), close(), toggleFullscreen() }). Typed structurally instead
|
|
316
|
-
// of imported so this file stays independent of the host type package; the host
|
|
317
|
-
// resolves the panel API at runtime.
|
|
318
|
-
type PanelInput = {
|
|
319
|
-
readonly name: string
|
|
320
|
-
readonly sessionID: string
|
|
321
|
-
readonly width: number
|
|
322
|
-
readonly presentation: "panel" | "fullscreen"
|
|
323
|
-
readonly focused: boolean
|
|
324
|
-
readonly focus: () => void
|
|
325
|
-
readonly close: () => void
|
|
326
|
-
readonly toggleFullscreen: () => void
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// Panel column layout (monospace), derived from the host-provided panel width
|
|
330
|
-
// so cells never collide on narrow terminals. Gaps between columns keep a
|
|
331
|
-
// truncated cell visually separated from its neighbour.
|
|
332
|
-
const GUTTER = 2
|
|
333
|
-
const CTX_WIDTH = 12
|
|
334
|
-
|
|
335
|
-
function panelLayout(panelWidth: number): {
|
|
336
|
-
roleW: number
|
|
337
|
-
modelW: number
|
|
338
|
-
ctxW: number
|
|
339
|
-
updW: number
|
|
340
|
-
gutter: string
|
|
341
|
-
} {
|
|
342
|
-
const w = num(panelWidth) > 0 ? num(panelWidth) : 60
|
|
343
|
-
const ctxW = CTX_WIDTH
|
|
344
|
-
const updW = Math.max(5, Math.min(8, w - ctxW - 3 * GUTTER - 24))
|
|
345
|
-
const roleW = Math.max(12, Math.min(20, Math.floor(w * 0.3)))
|
|
346
|
-
const modelW = Math.max(10, w - roleW - ctxW - updW - 3 * GUTTER)
|
|
347
|
-
return { roleW, modelW, ctxW, updW, gutter: " ".repeat(GUTTER) }
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// Full session panel: header + one row per session in the family (root plus
|
|
351
|
-
// subagent descendants). It reads the same state.json bridge as the sidebar via
|
|
352
|
-
// the shared 1 s tick, so both surfaces refresh on ONE poll. `panel.sessionID`
|
|
353
|
-
// selects the root; `cx` opens the panel (registered from the "app" slot below).
|
|
354
|
-
function BreakdownPanel(props: {
|
|
355
|
-
panel: PanelInput
|
|
356
|
-
snapshot: () => Record<string, StateEntry>
|
|
357
|
-
refresh: () => void
|
|
358
|
-
}) {
|
|
359
|
-
const context = usePlugin()
|
|
360
|
-
const base = () => token(context?.theme?.text?.base, "white")
|
|
361
|
-
const muted = () => token(context?.theme?.text?.muted, base())
|
|
362
|
-
|
|
363
|
-
// Panel-local keys. The layer is owned by this component and disposed with it.
|
|
364
|
-
// mode "global" + an explicit focused check is the portable fallback when no
|
|
365
|
-
// target renderable is available: the keys act only while the panel owns input.
|
|
366
|
-
// The capability guard keeps an older host (no keymap API) from throwing.
|
|
367
|
-
const keymap = context?.keymap
|
|
368
|
-
if (keymap && typeof keymap.layer === "function") {
|
|
369
|
-
keymap.layer(() => ({
|
|
370
|
-
mode: "global",
|
|
371
|
-
priority: -1,
|
|
372
|
-
commands: [
|
|
373
|
-
{
|
|
374
|
-
id: "context-indicator.breakdown.close",
|
|
375
|
-
title: "Close the context breakdown panel",
|
|
376
|
-
bind: "escape",
|
|
377
|
-
enabled: () => props.panel.focused,
|
|
378
|
-
run: () => {
|
|
379
|
-
if (props.panel.focused) props.panel.close()
|
|
380
|
-
},
|
|
381
|
-
},
|
|
382
|
-
{
|
|
383
|
-
id: "context-indicator.breakdown.refresh",
|
|
384
|
-
title: "Refresh the context breakdown panel",
|
|
385
|
-
bind: "r",
|
|
386
|
-
enabled: () => props.panel.focused,
|
|
387
|
-
run: () => {
|
|
388
|
-
if (props.panel.focused) props.refresh()
|
|
389
|
-
},
|
|
390
|
-
},
|
|
391
|
-
{
|
|
392
|
-
id: "context-indicator.breakdown.export",
|
|
393
|
-
title: "Export a redacted breakdown snapshot",
|
|
394
|
-
bind: "e",
|
|
395
|
-
enabled: () => props.panel.focused,
|
|
396
|
-
run: () => {
|
|
397
|
-
if (props.panel.focused) exportRedacted()
|
|
398
|
-
},
|
|
399
|
-
},
|
|
400
|
-
],
|
|
401
|
-
}))
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// One snapshot read per poll tick (props.snapshot tracks the shared tick), so
|
|
405
|
-
// the memo recomputes together with the sidebar instead of polling separately.
|
|
406
|
-
const data = createMemo(() => {
|
|
407
|
-
const snap = props.snapshot()
|
|
408
|
-
return {
|
|
409
|
-
root: snap[props.panel.sessionID],
|
|
410
|
-
rows: collectFamily(snap, props.panel.sessionID),
|
|
411
|
-
}
|
|
412
|
-
})
|
|
413
|
-
|
|
414
|
-
// Live message list from the TUI data layer (in-process, no LLM call). The
|
|
415
|
-
// shared `props.snapshot()` tick is read FIRST so this re-reads every poll.
|
|
416
|
-
// `available: false` means the API is absent/errored -> the WHOLE measured
|
|
417
|
-
// section is hidden (and a one-time hint was logged).
|
|
418
|
-
const messages = createMemo<{ available: boolean; list: unknown[] }>(() => {
|
|
419
|
-
props.snapshot()
|
|
420
|
-
try {
|
|
421
|
-
const api = context?.data?.session?.message
|
|
422
|
-
if (!api || typeof api.list !== "function") {
|
|
423
|
-
if (!warnedMessagesUnavailable) {
|
|
424
|
-
warnedMessagesUnavailable = true
|
|
425
|
-
console.error(
|
|
426
|
-
"[context-indicator] TUI message data API unavailable; " +
|
|
427
|
-
"measured contributors section hidden",
|
|
428
|
-
)
|
|
429
|
-
}
|
|
430
|
-
return { available: false, list: [] }
|
|
431
|
-
}
|
|
432
|
-
const v = api.list(props.panel.sessionID)
|
|
433
|
-
return { available: true, list: Array.isArray(v) ? v : [] }
|
|
434
|
-
} catch {
|
|
435
|
-
if (!warnedMessagesUnavailable) {
|
|
436
|
-
warnedMessagesUnavailable = true
|
|
437
|
-
console.error(
|
|
438
|
-
"[context-indicator] TUI message read failed; " +
|
|
439
|
-
"measured contributors section hidden",
|
|
440
|
-
)
|
|
441
|
-
}
|
|
442
|
-
return { available: false, list: [] }
|
|
443
|
-
}
|
|
444
|
-
})
|
|
445
|
-
|
|
446
|
-
const contributors = createMemo<Contributor[]>(() => {
|
|
447
|
-
const m = messages()
|
|
448
|
-
if (!m.available) return []
|
|
449
|
-
try {
|
|
450
|
-
return measureContributors(m.list)
|
|
451
|
-
} catch {
|
|
452
|
-
return []
|
|
453
|
-
}
|
|
454
|
-
})
|
|
455
|
-
|
|
456
|
-
// Redacted export (key `e`). No message text, no full session id, no tool
|
|
457
|
-
// output content ever leaves this function. Never throws: a failure logs and
|
|
458
|
-
// shows an error toast instead.
|
|
459
|
-
function exportRedacted(): void {
|
|
460
|
-
let exportedName: string | null = null
|
|
461
|
-
try {
|
|
462
|
-
const now = new Date()
|
|
463
|
-
const name = exportFileName(props.panel.sessionID, now)
|
|
464
|
-
const payload = buildRedactedPayload({
|
|
465
|
-
sessionID: props.panel.sessionID,
|
|
466
|
-
root: data().root,
|
|
467
|
-
rows: data().rows,
|
|
468
|
-
contributors: contributors(),
|
|
469
|
-
now,
|
|
470
|
-
panelDenom,
|
|
471
|
-
panelRole,
|
|
472
|
-
num,
|
|
473
|
-
})
|
|
474
|
-
const dir = join(tmpdir(), "opencode-context-indicator", "exports")
|
|
475
|
-
const file = join(dir, name)
|
|
476
|
-
mkdirSync(dir, { recursive: true })
|
|
477
|
-
writeFileSync(file, JSON.stringify(payload, null, 2), "utf8")
|
|
478
|
-
exportedName = basename(file)
|
|
479
|
-
} catch (err) {
|
|
480
|
-
console.error("[context-indicator] redacted export failed:", err)
|
|
481
|
-
try {
|
|
482
|
-
context?.ui?.toast?.show({
|
|
483
|
-
message: "redacted export failed",
|
|
484
|
-
variant: "error",
|
|
485
|
-
})
|
|
486
|
-
} catch {
|
|
487
|
-
/* toast is best-effort */
|
|
488
|
-
}
|
|
489
|
-
return
|
|
490
|
-
}
|
|
491
|
-
// Outside the write try: a toast failure must not report the export as
|
|
492
|
-
// failed when the file is already on disk.
|
|
493
|
-
try {
|
|
494
|
-
context?.ui?.toast?.show({
|
|
495
|
-
message: `exported ${exportedName}`,
|
|
496
|
-
variant: "success",
|
|
497
|
-
})
|
|
498
|
-
} catch {
|
|
499
|
-
/* toast is best-effort */
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
const header = (): string => {
|
|
504
|
-
const r = data().root
|
|
505
|
-
if (!r) return "Context breakdown"
|
|
506
|
-
const model = safeText(r.model && r.model !== "unknown" ? r.model : "model?")
|
|
507
|
-
const ctx = fmt(num(r.ctx))
|
|
508
|
-
const d = panelDenom(r)
|
|
509
|
-
return d > 0
|
|
510
|
-
? `Context breakdown \u2014 ${model} ${ctx}/${fmt(d)} (${Math.round(
|
|
511
|
-
(num(r.ctx) / d) * 100,
|
|
512
|
-
)}%)`
|
|
513
|
-
: `Context breakdown \u2014 ${model} ${ctx}`
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
const layout = (): {
|
|
517
|
-
roleW: number
|
|
518
|
-
modelW: number
|
|
519
|
-
ctxW: number
|
|
520
|
-
updW: number
|
|
521
|
-
gutter: string
|
|
522
|
-
} => panelLayout(props.panel.width)
|
|
523
|
-
|
|
524
|
-
const columns = (): string => {
|
|
525
|
-
const L = layout()
|
|
526
|
-
return (
|
|
527
|
-
fit("role", L.roleW) +
|
|
528
|
-
L.gutter +
|
|
529
|
-
fit("model", L.modelW) +
|
|
530
|
-
L.gutter +
|
|
531
|
-
fit("ctx", L.ctxW) +
|
|
532
|
-
L.gutter +
|
|
533
|
-
"updated"
|
|
534
|
-
)
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
const row = (e: StateEntry, isMain: boolean): string => {
|
|
538
|
-
const model = safeText(e.model && e.model !== "unknown" ? e.model : "model?")
|
|
539
|
-
const d = panelDenom(e)
|
|
540
|
-
const ctx =
|
|
541
|
-
d > 0
|
|
542
|
-
? `${fmt(num(e.ctx))} (${Math.round((num(e.ctx) / d) * 100)}%)`
|
|
543
|
-
: fmt(num(e.ctx))
|
|
544
|
-
const L = layout()
|
|
545
|
-
return (
|
|
546
|
-
fit(panelRole(e, isMain), L.roleW) +
|
|
547
|
-
L.gutter +
|
|
548
|
-
fit(model, L.modelW) +
|
|
549
|
-
L.gutter +
|
|
550
|
-
fit(ctx, L.ctxW) +
|
|
551
|
-
L.gutter +
|
|
552
|
-
updatedAt(e.updatedAt)
|
|
553
|
-
)
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
return (
|
|
557
|
-
<box flexDirection="column">
|
|
558
|
-
<text fg={base()}>{header()}</text>
|
|
559
|
-
<Show
|
|
560
|
-
when={data().root}
|
|
561
|
-
fallback={<text fg={muted()}>{" no data yet"}</text>}
|
|
562
|
-
>
|
|
563
|
-
<text fg={muted()}>{columns()}</text>
|
|
564
|
-
{data().rows.map((e, i) => (
|
|
565
|
-
<text fg={muted()}>{row(e, i === 0)}</text>
|
|
566
|
-
))}
|
|
567
|
-
<Show
|
|
568
|
-
when={
|
|
569
|
-
(data().root?.usable ?? 0) > 0
|
|
570
|
-
}
|
|
571
|
-
>
|
|
572
|
-
<text fg={muted()}>
|
|
573
|
-
{" compact at " +
|
|
574
|
-
fmt(num(data().root?.usable)) +
|
|
575
|
-
(num(data().root?.reserve) > 0
|
|
576
|
-
? " (" + fmt(num(data().root?.reserve)) + " reserve)"
|
|
577
|
-
: "")}
|
|
578
|
-
</text>
|
|
579
|
-
</Show>
|
|
580
|
-
<Show when={messages().available}>
|
|
581
|
-
<text fg={base()}>{"largest contributors (measured)"}</text>
|
|
582
|
-
<Show
|
|
583
|
-
when={contributors().length > 0}
|
|
584
|
-
fallback={<text fg={muted()}>{" no messages yet"}</text>}
|
|
585
|
-
>
|
|
586
|
-
{contributors()
|
|
587
|
-
.slice(0, 5)
|
|
588
|
-
.map((c, i) => (
|
|
589
|
-
<text fg={muted()}>{contributorLine(c, i + 1)}</text>
|
|
590
|
-
))}
|
|
591
|
-
</Show>
|
|
592
|
-
<text fg={muted()}>
|
|
593
|
-
{
|
|
594
|
-
"measured != server categories (tool outputs counted here; server categories exclude them)"
|
|
595
|
-
}
|
|
596
|
-
</text>
|
|
597
|
-
</Show>
|
|
598
|
-
</Show>
|
|
599
|
-
</box>
|
|
600
|
-
)
|
|
601
|
-
}
|
|
602
|
-
|
|
603
69
|
export default Plugin.define({
|
|
604
70
|
id: "context-indicator.tui",
|
|
605
71
|
setup(context) {
|
|
@@ -686,6 +152,31 @@ export default Plugin.define({
|
|
|
686
152
|
},
|
|
687
153
|
})
|
|
688
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
|
+
|
|
689
180
|
return () => {
|
|
690
181
|
clearInterval(timer)
|
|
691
182
|
for (const unregister of [
|