skydive-cli 0.6.0-beta.18 → 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-DZCq0tZw.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-CCXpClJw.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-Dvl9vzTP.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-DG0vUvWI.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-rHe1xapN.mjs";
10
+ import "./daemon-By6thwls.mjs";
11
11
  import { t as SandboxStream } from "./client-l6orYfqq.mjs";
12
- import { t as PortalDaemonClient } from "./daemon-client-D-k67MEB.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
@@ -3967,166 +4396,12 @@ function formatTranscript(messages, format) {
3967
4396
  if (format === "markdown") return `# Conversation transcript\n\n${entries.join("\n\n")}\n`;
3968
4397
  return `${entries.join("\n\n")}\n`;
3969
4398
  }
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
4097
- };
4098
- if (input.text.trim() === "") return { kind: "clipboard-image" };
4099
- return { kind: "text" };
4100
- }
4101
- /**
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).
4106
- *
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).
4111
- */
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
- }
4129
- }
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;
4404
+ }
4130
4405
 
4131
4406
  //#endregion
4132
4407
  //#region src/chat/save-file.ts
@@ -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`;
@@ -7059,9 +7334,14 @@ function todosCanExpand(todos, cap) {
7059
7334
  * reserve exactly this much and keep the composer pinned. Header + optional
7060
7335
  * done-summary line + rendered rows + optional "+N more" tail + top margin.
7061
7336
  * Must track `windowTodos` and `TodoCardView`'s layout.
7337
+ *
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.
7062
7341
  */
7063
- function todoCardHeight(todos, { expanded }) {
7342
+ function todoCardHeight(todos, { expanded, minimized }) {
7064
7343
  if (todos.length === 0) return 0;
7344
+ if (minimized) return 2;
7065
7345
  const w = windowTodos(todos, {
7066
7346
  cap: MAX_VISIBLE_ROWS$1,
7067
7347
  expanded
@@ -7102,15 +7382,24 @@ function rowColor$1(status) {
7102
7382
  }
7103
7383
  }
7104
7384
  /**
7105
- * The plan card's checklist content. The caller owns outer spacing and
7106
- * the expanded state — the chat screen has to reserve the card's rows, so it
7107
- * can't discover the height from a toggle held down here.
7385
+ * The plan card's checklist content. The caller owns outer spacing and both
7386
+ * view states — the chat screen has to reserve the card's rows, so it can't
7387
+ * discover the height from a toggle held down here.
7388
+ *
7389
+ * The card has two independent click targets, one per axis of "how much do I
7390
+ * show":
7391
+ * - the **header** minimizes the card to that one row and back
7392
+ * (`minimized` / `onToggleMinimized`), which is what keeps a long plan from
7393
+ * eating the screen above the composer. The chevron carries the affordance.
7394
+ * - the **body** unfolds the completed work the window hides
7395
+ * (`expanded` / `onToggleExpanded`), the same interaction reasoning and
7396
+ * tool output already use. Only clickable when something is actually folded.
7108
7397
  *
7109
- * A card whose completed work is folded away is clickable: the header carries
7110
- * the affordance and clicking anywhere on the card toggles it, the same
7111
- * interaction reasoning and tool output already use.
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.
7112
7401
  */
7113
- function TaskCardContent({ label, todos, isActive, expanded, onToggleExpanded }) {
7402
+ function TaskCardContent({ label, todos, isActive, minimized, expanded, onToggleMinimized, onToggleExpanded }) {
7114
7403
  const completed = todos.filter((todo) => todo.status === "completed").length;
7115
7404
  const canExpand = todosCanExpand(todos, MAX_VISIBLE_ROWS$1);
7116
7405
  const { doneSummary, rows, moreCount } = windowTodos(todos, {
@@ -7119,66 +7408,76 @@ function TaskCardContent({ label, todos, isActive, expanded, onToggleExpanded })
7119
7408
  });
7120
7409
  return /* @__PURE__ */ jsxs("box", {
7121
7410
  style: { flexDirection: "column" },
7122
- onMouseDown: canExpand ? onToggleExpanded : void 0,
7123
- children: [
7124
- /* @__PURE__ */ jsxs("box", {
7125
- style: { flexDirection: "row" },
7126
- children: [
7127
- /* @__PURE__ */ jsx("text", {
7128
- fg: isActive ? theme.accent : theme.muted,
7129
- children: /* @__PURE__ */ jsx("b", { children: label })
7130
- }),
7131
- /* @__PURE__ */ jsxs("text", {
7132
- fg: theme.dim,
7133
- children: [
7134
- " ",
7135
- completed,
7136
- "/",
7137
- todos.length
7138
- ]
7139
- }),
7140
- canExpand ? /* @__PURE__ */ jsxs("text", {
7141
- fg: theme.dim,
7142
- children: [
7143
- " ",
7144
- "· click to ",
7145
- expanded ? "collapse" : "expand"
7146
- ]
7147
- }) : null
7148
- ]
7149
- }),
7150
- doneSummary > 0 ? /* @__PURE__ */ jsxs("box", {
7151
- style: { flexDirection: "row" },
7152
- children: [/* @__PURE__ */ jsx("text", {
7153
- fg: theme.success,
7154
- children: "✔ "
7155
- }), /* @__PURE__ */ jsxs("text", {
7411
+ children: [/* @__PURE__ */ jsxs("box", {
7412
+ style: { flexDirection: "row" },
7413
+ onMouseDown: onToggleMinimized,
7414
+ children: [
7415
+ /* @__PURE__ */ jsx("text", {
7156
7416
  fg: theme.dim,
7157
- children: [doneSummary, " done"]
7158
- })]
7159
- }) : null,
7160
- rows.map((todo) => {
7161
- const g = glyph(todo.status);
7162
- return /* @__PURE__ */ jsxs("box", {
7417
+ children: minimized ? " " : "▾ "
7418
+ }),
7419
+ /* @__PURE__ */ jsx("text", {
7420
+ fg: isActive ? theme.accent : theme.muted,
7421
+ children: /* @__PURE__ */ jsx("b", { children: label })
7422
+ }),
7423
+ /* @__PURE__ */ jsxs("text", {
7424
+ fg: theme.dim,
7425
+ children: [
7426
+ " ",
7427
+ completed,
7428
+ "/",
7429
+ todos.length
7430
+ ]
7431
+ }),
7432
+ !minimized && expanded && canExpand ? /* @__PURE__ */ jsxs("text", {
7433
+ fg: theme.dim,
7434
+ children: [" ", "· click a row to fold"]
7435
+ }) : null
7436
+ ]
7437
+ }), minimized ? null : /* @__PURE__ */ jsxs("box", {
7438
+ style: { flexDirection: "column" },
7439
+ onMouseDown: canExpand ? onToggleExpanded : void 0,
7440
+ children: [
7441
+ doneSummary > 0 ? /* @__PURE__ */ jsxs("box", {
7163
7442
  style: { flexDirection: "row" },
7164
- children: [/* @__PURE__ */ jsxs("text", {
7165
- fg: g.fg,
7166
- children: [g.char, " "]
7167
- }), /* @__PURE__ */ jsx("text", {
7168
- fg: rowColor$1(todo.status),
7169
- children: todo.content
7170
- })]
7171
- }, todo.id);
7172
- }),
7173
- moreCount > 0 ? /* @__PURE__ */ jsxs("text", {
7174
- fg: theme.dim,
7175
- children: [
7176
- " … +",
7177
- moreCount,
7178
- " more"
7179
- ]
7180
- }) : null
7181
- ]
7443
+ children: [
7444
+ /* @__PURE__ */ jsx("text", {
7445
+ fg: theme.success,
7446
+ children: "✔ "
7447
+ }),
7448
+ /* @__PURE__ */ jsxs("text", {
7449
+ fg: theme.dim,
7450
+ children: [doneSummary, " done"]
7451
+ }),
7452
+ /* @__PURE__ */ jsxs("text", {
7453
+ fg: theme.dim,
7454
+ children: [" ", "· click to show"]
7455
+ })
7456
+ ]
7457
+ }) : null,
7458
+ rows.map((todo) => {
7459
+ const g = glyph(todo.status);
7460
+ return /* @__PURE__ */ jsxs("box", {
7461
+ style: { flexDirection: "row" },
7462
+ children: [/* @__PURE__ */ jsxs("text", {
7463
+ fg: g.fg,
7464
+ children: [g.char, " "]
7465
+ }), /* @__PURE__ */ jsx("text", {
7466
+ fg: rowColor$1(todo.status),
7467
+ children: todo.content
7468
+ })]
7469
+ }, todo.id);
7470
+ }),
7471
+ moreCount > 0 ? /* @__PURE__ */ jsxs("text", {
7472
+ fg: theme.dim,
7473
+ children: [
7474
+ " … +",
7475
+ moreCount,
7476
+ " more"
7477
+ ]
7478
+ }) : null
7479
+ ]
7480
+ })]
7182
7481
  });
7183
7482
  }
7184
7483
 
@@ -7193,9 +7492,12 @@ function TaskCardContent({ label, todos, isActive, expanded, onToggleExpanded })
7193
7492
  * The card can't scroll like the web card, so it windows completed and pending
7194
7493
  * work. `isActive` dims the header while the run is idle. `expanded` unfolds
7195
7494
  * the completed work the window hides — a settled plan collapses to `✔ N done`
7196
- * otherwise, and clicking the card (or `/plan expand`) brings the rows back.
7495
+ * otherwise, and clicking the body (or `/plan expand`) brings the rows back.
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.
7197
7499
  */
7198
- function TodoCardView({ todos, isActive, expanded, onToggleExpanded }) {
7500
+ function TodoCardView({ todos, isActive, minimized, expanded, onToggleMinimized, onToggleExpanded }) {
7199
7501
  if (todos.length === 0) return null;
7200
7502
  return /* @__PURE__ */ jsx("box", {
7201
7503
  style: {
@@ -7209,7 +7511,9 @@ function TodoCardView({ todos, isActive, expanded, onToggleExpanded }) {
7209
7511
  label: "Plan",
7210
7512
  todos,
7211
7513
  isActive,
7514
+ minimized,
7212
7515
  expanded,
7516
+ onToggleMinimized,
7213
7517
  onToggleExpanded
7214
7518
  })
7215
7519
  });
@@ -7552,8 +7856,14 @@ function SubagentRowView({ row, selected, width, nowMs, onOpen }) {
7552
7856
  * ↵ to view (the chat screen owns those keys and the esc-back bookkeeping;
7553
7857
  * the card reports its rows up through ConversationContext so the screen
7554
7858
  * knows what's selectable).
7859
+ *
7860
+ * `minimized` folds the card down to its header row: a wide fan-out is 10 rows
7861
+ * of the terminal for as long as the conversation lives, so clicking the
7862
+ * header (or `/subagent collapse`) folds the rows away and leaves the label,
7863
+ * the live glyph and the N/M counter. The chat screen owns the flag because it
7864
+ * reserves the card's rows to keep the composer pinned.
7555
7865
  */
7556
- function ToolSubagent({ items, isActive: _isActive, onHeightChange }) {
7866
+ function ToolSubagent({ items, isActive: _isActive, minimized, onToggleMinimized, onHeightChange }) {
7557
7867
  const { rest, conversationId, openConversation, onSubagentBatch, subagentSelection } = useConversationContext();
7558
7868
  const { width } = useTerminalDimensions();
7559
7869
  const optimistic = /* @__PURE__ */ new Map();
@@ -7596,8 +7906,9 @@ function ToolSubagent({ items, isActive: _isActive, onHeightChange }) {
7596
7906
  }, [live, rows.length]);
7597
7907
  useEffect(() => {
7598
7908
  const visibleRows = Math.min(rows.length, MAX_VISIBLE_ROWS);
7599
- onHeightChange?.(rows.length === 0 ? items.length > 0 ? 1 : 0 : 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));
7600
7910
  }, [
7911
+ minimized,
7601
7912
  items.length,
7602
7913
  onHeightChange,
7603
7914
  rows.length
@@ -7656,32 +7967,40 @@ function ToolSubagent({ items, isActive: _isActive, onHeightChange }) {
7656
7967
  const moreBelow = rows.length - start - visible.length;
7657
7968
  return /* @__PURE__ */ jsxs("box", {
7658
7969
  style: { flexDirection: "column" },
7659
- children: [/* @__PURE__ */ jsxs("text", { children: [
7660
- live ? /* @__PURE__ */ jsx(Spinner, { color: theme.accent }) : rows.every((row) => row.status === "completed") ? /* @__PURE__ */ jsx("span", {
7661
- fg: theme.success,
7662
- children: ""
7663
- }) : /* @__PURE__ */ jsx("span", {
7664
- fg: theme.error,
7665
- children: ""
7666
- }),
7667
- /* @__PURE__ */ jsxs("span", {
7668
- fg: theme.tool,
7669
- children: [" ", /* @__PURE__ */ jsx("b", { children: headerLabel(rows) })]
7670
- }),
7671
- /* @__PURE__ */ jsxs("span", {
7672
- fg: theme.dim,
7673
- children: [
7674
- " ",
7675
- rows.filter((row) => row.status === "completed").length,
7676
- "/",
7677
- rows.length
7678
- ]
7679
- }),
7680
- openConversation ? /* @__PURE__ */ jsxs("span", {
7681
- fg: theme.dim,
7682
- children: [" ", selectedIndex !== null ? "↑/↓ select · ↵ view · esc back" : "/subagent to browse"]
7683
- }) : null
7684
- ] }), /* @__PURE__ */ jsxs("box", {
7970
+ children: [/* @__PURE__ */ jsx("box", {
7971
+ style: { flexDirection: "row" },
7972
+ onMouseDown: onToggleMinimized,
7973
+ children: /* @__PURE__ */ jsxs("text", { children: [
7974
+ /* @__PURE__ */ jsx("span", {
7975
+ fg: theme.dim,
7976
+ children: minimized ? "" : "▾ "
7977
+ }),
7978
+ live ? /* @__PURE__ */ jsx(Spinner, { color: theme.accent }) : rows.every((row) => row.status === "completed") ? /* @__PURE__ */ jsx("span", {
7979
+ fg: theme.success,
7980
+ children: ""
7981
+ }) : /* @__PURE__ */ jsx("span", {
7982
+ fg: theme.error,
7983
+ children: "✗"
7984
+ }),
7985
+ /* @__PURE__ */ jsxs("span", {
7986
+ fg: theme.tool,
7987
+ children: [" ", /* @__PURE__ */ jsx("b", { children: headerLabel(rows) })]
7988
+ }),
7989
+ /* @__PURE__ */ jsxs("span", {
7990
+ fg: theme.dim,
7991
+ children: [
7992
+ " ",
7993
+ rows.filter((row) => row.status === "completed").length,
7994
+ "/",
7995
+ rows.length
7996
+ ]
7997
+ }),
7998
+ openConversation && !minimized ? /* @__PURE__ */ jsxs("span", {
7999
+ fg: theme.dim,
8000
+ children: [" ", selectedIndex !== null ? "↑/↓ select · ↵ view · esc back" : "/subagent to browse"]
8001
+ }) : null
8002
+ ] })
8003
+ }), minimized ? null : /* @__PURE__ */ jsxs("box", {
7685
8004
  style: { flexDirection: "column" },
7686
8005
  children: [visible.map((row, i) => {
7687
8006
  const target = row.conversationId;
@@ -7707,8 +8026,12 @@ function ToolSubagent({ items, isActive: _isActive, onHeightChange }) {
7707
8026
  * Delegated work pinned above the plan. Each batch owns its status polling;
7708
8027
  * a settled batch keeps its rows — titles and drill-in — until the next
7709
8028
  * fan-out replaces the group.
8029
+ *
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
8032
+ * composer for the rest of the conversation.
7710
8033
  */
7711
- function SubagentCardView({ items, isActive, onHeightChange }) {
8034
+ function SubagentCardView({ items, isActive, minimized, onToggleMinimized, onHeightChange }) {
7712
8035
  const [height, setHeight] = useState(0);
7713
8036
  useEffect(() => onHeightChange?.(height > 0 ? height + 1 : 0), [height, onHeightChange]);
7714
8037
  return /* @__PURE__ */ jsx("box", {
@@ -7723,6 +8046,8 @@ function SubagentCardView({ items, isActive, onHeightChange }) {
7723
8046
  children: /* @__PURE__ */ jsx(ToolSubagent, {
7724
8047
  items,
7725
8048
  isActive,
8049
+ minimized,
8050
+ onToggleMinimized,
7726
8051
  onHeightChange: setHeight
7727
8052
  })
7728
8053
  });
@@ -8751,12 +9076,20 @@ const keybindGroups = [
8751
9076
  action: "jump back to your last message"
8752
9077
  },
8753
9078
  {
8754
- keys: "click",
9079
+ keys: "click card header",
9080
+ action: "minimize the pinned plan or subagents card, and restore it"
9081
+ },
9082
+ {
9083
+ keys: "click card body",
8755
9084
  action: "expand a folded plan card back to its finished rows"
8756
9085
  },
8757
9086
  {
8758
- keys: "/plan [show|hide|expand|collapse]",
8759
- action: "show / hide / 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"
9089
+ },
9090
+ {
9091
+ keys: "/subagent [minimize]",
9092
+ action: "browse the pinned subagents card, or minimize it"
8760
9093
  },
8761
9094
  {
8762
9095
  keys: "/rename <title>",
@@ -8910,6 +9243,18 @@ const keybindGroups = [
8910
9243
  keys: "↵",
8911
9244
  action: "open conversation"
8912
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
+ },
8913
9258
  {
8914
9259
  keys: "ctrl+a",
8915
9260
  action: "archive conversation (hides it for you only; again to restore)"
@@ -9461,8 +9806,8 @@ const slashCommands = [
9461
9806
  {
9462
9807
  name: "subagent",
9463
9808
  aliases: ["subagents"],
9464
- argsHint: null,
9465
- description: "select a subagent from the fan-out card and open its chat",
9809
+ argsHint: "[minimize]",
9810
+ description: "browse or minimize the fan-out card",
9466
9811
  action: { kind: "subagent" }
9467
9812
  },
9468
9813
  {
@@ -9482,8 +9827,8 @@ const slashCommands = [
9482
9827
  {
9483
9828
  name: "plan",
9484
9829
  aliases: ["todos", "todo"],
9485
- argsHint: "[show|hide|expand|collapse]",
9486
- description: "show, hide, or expand the pinned plan card",
9830
+ argsHint: "[show|hide|minimize|expand|collapse]",
9831
+ description: "show, hide, minimize, or unfold the pinned plan card",
9487
9832
  action: {
9488
9833
  kind: "plan",
9489
9834
  mode: "toggle"
@@ -11794,7 +12139,7 @@ function canPreview(file) {
11794
12139
  if (/\/(json|xml|javascript|typescript|x-sh|yaml)$/.test(file.mediaType)) return true;
11795
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);
11796
12141
  }
11797
- function formatBytes$1(bytes) {
12142
+ function formatBytes(bytes) {
11798
12143
  if (bytes < 1024) return `${bytes} B`;
11799
12144
  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
11800
12145
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
@@ -11862,7 +12207,7 @@ function WorkspaceFilesTab({ agentId, viewportWidth, viewportHeight, active, she
11862
12207
  ]);
11863
12208
  const visibleFiles = useMemo(() => load.kind === "ready" ? filterReviewFiles(load.files, fileSearch ?? "", (file) => file.path) : [], [load, fileSearch]);
11864
12209
  const rows = useMemo(() => buildWorkspaceTree(visibleFiles), [visibleFiles]);
11865
- 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);
11866
12211
  const shellDimensions = reviewShellDimensions({
11867
12212
  width,
11868
12213
  height: shellHeight,
@@ -11901,7 +12246,7 @@ function WorkspaceFilesTab({ agentId, viewportWidth, viewportHeight, active, she
11901
12246
  if (!canPreview(selectedFile)) {
11902
12247
  setPreview({
11903
12248
  kind: "none",
11904
- 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`
11905
12250
  });
