u-foo 1.0.6 → 1.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/README.md +44 -4
  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 +11 -2
  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 +154 -0
  64. package/src/chat/index.js +935 -2909
  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 +132 -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 +1580 -0
  98. package/src/config.js +47 -1
  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 +661 -488
  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,839 @@ 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" },
1611
486
  ];
1612
487
  let selectedProviderIndex = agentProvider === "claude-cli" ? 1 : 0;
488
+ const assistantOptions = [
489
+ { label: "auto", value: "auto" },
490
+ { label: "codex", value: "codex" },
491
+ { label: "claude", value: "claude" },
492
+ { label: "ufoo", value: "ufoo" },
493
+ ];
494
+ let selectedAssistantIndex = Math.max(
495
+ 0,
496
+ assistantOptions.findIndex((opt) => opt.value === assistantEngine)
497
+ );
1613
498
  const resumeOptions = [
1614
- { label: "Auto", value: true },
1615
- { label: "Off", value: false },
499
+ { label: "Resume previous session", value: true },
500
+ { label: "Start new session", value: false },
1616
501
  ];
1617
502
  let selectedResumeIndex = autoResume ? 0 : 1;
1618
- let restartInProgress = false;
503
+ const DASH_HINTS = {
504
+ agents: "←/→ select · Enter · ↓ mode · ↑ back",
505
+ agentsEmpty: "↓ mode · ↑ back",
506
+ mode: "←/→ select · Enter · ↓ provider · ↑ back",
507
+ provider: "←/→ select · Enter · ↓ assistant · ↑ back",
508
+ assistant: "←/→ select · Enter · ↓ cron · ↑ back",
509
+ cron: "Ctrl+X close · ↑ back",
510
+ resume: "",
511
+ };
512
+ const AGENT_BAR_HINTS = {
513
+ normal: "↓ agents",
514
+ dashboard: "←/→ · Enter · ↑ · ^X",
515
+ };
1619
516
 
1620
- function getAgentLabel(agentId) {
1621
- return activeAgentLabelMap.get(agentId) || agentId;
517
+ function getCurrentView() {
518
+ return agentViewController ? agentViewController.getCurrentView() : "main";
1622
519
  }
1623
520
 
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;
521
+ function getViewingAgent() {
522
+ return agentViewController ? agentViewController.getViewingAgent() : "";
1640
523
  }
1641
524
 
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`);
525
+ function getAgentAdapter(agentId) {
526
+ if (!terminalAdapterRouter) return null;
527
+ const meta = activeAgentMetaMap ? activeAgentMetaMap.get(agentId) : null;
528
+ const agentLaunchMode = (meta && meta.launch_mode) || launchMode || "";
529
+ return terminalAdapterRouter.getAdapter({ launchMode: agentLaunchMode, agentId });
1649
530
  }
1650
531
 
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();
532
+ function getViewingAgentAdapter() {
533
+ const viewingAgent = getViewingAgent();
534
+ if (!viewingAgent) return null;
535
+ return getAgentAdapter(viewingAgent);
1666
536
  }
1667
537
 
1668
- function focusInput() {
1669
- input.focus();
1670
- input._updateCursor();
538
+ function canSendRaw(adapter) {
539
+ if (!adapter || !adapter.capabilities) return false;
540
+ return Boolean(
541
+ adapter.capabilities.supportsSocketProtocol
542
+ || adapter.capabilities.supportsInternalQueueLoop
543
+ );
1671
544
  }
1672
545
 
1673
- function focusLog() {
1674
- logBox.focus();
1675
- screen.program.hideCursor();
546
+ function canResize(adapter) {
547
+ return Boolean(adapter && adapter.capabilities && adapter.capabilities.supportsSocketProtocol);
1676
548
  }
1677
549
 
1678
- function scrollLog(offset) {
1679
- logBox.scroll(offset);
1680
- screen.render();
550
+ function canSnapshot(adapter) {
551
+ if (!adapter || !adapter.capabilities) return false;
552
+ return Boolean(
553
+ adapter.capabilities.supportsSnapshot
554
+ || adapter.capabilities.supportsSubscribeScreen
555
+ || adapter.capabilities.supportsSubscribeFull
556
+ );
1681
557
  }
1682
558
 
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;
559
+ function sendRawWithCapabilities(data) {
560
+ const adapter = getViewingAgentAdapter();
561
+ if (!canSendRaw(adapter)) return;
562
+ try {
563
+ adapter.sendRaw(data);
564
+ } catch {
565
+ // ignore unsupported errors
1690
566
  }
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
567
  }
1704
568
 
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");
569
+ function sendResizeWithCapabilities(cols, rows) {
570
+ const adapter = getViewingAgentAdapter();
571
+ if (!canResize(adapter)) return;
1709
572
  try {
1710
- fs.rmSync(stateFile, { force: true });
573
+ adapter.resize(cols, rows);
1711
574
  } catch {
1712
- // ignore
575
+ // ignore unsupported errors
1713
576
  }
577
+ }
578
+
579
+ function requestSnapshotWithCapabilities() {
580
+ const adapter = getViewingAgentAdapter();
581
+ if (!canSnapshot(adapter)) return false;
1714
582
  try {
1715
- fs.rmSync(historyFile, { force: true });
583
+ return adapter.snapshot();
1716
584
  } catch {
1717
- // ignore
585
+ return false;
1718
586
  }
1719
587
  }
1720
588
 
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();
589
+ function isAgentViewUsesBus() {
590
+ return agentViewController ? agentViewController.isAgentViewUsesBus() : false;
1732
591
  }
1733
592
 
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();
593
+ function getAgentInputSuppressUntil() {
594
+ return agentViewController ? agentViewController.getAgentInputSuppressUntil() : 0;
1744
595
  }
1745
596
 
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;
597
+ function getAgentOutputSuppressed() {
598
+ return agentViewController ? agentViewController.getAgentOutputSuppressed() : false;
599
+ }
600
+
601
+ function setAgentOutputSuppressed(value) {
602
+ if (agentViewController) {
603
+ agentViewController.setAgentOutputSuppressed(value);
1770
604
  }
1771
605
  }
1772
606
 
1773
- function clearLog() {
1774
- logBox.setContent("");
1775
- if (typeof logBox.scrollTo === "function") {
1776
- logBox.scrollTo(0);
607
+ function renderAgentDashboard() {
608
+ if (agentViewController) {
609
+ agentViewController.renderAgentDashboard();
1777
610
  }
1778
- screen.render();
1779
611
  }
1780
612
 
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}`;
613
+ function setAgentBarVisible(visible) {
614
+ if (agentViewController) {
615
+ agentViewController.setAgentBarVisible(visible);
1857
616
  }
1858
- dashboard.setContent(content);
1859
617
  }
1860
618
 
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
- }
1878
- }
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
- }
619
+ function enterAgentView(agentId, options = {}) {
620
+ if (agentViewController) {
621
+ agentViewController.enterAgentView(agentId, options);
1919
622
  }
1920
- renderDashboard();
1921
- screen.render();
1922
623
  }
