pilotswarm 0.5.16 → 0.5.18

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.
@@ -765,9 +765,20 @@ function stripPromptAttachmentTokens(prompt, attachments = []) {
765
765
  .trim();
766
766
  }
767
767
 
768
+ // Raster types + caps for image prompt attachments. Mirrors the SDK's
769
+ // IMAGE_ATTACHMENT_CONTENT_TYPES / ATTACHMENT_MAX_BYTES — the server is the
770
+ // enforcement point; these exist so the composer rejects early with a clear
771
+ // message instead of a failed send.
772
+ export const IMAGE_PROMPT_ATTACHMENT_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
773
+ export const IMAGE_PROMPT_ATTACHMENT_MAX_BYTES = 4 * 1024 * 1024;
774
+ export const IMAGE_PROMPT_ATTACHMENT_MAX_COUNT = 4;
775
+
768
776
  function expandPromptAttachments(prompt, attachments = []) {
769
777
  const validAttachments = Array.isArray(attachments)
770
- ? attachments.filter((attachment) => attachment?.sessionId && attachment?.filename)
778
+ // Image attachments are model-visible (sent as blob attachments by the
779
+ // worker) — they inject NO text ref. Only legacy artifact references
780
+ // still expand into artifact:// pointers.
781
+ ? attachments.filter((attachment) => attachment?.sessionId && attachment?.filename && attachment?.kind !== "image")
771
782
  : [];
772
783
  if (validAttachments.length === 0) return String(prompt || "");
773
784
 
@@ -1324,12 +1335,157 @@ export class PilotSwarmUiController {
1324
1335
  return await this.cancelOutboxItem(sessionId, target.id);
1325
1336
  }
1326
1337
 
1327
- queuePromptInOutbox(sessionId, prompt) {
1338
+ queuePromptInOutbox(sessionId, prompt, extras = null) {
1328
1339
  const item = this.buildOutboxItem(prompt, "pending");
1340
+ if (Array.isArray(extras?.attachments) && extras.attachments.length > 0) {
1341
+ item.attachments = extras.attachments;
1342
+ }
1329
1343
  this.setSessionOutboxItems(sessionId, [...this.getSessionOutbox(sessionId), item]);
1330
1344
  return item;
1331
1345
  }
1332
1346
 
1347
+ /**
1348
+ * Stage image files (paste / drop / picker) as pending prompt attachments.
1349
+ * Client-side pre-validation only — the API edge re-validates on send.
1350
+ * Returns { accepted, rejected: [{name, reason}] } for UI feedback.
1351
+ */
1352
+ addPendingImageFiles(files) {
1353
+ const incoming = Array.from(files || []).filter(Boolean);
1354
+ const current = this.getPromptAttachments();
1355
+ const existingImages = current.filter((a) => a?.kind === "image");
1356
+ const accepted = [];
1357
+ const rejected = [];
1358
+ for (const file of incoming) {
1359
+ const contentType = String(file.type || "").toLowerCase();
1360
+ if (!IMAGE_PROMPT_ATTACHMENT_TYPES.has(contentType)) {
1361
+ rejected.push({ name: file.name || "image", reason: "unsupported type" });
1362
+ continue;
1363
+ }
1364
+ if ((Number(file.size) || 0) > IMAGE_PROMPT_ATTACHMENT_MAX_BYTES) {
1365
+ rejected.push({ name: file.name || "image", reason: "over 4 MB" });
1366
+ continue;
1367
+ }
1368
+ if (existingImages.length + accepted.length >= IMAGE_PROMPT_ATTACHMENT_MAX_COUNT) {
1369
+ rejected.push({ name: file.name || "image", reason: `max ${IMAGE_PROMPT_ATTACHMENT_MAX_COUNT} images` });
1370
+ continue;
1371
+ }
1372
+ accepted.push({
1373
+ kind: "image",
1374
+ file,
1375
+ filename: String(file.name || "image"),
1376
+ contentType,
1377
+ sizeBytes: Number(file.size) || 0,
1378
+ });
1379
+ }
1380
+ if (accepted.length > 0) {
1381
+ this.setPromptAttachments([...current, ...accepted]);
1382
+ }
1383
+ if (rejected.length > 0) {
1384
+ this.dispatch({
1385
+ type: "ui/status",
1386
+ text: `Skipped ${rejected.map((r) => `${r.name} (${r.reason})`).join(", ")}`,
1387
+ });
1388
+ }
1389
+ return { accepted: accepted.length, rejected };
1390
+ }
1391
+
1392
+ /**
1393
+ * Stage an already-uploaded raster artifact as a prompt image attachment
1394
+ * (uploaded:true entries skip re-upload at send). Returns whether staged.
1395
+ * Shared by the TUI upload modal and clipboard paste.
1396
+ */
1397
+ stageUploadedImageAttachment(result, sessionId) {
1398
+ const contentType = String(result?.contentType || "").toLowerCase();
1399
+ if (!IMAGE_PROMPT_ATTACHMENT_TYPES.has(contentType)) return false;
1400
+ if ((Number(result?.sizeBytes) || 0) > IMAGE_PROMPT_ATTACHMENT_MAX_BYTES) return false;
1401
+ const stagedImages = this.getPromptAttachments().filter((a) => a?.kind === "image");
1402
+ if (stagedImages.length >= IMAGE_PROMPT_ATTACHMENT_MAX_COUNT) return false;
1403
+ this.setPromptAttachments([
1404
+ ...this.getPromptAttachments(),
1405
+ {
1406
+ kind: "image",
1407
+ uploaded: true,
1408
+ sessionId,
1409
+ filename: result.filename,
1410
+ contentType,
1411
+ sizeBytes: Number(result?.sizeBytes) || 0,
1412
+ },
1413
+ ]);
1414
+ return true;
1415
+ }
1416
+
1417
+ /**
1418
+ * TUI Ctrl+V: read an image off the OS clipboard (the transport shells to
1419
+ * the platform clipboard tool — terminals never deliver image bytes),
1420
+ * upload it as a session artifact, and stage it on the next message.
1421
+ */
1422
+ async pasteImageFromClipboard() {
1423
+ if (typeof this.transport.captureClipboardImage !== "function"
1424
+ || typeof this.transport.uploadArtifactFromPath !== "function") {
1425
+ this.dispatch({ type: "ui/status", text: "Clipboard image paste is not supported by this transport" });
1426
+ return false;
1427
+ }
1428
+ let capture = null;
1429
+ try {
1430
+ capture = await this.transport.captureClipboardImage();
1431
+ } catch (error) {
1432
+ this.dispatch({ type: "ui/status", text: `Clipboard read failed: ${error?.message || error}` });
1433
+ return false;
1434
+ }
1435
+ if (!capture?.path) {
1436
+ this.dispatch({ type: "ui/status", text: "No image on the clipboard" });
1437
+ return false;
1438
+ }
1439
+ this.dispatch({ type: "ui/status", text: "Attaching clipboard image..." });
1440
+ try {
1441
+ const sessionId = this.getPromptDraftSessionId() || await this.ensurePromptAttachmentSessionId();
1442
+ const upload = await this.transport.uploadArtifactFromPath(sessionId, capture.path);
1443
+ const result = await this.finalizeArtifactUpload(upload, { sessionId, suppressStatus: true });
1444
+ if (this.stageUploadedImageAttachment(result, sessionId)) {
1445
+ this.dispatch({ type: "ui/status", text: `Clipboard image attached to your next message (${result.filename})` });
1446
+ return true;
1447
+ }
1448
+ this.dispatch({ type: "ui/status", text: `Uploaded ${result.filename} (not attachable: type/size/count limit)` });
1449
+ return false;
1450
+ } catch (error) {
1451
+ this.dispatch({ type: "ui/status", text: `Clipboard image attach failed: ${error?.message || error}` });
1452
+ return false;
1453
+ }
1454
+ }
1455
+
1456
+ removePendingImageAttachment(index) {
1457
+ const current = this.getPromptAttachments();
1458
+ const images = current.filter((a) => a?.kind === "image");
1459
+ const target = images[index];
1460
+ if (!target) return;
1461
+ this.setPromptAttachments(current.filter((a) => a !== target));
1462
+ }
1463
+
1464
+ /**
1465
+ * Upload staged image attachments as session artifacts and return send
1466
+ * refs. Filenames are derived from the outbox client-message id so a
1467
+ * retry overwrites the same artifact instead of duplicating it.
1468
+ * Entries staged from an already-uploaded artifact (TUI attach-by-path,
1469
+ * marked uploaded:true with no File) skip the upload and ref directly.
1470
+ */
1471
+ async uploadPendingImageAttachments(sessionId, images, clientMessageId) {
1472
+ const refs = [];
1473
+ const idStem = String(clientMessageId || Date.now()).replace(/[^a-zA-Z0-9_-]/g, "").slice(-24) || "img";
1474
+ let uploadIndex = 0;
1475
+ for (const image of images) {
1476
+ if (!image.file) {
1477
+ if (image.uploaded && image.filename) refs.push({ filename: image.filename });
1478
+ continue;
1479
+ }
1480
+ uploadIndex += 1;
1481
+ const ext = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" }[image.contentType] || "png";
1482
+ const filename = `attach-${idStem}-${uploadIndex}.${ext}`;
1483
+ await this.transport.uploadArtifactFromFile(sessionId, image.file, filename);
1484
+ refs.push({ filename });
1485
+ }
1486
+ return refs;
1487
+ }
1488
+
1333
1489
  acknowledgeOutboxPrompt(sessionId, promptText, clientMessageId = null) {
1334
1490
  if (!sessionId) return false;
1335
1491
  const items = this.getSessionOutbox(sessionId);
@@ -1446,6 +1602,9 @@ export class PilotSwarmUiController {
1446
1602
  : [item.id]
1447
1603
  ));
1448
1604
  const mergedText = pendingItems.map((item) => String(item.text || "")).join("\n\n");
1605
+ const mergedAttachments = pendingItems.flatMap((item) => (
1606
+ Array.isArray(item.attachments) ? item.attachments : []
1607
+ ));
1449
1608
  const mergedItem = {
1450
1609
  id: pendingItems[0].id,
1451
1610
  text: mergedText,
@@ -1453,6 +1612,7 @@ export class PilotSwarmUiController {
1453
1612
  phase: "pending",
1454
1613
  attempted: true,
1455
1614
  clientMessageIds: mergedClientMessageIds,
1615
+ ...(mergedAttachments.length > 0 ? { attachments: mergedAttachments } : {}),
1456
1616
  };
1457
1617
  const pendingIdSet = new Set(pendingItems.map((item) => item.id));
1458
1618
  const otherItems = this.getSessionOutbox(sessionId).filter((item) => !pendingIdSet.has(item.id));
@@ -1469,6 +1629,9 @@ export class PilotSwarmUiController {
1469
1629
  await this.transport.sendMessage(sessionId, mergedItem.text, {
1470
1630
  enqueueOnly: true,
1471
1631
  clientMessageIds: mergedItem.clientMessageIds,
1632
+ ...(Array.isArray(mergedItem.attachments) && mergedItem.attachments.length > 0
1633
+ ? { attachments: mergedItem.attachments }
1634
+ : {}),
1472
1635
  });
1473
1636
 
1474
1637
  // Promote pending → queued for the merged item.
@@ -4294,6 +4457,16 @@ export class PilotSwarmUiController {
4294
4457
  const upload = await this.transport.uploadArtifactFromPath(sessionId, filePath);
4295
4458
  const result = await this.finalizeArtifactUpload(upload, { sessionId, suppressStatus: true });
4296
4459
  this.dispatch({ type: "ui/modal", modal: null });
4460
+ // TUI attach-by-path: an uploaded raster image is also staged as a
4461
+ // prompt attachment so the next message shows it to the model —
4462
+ // same send path as the portal's paste/drop chips.
4463
+ if (this.stageUploadedImageAttachment(result, sessionId)) {
4464
+ this.dispatch({
4465
+ type: "ui/status",
4466
+ text: `Uploaded ${result.filename} — attached to your next message`,
4467
+ });
4468
+ return;
4469
+ }
4297
4470
  this.dispatch({
4298
4471
  type: "ui/status",
4299
4472
  text: `Uploaded ${result.filename}`,
@@ -5208,7 +5381,10 @@ export class PilotSwarmUiController {
5208
5381
 
5209
5382
  // Empty Enter on a session with pending outbox items forces an immediate
5210
5383
  // dispatch of any pending merge group; this is the "send batch" affordance.
5211
- if (!prompt.trim()) {
5384
+ // Staged image attachments make an empty prompt sendable (a default
5385
+ // caption is filled in below), so they bypass this early return.
5386
+ const hasStagedImages = promptAttachments.some((a) => a?.kind === "image");
5387
+ if (!prompt.trim() && !hasStagedImages) {
5212
5388
  if (sessionId && this.getPendingOutboxItems(sessionId).length > 0) {
5213
5389
  await this.dispatchPendingOutbox(sessionId).catch(() => {});
5214
5390
  }
@@ -5296,7 +5472,32 @@ export class PilotSwarmUiController {
5296
5472
  // `controller.sendPrompt()` calls without intervening `await`s) still
5297
5473
  // merge because the second call enters before the first dispatcher
5298
5474
  // microtask runs.
5299
- this.queuePromptInOutbox(sessionId, prompt);
5475
+ //
5476
+ // Image attachments upload FIRST (artifact = source of truth), then the
5477
+ // message references them by filename. A failed upload keeps the prompt
5478
+ // and the staged images — a message never goes out half-attached.
5479
+ const pendingImages = promptAttachments.filter((a) => a?.kind === "image");
5480
+ let attachmentRefs = null;
5481
+ if (pendingImages.length > 0) {
5482
+ const needsUpload = pendingImages.some((a) => a.file);
5483
+ if (needsUpload && typeof this.transport.uploadArtifactFromFile !== "function") {
5484
+ this.dispatch({ type: "ui/status", text: "This transport does not support image attachments." });
5485
+ return;
5486
+ }
5487
+ const uploadItem = this.buildOutboxItem("", "pending");
5488
+ this.dispatch({ type: "ui/status", text: `Uploading ${pendingImages.length} image${pendingImages.length > 1 ? "s" : ""}...` });
5489
+ try {
5490
+ attachmentRefs = await this.uploadPendingImageAttachments(sessionId, pendingImages, uploadItem.id);
5491
+ this.dispatch({ type: "ui/status", text: "" });
5492
+ } catch (error) {
5493
+ this.dispatch({ type: "ui/status", text: `Image upload failed: ${error?.message || error}` });
5494
+ return;
5495
+ }
5496
+ }
5497
+ const outboundPrompt = prompt.trim()
5498
+ ? prompt
5499
+ : (attachmentRefs && attachmentRefs.length > 0 ? "See the attached image(s)." : prompt);
5500
+ this.queuePromptInOutbox(sessionId, outboundPrompt, attachmentRefs ? { attachments: attachmentRefs } : null);
5300
5501
  this.setPrompt("", 0);
5301
5502
  this.setPromptAttachments([]);
5302
5503
  await this.dispatchPendingOutbox(sessionId).catch(() => {});
@@ -567,8 +567,11 @@ function splitMarkdownTableCells(line) {
567
567
  return cells;
568
568
  }
569
569
 
570
+ // GFM delimiter cells are `:?-+:?` — one dash is enough, so `--:` and `-` are
571
+ // as valid as `---`. Requiring three dashes silently demoted whole tables to
572
+ // raw pipe text whenever a model emitted the shorter alignment form.
570
573
  function isMarkdownTableSeparatorRow(cells) {
571
- return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(String(cell || "").replace(/\s+/g, "")));
574
+ return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(String(cell || "").replace(/\s+/g, "")));
572
575
  }
573
576
 
574
577
  function displayWidth(value) {
@@ -380,6 +380,11 @@ function buildChatMessage(event, role) {
380
380
  const sender = event?.data?.sender && typeof event.data.sender === "object"
381
381
  ? event.data.sender
382
382
  : null;
383
+ // Image attachment refs recorded by the runTurn activity — rendered as
384
+ // chips under the user message (bytes stay in the artifact store).
385
+ const attachments = Array.isArray(event?.data?.attachments)
386
+ ? event.data.attachments.filter((a) => a && typeof a.filename === "string" && a.filename)
387
+ : [];
383
388
  return {
384
389
  id: `${event.sessionId}:${event.seq}`,
385
390
  role: deriveChatRole(event, role, text),
@@ -388,6 +393,7 @@ function buildChatMessage(event, role) {
388
393
  createdAt: event.createdAt instanceof Date ? event.createdAt.getTime() : new Date(event.createdAt).getTime(),
389
394
  ...(clientMessageIds.length > 0 ? { clientMessageIds } : {}),
390
395
  ...(sender ? { sender } : {}),
396
+ ...(attachments.length > 0 ? { attachments } : {}),
391
397
  };
392
398
  }
393
399
 
@@ -186,10 +186,79 @@ function areStructuredValuesEqual(left, right) {
186
186
  return true;
187
187
  }
188
188
 
189
+ function sessionUpdateTimestampMs(session) {
190
+ const value = session?.updatedAt;
191
+ if (typeof value === "number" && Number.isFinite(value)) return value;
192
+ if (value instanceof Date) return value.getTime();
193
+ const parsed = Date.parse(value || "");
194
+ return Number.isFinite(parsed) ? parsed : 0;
195
+ }
196
+
197
+ // True when `nextSession` carries no newer information than what we already
198
+ // hold. Both timestamps must be present — an update with no `updatedAt` is
199
+ // treated as newer, because we cannot prove otherwise.
200
+ function isSameOrOlderSessionUpdate(previousSession, nextSession) {
201
+ const previousAt = sessionUpdateTimestampMs(previousSession);
202
+ const nextAt = sessionUpdateTimestampMs(nextSession);
203
+ return previousAt > 0 && nextAt > 0 && nextAt <= previousAt;
204
+ }
205
+
206
+ // A run that has ended for good. These are authoritative and must always land,
207
+ // even from an update that looks stale — stranding a finished session as
208
+ // "running" is far worse than a brief wrong status.
209
+ const TERMINAL_SESSION_STATUSES = new Set(["completed", "failed", "cancelled", "error"]);
210
+
211
+ // Session state is written from several concurrent sources: the session-list
212
+ // poll, live events, and a per-session detail fetch. They do not agree
213
+ // instant-to-instant, and the list poll in particular can report a status the
214
+ // orchestration has already moved on from — observed live as:
215
+ //
216
+ // sessions/loaded running + waiting -> waiting prevAt == incomingAt
217
+ // sessions/merged waiting + running -> running (105ms later)
218
+ //
219
+ // while the server's own session row and orchestration both read "running"
220
+ // throughout. Consumers gated on status === "running" — the live-activity
221
+ // strip, the composer's Stop button — blink off for the width of that gap.
222
+ //
223
+ // Refuse ANY non-terminal downgrade out of "running" unless the incoming
224
+ // update is genuinely newer. Deliberately not restricted to idle-like
225
+ // statuses: the status actually observed clobbering a live run was "waiting",
226
+ // and an earlier version of this guard missed the bug by excluding it.
227
+ // Terminal statuses are exempt so a finished run can never be stranded.
228
+ //
229
+ // "Genuinely newer" is decided by the server's monotonic statusVersion when
230
+ // both sides carry one — wall-clock updatedAt is only the fallback. The
231
+ // timestamp heuristic alone can WEDGE a finished turn as "running" forever:
232
+ // client-side event merges (turn_completed, context usage) inflate the held
233
+ // session's updatedAt past the server row's idle-write timestamp, so every
234
+ // subsequent idle poll compares as stale and only a terminal status could
235
+ // ever land. Observed live (2026-07-21, local portal): server row idle at
236
+ // statusVersion 5 while the client showed "Working.." indefinitely.
237
+ function shouldPreserveRunningStatus(previousSession, nextSession) {
238
+ const nextStatus = String(nextSession?.status ?? "").toLowerCase();
239
+ if (previousSession?.status !== "running"
240
+ || nextStatus === "running"
241
+ || TERMINAL_SESSION_STATUSES.has(nextStatus)) {
242
+ return false;
243
+ }
244
+ const previousVersion = Number(previousSession?.statusVersion);
245
+ const nextVersion = Number(nextSession?.statusVersion);
246
+ if (Number.isFinite(previousVersion) && previousVersion > 0
247
+ && Number.isFinite(nextVersion) && nextVersion > 0) {
248
+ // Monotonic server counter: a higher version is authoritative and
249
+ // must land; a same-or-lower version is provably stale and is held
250
+ // regardless of what the timestamps claim.
251
+ return nextVersion <= previousVersion;
252
+ }
253
+ return isSameOrOlderSessionUpdate(previousSession, nextSession);
254
+ }
255
+
189
256
  function mergeDefinedSessionFields(previousSession = {}, nextSession = {}) {
190
257
  let merged = previousSession || {};
258
+ const preserveRunning = shouldPreserveRunningStatus(previousSession, nextSession);
191
259
  for (const [key, value] of Object.entries(nextSession || {})) {
192
260
  if (value === undefined) continue;
261
+ if (key === "status" && preserveRunning) continue;
193
262
  if (key === "pendingQuestion" && isAnsweredPendingQuestion(previousSession, value)) {
194
263
  if (merged === previousSession) {
195
264
  merged = { ...(previousSession || {}) };
@@ -492,6 +561,9 @@ function normalizePromptAttachments(prompt, attachments) {
492
561
  const safePrompt = String(prompt || "");
493
562
  const list = Array.isArray(attachments) ? attachments.filter(Boolean) : [];
494
563
  return list.filter((attachment) => {
564
+ // Image attachments are chip-managed (staged File objects, no inline
565
+ // token in the prompt) — they live until sent or explicitly removed.
566
+ if (attachment?.kind === "image") return true;
495
567
  const token = String(attachment?.token || "").trim();
496
568
  return token && safePrompt.includes(token);
497
569
  });
@@ -2017,13 +2017,16 @@ function buildChatMessageLines(message, maxWidth, options = {}) {
2017
2017
  : message?.role === "user" ? USER_CHAT_COLOR : null,
2018
2018
  );
2019
2019
  const prefix = options.skipPrefix ? [] : buildChatMessagePrefix(message, options);
2020
+ const attachmentChipLines = buildAttachmentChipLines(message, options);
2020
2021
 
2021
2022
  if (tintedMarkdownLines.length === 0) {
2022
- return prefix.length > 0 ? [prefix] : [];
2023
+ const bare = prefix.length > 0 ? [prefix] : [];
2024
+ return attachmentChipLines.length > 0 ? [...bare, ...attachmentChipLines] : bare;
2023
2025
  }
2024
2026
 
2025
2027
  if (startsWithStructuredBlock(tintedMarkdownLines)) {
2026
- return prefix.length > 0 ? [prefix, ...tintedMarkdownLines] : tintedMarkdownLines;
2028
+ const structured = prefix.length > 0 ? [prefix, ...tintedMarkdownLines] : tintedMarkdownLines;
2029
+ return attachmentChipLines.length > 0 ? [...structured, ...attachmentChipLines] : structured;
2027
2030
  }
2028
2031
 
2029
2032
  const renderedLines = [];
@@ -2044,9 +2047,35 @@ function buildChatMessageLines(message, maxWidth, options = {}) {
2044
2047
  renderedLines.push(lineRuns);
2045
2048
  }
2046
2049
  }
2050
+ renderedLines.push(...attachmentChipLines);
2047
2051
  return renderedLines;
2048
2052
  }
2049
2053
 
2054
+ // Image attachment chips under a message. In sentinel mode (browser portal)
2055
+ // a single structured line carries the refs so the web renderer can show
2056
+ // authenticated thumbnails; everywhere else (TUI) each file renders as a
2057
+ // `[image: name · 312 KB]` text chip.
2058
+ function buildAttachmentChipLines(message, options = {}) {
2059
+ const attachments = Array.isArray(message?.attachments) ? message.attachments : [];
2060
+ if (attachments.length === 0) return [];
2061
+ if (options.tableMode === "sentinel") {
2062
+ // message.id is `${sessionId}:${seq}` — the renderer needs the session
2063
+ // to fetch artifact bytes through the authenticated transport.
2064
+ const sessionId = String(message?.id || "").split(":")[0] || null;
2065
+ return [{ kind: "imageAttachments", sessionId, attachments }];
2066
+ }
2067
+ return attachments.map((attachment) => {
2068
+ const size = Number(attachment?.sizeBytes) || 0;
2069
+ const sizeLabel = size >= 1024 * 1024
2070
+ ? `${(size / (1024 * 1024)).toFixed(1)} MB`
2071
+ : size >= 1024 ? `${Math.round(size / 1024)} KB` : `${size} B`;
2072
+ return [
2073
+ { text: " ", color: null },
2074
+ { text: `[image: ${attachment.filename}${size > 0 ? ` · ${sizeLabel}` : ""}]`, color: "cyan" },
2075
+ ];
2076
+ });
2077
+ }
2078
+
2050
2079
  // Activity-log event types that are not surfaced as live "recent action" lines
2051
2080
  // in the chat pane: bookkeeping, high-frequency streaming noise, and the turn
2052
2081
  // bracket markers (which don't read well as prose).