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