1923
624
 
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();
625
+ function exitAgentView() {
626
+ if (agentViewController) {
627
+ agentViewController.exitAgentView();
1937
628
  }
1938
- screen.grabKeys = true;
1939
- renderDashboard();
1940
- screen.program.hideCursor();
1941
- screen.render();
1942
629
  }
1943
630
 
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;
631
+ function sendRawToAgent(data) {
632
+ if (agentViewController) {
633
+ agentViewController.sendRawToAgent(data);
2218
634
  }
2219
- return false;
2220
635
  }
2221
636
 
2222
- function exitDashboardMode(selectAgent = false) {
2223
- if (selectAgent && selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
2224
- targetAgent = activeAgents[selectedAgentIndex];
2225
- updatePromptBox();
637
+ function sendResizeToAgent(cols, rows) {
638
+ if (agentViewController) {
639
+ agentViewController.sendResizeToAgent(cols, rows);
2226
640
  }
2227
- focusMode = "input";
2228
- dashboardView = "agents";
2229
- selectedAgentIndex = -1;
2230
- screen.grabKeys = false;
2231
- renderDashboard();
2232
- focusInput();
2233
- screen.render();
2234
- }
2235
-
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
641
  }
2246
642
 
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}`);
643
+ function requestAgentSnapshot() {
644
+ if (agentViewController) {
645
+ agentViewController.requestAgentSnapshot();
2279
646
  }
2280
647
  }
2281
648
 
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`;
649
+ function writeToAgentTerm(text) {
650
+ if (agentViewController) {
651
+ agentViewController.writeToAgentTerm(text);
2319
652
  }
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
653
  }
2327
654
 
2328
- function enterAgentView(agentId) {
2329
- if (currentView === "agent" && viewingAgent === agentId) return;
2330
- if (currentView === "agent") {
2331
- disconnectAgentOutput();
2332
- disconnectAgentInput();
655
+ function placeAgentCursor(cursor) {
656
+ if (agentViewController) {
657
+ agentViewController.placeAgentCursor(cursor);
2333
658
  }
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
659
  }
2368
660
 
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();
661
+ function handleResizeInAgentView() {
662
+ if (!agentViewController) return false;
663
+ return agentViewController.handleResizeInAgentView();
2405
664
  }
2406
665
 
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
- });
666
+ function getAgentLabel(agentId) {
667
+ return agentDirectory.getAgentLabel(activeAgentLabelMap, agentId);
668
+ }
2435
669
 
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
670
+ function resolveAgentId(label) {
671
+ return agentDirectory.resolveAgentId({
672
+ label,
673
+ activeAgents,
674
+ labelMap: activeAgentLabelMap,
675
+ lookupNickname: (nickname) => {
676
+ try {
677
+ const busPath = getUfooPaths(projectRoot).agentsFile;
678
+ const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
679
+ for (const [id, meta] of Object.entries(bus.agents || {})) {
680
+ if (meta && meta.nickname === nickname) return id;
2452
681
  }
682
+ } catch {
683
+ // ignore lookup errors
2453
684
  }
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
- }
685
+ return null;
686
+ },
687
+ });
2509
688
  }
