@serkanalgur/opencodev2-slim 2.0.11 → 2.0.13
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/README.md +14 -0
- package/package.json +1 -1
- package/src/index.ts +40 -14
- package/src/lib/tui.ts +28 -8
- package/src/tui.tsx +63 -7
package/README.md
CHANGED
|
@@ -141,6 +141,20 @@ Note: Compression is performed by the AI assistant using the `compress` tool. Th
|
|
|
141
141
|
|
|
142
142
|
## Changelog
|
|
143
143
|
|
|
144
|
+
### 2.0.13
|
|
145
|
+
|
|
146
|
+
- Fix `/panel` in the TUI not reflecting real context usage:
|
|
147
|
+
- The TUI command now reads live server measurements (`Session.Info.tokens` + `cost` + `model` + context window) via `context.client.session.get()` and prints them (measured tokens, %, cost, model) at the bottom of the panel, matching what the `panel` tool reports.
|
|
148
|
+
- Resolve the active session from `context.ui.router.current()` instead of a non-existent `context.router`, so the panel targets the focused session rather than always the first one.
|
|
149
|
+
- Call `context.data.session.message.sync()` before reading the transcript so stats aren't computed from an empty/stale cache.
|
|
150
|
+
|
|
151
|
+
### 2.0.12
|
|
152
|
+
|
|
153
|
+
- Bind panel/nudge to real OpenCode context measurements:
|
|
154
|
+
- Resolve the active model's real context window from `ctx.model.default().data.limit.context` instead of the hard-coded 200k.
|
|
155
|
+
- `/panel` now reads live server measurements (`Session.Info.tokens` + `cost`) via `ctx.session.get()` and feeds them to `buildPanelData`, so the headline tokens/percent/cost match what OpenCode's UI reports.
|
|
156
|
+
- The nudge decision prefers the measured token count over the rough 4-char estimation.
|
|
157
|
+
|
|
144
158
|
### 2.0.11
|
|
145
159
|
|
|
146
160
|
- Fix CI publish: add `solid-js`, `@opentui/core`, `@opentui/solid` to `devDependencies`.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -36,20 +36,13 @@ function getConfig(sessionId: string): SlimConfig {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// Resolve the active model's real context limit instead of hard-coding 200k.
|
|
39
|
+
// ctx.model.default() returns { data: ModelInfo | null }, where ModelInfo.limit.context
|
|
40
|
+
// holds the model's context window. Use it directly and only fall back when missing.
|
|
39
41
|
async function resolveModelContextLimit(ctx: any): Promise<number> {
|
|
40
42
|
try {
|
|
41
|
-
const
|
|
42
|
-
const selected: { providerID?: string; modelID?: string } | undefined =
|
|
43
|
+
const selected: { data?: { limit?: { context?: number } } | null } | undefined =
|
|
43
44
|
await ctx.model.default()
|
|
44
|
-
const
|
|
45
|
-
models.find(
|
|
46
|
-
(m) =>
|
|
47
|
-
(selected?.modelID && m.id === selected.modelID) ||
|
|
48
|
-
(selected?.providerID && m.providerID === selected.providerID),
|
|
49
|
-
) ||
|
|
50
|
-
models.find((m) => m.limit?.context) ||
|
|
51
|
-
undefined
|
|
52
|
-
const limit = match?.limit?.context
|
|
45
|
+
const limit = selected?.data?.limit?.context
|
|
53
46
|
return typeof limit === "number" && limit > 0 ? limit : DEFAULT_MODEL_LIMIT
|
|
54
47
|
} catch {
|
|
55
48
|
return DEFAULT_MODEL_LIMIT
|
|
@@ -300,6 +293,31 @@ export default Plugin.define({
|
|
|
300
293
|
const state = getState(sessionId, config)
|
|
301
294
|
|
|
302
295
|
try {
|
|
296
|
+
// Pull the real, server-measured context usage for this session.
|
|
297
|
+
let measured: import("./lib/tui").MeasuredContext | undefined
|
|
298
|
+
try {
|
|
299
|
+
const info = await ctx.session.get({ sessionID: sessionId })
|
|
300
|
+
const tokens = info.tokens as any
|
|
301
|
+
const tokenCount =
|
|
302
|
+
(tokens?.input ?? 0) +
|
|
303
|
+
(tokens?.output ?? 0) +
|
|
304
|
+
(tokens?.reasoning ?? 0) +
|
|
305
|
+
(tokens?.cache?.read ?? 0) +
|
|
306
|
+
(tokens?.cache?.write ?? 0)
|
|
307
|
+
measured = {
|
|
308
|
+
tokens: tokenCount,
|
|
309
|
+
cost: typeof info.cost === "number" ? info.cost : 0,
|
|
310
|
+
contextLimit:
|
|
311
|
+
state.modelContextLimit || (await resolveModelContextLimit(ctx)),
|
|
312
|
+
model: (info.model && (info.model as any).id) || "unknown",
|
|
313
|
+
}
|
|
314
|
+
// Keep state's headline figure aligned with reality.
|
|
315
|
+
state.modelContextLimit = measured.contextLimit
|
|
316
|
+
state.currentTokenCount = measured.tokens
|
|
317
|
+
} catch {
|
|
318
|
+
// Fall through to estimation if session.get fails.
|
|
319
|
+
}
|
|
320
|
+
|
|
303
321
|
const messages = await ctx.session.context({ sessionID: sessionId })
|
|
304
322
|
|
|
305
323
|
if (!messages || messages.length === 0) {
|
|
@@ -315,9 +333,12 @@ export default Plugin.define({
|
|
|
315
333
|
messageWithParts,
|
|
316
334
|
state,
|
|
317
335
|
config,
|
|
336
|
+
measured?.model,
|
|
337
|
+
measured,
|
|
318
338
|
)
|
|
319
339
|
|
|
320
340
|
const panel = renderPanel(panelData)
|
|
341
|
+
saveSessionState(state, config.persistence.directory)
|
|
321
342
|
return { content: panel }
|
|
322
343
|
} catch (error) {
|
|
323
344
|
return {
|
|
@@ -367,19 +388,24 @@ export default Plugin.define({
|
|
|
367
388
|
}
|
|
368
389
|
}
|
|
369
390
|
|
|
370
|
-
// Quick token estimate (sync, ~4 chars per token)
|
|
371
|
-
|
|
391
|
+
// Quick token estimate (sync, ~4 chars per token). On the first pass
|
|
392
|
+
// (before the panel has written real numbers into state) this is a
|
|
393
|
+
// fallback; afterwards we prefer the server-measured value.
|
|
394
|
+
let estimatedTokens = 0
|
|
372
395
|
for (const msg of event.messages) {
|
|
373
396
|
const content = (msg as any).content
|
|
374
397
|
if (Array.isArray(content)) {
|
|
375
398
|
for (const part of content) {
|
|
376
399
|
if (part.type === "text" && part.text) {
|
|
377
|
-
|
|
400
|
+
estimatedTokens += Math.ceil(part.text.length / 4)
|
|
378
401
|
}
|
|
379
402
|
}
|
|
380
403
|
}
|
|
381
404
|
}
|
|
382
405
|
|
|
406
|
+
// Prefer the real measured token count when available; else the estimate.
|
|
407
|
+
const totalTokens =
|
|
408
|
+
state.currentTokenCount > 0 ? state.currentTokenCount : estimatedTokens
|
|
383
409
|
state.currentTokenCount = totalTokens
|
|
384
410
|
|
|
385
411
|
const maxTokens = resolveTokenLimit(
|
package/src/lib/tui.ts
CHANGED
|
@@ -50,17 +50,30 @@ export interface PanelData {
|
|
|
50
50
|
|
|
51
51
|
// ─── Panel Builder ─────────────────────────────────────────────────────────
|
|
52
52
|
|
|
53
|
+
export interface MeasuredContext {
|
|
54
|
+
/** Real context input tokens for this session (Session.Info.tokens.input). */
|
|
55
|
+
tokens: number
|
|
56
|
+
/** Real total spend for this session (Session.Info.cost). */
|
|
57
|
+
cost: number
|
|
58
|
+
/** Real model context window (Model.Info.limit.context). */
|
|
59
|
+
contextLimit: number
|
|
60
|
+
/** Real model id. */
|
|
61
|
+
model: string
|
|
62
|
+
}
|
|
63
|
+
|
|
53
64
|
export async function buildPanelData(
|
|
54
65
|
sessionId: string,
|
|
55
66
|
messages: MessageWithParts[],
|
|
56
67
|
state: SessionState,
|
|
57
68
|
config: SlimConfig,
|
|
58
69
|
modelId?: string,
|
|
70
|
+
measured?: MeasuredContext,
|
|
59
71
|
): Promise<PanelData> {
|
|
60
|
-
|
|
72
|
+
// Prefer the real model context window from the server; fall back to state/default.
|
|
73
|
+
const modelContextLimit = measured?.contextLimit || state.modelContextLimit || 200000
|
|
61
74
|
const maxTokens = resolveTokenLimit(config.compress.maxContextLimit, modelContextLimit)
|
|
62
75
|
|
|
63
|
-
// Count tokens
|
|
76
|
+
// Count tokens (estimation for role breakdown; real total used for usage %).
|
|
64
77
|
let currentTokens = 0
|
|
65
78
|
const tokensByRole = { user: 0, assistant: 0, tools: 0, system: 0 }
|
|
66
79
|
let userMessages = 0
|
|
@@ -102,8 +115,15 @@ export async function buildPanelData(
|
|
|
102
115
|
tokensByRole.tools = Math.max(tokensByRole.tools, 0)
|
|
103
116
|
tokensByRole.system = Math.max(0, currentTokens - tokensByRole.user - tokensByRole.assistant - tokensByRole.tools)
|
|
104
117
|
|
|
105
|
-
//
|
|
106
|
-
|
|
118
|
+
// Prefer the server-measured real token count for the headline usage figure.
|
|
119
|
+
// Role buckets remain our estimate for breakdown detail.
|
|
120
|
+
const effectiveTokens = measured?.tokens ?? currentTokens
|
|
121
|
+
|
|
122
|
+
// Usage % shown to the user is relative to the real model context window
|
|
123
|
+
// (e.g. 220k / 1M = 22%), matching what OpenCode's own UI displays. The
|
|
124
|
+
// configured maxTokens (a percentage of that same window) drives nudge/compress.
|
|
125
|
+
const usageBase = measured?.contextLimit ? modelContextLimit : modelContextLimit
|
|
126
|
+
const usagePercent = (effectiveTokens / modelContextLimit) * 100
|
|
107
127
|
let status: "healthy" | "warning" | "critical" = "healthy"
|
|
108
128
|
if (usagePercent > 90) status = "critical"
|
|
109
129
|
else if (usagePercent > 70) status = "warning"
|
|
@@ -121,9 +141,9 @@ export async function buildPanelData(
|
|
|
121
141
|
? state.compressionHistory[state.compressionHistory.length - 1]
|
|
122
142
|
: null
|
|
123
143
|
|
|
124
|
-
// Cost estimate
|
|
144
|
+
// Cost: prefer the server-measured real spend; else estimate from tokens.
|
|
125
145
|
const profile = COST_PROFILES[modelId || "default"] || COST_PROFILES.default
|
|
126
|
-
const estimatedCost = (currentTokens / 1000) * profile.inputPricePer1k
|
|
146
|
+
const estimatedCost = measured?.cost ?? (currentTokens / 1000) * profile.inputPricePer1k
|
|
127
147
|
const costSaved = (totalTokensSaved / 1000) * profile.inputPricePer1k
|
|
128
148
|
|
|
129
149
|
// Topic distribution
|
|
@@ -155,7 +175,7 @@ export async function buildPanelData(
|
|
|
155
175
|
return {
|
|
156
176
|
sessionId,
|
|
157
177
|
timestamp: Date.now(),
|
|
158
|
-
currentTokens,
|
|
178
|
+
currentTokens: effectiveTokens,
|
|
159
179
|
maxTokens,
|
|
160
180
|
usagePercent,
|
|
161
181
|
status,
|
|
@@ -171,7 +191,7 @@ export async function buildPanelData(
|
|
|
171
191
|
lastCompression,
|
|
172
192
|
estimatedCost,
|
|
173
193
|
costSaved,
|
|
174
|
-
model: modelId || "unknown",
|
|
194
|
+
model: measured?.model || modelId || "unknown",
|
|
175
195
|
topics,
|
|
176
196
|
recommendations,
|
|
177
197
|
}
|
package/src/tui.tsx
CHANGED
|
@@ -94,7 +94,14 @@ export function deriveStats(messages: readonly unknown[]): PanelStats {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
// Builds a human-readable panel as plain text (injected into the message stream).
|
|
97
|
-
function renderPanelText(
|
|
97
|
+
function renderPanelText(
|
|
98
|
+
sessionID: string,
|
|
99
|
+
stats: PanelStats,
|
|
100
|
+
real?: MeasuredReal | null,
|
|
101
|
+
): string {
|
|
102
|
+
const limit = real?.contextLimit ?? 0
|
|
103
|
+
const pct = real?.usagePercent ?? 0
|
|
104
|
+
const status = real ? (pct >= 90 ? "critical" : pct >= 70 ? "warning" : "healthy") : "n/a"
|
|
98
105
|
const lines: string[] = []
|
|
99
106
|
lines.push("┌─────────────────────────────────────────────────────────────┐")
|
|
100
107
|
lines.push("│ SLIM CONTEXT PANEL │")
|
|
@@ -107,22 +114,67 @@ function renderPanelText(sessionID: string, stats: PanelStats): string {
|
|
|
107
114
|
lines.push(`│ Tool calls: ${stats.toolCalls} Compactions: ${stats.compactionCount}`)
|
|
108
115
|
lines.push(`│ Tokens (est): User ${stats.tokensByRole.user} | Assistant ${stats.tokensByRole.assistant} | System ${stats.tokensByRole.system}`)
|
|
109
116
|
lines.push(`│ Total token estimate: ${stats.totalTokens}`)
|
|
117
|
+
if (real) {
|
|
118
|
+
lines.push("├─────────────────────────────────────────────────────────────┤")
|
|
119
|
+
lines.push(`│ Measured tokens: ${real.tokens} (${pct}% of ${limit}) [${status}]`)
|
|
120
|
+
if (real.cost > 0) lines.push(`│ Cost: $${real.cost.toFixed(6)}`)
|
|
121
|
+
lines.push(`│ Model: ${real.model}`)
|
|
122
|
+
}
|
|
110
123
|
lines.push("└─────────────────────────────────────────────────────────────┘")
|
|
111
124
|
return lines.join("\n")
|
|
112
125
|
}
|
|
113
126
|
|
|
114
|
-
// Resolves the "current" session: the focused session if any, else the most recent.
|
|
127
|
+
// Resolves the "current" session: the router-focused session if any, else the most recent.
|
|
115
128
|
function resolveCurrentSession(context: any): string | null {
|
|
116
129
|
const sessions = context.data.session.list() || []
|
|
117
130
|
if (sessions.length === 0) return null
|
|
118
|
-
//
|
|
119
|
-
const
|
|
120
|
-
if (
|
|
121
|
-
return
|
|
131
|
+
// The TUI host exposes the active route via context.ui.router (not context.router).
|
|
132
|
+
const route = context.ui?.router?.current?.()
|
|
133
|
+
if (route && typeof route === "object" && "sessionID" in route) {
|
|
134
|
+
return route.sessionID as string
|
|
122
135
|
}
|
|
123
136
|
return sessions[0].id
|
|
124
137
|
}
|
|
125
138
|
|
|
139
|
+
// Server-measured context numbers for a session (Session.Info.tokens + cost + model),
|
|
140
|
+
// mirroring what the `panel` tool in index.ts reads via ctx.session.get().
|
|
141
|
+
interface MeasuredReal {
|
|
142
|
+
tokens: number
|
|
143
|
+
cost: number
|
|
144
|
+
contextLimit: number
|
|
145
|
+
model: string
|
|
146
|
+
usagePercent: number
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function measureSession(context: any, sessionID: string): Promise<MeasuredReal | null> {
|
|
150
|
+
try {
|
|
151
|
+
const info: any = await context.client.session.get({ sessionID })
|
|
152
|
+
if (!info) return null
|
|
153
|
+
const tokens: any = info.tokens ?? {}
|
|
154
|
+
const tokenCount =
|
|
155
|
+
(typeof tokens.input === "number" ? tokens.input : 0) +
|
|
156
|
+
(typeof tokens.output === "number" ? tokens.output : 0) +
|
|
157
|
+
(typeof tokens.reasoning === "number" ? tokens.reasoning : 0) +
|
|
158
|
+
(typeof tokens.cache?.read === "number" ? tokens.cache.read : 0) +
|
|
159
|
+
(typeof tokens.cache?.write === "number" ? tokens.cache.write : 0)
|
|
160
|
+
const contextLimit: number =
|
|
161
|
+
typeof info.model?.limit?.context === "number" && info.model.limit.context > 0
|
|
162
|
+
? info.model.limit.context
|
|
163
|
+
: 200000
|
|
164
|
+
const usagePercent =
|
|
165
|
+
contextLimit > 0 ? Math.min(100, Math.round((tokenCount / contextLimit) * 100)) : 0
|
|
166
|
+
return {
|
|
167
|
+
tokens: tokenCount,
|
|
168
|
+
cost: typeof info.cost === "number" ? info.cost : 0,
|
|
169
|
+
contextLimit,
|
|
170
|
+
model: info.model?.id || "unknown",
|
|
171
|
+
usagePercent,
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
return null
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
126
178
|
export default Plugin.define({
|
|
127
179
|
id: "opencodev2-slim.cli",
|
|
128
180
|
setup(context) {
|
|
@@ -160,10 +212,14 @@ export default Plugin.define({
|
|
|
160
212
|
}
|
|
161
213
|
|
|
162
214
|
try {
|
|
215
|
+
// Make sure the cached transcript is loaded before reading it.
|
|
216
|
+
await context.data.session.message.sync(sessionID)
|
|
163
217
|
const messages =
|
|
164
218
|
context.data.session.message.list(sessionID) || []
|
|
219
|
+
// Prefer live server-measured context numbers when available.
|
|
220
|
+
const real = await measureSession(context, sessionID)
|
|
165
221
|
const stats = deriveStats(messages)
|
|
166
|
-
const text = renderPanelText(sessionID, stats)
|
|
222
|
+
const text = renderPanelText(sessionID, stats, real)
|
|
167
223
|
// Inject the panel as plain text into the session stream,
|
|
168
224
|
// so it doesn't take over OpenCode's own panel UI.
|
|
169
225
|
await context.client.session.synthetic({
|