opencode-context-indicator 1.7.1 → 1.8.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.
@@ -0,0 +1,153 @@
1
+ // lib/tui-sidebar.ts — the `sidebar.content` slot component.
2
+ //
3
+ // No JSX and no `@jsxImportSource` pragma anywhere in this file: see the long
4
+ // explanation at the top of lib/tui-h.ts. Every dynamic prop is a getter, which
5
+ // is exactly what the Solid compiler would have emitted for `prop={expr}`.
6
+ //
7
+ // This file lives in lib/ rather than in the tui.ts entry because the sidebar
8
+ // is the surface that renders the per-category breakdown; the entry stays a
9
+ // thin setup shell.
10
+
11
+ import { usePlugin } from "@opencode/plugin/tui"
12
+ import { Show } from "solid-js"
13
+ import type { StateEntry } from "./tui-data.js"
14
+ import { fmt, num, updatedAt } from "./tui-data.js"
15
+ import { token } from "./tui-theme.js"
16
+ import { c, h } from "./tui-h.ts"
17
+
18
+ // Sidebar category-label column width (monospace), used by the `line()` helper.
19
+ const LABEL_WIDTH = 12
20
+
21
+ export function Sidebar(props: { entry: () => StateEntry | null }) {
22
+ const context = usePlugin()
23
+ const base = () => token(context?.theme?.text?.base, "white")
24
+ const muted = () => token(context?.theme?.text?.muted, base())
25
+
26
+ const e = () => props.entry()
27
+
28
+ const denom = (): number | null => {
29
+ const x = e()
30
+ const d = x?.usable ?? x?.limit
31
+ return typeof d === "number" && d > 0 ? d : null
32
+ }
33
+ const pct = (v: number): string => {
34
+ const d = denom()
35
+ return d ? `${Math.round((v / d) * 100)}%` : ""
36
+ }
37
+ const line = (label: string, v: unknown): string => {
38
+ const n = num(v)
39
+ const l = (label + " ".repeat(LABEL_WIDTH)).slice(0, LABEL_WIDTH)
40
+ const p = pct(n)
41
+ return `${l}${fmt(n).padStart(7)}${p ? ` ${p.padStart(4)}` : ""}`
42
+ }
43
+ const header = (): string => {
44
+ const x = e()
45
+ if (!x) return ""
46
+ const d = denom()
47
+ return `${x.model ?? "?"} · ${fmt(num(x.ctx))}${
48
+ d ? ` / ${fmt(d)}` : ""
49
+ }${d ? ` (${pct(num(x.ctx))})` : ""}`
50
+ }
51
+
52
+ // One muted category row. The accessor is called INSIDE the getter: the row
53
+ // element is created once and then updates in place on every poll, exactly
54
+ // like `<text fg={muted()}>{line(...)}</text>` in JSX.
55
+ const row = (label: string, value: () => unknown) =>
56
+ h("text", {
57
+ get fg() {
58
+ return muted()
59
+ },
60
+ get children() {
61
+ return line(label, value())
62
+ },
63
+ })
64
+
65
+ // `system` and `tool schemas` are absent from state files written by older
66
+ // plugin versions (null) and render as "n/a" instead of a zero row.
67
+ const optionalRow = (label: string, value: () => number | null | undefined) =>
68
+ h("text", {
69
+ get fg() {
70
+ return muted()
71
+ },
72
+ get children() {
73
+ const v = value()
74
+ return v == null
75
+ ? `${label.padEnd(LABEL_WIDTH)} n/a`
76
+ : line(label, v)
77
+ },
78
+ })
79
+
80
+ return h("box", {
81
+ flexDirection: "column",
82
+ get children() {
83
+ return [
84
+ h("text", {
85
+ get fg() {
86
+ return base()
87
+ },
88
+ get children() {
89
+ return "Context"
90
+ },
91
+ }),
92
+ c(Show, {
93
+ get when() {
94
+ return e()
95
+ },
96
+ get fallback() {
97
+ return h("text", {
98
+ get fg() {
99
+ return muted()
100
+ },
101
+ get children() {
102
+ return " no data yet"
103
+ },
104
+ })
105
+ },
106
+ get children() {
107
+ return [
108
+ h("text", {
109
+ get fg() {
110
+ return base()
111
+ },
112
+ get children() {
113
+ return header()
114
+ },
115
+ }),
116
+ row("user", () => e()?.categories?.user),
117
+ row("assistant", () => e()?.categories?.assistant),
118
+ row("reasoning", () => e()?.categories?.reasoning),
119
+ row("tool args", () => e()?.categories?.toolArgs),
120
+ row("tool out", () => e()?.categories?.toolOut),
121
+ optionalRow("system", () => e()?.categories?.system),
122
+ optionalRow("tool schemas", () => e()?.categories?.toolSchemas),
123
+ row("other", () => e()?.categories?.other),
124
+ c(Show, {
125
+ get when() {
126
+ return e()?.compactStale === true
127
+ },
128
+ get children() {
129
+ return h("text", {
130
+ get fg() {
131
+ return muted()
132
+ },
133
+ get children() {
134
+ return " stale — compacted"
135
+ },
136
+ })
137
+ },
138
+ }),
139
+ h("text", {
140
+ get fg() {
141
+ return muted()
142
+ },
143
+ get children() {
144
+ return `updated ${updatedAt(e()?.updatedAt)}`
145
+ },
146
+ }),
147
+ ]
148
+ },
149
+ }),
150
+ ]
151
+ },
152
+ })
153
+ }
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "opencode-context-indicator",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
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",
7
7
  "exports": {
8
8
  ".": "./index.js",
9
- "./tui": "./tui.tsx",
9
+ "./tui": "./tui.ts",
10
10
  "./rpc": "./lib/rpc.js",
11
11
  "./gui-experimental": "./dist/gui-experimental.cjs"
12
12
  },
