@cortexkit/aft-opencode 0.22.1 → 0.24.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/src/tui/index.tsx CHANGED
@@ -1,10 +1,11 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  // @ts-nocheck
3
3
 
4
- import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui";
4
+ import type { TuiPlugin, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui";
5
+ import { createMemo, createSignal, onCleanup } from "solid-js";
5
6
  import packageJson from "../../package.json";
6
7
  import { AftRpcClient } from "../shared/rpc-client";
7
- import { coerceAftStatus, formatStatusDialogMessage } from "../shared/status";
8
+ import { type AftStatusSnapshot, coerceAftStatus, formatBytes } from "../shared/status";
8
9
  import { createAftSidebarSlot } from "./sidebar";
9
10
 
10
11
  // The TUI talks to the server plugin via AftRpcClient. The client reads the
@@ -42,6 +43,332 @@ function getSessionId(api: TuiPluginApi): string | null {
42
43
  return null;
43
44
  }
44
45
 
46
+ // ---------------------------------------------------------------------------
47
+ // StatusDialog — themed, two-column JSX dialog. Modeled on the magic-context
48
+ // /ctx-status pattern (packages/plugin/src/tui/index.tsx in that repo):
49
+ // custom JSX rendered via `api.ui.dialog.replace(() => <StatusDialog .../>)`
50
+ // instead of feeding a padded monospace string into DialogAlert. The
51
+ // difference matters because OpenCode renders DialogAlert text in a
52
+ // proportional font with no column alignment; only TUI flex primitives
53
+ // (<box flexDirection="row" flexBasis={0}>) actually produce visible
54
+ // columns. This component owns its own RPC polling so it can re-render
55
+ // reactively as the status snapshot changes, with no parent re-mount.
56
+ // ---------------------------------------------------------------------------
57
+
58
+ const POLL_INTERVAL_MS = 1500;
59
+
60
+ function formatCountShort(value: number | null | undefined): string {
61
+ if (value == null || !Number.isFinite(value)) return "—";
62
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
63
+ if (value >= 1_000) return `${Math.round(value / 1_000)}K`;
64
+ return String(value);
65
+ }
66
+
67
+ function statusTone(status: string): "ok" | "warn" | "err" | "muted" {
68
+ switch (status) {
69
+ case "ready":
70
+ return "ok";
71
+ case "loading":
72
+ case "building":
73
+ return "warn";
74
+ case "failed":
75
+ case "error":
76
+ return "err";
77
+ default:
78
+ return "muted";
79
+ }
80
+ }
81
+
82
+ function pickToneColor(theme: TuiThemeCurrent, tone: "ok" | "warn" | "err" | "muted"): string {
83
+ switch (tone) {
84
+ case "ok":
85
+ return (theme as any).success ?? theme.accent;
86
+ case "warn":
87
+ return theme.warning;
88
+ case "err":
89
+ return theme.error;
90
+ case "muted":
91
+ return theme.textMuted;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Label/value row. Label is left-aligned and muted; value is right-aligned
97
+ * and themed. flexDirection="row" + justifyContent="space-between" replaces
98
+ * the monospace `padEnd(40)` hack from the previous string formatter.
99
+ */
100
+ const R = (props: {
101
+ theme: TuiThemeCurrent;
102
+ label: string;
103
+ value: string;
104
+ tone?: "ok" | "warn" | "err" | "muted" | "accent";
105
+ }) => {
106
+ const fg = createMemo(() => {
107
+ if (!props.tone) return props.theme.text;
108
+ if (props.tone === "accent") return props.theme.accent;
109
+ return pickToneColor(props.theme, props.tone);
110
+ });
111
+
112
+ return (
113
+ <box flexDirection="row" width="100%" justifyContent="space-between">
114
+ <text fg={props.theme.textMuted}>{props.label}</text>
115
+ <text fg={fg()}>{props.value}</text>
116
+ </box>
117
+ );
118
+ };
119
+
120
+ interface StatusDialogProps {
121
+ api: TuiPluginApi;
122
+ client: AftRpcClient;
123
+ sessionID: string;
124
+ initial: AftStatusSnapshot | null;
125
+ initialError: string | null;
126
+ onClose: () => void;
127
+ }
128
+
129
+ const StatusDialog = (props: StatusDialogProps) => {
130
+ const theme = createMemo(() => (props.api as any).theme.current as TuiThemeCurrent);
131
+ const t = () => theme();
132
+
133
+ // Reactive status signal — the dialog re-renders on every status
134
+ // transition without remounting. The RPC polling is local to the dialog
135
+ // and stops when it unmounts.
136
+ const [status, setStatus] = createSignal<AftStatusSnapshot | null>(props.initial);
137
+ const [error, setError] = createSignal<string | null>(props.initialError);
138
+
139
+ const timer = setInterval(async () => {
140
+ try {
141
+ const response = await props.client.call("status", { sessionID: props.sessionID });
142
+ if ((response as Record<string, unknown>).success !== false) {
143
+ setStatus(coerceAftStatus(response as Record<string, unknown>));
144
+ setError(null);
145
+ }
146
+ } catch {
147
+ // transient — keep showing last good snapshot
148
+ }
149
+ }, POLL_INTERVAL_MS);
150
+ onCleanup(() => clearInterval(timer));
151
+
152
+ // Visual cache-role badge: main is accent, worktree is warning,
153
+ // not_initialized is muted. Matches the sidebar convention.
154
+ const cacheRoleTone = (role: string): "accent" | "warn" | "muted" =>
155
+ role === "main" ? "accent" : role === "worktree" ? "warn" : "muted";
156
+
157
+ return (
158
+ <box
159
+ flexDirection="column"
160
+ width="100%"
161
+ paddingLeft={2}
162
+ paddingRight={2}
163
+ paddingTop={1}
164
+ paddingBottom={1}
165
+ >
166
+ {/* Title */}
167
+ <box justifyContent="center" width="100%" marginBottom={1} flexDirection="row" gap={2}>
168
+ <text fg={t().accent}>
169
+ <b>⚡ AFT Status</b>
170
+ </text>
171
+ <text fg={t().textMuted}>v{status()?.version ?? packageJson.version}</text>
172
+ </box>
173
+
174
+ {/* Error / not-yet-ready state */}
175
+ {error() ? (
176
+ <box width="100%" marginBottom={1}>
177
+ <text fg={t().warning}>{error()}</text>
178
+ </box>
179
+ ) : null}
180
+
181
+ {/* Header rows — paths span full width since they can be long */}
182
+ {status() ? (
183
+ <box flexDirection="column" width="100%" marginBottom={1}>
184
+ <R
185
+ theme={t()}
186
+ label="Project root"
187
+ value={status()!.project_root ?? "(not configured)"}
188
+ />
189
+ <R
190
+ theme={t()}
191
+ label="Canonical root"
192
+ value={status()!.canonical_root ?? "(not configured)"}
193
+ />
194
+ <R
195
+ theme={t()}
196
+ label="Cache role"
197
+ value={status()!.cache_role}
198
+ tone={cacheRoleTone(status()!.cache_role)}
199
+ />
200
+ </box>
201
+ ) : null}
202
+
203
+ {/* 2-column body */}
204
+ {status() ? (
205
+ <box flexDirection="row" width="100%" gap={4}>
206
+ {/* Left column */}
207
+ <box flexDirection="column" flexGrow={1} flexBasis={0}>
208
+ <text fg={t().text}>
209
+ <b>Search index</b>
210
+ </text>
211
+ <R
212
+ theme={t()}
213
+ label="Status"
214
+ value={status()!.search_index.status}
215
+ tone={statusTone(status()!.search_index.status)}
216
+ />
217
+ <R theme={t()} label="Files" value={formatCountShort(status()!.search_index.files)} />
218
+ <R
219
+ theme={t()}
220
+ label="Trigrams"
221
+ value={formatCountShort(status()!.search_index.trigrams)}
222
+ />
223
+ <R
224
+ theme={t()}
225
+ label="Disk"
226
+ value={formatBytes(status()!.disk.trigram_disk_bytes)}
227
+ tone="muted"
228
+ />
229
+
230
+ <box marginTop={1}>
231
+ <text fg={t().text}>
232
+ <b>Runtime</b>
233
+ </text>
234
+ </box>
235
+ <R theme={t()} label="LSP servers" value={String(status()!.lsp_servers)} />
236
+ <R
237
+ theme={t()}
238
+ label="Symbol cache (local)"
239
+ value={formatCountShort(status()!.symbol_cache.local_entries)}
240
+ />
241
+ <R
242
+ theme={t()}
243
+ label="Symbol cache (warm)"
244
+ value={formatCountShort(status()!.symbol_cache.warm_entries)}
245
+ tone="muted"
246
+ />
247
+
248
+ <box marginTop={1}>
249
+ <text fg={t().text}>
250
+ <b>Features</b>
251
+ </text>
252
+ </box>
253
+ <R
254
+ theme={t()}
255
+ label="format_on_edit"
256
+ value={status()!.features.format_on_edit ? "on" : "off"}
257
+ tone={status()!.features.format_on_edit ? "ok" : "muted"}
258
+ />
259
+ <R
260
+ theme={t()}
261
+ label="search_index"
262
+ value={status()!.features.search_index ? "on" : "off"}
263
+ tone={status()!.features.search_index ? "ok" : "muted"}
264
+ />
265
+ <R
266
+ theme={t()}
267
+ label="semantic_search"
268
+ value={status()!.features.semantic_search ? "on" : "off"}
269
+ tone={status()!.features.semantic_search ? "ok" : "muted"}
270
+ />
271
+ </box>
272
+
273
+ {/* Right column */}
274
+ <box flexDirection="column" flexGrow={1} flexBasis={0}>
275
+ <text fg={t().text}>
276
+ <b>Semantic index</b>
277
+ </text>
278
+ <R
279
+ theme={t()}
280
+ label="Status"
281
+ value={status()!.semantic_index.status}
282
+ tone={statusTone(status()!.semantic_index.status)}
283
+ />
284
+ <R
285
+ theme={t()}
286
+ label="Entries"
287
+ value={formatCountShort(status()!.semantic_index.entries)}
288
+ />
289
+ {status()!.semantic_index.backend ? (
290
+ <R
291
+ theme={t()}
292
+ label="Backend"
293
+ value={status()!.semantic_index.backend!}
294
+ tone="muted"
295
+ />
296
+ ) : null}
297
+ {status()!.semantic_index.model ? (
298
+ <R theme={t()} label="Model" value={status()!.semantic_index.model!} tone="muted" />
299
+ ) : null}
300
+ {status()!.semantic_index.dimension != null ? (
301
+ <R
302
+ theme={t()}
303
+ label="Dimension"
304
+ value={String(status()!.semantic_index.dimension)}
305
+ tone="muted"
306
+ />
307
+ ) : null}
308
+ <R
309
+ theme={t()}
310
+ label="Disk"
311
+ value={formatBytes(status()!.disk.semantic_disk_bytes)}
312
+ tone="muted"
313
+ />
314
+
315
+ <box marginTop={1}>
316
+ <text fg={t().text}>
317
+ <b>Current session</b>
318
+ </text>
319
+ </box>
320
+ <R theme={t()} label="Tracked files" value={String(status()!.session.tracked_files)} />
321
+ <R theme={t()} label="Checkpoints" value={String(status()!.session.checkpoints)} />
322
+ <R
323
+ theme={t()}
324
+ label="All-session checkpoints"
325
+ value={String(status()!.checkpoints_total)}
326
+ tone="muted"
327
+ />
328
+ </box>
329
+ </box>
330
+ ) : null}
331
+
332
+ {/* Optional semantic build progress — full-width below the columns */}
333
+ {status()?.semantic_index.stage ? (
334
+ <box flexDirection="column" width="100%" marginTop={1}>
335
+ <text fg={t().text}>
336
+ <b>Semantic build progress</b>
337
+ </text>
338
+ <R theme={t()} label="Stage" value={status()!.semantic_index.stage!} />
339
+ {status()!.semantic_index.files != null ? (
340
+ <R
341
+ theme={t()}
342
+ label="Files seen"
343
+ value={formatCountShort(status()!.semantic_index.files)}
344
+ />
345
+ ) : null}
346
+ {status()!.semantic_index.entries_done != null ||
347
+ status()!.semantic_index.entries_total != null ? (
348
+ <R
349
+ theme={t()}
350
+ label="Progress"
351
+ value={`${formatCountShort(status()!.semantic_index.entries_done ?? null)} / ${formatCountShort(status()!.semantic_index.entries_total ?? null)}`}
352
+ />
353
+ ) : null}
354
+ </box>
355
+ ) : null}
356
+
357
+ {/* Semantic error — full-width, themed error color */}
358
+ {status()?.semantic_index.error ? (
359
+ <box marginTop={1} width="100%">
360
+ <text fg={t().error}>⚠ {status()!.semantic_index.error}</text>
361
+ </box>
362
+ ) : null}
363
+
364
+ {/* Footer */}
365
+ <box marginTop={1} justifyContent="flex-end" width="100%">
366
+ <text fg={t().textMuted}>Enter or Esc to close</text>
367
+ </box>
368
+ </box>
369
+ );
370
+ };
371
+
45
372
  async function showStatusDialog(api: TuiPluginApi): Promise<void> {
46
373
  const sessionID = getSessionId(api);
47
374
  if (!sessionID) {
@@ -57,82 +384,36 @@ async function showStatusDialog(api: TuiPluginApi): Promise<void> {
57
384
 
58
385
  const client = getRpcClient(directory);
59
386
 
60
- // Fetch status immediately and show the dialog
61
- let currentMessage = "Connecting to AFT...";
387
+ // Prime the dialog with one initial fetch so we don't show a blank
388
+ // skeleton the component then takes over polling.
389
+ let initial: AftStatusSnapshot | null = null;
390
+ let initialError: string | null = null;
62
391
  try {
63
392
  const response = await client.call("status", { sessionID });
64
393
  if ((response as Record<string, unknown>).success !== false) {
65
- const status = coerceAftStatus(response as Record<string, unknown>);
66
- currentMessage = formatStatusDialogMessage(status);
394
+ initial = coerceAftStatus(response as Record<string, unknown>);
395
+ } else {
396
+ initialError = "AFT bridge returned an error response.";
67
397
  }
68
398
  } catch {
69
- currentMessage = "AFT is starting up. Status will refresh automatically...";
399
+ initialError = "AFT is starting up. Status will refresh automatically...";
70
400
  }
71
401
 
72
- // Track whether dialog is still open for polling cleanup
73
- let dialogOpen = true;
74
- let pollTimer: ReturnType<typeof setInterval> | null = null;
75
-
76
- // Show dialog with initial data
77
402
  api.ui.dialog.setSize("large");
78
403
  api.ui.dialog.replace(
404
+ () => (
405
+ <StatusDialog
406
+ api={api}
407
+ client={client}
408
+ sessionID={sessionID}
409
+ initial={initial}
410
+ initialError={initialError}
411
+ onClose={() => {
412
+ api.ui.dialog.setSize("medium");
413
+ }}
414
+ />
415
+ ),
79
416
  () => {
80
- // Start polling after dialog renders
81
- if (!pollTimer) {
82
- pollTimer = setInterval(async () => {
83
- if (!dialogOpen) {
84
- if (pollTimer) clearInterval(pollTimer);
85
- return;
86
- }
87
- try {
88
- const response = await client.call("status", { sessionID });
89
- if ((response as Record<string, unknown>).success !== false) {
90
- const status = coerceAftStatus(response as Record<string, unknown>);
91
- const newMessage = formatStatusDialogMessage(status);
92
- if (newMessage !== currentMessage) {
93
- currentMessage = newMessage;
94
- // Re-render dialog with updated status
95
- api.ui.dialog.replace(
96
- () => (
97
- <api.ui.DialogAlert
98
- title="AFT Status"
99
- message={currentMessage}
100
- onConfirm={() => {
101
- dialogOpen = false;
102
- if (pollTimer) clearInterval(pollTimer);
103
- api.ui.dialog.setSize("medium");
104
- }}
105
- />
106
- ),
107
- () => {
108
- dialogOpen = false;
109
- if (pollTimer) clearInterval(pollTimer);
110
- api.ui.dialog.setSize("medium");
111
- },
112
- );
113
- }
114
- }
115
- } catch {
116
- // Polling failure is non-fatal — just skip this tick
117
- }
118
- }, 1500);
119
- }
120
-
121
- return (
122
- <api.ui.DialogAlert
123
- title="AFT Status"
124
- message={currentMessage}
125
- onConfirm={() => {
126
- dialogOpen = false;
127
- if (pollTimer) clearInterval(pollTimer);
128
- api.ui.dialog.setSize("medium");
129
- }}
130
- />
131
- );
132
- },
133
- () => {
134
- dialogOpen = false;
135
- if (pollTimer) clearInterval(pollTimer);
136
417
  api.ui.dialog.setSize("medium");
137
418
  },
138
419
  );