@poncho-ai/cli 0.10.1 → 0.11.0

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.
@@ -7,6 +7,8 @@
7
7
  * produces reliable output with native scroll and text selection.
8
8
  */
9
9
  import * as readline from "node:readline";
10
+ import { readFile } from "node:fs/promises";
11
+ import { resolve, basename } from "node:path";
10
12
  import { stdout } from "node:process";
11
13
  import {
12
14
  parseAgentFile,
@@ -14,7 +16,7 @@ import {
14
16
  type AgentHarness,
15
17
  type ConversationStore,
16
18
  } from "@poncho-ai/harness";
17
- import type { AgentEvent, Message, TokenUsage } from "@poncho-ai/sdk";
19
+ import type { AgentEvent, FileInput, Message, TokenUsage } from "@poncho-ai/sdk";
18
20
  import { inferConversationTitle } from "./web-ui.js";
19
21
  import { consumeFirstRunIntro } from "./init-feature-context.js";
20
22
  import { resolveHarnessEnvironment } from "./index.js";
@@ -135,6 +137,40 @@ const streamTextAsTokens = async (text: string): Promise<void> => {
135
137
  }
136
138
  };
137
139
 
140
+ // ---------------------------------------------------------------------------
141
+ // File helpers
142
+ // ---------------------------------------------------------------------------
143
+
144
+ const EXT_MIME: Record<string, string> = {
145
+ jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png",
146
+ gif: "image/gif", webp: "image/webp", svg: "image/svg+xml",
147
+ pdf: "application/pdf", mp4: "video/mp4", webm: "video/webm",
148
+ mp3: "audio/mpeg", wav: "audio/wav", txt: "text/plain",
149
+ json: "application/json", csv: "text/csv", html: "text/html",
150
+ };
151
+
152
+ const extToMime = (ext: string): string => EXT_MIME[ext] ?? "application/octet-stream";
153
+
154
+ const readPendingFiles = async (
155
+ files: Array<{ path: string; resolved: string }>,
156
+ ): Promise<FileInput[]> => {
157
+ const results: FileInput[] = [];
158
+ for (const f of files) {
159
+ try {
160
+ const buf = await readFile(f.resolved);
161
+ const ext = f.resolved.split(".").pop()?.toLowerCase() ?? "";
162
+ results.push({
163
+ data: buf.toString("base64"),
164
+ mediaType: extToMime(ext),
165
+ filename: basename(f.path),
166
+ });
167
+ } catch {
168
+ console.log(`${C.yellow}warn: could not read ${f.path}, skipping${C.reset}`);
169
+ }
170
+ }
171
+ return results;
172
+ };
173
+
138
174
  // ---------------------------------------------------------------------------
139
175
  // Slash commands
140
176
  // ---------------------------------------------------------------------------
@@ -145,6 +181,7 @@ type InteractiveState = {
145
181
  messages: Message[];
146
182
  turn: number;
147
183
  activeConversationId: string | null;
184
+ pendingFiles: Array<{ path: string; resolved: string }>;
148
185
  };
149
186
 
150
187
  const computeTurn = (messages: Message[]): number =>
