@poncho-ai/cli 0.7.0 → 0.8.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.
Files changed (57) hide show
  1. package/.turbo/turbo-build.log +5 -5
  2. package/dist/chunk-3QVOY7B6.js +5715 -0
  3. package/dist/chunk-ANOPJ4X2.js +5642 -0
  4. package/dist/chunk-CC667GH3.js +5730 -0
  5. package/dist/chunk-CH4XYHJE.js +5727 -0
  6. package/dist/chunk-CP6UFUK2.js +5724 -0
  7. package/dist/chunk-EZLJV7OP.js +5710 -0
  8. package/dist/chunk-JBSQRXIR.js +5458 -0
  9. package/dist/chunk-JRIAQTMD.js +5548 -0
  10. package/dist/chunk-KR5CYUSD.js +5709 -0
  11. package/dist/chunk-LSOIOT57.js +5715 -0
  12. package/dist/chunk-MVYF325X.js +5695 -0
  13. package/dist/chunk-N6NRICKA.js +5669 -0
  14. package/dist/chunk-PPPOPLC5.js +5683 -0
  15. package/dist/chunk-QXF4F3KZ.js +5725 -0
  16. package/dist/chunk-RJTGWTAI.js +5648 -0
  17. package/dist/chunk-RYXYGUDN.js +5710 -0
  18. package/dist/chunk-TBKHI5AK.js +5500 -0
  19. package/dist/chunk-TDPERUEG.js +5669 -0
  20. package/dist/chunk-TF3KCJAS.js +5458 -0
  21. package/dist/chunk-XPDWQPS2.js +5724 -0
  22. package/dist/chunk-XR3VAI5W.js +5458 -0
  23. package/dist/chunk-YSDDBHRF.js +5705 -0
  24. package/dist/chunk-ZKK2TINV.js +5709 -0
  25. package/dist/chunk-ZL7H73NW.js +5458 -0
  26. package/dist/chunk-ZMR6K3AO.js +5730 -0
  27. package/dist/cli.js +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/run-interactive-ink-35CB7WDL.js +494 -0
  30. package/dist/run-interactive-ink-4ANAOBOL.js +517 -0
  31. package/dist/run-interactive-ink-4KWEORVB.js +517 -0
  32. package/dist/run-interactive-ink-4PXAYUJG.js +517 -0
  33. package/dist/run-interactive-ink-5OUK2NRE.js +494 -0
  34. package/dist/run-interactive-ink-7SLXGC72.js +517 -0
  35. package/dist/run-interactive-ink-C7TDMV7Z.js +517 -0
  36. package/dist/run-interactive-ink-IFKUSZIO.js +494 -0
  37. package/dist/run-interactive-ink-IP53WQ3O.js +517 -0
  38. package/dist/run-interactive-ink-KH2MJRH7.js +494 -0
  39. package/dist/run-interactive-ink-KLIGYEVZ.js +520 -0
  40. package/dist/run-interactive-ink-LNAAWJDA.js +517 -0
  41. package/dist/run-interactive-ink-OQUOMIO3.js +520 -0
  42. package/dist/run-interactive-ink-RDRKOXZ5.js +517 -0
  43. package/dist/run-interactive-ink-RDSD5STV.js +517 -0
  44. package/dist/run-interactive-ink-RO6MARC3.js +517 -0
  45. package/dist/run-interactive-ink-S6MSMSX3.js +494 -0
  46. package/dist/run-interactive-ink-SVHTURD5.js +517 -0
  47. package/dist/run-interactive-ink-TSHABWSR.js +494 -0
  48. package/dist/run-interactive-ink-U4JKAUVO.js +517 -0
  49. package/dist/run-interactive-ink-UL5MFLAE.js +517 -0
  50. package/dist/run-interactive-ink-VIE5B4YO.js +517 -0
  51. package/dist/run-interactive-ink-WTEVB7TK.js +517 -0
  52. package/dist/run-interactive-ink-YZM7ZTJJ.js +517 -0
  53. package/dist/run-interactive-ink-YZRP7BPI.js +525 -0
  54. package/package.json +3 -3
  55. package/src/index.ts +160 -15
  56. package/src/run-interactive-ink.ts +45 -18
  57. package/src/web-ui.ts +132 -23
