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.
- package/LICENSE +35 -0
- package/README.md +163 -0
- package/README.zh-CN.md +163 -0
- package/bin/uclaude +65 -0
- package/bin/ucodex +65 -0
- package/bin/ufoo +93 -0
- package/bin/ufoo.js +35 -0
- package/modules/AGENTS.template.md +87 -0
- package/modules/bus/README.md +132 -0
- package/modules/bus/SKILLS/ubus/SKILL.md +209 -0
- package/modules/bus/scripts/bus-alert.sh +185 -0
- package/modules/bus/scripts/bus-listen.sh +117 -0
- package/modules/context/ASSUMPTIONS.md +7 -0
- package/modules/context/CONSTRAINTS.md +7 -0
- package/modules/context/CONTEXT-STRUCTURE.md +49 -0
- package/modules/context/DECISION-PROTOCOL.md +62 -0
- package/modules/context/HANDOFF.md +33 -0
- package/modules/context/README.md +82 -0
- package/modules/context/RULES.md +15 -0
- package/modules/context/SKILLS/README.md +14 -0
- package/modules/context/SKILLS/uctx/SKILL.md +91 -0
- package/modules/context/SYSTEM.md +18 -0
- package/modules/context/TEMPLATES/assumptions.md +4 -0
- package/modules/context/TEMPLATES/constraints.md +4 -0
- package/modules/context/TEMPLATES/decision.md +16 -0
- package/modules/context/TEMPLATES/project-context-readme.md +6 -0
- package/modules/context/TEMPLATES/system.md +3 -0
- package/modules/context/TEMPLATES/terminology.md +4 -0
- package/modules/context/TERMINOLOGY.md +10 -0
- package/modules/resources/ICONS/README.md +12 -0
- package/modules/resources/ICONS/libraries/README.md +17 -0
- package/modules/resources/ICONS/libraries/heroicons/LICENSE +22 -0
- package/modules/resources/ICONS/libraries/heroicons/README.md +15 -0
- package/modules/resources/ICONS/libraries/heroicons/arrow-right.svg +4 -0
- package/modules/resources/ICONS/libraries/heroicons/check.svg +4 -0
- package/modules/resources/ICONS/libraries/heroicons/chevron-down.svg +4 -0
- package/modules/resources/ICONS/libraries/heroicons/cog-6-tooth.svg +5 -0
- package/modules/resources/ICONS/libraries/heroicons/magnifying-glass.svg +4 -0
- package/modules/resources/ICONS/libraries/heroicons/x-mark.svg +4 -0
- package/modules/resources/ICONS/libraries/lucide/LICENSE +40 -0
- package/modules/resources/ICONS/libraries/lucide/README.md +15 -0
- package/modules/resources/ICONS/libraries/lucide/arrow-right.svg +15 -0
- package/modules/resources/ICONS/libraries/lucide/check.svg +14 -0
- package/modules/resources/ICONS/libraries/lucide/chevron-down.svg +14 -0
- package/modules/resources/ICONS/libraries/lucide/search.svg +15 -0
- package/modules/resources/ICONS/libraries/lucide/settings.svg +15 -0
- package/modules/resources/ICONS/libraries/lucide/x.svg +15 -0
- package/modules/resources/ICONS/rules.md +7 -0
- package/modules/resources/README.md +9 -0
- package/modules/resources/UI/ANTI-PATTERNS.md +6 -0
- package/modules/resources/UI/TONE.md +6 -0
- package/package.json +40 -0
- package/scripts/banner.sh +89 -0
- package/scripts/bus-alert.sh +6 -0
- package/scripts/bus-autotrigger.sh +6 -0
- package/scripts/bus-daemon.sh +231 -0
- package/scripts/bus-inject.sh +144 -0
- package/scripts/bus-listen.sh +6 -0
- package/scripts/bus.sh +984 -0
- package/scripts/context-decisions.sh +167 -0
- package/scripts/context-doctor.sh +72 -0
- package/scripts/context-lint.sh +110 -0
- package/scripts/doctor.sh +22 -0
- package/scripts/init.sh +247 -0
- package/scripts/skills.sh +113 -0
- package/scripts/status.sh +125 -0
- package/src/agent/cliRunner.js +190 -0
- package/src/agent/internalRunner.js +212 -0
- package/src/agent/normalizeOutput.js +41 -0
- package/src/agent/ufooAgent.js +222 -0
- package/src/chat/index.js +1603 -0
- package/src/cli.js +349 -0
- package/src/config.js +37 -0
- package/src/daemon/index.js +501 -0
- package/src/daemon/ops.js +120 -0
- package/src/daemon/run.js +41 -0
- 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 };
|