@sjawhar/opencode-legion-envoy 0.1.9 → 0.2.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/AGENTS.md +3 -2
- package/bin/dispatch-mcp-shim.ts +75 -0
- package/package.json +18 -5
- package/scripts/sync-host.sh +18 -1
- package/src/__tests__/clipboard.test.ts +56 -0
- package/src/__tests__/dispatch-mcp-bridge.test.ts +339 -0
- package/src/__tests__/dispatch-mcp.test.ts +116 -0
- package/src/__tests__/dispatch-subscribe.test.ts +66 -0
- package/src/__tests__/index.test.ts +433 -8
- package/src/__tests__/log.test.ts +135 -0
- package/src/__tests__/tui-port.test.ts +85 -0
- package/src/clipboard.ts +140 -0
- package/src/config/__tests__/index.test.ts +93 -0
- package/src/config/index.ts +62 -0
- package/src/config/schema.ts +26 -0
- package/src/dispatch-mcp-bridge.ts +310 -0
- package/src/dispatch-mcp.ts +102 -0
- package/src/dispatch-subscribe.ts +52 -0
- package/src/log.ts +86 -0
- package/src/{index.ts → server.ts} +168 -45
- package/src/tui-port.ts +83 -0
- package/src/tui.tsx +122 -0
- package/tsconfig.json +3 -1
- package/dist/index.js +0 -12633
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin/tool";
|
|
2
|
+
import { loadEnvoyConfig } from "./config";
|
|
3
|
+
import { buildDispatchMcpEntry, injectEnvoyMcp } from "./dispatch-mcp";
|
|
4
|
+
import { dispatchSubscriptionTopic } from "./dispatch-subscribe";
|
|
5
|
+
import { logger } from "./log";
|
|
2
6
|
import { resolvePort } from "./port";
|
|
3
7
|
|
|
4
8
|
const root = process.env.ENVOY_URL ?? "http://127.0.0.1:9020";
|
|
@@ -17,8 +21,17 @@ async function call(path: string, init?: RequestInit) {
|
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
export default async (input: { serverUrl: URL }) => {
|
|
24
|
+
const cwd = process.cwd();
|
|
25
|
+
const config = await loadEnvoyConfig(cwd);
|
|
20
26
|
let activeSessionID: string | null = null;
|
|
21
27
|
let activeSessionTitle: string | null = null;
|
|
28
|
+
// All sessions that have become busy in this serve instance. The heartbeat
|
|
29
|
+
// refreshes the envoy_sessions TTL for ALL of them — a single serve hosts many
|
|
30
|
+
// sessions, so tracking only the most-recently-active one lets idle siblings
|
|
31
|
+
// expire out of the registry and become undeliverable.
|
|
32
|
+
const trackedSessions = new Map<string, { title: string | null }>();
|
|
33
|
+
// Guard so sibling re-adoption (after a serve restart) runs at most once.
|
|
34
|
+
let readoptDone = false;
|
|
22
35
|
/** Cached port — resolved asynchronously, null until first successful resolution. */
|
|
23
36
|
let resolvedPort: number | null = null;
|
|
24
37
|
|
|
@@ -27,7 +40,7 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
27
40
|
const port = await resolvePort(input.serverUrl);
|
|
28
41
|
if (!port && !portWarningLogged) {
|
|
29
42
|
portWarningLogged = true;
|
|
30
|
-
|
|
43
|
+
logger.error(
|
|
31
44
|
`[envoy-plugin] Could not resolve serve port: serverUrl=${input.serverUrl.href}, pid=${process.pid}`
|
|
32
45
|
);
|
|
33
46
|
}
|
|
@@ -71,26 +84,70 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
71
84
|
// Fire an immediate async attempt (non-blocking)
|
|
72
85
|
syncPort().catch(() => {});
|
|
73
86
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
87
|
+
const subscribeSession = (sessionID: string, title: string | null, port: number) =>
|
|
88
|
+
call("/v1/interests/subscribe", {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { "Content-Type": "application/json" },
|
|
91
|
+
body: JSON.stringify({
|
|
92
|
+
session_id: sessionID,
|
|
93
|
+
dir: cwd,
|
|
94
|
+
topics: [`notifications.agent.${sessionID}`],
|
|
95
|
+
port,
|
|
96
|
+
title: title ?? "",
|
|
97
|
+
}),
|
|
98
|
+
}).catch(() => {});
|
|
99
|
+
|
|
100
|
+
// After a serve restart, sessions that were live in the previous serve instance
|
|
101
|
+
// do NOT re-register on their own (registration is gated on a session going
|
|
102
|
+
// busy), so an idle session waiting to RECEIVE a message silently falls out of
|
|
103
|
+
// the registry. Recover them once, on first activity: read the live registry and
|
|
104
|
+
// re-subscribe siblings that share this serve's machine + dir at the new port.
|
|
105
|
+
const readoptSiblings = async (selfSessionID: string) => {
|
|
106
|
+
if (readoptDone) return;
|
|
107
|
+
const port = currentPort();
|
|
108
|
+
if (!port) return;
|
|
109
|
+
try {
|
|
110
|
+
const res = await call("/v1/sessions");
|
|
111
|
+
const sessions = JSON.parse(res) as Array<{
|
|
112
|
+
session_id: string;
|
|
113
|
+
machine_id: string;
|
|
114
|
+
dir: string;
|
|
115
|
+
title?: string;
|
|
116
|
+
}>;
|
|
117
|
+
// Authoritative machine id for this serve = the listener-stamped machine of
|
|
118
|
+
// our own active session. Only adopt siblings that match it (and our dir) to
|
|
119
|
+
// avoid hijacking a same-path session that lives on another machine.
|
|
120
|
+
const self = sessions.find((s) => s.session_id === selfSessionID);
|
|
121
|
+
if (!self) return;
|
|
122
|
+
readoptDone = true;
|
|
123
|
+
for (const s of sessions) {
|
|
124
|
+
if (s.machine_id !== self.machine_id) continue;
|
|
125
|
+
if (s.dir !== cwd) continue;
|
|
126
|
+
if (trackedSessions.has(s.session_id)) continue;
|
|
127
|
+
trackedSessions.set(s.session_id, { title: s.title ?? null });
|
|
128
|
+
subscribeSession(s.session_id, s.title ?? null, port);
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Heartbeat: re-subscribe every tracked session to refresh the envoy_sessions
|
|
134
|
+
// TTL (5-min). Refreshes ALL sessions that have been busy in this serve, not
|
|
135
|
+
// just the most recently active one. Interval is env-tunable for tests/tuning.
|
|
136
|
+
const rawHeartbeatMs = Number(process.env.ENVOY_HEARTBEAT_MS);
|
|
137
|
+
const heartbeatMs =
|
|
138
|
+
Number.isFinite(rawHeartbeatMs) && rawHeartbeatMs > 0
|
|
139
|
+
? Math.max(rawHeartbeatMs, 25)
|
|
140
|
+
: 2 * 60 * 1000;
|
|
141
|
+
const heartbeatInterval = setInterval(() => {
|
|
142
|
+
const port = currentPort();
|
|
143
|
+
if (!port) return;
|
|
144
|
+
for (const [sessionID, info] of trackedSessions) {
|
|
145
|
+
subscribeSession(sessionID, info.title, port);
|
|
146
|
+
}
|
|
147
|
+
// Retry sibling re-adoption until the registry shows our own session.
|
|
148
|
+
if (!readoptDone && activeSessionID) readoptSiblings(activeSessionID).catch(() => {});
|
|
149
|
+
}, heartbeatMs);
|
|
150
|
+
heartbeatInterval.unref?.();
|
|
94
151
|
|
|
95
152
|
process.on("exit", () => {
|
|
96
153
|
clearInterval(timer);
|
|
@@ -98,6 +155,22 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
98
155
|
});
|
|
99
156
|
|
|
100
157
|
return {
|
|
158
|
+
config: (cfg: { mcp?: Record<string, unknown> } & Record<string, unknown>) => {
|
|
159
|
+
// Inject the envoy MCP entry into the OpenCode config when
|
|
160
|
+
// dispatch is enabled. Centralizing this in the plugin (instead of
|
|
161
|
+
// each user's opencode.json) means:
|
|
162
|
+
// 1. Registration is gated by `dispatch.enabled`
|
|
163
|
+
// 2. The bearer token is sourced per-CWD via the user's gh shim
|
|
164
|
+
// (no env coordination needed)
|
|
165
|
+
// 3. Token rotation happens transparently inside the shim
|
|
166
|
+
// subprocess — OpenCode never sees an expired token
|
|
167
|
+
const entry = buildDispatchMcpEntry({
|
|
168
|
+
dispatch: config.dispatch,
|
|
169
|
+
});
|
|
170
|
+
if (!entry) return;
|
|
171
|
+
const { warning } = injectEnvoyMcp(cfg, entry);
|
|
172
|
+
if (warning) logger.warn(warning);
|
|
173
|
+
},
|
|
101
174
|
event: async ({
|
|
102
175
|
event,
|
|
103
176
|
}: {
|
|
@@ -113,6 +186,9 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
113
186
|
if (sessionID && sessionID !== activeSessionID) {
|
|
114
187
|
activeSessionID = sessionID;
|
|
115
188
|
activeSessionTitle = null;
|
|
189
|
+
if (!trackedSessions.has(sessionID)) {
|
|
190
|
+
trackedSessions.set(sessionID, { title: null });
|
|
191
|
+
}
|
|
116
192
|
await syncPort();
|
|
117
193
|
const port = currentPort();
|
|
118
194
|
// Fetch title — best-effort, non-blocking for initial subscribe
|
|
@@ -122,37 +198,84 @@ export default async (input: { serverUrl: URL }) => {
|
|
|
122
198
|
return t;
|
|
123
199
|
});
|
|
124
200
|
if (port) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
dir: process.cwd(),
|
|
131
|
-
topics: [`notifications.agent.${sessionID}`],
|
|
132
|
-
port,
|
|
133
|
-
title: activeSessionTitle ?? "",
|
|
134
|
-
}),
|
|
135
|
-
}).catch(() => {});
|
|
136
|
-
// After title arrives, send one follow-up subscribe with title populated
|
|
201
|
+
// Await so our own session is persisted in the registry before
|
|
202
|
+
// readoptSiblings reads it back (otherwise self may be absent and
|
|
203
|
+
// re-adoption would be skipped).
|
|
204
|
+
await subscribeSession(sessionID, activeSessionTitle, port);
|
|
205
|
+
// After the title arrives, send one follow-up subscribe with it.
|
|
137
206
|
titlePromise.then((title) => {
|
|
138
|
-
if (title
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
dir: process.cwd(),
|
|
145
|
-
topics: [`notifications.agent.${sessionID}`],
|
|
146
|
-
port: currentPort() ?? 0,
|
|
147
|
-
title,
|
|
148
|
-
}),
|
|
149
|
-
}).catch(() => {});
|
|
207
|
+
if (!title) return;
|
|
208
|
+
// Update tracked metadata even if this session is no longer the
|
|
209
|
+
// active one (another session may have become busy meanwhile).
|
|
210
|
+
if (trackedSessions.has(sessionID)) {
|
|
211
|
+
trackedSessions.set(sessionID, { title });
|
|
212
|
+
subscribeSession(sessionID, title, currentPort() ?? 0);
|
|
150
213
|
}
|
|
214
|
+
if (activeSessionID === sessionID) activeSessionTitle = title;
|
|
151
215
|
});
|
|
216
|
+
// Recover idle siblings orphaned by a serve restart (retries from the
|
|
217
|
+
// heartbeat until the registry shows our own session).
|
|
218
|
+
readoptSiblings(sessionID).catch(() => {});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (event.type === "session.deleted") {
|
|
224
|
+
const props = event.properties ?? {};
|
|
225
|
+
const deletedID =
|
|
226
|
+
(props.sessionID as string | undefined) ??
|
|
227
|
+
(props.info as { id?: string } | undefined)?.id;
|
|
228
|
+
if (deletedID) {
|
|
229
|
+
// Stop heartbeating a session that no longer exists, so its 5-min
|
|
230
|
+
// envoy_sessions entry expires instead of being kept alive (which would
|
|
231
|
+
// cause delivery attempts to a dead session id on the current port).
|
|
232
|
+
trackedSessions.delete(deletedID);
|
|
233
|
+
if (activeSessionID === deletedID) {
|
|
234
|
+
activeSessionID = null;
|
|
235
|
+
activeSessionTitle = null;
|
|
152
236
|
}
|
|
237
|
+
// Best-effort: drop the deleted session's interests so routing stops.
|
|
238
|
+
call("/v1/interests/unsubscribe", {
|
|
239
|
+
method: "POST",
|
|
240
|
+
headers: { "Content-Type": "application/json" },
|
|
241
|
+
body: JSON.stringify({ session_id: deletedID, topics: [] }),
|
|
242
|
+
}).catch(() => {});
|
|
153
243
|
}
|
|
154
244
|
}
|
|
155
245
|
},
|
|
246
|
+
"tool.execute.after": async (
|
|
247
|
+
input: { tool: string; sessionID: string; callID: string; args: unknown },
|
|
248
|
+
output: { title: string; output: string; metadata: unknown }
|
|
249
|
+
) => {
|
|
250
|
+
// Dispatch AC#4: when this session opens a Dispatch thread via the
|
|
251
|
+
// envoy_dispatch MCP tool, auto-subscribe it to the thread's GitHub topic
|
|
252
|
+
// so the human's reply is delivered back through Envoy. Best-effort — a
|
|
253
|
+
// subscribe failure must never surface to the model or fail the tool call.
|
|
254
|
+
const topic = dispatchSubscriptionTopic(input.tool, output.output);
|
|
255
|
+
if (!topic) return;
|
|
256
|
+
try {
|
|
257
|
+
await call("/v1/interests/subscribe", {
|
|
258
|
+
method: "POST",
|
|
259
|
+
headers: { "Content-Type": "application/json" },
|
|
260
|
+
body: JSON.stringify({
|
|
261
|
+
session_id: input.sessionID,
|
|
262
|
+
dir: cwd,
|
|
263
|
+
topics: [topic],
|
|
264
|
+
port: currentPort() ?? 0,
|
|
265
|
+
title: activeSessionTitle ?? "",
|
|
266
|
+
}),
|
|
267
|
+
});
|
|
268
|
+
} catch (err) {
|
|
269
|
+
logger.warn(
|
|
270
|
+
`[envoy-plugin] dispatch auto-subscribe failed: ${err instanceof Error ? err.message : String(err)}`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
// Cleanup hook (used by tests; production relies on process 'exit').
|
|
275
|
+
dispose: () => {
|
|
276
|
+
clearInterval(timer);
|
|
277
|
+
clearInterval(heartbeatInterval);
|
|
278
|
+
},
|
|
156
279
|
tool: {
|
|
157
280
|
envoy_subscribe: tool({
|
|
158
281
|
description:
|
package/src/tui-port.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
type ExecSyncFn = (command: string, args: string[], options: { encoding: string }) => string;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse the port from an OpenCode serve baseUrl.
|
|
7
|
+
*
|
|
8
|
+
* Synchronous and URL-only: the TUI plugin runs in-process with the
|
|
9
|
+
* OpenCode TUI, which always knows the baseUrl of its serve daemon.
|
|
10
|
+
* This is separate from the server-side `resolvePort` helper, which
|
|
11
|
+
* additionally consults `ss(8)` by PID — irrelevant in the TUI process.
|
|
12
|
+
*
|
|
13
|
+
* Returns null when input is missing, malformed, or has no explicit
|
|
14
|
+
* numeric port.
|
|
15
|
+
*/
|
|
16
|
+
export function parsePort(baseUrl: string | undefined): number | null {
|
|
17
|
+
if (!baseUrl) return null;
|
|
18
|
+
let parsed: URL;
|
|
19
|
+
try {
|
|
20
|
+
parsed = new URL(baseUrl);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (!parsed.port) return null;
|
|
25
|
+
const port = Number.parseInt(parsed.port, 10);
|
|
26
|
+
if (!Number.isFinite(port) || port <= 0) return null;
|
|
27
|
+
return port;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const defaultExecSync: ExecSyncFn = (command, args, options) =>
|
|
31
|
+
execFileSync(command, args, { encoding: options.encoding as BufferEncoding }) as string;
|
|
32
|
+
|
|
33
|
+
export function resolveCurrentProcessPort(exec: ExecSyncFn = defaultExecSync): number | null {
|
|
34
|
+
return resolveProcessPort(process.pid, exec);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveProcessPort(pid: number, exec: ExecSyncFn = defaultExecSync): number | null {
|
|
38
|
+
try {
|
|
39
|
+
const output = exec("ss", ["-tlnp"], { encoding: "utf-8" });
|
|
40
|
+
for (const line of output.split("\n")) {
|
|
41
|
+
if (!line.includes(`pid=${pid}`)) continue;
|
|
42
|
+
const parts = line.trim().split(/\s+/);
|
|
43
|
+
const local = parts[3];
|
|
44
|
+
const match = local?.match(/:(\d+)$/);
|
|
45
|
+
if (!match) continue;
|
|
46
|
+
const port = Number.parseInt(match[1], 10);
|
|
47
|
+
if (Number.isFinite(port) && port > 0) return port;
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function resolveSessionProcessPort(
|
|
55
|
+
sessionID: string,
|
|
56
|
+
exec: ExecSyncFn = defaultExecSync
|
|
57
|
+
): number | null {
|
|
58
|
+
try {
|
|
59
|
+
const output = exec("ps", ["-eo", "pid=,args="], { encoding: "utf-8" });
|
|
60
|
+
for (const line of output.split("\n")) {
|
|
61
|
+
if (!line.includes(sessionID)) continue;
|
|
62
|
+
if (!/(^|\s)-s\s+/.test(line)) continue;
|
|
63
|
+
const pid = Number.parseInt(line.trim().split(/\s+/, 1)[0] ?? "", 10);
|
|
64
|
+
if (!Number.isFinite(pid) || pid <= 0) continue;
|
|
65
|
+
const port = resolveProcessPort(pid, exec);
|
|
66
|
+
if (port !== null) return port;
|
|
67
|
+
}
|
|
68
|
+
} catch {}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function resolveTuiPort(
|
|
74
|
+
baseUrl: string | undefined,
|
|
75
|
+
sessionID?: string,
|
|
76
|
+
exec: ExecSyncFn = defaultExecSync
|
|
77
|
+
): number | null {
|
|
78
|
+
return (
|
|
79
|
+
parsePort(baseUrl) ??
|
|
80
|
+
(sessionID ? resolveSessionProcessPort(sessionID, exec) : null) ??
|
|
81
|
+
resolveCurrentProcessPort(exec)
|
|
82
|
+
);
|
|
83
|
+
}
|
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type {
|
|
3
|
+
TuiPlugin,
|
|
4
|
+
TuiPluginApi,
|
|
5
|
+
TuiPluginModule,
|
|
6
|
+
TuiSlotPlugin,
|
|
7
|
+
} from "@opencode-ai/plugin/tui";
|
|
8
|
+
import { createSignal } from "solid-js";
|
|
9
|
+
import { copyToClipboard } from "./clipboard";
|
|
10
|
+
import { resolveTuiPort } from "./tui-port";
|
|
11
|
+
|
|
12
|
+
function currentSessionID(api: TuiPluginApi): string | undefined {
|
|
13
|
+
const route = api.route.current;
|
|
14
|
+
if (route.name !== "session") return undefined;
|
|
15
|
+
return (route.params as { sessionID?: string } | undefined)?.sessionID;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function copyWithToast(api: TuiPluginApi, text: string, successMessage: string) {
|
|
19
|
+
if (copyToClipboard(text, api.renderer)) {
|
|
20
|
+
api.ui.toast({ message: successMessage, variant: "success" });
|
|
21
|
+
} else {
|
|
22
|
+
api.ui.toast({ message: `Failed: ${successMessage}`, variant: "error" });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type ClientWithConfig = TuiPluginApi["client"] & {
|
|
27
|
+
client: { getConfig(): { baseUrl?: string } };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function baseUrl(api: TuiPluginApi): string | undefined {
|
|
31
|
+
const url = (api.client as ClientWithConfig).client.getConfig().baseUrl;
|
|
32
|
+
return url;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ClickableRow(props: {
|
|
36
|
+
text: string;
|
|
37
|
+
onCopy: () => void;
|
|
38
|
+
mutedColor: TuiPluginApi["theme"]["current"]["textMuted"];
|
|
39
|
+
textColor: TuiPluginApi["theme"]["current"]["text"];
|
|
40
|
+
}) {
|
|
41
|
+
const [hover, setHover] = createSignal(false);
|
|
42
|
+
return (
|
|
43
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box supports mouse events.
|
|
44
|
+
// biome-ignore lint/a11y/useKeyWithMouseEvents: This row is mouse-only in the TUI sidebar.
|
|
45
|
+
<box
|
|
46
|
+
flexDirection="row"
|
|
47
|
+
onMouseOver={() => setHover(true)}
|
|
48
|
+
onMouseOut={() => setHover(false)}
|
|
49
|
+
onMouseDown={() => props.onCopy()}
|
|
50
|
+
>
|
|
51
|
+
<text fg={hover() ? props.textColor : props.mutedColor} wrapMode="none">
|
|
52
|
+
{props.text}
|
|
53
|
+
</text>
|
|
54
|
+
</box>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function EnvoySidebar(props: { api: TuiPluginApi; sessionID: string }) {
|
|
59
|
+
const theme = () => props.api.theme.current;
|
|
60
|
+
const port = resolveTuiPort(baseUrl(props.api), props.sessionID);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<box>
|
|
64
|
+
<ClickableRow
|
|
65
|
+
text={props.sessionID}
|
|
66
|
+
mutedColor={theme().textMuted}
|
|
67
|
+
textColor={theme().text}
|
|
68
|
+
onCopy={() => copyWithToast(props.api, props.sessionID, "Session ID copied")}
|
|
69
|
+
/>
|
|
70
|
+
{port !== null ? (
|
|
71
|
+
<ClickableRow
|
|
72
|
+
text={`port ${port}`}
|
|
73
|
+
mutedColor={theme().textMuted}
|
|
74
|
+
textColor={theme().text}
|
|
75
|
+
onCopy={() => copyWithToast(props.api, String(port), "Port copied")}
|
|
76
|
+
/>
|
|
77
|
+
) : null}
|
|
78
|
+
</box>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const tui: TuiPlugin = async (api) => {
|
|
83
|
+
// Slash command
|
|
84
|
+
api.keymap.registerLayer({
|
|
85
|
+
commands: [
|
|
86
|
+
{
|
|
87
|
+
name: "envoy.whoami.copy",
|
|
88
|
+
title: "Copy session ID",
|
|
89
|
+
category: "Envoy",
|
|
90
|
+
namespace: "palette",
|
|
91
|
+
slashName: "whoami",
|
|
92
|
+
run() {
|
|
93
|
+
const sessionID = currentSessionID(api);
|
|
94
|
+
if (!sessionID) {
|
|
95
|
+
api.ui.toast({ message: "No active session", variant: "warning" });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
copyWithToast(api, sessionID, "Session ID copied");
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Sidebar: clickable session ID row
|
|
105
|
+
const slot: TuiSlotPlugin = {
|
|
106
|
+
order: 10,
|
|
107
|
+
slots: {
|
|
108
|
+
sidebar_content(_ctx, value) {
|
|
109
|
+
if (!value.session_id) return null;
|
|
110
|
+
return <EnvoySidebar api={api} sessionID={value.session_id} />;
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
api.slots.register(slot);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const plugin: TuiPluginModule = {
|
|
118
|
+
id: "envoy-tui",
|
|
119
|
+
tui,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export default plugin;
|