11906
12251
  return;
11907
12252
  }
@@ -12140,7 +12485,7 @@ function WorkspaceFilesTab({ agentId, viewportWidth, viewportHeight, active, she
12140
12485
  row.name,
12141
12486
  /* @__PURE__ */ jsxs("span", {
12142
12487
  fg: theme.faint,
12143
- children: [" · ", formatBytes$1(row.file.sizeBytes)]
12488
+ children: [" · ", formatBytes(row.file.sizeBytes)]
12144
12489
  })
12145
12490
  ]
12146
12491
  }, row.file.id);
@@ -13322,9 +13667,13 @@ function routeInput(raw) {
13322
13667
  const arg = match.rest.toLowerCase();
13323
13668
  return {
13324
13669
  kind: "plan",
13325
- mode: arg === "show" ? "show" : arg === "hide" ? "hide" : arg === "expand" ? "expand" : arg === "collapse" ? "collapse" : "toggle"
13670
+ mode: arg === "show" ? "show" : arg === "hide" ? "hide" : arg === "expand" ? "expand" : arg === "collapse" ? "collapse" : arg === "minimize" ? "minimize" : "toggle"
13326
13671
  };
13327
13672
  }
13673
+ case "subagent": return {
13674
+ kind: "subagent",
13675
+ mode: match.rest.toLowerCase() === "minimize" ? "minimize" : "browse"
13676
+ };
13328
13677
  case "new-conversation":
