u-foo 2.5.14 → 3.0.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 (59) hide show
  1. package/package.json +1 -1
  2. package/src/agents/prompts/native/environment.js +20 -8
  3. package/src/code/agent.js +517 -112
  4. package/src/code/commands.js +77 -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 +703 -0
  9. package/src/code/context/executionSegment.js +292 -0
  10. package/src/code/context/index.js +28 -0
  11. package/src/code/context/planGraph.js +1410 -0
  12. package/src/code/context/planGraphService.js +857 -0
  13. package/src/code/context/planMode.js +398 -0
  14. package/src/code/context/planProjection.js +432 -0
  15. package/src/code/context/projectSnapshot.js +201 -0
  16. package/src/code/context/promptLayers.js +175 -0
  17. package/src/code/context/reducers.js +328 -0
  18. package/src/code/context/stableJson.js +29 -0
  19. package/src/code/context/stateCommit.js +414 -0
  20. package/src/code/context/toolRuntime.js +172 -0
  21. package/src/code/context/transcript.js +182 -0
  22. package/src/code/context/transcriptSync.js +106 -0
  23. package/src/code/context/userInteraction.js +457 -0
  24. package/src/code/context/userNudge.js +116 -0
  25. package/src/code/context/workingSet.js +323 -0
  26. package/src/code/dispatch.js +20 -1
  27. package/src/code/index.js +8 -0
  28. package/src/code/modelCommand.js +87 -0
  29. package/src/code/nativeRunner.js +625 -34
  30. package/src/code/repl.js +196 -50
  31. package/src/code/runtime/agentWakeup.js +58 -0
  32. package/src/code/runtime/graphOwner.js +41 -0
  33. package/src/code/runtime/graphYieldRouter.js +42 -0
  34. package/src/code/runtime/index.js +15 -0
  35. package/src/code/runtime/loopMailbox.js +124 -0
  36. package/src/code/runtime/runtimeEvents.js +39 -0
  37. package/src/code/runtime/taskControl.js +565 -0
  38. package/src/code/runtime/taskFocus.js +165 -0
  39. package/src/code/runtime/taskLoop.js +383 -0
  40. package/src/code/runtime/taskRun.js +187 -0
  41. package/src/code/runtime/toolProvenance.js +70 -0
  42. package/src/code/runtime/workspaceLease.js +208 -0
  43. package/src/code/sessionStore.js +217 -15
  44. package/src/code/skills/index.js +10 -0
  45. package/src/code/skills/injection.js +66 -3
  46. package/src/code/skills/loader.js +21 -0
  47. package/src/code/skills/manifest.js +87 -0
  48. package/src/code/skills/render.js +15 -1
  49. package/src/code/taskDecomposer.js +56 -2
  50. package/src/code/tools/artifactRead.js +40 -0
  51. package/src/code/tools/askUser.js +11 -0
  52. package/src/code/tools/planGraph.js +29 -0
  53. package/src/code/tui.js +2 -0
  54. package/src/code/usageStore.js +15 -0
  55. package/src/ui/format/index.js +285 -45
  56. package/src/ui/format/markdownRenderer.js +436 -71
  57. package/src/ui/ink/ChatApp.js +39 -8
  58. package/src/ui/ink/UcodeApp.js +592 -43
  59. 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) {
@@ -50,6 +57,11 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
50
57
  engine: (props.state && props.state.engine) || "ufoo-core",
51
58
  workspaceRoot: props.workspaceRoot,
52
59
  sessionId: (props.state && props.state.sessionId) || "",
60
+ planMode: Boolean(
61
+ props.state
62
+ && props.state.executionState
63
+ && props.state.executionState.planMode
64
+ ),
53
65
  });
54
66
 
55
67
  return function UcodeApp() {
@@ -68,6 +80,15 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
68
80
  showTimer: false,
69
81
  startedAt: 0,
70
82
  });
83
+ const [planUi, setPlanUi] = useState(() => ({
84
+ hasPlan: false,
85
+ visible: false,
86
+ bandLines: [],
87
+ idleHint: "",
88
+ statusLine: "",
89
+ hash: "",
90
+ }));
91
+ const [interactionLines, setInteractionLines] = useState([]);
71
92
  const [spinnerTick, setSpinnerTick] = useState(0);
72
93
  const [size, setSize] = useState({ cols: 0, rows: 0 });
73
94
  const [agents, setAgents] = useState([]);
@@ -95,6 +116,10 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
95
116
  // visual row (i.e. moveCursorVertically returned moved=false).
96
117
  const [inputHistory, setInputHistory] = useState([]);
97
118
  const [historyIndex, setHistoryIndex] = useState(0);
119
+ const [completionIndex, setCompletionIndex] = useState(0);
120
+ const [completionWindowStart, setCompletionWindowStart] = useState(0);
121
+ const [completionSuppressedDraft, setCompletionSuppressedDraft] = useState(null);
122
+ const POPUP_PAGE_SIZE = 8;
98
123
  const { exit } = useApp();
99
124
  const { stdout } = useStdout();
100
125
  const lineSeqRef = useRef(banner.length + 1);
@@ -107,6 +132,12 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
107
132
  const thinkingTailRef = useRef("");
108
133
  const thinkingFlushAtRef = useRef(0);
109
134
  const thinkingTimerRef = useRef(null);
