opencode-context-indicator 1.8.1 → 1.8.3

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,107 @@ 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
+
432
+ // Session IDs whose persisted entry was removed by `session.deleted` (or is
433
+ // queued for removal because the state lock was busy). The host never reuses an
434
+ // ID, so once a session is gone every later write for it must be DROPPED - a
435
+ // write would recreate the entry, and after `clearChildSubagentTracking()` there
436
+ // is no parent cache left either, so the child came back as a phantom ROOT row
437
+ // that lived up to the 7-day tombstone (worse than the old phantom child).
438
+ // Bounded FIFO like the other per-instance maps.
439
+ const PRUNED_MAX = 512;
440
+ const prunedSessions = new Set();
441
+ // DELETE REQUESTS waiting for a state-file write: the lock was busy when the
442
+ // event arrived. Stored as the deleted ROOT id, not as a flat list of entries -
443
+ // the subtree is unknown without the file, and the request is replayed under the
444
+ // next write's lock (where the snapshot is available) so a busy lock cannot leave
445
+ // descendants alive. Bounded FIFO as well.
446
+ const pendingPruneRoots = new Set();
447
+ // Diagnostics: one log line per session that a late write was blocked for. A
448
+ // silent drop is impossible to debug in the field, but a line per idle/step
449
+ // would flood the log.
450
+ const barrierLogged = new Set();
451
+
452
+ function rememberPruned(id) {
453
+ if (typeof id !== "string" || !id) return;
454
+ prunedSessions.add(id);
455
+ if (prunedSessions.size > PRUNED_MAX) {
456
+ const oldest = prunedSessions.values().next().value;
457
+ if (oldest !== undefined) prunedSessions.delete(oldest);
458
+ }
459
+ }
460
+
461
+ function queuePrune(rootID) {
462
+ if (typeof rootID !== "string" || !rootID) return;
463
+ pendingPruneRoots.add(rootID);
464
+ if (pendingPruneRoots.size > PRUNED_MAX) {
465
+ const oldest = pendingPruneRoots.values().next().value;
466
+ if (oldest !== undefined) pendingPruneRoots.delete(oldest);
467
+ }
468
+ }
469
+
470
+ function logBarrierOnce(id) {
471
+ if (barrierLogged.has(id)) return;
472
+ barrierLogged.add(id);
473
+ if (barrierLogged.size > PRUNED_MAX) {
474
+ const oldest = barrierLogged.values().next().value;
475
+ if (oldest !== undefined) barrierLogged.delete(oldest);
476
+ }
477
+ logErr(`writeStateFile dropped for deleted session=${oneLine(id)}`);
478
+ }
479
+
480
+ // `rootID` plus every persisted entry below it in the parentID chain. The index
481
+ // is built once (O(n)) instead of rescanning the whole snapshot per visited node:
482
+ // the file is user-writable and unbounded on read, and this runs on the event
483
+ // loop. `doomed` doubles as the cycle guard.
484
+ function subtreeIDs(all, rootID) {
485
+ const childrenByParent = new Map();
486
+ for (const [id, entry] of Object.entries(all)) {
487
+ const parent = entry?.parentID;
488
+ if (typeof parent !== "string") continue;
489
+ const kids = childrenByParent.get(parent);
490
+ if (kids) kids.push(id);
491
+ else childrenByParent.set(parent, [id]);
492
+ }
493
+ const doomed = new Set([rootID]);
494
+ const stack = [rootID];
495
+ while (stack.length > 0) {
496
+ const current = stack.pop();
497
+ for (const child of childrenByParent.get(current) || []) {
498
+ if (doomed.has(child)) continue;
499
+ doomed.add(child);
500
+ stack.push(child);
501
+ }
502
+ }
503
+ return doomed;
504
+ }
505
+
506
+ // Atomic commit of a state snapshot: random temp suffix + exclusive create
507
+ // (CWE-377), rename over a regular file only, temp cleanup on failure. Shared by
508
+ // the writer and the deleter so the two can never drift apart.
509
+ function commitState(all) {
510
+ const tmp = `${STATE_FILE}.${randomBytes(6).toString("hex")}.tmp`;
511
+ writeFileSync(tmp, JSON.stringify(all), { encoding: "utf8", flag: "wx" });
512
+ let renamed = false;
513
+ try {
514
+ ensureRegularFile(STATE_FILE); // never rename over a planted symlink
515
+ renamed = renameWithRetry(tmp, STATE_FILE);
516
+ } finally {
517
+ // A rename that exhausted its retries leaves the temp file behind; drop it
518
+ // so repeated failures cannot litter %TEMP% (best-effort: on a SUCCESSFUL
519
+ // rename the temp is already gone, existsSync filters that).
520
+ try {
521
+ if (existsSync(tmp)) unlinkSync(tmp);
522
+ } catch {
523
+ /* best-effort cleanup */
524
+ }
525
+ }
526
+ return renamed;
527
+ }
431
528
 
