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.
- package/CHANGELOG.md +2 -1
- package/dist/js/bin.mjs +23 -21
- package/dist/js/{boot-BWiSsPQJ.mjs → boot-BDibPsNk.mjs} +691 -511
- package/dist/js/{client-HWCoL5Jm.mjs → client-HGcuhUqy.mjs} +1 -1
- package/dist/js/client-YfdR02i6.mjs +5 -0
- package/dist/js/{daemon-Dl3nN-w4.mjs → daemon-C7mYwnPA.mjs} +1 -1
- package/dist/js/{daemon-L_e8QFNp.mjs → daemon-DjqqqNuD.mjs} +2 -2
- package/dist/js/{daemon-client-BUakFQGS.mjs → daemon-client-CNiHI_BQ.mjs} +1 -1
- package/dist/js/daemon-client-DQBDJqVk.mjs +8 -0
- package/dist/js/{install-Bs9OISd4.mjs → install-YcAgiY79.mjs} +2 -2
- package/dist/js/{print-BC2o90gX.mjs → print-DPOx1Zm4.mjs} +1 -1
- package/dist/js/{print-iB47KC-f.mjs → print-gJrTd9ZR.mjs} +2 -2
- package/dist/js/{print-share-CsIKrGto.mjs → print-share-BT28SPdO.mjs} +1 -1
- package/dist/js/{rest-DMNGNHgm.mjs → rest-BzeGQ7x_.mjs} +1 -1
- package/dist/js/{rest-DQFiN7VL.mjs → rest-CiwSFzD8.mjs} +1 -1
- package/package.json +5 -5
- package/dist/js/client-BHXI6wfY.mjs +0 -5
- package/dist/js/daemon-client-BTkEdnyF.mjs +0 -8
|
@@ -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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
10
|
+
import "./daemon-C7mYwnPA.mjs";
|
|
11
11
|
import { t as SandboxStream } from "./client-l6orYfqq.mjs";
|
|
12
|
-
import { t as PortalDaemonClient } from "./daemon-client-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
4099
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
4108
|
-
*
|
|
4109
|
-
*
|
|
4110
|
-
*
|
|
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
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
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$
|
|
6272
|
+
return `${ELISION_MARKER_TAG} ${formatBytes$1(bytes)} to save memory ⋯`;
|
|
5998
6273
|
}
|
|
5999
|
-
function formatBytes$
|
|
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
|
-
* `
|
|
7064
|
-
* and the chevron that brings the rows back) plus the top margin, and
|
|
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,
|
|
7350
|
+
function todoCardHeight(todos, { expanded, minimized }) {
|
|
7068
7351
|
if (todos.length === 0) return 0;
|
|
7069
|
-
if (
|
|
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
|
-
* (`
|
|
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,
|
|
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:
|
|
7421
|
+
onMouseDown: onToggleMinimized,
|
|
7135
7422
|
children: [
|
|
7136
7423
|
/* @__PURE__ */ jsx("text", {
|
|
7137
7424
|
fg: theme.dim,
|
|
7138
|
-
children:
|
|
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
|
-
}),
|
|
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
|
-
* `
|
|
7214
|
-
* out of the way (click the header, or `/plan
|
|
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,
|
|
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
|
-
|
|
7522
|
+
minimized,
|
|
7232
7523
|
expanded,
|
|
7233
|
-
|
|
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
|
-
* `
|
|
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,
|
|
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
|
|
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 :
|
|
7918
|
+
onHeightChange?.(rows.length === 0 ? items.length > 0 ? 1 : 0 : minimized ? 1 : 1 + visibleRows + (rows.length > MAX_VISIBLE_ROWS ? 1 : 0));
|
|
7627
7919
|
}, [
|
|
7628
|
-
|
|
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:
|
|
7983
|
+
onMouseDown: onToggleMinimized,
|
|
7690
7984
|
children: /* @__PURE__ */ jsxs("text", { children: [
|
|
7691
7985
|
/* @__PURE__ */ jsx("span", {
|
|
7692
7986
|
fg: theme.dim,
|
|
7693
|
-
children:
|
|
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 && !
|
|
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
|
-
}),
|
|
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
|
-
* `
|
|
7748
|
-
* `/subagent
|
|
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,
|
|
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
|
-
|
|
7767
|
-
|
|
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
|
|
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|
|
|
8805
|
-
action: "show / hide / minimize
|
|
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 [
|
|
8809
|
-
action: "browse the pinned subagents card, or minimize
|
|
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: "[
|
|
9515
|
-
description: "browse
|
|
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|
|
|
9539
|
-
description: "show, hide, minimize or
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
13519
|
-
|
|
13520
|
-
|
|
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 [
|
|
13587
|
-
const
|
|
13588
|
-
|
|
13589
|
-
|
|
13590
|
-
|
|
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 [
|
|
13625
|
-
const
|
|
13626
|
-
|
|
13627
|
-
|
|
13628
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
14114
|
-
const
|
|
14115
|
-
const
|
|
14116
|
-
|
|
14117
|
-
|
|
14118
|
-
|
|
14119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 === "
|
|
15283
|
-
|
|
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
|
|
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
|
-
|
|
15290
|
-
setPlanExpanded(
|
|
15490
|
+
if (routed.mode === "expand" || routed.mode === "collapse") {
|
|
15491
|
+
const nextExpanded = routed.mode === "expand";
|
|
15492
|
+
setPlanExpanded(nextExpanded);
|
|
15291
15493
|
setPlanHidden(false);
|
|
15292
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16043
|
-
|
|
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
|
-
|
|
16251
|
+
minimized: planMinimized,
|
|
16050
16252
|
expanded: planExpanded,
|
|
16051
|
-
|
|
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__ */
|
|
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,
|