u-foo 2.5.13 → 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 +187 -31
  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 +408 -55
  39. package/src/ui/ink/chatLogModel.js +102 -21
@@ -18,6 +18,34 @@ 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
+ tool: {},
33
+ toolDetail: { color: "gray", dimColor: true },
34
+ bus: { color: "cyan" },
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
+
43
+ // Resolve a log line kind to ink <Text> props. Unknown/missing kinds (e.g.
44
+ // the banner, which already carries chalk ANSI styling) render uncolored.
45
+ function resolveLogLineTextProps(kind) {
46
+ return LOG_LINE_TEXT_PROPS[kind] || LOG_LINE_TEXT_PROPS.assistant;
47
+ }
48
+
21
49
  function createUcodeApp({ React, ink, props, interactive = true }) {
22
50
  const { useEffect, useState, useCallback, useRef } = React;
23
51
  const { Box, Text, useInput, useApp, useStdout } = ink;
@@ -74,11 +102,25 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
74
102
  // visual row (i.e. moveCursorVertically returned moved=false).
75
103
  const [inputHistory, setInputHistory] = useState([]);
76
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;
77
109
  const { exit } = useApp();
78
110
  const { stdout } = useStdout();
79
111
  const lineSeqRef = useRef(banner.length + 1);
80
112
  const mergeIdRef = useRef(0);
81
113
  const toolMergeScopeRef = useRef(0);
114
+ // thinkingTailRef accumulates raw thinking_delta text for the live
115
+ // status line; the collapsed tail is pushed through a throttled
116
+ // trailing flush (thinkingTimerRef) so fast streams don't re-render
117
+ // the footer on every chunk.
118
+ const thinkingTailRef = useRef("");
119
+ const thinkingFlushAtRef = useRef(0);
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 });
82
124
 
83
125
  const targetAgent = agentSelectionMode && selectedAgentIndex >= 0
84
126
  ? agents[selectedAgentIndex]
@@ -180,6 +222,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
180
222
  if (transition.moved) {
181
223
  setHistoryIndex(transition.nextHistoryIndex);
182
224
  setDraft(transition.nextValue);
225
+ setCompletionSuppressedDraft(transition.nextValue || null);
183
226
  setDraftVersion((v) => v + 1);
184
227
  return;
185
228
  }
@@ -196,22 +239,31 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
196
239
  }
197
240
  }, [inputHistory, historyIndex, agents, agentSelectionMode, selectedAgentIndex]);
198
241
 
199
- const onArrowUpAtStart = useCallback(() => {
200
- // History first: if we're already on the top visual row, walk back
201
- // 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.
202
257
  if (inputHistory.length > 0) {
203
258
  const nextIndex = Math.max(0, historyIndex - 1);
204
259
  if (nextIndex !== historyIndex || draft !== inputHistory[nextIndex]) {
205
260
  setHistoryIndex(nextIndex);
206
- setDraft(inputHistory[nextIndex] || "");
261
+ const nextValue = inputHistory[nextIndex] || "";
262
+ setDraft(nextValue);
263
+ setCompletionSuppressedDraft(nextValue || null);
207
264
  setDraftVersion((v) => v + 1);
208
- return;
209
265
  }
210
266
  }
211
- if (agentSelectionMode) {
212
- setAgentSelectionMode(false);
213
- setSelectedAgentIndex(-1);
214
- }
215
267
  }, [inputHistory, historyIndex, draft, agentSelectionMode]);
216
268
 
217
269
  const onArrowSideAtEmpty = useCallback((direction) => {
@@ -225,11 +277,73 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
225
277
  setSelectedAgentIndex(next);
226
278
  }, [agents, agentSelectionMode, selectedAgentIndex]);
227
279
 
228
- const appendLogLine = useCallback((text) => {
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
+
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
+ }
229
340
  setLogLines((prev) => {
230
- const id = `l-${lineSeqRef.current}`;
231
- lineSeqRef.current += 1;
232
- const next = prev.concat([{ id, text: String(text || "") }]);
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
+ }
233
347
  return next.length > 1000 ? next.slice(-1000) : next;
234
348
  });
235
349
  }, []);
@@ -246,7 +360,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
246
360
  const flushActiveMerge = useCallback(() => {
247
361
  setActiveMerge((current) => {
248
362
  if (!current) return null;
249
- appendLogLine(renderMergeText(current));
363
+ appendLogLine(renderMergeText(current), "tool");
250
364
  return null;
251
365
  });
252
366
  }, [appendLogLine, renderMergeText]);