135
+ // Persist fence/open-code state across streamed assistant log lines so
136
+ // ``` blocks stay styled even when deltas arrive one line at a time.
137
+ const markdownStateRef = useRef({ inCodeBlock: false });
138
+ // GFM tables need the full block for column alignment — buffer consecutive
139
+ // pipe rows and flush as one multi-line markdown unit.
140
+ const tableBufRef = useRef(fmt.createMarkdownTableBuffer());
110
141
 
111
142
  const targetAgent = agentSelectionMode && selectedAgentIndex >= 0
112
143
  ? agents[selectedAgentIndex]
@@ -114,6 +145,33 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
114
145
 
115
146
  const bumpBackground = useCallback(() => setBackgroundVersion((v) => v + 1), []);
116
147
 
148
+ const refreshPlanUi = useCallback((activityMessage = "") => {
149
+ try {
150
+ const { buildPlanUiProjection } = require("../../code/context/planProjection");
151
+ const {
152
+ getPendingUserInteraction,
153
+ formatInteractionPromptLines,
154
+ syncInteractionFromPlanGraph,
155
+ } = require("../../code/context/userInteraction");
156
+ if (props.state && props.state.executionState) {
157
+ syncInteractionFromPlanGraph(props.state.executionState);
158
+ }
159
+ const next = buildPlanUiProjection(
160
+ props.state && props.state.executionState,
161
+ {
162
+ cols: size.cols || 80,
163
+ activityMessage: String(activityMessage || ""),
164
+ }
165
+ );
166
+ setPlanUi((prev) => (prev && prev.hash === next.hash ? prev : next));
167
+ const pending = getPendingUserInteraction(props.state && props.state.executionState);
168
+ setInteractionLines(pending ? formatInteractionPromptLines(pending) : []);
169
+ return next;
170
+ } catch {
171
+ return null;
172
+ }
173
+ }, [props.state, size.cols]);
174
+
117
175
  const getBackgroundSuffix = useCallback(() => {
118
176
  const tasks = backgroundTasksRef.current;
119
177
  if (!tasks || tasks.size === 0) return "";
@@ -208,6 +266,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
208
266
  if (transition.moved) {
209
267
  setHistoryIndex(transition.nextHistoryIndex);
210
268
  setDraft(transition.nextValue);
269
+ setCompletionSuppressedDraft(transition.nextValue || null);
211
270
  setDraftVersion((v) => v + 1);
212
271
  return;
213
272
  }
@@ -224,22 +283,31 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
224
283
  }
225
284
  }, [inputHistory, historyIndex, agents, agentSelectionMode, selectedAgentIndex]);
226
285
 
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.
286
+ const onArrowUpAtStart = useCallback((currentValue) => {
287
+ // While @-targeting an agent with an empty draft, Up clears the
288
+ // selection before walking input history otherwise history eats the
289
+ // key and the ›@agent prefix sticks.
290
+ const inputValue = currentValue != null ? currentValue : draft;
291
+ if (fmt.shouldClearAgentSelectionOnUp({
292
+ agentSelectionMode,
293
+ inputValue,
294
+ })) {
295
+ setAgentSelectionMode(false);
296
+ setSelectedAgentIndex(-1);
297
+ return;
298
+ }
299
+ // History: if we're already on the top visual row, walk back through
300
+ // the recent history before doing anything else.
230
301
  if (inputHistory.length > 0) {
231
302
  const nextIndex = Math.max(0, historyIndex - 1);
232
303
  if (nextIndex !== historyIndex || draft !== inputHistory[nextIndex]) {
233
304
  setHistoryIndex(nextIndex);
234
- setDraft(inputHistory[nextIndex] || "");
305
+ const nextValue = inputHistory[nextIndex] || "";
306
+ setDraft(nextValue);
307
+ setCompletionSuppressedDraft(nextValue || null);
235
308
  setDraftVersion((v) => v + 1);
236
- return;
237
309
  }
238
310
  }
239
- if (agentSelectionMode) {
240
- setAgentSelectionMode(false);
241
- setSelectedAgentIndex(-1);
242
- }
243
311
  }, [inputHistory, historyIndex, draft, agentSelectionMode]);
244
312
 
245
313
  const onArrowSideAtEmpty = useCallback((direction) => {
@@ -253,15 +321,94 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
253
321
  setSelectedAgentIndex(next);
254
322
  }, [agents, agentSelectionMode, selectedAgentIndex]);
255
323
 
