u-foo 2.5.14 → 2.5.15

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 (39) hide show
  1. package/package.json +1 -1
  2. package/src/agents/prompts/native/environment.js +20 -8
  3. package/src/code/agent.js +339 -24
  4. package/src/code/commands.js +61 -0
  5. package/src/code/context/artifactGc.js +292 -0
  6. package/src/code/context/artifactIndex.js +161 -0
  7. package/src/code/context/artifacts.js +183 -0
  8. package/src/code/context/assembler.js +698 -0
  9. package/src/code/context/executionSegment.js +314 -0
  10. package/src/code/context/featureFlag.js +13 -0
  11. package/src/code/context/index.js +18 -0
  12. package/src/code/context/projectSnapshot.js +201 -0
  13. package/src/code/context/promptLayers.js +159 -0
  14. package/src/code/context/reducers.js +328 -0
  15. package/src/code/context/stableJson.js +29 -0
  16. package/src/code/context/stateCommit.js +412 -0
  17. package/src/code/context/transcript.js +182 -0
  18. package/src/code/context/transcriptSync.js +106 -0
  19. package/src/code/context/workingSet.js +323 -0
  20. package/src/code/dispatch.js +4 -1
  21. package/src/code/index.js +6 -0
  22. package/src/code/modelCommand.js +87 -0
  23. package/src/code/nativeRunner.js +140 -30
  24. package/src/code/repl.js +36 -32
  25. package/src/code/sessionStore.js +227 -15
  26. package/src/code/skills/index.js +10 -0
  27. package/src/code/skills/injection.js +65 -3
  28. package/src/code/skills/loader.js +21 -0
  29. package/src/code/skills/manifest.js +87 -0
  30. package/src/code/skills/render.js +15 -1
  31. package/src/code/taskDecomposer.js +32 -2
  32. package/src/code/tools/artifactRead.js +40 -0
  33. package/src/code/tui.js +2 -0
  34. package/src/code/usageStore.js +15 -0
  35. package/src/ui/format/index.js +260 -44
  36. package/src/ui/format/markdownRenderer.js +215 -72
  37. package/src/ui/ink/ChatApp.js +39 -8
  38. package/src/ui/ink/UcodeApp.js +313 -27
  39. package/src/ui/ink/chatLogModel.js +102 -21
@@ -29,10 +29,17 @@ const LOG_LINE_TEXT_PROPS = {
29
29
  assistant: {},
30
30
  system: { color: "gray", dimColor: true },
31
31
  error: { color: "red" },
32
+ tool: {},
32
33
  toolDetail: { color: "gray", dimColor: true },
33
34
  bus: { color: "cyan" },
34
35
  };
35
36
 
37
+ // Only assistant prose gets markdown. Error rows are app-generated
38
+ // (`Error: …`) and already painted red via resolveLogLineTextProps — running
39
+ // them through the MD Error: line rule would wrap chalk ANSI and break the
40
+ // plain-text body the Ink color prop expects.
41
+ const MARKDOWN_LOG_KINDS = new Set(["assistant"]);
42
+
36
43
  // Resolve a log line kind to ink <Text> props. Unknown/missing kinds (e.g.
37
44
  // the banner, which already carries chalk ANSI styling) render uncolored.
38
45
  function resolveLogLineTextProps(kind) {
@@ -95,6 +102,10 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
95
102
  // visual row (i.e. moveCursorVertically returned moved=false).
96
103
  const [inputHistory, setInputHistory] = useState([]);
97
104
  const [historyIndex, setHistoryIndex] = useState(0);
105
+ const [completionIndex, setCompletionIndex] = useState(0);
106
+ const [completionWindowStart, setCompletionWindowStart] = useState(0);
107
+ const [completionSuppressedDraft, setCompletionSuppressedDraft] = useState(null);
108
+ const POPUP_PAGE_SIZE = 8;
98
109
  const { exit } = useApp();
99
110
  const { stdout } = useStdout();
100
111
  const lineSeqRef = useRef(banner.length + 1);
@@ -107,6 +118,9 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
107
118
  const thinkingTailRef = useRef("");
108
119
  const thinkingFlushAtRef = useRef(0);
109
120
  const thinkingTimerRef = useRef(null);
121
+ // Persist fence/open-code state across streamed assistant log lines so
122
+ // ``` blocks stay styled even when deltas arrive one line at a time.
123
+ const markdownStateRef = useRef({ inCodeBlock: false });
110
124
 
111
125
  const targetAgent = agentSelectionMode && selectedAgentIndex >= 0
112
126
  ? agents[selectedAgentIndex]
@@ -208,6 +222,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
208
222
  if (transition.moved) {
209
223
  setHistoryIndex(transition.nextHistoryIndex);
210
224
  setDraft(transition.nextValue);
225
+ setCompletionSuppressedDraft(transition.nextValue || null);
211
226
  setDraftVersion((v) => v + 1);
212
227
  return;
213
228
  }
@@ -224,22 +239,31 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
224
239
  }
