u-foo 2.3.30 → 2.3.32

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 (38) hide show
  1. package/package.json +5 -1
  2. package/scripts/chat-app-smoke.js +30 -0
  3. package/scripts/ink-demo.js +23 -0
  4. package/scripts/ink-smoke.js +30 -0
  5. package/scripts/ucode-app-smoke.js +36 -0
  6. package/src/chat/commandExecutor.js +6 -2
  7. package/src/chat/daemonMessageRouter.js +9 -1
  8. package/src/chat/daemonTransport.js +2 -1
  9. package/src/chat/dashboardKeyController.js +0 -40
  10. package/src/chat/dashboardView.js +0 -20
  11. package/src/chat/index.js +9 -1
  12. package/src/chat/inputSubmitHandler.js +34 -0
  13. package/src/chat/projectCloseController.js +1 -1
  14. package/src/chat/shellCommand.js +42 -0
  15. package/src/chat/transport.js +16 -3
  16. package/src/cli.js +4 -3
  17. package/src/code/agent.js +4 -0
  18. package/src/code/nativeRunner.js +74 -0
  19. package/src/code/taskDecomposer.js +5 -4
  20. package/src/code/tui.js +73 -561
  21. package/src/daemon/index.js +169 -27
  22. package/src/daemon/ipcServer.js +23 -1
  23. package/src/daemon/promptRequest.js +6 -1
  24. package/src/daemon/run.js +11 -4
  25. package/src/projects/runtimes.js +1 -1
  26. package/src/ufoo/agentRegistryDiagnostics.js +43 -0
  27. package/src/ui/MIGRATION.md +382 -0
  28. package/src/ui/components/ChatApp.js +2950 -0
  29. package/src/ui/components/DashboardBar.js +417 -0
  30. package/src/ui/components/InkDemo.js +96 -0
  31. package/src/ui/components/MultilineInput.js +387 -0
  32. package/src/ui/components/UcodeApp.js +813 -0
  33. package/src/ui/components/agentMirror.js +725 -0
  34. package/src/ui/components/chatReducer.js +337 -0
  35. package/src/ui/format/index.js +997 -0
  36. package/src/ui/index.js +9 -0
  37. package/src/ui/runInk.js +57 -0
  38. package/src/utils/nodeExecutable.js +26 -0