256
- const appendLogLine = useCallback((text, kind = "assistant") => {
324
+ const { UCODE_COMMAND_REGISTRY, UCODE_COMMAND_TREE } = require("../../code/commands");
325
+ const { listSessionSummaries } = require("../../code/sessionStore");
326
+ const { suggestUcodeModels, applyUcodeModelCommand } = require("../../code/modelCommand");
327
+ let resumeSessions = [];
328
+ try {
329
+ resumeSessions = listSessionSummaries(props.workspaceRoot || process.cwd(), { limit: 40 });
330
+ } catch {
331
+ resumeSessions = [];
332
+ }
333
+ const modelSuggestions = suggestUcodeModels(props.state || {});
334
+
335
+ const completions = fmt.buildCompletions({
336
+ text: draft,
337
+ agents: agents.map((a) => String((a && (a.fullId || a.id || a.nickname)) || "")).filter(Boolean),
338
+ agentLabels: agents.map((a) => getAgentLabel(a)),
339
+ commands: UCODE_COMMAND_REGISTRY,
340
+ commandTree: UCODE_COMMAND_TREE,
341
+ argumentLists: {
342
+ "/resume": resumeSessions,
343
+ "/model": modelSuggestions,
344
+ },
345
+ limit: 20,
346
+ });
347
+ const completionsOpen = completions.length > 0 && draft !== completionSuppressedDraft;
348
+
349
+ useEffect(() => {
350
+ if (completions.length === 0) {
351
+ if (completionIndex !== 0) setCompletionIndex(0);
352
+ if (completionWindowStart !== 0) setCompletionWindowStart(0);
353
+ } else if (completionIndex >= completions.length) {
354
+ setCompletionIndex(completions.length - 1);
355
+ setCompletionWindowStart(Math.max(0, completions.length - POPUP_PAGE_SIZE));
356
+ }
357
+ }, [completions.length, completionIndex, completionWindowStart]);
358
+
359
+ const acceptCompletion = useCallback(() => {
360
+ if (!completionsOpen) return false;
361
+ const item = completions[Math.max(0, Math.min(completions.length - 1, completionIndex))];
362
+ if (item) {
363
+ setDraft(item.replace);
364
+ setCompletionSuppressedDraft(item.hasChildren ? null : item.replace);
365
+ setDraftVersion((v) => v + 1);
366
+ }
367
+ setCompletionIndex(0);
368
+ return true;
369
+ }, [completionsOpen, completions, completionIndex]);
370
+
371
+ const pushRenderedLogLines = useCallback((rawText, kind = "assistant") => {
372
+ const raw = String(rawText == null ? "" : rawText);
373
+ let renderedLines = [raw];
374
+ if (MARKDOWN_LOG_KINDS.has(kind)) {
375
+ try {
376
+ renderedLines = fmt.renderLogLinesWithMarkdownAnsi(raw, markdownStateRef.current);
377
+ if (!Array.isArray(renderedLines) || renderedLines.length === 0) {
378
+ renderedLines = raw.split(/\r?\n/);
379
+ }
380
+ } catch {
381
+ renderedLines = raw.split(/\r?\n/);
382
+ }
383
+ }
257
384
  setLogLines((prev) => {
258
- const id = `l-${lineSeqRef.current}`;
259
- lineSeqRef.current += 1;
260
- const next = prev.concat([{ id, text: String(text || ""), kind }]);
385
+ const next = prev.slice();
386
+ for (const line of renderedLines) {
387
+ const id = `l-${lineSeqRef.current}`;
388
+ lineSeqRef.current += 1;
389
+ next.push({ id, text: String(line || ""), kind });
390
+ }
261
391
  return next.length > 1000 ? next.slice(-1000) : next;
262
392
  });
263
393
  }, []);
264
394
 
395
+ const flushTableBuffer = useCallback(() => {
396
+ const buffered = tableBufRef.current.flush();
397
+ if (buffered == null) return;
398
+ pushRenderedLogLines(buffered, "assistant");
399
+ }, [pushRenderedLogLines]);
400
+
401
+ const appendLogLine = useCallback((text, kind = "assistant") => {
402
+ const raw = String(text == null ? "" : text);
403
+ if (MARKDOWN_LOG_KINDS.has(kind)) {
404
+ if (tableBufRef.current.push(raw)) return;
405
+ flushTableBuffer();
406
+ } else {
407
+ flushTableBuffer();
408
+ }
409
+ pushRenderedLogLines(raw, kind);
410
+ }, [flushTableBuffer, pushRenderedLogLines]);
411
+
265
412
  const renderMergeText = useCallback((merge) => {
266
413
  if (!merge || !Array.isArray(merge.entries)) return "";
267
414
  return fmt.buildToolMergeRowText(merge.entries);
@@ -274,7 +421,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
274
421
  const flushActiveMerge = useCallback(() => {
275
422
  setActiveMerge((current) => {
276
423
  if (!current) return null;
277
- appendLogLine(renderMergeText(current));
424
+ appendLogLine(renderMergeText(current), "tool");
278
425
  return null;
279
426
  });
280
427
  }, [appendLogLine, renderMergeText]);
@@ -285,7 +432,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
285
432
  const resObj = payload && typeof payload === "object" ? payload : (entry && entry.result) || {};
286
433
  const phase = String((entry && entry.phase) || "").trim().toLowerCase();
287
434
  const isError = phase === "error" || resObj.ok === false;
288
- const detail = tool === "bash" ? fmt.normalizeBashToolCommand(entry && entry.args, resObj) : "";
435
+ const detail = fmt.normalizeToolLogDetail(tool, entry && entry.args, resObj);
289
436
  const errorText = String((entry && entry.error) || resObj.error || "").trim();
290
437
  const toolEntry = fmt.normalizeToolMergeEntry({ tool, detail, isError, errorText });
291
438
 
@@ -305,12 +452,14 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
305
452
  // Multi-line text → split into separate log entries so <Static> keys
306
453
  // stay stable when streaming arrives line-by-line. Always promote any
307
454
  // in-flight tool group first so it freezes above the new text.
455
+ // Table rows are re-batched inside appendLogLine before markdown render.
308
456
  const raw = String(text == null ? "" : text);
309
457
  if (!raw) return;
310
458
  flushActiveMerge();
311
459
  const lines = raw.split(/\r?\n/);
312
460
  for (const line of lines) appendLogLine(line, kind);
313
- }, [appendLogLine, flushActiveMerge]);
461
+ if (MARKDOWN_LOG_KINDS.has(kind)) flushTableBuffer();
462
+ }, [appendLogLine, flushActiveMerge, flushTableBuffer]);
314
463
 