2510
689
 
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
- }
690
+ function resolveAgentDisplayName(publisher) {
691
+ return agentDirectory.resolveAgentDisplayName({
692
+ publisher,
693
+ labelMap: activeAgentLabelMap,
694
+ lookupNicknameById: (id) => {
695
+ try {
696
+ const busPath = getUfooPaths(projectRoot).agentsFile;
697
+ const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
698
+ const meta = bus.agents && bus.agents[id];
699
+ if (meta && meta.nickname) return meta.nickname;
700
+ } catch {
701
+ // Keep original publisher ID
702
+ }
703
+ return null;
704
+ },
705
+ });
2518
706
  }
2519
707
 
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
- }
708
+ function clampAgentWindowWithSelection(selectionIndex) {
709
+ agentListWindowStart = agentDirectory.clampAgentWindowWithSelection({
710
+ activeCount: activeAgents.length,
711
+ maxWindow: MAX_AGENT_WINDOW,
712
+ windowStart: agentListWindowStart,
713
+ selectionIndex,
714
+ });
2527
715
  }
2528
716
 
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
- }
717
+ function clampAgentWindow() {
718
+ clampAgentWindowWithSelection(selectedAgentIndex);
2546
719
  }
2547
720
 
2548
- function requestStatus() {
2549
- send({ type: "status" });
721
+ function send(req) {
722
+ if (!daemonCoordinator) return;
723
+ daemonCoordinator.send(req);
2550
724
  }
2551
725
 
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
- }
726
+ cronScheduler = createCronScheduler({
727
+ dispatch: ({ taskId, target, message }) => {
728
+ send({
729
+ type: IPC_REQUEST_TYPES.BUS_SEND,
730
+ target,
731
+ message,
732
+ });
733
+ queueStatusLine(`cron:${taskId} -> ${target}`);
734
+ },
735
+ onChange: () => {
736
+ renderDashboard();
737
+ screen.render();
738
+ },
2702
739
  });
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
-
2725
- // Capture console output safely
2726
- const originalLog = console.log;
2727
- const originalError = console.error;
2728
740
 
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;
741
+ function updatePromptBox() {
742
+ if (targetAgent) {
743
+ const label = getAgentLabel(targetAgent);
744
+ promptBox.setContent(`>@${label}`);
745
+ promptBox.width = label.length + 3; // >@name + spacer
746
+ input.left = promptBox.width;
747
+ input.width = `100%-${promptBox.width}`;
748
+ } else {
749
+ promptBox.setContent(">");
750
+ promptBox.width = 2;
751
+ input.left = 2;
752
+ input.width = "100%-2";
753
+ }
754
+ if (!input.parent || !promptBox.parent) return;
755
+ resizeInput();
756
+ if (typeof input._updateCursor === "function") {
757
+ input._updateCursor();
2749
758
  }
2750
759
  }
2751
760
 
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}`);
761
+ function syncTargetFromSelection() {
762
+ if (focusMode !== "dashboard" || dashboardView !== "agents") return;
763
+ if (selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
764
+ const nextTarget = activeAgents[selectedAgentIndex];
765
+ if (nextTarget !== targetAgent) {
766
+ targetAgent = nextTarget;
767
+ updatePromptBox();
768
+ screen.render();
2763
769
  }
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");
770
+ } else if (targetAgent) {
771
+ targetAgent = null;
772
+ updatePromptBox();
773
+ screen.render();
2771
774
  }
2772
775
  }
2773
776
 
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");
777
+ function restoreTargetFromSelection() {
778
+ if (selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
779
+ targetAgent = activeAgents[selectedAgentIndex];
780
+ updatePromptBox();
2810
781
  }
2811
782
  }
2812
783
 
2813
- async function handleInitCommand(args) {
2814
- logMessage("system", "{yellow-fg}⚙{/yellow-fg} Initializing ufoo modules...");
784
+ function focusInput() {
785
+ input.focus();
786
+ input._updateCursor();
787
+ }
2815
788
 
2816
- // Capture console output safely
2817
- const originalLog = console.log;
2818
- const originalError = console.error;
2819
- const logs = [];
789
+ function focusLog() {
790
+ logBox.focus();
791
+ screen.program.hideCursor();
792
+ }
2820
793
 
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
- };
794
+ function scrollLog(offset) {
795
+ logBox.scroll(offset);
796
+ screen.render();
797
+ }
2832
798
 
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 });
799
+ let settingsController = null;
2838
800
 
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;
801
+ function setLaunchMode(mode) {
802
+ if (settingsController) {
803
+ settingsController.setLaunchMode(mode);
2850
804
  }
2851
805
  }
2852
806
 
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}`);
807
+ function requestCloseAgent(agentId) {
808
+ if (!agentId) {
809
+ logMessage("error", "{white-fg}✗{/white-fg} No agent selected");
810
+ return;
2916
811
  }
812
+ const label = getAgentLabel(agentId);
813
+ logMessage("status", `{white-fg}⚙{/white-fg} Closing ${label}...`);
814
+ send({ type: IPC_REQUEST_TYPES.CLOSE_AGENT, agent_id: agentId });
2917
815
  }
