skydive-cli 0.1.0-beta.260 → 0.1.0-beta.286
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-C5ztfr8K.mjs → boot-D23rGRlW.mjs} +176 -523
- package/dist/js/client-_OL8-XGH.mjs +367 -0
- package/dist/js/rolldown-runtime-Cz4Tg37Z.mjs +19 -0
- package/package.json +1 -1
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { A as
|
|
3
|
-
import
|
|
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";
|
|
4
|
+
import path, { basename, extname, 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";
|
|
@@ -158,448 +159,6 @@ async function resolveInitialScreen({ rest, agentSelector }) {
|
|
|
158
159
|
};
|
|
159
160
|
}
|
|
160
161
|
|
|
161
|
-
//#endregion
|
|
162
|
-
//#region ../portal-protocol/src/index.ts
|
|
163
|
-
const MAX_WS_FRAME_BYTES = 16 * 1024 * 1024;
|
|
164
|
-
const T_DATA = 1;
|
|
165
|
-
const T_CTRL = 2;
|
|
166
|
-
const STREAM = {
|
|
167
|
-
stdout: 0,
|
|
168
|
-
stderr: 1,
|
|
169
|
-
stdin: 2
|
|
170
|
-
};
|
|
171
|
-
const uuidToBytes = (id) => Buffer.from(id.replace(/-/g, ""), "hex");
|
|
172
|
-
const bytesToUuid = (b) => {
|
|
173
|
-
const h = b.toString("hex");
|
|
174
|
-
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
|
|
175
|
-
};
|
|
176
|
-
function encodeData(id, stream, seq, payload) {
|
|
177
|
-
const head = Buffer.allocUnsafe(22);
|
|
178
|
-
head[0] = T_DATA;
|
|
179
|
-
uuidToBytes(id).copy(head, 1);
|
|
180
|
-
head[17] = stream;
|
|
181
|
-
head.writeUInt32BE(seq >>> 0, 18);
|
|
182
|
-
return Buffer.concat([head, payload]);
|
|
183
|
-
}
|
|
184
|
-
const ctrlMessageSchema = z.discriminatedUnion("t", [
|
|
185
|
-
z.object({
|
|
186
|
-
t: z.literal("open"),
|
|
187
|
-
argv: z.array(z.string()),
|
|
188
|
-
env: z.record(z.string()).nullable()
|
|
189
|
-
}),
|
|
190
|
-
z.object({ t: z.literal("stdin_eof") }),
|
|
191
|
-
z.object({ t: z.literal("pause") }),
|
|
192
|
-
z.object({ t: z.literal("resume") }),
|
|
193
|
-
z.object({ t: z.literal("cancel") }),
|
|
194
|
-
z.object({
|
|
195
|
-
t: z.literal("close"),
|
|
196
|
-
exitCode: z.number()
|
|
197
|
-
}),
|
|
198
|
-
z.object({
|
|
199
|
-
t: z.literal("error"),
|
|
200
|
-
message: z.string()
|
|
201
|
-
})
|
|
202
|
-
]);
|
|
203
|
-
function encodeCtrl(id, obj) {
|
|
204
|
-
const head = Buffer.allocUnsafe(17);
|
|
205
|
-
head[0] = T_CTRL;
|
|
206
|
-
uuidToBytes(id).copy(head, 1);
|
|
207
|
-
return Buffer.concat([head, Buffer.from(JSON.stringify(obj), "utf8")]);
|
|
208
|
-
}
|
|
209
|
-
function decodeFrame(frame) {
|
|
210
|
-
const id = bytesToUuid(frame.subarray(1, 17));
|
|
211
|
-
if (frame[0] === T_DATA) return {
|
|
212
|
-
kind: "data",
|
|
213
|
-
id,
|
|
214
|
-
stream: frame[17] ?? 0,
|
|
215
|
-
seq: frame.readUInt32BE(18),
|
|
216
|
-
payload: frame.subarray(22)
|
|
217
|
-
};
|
|
218
|
-
return {
|
|
219
|
-
kind: "ctrl",
|
|
220
|
-
id,
|
|
221
|
-
obj: ctrlMessageSchema.parse(JSON.parse(frame.subarray(17).toString("utf8")))
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
//#endregion
|
|
226
|
-
//#region src/chat/portal/machine.ts
|
|
227
|
-
/**
|
|
228
|
-
* Identity this machine registers under when the CLI shares it via the portal.
|
|
229
|
-
*
|
|
230
|
-
* The `-cli` suffix / `(CLI)` label keep a CLI-shared machine a DISTINCT portal
|
|
231
|
-
* device from the same host's Skydive Desktop app. `portal_device` is unique on
|
|
232
|
-
* (org, user, machineName), and directives route to whichever socket holds the
|
|
233
|
-
* device — if the CLI and desktop registered the same name they'd share a
|
|
234
|
-
* device row and both execute every directive. Distinct names also make the
|
|
235
|
-
* grant UI unambiguous about which surface is being authorized.
|
|
236
|
-
*/
|
|
237
|
-
function machineIdentity() {
|
|
238
|
-
const host = (os.hostname() || "machine").trim().replace(/\.local$/i, "") || "machine";
|
|
239
|
-
return {
|
|
240
|
-
machineName: `${host}-cli`,
|
|
241
|
-
friendlyName: `${host} (CLI)`
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
const INHERITED_ENV = [
|
|
245
|
-
"HOME",
|
|
246
|
-
"USER",
|
|
247
|
-
"LOGNAME",
|
|
248
|
-
"SHELL",
|
|
249
|
-
"LANG",
|
|
250
|
-
"LC_ALL",
|
|
251
|
-
"TMPDIR",
|
|
252
|
-
"TERM",
|
|
253
|
-
"PATH"
|
|
254
|
-
];
|
|
255
|
-
function buildEnv(extra) {
|
|
256
|
-
const env = {};
|
|
257
|
-
for (const key of INHERITED_ENV) {
|
|
258
|
-
const value = process.env[key];
|
|
259
|
-
if (value !== void 0) env[key] = value;
|
|
260
|
-
}
|
|
261
|
-
if (extra) for (const [key, value] of Object.entries(extra)) env[key] = value;
|
|
262
|
-
return env;
|
|
263
|
-
}
|
|
264
|
-
/**
|
|
265
|
-
* Build the desktop-portal WebSocket URL from the chat origin. Mirrors the Rust
|
|
266
|
-
* desktop client: http→ws, https→wss, scheme-less defaults to wss, and the
|
|
267
|
-
* machine/label ride as query pairs (percent-encoded by URL).
|
|
268
|
-
*/
|
|
269
|
-
function portalWsUrl(appUrl, machine, label) {
|
|
270
|
-
const base = appUrl.replace(/\/+$/, "");
|
|
271
|
-
let wsBase;
|
|
272
|
-
if (base.startsWith("https://")) wsBase = `wss://${base.slice(8)}`;
|
|
273
|
-
else if (base.startsWith("http://")) wsBase = `ws://${base.slice(7)}`;
|
|
274
|
-
else wsBase = `wss://${base}`;
|
|
275
|
-
const url = new URL(`${wsBase}/api/v1/portal/desktop`);
|
|
276
|
-
url.searchParams.set("machine", machine);
|
|
277
|
-
url.searchParams.set("label", label);
|
|
278
|
-
return url.toString();
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
//#endregion
|
|
282
|
-
//#region src/chat/portal/exec.ts
|
|
283
|
-
/**
|
|
284
|
-
* Runs portal `exec` directives locally. Each `open` spawns a child process
|
|
285
|
-
* whose stdout/stderr stream back as data frames and whose stdin is fed by
|
|
286
|
-
* inbound data frames, with pause/resume backpressure and cancel/teardown that
|
|
287
|
-
* kill the child. This is the TypeScript counterpart of the desktop's Rust
|
|
288
|
-
* `portal/mod.rs` job machinery, minus the connection supervision (which lives
|
|
289
|
-
* in the client).
|
|
290
|
-
*/
|
|
291
|
-
var JobManager = class {
|
|
292
|
-
jobs = /* @__PURE__ */ new Map();
|
|
293
|
-
constructor(opts) {
|
|
294
|
-
this.opts = opts;
|
|
295
|
-
}
|
|
296
|
-
handleFrame(raw) {
|
|
297
|
-
let decoded;
|
|
298
|
-
try {
|
|
299
|
-
decoded = decodeFrame(raw);
|
|
300
|
-
} catch (_error) {
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
303
|
-
if (decoded.kind === "ctrl") this.handleCtrl(decoded.id, decoded.obj);
|
|
304
|
-
else if (decoded.stream === STREAM.stdin) this.jobs.get(decoded.id)?.child.stdin.write(decoded.payload);
|
|
305
|
-
}
|
|
306
|
-
killAll() {
|
|
307
|
-
for (const job of this.jobs.values()) {
|
|
308
|
-
job.settled = true;
|
|
309
|
-
job.child.kill("SIGKILL");
|
|
310
|
-
}
|
|
311
|
-
this.jobs.clear();
|
|
312
|
-
}
|
|
313
|
-
handleCtrl(id, msg) {
|
|
314
|
-
switch (msg.t) {
|
|
315
|
-
case "open":
|
|
316
|
-
this.startJob(id, msg.argv, msg.env);
|
|
317
|
-
return;
|
|
318
|
-
case "stdin_eof":
|
|
319
|
-
this.jobs.get(id)?.child.stdin.end();
|
|
320
|
-
return;
|
|
321
|
-
case "pause":
|
|
322
|
-
this.setPaused(id, true);
|
|
323
|
-
return;
|
|
324
|
-
case "resume":
|
|
325
|
-
this.setPaused(id, false);
|
|
326
|
-
return;
|
|
327
|
-
case "cancel":
|
|
328
|
-
this.jobs.get(id)?.child.kill("SIGKILL");
|
|
329
|
-
return;
|
|
330
|
-
case "close":
|
|
331
|
-
case "error": return;
|
|
332
|
-
default: return msg;
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
setPaused(id, paused) {
|
|
336
|
-
const job = this.jobs.get(id);
|
|
337
|
-
if (!job) return;
|
|
338
|
-
if (paused) {
|
|
339
|
-
job.child.stdout.pause();
|
|
340
|
-
job.child.stderr.pause();
|
|
341
|
-
} else {
|
|
342
|
-
job.child.stdout.resume();
|
|
343
|
-
job.child.stderr.resume();
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
startJob(id, argv, env) {
|
|
347
|
-
const [program, ...args] = argv;
|
|
348
|
-
if (!program) {
|
|
349
|
-
this.opts.send(encodeCtrl(id, {
|
|
350
|
-
t: "error",
|
|
351
|
-
message: "empty argv"
|
|
352
|
-
}));
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
let child;
|
|
356
|
-
try {
|
|
357
|
-
child = spawn(program, args, {
|
|
358
|
-
cwd: this.opts.cwd,
|
|
359
|
-
env: buildEnv(env),
|
|
360
|
-
stdio: [
|
|
361
|
-
"pipe",
|
|
362
|
-
"pipe",
|
|
363
|
-
"pipe"
|
|
364
|
-
]
|
|
365
|
-
});
|
|
366
|
-
} catch (err) {
|
|
367
|
-
this.opts.send(encodeCtrl(id, {
|
|
368
|
-
t: "error",
|
|
369
|
-
message: `spawn failed: ${errorMessage(err)}`
|
|
370
|
-
}));
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
const job = {
|
|
374
|
-
child,
|
|
375
|
-
seq: 0,
|
|
376
|
-
settled: false
|
|
377
|
-
};
|
|
378
|
-
this.jobs.set(id, job);
|
|
379
|
-
child.on("error", (err) => {
|
|
380
|
-
if (job.settled) return;
|
|
381
|
-
job.settled = true;
|
|
382
|
-
this.jobs.delete(id);
|
|
383
|
-
this.opts.send(encodeCtrl(id, {
|
|
384
|
-
t: "error",
|
|
385
|
-
message: `spawn failed: ${errorMessage(err)}`
|
|
386
|
-
}));
|
|
387
|
-
});
|
|
388
|
-
child.stdout.on("data", (chunk) => this.sendData(job, id, STREAM.stdout, chunk));
|
|
389
|
-
child.stderr.on("data", (chunk) => this.sendData(job, id, STREAM.stderr, chunk));
|
|
390
|
-
child.on("close", (code) => {
|
|
391
|
-
if (job.settled) return;
|
|
392
|
-
job.settled = true;
|
|
393
|
-
this.jobs.delete(id);
|
|
394
|
-
this.opts.send(encodeCtrl(id, {
|
|
395
|
-
t: "close",
|
|
396
|
-
exitCode: code ?? -1
|
|
397
|
-
}));
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
sendData(job, id, stream, chunk) {
|
|
401
|
-
if (job.settled) return;
|
|
402
|
-
this.opts.send(encodeData(id, stream, job.seq, chunk));
|
|
403
|
-
job.seq = job.seq + 1 >>> 0;
|
|
404
|
-
}
|
|
405
|
-
};
|
|
406
|
-
|
|
407
|
-
//#endregion
|
|
408
|
-
//#region src/chat/portal/client.ts
|
|
409
|
-
const deviceTokenSchema = z.object({ token: z.string().min(1) });
|
|
410
|
-
const devicesSchema = z.object({ devices: z.array(z.object({
|
|
411
|
-
id: z.string(),
|
|
412
|
-
machineName: z.string(),
|
|
413
|
-
grantedAgentIds: z.array(z.string())
|
|
414
|
-
})) });
|
|
415
|
-
const INITIAL_BACKOFF_MS = 500;
|
|
416
|
-
const MAX_BACKOFF_MS = 1e4;
|
|
417
|
-
/**
|
|
418
|
-
* Shares the local machine with agents over the portal: dials OUT to the api's
|
|
419
|
-
* desktop-portal WebSocket (authenticating with a short-lived device token
|
|
420
|
-
* minted from the CLI session), then runs inbound `exec` directives via a
|
|
421
|
-
* `JobManager`. Reconnects with backoff while enabled; disabling drops presence
|
|
422
|
-
* and kills any in-flight children. No inbound port is ever opened.
|
|
423
|
-
*
|
|
424
|
-
* Access stays default-deny: connecting only makes the machine reachable — an
|
|
425
|
-
* agent can't run anything until the user grants it (`grantAgent`).
|
|
426
|
-
*/
|
|
427
|
-
var PortalClient = class {
|
|
428
|
-
enabled = false;
|
|
429
|
-
disposed = false;
|
|
430
|
-
ws = null;
|
|
431
|
-
jobs = null;
|
|
432
|
-
status = "off";
|
|
433
|
-
error = null;
|
|
434
|
-
deviceId = null;
|
|
435
|
-
granted = /* @__PURE__ */ new Set();
|
|
436
|
-
machineName;
|
|
437
|
-
friendlyName;
|
|
438
|
-
constructor(opts) {
|
|
439
|
-
this.opts = opts;
|
|
440
|
-
const identity = machineIdentity();
|
|
441
|
-
this.machineName = identity.machineName;
|
|
442
|
-
this.friendlyName = identity.friendlyName;
|
|
443
|
-
}
|
|
444
|
-
isEnabled() {
|
|
445
|
-
return this.enabled;
|
|
446
|
-
}
|
|
447
|
-
isGranted(agentId) {
|
|
448
|
-
return this.granted.has(agentId);
|
|
449
|
-
}
|
|
450
|
-
enable() {
|
|
451
|
-
if (this.enabled || this.disposed) return;
|
|
452
|
-
this.enabled = true;
|
|
453
|
-
this.error = null;
|
|
454
|
-
this.connectLoop();
|
|
455
|
-
}
|
|
456
|
-
disable() {
|
|
457
|
-
if (!this.enabled) return;
|
|
458
|
-
this.enabled = false;
|
|
459
|
-
this.jobs?.killAll();
|
|
460
|
-
this.ws?.close();
|
|
461
|
-
this.ws = null;
|
|
462
|
-
this.deviceId = null;
|
|
463
|
-
this.granted = /* @__PURE__ */ new Set();
|
|
464
|
-
this.setStatus("off");
|
|
465
|
-
}
|
|
466
|
-
/**
|
|
467
|
-
* Tear down for good (app quit). Kills children synchronously and closes the
|
|
468
|
-
* socket so it stops holding the event loop open — otherwise the process
|
|
469
|
-
* would hang after the TUI is destroyed.
|
|
470
|
-
*/
|
|
471
|
-
dispose() {
|
|
472
|
-
this.disposed = true;
|
|
473
|
-
this.enabled = false;
|
|
474
|
-
this.jobs?.killAll();
|
|
475
|
-
this.jobs = null;
|
|
476
|
-
this.ws?.close();
|
|
477
|
-
this.ws = null;
|
|
478
|
-
}
|
|
479
|
-
/** Grant one agent access to this machine (default-deny; user-initiated). */
|
|
480
|
-
async grantAgent(agentId) {
|
|
481
|
-
const deviceId = await this.ensureDeviceId();
|
|
482
|
-
const res = await this.fetchJson(`/api/v1/portal/devices/${encodeURIComponent(deviceId)}/grants`, {
|
|
483
|
-
method: "POST",
|
|
484
|
-
body: JSON.stringify({ agentId })
|
|
485
|
-
});
|
|
486
|
-
if (!res.ok) throw new Error(`grant failed (${res.status}): ${await shortBody(res)}`);
|
|
487
|
-
this.granted.add(agentId);
|
|
488
|
-
this.emit();
|
|
489
|
-
}
|
|
490
|
-
setStatus(status, error = null) {
|
|
491
|
-
this.status = status;
|
|
492
|
-
this.error = error;
|
|
493
|
-
this.emit();
|
|
494
|
-
}
|
|
495
|
-
emit() {
|
|
496
|
-
this.opts.onState({
|
|
497
|
-
status: this.status,
|
|
498
|
-
machineName: this.machineName,
|
|
499
|
-
friendlyName: this.friendlyName,
|
|
500
|
-
error: this.error,
|
|
501
|
-
grantedAgentIds: [...this.granted]
|
|
502
|
-
});
|
|
503
|
-
}
|
|
504
|
-
async connectLoop() {
|
|
505
|
-
let backoff = INITIAL_BACKOFF_MS;
|
|
506
|
-
while (this.enabled && !this.disposed) {
|
|
507
|
-
this.setStatus("connecting");
|
|
508
|
-
try {
|
|
509
|
-
const token = await this.mintDeviceToken();
|
|
510
|
-
await this.runConnection(token);
|
|
511
|
-
backoff = INITIAL_BACKOFF_MS;
|
|
512
|
-
} catch (err) {
|
|
513
|
-
if (!this.enabled || this.disposed) break;
|
|
514
|
-
this.setStatus("error", errorMessage(err));
|
|
515
|
-
}
|
|
516
|
-
if (!this.enabled || this.disposed) break;
|
|
517
|
-
await sleep(backoff);
|
|
518
|
-
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
runConnection(token) {
|
|
522
|
-
return new Promise((resolve) => {
|
|
523
|
-
const ws = new WebSocket(portalWsUrl(this.opts.appUrl, this.machineName, this.friendlyName), {
|
|
524
|
-
headers: { authorization: `Bearer ${token}` },
|
|
525
|
-
maxPayload: MAX_WS_FRAME_BYTES
|
|
526
|
-
});
|
|
527
|
-
this.ws = ws;
|
|
528
|
-
const jobs = new JobManager({
|
|
529
|
-
cwd: this.opts.cwd,
|
|
530
|
-
send: (frame) => {
|
|
531
|
-
if (ws.readyState === WebSocket.OPEN) ws.send(frame);
|
|
532
|
-
}
|
|
533
|
-
});
|
|
534
|
-
this.jobs = jobs;
|
|
535
|
-
ws.on("open", () => {
|
|
536
|
-
this.setStatus("connected");
|
|
537
|
-
this.refreshDevice();
|
|
538
|
-
});
|
|
539
|
-
ws.on("message", (data, isBinary) => {
|
|
540
|
-
if (isBinary) jobs.handleFrame(toBuffer$1(data));
|
|
541
|
-
});
|
|
542
|
-
ws.on("error", (err) => {
|
|
543
|
-
this.error = errorMessage(err);
|
|
544
|
-
});
|
|
545
|
-
ws.on("close", () => {
|
|
546
|
-
jobs.killAll();
|
|
547
|
-
if (this.jobs === jobs) this.jobs = null;
|
|
548
|
-
if (this.ws === ws) this.ws = null;
|
|
549
|
-
resolve();
|
|
550
|
-
});
|
|
551
|
-
});
|
|
552
|
-
}
|
|
553
|
-
async mintDeviceToken() {
|
|
554
|
-
const res = await this.fetchJson("/api/v1/portal/device-token", { method: "POST" });
|
|
555
|
-
if (!res.ok) throw new Error(`device-token failed (${res.status}): ${await shortBody(res)}`);
|
|
556
|
-
return deviceTokenSchema.parse(await res.json()).token;
|
|
557
|
-
}
|
|
558
|
-
async ensureDeviceId() {
|
|
559
|
-
if (this.deviceId) return this.deviceId;
|
|
560
|
-
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
561
|
-
await this.refreshDevice();
|
|
562
|
-
if (this.deviceId) return this.deviceId;
|
|
563
|
-
await sleep(300);
|
|
564
|
-
}
|
|
565
|
-
throw new Error("this machine is not connected yet");
|
|
566
|
-
}
|
|
567
|
-
async refreshDevice() {
|
|
568
|
-
try {
|
|
569
|
-
const res = await this.fetchJson("/api/v1/portal/devices", { method: "GET" });
|
|
570
|
-
if (!res.ok) return;
|
|
571
|
-
const { devices } = devicesSchema.parse(await res.json());
|
|
572
|
-
const mine = devices.find((device) => device.machineName === this.machineName);
|
|
573
|
-
if (!mine) return;
|
|
574
|
-
this.deviceId = mine.id;
|
|
575
|
-
this.granted = new Set(mine.grantedAgentIds);
|
|
576
|
-
this.emit();
|
|
577
|
-
} catch (_error) {}
|
|
578
|
-
}
|
|
579
|
-
fetchJson(path, init) {
|
|
580
|
-
return fetch(`${this.opts.appUrl}${path}`, {
|
|
581
|
-
method: init.method,
|
|
582
|
-
headers: {
|
|
583
|
-
authorization: `Bearer ${this.opts.sessionToken}`,
|
|
584
|
-
accept: "application/json",
|
|
585
|
-
...init.body ? { "content-type": "application/json" } : {}
|
|
586
|
-
},
|
|
587
|
-
...init.body ? { body: init.body } : {}
|
|
588
|
-
});
|
|
589
|
-
}
|
|
590
|
-
};
|
|
591
|
-
function toBuffer$1(data) {
|
|
592
|
-
if (Buffer.isBuffer(data)) return data;
|
|
593
|
-
if (Array.isArray(data)) return Buffer.concat(data);
|
|
594
|
-
return Buffer.from(data);
|
|
595
|
-
}
|
|
596
|
-
function shortBody(res) {
|
|
597
|
-
return res.text().then((text) => text.slice(0, 120)).catch(() => "");
|
|
598
|
-
}
|
|
599
|
-
function sleep(ms) {
|
|
600
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
601
|
-
}
|
|
602
|
-
|
|
603
162
|
//#endregion
|
|
604
163
|
//#region src/chat/portal/use-portal.ts
|
|
605
164
|
/**
|
|
@@ -2000,7 +1559,7 @@ function parseDroppedPaths(text) {
|
|
|
2000
1559
|
i += 1;
|
|
2001
1560
|
continue;
|
|
2002
1561
|
}
|
|
2003
|
-
if (
|
|
1562
|
+
if (ch === " " || ch === " ") {
|
|
2004
1563
|
if (current) {
|
|
2005
1564
|
tokens.push(current);
|
|
2006
1565
|
current = "";
|
|
@@ -2026,6 +1585,28 @@ function parseDroppedPaths(text) {
|
|
|
2026
1585
|
}
|
|
2027
1586
|
return paths;
|
|
2028
1587
|
}
|
|
1588
|
+
const TEXT_MIME_BY_EXT = {
|
|
1589
|
+
".txt": "text/plain",
|
|
1590
|
+
".md": "text/markdown",
|
|
1591
|
+
".markdown": "text/markdown",
|
|
1592
|
+
".csv": "text/csv",
|
|
1593
|
+
".tsv": "text/tab-separated-values",
|
|
1594
|
+
".json": "application/json",
|
|
1595
|
+
".yaml": "application/yaml",
|
|
1596
|
+
".yml": "application/yaml",
|
|
1597
|
+
".xml": "application/xml",
|
|
1598
|
+
".html": "text/html",
|
|
1599
|
+
".htm": "text/html",
|
|
1600
|
+
".css": "text/css",
|
|
1601
|
+
".js": "text/javascript",
|
|
1602
|
+
".ts": "text/plain",
|
|
1603
|
+
".tsx": "text/plain",
|
|
1604
|
+
".jsx": "text/plain",
|
|
1605
|
+
".py": "text/x-python",
|
|
1606
|
+
".sh": "text/x-shellscript",
|
|
1607
|
+
".log": "text/plain",
|
|
1608
|
+
".svg": "image/svg+xml"
|
|
1609
|
+
};
|
|
2029
1610
|
/** Decide how to handle a paste. `kind: 'binary'` events carry the mime of
|
|
2030
1611
|
* bytes the terminal forwarded; text events carry the decoded paste text.
|
|
2031
1612
|
*
|
|
@@ -2051,25 +1632,30 @@ function routePaste(input) {
|
|
|
2051
1632
|
return { kind: "text" };
|
|
2052
1633
|
}
|
|
2053
1634
|
/**
|
|
2054
|
-
* Resolve path candidates into
|
|
2055
|
-
*
|
|
2056
|
-
*
|
|
1635
|
+
* Resolve path candidates into attachable files. Every path must be an
|
|
1636
|
+
* existing regular file; otherwise the caller should restore the original
|
|
1637
|
+
* paste as text (an all-or-nothing rule so a paste meant as text — which
|
|
1638
|
+
* merely *looks* path-shaped — is never partially eaten).
|
|
1639
|
+
*
|
|
1640
|
+
* Any file type attaches. The mediaType comes from magic bytes when
|
|
1641
|
+
* detectable, an extension map for the plain-text formats magic can't see,
|
|
1642
|
+
* and application/octet-stream as the last resort — the attachments API
|
|
1643
|
+
* accepts any mediaType (100MB cap enforced server-side at presign).
|
|
2057
1644
|
*/
|
|
2058
|
-
async function
|
|
1645
|
+
async function resolveDroppedFiles(paths) {
|
|
2059
1646
|
try {
|
|
2060
|
-
const
|
|
1647
|
+
const files = await Promise.all(paths.map(async (path) => {
|
|
2061
1648
|
if (!(await stat(path)).isFile()) return null;
|
|
2062
1649
|
const data = new Uint8Array(await readFile(path));
|
|
2063
|
-
const
|
|
2064
|
-
if (!detected?.mime.startsWith("image/")) return null;
|
|
1650
|
+
const mediaType = (await fileTypeFromBuffer(data))?.mime ?? TEXT_MIME_BY_EXT[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
2065
1651
|
return {
|
|
2066
1652
|
fileName: basename(path),
|
|
2067
|
-
mediaType
|
|
1653
|
+
mediaType,
|
|
2068
1654
|
data
|
|
2069
1655
|
};
|
|
2070
1656
|
}));
|
|
2071
|
-
if (!
|
|
2072
|
-
return
|
|
1657
|
+
if (!files.every((file) => file !== null)) return null;
|
|
1658
|
+
return files;
|
|
2073
1659
|
} catch (_error) {
|
|
2074
1660
|
return null;
|
|
2075
1661
|
}
|
|
@@ -3215,12 +2801,15 @@ function Row({ barColor, children }) {
|
|
|
3215
2801
|
}
|
|
3216
2802
|
function RenderItem({ item }) {
|
|
3217
2803
|
switch (item.kind) {
|
|
3218
|
-
case "user": return /* @__PURE__ */
|
|
2804
|
+
case "user": return /* @__PURE__ */ jsxs(Row, {
|
|
3219
2805
|
barColor: theme.accent,
|
|
3220
|
-
children: /* @__PURE__ */ jsx("text", {
|
|
2806
|
+
children: [item.text ? /* @__PURE__ */ jsx("text", {
|
|
3221
2807
|
fg: theme.user,
|
|
3222
2808
|
children: item.text
|
|
3223
|
-
})
|
|
2809
|
+
}) : null, item.attachments && item.attachments.length > 0 ? /* @__PURE__ */ jsxs("text", {
|
|
2810
|
+
fg: theme.muted,
|
|
2811
|
+
children: ["⎘ ", item.attachments.join(" · ")]
|
|
2812
|
+
}) : null]
|
|
3224
2813
|
});
|
|
3225
2814
|
case "pending-steer": return /* @__PURE__ */ jsx(Row, {
|
|
3226
2815
|
barColor: theme.dim,
|
|
@@ -3641,10 +3230,15 @@ function uiMessagesToItems(messages) {
|
|
|
3641
3230
|
for (const m of messages) {
|
|
3642
3231
|
if (m.role === "user") {
|
|
3643
3232
|
const text = m.parts.map((p) => p.type === "text" ? p.text : "").join("").trim();
|
|
3644
|
-
|
|
3233
|
+
const attachments = m.parts.filter((p) => p.type === "data-anyone-attachment").map((p) => {
|
|
3234
|
+
const data = isRecord(p) && "data" in p && isRecord(p.data) ? p.data : null;
|
|
3235
|
+
return data && typeof data.fileName === "string" ? data.fileName : "file";
|
|
3236
|
+
});
|
|
3237
|
+
if (text || attachments.length > 0) out.push({
|
|
3645
3238
|
kind: "user",
|
|
3646
3239
|
id: m.id,
|
|
3647
|
-
text
|
|
3240
|
+
text,
|
|
3241
|
+
...attachments.length > 0 ? { attachments } : {}
|
|
3648
3242
|
});
|
|
3649
3243
|
continue;
|
|
3650
3244
|
}
|
|
@@ -5380,6 +4974,7 @@ const composerKeyBindings = [
|
|
|
5380
4974
|
];
|
|
5381
4975
|
/** Cap composer growth; past this the textarea scrolls its content instead. */
|
|
5382
4976
|
const maxComposerRows = 8;
|
|
4977
|
+
const maxAttachments = 10;
|
|
5383
4978
|
function isNewConversation(c) {
|
|
5384
4979
|
return "kind" in c && c.kind === "new";
|
|
5385
4980
|
}
|
|
@@ -5388,12 +4983,13 @@ function formatBytes(bytes) {
|
|
|
5388
4983
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
5389
4984
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
5390
4985
|
}
|
|
5391
|
-
/**
|
|
5392
|
-
|
|
5393
|
-
|
|
5394
|
-
|
|
5395
|
-
|
|
5396
|
-
|
|
4986
|
+
/** Status glyph for a staged attachment chip: uploading / ready / failed. */
|
|
4987
|
+
function attachmentGlyph(a) {
|
|
4988
|
+
switch (a.status) {
|
|
4989
|
+
case "uploading": return "↑";
|
|
4990
|
+
case "ready": return "✓";
|
|
4991
|
+
case "error": return `✗ ${a.errorText ?? "upload failed"}`;
|
|
4992
|
+
}
|
|
5397
4993
|
}
|
|
5398
4994
|
/**
|
|
5399
4995
|
* Open a URL in the user's local browser. Best-effort: over SSH there may be
|
|
@@ -5435,14 +5031,16 @@ function ChatScreen({ agent, conversation }) {
|
|
|
5435
5031
|
const [helpOpen, setHelpOpen] = useState(false);
|
|
5436
5032
|
const [ctrlCArmed, setCtrlCArmed] = useState(false);
|
|
5437
5033
|
const [composerRows, setComposerRows] = useState(1);
|
|
5438
|
-
const [
|
|
5439
|
-
const
|
|
5034
|
+
const [attachments, setAttachments] = useState([]);
|
|
5035
|
+
const uploadsRef = useRef(/* @__PURE__ */ new Map());
|
|
5440
5036
|
const [credPrompt, setCredPrompt] = useState(null);
|
|
5441
5037
|
const credPromptOpen = credPrompt !== null;
|
|
5442
5038
|
const runRef = useRef(run);
|
|
5443
5039
|
runRef.current = run;
|
|
5444
5040
|
const itemsRef = useRef(items);
|
|
5445
5041
|
itemsRef.current = items;
|
|
5042
|
+
const attachmentsRef = useRef(attachments);
|
|
5043
|
+
attachmentsRef.current = attachments;
|
|
5446
5044
|
const inputRef = useRef(input);
|
|
5447
5045
|
inputRef.current = input;
|
|
5448
5046
|
const credPromptRef = useRef(credPrompt);
|
|
@@ -5462,8 +5060,8 @@ function ChatScreen({ agent, conversation }) {
|
|
|
5462
5060
|
const scrollRef = useRef(null);
|
|
5463
5061
|
const composerRef = useRef(null);
|
|
5464
5062
|
const composerBoxHeight = composerRows + 2;
|
|
5465
|
-
const pendingVisible = !grantPrompt && !credPrompt &&
|
|
5466
|
-
const pendingRows = pendingVisible ?
|
|
5063
|
+
const pendingVisible = !grantPrompt && !credPrompt && attachments.length > 0;
|
|
5064
|
+
const pendingRows = pendingVisible ? attachments.length + 2 : 0;
|
|
5467
5065
|
const credPromptHeight = 4 + (credPrompt && (credPrompt.error || credPrompt.submitting) ? 1 : 0);
|
|
5468
5066
|
const bottomBoxHeight = credPrompt ? credPromptHeight : composerBoxHeight;
|
|
5469
5067
|
const scrollHeight = Math.max(3, height - 5 - bottomBoxHeight - pendingRows);
|
|
@@ -5599,34 +5197,64 @@ function ChatScreen({ agent, conversation }) {
|
|
|
5599
5197
|
cancelled = true;
|
|
5600
5198
|
};
|
|
5601
5199
|
}, []);
|
|
5602
|
-
const sendContent = useCallback(async (content,
|
|
5200
|
+
const sendContent = useCallback(async (content, staged, opts) => {
|
|
5603
5201
|
if (!rest) return;
|
|
5604
5202
|
const trimmed = content.trim();
|
|
5605
|
-
if (!trimmed &&
|
|
5203
|
+
if (!trimmed && staged.length === 0) return;
|
|
5606
5204
|
const echo = opts?.echo !== false;
|
|
5607
5205
|
const optimisticId = crypto.randomUUID();
|
|
5206
|
+
const stagedNames = staged.map((a) => a.fileName);
|
|
5608
5207
|
if (echo) setItems((prev) => [...prev, {
|
|
5609
5208
|
kind: "user",
|
|
5610
5209
|
id: optimisticId,
|
|
5611
|
-
text:
|
|
5210
|
+
text: trimmed,
|
|
5211
|
+
...stagedNames.length > 0 ? { attachments: stagedNames } : {}
|
|
5612
5212
|
}]);
|
|
5613
5213
|
const wasStreaming = runRef.current.kind === "streaming";
|
|
5614
5214
|
if (!wasStreaming) setRun({ kind: "sending" });
|
|
5615
5215
|
try {
|
|
5216
|
+
const attachmentIds = [];
|
|
5217
|
+
const restorable = [];
|
|
5218
|
+
const settled = await Promise.allSettled(staged.map((a) => uploadsRef.current.get(a.tempId)));
|
|
5219
|
+
for (const [i, result] of settled.entries()) {
|
|
5220
|
+
const row = staged[i];
|
|
5221
|
+
if (!row) continue;
|
|
5222
|
+
if (result.status === "fulfilled" && result.value) {
|
|
5223
|
+
attachmentIds.push(result.value.id);
|
|
5224
|
+
restorable.push({
|
|
5225
|
+
...row,
|
|
5226
|
+
status: "ready",
|
|
5227
|
+
errorText: null
|
|
5228
|
+
});
|
|
5229
|
+
} else {
|
|
5230
|
+
uploadsRef.current.delete(row.tempId);
|
|
5231
|
+
setItems((prev) => [...prev, {
|
|
5232
|
+
kind: "error",
|
|
5233
|
+
id: crypto.randomUUID(),
|
|
5234
|
+
text: `attachment failed: ${row.fileName}${result.status === "rejected" ? ` (${errorMessage(result.reason)})` : ""}`
|
|
5235
|
+
}]);
|
|
5236
|
+
}
|
|
5237
|
+
}
|
|
5238
|
+
if (!trimmed && attachmentIds.length === 0) {
|
|
5239
|
+
setItems((prev) => prev.filter((m) => m.id !== optimisticId));
|
|
5240
|
+
if (!wasStreaming) setRun({ kind: "idle" });
|
|
5241
|
+
return;
|
|
5242
|
+
}
|
|
5616
5243
|
const result = await rest.sendMessage({
|
|
5617
5244
|
agentId: agent.id,
|
|
5618
5245
|
conversationId,
|
|
5619
5246
|
content: trimmed,
|
|
5620
|
-
attachmentIds
|
|
5247
|
+
attachmentIds,
|
|
5621
5248
|
clientSurface: "tui"
|
|
5622
5249
|
});
|
|
5250
|
+
for (const row of restorable) uploadsRef.current.delete(row.tempId);
|
|
5623
5251
|
if (result.isNewConversation) setConversationId(result.conversationId);
|
|
5624
5252
|
if (result.steered) {
|
|
5625
5253
|
const directiveId = result.directive?.id;
|
|
5626
5254
|
if (directiveId) setItems((prev) => prev.map((m) => m.id === optimisticId ? {
|
|
5627
5255
|
kind: "pending-steer",
|
|
5628
5256
|
id: directiveId,
|
|
5629
|
-
text:
|
|
5257
|
+
text: trimmed
|
|
5630
5258
|
} : m));
|
|
5631
5259
|
const current = runRef.current;
|
|
5632
5260
|
if (!(current.kind === "streaming" && current.runId === result.runId)) attachToRun(result.runId);
|
|
@@ -5639,10 +5267,13 @@ function ChatScreen({ agent, conversation }) {
|
|
|
5639
5267
|
id: crypto.randomUUID(),
|
|
5640
5268
|
text: errorMessage(err)
|
|
5641
5269
|
}]);
|
|
5642
|
-
if (echo)
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5270
|
+
if (echo) setInput((existing) => existing ? existing : trimmed);
|
|
5271
|
+
const restore = staged.filter((a) => uploadsRef.current.has(a.tempId));
|
|
5272
|
+
if (restore.length > 0) setAttachments((prev) => [...restore.map((a) => ({
|
|
5273
|
+
...a,
|
|
5274
|
+
status: "ready",
|
|
5275
|
+
errorText: null
|
|
5276
|
+
})), ...prev]);
|
|
5646
5277
|
if (!wasStreaming) setRun({ kind: "idle" });
|
|
5647
5278
|
}
|
|
5648
5279
|
}, [
|
|
@@ -5942,8 +5573,8 @@ function ChatScreen({ agent, conversation }) {
|
|
|
5942
5573
|
]);
|
|
5943
5574
|
const submit = useCallback(() => {
|
|
5944
5575
|
const content = input.trim();
|
|
5945
|
-
const
|
|
5946
|
-
if (!content &&
|
|
5576
|
+
const staged = attachments.filter((a) => a.status !== "error");
|
|
5577
|
+
if (!content && staged.length === 0) return;
|
|
5947
5578
|
const routed = routeInput(content);
|
|
5948
5579
|
if (routed.kind !== "message") {
|
|
5949
5580
|
history.append(content);
|
|
@@ -5960,11 +5591,11 @@ function ChatScreen({ agent, conversation }) {
|
|
|
5960
5591
|
setInput("");
|
|
5961
5592
|
composerRef.current?.clear();
|
|
5962
5593
|
setComposerRows(1);
|
|
5963
|
-
|
|
5964
|
-
sendContent(content,
|
|
5594
|
+
setAttachments([]);
|
|
5595
|
+
sendContent(content, staged);
|
|
5965
5596
|
}, [
|
|
5966
5597
|
input,
|
|
5967
|
-
|
|
5598
|
+
attachments,
|
|
5968
5599
|
sendContent,
|
|
5969
5600
|
history,
|
|
5970
5601
|
runLocalShell,
|
|
@@ -5978,38 +5609,58 @@ function ChatScreen({ agent, conversation }) {
|
|
|
5978
5609
|
setInput(composer.plainText);
|
|
5979
5610
|
setComposerRows(Math.min(Math.max(composer.editorView.getTotalVirtualLineCount(), 1), maxComposerRows));
|
|
5980
5611
|
}, []);
|
|
5981
|
-
|
|
5982
|
-
|
|
5983
|
-
|
|
5984
|
-
|
|
5985
|
-
|
|
5986
|
-
|
|
5987
|
-
|
|
5988
|
-
|
|
5989
|
-
|
|
5990
|
-
data: image.data
|
|
5991
|
-
});
|
|
5992
|
-
setPending((prev) => [...prev, uploaded]);
|
|
5993
|
-
}
|
|
5994
|
-
} catch (err) {
|
|
5612
|
+
/**
|
|
5613
|
+
* Stage a pasted/dropped file in the composer tray and start its upload
|
|
5614
|
+
* immediately (the web composer's behavior). Submit awaits the in-flight
|
|
5615
|
+
* uploads and carries the resulting attachment ids; the composer never
|
|
5616
|
+
* blocks while an upload runs.
|
|
5617
|
+
*/
|
|
5618
|
+
const stageAttachment = useCallback(({ fileName, mediaType, data }) => {
|
|
5619
|
+
if (!rest) return;
|
|
5620
|
+
if (attachmentsRef.current.length >= maxAttachments) {
|
|
5995
5621
|
setItems((prev) => [...prev, {
|
|
5996
5622
|
kind: "error",
|
|
5997
5623
|
id: crypto.randomUUID(),
|
|
5998
|
-
text: `
|
|
5624
|
+
text: `attachment limit reached (${maxAttachments}) — ${fileName} skipped`
|
|
5999
5625
|
}]);
|
|
6000
|
-
|
|
6001
|
-
setUploading(false);
|
|
5626
|
+
return;
|
|
6002
5627
|
}
|
|
6003
|
-
|
|
6004
|
-
|
|
6005
|
-
|
|
6006
|
-
|
|
6007
|
-
|
|
5628
|
+
const tempId = crypto.randomUUID();
|
|
5629
|
+
setAttachments((prev) => [...prev, {
|
|
5630
|
+
tempId,
|
|
5631
|
+
fileName,
|
|
5632
|
+
sizeBytes: data.byteLength,
|
|
5633
|
+
status: "uploading",
|
|
5634
|
+
errorText: null
|
|
5635
|
+
}]);
|
|
5636
|
+
const promise = rest.uploadAttachment({
|
|
5637
|
+
agentId: agent.id,
|
|
5638
|
+
fileName,
|
|
5639
|
+
mediaType,
|
|
5640
|
+
data
|
|
5641
|
+
});
|
|
5642
|
+
uploadsRef.current.set(tempId, promise);
|
|
5643
|
+
promise.then(() => {
|
|
5644
|
+
setAttachments((prev) => prev.map((a) => a.tempId === tempId ? {
|
|
5645
|
+
...a,
|
|
5646
|
+
status: "ready"
|
|
5647
|
+
} : a));
|
|
5648
|
+
}).catch((err) => {
|
|
5649
|
+
setAttachments((prev) => prev.map((a) => a.tempId === tempId ? {
|
|
5650
|
+
...a,
|
|
5651
|
+
status: "error",
|
|
5652
|
+
errorText: errorMessage(err)
|
|
5653
|
+
} : a));
|
|
5654
|
+
});
|
|
5655
|
+
}, [rest, agent.id]);
|
|
5656
|
+
const stageFiles = useCallback((files) => {
|
|
5657
|
+
for (const file of files) stageAttachment(file);
|
|
5658
|
+
}, [stageAttachment]);
|
|
6008
5659
|
const pasteImage = useCallback(async () => {
|
|
6009
5660
|
const image = await readClipboardImage();
|
|
6010
5661
|
if (!image) return;
|
|
6011
|
-
|
|
6012
|
-
}, [
|
|
5662
|
+
stageAttachment(image);
|
|
5663
|
+
}, [stageAttachment]);
|
|
6013
5664
|
usePaste((event) => {
|
|
6014
5665
|
if (modelPickerOpen || themePickerOpen || helpOpen || credPromptOpen || grantPrompt) return;
|
|
6015
5666
|
const text = event.metadata?.kind === "binary" ? "" : decodePasteBytes(event.bytes);
|
|
@@ -6022,7 +5673,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
6022
5673
|
case "binary-image": {
|
|
6023
5674
|
event.preventDefault();
|
|
6024
5675
|
const ext = route.mediaType.split("/")[1]?.split("+")[0] ?? "png";
|
|
6025
|
-
|
|
5676
|
+
stageFiles([{
|
|
6026
5677
|
fileName: `pasted-image-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.${ext}`,
|
|
6027
5678
|
mediaType: route.mediaType,
|
|
6028
5679
|
data: event.bytes
|
|
@@ -6035,9 +5686,9 @@ function ChatScreen({ agent, conversation }) {
|
|
|
6035
5686
|
return;
|
|
6036
5687
|
case "dropped-paths":
|
|
6037
5688
|
event.preventDefault();
|
|
6038
|
-
|
|
6039
|
-
if (
|
|
6040
|
-
|
|
5689
|
+
resolveDroppedFiles(route.paths).then((files) => {
|
|
5690
|
+
if (files) {
|
|
5691
|
+
stageFiles(files);
|
|
6041
5692
|
return;
|
|
6042
5693
|
}
|
|
6043
5694
|
composerRef.current?.insertText(route.text);
|
|
@@ -6227,8 +5878,12 @@ function ChatScreen({ agent, conversation }) {
|
|
|
6227
5878
|
pasteImage();
|
|
6228
5879
|
return;
|
|
6229
5880
|
}
|
|
6230
|
-
if (key.name === "x" && key.ctrl) {
|
|
6231
|
-
|
|
5881
|
+
if (key.name === "x" && key.ctrl || key.name === "backspace" && attachmentsRef.current.length > 0 && !composerRef.current?.plainText) {
|
|
5882
|
+
const last = attachmentsRef.current.at(-1);
|
|
5883
|
+
if (last) {
|
|
5884
|
+
uploadsRef.current.delete(last.tempId);
|
|
5885
|
+
setAttachments((prev) => prev.filter((a) => a.tempId !== last.tempId));
|
|
5886
|
+
}
|
|
6232
5887
|
return;
|
|
6233
5888
|
}
|
|
6234
5889
|
if (key.name === "l" && key.ctrl) openInBrowser();
|
|
@@ -6347,19 +6002,17 @@ function ChatScreen({ agent, conversation }) {
|
|
|
6347
6002
|
marginTop: 1,
|
|
6348
6003
|
flexDirection: "column"
|
|
6349
6004
|
},
|
|
6350
|
-
children: [
|
|
6351
|
-
fg: theme.muted,
|
|
6005
|
+
children: [attachments.map((a) => /* @__PURE__ */ jsxs("text", {
|
|
6006
|
+
fg: a.status === "error" ? theme.error : theme.muted,
|
|
6352
6007
|
children: [
|
|
6353
6008
|
"📎 ",
|
|
6354
6009
|
a.fileName,
|
|
6355
6010
|
" (",
|
|
6356
6011
|
formatBytes(a.sizeBytes),
|
|
6357
|
-
")"
|
|
6012
|
+
") ",
|
|
6013
|
+
attachmentGlyph(a)
|
|
6358
6014
|
]
|
|
6359
|
-
}, a.
|
|
6360
|
-
fg: theme.dim,
|
|
6361
|
-
children: "uploading image…"
|
|
6362
|
-
}) : /* @__PURE__ */ jsx("text", {
|
|
6015
|
+
}, a.tempId)), /* @__PURE__ */ jsx("text", {
|
|
6363
6016
|
fg: theme.dim,
|
|
6364
6017
|
children: "ctrl+x remove last"
|
|
6365
6018
|
})]
|