315
464
  const expandLastMerge = useCallback(() => {
316
465
  // Try the active group first; fall back to the most recent frozen one.
@@ -370,6 +519,61 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
370
519
  case "error":
371
520
  appendLogText(result.output || "");
372
521
  return;
522
+ case "status": {
523
+ try {
524
+ const { summarizeSessionUsage, formatSessionUsageStatus } = require("../../code/usageStore");
525
+ const usageSummary = summarizeSessionUsage({
526
+ workspaceRoot: runtimeWorkspace,
527
+ sessionId: (props.state && props.state.sessionId) || "",
528
+ });
529
+ appendLogText(formatSessionUsageStatus(usageSummary), "system");
530
+ if (props.state && props.state.executionState) {
531
+ const { formatPlanModeStatus } = require("../../code/context/planMode");
532
+ const planLines = formatPlanModeStatus(props.state.executionState)
533
+ .split("\n")
534
+ .slice(0, 6)
535
+ .join("\n");
536
+ appendLogText(planLines, "system");
537
+ }
538
+ } catch (err) {
539
+ appendLogText(`Error: ${err && err.message ? err.message : "status failed"}`, "error");
540
+ }
541
+ return;
542
+ }
543
+ case "model": {
544
+ const applied = applyUcodeModelCommand(props.state || {}, result);
545
+ appendLogText(applied.output || "", applied.ok ? "system" : "error");
546
+ if (applied.ok && result.action === "set" && typeof props.persistSessionState === "function") {
547
+ try {
548
+ const persisted = props.persistSessionState(props.state);
549
+ if (persisted && persisted.ok === false) {
550
+ appendLogText(
551
+ `Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${persisted.error || "unknown error"}`,
552
+ "error"
553
+ );
554
+ }
555
+ } catch {
556
+ // persist is best-effort after a successful model switch
557
+ }
558
+ }
559
+ return;
560
+ }
561
+ case "plan": {
562
+ const { applyUcodePlanCommand } = require("../../code/context/planMode");
563
+ const applied = applyUcodePlanCommand(props.state || {}, result);
564
+ appendLogText(applied.output || "", applied.ok ? "system" : "error");
565
+ if (applied.refreshPlanUi || applied.ok) {
566
+ refreshPlanUi();
567
+ }
568
+ if (applied.ok && typeof props.persistSessionState === "function") {
569
+ try {
570
+ props.persistSessionState(props.state);
571
+ } catch {
572
+ // best-effort
573
+ }
574
+ }
575
+ return;
576
+ }
373
577
  case "ubus": {
374
578
  setStatus({ message: "Checking bus messages...", type: "typing", showTimer: false, startedAt: Date.now() });
375
579
  try {
@@ -415,7 +619,32 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
415
619
  appendLogText(`Error: ${(resumed && resumed.error) || "resume failed"}`, "error");
416
620
  return;
417
621
  }
418
- appendLogText(`Resumed session ${resumed.sessionId} (${resumed.restoredMessages} messages).`, "system");
622
+ // Rebuild the visible log from the restored session transcript so
623
+ // the user sees prior turns instead of only a status toast.
624
+ markdownStateRef.current = { inCodeBlock: false };
625
+ tableBufRef.current = fmt.createMarkdownTableBuffer();
626
+ const history = fmt.buildUcodeSessionLogEntries(
627
+ Array.isArray(props.state && props.state.nlMessages) ? props.state.nlMessages : [],
628
+ { markdownState: markdownStateRef.current, idPrefix: "h", startSeq: 0 },
629
+ );
630
+ const bannerEntries = banner.concat([""]).map((line, idx) => ({
631
+ id: `b-${idx}`,
632
+ text: line,
633
+ }));
634
+ const notice = {
635
+ id: `h-resume-${Date.now().toString(36)}`,
636
+ text: `Resumed session ${resumed.sessionId} (${resumed.restoredMessages} messages).`,
637
+ kind: "system",
638
+ };
639
+ const nextLines = bannerEntries.concat(history.entries).concat([notice]);
640
+ setLogLines(nextLines.length > 1000 ? nextLines.slice(-1000) : nextLines);
641
+ lineSeqRef.current = Math.max(
642
+ bannerEntries.length + 1,
643
+ Number(history.nextSeq) || 0,
644
+ nextLines.length,
645
+ );
646
+ setActiveMerge(null);
647
+ lastMergeRef.current = null;
419
648
  return;
420
649
  }
421
650
  case "tool": {
@@ -480,12 +709,18 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
480
709
  const startedAt = Date.now();
481
710
  const abortController = new AbortController();
482
711
  pendingTaskRef.current = { abortController, startedAt };
483
- const setNlStatus = (msg) => setStatus({
484
- message: msg,
485
- type: "thinking",
486
- showTimer: true,
487
- startedAt,
488
- });
712
+ const setNlStatus = (msg) => {
713
+ const projection = refreshPlanUi(msg);
714
+ const message = projection && projection.hasPlan && projection.activityStatusLine
715
+ ? projection.activityStatusLine
716
+ : msg;
717
+ setStatus({
718
+ message,
719
+ type: "thinking",
720
+ showTimer: true,
721
+ startedAt,
722
+ });
723
+ };
489
724
  const cancelThinkingFlush = () => {
490
725
  if (thinkingTimerRef.current) {
491
726
  clearTimeout(thinkingTimerRef.current);
@@ -561,6 +796,9 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
561
796
  setNlStatus(`${label}...`);
562
797
  dropLeadingStreamBlank = true;
563
798
  }
799
+ if (entry.tool === "plan_graph" || entry.phase === "end" || entry.phase === "result") {
800
+ refreshPlanUi();
801
+ }
564
802
  logToolHint(entry, entry.result);
565
803
  },
566
804
  });
@@ -571,12 +809,14 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
571
809
  pendingTaskRef.current = null;
572
810
  cancelThinkingFlush();
573
811
  thinkingTailRef.current = "";
812
+ refreshPlanUi();
574
813
  setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
575
814
  }
