opencode-context-indicator 1.2.1 → 1.3.0

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 ADDED
@@ -0,0 +1,101 @@
1
+ # Changelog
2
+
3
+ Notable user-facing changes per release. Dates are commit days (UTC).
4
+ The package also ships a dual V1/V2 plugin — see the README for the surface
5
+ matrix; everything below concerns the V2 path unless stated otherwise.
6
+
7
+ ## 1.3.0 — unreleased
8
+
9
+ Highlights: per-session cost in `/cx` (family-aggregated for the main session),
10
+ a searchable family-session picker, and `/cx` reachable from subagent session
11
+ views.
12
+
13
+ - `/cx` panel: new **cost** column (upstream #45417). The main session shows
14
+ the family-aggregated spend, each subagent its own; `-` when the host has no
15
+ cost for a session.
16
+ - New panel key `s` — a searchable picker over all family sessions
17
+ (title / model / cost / running marker). `Enter` navigates: root sessions
18
+ focus their tab, subagent sessions switch the route.
19
+ - `/cx` now works from **subagent session views**: those views have no prompt
20
+ input, so the command is also in the command palette and bound to
21
+ `<leader>c` (ctrl+x, then c) and `f4`. The panel family is rooted at the
22
+ topmost ancestor, so the whole family is shown, not just the child subtree.
23
+ - OpenTUI pinned `0.5.17`; `@opencode/plugin` types devDep pinned `2.0.26`
24
+ (drift-watch now tracks both). Tested against opencode **2.0.26**.
25
+
26
+ ## 1.2.2 — 2026-10-08
27
+
28
+ - TUI entry split for maintainability: `tui.tsx` down to ~195 lines; the
29
+ components and the data layer moved to `lib/` (`tui-sidebar.tsx`,
30
+ `tui-panel.tsx`, `tui-data.js` + `.d.ts`, `tui-theme.js`). No behavior
31
+ change. The pragma regression gate now covers every `lib/*.tsx`.
32
+ - E2E hardened: deterministic fail-closed plugin-load check (isolated global
33
+ config via `OPENCODE_CONFIG_DIR` + the isolated instance's own log file +
34
+ a live-surface stamp), cleanup of spawned CLI instances, WMI probe timeouts.
35
+
36
+ ## 1.2.1 — 2026-10-08
37
+
38
+ - The panel / percentage denominator now equals **opencode's compaction
39
+ ceiling** (v2.0.23+ `calculateCeiling`: window = `limit.input ||
40
+ limit.context`; reserve = max(10% of the window, 16k when the window is at
41
+ least 32k)) — percentages read as "% to compaction", not "% of the raw
42
+ window" (upstream #51271).
43
+ - `state.json`: new `reserve` field; the panel and `/context` show
44
+ `compact at X (N reserve)`.
45
+ - Unit harness extended to 34 checks (measured contributors, redacted-export
46
+ invariants, ceiling formula, hydrate seeding).
47
+ - OpenTUI pinned `0.5.16`.
48
+
49
+ ## 1.2.0 — 2026-10-04
50
+
51
+ - New: **zero-LLM `/cx` session panel** — a family table (role / model /
52
+ ctx% / updated) for the current session and all subagent descendants, plus a
53
+ `largest contributors (measured)` section computed from the in-process
54
+ message list (per tool call, with message clock times and bounded previews).
55
+ - Redacted export (`e`): no message text, no full session ids, no tool output
56
+ content — written under `%TEMP%/opencode-context-indicator/exports/`.
57
+ - Keys: `esc` closes, `r` refreshes, `e` exports.
58
+
59
+ ## 1.1.6 — 2026-10-04
60
+
61
+ - OpenTUI pinned `0.5.14` (opencode 2.0.21+), optional-peer floor aligned.
62
+ - `index.js` split into `lib/` modules (no behavior change).
63
+
64
+ ## 1.1.5 — 2026-10-02
65
+
66
+ - Security hardening: sanitized table metadata, validated `state.json`
67
+ (prototype-poisoning keys dropped), ownership-safe state lock, tmp hygiene.
68
+
69
+ ## 1.1.4 — 2026-10-02
70
+
71
+ - Removed a stray `opencode/process` import from the TUI entry; the prepublish
72
+ gate now rejects bare `opencode/*` specifiers (not bridged at runtime).
73
+
74
+ ## 1.1.3 — 2026-10-01
75
+
76
+ - Stable context denominator across plugin instances: a transient
77
+ `ctx.model.list()` miss falls back to the shared snapshot instead of
78
+ clobbering persisted windows.
79
+
80
+ ## 1.1.2 — 2026-10-01
81
+
82
+ - Graceful sidebar/panel disable on npm installs (upstream #33884: `node_modules`
83
+ plugins skip the host Solid transform, so slots would freeze) — a single hint
84
+ is logged instead; `@opentui/solid` is shipped pinned; `file://` install docs.
85
+
86
+ ## 1.1.1 — 2026-10-01
87
+
88
+ - Fixed `Cannot find package 'react'` when loaded from the npm cache: the
89
+ `@jsxImportSource @opentui/solid` pragma must be the file's first line.
90
+
91
+ ## 1.1.0 — 2026-10-01
92
+
93
+ - New: `/context` (compact summary) and `/context-breakdown` (full table, main
94
+ + all subagent sessions) slash commands.
95
+ - Stable per-model window limits; strict subagent discovery.
96
+
97
+ ## 1.0.0 — 2026-09-30
98
+
99
+ - Initial release: dual V1/V2 plugin, real-time context indicator, per-category
100
+ token breakdown (system / tool schemas / user / assistant / reasoning / tool
101
+ args / residual), TUI sidebar.
package/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
  Real-time **context-window usage** indicator for [OpenCode](https://opencode.ai),
4
4
  with a **per-category token breakdown** and an optional **terminal-UI sidebar**.
5
5
 
6
+ User-facing changes per release: [CHANGELOG.md](./CHANGELOG.md).
7
+
6
8
  It tells you how full the current model's context window is, and *what* is
7
9
  filling it: system prompt, tool schemas, user/assistant text, reasoning, tool
8
10
  arguments and the residual "other" bucket.
@@ -10,7 +12,7 @@ arguments and the residual "other" bucket.
10
12
  The package ships a **dual V1 + V2 plugin** (one entry works in both OpenCode
11
13
  generations) plus an optional TUI module (`./tui` entry) that renders a **live
12
14
  sidebar** and a **zero-LLM `/cx` breakdown panel** with measured contributors
13
- and a redacted export. The V2 path is **tested against opencode v2.0.25**; the
15
+ and a redacted export. The V2 path is **tested against opencode v2.0.26**; the
14
16
  V1 path against 1.18.29+.
15
17
 
16
18
  > **V1 is feature-frozen.** It is kept working for compatibility (bug fixes
@@ -56,7 +58,7 @@ For every served request the plugin estimates how the context window is split:
56
58
  carries a `compaction.buffer` configured in its own settings, the plugin
57
59
  cannot read that value (it is not exposed to plugins), so the default
58
60
  reserve formula is used — set `OPENCODE_CONTEXT_INDICATOR_COMPACT_BUFFER`
59
- to override the reserve for testing or custom setups (positive integer <=
61
+ to override the reserve for testing or custom setups (non-negative integer <=
60
62
  1e9; invalid values are silently ignored).
61
63
 
62
64
  ### Toast (V1 only)
@@ -240,7 +242,20 @@ back to a plain notice if needed.
240
242
  `/cx` opens a full `session.panel` showing the breakdown for the current session
241
243
  **and its subagent descendants**, read from the same `state.json` bridge as the
242
244
  sidebar. Keys: `/cx` opens, `esc` closes, `r` refreshes (re-read the bridge),
243
- `e` exports a redacted snapshot.
245
+ `e` exports a redacted snapshot, `s` picks a family session.
246
+
247
+ Every row carries a **cost** column read from the host data layer: a main
248
+ session shows the **family-aggregated** spend, each subagent its own — no more
249
+ invisible subagent spend (upstream #45417). `s` opens a searchable picker over
250
+ all family sessions (title / model / cost / running marker); `Enter` navigates
251
+ there — root sessions focus their tab, subagent sessions switch the route.
252
+
253
+ Works from **subagent session views too**: those views have no prompt input
254
+ (the composer switches to its subagents tab), so `/cx` is also registered in
255
+ the command palette and bound to `<leader>c` / `f4` — both fire from any
256
+ session view (the palette itself is only reachable outside the composer).
257
+ Opened from a subagent view, the family is rooted at the topmost ancestor, so
258
+ the whole family is shown, not just the child subtree.
244
259
 
245
260
  **Zero LLM calls.** Unlike `/context` and `/context-breakdown` (which run a real
246
261
  agent turn so the model renders the table), the panel only reads local TUI data
@@ -437,13 +452,13 @@ the sidebar instead.
437
452
  gracefully (the breakdown falls back to throttled `session.messages` fetches
438
453
  and toasts keep working).
439
454
  * **OpenCode ≥ 2.0.16** for the V2 path (built and verified against
440
- **2.0.25**).
455
+ **2.0.26**).
441
456
  * **TUI sidebar**: terminal TUI only, requires the OpenTUI rendering stack that
442
457
  ships with OpenCode. `@opentui/core` and `solid-js` are **optional** peer
443
458
  dependencies resolved by OpenCode at runtime; the main plugin installs and runs
444
459
  fine without them (the sidebar simply is not available). `@opentui/solid` is
445
- instead shipped as a **pinned direct dependency** (exact `0.5.16`, matching
446
- the OpenTUI stack OpenCode 2.0.25 ships): OpenCode's
460
+ instead shipped as a **pinned direct dependency** (exact `0.5.17`, matching
461
+ the OpenTUI stack OpenCode 2.0.26 ships): OpenCode's
447
462
  TUI loader does not expose a host instance of it, so for npm-installed plugins
448
463
  the JSX pragma would otherwise fail to resolve `@opentui/solid/jsx-runtime`
449
464
  (upstream: opencode issue #33884 — `node_modules` plugins are excluded from the
@@ -505,9 +520,10 @@ caller.
505
520
  ### Running tests
506
521
 
507
522
  ```bash
508
- npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–T12 unit
523
+ npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–T16i unit checks
509
524
  npm run test:e2e # full E2E: isolated plugin load + one "Say OK" round-trip
510
525
  npm run test:e2e:quick # E2E load-only, no LLM call
526
+ npm run test:drift # OpenTUI pin vs the latest RELEASED opencode (also in CI)
511
527
  ```
512
528
 
513
529
  `prepublishOnly` runs **both** gates automatically on `npm publish`
@@ -524,8 +540,8 @@ STEP 80 s, ~3 min total.
524
540
  |---|---|---|
525
541
  | (a) tarball composition | `package.json` `files[]` expands to the base files plus every `lib/*` module (count asserted dynamically) | no test/node_modules/tsconfig in the tarball |
526
542
  | (b) pragma regression | esbuild `--jsx=automatic` → `@opentui/solid/jsx-runtime` present, `react/jsx-runtime` absent | a removed/broken pragma kills the sidebar on npm install |
527
- | (c) resolve chain | replica install → `import.meta.resolve` for all tui.tsx imports | broken peer/optional dep silently breaks the sidebar |
528
- | (d) unit harness T1–T12 | all limit/agent/state fix assertions | regressions in modelLimits, writeStateFile, hydrate |
543
+ | (c) resolve chain | replica install: host-bridged + peer packages resolve; local tui.tsx relative imports resolve via node from the entry (resolve-bridge.cjs) | broken peer/optional dep or a bad local import silently breaks the sidebar |
544
+ | (d) unit harness T1–T16i | limit/agent/state fix assertions plus measureContributors and redacted-export checks (34 total) | regressions in modelLimits, writeStateFile, hydrate, contributors, export |
529
545
  | (e) corporate-identifier scan | no forbidden identifiers in the published surface + test tree | compliance |
530
546
  | (f) non-English scan | no non-ASCII letters (`\p{L}` above U+007F) in any repo file | English-only source rule |
531
547
 
@@ -533,7 +549,7 @@ STEP 80 s, ~3 min total.
533
549
 
534
550
  | Check | What | Why |
535
551
  |---|---|---|
536
- | A — plugin load | spins a private `opencode --standalone` PTY, asserts `msg="loading plugin" → opencode-context-indicator` and that the `file:///` entrypoint is **this checkout** | catches a plugin that no longer imports cleanly, or a config pointing at the published npm copy |
552
+ | A — plugin load | spins a private `opencode --standalone` PTY against an **isolated global config** (`OPENCODE_CONFIG_DIR` → `%TEMP%/oci-e2e-config`, sole plugin entry = this checkout's `file://` URL), then asserts **from the host opencode log** (atomic lines, immune to PTY interleaving) that the plugin loaded and its `entrypoint` is **this checkout's `index.js`** — both fail-closed | catches a plugin that no longer imports cleanly, or an isolated config the host did not honor |
537
553
  | B — live update | one `opencode run "Say OK" --standalone` round-trip; asserts the LLM answered **and** the isolated `state.json` gained a session with `ctx > 0` | catches a regression in the `session.idle` → `writeStateFile` path |
538
554
 
539
555
  The E2E is fully **non-invasive** to your running OpenCode Desktop:
@@ -542,7 +558,14 @@ The E2E is fully **non-invasive** to your running OpenCode Desktop:
542
558
  - `OPENCODE_DB` points at a throwaway DB in `%TEMP%` (your sessions are untouched);
543
559
  - `OPENCODE_CONTEXT_INDICATOR_STATE_FILE` points at a throwaway state file in `%TEMP%`
544
560
  — the live `opencode-context-indicator-state.json` is never read or written;
561
+ - `OPENCODE_CONFIG_DIR` points at a throwaway global config written by the script
562
+ (plugins: this checkout; `update: "disable"`) **for Check A only** — the load
563
+ source is deterministic and fail-closed instead of following your live config;
564
+ Check B uses your real global config read-only (the round-trip needs the
565
+ provider/model defined there). Your real config is never modified;
545
566
  - `OPENCODE_DISABLE_PROJECT_CONFIG=1` avoids project-level config;
567
+ - any `opencode-cli` instance the test itself spawned is force-killed after each
568
+ check (`serve --service` — your Desktop background service — is never touched);
546
569
  - exactly **one** minimal LLM call (`Say OK`) is made, in the isolated session.
547
570
 
548
571
  The script prints `[e2e] running against ISOLATED opencode instance — your running
@@ -555,10 +578,20 @@ model.
555
578
 
556
579
  ### CI
557
580
 
558
- `.github/workflows/ci.yml` runs `test/prepublish.mjs` on every push/PR and additionally
559
- does a standalone esbuild transpile check (pragmas, no React). The Node-pty E2E is not
560
- run in CI (requires the OpenCode Desktop binary) and is skipped gracefully when the CLI
561
- is absent (`[SKIP] opencode CLI not found`).
581
+ `.github/workflows/ci.yml` runs on every push/PR to `main`, on `workflow_dispatch`,
582
+ and on a daily schedule (GitHub auto-parks scheduled workflows after 60 days of
583
+ repo inactivity — a push or manual run re-enables them):
584
+
585
+ 1. `npx --no-install tsc --noEmit` — typecheck of the TUI entry (`tui.tsx` + its
586
+ `lib/*` imports);
587
+ 2. `test/prepublish.mjs` — the full fast gate (a)–(f), including the standalone
588
+ esbuild transpile check (pragmas, no React);
589
+ 3. `test/drift-watch.mjs` — the OpenTUI pin must match the OpenTUI catalog of the
590
+ **latest released** opencode; red CI means "bump the pin before the next
591
+ release".
592
+
593
+ The Node-pty E2E is not run in CI (requires the OpenCode binary) and is skipped
594
+ gracefully when the CLI is absent (`[SKIP] opencode CLI not found`).
562
595
 
563
596
  ---
564
597
 
@@ -0,0 +1,45 @@
1
+ // lib/tui-data.d.ts
2
+ //
3
+ // Types for lib/tui-data.js (the TUI data layer). state.json is written by
4
+ // writeStateFile() in index.js and is untrusted input — every consumer treats
5
+ // the fields as optional.
6
+
7
+ export interface Categories {
8
+ user?: number
9
+ assistant?: number
10
+ reasoning?: number
11
+ toolArgs?: number
12
+ system?: number | null
13
+ toolSchemas?: number | null
14
+ other?: number
15
+ }
16
+
17
+ export interface StateEntry {
18
+ sessionID?: string
19
+ parentID?: string | null
20
+ role?: string
21
+ agent?: string | null
22
+ model?: string
23
+ providerID?: string
24
+ ctx?: number
25
+ input?: number
26
+ usable?: number | null
27
+ reserve?: number | null
28
+ limit?: number | null
29
+ reasoning?: number
30
+ categories?: Categories
31
+ updatedAt?: string
32
+ }
33
+
34
+ export declare const STATE_FILE: string
35
+ export declare const POLL_MS: number
36
+ export declare function isNodeModulesInstall(): boolean
37
+ export declare function readSnapshot(): Record<string, StateEntry>
38
+ export declare function collectFamily(
39
+ snapshot: Record<string, StateEntry>,
40
+ rootID: string,
41
+ ): StateEntry[]
42
+ export declare function fmt(n: number): string
43
+ export declare function num(v: unknown): number
44
+ export declare function fit(s: string, w: number): string
45
+ export declare function updatedAt(iso: string | undefined): string
@@ -0,0 +1,165 @@
1
+ // lib/tui-data.js
2
+ //
3
+ // Data layer for the TUI entry (tui.tsx): the state.json bridge (readSnapshot /
4
+ // isEmptyEntry / collectFamily), the shared formatting helpers (fmt / num / fit
5
+ // / updatedAt) and the npm-install detection. Plain JS with JSDoc — same shape
6
+ // as lib/contributors.js — so tui.tsx stays a thin JSX/setup shell and the node
7
+ // tooling (resolve gate, future unit tests) can import it without a transform.
8
+ //
9
+ // These helpers must stay free of solid-js / @opentui imports: the TUI host
10
+ // bridges those modules only for the JSX entry, and keeping this file pure
11
+ // means it also loads anywhere node does.
12
+ //
13
+ // state.json shape (written by writeStateFile() in index.js, untrusted input):
14
+ // { [sessionID]: { sessionID, parentID, role, agent, model, providerID,
15
+ // ctx, input, usable, reserve, limit, reasoning, categories{...}, updatedAt } }
16
+ // The authoritative TS mirror of the entry shape is the `StateEntry` interface
17
+ // in lib/tui-data.d.ts — keep the two structurally compatible.
18
+
19
+ import { readFileSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ export const STATE_FILE =
25
+ process.env.OPENCODE_CONTEXT_INDICATOR_STATE_FILE ||
26
+ join(tmpdir(), "opencode-context-indicator-state.json");
27
+
28
+ export const POLL_MS = 1000;
29
+
30
+ // True when this package was loaded from an npm install, i.e. its own file
31
+ // path contains a `node_modules` segment (OpenCode's TUI loader skips the host
32
+ // Solid transform for such paths → slots mount once but never live-update; see
33
+ // the Graceful mode note in tui.tsx). Normalised to forward slashes for
34
+ // Windows paths. ANY file of the package yields the same answer, so checking
35
+ // from lib/ is equivalent to checking from the tui.tsx entry.
36
+ export function isNodeModulesInstall() {
37
+ try {
38
+ const selfPath = fileURLToPath(import.meta.url).replace(/\\/g, "/").toLowerCase()
39
+ return selfPath.includes("/node_modules/")
40
+ } catch {
41
+ return false
42
+ }
43
+ }
44
+
45
+ // Reserved object keys must never be treated as session ids: state.json is
46
+ // untrusted input (structural mirror of isSafeSessionKey() in lib/estimate.js,
47
+ // including the string/length requirement).
48
+ function isSafeKey(k) {
49
+ return (
50
+ typeof k === "string" &&
51
+ k.length > 0 &&
52
+ k !== "__proto__" &&
53
+ k !== "constructor" &&
54
+ k !== "prototype"
55
+ )
56
+ }
57
+
58
+ // Whole state.json snapshot ({ sessionID: entry }) or {} on any error. The
59
+ // sidebar resolves one entry from it and the breakdown panel walks the
60
+ // parentID family over it. Never throws — a missing / empty / corrupt
61
+ // file simply yields {}.
62
+ export function readSnapshot() {
63
+ try {
64
+ const raw = readFileSync(STATE_FILE, "utf8")
65
+ const all = JSON.parse(raw)
66
+ if (!all || typeof all !== "object" || Array.isArray(all)) return {}
67
+ // Rebuild into a fresh plain object: drops prototype-poisoning keys
68
+ // (__proto__ / constructor / prototype) and non-object entries in one pass.
69
+ const clean = {}
70
+ for (const [k, v] of Object.entries(all)) {
71
+ if (isSafeKey(k) && v && typeof v === "object") clean[k] = v
72
+ }
73
+ return clean
74
+ } catch {
75
+ return {}
76
+ }
77
+ }
78
+
79
+ // True when an entry carries no measured tokens at all (a registered session
80
+ // that never served a step). Mirrors isEmptyStateEntry() in lib/commands.js:
81
+ // `categories` is always persisted, so the emptiness test SUMS its fields —
82
+ // a missing categories object is not the only trigger. `system` / `toolSchemas`
83
+ // may legitimately be null, hence the num() coercion.
84
+ function isEmptyEntry(e) {
85
+ const c = e?.categories
86
+ const sum =
87
+ num(c?.user) +
88
+ num(c?.assistant) +
89
+ num(c?.reasoning) +
90
+ num(c?.toolArgs) +
91
+ num(c?.system) +
92
+ num(c?.toolSchemas) +
93
+ num(c?.other)
94
+ return sum === 0 && num(e?.ctx) === 0 && num(e?.input) === 0
95
+ }
96
+
97
+ // The panel row set: the root entry (panel.sessionID) plus every entry whose
98
+ // parentID chain reaches it, breadth-first. A small local walk over the snapshot
99
+ // — the server's collectDescendantSessionIDs lives in lib/commands.js and is NOT
100
+ // imported here (the TUI entry stays independent of the server code by design). A
101
+ // phantom node (no tokens, see isEmptyEntry) is not emitted, but traversal still
102
+ // descends through it so real grandchildren behind it are found. `seen` guards
103
+ // against cycles (parentID loops) and double-listing.
104
+ export function collectFamily(snapshot, rootID) {
105
+ const out = []
106
+ // hasOwnProperty guard: a host-supplied rootID of "__proto__"/"constructor"
107
+ // must never resolve through the prototype chain into a phantom row.
108
+ const root = Object.prototype.hasOwnProperty.call(snapshot, rootID)
109
+ ? snapshot[rootID]
110
+ : undefined
111
+ if (root && typeof root === "object") out.push(root)
112
+ const seen = new Set([rootID])
113
+ const queue = [rootID]
114
+ // Index the snapshot by parentID once (O(n)) instead of an O(n) scan per node.
115
+ const byParent = new Map()
116
+ for (const [id, e] of Object.entries(snapshot)) {
117
+ const pid = e?.parentID
118
+ if (!pid || typeof pid !== "string") continue
119
+ const kids = byParent.get(pid)
120
+ if (kids) kids.push(id)
121
+ else byParent.set(pid, [id])
122
+ }
123
+ while (queue.length > 0) {
124
+ const cur = queue.shift()
125
+ const kids = byParent.get(cur)
126
+ if (!kids) continue
127
+ for (const id of kids) {
128
+ if (!id || seen.has(id)) continue
129
+ seen.add(id)
130
+ const e = snapshot[id]
131
+ if (e && !isEmptyEntry(e)) out.push(e)
132
+ queue.push(id) // descend through phantom parents too
133
+ }
134
+ }
135
+ return out
136
+ }
137
+
138
+ export function fmt(n) {
139
+ if (!Number.isFinite(n)) return "0"
140
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n))
141
+ }
142
+
143
+ export function num(v) {
144
+ return typeof v === "number" && Number.isFinite(v) ? v : 0
145
+ }
146
+
147
+ // Code-point-safe column fit: truncate on code points (never splitting a
148
+ // surrogate pair at a column boundary), mark a truncated cell with a trailing
149
+ // "~" so clipping is VISIBLE (a silently clipped cell reads as two columns
150
+ // colliding), then pad to the column width.
151
+ export function fit(s, w) {
152
+ const cps = Array.from(s)
153
+ if (cps.length > w) return `${cps.slice(0, Math.max(0, w - 1)).join("")}~`
154
+ return s.padEnd(w)
155
+ }
156
+
157
+ export function updatedAt(iso) {
158
+ if (!iso) return "?"
159
+ try {
160
+ const d = new Date(iso)
161
+ return Number.isNaN(d.getTime()) ? "?" : d.toLocaleTimeString()
162
+ } catch {
163
+ return "?"
164
+ }
165
+ }