u-foo 1.0.6 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/README.md +247 -23
  2. package/SKILLS/ufoo/SKILL.md +17 -2
  3. package/SKILLS/uinit/SKILL.md +8 -3
  4. package/bin/ucode-core.js +15 -0
  5. package/bin/ucode.js +125 -0
  6. package/bin/ufoo-assistant-agent.js +5 -0
  7. package/bin/ufoo-engine.js +25 -0
  8. package/bin/ufoo.js +4 -0
  9. package/modules/AGENTS.template.md +14 -4
  10. package/modules/bus/README.md +8 -5
  11. package/modules/bus/SKILLS/ubus/SKILL.md +5 -4
  12. package/modules/context/SKILLS/uctx/SKILL.md +3 -1
  13. package/modules/online/SKILLS/ufoo-online/SKILL.md +144 -0
  14. package/package.json +12 -3
  15. package/scripts/import-pi-mono.js +124 -0
  16. package/scripts/postinstall.js +20 -49
  17. package/scripts/sync-claude-skills.sh +21 -0
  18. package/src/agent/cliRunner.js +524 -31
  19. package/src/agent/internalRunner.js +76 -9
  20. package/src/agent/launcher.js +97 -45
  21. package/src/agent/normalizeOutput.js +1 -1
  22. package/src/agent/notifier.js +144 -4
  23. package/src/agent/ptyRunner.js +480 -10
  24. package/src/agent/ptyWrapper.js +28 -3
  25. package/src/agent/readyDetector.js +16 -0
  26. package/src/agent/ucode.js +443 -0
  27. package/src/agent/ucodeBootstrap.js +113 -0
  28. package/src/agent/ucodeBuild.js +67 -0
  29. package/src/agent/ucodeDoctor.js +184 -0
  30. package/src/agent/ucodeRuntimeConfig.js +129 -0
  31. package/src/agent/ufooAgent.js +168 -28
  32. package/src/assistant/agent.js +260 -0
  33. package/src/assistant/bridge.js +172 -0
  34. package/src/assistant/engine.js +252 -0
  35. package/src/assistant/stdio.js +58 -0
  36. package/src/assistant/ufooEngineCli.js +306 -0
  37. package/src/bus/activate.js +27 -11
  38. package/src/bus/daemon.js +133 -5
  39. package/src/bus/index.js +137 -80
  40. package/src/bus/inject.js +47 -17
  41. package/src/bus/message.js +145 -17
  42. package/src/bus/nickname.js +3 -1
  43. package/src/bus/queue.js +6 -1
  44. package/src/bus/store.js +189 -0
  45. package/src/bus/subscriber.js +20 -4
  46. package/src/bus/utils.js +9 -3
  47. package/src/chat/agentBar.js +117 -0
  48. package/src/chat/agentDirectory.js +88 -0
  49. package/src/chat/agentSockets.js +225 -0
  50. package/src/chat/agentViewController.js +298 -0
  51. package/src/chat/chatLogController.js +115 -0
  52. package/src/chat/commandExecutor.js +700 -0
  53. package/src/chat/commands.js +132 -0
  54. package/src/chat/completionController.js +414 -0
  55. package/src/chat/cronScheduler.js +160 -0
  56. package/src/chat/daemonConnection.js +166 -0
  57. package/src/chat/daemonCoordinator.js +64 -0
  58. package/src/chat/daemonMessageRouter.js +257 -0
  59. package/src/chat/daemonReconnect.js +41 -0
  60. package/src/chat/daemonTransport.js +36 -0
  61. package/src/chat/daemonTransportDefaults.js +10 -0
  62. package/src/chat/dashboardKeyController.js +480 -0
  63. package/src/chat/dashboardView.js +157 -0
  64. package/src/chat/index.js +938 -2910
  65. package/src/chat/inputHistoryController.js +105 -0
  66. package/src/chat/inputListenerController.js +304 -0
  67. package/src/chat/inputMath.js +104 -0
  68. package/src/chat/inputSubmitHandler.js +171 -0
  69. package/src/chat/layout.js +165 -0
  70. package/src/chat/pasteController.js +81 -0
  71. package/src/chat/rawKeyMap.js +42 -0
  72. package/src/chat/settingsController.js +133 -0
  73. package/src/chat/statusLineController.js +177 -0
  74. package/src/chat/streamTracker.js +138 -0
  75. package/src/chat/text.js +70 -0
  76. package/src/chat/transport.js +61 -0
  77. package/src/cli/busCoreCommands.js +59 -0
  78. package/src/cli/ctxCoreCommands.js +199 -0
  79. package/src/cli/onlineCoreCommands.js +379 -0
  80. package/src/cli.js +741 -238
  81. package/src/code/README.md +29 -0
  82. package/src/code/UCODE_PROMPT.md +32 -0
  83. package/src/code/agent.js +1651 -0
  84. package/src/code/cli.js +158 -0
  85. package/src/code/config +0 -0
  86. package/src/code/dispatch.js +42 -0
  87. package/src/code/index.js +70 -0
  88. package/src/code/nativeRunner.js +1213 -0
  89. package/src/code/runtime.js +154 -0
  90. package/src/code/sessionStore.js +162 -0
  91. package/src/code/taskDecomposer.js +269 -0
  92. package/src/code/tools/bash.js +53 -0
  93. package/src/code/tools/common.js +42 -0
  94. package/src/code/tools/edit.js +70 -0
  95. package/src/code/tools/read.js +44 -0
  96. package/src/code/tools/write.js +35 -0
  97. package/src/code/tui.js +1587 -0
  98. package/src/config.js +50 -2
  99. package/src/context/decisions.js +12 -2
  100. package/src/context/index.js +18 -1
  101. package/src/context/sync.js +127 -0
  102. package/src/daemon/agentProcessManager.js +74 -0
  103. package/src/daemon/cronOps.js +241 -0
  104. package/src/daemon/index.js +662 -489
  105. package/src/daemon/ipcServer.js +99 -0
  106. package/src/daemon/ops.js +417 -179
  107. package/src/daemon/promptLoop.js +319 -0
  108. package/src/daemon/promptRequest.js +101 -0
  109. package/src/daemon/providerSessions.js +32 -17
  110. package/src/daemon/reporting.js +90 -0
  111. package/src/daemon/run.js +2 -5
  112. package/src/daemon/status.js +24 -1
  113. package/src/init/index.js +68 -14
  114. package/src/online/bridge.js +663 -0
  115. package/src/online/client.js +245 -0
  116. package/src/online/runner.js +253 -0
  117. package/src/online/server.js +992 -0
  118. package/src/online/tokens.js +103 -0
  119. package/src/report/store.js +331 -0
  120. package/src/shared/eventContract.js +35 -0
  121. package/src/shared/ptySocketContract.js +21 -0
  122. package/src/status/index.js +50 -17
  123. package/src/terminal/adapterContract.js +87 -0
  124. package/src/terminal/adapterRouter.js +84 -0
  125. package/src/terminal/adapters/externalAdapter.js +14 -0
  126. package/src/terminal/adapters/internalAdapter.js +13 -0
  127. package/src/terminal/adapters/internalPtyAdapter.js +42 -0
  128. package/src/terminal/adapters/internalQueueAdapter.js +37 -0
  129. package/src/terminal/adapters/terminalAdapter.js +31 -0
  130. package/src/terminal/adapters/tmuxAdapter.js +30 -0
  131. package/src/ufoo/agentsStore.js +69 -3
  132. package/src/utils/banner.js +5 -2
  133. package/scripts/.archived/bash-to-js-migration/README.md +0 -46
  134. package/scripts/.archived/bash-to-js-migration/banner.sh +0 -89
  135. package/scripts/.archived/bash-to-js-migration/bus-alert.sh +0 -6
  136. package/scripts/.archived/bash-to-js-migration/bus-autotrigger.sh +0 -6
  137. package/scripts/.archived/bash-to-js-migration/bus-daemon.sh +0 -231
  138. package/scripts/.archived/bash-to-js-migration/bus-inject.sh +0 -176
  139. package/scripts/.archived/bash-to-js-migration/bus-listen.sh +0 -6
  140. package/scripts/.archived/bash-to-js-migration/bus.sh +0 -986
  141. package/scripts/.archived/bash-to-js-migration/context-decisions.sh +0 -167
  142. package/scripts/.archived/bash-to-js-migration/context-doctor.sh +0 -72
  143. package/scripts/.archived/bash-to-js-migration/context-lint.sh +0 -110
  144. package/scripts/.archived/bash-to-js-migration/doctor.sh +0 -22
  145. package/scripts/.archived/bash-to-js-migration/init.sh +0 -247
  146. package/scripts/.archived/bash-to-js-migration/skills.sh +0 -113
  147. package/scripts/.archived/bash-to-js-migration/status.sh +0 -125
  148. package/scripts/banner.sh +0 -2
  149. package/src/bus/API_DESIGN.md +0 -204
package/src/chat/index.js CHANGED
@@ -1,64 +1,47 @@
1
- const net = require("net");
2
1
  const path = require("path");
3
2
  const blessed = require("blessed");
4
- const { spawn, spawnSync, execSync } = 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");
8
13
  const UfooInit = require("../init");
9
- const EventBus = require("../bus");
10
14
  const AgentActivator = require("../bus/activate");
11
- const { getUfooPaths } = require("../ufoo/paths");
12
15
  const { subscriberToSafeName } = require("../bus/utils");
13
-
14
- function connectSocket(sockPath) {
15
- return new Promise((resolve, reject) => {
16
- const client = net.createConnection(sockPath, () => resolve(client));
17
- client.on("error", reject);
18
- });
19
- }
20
-
21
- function resolveProjectFile(projectRoot, relativePath, fallbackRelativePath) {
22
- const local = path.join(projectRoot, relativePath);
23
- if (fs.existsSync(local)) return local;
24
- return path.join(__dirname, "..", "..", fallbackRelativePath);
25
- }
26
-
27
- function startDaemon(projectRoot, options = {}) {
28
- const daemonBin = resolveProjectFile(projectRoot, path.join("bin", "ufoo.js"), path.join("bin", "ufoo.js"));
29
- const env = options.forceResume
30
- ? { ...process.env, UFOO_FORCE_RESUME: "1" }
31
- : process.env;
32
- const child = spawn(process.execPath, [daemonBin, "daemon", "--start"], {
33
- detached: true,
34
- stdio: "ignore",
35
- cwd: projectRoot,
36
- env,
37
- });
38
- child.unref();
39
- }
40
-
41
- function stopDaemon(projectRoot) {
42
- const daemonBin = resolveProjectFile(projectRoot, path.join("bin", "ufoo.js"), path.join("bin", "ufoo.js"));
43
- spawnSync(process.execPath, [daemonBin, "daemon", "--stop"], {
44
- stdio: "ignore",
45
- cwd: projectRoot,
46
- });
47
- }
48
-
49
- async function connectWithRetry(sockPath, retries, delayMs) {
50
- for (let i = 0; i < retries; i += 1) {
51
- try {
52
- // eslint-disable-next-line no-await-in-loop
53
- const client = await connectSocket(sockPath);
54
- return client;
55
- } catch {
56
- // eslint-disable-next-line no-await-in-loop
57
- await new Promise((r) => setTimeout(r, delayMs));
58
- }
59
- }
60
- return null;
61
- }
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");
62
45
 