2918
816
 
2919
- async function handleCtxCommand(args) {
2920
- logMessage("system", "{yellow-fg}⚙{/yellow-fg} Running context check...");
817
+ function setAgentProvider(provider) {
818
+ if (settingsController) {
819
+ settingsController.setAgentProvider(provider);
820
+ }
821
+ }
2921
822
 
2922
- // Capture console output safely
2923
- const originalLog = console.log;
2924
- const originalError = console.error;
823
+ function setAssistantEngine(value) {
824
+ if (settingsController) {
825
+ settingsController.setAssistantEngine(value);
826
+ }
827
+ }
2925
828
 
2926
- console.log = (...args) => logMessage("system", args.join(" "));
2927
- console.error = (...args) => logMessage("error", args.join(" "));
829
+ function setAutoResume(value) {
830
+ if (settingsController) {
831
+ settingsController.setAutoResume(value);
832
+ }
833
+ }
2928
834
 
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
- }
835
+ async function restartDaemon() {
836
+ if (!daemonCoordinator) return;
837
+ return daemonCoordinator.restart();
838
+ }
839
+
840
+ settingsController = createSettingsController({
841
+ projectRoot,
842
+ saveConfig,
843
+ normalizeLaunchMode,
844
+ normalizeAgentProvider,
845
+ normalizeAssistantEngine,
846
+ fsModule: fs,
847
+ getUfooPaths,
848
+ logMessage,
849
+ renderDashboard,
850
+ renderScreen: () => screen.render(),
851
+ restartDaemon,
852
+ getLaunchMode: () => launchMode,
853
+ setLaunchModeState: (value) => {
854
+ launchMode = value;
855
+ },
856
+ setSelectedModeIndex: (value) => {
857
+ selectedModeIndex = value;
858
+ },
859
+ getAgentProvider: () => agentProvider,
860
+ setAgentProviderState: (value) => {
861
+ agentProvider = value;
862
+ },
863
+ setSelectedProviderIndex: (value) => {
864
+ selectedProviderIndex = value;
865
+ },
866
+ getAssistantEngine: () => assistantEngine,
867
+ setAssistantEngineState: (value) => {
868
+ assistantEngine = value;
869
+ },
870
+ setSelectedAssistantIndex: (value) => {
871
+ selectedAssistantIndex = value;
872
+ },
873
+ assistantOptions,
874
+ getAutoResume: () => autoResume,
875
+ setAutoResumeState: (value) => {
876
+ autoResume = value;
877
+ },
878
+ setSelectedResumeIndex: (value) => {
879
+ selectedResumeIndex = value;
880
+ },
881
+ });
2940
882
 
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;
883
+ function clearLog() {
884
+ logBox.setContent("");
885
+ if (typeof logBox.scrollTo === "function") {
886
+ logBox.scrollTo(0);
2948
887
  }
888
+ screen.render();
2949
889
  }
2950
890
 
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(" "));
891
+ function renderDashboard() {
892
+ const computed = computeDashboardContent({
893
+ focusMode,
894
+ dashboardView,
895
+ activeAgents,
896
+ selectedAgentIndex,
897
+ agentListWindowStart,
898
+ maxAgentWindow: MAX_AGENT_WINDOW,
899
+ getAgentLabel,
900
+ launchMode,
901
+ agentProvider,
902
+ assistantEngine,
903
+ autoResume,
904
+ selectedModeIndex,
905
+ selectedProviderIndex,
906
+ selectedAssistantIndex,
907
+ selectedResumeIndex,
908
+ cronTasks: cronScheduler.listTasks(),
909
+ providerOptions,
910
+ assistantOptions,
911
+ resumeOptions,
912
+ pendingReports: reportPendingTotal,
913
+ dashHints: DASH_HINTS,
914
+ });
915
+ agentListWindowStart = computed.windowStart;
916
+ dashboard.setContent(computed.content);
917
+ }
2957
918
 
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
- }
919
+ function updateDashboard(status) {
920
+ activeAgents = status.active || [];
921
+ reportPendingTotal = Number.isFinite(status?.reports?.pending_total)
922
+ ? status.reports.pending_total
923
+ : 0;
924
+ const metaList = Array.isArray(status.active_meta) ? status.active_meta : [];
925
+ let fallbackMap = null;
926
+ if (metaList.length === 0 && activeAgents.length > 0) {
927
+ try {
928
+ const busPath = getUfooPaths(projectRoot).agentsFile;
929
+ const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
930
+ fallbackMap = new Map();
931
+ for (const [id, meta] of Object.entries(bus.agents || {})) {
932
+ if (meta && meta.nickname) fallbackMap.set(id, meta.nickname);
2971
933
  }
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");
934
+ } catch {
935
+ fallbackMap = null;
2979
936
  }
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
937
  }
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>]");
938
+ const maps = agentDirectory.buildAgentMaps(activeAgents, metaList, fallbackMap);
939
+ activeAgentLabelMap = maps.labelMap;
940
+ activeAgentMetaMap = maps.metaMap;
941
+ clampAgentWindow();
942
+ // If viewing agent went offline, exit view
943
+ const currentView = getCurrentView();
944
+ const viewingAgent = getViewingAgent();
945
+ if (currentView === "agent" && viewingAgent && !activeAgents.includes(viewingAgent)) {
946
+ writeToAgentTerm("\r\n\x1b[1;31m[Agent went offline]\x1b[0m\r\n");
947
+ exitAgentView();
2993
948
  return;
2994
949
  }
