@cortexkit/aft-opencode 0.42.0 → 0.43.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.
Files changed (59) hide show
  1. package/dist/bg-notifications.d.ts +35 -0
  2. package/dist/bg-notifications.d.ts.map +1 -1
  3. package/dist/config.d.ts +7 -3
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/configure-warnings.d.ts +3 -3
  6. package/dist/configure-warnings.d.ts.map +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +6092 -9528
  9. package/dist/notifications.d.ts +2 -2
  10. package/dist/notifications.d.ts.map +1 -1
  11. package/dist/shared/rpc-client.d.ts +6 -0
  12. package/dist/shared/rpc-client.d.ts.map +1 -1
  13. package/dist/shared/rpc-notifications.d.ts +29 -15
  14. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  15. package/dist/shared/rpc-server.d.ts +10 -1
  16. package/dist/shared/rpc-server.d.ts.map +1 -1
  17. package/dist/subc-tool-schemas.d.ts +1 -1
  18. package/dist/subc-tool-schemas.d.ts.map +1 -1
  19. package/dist/tools/_shared.d.ts +12 -2
  20. package/dist/tools/_shared.d.ts.map +1 -1
  21. package/dist/tools/ast.d.ts.map +1 -1
  22. package/dist/tools/bash.d.ts.map +1 -1
  23. package/dist/tools/bash_watch.d.ts.map +1 -1
  24. package/dist/tools/hoisted.d.ts.map +1 -1
  25. package/dist/tools/imports.d.ts.map +1 -1
  26. package/dist/tools/inspect.d.ts +0 -1
  27. package/dist/tools/inspect.d.ts.map +1 -1
  28. package/dist/tools/navigation.d.ts.map +1 -1
  29. package/dist/tools/permissions.d.ts +23 -13
  30. package/dist/tools/permissions.d.ts.map +1 -1
  31. package/dist/tools/reading.d.ts +0 -1
  32. package/dist/tools/reading.d.ts.map +1 -1
  33. package/dist/tools/refactoring.d.ts.map +1 -1
  34. package/dist/tools/safety.d.ts.map +1 -1
  35. package/dist/tools/search.d.ts.map +1 -1
  36. package/dist/tools/semantic.d.ts.map +1 -1
  37. package/dist/tui/notification-socket.d.ts +41 -0
  38. package/dist/tui/notification-socket.d.ts.map +1 -0
  39. package/dist/tui.js +2460 -187
  40. package/dist/types.d.ts +4 -2
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +8 -9
  43. package/src/shared/rpc-client.ts +9 -0
  44. package/src/shared/rpc-notifications.ts +97 -41
  45. package/src/shared/rpc-server.ts +286 -67
  46. package/src/tui/index.tsx +77 -125
  47. package/src/tui/notification-socket.ts +421 -0
  48. package/src/tui/sidebar.tsx +38 -54
  49. package/dist/patch-parser.d.ts +0 -33
  50. package/dist/patch-parser.d.ts.map +0 -1
  51. package/dist/shared/opencode-config-dir.d.ts +0 -16
  52. package/dist/shared/opencode-config-dir.d.ts.map +0 -1
  53. package/dist/shared/pty-cache.d.ts +0 -18
  54. package/dist/shared/pty-cache.d.ts.map +0 -1
  55. package/dist/shared/tui-config.d.ts +0 -2
  56. package/dist/shared/tui-config.d.ts.map +0 -1
  57. package/src/shared/opencode-config-dir.ts +0 -46
  58. package/src/shared/pty-cache.ts +0 -113
  59. package/src/shared/tui-config.ts +0 -58
package/src/tui/index.tsx CHANGED
@@ -13,6 +13,14 @@ import {
13
13
  formatSemanticIndexStatus,
14
14
  formatSemanticRefreshing,
15
15
  } from "../shared/status";
