u-foo 2.5.13 → 2.5.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "2.5.13",
3
+ "version": "2.5.14",
4
4
  "description": "Multi-Agent Workspace Protocol. Just add u. claude → uclaude, codex → ucodex.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://ufoo.dev",
@@ -26,6 +26,11 @@ const DEFAULT_MAX_NATIVE_TOOL_ERRORS = 5;
26
26
  // via UFOO_UCODE_MAX_TOKENS (positive integer).
27
27
  const DEFAULT_OPENAI_MAX_TOKENS = 131072;
28
28
  const DEFAULT_ANTHROPIC_MAX_TOKENS = 64000;
29
+ // Extended thinking is on by default for the anthropic transport; the budget
30
+ // stays well below the 64K max_tokens cap as the Messages API requires.
31
+ // UFOO_UCODE_THINKING_BUDGET_TOKENS overrides; 0 or a non-numeric value
32
+ // disables thinking (the payload then omits the field entirely).
33
+ const DEFAULT_ANTHROPIC_THINKING_BUDGET_TOKENS = 10000;
29
34
  // Prompt caching is GA on the current Messages API: cache_control blocks need
30
35
  // no anthropic-beta header. Kept as a constant so the marker shape stays in
31
36
  // one place (system block + last history message, 2 of the 4 allowed
@@ -59,6 +64,16 @@ function resolveMaxTokens(fallback) {
59
64
  return normalizePositiveInt(process.env.UFOO_UCODE_MAX_TOKENS, fallback);
60
65
  }
61
66
 
67
+ function resolveThinkingBudgetTokens() {
68
+ const raw = process.env.UFOO_UCODE_THINKING_BUDGET_TOKENS;
69
+ if (raw === undefined || raw === null || String(raw).trim() === "") {
70
+ return DEFAULT_ANTHROPIC_THINKING_BUDGET_TOKENS;
71
+ }
72
+ const parsed = Number.parseInt(String(raw), 10);
73
+ if (!Number.isFinite(parsed) || parsed <= 0) return 0;
74
+ return Math.floor(parsed);
75
+ }
76
+
62
77
  function toUsageInt(value) {
63
78
  const parsed = Number(value);
64
79
  if (!Number.isFinite(parsed) || parsed <= 0) return 0;
@@ -794,6 +809,13 @@ function normalizeAnthropicMessageContent(raw = []) {
794
809
  text: String(item.text || ""),
795
810
  };
796
811
  }
