@cortexkit/aft-opencode 0.56.2 → 0.57.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.
Files changed (39) hide show
  1. package/dist/bg-notifications.d.ts +1 -0
  2. package/dist/bg-notifications.d.ts.map +1 -1
  3. package/dist/entry/server.js +2650 -267
  4. package/dist/index.js +420 -228
  5. package/dist/lsp-github-install.d.ts.map +1 -1
  6. package/dist/permissions/v2-service.d.ts +30 -0
  7. package/dist/permissions/v2-service.d.ts.map +1 -0
  8. package/dist/permissions/v2.d.ts +121 -9
  9. package/dist/permissions/v2.d.ts.map +1 -1
  10. package/dist/shared/rpc-server.d.ts.map +1 -1
  11. package/dist/shared/status.d.ts +8 -1
  12. package/dist/shared/status.d.ts.map +1 -1
  13. package/dist/subc-tool-schemas.d.ts +11 -0
  14. package/dist/subc-tool-schemas.d.ts.map +1 -1
  15. package/dist/tool-registration.d.ts.map +1 -1
  16. package/dist/tools/definitions/v2.d.ts +6 -0
  17. package/dist/tools/definitions/v2.d.ts.map +1 -1
  18. package/dist/tools/hoisted/v2.d.ts +12 -2
  19. package/dist/tools/hoisted/v2.d.ts.map +1 -1
  20. package/dist/tools/hoisted.d.ts.map +1 -1
  21. package/dist/tools/semantic.d.ts.map +1 -1
  22. package/dist/tui/v2-status.d.ts +8 -0
  23. package/dist/tui/v2-status.d.ts.map +1 -1
  24. package/index.js +14 -0
  25. package/package.json +14 -10
  26. package/server.js +8 -0
  27. package/src/shared/rpc-server.ts +5 -0
  28. package/src/shared/status.ts +16 -9
  29. package/src/tui/index.tsx +1 -0
  30. package/src/tui/sidebar-view.tsx +722 -0
  31. package/src/tui/sidebar.tsx +42 -560
  32. package/src/tui/v2-status.ts +8 -1
  33. package/src/tui/v2.tsx +173 -30
  34. package/src/tui-compiled/index.tsx +1 -1
  35. package/src/tui-compiled/sidebar-view.tsx +815 -0
  36. package/src/tui-compiled/sidebar.tsx +45 -693
  37. package/src/tui-compiled/v2-status.ts +8 -1
  38. package/src/tui-compiled/v2.tsx +163 -54
  39. package/tui.js +7 -0