16
+ import {
17
+ createDebouncedStatusRefresh,
18
+ refreshAftTuiSocketScope,
19
+ type SocketNotification,
20
+ startAftTuiSocket,
21
+ stopAftTuiSocket,
22
+ subscribeStatusInvalidations,
23
+ } from "./notification-socket";
16
24
  import {
17
25
  createAftSidebarSlot,
18
26
  formatCompressionSidebarRows,
@@ -50,53 +58,14 @@ function getSessionId(api: TuiPluginApi): string | null {
50
58
  return null;
51
59
  }
52
60
 
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
-
86
61
  // ---------------------------------------------------------------------------
87
- // StatusDialog — themed, two-column JSX dialog. Modeled on the magic-context
88
- // /ctx-status pattern (packages/plugin/src/tui/index.tsx in that repo):
89
- // custom JSX rendered via `api.ui.dialog.replace(() => <StatusDialog .../>)`
90
- // instead of feeding a padded monospace string into DialogAlert. The
91
- // difference matters because OpenCode renders DialogAlert text in a
92
- // proportional font with no column alignment; only TUI flex primitives
93
- // (<box flexDirection="row" flexBasis={0}>) actually produce visible
94
- // columns. This component owns its own RPC polling so it can re-render
95
- // reactively as the status snapshot changes, with no parent re-mount.
62
+ // StatusDialog — themed, two-column JSX dialog. OpenCode renders DialogAlert
63
+ // text in a proportional font with no column alignment, so the status view uses
64
+ // TUI flex primitives instead of a padded monospace string. The component
65
+ // subscribes to server-pushed invalidations so it can re-render as the status
66
+ // snapshot changes, with no parent re-mount.
96
67
  // ---------------------------------------------------------------------------
97
68
 
98
- const POLL_INTERVAL_MS = 1500;
99
-
100
69
  function formatCountShort(value: number | null | undefined): string {
101
70
  if (value == null || !Number.isFinite(value)) return "—";
102
71
  if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
@@ -187,20 +156,19 @@ const StatusDialog = (props: StatusDialogProps) => {
187
156
  const theme = createMemo(() => (props.api as any).theme.current as TuiThemeCurrent);
188
157
  const t = () => theme();
189
158
 
190
- // Reactive status signal — the dialog re-renders on every status
191
- // transition without remounting. The RPC polling is local to the dialog
192
- // and stops when it unmounts.
159
+ // Reactive status signal — the dialog re-renders on pushed status
160
+ // invalidations without remounting.
193
161
  const [status, setStatus] = createSignal<AftStatusSnapshot | null>(props.initial);
194
162
  const [error, setError] = createSignal<string | null>(props.initialError);
195
163
 
196
- let pollGeneration = 0;
197
- let pollController: AbortController | null = null;
198
- const pollStatus = async () => {
199
- if (pollController) return;
164
+ let refreshGeneration = 0;
165
+ let refreshController: AbortController | null = null;
166
+ const refreshStatus = async () => {
167
+ if (refreshController) return;
200
168
 
201
169
  const controller = new AbortController();
202
- const requestGeneration = ++pollGeneration;
203
- pollController = controller;
170
+ const requestGeneration = ++refreshGeneration;
171
+ refreshController = controller;
204
172
 
205
173
  try {
206
174
  const response = await props.client.call(
@@ -208,12 +176,13 @@ const StatusDialog = (props: StatusDialogProps) => {
208
176
  { sessionID: props.sessionID },
209
177
  { signal: controller.signal, accept: statusAcceptGate(props.directory) },
210
178
  );
211
- if (controller.signal.aborted || requestGeneration !== pollGeneration) return;
179
+ if (controller.signal.aborted || requestGeneration !== refreshGeneration) return;
212
180
  if ((response as Record<string, unknown>).success !== false) {
213
181
  const snapshot = coerceAftStatus(response as Record<string, unknown>);
214
182
  // Stale-while-revalidate: don't downgrade a good snapshot to a transient
215
- // `not_initialized` (bridge mid-respawn / session-dir key miss) it
216
- // arrives as success:true and would blank the dialog until the next poll.
183
+ // `not_initialized` (for example, while the bridge process respawns or
184
+ // a session directory lookup briefly misses) it arrives as success:true
185
+ // and would blank the dialog until the next refresh.
217
186
  const current = status();
218
187
  if (
219
188
  shouldSuppressUninitializedDowngrade(
@@ -227,22 +196,25 @@ const StatusDialog = (props: StatusDialogProps) => {
227
196
  setError(null);
228
197
  }
229
198
  } catch {
230
- if (controller.signal.aborted || requestGeneration !== pollGeneration) return;
199
+ if (controller.signal.aborted || requestGeneration !== refreshGeneration) return;
231
200
  // transient — keep showing last good snapshot
232
201
  } finally {
233
- if (pollController === controller) pollController = null;
202
+ if (refreshController === controller) refreshController = null;
234
203
  }
235
204
  };
236
205
 
237
- const timer = setInterval(() => {
238
- void pollStatus();
239
- }, POLL_INTERVAL_MS);
206
+ const statusDebouncer = createDebouncedStatusRefresh(refreshStatus, 200);
207
+ const unsubscribeStatusInvalidations = subscribeStatusInvalidations((event) => {
208
+ if (event.sessionId && event.sessionId !== props.sessionID) return;
209
+ statusDebouncer.schedule();
210
+ });
240
211
  onCleanup(() => {
241
- clearInterval(timer);
242
- pollGeneration++;
243
- if (pollController) {
244
- pollController.abort();
245
- pollController = null;
212
+ unsubscribeStatusInvalidations();
213
+ statusDebouncer.dispose();
214
+ refreshGeneration++;
215
+ if (refreshController) {
216
+ refreshController.abort();
217
+ refreshController = null;
246
218
  }
247
219
  });
248
220
 
@@ -588,7 +560,7 @@ async function showStatusDialog(api: TuiPluginApi): Promise<void> {
588
560
  const client = getRpcClient(directory);
589
561
 
590
562
  // Prime the dialog with one initial fetch so we don't show a blank
591
- // skeleton — the component then takes over polling.
563
+ // skeleton — the component then listens for pushed invalidations.
592
564
  let initial: AftStatusSnapshot | null = null;
593
565
  let initialError: string | null = null;
594
566
  try {
@@ -606,20 +578,15 @@ async function showStatusDialog(api: TuiPluginApi): Promise<void> {
606
578
  initialError = "AFT is starting up. Status will refresh automatically...";
607
579
  }
608
580
 
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.
581
+ // The dialog host already wraps every replace()'d element in its own centered
582
+ // frame and binds Esc/Ctrl-C to close it. Rendering our own dialog frame inside
583
+ // that host frame would misplace the content and focus; pass the bare component
584
+ // so the host frames it once.
585
+ // Do not pass an onClose that calls dialog.clear(). The dialog system calls
586
+ // every entry's onClose on Esc, and clear() would re-trigger them, causing
587
+ // infinite recursion. The host itself pops the dialog; StatusDialog cleans up
588
+ // its own subscriptions in onCleanup. `replace` resets size to "medium", so
589
+ // request "large" after the call.
623
590
  api.ui.dialog.replace(() => (
624
591
  <StatusDialog
625
592
  api={api}
@@ -774,54 +741,39 @@ const tui: TuiPlugin = async (api) => {
774
741
  // See https://github.com/cortexkit/aft/issues/33.
775
742
  registerStatusCommand(api);
776
743
 
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
-
744
+ // Receive server → TUI dialog requests and status invalidations over one
745
+ // persistent WebSocket. This avoids repeated loopback HTTP connection setup,
746
+ // which caused idle CPU usage.
747
+ const handleNotification = async (message: SocketNotification): Promise<boolean> => {
787
748
  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
- }
749
+ if (!requestedSessionId) return false;
750
+ if (message.sessionId && message.sessionId !== requestedSessionId) return false;
751
+ if (message.type !== "action") return false;
752
+ if (message.payload?.action !== "show-status-dialog") return false;
753
+ await showStatusDialog(api);
754
+ return true;
755
+ };
756
+
757
+ startAftTuiSocket({
758
+ getDirectory: () => api.state.path.directory ?? "",
759
+ getSessionId: () => getSessionId(api),
760
+ onNotification: handleNotification,
761
+ });
810
762
 
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);
763
+ const socketScopeUnsubs = [
764
+ api.event?.on?.("message.updated", () => refreshAftTuiSocketScope()),
765
+ api.event?.on?.("session.updated", () => refreshAftTuiSocketScope()),
766
+ ].filter(Boolean);
821
767
 
822
768
  api.lifecycle?.onDispose?.(() => {
823
- clearInterval(messagePoller);
824
- lastReceivedNotificationIdBySession.clear();
769
+ stopAftTuiSocket();
770
+ for (const unsub of socketScopeUnsubs) {
771
+ try {
772
+ unsub();
773
+ } catch {
774
+ // Ignore unsubscribe errors during cleanup; socket shutdown is handled separately.
775
+ }
776
+ }
825
777
  for (const client of rpcClients.values()) client.reset();
826
778
  rpcClients.clear();
827
779
  });