576
815
  if (streamBuf) {
577
816
  if (/[^\s]/.test(streamBuf)) sawStreamText = true;
578
817
  appendLogLine(streamBuf);
579
818
  }
819
+ flushTableBuffer();
580
820
  // Skip the summary echo when the model already streamed its
581
821
  // response in full — otherwise the user sees the same text twice.
582
822
  // Mirrors the shouldSkipSummary check in tui.js.
@@ -604,7 +844,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
604
844
  default:
605
845
  if (result.output) appendLogText(result.output);
606
846
  }
607
- }, [appendLogLine, appendLogText, exit, props, logToolHint, flushActiveMerge]);
847
+ }, [appendLogLine, appendLogText, exit, props, logToolHint, flushActiveMerge, flushTableBuffer, refreshPlanUi]);
608
848
  // ^ `props` is captured by the createUcodeApp closure on a single mount,
609
849
  // so its reference is stable across renders even though it looks like a
610
850
  // changing dep to React's exhaustive-deps lint.
@@ -707,11 +947,129 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
707
947
  setHistoryIndex(next.length);
708
948
  return next;
709
949
  });
950
+
951
+ // Pending approval/choice/chat takes priority over nudge / new NL.
952
+ try {
953
+ const {
954
+ hasPendingUserInteraction,
955
+ parseUserInteractionInput,
956
+ getPendingUserInteraction,
957
+ } = require("../../code/context/userInteraction");
958
+ if (props.state && props.state.executionState && hasPendingUserInteraction(props.state.executionState)) {
959
+ const pending = getPendingUserInteraction(props.state.executionState);
960
+ const parsed = parseUserInteractionInput(pending, trimmed);
961
+ if (!parsed.ok) {
962
+ appendLogText(parsed.error || "Invalid reply", "error");
963
+ return;
964
+ }
965
+ appendLogText(`› ${trimmed}`, "user");
966
+ const startedAt = Date.now();
967
+ setStatus({
968
+ message: "Applying your reply...",
969
+ type: "thinking",
970
+ showTimer: true,
971
+ startedAt,
972
+ });
973
+ runChainRef.current = runChainRef.current
974
+ .then(async () => {
975
+ const resume = typeof props.resumeAfterUserInteraction === "function"
976
+ ? props.resumeAfterUserInteraction
977
+ : require("../../code/agent").resumeAfterUserInteraction;
978
+ let streamBuf = "";
979
+ let sawStreamText = false;
980
+ let streamStarted = false;
981
+ let dropLeadingStreamBlank = false;
982
+ const result = await resume(trimmed, props.state, {
983
+ onDelta: (delta) => {
984
+ const text = String(delta || "");
985
+ if (!text) return;
986
+ if (!streamStarted) {
987
+ flushActiveMerge();
988
+ streamStarted = true;
989
+ }
990
+ const split = fmt.splitStreamingLogChunk(streamBuf, text, {
991
+ dropLeadingBlank: dropLeadingStreamBlank,
992
+ });
993
+ if (split.sawVisible) {
994
+ sawStreamText = true;
995
+ dropLeadingStreamBlank = false;
996
+ }
997
+ for (const line of split.lines) {
998
+ appendLogLine(line);
999
+ }
1000
+ streamBuf = split.buffer;
1001
+ },
1002
+ });
1003
+ if (streamBuf) {
1004
+ if (/[^\s]/.test(streamBuf)) sawStreamText = true;
1005
+ appendLogLine(streamBuf);
1006
+ }
1007
+ flushTableBuffer();
1008
+ refreshPlanUi();
1009
+ if (result && result.waitingUserInteraction) {
1010
+ appendLogText("Still waiting for your reply.", "system");
1011
+ setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
1012
+ return;
1013
+ }
1014
+ if (!result || result.ok === false) {
1015
+ appendLogText(`Error: ${(result && result.error) || "resume failed"}`, "error");
1016
+ } else {
1017
+ // Skip summary echo when deltas were already rendered (mirrors NL path).
1018
+ const shouldSkipSummary = Boolean(result.streamed && result.ok && sawStreamText);
1019
+ if (result.summary && !shouldSkipSummary) {
1020
+ appendLogText(result.summary);
1021
+ }
1022
+ }
1023
+ setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
1024
+ })
1025
+ .catch((err) => {
1026
+ appendLogText(`Error: ${err && err.message ? err.message : err}`, "error");
1027
+ setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
1028
+ });
1029
+ return;
1030
+ }
1031
+ } catch (err) {
1032
+ appendLogText(`Error: ${err && err.message ? err.message : "interaction failed"}`, "error");
1033
+ return;
1034
+ }
1035
+
1036
+ // While a native task is in flight, queue an additional user reminder
1037
+ // for the next LLM turn instead of starting a second NL task.
1038
+ if (pendingTaskRef.current) {
1039
+ const { enqueueUserPrompt } = require("../../code/context/userNudge");
1040
+ const { emptyExecutionState } = require("../../code/context/executionSegment");
1041
+ if (!props.state || typeof props.state !== "object") {
1042
+ appendLogText("Error: missing session state for user reminder", "error");
1043
+ return;
1044
+ }
1045
+ if (!props.state.executionState || typeof props.state.executionState !== "object") {
1046
+ props.state.executionState = emptyExecutionState();
1047
+ }
1048
+ const queued = enqueueUserPrompt(props.state.executionState, trimmed);
1049
+ appendLogText(
1050
+ queued.enqueued
1051
+ ? `Queued user reminder for next model turn: ${trimmed.slice(0, 120)}${trimmed.length > 120 ? "…" : ""}`
1052
+ : "Could not queue user reminder (empty).",
1053
+ "system",
1054
+ );
1055
+ return;
1056
+ }
1057
+
710
1058
  // Serialize executions so streaming tasks don't interleave.