63
46
  async function runChat(projectRoot) {
64
47
  if (!fs.existsSync(getUfooPaths(projectRoot).ufooDir)) {
@@ -89,687 +72,153 @@ async function runChat(projectRoot) {
89
72
  startDaemon(projectRoot);
90
73
  }
91
74
 
92
- const daemonBin = resolveProjectFile(projectRoot, path.join("bin", "ufoo.js"), path.join("bin", "ufoo.js"));
93
75
  const sock = socketPath(projectRoot);
94
- let client = null;
95
- let reconnectPromise = null;
96
- let exitRequested = false;
97
- let connectionLostNotified = false;
98
- const pendingRequests = [];
99
- const MAX_PENDING_REQUESTS = 50;
100
-
101
- const connectClient = async () => {
102
- let newClient = await connectWithRetry(sock, 25, 200);
103
- if (!newClient) {
104
- // Retry once with a fresh daemon start and longer wait.
105
- if (!isRunning(projectRoot)) {
106
- startDaemon(projectRoot);
107
- // Wait for daemon to write PID file and create socket
108
- await new Promise(r => setTimeout(r, 1000));
109
- }
110
- newClient = await connectWithRetry(sock, 50, 200);
111
- }
112
- return newClient;
113
- };
114
-
115
- function enqueueRequest(req) {
116
- if (!req || req.type === "status") return;
117
- pendingRequests.push(req);
118
- if (pendingRequests.length > MAX_PENDING_REQUESTS) {
119
- pendingRequests.shift();
120
- }
121
- }
122
-
123
- function flushPendingRequests() {
124
- if (!client || client.destroyed) return;
125
- while (pendingRequests.length > 0) {
126
- const req = pendingRequests.shift();
127
- client.write(`${JSON.stringify(req)}\n`);
128
- }
129
- }
130
-
131
- async function ensureConnected() {
132
- if (client && !client.destroyed) return true;
133
- if (exitRequested) return false;
134
- if (reconnectPromise) return reconnectPromise;
135
- queueStatusLine("Reconnecting to daemon");
136
- logMessage("status", "{magenta-fg}⚙{/magenta-fg} Reconnecting to daemon...");
137
- reconnectPromise = (async () => {
138
- const newClient = await connectClient();
139
- if (!newClient) {
140
- resolveStatusLine("{red-fg}✗{/red-fg} Daemon offline");
141
- logMessage("error", "{red-fg}✗{/red-fg} Failed to reconnect to daemon");
142
- return false;
143
- }
144
- attachClient(newClient);
145
- connectionLostNotified = false;
146
- resolveStatusLine("{green-fg}✓{/green-fg} Daemon reconnected");
147
- requestStatus();
148
- return true;
149
- })();
150
- try {
151
- return await reconnectPromise;
152
- } finally {
153
- reconnectPromise = null;
154
- }
155
- }
156
-
157
- client = await connectClient();
158
- if (!client) {
159
- // Check if daemon failed to start
160
- if (!isRunning(projectRoot)) {
161
- const logFile = getUfooPaths(projectRoot).ufooDaemonLog;
162
- // eslint-disable-next-line no-console
163
- console.error("Failed to start ufoo daemon. Check logs at:", logFile);
164
- throw new Error("Daemon failed to start. Check the daemon log for details.");
165
- }
166
- throw new Error("Failed to connect to ufoo daemon (timeout). The daemon may still be starting.");
167
- }
168
-
169
- const screen = blessed.screen({
170
- smartCSR: true,
171
- title: "ufoo chat",
172
- fullUnicode: true,
173
- // Toggle mouse at runtime to balance copy vs scroll
174
- sendFocus: true,
175
- mouse: false,
176
- // Allow Ctrl+C to exit even when input grabs keys
177
- ignoreLocked: ["C-c"],
76
+ let daemonCoordinator = null;
77
+ const daemonTransport = createDaemonTransport({
78
+ projectRoot,
79
+ sockPath: sock,
80
+ isRunning,
81
+ startDaemon,
82
+ connectWithRetry,
178
83
  });
179
- // Prefer normal buffer for reliable terminal selection/copy
180
- if (screen.program && typeof screen.program.normalBuffer === "function") {
181
- screen.program.normalBuffer();
182
- if (screen.program.put && typeof screen.program.put.keypad_local === "function") {
183
- screen.program.put.keypad_local();
184
- }
185
- if (typeof screen.program.clear === "function") {
186
- screen.program.clear();
187
- screen.program.cup(0, 0);
188
- }
189
- }
190
84
 
191
85
  const config = loadConfig(projectRoot);
192
86
  let launchMode = config.launchMode;
193
87
  let agentProvider = config.agentProvider;
88
+ let assistantEngine = normalizeAssistantEngine(config.assistantEngine);
194
89
  let autoResume = config.autoResume !== false;
90
+ let cronScheduler = {
91
+ addTask: () => null,
92
+ listTasks: () => [],
93
+ stopTask: () => false,
94
+ stopAll: () => 0,
95
+ };
195
96
 
196
97
  // Dynamic input height settings
197
98
  // Layout: topLine(1) + content + bottomLine(1) + dashboard(1)
198
99
  const MIN_INPUT_HEIGHT = 4; // 1 content + 3
199
100
  const MAX_INPUT_HEIGHT = 9; // 6 content + 3
200
101
  let currentInputHeight = MIN_INPUT_HEIGHT;
201
-
202
- // Log area (no border for cleaner look)
203
- const logBox = blessed.log({
204
- parent: screen,
205
- top: 0,
206
- left: 0,
207
- width: "100%",
208
- height: "100%-5", // Will be adjusted dynamically
209
- tags: true,
210
- scrollable: true,
211
- alwaysScroll: true,
212
- scrollback: 10000,
213
- scrollbar: null,
214
- keys: true,
215
- vi: true,
216
- // Mouse handled globally (toggleable) to keep copy working
217
- mouse: false,
218
- });
219
-
220
- // Status line just above input
221
- const statusLine = blessed.box({
222
- parent: screen,
223
- bottom: currentInputHeight,
224
- left: 0,
225
- width: "100%",
226
- height: 1,
227
- style: { fg: "gray" },
228
- tags: true,
229
- content: "",
230
- });
231
102
  const pkg = require("../../package.json");
232
- const bannerText = `{bold}UFOO{/bold} · Multi-Agent Manager{|}v${pkg.version}`;
233
- 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
+ });
234
119
 
235
120
  const historyDir = path.join(getUfooPaths(projectRoot).ufooDir, "chat");
236
121
  const historyFile = path.join(historyDir, "history.jsonl");
237
122
  const inputHistoryFile = path.join(historyDir, "input-history.jsonl");
238
123
 
239
- function appendHistory(entry) {
240
- fs.mkdirSync(historyDir, { recursive: true });
241
- fs.appendFileSync(historyFile, `${JSON.stringify(entry)}\n`);
242
- }
243
-
244
- const SPACED_TYPES = new Set(["user", "reply", "bus", "dispatch", "error"]);
245
- let lastLogWasSpacer = false;
246
- let lastLogType = null;
247
- let hasLoggedAny = false;
248
-
249
- function shouldSpace(type, text) {
250
- if (SPACED_TYPES.has(type)) return true;
251
- if (text && /daemon/i.test(text)) return true;
252
- return false;
253
- }
124
+ const chatLogController = createChatLogController({
125
+ logBox,
126
+ fsModule: fs,
127
+ historyDir,
128
+ historyFile,
129
+ });
254
130
 
255
- function writeSpacer(writeHistory) {
256
- if (lastLogWasSpacer || !hasLoggedAny) return;
257
- try {
258
- logBox.log(" ");
259
- } catch {
260
- // ignore rendering errors
261
- }
262
- if (writeHistory) {
263
- appendHistory({
264
- ts: new Date().toISOString(),
265
- type: "spacer",
266
- text: "",
267
- meta: {},
268
- });
269
- }
270
- lastLogWasSpacer = true;
271
- lastLogType = "spacer";
272
- hasLoggedAny = true;
273
- }
131
+ const streamTracker = createStreamTracker({
132
+ logBox,
133
+ writeSpacer: () => chatLogController.writeSpacer(false),
134
+ appendHistory: (...args) => chatLogController.appendHistory(...args),
135
+ escapeBlessed,
136
+ onStreamStart: () => chatLogController.markStreamStart(),
137
+ });
274
138
 
275
- function recordLog(type, text, meta = {}, writeHistory = true) {
276
- const lineText = text == null ? "" : String(text);
277
- if (type !== "spacer" && shouldSpace(type, text)) {
278
- writeSpacer(writeHistory);
279
- }
280
- appendToLogBox(lineText);
281
- if (writeHistory) {
282
- appendHistory({
283
- ts: new Date().toISOString(),
284
- type,
285
- text: lineText,
286
- meta,
287
- });
288
- }
289
- lastLogWasSpacer = false;
290
- lastLogType = type;
291
- hasLoggedAny = true;
292
- }
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);
293
145
 
294
146
  function logMessage(type, text, meta = {}) {
295
- recordLog(type, text, meta, true);
296
- }
297
-
298
- // Prevent blessed tag parsing crashes from untrusted text.
299
- // blessed parses `{...}` as style tags; certain inputs like `{foo,bar}` can
300
- // trigger a blessed bug (Program._attr on unknown comma/semicolon parts).
301
- //
302
- // Workaround: blessed@0.1.81 has a bug where tags containing comma/semicolon
303
- // (e.g. `{foo,bar}`) can crash when the log widget reparses cached lines.
304
- // We proactively neutralize any such tag-like sequences so they don't match
305
- // blessed's tag regex on subsequent reparses.
306
- function neutralizeBlessedCommaTags(text) {
307
- if (text == null) return "";
308
- const raw = String(text);
309
- if (!raw.includes("{")) return raw;
310
- return raw.replace(/\{\/?[\w\-,;!#]*[;,][\w\-,;!#]*\}/g, (m) => {
311
- // Insert a space after separators so `{foo,bar}` becomes `{foo, bar}`.
312
- // This stops blessed from treating it as a tag on future reparses.
313
- const inner = m.slice(1, -1).replace(/[,;]/g, (ch) => `${ch} `);
314
- return `{${inner}}`;
315
- });
316
- }
317
-
318
- function escapeBlessed(text) {
319
- if (text == null) return "{escape}{/escape}";
320
- const raw = neutralizeBlessedCommaTags(text);
321
- // Avoid allowing payload to terminate escape mode.
322
- const safe = raw.replace(/\{\/escape\}/g, "{open}/escape{close}");
323
- return `{escape}${safe}{/escape}`;
324
- }
325
-
326
- function appendToLogBox(text) {
327
- // Avoid a blessed render-time crash for `{foo,bar}`-like tag sequences.
328
- logBox.log(neutralizeBlessedCommaTags(text));
147
+ chatLogController.logMessage(type, text, meta);
329
148
  }
330
149
 
331
150
  function loadHistory(limit = 2000) {
332
- try {
333
- const lines = fs.readFileSync(historyFile, "utf8").trim().split(/\r?\n/).filter(Boolean);
334
- const items = lines.slice(-limit).map((line) => JSON.parse(line));
335
- const hasSpacer = items.some((item) => item && item.type === "spacer");
336
- for (const item of items) {
337
- if (!item) continue;
338
- if (item.type === "spacer") {
339
- writeSpacer(false);
340
- continue;
341
- }
342
- if (!item.text) continue;
343
- if (hasSpacer) {
344
- appendToLogBox(item.text);
345
- lastLogWasSpacer = false;
346
- lastLogType = item.type || null;
347
- hasLoggedAny = true;
348
- } else {
349
- recordLog(item.type || "unknown", item.text, item.meta || {}, false);
350
- }
351
- }
352
- } catch {
353
- // ignore missing/invalid history
354
- }
151
+ chatLogController.loadHistory(limit);
355
152
  }
356
153
 
357
- const inputHistory = [];
358
- let historyIndex = 0;
359
- let historyDraft = "";
360
-
361
- function appendInputHistory(text) {
362
- if (!text) return;
363
- fs.mkdirSync(historyDir, { recursive: true });
364
- fs.appendFileSync(inputHistoryFile, `${JSON.stringify({ text })}\n`);
365
- }
154
+ let inputHistoryController = null;
366
155
 
367
156
  function loadInputHistory(limit = 2000) {
368
- try {
369
- const lines = fs.readFileSync(inputHistoryFile, "utf8").trim().split(/\r?\n/).filter(Boolean);
370
- const items = lines.slice(-limit).map((line) => JSON.parse(line));
371
- for (const item of items) {
372
- if (item && typeof item.text === "string" && item.text.trim() !== "") {
373
- inputHistory.push(item.text);
374
- }
375
- }
376
- } catch {
377
- // ignore missing/invalid history
378
- }
379
- historyIndex = inputHistory.length;
380
- }
381
-
382
- const pendingStatusLines = [];
383
- const busStatusQueue = [];
384
- let primaryStatusText = bannerText;
385
- let primaryStatusPending = false;
386
- const shimmerStart = Date.now();
387
- let statusAnimationTimer = null;
388
- const STATUS_ANIM_FRAME_MS = 50;
389
- const SHIMMER_PADDING = 10;
390
- const SHIMMER_BAND_HALF_WIDTH = 5;
391
- const SHIMMER_SWEEP_MS = 2000;
392
- const SPINNER_PERIOD_MS = 600;
393
-
394
- function formatProcessingText(text) {
395
- if (!text) return text;
396
- if (text.includes("{")) return text;
397
- if (!/processing/i.test(text)) return text;
398
- return text;
399
- }
400
-
401
- function shimmerText(text, nowMs) {
402
- if (!text) return "";
403
- if (text.includes("{")) return text;
404
- const chars = Array.from(text);
405
- const period = chars.length + SHIMMER_PADDING * 2;
406
- const pos =
407
- Math.floor(((nowMs - shimmerStart) % SHIMMER_SWEEP_MS) / SHIMMER_SWEEP_MS * period);
408
- let out = "";
409
- for (let i = 0; i < chars.length; i += 1) {
410
- const iPos = i + SHIMMER_PADDING;
411
- const dist = Math.abs(iPos - pos);
412
- let intensity = 0;
413
- if (dist <= SHIMMER_BAND_HALF_WIDTH) {
414
- const x = Math.PI * (dist / SHIMMER_BAND_HALF_WIDTH);
415
- intensity = 0.5 * (1 + Math.cos(x));
416
- }
417
- const ch = chars[i];
418
- if (intensity < 0.2) {
419
- out += `{gray-fg}${ch}{/gray-fg}`;
420
- } else if (intensity < 0.6) {
421
- out += ch;
422
- } else {
423
- out += `{bold}{white-fg}${ch}{/white-fg}{/bold}`;
424
- }
425
- }
426
- return out;
427
- }
428
-
429
- function spinnerFrame(nowMs) {
430
- const on = Math.floor((nowMs - shimmerStart) / SPINNER_PERIOD_MS) % 2 === 0;
431
- return on
432
- ? "{white-fg}•{/white-fg}"
433
- : "{gray-fg}◦{/gray-fg}";
434
- }
435
-
436
- function renderPendingStatus(text, nowMs) {
437
- const spinner = spinnerFrame(nowMs);
438
- const shimmer = shimmerText(text, nowMs);
439
- if (!shimmer) return spinner;
440
- return `${spinner} ${shimmer}`;
157
+ if (!inputHistoryController) return;
158
+ inputHistoryController.loadInputHistory(limit);
441
159
  }
442
160
 
443
- function renderStatusLine(nowMs = Date.now()) {
444
- let content = primaryStatusText || "";
445
- if (primaryStatusPending) {
446
- content = renderPendingStatus(primaryStatusText, nowMs);
447
- }
448
- if (busStatusQueue.length > 0) {
449
- const extra = busStatusQueue.length > 1
450
- ? ` {gray-fg}(+${busStatusQueue.length - 1}){/gray-fg}`
451
- : "";
452
- const busText = `${busStatusQueue[0].text}${extra}`;
453
- content = content
454
- ? `${content} {gray-fg}·{/gray-fg} ${busText}`
455
- : busText;
456
- }
457
- statusLine.setContent(content);
458
- }
459
-
460
- function updateStatusAnimation() {
461
- if (primaryStatusPending && !statusAnimationTimer) {
462
- statusAnimationTimer = setInterval(() => {
463
- if (!primaryStatusPending) return;
464
- renderStatusLine(Date.now());
465
- screen.render();
466
- }, STATUS_ANIM_FRAME_MS);
467
- } else if (!primaryStatusPending && statusAnimationTimer) {
468
- clearInterval(statusAnimationTimer);
469
- statusAnimationTimer = null;
470
- }
471
- }
472
-
473
- function setPrimaryStatus(text, options = {}) {
474
- primaryStatusText = text || "";
475
- primaryStatusPending = Boolean(options.pending);
476
- updateStatusAnimation();
477
- renderStatusLine();
478
- }
479
-
480
- function queueStatusLine(text) {
481
- let raw = text || "";
482
- pendingStatusLines.push(raw);
483
- if (pendingStatusLines.length === 1) {
484
- setPrimaryStatus(raw, { pending: true });
485
- screen.render();
486
- }
487
- }
488
-
489
- function resolveStatusLine(text) {
490
- if (pendingStatusLines.length > 0) {
491
- pendingStatusLines.shift();
492
- }
493
- if (pendingStatusLines.length > 0) {
494
- setPrimaryStatus(pendingStatusLines[0], { pending: true });
495
- } else {
496
- setPrimaryStatus(text || "", { pending: false });
497
- }
498
- screen.render();
499
- }
500
-
501
- function enqueueBusStatus(item) {
502
- if (!item || !item.text) return;
503
- const rawText = item.text == null ? "" : String(item.text);
504
- const key = item.key || rawText;
505
- const formatted = escapeBlessed(formatProcessingText(rawText));
506
- const existing = busStatusQueue.find((entry) => entry.key === key);
507
- if (existing) {
508
- existing.text = formatted;
509
- } else {
510
- busStatusQueue.push({ key, text: formatted });
511
- }
512
- renderStatusLine();
513
- }
514
-
515
- function resolveBusStatus(item) {
516
- if (!item) return;
517
- const rawText = item.text == null ? "" : String(item.text);
518
- const key = item.key || rawText;
519
- let index = -1;
520
- if (key) {
521
- index = busStatusQueue.findIndex((entry) => entry.key === key);
522
- }
523
- if (index === -1 && item.text) {
524
- index = busStatusQueue.findIndex((entry) => entry.text === item.text);
525
- }
526
- if (index === -1) return;
527
- busStatusQueue.splice(index, 1);
528
- renderStatusLine();
529
- }
530
-
531
- // Command completion panel
532
- const completionPanel = blessed.box({
533
- parent: screen,
534
- bottom: currentInputHeight - 1,
535
- left: 0,
536
- width: "100%",
537
- height: 0,
538
- hidden: true,
539
- wrap: false,
540
- border: {
541
- type: "line",
542
- top: true,
543
- left: false,
544
- right: false,
545
- bottom: false
546
- },
547
- style: {
548
- border: { fg: "yellow" },
549
- fg: "white"
550
- // No bg - uses terminal default background
551
- },
552
- padding: {
553
- left: 0,
554
- right: 0,
555
- top: 0,
556
- bottom: 0
557
- },
558
- tags: true,
559
- });
560
-
561
- // Dashboard at very bottom
562
- const dashboard = blessed.box({
563
- parent: screen,
564
- bottom: 0,
565
- left: 0,
566
- width: "100%",
567
- height: 1,
568
- style: { fg: "gray" },
569
- tags: true,
570
- });
571
-
572
- // Agent TTY view state
573
- let currentView = "main"; // "main" | "agent"
574
- let viewingAgent = null; // subscriber ID of agent being viewed
575
- let agentOutputClient = null; // net.Socket connected to inject.sock
576
- let agentOutputBuffer = ""; // partial line buffer for output parsing
577
- let agentInputClient = null; // net.Socket for sending raw input
578
- let _detachedChildren = null; // Screen children saved during agent view
579
- let agentInputSuppressUntil = 0; // Suppress input forwarding until this timestamp
580
-
581
- // Bottom border line for input area (above dashboard)
582
- const inputBottomLine = blessed.line({
583
- parent: screen,
584
- bottom: 1,
585
- left: 0,
586
- width: "100%",
587
- orientation: "horizontal",
588
- style: { fg: "cyan" },
589
- });
590
-
591
- // Prompt indicator
592
- const promptBox = blessed.box({
593
- parent: screen,
594
- bottom: 2,
595
- left: 0,
596
- width: 2,
597
- height: currentInputHeight - 3,
598
- content: ">",
599
- style: { fg: "cyan" },
161
+ const statusLineController = createStatusLineController({
162
+ statusLine,
163
+ bannerText,
164
+ renderScreen: () => screen.render(),
600
165
  });
601
166
 
602
- // Input area without left/right border
603
- const input = blessed.textarea({
604
- parent: screen,
605
- bottom: 2,
606
- left: 2,
607
- width: "100%-2",
608
- height: currentInputHeight - 3,
609
- inputOnFocus: true,
610
- keys: true,
611
- });
612
- // Avoid textarea's extra wrap margin (causes a phantom empty column)
613
- input.type = "box";
614
-
615
- // Top border line for input area (just above input)
616
- const inputTopLine = blessed.line({
617
- parent: screen,
618
- bottom: currentInputHeight - 1, // 4-1=3: above input(2) + inputHeight(1)
619
- left: 0,
620
- width: "100%",
621
- orientation: "horizontal",
622
- 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
+ },
623
187
  });
624
188
 
625
189
  // Add cursor position tracking
626
190
  let cursorPos = 0;
627
191
  let preferredCol = null;
628
- const unicode = blessed.unicode;
629
- const wideRegex = new RegExp(unicode.chars.all.source);
630
192
 
631
- // Get inner width
632
193
  function getInnerWidth() {
633
- const lpos = input.lpos || input._getCoords();
634
- if (lpos && Number.isFinite(lpos.xl) && Number.isFinite(lpos.xi)) {
635
- return Math.max(1, lpos.xl - lpos.xi + 1);
636
- }
637
- if (typeof input.width === "number") return Math.max(1, input.width);
638
- if (typeof input.width === "string") {
639
- const match = input.width.match(/^100%-([0-9]+)$/);
640
- if (match && typeof screen.width === "number") {
641
- return Math.max(1, screen.width - parseInt(match[1], 10));
642
- }
643
- }
644
194
  const promptWidth = typeof promptBox.width === "number" ? promptBox.width : 2;
645
- if (typeof screen.width === "number") return Math.max(1, screen.width - promptWidth);
646
- if (typeof screen.cols === "number") return Math.max(1, screen.cols - promptWidth);
647
- return 1;
195
+ return inputMath.getInnerWidth({ input, screen, promptWidth });
648
196
  }
649
197
 
650
198
  function getWrapWidth() {
651
- if (input._clines && typeof input._clines.width === "number") {
652
- return Math.max(1, input._clines.width);
653
- }
654
- return getInnerWidth();
655
- }
656
-
657
- function isWideChar(ch) {
658
- return wideRegex.test(ch);
659
- }
660
-
661
- function transformChar(ch) {
662
- if (ch === "\n") return "\n";
663
- if (ch === "\r") return "";
664
- if (ch === "\t") return screen.tabc;
665
-
666
- const code = ch.codePointAt(0);
667
- if (
668
- code <= 0x08
669
- || code === 0x0b
670
- || code === 0x0c
671
- || (code >= 0x0e && code <= 0x1a)
672
- || (code >= 0x1c && code <= 0x1f)
673
- || code === 0x7f
674
- ) {
675
- return "";
676
- }
677
-
678
- if (ch === "\x1b") return "";
679
-
680
- const isWide = isWideChar(ch);
681
-
682
- if (screen.fullUnicode) {
683
- if (screen.program && screen.program.isiTerm2 && unicode.isCombining(ch, 0)) {
684
- return "";
685
- }
686
- if (isWide) return `${ch}\x03`;
687
- return ch;
688
- }
689
-
690
- if (unicode.isCombining(ch, 0)) return "";
691
- if (unicode.isSurrogate(ch, 0)) return "?";
692
- if (isWide) return "??";
693
- return ch;
694
- }
695
-
696
- function transformText(text) {
697
- if (!text) return "";
698
- const out = [];
699
- for (const ch of text) {
700
- out.push(transformChar(ch));
701
- }
702
- return out.join("");
703
- }
704
-
705
- function visualLength(text) {
706
- return transformText(text).length;
199
+ return inputMath.getWrapWidth(input, getInnerWidth());
707
200
  }
708
201
 
709
- function originalIndexForVisual(line, visualIndex) {
710
- if (visualIndex <= 0) return 0;
711
- let visual = 0;
712
- let offset = 0;
713
- for (const ch of line) {
714
- const rep = transformChar(ch);
715
- const repLen = rep.length;
716
- if (visual + repLen > visualIndex) return offset;
717
- visual += repLen;
718
- offset += ch.length;
719
- }
720
- return line.length;
721
- }
722
-
723
- // Count lines considering both wrapping and newlines (matches blessed wrap)
724
202
  function countLines(text, width) {
725
- if (width <= 0) return 1;
726
- const lines = (text || "").split("\n");
727
- let total = 0;
728
- for (const line of lines) {
729
- const lineWidth = visualLength(line);
730
- total += Math.max(1, Math.ceil(lineWidth / width));
731
- }
732
- return total;
203
+ return inputMath.countLines(text, width, (value) => input.strWidth(value));
733
204
  }
734
205
 
735
206
  function getCursorRowCol(text, pos, width) {
736
- if (width <= 0) return { row: 0, col: 0 };
737
- const before = (text || "").slice(0, pos);
738
- const transformed = transformText(before);
739
- const lines = transformed.split("\n");
740
- let row = 0;
741
- for (let i = 0; i < lines.length - 1; i++) {
742
- const lineWidth = lines[i].length;
743
- row += Math.max(1, Math.ceil(lineWidth / width));
744
- }
745
- const lastLine = lines[lines.length - 1] || "";
746
- const lastWidth = lastLine.length;
747
- row += Math.floor(lastWidth / width);
748
- const col = lastWidth % width;
749
- return { row, col };
207
+ return inputMath.getCursorRowCol(text, pos, width, (value) => input.strWidth(value));
750
208
  }
751
209
 
752
210
  function getCursorPosForRowCol(text, targetRow, targetCol, width) {
753
- if (width <= 0) return 0;
754
- const lines = (text || "").split("\n");
755
- let row = 0;
756
- let pos = 0;
757
- for (const line of lines) {
758
- const lineWidth = visualLength(line);
759
- const wrappedRows = Math.max(1, Math.ceil(lineWidth / width));
760
- if (targetRow < row + wrappedRows) {
761
- const rowInLine = targetRow - row;
762
- const visualCol = rowInLine * width + Math.max(0, targetCol);
763
- return pos + originalIndexForVisual(line, Math.min(visualCol, lineWidth));
764
- }
765
- pos += line.length + 1;
766
- row += wrappedRows;
767
- }
768
- return text.length;
211
+ return inputMath.getCursorPosForRowCol(
212
+ text,
213
+ targetRow,
214
+ targetCol,
215
+ width,
216
+ (value) => input.strWidth(value),
217
+ );
769
218
  }
770
219
 
771
220
  function ensureInputCursorVisible() {
772
- const innerWidth = getWrapWidth();
221
+ const innerWidth = getInnerWidth();
773
222
  if (innerWidth <= 0) return;
774
223
  const totalRows = countLines(input.value, innerWidth);
775
224
  const visibleRows = Math.max(1, input.height || 1);
@@ -800,33 +249,21 @@ async function runChat(projectRoot) {
800
249
  preferredCol = null;
801
250
  }
802
251
 
803
- const PASTE_START = "\x1b[200~";
804
- const PASTE_END = "\x1b[201~";
805
- let pasteActive = false;
806
- let pasteBuffer = "";
807
- let pasteRemainder = "";
808
- let suppressKeypress = false;
809
- let suppressReset = null;
252
+ function getPreferredCol() {
253
+ return preferredCol;
254
+ }
810
255
 
811
- function scheduleSuppressReset() {
812
- suppressKeypress = true;
813
- if (suppressReset) clearImmediate(suppressReset);
814
- suppressReset = setImmediate(() => {
815
- if (!pasteActive) suppressKeypress = false;
816
- });
256
+ function setPreferredCol(value) {
257
+ preferredCol = value;
817
258
  }
818
259
 
819
260
  function normalizePaste(text) {
820
- if (!text) return "";
821
- let normalized = text.replace(/\x1b\[200~|\x1b\[201~/g, "");
822
- normalized = normalized.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
823
- return normalized;
261
+ return inputMath.normalizePaste(text);
824
262
  }
825
263
 
826
264
  function updateDraftFromInput() {
827
- if (historyIndex === inputHistory.length) {
828
- historyDraft = input.value;
829
- }
265
+ if (!inputHistoryController) return;
266
+ inputHistoryController.updateDraftFromInput();
830
267
  }
831
268
 
832
269
  function normalizeCommandPrefix() {
@@ -861,639 +298,156 @@ async function runChat(projectRoot) {
861
298
  screen.render();
862
299
  }
863
300
 
301
+ inputHistoryController = createInputHistoryController({
302
+ inputHistoryFile,
303
+ historyDir,
304
+ setInputValue,
305
+ getInputValue: () => input.value || "",
306
+ });
307
+
864
308
  function historyUp() {
865
- if (inputHistory.length === 0) return false;
866
- if (historyIndex === inputHistory.length) {
867
- historyDraft = input.value;
868
- }
869
- if (historyIndex > 0) {
870
- historyIndex -= 1;
871
- setInputValue(inputHistory[historyIndex]);
872
- return true;
873
- }
874
- return true;
309
+ if (!inputHistoryController) return false;
310
+ return inputHistoryController.historyUp();
875
311
  }
876
312
 
877
313
  function historyDown() {
878
- if (inputHistory.length === 0) return false;
879
- if (historyIndex < inputHistory.length - 1) {
880
- historyIndex += 1;
881
- setInputValue(inputHistory[historyIndex]);
882
- return true;
883
- }
884
- if (historyIndex === inputHistory.length - 1) {
885
- historyIndex = inputHistory.length;
886
- setInputValue(historyDraft || "");
887
- return true;
888
- }
889
- return false;
314
+ if (!inputHistoryController) return false;
315
+ return inputHistoryController.historyDown();
890
316
  }
891
317
 
892
318
  function exitHandler() {
893
- exitRequested = true;
894
- // Clean up agent view connections
895
- disconnectAgentOutput();
896
- disconnectAgentInput();
319
+ if (daemonCoordinator) {
320
+ daemonCoordinator.markExit();
321
+ }
322
+ cronScheduler.stopAll();
323
+ exitAgentView();
897
324
  if (screen && screen.program && typeof screen.program.decrst === "function") {
898
325
  screen.program.decrst(2004);
899
326
  }
900
- if (statusAnimationTimer) {
901
- clearInterval(statusAnimationTimer);
902
- statusAnimationTimer = null;
903
- }
904
- if (client) {
905
- client.end();
327
+ statusLineController.destroy();
328
+ if (daemonCoordinator) {
329
+ daemonCoordinator.close();
906
330
  }
907
331
  process.exit(0);
908
332
  }
909
333
 
910
- // Command completion functions
911
- function showCompletion(filterText) {
912
- // Ensure accidental double-prefix doesn't break filtering.
913
- normalizeCommandPrefix();
914
- if (filterText !== input.value) {
915
- filterText = input.value;
916
- }
917
- if (filterText.startsWith("//")) {
918
- filterText = filterText.replace(/^\/+/, "/");
919
- input.value = filterText;
920
- cursorPos = Math.min(cursorPos, input.value.length);
921
- }
922
- if (!filterText || filterText === "") {
923
- hideCompletion();
924
- return;
925
- }
926
-
927
- // Trim the filterText to handle trailing spaces for main command mode
928
- // But preserve spaces for subcommand mode detection
929
- const endsWithSpace = /\s$/.test(filterText);
930
- const trimmed = filterText.trim();
931
- if (!trimmed) {
932
- hideCompletion();
933
- return;
934
- }
935
- filterText = trimmed;
936
-
937
- // Check if we're in subcommand mode
938
- const parts = filterText.split(/\s+/);
939
- let commands = [];
940
-
941
- const mainCmd = parts[0];
942
- const isLaunch = mainCmd && mainCmd.toLowerCase() === "/launch";
943
- const wantsSubcommands = (parts.length > 1 || (endsWithSpace && parts.length === 1));
944
-
945
- if ((wantsSubcommands || isLaunch) && mainCmd && mainCmd.startsWith("/")) {
946
- // Subcommand mode: "/bus rename"
947
- const subFilter = parts[1] || "";
948
-
949
- // Find the main command
950
- const mainCmdObj = COMMAND_REGISTRY.find(item =>
951
- item.cmd.toLowerCase() === mainCmd.toLowerCase()
952
- );
953
-
954
- const fallbackLaunchSubs = [
955
- { cmd: "claude", desc: "Launch Claude agent" },
956
- { cmd: "codex", desc: "Launch Codex agent" },
957
- ];
958
-
959
- if ((mainCmdObj && mainCmdObj.subcommands) || isLaunch) {
960
- const baseSubs = mainCmdObj && mainCmdObj.subcommands ? mainCmdObj.subcommands : [];
961
- let subs = baseSubs;
962
- if (isLaunch) {
963
- const merged = new Map();
964
- for (const sub of [...baseSubs, ...fallbackLaunchSubs]) {
965
- if (!sub || !sub.cmd) continue;
966
- merged.set(sub.cmd, sub);
967
- }
968
- subs = Array.from(merged.values());
969
- }
970
- if (isLaunch) {
971
- // Always show both launch targets for clarity
972
- commands = subs
973
- .map(sub => ({ ...sub, isSubcommand: true, parentCmd: mainCmd }))
974
- .sort((a, b) => a.cmd.localeCompare(b.cmd));
975
- } else {
976
- // Filter subcommands
977
- commands = subs
978
- .filter(sub => sub.cmd.toLowerCase().startsWith(subFilter.toLowerCase()))
979
- .map(sub => ({ ...sub, isSubcommand: true, parentCmd: mainCmd }))
980
- .sort((a, b) => a.cmd.localeCompare(b.cmd));
981
- }
982
- }
983
- } else {
984
- // Main command mode: "/bus"
985
- const filterLower = filterText.toLowerCase();
986
- commands = COMMAND_REGISTRY
987
- .filter(item => item.cmd.toLowerCase().startsWith(filterLower))
988
- .sort((a, b) => a.cmd.localeCompare(b.cmd, "en", { sensitivity: "base" }));
989
- }
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
+ });
990
355
 
991
- if (commands.length === 0) {
992
- hideCompletion();
993
- return;
994
- }
356
+ const pasteController = createPasteController({
357
+ shouldHandle: () => screen.focused === input && focusMode === "input",
358
+ normalizePaste,
359
+ insertTextAtCursor,
360
+ });
995
361
 
996
- completionCommands = commands;
997
- completionActive = true;
998
- completionIndex = 0;
999
- completionScrollOffset = 0;
1000
-
1001
- // Calculate panel height (visible items + 2 for blessed border overhead)
1002
- // blessed reserves 2 rows for border (iheight) even when only border.top is set
1003
- const availableHeight = screen.height - currentInputHeight - 1;
1004
- completionVisibleCount = Math.min(7, completionCommands.length);
1005
- completionVisibleCount = Math.min(completionVisibleCount, Math.max(1, availableHeight - 2));
1006
- completionPanel.height = completionVisibleCount + 2;
1007
- completionPanel.bottom = currentInputHeight - 1;
1008
- completionPanel.hidden = false;
1009
-
1010
- renderCompletionPanel();
1011
- }
1012
-
1013
- function hideCompletion() {
1014
- completionActive = false;
1015
- completionCommands = [];
1016
- completionIndex = 0;
1017
- completionScrollOffset = 0;
1018
- completionVisibleCount = 0;
1019
- completionPanel.hidden = true;
1020
- screen.render();
1021
- }
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
+ });
1022
399
 
1023
- function renderCompletionPanel() {
1024
- if (!completionActive || completionCommands.length === 0) return;
400
+ // Resize input box based on content
401
+ function resizeInput() {
402
+ const innerWidth = getWrapWidth();
403
+ if (innerWidth <= 0) return;
1025
404
 
1026
- // blessed reserves 2 rows for border (iheight=2) even with only border.top
1027
- const panelVisible = Math.max(1, (completionPanel.height || 2) - 2);
1028
- const maxVisible = completionVisibleCount
1029
- ? Math.max(1, Math.min(completionVisibleCount, panelVisible))
1030
- : panelVisible;
405
+ const numLines = countLines(input.value, innerWidth);
406
+ const contentHeight = Math.min(MAX_INPUT_HEIGHT - 3, Math.max(1, numLines));
407
+ const targetHeight = contentHeight + 3; // +1 topLine +1 bottomLine +1 dashboard
1031
408
 
1032
- // Adjust scroll offset to keep selected item visible
1033
- if (completionIndex < completionScrollOffset) {
1034
- completionScrollOffset = completionIndex;
1035
- } else if (completionIndex >= completionScrollOffset + maxVisible) {
1036
- completionScrollOffset = completionIndex - maxVisible + 1;
409
+ if (targetHeight !== currentInputHeight) {
410
+ currentInputHeight = targetHeight;
411
+ input.height = contentHeight;
412
+ promptBox.height = contentHeight;
413
+ inputTopLine.bottom = currentInputHeight - 1; // Just above input area
1037
414
  }
1038
-
1039
- // Calculate visible slice
1040
- const visibleStart = completionScrollOffset;
1041
- const visibleEnd = Math.min(completionScrollOffset + maxVisible, completionCommands.length);
1042
- const visibleCommands = completionCommands.slice(visibleStart, visibleEnd);
1043
-
1044
- const panelWidth = typeof completionPanel.width === "number"
1045
- ? completionPanel.width
1046
- : screen.width;
1047
- const lines = visibleCommands.map((item, i) => {
1048
- const actualIndex = visibleStart + i;
1049
- const cmdText = item.cmd;
1050
- const descText = item.desc || "";
1051
- const cmdPart = actualIndex === completionIndex
1052
- ? `{inverse}${cmdText}{/inverse}`
1053
- : `{cyan-fg}${cmdText}{/cyan-fg}`;
1054
- const indent = " ".repeat(promptBox.width || 2);
1055
- const maxDescWidth = Math.max(0, panelWidth - indent.length - cmdText.length - 2);
1056
- const trimmedDesc = truncateText(descText, maxDescWidth);
1057
- const descPart = trimmedDesc ? `{gray-fg}${trimmedDesc}{/gray-fg}` : "";
1058
- // Use promptBox width (2) to align with input position
1059
- return descPart
1060
- ? `${indent}${cmdPart} ${descPart}`
1061
- : `${indent}${cmdPart}`;
1062
- });
1063
-
1064
- completionPanel.setContent(lines.join("\n"));
1065
- screen.render();
1066
- }
1067
-
1068
- function completionPageSize() {
1069
- const panelVisible = Math.max(1, (completionPanel.height || 2) - 2);
1070
- return completionVisibleCount
1071
- ? Math.max(1, Math.min(completionVisibleCount, panelVisible))
1072
- : panelVisible;
1073
- }
1074
-
1075
- function completionUp() {
1076
- if (completionCommands.length === 0) return;
1077
- completionIndex = completionIndex <= 0
1078
- ? completionCommands.length - 1
1079
- : completionIndex - 1;
1080
- renderCompletionPanel();
415
+ statusLine.bottom = currentInputHeight;
416
+ // Reposition completion panel if active
417
+ if (completionController.isActive()) completionController.reflow();
418
+ // dashboard and inputBottomLine stay fixed at bottom 0 and 1
419
+ logBox.height = Math.max(1, screen.height - currentInputHeight - 1);
420
+ ensureInputCursorVisible();
1081
421
  }
1082
422
 
1083
- function completionDown() {
1084
- if (completionCommands.length === 0) return;
1085
- completionIndex = completionIndex >= completionCommands.length - 1
1086
- ? 0
1087
- : completionIndex + 1;
1088
- renderCompletionPanel();
1089
- }
423
+ // Override the internal listener to support cursor movement
424
+ input._listener = function(ch, key) {
425
+ inputListenerController.handleKey(ch, key, this);
426
+ };
1090
427
 
1091
- function completionPageUp() {
1092
- if (completionCommands.length === 0) return;
1093
- const step = completionPageSize();
1094
- completionIndex = Math.max(0, completionIndex - step);
1095
- renderCompletionPanel();
1096
- }
428
+ // Override cursor update to use our cursor position
429
+ input._updateCursor = function() {
430
+ if (this.screen.focused !== this) return;
1097
431
 
1098
- function completionPageDown() {
1099
- if (completionCommands.length === 0) return;
1100
- const step = completionPageSize();
1101
- completionIndex = Math.min(completionCommands.length - 1, completionIndex + step);
1102
- renderCompletionPanel();
1103
- }
432
+ let lpos;
433
+ try { lpos = this._getCoords(); } catch { return; }
434
+ if (!lpos) return;
1104
435
 
1105
- function completionPreview(selected) {
1106
- const current = input.value || "";
1107
- const trimmed = current.trim();
1108
- const endsWithSpace = /\s$/.test(current);
1109
- if (selected.isSubcommand) {
1110
- const parts = trimmed.split(/\s+/);
1111
- const base = parts[0] || "";
1112
- const completedCore = base ? `${base} ${selected.cmd}` : selected.cmd;
1113
- const isComplete = trimmed === completedCore || trimmed.startsWith(`${completedCore} `);
1114
- return { text: `${completedCore} `, isComplete };
1115
- }
1116
- const completedCore = selected.cmd;
1117
- const hasChildren = selected.subcommands && selected.subcommands.length > 0;
1118
- const isComplete =
1119
- (trimmed === completedCore && (!hasChildren || endsWithSpace)) ||
1120
- trimmed.startsWith(`${completedCore} `);
1121
- return { text: `${completedCore} `, isComplete };
1122
- }
436
+ const innerWidth = getWrapWidth();
437
+ if (innerWidth <= 0) return;
1123
438
 
1124
- function applyCompletionPreview(preview) {
1125
- input.value = preview.text;
1126
- cursorPos = input.value.length;
1127
- resetPreferredCol();
1128
- input._updateCursor();
1129
- updateDraftFromInput();
1130
- screen.render();
1131
- }
439
+ ensureInputCursorVisible();
440
+ const { row, col } = getCursorRowCol(this.value, cursorPos, innerWidth);
441
+ const scrollOffset = this.childBase || 0;
1132
442
 
1133
- function truncateText(text, maxWidth) {
1134
- if (maxWidth <= 0) return "";
1135
- if (text.length <= maxWidth) return text;
1136
- if (maxWidth <= 3) return text.slice(0, maxWidth);
1137
- return `${text.slice(0, maxWidth - 3)}...`;
1138
- }
443
+ const displayRow = row - scrollOffset;
444
+ const safeCol = Math.min(Math.max(0, col), innerWidth - 1);
445
+ const cy = lpos.yi + displayRow;
446
+ const cx = lpos.xi + safeCol;
1139
447
 
1140
- function confirmCompletion() {
1141
- if (!completionActive || completionCommands.length === 0) return;
1142
-
1143
- const selected = completionCommands[completionIndex];
1144
-
1145
- if (selected.isSubcommand) {
1146
- // Subcommand: replace the last word with selected subcommand
1147
- const parts = input.value.split(/\s+/);
1148
- parts[parts.length - 1] = selected.cmd;
1149
- input.value = parts.join(" ") + " ";
1150
- } else {
1151
- // Main command
1152
- input.value = selected.cmd + " ";
1153
- }
1154
-
1155
- cursorPos = input.value.length;
1156
- resetPreferredCol();
1157
- input._updateCursor();
1158
- updateDraftFromInput();
1159
-
1160
- // If selected command has subcommands, trigger subcommand completion immediately
1161
- if (!selected.isSubcommand && selected.subcommands && selected.subcommands.length > 0) {
1162
- // Don't hide - directly show subcommand completion
1163
- showCompletion(input.value);
1164
- } else {
1165
- // No subcommands - hide completion
1166
- hideCompletion();
1167
- }
1168
-
1169
- screen.render();
1170
- }
1171
-
1172
- function handleCompletionKey(ch, key) {
1173
- if (!completionActive) return false;
1174
-
1175
- if (key.name === "up") {
1176
- completionUp();
1177
- return true;
1178
- }
1179
- if (key.name === "down") {
1180
- completionDown();
1181
- return true;
1182
- }
1183
- if (key.name === "tab") {
1184
- confirmCompletion();
1185
- return true;
1186
- }
1187
- if (key.name === "pageup") {
1188
- completionPageUp();
1189
- return true;
1190
- }
1191
- if (key.name === "pagedown") {
1192
- completionPageDown();
1193
- return true;
1194
- }
1195
- if (key.name === "enter" || key.name === "return") {
1196
- if (completionEnterSuppressed) {
1197
- return true;
1198
- }
1199
- const selected = completionCommands[completionIndex];
1200
- if (selected) {
1201
- const preview = completionPreview(selected);
1202
- if (!preview.isComplete) {
1203
- applyCompletionPreview(preview);
1204
- if (!selected.isSubcommand && selected.subcommands && selected.subcommands.length > 0) {
1205
- showCompletion(input.value);
1206
- } else {
1207
- hideCompletion();
1208
- }
1209
- completionEnterSuppressed = true;
1210
- if (completionEnterReset) clearImmediate(completionEnterReset);
1211
- completionEnterReset = setImmediate(() => {
1212
- completionEnterSuppressed = false;
1213
- });
1214
- return true;
1215
- }
1216
- }
1217
- // Already complete; allow normal submit
1218
- hideCompletion();
1219
- completionEnterSuppressed = true;
1220
- if (completionEnterReset) clearImmediate(completionEnterReset);
1221
- completionEnterReset = setImmediate(() => {
1222
- completionEnterSuppressed = false;
1223
- });
1224
- return false;
1225
- }
1226
- if (key.name === "escape") {
1227
- hideCompletion();
1228
- return true;
1229
- }
1230
- if (ch === " ") {
1231
- // Check if current input is a command that might have subcommands
1232
- const currentInput = input.value.trim();
1233
- if (currentInput.startsWith("/") && !currentInput.includes(" ")) {
1234
- // Let space be inserted, will trigger subcommand completion
1235
- return false;
1236
- }
1237
- hideCompletion();
1238
- return false;
1239
- }
1240
- // Regular character and backspace - don't intercept, let it be handled normally
1241
- // Completion will be updated in the main input handler
1242
- return false;
1243
- }
1244
-
1245
- // Resize input box based on content
1246
- function resizeInput() {
1247
- const innerWidth = getWrapWidth();
1248
- if (innerWidth <= 0) return;
1249
-
1250
- const numLines = countLines(input.value, innerWidth);
1251
- const contentHeight = Math.min(MAX_INPUT_HEIGHT - 3, Math.max(1, numLines));
1252
- const targetHeight = contentHeight + 3; // +1 topLine +1 bottomLine +1 dashboard
1253
-
1254
- if (targetHeight !== currentInputHeight) {
1255
- currentInputHeight = targetHeight;
1256
- input.height = contentHeight;
1257
- promptBox.height = contentHeight;
1258
- inputTopLine.bottom = currentInputHeight - 1; // Just above input area
1259
- }
1260
- statusLine.bottom = currentInputHeight;
1261
- // Reposition completion panel if active
1262
- if (completionActive) {
1263
- completionPanel.bottom = currentInputHeight - 1;
1264
- // Re-clamp visible count for new available space
1265
- const availableHeight = screen.height - currentInputHeight - 1;
1266
- const maxVisible = Math.min(7, completionCommands.length);
1267
- completionVisibleCount = Math.min(maxVisible, Math.max(1, availableHeight - 2));
1268
- completionPanel.height = completionVisibleCount + 2;
1269
- renderCompletionPanel();
1270
- }
1271
- // dashboard and inputBottomLine stay fixed at bottom 0 and 1
1272
- logBox.height = Math.max(1, screen.height - currentInputHeight - 1);
1273
- ensureInputCursorVisible();
1274
- }
1275
-
1276
- // Override the internal listener to support cursor movement
1277
- input._listener = function(ch, key) {
1278
- if (currentView === "agent") return; // Agent view handles keys at screen level
1279
- if (key && key.ctrl && key.name === "c") {
1280
- exitHandler();
1281
- return;
1282
- }
1283
- if (suppressKeypress) {
1284
- return;
1285
- }
1286
- normalizeCommandPrefix();
1287
- if (focusMode === "dashboard") {
1288
- if (handleDashboardKey(key)) return;
1289
- // On agents view, printable char auto-exits dashboard keeping @target
1290
- if (dashboardView === "agents" && ch && ch.length === 1 && !key.ctrl && !key.meta
1291
- && !/^[\x00-\x1f\x7f]$/.test(ch)) {
1292
- exitDashboardMode(true);
1293
- // Fall through to normal input handling so the char is inserted
1294
- } else {
1295
- return;
1296
- }
1297
- }
1298
-
1299
- // Command completion mode
1300
- if (completionActive) {
1301
- if (handleCompletionKey(ch, key)) return;
1302
- }
1303
- if (key && (key.name === "pageup" || key.name === "pagedown")) {
1304
- const delta = Math.max(1, Math.floor(logBox.height / 2));
1305
- scrollLog(key.name === "pageup" ? -delta : delta);
1306
- return;
1307
- }
1308
-
1309
- // Treat multi-char input (paste) as insertion, including newlines.
1310
- if (ch && ch.length > 1 && (!key || !key.name || key.name.length !== 1)) {
1311
- insertTextAtCursor(normalizePaste(ch));
1312
- return;
1313
- }
1314
- if (ch && (ch.includes("\n") || ch.includes("\r")) && (!key || (key.name !== "return" && key.name !== "enter"))) {
1315
- insertTextAtCursor(normalizePaste(ch));
1316
- return;
1317
- }
1318
- // Plain enter submits, shift+enter inserts newline
1319
- if (key.name === "return" || key.name === "enter") {
1320
- if (key.shift) {
1321
- // Insert newline at cursor
1322
- insertTextAtCursor("\n");
1323
- } else {
1324
- // Submit
1325
- resetPreferredCol();
1326
- this._done(null, this.value);
1327
- }
1328
- return;
1329
- }
1330
-
1331
- if (key.name === "left") {
1332
- if (cursorPos > 0) cursorPos--;
1333
- resetPreferredCol();
1334
- ensureInputCursorVisible();
1335
- this._updateCursor();
1336
- this.screen.render();
1337
- return;
1338
- }
1339
-
1340
- if (key.name === "right") {
1341
- if (cursorPos < this.value.length) cursorPos++;
1342
- resetPreferredCol();
1343
- ensureInputCursorVisible();
1344
- this._updateCursor();
1345
- this.screen.render();
1346
- return;
1347
- }
1348
-
1349
- if (key.name === "home") {
1350
- cursorPos = 0;
1351
- resetPreferredCol();
1352
- ensureInputCursorVisible();
1353
- this._updateCursor();
1354
- this.screen.render();
1355
- return;
1356
- }
1357
-
1358
- if (key.name === "end") {
1359
- cursorPos = this.value.length;
1360
- resetPreferredCol();
1361
- ensureInputCursorVisible();
1362
- this._updateCursor();
1363
- this.screen.render();
1364
- return;
1365
- }
1366
-
1367
- if (key.name === "up") {
1368
- // Special case: "/" + Up → jump to last command in completion
1369
- if (completionActive && input.value === "/" && cursorPos === 1) {
1370
- completionIndex = completionCommands.length - 1;
1371
- renderCompletionPanel();
1372
- return;
1373
- }
1374
- if (historyUp()) {
1375
- hideCompletion();
1376
- return;
1377
- }
1378
- }
1379
- if (key.name === "down") {
1380
- if (historyDown()) {
1381
- hideCompletion();
1382
- return;
1383
- }
1384
- }
1385
- if (key.name === "up" || key.name === "down") {
1386
- const innerWidth = getWrapWidth();
1387
- if (innerWidth > 0) {
1388
- const { row, col } = getCursorRowCol(this.value, cursorPos, innerWidth);
1389
- if (preferredCol === null) preferredCol = col;
1390
- const totalRows = countLines(this.value, innerWidth);
1391
-
1392
- // Down at last row -> enter dashboard mode
1393
- if (key.name === "down" && row >= totalRows - 1) {
1394
- enterDashboardMode();
1395
- return;
1396
- }
1397
-
1398
- const targetRow = key.name === "up"
1399
- ? Math.max(0, row - 1)
1400
- : Math.min(totalRows - 1, row + 1);
1401
- cursorPos = getCursorPosForRowCol(this.value, targetRow, preferredCol, innerWidth);
1402
- }
1403
- ensureInputCursorVisible();
1404
- this._updateCursor();
1405
- this.screen.render();
1406
- return;
1407
- }
1408
-
1409
- if (key.name === "escape") {
1410
- this._done(null, null);
1411
- return;
1412
- }
1413
-
1414
- if (key.name === "backspace") {
1415
- if (cursorPos > 0) {
1416
- this.value = this.value.slice(0, cursorPos - 1) + this.value.slice(cursorPos);
1417
- cursorPos--;
1418
- resetPreferredCol();
1419
- resizeInput();
1420
- ensureInputCursorVisible();
1421
- this._updateCursor();
1422
- updateDraftFromInput();
1423
-
1424
- // Update or hide completion after backspace
1425
- if (this.value.startsWith("/")) {
1426
- showCompletion(this.value);
1427
- } else {
1428
- hideCompletion();
1429
- }
1430
-
1431
- this.screen.render();
1432
- }
1433
- return;
1434
- }
1435
-
1436
- if (key.name === "delete") {
1437
- if (cursorPos < this.value.length) {
1438
- this.value = this.value.slice(0, cursorPos) + this.value.slice(cursorPos + 1);
1439
- resetPreferredCol();
1440
- resizeInput();
1441
- ensureInputCursorVisible();
1442
- this._updateCursor();
1443
- this.screen.render();
1444
- updateDraftFromInput();
1445
- }
1446
- return;
1447
- }
1448
-
1449
- // Insert character at cursor position
1450
- const insertChar = (ch && ch.length === 1)
1451
- ? ch
1452
- : (key && key.name && key.name.length === 1 ? key.name : null);
1453
- if (insertChar && !/^[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]$/.test(insertChar)) {
1454
- this.value = this.value.slice(0, cursorPos) + insertChar + this.value.slice(cursorPos);
1455
- cursorPos++;
1456
- normalizeCommandPrefix();
1457
- resetPreferredCol();
1458
- resizeInput();
1459
- this._updateCursor();
1460
- updateDraftFromInput();
1461
-
1462
- // Update completion filter if typing after "/"
1463
- if (this.value.startsWith("/")) {
1464
- showCompletion(this.value);
1465
- } else if (completionActive) {
1466
- hideCompletion();
1467
- }
1468
-
1469
- this.screen.render();
1470
- return;
1471
- }
1472
- };
1473
-
1474
- // Override cursor update to use our cursor position
1475
- input._updateCursor = function() {
1476
- if (this.screen.focused !== this) return;
1477
-
1478
- let lpos;
1479
- try { lpos = this._getCoords(); } catch { return; }
1480
- if (!lpos) return;
1481
-
1482
- const innerWidth = getWrapWidth();
1483
- if (innerWidth <= 0) return;
1484
-
1485
- ensureInputCursorVisible();
1486
- const { row, col } = getCursorRowCol(this.value, cursorPos, innerWidth);
1487
- const scrollOffset = this.childBase || 0;
1488
-
1489
- const displayRow = row - scrollOffset;
1490
- const safeCol = Math.min(Math.max(0, col), innerWidth - 1);
1491
- const cy = lpos.yi + displayRow;
1492
- const cx = lpos.xi + safeCol;
1493
-
1494
- this.screen.program.cup(cy, cx);
1495
- this.screen.program.showCursor();
1496
- };
448
+ this.screen.program.cup(cy, cx);
449
+ this.screen.program.showCursor();
450
+ };
1497
451
 
1498
452
  // Reset cursor and height on clear
1499
453
  const originalClearValue = input.clearValue.bind(input);
@@ -1501,9 +455,8 @@ async function runChat(projectRoot) {
1501
455
  cursorPos = 0;
1502
456
  resetPreferredCol();
1503
457
  currentInputHeight = MIN_INPUT_HEIGHT;
1504
- historyIndex = inputHistory.length;
1505
- historyDraft = "";
1506
- hideCompletion();
458
+ if (inputHistoryController) inputHistoryController.setIndexToEnd();
459
+ completionController.hide();
1507
460
  const contentHeight = 1; // MIN content height
1508
461
  input.height = contentHeight;
1509
462
  promptBox.height = contentHeight;
@@ -1515,1725 +468,841 @@ async function runChat(projectRoot) {
1515
468
 
1516
469
  let pending = null;
1517
470
 
1518
- // Command completion state
1519
- let completionActive = false;
1520
- let completionCommands = [];
1521
- let completionIndex = 0;
1522
- let completionScrollOffset = 0;
1523
- let completionVisibleCount = 0;
1524
- let completionEnterSuppressed = false;
1525
- let completionEnterReset = null;
1526
-
1527
- const COMMAND_TREE = {
1528
- "/bus": {
1529
- desc: "Event bus operations",
1530
- children: {
1531
- activate: { desc: "Activate agent terminal" },
1532
- list: { desc: "List all agents" },
1533
- rename: { desc: "Rename agent nickname" },
1534
- send: { desc: "Send message to agent" },
1535
- status: { desc: "Bus status" },
1536
- },
1537
- },
1538
- "/ctx": {
1539
- desc: "Context management",
1540
- children: {
1541
- decisions: { desc: "List all decisions" },
1542
- doctor: { desc: "Check context integrity" },
1543
- status: { desc: "Show context status (default)" },
1544
- },
1545
- },
1546
- "/daemon": {
1547
- desc: "Daemon management",
1548
- children: {
1549
- restart: { desc: "Restart daemon" },
1550
- start: { desc: "Start daemon" },
1551
- status: { desc: "Daemon status" },
1552
- stop: { desc: "Stop daemon" },
1553
- },
1554
- },
1555
- "/doctor": { desc: "Health check diagnostics" },
1556
- "/init": { desc: "Initialize modules" },
1557
- "/launch": {
1558
- desc: "Launch new agent",
1559
- children: {
1560
- claude: { desc: "Launch Claude agent" },
1561
- codex: { desc: "Launch Codex agent" },
1562
- },
1563
- },
1564
- "/resume": { desc: "Resume agents (optional nickname)" },
1565
- "/skills": {
1566
- desc: "Skills management",
1567
- children: {
1568
- install: { desc: "Install skills (use: all or name)" },
1569
- list: { desc: "List available skills" },
1570
- },
1571
- },
1572
- "/status": { desc: "Status display" },
1573
- };
1574
-
1575
- function buildCommandRegistry(tree) {
1576
- return Object.keys(tree)
1577
- .sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" }))
1578
- .map((cmd) => {
1579
- const node = tree[cmd] || {};
1580
- const entry = { cmd, desc: node.desc || "" };
1581
- if (node.children) {
1582
- entry.subcommands = Object.keys(node.children)
1583
- .sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" }))
1584
- .map((sub) => ({
1585
- cmd: sub,
1586
- desc: (node.children[sub] && node.children[sub].desc) || "",
1587
- }));
1588
- }
1589
- return entry;
1590
- });
1591
- }
1592
-
1593
- const COMMAND_REGISTRY = buildCommandRegistry(COMMAND_TREE);
1594
-
1595
471
  // Agent selection state
1596
472
  let activeAgents = [];
1597
473
  let activeAgentLabelMap = new Map();
1598
474
  let activeAgentMetaMap = new Map(); // Store full meta including launch_mode
1599
475
  let agentListWindowStart = 0;
1600
- const MAX_AGENT_WINDOW = 5;
476
+ const MAX_AGENT_WINDOW = 4;
1601
477
  let selectedAgentIndex = -1; // -1 = not in dashboard selection mode
1602
478
  let targetAgent = null; // Selected agent for direct messaging
1603
479
  let focusMode = "input"; // "input" or "dashboard"
1604
- let dashboardView = "agents"; // "agents" | "mode" | "provider" | "resume"
1605
- const launchModes = ["auto", "terminal", "tmux", "internal"];
1606
- function modeToIndex(m) { const i = launchModes.indexOf(m); return i >= 0 ? i : 0; }
1607
- let selectedModeIndex = modeToIndex(launchMode);
480
+ let dashboardView = "agents"; // "agents" | "mode" | "provider" | "assistant" | "cron"
481
+ let reportPendingTotal = 0;
482
+ let selectedModeIndex = launchMode === "internal" ? 2 : (launchMode === "tmux" ? 1 : 0);
1608
483
  const providerOptions = [
1609
484
  { label: "codex", value: "codex-cli" },
1610
485
  { label: "claude", value: "claude-cli" },
486
+ { label: "ucode", value: "ucode" },
487
+ ];
488
+ let selectedProviderIndex = Math.max(0, providerOptions.findIndex((opt) => opt.value === agentProvider));
489
+ const assistantOptions = [
490
+ { label: "auto", value: "auto" },
491
+ { label: "codex", value: "codex" },
492
+ { label: "claude", value: "claude" },
493
+ { label: "ucode", value: "ufoo" },
1611
494
  ];
1612
- let selectedProviderIndex = agentProvider === "claude-cli" ? 1 : 0;
495
+ let selectedAssistantIndex = Math.max(
496
+ 0,
497
+ assistantOptions.findIndex((opt) => opt.value === assistantEngine)
498
+ );
1613
499
  const resumeOptions = [
1614
- { label: "Auto", value: true },
1615
- { label: "Off", value: false },
500
+ { label: "Resume previous session", value: true },
501
+ { label: "Start new session", value: false },
1616
502
  ];
1617
503
  let selectedResumeIndex = autoResume ? 0 : 1;
1618
- let restartInProgress = false;
504
+ const DASH_HINTS = {
505
+ agents: "←/→ select · Enter · ↓ mode · ↑ back",
506
+ agentsEmpty: "↓ mode · ↑ back",
507
+ mode: "←/→ select · Enter · ↓ provider · ↑ back",
508
+ provider: "←/→ select · Enter · ↓ assistant · ↑ back",
509
+ assistant: "←/→ select · Enter · ↓ cron · ↑ back",
510
+ cron: "Ctrl+X close · ↑ back",
511
+ resume: "",
512
+ };
513
+ const AGENT_BAR_HINTS = {
514
+ normal: "↓ agents",
515
+ dashboard: "←/→ · Enter · ↑ · ^X",
516
+ };
1619
517
 
1620
- function getAgentLabel(agentId) {
1621
- return activeAgentLabelMap.get(agentId) || agentId;
518
+ function getCurrentView() {
519
+ return agentViewController ? agentViewController.getCurrentView() : "main";
1622
520
  }
1623
521
 
1624
- function clampAgentWindow() {
1625
- if (activeAgents.length === 0) {
1626
- agentListWindowStart = 0;
1627
- return;
1628
- }
1629
- const maxItems = Math.max(1, Math.min(MAX_AGENT_WINDOW, activeAgents.length));
1630
- if (selectedAgentIndex >= 0) {
1631
- if (selectedAgentIndex < agentListWindowStart) {
1632
- agentListWindowStart = selectedAgentIndex;
1633
- } else if (selectedAgentIndex >= agentListWindowStart + maxItems) {
1634
- agentListWindowStart = selectedAgentIndex - maxItems + 1;
1635
- }
1636
- }
1637
- const maxStart = Math.max(0, activeAgents.length - maxItems);
1638
- if (agentListWindowStart > maxStart) agentListWindowStart = maxStart;
1639
- if (agentListWindowStart < 0) agentListWindowStart = 0;
522
+ function getViewingAgent() {
523
+ return agentViewController ? agentViewController.getViewingAgent() : "";
1640
524
  }
1641
525
 
1642
- function send(req) {
1643
- if (!client || client.destroyed) {
1644
- enqueueRequest(req);
1645
- void ensureConnected();
1646
- return;
1647
- }
1648
- client.write(`${JSON.stringify(req)}\n`);
526
+ function getAgentAdapter(agentId) {
527
+ if (!terminalAdapterRouter) return null;
528
+ const meta = activeAgentMetaMap ? activeAgentMetaMap.get(agentId) : null;
529
+ const agentLaunchMode = (meta && meta.launch_mode) || launchMode || "";
530
+ return terminalAdapterRouter.getAdapter({ launchMode: agentLaunchMode, agentId });
1649
531
  }
1650
532
 
1651
- function updatePromptBox() {
1652
- if (targetAgent) {
1653
- const label = getAgentLabel(targetAgent);
1654
- promptBox.setContent(`@${label}>`);
1655
- promptBox.width = label.length + 3; // @name>
1656
- input.left = promptBox.width;
1657
- input.width = `100%-${promptBox.width}`;
1658
- } else {
1659
- promptBox.setContent(">");
1660
- promptBox.width = 2;
1661
- input.left = 2;
1662
- input.width = "100%-2";
1663
- }
1664
- resizeInput();
1665
- input._updateCursor();
533
+ function getViewingAgentAdapter() {
534
+ const viewingAgent = getViewingAgent();
535
+ if (!viewingAgent) return null;
536
+ return getAgentAdapter(viewingAgent);
1666
537
  }
1667
538
 
1668
- function focusInput() {
1669
- input.focus();
1670
- input._updateCursor();
539
+ function canSendRaw(adapter) {
540
+ if (!adapter || !adapter.capabilities) return false;
541
+ return Boolean(
542
+ adapter.capabilities.supportsSocketProtocol
543
+ || adapter.capabilities.supportsInternalQueueLoop
544
+ );
1671
545
  }
1672
546
 
1673
- function focusLog() {
1674
- logBox.focus();
1675
- screen.program.hideCursor();
547
+ function canResize(adapter) {
548
+ return Boolean(adapter && adapter.capabilities && adapter.capabilities.supportsSocketProtocol);
1676
549
  }
1677
550
 
1678
- function scrollLog(offset) {
1679
- logBox.scroll(offset);
1680
- screen.render();
551
+ function canSnapshot(adapter) {
552
+ if (!adapter || !adapter.capabilities) return false;
553
+ return Boolean(
554
+ adapter.capabilities.supportsSnapshot
555
+ || adapter.capabilities.supportsSubscribeScreen
556
+ || adapter.capabilities.supportsSubscribeFull
557
+ );
1681
558
  }
1682
559
 
1683
- function setLaunchMode(mode) {
1684
- const next = normalizeLaunchMode(mode);
1685
- if (next === launchMode) return;
1686
- // Check tmux availability before switching
1687
- if (next === "tmux" && !process.env.TMUX) {
1688
- logMessage("error", "{red-fg}✗{/red-fg} tmux mode requires running inside a tmux session");
1689
- return;
560
+ function sendRawWithCapabilities(data) {
561
+ const adapter = getViewingAgentAdapter();
562
+ if (!canSendRaw(adapter)) return;
563
+ try {
564
+ adapter.sendRaw(data);
565
+ } catch {
566
+ // ignore unsupported errors
1690
567
  }
1691
- launchMode = next;
1692
- selectedModeIndex = modeToIndex(launchMode);
1693
- saveConfig(projectRoot, { launchMode });
1694
- logMessage("status", `{magenta-fg}⚙{/magenta-fg} Launch mode: ${launchMode}`);
1695
- renderDashboard();
1696
- screen.render();
1697
- void restartDaemon();
1698
- }
1699
-
1700
-
1701
- function providerLabel(value) {
1702
- return value === "claude-cli" ? "claude" : "codex";
1703
568
  }
1704
569
 
1705
- function clearUfooAgentIdentity() {
1706
- const agentDir = getUfooPaths(projectRoot).agentDir;
1707
- const stateFile = path.join(agentDir, "ufoo-agent.json");
1708
- const historyFile = path.join(agentDir, "ufoo-agent.history.jsonl");
570
+ function sendResizeWithCapabilities(cols, rows) {
571
+ const adapter = getViewingAgentAdapter();
572
+ if (!canResize(adapter)) return;
1709
573
  try {
1710
- fs.rmSync(stateFile, { force: true });
574
+ adapter.resize(cols, rows);
1711
575
  } catch {
1712
- // ignore
576
+ // ignore unsupported errors
1713
577
  }
578
+ }
579
+
580
+ function requestSnapshotWithCapabilities() {
581
+ const adapter = getViewingAgentAdapter();
582
+ if (!canSnapshot(adapter)) return false;
1714
583
  try {
1715
- fs.rmSync(historyFile, { force: true });
584
+ return adapter.snapshot();
1716
585
  } catch {
1717
- // ignore
586
+ return false;
1718
587
  }
1719
588
  }
1720
589
 
1721
- function setAgentProvider(provider) {
1722
- const next = normalizeAgentProvider(provider);
1723
- if (next === agentProvider) return;
1724
- agentProvider = next;
1725
- selectedProviderIndex = agentProvider === "claude-cli" ? 1 : 0;
1726
- saveConfig(projectRoot, { agentProvider });
1727
- clearUfooAgentIdentity();
1728
- logMessage("status", `{magenta-fg}⚙{/magenta-fg} ufoo-agent: ${providerLabel(agentProvider)}`);
1729
- renderDashboard();
1730
- screen.render();
1731
- void restartDaemon();
590
+ function isAgentViewUsesBus() {
591
+ return agentViewController ? agentViewController.isAgentViewUsesBus() : false;
1732
592
  }
1733
593
 
1734
- function setAutoResume(value) {
1735
- const next = value !== false;
1736
- if (next === autoResume) return;
1737
- autoResume = next;
1738
- selectedResumeIndex = autoResume ? 0 : 1;
1739
- saveConfig(projectRoot, { autoResume });
1740
- const label = autoResume ? "Auto" : "Off";
1741
- logMessage("status", `{magenta-fg}⚙{/magenta-fg} Resume: ${label}`);
1742
- renderDashboard();
1743
- screen.render();
594
+ function getAgentInputSuppressUntil() {
595
+ return agentViewController ? agentViewController.getAgentInputSuppressUntil() : 0;
1744
596
  }
1745
597
 
1746
- async function restartDaemon() {
1747
- if (restartInProgress) return;
1748
- restartInProgress = true;
1749
- logMessage("status", "{magenta-fg}⚙{/magenta-fg} Restarting daemon...");
1750
- try {
1751
- if (client) {
1752
- client.removeAllListeners();
1753
- try {
1754
- client.end();
1755
- } catch {
1756
- // ignore
1757
- }
1758
- }
1759
- stopDaemon(projectRoot);
1760
- startDaemon(projectRoot, { forceResume: true });
1761
- const newClient = await connectClient();
1762
- if (newClient) {
1763
- attachClient(newClient);
1764
- logMessage("status", "{green-fg}✓{/green-fg} Daemon reconnected");
1765
- } else {
1766
- logMessage("error", "{red-fg}✗{/red-fg} Failed to reconnect to daemon");
1767
- }
1768
- } finally {
1769
- restartInProgress = false;
598
+ function getAgentOutputSuppressed() {
599
+ return agentViewController ? agentViewController.getAgentOutputSuppressed() : false;
600
+ }
601
+
602
+ function setAgentOutputSuppressed(value) {
603
+ if (agentViewController) {
604
+ agentViewController.setAgentOutputSuppressed(value);
1770
605
  }
1771
606
  }
1772
607
 
1773
- function clearLog() {
1774
- logBox.setContent("");
1775
- if (typeof logBox.scrollTo === "function") {
1776
- logBox.scrollTo(0);
608
+ function renderAgentDashboard() {
609
+ if (agentViewController) {
610
+ agentViewController.renderAgentDashboard();
1777
611
  }
1778
- screen.render();
1779
612
  }
1780
613
 
1781
- function renderDashboard() {
1782
- let content = " ";
1783
- if (focusMode === "dashboard") {
1784
- if (dashboardView === "mode") {
1785
- const modeParts = launchModes.map((mode, i) => {
1786
- if (i === selectedModeIndex) {
1787
- return `{inverse}${mode}{/inverse}`;
1788
- }
1789
- if (mode === launchMode) {
1790
- return `{bold}{cyan-fg}${mode}{/cyan-fg}{/bold}`;
1791
- }
1792
- return `{cyan-fg}${mode}{/cyan-fg}`;
1793
- });
1794
- content += `{gray-fg}Mode:{/gray-fg} ${modeParts.join(" ")}`;
1795
- content += " {gray-fg}│ ←/→ select, Enter confirm, ↓ agent, ↑ back{/gray-fg}";
1796
- } else if (dashboardView === "provider") {
1797
- const providerParts = providerOptions.map((opt, i) => {
1798
- if (i === selectedProviderIndex) {
1799
- return `{inverse}${opt.label}{/inverse}`;
1800
- }
1801
- if (opt.value === agentProvider) {
1802
- return `{bold}{cyan-fg}${opt.label}{/cyan-fg}{/bold}`;
1803
- }
1804
- return `{cyan-fg}${opt.label}{/cyan-fg}`;
1805
- });
1806
- content += `{gray-fg}Agent:{/gray-fg} ${providerParts.join(" ")}`;
1807
- content += " {gray-fg}│ ←/→ select, Enter confirm, ↓ resume, ↑ back{/gray-fg}";
1808
- } else if (dashboardView === "resume") {
1809
- const resumeParts = resumeOptions.map((opt, i) => {
1810
- if (i === selectedResumeIndex) {
1811
- return `{inverse}${opt.label}{/inverse}`;
1812
- }
1813
- if (opt.value === autoResume) {
1814
- return `{bold}{cyan-fg}${opt.label}{/cyan-fg}{/bold}`;
1815
- }
1816
- return `{cyan-fg}${opt.label}{/cyan-fg}`;
1817
- });
1818
- content += `{gray-fg}Resume:{/gray-fg} ${resumeParts.join(" ")}`;
1819
- content += " {gray-fg}│ ←/→ select, Enter confirm, ↑ back{/gray-fg}";
1820
- } else {
1821
- if (activeAgents.length > 0) {
1822
- clampAgentWindow();
1823
- const maxItems = Math.max(1, Math.min(MAX_AGENT_WINDOW, activeAgents.length));
1824
- const start = agentListWindowStart;
1825
- const end = start + maxItems;
1826
- const visibleAgents = activeAgents.slice(start, end);
1827
- const agentParts = visibleAgents.map((agent, i) => {
1828
- const absoluteIndex = start + i;
1829
- const label = getAgentLabel(agent);
1830
- if (absoluteIndex === selectedAgentIndex) {
1831
- return `{inverse}${label}{/inverse}`;
1832
- }
1833
- return `{cyan-fg}${label}{/cyan-fg}`;
1834
- });
1835
- const leftMore = start > 0 ? "{gray-fg}«{/gray-fg} " : "";
1836
- const rightMore = end < activeAgents.length ? " {gray-fg}»{/gray-fg}" : "";
1837
- content += `{gray-fg}Agents:{/gray-fg} ${agentParts.join(" ")}`;
1838
- content = `${content.replace("{gray-fg}Agents:{/gray-fg} ", `{gray-fg}Agents:{/gray-fg} ${leftMore}`)}${rightMore}`;
1839
- content += " {gray-fg}│ ←/→ select, Enter confirm, ^X close, ↓ mode, ↑ back{/gray-fg}";
1840
- } else {
1841
- content += "{gray-fg}Agents:{/gray-fg} {cyan-fg}none{/cyan-fg}";
1842
- content += " {gray-fg}│ ↓ mode, ↑ back{/gray-fg}";
1843
- }
1844
- }
1845
- } else {
1846
- // Normal dashboard display (input mode)
1847
- const agents = activeAgents.length > 0
1848
- ? activeAgents.slice(0, 3).map((id) => {
1849
- const label = getAgentLabel(id);
1850
- return label;
1851
- }).join(", ") + (activeAgents.length > 3 ? ` +${activeAgents.length - 3}` : "")
1852
- : "none";
1853
- content += `{gray-fg}Agents:{/gray-fg} {cyan-fg}${agents}{/cyan-fg}`;
1854
- content += ` {gray-fg}Mode:{/gray-fg} {cyan-fg}${launchMode}{/cyan-fg}`;
1855
- content += ` {gray-fg}Agent:{/gray-fg} {cyan-fg}${providerLabel(agentProvider)}{/cyan-fg}`;
1856
- content += ` {gray-fg}Resume:{/gray-fg} {cyan-fg}${autoResume ? "auto" : "off"}{/cyan-fg}`;
614
+ function setAgentBarVisible(visible) {
615
+ if (agentViewController) {
616
+ agentViewController.setAgentBarVisible(visible);
1857
617
  }
1858
- dashboard.setContent(content);
1859
618
  }
1860
619
 
1861
- function updateDashboard(status) {
1862
- activeAgents = status.active || [];
1863
- const metaList = Array.isArray(status.active_meta) ? status.active_meta : [];
1864
- activeAgentLabelMap = new Map();
1865
- activeAgentMetaMap = new Map();
1866
- let fallbackMap = null;
1867
- if (metaList.length === 0 && activeAgents.length > 0) {
1868
- try {
1869
- const busPath = getUfooPaths(projectRoot).agentsFile;
1870
- const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
1871
- fallbackMap = new Map();
1872
- for (const [id, meta] of Object.entries(bus.agents || {})) {
1873
- if (meta && meta.nickname) fallbackMap.set(id, meta.nickname);
1874
- }
1875
- } catch {
1876
- fallbackMap = null;
1877
- }
620
+ function enterAgentView(agentId, options = {}) {
621
+ if (agentViewController) {
622
+ agentViewController.enterAgentView(agentId, options);
1878
623
  }
1879
- for (const id of activeAgents) {
1880
- const meta = metaList.find((item) => item && item.id === id);
1881
- const label = meta && meta.nickname
1882
- ? meta.nickname
1883
- : (fallbackMap && fallbackMap.get(id)) || id;
1884
- activeAgentLabelMap.set(id, label);
1885
- if (meta) {
1886
- activeAgentMetaMap.set(id, meta);
1887
- }
1888
- }
1889
- clampAgentWindow();
1890
-
1891
- // Check if viewed agent went offline
1892
- if (currentView === "agent" && viewingAgent && !activeAgents.includes(viewingAgent)) {
1893
- writeToAgentTerm("\r\n\x1b[1;31m[Agent went offline]\x1b[0m\r\n");
1894
- exitAgentView();
1895
- return;
1896
- }
1897
-
1898
- // In agent view, only update the dashboard bar (via ANSI, blessed is frozen)
1899
- if (currentView === "agent") {
1900
- if (focusMode === "dashboard") {
1901
- const totalItems = 1 + activeAgents.length;
1902
- if (selectedAgentIndex < 0 || selectedAgentIndex >= totalItems) {
1903
- selectedAgentIndex = 0;
1904
- }
1905
- }
1906
- renderAgentDashboard();
1907
- return;
1908
- }
1909
-
1910
- if (focusMode === "dashboard") {
1911
- if (dashboardView === "agents") {
1912
- if (activeAgents.length === 0) {
1913
- selectedAgentIndex = -1;
1914
- } else if (selectedAgentIndex < 0 || selectedAgentIndex >= activeAgents.length) {
1915
- selectedAgentIndex = 0;
1916
- }
1917
- clampAgentWindow();
1918
- }
1919
- }
1920
- renderDashboard();
1921
- screen.render();
1922
624
  }
1923
625
 
1924
- function enterDashboardMode() {
1925
- focusMode = "dashboard";
1926
- dashboardView = "agents";
1927
- selectedAgentIndex = activeAgents.length > 0 ? 0 : -1;
1928
- agentListWindowStart = 0;
1929
- clampAgentWindow();
1930
- selectedModeIndex = modeToIndex(launchMode);
1931
- selectedProviderIndex = agentProvider === "claude-cli" ? 1 : 0;
1932
- selectedResumeIndex = autoResume ? 0 : 1;
1933
- // Immediately set @target when first agent is selected
1934
- if (selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
1935
- targetAgent = activeAgents[selectedAgentIndex];
1936
- updatePromptBox();
626
+ function exitAgentView() {
627
+ if (agentViewController) {
628
+ agentViewController.exitAgentView();
1937
629
  }
1938
- screen.grabKeys = true;
1939
- renderDashboard();
1940
- screen.program.hideCursor();
1941
- screen.render();
1942
630
  }
1943
631
 
1944
- function handleDashboardKey(key) {
1945
- if (!key || focusMode !== "dashboard") return false;
1946
-
1947
- // Agent TTY view dashboard navigation
1948
- // Items: [ufoo(0), agent1(1), agent2(2), ...]
1949
- if (currentView === "agent") {
1950
- const totalItems = 1 + activeAgents.length; // ufoo + agents
1951
- if (key.name === "left") {
1952
- if (selectedAgentIndex > 0) {
1953
- selectedAgentIndex--;
1954
- }
1955
- renderAgentDashboard();
1956
- return true;
1957
- }
1958
- if (key.name === "right") {
1959
- if (selectedAgentIndex < totalItems - 1) {
1960
- selectedAgentIndex++;
1961
- }
1962
- renderAgentDashboard();
1963
- return true;
1964
- }
1965
- if (key.name === "enter" || key.name === "return") {
1966
- if (selectedAgentIndex === 0) {
1967
- // "ufoo" selected -> exit agent view back to main chat
1968
- exitAgentView();
1969
- } else {
1970
- // Another agent selected -> switch based on launch mode
1971
- const agentId = activeAgents[selectedAgentIndex - 1];
1972
- if (agentId && agentId !== viewingAgent) {
1973
- const meta = activeAgentMetaMap.get(agentId);
1974
- const agentLaunchMode = meta?.launch_mode || "";
1975
-
1976
- if (agentLaunchMode === "tmux" || agentLaunchMode === "terminal") {
1977
- // Exit PTY view, then activate agent's terminal/pane
1978
- exitAgentView();
1979
- try {
1980
- const activator = new AgentActivator(projectRoot);
1981
- activator.activate(agentId).catch(() => {});
1982
- } catch { /* ignore */ }
1983
- } else {
1984
- // Internal mode: switch PTY view
1985
- focusMode = "input";
1986
- enterAgentView(agentId);
1987
- }
1988
- } else {
1989
- // Same agent, just exit dashboard
1990
- focusMode = "input";
1991
- renderAgentDashboard();
1992
- }
1993
- }
1994
- return true;
1995
- }
1996
- if (key.name === "up") {
1997
- // Up exits dashboard back to agent PTY view
1998
- focusMode = "input";
1999
- renderAgentDashboard();
2000
- return true;
2001
- }
2002
- if (key.name === "x" && key.ctrl) {
2003
- // Ctrl+x: close selected agent (not ufoo)
2004
- if (selectedAgentIndex > 0 && selectedAgentIndex <= activeAgents.length) {
2005
- const agentId = activeAgents[selectedAgentIndex - 1];
2006
- const label = getAgentLabel(agentId);
2007
- // If closing the currently viewed agent, exit view first
2008
- if (agentId === viewingAgent) {
2009
- exitAgentView();
2010
- }
2011
- closeAgentViaDaemon(agentId, label);
2012
- }
2013
- return true;
2014
- }
2015
- return true;
2016
- }
2017
-
2018
- if (dashboardView === "mode") {
2019
- const maxMode = launchModes.length - 1;
2020
- if (key.name === "left") {
2021
- selectedModeIndex = selectedModeIndex <= 0 ? maxMode : selectedModeIndex - 1;
2022
- renderDashboard();
2023
- screen.render();
2024
- return true;
2025
- }
2026
- if (key.name === "right") {
2027
- selectedModeIndex = selectedModeIndex >= maxMode ? 0 : selectedModeIndex + 1;
2028
- renderDashboard();
2029
- screen.render();
2030
- return true;
2031
- }
2032
- if (key.name === "down") {
2033
- dashboardView = "provider";
2034
- selectedProviderIndex = agentProvider === "claude-cli" ? 1 : 0;
2035
- renderDashboard();
2036
- screen.render();
2037
- return true;
2038
- }
2039
- if (key.name === "up") {
2040
- dashboardView = "agents";
2041
- // Restore @target when returning to agents page
2042
- if (selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
2043
- targetAgent = activeAgents[selectedAgentIndex];
2044
- updatePromptBox();
2045
- }
2046
- renderDashboard();
2047
- screen.render();
2048
- return true;
2049
- }
2050
- if (key.name === "enter" || key.name === "return") {
2051
- setLaunchMode(launchModes[selectedModeIndex]);
2052
- exitDashboardMode(false);
2053
- return true;
2054
- }
2055
- if (key.name === "escape") {
2056
- exitDashboardMode(false);
2057
- return true;
2058
- }
2059
- return true;
2060
- }
2061
- if (dashboardView === "provider") {
2062
- if (key.name === "left") {
2063
- selectedProviderIndex = selectedProviderIndex <= 0 ? providerOptions.length - 1 : selectedProviderIndex - 1;
2064
- renderDashboard();
2065
- screen.render();
2066
- return true;
2067
- }
2068
- if (key.name === "right") {
2069
- selectedProviderIndex = selectedProviderIndex >= providerOptions.length - 1 ? 0 : selectedProviderIndex + 1;
2070
- renderDashboard();
2071
- screen.render();
2072
- return true;
2073
- }
2074
- if (key.name === "down") {
2075
- dashboardView = "resume";
2076
- selectedResumeIndex = autoResume ? 0 : 1;
2077
- renderDashboard();
2078
- screen.render();
2079
- return true;
2080
- }
2081
- if (key.name === "up") {
2082
- dashboardView = "mode";
2083
- renderDashboard();
2084
- screen.render();
2085
- return true;
2086
- }
2087
- if (key.name === "enter" || key.name === "return") {
2088
- const selected = providerOptions[selectedProviderIndex];
2089
- if (selected) setAgentProvider(selected.value);
2090
- exitDashboardMode(false);
2091
- return true;
2092
- }
2093
- if (key.name === "escape") {
2094
- exitDashboardMode(false);
2095
- return true;
2096
- }
2097
- return true;
2098
- }
2099
- if (dashboardView === "resume") {
2100
- if (key.name === "left") {
2101
- selectedResumeIndex = selectedResumeIndex <= 0 ? resumeOptions.length - 1 : selectedResumeIndex - 1;
2102
- renderDashboard();
2103
- screen.render();
2104
- return true;
2105
- }
2106
- if (key.name === "right") {
2107
- selectedResumeIndex = selectedResumeIndex >= resumeOptions.length - 1 ? 0 : selectedResumeIndex + 1;
2108
- renderDashboard();
2109
- screen.render();
2110
- return true;
2111
- }
2112
- if (key.name === "up") {
2113
- dashboardView = "provider";
2114
- renderDashboard();
2115
- screen.render();
2116
- return true;
2117
- }
2118
- if (key.name === "enter" || key.name === "return") {
2119
- const selected = resumeOptions[selectedResumeIndex];
2120
- if (selected) {
2121
- setAutoResume(selected.value);
2122
- const label = selected.value ? "Auto" : "Off";
2123
- logMessage("status", `{magenta-fg}⚙{/magenta-fg} Resume: ${label}`);
2124
- }
2125
- exitDashboardMode(false);
2126
- return true;
2127
- }
2128
- if (key.name === "escape") {
2129
- exitDashboardMode(false);
2130
- return true;
2131
- }
2132
- return true;
2133
- }
2134
-
2135
- if (key.name === "left") {
2136
- if (activeAgents.length > 0 && selectedAgentIndex > 0) {
2137
- selectedAgentIndex--;
2138
- clampAgentWindow();
2139
- // Update @target in real-time as user navigates
2140
- targetAgent = activeAgents[selectedAgentIndex];
2141
- updatePromptBox();
2142
- renderDashboard();
2143
- screen.render();
2144
- }
2145
- return true;
2146
- }
2147
- if (key.name === "right") {
2148
- if (activeAgents.length > 0 && selectedAgentIndex < activeAgents.length - 1) {
2149
- selectedAgentIndex++;
2150
- clampAgentWindow();
2151
- // Update @target in real-time as user navigates
2152
- targetAgent = activeAgents[selectedAgentIndex];
2153
- updatePromptBox();
2154
- renderDashboard();
2155
- screen.render();
2156
- }
2157
- return true;
2158
- }
2159
- if (key.name === "down") {
2160
- // Leaving agents page: clear temporary @target
2161
- clearTargetAgent();
2162
- dashboardView = "mode";
2163
- selectedModeIndex = modeToIndex(launchMode);
2164
- renderDashboard();
2165
- screen.render();
2166
- return true;
2167
- }
2168
- if (key.name === "up" || key.name === "escape") {
2169
- // Cancel: clear @target, back to normal chat
2170
- clearTargetAgent();
2171
- exitDashboardMode(false);
2172
- return true;
2173
- }
2174
- if (key.name === "x" && key.ctrl) {
2175
- // Ctrl+x: close selected agent
2176
- if (selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
2177
- const agentId = activeAgents[selectedAgentIndex];
2178
- const label = getAgentLabel(agentId);
2179
- closeAgentViaDaemon(agentId, label);
2180
- clearTargetAgent();
2181
- exitDashboardMode(false);
2182
- }
2183
- return true;
2184
- }
2185
- if (key.name === "enter" || key.name === "return") {
2186
- // Enter: action depends on agent's launch mode
2187
- if (selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
2188
- const agentId = activeAgents[selectedAgentIndex];
2189
- const meta = activeAgentMetaMap.get(agentId);
2190
- const agentLaunchMode = meta?.launch_mode || "";
2191
-
2192
- if (agentLaunchMode === "tmux" || agentLaunchMode === "terminal") {
2193
- // Tmux: select pane; Terminal: activate tab/window by tty
2194
- clearTargetAgent();
2195
- exitDashboardMode(false);
2196
- try {
2197
- const activator = new AgentActivator(projectRoot);
2198
- activator.activate(agentId).catch(() => {});
2199
- } catch { /* ignore */ }
2200
- return true;
2201
- }
2202
-
2203
- // Internal / internal-pty mode: enter PTY view if inject.sock exists
2204
- const sockPath = getInjectSockPath(agentId);
2205
- if (fs.existsSync(sockPath)) {
2206
- clearTargetAgent();
2207
- focusMode = "input";
2208
- dashboardView = "agents";
2209
- selectedAgentIndex = -1;
2210
- screen.grabKeys = false;
2211
- enterAgentView(agentId);
2212
- return true;
2213
- }
2214
- }
2215
- // Fallback: just exit dashboard, keep @target for messaging
2216
- exitDashboardMode(false);
2217
- return true;
632
+ function sendRawToAgent(data) {
633
+ if (agentViewController) {
634
+ agentViewController.sendRawToAgent(data);
2218
635
  }
2219
- return false;
2220
636
  }
2221
637
 
2222
- function exitDashboardMode(selectAgent = false) {
2223
- if (selectAgent && selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
2224
- targetAgent = activeAgents[selectedAgentIndex];
2225
- updatePromptBox();
638
+ function sendResizeToAgent(cols, rows) {
639
+ if (agentViewController) {
640
+ agentViewController.sendResizeToAgent(cols, rows);
2226
641
  }
2227
- focusMode = "input";
2228
- dashboardView = "agents";
2229
- selectedAgentIndex = -1;
2230
- screen.grabKeys = false;
2231
- renderDashboard();
2232
- focusInput();
2233
- screen.render();
2234
642
  }
2235
643
 
2236
- function clearTargetAgent() {
2237
- targetAgent = null;
2238
- updatePromptBox();
2239
- screen.render();
2240
- }
2241
-
2242
- function getInjectSockPath(agentId) {
2243
- const safeName = subscriberToSafeName(agentId);
2244
- return path.join(getUfooPaths(projectRoot).busQueuesDir, safeName, "inject.sock");
2245
- }
2246
-
2247
- function closeAgentViaDaemon(agentId, label) {
2248
- logMessage("system", `{yellow-fg}⚙{/yellow-fg} Closing ${label}...`);
2249
- const sockFile = socketPath(projectRoot);
2250
- try {
2251
- const conn = net.createConnection(sockFile, () => {
2252
- conn.write(JSON.stringify({ type: "close_agent", agentId }) + "\n");
2253
- });
2254
- let buffer = "";
2255
- conn.on("data", (data) => {
2256
- buffer += data.toString("utf8");
2257
- const lines = buffer.split("\n");
2258
- buffer = lines.pop() || "";
2259
- for (const line of lines) {
2260
- if (!line.trim()) continue;
2261
- try {
2262
- const res = JSON.parse(line);
2263
- if (res.type === "close_agent_ok") {
2264
- if (res.ok) {
2265
- logMessage("system", `{green-fg}✓{/green-fg} Closed ${label}`);
2266
- } else {
2267
- logMessage("system", `{red-fg}✗{/red-fg} Agent ${label} not found or already stopped`);
2268
- }
2269
- }
2270
- } catch { /* ignore */ }
2271
- }
2272
- });
2273
- conn.on("error", () => {
2274
- logMessage("error", `{red-fg}✗{/red-fg} Failed to connect to daemon`);
2275
- });
2276
- setTimeout(() => { try { conn.destroy(); } catch {} }, 3000);
2277
- } catch {
2278
- logMessage("error", `{red-fg}✗{/red-fg} Failed to close ${label}`);
644
+ function requestAgentSnapshot() {
645
+ if (agentViewController) {
646
+ agentViewController.requestAgentSnapshot();
2279
647
  }
2280
648
  }
2281
649
 
2282
- // Freeze blessed rendering during agent PTY view (direct stdout mode)
2283
- const _originalRender = screen.render.bind(screen);
2284
- let renderFrozen = false;
2285
- screen.render = function() {
2286
- if (renderFrozen) return;
2287
- return _originalRender();
2288
- };
2289
-
2290
- // Render agent view dashboard bar via ANSI — matches blessed dashboard style
2291
- function renderAgentDashboard() {
2292
- const rows = process.stdout.rows || 24;
2293
- const cols = process.stdout.columns || 80;
2294
- let bar = " ";
2295
-
2296
- if (focusMode === "dashboard") {
2297
- // Dashboard mode: \x1b[90;7m = gray+inverse, matches blessed {inverse} on gray fg widget
2298
- const ufooItem = selectedAgentIndex === 0
2299
- ? "\x1b[90;7mufoo\x1b[0m"
2300
- : "\x1b[36mufoo\x1b[0m";
2301
- const agentParts = activeAgents.map((agent, i) => {
2302
- const label = getAgentLabel(agent);
2303
- const idx = i + 1; // +1 for ufoo at index 0
2304
- if (idx === selectedAgentIndex) return `\x1b[90;7m${label}\x1b[0m`;
2305
- if (agent === viewingAgent) return `\x1b[1;36m${label}\x1b[0m`;
2306
- return `\x1b[36m${label}\x1b[0m`;
2307
- });
2308
- bar += `${ufooItem} ${agentParts.join(" ")}`;
2309
- bar += ` \x1b[90m│ ←/→ select, Enter switch, ^X close, ↑ back\x1b[0m`;
2310
- } else {
2311
- // Normal PTY mode: bold current viewing agent
2312
- const agentParts = activeAgents.map((agent) => {
2313
- const label = getAgentLabel(agent);
2314
- if (agent === viewingAgent) return `\x1b[1;36m${label}\x1b[0m`;
2315
- return `\x1b[36m${label}\x1b[0m`;
2316
- });
2317
- bar += `\x1b[36mufoo\x1b[0m ${agentParts.join(" ")}`;
2318
- bar += ` \x1b[90m│ ↓: agents\x1b[0m`;
650
+ function writeToAgentTerm(text) {
651
+ if (agentViewController) {
652
+ agentViewController.writeToAgentTerm(text);
2319
653
  }
2320
-
2321
- // Pad to full width
2322
- const plainLen = bar.replace(/\x1b\[[0-9;]*m/g, "").length;
2323
- const pad = Math.max(0, cols - plainLen);
2324
- // Save cursor → move to last row → write bar → restore cursor
2325
- process.stdout.write(`\x1b7\x1b[${rows};1H${bar}${" ".repeat(pad)}\x1b8`);
2326
654
  }
2327
655
 
2328
- function enterAgentView(agentId) {
2329
- if (currentView === "agent" && viewingAgent === agentId) return;
2330
- if (currentView === "agent") {
2331
- disconnectAgentOutput();
2332
- disconnectAgentInput();
656
+ function placeAgentCursor(cursor) {
657
+ if (agentViewController) {
658
+ agentViewController.placeAgentCursor(cursor);
2333
659
  }
2334
-
2335
- currentView = "agent";
2336
- viewingAgent = agentId;
2337
- focusMode = "input";
2338
-
2339
- // Detach all blessed widgets from screen — nothing left to render
2340
- _detachedChildren = [...screen.children];
2341
- for (const child of _detachedChildren) screen.remove(child);
2342
-
2343
- // Freeze blessed — we take over the terminal with direct stdout
2344
- renderFrozen = true;
2345
-
2346
- const rows = process.stdout.rows || 24;
2347
- const cols = process.stdout.columns || 80;
2348
- process.stdout.write("\x1b[2J\x1b[H"); // Clear + home
2349
- process.stdout.write(`\x1b[1;${rows - 1}r`); // Scroll region
2350
- process.stdout.write("\x1b[H"); // Cursor to top
2351
- process.stdout.write("\x1b[?25h"); // Show cursor
2352
-
2353
- // Render dashboard bar
2354
- renderAgentDashboard();
2355
-
2356
- // Suppress input forwarding briefly — prevents the Enter that triggered
2357
- // view switch and any terminal query responses (CPR etc) from leaking
2358
- agentInputSuppressUntil = Date.now() + 300;
2359
-
2360
- // Connect to agent's inject.sock for output streaming and input
2361
- const sockPath = getInjectSockPath(agentId);
2362
- connectAgentOutput(sockPath);
2363
- connectAgentInput(sockPath);
2364
-
2365
- // Resize agent PTY to match our viewport (rows-1 for status bar)
2366
- setTimeout(() => sendResizeToAgent(cols, rows - 1), 100);
2367
660
  }
2368
661
 
2369
- function exitAgentView() {
2370
- if (currentView !== "agent") return;
2371
-
2372
- // Restore agent PTY to full terminal size before disconnecting
2373
- const rows = process.stdout.rows || 24;
2374
- const cols = process.stdout.columns || 80;
2375
- sendResizeToAgent(cols, rows);
2376
-
2377
- disconnectAgentOutput();
2378
- disconnectAgentInput();
2379
-
2380
- currentView = "main";
2381
- viewingAgent = null;
2382
-
2383
- // Reset scroll region to full screen
2384
- process.stdout.write(`\x1b[1;${rows}r`);
2385
- process.stdout.write("\x1b[2J\x1b[H");
2386
-
2387
- // Re-attach all blessed widgets to screen
2388
- if (_detachedChildren) {
2389
- for (const child of _detachedChildren) screen.append(child);
2390
- _detachedChildren = null;
2391
- }
2392
-
2393
- // Unfreeze blessed and force full redraw
2394
- renderFrozen = false;
2395
- focusMode = "input";
2396
- dashboardView = "agents";
2397
- selectedAgentIndex = -1;
2398
- screen.grabKeys = false;
2399
- clearTargetAgent();
2400
- renderDashboard();
2401
- focusInput();
2402
- resizeInput();
2403
- screen.alloc();
2404
- screen.render();
662
+ function handleResizeInAgentView() {
663
+ if (!agentViewController) return false;
664
+ return agentViewController.handleResizeInAgentView();
2405
665
  }
2406
666
 
2407
- function connectAgentOutput(sockPath) {
2408
- if (agentOutputClient) {
2409
- disconnectAgentOutput();
2410
- }
2411
- agentOutputBuffer = "";
2412
-
2413
- if (!fs.existsSync(sockPath)) {
2414
- writeToAgentTerm("\x1b[1;31m[Error]\x1b[0m inject.sock not found\r\n");
2415
- writeToAgentTerm("\x1b[33m[Hint]\x1b[0m Agent may not be running in terminal mode\r\n");
2416
- writeToAgentTerm("Press Esc to return\r\n");
2417
- return;
2418
- }
2419
-
2420
- try {
2421
- agentOutputClient = net.createConnection(sockPath, () => {
2422
- agentOutputClient.write(JSON.stringify({ type: "subscribe" }) + "\n");
2423
- });
2424
-
2425
- // Connection timeout
2426
- const connectTimeout = setTimeout(() => {
2427
- if (agentOutputClient && !agentOutputClient.connecting) return;
2428
- writeToAgentTerm("\x1b[1;31m[Timeout]\x1b[0m Could not connect\r\nPress Esc to return\r\n");
2429
- disconnectAgentOutput();
2430
- }, 5000);
2431
-
2432
- agentOutputClient.on("connect", () => {
2433
- clearTimeout(connectTimeout);
2434
- });
667
+ function getAgentLabel(agentId) {
668
+ return agentDirectory.getAgentLabel(activeAgentLabelMap, agentId);
669
+ }
2435
670
 
2436
- agentOutputClient.on("data", (data) => {
2437
- agentOutputBuffer += data.toString("utf8");
2438
- const lines = agentOutputBuffer.split("\n");
2439
- agentOutputBuffer = lines.pop() || "";
2440
-
2441
- for (const line of lines) {
2442
- if (!line.trim()) continue;
2443
- try {
2444
- const msg = JSON.parse(line);
2445
- if (msg.type === "output" || msg.type === "replay") {
2446
- if (msg.data) {
2447
- writeToAgentTerm(msg.data);
2448
- }
2449
- }
2450
- } catch {
2451
- // ignore malformed messages
671
+ function resolveAgentId(label) {
672
+ return agentDirectory.resolveAgentId({
673
+ label,
674
+ activeAgents,
675
+ labelMap: activeAgentLabelMap,
676
+ lookupNickname: (nickname) => {
677
+ try {
678
+ const busPath = getUfooPaths(projectRoot).agentsFile;
679
+ const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
680
+ for (const [id, meta] of Object.entries(bus.agents || {})) {
681
+ if (meta && meta.nickname === nickname) return id;
2452
682
  }
683
+ } catch {
684
+ // ignore lookup errors
2453
685
  }
2454
- });
2455
-
2456
- agentOutputClient.on("error", (err) => {
2457
- if (currentView === "agent") {
2458
- writeToAgentTerm(`\r\n\x1b[1;31m[Connection error]\x1b[0m ${err.message}\r\nPress Esc to return\r\n`);
2459
- }
2460
- });
2461
-
2462
- agentOutputClient.on("close", () => {
2463
- agentOutputClient = null;
2464
- if (currentView === "agent") {
2465
- writeToAgentTerm("\r\n\x1b[1;33m[Agent disconnected]\x1b[0m\r\nPress Esc to return\r\n");
2466
- }
2467
- });
2468
- } catch (err) {
2469
- writeToAgentTerm(`\x1b[1;31m[Error]\x1b[0m ${err.message}\r\nPress Esc to return\r\n`);
2470
- }
2471
- }
2472
-
2473
- function disconnectAgentOutput() {
2474
- if (agentOutputClient) {
2475
- try {
2476
- agentOutputClient.removeAllListeners();
2477
- agentOutputClient.destroy();
2478
- } catch { /* ignore */ }
2479
- agentOutputClient = null;
2480
- }
2481
- agentOutputBuffer = "";
2482
- }
2483
-
2484
- function connectAgentInput(sockPath) {
2485
- if (agentInputClient) {
2486
- disconnectAgentInput();
2487
- }
2488
- try {
2489
- agentInputClient = net.createConnection(sockPath);
2490
- agentInputClient.on("error", () => {
2491
- agentInputClient = null;
2492
- });
2493
- agentInputClient.on("close", () => {
2494
- agentInputClient = null;
2495
- });
2496
- } catch {
2497
- agentInputClient = null;
2498
- }
2499
- }
2500
-
2501
- function disconnectAgentInput() {
2502
- if (agentInputClient) {
2503
- try {
2504
- agentInputClient.removeAllListeners();
2505
- agentInputClient.destroy();
2506
- } catch { /* ignore */ }
2507
- agentInputClient = null;
2508
- }
686
+ return null;
687
+ },
688
+ });
2509
689
  }
2510
690
 
2511
- function sendRawToAgent(data) {
2512
- if (!agentInputClient || agentInputClient.destroyed) return;
2513
- try {
2514
- agentInputClient.write(JSON.stringify({ type: "raw", data }) + "\n");
2515
- } catch {
2516
- // ignore write errors
2517
- }
691
+ function resolveAgentDisplayName(publisher) {
692
+ return agentDirectory.resolveAgentDisplayName({
693
+ publisher,
694
+ labelMap: activeAgentLabelMap,
695
+ lookupNicknameById: (id) => {
696
+ try {
697
+ const busPath = getUfooPaths(projectRoot).agentsFile;
698
+ const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
699
+ const meta = bus.agents && bus.agents[id];
700
+ if (meta && meta.nickname) return meta.nickname;
701
+ } catch {
702
+ // Keep original publisher ID
703
+ }
704
+ return null;
705
+ },
706
+ });
2518
707
  }
2519
708
 
2520
- function sendResizeToAgent(cols, rows) {
2521
- if (!agentInputClient || agentInputClient.destroyed) return;
2522
- try {
2523
- agentInputClient.write(JSON.stringify({ type: "resize", cols, rows }) + "\n");
2524
- } catch {
2525
- // ignore write errors
2526
- }
709
+ function clampAgentWindowWithSelection(selectionIndex) {
710
+ agentListWindowStart = agentDirectory.clampAgentWindowWithSelection({
711
+ activeCount: activeAgents.length,
712
+ maxWindow: MAX_AGENT_WINDOW,
713
+ windowStart: agentListWindowStart,
714
+ selectionIndex,
715
+ });
2527
716
  }
2528
717
 
2529
- function writeToAgentTerm(text) {
2530
- if (!text) return;
2531
- if (currentView === "agent") {
2532
- // Strip sequences that cause the real terminal to respond, feeding
2533
- // garbage back into the agent's input:
2534
- // - OSC queries: \x1b]10;?\x07 etc (color queries)
2535
- // - CSI DSR: \x1b[6n / \x1b[?6n (cursor position query → CPR response)
2536
- // - CSI DSR: \x1b[5n (device status query)
2537
- // - CSI DA: \x1b[c / \x1b[>c / \x1b[=c (device attributes query)
2538
- const cleaned = text
2539
- .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
2540
- .replace(/\x1b\[(?:[?>=]?[0-9]*c|[?]?6n|5n)/g, "");
2541
- if (cleaned) process.stdout.write(cleaned);
2542
- // Always re-render dashboard bar — PTY output may overwrite it
2543
- // via absolute cursor positioning before the resize takes effect
2544
- renderAgentDashboard();
2545
- }
718
+ function clampAgentWindow() {
719
+ clampAgentWindowWithSelection(selectedAgentIndex);
2546
720
  }
2547
721
 
2548
- function requestStatus() {
2549
- send({ type: "status" });
722
+ function send(req) {
723
+ if (!daemonCoordinator) return;
724
+ daemonCoordinator.send(req);
2550
725
  }
2551
726
 
2552
- const detachClient = () => {
2553
- if (!client) return;
2554
- client.removeAllListeners("data");
2555
- client.removeAllListeners("close");
2556
- try {
2557
- client.end();
2558
- client.destroy();
2559
- } catch {
2560
- // ignore
2561
- }
2562
- };
2563
-
2564
- const attachClient = (newClient) => {
2565
- if (!newClient) return;
2566
- detachClient();
2567
- client = newClient;
2568
- connectionLostNotified = false;
2569
- let buffer = "";
2570
- client.on("data", (data) => {
2571
- buffer += data.toString("utf8");
2572
- const lines = buffer.split(/\r?\n/);
2573
- buffer = lines.pop() || "";
2574
- for (const line of lines.filter((l) => l.trim())) {
2575
- try {
2576
- const msg = JSON.parse(line);
2577
- if (msg.type === "status") {
2578
- const data = msg.data || {};
2579
- if (typeof data.phase === "string") {
2580
- const rawText = data.text == null ? "" : String(data.text);
2581
- const item = { key: data.key, text: rawText };
2582
- if (data.phase === "start") {
2583
- enqueueBusStatus(item);
2584
- } else if (data.phase === "done" || data.phase === "error") {
2585
- resolveBusStatus(item);
2586
- if (rawText) {
2587
- const prefix = data.phase === "error"
2588
- ? "{red-fg}✗{/red-fg}"
2589
- : "{green-fg}✓{/green-fg}";
2590
- logMessage("status", `${prefix} ${escapeBlessed(rawText)}`, data);
2591
- }
2592
- } else {
2593
- enqueueBusStatus(item);
2594
- }
2595
- screen.render();
2596
- } else {
2597
- // 收到 dashboard 状态更新
2598
- if (process.env.UFOO_DEBUG) {
2599
- logMessage("debug", `[status] active: ${(data.active || []).length}`);
2600
- }
2601
- updateDashboard(data);
2602
- }
2603
- } else if (msg.type === "response") {
2604
- const payload = msg.data || {};
2605
- if (payload.reply) {
2606
- resolveStatusLine(`{green-fg}←{/green-fg} ${escapeBlessed(payload.reply)}`);
2607
- logMessage("reply", `{green-fg}←{/green-fg} ${escapeBlessed(payload.reply)}`);
2608
- }
2609
- if (payload.dispatch && payload.dispatch.length > 0) {
2610
- const targets = payload.dispatch.map((d) => d.target || d).join(", ");
2611
- logMessage("dispatch", `{blue-fg}→{/blue-fg} Dispatched to: ${escapeBlessed(targets)}`);
2612
- }
2613
- if (payload.disambiguate && Array.isArray(payload.disambiguate.candidates) && payload.disambiguate.candidates.length > 0) {
2614
- pending = { disambiguate: payload.disambiguate, original: pending?.original };
2615
- const prompt = payload.disambiguate.prompt || "Choose target:";
2616
- resolveStatusLine(`{yellow-fg}?{/yellow-fg} ${escapeBlessed(prompt)}`);
2617
- logMessage("disambiguate", `{yellow-fg}?{/yellow-fg} ${escapeBlessed(prompt)}`);
2618
- payload.disambiguate.candidates.forEach((c, i) => {
2619
- const agentId = c.agent_id || "";
2620
- const reason = c.reason || "";
2621
- logMessage(
2622
- "disambiguate",
2623
- ` {cyan-fg}${i + 1}){/cyan-fg} ${escapeBlessed(agentId)} {gray-fg}— ${escapeBlessed(reason)}{/gray-fg}`
2624
- );
2625
- });
2626
- } else {
2627
- pending = null;
2628
- }
2629
- if (!payload.reply && !payload.disambiguate) {
2630
- resolveStatusLine("{gray-fg}✓{/gray-fg} Done");
2631
- }
2632
- // opsResults are noisy JSON; keep them out of the log UI
2633
- screen.render();
2634
- } else if (msg.type === "bus") {
2635
- const data = msg.data || {};
2636
- const prefix = data.event === "broadcast" ? "{magenta-fg}⇢{/magenta-fg}" : "{blue-fg}↔{/blue-fg}";
2637
- let publisher = data.publisher && data.publisher !== "unknown"
2638
- ? data.publisher
2639
- : (data.event === "broadcast" ? "broadcast" : "bus");
2640
-
2641
- // Try to parse message as JSON (from internal agents)
2642
- let displayMessage = data.message == null ? "" : String(data.message);
2643
- let isStream = false;
2644
- try {
2645
- const parsed = JSON.parse(data.message);
2646
- if (parsed && typeof parsed === "object" && parsed.reply) {
2647
- displayMessage = parsed.reply == null ? "" : String(parsed.reply);
2648
- } else if (parsed && typeof parsed === "object" && parsed.stream) {
2649
- displayMessage = typeof parsed.delta === "string" ? parsed.delta : "";
2650
- isStream = true;
2651
- }
2652
- } catch {
2653
- // Not JSON, use as-is
2654
- }
2655
-
2656
- // Convert literal \n to actual newlines for better display
2657
- if (typeof displayMessage === "string") {
2658
- displayMessage = displayMessage.replace(/\\n/g, "\n");
2659
- }
2660
-
2661
- // Extract nickname if publisher is in subscriber:id format
2662
- let displayName = publisher;
2663
- if (publisher.includes(":")) {
2664
- // Try to get nickname from activeAgentLabelMap or all-agents.json
2665
- if (activeAgentLabelMap && activeAgentLabelMap.has(publisher)) {
2666
- displayName = activeAgentLabelMap.get(publisher);
2667
- } else {
2668
- // Fallback: read directly from all-agents.json
2669
- try {
2670
- const busPath = getUfooPaths(projectRoot).agentsFile;
2671
- const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
2672
- const meta = bus.agents && bus.agents[publisher];
2673
- if (meta && meta.nickname) {
2674
- displayName = meta.nickname;
2675
- }
2676
- } catch {
2677
- // Keep original publisher ID
2678
- }
2679
- }
2680
- }
2681
-
2682
- const line = `${prefix} {gray-fg}${escapeBlessed(displayName)}{/gray-fg}: ${escapeBlessed(displayMessage)}`;
2683
- if (isStream) {
2684
- recordLog("bus_stream", line, data, true);
2685
- } else {
2686
- logMessage("bus", line, data);
2687
- }
2688
- if (data.event === "agent_renamed" || data.event === "message") {
2689
- // 收到消息时刷新 status,更新在线 agent 列表
2690
- requestStatus();
2691
- }
2692
- screen.render();
2693
- } else if (msg.type === "error") {
2694
- resolveStatusLine(`{red-fg}✗{/red-fg} Error: ${escapeBlessed(msg.error)}`);
2695
- logMessage("error", `{red-fg}✗{/red-fg} Error: ${escapeBlessed(msg.error)}`);
2696
- screen.render();
2697
- }
2698
- } catch {
2699
- // ignore
2700
- }
2701
- }
727
+ cronScheduler = createCronScheduler({
728
+ dispatch: ({ taskId, target, message }) => {
729
+ send({
730
+ type: IPC_REQUEST_TYPES.BUS_SEND,
731
+ target,
732
+ message,
733
+ });
734
+ queueStatusLine(`cron:${taskId} -> ${target}`);
735
+ },
736
+ onChange: () => {
737
+ renderDashboard();
738
+ screen.render();
739
+ },
2702
740
  });
2703
- const handleDisconnect = () => {
2704
- if (client === newClient) {
2705
- client = null;
2706
- }
2707
- if (exitRequested) return;
2708
- if (!connectionLostNotified) {
2709
- connectionLostNotified = true;
2710
- logMessage("status", "{red-fg}✗{/red-fg} Daemon disconnected");
2711
- }
2712
- void ensureConnected();
2713
- };
2714
- client.on("close", handleDisconnect);
2715
- client.on("error", handleDisconnect);
2716
- flushPendingRequests();
2717
- };
2718
-
2719
- attachClient(client);
2720
-
2721
- // Command handlers
2722
- async function handleDoctorCommand() {
2723
- logMessage("system", "{yellow-fg}⚙{/yellow-fg} Running health check...");
2724
741
 
2725
- // Capture console output safely
2726
- const originalLog = console.log;
2727
- const originalError = console.error;
2728
-
2729
- console.log = (...args) => logMessage("system", args.join(" "));
2730
- console.error = (...args) => logMessage("error", args.join(" "));
2731
-
2732
- try {
2733
- const UfooDoctor = require("../doctor");
2734
- const doctor = new UfooDoctor(projectRoot);
2735
- const result = doctor.run();
2736
-
2737
- if (result) {
2738
- logMessage("system", "{green-fg}✓{/green-fg} System healthy");
2739
- } else {
2740
- logMessage("error", "{red-fg}✗{/red-fg} Health check failed");
2741
- }
2742
- screen.render();
2743
- } catch (err) {
2744
- logMessage("error", `{red-fg}✗{/red-fg} Doctor check failed: ${err.message}`);
2745
- screen.render();
2746
- } finally {
2747
- console.log = originalLog;
2748
- console.error = originalError;
742
+ function updatePromptBox() {
743
+ if (targetAgent) {
744
+ const label = getAgentLabel(targetAgent);
745
+ promptBox.setContent(`>@${label}`);
746
+ promptBox.width = label.length + 3; // >@name + spacer
747
+ input.left = promptBox.width;
748
+ input.width = `100%-${promptBox.width}`;
749
+ } else {
750
+ promptBox.setContent(">");
751
+ promptBox.width = 2;
752
+ input.left = 2;
753
+ input.width = "100%-2";
754
+ }
755
+ if (!input.parent || !promptBox.parent) return;
756
+ resizeInput();
757
+ if (typeof input._updateCursor === "function") {
758
+ input._updateCursor();
2749
759
  }
2750
760
  }
2751
761
 
2752
- async function handleStatusCommand() {
2753
- // Display current status directly instead of requesting
2754
- if (activeAgents.length === 0) {
2755
- logMessage("system", "{cyan-fg}Status:{/cyan-fg} No active agents");
2756
- } else {
2757
- logMessage("system", `{cyan-fg}Status:{/cyan-fg} ${activeAgents.length} active agent(s)`);
2758
- for (const id of activeAgents) {
2759
- const label = getAgentLabel(id);
2760
- const meta = activeAgentMetaMap.get(id);
2761
- const mode = meta?.launch_mode || "unknown";
2762
- logMessage("system", ` • {cyan-fg}${label}{/cyan-fg} {gray-fg}[${mode}]{/gray-fg}`);
762
+ function syncTargetFromSelection() {
763
+ if (focusMode !== "dashboard" || dashboardView !== "agents") return;
764
+ if (selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
765
+ const nextTarget = activeAgents[selectedAgentIndex];
766
+ if (nextTarget !== targetAgent) {
767
+ targetAgent = nextTarget;
768
+ updatePromptBox();
769
+ screen.render();
2763
770
  }
2764
- }
2765
-
2766
- // Also show daemon status
2767
- if (isRunning(projectRoot)) {
2768
- logMessage("system", "{green-fg}✓{/green-fg} Daemon is running");
2769
- } else {
2770
- logMessage("system", "{red-fg}✗{/red-fg} Daemon is not running");
771
+ } else if (targetAgent) {
772
+ targetAgent = null;
773
+ updatePromptBox();
774
+ screen.render();
2771
775
  }
2772
776
  }
2773
777
 
2774
- async function handleDaemonCommand(args) {
2775
- const subcommand = args[0];
2776
-
2777
- if (subcommand === "start") {
2778
- if (isRunning(projectRoot)) {
2779
- logMessage("system", "{yellow-fg}⚠{/yellow-fg} Daemon already running");
2780
- } else {
2781
- logMessage("system", "{yellow-fg}⚙{/yellow-fg} Starting daemon...");
2782
- startDaemon(projectRoot);
2783
- await new Promise(r => setTimeout(r, 1000));
2784
- if (isRunning(projectRoot)) {
2785
- logMessage("system", "{green-fg}✓{/green-fg} Daemon started");
2786
- } else {
2787
- logMessage("error", "{red-fg}✗{/red-fg} Failed to start daemon");
2788
- }
2789
- }
2790
- } else if (subcommand === "stop") {
2791
- logMessage("system", "{yellow-fg}⚙{/yellow-fg} Stopping daemon...");
2792
- stopDaemon(projectRoot);
2793
- await new Promise(r => setTimeout(r, 1000));
2794
- if (!isRunning(projectRoot)) {
2795
- logMessage("system", "{green-fg}✓{/green-fg} Daemon stopped");
2796
- } else {
2797
- logMessage("error", "{red-fg}✗{/red-fg} Failed to stop daemon");
2798
- }
2799
- } else if (subcommand === "restart") {
2800
- logMessage("system", "{yellow-fg}⚙{/yellow-fg} Restarting daemon...");
2801
- await restartDaemon();
2802
- } else if (subcommand === "status") {
2803
- if (isRunning(projectRoot)) {
2804
- logMessage("system", "{green-fg}✓{/green-fg} Daemon is running");
2805
- } else {
2806
- logMessage("system", "{red-fg}✗{/red-fg} Daemon is not running");
2807
- }
2808
- } else {
2809
- logMessage("error", "{red-fg}✗{/red-fg} Unknown daemon command. Use: start, stop, restart, status");
778
+ function restoreTargetFromSelection() {
779
+ if (selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
780
+ targetAgent = activeAgents[selectedAgentIndex];
781
+ updatePromptBox();
2810
782
  }
2811
783
  }
2812
784
 
2813
- async function handleInitCommand(args) {
2814
- logMessage("system", "{yellow-fg}⚙{/yellow-fg} Initializing ufoo modules...");
785
+ function focusInput() {
786
+ input.focus();
787
+ input._updateCursor();
788
+ }
2815
789
 
2816
- // Capture console output safely
2817
- const originalLog = console.log;
2818
- const originalError = console.error;
2819
- const logs = [];
790
+ function focusLog() {
791
+ logBox.focus();
792
+ screen.program.hideCursor();
793
+ }
2820
794
 
2821
- console.log = (...args) => {
2822
- const msg = args.join(" ");
2823
- logs.push(msg);
2824
- // Also output to logMessage immediately to avoid UI blocking
2825
- logMessage("system", msg);
2826
- };
2827
- console.error = (...args) => {
2828
- const msg = args.join(" ");
2829
- logs.push(`ERROR: ${msg}`);
2830
- logMessage("error", msg);
2831
- };
795
+ function scrollLog(offset) {
796
+ logBox.scroll(offset);
797
+ screen.render();
798
+ }
2832
799
 
2833
- try {
2834
- const repoRoot = path.join(__dirname, "..", "..");
2835
- const init = new UfooInit(repoRoot);
2836
- const modules = args.length > 0 ? args.join(",") : "context,bus";
2837
- await init.init({ modules, project: projectRoot });
800
+ let settingsController = null;
2838
801
 
2839
- logMessage("system", "{green-fg}✓{/green-fg} Initialization complete");
2840
- screen.render();
2841
- } catch (err) {
2842
- logMessage("error", `{red-fg}✗{/red-fg} Init failed: ${err.message}`);
2843
- if (err.stack) {
2844
- logMessage("error", err.stack);
2845
- }
2846
- screen.render();
2847
- } finally {
2848
- console.log = originalLog;
2849
- console.error = originalError;
802
+ function setLaunchMode(mode) {
803
+ if (settingsController) {
804
+ settingsController.setLaunchMode(mode);
2850
805
  }
2851
806
  }
2852
807
 
2853
- async function handleBusCommand(args) {
2854
- const subcommand = args[0];
2855
-
2856
- try {
2857
- if (subcommand === "send") {
2858
- if (args.length < 3) {
2859
- logMessage("error", "{red-fg}✗{/red-fg} Usage: /bus send <target> <message>");
2860
- return;
2861
- }
2862
- const target = args[1];
2863
- const message = args.slice(2).join(" ");
2864
- // Send via daemon to ensure proper publisher ID
2865
- send({ type: "bus_send", target, message });
2866
- logMessage("system", `{green-fg}✓{/green-fg} Message sent to ${target}`);
2867
- return;
2868
- }
2869
-
2870
- const bus = new EventBus(projectRoot);
2871
-
2872
- if (subcommand === "rename") {
2873
- if (args.length < 3) {
2874
- logMessage("error", "{red-fg}✗{/red-fg} Usage: /bus rename <agent> <nickname>");
2875
- return;
2876
- }
2877
- const agentId = args[1];
2878
- const nickname = args[2];
2879
- await bus.rename(agentId, nickname);
2880
- logMessage("system", `{green-fg}✓{/green-fg} Renamed ${agentId} to ${nickname}`);
2881
- requestStatus();
2882
- } else if (subcommand === "list") {
2883
- bus.ensureBus();
2884
- bus.loadBusData();
2885
- const subscribers = Object.entries(bus.busData.agents || {});
2886
- if (subscribers.length === 0) {
2887
- logMessage("system", "{gray-fg}No active agents{/gray-fg}");
2888
- } else {
2889
- logMessage("system", "{cyan-fg}Active agents:{/cyan-fg}");
2890
- for (const [id, meta] of subscribers) {
2891
- const nickname = meta.nickname ? ` (${meta.nickname})` : "";
2892
- const status = meta.status || "unknown";
2893
- logMessage("system", ` • ${id}${nickname} {gray-fg}[${status}]{/gray-fg}`);
2894
- }
2895
- }
2896
- } else if (subcommand === "status") {
2897
- bus.ensureBus();
2898
- bus.loadBusData();
2899
- const count = Object.keys(bus.busData.agents || {}).length;
2900
- logMessage("system", `{cyan-fg}Bus status:{/cyan-fg} ${count} agent(s) registered`);
2901
- } else if (subcommand === "activate") {
2902
- if (args.length < 2) {
2903
- logMessage("error", "{red-fg}✗{/red-fg} Usage: /bus activate <agent>");
2904
- return;
2905
- }
2906
- const target = args[1];
2907
- const AgentActivator = require("../bus/activate");
2908
- const activator = new AgentActivator(projectRoot);
2909
- await activator.activate(target);
2910
- logMessage("system", `{green-fg}✓{/green-fg} Activated ${target}`);
2911
- } else {
2912
- logMessage("error", "{red-fg}✗{/red-fg} Unknown bus command. Use: send, rename, list, status, activate");
2913
- }
2914
- } catch (err) {
2915
- logMessage("error", `{red-fg}✗{/red-fg} Bus command failed: ${err.message}`);
808
+ function requestCloseAgent(agentId) {
809
+ if (!agentId) {
810
+ logMessage("error", "{white-fg}✗{/white-fg} No agent selected");
811
+ return;
2916
812
  }
813
+ const label = getAgentLabel(agentId);
814
+ logMessage("status", `{white-fg}⚙{/white-fg} Closing ${label}...`);
815
+ send({ type: IPC_REQUEST_TYPES.CLOSE_AGENT, agent_id: agentId });
2917
816
  }
2918
817
 
2919
- async function handleCtxCommand(args) {
2920
- logMessage("system", "{yellow-fg}⚙{/yellow-fg} Running context check...");
818
+ function setAgentProvider(provider) {
819
+ if (settingsController) {
820
+ settingsController.setAgentProvider(provider);
821
+ }
822
+ }
2921
823
 
2922
- // Capture console output safely
2923
- const originalLog = console.log;
2924
- const originalError = console.error;
824
+ function setAssistantEngine(value) {
825
+ if (settingsController) {
826
+ settingsController.setAssistantEngine(value);
827
+ }
828
+ }
2925
829
 
2926
- console.log = (...args) => logMessage("system", args.join(" "));
2927
- console.error = (...args) => logMessage("error", args.join(" "));
830
+ function setAutoResume(value) {
831
+ if (settingsController) {
832
+ settingsController.setAutoResume(value);
833
+ }
834
+ }
2928
835
 
2929
- try {
2930
- const UfooContext = require("../context");
2931
- const ctx = new UfooContext(projectRoot);
2932
-
2933
- if (args.length === 0 || args[0] === "doctor") {
2934
- await ctx.doctor();
2935
- } else if (args[0] === "decisions") {
2936
- await ctx.listDecisions();
2937
- } else {
2938
- await ctx.status();
2939
- }
836
+ async function restartDaemon() {
837
+ if (!daemonCoordinator) return;
838
+ return daemonCoordinator.restart();
839
+ }
840
+
841
+ settingsController = createSettingsController({
842
+ projectRoot,
843
+ saveConfig,
844
+ normalizeLaunchMode,
845
+ normalizeAgentProvider,
846
+ normalizeAssistantEngine,
847
+ fsModule: fs,
848
+ getUfooPaths,
849
+ logMessage,
850
+ renderDashboard,
851
+ renderScreen: () => screen.render(),
852
+ restartDaemon,
853
+ getLaunchMode: () => launchMode,
854
+ setLaunchModeState: (value) => {
855
+ launchMode = value;
856
+ },
857
+ setSelectedModeIndex: (value) => {
858
+ selectedModeIndex = value;
859
+ },
860
+ getAgentProvider: () => agentProvider,
861
+ setAgentProviderState: (value) => {
862
+ agentProvider = value;
863
+ },
864
+ setSelectedProviderIndex: (value) => {
865
+ selectedProviderIndex = value;
866
+ },
867
+ getAssistantEngine: () => assistantEngine,
868
+ setAssistantEngineState: (value) => {
869
+ assistantEngine = value;
870
+ },
871
+ setSelectedAssistantIndex: (value) => {
872
+ selectedAssistantIndex = value;
873
+ },
874
+ assistantOptions,
875
+ providerOptions,
876
+ getAutoResume: () => autoResume,
877
+ setAutoResumeState: (value) => {
878
+ autoResume = value;
879
+ },
880
+ setSelectedResumeIndex: (value) => {
881
+ selectedResumeIndex = value;
882
+ },
883
+ });
2940
884
 
2941
- screen.render();
2942
- } catch (err) {
2943
- logMessage("error", `{red-fg}✗{/red-fg} Context check failed: ${err.message}`);
2944
- screen.render();
2945
- } finally {
2946
- console.log = originalLog;
2947
- console.error = originalError;
885
+ function clearLog() {
886
+ logBox.setContent("");
887
+ if (typeof logBox.scrollTo === "function") {
888
+ logBox.scrollTo(0);
2948
889
  }
890
+ screen.render();
2949
891
  }
2950
892
 
2951
- async function handleSkillsCommand(args) {
2952
- const subcommand = args[0];
2953
-
2954
- // Capture console output safely
2955
- const originalLog = console.log;
2956
- console.log = (...args) => logMessage("system", args.join(" "));
893
+ function renderDashboard() {
894
+ const computed = computeDashboardContent({
895
+ focusMode,
896
+ dashboardView,
897
+ activeAgents,
898
+ selectedAgentIndex,
899
+ agentListWindowStart,
900
+ maxAgentWindow: MAX_AGENT_WINDOW,
901
+ getAgentLabel,
902
+ launchMode,
903
+ agentProvider,
904
+ assistantEngine,
905
+ autoResume,
906
+ selectedModeIndex,
907
+ selectedProviderIndex,
908
+ selectedAssistantIndex,
909
+ selectedResumeIndex,
910
+ cronTasks: cronScheduler.listTasks(),
911
+ providerOptions,
912
+ assistantOptions,
913
+ resumeOptions,
914
+ pendingReports: reportPendingTotal,
915
+ dashHints: DASH_HINTS,
916
+ });
917
+ agentListWindowStart = computed.windowStart;
918
+ dashboard.setContent(computed.content);
919
+ }
2957
920
 
2958
- try {
2959
- const UfooSkills = require("../skills");
2960
- const skills = new UfooSkills(projectRoot);
2961
-
2962
- if (subcommand === "list") {
2963
- const skillList = skills.list();
2964
- if (skillList.length === 0) {
2965
- logMessage("system", "{gray-fg}No skills found{/gray-fg}");
2966
- } else {
2967
- logMessage("system", `{cyan-fg}Available skills:{/cyan-fg} ${skillList.length}`);
2968
- for (const skill of skillList) {
2969
- logMessage("system", ` • ${skill}`);
2970
- }
921
+ function updateDashboard(status) {
922
+ activeAgents = status.active || [];
923
+ reportPendingTotal = Number.isFinite(status?.reports?.pending_total)
924
+ ? status.reports.pending_total
925
+ : 0;
926
+ const metaList = Array.isArray(status.active_meta) ? status.active_meta : [];
927
+ let fallbackMap = null;
928
+ if (metaList.length === 0 && activeAgents.length > 0) {
929
+ try {
930
+ const busPath = getUfooPaths(projectRoot).agentsFile;
931
+ const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
932
+ fallbackMap = new Map();
933
+ for (const [id, meta] of Object.entries(bus.agents || {})) {
934
+ if (meta && meta.nickname) fallbackMap.set(id, meta.nickname);
2971
935
  }
2972
- } else if (subcommand === "install") {
2973
- const target = args[1] || "all";
2974
- logMessage("system", `{yellow-fg}⚙{/yellow-fg} Installing skills: ${target}...`);
2975
- await skills.install(target);
2976
- logMessage("system", "{green-fg}✓{/green-fg} Skills installed");
2977
- } else {
2978
- logMessage("error", "{red-fg}✗{/red-fg} Unknown skills command. Use: list, install");
936
+ } catch {
937
+ fallbackMap = null;
2979
938
  }
2980
-
2981
- screen.render();
2982
- } catch (err) {
2983
- logMessage("error", `{red-fg}✗{/red-fg} Skills command failed: ${err.message}`);
2984
- screen.render();
2985
- } finally {
2986
- console.log = originalLog;
2987
939
  }
2988
- }
2989
-
2990
- async function handleLaunchCommand(args) {
2991
- if (args.length === 0) {
2992
- logMessage("error", "{red-fg}✗{/red-fg} Usage: /launch <claude|codex> [nickname=<name>] [count=<n>]");
940
+ const maps = agentDirectory.buildAgentMaps(activeAgents, metaList, fallbackMap);
941
+ activeAgentLabelMap = maps.labelMap;
942
+ activeAgentMetaMap = maps.metaMap;
943
+ clampAgentWindow();
944
+ // If viewing agent went offline, exit view
945
+ const currentView = getCurrentView();
946
+ const viewingAgent = getViewingAgent();
947
+ if (currentView === "agent" && viewingAgent && !activeAgents.includes(viewingAgent)) {
948
+ writeToAgentTerm("\r\n\x1b[1;31m[Agent went offline]\x1b[0m\r\n");
949
+ exitAgentView();
2993
950
  return;
2994
951
  }
2995
952
 
2996
- const agentType = args[0];
2997
- if (agentType !== "claude" && agentType !== "codex") {
2998
- logMessage("error", "{red-fg}✗{/red-fg} Unknown agent type. Use: claude or codex");
953
+ // In agent view, only update the dashboard bar (blessed is frozen)
954
+ if (currentView === "agent") {
955
+ if (focusMode === "dashboard") {
956
+ const totalItems = 1 + activeAgents.length;
957
+ if (selectedAgentIndex < 0 || selectedAgentIndex >= totalItems) {
958
+ selectedAgentIndex = 0;
959
+ }
960
+ }
961
+ renderAgentDashboard();
2999
962
  return;
3000
963
  }
3001
-
3002
- // Parse options
3003
- const options = {};
3004
- for (let i = 1; i < args.length; i++) {
3005
- const arg = args[i];
3006
- if (arg.includes("=")) {
3007
- const [key, value] = arg.split("=", 2);
3008
- options[key] = value;
964
+ if (focusMode === "dashboard") {
965
+ if (dashboardView === "agents") {
966
+ if (activeAgents.length === 0) {
967
+ selectedAgentIndex = -1;
968
+ } else if (selectedAgentIndex < 0 || selectedAgentIndex >= activeAgents.length) {
969
+ selectedAgentIndex = 0;
970
+ }
971
+ clampAgentWindow();
3009
972
  }
3010
973
  }
974
+ syncTargetFromSelection();
975
+ renderDashboard();
976
+ screen.render();
977
+ }
3011
978
 
3012
- const nickname = options.nickname || "";
3013
- const count = parseInt(options.count || "1", 10);
3014
- if (nickname && count > 1) {
3015
- logMessage("error", "{red-fg}✗{/red-fg} nickname requires count=1");
3016
- return;
979
+ function enterDashboardMode() {
980
+ focusMode = "dashboard";
981
+ dashboardView = "agents";
982
+ selectedAgentIndex = activeAgents.length > 0 ? 0 : -1;
983
+ agentListWindowStart = 0;
984
+ clampAgentWindow();
985
+ selectedModeIndex = launchMode === "internal" ? 2 : (launchMode === "tmux" ? 1 : 0);
986
+ selectedProviderIndex = Math.max(0, providerOptions.findIndex((opt) => opt.value === agentProvider));
987
+ selectedAssistantIndex = Math.max(
988
+ 0,
989
+ assistantOptions.findIndex((opt) => opt.value === assistantEngine)
990
+ );
991
+ selectedResumeIndex = autoResume ? 0 : 1;
992
+ // Immediately set @target when first agent is selected
993
+ if (selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
994
+ targetAgent = activeAgents[selectedAgentIndex];
995
+ updatePromptBox();
3017
996
  }
997
+ screen.grabKeys = true;
998
+ renderDashboard();
999
+ screen.program.hideCursor();
1000
+ screen.render();
1001
+ syncTargetFromSelection();
1002
+ }
1003
+
1004
+ const dashboardState = {};
1005
+ Object.defineProperties(dashboardState, {
1006
+ currentView: { get: () => getCurrentView() },
1007
+ focusMode: { get: () => focusMode, set: (value) => { focusMode = value; } },
1008
+ dashboardView: { get: () => dashboardView, set: (value) => { dashboardView = value; } },
1009
+ selectedAgentIndex: { get: () => selectedAgentIndex, set: (value) => { selectedAgentIndex = value; } },
1010
+ activeAgents: { get: () => activeAgents },
1011
+ viewingAgent: { get: () => getViewingAgent() },
1012
+ activeAgentMetaMap: { get: () => activeAgentMetaMap },
1013
+ selectedModeIndex: { get: () => selectedModeIndex, set: (value) => { selectedModeIndex = value; } },
1014
+ selectedProviderIndex: { get: () => selectedProviderIndex, set: (value) => { selectedProviderIndex = value; } },
1015
+ selectedAssistantIndex: { get: () => selectedAssistantIndex, set: (value) => { selectedAssistantIndex = value; } },
1016
+ selectedResumeIndex: { get: () => selectedResumeIndex, set: (value) => { selectedResumeIndex = value; } },
1017
+ launchMode: { get: () => launchMode },
1018
+ agentProvider: { get: () => agentProvider },
1019
+ assistantEngine: { get: () => assistantEngine },
1020
+ autoResume: { get: () => autoResume },
1021
+ cronTasks: { get: () => cronScheduler.listTasks() },
1022
+ providerOptions: { get: () => providerOptions },
1023
+ assistantOptions: { get: () => assistantOptions },
1024
+ resumeOptions: { get: () => resumeOptions },
1025
+ agentOutputSuppressed: {
1026
+ get: () => getAgentOutputSuppressed(),
1027
+ set: (value) => { setAgentOutputSuppressed(value); },
1028
+ },
1029
+ });
3018
1030
 
3019
- try {
3020
- const label = nickname ? ` (${nickname})` : "";
3021
- logMessage("system", `{yellow-fg}⚙{/yellow-fg} Launching ${agentType}${label}...`);
3022
- send({
3023
- type: "launch_agent",
3024
- agent: agentType,
3025
- count: Number.isFinite(count) ? count : 1,
3026
- nickname,
3027
- });
3028
- setTimeout(requestStatus, 1000);
3029
- } catch (err) {
3030
- logMessage("error", `{red-fg}✗{/red-fg} Launch failed: ${err.message}`);
1031
+ function activateAgent(agentId) {
1032
+ if (!agentId) return;
1033
+ const activator = new AgentActivator(projectRoot);
1034
+ activator.activate(agentId).catch(() => {});
1035
+ }
1036
+
1037
+ terminalAdapterRouter = createTerminalAdapterRouter({
1038
+ activateAgent,
1039
+ sendRaw: (data) => agentSockets.sendRaw(data),
1040
+ sendResize: (cols, rows) => agentSockets.sendResize(cols, rows),
1041
+ requestSnapshot: (mode) => agentSockets.requestSnapshot(mode),
1042
+ });
1043
+
1044
+ const dashboardController = createDashboardKeyController({
1045
+ state: dashboardState,
1046
+ existsSync: fs.existsSync,
1047
+ getInjectSockPath,
1048
+ getAgentAdapter,
1049
+ activateAgent,
1050
+ requestCloseAgent,
1051
+ enterAgentView,
1052
+ exitAgentView,
1053
+ setAgentBarVisible,
1054
+ requestAgentSnapshot,
1055
+ clearTargetAgent,
1056
+ restoreTargetFromSelection,
1057
+ syncTargetFromSelection,
1058
+ exitDashboardMode,
1059
+ setLaunchMode,
1060
+ setAgentProvider,
1061
+ setAssistantEngine,
1062
+ setAutoResume,
1063
+ clampAgentWindow,
1064
+ clampAgentWindowWithSelection,
1065
+ renderDashboard,
1066
+ renderAgentDashboard,
1067
+ renderScreen: () => screen.render(),
1068
+ setScreenGrabKeys: (value) => {
1069
+ screen.grabKeys = Boolean(value);
1070
+ },
1071
+ });
1072
+
1073
+ function handleDashboardKey(key) {
1074
+ return dashboardController.handleDashboardKey(key);
1075
+ }
1076
+
1077
+ function exitDashboardMode(selectAgent = false) {
1078
+ if (selectAgent && selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
1079
+ targetAgent = activeAgents[selectedAgentIndex];
1080
+ updatePromptBox();
3031
1081
  }
1082
+ focusMode = "input";
1083
+ dashboardView = "agents";
1084
+ selectedAgentIndex = -1;
1085
+ screen.grabKeys = false;
1086
+ renderDashboard();
1087
+ focusInput();
1088
+ screen.render();
3032
1089
  }
3033
1090
 
3034
- async function handleResumeCommand(args) {
3035
- const target = args[0] || "";
3036
- const label = target ? ` (${target})` : "";
3037
- logMessage("system", `{yellow-fg}⚙{/yellow-fg} Resuming agents${label}...`);
3038
- send({ type: "resume_agents", target });
3039
- setTimeout(requestStatus, 1000);
1091
+ function clearTargetAgent() {
1092
+ targetAgent = null;
1093
+ updatePromptBox();
1094
+ screen.render();
3040
1095
  }
3041
1096
 
3042
- function parseCommand(text) {
3043
- if (!text.startsWith("/")) return null;
1097
+ function getInjectSockPath(agentId) {
1098
+ const safeName = subscriberToSafeName(agentId);
1099
+ return path.join(getUfooPaths(projectRoot).busQueuesDir, safeName, "inject.sock");
1100
+ }
3044
1101
 
3045
- // Split by whitespace, respecting quotes
3046
- const parts = text.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
3047
- if (parts.length === 0) return null;
1102
+ agentViewController = createAgentViewController({
1103
+ screen,
1104
+ input,
1105
+ processStdout: process.stdout,
1106
+ computeAgentBar,
1107
+ agentBarHints: AGENT_BAR_HINTS,
1108
+ maxAgentWindow: MAX_AGENT_WINDOW,
1109
+ getFocusMode: () => focusMode,
1110
+ setFocusMode: (value) => {
1111
+ focusMode = value;
1112
+ },
1113
+ getSelectedAgentIndex: () => selectedAgentIndex,
1114
+ setSelectedAgentIndex: (value) => {
1115
+ selectedAgentIndex = value;
1116
+ },
1117
+ getActiveAgents: () => activeAgents,
1118
+ getAgentListWindowStart: () => agentListWindowStart,
1119
+ setAgentListWindowStart: (value) => {
1120
+ agentListWindowStart = value;
1121
+ },
1122
+ getAgentLabel,
1123
+ setDashboardView: (value) => {
1124
+ dashboardView = value;
1125
+ },
1126
+ setScreenGrabKeys: (value) => {
1127
+ screen.grabKeys = Boolean(value);
1128
+ },
1129
+ clearTargetAgent,
1130
+ renderDashboard,
1131
+ focusInput,
1132
+ resizeInput,
1133
+ renderScreen: () => screen.render(),
1134
+ getInjectSockPath,
1135
+ connectAgentOutput: (sockPath) => {
1136
+ agentSockets.connectOutput(sockPath);
1137
+ },
1138
+ disconnectAgentOutput: () => {
1139
+ agentSockets.disconnectOutput();
1140
+ },
1141
+ connectAgentInput: (sockPath) => {
1142
+ agentSockets.connectInput(sockPath);
1143
+ },
1144
+ disconnectAgentInput: () => {
1145
+ agentSockets.disconnectInput();
1146
+ },
1147
+ sendRaw: (data) => {
1148
+ sendRawWithCapabilities(data);
1149
+ },
1150
+ sendResize: (cols, rows) => {
1151
+ sendResizeWithCapabilities(cols, rows);
1152
+ },
1153
+ requestScreenSnapshot: () => {
1154
+ requestSnapshotWithCapabilities();
1155
+ },
1156
+ });
3048
1157
 
3049
- const command = parts[0].slice(1); // Remove leading /
3050
- const args = parts.slice(1).map(arg => arg.replace(/^"|"$/g, "")); // Remove quotes
1158
+ function requestStatus() {
1159
+ if (!daemonCoordinator) return;
1160
+ daemonCoordinator.requestStatus();
1161
+ }
1162
+
1163
+ const daemonMessageRouter = createDaemonMessageRouter({
1164
+ escapeBlessed,
1165
+ stripBlessedTags,
1166
+ logMessage,
1167
+ renderScreen: () => screen.render(),
1168
+ updateDashboard,
1169
+ requestStatus,
1170
+ resolveStatusLine,
1171
+ enqueueBusStatus,
1172
+ resolveBusStatus,
1173
+ getPending: () => pending,
1174
+ setPending: (value) => {
1175
+ pending = value;
1176
+ },
1177
+ resolveAgentDisplayName,
1178
+ getCurrentView: () => getCurrentView(),
1179
+ isAgentViewUsesBus: () => isAgentViewUsesBus(),
1180
+ getViewingAgent: () => getViewingAgent(),
1181
+ writeToAgentTerm,
1182
+ consumePendingDelivery,
1183
+ getPendingState,
1184
+ beginStream,
1185
+ appendStreamDelta,
1186
+ finalizeStream,
1187
+ hasStream: (publisher) => streamTracker.hasStream(publisher),
1188
+ });
3051
1189
 
3052
- return { command, args };
3053
- }
1190
+ daemonCoordinator = createDaemonCoordinator({
1191
+ projectRoot,
1192
+ daemonTransport,
1193
+ handleMessage: (msg) => daemonMessageRouter.handleMessage(msg),
1194
+ queueStatusLine,
1195
+ resolveStatusLine,
1196
+ logMessage,
1197
+ stopDaemon,
1198
+ startDaemon,
1199
+ });
3054
1200
 
3055
- async function executeCommand(text) {
3056
- const parsed = parseCommand(text);
3057
- if (!parsed) return false;
3058
-
3059
- const { command, args } = parsed;
3060
-
3061
- switch (command) {
3062
- case "doctor":
3063
- await handleDoctorCommand();
3064
- return true;
3065
- case "status":
3066
- await handleStatusCommand();
3067
- return true;
3068
- case "daemon":
3069
- await handleDaemonCommand(args);
3070
- return true;
3071
- case "init":
3072
- await handleInitCommand(args);
3073
- return true;
3074
- case "bus":
3075
- await handleBusCommand(args);
3076
- return true;
3077
- case "ctx":
3078
- await handleCtxCommand(args);
3079
- return true;
3080
- case "skills":
3081
- await handleSkillsCommand(args);
3082
- return true;
3083
- case "launch":
3084
- await handleLaunchCommand(args);
3085
- return true;
3086
- case "resume":
3087
- await handleResumeCommand(args);
3088
- return true;
3089
- default:
3090
- logMessage("error", `{red-fg}✗{/red-fg} Unknown command: /${command}`);
3091
- return true;
1201
+ const connected = await daemonCoordinator.connect();
1202
+ if (!connected) {
1203
+ // Check if daemon failed to start
1204
+ if (!isRunning(projectRoot)) {
1205
+ const logFile = getUfooPaths(projectRoot).ufooDaemonLog;
1206
+ // eslint-disable-next-line no-console
1207
+ console.error("Failed to start ufoo daemon. Check logs at:", logFile);
1208
+ throw new Error("Daemon failed to start. Check the daemon log for details.");
3092
1209
  }
1210
+ throw new Error("Failed to connect to ufoo daemon (timeout). The daemon may still be starting.");
3093
1211
  }
3094
1212
 
3095
- input.on("submit", async (value) => {
3096
- const text = value.trim();
3097
- input.clearValue();
3098
- screen.render();
3099
- if (!text) {
3100
- // Empty Enter with @target → enter TTY view
3101
- if (targetAgent) {
3102
- const agentId = targetAgent;
3103
- const sockPath = getInjectSockPath(agentId);
3104
- if (fs.existsSync(sockPath)) {
3105
- clearTargetAgent();
3106
- enterAgentView(agentId);
3107
- return;
3108
- }
3109
- }
3110
- input.focus();
3111
- return;
3112
- }
3113
- inputHistory.push(text);
3114
- appendInputHistory(text);
3115
- historyIndex = inputHistory.length;
3116
- historyDraft = "";
1213
+ const commandExecutor = createCommandExecutor({
1214
+ projectRoot,
1215
+ parseCommand,
1216
+ escapeBlessed,
1217
+ logMessage,
1218
+ renderScreen: () => screen.render(),
1219
+ getActiveAgents: () => activeAgents,
1220
+ getActiveAgentMetaMap: () => activeAgentMetaMap,
1221
+ getAgentLabel,
1222
+ isDaemonRunning: isRunning,
1223
+ startDaemon,
1224
+ stopDaemon,
1225
+ restartDaemon,
1226
+ send,
1227
+ requestStatus,
1228
+ createCronTask: ({ intervalMs, targets, prompt }) =>
1229
+ cronScheduler.addTask({ intervalMs, targets, prompt }),
1230
+ listCronTasks: () => cronScheduler.listTasks(),
1231
+ stopCronTask: (id) => cronScheduler.stopTask(id),
1232
+ activateAgent: async (target) => {
1233
+ const activator = new AgentActivator(projectRoot);
1234
+ await activator.activate(target);
1235
+ },
1236
+ });
3117
1237
 
3118
- // If target agent is selected, inject directly into agent's PTY
3119
- if (targetAgent) {
3120
- const label = getAgentLabel(targetAgent);
3121
- logMessage("user", `{magenta-fg}${escapeBlessed(label)}{/magenta-fg}: ${escapeBlessed(text)}`);
3122
-
3123
- const meta = activeAgentMetaMap.get(targetAgent);
3124
- const agentMode = meta?.launch_mode || "";
3125
-
3126
- if (agentMode === "tmux" && meta?.tmux_pane) {
3127
- // Tmux mode: use tmux send-keys
3128
- // Send text first, then Enter after a delay (Claude Code needs time to process)
3129
- const pane = meta.tmux_pane;
3130
- const textProc = spawn("tmux", ["send-keys", "-t", pane, text]);
3131
- textProc.on("close", () => {
3132
- setTimeout(() => {
3133
- spawn("tmux", ["send-keys", "-t", pane, "Enter"]);
3134
- }, 150);
3135
- });
3136
- } else {
3137
- // Terminal / internal mode: inject via inject.sock
3138
- const sockPath = getInjectSockPath(targetAgent);
3139
- try {
3140
- const conn = net.createConnection(sockPath, () => {
3141
- conn.write(JSON.stringify({ type: "raw", data: text }) + "\n");
3142
- setTimeout(() => {
3143
- conn.write(JSON.stringify({ type: "raw", data: "\r" }) + "\n");
3144
- setTimeout(() => conn.destroy(), 500);
3145
- }, 100);
3146
- });
3147
- conn.on("error", () => {});
3148
- } catch {
3149
- // ignore connection errors
3150
- }
3151
- }
1238
+ async function executeCommand(text) {
1239
+ return commandExecutor.executeCommand(text);
1240
+ }
3152
1241
 
3153
- clearTargetAgent();
3154
- input.focus();
3155
- return;
3156
- }
1242
+ const submitState = {};
1243
+ Object.defineProperties(submitState, {
1244
+ targetAgent: { get: () => targetAgent, set: (value) => { targetAgent = value; } },
1245
+ pending: { get: () => pending, set: (value) => { pending = value; } },
1246
+ activeAgentMetaMap: { get: () => activeAgentMetaMap },
1247
+ });
3157
1248
 
3158
- // Check if it's a command
3159
- if (text.startsWith("/")) {
3160
- logMessage("user", `{cyan-fg}→{/cyan-fg} ${escapeBlessed(text)}`);
3161
- try {
3162
- await executeCommand(text);
3163
- } catch (err) {
3164
- logMessage("error", `{red-fg}✗{/red-fg} Command error: ${escapeBlessed(err.message)}`);
3165
- }
3166
- input.focus();
3167
- return;
3168
- }
1249
+ const inputSubmitHandler = createInputSubmitHandler({
1250
+ state: submitState,
1251
+ parseAtTarget,
1252
+ resolveAgentId,
1253
+ executeCommand,
1254
+ queueStatusLine,
1255
+ send,
1256
+ logMessage,
1257
+ getAgentLabel,
1258
+ escapeBlessed,
1259
+ markPendingDelivery,
1260
+ clearTargetAgent,
1261
+ setTargetAgent: (agentId) => {
1262
+ targetAgent = agentId || null;
1263
+ updatePromptBox();
1264
+ screen.render();
1265
+ },
1266
+ enterAgentView,
1267
+ activateAgent: async (agentId) => {
1268
+ const activator = new AgentActivator(projectRoot);
1269
+ await activator.activate(agentId);
1270
+ },
1271
+ getInjectSockPath,
1272
+ existsSync: fs.existsSync,
1273
+ commitInputHistory: (text) => {
1274
+ if (inputHistoryController) inputHistoryController.commitSubmittedText(text);
1275
+ },
1276
+ focusInput: () => input.focus(),
1277
+ renderScreen: () => screen.render(), // Add renderScreen callback
1278
+ });
3169
1279
 
3170
- if (pending && pending.disambiguate) {
3171
- const idx = parseInt(text, 10);
3172
- const choice = pending.disambiguate.candidates[idx - 1];
3173
- if (choice) {
3174
- queueStatusLine(`ufoo-agent processing (assigning ${choice.agent_id})`);
3175
- send({
3176
- type: "prompt",
3177
- text: `Use agent ${choice.agent_id} to handle: ${pending.original || "the request"}`,
3178
- });
3179
- pending = null;
3180
- } else {
3181
- logMessage("error", escapeBlessed("Invalid selection."));
3182
- }
3183
- } else {
3184
- pending = { original: text };
3185
- queueStatusLine("ufoo-agent processing");
3186
- send({ type: "prompt", text });
3187
- logMessage("user", `{cyan-fg}→{/cyan-fg} ${escapeBlessed(text)}`);
3188
- }
3189
- input.focus();
1280
+ input.on("submit", async (value) => {
1281
+ input.clearValue();
1282
+ screen.render(); // Render cleared input
1283
+ await inputSubmitHandler.handleSubmit(value);
1284
+ // No need for second render - handleSubmit now calls renderScreen() internally
3190
1285
  });
3191
1286
 
3192
1287
  screen.key(["C-c"], exitHandler);
3193
1288
 
3194
1289
  // Agent TTY view: enter dashboard mode
3195
1290
  function enterAgentDashboardMode() {
3196
- focusMode = "dashboard";
3197
- dashboardView = "agents";
3198
- // Find the current viewing agent's index in the [ufoo, ...agents] list
3199
- selectedAgentIndex = 0; // Default to ufoo for quick exit
3200
- renderAgentDashboard();
3201
- }
3202
-
3203
- // Map key names to ANSI escape sequences for raw PTY passthrough
3204
- function keyToRaw(ch, key) {
3205
- if (ch && ch.length === 1) return ch;
3206
- if (!key) return null;
3207
- switch (key.name) {
3208
- case "return": case "enter": return "\r";
3209
- case "backspace": return "\x7f";
3210
- case "tab": return "\t";
3211
- case "escape": return "\x1b";
3212
- case "up": return "\x1b[A";
3213
- case "down": return "\x1b[B";
3214
- case "right": return "\x1b[C";
3215
- case "left": return "\x1b[D";
3216
- case "home": return "\x1b[H";
3217
- case "end": return "\x1b[F";
3218
- case "pageup": return "\x1b[5~";
3219
- case "pagedown": return "\x1b[6~";
3220
- case "delete": return "\x1b[3~";
3221
- case "insert": return "\x1b[2~";
3222
- default: return ch || null;
1291
+ if (agentViewController) {
1292
+ agentViewController.enterAgentDashboardMode();
3223
1293
  }
3224
1294
  }
3225
1295
 
3226
1296
  // Dashboard navigation - use screen.on to capture even when input is focused
3227
1297
  screen.on("keypress", (ch, key) => {
3228
1298
  // Agent TTY view: handle keystrokes
3229
- if (currentView === "agent") {
1299
+ if (getCurrentView() === "agent") {
3230
1300
  if (focusMode === "dashboard") {
3231
1301
  handleDashboardKey(key);
3232
1302
  return;
3233
1303
  }
3234
- // Suppress input briefly after entering agent view (prevents Enter
3235
- // leak from dashboard selection and terminal query responses like CPR)
3236
- if (Date.now() < agentInputSuppressUntil) {
1304
+ // Suppress input briefly after entering agent view
1305
+ if (Date.now() < getAgentInputSuppressUntil()) {
3237
1306
  return;
3238
1307
  }
3239
1308
  // Ctrl+C exits entire app
@@ -3258,7 +1327,7 @@ async function runChat(projectRoot) {
3258
1327
  });
3259
1328
 
3260
1329
  screen.key(["tab"], () => {
3261
- if (currentView === "agent") return; // Tab goes to PTY via keypress handler
1330
+ if (getCurrentView() === "agent") return; // Tab goes to PTY via keypress handler
3262
1331
  if (focusMode === "dashboard") {
3263
1332
  exitDashboardMode(false);
3264
1333
  } else {
@@ -3267,13 +1336,13 @@ async function runChat(projectRoot) {
3267
1336
  });
3268
1337
 
3269
1338
  screen.key(["C-k", "M-k"], () => {
3270
- if (currentView === "agent") return;
1339
+ if (getCurrentView() === "agent") return;
3271
1340
  clearLog();
3272
1341
  });
3273
1342
 
3274
1343
 
3275
1344
  screen.key(["i", "enter"], () => {
3276
- if (currentView === "agent") return;
1345
+ if (getCurrentView() === "agent") return;
3277
1346
  if (focusMode === "dashboard") return;
3278
1347
  if (screen.focused === input) return;
3279
1348
  focusInput();
@@ -3292,43 +1361,7 @@ async function runChat(projectRoot) {
3292
1361
  }
3293
1362
  if (screen.program) {
3294
1363
  screen.program.on("data", (data) => {
3295
- if (screen.focused !== input || focusMode !== "input") return;
3296
- const chunk = data.toString("utf8");
3297
- if (!pasteActive && !chunk.includes(PASTE_START) && !pasteRemainder.includes(PASTE_START)) {
3298
- const keep = PASTE_START.length - 1;
3299
- pasteRemainder = (pasteRemainder + chunk).slice(-keep);
3300
- return;
3301
- }
3302
- let buffer = pasteRemainder + chunk;
3303
- pasteRemainder = "";
3304
- while (buffer.length > 0) {
3305
- if (!pasteActive) {
3306
- const start = buffer.indexOf(PASTE_START);
3307
- if (start === -1) {
3308
- const keep = PASTE_START.length - 1;
3309
- pasteRemainder = buffer.slice(-keep);
3310
- return;
3311
- }
3312
- buffer = buffer.slice(start + PASTE_START.length);
3313
- pasteActive = true;
3314
- pasteBuffer = "";
3315
- scheduleSuppressReset();
3316
- continue;
3317
- }
3318
- const end = buffer.indexOf(PASTE_END);
3319
- if (end === -1) {
3320
- pasteBuffer += buffer;
3321
- scheduleSuppressReset();
3322
- return;
3323
- }
3324
- pasteBuffer += buffer.slice(0, end);
3325
- buffer = buffer.slice(end + PASTE_END.length);
3326
- pasteActive = false;
3327
- scheduleSuppressReset();
3328
- const normalized = normalizePaste(pasteBuffer);
3329
- pasteBuffer = "";
3330
- if (normalized) insertTextAtCursor(normalized);
3331
- }
1364
+ pasteController.handleProgramData(data);
3332
1365
  });
3333
1366
  }
3334
1367
  loadHistory();
@@ -3339,24 +1372,19 @@ async function runChat(projectRoot) {
3339
1372
 
3340
1373
  // 定期刷新 dashboard 状态(兜底,daemon 会主动推送变化)
3341
1374
  setInterval(() => {
3342
- if (client && !client.destroyed) {
1375
+ if (daemonCoordinator && daemonCoordinator.isConnected()) {
3343
1376
  requestStatus();
3344
1377
  }
3345
1378
  }, 30000);
3346
-
3347
1379
  screen.on("resize", () => {
3348
- if (currentView === "agent") {
3349
- // Update scroll region and agent PTY size for new terminal dimensions
3350
- const rows = process.stdout.rows || 24;
3351
- const cols = process.stdout.columns || 80;
3352
- process.stdout.write(`\x1b[1;${rows - 1}r`);
3353
- sendResizeToAgent(cols, rows - 1);
3354
- renderAgentDashboard();
1380
+ if (handleResizeInAgentView()) {
3355
1381
  return;
3356
1382
  }
3357
1383
  resizeInput();
3358
- if (completionActive) hideCompletion();
1384
+ if (completionController.isActive()) completionController.hide();
3359
1385
  input._updateCursor();
1386
+ // Force recalculate logBox width to match terminal
1387
+ logBox.width = screen.width;
3360
1388
  screen.render();
3361
1389
  });
3362
1390
  screen.render();