opencode-context-indicator 1.7.0 → 1.8.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 +106 -12
- package/README.md +102 -96
- package/index.js +6 -6
- package/lib/contributors.js +5 -4
- package/lib/limits.js +27 -15
- package/lib/state.js +6 -5
- package/lib/tui-data.d.ts +0 -1
- package/lib/tui-data.js +5 -21
- package/lib/tui-h.ts +73 -0
- package/lib/{tui-panel.tsx → tui-panel.ts} +779 -713
- package/lib/tui-sidebar.ts +153 -0
- package/lib/v2.js +56 -10
- package/package.json +10 -12
- package/tui.ts +317 -0
- package/lib/tui-sidebar.tsx +0 -76
- package/tui.tsx +0 -206
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// lib/tui-sidebar.ts — the `sidebar.content` slot component.
|
|
2
|
+
//
|
|
3
|
+
// No JSX and no `@jsxImportSource` pragma anywhere in this file: see the long
|
|
4
|
+
// explanation at the top of lib/tui-h.ts. Every dynamic prop is a getter, which
|
|
5
|
+
// is exactly what the Solid compiler would have emitted for `prop={expr}`.
|
|
6
|
+
//
|
|
7
|
+
// This file lives in lib/ rather than in the tui.ts entry because the sidebar
|
|
8
|
+
// is the surface that renders the per-category breakdown; the entry stays a
|
|
9
|
+
// thin setup shell.
|
|
10
|
+
|
|
11
|
+
import { usePlugin } from "@opencode/plugin/tui"
|
|
12
|
+
import { Show } from "solid-js"
|
|
13
|
+
import type { StateEntry } from "./tui-data.js"
|
|
14
|
+
import { fmt, num, updatedAt } from "./tui-data.js"
|
|
15
|
+
import { token } from "./tui-theme.js"
|
|
16
|
+
import { c, h } from "./tui-h.ts"
|
|
17
|
+
|
|
18
|
+
// Sidebar category-label column width (monospace), used by the `line()` helper.
|
|
19
|
+
const LABEL_WIDTH = 12
|
|
20
|
+
|
|
21
|
+
export function Sidebar(props: { entry: () => StateEntry | null }) {
|
|
22
|
+
const context = usePlugin()
|
|
23
|
+
const base = () => token(context?.theme?.text?.base, "white")
|
|
24
|
+
const muted = () => token(context?.theme?.text?.muted, base())
|
|
25
|
+
|
|
26
|
+
const e = () => props.entry()
|
|
27
|
+
|
|
28
|
+
const denom = (): number | null => {
|
|
29
|
+
const x = e()
|
|
30
|
+
const d = x?.usable ?? x?.limit
|
|
31
|
+
return typeof d === "number" && d > 0 ? d : null
|
|
32
|
+
}
|
|
33
|
+
const pct = (v: number): string => {
|
|
34
|
+
const d = denom()
|
|
35
|
+
return d ? `${Math.round((v / d) * 100)}%` : ""
|
|
36
|
+
}
|
|
37
|
+
const line = (label: string, v: unknown): string => {
|
|
38
|
+
const n = num(v)
|
|
39
|
+
const l = (label + " ".repeat(LABEL_WIDTH)).slice(0, LABEL_WIDTH)
|
|
40
|
+
const p = pct(n)
|
|
41
|
+
return `${l}${fmt(n).padStart(7)}${p ? ` ${p.padStart(4)}` : ""}`
|
|
42
|
+
}
|
|
43
|
+
const header = (): string => {
|
|
44
|
+
const x = e()
|
|
45
|
+
if (!x) return ""
|
|
46
|
+
const d = denom()
|
|
47
|
+
return `${x.model ?? "?"} · ${fmt(num(x.ctx))}${
|
|
48
|
+
d ? ` / ${fmt(d)}` : ""
|
|
49
|
+
}${d ? ` (${pct(num(x.ctx))})` : ""}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// One muted category row. The accessor is called INSIDE the getter: the row
|
|
53
|
+
// element is created once and then updates in place on every poll, exactly
|
|
54
|
+
// like `<text fg={muted()}>{line(...)}</text>` in JSX.
|
|
55
|
+
const row = (label: string, value: () => unknown) =>
|
|
56
|
+
h("text", {
|
|
57
|
+
get fg() {
|
|
58
|
+
return muted()
|
|
59
|
+
},
|
|
60
|
+
get children() {
|
|
61
|
+
return line(label, value())
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// `system` and `tool schemas` are absent from state files written by older
|
|
66
|
+
// plugin versions (null) and render as "n/a" instead of a zero row.
|
|
67
|
+
const optionalRow = (label: string, value: () => number | null | undefined) =>
|
|
68
|
+
h("text", {
|
|
69
|
+
get fg() {
|
|
70
|
+
return muted()
|
|
71
|
+
},
|
|
72
|
+
get children() {
|
|
73
|
+
const v = value()
|
|
74
|
+
return v == null
|
|
75
|
+
? `${label.padEnd(LABEL_WIDTH)} n/a`
|
|
76
|
+
: line(label, v)
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
return h("box", {
|
|
81
|
+
flexDirection: "column",
|
|
82
|
+
get children() {
|
|
83
|
+
return [
|
|
84
|
+
h("text", {
|
|
85
|
+
get fg() {
|
|
86
|
+
return base()
|
|
87
|
+
},
|
|
88
|
+
get children() {
|
|
89
|
+
return "Context"
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
c(Show, {
|
|
93
|
+
get when() {
|
|
94
|
+
return e()
|
|
95
|
+
},
|
|
96
|
+
get fallback() {
|
|
97
|
+
return h("text", {
|
|
98
|
+
get fg() {
|
|
99
|
+
return muted()
|
|
100
|
+
},
|
|
101
|
+
get children() {
|
|
102
|
+
return " no data yet"
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
},
|
|
106
|
+
get children() {
|
|
107
|
+
return [
|
|
108
|
+
h("text", {
|
|
109
|
+
get fg() {
|
|
110
|
+
return base()
|
|
111
|
+
},
|
|
112
|
+
get children() {
|
|
113
|
+
return header()
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
116
|
+
row("user", () => e()?.categories?.user),
|
|
117
|
+
row("assistant", () => e()?.categories?.assistant),
|
|
118
|
+
row("reasoning", () => e()?.categories?.reasoning),
|
|
119
|
+
row("tool args", () => e()?.categories?.toolArgs),
|
|
120
|
+
row("tool out", () => e()?.categories?.toolOut),
|
|
121
|
+
optionalRow("system", () => e()?.categories?.system),
|
|
122
|
+
optionalRow("tool schemas", () => e()?.categories?.toolSchemas),
|
|
123
|
+
row("other", () => e()?.categories?.other),
|
|
124
|
+
c(Show, {
|
|
125
|
+
get when() {
|
|
126
|
+
return e()?.compactStale === true
|
|
127
|
+
},
|
|
128
|
+
get children() {
|
|
129
|
+
return h("text", {
|
|
130
|
+
get fg() {
|
|
131
|
+
return muted()
|
|
132
|
+
},
|
|
133
|
+
get children() {
|
|
134
|
+
return " stale — compacted"
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
h("text", {
|
|
140
|
+
get fg() {
|
|
141
|
+
return muted()
|
|
142
|
+
},
|
|
143
|
+
get children() {
|
|
144
|
+
return `updated ${updatedAt(e()?.updatedAt)}`
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
]
|
|
148
|
+
},
|
|
149
|
+
}),
|
|
150
|
+
]
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
}
|
package/lib/v2.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
extractToolTarget,
|
|
16
16
|
fmt,
|
|
17
17
|
isHookMeasuredEntry,
|
|
18
|
+
isHookSource,
|
|
18
19
|
isSafeSessionKey,
|
|
19
20
|
oneLine,
|
|
20
21
|
safeReclaim,
|
|
@@ -741,7 +742,21 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
|
|
|
741
742
|
let snapInputTokens = null;
|
|
742
743
|
let snapCtxTokens = null;
|
|
743
744
|
let snapAt = null;
|
|
744
|
-
|
|
745
|
+
let borrowedSnapshot = false;
|
|
746
|
+
// A hook breakdown whose instance has NO request counters is just as
|
|
747
|
+
// unprintable as a fallback: the categories are measured, but the total this
|
|
748
|
+
// instance knows is 0 (it never completed a step - e.g. a mirror that only
|
|
749
|
+
// saw the compaction hook). Such an entry also takes the state.json path, so
|
|
750
|
+
// the header and the categories come from ONE measurement instead of a
|
|
751
|
+
// `ctx=0` header sitting above non-zero categories.
|
|
752
|
+
const haveLocalCounters =
|
|
753
|
+
(typeof inputTokens === "number" && inputTokens > 0) ||
|
|
754
|
+
(typeof ctxTokens === "number" && ctxTokens > 0);
|
|
755
|
+
if (
|
|
756
|
+
!bd ||
|
|
757
|
+
bd.source === "client-context" ||
|
|
758
|
+
(isHookSource(bd.source) && !haveLocalCounters)
|
|
759
|
+
) {
|
|
745
760
|
const entry = isSafeSessionKey(sessionID)
|
|
746
761
|
? readStateSnapshot()[sessionID]
|
|
747
762
|
: null;
|
|
@@ -750,13 +765,33 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
|
|
|
750
765
|
snapInputTokens = safeSaneOrNull(entry.input);
|
|
751
766
|
snapCtxTokens = safeSaneOrNull(entry.ctx);
|
|
752
767
|
snapAt = typeof entry.updatedAt === "string" ? entry.updatedAt : null;
|
|
768
|
+
borrowedSnapshot = true;
|
|
753
769
|
}
|
|
754
770
|
}
|
|
755
771
|
const source = bd?.source || "none";
|
|
756
772
|
const rt = typeof reasoningTokens === "number" ? reasoningTokens : 0;
|
|
757
773
|
const effInput = snapInputTokens != null ? snapInputTokens : inputTokens;
|
|
758
774
|
const effCtx = snapCtxTokens != null ? snapCtxTokens : ctxTokens;
|
|
759
|
-
|
|
775
|
+
// Only a POSITIVE counter is knowledge. A zero is exactly what the broken
|
|
776
|
+
// case looks like (a state entry written by the trailing-event fallback of a
|
|
777
|
+
// mirror instance carries `ctx: 0, input: 0`), and treating it as "known"
|
|
778
|
+
// would reprint the very `ctx=0` this path exists to avoid.
|
|
779
|
+
const haveSnapCounters =
|
|
780
|
+
(typeof snapInputTokens === "number" && snapInputTokens > 0) ||
|
|
781
|
+
(typeof snapCtxTokens === "number" && snapCtxTokens > 0);
|
|
782
|
+
// Still unknown (no counters here AND no usable hook measurement on disk):
|
|
783
|
+
// print "?" rather than a fabricated 0 that contradicts the categories below.
|
|
784
|
+
const countersKnown = haveLocalCounters || haveSnapCounters;
|
|
785
|
+
// `other = input - sum` needs the input itself; a ctx-only snapshot (the
|
|
786
|
+
// compaction hook knows ctx but a mirror never saw the request) cannot
|
|
787
|
+
// produce an honest residual.
|
|
788
|
+
const inputKnown =
|
|
789
|
+
countersKnown && typeof effInput === "number" && effInput > 0;
|
|
790
|
+
const total = !countersKnown
|
|
791
|
+
? null
|
|
792
|
+
: typeof effCtx === "number" && effCtx > 0
|
|
793
|
+
? effCtx
|
|
794
|
+
: effInput;
|
|
760
795
|
const denom =
|
|
761
796
|
usableInfo != null
|
|
762
797
|
? usableInfo.usable
|
|
@@ -764,13 +799,19 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
|
|
|
764
799
|
? limit
|
|
765
800
|
: null;
|
|
766
801
|
const pct =
|
|
767
|
-
denom != null
|
|
802
|
+
denom != null && total != null
|
|
803
|
+
? ` (${((total / denom) * 100).toFixed(1)}%)`
|
|
804
|
+
: "";
|
|
768
805
|
// The reader must be able to tell a fresh local measurement from a borrowed
|
|
769
|
-
// snapshot (and from how old it is).
|
|
770
|
-
|
|
806
|
+
// snapshot (and from how old it is). A snapshot without a timestamp still
|
|
807
|
+
// must not look local.
|
|
808
|
+
const snapNote = borrowedSnapshot
|
|
809
|
+
? ` snapshot=${snapAt || "unknown"}`
|
|
810
|
+
: "";
|
|
771
811
|
|
|
812
|
+
const shownTotal = total != null ? fmt(total) : "?";
|
|
772
813
|
lines.push(
|
|
773
|
-
`[${ts}] session=${sessionID} model=${modelID} ctx=${
|
|
814
|
+
`[${ts}] session=${sessionID} model=${modelID} ctx=${shownTotal}/${denom != null ? fmt(denom) : "?"}${pct} count=${shownTotal} usable=${usableInfo != null ? fmt(usableInfo.usable) : "?"} limit=${limit != null ? fmt(limit) : "?"} maxOutput=${usableInfo != null ? fmt(usableInfo.maxOutput) : "?"} reserved=${usableInfo != null ? fmt(usableInfo.reserved) : "?"} input=${inputKnown ? fmt(effInput) : "?"}${snapNote}`,
|
|
774
815
|
);
|
|
775
816
|
|
|
776
817
|
// Residual = input minus every measured category (shared normaliser; `bd`
|
|
@@ -778,10 +819,11 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
|
|
|
778
819
|
// breakdown taken from state.json carries the residual the HOOK computed
|
|
779
820
|
// against the input IT saw; this instance's `inputTokens` can differ (a mirror
|
|
780
821
|
// that only saw the trailing event has 0), so that value is used instead.
|
|
781
|
-
const other =
|
|
782
|
-
typeof bd?.otherChars === "number"
|
|
822
|
+
const other = inputKnown
|
|
823
|
+
? typeof bd?.otherChars === "number"
|
|
783
824
|
? bd.otherChars
|
|
784
|
-
: breakdownCategories(bd, effInput).other
|
|
825
|
+
: breakdownCategories(bd, effInput).other
|
|
826
|
+
: null;
|
|
785
827
|
|
|
786
828
|
if (bd) {
|
|
787
829
|
lines.push(` user ${fmt(bd.userChars || 0)} (est tokens)`);
|
|
@@ -811,7 +853,11 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
|
|
|
811
853
|
lines.push(` system n/a (hook did not run)`);
|
|
812
854
|
lines.push(` tool schemas n/a`);
|
|
813
855
|
}
|
|
814
|
-
lines.push(
|
|
856
|
+
lines.push(
|
|
857
|
+
other != null
|
|
858
|
+
? ` other ${fmt(other)} (= input - sum of estimates)`
|
|
859
|
+
: ` other n/a (input unknown on this instance)`,
|
|
860
|
+
);
|
|
815
861
|
lines.push(` source ${source}`);
|
|
816
862
|
|
|
817
863
|
// Provider-reported prompt cache (EXACT, not an estimate; a subset of input —
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-context-indicator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Real-time context-window usage for OpenCode: per-category token breakdown, /context-diff, a zero-LLM /cx panel with per-session cost and a searchable family-session picker, a TUI sidebar, and an experimental Desktop panel. Dual V1 + V2 plugin entry.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./index.js",
|
|
9
|
-
"./tui": "./tui.
|
|
9
|
+
"./tui": "./tui.ts",
|
|
10
10
|
"./rpc": "./lib/rpc.js",
|
|
11
11
|
"./gui-experimental": "./dist/gui-experimental.cjs"
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"index.js",
|
|
15
15
|
"lib/",
|
|
16
|
-
"tui.
|
|
16
|
+
"tui.ts",
|
|
17
17
|
"dist/",
|
|
18
18
|
"docs/",
|
|
19
19
|
"README.md",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"@opencode/plugin": ">=2.0.16",
|
|
55
|
-
"@opentui/core": ">=0.
|
|
55
|
+
"@opentui/core": ">=0.8.0",
|
|
56
56
|
"solid-js": ">=1.9.0"
|
|
57
57
|
},
|
|
58
58
|
"peerDependenciesMeta": {
|
|
@@ -66,12 +66,10 @@
|
|
|
66
66
|
"optional": true
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
|
-
"dependencies": {
|
|
70
|
-
"@opentui/solid": "0.7.0"
|
|
71
|
-
},
|
|
72
69
|
"devDependencies": {
|
|
73
|
-
"@opencode/plugin": "2.2.
|
|
74
|
-
"@opentui/core": "0.
|
|
70
|
+
"@opencode/plugin": "2.2.5",
|
|
71
|
+
"@opentui/core": "0.8.0",
|
|
72
|
+
"@opentui/solid": "0.8.0",
|
|
75
73
|
"@types/node": "26.6.3",
|
|
76
74
|
"esbuild": "^0.28.2",
|
|
77
75
|
"node-pty": "^1.1.0",
|
|
@@ -87,7 +85,7 @@
|
|
|
87
85
|
"test:e2e": "node test/e2e-tui.mjs --full",
|
|
88
86
|
"test:e2e:quick": "node test/e2e-tui.mjs --quick",
|
|
89
87
|
"test:drift": "node test/drift-watch.mjs",
|
|
90
|
-
"test:drift:strict": "node test/drift-watch.mjs --strict"
|
|
91
|
-
|
|
92
|
-
|
|
88
|
+
"test:drift:strict": "node test/drift-watch.mjs --strict",
|
|
89
|
+
"prepublishOnly": "npm run build:gui && node test/prepublish.mjs && node test/e2e-tui.mjs --full"
|
|
90
|
+
}
|
|
93
91
|
}
|
package/tui.ts
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// tui.ts — optional OpenCode TUI surfaces for `opencode-context-indicator`.
|
|
2
|
+
//
|
|
3
|
+
// NO JSX AND NO `@jsxImportSource` PRAGMA IN THIS PACKAGE. Read the long
|
|
4
|
+
// explanation at the top of lib/tui-h.ts before touching any TUI file:
|
|
5
|
+
// OpenCode installs npm plugins inside a `node_modules` tree, where the host
|
|
6
|
+
// SKIPS the Solid/Babel JSX transform (upstream anomalyco/opencode#33884).
|
|
7
|
+
// Building the tree with jsx() and getter props keeps the surfaces in sync with
|
|
8
|
+
// the 1 s poll from BOTH install layouts (npm and file://), and lets the
|
|
9
|
+
// package declare no runtime dependencies at all.
|
|
10
|
+
//
|
|
11
|
+
// This file is the ENTRY + setup wiring only. The components and the data layer
|
|
12
|
+
// live in lib/:
|
|
13
|
+
// - lib/tui-sidebar.ts the `sidebar.content` slot component
|
|
14
|
+
// - lib/tui-panel.ts the `session.panel` component (BreakdownPanel)
|
|
15
|
+
// - lib/tui-h.ts the jsx()/component() builders (only jsx-runtime user)
|
|
16
|
+
// - lib/tui-data.js state.json bridge, family walk, formatting helpers
|
|
17
|
+
// (+ lib/tui-data.d.ts for the shared types)
|
|
18
|
+
// - lib/tui-theme.js theme-token helper
|
|
19
|
+
// - lib/contributors.js measured-contributor computation + redacted export
|
|
20
|
+
//
|
|
21
|
+
// Exposed as the `./tui` package entry ("beside the main plugin for automatic
|
|
22
|
+
// loading"). OpenCode loads it only in the terminal TUI; it is NOT loaded in
|
|
23
|
+
// the Desktop app (Desktop exposes no TUI slot API). It renders the
|
|
24
|
+
// per-category context breakdown produced by the server plugin (index.js) into
|
|
25
|
+
// the session sidebar via `append: "sidebar.content"` and into a full session
|
|
26
|
+
// panel (`append: "session.panel"`) opened by the `cx` slash command / keymap
|
|
27
|
+
// layer registered from an `append: "app"` slot.
|
|
28
|
+
//
|
|
29
|
+
// Data bridge: the server plugin rewrites
|
|
30
|
+
// %TEMP%/opencode-context-indicator-state.json
|
|
31
|
+
// (see writeStateFile() in index.js) on every breakdown update. This module
|
|
32
|
+
// ONLY reads that file and re-polls it once per second; a missing or corrupt
|
|
33
|
+
// file renders "no data yet". The main plugin never imports the TUI entry, so
|
|
34
|
+
// the absence of the OpenTUI peers cannot break the server/V1 path.
|
|
35
|
+
//
|
|
36
|
+
// Runtime requirements: @opentui/core, @opentui/solid and solid-js are resolved
|
|
37
|
+
// by OpenCode at runtime — it rewrites these specifiers to in-process modules
|
|
38
|
+
// (`nodeModulesRuntimeSpecifiers: true` in @opentui/core's
|
|
39
|
+
// DEFAULT_RUNTIME_PLUGIN_REWRITE_OPTIONS) for any file under node_modules, so
|
|
40
|
+
// @opentui/solid is a DEV dependency here (types + local tooling only), never a
|
|
41
|
+
// production one. Shipping it dragged in a duplicate @opentui/core and the whole
|
|
42
|
+
// Babel toolchain for nothing.
|
|
43
|
+
|
|
44
|
+
import { Plugin } from "@opencode/plugin/tui"
|
|
45
|
+
import { createSignal, Show } from "solid-js"
|
|
46
|
+
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"
|
|
47
|
+
import { tmpdir } from "node:os"
|
|
48
|
+
import { dirname, join } from "node:path"
|
|
49
|
+
import type { StateEntry } from "./lib/tui-data.js"
|
|
50
|
+
import { POLL_MS, readSnapshot } from "./lib/tui-data.js"
|
|
51
|
+
import { c as comp, h } from "./lib/tui-h.ts"
|
|
52
|
+
import { Sidebar } from "./lib/tui-sidebar.ts"
|
|
53
|
+
import { BreakdownPanel } from "./lib/tui-panel.ts"
|
|
54
|
+
|
|
55
|
+
// Session-panel contribution name. Session-panel names are shared selection
|
|
56
|
+
// values (not registered claims), so it MUST be plugin-prefixed to avoid
|
|
57
|
+
// colliding with another plugin's panel.
|
|
58
|
+
const PANEL_NAME = "context-indicator.breakdown"
|
|
59
|
+
|
|
60
|
+
export default Plugin.define({
|
|
61
|
+
id: "context-indicator.tui",
|
|
62
|
+
setup(context) {
|
|
63
|
+
// Re-read the bridge file once per second and re-render reactively. ONE
|
|
64
|
+
// ticker drives BOTH surfaces, and the snapshot is cached per tick: every
|
|
65
|
+
// consumer (sidebar, panel data, messages) shares a single readFileSync.
|
|
66
|
+
const [tick, setTick] = createSignal(0)
|
|
67
|
+
let currentSession: string | undefined
|
|
68
|
+
let snapCache: Record<string, StateEntry> | null = null
|
|
69
|
+
let snapAt = -1
|
|
70
|
+
const snapshot = (): Record<string, StateEntry> => {
|
|
71
|
+
const t = tick() // track the ticker so reads re-run every poll
|
|
72
|
+
if (snapCache === null || snapAt !== t) {
|
|
73
|
+
snapCache = readSnapshot()
|
|
74
|
+
snapAt = t
|
|
75
|
+
}
|
|
76
|
+
return snapCache
|
|
77
|
+
}
|
|
78
|
+
const entry = (): StateEntry | null =>
|
|
79
|
+
snapshot()[currentSession ?? ""] ?? null
|
|
80
|
+
const refresh = () => setTick((n) => n + 1)
|
|
81
|
+
const timer = setInterval(() => setTick((n) => n + 1), POLL_MS)
|
|
82
|
+
|
|
83
|
+
// E2E liveness probe (test/e2e-tui.mjs Check C only; inert in production).
|
|
84
|
+
//
|
|
85
|
+
// Check A proves the ./tui entry LOADED; this probe proves the tree stays
|
|
86
|
+
// REACTIVE when the package is loaded from a `node_modules` path — the exact
|
|
87
|
+
// shape of an npm install, where the host skips its Solid JSX transform
|
|
88
|
+
// (#33884). The node is built by the same h()/getter builders as the real
|
|
89
|
+
// sidebar and is driven by the same 1 s poll tick; its `ref` is sampled into
|
|
90
|
+
// a file because pty screen text is not a reliable liveness oracle
|
|
91
|
+
// (differential redraws).
|
|
92
|
+
const livenessFile = process.env.OPENCODE_CONTEXT_INDICATOR_TUI_LIVENESS
|
|
93
|
+
let probeNode: unknown = null
|
|
94
|
+
const setProbeNode = (el: unknown) => {
|
|
95
|
+
probeNode = el
|
|
96
|
+
}
|
|
97
|
+
// `ref` + getter-driven props: the text content and the foreground colour
|
|
98
|
+
// both come from the poll tick, so a frozen (untransformed) tree leaves the
|
|
99
|
+
// sampled values constant. `plainText` is the renderable's own rendered text
|
|
100
|
+
// — a throwaway pty probe showed it tracks updates reliably while screen
|
|
101
|
+
// scraping does not (hence Check C reads this file, not the pty screen).
|
|
102
|
+
const livenessProbe = livenessFile
|
|
103
|
+
? () =>
|
|
104
|
+
h("box", {
|
|
105
|
+
flexDirection: "column",
|
|
106
|
+
get children() {
|
|
107
|
+
return [
|
|
108
|
+
h("text", {
|
|
109
|
+
ref: setProbeNode,
|
|
110
|
+
get fg() {
|
|
111
|
+
return tick() % 2 ? "#ff0000" : "#0000ff"
|
|
112
|
+
},
|
|
113
|
+
get children() {
|
|
114
|
+
return `probe ${tick()}`
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
]
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
: null
|
|
121
|
+
const noteLive = livenessFile
|
|
122
|
+
? (line: string) => {
|
|
123
|
+
try {
|
|
124
|
+
appendFileSync(livenessFile, line + "\n")
|
|
125
|
+
} catch {
|
|
126
|
+
/* probe is best-effort test instrumentation */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
: null
|
|
130
|
+
let probeTimer: ReturnType<typeof setInterval> | undefined
|
|
131
|
+
if (livenessFile) {
|
|
132
|
+
try {
|
|
133
|
+
mkdirSync(dirname(livenessFile), { recursive: true })
|
|
134
|
+
writeFileSync(livenessFile, "")
|
|
135
|
+
} catch {
|
|
136
|
+
/* probe is best-effort test instrumentation */
|
|
137
|
+
}
|
|
138
|
+
probeTimer = setInterval(() => {
|
|
139
|
+
let text = "?"
|
|
140
|
+
let fg = "?"
|
|
141
|
+
try {
|
|
142
|
+
const node = probeNode as Record<string, unknown> | null
|
|
143
|
+
text = String(node?.plainText ?? node?.text ?? "?")
|
|
144
|
+
fg = String(node?.fg ?? node?.foregroundColor ?? "?")
|
|
145
|
+
} catch {
|
|
146
|
+
/* node not mounted yet */
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
appendFileSync(livenessFile, `tick=${tick()} fg=${fg} text=${text}\n`)
|
|
150
|
+
} catch {
|
|
151
|
+
/* probe is best-effort test instrumentation */
|
|
152
|
+
}
|
|
153
|
+
}, 250)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Registering a surface must never throw out of setup(): the peer floor is
|
|
157
|
+
// @opencode/plugin >=2.0.16, and a host without the slot API would otherwise
|
|
158
|
+
// take the whole TUI entry down — losing every other surface with it. A
|
|
159
|
+
// missing surface is logged once and the rest keeps working.
|
|
160
|
+
const registerSlot = (
|
|
161
|
+
name: string,
|
|
162
|
+
register: () => (() => void) | undefined,
|
|
163
|
+
): (() => void) => {
|
|
164
|
+
try {
|
|
165
|
+
const unregister = register()
|
|
166
|
+
return () => {
|
|
167
|
+
try {
|
|
168
|
+
unregister?.()
|
|
169
|
+
} catch {
|
|
170
|
+
/* ignore */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} catch (error) {
|
|
174
|
+
console.error(
|
|
175
|
+
`[context-indicator] TUI slot "${name}" unavailable: ${String(error)}`,
|
|
176
|
+
)
|
|
177
|
+
return () => {}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const unregisterSidebar = registerSlot("sidebar.content", () =>
|
|
182
|
+
context.ui.slot({
|
|
183
|
+
append: "sidebar.content",
|
|
184
|
+
render: ({ sessionID }) => {
|
|
185
|
+
currentSession = sessionID
|
|
186
|
+
noteLive?.("slot-render sidebar.content")
|
|
187
|
+
const sidebar = comp(Sidebar, { entry })
|
|
188
|
+
if (!livenessProbe) return sidebar
|
|
189
|
+
// The probe must live in a real container: a bare array returned from a
|
|
190
|
+
// slot render is not mounted by every host version.
|
|
191
|
+
return h("box", {
|
|
192
|
+
flexDirection: "column",
|
|
193
|
+
get children() {
|
|
194
|
+
return [sidebar, livenessProbe()]
|
|
195
|
+
},
|
|
196
|
+
})
|
|
197
|
+
},
|
|
198
|
+
}),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
// The probe is ALSO mounted in the `app` slot, which the host renders even
|
|
202
|
+
// when no session/sidebar is on screen (a standalone instance with no prompt
|
|
203
|
+
// has no sidebar.content contribution to mount). Whichever slot mounts
|
|
204
|
+
// first fills the sample file; both share the same poll tick.
|
|
205
|
+
const unregisterProbe = livenessProbe
|
|
206
|
+
? registerSlot("app", () =>
|
|
207
|
+
context.ui.slot({
|
|
208
|
+
append: "app",
|
|
209
|
+
render: () => {
|
|
210
|
+
noteLive?.("slot-render app")
|
|
211
|
+
return livenessProbe()
|
|
212
|
+
},
|
|
213
|
+
}),
|
|
214
|
+
)
|
|
215
|
+
: undefined
|
|
216
|
+
|
|
217
|
+
// A `session.panel` contribution is independently selectable: it renders
|
|
218
|
+
// only while its name is the selected one, and the root session comes from
|
|
219
|
+
// panel.sessionID.
|
|
220
|
+
const unregisterPanel = registerSlot("session.panel", () =>
|
|
221
|
+
context.ui.slot({
|
|
222
|
+
append: "session.panel",
|
|
223
|
+
render: (panel) =>
|
|
224
|
+
comp(Show, {
|
|
225
|
+
get when() {
|
|
226
|
+
return panel.name === PANEL_NAME
|
|
227
|
+
},
|
|
228
|
+
get children() {
|
|
229
|
+
return comp(BreakdownPanel, { panel, snapshot, refresh })
|
|
230
|
+
},
|
|
231
|
+
}),
|
|
232
|
+
}),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
// The `app` slot renders nothing itself; it exists to register the global
|
|
236
|
+
// keymap layer (slash command `cx` -> open the panel). The layer is owned by
|
|
237
|
+
// this slot and disposed when the slot unregisters.
|
|
238
|
+
const unregisterCommands = registerSlot("app (keymap)", () =>
|
|
239
|
+
context.ui.slot({
|
|
240
|
+
append: "app",
|
|
241
|
+
render: () => {
|
|
242
|
+
const keymap = context?.keymap
|
|
243
|
+
if (keymap && typeof keymap.layer === "function") {
|
|
244
|
+
keymap.layer(() => ({
|
|
245
|
+
mode: "global",
|
|
246
|
+
commands: [
|
|
247
|
+
{
|
|
248
|
+
id: "context-indicator.breakdown",
|
|
249
|
+
title: "Context breakdown panel",
|
|
250
|
+
slash: { name: "cx" },
|
|
251
|
+
// Palette-visible AND directly bound: subagent session views
|
|
252
|
+
// force the composer into its "subagents" tab, which pushes
|
|
253
|
+
// keymap mode "composer" — the base-mode palette binding
|
|
254
|
+
// (ctrl+p) is inert there and there is no prompt to type /cx.
|
|
255
|
+
// A bind on this mode:"global" layer fires in every mode
|
|
256
|
+
// (leader matches <leader>b "toggle sidebar"; f4 as a direct
|
|
257
|
+
// single-chord fallback).
|
|
258
|
+
palette: true,
|
|
259
|
+
bind: "<leader>c,f4",
|
|
260
|
+
run: () => {
|
|
261
|
+
const panelApi = context?.ui?.panel
|
|
262
|
+
if (panelApi && typeof panelApi.open === "function") {
|
|
263
|
+
panelApi.open(PANEL_NAME)
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
}))
|
|
269
|
+
}
|
|
270
|
+
return null
|
|
271
|
+
},
|
|
272
|
+
}),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
// One-time live-surface marker. The e2e Check A asserts this line from the
|
|
276
|
+
// isolated instance's private log: it proves the ./tui entry not only
|
|
277
|
+
// loaded but REGISTERED its surfaces — a host-transform failure of this
|
|
278
|
+
// file would otherwise die silently between "loading plugin" and here.
|
|
279
|
+
console.log(
|
|
280
|
+
"[context-indicator] live TUI surfaces registered (sidebar + session.panel + keymap)",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
// E2E instrumentation (only when test/e2e-tui.mjs sets the marker env):
|
|
284
|
+
// same proof as the console line above, but via a FILE — the TUI captures
|
|
285
|
+
// console output, so the log channel cannot carry it. Production runs
|
|
286
|
+
// never set the env and never write the stamp.
|
|
287
|
+
if (process.env.OPENCODE_CONTEXT_INDICATOR_TUI_MARKER) {
|
|
288
|
+
try {
|
|
289
|
+
mkdirSync(join(tmpdir(), "opencode-context-indicator"), { recursive: true })
|
|
290
|
+
writeFileSync(
|
|
291
|
+
join(tmpdir(), "opencode-context-indicator", "tui-live.stamp"),
|
|
292
|
+
new Date().toISOString(),
|
|
293
|
+
"utf8",
|
|
294
|
+
)
|
|
295
|
+
} catch {
|
|
296
|
+
/* marker is best-effort test instrumentation */
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return () => {
|
|
301
|
+
clearInterval(timer)
|
|
302
|
+
if (probeTimer) clearInterval(probeTimer)
|
|
303
|
+
for (const unregister of [
|
|
304
|
+
unregisterSidebar,
|
|
305
|
+
unregisterProbe,
|
|
306
|
+
unregisterPanel,
|
|
307
|
+
unregisterCommands,
|
|
308
|
+
]) {
|
|
309
|
+
try {
|
|
310
|
+
unregister?.()
|
|
311
|
+
} catch {
|
|
312
|
+
/* ignore */
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
})
|