u-foo 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +163 -0
  3. package/README.zh-CN.md +163 -0
  4. package/bin/uclaude +65 -0
  5. package/bin/ucodex +65 -0
  6. package/bin/ufoo +93 -0
  7. package/bin/ufoo.js +35 -0
  8. package/modules/AGENTS.template.md +87 -0
  9. package/modules/bus/README.md +132 -0
  10. package/modules/bus/SKILLS/ubus/SKILL.md +209 -0
  11. package/modules/bus/scripts/bus-alert.sh +185 -0
  12. package/modules/bus/scripts/bus-listen.sh +117 -0
  13. package/modules/context/ASSUMPTIONS.md +7 -0
  14. package/modules/context/CONSTRAINTS.md +7 -0
  15. package/modules/context/CONTEXT-STRUCTURE.md +49 -0
  16. package/modules/context/DECISION-PROTOCOL.md +62 -0
  17. package/modules/context/HANDOFF.md +33 -0
  18. package/modules/context/README.md +82 -0
  19. package/modules/context/RULES.md +15 -0
  20. package/modules/context/SKILLS/README.md +14 -0
  21. package/modules/context/SKILLS/uctx/SKILL.md +91 -0
  22. package/modules/context/SYSTEM.md +18 -0
  23. package/modules/context/TEMPLATES/assumptions.md +4 -0
  24. package/modules/context/TEMPLATES/constraints.md +4 -0
  25. package/modules/context/TEMPLATES/decision.md +16 -0
  26. package/modules/context/TEMPLATES/project-context-readme.md +6 -0
  27. package/modules/context/TEMPLATES/system.md +3 -0
  28. package/modules/context/TEMPLATES/terminology.md +4 -0
  29. package/modules/context/TERMINOLOGY.md +10 -0
  30. package/modules/resources/ICONS/README.md +12 -0
  31. package/modules/resources/ICONS/libraries/README.md +17 -0
  32. package/modules/resources/ICONS/libraries/heroicons/LICENSE +22 -0
  33. package/modules/resources/ICONS/libraries/heroicons/README.md +15 -0
  34. package/modules/resources/ICONS/libraries/heroicons/arrow-right.svg +4 -0
  35. package/modules/resources/ICONS/libraries/heroicons/check.svg +4 -0
  36. package/modules/resources/ICONS/libraries/heroicons/chevron-down.svg +4 -0
  37. package/modules/resources/ICONS/libraries/heroicons/cog-6-tooth.svg +5 -0
  38. package/modules/resources/ICONS/libraries/heroicons/magnifying-glass.svg +4 -0
  39. package/modules/resources/ICONS/libraries/heroicons/x-mark.svg +4 -0
  40. package/modules/resources/ICONS/libraries/lucide/LICENSE +40 -0
  41. package/modules/resources/ICONS/libraries/lucide/README.md +15 -0
  42. package/modules/resources/ICONS/libraries/lucide/arrow-right.svg +15 -0
  43. package/modules/resources/ICONS/libraries/lucide/check.svg +14 -0
  44. package/modules/resources/ICONS/libraries/lucide/chevron-down.svg +14 -0
  45. package/modules/resources/ICONS/libraries/lucide/search.svg +15 -0
  46. package/modules/resources/ICONS/libraries/lucide/settings.svg +15 -0
  47. package/modules/resources/ICONS/libraries/lucide/x.svg +15 -0
  48. package/modules/resources/ICONS/rules.md +7 -0
  49. package/modules/resources/README.md +9 -0
  50. package/modules/resources/UI/ANTI-PATTERNS.md +6 -0
  51. package/modules/resources/UI/TONE.md +6 -0
  52. package/package.json +40 -0
  53. package/scripts/banner.sh +89 -0
  54. package/scripts/bus-alert.sh +6 -0
  55. package/scripts/bus-autotrigger.sh +6 -0
  56. package/scripts/bus-daemon.sh +231 -0
  57. package/scripts/bus-inject.sh +144 -0
  58. package/scripts/bus-listen.sh +6 -0
  59. package/scripts/bus.sh +984 -0
  60. package/scripts/context-decisions.sh +167 -0
  61. package/scripts/context-doctor.sh +72 -0
  62. package/scripts/context-lint.sh +110 -0
  63. package/scripts/doctor.sh +22 -0
  64. package/scripts/init.sh +247 -0
  65. package/scripts/skills.sh +113 -0
  66. package/scripts/status.sh +125 -0
  67. package/src/agent/cliRunner.js +190 -0
  68. package/src/agent/internalRunner.js +212 -0
  69. package/src/agent/normalizeOutput.js +41 -0
  70. package/src/agent/ufooAgent.js +222 -0
  71. package/src/chat/index.js +1603 -0
  72. package/src/cli.js +349 -0
  73. package/src/config.js +37 -0
  74. package/src/daemon/index.js +501 -0
  75. package/src/daemon/ops.js +120 -0
  76. package/src/daemon/run.js +41 -0
  77. package/src/daemon/status.js +78 -0