2995
950
 
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");
951
+ // In agent view, only update the dashboard bar (blessed is frozen)
952
+ if (currentView === "agent") {
953
+ if (focusMode === "dashboard") {
954
+ const totalItems = 1 + activeAgents.length;
955
+ if (selectedAgentIndex < 0 || selectedAgentIndex >= totalItems) {
956
+ selectedAgentIndex = 0;
957
+ }
958
+ }
959
+ renderAgentDashboard();
2999
960
  return;
3000
961
  }
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;
962
+ if (focusMode === "dashboard") {
963
+ if (dashboardView === "agents") {
964
+ if (activeAgents.length === 0) {
965
+ selectedAgentIndex = -1;
966
+ } else if (selectedAgentIndex < 0 || selectedAgentIndex >= activeAgents.length) {
967
+ selectedAgentIndex = 0;
968
+ }
969
+ clampAgentWindow();
3009
970
  }
3010
971
  }
972
+ syncTargetFromSelection();
973
+ renderDashboard();
974
+ screen.render();
975
+ }
3011
976
 
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;
977
+ function enterDashboardMode() {
978
+ focusMode = "dashboard";
979
+ dashboardView = "agents";
980
+ selectedAgentIndex = activeAgents.length > 0 ? 0 : -1;
981
+ agentListWindowStart = 0;
982
+ clampAgentWindow();
983
+ selectedModeIndex = launchMode === "internal" ? 2 : (launchMode === "tmux" ? 1 : 0);
984
+ selectedProviderIndex = agentProvider === "claude-cli" ? 1 : 0;
985
+ selectedAssistantIndex = Math.max(
986
+ 0,
987
+ assistantOptions.findIndex((opt) => opt.value === assistantEngine)
988
+ );
989
+ selectedResumeIndex = autoResume ? 0 : 1;
990
+ // Immediately set @target when first agent is selected
991
+ if (selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
992
+ targetAgent = activeAgents[selectedAgentIndex];
993
+ updatePromptBox();
3017
994
  }
995
+ screen.grabKeys = true;
996
+ renderDashboard();
997
+ screen.program.hideCursor();
998
+ screen.render();
999
+ syncTargetFromSelection();
1000
+ }
1001
+
1002
+ const dashboardState = {};
1003
+ Object.defineProperties(dashboardState, {
1004
+ currentView: { get: () => getCurrentView() },
1005
+ focusMode: { get: () => focusMode, set: (value) => { focusMode = value; } },
1006
+ dashboardView: { get: () => dashboardView, set: (value) => { dashboardView = value; } },
1007
+ selectedAgentIndex: { get: () => selectedAgentIndex, set: (value) => { selectedAgentIndex = value; } },
1008
+ activeAgents: { get: () => activeAgents },
1009
+ viewingAgent: { get: () => getViewingAgent() },
1010
+ activeAgentMetaMap: { get: () => activeAgentMetaMap },
1011
+ selectedModeIndex: { get: () => selectedModeIndex, set: (value) => { selectedModeIndex = value; } },
1012
+ selectedProviderIndex: { get: () => selectedProviderIndex, set: (value) => { selectedProviderIndex = value; } },
1013
+ selectedAssistantIndex: { get: () => selectedAssistantIndex, set: (value) => { selectedAssistantIndex = value; } },
1014
+ selectedResumeIndex: { get: () => selectedResumeIndex, set: (value) => { selectedResumeIndex = value; } },
1015
+ launchMode: { get: () => launchMode },
1016
+ agentProvider: { get: () => agentProvider },
1017
+ assistantEngine: { get: () => assistantEngine },
1018
+ autoResume: { get: () => autoResume },
1019
+ cronTasks: { get: () => cronScheduler.listTasks() },
1020
+ providerOptions: { get: () => providerOptions },
1021
+ assistantOptions: { get: () => assistantOptions },
1022
+ resumeOptions: { get: () => resumeOptions },
1023
+ agentOutputSuppressed: {
1024
+ get: () => getAgentOutputSuppressed(),
1025
+ set: (value) => { setAgentOutputSuppressed(value); },
1026
+ },
1027
+ });
3018
1028
 
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}`);
1029
+ function activateAgent(agentId) {
1030
+ if (!agentId) return;
1031
+ const activator = new AgentActivator(projectRoot);
1032
+ activator.activate(agentId).catch(() => {});
1033
+ }
1034
+
1035
+ terminalAdapterRouter = createTerminalAdapterRouter({
1036
+ activateAgent,
1037
+ sendRaw: (data) => agentSockets.sendRaw(data),
1038
+ sendResize: (cols, rows) => agentSockets.sendResize(cols, rows),
1039
+ requestSnapshot: (mode) => agentSockets.requestSnapshot(mode),
1040
+ });
1041
+
1042
+ const dashboardController = createDashboardKeyController({
1043
+ state: dashboardState,
1044
+ existsSync: fs.existsSync,
1045
+ getInjectSockPath,
1046
+ getAgentAdapter,
1047
+ activateAgent,
1048
+ requestCloseAgent,
1049
+ enterAgentView,
1050
+ exitAgentView,
1051
+ setAgentBarVisible,
1052
+ requestAgentSnapshot,
1053
+ clearTargetAgent,
1054
+ restoreTargetFromSelection,
1055
+ syncTargetFromSelection,
1056
+ exitDashboardMode,
1057
+ setLaunchMode,
1058
+ setAgentProvider,
1059
+ setAssistantEngine,
1060
+ setAutoResume,
1061
+ clampAgentWindow,
1062
+ clampAgentWindowWithSelection,
1063
+ renderDashboard,
1064
+ renderAgentDashboard,
1065
+ renderScreen: () => screen.render(),
1066
+ setScreenGrabKeys: (value) => {
1067
+ screen.grabKeys = Boolean(value);
1068
+ },
1069
+ });
1070
+
1071
+ function handleDashboardKey(key) {
1072
+ return dashboardController.handleDashboardKey(key);
1073
+ }
1074
+
1075
+ function exitDashboardMode(selectAgent = false) {
1076
+ if (selectAgent && selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
1077
+ targetAgent = activeAgents[selectedAgentIndex];
1078
+ updatePromptBox();
3031
1079
  }
1080
+ focusMode = "input";
1081
+ dashboardView = "agents";
1082
+ selectedAgentIndex = -1;
1083
+ screen.grabKeys = false;
1084
+ renderDashboard();
1085
+ focusInput();
1086
+ screen.render();
3032
1087
  }
3033
1088
 
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);
1089
+ function clearTargetAgent() {
1090
+ targetAgent = null;
1091
+ updatePromptBox();
1092
+ screen.render();
3040
1093
  }
3041
1094
 
3042
- function parseCommand(text) {
3043
- if (!text.startsWith("/")) return null;
1095
+ function getInjectSockPath(agentId) {
1096
+ const safeName = subscriberToSafeName(agentId);
1097
+ return path.join(getUfooPaths(projectRoot).busQueuesDir, safeName, "inject.sock");
1098
+ }
3044
1099
 
3045
- // Split by whitespace, respecting quotes
3046
- const parts = text.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
3047
- if (parts.length === 0) return null;
1100
+ agentViewController = createAgentViewController({
1101
+ screen,
1102
+ input,
1103
+ processStdout: process.stdout,
1104
+ computeAgentBar,
1105
+ agentBarHints: AGENT_BAR_HINTS,
1106
+ maxAgentWindow: MAX_AGENT_WINDOW,
1107
+ getFocusMode: () => focusMode,
1108
+ setFocusMode: (value) => {
1109
+ focusMode = value;
1110
+ },
1111
+ getSelectedAgentIndex: () => selectedAgentIndex,
1112
+ setSelectedAgentIndex: (value) => {
1113
+ selectedAgentIndex = value;
1114
+ },
1115
+ getActiveAgents: () => activeAgents,
1116
+ getAgentListWindowStart: () => agentListWindowStart,
1117
+ setAgentListWindowStart: (value) => {
1118
+ agentListWindowStart = value;
1119
+ },
1120
+ getAgentLabel,
1121
+ setDashboardView: (value) => {
1122
+ dashboardView = value;
1123
+ },
1124
+ setScreenGrabKeys: (value) => {
1125
+ screen.grabKeys = Boolean(value);
1126
+ },
1127
+ clearTargetAgent,
1128
+ renderDashboard,
1129
+ focusInput,
1130
+ resizeInput,
1131
+ renderScreen: () => screen.render(),
1132
+ getInjectSockPath,
1133
+ connectAgentOutput: (sockPath) => {
1134
+ agentSockets.connectOutput(sockPath);
1135
+ },
1136
+ disconnectAgentOutput: () => {
1137
+ agentSockets.disconnectOutput();
1138
+ },
1139
+ connectAgentInput: (sockPath) => {
1140
+ agentSockets.connectInput(sockPath);
1141
+ },
1142
+ disconnectAgentInput: () => {
1143
+ agentSockets.disconnectInput();
1144
+ },
1145
+ sendRaw: (data) => {
1146
+ sendRawWithCapabilities(data);
1147
+ },
1148
+ sendResize: (cols, rows) => {
1149
+ sendResizeWithCapabilities(cols, rows);
1150
+ },
1151
+ requestScreenSnapshot: () => {
1152
+ requestSnapshotWithCapabilities();
1153
+ },
1154
+ });
3048
1155
 
3049
- const command = parts[0].slice(1); // Remove leading /
3050
- const args = parts.slice(1).map(arg => arg.replace(/^"|"$/g, "")); // Remove quotes
1156
+ function requestStatus() {
1157
+ if (!daemonCoordinator) return;
1158
+ daemonCoordinator.requestStatus();
1159
+ }
1160
+
1161
+ const daemonMessageRouter = createDaemonMessageRouter({
1162
+ escapeBlessed,
1163
+ stripBlessedTags,
1164
+ logMessage,
1165
+ renderScreen: () => screen.render(),
1166
+ updateDashboard,
1167
+ requestStatus,
1168
+ resolveStatusLine,
1169
+ enqueueBusStatus,
1170
+ resolveBusStatus,
1171
+ getPending: () => pending,
1172
+ setPending: (value) => {
1173
+ pending = value;
1174
+ },
1175
+ resolveAgentDisplayName,
1176
+ getCurrentView: () => getCurrentView(),
1177
+ isAgentViewUsesBus: () => isAgentViewUsesBus(),
1178
+ getViewingAgent: () => getViewingAgent(),
1179
+ writeToAgentTerm,
1180
+ consumePendingDelivery,
1181
+ getPendingState,
1182
+ beginStream,
1183
+ appendStreamDelta,
1184
+ finalizeStream,
1185
+ hasStream: (publisher) => streamTracker.hasStream(publisher),
1186
+ });
3051
1187
 
3052
- return { command, args };
3053
- }
1188
+ daemonCoordinator = createDaemonCoordinator({
1189
+ projectRoot,
1190
+ daemonTransport,
1191
+ handleMessage: (msg) => daemonMessageRouter.handleMessage(msg),
1192
+ queueStatusLine,
1193
+ resolveStatusLine,
1194
+ logMessage,
1195
+ stopDaemon,
1196
+ startDaemon,
1197
+ });
3054
1198
 
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;
1199
+ const connected = await daemonCoordinator.connect();
1200
+ if (!connected) {
1201
+ // Check if daemon failed to start
1202
+ if (!isRunning(projectRoot)) {
1203
+ const logFile = getUfooPaths(projectRoot).ufooDaemonLog;
1204
+ // eslint-disable-next-line no-console
1205
+ console.error("Failed to start ufoo daemon. Check logs at:", logFile);
1206
+ throw new Error("Daemon failed to start. Check the daemon log for details.");
3092
1207
  }
1208
+ throw new Error("Failed to connect to ufoo daemon (timeout). The daemon may still be starting.");
3093
1209
  }
3094
1210
 
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 = "";
1211
+ const commandExecutor = createCommandExecutor({
1212
+ projectRoot,
1213
+ parseCommand,
1214
+ escapeBlessed,
1215
+ logMessage,
1216
+ renderScreen: () => screen.render(),
1217
+ getActiveAgents: () => activeAgents,
1218
+ getActiveAgentMetaMap: () => activeAgentMetaMap,
1219
+ getAgentLabel,
1220
+ isDaemonRunning: isRunning,
1221
+ startDaemon,
1222
+ stopDaemon,
1223
+ restartDaemon,
1224
+ send,
1225
+ requestStatus,
1226
+ createCronTask: ({ intervalMs, targets, prompt }) =>
1227
+ cronScheduler.addTask({ intervalMs, targets, prompt }),
1228
+ listCronTasks: () => cronScheduler.listTasks(),
1229
+ stopCronTask: (id) => cronScheduler.stopTask(id),
1230
+ activateAgent: async (target) => {
1231
+ const activator = new AgentActivator(projectRoot);
1232
+ await activator.activate(target);
1233
+ },
1234
+ });
3117
1235
 
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
- }
1236
+ async function executeCommand(text) {
1237
+ return commandExecutor.executeCommand(text);
1238
+ }
3152
1239
 
3153
- clearTargetAgent();
3154
- input.focus();
3155
- return;
3156
- }
1240
+ const submitState = {};
1241
+ Object.defineProperties(submitState, {
1242
+ targetAgent: { get: () => targetAgent, set: (value) => { targetAgent = value; } },
1243
+ pending: { get: () => pending, set: (value) => { pending = value; } },
1244
+ activeAgentMetaMap: { get: () => activeAgentMetaMap },
1245
+ });
3157
1246
 
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
- }
1247
+ const inputSubmitHandler = createInputSubmitHandler({
1248
+ state: submitState,
1249
+ parseAtTarget,
1250
+ resolveAgentId,
1251
+ executeCommand,
1252
+ queueStatusLine,
1253
+ send,
1254
+ logMessage,
1255
+ getAgentLabel,
1256
+ escapeBlessed,
1257
+ markPendingDelivery,
1258
+ clearTargetAgent,
1259
+ setTargetAgent: (agentId) => {
1260
+ targetAgent = agentId || null;
1261
+ updatePromptBox();
1262
+ screen.render();
1263
+ },
1264
+ enterAgentView,
1265
+ activateAgent: async (agentId) => {
1266
+ const activator = new AgentActivator(projectRoot);
1267
+ await activator.activate(agentId);
1268
+ },
1269
+ getInjectSockPath,
1270
+ existsSync: fs.existsSync,
1271
+ commitInputHistory: (text) => {
1272
+ if (inputHistoryController) inputHistoryController.commitSubmittedText(text);
1273
+ },
1274
+ focusInput: () => input.focus(),
1275
+ renderScreen: () => screen.render(), // Add renderScreen callback
1276
+ });
3169
1277
 
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();
1278
+ input.on("submit", async (value) => {
1279
+ input.clearValue();
1280
+ screen.render(); // Render cleared input
1281
+ await inputSubmitHandler.handleSubmit(value);
1282
+ // No need for second render - handleSubmit now calls renderScreen() internally
3190
1283
  });
3191
1284
 
3192
1285
  screen.key(["C-c"], exitHandler);
3193
1286
 
3194
1287
  // Agent TTY view: enter dashboard mode
3195
1288
  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;
1289
+ if (agentViewController) {
1290
+ agentViewController.enterAgentDashboardMode();
3223
1291
  }
3224
1292
  }
3225
1293
 
3226
1294
  // Dashboard navigation - use screen.on to capture even when input is focused
3227
1295
  screen.on("keypress", (ch, key) => {
3228
1296
  // Agent TTY view: handle keystrokes
3229
- if (currentView === "agent") {
1297
+ if (getCurrentView() === "agent") {
3230
1298
  if (focusMode === "dashboard") {
3231
1299
  handleDashboardKey(key);
3232
1300
  return;
3233
1301
  }
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) {
1302
+ // Suppress input briefly after entering agent view
1303
+ if (Date.now() < getAgentInputSuppressUntil()) {
3237
1304
  return;
3238
1305
  }
3239
1306
  // Ctrl+C exits entire app
@@ -3258,7 +1325,7 @@ async function runChat(projectRoot) {
3258
1325
  });
3259
1326
 
3260
1327
  screen.key(["tab"], () => {
3261
- if (currentView === "agent") return; // Tab goes to PTY via keypress handler
1328
+ if (getCurrentView() === "agent") return; // Tab goes to PTY via keypress handler
3262
1329
  if (focusMode === "dashboard") {
3263
1330
  exitDashboardMode(false);
3264
1331
  } else {
@@ -3267,13 +1334,13 @@ async function runChat(projectRoot) {
3267
1334
  });
3268
1335
 
3269
1336
  screen.key(["C-k", "M-k"], () => {
3270
- if (currentView === "agent") return;
1337
+ if (getCurrentView() === "agent") return;
3271
1338
  clearLog();
3272
1339
  });
3273
1340
 
3274
1341
 
3275
1342
  screen.key(["i", "enter"], () => {
3276
- if (currentView === "agent") return;
1343
+ if (getCurrentView() === "agent") return;
3277
1344
  if (focusMode === "dashboard") return;
3278
1345
  if (screen.focused === input) return;
3279
1346
  focusInput();
@@ -3292,43 +1359,7 @@ async function runChat(projectRoot) {
3292
1359
  }
3293
1360
  if (screen.program) {
3294
1361
  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
- }
1362
+ pasteController.handleProgramData(data);
3332
1363
  });
3333
1364
  }
3334
1365
  loadHistory();
@@ -3339,24 +1370,19 @@ async function runChat(projectRoot) {
3339
1370
 
3340
1371
  // 定期刷新 dashboard 状态(兜底,daemon 会主动推送变化)
3341
1372
  setInterval(() => {
3342
- if (client && !client.destroyed) {
1373
+ if (daemonCoordinator && daemonCoordinator.isConnected()) {
3343
1374
  requestStatus();
3344
1375
  }
3345
1376
  }, 30000);
3346
-
3347
1377
  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();
1378
+ if (handleResizeInAgentView()) {
3355
1379
  return;
3356
1380
  }
3357
1381
  resizeInput();
3358
- if (completionActive) hideCompletion();
1382
+ if (completionController.isActive()) completionController.hide();
3359
1383
  input._updateCursor();
1384
+ // Force recalculate logBox width to match terminal
1385
+ logBox.width = screen.width;
3360
1386
  screen.render();
3361
1387
  });
3362
1388
  screen.render();