@@ -257,7 +371,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
257
371
  const resObj = payload && typeof payload === "object" ? payload : (entry && entry.result) || {};
258
372
  const phase = String((entry && entry.phase) || "").trim().toLowerCase();
259
373
  const isError = phase === "error" || resObj.ok === false;
260
- const detail = tool === "bash" ? fmt.normalizeBashToolCommand(entry && entry.args, resObj) : "";
374
+ const detail = fmt.normalizeToolLogDetail(tool, entry && entry.args, resObj);
261
375
  const errorText = String((entry && entry.error) || resObj.error || "").trim();
262
376
  const toolEntry = fmt.normalizeToolMergeEntry({ tool, detail, isError, errorText });
263
377
 
@@ -273,7 +387,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
273
387
  });
274
388
  }, []);
275
389
 
276
- const appendLogText = useCallback((text) => {
390
+ const appendLogText = useCallback((text, kind = "assistant") => {
277
391
  // Multi-line text → split into separate log entries so <Static> keys
278
392
  // stay stable when streaming arrives line-by-line. Always promote any
279
393
  // in-flight tool group first so it freezes above the new text.
@@ -281,7 +395,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
281
395
  if (!raw) return;
282
396
  flushActiveMerge();
283
397
  const lines = raw.split(/\r?\n/);
284
- for (const line of lines) appendLogLine(line);
398
+ for (const line of lines) appendLogLine(line, kind);
285
399
  }, [appendLogLine, flushActiveMerge]);
286
400
 
287
401
  const expandLastMerge = useCallback(() => {
@@ -299,7 +413,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
299
413
  const lines = fmt.buildMergedToolExpandedLines(candidate.entries);
300
414
  for (let i = 0; i < lines.length; i += 1) {
301
415
  const branch = i === lines.length - 1 ? "└" : "│";
302
- appendLogLine(`${branch} ${lines[i]}`);
416
+ appendLogLine(`${branch} ${lines[i]}`, "toolDetail");
303
417
  }
304
418
  candidate.expanded = true;
305
419
  if (active && active.id === candidate.id) setActiveMerge(null);
@@ -315,7 +429,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
315
429
  if (!normalized) return;
316
430
  toolMergeScopeRef.current += 1;
317
431
  flushActiveMerge();
318
- appendLogLine(`› ${normalized}`);
432
+ appendLogLine(`› ${normalized}`, "user");
319
433
 
320
434
  const runtimeWorkspace = String(
321
435
  (props.state && props.state.workspaceRoot) || props.workspaceRoot || process.cwd()
@@ -325,7 +439,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
325
439
  try {
326
440
  result = props.runSingleCommand(normalized, runtimeWorkspace);
327
441
  } catch (err) {
328
- appendLogText(`Error: ${err && err.message ? err.message : "command parse failed"}`);
442
+ appendLogText(`Error: ${err && err.message ? err.message : "command parse failed"}`, "error");
329
443
  return;
330
444
  }
331
445
  if (!result || typeof result !== "object") return;
@@ -342,6 +456,37 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
342
456
  case "error":
343
457
  appendLogText(result.output || "");
344
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
+ }
345
490
  case "ubus": {
346
491
  setStatus({ message: "Checking bus messages...", type: "typing", showTimer: false, startedAt: Date.now() });
347
492
  try {
@@ -350,26 +495,26 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
350
495
  workspaceRoot: runtimeWorkspace,
351
496
  onMessageReceived: (msg) => {
352
497
  const nickname = extractAgentNickname(msg && msg.from) || (msg && msg.from) || "bus";
353
- appendLogText(`${nickname}: ${(msg && msg.task) || ""}`);
498
+ appendLogText(`${nickname}: ${(msg && msg.task) || ""}`, "bus");
354
499
  },
355
500
  });
356
501
  if (!ubusResult || !ubusResult.ok) {
357
- appendLogText(`Error: ${(ubusResult && ubusResult.error) || "ubus failed"}`);
502
+ appendLogText(`Error: ${(ubusResult && ubusResult.error) || "ubus failed"}`, "error");
358
503
  return;
359
504
  }
360
505
  const exchanges = Array.isArray(ubusResult.messageExchanges) ? ubusResult.messageExchanges : [];
361
506
  if (exchanges.length > 0) {
362
507
  for (const exchange of exchanges) {
363
508
  const nickname = extractAgentNickname(exchange && exchange.from) || (exchange && exchange.from) || "bus";
364
- appendLogText(`@${nickname} ${(exchange && exchange.reply) || ""}`);
509
+ appendLogText(`@${nickname} ${(exchange && exchange.reply) || ""}`, "bus");
365
510
  }
366
511
  } else if (Number(ubusResult.handled) === 0) {
367
- appendLogText("ubus: no pending messages.");
512
+ appendLogText("ubus: no pending messages.", "system");
368
513
  }
369
514
  if (typeof props.persistSessionState === "function") {
370
515
  const persisted = props.persistSessionState(props.state);
371
516
  if (!persisted || persisted.ok === false) {
372
- appendLogText(`Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${(persisted && persisted.error) || "unknown error"}`);
517
+ appendLogText(`Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${(persisted && persisted.error) || "unknown error"}`, "error");
373
518
  }
374
519
  }
375
520
  } finally {
@@ -379,15 +524,39 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
379
524
  }
380
525
  case "resume": {
381
526
  if (typeof props.resumeSessionState !== "function") {
382
- appendLogText("Error: resume unsupported");
527
+ appendLogText("Error: resume unsupported", "error");
383
528
  return;
384
529
  }
385
530
  const resumed = props.resumeSessionState(props.state, result.sessionId, runtimeWorkspace);
386
531
  if (!resumed || !resumed.ok) {
387
- appendLogText(`Error: ${(resumed && resumed.error) || "resume failed"}`);
532
+ appendLogText(`Error: ${(resumed && resumed.error) || "resume failed"}`, "error");
388
533
  return;
389
534
  }
390
- appendLogText(`Resumed session ${resumed.sessionId} (${resumed.restoredMessages} messages).`);
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;
391
560
  return;
392
561
  }
393
562
  case "tool": {
@@ -413,7 +582,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
413
582
  backgroundTasksRef.current.set(jobId, taskRecord);
414
583
  bumpBackground();
415
584
  setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
416
- appendLogText(`[${jobId}] started in background.`);
585
+ appendLogText(`[${jobId}] started in background.`, "system");
417
586
 
418
587
  const bgState = {
419
588
  workspaceRoot: props.state && props.state.workspaceRoot,
@@ -434,13 +603,13 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
434
603
  taskRecord.finishedAt = Date.now();
435
604
  taskRecord.summary = String(props.formatNlResult(nlResult, false) || "").trim();
436
605
  const title = taskRecord.status === "done" ? "done" : "failed";
437
- appendLogText(`[${jobId}] ${title}: ${taskRecord.summary || "no summary"}`);
606
+ appendLogText(`[${jobId}] ${title}: ${taskRecord.summary || "no summary"}`, "system");
438
607
  })
439
608
  .catch((err) => {
440
609
  taskRecord.status = "failed";
441
610
  taskRecord.finishedAt = Date.now();
442
611
  taskRecord.summary = err && err.message ? String(err.message) : "background task failed";
443
- appendLogText(`[${jobId}] failed: ${taskRecord.summary}`);
612
+ appendLogText(`[${jobId}] failed: ${taskRecord.summary}`, "system");
444
613
  })
445
614
  .finally(() => {
446
615
  bumpBackground();
@@ -458,6 +627,16 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
458
627
  showTimer: true,
459
628
  startedAt,
460
629
  });
630
+ const cancelThinkingFlush = () => {
631
+ if (thinkingTimerRef.current) {
632
+ clearTimeout(thinkingTimerRef.current);
633
+ thinkingTimerRef.current = null;
634
+ }
635
+ };
636
+ const flushThinkingStatus = () => {
637
+ thinkingFlushAtRef.current = Date.now();
638
+ setNlStatus(collapseThinkingTail(thinkingTailRef.current) || "Thinking...");
639
+ };
461
640
  setNlStatus("Waiting for model...");
462
641
  let streamBuf = "";
463
642
  let sawStreamText = false;
@@ -469,10 +648,28 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
469
648
  signal: abortController.signal,
470
649
  onPhase: (event) => {
471
650
  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") {
651
+ if (event.type === "request_start") {
652
+ cancelThinkingFlush();
653
+ setNlStatus("Waiting for model...");
654
+ } else if (event.type === "thinking_delta") {
655
+ thinkingTailRef.current += String(event.text || "");
656
+ const elapsed = Date.now() - thinkingFlushAtRef.current;
657
+ if (elapsed >= THINKING_STATUS_THROTTLE_MS) {
658
+ cancelThinkingFlush();
659
+ flushThinkingStatus();
660
+ } else if (!thinkingTimerRef.current) {
661
+ // Trailing flush guarantees the final tail lands even
662
+ // when the stream ends inside a throttle window.
663
+ thinkingTimerRef.current = setTimeout(() => {
664
+ thinkingTimerRef.current = null;
665
+ flushThinkingStatus();
666
+ }, THINKING_STATUS_THROTTLE_MS - elapsed);
667
+ }
668
+ } else if (event.type === "text_delta") {
669
+ cancelThinkingFlush();
670
+ setNlStatus("Generating response...");
671
+ } else if (event.type === "tool_request") {
672
+ cancelThinkingFlush();
476
673
  const label = fmt.TOOL_LABELS[String(event.name || "").toLowerCase()] ||
477
674
  `Calling ${event.name}`;
478
675
  setNlStatus(`${label}...`);
@@ -509,10 +706,12 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
509
706
  },
510
707
  });
511
708
  } catch (err) {
512
- appendLogText(`Error: ${err && err.message ? err.message : "agent loop failed"}`);
709
+ appendLogText(`Error: ${err && err.message ? err.message : "agent loop failed"}`, "error");
513
710
  return;
514
711
  } finally {
515
712
  pendingTaskRef.current = null;
713
+ cancelThinkingFlush();
714
+ thinkingTailRef.current = "";
516
715
  setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
517
716
  }
518
717
  if (streamBuf) {
@@ -534,7 +733,8 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
534
733
  const persisted = props.persistSessionState(props.state);
535
734
  if (persisted && persisted.ok === false) {
536
735
  appendLogText(
537
- `Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${persisted.error || "unknown error"}`
736
+ `Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${persisted.error || "unknown error"}`,
737
+ "error"
538
738
  );
539
739
  }
540
740
  } catch {
@@ -579,7 +779,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
579
779
  signal: abortController.signal,
580
780
  onMessageReceived: (msg) => {
581
781
  const nickname = extractAgentNickname(msg && msg.from) || (msg && msg.from) || "bus";
582
- appendLogText(`${nickname}: ${(msg && msg.task) || ""}`);
782
+ appendLogText(`${nickname}: ${(msg && msg.task) || ""}`, "bus");
583
783
  setStatus({
584
784
  message: "Working on task...",
585
785
  type: "thinking",
@@ -593,7 +793,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
593
793
  const nextError = String((ubusResult && ubusResult.error) || "ubus failed");
594
794
  if (nextError !== autoBusErrorRef.current) {
595
795
  autoBusErrorRef.current = nextError;
596
- appendLogText(`Error: ${nextError}`);
796
+ appendLogText(`Error: ${nextError}`, "error");
597
797
  }
598
798
  return;
599
799
  }
@@ -602,12 +802,12 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
602
802
  const exchanges = Array.isArray(ubusResult.messageExchanges) ? ubusResult.messageExchanges : [];
603
803
  for (const exchange of exchanges) {
604
804
  const nickname = extractAgentNickname(exchange && exchange.from) || (exchange && exchange.from) || "bus";
605
- appendLogText(`@${nickname} ${(exchange && exchange.reply) || ""}`);
805
+ appendLogText(`@${nickname} ${(exchange && exchange.reply) || ""}`, "bus");
606
806
  }
607
807
  if (Number(ubusResult.handled) > 0 && typeof props.persistSessionState === "function") {
608
808
  const persisted = props.persistSessionState(props.state);
609
809
  if (!persisted || persisted.ok === false) {
610
- appendLogText(`Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${(persisted && persisted.error) || "unknown error"}`);
810
+ appendLogText(`Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${(persisted && persisted.error) || "unknown error"}`, "error");
611
811
  }
612
812
  }
613
813
  } finally {
@@ -627,7 +827,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
627
827
  autoBusQueuedRef.current = true;
628
828
  runChainRef.current = runChainRef.current
629
829
  .then(() => runAutoBusOnce())
630
- .catch((err) => appendLogText(`Error: ${err && err.message ? err.message : "ubus failed"}`))
830
+ .catch((err) => appendLogText(`Error: ${err && err.message ? err.message : "ubus failed"}`, "error"))
631
831
  .finally(() => {
632
832
  autoBusQueuedRef.current = false;
633
833
  });
@@ -651,7 +851,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
651
851
  // Serialize executions so streaming tasks don't interleave.
652
852
  runChainRef.current = runChainRef.current
653
853
  .then(() => executeLine(value))
654
- .catch((err) => appendLogText(`Error: ${err && err.message ? err.message : err}`));
854
+ .catch((err) => appendLogText(`Error: ${err && err.message ? err.message : err}`, "error"));
655
855
  }, [draft, executeLine, appendLogText]);
656
856
 
657
857
  useEffect(() => {
@@ -680,18 +880,107 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
680
880
 
681
881
  const statusText = useMemoStatusText(React, status, spinnerTick, getBackgroundSuffix());
682
882
 
683
- // Top-level only catches Ctrl+C and Ctrl+O (expand last tool group);
684
- // 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.
685
885
  useInput((input, key) => {
686
886
  if (key.ctrl && input === "c") { exit(); return; }
687
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
+ }
688
938
  }, { isActive: interactive });
689
939
 
690
940
  return h(Box, { flexDirection: "column", width: "100%" },
691
941
  h(Box, { flexDirection: "column", width: "100%" },
692
- ...logLines.map((item) =>
693
- h(Text, { key: item.id }, item.text || " ")
694
- )
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
+ })()
695
984
  ),
696
985
  activeMerge ? h(Box, null,
697
986
  h(Text, { color: activeMerge.entries.some((e) => e.isError) ? "red" : "cyan" },
@@ -703,20 +992,62 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
703
992
  h(Box, { flexGrow: 1 }),
704
993
  h(Text, { color: "gray" }, `v${fmt.UCODE_VERSION}`),
705
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,
706
1023
  h(Box, { width: "100%" },
707
1024
  h(MultilineInput, {
708
1025
  value: draft,
709
1026
  valueVersion: draftVersion,
710
- onChange: (next) => setDraft(next),
711
- 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
+ },
712
1037
  onCancel: () => {
1038
+ if (completionsOpen) {
1039
+ setCompletionSuppressedDraft(null);
1040
+ setDraft("");
1041
+ setDraftVersion((v) => v + 1);
1042
+ return;
1043
+ }
713
1044
  // If a task is in flight, Esc requests cancellation. Otherwise
714
1045
  // it clears the agent selection (matches blessed). The text
715
1046
  // value is left alone so the user doesn't lose what they typed.
716
1047
  const pending = pendingTaskRef.current;
717
1048
  if (pending && pending.abortController && !pending.abortController.signal.aborted) {
718
1049
  try { pending.abortController.abort(); } catch { /* ignore */ }
719
- appendLogLine("⚙ Cancellation requested. Stopping the current task...");
1050
+ appendLogLine("⚙ Cancellation requested. Stopping the current task...", "system");
720
1051
  setStatus({
721
1052
  message: "Cancelling...",
722
1053
  type: "waiting",
@@ -736,11 +1067,12 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
736
1067
  onArrowRightAtEmpty: () => onArrowSideAtEmpty("right"),
737
1068
  width: Math.max(20, (size.cols || 80) - 4),
738
1069
  interactive,
1070
+ interceptArrowsAndEnter: completionsOpen,
739
1071
  placeholder: "",
740
1072
  promptPrefix: targetAgent ? `›@${getAgentLabel(targetAgent)} ` : "› ",
741
- // The agents footer is rendered below the input. Matching chat's
742
- // IME parking contract keeps the hardware cursor aligned with the
743
- // 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.).
744
1076
  linesBelowInput: 1,
745
1077
  // During model/tool activity ucode redraws the status line every
746
1078
  // spinner frame. Keeping the hardware cursor hidden avoids a
@@ -813,7 +1145,7 @@ function runUcodeInkTui(props = {}) {
813
1145
  });
814
1146
  }
815
1147
 
816
- module.exports = { runUcodeInkTui, createUcodeApp, computeStatusText };
1148
+ module.exports = { runUcodeInkTui, createUcodeApp, computeStatusText, collapseThinkingTail, resolveLogLineTextProps };
817
1149
 
818
1150
  function inferStatusType(text = "", requestedType = "") {
819
1151
  const type = String(requestedType || "").trim().toLowerCase();
@@ -837,6 +1169,27 @@ function inferStatusType(text = "", requestedType = "") {
837
1169
  * combination while a task is in flight, mirroring updateStatus() in the
838
1170
  * blessed implementation.
839
1171
  */
1172
+ function collapseThinkingTail(text, maxChars = 80) {
1173
+ const collapsed = String(text || "").replace(/\s+/g, " ").trim();
1174
+ const parsed = Number(maxChars);
1175
+ const limit = Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 80;
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))}`;
1191
+ }
1192
+
840
1193
  function computeStatusText(status, spinnerTick, backgroundSuffix = "") {
841
1194
  const message = String((status && status.message) || "");
842
1195
  const suffix = String(backgroundSuffix || "");