13
13
  "files": [
14
14
  "index.js",
15
15
  "lib/",
16
- "tui.tsx",
16
+ "tui.ts",
17
17
  "dist/",
18
18
  "docs/",
19
19
  "README.md",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@opencode/plugin": ">=2.0.16",
55
- "@opentui/core": ">=0.5.14",
55
+ "@opentui/core": ">=0.8.0",
56
56
  "solid-js": ">=1.9.0"
57
57
  },
58
58
  "peerDependenciesMeta": {
@@ -66,12 +66,10 @@
66
66
  "optional": true
67
67
  }
68
68
  },
69
- "dependencies": {
70
- "@opentui/solid": "0.7.0"
71
- },
72
69
  "devDependencies": {
73
- "@opencode/plugin": "2.2.0",
74
- "@opentui/core": "0.7.0",
70
+ "@opencode/plugin": "2.2.5",
71
+ "@opentui/core": "0.8.0",
72
+ "@opentui/solid": "0.8.0",
75
73
  "@types/node": "26.6.3",
76
74
  "esbuild": "^0.28.2",
77
75
  "node-pty": "^1.1.0",
@@ -87,7 +85,7 @@
87
85
  "test:e2e": "node test/e2e-tui.mjs --full",
88
86
  "test:e2e:quick": "node test/e2e-tui.mjs --quick",
89
87
  "test:drift": "node test/drift-watch.mjs",
90
- "test:drift:strict": "node test/drift-watch.mjs --strict"
91
- },
92
- "prepublishOnly": "npm run build:gui && node test/prepublish.mjs && node test/e2e-tui.mjs --full"
88
+ "test:drift:strict": "node test/drift-watch.mjs --strict",
89
+ "prepublishOnly": "npm run build:gui && node test/prepublish.mjs && node test/e2e-tui.mjs --full"
90
+ }
93
91
  }
