@cortexkit/aft-opencode 0.17.3 → 0.18.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/aft-opencode",
3
- "version": "0.17.3",
3
+ "version": "0.18.1",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for Agent File Tools (AFT) — tree-sitter and lsp powered code analysis",
6
6
  "main": "dist/index.js",
@@ -32,11 +32,11 @@
32
32
  "zod": "^4.1.8"
33
33
  },
34
34
  "optionalDependencies": {
35
- "@cortexkit/aft-darwin-arm64": "0.17.3",
36
- "@cortexkit/aft-darwin-x64": "0.17.3",
37
- "@cortexkit/aft-linux-arm64": "0.17.3",
38
- "@cortexkit/aft-linux-x64": "0.17.3",
39
- "@cortexkit/aft-win32-x64": "0.17.3"
35
+ "@cortexkit/aft-darwin-arm64": "0.18.1",
36
+ "@cortexkit/aft-darwin-x64": "0.18.1",
37
+ "@cortexkit/aft-linux-arm64": "0.18.1",
38
+ "@cortexkit/aft-linux-x64": "0.18.1",
39
+ "@cortexkit/aft-win32-x64": "0.18.1"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/node": "^22.0.0",
@@ -5,8 +5,8 @@ export interface AftStatusSnapshot {
5
5
  format_on_edit: boolean;
6
6
  validate_on_edit: string;
7
7
  restrict_to_project_root: boolean;
8
- experimental_search_index: boolean;
9
- experimental_semantic_search: boolean;
8
+ search_index: boolean;
9
+ semantic_search: boolean;
10
10
  };
11
11
  search_index: {
12
12
  status: string;
@@ -112,8 +112,10 @@ export function coerceAftStatus(response: Record<string, unknown>): AftStatusSna
112
112
  format_on_edit: readBoolean(features.format_on_edit),
113
113
  validate_on_edit: readString(features.validate_on_edit, "off"),
114
114
  restrict_to_project_root: readBoolean(features.restrict_to_project_root),
115
- experimental_search_index: readBoolean(features.experimental_search_index),
116
- experimental_semantic_search: readBoolean(features.experimental_semantic_search),
115
+ search_index: readBoolean(features.search_index ?? features.experimental_search_index),
116
+ semantic_search: readBoolean(
117
+ features.semantic_search ?? features.experimental_semantic_search,
118
+ ),
117
119
  },
118
120
  search_index: {
119
121
  status: readString(searchIndex.status, "unknown"),
@@ -159,8 +161,8 @@ export function formatStatusDialogMessage(status: AftStatusSnapshot): string {
159
161
  "",
160
162
  "Enabled features",
161
163
  `- format_on_edit: ${formatFlag(status.features.format_on_edit)}`,
162
- `- experimental_search_index: ${formatFlag(status.features.experimental_search_index)}`,
163
- `- experimental_semantic_search: ${formatFlag(status.features.experimental_semantic_search)}`,
164
+ `- search_index: ${formatFlag(status.features.search_index)}`,
165
+ `- semantic_search: ${formatFlag(status.features.semantic_search)}`,
164
166
  "",
165
167
  "Search index",
166
168
  `- status: ${status.search_index.status}`,
@@ -233,8 +235,8 @@ export function formatStatusMarkdown(status: AftStatusSnapshot): string {
233
235
  "",
234
236
  "### Enabled features",
235
237
  `- \`format_on_edit\`: ${formatFlag(status.features.format_on_edit)}`,
236
- `- \`experimental_search_index\`: ${formatFlag(status.features.experimental_search_index)}`,
237
- `- \`experimental_semantic_search\`: ${formatFlag(status.features.experimental_semantic_search)}`,
238
+ `- \`search_index\`: ${formatFlag(status.features.search_index)}`,
239
+ `- \`semantic_search\`: ${formatFlag(status.features.semantic_search)}`,
238
240
  "",
239
241
  "### Search index",
240
242
  `- **Status:** \`${status.search_index.status}\``,
package/src/tui/index.tsx CHANGED
@@ -2,8 +2,10 @@
2
2
  // @ts-nocheck
3
3
 
4
4
  import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui";
5
+ import packageJson from "../../package.json";
5
6
  import { AftRpcClient } from "../shared/rpc-client";
6
7
  import { coerceAftStatus, formatStatusDialogMessage } from "../shared/status";
8
+ import { createAftSidebarSlot } from "./sidebar";
7
9
 
8
10
  // The TUI talks to the server plugin via AftRpcClient. The client reads the
9
11
  // JSON port file written by AftRpcServer ({ port, token }) and includes that
@@ -190,6 +192,17 @@ async function showStartupNotifications(api: TuiPluginApi): Promise<void> {
190
192
  }
191
193
 
192
194
  const tui: TuiPlugin = async (api) => {
195
+ // Sidebar slot: live status of search index, semantic index, and disk
196
+ // usage. See ./sidebar.tsx for the panel itself. Registered before the
197
+ // command palette entry so the sidebar is available immediately when the
198
+ // user opens their first session.
199
+ try {
200
+ api.slots.register(createAftSidebarSlot(api, packageJson.version));
201
+ } catch {
202
+ // Older OpenCode TUI hosts may not implement api.slots; fall through
203
+ // and keep the slash command working.
204
+ }
205
+
193
206
  api.command.register(() => [
194
207
  {
195
208
  title: "AFT: Status",
@@ -0,0 +1,331 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ // @ts-nocheck
3
+
4
+ // AFT sidebar slot — mirrors opencode-magic-context's sidebar pattern.
5
+ // Header with "AFT" badge + version, then live status of search and semantic
6
+ // indexes plus their on-disk size. Refreshes on session change and on
7
+ // session.updated/message.updated events with a small debounce, same as
8
+ // magic-context, so the panel stays current without polling.
9
+
10
+ import type { TuiPluginApi, TuiSlotPlugin, TuiThemeCurrent } from "@opencode-ai/plugin/tui";
11
+ import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js";
12
+
13
+ import { AftRpcClient } from "../shared/rpc-client";
14
+ import { type AftStatusSnapshot, coerceAftStatus } from "../shared/status";
15
+
16
+ const SINGLE_BORDER = { type: "single" } as any;
17
+ const REFRESH_DEBOUNCE_MS = 200;
18
+ // The sidebar polls the bridge as a backstop because not every state change
19
+ // (e.g. semantic index transitioning from "loading" → "ready" mid-session)
20
+ // emits a session/message event. 1.5s matches the /aft-status dialog cadence.
21
+ const POLL_INTERVAL_MS = 1500;
22
+
23
+ function formatBytes(n: number): string {
24
+ if (!Number.isFinite(n) || n <= 0) return "—";
25
+ if (n >= 1_073_741_824) return `${(n / 1_073_741_824).toFixed(1)} GB`;
26
+ if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB`;
27
+ if (n >= 1_024) return `${Math.round(n / 1_024)} KB`;
28
+ return `${n} B`;
29
+ }
30
+
31
+ function formatCount(n: number | null | undefined): string {
32
+ if (n == null || !Number.isFinite(n)) return "—";
33
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
34
+ if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
35
+ return String(n);
36
+ }
37
+
38
+ // Map index status → (label, theme color name). The label is what we want
39
+ // the user to see; the color encodes severity so the eye lands on warnings.
40
+ function statusDisplay(status: string): { label: string; tone: "ok" | "warn" | "err" | "muted" } {
41
+ switch (status) {
42
+ case "ready":
43
+ return { label: "ready", tone: "ok" };
44
+ case "loading":
45
+ case "building":
46
+ return { label: status, tone: "warn" };
47
+ case "failed":
48
+ case "error":
49
+ return { label: status, tone: "err" };
50
+ case "disabled":
51
+ return { label: "disabled", tone: "muted" };
52
+ default:
53
+ return { label: status || "unknown", tone: "muted" };
54
+ }
55
+ }
56
+
57
+ const StatRow = (props: {
58
+ theme: TuiThemeCurrent;
59
+ label: string;
60
+ value: string;
61
+ tone?: "ok" | "warn" | "err" | "muted" | "accent";
62
+ }) => {
63
+ const fg = createMemo(() => {
64
+ switch (props.tone) {
65
+ case "ok":
66
+ return props.theme.success ?? props.theme.accent;
67
+ case "warn":
68
+ return props.theme.warning;
69
+ case "err":
70
+ return props.theme.error;
71
+ case "muted":
72
+ return props.theme.textMuted;
73
+ case "accent":
74
+ return props.theme.accent;
75
+ default:
76
+ return props.theme.text;
77
+ }
78
+ });
79
+
80
+ return (
81
+ <box width="100%" flexDirection="row" justifyContent="space-between">
82
+ <text fg={props.theme.textMuted}>{props.label}</text>
83
+ <text fg={fg()}>
84
+ <b>{props.value}</b>
85
+ </text>
86
+ </box>
87
+ );
88
+ };
89
+
90
+ const SectionHeader = (props: { theme: TuiThemeCurrent; title: string; marginTop?: number }) => (
91
+ <box width="100%" marginTop={props.marginTop ?? 1}>
92
+ <text fg={props.theme.text}>
93
+ <b>{props.title}</b>
94
+ </text>
95
+ </box>
96
+ );
97
+
98
+ // One RPC client per project directory — same pattern as the /aft-status
99
+ // dialog handler in tui/index.tsx. Sharing the map avoids opening a second
100
+ // connection just for the sidebar.
101
+ const sidebarClients = new Map<string, AftRpcClient>();
102
+ function getClient(directory: string): AftRpcClient {
103
+ let client = sidebarClients.get(directory);
104
+ if (client) return client;
105
+ const home = process.env.HOME || process.env.USERPROFILE || "";
106
+ const dataHome = process.env.XDG_DATA_HOME || `${home}/.local/share`;
107
+ const storageDir = `${dataHome}/opencode/storage/plugin/aft`;
108
+ client = new AftRpcClient(storageDir, directory);
109
+ sidebarClients.set(directory, client);
110
+ return client;
111
+ }
112
+
113
+ const SidebarContent = (props: {
114
+ api: TuiPluginApi;
115
+ sessionID: () => string;
116
+ theme: TuiThemeCurrent;
117
+ pluginVersion: string;
118
+ }) => {
119
+ const [status, setStatus] = createSignal<AftStatusSnapshot | null>(null);
120
+ // Once a request is in flight, suppress any overlapping refresh so we
121
+ // don't open a thundering herd of RPCs on rapid event bursts.
122
+ let inflight = false;
123
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
124
+ let pollTimer: ReturnType<typeof setInterval> | undefined;
125
+
126
+ const refresh = async () => {
127
+ const sid = props.sessionID();
128
+ if (!sid) return;
129
+ if (inflight) return;
130
+ const directory = props.api.state.path.directory ?? "";
131
+ if (!directory) return;
132
+
133
+ inflight = true;
134
+ try {
135
+ const client = getClient(directory);
136
+ const response = await client.call("status", { sessionID: sid });
137
+ if (response && (response as Record<string, unknown>).success !== false) {
138
+ const snapshot = coerceAftStatus(response as Record<string, unknown>);
139
+ setStatus(snapshot);
140
+ try {
141
+ props.api.renderer.requestRender();
142
+ } catch {
143
+ // renderer may not be available during teardown; safe to ignore
144
+ }
145
+ }
146
+ } catch {
147
+ // RPC server may not be ready yet, or the bridge may be respawning
148
+ // after a binary swap — leave the previous snapshot visible rather
149
+ // than blanking the sidebar.
150
+ } finally {
151
+ inflight = false;
152
+ }
153
+ };
154
+
155
+ const scheduleRefresh = () => {
156
+ if (debounceTimer) clearTimeout(debounceTimer);
157
+ debounceTimer = setTimeout(() => {
158
+ debounceTimer = undefined;
159
+ void refresh();
160
+ }, REFRESH_DEBOUNCE_MS);
161
+ };
162
+
163
+ onCleanup(() => {
164
+ if (debounceTimer) clearTimeout(debounceTimer);
165
+ if (pollTimer) clearInterval(pollTimer);
166
+ });
167
+
168
+ // Refresh on session id change + initial load
169
+ createEffect(
170
+ on(props.sessionID, () => {
171
+ void refresh();
172
+ }),
173
+ );
174
+
175
+ // Wire live updates: session/message events are cheap signals that
176
+ // *something* AFT-relevant probably changed (formatted edit, lsp activity,
177
+ // index pre-warm completion). The status RPC is debounced so we don't
178
+ // recompute disk usage on every keystroke.
179
+ createEffect(
180
+ on(
181
+ props.sessionID,
182
+ (sessionID) => {
183
+ if (!sessionID) return;
184
+ const unsubs = [
185
+ props.api.event.on("message.updated", (event) => {
186
+ if (event.properties?.info?.sessionID !== sessionID) return;
187
+ scheduleRefresh();
188
+ }),
189
+ props.api.event.on("session.updated", (event) => {
190
+ if (event.properties?.info?.id !== sessionID) return;
191
+ scheduleRefresh();
192
+ }),
193
+ ];
194
+ // Background poller for state that doesn't emit session events
195
+ // (semantic index `loading` → `ready`, disk size growth during
196
+ // a background indexer rebuild). Self-cancelling on cleanup.
197
+ if (!pollTimer) {
198
+ pollTimer = setInterval(() => {
199
+ scheduleRefresh();
200
+ }, POLL_INTERVAL_MS);
201
+ }
202
+ onCleanup(() => {
203
+ for (const unsub of unsubs) {
204
+ try {
205
+ unsub();
206
+ } catch {
207
+ // best effort
208
+ }
209
+ }
210
+ if (pollTimer) {
211
+ clearInterval(pollTimer);
212
+ pollTimer = undefined;
213
+ }
214
+ });
215
+ },
216
+ { defer: false },
217
+ ),
218
+ );
219
+
220
+ const s = createMemo(() => status());
221
+
222
+ // Pre-compute display values so the JSX stays readable. createMemo for
223
+ // each derived field would be overkill — these are cheap derivations.
224
+ const searchStatus = () => statusDisplay(s()?.search_index?.status ?? "disabled");
225
+ const semanticStatus = () => statusDisplay(s()?.semantic_index?.status ?? "disabled");
226
+ const trigramBytes = () => s()?.disk?.trigram_disk_bytes ?? 0;
227
+ const semanticBytes = () => s()?.disk?.semantic_disk_bytes ?? 0;
228
+
229
+ return (
230
+ <box
231
+ width="100%"
232
+ flexDirection="column"
233
+ border={SINGLE_BORDER}
234
+ borderColor={props.theme.borderActive}
235
+ paddingTop={1}
236
+ paddingBottom={1}
237
+ paddingLeft={1}
238
+ paddingRight={1}
239
+ >
240
+ {/* Header: AFT badge + binary version */}
241
+ <box flexDirection="row" justifyContent="space-between" alignItems="center">
242
+ <box paddingLeft={1} paddingRight={1} backgroundColor={props.theme.accent}>
243
+ <text fg={props.theme.background}>
244
+ <b>AFT</b>
245
+ </text>
246
+ </box>
247
+ <text fg={props.theme.textMuted}>v{s()?.version ?? props.pluginVersion}</text>
248
+ </box>
249
+
250
+ {/* Search index */}
251
+ <SectionHeader theme={props.theme} title="Search Index" />
252
+ <StatRow
253
+ theme={props.theme}
254
+ label="Status"
255
+ value={searchStatus().label}
256
+ tone={searchStatus().tone}
257
+ />
258
+ {(s()?.search_index?.files ?? null) != null && (
259
+ <StatRow
260
+ theme={props.theme}
261
+ label="Files"
262
+ value={formatCount(s()!.search_index.files)}
263
+ tone="muted"
264
+ />
265
+ )}
266
+ <StatRow theme={props.theme} label="Disk" value={formatBytes(trigramBytes())} tone="muted" />
267
+
268
+ {/* Semantic index */}
269
+ <SectionHeader theme={props.theme} title="Semantic Index" />
270
+ <StatRow
271
+ theme={props.theme}
272
+ label="Status"
273
+ value={semanticStatus().label}
274
+ tone={semanticStatus().tone}
275
+ />
276
+ {/* When loading, magic-context-style progress hint helps users see
277
+ background work is making progress instead of stuck. */}
278
+ {s()?.semantic_index?.status === "loading" &&
279
+ s()?.semantic_index?.entries_total != null &&
280
+ s()!.semantic_index.entries_total! > 0 && (
281
+ <StatRow
282
+ theme={props.theme}
283
+ label="Progress"
284
+ value={`${formatCount(s()!.semantic_index.entries_done)} / ${formatCount(
285
+ s()!.semantic_index.entries_total,
286
+ )}`}
287
+ tone="warn"
288
+ />
289
+ )}
290
+ {(s()?.semantic_index?.entries ?? null) != null && (
291
+ <StatRow
292
+ theme={props.theme}
293
+ label="Entries"
294
+ value={formatCount(s()!.semantic_index.entries)}
295
+ tone="muted"
296
+ />
297
+ )}
298
+ <StatRow theme={props.theme} label="Disk" value={formatBytes(semanticBytes())} tone="muted" />
299
+
300
+ {/* Surface failures clearly so users know to act (install ONNX,
301
+ fix config, etc.) rather than silently leaving the panel "off". */}
302
+ {s()?.semantic_index?.status === "failed" && s()?.semantic_index?.error && (
303
+ <box marginTop={1} width="100%">
304
+ <text fg={props.theme.error}>⚠ {s()!.semantic_index.error}</text>
305
+ </box>
306
+ )}
307
+ </box>
308
+ );
309
+ };
310
+
311
+ export function createAftSidebarSlot(api: TuiPluginApi, pluginVersion: string): TuiSlotPlugin {
312
+ return {
313
+ // 150 matches magic-context's order — chosen so AFT renders below
314
+ // higher-priority panels but above default plugin slots. If both
315
+ // plugins are loaded together, magic-context will appear first.
316
+ order: 160,
317
+ slots: {
318
+ sidebar_content: (ctx, value) => {
319
+ const theme = createMemo(() => (ctx as any).theme.current);
320
+ return (
321
+ <SidebarContent
322
+ api={api}
323
+ sessionID={() => value.session_id}
324
+ theme={theme()}
325
+ pluginVersion={pluginVersion}
326
+ />
327
+ );
328
+ },
329
+ },
330
+ };
331
+ }