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.
@@ -1,18 +1,19 @@
1
1
  #!/usr/bin/env node
2
- import { A as getSavedTheme, C as themesForMode, D as DEFAULT_API_URL, E as setActiveWorkspace, M as saveTheme, O as DEFAULT_APP_URL, S as themeVersion, T as listWorkspaces, _ as noColorRequested, a as parseOauthConnectParams, b as themeMode, c as parseConnectCard, d as HttpError, f as createRestClient, g as monoTheme, h as findTheme, i as parseExternalOauthConnectParams, j as resolveWebUrl, k as getConfigPath, l as errorMessage, m as applyTheme, n as MASK_CHAR, o as reconcileMaskedInput, p as DEFAULT_THEME_ID, r as cardActionErrorMessage, s as resolveConnectUrl, t as resolveAgent, u as isRecord, v as theme, w as getActiveWorkspaceId, x as themeModeFromColorFgBg, y as themeForMode } from "./bin.mjs";
3
- import path, { basename, isAbsolute, join, win32 } from "node:path";
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 (/\s/.test(ch)) {
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 images using the filesystem and file magic,
2055
- * not extensions. The whole batch must be regular image files; otherwise the
2056
- * caller should restore the original paste as text.
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 resolveDroppedImages(paths) {
1645
+ async function resolveDroppedFiles(paths) {
2059
1646
  try {
2060
- const images = await Promise.all(paths.map(async (path) => {
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 detected = await fileTypeFromBuffer(data);
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: detected.mime,
1653
+ mediaType,
2068
1654
  data
2069
1655
  };
2070
1656
  }));
2071
- if (!images.every((image) => image !== null)) return null;
2072
- return images;
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__ */ jsx(Row, {
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
- if (text) out.push({
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
- * Builds the transcript bubble text for a sent user message: the typed text
5393
- * plus a line per attachment, so an image-only send still shows something.
5394
- */
5395
- function userItemText(text, attachments) {
5396
- return [text, ...attachments.map((a) => `📎 ${a.fileName}`)].filter((line) => line.length > 0).join("\n");
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 [pending, setPending] = useState([]);
5439
- const [uploading, setUploading] = useState(false);
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 && (pending.length > 0 || uploading);
5466
- const pendingRows = pendingVisible ? pending.length + 2 : 0;
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, attachments, opts) => {
5200
+ const sendContent = useCallback(async (content, staged, opts) => {
5603
5201
  if (!rest) return;
5604
5202
  const trimmed = content.trim();
5605
- if (!trimmed && attachments.length === 0) return;
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: userItemText(trimmed, attachments)
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: attachments.map((a) => a.id),
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: userItemText(trimmed, attachments)
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
- setInput((existing) => existing ? existing : trimmed);
5644
- setPending((prev) => prev.length > 0 ? prev : attachments);
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 attachments = pending;
5946
- if (!content && attachments.length === 0) return;
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
- setPending([]);
5964
- sendContent(content, attachments);
5594
+ setAttachments([]);
5595
+ sendContent(content, staged);
5965
5596
  }, [
5966
5597
  input,
5967
- pending,
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
- const stageImages = useCallback(async (images) => {
5982
- if (!rest || uploading || images.length === 0) return;
5983
- setUploading(true);
5984
- try {
5985
- for (const image of images) {
5986
- const uploaded = await rest.uploadAttachment({
5987
- agentId: agent.id,
5988
- fileName: image.fileName,
5989
- mediaType: image.mediaType,
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: `couldn't attach image: ${errorMessage(err)}`
5624
+ text: `attachment limit reached (${maxAttachments}) — ${fileName} skipped`
5999
5625
  }]);
6000
- } finally {
6001
- setUploading(false);
5626
+ return;
6002
5627
  }
6003
- }, [
6004
- rest,
6005
- uploading,
6006
- agent.id
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
- await stageImages([image]);
6012
- }, [stageImages]);
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
- stageImages([{
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
- resolveDroppedImages(route.paths).then((images) => {
6039
- if (images) {
6040
- stageImages(images);
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
- setPending((prev) => prev.slice(0, -1));
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: [pending.map((a) => /* @__PURE__ */ jsxs("text", {
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.id)), uploading ? /* @__PURE__ */ jsx("text", {
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
  })]