13329
13678
  case "fork":
13330
13679
  case "archive-conversation":
@@ -13332,7 +13681,6 @@ function routeInput(raw) {
13332
13681
  case "effort-picker":
13333
13682
  case "theme-picker":
13334
13683
  case "pr-picker":
13335
- case "subagent":
13336
13684
  case "status":
13337
13685
  case "share":
13338
13686
  case "browser":
@@ -13446,7 +13794,6 @@ const pageScrollFraction = .75;
13446
13794
  const HISTORY_PAGE_SIZE = 60;
13447
13795
  /** Cap composer growth; past this the textarea scrolls its content instead. */
13448
13796
  const maxComposerRows = 8;
13449
- const maxAttachments = 10;
13450
13797
  /**
13451
13798
  * A server timestamp as local epoch ms, or null when it is missing or
13452
13799
  * unparseable so the caller can fall back to its own clock.
@@ -13456,19 +13803,6 @@ function parseStampMs(iso) {
13456
13803
  const ms = Date.parse(iso);
13457
13804
  return Number.isNaN(ms) ? null : ms;
13458
13805
  }
13459
- function formatBytes(bytes) {
13460
- if (bytes < 1024) return `${bytes} B`;
13461
- if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
13462
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
13463
- }
13464
- /** Status glyph for a staged attachment chip: uploading / ready / failed. */
13465
- function attachmentGlyph(a) {
13466
- switch (a.status) {
13467
- case "uploading": return "↑";
13468
- case "ready": return "✓";
13469
- case "error": return `✗ ${a.errorText ?? "upload failed"}`;
13470
- }
13471
- }
13472
13806
  /**
13473
13807
  * Open a URL in the user's local browser. Best-effort: over SSH there may be
13474
13808
  * nothing to open — the card keeps showing the URL as the copyable fallback.
@@ -13524,6 +13858,11 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13524
13858
  const [planHidden, setPlanHidden] = useState(false);
13525
13859
  const [planExpanded, setPlanExpanded] = useState(false);
13526
13860
  const togglePlanExpanded = useCallback(() => setPlanExpanded((current) => !current), []);
13861
+ const [planMinimized, setPlanMinimized] = useState(false);
13862
+ const togglePlanMinimized = useCallback(() => {
13863
+ setPlanMinimized((current) => !current);
13864
+ setPlanExpanded(false);
13865
+ }, []);
13527
13866
  const [subagentRows, setSubagentRows] = useState(0);
13528
13867
  const [anchoredSubagentIds, setAnchoredSubagentIds] = useState([]);
13529
13868
  const [scrolledUp, setScrolledUp] = useState(false);
@@ -13555,6 +13894,11 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13555
13894
  const [modelPickerView, setModelPickerView] = useState(null);
13556
13895
  const modelPickerOpen = modelPickerView !== null;
13557
13896
  const [subagentSelection, setSubagentSelection] = useState(null);
13897
+ const [subagentMinimized, setSubagentMinimized] = useState(false);
13898
+ const toggleSubagentMinimized = useCallback(() => {
13899
+ setSubagentMinimized((current) => !current);
13900
+ setSubagentSelection(null);
13901
+ }, []);
13558
13902
  const [subagentBatches, setSubagentBatches] = useState(() => /* @__PURE__ */ new Map());
