@cortexkit/aft-opencode 0.39.4 → 0.40.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/config.d.ts +12 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/configure-warnings.d.ts +3 -1
- package/dist/configure-warnings.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1285 -1218
- package/dist/lsp-cache.d.ts +3 -3
- package/dist/lsp-github-install.d.ts +5 -5
- package/dist/lsp-github-probe.d.ts +1 -1
- package/dist/notifications.d.ts +1 -1
- package/dist/notifications.d.ts.map +1 -1
- package/dist/patch-parser.d.ts.map +1 -1
- package/dist/shared/rpc-notifications.d.ts +45 -0
- package/dist/shared/rpc-notifications.d.ts.map +1 -0
- package/dist/shared/rpc-utils.d.ts +10 -3
- package/dist/shared/rpc-utils.d.ts.map +1 -1
- package/dist/subc-tool-schemas.d.ts +20 -0
- package/dist/subc-tool-schemas.d.ts.map +1 -0
- package/dist/tools/_shared.d.ts.map +1 -1
- package/dist/tools/ast.d.ts.map +1 -1
- package/dist/tools/bash.d.ts.map +1 -1
- package/dist/tools/bash_watch.d.ts.map +1 -1
- package/dist/tools/hoisted.d.ts.map +1 -1
- package/dist/tools/inspect.d.ts.map +1 -1
- package/dist/tools/navigation.d.ts.map +1 -1
- package/dist/tools/reading.d.ts.map +1 -1
- package/dist/tools/refactoring.d.ts.map +1 -1
- package/dist/tools/semantic.d.ts.map +1 -1
- package/dist/tui.js +1351 -81
- package/dist/workflow-hints.d.ts.map +1 -1
- package/package.json +11 -10
- package/src/shared/rpc-notifications.ts +121 -0
- package/src/shared/rpc-utils.ts +12 -7
- package/src/tui/index.tsx +118 -38
- package/src/tui/sidebar.tsx +12 -6
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workflow-hints.d.ts","sourceRoot":"","sources":["../src/workflow-hints.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,KAAK,SAAS,EAAqB,MAAM,aAAa,CAAC;AAEhE,MAAM,WAAW,iBAAiB;IAChC,oEAAoE;IACpE,WAAW,EAAE,SAAS,GAAG,aAAa,GAAG,KAAK,CAAC;IAC/C,4EAA4E;IAC5E,aAAa,EAAE,OAAO,CAAC;IACvB,mEAAmE;IACnE,eAAe,EAAE,OAAO,CAAC;IACzB,2DAA2D;IAC3D,qBAAqB,EAAE,OAAO,CAAC;IAC/B,sCAAsC;IACtC,sBAAsB,EAAE,OAAO,CAAC;IAChC,4DAA4D;IAC5D,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CAC5B;AAID;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"workflow-hints.d.ts","sourceRoot":"","sources":["../src/workflow-hints.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,KAAK,SAAS,EAAqB,MAAM,aAAa,CAAC;AAEhE,MAAM,WAAW,iBAAiB;IAChC,oEAAoE;IACpE,WAAW,EAAE,SAAS,GAAG,aAAa,GAAG,KAAK,CAAC;IAC/C,4EAA4E;IAC5E,aAAa,EAAE,OAAO,CAAC;IACvB,mEAAmE;IACnE,eAAe,EAAE,OAAO,CAAC;IACzB,2DAA2D;IAC3D,qBAAqB,EAAE,OAAO,CAAC;IAC/B,sCAAsC;IACtC,sBAAsB,EAAE,OAAO,CAAC;IAChC,4DAA4D;IAC5D,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CAC5B;AAID;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,GAAG,IAAI,CAgIzE;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,SAAS,EAAE,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,GAAG,IAAI,CASjG"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cortexkit/aft-opencode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
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",
|
|
@@ -25,28 +25,29 @@
|
|
|
25
25
|
"test:e2e": "bun test src/__tests__/e2e/",
|
|
26
26
|
"lint": "biome check src",
|
|
27
27
|
"schema": "bun scripts/build-schema.ts",
|
|
28
|
+
"build:tool-schemas": "bun run scripts/build-tool-schemas.ts",
|
|
28
29
|
"prepublishOnly": "bun run build",
|
|
29
30
|
"test:unit": "bun test src/__tests__ --path-ignore-patterns 'src/__tests__/e2e/**'"
|
|
30
31
|
},
|
|
31
32
|
"dependencies": {
|
|
32
33
|
"@clack/prompts": "^1.2.0",
|
|
33
|
-
"@cortexkit/aft-bridge": "0.
|
|
34
|
+
"@cortexkit/aft-bridge": "0.40.0",
|
|
34
35
|
"@xterm/headless": "^5.5.0",
|
|
35
36
|
"comment-json": "^4.6.2",
|
|
36
37
|
"undici": "^7.25.0",
|
|
37
38
|
"zod": "^4.1.8"
|
|
38
39
|
},
|
|
39
40
|
"optionalDependencies": {
|
|
40
|
-
"@cortexkit/aft-darwin-arm64": "0.
|
|
41
|
-
"@cortexkit/aft-darwin-x64": "0.
|
|
42
|
-
"@cortexkit/aft-linux-arm64": "0.
|
|
43
|
-
"@cortexkit/aft-linux-x64": "0.
|
|
44
|
-
"@cortexkit/aft-win32-arm64": "0.
|
|
45
|
-
"@cortexkit/aft-win32-x64": "0.
|
|
41
|
+
"@cortexkit/aft-darwin-arm64": "0.40.0",
|
|
42
|
+
"@cortexkit/aft-darwin-x64": "0.40.0",
|
|
43
|
+
"@cortexkit/aft-linux-arm64": "0.40.0",
|
|
44
|
+
"@cortexkit/aft-linux-x64": "0.40.0",
|
|
45
|
+
"@cortexkit/aft-win32-arm64": "0.40.0",
|
|
46
|
+
"@cortexkit/aft-win32-x64": "0.40.0"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
|
-
"@opencode-ai/plugin": "^1.17.
|
|
49
|
-
"@opencode-ai/sdk": "^1.17.
|
|
49
|
+
"@opencode-ai/plugin": "^1.17.9",
|
|
50
|
+
"@opencode-ai/sdk": "^1.17.9",
|
|
50
51
|
"@types/node": "^22.0.0",
|
|
51
52
|
"typescript": "^5.8.0"
|
|
52
53
|
},
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory notification queue for server → TUI push.
|
|
3
|
+
*
|
|
4
|
+
* Also tracks whether a TUI client is actively connected (polling).
|
|
5
|
+
* The server plugin cannot use `process.env.OPENCODE_CLIENT` to detect TUI
|
|
6
|
+
* because the server runs in a separate process from the TUI client.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface RpcNotification {
|
|
10
|
+
id: number;
|
|
11
|
+
type: string;
|
|
12
|
+
payload: Record<string, unknown>;
|
|
13
|
+
sessionId?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let queue: RpcNotification[] = [];
|
|
17
|
+
let nextNotificationId = 1;
|
|
18
|
+
// Timestamp of last drain — used to detect if a TUI is actively polling.
|
|
19
|
+
// The TUI polls every 500ms; we consider it connected if it polled within
|
|
20
|
+
// the last 3 seconds (6× the poll interval, tolerates transient delays).
|
|
21
|
+
//
|
|
22
|
+
// PER-SESSION: a single server process can serve MANY sessions (e.g. a TUI on
|
|
23
|
+
// session A plus an OpenCode Desktop opened on session B for the same project,
|
|
24
|
+
// whose newer RPC server this TUI's port discovery then selects). The TUI
|
|
25
|
+
// poller drains with ITS active session id, so a session is "TUI-connected"
|
|
26
|
+
// only if a TUI recently drained FOR THAT session. A process-global timestamp
|
|
27
|
+
// would make session B's producers (`/aft-status`, configure warnings, etc.)
|
|
28
|
+
// take the TUI-dialog path because session A's TUI is polling — queuing a
|
|
29
|
+
// B-scoped dialog action that A's poller correctly refuses to show, so B's
|
|
30
|
+
// notice is lost (it also suppressed B's non-TUI fallback). Tracking drains per
|
|
31
|
+
// session routes each producer to the right delivery path.
|
|
32
|
+
const lastDrainAtBySession = new Map<string, number>();
|
|
33
|
+
let lastDrainAtAny = 0;
|
|
34
|
+
const TUI_CONNECTED_WINDOW_MS = 3_000;
|
|
35
|
+
|
|
36
|
+
/** Push a notification for the TUI to pick up via polling. */
|
|
37
|
+
export function pushNotification(
|
|
38
|
+
type: string,
|
|
39
|
+
payload: Record<string, unknown>,
|
|
40
|
+
sessionId?: string,
|
|
41
|
+
): void {
|
|
42
|
+
queue.push({ id: nextNotificationId++, type, payload, sessionId });
|
|
43
|
+
// Cap queue size to prevent unbounded growth if a TUI is not draining.
|
|
44
|
+
// Session-fair eviction: a naive `slice(-50)` drops the globally-oldest
|
|
45
|
+
// items, so a noisy session could evict ANOTHER session's single unseen
|
|
46
|
+
// notification. Instead, always retain each session's newest item, then
|
|
47
|
+
// fill the rest of the budget with the newest overall — no session can
|
|
48
|
+
// starve another's pending dialog out of the window.
|
|
49
|
+
if (queue.length > 100) {
|
|
50
|
+
const newestPerSession = new Map<string | undefined, number>();
|
|
51
|
+
for (const notification of queue) {
|
|
52
|
+
const previous = newestPerSession.get(notification.sessionId);
|
|
53
|
+
if (previous === undefined || notification.id > previous) {
|
|
54
|
+
newestPerSession.set(notification.sessionId, notification.id);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const mustKeep = new Set(newestPerSession.values());
|
|
58
|
+
const byNewest = [...queue].sort((a, b) => b.id - a.id);
|
|
59
|
+
const kept: RpcNotification[] = [];
|
|
60
|
+
for (const notification of byNewest) {
|
|
61
|
+
if (kept.length < 50 || mustKeep.has(notification.id)) kept.push(notification);
|
|
62
|
+
}
|
|
63
|
+
queue = kept.sort((a, b) => a.id - b.id);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Return pending notifications after acking the client's last received id.
|
|
68
|
+
* Updates lastDrainAt so isTuiConnected() reflects recent activity.
|
|
69
|
+
*
|
|
70
|
+
* Session scoping: when `sessionId` is provided, only notifications tagged for
|
|
71
|
+
* that session (or session-less/global ones) are returned and pruned — a
|
|
72
|
+
* notification tagged for a DIFFERENT session is never handed to this client
|
|
73
|
+
* and is never pruned by this client's ack. This matters because the in-memory
|
|
74
|
+
* queue is per-process but a TUI can end up draining a process that also serves
|
|
75
|
+
* OTHER sessions: e.g. opening OpenCode Desktop on the same project starts a
|
|
76
|
+
* newer RPC server that the TUI's port discovery (newest-pid-wins) then selects,
|
|
77
|
+
* so a Desktop-session dialog action would otherwise surface in an unrelated
|
|
78
|
+
* TUI session. Each client also tracks its own `lastReceivedId`, so a global
|
|
79
|
+
* watermark prune would let session A's ack drop session B's still-unseen
|
|
80
|
+
* notification — scoping the prune to the acking session prevents that too.
|
|
81
|
+
*
|
|
82
|
+
* Delivery is at-least-once (non-destructive return + prune-on-ack): a returned
|
|
83
|
+
* notification stays queued until a later call acks it via a higher
|
|
84
|
+
* `lastReceivedId`, so a lost poll response re-delivers on the next poll. */
|
|
85
|
+
export function drainNotifications(lastReceivedId = 0, sessionId?: string): RpcNotification[] {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
lastDrainAtAny = now;
|
|
88
|
+
if (sessionId !== undefined) lastDrainAtBySession.set(sessionId, now);
|
|
89
|
+
const matchesClient = (notification: RpcNotification): boolean =>
|
|
90
|
+
sessionId === undefined ||
|
|
91
|
+
notification.sessionId === undefined ||
|
|
92
|
+
notification.sessionId === sessionId;
|
|
93
|
+
if (lastReceivedId > 0) {
|
|
94
|
+
// Prune only notifications THIS client both owns (session-matched) and has
|
|
95
|
+
// acked (id <= lastReceivedId). Other sessions' notifications survive.
|
|
96
|
+
queue = queue.filter(
|
|
97
|
+
(notification) => !(notification.id <= lastReceivedId && matchesClient(notification)),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return queue.filter(
|
|
101
|
+
(notification) => notification.id > lastReceivedId && matchesClient(notification),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Whether a TUI client is actively polling for notifications.
|
|
106
|
+
* Returns true only if a TUI has drained within the last 3 seconds.
|
|
107
|
+
*
|
|
108
|
+
* Pass `sessionId` (preferred) to ask whether a TUI is polling FOR THAT
|
|
109
|
+
* SESSION — this is what producers (`/aft-status`, configure warnings, etc.)
|
|
110
|
+
* must use to decide dialog-vs-message, so a TUI on a different session in the
|
|
111
|
+
* same process does not misroute their delivery. Omit it only for legacy/global
|
|
112
|
+
* callers that genuinely have no session context; they fall back to "any
|
|
113
|
+
* session recently drained" (the pre-per-session behavior). */
|
|
114
|
+
export function isTuiConnected(sessionId?: string): boolean {
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
if (sessionId !== undefined) {
|
|
117
|
+
const at = lastDrainAtBySession.get(sessionId) ?? 0;
|
|
118
|
+
return at > 0 && now - at < TUI_CONNECTED_WINDOW_MS;
|
|
119
|
+
}
|
|
120
|
+
return lastDrainAtAny > 0 && now - lastDrainAtAny < TUI_CONNECTED_WINDOW_MS;
|
|
121
|
+
}
|
package/src/shared/rpc-utils.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { join } from "node:path";
|
|
2
|
+
import { projectRootKeyHash } from "@cortexkit/aft-bridge";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Compute a stable hash for a project directory
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Compute a stable hash for a project directory, used to scope RPC port files
|
|
6
|
+
* per-project so multiple OpenCode Desktop instances don't overwrite each
|
|
7
|
+
* other.
|
|
8
|
+
*
|
|
9
|
+
* Delegates to the shared `projectRootKeyHash`, which CANONICALIZES (realpath +
|
|
10
|
+
* Windows normalization) before hashing — the same identity the bridge pool
|
|
11
|
+
* routes by. Previously this hashed the RAW directory string, so a symlinked or
|
|
12
|
+
* raw-spelled launch dir scoped its port file to a different directory than the
|
|
13
|
+
* bridge routed to, leaving the sidebar unable to discover the live server
|
|
14
|
+
* (stale-port / wrong-project class).
|
|
8
15
|
*/
|
|
9
16
|
export function projectHash(directory: string): string {
|
|
10
|
-
|
|
11
|
-
const normalized = directory.replace(/\/+$/, "");
|
|
12
|
-
return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
17
|
+
return projectRootKeyHash(directory);
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
/**
|
package/src/tui/index.tsx
CHANGED
|
@@ -26,8 +26,6 @@ import {
|
|
|
26
26
|
// per-server token on every RPC request; legacy integer port files are still
|
|
27
27
|
// tolerated for already-running older server plugins.
|
|
28
28
|
|
|
29
|
-
const STATUS_COMMAND = "aft-status";
|
|
30
|
-
|
|
31
29
|
// RPC clients keyed by directory — one per project
|
|
32
30
|
const rpcClients = new Map<string, AftRpcClient>();
|
|
33
31
|
|
|
@@ -52,6 +50,39 @@ function getSessionId(api: TuiPluginApi): string | null {
|
|
|
52
50
|
return null;
|
|
53
51
|
}
|
|
54
52
|
|
|
53
|
+
interface TuiMessage {
|
|
54
|
+
id: number;
|
|
55
|
+
type: string;
|
|
56
|
+
payload: Record<string, unknown>;
|
|
57
|
+
sessionId?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const lastReceivedNotificationIdBySession = new Map<string, number>();
|
|
61
|
+
|
|
62
|
+
/** Poll for pending server → TUI notifications via RPC. */
|
|
63
|
+
async function consumeTuiMessages(client: AftRpcClient, sessionId: string): Promise<TuiMessage[]> {
|
|
64
|
+
try {
|
|
65
|
+
const result = await client.call<{ messages?: TuiMessage[] }>("consume-notifications", {
|
|
66
|
+
lastReceivedId: lastReceivedNotificationIdBySession.get(sessionId) ?? 0,
|
|
67
|
+
sessionId,
|
|
68
|
+
});
|
|
69
|
+
return (result.messages ?? []).map((message) => ({
|
|
70
|
+
id: message.id,
|
|
71
|
+
type: message.type,
|
|
72
|
+
payload: message.payload,
|
|
73
|
+
sessionId: message.sessionId,
|
|
74
|
+
}));
|
|
75
|
+
} catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function markTuiMessagesReceived(sessionId: string, messages: TuiMessage[]): void {
|
|
81
|
+
const previous = lastReceivedNotificationIdBySession.get(sessionId) ?? 0;
|
|
82
|
+
const next = messages.reduce((max, message) => Math.max(max, message.id), previous);
|
|
83
|
+
if (next > previous) lastReceivedNotificationIdBySession.set(sessionId, next);
|
|
84
|
+
}
|
|
85
|
+
|
|
55
86
|
// ---------------------------------------------------------------------------
|
|
56
87
|
// StatusDialog — themed, two-column JSX dialog. Modeled on the magic-context
|
|
57
88
|
// /ctx-status pattern (packages/plugin/src/tui/index.tsx in that repo):
|
|
@@ -133,7 +164,6 @@ interface StatusDialogProps {
|
|
|
133
164
|
sessionID: string;
|
|
134
165
|
initial: AftStatusSnapshot | null;
|
|
135
166
|
initialError: string | null;
|
|
136
|
-
onClose: () => void;
|
|
137
167
|
}
|
|
138
168
|
|
|
139
169
|
/**
|
|
@@ -576,39 +606,40 @@ async function showStatusDialog(api: TuiPluginApi): Promise<void> {
|
|
|
576
606
|
initialError = "AFT is starting up. Status will refresh automatically...";
|
|
577
607
|
}
|
|
578
608
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
609
|
+
// The host's DialogProvider already wraps every replace()'d element in its own
|
|
610
|
+
// <Dialog> frame (absolute, centered, zIndex 3000) and binds Esc/Ctrl-C to pop
|
|
611
|
+
// the stack. Rendering our own <api.ui.Dialog> inside it was a frame-inside-a-
|
|
612
|
+
// frame: the inner absolute box anchored within the already-narrowed parent and
|
|
613
|
+
// rendered shifted down-right off the screen edge, unthemed, with key focus on
|
|
614
|
+
// the wrong layer. Pass the bare component — the host frames it — exactly like
|
|
615
|
+
// the DialogAlert/DialogConfirm paths and magic-context's status dialog do.
|
|
616
|
+
//
|
|
617
|
+
// Also: do NOT pass an onClose that calls dialog.clear(). The host invokes the
|
|
618
|
+
// entry's onClose on Esc AND clear() re-invokes every entry's onClose, so a
|
|
619
|
+
// clear()-based onClose recursed infinitely on close. The host pops the stack
|
|
620
|
+
// itself, and StatusDialog stops its own polling via onCleanup when unmounted,
|
|
621
|
+
// so no custom onClose is needed. `replace` resets size to "medium", so request
|
|
622
|
+
// "large" AFTER it.
|
|
623
|
+
api.ui.dialog.replace(() => (
|
|
624
|
+
<StatusDialog
|
|
625
|
+
api={api}
|
|
626
|
+
client={client}
|
|
627
|
+
directory={directory}
|
|
628
|
+
sessionID={sessionID}
|
|
629
|
+
initial={initial}
|
|
630
|
+
initialError={initialError}
|
|
631
|
+
/>
|
|
632
|
+
));
|
|
584
633
|
api.ui.dialog.setSize("large");
|
|
585
|
-
api.ui.dialog.replace(
|
|
586
|
-
() => (
|
|
587
|
-
<api.ui.Dialog size="large" onClose={dismissStatusDialog}>
|
|
588
|
-
<StatusDialog
|
|
589
|
-
api={api}
|
|
590
|
-
client={client}
|
|
591
|
-
directory={directory}
|
|
592
|
-
sessionID={sessionID}
|
|
593
|
-
initial={initial}
|
|
594
|
-
initialError={initialError}
|
|
595
|
-
onClose={dismissStatusDialog}
|
|
596
|
-
/>
|
|
597
|
-
</api.ui.Dialog>
|
|
598
|
-
),
|
|
599
|
-
dismissStatusDialog,
|
|
600
|
-
);
|
|
601
634
|
}
|
|
602
635
|
|
|
603
636
|
/**
|
|
604
|
-
* Register the
|
|
605
|
-
*
|
|
637
|
+
* Register the AFT status palette command, preferring the v1.14.42+ keymap API
|
|
638
|
+
* and falling back to the legacy `api.command.register` for older hosts.
|
|
606
639
|
*
|
|
607
|
-
*
|
|
608
|
-
* `
|
|
609
|
-
*
|
|
610
|
-
* deprecation warning and works without depending on the (now-deprecated)
|
|
611
|
-
* `api.command` namespace existing at all.
|
|
640
|
+
* No slash entry is registered here. The server config hook owns the single
|
|
641
|
+
* `/aft-status` slash registration, while this palette command keeps Ctrl+P
|
|
642
|
+
* discovery in the TUI.
|
|
612
643
|
*/
|
|
613
644
|
function registerStatusCommand(api: TuiPluginApi): void {
|
|
614
645
|
type ApiAny = {
|
|
@@ -632,7 +663,6 @@ function registerStatusCommand(api: TuiPluginApi): void {
|
|
|
632
663
|
name: "aft.status",
|
|
633
664
|
title: "AFT: Status",
|
|
634
665
|
category: "AFT",
|
|
635
|
-
slashName: STATUS_COMMAND,
|
|
636
666
|
run() {
|
|
637
667
|
void showStatusDialog(api);
|
|
638
668
|
},
|
|
@@ -649,7 +679,6 @@ function registerStatusCommand(api: TuiPluginApi): void {
|
|
|
649
679
|
title: "AFT: Status",
|
|
650
680
|
value: "aft.status",
|
|
651
681
|
category: "AFT",
|
|
652
|
-
slash: { name: STATUS_COMMAND },
|
|
653
682
|
onSelect() {
|
|
654
683
|
void showStatusDialog(api);
|
|
655
684
|
},
|
|
@@ -658,9 +687,8 @@ function registerStatusCommand(api: TuiPluginApi): void {
|
|
|
658
687
|
return;
|
|
659
688
|
}
|
|
660
689
|
|
|
661
|
-
// Neither API surface is present. The TUI host can still load
|
|
662
|
-
//
|
|
663
|
-
// remains available so users can still see AFT status visually.
|
|
690
|
+
// Neither API surface is present. The TUI host can still load; users can
|
|
691
|
+
// still see AFT status through the sidebar and server-owned slash command.
|
|
664
692
|
}
|
|
665
693
|
|
|
666
694
|
async function showStartupNotifications(api: TuiPluginApi): Promise<void> {
|
|
@@ -732,8 +760,8 @@ const tui: TuiPlugin = async (api) => {
|
|
|
732
760
|
try {
|
|
733
761
|
api.slots.register(await createAftSidebarSlot(api, packageVersion));
|
|
734
762
|
} catch {
|
|
735
|
-
// Older OpenCode TUI hosts may not implement api.slots; fall through
|
|
736
|
-
//
|
|
763
|
+
// Older OpenCode TUI hosts may not implement api.slots; fall through and
|
|
764
|
+
// keep the palette command / server-owned slash command working.
|
|
737
765
|
}
|
|
738
766
|
|
|
739
767
|
// OpenCode 1.14.42 removed `api.command.register` entirely
|
|
@@ -746,6 +774,58 @@ const tui: TuiPlugin = async (api) => {
|
|
|
746
774
|
// See https://github.com/cortexkit/aft/issues/33.
|
|
747
775
|
registerStatusCommand(api);
|
|
748
776
|
|
|
777
|
+
// Poll for server → TUI dialog requests. The server owns the slash command,
|
|
778
|
+
// and this poller is how it asks the TUI process to show native UI.
|
|
779
|
+
let pollInFlight = false;
|
|
780
|
+
const messagePoller = setInterval(() => {
|
|
781
|
+
// Scope the drain to the TUI's active session so notifications tagged for a
|
|
782
|
+
// different session (served by the same RPC process) are not consumed here.
|
|
783
|
+
// Do not poll on non-session routes: a session-scoped action fetched while
|
|
784
|
+
// sessionless could otherwise be acked without being shown.
|
|
785
|
+
if (pollInFlight) return;
|
|
786
|
+
|
|
787
|
+
const requestedSessionId = getSessionId(api);
|
|
788
|
+
if (!requestedSessionId) return;
|
|
789
|
+
|
|
790
|
+
const directory = api.state.path.directory ?? "";
|
|
791
|
+
if (!directory) return;
|
|
792
|
+
|
|
793
|
+
pollInFlight = true;
|
|
794
|
+
const client = getRpcClient(directory);
|
|
795
|
+
void consumeTuiMessages(client, requestedSessionId)
|
|
796
|
+
.then(async (messages) => {
|
|
797
|
+
// If the user switched routes while the RPC was in flight, drop this
|
|
798
|
+
// batch without advancing the cursor; the next poll for the active
|
|
799
|
+
// session will fetch the right notifications.
|
|
800
|
+
if (getSessionId(api) !== requestedSessionId) return;
|
|
801
|
+
|
|
802
|
+
const orderedMessages = [...messages].sort((a, b) => a.id - b.id);
|
|
803
|
+
for (const message of orderedMessages) {
|
|
804
|
+
if (getSessionId(api) !== requestedSessionId) return;
|
|
805
|
+
if (message.sessionId && message.sessionId !== requestedSessionId) continue;
|
|
806
|
+
if (message.type !== "action") continue;
|
|
807
|
+
if (message.payload?.action !== "show-status-dialog") continue;
|
|
808
|
+
await showStatusDialog(api);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (getSessionId(api) !== requestedSessionId) return;
|
|
812
|
+
markTuiMessagesReceived(requestedSessionId, orderedMessages);
|
|
813
|
+
})
|
|
814
|
+
.catch(() => {
|
|
815
|
+
// Intentional: message polling should never crash the TUI.
|
|
816
|
+
})
|
|
817
|
+
.finally(() => {
|
|
818
|
+
pollInFlight = false;
|
|
819
|
+
});
|
|
820
|
+
}, 500);
|
|
821
|
+
|
|
822
|
+
api.lifecycle?.onDispose?.(() => {
|
|
823
|
+
clearInterval(messagePoller);
|
|
824
|
+
lastReceivedNotificationIdBySession.clear();
|
|
825
|
+
for (const client of rpcClients.values()) client.reset();
|
|
826
|
+
rpcClients.clear();
|
|
827
|
+
});
|
|
828
|
+
|
|
749
829
|
// Show startup notifications — RPC server is already running by the time TUI loads
|
|
750
830
|
void showStartupNotifications(api);
|
|
751
831
|
};
|
package/src/tui/sidebar.tsx
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
// session.updated/message.updated events with a small debounce, same as
|
|
8
8
|
// magic-context, so the panel stays current without polling.
|
|
9
9
|
|
|
10
|
+
import { canonicalizeProjectRoot } from "@cortexkit/aft-bridge";
|
|
10
11
|
import type { TuiPluginApi, TuiSlotPlugin, TuiThemeCurrent } from "@opencode-ai/plugin/tui";
|
|
11
12
|
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js";
|
|
12
|
-
|
|
13
13
|
import { AftRpcClient } from "../shared/rpc-client";
|
|
14
14
|
import {
|
|
15
15
|
type AftStatusSnapshot,
|
|
@@ -317,16 +317,22 @@ export function isSnapshotForContext(
|
|
|
317
317
|
directory: string,
|
|
318
318
|
servedDirectory: string | undefined,
|
|
319
319
|
): boolean {
|
|
320
|
-
|
|
320
|
+
// Canonicalize both sides through the SAME canonicalizer the bridge routes
|
|
321
|
+
// by, so a symlinked / `/var`-vs-`/private/var` / trailing-slash spelling of
|
|
322
|
+
// the sidebar directory still matches Rust's canonical_root. A raw stripSlash
|
|
323
|
+
// compare (the old behavior) rejected legitimate snapshots whenever the TUI
|
|
324
|
+
// directory and Rust's root were different spellings of the same location,
|
|
325
|
+
// leaving the sidebar blank on aliased roots.
|
|
326
|
+
const canon = (p: string) => canonicalizeProjectRoot(p);
|
|
321
327
|
const roots = [snapshot.project_root, snapshot.canonical_root].filter(
|
|
322
328
|
(r): r is string => typeof r === "string" && r.length > 0,
|
|
323
329
|
);
|
|
324
330
|
if (roots.length === 0) return true; // placeholder / synthetic
|
|
325
|
-
const dir =
|
|
326
|
-
if (roots.some((r) =>
|
|
331
|
+
const dir = canon(directory);
|
|
332
|
+
if (roots.some((r) => canon(r) === dir)) return true;
|
|
327
333
|
if (typeof servedDirectory === "string" && servedDirectory.length > 0) {
|
|
328
|
-
const served =
|
|
329
|
-
return roots.some((r) =>
|
|
334
|
+
const served = canon(servedDirectory);
|
|
335
|
+
return roots.some((r) => canon(r) === served);
|
|
330
336
|
}
|
|
331
337
|
return false;
|
|
332
338
|
}
|