package/tui.ts ADDED
@@ -0,0 +1,317 @@
1
+ // tui.ts — optional OpenCode TUI surfaces for `opencode-context-indicator`.
2
+ //
3
+ // NO JSX AND NO `@jsxImportSource` PRAGMA IN THIS PACKAGE. Read the long
4
+ // explanation at the top of lib/tui-h.ts before touching any TUI file:
5
+ // OpenCode installs npm plugins inside a `node_modules` tree, where the host
6
+ // SKIPS the Solid/Babel JSX transform (upstream anomalyco/opencode#33884).
7
+ // Building the tree with jsx() and getter props keeps the surfaces in sync with
8
+ // the 1 s poll from BOTH install layouts (npm and file://), and lets the
9
+ // package declare no runtime dependencies at all.
10
+ //
11
+ // This file is the ENTRY + setup wiring only. The components and the data layer
12
+ // live in lib/:
13
+ // - lib/tui-sidebar.ts the `sidebar.content` slot component
14
+ // - lib/tui-panel.ts the `session.panel` component (BreakdownPanel)
15
+ // - lib/tui-h.ts the jsx()/component() builders (only jsx-runtime user)
16
+ // - lib/tui-data.js state.json bridge, family walk, formatting helpers
17
+ // (+ lib/tui-data.d.ts for the shared types)
18
+ // - lib/tui-theme.js theme-token helper
19
+ // - lib/contributors.js measured-contributor computation + redacted export
20
+ //
21
+ // Exposed as the `./tui` package entry ("beside the main plugin for automatic
22
+ // loading"). OpenCode loads it only in the terminal TUI; it is NOT loaded in
23
+ // the Desktop app (Desktop exposes no TUI slot API). It renders the
24
+ // per-category context breakdown produced by the server plugin (index.js) into
25
+ // the session sidebar via `append: "sidebar.content"` and into a full session
26
+ // panel (`append: "session.panel"`) opened by the `cx` slash command / keymap
27
+ // layer registered from an `append: "app"` slot.
28
+ //
29
+ // Data bridge: the server plugin rewrites
30
+ // %TEMP%/opencode-context-indicator-state.json
31
+ // (see writeStateFile() in index.js) on every breakdown update. This module
32
+ // ONLY reads that file and re-polls it once per second; a missing or corrupt
33
+ // file renders "no data yet". The main plugin never imports the TUI entry, so
34
+ // the absence of the OpenTUI peers cannot break the server/V1 path.
35
+ //
36
+ // Runtime requirements: @opentui/core, @opentui/solid and solid-js are resolved
37
+ // by OpenCode at runtime — it rewrites these specifiers to in-process modules
38
+ // (`nodeModulesRuntimeSpecifiers: true` in @opentui/core's
39
+ // DEFAULT_RUNTIME_PLUGIN_REWRITE_OPTIONS) for any file under node_modules, so
40
+ // @opentui/solid is a DEV dependency here (types + local tooling only), never a
41
+ // production one. Shipping it dragged in a duplicate @opentui/core and the whole
42
+ // Babel toolchain for nothing.
43
+
44
+ import { Plugin } from "@opencode/plugin/tui"
45
+ import { createSignal, Show } from "solid-js"
46
+ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"
47
+ import { tmpdir } from "node:os"
48
+ import { dirname, join } from "node:path"
49
+ import type { StateEntry } from "./lib/tui-data.js"
50
+ import { POLL_MS, readSnapshot } from "./lib/tui-data.js"
51
+ import { c as comp, h } from "./lib/tui-h.ts"
52
+ import { Sidebar } from "./lib/tui-sidebar.ts"
53
+ import { BreakdownPanel } from "./lib/tui-panel.ts"
54
+
55
+ // Session-panel contribution name. Session-panel names are shared selection
56
+ // values (not registered claims), so it MUST be plugin-prefixed to avoid
57
+ // colliding with another plugin's panel.
58
+ const PANEL_NAME = "context-indicator.breakdown"
59
+
60
+ export default Plugin.define({
61
+ id: "context-indicator.tui",
62
+ setup(context) {
63
+ // Re-read the bridge file once per second and re-render reactively. ONE
64
+ // ticker drives BOTH surfaces, and the snapshot is cached per tick: every
65
+ // consumer (sidebar, panel data, messages) shares a single readFileSync.
66
+ const [tick, setTick] = createSignal(0)
67
+ let currentSession: string | undefined
68
+ let snapCache: Record<string, StateEntry> | null = null
69
+ let snapAt = -1
70
+ const snapshot = (): Record<string, StateEntry> => {
71
+ const t = tick() // track the ticker so reads re-run every poll
72
+ if (snapCache === null || snapAt !== t) {
73
+ snapCache = readSnapshot()
74
+ snapAt = t
75
+ }
76
+ return snapCache
77
+ }
78
+ const entry = (): StateEntry | null =>
79
+ snapshot()[currentSession ?? ""] ?? null
80
+ const refresh = () => setTick((n) => n + 1)
81
+ const timer = setInterval(() => setTick((n) => n + 1), POLL_MS)
82
+
83
+ // E2E liveness probe (test/e2e-tui.mjs Check C only; inert in production).
84
+ //
85
+ // Check A proves the ./tui entry LOADED; this probe proves the tree stays
86
+ // REACTIVE when the package is loaded from a `node_modules` path — the exact
87
+ // shape of an npm install, where the host skips its Solid JSX transform
88
+ // (#33884). The node is built by the same h()/getter builders as the real
89
+ // sidebar and is driven by the same 1 s poll tick; its `ref` is sampled into
90
+ // a file because pty screen text is not a reliable liveness oracle
91
+ // (differential redraws).
92
+ const livenessFile = process.env.OPENCODE_CONTEXT_INDICATOR_TUI_LIVENESS
93
+ let probeNode: unknown = null
94
+ const setProbeNode = (el: unknown) => {
95
+ probeNode = el
96
+ }
97
+ // `ref` + getter-driven props: the text content and the foreground colour
98
+ // both come from the poll tick, so a frozen (untransformed) tree leaves the
99
+ // sampled values constant. `plainText` is the renderable's own rendered text
100
+ // — a throwaway pty probe showed it tracks updates reliably while screen
101
+ // scraping does not (hence Check C reads this file, not the pty screen).
102
+ const livenessProbe = livenessFile
103
+ ? () =>
104
+ h("box", {
105
+ flexDirection: "column",
106
+ get children() {
107
+ return [
108
+ h("text", {
109
+ ref: setProbeNode,
110
+ get fg() {
111
+ return tick() % 2 ? "#ff0000" : "#0000ff"
112
+ },
113
+ get children() {
114
+ return `probe ${tick()}`
115
+ },
116
+ }),
117
+ ]
118
+ },
119
+ })
120
+ : null
121
+ const noteLive = livenessFile
122
+ ? (line: string) => {
123
+ try {
124
+ appendFileSync(livenessFile, line + "\n")
125
+ } catch {
126
+ /* probe is best-effort test instrumentation */
127
+ }
128
+ }
129
+ : null
130
+ let probeTimer: ReturnType<typeof setInterval> | undefined
131
+ if (livenessFile) {
132
+ try {
133
+ mkdirSync(dirname(livenessFile), { recursive: true })
134
+ writeFileSync(livenessFile, "")
135
+ } catch {
136
+ /* probe is best-effort test instrumentation */
137
+ }
138
+ probeTimer = setInterval(() => {
139
+ let text = "?"
140
+ let fg = "?"
141
+ try {
142
+ const node = probeNode as Record<string, unknown> | null
143
+ text = String(node?.plainText ?? node?.text ?? "?")
144
+ fg = String(node?.fg ?? node?.foregroundColor ?? "?")
145
+ } catch {
146
+ /* node not mounted yet */
147
+ }
148
+ try {
149
+ appendFileSync(livenessFile, `tick=${tick()} fg=${fg} text=${text}\n`)
150
+ } catch {
151
+ /* probe is best-effort test instrumentation */
152
+ }
153
+ }, 250)
154
+ }
155
+
156
+ // Registering a surface must never throw out of setup(): the peer floor is
157
+ // @opencode/plugin >=2.0.16, and a host without the slot API would otherwise
158
+ // take the whole TUI entry down — losing every other surface with it. A
159
+ // missing surface is logged once and the rest keeps working.
160
+ const registerSlot = (
161
+ name: string,
162
+ register: () => (() => void) | undefined,
163
+ ): (() => void) => {
164
+ try {
165
+ const unregister = register()
166
+ return () => {
167
+ try {
168
+ unregister?.()
169
+ } catch {
170
+ /* ignore */
171
+ }
172
+ }
173
+ } catch (error) {
174
+ console.error(
175
+ `[context-indicator] TUI slot "${name}" unavailable: ${String(error)}`,
176
+ )
177
+ return () => {}
178
+ }
179
+ }
180
+
181
+ const unregisterSidebar = registerSlot("sidebar.content", () =>
182
+ context.ui.slot({
183
+ append: "sidebar.content",
184
+ render: ({ sessionID }) => {
185
+ currentSession = sessionID
186
+ noteLive?.("slot-render sidebar.content")
187
+ const sidebar = comp(Sidebar, { entry })
188
+ if (!livenessProbe) return sidebar
189
+ // The probe must live in a real container: a bare array returned from a
190
+ // slot render is not mounted by every host version.
191
+ return h("box", {
192
+ flexDirection: "column",
193
+ get children() {
194
+ return [sidebar, livenessProbe()]
195
+ },
196
+ })
197
+ },
198
+ }),
199
+ )
200
+
201
+ // The probe is ALSO mounted in the `app` slot, which the host renders even
202
+ // when no session/sidebar is on screen (a standalone instance with no prompt
203
+ // has no sidebar.content contribution to mount). Whichever slot mounts
204
+ // first fills the sample file; both share the same poll tick.
205
+ const unregisterProbe = livenessProbe
206
+ ? registerSlot("app", () =>
207
+ context.ui.slot({
208
+ append: "app",
209
+ render: () => {
210
+ noteLive?.("slot-render app")
211
+ return livenessProbe()
212
+ },
213
+ }),
214
+ )
215
+ : undefined
216
+
217
+ // A `session.panel` contribution is independently selectable: it renders
218
+ // only while its name is the selected one, and the root session comes from
219
+ // panel.sessionID.
220
+ const unregisterPanel = registerSlot("session.panel", () =>
221
+ context.ui.slot({
222
+ append: "session.panel",
223
+ render: (panel) =>
224
+ comp(Show, {
225
+ get when() {
226
+ return panel.name === PANEL_NAME
227
+ },
228
+ get children() {
229
+ return comp(BreakdownPanel, { panel, snapshot, refresh })
230
+ },
231
+ }),
232
+ }),
233
+ )
234
+
235
+ // The `app` slot renders nothing itself; it exists to register the global
236
+ // keymap layer (slash command `cx` -> open the panel). The layer is owned by
237
+ // this slot and disposed when the slot unregisters.
238
+ const unregisterCommands = registerSlot("app (keymap)", () =>
239
+ context.ui.slot({
240
+ append: "app",
241
+ render: () => {
242
+ const keymap = context?.keymap
243
+ if (keymap && typeof keymap.layer === "function") {
244
+ keymap.layer(() => ({
245
+ mode: "global",
246
+ commands: [
247
+ {
248
+ id: "context-indicator.breakdown",
249
+ title: "Context breakdown panel",
250
+ slash: { name: "cx" },
251
+ // Palette-visible AND directly bound: subagent session views
252
+ // force the composer into its "subagents" tab, which pushes
253
+ // keymap mode "composer" — the base-mode palette binding
254
+ // (ctrl+p) is inert there and there is no prompt to type /cx.
255
+ // A bind on this mode:"global" layer fires in every mode
256
+ // (leader matches <leader>b "toggle sidebar"; f4 as a direct
257
+ // single-chord fallback).
258
+ palette: true,
259
+ bind: "<leader>c,f4",
260
+ run: () => {
261
+ const panelApi = context?.ui?.panel
262
+ if (panelApi && typeof panelApi.open === "function") {
263
+ panelApi.open(PANEL_NAME)
264
+ }
265
+ },
266
+ },
267
+ ],
268
+ }))
269
+ }
270
+ return null
271
+ },
272
+ }),
273
+ )
274
+
275
+ // One-time live-surface marker. The e2e Check A asserts this line from the
276
+ // isolated instance's private log: it proves the ./tui entry not only
277
+ // loaded but REGISTERED its surfaces — a host-transform failure of this
278
+ // file would otherwise die silently between "loading plugin" and here.
279
+ console.log(
280
+ "[context-indicator] live TUI surfaces registered (sidebar + session.panel + keymap)",
281
+ )
282
+
283
+ // E2E instrumentation (only when test/e2e-tui.mjs sets the marker env):
284
+ // same proof as the console line above, but via a FILE — the TUI captures
285
+ // console output, so the log channel cannot carry it. Production runs
286
+ // never set the env and never write the stamp.
287
+ if (process.env.OPENCODE_CONTEXT_INDICATOR_TUI_MARKER) {
288
+ try {
289
+ mkdirSync(join(tmpdir(), "opencode-context-indicator"), { recursive: true })
290
+ writeFileSync(
291
+ join(tmpdir(), "opencode-context-indicator", "tui-live.stamp"),
292
+ new Date().toISOString(),
293
+ "utf8",
294
+ )
295
+ } catch {
296
+ /* marker is best-effort test instrumentation */
297
+ }
298
+ }
299
+
300
+ return () => {
301
+ clearInterval(timer)
302
+ if (probeTimer) clearInterval(probeTimer)
303
+ for (const unregister of [
304
+ unregisterSidebar,
305
+ unregisterProbe,
306
+ unregisterPanel,
307
+ unregisterCommands,
308
+ ]) {
309
+ try {
310
+ unregister?.()
311
+ } catch {
312
+ /* ignore */
313
+ }
314
+ }
315
+ }
316
+ },
317
+ })
@@ -1,76 +0,0 @@
1
- /** @jsxImportSource @opentui/solid */
2
- // lib/tui-sidebar.tsx — the `sidebar.content` slot component. Moved verbatim
3
- // from tui.tsx when the TUI entry was split: the host's Solid transform
4
- // applies to EVERY plugin file outside node_modules (solid-plugin.js
5
- // sourceFilter in @opentui/solid), so JSX in imported files is compiled the
6
- // same way as in the entry.
7
-
8
- import { usePlugin } from "@opencode/plugin/tui"
9
- import { Show } from "solid-js"
10
- import type { StateEntry } from "./tui-data.js"
11
- import { fmt, num, updatedAt } from "./tui-data.js"
12
- import { token } from "./tui-theme.js"
13
-
14
- // Sidebar category-label column width (monospace), used by the `line()` helper.
15
- const LABEL_WIDTH = 12
16
-
17
- export function Sidebar(props: { entry: () => StateEntry | null }) {
18
- const context = usePlugin()
19
- const base = () => token(context?.theme?.text?.base, "white")
20
- const muted = () => token(context?.theme?.text?.muted, base())
21
-
22
- const e = () => props.entry()
23
-
24
- const denom = (): number | null => {
25
- const x = e()
26
- const d = x?.usable ?? x?.limit
27
- return typeof d === "number" && d > 0 ? d : null
28
- }
29
- const pct = (v: number): string => {
30
- const d = denom()
31
- return d ? `${Math.round((v / d) * 100)}%` : ""
32
- }
33
- const line = (label: string, v: unknown): string => {
34
- const n = num(v)
35
- const l = (label + " ".repeat(LABEL_WIDTH)).slice(0, LABEL_WIDTH)
36
- const p = pct(n)
37
- return `${l}${fmt(n).padStart(7)}${p ? ` ${p.padStart(4)}` : ""}`
38
- }
39
- const header = (): string => {
40
- const x = e()
41
- if (!x) return ""
42
- const d = denom()
43
- return `${x.model ?? "?"} · ${fmt(num(x.ctx))}${
44
- d ? ` / ${fmt(d)}` : ""
45
- }${d ? ` (${pct(num(x.ctx))})` : ""}`
46
- }
47
-
48
- return (
49
- <box flexDirection="column">
50
- <text fg={base()}>{"Context"}</text>
51
- <Show when={e()} fallback={<text fg={muted()}>{" no data yet"}</text>}>
52
- <text fg={base()}>{header()}</text>
53
- <text fg={muted()}>{line("user", e()?.categories?.user)}</text>
54
- <text fg={muted()}>{line("assistant", e()?.categories?.assistant)}</text>
55
- <text fg={muted()}>{line("reasoning", e()?.categories?.reasoning)}</text>
56
- <text fg={muted()}>{line("tool args", e()?.categories?.toolArgs)}</text>
57
- <text fg={muted()}>{line("tool out", e()?.categories?.toolOut)}</text>
58
- <text fg={muted()}>
59
- {e()?.categories?.system == null
60
- ? `${"system".padEnd(LABEL_WIDTH)} n/a`
61
- : line("system", e()?.categories?.system)}
62
- </text>
63
- <text fg={muted()}>
64
- {e()?.categories?.toolSchemas == null
65
- ? `${"tool schemas".padEnd(LABEL_WIDTH)} n/a`
66
- : line("tool schemas", e()?.categories?.toolSchemas)}
67
- </text>
68
- <text fg={muted()}>{line("other", e()?.categories?.other)}</text>
69
- <Show when={e()?.compactStale === true}>
70
- <text fg={muted()}>{" stale — compacted"}</text>
71
- </Show>
72
- <text fg={muted()}>{`updated ${updatedAt(e()?.updatedAt)}`}</text>
73
- </Show>
74
- </box>
75
- )
76
- }