13559
13903
  const onSubagentBatch = useCallback((report) => {
13560
13904
  setSubagentBatches((current) => {
@@ -13588,8 +13932,6 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13588
13932
  }, [initialConversationId]);
13589
13933
  const [ctrlCArmed, setCtrlCArmed] = useState(false);
13590
13934
  const [composerRows, setComposerRows] = useState(1);
13591
- const [attachments, setAttachments] = useState([]);
13592
- const uploadsRef = useRef(/* @__PURE__ */ new Map());
13593
13935
  const [forking, setForking] = useState(false);
13594
13936
  const forkingRef = useRef(false);
13595
13937
  const [credPrompt, setCredPrompt] = useState(null);
@@ -13599,6 +13941,20 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13599
13941
  const [askPrompt, setAskPrompt] = useState(null);
13600
13942
  const askPromptOpen = askPrompt !== null;
13601
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
+ });
13602
13958
  const [menuDismissed, setMenuDismissed] = useState(false);
13603
13959
  const [menuHighlight, setMenuHighlight] = useState(0);
13604
13960
  const commandQuery = grantPrompt || credPrompt || computePrompt || askPrompt || restartConfirm ? null : detectCommandTrigger(input);
@@ -13611,8 +13967,6 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13611
13967
  conversationIdRef.current = conversationId;
