@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/dist/bg-notifications.d.ts +0 -1
- package/dist/bg-notifications.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +85 -21
- package/dist/shared/status.d.ts +9 -0
- package/dist/shared/status.d.ts.map +1 -1
- package/dist/tui.js +385 -119
- package/dist/workflow-hints.d.ts.map +1 -1
- package/package.json +7 -7
- package/src/shared/status.ts +15 -1
- package/src/tui/index.tsx +349 -68
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,
|
|
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
|
-
//
|
|
61
|
-
|
|
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
|
-
|
|
66
|
-
|
|
394
|
+
initial = coerceAftStatus(response as Record<string, unknown>);
|
|
395
|
+
} else {
|
|
396
|
+
initialError = "AFT bridge returned an error response.";
|
|
67
397
|
}
|
|
68
398
|
} catch {
|
|
69
|
-
|
|
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
|
);
|