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

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-CiwSFzD8.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-DPOx1Zm4.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-YcAgiY79.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-HGcuhUqy.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-C7mYwnPA.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-CNiHI_BQ.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
@@ -3890,242 +4319,88 @@ function layoutRow(title, time, tag, width) {
3890
4319
  const GLYPH = 2;
3891
4320
  const tagCost = tag ? tag.length + 1 : 0;
3892
4321
  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
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`;
@@ -7018,6 +7293,14 @@ function latestTodosFromHistory(messages) {
7018
7293
  }
7019
7294
  return null;
7020
7295
  }
7296
+ /**
7297
+ * Whether the plan has work left in it: any row still pending or in
7298
+ * progress. A plan with none is a record of finished work, which is what lets
7299
+ * a reopened conversation fold its card to the header row.
7300
+ */
7301
+ function todosHaveLiveWork(todos) {
7302
+ return todos.some((todo) => todo.status === "pending" || todo.status === "in_progress");
7303
+ }
7021
7304
  /** How many todo rows the card shows before it caps the tail with "+N more". */
7022
7305
  const MAX_VISIBLE_ROWS$1 = 6;
7023
7306
  /** Fit `rest` under `rowBudget`, spending the last row on a "+N more" tail. */
@@ -7060,13 +7343,13 @@ function todosCanExpand(todos, cap) {
7060
7343
  * done-summary line + rendered rows + optional "+N more" tail + top margin.
7061
7344
  * Must track `windowTodos` and `TodoCardView`'s layout.
7062
7345
  *
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.
7346
+ * `minimized` is the card folded down to its header row (label, progress
7347
+ * counter and the chevron that brings the rows back) plus the top margin, and
7348
+ * nothing else, however long the list is.
7066
7349
  */
7067
- function todoCardHeight(todos, { expanded, collapsed }) {
7350
+ function todoCardHeight(todos, { expanded, minimized }) {
7068
7351
  if (todos.length === 0) return 0;
7069
- if (collapsed) return 2;
7352
+ if (minimized) return 2;
7070
7353
  const w = windowTodos(todos, {
7071
7354
  cap: MAX_VISIBLE_ROWS$1,
7072
7355
  expanded
@@ -7114,13 +7397,17 @@ function rowColor$1(status) {
7114
7397
  * The card has two independent click targets, one per axis of "how much do I
7115
7398
  * show":
7116
7399
  * - the **header** minimizes the card to that one row and back
7117
- * (`collapsed` / `onToggleCollapsed`), which is what keeps a long plan from
7400
+ * (`minimized` / `onToggleMinimized`), which is what keeps a long plan from
7118
7401
  * eating the screen above the composer. The chevron carries the affordance.
7119
7402
  * - the **body** unfolds the completed work the window hides
7120
7403
  * (`expanded` / `onToggleExpanded`), the same interaction reasoning and
7121
7404
  * tool output already use. Only clickable when something is actually folded.
7405
+ *
7406
+ * Each surface names the other in its hint, since a chevron alone can't say
7407
+ * which click does which: folded, the `✔ N done` row offers the unfold;
7408
+ * unfolded, the header says the rows are what folds back.
7122
7409
  */
7123
- function TaskCardContent({ label, todos, isActive, collapsed, expanded, onToggleCollapsed, onToggleExpanded }) {
7410
+ function TaskCardContent({ label, todos, isActive, minimized, expanded, onToggleMinimized, onToggleExpanded }) {
7124
7411
  const completed = todos.filter((todo) => todo.status === "completed").length;
7125
7412
  const canExpand = todosCanExpand(todos, MAX_VISIBLE_ROWS$1);
7126
7413
  const { doneSummary, rows, moreCount } = windowTodos(todos, {
@@ -7131,11 +7418,11 @@ function TaskCardContent({ label, todos, isActive, collapsed, expanded, onToggle
7131
7418
  style: { flexDirection: "column" },
7132
7419
  children: [/* @__PURE__ */ jsxs("box", {
7133
7420
  style: { flexDirection: "row" },
7134
- onMouseDown: onToggleCollapsed,
7421
+ onMouseDown: onToggleMinimized,
7135
7422
  children: [
7136
7423
  /* @__PURE__ */ jsx("text", {
7137
7424
  fg: theme.dim,
7138
- children: collapsed ? "▸ " : "▾ "
7425
+ children: minimized ? "▸ " : "▾ "
7139
7426
  }),
7140
7427
  /* @__PURE__ */ jsx("text", {
7141
7428
  fg: isActive ? theme.accent : theme.muted,
@@ -7149,9 +7436,13 @@ function TaskCardContent({ label, todos, isActive, collapsed, expanded, onToggle
7149
7436
  "/",
7150
7437
  todos.length
7151
7438
  ]
7152
- })
7439
+ }),
7440
+ !minimized && expanded && canExpand ? /* @__PURE__ */ jsxs("text", {
7441
+ fg: theme.dim,
7442
+ children: [" ", "· click a row to fold"]
7443
+ }) : null
7153
7444
  ]
7154
- }), collapsed ? null : /* @__PURE__ */ jsxs("box", {
7445
+ }), minimized ? null : /* @__PURE__ */ jsxs("box", {
7155
7446
  style: { flexDirection: "column" },
7156
7447
  onMouseDown: canExpand ? onToggleExpanded : void 0,
7157
7448
  children: [
@@ -7210,11 +7501,11 @@ function TaskCardContent({ label, todos, isActive, collapsed, expanded, onToggle
7210
7501
  * work. `isActive` dims the header while the run is idle. `expanded` unfolds
7211
7502
  * the completed work the window hides — a settled plan collapses to `✔ N done`
7212
7503
  * 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.
7504
+ * `minimized` is the card folded down to its header row, so a long plan can be
7505
+ * kept out of the way (click the header, or `/plan minimize`) without losing
7506
+ * the label and progress counter the way `/plan hide` does.
7216
7507
  */
7217
- function TodoCardView({ todos, isActive, collapsed, expanded, onToggleCollapsed, onToggleExpanded }) {
7508
+ function TodoCardView({ todos, isActive, minimized, expanded, onToggleMinimized, onToggleExpanded }) {
7218
7509
  if (todos.length === 0) return null;
7219
7510
  return /* @__PURE__ */ jsx("box", {
7220
7511
  style: {
@@ -7228,9 +7519,9 @@ function TodoCardView({ todos, isActive, collapsed, expanded, onToggleCollapsed,
7228
7519
  label: "Plan",
7229
7520
  todos,
7230
7521
  isActive,
7231
- collapsed,
7522
+ minimized,
7232
7523
  expanded,
7233
- onToggleCollapsed,
7524
+ onToggleMinimized,
7234
7525
  onToggleExpanded
7235
7526
  })
7236
7527
  });
@@ -7574,13 +7865,13 @@ function SubagentRowView({ row, selected, width, nowMs, onOpen }) {
7574
7865
  * the card reports its rows up through ConversationContext so the screen
7575
7866
  * knows what's selectable).
7576
7867
  *
7577
- * `collapsed` minimizes the card to its header row: a wide fan-out is 10 rows
7868
+ * `minimized` folds the card down to its header row: a wide fan-out is 10 rows
7578
7869
  * of the terminal for as long as the conversation lives, so clicking the
7579
7870
  * header (or `/subagent collapse`) folds the rows away and leaves the label,
7580
7871
  * the live glyph and the N/M counter. The chat screen owns the flag because it
7581
7872
  * reserves the card's rows to keep the composer pinned.
7582
7873
  */
7583
- function ToolSubagent({ items, isActive: _isActive, collapsed, onToggleCollapsed, onHeightChange }) {
7874
+ function ToolSubagent({ items, isActive: _isActive, minimized, onToggleMinimized, onHeightChange }) {
7584
7875
  const { rest, conversationId, openConversation, onSubagentBatch, subagentSelection } = useConversationContext();
7585
7876
  const { width } = useTerminalDimensions();
7586
7877
  const optimistic = /* @__PURE__ */ new Map();
@@ -7614,7 +7905,8 @@ function ToolSubagent({ items, isActive: _isActive, collapsed, onToggleCollapsed
7614
7905
  queued,
7615
7906
  fetched
7616
7907
  });
7617
- const live = fetched === null || rows.some((row) => isLiveStatus(row.status));
7908
+ const resolved = fetched !== null;
7909
+ const live = !resolved || rows.some((row) => isLiveStatus(row.status));
7618
7910
  const [nowMs, setNowMs] = useState(() => Date.now());
7619
7911
  useEffect(() => {
7620
7912
  if (!live || rows.length === 0) return;
@@ -7623,9 +7915,9 @@ function ToolSubagent({ items, isActive: _isActive, collapsed, onToggleCollapsed
7623
7915
  }, [live, rows.length]);
7624
7916
  useEffect(() => {
7625
7917
  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));
7918
+ onHeightChange?.(rows.length === 0 ? items.length > 0 ? 1 : 0 : minimized ? 1 : 1 + visibleRows + (rows.length > MAX_VISIBLE_ROWS ? 1 : 0));
7627
7919
  }, [
7628
- collapsed,
7920
+ minimized,
7629
7921
  items.length,
7630
7922
  onHeightChange,
7631
7923
  rows.length
@@ -7657,6 +7949,7 @@ function ToolSubagent({ items, isActive: _isActive, collapsed, onToggleCollapsed
7657
7949
  onSubagentBatch({
7658
7950
  toolCallId: conversationId,
7659
7951
  live,
7952
+ resolved,
7660
7953
  rows: rows.map((row) => ({
7661
7954
  id: row.id,
7662
7955
  conversationId: row.conversationId
@@ -7666,6 +7959,7 @@ function ToolSubagent({ items, isActive: _isActive, collapsed, onToggleCollapsed
7666
7959
  onSubagentBatch,
7667
7960
  conversationId,
7668
7961
  live,
7962
+ resolved,
7669
7963
  rows.map((row) => `${row.id}:${row.status}:${row.conversationId ?? ""}`).join("|")
7670
7964
  ]);
7671
7965
  if (rows.length === 0) {
@@ -7686,11 +7980,11 @@ function ToolSubagent({ items, isActive: _isActive, collapsed, onToggleCollapsed
7686
7980
  style: { flexDirection: "column" },
7687
7981
  children: [/* @__PURE__ */ jsx("box", {
7688
7982
  style: { flexDirection: "row" },
7689
- onMouseDown: onToggleCollapsed,
7983
+ onMouseDown: onToggleMinimized,
7690
7984
  children: /* @__PURE__ */ jsxs("text", { children: [
7691
7985
  /* @__PURE__ */ jsx("span", {
7692
7986
  fg: theme.dim,
7693
- children: collapsed ? "▸ " : "▾ "
7987
+ children: minimized ? "▸ " : "▾ "
7694
7988
  }),
7695
7989
  live ? /* @__PURE__ */ jsx(Spinner, { color: theme.accent }) : rows.every((row) => row.status === "completed") ? /* @__PURE__ */ jsx("span", {
7696
7990
  fg: theme.success,
@@ -7712,12 +8006,12 @@ function ToolSubagent({ items, isActive: _isActive, collapsed, onToggleCollapsed
7712
8006
  rows.length
7713
8007
  ]
7714
8008
  }),
7715
- openConversation && !collapsed ? /* @__PURE__ */ jsxs("span", {
8009
+ openConversation && !minimized ? /* @__PURE__ */ jsxs("span", {
7716
8010
  fg: theme.dim,
7717
8011
  children: [" ", selectedIndex !== null ? "↑/↓ select · ↵ view · esc back" : "/subagent to browse"]
7718
8012
  }) : null
7719
8013
  ] })
7720
- }), collapsed ? null : /* @__PURE__ */ jsxs("box", {
8014
+ }), minimized ? null : /* @__PURE__ */ jsxs("box", {
7721
8015
  style: { flexDirection: "column" },
7722
8016
  children: [visible.map((row, i) => {
7723
8017
  const target = row.conversationId;
@@ -7744,11 +8038,11 @@ function ToolSubagent({ items, isActive: _isActive, collapsed, onToggleCollapsed
7744
8038
  * a settled batch keeps its rows — titles and drill-in — until the next
7745
8039
  * fan-out replaces the group.
7746
8040
  *
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
8041
+ * `minimized` folds the rows away and leaves the header (click it, or
8042
+ * `/subagent minimize`), so a ten-task fan-out stops owning ten rows above the
7749
8043
  * composer for the rest of the conversation.
7750
8044
  */
7751
- function SubagentCardView({ items, isActive, collapsed, onToggleCollapsed, onHeightChange }) {
8045
+ function SubagentCardView({ items, isActive, minimized, onToggleMinimized, onHeightChange }) {
7752
8046
  const [height, setHeight] = useState(0);
7753
8047
  useEffect(() => onHeightChange?.(height > 0 ? height + 1 : 0), [height, onHeightChange]);
7754
8048
  return /* @__PURE__ */ jsx("box", {
@@ -7763,8 +8057,8 @@ function SubagentCardView({ items, isActive, collapsed, onToggleCollapsed, onHei
7763
8057
  children: /* @__PURE__ */ jsx(ToolSubagent, {
7764
8058
  items,
7765
8059
  isActive,
7766
- collapsed,
7767
- onToggleCollapsed,
8060
+ minimized,
8061
+ onToggleMinimized,
7768
8062
  onHeightChange: setHeight
7769
8063
  })
7770
8064
  });
@@ -8793,20 +9087,20 @@ const keybindGroups = [
8793
9087
  action: "jump back to your last message"
8794
9088
  },
8795
9089
  {
8796
- keys: "click header",
8797
- action: "minimize / expand the pinned plan or subagents card"
9090
+ keys: "click card header",
9091
+ action: "minimize the pinned plan or subagents card, and restore it"
8798
9092
  },
8799
9093
  {
8800
- keys: "click",
9094
+ keys: "click card body",
8801
9095
  action: "expand a folded plan card back to its finished rows"
8802
9096
  },
8803
9097
  {
8804
- keys: "/plan [show|hide|collapse|expand]",
8805
- action: "show / hide / minimize / unfold the pinned plan card"
9098
+ keys: "/plan [show|hide|minimize|expand|collapse]",
9099
+ action: "show / hide / minimize the pinned plan card, or unfold it"
8806
9100
  },
8807
9101
  {
8808
- keys: "/subagent [collapse|expand]",
8809
- action: "browse the pinned subagents card, or minimize / expand it"
9102
+ keys: "/subagent [minimize]",
9103
+ action: "browse the pinned subagents card, or minimize it"
8810
9104
  },
8811
9105
  {
8812
9106
  keys: "/rename <title>",
@@ -8960,6 +9254,18 @@ const keybindGroups = [
8960
9254
  keys: "↵",
8961
9255
  action: "open conversation"
8962
9256
  },
9257
+ {
9258
+ keys: "ctrl+v",
9259
+ action: "paste image from clipboard"
9260
+ },
9261
+ {
9262
+ keys: "drag-drop",
9263
+ action: "attach image files dropped onto the terminal"
9264
+ },
9265
+ {
9266
+ keys: "ctrl+x",
9267
+ action: "drop the last staged image"
9268
+ },
8963
9269
  {
8964
9270
  keys: "ctrl+a",
8965
9271
  action: "archive conversation (hides it for you only; again to restore)"
@@ -9511,12 +9817,9 @@ const slashCommands = [
9511
9817
  {
9512
9818
  name: "subagent",
9513
9819
  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
- }
9820
+ argsHint: "[minimize]",
9821
+ description: "browse or minimize the fan-out card",
9822
+ action: { kind: "subagent" }
9520
9823
  },
9521
9824
  {
9522
9825
  name: "status",
@@ -9535,8 +9838,8 @@ const slashCommands = [
9535
9838
  {
9536
9839
  name: "plan",
9537
9840
  aliases: ["todos", "todo"],
9538
- argsHint: "[show|hide|collapse|expand]",
9539
- description: "show, hide, minimize or expand the pinned plan",
9841
+ argsHint: "[show|hide|minimize|expand|collapse]",
9842
+ description: "show, hide, minimize, or unfold the pinned plan card",
9540
9843
  action: {
9541
9844
  kind: "plan",
9542
9845
  mode: "toggle"
@@ -11847,7 +12150,7 @@ function canPreview(file) {
11847
12150
  if (/\/(json|xml|javascript|typescript|x-sh|yaml)$/.test(file.mediaType)) return true;
11848
12151
  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
12152
  }
11850
- function formatBytes$1(bytes) {
12153
+ function formatBytes(bytes) {
11851
12154
  if (bytes < 1024) return `${bytes} B`;
11852
12155
  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
11853
12156
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
@@ -11915,7 +12218,7 @@ function WorkspaceFilesTab({ agentId, viewportWidth, viewportHeight, active, she
11915
12218
  ]);
11916
12219
  const visibleFiles = useMemo(() => load.kind === "ready" ? filterReviewFiles(load.files, fileSearch ?? "", (file) => file.path) : [], [load, fileSearch]);
11917
12220
  const rows = useMemo(() => buildWorkspaceTree(visibleFiles), [visibleFiles]);
11918
- const navigatorPreferredWidth = reviewTreePreferredWidth(useMemo(() => buildReviewTree(visibleFiles, (file) => file.path), [visibleFiles]), (file) => ` · ${formatBytes$1(file.sizeBytes)}`.length);
12221
+ const navigatorPreferredWidth = reviewTreePreferredWidth(useMemo(() => buildReviewTree(visibleFiles, (file) => file.path), [visibleFiles]), (file) => ` · ${formatBytes(file.sizeBytes)}`.length);
11919
12222
  const shellDimensions = reviewShellDimensions({
11920
12223
  width,
11921
12224
  height: shellHeight,
@@ -11954,7 +12257,7 @@ function WorkspaceFilesTab({ agentId, viewportWidth, viewportHeight, active, she
11954
12257
  if (!canPreview(selectedFile)) {
11955
12258
  setPreview({
11956
12259
  kind: "none",
11957
- message: `${selectedFile.mediaType} · ${formatBytes$1(selectedFile.sizeBytes)}\npreview unavailable for binary or large files`
12260
+ message: `${selectedFile.mediaType} · ${formatBytes(selectedFile.sizeBytes)}\npreview unavailable for binary or large files`
11958
12261
  });
11959
12262
  return;
11960
12263
  }
@@ -12193,7 +12496,7 @@ function WorkspaceFilesTab({ agentId, viewportWidth, viewportHeight, active, she
12193
12496
  row.name,
12194
12497
  /* @__PURE__ */ jsxs("span", {
12195
12498
  fg: theme.faint,
12196
- children: [" · ", formatBytes$1(row.file.sizeBytes)]
12499
+ children: [" · ", formatBytes(row.file.sizeBytes)]
12197
12500
  })
12198
12501
  ]
12199
12502
  }, row.file.id);
@@ -13375,16 +13678,13 @@ function routeInput(raw) {
13375
13678
  const arg = match.rest.toLowerCase();
13376
13679
  return {
13377
13680
  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"
13681
+ mode: arg === "show" ? "show" : arg === "hide" ? "hide" : arg === "expand" ? "expand" : arg === "collapse" ? "collapse" : arg === "minimize" ? "minimize" : "toggle"
13386
13682
  };
13387
13683
  }
13684
+ case "subagent": return {
13685
+ kind: "subagent",
13686
+ mode: match.rest.toLowerCase() === "minimize" ? "minimize" : "browse"
13687
+ };
13388
13688
  case "new-conversation":
13389
13689
  case "fork":
13390
13690
  case "archive-conversation":
@@ -13505,7 +13805,6 @@ const pageScrollFraction = .75;
13505
13805
  const HISTORY_PAGE_SIZE = 60;
13506
13806
  /** Cap composer growth; past this the textarea scrolls its content instead. */
13507
13807
  const maxComposerRows = 8;
13508
- const maxAttachments = 10;
13509
13808
  /**
13510
13809
  * A server timestamp as local epoch ms, or null when it is missing or
13511
13810
  * unparseable so the caller can fall back to its own clock.
@@ -13515,18 +13814,9 @@ function parseStampMs(iso) {
13515
13814
  const ms = Date.parse(iso);
13516
13815
  return Number.isNaN(ms) ? null : ms;
13517
13816
  }
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
- }
13817
+ /** State updater: undo an opened-finished fold, leave a reader's fold alone. */
13818
+ function unfoldSettled(current) {
13819
+ return current === "settled" ? null : current;
13530
13820
  }
13531
13821
  /**
13532
13822
  * Open a URL in the user's local browser. Best-effort: over SSH there may be
@@ -13583,12 +13873,11 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13583
13873
  const [planHidden, setPlanHidden] = useState(false);
13584
13874
  const [planExpanded, setPlanExpanded] = useState(false);
13585
13875
  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
- });
13876
+ const [planMinimizedBy, setPlanMinimizedBy] = useState(null);
13877
+ const planMinimized = planMinimizedBy !== null;
13878
+ const togglePlanMinimized = useCallback(() => {
13879
+ setPlanMinimizedBy((current) => current === null ? "reader" : null);
13880
+ setPlanExpanded(false);
13592
13881
  }, []);
13593
13882
  const [subagentRows, setSubagentRows] = useState(0);
13594
13883
  const [anchoredSubagentIds, setAnchoredSubagentIds] = useState([]);
@@ -13610,6 +13899,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13610
13899
  setPlanExpanded(false);
13611
13900
  }
13612
13901
  hadTodosRef.current = hasTodos;
13902
+ if (hasTodos && todosHaveLiveWork(todos)) setPlanMinimizedBy(unfoldSettled);
13613
13903
  }, [todos]);
13614
13904
  const [historyLoaded, setHistoryLoaded] = useState(initialConversationId === null);
13615
13905
  const [input, setInput] = useState(() => useStore.getState().takeEarlyInput());
@@ -13621,12 +13911,11 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13621
13911
  const [modelPickerView, setModelPickerView] = useState(null);
13622
13912
  const modelPickerOpen = modelPickerView !== null;
13623
13913
  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
- });
13914
+ const [subagentMinimizedBy, setSubagentMinimizedBy] = useState(null);
13915
+ const subagentMinimized = subagentMinimizedBy !== null;
13916
+ const toggleSubagentMinimized = useCallback(() => {
13917
+ setSubagentMinimizedBy((current) => current === null ? "reader" : null);
13918
+ setSubagentSelection(null);
13630
13919
  }, []);
13631
13920
  const [subagentBatches, setSubagentBatches] = useState(() => /* @__PURE__ */ new Map());
13632
13921
  const onSubagentBatch = useCallback((report) => {
@@ -13661,8 +13950,6 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13661
13950
  }, [initialConversationId]);
13662
13951
  const [ctrlCArmed, setCtrlCArmed] = useState(false);
13663
13952
  const [composerRows, setComposerRows] = useState(1);
13664
- const [attachments, setAttachments] = useState([]);
13665
- const uploadsRef = useRef(/* @__PURE__ */ new Map());
13666
13953
  const [forking, setForking] = useState(false);
13667
13954
  const forkingRef = useRef(false);
13668
13955
  const [credPrompt, setCredPrompt] = useState(null);
@@ -13672,6 +13959,20 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13672
13959
  const [askPrompt, setAskPrompt] = useState(null);
13673
13960
  const askPromptOpen = askPrompt !== null;
13674
13961
  const [restartConfirm, setRestartConfirm] = useState(false);
13962
+ const { staged: attachments, stagedRef: attachmentsRef, stageClipboardImage, dropLast: dropLastAttachment, clearStaged: clearAttachments, collectForSend, adoptUploaded, releaseUploaded, restoreStaged } = useComposerAttachments({
13963
+ rest,
13964
+ agentId: agent.id,
13965
+ pasteDisabled: cardPickerOpen || modelPickerOpen || themePickerOpen || prPickerOpen || helpOpen || creditsOpen || bgTasksOpen || credPromptOpen || Boolean(grantPrompt) || computePromptOpen || askPromptOpen || restartConfirm || nonWebChannel !== null,
13966
+ onPasteText: (text) => {
13967
+ composerRef.current?.insertText(text);
13968
+ handleComposerChange();
13969
+ },
13970
+ onError: (message) => setItems((prev) => [...prev, {
13971
+ kind: "error",
13972
+ id: crypto.randomUUID(),
13973
+ text: message
13974
+ }])
13975
+ });
13675
13976
  const [menuDismissed, setMenuDismissed] = useState(false);
13676
13977
  const [menuHighlight, setMenuHighlight] = useState(0);
13677
13978
  const commandQuery = grantPrompt || credPrompt || computePrompt || askPrompt || restartConfirm ? null : detectCommandTrigger(input);
@@ -13684,8 +13985,6 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13684
13985
  conversationIdRef.current = conversationId;
13685
13986
  const itemsRef = useRef(items);
13686
13987
  itemsRef.current = items;
13687
- const attachmentsRef = useRef(attachments);
13688
- attachmentsRef.current = attachments;
13689
13988
  const inputRef = useRef(input);
13690
13989
  inputRef.current = input;
13691
13990
  const credPromptRef = useRef(credPrompt);
@@ -13772,7 +14071,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13772
14071
  const paneDragRef = useRef(null);
13773
14072
  const composerBoxHeight = composerRows + 2;
13774
14073
  const pendingVisible = !grantPrompt && !credPrompt && !computePrompt && !askPrompt && !nonWebChannel && attachments.length > 0;
13775
- const pendingRows = pendingVisible ? attachments.length + 2 : 0;
14074
+ const pendingRows = pendingVisible ? attachmentTrayRows(attachments.length) : 0;
13776
14075
  const credPromptHeight = 4 + (credPrompt && (credPrompt.error || credPrompt.submitting) ? 1 : 0);
13777
14076
  const nonWebNoticeHeight = 5;
13778
14077
  const computePromptHeight = 4;
@@ -13795,7 +14094,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13795
14094
  const todoCardVisible = !credPrompt && !planHidden && !!todos && todos.length > 0;
13796
14095
  const todoRows = todoCardVisible ? todoCardHeight(todos, {
13797
14096
  expanded: planExpanded,
13798
- collapsed: planCollapsed
14097
+ minimized: planMinimized
13799
14098
  }) : 0;
13800
14099
  const subagentItems = useMemo(() => {
13801
14100
  const ids = new Set(anchoredSubagentIds);
@@ -13808,6 +14107,13 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13808
14107
  }, [conversationId, subagentBatches]);
13809
14108
  const anchoredSubagentBatchRef = useRef(anchoredSubagentBatch);
13810
14109
  anchoredSubagentBatchRef.current = anchoredSubagentBatch;
14110
+ const subagentStatusResolvedRef = useRef(false);
14111
+ useEffect(() => {
14112
+ if (!anchoredSubagentBatch?.resolved) return;
14113
+ if (anchoredSubagentBatch.live) setSubagentMinimizedBy(unfoldSettled);
14114
+ else if (!subagentStatusResolvedRef.current) setSubagentMinimizedBy((current) => current ?? "settled");
14115
+ subagentStatusResolvedRef.current = true;
14116
+ }, [anchoredSubagentBatch]);
13811
14117
  useEffect(() => {
13812
14118
  setSubagentSelection((sel) => {
13813
14119
  if (!sel) return sel;
@@ -13908,7 +14214,9 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13908
14214
  const anchors = subagentAnchorsFromHistory(loaded);
13909
14215
  if (anchors.length > 0) setAnchoredSubagentIds((current) => [...new Set([...anchors, ...current])]);
13910
14216
  seedPrLinks(loaded);
13911
- setTodos(latestTodosFromHistory(firstPage.messages));
14217
+ const loadedTodos = latestTodosFromHistory(firstPage.messages);
14218
+ setTodos(loadedTodos);
14219
+ if (loadedTodos?.length && !todosHaveLiveWork(loadedTodos)) setPlanMinimizedBy("settled");
13912
14220
  setHistoryLoaded(true);
13913
14221
  let before = firstPage.hasMore ? firstPage.nextBefore : null;
13914
14222
  while (before) {
@@ -14110,28 +14418,13 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14110
14418
  startedAtMs: Date.now()
14111
14419
  });
14112
14420
  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
- }
14421
+ const { files, rows, failed } = await collectForSend(staged);
14422
+ const attachmentIds = files.map((file) => file.id);
14423
+ for (const { attachment, reason } of failed) setItems((prev) => [...prev, {
14424
+ kind: "error",
14425
+ id: crypto.randomUUID(),
14426
+ text: `attachment failed: ${attachment.fileName}${reason === null ? "" : ` (${errorDetail(reason)})`}`
14427
+ }]);
14135
14428
  if (!trimmed && attachmentIds.length === 0) {
14136
14429
  setItems((prev) => prev.filter((m) => m.id !== optimisticId));
14137
14430
  if (!wasStreaming) setRun({ kind: "idle" });
@@ -14144,7 +14437,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14144
14437
  attachmentIds,
14145
14438
  clientSurface: "tui"
14146
14439
  });
14147
- for (const row of restorable) uploadsRef.current.delete(row.tempId);
14440
+ releaseUploaded(rows);
14148
14441
  if (result.isNewConversation) setConversationId(result.conversationId);
14149
14442
  if (result.steered) {
14150
14443
  const directiveId = result.directive?.id;
@@ -14174,27 +14467,26 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14174
14467
  text: sendErrorMessage(err)
14175
14468
  }]);
14176
14469
  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]);
14470
+ restoreStaged(staged);
14183
14471
  if (!wasStreaming) setRun({ kind: "idle" });
14184
14472
  }
14185
14473
  }, [
14186
14474
  rest,
14187
14475
  agent.id,
14188
14476
  conversationId,
14189
- attachToRun
14477
+ attachToRun,
14478
+ collectForSend,
14479
+ releaseUploaded,
14480
+ restoreStaged
14190
14481
  ]);
14191
14482
  const portalStatus = portal.status;
14192
14483
  useEffect(() => {
14193
- const { seedPrompt, autoGrantMachine } = useStore.getState();
14194
- if (!seedPrompt || !isNewConversation(conversation)) return;
14484
+ const { seedPrompt, seedAttachments, autoGrantMachine } = useStore.getState();
14485
+ if (!seedPrompt && seedAttachments.length === 0 || !isNewConversation(conversation)) return;
14195
14486
  if (autoGrantMachine && (portalStatus === "off" || portalStatus === "connecting")) return;
14196
14487
  useStore.setState({
14197
14488
  seedPrompt: null,
14489
+ seedAttachments: [],
14198
14490
  autoGrantMachine: false
14199
14491
  });
14200
14492
  (async () => {
@@ -14212,7 +14504,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14212
14504
  variant: "warning"
14213
14505
  });
14214
14506
  }
14215
- await sendContent(seedPrompt, []);
14507
+ await sendContent(seedPrompt ?? "", adoptUploaded(seedAttachments));
14216
14508
  })();
14217
14509
  }, [
14218
14510
  conversation,
@@ -14220,7 +14512,8 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14220
14512
  portalClient,
14221
14513
  agent.id,
14222
14514
  agent.name,
14223
- sendContent
14515
+ sendContent,
14516
+ adoptUploaded
14224
14517
  ]);
14225
14518
  const cancelLatestSteer = useCallback(() => {
14226
14519
  if (!rest) return false;
@@ -14627,95 +14920,6 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14627
14920
  if (detectCommandTrigger(composer.plainText) === null) setMenuDismissed(false);
14628
14921
  setComposerRows(Math.min(Math.max(composer.editorView.getTotalVirtualLineCount(), 1), maxComposerRows));
14629
14922
  }, []);
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
14923
  const applyComposerText = useCallback((text, caret) => {
14720
14924
  const composer = composerRef.current;
14721
14925
  if (!composer) return;
@@ -15226,23 +15430,19 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15226
15430
  else setPrPickerOpen(true);
15227
15431
  break;
15228
15432
  case "subagent": {
15433
+ if (routed.mode === "minimize") {
15434
+ const next = !subagentMinimized;
15435
+ setSubagentMinimizedBy(next ? "reader" : null);
15436
+ if (next) setSubagentSelection(null);
15437
+ useStore.getState().showToast({ message: next ? "subagents minimized (/subagent minimize again to restore)" : "subagents restored" });
15438
+ break;
15439
+ }
15229
15440
  const batch = anchoredSubagentBatchRef.current;
15230
15441
  if (!batch || batch.rows.length === 0) {
15231
15442
  useStore.getState().showToast({ message: "no subagents in this conversation yet" });
15232
15443
  break;
15233
15444
  }
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);
15445
+ setSubagentMinimizedBy(null);
15246
15446
  setSubagentSelection({
15247
15447
  toolCallId: batch.toolCallId,
15248
15448
  index: 0
@@ -15279,22 +15479,25 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15279
15479
  useStore.getState().showToast({ message: "no plan to show yet" });
15280
15480
  break;
15281
15481
  }
15282
- if (routed.mode === "collapse") {
15283
- setPlanCollapsed(true);
15482
+ if (routed.mode === "minimize") {
15483
+ const next = !planMinimized;
15484
+ setPlanMinimizedBy(next ? "reader" : null);
15485
+ setPlanHidden(false);
15284
15486
  setPlanExpanded(false);
15285
- useStore.getState().showToast({ message: "plan collapsed (/plan expand)" });
15487
+ useStore.getState().showToast({ message: next ? "plan minimized (/plan minimize again to restore)" : "plan restored" });
15286
15488
  break;
15287
15489
  }
15288
- if (routed.mode === "expand") {
15289
- setPlanCollapsed(false);
15290
- setPlanExpanded(true);
15490
+ if (routed.mode === "expand" || routed.mode === "collapse") {
15491
+ const nextExpanded = routed.mode === "expand";
15492
+ setPlanExpanded(nextExpanded);
15291
15493
  setPlanHidden(false);
15292
- useStore.getState().showToast({ message: "plan expanded (/plan collapse)" });
15494
+ setPlanMinimizedBy(null);
15495
+ useStore.getState().showToast({ message: nextExpanded ? "plan expanded (/plan collapse)" : "plan collapsed" });
15293
15496
  break;
15294
15497
  }
15295
15498
  const nextHidden = routed.mode === "toggle" ? !planHidden : routed.mode === "hide";
15296
15499
  setPlanHidden(nextHidden);
15297
- if (!nextHidden) setPlanCollapsed(false);
15500
+ if (!nextHidden) setPlanMinimizedBy(null);
15298
15501
  useStore.getState().showToast({ message: nextHidden ? "plan hidden (/plan show)" : "plan shown" });
15299
15502
  break;
15300
15503
  }
@@ -15362,7 +15565,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15362
15565
  setInput("");
15363
15566
  composerRef.current?.clear();
15364
15567
  setComposerRows(1);
15365
- setAttachments([]);
15568
+ clearAttachments();
15366
15569
  sendContent(content, staged);
15367
15570
  }, [
15368
15571
  history,
@@ -15382,6 +15585,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15382
15585
  compactConversation,
15383
15586
  quit,
15384
15587
  sendContent,
15588
+ clearAttachments,
15385
15589
  goTo,
15386
15590
  agent,
15387
15591
  appUrl,
@@ -15393,6 +15597,8 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15393
15597
  promptForCards,
15394
15598
  todos,
15395
15599
  planHidden,
15600
+ planMinimized,
15601
+ subagentMinimized,
15396
15602
  prLinks.length
15397
15603
  ]);
15398
15604
  const submit = useCallback(() => {
@@ -15635,16 +15841,12 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15635
15841
  }
15636
15842
  if (key.name === "v" && key.ctrl) {
15637
15843
  if (nonWebChannel) return;
15638
- pasteImage();
15844
+ stageClipboardImage();
15639
15845
  return;
15640
15846
  }
15641
15847
  if (key.name === "x" && key.ctrl || key.name === "backspace" && attachmentsRef.current.length > 0 && !composerRef.current?.plainText) {
15642
15848
  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
- }
15849
+ dropLastAttachment();
15648
15850
  return;
15649
15851
  }
15650
15852
  if (key.name === "l" && key.ctrl) openInBrowser();
@@ -16039,16 +16241,16 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
16039
16241
  /* @__PURE__ */ jsx(SubagentCardView, {
16040
16242
  items: subagentItems,
16041
16243
  isActive: run.kind !== "idle",
16042
- collapsed: subagentCollapsed,
16043
- onToggleCollapsed: toggleSubagentCollapsed,
16244
+ minimized: subagentMinimized,
16245
+ onToggleMinimized: toggleSubagentMinimized,
16044
16246
  onHeightChange: setSubagentRows
16045
16247
  }),
16046
16248
  todoCardVisible && todos ? /* @__PURE__ */ jsx(TodoCardView, {
16047
16249
  todos,
16048
16250
  isActive: run.kind !== "idle",
16049
- collapsed: planCollapsed,
16251
+ minimized: planMinimized,
16050
16252
  expanded: planExpanded,
16051
- onToggleCollapsed: togglePlanCollapsed,
16253
+ onToggleMinimized: togglePlanMinimized,
16052
16254
  onToggleExpanded: togglePlanExpanded
16053
16255
  }) : null,
16054
16256
  compacting ? /* @__PURE__ */ jsxs("box", {
@@ -16068,29 +16270,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
16068
16270
  children: [" ", "you can keep typing"]
16069
16271
  })]
16070
16272
  }) : 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,
16273
+ pendingVisible ? /* @__PURE__ */ jsx(AttachmentTray, { attachments }) : null,
16094
16274
  menuOpen ? /* @__PURE__ */ jsx(CompletionMenu, {
16095
16275
  items: menuCommands.map((c) => ({
16096
16276
  id: c.name,