13612
13968
  const itemsRef = useRef(items);
13613
13969
  itemsRef.current = items;
13614
- const attachmentsRef = useRef(attachments);
13615
- attachmentsRef.current = attachments;
13616
13970
  const inputRef = useRef(input);
13617
13971
  inputRef.current = input;
13618
13972
  const credPromptRef = useRef(credPrompt);
@@ -13699,7 +14053,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13699
14053
  const paneDragRef = useRef(null);
13700
14054
  const composerBoxHeight = composerRows + 2;
13701
14055
  const pendingVisible = !grantPrompt && !credPrompt && !computePrompt && !askPrompt && !nonWebChannel && attachments.length > 0;
13702
- const pendingRows = pendingVisible ? attachments.length + 2 : 0;
14056
+ const pendingRows = pendingVisible ? attachmentTrayRows(attachments.length) : 0;
13703
14057
  const credPromptHeight = 4 + (credPrompt && (credPrompt.error || credPrompt.submitting) ? 1 : 0);
13704
14058
  const nonWebNoticeHeight = 5;
13705
14059
  const computePromptHeight = 4;
@@ -13720,7 +14074,10 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
13720
14074
  const inlineReviewHeight = reviewOpen && reviewPlacement === "below" ? reviewHeight : 0;
13721
14075
  const menuRows = menuOpen ? completionMenuHeight(menuCommands.length) : 0;
13722
14076
  const todoCardVisible = !credPrompt && !planHidden && !!todos && todos.length > 0;