@@ -0,0 +1,1603 @@
1
+ const net = require("net");
2
+ const path = require("path");
3
+ const blessed = require("blessed");
4
+ const { spawn, spawnSync } = require("child_process");
5
+ const fs = require("fs");
6
+ const { loadConfig, saveConfig, normalizeLaunchMode } = require("../config");
7
+ const { socketPath, isRunning } = require("../daemon");
8
+
9
+ function connectSocket(sockPath) {
10
+ return new Promise((resolve, reject) => {
11
+ const client = net.createConnection(sockPath, () => resolve(client));
12
+ client.on("error", reject);
13
+ });
14
+ }
15
+
16
+ function startDaemon(projectRoot) {
17
+ const child = spawn(process.execPath, [path.join(projectRoot, "bin", "ufoo.js"), "daemon", "--start"], {
18
+ detached: true,
19
+ stdio: "ignore",
20
+ cwd: projectRoot,
21
+ });
22
+ child.unref();
23
+ }
24
+
25
+ async function runChat(projectRoot) {
26
+ if (!fs.existsSync(path.join(projectRoot, ".ufoo"))) {
27
+ spawnSync("bash", [path.join(projectRoot, "scripts", "init.sh"), "--modules", "context,bus", "--project", projectRoot], {
28
+ stdio: "inherit",
29
+ });
30
+ }
31
+ if (!isRunning(projectRoot)) {
32
+ startDaemon(projectRoot);
33
+ }
34
+
35
+ const sock = socketPath(projectRoot);
36
+ let client = null;
37
+ for (let i = 0; i < 10; i += 1) {
38
+ try {
39
+ client = await connectSocket(sock);
40
+ break;
41
+ } catch {
42
+ await new Promise((r) => setTimeout(r, 200));
43
+ }
44
+ }
45
+ if (!client) throw new Error("Failed to connect to ufoo daemon");
46
+
47
+ const screen = blessed.screen({
48
+ smartCSR: true,
49
+ title: "ufoo chat",
50
+ fullUnicode: true,
51
+ // Allow terminal native copy by not fully grabbing mouse
52
+ // Hold Option/Alt to use native selection in most terminals
53
+ sendFocus: true,
54
+ mouse: false,
55
+ // Allow Ctrl+C to exit even when input grabs keys
56
+ ignoreLocked: ["C-c"],
57
+ });
58
+
59
+ const config = loadConfig(projectRoot);
60
+ let launchMode = config.launchMode;
61
+
62
+ // Dynamic input height settings
63
+ // Layout: topLine(1) + content + bottomLine(1) + dashboard(1)
64
+ const MIN_INPUT_HEIGHT = 4; // 1 content + 3
65
+ const MAX_INPUT_HEIGHT = 9; // 6 content + 3
66
+ let currentInputHeight = MIN_INPUT_HEIGHT;
67
+
68
+ // Log area (no border for cleaner look)
69
+ const logBox = blessed.log({
70
+ parent: screen,
71
+ top: 0,
72
+ left: 0,
73
+ width: "100%",
74
+ height: "100%-5", // Will be adjusted dynamically
75
+ tags: true,
76
+ scrollable: true,
77
+ alwaysScroll: true,
78
+ scrollback: 10000,
79
+ scrollbar: { ch: "│", style: { fg: "cyan" } },
80
+ keys: true,
81
+ vi: true,
82
+ // Enable mouse wheel scrolling in log area (use Option/Alt for native selection)
83
+ mouse: true,
84
+ });
85
+
86
+ // Status line just above input
87
+ const statusLine = blessed.box({
88
+ parent: screen,
89
+ bottom: currentInputHeight,
90
+ left: 0,
91
+ width: "100%",
92
+ height: 1,
93
+ style: { fg: "gray" },
94
+ tags: true,
95
+ content: "",
96
+ });
97
+ const pkg = require("../../package.json");
98
+ const bannerText = `{bold}UFOO{/bold} · Multi-Agent Manager{|}v${pkg.version}`;
99
+ statusLine.setContent(bannerText);
100
+
101
+ const historyDir = path.join(projectRoot, ".ufoo", "chat");
102
+ const historyFile = path.join(historyDir, "history.jsonl");
103
+ const inputHistoryFile = path.join(historyDir, "input-history.jsonl");
104
+
105
+ function appendHistory(entry) {
106
+ fs.mkdirSync(historyDir, { recursive: true });
107
+ fs.appendFileSync(historyFile, `${JSON.stringify(entry)}\n`);
108
+ }
109
+
110
+ const SPACED_TYPES = new Set(["user", "reply", "bus", "dispatch", "error"]);
111
+ let lastLogWasSpacer = false;
112
+ let lastLogType = null;
113
+ let hasLoggedAny = false;
114
+
115
+ function shouldSpace(type) {
116
+ return SPACED_TYPES.has(type);
117
+ }
118
+
119
+ function writeSpacer(writeHistory) {
120
+ if (lastLogWasSpacer || !hasLoggedAny) return;
121
+ logBox.log(" ");
122
+ if (writeHistory) {
123
+ appendHistory({
124
+ ts: new Date().toISOString(),
125
+ type: "spacer",
126
+ text: "",
127
+ meta: {},
128
+ });
129
+ }
130
+ lastLogWasSpacer = true;
131
+ lastLogType = "spacer";
132
+ hasLoggedAny = true;
133
+ }
134
+
135
+ function recordLog(type, text, meta = {}, writeHistory = true) {
136
+ if (type !== "spacer" && shouldSpace(type)) {
137
+ writeSpacer(writeHistory);
138
+ }
139
+ logBox.log(text);
140
+ if (writeHistory) {
141
+ appendHistory({
142
+ ts: new Date().toISOString(),
143
+ type,
144
+ text,
145
+ meta,
146
+ });
147
+ }
148
+ lastLogWasSpacer = false;
149
+ lastLogType = type;
150
+ hasLoggedAny = true;
151
+ }
152
+
153
+ function logMessage(type, text, meta = {}) {
154
+ recordLog(type, text, meta, true);
155
+ }
156
+
157
+ function loadHistory(limit = 2000) {
158
+ try {
159
+ const lines = fs.readFileSync(historyFile, "utf8").trim().split(/\r?\n/).filter(Boolean);
160
+ const items = lines.slice(-limit).map((line) => JSON.parse(line));
161
+ const hasSpacer = items.some((item) => item && item.type === "spacer");
162
+ for (const item of items) {
163
+ if (!item) continue;
164
+ if (item.type === "spacer") {
165
+ writeSpacer(false);
166
+ continue;
167
+ }
168
+ if (!item.text) continue;
169
+ if (hasSpacer) {
170
+ logBox.log(item.text);
171
+ lastLogWasSpacer = false;
172
+ lastLogType = item.type || null;
173
+ hasLoggedAny = true;
174
+ } else {
175
+ recordLog(item.type || "unknown", item.text, item.meta || {}, false);
176
+ }
177
+ }
178
+ } catch {
179
+ // ignore missing/invalid history
180
+ }
181
+ }
182
+
183
+ const inputHistory = [];
184
+ let historyIndex = 0;
185
+ let historyDraft = "";
186
+
187
+ function appendInputHistory(text) {
188
+ if (!text) return;
189
+ fs.mkdirSync(historyDir, { recursive: true });
190
+ fs.appendFileSync(inputHistoryFile, `${JSON.stringify({ text })}\n`);
191
+ }
192
+
193
+ function loadInputHistory(limit = 2000) {
194
+ try {
195
+ const lines = fs.readFileSync(inputHistoryFile, "utf8").trim().split(/\r?\n/).filter(Boolean);
196
+ const items = lines.slice(-limit).map((line) => JSON.parse(line));
197
+ for (const item of items) {
198
+ if (item && typeof item.text === "string" && item.text.trim() !== "") {
199
+ inputHistory.push(item.text);
200
+ }
201
+ }
202
+ } catch {
203
+ // ignore missing/invalid history
204
+ }
205
+ historyIndex = inputHistory.length;
206
+ }
207
+
208
+ const pendingStatusLines = [];
209
+ const busStatusQueue = [];
210
+ let primaryStatusText = bannerText;
211
+
212
+ function formatProcessingText(text) {
213
+ if (!text) return text;
214
+ if (text.includes("{")) return text;
215
+ if (!/processing/i.test(text)) return text;
216
+ return `{yellow-fg}⏳{/yellow-fg} ${text}`;
217
+ }
218
+
219
+ function renderStatusLine() {
220
+ let content = primaryStatusText || "";
221
+ if (busStatusQueue.length > 0) {
222
+ const extra = busStatusQueue.length > 1
223
+ ? ` {gray-fg}(+${busStatusQueue.length - 1}){/gray-fg}`
224
+ : "";
225
+ const busText = `${busStatusQueue[0].text}${extra}`;
226
+ content = content
227
+ ? `${content} {gray-fg}·{/gray-fg} ${busText}`
228
+ : busText;
229
+ }
230
+ statusLine.setContent(content);
231
+ }
232
+
233
+ function setPrimaryStatus(text) {
234
+ primaryStatusText = text || "";
235
+ renderStatusLine();
236
+ }
237
+
238
+ function queueStatusLine(text) {
239
+ const formatted = formatProcessingText(text);
240
+ pendingStatusLines.push(formatted);
241
+ if (pendingStatusLines.length === 1) {
242
+ setPrimaryStatus(formatted);
243
+ screen.render();
244
+ }
245
+ }
246
+
247
+ function resolveStatusLine(text) {
248
+ if (pendingStatusLines.length > 0) {
249
+ pendingStatusLines.shift();
250
+ }
251
+ if (pendingStatusLines.length > 0) {
252
+ setPrimaryStatus(pendingStatusLines[0]);
253
+ } else {
254
+ setPrimaryStatus(text || "");
255
+ }
256
+ screen.render();
257
+ }
258
+
259
+ function enqueueBusStatus(item) {
260
+ if (!item || !item.text) return;
261
+ const key = item.key || item.text;
262
+ const formatted = formatProcessingText(item.text);
263
+ const existing = busStatusQueue.find((entry) => entry.key === key);
264
+ if (existing) {
265
+ existing.text = formatted;
266
+ } else {
267
+ busStatusQueue.push({ key, text: formatted });
268
+ }
269
+ renderStatusLine();
270
+ }
271
+
272
+ function resolveBusStatus(item) {
273
+ if (!item) return;
274
+ const key = item.key || item.text;
275
+ let index = -1;
276
+ if (key) {
277
+ index = busStatusQueue.findIndex((entry) => entry.key === key);
278
+ }
279
+ if (index === -1 && item.text) {
280
+ index = busStatusQueue.findIndex((entry) => entry.text === item.text);
281
+ }
282
+ if (index === -1) return;
283
+ busStatusQueue.splice(index, 1);
284
+ renderStatusLine();
285
+ }
286
+
287
+ // Command completion panel
288
+ const completionPanel = blessed.box({
289
+ parent: screen,
290
+ bottom: currentInputHeight - 1,
291
+ left: 0,
292
+ width: "100%",
293
+ height: 0,
294
+ hidden: true,
295
+ border: {
296
+ type: "line",
297
+ top: true,
298
+ left: false,
299
+ right: false,
300
+ bottom: false
301
+ },
302
+ style: {
303
+ border: { fg: "yellow" },
304
+ fg: "white"
305
+ // No bg - uses terminal default background
306
+ },
307
+ padding: {
308
+ left: 0,
309
+ right: 0,
310
+ top: 0,
311
+ bottom: 0
312
+ },
313
+ tags: true,
314
+ });
315
+
316
+ // Dashboard at very bottom
317
+ const dashboard = blessed.box({
318
+ parent: screen,
319
+ bottom: 0,
320
+ left: 0,
321
+ width: "100%",
322
+ height: 1,
323
+ style: { fg: "gray" },
324
+ tags: true,
325
+ });
326
+
327
+ // Bottom border line for input area (above dashboard)
328
+ const inputBottomLine = blessed.line({
329
+ parent: screen,
330
+ bottom: 1,
331
+ left: 0,
332
+ width: "100%",
333
+ orientation: "horizontal",
334
+ style: { fg: "cyan" },
335
+ });
336
+
337
+ // Prompt indicator
338
+ const promptBox = blessed.box({
339
+ parent: screen,
340
+ bottom: 2,
341
+ left: 0,
342
+ width: 2,
343
+ height: currentInputHeight - 3,
344
+ content: ">",
345
+ style: { fg: "cyan" },
346
+ });
347
+
348
+ // Input area without left/right border
349
+ const input = blessed.textarea({
350
+ parent: screen,
351
+ bottom: 2,
352
+ left: 2,
353
+ width: "100%-2",
354
+ height: currentInputHeight - 3,
355
+ inputOnFocus: true,
356
+ keys: true,
357
+ });
358
+ // Avoid textarea's extra wrap margin (causes a phantom empty column)
359
+ input.type = "box";
360
+
361
+ // Top border line for input area (just above input)
362
+ const inputTopLine = blessed.line({
363
+ parent: screen,
364
+ bottom: currentInputHeight - 1, // 4-1=3: above input(2) + inputHeight(1)
365
+ left: 0,
366
+ width: "100%",
367
+ orientation: "horizontal",
368
+ style: { fg: "cyan" },
369
+ });
370
+
371
+ // Add cursor position tracking
372
+ let cursorPos = 0;
373
+ let preferredCol = null;
374
+
375
+ // Get inner width
376
+ function getInnerWidth() {
377
+ const lpos = input.lpos || input._getCoords();
378
+ if (lpos && Number.isFinite(lpos.xl) && Number.isFinite(lpos.xi)) {
379
+ return Math.max(1, lpos.xl - lpos.xi + 1);
380
+ }
381
+ if (typeof input.width === "number") return Math.max(1, input.width);
382
+ if (typeof input.width === "string") {
383
+ const match = input.width.match(/^100%-([0-9]+)$/);
384
+ if (match && typeof screen.width === "number") {
385
+ return Math.max(1, screen.width - parseInt(match[1], 10));
386
+ }
387
+ }
388
+ const promptWidth = typeof promptBox.width === "number" ? promptBox.width : 2;
389
+ if (typeof screen.width === "number") return Math.max(1, screen.width - promptWidth);
390
+ if (typeof screen.cols === "number") return Math.max(1, screen.cols - promptWidth);
391
+ return 1;
392
+ }
393
+
394
+ // Count lines considering both wrapping and newlines
395
+ function countLines(text, width) {
396
+ if (width <= 0) return 1;
397
+ const lines = text.split("\n");
398
+ let total = 0;
399
+ for (const line of lines) {
400
+ const lineWidth = input.strWidth(line);
401
+ total += Math.max(1, Math.ceil(lineWidth / width));
402
+ }
403
+ return total;
404
+ }
405
+
406
+ function getCursorRowCol(text, pos, width) {
407
+ if (width <= 0) return { row: 0, col: 0 };
408
+ const before = text.slice(0, pos);
409
+ const lines = before.split("\n");
410
+ let row = 0;
411
+ for (let i = 0; i < lines.length - 1; i++) {
412
+ const lineWidth = input.strWidth(lines[i]);
413
+ row += Math.max(1, Math.ceil(lineWidth / width));
414
+ }
415
+ const lastLine = lines[lines.length - 1] || "";
416
+ const lastWidth = input.strWidth(lastLine);
417
+ row += Math.floor(lastWidth / width);
418
+ const col = lastWidth % width;
419
+ return { row, col };
420
+ }
421
+
422
+ function getLinePosForCol(line, targetCol) {
423
+ if (targetCol <= 0) return 0;
424
+ let col = 0;
425
+ let offset = 0;
426
+ for (const ch of Array.from(line)) {
427
+ const w = input.strWidth(ch);
428
+ if (col + w > targetCol) return offset;
429
+ col += w;
430
+ offset += ch.length;
431
+ }
432
+ return offset;
433
+ }
434
+
435
+ function getCursorPosForRowCol(text, targetRow, targetCol, width) {
436
+ if (width <= 0) return 0;
437
+ const lines = text.split("\n");
438
+ let row = 0;
439
+ let pos = 0;
440
+ for (const line of lines) {
441
+ const lineWidth = input.strWidth(line);
442
+ const wrappedRows = Math.max(1, Math.ceil(lineWidth / width));
443
+ if (targetRow < row + wrappedRows) {
444
+ const rowInLine = targetRow - row;
445
+ const visualCol = rowInLine * width + Math.max(0, targetCol);
446
+ return pos + getLinePosForCol(line, visualCol);
447
+ }
448
+ pos += line.length + 1;
449
+ row += wrappedRows;
450
+ }
451
+ return text.length;
452
+ }
453
+
454
+ function resetPreferredCol() {
455
+ preferredCol = null;
456
+ }
457
+
458
+ const PASTE_START = "\x1b[200~";
459
+ const PASTE_END = "\x1b[201~";
460
+ let pasteActive = false;
461
+ let pasteBuffer = "";
462
+ let pasteRemainder = "";
463
+ let suppressKeypress = false;
464
+ let suppressReset = null;
465
+
466
+ function scheduleSuppressReset() {
467
+ suppressKeypress = true;
468
+ if (suppressReset) clearImmediate(suppressReset);
469
+ suppressReset = setImmediate(() => {
470
+ if (!pasteActive) suppressKeypress = false;
471
+ });
472
+ }
473
+
474
+ function normalizePaste(text) {
475
+ if (!text) return "";
476
+ let normalized = text.replace(/\x1b\[200~|\x1b\[201~/g, "");
477
+ normalized = normalized.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
478
+ return normalized;
479
+ }
480
+
481
+ function updateDraftFromInput() {
482
+ if (historyIndex === inputHistory.length) {
483
+ historyDraft = input.value;
484
+ }
485
+ }
486
+
487
+ function normalizeCommandPrefix() {
488
+ if (!input.value.startsWith("//")) return;
489
+ const match = input.value.match(/^\/{2,}/);
490
+ if (!match) return;
491
+ const extra = match[0].length - 1;
492
+ input.value = `/${input.value.slice(match[0].length)}`;
493
+ cursorPos = Math.max(0, cursorPos - extra);
494
+ }
495
+
496
+ function insertTextAtCursor(text) {
497
+ if (!text) return;
498
+ input.value = input.value.slice(0, cursorPos) + text + input.value.slice(cursorPos);
499
+ cursorPos += text.length;
500
+ normalizeCommandPrefix();
501
+ resetPreferredCol();
502
+ resizeInput();
503
+ input._updateCursor();
504
+ screen.render();
505
+ updateDraftFromInput();
506
+ }
507
+
508
+ function setInputValue(value) {
509
+ input.value = value || "";
510
+ cursorPos = input.value.length;
511
+ resetPreferredCol();
512
+ resizeInput();
513
+ input._updateCursor();
514
+ screen.render();
515
+ }
516
+
517
+ function historyUp() {
518
+ if (inputHistory.length === 0) return false;
519
+ if (historyIndex === inputHistory.length) {
520
+ historyDraft = input.value;
521
+ }
522
+ if (historyIndex > 0) {
523
+ historyIndex -= 1;
524
+ setInputValue(inputHistory[historyIndex]);
525
+ return true;
526
+ }
527
+ return true;
528
+ }
529
+
530
+ function historyDown() {
531
+ if (inputHistory.length === 0) return false;
532
+ if (historyIndex < inputHistory.length - 1) {
533
+ historyIndex += 1;
534
+ setInputValue(inputHistory[historyIndex]);
535
+ return true;
536
+ }
537
+ if (historyIndex === inputHistory.length - 1) {
538
+ historyIndex = inputHistory.length;
539
+ setInputValue(historyDraft || "");
540
+ return true;
541
+ }
542
+ return false;
543
+ }
544
+
545
+ function exitHandler() {
546
+ if (screen && screen.program && typeof screen.program.decrst === "function") {
547
+ screen.program.decrst(2004);
548
+ }
549
+ if (client) {
550
+ client.end();
551
+ }
552
+ process.exit(0);
553
+ }
554
+
555
+ // Command completion functions
556
+ function showCompletion(filterText) {
557
+ // Ensure accidental double-prefix doesn't break filtering.
558
+ normalizeCommandPrefix();
559
+ if (filterText !== input.value) {
560
+ filterText = input.value;
561
+ }
562
+ if (filterText.startsWith("//")) {
563
+ filterText = filterText.replace(/^\/+/, "/");
564
+ input.value = filterText;
565
+ cursorPos = Math.min(cursorPos, input.value.length);
566
+ }
567
+ if (!filterText || filterText === "") {
568
+ hideCompletion();
569
+ return;
570
+ }
571
+
572
+ // Trim the filterText to handle trailing spaces for main command mode
573
+ // But preserve spaces for subcommand mode detection
574
+ const endsWithSpace = /\s$/.test(filterText);
575
+ const trimmed = filterText.trim();
576
+ if (!trimmed) {
577
+ hideCompletion();
578
+ return;
579
+ }
580
+ filterText = trimmed;
581
+
582
+ // Check if we're in subcommand mode
583
+ const parts = filterText.split(/\s+/);
584
+ let commands = [];
585
+
586
+ if ((parts.length > 1 || (endsWithSpace && parts.length === 1)) && parts[0].startsWith("/")) {
587
+ // Subcommand mode: "/bus rename"
588
+ const mainCmd = parts[0];
589
+ const subFilter = parts[1] || "";
590
+
591
+ // Find the main command
592
+ const mainCmdObj = COMMAND_REGISTRY.find(item =>
593
+ item.cmd.toLowerCase() === mainCmd.toLowerCase()
594
+ );
595
+
596
+ if (mainCmdObj && mainCmdObj.subcommands) {
597
+ // Filter subcommands
598
+ commands = mainCmdObj.subcommands
599
+ .filter(sub => sub.cmd.toLowerCase().startsWith(subFilter.toLowerCase()))
600
+ .map(sub => ({ ...sub, isSubcommand: true, parentCmd: mainCmd }));
601
+ }
602
+ } else {
603
+ // Main command mode: "/bus"
604
+ const prefixMatches = COMMAND_REGISTRY.filter(item =>
605
+ item.cmd.toLowerCase().startsWith(filterText.toLowerCase())
606
+ );
607
+ // Also allow fuzzy matches on the command body (e.g. "/b" -> /bus + /ubus)
608
+ let fuzzyMatches = [];
609
+ if (filterText.startsWith("/") && parts.length === 1) {
610
+ const needle = filterText.slice(1).toLowerCase();
611
+ if (needle) {
612
+ fuzzyMatches = COMMAND_REGISTRY.filter(item =>
613
+ item.cmd.toLowerCase().includes(needle)
614
+ );
615
+ }
616
+ }
617
+ const merged = new Map();
618
+ for (const item of prefixMatches) merged.set(item.cmd, item);
619
+ for (const item of fuzzyMatches) merged.set(item.cmd, item);
620
+ commands = Array.from(merged.values());
621
+ }
622
+
623
+ if (commands.length === 0) {
624
+ hideCompletion();
625
+ return;
626
+ }
627
+
628
+ completionCommands = commands;
629
+ completionActive = true;
630
+ completionIndex = 0;
631
+ completionScrollOffset = 0;
632
+
633
+ // Calculate panel height (max 8 visible + 1 for top border)
634
+ const visibleItems = Math.min(8, completionCommands.length);
635
+ completionPanel.height = visibleItems + 1;
636
+ completionPanel.bottom = currentInputHeight - 1;
637
+ completionPanel.hidden = false;
638
+
639
+ renderCompletionPanel();
640
+ }
641
+
642
+ function hideCompletion() {
643
+ completionActive = false;
644
+ completionCommands = [];
645
+ completionIndex = 0;
646
+ completionScrollOffset = 0;
647
+ completionPanel.hidden = true;
648
+ screen.render();
649
+ }
650
+
651
+ function renderCompletionPanel() {
652
+ if (!completionActive || completionCommands.length === 0) return;
653
+
654
+ const maxVisible = 8;
655
+
656
+ // Adjust scroll offset to keep selected item visible
657
+ if (completionIndex < completionScrollOffset) {
658
+ completionScrollOffset = completionIndex;
659
+ } else if (completionIndex >= completionScrollOffset + maxVisible) {
660
+ completionScrollOffset = completionIndex - maxVisible + 1;
661
+ }
662
+
663
+ // Calculate visible slice
664
+ const visibleStart = completionScrollOffset;
665
+ const visibleEnd = Math.min(completionScrollOffset + maxVisible, completionCommands.length);
666
+ const visibleCommands = completionCommands.slice(visibleStart, visibleEnd);
667
+
668
+ const lines = visibleCommands.map((item, i) => {
669
+ const actualIndex = visibleStart + i;
670
+ const cmdPart = actualIndex === completionIndex
671
+ ? `{inverse}${item.cmd}{/inverse}`
672
+ : `{cyan-fg}${item.cmd}{/cyan-fg}`;
673
+ const descPart = `{gray-fg}${item.desc}{/gray-fg}`;
674
+ // Use promptBox width (2) to align with input position
675
+ const indent = " ".repeat(promptBox.width || 2);
676
+ return `${indent}${cmdPart} ${descPart}`;
677
+ });
678
+
679
+ completionPanel.setContent(lines.join("\n"));
680
+ screen.render();
681
+ }
682
+
683
+ function completionUp() {
684
+ if (completionCommands.length === 0) return;
685
+ completionIndex = completionIndex <= 0
686
+ ? completionCommands.length - 1
687
+ : completionIndex - 1;
688
+ renderCompletionPanel();
689
+ }
690
+
691
+ function completionDown() {
692
+ if (completionCommands.length === 0) return;
693
+ completionIndex = completionIndex >= completionCommands.length - 1
694
+ ? 0
695
+ : completionIndex + 1;
696
+ renderCompletionPanel();
697
+ }
698
+
699
+ function confirmCompletion() {
700
+ if (!completionActive || completionCommands.length === 0) return;
701
+
702
+ const selected = completionCommands[completionIndex];
703
+
704
+ if (selected.isSubcommand) {
705
+ // Subcommand: replace the last word with selected subcommand
706
+ const parts = input.value.split(/\s+/);
707
+ parts[parts.length - 1] = selected.cmd;
708
+ input.value = parts.join(" ") + " ";
709
+ } else {
710
+ // Main command
711
+ input.value = selected.cmd + " ";
712
+ }
713
+
714
+ cursorPos = input.value.length;
715
+ resetPreferredCol();
716
+ input._updateCursor();
717
+ updateDraftFromInput();
718
+
719
+ // If selected command has subcommands, trigger subcommand completion immediately
720
+ if (!selected.isSubcommand && selected.subcommands && selected.subcommands.length > 0) {
721
+ // Don't hide - directly show subcommand completion
722
+ showCompletion(input.value);
723
+ } else {
724
+ // No subcommands - hide completion
725
+ hideCompletion();
726
+ }
727
+
728
+ screen.render();
729
+ }
730
+
731
+ function handleCompletionKey(ch, key) {
732
+ if (!completionActive) return false;
733
+
734
+ if (key.name === "up") {
735
+ completionUp();
736
+ return true;
737
+ }
738
+ if (key.name === "down") {
739
+ completionDown();
740
+ return true;
741
+ }
742
+ if (key.name === "tab") {
743
+ confirmCompletion();
744
+ return true;
745
+ }
746
+ if (key.name === "enter" || key.name === "return") {
747
+ // Enter submits input, doesn't confirm completion
748
+ hideCompletion();
749
+ return false;
750
+ }
751
+ if (key.name === "escape") {
752
+ hideCompletion();
753
+ return true;
754
+ }
755
+ if (ch === " ") {
756
+ // Check if current input is a command that might have subcommands
757
+ const currentInput = input.value.trim();
758
+ if (currentInput.startsWith("/") && !currentInput.includes(" ")) {
759
+ // Let space be inserted, will trigger subcommand completion
760
+ return false;
761
+ }
762
+ hideCompletion();
763
+ return false;
764
+ }
765
+ // Regular character and backspace - don't intercept, let it be handled normally
766
+ // Completion will be updated in the main input handler
767
+ return false;
768
+ }
769
+
770
+ // Resize input box based on content
771
+ function resizeInput() {
772
+ const innerWidth = getInnerWidth();
773
+ if (innerWidth <= 0) return;
774
+
775
+ const numLines = countLines(input.value, innerWidth);
776
+ const contentHeight = Math.min(MAX_INPUT_HEIGHT - 3, Math.max(1, numLines));
777
+ const targetHeight = contentHeight + 3; // +1 topLine +1 bottomLine +1 dashboard
778
+
779
+ if (targetHeight !== currentInputHeight) {
780
+ currentInputHeight = targetHeight;
781
+ input.height = contentHeight;
782
+ promptBox.height = contentHeight;
783
+ inputTopLine.bottom = currentInputHeight - 1; // Just above input area
784
+ }
785
+ statusLine.bottom = currentInputHeight;
786
+ // Reposition completion panel if active
787
+ if (completionActive) {
788
+ completionPanel.bottom = currentInputHeight - 1;
789
+ }
790
+ // dashboard and inputBottomLine stay fixed at bottom 0 and 1
791
+ logBox.height = Math.max(1, screen.height - currentInputHeight - 1);
792
+ }
793
+
794
+ // Override the internal listener to support cursor movement
795
+ input._listener = function(ch, key) {
796
+ if (key && key.ctrl && key.name === "c") {
797
+ exitHandler();
798
+ return;
799
+ }
800
+ if (suppressKeypress) {
801
+ return;
802
+ }
803
+ normalizeCommandPrefix();
804
+ if (key && (key.name === "pageup" || key.name === "pagedown")) {
805
+ const delta = Math.max(1, Math.floor(logBox.height / 2));
806
+ scrollLog(key.name === "pageup" ? -delta : delta);
807
+ return;
808
+ }
809
+ if (focusMode === "dashboard") {
810
+ if (handleDashboardKey(key)) return;
811
+ return;
812
+ }
813
+
814
+ // Command completion mode
815
+ if (completionActive) {
816
+ if (handleCompletionKey(ch, key)) return;
817
+ }
818
+
819
+ // Treat multi-char input (paste) as insertion, including newlines.
820
+ if (ch && ch.length > 1 && (!key || !key.name || key.name.length !== 1)) {
821
+ insertTextAtCursor(normalizePaste(ch));
822
+ return;
823
+ }
824
+ if (ch && (ch.includes("\n") || ch.includes("\r")) && (!key || (key.name !== "return" && key.name !== "enter"))) {
825
+ insertTextAtCursor(normalizePaste(ch));
826
+ return;
827
+ }
828
+ // Plain enter submits, shift+enter inserts newline
829
+ if (key.name === "return" || key.name === "enter") {
830
+ if (key.shift) {
831
+ // Insert newline at cursor
832
+ insertTextAtCursor("\n");
833
+ } else {
834
+ // Submit
835
+ resetPreferredCol();
836
+ this._done(null, this.value);
837
+ }
838
+ return;
839
+ }
840
+
841
+ if (key.name === "left") {
842
+ if (cursorPos > 0) cursorPos--;
843
+ resetPreferredCol();
844
+ this._updateCursor();
845
+ this.screen.render();
846
+ return;
847
+ }
848
+
849
+ if (key.name === "right") {
850
+ if (cursorPos < this.value.length) cursorPos++;
851
+ resetPreferredCol();
852
+ this._updateCursor();
853
+ this.screen.render();
854
+ return;
855
+ }
856
+
857
+ if (key.name === "home") {
858
+ cursorPos = 0;
859
+ resetPreferredCol();
860
+ this._updateCursor();
861
+ this.screen.render();
862
+ return;
863
+ }
864
+
865
+ if (key.name === "end") {
866
+ cursorPos = this.value.length;
867
+ resetPreferredCol();
868
+ this._updateCursor();
869
+ this.screen.render();
870
+ return;
871
+ }
872
+
873
+ if (key.name === "up") {
874
+ // Special case: "/" + Up → jump to last command in completion
875
+ if (completionActive && input.value === "/" && cursorPos === 1) {
876
+ completionIndex = completionCommands.length - 1;
877
+ renderCompletionPanel();
878
+ return;
879
+ }
880
+ if (historyUp()) {
881
+ hideCompletion();
882
+ return;
883
+ }
884
+ }
885
+ if (key.name === "down") {
886
+ if (historyDown()) {
887
+ hideCompletion();
888
+ return;
889
+ }
890
+ }
891
+ if (key.name === "up" || key.name === "down") {
892
+ const innerWidth = getInnerWidth();
893
+ if (innerWidth > 0) {
894
+ const { row, col } = getCursorRowCol(this.value, cursorPos, innerWidth);
895
+ if (preferredCol === null) preferredCol = col;
896
+ const totalRows = countLines(this.value, innerWidth);
897
+
898
+ // Down at last row -> enter dashboard mode
899
+ if (key.name === "down" && row >= totalRows - 1) {
900
+ enterDashboardMode();
901
+ return;
902
+ }
903
+
904
+ const targetRow = key.name === "up"
905
+ ? Math.max(0, row - 1)
906
+ : Math.min(totalRows - 1, row + 1);
907
+ cursorPos = getCursorPosForRowCol(this.value, targetRow, preferredCol, innerWidth);
908
+ }
909
+ this._updateCursor();
910
+ this.screen.render();
911
+ return;
912
+ }
913
+
914
+ if (key.name === "escape") {
915
+ this._done(null, null);
916
+ return;
917
+ }
918
+
919
+ if (key.name === "backspace") {
920
+ if (cursorPos > 0) {
921
+ this.value = this.value.slice(0, cursorPos - 1) + this.value.slice(cursorPos);
922
+ cursorPos--;
923
+ resetPreferredCol();
924
+ resizeInput();
925
+ this._updateCursor();
926
+ updateDraftFromInput();
927
+
928
+ // Update or hide completion after backspace
929
+ if (this.value.startsWith("/")) {
930
+ showCompletion(this.value);
931
+ } else {
932
+ hideCompletion();
933
+ }
934
+
935
+ this.screen.render();
936
+ }
937
+ return;
938
+ }
939
+
940
+ if (key.name === "delete") {
941
+ if (cursorPos < this.value.length) {
942
+ this.value = this.value.slice(0, cursorPos) + this.value.slice(cursorPos + 1);
943
+ resetPreferredCol();
944
+ resizeInput();
945
+ this._updateCursor();
946
+ this.screen.render();
947
+ updateDraftFromInput();
948
+ }
949
+ return;
950
+ }
951
+
952
+ // Insert character at cursor position
953
+ const insertChar = (ch && ch.length === 1)
954
+ ? ch
955
+ : (key && key.name && key.name.length === 1 ? key.name : null);
956
+ if (insertChar && !/^[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]$/.test(insertChar)) {
957
+ this.value = this.value.slice(0, cursorPos) + insertChar + this.value.slice(cursorPos);
958
+ cursorPos++;
959
+ normalizeCommandPrefix();
960
+ resetPreferredCol();
961
+ resizeInput();
962
+ this._updateCursor();
963
+ updateDraftFromInput();
964
+
965
+ // Update completion filter if typing after "/"
966
+ if (this.value.startsWith("/")) {
967
+ showCompletion(this.value);
968
+ } else if (completionActive) {
969
+ hideCompletion();
970
+ }
971
+
972
+ this.screen.render();
973
+ return;
974
+ }
975
+ };
976
+
977
+ // Override cursor update to use our cursor position
978
+ input._updateCursor = function() {
979
+ if (this.screen.focused !== this) return;
980
+
981
+ const lpos = this._getCoords();
982
+ if (!lpos) return;
983
+
984
+ const innerWidth = getInnerWidth();
985
+ if (innerWidth <= 0) return;
986
+
987
+ const { row, col } = getCursorRowCol(this.value, cursorPos, innerWidth);
988
+ const innerHeight = this.height || 1;
989
+
990
+ let scrollOffset = this.childBase || 0;
991
+ if (row < scrollOffset) {
992
+ scrollOffset = row;
993
+ } else if (row >= scrollOffset + innerHeight) {
994
+ scrollOffset = row - innerHeight + 1;
995
+ }
996
+ if (scrollOffset !== this.childBase) {
997
+ this.childBase = scrollOffset;
998
+ if (typeof this.scrollTo === "function") {
999
+ this.scrollTo(scrollOffset);
1000
+ }
1001
+ }
1002
+
1003
+ const displayRow = row - scrollOffset;
1004
+ const safeCol = Math.min(Math.max(0, col), innerWidth - 1);
1005
+ const cy = lpos.yi + displayRow;
1006
+ const cx = lpos.xi + safeCol;
1007
+
1008
+ this.screen.program.cup(cy, cx);
1009
+ this.screen.program.showCursor();
1010
+ };
1011
+
1012
+ // Reset cursor and height on clear
1013
+ const originalClearValue = input.clearValue.bind(input);
1014
+ input.clearValue = function() {
1015
+ cursorPos = 0;
1016
+ resetPreferredCol();
1017
+ currentInputHeight = MIN_INPUT_HEIGHT;
1018
+ historyIndex = inputHistory.length;
1019
+ historyDraft = "";
1020
+ hideCompletion();
1021
+ const contentHeight = 1; // MIN content height
1022
+ input.height = contentHeight;
1023
+ promptBox.height = contentHeight;
1024
+ inputTopLine.bottom = currentInputHeight - 1;
1025
+ statusLine.bottom = currentInputHeight;
1026
+ logBox.height = Math.max(1, screen.height - currentInputHeight - 1);
1027
+ return originalClearValue();
1028
+ };
1029
+
1030
+ let pending = null;
1031
+
1032
+ // Command completion state
1033
+ let completionActive = false;
1034
+ let completionCommands = [];
1035
+ let completionIndex = 0;
1036
+ let completionScrollOffset = 0;
1037
+
1038
+ const COMMAND_REGISTRY = [
1039
+ { cmd: "/doctor", desc: "Health check diagnostics" },
1040
+ { cmd: "/status", desc: "Status display" },
1041
+ {
1042
+ cmd: "/daemon",
1043
+ desc: "Daemon management",
1044
+ subcommands: [
1045
+ { cmd: "start", desc: "Start daemon" },
1046
+ { cmd: "stop", desc: "Stop daemon" },
1047
+ { cmd: "restart", desc: "Restart daemon" },
1048
+ { cmd: "status", desc: "Daemon status" },
1049
+ ]
1050
+ },
1051
+ { cmd: "/init", desc: "Initialize modules" },
1052
+ {
1053
+ cmd: "/bus",
1054
+ desc: "Event bus operations",
1055
+ subcommands: [
1056
+ { cmd: "send", desc: "Send message to agent" },
1057
+ { cmd: "rename", desc: "Rename agent nickname" },
1058
+ { cmd: "list", desc: "List all agents" },
1059
+ { cmd: "status", desc: "Bus status" },
1060
+ ]
1061
+ },
1062
+ { cmd: "/ctx", desc: "Context management" },
1063
+ { cmd: "/skills", desc: "Skills management" },
1064
+ { cmd: "/ubus", desc: "Check bus messages" },
1065
+ { cmd: "/uctx", desc: "Context status" },
1066
+ { cmd: "/uinit", desc: "Initialize/repair" },
1067
+ { cmd: "/ustatus", desc: "Unified status" },
1068
+ ];
1069
+
1070
+ // Agent selection state
1071
+ let activeAgents = [];
1072
+ let activeAgentLabelMap = new Map();
1073
+ let agentListWindowStart = 0;
1074
+ const MAX_AGENT_WINDOW = 5;
1075
+ let selectedAgentIndex = -1; // -1 = not in dashboard selection mode
1076
+ let targetAgent = null; // Selected agent for direct messaging
1077
+ let focusMode = "input"; // "input" or "dashboard"
1078
+ let dashboardView = "agents"; // "agents" or "mode"
1079
+ let selectedModeIndex = launchMode === "internal" ? 1 : 0;
1080
+
1081
+ function getAgentLabel(agentId) {
1082
+ return activeAgentLabelMap.get(agentId) || agentId;
1083
+ }
1084
+
1085
+ function clampAgentWindow() {
1086
+ if (activeAgents.length === 0) {
1087
+ agentListWindowStart = 0;
1088
+ return;
1089
+ }
1090
+ const maxItems = Math.max(1, Math.min(MAX_AGENT_WINDOW, activeAgents.length));
1091
+ if (selectedAgentIndex >= 0) {
1092
+ if (selectedAgentIndex < agentListWindowStart) {
1093
+ agentListWindowStart = selectedAgentIndex;
1094
+ } else if (selectedAgentIndex >= agentListWindowStart + maxItems) {
1095
+ agentListWindowStart = selectedAgentIndex - maxItems + 1;
1096
+ }
1097
+ }
1098
+ const maxStart = Math.max(0, activeAgents.length - maxItems);
1099
+ if (agentListWindowStart > maxStart) agentListWindowStart = maxStart;
1100
+ if (agentListWindowStart < 0) agentListWindowStart = 0;
1101
+ }
1102
+
1103
+ function send(req) {
1104
+ client.write(`${JSON.stringify(req)}\n`);
1105
+ }
1106
+
1107
+ function updatePromptBox() {
1108
+ if (targetAgent) {
1109
+ const label = getAgentLabel(targetAgent);
1110
+ promptBox.setContent(`@${label}>`);
1111
+ promptBox.width = label.length + 3; // @name>
1112
+ input.left = promptBox.width;
1113
+ input.width = `100%-${promptBox.width}`;
1114
+ } else {
1115
+ promptBox.setContent(">");
1116
+ promptBox.width = 2;
1117
+ input.left = 2;
1118
+ input.width = "100%-2";
1119
+ }
1120
+ resizeInput();
1121
+ input._updateCursor();
1122
+ }
1123
+
1124
+ function focusInput() {
1125
+ input.focus();
1126
+ input._updateCursor();
1127
+ }
1128
+
1129
+ function focusLog() {
1130
+ logBox.focus();
1131
+ screen.program.hideCursor();
1132
+ }
1133
+
1134
+ function scrollLog(offset) {
1135
+ logBox.scroll(offset);
1136
+ screen.render();
1137
+ }
1138
+
1139
+ function setLaunchMode(mode) {
1140
+ const next = normalizeLaunchMode(mode);
1141
+ if (next === launchMode) return;
1142
+ launchMode = next;
1143
+ selectedModeIndex = launchMode === "internal" ? 1 : 0;
1144
+ saveConfig(projectRoot, { launchMode });
1145
+ logMessage("status", `{magenta-fg}⚙{/magenta-fg} Launch mode: ${launchMode}`);
1146
+ renderDashboard();
1147
+ screen.render();
1148
+ }
1149
+
1150
+ function clearLog() {
1151
+ logBox.setContent("");
1152
+ if (typeof logBox.scrollTo === "function") {
1153
+ logBox.scrollTo(0);
1154
+ }
1155
+ screen.render();
1156
+ }
1157
+
1158
+ function renderDashboard() {
1159
+ let content = " ";
1160
+ if (focusMode === "dashboard") {
1161
+ if (dashboardView === "mode") {
1162
+ const modes = ["terminal", "internal"];
1163
+ const modeParts = modes.map((mode, i) => {
1164
+ if (i === selectedModeIndex) {
1165
+ return `{inverse}${mode}{/inverse}`;
1166
+ }
1167
+ return `{cyan-fg}${mode}{/cyan-fg}`;
1168
+ });
1169
+ content += `{gray-fg}Mode:{/gray-fg} ${modeParts.join(" ")}`;
1170
+ content += " {gray-fg}│ ←/→ select, Enter confirm, ↑ back{/gray-fg}";
1171
+ } else {
1172
+ if (activeAgents.length > 0) {
1173
+ clampAgentWindow();
1174
+ const maxItems = Math.max(1, Math.min(MAX_AGENT_WINDOW, activeAgents.length));
1175
+ const start = agentListWindowStart;
1176
+ const end = start + maxItems;
1177
+ const visibleAgents = activeAgents.slice(start, end);
1178
+ const agentParts = visibleAgents.map((agent, i) => {
1179
+ const absoluteIndex = start + i;
1180
+ const label = getAgentLabel(agent);
1181
+ if (absoluteIndex === selectedAgentIndex) {
1182
+ return `{inverse}${label}{/inverse}`;
1183
+ }
1184
+ return `{cyan-fg}${label}{/cyan-fg}`;
1185
+ });
1186
+ const leftMore = start > 0 ? "{gray-fg}«{/gray-fg} " : "";
1187
+ const rightMore = end < activeAgents.length ? " {gray-fg}»{/gray-fg}" : "";
1188
+ content += `{gray-fg}Agents:{/gray-fg} ${agentParts.join(" ")}`;
1189
+ content = `${content.replace("{gray-fg}Agents:{/gray-fg} ", `{gray-fg}Agents:{/gray-fg} ${leftMore}`)}${rightMore}`;
1190
+ content += " {gray-fg}│ ←/→ select, Enter confirm, ↓ mode, ↑ back{/gray-fg}";
1191
+ } else {
1192
+ content += "{gray-fg}Agents:{/gray-fg} {cyan-fg}none{/cyan-fg}";
1193
+ content += " {gray-fg}│ ↓ mode, ↑ back{/gray-fg}";
1194
+ }
1195
+ }
1196
+ } else {
1197
+ // Normal dashboard display (input mode)
1198
+ const agents = activeAgents.length > 0
1199
+ ? activeAgents.slice(0, 3).map((id) => getAgentLabel(id)).join(", ") + (activeAgents.length > 3 ? ` +${activeAgents.length - 3}` : "")
1200
+ : "none";
1201
+ content += `{gray-fg}Agents:{/gray-fg} {cyan-fg}${agents}{/cyan-fg}`;
1202
+ content += ` {gray-fg}Mode:{/gray-fg} {cyan-fg}${launchMode}{/cyan-fg}`;
1203
+ }
1204
+ dashboard.setContent(content);
1205
+ }
1206
+
1207
+ function updateDashboard(status) {
1208
+ activeAgents = status.active || [];
1209
+ const metaList = Array.isArray(status.active_meta) ? status.active_meta : [];
1210
+ activeAgentLabelMap = new Map();
1211
+ let fallbackMap = null;
1212
+ if (metaList.length === 0 && activeAgents.length > 0) {
1213
+ try {
1214
+ const busPath = path.join(projectRoot, ".ufoo", "bus", "bus.json");
1215
+ const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
1216
+ fallbackMap = new Map();
1217
+ for (const [id, meta] of Object.entries(bus.subscribers || {})) {
1218
+ if (meta && meta.nickname) fallbackMap.set(id, meta.nickname);
1219
+ }
1220
+ } catch {
1221
+ fallbackMap = null;
1222
+ }
1223
+ }
1224
+ for (const id of activeAgents) {
1225
+ const meta = metaList.find((item) => item && item.id === id);
1226
+ const label = meta && meta.nickname
1227
+ ? meta.nickname
1228
+ : (fallbackMap && fallbackMap.get(id)) || id;
1229
+ activeAgentLabelMap.set(id, label);
1230
+ }
1231
+ clampAgentWindow();
1232
+ if (focusMode === "dashboard") {
1233
+ if (dashboardView === "agents") {
1234
+ if (activeAgents.length === 0) {
1235
+ selectedAgentIndex = -1;
1236
+ } else if (selectedAgentIndex < 0 || selectedAgentIndex >= activeAgents.length) {
1237
+ selectedAgentIndex = 0;
1238
+ }
1239
+ clampAgentWindow();
1240
+ }
1241
+ }
1242
+ renderDashboard();
1243
+ screen.render();
1244
+ }
1245
+
1246
+ function enterDashboardMode() {
1247
+ focusMode = "dashboard";
1248
+ dashboardView = "agents";
1249
+ selectedAgentIndex = activeAgents.length > 0 ? 0 : -1;
1250
+ agentListWindowStart = 0;
1251
+ clampAgentWindow();
1252
+ selectedModeIndex = launchMode === "internal" ? 1 : 0;
1253
+ screen.grabKeys = true;
1254
+ renderDashboard();
1255
+ screen.program.hideCursor();
1256
+ screen.render();
1257
+ }
1258
+
1259
+ function handleDashboardKey(key) {
1260
+ if (!key || focusMode !== "dashboard") return false;
1261
+ if (dashboardView === "mode") {
1262
+ if (key.name === "left") {
1263
+ selectedModeIndex = selectedModeIndex <= 0 ? 1 : 0;
1264
+ renderDashboard();
1265
+ screen.render();
1266
+ return true;
1267
+ }
1268
+ if (key.name === "right") {
1269
+ selectedModeIndex = selectedModeIndex >= 1 ? 0 : 1;
1270
+ renderDashboard();
1271
+ screen.render();
1272
+ return true;
1273
+ }
1274
+ if (key.name === "up") {
1275
+ dashboardView = "agents";
1276
+ renderDashboard();
1277
+ screen.render();
1278
+ return true;
1279
+ }
1280
+ if (key.name === "enter" || key.name === "return") {
1281
+ const modes = ["terminal", "internal"];
1282
+ setLaunchMode(modes[selectedModeIndex]);
1283
+ exitDashboardMode(false);
1284
+ return true;
1285
+ }
1286
+ if (key.name === "escape") {
1287
+ exitDashboardMode(false);
1288
+ return true;
1289
+ }
1290
+ return true;
1291
+ }
1292
+
1293
+ if (key.name === "left") {
1294
+ if (activeAgents.length > 0 && selectedAgentIndex > 0) {
1295
+ selectedAgentIndex--;
1296
+ clampAgentWindow();
1297
+ renderDashboard();
1298
+ screen.render();
1299
+ }
1300
+ return true;
1301
+ }
1302
+ if (key.name === "right") {
1303
+ if (activeAgents.length > 0 && selectedAgentIndex < activeAgents.length - 1) {
1304
+ selectedAgentIndex++;
1305
+ clampAgentWindow();
1306
+ renderDashboard();
1307
+ screen.render();
1308
+ }
1309
+ return true;
1310
+ }
1311
+ if (key.name === "down") {
1312
+ dashboardView = "mode";
1313
+ selectedModeIndex = launchMode === "internal" ? 1 : 0;
1314
+ renderDashboard();
1315
+ screen.render();
1316
+ return true;
1317
+ }
1318
+ if (key.name === "up" || key.name === "escape") {
1319
+ exitDashboardMode(false);
1320
+ return true;
1321
+ }
1322
+ if (key.name === "enter" || key.name === "return") {
1323
+ exitDashboardMode(true);
1324
+ return true;
1325
+ }
1326
+ return false;
1327
+ }
1328
+
1329
+ function exitDashboardMode(selectAgent = false) {
1330
+ if (selectAgent && selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
1331
+ targetAgent = activeAgents[selectedAgentIndex];
1332
+ updatePromptBox();
1333
+ }
1334
+ focusMode = "input";
1335
+ dashboardView = "agents";
1336
+ selectedAgentIndex = -1;
1337
+ screen.grabKeys = false;
1338
+ renderDashboard();
1339
+ focusInput();
1340
+ screen.render();
1341
+ }
1342
+
1343
+ function clearTargetAgent() {
1344
+ targetAgent = null;
1345
+ updatePromptBox();
1346
+ screen.render();
1347
+ }
1348
+
1349
+ function requestStatus() {
1350
+ send({ type: "status" });
1351
+ }
1352
+
1353
+ let buffer = "";
1354
+ client.on("data", (data) => {
1355
+ buffer += data.toString("utf8");
1356
+ const lines = buffer.split(/\r?\n/);
1357
+ buffer = lines.pop() || "";
1358
+ for (const line of lines.filter((l) => l.trim())) {
1359
+ try {
1360
+ const msg = JSON.parse(line);
1361
+ if (msg.type === "status") {
1362
+ const data = msg.data || {};
1363
+ if (typeof data.phase === "string") {
1364
+ const text = data.text || "";
1365
+ const item = { key: data.key, text };
1366
+ if (data.phase === "start") {
1367
+ enqueueBusStatus(item);
1368
+ } else if (data.phase === "done" || data.phase === "error") {
1369
+ resolveBusStatus(item);
1370
+ if (text) {
1371
+ const prefix = data.phase === "error"
1372
+ ? "{red-fg}✗{/red-fg}"
1373
+ : "{green-fg}✓{/green-fg}";
1374
+ logMessage("status", `${prefix} ${text}`, data);
1375
+ }
1376
+ } else {
1377
+ enqueueBusStatus(item);
1378
+ }
1379
+ screen.render();
1380
+ } else {
1381
+ updateDashboard(data);
1382
+ }
1383
+ } else if (msg.type === "response") {
1384
+ const payload = msg.data || {};
1385
+ if (payload.reply) {
1386
+ resolveStatusLine(`{green-fg}←{/green-fg} ${payload.reply}`);
1387
+ logMessage("reply", `{green-fg}←{/green-fg} ${payload.reply}`);
1388
+ }
1389
+ if (payload.dispatch && payload.dispatch.length > 0) {
1390
+ logMessage("dispatch", `{blue-fg}→{/blue-fg} Dispatched to: ${payload.dispatch.map(d => d.target || d).join(", ")}`);
1391
+ }
1392
+ if (payload.disambiguate && Array.isArray(payload.disambiguate.candidates) && payload.disambiguate.candidates.length > 0) {
1393
+ pending = { disambiguate: payload.disambiguate, original: pending?.original };
1394
+ resolveStatusLine(`{yellow-fg}?{/yellow-fg} ${payload.disambiguate.prompt || "Choose target:"}`);
1395
+ logMessage("disambiguate", `{yellow-fg}?{/yellow-fg} ${payload.disambiguate.prompt || "Choose target:"}`);
1396
+ payload.disambiguate.candidates.forEach((c, i) => {
1397
+ logMessage("disambiguate", ` {cyan-fg}${i + 1}){/cyan-fg} ${c.agent_id} {gray-fg}— ${c.reason || ""}{/gray-fg}`);
1398
+ });
1399
+ } else {
1400
+ pending = null;
1401
+ }
1402
+ if (!payload.reply && !payload.disambiguate) {
1403
+ resolveStatusLine("{gray-fg}✓{/gray-fg} Done");
1404
+ }
1405
+ if (msg.opsResults && msg.opsResults.length > 0) {
1406
+ logMessage("ops", `{magenta-fg}⚡{/magenta-fg} ${JSON.stringify(msg.opsResults)}`);
1407
+ }
1408
+ screen.render();
1409
+ } else if (msg.type === "bus") {
1410
+ const data = msg.data || {};
1411
+ const prefix = data.event === "broadcast" ? "{magenta-fg}⇢{/magenta-fg}" : "{blue-fg}↔{/blue-fg}";
1412
+ let publisher = data.publisher && data.publisher !== "unknown"
1413
+ ? data.publisher
1414
+ : (data.event === "broadcast" ? "broadcast" : "bus");
1415
+
1416
+ // Try to parse message as JSON (from internal agents)
1417
+ let displayMessage = data.message || "";
1418
+ try {
1419
+ const parsed = JSON.parse(data.message);
1420
+ if (parsed && typeof parsed === "object" && parsed.reply) {
1421
+ displayMessage = parsed.reply;
1422
+ }
1423
+ } catch {
1424
+ // Not JSON, use as-is
1425
+ }
1426
+
1427
+ // Extract nickname if publisher is in subscriber:id format
1428
+ let displayName = publisher;
1429
+ if (publisher.includes(":")) {
1430
+ // Try to get nickname from activeAgentLabelMap or bus.json
1431
+ if (activeAgentLabelMap && activeAgentLabelMap.has(publisher)) {
1432
+ displayName = activeAgentLabelMap.get(publisher);
1433
+ } else {
1434
+ // Fallback: read directly from bus.json
1435
+ try {
1436
+ const busPath = path.join(projectRoot, ".ufoo", "bus", "bus.json");
1437
+ const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
1438
+ const meta = bus.subscribers && bus.subscribers[publisher];
1439
+ if (meta && meta.nickname) {
1440
+ displayName = meta.nickname;
1441
+ }
1442
+ } catch {
1443
+ // Keep original publisher ID
1444
+ }
1445
+ }
1446
+ }
1447
+
1448
+ const line = `${prefix} {gray-fg}${displayName}{/gray-fg}: ${displayMessage}`;
1449
+ logMessage("bus", line, data);
1450
+ if (data.event === "agent_renamed") {
1451
+ requestStatus();
1452
+ }
1453
+ screen.render();
1454
+ } else if (msg.type === "error") {
1455
+ resolveStatusLine(`{red-fg}✗{/red-fg} Error: ${msg.error}`);
1456
+ logMessage("error", `{red-fg}✗{/red-fg} Error: ${msg.error}`);
1457
+ screen.render();
1458
+ }
1459
+ } catch {
1460
+ // ignore
1461
+ }
1462
+ }
1463
+ });
1464
+
1465
+ input.on("submit", (value) => {
1466
+ const text = value.trim();
1467
+ input.clearValue();
1468
+ screen.render();
1469
+ if (!text) {
1470
+ input.focus();
1471
+ return;
1472
+ }
1473
+ inputHistory.push(text);
1474
+ appendInputHistory(text);
1475
+ historyIndex = inputHistory.length;
1476
+ historyDraft = "";
1477
+
1478
+ // If target agent is selected, send directly via bus
1479
+ if (targetAgent) {
1480
+ const label = getAgentLabel(targetAgent);
1481
+ logMessage("user", `{cyan-fg}→{/cyan-fg} {magenta-fg}@${label}{/magenta-fg} ${text}`);
1482
+ // Use bus send command
1483
+ const { spawnSync } = require("child_process");
1484
+ spawnSync("ufoo", ["bus", "send", targetAgent, text], { cwd: projectRoot });
1485
+ clearTargetAgent();
1486
+ input.focus();
1487
+ return;
1488
+ }
1489
+
1490
+ if (pending && pending.disambiguate) {
1491
+ const idx = parseInt(text, 10);
1492
+ const choice = pending.disambiguate.candidates[idx - 1];
1493
+ if (choice) {
1494
+ queueStatusLine(`ufoo-agent processing (assigning ${choice.agent_id})`);
1495
+ send({
1496
+ type: "prompt",
1497
+ text: `Use agent ${choice.agent_id} to handle: ${pending.original || "the request"}`,
1498
+ });
1499
+ pending = null;
1500
+ } else {
1501
+ logMessage("error", "Invalid selection.");
1502
+ }
1503
+ } else {
1504
+ pending = { original: text };
1505
+ queueStatusLine("ufoo-agent processing");
1506
+ send({ type: "prompt", text });
1507
+ logMessage("user", `{cyan-fg}→{/cyan-fg} ${text}`);
1508
+ }
1509
+ input.focus();
1510
+ });
1511
+
1512
+ screen.key(["C-c"], exitHandler);
1513
+
1514
+ // Dashboard navigation - use screen.on to capture even when input is focused
1515
+ screen.on("keypress", (ch, key) => {
1516
+ handleDashboardKey(key);
1517
+ });
1518
+
1519
+ screen.key(["tab"], () => {
1520
+ if (focusMode === "dashboard") {
1521
+ exitDashboardMode(false);
1522
+ } else {
1523
+ enterDashboardMode();
1524
+ }
1525
+ });
1526
+
1527
+ screen.key(["C-k", "M-k"], () => {
1528
+ clearLog();
1529
+ });
1530
+
1531
+ screen.key(["i", "enter"], () => {
1532
+ if (focusMode === "dashboard") return;
1533
+ if (screen.focused === input) return;
1534
+ focusInput();
1535
+ });
1536
+
1537
+ // Escape in input mode only clears @target, never exits
1538
+ input.key(["escape"], () => {
1539
+ if (targetAgent) {
1540
+ clearTargetAgent();
1541
+ }
1542
+ });
1543
+
1544
+ focusInput();
1545
+ if (screen.program && typeof screen.program.decset === "function") {
1546
+ screen.program.decset(2004);
1547
+ }
1548
+ if (screen.program) {
1549
+ screen.program.on("data", (data) => {
1550
+ if (screen.focused !== input || focusMode !== "input") return;
1551
+ const chunk = data.toString("utf8");
1552
+ if (!pasteActive && !chunk.includes(PASTE_START) && !pasteRemainder.includes(PASTE_START)) {
1553
+ const keep = PASTE_START.length - 1;
1554
+ pasteRemainder = (pasteRemainder + chunk).slice(-keep);
1555
+ return;
1556
+ }
1557
+ let buffer = pasteRemainder + chunk;
1558
+ pasteRemainder = "";
1559
+ while (buffer.length > 0) {
1560
+ if (!pasteActive) {
1561
+ const start = buffer.indexOf(PASTE_START);
1562
+ if (start === -1) {
1563
+ const keep = PASTE_START.length - 1;
1564
+ pasteRemainder = buffer.slice(-keep);
1565
+ return;
1566
+ }
1567
+ buffer = buffer.slice(start + PASTE_START.length);
1568
+ pasteActive = true;
1569
+ pasteBuffer = "";
1570
+ scheduleSuppressReset();
1571
+ continue;
1572
+ }
1573
+ const end = buffer.indexOf(PASTE_END);
1574
+ if (end === -1) {
1575
+ pasteBuffer += buffer;
1576
+ scheduleSuppressReset();
1577
+ return;
1578
+ }
1579
+ pasteBuffer += buffer.slice(0, end);
1580
+ buffer = buffer.slice(end + PASTE_END.length);
1581
+ pasteActive = false;
1582
+ scheduleSuppressReset();
1583
+ const normalized = normalizePaste(pasteBuffer);
1584
+ pasteBuffer = "";
1585
+ if (normalized) insertTextAtCursor(normalized);
1586
+ }
1587
+ });
1588
+ }
1589
+ loadHistory();
1590
+ loadInputHistory();
1591
+ resizeInput();
1592
+ requestStatus();
1593
+ setInterval(requestStatus, 2000);
1594
+ screen.on("resize", () => {
1595
+ resizeInput();
1596
+ if (completionActive) hideCompletion();
1597
+ input._updateCursor();
1598
+ screen.render();
1599
+ });
1600
+ screen.render();
1601
+ }
1602
+
1603
+ module.exports = { runChat };