u-foo 1.0.3 → 1.1.9

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 (179) hide show
  1. package/README.md +110 -11
  2. package/README.zh-CN.md +9 -7
  3. package/SKILLS/ufoo/SKILL.md +132 -0
  4. package/SKILLS/uinit/SKILL.md +78 -0
  5. package/SKILLS/ustatus/SKILL.md +36 -0
  6. package/bin/uclaude.js +13 -0
  7. package/bin/ucode-core.js +15 -0
  8. package/bin/ucode.js +125 -0
  9. package/bin/ucodex.js +13 -0
  10. package/bin/ufoo +9 -31
  11. package/bin/ufoo-assistant-agent.js +5 -0
  12. package/bin/ufoo-engine.js +25 -0
  13. package/bin/ufoo.js +17 -0
  14. package/modules/AGENTS.template.md +29 -11
  15. package/modules/bus/README.md +33 -25
  16. package/modules/bus/SKILLS/ubus/SKILL.md +19 -8
  17. package/modules/context/README.md +18 -40
  18. package/modules/context/SKILLS/uctx/SKILL.md +63 -1
  19. package/modules/online/SKILLS/ufoo-online/SKILL.md +144 -0
  20. package/package.json +25 -4
  21. package/scripts/import-pi-mono.js +124 -0
  22. package/scripts/postinstall.js +30 -0
  23. package/scripts/sync-claude-skills.sh +21 -0
  24. package/src/agent/cliRunner.js +554 -33
  25. package/src/agent/internalRunner.js +150 -56
  26. package/src/agent/launcher.js +754 -0
  27. package/src/agent/normalizeOutput.js +1 -1
  28. package/src/agent/notifier.js +340 -0
  29. package/src/agent/ptyRunner.js +847 -0
  30. package/src/agent/ptyWrapper.js +379 -0
  31. package/src/agent/readyDetector.js +175 -0
  32. package/src/agent/ucode.js +443 -0
  33. package/src/agent/ucodeBootstrap.js +113 -0
  34. package/src/agent/ucodeBuild.js +67 -0
  35. package/src/agent/ucodeDoctor.js +184 -0
  36. package/src/agent/ucodeRuntimeConfig.js +129 -0
  37. package/src/agent/ufooAgent.js +46 -42
  38. package/src/assistant/agent.js +260 -0
  39. package/src/assistant/bridge.js +172 -0
  40. package/src/assistant/engine.js +252 -0
  41. package/src/assistant/stdio.js +58 -0
  42. package/src/assistant/ufooEngineCli.js +306 -0
  43. package/src/bus/activate.js +172 -0
  44. package/src/bus/daemon.js +436 -0
  45. package/src/bus/index.js +842 -0
  46. package/src/bus/inject.js +315 -0
  47. package/src/bus/message.js +430 -0
  48. package/src/bus/nickname.js +88 -0
  49. package/src/bus/queue.js +136 -0
  50. package/src/bus/shake.js +26 -0
  51. package/src/bus/store.js +189 -0
  52. package/src/bus/subscriber.js +312 -0
  53. package/src/bus/utils.js +363 -0
  54. package/src/chat/agentBar.js +117 -0
  55. package/src/chat/agentDirectory.js +88 -0
  56. package/src/chat/agentSockets.js +225 -0
  57. package/src/chat/agentViewController.js +298 -0
  58. package/src/chat/chatLogController.js +115 -0
  59. package/src/chat/commandExecutor.js +700 -0
  60. package/src/chat/commands.js +132 -0
  61. package/src/chat/completionController.js +414 -0
  62. package/src/chat/cronScheduler.js +160 -0
  63. package/src/chat/daemonConnection.js +166 -0
  64. package/src/chat/daemonCoordinator.js +64 -0
  65. package/src/chat/daemonMessageRouter.js +257 -0
  66. package/src/chat/daemonReconnect.js +41 -0
  67. package/src/chat/daemonTransport.js +36 -0
  68. package/src/chat/daemonTransportDefaults.js +10 -0
  69. package/src/chat/dashboardKeyController.js +480 -0
  70. package/src/chat/dashboardView.js +154 -0
  71. package/src/chat/index.js +1011 -1392
  72. package/src/chat/inputHistoryController.js +105 -0
  73. package/src/chat/inputListenerController.js +304 -0
  74. package/src/chat/inputMath.js +104 -0
  75. package/src/chat/inputSubmitHandler.js +171 -0
  76. package/src/chat/layout.js +165 -0
  77. package/src/chat/pasteController.js +81 -0
  78. package/src/chat/rawKeyMap.js +42 -0
  79. package/src/chat/settingsController.js +132 -0
  80. package/src/chat/statusLineController.js +177 -0
  81. package/src/chat/streamTracker.js +138 -0
  82. package/src/chat/text.js +70 -0
  83. package/src/chat/transport.js +61 -0
  84. package/src/cli/busCoreCommands.js +59 -0
  85. package/src/cli/ctxCoreCommands.js +199 -0
  86. package/src/cli/onlineCoreCommands.js +379 -0
  87. package/src/cli.js +1162 -96
  88. package/src/code/README.md +29 -0
  89. package/src/code/UCODE_PROMPT.md +32 -0
  90. package/src/code/agent.js +1651 -0
  91. package/src/code/cli.js +158 -0
  92. package/src/code/config +0 -0
  93. package/src/code/dispatch.js +42 -0
  94. package/src/code/index.js +70 -0
  95. package/src/code/nativeRunner.js +1213 -0
  96. package/src/code/runtime.js +154 -0
  97. package/src/code/sessionStore.js +162 -0
  98. package/src/code/taskDecomposer.js +269 -0
  99. package/src/code/tools/bash.js +53 -0
  100. package/src/code/tools/common.js +42 -0
  101. package/src/code/tools/edit.js +70 -0
  102. package/src/code/tools/read.js +44 -0
  103. package/src/code/tools/write.js +35 -0
  104. package/src/code/tui.js +1580 -0
  105. package/src/config.js +56 -3
  106. package/src/context/decisions.js +324 -0
  107. package/src/context/doctor.js +183 -0
  108. package/src/context/index.js +55 -0
  109. package/src/context/sync.js +127 -0
  110. package/src/daemon/agentProcessManager.js +74 -0
  111. package/src/daemon/cronOps.js +241 -0
  112. package/src/daemon/index.js +998 -170
  113. package/src/daemon/ipcServer.js +99 -0
  114. package/src/daemon/ops.js +630 -48
  115. package/src/daemon/promptLoop.js +319 -0
  116. package/src/daemon/promptRequest.js +101 -0
  117. package/src/daemon/providerSessions.js +306 -0
  118. package/src/daemon/reporting.js +90 -0
  119. package/src/daemon/run.js +31 -1
  120. package/src/daemon/status.js +48 -8
  121. package/src/doctor/index.js +50 -0
  122. package/src/init/index.js +318 -0
  123. package/src/online/bridge.js +663 -0
  124. package/src/online/client.js +245 -0
  125. package/src/online/runner.js +253 -0
  126. package/src/online/server.js +992 -0
  127. package/src/online/tokens.js +103 -0
  128. package/src/report/store.js +331 -0
  129. package/src/shared/eventContract.js +35 -0
  130. package/src/shared/ptySocketContract.js +21 -0
  131. package/src/skills/index.js +159 -0
  132. package/src/status/index.js +285 -0
  133. package/src/terminal/adapterContract.js +87 -0
  134. package/src/terminal/adapterRouter.js +84 -0
  135. package/src/terminal/adapters/externalAdapter.js +14 -0
  136. package/src/terminal/adapters/internalAdapter.js +13 -0
  137. package/src/terminal/adapters/internalPtyAdapter.js +42 -0
  138. package/src/terminal/adapters/internalQueueAdapter.js +37 -0
  139. package/src/terminal/adapters/terminalAdapter.js +31 -0
  140. package/src/terminal/adapters/tmuxAdapter.js +30 -0
  141. package/src/terminal/detect.js +64 -0
  142. package/src/terminal/index.js +8 -0
  143. package/src/terminal/iterm2.js +126 -0
  144. package/src/ufoo/agentsStore.js +107 -0
  145. package/src/ufoo/paths.js +46 -0
  146. package/src/utils/banner.js +76 -0
  147. package/bin/uclaude +0 -65
  148. package/bin/ucodex +0 -65
  149. package/modules/bus/scripts/bus-alert.sh +0 -185
  150. package/modules/bus/scripts/bus-listen.sh +0 -117
  151. package/modules/context/ASSUMPTIONS.md +0 -7
  152. package/modules/context/CONSTRAINTS.md +0 -7
  153. package/modules/context/CONTEXT-STRUCTURE.md +0 -49
  154. package/modules/context/DECISION-PROTOCOL.md +0 -62
  155. package/modules/context/HANDOFF.md +0 -33
  156. package/modules/context/RULES.md +0 -15
  157. package/modules/context/SKILLS/README.md +0 -14
  158. package/modules/context/SYSTEM.md +0 -18
  159. package/modules/context/TEMPLATES/assumptions.md +0 -4
  160. package/modules/context/TEMPLATES/constraints.md +0 -4
  161. package/modules/context/TEMPLATES/decision.md +0 -16
  162. package/modules/context/TEMPLATES/project-context-readme.md +0 -6
  163. package/modules/context/TEMPLATES/system.md +0 -3
  164. package/modules/context/TEMPLATES/terminology.md +0 -4
  165. package/modules/context/TERMINOLOGY.md +0 -10
  166. package/scripts/banner.sh +0 -89
  167. package/scripts/bus-alert.sh +0 -6
  168. package/scripts/bus-autotrigger.sh +0 -6
  169. package/scripts/bus-daemon.sh +0 -231
  170. package/scripts/bus-inject.sh +0 -144
  171. package/scripts/bus-listen.sh +0 -6
  172. package/scripts/bus.sh +0 -984
  173. package/scripts/context-decisions.sh +0 -167
  174. package/scripts/context-doctor.sh +0 -72
  175. package/scripts/context-lint.sh +0 -110
  176. package/scripts/doctor.sh +0 -22
  177. package/scripts/init.sh +0 -247
  178. package/scripts/skills.sh +0 -113
  179. package/scripts/status.sh +0 -125
package/src/chat/index.js CHANGED
@@ -1,534 +1,269 @@
1
- const net = require("net");
2
1
  const path = require("path");
3
2
  const blessed = require("blessed");
4
- const { spawn, spawnSync } = require("child_process");
3
+ const { execSync } = require("child_process");
5
4
  const fs = require("fs");
6
- const { loadConfig, saveConfig, normalizeLaunchMode, normalizeAgentProvider } = require("../config");
5
+ const {
6
+ loadConfig,
7
+ saveConfig,
8
+ normalizeLaunchMode,
9
+ normalizeAgentProvider,
10
+ normalizeAssistantEngine,
11
+ } = require("../config");
7
12
  const { socketPath, isRunning } = require("../daemon");
13
+ const UfooInit = require("../init");
14
+ const AgentActivator = require("../bus/activate");
15
+ const { subscriberToSafeName } = require("../bus/utils");
16
+ const { getUfooPaths } = require("../ufoo/paths");
17
+ const { startDaemon, stopDaemon, connectWithRetry } = require("./transport");
18
+ const { escapeBlessed, stripBlessedTags, truncateText } = require("./text");
19
+ const { COMMAND_REGISTRY, parseCommand, parseAtTarget } = require("./commands");
20
+ const inputMath = require("./inputMath");
21
+ const { createStreamTracker } = require("./streamTracker");
22
+ const agentDirectory = require("./agentDirectory");
23
+ const { computeAgentBar } = require("./agentBar");
24
+ const { createAgentSockets } = require("./agentSockets");
25
+ const { createDashboardKeyController } = require("./dashboardKeyController");
26
+ const { computeDashboardContent } = require("./dashboardView");
27
+ const { createCommandExecutor } = require("./commandExecutor");
28
+ const { createInputSubmitHandler } = require("./inputSubmitHandler");
29
+ const { keyToRaw } = require("./rawKeyMap");
30
+ const { createCompletionController } = require("./completionController");
31
+ const { createStatusLineController } = require("./statusLineController");
32
+ const { createInputHistoryController } = require("./inputHistoryController");
33
+ const { createInputListenerController } = require("./inputListenerController");
34
+ const { createDaemonMessageRouter } = require("./daemonMessageRouter");
35
+ const { createChatLogController } = require("./chatLogController");
36
+ const { createPasteController } = require("./pasteController");
37
+ const { createCronScheduler } = require("./cronScheduler");
38
+ const { createAgentViewController } = require("./agentViewController");
39
+ const { createSettingsController } = require("./settingsController");
40
+ const { createChatLayout } = require("./layout");
41
+ const { createDaemonCoordinator } = require("./daemonCoordinator");
42
+ const { IPC_REQUEST_TYPES } = require("../shared/eventContract");
43
+ const { createTerminalAdapterRouter } = require("../terminal/adapterRouter");
44
+ const { createDaemonTransport } = require("./daemonTransport");
8
45
 