package/src/index.ts CHANGED
@@ -307,6 +307,11 @@ poncho dev
307
307
  Open \`http://localhost:3000\` for the web UI.
308
308
 
309
309
  On your first interactive session, the agent introduces its configurable capabilities.
310
+ While a response is streaming, you can stop it:
311
+ - Web UI: click the send button again (it switches to a stop icon)
312
+ - Interactive CLI: press \`Ctrl+C\`
313
+
314
+ Stopping is best-effort and keeps partial assistant output/tool activity already produced.
310
315
 
311
316
  ## Common Commands
312
317
 
@@ -857,6 +862,12 @@ export const createRequestHandler = async (options?: {
857
862
  } catch {}
858
863
  const runOwners = new Map<string, string>();
859
864
  const runConversations = new Map<string, string>();
865
+ type ActiveConversationRun = {
866
+ ownerId: string;
867
+ abortController: AbortController;
868
+ runId: string | null;
869
+ };
870
+ const activeConversationRuns = new Map<string, ActiveConversationRun>();
860
871
  type PendingApproval = {
861
872
  ownerId: string;
862
873
  runId: string;
@@ -924,6 +935,16 @@ export const createRequestHandler = async (options?: {
924
935
  }));
925
936
  await conversationStore.update(conversation);
926
937
  };
938
+ const clearPendingApprovalsForConversation = async (conversationId: string): Promise<void> => {
939
+ for (const [approvalId, pending] of pendingApprovals.entries()) {
940
+ if (pending.conversationId !== conversationId) {
941
+ continue;
942
+ }
943
+ pendingApprovals.delete(approvalId);
944
+ pending.resolve(false);
945
+ }
946
+ await persistConversationPendingApprovals(conversationId);
947
+ };
927
948
  const harness = new AgentHarness({
928
949
  workingDir,
929
950
  environment: resolveHarnessEnvironment(),
@@ -1319,6 +1340,61 @@ export const createRequestHandler = async (options?: {
1319
1340
  }
1320
1341
  }
1321
1342
 
1343
+ const conversationStopMatch = pathname.match(/^\/api\/conversations\/([^/]+)\/stop$/);
1344
+ if (conversationStopMatch && request.method === "POST") {
1345
+ const conversationId = decodeURIComponent(conversationStopMatch[1] ?? "");
1346
+ const body = (await readRequestBody(request)) as { runId?: string };
1347
+ const requestedRunId = typeof body.runId === "string" ? body.runId.trim() : "";
1348
+ const conversation = await conversationStore.get(conversationId);
1349
+ if (!conversation || conversation.ownerId !== ownerId) {
1350
+ writeJson(response, 404, {
1351
+ code: "CONVERSATION_NOT_FOUND",
1352
+ message: "Conversation not found",
1353
+ });
1354
+ return;
1355
+ }
1356
+ const activeRun = activeConversationRuns.get(conversationId);
1357
+ if (!activeRun || activeRun.ownerId !== ownerId) {
1358
+ writeJson(response, 200, {
1359
+ ok: true,
1360
+ stopped: false,
1361
+ });
1362
+ return;
1363
+ }
1364
+ if (activeRun.abortController.signal.aborted) {
1365
+ activeConversationRuns.delete(conversationId);
1366
+ writeJson(response, 200, {
1367
+ ok: true,
1368
+ stopped: false,
1369
+ });
1370
+ return;
1371
+ }
1372
+ if (requestedRunId && activeRun.runId !== requestedRunId) {
1373
+ writeJson(response, 200, {
1374
+ ok: true,
1375
+ stopped: false,
1376
+ runId: activeRun.runId ?? undefined,
1377
+ });
1378
+ return;
1379
+ }
1380
+ if (!requestedRunId) {
1381
+ writeJson(response, 200, {
1382
+ ok: true,
1383
+ stopped: false,
1384
+ runId: activeRun.runId ?? undefined,
1385
+ });
1386
+ return;
1387
+ }
1388
+ activeRun.abortController.abort();
1389
+ await clearPendingApprovalsForConversation(conversationId);
1390
+ writeJson(response, 200, {
1391
+ ok: true,
1392
+ stopped: true,
1393
+ runId: activeRun.runId ?? undefined,
1394
+ });
1395
+ return;
1396
+ }
1397
+
1322
1398
  const conversationMessageMatch = pathname.match(/^\/api\/conversations\/([^/]+)\/messages$/);
1323
1399
  if (conversationMessageMatch && request.method === "POST") {
1324
1400
  const conversationId = decodeURIComponent(conversationMessageMatch[1] ?? "");
@@ -1342,6 +1418,24 @@ export const createRequestHandler = async (options?: {
1342
1418
  });
1343
1419
  return;
1344
1420
  }
1421
+ const activeRun = activeConversationRuns.get(conversationId);
1422
+ if (activeRun && activeRun.ownerId === ownerId) {
1423
+ if (activeRun.abortController.signal.aborted) {
1424
+ activeConversationRuns.delete(conversationId);
1425
+ } else {
1426
+ writeJson(response, 409, {
1427
+ code: "RUN_IN_PROGRESS",
1428
+ message: "A run is already active for this conversation",
1429
+ });
1430
+ return;
1431
+ }
1432
+ }
1433
+ const abortController = new AbortController();
1434
+ activeConversationRuns.set(conversationId, {
1435
+ ownerId,
1436
+ abortController,
1437
+ runId: null,
1438
+ });
1345
1439
  if (
1346
1440
  conversation.messages.length === 0 &&
1347
1441
  (conversation.title === "New conversation" || conversation.title.trim().length === 0)
@@ -1360,6 +1454,7 @@ export const createRequestHandler = async (options?: {
1360
1454
  const sections: Array<{ type: "text" | "tools"; content: string | string[] }> = [];
1361
1455
  let currentText = "";
1362
1456
  let currentTools: string[] = [];
1457
+ let runCancelled = false;
1363
1458
  try {
1364
1459
  // Persist the user turn immediately so refreshing mid-run keeps chat context.
1365
1460
  conversation.messages = [...historyMessages, { role: "user", content: messageText }];
@@ -1426,11 +1521,19 @@ export const createRequestHandler = async (options?: {
1426
1521
  __activeConversationId: conversationId,
1427
1522
  },
1428
1523
  messages: historyMessages,
1524
+ abortSignal: abortController.signal,
1429
1525
  })) {
1430
1526
  if (event.type === "run:started") {
1431
1527
  latestRunId = event.runId;
1432
1528
  runOwners.set(event.runId, ownerId);
1433
1529
  runConversations.set(event.runId, conversationId);
1530
+ const active = activeConversationRuns.get(conversationId);
1531
+ if (active && active.abortController === abortController) {
1532
+ active.runId = event.runId;
1533
+ }
1534
+ }
1535
+ if (event.type === "run:cancelled") {
1536
+ runCancelled = true;
1434
1537
  }
1435
1538
  if (event.type === "model:chunk") {
1436
1539
  // If we have tools accumulated and text starts again, push tools as a section
@@ -1502,26 +1605,60 @@ export const createRequestHandler = async (options?: {
1502
1605
  if (currentText.length > 0) {
1503
1606
  sections.push({ type: "text", content: currentText });
1504
1607
  }
1505
- conversation.messages = [
1506
- ...historyMessages,
1507
- { role: "user", content: messageText },
1508
- {
1509
- role: "assistant",
1510
- content: assistantResponse,
1511
- metadata:
1512
- toolTimeline.length > 0 || sections.length > 0
1513
- ? ({
1514
- toolActivity: toolTimeline,
1515
- sections: sections.length > 0 ? sections : undefined,
1516
- } as Message["metadata"])
1517
- : undefined,
1518
- },
1519
- ];
1608
+ const hasAssistantContent =
1609
+ assistantResponse.length > 0 || toolTimeline.length > 0 || sections.length > 0;
1610
+ conversation.messages = hasAssistantContent
1611
+ ? [
1612
+ ...historyMessages,
1613
+ { role: "user", content: messageText },
1614
+ {
1615
+ role: "assistant",
1616
+ content: assistantResponse,
1617
+ metadata:
1618
+ toolTimeline.length > 0 || sections.length > 0
1619
+ ? ({
1620
+ toolActivity: toolTimeline,
1621
+ sections: sections.length > 0 ? sections : undefined,
1622
+ } as Message["metadata"])
1623
+ : undefined,
1624
+ },
1625
+ ]
1626
+ : [...historyMessages, { role: "user", content: messageText }];
1520
1627
  conversation.runtimeRunId = latestRunId || conversation.runtimeRunId;
1521
1628
  conversation.pendingApprovals = [];
1522
1629
  conversation.updatedAt = Date.now();
1523
1630
  await conversationStore.update(conversation);
1524
1631
  } catch (error) {
1632
+ if (abortController.signal.aborted || runCancelled) {
1633
+ const fallbackSections = [...sections];
1634
+ if (currentTools.length > 0) {
1635
+ fallbackSections.push({ type: "tools", content: [...currentTools] });
1636
+ }
1637
+ if (currentText.length > 0) {
1638
+ fallbackSections.push({ type: "text", content: currentText });
1639
+ }
1640
+ if (assistantResponse.length > 0 || toolTimeline.length > 0 || fallbackSections.length > 0) {
1641
+ conversation.messages = [
1642
+ ...historyMessages,
1643
+ { role: "user", content: messageText },
1644
+ {
1645
+ role: "assistant",
1646
+ content: assistantResponse,
1647
+ metadata:
1648
+ toolTimeline.length > 0 || fallbackSections.length > 0
1649
+ ? ({
1650
+ toolActivity: [...toolTimeline],
1651
+ sections: fallbackSections.length > 0 ? fallbackSections : undefined,
1652
+ } as Message["metadata"])
1653
+ : undefined,
1654
+ },
1655
+ ];
1656
+ conversation.updatedAt = Date.now();
1657
+ await conversationStore.update(conversation);
1658
+ }
1659
+ await clearPendingApprovalsForConversation(conversationId);
1660
+ return;
1661
+ }
1525
1662
  try {
1526
1663
  response.write(
1527
1664
  formatSseEvent({
@@ -1563,6 +1700,10 @@ export const createRequestHandler = async (options?: {
1563
1700
  }
1564
1701
  }
1565
1702
  } finally {
1703
+ const active = activeConversationRuns.get(conversationId);
1704
+ if (active && active.abortController === abortController) {
1705
+ activeConversationRuns.delete(conversationId);
1706
+ }
1566
1707
  finishConversationStream(conversationId);
1567
1708
  await persistConversationPendingApprovals(conversationId);
1568
1709
  if (latestRunId) {
@@ -1654,6 +1795,10 @@ export const runOnce = async (
1654
1795
  if (event.type === "run:completed") {
1655
1796
  process.stdout.write("\n");
1656
1797
  }
1798
+ if (event.type === "run:cancelled") {
1799
+ process.stdout.write("\n");
1800
+ process.stderr.write("Run cancelled.\n");
1801
+ }
1657
1802
  }
1658
1803
  };
1659
1804
 
@@ -355,6 +355,7 @@ export const runInteractiveInk = async ({
355
355
  ),
356
356
  );
357
357
  console.log(gray('Type "exit" to quit, "/help" for commands'));
358
+ console.log(gray("Press Ctrl+C during a run to stop streaming output."));
358
359
  console.log(
359
360
  gray("Conversation controls: /list /open <id> /new [title] /delete [id] /continue /reset [all]\n"),
360
361
  );
@@ -376,6 +377,17 @@ export const runInteractiveInk = async ({
376
377
  let turn = 1;
377
378
  let activeConversationId: string | null = null;
378
379
  let showToolPayloads = false;
380
+ let activeRunAbortController: AbortController | null = null;
381
+
382
+ rl.on("SIGINT", () => {
383
+ if (activeRunAbortController && !activeRunAbortController.signal.aborted) {
384
+ activeRunAbortController.abort();
385
+ process.stdout.write("\n");
386
+ console.log(gray("stop> cancelling current run..."));
387
+ return;
388
+ }
389
+ rl.close();
390
+ });
379
391
 
380
392
  // --- Main loop -------------------------------------------------------------
381
393
 
@@ -447,15 +459,18 @@ export const runInteractiveInk = async ({
447
459
  let currentText = "";
448
460
  let currentTools: string[] = [];
449
461
  let runFailed = false;
462
+ let runCancelled = false;
450
463
  let usage: TokenUsage | undefined;
451
464
  let latestRunId = "";
452
465
  const startedAt = Date.now();
466
+ activeRunAbortController = new AbortController();
453
467
 
454
468
  try {
455
469
  for await (const event of harness.run({
456
470
  task: trimmed,
457
471
  parameters: params,
458
472
  messages,
473
+ abortSignal: activeRunAbortController.signal,
459
474
  })) {
460
475
  if (event.type === "run:started") {
461
476
  latestRunId = event.runId;
@@ -555,6 +570,9 @@ export const runInteractiveInk = async ({
555
570
  clearThinking();
556
571
  runFailed = true;
557
572
  console.log(red(`error> ${event.error.message}`));
573
+ } else if (event.type === "run:cancelled") {
574
+ clearThinking();
575
+ runCancelled = true;
558
576
  } else if (event.type === "model:response") {
559
577
  usage = event.usage;
560
578
  } else if (event.type === "run:completed" && !sawChunk) {
@@ -569,18 +587,24 @@ export const runInteractiveInk = async ({
569
587
  }
570
588
  } catch (error) {
571
589
  clearThinking();
572
- runFailed = true;
573
- console.log(
574
- red(
575
- `error> ${error instanceof Error ? error.message : "Unknown error"}`,
576
- ),
577
- );
590
+ if (activeRunAbortController.signal.aborted) {
591
+ runCancelled = true;
592
+ } else {
593
+ runFailed = true;
594
+ console.log(
595
+ red(
596
+ `error> ${error instanceof Error ? error.message : "Unknown error"}`,
597
+ ),
598
+ );
599
+ }
600
+ } finally {
601
+ activeRunAbortController = null;
578
602
  }
579
603
 
580
604
  // End the streaming line if needed
581
605
  if (sawChunk && streamedText.length > 0) {
582
606
  process.stdout.write("\n");
583
- } else if (!sawChunk && !runFailed && responseText.length === 0) {
607
+ } else if (!sawChunk && !runFailed && !runCancelled && responseText.length === 0) {
584
608
  clearThinking();
585
609
  console.log(green("assistant> (no response)"));
586
610
  }
@@ -621,17 +645,20 @@ export const runInteractiveInk = async ({
621
645
  }
622
646
 
623
647
  messages.push({ role: "user", content: trimmed });
624
- messages.push({
625
- role: "assistant",
626
- content: responseText,
627
- metadata:
628
- toolTimeline.length > 0 || sections.length > 0
629
- ? ({
630
- toolActivity: toolTimeline,
631
- sections: sections.length > 0 ? sections : undefined,
632
- } as Message["metadata"])
633
- : undefined,
634
- });
648
+ const hasAssistantContent = responseText.length > 0 || toolTimeline.length > 0 || sections.length > 0;
649
+ if (hasAssistantContent) {
650
+ messages.push({
651
+ role: "assistant",
652
+ content: responseText,
653
+ metadata:
654
+ toolTimeline.length > 0 || sections.length > 0
655
+ ? ({
656
+ toolActivity: toolTimeline,
657
+ sections: sections.length > 0 ? sections : undefined,
658
+ } as Message["metadata"])
659
+ : undefined,
660
+ });
661
+ }
635
662
  turn = computeTurn(messages);
636
663
 
637
664
  const conversation = await conversationStore.get(activeConversationId);
package/src/web-ui.ts CHANGED
@@ -1074,9 +1074,9 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
1074
1074
  .composer-shell {
1075
1075
  background: #0a0a0a;
1076
1076
  border: 1px solid rgba(255,255,255,0.1);
1077
- border-radius: 9999px;
1077
+ border-radius: 24px;
1078
1078
  display: flex;
1079
- align-items: center;
1079
+ align-items: end;
1080
1080
  padding: 4px 6px 4px 18px;
1081
1081
  transition: border-color 0.15s;
1082
1082
  }
@@ -1093,7 +1093,7 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
1093
1093
  padding: 10px 0 8px;
1094
1094
  font-size: 14px;
1095
1095
  line-height: 1.5;
1096
- margin-top: -2px;
1096
+ margin-top: -4px;
1097
1097
  }
1098
1098
  .composer-input::placeholder { color: #444; }
1099
1099
  .send-btn {
@@ -1111,6 +1111,11 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
1111
1111
  transition: background 0.15s, opacity 0.15s;
1112
1112
  }
1113
1113
  .send-btn:hover { background: #fff; }
1114
+ .send-btn.stop-mode {
1115
+ background: #4a4a4a;
1116
+ color: #fff;
1117
+ }
1118
+ .send-btn.stop-mode:hover { background: #565656; }
1114
1119
  .send-btn:disabled { opacity: 0.2; cursor: default; }
1115
1120
  .send-btn:disabled:hover { background: #ededed; }
1116
1121
  .disclaimer {
@@ -1238,6 +1243,9 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
1238
1243
  activeConversationId: null,
1239
1244
  activeMessages: [],
1240
1245
  isStreaming: false,
1246
+ activeStreamAbortController: null,
1247
+ activeStreamConversationId: null,
1248
+ activeStreamRunId: null,
1241
1249
  isMessagesPinnedToBottom: true,
1242
1250
  confirmDeleteId: null,
1243
1251
  approvalRequestsInFlight: {}
@@ -1264,6 +1272,10 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
1264
1272
  sidebarToggle: $("sidebar-toggle"),
1265
1273
  sidebarBackdrop: $("sidebar-backdrop")
1266
1274
  };
1275
+ const sendIconMarkup =
1276
+ '<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>';
1277
+ const stopIconMarkup =
1278
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="4" y="4" width="8" height="8" rx="2" fill="currentColor"/></svg>';
1267
1279
 
1268
1280
  const pushConversationUrl = (conversationId) => {
1269
1281
  const target = conversationId ? "/c/" + encodeURIComponent(conversationId) : "/";
@@ -1745,12 +1757,7 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
1745
1757
  );
1746
1758
  }
1747
1759
  }
1748
- if (
1749
- isStreaming &&
1750
- isLastAssistant &&
1751
- !hasPendingApprovals &&
1752
- (!m._currentText || m._currentText.length === 0)
1753
- ) {
1760
+ if (isStreaming && isLastAssistant && !hasPendingApprovals) {
1754
1761
  const waitIndicator = document.createElement("div");
1755
1762
  waitIndicator.appendChild(createThinkingIndicator(getThinkingStatusLabel(m)));
1756
1763
  content.appendChild(waitIndicator);
@@ -1955,6 +1962,24 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
1955
1962
  }
1956
1963
  renderIfActiveConversation(false);
1957
1964
  }
1965
+ if (eventName === "run:cancelled") {
1966
+ assistantMessage._activeActivities = [];
1967
+ if (assistantMessage._currentTools.length > 0) {
1968
+ assistantMessage._sections.push({
1969
+ type: "tools",
1970
+ content: assistantMessage._currentTools,
1971
+ });
1972
+ assistantMessage._currentTools = [];
1973
+ }
1974
+ if (assistantMessage._currentText.length > 0) {
1975
+ assistantMessage._sections.push({
1976
+ type: "text",
1977
+ content: assistantMessage._currentText,
1978
+ });
1979
+ assistantMessage._currentText = "";
1980
+ }
1981
+ renderIfActiveConversation(false);
1982
+ }
1958
1983
  if (eventName === "run:error") {
1959
1984
  assistantMessage._activeActivities = [];
1960
1985
  const errMsg =
@@ -2028,7 +2053,15 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2028
2053
 
2029
2054
  const setStreaming = (value) => {
2030
2055
  state.isStreaming = value;
2031
- elements.send.disabled = value;
2056
+ const canStop = value && !!state.activeStreamRunId;
2057
+ elements.send.disabled = value ? !canStop : false;
2058
+ elements.send.innerHTML = value ? stopIconMarkup : sendIconMarkup;
2059
+ elements.send.classList.toggle("stop-mode", value);
2060
+ elements.send.setAttribute("aria-label", value ? "Stop response" : "Send message");
2061
+ elements.send.setAttribute(
2062
+ "title",
2063
+ value ? (canStop ? "Stop response" : "Starting response...") : "Send message",
2064
+ );
2032
2065
  };
2033
2066
 
2034
2067
  const pushToolActivity = (assistantMessage, line) => {
@@ -2197,7 +2230,7 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2197
2230
  }
2198
2231
  }
2199
2232
  }
2200
- return "Thinking...";
2233
+ return "";
2201
2234
  };
2202
2235
 
2203
2236
  const autoResizePrompt = () => {
@@ -2209,6 +2242,36 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2209
2242
  el.style.overflowY = scrollHeight > 200 ? "auto" : "hidden";
2210
2243
  };
2211
2244
 
2245
+ const stopActiveRun = async () => {
2246
+ const stopRunId = state.activeStreamRunId;
2247
+ if (!stopRunId) return;
2248
+ const conversationId = state.activeStreamConversationId || state.activeConversationId;
2249
+ if (!conversationId) return;
2250
+ // Disable the stop button immediately so the user sees feedback.
2251
+ state.activeStreamRunId = null;
2252
+ setStreaming(state.isStreaming);
2253
+ // Signal the server to cancel the run. The server will emit
2254
+ // run:cancelled through the still-open SSE stream, which
2255
+ // sendMessage() processes naturally – the stream ends on its own
2256
+ // and cleanup happens in one finally block. No fetch abort needed.
2257
+ try {
2258
+ await api(
2259
+ "/api/conversations/" + encodeURIComponent(conversationId) + "/stop",
2260
+ {
2261
+ method: "POST",
2262
+ body: JSON.stringify({ runId: stopRunId }),
2263
+ },
2264
+ );
2265
+ } catch (e) {
2266
+ console.warn("Failed to stop conversation run:", e);
2267
+ // Fallback: abort the local fetch so the UI at least stops.
2268
+ const abortController = state.activeStreamAbortController;
2269
+ if (abortController && !abortController.signal.aborted) {
2270
+ abortController.abort();
2271
+ }
2272
+ }
2273
+ };
2274
+
2212
2275
  const sendMessage = async (text) => {
2213
2276
  const messageText = (text || "").trim();
2214
2277
  if (!messageText || state.isStreaming) {
@@ -2228,12 +2291,16 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2228
2291
  localMessages.push(assistantMessage);
2229
2292
  state.activeMessages = localMessages;
2230
2293
  renderMessages(localMessages, true, { forceScrollBottom: true });
2231
- setStreaming(true);
2232
2294
  let conversationId = state.activeConversationId;
2295
+ const streamAbortController = new AbortController();
2296
+ state.activeStreamAbortController = streamAbortController;
2297
+ state.activeStreamRunId = null;
2298
+ setStreaming(true);
2233
2299
  try {
2234
2300
  if (!conversationId) {
2235
2301
  conversationId = await createConversation(messageText, { loadConversation: false });
2236
2302
  }
2303
+ state.activeStreamConversationId = conversationId;
2237
2304
  const streamConversationId = conversationId;
2238
2305
  const renderIfActiveConversation = (streaming) => {
2239
2306
  if (state.activeConversationId !== streamConversationId) {
@@ -2242,11 +2309,23 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2242
2309
  state.activeMessages = localMessages;
2243
2310
  renderMessages(localMessages, streaming);
2244
2311
  };
2312
+ const finalizeAssistantMessage = () => {
2313
+ assistantMessage._activeActivities = [];
2314
+ if (assistantMessage._currentTools.length > 0) {
2315
+ assistantMessage._sections.push({ type: "tools", content: assistantMessage._currentTools });
2316
+ assistantMessage._currentTools = [];
2317
+ }
2318
+ if (assistantMessage._currentText.length > 0) {
2319
+ assistantMessage._sections.push({ type: "text", content: assistantMessage._currentText });
2320
+ assistantMessage._currentText = "";
2321
+ }
2322
+ };
2245
2323
  const response = await fetch("/api/conversations/" + encodeURIComponent(conversationId) + "/messages", {
2246
2324
  method: "POST",
2247
2325
  credentials: "include",
2248
2326
  headers: { "Content-Type": "application/json", "x-csrf-token": state.csrfToken },
2249
- body: JSON.stringify({ message: messageText })
2327
+ body: JSON.stringify({ message: messageText }),
2328
+ signal: streamAbortController.signal,
2250
2329
  });
2251
2330
  if (!response.ok || !response.body) {
2252
2331
  throw new Error("Failed to stream response");
@@ -2273,6 +2352,10 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2273
2352
  assistantMessage._currentText += chunk;
2274
2353
  renderIfActiveConversation(true);
2275
2354
  }
2355
+ if (eventName === "run:started") {
2356
+ state.activeStreamRunId = typeof payload.runId === "string" ? payload.runId : null;
2357
+ setStreaming(state.isStreaming);
2358
+ }
2276
2359
  if (eventName === "tool:started") {
2277
2360
  const toolName = payload.tool || "tool";
2278
2361
  const startedActivity = addActiveActivityFromToolStart(
@@ -2418,19 +2501,14 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2418
2501
  renderIfActiveConversation(true);
2419
2502
  }
2420
2503
  if (eventName === "run:completed") {
2421
- assistantMessage._activeActivities = [];
2504
+ finalizeAssistantMessage();
2422
2505
  if (!assistantMessage.content || assistantMessage.content.length === 0) {
2423
2506
  assistantMessage.content = String(payload.result?.response || "");
2424
2507
  }
2425
- // Finalize sections: push any remaining tools and text
2426
- if (assistantMessage._currentTools.length > 0) {
2427
- assistantMessage._sections.push({ type: "tools", content: assistantMessage._currentTools });
2428
- assistantMessage._currentTools = [];
2429
- }
2430
- if (assistantMessage._currentText.length > 0) {
2431
- assistantMessage._sections.push({ type: "text", content: assistantMessage._currentText });
2432
- assistantMessage._currentText = "";
2433
- }
2508
+ renderIfActiveConversation(false);
2509
+ }
2510
+ if (eventName === "run:cancelled") {
2511
+ finalizeAssistantMessage();
2434
2512
  renderIfActiveConversation(false);
2435
2513
  }
2436
2514
  if (eventName === "run:error") {
@@ -2451,7 +2529,31 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2451
2529
  }
2452
2530
  await loadConversations();
2453
2531
  // Don't reload the conversation - we already have the latest state with tool chips
2532
+ } catch (error) {
2533
+ if (streamAbortController.signal.aborted) {
2534
+ assistantMessage._activeActivities = [];
2535
+ if (assistantMessage._currentTools.length > 0) {
2536
+ assistantMessage._sections.push({ type: "tools", content: assistantMessage._currentTools });
2537
+ assistantMessage._currentTools = [];
2538
+ }
2539
+ if (assistantMessage._currentText.length > 0) {
2540
+ assistantMessage._sections.push({ type: "text", content: assistantMessage._currentText });
2541
+ assistantMessage._currentText = "";
2542
+ }
2543
+ renderMessages(localMessages, false);
2544
+ } else {
2545
+ assistantMessage._activeActivities = [];
2546
+ assistantMessage._error = error instanceof Error ? error.message : "Something went wrong";
2547
+ renderMessages(localMessages, false);
2548
+ }
2454
2549
  } finally {
2550
+ if (state.activeStreamAbortController === streamAbortController) {
2551
+ state.activeStreamAbortController = null;
2552
+ }
2553
+ if (state.activeStreamConversationId === conversationId) {
2554
+ state.activeStreamConversationId = null;
2555
+ }
2556
+ state.activeStreamRunId = null;
2455
2557
  setStreaming(false);
2456
2558
  elements.prompt.focus();
2457
2559
  }
@@ -2554,6 +2656,13 @@ export const renderWebUiHtml = (options?: { agentName?: string }): string => {
2554
2656
 
2555
2657
  elements.composer.addEventListener("submit", async (event) => {
2556
2658
  event.preventDefault();
2659
+ if (state.isStreaming) {
2660
+ if (!state.activeStreamRunId) {
2661
+ return;
2662
+ }
2663
+ await stopActiveRun();
2664
+ return;
2665
+ }
2557
2666
  const value = elements.prompt.value;
2558
2667
  elements.prompt.value = "";
2559
2668
  autoResizePrompt();