@@ -0,0 +1,722 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ // @ts-nocheck
3
+
4
+ // The AFT sidebar panel, shared by both OpenCode hosts.
5
+ //
6
+ // Everything that decides what the panel *draws* lives here and takes the
7
+ // status snapshot, the user's preferences and a colour palette as plain
8
+ // inputs. The two hosts differ only in how a component is mounted and how the
9
+ // theme arrives, so those two things stay at the edge (sidebar.tsx for the
10
+ // slot-plugin host, v2.tsx for the GA host) and the body below is rendered
11
+ // identically by both. Without this split the second host drifts into a
12
+ // smaller, differently-worded panel, which is exactly what happened before.
13
+
14
+ import { createMemo, createSignal, onCleanup } from "solid-js";
15
+ import {
16
+ type AftStatusSnapshot,
17
+ formatSemanticIndexStatus,
18
+ formatSemanticRefreshing,
19
+ type StatusBar,
20
+ type StatusCompression,
21
+ semanticIndexStatusKind,
22
+ worktreeCacheRoleNote,
23
+ } from "../shared/status";
24
+ import { badgeTextColor } from "./badge-contrast";
25
+ import {
26
+ type AftTuiPrefs,
27
+ DEFAULT_PREFS,
28
+ persistCollapsedIfEnabled,
29
+ readTuiPreferencesFile,
30
+ resolveAftPrefs,
31
+ seedCollapsedFromPrefs,
32
+ watchTuiPreferences,
33
+ } from "./preferences";
34
+
35
+ const SINGLE_BORDER = { type: "single" } as any;
36
+
37
+ /**
38
+ * A colour is whatever the host's theme hands us: OpenTUI accepts both its own
39
+ * RGBA instances and hex strings, and the two hosts spell their themes
40
+ * differently, so the panel never looks inside a colour — it only passes it on.
41
+ */
42
+ export type SidebarColor = unknown;
43
+
44
+ /**
45
+ * The colours the panel draws with, named after their role rather than after
46
+ * either host's theme keys. Each host resolves its own theme into this shape
47
+ * before rendering; see resolveV1Palette / resolveV2Palette at the edges.
48
+ */
49
+ export interface SidebarPalette {
50
+ text: SidebarColor;
51
+ textMuted: SidebarColor;
52
+ success: SidebarColor;
53
+ warning: SidebarColor;
54
+ error: SidebarColor;
55
+ accent: SidebarColor;
56
+ background: SidebarColor;
57
+ border: SidebarColor;
58
+ }
59
+
60
+ type Channels = { r: number; g: number; b: number; a?: number };
61
+
62
+ function hexChannels(value: string): Channels | null {
63
+ const hex = value.trim().replace(/^#/, "");
64
+ if (hex.length !== 3 && hex.length !== 6 && hex.length !== 8) return null;
65
+ const wide = hex.length === 3 ? [...hex].map((c) => c + c).join("") : hex;
66
+ const int = Number.parseInt(wide.slice(0, 6), 16);
67
+ if (Number.isNaN(int)) return null;
68
+ const alpha = wide.length === 8 ? Number.parseInt(wide.slice(6, 8), 16) / 255 : 1;
69
+ return {
70
+ r: ((int >> 16) & 0xff) / 255,
71
+ g: ((int >> 8) & 0xff) / 255,
72
+ b: (int & 0xff) / 255,
73
+ a: alpha,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Read a colour's 0..1 channels for the badge contrast decision only. OpenTUI's
79
+ * RGBA already exposes r/g/b/a getters in that range; a hex string has to be
80
+ * parsed. Anything else returns null and the caller keeps its existing choice
81
+ * rather than guessing.
82
+ */
83
+ function colorChannels(value: SidebarColor): Channels | null {
84
+ if (typeof value === "string") return hexChannels(value);
85
+ if (value && typeof value === "object") {
86
+ const candidate = value as Partial<Channels>;
87
+ if (typeof candidate.r === "number" && typeof candidate.g === "number") {
88
+ if (typeof candidate.b !== "number") return null;
89
+ return { r: candidate.r, g: candidate.g, b: candidate.b, a: candidate.a };
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Text colour for a label drawn on the accent badge. badgeTextColor works on
97
+ * plain channels, but the value handed back to OpenTUI has to be the host's
98
+ * original colour object, so the decision is made on the channels and then
99
+ * mapped back.
100
+ */
101
+ export function badgeForeground(accent: SidebarColor, background: SidebarColor): SidebarColor {
102
+ const accentChannels = colorChannels(accent);
103
+ const backgroundChannels = colorChannels(background);
104
+ if (!accentChannels || !backgroundChannels) return background;
105
+ const picked = badgeTextColor(accentChannels, backgroundChannels);
106
+ return picked === backgroundChannels ? background : picked;
107
+ }
108
+
109
+ function formatBytes(n: number): string {
110
+ if (!Number.isFinite(n) || n <= 0) return "—";
111
+ if (n >= 1_073_741_824) return `${(n / 1_073_741_824).toFixed(1)} GB`;
112
+ if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB`;
113
+ if (n >= 1_024) return `${Math.round(n / 1_024)} KB`;
114
+ return `${n} B`;
115
+ }
116
+
117
+ function formatCount(n: number | null | undefined): string {
118
+ if (n == null || !Number.isFinite(n)) return "—";
119
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
120
+ if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
121
+ return String(n);
122
+ }
123
+
124
+ /** Tagged rows for the Compression section. Each scope (Session / Project)
125
+ * emits a "scope" header followed by two "stat" rows — Tokens Saved and
126
+ * Compression Ratio — so the renderer can use the same StatRow layout as
127
+ * Search Index / Semantic Index above. Pi's monospace overlay and the
128
+ * OpenCode TUI dialog/sidebar all consume this same shape. */
129
+ export type CompressionRow =
130
+ | { kind: "scope"; label: string }
131
+ | { kind: "stat"; label: string; value: string };
132
+
133
+ function appendScope(
134
+ rows: CompressionRow[],
135
+ label: string,
136
+ scope: {
137
+ events: number;
138
+ original_tokens: number;
139
+ compressed_tokens: number;
140
+ savings_tokens: number;
141
+ },
142
+ ): void {
143
+ const savings = scope.savings_tokens;
144
+ const pct = scope.original_tokens > 0 ? Math.round((savings / scope.original_tokens) * 100) : 0;
145
+ rows.push({ kind: "scope", label });
146
+ rows.push({ kind: "stat", label: "Tokens Saved", value: savings.toLocaleString("en-US") });
147
+ rows.push({ kind: "stat", label: "Compression Ratio", value: `${pct}%` });
148
+ }
149
+
150
+ export function formatCompressionSidebarRows(
151
+ compression: StatusCompression | undefined,
152
+ ): CompressionRow[] {
153
+ if (!compression || compression.project.events <= 0) return [];
154
+
155
+ const rows: CompressionRow[] = [];
156
+ if (compression.session.events > 0) {
157
+ appendScope(rows, "Session", compression.session);
158
+ }
159
+ appendScope(rows, "Project", compression.project);
160
+
161
+ return rows;
162
+ }
163
+
164
+ // Map an index status word to (label, theme color name). The label is what we
165
+ // want the user to see; the color encodes severity so the eye lands on trouble.
166
+ //
167
+ // The tone comes from the same classification the label uses
168
+ // (`semanticIndexStatusKind` in @cortexkit/aft-bridge), so a word cannot be
169
+ // readable in one column and unrecognised grey in the other.
170
+ // `backend_unavailable` used to fall through to the default arm: a real
171
+ // embedding-backend outage was drawn in the same muted grey as a word the
172
+ // renderer has no idea about, which reads as noise rather than as a condition
173
+ // the user has to act on.
174
+ export function statusDisplay(status: string): {
175
+ label: string;
176
+ tone: "ok" | "warn" | "err" | "muted";
177
+ } {
178
+ const label = status || "unknown";
179
+ switch (semanticIndexStatusKind(status)) {
180
+ case "ready":
181
+ return { label, tone: "ok" };
182
+ case "progress":
183
+ return { label, tone: "warn" };
184
+ case "failure":
185
+ return { label, tone: "err" };
186
+ case "inactive":
187
+ return { label, tone: "muted" };
188
+ default:
189
+ // Nothing is known about this word, so grey is honest here: it says the
190
+ // renderer has no reading to offer, not that everything is fine.
191
+ return { label, tone: "muted" };
192
+ }
193
+ }
194
+
195
+ const StatRow = (props: {
196
+ palette: SidebarPalette;
197
+ label: string;
198
+ value: string;
199
+ tone?: "ok" | "warn" | "err" | "muted" | "accent";
200
+ }) => {
201
+ const fg = createMemo(() => {
202
+ switch (props.tone) {
203
+ case "ok":
204
+ return props.palette.success;
205
+ case "warn":
206
+ return props.palette.warning;
207
+ case "err":
208
+ return props.palette.error;
209
+ case "muted":
210
+ return props.palette.textMuted;
211
+ case "accent":
212
+ return props.palette.accent;
213
+ default:
214
+ return props.palette.text;
215
+ }
216
+ });
217
+
218
+ return (
219
+ <box width="100%" flexDirection="row" justifyContent="space-between">
220
+ <text fg={props.palette.textMuted}>{props.label}</text>
221
+ <text fg={fg()}>
222
+ <b>{props.value}</b>
223
+ </text>
224
+ </box>
225
+ );
226
+ };
227
+
228
+ const SectionHeader = (props: { palette: SidebarPalette; title: string; marginTop?: number }) => (
229
+ <box width="100%" marginTop={props.marginTop ?? 1}>
230
+ <text fg={props.palette.text}>
231
+ <b>{props.title}</b>
232
+ </text>
233
+ </box>
234
+ );
235
+
236
+ // Map a status tone to a palette color — used for the collapsed-view status dots.
237
+ function toneColor(palette: SidebarPalette, tone: "ok" | "warn" | "err" | "muted"): SidebarColor {
238
+ switch (tone) {
239
+ case "ok":
240
+ return palette.success;
241
+ case "warn":
242
+ return palette.warning;
243
+ case "err":
244
+ return palette.error;
245
+ default:
246
+ return palette.textMuted;
247
+ }
248
+ }
249
+
250
+ // Collapsed-view row: label on the left, a status dot (or compact value) on the
251
+ // right. Mirrors the expanded StatRow layout so the columns line up.
252
+ const CollapsedRow = (props: { palette: SidebarPalette; label: string; children: unknown }) => (
253
+ <box width="100%" flexDirection="row" justifyContent="space-between">
254
+ <text fg={props.palette.textMuted}>{props.label}</text>
255
+ {props.children}
256
+ </box>
257
+ );
258
+
259
+ // Compact "saved / ratio" string for the collapsed Compression row — e.g.
260
+ // "7.6M / 64%". Uses the local `formatCount` (not the aft-bridge token
261
+ // formatter) so the TUI bundle doesn't pull the bridge barrel, which exports
262
+ // URL-fetch helpers unsuitable for Bun's TUI runtime. Returns null when no
263
+ // compression has been recorded yet.
264
+ export function collapsedCompressionValue(
265
+ compression: StatusCompression | undefined,
266
+ ): string | null {
267
+ if (!compression || compression.project.events <= 0) return null;
268
+ const { savings_tokens, original_tokens } = compression.project;
269
+ const pct = original_tokens > 0 ? Math.round((savings_tokens / original_tokens) * 100) : 0;
270
+ return `${formatCount(savings_tokens)} / ${pct}%`;
271
+ }
272
+
273
+ export type HealthLightTone = "ok" | "warn" | "err" | "muted";
274
+
275
+ // Degraded-mode reason → human-readable hint. Distinct strings per reason
276
+ // because the UX direction is different: "home_root" tells the user to open a
277
+ // real project subdirectory, "search_too_many_files" tells them the tree is too
278
+ // big for full indexing, and "watcher_unavailable" is an honest soft
279
+ // degradation (AFT continues without live external-change invalidation).
280
+ export function degradedReasonLabel(reason: string): string {
281
+ if (reason === "home_root") {
282
+ return "project root is your home directory";
283
+ }
284
+ if (reason.startsWith("search_too_many_files:")) {
285
+ const threshold = reason.split(":")[1] ?? "20000";
286
+ return `project exceeds ${threshold} files`;
287
+ }
288
+ if (reason === "watcher_unavailable") {
289
+ return "file watcher unavailable; continuing without live external-change invalidation";
290
+ }
291
+ return reason; // unknown reason — surface verbatim so users can grep logs
292
+ }
293
+
294
+ export interface HealthLights {
295
+ diagnostics: HealthLightTone;
296
+ code: HealthLightTone;
297
+ todos: HealthLightTone;
298
+ }
299
+
300
+ // Missing categories are intentionally muted: a green light requires an
301
+ // explicit zero for every category that feeds that light.
302
+ export function collapsedHealthLights(statusBar: StatusBar | undefined): HealthLights | null {
303
+ if (!statusBar) return null;
304
+ const diagnostics: HealthLightTone =
305
+ statusBar.errors !== undefined && statusBar.errors > 0
306
+ ? "err"
307
+ : statusBar.warnings !== undefined && statusBar.warnings > 0
308
+ ? "warn"
309
+ : statusBar.errors === 0 && statusBar.warnings === 0
310
+ ? "ok"
311
+ : "muted";
312
+ const codeValues = [statusBar.dead_code, statusBar.unused_exports, statusBar.duplicates];
313
+ const code: HealthLightTone = codeValues.some((value) => value !== undefined && value > 0)
314
+ ? "warn"
315
+ : codeValues.every((value) => value === 0)
316
+ ? "ok"
317
+ : "muted";
318
+ const todos: HealthLightTone =
319
+ statusBar.todos === undefined ? "muted" : statusBar.todos > 0 ? "warn" : "ok";
320
+ return { diagnostics, code, todos };
321
+ }
322
+
323
+ export interface AftSidebarPanelProps {
324
+ palette: SidebarPalette;
325
+ snapshot: AftStatusSnapshot | null;
326
+ prefs: AftTuiPrefs;
327
+ collapsed: boolean;
328
+ onToggleCollapsed: () => void;
329
+ pluginVersion: string;
330
+ }
331
+
332
+ /**
333
+ * The preference-backed state the panel needs: which sections are enabled, the
334
+ * header label, and whether the panel is collapsed. Both hosts read the same
335
+ * shared `tui-preferences.jsonc`, so this lives here rather than being wired
336
+ * up twice.
337
+ *
338
+ * `onChange` lets a host that does not repaint on its own ask for a frame
339
+ * after preferences or the collapsed flag change.
340
+ */
341
+ export function createSidebarPreferences(onChange?: () => void): {
342
+ prefs: () => AftTuiPrefs;
343
+ collapsed: () => boolean;
344
+ toggleCollapsed: () => void;
345
+ } {
346
+ const [prefs, setPrefs] = createSignal<AftTuiPrefs>(structuredClone(DEFAULT_PREFS));
347
+ const [collapsed, setCollapsed] = createSignal(seedCollapsedFromPrefs(DEFAULT_PREFS));
348
+
349
+ const reload = async (): Promise<void> => {
350
+ const root = await readTuiPreferencesFile();
351
+ const next = resolveAftPrefs(root);
352
+ setPrefs(next);
353
+ setCollapsed(seedCollapsedFromPrefs(next));
354
+ onChange?.();
355
+ };
356
+
357
+ void reload();
358
+ const unwatch = watchTuiPreferences(() => {
359
+ void reload();
360
+ });
361
+ onCleanup(unwatch);
362
+
363
+ const toggleCollapsed = (): void => {
364
+ setCollapsed((current) => {
365
+ const next = !current;
366
+ persistCollapsedIfEnabled(prefs(), next);
367
+ return next;
368
+ });
369
+ onChange?.();
370
+ };
371
+
372
+ return { prefs, collapsed, toggleCollapsed };
373
+ }
374
+
375
+ /**
376
+ * The whole sidebar body. Both hosts render this component; neither adds rows
377
+ * of its own, so a section added or removed here shows up in both at once.
378
+ */
379
+ export const AftSidebarPanel = (props: AftSidebarPanelProps) => {
380
+ const s = () => props.snapshot;
381
+
382
+ // Lazy-bridge: while AFT has no live bridge yet, the RPC server returns a
383
+ // synthetic snapshot with `cache_role === "not_initialized"`. In that state
384
+ // every metric is unknown by design — not "disabled" — so we hide the
385
+ // version line and the entire Search Index / Semantic Index / Compression
386
+ // grid until a first tool call warms the bridge. Users were reading the
387
+ // pre-init `vunknown` + `Status: unknown` rows as broken state instead of
388
+ // "AFT has not been used yet for this project".
389
+ const notInitialized = () => s()?.cache_role === "not_initialized";
390
+
391
+ // Pre-compute display values so the JSX stays readable. createMemo for
392
+ // each derived field would be overkill — these are cheap derivations.
393
+ const searchStatus = () => statusDisplay(s()?.search_index?.status ?? "disabled");
394
+ const semanticStatus = () => {
395
+ const rawStatus = s()?.semantic_index?.status ?? "disabled";
396
+ const display = statusDisplay(rawStatus);
397
+ return {
398
+ ...display,
399
+ // The error is passed through so a capability failure (a missing ONNX
400
+ // Runtime) is named here instead of arriving as a bare status word.
401
+ label: formatSemanticIndexStatus(
402
+ rawStatus,
403
+ s()?.semantic_index?.stage,
404
+ s()?.semantic_index?.error,
405
+ ),
406
+ };
407
+ };
408
+ const semanticRefreshing = () =>
409
+ formatSemanticRefreshing(s()?.semantic_index?.refreshing_count ?? 0);
410
+ const trigramBytes = () => s()?.disk?.trigram_disk_bytes ?? 0;
411
+ const semanticBytes = () => s()?.disk?.semantic_disk_bytes ?? 0;
412
+ const compressionRows = () => formatCompressionSidebarRows(s()?.compression);
413
+ const statusBar = () => s()?.status_bar;
414
+
415
+ const degradedSummary = () => {
416
+ const snap = s();
417
+ if (!snap?.degraded) return null;
418
+ const reasons = snap.degraded_reasons ?? [];
419
+ if (reasons.length === 0) return null;
420
+ return reasons.map(degradedReasonLabel).join("; ");
421
+ };
422
+
423
+ // Worktree borrow is a shared-index arrangement, not a degraded_reasons
424
+ // entry. Keep this muted and separate from the DEGRADED badge above.
425
+ const worktreeNote = () => worktreeCacheRoleNote(s()?.cache_role);
426
+
427
+ return (
428
+ <box
429
+ width="100%"
430
+ flexDirection="column"
431
+ border={SINGLE_BORDER}
432
+ borderColor={props.palette.border}
433
+ paddingTop={1}
434
+ paddingBottom={1}
435
+ paddingLeft={1}
436
+ paddingRight={1}
437
+ >
438
+ {/* Header: triangle toggle + AFT badge + binary version + degraded badge.
439
+ Clicking the header row collapses/expands the panel (mirrors OpenCode's
440
+ native MCP sidebar section). Only interactive once initialized — the
441
+ lazy-bridge placeholder has nothing to collapse. */}
442
+ <box
443
+ flexDirection="row"
444
+ justifyContent="space-between"
445
+ alignItems="center"
446
+ onMouseDown={() => {
447
+ if (notInitialized()) return;
448
+ props.onToggleCollapsed();
449
+ }}
450
+ >
451
+ <box flexDirection="row" alignItems="center">
452
+ {/* Triangle lives inside the accent badge so the toggle reads as one
453
+ unit: "▶ AFT" / "▼ AFT". Hidden pre-init (nothing to collapse). */}
454
+ <box paddingLeft={1} paddingRight={1} backgroundColor={props.palette.accent}>
455
+ <text fg={badgeForeground(props.palette.accent, props.palette.background)}>
456
+ <b>
457
+ {notInitialized() ? "" : props.collapsed ? "▶ " : "▼ "}
458
+ {props.prefs.header.label}
459
+ </b>
460
+ </text>
461
+ </box>
462
+ {s()?.degraded && (
463
+ <box
464
+ paddingLeft={1}
465
+ paddingRight={1}
466
+ marginLeft={1}
467
+ backgroundColor={props.palette.warning}
468
+ >
469
+ <text fg={badgeForeground(props.palette.warning, props.palette.background)}>
470
+ <b>DEGRADED</b>
471
+ </text>
472
+ </box>
473
+ )}
474
+ </box>
475
+ {!notInitialized() && props.prefs.header.showVersion && (
476
+ <text fg={props.palette.textMuted}>v{s()?.version ?? props.pluginVersion}</text>
477
+ )}
478
+ </box>
479
+
480
+ {/* Degraded reason — explains why heavy tools (aft_search, aft_callgraph)
481
+ are disabled. Surface this prominently so users know to open a real
482
+ project subdirectory if they want full features. */}
483
+ {s()?.degraded && degradedSummary() && (
484
+ <box marginTop={1} width="100%">
485
+ <text fg={props.palette.warning}>⚠ {degradedSummary()}</text>
486
+ </box>
487
+ )}
488
+
489
+ {!notInitialized() && worktreeNote() && (
490
+ <box marginTop={1} width="100%">
491
+ <text fg={props.palette.textMuted}>{worktreeNote()}</text>
492
+ </box>
493
+ )}
494
+
495
+ {/* Lazy-bridge placeholder. AFT skips spawning the `aft` binary at
496
+ plugin init to keep memory/CPU low on OpenCode Desktop sessions
497
+ that have many projects pinned in the sidebar. The RPC server
498
+ returns a synthetic `cache_role === "not_initialized"` snapshot
499
+ until the first tool call routes through `callBridge()` and warms
500
+ the bridge. Show the explanatory message instead of empty status
501
+ rows so users understand why metrics are blank. */}
502
+ {notInitialized() && (
503
+ <box marginTop={1} width="100%">
504
+ <text fg={props.palette.textMuted}>
505
+ {s()!.message ||
506
+ "AFT bridge is now spawned lazily, information here will be populated after first tool call."}
507
+ </text>
508
+ </box>
509
+ )}
510
+
511
+ {/* Collapsed view — condensed status dots + compact compression. Shown
512
+ only when initialized AND collapsed. Three rows mirroring the section
513
+ order of the expanded grid. */}
514
+ {!notInitialized() && props.collapsed && (
515
+ <box width="100%" flexDirection="column">
516
+ {props.prefs.sections.searchIndex && (
517
+ <CollapsedRow palette={props.palette} label="Search Index">
518
+ <text fg={toneColor(props.palette, searchStatus().tone)}>●</text>
519
+ </CollapsedRow>
520
+ )}
521
+ {props.prefs.sections.semanticIndex && (
522
+ <CollapsedRow palette={props.palette} label="Semantic Index">
523
+ <text fg={toneColor(props.palette, semanticStatus().tone)}>●</text>
524
+ </CollapsedRow>
525
+ )}
526
+ {props.prefs.sections.codeHealth && collapsedHealthLights(statusBar()) && (
527
+ <CollapsedRow palette={props.palette} label="Code Health">
528
+ <box flexDirection="row" gap={1}>
529
+ <text
530
+ fg={toneColor(props.palette, collapsedHealthLights(statusBar())!.diagnostics)}
531
+ >
532
+ ●
533
+ </text>
534
+ <text fg={toneColor(props.palette, collapsedHealthLights(statusBar())!.code)}>
535
+ ●
536
+ </text>
537
+ <text fg={toneColor(props.palette, collapsedHealthLights(statusBar())!.todos)}>
538
+ ●
539
+ </text>
540
+ </box>
541
+ </CollapsedRow>
542
+ )}
543
+ {props.prefs.sections.compression && collapsedCompressionValue(s()?.compression) && (
544
+ <CollapsedRow palette={props.palette} label="Compression">
545
+ <text fg={props.palette.textMuted}>
546
+ <b>{collapsedCompressionValue(s()?.compression)}</b>
547
+ </text>
548
+ </CollapsedRow>
549
+ )}
550
+ </box>
551
+ )}
552
+
553
+ {/* Search index */}
554
+ {!notInitialized() && !props.collapsed && (
555
+ <>
556
+ {props.prefs.sections.searchIndex && (
557
+ <>
558
+ <SectionHeader palette={props.palette} title="Search Index" />
559
+ <StatRow
560
+ palette={props.palette}
561
+ label="Status"
562
+ value={searchStatus().label}
563
+ tone={searchStatus().tone}
564
+ />
565
+ {(s()?.search_index?.files ?? null) != null && (
566
+ <StatRow
567
+ palette={props.palette}
568
+ label="Files"
569
+ value={formatCount(s()!.search_index.files)}
570
+ tone="muted"
571
+ />
572
+ )}
573
+ <StatRow
574
+ palette={props.palette}
575
+ label="Disk"
576
+ value={formatBytes(trigramBytes())}
577
+ tone="muted"
578
+ />
579
+ </>
580
+ )}
581
+
582
+ {props.prefs.sections.semanticIndex && (
583
+ <>
584
+ <SectionHeader palette={props.palette} title="Semantic Index" />
585
+ <StatRow
586
+ palette={props.palette}
587
+ label="Status"
588
+ value={semanticStatus().label}
589
+ tone={semanticStatus().tone}
590
+ />
591
+ {semanticRefreshing() && (
592
+ <box width="100%">
593
+ <text fg={props.palette.textMuted}>{semanticRefreshing()}</text>
594
+ </box>
595
+ )}
596
+ {/* When loading, magic-context-style progress hint helps users see
597
+ background work is making progress instead of stuck. */}
598
+ {s()?.semantic_index?.status === "loading" &&
599
+ s()?.semantic_index?.entries_total != null &&
600
+ s()!.semantic_index.entries_total! > 0 && (
601
+ <StatRow
602
+ palette={props.palette}
603
+ label="Progress"
604
+ value={`${formatCount(s()!.semantic_index.entries_done)} / ${formatCount(
605
+ s()!.semantic_index.entries_total,
606
+ )}`}
607
+ tone="warn"
608
+ />
609
+ )}
610
+ {(s()?.semantic_index?.entries ?? null) != null && (
611
+ <StatRow
612
+ palette={props.palette}
613
+ label="Entries"
614
+ value={formatCount(s()!.semantic_index.entries)}
615
+ tone="muted"
616
+ />
617
+ )}
618
+ <StatRow
619
+ palette={props.palette}
620
+ label="Disk"
621
+ value={formatBytes(semanticBytes())}
622
+ tone="muted"
623
+ />
624
+ </>
625
+ )}
626
+
627
+ {/* Human health values are optional. A category stays absent until the
628
+ server proves it, rather than appearing as a clean zero. */}
629
+ {props.prefs.sections.codeHealth && statusBar() && (
630
+ <>
631
+ <SectionHeader
632
+ palette={props.palette}
633
+ title={statusBar()!.tier2_stale ? "Code Health ~" : "Code Health"}
634
+ />
635
+ {statusBar()!.errors !== undefined && (
636
+ <StatRow
637
+ palette={props.palette}
638
+ label="Errors"
639
+ value={formatCount(statusBar()!.errors)}
640
+ tone={statusBar()!.errors! > 0 ? "err" : "muted"}
641
+ />
642
+ )}
643
+ {statusBar()!.warnings !== undefined && (
644
+ <StatRow
645
+ palette={props.palette}
646
+ label="Warnings"
647
+ value={formatCount(statusBar()!.warnings)}
648
+ tone={statusBar()!.warnings! > 0 ? "warn" : "muted"}
649
+ />
650
+ )}
651
+ {statusBar()!.dead_code !== undefined && (
652
+ <StatRow
653
+ palette={props.palette}
654
+ label="Dead Code"
655
+ value={formatCount(statusBar()!.dead_code)}
656
+ tone="muted"
657
+ />
658
+ )}
659
+ {statusBar()!.unused_exports !== undefined && (
660
+ <StatRow
661
+ palette={props.palette}
662
+ label="Unused Exports"
663
+ value={formatCount(statusBar()!.unused_exports)}
664
+ tone="muted"
665
+ />
666
+ )}
667
+ {statusBar()!.duplicates !== undefined && (
668
+ <StatRow
669
+ palette={props.palette}
670
+ label="Duplicates"
671
+ value={formatCount(statusBar()!.duplicates)}
672
+ tone="muted"
673
+ />
674
+ )}
675
+ {statusBar()!.todos !== undefined && (
676
+ <StatRow
677
+ palette={props.palette}
678
+ label="TODOs"
679
+ value={formatCount(statusBar()!.todos)}
680
+ tone="muted"
681
+ />
682
+ )}
683
+ </>
684
+ )}
685
+
686
+ {/* Compression aggregates. Tabular layout matching Search/Semantic
687
+ Index above: each scope ("Session", "Project") renders as a
688
+ subheader followed by two StatRows (Tokens Saved, Compression
689
+ Ratio). Keeps numbers right-aligned in the value column instead
690
+ of jamming them after the label on the same line. */}
691
+ {props.prefs.sections.compression && compressionRows().length > 0 && (
692
+ <>
693
+ <SectionHeader palette={props.palette} title="Compression" />
694
+ {compressionRows().map((row) =>
695
+ row.kind === "scope" ? (
696
+ <box width="100%">
697
+ <text fg={props.palette.text}>{row.label}</text>
698
+ </box>
699
+ ) : (
700
+ <StatRow
701
+ palette={props.palette}
702
+ label={row.label}
703
+ value={row.value}
704
+ tone="muted"
705
+ />
706
+ ),
707
+ )}
708
+ </>
709
+ )}
710
+
711
+ {/* Surface failures clearly so users know to act (install ONNX,
712
+ fix config, etc.) rather than silently leaving the panel "off". */}
713
+ {s()?.semantic_index?.status === "failed" && s()?.semantic_index?.error && (
714
+ <box marginTop={1} width="100%">
715
+ <text fg={props.palette.error}>⚠ {s()!.semantic_index.error}</text>
716
+ </box>
717
+ )}
718
+ </>
719
+ )}
720
+ </box>
721
+ );
722
+ };