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,3674 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Ink-based chat TUI rendered via React + ink.
5
+ *
6
+ * Activation: this is the only chat TUI.
7
+ *
8
+ * Coverage today: layout shell + dashboard bar (5 modes: projects, agents,
9
+ * mode, provider, cron) + multiline editor + status line +
10
+ * Tab/Esc focus + agent selection + Up/Down history, daemon routing,
11
+ * command execution, completion and internal-agent views.
12
+ *
13
+ * Chat state is kept in chatReducer.js so the entire transition table can
14
+ * be exercised by jest without mounting ink.
15
+ */
16
+
17
+ const path = require("path");
18
+ const fs = require("fs");
19
+ const crypto = require("crypto");
20
+
21
+ const { runInk } = require("../runInk");
22
+ const fmt = require("../format");
23
+ const { createMultilineInput } = require("./MultilineInput");
24
+ const { createDashboardBar } = require("./DashboardBar");
25
+ const { reducer, createInitialState } = require("./chatReducer");
26
+
27
+ function bootstrapEnvironment(projectRoot, options = {}) {
28
+ // Ensure ufoo dirs exist and that we have a stable subscriber ID.
29
+ // We deliberately keep the
30
+ // non-UI side-effects in their own helper so unit tests can assert on
31
+ // them without importing ink.
32
+ const { canonicalProjectRoot } = require("../../runtime/projects");
33
+ const { getUfooPaths } = require("../../coordination/state/paths");
34
+ const UfooInit = require("../../app/cli/features/init");
35
+ const { isRunning } = require("../../runtime/daemon");
36
+ const { startDaemon } = require("../../app/chat/transport");
37
+
38
+ const globalMode = options && options.globalMode === true;
39
+ let activeProjectRoot = projectRoot;
40
+ try {
41
+ activeProjectRoot = canonicalProjectRoot(projectRoot);
42
+ } catch {
43
+ activeProjectRoot = path.resolve(projectRoot || process.cwd());
44
+ }
45
+
46
+ const runtimePaths = getUfooPaths(projectRoot);
47
+ const contextIndexFile = path.join(runtimePaths.ufooDir, "context", "decisions.jsonl");
48
+ const needsBootstrap = globalMode && (
49
+ !fs.existsSync(runtimePaths.ufooDir)
50
+ || !fs.existsSync(runtimePaths.busDir)
51
+ || !fs.existsSync(runtimePaths.agentDir)
52
+ || !fs.existsSync(contextIndexFile)
53
+ );
54
+
55
+ return {
56
+ activeProjectRoot,
57
+ globalMode,
58
+ runtimePaths,
59
+ needsBootstrap,
60
+ UfooInit,
61
+ isRunning,
62
+ startDaemon,
63
+ };
64
+ }
65
+
66
+ async function ensureSubscriberId(projectRoot) {
67
+ if (process.env.UFOO_SUBSCRIBER_ID) return;
68
+ const { getUfooPaths } = require("../../coordination/state/paths");
69
+ const sessionFile = path.join(getUfooPaths(projectRoot).ufooDir, "chat", "session-id.txt");
70
+ const sessionDir = path.dirname(sessionFile);
71
+ fs.mkdirSync(sessionDir, { recursive: true });
72
+ let sessionId;
73
+ if (fs.existsSync(sessionFile)) {
74
+ sessionId = fs.readFileSync(sessionFile, "utf8").trim();
75
+ } else {
76
+ sessionId = crypto.randomBytes(4).toString("hex");
77
+ fs.writeFileSync(sessionFile, sessionId, "utf8");
78
+ }
79
+ process.env.UFOO_SUBSCRIBER_ID = `claude-code:${sessionId}`;
80
+ }
81
+
82
+ function inputHistoryFilePath(projectRoot, options = {}) {
83
+ const { getUfooPaths } = require("../../coordination/state/paths");
84
+ const { globalMode } = options || {};
85
+ if (globalMode) {
86
+ const os = require("os");
87
+ const globalChatRoot = path.join(os.homedir(), ".ufoo", "chat");
88
+ const globalDir = path.join(globalChatRoot, "global-input-history");
89
+ const projectId = projectRootToId(projectRoot);
90
+ return path.join(globalDir, `${projectId}.jsonl`);
91
+ }
92
+ return path.join(getUfooPaths(projectRoot || process.cwd()).ufooDir, "chat", "input-history.jsonl");
93
+ }
94
+
95
+ function chatHistoryFilePath(projectRoot, options = {}) {
96
+ const { getUfooPaths } = require("../../coordination/state/paths");
97
+ const { globalMode } = options || {};
98
+ if (globalMode) {
99
+ const os = require("os");
100
+ const globalChatRoot = path.join(os.homedir(), ".ufoo", "chat");
101
+ const globalDir = path.join(globalChatRoot, "global-history");
102
+ const projectId = projectRootToId(projectRoot);
103
+ return path.join(globalDir, `${projectId}.jsonl`);
104
+ }
105
+ return path.join(getUfooPaths(projectRoot || process.cwd()).ufooDir, "chat", "history.jsonl");
106
+ }
107
+
108
+ function projectRootToId(projectRoot) {
109
+ try {
110
+ const { buildProjectId } = require("../../runtime/projects");
111
+ return buildProjectId(projectRoot || process.cwd());
112
+ } catch {
113
+ return crypto.createHash("sha256").update(String(projectRoot || "")).digest("hex").slice(0, 16);
114
+ }
115
+ }
116
+
117
+ function resolveInjectSockPathForAgent(projectRoot, agentId) {
118
+ const { getUfooPaths } = require("../../coordination/state/paths");
119
+ const { subscriberToSafeName } = require("../../coordination/bus/utils");
120
+ const safeName = subscriberToSafeName(agentId);
121
+ return path.join(getUfooPaths(projectRoot || process.cwd()).busQueuesDir, safeName, "inject.sock");
122
+ }
123
+
124
+ function createInkMultiWindowToggle({
125
+ getController = () => null,
126
+ setActive = () => {},
127
+ logMessage = () => {},
128
+ } = {}) {
129
+ return () => {
130
+ const controller = typeof getController === "function" ? getController() : null;
131
+ if (!controller || typeof controller.enter !== "function" || typeof controller.exit !== "function") {
132
+ logMessage("error", "✗ Multi-window mode is not available");
133
+ return false;
134
+ }
135
+
136
+ if (typeof controller.isActive === "function" && controller.isActive()) {
137
+ controller.exit();
138
+ setActive(false);
139
+ return true;
140
+ }
141
+
142
+ setActive(true);
143
+ if (!controller.enter()) {
144
+ setActive(false);
145
+ logMessage("info", "No active agents for multi-window mode");
146
+ return false;
147
+ }
148
+ return true;
149
+ };
150
+ }
151
+
152
+ function loadChatHistory(projectRoot, cap = 200, options = {}) {
153
+ const file = chatHistoryFilePath(projectRoot, options);
154
+ try {
155
+ if (!fs.existsSync(file)) return [];
156
+ const raw = fs.readFileSync(file, "utf8");
157
+ const lines = raw.split(/\r?\n/).filter(Boolean);
158
+ const out = [];
159
+ for (const line of lines) {
160
+ try {
161
+ const entry = JSON.parse(line);
162
+ if (!entry) continue;
163
+ if (entry.type === "spacer") {
164
+ out.push("");
165
+ continue;
166
+ }
167
+ const text = String(entry.text || "");
168
+ if (!text) continue;
169
+ // Strip blessed-tag markup that the legacy log writer used; ink
170
+ // can't render those tags and we don't want them shown literally.
171
+ const stripped = text.replace(/\{[^{}]+\}/g, "");
172
+ out.push(stripped);
173
+ } catch {
174
+ // ignore malformed lines
175
+ }
176
+ }
177
+ return out.slice(-cap);
178
+ } catch {
179
+ return [];
180
+ }
181
+ }
182
+
183
+ function loadInputHistory(projectRoot, cap = 200, options = {}) {
184
+ const file = inputHistoryFilePath(projectRoot, options);
185
+ try {
186
+ if (!fs.existsSync(file)) return [];
187
+ const raw = fs.readFileSync(file, "utf8");
188
+ const lines = raw.split(/\r?\n/).filter(Boolean);
189
+ const out = [];
190
+ for (const line of lines) {
191
+ try {
192
+ const obj = JSON.parse(line);
193
+ const value = String((obj && obj.value) || "").trim();
194
+ if (value) out.push(value);
195
+ } catch {
196
+ // ignore malformed entries
197
+ }
198
+ }
199
+ return out.slice(-cap);
200
+ } catch {
201
+ return [];
202
+ }
203
+ }
204
+
205
+ function appendInputHistory(projectRoot, value, options = {}) {
206
+ const trimmed = String(value || "").trim();
207
+ if (!trimmed) return;
208
+ const file = inputHistoryFilePath(projectRoot, options);
209
+ try {
210
+ fs.mkdirSync(path.dirname(file), { recursive: true });
211
+ fs.appendFileSync(file, `${JSON.stringify({ value: trimmed, ts: Date.now() })}\n`);
212
+ } catch {
213
+ // best-effort persistence; failure is not user-visible
214
+ }
215
+ }
216
+
217
+ function appendChatHistory(projectRoot, type, text, meta = {}, options = {}) {
218
+ const value = String(text || "");
219
+ if (!value && type !== "spacer") return;
220
+ const file = chatHistoryFilePath(projectRoot, options);
221
+ try {
222
+ fs.mkdirSync(path.dirname(file), { recursive: true });
223
+ fs.appendFileSync(file, `${JSON.stringify({
224
+ ts: new Date().toISOString(),
225
+ type,
226
+ text: value,
227
+ meta: meta && typeof meta === "object" ? meta : {},
228
+ })}\n`);
229
+ } catch {
230
+ // best-effort persistence; failure is not user-visible
231
+ }
232
+ }
233
+
234
+ function chatHistoryOptionsForScope({ globalMode = false, globalScope = "controller" } = {}) {
235
+ return {
236
+ globalMode: Boolean(globalMode && globalScope !== "project"),
237
+ };
238
+ }
239
+
240
+ function getAgentLabelFor(meta, agentId) {
241
+ // Prefer the project-stripped display nickname so the dashboard never shows
242
+ // the scoped form ("neptune-builder"); fall back to the raw nickname (which
243
+ // may itself be unscoped depending on write path) and finally to a short
244
+ // form of the subscriber id.
245
+ if (meta && meta.display_nickname) return meta.display_nickname;
246
+ if (meta && meta.nickname) return meta.nickname;
247
+ if (!agentId) return "";
248
+ const colon = agentId.indexOf(":");
249
+ if (colon < 0) return agentId;
250
+ const head = agentId.slice(0, colon);
251
+ const tail = agentId.slice(colon + 1).slice(0, 6);
252
+ return tail ? `${head}:${tail}` : head;
253
+ }
254
+
255
+ function buildActiveAgentLabelMap(activeAgents = [], activeAgentMeta = new Map()) {
256
+ const out = new Map();
257
+ const metaMap = activeAgentMeta instanceof Map ? activeAgentMeta : new Map();
258
+ for (const id of Array.isArray(activeAgents) ? activeAgents : []) {
259
+ out.set(id, getAgentLabelFor(metaMap.get(id), id));
260
+ }
261
+ return out;
262
+ }
263
+
264
+ function resolveActiveAgentId(label, activeAgents = [], activeAgentMeta = new Map()) {
265
+ const { resolveAgentId } = require("../../app/chat/agentDirectory");
266
+ const metaMap = activeAgentMeta instanceof Map ? activeAgentMeta : new Map();
267
+ return resolveAgentId({
268
+ label,
269
+ activeAgents: Array.isArray(activeAgents) ? activeAgents : [],
270
+ labelMap: buildActiveAgentLabelMap(activeAgents, metaMap),
271
+ lookupNickname: (nickname) => {
272
+ for (const [id, meta] of metaMap.entries()) {
273
+ if (!meta) continue;
274
+ if (meta.nickname === nickname || meta.scoped_nickname === nickname || meta.display_nickname === nickname) {
275
+ return id;
276
+ }
277
+ }
278
+ return null;
279
+ },
280
+ });
281
+ }
282
+
283
+ function buildDirectBusSendRequest({
284
+ text,
285
+ targetAgentId = null,
286
+ activeAgents = [],
287
+ activeAgentMeta = new Map(),
288
+ } = {}) {
289
+ const trimmed = String(text || "").trim();
290
+ if (!trimmed) return null;
291
+ if (targetAgentId) {
292
+ return {
293
+ target: targetAgentId,
294
+ message: trimmed,
295
+ source: "chat-direct",
296
+ };
297
+ }
298
+
299
+ const { parseAtTarget } = require("../../app/chat/commands");
300
+ const atTarget = parseAtTarget(trimmed);
301
+ if (!atTarget || !atTarget.message) return null;
302
+ const target = resolveActiveAgentId(atTarget.target, activeAgents, activeAgentMeta) || atTarget.target;
303
+ return {
304
+ target,
305
+ message: atTarget.message.trim(),
306
+ source: "chat-direct",
307
+ };
308
+ }
309
+
310
+ function resolveAgentEnterRequest({
311
+ agentId,
312
+ projectRoot = "",
313
+ activeAgentMeta = new Map(),
314
+ settings = {},
315
+ } = {}) {
316
+ const id = String(agentId || "").trim();
317
+ if (!id) return null;
318
+
319
+ const metaMap = activeAgentMeta instanceof Map ? activeAgentMeta : new Map();
320
+ const meta = metaMap.get(id) || {};
321
+ const configuredLaunchMode = settings && settings.launchMode && settings.launchMode !== "auto"
322
+ ? settings.launchMode
323
+ : "";
324
+ const launchMode = String(meta.launch_mode || meta.launchMode || configuredLaunchMode || "").trim();
325
+ const { createTerminalAdapterRouter } = require("../../runtime/terminal/adapterRouter");
326
+ const adapter = createTerminalAdapterRouter().getAdapter({ launchMode, agentId: id, meta });
327
+ const caps = adapter && adapter.capabilities ? adapter.capabilities : {};
328
+
329
+ return {
330
+ agentId: id,
331
+ projectRoot: String(projectRoot || ""),
332
+ launchMode,
333
+ useBus: Boolean(caps.supportsInternalQueueLoop && !caps.supportsSocketProtocol),
334
+ supportsSocket: Boolean(caps.supportsSocketProtocol),
335
+ supportsInternalQueue: Boolean(caps.supportsInternalQueueLoop),
336
+ supportsActivate: Boolean(caps.supportsActivate),
337
+ };
338
+ }
339
+
340
+ function resolveDashboardAgentEnterAction(enterRequest = {}) {
341
+ if (!enterRequest || typeof enterRequest !== "object") return "none";
342
+ if (enterRequest.useBus) return "internal";
343
+ if (enterRequest.supportsActivate) return "activate";
344
+ return "agent-view";
345
+ }
346
+
347
+ function buildEmptyProjectsDownActions(state = {}, displayAgents = []) {
348
+ if (!state.emptyProjectsDownArmed) {
349
+ return [{ type: "projects/armEmptyDown" }];
350
+ }
351
+ const actions = [{ type: "view/set", view: "agents" }];
352
+ if (displayAgents.length > 0 && state.selectedAgentIndex < 0) {
353
+ actions.push({ type: "agents/select", index: 0 });
354
+ }
355
+ return actions;
356
+ }
357
+
358
+ function buildPromptIpcRequest(text) {
359
+ const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
360
+ return {
361
+ type: IPC_REQUEST_TYPES.PROMPT,
362
+ text,
363
+ request_meta: {
364
+ source: "chat-dialog",
365
+ dispatch_default_injection_mode: "immediate",
366
+ allow_relevance_queue: true,
367
+ },
368
+ };
369
+ }
370
+
371
+ function stripBlessedTags(text = "") {
372
+ return String(text || "")
373
+ .replace(/\{\/?[^{}\n]+\}/g, "")
374
+ .replace(/\r/g, "");
375
+ }
376
+
377
+ function normalizeInkLogLines(text = "") {
378
+ const clean = stripBlessedTags(text);
379
+ return clean.split(/\r?\n/);
380
+ }
381
+
382
+ function stripMarkdownDecorators(text = "") {
383
+ return String(text || "")
384
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
385
+ .replace(/`([^`]+)`/g, "$1");
386
+ }
387
+
388
+ function classifyChatLogLine(text = "") {
389
+ const raw = stripBlessedTags(text).replace(/\r/g, "");
390
+ const clean = stripMarkdownDecorators(raw);
391
+ const trimmed = clean.trim();
392
+ if (!trimmed) return { kind: "spacer", marker: " ", speaker: "", body: " " };
393
+ if (/^[█▀▄ ]+$/.test(trimmed) || /^ufoo chat/i.test(trimmed)) {
394
+ return { kind: "banner", marker: " ", speaker: "", body: clean };
395
+ }
396
+ if (/^───.*───$/.test(trimmed)) {
397
+ return { kind: "divider", marker: "─", speaker: "", body: clean };
398
+ }
399
+ if (/^(error:|✗|failed\b)/i.test(trimmed)) {
400
+ return { kind: "error", marker: "!", speaker: "error", body: clean.replace(/^(error:\s*)/i, "") };
401
+ }
402
+ if (/^(✓|✔|done\b|closed\b)/i.test(trimmed)) {
403
+ return { kind: "success", marker: "✓", speaker: "", body: clean.replace(/^[✓✔]\s*/, "") };
404
+ }
405
+ const dotMatch = clean.match(/^([^·:\n]{1,42})\s+·\s+(.*)$/);
406
+ if (dotMatch) {
407
+ const speaker = dotMatch[1].trim();
408
+ const lower = speaker.toLowerCase();
409
+ const kind = lower === "ufoo" ? "assistant" : "agent";
410
+ return { kind, marker: kind === "assistant" ? "◆" : "●", speaker, body: dotMatch[2] || " " };
411
+ }
412
+ const colonMatch = clean.match(/^([A-Za-z0-9_.:@/-]{1,42}):\s+(.*)$/);
413
+ if (colonMatch) {
414
+ return { kind: "agent", marker: "●", speaker: colonMatch[1], body: colonMatch[2] || " " };
415
+ }
416
+ if (/^(CHAT|UCODE)\s+·/i.test(trimmed)) {
417
+ return { kind: "meta", marker: "·", speaker: "", body: clean };
418
+ }
419
+ return { kind: "plain", marker: "│", speaker: "", body: clean };
420
+ }
421
+
422
+ function createInkStreamState({
423
+ dispatch,
424
+ appendHistory,
425
+ displayNameForPublisher = (value) => value,
426
+ } = {}) {
427
+ const streams = new Map();
428
+ const pendingDeliveries = new Map();
429
+
430
+ function deliveryKey(agentId, agentLabel) {
431
+ return String(agentId || agentLabel || "").trim();
432
+ }
433
+
434
+ function markPendingDelivery(agentId, agentLabel) {
435
+ const key = deliveryKey(agentId, agentLabel);
436
+ if (!key) return;
437
+ const existing = pendingDeliveries.get(key) || { count: 0, keys: new Set() };
438
+ existing.count += 1;
439
+ for (const candidate of [agentId, agentLabel]) {
440
+ const value = String(candidate || "").trim();
441
+ if (value) {
442
+ pendingDeliveries.set(value, existing);
443
+ existing.keys.add(value);
444
+ }
445
+ }
446
+ }
447
+
448
+ function getPendingState(publisher, displayName) {
449
+ for (const candidate of [publisher, displayName]) {
450
+ const key = String(candidate || "").trim();
451
+ if (key && pendingDeliveries.has(key)) {
452
+ return { key, state: pendingDeliveries.get(key) };
453
+ }
454
+ }
455
+ return null;
456
+ }
457
+
458
+ function consumePendingDelivery(publisher, displayName) {
459
+ const hit = getPendingState(publisher, displayName);
460
+ if (!hit) return false;
461
+ hit.state.count -= 1;
462
+ if (hit.state.count <= 0) {
463
+ for (const key of hit.state.keys || []) pendingDeliveries.delete(key);
464
+ }
465
+ return true;
466
+ }
467
+
468
+ function beginStream(publisher, prefix, continuationPrefix, meta) {
469
+ const key = String(publisher || "bus");
470
+ let state = streams.get(key);
471
+ if (state) return state;
472
+ const displayName = stripBlessedTags(prefix || displayNameForPublisher(key) || key)
473
+ .replace(/\s*·\s*$/, "")
474
+ .trim() || displayNameForPublisher(key) || key;
475
+ state = {
476
+ publisher: key,
477
+ displayName,
478
+ prefix,
479
+ continuationPrefix,
480
+ full: "",
481
+ meta: meta || {},
482
+ };
483
+ streams.set(key, state);
484
+ dispatch({ type: "stream/begin", publisher: displayName });
485
+ return state;
486
+ }
487
+
488
+ function appendStreamDelta(state, delta) {
489
+ if (!state || !delta) return;
490
+ state.full += String(delta || "");
491
+ dispatch({ type: "stream/delta", publisher: state.displayName || state.publisher, delta: String(delta || "") });
492
+ }
493
+
494
+ function finalizeStream(publisher, meta, reason = "") {
495
+ const key = String(publisher || "bus");
496
+ const state = streams.get(key);
497
+ if (!state) return;
498
+ dispatch({ type: "stream/end" });
499
+ if (typeof appendHistory === "function") {
500
+ const text = state.displayName
501
+ ? `${state.displayName}: ${state.full}`
502
+ : state.full;
503
+ appendHistory("bus", text, { ...(meta || state.meta || {}), stream_done: true, stream_reason: reason });
504
+ }
505
+ streams.delete(key);
506
+ }
507
+
508
+ function hasStream(publisher) {
509
+ return streams.has(String(publisher || "bus"));
510
+ }
511
+
512
+ return {
513
+ markPendingDelivery,
514
+ getPendingState,
515
+ consumePendingDelivery,
516
+ beginStream,
517
+ appendStreamDelta,
518
+ finalizeStream,
519
+ hasStream,
520
+ };
521
+ }
522
+
523
+ function formatShellCommandResultLines(result = {}) {
524
+ const lines = [];
525
+ const stdout = String(result.stdout || "").trimEnd();
526
+ const stderr = String(result.stderr || "").trimEnd();
527
+ if (stdout) lines.push(...stdout.split(/\r?\n/).map((line) => ({ type: "system", text: line })));
528
+ if (stderr) lines.push(...stderr.split(/\r?\n/).map((line) => ({ type: result.ok ? "system" : "error", text: line })));
529
+ if (!stdout && !stderr) lines.push({ type: "system", text: "(no output)" });
530
+ if (!result.ok) {
531
+ const suffix = result.signal ? ` signal ${result.signal}` : ` exit ${result.code != null ? result.code : 1}`;
532
+ lines.push({ type: "error", text: `Command failed:${suffix}` });
533
+ }
534
+ return lines;
535
+ }
536
+
537
+ function fitPlainLine(text = "", width = 80) {
538
+ const limit = Math.max(1, Math.floor(Number(width) || 80));
539
+ const raw = String(text || "").replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
540
+ let out = "";
541
+ let cells = 0;
542
+ for (const char of Array.from(raw)) {
543
+ const charWidth = fmt.charDisplayWidth(char);
544
+ if (cells + charWidth > limit) break;
545
+ out += char;
546
+ cells += charWidth;
547
+ }
548
+ if (out.length < raw.length && limit > 1) {
549
+ while (fmt.displayCellWidth(out) > limit - 1) {
550
+ out = Array.from(out).slice(0, -1).join("");
551
+ }
552
+ out = `${out}…`;
553
+ }
554
+ return out || " ";
555
+ }
556
+
557
+ function stripInternalLogMarkup(text = "") {
558
+ return String(text || "")
559
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
560
+ .replace(/\{\/?[^{}\n]+\}/g, "");
561
+ }
562
+
563
+ function wrapInternalPlainLine(text = "", width = 80) {
564
+ const limit = Math.max(1, Math.floor(Number(width) || 80));
565
+ const clean = stripInternalLogMarkup(text).replace(/\r/g, "");
566
+ if (!clean) return [""];
567
+ const rows = [];
568
+ let row = "";
569
+ let cells = 0;
570
+ for (const char of Array.from(clean)) {
571
+ const charWidth = fmt.charDisplayWidth(char);
572
+ if (cells > 0 && cells + charWidth > limit) {
573
+ rows.push(row);
574
+ row = "";
575
+ cells = 0;
576
+ }
577
+ row += char;
578
+ cells += charWidth;
579
+ }
580
+ rows.push(row);
581
+ return rows;
582
+ }
583
+
584
+ function classifyInternalLogLine(line = "") {
585
+ const raw = stripInternalLogMarkup(line).replace(/\r/g, "");
586
+ if (!raw) return { kind: "spacer", text: "", markdown: false, bold: false };
587
+ if (raw.startsWith("> ")) return { kind: "user", text: raw.slice(2), markdown: false, bold: false };
588
+ if (raw.startsWith("* ")) return { kind: "agent", text: raw.slice(2), markdown: true, bold: false };
589
+ if (/^error:/i.test(raw) || /^\[error\]/i.test(raw)) {
590
+ return { kind: "error", text: raw, markdown: true, bold: false };
591
+ }
592
+ if (/^ufoo internal agent\b/i.test(raw)) {
593
+ return { kind: "system", text: raw, markdown: false, bold: true };
594
+ }
595
+ if (/^(agent|directory):/i.test(raw)) {
596
+ return { kind: "meta", text: raw, markdown: false, bold: false };
597
+ }
598
+ return { kind: "agent", text: raw, markdown: true, bold: false };
599
+ }
600
+
601
+ function internalLogPrefixes(kind) {
602
+ if (kind === "user") return { first: "› ", rest: " " };
603
+ if (kind === "system") return { first: "· ", rest: " " };
604
+ if (kind === "meta") return { first: " ", rest: " " };
605
+ return { first: "", rest: "" };
606
+ }
607
+
608
+ function buildInternalLogRows(lines = [], width = 80, maxRows = 20) {
609
+ const limit = Math.max(1, Math.floor(Number(width) || 80));
610
+ const rows = [];
611
+ const markdownState = {};
612
+ const source = Array.isArray(lines) ? lines : [];
613
+ for (const line of source) {
614
+ const classified = classifyInternalLogLine(line);
615
+ if (classified.kind === "spacer") {
616
+ rows.push({ kind: "spacer", text: " ", bold: false });
617
+ continue;
618
+ }
619
+
620
+ let rendered = [classified.text];
621
+ if (classified.markdown) {
622
+ try {
623
+ rendered = fmt.renderLogLinesWithMarkdown(classified.text, markdownState, (value) => String(value || ""))
624
+ .map(stripInternalLogMarkup);
625
+ } catch {
626
+ rendered = [classified.text];
627
+ }
628
+ }
629
+
630
+ const prefixes = internalLogPrefixes(classified.kind);
631
+ for (const renderedLine of rendered) {
632
+ const chunks = wrapInternalPlainLine(
633
+ renderedLine,
634
+ Math.max(1, limit - fmt.displayCellWidth(prefixes.first)),
635
+ );
636
+ chunks.forEach((chunk, idx) => {
637
+ const prefix = idx === 0 ? prefixes.first : prefixes.rest;
638
+ rows.push({
639
+ kind: classified.kind,
640
+ text: fitPlainLine(`${prefix}${chunk}`, limit),
641
+ bold: classified.bold,
642
+ });
643
+ });
644
+ }
645
+ }
646
+ return rows.slice(-Math.max(1, Math.floor(Number(maxRows) || 20)));
647
+ }
648
+
649
+ function internalInputBoundaries(text = "") {
650
+ const source = String(text || "");
651
+ if (!source) return [0];
652
+ try {
653
+ if (typeof Intl !== "undefined" && typeof Intl.Segmenter === "function") {
654
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
655
+ const boundaries = [0];
656
+ for (const part of segmenter.segment(source)) {
657
+ boundaries.push(part.index + part.segment.length);
658
+ }
659
+ return Array.from(new Set(boundaries)).sort((a, b) => a - b);
660
+ }
661
+ } catch {
662
+ // Fall through.
663
+ }
664
+ const boundaries = [0];
665
+ let offset = 0;
666
+ for (const char of Array.from(source)) {
667
+ offset += char.length;
668
+ boundaries.push(offset);
669
+ }
670
+ return boundaries;
671
+ }
672
+
673
+ function previousInternalBoundary(text = "", cursor = 0) {
674
+ const target = Math.max(0, Math.min(String(text || "").length, cursor));
675
+ let previous = 0;
676
+ for (const boundary of internalInputBoundaries(text)) {
677
+ if (boundary < target) previous = boundary;
678
+ else break;
679
+ }
680
+ return previous;
681
+ }
682
+
683
+ function nextInternalBoundary(text = "", cursor = 0) {
684
+ const source = String(text || "");
685
+ const target = Math.max(0, Math.min(source.length, cursor));
686
+ for (const boundary of internalInputBoundaries(source)) {
687
+ if (boundary > target) return boundary;
688
+ }
689
+ return source.length;
690
+ }
691
+
692
+ function resolveInternalKeyName(input = "", key = {}) {
693
+ const raw = String(input || "");
694
+ if (raw === "\x7f" || raw === "\b" || raw === "\x08") return "backspace";
695
+ if (raw === "\x1b[3~" || raw === "\u001b[3~") return "delete";
696
+ if (key && key.backspace) return "backspace";
697
+ if (key && key.delete) return "backspace";
698
+ if (key && key.name === "backspace") return "backspace";
699
+ if (key && key.name === "delete") return "backspace";
700
+ if (key && key.name) return String(key.name);
701
+ if (key && key.escape) return "escape";
702
+ if (key && key.return) return "return";
703
+ if (key && key.leftArrow) return "left";
704
+ if (key && key.rightArrow) return "right";
705
+ if (key && key.upArrow) return "up";
706
+ if (key && key.downArrow) return "down";
707
+ if (key && key.ctrl && raw.length === 1) return raw.toLowerCase();
708
+ return "";
709
+ }
710
+
711
+ function isInternalViewingAgent(agentId, meta, view = {}, viewingAgentId = "") {
712
+ const id = String(agentId || "").trim();
713
+ if (!id) return false;
714
+ const candidates = new Set([
715
+ viewingAgentId,
716
+ view && view.agentId,
717
+ view && view.label,
718
+ ...((view && Array.isArray(view.aliases)) ? view.aliases : []),
719
+ ].filter(Boolean).map((value) => String(value).trim()).filter(Boolean));
720
+ if (candidates.has(id)) return true;
721
+ const metaIds = [
722
+ meta && meta.fullId,
723
+ meta && meta.agent_id,
724
+ meta && meta.subscriber_id,
725
+ meta && meta.nickname,
726
+ meta && meta.scoped_nickname,
727
+ meta && meta.display_nickname,
728
+ meta && meta.type && meta.id ? `${meta.type}:${meta.id}` : "",
729
+ getAgentLabelFor(meta, id),
730
+ ].filter(Boolean).map((value) => String(value).trim()).filter(Boolean);
731
+ return metaIds.some((value) => candidates.has(value));
732
+ }
733
+
734
+ function compactDisplayProjectRoot(projectRoot = "") {
735
+ const os = require("os");
736
+ const raw = String(projectRoot || process.cwd() || "").trim();
737
+ const home = os.homedir();
738
+ if (home && (raw === home || raw.startsWith(`${home}/`))) return `~${raw.slice(home.length)}`;
739
+ return raw || ".";
740
+ }
741
+
742
+ function buildInternalAgentStartupLines({ agentId = "", label = "", projectRoot = "", width = 80 } = {}) {
743
+ return [
744
+ fitPlainLine(`ufoo internal agent · ${label || agentId}`, width),
745
+ fitPlainLine(`agent: ${agentId}`, width),
746
+ fitPlainLine(`directory: ${compactDisplayProjectRoot(projectRoot)}`, width),
747
+ "",
748
+ ];
749
+ }
750
+
751
+ function createInternalAgentViewState({
752
+ agentId,
753
+ label,
754
+ aliases = [],
755
+ projectRoot,
756
+ width = 80,
757
+ } = {}) {
758
+ let history = [];
759
+ try {
760
+ const { loadInternalAgentLogHistory } = require("../../app/chat/internalAgentLogHistory");
761
+ history = loadInternalAgentLogHistory(projectRoot || process.cwd(), agentId, {
762
+ maxEvents: 400,
763
+ maxLines: 1000,
764
+ });
765
+ } catch {
766
+ history = [];
767
+ }
768
+ const safeAliases = [agentId, label].concat(aliases || []).filter(Boolean).map(String);
769
+ return {
770
+ agentId: String(agentId || ""),
771
+ label: String(label || agentId || ""),
772
+ aliases: Array.from(new Set(safeAliases)),
773
+ projectRoot: String(projectRoot || ""),
774
+ lines: buildInternalAgentStartupLines({ agentId, label, projectRoot, width })
775
+ .concat(history.length > 0 ? history : [""]),
776
+ input: "",
777
+ cursor: 0,
778
+ status: "ready",
779
+ detail: "",
780
+ statusStartedAt: 0,
781
+ barIndex: 0,
782
+ };
783
+ }
784
+
785
+ function appendInternalAgentText(view, text = "", options = {}) {
786
+ const current = view && typeof view === "object" ? view : {};
787
+ const lines = Array.isArray(current.lines) ? current.lines.slice() : [];
788
+ if (lines.length === 0) lines.push("");
789
+ const prefix = options.prefix || "";
790
+ const clean = String(text || "").replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
791
+ .replace(/\r\n/g, "\n")
792
+ .replace(/\r/g, "\n");
793
+ if (prefix && lines[lines.length - 1] !== "") lines.push("");
794
+ if (prefix && lines[lines.length - 1] === "") lines[lines.length - 1] = prefix;
795
+ for (const char of clean) {
796
+ if (char === "\n") {
797
+ lines.push("");
798
+ } else {
799
+ lines[lines.length - 1] += char;
800
+ }
801
+ }
802
+ return {
803
+ ...current,
804
+ lines: lines.slice(-1000),
805
+ };
806
+ }
807
+
808
+ function parseInternalBusPayload(raw = "") {
809
+ let displayMessage = String(raw || "");
810
+ let streamPayload = null;
811
+ try {
812
+ const parsed = JSON.parse(raw);
813
+ if (parsed && typeof parsed === "object" && parsed.reply) {
814
+ displayMessage = parsed.reply;
815
+ } else if (parsed && typeof parsed === "object" && parsed.stream) {
816
+ streamPayload = parsed;
817
+ }
818
+ } catch {
819
+ // Plain text.
820
+ }
821
+ return {
822
+ displayMessage: String(displayMessage || "").replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n").replace(/\\r/g, "\n"),
823
+ streamPayload,
824
+ };
825
+ }
826
+
827
+ function internalStatusLabel(value = "") {
828
+ const state = String(value || "").trim().toLowerCase();
829
+ if (state === "waiting" || state === "waiting_input") return "waiting";
830
+ if (state === "blocked" || state === "error") return "blocked";
831
+ if (state === "busy" || state === "processing" || state === "working") return "working";
832
+ if (state === "idle" || state === "ready") return "ready";
833
+ return state || "ready";
834
+ }
835
+
836
+ function updateInternalViewStatus(view = {}, status = "", detail = "", now = Date.now()) {
837
+ const current = view && typeof view === "object" ? view : {};
838
+ const nextStatus = internalStatusLabel(status || current.status || "");
839
+ const nextDetail = String(detail || "").trim();
840
+ const timed = nextStatus === "working" || nextStatus === "waiting" || nextStatus === "blocked";
841
+ const previousStartedAt = Number.isFinite(current.statusStartedAt) ? current.statusStartedAt : 0;
842
+ const statusStartedAt = timed
843
+ ? (current.status === nextStatus && previousStartedAt ? previousStartedAt : now)
844
+ : 0;
845
+ return {
846
+ ...current,
847
+ status: nextStatus,
848
+ detail: nextDetail,
849
+ statusStartedAt,
850
+ };
851
+ }
852
+
853
+ function applyInternalAgentTermWrite(view = {}, activeAgentId = "", text = "", meta = {}) {
854
+ const current = view && typeof view === "object" ? view : {};
855
+ if (!current.agentId || current.agentId !== activeAgentId) return current;
856
+ const streamPayload = meta && meta.streamPayload && typeof meta.streamPayload === "object"
857
+ ? meta.streamPayload
858
+ : {};
859
+ const done = Boolean((meta && meta.done) || streamPayload.done);
860
+ const rawText = String(text || "");
861
+ const next = rawText
862
+ ? appendInternalAgentText(current, rawText, { prefix: "* " })
863
+ : current;
864
+ if (done) return updateInternalViewStatus(next, "ready", "");
865
+ return updateInternalViewStatus(next, "working", "");
866
+ }
867
+
868
+ function appendInternalErrorToView(view = {}, activeAgentId = "", message = "") {
869
+ const current = view && typeof view === "object" ? view : {};
870
+ if (!current.agentId || current.agentId !== activeAgentId) return current;
871
+ const detail = String(message || "unknown error");
872
+ const lines = Array.isArray(current.lines) ? current.lines : [];
873
+ const separator = lines.length > 0 && lines[lines.length - 1] ? "\n" : "";
874
+ return appendInternalAgentText(
875
+ updateInternalViewStatus(current, "blocked", detail),
876
+ `${separator}Error: ${detail}\n`,
877
+ );
878
+ }
879
+
880
+ function computeInternalStatusText(view = {}, spinnerTick = 0, now = Date.now()) {
881
+ const current = view && typeof view === "object" ? view : {};
882
+ const status = internalStatusLabel(current.status || "");
883
+ const label = String(current.label || current.agentId || "agent").trim();
884
+ const detail = String(current.detail || "").trim();
885
+ if (status === "ready") {
886
+ return `ufoo · ${label} · Ready · Enter send · Esc back`;
887
+ }
888
+ const type = status === "waiting" ? "waiting" : "thinking";
889
+ const indicators = fmt.STATUS_INDICATORS[type] || fmt.STATUS_INDICATORS.thinking;
890
+ const indicator = status === "blocked"
891
+ ? "!"
892
+ : indicators[Math.max(0, Math.floor(Number(spinnerTick) || 0)) % indicators.length];
893
+ const message = status === "waiting"
894
+ ? "Waiting for input"
895
+ : (status === "blocked" ? "Blocked" : "Working");
896
+ const startedAt = Number.isFinite(current.statusStartedAt) ? current.statusStartedAt : 0;
897
+ const timer = startedAt ? ` (${fmt.formatPendingElapsed(now - startedAt)})` : "";
898
+ return `${indicator} ${label} · ${message}${detail ? ` · ${detail}` : ""}${timer} · Esc back`;
899
+ }
900
+
901
+ const CHAT_BANNER_LINES = [
902
+ "█ █ █▀▀ █▀█ █▀▄ █▀▀ █ █ ▄▀█ ▀█▀",
903
+ "█ █ █ █ █ █ █ █ █▀█ █▀█ █ ",
904
+ "▀▀▀ ▀▀▀ ▀▀▀ ▀▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀ ",
905
+ ];
906
+
907
+ function buildChatBannerLines(props, version) {
908
+ const os = require("os");
909
+ const home = os.homedir();
910
+ const root = props.activeProjectRoot || process.cwd();
911
+ const shortRoot = root.startsWith(home) ? root.replace(home, "~") : root;
912
+ const modeLabel = props.globalMode
913
+ ? `global (${props.globalScope || "controller"})`
914
+ : "project";
915
+ const padding = " ".repeat(
916
+ CHAT_BANNER_LINES.reduce((max, line) => Math.max(max, line.length), 0)
917
+ );
918
+ const info = [
919
+ `Version: ${version}`,
920
+ `Mode: ${modeLabel}`,
921
+ `Dictionary: ${shortRoot}`,
922
+ ];
923
+ const rows = Math.max(CHAT_BANNER_LINES.length, info.length);
924
+ const out = [];
925
+ for (let i = 0; i < rows; i += 1) {
926
+ const left = CHAT_BANNER_LINES[i] || padding;
927
+ const right = info[i] || "";
928
+ out.push(` ${left} ${right}`);
929
+ }
930
+ return out;
931
+ }
932
+
933
+ function resolveProjectRowRoot(row = {}) {
934
+ const raw = String((row && (row.root || row.project_root)) || "").trim();
935
+ if (!raw) return "";
936
+ try {
937
+ const { canonicalProjectRoot } = require("../../runtime/projects");
938
+ return canonicalProjectRoot(raw);
939
+ } catch {
940
+ return path.resolve(raw);
941
+ }
942
+ }
943
+
944
+ function loadGlobalProjectRows(activeProjectRoot = "") {
945
+ const {
946
+ listProjectRuntimes,
947
+ filterVisibleProjectRuntimes,
948
+ isGlobalControllerProjectRoot,
949
+ markProjectStopped,
950
+ } = require("../../runtime/projects");
951
+ let rows = listProjectRuntimes({ validate: true, cleanupTmp: true }) || [];
952
+ for (const row of rows) {
953
+ const status = String((row && row.status) || "").trim().toLowerCase();
954
+ const root = resolveProjectRowRoot(row);
955
+ if (status === "stale" && root && !isGlobalControllerProjectRoot(root)) {
956
+ try { markProjectStopped(root); } catch { /* ignore stale cleanup failures */ }
957
+ }
958
+ }
959
+ rows = filterVisibleProjectRuntimes(rows);
960
+ rows = rows.filter((row) => !isGlobalControllerProjectRoot(resolveProjectRowRoot(row)));
961
+ return rows.map((row) => ({
962
+ id: row.project_id || row.project_root || "",
963
+ label: row.project_name || (row.project_root ? path.basename(row.project_root) : ""),
964
+ root: row.project_root || "",
965
+ status: row.status || "",
966
+ active: resolveProjectRowRoot(row) === String(activeProjectRoot || ""),
967
+ }));
968
+ }
969
+
970
+ function readProjectAgentSnapshot(projectRoot = "") {
971
+ if (!projectRoot) return { agents: [], metaMap: new Map() };
972
+ try {
973
+ const { buildStatus } = require("../../runtime/daemon/status");
974
+ const { buildAgentMaps } = require("../../app/chat/agentDirectory");
975
+ const status = buildStatus(projectRoot);
976
+ const activeIds = Array.isArray(status.active) ? status.active : [];
977
+ const metaList = Array.isArray(status.active_meta) ? status.active_meta : [];
978
+ const { labelMap, metaMap } = buildAgentMaps(activeIds, metaList);
979
+ const merged = new Map();
980
+ for (const id of activeIds) {
981
+ const meta = metaMap.get(id) || {};
982
+ const colon = id.indexOf(":");
983
+ const fallbackType = colon > 0 ? id.slice(0, colon) : id;
984
+ const fallbackId = colon > 0 ? id.slice(colon + 1) : "";
985
+ merged.set(id, {
986
+ ...meta,
987
+ fullId: id,
988
+ type: meta.type || fallbackType,
989
+ id: meta.id || fallbackId,
990
+ nickname: labelMap.get(id) || id,
991
+ });
992
+ }
993
+ return { agents: activeIds, metaMap: merged };
994
+ } catch {
995
+ return { agents: [], metaMap: new Map() };
996
+ }
997
+ }
998
+
999
+ function isCJK(ch) {
1000
+ if (!ch) return false;
1001
+ const code = ch.codePointAt(0);
1002
+ return (code >= 0x2e80 && code <= 0x9fff) ||
1003
+ (code >= 0xac00 && code <= 0xd7af) ||
1004
+ (code >= 0xf900 && code <= 0xfaff) ||
1005
+ (code >= 0xfe30 && code <= 0xfe4f) ||
1006
+ (code >= 0x20000 && code <= 0x2fa1f);
1007
+ }
1008
+
1009
+ function inferStatusType(text = "", requestedType = "") {
1010
+ const type = String(requestedType || "").trim().toLowerCase();
1011
+ if (type === "done" || type === "success" || type === "error" || type === "idle") return type;
1012
+ const clean = stripBlessedTags(String(text || "")).trim();
1013
+ if (/^[✓✔]/.test(clean) || /\bdone\b/i.test(clean) || /\bprocessed\b/i.test(clean)) return "done";
1014
+ if (/^[✗!]/.test(clean) || /\berror\b/i.test(clean) || /\bfailed\b/i.test(clean)) return "error";
1015
+ return type || "typing";
1016
+ }
1017
+
1018
+ function isAnimatedStatusType(type = "") {
1019
+ const value = String(type || "").trim().toLowerCase();
1020
+ return value !== "done" && value !== "success" && value !== "error" && value !== "idle" && value !== "none";
1021
+ }
1022
+
1023
+ function inkKeyToRaw(input, key) {
1024
+ if (key.ctrl && input) {
1025
+ const code = input.charCodeAt(0) - 96;
1026
+ if (code >= 1 && code <= 26) return String.fromCharCode(code);
1027
+ return "";
1028
+ }
1029
+ if (key.return) return "\r";
1030
+ if (key.escape) return "\x1b";
1031
+ if (key.backspace || key.delete) return "\x7f";
1032
+ if (key.tab) return "\t";
1033
+ if (key.upArrow) return "\x1b[A";
1034
+ if (key.downArrow) return "\x1b[B";
1035
+ if (key.rightArrow) return "\x1b[C";
1036
+ if (key.leftArrow) return "\x1b[D";
1037
+ if (input && !key.meta) return input;
1038
+ if (key.meta && input) return `\x1b${input}`;
1039
+ return "";
1040
+ }
1041
+
1042
+ function createChatApp({ React, ink, props, interactive = true }) {
1043
+ const { useReducer, useEffect, useState, useCallback, useRef } = React;
1044
+ const { Box, Text, Static, useInput, useApp, useStdout } = ink;
1045
+ const h = React.createElement;
1046
+ const MultilineInput = createMultilineInput({ React, ink });
1047
+ const DashboardBar = createDashboardBar({ React, ink });
1048
+
1049
+ // Build the initial log: chat history if there is any, otherwise an
1050
+ // ASCII banner with project / mode / version info. We resolve history
1051
+ // synchronously here so the very first paint already shows it instead
1052
+ // of rendering an empty banner and then flashing in the lines.
1053
+ const versionLabel = String(fmt.UCODE_VERSION || "");
1054
+ const banner = buildChatBannerLines(props, versionLabel);
1055
+ const persistedHistory = loadChatHistory(props.projectRoot, 200, { globalMode: props.globalMode });
1056
+ const initialLogText = persistedHistory.length > 0
1057
+ ? banner.concat(["", "─── history ───"]).concat(persistedHistory).concat([""])
1058
+ : banner.concat([""]);
1059
+
1060
+ return function ChatApp() {
1061
+ const [state, dispatch] = useReducer(
1062
+ reducer,
1063
+ undefined,
1064
+ () => createInitialState({
1065
+ banner: initialLogText,
1066
+ globalMode: props.globalMode,
1067
+ globalScope: props.globalScope || "controller",
1068
+ settings: props.initialSettings || {},
1069
+ })
1070
+ );
1071
+ const [size, setSize] = useState({ cols: 0, rows: 0 });
1072
+ const [spinnerTick, setSpinnerTick] = useState(0);
1073
+ const [currentProjectRoot, setCurrentProjectRoot] = useState(props.activeProjectRoot || props.projectRoot || "");
1074
+ const [internalAgentView, setInternalAgentView] = useState(() => createInternalAgentViewState());
1075
+ const [multiWindowActive, setMultiWindowActive] = useState(false);
1076
+ const [mwCursor, setMwCursor] = useState(0);
1077
+ const [mwTerminalFocused, setMwTerminalFocused] = useState(false);
1078
+ const mwTerminalFocusedRef = useRef(false);
1079
+ const mwLastInputRef = useRef({ char: "", time: 0 });
1080
+ const stateRef = useRef(state);
1081
+ const sizeRef = useRef(size);
1082
+ const currentProjectRootRef = useRef(currentProjectRoot);
1083
+ const internalAgentViewRef = useRef(internalAgentView);
1084
+ const multiWindowControllerRef = useRef(null);
1085
+ const multiWindowChromeRef = useRef({ statusText: "", promptPrefix: "› ", draft: "", dashboardLines: [] });
1086
+ const multiWindowWatchedInternalAgentsRef = useRef(new Set());
1087
+ const pendingRef = useRef(null);
1088
+ const streamStateRef = useRef(null);
1089
+ const historyScopeRef = useRef(null);
1090
+ const switchToProjectRootRef = useRef(null);
1091
+ const activeChatHistoryRoot = currentProjectRoot || props.projectRoot;
1092
+ const activeChatHistoryOptions = chatHistoryOptionsForScope({
1093
+ globalMode: props.globalMode,
1094
+ globalScope: state.globalScope,
1095
+ });
1096
+ const { exit } = useApp();
1097
+ const { stdout } = useStdout();
1098
+
1099
+ useEffect(() => {
1100
+ stateRef.current = state;
1101
+ }, [state]);
1102
+
1103
+ useEffect(() => {
1104
+ sizeRef.current = size;
1105
+ }, [size]);
1106
+
1107
+ useEffect(() => {
1108
+ currentProjectRootRef.current = currentProjectRoot;
1109
+ }, [currentProjectRoot]);
1110
+
1111
+ historyScopeRef.current = {
1112
+ root: activeChatHistoryRoot,
1113
+ options: activeChatHistoryOptions,
1114
+ };
1115
+
1116
+ const appendScopedHistory = useCallback((kind, text, meta = {}) => {
1117
+ appendChatHistory(activeChatHistoryRoot, kind, text, meta, activeChatHistoryOptions);
1118
+ }, [activeChatHistoryRoot, activeChatHistoryOptions.globalMode]);
1119
+
1120
+ const setStatusText = useCallback((text, options = {}) => {
1121
+ const clean = stripBlessedTags(text).trim();
1122
+ if (!clean) {
1123
+ dispatch({ type: "status/idle" });
1124
+ return;
1125
+ }
1126
+ dispatch({
1127
+ type: "status/set",
1128
+ payload: {
1129
+ message: clean,
1130
+ type: inferStatusType(clean, options.type || "typing"),
1131
+ showTimer: options.showTimer === true,
1132
+ startedAt: options.startedAt || Date.now(),
1133
+ },
1134
+ });
1135
+ }, []);
1136
+
1137
+ const logInkMessage = useCallback((kind, text, meta = {}) => {
1138
+ const type = String(kind || "system");
1139
+ if (type === "status") {
1140
+ setStatusText(text);
1141
+ return;
1142
+ }
1143
+ const lines = normalizeInkLogLines(text);
1144
+ if (lines.length === 0) return;
1145
+ dispatch({ type: "log/appendMany", lines });
1146
+ appendScopedHistory(type, stripBlessedTags(text), meta);
1147
+ }, [appendScopedHistory, setStatusText]);
1148
+
1149
+ if (!streamStateRef.current) {
1150
+ streamStateRef.current = createInkStreamState({
1151
+ dispatch,
1152
+ appendHistory: (kind, text, meta = {}) => {
1153
+ const scope = historyScopeRef.current || {};
1154
+ appendChatHistory(scope.root || props.projectRoot, kind, text, meta, scope.options || {});
1155
+ },
1156
+ displayNameForPublisher: (publisher) => {
1157
+ const current = stateRef.current || {};
1158
+ const meta = current.activeAgentMeta instanceof Map ? current.activeAgentMeta.get(publisher) : null;
1159
+ return getAgentLabelFor(meta, publisher);
1160
+ },
1161
+ });
1162
+ }
1163
+
1164
+ const getMultiWindowController = useCallback(() => {
1165
+ if (multiWindowControllerRef.current) return multiWindowControllerRef.current;
1166
+ const processStdout = stdout || (typeof process !== "undefined" ? process.stdout : null);
1167
+ if (!processStdout || typeof processStdout.write !== "function") return null;
1168
+
1169
+ const originalWrite = processStdout.write.bind(processStdout);
1170
+ const { createMultiWindowController } = require("../../app/chat/multiWindow");
1171
+ multiWindowControllerRef.current = createMultiWindowController({
1172
+ processStdout: { write: originalWrite, rows: processStdout.rows, columns: processStdout.columns },
1173
+ getRows: () => {
1174
+ const currentSize = sizeRef.current || {};
1175
+ return currentSize.rows || processStdout.rows || 24;
1176
+ },
1177
+ getCols: () => {
1178
+ const currentSize = sizeRef.current || {};
1179
+ return currentSize.cols || processStdout.columns || 80;
1180
+ },
1181
+ getInjectSockPath: (agentId) =>
1182
+ resolveInjectSockPathForAgent(currentProjectRootRef.current || props.projectRoot, agentId),
1183
+ getActiveAgents: () => {
1184
+ const current = stateRef.current || {};
1185
+ return Array.isArray(current.agents) ? current.agents : [];
1186
+ },
1187
+ getAgentPaneOptions: (agentId) => {
1188
+ const current = stateRef.current || {};
1189
+ const enterRequest = resolveAgentEnterRequest({
1190
+ agentId,
1191
+ projectRoot: currentProjectRootRef.current || props.projectRoot,
1192
+ activeAgentMeta: current.activeAgentMeta,
1193
+ settings: current.settings,
1194
+ });
1195
+ if (!enterRequest || !enterRequest.useBus) return { mode: "socket" };
1196
+ const metaMap = current.activeAgentMeta instanceof Map ? current.activeAgentMeta : new Map();
1197
+ const agentMeta = metaMap.get(agentId) || {};
1198
+ let initialLines = [];
1199
+ try {
1200
+ const { loadInternalAgentLogHistory } = require("../../app/chat/internalAgentLogHistory");
1201
+ initialLines = loadInternalAgentLogHistory(currentProjectRootRef.current || props.projectRoot, agentId, {
1202
+ maxEvents: 200,
1203
+ maxLines: 200,
1204
+ });
1205
+ } catch { initialLines = []; }
1206
+ return {
1207
+ mode: "internal",
1208
+ initialLines: [
1209
+ `ufoo internal agent · ${getAgentLabelFor(agentMeta, agentId)}`,
1210
+ `agent: ${agentId}`,
1211
+ "",
1212
+ ...initialLines,
1213
+ ],
1214
+ };
1215
+ },
1216
+ getChatLogLines: () => {
1217
+ const current = stateRef.current || {};
1218
+ return Array.isArray(current.logLines)
1219
+ ? current.logLines.map((item) => String((item && item.text) || ""))
1220
+ : [];
1221
+ },
1222
+ getStatusText: () => {
1223
+ const chrome = multiWindowChromeRef.current;
1224
+ return chrome ? chrome.statusText : "";
1225
+ },
1226
+ getPromptPrefix: () => {
1227
+ const chrome = multiWindowChromeRef.current;
1228
+ return chrome ? chrome.promptPrefix : "› ";
1229
+ },
1230
+ getCurrentDraft: () => {
1231
+ const chrome = multiWindowChromeRef.current;
1232
+ return chrome ? chrome.draft : "";
1233
+ },
1234
+ getCursorPos: () => {
1235
+ const chrome = multiWindowChromeRef.current;
1236
+ return chrome ? chrome.cursor : 0;
1237
+ },
1238
+ getCompletions: () => {
1239
+ const chrome = multiWindowChromeRef.current;
1240
+ if (!chrome || !chrome.completions || chrome.completions.length === 0) {
1241
+ return { items: [], index: -1, windowStart: 0, pageSize: 8 };
1242
+ }
1243
+ return {
1244
+ items: chrome.completions,
1245
+ index: chrome.completionIndex,
1246
+ windowStart: chrome.completionWindowStart,
1247
+ pageSize: chrome.completionPageSize || 8,
1248
+ };
1249
+ },
1250
+ getAgentLabel: (id) => {
1251
+ const current = stateRef.current || {};
1252
+ const metaMap = current.activeAgentMeta || new Map();
1253
+ return getAgentLabelFor(metaMap.get(id), id);
1254
+ },
1255
+ getInternalPaneInfo: (id) => {
1256
+ const current = stateRef.current || {};
1257
+ const metaMap = current.activeAgentMeta instanceof Map ? current.activeAgentMeta : new Map();
1258
+ const meta = metaMap.get(id) || {};
1259
+ const status = internalStatusLabel(meta.activity_state || meta.state || "");
1260
+ const detail = String(meta.activity_detail || meta.detail || meta.status_text || "").trim();
1261
+ return {
1262
+ status,
1263
+ detail,
1264
+ input: "",
1265
+ cursor: 0,
1266
+ };
1267
+ },
1268
+ getDashboardLines: () => {
1269
+ const chrome = multiWindowChromeRef.current;
1270
+ return chrome ? chrome.dashboardLines : [];
1271
+ },
1272
+ getTerminalFocused: () => mwTerminalFocusedRef.current,
1273
+ freezeScreen: (frozen) => {
1274
+ if (frozen) {
1275
+ processStdout.write = () => true;
1276
+ } else {
1277
+ processStdout.write = originalWrite;
1278
+ }
1279
+ },
1280
+ restoreTerminal: () => {
1281
+ const rows = processStdout.rows || 24;
1282
+ originalWrite(`\x1b[1;${rows}r`);
1283
+ originalWrite("\x1b[2J\x1b[H");
1284
+ },
1285
+ onInternalSubmit: (agentId, message) => {
1286
+ sendInternalAgentMessage(agentId, message);
1287
+ },
1288
+ onExit: () => {
1289
+ setMultiWindowActive(false);
1290
+ },
1291
+ });
1292
+ return multiWindowControllerRef.current;
1293
+ }, [props.projectRoot, stdout]);
1294
+
1295
+ const toggleMultiWindow = useCallback(() => createInkMultiWindowToggle({
1296
+ getController: getMultiWindowController,
1297
+ setActive: setMultiWindowActive,
1298
+ logMessage: logInkMessage,
1299
+ })(), [getMultiWindowController, logInkMessage]);
1300
+
1301
+ useEffect(() => () => {
1302
+ const controller = multiWindowControllerRef.current;
1303
+ if (controller && typeof controller.exit === "function") {
1304
+ try { controller.exit(); } catch { /* ignore */ }
1305
+ }
1306
+ multiWindowControllerRef.current = null;
1307
+ }, []);
1308
+
1309
+ useEffect(() => {
1310
+ internalAgentViewRef.current = internalAgentView;
1311
+ }, [internalAgentView]);
1312
+
1313
+ useEffect(() => {
1314
+ if (!stdout) return undefined;
1315
+ const update = () =>
1316
+ setSize({ cols: stdout.columns || 0, rows: stdout.rows || 0 });
1317
+ update();
1318
+ stdout.on("resize", update);
1319
+ return () => stdout.off("resize", update);
1320
+ }, [stdout]);
1321
+
1322
+ // Load persisted input history once on mount.
1323
+ useEffect(() => {
1324
+ try {
1325
+ const history = loadInputHistory(props.projectRoot, 200, { globalMode: props.globalMode });
1326
+ if (history.length > 0) dispatch({ type: "history/load", list: history });
1327
+ } catch { /* ignore */ }
1328
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1329
+ }, []);
1330
+
1331
+ const sendInternalAgentWatch = (agentId, enabled) => {
1332
+ if (!agentId || !props.daemonConnection || typeof props.daemonConnection.send !== "function") return;
1333
+ try {
1334
+ const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
1335
+ props.daemonConnection.send({
1336
+ type: IPC_REQUEST_TYPES.BUS_WATCH,
1337
+ agent_id: agentId,
1338
+ enabled: enabled !== false,
1339
+ });
1340
+ } catch { /* ignore */ }
1341
+ };
1342
+
1343
+ const reconcileMultiWindowInternalWatches = useCallback(() => {
1344
+ const current = stateRef.current || {};
1345
+ const agents = Array.isArray(current.agents) ? current.agents : [];
1346
+ const next = new Set();
1347
+ if (multiWindowActive) {
1348
+ for (const agentId of agents) {
1349
+ const enterRequest = resolveAgentEnterRequest({
1350
+ agentId,
1351
+ projectRoot: currentProjectRootRef.current || props.projectRoot,
1352
+ activeAgentMeta: current.activeAgentMeta,
1353
+ settings: current.settings,
1354
+ });
1355
+ if (enterRequest && enterRequest.useBus) next.add(agentId);
1356
+ }
1357
+ }
1358
+ const previous = multiWindowWatchedInternalAgentsRef.current;
1359
+ for (const agentId of next) {
1360
+ if (!previous.has(agentId)) sendInternalAgentWatch(agentId, true);
1361
+ }
1362
+ for (const agentId of previous) {
1363
+ if (!next.has(agentId)) sendInternalAgentWatch(agentId, false);
1364
+ }
1365
+ multiWindowWatchedInternalAgentsRef.current = next;
1366
+ }, [multiWindowActive, props.projectRoot, props.daemonConnection]);
1367
+
1368
+ useEffect(() => {
1369
+ if (!multiWindowActive) return;
1370
+ const controller = multiWindowControllerRef.current;
1371
+ if (!controller) return;
1372
+ reconcileMultiWindowInternalWatches();
1373
+ if (typeof controller.syncAgents === "function") controller.syncAgents();
1374
+ if (typeof controller.renderAll === "function") controller.renderAll();
1375
+ }, [multiWindowActive, state.agents, state.logLines, state.draft, state.status, size.cols, size.rows, mwCursor, state.focusMode, state.dashboardView, state.selectedAgentIndex, state.selectedProjectIndex, state.selectedModeIndex, state.selectedProviderIndex, state.selectedCronIndex, mwTerminalFocused, reconcileMultiWindowInternalWatches]);
1376
+
1377
+ useEffect(() => {
1378
+ if (multiWindowActive) return;
1379
+ reconcileMultiWindowInternalWatches();
1380
+ }, [multiWindowActive, reconcileMultiWindowInternalWatches]);
1381
+
1382
+ const sendInternalAgentMessage = (agentId, message) => {
1383
+ if (!agentId || !message || !props.daemonConnection || typeof props.daemonConnection.send !== "function") return;
1384
+ try {
1385
+ const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
1386
+ props.daemonConnection.send({
1387
+ type: IPC_REQUEST_TYPES.BUS_SEND,
1388
+ target: agentId,
1389
+ message,
1390
+ injection_mode: "immediate",
1391
+ source: "chat-internal-agent-view",
1392
+ });
1393
+ } catch (err) {
1394
+ setInternalAgentView((prev) => appendInternalAgentText(
1395
+ updateInternalViewStatus(prev, "blocked", err && err.message ? err.message : String(err || "")),
1396
+ `Error: ${err && err.message ? err.message : err}\n`,
1397
+ ));
1398
+ }
1399
+ };
1400
+
1401
+ const isInternalAlias = (view, value) => {
1402
+ if (!view || !view.agentId) return false;
1403
+ const text = String(value || "");
1404
+ if (!text) return false;
1405
+ const aliases = new Set((view.aliases || []).concat([view.agentId, view.label]).filter(Boolean).map(String));
1406
+ return aliases.has(text);
1407
+ };
1408
+
1409
+ const buildInternalAgentAliases = (agentId) => {
1410
+ const current = stateRef.current || {};
1411
+ const metaMap = current.activeAgentMeta instanceof Map ? current.activeAgentMeta : new Map();
1412
+ const meta = metaMap.get(agentId) || {};
1413
+ return new Set([
1414
+ agentId,
1415
+ meta.nickname,
1416
+ meta.scoped_nickname,
1417
+ meta.display_nickname,
1418
+ meta.fullId,
1419
+ ].filter(Boolean).map(String));
1420
+ };
1421
+
1422
+ const writeMultiWindowInternalEvent = useCallback((data = {}) => {
1423
+ const controller = multiWindowControllerRef.current;
1424
+ if (!multiWindowActive || !controller || typeof controller.writeToPane !== "function") return false;
1425
+ const watched = multiWindowWatchedInternalAgentsRef.current;
1426
+ if (!watched || watched.size === 0) return false;
1427
+
1428
+ let handled = false;
1429
+ for (const agentId of watched) {
1430
+ const aliases = buildInternalAgentAliases(agentId);
1431
+ const publisher = String(data.publisher || (data.event === "broadcast" ? "broadcast" : "bus"));
1432
+ const target = String(data.target || data.subscriber || "");
1433
+ const fromAgent = aliases.has(publisher);
1434
+ const toAgent = aliases.has(target) || aliases.has(String(data.subscriber || ""));
1435
+ if (!fromAgent && !toAgent) continue;
1436
+ if (data.silent) {
1437
+ handled = true;
1438
+ continue;
1439
+ }
1440
+ if (data.source === "chat-internal-agent-view" && toAgent && !fromAgent) {
1441
+ handled = true;
1442
+ continue;
1443
+ }
1444
+ if (data.event === "activity_state_changed") {
1445
+ const state = internalStatusLabel(data.state || data.activity_state || "");
1446
+ const detail = String(data.detail || (data.data && data.data.detail) || data.message || "").trim();
1447
+ controller.writeToPane(agentId, `\r\n[${state}${detail ? ` · ${detail}` : ""}]\r\n`);
1448
+ handled = true;
1449
+ continue;
1450
+ }
1451
+
1452
+ const { displayMessage, streamPayload } = parseInternalBusPayload(data.message || "");
1453
+ if (streamPayload) {
1454
+ if (!fromAgent) {
1455
+ handled = true;
1456
+ continue;
1457
+ }
1458
+ const delta = typeof streamPayload.delta === "string"
1459
+ ? streamPayload.delta.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n").replace(/\\r/g, "\n")
1460
+ : "";
1461
+ if (delta) controller.writeToPane(agentId, delta);
1462
+ if (streamPayload.done) controller.writeToPane(agentId, "\r\n");
1463
+ handled = true;
1464
+ continue;
1465
+ }
1466
+ if (!displayMessage) {
1467
+ handled = true;
1468
+ continue;
1469
+ }
1470
+ const prefix = fromAgent ? "* " : "> ";
1471
+ controller.writeToPane(agentId, `${prefix}${displayMessage.replace(/\n/g, `\r\n `)}\r\n`);
1472
+ handled = true;
1473
+ }
1474
+ return handled;
1475
+ }, [multiWindowActive]);
1476
+
1477
+ const handleInternalStatus = (data = {}) => {
1478
+ const view = internalAgentViewRef.current;
1479
+ if (!view || !view.agentId) return;
1480
+ const metaList = Array.isArray(data.active_meta) ? data.active_meta : [];
1481
+ for (const meta of metaList) {
1482
+ const metaId = meta && (meta.fullId || meta.subscriber_id || meta.id) ? String(meta.fullId || meta.subscriber_id || meta.id) : "";
1483
+ const typedId = meta && meta.type && meta.id ? `${meta.type}:${meta.id}` : "";
1484
+ if (!isInternalAlias(view, metaId) && !isInternalAlias(view, typedId)) continue;
1485
+ const status = internalStatusLabel(meta.activity_state || meta.state || "");
1486
+ const detail = String(meta.activity_detail || meta.detail || meta.status_text || "").trim();
1487
+ setInternalAgentView((prev) => (
1488
+ prev.agentId === view.agentId ? updateInternalViewStatus(prev, status, detail) : prev
1489
+ ));
1490
+ return;
1491
+ }
1492
+ };
1493
+
1494
+ const handleInternalBusMessage = (data = {}) => {
1495
+ const view = internalAgentViewRef.current;
1496
+ if (!view || !view.agentId) return false;
1497
+ if (data.event === "activity_state_changed") {
1498
+ const actor = String(data.subscriber || data.publisher || "").trim();
1499
+ if (!isInternalAlias(view, actor)) return false;
1500
+ setInternalAgentView((prev) => (
1501
+ prev.agentId === view.agentId
1502
+ ? {
1503
+ ...updateInternalViewStatus(
1504
+ prev,
1505
+ data.state || data.activity_state || "",
1506
+ data.detail || (data.data && data.data.detail) || data.message || "",
1507
+ ),
1508
+ }
1509
+ : prev
1510
+ ));
1511
+ return true;
1512
+ }
1513
+ const publisher = String(data.publisher || (data.event === "broadcast" ? "broadcast" : "bus"));
1514
+ const target = String(data.target || data.subscriber || "");
1515
+ const fromAgent = isInternalAlias(view, publisher);
1516
+ const toAgent = isInternalAlias(view, target);
1517
+ if (!fromAgent && !toAgent) return false;
1518
+ if (data.silent) return true;
1519
+ if (data.source === "chat-internal-agent-view" && toAgent && !fromAgent) return true;
1520
+
1521
+ const { displayMessage, streamPayload } = parseInternalBusPayload(data.message || "");
1522
+ if (streamPayload) {
1523
+ if (!fromAgent) return true;
1524
+ const delta = typeof streamPayload.delta === "string"
1525
+ ? streamPayload.delta.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n").replace(/\\r/g, "\n")
1526
+ : "";
1527
+ if (delta) {
1528
+ setInternalAgentView((prev) => (
1529
+ prev.agentId === view.agentId
1530
+ ? updateInternalViewStatus(
1531
+ appendInternalAgentText(prev, delta, { prefix: "* " }),
1532
+ streamPayload.done ? "ready" : "working",
1533
+ streamPayload.reason || prev.detail || "",
1534
+ )
1535
+ : prev
1536
+ ));
1537
+ } else if (streamPayload.done) {
1538
+ setInternalAgentView((prev) => (
1539
+ prev.agentId === view.agentId ? updateInternalViewStatus(prev, "ready", "") : prev
1540
+ ));
1541
+ }
1542
+ return true;
1543
+ }
1544
+ if (!displayMessage) return true;
1545
+ setInternalAgentView((prev) => {
1546
+ if (prev.agentId !== view.agentId) return prev;
1547
+ const next = fromAgent
1548
+ ? appendInternalAgentText(prev, `${displayMessage}\n`, { prefix: "* " })
1549
+ : appendInternalAgentText(prev, `${displayMessage}\n`, { prefix: "> " });
1550
+ return fromAgent ? updateInternalViewStatus(next, "ready", "") : next;
1551
+ });
1552
+ return true;
1553
+ };
1554
+
1555
+ const handleInternalErrorMessage = (message = "") => {
1556
+ const view = internalAgentViewRef.current;
1557
+ if (!view || !view.agentId) return false;
1558
+ setInternalAgentView((prev) => (
1559
+ appendInternalErrorToView(prev, view.agentId, message)
1560
+ ));
1561
+ return true;
1562
+ };
1563
+
1564
+ const handleInternalSendOk = () => {
1565
+ const view = internalAgentViewRef.current;
1566
+ if (!view || !view.agentId) return false;
1567
+ setInternalAgentView((prev) => (
1568
+ prev.agentId === view.agentId ? updateInternalViewStatus(prev, "ready", "") : prev
1569
+ ));
1570
+ return true;
1571
+ };
1572
+
1573
+ const requestDaemonStatus = useCallback(() => {
1574
+ try {
1575
+ const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
1576
+ const conn = props.daemonConnection;
1577
+ if (conn && typeof conn.send === "function") conn.send({ type: IPC_REQUEST_TYPES.STATUS });
1578
+ } catch { /* ignore */ }
1579
+ }, [props.daemonConnection]);
1580
+
1581
+ const updateDashboardFromStatus = useCallback((data = {}) => {
1582
+ const activeIds = Array.isArray(data.active) ? data.active : [];
1583
+ const metaList = Array.isArray(data.active_meta) ? data.active_meta : [];
1584
+ const { buildAgentMaps } = require("../../app/chat/agentDirectory");
1585
+ const { labelMap, metaMap } = buildAgentMaps(activeIds, metaList);
1586
+ const agentsForDispatch = activeIds.map((id) => {
1587
+ const meta = metaMap.get(id) || {};
1588
+ const colon = id.indexOf(":");
1589
+ const fallbackType = colon > 0 ? id.slice(0, colon) : id;
1590
+ const fallbackId = colon > 0 ? id.slice(colon + 1) : "";
1591
+ return {
1592
+ ...meta,
1593
+ fullId: id,
1594
+ type: meta.type || fallbackType,
1595
+ id: meta.id || fallbackId,
1596
+ nickname: labelMap.get(id) || id,
1597
+ };
1598
+ });
1599
+ dispatch({ type: "agents/set", list: agentsForDispatch });
1600
+ if (data.cron && Array.isArray(data.cron.tasks)) {
1601
+ dispatch({ type: "cron/set", list: data.cron.tasks });
1602
+ }
1603
+ dispatch({ type: "loop/set", summary: data.loop || null });
1604
+ handleInternalStatus(data);
1605
+ }, []);
1606
+
1607
+ // Wire daemon: register a message handler that turns IPC responses
1608
+ // through the same daemonMessageRouter blessed uses, then adapts the
1609
+ // blessed callbacks to Ink state updates.
1610
+ useEffect(() => {
1611
+ if (!interactive) return undefined;
1612
+ const conn = props.daemonConnection;
1613
+ const setHandler = props.setDaemonMessageHandler;
1614
+ if (!conn || typeof conn.connect !== "function" || typeof setHandler !== "function") {
1615
+ return undefined;
1616
+ }
1617
+ const { IPC_RESPONSE_TYPES } = require("../../runtime/contracts/eventContract");
1618
+ const { createDaemonMessageRouter } = require("../../app/chat/daemonMessageRouter");
1619
+ const streamState = streamStateRef.current;
1620
+ const router = createDaemonMessageRouter({
1621
+ escapeBlessed: (value) => String(value == null ? "" : value),
1622
+ stripBlessedTags,
1623
+ logMessage: logInkMessage,
1624
+ renderScreen: () => {},
1625
+ updateDashboard: updateDashboardFromStatus,
1626
+ requestStatus: requestDaemonStatus,
1627
+ resolveStatusLine: (text, data = {}) => {
1628
+ setStatusText(text, {
1629
+ type: data && data.phase === "error" ? "error" : "typing",
1630
+ showTimer: false,
1631
+ });
1632
+ },
1633
+ enqueueBusStatus: (item = {}) => setStatusText(item.text || "Processing bus message", { type: "typing" }),
1634
+ resolveBusStatus: (item = {}) => setStatusText(item.text || "Bus message processed", { type: "done" }),
1635
+ getPending: () => pendingRef.current,
1636
+ setPending: (value) => { pendingRef.current = value || null; },
1637
+ resolveAgentDisplayName: (value) => {
1638
+ const current = stateRef.current || {};
1639
+ const meta = current.activeAgentMeta instanceof Map ? current.activeAgentMeta.get(value) : null;
1640
+ return getAgentLabelFor(meta, value);
1641
+ },
1642
+ getCurrentView: () => {
1643
+ const current = stateRef.current || {};
1644
+ return current.viewingAgentId ? "agent" : "main";
1645
+ },
1646
+ isAgentViewUsesBus: () => Boolean(internalAgentViewRef.current && internalAgentViewRef.current.agentId),
1647
+ getViewingAgent: () => {
1648
+ const current = stateRef.current || {};
1649
+ return current.viewingAgentId || (internalAgentViewRef.current && internalAgentViewRef.current.agentId) || "";
1650
+ },
1651
+ isAgentEventForViewingAgent: (data, viewingAgent, publisher) => {
1652
+ const view = internalAgentViewRef.current || {};
1653
+ if (!view.agentId && !viewingAgent) return false;
1654
+ const candidates = [
1655
+ viewingAgent,
1656
+ publisher,
1657
+ data && data.publisher,
1658
+ data && data.target,
1659
+ data && data.subscriber,
1660
+ ];
1661
+ return candidates.some((candidate) => isInternalAlias(view, candidate));
1662
+ },
1663
+ writeToAgentTerm: (text, meta = {}) => {
1664
+ const view = internalAgentViewRef.current;
1665
+ if (!view || !view.agentId) return;
1666
+ setInternalAgentView((prev) => (
1667
+ applyInternalAgentTermWrite(prev, view.agentId, text, meta)
1668
+ ));
1669
+ },
1670
+ consumePendingDelivery: (...args) => streamState.consumePendingDelivery(...args),
1671
+ getPendingState: (...args) => streamState.getPendingState(...args),
1672
+ beginStream: (...args) => streamState.beginStream(...args),
1673
+ appendStreamDelta: (...args) => streamState.appendStreamDelta(...args),
1674
+ finalizeStream: (...args) => streamState.finalizeStream(...args),
1675
+ hasStream: (...args) => streamState.hasStream(...args),
1676
+ setTransientAgentState: (agentId, value, options = {}) => {
1677
+ if (!agentId || !value) return;
1678
+ dispatch({
1679
+ type: "agents/patchMeta",
1680
+ agentId,
1681
+ patch: {
1682
+ activity_state: value,
1683
+ activity_detail: options.detail || "",
1684
+ },
1685
+ });
1686
+ },
1687
+ clearTransientAgentState: (agentId) => {
1688
+ if (!agentId) return;
1689
+ dispatch({
1690
+ type: "agents/patchMeta",
1691
+ agentId,
1692
+ patch: {
1693
+ activity_state: "",
1694
+ activity_detail: "",
1695
+ },
1696
+ });
1697
+ },
1698
+ refreshDashboard: () => {},
1699
+ });
1700
+ setHandler((msg) => {
1701
+ if (!msg || typeof msg !== "object") return;
1702
+ if (msg.type === IPC_RESPONSE_TYPES.ERROR && handleInternalErrorMessage(msg.error || "unknown error")) {
1703
+ return;
1704
+ }
1705
+ if (msg.type === IPC_RESPONSE_TYPES.BUS_SEND_OK) {
1706
+ if (handleInternalSendOk()) return;
1707
+ const text = `✓ Message delivered`;
1708
+ logInkMessage("system", text);
1709
+ dispatch({ type: "status/idle" });
1710
+ requestDaemonStatus();
1711
+ return;
1712
+ }
1713
+ if (msg.type === IPC_RESPONSE_TYPES.BUS) {
1714
+ writeMultiWindowInternalEvent(msg.data || {});
1715
+ }
1716
+ router.handleMessage(msg);
1717
+ });
1718
+ conn.connect();
1719
+ return () => {
1720
+ try { if (typeof conn.close === "function") conn.close(); } catch { /* ignore */ }
1721
+ };
1722
+ }, [interactive, logInkMessage, requestDaemonStatus, setStatusText, updateDashboardFromStatus, writeMultiWindowInternalEvent]);
1723
+
1724
+ // commandExecutor wiring. The blessed implementation reuses this
1725
+ // module to dispatch every slash command (~30 callbacks). We adapt
1726
+ // the callback surface to ink: log/status/render writes go through
1727
+ // dispatch, daemon ops go through props.daemonConnection, and
1728
+ // blessed-tag markup the executor sprinkles into log lines is
1729
+ // stripped before rendering.
1730
+ const commandExecutorRef = useRef(null);
1731
+ useEffect(() => {
1732
+ if (!interactive) return undefined;
1733
+ const { createCommandExecutor } = require("../../app/chat/commandExecutor");
1734
+ const { parseCommand: parseCmd } = require("../../app/chat/commands");
1735
+ const { startDaemon: transportStartDaemon, stopDaemon: transportStopDaemon } = require("../../app/chat/transport");
1736
+ const AgentActivator = require("../../coordination/bus/activate");
1737
+ const conn = props.daemonConnection;
1738
+
1739
+ try {
1740
+ commandExecutorRef.current = createCommandExecutor({
1741
+ projectRoot: props.projectRoot,
1742
+ getActiveProjectRoot: () => currentProjectRootRef.current || props.projectRoot,
1743
+ parseCommand: parseCmd,
1744
+ escapeBlessed: (v) => String(v == null ? "" : v),
1745
+ logMessage: logInkMessage,
1746
+ resolveStatusLine: (text) => setStatusText(text),
1747
+ renderScreen: () => {},
1748
+ clearLog: () => {
1749
+ // Clear the persisted chat history file so reopening the chat
1750
+ // doesn't reload old messages.
1751
+ try {
1752
+ const root = currentProjectRootRef.current || props.projectRoot;
1753
+ const historyOptions = chatHistoryOptionsForScope({
1754
+ globalMode: props.globalMode,
1755
+ globalScope: (stateRef.current && stateRef.current.globalScope) || "controller",
1756
+ });
1757
+ const file = chatHistoryFilePath(root, historyOptions);
1758
+ if (file && fs.existsSync(file)) fs.writeFileSync(file, "");
1759
+ } catch { /* ignore */ }
1760
+ // ink redraws by erasing only as many lines as the last frame
1761
+ // emitted. After log/clear the next frame is shorter, so the
1762
+ // older log lines remain in the terminal scrollback. Wipe the
1763
+ // visible screen + scrollback first, then dispatch — ink will
1764
+ // repaint the (now small) frame onto a clean buffer.
1765
+ try {
1766
+ const out = (typeof process !== "undefined" && process.stdout) || null;
1767
+ if (out && out.isTTY && typeof out.write === "function") {
1768
+ out.write("\x1b[2J\x1b[3J\x1b[H");
1769
+ }
1770
+ } catch { /* ignore */ }
1771
+ dispatch({ type: "log/clear" });
1772
+ },
1773
+ getActiveAgents: () => (stateRef.current && stateRef.current.agents) || [],
1774
+ getActiveAgentMetaMap: () => (stateRef.current && stateRef.current.activeAgentMeta) || new Map(),
1775
+ getAgentLabel: (id) => {
1776
+ const metaMap = (stateRef.current && stateRef.current.activeAgentMeta) || new Map();
1777
+ return getAgentLabelFor(metaMap.get(id), id);
1778
+ },
1779
+ isDaemonRunning: (root) => props.env && props.env.isRunning ? props.env.isRunning(root || props.projectRoot) : true,
1780
+ startDaemon: (root, options = {}) => {
1781
+ const targetRoot = root || props.projectRoot;
1782
+ if (props.env && typeof props.env.startDaemon === "function") return props.env.startDaemon(targetRoot, options);
1783
+ return transportStartDaemon(targetRoot, options);
1784
+ },
1785
+ stopDaemon: (root, options = {}) => transportStopDaemon(root || props.projectRoot, options),
1786
+ restartDaemon: async (root) => {
1787
+ const targetRoot = root || currentProjectRootRef.current || props.projectRoot;
1788
+ if (
1789
+ targetRoot === (currentProjectRootRef.current || props.projectRoot) &&
1790
+ props.daemonCoordinator &&
1791
+ typeof props.daemonCoordinator.restart === "function"
1792
+ ) {
1793
+ await props.daemonCoordinator.restart();
1794
+ return;
1795
+ }
1796
+ try { if (conn && typeof conn.close === "function") conn.close(); } catch { /* ignore */ }
1797
+ transportStopDaemon(targetRoot, { source: "ink-command:/daemon restart" });
1798
+ transportStartDaemon(targetRoot);
1799
+ if (targetRoot === (currentProjectRootRef.current || props.projectRoot) && conn && typeof conn.connect === "function") {
1800
+ await conn.connect();
1801
+ }
1802
+ },
1803
+ send: (req) => { try { if (conn && typeof conn.send === "function") conn.send(req); } catch { /* ignore */ } },
1804
+ requestStatus: requestDaemonStatus,
1805
+ requestCron: (payload = {}) => {
1806
+ try {
1807
+ const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
1808
+ if (conn && typeof conn.send === "function") {
1809
+ conn.send({ type: IPC_REQUEST_TYPES.CRON, ...payload });
1810
+ }
1811
+ } catch { /* ignore */ }
1812
+ },
1813
+ activateAgent: async (target) => {
1814
+ const activator = new AgentActivator(currentProjectRootRef.current || props.projectRoot);
1815
+ await activator.activate(target);
1816
+ },
1817
+ globalMode: Boolean(props.globalMode),
1818
+ listProjects: () => (stateRef.current && stateRef.current.projects) || [],
1819
+ getCurrentProject: () => ({ project_root: currentProjectRootRef.current || props.projectRoot }),
1820
+ switchProject: async (target) => {
1821
+ const rawTarget = String((target && (target.projectRoot || target.project_root || target.target)) || target || "").trim();
1822
+ let targetRoot = rawTarget;
1823
+ if (/^\d+$/.test(rawTarget)) {
1824
+ const idx = Number.parseInt(rawTarget, 10) - 1;
1825
+ const projects = (stateRef.current && stateRef.current.projects) || [];
1826
+ targetRoot = resolveProjectRowRoot(projects[idx]);
1827
+ }
1828
+ const switchProject = switchToProjectRootRef.current;
1829
+ if (typeof switchProject !== "function") {
1830
+ return { ok: false, error: "project switching unavailable" };
1831
+ }
1832
+ return switchProject(targetRoot, { focusInput: true });
1833
+ },
1834
+ toggleMultiWindow,
1835
+ });
1836
+ } catch (err) {
1837
+ dispatch({ type: "log/append", text: `Error: command executor unavailable (${err && err.message ? err.message : err})` });
1838
+ }
1839
+ return undefined;
1840
+ }, [interactive, logInkMessage, requestDaemonStatus, setStatusText, toggleMultiWindow]);
1841
+
1842
+ // Periodic STATUS poll to keep the agents footer fresh, mirroring
1843
+ // blessed's requestStatus on a timer.
1844
+ useEffect(() => {
1845
+ if (!interactive) return undefined;
1846
+ const conn = props.daemonConnection;
1847
+ if (!conn || typeof conn.send !== "function") return undefined;
1848
+ const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
1849
+ const tick = () => {
1850
+ try { conn.send({ type: IPC_REQUEST_TYPES.STATUS }); } catch { /* ignore */ }
1851
+ };
1852
+ tick();
1853
+ const timer = setInterval(tick, 3000);
1854
+ return () => clearInterval(timer);
1855
+ }, [interactive]);
1856
+
1857
+ // Refresh the project rail in global mode. blessed pulls this off the
1858
+ // local registry; we do the same so the dashboard's first row tracks
1859
+ // every running project without needing a daemon round-trip.
1860
+ const refreshGlobalProjects = useCallback((activeRoot = currentProjectRoot) => {
1861
+ if (!props.globalMode) return [];
1862
+ const list = loadGlobalProjectRows(activeRoot);
1863
+ dispatch({
1864
+ type: "projects/set",
1865
+ list,
1866
+ activeProjectRoot: activeRoot,
1867
+ });
1868
+ return list;
1869
+ }, [props.globalMode, currentProjectRoot]);
1870
+
1871
+ useEffect(() => {
1872
+ if (!interactive || !props.globalMode) return undefined;
1873
+ const refresh = () => {
1874
+ try { refreshGlobalProjects(currentProjectRoot); } catch { /* ignore */ }
1875
+ };
1876
+ refresh();
1877
+ const timer = setInterval(refresh, 4000);
1878
+ return () => clearInterval(timer);
1879
+ }, [interactive, props.globalMode, currentProjectRoot, refreshGlobalProjects]);
1880
+
1881
+ useEffect(() => {
1882
+ const internalStatus = state.viewingAgentId ? internalStatusLabel(internalAgentView.status) : "ready";
1883
+ const internalActive = internalStatus !== "ready";
1884
+ const statusAnimated = state.status.message && isAnimatedStatusType(state.status.type);
1885
+ if ((!statusAnimated) && !internalActive) return undefined;
1886
+ const timer = setInterval(() => setSpinnerTick((t) => t + 1), 100);
1887
+ return () => clearInterval(timer);
1888
+ }, [state.status.message, state.status.type, state.viewingAgentId, internalAgentView.status]);
1889
+
1890
+ const selectedProject = state.selectedProjectIndex >= 0 ? state.projects[state.selectedProjectIndex] : null;
1891
+ const selectedProjectRoot = state.selectedProjectRoot || resolveProjectRowRoot(selectedProject);
1892
+ const currentProject = state.projects.find((row) => resolveProjectRowRoot(row) === currentProjectRoot) || null;
1893
+ const currentProjectLabel = currentProject
1894
+ ? String(currentProject.label || currentProject.project_name || path.basename(currentProjectRoot) || currentProjectRoot)
1895
+ : "";
1896
+ const inCommittedProjectScope = Boolean(props.globalMode && state.globalScope === "project" && currentProjectRoot);
1897
+ const displayAgents = state.agents;
1898
+ const displayAgentMeta = state.activeAgentMeta;
1899
+ const targetAgentId = state.agentSelectionMode && state.selectedAgentIndex >= 0
1900
+ ? displayAgents[state.selectedAgentIndex]
1901
+ : null;
1902
+ const targetAgentMeta = targetAgentId ? displayAgentMeta.get(targetAgentId) : null;
1903
+ const targetAgentLabel = targetAgentId ? getAgentLabelFor(targetAgentMeta, targetAgentId) : "";
1904
+ const restartDaemonBestEffort = useCallback(() => {
1905
+ const coordinator = props.daemonCoordinator;
1906
+ if (coordinator && typeof coordinator.restart === "function") {
1907
+ Promise.resolve(coordinator.restart()).catch((err) => {
1908
+ dispatch({ type: "log/append", text: `Error: ${err && err.message ? err.message : err}` });
1909
+ });
1910
+ return;
1911
+ }
1912
+ const conn = props.daemonConnection;
1913
+ try { if (conn && typeof conn.close === "function") conn.close(); } catch { /* ignore */ }
1914
+ try { if (conn && typeof conn.connect === "function") conn.connect(); } catch { /* ignore */ }
1915
+ }, []);
1916
+
1917
+ const persistSetting = useCallback((patch, statusText, restart = false) => {
1918
+ try {
1919
+ const { saveConfig } = require("../../config");
1920
+ saveConfig(props.projectRoot, patch);
1921
+ } catch (err) {
1922
+ dispatch({ type: "log/append", text: `Error: ${err && err.message ? err.message : err}` });
1923
+ }
1924
+ if (statusText) {
1925
+ dispatch({
1926
+ type: "status/set",
1927
+ payload: { message: statusText, type: "typing", showTimer: false, startedAt: Date.now() },
1928
+ });
1929
+ }
1930
+ if (restart) restartDaemonBestEffort();
1931
+ }, [restartDaemonBestEffort]);
1932
+
1933
+ const clearUfooAgentIdentity = useCallback(() => {
1934
+ try {
1935
+ const { getUfooPaths } = require("../../coordination/state/paths");
1936
+ const agentDir = getUfooPaths(props.projectRoot).agentDir;
1937
+ fs.rmSync(path.join(agentDir, "ufoo-agent.json"), { force: true });
1938
+ fs.rmSync(path.join(agentDir, "ufoo-agent.history.jsonl"), { force: true });
1939
+ } catch { /* ignore */ }
1940
+ }, []);
1941
+
1942
+ const applySelectedMode = useCallback(() => {
1943
+ const { normalizeLaunchMode } = require("../../config");
1944
+ const mode = normalizeLaunchMode(state.modeOptions[state.selectedModeIndex]);
1945
+ dispatch({ type: "settings/applyMode" });
1946
+ persistSetting({ launchMode: mode }, `Launch mode: ${mode}`, true);
1947
+ dispatch({ type: "focus/set", mode: "input" });
1948
+ }, [state.modeOptions, state.selectedModeIndex, persistSetting]);
1949
+
1950
+ const applySelectedProvider = useCallback(() => {
1951
+ const { normalizeAgentProvider } = require("../../config");
1952
+ const selected = state.providerOptions[state.selectedProviderIndex];
1953
+ const provider = normalizeAgentProvider(selected && selected.value);
1954
+ dispatch({ type: "settings/applyProvider" });
1955
+ clearUfooAgentIdentity();
1956
+ persistSetting({ agentProvider: provider }, `ufoo-agent: ${provider === "claude-cli" ? "claude" : "codex"}`, true);
1957
+ dispatch({ type: "focus/set", mode: "input" });
1958
+ }, [state.providerOptions, state.selectedProviderIndex, clearUfooAgentIdentity, persistSetting]);
1959
+
1960
+ const sendCronStop = useCallback((taskId) => {
1961
+ if (!taskId || !props.daemonConnection || typeof props.daemonConnection.send !== "function") return;
1962
+ try {
1963
+ const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
1964
+ props.daemonConnection.send({ type: IPC_REQUEST_TYPES.CRON, operation: "stop", id: taskId });
1965
+ } catch (err) {
1966
+ dispatch({ type: "log/append", text: `Error: ${err && err.message ? err.message : err}` });
1967
+ }
1968
+ }, []);
1969
+
1970
+ const switchToProjectRoot = useCallback(async (targetRoot, options = {}) => {
1971
+ const root = String(targetRoot || "").trim();
1972
+ if (!root) return { ok: false, error: "project root unavailable" };
1973
+ if (props.globalMode && props.env && typeof props.env.isRunning === "function" && !props.env.isRunning(root)) {
1974
+ try {
1975
+ const { markProjectStopped } = require("../../runtime/projects");
1976
+ markProjectStopped(root);
1977
+ } catch { /* ignore */ }
1978
+ refreshGlobalProjects(currentProjectRoot);
1979
+ dispatch({ type: "projects/clearSelection" });
1980
+ dispatch({ type: "focus/set", mode: "input" });
1981
+ const label = path.basename(root) || root;
1982
+ const result = { ok: false, error: `project is not running: ${label}`, stopped: true };
1983
+ dispatch({ type: "log/append", text: `Project ${label} is not running; removed stale dashboard entry` });
1984
+ return result;
1985
+ }
1986
+ const focusInput = options.focusInput === true;
1987
+ const selected = state.projects.find((row) => resolveProjectRowRoot(row) === root) || {};
1988
+ dispatch({ type: "log/clear" });
1989
+ const banner = buildChatBannerLines({
1990
+ ...props,
1991
+ activeProjectRoot: root,
1992
+ globalScope: "project",
1993
+ }, fmt.UCODE_VERSION || "");
1994
+ dispatch({ type: "log/appendMany", lines: banner });
1995
+ const persisted = loadChatHistory(root, 200, { globalMode: false });
1996
+ if (persisted.length > 0) {
1997
+ dispatch({ type: "log/append", text: "" });
1998
+ dispatch({ type: "log/append", text: "─── history ───" });
1999
+ dispatch({ type: "log/appendMany", lines: persisted });
2000
+ }
2001
+ if (props.daemonCoordinator && typeof props.daemonCoordinator.switchProject === "function") {
2002
+ const { socketPath } = require("../../runtime/daemon");
2003
+ const res = await Promise.resolve(props.daemonCoordinator.switchProject({
2004
+ projectRoot: root,
2005
+ sockPath: socketPath(root),
2006
+ autoStart: false,
2007
+ }));
2008
+ if (!res || res.ok !== true) {
2009
+ dispatch({ type: "log/append", text: `Error: ${(res && res.error) || "switch failed"}` });
2010
+ return res || { ok: false, error: "switch failed" };
2011
+ }
2012
+ }
2013
+ setCurrentProjectRoot(root);
2014
+ dispatch({ type: "scope/set", scope: "project" });
2015
+ dispatch({
2016
+ type: "projects/select",
2017
+ index: state.projects.indexOf(selected),
2018
+ projectRoot: root,
2019
+ });
2020
+ refreshGlobalProjects(root);
2021
+ if (focusInput) dispatch({ type: "focus/set", mode: "input" });
2022
+ try {
2023
+ const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
2024
+ if (props.daemonConnection && typeof props.daemonConnection.send === "function") {
2025
+ props.daemonConnection.send({ type: IPC_REQUEST_TYPES.STATUS });
2026
+ }
2027
+ } catch { /* ignore */ }
2028
+ return { ok: true, project_root: root };
2029
+ }, [
2030
+ props,
2031
+ props.daemonCoordinator,
2032
+ props.daemonConnection,
2033
+ props.env,
2034
+ state.projects,
2035
+ refreshGlobalProjects,
2036
+ currentProjectRoot,
2037
+ ]);
2038
+
2039
+ useEffect(() => {
2040
+ switchToProjectRootRef.current = switchToProjectRoot;
2041
+ }, [switchToProjectRoot]);
2042
+
2043
+ const switchToControllerRoot = useCallback(async () => {
2044
+ const root = props.activeProjectRoot || props.projectRoot || "";
2045
+ if (!root) return { ok: false, error: "controller root unavailable" };
2046
+ if (props.daemonCoordinator && typeof props.daemonCoordinator.switchProject === "function") {
2047
+ const { socketPath } = require("../../runtime/daemon");
2048
+ const res = await Promise.resolve(props.daemonCoordinator.switchProject({
2049
+ projectRoot: root,
2050
+ sockPath: socketPath(root),
2051
+ }));
2052
+ if (!res || res.ok !== true) {
2053
+ dispatch({ type: "log/append", text: `Error: ${(res && res.error) || "switch to global failed"}` });
2054
+ return res || { ok: false, error: "switch to global failed" };
2055
+ }
2056
+ }
2057
+
2058
+ dispatch({ type: "projects/clearSelection" });
2059
+ dispatch({ type: "scope/set", scope: "controller" });
2060
+ setCurrentProjectRoot(root);
2061
+ refreshGlobalProjects(root);
2062
+
2063
+ dispatch({ type: "log/clear" });
2064
+ const banner = buildChatBannerLines({
2065
+ ...props,
2066
+ activeProjectRoot: root,
2067
+ globalScope: "controller",
2068
+ }, fmt.UCODE_VERSION || "");
2069
+ dispatch({ type: "log/appendMany", lines: banner });
2070
+ const persisted = loadChatHistory(root, 200, { globalMode: true });
2071
+ if (persisted.length > 0) {
2072
+ dispatch({ type: "log/append", text: "" });
2073
+ dispatch({ type: "log/append", text: "─── history ───" });
2074
+ dispatch({ type: "log/appendMany", lines: persisted });
2075
+ }
2076
+
2077
+ const snapshot = readProjectAgentSnapshot(root);
2078
+ dispatch({ type: "agents/set", list: snapshot.agents.map((id) => snapshot.metaMap.get(id) || { fullId: id }) });
2079
+ try {
2080
+ const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
2081
+ if (props.daemonConnection && typeof props.daemonConnection.send === "function") {
2082
+ props.daemonConnection.send({ type: IPC_REQUEST_TYPES.STATUS });
2083
+ }
2084
+ } catch { /* ignore */ }
2085
+ return { ok: true, project_root: root };
2086
+ }, [
2087
+ props,
2088
+ props.daemonCoordinator,
2089
+ props.daemonConnection,
2090
+ refreshGlobalProjects,
2091
+ ]);
2092
+
2093
+ const closeSelectedProject = useCallback(async () => {
2094
+ if (!props.globalMode || !Array.isArray(state.projects) || state.projects.length === 0) return;
2095
+ const selectedIndex = state.selectedProjectIndex >= 0 ? state.selectedProjectIndex : 0;
2096
+ const proj = state.projects[selectedIndex];
2097
+ const targetRoot = resolveProjectRowRoot(proj);
2098
+ const label = (proj && (proj.label || proj.project_name)) || targetRoot;
2099
+ if (!targetRoot) {
2100
+ dispatch({ type: "log/append", text: "Error: project root unavailable" });
2101
+ return;
2102
+ }
2103
+
2104
+ dispatch({ type: "log/append", text: `Closing project ${label} daemon and agents...` });
2105
+ let activeRoot = currentProjectRoot;
2106
+ try {
2107
+ if (targetRoot === currentProjectRoot) {
2108
+ const fallback = state.projects
2109
+ .map(resolveProjectRowRoot)
2110
+ .find((root) => root && root !== targetRoot);
2111
+ if (!fallback) {
2112
+ dispatch({ type: "log/append", text: "Error: Cannot close current project; switch to another project first" });
2113
+ return;
2114
+ }
2115
+ if (!props.daemonCoordinator || typeof props.daemonCoordinator.switchProject !== "function") {
2116
+ dispatch({ type: "log/append", text: "Error: project switching unavailable" });
2117
+ return;
2118
+ }
2119
+ const { socketPath } = require("../../runtime/daemon");
2120
+ const switched = await Promise.resolve(props.daemonCoordinator.switchProject({
2121
+ projectRoot: fallback,
2122
+ sockPath: socketPath(fallback),
2123
+ autoStart: false,
2124
+ }));
2125
+ if (!switched || switched.ok !== true) {
2126
+ dispatch({ type: "log/append", text: `Error: Failed to switch project before close: ${(switched && switched.error) || "switch failed"}` });
2127
+ return;
2128
+ }
2129
+ activeRoot = fallback;
2130
+ setCurrentProjectRoot(fallback);
2131
+ dispatch({ type: "scope/set", scope: "project" });
2132
+ }
2133
+
2134
+ const { stopDaemon } = require("../../app/chat/transport");
2135
+ const { isRunning } = require("../../runtime/daemon");
2136
+ stopDaemon(targetRoot, { source: `ink-project-close:${targetRoot}` });
2137
+ refreshGlobalProjects(activeRoot);
2138
+ if (isRunning(targetRoot)) {
2139
+ dispatch({ type: "log/append", text: `Error: Project ${label} daemon is still running after stop` });
2140
+ return;
2141
+ }
2142
+ dispatch({ type: "log/append", text: `Closed project ${label} daemon and agents` });
2143
+ } catch (err) {
2144
+ dispatch({ type: "log/append", text: `Error: ${err && err.message ? err.message : err}` });
2145
+ }
2146
+ }, [
2147
+ props.globalMode,
2148
+ props.daemonCoordinator,
2149
+ state.projects,
2150
+ state.selectedProjectIndex,
2151
+ currentProjectRoot,
2152
+ refreshGlobalProjects,
2153
+ ]);
2154
+
2155
+ const submit = useCallback(async (submitted) => {
2156
+ const value = String(submitted == null ? state.draft : submitted);
2157
+ const trimmed = value.trim();
2158
+ if (props.globalMode && state.globalScope === "project" && selectedProjectRoot && selectedProjectRoot !== currentProjectRoot) {
2159
+ const switched = await switchToProjectRoot(selectedProjectRoot, { focusInput: true });
2160
+ if (!switched || switched.ok !== true) return;
2161
+ }
2162
+ dispatch({ type: "draft/clear" });
2163
+ const { createInputSubmitHandler } = require("../../app/chat/inputSubmitHandler");
2164
+ const { parseAtTarget } = require("../../app/chat/commands");
2165
+ const { resolveAgentId } = require("../../app/chat/agentDirectory");
2166
+ const { subscriberToSafeName } = require("../../coordination/bus/utils");
2167
+ const { getUfooPaths } = require("../../coordination/state/paths");
2168
+ const { createTerminalAdapterRouter } = require("../../runtime/terminal/adapterRouter");
2169
+ const submitState = {};
2170
+ Object.defineProperties(submitState, {
2171
+ targetAgent: {
2172
+ get: () => targetAgentId || null,
2173
+ set: (next) => {
2174
+ const id = String(next || "");
2175
+ if (!id) {
2176
+ dispatch({ type: "agents/clearTarget" });
2177
+ return;
2178
+ }
2179
+ const idx = displayAgents.indexOf(id);
2180
+ if (idx >= 0) dispatch({ type: "agents/select", index: idx });
2181
+ },
2182
+ },
2183
+ pending: {
2184
+ get: () => pendingRef.current,
2185
+ set: (next) => { pendingRef.current = next || null; },
2186
+ },
2187
+ activeAgentMetaMap: {
2188
+ get: () => displayAgentMeta,
2189
+ },
2190
+ });
2191
+ const send = (req) => {
2192
+ if (!props.daemonConnection || typeof props.daemonConnection.send !== "function") {
2193
+ throw new Error("daemon connection unavailable");
2194
+ }
2195
+ props.daemonConnection.send(req);
2196
+ };
2197
+ const handler = createInputSubmitHandler({
2198
+ state: submitState,
2199
+ parseAtTarget,
2200
+ resolveAgentId: (label) => resolveAgentId({
2201
+ label,
2202
+ activeAgents: displayAgents,
2203
+ labelMap: buildActiveAgentLabelMap(displayAgents, displayAgentMeta),
2204
+ lookupNickname: (nickname) => {
2205
+ for (const [id, meta] of displayAgentMeta.entries()) {
2206
+ if (!meta) continue;
2207
+ if (meta.nickname === nickname || meta.scoped_nickname === nickname || meta.display_nickname === nickname) return id;
2208
+ }
2209
+ return null;
2210
+ },
2211
+ }),
2212
+ executeCommand: async (text) => {
2213
+ const exec = commandExecutorRef.current;
2214
+ if (!exec || typeof exec.executeCommand !== "function") {
2215
+ throw new Error("command executor not ready yet");
2216
+ }
2217
+ return exec.executeCommand(text);
2218
+ },
2219
+ queueStatusLine: (text) => setStatusText(text, { type: "typing", showTimer: true }),
2220
+ send,
2221
+ logMessage: logInkMessage,
2222
+ getAgentLabel: (id) => getAgentLabelFor(displayAgentMeta.get(id), id),
2223
+ escapeBlessed: (next) => String(next == null ? "" : next),
2224
+ markPendingDelivery: (agentId) => {
2225
+ const meta = displayAgentMeta.get(agentId);
2226
+ streamStateRef.current.markPendingDelivery(agentId, getAgentLabelFor(meta, agentId));
2227
+ },
2228
+ clearTargetAgent: () => dispatch({ type: "agents/clearTarget" }),
2229
+ setTargetAgent: (agentId) => {
2230
+ const idx = displayAgents.indexOf(agentId);
2231
+ if (idx >= 0) dispatch({ type: "agents/select", index: idx });
2232
+ },
2233
+ enterAgentView: (agentId, options = {}) => {
2234
+ const payload = buildAgentEnterPayload(agentId);
2235
+ if (payload && options.useBus) payload.useBus = true;
2236
+ if (payload && payload.useBus) {
2237
+ enterInternalAgentView(payload);
2238
+ return;
2239
+ }
2240
+ if (payload && typeof props.requestEnterAgentView === "function") {
2241
+ props.requestEnterAgentView(agentId, payload);
2242
+ exit();
2243
+ }
2244
+ },
2245
+ getAgentAdapter: (agentId) => {
2246
+ const meta = displayAgentMeta.get(agentId) || {};
2247
+ const launchMode = String(meta.launch_mode || meta.launchMode || state.settings.launchMode || "").trim();
2248
+ return createTerminalAdapterRouter().getAdapter({ launchMode, agentId, meta });
2249
+ },
2250
+ activateAgent: async (agentId) => {
2251
+ const AgentActivator = require("../../coordination/bus/activate");
2252
+ const activator = new AgentActivator(currentProjectRoot || props.projectRoot);
2253
+ await activator.activate(agentId);
2254
+ },
2255
+ getInjectSockPath: (agentId) => {
2256
+ const safeName = subscriberToSafeName(agentId);
2257
+ return path.join(getUfooPaths(currentProjectRoot || props.projectRoot).busQueuesDir, safeName, "inject.sock");
2258
+ },
2259
+ existsSync: fs.existsSync,
2260
+ commitInputHistory: (text) => {
2261
+ dispatch({ type: "history/push", value: text });
2262
+ try { appendInputHistory(props.projectRoot, text, { globalMode: props.globalMode }); } catch { /* ignore */ }
2263
+ },
2264
+ focusInput: () => dispatch({ type: "focus/set", mode: "input" }),
2265
+ renderScreen: () => {},
2266
+ getShellCwd: () => activeChatHistoryRoot,
2267
+ runShellCommand: async (shellCommand, options = {}) => {
2268
+ const { runShellCommand } = require("../../app/chat/shellCommand");
2269
+ return runShellCommand(shellCommand, options);
2270
+ },
2271
+ });
2272
+ try {
2273
+ await handler.handleSubmit(value);
2274
+ } catch (err) {
2275
+ dispatch({ type: "log/append", text: `Error: ${err && err.message ? err.message : "send failed"}` });
2276
+ dispatch({ type: "status/idle" });
2277
+ }
2278
+ }, [
2279
+ state.draft,
2280
+ targetAgentId,
2281
+ props.globalMode,
2282
+ props.projectRoot,
2283
+ props.daemonConnection,
2284
+ props.requestEnterAgentView,
2285
+ selectedProjectRoot,
2286
+ currentProjectRoot,
2287
+ state.globalScope,
2288
+ state.settings.launchMode,
2289
+ switchToProjectRoot,
2290
+ displayAgents,
2291
+ displayAgentMeta,
2292
+ activeChatHistoryRoot,
2293
+ logInkMessage,
2294
+ setStatusText,
2295
+ exit,
2296
+ ]);
2297
+
2298
+ const onArrowUpAtTop = useCallback(() => {
2299
+ if (state.inputHistory.length > 0) {
2300
+ const next = Math.max(0, state.historyIndex - 1);
2301
+ if (next !== state.historyIndex || state.draft !== state.inputHistory[next]) {
2302
+ dispatch({ type: "history/setIndex", index: next });
2303
+ dispatch({ type: "draft/set", value: state.inputHistory[next] || "" });
2304
+ setCompletionSuppressedDraft(state.inputHistory[next] || "");
2305
+ setDraftVersion((v) => v + 1);
2306
+ return;
2307
+ }
2308
+ }
2309
+ if (state.agentSelectionMode) dispatch({ type: "agents/clearTarget" });
2310
+ }, [state.inputHistory, state.historyIndex, state.draft, state.agentSelectionMode]);
2311
+
2312
+ const onArrowDownAtBottom = useCallback((currentValue) => {
2313
+ if (state.inputHistory.length > 0) {
2314
+ const transition = fmt.resolveHistoryDownTransition({
2315
+ inputHistory: state.inputHistory,
2316
+ historyIndex: state.historyIndex,
2317
+ currentValue,
2318
+ });
2319
+ if (transition.moved) {
2320
+ dispatch({ type: "history/setIndex", index: transition.nextHistoryIndex });
2321
+ dispatch({ type: "draft/set", value: transition.nextValue });
2322
+ setCompletionSuppressedDraft(transition.nextValue);
2323
+ setDraftVersion((v) => v + 1);
2324
+ return;
2325
+ }
2326
+ }
2327
+ // Hand focus to the dashboard. Three-tier flow:
2328
+ // global mode → projects → agents → mode/provider/cron
2329
+ // project mode → agents → mode/provider/cron
2330
+ if (props.globalMode) {
2331
+ dispatch({ type: "focus/set", mode: "dashboard" });
2332
+ if (state.projects.length > 0 && state.selectedProjectIndex < 0) {
2333
+ dispatch({ type: "view/set", view: "projects" });
2334
+ dispatch({ type: "projects/select", index: 0, projectRoot: resolveProjectRowRoot(state.projects[0]) });
2335
+ dispatch({ type: "projects/window", windowStart: 0 });
2336
+ } else {
2337
+ dispatch({ type: "view/set", view: "agents" });
2338
+ if (displayAgents.length > 0 && state.selectedAgentIndex < 0) {
2339
+ dispatch({ type: "agents/select", index: 0 });
2340
+ }
2341
+ }
2342
+ return;
2343
+ }
2344
+ dispatch({ type: "focus/set", mode: "dashboard" });
2345
+ dispatch({ type: "view/set", view: "agents" });
2346
+ if (displayAgents.length > 0 && state.selectedAgentIndex < 0) {
2347
+ dispatch({ type: "agents/select", index: 0 });
2348
+ }
2349
+ }, [state.inputHistory, state.historyIndex, state.projects.length, state.selectedProjectIndex, displayAgents.length, state.selectedAgentIndex, props.globalMode]);
2350
+
2351
+ const onArrowSideAtEmpty = useCallback((direction) => {
2352
+ if (!state.agentSelectionMode || displayAgents.length === 0) return;
2353
+ const cur = state.selectedAgentIndex < 0 ? 0 : state.selectedAgentIndex;
2354
+ const next = direction === "left"
2355
+ ? Math.max(0, cur - 1)
2356
+ : Math.min(displayAgents.length - 1, cur + 1);
2357
+ dispatch({ type: "agents/select", index: next });
2358
+ }, [state.agentSelectionMode, state.selectedAgentIndex, displayAgents.length]);
2359
+
2360
+ // Inline completions: shown above the input whenever the draft starts
2361
+ // with "/" or "@". Tab/Enter accept the highlighted entry, ↑↓ move the
2362
+ // selection. The list reuses the pure buildCompletions helper from
2363
+ // src/ui/format so jest can pin the source list without rendering ink.
2364
+ const { COMMAND_REGISTRY, COMMAND_TREE } = require("../../app/chat/commands");
2365
+ const agentLabels = displayAgents.map((id) =>
2366
+ getAgentLabelFor(displayAgentMeta.get(id), id)
2367
+ );
2368
+
2369
+ // Lazy-load the dynamic completion sources once so /group run and
2370
+ // /solo run get the same alias/profile suggestions blessed shows.
2371
+ const dynamicSourcesRef = useRef(null);
2372
+ if (!dynamicSourcesRef.current) {
2373
+ const sources = { groupTemplates: [], soloProfiles: [] };
2374
+ try {
2375
+ const { loadTemplateRegistry } = require("../../orchestration/groups/templates");
2376
+ const reg = typeof loadTemplateRegistry === "function" ? loadTemplateRegistry(props.projectRoot) : null;
2377
+ if (reg && Array.isArray(reg.templates)) {
2378
+ sources.groupTemplates = reg.templates.map((item) => ({
2379
+ alias: item.alias,
2380
+ cmd: item.alias,
2381
+ desc: item.templateDescription || "",
2382
+ source: item.source || "",
2383
+ }));
2384
+ }
2385
+ } catch { /* ignore */ }
2386
+ try {
2387
+ const { loadPromptProfileRegistry } = require("../../orchestration/groups/promptProfiles");
2388
+ const { buildPromptProfileCandidates } = require("../../orchestration/solo/commands");
2389
+ const reg = typeof loadPromptProfileRegistry === "function" ? loadPromptProfileRegistry(props.projectRoot) : null;
2390
+ if (reg && typeof buildPromptProfileCandidates === "function") {
2391
+ sources.soloProfiles = buildPromptProfileCandidates(reg) || [];
2392
+ }
2393
+ } catch { /* ignore */ }
2394
+ dynamicSourcesRef.current = sources;
2395
+ }
2396
+
2397
+ const completions = fmt.buildCompletions({
2398
+ text: state.draft,
2399
+ agents: displayAgents,
2400
+ agentLabels,
2401
+ commands: COMMAND_REGISTRY,
2402
+ commandTree: COMMAND_TREE,
2403
+ groupTemplates: dynamicSourcesRef.current.groupTemplates,
2404
+ soloProfiles: dynamicSourcesRef.current.soloProfiles,
2405
+ limit: 20,
2406
+ });
2407
+ const [completionIndex, setCompletionIndex] = useState(0);
2408
+ // First visible row inside the popup. We show 8 rows at a time
2409
+ // (POPUP_PAGE_SIZE) and slide the window when the cursor crosses
2410
+ // the bottom or top, mimicking how a terminal list typically scrolls.
2411
+ const POPUP_PAGE_SIZE = 8;
2412
+ const [completionWindowStart, setCompletionWindowStart] = useState(0);
2413
+ // Bumped whenever the completion popup writes a new value into the
2414
+ // draft — MultilineInput watches this counter so it can park its
2415
+ // cursor at the end of the freshly accepted suggestion instead of
2416
+ // staying wherever the user last typed.
2417
+ const [draftVersion, setDraftVersion] = useState(0);
2418
+ // History recall should not immediately turn a recalled command such as
2419
+ // "/history" into an active completion popup; otherwise ↑/↓ get captured
2420
+ // by completion navigation and the user cannot keep walking history.
2421
+ const [completionSuppressedDraft, setCompletionSuppressedDraft] = useState(null);
2422
+ // Reset the selection cursor whenever the suggestion list shape changes.
2423
+ useEffect(() => {
2424
+ if (completions.length === 0) {
2425
+ if (completionIndex !== 0) setCompletionIndex(0);
2426
+ if (completionWindowStart !== 0) setCompletionWindowStart(0);
2427
+ } else if (completionIndex >= completions.length) {
2428
+ setCompletionIndex(completions.length - 1);
2429
+ setCompletionWindowStart(Math.max(0, completions.length - POPUP_PAGE_SIZE));
2430
+ }
2431
+ }, [completions.length, completionIndex, completionWindowStart]);
2432
+ useEffect(() => {
2433
+ if (multiWindowActive) setMwCursor(String(state.draft || "").length);
2434
+ }, [draftVersion]);
2435
+ const completionsOpen = completions.length > 0 && state.draft !== completionSuppressedDraft;
2436
+ const acceptCompletion = useCallback(() => {
2437
+ if (!completionsOpen) return false;
2438
+ const item = completions[Math.max(0, Math.min(completions.length - 1, completionIndex))];
2439
+ if (item) {
2440
+ dispatch({ type: "draft/set", value: item.replace });
2441
+ setCompletionSuppressedDraft(item.hasChildren ? null : item.replace);
2442
+ setDraftVersion((v) => v + 1);
2443
+ }
2444
+ setCompletionIndex(0);
2445
+ return true;
2446
+ }, [completionsOpen, completions, completionIndex]);
2447
+
2448
+ const buildAgentEnterPayload = (agentId) => {
2449
+ const agentMeta = displayAgentMeta.get(agentId);
2450
+ const enterRequest = resolveAgentEnterRequest({
2451
+ agentId,
2452
+ projectRoot: currentProjectRoot || props.projectRoot,
2453
+ activeAgentMeta: displayAgentMeta,
2454
+ settings: state.settings,
2455
+ });
2456
+ return {
2457
+ ...enterRequest,
2458
+ agentLabel: getAgentLabelFor(agentMeta, agentId),
2459
+ agentAliases: [
2460
+ agentId,
2461
+ agentMeta && agentMeta.nickname,
2462
+ agentMeta && agentMeta.scoped_nickname,
2463
+ agentMeta && agentMeta.display_nickname,
2464
+ ].filter(Boolean).map(String),
2465
+ };
2466
+ };
2467
+
2468
+ const activateExternalAgent = (agentId) => {
2469
+ const id = String(agentId || "").trim();
2470
+ if (!id) return;
2471
+ try {
2472
+ const AgentActivator = require("../../coordination/bus/activate");
2473
+ const activator = new AgentActivator(currentProjectRoot || props.projectRoot);
2474
+ void activator.activate(id);
2475
+ } catch (err) {
2476
+ logInkMessage("error", `✗ Failed to activate ${id}: ${err && err.message ? err.message : "unknown error"}`);
2477
+ }
2478
+ };
2479
+
2480
+ const enterInternalAgentView = (enterRequest = {}) => {
2481
+ const agentId = String(enterRequest.agentId || "").trim();
2482
+ if (!agentId) return;
2483
+ const previous = internalAgentViewRef.current;
2484
+ if (previous && previous.agentId && previous.agentId !== agentId) {
2485
+ sendInternalAgentWatch(previous.agentId, false);
2486
+ }
2487
+ const next = createInternalAgentViewState({
2488
+ agentId,
2489
+ label: enterRequest.agentLabel || agentId,
2490
+ aliases: enterRequest.agentAliases || [],
2491
+ projectRoot: enterRequest.projectRoot || currentProjectRoot || props.projectRoot,
2492
+ width: size.cols || 80,
2493
+ });
2494
+ setInternalAgentView(next);
2495
+ internalAgentViewRef.current = next;
2496
+ dispatch({ type: "agentView/enter", agentId });
2497
+ dispatch({ type: "focus/set", mode: "input" });
2498
+ dispatch({ type: "agents/clearTarget" });
2499
+ sendInternalAgentWatch(agentId, true);
2500
+ try {
2501
+ const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
2502
+ if (props.daemonConnection && typeof props.daemonConnection.send === "function") {
2503
+ props.daemonConnection.send({ type: IPC_REQUEST_TYPES.STATUS });
2504
+ }
2505
+ } catch { /* ignore */ }
2506
+ };
2507
+
2508
+ const exitInternalAgentView = () => {
2509
+ const view = internalAgentViewRef.current;
2510
+ if (view && view.agentId) sendInternalAgentWatch(view.agentId, false);
2511
+ const empty = createInternalAgentViewState();
2512
+ setInternalAgentView(empty);
2513
+ internalAgentViewRef.current = empty;
2514
+ dispatch({ type: "agentView/exit" });
2515
+ dispatch({ type: "view/set", view: "agents" });
2516
+ dispatch({ type: "focus/set", mode: "input" });
2517
+ };
2518
+
2519
+ const submitInternalAgentInput = () => {
2520
+ const view = internalAgentViewRef.current;
2521
+ const text = String((view && view.input) || "").trim();
2522
+ if (!view || !view.agentId || !text) return;
2523
+ setInternalAgentView((prev) => ({
2524
+ ...updateInternalViewStatus(
2525
+ appendInternalAgentText(prev, `${text}\n`, { prefix: "> " }),
2526
+ "working",
2527
+ "",
2528
+ ),
2529
+ input: "",
2530
+ cursor: 0,
2531
+ }));
2532
+ sendInternalAgentMessage(view.agentId, text);
2533
+ };
2534
+
2535
+ const handleInternalAgentDashboardKey = (input, key = {}) => {
2536
+ const keyName = resolveInternalKeyName(input, key);
2537
+ const totalItems = 1 + displayAgents.length;
2538
+ const currentIndex = Math.max(
2539
+ 0,
2540
+ Math.min(totalItems - 1, Number(internalAgentViewRef.current.barIndex) || 0),
2541
+ );
2542
+ if (keyName === "left") {
2543
+ setInternalAgentView((prev) => ({
2544
+ ...prev,
2545
+ barIndex: Math.max(0, (Number(prev.barIndex) || 0) - 1),
2546
+ }));
2547
+ return true;
2548
+ }
2549
+ if (keyName === "right") {
2550
+ setInternalAgentView((prev) => ({
2551
+ ...prev,
2552
+ barIndex: Math.min(totalItems - 1, (Number(prev.barIndex) || 0) + 1),
2553
+ }));
2554
+ return true;
2555
+ }
2556
+ if (keyName === "up") {
2557
+ dispatch({ type: "focus/set", mode: "input" });
2558
+ return true;
2559
+ }
2560
+ if (keyName === "return" || keyName === "enter") {
2561
+ if (currentIndex === 0) {
2562
+ exitInternalAgentView();
2563
+ return true;
2564
+ }
2565
+ const agentId = displayAgents[currentIndex - 1];
2566
+ if (!agentId) return true;
2567
+ if (agentId === state.viewingAgentId) {
2568
+ dispatch({ type: "focus/set", mode: "input" });
2569
+ return true;
2570
+ }
2571
+ const payload = buildAgentEnterPayload(agentId);
2572
+ const action = resolveDashboardAgentEnterAction(payload);
2573
+ if (action === "internal") {
2574
+ enterInternalAgentView(payload);
2575
+ return true;
2576
+ }
2577
+ if (action === "activate") {
2578
+ if (state.viewingAgentId) sendInternalAgentWatch(state.viewingAgentId, false);
2579
+ dispatch({ type: "agentView/exit" });
2580
+ dispatch({ type: "view/set", view: "agents" });
2581
+ dispatch({ type: "focus/set", mode: "input" });
2582
+ activateExternalAgent(agentId);
2583
+ return true;
2584
+ }
2585
+ if (payload && typeof props.requestEnterAgentView === "function") {
2586
+ if (state.viewingAgentId) sendInternalAgentWatch(state.viewingAgentId, false);
2587
+ props.requestEnterAgentView(agentId, payload);
2588
+ exit();
2589
+ }
2590
+ return true;
2591
+ }
2592
+ if (key && key.ctrl && input === "x") {
2593
+ if (currentIndex <= 0) return true;
2594
+ const agentId = displayAgents[currentIndex - 1];
2595
+ if (!agentId) return true;
2596
+ try {
2597
+ const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
2598
+ if (props.daemonConnection && typeof props.daemonConnection.send === "function") {
2599
+ props.daemonConnection.send({ type: IPC_REQUEST_TYPES.CLOSE_AGENT, agent_id: agentId });
2600
+ }
2601
+ } catch { /* ignore */ }
2602
+ if (agentId === state.viewingAgentId) {
2603
+ exitInternalAgentView();
2604
+ } else {
2605
+ setInternalAgentView((prev) => ({
2606
+ ...prev,
2607
+ barIndex: Math.min(Number(prev.barIndex) || 0, Math.max(0, displayAgents.length - 1)),
2608
+ }));
2609
+ }
2610
+ return true;
2611
+ }
2612
+ return true;
2613
+ };
2614
+
2615
+ const handleInternalAgentViewKey = (input, key = {}) => {
2616
+ if (!state.viewingAgentId) return false;
2617
+ const keyName = resolveInternalKeyName(input, key);
2618
+
2619
+ if (state.focusMode === "dashboard") {
2620
+ return handleInternalAgentDashboardKey(input, key);
2621
+ }
2622
+
2623
+ if (keyName === "escape") {
2624
+ exitInternalAgentView();
2625
+ return true;
2626
+ }
2627
+ if (keyName === "down") {
2628
+ setInternalAgentView((prev) => ({ ...prev, barIndex: 0 }));
2629
+ dispatch({ type: "focus/set", mode: "dashboard" });
2630
+ return true;
2631
+ }
2632
+ if (keyName === "return" || keyName === "enter") {
2633
+ submitInternalAgentInput();
2634
+ return true;
2635
+ }
2636
+ if (key && key.ctrl && keyName === "u") {
2637
+ setInternalAgentView((prev) => ({ ...prev, input: "", cursor: 0 }));
2638
+ return true;
2639
+ }
2640
+ if (key && key.ctrl && keyName === "a") {
2641
+ setInternalAgentView((prev) => ({ ...prev, cursor: 0 }));
2642
+ return true;
2643
+ }
2644
+ if (key && key.ctrl && keyName === "e") {
2645
+ setInternalAgentView((prev) => ({ ...prev, cursor: String(prev.input || "").length }));
2646
+ return true;
2647
+ }
2648
+ if (keyName === "left") {
2649
+ setInternalAgentView((prev) => ({
2650
+ ...prev,
2651
+ cursor: previousInternalBoundary(prev.input, prev.cursor),
2652
+ }));
2653
+ return true;
2654
+ }
2655
+ if (keyName === "right") {
2656
+ setInternalAgentView((prev) => ({
2657
+ ...prev,
2658
+ cursor: nextInternalBoundary(prev.input, prev.cursor),
2659
+ }));
2660
+ return true;
2661
+ }
2662
+ if (keyName === "backspace") {
2663
+ setInternalAgentView((prev) => {
2664
+ const cursor = Number.isFinite(prev.cursor) ? prev.cursor : String(prev.input || "").length;
2665
+ if (cursor <= 0) return prev;
2666
+ const previous = previousInternalBoundary(prev.input, cursor);
2667
+ return {
2668
+ ...prev,
2669
+ input: String(prev.input || "").slice(0, previous) + String(prev.input || "").slice(cursor),
2670
+ cursor: previous,
2671
+ };
2672
+ });
2673
+ return true;
2674
+ }
2675
+ if (keyName === "delete") {
2676
+ setInternalAgentView((prev) => {
2677
+ const text = String(prev.input || "");
2678
+ const cursor = Number.isFinite(prev.cursor) ? prev.cursor : text.length;
2679
+ if (cursor >= text.length) return prev;
2680
+ const next = nextInternalBoundary(text, cursor);
2681
+ return {
2682
+ ...prev,
2683
+ input: text.slice(0, cursor) + text.slice(next),
2684
+ cursor,
2685
+ };
2686
+ });
2687
+ return true;
2688
+ }
2689
+ if (input
2690
+ && !(key && key.ctrl)
2691
+ && !(key && key.meta)
2692
+ && !/^[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]+$/.test(input)) {
2693
+ const clean = String(input).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2694
+ setInternalAgentView((prev) => {
2695
+ const text = String(prev.input || "");
2696
+ const cursor = Number.isFinite(prev.cursor) ? prev.cursor : text.length;
2697
+ return {
2698
+ ...prev,
2699
+ input: text.slice(0, cursor) + clean + text.slice(cursor),
2700
+ cursor: cursor + clean.length,
2701
+ };
2702
+ });
2703
+ return true;
2704
+ }
2705
+ return true;
2706
+ };
2707
+
2708
+ useInput((input, key) => {
2709
+ if (multiWindowActive) {
2710
+ const controller = multiWindowControllerRef.current;
2711
+ const termFocused = mwTerminalFocusedRef.current;
2712
+ if (key.ctrl && input === "q") {
2713
+ if (controller && typeof controller.handleKey === "function") {
2714
+ controller.handleKey({ name: "q", ctrl: true, sequence: "" });
2715
+ }
2716
+ mwTerminalFocusedRef.current = false;
2717
+ setMwTerminalFocused(false);
2718
+ return;
2719
+ }
2720
+ if (key.ctrl && input === "w") {
2721
+ const agents = controller ? controller.getAgentIds() : [];
2722
+ if (agents.length === 0) return;
2723
+ if (!termFocused) {
2724
+ if (controller) controller.focusAgent(agents[0]);
2725
+ mwTerminalFocusedRef.current = true;
2726
+ setMwTerminalFocused(true);
2727
+ } else {
2728
+ const current = controller ? controller.getFocused() : null;
2729
+ const idx = current ? agents.indexOf(current) : -1;
2730
+ if (idx >= 0 && idx < agents.length - 1) {
2731
+ controller.focusAgent(agents[idx + 1]);
2732
+ } else {
2733
+ mwTerminalFocusedRef.current = false;
2734
+ setMwTerminalFocused(false);
2735
+ if (controller) controller.focusAgent(agents[0]);
2736
+ }
2737
+ }
2738
+ if (controller) controller.renderAll();
2739
+ return;
2740
+ }
2741
+ if (termFocused && controller && typeof controller.sendInput === "function") {
2742
+ const now = Date.now();
2743
+ const last = mwLastInputRef.current;
2744
+ if (input === " " && !key.ctrl && !key.meta && isCJK(last.char) && now - last.time < 150) {
2745
+ return;
2746
+ }
2747
+ const raw = inkKeyToRaw(input, key);
2748
+ if (raw) {
2749
+ const cleaned = raw.length > 1 && /[⺀-鿿가-힯豈-﫿︰-﹏]/.test(raw)
2750
+ ? raw.replace(/ +$/, "")
2751
+ : raw;
2752
+ if (cleaned) {
2753
+ controller.sendInput(cleaned);
2754
+ const lastChar = cleaned[cleaned.length - 1];
2755
+ mwLastInputRef.current = { char: lastChar, time: now };
2756
+ }
2757
+ }
2758
+ return;
2759
+ }
2760
+ }
2761
+ if (key.ctrl && input === "c") { exit(); return; }
2762
+ if (key.ctrl && input === "o") { dispatch({ type: "merge/expand" }); return; }
2763
+ if (state.viewingAgentId) {
2764
+ handleInternalAgentViewKey(input, key);
2765
+ return;
2766
+ }
2767
+
2768
+ // Completion popup steals arrow/Enter/Esc/Tab while it's open. The
2769
+ // user types to filter, picks with the cursor and accepts with Tab
2770
+ // or Enter; Esc dismisses by clearing the trigger character.
2771
+ if (completionsOpen) {
2772
+ if (key.upArrow) {
2773
+ setCompletionIndex((i) => {
2774
+ const next = (i - 1 + completions.length) % completions.length;
2775
+ setCompletionWindowStart((ws) => {
2776
+ if (next < ws) return next;
2777
+ if (next === completions.length - 1) {
2778
+ // wrapped to the bottom — snap window to the tail.
2779
+ return Math.max(0, completions.length - POPUP_PAGE_SIZE);
2780
+ }
2781
+ return ws;
2782
+ });
2783
+ return next;
2784
+ });
2785
+ return;
2786
+ }
2787
+ if (key.downArrow) {
2788
+ setCompletionIndex((i) => {
2789
+ const next = (i + 1) % completions.length;
2790
+ setCompletionWindowStart((ws) => {
2791
+ if (next === 0) return 0; // wrapped to the head
2792
+ if (next >= ws + POPUP_PAGE_SIZE) return next - POPUP_PAGE_SIZE + 1;
2793
+ return ws;
2794
+ });
2795
+ return next;
2796
+ });
2797
+ return;
2798
+ }
2799
+ if (key.return || key.tab) { acceptCompletion(); return; }
2800
+ if (key.escape) {
2801
+ setCompletionSuppressedDraft(null);
2802
+ dispatch({ type: "draft/clear" });
2803
+ return;
2804
+ }
2805
+ }
2806
+
2807
+ if (key.tab) {
2808
+ if (state.focusMode === "dashboard") {
2809
+ dispatch({ type: "focus/set", mode: "input" });
2810
+ return;
2811
+ }
2812
+ dispatch({ type: "focus/set", mode: "dashboard" });
2813
+ dispatch({ type: "view/set", view: props.globalMode ? "projects" : "agents" });
2814
+ if (props.globalMode && state.projects.length > 0 && state.selectedProjectIndex < 0) {
2815
+ dispatch({ type: "view/set", view: "projects" });
2816
+ dispatch({ type: "projects/select", index: 0, projectRoot: resolveProjectRowRoot(state.projects[0]) });
2817
+ } else if (!props.globalMode && state.agents.length > 0 && state.selectedAgentIndex < 0) {
2818
+ dispatch({ type: "agents/select", index: 0 });
2819
+ } else if (props.globalMode && state.projects.length === 0) {
2820
+ dispatch({ type: "view/set", view: "agents" });
2821
+ if (displayAgents.length > 0 && state.selectedAgentIndex < 0) {
2822
+ dispatch({ type: "agents/select", index: 0 });
2823
+ }
2824
+ }
2825
+ return;
2826
+ }
2827
+ // Dashboard focus + agents view + agent selected + Enter: hand off
2828
+ // to the agent view. Queue-only internal agents stay inside Ink,
2829
+ // matching blessed's useBus view; PTY/socket agents still hand off
2830
+ // to the raw mirror via the runChatInk loop.
2831
+ if (key.return && state.focusMode === "dashboard"
2832
+ && state.dashboardView === "agents"
2833
+ && state.agentSelectionMode
2834
+ && state.selectedAgentIndex >= 0) {
2835
+ const agentId = displayAgents[state.selectedAgentIndex];
2836
+ if (agentId && multiWindowActive) {
2837
+ const controller = multiWindowControllerRef.current;
2838
+ if (controller && typeof controller.focusAgent === "function") {
2839
+ controller.focusAgent(agentId);
2840
+ }
2841
+ setMwTerminalFocused(true);
2842
+ mwTerminalFocusedRef.current = true;
2843
+ dispatch({ type: "focus/set", mode: "input" });
2844
+ return;
2845
+ }
2846
+ if (agentId) {
2847
+ const enterPayload = buildAgentEnterPayload(agentId);
2848
+ const action = resolveDashboardAgentEnterAction(enterPayload);
2849
+ if (action === "internal") {
2850
+ enterInternalAgentView(enterPayload);
2851
+ return;
2852
+ }
2853
+ if (action === "activate") {
2854
+ dispatch({ type: "agents/clearTarget" });
2855
+ dispatch({ type: "focus/set", mode: "input" });
2856
+ activateExternalAgent(agentId);
2857
+ return;
2858
+ }
2859
+ if (typeof props.requestEnterAgentView === "function") {
2860
+ props.requestEnterAgentView(agentId, enterPayload);
2861
+ exit();
2862
+ }
2863
+ }
2864
+ return;
2865
+ }
2866
+ // Dashboard focus + projects view: ←/→ moves the highlighted
2867
+ // project, Enter switches the daemon connection to that project,
2868
+ // Ctrl+X stops it.
2869
+ if (state.focusMode === "dashboard" && state.dashboardView === "projects" && state.projects.length === 0) {
2870
+ if (key.downArrow) {
2871
+ for (const action of buildEmptyProjectsDownActions(state, displayAgents)) dispatch(action);
2872
+ return;
2873
+ }
2874
+ if (key.upArrow || key.return || key.escape) {
2875
+ dispatch({ type: "focus/set", mode: "input" });
2876
+ }
2877
+ return;
2878
+ }
2879
+ if (state.focusMode === "dashboard" && state.dashboardView === "projects" && state.projects.length > 0) {
2880
+ if (key.leftArrow || key.rightArrow) {
2881
+ const cur = Number.isFinite(state.selectedProjectIndex) && state.selectedProjectIndex >= 0
2882
+ ? state.selectedProjectIndex : 0;
2883
+ const next = key.leftArrow
2884
+ ? Math.max(0, cur - 1)
2885
+ : Math.min(state.projects.length - 1, cur + 1);
2886
+ if (next === cur) return;
2887
+ dispatch({ type: "projects/select", index: next, projectRoot: resolveProjectRowRoot(state.projects[next]) });
2888
+ // Slide the visible window to keep the cursor on screen. We mirror
2889
+ // clampAgentWindowWithSelection's logic with maxProjectWindow=5.
2890
+ const max = Math.max(1, Math.min(5, state.projects.length));
2891
+ let nextStart = state.projectListWindowStart || 0;
2892
+ if (next < nextStart) nextStart = next;
2893
+ else if (next >= nextStart + max) nextStart = next - max + 1;
2894
+ if (nextStart !== state.projectListWindowStart) {
2895
+ dispatch({ type: "projects/window", windowStart: nextStart });
2896
+ }
2897
+
2898
+ const proj = state.projects[next];
2899
+ const target = resolveProjectRowRoot(proj);
2900
+ if (target && state.globalScope === "project") {
2901
+ void switchToProjectRoot(target);
2902
+ }
2903
+ return;
2904
+ }
2905
+ if (key.return) {
2906
+ const cur = state.selectedProjectIndex >= 0 ? state.selectedProjectIndex : 0;
2907
+ const proj = state.projects[cur];
2908
+ const target = resolveProjectRowRoot(proj);
2909
+ void switchToProjectRoot(target, { focusInput: true });
2910
+ return;
2911
+ }
2912
+ if (input
2913
+ && !(key && key.ctrl)
2914
+ && !(key && key.meta)
2915
+ && !/^[\x00-\x1f\x7f]+$/.test(input)
2916
+ && !input.includes("\n")
2917
+ && !input.includes("\r")) {
2918
+ const cur = state.selectedProjectIndex >= 0 ? state.selectedProjectIndex : 0;
2919
+ const target = resolveProjectRowRoot(state.projects[cur]);
2920
+ void switchToProjectRoot(target, { focusInput: true });
2921
+ dispatch({ type: "draft/set", value: `${state.draft || ""}${input}` });
2922
+ setDraftVersion((v) => v + 1);
2923
+ return;
2924
+ }
2925
+ if (key.ctrl && input === "x") {
2926
+ void closeSelectedProject();
2927
+ return;
2928
+ }
2929
+ if (key.upArrow) {
2930
+ // Up out of projects → toggle back to input.
2931
+ dispatch({ type: "projects/clearSelection" });
2932
+ dispatch({ type: "focus/set", mode: "input" });
2933
+ return;
2934
+ }
2935
+ if (key.escape) {
2936
+ dispatch({ type: "projects/clearSelection" });
2937
+ if (state.globalScope === "project") {
2938
+ void switchToControllerRoot();
2939
+ return;
2940
+ }
2941
+ dispatch({ type: "focus/set", mode: "input" });
2942
+ return;
2943
+ }
2944
+ if (key.downArrow) {
2945
+ // Down from projects → agents row stays in dashboard focus.
2946
+ dispatch({ type: "view/set", view: "agents" });
2947
+ if (displayAgents.length > 0 && state.selectedAgentIndex < 0) {
2948
+ dispatch({ type: "agents/select", index: 0 });
2949
+ }
2950
+ return;
2951
+ }
2952
+ }
2953
+
2954
+ if (state.focusMode === "dashboard"
2955
+ && state.dashboardView === "agents"
2956
+ && input
2957
+ && !(key && key.ctrl)
2958
+ && !(key && key.meta)
2959
+ && !/^[\x00-\x1f\x7f]+$/.test(input)
2960
+ && !input.includes("\n")
2961
+ && !input.includes("\r")) {
2962
+ if (displayAgents.length > 0 && state.selectedAgentIndex < 0) {
2963
+ dispatch({ type: "agents/select", index: 0 });
2964
+ }
2965
+ dispatch({ type: "focus/set", mode: "input" });
2966
+ dispatch({ type: "draft/set", value: `${state.draft || ""}${input}` });
2967
+ setDraftVersion((v) => v + 1);
2968
+ return;
2969
+ }
2970
+
2971
+ // Dashboard focus on agents/mode/provider/cron — ↑↓ flip between
2972
+ // sibling views, ←/→ pick within the active view, Esc returns to
2973
+ // the input.
2974
+ if (state.focusMode === "dashboard"
2975
+ && (state.dashboardView === "agents"
2976
+ || state.dashboardView === "mode"
2977
+ || state.dashboardView === "provider"
2978
+ || state.dashboardView === "cron")) {
2979
+ if (key.escape) {
2980
+ if (state.dashboardView === "agents") dispatch({ type: "agents/clearTarget" });
2981
+ dispatch({ type: "focus/set", mode: "input" });
2982
+ return;
2983
+ }
2984
+ if (state.dashboardView === "agents") {
2985
+ if (key.leftArrow || key.rightArrow) {
2986
+ if (displayAgents.length > 0) {
2987
+ const cur = state.selectedAgentIndex < 0 ? 0 : state.selectedAgentIndex;
2988
+ const next = key.leftArrow
2989
+ ? Math.max(0, cur - 1)
2990
+ : Math.min(displayAgents.length - 1, cur + 1);
2991
+ dispatch({ type: "agents/select", index: next });
2992
+ }
2993
+ return;
2994
+ }
2995
+ if (key.ctrl && input === "x") {
2996
+ if (state.selectedAgentIndex >= 0 && state.selectedAgentIndex < displayAgents.length) {
2997
+ const agentId = displayAgents[state.selectedAgentIndex];
2998
+ try {
2999
+ const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
3000
+ if (props.daemonConnection && typeof props.daemonConnection.send === "function") {
3001
+ props.daemonConnection.send({ type: IPC_REQUEST_TYPES.CLOSE_AGENT, agent_id: agentId });
3002
+ }
3003
+ } catch (err) {
3004
+ dispatch({ type: "log/append", text: `Error: ${err && err.message ? err.message : err}` });
3005
+ }
3006
+ dispatch({ type: "agents/clearTarget" });
3007
+ dispatch({ type: "focus/set", mode: "input" });
3008
+ }
3009
+ return;
3010
+ }
3011
+ if (key.return) {
3012
+ dispatch({ type: "focus/set", mode: "input" });
3013
+ return;
3014
+ }
3015
+ if (key.downArrow) {
3016
+ dispatch({ type: "view/set", view: "mode" });
3017
+ const launchModeIndex = state.modeOptions.indexOf(state.settings.launchMode);
3018
+ dispatch({ type: "modeIndex/set", index: launchModeIndex >= 0 ? launchModeIndex : 0 });
3019
+ return;
3020
+ }
3021
+ if (key.upArrow) {
3022
+ // Top of the agents tier: in global mode go back to projects,
3023
+ // otherwise leave dashboard focus altogether.
3024
+ dispatch({ type: "agents/clearTarget" });
3025
+ if (props.globalMode) dispatch({ type: "view/set", view: "projects" });
3026
+ else dispatch({ type: "focus/set", mode: "input" });
3027
+ return;
3028
+ }
3029
+ }
3030
+ if (state.dashboardView === "mode") {
3031
+ if (key.leftArrow || key.rightArrow) {
3032
+ const len = state.modeOptions.length;
3033
+ if (len > 0) {
3034
+ const cur = state.selectedModeIndex;
3035
+ const next = key.leftArrow
3036
+ ? Math.max(0, cur - 1)
3037
+ : Math.min(len - 1, cur + 1);
3038
+ if (next !== cur) dispatch({ type: "modeIndex/set", index: next });
3039
+ }
3040
+ return;
3041
+ }
3042
+ if (key.downArrow) {
3043
+ dispatch({ type: "view/set", view: "provider" });
3044
+ const providerIndex = state.providerOptions.findIndex((opt) => opt.value === state.settings.agentProvider);
3045
+ dispatch({ type: "providerIndex/set", index: providerIndex >= 0 ? providerIndex : 0 });
3046
+ return;
3047
+ }
3048
+ if (key.upArrow) { dispatch({ type: "view/set", view: "agents" }); return; }
3049
+ if (key.return) { applySelectedMode(); return; }
3050
+ }
3051
+ if (state.dashboardView === "provider") {
3052
+ if (key.leftArrow || key.rightArrow) {
3053
+ const len = state.providerOptions.length;
3054
+ if (len > 0) {
3055
+ const cur = state.selectedProviderIndex;
3056
+ const next = key.leftArrow
3057
+ ? Math.max(0, cur - 1)
3058
+ : Math.min(len - 1, cur + 1);
3059
+ if (next !== cur) dispatch({ type: "providerIndex/set", index: next });
3060
+ }
3061
+ return;
3062
+ }
3063
+ if (key.downArrow) {
3064
+ dispatch({ type: "view/set", view: "cron" });
3065
+ dispatch({ type: "cronIndex/set", index: state.cronTasks.length > 0 ? 0 : -1 });
3066
+ return;
3067
+ }
3068
+ if (key.upArrow) { dispatch({ type: "view/set", view: "mode" }); return; }
3069
+ if (key.return) { applySelectedProvider(); return; }
3070
+ }
3071
+ if (state.dashboardView === "cron") {
3072
+ if (key.leftArrow || key.rightArrow) {
3073
+ const len = state.cronTasks.length;
3074
+ if (len > 0) {
3075
+ const cur = state.selectedCronIndex < 0 ? 0 : state.selectedCronIndex;
3076
+ const next = key.leftArrow ? Math.max(0, cur - 1) : Math.min(len - 1, cur + 1);
3077
+ if (next !== cur) dispatch({ type: "cronIndex/set", index: next });
3078
+ }
3079
+ return;
3080
+ }
3081
+ if (key.downArrow) {
3082
+ // Cron is the last tier — don't wrap back to agents.
3083
+ return;
3084
+ }
3085
+ if (key.upArrow) { dispatch({ type: "view/set", view: "provider" }); return; }
3086
+ if (key.ctrl && input === "x") {
3087
+ const maxIndex = state.cronTasks.length - 1;
3088
+ if (maxIndex >= 0 && state.selectedCronIndex >= 0 && state.selectedCronIndex <= maxIndex) {
3089
+ const task = state.cronTasks[state.selectedCronIndex];
3090
+ const id = task && task.id ? String(task.id).trim() : "";
3091
+ if (id) {
3092
+ sendCronStop(id);
3093
+ return;
3094
+ }
3095
+ }
3096
+ dispatch({ type: "focus/set", mode: "input" });
3097
+ return;
3098
+ }
3099
+ if (key.return) { dispatch({ type: "focus/set", mode: "input" }); return; }
3100
+ }
3101
+ }
3102
+
3103
+ // Multi-window typing handler: replicates MultilineInput's key handling
3104
+ // so both modes share the same input behavior.
3105
+ if (multiWindowActive && state.focusMode !== "dashboard") {
3106
+ const intercepted = completionsOpen && (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow || key.return);
3107
+ if (intercepted) return;
3108
+ if (key.return) {
3109
+ if (key.meta) {
3110
+ const before = (state.draft || "").slice(0, mwCursor);
3111
+ const after = (state.draft || "").slice(mwCursor);
3112
+ dispatch({ type: "draft/set", value: `${before}\n${after}` });
3113
+ setMwCursor(mwCursor + 1);
3114
+ return;
3115
+ }
3116
+ const value = String(state.draft || "").trim();
3117
+ if (value) { submit(value); setMwCursor(0); }
3118
+ return;
3119
+ }
3120
+ if (key.escape) {
3121
+ if (state.agentSelectionMode) { dispatch({ type: "agents/clearTarget" }); return; }
3122
+ if (state.draft) { dispatch({ type: "draft/clear" }); setMwCursor(0); }
3123
+ else if (state.status && state.status.message) { dispatch({ type: "status/idle" }); }
3124
+ return;
3125
+ }
3126
+ if (key.ctrl) {
3127
+ if (input === "a") { setMwCursor(fmt.moveCursorToVisualLineBoundary({ cursorPos: mwCursor, inputValue: state.draft || "", width: inputWidth, boundary: "start" })); return; }
3128
+ if (input === "e") { setMwCursor(fmt.moveCursorToVisualLineBoundary({ cursorPos: mwCursor, inputValue: state.draft || "", width: inputWidth, boundary: "end" })); return; }
3129
+ if (input === "b") { setMwCursor(fmt.moveCursorHorizontally(mwCursor, state.draft || "", "left")); return; }
3130
+ if (input === "f") { setMwCursor(fmt.moveCursorHorizontally(mwCursor, state.draft || "", "right")); return; }
3131
+ if (input === "d") { const d = state.draft || ""; if (mwCursor < d.length) { dispatch({ type: "draft/set", value: d.slice(0, mwCursor) + d.slice(mwCursor + 1) }); } return; }
3132
+ if (input === "h") { const d = state.draft || ""; if (mwCursor > 0) { dispatch({ type: "draft/set", value: d.slice(0, mwCursor - 1) + d.slice(mwCursor) }); setMwCursor(mwCursor - 1); } return; }
3133
+ if (input === "k") { dispatch({ type: "draft/set", value: (state.draft || "").slice(0, mwCursor) }); return; }
3134
+ if (input === "u") { dispatch({ type: "draft/set", value: (state.draft || "").slice(mwCursor) }); setMwCursor(0); return; }
3135
+ if (input === "w") { const r = fmt.deleteWordBeforeCursor(state.draft || "", mwCursor); dispatch({ type: "draft/set", value: r.value }); setMwCursor(r.cursorPos); return; }
3136
+ return;
3137
+ }
3138
+ if (key.meta) {
3139
+ if (input === "b") { setMwCursor(fmt.moveCursorByWord(state.draft || "", mwCursor, "backward")); return; }
3140
+ if (input === "f") { setMwCursor(fmt.moveCursorByWord(state.draft || "", mwCursor, "forward")); return; }
3141
+ if (input === "d") { const end = fmt.moveCursorByWord(state.draft || "", mwCursor, "forward"); const d = state.draft || ""; dispatch({ type: "draft/set", value: d.slice(0, mwCursor) + d.slice(end) }); return; }
3142
+ }
3143
+ if (key.backspace || key.delete) {
3144
+ const d = state.draft || "";
3145
+ if (key.meta || key.ctrl) { const r = fmt.deleteWordBeforeCursor(d, mwCursor); dispatch({ type: "draft/set", value: r.value }); setMwCursor(r.cursorPos); }
3146
+ else if (mwCursor > 0) { dispatch({ type: "draft/set", value: d.slice(0, mwCursor - 1) + d.slice(mwCursor) }); setMwCursor(mwCursor - 1); }
3147
+ return;
3148
+ }
3149
+ if (key.leftArrow) {
3150
+ if (!state.draft && typeof onArrowSideAtEmpty === "function") { onArrowSideAtEmpty("left"); return; }
3151
+ setMwCursor(fmt.moveCursorHorizontally(mwCursor, state.draft || "", "left"));
3152
+ return;
3153
+ }
3154
+ if (key.rightArrow) {
3155
+ if (!state.draft && typeof onArrowSideAtEmpty === "function") { onArrowSideAtEmpty("right"); return; }
3156
+ setMwCursor(fmt.moveCursorHorizontally(mwCursor, state.draft || "", "right"));
3157
+ return;
3158
+ }
3159
+ if (key.upArrow) { onArrowUpAtTop(); return; }
3160
+ if (key.downArrow) { onArrowDownAtBottom(state.draft); return; }
3161
+ if (input && !key.ctrl && !key.meta) {
3162
+ const filtered = input.replace(/[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]/g, "");
3163
+ if (filtered) {
3164
+ const d = state.draft || "";
3165
+ dispatch({ type: "draft/set", value: d.slice(0, mwCursor) + filtered + d.slice(mwCursor) });
3166
+ setMwCursor(mwCursor + filtered.length);
3167
+ }
3168
+ return;
3169
+ }
3170
+ return;
3171
+ }
3172
+ }, { isActive: interactive });
3173
+
3174
+ const statusText = computeStatusText(state.status, spinnerTick);
3175
+ const inputWidth = Math.max(20, (size.cols || 80) - 4);
3176
+ const promptPrefix = (() => {
3177
+ const projectPrefix = inCommittedProjectScope && currentProjectLabel ? `${currentProjectLabel} ` : "";
3178
+ const visibleTargetAgentLabel = state.focusMode === "dashboard" && state.dashboardView !== "agents"
3179
+ ? ""
3180
+ : targetAgentLabel;
3181
+ if (visibleTargetAgentLabel) return `${projectPrefix}›@${visibleTargetAgentLabel} `;
3182
+ return `${projectPrefix}› `;
3183
+ })();
3184
+
3185
+ if (multiWindowActive) {
3186
+ const { renderDashboardLines } = require("./DashboardBar");
3187
+ const clampedCursor = fmt.clampCursorPos(mwCursor, state.draft || "");
3188
+ multiWindowChromeRef.current = {
3189
+ statusText,
3190
+ promptPrefix,
3191
+ draft: state.draft || "",
3192
+ cursor: clampedCursor,
3193
+ completions: completionsOpen ? completions : [],
3194
+ completionIndex: completionsOpen ? completionIndex : -1,
3195
+ completionWindowStart: completionsOpen ? completionWindowStart : 0,
3196
+ completionPageSize: POPUP_PAGE_SIZE,
3197
+ dashboardLines: renderDashboardLines({
3198
+ dashboardView: state.dashboardView,
3199
+ focusMode: state.focusMode,
3200
+ globalMode: state.globalMode,
3201
+ globalScope: state.globalScope,
3202
+ activeAgents: displayAgents,
3203
+ activeAgentMeta: displayAgentMeta,
3204
+ activeAgentId: targetAgentId || "",
3205
+ selectedAgentIndex: state.selectedAgentIndex,
3206
+ agentListWindowStart: state.agentListWindowStart,
3207
+ projectListWindowStart: state.projectListWindowStart,
3208
+ maxProjectWindow: 5,
3209
+ maxWidth: Math.max(20, size.cols || 80),
3210
+ getAgentLabel: (id) => getAgentLabelFor(displayAgentMeta.get(id), id),
3211
+ getAgentState: (id) => {
3212
+ const meta = displayAgentMeta.get(id);
3213
+ return meta && typeof meta.activity_state === "string" ? meta.activity_state : "";
3214
+ },
3215
+ launchMode: state.settings.launchMode,
3216
+ agentProvider: state.settings.agentProvider,
3217
+ modeOptions: state.modeOptions,
3218
+ selectedModeIndex: state.selectedModeIndex,
3219
+ providerOptions: state.providerOptions,
3220
+ selectedProviderIndex: state.selectedProviderIndex,
3221
+ cronTasks: state.cronTasks,
3222
+ selectedCronIndex: state.selectedCronIndex,
3223
+ projects: state.projects,
3224
+ selectedProjectIndex: state.selectedProjectIndex,
3225
+ activeProjectRoot: currentProjectRoot,
3226
+ dashHints: buildDashHints(state, targetAgentLabel),
3227
+ }),
3228
+ };
3229
+ }
3230
+
3231
+ useEffect(() => {
3232
+ if (!multiWindowActive) return;
3233
+ const controller = multiWindowControllerRef.current;
3234
+ if (controller && typeof controller.renderAll === "function") {
3235
+ controller.renderAll();
3236
+ }
3237
+ }, [multiWindowActive, completionsOpen, completions.length, completionIndex, completionWindowStart]);
3238
+
3239
+ if (multiWindowActive) {
3240
+ return null;
3241
+ }
3242
+
3243
+ const renderChatLogLine = (item) => {
3244
+ const row = classifyChatLogLine((item && item.text) || "");
3245
+ const key = item && item.id ? item.id : `log-${row.body}`;
3246
+ if (row.kind === "spacer") {
3247
+ return h(Text, { key, color: "gray" }, " ");
3248
+ }
3249
+ const palette = {
3250
+ assistant: { marker: "cyan", speaker: "white", body: undefined, bold: true },
3251
+ agent: { marker: "cyan", speaker: "cyan", body: undefined, bold: false },
3252
+ error: { marker: "red", speaker: "red", body: "red", bold: true },
3253
+ success: { marker: "green", speaker: "green", body: "green", bold: false },
3254
+ divider: { marker: "gray", speaker: "gray", body: "gray", bold: false },
3255
+ banner: { marker: "cyan", speaker: "cyan", body: "cyan", bold: true },
3256
+ meta: { marker: "gray", speaker: "gray", body: "gray", bold: false },
3257
+ plain: { marker: "gray", speaker: "gray", body: undefined, bold: false },
3258
+ };
3259
+ const colors = palette[row.kind] || palette.plain;
3260
+ if (row.kind === "divider") {
3261
+ return h(Box, { key, marginBottom: 1 },
3262
+ h(Text, { color: colors.body, wrap: "truncate" }, row.body),
3263
+ );
3264
+ }
3265
+ if (row.kind === "banner") {
3266
+ return h(Box, { key, marginBottom: 1 },
3267
+ h(Text, { color: colors.body, bold: true, wrap: "truncate" }, row.body),
3268
+ );
3269
+ }
3270
+ return h(Box, { key, width: "100%", marginBottom: 1 },
3271
+ h(Box, { width: 2 },
3272
+ h(Text, { color: colors.marker, bold: row.kind === "error" }, row.marker || " "),
3273
+ ),
3274
+ row.speaker
3275
+ ? h(Text, { color: colors.speaker, bold: colors.bold }, row.speaker)
3276
+ : null,
3277
+ row.speaker
3278
+ ? h(Text, { color: "gray" }, " · ")
3279
+ : null,
3280
+ h(Text, { color: colors.body, wrap: "wrap" }, row.body || " "),
3281
+ );
3282
+ };
3283
+
3284
+ if (state.viewingAgentId) {
3285
+ const maxWidth = Math.max(20, size.cols || 80);
3286
+ const logRows = Math.max(1, (size.rows || 24) - 5);
3287
+ const visibleRows = buildInternalLogRows(internalAgentView.lines || [], maxWidth, logRows);
3288
+ const status = internalStatusLabel(internalAgentView.status);
3289
+ const internalStatusText = computeInternalStatusText(internalAgentView, spinnerTick);
3290
+ const internalStatusColor = status === "blocked" ? "red" : (status === "ready" ? "gray" : "cyan");
3291
+ const inputText = String(internalAgentView.input || "");
3292
+ const cursor = Math.max(0, Math.min(inputText.length, Number(internalAgentView.cursor) || 0));
3293
+ const beforeCursor = inputText.slice(0, cursor);
3294
+ const cursorChar = inputText.slice(cursor, nextInternalBoundary(inputText, cursor)) || " ";
3295
+ const afterCursor = inputText.slice(cursor + (cursorChar === " " ? 0 : cursorChar.length));
3296
+ const barFocused = state.focusMode === "dashboard";
3297
+ const barIndex = Math.max(
3298
+ 0,
3299
+ Math.min(displayAgents.length, Number(internalAgentView.barIndex) || 0),
3300
+ );
3301
+ const barHint = barFocused ? "│ ←/→ · Enter · ↑ · ^X" : "│ ↓ agents";
3302
+ const barItem = (text, index, options = {}) => {
3303
+ const keyboardSelected = barFocused && barIndex === index;
3304
+ return h(Text, {
3305
+ key: `agent-bar-${index}-${text}`,
3306
+ color: keyboardSelected || options.current === true ? undefined : "cyan",
3307
+ inverse: keyboardSelected,
3308
+ bold: options.current === true,
3309
+ wrap: "truncate",
3310
+ }, text);
3311
+ };
3312
+ const agentBarChildren = displayAgents.length === 0
3313
+ ? [h(Text, { key: "agent-bar-none", color: "cyan", wrap: "truncate" }, "none")]
3314
+ : displayAgents.flatMap((id, idx) => {
3315
+ const meta = displayAgentMeta.get(id);
3316
+ return [
3317
+ idx > 0 ? h(Text, { key: `agent-bar-space-${id}`, color: "gray", wrap: "truncate" }, " ") : null,
3318
+ barItem(getAgentLabelFor(meta, id), idx + 1, {
3319
+ current: isInternalViewingAgent(id, meta, internalAgentView, state.viewingAgentId),
3320
+ }),
3321
+ ];
3322
+ }).filter(Boolean);
3323
+ return h(Box, { flexDirection: "column", width: "100%" },
3324
+ h(Box, { flexDirection: "column", width: "100%" },
3325
+ ...visibleRows.map((row, idx) => {
3326
+ const kind = row && row.kind ? row.kind : "agent";
3327
+ const color = kind === "user"
3328
+ ? "cyan"
3329
+ : (kind === "system" || kind === "meta" || kind === "spacer" ? "gray" : (kind === "error" ? "red" : undefined));
3330
+ return h(Text, {
3331
+ key: `agent-log-${idx}`,
3332
+ color,
3333
+ bold: Boolean(row && row.bold),
3334
+ wrap: "truncate",
3335
+ }, (row && row.text) || " ");
3336
+ }),
3337
+ ),
3338
+ h(Text, { color: internalStatusColor, wrap: "truncate" },
3339
+ fitPlainLine(internalStatusText, maxWidth)),
3340
+ h(Text, { color: "gray", wrap: "truncate" }, "─".repeat(maxWidth)),
3341
+ h(Box, { width: "100%" },
3342
+ h(Text, { color: "magenta" }, "› "),
3343
+ beforeCursor ? h(Text, { wrap: "truncate" }, beforeCursor) : null,
3344
+ h(Text, { inverse: true }, cursorChar),
3345
+ afterCursor ? h(Text, { wrap: "truncate" }, afterCursor) : null,
3346
+ ),
3347
+ h(Text, { color: "gray", wrap: "truncate" }, "─".repeat(maxWidth)),
3348
+ h(Box, { width: "100%" },
3349
+ h(Text, { color: "gray", wrap: "truncate" }, " "),
3350
+ barItem("ufoo", 0),
3351
+ h(Text, { color: "gray", wrap: "truncate" }, " "),
3352
+ ...agentBarChildren,
3353
+ h(Text, { color: "gray", wrap: "truncate" }, ` ${barHint}`),
3354
+ ),
3355
+ );
3356
+ }
3357
+
3358
+ return h(Box, { flexDirection: "column", width: "100%" },
3359
+ h(Box, { flexDirection: "column", width: "100%" },
3360
+ ...state.logLines.map(renderChatLogLine),
3361
+ ),
3362
+ state.activeMerge ? h(Box, null,
3363
+ h(Text, { color: state.activeMerge.entries.some((e) => e.isError) ? "red" : "cyan" },
3364
+ fmt.buildToolMergeRowText(state.activeMerge.entries)),
3365
+ ) : null,
3366
+ state.activeStream ? h(Box, { flexDirection: "column" },
3367
+ ...(() => {
3368
+ const lines = String(state.activeStream.text || "").split(/\r?\n/);
3369
+ const prefix = state.activeStream.publisher
3370
+ ? `${state.activeStream.publisher}: `
3371
+ : "";
3372
+ return lines.map((line, idx) => renderChatLogLine({
3373
+ id: `s-${idx}`,
3374
+ text: idx === 0 ? `${prefix}${line}` : ` ${line}`,
3375
+ }));
3376
+ })(),
3377
+ ) : null,
3378
+ h(Box, { marginTop: 1, width: "100%" },
3379
+ h(Text, { color: "gray" }, statusText),
3380
+ h(Box, { flexGrow: 1 }),
3381
+ h(Text, { color: "gray" }, `v${fmt.UCODE_VERSION}`),
3382
+ ),
3383
+ completionsOpen ? (() => {
3384
+ const start = Math.min(completionWindowStart, Math.max(0, completions.length - POPUP_PAGE_SIZE));
3385
+ const end = Math.min(completions.length, start + POPUP_PAGE_SIZE);
3386
+ const visible = completions.slice(start, end);
3387
+ return h(Box, { flexDirection: "column" },
3388
+ h(Text, { color: "gray" }, "─".repeat(Math.max(8, size.cols || 80))),
3389
+ ...visible.map((s, idxInWindow) => {
3390
+ const idx = start + idxInWindow;
3391
+ return h(Box, { key: `cmp-${idx}` },
3392
+ h(Text, { color: idx === completionIndex ? "cyan" : "gray", inverse: idx === completionIndex }, s.label),
3393
+ s.description ? h(Text, { color: "gray" }, ` ${s.description}`) : null,
3394
+ );
3395
+ }),
3396
+ );
3397
+ })() : null,
3398
+ h(Box, { width: "100%" },
3399
+ h(MultilineInput, {
3400
+ value: state.draft,
3401
+ valueVersion: draftVersion,
3402
+ onChange: (next) => {
3403
+ if (completionSuppressedDraft !== null && next !== completionSuppressedDraft) {
3404
+ setCompletionSuppressedDraft(null);
3405
+ }
3406
+ dispatch({ type: "draft/set", value: next });
3407
+ },
3408
+ onSubmit: (value) => {
3409
+ setCompletionSuppressedDraft(null);
3410
+ submit(value);
3411
+ },
3412
+ onCancel: () => {
3413
+ setCompletionSuppressedDraft(null);
3414
+ if (props.globalMode && state.globalScope === "project") {
3415
+ void switchToControllerRoot();
3416
+ return;
3417
+ }
3418
+ // Esc clears the current target if one is locked, otherwise
3419
+ // dismisses the in-flight task status. There's no per-request
3420
+ // AbortController on daemonConnection (the IPC layer is fire-
3421
+ // and-forget), so we clear the spinner so the user knows the
3422
+ // UI is responsive again.
3423
+ if (state.agentSelectionMode) {
3424
+ dispatch({ type: "agents/clearTarget" });
3425
+ return;
3426
+ }
3427
+ if (state.status && state.status.message) {
3428
+ dispatch({ type: "status/idle" });
3429
+ }
3430
+ },
3431
+ onArrowUpAtTop,
3432
+ onArrowDownAtBottom,
3433
+ onArrowLeftAtEmpty: () => onArrowSideAtEmpty("left"),
3434
+ onArrowRightAtEmpty: () => onArrowSideAtEmpty("right"),
3435
+ width: inputWidth,
3436
+ interactive: interactive && state.focusMode !== "dashboard",
3437
+ interceptArrowsAndEnter: completionsOpen,
3438
+ placeholder: "",
3439
+ promptPrefix,
3440
+ // Dashboard renders 2 rows in global mode (always shows the
3441
+ // projects rail) or when an agents/mode/provider/cron view is
3442
+ // focused; otherwise it's a single summary row. Telling
3443
+ // MultilineInput how many UI rows live below it lets the IME
3444
+ // composition popup follow the on-screen caret instead of
3445
+ // appearing at the bottom-right of the terminal.
3446
+ linesBelowInput: props.globalMode
3447
+ ? 2
3448
+ : (state.focusMode === "dashboard" ? 2 : 1),
3449
+ }),
3450
+ ),
3451
+ h(DashboardBar, {
3452
+ dashboardView: state.dashboardView,
3453
+ focusMode: state.focusMode,
3454
+ globalMode: state.globalMode,
3455
+ globalScope: state.globalScope,
3456
+ activeAgents: displayAgents,
3457
+ activeAgentMeta: displayAgentMeta,
3458
+ activeAgentId: targetAgentId || "",
3459
+ selectedAgentIndex: state.selectedAgentIndex,
3460
+ agentListWindowStart: state.agentListWindowStart,
3461
+ projectListWindowStart: state.projectListWindowStart,
3462
+ maxProjectWindow: 5,
3463
+ maxWidth: Math.max(20, size.cols || 80),
3464
+ getAgentLabel: (id) => getAgentLabelFor(displayAgentMeta.get(id), id),
3465
+ getAgentState: (id) => {
3466
+ const meta = displayAgentMeta.get(id);
3467
+ return meta && typeof meta.activity_state === "string" ? meta.activity_state : "";
3468
+ },
3469
+ launchMode: state.settings.launchMode,
3470
+ agentProvider: state.settings.agentProvider,
3471
+ modeOptions: state.modeOptions,
3472
+ selectedModeIndex: state.selectedModeIndex,
3473
+ providerOptions: state.providerOptions,
3474
+ selectedProviderIndex: state.selectedProviderIndex,
3475
+ cronTasks: state.cronTasks,
3476
+ selectedCronIndex: state.selectedCronIndex,
3477
+ projects: state.projects,
3478
+ selectedProjectIndex: state.selectedProjectIndex,
3479
+ activeProjectRoot: currentProjectRoot,
3480
+ dashHints: buildDashHints(state, targetAgentLabel),
3481
+ }),
3482
+ );
3483
+ };
3484
+ }
3485
+
3486
+ function buildDashHints(state, targetAgentLabel) {
3487
+ void targetAgentLabel; // navigation hint removed by request
3488
+ return {
3489
+ agents: "←/→ select · Enter · ↓ mode · ↑ back",
3490
+ agentsGlobal: "←/→ select · Enter · ↓ mode · ↑ projects",
3491
+ agentsEmpty: "↓ mode · ↑ back",
3492
+ mode: "←/→ select · Enter · ↓ provider · ↑ back",
3493
+ provider: "←/→ select · Enter · ↓ cron · ↑ back",
3494
+ cron: "←/→ switch · Ctrl+X stop · ↑ back",
3495
+ projects: "Use /open <path> or /project switch <index|path>",
3496
+ projectsFocus: "←/→ switch · Ctrl+X close · ↓ second row · Enter confirm · ↑ back",
3497
+ projectsEmpty: "Run ufoo chat or ufoo daemon start in project directories",
3498
+ };
3499
+ }
3500
+
3501
+ function computeStatusText(status, spinnerTick) {
3502
+ const message = String((status && status.message) || "");
3503
+ if (!message) return "CHAT · Ready";
3504
+ const type = String((status && status.type) || "thinking");
3505
+ if (type === "done" || type === "success") {
3506
+ const clean = stripBlessedTags(message).trim();
3507
+ return /^[✓✔]/.test(clean) ? clean : `✓ ${clean}`;
3508
+ }
3509
+ if (type === "error") {
3510
+ const clean = stripBlessedTags(message).trim();
3511
+ return /^[✗!]/.test(clean) ? clean : `✗ ${clean}`;
3512
+ }
3513
+ if (!isAnimatedStatusType(type)) return stripBlessedTags(message).trim() || "CHAT · Ready";
3514
+ const indicators = fmt.STATUS_INDICATORS[type] || fmt.STATUS_INDICATORS.thinking;
3515
+ const indicator = indicators[Math.max(0, Math.floor(Number(spinnerTick) || 0)) % indicators.length];
3516
+ const startedAt = Number.isFinite(status && status.startedAt) ? status.startedAt : 0;
3517
+ const timerText = status && status.showTimer && startedAt
3518
+ ? ` (${fmt.formatPendingElapsed(Date.now() - startedAt)}, esc cancel)`
3519
+ : "";
3520
+ return `${indicator} ${message}${timerText}`;
3521
+ }
3522
+
3523
+ async function runChatInk(projectRoot, options = {}) {
3524
+ const env = bootstrapEnvironment(projectRoot, options);
3525
+
3526
+ if (env.needsBootstrap || !fs.existsSync(env.runtimePaths.ufooDir)) {
3527
+ const repoRoot = path.join(__dirname, "..", "..", "..");
3528
+ const init = new env.UfooInit(repoRoot);
3529
+ await init.init({
3530
+ modules: "context,bus",
3531
+ project: projectRoot,
3532
+ controllerMode: env.globalMode,
3533
+ });
3534
+ }
3535
+
3536
+ await ensureSubscriberId(projectRoot);
3537
+
3538
+ if (!env.isRunning(projectRoot)) {
3539
+ env.startDaemon(projectRoot);
3540
+ }
3541
+
3542
+ const { socketPath } = require("../../runtime/daemon");
3543
+ const { connectWithRetry } = require("../../app/chat/transport");
3544
+ const { createDaemonTransport } = require("../../app/chat/daemonTransport");
3545
+ const { createDaemonConnection } = require("../../app/chat/daemonConnection");
3546
+ const { createDaemonCoordinator } = require("../../app/chat/daemonCoordinator");
3547
+ const { startDaemon, stopDaemon } = require("../../app/chat/transport");
3548
+ const { loadConfig } = require("../../config");
3549
+ const { startAgentMirror, startInternalAgentMirror } = require("./agentMirror");
3550
+ const sock = socketPath(projectRoot);
3551
+ const daemonTransport = createDaemonTransport({
3552
+ projectRoot,
3553
+ sockPath: sock,
3554
+ isRunning: env.isRunning,
3555
+ startDaemon: env.startDaemon,
3556
+ connectWithRetry,
3557
+ });
3558
+
3559
+ // The connection's `handleMessage` callback is filled in by ChatApp once
3560
+ // it mounts and has its dispatcher ready. We expose a setter so the
3561
+ // component can wire it without ChatApp needing to construct daemon
3562
+ // internals itself.
3563
+ let routedMessageHandler = () => {};
3564
+ const daemonConnection = createDaemonConnection({
3565
+ connectClient: daemonTransport.connectClient.bind(daemonTransport),
3566
+ handleMessage: (msg) => routedMessageHandler(msg),
3567
+ queueStatusLine: () => {},
3568
+ resolveStatusLine: () => {},
3569
+ logMessage: () => {},
3570
+ });
3571
+ const daemonCoordinator = createDaemonCoordinator({
3572
+ projectRoot,
3573
+ daemonTransport,
3574
+ daemonConnection,
3575
+ stopDaemon,
3576
+ startDaemon,
3577
+ logMessage: () => {},
3578
+ queueStatusLine: () => {},
3579
+ resolveStatusLine: () => {},
3580
+ });
3581
+
3582
+ // We loop the ink mount so an "enter agent" request can unmount ink,
3583
+ // hand stdout/stdin to the raw PTY mirror, then bring ink back on exit.
3584
+ let pendingEnter = null;
3585
+ const baseProps = {
3586
+ activeProjectRoot: env.activeProjectRoot,
3587
+ projectRoot,
3588
+ globalMode: env.globalMode,
3589
+ globalScope: env.globalMode ? "controller" : "project",
3590
+ daemonConnection,
3591
+ daemonTransport,
3592
+ daemonCoordinator,
3593
+ env,
3594
+ initialSettings: loadConfig(projectRoot),
3595
+ setDaemonMessageHandler: (fn) => { routedMessageHandler = typeof fn === "function" ? fn : () => {}; },
3596
+ requestEnterAgentView: (agentId, enterOptions = {}) => {
3597
+ pendingEnter = {
3598
+ agentId,
3599
+ options: enterOptions && typeof enterOptions === "object" ? enterOptions : {},
3600
+ };
3601
+ },
3602
+ };
3603
+
3604
+ // eslint-disable-next-line no-constant-condition
3605
+ while (true) {
3606
+ pendingEnter = null;
3607
+ const handle = await runInk(
3608
+ (React, ink) => {
3609
+ const ChatApp = createChatApp({ React, ink, props: baseProps });
3610
+ return React.createElement(ChatApp);
3611
+ },
3612
+ { stdin: process.stdin, stdout: process.stdout, exitOnCtrlC: true }
3613
+ );
3614
+
3615
+ // Wait until either the user exits the app or ChatApp asks to enter
3616
+ // an agent view. The component triggers the latter by setting
3617
+ // pendingEnter and then calling handle.unmount() via its onExit.
3618
+ await handle.waitUntilExit();
3619
+ if (!pendingEnter) return;
3620
+
3621
+ // Hand stdout/stdin to the mirror. When it exits, loop and re-mount.
3622
+ const enterRequest = pendingEnter;
3623
+ pendingEnter = null;
3624
+ const enteredAgentId = enterRequest && enterRequest.agentId;
3625
+ const enterOptions = enterRequest && enterRequest.options ? enterRequest.options : {};
3626
+ const enteredProjectRoot = enterOptions.projectRoot || projectRoot;
3627
+ await new Promise((resolve) => {
3628
+ if (enterOptions.useBus) {
3629
+ startInternalAgentMirror({
3630
+ agentId: enteredAgentId,
3631
+ agentLabel: enterOptions.agentLabel,
3632
+ agentAliases: enterOptions.agentAliases,
3633
+ projectRoot: enteredProjectRoot,
3634
+ daemonConnection,
3635
+ setDaemonMessageHandler: (fn) => {
3636
+ routedMessageHandler = typeof fn === "function" ? fn : () => {};
3637
+ },
3638
+ onExit: resolve,
3639
+ });
3640
+ return;
3641
+ }
3642
+ startAgentMirror({
3643
+ agentId: enteredAgentId,
3644
+ projectRoot: enteredProjectRoot,
3645
+ onExit: resolve,
3646
+ });
3647
+ });
3648
+ }
3649
+ }
3650
+
3651
+ module.exports = {
3652
+ runChatInk,
3653
+ createChatApp,
3654
+ bootstrapEnvironment,
3655
+ buildDirectBusSendRequest,
3656
+ buildPromptIpcRequest,
3657
+ chatHistoryOptionsForScope,
3658
+ classifyChatLogLine,
3659
+ createInkMultiWindowToggle,
3660
+ resolveActiveAgentId,
3661
+ resolveInjectSockPathForAgent,
3662
+ resolveAgentEnterRequest,
3663
+ resolveDashboardAgentEnterAction,
3664
+ buildEmptyProjectsDownActions,
3665
+ buildInternalLogRows,
3666
+ computeStatusText,
3667
+ computeInternalStatusText,
3668
+ inferStatusType,
3669
+ isAnimatedStatusType,
3670
+ resolveInternalKeyName,
3671
+ isInternalViewingAgent,
3672
+ applyInternalAgentTermWrite,
3673
+ appendInternalErrorToView,
3674
+ };