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/config.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// lib/config.js
|
|
2
|
+
//
|
|
3
|
+
// Reads the ONE opencode setting the plugin cannot obtain through the plugin
|
|
4
|
+
// API: `compaction.buffer` (opencode 2.2.5+, a token count or a percentage such
|
|
5
|
+
// as "20%"). Core applies it in calculateCeiling(); a plugin only sees the
|
|
6
|
+
// resulting model limits, so the reserve has to be mirrored from the user's own
|
|
7
|
+
// config file.
|
|
8
|
+
//
|
|
9
|
+
// The plugin has NO runtime dependencies (see README "Live sidebar"), so the
|
|
10
|
+
// JSONC reader is a small in-file scanner instead of a parser package:
|
|
11
|
+
// - strips // line and /* block */ comments OUTSIDE strings,
|
|
12
|
+
// - drops a trailing comma before } or ] OUTSIDE strings,
|
|
13
|
+
// - understands strings with escapes so "a//b" and "/*" survive.
|
|
14
|
+
// That covers the shapes opencode itself accepts (opencode.jsonc is JSONC).
|
|
15
|
+
//
|
|
16
|
+
// Precedence mirrors opencode: the project config wins over the global one; the
|
|
17
|
+
// env var OPENCODE_CONTEXT_INDICATOR_COMPACT_BUFFER still wins over both (it is
|
|
18
|
+
// a testing/power-user override, resolved in lib/limits.js). A buffer that is
|
|
19
|
+
// not smaller than the window is ignored by the caller, exactly like core.
|
|
20
|
+
//
|
|
21
|
+
// The directory is an ARGUMENT, never module state: one server process can host
|
|
22
|
+
// several plugin instances (one per location), so a single "current project"
|
|
23
|
+
// variable would leak project A's buffer into project B's measurement. Callers
|
|
24
|
+
// pass `ctx.location.directory` (see locationDirectory) or nothing for the
|
|
25
|
+
// global-only lookup.
|
|
26
|
+
//
|
|
27
|
+
// The file is read on demand and re-parsed only when its mtime/size changes, so
|
|
28
|
+
// the 1 s tick never turns this into a hot loop.
|
|
29
|
+
|
|
30
|
+
import { readFileSync, statSync } from "node:fs"
|
|
31
|
+
import { homedir } from "node:os"
|
|
32
|
+
import { join } from "node:path"
|
|
33
|
+
|
|
34
|
+
// path -> { mtimeMs, size, entry }; entry = { ok: true, value } | { ok: false }
|
|
35
|
+
const fileCache = new Map()
|
|
36
|
+
|
|
37
|
+
// The project directory a V2 plugin context belongs to, or "" when the host did
|
|
38
|
+
// not give one. Accepts any object with a `location.directory` string.
|
|
39
|
+
export function locationDirectory(ctx) {
|
|
40
|
+
try {
|
|
41
|
+
const dir = ctx?.location?.directory
|
|
42
|
+
return typeof dir === "string" && dir.length > 0 ? dir : ""
|
|
43
|
+
} catch {
|
|
44
|
+
return ""
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Global config directory, same resolution opencode uses on every platform.
|
|
49
|
+
function globalConfigDir() {
|
|
50
|
+
const xdg = process.env.XDG_CONFIG_HOME
|
|
51
|
+
const base = typeof xdg === "string" && xdg.length > 0 ? xdg : join(homedir(), ".config")
|
|
52
|
+
return join(base, "opencode")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Drop a trailing comma (one followed only by whitespace and } or ]). This is a
|
|
56
|
+
// second string-aware pass: a plain regex would also rewrite `"a,}"` INSIDE a
|
|
57
|
+
// string value, silently changing it.
|
|
58
|
+
function dropTrailingCommas(text) {
|
|
59
|
+
let out = ""
|
|
60
|
+
let inString = false
|
|
61
|
+
let escaped = false
|
|
62
|
+
for (let i = 0; i < text.length; i++) {
|
|
63
|
+
const ch = text[i]
|
|
64
|
+
if (inString) {
|
|
65
|
+
out += ch
|
|
66
|
+
if (escaped) escaped = false
|
|
67
|
+
else if (ch === "\\") escaped = true
|
|
68
|
+
else if (ch === '"') inString = false
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
if (ch === '"') {
|
|
72
|
+
inString = true
|
|
73
|
+
out += ch
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
if (ch === ",") {
|
|
77
|
+
let j = i + 1
|
|
78
|
+
while (j < text.length && /\s/.test(text[j])) j++
|
|
79
|
+
if (text[j] === "}" || text[j] === "]") continue // drop the comma only
|
|
80
|
+
}
|
|
81
|
+
out += ch
|
|
82
|
+
}
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Strip comments and trailing commas from a JSONC document. The scanner is
|
|
87
|
+
// string-aware: `//` or `/*` inside a string value is left untouched.
|
|
88
|
+
export function stripJsonc(text) {
|
|
89
|
+
if (typeof text !== "string") return ""
|
|
90
|
+
let out = ""
|
|
91
|
+
let inString = false
|
|
92
|
+
let escaped = false
|
|
93
|
+
for (let i = 0; i < text.length; i++) {
|
|
94
|
+
const ch = text[i]
|
|
95
|
+
if (inString) {
|
|
96
|
+
out += ch
|
|
97
|
+
if (escaped) {
|
|
98
|
+
escaped = false
|
|
99
|
+
} else if (ch === "\\") {
|
|
100
|
+
escaped = true
|
|
101
|
+
} else if (ch === '"') {
|
|
102
|
+
inString = false
|
|
103
|
+
}
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
if (ch === '"') {
|
|
107
|
+
inString = true
|
|
108
|
+
out += ch
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (ch === "/" && text[i + 1] === "/") {
|
|
112
|
+
while (i < text.length && text[i] !== "\n") i++
|
|
113
|
+
out += "\n"
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
if (ch === "/" && text[i + 1] === "*") {
|
|
117
|
+
i += 2
|
|
118
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++
|
|
119
|
+
i++ // skip the closing "/"
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
out += ch
|
|
123
|
+
}
|
|
124
|
+
return dropTrailingCommas(out)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Parse a JSONC file with caching. Returns { ok, value } (never throws).
|
|
128
|
+
function readJsoncFile(path) {
|
|
129
|
+
let stat
|
|
130
|
+
try {
|
|
131
|
+
stat = statSync(path)
|
|
132
|
+
} catch {
|
|
133
|
+
return { ok: false }
|
|
134
|
+
}
|
|
135
|
+
const cached = fileCache.get(path)
|
|
136
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
137
|
+
return cached.entry
|
|
138
|
+
}
|
|
139
|
+
let entry
|
|
140
|
+
try {
|
|
141
|
+
const parsed = JSON.parse(stripJsonc(readFileSync(path, "utf8")))
|
|
142
|
+
entry = { ok: true, value: parsed }
|
|
143
|
+
} catch {
|
|
144
|
+
// A malformed config is the host's problem to report; the plugin simply
|
|
145
|
+
// falls back to the default reserve rather than guessing.
|
|
146
|
+
entry = { ok: false }
|
|
147
|
+
}
|
|
148
|
+
fileCache.set(path, { mtimeMs: stat.mtimeMs, size: stat.size, entry })
|
|
149
|
+
return entry
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Candidate files for one config directory, in opencode's own precedence.
|
|
153
|
+
function configFiles(dir) {
|
|
154
|
+
return [
|
|
155
|
+
join(dir, "opencode.jsonc"),
|
|
156
|
+
join(dir, "opencode.json"),
|
|
157
|
+
join(dir, ".opencode", "opencode.jsonc"),
|
|
158
|
+
join(dir, ".opencode", "opencode.json"),
|
|
159
|
+
]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function bufferFromFile(path) {
|
|
163
|
+
const read = readJsoncFile(path)
|
|
164
|
+
if (!read.ok) return { found: false }
|
|
165
|
+
const root = read.value && typeof read.value === "object" ? read.value : null
|
|
166
|
+
const compaction = root && typeof root.compaction === "object" && root.compaction ? root.compaction : null
|
|
167
|
+
if (!compaction || !Object.prototype.hasOwnProperty.call(compaction, "buffer")) {
|
|
168
|
+
return { found: false }
|
|
169
|
+
}
|
|
170
|
+
return { found: true, value: compaction.buffer, source: path }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Resolve the effective `compaction.buffer`. Project beats global; inside one
|
|
174
|
+
// directory the first existing file wins (that is how opencode picks a file).
|
|
175
|
+
// Returns { value, source } where value may be undefined when nobody set it.
|
|
176
|
+
export function resolveCompactionBuffer(directory) {
|
|
177
|
+
const globalDir = globalConfigDir()
|
|
178
|
+
const dirs = [globalDir]
|
|
179
|
+
if (typeof directory === "string" && directory.length > 0 && directory !== globalDir) {
|
|
180
|
+
dirs.push(directory)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let result = { value: undefined, source: undefined }
|
|
184
|
+
for (const configDir of dirs) {
|
|
185
|
+
for (const path of configFiles(configDir)) {
|
|
186
|
+
const hit = bufferFromFile(path)
|
|
187
|
+
if (hit.found) {
|
|
188
|
+
result = { value: hit.value, source: hit.source }
|
|
189
|
+
break
|
|
190
|
+
}
|
|
191
|
+
// A file that exists but has no buffer still ends the search in this
|
|
192
|
+
// directory only if it exists; otherwise keep looking at the next name.
|
|
193
|
+
let exists = true
|
|
194
|
+
try {
|
|
195
|
+
statSync(path)
|
|
196
|
+
} catch {
|
|
197
|
+
exists = false
|
|
198
|
+
}
|
|
199
|
+
if (exists) break
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return result
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Convert a configured buffer to tokens for a given window. Accepts the two
|
|
206
|
+
// shapes opencode 2.2.5 accepts (`NonNegativeInt` and a 1%-99% string) and
|
|
207
|
+
// returns undefined for anything else - the caller then keeps the default 15%
|
|
208
|
+
// formula, exactly like core ignoring an unusable buffer. The integer/1e9
|
|
209
|
+
// bounds match the env override in lib/limits.js so both paths reject the same
|
|
210
|
+
// shapes.
|
|
211
|
+
export function bufferTokens(window, raw) {
|
|
212
|
+
if (typeof raw === "number") {
|
|
213
|
+
return Number.isInteger(raw) && raw >= 0 && raw <= 1e9 ? raw : undefined
|
|
214
|
+
}
|
|
215
|
+
if (typeof raw !== "string") return undefined
|
|
216
|
+
const match = /^([1-9]\d?(?:\.\d+)?)%$/.exec(raw)
|
|
217
|
+
if (!match) return undefined
|
|
218
|
+
const percent = Number.parseFloat(match[1])
|
|
219
|
+
if (!Number.isFinite(percent) || percent <= 0 || percent >= 100) return undefined
|
|
220
|
+
if (typeof window !== "number" || !Number.isFinite(window) || window <= 0) return undefined
|
|
221
|
+
return Math.round((window * percent) / 100)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Test-only: drop the parse cache so a rewritten fixture is re-read on the same
|
|
225
|
+
// mtime granularity (Windows FAT timestamps are coarse).
|
|
226
|
+
export function resetConfigCache() {
|
|
227
|
+
fileCache.clear()
|
|
228
|
+
}
|
package/lib/contributors.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// lib/contributors.js
|
|
2
2
|
//
|
|
3
|
-
// Pure contributor-measurement helpers extracted from
|
|
3
|
+
// Pure contributor-measurement helpers extracted from the TUI panel (lib/tui-panel.ts)
|
|
4
|
+
// for unit testing.
|
|
4
5
|
// All functions are stateless transforms of their arguments.
|
|
5
6
|
//
|
|
6
7
|
// Functions: asRecord, safeText, measureContributors, contributorLine,
|
|
@@ -185,7 +186,7 @@ function exportFileName(sessionID, now) {
|
|
|
185
186
|
}
|
|
186
187
|
|
|
187
188
|
// Build the redacted export payload. All IO (mkdirSync/writeFileSync) and
|
|
188
|
-
// toasts stay in tui.
|
|
189
|
+
// toasts stay in lib/tui-panel.ts as the thin exportRedacted() wrapper.
|
|
189
190
|
//
|
|
190
191
|
// Fields: generatedAt, session{id8-prefixed id, model, providerID},
|
|
191
192
|
// family[role/model/ctx/usable/limit/pct/updated], contributors[kind/tool/ref/
|
|
@@ -193,7 +194,7 @@ function exportFileName(sessionID, now) {
|
|
|
193
194
|
// content, NO contributor preview text.
|
|
194
195
|
//
|
|
195
196
|
// panelDenom / panelRole are passed as parameters so this file stays pure and
|
|
196
|
-
// tui.
|
|
197
|
+
// lib/tui-panel.ts controls their source.
|
|
197
198
|
function buildRedactedPayload({ sessionID, root, rows, contributors, now, panelDenom, panelRole, num }) {
|
|
198
199
|
const id8 = String(sessionID).slice(0, 8);
|
|
199
200
|
return {
|
|
@@ -227,7 +228,7 @@ function buildRedactedPayload({ sessionID, root, rows, contributors, now, panelD
|
|
|
227
228
|
}
|
|
228
229
|
|
|
229
230
|
// ---------------------------------------------------------------------------
|
|
230
|
-
// Panel helpers (pure, shared with tui.
|
|
231
|
+
// Panel helpers (pure, shared with lib/tui-panel.ts)
|
|
231
232
|
// ---------------------------------------------------------------------------
|
|
232
233
|
|
|
233
234
|
// Per-row denominator with the same fallback cascade as the server table:
|
package/lib/limits.js
CHANGED
|
@@ -10,21 +10,27 @@
|
|
|
10
10
|
// Compaction ceiling (opencode calculateCeiling): opencode's core
|
|
11
11
|
// calculateCeiling() in packages/core/src/session/compaction.ts derives the
|
|
12
12
|
// auto-compaction trigger ceiling as window - reserve, where
|
|
13
|
-
//
|
|
14
|
-
//
|
|
13
|
+
// window = input && context ? min(input, context) : input || context
|
|
14
|
+
// (null/zero falls through; since opencode 2.2.5 (#60660) an input limit ABOVE
|
|
15
|
+
// the context window is capped at the context window, because the context also
|
|
16
|
+
// holds the reply) and
|
|
15
17
|
// reserve = max(floor(window * 0.15), window >= 2 * 16_000 ? 16_000 : 0)
|
|
16
|
-
// `RESERVE_SHARE = 0.15` in every RELEASED host from 2.1.4 through 2.2.
|
|
18
|
+
// `RESERVE_SHARE = 0.15` in every RELEASED host from 2.1.4 through 2.2.5
|
|
17
19
|
// (verified tag by tag; the `2 * RESERVE_MIN` gate is older and unchanged). The
|
|
18
20
|
// unreleased `v2` branch HEAD has an inline `0.1` instead - do NOT mirror a
|
|
19
21
|
// branch head: a wrong reserve makes every displayed percentage wrong.
|
|
20
|
-
// A compaction.buffer configured in opencode itself is NOT
|
|
21
|
-
// plugin
|
|
22
|
-
//
|
|
23
|
-
// testing
|
|
24
|
-
//
|
|
25
|
-
//
|
|
22
|
+
// A compaction.buffer configured in opencode itself is NOT exposed through the
|
|
23
|
+
// plugin API, so lib/config.js reads the user's own config file and this module
|
|
24
|
+
// applies it as the reserve. Precedence: the env var
|
|
25
|
+
// OPENCODE_CONTEXT_INDICATOR_COMPACT_BUFFER (testing / power users) wins, then
|
|
26
|
+
// the configured buffer, then the 15% default.
|
|
27
|
+
// opencode 2.2.5 accepts a token count or a percentage ("buffer": "20%"); both
|
|
28
|
+
// are converted with Math.round(window * pct / 100), matching core. Like core, a
|
|
29
|
+
// buffer that is NOT smaller than the window is ignored: a buffer set for a
|
|
30
|
+
// larger model must never leave zero room.
|
|
26
31
|
|
|
27
32
|
import { finitePos, withTimeout } from "./estimate.js";
|
|
33
|
+
import { bufferTokens, resolveCompactionBuffer } from "./config.js";
|
|
28
34
|
import { logErr, readStateSnapshot } from "./state.js";
|
|
29
35
|
|
|
30
36
|
const PROVIDERS_TTL_MS = 5 * 60 * 1000; // providers()/model.list() cache TTL after SUCCESS
|
|
@@ -86,7 +92,7 @@ function getModelLimit(providerID, modelID) {
|
|
|
86
92
|
|
|
87
93
|
// Compute the reserve for a given window, matching opencode's
|
|
88
94
|
// packages/core/src/session/compaction.ts calculateCeiling() as RELEASED
|
|
89
|
-
// (RESERVE_SHARE = 0.15 in 2.1.4 .. 2.2.
|
|
95
|
+
// (RESERVE_SHARE = 0.15 in 2.1.4 .. 2.2.5; the branch head is not a release).
|
|
90
96
|
// Returns null when
|
|
91
97
|
// window is not a finite positive number.
|
|
92
98
|
const RESERVE_SHARE = 0.15; // core: RESERVE_SHARE in every released 2.x
|
|
@@ -117,17 +123,60 @@ function reserveFor(window, bufferOverride) {
|
|
|
117
123
|
);
|
|
118
124
|
}
|
|
119
125
|
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Configured compaction.buffer (opencode 2.2.5+), cached per project directory.
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
// getUsableContext runs on every measured request and on every command/panel
|
|
131
|
+
// build, so the config file must not be stat()ed four times per call. One
|
|
132
|
+
// second is far below a human edit cycle and keeps a config change visible
|
|
133
|
+
// almost immediately. The cache is keyed by the caller's project directory: one
|
|
134
|
+
// server process can host several plugin instances (one per location) and the
|
|
135
|
+
// shared module must not apply project A's buffer to project B.
|
|
136
|
+
const CONFIGURED_BUFFER_TTL_MS = 1000;
|
|
137
|
+
const configuredBufferCache = new Map(); // dirKey -> { at, value }
|
|
138
|
+
|
|
139
|
+
function configuredCompactionBuffer(directory) {
|
|
140
|
+
const key = typeof directory === "string" ? directory : "";
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
const cached = configuredBufferCache.get(key);
|
|
143
|
+
if (cached && now - cached.at < CONFIGURED_BUFFER_TTL_MS) return cached.value;
|
|
144
|
+
let value;
|
|
145
|
+
try {
|
|
146
|
+
value = resolveCompactionBuffer(key).value;
|
|
147
|
+
} catch {
|
|
148
|
+
value = undefined;
|
|
149
|
+
}
|
|
150
|
+
configuredBufferCache.set(key, { at: now, value });
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Test-only: forget the TTL cache so a rewritten fixture is picked up by the
|
|
155
|
+
// next call.
|
|
156
|
+
function resetConfiguredBufferCache() {
|
|
157
|
+
configuredBufferCache.clear();
|
|
158
|
+
}
|
|
159
|
+
|
|
120
160
|
// Native opencode compaction ceiling (calculateCeiling, released 2.x):
|
|
121
|
-
// window =
|
|
161
|
+
// window = input && context ? min(input, context) : input || context
|
|
122
162
|
// reserve = reserveFor(window) [or bufferOverride when provided]
|
|
123
163
|
// usable = window - reserve
|
|
124
164
|
// Returns { usable, limit: window, maxOutput, reserved } or null when not
|
|
125
165
|
// derivable (callers then degrade to count / limit.context, or no percentage).
|
|
126
166
|
// The per-model compaction.reserved and the old min(20000, maxOutput) no longer
|
|
127
|
-
// participate in this calculation
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
167
|
+
// participate in this calculation. The reserve comes from (in order) the
|
|
168
|
+
// argument, the env override, the user's configured compaction.buffer (see
|
|
169
|
+
// lib/config.js) or the 15% share, and replaces the whole reserve only while it
|
|
170
|
+
// is smaller than the window - a buffer >= window falls back to the share.
|
|
171
|
+
//
|
|
172
|
+
// The min() is opencode 2.2.5 (#60660): a provider that reports an input limit
|
|
173
|
+
// ABOVE the context window cannot use it in full, because the context also holds
|
|
174
|
+
// the reply. Using input alone would overstate the denominator (and show a
|
|
175
|
+
// compact-at point the host never reaches).
|
|
176
|
+
//
|
|
177
|
+
// `directory` is the calling instance's project directory (ctx.location); it
|
|
178
|
+
// scopes the configured-buffer lookup. Omitted means global config only.
|
|
179
|
+
function getUsableContext(providerID, modelID, bufferOverride, directory) {
|
|
131
180
|
const rec = getModelLimitRecord(providerID, modelID);
|
|
132
181
|
if (!rec) return null;
|
|
133
182
|
const context =
|
|
@@ -135,13 +184,18 @@ function getUsableContext(providerID, modelID, bufferOverride) {
|
|
|
135
184
|
const input =
|
|
136
185
|
typeof rec.input === "number" && rec.input > 0 ? rec.input : null;
|
|
137
186
|
if (input == null && context == null) return null;
|
|
138
|
-
// Mirror core's
|
|
139
|
-
|
|
187
|
+
// Mirror core's inputWindow(): min(input, context) when both are known.
|
|
188
|
+
// The `??` is core's `||` here: a non-positive input was normalized to null
|
|
189
|
+
// above, so there is no 0 to fall through.
|
|
190
|
+
const window =
|
|
191
|
+
input != null && context != null ? Math.min(input, context) : input ?? context;
|
|
140
192
|
if (window == null || !(window > 0)) return null;
|
|
141
193
|
|
|
142
194
|
// When bufferOverride is omitted, read it from the env var once per call.
|
|
143
195
|
let resolvedBuffer = bufferOverride;
|
|
144
|
-
if (resolvedBuffer
|
|
196
|
+
if (resolvedBuffer !== undefined) {
|
|
197
|
+
// explicit argument wins over everything
|
|
198
|
+
} else {
|
|
145
199
|
const raw = process.env.OPENCODE_CONTEXT_INDICATOR_COMPACT_BUFFER;
|
|
146
200
|
// Strict digits-only: reject "5000abc", "5e3", " 5000", "", "-1" and other
|
|
147
201
|
// partial parses (parseInt/parseFloat stop at the first non-digit).
|
|
@@ -152,6 +206,13 @@ function getUsableContext(providerID, modelID, bufferOverride) {
|
|
|
152
206
|
}
|
|
153
207
|
}
|
|
154
208
|
}
|
|
209
|
+
// No env override: use opencode's own compaction.buffer when the user set one.
|
|
210
|
+
if (resolvedBuffer === undefined) {
|
|
211
|
+
const configured = configuredCompactionBuffer(directory);
|
|
212
|
+
if (configured !== undefined) {
|
|
213
|
+
resolvedBuffer = bufferTokens(window, configured);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
155
216
|
|
|
156
217
|
const reserve = reserveFor(window, resolvedBuffer);
|
|
157
218
|
if (reserve == null) return null;
|
|
@@ -469,6 +530,7 @@ export {
|
|
|
469
530
|
getModelLimit,
|
|
470
531
|
getUsableContext,
|
|
471
532
|
reserveFor,
|
|
533
|
+
resetConfiguredBufferCache,
|
|
472
534
|
ensureProviderLimits,
|
|
473
535
|
ensureModelLimits,
|
|
474
536
|
hydrateModelLimitsFromState,
|
package/lib/state.js
CHANGED
|
@@ -60,7 +60,7 @@ const LOG_FILE =
|
|
|
60
60
|
process.env.OPENCODE_CONTEXT_INDICATOR_LOG_FILE ||
|
|
61
61
|
join(tmpdir(), "context-breakdown.log");
|
|
62
62
|
|
|
63
|
-
// Machine-readable snapshot consumed by the optional TUI sidebar (./tui.
|
|
63
|
+
// Machine-readable snapshot consumed by the optional TUI sidebar (./tui.ts,
|
|
64
64
|
// package entry "./tui"). Written next to each log update — same moment, same
|
|
65
65
|
// data. See writeStateFile() below.
|
|
66
66
|
// State file path — overridable via env for test isolation.
|
|
@@ -417,7 +417,7 @@ function renameWithRetry(from, to, attempts = 3, delayMs = 50) {
|
|
|
417
417
|
// TUI state bridge (opencode-context-indicator)
|
|
418
418
|
// ---------------------------------------------------------------------------
|
|
419
419
|
// Persist a tiny machine-readable snapshot of the CURRENT breakdown for one
|
|
420
|
-
// session so the optional TUI sidebar (./tui.
|
|
420
|
+
// session so the optional TUI sidebar (./tui.ts, package entry "./tui") can
|
|
421
421
|
// render it without talking to the server. The file is one JSON object keyed by
|
|
422
422
|
// sessionID, rewritten atomically (temp + rename) at every log write — same
|
|
423
423
|
// moment, same data. Strictly additive: any failure is swallowed and must never
|
|
@@ -459,10 +459,11 @@ function writeStateFile(
|
|
|
459
459
|
}
|
|
460
460
|
try {
|
|
461
461
|
const bd = breakdownCache.get(sessionID) || null;
|
|
462
|
-
const usableInfo = getUsableContext(providerID, modelID);
|
|
463
|
-
// Persist the EFFECTIVE window (
|
|
464
|
-
// window the ceiling was derived from),
|
|
465
|
-
//
|
|
462
|
+
const usableInfo = getUsableContext(providerID, modelID, undefined, opts?.directory);
|
|
463
|
+
// Persist the EFFECTIVE window (min(input, context) when both are known,
|
|
464
|
+
// else input || context: the same window the ceiling was derived from),
|
|
465
|
+
// not the raw context window: on a cold instance
|
|
466
|
+
// hydrateModelLimitsFromState seeds context = limit, so a
|
|
466
467
|
// mismatched limit would re-derive a WRONG (too large) ceiling for models
|
|
467
468
|
// where limit.input < limit.context.
|
|
468
469
|
const limit =
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// lib/tui-composer.ts - the `prompt.footer.status` slot component.
|
|
2
|
+
//
|
|
3
|
+
// One always-visible usage capsule inside the TUI composer (`38% ctx`), the
|
|
4
|
+
// terminal counterpart of the Desktop composer pill (gui/index.tsx). Without it
|
|
5
|
+
// the percentage only exists inside the sidebar (hidden when collapsed) or the
|
|
6
|
+
// full-screen `/cx` panel.
|
|
7
|
+
//
|
|
8
|
+
// Same rules as every other TUI surface here: NO JSX, no pragma, every dynamic
|
|
9
|
+
// prop a getter (see lib/tui-h.ts). The host ships the slot since opencode 2.2.x
|
|
10
|
+
// (`prompt.footer.status` accepts `{ sessionID, mode, showDetails }`); an older
|
|
11
|
+
// host simply lacks the path and registerSlot() logs once in tui.ts.
|
|
12
|
+
//
|
|
13
|
+
// The component renders nothing on its own when there is no measurement: a
|
|
14
|
+
// missing window must not push an empty box into the composer.
|
|
15
|
+
|
|
16
|
+
import { usePlugin } from "@opencode/plugin/tui"
|
|
17
|
+
import { Show } from "solid-js"
|
|
18
|
+
import type { StateEntry } from "./tui-data.js"
|
|
19
|
+
import { composerStatus } from "./tui-data.js"
|
|
20
|
+
import { fmt } from "./format.js"
|
|
21
|
+
import { token } from "./tui-theme.js"
|
|
22
|
+
import { c, h } from "./tui-h.ts"
|
|
23
|
+
|
|
24
|
+
export function ComposerStatus(props: {
|
|
25
|
+
entry: () => StateEntry | null
|
|
26
|
+
/** "normal" | "shell"; the host passes this on every render. */
|
|
27
|
+
mode: () => string
|
|
28
|
+
/** The host's own compact/detailed toggle (`showDetails`). */
|
|
29
|
+
showDetails: () => boolean
|
|
30
|
+
}) {
|
|
31
|
+
const context = usePlugin()
|
|
32
|
+
const base = () => token(context?.theme?.text?.base, "white")
|
|
33
|
+
const warn = () => token(context?.theme?.text?.feedback?.warning?.base, "yellow")
|
|
34
|
+
const danger = () => token(context?.theme?.text?.feedback?.error?.base, "red")
|
|
35
|
+
|
|
36
|
+
// A shell prompt is not a model turn: the number would describe the previous
|
|
37
|
+
// exchange and sit next to a `$` prompt, so it is hidden there.
|
|
38
|
+
const status = () => (props.mode() === "shell" ? null : composerStatus(props.entry()))
|
|
39
|
+
|
|
40
|
+
const fg = () => {
|
|
41
|
+
const tone = status()?.tone
|
|
42
|
+
return tone === "danger" ? danger() : tone === "warning" ? warn() : base()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const label = () => {
|
|
46
|
+
const s = status()
|
|
47
|
+
if (!s) return ""
|
|
48
|
+
if (!props.showDetails()) return s.label
|
|
49
|
+
const e = props.entry()
|
|
50
|
+
const ctx = typeof e?.ctx === "number" ? e.ctx : 0
|
|
51
|
+
const usable = typeof e?.usable === "number" ? e.usable : 0
|
|
52
|
+
return `${s.label} ${fmt(ctx)}/${fmt(usable)}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return c(Show, {
|
|
56
|
+
get when() {
|
|
57
|
+
return status()
|
|
58
|
+
},
|
|
59
|
+
get children() {
|
|
60
|
+
return h("text", {
|
|
61
|
+
get fg() {
|
|
62
|
+
return fg()
|
|
63
|
+
},
|
|
64
|
+
get children() {
|
|
65
|
+
return label()
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
}
|
package/lib/tui-data.d.ts
CHANGED
|
@@ -54,7 +54,16 @@ export interface StateEntry {
|
|
|
54
54
|
|
|
55
55
|
export declare const STATE_FILE: string
|
|
56
56
|
export declare const POLL_MS: number
|
|
57
|
-
export
|
|
57
|
+
export interface ComposerStatus {
|
|
58
|
+
percent: number
|
|
59
|
+
label: string
|
|
60
|
+
tone?: "warning" | "danger"
|
|
61
|
+
}
|
|
62
|
+
export declare const COMPOSER_WARN_PERCENT: number
|
|
63
|
+
export declare const COMPOSER_DANGER_PERCENT: number
|
|
64
|
+
export declare function composerStatus(
|
|
65
|
+
entry: StateEntry | null | undefined,
|
|
66
|
+
): ComposerStatus | null
|
|
58
67
|
export declare function readSnapshot(): Record<string, StateEntry>
|
|
59
68
|
export declare function collectFamily(
|
|
60
69
|
snapshot: Record<string, StateEntry>,
|
package/lib/tui-data.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// lib/tui-data.js
|
|
2
2
|
//
|
|
3
|
-
// Data layer for the TUI entry (tui.
|
|
3
|
+
// Data layer for the TUI entry (tui.ts): the state.json bridge (readSnapshot /
|
|
4
4
|
// isEmptyEntry / collectFamily), the shared formatting helpers (fmt / num / fit
|
|
5
|
-
// / updatedAt) and the
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// / updatedAt) and the family walk. Plain JS with JSDoc — same shape as
|
|
6
|
+
// lib/contributors.js — so tui.ts stays a thin setup shell and the node tooling
|
|
7
|
+
// (resolve gate, unit tests) can import it without a transform.
|
|
8
8
|
//
|
|
9
9
|
// These helpers must stay free of solid-js / @opentui imports: the TUI host
|
|
10
|
-
// bridges those modules only for the
|
|
10
|
+
// bridges those modules only for the plugin bundle, and keeping this file pure
|
|
11
11
|
// means it also loads anywhere node does.
|
|
12
12
|
//
|
|
13
13
|
// state.json shape (written by writeStateFile() in index.js, untrusted input):
|
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
import { readFileSync } from "node:fs";
|
|
21
21
|
import { tmpdir } from "node:os";
|
|
22
22
|
import { join } from "node:path";
|
|
23
|
-
import { fileURLToPath } from "node:url";
|
|
24
23
|
import { isSafeSessionKey, sumCategories } from "./estimate.js";
|
|
25
24
|
import { fmt, num } from "./format.js";
|
|
26
25
|
|
|
@@ -38,21 +37,6 @@ export const STATE_FILE =
|
|
|
38
37
|
|
|
39
38
|
export const POLL_MS = 1000;
|
|
40
39
|
|
|
41
|
-
// True when this package was loaded from an npm install, i.e. its own file
|
|
42
|
-
// path contains a `node_modules` segment (OpenCode's TUI loader skips the host
|
|
43
|
-
// Solid transform for such paths → slots mount once but never live-update; see
|
|
44
|
-
// the Graceful mode note in tui.tsx). Normalised to forward slashes for
|
|
45
|
-
// Windows paths. ANY file of the package yields the same answer, so checking
|
|
46
|
-
// from lib/ is equivalent to checking from the tui.tsx entry.
|
|
47
|
-
export function isNodeModulesInstall() {
|
|
48
|
-
try {
|
|
49
|
-
const selfPath = fileURLToPath(import.meta.url).replace(/\\/g, "/").toLowerCase()
|
|
50
|
-
return selfPath.includes("/node_modules/")
|
|
51
|
-
} catch {
|
|
52
|
-
return false
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
40
|
// Whole state.json snapshot ({ sessionID: entry }) or {} on any error. The
|
|
57
41
|
// sidebar resolves one entry from it and the breakdown panel walks the
|
|
58
42
|
// parentID family over it. Never throws — a missing / empty / corrupt
|
|
@@ -147,3 +131,37 @@ export function updatedAt(iso) {
|
|
|
147
131
|
return "?"
|
|
148
132
|
}
|
|
149
133
|
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Composer pill (`prompt.footer.status` slot).
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
// Same thresholds as the Desktop pill (gui/index.tsx PILL_* constants): amber
|
|
140
|
+
// from 65% of the usable window, red from 85%. Kept in ONE place for the TUI
|
|
141
|
+
// and mirrored there on purpose - the GUI bundle is separate, but a divergence
|
|
142
|
+
// in the two numbers reads as a bug.
|
|
143
|
+
export const COMPOSER_WARN_PERCENT = 65;
|
|
144
|
+
export const COMPOSER_DANGER_PERCENT = 85;
|
|
145
|
+
|
|
146
|
+
// One-line usage capsule: `{ percent, label, tone }`, or null when the entry
|
|
147
|
+
// has no usable window (no data yet, a fresh session, or a model without a
|
|
148
|
+
// limit). Pure and dependency-free so the unit harness can pin the thresholds.
|
|
149
|
+
export function composerStatus(entry) {
|
|
150
|
+
if (!entry || typeof entry !== "object") return null;
|
|
151
|
+
const usable = num(entry.usable);
|
|
152
|
+
const ctx = num(entry.ctx);
|
|
153
|
+
if (!(usable > 0) || ctx < 0) return null;
|
|
154
|
+
const share = (ctx / usable) * 100;
|
|
155
|
+
if (!Number.isFinite(share)) return null;
|
|
156
|
+
const percent = Math.round(share);
|
|
157
|
+
return {
|
|
158
|
+
percent,
|
|
159
|
+
label: `${percent}% ctx`,
|
|
160
|
+
tone:
|
|
161
|
+
share >= COMPOSER_DANGER_PERCENT
|
|
162
|
+
? "danger"
|
|
163
|
+
: share >= COMPOSER_WARN_PERCENT
|
|
164
|
+
? "warning"
|
|
165
|
+
: undefined,
|
|
166
|
+
};
|
|
167
|
+
}
|