812
+ if (item.type === "thinking") {
813
+ return {
814
+ type: "thinking",
815
+ thinking: String(item.thinking || ""),
816
+ signature: String(item.signature || ""),
817
+ };
818
+ }
797
819
  if (item.type === "tool_use") {
798
820
  return {
799
821
  type: "tool_use",
@@ -882,6 +904,10 @@ async function runAnthropicTurn({
882
904
  tools: buildAnthropicToolSpecs(),
883
905
  stream: true,
884
906
  };
907
+ const thinkingBudget = resolveThinkingBudgetTokens();
908
+ if (thinkingBudget > 0) {
909
+ payload.thinking = { type: "enabled", budget_tokens: thinkingBudget };
910
+ }
885
911
  const systemText = String(systemPrompt || "").trim();
886
912
  if (systemText) {
887
913
  // Block form with a cache breakpoint; the system prompt is the most
@@ -994,6 +1020,7 @@ async function runAnthropicTurn({
994
1020
  order: index,
995
1021
  type: "thinking",
996
1022
  text: String(contentBlock.thinking || ""),
1023
+ signature: String(contentBlock.signature || ""),
997
1024
  });
998
1025
  } else if (contentBlock.type === "tool_use") {
999
1026
  blockMap.set(index, {
@@ -1058,6 +1085,16 @@ async function runAnthropicTurn({
1058
1085
  return;
1059
1086
  }
1060
1087
 
1088
+ if (delta.type === "signature_delta") {
1089
+ // Signed thinking blocks must be replayed verbatim on later turns
1090
+ // (tool-use continuation contract), so accumulate the signature
1091
+ // alongside the thinking text.
1092
+ current.type = "thinking";
1093
+ current.signature = `${String(current.signature || "")}${String(delta.signature || "")}`;
1094
+ blockMap.set(index, current);
1095
+ return;
1096
+ }
1097
+
1061
1098
  if (delta.type === "input_json_delta") {
1062
1099
  current.type = "tool_use";
1063
1100
  current.inputJson = `${String(current.inputJson || "")}${String(delta.partial_json || "")}`;
@@ -1069,8 +1106,17 @@ async function runAnthropicTurn({
1069
1106
  buildResult: () => {
1070
1107
  const assistantContent = Array.from(blockMap.values())
1071
1108
  .sort((a, b) => a.order - b.order)
1072
- .filter((item) => item.type !== "thinking")
1073
1109
  .map((item) => {
1110
+ if (item.type === "thinking") {
1111
+ // Kept (with signature) so tool-use continuation turns can
1112
+ // replay the thinking blocks the API requires.
1113
+ return {
1114
+ type: "thinking",
1115
+ thinking: String(item.text || ""),
1116
+ signature: String(item.signature || ""),
1117
+ };
1118
+ }
1119
+
1074
1120
  if (item.type === "text") {
1075
1121
  return {
1076
1122
  type: "text",
@@ -18,6 +18,27 @@ const { runInk } = require("../runInk");
18
18
  const fmt = require("../format");
19
19
  const { createMultilineInput } = require("./MultilineInput");
20
20
 
21
+ // Throttle for the live thinking-chain status line: rapid thinking_delta
22
+ // chunks would otherwise re-render the footer on every SSE event.
23
+ const THINKING_STATUS_THROTTLE_MS = 120;
24
+
25
+ // Log line kinds drive the color treatment of scrollback rows. Kind is pure
26
+ // presentation metadata — the stored text never changes.
27
+ const LOG_LINE_TEXT_PROPS = {
28
+ user: { color: "green", bold: true },
29
+ assistant: {},
30
+ system: { color: "gray", dimColor: true },
31
+ error: { color: "red" },
32
+ toolDetail: { color: "gray", dimColor: true },
33
+ bus: { color: "cyan" },
34
+ };
35
+
36
+ // Resolve a log line kind to ink <Text> props. Unknown/missing kinds (e.g.
37
+ // the banner, which already carries chalk ANSI styling) render uncolored.
38
+ function resolveLogLineTextProps(kind) {
39
+ return LOG_LINE_TEXT_PROPS[kind] || LOG_LINE_TEXT_PROPS.assistant;
40
+ }
41
+
21
42
  function createUcodeApp({ React, ink, props, interactive = true }) {
22
43
  const { useEffect, useState, useCallback, useRef } = React;
23
44
  const { Box, Text, useInput, useApp, useStdout } = ink;
@@ -79,6 +100,13 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
79
100
  const lineSeqRef = useRef(banner.length + 1);
80
101
  const mergeIdRef = useRef(0);
81
102
  const toolMergeScopeRef = useRef(0);
103
+ // thinkingTailRef accumulates raw thinking_delta text for the live
104
+ // status line; the collapsed tail is pushed through a throttled
105
+ // trailing flush (thinkingTimerRef) so fast streams don't re-render
106
+ // the footer on every chunk.
107
+ const thinkingTailRef = useRef("");
108
+ const thinkingFlushAtRef = useRef(0);
109
+ const thinkingTimerRef = useRef(null);
82
110
 
83
111
  const targetAgent = agentSelectionMode && selectedAgentIndex >= 0
84
112
  ? agents[selectedAgentIndex]
@@ -225,11 +253,11 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
225
253
  setSelectedAgentIndex(next);
226
254
  }, [agents, agentSelectionMode, selectedAgentIndex]);
227
255
 
228
- const appendLogLine = useCallback((text) => {
256
+ const appendLogLine = useCallback((text, kind = "assistant") => {
229
257
  setLogLines((prev) => {
230
258
  const id = `l-${lineSeqRef.current}`;
231
259
  lineSeqRef.current += 1;
232
- const next = prev.concat([{ id, text: String(text || "") }]);
260
+ const next = prev.concat([{ id, text: String(text || ""), kind }]);
233
261
  return next.length > 1000 ? next.slice(-1000) : next;
234
262
  });
235
263
  }, []);
@@ -273,7 +301,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
273
301
  });
274
302
  }, []);
275
303
 
276
- const appendLogText = useCallback((text) => {
304
+ const appendLogText = useCallback((text, kind = "assistant") => {
277
305
  // Multi-line text → split into separate log entries so <Static> keys
278
306
  // stay stable when streaming arrives line-by-line. Always promote any
279
307
  // in-flight tool group first so it freezes above the new text.
@@ -281,7 +309,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
281
309
  if (!raw) return;
282
310
  flushActiveMerge();
283
311
  const lines = raw.split(/\r?\n/);
284
- for (const line of lines) appendLogLine(line);
312
+ for (const line of lines) appendLogLine(line, kind);
285
313
  }, [appendLogLine, flushActiveMerge]);
286
314
 
287
315
  const expandLastMerge = useCallback(() => {
@@ -299,7 +327,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
299
327
  const lines = fmt.buildMergedToolExpandedLines(candidate.entries);
300
328
  for (let i = 0; i < lines.length; i += 1) {
301
329
  const branch = i === lines.length - 1 ? "└" : "│";
302
- appendLogLine(`${branch} ${lines[i]}`);
330
+ appendLogLine(`${branch} ${lines[i]}`, "toolDetail");
303
331
  }
304
332
  candidate.expanded = true;
305
333
  if (active && active.id === candidate.id) setActiveMerge(null);
@@ -315,7 +343,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
315
343
  if (!normalized) return;
316
344
  toolMergeScopeRef.current += 1;
317
345
  flushActiveMerge();
318
- appendLogLine(`› ${normalized}`);
346
+ appendLogLine(`› ${normalized}`, "user");
319
347
 
320
348
  const runtimeWorkspace = String(
321
349
  (props.state && props.state.workspaceRoot) || props.workspaceRoot || process.cwd()
@@ -325,7 +353,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
325
353
  try {
326
354
  result = props.runSingleCommand(normalized, runtimeWorkspace);
327
355
  } catch (err) {
328
- appendLogText(`Error: ${err && err.message ? err.message : "command parse failed"}`);
356
+ appendLogText(`Error: ${err && err.message ? err.message : "command parse failed"}`, "error");
329
357
  return;
330
358
  }
331
359
  if (!result || typeof result !== "object") return;
@@ -350,26 +378,26 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
350
378
  workspaceRoot: runtimeWorkspace,
351
379
  onMessageReceived: (msg) => {
352
380
  const nickname = extractAgentNickname(msg && msg.from) || (msg && msg.from) || "bus";
353
- appendLogText(`${nickname}: ${(msg && msg.task) || ""}`);
381
+ appendLogText(`${nickname}: ${(msg && msg.task) || ""}`, "bus");
354
382
  },
355
383
  });
356
384
  if (!ubusResult || !ubusResult.ok) {
357
- appendLogText(`Error: ${(ubusResult && ubusResult.error) || "ubus failed"}`);
385
+ appendLogText(`Error: ${(ubusResult && ubusResult.error) || "ubus failed"}`, "error");
358
386
  return;
359
387
  }
360
388
  const exchanges = Array.isArray(ubusResult.messageExchanges) ? ubusResult.messageExchanges : [];
361
389
  if (exchanges.length > 0) {
362
390
  for (const exchange of exchanges) {
363
391
  const nickname = extractAgentNickname(exchange && exchange.from) || (exchange && exchange.from) || "bus";
364
- appendLogText(`@${nickname} ${(exchange && exchange.reply) || ""}`);
392
+ appendLogText(`@${nickname} ${(exchange && exchange.reply) || ""}`, "bus");
365
393
  }
366
394
  } else if (Number(ubusResult.handled) === 0) {
367
- appendLogText("ubus: no pending messages.");
395
+ appendLogText("ubus: no pending messages.", "system");
368
396
  }
369
397
  if (typeof props.persistSessionState === "function") {
370
398
  const persisted = props.persistSessionState(props.state);
371
399
  if (!persisted || persisted.ok === false) {
372
- appendLogText(`Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${(persisted && persisted.error) || "unknown error"}`);
400
+ appendLogText(`Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${(persisted && persisted.error) || "unknown error"}`, "error");
373
401
  }
374
402
  }
375
403
  } finally {
@@ -379,15 +407,15 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
379
407
  }
380
408
  case "resume": {
381
409
  if (typeof props.resumeSessionState !== "function") {
382
- appendLogText("Error: resume unsupported");
410
+ appendLogText("Error: resume unsupported", "error");
383
411
  return;
384
412
  }
385
413
  const resumed = props.resumeSessionState(props.state, result.sessionId, runtimeWorkspace);
386
414
  if (!resumed || !resumed.ok) {
387
- appendLogText(`Error: ${(resumed && resumed.error) || "resume failed"}`);
415
+ appendLogText(`Error: ${(resumed && resumed.error) || "resume failed"}`, "error");
388
416
  return;
389
417
  }
390
- appendLogText(`Resumed session ${resumed.sessionId} (${resumed.restoredMessages} messages).`);
418
+ appendLogText(`Resumed session ${resumed.sessionId} (${resumed.restoredMessages} messages).`, "system");
391
419
  return;
392
420
  }
393
421
  case "tool": {
@@ -413,7 +441,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
413
441
  backgroundTasksRef.current.set(jobId, taskRecord);
414
442
  bumpBackground();
415
443
  setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
416
- appendLogText(`[${jobId}] started in background.`);
444
+ appendLogText(`[${jobId}] started in background.`, "system");
417
445
 
418
446
  const bgState = {
419
447
  workspaceRoot: props.state && props.state.workspaceRoot,
@@ -434,13 +462,13 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
434
462
  taskRecord.finishedAt = Date.now();
435
463
  taskRecord.summary = String(props.formatNlResult(nlResult, false) || "").trim();
436
464
  const title = taskRecord.status === "done" ? "done" : "failed";
437
- appendLogText(`[${jobId}] ${title}: ${taskRecord.summary || "no summary"}`);
465
+ appendLogText(`[${jobId}] ${title}: ${taskRecord.summary || "no summary"}`, "system");
438
466
  })
439
467
  .catch((err) => {
440
468
  taskRecord.status = "failed";
441
469
  taskRecord.finishedAt = Date.now();
442
470
  taskRecord.summary = err && err.message ? String(err.message) : "background task failed";
443
- appendLogText(`[${jobId}] failed: ${taskRecord.summary}`);
471
+ appendLogText(`[${jobId}] failed: ${taskRecord.summary}`, "system");
444
472
  })
445
473
  .finally(() => {
446
474
  bumpBackground();
@@ -458,6 +486,16 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
458
486
  showTimer: true,
459
487
  startedAt,
460
488
  });
489
+ const cancelThinkingFlush = () => {
490
+ if (thinkingTimerRef.current) {
491
+ clearTimeout(thinkingTimerRef.current);
492
+ thinkingTimerRef.current = null;
493
+ }
494
+ };
495
+ const flushThinkingStatus = () => {
496
+ thinkingFlushAtRef.current = Date.now();
497
+ setNlStatus(collapseThinkingTail(thinkingTailRef.current) || "Thinking...");
498
+ };
461
499
  setNlStatus("Waiting for model...");
462
500
  let streamBuf = "";
463
501
  let sawStreamText = false;
@@ -469,10 +507,28 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
469
507
  signal: abortController.signal,
470
508
  onPhase: (event) => {
471
509
  if (!event || typeof event !== "object") return;
472
- if (event.type === "request_start") setNlStatus("Waiting for model...");
473
- else if (event.type === "thinking_delta") setNlStatus("Thinking...");
474
- else if (event.type === "text_delta") setNlStatus("Generating response...");
475
- else if (event.type === "tool_request") {
510
+ if (event.type === "request_start") {
511
+ cancelThinkingFlush();
512
+ setNlStatus("Waiting for model...");
513
+ } else if (event.type === "thinking_delta") {
514
+ thinkingTailRef.current += String(event.text || "");
515
+ const elapsed = Date.now() - thinkingFlushAtRef.current;
516
+ if (elapsed >= THINKING_STATUS_THROTTLE_MS) {
517
+ cancelThinkingFlush();
518
+ flushThinkingStatus();
519
+ } else if (!thinkingTimerRef.current) {
520
+ // Trailing flush guarantees the final tail lands even
521
+ // when the stream ends inside a throttle window.
522
+ thinkingTimerRef.current = setTimeout(() => {
523
+ thinkingTimerRef.current = null;
524
+ flushThinkingStatus();
525
+ }, THINKING_STATUS_THROTTLE_MS - elapsed);
526
+ }
527
+ } else if (event.type === "text_delta") {
528
+ cancelThinkingFlush();
529
+ setNlStatus("Generating response...");
530
+ } else if (event.type === "tool_request") {
531
+ cancelThinkingFlush();
476
532
  const label = fmt.TOOL_LABELS[String(event.name || "").toLowerCase()] ||
477
533
  `Calling ${event.name}`;
478
534
  setNlStatus(`${label}...`);
@@ -509,10 +565,12 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
509
565
  },
510
566
  });
511
567
  } catch (err) {
512
- appendLogText(`Error: ${err && err.message ? err.message : "agent loop failed"}`);
568
+ appendLogText(`Error: ${err && err.message ? err.message : "agent loop failed"}`, "error");
513
569
  return;
514
570
  } finally {
515
571
  pendingTaskRef.current = null;
572
+ cancelThinkingFlush();
573
+ thinkingTailRef.current = "";
516
574
  setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
517
575
  }
518
576
  if (streamBuf) {
@@ -534,7 +592,8 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
534
592
  const persisted = props.persistSessionState(props.state);
535
593
  if (persisted && persisted.ok === false) {
536
594
  appendLogText(
537
- `Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${persisted.error || "unknown error"}`
595
+ `Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${persisted.error || "unknown error"}`,
596
+ "error"
538
597
  );
539
598
  }
540
599
  } catch {
@@ -579,7 +638,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
579
638
  signal: abortController.signal,
580
639
  onMessageReceived: (msg) => {
581
640
  const nickname = extractAgentNickname(msg && msg.from) || (msg && msg.from) || "bus";
582
- appendLogText(`${nickname}: ${(msg && msg.task) || ""}`);
641
+ appendLogText(`${nickname}: ${(msg && msg.task) || ""}`, "bus");
583
642
  setStatus({
584
643
  message: "Working on task...",
585
644
  type: "thinking",
@@ -593,7 +652,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
593
652
  const nextError = String((ubusResult && ubusResult.error) || "ubus failed");
594
653
  if (nextError !== autoBusErrorRef.current) {
595
654
  autoBusErrorRef.current = nextError;
596
- appendLogText(`Error: ${nextError}`);
655
+ appendLogText(`Error: ${nextError}`, "error");
597
656
  }
598
657
  return;
599
658
  }
@@ -602,12 +661,12 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
602
661
  const exchanges = Array.isArray(ubusResult.messageExchanges) ? ubusResult.messageExchanges : [];
603
662
  for (const exchange of exchanges) {
604
663
  const nickname = extractAgentNickname(exchange && exchange.from) || (exchange && exchange.from) || "bus";
605
- appendLogText(`@${nickname} ${(exchange && exchange.reply) || ""}`);
664
+ appendLogText(`@${nickname} ${(exchange && exchange.reply) || ""}`, "bus");
606
665
  }
607
666
  if (Number(ubusResult.handled) > 0 && typeof props.persistSessionState === "function") {
608
667
  const persisted = props.persistSessionState(props.state);
609
668
  if (!persisted || persisted.ok === false) {
610
- appendLogText(`Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${(persisted && persisted.error) || "unknown error"}`);
669
+ appendLogText(`Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${(persisted && persisted.error) || "unknown error"}`, "error");
611
670
  }
612
671
  }
613
672
  } finally {
@@ -627,7 +686,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
627
686
  autoBusQueuedRef.current = true;
628
687
  runChainRef.current = runChainRef.current
629
688
  .then(() => runAutoBusOnce())
630
- .catch((err) => appendLogText(`Error: ${err && err.message ? err.message : "ubus failed"}`))
689
+ .catch((err) => appendLogText(`Error: ${err && err.message ? err.message : "ubus failed"}`, "error"))
631
690
  .finally(() => {
632
691
  autoBusQueuedRef.current = false;
633
692
  });
@@ -651,7 +710,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
651
710
  // Serialize executions so streaming tasks don't interleave.
652
711
  runChainRef.current = runChainRef.current
653
712
  .then(() => executeLine(value))
654
- .catch((err) => appendLogText(`Error: ${err && err.message ? err.message : err}`));
713
+ .catch((err) => appendLogText(`Error: ${err && err.message ? err.message : err}`, "error"));
655
714
  }, [draft, executeLine, appendLogText]);
656
715
 
657
716
  useEffect(() => {
@@ -690,7 +749,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
690
749
  return h(Box, { flexDirection: "column", width: "100%" },
691
750
  h(Box, { flexDirection: "column", width: "100%" },
692
751
  ...logLines.map((item) =>
693
- h(Text, { key: item.id }, item.text || " ")
752
+ h(Text, { key: item.id, ...resolveLogLineTextProps(item.kind) }, item.text || " ")
694
753
  )
695
754
  ),
696
755
  activeMerge ? h(Box, null,
@@ -716,7 +775,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
716
775
  const pending = pendingTaskRef.current;
717
776
  if (pending && pending.abortController && !pending.abortController.signal.aborted) {
718
777
  try { pending.abortController.abort(); } catch { /* ignore */ }
719
- appendLogLine("⚙ Cancellation requested. Stopping the current task...");
778
+ appendLogLine("⚙ Cancellation requested. Stopping the current task...", "system");
720
779
  setStatus({
721
780
  message: "Cancelling...",
722
781
  type: "waiting",
@@ -813,7 +872,7 @@ function runUcodeInkTui(props = {}) {
813
872
  });
814
873
  }
815
874
 
816
- module.exports = { runUcodeInkTui, createUcodeApp, computeStatusText };
875
+ module.exports = { runUcodeInkTui, createUcodeApp, computeStatusText, collapseThinkingTail, resolveLogLineTextProps };
817
876
 
818
877
  function inferStatusType(text = "", requestedType = "") {
819
878
  const type = String(requestedType || "").trim().toLowerCase();
@@ -837,6 +896,14 @@ function inferStatusType(text = "", requestedType = "") {
837
896
  * combination while a task is in flight, mirroring updateStatus() in the
838
897
  * blessed implementation.
839
898
  */
899
+ function collapseThinkingTail(text, maxChars = 80) {
900
+ const collapsed = String(text || "").replace(/\s+/g, " ").trim();
901
+ const parsed = Number(maxChars);
902
+ const limit = Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 80;
903
+ if (collapsed.length <= limit) return collapsed;
904
+ return collapsed.slice(collapsed.length - limit);
905
+ }
906
+
840
907
  function computeStatusText(status, spinnerTick, backgroundSuffix = "") {
841
908
  const message = String((status && status.message) || "");
842
909
  const suffix = String(backgroundSuffix || "");