@@ -168,7 +205,7 @@ const handleSlash = async (
168
205
  if (norm === "/help") {
169
206
  console.log(
170
207
  gray(
171
- "commands> /help /clear /exit /tools /list /open <id> /new [title] /delete [id] /continue /reset [all]",
208
+ "commands> /help /clear /exit /tools /attach <path> /files /list /open <id> /new [title] /delete [id] /continue /reset [all]",
172
209
  ),
173
210
  );
174
211
  return { shouldExit: false };
@@ -296,6 +333,33 @@ const handleSlash = async (
296
333
  console.log(gray(`conversations> reset ${conversation.conversationId}`));
297
334
  return { shouldExit: false };
298
335
  }
336
+ if (norm === "/attach") {
337
+ const filePath = args.join(" ").trim();
338
+ if (!filePath) {
339
+ console.log(yellow("usage> /attach <path>"));
340
+ return { shouldExit: false };
341
+ }
342
+ const resolvedPath = resolve(process.cwd(), filePath);
343
+ try {
344
+ await readFile(resolvedPath);
345
+ state.pendingFiles.push({ path: filePath, resolved: resolvedPath });
346
+ console.log(gray(`attached> ${filePath} [${state.pendingFiles.length} file(s) queued]`));
347
+ } catch {
348
+ console.log(yellow(`attach> file not found: ${filePath}`));
349
+ }
350
+ return { shouldExit: false };
351
+ }
352
+ if (norm === "/files") {
353
+ if (state.pendingFiles.length === 0) {
354
+ console.log(gray("files> none attached"));
355
+ } else {
356
+ console.log(gray("files>"));
357
+ for (const f of state.pendingFiles) {
358
+ console.log(gray(` ${f.path}`));
359
+ }
360
+ }
361
+ return { shouldExit: false };
362
+ }
299
363
  console.log(yellow(`Unknown command: ${command}`));
300
364
  return { shouldExit: false };
301
365
  };
@@ -369,7 +433,10 @@ export const runInteractiveInk = async ({
369
433
  console.log(gray(' Type "exit" to quit, "/help" for commands'));
370
434
  console.log(gray(" Press Ctrl+C during a run to stop streaming output."));
371
435
  console.log(
372
- gray(" Conversation controls: /list /open <id> /new [title] /delete [id] /continue /reset [all]\n"),
436
+ gray(" Conversation: /list /open <id> /new [title] /delete [id] /continue /reset [all]"),
437
+ );
438
+ console.log(
439
+ gray(" Files: /attach <path> /files\n"),
373
440
  );
374
441
  const intro = await consumeFirstRunIntro(workingDir, {
375
442
  agentName: metadata.agentName,
@@ -390,6 +457,7 @@ export const runInteractiveInk = async ({
390
457
  let activeConversationId: string | null = null;
391
458
  let showToolPayloads = false;
392
459
  let activeRunAbortController: AbortController | null = null;
460
+ let pendingFiles: Array<{ path: string; resolved: string }> = [];
393
461
 
394
462
  rl.on("SIGINT", () => {
395
463
  if (activeRunAbortController && !activeRunAbortController.signal.aborted) {
@@ -403,10 +471,12 @@ export const runInteractiveInk = async ({
403
471
 
404
472
  // --- Main loop -------------------------------------------------------------
405
473
 
406
- const prompt = `${C.cyan}you> ${C.reset}`;
407
-
408
474
  // eslint-disable-next-line no-constant-condition
409
475
  while (true) {
476
+ const filesTag = pendingFiles.length > 0
477
+ ? `${C.dim}[${pendingFiles.length} file(s)] ${C.reset}`
478
+ : "";
479
+ const prompt = `${filesTag}${C.cyan}you> ${C.reset}`;
410
480
  let task: string;
411
481
  try {
412
482
  task = await ask(rl, prompt);
@@ -431,6 +501,7 @@ export const runInteractiveInk = async ({
431
501
  messages,
432
502
  turn,
433
503
  activeConversationId,
504
+ pendingFiles,
434
505
  };
435
506
  const slashResult = await handleSlash(
436
507
  trimmed,
@@ -443,6 +514,7 @@ export const runInteractiveInk = async ({
443
514
  messages = interactiveState.messages;
444
515
  turn = interactiveState.turn;
445
516
  activeConversationId = interactiveState.activeConversationId;
517
+ pendingFiles = interactiveState.pendingFiles;
446
518
  continue;
447
519
  }
448
520
 
@@ -477,11 +549,18 @@ export const runInteractiveInk = async ({
477
549
  const startedAt = Date.now();
478
550
  activeRunAbortController = new AbortController();
479
551
 
552
+ const turnFiles = pendingFiles.length > 0 ? await readPendingFiles(pendingFiles) : [];
553
+ if (pendingFiles.length > 0) {
554
+ console.log(gray(` sending ${turnFiles.length} file(s)`));
555
+ pendingFiles = [];
556
+ }
557
+
480
558
  try {
481
559
  for await (const event of harness.run({
482
560
  task: trimmed,
483
561
  parameters: params,
484
562
  messages,
563
+ files: turnFiles.length > 0 ? turnFiles : undefined,
485
564
  abortSignal: activeRunAbortController.signal,
486
565
  })) {
487
566
  if (event.type === "run:started") {
package/src/web-ui.ts CHANGED
@@ -1077,7 +1077,7 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
1077
1077
  border-radius: 24px;
1078
1078
  display: flex;
1079
1079
  align-items: end;
1080
- padding: 4px 6px 4px 18px;
1080
+ padding: 4px 6px 4px 6px;
1081
1081
  transition: border-color 0.15s;
1082
1082
  }
1083
1083
  .composer-shell:focus-within { border-color: rgba(255,255,255,0.2); }
@@ -1090,7 +1090,7 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
1090
1090
  min-height: 40px;
1091
1091
  max-height: 200px;
1092
1092
  resize: none;
1093
- padding: 10px 0 8px;
1093
+ padding: 11px 0 8px;
1094
1094
  font-size: 14px;
1095
1095
  line-height: 1.5;
1096
1096
  margin-top: -4px;
@@ -1118,6 +1118,149 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
1118
1118
  .send-btn.stop-mode:hover { background: #565656; }
1119
1119
  .send-btn:disabled { opacity: 0.2; cursor: default; }
1120
1120
  .send-btn:disabled:hover { background: #ededed; }
1121
+ .attach-btn {
1122
+ width: 32px;
1123
+ height: 32px;
1124
+ background: rgba(255,255,255,0.08);
1125
+ border: 0;
1126
+ border-radius: 50%;
1127
+ color: #999;
1128
+ cursor: pointer;
1129
+ display: grid;
1130
+ place-items: center;
1131
+ flex-shrink: 0;
1132
+ margin-bottom: 2px;
1133
+ margin-right: 8px;
1134
+ transition: color 0.15s, background 0.15s;
1135
+ }
1136
+ .attach-btn:hover { color: #ededed; background: rgba(255,255,255,0.14); }
1137
+ .attachment-preview {
1138
+ display: flex;
1139
+ gap: 8px;
1140
+ padding: 8px 0;
1141
+ flex-wrap: wrap;
1142
+ }
1143
+ .attachment-chip {
1144
+ display: inline-flex;
1145
+ align-items: center;
1146
+ gap: 6px;
1147
+ background: rgba(0, 0, 0, 0.6);
1148
+ border: 1px solid rgba(255, 255, 255, 0.12);
1149
+ border-radius: 9999px;
1150
+ padding: 4px 10px 4px 6px;
1151
+ font-size: 11px;
1152
+ color: #777;
1153
+ max-width: 200px;
1154
+ cursor: pointer;
1155
+ backdrop-filter: blur(6px);
1156
+ -webkit-backdrop-filter: blur(6px);
1157
+ transition: color 0.15s, border-color 0.15s, background 0.15s;
1158
+ }
1159
+ .attachment-chip:hover {
1160
+ color: #ededed;
1161
+ border-color: rgba(255, 255, 255, 0.25);
1162
+ background: rgba(0, 0, 0, 0.75);
1163
+ }
1164
+ .attachment-chip img {
1165
+ width: 20px;
1166
+ height: 20px;
1167
+ object-fit: cover;
1168
+ border-radius: 50%;
1169
+ flex-shrink: 0;
1170
+ cursor: pointer;
1171
+ }
1172
+ .attachment-chip .file-icon {
1173
+ width: 20px;
1174
+ height: 20px;
1175
+ border-radius: 50%;
1176
+ background: rgba(255,255,255,0.1);
1177
+ display: grid;
1178
+ place-items: center;
1179
+ font-size: 11px;
1180
+ flex-shrink: 0;
1181
+ }
1182
+ .attachment-chip .remove-attachment {
1183
+ cursor: pointer;
1184
+ color: #555;
1185
+ font-size: 14px;
1186
+ margin-left: 2px;
1187
+ line-height: 1;
1188
+ transition: color 0.15s;
1189
+ }
1190
+ .attachment-chip .remove-attachment:hover { color: #fff; }
1191
+ .attachment-chip .filename { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100px; }
1192
+ .user-bubble .user-file-attachments {
1193
+ display: flex;
1194
+ gap: 6px;
1195
+ flex-wrap: wrap;
1196
+ margin-top: 8px;
1197
+ }
1198
+ .user-file-attachments img {
1199
+ max-width: 200px;
1200
+ max-height: 160px;
1201
+ border-radius: 8px;
1202
+ object-fit: cover;
1203
+ cursor: pointer;
1204
+ transition: opacity 0.15s;
1205
+ }
1206
+ .user-file-attachments img:hover { opacity: 0.85; }
1207
+ .lightbox {
1208
+ position: fixed;
1209
+ inset: 0;
1210
+ z-index: 9999;
1211
+ display: flex;
1212
+ align-items: center;
1213
+ justify-content: center;
1214
+ background: rgba(0,0,0,0);
1215
+ backdrop-filter: blur(0px);
1216
+ cursor: zoom-out;
1217
+ transition: background 0.25s ease, backdrop-filter 0.25s ease;
1218
+ }
1219
+ .lightbox.active {
1220
+ background: rgba(0,0,0,0.85);
1221
+ backdrop-filter: blur(8px);
1222
+ }
1223
+ .lightbox img {
1224
+ max-width: 90vw;
1225
+ max-height: 90vh;
1226
+ border-radius: 8px;
1227
+ object-fit: contain;
1228
+ transform: scale(0.4);
1229
+ opacity: 0;
1230
+ transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.25s ease;
1231
+ }
1232
+ .lightbox.active img {
1233
+ transform: scale(1);
1234
+ opacity: 1;
1235
+ }
1236
+ .user-file-badge {
1237
+ display: inline-flex;
1238
+ align-items: center;
1239
+ gap: 4px;
1240
+ background: rgba(0,0,0,0.2);
1241
+ border-radius: 6px;
1242
+ padding: 4px 8px;
1243
+ font-size: 12px;
1244
+ color: rgba(255,255,255,0.8);
1245
+ }
1246
+ .drag-overlay {
1247
+ position: fixed;
1248
+ inset: 0;
1249
+ background: rgba(0,0,0,0.6);
1250
+ z-index: 9999;
1251
+ display: none;
1252
+ align-items: center;
1253
+ justify-content: center;
1254
+ pointer-events: none;
1255
+ }
1256
+ .drag-overlay.active { display: flex; }
1257
+ .drag-overlay-inner {
1258
+ border: 2px dashed rgba(255,255,255,0.4);
1259
+ border-radius: 16px;
1260
+ padding: 40px 60px;
1261
+ color: #fff;
1262
+ font-size: 16px;
1263
+ }
1121
1264
  .disclaimer {
1122
1265
  text-align: center;
1123
1266
  color: #333;
@@ -1261,7 +1404,12 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
1261
1404
  </div>
1262
1405
  <form id="composer" class="composer">
1263
1406
  <div class="composer-inner">
1407
+ <div id="attachment-preview" class="attachment-preview" style="display:none"></div>
1264
1408
  <div class="composer-shell">
1409
+ <button id="attach-btn" class="attach-btn" type="button" title="Attach files">
1410
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
1411
+ </button>
1412
+ <input id="file-input" type="file" multiple accept="image/*,video/*,application/pdf,.txt,.csv,.json,.html" style="display:none" />
1265
1413
  <textarea id="prompt" class="composer-input" placeholder="Send a message..." rows="1"></textarea>
1266
1414
  <button id="send" class="send-btn" type="submit">
1267
1415
  <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 12V4M4 7l4-4 4 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
@@ -1271,6 +1419,8 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
1271
1419
  </form>
1272
1420
  </main>
1273
1421
  </div>
1422
+ <div id="drag-overlay" class="drag-overlay"><div class="drag-overlay-inner">Drop files to attach</div></div>
1423
+ <div id="lightbox" class="lightbox" style="display:none"><img /></div>
1274
1424
 
1275
1425
  <script>
1276
1426
  // Marked library (inlined)
@@ -1293,7 +1443,8 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
1293
1443
  activeStreamRunId: null,
1294
1444
  isMessagesPinnedToBottom: true,
1295
1445
  confirmDeleteId: null,
1296
- approvalRequestsInFlight: {}
1446
+ approvalRequestsInFlight: {},
1447
+ pendingFiles: [],
1297
1448
  };
1298
1449
 
1299
1450
  const agentInitial = document.body.dataset.agentInitial || "A";
@@ -1315,7 +1466,12 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
1315
1466
  send: $("send"),
1316
1467
  shell: $("app"),
1317
1468
  sidebarToggle: $("sidebar-toggle"),
1318
- sidebarBackdrop: $("sidebar-backdrop")
1469
+ sidebarBackdrop: $("sidebar-backdrop"),
1470
+ attachBtn: $("attach-btn"),
1471
+ fileInput: $("file-input"),
1472
+ attachmentPreview: $("attachment-preview"),
1473
+ dragOverlay: $("drag-overlay"),
1474
+ lightbox: $("lightbox"),
1319
1475
  };
1320
1476
  const sendIconMarkup =
1321
1477
  '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 12V4M4 7l4-4 4 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
@@ -1811,7 +1967,47 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
1811
1967
  wrap.appendChild(content);
1812
1968
  row.appendChild(wrap);
1813
1969
  } else {
1814
- row.innerHTML = '<div class="user-bubble">' + escapeHtml(m.content) + '</div>';
1970
+ const bubble = document.createElement("div");
1971
+ bubble.className = "user-bubble";
1972
+ if (typeof m.content === "string") {
1973
+ bubble.textContent = m.content;
1974
+ } else if (Array.isArray(m.content)) {
1975
+ const textParts = m.content.filter(p => p.type === "text").map(p => p.text).join("");
1976
+ if (textParts) {
1977
+ const textEl = document.createElement("div");
1978
+ textEl.textContent = textParts;
1979
+ bubble.appendChild(textEl);
1980
+ }
1981
+ const fileParts = m.content.filter(p => p.type === "file");
1982
+ if (fileParts.length > 0) {
1983
+ const filesEl = document.createElement("div");
1984
+ filesEl.className = "user-file-attachments";
1985
+ fileParts.forEach(fp => {
1986
+ if (fp.mediaType && fp.mediaType.startsWith("image/")) {
1987
+ const img = document.createElement("img");
1988
+ if (fp._localBlob) {
1989
+ if (!fp._cachedUrl) fp._cachedUrl = URL.createObjectURL(fp._localBlob);
1990
+ img.src = fp._cachedUrl;
1991
+ } else if (fp.data && fp.data.startsWith("poncho-upload://")) {
1992
+ img.src = "/api/uploads/" + encodeURIComponent(fp.data.replace("poncho-upload://", ""));
1993
+ } else if (fp.data && (fp.data.startsWith("http://") || fp.data.startsWith("https://"))) {
1994
+ img.src = fp.data;
1995
+ } else if (fp.data) {
1996
+ img.src = "data:" + fp.mediaType + ";base64," + fp.data;
1997
+ }
1998
+ img.alt = fp.filename || "image";
1999
+ filesEl.appendChild(img);
2000
+ } else {
2001
+ const badge = document.createElement("span");
2002
+ badge.className = "user-file-badge";
2003
+ badge.textContent = "📎 " + (fp.filename || "file");
2004
+ filesEl.appendChild(badge);
2005
+ }
2006
+ });
2007
+ bubble.appendChild(filesEl);
2008
+ }
2009
+ }
2010
+ row.appendChild(bubble);
1815
2011
  }
1816
2012
  col.appendChild(row);
1817
2013
  });
@@ -2317,12 +2513,62 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2317
2513
  }
2318
2514
  };
2319
2515
 
2516
+ const renderAttachmentPreview = () => {
2517
+ const el = elements.attachmentPreview;
2518
+ if (state.pendingFiles.length === 0) {
2519
+ el.style.display = "none";
2520
+ el.innerHTML = "";
2521
+ return;
2522
+ }
2523
+ el.style.display = "flex";
2524
+ el.innerHTML = state.pendingFiles.map((f, i) => {
2525
+ const isImage = f.type.startsWith("image/");
2526
+ const thumbHtml = isImage
2527
+ ? '<img src="' + URL.createObjectURL(f) + '" alt="" />'
2528
+ : '<span class="file-icon">📎</span>';
2529
+ return '<div class="attachment-chip" data-idx="' + i + '">'
2530
+ + thumbHtml
2531
+ + '<span class="filename">' + escapeHtml(f.name) + '</span>'
2532
+ + '<span class="remove-attachment" data-idx="' + i + '">&times;</span>'
2533
+ + '</div>';
2534
+ }).join("");
2535
+ };
2536
+
2537
+ const addFiles = (fileList) => {
2538
+ for (const f of fileList) {
2539
+ if (f.size > 25 * 1024 * 1024) {
2540
+ alert("File too large: " + f.name + " (max 25MB)");
2541
+ continue;
2542
+ }
2543
+ state.pendingFiles.push(f);
2544
+ }
2545
+ renderAttachmentPreview();
2546
+ };
2547
+
2320
2548
  const sendMessage = async (text) => {
2321
2549
  const messageText = (text || "").trim();
2322
2550
  if (!messageText || state.isStreaming) {
2323
2551
  return;
2324
2552
  }
2325
- const localMessages = [...(state.activeMessages || []), { role: "user", content: messageText }];
2553
+ const filesToSend = [...state.pendingFiles];
2554
+ state.pendingFiles = [];
2555
+ renderAttachmentPreview();
2556
+ let userContent;
2557
+ if (filesToSend.length > 0) {
2558
+ userContent = [{ type: "text", text: messageText }];
2559
+ for (const f of filesToSend) {
2560
+ userContent.push({
2561
+ type: "file",
2562
+ data: URL.createObjectURL(f),
2563
+ mediaType: f.type,
2564
+ filename: f.name,
2565
+ _localBlob: f,
2566
+ });
2567
+ }
2568
+ } else {
2569
+ userContent = messageText;
2570
+ }
2571
+ const localMessages = [...(state.activeMessages || []), { role: "user", content: userContent }];
2326
2572
  let assistantMessage = {
2327
2573
  role: "assistant",
2328
2574
  content: "",
@@ -2365,13 +2611,38 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2365
2611
  assistantMessage._currentText = "";
2366
2612
  }
2367
2613
  };
2368
- const response = await fetch("/api/conversations/" + encodeURIComponent(conversationId) + "/messages", {
2369
- method: "POST",
2370
- credentials: "include",
2371
- headers: { "Content-Type": "application/json", "x-csrf-token": state.csrfToken },
2372
- body: JSON.stringify({ message: messageText }),
2373
- signal: streamAbortController.signal,
2374
- });
2614
+ let _continuationMessage = messageText;
2615
+ let _totalSteps = 0;
2616
+ let _maxSteps = 0;
2617
+ while (_continuationMessage) {
2618
+ let _shouldContinue = false;
2619
+ let fetchOpts;
2620
+ if (filesToSend.length > 0 && _continuationMessage === messageText) {
2621
+ const formData = new FormData();
2622
+ formData.append("message", _continuationMessage);
2623
+ for (const f of filesToSend) {
2624
+ formData.append("files", f, f.name);
2625
+ }
2626
+ fetchOpts = {
2627
+ method: "POST",
2628
+ credentials: "include",
2629
+ headers: { "x-csrf-token": state.csrfToken },
2630
+ body: formData,
2631
+ signal: streamAbortController.signal,
2632
+ };
2633
+ } else {
2634
+ fetchOpts = {
2635
+ method: "POST",
2636
+ credentials: "include",
2637
+ headers: { "Content-Type": "application/json", "x-csrf-token": state.csrfToken },
2638
+ body: JSON.stringify({ message: _continuationMessage }),
2639
+ signal: streamAbortController.signal,
2640
+ };
2641
+ }
2642
+ const response = await fetch(
2643
+ "/api/conversations/" + encodeURIComponent(conversationId) + "/messages",
2644
+ fetchOpts,
2645
+ );
2375
2646
  if (!response.ok || !response.body) {
2376
2647
  throw new Error("Failed to stream response");
2377
2648
  }
@@ -2546,11 +2817,17 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2546
2817
  renderIfActiveConversation(true);
2547
2818
  }
2548
2819
  if (eventName === "run:completed") {
2549
- finalizeAssistantMessage();
2550
- if (!assistantMessage.content || assistantMessage.content.length === 0) {
2551
- assistantMessage.content = String(payload.result?.response || "");
2820
+ _totalSteps += typeof payload.result?.steps === "number" ? payload.result.steps : 0;
2821
+ if (typeof payload.result?.maxSteps === "number") _maxSteps = payload.result.maxSteps;
2822
+ if (payload.result?.continuation === true && (_maxSteps <= 0 || _totalSteps < _maxSteps)) {
2823
+ _shouldContinue = true;
2824
+ } else {
2825
+ finalizeAssistantMessage();
2826
+ if (!assistantMessage.content || assistantMessage.content.length === 0) {
2827
+ assistantMessage.content = String(payload.result?.response || "");
2828
+ }
2829
+ renderIfActiveConversation(false);
2552
2830
  }
2553
- renderIfActiveConversation(false);
2554
2831
  }
2555
2832
  if (eventName === "run:cancelled") {
2556
2833
  finalizeAssistantMessage();
@@ -2568,6 +2845,9 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2568
2845
  }
2569
2846
  });
2570
2847
  }
2848
+ if (!_shouldContinue) break;
2849
+ _continuationMessage = "Continue";
2850
+ }
2571
2851
  // Update active state only if user is still on this conversation.
2572
2852
  if (state.activeConversationId === streamConversationId) {
2573
2853
  state.activeMessages = localMessages;
@@ -2714,6 +2994,83 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2714
2994
  await sendMessage(value);
2715
2995
  });
2716
2996
 
2997
+ elements.attachBtn.addEventListener("click", () => elements.fileInput.click());
2998
+ elements.fileInput.addEventListener("change", () => {
2999
+ if (elements.fileInput.files && elements.fileInput.files.length > 0) {
3000
+ addFiles(elements.fileInput.files);
3001
+ elements.fileInput.value = "";
3002
+ }
3003
+ });
3004
+ elements.attachmentPreview.addEventListener("click", (e) => {
3005
+ const rm = e.target.closest(".remove-attachment");
3006
+ if (rm) {
3007
+ const idx = parseInt(rm.dataset.idx, 10);
3008
+ state.pendingFiles.splice(idx, 1);
3009
+ renderAttachmentPreview();
3010
+ }
3011
+ });
3012
+
3013
+ let dragCounter = 0;
3014
+ document.addEventListener("dragenter", (e) => {
3015
+ e.preventDefault();
3016
+ dragCounter++;
3017
+ if (dragCounter === 1) elements.dragOverlay.classList.add("active");
3018
+ });
3019
+ document.addEventListener("dragleave", (e) => {
3020
+ e.preventDefault();
3021
+ dragCounter--;
3022
+ if (dragCounter <= 0) { dragCounter = 0; elements.dragOverlay.classList.remove("active"); }
3023
+ });
3024
+ document.addEventListener("dragover", (e) => e.preventDefault());
3025
+ document.addEventListener("drop", (e) => {
3026
+ e.preventDefault();
3027
+ dragCounter = 0;
3028
+ elements.dragOverlay.classList.remove("active");
3029
+ if (e.dataTransfer && e.dataTransfer.files.length > 0) {
3030
+ addFiles(e.dataTransfer.files);
3031
+ }
3032
+ });
3033
+
3034
+ // Lightbox: open/close helpers
3035
+ const lightboxImg = elements.lightbox.querySelector("img");
3036
+ const openLightbox = (src) => {
3037
+ lightboxImg.src = src;
3038
+ elements.lightbox.style.display = "flex";
3039
+ requestAnimationFrame(() => {
3040
+ requestAnimationFrame(() => elements.lightbox.classList.add("active"));
3041
+ });
3042
+ };
3043
+ const closeLightbox = () => {
3044
+ elements.lightbox.classList.remove("active");
3045
+ elements.lightbox.addEventListener("transitionend", function handler() {
3046
+ elements.lightbox.removeEventListener("transitionend", handler);
3047
+ elements.lightbox.style.display = "none";
3048
+ lightboxImg.src = "";
3049
+ });
3050
+ };
3051
+ elements.lightbox.addEventListener("click", closeLightbox);
3052
+ document.addEventListener("keydown", (e) => {
3053
+ if (e.key === "Escape" && elements.lightbox.style.display !== "none") closeLightbox();
3054
+ });
3055
+
3056
+ // Lightbox from message images
3057
+ elements.messages.addEventListener("click", (e) => {
3058
+ const img = e.target;
3059
+ if (!(img instanceof HTMLImageElement) || !img.closest(".user-file-attachments")) return;
3060
+ openLightbox(img.src);
3061
+ });
3062
+
3063
+ // Lightbox from attachment preview chips
3064
+ elements.attachmentPreview.addEventListener("click", (e) => {
3065
+ if (e.target.closest(".remove-attachment")) return;
3066
+ const chip = e.target.closest(".attachment-chip");
3067
+ if (!chip) return;
3068
+ const img = chip.querySelector("img");
3069
+ if (!img) return;
3070
+ e.stopPropagation();
3071
+ openLightbox(img.src);
3072
+ });
3073
+
2717
3074
  elements.messages.addEventListener("click", async (event) => {
2718
3075
  const target = event.target;
2719
3076
  if (!(target instanceof Element)) {
package/test/cli.test.ts CHANGED
@@ -155,6 +155,11 @@ vi.mock("@poncho-ai/harness", () => ({
155
155
  TelemetryEmitter: class {
156
156
  async emit(): Promise<void> {}
157
157
  },
158
+ createUploadStore: async () => ({
159
+ put: async () => "poncho-upload://test",
160
+ get: async () => Buffer.from(""),
161
+ delete: async () => {},
162
+ }),
158
163
  }));
159
164
 
160
165
  import {