opencode-context-indicator 1.0.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.
Files changed (6) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +242 -0
  3. package/index.js +2367 -0
  4. package/lib/dedup.js +120 -0
  5. package/package.json +57 -0
  6. package/tui.tsx +180 -0
package/lib/dedup.js ADDED
@@ -0,0 +1,120 @@
1
+ // lib/dedup.js
2
+ //
3
+ // Shared helpers for the local OpenCode plugins in ../ (V2 + V1 dual format).
4
+ //
5
+ // WHY THIS FILE LIVES IN A SUBDIRECTORY WITHOUT AN INDEX FILE
6
+ // The V2 plugin loader auto-discovers entries under the plugins/ directory.
7
+ // A *directory* entry is treated as a plugin only when it contains an index
8
+ // file (docs: ".opencode/plugins/example/index.ts"); a directory without one
9
+ // is ignored entirely (verified empirically against opencode 2.0.16:
10
+ // plugins/lib/index.js showed up as a plugin, plugins/libprobe2/dedup.js did
11
+ // not). Keeping the shared helpers in ./lib/dedup.js therefore lets other
12
+ // plugin files `import` them with a plain relative specifier — the documented
13
+ // multi-file plugin pattern — WITHOUT registering a phantom no-op plugin.
14
+ // The default export below is still a valid no-op plugin definition as
15
+ // defense-in-depth, so the file stays inert even if a future loader starts
16
+ // discovering non-index modules.
17
+ //
18
+ // EXPORTS
19
+ // claimOnce(key, dedupDir) atomic cross-instance / cross-process claim
20
+ // pruneDedup(dedupDir) delete claim markers older than DEDUP_TTL_MS
21
+ // pruneMap(map, maxSize) LRU-trim an in-process Map
22
+ // DEDUP_TTL_MS marker lifetime (24h)
23
+ //
24
+ // The claim mechanism is the exact one proven in token-usage-logger.js:
25
+ // OpenCode loads one plugin instance per loaded location and mirrors the public
26
+ // event stream to every instance, so without an atomic claim each logical event
27
+ // would be recorded once per location. An exclusive-create file marker ("wx")
28
+ // makes exactly one instance win. Fail-open: if the marker cannot be created
29
+ // for an unexpected reason the event is still recorded (never lose data over
30
+ // bookkeeping).
31
+
32
+ import {
33
+ closeSync,
34
+ mkdirSync,
35
+ openSync,
36
+ readdirSync,
37
+ statSync,
38
+ unlinkSync,
39
+ } from "node:fs";
40
+ import { join } from "node:path";
41
+
42
+ export const DEDUP_TTL_MS = 24 * 60 * 60 * 1000; // prune claim markers after a day
43
+ // Time-throttled pruning. A claim directory can hold tens of thousands of
44
+ // markers (mostly legacy per-delta markers), and pruneDedup() does a
45
+ // synchronous readdirSync+statSync over the whole directory — running that in
46
+ // the event-loop hot path stalls the event stream. Pruning therefore runs at
47
+ // most once per PRUNE_MIN_INTERVAL_MS. The timestamp is seeded at module load
48
+ // so the first claim after startup does NOT trigger a scan (keeping startup /
49
+ // first events off the scan path); the first prune happens on the first claim
50
+ // at least PRUNE_MIN_INTERVAL_MS after load.
51
+ const PRUNE_MIN_INTERVAL_MS = 10 * 60 * 1000; // at most one prune / 10 minutes
52
+ let lastPruneAt = Date.now();
53
+
54
+ function logErr(msg, err) {
55
+ try {
56
+ console.error(`[plugin-lib] ${msg}:`, err);
57
+ } catch {
58
+ /* ignore */
59
+ }
60
+ }
61
+
62
+ // Trim an in-process Map to at most `maxSize` entries (insertion order).
63
+ export function pruneMap(map, maxSize) {
64
+ if (!map || map.size <= maxSize) return;
65
+ const excess = map.size - maxSize;
66
+ let removed = 0;
67
+ for (const key of map.keys()) {
68
+ if (removed >= excess) break;
69
+ map.delete(key);
70
+ removed++;
71
+ }
72
+ }
73
+
74
+ // Atomic, cross-instance, cross-process exactly-once claim.
75
+ // Returns true when this caller won the claim (or on fail-open), false when the
76
+ // marker already existed (another instance already claimed it).
77
+ export function claimOnce(key, dedupDir) {
78
+ try {
79
+ mkdirSync(dedupDir, { recursive: true });
80
+ const safe = String(key).replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 200);
81
+ const fd = openSync(join(dedupDir, `${safe}.seen`), "wx");
82
+ closeSync(fd);
83
+ const now = Date.now();
84
+ if (now - lastPruneAt >= PRUNE_MIN_INTERVAL_MS) {
85
+ lastPruneAt = now;
86
+ pruneDedup(dedupDir);
87
+ }
88
+ return true;
89
+ } catch (err) {
90
+ if (err && err.code === "EEXIST") return false;
91
+ logErr("claimOnce failed (fail-open)", err);
92
+ return true; // fail open
93
+ }
94
+ }
95
+
96
+ // Delete claim markers older than DEDUP_TTL_MS. Never throws.
97
+ export function pruneDedup(dedupDir) {
98
+ try {
99
+ const now = Date.now();
100
+ for (const f of readdirSync(dedupDir)) {
101
+ try {
102
+ const p = join(dedupDir, f);
103
+ if (now - statSync(p).mtimeMs > DEDUP_TTL_MS) unlinkSync(p);
104
+ } catch {
105
+ /* ignore single file */
106
+ }
107
+ }
108
+ } catch {
109
+ /* ignore (dir missing / transient) */
110
+ }
111
+ }
112
+
113
+ // Valid no-op plugin definition (see "WHY THIS FILE LIVES ..." above).
114
+ export default {
115
+ id: "plugin-lib",
116
+ async setup() {},
117
+ async server() {
118
+ return {};
119
+ },
120
+ };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "opencode-context-indicator",
3
+ "version": "1.0.0",
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
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./tui": "./tui.tsx"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "lib/",
14
+ "tui.tsx",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "keywords": [
22
+ "opencode",
23
+ "opencode-plugin",
24
+ "context",
25
+ "tokens",
26
+ "usage",
27
+ "tui",
28
+ "sidebar"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "author": "VladSmr",
34
+ "repository": { "type": "git", "url": "git+https://github.com/VladSmr/opencode-context-indicator.git" },
35
+ "homepage": "https://github.com/VladSmr/opencode-context-indicator#readme",
36
+ "bugs": { "url": "https://github.com/VladSmr/opencode-context-indicator/issues" },
37
+ "peerDependencies": {
38
+ "@opencode/plugin": ">=2.0.16",
39
+ "@opentui/core": ">=0.5.12",
40
+ "@opentui/solid": ">=0.5.12",
41
+ "solid-js": ">=1.9.0"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "@opencode/plugin": {
45
+ "optional": true
46
+ },
47
+ "@opentui/core": {
48
+ "optional": true
49
+ },
50
+ "@opentui/solid": {
51
+ "optional": true
52
+ },
53
+ "solid-js": {
54
+ "optional": true
55
+ }
56
+ }
57
+ }
package/tui.tsx ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * tui.tsx — optional OpenCode TUI sidebar for `opencode-context-indicator`.
3
+ *
4
+ * Exposed as the `./tui` package entry ("beside the main plugin for automatic
5
+ * loading"). OpenCode loads it only in the terminal TUI; it is NOT loaded in
6
+ * the Desktop app (Desktop exposes no TUI slot API). It renders the
7
+ * per-category context breakdown produced by the server plugin (index.js) into
8
+ * the session sidebar via `append: "sidebar.content"`.
9
+ *
10
+ * Data bridge: the server plugin rewrites
11
+ * %TEMP%/opencode-context-indicator-state.json
12
+ * (see writeStateFile() in index.js) on every breakdown update. This module
13
+ * ONLY reads that file and re-polls it once per second; a missing or corrupt
14
+ * file renders "no data yet". The main plugin never imports this file, so the
15
+ * absence of the OpenTUI peers cannot break the server/V1 path.
16
+ *
17
+ * Peer requirements (declared optional in package.json): @opentui/core,
18
+ * @opentui/solid, solid-js.
19
+ */
20
+ import {Plugin, usePlugin} from "@opencode/plugin/tui"
21
+ import {createSignal, Show} from "solid-js"
22
+ import {readFileSync} from "node:fs"
23
+ import {tmpdir} from "node:os"
24
+ import {join} from "node:path"
25
+
26
+ const STATE_FILE = join(tmpdir(), "opencode-context-indicator-state.json")
27
+ const POLL_MS = 1000
28
+ const LABEL_WIDTH = 12
29
+
30
+ type Categories = {
31
+ user?: number
32
+ assistant?: number
33
+ reasoning?: number
34
+ toolArgs?: number
35
+ system?: number | null
36
+ toolSchemas?: number | null
37
+ other?: number
38
+ }
39
+
40
+ type StateEntry = {
41
+ sessionID?: string
42
+ model?: string
43
+ providerID?: string
44
+ ctx?: number
45
+ input?: number
46
+ usable?: number | null
47
+ limit?: number | null
48
+ reasoning?: number
49
+ categories?: Categories
50
+ updatedAt?: string
51
+ }
52
+
53
+ function fmt(n: number): string {
54
+ if (!Number.isFinite(n)) return "0"
55
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n))
56
+ }
57
+
58
+ function num(v: unknown): number {
59
+ return typeof v === "number" && Number.isFinite(v) ? v : 0
60
+ }
61
+
62
+ // Theme tokens are OpenTUI RGBA values (see ResolvedTheme.text.base/.muted);
63
+ // pass them straight to the renderer. Fall back to a named color when a token
64
+ // is absent so an unexpected theme shape can never blank the sidebar.
65
+ type Fg = string | object
66
+ function token(v: unknown, fallback: string): Fg {
67
+ return v && typeof v === "object" ? (v as Fg) : fallback
68
+ }
69
+
70
+ // Read the snapshot entry for one session. Never throws: a missing, empty or
71
+ // corrupt state file simply yields null ("no data yet").
72
+ function readEntry(sessionID: string | undefined): StateEntry | null {
73
+ if (!sessionID) return null
74
+ try {
75
+ const raw = readFileSync(STATE_FILE, "utf8")
76
+ const all = JSON.parse(raw) as Record<string, StateEntry>
77
+ if (!all || typeof all !== "object" || Array.isArray(all)) return null
78
+ const entry = all[sessionID]
79
+ return entry && typeof entry === "object" ? entry : null
80
+ } catch {
81
+ return null
82
+ }
83
+ }
84
+
85
+ function updatedAt(iso: string | undefined): string {
86
+ if (!iso) return "?"
87
+ try {
88
+ return new Date(iso).toLocaleTimeString()
89
+ } catch {
90
+ return "?"
91
+ }
92
+ }
93
+
94
+ function Sidebar(props: { entry: () => StateEntry | null }) {
95
+ const context = usePlugin()
96
+ const base = () => token(context?.theme?.text?.base, "white")
97
+ const muted = () => token(context?.theme?.text?.muted, base())
98
+
99
+ const e = () => props.entry()
100
+
101
+ const denom = (): number | null => {
102
+ const x = e()
103
+ const d = x?.usable ?? x?.limit
104
+ return typeof d === "number" && d > 0 ? d : null
105
+ }
106
+ const pct = (v: number): string => {
107
+ const d = denom()
108
+ return d ? `${Math.round((v / d) * 100)}%` : ""
109
+ }
110
+ const line = (label: string, v: unknown): string => {
111
+ const n = num(v)
112
+ const l = (label + " ".repeat(LABEL_WIDTH)).slice(0, LABEL_WIDTH)
113
+ const p = pct(n)
114
+ return `${l}${fmt(n).padStart(7)}${p ? ` ${p.padStart(4)}` : ""}`
115
+ }
116
+ const header = (): string => {
117
+ const x = e()
118
+ if (!x) return ""
119
+ const d = denom()
120
+ return `${x.model ?? "?"} · ${fmt(num(x.ctx))}${
121
+ d ? ` / ${fmt(d)}` : ""
122
+ }${d ? ` (${pct(num(x.ctx))})` : ""}`
123
+ }
124
+
125
+ return (
126
+ <box flexDirection="column">
127
+ <text fg={base()}>{"Context"}</text>
128
+ <Show when={e()} fallback={<text fg={muted()}>{" no data yet"}</text>}>
129
+ <text fg={base()}>{header()}</text>
130
+ <text fg={muted()}>{line("user", e()?.categories?.user)}</text>
131
+ <text fg={muted()}>{line("assistant", e()?.categories?.assistant)}</text>
132
+ <text fg={muted()}>{line("reasoning", e()?.categories?.reasoning)}</text>
133
+ <text fg={muted()}>{line("tool args", e()?.categories?.toolArgs)}</text>
134
+ <text fg={muted()}>
135
+ {e()?.categories?.system == null
136
+ ? `${"system".padEnd(LABEL_WIDTH)} n/a`
137
+ : line("system", e()?.categories?.system)}
138
+ </text>
139
+ <text fg={muted()}>
140
+ {e()?.categories?.toolSchemas == null
141
+ ? `${"tool schemas".padEnd(LABEL_WIDTH)} n/a`
142
+ : line("tool schemas", e()?.categories?.toolSchemas)}
143
+ </text>
144
+ <text fg={muted()}>{line("other", e()?.categories?.other)}</text>
145
+ <text fg={muted()}>{`updated ${updatedAt(e()?.updatedAt)}`}</text>
146
+ </Show>
147
+ </box>
148
+ )
149
+ }
150
+
151
+ export default Plugin.define({
152
+ id: "context-indicator.tui",
153
+ setup(context) {
154
+ // Re-read the bridge file once per second and re-render reactively.
155
+ const [tick, setTick] = createSignal(0)
156
+ let currentSession: string | undefined
157
+ const entry = (): StateEntry | null => {
158
+ tick() // track the ticker so reads re-run every poll
159
+ return readEntry(currentSession)
160
+ }
161
+ const timer = setInterval(() => setTick((n) => n + 1), POLL_MS)
162
+
163
+ const unregister = context.ui.slot({
164
+ append: "sidebar.content",
165
+ render: ({ sessionID }) => {
166
+ currentSession = sessionID
167
+ return <Sidebar entry={entry} />
168
+ },
169
+ })
170
+
171
+ return () => {
172
+ clearInterval(timer)
173
+ try {
174
+ unregister?.()
175
+ } catch {
176
+ /* ignore */
177
+ }
178
+ }
179
+ },
180
+ })