opencode-context-indicator 1.8.0 → 1.8.2
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 +72 -0
- package/README.md +101 -60
- package/dist/gui-experimental.cjs +1 -1
- package/index.js +11 -3
- package/lib/cache.js +19 -3
- package/lib/commands.js +68 -46
- package/lib/config.js +228 -0
- package/lib/contributors.js +6 -2
- package/lib/estimate.js +2 -1
- package/lib/limits.js +63 -13
- package/lib/state.js +66 -37
- package/lib/tui-composer.ts +70 -0
- package/lib/tui-data.d.ts +23 -0
- package/lib/tui-data.js +121 -1
- package/lib/tui-panel.ts +14 -27
- package/lib/v2.js +48 -5
- package/package.json +1 -1
- package/tui.ts +24 -1
package/lib/state.js
CHANGED
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
sumCategories,
|
|
39
39
|
} from "./estimate.js";
|
|
40
40
|
import {
|
|
41
|
-
|
|
41
|
+
breakdownForModel,
|
|
42
42
|
MAX_TRACKED_SESSIONS,
|
|
43
43
|
sessionAgentCache,
|
|
44
44
|
sessionParentCache,
|
|
@@ -424,10 +424,10 @@ function renameWithRetry(from, to, attempts = 3, delayMs = 50) {
|
|
|
424
424
|
// affect the log / toast / event paths.
|
|
425
425
|
// Tombstone window: state.json entries untouched for longer than this are
|
|
426
426
|
// pruned during the next write (deleted / abandoned sessions used to linger
|
|
427
|
-
// until the LRU cap evicted them).
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
const PRUNE_AFTER_MS =
|
|
427
|
+
// until the LRU cap evicted them). Hygiene, not correctness: consumers are
|
|
428
|
+
// family-scoped, and a resurrected session re-measures on its first request.
|
|
429
|
+
// 7 days: a tombstone only matters while a sibling/parent poll can still see it.
|
|
430
|
+
const PRUNE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
|
|
431
431
|
|
|
432
432
|
function writeStateFile(
|
|
433
433
|
sessionID,
|
|
@@ -458,8 +458,7 @@ function writeStateFile(
|
|
|
458
458
|
return false;
|
|
459
459
|
}
|
|
460
460
|
try {
|
|
461
|
-
const
|
|
462
|
-
const usableInfo = getUsableContext(providerID, modelID);
|
|
461
|
+
const usableInfo = getUsableContext(providerID, modelID, undefined, opts?.directory);
|
|
463
462
|
// Persist the EFFECTIVE window (min(input, context) when both are known,
|
|
464
463
|
// else input || context: the same window the ceiling was derived from),
|
|
465
464
|
// not the raw context window: on a cold instance
|
|
@@ -503,7 +502,7 @@ function writeStateFile(
|
|
|
503
502
|
}
|
|
504
503
|
// Tombstone prune: entries untouched for PRUNE_AFTER_MS drop out of the
|
|
505
504
|
// shared file (deleted / abandoned sessions used to linger until the LRU
|
|
506
|
-
// cap). Edge case: resuming a
|
|
505
|
+
// cap). Edge case: resuming a 7+-day-stale session prunes its own old
|
|
507
506
|
// entry once, losing the inherited usable/agent/parentID for the first
|
|
508
507
|
// write — it self-heals on the next event. Unparsable updatedAt -> keep.
|
|
509
508
|
{
|
|
@@ -516,27 +515,6 @@ function writeStateFile(
|
|
|
516
515
|
const prevEntry = Object.prototype.hasOwnProperty.call(all, sessionID)
|
|
517
516
|
? all[sessionID]
|
|
518
517
|
: null;
|
|
519
|
-
const pc = prevEntry?.categories || null;
|
|
520
|
-
|
|
521
|
-
// Which breakdown is allowed to OWN the persisted categories?
|
|
522
|
-
//
|
|
523
|
-
// This file is shared by every plugin instance, and the instance that
|
|
524
|
-
// answers the Desktop RPC (the window's) never serves a model request, so
|
|
525
|
-
// its `breakdownCache` entry is always the client-context fallback rebuilt
|
|
526
|
-
// from the persisted message list. That fallback cannot see the assembled
|
|
527
|
-
// system prompt or the tool schemas, folds their whole mass into `other`,
|
|
528
|
-
// and after a compaction it can even read an empty conversation. Letting
|
|
529
|
-
// it overwrite the hook's measurement — the two writes landed 11 ms apart
|
|
530
|
-
// in the field — produced "system 0 / tool schemas 0 / other inflated" and
|
|
531
|
-
// could blank user/assistant. So: a hook-family source (see
|
|
532
|
-
// isHookSource) always wins; the fallback only fills an entry that no hook
|
|
533
|
-
// has ever measured.
|
|
534
|
-
//
|
|
535
|
-
// Legacy entries (written before `source` was persisted) carry no tag, but
|
|
536
|
-
// only a hook can produce a non-null system / tool-schema estimate.
|
|
537
|
-
const prevHookMeasured = isHookMeasuredEntry(prevEntry);
|
|
538
|
-
const bdFromHook = bd != null && isHookSource(bd.source);
|
|
539
|
-
const useBd = bd != null && (bdFromHook || !prevHookMeasured);
|
|
540
518
|
|
|
541
519
|
// Resolve the model identity for THIS write. An unmeasured write from a
|
|
542
520
|
// mirror/idle instance may not know the model (its caches were cleared);
|
|
@@ -554,10 +532,52 @@ function writeStateFile(
|
|
|
554
532
|
// Same model AND provider? Only then is a previously persisted denominator
|
|
555
533
|
// meaningful — a session that switched model (or provider, even with the
|
|
556
534
|
// same modelID) must not inherit the old window.
|
|
535
|
+
//
|
|
536
|
+
// An EMPTY provider on either side is "not known yet", not a change: an
|
|
537
|
+
// idle/mirror write carries no provider (its caches were cleared), and a
|
|
538
|
+
// valid split written by another instance under `providerID: ""` must
|
|
539
|
+
// survive the next write that knows the provider. Treating that as a
|
|
540
|
+
// switch would zero the categories and drop the denominator.
|
|
541
|
+
const providerCompatible =
|
|
542
|
+
prevEntry == null ||
|
|
543
|
+
!prevEntry.providerID ||
|
|
544
|
+
!effectiveProvider ||
|
|
545
|
+
prevEntry.providerID === effectiveProvider;
|
|
557
546
|
const sameModelAsPrev =
|
|
558
547
|
prevEntry != null &&
|
|
559
548
|
prevEntry.model === effectiveModel &&
|
|
560
|
-
|
|
549
|
+
providerCompatible;
|
|
550
|
+
|
|
551
|
+
// A breakdown measured for a DIFFERENT model describes the payload the
|
|
552
|
+
// previous model received: same tokens, but the window and the tool/system
|
|
553
|
+
// share move. Persisting it under the new model shows the old split until
|
|
554
|
+
// the next request re-measures (the reported "categories lag after a model
|
|
555
|
+
// switch"). breakdownForModel drops exactly that case.
|
|
556
|
+
const bd = breakdownForModel(sessionID, effectiveModel);
|
|
557
|
+
// Persisted categories are model-scoped too: after a switch the old split
|
|
558
|
+
// must not seed the new entry (a hook measurement of the OLD model also
|
|
559
|
+
// must not block the fallback from filling the new one).
|
|
560
|
+
const pc = sameModelAsPrev ? prevEntry?.categories || null : null;
|
|
561
|
+
|
|
562
|
+
// Which breakdown is allowed to OWN the persisted categories?
|
|
563
|
+
//
|
|
564
|
+
// This file is shared by every plugin instance, and the instance that
|
|
565
|
+
// answers the Desktop RPC (the window's) never serves a model request, so
|
|
566
|
+
// its `breakdownCache` entry is always the client-context fallback rebuilt
|
|
567
|
+
// from the persisted message list. That fallback cannot see the assembled
|
|
568
|
+
// system prompt or the tool schemas, folds their whole mass into `other`,
|
|
569
|
+
// and after a compaction it can even read an empty conversation. Letting
|
|
570
|
+
// it overwrite the hook's measurement — the two writes landed 11 ms apart
|
|
571
|
+
// in the field — produced "system 0 / tool schemas 0 / other inflated" and
|
|
572
|
+
// could blank user/assistant. So: a hook-family source (see
|
|
573
|
+
// isHookSource) always wins; the fallback only fills an entry that no hook
|
|
574
|
+
// has ever measured.
|
|
575
|
+
//
|
|
576
|
+
// Legacy entries (written before `source` was persisted) carry no tag, but
|
|
577
|
+
// only a hook can produce a non-null system / tool-schema estimate.
|
|
578
|
+
const prevHookMeasured = sameModelAsPrev && isHookMeasuredEntry(prevEntry);
|
|
579
|
+
const bdFromHook = bd != null && isHookSource(bd.source);
|
|
580
|
+
const useBd = bd != null && (bdFromHook || !prevHookMeasured);
|
|
561
581
|
|
|
562
582
|
// A write with no token figure at all is NOT a measurement: it happens on
|
|
563
583
|
// a mirror instance (the hook/step served another instance, so this
|
|
@@ -608,25 +628,34 @@ function writeStateFile(
|
|
|
608
628
|
// Untrusted names -> sanitised + bounded. A present breakdown OWNS the
|
|
609
629
|
// split (an empty one means "no tool results now" — do NOT revive a stale
|
|
610
630
|
// peer value); only fall back to the persisted map when this instance has
|
|
611
|
-
// no live breakdown at all
|
|
631
|
+
// no live breakdown at all AND the model is unchanged (the split describes
|
|
632
|
+
// the previous model's request payload).
|
|
612
633
|
const toolOutByTool = useBd
|
|
613
634
|
? safeToolOutByTool(bd.toolOutByTool)
|
|
614
|
-
:
|
|
635
|
+
: sameModelAsPrev
|
|
636
|
+
? safeToolOutByTool(prevEntry?.toolOutByTool)
|
|
637
|
+
: null;
|
|
615
638
|
|
|
616
639
|
// Redundant tool output (waste detector). Persisted WITHOUT targets
|
|
617
640
|
// (keepTarget=false): state.json is a local temp file, but keeping it
|
|
618
641
|
// path-free matches the redacted-export policy. A present breakdown owns
|
|
619
|
-
// the value; otherwise inherit the persisted one
|
|
642
|
+
// the value; otherwise inherit the persisted one (model-gated, like the
|
|
643
|
+
// categories it summarises).
|
|
620
644
|
const reclaim = useBd
|
|
621
645
|
? safeReclaim(bd.reclaim, false)
|
|
622
|
-
:
|
|
646
|
+
: sameModelAsPrev
|
|
647
|
+
? safeReclaim(prevEntry?.reclaim, false)
|
|
648
|
+
: null;
|
|
623
649
|
|
|
624
650
|
// Heaviest single items still in the request (top-N). A present breakdown
|
|
625
|
-
// OWNS the list; otherwise inherit the persisted one
|
|
626
|
-
//
|
|
651
|
+
// OWNS the list; otherwise inherit the persisted one (model-gated: the
|
|
652
|
+
// system prompt / tool-schema share moves between models). Persisted
|
|
653
|
+
// WITHOUT targets (path-free policy, same as reclaim).
|
|
627
654
|
const top = useBd
|
|
628
655
|
? safeTop(bd.top, false)
|
|
629
|
-
:
|
|
656
|
+
: sameModelAsPrev
|
|
657
|
+
? safeTop(prevEntry?.top, false)
|
|
658
|
+
: [];
|
|
630
659
|
|
|
631
660
|
// Provider-reported prompt cache (EXACT; a subset of input — never added
|
|
632
661
|
// to the category sum). Inherit when this write carries no fresh figure
|
|
@@ -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,6 +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 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
|
|
57
67
|
export declare function readSnapshot(): Record<string, StateEntry>
|
|
58
68
|
export declare function collectFamily(
|
|
59
69
|
snapshot: Record<string, StateEntry>,
|
|
@@ -63,6 +73,19 @@ export declare function fmt(n: number): string
|
|
|
63
73
|
export declare function num(v: unknown): number
|
|
64
74
|
export declare function fit(s: string, w: number): string
|
|
65
75
|
export declare function updatedAt(iso: string | undefined): string
|
|
76
|
+
export declare function updatedCell(
|
|
77
|
+
iso: string | undefined,
|
|
78
|
+
stale: boolean | undefined,
|
|
79
|
+
width: number,
|
|
80
|
+
): string
|
|
81
|
+
export declare function panelLayout(panelWidth: number | undefined): {
|
|
82
|
+
roleW: number
|
|
83
|
+
modelW: number
|
|
84
|
+
ctxW: number
|
|
85
|
+
costW: number
|
|
86
|
+
updW: number
|
|
87
|
+
gutter: string
|
|
88
|
+
}
|
|
66
89
|
export declare function toolOutEntries(
|
|
67
90
|
map: Record<string, number> | undefined,
|
|
68
91
|
limit?: number,
|
package/lib/tui-data.js
CHANGED
|
@@ -122,12 +122,132 @@ export function fit(s, w) {
|
|
|
122
122
|
return s.padEnd(w)
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
// Compose the `updated` cell. The stale marker is appended AFTER fitting: fit()
|
|
126
|
+
// cuts the tail and replaces it with "~", so a marker folded into the fitted
|
|
127
|
+
// string would disappear at the first narrow width - while the legend line below
|
|
128
|
+
// the table would still advertise it. The time cell keeps at least 5 columns.
|
|
129
|
+
export function updatedCell(iso, stale, width) {
|
|
130
|
+
const marker = stale === true ? " *" : ""
|
|
131
|
+
const timeW = Math.max(5, width - marker.length)
|
|
132
|
+
return fit(updatedAt(iso), timeW) + marker
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Deterministic clock for the updated column: HH:MM:SS in LOCAL time, built
|
|
136
|
+
// from the Date getters instead of toLocaleTimeString(). toLocaleTimeString()
|
|
137
|
+
// follows the host locale (AM/PM, dots, seconds dropped) and can exceed the
|
|
138
|
+
// column width - the sidebar and the `/cx` row then lose their alignment on
|
|
139
|
+
// non-en-US machines.
|
|
125
140
|
export function updatedAt(iso) {
|
|
126
141
|
if (!iso) return "?"
|
|
127
142
|
try {
|
|
128
143
|
const d = new Date(iso)
|
|
129
|
-
|
|
144
|
+
if (Number.isNaN(d.getTime())) return "?"
|
|
145
|
+
const pad = (n) => String(n).padStart(2, "0")
|
|
146
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
|
130
147
|
} catch {
|
|
131
148
|
return "?"
|
|
132
149
|
}
|
|
133
150
|
}
|
|
151
|
+
|
|
152
|
+
// Panel column layout (monospace), derived from the host-provided panel width so
|
|
153
|
+
// cells never collide on narrow terminals. Pure math (no JSX, no host imports),
|
|
154
|
+
// so the unit harness can assert the row never grows past the panel.
|
|
155
|
+
//
|
|
156
|
+
// Columns: role | model | ctx | cost | updated, four 2-column gutters.
|
|
157
|
+
//
|
|
158
|
+
// Width budget (row = role + model + ctx + cost + updated + 4 gutters):
|
|
159
|
+
// - the `updated` cell takes 10 columns (HH:MM:SS + " *") only when that
|
|
160
|
+
// leaves the model cell at its 8-column floor, otherwise 8 (the marker then
|
|
161
|
+
// elides the seconds) - growing it must never push the row past the panel;
|
|
162
|
+
// - the model cell absorbs whatever is left above its floor;
|
|
163
|
+
// - if both floors bind (narrow panels), the ROLE cell gives back the
|
|
164
|
+
// difference down to its own floor: without that clawback the row landed on
|
|
165
|
+
// an exact fit at w=60..62, and an exact fit is what clipped the last cell
|
|
166
|
+
// (the stale marker) in the first place;
|
|
167
|
+
// - PANEL_SAFE_COLUMNS is therefore preserved for every width >= 57; at 56
|
|
168
|
+
// the role floor stops the clawback and the row is an exact fit.
|
|
169
|
+
// Below 56 columns the fixed columns (ctx 12 + cost 8 + updated 8 + four
|
|
170
|
+
// gutters) plus the role/model floors no longer fit; the host clips the right
|
|
171
|
+
// edge there.
|
|
172
|
+
const PANEL_GUTTER = 2
|
|
173
|
+
const PANEL_CTX_WIDTH = 12
|
|
174
|
+
const PANEL_COST_WIDTH = 8
|
|
175
|
+
// One spare column: the host adds its own padding/border around a panel and an
|
|
176
|
+
// exact fit let the last cell land on the right edge (clipped on some widths).
|
|
177
|
+
const PANEL_SAFE_COLUMNS = 1
|
|
178
|
+
// Model cell floor: a model name shorter than this is unreadable.
|
|
179
|
+
const PANEL_MIN_MODEL_W = 8
|
|
180
|
+
// Role cell floor: `subagent-12` is the longest role label we print.
|
|
181
|
+
const PANEL_MIN_ROLE_W = 12
|
|
182
|
+
// `HH:MM:SS` is 8 columns; the stale marker needs 2 more.
|
|
183
|
+
const PANEL_TIME_W = 8
|
|
184
|
+
const PANEL_TIME_STALE_W = 10
|
|
185
|
+
|
|
186
|
+
export function panelLayout(panelWidth) {
|
|
187
|
+
const w = num(panelWidth) > 0 ? num(panelWidth) : 60
|
|
188
|
+
const ctxW = PANEL_CTX_WIDTH
|
|
189
|
+
const costW = PANEL_COST_WIDTH
|
|
190
|
+
const gutters = 4 * PANEL_GUTTER
|
|
191
|
+
let roleW = Math.max(PANEL_MIN_ROLE_W, Math.min(20, Math.floor(w * 0.28)))
|
|
192
|
+
const usable = w - PANEL_SAFE_COLUMNS - roleW - ctxW - costW - gutters
|
|
193
|
+
// Prefer the full `HH:MM:SS` + " *" cell. When that does not leave the model
|
|
194
|
+
// cell its floor, fall back to the 8-column time cell: the marker then elides
|
|
195
|
+
// the SECONDS instead of the row overrunning the panel (which clipped the very
|
|
196
|
+
// marker the legend explains).
|
|
197
|
+
const updW =
|
|
198
|
+
usable - PANEL_TIME_STALE_W >= PANEL_MIN_MODEL_W
|
|
199
|
+
? PANEL_TIME_STALE_W
|
|
200
|
+
: PANEL_TIME_W
|
|
201
|
+
const modelW = Math.max(PANEL_MIN_MODEL_W, usable - updW)
|
|
202
|
+
// Both floors binding (narrow panel) made the row an EXACT fit: the model
|
|
203
|
+
// floor ate PANEL_SAFE_COLUMNS and the last cell again landed on the panel
|
|
204
|
+
// edge. Take the difference back from the role cell instead - it is prose and
|
|
205
|
+
// elides gracefully, and the safe column survives.
|
|
206
|
+
const total = roleW + modelW + ctxW + costW + updW + gutters
|
|
207
|
+
const over = total - (w - PANEL_SAFE_COLUMNS)
|
|
208
|
+
if (over > 0) {
|
|
209
|
+
roleW = Math.max(PANEL_MIN_ROLE_W, roleW - over)
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
roleW,
|
|
213
|
+
modelW,
|
|
214
|
+
ctxW,
|
|
215
|
+
costW,
|
|
216
|
+
updW,
|
|
217
|
+
gutter: " ".repeat(PANEL_GUTTER),
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Composer pill (`prompt.footer.status` slot).
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
// Same thresholds as the Desktop pill (gui/index.tsx PILL_* constants): amber
|
|
226
|
+
// from 65% of the usable window, red from 85%. Kept in ONE place for the TUI
|
|
227
|
+
// and mirrored there on purpose - the GUI bundle is separate, but a divergence
|
|
228
|
+
// in the two numbers reads as a bug.
|
|
229
|
+
export const COMPOSER_WARN_PERCENT = 65;
|
|
230
|
+
export const COMPOSER_DANGER_PERCENT = 85;
|
|
231
|
+
|
|
232
|
+
// One-line usage capsule: `{ percent, label, tone }`, or null when the entry
|
|
233
|
+
// has no usable window (no data yet, a fresh session, or a model without a
|
|
234
|
+
// limit). Pure and dependency-free so the unit harness can pin the thresholds.
|
|
235
|
+
export function composerStatus(entry) {
|
|
236
|
+
if (!entry || typeof entry !== "object") return null;
|
|
237
|
+
const usable = num(entry.usable);
|
|
238
|
+
const ctx = num(entry.ctx);
|
|
239
|
+
if (!(usable > 0) || ctx < 0) return null;
|
|
240
|
+
const share = (ctx / usable) * 100;
|
|
241
|
+
if (!Number.isFinite(share)) return null;
|
|
242
|
+
const percent = Math.round(share);
|
|
243
|
+
return {
|
|
244
|
+
percent,
|
|
245
|
+
label: `${percent}% ctx`,
|
|
246
|
+
tone:
|
|
247
|
+
share >= COMPOSER_DANGER_PERCENT
|
|
248
|
+
? "danger"
|
|
249
|
+
: share >= COMPOSER_WARN_PERCENT
|
|
250
|
+
? "warning"
|
|
251
|
+
: undefined,
|
|
252
|
+
};
|
|
253
|
+
}
|
package/lib/tui-panel.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { mkdirSync, writeFileSync } from "node:fs"
|
|
|
10
10
|
import { tmpdir } from "node:os"
|
|
11
11
|
import { basename, join } from "node:path"
|
|
12
12
|
import type { StateEntry } from "./tui-data.js"
|
|
13
|
-
import { collectFamily, fit, fmt, num, toolOutEntries,
|
|
13
|
+
import { collectFamily, fit, fmt, num, panelLayout, toolOutEntries, updatedCell } from "./tui-data.js"
|
|
14
14
|
import { token } from "./tui-theme.js"
|
|
15
15
|
import { c as comp, h } from "./tui-h.ts"
|
|
16
16
|
import {
|
|
@@ -121,29 +121,9 @@ function listSignature(list: unknown[]): string {
|
|
|
121
121
|
return `${n}:${id}:${created}:${size}`
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
// Panel column layout (
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
const GUTTER = 2
|
|
128
|
-
const CTX_WIDTH = 12
|
|
129
|
-
const COST_WIDTH = 8
|
|
130
|
-
|
|
131
|
-
function panelLayout(panelWidth: number): {
|
|
132
|
-
roleW: number
|
|
133
|
-
modelW: number
|
|
134
|
-
ctxW: number
|
|
135
|
-
costW: number
|
|
136
|
-
updW: number
|
|
137
|
-
gutter: string
|
|
138
|
-
} {
|
|
139
|
-
const w = num(panelWidth) > 0 ? num(panelWidth) : 60
|
|
140
|
-
const ctxW = CTX_WIDTH
|
|
141
|
-
const costW = COST_WIDTH
|
|
142
|
-
const updW = Math.max(5, Math.min(8, w - ctxW - 4 * GUTTER - 24))
|
|
143
|
-
const roleW = Math.max(12, Math.min(20, Math.floor(w * 0.28)))
|
|
144
|
-
const modelW = Math.max(8, w - roleW - ctxW - costW - updW - 4 * GUTTER)
|
|
145
|
-
return { roleW, modelW, ctxW, costW, updW, gutter: " ".repeat(GUTTER) }
|
|
146
|
-
}
|
|
124
|
+
// Panel column layout lives in lib/tui-data.js (pure math) so the unit harness
|
|
125
|
+
// can regression-test that the row never grows past the panel width (T42 in
|
|
126
|
+
// test/prepublish.mjs).
|
|
147
127
|
|
|
148
128
|
// Dollar formatting for the cost column: compact, always `$`-prefixed, `-`
|
|
149
129
|
// when the host data layer has no cost for the session.
|
|
@@ -617,8 +597,7 @@ export function BreakdownPanel(props: {
|
|
|
617
597
|
L.gutter +
|
|
618
598
|
fit(money(costOf(e.sessionID)), L.costW) +
|
|
619
599
|
L.gutter +
|
|
620
|
-
|
|
621
|
-
(e.compactStale === true ? " (stale)" : "")
|
|
600
|
+
updatedCell(e.updatedAt, e.compactStale === true, L.updW)
|
|
622
601
|
)
|
|
623
602
|
}
|
|
624
603
|
|
|
@@ -633,7 +612,7 @@ export function BreakdownPanel(props: {
|
|
|
633
612
|
all && typeof all === "object"
|
|
634
613
|
? Object.values(all).filter((v) => num(v) > 0).length
|
|
635
614
|
: 0
|
|
636
|
-
const parts = entries.map(([name, val]) => `${safeText(name)} ${fmt(val)}`)
|
|
615
|
+
const parts = entries.map(([name, val]) => `${safeText(name)} ${fmt(val)} tokens`)
|
|
637
616
|
if (total > entries.length) parts.push(`+${total - entries.length} more`)
|
|
638
617
|
return parts.join(" \u00b7 ")
|
|
639
618
|
}
|
|
@@ -701,6 +680,14 @@ export function BreakdownPanel(props: {
|
|
|
701
680
|
.rows.map((e, i) =>
|
|
702
681
|
mutedText(() => row(e, i === 0)),
|
|
703
682
|
),
|
|
683
|
+
comp(Show, {
|
|
684
|
+
get when() {
|
|
685
|
+
return data().rows.some((e) => e.compactStale === true)
|
|
686
|
+
},
|
|
687
|
+
get children() {
|
|
688
|
+
return mutedText(() => " * stale - compacted")
|
|
689
|
+
},
|
|
690
|
+
}),
|
|
704
691
|
comp(Show, {
|
|
705
692
|
get when() {
|
|
706
693
|
return (data().root?.usable ?? 0) > 0
|
package/lib/v2.js
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
import {
|
|
30
30
|
BREAKDOWN_THROTTLE_MS,
|
|
31
31
|
breakdownCache,
|
|
32
|
+
breakdownForModel,
|
|
32
33
|
clearChildSubagentTracking,
|
|
33
34
|
clearSessionTracking,
|
|
34
35
|
ERROR_TOAST_DEDUP_MS,
|
|
@@ -61,6 +62,7 @@ import {
|
|
|
61
62
|
getModelLimit,
|
|
62
63
|
getUsableContext,
|
|
63
64
|
} from "./limits.js";
|
|
65
|
+
import { locationDirectory } from "./config.js";
|
|
64
66
|
import {
|
|
65
67
|
debugEvent,
|
|
66
68
|
logErr,
|
|
@@ -364,17 +366,32 @@ function estimateBreakdownFromContext(context, sessionID) {
|
|
|
364
366
|
|
|
365
367
|
// V2 context hook handler: capture the final messages / system prompt / tool
|
|
366
368
|
// schemas + the request model. READ-ONLY — the context object is never mutated.
|
|
367
|
-
|
|
369
|
+
//
|
|
370
|
+
// `ctx` (the plugin context) is optional and only used to remember which project
|
|
371
|
+
// the hook belongs to: the limits path resolves the user's configured
|
|
372
|
+
// compaction.buffer per directory, and a process can host several instances.
|
|
373
|
+
function onV2Context(context, ctx) {
|
|
368
374
|
try {
|
|
369
375
|
if (!context || typeof context !== "object") return;
|
|
370
376
|
const sid = context.sessionID;
|
|
371
377
|
if (!sid) return;
|
|
372
378
|
|
|
373
379
|
const parts = estimateBreakdownFromContext(context, sid);
|
|
380
|
+
// Tag the entry with the model whose payload was measured: a later in-session
|
|
381
|
+
// model switch must not keep showing this split (see breakdownForModel).
|
|
382
|
+
const reqModel = context.model;
|
|
383
|
+
const reqModelID =
|
|
384
|
+
reqModel &&
|
|
385
|
+
typeof reqModel === "object" &&
|
|
386
|
+
typeof reqModel.id === "string" &&
|
|
387
|
+
reqModel.id
|
|
388
|
+
? reqModel.id
|
|
389
|
+
: "unknown";
|
|
374
390
|
breakdownCache.set(sid, {
|
|
375
391
|
...parts,
|
|
376
392
|
capturedAt: Date.now(),
|
|
377
393
|
source: "context-hook",
|
|
394
|
+
model: reqModelID,
|
|
378
395
|
});
|
|
379
396
|
pruneMap(breakdownCache, MAX_TRACKED_SESSIONS);
|
|
380
397
|
|
|
@@ -404,17 +421,28 @@ function onV2Context(context) {
|
|
|
404
421
|
// compact yet") on a session with no completed request — there is no measured
|
|
405
422
|
// context to mark, so we only cache and skip the state write (no fake stale
|
|
406
423
|
// badge).
|
|
407
|
-
function onV2Compaction(context) {
|
|
424
|
+
function onV2Compaction(context, ctx) {
|
|
408
425
|
try {
|
|
409
426
|
if (!context || typeof context !== "object") return;
|
|
410
427
|
const sid = context.sessionID;
|
|
411
428
|
if (!sid) return;
|
|
429
|
+
const directory = locationDirectory(ctx);
|
|
412
430
|
|
|
413
431
|
const parts = estimateBreakdownFromContext(context, sid);
|
|
432
|
+
// Same model tag as the context hook (see onV2Context / breakdownForModel).
|
|
433
|
+
const reqModel = context.model;
|
|
434
|
+
const reqModelID =
|
|
435
|
+
reqModel &&
|
|
436
|
+
typeof reqModel === "object" &&
|
|
437
|
+
typeof reqModel.id === "string" &&
|
|
438
|
+
reqModel.id
|
|
439
|
+
? reqModel.id
|
|
440
|
+
: "unknown";
|
|
414
441
|
breakdownCache.set(sid, {
|
|
415
442
|
...parts,
|
|
416
443
|
capturedAt: Date.now(),
|
|
417
444
|
source: "compaction-hook",
|
|
445
|
+
model: reqModelID,
|
|
418
446
|
});
|
|
419
447
|
pruneMap(breakdownCache, MAX_TRACKED_SESSIONS);
|
|
420
448
|
|
|
@@ -437,6 +465,7 @@ function onV2Compaction(context) {
|
|
|
437
465
|
lastKnownInput(sid),
|
|
438
466
|
ctxTokens,
|
|
439
467
|
lastKnownReasoning(sid),
|
|
468
|
+
directory,
|
|
440
469
|
);
|
|
441
470
|
const wrote = writeStateFile(
|
|
442
471
|
sid,
|
|
@@ -449,6 +478,7 @@ function onV2Compaction(context) {
|
|
|
449
478
|
compacted: true,
|
|
450
479
|
cacheRead: lastKnownCacheRead(sid),
|
|
451
480
|
cacheWrite: lastKnownCacheWrite(sid),
|
|
481
|
+
directory,
|
|
452
482
|
},
|
|
453
483
|
);
|
|
454
484
|
// Arm the fallback guard for the trailing `session.compacted` event ONLY
|
|
@@ -620,6 +650,7 @@ async function ensureV2Breakdown(ctx, sessionID) {
|
|
|
620
650
|
...estimateBreakdownFromClientMessages(msgs),
|
|
621
651
|
capturedAt: Date.now(),
|
|
622
652
|
source: "client-context",
|
|
653
|
+
model: lastKnownModel(sessionID),
|
|
623
654
|
});
|
|
624
655
|
pruneMap(breakdownCache, MAX_TRACKED_SESSIONS);
|
|
625
656
|
}
|
|
@@ -719,11 +750,11 @@ function stateEntryToBreakdown(entry) {
|
|
|
719
750
|
|
|
720
751
|
// V2: assemble the live breakdown lines (no client, no fallback fetch —
|
|
721
752
|
// the context hook fills breakdownCache on every request).
|
|
722
|
-
function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens, reasoningTokens) {
|
|
753
|
+
function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens, reasoningTokens, directory) {
|
|
723
754
|
const lines = [];
|
|
724
755
|
const ts = new Date().toISOString();
|
|
725
756
|
const limit = getModelLimit(providerID, modelID);
|
|
726
|
-
const usableInfo = getUsableContext(providerID, modelID);
|
|
757
|
+
const usableInfo = getUsableContext(providerID, modelID, undefined, directory);
|
|
727
758
|
|
|
728
759
|
// A client-context fallback cannot see the system prompt or the tool schemas:
|
|
729
760
|
// its `other` swallows both and, right after a compaction, it can even read a
|
|
@@ -738,7 +769,10 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
|
|
|
738
769
|
// input/ctx belong to ITS view (a mirror that only saw the trailing event has
|
|
739
770
|
// 0), and mixing them with the hook's categories made the header contradict
|
|
740
771
|
// the "other = input - sum of estimates" line right below it.
|
|
741
|
-
let bd =
|
|
772
|
+
let bd = breakdownForModel(sessionID, modelID);
|
|
773
|
+
// A breakdown measured for a DIFFERENT model is not this session's split any
|
|
774
|
+
// more: dropping it falls through to the state.json snapshot path below (which
|
|
775
|
+
// is model-consistent) or to the header-only path.
|
|
742
776
|
let snapInputTokens = null;
|
|
743
777
|
let snapCtxTokens = null;
|
|
744
778
|
let snapAt = null;
|
|
@@ -906,6 +940,9 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
|
|
|
906
940
|
// V2 event handler. `event` is a V2Event ({ type, data, ... }).
|
|
907
941
|
async function handleV2Event(ctx, event) {
|
|
908
942
|
if (!event || typeof event.type !== "string") return;
|
|
943
|
+
// The instance's project directory scopes the configured compaction.buffer
|
|
944
|
+
// lookup (one process can host several plugin instances, one per location).
|
|
945
|
+
const directory = locationDirectory(ctx);
|
|
909
946
|
|
|
910
947
|
// DEBUG tap (fault-tolerant, off via flag).
|
|
911
948
|
debugEvent(event);
|
|
@@ -981,6 +1018,7 @@ async function handleV2Event(ctx, event) {
|
|
|
981
1018
|
lastKnownInput(sid),
|
|
982
1019
|
lastKnownCtx(sid),
|
|
983
1020
|
lastKnownReasoning(sid),
|
|
1021
|
+
directory,
|
|
984
1022
|
);
|
|
985
1023
|
// The `compaction` HOOK already captured + persisted the exact
|
|
986
1024
|
// pre-compaction snapshot and marked the entry stale. This event is now a
|
|
@@ -1003,6 +1041,7 @@ async function handleV2Event(ctx, event) {
|
|
|
1003
1041
|
compacted: true,
|
|
1004
1042
|
cacheRead: lastKnownCacheRead(sid),
|
|
1005
1043
|
cacheWrite: lastKnownCacheWrite(sid),
|
|
1044
|
+
directory,
|
|
1006
1045
|
},
|
|
1007
1046
|
);
|
|
1008
1047
|
}
|
|
@@ -1073,6 +1112,7 @@ async function handleV2Event(ctx, event) {
|
|
|
1073
1112
|
lastKnownInput(sid),
|
|
1074
1113
|
lastKnownCtx(sid),
|
|
1075
1114
|
lastKnownReasoning(sid),
|
|
1115
|
+
directory,
|
|
1076
1116
|
);
|
|
1077
1117
|
writeStateFile(
|
|
1078
1118
|
sid,
|
|
@@ -1084,6 +1124,7 @@ async function handleV2Event(ctx, event) {
|
|
|
1084
1124
|
{
|
|
1085
1125
|
cacheRead: lastKnownCacheRead(sid),
|
|
1086
1126
|
cacheWrite: lastKnownCacheWrite(sid),
|
|
1127
|
+
directory,
|
|
1087
1128
|
},
|
|
1088
1129
|
);
|
|
1089
1130
|
pushFinalSummary(`final:${event.id ?? sid}`, [
|
|
@@ -1185,6 +1226,7 @@ async function handleV2Event(ctx, event) {
|
|
|
1185
1226
|
input,
|
|
1186
1227
|
contextTokens,
|
|
1187
1228
|
reasoning,
|
|
1229
|
+
directory,
|
|
1188
1230
|
);
|
|
1189
1231
|
writeStateFile(
|
|
1190
1232
|
sid,
|
|
@@ -1196,6 +1238,7 @@ async function handleV2Event(ctx, event) {
|
|
|
1196
1238
|
{
|
|
1197
1239
|
cacheRead: lastKnownCacheRead(sid),
|
|
1198
1240
|
cacheWrite: lastKnownCacheWrite(sid),
|
|
1241
|
+
directory,
|
|
1199
1242
|
},
|
|
1200
1243
|
);
|
|
1201
1244
|
writeLog(lines);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-context-indicator",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.2",
|
|
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",
|