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/lib/state.js CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  sumCategories,
39
39
  } from "./estimate.js";
40
40
  import {
41
- breakdownCache,
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). Conservative: consumers are family-scoped,
428
- // so this is file hygiene, not correctness. Entries with an unparsable
429
- // updatedAt are kept.
430
- const PRUNE_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
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 bd = breakdownCache.get(sessionID) || null;
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 30+-day-stale session prunes its own old
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
- (prevEntry.providerID || "") === effectiveProvider;
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
- : safeToolOutByTool(prevEntry?.toolOutByTool);
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
- : safeReclaim(prevEntry?.reclaim, false);
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. Persisted WITHOUT
626
- // targets (path-free policy, same as reclaim).
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
- : safeTop(prevEntry?.top, false);
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
- return Number.isNaN(d.getTime()) ? "?" : d.toLocaleTimeString()
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, updatedAt } from "./tui-data.js"
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 (monospace), derived from the host-provided panel width
125
- // so cells never collide on narrow terminals. Gaps between columns keep a
126
- // truncated cell visually separated from its neighbour.
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
- updatedAt(e.updatedAt) +
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
- function onV2Context(context) {
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 = breakdownCache.get(sessionID);
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.0",
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",