711
1059
  runChainRef.current = runChainRef.current
712
1060
  .then(() => executeLine(value))
713
1061
  .catch((err) => appendLogText(`Error: ${err && err.message ? err.message : err}`, "error"));
714
- }, [draft, executeLine, appendLogText]);
1062
+ }, [
1063
+ draft,
1064
+ executeLine,
1065
+ appendLogText,
1066
+ appendLogLine,
1067
+ flushActiveMerge,
1068
+ flushTableBuffer,
1069
+ props.state,
1070
+ props.resumeAfterUserInteraction,
1071
+ refreshPlanUi,
1072
+ ]);
715
1073
 
716
1074
  useEffect(() => {
717
1075
  if (!stdout) return undefined;
@@ -724,6 +1082,10 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
724
1082
  return () => stdout.off("resize", update);
725
1083
  }, [stdout]);
726
1084
 
1085
+ useEffect(() => {
1086
+ refreshPlanUi();
1087
+ }, [refreshPlanUi]);
1088
+
727
1089
  // Drive the spinner + elapsed-timer redraws while a task is in flight.
728
1090
  useEffect(() => {
729
1091
  const statusType = inferStatusType(status.message, status.type);
@@ -737,44 +1099,214 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
737
1099
  return () => clearInterval(timer);
738
1100
  }, [status.message, status.type, status.showTimer]);
739
1101
 
740
- const statusText = useMemoStatusText(React, status, spinnerTick, getBackgroundSuffix());
1102
+ const statusText = useMemoStatusText(
1103
+ React,
1104
+ status,
1105
+ spinnerTick,
1106
+ getBackgroundSuffix(),
1107
+ !status.message ? (planUi.idleHint || "") : ""
1108
+ );
741
1109
 
742
- // Top-level only catches Ctrl+C and Ctrl+O (expand last tool group);
743
- // the editor handles all text editing.
1110
+ // Top-level catches Ctrl+C / Ctrl+O, plus completion popup navigation
1111
+ // while a slash/agent menu is open.
744
1112
  useInput((input, key) => {
745
1113
  if (key.ctrl && input === "c") { exit(); return; }
746
1114
  if (key.ctrl && input === "o") { expandLastMerge(); return; }
1115
+ if (!completionsOpen) return;
1116
+ if (key.upArrow) {
1117
+ setCompletionIndex((i) => {
1118
+ const next = (i - 1 + completions.length) % completions.length;
1119
+ setCompletionWindowStart((ws) => {
1120
+ if (next < ws) return next;
1121
+ if (next === completions.length - 1) {
1122
+ return Math.max(0, completions.length - POPUP_PAGE_SIZE);
1123
+ }
1124
+ return ws;
1125
+ });
1126
+ return next;
1127
+ });
1128
+ return;
1129
+ }
1130
+ if (key.downArrow) {
1131
+ setCompletionIndex((i) => {
1132
+ const next = (i + 1) % completions.length;
1133
+ setCompletionWindowStart((ws) => {
1134
+ if (next === 0) return 0;
1135
+ if (next >= ws + POPUP_PAGE_SIZE) return next - POPUP_PAGE_SIZE + 1;
1136
+ return ws;
1137
+ });
1138
+ return next;
1139
+ });
1140
+ return;
1141
+ }
1142
+ if (key.return) {
1143
+ // Leaf completions (e.g. /resume <session>) run immediately on Enter.
1144
+ // Parents with children only fill the draft so the next menu can open.
1145
+ const item = completions[Math.max(0, Math.min(completions.length - 1, completionIndex))];
1146
+ if (item && !item.hasChildren) {
1147
+ const cmd = String(item.replace || "").trim();
1148
+ setCompletionIndex(0);
1149
+ setCompletionSuppressedDraft(null);
1150
+ if (cmd) submit(cmd);
1151
+ return;
1152
+ }
1153
+ acceptCompletion();
1154
+ return;
1155
+ }
1156
+ if (key.tab) {
1157
+ acceptCompletion();
1158
+ return;
1159
+ }
1160
+ if (key.escape) {
1161
+ setCompletionSuppressedDraft(null);
1162
+ setDraft("");
1163
+ setDraftVersion((v) => v + 1);
1164
+ }
747
1165
  }, { isActive: interactive });
748
1166
 