13723
- const todoRows = todoCardVisible ? todoCardHeight(todos, { expanded: planExpanded }) : 0;
14077
+ const todoRows = todoCardVisible ? todoCardHeight(todos, {
14078
+ expanded: planExpanded,
14079
+ minimized: planMinimized
14080
+ }) : 0;
13724
14081
  const subagentItems = useMemo(() => {
13725
14082
  const ids = new Set(anchoredSubagentIds);
13726
14083
  return items.filter((item) => item.kind === "tool" && item.toolName === "subagent" && ids.has(item.id));
@@ -14034,28 +14391,13 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14034
14391
  startedAtMs: Date.now()
14035
14392
  });
14036
14393
  try {
14037
- const attachmentIds = [];
14038
- const restorable = [];
14039
- const settled = await Promise.allSettled(staged.map((a) => uploadsRef.current.get(a.tempId)));
14040
- for (const [i, result] of settled.entries()) {
14041
- const row = staged[i];
14042
- if (!row) continue;
14043
- if (result.status === "fulfilled" && result.value) {
14044
- attachmentIds.push(result.value.id);
14045
- restorable.push({
14046
- ...row,
14047
- status: "ready",
14048
- errorText: null
14049
- });
14050
- } else {
14051
- uploadsRef.current.delete(row.tempId);
14052
- setItems((prev) => [...prev, {
14053
- kind: "error",
14054
- id: crypto.randomUUID(),
14055
- text: `attachment failed: ${row.fileName}${result.status === "rejected" ? ` (${errorDetail(result.reason)})` : ""}`
14056
- }]);
14057
- }
14058
- }
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
+ }]);
14059
14401
  if (!trimmed && attachmentIds.length === 0) {
14060
14402
  setItems((prev) => prev.filter((m) => m.id !== optimisticId));
14061
14403
  if (!wasStreaming) setRun({ kind: "idle" });
@@ -14068,7 +14410,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14068
14410
  attachmentIds,
14069
14411
  clientSurface: "tui"
14070
14412
  });
14071
- for (const row of restorable) uploadsRef.current.delete(row.tempId);
14413
+ releaseUploaded(rows);
14072
14414
  if (result.isNewConversation) setConversationId(result.conversationId);
14073
14415
  if (result.steered) {
14074
14416
  const directiveId = result.directive?.id;
@@ -14098,27 +14440,26 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14098
14440
  text: sendErrorMessage(err)
14099
14441
  }]);
14100
14442
  if (echo) setInput((existing) => existing ? existing : trimmed);
14101
- const restore = staged.filter((a) => uploadsRef.current.has(a.tempId));
14102
- if (restore.length > 0) setAttachments((prev) => [...restore.map((a) => ({
14103
- ...a,
14104
- status: "ready",
14105
- errorText: null
14106
- })), ...prev]);
14443
+ restoreStaged(staged);
14107
14444
  if (!wasStreaming) setRun({ kind: "idle" });
14108
14445
  }
14109
14446
  }, [
14110
14447
  rest,
14111
14448
  agent.id,
14112
14449
  conversationId,
14113
- attachToRun
14450
+ attachToRun,
14451
+ collectForSend,
14452
+ releaseUploaded,
14453
+ restoreStaged
14114
14454
  ]);
14115
14455
  const portalStatus = portal.status;
14116
14456
  useEffect(() => {
14117
- const { seedPrompt, autoGrantMachine } = useStore.getState();
14118
- if (!seedPrompt || !isNewConversation(conversation)) return;
14457
+ const { seedPrompt, seedAttachments, autoGrantMachine } = useStore.getState();
14458
+ if (!seedPrompt && seedAttachments.length === 0 || !isNewConversation(conversation)) return;
14119
14459
  if (autoGrantMachine && (portalStatus === "off" || portalStatus === "connecting")) return;
14120
14460
  useStore.setState({
14121
14461
  seedPrompt: null,
14462
+ seedAttachments: [],
14122
14463
  autoGrantMachine: false
14123
14464
  });
14124
14465
  (async () => {
@@ -14136,7 +14477,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14136
14477
  variant: "warning"
14137
14478
  });
14138
14479
  }
14139
- await sendContent(seedPrompt, []);
14480
+ await sendContent(seedPrompt ?? "", adoptUploaded(seedAttachments));
14140
14481
  })();
14141
14482
  }, [
14142
14483
  conversation,
@@ -14144,7 +14485,8 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14144
14485
  portalClient,
14145
14486
  agent.id,
14146
14487
  agent.name,
14147
- sendContent
14488
+ sendContent,
14489
+ adoptUploaded
14148
14490
  ]);
14149
14491
  const cancelLatestSteer = useCallback(() => {
14150
14492
  if (!rest) return false;
@@ -14551,95 +14893,6 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14551
14893
  if (detectCommandTrigger(composer.plainText) === null) setMenuDismissed(false);
14552
14894
  setComposerRows(Math.min(Math.max(composer.editorView.getTotalVirtualLineCount(), 1), maxComposerRows));
14553
14895
  }, []);
