@wolfx/opencode-magic-context 0.28.0 → 0.30.3
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/agents/magic-context-prompt.d.ts +1 -1
- package/dist/agents/magic-context-prompt.d.ts.map +1 -1
- package/dist/config/schema/magic-context.d.ts +11 -0
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- package/dist/features/magic-context/compartment-chunk-embedding.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/retrospective-raw-provider.d.ts +0 -1
- package/dist/features/magic-context/dreamer/retrospective-raw-provider.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/storage-task-schedule.d.ts +10 -0
- package/dist/features/magic-context/dreamer/storage-task-schedule.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-executor.d.ts +0 -3
- package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-prompts.d.ts +1 -1
- package/dist/features/magic-context/dreamer/task-prompts.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-registry.d.ts +0 -1
- package/dist/features/magic-context/dreamer/task-registry.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
- package/dist/features/magic-context/recursive-text-splitter.d.ts +36 -0
- package/dist/features/magic-context/recursive-text-splitter.d.ts.map +1 -0
- package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
- package/dist/features/magic-context/storage-db.d.ts +2 -21
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-schema-helpers.d.ts +30 -0
- package/dist/features/magic-context/storage-schema-helpers.d.ts.map +1 -0
- package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
- package/dist/features/magic-context/types.d.ts +12 -1
- package/dist/features/magic-context/types.d.ts.map +1 -1
- package/dist/hooks/magic-context/apply-operations.d.ts +8 -1
- package/dist/hooks/magic-context/apply-operations.d.ts.map +1 -1
- package/dist/hooks/magic-context/channel2-delivery.d.ts +9 -5
- package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
- package/dist/hooks/magic-context/edit-marker.d.ts +11 -0
- package/dist/hooks/magic-context/edit-marker.d.ts.map +1 -0
- package/dist/hooks/magic-context/event-handler.d.ts +1 -4
- package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook.d.ts +1 -2
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-formatting.d.ts.map +1 -1
- package/dist/hooks/magic-context/supersession-reclaim.d.ts +34 -0
- package/dist/hooks/magic-context/supersession-reclaim.d.ts.map +1 -0
- package/dist/hooks/magic-context/system-prompt-hash.d.ts +5 -0
- package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
- package/dist/hooks/magic-context/tag-messages.d.ts +8 -0
- package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
- package/dist/hooks/magic-context/tool-drop-target.d.ts +2 -0
- package/dist/hooks/magic-context/tool-drop-target.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +8 -0
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +4 -0
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3587 -5086
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/plugin/tool-registry.d.ts.map +1 -1
- package/dist/shared/announcement.d.ts +1 -1
- package/dist/shared/announcement.d.ts.map +1 -1
- package/dist/shared/commit-detection.d.ts +29 -0
- package/dist/shared/commit-detection.d.ts.map +1 -0
- package/dist/shared/data-path.d.ts.map +1 -1
- package/dist/shared/exit-abort-registry.d.ts +25 -0
- package/dist/shared/exit-abort-registry.d.ts.map +1 -0
- package/dist/shared/harness-provider-map.d.ts +30 -0
- package/dist/shared/harness-provider-map.d.ts.map +1 -0
- package/dist/shared/rpc-client.d.ts +8 -0
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/shared/rpc-notifications.d.ts +28 -10
- package/dist/shared/rpc-notifications.d.ts.map +1 -1
- package/dist/shared/rpc-server.d.ts +22 -3
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/tag-transcript.d.ts.map +1 -1
- package/dist/shared/transcript.d.ts +15 -0
- package/dist/shared/transcript.d.ts.map +1 -1
- package/dist/tools/ctx-note/tools.d.ts.map +1 -1
- package/dist/tui/badge-contrast.d.ts +37 -22
- package/dist/tui/badge-contrast.d.ts.map +1 -1
- package/dist/tui/data/context-db.d.ts +4 -14
- package/dist/tui/data/context-db.d.ts.map +1 -1
- package/dist/tui/data/notification-socket.d.ts +39 -0
- package/dist/tui/data/notification-socket.d.ts.map +1 -0
- package/package.json +78 -77
- package/src/shared/announcement.ts +2 -3
- package/src/shared/commit-detection.test.ts +63 -0
- package/src/shared/commit-detection.ts +53 -0
- package/src/shared/data-path.test.ts +28 -0
- package/src/shared/data-path.ts +5 -0
- package/src/shared/exit-abort-registry.test.ts +50 -0
- package/src/shared/exit-abort-registry.ts +46 -0
- package/src/shared/harness-provider-map.test.ts +63 -0
- package/src/shared/harness-provider-map.ts +56 -0
- package/src/shared/rpc-client.ts +14 -0
- package/src/shared/rpc-notifications.test.ts +68 -11
- package/src/shared/rpc-notifications.ts +75 -36
- package/src/shared/rpc-server.ts +249 -150
- package/src/shared/tag-transcript.ts +32 -0
- package/src/shared/transcript-opencode.ts +33 -0
- package/src/shared/transcript.ts +17 -0
- package/src/tui/badge-contrast.test.ts +39 -1
- package/src/tui/badge-contrast.ts +63 -25
- package/src/tui/data/context-db.ts +10 -64
- package/src/tui/data/notification-socket.ts +229 -0
- package/src/tui/index.tsx +68 -118
- package/src/tui/slots/sidebar-content.tsx +2 -2
- package/dist/hooks/is-anthropic-provider.d.ts +0 -2
- package/dist/hooks/is-anthropic-provider.d.ts.map +0 -1
- package/dist/shared/live-server-client.d.ts +0 -50
- package/dist/shared/live-server-client.d.ts.map +0 -1
- package/src/shared/live-server-client.ts +0 -152
|
@@ -1,39 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pick
|
|
3
|
-
* background
|
|
2
|
+
* Pick the text color for the sidebar header badge (a bold label drawn on a
|
|
3
|
+
* `theme.accent` background).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* always opaque, so deriving the text color from it is transparency-proof.
|
|
5
|
+
* Primary rule (matches AFT's sidebar by construction): paint the theme's own
|
|
6
|
+
* `background` color as the label, the inverse-of-panel look. Because it is a
|
|
7
|
+
* fixed theme token rather than an accent-derived computation, MC's badge and
|
|
8
|
+
* AFT's badge agree on EVERY accent automatically, so the same theme can never
|
|
9
|
+
* make one badge black and the other white (issue #198).
|
|
11
10
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
11
|
+
* Fallback rule (the reason a luminance pick exists at all): `theme.background`
|
|
12
|
+
* can be unusable as a label color in two degenerate cases, where it would
|
|
13
|
+
* render the label invisible on the accent:
|
|
14
|
+
* 1. Transparent background. Themes that respect terminal transparency set
|
|
15
|
+
* `background: "none"`, which resolves to `RGBA(0,0,0,0)`; drawing it as
|
|
16
|
+
* text renders fully transparent and the label disappears (issue #186).
|
|
17
|
+
* 2. Background ~= accent. If the theme's background and accent are nearly the
|
|
18
|
+
* same color, background-on-accent text has no contrast.
|
|
19
|
+
* In either case we fall back to a black/white pick that is guaranteed visible
|
|
20
|
+
* on the always-opaque accent.
|
|
21
21
|
*
|
|
22
|
-
* `RGBA` channels from @opentui/core are normalized 0..1 floats
|
|
23
|
-
* minimal `{ r, g, b }` shape so this stays a pure, trivially
|
|
24
|
-
* independent of the native color class
|
|
22
|
+
* `RGBA` channels from @opentui/core are normalized 0..1 floats (alpha included).
|
|
23
|
+
* We accept the minimal `{ r, g, b, a? }` shape so this stays a pure, trivially
|
|
24
|
+
* testable function independent of the native color class, and we return the
|
|
25
|
+
* passed-in `background` object unchanged on the primary path so it stays the
|
|
26
|
+
* exact same theme token AFT uses.
|
|
25
27
|
*/
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
//
|
|
29
|
+
type Color = { r: number; g: number; b: number; a?: number };
|
|
30
|
+
|
|
31
|
+
// Below this alpha the theme background is too transparent to read as a label on
|
|
32
|
+
// the accent (issue #186: background:"none" resolves to alpha 0).
|
|
33
|
+
const MIN_OPAQUE_ALPHA = 0.5;
|
|
34
|
+
|
|
35
|
+
// If the theme background and accent are within this per-channel distance they
|
|
36
|
+
// are effectively the same color, so background-on-accent text is unreadable.
|
|
37
|
+
const MIN_CHANNEL_DISTANCE = 0.06;
|
|
38
|
+
|
|
39
|
+
// Luminance midpoint for the fallback pick: accents below this keep white text,
|
|
40
|
+
// accents at/above it (light/pastel/near-white) get black. White-biased relative
|
|
41
|
+
// to the strict equal-contrast crossover (~0.179) so saturated mid-tone accents
|
|
42
|
+
// stay white. Only consulted on the degenerate fallback path.
|
|
30
43
|
const LIGHT_ACCENT_LUMINANCE = 0.5;
|
|
31
44
|
|
|
32
45
|
function srgbChannelToLinear(c: number): number {
|
|
33
46
|
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
34
47
|
}
|
|
35
48
|
|
|
36
|
-
function relativeLuminance(bg:
|
|
49
|
+
function relativeLuminance(bg: Color): number {
|
|
37
50
|
return (
|
|
38
51
|
0.2126 * srgbChannelToLinear(bg.r) +
|
|
39
52
|
0.7152 * srgbChannelToLinear(bg.g) +
|
|
@@ -41,6 +54,31 @@ function relativeLuminance(bg: { r: number; g: number; b: number }): number {
|
|
|
41
54
|
);
|
|
42
55
|
}
|
|
43
56
|
|
|
44
|
-
|
|
57
|
+
function nearlyEqual(a: Color, b: Color): boolean {
|
|
58
|
+
return (
|
|
59
|
+
Math.abs(a.r - b.r) < MIN_CHANNEL_DISTANCE &&
|
|
60
|
+
Math.abs(a.g - b.g) < MIN_CHANNEL_DISTANCE &&
|
|
61
|
+
Math.abs(a.b - b.b) < MIN_CHANNEL_DISTANCE
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Pure black/white pick by accent luminance. Used as the badge fallback and kept
|
|
67
|
+
* exported for callers that only have the accent.
|
|
68
|
+
*/
|
|
69
|
+
export function readableTextColorOn(bg: Color): string {
|
|
45
70
|
return relativeLuminance(bg) < LIGHT_ACCENT_LUMINANCE ? "#ffffff" : "#000000";
|
|
46
71
|
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Badge label color on the accent: the theme background (AFT parity) when it is
|
|
75
|
+
* usable, else a guaranteed-visible black/white fallback. Returns the passed-in
|
|
76
|
+
* `background` reference unchanged on the primary path.
|
|
77
|
+
*/
|
|
78
|
+
export function badgeTextColor<T extends Color>(accent: T, background: T): T | string {
|
|
79
|
+
const alpha = background.a ?? 1;
|
|
80
|
+
if (alpha >= MIN_OPAQUE_ALPHA && !nearlyEqual(accent, background)) {
|
|
81
|
+
return background;
|
|
82
|
+
}
|
|
83
|
+
return readableTextColorOn(accent);
|
|
84
|
+
}
|
|
@@ -5,18 +5,12 @@
|
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { MagicContextRpcClient } from "../../shared/rpc-client";
|
|
8
|
-
import type {
|
|
9
|
-
EmbedDetail,
|
|
10
|
-
RpcNotificationMessage,
|
|
11
|
-
SidebarSnapshot,
|
|
12
|
-
StatusDetail,
|
|
13
|
-
} from "../../shared/rpc-types";
|
|
8
|
+
import type { EmbedDetail, SidebarSnapshot, StatusDetail } from "../../shared/rpc-types";
|
|
14
9
|
|
|
15
10
|
export type { EmbedDetail, SidebarSnapshot, StatusDetail };
|
|
16
11
|
|
|
17
12
|
let rpcClient: MagicContextRpcClient | null = null;
|
|
18
13
|
let rpcGeneration = 0;
|
|
19
|
-
const lastReceivedNotificationIdBySession = new Map<string, number>();
|
|
20
14
|
|
|
21
15
|
function getStorageDir(): string {
|
|
22
16
|
// Plugin v0.16+ uses the shared cortexkit/magic-context path so OpenCode
|
|
@@ -31,9 +25,9 @@ function getStorageDir(): string {
|
|
|
31
25
|
export function initRpcClient(directory: string): void {
|
|
32
26
|
const storageDir = getStorageDir();
|
|
33
27
|
// Bump the generation before replacing the client so late notification
|
|
34
|
-
// responses from a disposed client
|
|
28
|
+
// responses from a disposed client are ignored (the WS socket observes the
|
|
29
|
+
// new generation and abandons its in-flight connect).
|
|
35
30
|
rpcGeneration += 1;
|
|
36
|
-
lastReceivedNotificationIdBySession.clear();
|
|
37
31
|
rpcClient = new MagicContextRpcClient(storageDir, directory);
|
|
38
32
|
}
|
|
39
33
|
|
|
@@ -41,14 +35,19 @@ export function getRpcGeneration(): number {
|
|
|
41
35
|
return rpcGeneration;
|
|
42
36
|
}
|
|
43
37
|
|
|
38
|
+
/** The live RPC client (for the WS notification socket's endpoint discovery).
|
|
39
|
+
* Null before init / after close. */
|
|
40
|
+
export function getRpcClient(): MagicContextRpcClient | null {
|
|
41
|
+
return rpcClient;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
44
|
/** Clean up the RPC client. */
|
|
45
45
|
export function closeRpc(): void {
|
|
46
46
|
// Closing invalidates any already-issued RPC calls; their callbacks must
|
|
47
|
-
// observe the new generation and
|
|
47
|
+
// observe the new generation and abandon (the WS socket checks it too).
|
|
48
48
|
rpcGeneration += 1;
|
|
49
49
|
rpcClient?.reset();
|
|
50
50
|
rpcClient = null;
|
|
51
|
-
lastReceivedNotificationIdBySession.clear();
|
|
52
51
|
}
|
|
53
52
|
|
|
54
53
|
const EMPTY_SNAPSHOT: SidebarSnapshot = {
|
|
@@ -311,13 +310,6 @@ export async function loadToastDurationMs(): Promise<number> {
|
|
|
311
310
|
}
|
|
312
311
|
}
|
|
313
312
|
|
|
314
|
-
export interface TuiMessage {
|
|
315
|
-
id: number;
|
|
316
|
-
type: string;
|
|
317
|
-
payload: Record<string, unknown>;
|
|
318
|
-
sessionId?: string;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
313
|
/**
|
|
322
314
|
* Fetch the current startup announcement from the server, if any.
|
|
323
315
|
* Returns `{show: false}` when there's nothing to announce or when the
|
|
@@ -360,49 +352,3 @@ export async function markAnnounced(): Promise<boolean> {
|
|
|
360
352
|
return false;
|
|
361
353
|
}
|
|
362
354
|
}
|
|
363
|
-
|
|
364
|
-
/** Poll for pending server→TUI notifications via RPC. */
|
|
365
|
-
export async function consumeTuiMessages(sessionId: string): Promise<TuiMessage[]> {
|
|
366
|
-
if (!rpcClient) return [];
|
|
367
|
-
try {
|
|
368
|
-
const result = await rpcClient.call<{ messages: RpcNotificationMessage[] }>(
|
|
369
|
-
"pending-notifications",
|
|
370
|
-
// Pass the TUI's active session so the server only drains
|
|
371
|
-
// notifications scoped to it (or global ones). Without this, a
|
|
372
|
-
// notification for another session served by the same process (e.g.
|
|
373
|
-
// OpenCode Desktop on the same project) could surface here. The
|
|
374
|
-
// cursor is per-session and is advanced by the poller only after it
|
|
375
|
-
// has delivered the returned batch.
|
|
376
|
-
{
|
|
377
|
-
lastReceivedId: lastReceivedNotificationIdBySession.get(sessionId) ?? 0,
|
|
378
|
-
sessionId,
|
|
379
|
-
},
|
|
380
|
-
);
|
|
381
|
-
return (result.messages ?? []).map((m) => ({
|
|
382
|
-
id: m.id,
|
|
383
|
-
type: m.type,
|
|
384
|
-
payload: m.payload,
|
|
385
|
-
sessionId: m.sessionId,
|
|
386
|
-
}));
|
|
387
|
-
} catch {
|
|
388
|
-
return [];
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
* Advance the delivered-message cursor for one active TUI session.
|
|
394
|
-
* Callers must pass only the contiguous handled prefix of the drained batch;
|
|
395
|
-
* this helper remains empty-safe and monotonic for that prefix.
|
|
396
|
-
*/
|
|
397
|
-
export function markTuiMessagesHandled(sessionId: string, messages: TuiMessage[]): void {
|
|
398
|
-
const previous = lastReceivedNotificationIdBySession.get(sessionId) ?? 0;
|
|
399
|
-
let next = previous;
|
|
400
|
-
for (const message of messages) {
|
|
401
|
-
if (message.id > next) {
|
|
402
|
-
next = message.id;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
if (next > previous) {
|
|
406
|
-
lastReceivedNotificationIdBySession.set(sessionId, next);
|
|
407
|
-
}
|
|
408
|
-
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent WebSocket to the server plugin's RPC server, replacing the old
|
|
3
|
+
* 500ms HTTP notification poll.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: the TUI plugin and the server plugin run in separate Bun
|
|
6
|
+
* runners in the same process, so they bridge over a localhost socket. The old
|
|
7
|
+
* bridge polled `pending-notifications` over HTTP every 500ms — and each poll
|
|
8
|
+
* opened a NEW loopback TCP connection (Bun's fetch isn't pooled to our server),
|
|
9
|
+
* which was the entire source of idle TUI CPU (#200). A single long-lived WS
|
|
10
|
+
* carries server→TUI pushes with zero per-event connection cost, and the server
|
|
11
|
+
* pushes notifications the instant they're queued (no polling latency).
|
|
12
|
+
*
|
|
13
|
+
* Session scope: the socket carries the TUI's active session in its `hello` so
|
|
14
|
+
* the server delivers only that session's (plus global) notifications and its
|
|
15
|
+
* `isTuiConnected(session)` routing stays correct. The active session is tracked
|
|
16
|
+
* with a cheap watcher that only reads `api.route.current` (a property access,
|
|
17
|
+
* no IPC) and re-scopes the socket ONLY when the session actually changes — so
|
|
18
|
+
* unlike the old poll it does no network work at idle.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { getRpcClient, getRpcGeneration } from "./context-db";
|
|
22
|
+
|
|
23
|
+
export interface SocketNotification {
|
|
24
|
+
id: number;
|
|
25
|
+
type: string;
|
|
26
|
+
payload: Record<string, unknown>;
|
|
27
|
+
sessionId?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface NotificationSocketOptions {
|
|
31
|
+
/** Current active session id (re-read cheaply to follow session switches). */
|
|
32
|
+
getSessionId: () => string | null;
|
|
33
|
+
/** Handle one delivered notification. Returns true if it was consumed (so its
|
|
34
|
+
* id can advance the ack cursor). Async because dialog handlers await. */
|
|
35
|
+
onNotification: (notification: SocketNotification) => boolean | Promise<boolean>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const RECONNECT_BASE_MS = 500;
|
|
39
|
+
const RECONNECT_MAX_MS = 10_000;
|
|
40
|
+
/** Cheap session-watch interval. Reads a property only; no network. The CPU bug
|
|
41
|
+
* was the per-tick fetch, not the timer — this tick does zero IPC at idle. */
|
|
42
|
+
const SESSION_WATCH_MS = 1_000;
|
|
43
|
+
|
|
44
|
+
let socket: WebSocket | null = null;
|
|
45
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
46
|
+
let sessionWatchTimer: ReturnType<typeof setInterval> | undefined;
|
|
47
|
+
let reconnectAttempt = 0;
|
|
48
|
+
let closed = false;
|
|
49
|
+
let helloedSession: string | null = null;
|
|
50
|
+
let opts: NotificationSocketOptions | null = null;
|
|
51
|
+
/** Generation of the rpc client at connect time; a dispose/reinit bumps it and
|
|
52
|
+
* invalidates an in-flight socket so its late callbacks are ignored. */
|
|
53
|
+
let connectGeneration = 0;
|
|
54
|
+
/** Highest handled notification id (sent in hello so a reconnect re-delivers the
|
|
55
|
+
* unhandled backlog; sent in ack so the server prunes the queue). */
|
|
56
|
+
let lastHandledId = 0;
|
|
57
|
+
|
|
58
|
+
/** Open the persistent notification socket. Idempotent: a second call while open
|
|
59
|
+
* is a no-op. Reconnects on its own after any drop. */
|
|
60
|
+
export function startNotificationSocket(options: NotificationSocketOptions): void {
|
|
61
|
+
opts = options;
|
|
62
|
+
closed = false;
|
|
63
|
+
connectGeneration = getRpcGeneration();
|
|
64
|
+
connect();
|
|
65
|
+
if (!sessionWatchTimer) {
|
|
66
|
+
sessionWatchTimer = setInterval(watchSession, SESSION_WATCH_MS);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Close the socket and stop reconnecting. Call on TUI dispose. */
|
|
71
|
+
export function stopNotificationSocket(): void {
|
|
72
|
+
closed = true;
|
|
73
|
+
if (reconnectTimer) {
|
|
74
|
+
clearTimeout(reconnectTimer);
|
|
75
|
+
reconnectTimer = undefined;
|
|
76
|
+
}
|
|
77
|
+
if (sessionWatchTimer) {
|
|
78
|
+
clearInterval(sessionWatchTimer);
|
|
79
|
+
sessionWatchTimer = undefined;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
socket?.close();
|
|
83
|
+
} catch {
|
|
84
|
+
// best-effort
|
|
85
|
+
}
|
|
86
|
+
socket = null;
|
|
87
|
+
helloedSession = null;
|
|
88
|
+
reconnectAttempt = 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function scheduleReconnect(): void {
|
|
92
|
+
if (closed) return;
|
|
93
|
+
if (reconnectTimer) return;
|
|
94
|
+
const delay = Math.min(RECONNECT_BASE_MS * 2 ** reconnectAttempt, RECONNECT_MAX_MS);
|
|
95
|
+
reconnectAttempt += 1;
|
|
96
|
+
reconnectTimer = setTimeout(() => {
|
|
97
|
+
reconnectTimer = undefined;
|
|
98
|
+
connect();
|
|
99
|
+
}, delay);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function connect(): Promise<void> {
|
|
103
|
+
if (closed) return;
|
|
104
|
+
if (socket) return; // already connected/connecting
|
|
105
|
+
|
|
106
|
+
const client = getRpcClient();
|
|
107
|
+
if (!client) {
|
|
108
|
+
scheduleReconnect();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const endpoint = await client.resolveEndpoint();
|
|
112
|
+
// The generation may have bumped (dispose/reinit) while resolving — abandon.
|
|
113
|
+
if (closed || getRpcGeneration() !== connectGeneration) return;
|
|
114
|
+
if (!endpoint) {
|
|
115
|
+
scheduleReconnect();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let ws: WebSocket;
|
|
120
|
+
try {
|
|
121
|
+
ws = new WebSocket(`ws://127.0.0.1:${endpoint.port}/ws`);
|
|
122
|
+
} catch {
|
|
123
|
+
scheduleReconnect();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
socket = ws;
|
|
127
|
+
|
|
128
|
+
ws.addEventListener("open", () => {
|
|
129
|
+
if (socket !== ws) return;
|
|
130
|
+
reconnectAttempt = 0;
|
|
131
|
+
sendHello(ws, endpoint.token);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
ws.addEventListener("message", (event) => {
|
|
135
|
+
if (socket !== ws) return;
|
|
136
|
+
void handleSocketMessage(ws, String((event as MessageEvent).data));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const onDown = () => {
|
|
140
|
+
if (socket === ws) {
|
|
141
|
+
socket = null;
|
|
142
|
+
helloedSession = null;
|
|
143
|
+
}
|
|
144
|
+
scheduleReconnect();
|
|
145
|
+
};
|
|
146
|
+
ws.addEventListener("close", onDown);
|
|
147
|
+
ws.addEventListener("error", onDown);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function sendHello(ws: WebSocket, token: string | null): void {
|
|
151
|
+
const sessionId = opts?.getSessionId() ?? undefined;
|
|
152
|
+
helloedSession = sessionId ?? null;
|
|
153
|
+
ws.send(
|
|
154
|
+
JSON.stringify({
|
|
155
|
+
type: "hello",
|
|
156
|
+
token: token ?? "",
|
|
157
|
+
sessionId,
|
|
158
|
+
lastReceivedId: lastHandledId,
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function handleSocketMessage(ws: WebSocket, raw: string): Promise<void> {
|
|
164
|
+
let msg: { type?: string; notification?: SocketNotification; error?: string };
|
|
165
|
+
try {
|
|
166
|
+
msg = JSON.parse(raw);
|
|
167
|
+
} catch {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (msg.type === "notification" && msg.notification) {
|
|
172
|
+
const notification = msg.notification;
|
|
173
|
+
// Client-side session filter mirrors the old poller's per-message re-check:
|
|
174
|
+
// a session-scoped notification is only acted on while the TUI is actually
|
|
175
|
+
// viewing that session (the active session can change between queueing and
|
|
176
|
+
// delivery). Global (session-less) notifications always apply.
|
|
177
|
+
const active = opts?.getSessionId() ?? null;
|
|
178
|
+
if (notification.sessionId && active && notification.sessionId !== active) {
|
|
179
|
+
// Not for the session we're viewing — do NOT ack it (a TUI on the right
|
|
180
|
+
// session, or a later switch back, should still get it). Just skip.
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
let consumed = false;
|
|
184
|
+
try {
|
|
185
|
+
consumed = await Promise.resolve(opts?.onNotification(notification) ?? false);
|
|
186
|
+
} catch {
|
|
187
|
+
consumed = false;
|
|
188
|
+
}
|
|
189
|
+
// A dispose/reinit during an awaited dialog handler invalidates this socket.
|
|
190
|
+
if (socket !== ws || getRpcGeneration() !== connectGeneration) return;
|
|
191
|
+
if (consumed && notification.id > lastHandledId) {
|
|
192
|
+
lastHandledId = notification.id;
|
|
193
|
+
// Ack so the server prunes the queue during this long-lived connection.
|
|
194
|
+
// (The id also rides the next reconnect hello via lastReceivedId, so a
|
|
195
|
+
// dropped ack just re-delivers an already-consumed item, which the
|
|
196
|
+
// handlers are idempotent against.)
|
|
197
|
+
try {
|
|
198
|
+
ws.send(JSON.stringify({ type: "ack", lastReceivedId: lastHandledId }));
|
|
199
|
+
} catch {
|
|
200
|
+
// best-effort; reconnect hello re-syncs via lastReceivedId
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (msg.type === "error") {
|
|
207
|
+
// Server rejected us (bad token, etc.). Close and let backoff retry after
|
|
208
|
+
// rediscovering the port/token (the server may have been replaced).
|
|
209
|
+
try {
|
|
210
|
+
ws.close();
|
|
211
|
+
} catch {
|
|
212
|
+
// best-effort
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Cheap session-change watcher: re-scope the socket only when the active session
|
|
218
|
+
* actually changes. Reads a property; no network at idle. */
|
|
219
|
+
function watchSession(): void {
|
|
220
|
+
if (closed || !socket || socket.readyState !== WebSocket.OPEN) return;
|
|
221
|
+
const current = opts?.getSessionId() ?? null;
|
|
222
|
+
if (current === helloedSession) return;
|
|
223
|
+
// Re-hello with the new session; the server replaces this socket's sink scope.
|
|
224
|
+
const client = getRpcClient();
|
|
225
|
+
void client?.resolveEndpoint().then((endpoint) => {
|
|
226
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
|
227
|
+
sendHello(socket, endpoint?.token ?? null);
|
|
228
|
+
});
|
|
229
|
+
}
|
package/src/tui/index.tsx
CHANGED
|
@@ -6,7 +6,8 @@ import { createMemo } from "solid-js"
|
|
|
6
6
|
import type { TuiPlugin, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
|
|
7
7
|
import { createSidebarContentSlot, kickRecompProgressRefresh } from "./slots/sidebar-content"
|
|
8
8
|
import packageJson from "../../package.json"
|
|
9
|
-
import { closeRpc,
|
|
9
|
+
import { closeRpc, dismissUpgradeReminder, getAnnouncement, getCompartmentCount, getRpcGeneration, initRpcClient, loadEmbedDetail, loadStatusDetail, loadToastDurationMs, markAnnounced, requestRecomp, requestUpgrade, type EmbedDetail, type StatusDetail } from "./data/context-db"
|
|
10
|
+
import { startNotificationSocket, stopNotificationSocket, type SocketNotification } from "./data/notification-socket"
|
|
10
11
|
import { formatThresholdPercent } from "../shared/format-threshold"
|
|
11
12
|
import { detectConflicts } from "../shared/conflict-detector"
|
|
12
13
|
import { fixConflicts } from "../shared/conflict-fixer"
|
|
@@ -869,128 +870,77 @@ const tui: TuiPlugin = async (api, _options, meta) => {
|
|
|
869
870
|
// only when keymap is missing.
|
|
870
871
|
registerCommandPaletteEntries(api)
|
|
871
872
|
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
-
//
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
// here. Do not poll on non-session routes: a session-scoped action fetched
|
|
880
|
-
// while sessionless could otherwise be acked without being shown.
|
|
881
|
-
// Avoid overlapping read-only drains: the server re-delivers until acked,
|
|
882
|
-
// so a second in-flight poll can fetch and dispatch the same batch twice.
|
|
883
|
-
if (pollInFlight) return
|
|
884
|
-
|
|
873
|
+
// Receive server→TUI notifications (toasts + dialog requests) over a single
|
|
874
|
+
// persistent WebSocket, pushed the instant the server queues them. This
|
|
875
|
+
// replaces the old 500ms HTTP poll whose new-connection-per-tick cost was the
|
|
876
|
+
// source of idle TUI CPU (#200). The socket carries the active session in its
|
|
877
|
+
// hello so the server scopes delivery; here we re-check the active session per
|
|
878
|
+
// notification (it can change between queue and delivery) before acting.
|
|
879
|
+
const handleNotification = async (n: SocketNotification): Promise<boolean> => {
|
|
885
880
|
const requestedSessionId = getSessionId(api)
|
|
886
|
-
|
|
881
|
+
const generation = getRpcGeneration()
|
|
882
|
+
// A session-scoped notification only applies while we're viewing that
|
|
883
|
+
// session; global (session-less) ones always apply. Returning false leaves
|
|
884
|
+
// it unacked so a TUI on the right session (or a later switch back) still
|
|
885
|
+
// gets it.
|
|
886
|
+
if (n.sessionId && requestedSessionId && n.sessionId !== requestedSessionId) {
|
|
887
|
+
return false
|
|
888
|
+
}
|
|
889
|
+
if (n.type === "toast") {
|
|
890
|
+
const p = n.payload
|
|
891
|
+
showToast(api, {
|
|
892
|
+
message: String(p.message ?? ""),
|
|
893
|
+
variant: (p.variant as "info" | "warning" | "error" | "success") ?? "info",
|
|
894
|
+
durationOverrideMs:
|
|
895
|
+
typeof p.duration === "number" && Number.isFinite(p.duration)
|
|
896
|
+
? p.duration
|
|
897
|
+
: undefined,
|
|
898
|
+
})
|
|
899
|
+
return true
|
|
900
|
+
}
|
|
901
|
+
if (n.type !== "action") return false
|
|
902
|
+
const action = n.payload?.action
|
|
903
|
+
const stillActive = () =>
|
|
904
|
+
getRpcGeneration() === generation && getSessionId(api) === requestedSessionId
|
|
905
|
+
if (action === "show-status-dialog") {
|
|
906
|
+
return stillActive() && (await showStatusDialog(api, requestedSessionId))
|
|
907
|
+
}
|
|
908
|
+
if (action === "show-recomp-dialog") {
|
|
909
|
+
return stillActive() && (await showRecompDialog(api, requestedSessionId))
|
|
910
|
+
}
|
|
911
|
+
if (action === "show-upgrade-dialog") {
|
|
912
|
+
const resume =
|
|
913
|
+
n.payload?.resume === true
|
|
914
|
+
? {
|
|
915
|
+
stagedCount: Number(n.payload?.stagedCount ?? 0),
|
|
916
|
+
stagedThrough: Number(n.payload?.stagedThrough ?? 0),
|
|
917
|
+
}
|
|
918
|
+
: undefined
|
|
919
|
+
return stillActive() && showUpgradeDialog(api, resume, requestedSessionId)
|
|
920
|
+
}
|
|
921
|
+
if (action === "show-embed-dialog") {
|
|
922
|
+
return stillActive() && (await showEmbedDialog(api, requestedSessionId))
|
|
923
|
+
}
|
|
924
|
+
if (action === "show-flush-dialog") {
|
|
925
|
+
const flushMsg = String(n.payload?.message ?? "Flushed.")
|
|
926
|
+
return stillActive() && showResultDialog(api, "Flush", flushMsg)
|
|
927
|
+
}
|
|
928
|
+
if (action === "show-result-dialog") {
|
|
929
|
+
const title = String(n.payload?.title ?? "Magic Context")
|
|
930
|
+
const body = String(n.payload?.message ?? "")
|
|
931
|
+
return stillActive() && showResultDialog(api, title, body)
|
|
932
|
+
}
|
|
933
|
+
return false
|
|
934
|
+
}
|
|
887
935
|
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
void refreshToastDurationMs()
|
|
893
|
-
}
|
|
894
|
-
// The dialog handlers read the current session when they run. If the
|
|
895
|
-
// user switched routes while the RPC was in flight, drop this whole
|
|
896
|
-
// batch without advancing the cursor; the next poll for the new
|
|
897
|
-
// session will fetch the right notifications.
|
|
898
|
-
// Ignore late responses from an older RPC client generation; close/init
|
|
899
|
-
// clears cursors and stale callbacks must not recreate them.
|
|
900
|
-
if (getRpcGeneration() !== pollGeneration) return
|
|
901
|
-
|
|
902
|
-
if (getSessionId(api) !== requestedSessionId) return
|
|
903
|
-
|
|
904
|
-
const orderedMessages = [...messages].sort((a, b) => a.id - b.id)
|
|
905
|
-
const handledMessageIds = new Set<number>()
|
|
906
|
-
for (const msg of orderedMessages) {
|
|
907
|
-
// A dialog helper earlier in this batch may have awaited; re-check
|
|
908
|
-
// the route before EACH message so a later action/toast in the same
|
|
909
|
-
// batch can't paint into a session the user switched to mid-batch
|
|
910
|
-
// (the pre-batch + pre-ack guards alone don't cover mid-batch awaits).
|
|
911
|
-
if (getRpcGeneration() !== pollGeneration) return
|
|
912
|
-
if (getSessionId(api) !== requestedSessionId) return
|
|
913
|
-
// Drop any action/dialog whose sessionId doesn't match this TUI's
|
|
914
|
-
// active session (session-less/global notifications still apply).
|
|
915
|
-
if (
|
|
916
|
-
msg.type === "action" &&
|
|
917
|
-
msg.sessionId &&
|
|
918
|
-
msg.sessionId !== requestedSessionId
|
|
919
|
-
) {
|
|
920
|
-
continue
|
|
921
|
-
}
|
|
922
|
-
if (msg.type === "toast") {
|
|
923
|
-
const p = msg.payload
|
|
924
|
-
showToast(api, {
|
|
925
|
-
message: String(p.message ?? ""),
|
|
926
|
-
variant: (p.variant as "info" | "warning" | "error" | "success") ?? "info",
|
|
927
|
-
durationOverrideMs:
|
|
928
|
-
typeof p.duration === "number" && Number.isFinite(p.duration)
|
|
929
|
-
? p.duration
|
|
930
|
-
: undefined,
|
|
931
|
-
})
|
|
932
|
-
handledMessageIds.add(msg.id)
|
|
933
|
-
} else if (msg.type === "action") {
|
|
934
|
-
const action = msg.payload?.action
|
|
935
|
-
if (action === "show-status-dialog") {
|
|
936
|
-
if (await showStatusDialog(api, requestedSessionId)) {
|
|
937
|
-
handledMessageIds.add(msg.id)
|
|
938
|
-
}
|
|
939
|
-
} else if (action === "show-recomp-dialog") {
|
|
940
|
-
if (await showRecompDialog(api, requestedSessionId)) {
|
|
941
|
-
handledMessageIds.add(msg.id)
|
|
942
|
-
}
|
|
943
|
-
} else if (action === "show-upgrade-dialog") {
|
|
944
|
-
const resume =
|
|
945
|
-
msg.payload?.resume === true
|
|
946
|
-
? {
|
|
947
|
-
stagedCount: Number(msg.payload?.stagedCount ?? 0),
|
|
948
|
-
stagedThrough: Number(msg.payload?.stagedThrough ?? 0),
|
|
949
|
-
}
|
|
950
|
-
: undefined
|
|
951
|
-
if (showUpgradeDialog(api, resume, requestedSessionId)) {
|
|
952
|
-
handledMessageIds.add(msg.id)
|
|
953
|
-
}
|
|
954
|
-
} else if (action === "show-embed-dialog") {
|
|
955
|
-
if (await showEmbedDialog(api, requestedSessionId)) {
|
|
956
|
-
handledMessageIds.add(msg.id)
|
|
957
|
-
}
|
|
958
|
-
} else if (action === "show-flush-dialog") {
|
|
959
|
-
const flushMsg = String(msg.payload?.message ?? "Flushed.")
|
|
960
|
-
if (showResultDialog(api, "Flush", flushMsg)) {
|
|
961
|
-
handledMessageIds.add(msg.id)
|
|
962
|
-
}
|
|
963
|
-
} else if (action === "show-result-dialog") {
|
|
964
|
-
const title = String(msg.payload?.title ?? "Magic Context")
|
|
965
|
-
const body = String(msg.payload?.message ?? "")
|
|
966
|
-
if (showResultDialog(api, title, body)) {
|
|
967
|
-
handledMessageIds.add(msg.id)
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
const handledPrefixMessages: TuiMessage[] = []
|
|
973
|
-
for (const msg of orderedMessages) {
|
|
974
|
-
if (!handledMessageIds.has(msg.id)) break
|
|
975
|
-
handledPrefixMessages.push(msg)
|
|
976
|
-
}
|
|
977
|
-
// A dialog helper may have awaited more RPC work; re-check before
|
|
978
|
-
// acking so a dispose/reinit or route switch during that await cannot
|
|
979
|
-
// advance a stale cursor.
|
|
980
|
-
if (getRpcGeneration() !== pollGeneration) return
|
|
981
|
-
if (getSessionId(api) !== requestedSessionId) return
|
|
982
|
-
|
|
983
|
-
markTuiMessagesHandled(requestedSessionId, handledPrefixMessages)
|
|
984
|
-
}).catch(() => {
|
|
985
|
-
// Intentional: message polling should never crash the TUI
|
|
986
|
-
}).finally(() => {
|
|
987
|
-
pollInFlight = false
|
|
988
|
-
})
|
|
989
|
-
}, 500)
|
|
936
|
+
startNotificationSocket({
|
|
937
|
+
getSessionId: () => getSessionId(api),
|
|
938
|
+
onNotification: handleNotification,
|
|
939
|
+
})
|
|
990
940
|
|
|
991
941
|
// Clean up on dispose
|
|
992
942
|
api.lifecycle.onDispose(() => {
|
|
993
|
-
|
|
943
|
+
stopNotificationSocket()
|
|
994
944
|
closeRpc()
|
|
995
945
|
})
|
|
996
946
|
|