@@ -0,0 +1,813 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Ink-based ucode TUI. Behaviourally equivalent to runUcodeBlessedTui in
5
+ * src/code/tui.js but rendered via React + ink.
6
+ *
7
+ * Activation: Ink is the default ucode TUI. Set UFOO_TUI=blessed to use
8
+ * the legacy blessed renderer while it remains available as a fallback.
9
+ *
10
+ * Coverage today: banner, scrolling log via <Static>, tool-call merge with
11
+ * Ctrl+O expand, multiline editor (see MultilineInput.js), spinner+phase
12
+ * status line, abortController-driven Esc cancel, input history Up/Down,
13
+ * agent selection footer, runSingleCommand + runNaturalLanguageTask path.
14
+ *
15
+ * Also covers blessed parity branches: background tasks, ubus, resume,
16
+ * nl_bg, and autoBus polling.
17
+ */
18
+
19
+ const { runInk } = require("../runInk");
20
+ const fmt = require("../format");
21
+ const { createMultilineInput } = require("./MultilineInput");
22
+
23
+ function createUcodeApp({ React, ink, props, interactive = true }) {
24
+ const { useEffect, useState, useCallback, useRef } = React;
25
+ const { Box, Text, Static, useInput, useApp, useStdout } = ink;
26
+ const h = React.createElement;
27
+ const MultilineInput = createMultilineInput({ React, ink });
28
+
29
+ const banner = fmt.buildUcodeBannerLines({
30
+ model: (props.state && props.state.model) || process.env.UFOO_UCODE_MODEL || "",
31
+ engine: (props.state && props.state.engine) || "ufoo-core",
32
+ workspaceRoot: props.workspaceRoot,
33
+ sessionId: (props.state && props.state.sessionId) || "",
34
+ });
35
+
36
+ return function UcodeApp() {
37
+ const [logLines, setLogLines] = useState(() =>
38
+ banner.concat([""]).map((line, idx) => ({ id: `b-${idx}`, text: line }))
39
+ );
40
+ const [draft, setDraft] = useState("");
41
+ // status: idle when message === "". `type` picks a STATUS_INDICATORS
42
+ // bucket; `showTimer` and `startedAt` reproduce the blessed spinner
43
+ // controls. The BG suffix is computed from backgroundTasksRef and
44
+ // appended by computeStatusText below.
45
+ const [status, setStatus] = useState({
46
+ message: "",
47
+ type: "thinking",
48
+ showTimer: false,
49
+ startedAt: 0,
50
+ });
51
+ const [spinnerTick, setSpinnerTick] = useState(0);
52
+ const [, setNowTick] = useState(0);
53
+ const [size, setSize] = useState({ cols: 0, rows: 0 });
54
+ const [agents, setAgents] = useState([]);
55
+ const [selectedAgentIndex, setSelectedAgentIndex] = useState(-1);
56
+ const [agentSelectionMode, setAgentSelectionMode] = useState(false);
57
+ // activeMerge holds the in-flight group of consecutive tool calls.
58
+ // Rendered as a single live row below <Static>; promoted to <Static>
59
+ // and cleared whenever a non-tool log line arrives. lastMergeRef tracks
60
+ // the most recent group with >=2 entries so Ctrl+O can still expand it
61
+ // after the group has been frozen into the log.
62
+ const [activeMerge, setActiveMerge] = useState(null);
63
+ const lastMergeRef = useRef(null);
64
+ // pendingTaskRef holds the live AbortController for the current
65
+ // runNaturalLanguageTask call so Esc can cancel it. We use a ref (not
66
+ // state) because the value is consumed inside the run loop, not by
67
+ // render.
68
+ const pendingTaskRef = useRef(null);
69
+ const backgroundTasksRef = useRef(new Map());
70
+ const backgroundSeqRef = useRef(0);
71
+ const autoBusQueuedRef = useRef(false);
72
+ const autoBusErrorRef = useRef("");
73
+ const [, setBackgroundVersion] = useState(0);
74
+ // inputHistory mirrors blessed's flat history list. Up walks back
75
+ // through it when the editor reports the cursor is already on the top
76
+ // visual row (i.e. moveCursorVertically returned moved=false).
77
+ const [inputHistory, setInputHistory] = useState([]);
78
+ const [historyIndex, setHistoryIndex] = useState(0);
79
+ const { exit } = useApp();
80
+ const { stdout } = useStdout();
81
+ const lineSeqRef = useRef(banner.length + 1);
82
+ const mergeIdRef = useRef(0);
83
+
84
+ const targetAgent = agentSelectionMode && selectedAgentIndex >= 0
85
+ ? agents[selectedAgentIndex]
86
+ : null;
87
+
88
+ const bumpBackground = useCallback(() => setBackgroundVersion((v) => v + 1), []);
89
+
90
+ const getBackgroundSuffix = useCallback(() => {
91
+ const tasks = backgroundTasksRef.current;
92
+ if (!tasks || tasks.size === 0) return "";
93
+ let running = 0;
94
+ let done = 0;
95
+ let failed = 0;
96
+ for (const task of tasks.values()) {
97
+ if (!task) continue;
98
+ if (task.status === "running") running += 1;
99
+ else if (task.status === "done") done += 1;
100
+ else if (task.status === "failed") failed += 1;
101
+ }
102
+ const parts = [];
103
+ if (running) parts.push(`${running} running`);
104
+ if (done) parts.push(`${done} done`);
105
+ if (failed) parts.push(`${failed} failed`);
106
+ return parts.length ? ` · BG ${parts.join("/")}` : "";
107
+ }, []);
108
+
109
+ const getAgentLabel = useCallback((agent) => {
110
+ if (!agent) return "";
111
+ if (agent.nickname) return agent.nickname;
112
+ const idTail = String(agent.id || "").slice(0, 6);
113
+ return idTail ? `${agent.type}:${idTail}` : agent.type;
114
+ }, []);
115
+
116
+ const ucodeModel = (props.state && props.state.model)
117
+ || process.env.UFOO_UCODE_MODEL
118
+ || "default";
119
+ let workspaceLabel = "";
120
+ try {
121
+ const os = require("os");
122
+ const path = require("path");
123
+ const root = props.workspaceRoot || process.cwd();
124
+ const home = os.homedir();
125
+ let normalized = root.startsWith(home) ? root.replace(home, "~") : root;
126
+ workspaceLabel = path.normalize(normalized);
127
+ } catch {
128
+ workspaceLabel = String(props.workspaceRoot || "");
129
+ }
130
+ const hintParts = [ucodeModel];
131
+ if (workspaceLabel) hintParts.push(workspaceLabel);
132
+ const agentsHint = hintParts.join(" · ");
133
+
134
+ const selfSubscriberId = String(
135
+ (props.autoBus && props.autoBus.subscriberId) ||
136
+ process.env.UFOO_SUBSCRIBER_ID ||
137
+ ""
138
+ ).trim();
139
+
140
+ const refreshAgents = useCallback(() => {
141
+ try {
142
+ const list = fmt.filterSelectableAgents(
143
+ fmt.loadActiveAgents(props.workspaceRoot),
144
+ selfSubscriberId
145
+ );
146
+ setAgents(list);
147
+ } catch {
148
+ // loadActiveAgents already swallows errors and returns []. This catch
149
+ // is just a belt-and-braces guard against future regressions.
150
+ }
151
+ }, [selfSubscriberId]);
152
+
153
+ useEffect(() => {
154
+ if (!interactive) return undefined;
155
+ refreshAgents();
156
+ const timer = setInterval(refreshAgents, 3000);
157
+ return () => clearInterval(timer);
158
+ }, [interactive, refreshAgents]);
159
+
160
+ // Keep selection within bounds when the agents list changes.
161
+ useEffect(() => {
162
+ if (selectedAgentIndex < 0) return;
163
+ if (agents.length === 0) {
164
+ setSelectedAgentIndex(-1);
165
+ setAgentSelectionMode(false);
166
+ } else if (selectedAgentIndex >= agents.length) {
167
+ setSelectedAgentIndex(agents.length - 1);
168
+ }
169
+ }, [agents, selectedAgentIndex]);
170
+
171
+ const onArrowDownAtEnd = useCallback((currentValue) => {
172
+ // History first: if we're past the bottom of a multi-line edit, walk
173
+ // forward through the recent history. Reaching the end clears the
174
+ // input the same way blessed does.
175
+ if (inputHistory.length > 0) {
176
+ const transition = fmt.resolveHistoryDownTransition({
177
+ inputHistory,
178
+ historyIndex,
179
+ currentValue,
180
+ });
181
+ if (transition.moved) {
182
+ setHistoryIndex(transition.nextHistoryIndex);
183
+ setDraft(transition.nextValue);
184
+ return;
185
+ }
186
+ }
187
+ if (agents.length === 0) return;
188
+ const decision = fmt.resolveAgentSelectionOnDown({
189
+ agentSelectionMode,
190
+ selectedAgentIndex,
191
+ totalAgents: agents.length,
192
+ });
193
+ if (decision.action === "enter") {
194
+ setSelectedAgentIndex(decision.index);
195
+ setAgentSelectionMode(true);
196
+ }
197
+ }, [inputHistory, historyIndex, agents, agentSelectionMode, selectedAgentIndex]);
198
+
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.
202
+ if (inputHistory.length > 0) {
203
+ const nextIndex = Math.max(0, historyIndex - 1);
204
+ if (nextIndex !== historyIndex || draft !== inputHistory[nextIndex]) {
205
+ setHistoryIndex(nextIndex);
206
+ setDraft(inputHistory[nextIndex] || "");
207
+ return;
208
+ }
209
+ }
210
+ if (agentSelectionMode) {
211
+ setAgentSelectionMode(false);
212
+ setSelectedAgentIndex(-1);
213
+ }
214
+ }, [inputHistory, historyIndex, draft, agentSelectionMode]);
215
+
216
+ const onArrowSideAtEmpty = useCallback((direction) => {
217
+ if (!agentSelectionMode) return;
218
+ if (agents.length === 0) return;
219
+ const next = fmt.cycleAgentSelectionIndex(
220
+ selectedAgentIndex,
221
+ agents.length,
222
+ direction
223
+ );
224
+ setSelectedAgentIndex(next);
225
+ }, [agents, agentSelectionMode, selectedAgentIndex]);
226
+
227
+ const appendLogLine = useCallback((text) => {
228
+ setLogLines((prev) => {
229
+ const id = `l-${lineSeqRef.current}`;
230
+ lineSeqRef.current += 1;
231
+ const next = prev.concat([{ id, text: String(text || "") }]);
232
+ return next.length > 1000 ? next.slice(-1000) : next;
233
+ });
234
+ }, []);
235
+
236
+ const renderMergeText = useCallback((merge) => {
237
+ if (!merge || !Array.isArray(merge.entries)) return "";
238
+ return fmt.buildToolMergeRowText(merge.entries);
239
+ }, []);
240
+
241
+ // Promote the in-flight tool group (if any) to a permanent log line.
242
+ // Called before any non-tool text is logged, so the group "freezes"
243
+ // exactly the way blessed updates the line in place when the next text
244
+ // arrives.
245
+ const flushActiveMerge = useCallback(() => {
246
+ setActiveMerge((current) => {
247
+ if (!current) return null;
248
+ appendLogLine(renderMergeText(current));
249
+ return null;
250
+ });
251
+ }, [appendLogLine, renderMergeText]);
252
+
253
+ const logToolHint = useCallback((entry, payload) => {
254
+ const tool = String((entry && entry.tool) || "").trim().toLowerCase();
255
+ if (!tool) return;
256
+ const resObj = payload && typeof payload === "object" ? payload : (entry && entry.result) || {};
257
+ const phase = String((entry && entry.phase) || "").trim().toLowerCase();
258
+ const isError = phase === "error" || resObj.ok === false;
259
+ const detail = tool === "bash" ? fmt.normalizeBashToolCommand(entry && entry.args, resObj) : "";
260
+ const errorText = String((entry && entry.error) || resObj.error || "").trim();
261
+ const toolEntry = fmt.normalizeToolMergeEntry({ tool, detail, isError, errorText });
262
+
263
+ setActiveMerge((current) => {
264
+ let next;
265
+ if (current) {
266
+ next = { ...current, entries: current.entries.concat([toolEntry]) };
267
+ } else {
268
+ mergeIdRef.current += 1;
269
+ next = { id: mergeIdRef.current, entries: [toolEntry], expanded: false };
270
+ }
271
+ if (next.entries.length >= 2) lastMergeRef.current = next;
272
+ return next;
273
+ });
274
+ }, []);
275
+
276
+ const appendLogText = useCallback((text) => {
277
+ // Multi-line text → split into separate log entries so <Static> keys
278
+ // stay stable when streaming arrives line-by-line. Always promote any
279
+ // in-flight tool group first so it freezes above the new text.
280
+ const raw = String(text == null ? "" : text);
281
+ if (!raw) return;
282
+ flushActiveMerge();
283
+ const lines = raw.split(/\r?\n/);
284
+ for (const line of lines) appendLogLine(line);
285
+ }, [appendLogLine, flushActiveMerge]);
286
+
287
+ const expandLastMerge = useCallback(() => {
288
+ // Try the active group first; fall back to the most recent frozen one.
289
+ // Both paths must keep the "expand only once" guarantee that blessed
290
+ // enforces via group.expanded.
291
+ const active = activeMerge;
292
+ const candidate = (active && !active.expanded && active.entries.length >= 2)
293
+ ? active
294
+ : (lastMergeRef.current && !lastMergeRef.current.expanded && lastMergeRef.current.entries.length >= 2
295
+ ? lastMergeRef.current
296
+ : null);
297
+ if (!candidate) return;
298
+
299
+ const lines = fmt.buildMergedToolExpandedLines(candidate.entries);
300
+ for (let i = 0; i < lines.length; i += 1) {
301
+ const branch = i === lines.length - 1 ? "└" : "│";
302
+ appendLogLine(`${branch} ${lines[i]}`);
303
+ }
304
+ candidate.expanded = true;
305
+ if (active && active.id === candidate.id) setActiveMerge(null);
306
+ if (lastMergeRef.current && lastMergeRef.current.id === candidate.id) {
307
+ lastMergeRef.current = null;
308
+ }
309
+ }, [activeMerge, appendLogLine]);
310
+
311
+ const runChainRef = useRef(Promise.resolve());
312
+
313
+ const executeLine = useCallback(async (rawValue) => {
314
+ const normalized = String(rawValue || "").replace(/\r?\n/g, " ").trim();
315
+ if (!normalized) return;
316
+ appendLogLine(`› ${normalized}`);
317
+
318
+ const runtimeWorkspace = String(
319
+ (props.state && props.state.workspaceRoot) || props.workspaceRoot || process.cwd()
320
+ );
321
+
322
+ let result;
323
+ try {
324
+ result = props.runSingleCommand(normalized, runtimeWorkspace);
325
+ } catch (err) {
326
+ appendLogText(`Error: ${err && err.message ? err.message : "command parse failed"}`);
327
+ return;
328
+ }
329
+ if (!result || typeof result !== "object") return;
330
+
331
+ switch (result.kind) {
332
+ case "empty":
333
+ return;
334
+ case "exit":
335
+ exit();
336
+ return;
337
+ case "probe":
338
+ return;
339
+ case "help":
340
+ case "error":
341
+ appendLogText(result.output || "");
342
+ return;
343
+ case "ubus": {
344
+ setStatus({ message: "Checking bus messages...", type: "typing", showTimer: false, startedAt: Date.now() });
345
+ try {
346
+ const { extractAgentNickname } = require("../../code/agent");
347
+ const ubusResult = await props.runUbusCommand(props.state, {
348
+ workspaceRoot: runtimeWorkspace,
349
+ onMessageReceived: (msg) => {
350
+ const nickname = extractAgentNickname(msg && msg.from) || (msg && msg.from) || "bus";
351
+ appendLogText(`${nickname}: ${(msg && msg.task) || ""}`);
352
+ },
353
+ });
354
+ if (!ubusResult || !ubusResult.ok) {
355
+ appendLogText(`Error: ${(ubusResult && ubusResult.error) || "ubus failed"}`);
356
+ return;
357
+ }
358
+ const exchanges = Array.isArray(ubusResult.messageExchanges) ? ubusResult.messageExchanges : [];
359
+ if (exchanges.length > 0) {
360
+ for (const exchange of exchanges) {
361
+ const nickname = extractAgentNickname(exchange && exchange.from) || (exchange && exchange.from) || "bus";
362
+ appendLogText(`@${nickname} ${(exchange && exchange.reply) || ""}`);
363
+ }
364
+ } else if (Number(ubusResult.handled) === 0) {
365
+ appendLogText("ubus: no pending messages.");
366
+ }
367
+ if (typeof props.persistSessionState === "function") {
368
+ const persisted = props.persistSessionState(props.state);
369
+ if (!persisted || persisted.ok === false) {
370
+ appendLogText(`Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${(persisted && persisted.error) || "unknown error"}`);
371
+ }
372
+ }
373
+ } finally {
374
+ setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
375
+ }
376
+ return;
377
+ }
378
+ case "resume": {
379
+ if (typeof props.resumeSessionState !== "function") {
380
+ appendLogText("Error: resume unsupported");
381
+ return;
382
+ }
383
+ const resumed = props.resumeSessionState(props.state, result.sessionId, runtimeWorkspace);
384
+ if (!resumed || !resumed.ok) {
385
+ appendLogText(`Error: ${(resumed && resumed.error) || "resume failed"}`);
386
+ return;
387
+ }
388
+ appendLogText(`Resumed session ${resumed.sessionId} (${resumed.restoredMessages} messages).`);
389
+ return;
390
+ }
391
+ case "tool": {
392
+ const payload = result.result && typeof result.result === "object" ? result.result : {};
393
+ logToolHint({
394
+ tool: result.tool,
395
+ args: result.args,
396
+ phase: payload.ok === false ? "error" : "end",
397
+ error: payload.error || "",
398
+ }, payload);
399
+ return;
400
+ }
401
+ case "nl_bg": {
402
+ backgroundSeqRef.current += 1;
403
+ const jobId = `bg-${Date.now().toString(36)}-${backgroundSeqRef.current.toString(36)}`;
404
+ const taskRecord = {
405
+ id: jobId,
406
+ task: result.task,
407
+ status: "running",
408
+ startedAt: Date.now(),
409
+ summary: "",
410
+ };
411
+ backgroundTasksRef.current.set(jobId, taskRecord);
412
+ bumpBackground();
413
+ setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
414
+ appendLogText(`[${jobId}] started in background.`);
415
+
416
+ const bgState = {
417
+ workspaceRoot: props.state && props.state.workspaceRoot,
418
+ provider: props.state && props.state.provider,
419
+ model: props.state && props.state.model,
420
+ engine: props.state && props.state.engine,
421
+ context: props.state && props.state.context,
422
+ nlMessages: Array.isArray(props.state && props.state.nlMessages) ? props.state.nlMessages.slice() : [],
423
+ sessionId: "",
424
+ timeoutMs: props.state && props.state.timeoutMs,
425
+ jsonOutput: false,
426
+ };
427
+
428
+ Promise.resolve()
429
+ .then(() => props.runNaturalLanguageTask(result.task, bgState))
430
+ .then((nlResult) => {
431
+ taskRecord.status = nlResult && nlResult.ok ? "done" : "failed";
432
+ taskRecord.finishedAt = Date.now();
433
+ taskRecord.summary = String(props.formatNlResult(nlResult, false) || "").trim();
434
+ const title = taskRecord.status === "done" ? "done" : "failed";
435
+ appendLogText(`[${jobId}] ${title}: ${taskRecord.summary || "no summary"}`);
436
+ })
437
+ .catch((err) => {
438
+ taskRecord.status = "failed";
439
+ taskRecord.finishedAt = Date.now();
440
+ taskRecord.summary = err && err.message ? String(err.message) : "background task failed";
441
+ appendLogText(`[${jobId}] failed: ${taskRecord.summary}`);
442
+ })
443
+ .finally(() => {
444
+ bumpBackground();
445
+ setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
446
+ });
447
+ return;
448
+ }
449
+ case "nl": {
450
+ const startedAt = Date.now();
451
+ const abortController = new AbortController();
452
+ pendingTaskRef.current = { abortController, startedAt };
453
+ const setNlStatus = (msg) => setStatus({
454
+ message: msg,
455
+ type: "thinking",
456
+ showTimer: true,
457
+ startedAt,
458
+ });
459
+ setNlStatus("Waiting for model...");
460
+ let streamBuf = "";
461
+ let sawStreamText = false;
462
+ let nlResult = null;
463
+ try {
464
+ nlResult = await props.runNaturalLanguageTask(result.task, props.state, {
465
+ signal: abortController.signal,
466
+ onPhase: (event) => {
467
+ if (!event || typeof event !== "object") return;
468
+ if (event.type === "request_start") setNlStatus("Waiting for model...");
469
+ else if (event.type === "thinking_delta") setNlStatus("Thinking...");
470
+ else if (event.type === "text_delta") setNlStatus("Generating response...");
471
+ else if (event.type === "tool_request") {
472
+ const label = fmt.TOOL_LABELS[String(event.name || "").toLowerCase()] ||
473
+ `Calling ${event.name}`;
474
+ setNlStatus(`${label}...`);
475
+ }
476
+ },
477
+ onDelta: (delta) => {
478
+ const text = String(delta || "");
479
+ if (!text) return;
480
+ if (/[^\s]/.test(text)) sawStreamText = true;
481
+ streamBuf += text;
482
+ const parts = streamBuf.split(/\r?\n/);
483
+ while (parts.length > 1) {
484
+ appendLogLine(parts.shift());
485
+ }
486
+ streamBuf = parts[0];
487
+ },
488
+ onToolLog: (entry) => {
489
+ if (!entry || typeof entry !== "object") return;
490
+ if (entry.tool && entry.phase === "start") {
491
+ const label = fmt.TOOL_LABELS[String(entry.tool || "").toLowerCase()] ||
492
+ `Calling ${entry.tool}`;
493
+ setNlStatus(`${label}...`);
494
+ }
495
+ logToolHint(entry, entry.result);
496
+ },
497
+ });
498
+ } catch (err) {
499
+ appendLogText(`Error: ${err && err.message ? err.message : "agent loop failed"}`);
500
+ return;
501
+ } finally {
502
+ pendingTaskRef.current = null;
503
+ setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
504
+ }
505
+ if (streamBuf) {
506
+ if (/[^\s]/.test(streamBuf)) sawStreamText = true;
507
+ appendLogLine(streamBuf);
508
+ }
509
+ // Skip the summary echo when the model already streamed its
510
+ // response in full — otherwise the user sees the same text twice.
511
+ // Mirrors the shouldSkipSummary check in tui.js.
512
+ const streamed = Boolean(nlResult && nlResult.streamed);
513
+ const ok = Boolean(nlResult && nlResult.ok);
514
+ const shouldSkipSummary = streamed && ok && sawStreamText;
515
+ if (!shouldSkipSummary) {
516
+ const summary = props.formatNlResult(nlResult, false);
517
+ if (summary) appendLogText(summary);
518
+ }
519
+ try {
520
+ const persisted = props.persistSessionState(props.state);
521
+ if (persisted && persisted.ok === false) {
522
+ appendLogText(
523
+ `Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${persisted.error || "unknown error"}`
524
+ );
525
+ }
526
+ } catch {
527
+ // persistSessionState failures shouldn't crash the TUI.
528
+ }
529
+ return;
530
+ }
531
+ default:
532
+ if (result.output) appendLogText(result.output);
533
+ }
534
+ }, [appendLogLine, appendLogText, exit, props, logToolHint]);
535
+ // ^ `props` is captured by the createUcodeApp closure on a single mount,
536
+ // so its reference is stable across renders even though it looks like a
537
+ // changing dep to React's exhaustive-deps lint.
538
+
539
+ const runAutoBusOnce = useCallback(async () => {
540
+ const autoBus = props.autoBus || {};
541
+ if (!autoBus.enabled || pendingTaskRef.current) return;
542
+ const getPendingCount = typeof autoBus.getPendingCount === "function"
543
+ ? autoBus.getPendingCount
544
+ : () => 0;
545
+ if (Number(getPendingCount()) <= 0) {
546
+ autoBusErrorRef.current = "";
547
+ return;
548
+ }
549
+
550
+ const abortController = new AbortController();
551
+ const startedAt = Date.now();
552
+ pendingTaskRef.current = { abortController, startedAt };
553
+ setStatus({
554
+ message: "Processing bus messages...",
555
+ type: "thinking",
556
+ showTimer: true,
557
+ startedAt,
558
+ });
559
+
560
+ try {
561
+ const { extractAgentNickname } = require("../../code/agent");
562
+ const ubusResult = await props.runUbusCommand(props.state, {
563
+ workspaceRoot: props.workspaceRoot,
564
+ subscriberId: autoBus.subscriberId,
565
+ signal: abortController.signal,
566
+ onMessageReceived: (msg) => {
567
+ const nickname = extractAgentNickname(msg && msg.from) || (msg && msg.from) || "bus";
568
+ appendLogText(`${nickname}: ${(msg && msg.task) || ""}`);
569
+ setStatus({
570
+ message: "Working on task...",
571
+ type: "thinking",
572
+ showTimer: true,
573
+ startedAt,
574
+ });
575
+ },
576
+ });
577
+
578
+ if (!ubusResult || !ubusResult.ok) {
579
+ const nextError = String((ubusResult && ubusResult.error) || "ubus failed");
580
+ if (nextError !== autoBusErrorRef.current) {
581
+ autoBusErrorRef.current = nextError;
582
+ appendLogText(`Error: ${nextError}`);
583
+ }
584
+ return;
585
+ }
586
+
587
+ autoBusErrorRef.current = "";
588
+ const exchanges = Array.isArray(ubusResult.messageExchanges) ? ubusResult.messageExchanges : [];
589
+ for (const exchange of exchanges) {
590
+ const nickname = extractAgentNickname(exchange && exchange.from) || (exchange && exchange.from) || "bus";
591
+ appendLogText(`@${nickname} ${(exchange && exchange.reply) || ""}`);
592
+ }
593
+ if (Number(ubusResult.handled) > 0 && typeof props.persistSessionState === "function") {
594
+ const persisted = props.persistSessionState(props.state);
595
+ if (!persisted || persisted.ok === false) {
596
+ appendLogText(`Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${(persisted && persisted.error) || "unknown error"}`);
597
+ }
598
+ }
599
+ } finally {
600
+ pendingTaskRef.current = null;
601
+ setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
602
+ }
603
+ }, [appendLogText, props]);
604
+
605
+ useEffect(() => {
606
+ if (!interactive || !(props.autoBus && props.autoBus.enabled)) return undefined;
607
+ const schedule = () => {
608
+ if (autoBusQueuedRef.current || pendingTaskRef.current) return;
609
+ const getPendingCount = typeof props.autoBus.getPendingCount === "function"
610
+ ? props.autoBus.getPendingCount
611
+ : () => 0;
612
+ if (Number(getPendingCount()) <= 0) return;
613
+ autoBusQueuedRef.current = true;
614
+ runChainRef.current = runChainRef.current
615
+ .then(() => runAutoBusOnce())
616
+ .catch((err) => appendLogText(`Error: ${err && err.message ? err.message : "ubus failed"}`))
617
+ .finally(() => {
618
+ autoBusQueuedRef.current = false;
619
+ });
620
+ };
621
+ const timer = setInterval(schedule, 1500);
622
+ schedule();
623
+ return () => clearInterval(timer);
624
+ }, [interactive, props.autoBus, runAutoBusOnce, appendLogText]);
625
+
626
+ const submit = useCallback((submitted) => {
627
+ const value = String(submitted == null ? draft : submitted);
628
+ const trimmed = value.trim();
629
+ if (!trimmed) return;
630
+ setDraft("");
631
+ setInputHistory((prev) => {
632
+ const next = prev.concat([trimmed]).slice(-200);
633
+ setHistoryIndex(next.length);
634
+ return next;
635
+ });
636
+ // Serialize executions so streaming tasks don't interleave.
637
+ runChainRef.current = runChainRef.current
638
+ .then(() => executeLine(value))
639
+ .catch((err) => appendLogText(`Error: ${err && err.message ? err.message : err}`));
640
+ }, [draft, executeLine, appendLogText]);
641
+
642
+ useEffect(() => {
643
+ if (!stdout) return undefined;
644
+ const update = () =>
645
+ setSize({ cols: stdout.columns || 0, rows: stdout.rows || 0 });
646
+ update();
647
+ stdout.on("resize", update);
648
+ return () => stdout.off("resize", update);
649
+ }, [stdout]);
650
+
651
+ // Drive the spinner + elapsed-timer redraws while a task is in flight.
652
+ useEffect(() => {
653
+ if (!status.message || status.type === "none") return undefined;
654
+ const timer = setInterval(() => {
655
+ setSpinnerTick((t) => t + 1);
656
+ if (status.showTimer) setNowTick((t) => t + 1);
657
+ }, 100);
658
+ return () => clearInterval(timer);
659
+ }, [status.message, status.type, status.showTimer]);
660
+
661
+ const statusText = useMemoStatusText(React, status, spinnerTick, getBackgroundSuffix());
662
+
663
+ // Top-level only catches Ctrl+C and Ctrl+O (expand last tool group);
664
+ // the editor handles all text editing.
665
+ useInput((input, key) => {
666
+ if (key.ctrl && input === "c") { exit(); return; }
667
+ if (key.ctrl && input === "o") { expandLastMerge(); return; }
668
+ }, { isActive: interactive });
669
+
670
+ return h(Box, { flexDirection: "column", width: "100%" },
671
+ h(Static, { items: logLines }, (item) =>
672
+ h(Text, { key: item.id }, item.text || " ")
673
+ ),
674
+ activeMerge ? h(Box, null,
675
+ h(Text, { color: activeMerge.entries.some((e) => e.isError) ? "red" : "cyan" },
676
+ renderMergeText(activeMerge)
677
+ ),
678
+ ) : null,
679
+ h(Box, { marginTop: 1, width: "100%" },
680
+ h(Text, { color: "gray" }, statusText),
681
+ h(Box, { flexGrow: 1 }),
682
+ h(Text, { color: "gray" }, `v${fmt.UCODE_VERSION}`),
683
+ ),
684
+ h(Box, { width: "100%" },
685
+ h(MultilineInput, {
686
+ value: draft,
687
+ onChange: (next) => setDraft(next),
688
+ onSubmit: (value) => submit(value),
689
+ onCancel: () => {
690
+ // If a task is in flight, Esc requests cancellation. Otherwise
691
+ // it clears the agent selection (matches blessed). The text
692
+ // value is left alone so the user doesn't lose what they typed.
693
+ const pending = pendingTaskRef.current;
694
+ if (pending && pending.abortController && !pending.abortController.signal.aborted) {
695
+ try { pending.abortController.abort(); } catch { /* ignore */ }
696
+ appendLogLine("⚙ Cancellation requested. Stopping the current task...");
697
+ setStatus({
698
+ message: "Cancelling...",
699
+ type: "waiting",
700
+ showTimer: true,
701
+ startedAt: pending.startedAt,
702
+ });
703
+ return;
704
+ }
705
+ if (agentSelectionMode) {
706
+ setAgentSelectionMode(false);
707
+ setSelectedAgentIndex(-1);
708
+ }
709
+ },
710
+ onArrowDownAtBottom: onArrowDownAtEnd,
711
+ onArrowUpAtTop: onArrowUpAtStart,
712
+ onArrowLeftAtEmpty: () => onArrowSideAtEmpty("left"),
713
+ onArrowRightAtEmpty: () => onArrowSideAtEmpty("right"),
714
+ width: Math.max(20, (size.cols || 80) - 4),
715
+ interactive,
716
+ placeholder: "",
717
+ promptPrefix: targetAgent ? `›@${getAgentLabel(targetAgent)} ` : "› ",
718
+ }),
719
+ ),
720
+ h(Box, { width: "100%" },
721
+ h(Text, { wrap: "truncate", color: "gray" }, "Agents: "),
722
+ agents.length === 0
723
+ ? h(Text, { wrap: "truncate", color: "cyan" }, "none")
724
+ : (() => {
725
+ const labels = agents.map((a) => `@${getAgentLabel(a)}`);
726
+ // Reserve 1 col for borders, the "Agents: " prefix, the hint
727
+ // and a few spaces for safety. We just clamp aggressively
728
+ // when stdout.cols is unknown.
729
+ const cols = size.cols || 80;
730
+ const reservedForHint = fmt.displayCellWidth(` · ${agentsHint}`);
731
+ const budget = Math.max(20, cols - 10 - reservedForHint);
732
+ const plan = fmt.planAgentsFooter(
733
+ labels,
734
+ agentSelectionMode ? selectedAgentIndex : -1,
735
+ budget
736
+ );
737
+ return h(React.Fragment, null,
738
+ ...plan.items.map((item, idx) =>
739
+ h(React.Fragment, { key: idx },
740
+ idx > 0 ? h(Text, { color: "gray" }, " ") : null,
741
+ h(Text, {
742
+ wrap: "truncate",
743
+ color: item.selected ? undefined : "cyan",
744
+ inverse: item.selected,
745
+ }, item.label),
746
+ )
747
+ ),
748
+ plan.hint
749
+ ? h(Text, { wrap: "truncate", color: "gray" }, plan.hint)
750
+ : null,
751
+ );
752
+ })(),
753
+ h(Text, { wrap: "truncate", color: "gray" }, ` · ${agentsHint}`),
754
+ ),
755
+ );
756
+ };
757
+ }
758
+
759
+ function runUcodeInkTui(props = {}) {
760
+ return new Promise((resolve, reject) => {
761
+ runInk(
762
+ (React, ink) => {
763
+ const UcodeApp = createUcodeApp({ React, ink, props });
764
+ return React.createElement(UcodeApp);
765
+ },
766
+ {
767
+ stdin: props.stdin || process.stdin,
768
+ stdout: props.stdout || process.stdout,
769
+ exitOnCtrlC: true,
770
+ }
771
+ )
772
+ .then(async (handle) => {
773
+ try {
774
+ await handle.waitUntilExit();
775
+ resolve({ code: 0 });
776
+ } catch (err) {
777
+ reject(err);
778
+ }
779
+ })
780
+ .catch(reject);
781
+ });
782
+ }
783
+
784
+ module.exports = { runUcodeInkTui, createUcodeApp, computeStatusText };
785
+
786
+ /**
787
+ * Pure status-line text builder used by the React component (and unit
788
+ * tests). Returns "UCODE · Ready" while idle and a spinner+message+timer
789
+ * combination while a task is in flight, mirroring updateStatus() in the
790
+ * blessed implementation.
791
+ */
792
+ function computeStatusText(status, spinnerTick, backgroundSuffix = "") {
793
+ const message = String((status && status.message) || "");
794
+ const suffix = String(backgroundSuffix || "");
795
+ if (!message) return `UCODE · Ready${suffix}`;
796
+ const type = String((status && status.type) || "thinking");
797
+ const indicators = fmt.STATUS_INDICATORS[type] || fmt.STATUS_INDICATORS.thinking;
798
+ const indicator = indicators[Math.max(0, Math.floor(Number(spinnerTick) || 0)) % indicators.length];
799
+ const startedAt = Number.isFinite(status && status.startedAt) ? status.startedAt : 0;
800
+ const timerText = status && status.showTimer && startedAt
801
+ ? ` (${fmt.formatPendingElapsed(Date.now() - startedAt)}, esc cancel)`
802
+ : "";
803
+ return `${indicator} ${message}${timerText}${suffix}`;
804
+ }
805
+
806
+ function useMemoStatusText(React, status, spinnerTick, backgroundSuffix = "") {
807
+ // Dependencies intentionally include startedAt so the timer ticks even
808
+ // when the message string is unchanged.
809
+ return React.useMemo(
810
+ () => computeStatusText(status, spinnerTick, backgroundSuffix),
811
+ [status, spinnerTick, backgroundSuffix]
812
+ );
813
+ }