skydive-cli 0.6.0-beta.19 → 0.6.0-beta.20

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,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { a as errorMessage, i as sendErrorMessage, n as errorDetail, o as isRecord, t as createRestClient } from "./rest-DQFiN7VL.mjs";
3
- import { C as DEFAULT_APP_URL, E as getConfigPath, K as saveTheme, L as recordDefaultAgent, M as getSavedTheme, S as DEFAULT_API_URL, U as resolveWebUrl, _ as parseConnectCard, c as actionableCardVerb, d as parseOauthConnectParams, f as prioritizeActionableCards, g as computeSettledLabel, h as composeQuestionAnswer, i as resolveAgent, j as getReviewStateDir, l as cardActionErrorMessage, m as resolveConnectUrl, p as reconcileMaskedInput, s as MASK_CHAR, u as parseExternalOauthConnectParams, v as specKeyFor } from "./print-BC2o90gX.mjs";
4
- import { A as installAgentAlias, C as themeVersion, D as machineOsFromPlatform, F as detectShell, L as getActiveWorkspaceId, M as takenAliasNames, O as aliasActivationHint, S as themeModeFromColorFgBg, T as themesForMode, V as setActiveWorkspace, _ as monoTheme, a as WORDMARK, b as themeForMode, d as profilingEnabled, f as record, g as findTheme, h as applyTheme, i as MARK_CELLS, j as slugifyAliasName, k as dedupeAliasName, m as DEFAULT_THEME_ID, n as buildCrashReport, p as writeArtifact, r as writeCrashReport, s as splashFitsWidth, t as installCrashHandler, v as noColorRequested, x as themeMode, y as theme, z as listWorkspaces } from "./install-Bs9OISd4.mjs";
2
+ import { a as errorMessage, i as sendErrorMessage, n as errorDetail, o as isRecord, t as createRestClient } from "./rest-iJiyyUZk.mjs";
3
+ import { C as DEFAULT_APP_URL, E as getConfigPath, K as saveTheme, L as recordDefaultAgent, M as getSavedTheme, S as DEFAULT_API_URL, U as resolveWebUrl, _ as parseConnectCard, c as actionableCardVerb, d as parseOauthConnectParams, f as prioritizeActionableCards, g as computeSettledLabel, h as composeQuestionAnswer, i as resolveAgent, j as getReviewStateDir, l as cardActionErrorMessage, m as resolveConnectUrl, p as reconcileMaskedInput, s as MASK_CHAR, u as parseExternalOauthConnectParams, v as specKeyFor } from "./print-BryBur1Q.mjs";
4
+ import { A as installAgentAlias, C as themeVersion, D as machineOsFromPlatform, F as detectShell, L as getActiveWorkspaceId, M as takenAliasNames, O as aliasActivationHint, S as themeModeFromColorFgBg, T as themesForMode, V as setActiveWorkspace, _ as monoTheme, a as WORDMARK, b as themeForMode, d as profilingEnabled, f as record, g as findTheme, h as applyTheme, i as MARK_CELLS, j as slugifyAliasName, k as dedupeAliasName, m as DEFAULT_THEME_ID, n as buildCrashReport, p as writeArtifact, r as writeCrashReport, s as splashFitsWidth, t as installCrashHandler, v as noColorRequested, x as themeMode, y as theme, z as listWorkspaces } from "./install-i9xERvoF.mjs";
5
5
  import { t as HttpError } from "./http-error-BF2NZZE3.mjs";
6
6
  import { i as billingBlockedOutcomeFromSendResponse } from "./billing-blocked-D3l5kJlX.mjs";
7
- import { t as PortalClient } from "./client-HWCoL5Jm.mjs";
7
+ import { t as PortalClient } from "./client-BPgSlxoE.mjs";
8
8
  import { t as defaultTlsCertSource } from "./tls-cert-Rua2oV7n.mjs";
9
9
  import "./api-BFQ4PQDA.mjs";
10
- import "./daemon-Dl3nN-w4.mjs";
10
+ import "./daemon-By6thwls.mjs";
11
11
  import { t as SandboxStream } from "./client-l6orYfqq.mjs";
12
- import { t as PortalDaemonClient } from "./daemon-client-BUakFQGS.mjs";
12
+ import { t as PortalDaemonClient } from "./daemon-client-CyYn91wO.mjs";
13
13
  import { t as runRawPtyPassthrough } from "./raw-pty-dndn1Kxc.mjs";
14
14
  import * as os$1 from "node:os";
15
15
  import { homedir, platform, release, tmpdir } from "node:os";
