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