14554
- /**
14555
- * Stage a pasted/dropped file in the composer tray and start its upload
14556
- * immediately (the web composer's behavior). Submit awaits the in-flight
14557
- * uploads and carries the resulting attachment ids; the composer never
14558
- * blocks while an upload runs.
14559
- */
14560
- const stageAttachment = useCallback(({ fileName, mediaType, data }) => {
14561
- if (!rest) return;
14562
- if (attachmentsRef.current.length >= maxAttachments) {
14563
- setItems((prev) => [...prev, {
14564
- kind: "error",
14565
- id: crypto.randomUUID(),
14566
- text: `attachment limit reached (${maxAttachments}); ${fileName} skipped`
14567
- }]);
14568
- return;
14569
- }
14570
- const tempId = crypto.randomUUID();
14571
- setAttachments((prev) => [...prev, {
14572
- tempId,
14573
- fileName,
14574
- sizeBytes: data.byteLength,
14575
- status: "uploading",
14576
- errorText: null
14577
- }]);
14578
- const promise = rest.uploadAttachment({
14579
- agentId: agent.id,
14580
- fileName,
14581
- mediaType,
14582
- data
14583
- });
14584
- uploadsRef.current.set(tempId, promise);
14585
- promise.then(() => {
14586
- setAttachments((prev) => prev.map((a) => a.tempId === tempId ? {
14587
- ...a,
14588
- status: "ready"
14589
- } : a));
14590
- }).catch((err) => {
14591
- setAttachments((prev) => prev.map((a) => a.tempId === tempId ? {
14592
- ...a,
14593
- status: "error",
14594
- errorText: errorMessage(err)
14595
- } : a));
14596
- });
14597
- }, [rest, agent.id]);
14598
- const stageFiles = useCallback((files) => {
14599
- for (const file of files) stageAttachment(file);
14600
- }, [stageAttachment]);
14601
- const pasteImage = useCallback(async () => {
14602
- const image = await readClipboardImage();
14603
- if (!image) return;
14604
- stageAttachment(image);
14605
- }, [stageAttachment]);
14606
- usePaste((event) => {
14607
- if (cardPickerOpen || modelPickerOpen || themePickerOpen || prPickerOpen || helpOpen || creditsOpen || bgTasksOpen || credPromptOpen || grantPrompt || computePromptOpen || askPromptOpen || restartConfirm || nonWebChannel) return;
14608
- const text = event.metadata?.kind === "binary" ? "" : decodePasteBytes(event.bytes);
14609
- const route = routePaste({
14610
- kind: event.metadata?.kind,
14611
- mimeType: event.metadata?.mimeType,
14612
- text
14613
- });
14614
- switch (route.kind) {
14615
- case "binary-image": {
14616
- event.preventDefault();
14617
- const ext = route.mediaType.split("/")[1]?.split("+")[0] ?? "png";
14618
- stageFiles([{
14619
- fileName: `pasted-image-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.${ext}`,
14620
- mediaType: route.mediaType,
14621
- data: event.bytes
14622
- }]);
14623
- return;
14624
- }
14625
- case "clipboard-image":
14626
- event.preventDefault();
14627
- pasteImage();
14628
- return;
14629
- case "dropped-paths":
14630
- event.preventDefault();
14631
- resolveDroppedFiles(route.paths).then((files) => {
14632
- if (files) {
14633
- stageFiles(files);
14634
- return;
14635
- }
14636
- composerRef.current?.insertText(route.text);
14637
- handleComposerChange();
14638
- });
14639
- return;
14640
- case "text": return;
14641
- }
14642
- });
14643
14896
  const applyComposerText = useCallback((text, caret) => {
14644
14897
  const composer = composerRef.current;
14645
14898
  if (!composer) return;
@@ -14759,7 +15012,13 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14759
15012
  const screenAtFork = useStore.getState().screen;
14760
15013
  const stillHere = () => useStore.getState().screen === screenAtFork;
14761
15014
  rest.forkConversation({ conversationId }).then((forked) => {
14762
- if (!stillHere()) return;
15015
+ if (!stillHere()) {
15016
+ forkingRef.current = false;
15017
+ setForking(false);
15018
+ return;
15019
+ }
15020
+ const current = runRef.current;
15021
+ if (current.kind === "streaming") current.abort.abort();
14763
15022
  goTo({
14764
15023
  kind: "chat",
14765
15024
  agent,
@@ -14772,7 +15031,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
14772
15031
  });
14773
15032
  useStore.getState().showToast({
14774
15033
  variant: "success",
14775
- message: "Forked conversation"
15034
+ message: current.kind === "idle" ? "Forked conversation" : `Forked conversation — ${agent.name} keeps working on the original; reopen to reattach`
14776
15035
  });
14777
15036
  }).catch((err) => {
14778
15037
  forkingRef.current = false;
@@ -15107,10 +15366,6 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15107
15366
  });
15108
15367
  break;
15109
15368
  case "fork":
15110
- if (runRef.current.kind !== "idle") {
15111
- useStore.getState().showToast({ message: "finish or cancel the run first (ctrl+c)" });
15112
- break;
15113
- }
15114
15369
  if (!conversationId) {
15115
15370
  useStore.getState().showToast({ message: "nothing to fork yet; send a message first" });
15116
15371
  break;
@@ -15148,9 +15403,20 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15148
15403
  else setPrPickerOpen(true);
15149
15404
  break;
15150
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
+ }
15151
15413
  const batch = anchoredSubagentBatchRef.current;
15152
- if (!batch || batch.rows.length === 0) useStore.getState().showToast({ message: "no subagents in this conversation yet" });
15153
- else setSubagentSelection({
15414
+ if (!batch || batch.rows.length === 0) {
15415
+ useStore.getState().showToast({ message: "no subagents in this conversation yet" });
15416
+ break;
15417
+ }
15418
+ setSubagentMinimized(false);
15419
+ setSubagentSelection({
15154
15420
  toolCallId: batch.toolCallId,
15155
15421
  index: 0
15156
15422
  });
@@ -15186,15 +15452,25 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15186
15452
  useStore.getState().showToast({ message: "no plan to show yet" });
15187
15453
  break;
15188
15454
  }
15455
+ if (routed.mode === "minimize") {
15456
+ const next = !planMinimized;
15457
+ setPlanMinimized(next);
15458
+ setPlanHidden(false);
15459
+ setPlanExpanded(false);
15460
+ useStore.getState().showToast({ message: next ? "plan minimized (/plan minimize again to restore)" : "plan restored" });
15461
+ break;
15462
+ }
15189
15463
  if (routed.mode === "expand" || routed.mode === "collapse") {
15190
15464
  const nextExpanded = routed.mode === "expand";
15191
15465
  setPlanExpanded(nextExpanded);
15192
- if (nextExpanded) setPlanHidden(false);
15466
+ setPlanHidden(false);
15467
+ setPlanMinimized(false);
15193
15468
  useStore.getState().showToast({ message: nextExpanded ? "plan expanded (/plan collapse)" : "plan collapsed" });
15194
15469
  break;
15195
15470
  }
15196
15471
  const nextHidden = routed.mode === "toggle" ? !planHidden : routed.mode === "hide";
15197
15472
  setPlanHidden(nextHidden);
15473
+ if (!nextHidden) setPlanMinimized(false);
15198
15474
  useStore.getState().showToast({ message: nextHidden ? "plan hidden (/plan show)" : "plan shown" });
15199
15475
  break;
15200
15476
  }
@@ -15262,7 +15538,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15262
15538
  setInput("");
15263
15539
  composerRef.current?.clear();
15264
15540
  setComposerRows(1);
15265
- setAttachments([]);
15541
+ clearAttachments();
15266
15542
  sendContent(content, staged);
15267
15543
  }, [
15268
15544
  history,
@@ -15282,6 +15558,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15282
15558
  compactConversation,
15283
15559
  quit,
15284
15560
  sendContent,
15561
+ clearAttachments,
15285
15562
  goTo,
15286
15563
  agent,
15287
15564
  appUrl,
@@ -15293,6 +15570,8 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15293
15570
  promptForCards,
15294
15571
  todos,
15295
15572
  planHidden,
15573
+ planMinimized,
15574
+ subagentMinimized,
15296
15575
  prLinks.length
15297
15576
  ]);
