opencode-context-indicator 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +101 -0
- package/README.md +47 -14
- package/lib/tui-data.d.ts +45 -0
- package/lib/tui-data.js +165 -0
- package/lib/tui-panel.tsx +575 -0
- package/lib/tui-sidebar.tsx +72 -0
- package/lib/tui-theme.js +17 -0
- package/package.json +10 -6
- package/tui.tsx +57 -557
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Notable user-facing changes per release. Dates are commit days (UTC).
|
|
4
|
+
The package also ships a dual V1/V2 plugin — see the README for the surface
|
|
5
|
+
matrix; everything below concerns the V2 path unless stated otherwise.
|
|
6
|
+
|
|
7
|
+
## 1.3.0 — unreleased
|
|
8
|
+
|
|
9
|
+
Highlights: per-session cost in `/cx` (family-aggregated for the main session),
|
|
10
|
+
a searchable family-session picker, and `/cx` reachable from subagent session
|
|
11
|
+
views.
|
|
12
|
+
|
|
13
|
+
- `/cx` panel: new **cost** column (upstream #45417). The main session shows
|
|
14
|
+
the family-aggregated spend, each subagent its own; `-` when the host has no
|
|
15
|
+
cost for a session.
|
|
16
|
+
- New panel key `s` — a searchable picker over all family sessions
|
|
17
|
+
(title / model / cost / running marker). `Enter` navigates: root sessions
|
|
18
|
+
focus their tab, subagent sessions switch the route.
|
|
19
|
+
- `/cx` now works from **subagent session views**: those views have no prompt
|
|
20
|
+
input, so the command is also in the command palette and bound to
|
|
21
|
+
`<leader>c` (ctrl+x, then c) and `f4`. The panel family is rooted at the
|
|
22
|
+
topmost ancestor, so the whole family is shown, not just the child subtree.
|
|
23
|
+
- OpenTUI pinned `0.5.17`; `@opencode/plugin` types devDep pinned `2.0.26`
|
|
24
|
+
(drift-watch now tracks both). Tested against opencode **2.0.26**.
|
|
25
|
+
|
|
26
|
+
## 1.2.2 — 2026-10-08
|
|
27
|
+
|
|
28
|
+
- TUI entry split for maintainability: `tui.tsx` down to ~195 lines; the
|
|
29
|
+
components and the data layer moved to `lib/` (`tui-sidebar.tsx`,
|
|
30
|
+
`tui-panel.tsx`, `tui-data.js` + `.d.ts`, `tui-theme.js`). No behavior
|
|
31
|
+
change. The pragma regression gate now covers every `lib/*.tsx`.
|
|
32
|
+
- E2E hardened: deterministic fail-closed plugin-load check (isolated global
|
|
33
|
+
config via `OPENCODE_CONFIG_DIR` + the isolated instance's own log file +
|
|
34
|
+
a live-surface stamp), cleanup of spawned CLI instances, WMI probe timeouts.
|
|
35
|
+
|
|
36
|
+
## 1.2.1 — 2026-10-08
|
|
37
|
+
|
|
38
|
+
- The panel / percentage denominator now equals **opencode's compaction
|
|
39
|
+
ceiling** (v2.0.23+ `calculateCeiling`: window = `limit.input ||
|
|
40
|
+
limit.context`; reserve = max(10% of the window, 16k when the window is at
|
|
41
|
+
least 32k)) — percentages read as "% to compaction", not "% of the raw
|
|
42
|
+
window" (upstream #51271).
|
|
43
|
+
- `state.json`: new `reserve` field; the panel and `/context` show
|
|
44
|
+
`compact at X (N reserve)`.
|
|
45
|
+
- Unit harness extended to 34 checks (measured contributors, redacted-export
|
|
46
|
+
invariants, ceiling formula, hydrate seeding).
|
|
47
|
+
- OpenTUI pinned `0.5.16`.
|
|
48
|
+
|
|
49
|
+
## 1.2.0 — 2026-10-04
|
|
50
|
+
|
|
51
|
+
- New: **zero-LLM `/cx` session panel** — a family table (role / model /
|
|
52
|
+
ctx% / updated) for the current session and all subagent descendants, plus a
|
|
53
|
+
`largest contributors (measured)` section computed from the in-process
|
|
54
|
+
message list (per tool call, with message clock times and bounded previews).
|
|
55
|
+
- Redacted export (`e`): no message text, no full session ids, no tool output
|
|
56
|
+
content — written under `%TEMP%/opencode-context-indicator/exports/`.
|
|
57
|
+
- Keys: `esc` closes, `r` refreshes, `e` exports.
|
|
58
|
+
|
|
59
|
+
## 1.1.6 — 2026-10-04
|
|
60
|
+
|
|
61
|
+
- OpenTUI pinned `0.5.14` (opencode 2.0.21+), optional-peer floor aligned.
|
|
62
|
+
- `index.js` split into `lib/` modules (no behavior change).
|
|
63
|
+
|
|
64
|
+
## 1.1.5 — 2026-10-02
|
|
65
|
+
|
|
66
|
+
- Security hardening: sanitized table metadata, validated `state.json`
|
|
67
|
+
(prototype-poisoning keys dropped), ownership-safe state lock, tmp hygiene.
|
|
68
|
+
|
|
69
|
+
## 1.1.4 — 2026-10-02
|
|
70
|
+
|
|
71
|
+
- Removed a stray `opencode/process` import from the TUI entry; the prepublish
|
|
72
|
+
gate now rejects bare `opencode/*` specifiers (not bridged at runtime).
|
|
73
|
+
|
|
74
|
+
## 1.1.3 — 2026-10-01
|
|
75
|
+
|
|
76
|
+
- Stable context denominator across plugin instances: a transient
|
|
77
|
+
`ctx.model.list()` miss falls back to the shared snapshot instead of
|
|
78
|
+
clobbering persisted windows.
|
|
79
|
+
|
|
80
|
+
## 1.1.2 — 2026-10-01
|
|
81
|
+
|
|
82
|
+
- Graceful sidebar/panel disable on npm installs (upstream #33884: `node_modules`
|
|
83
|
+
plugins skip the host Solid transform, so slots would freeze) — a single hint
|
|
84
|
+
is logged instead; `@opentui/solid` is shipped pinned; `file://` install docs.
|
|
85
|
+
|
|
86
|
+
## 1.1.1 — 2026-10-01
|
|
87
|
+
|
|
88
|
+
- Fixed `Cannot find package 'react'` when loaded from the npm cache: the
|
|
89
|
+
`@jsxImportSource @opentui/solid` pragma must be the file's first line.
|
|
90
|
+
|
|
91
|
+
## 1.1.0 — 2026-10-01
|
|
92
|
+
|
|
93
|
+
- New: `/context` (compact summary) and `/context-breakdown` (full table, main
|
|
94
|
+
+ all subagent sessions) slash commands.
|
|
95
|
+
- Stable per-model window limits; strict subagent discovery.
|
|
96
|
+
|
|
97
|
+
## 1.0.0 — 2026-09-30
|
|
98
|
+
|
|
99
|
+
- Initial release: dual V1/V2 plugin, real-time context indicator, per-category
|
|
100
|
+
token breakdown (system / tool schemas / user / assistant / reasoning / tool
|
|
101
|
+
args / residual), TUI sidebar.
|
package/README.md
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
Real-time **context-window usage** indicator for [OpenCode](https://opencode.ai),
|
|
4
4
|
with a **per-category token breakdown** and an optional **terminal-UI sidebar**.
|
|
5
5
|
|
|
6
|
+
User-facing changes per release: [CHANGELOG.md](./CHANGELOG.md).
|
|
7
|
+
|
|
6
8
|
It tells you how full the current model's context window is, and *what* is
|
|
7
9
|
filling it: system prompt, tool schemas, user/assistant text, reasoning, tool
|
|
8
10
|
arguments and the residual "other" bucket.
|
|
@@ -10,7 +12,7 @@ arguments and the residual "other" bucket.
|
|
|
10
12
|
The package ships a **dual V1 + V2 plugin** (one entry works in both OpenCode
|
|
11
13
|
generations) plus an optional TUI module (`./tui` entry) that renders a **live
|
|
12
14
|
sidebar** and a **zero-LLM `/cx` breakdown panel** with measured contributors
|
|
13
|
-
and a redacted export. The V2 path is **tested against opencode v2.0.
|
|
15
|
+
and a redacted export. The V2 path is **tested against opencode v2.0.26**; the
|
|
14
16
|
V1 path against 1.18.29+.
|
|
15
17
|
|
|
16
18
|
> **V1 is feature-frozen.** It is kept working for compatibility (bug fixes
|
|
@@ -56,7 +58,7 @@ For every served request the plugin estimates how the context window is split:
|
|
|
56
58
|
carries a `compaction.buffer` configured in its own settings, the plugin
|
|
57
59
|
cannot read that value (it is not exposed to plugins), so the default
|
|
58
60
|
reserve formula is used — set `OPENCODE_CONTEXT_INDICATOR_COMPACT_BUFFER`
|
|
59
|
-
to override the reserve for testing or custom setups (
|
|
61
|
+
to override the reserve for testing or custom setups (non-negative integer <=
|
|
60
62
|
1e9; invalid values are silently ignored).
|
|
61
63
|
|
|
62
64
|
### Toast (V1 only)
|
|
@@ -240,7 +242,20 @@ back to a plain notice if needed.
|
|
|
240
242
|
`/cx` opens a full `session.panel` showing the breakdown for the current session
|
|
241
243
|
**and its subagent descendants**, read from the same `state.json` bridge as the
|
|
242
244
|
sidebar. Keys: `/cx` opens, `esc` closes, `r` refreshes (re-read the bridge),
|
|
243
|
-
`e` exports a redacted snapshot.
|
|
245
|
+
`e` exports a redacted snapshot, `s` picks a family session.
|
|
246
|
+
|
|
247
|
+
Every row carries a **cost** column read from the host data layer: a main
|
|
248
|
+
session shows the **family-aggregated** spend, each subagent its own — no more
|
|
249
|
+
invisible subagent spend (upstream #45417). `s` opens a searchable picker over
|
|
250
|
+
all family sessions (title / model / cost / running marker); `Enter` navigates
|
|
251
|
+
there — root sessions focus their tab, subagent sessions switch the route.
|
|
252
|
+
|
|
253
|
+
Works from **subagent session views too**: those views have no prompt input
|
|
254
|
+
(the composer switches to its subagents tab), so `/cx` is also registered in
|
|
255
|
+
the command palette and bound to `<leader>c` / `f4` — both fire from any
|
|
256
|
+
session view (the palette itself is only reachable outside the composer).
|
|
257
|
+
Opened from a subagent view, the family is rooted at the topmost ancestor, so
|
|
258
|
+
the whole family is shown, not just the child subtree.
|
|
244
259
|
|
|
245
260
|
**Zero LLM calls.** Unlike `/context` and `/context-breakdown` (which run a real
|
|
246
261
|
agent turn so the model renders the table), the panel only reads local TUI data
|
|
@@ -437,13 +452,13 @@ the sidebar instead.
|
|
|
437
452
|
gracefully (the breakdown falls back to throttled `session.messages` fetches
|
|
438
453
|
and toasts keep working).
|
|
439
454
|
* **OpenCode ≥ 2.0.16** for the V2 path (built and verified against
|
|
440
|
-
**2.0.
|
|
455
|
+
**2.0.26**).
|
|
441
456
|
* **TUI sidebar**: terminal TUI only, requires the OpenTUI rendering stack that
|
|
442
457
|
ships with OpenCode. `@opentui/core` and `solid-js` are **optional** peer
|
|
443
458
|
dependencies resolved by OpenCode at runtime; the main plugin installs and runs
|
|
444
459
|
fine without them (the sidebar simply is not available). `@opentui/solid` is
|
|
445
|
-
instead shipped as a **pinned direct dependency** (exact `0.5.
|
|
446
|
-
the OpenTUI stack OpenCode 2.0.
|
|
460
|
+
instead shipped as a **pinned direct dependency** (exact `0.5.17`, matching
|
|
461
|
+
the OpenTUI stack OpenCode 2.0.26 ships): OpenCode's
|
|
447
462
|
TUI loader does not expose a host instance of it, so for npm-installed plugins
|
|
448
463
|
the JSX pragma would otherwise fail to resolve `@opentui/solid/jsx-runtime`
|
|
449
464
|
(upstream: opencode issue #33884 — `node_modules` plugins are excluded from the
|
|
@@ -505,9 +520,10 @@ caller.
|
|
|
505
520
|
### Running tests
|
|
506
521
|
|
|
507
522
|
```bash
|
|
508
|
-
npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–
|
|
523
|
+
npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–T16i unit checks
|
|
509
524
|
npm run test:e2e # full E2E: isolated plugin load + one "Say OK" round-trip
|
|
510
525
|
npm run test:e2e:quick # E2E load-only, no LLM call
|
|
526
|
+
npm run test:drift # OpenTUI pin vs the latest RELEASED opencode (also in CI)
|
|
511
527
|
```
|
|
512
528
|
|
|
513
529
|
`prepublishOnly` runs **both** gates automatically on `npm publish`
|
|
@@ -524,8 +540,8 @@ STEP 80 s, ~3 min total.
|
|
|
524
540
|
|---|---|---|
|
|
525
541
|
| (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
542
|
| (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–
|
|
543
|
+
| (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 |
|
|
544
|
+
| (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
545
|
| (e) corporate-identifier scan | no forbidden identifiers in the published surface + test tree | compliance |
|
|
530
546
|
| (f) non-English scan | no non-ASCII letters (`\p{L}` above U+007F) in any repo file | English-only source rule |
|
|
531
547
|
|
|
@@ -533,7 +549,7 @@ STEP 80 s, ~3 min total.
|
|
|
533
549
|
|
|
534
550
|
| Check | What | Why |
|
|
535
551
|
|---|---|---|
|
|
536
|
-
| A — plugin load | spins a private `opencode --standalone` PTY
|
|
552
|
+
| 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
553
|
| 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
554
|
|
|
539
555
|
The E2E is fully **non-invasive** to your running OpenCode Desktop:
|
|
@@ -542,7 +558,14 @@ The E2E is fully **non-invasive** to your running OpenCode Desktop:
|
|
|
542
558
|
- `OPENCODE_DB` points at a throwaway DB in `%TEMP%` (your sessions are untouched);
|
|
543
559
|
- `OPENCODE_CONTEXT_INDICATOR_STATE_FILE` points at a throwaway state file in `%TEMP%`
|
|
544
560
|
— the live `opencode-context-indicator-state.json` is never read or written;
|
|
561
|
+
- `OPENCODE_CONFIG_DIR` points at a throwaway global config written by the script
|
|
562
|
+
(plugins: this checkout; `update: "disable"`) **for Check A only** — the load
|
|
563
|
+
source is deterministic and fail-closed instead of following your live config;
|
|
564
|
+
Check B uses your real global config read-only (the round-trip needs the
|
|
565
|
+
provider/model defined there). Your real config is never modified;
|
|
545
566
|
- `OPENCODE_DISABLE_PROJECT_CONFIG=1` avoids project-level config;
|
|
567
|
+
- any `opencode-cli` instance the test itself spawned is force-killed after each
|
|
568
|
+
check (`serve --service` — your Desktop background service — is never touched);
|
|
546
569
|
- exactly **one** minimal LLM call (`Say OK`) is made, in the isolated session.
|
|
547
570
|
|
|
548
571
|
The script prints `[e2e] running against ISOLATED opencode instance — your running
|
|
@@ -555,10 +578,20 @@ model.
|
|
|
555
578
|
|
|
556
579
|
### CI
|
|
557
580
|
|
|
558
|
-
`.github/workflows/ci.yml` runs
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
581
|
+
`.github/workflows/ci.yml` runs on every push/PR to `main`, on `workflow_dispatch`,
|
|
582
|
+
and on a daily schedule (GitHub auto-parks scheduled workflows after 60 days of
|
|
583
|
+
repo inactivity — a push or manual run re-enables them):
|
|
584
|
+
|
|
585
|
+
1. `npx --no-install tsc --noEmit` — typecheck of the TUI entry (`tui.tsx` + its
|
|
586
|
+
`lib/*` imports);
|
|
587
|
+
2. `test/prepublish.mjs` — the full fast gate (a)–(f), including the standalone
|
|
588
|
+
esbuild transpile check (pragmas, no React);
|
|
589
|
+
3. `test/drift-watch.mjs` — the OpenTUI pin must match the OpenTUI catalog of the
|
|
590
|
+
**latest released** opencode; red CI means "bump the pin before the next
|
|
591
|
+
release".
|
|
592
|
+
|
|
593
|
+
The Node-pty E2E is not run in CI (requires the OpenCode binary) and is skipped
|
|
594
|
+
gracefully when the CLI is absent (`[SKIP] opencode CLI not found`).
|
|
562
595
|
|
|
563
596
|
---
|
|
564
597
|
|
|
@@ -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
|
+
}
|