225
240
  }, [inputHistory, historyIndex, agents, agentSelectionMode, selectedAgentIndex]);
226
241
 
227
- const onArrowUpAtStart = useCallback(() => {
228
- // History first: if we're already on the top visual row, walk back
229
- // through the recent history before doing anything else.
242
+ const onArrowUpAtStart = useCallback((currentValue) => {
243
+ // While @-targeting an agent with an empty draft, Up clears the
244
+ // selection before walking input history otherwise history eats the
245
+ // key and the ›@agent prefix sticks.
246
+ const inputValue = currentValue != null ? currentValue : draft;
247
+ if (fmt.shouldClearAgentSelectionOnUp({
248
+ agentSelectionMode,
249
+ inputValue,
250
+ })) {
251
+ setAgentSelectionMode(false);
252
+ setSelectedAgentIndex(-1);
253
+ return;
254
+ }
255
+ // History: if we're already on the top visual row, walk back through
256
+ // the recent history before doing anything else.
230
257
  if (inputHistory.length > 0) {
231
258
  const nextIndex = Math.max(0, historyIndex - 1);
232
259
  if (nextIndex !== historyIndex || draft !== inputHistory[nextIndex]) {
233
260
  setHistoryIndex(nextIndex);
234
- setDraft(inputHistory[nextIndex] || "");
261
+ const nextValue = inputHistory[nextIndex] || "";
262
+ setDraft(nextValue);
263
+ setCompletionSuppressedDraft(nextValue || null);
235
264
  setDraftVersion((v) => v + 1);
236
- return;
237
265
  }
238
266
  }
239
- if (agentSelectionMode) {
240
- setAgentSelectionMode(false);
241
- setSelectedAgentIndex(-1);
242
- }
243
267
  }, [inputHistory, historyIndex, draft, agentSelectionMode]);
244
268
 
245
269
  const onArrowSideAtEmpty = useCallback((direction) => {
@@ -253,11 +277,73 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
253
277
  setSelectedAgentIndex(next);
254
278
  }, [agents, agentSelectionMode, selectedAgentIndex]);
255
279
 
280
+ const { UCODE_COMMAND_REGISTRY, UCODE_COMMAND_TREE } = require("../../code/commands");
281
+ const { listSessionSummaries } = require("../../code/sessionStore");
282
+ const { suggestUcodeModels, applyUcodeModelCommand } = require("../../code/modelCommand");
283
+ let resumeSessions = [];
284
+ try {
285
+ resumeSessions = listSessionSummaries(props.workspaceRoot || process.cwd(), { limit: 40 });
286
+ } catch {
287
+ resumeSessions = [];
288
+ }
289
+ const modelSuggestions = suggestUcodeModels(props.state || {});
290
+
291
+ const completions = fmt.buildCompletions({
292
+ text: draft,
293
+ agents: agents.map((a) => String((a && (a.fullId || a.id || a.nickname)) || "")).filter(Boolean),
294
+ agentLabels: agents.map((a) => getAgentLabel(a)),
295
+ commands: UCODE_COMMAND_REGISTRY,
296
+ commandTree: UCODE_COMMAND_TREE,
297
+ argumentLists: {
298
+ "/resume": resumeSessions,
299
+ "/model": modelSuggestions,
300
+ },
301
+ limit: 20,
302
+ });
303
+ const completionsOpen = completions.length > 0 && draft !== completionSuppressedDraft;
304
+
305
+ useEffect(() => {
306
+ if (completions.length === 0) {
307
+ if (completionIndex !== 0) setCompletionIndex(0);
308
+ if (completionWindowStart !== 0) setCompletionWindowStart(0);
309
+ } else if (completionIndex >= completions.length) {
310
+ setCompletionIndex(completions.length - 1);
311
+ setCompletionWindowStart(Math.max(0, completions.length - POPUP_PAGE_SIZE));
312
+ }
313
+ }, [completions.length, completionIndex, completionWindowStart]);
314
+
315
+ const acceptCompletion = useCallback(() => {
316
+ if (!completionsOpen) return false;
317
+ const item = completions[Math.max(0, Math.min(completions.length - 1, completionIndex))];
318
+ if (item) {
319
+ setDraft(item.replace);
320
+ setCompletionSuppressedDraft(item.hasChildren ? null : item.replace);
321
+ setDraftVersion((v) => v + 1);
322
+ }
323
+ setCompletionIndex(0);
324
+ return true;
325
+ }, [completionsOpen, completions, completionIndex]);
326
+
256
327
  const appendLogLine = useCallback((text, kind = "assistant") => {
328
+ const raw = String(text == null ? "" : text);
329
+ let renderedLines = [raw];
330
+ if (MARKDOWN_LOG_KINDS.has(kind)) {
331
+ try {
332
+ renderedLines = fmt.renderLogLinesWithMarkdownAnsi(raw, markdownStateRef.current);
333
+ if (!Array.isArray(renderedLines) || renderedLines.length === 0) {
334
+ renderedLines = [raw];
335
+ }
336
+ } catch {
337
+ renderedLines = [raw];
338
+ }
339
+ }
257
340
  setLogLines((prev) => {
258
- const id = `l-${lineSeqRef.current}`;
259
- lineSeqRef.current += 1;
260
- const next = prev.concat([{ id, text: String(text || ""), kind }]);
341
+ const next = prev.slice();
342
+ for (const line of renderedLines) {
343
+ const id = `l-${lineSeqRef.current}`;
344
+ lineSeqRef.current += 1;
345
+ next.push({ id, text: String(line || ""), kind });
346
+ }
261
347
  return next.length > 1000 ? next.slice(-1000) : next;
262
348
  });
263
349
  }, []);
@@ -274,7 +360,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
274
360
  const flushActiveMerge = useCallback(() => {
275
361
  setActiveMerge((current) => {
276
362
  if (!current) return null;
277
- appendLogLine(renderMergeText(current));
363
+ appendLogLine(renderMergeText(current), "tool");
278
364
  return null;
279
365
  });
280
366
  }, [appendLogLine, renderMergeText]);
@@ -285,7 +371,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
285
371
  const resObj = payload && typeof payload === "object" ? payload : (entry && entry.result) || {};
286
372
  const phase = String((entry && entry.phase) || "").trim().toLowerCase();
287
373
  const isError = phase === "error" || resObj.ok === false;
288
- const detail = tool === "bash" ? fmt.normalizeBashToolCommand(entry && entry.args, resObj) : "";
374
+ const detail = fmt.normalizeToolLogDetail(tool, entry && entry.args, resObj);
289
375
  const errorText = String((entry && entry.error) || resObj.error || "").trim();
290
376
  const toolEntry = fmt.normalizeToolMergeEntry({ tool, detail, isError, errorText });
291
377
 
@@ -370,6 +456,37 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
370
456
  case "error":
371
457
  appendLogText(result.output || "");
372
458
  return;
459
+ case "status": {
460
+ try {
461
+ const { summarizeSessionUsage, formatSessionUsageStatus } = require("../../code/usageStore");
462
+ const usageSummary = summarizeSessionUsage({
463
+ workspaceRoot: runtimeWorkspace,
464
+ sessionId: (props.state && props.state.sessionId) || "",
465
+ });
466
+ appendLogText(formatSessionUsageStatus(usageSummary), "system");
467
+ } catch (err) {
468
+ appendLogText(`Error: ${err && err.message ? err.message : "status failed"}`, "error");
469
+ }
470
+ return;
471
+ }
472
+ case "model": {
473
+ const applied = applyUcodeModelCommand(props.state || {}, result);
474
+ appendLogText(applied.output || "", applied.ok ? "system" : "error");
475
+ if (applied.ok && result.action === "set" && typeof props.persistSessionState === "function") {
476
+ try {
477
+ const persisted = props.persistSessionState(props.state);
478
+ if (persisted && persisted.ok === false) {
479
+ appendLogText(
480
+ `Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${persisted.error || "unknown error"}`,
481
+ "error"
482
+ );
483
+ }
484
+ } catch {
485
+ // persist is best-effort after a successful model switch
486
+ }
487
+ }
488
+ return;
489
+ }
373
490
  case "ubus": {
374
491
  setStatus({ message: "Checking bus messages...", type: "typing", showTimer: false, startedAt: Date.now() });
375
492
  try {
@@ -415,7 +532,31 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
415
532
  appendLogText(`Error: ${(resumed && resumed.error) || "resume failed"}`, "error");
416
533
  return;
417
534
  }
418
- appendLogText(`Resumed session ${resumed.sessionId} (${resumed.restoredMessages} messages).`, "system");
535
+ // Rebuild the visible log from the restored session transcript so
536
+ // the user sees prior turns instead of only a status toast.
537
+ markdownStateRef.current = { inCodeBlock: false };
538
+ const history = fmt.buildUcodeSessionLogEntries(
539
+ Array.isArray(props.state && props.state.nlMessages) ? props.state.nlMessages : [],
540
+ { markdownState: markdownStateRef.current, idPrefix: "h", startSeq: 0 },
541
+ );
542
+ const bannerEntries = banner.concat([""]).map((line, idx) => ({
543
+ id: `b-${idx}`,
544
+ text: line,
545
+ }));
546
+ const notice = {
547
+ id: `h-resume-${Date.now().toString(36)}`,
548
+ text: `Resumed session ${resumed.sessionId} (${resumed.restoredMessages} messages).`,
549
+ kind: "system",
550
+ };
551
+ const nextLines = bannerEntries.concat(history.entries).concat([notice]);
552
+ setLogLines(nextLines.length > 1000 ? nextLines.slice(-1000) : nextLines);
553
+ lineSeqRef.current = Math.max(
554
+ bannerEntries.length + 1,
555
+ Number(history.nextSeq) || 0,
556
+ nextLines.length,
557
+ );
558
+ setActiveMerge(null);
559
+ lastMergeRef.current = null;
419
560
  return;
420
561
  }
421
562
  case "tool": {
@@ -739,18 +880,107 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
739
880
 
740
881
  const statusText = useMemoStatusText(React, status, spinnerTick, getBackgroundSuffix());
741
882
 
742
- // Top-level only catches Ctrl+C and Ctrl+O (expand last tool group);
743
- // the editor handles all text editing.
883
+ // Top-level catches Ctrl+C / Ctrl+O, plus completion popup navigation
884
+ // while a slash/agent menu is open.
744
885
  useInput((input, key) => {
745
886
  if (key.ctrl && input === "c") { exit(); return; }
746
887
  if (key.ctrl && input === "o") { expandLastMerge(); return; }
888
+ if (!completionsOpen) return;
889
+ if (key.upArrow) {
890
+ setCompletionIndex((i) => {
891
+ const next = (i - 1 + completions.length) % completions.length;
892
+ setCompletionWindowStart((ws) => {
893
+ if (next < ws) return next;
894
+ if (next === completions.length - 1) {
895
+ return Math.max(0, completions.length - POPUP_PAGE_SIZE);
896
+ }
897
+ return ws;
898
+ });
899
+ return next;
900
+ });
901
+ return;
902
+ }
903
+ if (key.downArrow) {
904
+ setCompletionIndex((i) => {
905
+ const next = (i + 1) % completions.length;
906
+ setCompletionWindowStart((ws) => {
907
+ if (next === 0) return 0;
908
+ if (next >= ws + POPUP_PAGE_SIZE) return next - POPUP_PAGE_SIZE + 1;
909
+ return ws;
910
+ });
911
+ return next;
912
+ });
913
+ return;
914
+ }
915
+ if (key.return) {
916
+ // Leaf completions (e.g. /resume <session>) run immediately on Enter.
917
+ // Parents with children only fill the draft so the next menu can open.
918
+ const item = completions[Math.max(0, Math.min(completions.length - 1, completionIndex))];
919
+ if (item && !item.hasChildren) {
920
+ const cmd = String(item.replace || "").trim();
921
+ setCompletionIndex(0);
922
+ setCompletionSuppressedDraft(null);
923
+ if (cmd) submit(cmd);
924
+ return;
925
+ }
926
+ acceptCompletion();
927
+ return;
928
+ }
929
+ if (key.tab) {
930
+ acceptCompletion();
931
+ return;
932
+ }
933
+ if (key.escape) {
934
+ setCompletionSuppressedDraft(null);
935
+ setDraft("");
936
+ setDraftVersion((v) => v + 1);
937
+ }
747
938
  }, { isActive: interactive });
748
939
 
749
940
  return h(Box, { flexDirection: "column", width: "100%" },
750
941
  h(Box, { flexDirection: "column", width: "100%" },
751
- ...logLines.map((item) =>
752
- h(Text, { key: item.id, ...resolveLogLineTextProps(item.kind) }, item.text || " ")
753
- )
942
+ ...(() => {
943
+ // Re-render raw markdown at paint time so leftover ** / ### from
944
+ // older append paths or nested `**code**` patterns still resolve.
945
+ const mdState = { inCodeBlock: false };
946
+ return logLines.map((item, idx) => {
947
+ let text = item.text || " ";
948
+ if (MARKDOWN_LOG_KINDS.has(item.kind) && /(?:\*\*|__|^\s*#{1,6}\s|^\s*`{3})/m.test(text)) {
949
+ try {
950
+ const rendered = fmt.renderLogLinesWithMarkdownAnsi(text, mdState);
951
+ if (Array.isArray(rendered) && rendered.length > 0) {
952
+ text = rendered.length === 1 ? rendered[0] : rendered.join("\n");
953
+ }
954
+ } catch {
955
+ // keep original
956
+ }
957
+ } else if (MARKDOWN_LOG_KINDS.has(item.kind) && mdState.inCodeBlock) {
958
+ try {
959
+ const rendered = fmt.renderLogLinesWithMarkdownAnsi(text, mdState);
960
+ if (Array.isArray(rendered) && rendered[0] != null) text = rendered[0];
961
+ } catch {
962
+ // keep original
963
+ }
964
+ }
965
+ const textEl = h(Text, { ...resolveLogLineTextProps(item.kind) }, text || " ");
966
+ // Give user turns a blank line above/below so › prompts don't
967
+ // sit flush against system/tool rows. Multi-line user blocks
968
+ // only pad the outer edges.
969
+ if (item.kind === "user") {
970
+ const prev = logLines[idx - 1];
971
+ const next = logLines[idx + 1];
972
+ const marginTop = !prev || prev.kind !== "user" ? 1 : 0;
973
+ const marginBottom = !next || next.kind !== "user" ? 1 : 0;
974
+ return h(Box, {
975
+ key: item.id,
976
+ width: "100%",
977
+ marginTop,
978
+ marginBottom,
979
+ }, textEl);
980
+ }
981
+ return h(Text, { key: item.id, ...resolveLogLineTextProps(item.kind) }, text || " ");
982
+ });
983
+ })()
754
984
  ),
755
985
  activeMerge ? h(Box, null,
756
986
  h(Text, { color: activeMerge.entries.some((e) => e.isError) ? "red" : "cyan" },
@@ -762,13 +992,55 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
762
992
  h(Box, { flexGrow: 1 }),
763
993
  h(Text, { color: "gray" }, `v${fmt.UCODE_VERSION}`),
764
994
  ),
995
+ completionsOpen ? (() => {
996
+ const start = Math.min(completionWindowStart, Math.max(0, completions.length - POPUP_PAGE_SIZE));
997
+ const end = Math.min(completions.length, start + POPUP_PAGE_SIZE);
998
+ const visible = completions.slice(start, end);
999
+ const cols = Math.max(8, size.cols || 80);
1000
+ // Frame the popup with a top rule; MultilineInput's borderTop is the
1001
+ // matching bottom rule, so we intentionally omit a trailing ─ here.
1002
+ return h(Box, { flexDirection: "column", width: "100%" },
1003
+ h(Text, { color: "gray" }, "─".repeat(cols)),
1004
+ ...visible.map((s, idxInWindow) => {
1005
+ const idx = start + idxInWindow;
1006
+ const selected = idx === completionIndex;
1007
+ // Keep label+description in one Text. Splitting into sibling
1008
+ // Text nodes with wrap:"truncate" lets Yoga shrink the label
1009
+ // and mid-cut commands (e.g. "/help" → "/he p").
1010
+ const line = s.description
1011
+ ? `${s.label} ${s.description}`
1012
+ : String(s.label || "");
1013
+ return h(Box, { key: `cmp-${idx}`, width: "100%" },
1014
+ h(Text, {
1015
+ color: selected ? "cyan" : "gray",
1016
+ inverse: selected,
1017
+ wrap: "truncate",
1018
+ }, line),
1019
+ );
1020
+ }),
1021
+ );
1022
+ })() : null,
765
1023
  h(Box, { width: "100%" },
766
1024
  h(MultilineInput, {
767
1025
  value: draft,
768
1026
  valueVersion: draftVersion,
769
- onChange: (next) => setDraft(next),
770
- onSubmit: (value) => submit(value),
1027
+ onChange: (next) => {
1028
+ if (completionSuppressedDraft !== null && next !== completionSuppressedDraft) {
1029
+ setCompletionSuppressedDraft(null);
1030
+ }
1031
+ setDraft(next);
1032
+ },
1033
+ onSubmit: (value) => {
1034
+ setCompletionSuppressedDraft(null);
1035
+ submit(value);
1036
+ },
771
1037
  onCancel: () => {
1038
+ if (completionsOpen) {
1039
+ setCompletionSuppressedDraft(null);
1040
+ setDraft("");
1041
+ setDraftVersion((v) => v + 1);
1042
+ return;
1043
+ }
772
1044
  // If a task is in flight, Esc requests cancellation. Otherwise
773
1045
  // it clears the agent selection (matches blessed). The text
774
1046
  // value is left alone so the user doesn't lose what they typed.
@@ -795,11 +1067,12 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
795
1067
  onArrowRightAtEmpty: () => onArrowSideAtEmpty("right"),
796
1068
  width: Math.max(20, (size.cols || 80) - 4),
797
1069
  interactive,
1070
+ interceptArrowsAndEnter: completionsOpen,
798
1071
  placeholder: "",
799
1072
  promptPrefix: targetAgent ? `›@${getAgentLabel(targetAgent)} ` : "› ",
800
- // The agents footer is rendered below the input. Matching chat's
801
- // IME parking contract keeps the hardware cursor aligned with the
802
- // inverse caret instead of drifting to the bottom of the frame.
1073
+ // Completions render ABOVE the input. Only the Agents footer is
1074
+ // below counting popup rows here parks the hardware cursor up
1075
+ // into the menu (ghost block on /status etc.).
803
1076
  linesBelowInput: 1,
804
1077
  // During model/tool activity ucode redraws the status line every
805
1078
  // spinner frame. Keeping the hardware cursor hidden avoids a
@@ -900,8 +1173,21 @@ function collapseThinkingTail(text, maxChars = 80) {
900
1173
  const collapsed = String(text || "").replace(/\s+/g, " ").trim();
901
1174
  const parsed = Number(maxChars);
902
1175
  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);
1176
+ if (!collapsed) return "";
1177
+
1178
+ // Prefer the latest markdown emphasis / section so the status line shows the
1179
+ // current thought instead of a mid-word tail of an earlier heading.
1180
+ let candidate = collapsed;
1181
+ const boldParts = collapsed.match(/\*\*[^*]+\*\*/g);
1182
+ if (boldParts && boldParts.length > 0) {
1183
+ candidate = boldParts[boldParts.length - 1].replace(/\*/g, "").trim() || candidate;
1184
+ } else {
1185
+ const clauses = collapsed.split(/(?<=[.!?。!?])\s+/).map((part) => part.trim()).filter(Boolean);
1186
+ if (clauses.length > 1) candidate = clauses[clauses.length - 1];
1187
+ }
1188
+
1189
+ if (candidate.length <= limit) return candidate;
1190
+ return `…${candidate.slice(-(limit - 1))}`;
905
1191
  }
906
1192
 
907
1193
  function computeStatusText(status, spinnerTick, backgroundSuffix = "") {
@@ -1,5 +1,9 @@
1
1
  "use strict";
2
2
 
3
+ const { renderMarkdownLinesAnsi } = require("../format/markdownRenderer");
4
+
5
+ const MARKDOWN_BODY_KINDS = new Set(["assistant", "agent", "plain", "error", "success"]);
6
+
3
7
  function stripBlessedTags(text = "") {
4
8
  return String(text || "")
5
9
  .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
@@ -26,6 +30,39 @@ function compactDividerLabel(text = "") {
26
30
  return label || String(text || "").trim() || "section";
27
31
  }
28
32
 
33
+ function splitSpeakerBody(raw = "") {
34
+ const source = String(raw || "");
35
+ const dotIdx = source.indexOf(" · ");
36
+ if (dotIdx >= 0) {
37
+ return {
38
+ speaker: source.slice(0, dotIdx).trim(),
39
+ body: source.slice(dotIdx + 3),
40
+ };
41
+ }
42
+ const colonMatch = source.match(/^([A-Za-z0-9_.:@/-]{1,42}):\s+(.*)$/);
43
+ if (colonMatch) {
44
+ return { speaker: colonMatch[1], body: colonMatch[2] || "" };
45
+ }
46
+ return { speaker: "", body: source };
47
+ }
48
+
49
+ function formatChatLogBody(body = "", kind = "plain", markdownState = null) {
50
+ const text = String(body || "");
51
+ if (!MARKDOWN_BODY_KINDS.has(kind)) return text;
52
+ // Structural markers / empty rows stay untouched.
53
+ if (!text.trim()) return text;
54
+ try {
55
+ const state = markdownState && typeof markdownState === "object"
56
+ ? markdownState
57
+ : { inCodeBlock: false };
58
+ const lines = renderMarkdownLinesAnsi(text, state);
59
+ if (!Array.isArray(lines) || lines.length === 0) return text;
60
+ return lines.length === 1 ? lines[0] : lines.join("\n");
61
+ } catch {
62
+ return text;
63
+ }
64
+ }
65
+
29
66
  function classifyChatLogLine(text = "") {
30
67
  const raw = stripBlessedTags(text).replace(/\r/g, "");
31
68
  const clean = stripMarkdownDecorators(raw);
@@ -41,23 +78,47 @@ function classifyChatLogLine(text = "") {
41
78
  return { kind: "meta", marker: "·", speaker: "", body: clean };
42
79
  }
43
80
  if (/^(error:|✗|failed\b)/i.test(trimmed)) {
44
- return { kind: "error", marker: "!", speaker: "error", body: clean.replace(/^(error:\s*)/i, "") };
81
+ const rawBody = raw.replace(/^(error:\s*)/i, "");
82
+ return {
83
+ kind: "error",
84
+ marker: "!",
85
+ speaker: "error",
86
+ body: rawBody || clean.replace(/^(error:\s*)/i, ""),
87
+ };
45
88
  }
46
89
  if (/^(✓|✔|done\b|closed\b)/i.test(trimmed)) {
47
- return { kind: "success", marker: "✓", speaker: "", body: clean.replace(/^[✓✔]\s*/, "") };
90
+ const rawBody = raw.replace(/^[✓✔]\s*/, "");
91
+ return {
92
+ kind: "success",
93
+ marker: "✓",
94
+ speaker: "",
95
+ body: rawBody || clean.replace(/^[✓✔]\s*/, ""),
96
+ };
48
97
  }
49
- const dotMatch = clean.match(/^([^·\n]{1,64})\s+·\s+(.*)$/);
50
- if (dotMatch) {
51
- const speaker = dotMatch[1].trim();
98
+ const cleanDot = clean.match(/^([^·\n]{1,64})\s+·\s+(.*)$/);
99
+ if (cleanDot) {
100
+ const parts = splitSpeakerBody(raw);
101
+ const speaker = stripMarkdownDecorators(parts.speaker || cleanDot[1]).trim();
52
102
  const lower = speaker.toLowerCase();
53
103
  const kind = lower === "ufoo" ? "assistant" : "agent";
54
- return { kind, marker: kind === "assistant" ? "◆" : "•", speaker, body: dotMatch[2] || " " };
104
+ return {
105
+ kind,
106
+ marker: kind === "assistant" ? "◆" : "•",
107
+ speaker,
108
+ body: parts.body != null ? parts.body : (cleanDot[2] || " "),
109
+ };
55
110
  }
56
- const colonMatch = clean.match(/^([A-Za-z0-9_.:@/-]{1,42}):\s+(.*)$/);
57
- if (colonMatch) {
58
- return { kind: "agent", marker: "•", speaker: colonMatch[1], body: colonMatch[2] || " " };
111
+ const cleanColon = clean.match(/^([A-Za-z0-9_.:@/-]{1,42}):\s+(.*)$/);
112
+ if (cleanColon) {
113
+ const parts = splitSpeakerBody(raw);
114
+ return {
115
+ kind: "agent",
116
+ marker: "•",
117
+ speaker: stripMarkdownDecorators(parts.speaker || cleanColon[1]).trim(),
118
+ body: parts.body != null ? parts.body : (cleanColon[2] || " "),
119
+ };
59
120
  }
60
- return { kind: "plain", marker: "", speaker: "", body: clean };
121
+ return { kind: "plain", marker: "", speaker: "", body: raw || clean };
61
122
  }
62
123
 
63
124
  function defaultMarkerForKind(kind = "", speaker = "") {
@@ -71,12 +132,19 @@ function defaultMarkerForKind(kind = "", speaker = "") {
71
132
  return speaker ? "•" : "";
72
133
  }
73
134
 
74
- function buildChatLogLineModel(input = "") {
135
+ function buildChatLogLineModel(input = "", options = {}) {
136
+ const markdownState = options && options.markdownState;
137
+
75
138
  if (input && typeof input === "object" && !input.kind) {
76
- return buildChatLogLineModel(chatLogEntryText(input));
139
+ return buildChatLogLineModel(chatLogEntryText(input), options);
77
140
  }
78
141
 
79
142
  if (input && typeof input === "object" && input.kind) {
143
+ // Prefer original text when present so markdown can be (re)applied with
144
+ // a shared fence state — e.g. Static decoration.
145
+ if (input.text != null && String(input.text).length > 0 && markdownState) {
146
+ return buildChatLogLineModel(String(input.text), options);
147
+ }
80
148
  const kind = String(input.kind || "plain");
81
149
  const speaker = String(input.speaker || "");
82
150
  const marker = input.marker != null ? String(input.marker) : defaultMarkerForKind(kind, speaker);
@@ -84,6 +152,9 @@ function buildChatLogLineModel(input = "") {
84
152
  ? String(input.bodyText)
85
153
  : String(input.body != null ? input.body : chatLogEntryText(input));
86
154
  const body = kind === "plain" ? compactContinuationIndent(rawBody || " ") : (rawBody || " ");
155
+ const bodyText = markdownState
156
+ ? formatChatLogBody(body, kind, markdownState)
157
+ : body;
87
158
  return {
88
159
  kind,
89
160
  marker,
@@ -92,7 +163,7 @@ function buildChatLogLineModel(input = "") {
92
163
  markerText: input.markerText != null
93
164
  ? String(input.markerText)
94
165
  : (speaker ? `${marker || " "} ` : `${marker || " "} `),
95
- bodyText: body,
166
+ bodyText,
96
167
  };
97
168
  }
98
169
 
@@ -104,7 +175,7 @@ function buildChatLogLineModel(input = "") {
104
175
  return {
105
176
  ...row,
106
177
  markerText: hasSpeaker ? `${row.marker || " "} ` : `${row.marker || " "} `,
107
- bodyText: body,
178
+ bodyText: formatChatLogBody(body, row.kind, markdownState || { inCodeBlock: false }),
108
179
  };
109
180
  }
110
181
 
@@ -113,12 +184,15 @@ function normalizeEntryInput(input) {
113
184
  return { text: input };
114
185
  }
115
186
 
116
- function createChatLogEntry(input = "", id = "") {
187
+ function createChatLogEntry(input = "", id = "", options = {}) {
117
188
  const source = normalizeEntryInput(input);
118
189
  const text = String(source.text != null ? source.text : chatLogEntryText(source));
119
- const row = source.kind
120
- ? buildChatLogLineModel({ ...source, text })
121
- : buildChatLogLineModel(text);
190
+ const markdownState = options && options.markdownState
191
+ ? options.markdownState
192
+ : { inCodeBlock: false };
193
+ const row = source.kind && !(options && options.markdownState)
194
+ ? buildChatLogLineModel({ ...source, text }, { markdownState })
195
+ : buildChatLogLineModel(text, { markdownState });
122
196
  const meta = source.meta && typeof source.meta === "object" && !Array.isArray(source.meta)
123
197
  ? { ...source.meta }
124
198
  : {};
@@ -160,13 +234,17 @@ function buildChatLogGroups(items = []) {
160
234
  const source = Array.isArray(items) ? items : [];
161
235
  const groups = [];
162
236
  let current = null;
237
+ const markdownState = { inCodeBlock: false };
163
238
  for (let index = 0; index < source.length; index += 1) {
164
239
  const item = source[index] || {};
165
240
  const itemId = item && typeof item === "object" && item.id ? item.id : `log-${index}`;
166
- const row = buildChatLogLineModel(item);
241
+ const text = item && typeof item === "object" && item.text != null
242
+ ? String(item.text)
243
+ : chatLogEntryText(item);
244
+ const row = buildChatLogLineModel(text, { markdownState });
167
245
  const entry = {
168
246
  id: itemId,
169
- text: chatLogEntryText(item),
247
+ text,
170
248
  row,
171
249
  sourceType: item && typeof item === "object" ? String(item.sourceType || item.type || "") : "",
172
250
  meta: item && typeof item === "object" && item.meta ? item.meta : {},
@@ -196,7 +274,10 @@ module.exports = {
196
274
  compactDividerLabel,
197
275
  classifyChatLogLine,
198
276
  buildChatLogLineModel,
199
- buildChatLogGroups,
277
+ formatChatLogBody,
200
278
  createChatLogEntry,
201
279
  chatLogEntryText,
280
+ canAppendToChatLogGroup,
281
+ buildChatLogGroups,
282
+ MARKDOWN_BODY_KINDS,
202
283
  };