15298
15577
  const submit = useCallback(() => {
@@ -15535,16 +15814,12 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15535
15814
  }
15536
15815
  if (key.name === "v" && key.ctrl) {
15537
15816
  if (nonWebChannel) return;
15538
- pasteImage();
15817
+ stageClipboardImage();
15539
15818
  return;
15540
15819
  }
15541
15820
  if (key.name === "x" && key.ctrl || key.name === "backspace" && attachmentsRef.current.length > 0 && !composerRef.current?.plainText) {
15542
15821
  if (nonWebChannel) return;
15543
- const last = attachmentsRef.current.at(-1);
15544
- if (last) {
15545
- uploadsRef.current.delete(last.tempId);
15546
- setAttachments((prev) => prev.filter((a) => a.tempId !== last.tempId));
15547
- }
15822
+ dropLastAttachment();
15548
15823
  return;
15549
15824
  }
15550
15825
  if (key.name === "l" && key.ctrl) openInBrowser();
@@ -15939,12 +16214,16 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15939
16214
  /* @__PURE__ */ jsx(SubagentCardView, {
15940
16215
  items: subagentItems,
15941
16216
  isActive: run.kind !== "idle",
16217
+ minimized: subagentMinimized,
16218
+ onToggleMinimized: toggleSubagentMinimized,
15942
16219
  onHeightChange: setSubagentRows
15943
16220
  }),
15944
16221
  todoCardVisible && todos ? /* @__PURE__ */ jsx(TodoCardView, {
15945
16222
  todos,
15946
16223
  isActive: run.kind !== "idle",
16224
+ minimized: planMinimized,
15947
16225
  expanded: planExpanded,
16226
+ onToggleMinimized: togglePlanMinimized,
15948
16227
  onToggleExpanded: togglePlanExpanded
15949
16228
  }) : null,
15950
16229
  compacting ? /* @__PURE__ */ jsxs("box", {
@@ -15964,29 +16243,7 @@ function ChatScreen({ agent, conversation, attachRunId, parentConversationId })
15964
16243
  children: [" ", "you can keep typing"]
15965
16244
  })]
15966
16245
  }) : null,
15967
- pendingVisible ? /* @__PURE__ */ jsxs("box", {
15968
- style: {
15969
- paddingLeft: 1,
15970
- paddingRight: 1,
15971
- flexShrink: 0,
15972
- marginTop: 1,
15973
- flexDirection: "column"
15974
- },
15975
- children: [attachments.map((a) => /* @__PURE__ */ jsxs("text", {
15976
- fg: a.status === "error" ? theme.error : theme.muted,
15977
- children: [
15978
- "📎 ",
15979
- a.fileName,
15980
- " (",
15981
- formatBytes(a.sizeBytes),
15982
- ") ",
15983
- attachmentGlyph(a)
15984
- ]
15985
- }, a.tempId)), /* @__PURE__ */ jsx("text", {
15986
- fg: theme.dim,
15987
- children: "ctrl+x remove last"
15988
- })]
15989
- }) : null,
16246
+ pendingVisible ? /* @__PURE__ */ jsx(AttachmentTray, { attachments }) : null,
15990
16247
  menuOpen ? /* @__PURE__ */ jsx(CompletionMenu, {
15991
16248
  items: menuCommands.map((c) => ({
15992
16249
  id: c.name,