skydive-cli 0.1.0-beta.239 → 0.1.0-beta.276
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/README.md +43 -6
- package/dist/js/bin.mjs +336 -30
- package/dist/js/{boot-CIdJ5Als.mjs → boot-FPpnUcyM.mjs} +124 -489
- package/dist/js/client-_OL8-XGH.mjs +367 -0
- package/dist/js/rolldown-runtime-Cz4Tg37Z.mjs +19 -0
- package/package.json +2 -1
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { A as
|
|
2
|
+
import { A as themesForMode, C as monoTheme, D as themeMode, E as themeForMode, F as DEFAULT_APP_URL, I as getConfigPath, L as getSavedTheme, M as listWorkspaces, N as setActiveWorkspace, O as themeModeFromColorFgBg, P as DEFAULT_API_URL, R as resolveWebUrl, S as findTheme, T as theme, _ as isRecord, b as DEFAULT_THEME_ID, c as resolveAgent, d as parseExternalOauthConnectParams, f as parseOauthConnectParams, g as errorMessage, h as parseConnectCard, j as getActiveWorkspaceId, k as themeVersion, l as MASK_CHAR, m as resolveConnectUrl, p as reconcileMaskedInput, u as cardActionErrorMessage, v as HttpError, w as noColorRequested, x as applyTheme, y as createRestClient, z as saveTheme } from "./bin.mjs";
|
|
3
|
+
import { t as PortalClient } from "./client-_OL8-XGH.mjs";
|
|
3
4
|
import path, { basename, isAbsolute, join, win32 } from "node:path";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import open from "open";
|
|
6
7
|
import { execFile, spawn } from "node:child_process";
|
|
7
8
|
import { createHash } from "node:crypto";
|
|
8
9
|
import { constants } from "node:fs";
|
|
10
|
+
import * as os$1 from "node:os";
|
|
11
|
+
import { homedir, platform, release, tmpdir } from "node:os";
|
|
9
12
|
import { MarkdownRenderable, RenderableEvents, SyntaxStyle, createCliRenderer, decodePasteBytes, detectLinks } from "@opentui/core";
|
|
10
13
|
import { createRoot, extend, useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
11
14
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
12
15
|
import { create } from "zustand";
|
|
13
16
|
import { WebSocket } from "ws";
|
|
14
|
-
import * as os$1 from "node:os";
|
|
15
|
-
import os, { homedir, platform, release, tmpdir } from "node:os";
|
|
16
17
|
import { createConnection } from "node:net";
|
|
17
18
|
import { access, appendFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
18
19
|
import { Fragment, jsx, jsxs } from "@opentui/react/jsx-runtime";
|
|
20
|
+
import fuzzysort from "fuzzysort";
|
|
19
21
|
import { fileURLToPath } from "node:url";
|
|
20
22
|
import { fileTypeFromBuffer } from "file-type";
|
|
21
23
|
import { structuredPatch } from "diff";
|
|
@@ -157,448 +159,6 @@ async function resolveInitialScreen({ rest, agentSelector }) {
|
|
|
157
159
|
};
|
|
158
160
|
}
|
|
159
161
|
|
|
160
|
-
//#endregion
|
|
161
|
-
//#region ../portal-protocol/src/index.ts
|
|
162
|
-
const MAX_WS_FRAME_BYTES = 16 * 1024 * 1024;
|
|
163
|
-
const T_DATA = 1;
|
|
164
|
-
const T_CTRL = 2;
|
|
165
|
-
const STREAM = {
|
|
166
|
-
stdout: 0,
|
|
167
|
-
stderr: 1,
|
|
168
|
-
stdin: 2
|
|
169
|
-
};
|
|
170
|
-
const uuidToBytes = (id) => Buffer.from(id.replace(/-/g, ""), "hex");
|
|
171
|
-
const bytesToUuid = (b) => {
|
|
172
|
-
const h = b.toString("hex");
|
|
173
|
-
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
|
|
174
|
-
};
|
|
175
|
-
function encodeData(id, stream, seq, payload) {
|
|
176
|
-
const head = Buffer.allocUnsafe(22);
|
|
177
|
-
head[0] = T_DATA;
|
|
178
|
-
uuidToBytes(id).copy(head, 1);
|
|
179
|
-
head[17] = stream;
|
|
180
|
-
head.writeUInt32BE(seq >>> 0, 18);
|
|
181
|
-
return Buffer.concat([head, payload]);
|
|
182
|
-
}
|
|
183
|
-
const ctrlMessageSchema = z.discriminatedUnion("t", [
|
|
184
|
-
z.object({
|
|
185
|
-
t: z.literal("open"),
|
|
186
|
-
argv: z.array(z.string()),
|
|
187
|
-
env: z.record(z.string()).nullable()
|
|
188
|
-
}),
|
|
189
|
-
z.object({ t: z.literal("stdin_eof") }),
|
|
190
|
-
z.object({ t: z.literal("pause") }),
|
|
191
|
-
z.object({ t: z.literal("resume") }),
|
|
192
|
-
z.object({ t: z.literal("cancel") }),
|
|
193
|
-
z.object({
|
|
194
|
-
t: z.literal("close"),
|
|
195
|
-
exitCode: z.number()
|
|
196
|
-
}),
|
|
197
|
-
z.object({
|
|
198
|
-
t: z.literal("error"),
|
|
199
|
-
message: z.string()
|
|
200
|
-
})
|
|
201
|
-
]);
|
|
202
|
-
function encodeCtrl(id, obj) {
|
|
203
|
-
const head = Buffer.allocUnsafe(17);
|
|
204
|
-
head[0] = T_CTRL;
|
|
205
|
-
uuidToBytes(id).copy(head, 1);
|
|
206
|
-
return Buffer.concat([head, Buffer.from(JSON.stringify(obj), "utf8")]);
|
|
207
|
-
}
|
|
208
|
-
function decodeFrame(frame) {
|
|
209
|
-
const id = bytesToUuid(frame.subarray(1, 17));
|
|
210
|
-
if (frame[0] === T_DATA) return {
|
|
211
|
-
kind: "data",
|
|
212
|
-
id,
|
|
213
|
-
stream: frame[17] ?? 0,
|
|
214
|
-
seq: frame.readUInt32BE(18),
|
|
215
|
-
payload: frame.subarray(22)
|
|
216
|
-
};
|
|
217
|
-
return {
|
|
218
|
-
kind: "ctrl",
|
|
219
|
-
id,
|
|
220
|
-
obj: ctrlMessageSchema.parse(JSON.parse(frame.subarray(17).toString("utf8")))
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
//#endregion
|
|
225
|
-
//#region src/chat/portal/machine.ts
|
|
226
|
-
/**
|
|
227
|
-
* Identity this machine registers under when the CLI shares it via the portal.
|
|
228
|
-
*
|
|
229
|
-
* The `-cli` suffix / `(CLI)` label keep a CLI-shared machine a DISTINCT portal
|
|
230
|
-
* device from the same host's Skydive Desktop app. `portal_device` is unique on
|
|
231
|
-
* (org, user, machineName), and directives route to whichever socket holds the
|
|
232
|
-
* device — if the CLI and desktop registered the same name they'd share a
|
|
233
|
-
* device row and both execute every directive. Distinct names also make the
|
|
234
|
-
* grant UI unambiguous about which surface is being authorized.
|
|
235
|
-
*/
|
|
236
|
-
function machineIdentity() {
|
|
237
|
-
const host = (os.hostname() || "machine").trim().replace(/\.local$/i, "") || "machine";
|
|
238
|
-
return {
|
|
239
|
-
machineName: `${host}-cli`,
|
|
240
|
-
friendlyName: `${host} (CLI)`
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
const INHERITED_ENV = [
|
|
244
|
-
"HOME",
|
|
245
|
-
"USER",
|
|
246
|
-
"LOGNAME",
|
|
247
|
-
"SHELL",
|
|
248
|
-
"LANG",
|
|
249
|
-
"LC_ALL",
|
|
250
|
-
"TMPDIR",
|
|
251
|
-
"TERM",
|
|
252
|
-
"PATH"
|
|
253
|
-
];
|
|
254
|
-
function buildEnv(extra) {
|
|
255
|
-
const env = {};
|
|
256
|
-
for (const key of INHERITED_ENV) {
|
|
257
|
-
const value = process.env[key];
|
|
258
|
-
if (value !== void 0) env[key] = value;
|
|
259
|
-
}
|
|
260
|
-
if (extra) for (const [key, value] of Object.entries(extra)) env[key] = value;
|
|
261
|
-
return env;
|
|
262
|
-
}
|
|
263
|
-
/**
|
|
264
|
-
* Build the desktop-portal WebSocket URL from the chat origin. Mirrors the Rust
|
|
265
|
-
* desktop client: http→ws, https→wss, scheme-less defaults to wss, and the
|
|
266
|
-
* machine/label ride as query pairs (percent-encoded by URL).
|
|
267
|
-
*/
|
|
268
|
-
function portalWsUrl(appUrl, machine, label) {
|
|
269
|
-
const base = appUrl.replace(/\/+$/, "");
|
|
270
|
-
let wsBase;
|
|
271
|
-
if (base.startsWith("https://")) wsBase = `wss://${base.slice(8)}`;
|
|
272
|
-
else if (base.startsWith("http://")) wsBase = `ws://${base.slice(7)}`;
|
|
273
|
-
else wsBase = `wss://${base}`;
|
|
274
|
-
const url = new URL(`${wsBase}/api/v1/portal/desktop`);
|
|
275
|
-
url.searchParams.set("machine", machine);
|
|
276
|
-
url.searchParams.set("label", label);
|
|
277
|
-
return url.toString();
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
//#endregion
|
|
281
|
-
//#region src/chat/portal/exec.ts
|
|
282
|
-
/**
|
|
283
|
-
* Runs portal `exec` directives locally. Each `open` spawns a child process
|
|
284
|
-
* whose stdout/stderr stream back as data frames and whose stdin is fed by
|
|
285
|
-
* inbound data frames, with pause/resume backpressure and cancel/teardown that
|
|
286
|
-
* kill the child. This is the TypeScript counterpart of the desktop's Rust
|
|
287
|
-
* `portal/mod.rs` job machinery, minus the connection supervision (which lives
|
|
288
|
-
* in the client).
|
|
289
|
-
*/
|
|
290
|
-
var JobManager = class {
|
|
291
|
-
jobs = /* @__PURE__ */ new Map();
|
|
292
|
-
constructor(opts) {
|
|
293
|
-
this.opts = opts;
|
|
294
|
-
}
|
|
295
|
-
handleFrame(raw) {
|
|
296
|
-
let decoded;
|
|
297
|
-
try {
|
|
298
|
-
decoded = decodeFrame(raw);
|
|
299
|
-
} catch (_error) {
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
if (decoded.kind === "ctrl") this.handleCtrl(decoded.id, decoded.obj);
|
|
303
|
-
else if (decoded.stream === STREAM.stdin) this.jobs.get(decoded.id)?.child.stdin.write(decoded.payload);
|
|
304
|
-
}
|
|
305
|
-
killAll() {
|
|
306
|
-
for (const job of this.jobs.values()) {
|
|
307
|
-
job.settled = true;
|
|
308
|
-
job.child.kill("SIGKILL");
|
|
309
|
-
}
|
|
310
|
-
this.jobs.clear();
|
|
311
|
-
}
|
|
312
|
-
handleCtrl(id, msg) {
|
|
313
|
-
switch (msg.t) {
|
|
314
|
-
case "open":
|
|
315
|
-
this.startJob(id, msg.argv, msg.env);
|
|
316
|
-
return;
|
|
317
|
-
case "stdin_eof":
|
|
318
|
-
this.jobs.get(id)?.child.stdin.end();
|
|
319
|
-
return;
|
|
320
|
-
case "pause":
|
|
321
|
-
this.setPaused(id, true);
|
|
322
|
-
return;
|
|
323
|
-
case "resume":
|
|
324
|
-
this.setPaused(id, false);
|
|
325
|
-
return;
|
|
326
|
-
case "cancel":
|
|
327
|
-
this.jobs.get(id)?.child.kill("SIGKILL");
|
|
328
|
-
return;
|
|
329
|
-
case "close":
|
|
330
|
-
case "error": return;
|
|
331
|
-
default: return msg;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
setPaused(id, paused) {
|
|
335
|
-
const job = this.jobs.get(id);
|
|
336
|
-
if (!job) return;
|
|
337
|
-
if (paused) {
|
|
338
|
-
job.child.stdout.pause();
|
|
339
|
-
job.child.stderr.pause();
|
|
340
|
-
} else {
|
|
341
|
-
job.child.stdout.resume();
|
|
342
|
-
job.child.stderr.resume();
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
startJob(id, argv, env) {
|
|
346
|
-
const [program, ...args] = argv;
|
|
347
|
-
if (!program) {
|
|
348
|
-
this.opts.send(encodeCtrl(id, {
|
|
349
|
-
t: "error",
|
|
350
|
-
message: "empty argv"
|
|
351
|
-
}));
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
let child;
|
|
355
|
-
try {
|
|
356
|
-
child = spawn(program, args, {
|
|
357
|
-
cwd: this.opts.cwd,
|
|
358
|
-
env: buildEnv(env),
|
|
359
|
-
stdio: [
|
|
360
|
-
"pipe",
|
|
361
|
-
"pipe",
|
|
362
|
-
"pipe"
|
|
363
|
-
]
|
|
364
|
-
});
|
|
365
|
-
} catch (err) {
|
|
366
|
-
this.opts.send(encodeCtrl(id, {
|
|
367
|
-
t: "error",
|
|
368
|
-
message: `spawn failed: ${errorMessage(err)}`
|
|
369
|
-
}));
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
const job = {
|
|
373
|
-
child,
|
|
374
|
-
seq: 0,
|
|
375
|
-
settled: false
|
|
376
|
-
};
|
|
377
|
-
this.jobs.set(id, job);
|
|
378
|
-
child.on("error", (err) => {
|
|
379
|
-
if (job.settled) return;
|
|
380
|
-
job.settled = true;
|
|
381
|
-
this.jobs.delete(id);
|
|
382
|
-
this.opts.send(encodeCtrl(id, {
|
|
383
|
-
t: "error",
|
|
384
|
-
message: `spawn failed: ${errorMessage(err)}`
|
|
385
|
-
}));
|
|
386
|
-
});
|
|
387
|
-
child.stdout.on("data", (chunk) => this.sendData(job, id, STREAM.stdout, chunk));
|
|
388
|
-
child.stderr.on("data", (chunk) => this.sendData(job, id, STREAM.stderr, chunk));
|
|
389
|
-
child.on("close", (code) => {
|
|
390
|
-
if (job.settled) return;
|
|
391
|
-
job.settled = true;
|
|
392
|
-
this.jobs.delete(id);
|
|
393
|
-
this.opts.send(encodeCtrl(id, {
|
|
394
|
-
t: "close",
|
|
395
|
-
exitCode: code ?? -1
|
|
396
|
-
}));
|
|
397
|
-
});
|
|
398
|
-
}
|
|
399
|
-
sendData(job, id, stream, chunk) {
|
|
400
|
-
if (job.settled) return;
|
|
401
|
-
this.opts.send(encodeData(id, stream, job.seq, chunk));
|
|
402
|
-
job.seq = job.seq + 1 >>> 0;
|
|
403
|
-
}
|
|
404
|
-
};
|
|
405
|
-
|
|
406
|
-
//#endregion
|
|
407
|
-
//#region src/chat/portal/client.ts
|
|
408
|
-
const deviceTokenSchema = z.object({ token: z.string().min(1) });
|
|
409
|
-
const devicesSchema = z.object({ devices: z.array(z.object({
|
|
410
|
-
id: z.string(),
|
|
411
|
-
machineName: z.string(),
|
|
412
|
-
grantedAgentIds: z.array(z.string())
|
|
413
|
-
})) });
|
|
414
|
-
const INITIAL_BACKOFF_MS = 500;
|
|
415
|
-
const MAX_BACKOFF_MS = 1e4;
|
|
416
|
-
/**
|
|
417
|
-
* Shares the local machine with agents over the portal: dials OUT to the api's
|
|
418
|
-
* desktop-portal WebSocket (authenticating with a short-lived device token
|
|
419
|
-
* minted from the CLI session), then runs inbound `exec` directives via a
|
|
420
|
-
* `JobManager`. Reconnects with backoff while enabled; disabling drops presence
|
|
421
|
-
* and kills any in-flight children. No inbound port is ever opened.
|
|
422
|
-
*
|
|
423
|
-
* Access stays default-deny: connecting only makes the machine reachable — an
|
|
424
|
-
* agent can't run anything until the user grants it (`grantAgent`).
|
|
425
|
-
*/
|
|
426
|
-
var PortalClient = class {
|
|
427
|
-
enabled = false;
|
|
428
|
-
disposed = false;
|
|
429
|
-
ws = null;
|
|
430
|
-
jobs = null;
|
|
431
|
-
status = "off";
|
|
432
|
-
error = null;
|
|
433
|
-
deviceId = null;
|
|
434
|
-
granted = /* @__PURE__ */ new Set();
|
|
435
|
-
machineName;
|
|
436
|
-
friendlyName;
|
|
437
|
-
constructor(opts) {
|
|
438
|
-
this.opts = opts;
|
|
439
|
-
const identity = machineIdentity();
|
|
440
|
-
this.machineName = identity.machineName;
|
|
441
|
-
this.friendlyName = identity.friendlyName;
|
|
442
|
-
}
|
|
443
|
-
isEnabled() {
|
|
444
|
-
return this.enabled;
|
|
445
|
-
}
|
|
446
|
-
isGranted(agentId) {
|
|
447
|
-
return this.granted.has(agentId);
|
|
448
|
-
}
|
|
449
|
-
enable() {
|
|
450
|
-
if (this.enabled || this.disposed) return;
|
|
451
|
-
this.enabled = true;
|
|
452
|
-
this.error = null;
|
|
453
|
-
this.connectLoop();
|
|
454
|
-
}
|
|
455
|
-
disable() {
|
|
456
|
-
if (!this.enabled) return;
|
|
457
|
-
this.enabled = false;
|
|
458
|
-
this.jobs?.killAll();
|
|
459
|
-
this.ws?.close();
|
|
460
|
-
this.ws = null;
|
|
461
|
-
this.deviceId = null;
|
|
462
|
-
this.granted = /* @__PURE__ */ new Set();
|
|
463
|
-
this.setStatus("off");
|
|
464
|
-
}
|
|
465
|
-
/**
|
|
466
|
-
* Tear down for good (app quit). Kills children synchronously and closes the
|
|
467
|
-
* socket so it stops holding the event loop open — otherwise the process
|
|
468
|
-
* would hang after the TUI is destroyed.
|
|
469
|
-
*/
|
|
470
|
-
dispose() {
|
|
471
|
-
this.disposed = true;
|
|
472
|
-
this.enabled = false;
|
|
473
|
-
this.jobs?.killAll();
|
|
474
|
-
this.jobs = null;
|
|
475
|
-
this.ws?.close();
|
|
476
|
-
this.ws = null;
|
|
477
|
-
}
|
|
478
|
-
/** Grant one agent access to this machine (default-deny; user-initiated). */
|
|
479
|
-
async grantAgent(agentId) {
|
|
480
|
-
const deviceId = await this.ensureDeviceId();
|
|
481
|
-
const res = await this.fetchJson(`/api/v1/portal/devices/${encodeURIComponent(deviceId)}/grants`, {
|
|
482
|
-
method: "POST",
|
|
483
|
-
body: JSON.stringify({ agentId })
|
|
484
|
-
});
|
|
485
|
-
if (!res.ok) throw new Error(`grant failed (${res.status}): ${await shortBody(res)}`);
|
|
486
|
-
this.granted.add(agentId);
|
|
487
|
-
this.emit();
|
|
488
|
-
}
|
|
489
|
-
setStatus(status, error = null) {
|
|
490
|
-
this.status = status;
|
|
491
|
-
this.error = error;
|
|
492
|
-
this.emit();
|
|
493
|
-
}
|
|
494
|
-
emit() {
|
|
495
|
-
this.opts.onState({
|
|
496
|
-
status: this.status,
|
|
497
|
-
machineName: this.machineName,
|
|
498
|
-
friendlyName: this.friendlyName,
|
|
499
|
-
error: this.error,
|
|
500
|
-
grantedAgentIds: [...this.granted]
|
|
501
|
-
});
|
|
502
|
-
}
|
|
503
|
-
async connectLoop() {
|
|
504
|
-
let backoff = INITIAL_BACKOFF_MS;
|
|
505
|
-
while (this.enabled && !this.disposed) {
|
|
506
|
-
this.setStatus("connecting");
|
|
507
|
-
try {
|
|
508
|
-
const token = await this.mintDeviceToken();
|
|
509
|
-
await this.runConnection(token);
|
|
510
|
-
backoff = INITIAL_BACKOFF_MS;
|
|
511
|
-
} catch (err) {
|
|
512
|
-
if (!this.enabled || this.disposed) break;
|
|
513
|
-
this.setStatus("error", errorMessage(err));
|
|
514
|
-
}
|
|
515
|
-
if (!this.enabled || this.disposed) break;
|
|
516
|
-
await sleep(backoff);
|
|
517
|
-
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
runConnection(token) {
|
|
521
|
-
return new Promise((resolve) => {
|
|
522
|
-
const ws = new WebSocket(portalWsUrl(this.opts.appUrl, this.machineName, this.friendlyName), {
|
|
523
|
-
headers: { authorization: `Bearer ${token}` },
|
|
524
|
-
maxPayload: MAX_WS_FRAME_BYTES
|
|
525
|
-
});
|
|
526
|
-
this.ws = ws;
|
|
527
|
-
const jobs = new JobManager({
|
|
528
|
-
cwd: this.opts.cwd,
|
|
529
|
-
send: (frame) => {
|
|
530
|
-
if (ws.readyState === WebSocket.OPEN) ws.send(frame);
|
|
531
|
-
}
|
|
532
|
-
});
|
|
533
|
-
this.jobs = jobs;
|
|
534
|
-
ws.on("open", () => {
|
|
535
|
-
this.setStatus("connected");
|
|
536
|
-
this.refreshDevice();
|
|
537
|
-
});
|
|
538
|
-
ws.on("message", (data, isBinary) => {
|
|
539
|
-
if (isBinary) jobs.handleFrame(toBuffer$1(data));
|
|
540
|
-
});
|
|
541
|
-
ws.on("error", (err) => {
|
|
542
|
-
this.error = errorMessage(err);
|
|
543
|
-
});
|
|
544
|
-
ws.on("close", () => {
|
|
545
|
-
jobs.killAll();
|
|
546
|
-
if (this.jobs === jobs) this.jobs = null;
|
|
547
|
-
if (this.ws === ws) this.ws = null;
|
|
548
|
-
resolve();
|
|
549
|
-
});
|
|
550
|
-
});
|
|
551
|
-
}
|
|
552
|
-
async mintDeviceToken() {
|
|
553
|
-
const res = await this.fetchJson("/api/v1/portal/device-token", { method: "POST" });
|
|
554
|
-
if (!res.ok) throw new Error(`device-token failed (${res.status}): ${await shortBody(res)}`);
|
|
555
|
-
return deviceTokenSchema.parse(await res.json()).token;
|
|
556
|
-
}
|
|
557
|
-
async ensureDeviceId() {
|
|
558
|
-
if (this.deviceId) return this.deviceId;
|
|
559
|
-
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
560
|
-
await this.refreshDevice();
|
|
561
|
-
if (this.deviceId) return this.deviceId;
|
|
562
|
-
await sleep(300);
|
|
563
|
-
}
|
|
564
|
-
throw new Error("this machine is not connected yet");
|
|
565
|
-
}
|
|
566
|
-
async refreshDevice() {
|
|
567
|
-
try {
|
|
568
|
-
const res = await this.fetchJson("/api/v1/portal/devices", { method: "GET" });
|
|
569
|
-
if (!res.ok) return;
|
|
570
|
-
const { devices } = devicesSchema.parse(await res.json());
|
|
571
|
-
const mine = devices.find((device) => device.machineName === this.machineName);
|
|
572
|
-
if (!mine) return;
|
|
573
|
-
this.deviceId = mine.id;
|
|
574
|
-
this.granted = new Set(mine.grantedAgentIds);
|
|
575
|
-
this.emit();
|
|
576
|
-
} catch (_error) {}
|
|
577
|
-
}
|
|
578
|
-
fetchJson(path, init) {
|
|
579
|
-
return fetch(`${this.opts.appUrl}${path}`, {
|
|
580
|
-
method: init.method,
|
|
581
|
-
headers: {
|
|
582
|
-
authorization: `Bearer ${this.opts.sessionToken}`,
|
|
583
|
-
accept: "application/json",
|
|
584
|
-
...init.body ? { "content-type": "application/json" } : {}
|
|
585
|
-
},
|
|
586
|
-
...init.body ? { body: init.body } : {}
|
|
587
|
-
});
|
|
588
|
-
}
|
|
589
|
-
};
|
|
590
|
-
function toBuffer$1(data) {
|
|
591
|
-
if (Buffer.isBuffer(data)) return data;
|
|
592
|
-
if (Array.isArray(data)) return Buffer.concat(data);
|
|
593
|
-
return Buffer.from(data);
|
|
594
|
-
}
|
|
595
|
-
function shortBody(res) {
|
|
596
|
-
return res.text().then((text) => text.slice(0, 120)).catch(() => "");
|
|
597
|
-
}
|
|
598
|
-
function sleep(ms) {
|
|
599
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
600
|
-
}
|
|
601
|
-
|
|
602
162
|
//#endregion
|
|
603
163
|
//#region src/chat/portal/use-portal.ts
|
|
604
164
|
/**
|
|
@@ -1240,6 +800,68 @@ function windowStart(highlight, total, visible) {
|
|
|
1240
800
|
return Math.max(0, Math.min(desired, total - visible));
|
|
1241
801
|
}
|
|
1242
802
|
|
|
803
|
+
//#endregion
|
|
804
|
+
//#region src/chat/tui/fuzzy.ts
|
|
805
|
+
/**
|
|
806
|
+
* Rank `items` against `query` across multiple keys with fuzzysort — the
|
|
807
|
+
* OpenCode pattern. An empty/whitespace query returns everything in the
|
|
808
|
+
* caller's original order (fuzzysort has nothing to rank); otherwise items
|
|
809
|
+
* where no key matches are dropped and the rest come back best-first.
|
|
810
|
+
*/
|
|
811
|
+
function fuzzyFilter(query, items, keys) {
|
|
812
|
+
const needle = query.trim();
|
|
813
|
+
if (!needle) return items.map((item) => ({
|
|
814
|
+
item,
|
|
815
|
+
highlights: keys.map(() => null)
|
|
816
|
+
}));
|
|
817
|
+
return fuzzysort.go(needle, items, { keys: [...keys] }).map((result) => ({
|
|
818
|
+
item: result.obj,
|
|
819
|
+
highlights: keys.map((_key, i) => {
|
|
820
|
+
const indexes = result[i]?.indexes;
|
|
821
|
+
return indexes !== void 0 && indexes.length > 0 ? indexes : null;
|
|
822
|
+
})
|
|
823
|
+
}));
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Split `text` into contiguous segments for rendering, marking the ones
|
|
827
|
+
* covered by `indexes` (matched characters) so the view can color them.
|
|
828
|
+
* Null/absent indexes yield a single unmatched segment.
|
|
829
|
+
*/
|
|
830
|
+
function highlightSegments(text, indexes) {
|
|
831
|
+
if (!text) return [];
|
|
832
|
+
if (!indexes || indexes.length === 0) return [{
|
|
833
|
+
text,
|
|
834
|
+
matched: false
|
|
835
|
+
}];
|
|
836
|
+
const matched = new Set(indexes);
|
|
837
|
+
const segments = [];
|
|
838
|
+
for (let i = 0; i < text.length; i++) {
|
|
839
|
+
const isMatch = matched.has(i);
|
|
840
|
+
const last = segments.at(-1);
|
|
841
|
+
if (last && last.matched === isMatch) last.text += text[i];
|
|
842
|
+
else segments.push({
|
|
843
|
+
text: text[i] ?? "",
|
|
844
|
+
matched: isMatch
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
return segments;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
//#endregion
|
|
851
|
+
//#region src/chat/tui/fuzzy-text.tsx
|
|
852
|
+
/**
|
|
853
|
+
* Inline text with fuzzy-matched characters colored. Renders as spans, so it
|
|
854
|
+
* must sit inside a <text>. `fg` styles the unmatched characters (defaults to
|
|
855
|
+
* inheriting from the enclosing <text>); matches are always warning-colored,
|
|
856
|
+
* which reads on both the plain and the selected (accent) row.
|
|
857
|
+
*/
|
|
858
|
+
function FuzzyText({ text, indexes, fg }) {
|
|
859
|
+
return /* @__PURE__ */ jsx(Fragment, { children: highlightSegments(text, indexes).map((segment, i) => /* @__PURE__ */ jsx("span", {
|
|
860
|
+
fg: segment.matched ? theme.warning : fg,
|
|
861
|
+
children: segment.text
|
|
862
|
+
}, i)) });
|
|
863
|
+
}
|
|
864
|
+
|
|
1243
865
|
//#endregion
|
|
1244
866
|
//#region src/chat/tui/screens/agent-picker.tsx
|
|
1245
867
|
function AgentPickerScreen() {
|
|
@@ -1332,12 +954,12 @@ function FilterableAgentList({ agents, scope, onPick }) {
|
|
|
1332
954
|
useEffect(() => {
|
|
1333
955
|
setHighlight(0);
|
|
1334
956
|
}, [scope]);
|
|
1335
|
-
const
|
|
1336
|
-
const filtered =
|
|
957
|
+
const hits = fuzzyFilter(query, agents, agentKeys);
|
|
958
|
+
const filtered = hits.map((h) => h.item);
|
|
1337
959
|
const clamped = Math.min(highlight, Math.max(0, filtered.length - 1));
|
|
1338
960
|
const visibleRows = Math.max(3, height - 6);
|
|
1339
961
|
const start = windowStart(clamped, filtered.length, visibleRows);
|
|
1340
|
-
const windowed =
|
|
962
|
+
const windowed = hits.slice(start, start + visibleRows);
|
|
1341
963
|
useKeyboard((key) => {
|
|
1342
964
|
if (key.name === "up") setHighlight(Math.max(0, clamped - 1));
|
|
1343
965
|
else if (key.name === "down") setHighlight(Math.min(Math.max(0, filtered.length - 1), clamped + 1));
|
|
@@ -1399,28 +1021,33 @@ function FilterableAgentList({ agents, scope, onPick }) {
|
|
|
1399
1021
|
query,
|
|
1400
1022
|
"”"
|
|
1401
1023
|
]
|
|
1402
|
-
}) : windowed.map((
|
|
1024
|
+
}) : windowed.map((hit, i) => {
|
|
1025
|
+
const a = hit.item;
|
|
1403
1026
|
const selected = start + i === clamped;
|
|
1404
1027
|
return /* @__PURE__ */ jsxs("text", {
|
|
1405
1028
|
fg: selected ? theme.accent : theme.fg,
|
|
1406
|
-
children: [
|
|
1029
|
+
children: [
|
|
1030
|
+
selected ? "› " : " ",
|
|
1031
|
+
/* @__PURE__ */ jsx(FuzzyText, {
|
|
1032
|
+
text: a.name,
|
|
1033
|
+
indexes: hit.highlights[0]
|
|
1034
|
+
}),
|
|
1035
|
+
a.slug ? /* @__PURE__ */ jsxs(Fragment, { children: [" @", /* @__PURE__ */ jsx(FuzzyText, {
|
|
1036
|
+
text: a.slug,
|
|
1037
|
+
indexes: hit.highlights[1]
|
|
1038
|
+
})] }) : null
|
|
1039
|
+
]
|
|
1407
1040
|
}, a.id);
|
|
1408
1041
|
})
|
|
1409
1042
|
})
|
|
1410
1043
|
]
|
|
1411
1044
|
});
|
|
1412
1045
|
}
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
].join(" ").toLowerCase();
|
|
1419
|
-
}
|
|
1420
|
-
function agentLabel(a) {
|
|
1421
|
-
if (a.slug) return `${a.name} @${a.slug}`;
|
|
1422
|
-
return a.name;
|
|
1423
|
-
}
|
|
1046
|
+
const agentKeys = [
|
|
1047
|
+
(a) => a.name,
|
|
1048
|
+
(a) => a.slug ?? "",
|
|
1049
|
+
(a) => a.title ?? ""
|
|
1050
|
+
];
|
|
1424
1051
|
|
|
1425
1052
|
//#endregion
|
|
1426
1053
|
//#region src/chat/tui/agent-name.ts
|
|
@@ -1755,11 +1382,10 @@ function ConversationList({ agent, conversations, onPick }) {
|
|
|
1755
1382
|
const [list, setList] = useState(conversations);
|
|
1756
1383
|
const [confirmId, setConfirmId] = useState(null);
|
|
1757
1384
|
const [error, setError] = useState(null);
|
|
1758
|
-
const
|
|
1759
|
-
const
|
|
1760
|
-
const rows = [{ kind: "new" }, ...filtered.map((conv) => ({
|
|
1385
|
+
const hits = fuzzyFilter(query, list, conversationKeys);
|
|
1386
|
+
const rows = [{ kind: "new" }, ...hits.map((hit) => ({
|
|
1761
1387
|
kind: "conv",
|
|
1762
|
-
|
|
1388
|
+
hit
|
|
1763
1389
|
}))];
|
|
1764
1390
|
const clamped = Math.min(highlight, Math.max(0, rows.length - 1));
|
|
1765
1391
|
const visibleRows = Math.max(3, height - 6);
|
|
@@ -1777,7 +1403,7 @@ function ConversationList({ agent, conversations, onPick }) {
|
|
|
1777
1403
|
else onPick({
|
|
1778
1404
|
kind: "chat",
|
|
1779
1405
|
agent,
|
|
1780
|
-
conversation: row.
|
|
1406
|
+
conversation: row.hit.item
|
|
1781
1407
|
});
|
|
1782
1408
|
};
|
|
1783
1409
|
const deleteConversation = (id) => {
|
|
@@ -1804,7 +1430,7 @@ function ConversationList({ agent, conversations, onPick }) {
|
|
|
1804
1430
|
const row = rows[clamped];
|
|
1805
1431
|
if (row?.kind === "conv") {
|
|
1806
1432
|
setError(null);
|
|
1807
|
-
setConfirmId(row.
|
|
1433
|
+
setConfirmId(row.hit.item.id);
|
|
1808
1434
|
}
|
|
1809
1435
|
} else if (key.name === "return") {
|
|
1810
1436
|
const row = rows[clamped];
|
|
@@ -1849,7 +1475,7 @@ function ConversationList({ agent, conversations, onPick }) {
|
|
|
1849
1475
|
}) : /* @__PURE__ */ jsxs("text", {
|
|
1850
1476
|
fg: theme.dim,
|
|
1851
1477
|
children: [
|
|
1852
|
-
|
|
1478
|
+
hits.length,
|
|
1853
1479
|
"/",
|
|
1854
1480
|
list.length,
|
|
1855
1481
|
" · ↑/↓ move · ↵ open · ctrl+d delete · esc back"
|
|
@@ -1871,21 +1497,22 @@ function ConversationList({ agent, conversations, onPick }) {
|
|
|
1871
1497
|
fg,
|
|
1872
1498
|
children: [
|
|
1873
1499
|
selected ? "› " : " ",
|
|
1874
|
-
|
|
1500
|
+
/* @__PURE__ */ jsx(FuzzyText, {
|
|
1501
|
+
text: row.hit.item.title ?? "(untitled)",
|
|
1502
|
+
indexes: row.hit.item.title ? row.hit.highlights[0] : null
|
|
1503
|
+
}),
|
|
1875
1504
|
/* @__PURE__ */ jsxs("span", {
|
|
1876
1505
|
fg: theme.dim,
|
|
1877
|
-
children: [" ", previewLine(row.
|
|
1506
|
+
children: [" ", previewLine(row.hit.item)]
|
|
1878
1507
|
})
|
|
1879
1508
|
]
|
|
1880
|
-
}, row.
|
|
1509
|
+
}, row.hit.item.id);
|
|
1881
1510
|
})
|
|
1882
1511
|
})
|
|
1883
1512
|
]
|
|
1884
1513
|
});
|
|
1885
1514
|
}
|
|
1886
|
-
|
|
1887
|
-
return [c.title ?? "", c.preview ?? ""].join(" ").toLowerCase();
|
|
1888
|
-
}
|
|
1515
|
+
const conversationKeys = [(c) => c.title ?? "", (c) => c.preview ?? ""];
|
|
1889
1516
|
function previewLine(c) {
|
|
1890
1517
|
const when = relativeTime(c.updatedAt);
|
|
1891
1518
|
const prefix = c.preview ? c.preview.replace(/\s+/g, " ").trim() : "";
|
|
@@ -4311,12 +3938,12 @@ function FilterableModelList({ models, currentModel, modelLocked, agentId, onSel
|
|
|
4311
3938
|
const [saving, setSaving] = useState(false);
|
|
4312
3939
|
const [saveError, setSaveError] = useState(null);
|
|
4313
3940
|
const rest = useStore((s) => s.rest);
|
|
4314
|
-
const
|
|
4315
|
-
const filtered =
|
|
3941
|
+
const hits = fuzzyFilter(query, models, modelKeys);
|
|
3942
|
+
const filtered = hits.map((h) => h.item);
|
|
4316
3943
|
const clamped = Math.min(highlight, Math.max(0, filtered.length - 1));
|
|
4317
3944
|
const visibleRows = Math.max(3, height - 8);
|
|
4318
3945
|
const start = windowStart(clamped, filtered.length, visibleRows);
|
|
4319
|
-
const windowed =
|
|
3946
|
+
const windowed = hits.slice(start, start + visibleRows);
|
|
4320
3947
|
async function select(model) {
|
|
4321
3948
|
if (!rest || saving) return;
|
|
4322
3949
|
if (model.id === currentModel) {
|
|
@@ -4395,7 +4022,8 @@ function FilterableModelList({ models, currentModel, modelLocked, agentId, onSel
|
|
|
4395
4022
|
query,
|
|
4396
4023
|
"”"
|
|
4397
4024
|
]
|
|
4398
|
-
}) : windowed.map((
|
|
4025
|
+
}) : windowed.map((hit, i) => {
|
|
4026
|
+
const m = hit.item;
|
|
4399
4027
|
const selected = start + i === clamped;
|
|
4400
4028
|
const isCurrent = m.id === currentModel;
|
|
4401
4029
|
return /* @__PURE__ */ jsxs("text", {
|
|
@@ -4403,7 +4031,19 @@ function FilterableModelList({ models, currentModel, modelLocked, agentId, onSel
|
|
|
4403
4031
|
children: [
|
|
4404
4032
|
selected ? "› " : " ",
|
|
4405
4033
|
isCurrent ? "● " : " ",
|
|
4406
|
-
|
|
4034
|
+
m.providerDisplay ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(FuzzyText, {
|
|
4035
|
+
text: m.providerDisplay,
|
|
4036
|
+
indexes: hit.highlights[1]
|
|
4037
|
+
}), " · "] }) : null,
|
|
4038
|
+
/* @__PURE__ */ jsx(FuzzyText, {
|
|
4039
|
+
text: m.displayName,
|
|
4040
|
+
indexes: hit.highlights[0]
|
|
4041
|
+
}),
|
|
4042
|
+
" ",
|
|
4043
|
+
/* @__PURE__ */ jsx(FuzzyText, {
|
|
4044
|
+
text: m.id,
|
|
4045
|
+
indexes: hit.highlights[2]
|
|
4046
|
+
})
|
|
4407
4047
|
]
|
|
4408
4048
|
}, m.id);
|
|
4409
4049
|
})
|
|
@@ -4415,16 +4055,11 @@ function FilterableModelList({ models, currentModel, modelLocked, agentId, onSel
|
|
|
4415
4055
|
]
|
|
4416
4056
|
});
|
|
4417
4057
|
}
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
].join(" ").toLowerCase();
|
|
4424
|
-
}
|
|
4425
|
-
function modelLabel(m) {
|
|
4426
|
-
return `${m.providerDisplay ? `${m.providerDisplay} · ` : ""}${m.displayName} ${m.id}`;
|
|
4427
|
-
}
|
|
4058
|
+
const modelKeys = [
|
|
4059
|
+
(m) => m.displayName,
|
|
4060
|
+
(m) => m.providerDisplay ?? "",
|
|
4061
|
+
(m) => m.id
|
|
4062
|
+
];
|
|
4428
4063
|
/**
|
|
4429
4064
|
* The REST error routes return `{ "error": "message" }`. Pull that out for a
|
|
4430
4065
|
* human-readable status line; fall back to the raw body on anything else.
|