@@ -136,6 +136,7 @@ const useStore = create((set, get) => ({
136
136
  portalClient: null,
137
137
  toast: null,
138
138
  seedPrompt: null,
139
+ seedAttachments: [],
139
140
  autoGrantMachine: false,
140
141
  forcedOnboarding: false,
141
142
  earlyInput: "",
@@ -3120,6 +3121,393 @@ function WorkspacePicker({ appUrl, sessionToken, onSelect, onCancel }) {
3120
3121
  });
3121
3122
  }
3122
3123
 
3124
+ //#endregion
3125
+ //#region src/chat/paste.ts
3126
+ const MAX_PATH_PASTE_CHARS = 4096;
3127
+ /**
3128
+ * Decode a `file://` URL to a filesystem path, host-independently. Node's
3129
+ * `fileURLToPath` maps to the *running* OS's path shape, so a POSIX file URL
3130
+ * pasted on Windows would come back as a backslash drive-relative path. A
3131
+ * dropped-file URL is decided by its own shape, not by our host: a Windows
3132
+ * drive URL (`file:///C:/...`) yields a Windows path, anything else a POSIX
3133
+ * path. Returns null for a malformed URL.
3134
+ */
3135
+ function fileUrlToPath(url) {
3136
+ let parsed;
3137
+ try {
3138
+ parsed = new URL(url);
3139
+ } catch (_error) {
3140
+ return null;
3141
+ }
3142
+ if (parsed.protocol !== "file:") return null;
3143
+ const decoded = decodeURIComponent(parsed.pathname);
3144
+ if (/^\/[a-zA-Z]:\//.test(decoded)) return decoded.slice(1).replace(/\//g, "\\");
3145
+ return decoded;
3146
+ }
3147
+ /**
3148
+ * Parse the text a terminal emits for dropped files. This only identifies
3149
+ * path-shaped candidates; filesystem and content validation happen later.
3150
+ */
3151
+ function parseDroppedPaths(text) {
3152
+ const trimmed = text.trim();
3153
+ if (!trimmed || trimmed.length > MAX_PATH_PASTE_CHARS || /[\r\n]/.test(trimmed)) return null;
3154
+ const tokens = [];
3155
+ let current = "";
3156
+ let quote = null;
3157
+ for (let i = 0; i < trimmed.length; i += 1) {
3158
+ const ch = trimmed.charAt(i);
3159
+ if (quote) {
3160
+ if (ch === quote) quote = null;
3161
+ else if (ch === "\\" && quote === "\"" && i + 1 < trimmed.length) {
3162
+ current += trimmed.charAt(i + 1);
3163
+ i += 1;
3164
+ } else current += ch;
3165
+ continue;
3166
+ }
3167
+ if (ch === "'" || ch === "\"") {
3168
+ quote = ch;
3169
+ continue;
3170
+ }
3171
+ if (ch === "\\" && i + 1 < trimmed.length) {
3172
+ current += trimmed.charAt(i + 1);
3173
+ i += 1;
3174
+ continue;
3175
+ }
3176
+ if (ch === " " || ch === " ") {
3177
+ if (current) {
3178
+ tokens.push(current);
3179
+ current = "";
3180
+ }
3181
+ continue;
3182
+ }
3183
+ current += ch;
3184
+ }
3185
+ if (quote) return null;
3186
+ if (current) tokens.push(current);
3187
+ if (tokens.length === 0) return null;
3188
+ const paths = [];
3189
+ for (const token of tokens) {
3190
+ let path = token;
3191
+ if (path.startsWith("file://")) {
3192
+ const resolved = fileUrlToPath(path);
3193
+ if (resolved === null) return null;
3194
+ path = resolved;
3195
+ }
3196
+ if (path.startsWith("~/")) path = `${homedir()}${path.slice(1)}`;
3197
+ if (!isAbsolute(path) && !win32.isAbsolute(path) && !path.startsWith("./") && !path.startsWith("../")) return null;
3198
+ paths.push(path);
3199
+ }
3200
+ return paths;
3201
+ }
3202
+ const TEXT_MIME_BY_EXT = {
3203
+ ".txt": "text/plain",
3204
+ ".md": "text/markdown",
3205
+ ".markdown": "text/markdown",
3206
+ ".csv": "text/csv",
3207
+ ".tsv": "text/tab-separated-values",
3208
+ ".json": "application/json",
3209
+ ".yaml": "application/yaml",
3210
+ ".yml": "application/yaml",
3211
+ ".xml": "application/xml",
3212
+ ".html": "text/html",
3213
+ ".htm": "text/html",
3214
+ ".css": "text/css",
3215
+ ".js": "text/javascript",
3216
+ ".ts": "text/plain",
3217
+ ".tsx": "text/plain",
3218
+ ".jsx": "text/plain",
3219
+ ".py": "text/x-python",
3220
+ ".sh": "text/x-shellscript",
3221
+ ".log": "text/plain",
3222
+ ".svg": "image/svg+xml"
3223
+ };
3224
+ /** Decide how to handle a paste. `kind: 'binary'` events carry the mime of
3225
+ * bytes the terminal forwarded; text events carry the decoded paste text.
3226
+ *
3227
+ * The empty-text case is the fix for "can't paste an image": on macOS the
3228
+ * reflex is Cmd+V, which the terminal turns into a bracketed *text* paste, so
3229
+ * a screenshot on the clipboard arrives as empty bytes. We route that to an OS
3230
+ * clipboard read instead of dropping it, matching the explicit ctrl+v path. */
3231
+ function routePaste(input) {
3232
+ if (input.kind === "binary") {
3233
+ if (input.mimeType?.startsWith("image/")) return {
3234
+ kind: "binary-image",
3235
+ mediaType: input.mimeType
3236
+ };
3237
+ return { kind: "text" };
3238
+ }
3239
+ const paths = parseDroppedPaths(input.text);
3240
+ if (paths) return {
3241
+ kind: "dropped-paths",
3242
+ paths,
3243
+ text: input.text
3244
+ };
3245
+ if (input.text.trim() === "") return { kind: "clipboard-image" };
3246
+ return { kind: "text" };
3247
+ }
3248
+ /**
3249
+ * Resolve path candidates into attachable files. Every path must be an
3250
+ * existing regular file; otherwise the caller should restore the original
3251
+ * paste as text (an all-or-nothing rule so a paste meant as text — which
3252
+ * merely *looks* path-shaped — is never partially eaten).
3253
+ *
3254
+ * Any file type attaches. The mediaType comes from magic bytes when
3255
+ * detectable, an extension map for the plain-text formats magic can't see,
3256
+ * and application/octet-stream as the last resort — the attachments API
3257
+ * accepts any mediaType (100MB cap enforced server-side at presign).
3258
+ */
3259
+ async function resolveDroppedFiles(paths) {
3260
+ try {
3261
+ const files = await Promise.all(paths.map(async (path) => {
3262
+ if (!(await stat(path)).isFile()) return null;
3263
+ const data = new Uint8Array(await readFile(path));
3264
+ const mediaType = (await fileTypeFromBuffer(data))?.mime ?? TEXT_MIME_BY_EXT[extname(path).toLowerCase()] ?? "application/octet-stream";
3265
+ return {
3266
+ fileName: basename(path),
3267
+ mediaType,
3268
+ data
3269
+ };
3270
+ }));
3271
+ if (!files.every((file) => file !== null)) return null;
3272
+ return files;
3273
+ } catch (_error) {
3274
+ return null;
3275
+ }
3276
+ }
3277
+
3278
+ //#endregion
3279
+ //#region src/chat/tui/composer-attachments.tsx
3280
+ /**
3281
+ * Attachments a composer can stage for its next send, shared by every screen
3282
+ * that owns a composer (the chat transcript and the agent home). Staging
3283
+ * starts the upload immediately — the web composer's behavior — so submit
3284
+ * only awaits the in-flight uploads and collects their server ids.
3285
+ */
3286
+ /** Files one composer can hold at once, so a stray multi-file drop can't
3287
+ * queue an unbounded pile of uploads. */
3288
+ const MAX_COMPOSER_ATTACHMENTS = 10;
3289
+ /**
3290
+ * Owns a composer's staged attachments and the paste routing that feeds them.
3291
+ *
3292
+ * `pasteDisabled` suppresses attaching while the screen has something else in
3293
+ * front of the composer (an overlay, a prompt, a channel with no composer at
3294
+ * all); the paste then falls through to whatever is focused, untouched.
3295
+ * `onPasteText` restores a paste that only *looked* like dropped file paths —
3296
+ * the filesystem check is async, so the text has to be held and put back.
3297
+ * `onError` receives user-facing failures (the cap, a failed upload) because
3298
+ * each screen surfaces them differently: the transcript takes an error row,
3299
+ * the home takes its status line.
3300
+ */
3301
+ function useComposerAttachments(input) {
3302
+ const { rest, agentId, pasteDisabled, onPasteText, onError } = input;
3303
+ const [staged, setStaged] = useState([]);
3304
+ const uploadsRef = useRef(/* @__PURE__ */ new Map());
3305
+ const stagedRef = useRef(staged);
3306
+ stagedRef.current = staged;
3307
+ const stageFile = useCallback(({ fileName, mediaType, data }) => {
3308
+ if (!rest) return;
3309
+ if (stagedRef.current.length >= MAX_COMPOSER_ATTACHMENTS) {
3310
+ onError(`attachment limit reached (${MAX_COMPOSER_ATTACHMENTS}); ${fileName} skipped`);
3311
+ return;
3312
+ }
3313
+ const tempId = crypto.randomUUID();
3314
+ setStaged((prev) => [...prev, {
3315
+ tempId,
3316
+ fileName,
3317
+ sizeBytes: data.byteLength,
3318
+ status: "uploading",
3319
+ errorText: null
3320
+ }]);
3321
+ const promise = rest.uploadAttachment({
3322
+ agentId,
3323
+ fileName,
3324
+ mediaType,
3325
+ data
3326
+ });
3327
+ uploadsRef.current.set(tempId, promise);
3328
+ promise.then(() => {
3329
+ setStaged((prev) => prev.map((a) => a.tempId === tempId ? {
3330
+ ...a,
3331
+ status: "ready"
3332
+ } : a));
3333
+ }).catch((err) => {
3334
+ setStaged((prev) => prev.map((a) => a.tempId === tempId ? {
3335
+ ...a,
3336
+ status: "error",
3337
+ errorText: errorMessage(err)
3338
+ } : a));
3339
+ });
3340
+ }, [
3341
+ rest,
3342
+ agentId,
3343
+ onError
3344
+ ]);
3345
+ const stageFiles = useCallback((files) => {
3346
+ for (const file of files) stageFile(file);
3347
+ }, [stageFile]);
3348
+ const stageClipboardImage = useCallback(async () => {
3349
+ const image = await readClipboardImage();
3350
+ if (!image) return;
3351
+ stageFile(image);
3352
+ }, [stageFile]);
3353
+ const dropLast = useCallback(() => {
3354
+ const last = stagedRef.current.at(-1);
3355
+ if (!last) return;
3356
+ uploadsRef.current.delete(last.tempId);
3357
+ setStaged((prev) => prev.filter((a) => a.tempId !== last.tempId));
3358
+ }, []);
3359
+ const clearStaged = useCallback(() => setStaged([]), []);
3360
+ const collectForSend = useCallback(async (staged) => {
3361
+ const files = [];
3362
+ const rows = [];
3363
+ const failed = [];
3364
+ const settled = await Promise.allSettled(staged.map((a) => uploadsRef.current.get(a.tempId)));
3365
+ for (const [i, result] of settled.entries()) {
3366
+ const row = staged[i];
3367
+ if (!row) continue;
3368
+ if (result.status === "fulfilled" && result.value) {
3369
+ files.push(result.value);
3370
+ rows.push({
3371
+ ...row,
3372
+ status: "ready",
3373
+ errorText: null
3374
+ });
3375
+ } else {
3376
+ uploadsRef.current.delete(row.tempId);
3377
+ failed.push({
3378
+ attachment: row,
3379
+ reason: result.status === "rejected" ? result.reason : null
3380
+ });
3381
+ }
3382
+ }
3383
+ return {
3384
+ files,
3385
+ rows,
3386
+ failed
3387
+ };
3388
+ }, []);
3389
+ const releaseUploaded = useCallback((rows) => {
3390
+ for (const row of rows) uploadsRef.current.delete(row.tempId);
3391
+ }, []);
3392
+ const adoptUploaded = useCallback((uploaded) => uploaded.map((file) => {
3393
+ const tempId = crypto.randomUUID();
3394
+ uploadsRef.current.set(tempId, Promise.resolve(file));
3395
+ return {
3396
+ tempId,
3397
+ fileName: file.fileName,
3398
+ sizeBytes: file.sizeBytes,
3399
+ status: "ready",
3400
+ errorText: null
3401
+ };
3402
+ }), []);
3403
+ const restoreStaged = useCallback((rows) => {
3404
+ const restore = rows.filter((a) => uploadsRef.current.has(a.tempId));
3405
+ if (restore.length === 0) return;
3406
+ setStaged((prev) => [...restore.map((a) => ({
3407
+ ...a,
3408
+ status: "ready",
3409
+ errorText: null
3410
+ })), ...prev]);
3411
+ }, []);
3412
+ usePaste((event) => {
3413
+ if (pasteDisabled) return;
3414
+ const text = event.metadata?.kind === "binary" ? "" : decodePasteBytes(event.bytes);
3415
+ const route = routePaste({
3416
+ kind: event.metadata?.kind,
3417
+ mimeType: event.metadata?.mimeType,
3418
+ text
3419
+ });
3420
+ switch (route.kind) {
3421
+ case "binary-image":
3422
+ event.preventDefault();
3423
+ stageFiles([{
3424
+ fileName: pastedImageName(route.mediaType),
3425
+ mediaType: route.mediaType,
3426
+ data: event.bytes
3427
+ }]);
3428
+ return;
3429
+ case "clipboard-image":
3430
+ event.preventDefault();
3431
+ stageClipboardImage();
3432
+ return;
3433
+ case "dropped-paths":
3434
+ event.preventDefault();
3435
+ resolveDroppedFiles(route.paths).then((files) => {
3436
+ if (files) {
3437
+ stageFiles(files);
3438
+ return;
3439
+ }
3440
+ onPasteText(route.text);
3441
+ });
3442
+ return;
3443
+ case "text": return;
3444
+ }
3445
+ });
3446
+ return {
3447
+ staged,
3448
+ stagedRef,
3449
+ stageFiles,
3450
+ stageClipboardImage,
3451
+ dropLast,
3452
+ clearStaged,
3453
+ collectForSend,
3454
+ adoptUploaded,
3455
+ releaseUploaded,
3456
+ restoreStaged
3457
+ };
3458
+ }
3459
+ /** Name for clipboard image bytes, which arrive with no filename of their
3460
+ * own: `pasted-image-<timestamp>.<ext>` from the paste's own media type. */
3461
+ function pastedImageName(mediaType) {
3462
+ const ext = mediaType.split("/")[1]?.split("+")[0] ?? "png";
3463
+ return `pasted-image-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.${ext}`;
3464
+ }
3465
+ function formatBytes$2(bytes) {
3466
+ if (bytes < 1024) return `${bytes} B`;
3467
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
3468
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
3469
+ }
3470
+ /** Status glyph for a staged attachment chip: uploading / ready / failed. */
3471
+ function attachmentGlyph(a) {
3472
+ switch (a.status) {
3473
+ case "uploading": return "↑";
3474
+ case "ready": return "✓";
3475
+ case "error": return `✗ ${a.errorText ?? "upload failed"}`;
3476
+ }
3477
+ }
3478
+ /** Rows a rendered tray occupies: one per attachment plus the hint line and
3479
+ * its top margin, so a screen can budget its own layout around it. */
3480
+ function attachmentTrayRows(count) {
3481
+ return count === 0 ? 0 : count + 2;
3482
+ }
3483
+ /** The staged-attachment tray under a composer: a chip per file with its
3484
+ * upload state, and the chord that removes the last one. */
3485
+ function AttachmentTray({ attachments }) {
3486
+ return /* @__PURE__ */ jsxs("box", {
3487
+ style: {
3488
+ paddingLeft: 1,
3489
+ paddingRight: 1,
3490
+ flexShrink: 0,
3491
+ marginTop: 1,
3492
+ flexDirection: "column"
3493
+ },
3494
+ children: [attachments.map((a) => /* @__PURE__ */ jsxs("text", {
3495
+ fg: a.status === "error" ? theme.error : theme.muted,
3496
+ children: [
3497
+ "📎 ",
3498
+ a.fileName,
3499
+ " (",
3500
+ formatBytes$2(a.sizeBytes),
3501
+ ") ",
3502
+ attachmentGlyph(a)
3503
+ ]
3504
+ }, a.tempId)), /* @__PURE__ */ jsx("text", {
3505
+ fg: theme.dim,
3506
+ children: "ctrl+x remove last"
3507
+ })]
3508
+ });
3509
+ }
3510
+
3123
3511
  //#endregion
3124
3512
  //#region src/chat/tui/list-line.ts
3125
3513
  /**
@@ -3474,6 +3862,17 @@ function ConversationHome({ agent, initialQuery, conversations, showAutomated, o
3474
3862
  const [archiveOverrides, setArchiveOverrides] = useState(/* @__PURE__ */ new Map());
3475
3863
  const [confirmId, setConfirmId] = useState(null);
3476
3864
  const [error, setError] = useState(null);
3865
+ const [sending, setSending] = useState(false);
3866
+ const { staged: attachments, stageClipboardImage, dropLast: dropLastAttachment, collectForSend } = useComposerAttachments({
3867
+ rest,
3868
+ agentId: agent.id,
3869
+ pasteDisabled: confirmId !== null || isSearchInput || sending,
3870
+ onPasteText: (text) => {
3871
+ composerRef.current?.insertText(text);
3872
+ handleComposerChange();
3873
+ },
3874
+ onError: setError
3875
+ });
3477
3876
  const isArchived = (c) => archiveOverrides.get(c.id) ?? c.viewerArchivedAt != null;
3478
3877
  const list = ((isSearching && search?.query === debouncedQuery && search.state.kind === "ready" ? search.state.conversations : null) ?? conversations).filter((c) => !removedIds.has(c.id) && (showArchived || !isArchived(c)));
3479
3878
  useEffect(() => {
@@ -3497,10 +3896,14 @@ function ConversationHome({ agent, initialQuery, conversations, showAutomated, o
3497
3896
  const searchState = isSearching ? search?.state : void 0;
3498
3897
  const searchStatusSegment = !isSearching ? "" : searchState?.kind === "loading" || search === null ? "searching…" : searchState?.kind === "error" ? `search failed: ${searchState.message}` : list.length === 0 ? "no matches" : "";
3499
3898
  const trimmedMessage = isSearchInput ? "" : input.trim();
3500
- const enterHint = isSearchInput ? "filtering" : trimmedMessage ? "↵ send" : "↵ open";
3899
+ const hasMessage = trimmedMessage.length > 0 || attachments.length > 0;
3900
+ const enterHint = isSearchInput ? "filtering" : hasMessage ? "↵ send" : "↵ open";
3501
3901
  const status = confirmConv ? {
3502
3902
  fg: theme.error,
3503
3903
  text: truncate(`delete “${confirmConv.title ?? "(untitled)"}”? y/n`, innerWidth)
3904
+ } : sending ? {
3905
+ fg: theme.muted,
3906
+ text: "uploading attachments…"
3504
3907
  } : error ? {
3505
3908
  fg: theme.error,
3506
3909
  text: truncate(error, innerWidth)
@@ -3519,7 +3922,8 @@ function ConversationHome({ agent, initialQuery, conversations, showAutomated, o
3519
3922
  ].filter((segment) => segment !== ""), innerWidth)
3520
3923
  };
3521
3924
  const identityRows = height >= 24 ? 3 : 0;
3522
- const fixedRows = 6 + brandHeaderRows(height, width) + identityRows;
3925
+ const trayRows = attachmentTrayRows(attachments.length);
3926
+ const fixedRows = 6 + brandHeaderRows(height, width) + identityRows + trayRows;
3523
3927
  const composerRows = Math.max(1, Math.min(composerLines, height - fixedRows - 2 - MIN_LIST_ROWS));
3524
3928
  const visibleRows = Math.max(MIN_LIST_ROWS, height - fixedRows - (composerRows + 2));
3525
3929
  const start = windowStart(clamped, rows.length, visibleRows);
@@ -3534,8 +3938,22 @@ function ConversationHome({ agent, initialQuery, conversations, showAutomated, o
3534
3938
  conversation: row.hit.item
3535
3939
  });
3536
3940
  };
3537
- const sendNewConversation = (message) => {
3538
- useStore.setState({ seedPrompt: message });
3941
+ const sendNewConversation = async (message) => {
3942
+ setSending(true);
3943
+ const { files, failed } = await collectForSend(attachments);
3944
+ setSending(false);
3945
+ if (!message && files.length === 0) {
3946
+ setError("attachment upload failed");
3947
+ return;
3948
+ }
3949
+ if (failed.length > 0) {
3950
+ const plural = failed.length === 1 ? "" : "s";
3951
+ setError(`${failed.length} attachment${plural} failed to upload; sending without ${plural ? "them" : "it"}`);
3952
+ }
3953
+ useStore.setState({
3954
+ seedPrompt: message,
3955
+ seedAttachments: files
3956
+ });
3539
3957
  onPick({
3540
3958
  kind: "chat",
3541
3959
  agent,
@@ -3559,7 +3977,8 @@ function ConversationHome({ agent, initialQuery, conversations, showAutomated, o
3559
3977
  setHighlight(0);
3560
3978
  };
3561
3979
  const submit = () => {
3562
- if (trimmedMessage) sendNewConversation(trimmedMessage);
3980
+ if (sending) return;
3981
+ if (hasMessage) sendNewConversation(trimmedMessage);
3563
3982
  else openHighlighted();
3564
3983
  };
3565
3984
  /**
@@ -3653,6 +4072,15 @@ function ConversationHome({ agent, initialQuery, conversations, showAutomated, o
3653
4072
  toggleArchived(row.hit.item);
3654
4073
  }
3655
4074
  key.preventDefault();
4075
+ } else if (key.name === "v" && key.ctrl) {
4076
+ if (confirmId === null && !isSearchInput) {
4077
+ setError(null);
4078
+ stageClipboardImage();
4079
+ }
4080
+ key.preventDefault();
4081
+ } else if (key.name === "x" && key.ctrl) {
4082
+ dropLastAttachment();
4083
+ key.preventDefault();
3656
4084
  } else if (key.name === "tab" && key.shift) {
3657
4085
  onToggleArchived();
3658
4086
  key.preventDefault();
@@ -3736,6 +4164,7 @@ function ConversationHome({ agent, initialQuery, conversations, showAutomated, o
3736
4164
  }
3737
4165
  })
3738
4166
  }),
4167
+ attachments.length > 0 ? /* @__PURE__ */ jsx(AttachmentTray, { attachments }) : null,
3739
4168
  /* @__PURE__ */ jsx("text", {
3740
4169
  fg: status.fg,
3741
4170
  children: status.text
@@ -3886,246 +4315,92 @@ function channelGlyph(channel) {
3886
4315
  * flush against the right edge; when the row is too narrow to fit the title
3887
4316
  * AND the time, the time is dropped rather than wrapping the row.
3888
4317
  */
3889
- function layoutRow(title, time, tag, width) {
3890
- const GLYPH = 2;
3891
- const tagCost = tag ? tag.length + 1 : 0;
3892
- const timeCost = time.length + 1;
3893
- const titleBudget = width - GLYPH - tagCost - timeCost;
3894
- if (titleBudget < 8) return {
3895
- title: truncate(title, Math.max(1, width - GLYPH - tagCost)),
3896
- time: ""
3897
- };
3898
- const fitTitle = truncate(title, titleBudget);
3899
- const used = GLYPH + fitTitle.length + tagCost;
3900
- const pad = Math.max(1, width - used - time.length);
3901
- return {
3902
- title: fitTitle,
3903
- time: `${" ".repeat(pad)}${time}`
3904
- };
3905
- }
3906
- function relativeTime(iso) {
3907
- const diffMs = Date.now() - new Date(iso).getTime();
3908
- if (diffMs < 6e4) return "just now";
3909
- if (diffMs < 36e5) return `${Math.floor(diffMs / 6e4)}m ago`;
3910
- if (diffMs < 864e5) return `${Math.floor(diffMs / 36e5)}h ago`;
3911
- return `${Math.floor(diffMs / 864e5)}d ago`;
3912
- }
3913
-
3914
- //#endregion
3915
- //#region src/chat/conversation-ref.ts
3916
- /**
3917
- * Parsing the conversation reference a user hands `/conversation`.
3918
- *
3919
- * The whole point of the command is that you paste whatever you already have
3920
- * in front of you, so every form the platform hands out has to work: a bare
3921
- * id, the `https://skydive.com/c/<id>` link an agent replies with, or the
3922
- * `skydive chat --resume <id>` line the TUI prints on exit. All three carry
3923
- * exactly one id, so we pull the first uuid out of the string rather than
3924
- * teaching this about URL shapes and command syntax.
3925
- *
3926
- * Validating here (instead of letting the API answer) is what turns a typo
3927
- * into "not a conversation id" in the composer rather than a request that
3928
- * cannot succeed.
3929
- */
3930
- const uuidPattern = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
3931
- /**
3932
- * The conversation id in `input`, lowercased, or null when there isn't one.
3933
- * Trailing punctuation, query strings and surrounding prose are tolerated —
3934
- * a pasted link or command line is the expected input, not the exception.
3935
- */
3936
- function parseConversationRef(input) {
3937
- const match = uuidPattern.exec(input);
3938
- return match ? match[0].toLowerCase() : null;
3939
- }
3940
-
3941
- //#endregion
3942
- //#region src/chat/transcript-export.ts
3943
- function messageText(message) {
3944
- const chunks = [];
3945
- for (const part of message.parts) if ((part.type === "text" || part.type === "reasoning") && "text" in part && typeof part.text === "string") chunks.push(part.text);
3946
- else if (part.type === "dynamic-tool" && "toolName" in part) chunks.push(`[tool: ${typeof part.toolName === "string" ? part.toolName : "tool"}]`);
3947
- return chunks.join("\n").trim();
3948
- }
3949
- function messageLabel(message) {
3950
- if (message.role === "user") return "User";
3951
- if (message.role === "assistant") return message.metadata?.custom?.agentName ?? "Assistant";
3952
- return message.role.charAt(0).toUpperCase() + message.role.slice(1);
3953
- }
3954
- function parseTranscriptFormat(value) {
3955
- const normalized = value.trim().toLowerCase();
3956
- if (!normalized || normalized === "md" || normalized === "markdown") return "markdown";
3957
- if (normalized === "txt" || normalized === "text") return "text";
3958
- return null;
3959
- }
3960
- function formatTranscript(messages, format) {
3961
- const entries = messages.flatMap((message) => {
3962
- const text = messageText(message);
3963
- if (!text) return [];
3964
- const label = messageLabel(message);
3965
- return [format === "markdown" ? `## ${label}\n\n${text}` : `[${label}]\n${text}`];
3966
- });
3967
- if (format === "markdown") return `# Conversation transcript\n\n${entries.join("\n\n")}\n`;
3968
- return `${entries.join("\n\n")}\n`;
3969
- }
3970
- async function exportTranscript(options) {
3971
- const extension = options.format === "markdown" ? "md" : "txt";
3972
- const filePath = path.join(options.cwd, `skydive-conversation-${options.conversationId}.${extension}`);
3973
- await writeFile(filePath, formatTranscript(options.messages, options.format), "utf8");
3974
- return filePath;
3975
- }
3976
-
3977
- //#endregion
3978
- //#region src/chat/paste.ts
3979
- const MAX_PATH_PASTE_CHARS = 4096;
3980
- /**
3981
- * Decode a `file://` URL to a filesystem path, host-independently. Node's
3982
- * `fileURLToPath` maps to the *running* OS's path shape, so a POSIX file URL
3983
- * pasted on Windows would come back as a backslash drive-relative path. A
3984
- * dropped-file URL is decided by its own shape, not by our host: a Windows
3985
- * drive URL (`file:///C:/...`) yields a Windows path, anything else a POSIX
3986
- * path. Returns null for a malformed URL.
3987
- */
3988
- function fileUrlToPath(url) {
3989
- let parsed;
3990
- try {
3991
- parsed = new URL(url);
3992
- } catch (_error) {
3993
- return null;
3994
- }
3995
- if (parsed.protocol !== "file:") return null;
3996
- const decoded = decodeURIComponent(parsed.pathname);
3997
- if (/^\/[a-zA-Z]:\//.test(decoded)) return decoded.slice(1).replace(/\//g, "\\");
3998
- return decoded;
3999
- }
4000
- /**
4001
- * Parse the text a terminal emits for dropped files. This only identifies
4002
- * path-shaped candidates; filesystem and content validation happen later.
4003
- */
4004
- function parseDroppedPaths(text) {
4005
- const trimmed = text.trim();
4006
- if (!trimmed || trimmed.length > MAX_PATH_PASTE_CHARS || /[\r\n]/.test(trimmed)) return null;
4007
- const tokens = [];
4008
- let current = "";
4009
- let quote = null;
4010
- for (let i = 0; i < trimmed.length; i += 1) {
4011
- const ch = trimmed.charAt(i);
4012
- if (quote) {
4013
- if (ch === quote) quote = null;
4014
- else if (ch === "\\" && quote === "\"" && i + 1 < trimmed.length) {
4015
- current += trimmed.charAt(i + 1);
4016
- i += 1;
4017
- } else current += ch;
4018
- continue;
4019
- }
4020
- if (ch === "'" || ch === "\"") {
4021
- quote = ch;
4022
- continue;
4023
- }
4024
- if (ch === "\\" && i + 1 < trimmed.length) {
4025
- current += trimmed.charAt(i + 1);
4026
- i += 1;
4027
- continue;
4028
- }
4029
- if (ch === " " || ch === " ") {
4030
- if (current) {
4031
- tokens.push(current);
4032
- current = "";
4033
- }
4034
- continue;
4035
- }
4036
- current += ch;
4037
- }
4038
- if (quote) return null;
4039
- if (current) tokens.push(current);
4040
- if (tokens.length === 0) return null;
4041
- const paths = [];
4042
- for (const token of tokens) {
4043
- let path = token;
4044
- if (path.startsWith("file://")) {
4045
- const resolved = fileUrlToPath(path);
4046
- if (resolved === null) return null;
4047
- path = resolved;
4048
- }
4049
- if (path.startsWith("~/")) path = `${homedir()}${path.slice(1)}`;
4050
- if (!isAbsolute(path) && !win32.isAbsolute(path) && !path.startsWith("./") && !path.startsWith("../")) return null;
4051
- paths.push(path);
4052
- }
4053
- return paths;
4054
- }
4055
- const TEXT_MIME_BY_EXT = {
4056
- ".txt": "text/plain",
4057
- ".md": "text/markdown",
4058
- ".markdown": "text/markdown",
4059
- ".csv": "text/csv",
4060
- ".tsv": "text/tab-separated-values",
4061
- ".json": "application/json",
4062
- ".yaml": "application/yaml",
4063
- ".yml": "application/yaml",
4064
- ".xml": "application/xml",
4065
- ".html": "text/html",
4066
- ".htm": "text/html",
4067
- ".css": "text/css",
4068
- ".js": "text/javascript",
4069
- ".ts": "text/plain",
4070
- ".tsx": "text/plain",
4071
- ".jsx": "text/plain",
4072
- ".py": "text/x-python",
4073
- ".sh": "text/x-shellscript",
4074
- ".log": "text/plain",
4075
- ".svg": "image/svg+xml"
4076
- };
4077
- /** Decide how to handle a paste. `kind: 'binary'` events carry the mime of
4078
- * bytes the terminal forwarded; text events carry the decoded paste text.
4079
- *
4080
- * The empty-text case is the fix for "can't paste an image": on macOS the
4081
- * reflex is Cmd+V, which the terminal turns into a bracketed *text* paste, so
4082
- * a screenshot on the clipboard arrives as empty bytes. We route that to an OS
4083
- * clipboard read instead of dropping it, matching the explicit ctrl+v path. */
4084
- function routePaste(input) {
4085
- if (input.kind === "binary") {
4086
- if (input.mimeType?.startsWith("image/")) return {
4087
- kind: "binary-image",
4088
- mediaType: input.mimeType
4089
- };
4090
- return { kind: "text" };
4091
- }
4092
- const paths = parseDroppedPaths(input.text);
4093
- if (paths) return {
4094
- kind: "dropped-paths",
4095
- paths,
4096
- text: input.text
4318
+ function layoutRow(title, time, tag, width) {
4319
+ const GLYPH = 2;
4320
+ const tagCost = tag ? tag.length + 1 : 0;
4321
+ const timeCost = time.length + 1;
4322
+ const titleBudget = width - GLYPH - tagCost - timeCost;
4323
+ if (titleBudget < 8) return {
4324
+ title: truncate(title, Math.max(1, width - GLYPH - tagCost)),
4325
+ time: ""
4097
4326
  };
4098
- if (input.text.trim() === "") return { kind: "clipboard-image" };
4099
- return { kind: "text" };
4327
+ const fitTitle = truncate(title, titleBudget);
4328
+ const used = GLYPH + fitTitle.length + tagCost;
4329
+ const pad = Math.max(1, width - used - time.length);
4330
+ return {
4331
+ title: fitTitle,
4332
+ time: `${" ".repeat(pad)}${time}`
4333
+ };
4334
+ }
4335
+ function relativeTime(iso) {
4336
+ const diffMs = Date.now() - new Date(iso).getTime();
4337
+ if (diffMs < 6e4) return "just now";
4338
+ if (diffMs < 36e5) return `${Math.floor(diffMs / 6e4)}m ago`;
4339
+ if (diffMs < 864e5) return `${Math.floor(diffMs / 36e5)}h ago`;
4340
+ return `${Math.floor(diffMs / 864e5)}d ago`;
4100
4341
  }
4342
+
4343
+ //#endregion
4344
+ //#region src/chat/conversation-ref.ts
4101
4345
  /**
4102
- * Resolve path candidates into attachable files. Every path must be an
4103
- * existing regular file; otherwise the caller should restore the original
4104
- * paste as text (an all-or-nothing rule so a paste meant as text — which
4105
- * merely *looks* path-shaped — is never partially eaten).
4346
+ * Parsing the conversation reference a user hands `/conversation`.
4106
4347
  *
4107
- * Any file type attaches. The mediaType comes from magic bytes when
4108
- * detectable, an extension map for the plain-text formats magic can't see,
4109
- * and application/octet-stream as the last resort the attachments API
4110
- * accepts any mediaType (100MB cap enforced server-side at presign).
4348
+ * The whole point of the command is that you paste whatever you already have
4349
+ * in front of you, so every form the platform hands out has to work: a bare
4350
+ * id, the `https://skydive.com/c/<id>` link an agent replies with, or the
4351
+ * `skydive chat --resume <id>` line the TUI prints on exit. All three carry
4352
+ * exactly one id, so we pull the first uuid out of the string rather than
4353
+ * teaching this about URL shapes and command syntax.
4354
+ *
4355
+ * Validating here (instead of letting the API answer) is what turns a typo
4356
+ * into "not a conversation id" in the composer rather than a request that
4357
+ * cannot succeed.
4111
4358
  */
4112
- async function resolveDroppedFiles(paths) {
4113
- try {
4114
- const files = await Promise.all(paths.map(async (path) => {
4115
- if (!(await stat(path)).isFile()) return null;
4116
- const data = new Uint8Array(await readFile(path));
4117
- const mediaType = (await fileTypeFromBuffer(data))?.mime ?? TEXT_MIME_BY_EXT[extname(path).toLowerCase()] ?? "application/octet-stream";
4118
- return {
4119
- fileName: basename(path),
4120
- mediaType,
4121
- data
4122
- };
4123
- }));
4124
- if (!files.every((file) => file !== null)) return null;
4125
- return files;
4126
- } catch (_error) {
4127
- return null;
4128
- }
4359
+ const uuidPattern = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
4360
+ /**
4361
+ * The conversation id in `input`, lowercased, or null when there isn't one.
4362
+ * Trailing punctuation, query strings and surrounding prose are tolerated —
4363
+ * a pasted link or command line is the expected input, not the exception.
4364
+ */
4365
+ function parseConversationRef(input) {
4366
+ const match = uuidPattern.exec(input);
4367
+ return match ? match[0].toLowerCase() : null;
4368
+ }
4369
+
4370
+ //#endregion
4371
+ //#region src/chat/transcript-export.ts
4372
+ function messageText(message) {
4373
+ const chunks = [];
4374
+ for (const part of message.parts) if ((part.type === "text" || part.type === "reasoning") && "text" in part && typeof part.text === "string") chunks.push(part.text);
4375
+ else if (part.type === "dynamic-tool" && "toolName" in part) chunks.push(`[tool: ${typeof part.toolName === "string" ? part.toolName : "tool"}]`);
4376
+ return chunks.join("\n").trim();
4377
+ }
4378
+ function messageLabel(message) {
4379
+ if (message.role === "user") return "User";
4380
+ if (message.role === "assistant") return message.metadata?.custom?.agentName ?? "Assistant";
4381
+ return message.role.charAt(0).toUpperCase() + message.role.slice(1);
4382
+ }
4383
+ function parseTranscriptFormat(value) {
4384
+ const normalized = value.trim().toLowerCase();
4385
+ if (!normalized || normalized === "md" || normalized === "markdown") return "markdown";
4386
+ if (normalized === "txt" || normalized === "text") return "text";
4387
+ return null;
4388
+ }
4389
+ function formatTranscript(messages, format) {
4390
+ const entries = messages.flatMap((message) => {
4391
+ const text = messageText(message);
4392
+ if (!text) return [];
4393
+ const label = messageLabel(message);
4394
+ return [format === "markdown" ? `## ${label}\n\n${text}` : `[${label}]\n${text}`];
4395
+ });
4396
+ if (format === "markdown") return `# Conversation transcript\n\n${entries.join("\n\n")}\n`;
4397
+ return `${entries.join("\n\n")}\n`;
4398
+ }
4399
+ async function exportTranscript(options) {
4400
+ const extension = options.format === "markdown" ? "md" : "txt";
4401
+ const filePath = path.join(options.cwd, `skydive-conversation-${options.conversationId}.${extension}`);
4402
+ await writeFile(filePath, formatTranscript(options.messages, options.format), "utf8");
4403
+ return filePath;
4129
4404
  }
4130
4405
 
4131
4406
  //#endregion
@@ -5994,9 +6269,9 @@ function stripLeadingMarker(s) {
5994
6269
  }
5995
6270
  const ELISION_MARKER_TAG = "⋯ elided";
5996
6271
  function elisionMarker(bytes) {
5997
- return `${ELISION_MARKER_TAG} ${formatBytes$2(bytes)} to save memory ⋯`;
6272
+ return `${ELISION_MARKER_TAG} ${formatBytes$1(bytes)} to save memory ⋯`;
5998
6273
  }
5999
- function formatBytes$2(n) {
6274
+ function formatBytes$1(n) {
6000
6275
  if (n < 1024) return `${n} chars`;
6001
6276
  if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
6002
6277
  return `${(n / (1024 * 1024)).toFixed(1)} MB`;
@@ -7060,13 +7335,13 @@ function todosCanExpand(todos, cap) {
7060
7335
  * done-summary line + rendered rows + optional "+N more" tail + top margin.
7061
7336
  * Must track `windowTodos` and `TodoCardView`'s layout.
7062
7337
  *
7063
- * `collapsed` is the minimized card: the header row (label, progress counter
7064
- * and the chevron that brings the rows back) plus the top margin, and nothing
7065
- * else, however long the list is.
7338
+ * `minimized` is the card folded down to its header row (label, progress
7339
+ * counter and the chevron that brings the rows back) plus the top margin, and
7340
+ * nothing else, however long the list is.
7066
7341
  */
7067
- function todoCardHeight(todos, { expanded, collapsed }) {
7342
+ function todoCardHeight(todos, { expanded, minimized }) {
7068
7343
  if (todos.length === 0) return 0;
7069
- if (collapsed) return 2;
7344
+ if (minimized) return 2;
7070
7345
  const w = windowTodos(todos, {
7071
7346
  cap: MAX_VISIBLE_ROWS$1,
7072
7347
  expanded
@@ -7114,13 +7389,17 @@ function rowColor$1(status) {
7114
7389
  * The card has two independent click targets, one per axis of "how much do I
7115
7390
  * show":
7116
7391
  * - the **header** minimizes the card to that one row and back
7117
- * (`collapsed` / `onToggleCollapsed`), which is what keeps a long plan from
7392
+ * (`minimized` / `onToggleMinimized`), which is what keeps a long plan from
7118
7393
  * eating the screen above the composer. The chevron carries the affordance.
7119
7394
  * - the **body** unfolds the completed work the window hides
7120
7395
  * (`expanded` / `onToggleExpanded`), the same interaction reasoning and
7121
7396
  * tool output already use. Only clickable when something is actually folded.
7397
+ *
7398
+ * Each surface names the other in its hint, since a chevron alone can't say
7399
+ * which click does which: folded, the `✔ N done` row offers the unfold;
7400
+ * unfolded, the header says the rows are what folds back.
7122
7401
  */
7123
- function TaskCardContent({ label, todos, isActive, collapsed, expanded, onToggleCollapsed, onToggleExpanded }) {
7402
+ function TaskCardContent({ label, todos, isActive, minimized, expanded, onToggleMinimized, onToggleExpanded }) {
7124
7403
  const completed = todos.filter((todo) => todo.status === "completed").length;
7125
7404
  const canExpand = todosCanExpand(todos, MAX_VISIBLE_ROWS$1);
7126
7405
  const { doneSummary, rows, moreCount } = windowTodos(todos, {
@@ -7131,11 +7410,11 @@ function TaskCardContent({ label, todos, isActive, collapsed, expanded, onToggle
7131
7410
  style: { flexDirection: "column" },
7132
7411
  children: [/* @__PURE__ */ jsxs("box", {
7133
7412
  style: { flexDirection: "row" },
7134
- onMouseDown: onToggleCollapsed,
7413
+ onMouseDown: onToggleMinimized,
7135
7414
  children: [
7136
7415
  /* @__PURE__ */ jsx("text", {
7137
7416
  fg: theme.dim,
7138
- children: collapsed ? "▸ " : "▾ "
7417
+ children: minimized ? "▸ " : "▾ "
7139
7418
  }),
7140
7419
  /* @__PURE__ */ jsx("text", {
7141
7420
  fg: isActive ? theme.accent : theme.muted,
@@ -7149,9 +7428,13 @@ function TaskCardContent({ label, todos, isActive, collapsed, expanded, onToggle
7149
7428
  "/",
7150
7429
  todos.length
7151
7430
  ]
7152
- })
7431
+ }),
7432
+ !minimized && expanded && canExpand ? /* @__PURE__ */ jsxs("text", {
7433
+ fg: theme.dim,
7434
+ children: [" ", "· click a row to fold"]
7435
+ }) : null
7153
7436
  ]
7154
- }), collapsed ? null : /* @__PURE__ */ jsxs("box", {
7437
+ }), minimized ? null : /* @__PURE__ */ jsxs("box", {
7155
7438
  style: { flexDirection: "column" },
7156
7439
  onMouseDown: canExpand ? onToggleExpanded : void 0,
7157
7440
  children: [
@@ -7210,11 +7493,11 @@ function TaskCardContent({ label, todos, isActive, collapsed, expanded, onToggle
7210
7493
  * work. `isActive` dims the header while the run is idle. `expanded` unfolds
7211
7494
  * the completed work the window hides — a settled plan collapses to `✔ N done`
7212
7495
  * otherwise, and clicking the body (or `/plan expand`) brings the rows back.
7213
- * `collapsed` is the minimized card: header only, so a long plan can be kept
7214
- * out of the way (click the header, or `/plan collapse`) without losing the
7215
- * label and progress counter the way `/plan hide` does.
7496
+ * `minimized` is the card folded down to its header row, so a long plan can be
7497
+ * kept out of the way (click the header, or `/plan minimize`) without losing
7498
+ * the label and progress counter the way `/plan hide` does.
7216
7499
  */
7217
- function TodoCardView({ todos, isActive, collapsed, expanded, onToggleCollapsed, onToggleExpanded }) {
7500
+ function TodoCardView({ todos, isActive, minimized, expanded, onToggleMinimized, onToggleExpanded }) {
7218
7501
  if (todos.length === 0) return null;
7219
7502
  return /* @__PURE__ */ jsx("box", {
7220
7503
  style: {
@@ -7228,9 +7511,9 @@ function TodoCardView({ todos, isActive, collapsed, expanded, onToggleCollapsed,
7228
7511
  label: "Plan",
7229
7512
  todos,
7230
7513
  isActive,
7231
- collapsed,
7514
+ minimized,
7232
7515
  expanded,
7233
- onToggleCollapsed,
7516
+ onToggleMinimized,
7234
7517
  onToggleExpanded
7235
7518
  })
7236
7519
  });
@@ -7574,13 +7857,13 @@ function SubagentRowView({ row, selected, width, nowMs, onOpen }) {
7574
7857
  * the card reports its rows up through ConversationContext so the screen
7575
7858
  * knows what's selectable).
7576
7859
  *
7577
- * `collapsed` minimizes the card to its header row: a wide fan-out is 10 rows
7860
+ * `minimized` folds the card down to its header row: a wide fan-out is 10 rows
7578
7861
  * of the terminal for as long as the conversation lives, so clicking the
7579
7862
  * header (or `/subagent collapse`) folds the rows away and leaves the label,
7580
7863
  * the live glyph and the N/M counter. The chat screen owns the flag because it
7581
7864
  * reserves the card's rows to keep the composer pinned.
7582
7865
  */
7583
- function ToolSubagent({ items, isActive: _isActive, collapsed, onToggleCollapsed, onHeightChange }) {
7866
+ function ToolSubagent({ items, isActive: _isActive, minimized, onToggleMinimized, onHeightChange }) {
7584
7867
  const { rest, conversationId, openConversation, onSubagentBatch, subagentSelection } = useConversationContext();
7585
7868
  const { width } = useTerminalDimensions();
7586
7869
  const optimistic = /* @__PURE__ */ new Map();
@@ -7623,9 +7906,9 @@ function ToolSubagent({ items, isActive: _isActive, collapsed, onToggleCollapsed
7623
7906
  }, [live, rows.length]);
7624
7907
  useEffect(() => {
7625
7908
  const visibleRows = Math.min(rows.length, MAX_VISIBLE_ROWS);
7626
- onHeightChange?.(rows.length === 0 ? items.length > 0 ? 1 : 0 : collapsed ? 1 : 1 + visibleRows + (rows.length > MAX_VISIBLE_ROWS ? 1 : 0));
7909
+ onHeightChange?.(rows.length === 0 ? items.length > 0 ? 1 : 0 : minimized ? 1 : 1 + visibleRows + (rows.length > MAX_VISIBLE_ROWS ? 1 : 0));
7627
7910
  }, [
7628
- collapsed,
7911
+ minimized,
7629
7912
  items.length,
7630
7913
  onHeightChange,
7631
7914
  rows.length
@@ -7686,11 +7969,11 @@ function ToolSubagent({ items, isActive: _isActive, collapsed, onToggleCollapsed
7686
7969
  style: { flexDirection: "column" },
7687
7970
  children: [/* @__PURE__ */ jsx("box", {
7688
7971
  style: { flexDirection: "row" },
7689
- onMouseDown: onToggleCollapsed,
7972
+ onMouseDown: onToggleMinimized,
7690
7973
  children: /* @__PURE__ */ jsxs("text", { children: [
7691
7974
  /* @__PURE__ */ jsx("span", {
7692
7975
  fg: theme.dim,
7693
- children: collapsed ? "▸ " : "▾ "
7976
+ children: minimized ? "▸ " : "▾ "
7694
7977
  }),
7695
7978
  live ? /* @__PURE__ */ jsx(Spinner, { color: theme.accent }) : rows.every((row) => row.status === "completed") ? /* @__PURE__ */ jsx("span", {
7696
7979
  fg: theme.success,
@@ -7712,12 +7995,12 @@ function ToolSubagent({ items, isActive: _isActive, collapsed, onToggleCollapsed
7712
7995
  rows.length
7713
7996
  ]
7714
7997
  }),
7715
- openConversation && !collapsed ? /* @__PURE__ */ jsxs("span", {
7998
+ openConversation && !minimized ? /* @__PURE__ */ jsxs("span", {
7716
7999
  fg: theme.dim,
7717
8000
  children: [" ", selectedIndex !== null ? "↑/↓ select · ↵ view · esc back" : "/subagent to browse"]
7718
8001
  }) : null
7719
8002
  ] })
7720
- }), collapsed ? null : /* @__PURE__ */ jsxs("box", {
8003
+ }), minimized ? null : /* @__PURE__ */ jsxs("box", {
7721
8004
  style: { flexDirection: "column" },
7722
8005
  children: [visible.map((row, i) => {
7723
8006
  const target = row.conversationId;
@@ -7744,11 +8027,11 @@ function ToolSubagent({ items, isActive: _isActive, collapsed, onToggleCollapsed
7744
8027
  * a settled batch keeps its rows — titles and drill-in — until the next
7745
8028
  * fan-out replaces the group.
7746
8029
  *
7747
- * `collapsed` folds the rows away and leaves the header (click it, or
7748
- * `/subagent collapse`), so a ten-task fan-out stops owning ten rows above the
8030
+ * `minimized` folds the rows away and leaves the header (click it, or
8031
+ * `/subagent minimize`), so a ten-task fan-out stops owning ten rows above the
7749
8032
  * composer for the rest of the conversation.
7750
8033
  */
7751
- function SubagentCardView({ items, isActive, collapsed, onToggleCollapsed, onHeightChange }) {
8034
+ function SubagentCardView({ items, isActive, minimized, onToggleMinimized, onHeightChange }) {
7752
8035
  const [height, setHeight] = useState(0);
7753
8036
  useEffect(() => onHeightChange?.(height > 0 ? height + 1 : 0), [height, onHeightChange]);
7754
8037
  return /* @__PURE__ */ jsx("box", {
@@ -7763,8 +8046,8 @@ function SubagentCardView({ items, isActive, collapsed, onToggleCollapsed, onHei
7763
8046
  children: /* @__PURE__ */ jsx(ToolSubagent, {
7764
8047
  items,
7765
8048
  isActive,
7766
- collapsed,
7767
- onToggleCollapsed,
8049
+ minimized,
8050
+ onToggleMinimized,
7768
8051
  onHeightChange: setHeight
7769
8052
  })
7770
8053
  });
@@ -8793,20 +9076,20 @@ const keybindGroups = [
8793
9076
  action: "jump back to your last message"
8794
9077
  },
8795
9078
  {
8796
- keys: "click header",
8797
- action: "minimize / expand the pinned plan or subagents card"
9079
+ keys: "click card header",
9080
+ action: "minimize the pinned plan or subagents card, and restore it"
8798
9081
  },
8799
9082
  {
8800
- keys: "click",
9083
+ keys: "click card body",
8801
9084
  action: "expand a folded plan card back to its finished rows"
8802
9085
  },
8803
9086
  {
8804
- keys: "/plan [show|hide|collapse|expand]",
8805
- action: "show / hide / minimize / unfold the pinned plan card"
9087
+ keys: "/plan [show|hide|minimize|expand|collapse]",
9088
+ action: "show / hide / minimize the pinned plan card, or unfold it"
8806
9089
  },
8807
9090
  {
8808
- keys: "/subagent [collapse|expand]",
8809
- action: "browse the pinned subagents card, or minimize / expand it"
9091
+ keys: "/subagent [minimize]",
9092
+ action: "browse the pinned subagents card, or minimize it"
8810
9093
  },
8811
9094
  {
8812
9095
  keys: "/rename <title>",
@@ -8960,6 +9243,18 @@ const keybindGroups = [
8960
9243
  keys: "↵",
8961
9244
  action: "open conversation"
8962
9245
  },
9246
+ {
9247
+ keys: "ctrl+v",
9248
+ action: "paste image from clipboard"
9249
+ },
9250
+ {
9251
+ keys: "drag-drop",
9252
+ action: "attach image files dropped onto the terminal"
9253
+ },
9254
+ {
9255
+ keys: "ctrl+x",
9256
+ action: "drop the last staged image"
9257
+ },
8963
9258
  {
8964
9259
  keys: "ctrl+a",
8965
9260
  action: "archive conversation (hides it for you only; again to restore)"
@@ -9511,12 +9806,9 @@ const slashCommands = [
9511
9806
  {
9512
9807
  name: "subagent",
9513
9808
  aliases: ["subagents"],
9514
- argsHint: "[collapse|expand]",
9515
- description: "browse, minimize or expand the fan-out card",
9516
- action: {
9517
- kind: "subagent",
9518
- mode: "browse"
9519
- }
9809
+ argsHint: "[minimize]",
9810
+ description: "browse or minimize the fan-out card",
9811
+ action: { kind: "subagent" }
9520
9812
  },
9521
9813
  {
9522
9814
  name: "status",
@@ -9535,8 +9827,8 @@ const slashCommands = [
9535
9827
  {
9536
9828
  name: "plan",
9537
9829
  aliases: ["todos", "todo"],
9538
- argsHint: "[show|hide|collapse|expand]",
9539
- description: "show, hide, minimize or expand the pinned plan",
9830
+ argsHint: "[show|hide|minimize|expand|collapse]",
9831
+ description: "show, hide, minimize, or unfold the pinned plan card",
9540
9832
  action: {
9541
9833
  kind: "plan",
9542
9834
  mode: "toggle"
@@ -11847,7 +12139,7 @@ function canPreview(file) {
11847
12139
  if (/\/(json|xml|javascript|typescript|x-sh|yaml)$/.test(file.mediaType)) return true;
11848
12140
  return /\.(md|mdx|txt|json|jsonl|ya?ml|toml|ini|env|csv|ts|tsx|js|jsx|mjs|cjs|css|scss|html|xml|svg|py|rb|rs|go|java|kt|swift|c|cc|cpp|h|hpp|sh|zsh|fish|sql|graphql|gql)$/i.test(file.path);
11849
12141
  }
11850
- function formatBytes$1(bytes) {
12142
+ function formatBytes(bytes) {
11851
12143
  if (bytes < 1024) return `${bytes} B`;
11852
12144
  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
11853
12145
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
@@ -11915,7 +12207,7 @@ function WorkspaceFilesTab({ agentId, viewportWidth, viewportHeight, active, she
11915
12207
  ]);
11916
12208
  const visibleFiles = useMemo(() => load.kind === "ready" ? filterReviewFiles(load.files, fileSearch ?? "", (file) => file.path) : [], [load, fileSearch]);
11917
12209
  const rows = useMemo(() => buildWorkspaceTree(visibleFiles), [visibleFiles]);
11918
- const navigatorPreferredWidth = reviewTreePreferredWidth(useMemo(() => buildReviewTree(visibleFiles, (file) => file.path), [visibleFiles]), (file) => ` · ${formatBytes$1(file.sizeBytes)}`.length);
12210
+ const navigatorPreferredWidth = reviewTreePreferredWidth(useMemo(() => buildReviewTree(visibleFiles, (file) => file.path), [visibleFiles]), (file) => ` · ${formatBytes(file.sizeBytes)}`.length);
11919
12211
  const shellDimensions = reviewShellDimensions({
11920
12212
  width,
11921
12213
  height: shellHeight,
@@ -11954,7 +12246,7 @@ function WorkspaceFilesTab({ agentId, viewportWidth, viewportHeight, active, she
11954
12246
  if (!canPreview(selectedFile)) {
11955
12247
  setPreview({
11956
12248
  kind: "none",
11957
- message: `${selectedFile.mediaType} · ${formatBytes$1(selectedFile.sizeBytes)}\npreview unavailable for binary or large files`
12249
+ message: `${selectedFile.mediaType} · ${formatBytes(selectedFile.sizeBytes)}\npreview unavailable for binary or large files`
11958
12250
  });
11959
12251
  return;
11960
12252
  }
@@ -12193,7 +12485,7 @@ function WorkspaceFilesTab({ agentId, viewportWidth, viewportHeight, active, she
12193
12485
  row.name,
12194
12486
  /* @__PURE__ */ jsxs("span", {
12195
12487
  fg: theme.faint,
12196
- children: [" · ", formatBytes$1(row.file.sizeBytes)]
12488
+ children: [" · ", formatBytes(row.file.sizeBytes)]
12197
12489
  })
12198
12490
  ]
12199
12491
  }, row.file.id);
@@ -13375,16 +13667,13 @@ function routeInput(raw) {
13375
13667
  const arg = match.rest.toLowerCase();
13376
13668
  return {
13377
13669
  kind: "plan",
13378
- mode: arg === "show" ? "show" : arg === "hide" ? "hide" : arg === "expand" ? "expand" : arg === "collapse" ? "collapse" : "toggle"
13379
- };
13380
- }
13381
- case "subagent": {
13382
- const arg = match.rest.toLowerCase();
13383
- return {
13384
- kind: "subagent",
13385
- mode: arg === "collapse" ? "collapse" : arg === "expand" ? "expand" : "browse"
13670
+ mode: arg === "show" ? "show" : arg === "hide" ? "hide" : arg === "expand" ? "expand" : arg === "collapse" ? "collapse" : arg === "minimize" ? "minimize" : "toggle"
13386
13671
  };
13387
13672
  }
13673
+ case "subagent": return {
13674
+ kind: "subagent",
13675
+ mode: match.rest.toLowerCase() === "minimize" ? "minimize" : "browse"
13676
+ };
13388
13677
  case "new-conversation":
13389
13678
  case "fork":
13390
13679
  case "archive-conversation":
@@ -13505,7 +13794,6 @@ const pageScrollFraction = .75;
13505
13794
  const HISTORY_PAGE_SIZE = 60;
13506
13795
  /** Cap composer growth; past this the textarea scrolls its content instead. */
13507
13796
  const maxComposerRows = 8;
13508
- const maxAttachments = 10;
13509
13797
  /**
13510
13798
  * A server timestamp as local epoch ms, or null when it is missing or
13511
13799
  * unparseable so the caller can fall back to its own clock.
@@ -13515,19 +13803,6 @@ function parseStampMs(iso) {
13515
13803
  const ms = Date.parse(iso);
13516
13804
  return Number.isNaN(ms) ? null : ms;
13517
13805
  }
13518
- function formatBytes(bytes) {
13519
- if (bytes < 1024) return `${bytes} B`;
13520
- if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
13521
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
13522
- }
13523
- /** Status glyph for a staged attachment chip: uploading / ready / failed. */
13524
- function attachmentGlyph(a) {
13525
- switch (a.status) {
13526
- case "uploading": return "↑";
13527
- case "ready": return "✓";
13528
- case "error": return `✗ ${a.errorText ?? "upload failed"}`;
13529
- }
13530
- }
13531
13806
  /**
13532
13807
  * Open a URL in the user's local browser. Best-effort: over SSH there may be
13533
13808
  * nothing to open — the card keeps showing the URL as the copyable fallback.
@@ -13583,12 +13858,10 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13583
13858
  const [planHidden, setPlanHidden] = useState(false);
13584
13859
  const [planExpanded, setPlanExpanded] = useState(false);
13585
13860
  const togglePlanExpanded = useCallback(() => setPlanExpanded((current) => !current), []);
13586
- const [planCollapsed, setPlanCollapsed] = useState(false);
13587
- const togglePlanCollapsed = useCallback(() => {
13588
- setPlanCollapsed((current) => {
13589
- if (current) setPlanExpanded(false);
13590
- return !current;
13591
- });
13861
+ const [planMinimized, setPlanMinimized] = useState(false);
13862
+ const togglePlanMinimized = useCallback(() => {
13863
+ setPlanMinimized((current) => !current);
13864
+ setPlanExpanded(false);
13592
13865
  }, []);
13593
13866
  const [subagentRows, setSubagentRows] = useState(0);
13594
13867
  const [anchoredSubagentIds, setAnchoredSubagentIds] = useState([]);
@@ -13621,12 +13894,10 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13621
13894
  const [modelPickerView, setModelPickerView] = useState(null);
13622
13895
  const modelPickerOpen = modelPickerView !== null;
13623
13896
  const [subagentSelection, setSubagentSelection] = useState(null);
13624
- const [subagentCollapsed, setSubagentCollapsed] = useState(false);
13625
- const toggleSubagentCollapsed = useCallback(() => {
13626
- setSubagentCollapsed((current) => {
13627
- if (!current) setSubagentSelection(null);
13628
- return !current;
13629
- });
13897
+ const [subagentMinimized, setSubagentMinimized] = useState(false);
13898
+ const toggleSubagentMinimized = useCallback(() => {
13899
+ setSubagentMinimized((current) => !current);
13900
+ setSubagentSelection(null);
13630
13901
  }, []);
13631
13902
  const [subagentBatches, setSubagentBatches] = useState(() => /* @__PURE__ */ new Map());
13632
13903
  const onSubagentBatch = useCallback((report) => {
@@ -13661,8 +13932,6 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13661
13932
  }, [initialConversationId]);
13662
13933
  const [ctrlCArmed, setCtrlCArmed] = useState(false);
13663
13934
  const [composerRows, setComposerRows] = useState(1);
13664
- const [attachments, setAttachments] = useState([]);
13665
- const uploadsRef = useRef(/* @__PURE__ */ new Map());
13666
13935
  const [forking, setForking] = useState(false);
13667
13936
  const forkingRef = useRef(false);
13668
13937
  const [credPrompt, setCredPrompt] = useState(null);
@@ -13672,6 +13941,20 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13672
13941
  const [askPrompt, setAskPrompt] = useState(null);
13673
13942
  const askPromptOpen = askPrompt !== null;
13674
13943
  const [restartConfirm, setRestartConfirm] = useState(false);
13944
+ const { staged: attachments, stagedRef: attachmentsRef, stageClipboardImage, dropLast: dropLastAttachment, clearStaged: clearAttachments, collectForSend, adoptUploaded, releaseUploaded, restoreStaged } = useComposerAttachments({
13945
+ rest,
13946
+ agentId: agent.id,
13947
+ pasteDisabled: cardPickerOpen || modelPickerOpen || themePickerOpen || prPickerOpen || helpOpen || creditsOpen || bgTasksOpen || credPromptOpen || Boolean(grantPrompt) || computePromptOpen || askPromptOpen || restartConfirm || nonWebChannel !== null,
13948
+ onPasteText: (text) => {
13949
+ composerRef.current?.insertText(text);
13950
+ handleComposerChange();
13951
+ },
13952
+ onError: (message) => setItems((prev) => [...prev, {
13953
+ kind: "error",
13954
+ id: crypto.randomUUID(),
13955
+ text: message
13956
+ }])
13957
+ });
13675
13958
  const [menuDismissed, setMenuDismissed] = useState(false);
13676
13959
  const [menuHighlight, setMenuHighlight] = useState(0);
13677
13960
  const commandQuery = grantPrompt || credPrompt || computePrompt || askPrompt || restartConfirm ? null : detectCommandTrigger(input);
@@ -13684,8 +13967,6 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13684
13967
  conversationIdRef.current = conversationId;
13685
13968
  const itemsRef = useRef(items);
13686
13969
  itemsRef.current = items;
13687
- const attachmentsRef = useRef(attachments);
13688
- attachmentsRef.current = attachments;
13689
13970
  const inputRef = useRef(input);
13690
13971
  inputRef.current = input;
13691
13972
  const credPromptRef = useRef(credPrompt);
@@ -13772,7 +14053,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13772
14053
  const paneDragRef = useRef(null);
13773
14054
  const composerBoxHeight = composerRows + 2;
13774
14055
  const pendingVisible = !grantPrompt && !credPrompt && !computePrompt && !askPrompt && !nonWebChannel && attachments.length > 0;
13775
- const pendingRows = pendingVisible ? attachments.length + 2 : 0;
14056
+ const pendingRows = pendingVisible ? attachmentTrayRows(attachments.length) : 0;
13776
14057
  const credPromptHeight = 4 + (credPrompt && (credPrompt.error || credPrompt.submitting) ? 1 : 0);
13777
14058
  const nonWebNoticeHeight = 5;
13778
14059
  const computePromptHeight = 4;
@@ -13795,7 +14076,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13795
14076
  const todoCardVisible = !credPrompt && !planHidden && !!todos && todos.length > 0;
13796
14077
  const todoRows = todoCardVisible ? todoCardHeight(todos, {
13797
14078
  expanded: planExpanded,
13798
- collapsed: planCollapsed
14079
+ minimized: planMinimized
13799
14080
  }) : 0;
13800
14081
  const subagentItems = useMemo(() => {
13801
14082
  const ids = new Set(anchoredSubagentIds);
@@ -14110,28 +14391,13 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14110
14391
  startedAtMs: Date.now()
14111
14392
  });
14112
14393
  try {
14113
- const attachmentIds = [];
14114
- const restorable = [];
14115
- const settled = await Promise.allSettled(staged.map((a) => uploadsRef.current.get(a.tempId)));
14116
- for (const [i, result] of settled.entries()) {
14117
- const row = staged[i];
14118
- if (!row) continue;
14119
- if (result.status === "fulfilled" && result.value) {
14120
- attachmentIds.push(result.value.id);
14121
- restorable.push({
14122
- ...row,
14123
- status: "ready",
14124
- errorText: null
14125
- });
14126
- } else {
14127
- uploadsRef.current.delete(row.tempId);
14128
- setItems((prev) => [...prev, {
14129
- kind: "error",
14130
- id: crypto.randomUUID(),
14131
- text: `attachment failed: ${row.fileName}${result.status === "rejected" ? ` (${errorDetail(result.reason)})` : ""}`
14132
- }]);
14133
- }
14134
- }
14394
+ const { files, rows, failed } = await collectForSend(staged);
14395
+ const attachmentIds = files.map((file) => file.id);
14396
+ for (const { attachment, reason } of failed) setItems((prev) => [...prev, {
14397
+ kind: "error",
14398
+ id: crypto.randomUUID(),
14399
+ text: `attachment failed: ${attachment.fileName}${reason === null ? "" : ` (${errorDetail(reason)})`}`
14400
+ }]);
14135
14401
  if (!trimmed && attachmentIds.length === 0) {
14136
14402
  setItems((prev) => prev.filter((m) => m.id !== optimisticId));
14137
14403
  if (!wasStreaming) setRun({ kind: "idle" });
@@ -14144,7 +14410,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14144
14410
  attachmentIds,
14145
14411
  clientSurface: "tui"
14146
14412
  });
14147
- for (const row of restorable) uploadsRef.current.delete(row.tempId);
14413
+ releaseUploaded(rows);
14148
14414
  if (result.isNewConversation) setConversationId(result.conversationId);
14149
14415
  if (result.steered) {
14150
14416
  const directiveId = result.directive?.id;
@@ -14174,27 +14440,26 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14174
14440
  text: sendErrorMessage(err)
14175
14441
  }]);
14176
14442
  if (echo) setInput((existing) => existing ? existing : trimmed);
14177
- const restore = staged.filter((a) => uploadsRef.current.has(a.tempId));
14178
- if (restore.length > 0) setAttachments((prev) => [...restore.map((a) => ({
14179
- ...a,
14180
- status: "ready",
14181
- errorText: null
14182
- })), ...prev]);
14443
+ restoreStaged(staged);
14183
14444
  if (!wasStreaming) setRun({ kind: "idle" });
14184
14445
  }
14185
14446
  }, [
14186
14447
  rest,
14187
14448
  agent.id,
14188
14449
  conversationId,
14189
- attachToRun
14450
+ attachToRun,
14451
+ collectForSend,
14452
+ releaseUploaded,
14453
+ restoreStaged
14190
14454
  ]);
14191
14455
  const portalStatus = portal.status;
14192
14456
  useEffect(() => {
14193
- const { seedPrompt, autoGrantMachine } = useStore.getState();
14194
- if (!seedPrompt || !isNewConversation(conversation)) return;
14457
+ const { seedPrompt, seedAttachments, autoGrantMachine } = useStore.getState();
14458
+ if (!seedPrompt && seedAttachments.length === 0 || !isNewConversation(conversation)) return;
14195
14459
  if (autoGrantMachine && (portalStatus === "off" || portalStatus === "connecting")) return;
14196
14460
  useStore.setState({
14197
14461
  seedPrompt: null,
14462
+ seedAttachments: [],
14198
14463
  autoGrantMachine: false
14199
14464
  });
14200
14465
  (async () => {
@@ -14212,7 +14477,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14212
14477
  variant: "warning"
14213
14478
  });
14214
14479
  }
14215
- await sendContent(seedPrompt, []);
14480
+ await sendContent(seedPrompt ?? "", adoptUploaded(seedAttachments));
14216
14481
  })();
14217
14482
  }, [
14218
14483
  conversation,
@@ -14220,7 +14485,8 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14220
14485
  portalClient,
14221
14486
  agent.id,
14222
14487
  agent.name,
14223
- sendContent
14488
+ sendContent,
14489
+ adoptUploaded
14224
14490
  ]);
14225
14491
  const cancelLatestSteer = useCallback(() => {
14226
14492
  if (!rest) return false;
@@ -14627,95 +14893,6 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14627
14893
  if (detectCommandTrigger(composer.plainText) === null) setMenuDismissed(false);
14628
14894
  setComposerRows(Math.min(Math.max(composer.editorView.getTotalVirtualLineCount(), 1), maxComposerRows));
14629
14895
  }, []);
14630
- /**
14631
- * Stage a pasted/dropped file in the composer tray and start its upload
14632
- * immediately (the web composer's behavior). Submit awaits the in-flight
14633
- * uploads and carries the resulting attachment ids; the composer never
14634
- * blocks while an upload runs.
14635
- */
14636
- const stageAttachment = useCallback(({ fileName, mediaType, data }) => {
14637
- if (!rest) return;
14638
- if (attachmentsRef.current.length >= maxAttachments) {
14639
- setItems((prev) => [...prev, {
14640
- kind: "error",
14641
- id: crypto.randomUUID(),
14642
- text: `attachment limit reached (${maxAttachments}); ${fileName} skipped`
14643
- }]);
14644
- return;
14645
- }
14646
- const tempId = crypto.randomUUID();
14647
- setAttachments((prev) => [...prev, {
14648
- tempId,
14649
- fileName,
14650
- sizeBytes: data.byteLength,
14651
- status: "uploading",
14652
- errorText: null
14653
- }]);
14654
- const promise = rest.uploadAttachment({
14655
- agentId: agent.id,
14656
- fileName,
14657
- mediaType,
14658
- data
14659
- });
14660
- uploadsRef.current.set(tempId, promise);
14661
- promise.then(() => {
14662
- setAttachments((prev) => prev.map((a) => a.tempId === tempId ? {
14663
- ...a,
14664
- status: "ready"
14665
- } : a));
14666
- }).catch((err) => {
14667
- setAttachments((prev) => prev.map((a) => a.tempId === tempId ? {
14668
- ...a,
14669
- status: "error",
14670
- errorText: errorMessage(err)
14671
- } : a));
14672
- });
14673
- }, [rest, agent.id]);
14674
- const stageFiles = useCallback((files) => {
14675
- for (const file of files) stageAttachment(file);
14676
- }, [stageAttachment]);
14677
- const pasteImage = useCallback(async () => {
14678
- const image = await readClipboardImage();
14679
- if (!image) return;
14680
- stageAttachment(image);
14681
- }, [stageAttachment]);
14682
- usePaste((event) => {
14683
- if (cardPickerOpen || modelPickerOpen || themePickerOpen || prPickerOpen || helpOpen || creditsOpen || bgTasksOpen || credPromptOpen || grantPrompt || computePromptOpen || askPromptOpen || restartConfirm || nonWebChannel) return;
14684
- const text = event.metadata?.kind === "binary" ? "" : decodePasteBytes(event.bytes);
14685
- const route = routePaste({
14686
- kind: event.metadata?.kind,
14687
- mimeType: event.metadata?.mimeType,
14688
- text
14689
- });
14690
- switch (route.kind) {
14691
- case "binary-image": {
14692
- event.preventDefault();
14693
- const ext = route.mediaType.split("/")[1]?.split("+")[0] ?? "png";
14694
- stageFiles([{
14695
- fileName: `pasted-image-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.${ext}`,
14696
- mediaType: route.mediaType,
14697
- data: event.bytes
14698
- }]);
14699
- return;
14700
- }
14701
- case "clipboard-image":
14702
- event.preventDefault();
14703
- pasteImage();
14704
- return;
14705
- case "dropped-paths":
14706
- event.preventDefault();
14707
- resolveDroppedFiles(route.paths).then((files) => {
14708
- if (files) {
14709
- stageFiles(files);
14710
- return;
14711
- }
14712
- composerRef.current?.insertText(route.text);
14713
- handleComposerChange();
14714
- });
14715
- return;
14716
- case "text": return;
14717
- }
14718
- });
14719
14896
  const applyComposerText = useCallback((text, caret) => {
14720
14897
  const composer = composerRef.current;
14721
14898
  if (!composer) return;
@@ -15226,23 +15403,19 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15226
15403
  else setPrPickerOpen(true);
15227
15404
  break;
15228
15405
  case "subagent": {
15406
+ if (routed.mode === "minimize") {
15407
+ const next = !subagentMinimized;
15408
+ setSubagentMinimized(next);
15409
+ if (next) setSubagentSelection(null);
15410
+ useStore.getState().showToast({ message: next ? "subagents minimized (/subagent minimize again to restore)" : "subagents restored" });
15411
+ break;
15412
+ }
15229
15413
  const batch = anchoredSubagentBatchRef.current;
15230
15414
  if (!batch || batch.rows.length === 0) {
15231
15415
  useStore.getState().showToast({ message: "no subagents in this conversation yet" });
15232
15416
  break;
15233
15417
  }
15234
- if (routed.mode === "collapse") {
15235
- setSubagentCollapsed(true);
15236
- setSubagentSelection(null);
15237
- useStore.getState().showToast({ message: "subagents collapsed (/subagent expand)" });
15238
- break;
15239
- }
15240
- if (routed.mode === "expand") {
15241
- setSubagentCollapsed(false);
15242
- useStore.getState().showToast({ message: "subagents expanded" });
15243
- break;
15244
- }
15245
- setSubagentCollapsed(false);
15418
+ setSubagentMinimized(false);
15246
15419
  setSubagentSelection({
15247
15420
  toolCallId: batch.toolCallId,
15248
15421
  index: 0
@@ -15279,22 +15452,25 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15279
15452
  useStore.getState().showToast({ message: "no plan to show yet" });
15280
15453
  break;
15281
15454
  }
15282
- if (routed.mode === "collapse") {
15283
- setPlanCollapsed(true);
15455
+ if (routed.mode === "minimize") {
15456
+ const next = !planMinimized;
15457
+ setPlanMinimized(next);
15458
+ setPlanHidden(false);
15284
15459
  setPlanExpanded(false);
15285
- useStore.getState().showToast({ message: "plan collapsed (/plan expand)" });
15460
+ useStore.getState().showToast({ message: next ? "plan minimized (/plan minimize again to restore)" : "plan restored" });
15286
15461
  break;
15287
15462
  }
15288
- if (routed.mode === "expand") {
15289
- setPlanCollapsed(false);
15290
- setPlanExpanded(true);
15463
+ if (routed.mode === "expand" || routed.mode === "collapse") {
15464
+ const nextExpanded = routed.mode === "expand";
15465
+ setPlanExpanded(nextExpanded);
15291
15466
  setPlanHidden(false);
15292
- useStore.getState().showToast({ message: "plan expanded (/plan collapse)" });
15467
+ setPlanMinimized(false);
15468
+ useStore.getState().showToast({ message: nextExpanded ? "plan expanded (/plan collapse)" : "plan collapsed" });
15293
15469
  break;
15294
15470
  }
15295
15471
  const nextHidden = routed.mode === "toggle" ? !planHidden : routed.mode === "hide";
15296
15472
  setPlanHidden(nextHidden);
15297
- if (!nextHidden) setPlanCollapsed(false);
15473
+ if (!nextHidden) setPlanMinimized(false);
15298
15474
  useStore.getState().showToast({ message: nextHidden ? "plan hidden (/plan show)" : "plan shown" });
15299
15475
  break;
15300
15476
  }
@@ -15362,7 +15538,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15362
15538
  setInput("");
15363
15539
  composerRef.current?.clear();
15364
15540
  setComposerRows(1);
15365
- setAttachments([]);
15541
+ clearAttachments();
15366
15542
  sendContent(content, staged);
15367
15543
  }, [
15368
15544
  history,
@@ -15382,6 +15558,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15382
15558
  compactConversation,
15383
15559
  quit,
15384
15560
  sendContent,
15561
+ clearAttachments,
15385
15562
  goTo,
15386
15563
  agent,
15387
15564
  appUrl,
@@ -15393,6 +15570,8 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15393
15570
  promptForCards,
15394
15571
  todos,
15395
15572
  planHidden,
15573
+ planMinimized,
15574
+ subagentMinimized,
15396
15575
  prLinks.length
15397
15576
  ]);
15398
15577
  const submit = useCallback(() => {
@@ -15635,16 +15814,12 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15635
15814
  }
15636
15815
  if (key.name === "v" && key.ctrl) {
15637
15816
  if (nonWebChannel) return;
15638
- pasteImage();
15817
+ stageClipboardImage();
15639
15818
  return;
15640
15819
  }
15641
15820
  if (key.name === "x" && key.ctrl || key.name === "backspace" && attachmentsRef.current.length > 0 && !composerRef.current?.plainText) {
15642
15821
  if (nonWebChannel) return;
15643
- const last = attachmentsRef.current.at(-1);
15644
- if (last) {
15645
- uploadsRef.current.delete(last.tempId);
15646
- setAttachments((prev) => prev.filter((a) => a.tempId !== last.tempId));
15647
- }
15822
+ dropLastAttachment();
15648
15823
  return;
15649
15824
  }
15650
15825
  if (key.name === "l" && key.ctrl) openInBrowser();
@@ -16039,16 +16214,16 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
16039
16214
  /* @__PURE__ */ jsx(SubagentCardView, {
16040
16215
  items: subagentItems,
16041
16216
  isActive: run.kind !== "idle",
16042
- collapsed: subagentCollapsed,
16043
- onToggleCollapsed: toggleSubagentCollapsed,
16217
+ minimized: subagentMinimized,
16218
+ onToggleMinimized: toggleSubagentMinimized,
16044
16219
  onHeightChange: setSubagentRows
16045
16220
  }),
16046
16221
  todoCardVisible && todos ? /* @__PURE__ */ jsx(TodoCardView, {
16047
16222
  todos,
16048
16223
  isActive: run.kind !== "idle",
16049
- collapsed: planCollapsed,
16224
+ minimized: planMinimized,
16050
16225
  expanded: planExpanded,
16051
- onToggleCollapsed: togglePlanCollapsed,
16226
+ onToggleMinimized: togglePlanMinimized,
16052
16227
  onToggleExpanded: togglePlanExpanded
16053
16228
  }) : null,
16054
16229
  compacting ? /* @__PURE__ */ jsxs("box", {
@@ -16068,29 +16243,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
16068
16243
  children: [" ", "you can keep typing"]
16069
16244
  })]
16070
16245
  }) : null,
16071
- pendingVisible ? /* @__PURE__ */ jsxs("box", {
16072
- style: {
16073
- paddingLeft: 1,
16074
- paddingRight: 1,
16075
- flexShrink: 0,
16076
- marginTop: 1,
16077
- flexDirection: "column"
16078
- },
16079
- children: [attachments.map((a) => /* @__PURE__ */ jsxs("text", {
16080
- fg: a.status === "error" ? theme.error : theme.muted,
16081
- children: [
16082
- "📎 ",
16083
- a.fileName,
16084
- " (",
16085
- formatBytes(a.sizeBytes),
16086
- ") ",
16087
- attachmentGlyph(a)
16088
- ]
16089
- }, a.tempId)), /* @__PURE__ */ jsx("text", {
16090
- fg: theme.dim,
16091
- children: "ctrl+x remove last"
16092
- })]
16093
- }) : null,
16246
+ pendingVisible ? /* @__PURE__ */ jsx(AttachmentTray, { attachments }) : null,
16094
16247
  menuOpen ? /* @__PURE__ */ jsx(CompletionMenu, {
16095
16248
  items: menuCommands.map((c) => ({
16096
16249
  id: c.name,