432
529
  function writeStateFile(
433
530
  sessionID,
@@ -447,6 +544,16 @@ function writeStateFile(
447
544
  logErr(`writeStateFile skipped unsafe session key ${oneLine(sessionID)}`);
448
545
  return false;
449
546
  }
547
+ // The session was deleted: never recreate its entry. Without this barrier a
548
+ // late idle/step write (or a live child whose parent was just deleted)
549
+ // re-adds the row, and with the parent cache already cleared it lands with
550
+ // `parentID: null` - a phantom ROOT session in the table, the picker and the
551
+ // Desktop tab until the tombstone. Logged once per ID: the check runs on
552
+ // every write for that session and a silent drop would be undebuggable.
553
+ if (prunedSessions.has(sessionID)) {
554
+ logBarrierOnce(sessionID);
555
+ return false;
556
+ }
450
557
  const lock = acquireLock(STATE_LOCK_FILE);
451
558
  // {ok:false} => busy: skip (value stays in memory, lands next cycle), as
452
559
  // before. null => unexpected error: fail-open, write WITHOUT the lock and do
@@ -458,7 +565,6 @@ function writeStateFile(
458
565
  return false;
459
566
  }
460
567
  try {
461
- const bd = breakdownCache.get(sessionID) || null;
462
568
  const usableInfo = getUsableContext(providerID, modelID, undefined, opts?.directory);
463
569
  // Persist the EFFECTIVE window (min(input, context) when both are known,
464
570
  // else input || context: the same window the ceiling was derived from),
@@ -503,7 +609,7 @@ function writeStateFile(
503
609
  }
504
610
  // Tombstone prune: entries untouched for PRUNE_AFTER_MS drop out of the
505
611
  // 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
612
+ // cap). Edge case: resuming a 7+-day-stale session prunes its own old
507
613
  // entry once, losing the inherited usable/agent/parentID for the first
508
614
  // write — it self-heals on the next event. Unparsable updatedAt -> keep.
509
615
  {
@@ -516,27 +622,6 @@ function writeStateFile(
516
622
  const prevEntry = Object.prototype.hasOwnProperty.call(all, sessionID)
517
623
  ? all[sessionID]
518
624
  : 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
625
 
541
626
  // Resolve the model identity for THIS write. An unmeasured write from a
542
627
  // mirror/idle instance may not know the model (its caches were cleared);
@@ -554,10 +639,52 @@ function writeStateFile(
554
639
  // Same model AND provider? Only then is a previously persisted denominator
555
640
  // meaningful — a session that switched model (or provider, even with the
556
641
  // same modelID) must not inherit the old window.
642
+ //
643
+ // An EMPTY provider on either side is "not known yet", not a change: an
644
+ // idle/mirror write carries no provider (its caches were cleared), and a
645
+ // valid split written by another instance under `providerID: ""` must
646
+ // survive the next write that knows the provider. Treating that as a
647
+ // switch would zero the categories and drop the denominator.
648
+ const providerCompatible =
649
+ prevEntry == null ||
650
+ !prevEntry.providerID ||
651
+ !effectiveProvider ||
652
+ prevEntry.providerID === effectiveProvider;
557
653
  const sameModelAsPrev =
558
654
  prevEntry != null &&
559
655
  prevEntry.model === effectiveModel &&
560
- (prevEntry.providerID || "") === effectiveProvider;
656
+ providerCompatible;
657
+
658
+ // A breakdown measured for a DIFFERENT model describes the payload the
659
+ // previous model received: same tokens, but the window and the tool/system
660
+ // share move. Persisting it under the new model shows the old split until
661
+ // the next request re-measures (the reported "categories lag after a model
662
+ // switch"). breakdownForModel drops exactly that case.
663
+ const bd = breakdownForModel(sessionID, effectiveModel);
664
+ // Persisted categories are model-scoped too: after a switch the old split
665
+ // must not seed the new entry (a hook measurement of the OLD model also
666
+ // must not block the fallback from filling the new one).
667
+ const pc = sameModelAsPrev ? prevEntry?.categories || null : null;
668
+
669
+ // Which breakdown is allowed to OWN the persisted categories?
670
+ //
671
+ // This file is shared by every plugin instance, and the instance that
672
+ // answers the Desktop RPC (the window's) never serves a model request, so
673
+ // its `breakdownCache` entry is always the client-context fallback rebuilt
674
+ // from the persisted message list. That fallback cannot see the assembled
675
+ // system prompt or the tool schemas, folds their whole mass into `other`,
676
+ // and after a compaction it can even read an empty conversation. Letting
677
+ // it overwrite the hook's measurement — the two writes landed 11 ms apart
678
+ // in the field — produced "system 0 / tool schemas 0 / other inflated" and
679
+ // could blank user/assistant. So: a hook-family source (see
680
+ // isHookSource) always wins; the fallback only fills an entry that no hook
681
+ // has ever measured.
682
+ //
683
+ // Legacy entries (written before `source` was persisted) carry no tag, but
684
+ // only a hook can produce a non-null system / tool-schema estimate.
685
+ const prevHookMeasured = sameModelAsPrev && isHookMeasuredEntry(prevEntry);
686
+ const bdFromHook = bd != null && isHookSource(bd.source);
687
+ const useBd = bd != null && (bdFromHook || !prevHookMeasured);
561
688
 
562
689
  // A write with no token figure at all is NOT a measurement: it happens on
563
690
  // a mirror instance (the hook/step served another instance, so this
@@ -608,25 +735,34 @@ function writeStateFile(
608
735
  // Untrusted names -> sanitised + bounded. A present breakdown OWNS the
609
736
  // split (an empty one means "no tool results now" — do NOT revive a stale
610
737
  // peer value); only fall back to the persisted map when this instance has
611
- // no live breakdown at all.
738
+ // no live breakdown at all AND the model is unchanged (the split describes
739
+ // the previous model's request payload).
612
740
  const toolOutByTool = useBd
613
741
  ? safeToolOutByTool(bd.toolOutByTool)
614
- : safeToolOutByTool(prevEntry?.toolOutByTool);
742
+ : sameModelAsPrev
743
+ ? safeToolOutByTool(prevEntry?.toolOutByTool)
744
+ : null;
615
745
 
616
746
  // Redundant tool output (waste detector). Persisted WITHOUT targets
617
747
  // (keepTarget=false): state.json is a local temp file, but keeping it
618
748
  // path-free matches the redacted-export policy. A present breakdown owns
619
- // the value; otherwise inherit the persisted one.
749
+ // the value; otherwise inherit the persisted one (model-gated, like the
750
+ // categories it summarises).
620
751
  const reclaim = useBd
621
752
  ? safeReclaim(bd.reclaim, false)
622
- : safeReclaim(prevEntry?.reclaim, false);
753
+ : sameModelAsPrev
754
+ ? safeReclaim(prevEntry?.reclaim, false)
755
+ : null;
623
756
 
624
757
  // 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).
758
+ // OWNS the list; otherwise inherit the persisted one (model-gated: the
759
+ // system prompt / tool-schema share moves between models). Persisted
760
+ // WITHOUT targets (path-free policy, same as reclaim).
627
761
  const top = useBd
628
762
  ? safeTop(bd.top, false)
629
- : safeTop(prevEntry?.top, false);
763
+ : sameModelAsPrev
764
+ ? safeTop(prevEntry?.top, false)
765
+ : [];
630
766
 
631
767
  // Provider-reported prompt cache (EXACT; a subset of input — never added
632
768
  // to the category sum). Inherit when this write carries no fresh figure
@@ -788,24 +924,23 @@ function writeStateFile(
788
924
  .slice(MAX_TRACKED_SESSIONS)
789
925
  .forEach((k) => delete all[k]);
790
926
  }
791
- // Random temp suffix + exclusive create (CWE-377), then atomic rename.
792
- const tmp = `${STATE_FILE}.${randomBytes(6).toString("hex")}.tmp`;
793
- writeFileSync(tmp, JSON.stringify(all), { encoding: "utf8", flag: "wx" });
794
- let renamed = false;
795
- try {
796
- ensureRegularFile(STATE_FILE); // never rename over a planted symlink
797
- renamed = renameWithRetry(tmp, STATE_FILE);
798
- } finally {
799
- // A rename that exhausted its retries leaves the temp file behind; drop
800
- // it so repeated failures cannot litter %TEMP% (best-effort: on a
801
- // SUCCESSFUL rename the temp is already gone, existsSync filters that).
802
- try {
803
- if (existsSync(tmp)) unlinkSync(tmp);
804
- } catch {
805
- /* best-effort cleanup */
927
+ // Replay queued deletes inside THIS write's lock: the snapshot is only
928
+ // readable under it, so a busy lock at delete time cannot leave descendants
929
+ // alive. The requests are kept until the commit SUCCEEDS - clearing the
930
+ // queue before a rename that never happened would drop the delete silently
931
+ // and leave the row to the 7-day tombstone.
932
+ const replaying = pendingPruneRoots.size > 0;
933
+ if (replaying) {
934
+ for (const root of pendingPruneRoots) {
935
+ for (const id of subtreeIDs(all, root)) {
936
+ delete all[id];
937
+ rememberPruned(id);
938
+ }
806
939
  }
807
940
  }
808
- return renamed;
941
+ const committed = commitState(all);
942
+ if (committed && replaying) pendingPruneRoots.clear();
943
+ return committed;
809
944
  } finally {
810
945
  // Release ONLY a lock we actually own (lock === null => fail-open, skip).
811
946
  if (lock && lock.ok) releaseLock(STATE_LOCK_FILE, lock.token);
@@ -902,10 +1037,84 @@ function pushFinalSummary(claimKey, summaryLines) {
902
1037
  }
903
1038
  }
904
1039
 
1040
+ // Delete one session's entry AND its whole subtree from the shared state file.
1041
+ //
1042
+ // `session.deleted` is authoritative, so waiting for the tombstone (7 days) or
1043
+ // the LRU cap keeps a dead session visible in `/context-breakdown`, the Desktop
1044
+ // tab and the `/cx` picker - and a phantom row has no step event left to be
1045
+ // filtered out by. The walk follows the persisted parentID chain (the same
1046
+ // source discovery uses), so a deleted parent takes its descendants with it and
1047
+ // a deleted child takes its own sub-subagents.
1048
+ //
1049
+ // Every removed ID also enters `prunedSessions`: the host never reuses an ID, so
1050
+ // a later write for it is dropped instead of recreating the row (a live child
1051
+ // whose parent was deleted would otherwise come back as a phantom ROOT once
1052
+ // clearChildSubagentTracking() had dropped its parent cache).
1053
+ //
1054
+ // Same locking/atomic-write contract as writeStateFile, except busy: the delete
1055
+ // is QUEUED for the next write (which already holds the lock) rather than lost -
1056
+ // the tombstone would not remove it for 7 days.
1057
+ function pruneSessionState(sessionID) {
1058
+ try {
1059
+ if (!sessionID || !isSafeSessionKey(sessionID)) return false;
1060
+ // In-memory descendants first: a child that has not written its first entry
1061
+ // yet is invisible to the disk walk below, but it WILL write later - and by
1062
+ // then `session.deleted` has already wiped its parent attribution, so it
1063
+ // would land as a phantom root. Remember them regardless of the lock.
1064
+ const kidsByParent = new Map();
1065
+ for (const [childID, parentID] of sessionParentCache) {
1066
+ if (typeof parentID !== "string") continue;
1067
+ const kids = kidsByParent.get(parentID);
1068
+ if (kids) kids.push(childID);
1069
+ else kidsByParent.set(parentID, [childID]);
1070
+ }
1071
+ const inMemory = new Set([sessionID]);
1072
+ const pending = [sessionID];
1073
+ while (pending.length > 0) {
1074
+ const current = pending.pop();
1075
+ for (const child of kidsByParent.get(current) || []) {
1076
+ if (inMemory.has(child)) continue;
1077
+ inMemory.add(child);
1078
+ pending.push(child);
1079
+ }
1080
+ }
1081
+ for (const id of inMemory) rememberPruned(id);
1082
+ const lock = acquireLock(STATE_LOCK_FILE);
1083
+ if (lock && !lock.ok) {
1084
+ logErr(`pruneSessionState queued (state lock busy) session=${oneLine(sessionID)}`);
1085
+ queuePrune(sessionID); // replayed under the next write's lock, with the subtree
1086
+ return false;
1087
+ }
1088
+ try {
1089
+ const all = readStateSnapshot();
1090
+ const doomed = subtreeIDs(all, sessionID);
1091
+ let removed = 0;
1092
+ for (const id of doomed) {
1093
+ // Remembered even when nothing was persisted: the write barrier has to
1094
+ // cover a session whose entry was already gone (another instance pruned
1095
+ // it) and a child that has not written its first entry yet.
1096
+ rememberPruned(id);
1097
+ if (Object.prototype.hasOwnProperty.call(all, id)) {
1098
+ delete all[id];
1099
+ removed++;
1100
+ }
1101
+ }
1102
+ if (removed === 0) return false; // nothing persisted (already gone)
1103
+ return commitState(all);
1104
+ } finally {
1105
+ if (lock && lock.ok) releaseLock(STATE_LOCK_FILE, lock.token);
1106
+ }
1107
+ } catch (err) {
1108
+ logErr("pruneSessionState failed", err);
1109
+ return false;
1110
+ }
1111
+ }
1112
+
905
1113
  export {
906
1114
  logErr,
907
1115
  writeLog,
908
1116
  writeStateFile,
1117
+ pruneSessionState,
909
1118
  debugEvent,
910
1119
  readStateSnapshot,
911
1120
  pushFinalSummary,
package/lib/tui-data.d.ts CHANGED
@@ -73,6 +73,19 @@ export declare function fmt(n: number): string
73
73
  export declare function num(v: unknown): number
74
74
  export declare function fit(s: string, w: number): string
75
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
+ }
76
89
  export declare function toolOutEntries(
77
90
  map: Record<string, number> | undefined,
78
91
  limit?: number,
package/lib/tui-data.js CHANGED
@@ -122,16 +122,102 @@ 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
  }
134
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
+
135
221
  // ---------------------------------------------------------------------------
136
222
  // Composer pill (`prompt.footer.status` slot).
137
223
  // ---------------------------------------------------------------------------
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/v1.js CHANGED
@@ -671,6 +671,11 @@ function makeV1(client) {
671
671
  ) {
672
672
  // session.idle / session.compacted -> properties.sessionID;
673
673
  // session.deleted -> properties.info.id
674
+ //
675
+ // V1 is feature-frozen: unlike the V2 handler, a V1 delete event does NOT
676
+ // purge the state.json entry (it ages out via the 7-day tombstone), so a
677
+ // V1 host can still show a phantom row. Kept intentionally small; V2 is
678
+ // the supported path.
674
679
  const sid =
675
680
  event.properties?.sessionID ?? event.properties?.info?.id;
676
681
  if (!sid) return;