9
- function connectSocket(sockPath) {
10
- return new Promise((resolve, reject) => {
11
- const client = net.createConnection(sockPath, () => resolve(client));
12
- client.on("error", reject);
13
- });
14
- }
15
-
16
- function resolveProjectFile(projectRoot, relativePath, fallbackRelativePath) {
17
- const local = path.join(projectRoot, relativePath);
18
- if (fs.existsSync(local)) return local;
19
- return path.join(__dirname, "..", "..", fallbackRelativePath);
20
- }
21
-
22
- function startDaemon(projectRoot) {
23
- const daemonBin = resolveProjectFile(projectRoot, path.join("bin", "ufoo.js"), path.join("bin", "ufoo.js"));
24
- const child = spawn(process.execPath, [daemonBin, "daemon", "--start"], {
25
- detached: true,
26
- stdio: "ignore",
27
- cwd: projectRoot,
28
- });
29
- child.unref();
30
- }
46
+ async function runChat(projectRoot) {
47
+ if (!fs.existsSync(getUfooPaths(projectRoot).ufooDir)) {
48
+ const repoRoot = path.join(__dirname, "..", "..");
49
+ const init = new UfooInit(repoRoot);
50
+ await init.init({ modules: "context,bus", project: projectRoot });
51
+ }
31
52
 
32
- function stopDaemon(projectRoot) {
33
- const daemonBin = resolveProjectFile(projectRoot, path.join("bin", "ufoo.js"), path.join("bin", "ufoo.js"));
34
- spawnSync(process.execPath, [daemonBin, "daemon", "--stop"], {
35
- stdio: "ignore",
36
- cwd: projectRoot,
37
- });
38
- }
53
+ // Ensure subscriber ID exists for chat (persistent across restarts)
54
+ if (!process.env.UFOO_SUBSCRIBER_ID) {
55
+ const crypto = require("crypto");
56
+ const sessionFile = path.join(getUfooPaths(projectRoot).ufooDir, "chat", "session-id.txt");
57
+ const sessionDir = path.dirname(sessionFile);
58
+ fs.mkdirSync(sessionDir, { recursive: true });
39
59
 
40
- async function connectWithRetry(sockPath, retries, delayMs) {
41
- for (let i = 0; i < retries; i += 1) {
42
- try {
43
- // eslint-disable-next-line no-await-in-loop
44
- const client = await connectSocket(sockPath);
45
- return client;
46
- } catch {
47
- // eslint-disable-next-line no-await-in-loop
48
- await new Promise((r) => setTimeout(r, delayMs));
60
+ let sessionId;
61
+ if (fs.existsSync(sessionFile)) {
62
+ sessionId = fs.readFileSync(sessionFile, "utf8").trim();
63
+ } else {
64
+ sessionId = crypto.randomBytes(4).toString("hex");
65
+ fs.writeFileSync(sessionFile, sessionId, "utf8");
49
66
  }
67
+ // Chat 模式默认使用 claude-code 类型
68
+ process.env.UFOO_SUBSCRIBER_ID = `claude-code:${sessionId}`;
50
69
  }
51
- return null;
52
- }
53
70
 
54
- async function runChat(projectRoot) {
55
- if (!fs.existsSync(path.join(projectRoot, ".ufoo"))) {
56
- const initScript = resolveProjectFile(projectRoot, path.join("scripts", "init.sh"), path.join("scripts", "init.sh"));
57
- spawnSync("bash", [initScript, "--modules", "context,bus", "--project", projectRoot], {
58
- stdio: "inherit",
59
- });
60
- }
61
71
  if (!isRunning(projectRoot)) {
62
72
  startDaemon(projectRoot);
63
73
  }
64
74
 
65
- const daemonBin = resolveProjectFile(projectRoot, path.join("bin", "ufoo.js"), path.join("bin", "ufoo.js"));
66
75
  const sock = socketPath(projectRoot);
67
- let client = null;
68
-
69
- const connectClient = async () => {
70
- let newClient = await connectWithRetry(sock, 25, 200);
71
- if (!newClient) {
72
- // Retry once with a fresh daemon start and longer wait.
73
- if (!isRunning(projectRoot)) {
74
- startDaemon(projectRoot);
75
- }
76
- newClient = await connectWithRetry(sock, 50, 200);
77
- }
78
- return newClient;
79
- };
80
-
81
- client = await connectClient();
82
- if (!client) {
83
- // Check if daemon failed to start
84
- if (!isRunning(projectRoot)) {
85
- const logFile = path.join(projectRoot, ".ufoo", "run", "ufoo-daemon.log");
86
- // eslint-disable-next-line no-console
87
- console.error("Failed to start ufoo daemon. Check logs at:", logFile);
88
- throw new Error("Daemon failed to start. Check the daemon log for details.");
89
- }
90
- throw new Error("Failed to connect to ufoo daemon (timeout). The daemon may still be starting.");
91
- }
92
-
93
- const screen = blessed.screen({
94
- smartCSR: true,
95
- title: "ufoo chat",
96
- fullUnicode: true,
97
- // Allow terminal native copy by not fully grabbing mouse
98
- // Hold Option/Alt to use native selection in most terminals
99
- sendFocus: true,
100
- mouse: false,
101
- // Allow Ctrl+C to exit even when input grabs keys
102
- ignoreLocked: ["C-c"],
76
+ let daemonCoordinator = null;
77
+ const daemonTransport = createDaemonTransport({
78
+ projectRoot,
79
+ sockPath: sock,
80
+ isRunning,
81
+ startDaemon,
82
+ connectWithRetry,
103
83
  });
104
84
 
105
85
  const config = loadConfig(projectRoot);
106
86
  let launchMode = config.launchMode;
107
87
  let agentProvider = config.agentProvider;
88
+ let assistantEngine = normalizeAssistantEngine(config.assistantEngine);
89
+ let autoResume = config.autoResume !== false;
90
+ let cronScheduler = {
91
+ addTask: () => null,
92
+ listTasks: () => [],
93
+ stopTask: () => false,
94
+ stopAll: () => 0,
95
+ };
108
96
 
109
97
  // Dynamic input height settings
110
98
  // Layout: topLine(1) + content + bottomLine(1) + dashboard(1)
111
99
  const MIN_INPUT_HEIGHT = 4; // 1 content + 3
112
100
  const MAX_INPUT_HEIGHT = 9; // 6 content + 3
113
101
  let currentInputHeight = MIN_INPUT_HEIGHT;
114
-
115
- // Log area (no border for cleaner look)
116
- const logBox = blessed.log({
117
- parent: screen,
118
- top: 0,
119
- left: 0,
120
- width: "100%",
121
- height: "100%-5", // Will be adjusted dynamically
122
- tags: true,
123
- scrollable: true,
124
- alwaysScroll: true,
125
- scrollback: 10000,
126
- scrollbar: { ch: "│", style: { fg: "cyan" } },
127
- keys: true,
128
- vi: true,
129
- // Enable mouse wheel scrolling in log area (use Option/Alt for native selection)
130
- mouse: true,
131
- });
132
-
133
- // Status line just above input
134
- const statusLine = blessed.box({
135
- parent: screen,
136
- bottom: currentInputHeight,
137
- left: 0,
138
- width: "100%",
139
- height: 1,
140
- style: { fg: "gray" },
141
- tags: true,
142
- content: "",
143
- });
144
102
  const pkg = require("../../package.json");
145
- const bannerText = `{bold}UFOO{/bold} · Multi-Agent Manager{|}v${pkg.version}`;
146
- statusLine.setContent(bannerText);
103
+ const {
104
+ screen,
105
+ logBox,
106
+ statusLine,
107
+ bannerText,
108
+ completionPanel,
109
+ dashboard,
110
+ inputBottomLine,
111
+ promptBox,
112
+ input,
113
+ inputTopLine,
114
+ } = createChatLayout({
115
+ blessed,
116
+ currentInputHeight,
117
+ version: pkg.version,
118
+ });
147
119
 
148
- const historyDir = path.join(projectRoot, ".ufoo", "chat");
120
+ const historyDir = path.join(getUfooPaths(projectRoot).ufooDir, "chat");
149
121
  const historyFile = path.join(historyDir, "history.jsonl");
150
122
  const inputHistoryFile = path.join(historyDir, "input-history.jsonl");
151
123
 
152
- function appendHistory(entry) {
153
- fs.mkdirSync(historyDir, { recursive: true });
154
- fs.appendFileSync(historyFile, `${JSON.stringify(entry)}\n`);
155
- }
156
-
157
- const SPACED_TYPES = new Set(["user", "reply", "bus", "dispatch", "error"]);
158
- let lastLogWasSpacer = false;
159
- let lastLogType = null;
160
- let hasLoggedAny = false;
161
-
162
- function shouldSpace(type) {
163
- return SPACED_TYPES.has(type);
164
- }
124
+ const chatLogController = createChatLogController({
125
+ logBox,
126
+ fsModule: fs,
127
+ historyDir,
128
+ historyFile,
129
+ });
165
130
 
166
- function writeSpacer(writeHistory) {
167
- if (lastLogWasSpacer || !hasLoggedAny) return;
168
- logBox.log(" ");
169
- if (writeHistory) {
170
- appendHistory({
171
- ts: new Date().toISOString(),
172
- type: "spacer",
173
- text: "",
174
- meta: {},
175
- });
176
- }
177
- lastLogWasSpacer = true;
178
- lastLogType = "spacer";
179
- hasLoggedAny = true;
180
- }
131
+ const streamTracker = createStreamTracker({
132
+ logBox,
133
+ writeSpacer: () => chatLogController.writeSpacer(false),
134
+ appendHistory: (...args) => chatLogController.appendHistory(...args),
135
+ escapeBlessed,
136
+ onStreamStart: () => chatLogController.markStreamStart(),
137
+ });
181
138
 
182
- function recordLog(type, text, meta = {}, writeHistory = true) {
183
- if (type !== "spacer" && shouldSpace(type)) {
184
- writeSpacer(writeHistory);
185
- }
186
- logBox.log(text);
187
- if (writeHistory) {
188
- appendHistory({
189
- ts: new Date().toISOString(),
190
- type,
191
- text,
192
- meta,
193
- });
194
- }
195
- lastLogWasSpacer = false;
196
- lastLogType = type;
197
- hasLoggedAny = true;
198
- }
139
+ const beginStream = (...args) => streamTracker.beginStream(...args);
140
+ const appendStreamDelta = (...args) => streamTracker.appendStreamDelta(...args);
141
+ const finalizeStream = (...args) => streamTracker.finalizeStream(...args);
142
+ const markPendingDelivery = (...args) => streamTracker.markPendingDelivery(...args);
143
+ const getPendingState = (...args) => streamTracker.getPendingState(...args);
144
+ const consumePendingDelivery = (...args) => streamTracker.consumePendingDelivery(...args);
199
145
 
200
146
  function logMessage(type, text, meta = {}) {
201
- recordLog(type, text, meta, true);
147
+ chatLogController.logMessage(type, text, meta);
202
148
  }
203
149
 
204
150
  function loadHistory(limit = 2000) {
205
- try {
206
- const lines = fs.readFileSync(historyFile, "utf8").trim().split(/\r?\n/).filter(Boolean);
207
- const items = lines.slice(-limit).map((line) => JSON.parse(line));
208
- const hasSpacer = items.some((item) => item && item.type === "spacer");
209
- for (const item of items) {
210
- if (!item) continue;
211
- if (item.type === "spacer") {
212
- writeSpacer(false);
213
- continue;
214
- }
215
- if (!item.text) continue;
216
- if (hasSpacer) {
217
- logBox.log(item.text);
218
- lastLogWasSpacer = false;
219
- lastLogType = item.type || null;
220
- hasLoggedAny = true;
221
- } else {
222
- recordLog(item.type || "unknown", item.text, item.meta || {}, false);
223
- }
224
- }
225
- } catch {
226
- // ignore missing/invalid history
227
- }
151
+ chatLogController.loadHistory(limit);
228
152
  }
229
153
 
230
- const inputHistory = [];
231
- let historyIndex = 0;
232
- let historyDraft = "";
233
-
234
- function appendInputHistory(text) {
235
- if (!text) return;
236
- fs.mkdirSync(historyDir, { recursive: true });
237
- fs.appendFileSync(inputHistoryFile, `${JSON.stringify({ text })}\n`);
238
- }
154
+ let inputHistoryController = null;
239
155
 
240
156
  function loadInputHistory(limit = 2000) {
241
- try {
242
- const lines = fs.readFileSync(inputHistoryFile, "utf8").trim().split(/\r?\n/).filter(Boolean);
243
- const items = lines.slice(-limit).map((line) => JSON.parse(line));
244
- for (const item of items) {
245
- if (item && typeof item.text === "string" && item.text.trim() !== "") {
246
- inputHistory.push(item.text);
247
- }
248
- }
249
- } catch {
250
- // ignore missing/invalid history
251
- }
252
- historyIndex = inputHistory.length;
253
- }
254
-
255
- const pendingStatusLines = [];
256
- const busStatusQueue = [];
257
- let primaryStatusText = bannerText;
258
-
259
- function formatProcessingText(text) {
260
- if (!text) return text;
261
- if (text.includes("{")) return text;
262
- if (!/processing/i.test(text)) return text;
263
- return `{yellow-fg}⏳{/yellow-fg} ${text}`;
264
- }
265
-
266
- function renderStatusLine() {
267
- let content = primaryStatusText || "";
268
- if (busStatusQueue.length > 0) {
269
- const extra = busStatusQueue.length > 1
270
- ? ` {gray-fg}(+${busStatusQueue.length - 1}){/gray-fg}`
271
- : "";
272
- const busText = `${busStatusQueue[0].text}${extra}`;
273
- content = content
274
- ? `${content} {gray-fg}·{/gray-fg} ${busText}`
275
- : busText;
276
- }
277
- statusLine.setContent(content);
278
- }
279
-
280
- function setPrimaryStatus(text) {
281
- primaryStatusText = text || "";
282
- renderStatusLine();
157
+ if (!inputHistoryController) return;
158
+ inputHistoryController.loadInputHistory(limit);
283
159
  }
284
160
 
285
- function queueStatusLine(text) {
286
- const formatted = formatProcessingText(text);
287
- pendingStatusLines.push(formatted);
288
- if (pendingStatusLines.length === 1) {
289
- setPrimaryStatus(formatted);
290
- screen.render();
291
- }
292
- }
293
-
294
- function resolveStatusLine(text) {
295
- if (pendingStatusLines.length > 0) {
296
- pendingStatusLines.shift();
297
- }
298
- if (pendingStatusLines.length > 0) {
299
- setPrimaryStatus(pendingStatusLines[0]);
300
- } else {
301
- setPrimaryStatus(text || "");
302
- }
303
- screen.render();
304
- }
305
-
306
- function enqueueBusStatus(item) {
307
- if (!item || !item.text) return;
308
- const key = item.key || item.text;
309
- const formatted = formatProcessingText(item.text);
310
- const existing = busStatusQueue.find((entry) => entry.key === key);
311
- if (existing) {
312
- existing.text = formatted;
313
- } else {
314
- busStatusQueue.push({ key, text: formatted });
315
- }
316
- renderStatusLine();
317
- }
318
-
319
- function resolveBusStatus(item) {
320
- if (!item) return;
321
- const key = item.key || item.text;
322
- let index = -1;
323
- if (key) {
324
- index = busStatusQueue.findIndex((entry) => entry.key === key);
325
- }
326
- if (index === -1 && item.text) {
327
- index = busStatusQueue.findIndex((entry) => entry.text === item.text);
328
- }
329
- if (index === -1) return;
330
- busStatusQueue.splice(index, 1);
331
- renderStatusLine();
332
- }
333
-
334
- // Command completion panel
335
- const completionPanel = blessed.box({
336
- parent: screen,
337
- bottom: currentInputHeight - 1,
338
- left: 0,
339
- width: "100%",
340
- height: 0,
341
- hidden: true,
342
- border: {
343
- type: "line",
344
- top: true,
345
- left: false,
346
- right: false,
347
- bottom: false
348
- },
349
- style: {
350
- border: { fg: "yellow" },
351
- fg: "white"
352
- // No bg - uses terminal default background
353
- },
354
- padding: {
355
- left: 0,
356
- right: 0,
357
- top: 0,
358
- bottom: 0
359
- },
360
- tags: true,
361
- });
362
-
363
- // Dashboard at very bottom
364
- const dashboard = blessed.box({
365
- parent: screen,
366
- bottom: 0,
367
- left: 0,
368
- width: "100%",
369
- height: 1,
370
- style: { fg: "gray" },
371
- tags: true,
372
- });
373
-
374
- // Bottom border line for input area (above dashboard)
375
- const inputBottomLine = blessed.line({
376
- parent: screen,
377
- bottom: 1,
378
- left: 0,
379
- width: "100%",
380
- orientation: "horizontal",
381
- style: { fg: "cyan" },
161
+ const statusLineController = createStatusLineController({
162
+ statusLine,
163
+ bannerText,
164
+ renderScreen: () => screen.render(),
382
165
  });
383
166
 
384
- // Prompt indicator
385
- const promptBox = blessed.box({
386
- parent: screen,
387
- bottom: 2,
388
- left: 0,
389
- width: 2,
390
- height: currentInputHeight - 3,
391
- content: ">",
392
- style: { fg: "cyan" },
393
- });
394
-
395
- // Input area without left/right border
396
- const input = blessed.textarea({
397
- parent: screen,
398
- bottom: 2,
399
- left: 2,
400
- width: "100%-2",
401
- height: currentInputHeight - 3,
402
- inputOnFocus: true,
403
- keys: true,
404
- });
405
- // Avoid textarea's extra wrap margin (causes a phantom empty column)
406
- input.type = "box";
407
-
408
- // Top border line for input area (just above input)
409
- const inputTopLine = blessed.line({
410
- parent: screen,
411
- bottom: currentInputHeight - 1, // 4-1=3: above input(2) + inputHeight(1)
412
- left: 0,
413
- width: "100%",
414
- orientation: "horizontal",
415
- style: { fg: "cyan" },
167
+ const queueStatusLine = (...args) => statusLineController.queueStatusLine(...args);
168
+ const resolveStatusLine = (...args) => statusLineController.resolveStatusLine(...args);
169
+ const enqueueBusStatus = (...args) => statusLineController.enqueueBusStatus(...args);
170
+ const resolveBusStatus = (...args) => statusLineController.resolveBusStatus(...args);
171
+
172
+ let agentViewController = null;
173
+ let terminalAdapterRouter = null;
174
+ const agentSockets = createAgentSockets({
175
+ onTermWrite: (text) => writeToAgentTerm(text),
176
+ onPlaceCursor: (cursor) => placeAgentCursor(cursor),
177
+ isAgentView: () => getCurrentView() === "agent",
178
+ isBusMode: () => isAgentViewUsesBus(),
179
+ getViewingAgent: () => getViewingAgent(),
180
+ sendBusRaw: (target, data) => {
181
+ send({
182
+ type: IPC_REQUEST_TYPES.BUS_SEND,
183
+ target,
184
+ message: JSON.stringify({ raw: true, data }),
185
+ });
186
+ },
416
187
  });
417
188
 
418
189
  // Add cursor position tracking
419
190
  let cursorPos = 0;
420
191
  let preferredCol = null;
421
192
 
422
- // Get inner width
423
193
  function getInnerWidth() {
424
- const lpos = input.lpos || input._getCoords();
425
- if (lpos && Number.isFinite(lpos.xl) && Number.isFinite(lpos.xi)) {
426
- return Math.max(1, lpos.xl - lpos.xi + 1);
427
- }
428
- if (typeof input.width === "number") return Math.max(1, input.width);
429
- if (typeof input.width === "string") {
430
- const match = input.width.match(/^100%-([0-9]+)$/);
431
- if (match && typeof screen.width === "number") {
432
- return Math.max(1, screen.width - parseInt(match[1], 10));
433
- }
434
- }
435
194
  const promptWidth = typeof promptBox.width === "number" ? promptBox.width : 2;
436
- if (typeof screen.width === "number") return Math.max(1, screen.width - promptWidth);
437
- if (typeof screen.cols === "number") return Math.max(1, screen.cols - promptWidth);
438
- return 1;
195
+ return inputMath.getInnerWidth({ input, screen, promptWidth });
196
+ }
197
+
198
+ function getWrapWidth() {
199
+ return inputMath.getWrapWidth(input, getInnerWidth());
439
200
  }
440
201
 
441
- // Count lines considering both wrapping and newlines
442
202
  function countLines(text, width) {
443
- if (width <= 0) return 1;
444
- const lines = text.split("\n");
445
- let total = 0;
446
- for (const line of lines) {
447
- const lineWidth = input.strWidth(line);
448
- total += Math.max(1, Math.ceil(lineWidth / width));
449
- }
450
- return total;
203
+ return inputMath.countLines(text, width, (value) => input.strWidth(value));
451
204
  }
452
205
 
453
206
  function getCursorRowCol(text, pos, width) {
454
- if (width <= 0) return { row: 0, col: 0 };
455
- const before = text.slice(0, pos);
456
- const lines = before.split("\n");
457
- let row = 0;
458
- for (let i = 0; i < lines.length - 1; i++) {
459
- const lineWidth = input.strWidth(lines[i]);
460
- row += Math.max(1, Math.ceil(lineWidth / width));
461
- }
462
- const lastLine = lines[lines.length - 1] || "";
463
- const lastWidth = input.strWidth(lastLine);
464
- row += Math.floor(lastWidth / width);
465
- const col = lastWidth % width;
466
- return { row, col };
467
- }
468
-
469
- function getLinePosForCol(line, targetCol) {
470
- if (targetCol <= 0) return 0;
471
- let col = 0;
472
- let offset = 0;
473
- for (const ch of Array.from(line)) {
474
- const w = input.strWidth(ch);
475
- if (col + w > targetCol) return offset;
476
- col += w;
477
- offset += ch.length;
478
- }
479
- return offset;
207
+ return inputMath.getCursorRowCol(text, pos, width, (value) => input.strWidth(value));
480
208
  }
481
209
 
482
210
  function getCursorPosForRowCol(text, targetRow, targetCol, width) {
483
- if (width <= 0) return 0;
484
- const lines = text.split("\n");
485
- let row = 0;
486
- let pos = 0;
487
- for (const line of lines) {
488
- const lineWidth = input.strWidth(line);
489
- const wrappedRows = Math.max(1, Math.ceil(lineWidth / width));
490
- if (targetRow < row + wrappedRows) {
491
- const rowInLine = targetRow - row;
492
- const visualCol = rowInLine * width + Math.max(0, targetCol);
493
- return pos + getLinePosForCol(line, visualCol);
211
+ return inputMath.getCursorPosForRowCol(
212
+ text,
213
+ targetRow,
214
+ targetCol,
215
+ width,
216
+ (value) => input.strWidth(value),
217
+ );
218
+ }
219
+
220
+ function ensureInputCursorVisible() {
221
+ const innerWidth = getInnerWidth();
222
+ if (innerWidth <= 0) return;
223
+ const totalRows = countLines(input.value, innerWidth);
224
+ const visibleRows = Math.max(1, input.height || 1);
225
+ const { row } = getCursorRowCol(input.value, cursorPos, innerWidth);
226
+ let base = input.childBase || 0;
227
+ const maxBase = Math.max(0, totalRows - visibleRows);
228
+ const bottomMargin = visibleRows > 1 ? 1 : 0;
229
+ const upperLimit = base;
230
+ const lowerLimit = base + visibleRows - bottomMargin - 1;
231
+
232
+ if (row < upperLimit) {
233
+ base = row;
234
+ } else if (row > lowerLimit) {
235
+ base = row - (visibleRows - bottomMargin - 1);
236
+ }
237
+
238
+ if (base > maxBase) base = maxBase;
239
+ if (base < 0) base = 0;
240
+ if (base !== input.childBase) {
241
+ input.childBase = base;
242
+ if (typeof input.scrollTo === "function") {
243
+ input.scrollTo(base);
494
244
  }
495
- pos += line.length + 1;
496
- row += wrappedRows;
497
245
  }
498
- return text.length;
499
246
  }
500
247
 
501
248
  function resetPreferredCol() {
502
249
  preferredCol = null;
503
250
  }
504
251
 
505
- const PASTE_START = "\x1b[200~";
506
- const PASTE_END = "\x1b[201~";
507
- let pasteActive = false;
508
- let pasteBuffer = "";
509
- let pasteRemainder = "";
510
- let suppressKeypress = false;
511
- let suppressReset = null;
252
+ function getPreferredCol() {
253
+ return preferredCol;
254
+ }
512
255
 
513
- function scheduleSuppressReset() {
514
- suppressKeypress = true;
515
- if (suppressReset) clearImmediate(suppressReset);
516
- suppressReset = setImmediate(() => {
517
- if (!pasteActive) suppressKeypress = false;
518
- });
256
+ function setPreferredCol(value) {
257
+ preferredCol = value;
519
258
  }
520
259
 
521
260
  function normalizePaste(text) {
522
- if (!text) return "";
523
- let normalized = text.replace(/\x1b\[200~|\x1b\[201~/g, "");
524
- normalized = normalized.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
525
- return normalized;
261
+ return inputMath.normalizePaste(text);
526
262
  }
527
263
 
528
264
  function updateDraftFromInput() {
529
- if (historyIndex === inputHistory.length) {
530
- historyDraft = input.value;
531
- }
265
+ if (!inputHistoryController) return;
266
+ inputHistoryController.updateDraftFromInput();
532
267
  }
533
268
 
534
269
  function normalizeCommandPrefix() {
@@ -547,6 +282,7 @@ async function runChat(projectRoot) {
547
282
  normalizeCommandPrefix();
548
283
  resetPreferredCol();
549
284
  resizeInput();
285
+ ensureInputCursorVisible();
550
286
  input._updateCursor();
551
287
  screen.render();
552
288
  updateDraftFromInput();
@@ -557,266 +293,113 @@ async function runChat(projectRoot) {
557
293
  cursorPos = input.value.length;
558
294
  resetPreferredCol();
559
295
  resizeInput();
296
+ ensureInputCursorVisible();
560
297
  input._updateCursor();
561
298
  screen.render();
562
299
  }
563
300
 
301
+ inputHistoryController = createInputHistoryController({
302
+ inputHistoryFile,
303
+ historyDir,
304
+ setInputValue,
305
+ getInputValue: () => input.value || "",
306
+ });
307
+
564
308
  function historyUp() {
565
- if (inputHistory.length === 0) return false;
566
- if (historyIndex === inputHistory.length) {
567
- historyDraft = input.value;
568
- }
569
- if (historyIndex > 0) {
570
- historyIndex -= 1;
571
- setInputValue(inputHistory[historyIndex]);
572
- return true;
573
- }
574
- return true;
309
+ if (!inputHistoryController) return false;
310
+ return inputHistoryController.historyUp();
575
311
  }
576
312
 
577
313
  function historyDown() {
578
- if (inputHistory.length === 0) return false;
579
- if (historyIndex < inputHistory.length - 1) {
580
- historyIndex += 1;
581
- setInputValue(inputHistory[historyIndex]);
582
- return true;
583
- }
584
- if (historyIndex === inputHistory.length - 1) {
585
- historyIndex = inputHistory.length;
586
- setInputValue(historyDraft || "");
587
- return true;
588
- }
589
- return false;
314
+ if (!inputHistoryController) return false;
315
+ return inputHistoryController.historyDown();
590
316
  }
591
317
 
592
318
  function exitHandler() {
319
+ if (daemonCoordinator) {
320
+ daemonCoordinator.markExit();
321
+ }
322
+ cronScheduler.stopAll();
323
+ exitAgentView();
593
324
  if (screen && screen.program && typeof screen.program.decrst === "function") {
594
325
  screen.program.decrst(2004);
595
326
  }
596
- if (client) {
597
- client.end();
327
+ statusLineController.destroy();
328
+ if (daemonCoordinator) {
329
+ daemonCoordinator.close();
598
330
  }
599
331
  process.exit(0);
600
332
  }
601
333
 
602
- // Command completion functions
603
- function showCompletion(filterText) {
604
- // Ensure accidental double-prefix doesn't break filtering.
605
- normalizeCommandPrefix();
606
- if (filterText !== input.value) {
607
- filterText = input.value;
608
- }
609
- if (filterText.startsWith("//")) {
610
- filterText = filterText.replace(/^\/+/, "/");
611
- input.value = filterText;
612
- cursorPos = Math.min(cursorPos, input.value.length);
613
- }
614
- if (!filterText || filterText === "") {
615
- hideCompletion();
616
- return;
617
- }
618
-
619
- // Trim the filterText to handle trailing spaces for main command mode
620
- // But preserve spaces for subcommand mode detection
621
- const endsWithSpace = /\s$/.test(filterText);
622
- const trimmed = filterText.trim();
623
- if (!trimmed) {
624
- hideCompletion();
625
- return;
626
- }
627
- filterText = trimmed;
628
-
629
- // Check if we're in subcommand mode
630
- const parts = filterText.split(/\s+/);
631
- let commands = [];
632
-
633
- if ((parts.length > 1 || (endsWithSpace && parts.length === 1)) && parts[0].startsWith("/")) {
634
- // Subcommand mode: "/bus rename"
635
- const mainCmd = parts[0];
636
- const subFilter = parts[1] || "";
637
-
638
- // Find the main command
639
- const mainCmdObj = COMMAND_REGISTRY.find(item =>
640
- item.cmd.toLowerCase() === mainCmd.toLowerCase()
641
- );
642
-
643
- if (mainCmdObj && mainCmdObj.subcommands) {
644
- // Filter subcommands
645
- commands = mainCmdObj.subcommands
646
- .filter(sub => sub.cmd.toLowerCase().startsWith(subFilter.toLowerCase()))
647
- .map(sub => ({ ...sub, isSubcommand: true, parentCmd: mainCmd }));
648
- }
649
- } else {
650
- // Main command mode: "/bus"
651
- const prefixMatches = COMMAND_REGISTRY.filter(item =>
652
- item.cmd.toLowerCase().startsWith(filterText.toLowerCase())
653
- );
654
- // Also allow fuzzy matches on the command body (e.g. "/b" -> /bus + /ubus)
655
- let fuzzyMatches = [];
656
- if (filterText.startsWith("/") && parts.length === 1) {
657
- const needle = filterText.slice(1).toLowerCase();
658
- if (needle) {
659
- fuzzyMatches = COMMAND_REGISTRY.filter(item =>
660
- item.cmd.toLowerCase().includes(needle)
661
- );
662
- }
663
- }
664
- const merged = new Map();
665
- for (const item of prefixMatches) merged.set(item.cmd, item);
666
- for (const item of fuzzyMatches) merged.set(item.cmd, item);
667
- commands = Array.from(merged.values());
668
- }
669
-
670
- if (commands.length === 0) {
671
- hideCompletion();
672
- return;
673
- }
674
-
675
- completionCommands = commands;
676
- completionActive = true;
677
- completionIndex = 0;
678
- completionScrollOffset = 0;
679
-
680
- // Calculate panel height (max 8 visible + 1 for top border)
681
- const visibleItems = Math.min(8, completionCommands.length);
682
- completionPanel.height = visibleItems + 1;
683
- completionPanel.bottom = currentInputHeight - 1;
684
- completionPanel.hidden = false;
685
-
686
- renderCompletionPanel();
687
- }
688
-
689
- function hideCompletion() {
690
- completionActive = false;
691
- completionCommands = [];
692
- completionIndex = 0;
693
- completionScrollOffset = 0;
694
- completionPanel.hidden = true;
695
- screen.render();
696
- }
697
-
698
- function renderCompletionPanel() {
699
- if (!completionActive || completionCommands.length === 0) return;
700
-
701
- const maxVisible = 8;
702
-
703
- // Adjust scroll offset to keep selected item visible
704
- if (completionIndex < completionScrollOffset) {
705
- completionScrollOffset = completionIndex;
706
- } else if (completionIndex >= completionScrollOffset + maxVisible) {
707
- completionScrollOffset = completionIndex - maxVisible + 1;
708
- }
709
-
710
- // Calculate visible slice
711
- const visibleStart = completionScrollOffset;
712
- const visibleEnd = Math.min(completionScrollOffset + maxVisible, completionCommands.length);
713
- const visibleCommands = completionCommands.slice(visibleStart, visibleEnd);
714
-
715
- const lines = visibleCommands.map((item, i) => {
716
- const actualIndex = visibleStart + i;
717
- const cmdPart = actualIndex === completionIndex
718
- ? `{inverse}${item.cmd}{/inverse}`
719
- : `{cyan-fg}${item.cmd}{/cyan-fg}`;
720
- const descPart = `{gray-fg}${item.desc}{/gray-fg}`;
721
- // Use promptBox width (2) to align with input position
722
- const indent = " ".repeat(promptBox.width || 2);
723
- return `${indent}${cmdPart} ${descPart}`;
724
- });
725
-
726
- completionPanel.setContent(lines.join("\n"));
727
- screen.render();
728
- }
729
-
730
- function completionUp() {
731
- if (completionCommands.length === 0) return;
732
- completionIndex = completionIndex <= 0
733
- ? completionCommands.length - 1
734
- : completionIndex - 1;
735
- renderCompletionPanel();
736
- }
737
-
738
- function completionDown() {
739
- if (completionCommands.length === 0) return;
740
- completionIndex = completionIndex >= completionCommands.length - 1
741
- ? 0
742
- : completionIndex + 1;
743
- renderCompletionPanel();
744
- }
745
-
746
- function confirmCompletion() {
747
- if (!completionActive || completionCommands.length === 0) return;
748
-
749
- const selected = completionCommands[completionIndex];
750
-
751
- if (selected.isSubcommand) {
752
- // Subcommand: replace the last word with selected subcommand
753
- const parts = input.value.split(/\s+/);
754
- parts[parts.length - 1] = selected.cmd;
755
- input.value = parts.join(" ") + " ";
756
- } else {
757
- // Main command
758
- input.value = selected.cmd + " ";
759
- }
760
-
761
- cursorPos = input.value.length;
762
- resetPreferredCol();
763
- input._updateCursor();
764
- updateDraftFromInput();
765
-
766
- // If selected command has subcommands, trigger subcommand completion immediately
767
- if (!selected.isSubcommand && selected.subcommands && selected.subcommands.length > 0) {
768
- // Don't hide - directly show subcommand completion
769
- showCompletion(input.value);
770
- } else {
771
- // No subcommands - hide completion
772
- hideCompletion();
773
- }
774
-
775
- screen.render();
776
- }
334
+ const completionController = createCompletionController({
335
+ input,
336
+ screen,
337
+ completionPanel,
338
+ promptBox,
339
+ commandRegistry: COMMAND_REGISTRY,
340
+ getMentionCandidates: () => activeAgents.map((id) => ({
341
+ id,
342
+ label: getAgentLabel(id),
343
+ })),
344
+ normalizeCommandPrefix,
345
+ truncateText,
346
+ getCurrentInputHeight: () => currentInputHeight,
347
+ getCursorPos: () => cursorPos,
348
+ setCursorPos: (value) => {
349
+ cursorPos = value;
350
+ },
351
+ resetPreferredCol,
352
+ updateDraftFromInput,
353
+ renderScreen: () => screen.render(),
354
+ });
777
355
 
778
- function handleCompletionKey(ch, key) {
779
- if (!completionActive) return false;
356
+ const pasteController = createPasteController({
357
+ shouldHandle: () => screen.focused === input && focusMode === "input",
358
+ normalizePaste,
359
+ insertTextAtCursor,
360
+ });
780
361
 
781
- if (key.name === "up") {
782
- completionUp();
783
- return true;
784
- }
785
- if (key.name === "down") {
786
- completionDown();
787
- return true;
788
- }
789
- if (key.name === "tab") {
790
- confirmCompletion();
791
- return true;
792
- }
793
- if (key.name === "enter" || key.name === "return") {
794
- // Enter submits input, doesn't confirm completion
795
- hideCompletion();
796
- return false;
797
- }
798
- if (key.name === "escape") {
799
- hideCompletion();
800
- return true;
801
- }
802
- if (ch === " ") {
803
- // Check if current input is a command that might have subcommands
804
- const currentInput = input.value.trim();
805
- if (currentInput.startsWith("/") && !currentInput.includes(" ")) {
806
- // Let space be inserted, will trigger subcommand completion
807
- return false;
808
- }
809
- hideCompletion();
810
- return false;
811
- }
812
- // Regular character and backspace - don't intercept, let it be handled normally
813
- // Completion will be updated in the main input handler
814
- return false;
815
- }
362
+ const inputListenerController = createInputListenerController({
363
+ getCurrentView: () => getCurrentView(),
364
+ exitHandler,
365
+ getFocusMode: () => focusMode,
366
+ getDashboardView: () => dashboardView,
367
+ getSelectedAgentIndex: () => selectedAgentIndex,
368
+ getActiveAgents: () => activeAgents,
369
+ getTargetAgent: () => targetAgent,
370
+ requestCloseAgent,
371
+ logMessage,
372
+ isSuppressKeypress: () => pasteController.isSuppressKeypress(),
373
+ normalizeCommandPrefix,
374
+ handleDashboardKey,
375
+ exitDashboardMode,
376
+ completionController,
377
+ getLogHeight: () => logBox.height,
378
+ scrollLog,
379
+ insertTextAtCursor,
380
+ normalizePaste,
381
+ resetPreferredCol,
382
+ getCursorPos: () => cursorPos,
383
+ setCursorPos: (value) => {
384
+ cursorPos = value;
385
+ },
386
+ ensureInputCursorVisible,
387
+ getWrapWidth,
388
+ getCursorRowCol,
389
+ countLines,
390
+ getCursorPosForRowCol,
391
+ getPreferredCol,
392
+ setPreferredCol,
393
+ historyUp,
394
+ historyDown,
395
+ enterDashboardMode,
396
+ resizeInput,
397
+ updateDraftFromInput,
398
+ });
816
399
 
817
400
  // Resize input box based on content
818
401
  function resizeInput() {
819
- const innerWidth = getInnerWidth();
402
+ const innerWidth = getWrapWidth();
820
403
  if (innerWidth <= 0) return;
821
404
 
822
405
  const numLines = countLines(input.value, innerWidth);
@@ -831,221 +414,31 @@ async function runChat(projectRoot) {
831
414
  }
832
415
  statusLine.bottom = currentInputHeight;
833
416
  // Reposition completion panel if active
834
- if (completionActive) {
835
- completionPanel.bottom = currentInputHeight - 1;
836
- }
417
+ if (completionController.isActive()) completionController.reflow();
837
418
  // dashboard and inputBottomLine stay fixed at bottom 0 and 1
838
419
  logBox.height = Math.max(1, screen.height - currentInputHeight - 1);
420
+ ensureInputCursorVisible();
839
421
  }
840
422
 
841
423
  // Override the internal listener to support cursor movement
842
424
  input._listener = function(ch, key) {
843
- if (key && key.ctrl && key.name === "c") {
844
- exitHandler();
845
- return;
846
- }
847
- if (suppressKeypress) {
848
- return;
849
- }
850
- normalizeCommandPrefix();
851
- if (key && (key.name === "pageup" || key.name === "pagedown")) {
852
- const delta = Math.max(1, Math.floor(logBox.height / 2));
853
- scrollLog(key.name === "pageup" ? -delta : delta);
854
- return;
855
- }
856
- if (focusMode === "dashboard") {
857
- if (handleDashboardKey(key)) return;
858
- return;
859
- }
860
-
861
- // Command completion mode
862
- if (completionActive) {
863
- if (handleCompletionKey(ch, key)) return;
864
- }
865
-
866
- // Treat multi-char input (paste) as insertion, including newlines.
867
- if (ch && ch.length > 1 && (!key || !key.name || key.name.length !== 1)) {
868
- insertTextAtCursor(normalizePaste(ch));
869
- return;
870
- }
871
- if (ch && (ch.includes("\n") || ch.includes("\r")) && (!key || (key.name !== "return" && key.name !== "enter"))) {
872
- insertTextAtCursor(normalizePaste(ch));
873
- return;
874
- }
875
- // Plain enter submits, shift+enter inserts newline
876
- if (key.name === "return" || key.name === "enter") {
877
- if (key.shift) {
878
- // Insert newline at cursor
879
- insertTextAtCursor("\n");
880
- } else {
881
- // Submit
882
- resetPreferredCol();
883
- this._done(null, this.value);
884
- }
885
- return;
886
- }
887
-
888
- if (key.name === "left") {
889
- if (cursorPos > 0) cursorPos--;
890
- resetPreferredCol();
891
- this._updateCursor();
892
- this.screen.render();
893
- return;
894
- }
895
-
896
- if (key.name === "right") {
897
- if (cursorPos < this.value.length) cursorPos++;
898
- resetPreferredCol();
899
- this._updateCursor();
900
- this.screen.render();
901
- return;
902
- }
903
-
904
- if (key.name === "home") {
905
- cursorPos = 0;
906
- resetPreferredCol();
907
- this._updateCursor();
908
- this.screen.render();
909
- return;
910
- }
911
-
912
- if (key.name === "end") {
913
- cursorPos = this.value.length;
914
- resetPreferredCol();
915
- this._updateCursor();
916
- this.screen.render();
917
- return;
918
- }
919
-
920
- if (key.name === "up") {
921
- // Special case: "/" + Up → jump to last command in completion
922
- if (completionActive && input.value === "/" && cursorPos === 1) {
923
- completionIndex = completionCommands.length - 1;
924
- renderCompletionPanel();
925
- return;
926
- }
927
- if (historyUp()) {
928
- hideCompletion();
929
- return;
930
- }
931
- }
932
- if (key.name === "down") {
933
- if (historyDown()) {
934
- hideCompletion();
935
- return;
936
- }
937
- }
938
- if (key.name === "up" || key.name === "down") {
939
- const innerWidth = getInnerWidth();
940
- if (innerWidth > 0) {
941
- const { row, col } = getCursorRowCol(this.value, cursorPos, innerWidth);
942
- if (preferredCol === null) preferredCol = col;
943
- const totalRows = countLines(this.value, innerWidth);
944
-
945
- // Down at last row -> enter dashboard mode
946
- if (key.name === "down" && row >= totalRows - 1) {
947
- enterDashboardMode();
948
- return;
949
- }
950
-
951
- const targetRow = key.name === "up"
952
- ? Math.max(0, row - 1)
953
- : Math.min(totalRows - 1, row + 1);
954
- cursorPos = getCursorPosForRowCol(this.value, targetRow, preferredCol, innerWidth);
955
- }
956
- this._updateCursor();
957
- this.screen.render();
958
- return;
959
- }
960
-
961
- if (key.name === "escape") {
962
- this._done(null, null);
963
- return;
964
- }
965
-
966
- if (key.name === "backspace") {
967
- if (cursorPos > 0) {
968
- this.value = this.value.slice(0, cursorPos - 1) + this.value.slice(cursorPos);
969
- cursorPos--;
970
- resetPreferredCol();
971
- resizeInput();
972
- this._updateCursor();
973
- updateDraftFromInput();
974
-
975
- // Update or hide completion after backspace
976
- if (this.value.startsWith("/")) {
977
- showCompletion(this.value);
978
- } else {
979
- hideCompletion();
980
- }
981
-
982
- this.screen.render();
983
- }
984
- return;
985
- }
986
-
987
- if (key.name === "delete") {
988
- if (cursorPos < this.value.length) {
989
- this.value = this.value.slice(0, cursorPos) + this.value.slice(cursorPos + 1);
990
- resetPreferredCol();
991
- resizeInput();
992
- this._updateCursor();
993
- this.screen.render();
994
- updateDraftFromInput();
995
- }
996
- return;
997
- }
998
-
999
- // Insert character at cursor position
1000
- const insertChar = (ch && ch.length === 1)
1001
- ? ch
1002
- : (key && key.name && key.name.length === 1 ? key.name : null);
1003
- if (insertChar && !/^[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]$/.test(insertChar)) {
1004
- this.value = this.value.slice(0, cursorPos) + insertChar + this.value.slice(cursorPos);
1005
- cursorPos++;
1006
- normalizeCommandPrefix();
1007
- resetPreferredCol();
1008
- resizeInput();
1009
- this._updateCursor();
1010
- updateDraftFromInput();
1011
-
1012
- // Update completion filter if typing after "/"
1013
- if (this.value.startsWith("/")) {
1014
- showCompletion(this.value);
1015
- } else if (completionActive) {
1016
- hideCompletion();
1017
- }
1018
-
1019
- this.screen.render();
1020
- return;
1021
- }
425
+ inputListenerController.handleKey(ch, key, this);
1022
426
  };
1023
427
 
1024
428
  // Override cursor update to use our cursor position
1025
429
  input._updateCursor = function() {
1026
430
  if (this.screen.focused !== this) return;
1027
431
 
1028
- const lpos = this._getCoords();
432
+ let lpos;
433
+ try { lpos = this._getCoords(); } catch { return; }
1029
434
  if (!lpos) return;
1030
435
 
1031
- const innerWidth = getInnerWidth();
436
+ const innerWidth = getWrapWidth();
1032
437
  if (innerWidth <= 0) return;
1033
438
 
439
+ ensureInputCursorVisible();
1034
440
  const { row, col } = getCursorRowCol(this.value, cursorPos, innerWidth);
1035
- const innerHeight = this.height || 1;
1036
-
1037
- let scrollOffset = this.childBase || 0;
1038
- if (row < scrollOffset) {
1039
- scrollOffset = row;
1040
- } else if (row >= scrollOffset + innerHeight) {
1041
- scrollOffset = row - innerHeight + 1;
1042
- }
1043
- if (scrollOffset !== this.childBase) {
1044
- this.childBase = scrollOffset;
1045
- if (typeof this.scrollTo === "function") {
1046
- this.scrollTo(scrollOffset);
1047
- }
1048
- }
441
+ const scrollOffset = this.childBase || 0;
1049
442
 
1050
443
  const displayRow = row - scrollOffset;
1051
444
  const safeCol = Math.min(Math.max(0, col), innerWidth - 1);
@@ -1062,9 +455,8 @@ async function runChat(projectRoot) {
1062
455
  cursorPos = 0;
1063
456
  resetPreferredCol();
1064
457
  currentInputHeight = MIN_INPUT_HEIGHT;
1065
- historyIndex = inputHistory.length;
1066
- historyDraft = "";
1067
- hideCompletion();
458
+ if (inputHistoryController) inputHistoryController.setIndexToEnd();
459
+ completionController.hide();
1068
460
  const contentHeight = 1; // MIN content height
1069
461
  input.height = contentHeight;
1070
462
  promptBox.height = contentHeight;
@@ -1074,95 +466,283 @@ async function runChat(projectRoot) {
1074
466
  return originalClearValue();
1075
467
  };
1076
468
 
1077
- let pending = null;
469
+ let pending = null;
470
+
471
+ // Agent selection state
472
+ let activeAgents = [];
473
+ let activeAgentLabelMap = new Map();
474
+ let activeAgentMetaMap = new Map(); // Store full meta including launch_mode
475
+ let agentListWindowStart = 0;
476
+ const MAX_AGENT_WINDOW = 4;
477
+ let selectedAgentIndex = -1; // -1 = not in dashboard selection mode
478
+ let targetAgent = null; // Selected agent for direct messaging
479
+ let focusMode = "input"; // "input" or "dashboard"
480
+ let dashboardView = "agents"; // "agents" | "mode" | "provider" | "assistant" | "cron"
481
+ let reportPendingTotal = 0;
482
+ let selectedModeIndex = launchMode === "internal" ? 2 : (launchMode === "tmux" ? 1 : 0);
483
+ const providerOptions = [
484
+ { label: "codex", value: "codex-cli" },
485
+ { label: "claude", value: "claude-cli" },
486
+ ];
487
+ let selectedProviderIndex = agentProvider === "claude-cli" ? 1 : 0;
488
+ const assistantOptions = [
489
+ { label: "auto", value: "auto" },
490
+ { label: "codex", value: "codex" },
491
+ { label: "claude", value: "claude" },
492
+ { label: "ufoo", value: "ufoo" },
493
+ ];
494
+ let selectedAssistantIndex = Math.max(
495
+ 0,
496
+ assistantOptions.findIndex((opt) => opt.value === assistantEngine)
497
+ );
498
+ const resumeOptions = [
499
+ { label: "Resume previous session", value: true },
500
+ { label: "Start new session", value: false },
501
+ ];
502
+ let selectedResumeIndex = autoResume ? 0 : 1;
503
+ const DASH_HINTS = {
504
+ agents: "←/→ select · Enter · ↓ mode · ↑ back",
505
+ agentsEmpty: "↓ mode · ↑ back",
506
+ mode: "←/→ select · Enter · ↓ provider · ↑ back",
507
+ provider: "←/→ select · Enter · ↓ assistant · ↑ back",
508
+ assistant: "←/→ select · Enter · ↓ cron · ↑ back",
509
+ cron: "Ctrl+X close · ↑ back",
510
+ resume: "",
511
+ };
512
+ const AGENT_BAR_HINTS = {
513
+ normal: "↓ agents",
514
+ dashboard: "←/→ · Enter · ↑ · ^X",
515
+ };
516
+
517
+ function getCurrentView() {
518
+ return agentViewController ? agentViewController.getCurrentView() : "main";
519
+ }
520
+
521
+ function getViewingAgent() {
522
+ return agentViewController ? agentViewController.getViewingAgent() : "";
523
+ }
524
+
525
+ function getAgentAdapter(agentId) {
526
+ if (!terminalAdapterRouter) return null;
527
+ const meta = activeAgentMetaMap ? activeAgentMetaMap.get(agentId) : null;
528
+ const agentLaunchMode = (meta && meta.launch_mode) || launchMode || "";
529
+ return terminalAdapterRouter.getAdapter({ launchMode: agentLaunchMode, agentId });
530
+ }
531
+
532
+ function getViewingAgentAdapter() {
533
+ const viewingAgent = getViewingAgent();
534
+ if (!viewingAgent) return null;
535
+ return getAgentAdapter(viewingAgent);
536
+ }
537
+
538
+ function canSendRaw(adapter) {
539
+ if (!adapter || !adapter.capabilities) return false;
540
+ return Boolean(
541
+ adapter.capabilities.supportsSocketProtocol
542
+ || adapter.capabilities.supportsInternalQueueLoop
543
+ );
544
+ }
545
+
546
+ function canResize(adapter) {
547
+ return Boolean(adapter && adapter.capabilities && adapter.capabilities.supportsSocketProtocol);
548
+ }
549
+
550
+ function canSnapshot(adapter) {
551
+ if (!adapter || !adapter.capabilities) return false;
552
+ return Boolean(
553
+ adapter.capabilities.supportsSnapshot
554
+ || adapter.capabilities.supportsSubscribeScreen
555
+ || adapter.capabilities.supportsSubscribeFull
556
+ );
557
+ }
558
+
559
+ function sendRawWithCapabilities(data) {
560
+ const adapter = getViewingAgentAdapter();
561
+ if (!canSendRaw(adapter)) return;
562
+ try {
563
+ adapter.sendRaw(data);
564
+ } catch {
565
+ // ignore unsupported errors
566
+ }
567
+ }
568
+
569
+ function sendResizeWithCapabilities(cols, rows) {
570
+ const adapter = getViewingAgentAdapter();
571
+ if (!canResize(adapter)) return;
572
+ try {
573
+ adapter.resize(cols, rows);
574
+ } catch {
575
+ // ignore unsupported errors
576
+ }
577
+ }
578
+
579
+ function requestSnapshotWithCapabilities() {
580
+ const adapter = getViewingAgentAdapter();
581
+ if (!canSnapshot(adapter)) return false;
582
+ try {
583
+ return adapter.snapshot();
584
+ } catch {
585
+ return false;
586
+ }
587
+ }
588
+
589
+ function isAgentViewUsesBus() {
590
+ return agentViewController ? agentViewController.isAgentViewUsesBus() : false;
591
+ }
592
+
593
+ function getAgentInputSuppressUntil() {
594
+ return agentViewController ? agentViewController.getAgentInputSuppressUntil() : 0;
595
+ }
596
+
597
+ function getAgentOutputSuppressed() {
598
+ return agentViewController ? agentViewController.getAgentOutputSuppressed() : false;
599
+ }
600
+
601
+ function setAgentOutputSuppressed(value) {
602
+ if (agentViewController) {
603
+ agentViewController.setAgentOutputSuppressed(value);
604
+ }
605
+ }
606
+
607
+ function renderAgentDashboard() {
608
+ if (agentViewController) {
609
+ agentViewController.renderAgentDashboard();
610
+ }
611
+ }
612
+
613
+ function setAgentBarVisible(visible) {
614
+ if (agentViewController) {
615
+ agentViewController.setAgentBarVisible(visible);
616
+ }
617
+ }
618
+
619
+ function enterAgentView(agentId, options = {}) {
620
+ if (agentViewController) {
621
+ agentViewController.enterAgentView(agentId, options);
622
+ }
623
+ }
624
+
625
+ function exitAgentView() {
626
+ if (agentViewController) {
627
+ agentViewController.exitAgentView();
628
+ }
629
+ }
630
+
631
+ function sendRawToAgent(data) {
632
+ if (agentViewController) {
633
+ agentViewController.sendRawToAgent(data);
634
+ }
635
+ }
636
+
637
+ function sendResizeToAgent(cols, rows) {
638
+ if (agentViewController) {
639
+ agentViewController.sendResizeToAgent(cols, rows);
640
+ }
641
+ }
642
+
643
+ function requestAgentSnapshot() {
644
+ if (agentViewController) {
645
+ agentViewController.requestAgentSnapshot();
646
+ }
647
+ }
648
+
649
+ function writeToAgentTerm(text) {
650
+ if (agentViewController) {
651
+ agentViewController.writeToAgentTerm(text);
652
+ }
653
+ }
1078
654
 
1079
- // Command completion state
1080
- let completionActive = false;
1081
- let completionCommands = [];
1082
- let completionIndex = 0;
1083
- let completionScrollOffset = 0;
1084
-
1085
- const COMMAND_REGISTRY = [
1086
- { cmd: "/doctor", desc: "Health check diagnostics" },
1087
- { cmd: "/status", desc: "Status display" },
1088
- {
1089
- cmd: "/daemon",
1090
- desc: "Daemon management",
1091
- subcommands: [
1092
- { cmd: "start", desc: "Start daemon" },
1093
- { cmd: "stop", desc: "Stop daemon" },
1094
- { cmd: "restart", desc: "Restart daemon" },
1095
- { cmd: "status", desc: "Daemon status" },
1096
- ]
1097
- },
1098
- { cmd: "/init", desc: "Initialize modules" },
1099
- {
1100
- cmd: "/bus",
1101
- desc: "Event bus operations",
1102
- subcommands: [
1103
- { cmd: "send", desc: "Send message to agent" },
1104
- { cmd: "rename", desc: "Rename agent nickname" },
1105
- { cmd: "list", desc: "List all agents" },
1106
- { cmd: "status", desc: "Bus status" },
1107
- ]
1108
- },
1109
- { cmd: "/ctx", desc: "Context management" },
1110
- { cmd: "/skills", desc: "Skills management" },
1111
- { cmd: "/ubus", desc: "Check bus messages" },
1112
- { cmd: "/uctx", desc: "Context status" },
1113
- { cmd: "/uinit", desc: "Initialize/repair" },
1114
- { cmd: "/ustatus", desc: "Unified status" },
1115
- ];
655
+ function placeAgentCursor(cursor) {
656
+ if (agentViewController) {
657
+ agentViewController.placeAgentCursor(cursor);
658
+ }
659
+ }
1116
660
 
1117
- // Agent selection state
1118
- let activeAgents = [];
1119
- let activeAgentLabelMap = new Map();
1120
- let agentListWindowStart = 0;
1121
- const MAX_AGENT_WINDOW = 5;
1122
- let selectedAgentIndex = -1; // -1 = not in dashboard selection mode
1123
- let targetAgent = null; // Selected agent for direct messaging
1124
- let focusMode = "input"; // "input" or "dashboard"
1125
- let dashboardView = "agents"; // "agents" or "mode"
1126
- let selectedModeIndex = launchMode === "internal" ? 1 : 0;
1127
- const providerOptions = [
1128
- { label: "codex", value: "codex-cli" },
1129
- { label: "claude", value: "claude-cli" },
1130
- ];
1131
- let selectedProviderIndex = agentProvider === "claude-cli" ? 1 : 0;
1132
- let restartInProgress = false;
661
+ function handleResizeInAgentView() {
662
+ if (!agentViewController) return false;
663
+ return agentViewController.handleResizeInAgentView();
664
+ }
1133
665
 
1134
666
  function getAgentLabel(agentId) {
1135
- return activeAgentLabelMap.get(agentId) || agentId;
667
+ return agentDirectory.getAgentLabel(activeAgentLabelMap, agentId);
668
+ }
669
+
670
+ function resolveAgentId(label) {
671
+ return agentDirectory.resolveAgentId({
672
+ label,
673
+ activeAgents,
674
+ labelMap: activeAgentLabelMap,
675
+ lookupNickname: (nickname) => {
676
+ try {
677
+ const busPath = getUfooPaths(projectRoot).agentsFile;
678
+ const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
679
+ for (const [id, meta] of Object.entries(bus.agents || {})) {
680
+ if (meta && meta.nickname === nickname) return id;
681
+ }
682
+ } catch {
683
+ // ignore lookup errors
684
+ }
685
+ return null;
686
+ },
687
+ });
688
+ }
689
+
690
+ function resolveAgentDisplayName(publisher) {
691
+ return agentDirectory.resolveAgentDisplayName({
692
+ publisher,
693
+ labelMap: activeAgentLabelMap,
694
+ lookupNicknameById: (id) => {
695
+ try {
696
+ const busPath = getUfooPaths(projectRoot).agentsFile;
697
+ const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
698
+ const meta = bus.agents && bus.agents[id];
699
+ if (meta && meta.nickname) return meta.nickname;
700
+ } catch {
701
+ // Keep original publisher ID
702
+ }
703
+ return null;
704
+ },
705
+ });
706
+ }
707
+
708
+ function clampAgentWindowWithSelection(selectionIndex) {
709
+ agentListWindowStart = agentDirectory.clampAgentWindowWithSelection({
710
+ activeCount: activeAgents.length,
711
+ maxWindow: MAX_AGENT_WINDOW,
712
+ windowStart: agentListWindowStart,
713
+ selectionIndex,
714
+ });
1136
715
  }
1137
716
 
1138
717
  function clampAgentWindow() {
1139
- if (activeAgents.length === 0) {
1140
- agentListWindowStart = 0;
1141
- return;
1142
- }
1143
- const maxItems = Math.max(1, Math.min(MAX_AGENT_WINDOW, activeAgents.length));
1144
- if (selectedAgentIndex >= 0) {
1145
- if (selectedAgentIndex < agentListWindowStart) {
1146
- agentListWindowStart = selectedAgentIndex;
1147
- } else if (selectedAgentIndex >= agentListWindowStart + maxItems) {
1148
- agentListWindowStart = selectedAgentIndex - maxItems + 1;
1149
- }
1150
- }
1151
- const maxStart = Math.max(0, activeAgents.length - maxItems);
1152
- if (agentListWindowStart > maxStart) agentListWindowStart = maxStart;
1153
- if (agentListWindowStart < 0) agentListWindowStart = 0;
718
+ clampAgentWindowWithSelection(selectedAgentIndex);
1154
719
  }
1155
720
 
1156
721
  function send(req) {
1157
- if (!client || client.destroyed) return;
1158
- client.write(`${JSON.stringify(req)}\n`);
722
+ if (!daemonCoordinator) return;
723
+ daemonCoordinator.send(req);
1159
724
  }
1160
725
 
726
+ cronScheduler = createCronScheduler({
727
+ dispatch: ({ taskId, target, message }) => {
728
+ send({
729
+ type: IPC_REQUEST_TYPES.BUS_SEND,
730
+ target,
731
+ message,
732
+ });
733
+ queueStatusLine(`cron:${taskId} -> ${target}`);
734
+ },
735
+ onChange: () => {
736
+ renderDashboard();
737
+ screen.render();
738
+ },
739
+ });
740
+
1161
741
  function updatePromptBox() {
1162
742
  if (targetAgent) {
1163
743
  const label = getAgentLabel(targetAgent);
1164
- promptBox.setContent(`@${label}>`);
1165
- promptBox.width = label.length + 3; // @name>
744
+ promptBox.setContent(`>@${label}`);
745
+ promptBox.width = label.length + 3; // >@name + spacer
1166
746
  input.left = promptBox.width;
1167
747
  input.width = `100%-${promptBox.width}`;
1168
748
  } else {
@@ -1171,8 +751,34 @@ async function runChat(projectRoot) {
1171
751
  input.left = 2;
1172
752
  input.width = "100%-2";
1173
753
  }
754
+ if (!input.parent || !promptBox.parent) return;
1174
755
  resizeInput();
1175
- input._updateCursor();
756
+ if (typeof input._updateCursor === "function") {
757
+ input._updateCursor();
758
+ }
759
+ }
760
+
761
+ function syncTargetFromSelection() {
762
+ if (focusMode !== "dashboard" || dashboardView !== "agents") return;
763
+ if (selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
764
+ const nextTarget = activeAgents[selectedAgentIndex];
765
+ if (nextTarget !== targetAgent) {
766
+ targetAgent = nextTarget;
767
+ updatePromptBox();
768
+ screen.render();
769
+ }
770
+ } else if (targetAgent) {
771
+ targetAgent = null;
772
+ updatePromptBox();
773
+ screen.render();
774
+ }
775
+ }
776
+
777
+ function restoreTargetFromSelection() {
778
+ if (selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
779
+ targetAgent = activeAgents[selectedAgentIndex];
780
+ updatePromptBox();
781
+ }
1176
782
  }
1177
783
 
1178
784
  function focusInput() {
@@ -1190,60 +796,90 @@ async function runChat(projectRoot) {
1190
796
  screen.render();
1191
797
  }
1192
798
 
799
+ let settingsController = null;
800
+
1193
801
  function setLaunchMode(mode) {
1194
- const next = normalizeLaunchMode(mode);
1195
- if (next === launchMode) return;
1196
- launchMode = next;
1197
- selectedModeIndex = launchMode === "internal" ? 1 : 0;
1198
- saveConfig(projectRoot, { launchMode });
1199
- logMessage("status", `{magenta-fg}⚙{/magenta-fg} Launch mode: ${launchMode}`);
1200
- renderDashboard();
1201
- screen.render();
802
+ if (settingsController) {
803
+ settingsController.setLaunchMode(mode);
804
+ }
1202
805
  }
1203
806
 
1204
- function providerLabel(value) {
1205
- return value === "claude-cli" ? "claude" : "codex";
807
+ function requestCloseAgent(agentId) {
808
+ if (!agentId) {
809
+ logMessage("error", "{white-fg}✗{/white-fg} No agent selected");
810
+ return;
811
+ }
812
+ const label = getAgentLabel(agentId);
813
+ logMessage("status", `{white-fg}⚙{/white-fg} Closing ${label}...`);
814
+ send({ type: IPC_REQUEST_TYPES.CLOSE_AGENT, agent_id: agentId });
1206
815
  }
1207
816
 
1208
817
  function setAgentProvider(provider) {
1209
- const next = normalizeAgentProvider(provider);
1210
- if (next === agentProvider) return;
1211
- agentProvider = next;
1212
- selectedProviderIndex = agentProvider === "claude-cli" ? 1 : 0;
1213
- saveConfig(projectRoot, { agentProvider });
1214
- logMessage("status", `{magenta-fg}⚙{/magenta-fg} ufoo-agent: ${providerLabel(agentProvider)}`);
1215
- renderDashboard();
1216
- screen.render();
1217
- void restartDaemon();
818
+ if (settingsController) {
819
+ settingsController.setAgentProvider(provider);
820
+ }
1218
821
  }
1219
822
 
1220
- async function restartDaemon() {
1221
- if (restartInProgress) return;
1222
- restartInProgress = true;
1223
- logMessage("status", "{magenta-fg}⚙{/magenta-fg} Restarting daemon...");
1224
- try {
1225
- if (client) {
1226
- client.removeAllListeners();
1227
- try {
1228
- client.end();
1229
- } catch {
1230
- // ignore
1231
- }
1232
- }
1233
- stopDaemon(projectRoot);
1234
- startDaemon(projectRoot);
1235
- const newClient = await connectClient();
1236
- if (newClient) {
1237
- attachClient(newClient);
1238
- logMessage("status", "{green-fg}✓{/green-fg} Daemon reconnected");
1239
- } else {
1240
- logMessage("error", "{red-fg}✗{/red-fg} Failed to reconnect to daemon");
1241
- }
1242
- } finally {
1243
- restartInProgress = false;
823
+ function setAssistantEngine(value) {
824
+ if (settingsController) {
825
+ settingsController.setAssistantEngine(value);
1244
826
  }
1245
827
  }
1246
828
 
829
+ function setAutoResume(value) {
830
+ if (settingsController) {
831
+ settingsController.setAutoResume(value);
832
+ }
833
+ }
834
+
835
+ async function restartDaemon() {
836
+ if (!daemonCoordinator) return;
837
+ return daemonCoordinator.restart();
838
+ }
839
+
840
+ settingsController = createSettingsController({
841
+ projectRoot,
842
+ saveConfig,
843
+ normalizeLaunchMode,
844
+ normalizeAgentProvider,
845
+ normalizeAssistantEngine,
846
+ fsModule: fs,
847
+ getUfooPaths,
848
+ logMessage,
849
+ renderDashboard,
850
+ renderScreen: () => screen.render(),
851
+ restartDaemon,
852
+ getLaunchMode: () => launchMode,
853
+ setLaunchModeState: (value) => {
854
+ launchMode = value;
855
+ },
856
+ setSelectedModeIndex: (value) => {
857
+ selectedModeIndex = value;
858
+ },
859
+ getAgentProvider: () => agentProvider,
860
+ setAgentProviderState: (value) => {
861
+ agentProvider = value;
862
+ },
863
+ setSelectedProviderIndex: (value) => {
864
+ selectedProviderIndex = value;
865
+ },
866
+ getAssistantEngine: () => assistantEngine,
867
+ setAssistantEngineState: (value) => {
868
+ assistantEngine = value;
869
+ },
870
+ setSelectedAssistantIndex: (value) => {
871
+ selectedAssistantIndex = value;
872
+ },
873
+ assistantOptions,
874
+ getAutoResume: () => autoResume,
875
+ setAutoResumeState: (value) => {
876
+ autoResume = value;
877
+ },
878
+ setSelectedResumeIndex: (value) => {
879
+ selectedResumeIndex = value;
880
+ },
881
+ });
882
+
1247
883
  function clearLog() {
1248
884
  logBox.setContent("");
1249
885
  if (typeof logBox.scrollTo === "function") {
@@ -1253,89 +889,76 @@ async function runChat(projectRoot) {
1253
889
  }
1254
890
 
1255
891
  function renderDashboard() {
1256
- let content = " ";
1257
- if (focusMode === "dashboard") {
1258
- if (dashboardView === "mode") {
1259
- const modes = ["terminal", "internal"];
1260
- const modeParts = modes.map((mode, i) => {
1261
- if (i === selectedModeIndex) {
1262
- return `{inverse}${mode}{/inverse}`;
1263
- }
1264
- return `{cyan-fg}${mode}{/cyan-fg}`;
1265
- });
1266
- content += `{gray-fg}Mode:{/gray-fg} ${modeParts.join(" ")}`;
1267
- content += " {gray-fg}│ ←/→ select, Enter confirm, ↓ agent, ↑ back{/gray-fg}";
1268
- } else if (dashboardView === "provider") {
1269
- const providerParts = providerOptions.map((opt, i) => {
1270
- if (i === selectedProviderIndex) {
1271
- return `{inverse}${opt.label}{/inverse}`;
1272
- }
1273
- return `{cyan-fg}${opt.label}{/cyan-fg}`;
1274
- });
1275
- content += `{gray-fg}Agent:{/gray-fg} ${providerParts.join(" ")}`;
1276
- content += " {gray-fg}│ ←/→ select, Enter confirm, ↑ back{/gray-fg}";
1277
- } else {
1278
- if (activeAgents.length > 0) {
1279
- clampAgentWindow();
1280
- const maxItems = Math.max(1, Math.min(MAX_AGENT_WINDOW, activeAgents.length));
1281
- const start = agentListWindowStart;
1282
- const end = start + maxItems;
1283
- const visibleAgents = activeAgents.slice(start, end);
1284
- const agentParts = visibleAgents.map((agent, i) => {
1285
- const absoluteIndex = start + i;
1286
- const label = getAgentLabel(agent);
1287
- if (absoluteIndex === selectedAgentIndex) {
1288
- return `{inverse}${label}{/inverse}`;
1289
- }
1290
- return `{cyan-fg}${label}{/cyan-fg}`;
1291
- });
1292
- const leftMore = start > 0 ? "{gray-fg}«{/gray-fg} " : "";
1293
- const rightMore = end < activeAgents.length ? " {gray-fg}»{/gray-fg}" : "";
1294
- content += `{gray-fg}Agents:{/gray-fg} ${agentParts.join(" ")}`;
1295
- content = `${content.replace("{gray-fg}Agents:{/gray-fg} ", `{gray-fg}Agents:{/gray-fg} ${leftMore}`)}${rightMore}`;
1296
- content += " {gray-fg}│ ←/→ select, Enter confirm, ↓ mode, ↑ back{/gray-fg}";
1297
- } else {
1298
- content += "{gray-fg}Agents:{/gray-fg} {cyan-fg}none{/cyan-fg}";
1299
- content += " {gray-fg}│ ↓ mode, ↑ back{/gray-fg}";
1300
- }
1301
- }
1302
- } else {
1303
- // Normal dashboard display (input mode)
1304
- const agents = activeAgents.length > 0
1305
- ? activeAgents.slice(0, 3).map((id) => getAgentLabel(id)).join(", ") + (activeAgents.length > 3 ? ` +${activeAgents.length - 3}` : "")
1306
- : "none";
1307
- content += `{gray-fg}Agents:{/gray-fg} {cyan-fg}${agents}{/cyan-fg}`;
1308
- content += ` {gray-fg}Mode:{/gray-fg} {cyan-fg}${launchMode}{/cyan-fg}`;
1309
- content += ` {gray-fg}Agent:{/gray-fg} {cyan-fg}${providerLabel(agentProvider)}{/cyan-fg}`;
1310
- }
1311
- dashboard.setContent(content);
892
+ const computed = computeDashboardContent({
893
+ focusMode,
894
+ dashboardView,
895
+ activeAgents,
896
+ selectedAgentIndex,
897
+ agentListWindowStart,
898
+ maxAgentWindow: MAX_AGENT_WINDOW,
899
+ getAgentLabel,
900
+ launchMode,
901
+ agentProvider,
902
+ assistantEngine,
903
+ autoResume,
904
+ selectedModeIndex,
905
+ selectedProviderIndex,
906
+ selectedAssistantIndex,
907
+ selectedResumeIndex,
908
+ cronTasks: cronScheduler.listTasks(),
909
+ providerOptions,
910
+ assistantOptions,
911
+ resumeOptions,
912
+ pendingReports: reportPendingTotal,
913
+ dashHints: DASH_HINTS,
914
+ });
915
+ agentListWindowStart = computed.windowStart;
916
+ dashboard.setContent(computed.content);
1312
917
  }
1313
918
 
1314
919
  function updateDashboard(status) {
1315
920
  activeAgents = status.active || [];
921
+ reportPendingTotal = Number.isFinite(status?.reports?.pending_total)
922
+ ? status.reports.pending_total
923
+ : 0;
1316
924
  const metaList = Array.isArray(status.active_meta) ? status.active_meta : [];
1317
- activeAgentLabelMap = new Map();
1318
925
  let fallbackMap = null;
1319
926
  if (metaList.length === 0 && activeAgents.length > 0) {
1320
927
  try {
1321
- const busPath = path.join(projectRoot, ".ufoo", "bus", "bus.json");
928
+ const busPath = getUfooPaths(projectRoot).agentsFile;
1322
929
  const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
1323
930
  fallbackMap = new Map();
1324
- for (const [id, meta] of Object.entries(bus.subscribers || {})) {
931
+ for (const [id, meta] of Object.entries(bus.agents || {})) {
1325
932
  if (meta && meta.nickname) fallbackMap.set(id, meta.nickname);
1326
933
  }
1327
934
  } catch {
1328
935
  fallbackMap = null;
1329
936
  }
1330
937
  }
1331
- for (const id of activeAgents) {
1332
- const meta = metaList.find((item) => item && item.id === id);
1333
- const label = meta && meta.nickname
1334
- ? meta.nickname
1335
- : (fallbackMap && fallbackMap.get(id)) || id;
1336
- activeAgentLabelMap.set(id, label);
1337
- }
938
+ const maps = agentDirectory.buildAgentMaps(activeAgents, metaList, fallbackMap);
939
+ activeAgentLabelMap = maps.labelMap;
940
+ activeAgentMetaMap = maps.metaMap;
1338
941
  clampAgentWindow();
942
+ // If viewing agent went offline, exit view
943
+ const currentView = getCurrentView();
944
+ const viewingAgent = getViewingAgent();
945
+ if (currentView === "agent" && viewingAgent && !activeAgents.includes(viewingAgent)) {
946
+ writeToAgentTerm("\r\n\x1b[1;31m[Agent went offline]\x1b[0m\r\n");
947
+ exitAgentView();
948
+ return;
949
+ }
950
+
951
+ // In agent view, only update the dashboard bar (blessed is frozen)
952
+ if (currentView === "agent") {
953
+ if (focusMode === "dashboard") {
954
+ const totalItems = 1 + activeAgents.length;
955
+ if (selectedAgentIndex < 0 || selectedAgentIndex >= totalItems) {
956
+ selectedAgentIndex = 0;
957
+ }
958
+ }
959
+ renderAgentDashboard();
960
+ return;
961
+ }
1339
962
  if (focusMode === "dashboard") {
1340
963
  if (dashboardView === "agents") {
1341
964
  if (activeAgents.length === 0) {
@@ -1346,6 +969,7 @@ async function runChat(projectRoot) {
1346
969
  clampAgentWindow();
1347
970
  }
1348
971
  }
972
+ syncTargetFromSelection();
1349
973
  renderDashboard();
1350
974
  screen.render();
1351
975
  }
@@ -1356,120 +980,96 @@ async function runChat(projectRoot) {
1356
980
  selectedAgentIndex = activeAgents.length > 0 ? 0 : -1;
1357
981
  agentListWindowStart = 0;
1358
982
  clampAgentWindow();
1359
- selectedModeIndex = launchMode === "internal" ? 1 : 0;
983
+ selectedModeIndex = launchMode === "internal" ? 2 : (launchMode === "tmux" ? 1 : 0);
1360
984
  selectedProviderIndex = agentProvider === "claude-cli" ? 1 : 0;
985
+ selectedAssistantIndex = Math.max(
986
+ 0,
987
+ assistantOptions.findIndex((opt) => opt.value === assistantEngine)
988
+ );
989
+ selectedResumeIndex = autoResume ? 0 : 1;
990
+ // Immediately set @target when first agent is selected
991
+ if (selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
992
+ targetAgent = activeAgents[selectedAgentIndex];
993
+ updatePromptBox();
994
+ }
1361
995
  screen.grabKeys = true;
1362
996
  renderDashboard();
1363
997
  screen.program.hideCursor();
1364
998
  screen.render();
999
+ syncTargetFromSelection();
1365
1000
  }
1366
1001
 
1367
- function handleDashboardKey(key) {
1368
- if (!key || focusMode !== "dashboard") return false;
1369
- if (dashboardView === "mode") {
1370
- if (key.name === "left") {
1371
- selectedModeIndex = selectedModeIndex <= 0 ? 1 : 0;
1372
- renderDashboard();
1373
- screen.render();
1374
- return true;
1375
- }
1376
- if (key.name === "right") {
1377
- selectedModeIndex = selectedModeIndex >= 1 ? 0 : 1;
1378
- renderDashboard();
1379
- screen.render();
1380
- return true;
1381
- }
1382
- if (key.name === "down") {
1383
- dashboardView = "provider";
1384
- selectedProviderIndex = agentProvider === "claude-cli" ? 1 : 0;
1385
- renderDashboard();
1386
- screen.render();
1387
- return true;
1388
- }
1389
- if (key.name === "up") {
1390
- dashboardView = "agents";
1391
- renderDashboard();
1392
- screen.render();
1393
- return true;
1394
- }
1395
- if (key.name === "enter" || key.name === "return") {
1396
- const modes = ["terminal", "internal"];
1397
- setLaunchMode(modes[selectedModeIndex]);
1398
- exitDashboardMode(false);
1399
- return true;
1400
- }
1401
- if (key.name === "escape") {
1402
- exitDashboardMode(false);
1403
- return true;
1404
- }
1405
- return true;
1406
- }
1407
- if (dashboardView === "provider") {
1408
- if (key.name === "left") {
1409
- selectedProviderIndex = selectedProviderIndex <= 0 ? providerOptions.length - 1 : selectedProviderIndex - 1;
1410
- renderDashboard();
1411
- screen.render();
1412
- return true;
1413
- }
1414
- if (key.name === "right") {
1415
- selectedProviderIndex = selectedProviderIndex >= providerOptions.length - 1 ? 0 : selectedProviderIndex + 1;
1416
- renderDashboard();
1417
- screen.render();
1418
- return true;
1419
- }
1420
- if (key.name === "up") {
1421
- dashboardView = "mode";
1422
- renderDashboard();
1423
- screen.render();
1424
- return true;
1425
- }
1426
- if (key.name === "enter" || key.name === "return") {
1427
- const selected = providerOptions[selectedProviderIndex];
1428
- if (selected) setAgentProvider(selected.value);
1429
- exitDashboardMode(false);
1430
- return true;
1431
- }
1432
- if (key.name === "escape") {
1433
- exitDashboardMode(false);
1434
- return true;
1435
- }
1436
- return true;
1437
- }
1002
+ const dashboardState = {};
1003
+ Object.defineProperties(dashboardState, {
1004
+ currentView: { get: () => getCurrentView() },
1005
+ focusMode: { get: () => focusMode, set: (value) => { focusMode = value; } },
1006
+ dashboardView: { get: () => dashboardView, set: (value) => { dashboardView = value; } },
1007
+ selectedAgentIndex: { get: () => selectedAgentIndex, set: (value) => { selectedAgentIndex = value; } },
1008
+ activeAgents: { get: () => activeAgents },
1009
+ viewingAgent: { get: () => getViewingAgent() },
1010
+ activeAgentMetaMap: { get: () => activeAgentMetaMap },
1011
+ selectedModeIndex: { get: () => selectedModeIndex, set: (value) => { selectedModeIndex = value; } },
1012
+ selectedProviderIndex: { get: () => selectedProviderIndex, set: (value) => { selectedProviderIndex = value; } },
1013
+ selectedAssistantIndex: { get: () => selectedAssistantIndex, set: (value) => { selectedAssistantIndex = value; } },
1014
+ selectedResumeIndex: { get: () => selectedResumeIndex, set: (value) => { selectedResumeIndex = value; } },
1015
+ launchMode: { get: () => launchMode },
1016
+ agentProvider: { get: () => agentProvider },
1017
+ assistantEngine: { get: () => assistantEngine },
1018
+ autoResume: { get: () => autoResume },
1019
+ cronTasks: { get: () => cronScheduler.listTasks() },
1020
+ providerOptions: { get: () => providerOptions },
1021
+ assistantOptions: { get: () => assistantOptions },
1022
+ resumeOptions: { get: () => resumeOptions },
1023
+ agentOutputSuppressed: {
1024
+ get: () => getAgentOutputSuppressed(),
1025
+ set: (value) => { setAgentOutputSuppressed(value); },
1026
+ },
1027
+ });
1438
1028
 
1439
- if (key.name === "left") {
1440
- if (activeAgents.length > 0 && selectedAgentIndex > 0) {
1441
- selectedAgentIndex--;
1442
- clampAgentWindow();
1443
- renderDashboard();
1444
- screen.render();
1445
- }
1446
- return true;
1447
- }
1448
- if (key.name === "right") {
1449
- if (activeAgents.length > 0 && selectedAgentIndex < activeAgents.length - 1) {
1450
- selectedAgentIndex++;
1451
- clampAgentWindow();
1452
- renderDashboard();
1453
- screen.render();
1454
- }
1455
- return true;
1456
- }
1457
- if (key.name === "down") {
1458
- dashboardView = "mode";
1459
- selectedModeIndex = launchMode === "internal" ? 1 : 0;
1460
- renderDashboard();
1461
- screen.render();
1462
- return true;
1463
- }
1464
- if (key.name === "up" || key.name === "escape") {
1465
- exitDashboardMode(false);
1466
- return true;
1467
- }
1468
- if (key.name === "enter" || key.name === "return") {
1469
- exitDashboardMode(true);
1470
- return true;
1471
- }
1472
- return false;
1029
+ function activateAgent(agentId) {
1030
+ if (!agentId) return;
1031
+ const activator = new AgentActivator(projectRoot);
1032
+ activator.activate(agentId).catch(() => {});
1033
+ }
1034
+
1035
+ terminalAdapterRouter = createTerminalAdapterRouter({
1036
+ activateAgent,
1037
+ sendRaw: (data) => agentSockets.sendRaw(data),
1038
+ sendResize: (cols, rows) => agentSockets.sendResize(cols, rows),
1039
+ requestSnapshot: (mode) => agentSockets.requestSnapshot(mode),
1040
+ });
1041
+
1042
+ const dashboardController = createDashboardKeyController({
1043
+ state: dashboardState,
1044
+ existsSync: fs.existsSync,
1045
+ getInjectSockPath,
1046
+ getAgentAdapter,
1047
+ activateAgent,
1048
+ requestCloseAgent,
1049
+ enterAgentView,
1050
+ exitAgentView,
1051
+ setAgentBarVisible,
1052
+ requestAgentSnapshot,
1053
+ clearTargetAgent,
1054
+ restoreTargetFromSelection,
1055
+ syncTargetFromSelection,
1056
+ exitDashboardMode,
1057
+ setLaunchMode,
1058
+ setAgentProvider,
1059
+ setAssistantEngine,
1060
+ setAutoResume,
1061
+ clampAgentWindow,
1062
+ clampAgentWindowWithSelection,
1063
+ renderDashboard,
1064
+ renderAgentDashboard,
1065
+ renderScreen: () => screen.render(),
1066
+ setScreenGrabKeys: (value) => {
1067
+ screen.grabKeys = Boolean(value);
1068
+ },
1069
+ });
1070
+
1071
+ function handleDashboardKey(key) {
1072
+ return dashboardController.handleDashboardKey(key);
1473
1073
  }
1474
1074
 
1475
1075
  function exitDashboardMode(selectAgent = false) {
@@ -1492,199 +1092,240 @@ async function runChat(projectRoot) {
1492
1092
  screen.render();
1493
1093
  }
1494
1094
 
1495
- function requestStatus() {
1496
- send({ type: "status" });
1095
+ function getInjectSockPath(agentId) {
1096
+ const safeName = subscriberToSafeName(agentId);
1097
+ return path.join(getUfooPaths(projectRoot).busQueuesDir, safeName, "inject.sock");
1497
1098
  }
1498
1099
 
1499
- const detachClient = () => {
1500
- if (!client) return;
1501
- client.removeAllListeners("data");
1502
- client.removeAllListeners("close");
1503
- try {
1504
- client.end();
1505
- client.destroy();
1506
- } catch {
1507
- // ignore
1508
- }
1509
- };
1100
+ agentViewController = createAgentViewController({
1101
+ screen,
1102
+ input,
1103
+ processStdout: process.stdout,
1104
+ computeAgentBar,
1105
+ agentBarHints: AGENT_BAR_HINTS,
1106
+ maxAgentWindow: MAX_AGENT_WINDOW,
1107
+ getFocusMode: () => focusMode,
1108
+ setFocusMode: (value) => {
1109
+ focusMode = value;
1110
+ },
1111
+ getSelectedAgentIndex: () => selectedAgentIndex,
1112
+ setSelectedAgentIndex: (value) => {
1113
+ selectedAgentIndex = value;
1114
+ },
1115
+ getActiveAgents: () => activeAgents,
1116
+ getAgentListWindowStart: () => agentListWindowStart,
1117
+ setAgentListWindowStart: (value) => {
1118
+ agentListWindowStart = value;
1119
+ },
1120
+ getAgentLabel,
1121
+ setDashboardView: (value) => {
1122
+ dashboardView = value;
1123
+ },
1124
+ setScreenGrabKeys: (value) => {
1125
+ screen.grabKeys = Boolean(value);
1126
+ },
1127
+ clearTargetAgent,
1128
+ renderDashboard,
1129
+ focusInput,
1130
+ resizeInput,
1131
+ renderScreen: () => screen.render(),
1132
+ getInjectSockPath,
1133
+ connectAgentOutput: (sockPath) => {
1134
+ agentSockets.connectOutput(sockPath);
1135
+ },
1136
+ disconnectAgentOutput: () => {
1137
+ agentSockets.disconnectOutput();
1138
+ },
1139
+ connectAgentInput: (sockPath) => {
1140
+ agentSockets.connectInput(sockPath);
1141
+ },
1142
+ disconnectAgentInput: () => {
1143
+ agentSockets.disconnectInput();
1144
+ },
1145
+ sendRaw: (data) => {
1146
+ sendRawWithCapabilities(data);
1147
+ },
1148
+ sendResize: (cols, rows) => {
1149
+ sendResizeWithCapabilities(cols, rows);
1150
+ },
1151
+ requestScreenSnapshot: () => {
1152
+ requestSnapshotWithCapabilities();
1153
+ },
1154
+ });
1510
1155
 
1511
- const attachClient = (newClient) => {
1512
- if (!newClient) return;
1513
- detachClient();
1514
- client = newClient;
1515
- let buffer = "";
1516
- client.on("data", (data) => {
1517
- buffer += data.toString("utf8");
1518
- const lines = buffer.split(/\r?\n/);
1519
- buffer = lines.pop() || "";
1520
- for (const line of lines.filter((l) => l.trim())) {
1521
- try {
1522
- const msg = JSON.parse(line);
1523
- if (msg.type === "status") {
1524
- const data = msg.data || {};
1525
- if (typeof data.phase === "string") {
1526
- const text = data.text || "";
1527
- const item = { key: data.key, text };
1528
- if (data.phase === "start") {
1529
- enqueueBusStatus(item);
1530
- } else if (data.phase === "done" || data.phase === "error") {
1531
- resolveBusStatus(item);
1532
- if (text) {
1533
- const prefix = data.phase === "error"
1534
- ? "{red-fg}✗{/red-fg}"
1535
- : "{green-fg}✓{/green-fg}";
1536
- logMessage("status", `${prefix} ${text}`, data);
1537
- }
1538
- } else {
1539
- enqueueBusStatus(item);
1540
- }
1541
- screen.render();
1542
- } else {
1543
- updateDashboard(data);
1544
- }
1545
- } else if (msg.type === "response") {
1546
- const payload = msg.data || {};
1547
- if (payload.reply) {
1548
- resolveStatusLine(`{green-fg}←{/green-fg} ${payload.reply}`);
1549
- logMessage("reply", `{green-fg}←{/green-fg} ${payload.reply}`);
1550
- }
1551
- if (payload.dispatch && payload.dispatch.length > 0) {
1552
- logMessage("dispatch", `{blue-fg}→{/blue-fg} Dispatched to: ${payload.dispatch.map(d => d.target || d).join(", ")}`);
1553
- }
1554
- if (payload.disambiguate && Array.isArray(payload.disambiguate.candidates) && payload.disambiguate.candidates.length > 0) {
1555
- pending = { disambiguate: payload.disambiguate, original: pending?.original };
1556
- resolveStatusLine(`{yellow-fg}?{/yellow-fg} ${payload.disambiguate.prompt || "Choose target:"}`);
1557
- logMessage("disambiguate", `{yellow-fg}?{/yellow-fg} ${payload.disambiguate.prompt || "Choose target:"}`);
1558
- payload.disambiguate.candidates.forEach((c, i) => {
1559
- logMessage("disambiguate", ` {cyan-fg}${i + 1}){/cyan-fg} ${c.agent_id} {gray-fg}— ${c.reason || ""}{/gray-fg}`);
1560
- });
1561
- } else {
1562
- pending = null;
1563
- }
1564
- if (!payload.reply && !payload.disambiguate) {
1565
- resolveStatusLine("{gray-fg}✓{/gray-fg} Done");
1566
- }
1567
- if (msg.opsResults && msg.opsResults.length > 0) {
1568
- logMessage("ops", `{magenta-fg}⚡{/magenta-fg} ${JSON.stringify(msg.opsResults)}`);
1569
- }
1570
- screen.render();
1571
- } else if (msg.type === "bus") {
1572
- const data = msg.data || {};
1573
- const prefix = data.event === "broadcast" ? "{magenta-fg}⇢{/magenta-fg}" : "{blue-fg}↔{/blue-fg}";
1574
- let publisher = data.publisher && data.publisher !== "unknown"
1575
- ? data.publisher
1576
- : (data.event === "broadcast" ? "broadcast" : "bus");
1577
-
1578
- // Try to parse message as JSON (from internal agents)
1579
- let displayMessage = data.message || "";
1580
- try {
1581
- const parsed = JSON.parse(data.message);
1582
- if (parsed && typeof parsed === "object" && parsed.reply) {
1583
- displayMessage = parsed.reply;
1584
- }
1585
- } catch {
1586
- // Not JSON, use as-is
1587
- }
1156
+ function requestStatus() {
1157
+ if (!daemonCoordinator) return;
1158
+ daemonCoordinator.requestStatus();
1159
+ }
1588
1160
 
1589
- // Extract nickname if publisher is in subscriber:id format
1590
- let displayName = publisher;
1591
- if (publisher.includes(":")) {
1592
- // Try to get nickname from activeAgentLabelMap or bus.json
1593
- if (activeAgentLabelMap && activeAgentLabelMap.has(publisher)) {
1594
- displayName = activeAgentLabelMap.get(publisher);
1595
- } else {
1596
- // Fallback: read directly from bus.json
1597
- try {
1598
- const busPath = path.join(projectRoot, ".ufoo", "bus", "bus.json");
1599
- const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
1600
- const meta = bus.subscribers && bus.subscribers[publisher];
1601
- if (meta && meta.nickname) {
1602
- displayName = meta.nickname;
1603
- }
1604
- } catch {
1605
- // Keep original publisher ID
1606
- }
1607
- }
1608
- }
1161
+ const daemonMessageRouter = createDaemonMessageRouter({
1162
+ escapeBlessed,
1163
+ stripBlessedTags,
1164
+ logMessage,
1165
+ renderScreen: () => screen.render(),
1166
+ updateDashboard,
1167
+ requestStatus,
1168
+ resolveStatusLine,
1169
+ enqueueBusStatus,
1170
+ resolveBusStatus,
1171
+ getPending: () => pending,
1172
+ setPending: (value) => {
1173
+ pending = value;
1174
+ },
1175
+ resolveAgentDisplayName,
1176
+ getCurrentView: () => getCurrentView(),
1177
+ isAgentViewUsesBus: () => isAgentViewUsesBus(),
1178
+ getViewingAgent: () => getViewingAgent(),
1179
+ writeToAgentTerm,
1180
+ consumePendingDelivery,
1181
+ getPendingState,
1182
+ beginStream,
1183
+ appendStreamDelta,
1184
+ finalizeStream,
1185
+ hasStream: (publisher) => streamTracker.hasStream(publisher),
1186
+ });
1609
1187
 
1610
- const line = `${prefix} {gray-fg}${displayName}{/gray-fg}: ${displayMessage}`;
1611
- logMessage("bus", line, data);
1612
- if (data.event === "agent_renamed") {
1613
- requestStatus();
1614
- }
1615
- screen.render();
1616
- } else if (msg.type === "error") {
1617
- resolveStatusLine(`{red-fg}✗{/red-fg} Error: ${msg.error}`);
1618
- logMessage("error", `{red-fg}✗{/red-fg} Error: ${msg.error}`);
1619
- screen.render();
1620
- }
1621
- } catch {
1622
- // ignore
1623
- }
1188
+ daemonCoordinator = createDaemonCoordinator({
1189
+ projectRoot,
1190
+ daemonTransport,
1191
+ handleMessage: (msg) => daemonMessageRouter.handleMessage(msg),
1192
+ queueStatusLine,
1193
+ resolveStatusLine,
1194
+ logMessage,
1195
+ stopDaemon,
1196
+ startDaemon,
1197
+ });
1198
+
1199
+ const connected = await daemonCoordinator.connect();
1200
+ if (!connected) {
1201
+ // Check if daemon failed to start
1202
+ if (!isRunning(projectRoot)) {
1203
+ const logFile = getUfooPaths(projectRoot).ufooDaemonLog;
1204
+ // eslint-disable-next-line no-console
1205
+ console.error("Failed to start ufoo daemon. Check logs at:", logFile);
1206
+ throw new Error("Daemon failed to start. Check the daemon log for details.");
1624
1207
  }
1208
+ throw new Error("Failed to connect to ufoo daemon (timeout). The daemon may still be starting.");
1209
+ }
1210
+
1211
+ const commandExecutor = createCommandExecutor({
1212
+ projectRoot,
1213
+ parseCommand,
1214
+ escapeBlessed,
1215
+ logMessage,
1216
+ renderScreen: () => screen.render(),
1217
+ getActiveAgents: () => activeAgents,
1218
+ getActiveAgentMetaMap: () => activeAgentMetaMap,
1219
+ getAgentLabel,
1220
+ isDaemonRunning: isRunning,
1221
+ startDaemon,
1222
+ stopDaemon,
1223
+ restartDaemon,
1224
+ send,
1225
+ requestStatus,
1226
+ createCronTask: ({ intervalMs, targets, prompt }) =>
1227
+ cronScheduler.addTask({ intervalMs, targets, prompt }),
1228
+ listCronTasks: () => cronScheduler.listTasks(),
1229
+ stopCronTask: (id) => cronScheduler.stopTask(id),
1230
+ activateAgent: async (target) => {
1231
+ const activator = new AgentActivator(projectRoot);
1232
+ await activator.activate(target);
1233
+ },
1625
1234
  });
1626
- client.on("close", () => {
1627
- client = null;
1628
- });
1629
- };
1630
1235
 
1631
- attachClient(client);
1236
+ async function executeCommand(text) {
1237
+ return commandExecutor.executeCommand(text);
1238
+ }
1632
1239
 
1633
- input.on("submit", (value) => {
1634
- const text = value.trim();
1635
- input.clearValue();
1636
- screen.render();
1637
- if (!text) {
1638
- input.focus();
1639
- return;
1640
- }
1641
- inputHistory.push(text);
1642
- appendInputHistory(text);
1643
- historyIndex = inputHistory.length;
1644
- historyDraft = "";
1240
+ const submitState = {};
1241
+ Object.defineProperties(submitState, {
1242
+ targetAgent: { get: () => targetAgent, set: (value) => { targetAgent = value; } },
1243
+ pending: { get: () => pending, set: (value) => { pending = value; } },
1244
+ activeAgentMetaMap: { get: () => activeAgentMetaMap },
1245
+ });
1645
1246
 
1646
- // If target agent is selected, send directly via bus
1647
- if (targetAgent) {
1648
- const label = getAgentLabel(targetAgent);
1649
- logMessage("user", `{cyan-fg}→{/cyan-fg} {magenta-fg}@${label}{/magenta-fg} ${text}`);
1650
- // Use bus send command
1651
- const { spawnSync } = require("child_process");
1652
- spawnSync("ufoo", ["bus", "send", targetAgent, text], { cwd: projectRoot });
1653
- clearTargetAgent();
1654
- input.focus();
1655
- return;
1656
- }
1247
+ const inputSubmitHandler = createInputSubmitHandler({
1248
+ state: submitState,
1249
+ parseAtTarget,
1250
+ resolveAgentId,
1251
+ executeCommand,
1252
+ queueStatusLine,
1253
+ send,
1254
+ logMessage,
1255
+ getAgentLabel,
1256
+ escapeBlessed,
1257
+ markPendingDelivery,
1258
+ clearTargetAgent,
1259
+ setTargetAgent: (agentId) => {
1260
+ targetAgent = agentId || null;
1261
+ updatePromptBox();
1262
+ screen.render();
1263
+ },
1264
+ enterAgentView,
1265
+ activateAgent: async (agentId) => {
1266
+ const activator = new AgentActivator(projectRoot);
1267
+ await activator.activate(agentId);
1268
+ },
1269
+ getInjectSockPath,
1270
+ existsSync: fs.existsSync,
1271
+ commitInputHistory: (text) => {
1272
+ if (inputHistoryController) inputHistoryController.commitSubmittedText(text);
1273
+ },
1274
+ focusInput: () => input.focus(),
1275
+ renderScreen: () => screen.render(), // Add renderScreen callback
1276
+ });
1657
1277
 
1658
- if (pending && pending.disambiguate) {
1659
- const idx = parseInt(text, 10);
1660
- const choice = pending.disambiguate.candidates[idx - 1];
1661
- if (choice) {
1662
- queueStatusLine(`ufoo-agent processing (assigning ${choice.agent_id})`);
1663
- send({
1664
- type: "prompt",
1665
- text: `Use agent ${choice.agent_id} to handle: ${pending.original || "the request"}`,
1666
- });
1667
- pending = null;
1668
- } else {
1669
- logMessage("error", "Invalid selection.");
1670
- }
1671
- } else {
1672
- pending = { original: text };
1673
- queueStatusLine("ufoo-agent processing");
1674
- send({ type: "prompt", text });
1675
- logMessage("user", `{cyan-fg}→{/cyan-fg} ${text}`);
1676
- }
1677
- input.focus();
1278
+ input.on("submit", async (value) => {
1279
+ input.clearValue();
1280
+ screen.render(); // Render cleared input
1281
+ await inputSubmitHandler.handleSubmit(value);
1282
+ // No need for second render - handleSubmit now calls renderScreen() internally
1678
1283
  });
1679
1284
 
1680
1285
  screen.key(["C-c"], exitHandler);
1681
1286
 
1287
+ // Agent TTY view: enter dashboard mode
1288
+ function enterAgentDashboardMode() {
1289
+ if (agentViewController) {
1290
+ agentViewController.enterAgentDashboardMode();
1291
+ }
1292
+ }
1293
+
1682
1294
  // Dashboard navigation - use screen.on to capture even when input is focused
1683
1295
  screen.on("keypress", (ch, key) => {
1296
+ // Agent TTY view: handle keystrokes
1297
+ if (getCurrentView() === "agent") {
1298
+ if (focusMode === "dashboard") {
1299
+ handleDashboardKey(key);
1300
+ return;
1301
+ }
1302
+ // Suppress input briefly after entering agent view
1303
+ if (Date.now() < getAgentInputSuppressUntil()) {
1304
+ return;
1305
+ }
1306
+ // Ctrl+C exits entire app
1307
+ if (key && key.ctrl && key.name === "c") {
1308
+ return; // handled by screen.key(["C-c"])
1309
+ }
1310
+ // Down arrow: enter agents bar (same pattern as normal chat dashboard)
1311
+ if (key && key.name === "down") {
1312
+ enterAgentDashboardMode();
1313
+ return;
1314
+ }
1315
+ // All other keys (including Esc) go to agent PTY
1316
+ const raw = keyToRaw(ch, key);
1317
+ if (raw) {
1318
+ sendRawToAgent(raw);
1319
+ }
1320
+ return;
1321
+ }
1322
+
1323
+ // Normal mode: dashboard key handling
1684
1324
  handleDashboardKey(key);
1685
1325
  });
1686
1326
 
1687
1327
  screen.key(["tab"], () => {
1328
+ if (getCurrentView() === "agent") return; // Tab goes to PTY via keypress handler
1688
1329
  if (focusMode === "dashboard") {
1689
1330
  exitDashboardMode(false);
1690
1331
  } else {
@@ -1693,10 +1334,13 @@ async function runChat(projectRoot) {
1693
1334
  });
1694
1335
 
1695
1336
  screen.key(["C-k", "M-k"], () => {
1337
+ if (getCurrentView() === "agent") return;
1696
1338
  clearLog();
1697
1339
  });
1698
1340
 
1341
+
1699
1342
  screen.key(["i", "enter"], () => {
1343
+ if (getCurrentView() === "agent") return;
1700
1344
  if (focusMode === "dashboard") return;
1701
1345
  if (screen.focused === input) return;
1702
1346
  focusInput();
@@ -1715,43 +1359,7 @@ async function runChat(projectRoot) {
1715
1359
  }
1716
1360
  if (screen.program) {
1717
1361
  screen.program.on("data", (data) => {
1718
- if (screen.focused !== input || focusMode !== "input") return;
1719
- const chunk = data.toString("utf8");
1720
- if (!pasteActive && !chunk.includes(PASTE_START) && !pasteRemainder.includes(PASTE_START)) {
1721
- const keep = PASTE_START.length - 1;
1722
- pasteRemainder = (pasteRemainder + chunk).slice(-keep);
1723
- return;
1724
- }
1725
- let buffer = pasteRemainder + chunk;
1726
- pasteRemainder = "";
1727
- while (buffer.length > 0) {
1728
- if (!pasteActive) {
1729
- const start = buffer.indexOf(PASTE_START);
1730
- if (start === -1) {
1731
- const keep = PASTE_START.length - 1;
1732
- pasteRemainder = buffer.slice(-keep);
1733
- return;
1734
- }
1735
- buffer = buffer.slice(start + PASTE_START.length);
1736
- pasteActive = true;
1737
- pasteBuffer = "";
1738
- scheduleSuppressReset();
1739
- continue;
1740
- }
1741
- const end = buffer.indexOf(PASTE_END);
1742
- if (end === -1) {
1743
- pasteBuffer += buffer;
1744
- scheduleSuppressReset();
1745
- return;
1746
- }
1747
- pasteBuffer += buffer.slice(0, end);
1748
- buffer = buffer.slice(end + PASTE_END.length);
1749
- pasteActive = false;
1750
- scheduleSuppressReset();
1751
- const normalized = normalizePaste(pasteBuffer);
1752
- pasteBuffer = "";
1753
- if (normalized) insertTextAtCursor(normalized);
1754
- }
1362
+ pasteController.handleProgramData(data);
1755
1363
  });
1756
1364
  }
1757
1365
  loadHistory();
@@ -1759,11 +1367,22 @@ async function runChat(projectRoot) {
1759
1367
  renderDashboard();
1760
1368
  resizeInput();
1761
1369
  requestStatus();
1762
- setInterval(requestStatus, 2000);
1370
+
1371
+ // 定期刷新 dashboard 状态(兜底,daemon 会主动推送变化)
1372
+ setInterval(() => {
1373
+ if (daemonCoordinator && daemonCoordinator.isConnected()) {
1374
+ requestStatus();
1375
+ }
1376
+ }, 30000);
1763
1377
  screen.on("resize", () => {
1378
+ if (handleResizeInAgentView()) {
1379
+ return;
1380
+ }
1764
1381
  resizeInput();
1765
- if (completionActive) hideCompletion();
1382
+ if (completionController.isActive()) completionController.hide();
1766
1383
  input._updateCursor();
1384
+ // Force recalculate logBox width to match terminal
1385
+ logBox.width = screen.width;
1767
1386
  screen.render();
1768
1387
  });
1769
1388
  screen.render();