749
1167
  return h(Box, { flexDirection: "column", width: "100%" },
750
1168
  h(Box, { flexDirection: "column", width: "100%" },
751
- ...logLines.map((item) =>
752
- h(Text, { key: item.id, ...resolveLogLineTextProps(item.kind) }, item.text || " ")
753
- )
1169
+ ...(() => {
1170
+ // Re-render raw markdown at paint time so leftover ** / ### / tables
1171
+ // from older append paths or nested `**code**` patterns still resolve.
1172
+ const mdState = { inCodeBlock: false };
1173
+ return logLines.map((item, idx) => {
1174
+ let text = item.text || " ";
1175
+ if (MARKDOWN_LOG_KINDS.has(item.kind) && /(?:\*\*|__|^\s*#{1,6}\s|^\s*`{3}|^\s*\|)/m.test(text)) {
1176
+ try {
1177
+ const rendered = fmt.renderLogLinesWithMarkdownAnsi(text, mdState);
1178
+ if (Array.isArray(rendered) && rendered.length > 0) {
1179
+ text = rendered.length === 1 ? rendered[0] : rendered.join("\n");
1180
+ }
1181
+ } catch {
1182
+ // keep original
1183
+ }
1184
+ } else if (MARKDOWN_LOG_KINDS.has(item.kind) && mdState.inCodeBlock) {
1185
+ try {
1186
+ const rendered = fmt.renderLogLinesWithMarkdownAnsi(text, mdState);
1187
+ if (Array.isArray(rendered) && rendered[0] != null) text = rendered[0];
1188
+ } catch {
1189
+ // keep original
1190
+ }
1191
+ }
1192
+ const textEl = h(Text, { ...resolveLogLineTextProps(item.kind) }, text || " ");
1193
+ // Give user turns a blank line above/below so › prompts don't
1194
+ // sit flush against system/tool rows. Multi-line user blocks
1195
+ // only pad the outer edges.
1196
+ if (item.kind === "user") {
1197
+ const prev = logLines[idx - 1];
1198
+ const next = logLines[idx + 1];
1199
+ const marginTop = !prev || prev.kind !== "user" ? 1 : 0;
1200
+ const marginBottom = !next || next.kind !== "user" ? 1 : 0;
1201
+ return h(Box, {
1202
+ key: item.id,
1203
+ width: "100%",
1204
+ marginTop,
1205
+ marginBottom,
1206
+ }, textEl);
1207
+ }
1208
+ return h(Text, { key: item.id, ...resolveLogLineTextProps(item.kind) }, text || " ");
1209
+ });
1210
+ })()
754
1211
  ),
755
1212
  activeMerge ? h(Box, null,
756
1213
  h(Text, { color: activeMerge.entries.some((e) => e.isError) ? "red" : "cyan" },
757
1214
  renderMergeText(activeMerge)
758
1215
  ),
759
1216
  ) : null,
1217
+ planUi.visible && planUi.bandLines.length > 0
1218
+ ? h(Box, {
1219
+ flexDirection: "column",
1220
+ width: "100%",
1221
+ marginTop: 1,
1222
+ },
1223
+ ...planUi.bandLines.map((line, idx) => h(Text, {
1224
+ key: `plan-band-${idx}`,
1225
+ color: "magenta",
1226
+ dimColor: idx > 0,
1227
+ wrap: "truncate",
1228
+ }, line || " ")),
1229
+ )
1230
+ : null,
1231
+ interactionLines.length > 0
1232
+ ? h(Box, {
1233
+ flexDirection: "column",
1234
+ width: "100%",
1235
+ marginTop: 1,
1236
+ },
1237
+ ...interactionLines.map((line, idx) => h(Text, {
1238
+ key: `ask-${idx}`,
1239
+ color: "yellow",
1240
+ wrap: "truncate",
1241
+ }, line || " ")),
1242
+ )
1243
+ : null,
760
1244
  h(Box, { marginTop: 1, width: "100%" },
761
1245
  h(Text, { color: "gray" }, statusText),
762
1246
  h(Box, { flexGrow: 1 }),
763
1247
  h(Text, { color: "gray" }, `v${fmt.UCODE_VERSION}`),
764
1248
  ),
1249
+ completionsOpen ? (() => {
1250
+ const start = Math.min(completionWindowStart, Math.max(0, completions.length - POPUP_PAGE_SIZE));
1251
+ const end = Math.min(completions.length, start + POPUP_PAGE_SIZE);
1252
+ const visible = completions.slice(start, end);
1253
+ const cols = Math.max(8, size.cols || 80);
1254
+ // Frame the popup with a top rule; MultilineInput's borderTop is the
1255
+ // matching bottom rule, so we intentionally omit a trailing ─ here.
1256
+ return h(Box, { flexDirection: "column", width: "100%" },
1257
+ h(Text, { color: "gray" }, "─".repeat(cols)),
1258
+ ...visible.map((s, idxInWindow) => {
1259
+ const idx = start + idxInWindow;
1260
+ const selected = idx === completionIndex;
1261
+ // Keep label+description in one Text. Splitting into sibling
1262
+ // Text nodes with wrap:"truncate" lets Yoga shrink the label
1263
+ // and mid-cut commands (e.g. "/help" → "/he p").
1264
+ const line = s.description
1265
+ ? `${s.label} ${s.description}`
1266
+ : String(s.label || "");
1267
+ return h(Box, { key: `cmp-${idx}`, width: "100%" },
1268
+ h(Text, {
1269
+ color: selected ? "cyan" : "gray",
1270
+ inverse: selected,
1271
+ wrap: "truncate",
1272
+ }, line),
1273
+ );
1274
+ }),
1275
+ );
1276
+ })() : null,
765
1277
  h(Box, { width: "100%" },
766
1278
  h(MultilineInput, {
767
1279
  value: draft,
768
1280
  valueVersion: draftVersion,
769
- onChange: (next) => setDraft(next),
770
- onSubmit: (value) => submit(value),
1281
+ onChange: (next) => {
1282
+ if (completionSuppressedDraft !== null && next !== completionSuppressedDraft) {
1283
+ setCompletionSuppressedDraft(null);
1284
+ }
1285
+ setDraft(next);
1286
+ },
1287
+ onSubmit: (value) => {
1288
+ setCompletionSuppressedDraft(null);
1289
+ submit(value);
1290
+ },
771
1291
  onCancel: () => {
1292
+ if (completionsOpen) {
1293
+ setCompletionSuppressedDraft(null);
1294
+ setDraft("");
1295
+ setDraftVersion((v) => v + 1);
1296
+ return;
1297
+ }
772
1298
  // If a task is in flight, Esc requests cancellation. Otherwise
773
1299
  // it clears the agent selection (matches blessed). The text
774
1300
  // value is left alone so the user doesn't lose what they typed.
775
1301
  const pending = pendingTaskRef.current;
776
1302
  if (pending && pending.abortController && !pending.abortController.signal.aborted) {
777
1303
  try { pending.abortController.abort(); } catch { /* ignore */ }
1304
+ try {
1305
+ const { clearUserPrompts } = require("../../code/context/userNudge");
1306
+ if (props.state && props.state.executionState) {
1307
+ clearUserPrompts(props.state.executionState);
1308
+ }
1309
+ } catch { /* ignore */ }
778
1310
  appendLogLine("⚙ Cancellation requested. Stopping the current task...", "system");
779
1311
  setStatus({
780
1312
  message: "Cancelling...",
@@ -795,11 +1327,12 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
795
1327
  onArrowRightAtEmpty: () => onArrowSideAtEmpty("right"),
796
1328
  width: Math.max(20, (size.cols || 80) - 4),
797
1329
  interactive,
1330
+ interceptArrowsAndEnter: completionsOpen,
798
1331
  placeholder: "",
799
1332
  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.
1333
+ // Completions render ABOVE the input. Only the Agents footer is
1334
+ // below counting popup rows here parks the hardware cursor up
1335
+ // into the menu (ghost block on /status etc.).
803
1336
  linesBelowInput: 1,
804
1337
  // During model/tool activity ucode redraws the status line every
805
1338
  // spinner frame. Keeping the hardware cursor hidden avoids a
@@ -900,14 +1433,30 @@ function collapseThinkingTail(text, maxChars = 80) {
900
1433
  const collapsed = String(text || "").replace(/\s+/g, " ").trim();
901
1434
  const parsed = Number(maxChars);
902
1435
  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);
1436
+ if (!collapsed) return "";
1437
+
1438
+ // Prefer the latest markdown emphasis / section so the status line shows the
1439
+ // current thought instead of a mid-word tail of an earlier heading.
1440
+ let candidate = collapsed;
1441
+ const boldParts = collapsed.match(/\*\*[^*]+\*\*/g);
1442
+ if (boldParts && boldParts.length > 0) {
1443
+ candidate = boldParts[boldParts.length - 1].replace(/\*/g, "").trim() || candidate;
1444
+ } else {
1445
+ const clauses = collapsed.split(/(?<=[.!?。!?])\s+/).map((part) => part.trim()).filter(Boolean);
1446
+ if (clauses.length > 1) candidate = clauses[clauses.length - 1];
1447
+ }
1448
+
1449
+ if (candidate.length <= limit) return candidate;
1450
+ return `…${candidate.slice(-(limit - 1))}`;
905
1451
  }
906
1452
 
907
- function computeStatusText(status, spinnerTick, backgroundSuffix = "") {
1453
+ function computeStatusText(status, spinnerTick, backgroundSuffix = "", idlePlanHint = "") {
908
1454
  const message = String((status && status.message) || "");
909
1455
  const suffix = String(backgroundSuffix || "");
910
- if (!message) return `UCODE · Ready${suffix}`;
1456
+ if (!message) {
1457
+ const hint = String(idlePlanHint || "").trim();
1458
+ return hint ? `UCODE · Ready · ${hint}${suffix}` : `UCODE · Ready${suffix}`;
1459
+ }
911
1460
  const type = inferStatusType(message, status && status.type);
912
1461
  if (type === "done" || type === "success") {
913
1462
  const clean = message.trim();
@@ -927,11 +1476,11 @@ function computeStatusText(status, spinnerTick, backgroundSuffix = "") {
927
1476
  return `${indicator} ${message}${timerText}${suffix}`;
928
1477
  }
929
1478
 
930
- function useMemoStatusText(React, status, spinnerTick, backgroundSuffix = "") {
1479
+ function useMemoStatusText(React, status, spinnerTick, backgroundSuffix = "", idlePlanHint = "") {
931
1480
  // Dependencies intentionally include startedAt so the timer ticks even
932
1481
  // when the message string is unchanged.
933
1482
  return React.useMemo(
934
- () => computeStatusText(status, spinnerTick, backgroundSuffix),
935
- [status, spinnerTick, backgroundSuffix]
1483
+ () => computeStatusText(status, spinnerTick, backgroundSuffix, idlePlanHint),
1484
+ [status, spinnerTick, backgroundSuffix, idlePlanHint]
936
1485
  );
937
1486
  }