@cortexkit/aft-opencode 0.45.1 → 0.47.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/bash-wait-detach.d.ts +5 -0
- package/dist/bash-wait-detach.d.ts.map +1 -0
- package/dist/config.d.ts +3 -11
- package/dist/config.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2304 -1133
- package/dist/logger.d.ts.map +1 -1
- package/dist/lsp-auto-install.d.ts +8 -3
- package/dist/lsp-auto-install.d.ts.map +1 -1
- package/dist/lsp-github-install.d.ts +2 -0
- package/dist/lsp-github-install.d.ts.map +1 -1
- package/dist/shared/ignored-message.d.ts +9 -7
- package/dist/shared/ignored-message.d.ts.map +1 -1
- package/dist/shared/rpc-client.d.ts +23 -1
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/subc-tool-schemas.d.ts.map +1 -1
- package/dist/tools/_shared.d.ts.map +1 -1
- package/dist/tools/hoisted.d.ts.map +1 -1
- package/dist/tools/permissions.d.ts +8 -0
- package/dist/tools/permissions.d.ts.map +1 -1
- package/dist/tools/semantic.d.ts.map +1 -1
- package/dist/tui/notification-socket.d.ts.map +1 -1
- package/package.json +17 -14
- package/src/logger.ts +5 -6
- package/src/shared/ignored-message.ts +38 -19
- package/src/shared/rpc-client.ts +116 -4
- package/src/tui/entry.mjs +16 -0
- package/src/tui/notification-socket.ts +28 -1
- package/src/tui-compiled/badge-contrast.ts +43 -0
- package/src/tui-compiled/index.tsx +992 -0
- package/src/tui-compiled/notification-socket.ts +448 -0
- package/src/tui-compiled/preferences.ts +243 -0
- package/src/tui-compiled/sidebar.tsx +936 -0
- package/src/tui-compiled/types/opencode-plugin-tui.d.ts +239 -0
- package/dist/tui.js +0 -13462
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { AftRpcClient, type AftRpcEndpoint, subscribeRpcRedirects } from "../shared/rpc-client";
|
|
2
|
+
import { resolveCortexKitStorageRoot } from "../shared/storage-paths";
|
|
3
|
+
|
|
4
|
+
export interface SocketNotification {
|
|
5
|
+
id: number;
|
|
6
|
+
type: string;
|
|
7
|
+
payload: Record<string, unknown>;
|
|
8
|
+
sessionId?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface StatusInvalidation {
|
|
12
|
+
sessionId?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TuiSocketOptions {
|
|
16
|
+
getDirectory: () => string | null | undefined;
|
|
17
|
+
getSessionId: () => string | null;
|
|
18
|
+
onNotification: (notification: SocketNotification) => boolean | Promise<boolean>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface WebSocketLike {
|
|
22
|
+
readyState: number;
|
|
23
|
+
addEventListener(
|
|
24
|
+
type: "open" | "message" | "close" | "error",
|
|
25
|
+
listener: (event: unknown) => void,
|
|
26
|
+
): void;
|
|
27
|
+
send(data: string): void;
|
|
28
|
+
close(): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type WebSocketFactory = new (url: string) => WebSocketLike;
|
|
32
|
+
type RpcClientLike = Pick<AftRpcClient, "resolveEndpoint" | "reset">;
|
|
33
|
+
|
|
34
|
+
interface SocketDeps {
|
|
35
|
+
createClient: (directory: string) => RpcClientLike;
|
|
36
|
+
WebSocketCtor: WebSocketFactory | null;
|
|
37
|
+
setTimeout: typeof setTimeout;
|
|
38
|
+
clearTimeout: typeof clearTimeout;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface SocketScope {
|
|
42
|
+
directory: string;
|
|
43
|
+
sessionId: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const RECONNECT_BASE_MS = 500;
|
|
47
|
+
const RECONNECT_MAX_MS = 10_000;
|
|
48
|
+
const WEB_SOCKET_OPEN = 1;
|
|
49
|
+
const clients = new Map<string, AftRpcClient>();
|
|
50
|
+
const statusInvalidationListeners = new Set<(event: StatusInvalidation) => void>();
|
|
51
|
+
const lastHandledIdBySession = new Map<string, number>();
|
|
52
|
+
|
|
53
|
+
function defaultWebSocketCtor(): WebSocketFactory | null {
|
|
54
|
+
const ctor = (globalThis as typeof globalThis & { WebSocket?: WebSocketFactory }).WebSocket;
|
|
55
|
+
return ctor ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function defaultClient(directory: string): AftRpcClient {
|
|
59
|
+
let client = clients.get(directory);
|
|
60
|
+
if (!client) {
|
|
61
|
+
client = new AftRpcClient(resolveCortexKitStorageRoot(), directory);
|
|
62
|
+
clients.set(directory, client);
|
|
63
|
+
}
|
|
64
|
+
return client;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let deps: SocketDeps = {
|
|
68
|
+
createClient: defaultClient,
|
|
69
|
+
WebSocketCtor: defaultWebSocketCtor(),
|
|
70
|
+
setTimeout,
|
|
71
|
+
clearTimeout,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
let opts: TuiSocketOptions | null = null;
|
|
75
|
+
let socket: WebSocketLike | null = null;
|
|
76
|
+
let socketScope: SocketScope | null = null;
|
|
77
|
+
let helloedScope: SocketScope | null = null;
|
|
78
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
79
|
+
let reconnectAttempt = 0;
|
|
80
|
+
let closed = true;
|
|
81
|
+
let generation = 0;
|
|
82
|
+
let connectingGeneration: number | null = null;
|
|
83
|
+
let connectingScope: SocketScope | null = null;
|
|
84
|
+
|
|
85
|
+
export function startAftTuiSocket(options: TuiSocketOptions): void {
|
|
86
|
+
opts = options;
|
|
87
|
+
closed = false;
|
|
88
|
+
generation += 1;
|
|
89
|
+
ensureRedirectSubscription();
|
|
90
|
+
void reconcileSocketScope();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Re-home the socket when a status call learns a verified-directory redirect
|
|
95
|
+
* (see rpc-client). The socket may already be connected to this directory's
|
|
96
|
+
* bridgeless instance — which authenticates and accepts hello but will never
|
|
97
|
+
* push a status-changed frame, because the session's bridge lives in another
|
|
98
|
+
* process. Drop it and reconnect; resolveEndpoint now follows the redirect.
|
|
99
|
+
*/
|
|
100
|
+
let redirectUnsubscribe: (() => void) | null = null;
|
|
101
|
+
function ensureRedirectSubscription(): void {
|
|
102
|
+
if (redirectUnsubscribe) return;
|
|
103
|
+
redirectUnsubscribe = subscribeRpcRedirects((from) => {
|
|
104
|
+
if (closed) return;
|
|
105
|
+
const scope = currentScope();
|
|
106
|
+
if (!scope || scope.directory !== from) return;
|
|
107
|
+
generation += 1;
|
|
108
|
+
connectingGeneration = null;
|
|
109
|
+
connectingScope = null;
|
|
110
|
+
closeCurrentSocket(false);
|
|
111
|
+
void connect(scope, generation);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function stopAftTuiSocket(): void {
|
|
116
|
+
closed = true;
|
|
117
|
+
generation += 1;
|
|
118
|
+
connectingGeneration = null;
|
|
119
|
+
connectingScope = null;
|
|
120
|
+
if (redirectUnsubscribe) {
|
|
121
|
+
redirectUnsubscribe();
|
|
122
|
+
redirectUnsubscribe = null;
|
|
123
|
+
}
|
|
124
|
+
if (reconnectTimer) {
|
|
125
|
+
deps.clearTimeout(reconnectTimer);
|
|
126
|
+
reconnectTimer = undefined;
|
|
127
|
+
}
|
|
128
|
+
closeCurrentSocket(false);
|
|
129
|
+
for (const client of clients.values()) client.reset();
|
|
130
|
+
clients.clear();
|
|
131
|
+
reconnectAttempt = 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function refreshAftTuiSocketScope(): void {
|
|
135
|
+
if (closed) return;
|
|
136
|
+
void reconcileSocketScope();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function subscribeStatusInvalidations(
|
|
140
|
+
listener: (event: StatusInvalidation) => void,
|
|
141
|
+
): () => void {
|
|
142
|
+
statusInvalidationListeners.add(listener);
|
|
143
|
+
return () => {
|
|
144
|
+
statusInvalidationListeners.delete(listener);
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function createDebouncedStatusRefresh(
|
|
149
|
+
refresh: () => void | Promise<void>,
|
|
150
|
+
delayMs: number,
|
|
151
|
+
): { schedule: () => void; dispose: () => void } {
|
|
152
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
153
|
+
let disposed = false;
|
|
154
|
+
return {
|
|
155
|
+
schedule() {
|
|
156
|
+
if (disposed) return;
|
|
157
|
+
if (timer) deps.clearTimeout(timer);
|
|
158
|
+
timer = deps.setTimeout(() => {
|
|
159
|
+
timer = undefined;
|
|
160
|
+
void refresh();
|
|
161
|
+
}, delayMs);
|
|
162
|
+
},
|
|
163
|
+
dispose() {
|
|
164
|
+
disposed = true;
|
|
165
|
+
if (timer) {
|
|
166
|
+
deps.clearTimeout(timer);
|
|
167
|
+
timer = undefined;
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function currentScope(): SocketScope | null {
|
|
174
|
+
const directory = opts?.getDirectory() ?? "";
|
|
175
|
+
const sessionId = opts?.getSessionId() ?? "";
|
|
176
|
+
if (!directory || !sessionId) return null;
|
|
177
|
+
return { directory, sessionId };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function sameScope(a: SocketScope | null, b: SocketScope | null): boolean {
|
|
181
|
+
return a?.directory === b?.directory && a?.sessionId === b?.sessionId;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function reconcileSocketScope(): Promise<void> {
|
|
185
|
+
if (closed || !opts) return;
|
|
186
|
+
const scope = currentScope();
|
|
187
|
+
if (!scope) {
|
|
188
|
+
generation += 1;
|
|
189
|
+
connectingGeneration = null;
|
|
190
|
+
connectingScope = null;
|
|
191
|
+
closeCurrentSocket(false);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (socket && socketScope?.directory === scope.directory) {
|
|
196
|
+
if (!sameScope(socketScope, scope) && socket.readyState !== WEB_SOCKET_OPEN) {
|
|
197
|
+
generation += 1;
|
|
198
|
+
closeCurrentSocket(false);
|
|
199
|
+
await connect(scope, generation);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (socket.readyState === WEB_SOCKET_OPEN && !sameScope(helloedScope, scope)) {
|
|
203
|
+
await sendHelloWithFreshToken(socket, scope, generation);
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (sameScope(connectingScope, scope)) return;
|
|
209
|
+
|
|
210
|
+
generation += 1;
|
|
211
|
+
connectingGeneration = null;
|
|
212
|
+
connectingScope = null;
|
|
213
|
+
closeCurrentSocket(false);
|
|
214
|
+
await connect(scope, generation);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function connect(scope: SocketScope, connectGeneration: number): Promise<void> {
|
|
218
|
+
if (closed || socket || connectingGeneration !== null) return;
|
|
219
|
+
const WebSocketCtor = deps.WebSocketCtor ?? defaultWebSocketCtor();
|
|
220
|
+
if (!WebSocketCtor) {
|
|
221
|
+
scheduleReconnect();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
connectingGeneration = connectGeneration;
|
|
226
|
+
connectingScope = scope;
|
|
227
|
+
let endpoint: AftRpcEndpoint | null = null;
|
|
228
|
+
try {
|
|
229
|
+
endpoint = await deps.createClient(scope.directory).resolveEndpoint();
|
|
230
|
+
} catch {
|
|
231
|
+
endpoint = null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (connectingGeneration === connectGeneration) {
|
|
235
|
+
connectingGeneration = null;
|
|
236
|
+
connectingScope = null;
|
|
237
|
+
}
|
|
238
|
+
if (closed || connectGeneration !== generation || !sameScope(currentScope(), scope)) return;
|
|
239
|
+
if (!endpoint) {
|
|
240
|
+
scheduleReconnect();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let ws: WebSocketLike;
|
|
245
|
+
try {
|
|
246
|
+
ws = new WebSocketCtor(`ws://127.0.0.1:${endpoint.port}/ws`);
|
|
247
|
+
} catch {
|
|
248
|
+
scheduleReconnect();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
socket = ws;
|
|
253
|
+
socketScope = scope;
|
|
254
|
+
helloedScope = null;
|
|
255
|
+
|
|
256
|
+
ws.addEventListener("open", () => {
|
|
257
|
+
if (socket !== ws || connectGeneration !== generation) return;
|
|
258
|
+
reconnectAttempt = 0;
|
|
259
|
+
sendHello(ws, scope, endpoint.token);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
ws.addEventListener("message", (event) => {
|
|
263
|
+
if (socket !== ws || connectGeneration !== generation) return;
|
|
264
|
+
const data =
|
|
265
|
+
typeof (event as { data?: unknown }).data === "string"
|
|
266
|
+
? (event as { data: string }).data
|
|
267
|
+
: String((event as { data?: unknown }).data ?? "");
|
|
268
|
+
void handleSocketMessage(ws, data, connectGeneration);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const onDown = () => {
|
|
272
|
+
if (socket !== ws) return;
|
|
273
|
+
socket = null;
|
|
274
|
+
socketScope = null;
|
|
275
|
+
helloedScope = null;
|
|
276
|
+
generation += 1;
|
|
277
|
+
scheduleReconnect();
|
|
278
|
+
};
|
|
279
|
+
ws.addEventListener("close", onDown);
|
|
280
|
+
ws.addEventListener("error", () => {
|
|
281
|
+
try {
|
|
282
|
+
ws.close();
|
|
283
|
+
} catch {
|
|
284
|
+
// best-effort
|
|
285
|
+
}
|
|
286
|
+
onDown();
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function closeCurrentSocket(schedule: boolean): void {
|
|
291
|
+
const ws = socket;
|
|
292
|
+
socket = null;
|
|
293
|
+
socketScope = null;
|
|
294
|
+
helloedScope = null;
|
|
295
|
+
if (ws) {
|
|
296
|
+
try {
|
|
297
|
+
ws.close();
|
|
298
|
+
} catch {
|
|
299
|
+
// best-effort
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (schedule) scheduleReconnect();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function scheduleReconnect(): void {
|
|
306
|
+
if (closed || reconnectTimer) return;
|
|
307
|
+
const scope = currentScope();
|
|
308
|
+
if (!scope) return;
|
|
309
|
+
const delay = Math.min(RECONNECT_BASE_MS * 2 ** reconnectAttempt, RECONNECT_MAX_MS);
|
|
310
|
+
reconnectAttempt += 1;
|
|
311
|
+
reconnectTimer = deps.setTimeout(() => {
|
|
312
|
+
reconnectTimer = undefined;
|
|
313
|
+
const nextScope = currentScope();
|
|
314
|
+
if (!nextScope) return;
|
|
315
|
+
void connect(nextScope, generation);
|
|
316
|
+
}, delay);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function sendHelloWithFreshToken(
|
|
320
|
+
ws: WebSocketLike,
|
|
321
|
+
scope: SocketScope,
|
|
322
|
+
expectedGeneration: number,
|
|
323
|
+
): Promise<void> {
|
|
324
|
+
let endpoint: AftRpcEndpoint | null = null;
|
|
325
|
+
try {
|
|
326
|
+
endpoint = await deps.createClient(scope.directory).resolveEndpoint();
|
|
327
|
+
} catch {
|
|
328
|
+
endpoint = null;
|
|
329
|
+
}
|
|
330
|
+
if (
|
|
331
|
+
closed ||
|
|
332
|
+
socket !== ws ||
|
|
333
|
+
expectedGeneration !== generation ||
|
|
334
|
+
!sameScope(currentScope(), scope)
|
|
335
|
+
) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
sendHello(ws, scope, endpoint?.token ?? null);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function sendHello(ws: WebSocketLike, scope: SocketScope, token: string | null): void {
|
|
342
|
+
helloedScope = scope;
|
|
343
|
+
if (socket === ws) socketScope = scope;
|
|
344
|
+
const lastReceivedId = lastHandledIdBySession.get(scope.sessionId) ?? 0;
|
|
345
|
+
ws.send(
|
|
346
|
+
JSON.stringify({
|
|
347
|
+
type: "hello",
|
|
348
|
+
token: token ?? "",
|
|
349
|
+
sessionId: scope.sessionId,
|
|
350
|
+
lastReceivedId,
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function handleSocketMessage(
|
|
356
|
+
ws: WebSocketLike,
|
|
357
|
+
raw: string,
|
|
358
|
+
messageGeneration: number,
|
|
359
|
+
): Promise<void> {
|
|
360
|
+
let msg: {
|
|
361
|
+
type?: string;
|
|
362
|
+
notification?: SocketNotification;
|
|
363
|
+
sessionId?: string;
|
|
364
|
+
error?: string;
|
|
365
|
+
};
|
|
366
|
+
try {
|
|
367
|
+
msg = JSON.parse(raw);
|
|
368
|
+
} catch {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (msg.type === "status-changed") {
|
|
373
|
+
for (const listener of statusInvalidationListeners) {
|
|
374
|
+
try {
|
|
375
|
+
listener({ sessionId: msg.sessionId });
|
|
376
|
+
} catch {
|
|
377
|
+
// One sidebar/dialog listener must not block the others.
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (msg.type === "notification" && msg.notification) {
|
|
384
|
+
const notification = msg.notification;
|
|
385
|
+
const active = opts?.getSessionId() ?? null;
|
|
386
|
+
if (!active) return;
|
|
387
|
+
if (notification.sessionId && notification.sessionId !== active) return;
|
|
388
|
+
|
|
389
|
+
let consumed = false;
|
|
390
|
+
try {
|
|
391
|
+
consumed = await Promise.resolve(opts?.onNotification(notification) ?? false);
|
|
392
|
+
} catch {
|
|
393
|
+
consumed = false;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (
|
|
397
|
+
socket !== ws ||
|
|
398
|
+
messageGeneration !== generation ||
|
|
399
|
+
(opts?.getSessionId() ?? null) !== active
|
|
400
|
+
) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (consumed && notification.id > (lastHandledIdBySession.get(active) ?? 0)) {
|
|
404
|
+
lastHandledIdBySession.set(active, notification.id);
|
|
405
|
+
try {
|
|
406
|
+
ws.send(JSON.stringify({ type: "ack", lastReceivedId: notification.id }));
|
|
407
|
+
} catch {
|
|
408
|
+
// best-effort; reconnect hello replays the cursor.
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (msg.type === "error") {
|
|
415
|
+
try {
|
|
416
|
+
ws.close();
|
|
417
|
+
} catch {
|
|
418
|
+
// best-effort
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function __setAftTuiSocketDepsForTest(overrides: Partial<SocketDeps>): () => void {
|
|
424
|
+
const previous = deps;
|
|
425
|
+
deps = { ...deps, ...overrides };
|
|
426
|
+
return () => {
|
|
427
|
+
deps = previous;
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export function __resetAftTuiSocketForTest(): void {
|
|
432
|
+
stopAftTuiSocket();
|
|
433
|
+
opts = null;
|
|
434
|
+
statusInvalidationListeners.clear();
|
|
435
|
+
lastHandledIdBySession.clear();
|
|
436
|
+
clients.clear();
|
|
437
|
+
reconnectAttempt = 0;
|
|
438
|
+
generation = 0;
|
|
439
|
+
connectingGeneration = null;
|
|
440
|
+
connectingScope = null;
|
|
441
|
+
closed = true;
|
|
442
|
+
deps = {
|
|
443
|
+
createClient: defaultClient,
|
|
444
|
+
WebSocketCtor: defaultWebSocketCtor(),
|
|
445
|
+
setTimeout,
|
|
446
|
+
clearTimeout,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { watch } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { parse, stringify } from "comment-json";
|
|
6
|
+
|
|
7
|
+
export const TUI_PREFS_FILE_ENV = "OPENCODE_TUI_PREFERENCES_FILE";
|
|
8
|
+
const FILE_NAME = "tui-preferences.jsonc";
|
|
9
|
+
|
|
10
|
+
export function getTuiPreferencesFile(): string {
|
|
11
|
+
const override = process.env[TUI_PREFS_FILE_ENV];
|
|
12
|
+
if (override) return override;
|
|
13
|
+
const configDir =
|
|
14
|
+
process.env.OPENCODE_CONFIG_DIR ||
|
|
15
|
+
join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode");
|
|
16
|
+
return join(configDir, FILE_NAME);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
20
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function readTuiPreferencesFile(): Promise<Record<string, unknown>> {
|
|
24
|
+
try {
|
|
25
|
+
const raw = await readFile(getTuiPreferencesFile(), "utf8");
|
|
26
|
+
const root: unknown = parse(raw);
|
|
27
|
+
return isRecord(root) ? (root as Record<string, unknown>) : {};
|
|
28
|
+
} catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const PLUGIN_KEY = "aft";
|
|
34
|
+
export const DEFAULT_SLOT_ORDER = 180;
|
|
35
|
+
|
|
36
|
+
export interface AftTuiPrefs {
|
|
37
|
+
forceToTop: boolean;
|
|
38
|
+
order: number;
|
|
39
|
+
startCollapsed: boolean;
|
|
40
|
+
rememberCollapsed: boolean;
|
|
41
|
+
collapsed: boolean | null;
|
|
42
|
+
header: {
|
|
43
|
+
label: string;
|
|
44
|
+
showVersion: boolean;
|
|
45
|
+
};
|
|
46
|
+
sections: {
|
|
47
|
+
searchIndex: boolean;
|
|
48
|
+
semanticIndex: boolean;
|
|
49
|
+
codeHealth: boolean;
|
|
50
|
+
compression: boolean;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const DEFAULT_PREFS: AftTuiPrefs = {
|
|
55
|
+
forceToTop: false,
|
|
56
|
+
order: DEFAULT_SLOT_ORDER,
|
|
57
|
+
startCollapsed: false,
|
|
58
|
+
rememberCollapsed: true,
|
|
59
|
+
collapsed: null,
|
|
60
|
+
header: { label: "AFT", showVersion: true },
|
|
61
|
+
sections: {
|
|
62
|
+
searchIndex: true,
|
|
63
|
+
semanticIndex: true,
|
|
64
|
+
codeHealth: true,
|
|
65
|
+
compression: true,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function bool(value: unknown, fallback: boolean): boolean {
|
|
70
|
+
return typeof value === "boolean" ? value : fallback;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function int(value: unknown, fallback: number, min: number, max: number): number {
|
|
74
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
75
|
+
return Math.min(Math.max(Math.round(value), min), max);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function label(value: unknown, fallback: string, maxLength: number): string {
|
|
79
|
+
if (typeof value !== "string" || value.length === 0) return fallback;
|
|
80
|
+
return value.slice(0, maxLength);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Initial collapsed state from persisted prefs or startCollapsed default. */
|
|
84
|
+
export function seedCollapsedFromPrefs(prefs: AftTuiPrefs): boolean {
|
|
85
|
+
return prefs.collapsed ?? prefs.startCollapsed;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function resolveAftPrefs(root: Record<string, unknown>): AftTuiPrefs {
|
|
89
|
+
const entry = root[PLUGIN_KEY];
|
|
90
|
+
if (!isRecord(entry)) return structuredClone(DEFAULT_PREFS);
|
|
91
|
+
|
|
92
|
+
const d = DEFAULT_PREFS;
|
|
93
|
+
const header = isRecord(entry.header) ? entry.header : {};
|
|
94
|
+
const sections = isRecord(entry.sections) ? entry.sections : {};
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
forceToTop: bool(entry.forceToTop, d.forceToTop),
|
|
98
|
+
order: int(entry.order, d.order, -10000, 10000),
|
|
99
|
+
startCollapsed: bool(entry.startCollapsed, d.startCollapsed),
|
|
100
|
+
rememberCollapsed: bool(entry.rememberCollapsed, d.rememberCollapsed),
|
|
101
|
+
collapsed: typeof entry.collapsed === "boolean" ? entry.collapsed : null,
|
|
102
|
+
header: {
|
|
103
|
+
label: label(header.label, d.header.label, 20),
|
|
104
|
+
showVersion: bool(header.showVersion, d.header.showVersion),
|
|
105
|
+
},
|
|
106
|
+
sections: {
|
|
107
|
+
searchIndex: bool(sections.searchIndex, d.sections.searchIndex),
|
|
108
|
+
semanticIndex: bool(sections.semanticIndex, d.sections.semanticIndex),
|
|
109
|
+
codeHealth: bool(sections.codeHealth, d.sections.codeHealth),
|
|
110
|
+
compression: bool(sections.compression, d.sections.compression),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const FORCE_TOP_BASE = -100000;
|
|
116
|
+
|
|
117
|
+
export function computeEffectiveOrder(
|
|
118
|
+
root: Record<string, unknown>,
|
|
119
|
+
pluginKey: string,
|
|
120
|
+
defaultOrder: number,
|
|
121
|
+
): number {
|
|
122
|
+
const entry = root[pluginKey];
|
|
123
|
+
if (!isRecord(entry)) return defaultOrder;
|
|
124
|
+
if (entry.forceToTop === true) {
|
|
125
|
+
return FORCE_TOP_BASE + Object.keys(root).indexOf(pluginKey);
|
|
126
|
+
}
|
|
127
|
+
return int(entry.order, defaultOrder, -10000, 10000);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const TEMPLATE = `// Shared preferences for opencode TUI plugins.
|
|
131
|
+
// One top-level key per plugin (short name). See each plugin's README for
|
|
132
|
+
// its supported settings. This file is safe to hand-edit; plugins update
|
|
133
|
+
// individual keys in place and preserve comments.
|
|
134
|
+
{}
|
|
135
|
+
`;
|
|
136
|
+
|
|
137
|
+
type JsonValue = string | number | boolean | null;
|
|
138
|
+
|
|
139
|
+
function setNested(target: Record<string, unknown>, path: string[], value: JsonValue): void {
|
|
140
|
+
let current: Record<string, unknown> = target;
|
|
141
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
142
|
+
const key = path[i];
|
|
143
|
+
if (key === undefined) return;
|
|
144
|
+
const next = current[key];
|
|
145
|
+
if (!isRecord(next)) {
|
|
146
|
+
const created: Record<string, unknown> = {};
|
|
147
|
+
current[key] = created;
|
|
148
|
+
current = created;
|
|
149
|
+
} else {
|
|
150
|
+
current = next;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const leaf = path[path.length - 1];
|
|
154
|
+
if (leaf === undefined) return;
|
|
155
|
+
current[leaf] = value;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function writePreference(pluginKey: string, path: string[], value: JsonValue): Promise<void> {
|
|
159
|
+
const file = getTuiPreferencesFile();
|
|
160
|
+
await mkdir(dirname(file), { recursive: true });
|
|
161
|
+
let text: string;
|
|
162
|
+
try {
|
|
163
|
+
text = await readFile(file, "utf8");
|
|
164
|
+
} catch {
|
|
165
|
+
text = "";
|
|
166
|
+
}
|
|
167
|
+
if (text.trim() === "") text = TEMPLATE;
|
|
168
|
+
|
|
169
|
+
let root: Record<string, unknown>;
|
|
170
|
+
try {
|
|
171
|
+
const parsed = parse(text);
|
|
172
|
+
root = isRecord(parsed) ? (parsed as Record<string, unknown>) : {};
|
|
173
|
+
} catch {
|
|
174
|
+
root = {};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!isRecord(root[pluginKey])) {
|
|
178
|
+
root[pluginKey] = {};
|
|
179
|
+
}
|
|
180
|
+
const entry = root[pluginKey] as Record<string, unknown>;
|
|
181
|
+
setNested(entry, path, value);
|
|
182
|
+
|
|
183
|
+
const next = `${stringify(root, null, 2)}\n`;
|
|
184
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
185
|
+
await writeFile(tmp, next, "utf8");
|
|
186
|
+
await rename(tmp, file);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let writeChain: Promise<void> = Promise.resolve();
|
|
190
|
+
|
|
191
|
+
export function queueTuiPreferenceUpdate(
|
|
192
|
+
pluginKey: string,
|
|
193
|
+
path: string[],
|
|
194
|
+
value: JsonValue,
|
|
195
|
+
): Promise<void> {
|
|
196
|
+
writeChain = writeChain.then(() => writePreference(pluginKey, path, value)).catch(() => {});
|
|
197
|
+
return writeChain;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const WATCH_DEBOUNCE_MS = 150;
|
|
201
|
+
|
|
202
|
+
export function watchTuiPreferences(onChange: () => void): () => void {
|
|
203
|
+
const file = getTuiPreferencesFile();
|
|
204
|
+
const name = basename(file);
|
|
205
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
206
|
+
let lastSeen: string | null = null;
|
|
207
|
+
void readFile(file, "utf8")
|
|
208
|
+
.then((text) => {
|
|
209
|
+
if (lastSeen === null) lastSeen = text;
|
|
210
|
+
})
|
|
211
|
+
.catch(() => {});
|
|
212
|
+
try {
|
|
213
|
+
const watcher = watch(dirname(file), (_event, filename) => {
|
|
214
|
+
const isOurs =
|
|
215
|
+
filename === name || (filename?.startsWith(`${name}.`) && filename.endsWith(".tmp"));
|
|
216
|
+
if (filename != null && !isOurs) return;
|
|
217
|
+
if (timer) clearTimeout(timer);
|
|
218
|
+
timer = setTimeout(() => {
|
|
219
|
+
timer = null;
|
|
220
|
+
void readFile(file, "utf8")
|
|
221
|
+
.catch(() => null)
|
|
222
|
+
.then((text) => {
|
|
223
|
+
if (text === null) return;
|
|
224
|
+
if (text === lastSeen) return;
|
|
225
|
+
lastSeen = text;
|
|
226
|
+
onChange();
|
|
227
|
+
});
|
|
228
|
+
}, WATCH_DEBOUNCE_MS);
|
|
229
|
+
});
|
|
230
|
+
return () => {
|
|
231
|
+
if (timer) clearTimeout(timer);
|
|
232
|
+
watcher.close();
|
|
233
|
+
};
|
|
234
|
+
} catch {
|
|
235
|
+
return () => {};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function persistCollapsedIfEnabled(prefs: AftTuiPrefs, collapsed: boolean): void {
|
|
240
|
+
if (prefs.rememberCollapsed) {
|
|
241
|
+
void queueTuiPreferenceUpdate(PLUGIN_KEY, ["collapsed"], collapsed);
|
|
242
|
+
}
|
|
243
|
+
}
|