opencode-context-indicator 1.7.1 → 1.8.1
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 +86 -0
- package/README.md +151 -108
- package/index.js +10 -6
- package/lib/commands.js +2 -1
- package/lib/config.js +228 -0
- package/lib/contributors.js +5 -4
- package/lib/limits.js +80 -18
- package/lib/state.js +7 -6
- package/lib/tui-composer.ts +70 -0
- package/lib/tui-data.d.ts +10 -1
- package/lib/tui-data.js +39 -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 +21 -4
- package/package.json +10 -12
- package/tui.ts +340 -0
- package/lib/tui-sidebar.tsx +0 -76
- package/tui.tsx +0 -206
package/lib/tui-sidebar.tsx
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
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()}>{line("tool out", e()?.categories?.toolOut)}</text>
|
|
58
|
-
<text fg={muted()}>
|
|
59
|
-
{e()?.categories?.system == null
|
|
60
|
-
? `${"system".padEnd(LABEL_WIDTH)} n/a`
|
|
61
|
-
: line("system", e()?.categories?.system)}
|
|
62
|
-
</text>
|
|
63
|
-
<text fg={muted()}>
|
|
64
|
-
{e()?.categories?.toolSchemas == null
|
|
65
|
-
? `${"tool schemas".padEnd(LABEL_WIDTH)} n/a`
|
|
66
|
-
: line("tool schemas", e()?.categories?.toolSchemas)}
|
|
67
|
-
</text>
|
|
68
|
-
<text fg={muted()}>{line("other", e()?.categories?.other)}</text>
|
|
69
|
-
<Show when={e()?.compactStale === true}>
|
|
70
|
-
<text fg={muted()}>{" stale — compacted"}</text>
|
|
71
|
-
</Show>
|
|
72
|
-
<text fg={muted()}>{`updated ${updatedAt(e()?.updatedAt)}`}</text>
|
|
73
|
-
</Show>
|
|
74
|
-
</box>
|
|
75
|
-
)
|
|
76
|
-
}
|
package/tui.tsx
DELETED
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
/**
|
|
3
|
-
* tui.tsx — optional OpenCode TUI sidebar for `opencode-context-indicator`.
|
|
4
|
-
*
|
|
5
|
-
* The import-source pragma on the first line above is REQUIRED: without it the
|
|
6
|
-
* opencode TUI loader (Bun) transforms JSX against the React runtime and emits
|
|
7
|
-
* `import ... from "react/jsx-runtime"`, which the TUI runtime does not provide,
|
|
8
|
-
* so loading the plugin from the npm cache throws `Cannot find package 'react'`
|
|
9
|
-
* (react only ever resolved from a developer's local node_modules). The pragma
|
|
10
|
-
* must stay the very first line, before any import, and no other pragma-like
|
|
11
|
-
* token may appear in this file. See the OpenTUI Solid integration guide.
|
|
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
|
-
*
|
|
24
|
-
* Exposed as the `./tui` package entry ("beside the main plugin for automatic
|
|
25
|
-
* loading"). OpenCode loads it only in the terminal TUI; it is NOT loaded in
|
|
26
|
-
* the Desktop app (Desktop exposes no TUI slot API). It renders the
|
|
27
|
-
* per-category context breakdown produced by the server plugin (index.js) into
|
|
28
|
-
* the session sidebar via `append: "sidebar.content"` and into a full session
|
|
29
|
-
* panel (`append: "session.panel"`) opened by the `cx` slash command / keymap
|
|
30
|
-
* layer registered from an `append: "app"` slot.
|
|
31
|
-
*
|
|
32
|
-
* Data bridge: the server plugin rewrites
|
|
33
|
-
* %TEMP%/opencode-context-indicator-state.json
|
|
34
|
-
* (see writeStateFile() in index.js) on every breakdown update. This module
|
|
35
|
-
* ONLY reads that file and re-polls it once per second; a missing or corrupt
|
|
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.
|
|
38
|
-
*
|
|
39
|
-
* Runtime requirements: @opentui/core and solid-js are optional peers resolved
|
|
40
|
-
* by OpenCode at runtime. @opentui/solid is a pinned direct dependency (exact
|
|
41
|
-
* 0.7.0, the version opencode 2.2.0 ships): OpenCode's TUI loader does not
|
|
42
|
-
* expose a host instance of it, so for npm-installed plugins the JSX pragma
|
|
43
|
-
* would otherwise fail to resolve `@opentui/solid/jsx-runtime` (upstream
|
|
44
|
-
* opencode issue #33884). @opencode/plugin 2.2.0 declares OpenTUI 0.7.0 as its
|
|
45
|
-
* peer, so a plugin left on 0.6.x no longer installs against that host.
|
|
46
|
-
*
|
|
47
|
-
* Graceful mode: when this file is loaded from an npm install (its own path is
|
|
48
|
-
* inside a `node_modules` directory), OpenCode skips the host Solid transform
|
|
49
|
-
* for `node_modules` paths, so a mounted slot renders once and then never
|
|
50
|
-
* live-updates (anomalyco/opencode#33884). Rather than freeze a permanent
|
|
51
|
-
* "no data yet" panel, the entry detects that case, logs a single hint and
|
|
52
|
-
* registers no slot (neither the sidebar nor the session panel). Install via
|
|
53
|
-
* `file://` from a path outside any `node_modules` for the live surfaces (see
|
|
54
|
-
* README → "Live sidebar").
|
|
55
|
-
*/
|
|
56
|
-
import { Plugin } from "@opencode/plugin/tui"
|
|
57
|
-
import { createSignal, Show } from "solid-js"
|
|
58
|
-
import { mkdirSync, writeFileSync } from "node:fs"
|
|
59
|
-
import { tmpdir } from "node:os"
|
|
60
|
-
import { join } from "node:path"
|
|
61
|
-
import type { StateEntry } from "./lib/tui-data.js"
|
|
62
|
-
import { isNodeModulesInstall, POLL_MS, readSnapshot } from "./lib/tui-data.js"
|
|
63
|
-
import { Sidebar } from "./lib/tui-sidebar"
|
|
64
|
-
import { BreakdownPanel } from "./lib/tui-panel"
|
|
65
|
-
|
|
66
|
-
// Session-panel contribution name. Session-panel names are shared selection
|
|
67
|
-
// values (not registered claims), so it MUST be plugin-prefixed to avoid
|
|
68
|
-
// colliding with another plugin's panel.
|
|
69
|
-
const PANEL_NAME = "context-indicator.breakdown"
|
|
70
|
-
|
|
71
|
-
export default Plugin.define({
|
|
72
|
-
id: "context-indicator.tui",
|
|
73
|
-
setup(context) {
|
|
74
|
-
// npm installs (path under node_modules) cannot get live slot updates on
|
|
75
|
-
// this OpenCode version. Stay inert and log a single hint instead of
|
|
76
|
-
// registering slots that would freeze on "no data yet".
|
|
77
|
-
if (isNodeModulesInstall()) {
|
|
78
|
-
console.log(
|
|
79
|
-
"[context-indicator] TUI sidebar and breakdown panel disabled: " +
|
|
80
|
-
"npm-installed plugins cannot get live updates on this OpenCode " +
|
|
81
|
-
"version (anomalyco/opencode#33884). Install via file:// (see README " +
|
|
82
|
-
'→ "Live sidebar") for the live sidebar and panel. The /context slash ' +
|
|
83
|
-
"commands still work everywhere.",
|
|
84
|
-
)
|
|
85
|
-
return
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Re-read the bridge file once per second and re-render reactively. ONE
|
|
89
|
-
// ticker drives BOTH surfaces, and the snapshot is cached per tick: every
|
|
90
|
-
// consumer (sidebar, panel data, messages) shares a single readFileSync.
|
|
91
|
-
const [tick, setTick] = createSignal(0)
|
|
92
|
-
let currentSession: string | undefined
|
|
93
|
-
let snapCache: Record<string, StateEntry> | null = null
|
|
94
|
-
let snapAt = -1
|
|
95
|
-
const snapshot = (): Record<string, StateEntry> => {
|
|
96
|
-
const t = tick() // track the ticker so reads re-run every poll
|
|
97
|
-
if (snapCache === null || snapAt !== t) {
|
|
98
|
-
snapCache = readSnapshot()
|
|
99
|
-
snapAt = t
|
|
100
|
-
}
|
|
101
|
-
return snapCache
|
|
102
|
-
}
|
|
103
|
-
const entry = (): StateEntry | null =>
|
|
104
|
-
snapshot()[currentSession ?? ""] ?? null
|
|
105
|
-
const refresh = () => setTick((n) => n + 1)
|
|
106
|
-
const timer = setInterval(() => setTick((n) => n + 1), POLL_MS)
|
|
107
|
-
|
|
108
|
-
const unregisterSidebar = context.ui.slot({
|
|
109
|
-
append: "sidebar.content",
|
|
110
|
-
render: ({ sessionID }) => {
|
|
111
|
-
currentSession = sessionID
|
|
112
|
-
return <Sidebar entry={entry} />
|
|
113
|
-
},
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
// A `session.panel` contribution is independently selectable: it renders
|
|
117
|
-
// only while its name is the selected one, and the root session comes from
|
|
118
|
-
// panel.sessionID.
|
|
119
|
-
const unregisterPanel = context.ui.slot({
|
|
120
|
-
append: "session.panel",
|
|
121
|
-
render: (panel) => (
|
|
122
|
-
<Show when={panel.name === PANEL_NAME}>
|
|
123
|
-
<BreakdownPanel panel={panel} snapshot={snapshot} refresh={refresh} />
|
|
124
|
-
</Show>
|
|
125
|
-
),
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
// The `app` slot renders nothing itself; it exists to register the global
|
|
129
|
-
// keymap layer (slash command `cx` -> open the panel). The layer is owned by
|
|
130
|
-
// this slot and disposed when the slot unregisters.
|
|
131
|
-
const unregisterCommands = context.ui.slot({
|
|
132
|
-
append: "app",
|
|
133
|
-
render: () => {
|
|
134
|
-
const keymap = context?.keymap
|
|
135
|
-
if (keymap && typeof keymap.layer === "function") {
|
|
136
|
-
keymap.layer(() => ({
|
|
137
|
-
mode: "global",
|
|
138
|
-
commands: [
|
|
139
|
-
{
|
|
140
|
-
id: "context-indicator.breakdown",
|
|
141
|
-
title: "Context breakdown panel",
|
|
142
|
-
slash: { name: "cx" },
|
|
143
|
-
// Palette-visible AND directly bound: subagent session views
|
|
144
|
-
// force the composer into its "subagents" tab, which pushes
|
|
145
|
-
// keymap mode "composer" — the base-mode palette binding
|
|
146
|
-
// (ctrl+p) is inert there and there is no prompt to type /cx.
|
|
147
|
-
// A bind on this mode:"global" layer fires in every mode
|
|
148
|
-
// (leader matches <leader>b "toggle sidebar"; f4 as a direct
|
|
149
|
-
// single-chord fallback).
|
|
150
|
-
palette: true,
|
|
151
|
-
bind: "<leader>c,f4",
|
|
152
|
-
run: () => {
|
|
153
|
-
const panelApi = context?.ui?.panel
|
|
154
|
-
if (panelApi && typeof panelApi.open === "function") {
|
|
155
|
-
panelApi.open(PANEL_NAME)
|
|
156
|
-
}
|
|
157
|
-
},
|
|
158
|
-
},
|
|
159
|
-
],
|
|
160
|
-
}))
|
|
161
|
-
}
|
|
162
|
-
return null
|
|
163
|
-
},
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
// One-time live-surface marker. The e2e Check A asserts this line from the
|
|
167
|
-
// isolated instance's private log: it proves the ./tui entry not only
|
|
168
|
-
// loaded but REGISTERED its surfaces — a host-transform failure of this
|
|
169
|
-
// file would otherwise die silently between "loading plugin" and here.
|
|
170
|
-
console.log(
|
|
171
|
-
"[context-indicator] live TUI surfaces registered (sidebar + session.panel + keymap)",
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
// E2E instrumentation (only when test/e2e-tui.mjs sets the marker env):
|
|
175
|
-
// same proof as the console line above, but via a FILE — the TUI captures
|
|
176
|
-
// console output, so the log channel cannot carry it. Production runs
|
|
177
|
-
// never set the env and never write the stamp.
|
|
178
|
-
if (process.env.OPENCODE_CONTEXT_INDICATOR_TUI_MARKER) {
|
|
179
|
-
try {
|
|
180
|
-
mkdirSync(join(tmpdir(), "opencode-context-indicator"), { recursive: true })
|
|
181
|
-
writeFileSync(
|
|
182
|
-
join(tmpdir(), "opencode-context-indicator", "tui-live.stamp"),
|
|
183
|
-
new Date().toISOString(),
|
|
184
|
-
"utf8",
|
|
185
|
-
)
|
|
186
|
-
} catch {
|
|
187
|
-
/* marker is best-effort test instrumentation */
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
return () => {
|
|
192
|
-
clearInterval(timer)
|
|
193
|
-
for (const unregister of [
|
|
194
|
-
unregisterSidebar,
|
|
195
|
-
unregisterPanel,
|
|
196
|
-
unregisterCommands,
|
|
197
|
-
]) {
|
|
198
|
-
try {
|
|
199
|
-
unregister?.()
|
|
200
|
-
} catch {
|
|
201
|
-
/* ignore */
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
},
|
|
206
|
-
})
|