u-foo 3.0.9 → 3.0.11
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 +25 -9
- package/README.zh-CN.md +22 -9
- package/dist/tui/darwin-arm64/ufoo-tui +0 -0
- package/dist/tui/darwin-x64/ufoo-tui +0 -0
- package/dist/tui/linux-arm64/ufoo-tui +0 -0
- package/dist/tui/linux-x64/ufoo-tui +0 -0
- package/package.json +12 -4
- package/scripts/pack-tui.js +112 -0
- package/scripts/postinstall.js +11 -0
- package/src/agents/activity/activityReconcile.js +106 -0
- package/src/agents/activity/activityStatePublisher.js +31 -2
- package/src/agents/activity/index.js +1 -0
- package/src/agents/launch/launcher.js +19 -0
- package/src/agents/launch/ptyRunner.js +20 -1
- package/src/app/chat/ChatController.js +433 -0
- package/src/app/chat/agentDirectory.js +63 -0
- package/src/app/chat/agentEnter.js +70 -0
- package/src/app/chat/agentIdentity.js +50 -0
- package/src/app/chat/bootstrap.js +66 -0
- package/src/app/chat/commandExecutor.js +108 -0
- package/src/app/chat/commands.js +38 -1
- package/src/app/chat/dashboardView.js +6 -2
- package/src/app/chat/historyStore.js +181 -0
- package/src/app/chat/index.js +14 -2
- package/src/app/chat/inputSubmitHandler.js +21 -7
- package/src/app/chat/ipcBuilders.js +52 -0
- package/src/app/chat/multiWindow/paneManager.js +10 -1
- package/src/app/chat/multiWindow/renderer.js +1 -1
- package/src/app/chat/multiWindow/vtFrame.js +93 -0
- package/src/app/chat/streamState.js +182 -0
- package/src/app/cli/features/doctor.js +22 -0
- package/src/code/UcodeController.js +156 -0
- package/src/code/context/planGraphService.js +4 -0
- package/src/code/repl.js +4 -3
- package/src/code/runtime/taskLoop.js +46 -50
- package/src/code/tui.js +13 -2
- package/src/code/ucodeSlashDispatch.js +241 -0
- package/src/coordination/bus/activate.js +3 -0
- package/src/runtime/contracts/schemas/ufoo-ui-v1/envelope.json +28 -0
- package/src/runtime/contracts/uiProtocol.js +190 -0
- package/src/ui/{ink/chatLogModel.js → chatLogModel.js} +2 -2
- package/src/ui/dashboardBridge.js +81 -0
- package/src/ui/format/index.js +2 -2
- package/src/ui/index.js +8 -4
- package/src/ui/multiPaneBusMirror.js +137 -0
- package/src/ui/multiWindowHandoff.js +232 -0
- package/src/ui/ptyHandoff.js +23 -0
- package/src/ui/rustChatHost.js +1520 -0
- package/src/ui/rustMultiSession.js +497 -0
- package/src/ui/rustUcodeHost.js +999 -0
- package/src/ui/scrollbackReplay.js +82 -0
- package/src/ui/settingsBridge.js +49 -0
- package/src/ui/toolMergeBridge.js +66 -0
- package/src/ui/tuiLauncher.js +105 -0
- package/src/ui/ucodeStatusLine.js +74 -0
- package/src/ui/uiHostServer.js +339 -0
- package/src/ui/MIGRATION.md +0 -334
- package/src/ui/ink/ChatApp.js +0 -4152
- package/src/ui/ink/DashboardBar.js +0 -691
- package/src/ui/ink/InkDemo.js +0 -96
- package/src/ui/ink/MultilineInput.js +0 -662
- package/src/ui/ink/UcodeApp.js +0 -1675
- package/src/ui/ink/agentMirror.js +0 -730
- package/src/ui/ink/chatReducer.js +0 -473
- package/src/ui/runInk.js +0 -66
package/src/ui/ink/ChatApp.js
DELETED
|
@@ -1,4152 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Ink-based chat TUI rendered via React + ink.
|
|
5
|
-
*
|
|
6
|
-
* Activation: this is the only chat TUI.
|
|
7
|
-
*
|
|
8
|
-
* Coverage today: layout shell + dashboard bar (5 modes: projects, agents,
|
|
9
|
-
* mode, provider, cron) + multiline editor + status line +
|
|
10
|
-
* Tab/Esc focus + agent selection + Up/Down history, daemon routing,
|
|
11
|
-
* command execution, completion and internal-agent views.
|
|
12
|
-
*
|
|
13
|
-
* Chat state is kept in chatReducer.js so the entire transition table can
|
|
14
|
-
* be exercised by jest without mounting ink.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
const path = require("path");
|
|
18
|
-
const fs = require("fs");
|
|
19
|
-
const crypto = require("crypto");
|
|
20
|
-
|
|
21
|
-
const { runInk } = require("../runInk");
|
|
22
|
-
const fmt = require("../format");
|
|
23
|
-
const { createMultilineInput } = require("./MultilineInput");
|
|
24
|
-
const { createDashboardBar } = require("./DashboardBar");
|
|
25
|
-
const { reducer, createInitialState, activeStreamText } = require("./chatReducer");
|
|
26
|
-
const {
|
|
27
|
-
stripBlessedTags,
|
|
28
|
-
compactDividerLabel,
|
|
29
|
-
classifyChatLogLine,
|
|
30
|
-
buildChatLogLineModel,
|
|
31
|
-
buildChatLogGroups,
|
|
32
|
-
chatLogEntryText,
|
|
33
|
-
} = require("./chatLogModel");
|
|
34
|
-
const { restartDaemonLifecycle } = require("../../runtime/daemon/restart");
|
|
35
|
-
|
|
36
|
-
function bootstrapEnvironment(projectRoot, options = {}) {
|
|
37
|
-
// Ensure ufoo dirs exist and that we have a stable subscriber ID.
|
|
38
|
-
// We deliberately keep the
|
|
39
|
-
// non-UI side-effects in their own helper so unit tests can assert on
|
|
40
|
-
// them without importing ink.
|
|
41
|
-
const { canonicalProjectRoot } = require("../../runtime/projects");
|
|
42
|
-
const { getUfooPaths } = require("../../coordination/state/paths");
|
|
43
|
-
const UfooInit = require("../../app/cli/features/init");
|
|
44
|
-
const { isRunning } = require("../../runtime/daemon");
|
|
45
|
-
const { startDaemon } = require("../../app/chat/transport");
|
|
46
|
-
|
|
47
|
-
const globalMode = options && options.globalMode === true;
|
|
48
|
-
let activeProjectRoot = projectRoot;
|
|
49
|
-
try {
|
|
50
|
-
activeProjectRoot = canonicalProjectRoot(projectRoot);
|
|
51
|
-
} catch {
|
|
52
|
-
activeProjectRoot = path.resolve(projectRoot || process.cwd());
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const runtimePaths = getUfooPaths(projectRoot);
|
|
56
|
-
const contextIndexFile = path.join(runtimePaths.ufooDir, "context", "decisions.jsonl");
|
|
57
|
-
const needsBootstrap = globalMode && (
|
|
58
|
-
!fs.existsSync(runtimePaths.ufooDir)
|
|
59
|
-
|| !fs.existsSync(runtimePaths.busDir)
|
|
60
|
-
|| !fs.existsSync(runtimePaths.agentDir)
|
|
61
|
-
|| !fs.existsSync(contextIndexFile)
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
return {
|
|
65
|
-
activeProjectRoot,
|
|
66
|
-
globalMode,
|
|
67
|
-
runtimePaths,
|
|
68
|
-
needsBootstrap,
|
|
69
|
-
UfooInit,
|
|
70
|
-
isRunning,
|
|
71
|
-
startDaemon,
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function ensureSubscriberId(projectRoot) {
|
|
76
|
-
if (process.env.UFOO_SUBSCRIBER_ID) return;
|
|
77
|
-
const { getUfooPaths } = require("../../coordination/state/paths");
|
|
78
|
-
const sessionFile = path.join(getUfooPaths(projectRoot).ufooDir, "chat", "session-id.txt");
|
|
79
|
-
const sessionDir = path.dirname(sessionFile);
|
|
80
|
-
fs.mkdirSync(sessionDir, { recursive: true });
|
|
81
|
-
let sessionId;
|
|
82
|
-
if (fs.existsSync(sessionFile)) {
|
|
83
|
-
sessionId = fs.readFileSync(sessionFile, "utf8").trim();
|
|
84
|
-
} else {
|
|
85
|
-
sessionId = crypto.randomBytes(4).toString("hex");
|
|
86
|
-
fs.writeFileSync(sessionFile, sessionId, "utf8");
|
|
87
|
-
}
|
|
88
|
-
process.env.UFOO_SUBSCRIBER_ID = `claude-code:${sessionId}`;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function inputHistoryFilePath(projectRoot, options = {}) {
|
|
92
|
-
const { getUfooPaths } = require("../../coordination/state/paths");
|
|
93
|
-
const { globalMode } = options || {};
|
|
94
|
-
if (globalMode) {
|
|
95
|
-
const os = require("os");
|
|
96
|
-
const globalChatRoot = path.join(os.homedir(), ".ufoo", "chat");
|
|
97
|
-
const globalDir = path.join(globalChatRoot, "global-input-history");
|
|
98
|
-
const projectId = projectRootToId(projectRoot);
|
|
99
|
-
return path.join(globalDir, `${projectId}.jsonl`);
|
|
100
|
-
}
|
|
101
|
-
return path.join(getUfooPaths(projectRoot || process.cwd()).ufooDir, "chat", "input-history.jsonl");
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function chatHistoryFilePath(projectRoot, options = {}) {
|
|
105
|
-
const { getUfooPaths } = require("../../coordination/state/paths");
|
|
106
|
-
const { globalMode } = options || {};
|
|
107
|
-
if (globalMode) {
|
|
108
|
-
const os = require("os");
|
|
109
|
-
const globalChatRoot = path.join(os.homedir(), ".ufoo", "chat");
|
|
110
|
-
const globalDir = path.join(globalChatRoot, "global-history");
|
|
111
|
-
const projectId = projectRootToId(projectRoot);
|
|
112
|
-
return path.join(globalDir, `${projectId}.jsonl`);
|
|
113
|
-
}
|
|
114
|
-
return path.join(getUfooPaths(projectRoot || process.cwd()).ufooDir, "chat", "history.jsonl");
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function projectRootToId(projectRoot) {
|
|
118
|
-
try {
|
|
119
|
-
const { buildProjectId } = require("../../runtime/projects");
|
|
120
|
-
return buildProjectId(projectRoot || process.cwd());
|
|
121
|
-
} catch {
|
|
122
|
-
return crypto.createHash("sha256").update(String(projectRoot || "")).digest("hex").slice(0, 16);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function resolveInjectSockPathForAgent(projectRoot, agentId) {
|
|
127
|
-
const { getUfooPaths } = require("../../coordination/state/paths");
|
|
128
|
-
const { subscriberToSafeName } = require("../../coordination/bus/utils");
|
|
129
|
-
const safeName = subscriberToSafeName(agentId);
|
|
130
|
-
return path.join(getUfooPaths(projectRoot || process.cwd()).busQueuesDir, safeName, "inject.sock");
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function createInkMultiWindowToggle({
|
|
134
|
-
getController = () => null,
|
|
135
|
-
setActive = () => {},
|
|
136
|
-
logMessage = () => {},
|
|
137
|
-
} = {}) {
|
|
138
|
-
return () => {
|
|
139
|
-
const controller = typeof getController === "function" ? getController() : null;
|
|
140
|
-
if (!controller || typeof controller.enter !== "function" || typeof controller.exit !== "function") {
|
|
141
|
-
logMessage("error", "✗ Multi-window mode is not available");
|
|
142
|
-
return false;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (typeof controller.isActive === "function" && controller.isActive()) {
|
|
146
|
-
controller.exit();
|
|
147
|
-
setActive(false);
|
|
148
|
-
return true;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
setActive(true);
|
|
152
|
-
if (!controller.enter()) {
|
|
153
|
-
setActive(false);
|
|
154
|
-
logMessage("info", "No active agents for multi-window mode");
|
|
155
|
-
return false;
|
|
156
|
-
}
|
|
157
|
-
return true;
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function loadChatHistory(projectRoot, cap = 200, options = {}) {
|
|
162
|
-
const file = chatHistoryFilePath(projectRoot, options);
|
|
163
|
-
try {
|
|
164
|
-
if (!fs.existsSync(file)) return [];
|
|
165
|
-
const raw = fs.readFileSync(file, "utf8");
|
|
166
|
-
const lines = raw.split(/\r?\n/).filter(Boolean);
|
|
167
|
-
const out = [];
|
|
168
|
-
const pushLine = (line = "", sourceType = "") => {
|
|
169
|
-
const value = String(line || "");
|
|
170
|
-
if (!value.trim()) {
|
|
171
|
-
if (out.length > 0) {
|
|
172
|
-
const last = out[out.length - 1];
|
|
173
|
-
const lastText = typeof last === "object" ? last.text : last;
|
|
174
|
-
if (lastText !== "") out.push({ text: "", sourceType: sourceType || "system" });
|
|
175
|
-
}
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
out.push(sourceType ? { text: value, sourceType } : value);
|
|
179
|
-
};
|
|
180
|
-
for (const line of lines) {
|
|
181
|
-
try {
|
|
182
|
-
const entry = JSON.parse(line);
|
|
183
|
-
if (!entry) continue;
|
|
184
|
-
if (entry.type === "spacer") {
|
|
185
|
-
pushLine("", "system");
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
const text = String(entry.text || "");
|
|
189
|
-
if (!text) continue;
|
|
190
|
-
const sourceType = String(entry.type || "");
|
|
191
|
-
// Strip blessed-tag markup that the legacy log writer used; ink
|
|
192
|
-
// can't render those tags and we don't want them shown literally.
|
|
193
|
-
const stripped = text.replace(/\{[^{}]+\}/g, "");
|
|
194
|
-
for (const renderedLine of normalizeInkLogLines(stripped)) {
|
|
195
|
-
pushLine(renderedLine, sourceType);
|
|
196
|
-
}
|
|
197
|
-
} catch {
|
|
198
|
-
// ignore malformed lines
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
while (out.length > 0) {
|
|
202
|
-
const first = out[0];
|
|
203
|
-
const firstText = typeof first === "object" ? first.text : first;
|
|
204
|
-
if (firstText !== "") break;
|
|
205
|
-
out.shift();
|
|
206
|
-
}
|
|
207
|
-
while (out.length > 0) {
|
|
208
|
-
const last = out[out.length - 1];
|
|
209
|
-
const lastText = typeof last === "object" ? last.text : last;
|
|
210
|
-
if (lastText !== "") break;
|
|
211
|
-
out.pop();
|
|
212
|
-
}
|
|
213
|
-
const capped = out.slice(-cap);
|
|
214
|
-
while (capped.length > 0) {
|
|
215
|
-
const first = capped[0];
|
|
216
|
-
const firstText = typeof first === "object" ? first.text : first;
|
|
217
|
-
if (firstText !== "") break;
|
|
218
|
-
capped.shift();
|
|
219
|
-
}
|
|
220
|
-
return capped;
|
|
221
|
-
} catch {
|
|
222
|
-
return [];
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function loadInputHistory(projectRoot, cap = 200, options = {}) {
|
|
227
|
-
const file = inputHistoryFilePath(projectRoot, options);
|
|
228
|
-
try {
|
|
229
|
-
if (!fs.existsSync(file)) return [];
|
|
230
|
-
const raw = fs.readFileSync(file, "utf8");
|
|
231
|
-
const lines = raw.split(/\r?\n/).filter(Boolean);
|
|
232
|
-
const out = [];
|
|
233
|
-
for (const line of lines) {
|
|
234
|
-
try {
|
|
235
|
-
const obj = JSON.parse(line);
|
|
236
|
-
const value = String((obj && obj.value) || "").trim();
|
|
237
|
-
if (value) out.push(value);
|
|
238
|
-
} catch {
|
|
239
|
-
// ignore malformed entries
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
return out.slice(-cap);
|
|
243
|
-
} catch {
|
|
244
|
-
return [];
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function appendInputHistory(projectRoot, value, options = {}) {
|
|
249
|
-
const trimmed = String(value || "").trim();
|
|
250
|
-
if (!trimmed) return;
|
|
251
|
-
const file = inputHistoryFilePath(projectRoot, options);
|
|
252
|
-
try {
|
|
253
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
254
|
-
fs.appendFileSync(file, `${JSON.stringify({ value: trimmed, ts: Date.now() })}\n`);
|
|
255
|
-
} catch {
|
|
256
|
-
// best-effort persistence; failure is not user-visible
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function appendChatHistory(projectRoot, type, text, meta = {}, options = {}) {
|
|
261
|
-
const value = String(text || "");
|
|
262
|
-
if (!value && type !== "spacer") return;
|
|
263
|
-
const file = chatHistoryFilePath(projectRoot, options);
|
|
264
|
-
try {
|
|
265
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
266
|
-
fs.appendFileSync(file, `${JSON.stringify({
|
|
267
|
-
ts: new Date().toISOString(),
|
|
268
|
-
type,
|
|
269
|
-
text: value,
|
|
270
|
-
meta: meta && typeof meta === "object" ? meta : {},
|
|
271
|
-
})}\n`);
|
|
272
|
-
} catch {
|
|
273
|
-
// best-effort persistence; failure is not user-visible
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function chatHistoryOptionsForScope({ globalMode = false, globalScope = "controller" } = {}) {
|
|
278
|
-
return {
|
|
279
|
-
globalMode: Boolean(globalMode && globalScope !== "project"),
|
|
280
|
-
};
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function getAgentLabelFor(meta, agentId) {
|
|
284
|
-
// Prefer the project-stripped display nickname so the dashboard never shows
|
|
285
|
-
// the scoped form ("neptune-builder"); fall back to the raw nickname (which
|
|
286
|
-
// may itself be unscoped depending on write path) and finally to a short
|
|
287
|
-
// form of the subscriber id.
|
|
288
|
-
if (meta && meta.display_nickname) return meta.display_nickname;
|
|
289
|
-
if (meta && meta.nickname) return meta.nickname;
|
|
290
|
-
if (!agentId) return "";
|
|
291
|
-
const colon = agentId.indexOf(":");
|
|
292
|
-
if (colon < 0) return agentId;
|
|
293
|
-
const head = agentId.slice(0, colon);
|
|
294
|
-
const tail = agentId.slice(colon + 1).slice(0, 6);
|
|
295
|
-
return tail ? `${head}:${tail}` : head;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
function buildActiveAgentLabelMap(activeAgents = [], activeAgentMeta = new Map()) {
|
|
299
|
-
const out = new Map();
|
|
300
|
-
const metaMap = activeAgentMeta instanceof Map ? activeAgentMeta : new Map();
|
|
301
|
-
for (const id of Array.isArray(activeAgents) ? activeAgents : []) {
|
|
302
|
-
out.set(id, getAgentLabelFor(metaMap.get(id), id));
|
|
303
|
-
}
|
|
304
|
-
return out;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
function resolveActiveAgentId(label, activeAgents = [], activeAgentMeta = new Map()) {
|
|
308
|
-
const { resolveAgentId } = require("../../app/chat/agentDirectory");
|
|
309
|
-
const metaMap = activeAgentMeta instanceof Map ? activeAgentMeta : new Map();
|
|
310
|
-
return resolveAgentId({
|
|
311
|
-
label,
|
|
312
|
-
activeAgents: Array.isArray(activeAgents) ? activeAgents : [],
|
|
313
|
-
labelMap: buildActiveAgentLabelMap(activeAgents, metaMap),
|
|
314
|
-
lookupNickname: (nickname) => {
|
|
315
|
-
for (const [id, meta] of metaMap.entries()) {
|
|
316
|
-
if (!meta) continue;
|
|
317
|
-
if (meta.nickname === nickname || meta.scoped_nickname === nickname || meta.display_nickname === nickname) {
|
|
318
|
-
return id;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
return null;
|
|
322
|
-
},
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function buildDirectBusSendRequest({
|
|
327
|
-
text,
|
|
328
|
-
targetAgentId = null,
|
|
329
|
-
activeAgents = [],
|
|
330
|
-
activeAgentMeta = new Map(),
|
|
331
|
-
} = {}) {
|
|
332
|
-
const trimmed = String(text || "").trim();
|
|
333
|
-
if (!trimmed) return null;
|
|
334
|
-
if (targetAgentId) {
|
|
335
|
-
return {
|
|
336
|
-
target: targetAgentId,
|
|
337
|
-
message: trimmed,
|
|
338
|
-
source: "chat-direct",
|
|
339
|
-
};
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const { parseAtTarget } = require("../../app/chat/commands");
|
|
343
|
-
const atTarget = parseAtTarget(trimmed);
|
|
344
|
-
if (!atTarget || !atTarget.message) return null;
|
|
345
|
-
const target = resolveActiveAgentId(atTarget.target, activeAgents, activeAgentMeta) || atTarget.target;
|
|
346
|
-
return {
|
|
347
|
-
target,
|
|
348
|
-
message: atTarget.message.trim(),
|
|
349
|
-
source: "chat-direct",
|
|
350
|
-
};
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function resolveAgentEnterRequest({
|
|
354
|
-
agentId,
|
|
355
|
-
projectRoot = "",
|
|
356
|
-
activeAgentMeta = new Map(),
|
|
357
|
-
settings = {},
|
|
358
|
-
} = {}) {
|
|
359
|
-
const id = String(agentId || "").trim();
|
|
360
|
-
if (!id) return null;
|
|
361
|
-
|
|
362
|
-
const metaMap = activeAgentMeta instanceof Map ? activeAgentMeta : new Map();
|
|
363
|
-
const meta = metaMap.get(id) || {};
|
|
364
|
-
const configuredLaunchMode = settings && settings.launchMode && settings.launchMode !== "auto"
|
|
365
|
-
? settings.launchMode
|
|
366
|
-
: "";
|
|
367
|
-
const launchMode = String(meta.launch_mode || meta.launchMode || configuredLaunchMode || "").trim();
|
|
368
|
-
const { createTerminalAdapterRouter } = require("../../runtime/terminal/adapterRouter");
|
|
369
|
-
const adapter = createTerminalAdapterRouter().getAdapter({ launchMode, agentId: id, meta });
|
|
370
|
-
const caps = adapter && adapter.capabilities ? adapter.capabilities : {};
|
|
371
|
-
|
|
372
|
-
return {
|
|
373
|
-
agentId: id,
|
|
374
|
-
projectRoot: String(projectRoot || ""),
|
|
375
|
-
launchMode,
|
|
376
|
-
useBus: Boolean(caps.supportsInternalQueueLoop && !caps.supportsSocketProtocol),
|
|
377
|
-
supportsSocket: Boolean(caps.supportsSocketProtocol),
|
|
378
|
-
supportsInternalQueue: Boolean(caps.supportsInternalQueueLoop),
|
|
379
|
-
supportsActivate: Boolean(caps.supportsActivate),
|
|
380
|
-
};
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
function resolveDashboardAgentEnterAction(enterRequest = {}) {
|
|
384
|
-
if (!enterRequest || typeof enterRequest !== "object") return "none";
|
|
385
|
-
if (enterRequest.useBus) return "internal";
|
|
386
|
-
if (enterRequest.supportsActivate) return "activate";
|
|
387
|
-
return "agent-view";
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function buildEmptyProjectsDownActions(state = {}, displayAgents = []) {
|
|
391
|
-
if (!state.emptyProjectsDownArmed) {
|
|
392
|
-
return [{ type: "projects/armEmptyDown" }];
|
|
393
|
-
}
|
|
394
|
-
const actions = [{ type: "view/set", view: "agents" }];
|
|
395
|
-
if (displayAgents.length > 0 && state.selectedAgentIndex < 0) {
|
|
396
|
-
actions.push({ type: "agents/select", index: 0 });
|
|
397
|
-
}
|
|
398
|
-
return actions;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function buildPromptIpcRequest(text) {
|
|
402
|
-
const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
|
|
403
|
-
return {
|
|
404
|
-
type: IPC_REQUEST_TYPES.PROMPT,
|
|
405
|
-
text,
|
|
406
|
-
request_meta: {
|
|
407
|
-
source: "chat-dialog",
|
|
408
|
-
dispatch_default_injection_mode: "immediate",
|
|
409
|
-
allow_relevance_queue: true,
|
|
410
|
-
},
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function normalizeInkLogLines(text = "") {
|
|
415
|
-
const clean = stripBlessedTags(text);
|
|
416
|
-
return clean.split(/\r?\n/);
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// Stream deltas are batched into one dispatch per window (see
|
|
420
|
-
// createInkStreamState) so a fast stream can't force 30+ full-tree
|
|
421
|
-
// re-renders per second.
|
|
422
|
-
const STREAM_FLUSH_INTERVAL_MS = 80;
|
|
423
|
-
|
|
424
|
-
// Burst-coalescing sender: the first call fires immediately, calls inside
|
|
425
|
-
// the window collapse into a single trailing send. Used for daemon STATUS
|
|
426
|
-
// requests, which arrive in bursts (bus traffic + router callbacks) and
|
|
427
|
-
// each trigger a dashboard re-render.
|
|
428
|
-
function createThrottledSender(send, windowMs = 500) {
|
|
429
|
-
let lastSentAt = 0;
|
|
430
|
-
let timer = null;
|
|
431
|
-
const fire = () => {
|
|
432
|
-
timer = null;
|
|
433
|
-
lastSentAt = Date.now();
|
|
434
|
-
send();
|
|
435
|
-
};
|
|
436
|
-
return () => {
|
|
437
|
-
const now = Date.now();
|
|
438
|
-
const elapsed = now - lastSentAt;
|
|
439
|
-
if (elapsed >= windowMs) {
|
|
440
|
-
if (timer) {
|
|
441
|
-
clearTimeout(timer);
|
|
442
|
-
timer = null;
|
|
443
|
-
}
|
|
444
|
-
lastSentAt = now;
|
|
445
|
-
send();
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
if (!timer) {
|
|
449
|
-
timer = setTimeout(fire, windowMs - elapsed);
|
|
450
|
-
if (typeof timer.unref === "function") timer.unref();
|
|
451
|
-
}
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// Kinds whose log entries render as a margin-bottom "transcript cell" in
|
|
456
|
-
// buildChatLogGroups. Kept in sync with canAppendToChatLogGroup in
|
|
457
|
-
// chatLogModel.js.
|
|
458
|
-
const STATIC_GROUPABLE_KINDS = new Set([
|
|
459
|
-
"assistant",
|
|
460
|
-
"agent",
|
|
461
|
-
"report",
|
|
462
|
-
"success",
|
|
463
|
-
"error",
|
|
464
|
-
"meta",
|
|
465
|
-
"system",
|
|
466
|
-
"plain",
|
|
467
|
-
]);
|
|
468
|
-
|
|
469
|
-
// Shared row colors for both the dynamic (stream) and <Static> renderers.
|
|
470
|
-
// Aligned with ucode LOG_LINE_TEXT_PROPS: user green+bold, system dim gray,
|
|
471
|
-
// team bus/agent cyan, ufoo assistant white/bold marker.
|
|
472
|
-
const CHAT_LOG_ROW_PALETTE = {
|
|
473
|
-
user: { marker: "green", speaker: "green", body: "green", bold: true },
|
|
474
|
-
assistant: { marker: "cyan", speaker: "white", body: undefined, bold: true },
|
|
475
|
-
agent: { marker: "cyan", speaker: "cyan", body: undefined, bold: false },
|
|
476
|
-
report: { marker: "yellow", speaker: "yellow", body: undefined, bold: false },
|
|
477
|
-
system: { marker: "gray", speaker: "gray", body: "gray", bold: false, dim: true },
|
|
478
|
-
error: { marker: "red", speaker: "red", body: "red", bold: true },
|
|
479
|
-
success: { marker: "green", speaker: "green", body: "green", bold: false },
|
|
480
|
-
divider: { marker: "gray", speaker: "gray", body: "gray", bold: false },
|
|
481
|
-
banner: { marker: "cyan", speaker: "cyan", body: "cyan", bold: true },
|
|
482
|
-
meta: { marker: "gray", speaker: "gray", body: "gray", bold: false },
|
|
483
|
-
plain: { marker: "gray", speaker: "gray", body: undefined, bold: false },
|
|
484
|
-
};
|
|
485
|
-
|
|
486
|
-
// Decorate one finalized log entry with the grouping facts the <Static>
|
|
487
|
-
// renderer needs. Grouping is a deterministic left-to-right fold (same
|
|
488
|
-
// rules as buildChatLogGroups), so once an entry is decorated its flags
|
|
489
|
-
// never change — which is exactly what Static's append-only rendering
|
|
490
|
-
// requires. `marginBefore` reproduces the old group marginBottom as a
|
|
491
|
-
// margin-top on the next entry, because per-item rendering can't know a
|
|
492
|
-
// group's end until the following entry arrives.
|
|
493
|
-
function decorateStaticLogEntry(prev, entry) {
|
|
494
|
-
const markdownState = prev && prev.markdownState && typeof prev.markdownState === "object"
|
|
495
|
-
? { inCodeBlock: Boolean(prev.markdownState.inCodeBlock) }
|
|
496
|
-
: { inCodeBlock: false };
|
|
497
|
-
const source = entry && typeof entry === "object" ? entry : { text: entry };
|
|
498
|
-
const sourceText = source.text != null ? String(source.text) : String(entry || "");
|
|
499
|
-
const sourceType = String(source.sourceType || source.type || "");
|
|
500
|
-
const meta = source.meta && typeof source.meta === "object" ? source.meta : {};
|
|
501
|
-
const row = buildChatLogLineModel({
|
|
502
|
-
...source,
|
|
503
|
-
text: sourceText,
|
|
504
|
-
sourceType,
|
|
505
|
-
meta,
|
|
506
|
-
}, { markdownState, sourceType, meta });
|
|
507
|
-
const continuation = Boolean(
|
|
508
|
-
prev
|
|
509
|
-
&& (
|
|
510
|
-
((row.kind === "plain" || row.kind === "spacer") && STATIC_GROUPABLE_KINDS.has(prev.groupKind))
|
|
511
|
-
|| (prev.groupKind === "user" && row.kind === "user" && row.marker !== "›")
|
|
512
|
-
)
|
|
513
|
-
);
|
|
514
|
-
const groupKind = continuation ? prev.groupKind : row.kind;
|
|
515
|
-
// A gap belongs between visual blocks: only on entries that START a new
|
|
516
|
-
// block, and only when the previous block was a transcript group (whose
|
|
517
|
-
// old dynamic renderer contributed a trailing marginBottom).
|
|
518
|
-
// User turns also get a leading gap so › prompts don't sit flush against
|
|
519
|
-
// the previous transcript cell (ucode parity).
|
|
520
|
-
const marginBefore = Boolean(
|
|
521
|
-
!continuation
|
|
522
|
-
&& prev
|
|
523
|
-
&& (
|
|
524
|
-
STATIC_GROUPABLE_KINDS.has(prev.groupKind)
|
|
525
|
-
|| prev.groupKind === "user"
|
|
526
|
-
|| row.kind === "user"
|
|
527
|
-
)
|
|
528
|
-
);
|
|
529
|
-
return { entry: source, row, groupKind, continuation, marginBefore, markdownState };
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
function createInkStreamState({
|
|
533
|
-
dispatch,
|
|
534
|
-
appendHistory,
|
|
535
|
-
displayNameForPublisher = (value) => value,
|
|
536
|
-
flushIntervalMs = STREAM_FLUSH_INTERVAL_MS,
|
|
537
|
-
} = {}) {
|
|
538
|
-
const streams = new Map();
|
|
539
|
-
const pendingDeliveries = new Map();
|
|
540
|
-
// Delta batches awaiting dispatch, keyed like `streams`. Deltas arrive per
|
|
541
|
-
// daemon chunk (dozens per second); dispatching each one re-renders the
|
|
542
|
-
// whole Ink tree, so we accumulate for a short window and flush one
|
|
543
|
-
// stream/delta action per publisher per window.
|
|
544
|
-
const pendingDeltas = new Map();
|
|
545
|
-
let flushTimer = null;
|
|
546
|
-
|
|
547
|
-
function flushDeltas() {
|
|
548
|
-
if (flushTimer) {
|
|
549
|
-
clearTimeout(flushTimer);
|
|
550
|
-
flushTimer = null;
|
|
551
|
-
}
|
|
552
|
-
if (pendingDeltas.size === 0) return;
|
|
553
|
-
for (const batch of pendingDeltas.values()) {
|
|
554
|
-
dispatch({
|
|
555
|
-
type: "stream/delta",
|
|
556
|
-
publisher: batch.publisher,
|
|
557
|
-
delta: batch.parts.join(""),
|
|
558
|
-
});
|
|
559
|
-
}
|
|
560
|
-
pendingDeltas.clear();
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
function scheduleFlush() {
|
|
564
|
-
if (flushTimer) return;
|
|
565
|
-
flushTimer = setTimeout(flushDeltas, flushIntervalMs);
|
|
566
|
-
if (typeof flushTimer.unref === "function") flushTimer.unref();
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
function deliveryKey(agentId, agentLabel) {
|
|
570
|
-
return String(agentId || agentLabel || "").trim();
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
function markPendingDelivery(agentId, agentLabel) {
|
|
574
|
-
const key = deliveryKey(agentId, agentLabel);
|
|
575
|
-
if (!key) return;
|
|
576
|
-
const existing = pendingDeliveries.get(key) || { count: 0, keys: new Set() };
|
|
577
|
-
existing.count += 1;
|
|
578
|
-
for (const candidate of [agentId, agentLabel]) {
|
|
579
|
-
const value = String(candidate || "").trim();
|
|
580
|
-
if (value) {
|
|
581
|
-
pendingDeliveries.set(value, existing);
|
|
582
|
-
existing.keys.add(value);
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
function getPendingState(publisher, displayName) {
|
|
588
|
-
for (const candidate of [publisher, displayName]) {
|
|
589
|
-
const key = String(candidate || "").trim();
|
|
590
|
-
if (key && pendingDeliveries.has(key)) {
|
|
591
|
-
return { key, state: pendingDeliveries.get(key) };
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
return null;
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
function consumePendingDelivery(publisher, displayName) {
|
|
598
|
-
const hit = getPendingState(publisher, displayName);
|
|
599
|
-
if (!hit) return false;
|
|
600
|
-
hit.state.count -= 1;
|
|
601
|
-
if (hit.state.count <= 0) {
|
|
602
|
-
for (const key of hit.state.keys || []) pendingDeliveries.delete(key);
|
|
603
|
-
}
|
|
604
|
-
return true;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
function beginStream(publisher, prefix, continuationPrefix, meta) {
|
|
608
|
-
const key = String(publisher || "bus");
|
|
609
|
-
let state = streams.get(key);
|
|
610
|
-
if (state) return state;
|
|
611
|
-
const displayName = stripBlessedTags(prefix || displayNameForPublisher(key) || key)
|
|
612
|
-
.replace(/\s*·\s*$/, "")
|
|
613
|
-
.trim() || displayNameForPublisher(key) || key;
|
|
614
|
-
state = {
|
|
615
|
-
publisher: key,
|
|
616
|
-
displayName,
|
|
617
|
-
prefix,
|
|
618
|
-
continuationPrefix,
|
|
619
|
-
parts: [],
|
|
620
|
-
meta: meta || {},
|
|
621
|
-
};
|
|
622
|
-
streams.set(key, state);
|
|
623
|
-
dispatch({ type: "stream/begin", publisher: displayName });
|
|
624
|
-
return state;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
function appendStreamDelta(state, delta) {
|
|
628
|
-
if (!state || !delta) return;
|
|
629
|
-
const text = String(delta || "");
|
|
630
|
-
state.parts.push(text);
|
|
631
|
-
let batch = pendingDeltas.get(state.publisher);
|
|
632
|
-
if (!batch) {
|
|
633
|
-
batch = { publisher: state.displayName || state.publisher, parts: [] };
|
|
634
|
-
pendingDeltas.set(state.publisher, batch);
|
|
635
|
-
}
|
|
636
|
-
batch.parts.push(text);
|
|
637
|
-
scheduleFlush();
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
function finalizeStream(publisher, meta, reason = "") {
|
|
641
|
-
const key = String(publisher || "bus");
|
|
642
|
-
const state = streams.get(key);
|
|
643
|
-
if (!state) return;
|
|
644
|
-
// Flush first so the trailing deltas land on activeStream before the
|
|
645
|
-
// stream/end fold reads them.
|
|
646
|
-
flushDeltas();
|
|
647
|
-
dispatch({ type: "stream/end" });
|
|
648
|
-
if (typeof appendHistory === "function") {
|
|
649
|
-
const full = state.parts.join("");
|
|
650
|
-
const text = state.displayName
|
|
651
|
-
? `${state.displayName}: ${full}`
|
|
652
|
-
: full;
|
|
653
|
-
appendHistory("bus", text, { ...(meta || state.meta || {}), stream_done: true, stream_reason: reason });
|
|
654
|
-
}
|
|
655
|
-
streams.delete(key);
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
function hasStream(publisher) {
|
|
659
|
-
return streams.has(String(publisher || "bus"));
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
return {
|
|
663
|
-
markPendingDelivery,
|
|
664
|
-
getPendingState,
|
|
665
|
-
consumePendingDelivery,
|
|
666
|
-
beginStream,
|
|
667
|
-
appendStreamDelta,
|
|
668
|
-
finalizeStream,
|
|
669
|
-
hasStream,
|
|
670
|
-
flushDeltas,
|
|
671
|
-
};
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
function formatShellCommandResultLines(result = {}) {
|
|
675
|
-
const lines = [];
|
|
676
|
-
const stdout = String(result.stdout || "").trimEnd();
|
|
677
|
-
const stderr = String(result.stderr || "").trimEnd();
|
|
678
|
-
if (stdout) lines.push(...stdout.split(/\r?\n/).map((line) => ({ type: "system", text: line })));
|
|
679
|
-
if (stderr) lines.push(...stderr.split(/\r?\n/).map((line) => ({ type: result.ok ? "system" : "error", text: line })));
|
|
680
|
-
if (!stdout && !stderr) lines.push({ type: "system", text: "(no output)" });
|
|
681
|
-
if (!result.ok) {
|
|
682
|
-
const suffix = result.signal ? ` signal ${result.signal}` : ` exit ${result.code != null ? result.code : 1}`;
|
|
683
|
-
lines.push({ type: "error", text: `Command failed:${suffix}` });
|
|
684
|
-
}
|
|
685
|
-
return lines;
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
function fitPlainLine(text = "", width = 80) {
|
|
689
|
-
const limit = Math.max(1, Math.floor(Number(width) || 80));
|
|
690
|
-
const raw = String(text || "").replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
|
|
691
|
-
let out = "";
|
|
692
|
-
let cells = 0;
|
|
693
|
-
for (const char of Array.from(raw)) {
|
|
694
|
-
const charWidth = fmt.charDisplayWidth(char);
|
|
695
|
-
if (cells + charWidth > limit) break;
|
|
696
|
-
out += char;
|
|
697
|
-
cells += charWidth;
|
|
698
|
-
}
|
|
699
|
-
if (out.length < raw.length && limit > 1) {
|
|
700
|
-
while (fmt.displayCellWidth(out) > limit - 1) {
|
|
701
|
-
out = Array.from(out).slice(0, -1).join("");
|
|
702
|
-
}
|
|
703
|
-
out = `${out}…`;
|
|
704
|
-
}
|
|
705
|
-
return out || " ";
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
function stripInternalLogMarkup(text = "") {
|
|
709
|
-
return String(text || "")
|
|
710
|
-
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
711
|
-
.replace(/\{\/?[^{}\n]+\}/g, "");
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
function wrapInternalPlainLine(text = "", width = 80) {
|
|
715
|
-
const limit = Math.max(1, Math.floor(Number(width) || 80));
|
|
716
|
-
const clean = stripInternalLogMarkup(text).replace(/\r/g, "");
|
|
717
|
-
if (!clean) return [""];
|
|
718
|
-
const rows = [];
|
|
719
|
-
let row = "";
|
|
720
|
-
let cells = 0;
|
|
721
|
-
for (const char of Array.from(clean)) {
|
|
722
|
-
const charWidth = fmt.charDisplayWidth(char);
|
|
723
|
-
if (cells > 0 && cells + charWidth > limit) {
|
|
724
|
-
rows.push(row);
|
|
725
|
-
row = "";
|
|
726
|
-
cells = 0;
|
|
727
|
-
}
|
|
728
|
-
row += char;
|
|
729
|
-
cells += charWidth;
|
|
730
|
-
}
|
|
731
|
-
rows.push(row);
|
|
732
|
-
return rows;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
// Ink's wrap:"wrap" + <Static> under-counts CJK row height on live appends,
|
|
736
|
-
// so later log writes overpaint the previous line. Pre-wrap by display cells
|
|
737
|
-
// and paint with wrap:"truncate" so each Static item occupies a known height.
|
|
738
|
-
function expandChatLogPhysicalLines(text = "", width = 80) {
|
|
739
|
-
const limit = Math.max(1, Math.floor(Number(width) || 80));
|
|
740
|
-
const source = String(text || "").replace(/\r/g, "");
|
|
741
|
-
if (!source) return [""];
|
|
742
|
-
const out = [];
|
|
743
|
-
for (const line of source.split("\n")) {
|
|
744
|
-
out.push(...wrapInternalPlainLine(line, limit));
|
|
745
|
-
}
|
|
746
|
-
return out.length > 0 ? out : [""];
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
function padDisplayCells(cells = 0) {
|
|
750
|
-
return " ".repeat(Math.max(0, Math.floor(Number(cells) || 0)));
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
function splitUserLogAtMention(bodyText = "") {
|
|
754
|
-
const body = String(bodyText || "");
|
|
755
|
-
const atMatch = body.match(/^@([^\s]+)\s+(.*)$/);
|
|
756
|
-
if (atMatch) {
|
|
757
|
-
return { at: atMatch[1], rest: atMatch[2] || "" };
|
|
758
|
-
}
|
|
759
|
-
return { at: "", rest: body };
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
/**
|
|
763
|
-
* Flatten one chat log row into terminal-width physical lines. Each returned
|
|
764
|
-
* line is a single string (marker/speaker/body already merged) so Ink never
|
|
765
|
-
* has to wrap it — critical for <Static> append-only CJK safety.
|
|
766
|
-
*/
|
|
767
|
-
function buildChatLogDisplayLines(row = {}, options = {}) {
|
|
768
|
-
const cols = Math.max(8, Math.floor(Number(options.cols) || 80));
|
|
769
|
-
const continuation = Boolean(options.continuation);
|
|
770
|
-
const groupKind = options.groupKind || row.kind || "plain";
|
|
771
|
-
const kind = row.kind || "plain";
|
|
772
|
-
|
|
773
|
-
if (kind === "spacer") return [" "];
|
|
774
|
-
if (kind === "divider") {
|
|
775
|
-
return [fitPlainLine(` ${compactDividerLabel(row.body || row.bodyText || "")}`, cols)];
|
|
776
|
-
}
|
|
777
|
-
if (kind === "banner") {
|
|
778
|
-
return expandChatLogPhysicalLines(stripInternalLogMarkup(row.bodyText || row.body || ""), cols)
|
|
779
|
-
.map((line) => fitPlainLine(line, cols));
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
const markerText = continuation
|
|
783
|
-
? (groupKind === "assistant" || groupKind === "agent" || groupKind === "report" ? " " : " ")
|
|
784
|
-
: String(row.markerText != null ? row.markerText : "");
|
|
785
|
-
|
|
786
|
-
if (kind === "user") {
|
|
787
|
-
const userBody = splitUserLogAtMention(row.bodyText || row.body || "");
|
|
788
|
-
const atPrefix = userBody.at ? `@${userBody.at} ` : "";
|
|
789
|
-
const firstPrefix = `${markerText || "› "}${atPrefix}`;
|
|
790
|
-
const prefixCells = fmt.displayCellWidth(firstPrefix);
|
|
791
|
-
const budget = Math.max(1, cols - prefixCells);
|
|
792
|
-
const chunks = expandChatLogPhysicalLines(userBody.rest, budget);
|
|
793
|
-
const contPad = padDisplayCells(prefixCells);
|
|
794
|
-
return chunks.map((chunk, idx) => {
|
|
795
|
-
const line = idx === 0 ? `${firstPrefix}${chunk}` : `${contPad}${chunk}`;
|
|
796
|
-
return fitPlainLine(line, cols);
|
|
797
|
-
});
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
const speakerPrefix = (!continuation && row.speaker)
|
|
801
|
-
? `${row.speaker} · `
|
|
802
|
-
: "";
|
|
803
|
-
const head = `${markerText}${speakerPrefix}`;
|
|
804
|
-
const headCells = fmt.displayCellWidth(head);
|
|
805
|
-
const budget = Math.max(1, cols - headCells);
|
|
806
|
-
const bodyPlain = stripInternalLogMarkup(row.bodyText != null ? row.bodyText : (row.body || ""));
|
|
807
|
-
const chunks = expandChatLogPhysicalLines(bodyPlain, budget);
|
|
808
|
-
const contPad = padDisplayCells(headCells);
|
|
809
|
-
return chunks.map((chunk, idx) => {
|
|
810
|
-
const line = idx === 0 ? `${head}${chunk}` : `${contPad}${chunk}`;
|
|
811
|
-
return fitPlainLine(line, cols);
|
|
812
|
-
});
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
function classifyInternalLogLine(line = "") {
|
|
816
|
-
const raw = stripInternalLogMarkup(line).replace(/\r/g, "");
|
|
817
|
-
if (!raw) return { kind: "spacer", text: "", markdown: false, bold: false };
|
|
818
|
-
if (raw.startsWith("> ")) return { kind: "user", text: raw.slice(2), markdown: false, bold: false };
|
|
819
|
-
if (raw.startsWith("* ")) return { kind: "agent", text: raw.slice(2), markdown: true, bold: false };
|
|
820
|
-
if (/^error:/i.test(raw) || /^\[error\]/i.test(raw)) {
|
|
821
|
-
return { kind: "error", text: raw, markdown: true, bold: false };
|
|
822
|
-
}
|
|
823
|
-
if (/^ufoo internal agent\b/i.test(raw)) {
|
|
824
|
-
return { kind: "system", text: raw, markdown: false, bold: true };
|
|
825
|
-
}
|
|
826
|
-
if (/^(agent|directory):/i.test(raw)) {
|
|
827
|
-
return { kind: "meta", text: raw, markdown: false, bold: false };
|
|
828
|
-
}
|
|
829
|
-
return { kind: "agent", text: raw, markdown: true, bold: false };
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
function internalLogPrefixes(kind) {
|
|
833
|
-
if (kind === "user") return { first: "› ", rest: " " };
|
|
834
|
-
if (kind === "system") return { first: "· ", rest: " " };
|
|
835
|
-
if (kind === "meta") return { first: " ", rest: " " };
|
|
836
|
-
return { first: "", rest: "" };
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
function buildInternalLogRows(lines = [], width = 80, maxRows = 20) {
|
|
840
|
-
const limit = Math.max(1, Math.floor(Number(width) || 80));
|
|
841
|
-
const rows = [];
|
|
842
|
-
const markdownState = {};
|
|
843
|
-
const source = Array.isArray(lines) ? lines : [];
|
|
844
|
-
for (const line of source) {
|
|
845
|
-
const classified = classifyInternalLogLine(line);
|
|
846
|
-
if (classified.kind === "spacer") {
|
|
847
|
-
rows.push({ kind: "spacer", text: " ", bold: false });
|
|
848
|
-
continue;
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
let rendered = [classified.text];
|
|
852
|
-
if (classified.markdown) {
|
|
853
|
-
try {
|
|
854
|
-
// Share ucode's ANSI markdown renderer so Ink can show bold/code
|
|
855
|
-
// without blessed tags (which would otherwise be stripped).
|
|
856
|
-
rendered = fmt.renderLogLinesWithMarkdownAnsi(classified.text, markdownState);
|
|
857
|
-
} catch {
|
|
858
|
-
rendered = [classified.text];
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
const prefixes = internalLogPrefixes(classified.kind);
|
|
863
|
-
for (const renderedLine of rendered) {
|
|
864
|
-
const chunks = wrapInternalPlainLine(
|
|
865
|
-
renderedLine,
|
|
866
|
-
Math.max(1, limit - fmt.displayCellWidth(prefixes.first)),
|
|
867
|
-
);
|
|
868
|
-
chunks.forEach((chunk, idx) => {
|
|
869
|
-
const prefix = idx === 0 ? prefixes.first : prefixes.rest;
|
|
870
|
-
rows.push({
|
|
871
|
-
kind: classified.kind,
|
|
872
|
-
text: fitPlainLine(`${prefix}${chunk}`, limit),
|
|
873
|
-
bold: classified.bold,
|
|
874
|
-
});
|
|
875
|
-
});
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
return rows.slice(-Math.max(1, Math.floor(Number(maxRows) || 20)));
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
function internalInputBoundaries(text = "") {
|
|
882
|
-
const source = String(text || "");
|
|
883
|
-
if (!source) return [0];
|
|
884
|
-
try {
|
|
885
|
-
if (typeof Intl !== "undefined" && typeof Intl.Segmenter === "function") {
|
|
886
|
-
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
887
|
-
const boundaries = [0];
|
|
888
|
-
for (const part of segmenter.segment(source)) {
|
|
889
|
-
boundaries.push(part.index + part.segment.length);
|
|
890
|
-
}
|
|
891
|
-
return Array.from(new Set(boundaries)).sort((a, b) => a - b);
|
|
892
|
-
}
|
|
893
|
-
} catch {
|
|
894
|
-
// Fall through.
|
|
895
|
-
}
|
|
896
|
-
const boundaries = [0];
|
|
897
|
-
let offset = 0;
|
|
898
|
-
for (const char of Array.from(source)) {
|
|
899
|
-
offset += char.length;
|
|
900
|
-
boundaries.push(offset);
|
|
901
|
-
}
|
|
902
|
-
return boundaries;
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
function previousInternalBoundary(text = "", cursor = 0) {
|
|
906
|
-
const target = Math.max(0, Math.min(String(text || "").length, cursor));
|
|
907
|
-
let previous = 0;
|
|
908
|
-
for (const boundary of internalInputBoundaries(text)) {
|
|
909
|
-
if (boundary < target) previous = boundary;
|
|
910
|
-
else break;
|
|
911
|
-
}
|
|
912
|
-
return previous;
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
function nextInternalBoundary(text = "", cursor = 0) {
|
|
916
|
-
const source = String(text || "");
|
|
917
|
-
const target = Math.max(0, Math.min(source.length, cursor));
|
|
918
|
-
for (const boundary of internalInputBoundaries(source)) {
|
|
919
|
-
if (boundary > target) return boundary;
|
|
920
|
-
}
|
|
921
|
-
return source.length;
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
function resolveInternalKeyName(input = "", key = {}) {
|
|
925
|
-
const raw = String(input || "");
|
|
926
|
-
if (raw === "\x7f" || raw === "\b" || raw === "\x08") return "backspace";
|
|
927
|
-
if (raw === "\x1b[3~" || raw === "\u001b[3~") return "delete";
|
|
928
|
-
if (key && key.backspace) return "backspace";
|
|
929
|
-
if (key && key.delete) return "backspace";
|
|
930
|
-
if (key && key.name === "backspace") return "backspace";
|
|
931
|
-
if (key && key.name === "delete") return "backspace";
|
|
932
|
-
if (key && key.name) return String(key.name);
|
|
933
|
-
if (key && key.escape) return "escape";
|
|
934
|
-
if (key && key.return) return "return";
|
|
935
|
-
if (key && key.leftArrow) return "left";
|
|
936
|
-
if (key && key.rightArrow) return "right";
|
|
937
|
-
if (key && key.upArrow) return "up";
|
|
938
|
-
if (key && key.downArrow) return "down";
|
|
939
|
-
if (key && key.ctrl && raw.length === 1) return raw.toLowerCase();
|
|
940
|
-
return "";
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
function isInternalViewingAgent(agentId, meta, view = {}, viewingAgentId = "") {
|
|
944
|
-
const id = String(agentId || "").trim();
|
|
945
|
-
if (!id) return false;
|
|
946
|
-
const candidates = new Set([
|
|
947
|
-
viewingAgentId,
|
|
948
|
-
view && view.agentId,
|
|
949
|
-
view && view.label,
|
|
950
|
-
...((view && Array.isArray(view.aliases)) ? view.aliases : []),
|
|
951
|
-
].filter(Boolean).map((value) => String(value).trim()).filter(Boolean));
|
|
952
|
-
if (candidates.has(id)) return true;
|
|
953
|
-
const metaIds = [
|
|
954
|
-
meta && meta.fullId,
|
|
955
|
-
meta && meta.agent_id,
|
|
956
|
-
meta && meta.subscriber_id,
|
|
957
|
-
meta && meta.nickname,
|
|
958
|
-
meta && meta.scoped_nickname,
|
|
959
|
-
meta && meta.display_nickname,
|
|
960
|
-
meta && meta.type && meta.id ? `${meta.type}:${meta.id}` : "",
|
|
961
|
-
getAgentLabelFor(meta, id),
|
|
962
|
-
].filter(Boolean).map((value) => String(value).trim()).filter(Boolean);
|
|
963
|
-
return metaIds.some((value) => candidates.has(value));
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
function compactDisplayProjectRoot(projectRoot = "") {
|
|
967
|
-
const os = require("os");
|
|
968
|
-
const raw = String(projectRoot || process.cwd() || "").trim();
|
|
969
|
-
const home = os.homedir();
|
|
970
|
-
if (home && (raw === home || raw.startsWith(`${home}/`))) return `~${raw.slice(home.length)}`;
|
|
971
|
-
return raw || ".";
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
function buildInternalAgentStartupLines({ agentId = "", label = "", projectRoot = "", width = 80 } = {}) {
|
|
975
|
-
return [
|
|
976
|
-
fitPlainLine(`ufoo internal agent · ${label || agentId}`, width),
|
|
977
|
-
fitPlainLine(`agent: ${agentId}`, width),
|
|
978
|
-
fitPlainLine(`directory: ${compactDisplayProjectRoot(projectRoot)}`, width),
|
|
979
|
-
"",
|
|
980
|
-
];
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
function createInternalAgentViewState({
|
|
984
|
-
agentId,
|
|
985
|
-
label,
|
|
986
|
-
aliases = [],
|
|
987
|
-
projectRoot,
|
|
988
|
-
width = 80,
|
|
989
|
-
} = {}) {
|
|
990
|
-
let history = [];
|
|
991
|
-
try {
|
|
992
|
-
const { loadInternalAgentLogHistory } = require("../../app/chat/internalAgentLogHistory");
|
|
993
|
-
history = loadInternalAgentLogHistory(projectRoot || process.cwd(), agentId, {
|
|
994
|
-
maxEvents: 400,
|
|
995
|
-
maxLines: 1000,
|
|
996
|
-
});
|
|
997
|
-
} catch {
|
|
998
|
-
history = [];
|
|
999
|
-
}
|
|
1000
|
-
const safeAliases = [agentId, label].concat(aliases || []).filter(Boolean).map(String);
|
|
1001
|
-
return {
|
|
1002
|
-
agentId: String(agentId || ""),
|
|
1003
|
-
label: String(label || agentId || ""),
|
|
1004
|
-
aliases: Array.from(new Set(safeAliases)),
|
|
1005
|
-
projectRoot: String(projectRoot || ""),
|
|
1006
|
-
lines: buildInternalAgentStartupLines({ agentId, label, projectRoot, width })
|
|
1007
|
-
.concat(history.length > 0 ? history : [""]),
|
|
1008
|
-
input: "",
|
|
1009
|
-
cursor: 0,
|
|
1010
|
-
status: "ready",
|
|
1011
|
-
detail: "",
|
|
1012
|
-
statusStartedAt: 0,
|
|
1013
|
-
barIndex: 0,
|
|
1014
|
-
};
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
function appendInternalAgentText(view, text = "", options = {}) {
|
|
1018
|
-
const current = view && typeof view === "object" ? view : {};
|
|
1019
|
-
const lines = Array.isArray(current.lines) ? current.lines.slice() : [];
|
|
1020
|
-
if (lines.length === 0) lines.push("");
|
|
1021
|
-
const prefix = options.prefix || "";
|
|
1022
|
-
const clean = String(text || "").replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
1023
|
-
.replace(/\r\n/g, "\n")
|
|
1024
|
-
.replace(/\r/g, "\n");
|
|
1025
|
-
if (prefix && lines[lines.length - 1] !== "") lines.push("");
|
|
1026
|
-
if (prefix && lines[lines.length - 1] === "") lines[lines.length - 1] = prefix;
|
|
1027
|
-
for (const char of clean) {
|
|
1028
|
-
if (char === "\n") {
|
|
1029
|
-
lines.push("");
|
|
1030
|
-
} else {
|
|
1031
|
-
lines[lines.length - 1] += char;
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
return {
|
|
1035
|
-
...current,
|
|
1036
|
-
lines: lines.slice(-1000),
|
|
1037
|
-
};
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
function parseInternalBusPayload(raw = "") {
|
|
1041
|
-
let displayMessage = String(raw || "");
|
|
1042
|
-
let streamPayload = null;
|
|
1043
|
-
try {
|
|
1044
|
-
const parsed = JSON.parse(raw);
|
|
1045
|
-
if (parsed && typeof parsed === "object" && parsed.reply) {
|
|
1046
|
-
displayMessage = parsed.reply;
|
|
1047
|
-
} else if (parsed && typeof parsed === "object" && parsed.stream) {
|
|
1048
|
-
streamPayload = parsed;
|
|
1049
|
-
}
|
|
1050
|
-
} catch {
|
|
1051
|
-
// Plain text.
|
|
1052
|
-
}
|
|
1053
|
-
return {
|
|
1054
|
-
displayMessage: String(displayMessage || "").replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n").replace(/\\r/g, "\n"),
|
|
1055
|
-
streamPayload,
|
|
1056
|
-
};
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
function internalStatusLabel(value = "") {
|
|
1060
|
-
const state = String(value || "").trim().toLowerCase();
|
|
1061
|
-
if (state === "waiting" || state === "waiting_input") return "waiting";
|
|
1062
|
-
if (state === "blocked" || state === "error") return "blocked";
|
|
1063
|
-
if (state === "busy" || state === "processing" || state === "working") return "working";
|
|
1064
|
-
if (state === "idle" || state === "ready") return "ready";
|
|
1065
|
-
return state || "ready";
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
function updateInternalViewStatus(view = {}, status = "", detail = "", now = Date.now()) {
|
|
1069
|
-
const current = view && typeof view === "object" ? view : {};
|
|
1070
|
-
const nextStatus = internalStatusLabel(status || current.status || "");
|
|
1071
|
-
const nextDetail = String(detail || "").trim();
|
|
1072
|
-
const timed = nextStatus === "working" || nextStatus === "waiting" || nextStatus === "blocked";
|
|
1073
|
-
const previousStartedAt = Number.isFinite(current.statusStartedAt) ? current.statusStartedAt : 0;
|
|
1074
|
-
const statusStartedAt = timed
|
|
1075
|
-
? (current.status === nextStatus && previousStartedAt ? previousStartedAt : now)
|
|
1076
|
-
: 0;
|
|
1077
|
-
return {
|
|
1078
|
-
...current,
|
|
1079
|
-
status: nextStatus,
|
|
1080
|
-
detail: nextDetail,
|
|
1081
|
-
statusStartedAt,
|
|
1082
|
-
};
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
function applyInternalAgentTermWrite(view = {}, activeAgentId = "", text = "", meta = {}) {
|
|
1086
|
-
const current = view && typeof view === "object" ? view : {};
|
|
1087
|
-
if (!current.agentId || current.agentId !== activeAgentId) return current;
|
|
1088
|
-
const streamPayload = meta && meta.streamPayload && typeof meta.streamPayload === "object"
|
|
1089
|
-
? meta.streamPayload
|
|
1090
|
-
: {};
|
|
1091
|
-
const done = Boolean((meta && meta.done) || streamPayload.done);
|
|
1092
|
-
const rawText = String(text || "");
|
|
1093
|
-
const next = rawText
|
|
1094
|
-
? appendInternalAgentText(current, rawText, { prefix: "* " })
|
|
1095
|
-
: current;
|
|
1096
|
-
if (done) return updateInternalViewStatus(next, "ready", "");
|
|
1097
|
-
return updateInternalViewStatus(next, "working", "");
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
function appendInternalErrorToView(view = {}, activeAgentId = "", message = "") {
|
|
1101
|
-
const current = view && typeof view === "object" ? view : {};
|
|
1102
|
-
if (!current.agentId || current.agentId !== activeAgentId) return current;
|
|
1103
|
-
const detail = String(message || "unknown error");
|
|
1104
|
-
const lines = Array.isArray(current.lines) ? current.lines : [];
|
|
1105
|
-
const separator = lines.length > 0 && lines[lines.length - 1] ? "\n" : "";
|
|
1106
|
-
return appendInternalAgentText(
|
|
1107
|
-
updateInternalViewStatus(current, "blocked", detail),
|
|
1108
|
-
`${separator}Error: ${detail}\n`,
|
|
1109
|
-
);
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
function computeInternalStatusText(view = {}, spinnerTick = 0, now = Date.now()) {
|
|
1113
|
-
const current = view && typeof view === "object" ? view : {};
|
|
1114
|
-
const status = internalStatusLabel(current.status || "");
|
|
1115
|
-
const label = String(current.label || current.agentId || "agent").trim();
|
|
1116
|
-
const detail = String(current.detail || "").trim();
|
|
1117
|
-
if (status === "ready") {
|
|
1118
|
-
return `ufoo · ${label} · Ready · Enter send · Esc back`;
|
|
1119
|
-
}
|
|
1120
|
-
const type = status === "waiting" ? "waiting" : "thinking";
|
|
1121
|
-
const indicators = fmt.STATUS_INDICATORS[type] || fmt.STATUS_INDICATORS.thinking;
|
|
1122
|
-
const indicator = status === "blocked"
|
|
1123
|
-
? "!"
|
|
1124
|
-
: indicators[Math.max(0, Math.floor(Number(spinnerTick) || 0)) % indicators.length];
|
|
1125
|
-
const message = status === "waiting"
|
|
1126
|
-
? "Waiting for input"
|
|
1127
|
-
: (status === "blocked" ? "Blocked" : "Working");
|
|
1128
|
-
const startedAt = Number.isFinite(current.statusStartedAt) ? current.statusStartedAt : 0;
|
|
1129
|
-
const timer = startedAt ? ` (${fmt.formatPendingElapsed(now - startedAt)})` : "";
|
|
1130
|
-
return `${indicator} ${label} · ${message}${detail ? ` · ${detail}` : ""}${timer} · Esc back`;
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
const CHAT_BANNER_LINES = [
|
|
1134
|
-
"█ █ █▀▀ █▀█ █▀█",
|
|
1135
|
-
"█ █ █▀ █ █ █ █",
|
|
1136
|
-
"▀▀▀ ▀ ▀▀▀ ▀▀▀",
|
|
1137
|
-
];
|
|
1138
|
-
|
|
1139
|
-
function buildChatBannerLines(props, version) {
|
|
1140
|
-
const os = require("os");
|
|
1141
|
-
const home = os.homedir();
|
|
1142
|
-
const root = props.activeProjectRoot || process.cwd();
|
|
1143
|
-
const shortRoot = root.startsWith(home) ? root.replace(home, "~") : root;
|
|
1144
|
-
const modeLabel = props.globalMode
|
|
1145
|
-
? `global (${props.globalScope || "controller"})`
|
|
1146
|
-
: "project";
|
|
1147
|
-
const padding = " ".repeat(
|
|
1148
|
-
CHAT_BANNER_LINES.reduce((max, line) => Math.max(max, line.length), 0)
|
|
1149
|
-
);
|
|
1150
|
-
const info = [
|
|
1151
|
-
`Version: ${version}`,
|
|
1152
|
-
`Mode: ${modeLabel}`,
|
|
1153
|
-
`Dictionary: ${shortRoot}`,
|
|
1154
|
-
];
|
|
1155
|
-
const rows = Math.max(CHAT_BANNER_LINES.length, info.length);
|
|
1156
|
-
const out = [];
|
|
1157
|
-
for (let i = 0; i < rows; i += 1) {
|
|
1158
|
-
const left = CHAT_BANNER_LINES[i] || padding;
|
|
1159
|
-
const right = info[i] || "";
|
|
1160
|
-
out.push(` ${left} ${right}`);
|
|
1161
|
-
}
|
|
1162
|
-
return out;
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
function resolveProjectRowRoot(row = {}) {
|
|
1166
|
-
const raw = String((row && (row.root || row.project_root)) || "").trim();
|
|
1167
|
-
if (!raw) return "";
|
|
1168
|
-
try {
|
|
1169
|
-
const { canonicalProjectRoot } = require("../../runtime/projects");
|
|
1170
|
-
return canonicalProjectRoot(raw);
|
|
1171
|
-
} catch {
|
|
1172
|
-
return path.resolve(raw);
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
function loadGlobalProjectRows(activeProjectRoot = "") {
|
|
1177
|
-
const {
|
|
1178
|
-
listProjectRuntimes,
|
|
1179
|
-
filterVisibleProjectRuntimes,
|
|
1180
|
-
isGlobalControllerProjectRoot,
|
|
1181
|
-
markProjectStopped,
|
|
1182
|
-
} = require("../../runtime/projects");
|
|
1183
|
-
let rows = listProjectRuntimes({ validate: true, cleanupTmp: true }) || [];
|
|
1184
|
-
for (const row of rows) {
|
|
1185
|
-
const status = String((row && row.status) || "").trim().toLowerCase();
|
|
1186
|
-
const root = resolveProjectRowRoot(row);
|
|
1187
|
-
if (status === "stale" && root && !isGlobalControllerProjectRoot(root)) {
|
|
1188
|
-
try { markProjectStopped(root); } catch { /* ignore stale cleanup failures */ }
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
rows = filterVisibleProjectRuntimes(rows);
|
|
1192
|
-
rows = rows.filter((row) => !isGlobalControllerProjectRoot(resolveProjectRowRoot(row)));
|
|
1193
|
-
return rows.map((row) => ({
|
|
1194
|
-
id: row.project_id || row.project_root || "",
|
|
1195
|
-
label: row.project_name || (row.project_root ? path.basename(row.project_root) : ""),
|
|
1196
|
-
root: row.project_root || "",
|
|
1197
|
-
status: row.status || "",
|
|
1198
|
-
active: resolveProjectRowRoot(row) === String(activeProjectRoot || ""),
|
|
1199
|
-
}));
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
function readProjectAgentSnapshot(projectRoot = "") {
|
|
1203
|
-
if (!projectRoot) return { agents: [], metaMap: new Map() };
|
|
1204
|
-
try {
|
|
1205
|
-
const { buildStatus } = require("../../runtime/daemon/status");
|
|
1206
|
-
const { buildAgentMaps } = require("../../app/chat/agentDirectory");
|
|
1207
|
-
const status = buildStatus(projectRoot);
|
|
1208
|
-
const activeIds = Array.isArray(status.active) ? status.active : [];
|
|
1209
|
-
const metaList = Array.isArray(status.active_meta) ? status.active_meta : [];
|
|
1210
|
-
const { labelMap, metaMap } = buildAgentMaps(activeIds, metaList);
|
|
1211
|
-
const merged = new Map();
|
|
1212
|
-
for (const id of activeIds) {
|
|
1213
|
-
const meta = metaMap.get(id) || {};
|
|
1214
|
-
const colon = id.indexOf(":");
|
|
1215
|
-
const fallbackType = colon > 0 ? id.slice(0, colon) : id;
|
|
1216
|
-
const fallbackId = colon > 0 ? id.slice(colon + 1) : "";
|
|
1217
|
-
merged.set(id, {
|
|
1218
|
-
...meta,
|
|
1219
|
-
fullId: id,
|
|
1220
|
-
type: meta.type || fallbackType,
|
|
1221
|
-
id: meta.id || fallbackId,
|
|
1222
|
-
nickname: labelMap.get(id) || id,
|
|
1223
|
-
});
|
|
1224
|
-
}
|
|
1225
|
-
return { agents: activeIds, metaMap: merged };
|
|
1226
|
-
} catch {
|
|
1227
|
-
return { agents: [], metaMap: new Map() };
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
function isCJK(ch) {
|
|
1232
|
-
if (!ch) return false;
|
|
1233
|
-
const code = ch.codePointAt(0);
|
|
1234
|
-
return (code >= 0x2e80 && code <= 0x9fff) ||
|
|
1235
|
-
(code >= 0xac00 && code <= 0xd7af) ||
|
|
1236
|
-
(code >= 0xf900 && code <= 0xfaff) ||
|
|
1237
|
-
(code >= 0xfe30 && code <= 0xfe4f) ||
|
|
1238
|
-
(code >= 0x20000 && code <= 0x2fa1f);
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
function inferStatusType(text = "", requestedType = "") {
|
|
1242
|
-
const type = String(requestedType || "").trim().toLowerCase();
|
|
1243
|
-
if (type === "done" || type === "success" || type === "error" || type === "idle") return type;
|
|
1244
|
-
const clean = stripBlessedTags(String(text || "")).trim();
|
|
1245
|
-
if (/^[✗!]/.test(clean) || /\b(error|failed|failure|offline)\b/i.test(clean) || /失败|错误/.test(clean)) return "error";
|
|
1246
|
-
if (
|
|
1247
|
-
/^[✓✔]/.test(clean) ||
|
|
1248
|
-
/^(done|closed|complete|completed|finished|success|succeeded|ready)\b/i.test(clean) ||
|
|
1249
|
-
/\b(processed|reconnected|switched|saved)\b/i.test(clean) ||
|
|
1250
|
-
/\bdone\s*$/i.test(clean) ||
|
|
1251
|
-
/完成|成功|已处理|已保存|已切换|已连接/.test(clean)
|
|
1252
|
-
) return "done";
|
|
1253
|
-
return type || "typing";
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
function isAnimatedStatusType(type = "") {
|
|
1257
|
-
const value = String(type || "").trim().toLowerCase();
|
|
1258
|
-
return value !== "done" && value !== "success" && value !== "error" && value !== "idle" && value !== "none";
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
// The status bar owns its spinner tick: the 100ms animation timer lives in
|
|
1262
|
-
// this leaf component instead of the ChatApp root, so animating the spinner
|
|
1263
|
-
// re-renders one line of text rather than the whole tree (previously every
|
|
1264
|
-
// tick re-rendered the full log area, which Ink erased and rewrote at
|
|
1265
|
-
// 10fps — the visible flicker).
|
|
1266
|
-
function createChatStatusLine({ React, ink }) {
|
|
1267
|
-
const { useEffect, useState } = React;
|
|
1268
|
-
const { Box, Text } = ink;
|
|
1269
|
-
const h = React.createElement;
|
|
1270
|
-
return function ChatStatusLine({ status, version, cols = 80 }) {
|
|
1271
|
-
const message = String((status && status.message) || "");
|
|
1272
|
-
const animated = Boolean(message)
|
|
1273
|
-
&& isAnimatedStatusType(inferStatusType(message, status && status.type));
|
|
1274
|
-
const [tick, setTick] = useState(0);
|
|
1275
|
-
useEffect(() => {
|
|
1276
|
-
if (!animated) return undefined;
|
|
1277
|
-
const timer = setInterval(() => setTick((t) => t + 1), 100);
|
|
1278
|
-
return () => clearInterval(timer);
|
|
1279
|
-
}, [animated]);
|
|
1280
|
-
const versionText = `v${version || ""}`;
|
|
1281
|
-
const raw = computeStatusText(status, animated ? tick : 0);
|
|
1282
|
-
// Keep the version pinned on the right; truncate the live message so a
|
|
1283
|
-
// long bus status cannot collide with it or leave redraw trails.
|
|
1284
|
-
const leftBudget = Math.max(12, (Number(cols) || 80) - fmt.displayCellWidth(versionText) - 2);
|
|
1285
|
-
const left = fitPlainLine(raw, leftBudget);
|
|
1286
|
-
return h(Box, { marginTop: 1, width: "100%" },
|
|
1287
|
-
h(Text, { color: "gray", wrap: "truncate" }, left),
|
|
1288
|
-
h(Box, { flexGrow: 1 }),
|
|
1289
|
-
h(Text, { color: "gray" }, versionText),
|
|
1290
|
-
);
|
|
1291
|
-
};
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
// Same spinner isolation for the internal-agent view's status row.
|
|
1295
|
-
function createInternalStatusLine({ React, ink }) {
|
|
1296
|
-
const { useEffect, useState } = React;
|
|
1297
|
-
const { Text } = ink;
|
|
1298
|
-
const h = React.createElement;
|
|
1299
|
-
return function InternalStatusLine({ view, maxWidth }) {
|
|
1300
|
-
const status = internalStatusLabel(view && view.status);
|
|
1301
|
-
const active = status !== "ready";
|
|
1302
|
-
const [tick, setTick] = useState(0);
|
|
1303
|
-
useEffect(() => {
|
|
1304
|
-
if (!active) return undefined;
|
|
1305
|
-
const timer = setInterval(() => setTick((t) => t + 1), 100);
|
|
1306
|
-
return () => clearInterval(timer);
|
|
1307
|
-
}, [active]);
|
|
1308
|
-
const color = status === "blocked" ? "red" : (status === "ready" ? "gray" : "cyan");
|
|
1309
|
-
const text = computeInternalStatusText(view || {}, active ? tick : 0);
|
|
1310
|
-
return h(Text, { color, wrap: "truncate" }, fitPlainLine(text, maxWidth));
|
|
1311
|
-
};
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
function inkKeyToRaw(input, key) {
|
|
1315
|
-
if (key.ctrl && input) {
|
|
1316
|
-
const code = input.charCodeAt(0) - 96;
|
|
1317
|
-
if (code >= 1 && code <= 26) return String.fromCharCode(code);
|
|
1318
|
-
return "";
|
|
1319
|
-
}
|
|
1320
|
-
if (key.return) return "\r";
|
|
1321
|
-
if (key.escape) return "\x1b";
|
|
1322
|
-
if (key.backspace || key.delete) return "\x7f";
|
|
1323
|
-
if (key.tab) return "\t";
|
|
1324
|
-
if (key.upArrow) return "\x1b[A";
|
|
1325
|
-
if (key.downArrow) return "\x1b[B";
|
|
1326
|
-
if (key.rightArrow) return "\x1b[C";
|
|
1327
|
-
if (key.leftArrow) return "\x1b[D";
|
|
1328
|
-
if (input && !key.meta) return input;
|
|
1329
|
-
if (key.meta && input) return `\x1b${input}`;
|
|
1330
|
-
return "";
|
|
1331
|
-
}
|
|
1332
|
-
|
|
1333
|
-
function createChatApp({ React, ink, props, interactive = true }) {
|
|
1334
|
-
const { useReducer, useEffect, useState, useCallback, useRef, useMemo } = React;
|
|
1335
|
-
const { Box, Text, Static, useInput, useApp, useStdout } = ink;
|
|
1336
|
-
const h = React.createElement;
|
|
1337
|
-
const MultilineInput = createMultilineInput({ React, ink });
|
|
1338
|
-
const DashboardBar = createDashboardBar({ React, ink });
|
|
1339
|
-
const ChatStatusLine = createChatStatusLine({ React, ink });
|
|
1340
|
-
const InternalStatusLine = createInternalStatusLine({ React, ink });
|
|
1341
|
-
|
|
1342
|
-
// Build the initial log: chat history if there is any, otherwise an
|
|
1343
|
-
// ASCII banner with project / mode / version info. We resolve history
|
|
1344
|
-
// synchronously here so the very first paint already shows it instead
|
|
1345
|
-
// of rendering an empty banner and then flashing in the lines.
|
|
1346
|
-
const versionLabel = String(fmt.UCODE_VERSION || "");
|
|
1347
|
-
const banner = buildChatBannerLines(props, versionLabel);
|
|
1348
|
-
const persistedHistory = loadChatHistory(props.projectRoot, 200, { globalMode: props.globalMode });
|
|
1349
|
-
const initialLogText = persistedHistory.length > 0
|
|
1350
|
-
? banner.concat(["", "─── history ───"]).concat(persistedHistory).concat([""])
|
|
1351
|
-
: banner.concat([""]);
|
|
1352
|
-
|
|
1353
|
-
return function ChatApp() {
|
|
1354
|
-
const [state, dispatch] = useReducer(
|
|
1355
|
-
reducer,
|
|
1356
|
-
undefined,
|
|
1357
|
-
() => createInitialState({
|
|
1358
|
-
banner: initialLogText,
|
|
1359
|
-
globalMode: props.globalMode,
|
|
1360
|
-
globalScope: props.globalScope || "controller",
|
|
1361
|
-
settings: props.initialSettings || {},
|
|
1362
|
-
})
|
|
1363
|
-
);
|
|
1364
|
-
const [size, setSize] = useState({ cols: 0, rows: 0 });
|
|
1365
|
-
const [currentProjectRoot, setCurrentProjectRoot] = useState(props.activeProjectRoot || props.projectRoot || "");
|
|
1366
|
-
const [internalAgentView, setInternalAgentView] = useState(() => createInternalAgentViewState());
|
|
1367
|
-
const [multiWindowActive, setMultiWindowActive] = useState(false);
|
|
1368
|
-
const [mwCursor, setMwCursor] = useState(0);
|
|
1369
|
-
const [mwTerminalFocused, setMwTerminalFocused] = useState(false);
|
|
1370
|
-
const mwTerminalFocusedRef = useRef(false);
|
|
1371
|
-
const mwLastInputRef = useRef({ char: "", time: 0 });
|
|
1372
|
-
const stateRef = useRef(state);
|
|
1373
|
-
const sizeRef = useRef(size);
|
|
1374
|
-
const currentProjectRootRef = useRef(currentProjectRoot);
|
|
1375
|
-
const internalAgentViewRef = useRef(internalAgentView);
|
|
1376
|
-
const multiWindowControllerRef = useRef(null);
|
|
1377
|
-
const multiWindowChromeRef = useRef({ statusText: "", promptPrefix: "› ", draft: "", dashboardLines: [] });
|
|
1378
|
-
const multiWindowWatchedInternalAgentsRef = useRef(new Set());
|
|
1379
|
-
const pendingRef = useRef(null);
|
|
1380
|
-
const streamStateRef = useRef(null);
|
|
1381
|
-
const historyScopeRef = useRef(null);
|
|
1382
|
-
const switchToProjectRootRef = useRef(null);
|
|
1383
|
-
const activeChatHistoryRoot = currentProjectRoot || props.projectRoot;
|
|
1384
|
-
const activeChatHistoryOptions = chatHistoryOptionsForScope({
|
|
1385
|
-
globalMode: props.globalMode,
|
|
1386
|
-
globalScope: state.globalScope,
|
|
1387
|
-
});
|
|
1388
|
-
const { exit } = useApp();
|
|
1389
|
-
const { stdout } = useStdout();
|
|
1390
|
-
|
|
1391
|
-
useEffect(() => {
|
|
1392
|
-
stateRef.current = state;
|
|
1393
|
-
}, [state]);
|
|
1394
|
-
|
|
1395
|
-
useEffect(() => {
|
|
1396
|
-
sizeRef.current = size;
|
|
1397
|
-
}, [size]);
|
|
1398
|
-
|
|
1399
|
-
useEffect(() => {
|
|
1400
|
-
currentProjectRootRef.current = currentProjectRoot;
|
|
1401
|
-
}, [currentProjectRoot]);
|
|
1402
|
-
|
|
1403
|
-
historyScopeRef.current = {
|
|
1404
|
-
root: activeChatHistoryRoot,
|
|
1405
|
-
options: activeChatHistoryOptions,
|
|
1406
|
-
};
|
|
1407
|
-
|
|
1408
|
-
const appendScopedHistory = useCallback((kind, text, meta = {}) => {
|
|
1409
|
-
appendChatHistory(activeChatHistoryRoot, kind, text, meta, activeChatHistoryOptions);
|
|
1410
|
-
}, [activeChatHistoryRoot, activeChatHistoryOptions.globalMode]);
|
|
1411
|
-
|
|
1412
|
-
// Terminal statuses (command replies, ✓/✗ resolutions) must not animate
|
|
1413
|
-
// forever; show them briefly, then revert the status line to Ready.
|
|
1414
|
-
const statusAutoClearRef = useRef(null);
|
|
1415
|
-
|
|
1416
|
-
const setStatusText = useCallback((text, options = {}) => {
|
|
1417
|
-
// Any new status supersedes a pending auto-clear.
|
|
1418
|
-
if (statusAutoClearRef.current) {
|
|
1419
|
-
clearTimeout(statusAutoClearRef.current);
|
|
1420
|
-
statusAutoClearRef.current = null;
|
|
1421
|
-
}
|
|
1422
|
-
const clean = stripBlessedTags(text).trim();
|
|
1423
|
-
if (!clean) {
|
|
1424
|
-
dispatch({ type: "status/idle" });
|
|
1425
|
-
return;
|
|
1426
|
-
}
|
|
1427
|
-
const type = inferStatusType(clean, options.type || "typing");
|
|
1428
|
-
dispatch({
|
|
1429
|
-
type: "status/set",
|
|
1430
|
-
payload: {
|
|
1431
|
-
message: clean,
|
|
1432
|
-
type,
|
|
1433
|
-
showTimer: options.showTimer === true && isAnimatedStatusType(type),
|
|
1434
|
-
startedAt: options.startedAt || Date.now(),
|
|
1435
|
-
},
|
|
1436
|
-
});
|
|
1437
|
-
}, []);
|
|
1438
|
-
|
|
1439
|
-
const scheduleStatusAutoClear = useCallback(() => {
|
|
1440
|
-
if (statusAutoClearRef.current) clearTimeout(statusAutoClearRef.current);
|
|
1441
|
-
statusAutoClearRef.current = setTimeout(() => {
|
|
1442
|
-
statusAutoClearRef.current = null;
|
|
1443
|
-
dispatch({ type: "status/idle" });
|
|
1444
|
-
}, 5000);
|
|
1445
|
-
if (typeof statusAutoClearRef.current.unref === "function") statusAutoClearRef.current.unref();
|
|
1446
|
-
}, []);
|
|
1447
|
-
|
|
1448
|
-
const logInkMessage = useCallback((kind, text, meta = {}) => {
|
|
1449
|
-
const type = String(kind || "system");
|
|
1450
|
-
if (type === "status") {
|
|
1451
|
-
setStatusText(text);
|
|
1452
|
-
return;
|
|
1453
|
-
}
|
|
1454
|
-
const lines = normalizeInkLogLines(text);
|
|
1455
|
-
if (lines.length === 0) return;
|
|
1456
|
-
const payload = lines.map((line, index) => ({
|
|
1457
|
-
text: line,
|
|
1458
|
-
type,
|
|
1459
|
-
sourceType: type,
|
|
1460
|
-
// Attach router meta only on the first physical line so multi-line
|
|
1461
|
-
// bus/reply bodies don't duplicate publisher payloads.
|
|
1462
|
-
meta: index === 0 && meta && typeof meta === "object" ? meta : {},
|
|
1463
|
-
}));
|
|
1464
|
-
dispatch({ type: "log/appendMany", lines: payload });
|
|
1465
|
-
appendScopedHistory(type, stripBlessedTags(text), meta);
|
|
1466
|
-
}, [appendScopedHistory, setStatusText]);
|
|
1467
|
-
|
|
1468
|
-
if (!streamStateRef.current) {
|
|
1469
|
-
streamStateRef.current = createInkStreamState({
|
|
1470
|
-
dispatch,
|
|
1471
|
-
appendHistory: (kind, text, meta = {}) => {
|
|
1472
|
-
const scope = historyScopeRef.current || {};
|
|
1473
|
-
appendChatHistory(scope.root || props.projectRoot, kind, text, meta, scope.options || {});
|
|
1474
|
-
},
|
|
1475
|
-
displayNameForPublisher: (publisher) => {
|
|
1476
|
-
const current = stateRef.current || {};
|
|
1477
|
-
const meta = current.activeAgentMeta instanceof Map ? current.activeAgentMeta.get(publisher) : null;
|
|
1478
|
-
return getAgentLabelFor(meta, publisher);
|
|
1479
|
-
},
|
|
1480
|
-
});
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
const getMultiWindowController = useCallback(() => {
|
|
1484
|
-
if (multiWindowControllerRef.current) return multiWindowControllerRef.current;
|
|
1485
|
-
const processStdout = stdout || (typeof process !== "undefined" ? process.stdout : null);
|
|
1486
|
-
if (!processStdout || typeof processStdout.write !== "function") return null;
|
|
1487
|
-
|
|
1488
|
-
const originalWrite = processStdout.write.bind(processStdout);
|
|
1489
|
-
const { createMultiWindowController } = require("../../app/chat/multiWindow");
|
|
1490
|
-
multiWindowControllerRef.current = createMultiWindowController({
|
|
1491
|
-
processStdout: { write: originalWrite, rows: processStdout.rows, columns: processStdout.columns },
|
|
1492
|
-
getRows: () => {
|
|
1493
|
-
const currentSize = sizeRef.current || {};
|
|
1494
|
-
return currentSize.rows || processStdout.rows || 24;
|
|
1495
|
-
},
|
|
1496
|
-
getCols: () => {
|
|
1497
|
-
const currentSize = sizeRef.current || {};
|
|
1498
|
-
return currentSize.cols || processStdout.columns || 80;
|
|
1499
|
-
},
|
|
1500
|
-
getInjectSockPath: (agentId) =>
|
|
1501
|
-
resolveInjectSockPathForAgent(currentProjectRootRef.current || props.projectRoot, agentId),
|
|
1502
|
-
getActiveAgents: () => {
|
|
1503
|
-
const current = stateRef.current || {};
|
|
1504
|
-
return Array.isArray(current.agents) ? current.agents : [];
|
|
1505
|
-
},
|
|
1506
|
-
getAgentPaneOptions: (agentId) => {
|
|
1507
|
-
const current = stateRef.current || {};
|
|
1508
|
-
const enterRequest = resolveAgentEnterRequest({
|
|
1509
|
-
agentId,
|
|
1510
|
-
projectRoot: currentProjectRootRef.current || props.projectRoot,
|
|
1511
|
-
activeAgentMeta: current.activeAgentMeta,
|
|
1512
|
-
settings: current.settings,
|
|
1513
|
-
});
|
|
1514
|
-
if (!enterRequest || !enterRequest.useBus) return { mode: "socket" };
|
|
1515
|
-
const metaMap = current.activeAgentMeta instanceof Map ? current.activeAgentMeta : new Map();
|
|
1516
|
-
const agentMeta = metaMap.get(agentId) || {};
|
|
1517
|
-
let initialLines = [];
|
|
1518
|
-
try {
|
|
1519
|
-
const { loadInternalAgentLogHistory } = require("../../app/chat/internalAgentLogHistory");
|
|
1520
|
-
initialLines = loadInternalAgentLogHistory(currentProjectRootRef.current || props.projectRoot, agentId, {
|
|
1521
|
-
maxEvents: 200,
|
|
1522
|
-
maxLines: 200,
|
|
1523
|
-
});
|
|
1524
|
-
} catch { initialLines = []; }
|
|
1525
|
-
return {
|
|
1526
|
-
mode: "internal",
|
|
1527
|
-
initialLines: [
|
|
1528
|
-
`ufoo internal agent · ${getAgentLabelFor(agentMeta, agentId)}`,
|
|
1529
|
-
`agent: ${agentId}`,
|
|
1530
|
-
"",
|
|
1531
|
-
...initialLines,
|
|
1532
|
-
],
|
|
1533
|
-
};
|
|
1534
|
-
},
|
|
1535
|
-
getChatLogLines: () => {
|
|
1536
|
-
const current = stateRef.current || {};
|
|
1537
|
-
return Array.isArray(current.logLines)
|
|
1538
|
-
? current.logLines.map((item) => chatLogEntryText(item))
|
|
1539
|
-
: [];
|
|
1540
|
-
},
|
|
1541
|
-
getStatusText: () => {
|
|
1542
|
-
const chrome = multiWindowChromeRef.current;
|
|
1543
|
-
return chrome ? chrome.statusText : "";
|
|
1544
|
-
},
|
|
1545
|
-
getPromptPrefix: () => {
|
|
1546
|
-
const chrome = multiWindowChromeRef.current;
|
|
1547
|
-
return chrome ? chrome.promptPrefix : "› ";
|
|
1548
|
-
},
|
|
1549
|
-
getCurrentDraft: () => {
|
|
1550
|
-
const chrome = multiWindowChromeRef.current;
|
|
1551
|
-
return chrome ? chrome.draft : "";
|
|
1552
|
-
},
|
|
1553
|
-
getCursorPos: () => {
|
|
1554
|
-
const chrome = multiWindowChromeRef.current;
|
|
1555
|
-
return chrome ? chrome.cursor : 0;
|
|
1556
|
-
},
|
|
1557
|
-
getCompletions: () => {
|
|
1558
|
-
const chrome = multiWindowChromeRef.current;
|
|
1559
|
-
if (!chrome || !chrome.completions || chrome.completions.length === 0) {
|
|
1560
|
-
return { items: [], index: -1, windowStart: 0, pageSize: 8 };
|
|
1561
|
-
}
|
|
1562
|
-
return {
|
|
1563
|
-
items: chrome.completions,
|
|
1564
|
-
index: chrome.completionIndex,
|
|
1565
|
-
windowStart: chrome.completionWindowStart,
|
|
1566
|
-
pageSize: chrome.completionPageSize || 8,
|
|
1567
|
-
};
|
|
1568
|
-
},
|
|
1569
|
-
getAgentLabel: (id) => {
|
|
1570
|
-
const current = stateRef.current || {};
|
|
1571
|
-
const metaMap = current.activeAgentMeta || new Map();
|
|
1572
|
-
return getAgentLabelFor(metaMap.get(id), id);
|
|
1573
|
-
},
|
|
1574
|
-
getInternalPaneInfo: (id) => {
|
|
1575
|
-
const current = stateRef.current || {};
|
|
1576
|
-
const metaMap = current.activeAgentMeta instanceof Map ? current.activeAgentMeta : new Map();
|
|
1577
|
-
const meta = metaMap.get(id) || {};
|
|
1578
|
-
const status = internalStatusLabel(meta.activity_state || meta.state || "");
|
|
1579
|
-
const detail = String(meta.activity_detail || meta.detail || meta.status_text || "").trim();
|
|
1580
|
-
return {
|
|
1581
|
-
status,
|
|
1582
|
-
detail,
|
|
1583
|
-
input: "",
|
|
1584
|
-
cursor: 0,
|
|
1585
|
-
};
|
|
1586
|
-
},
|
|
1587
|
-
getDashboardLines: () => {
|
|
1588
|
-
const chrome = multiWindowChromeRef.current;
|
|
1589
|
-
return chrome ? chrome.dashboardLines : [];
|
|
1590
|
-
},
|
|
1591
|
-
getTerminalFocused: () => mwTerminalFocusedRef.current,
|
|
1592
|
-
freezeScreen: (frozen) => {
|
|
1593
|
-
if (frozen) {
|
|
1594
|
-
processStdout.write = () => true;
|
|
1595
|
-
} else {
|
|
1596
|
-
processStdout.write = originalWrite;
|
|
1597
|
-
}
|
|
1598
|
-
},
|
|
1599
|
-
restoreTerminal: () => {
|
|
1600
|
-
const rows = processStdout.rows || 24;
|
|
1601
|
-
originalWrite(`\x1b[1;${rows}r`);
|
|
1602
|
-
originalWrite("\x1b[2J\x1b[H");
|
|
1603
|
-
},
|
|
1604
|
-
onInternalSubmit: (agentId, message) => {
|
|
1605
|
-
sendInternalAgentMessage(agentId, message);
|
|
1606
|
-
},
|
|
1607
|
-
onExit: () => {
|
|
1608
|
-
setMultiWindowActive(false);
|
|
1609
|
-
},
|
|
1610
|
-
});
|
|
1611
|
-
return multiWindowControllerRef.current;
|
|
1612
|
-
}, [props.projectRoot, stdout]);
|
|
1613
|
-
|
|
1614
|
-
const toggleMultiWindow = useCallback(() => createInkMultiWindowToggle({
|
|
1615
|
-
getController: getMultiWindowController,
|
|
1616
|
-
setActive: setMultiWindowActive,
|
|
1617
|
-
logMessage: logInkMessage,
|
|
1618
|
-
})(), [getMultiWindowController, logInkMessage]);
|
|
1619
|
-
|
|
1620
|
-
useEffect(() => () => {
|
|
1621
|
-
const controller = multiWindowControllerRef.current;
|
|
1622
|
-
if (controller && typeof controller.exit === "function") {
|
|
1623
|
-
try { controller.exit(); } catch { /* ignore */ }
|
|
1624
|
-
}
|
|
1625
|
-
multiWindowControllerRef.current = null;
|
|
1626
|
-
}, []);
|
|
1627
|
-
|
|
1628
|
-
useEffect(() => {
|
|
1629
|
-
internalAgentViewRef.current = internalAgentView;
|
|
1630
|
-
}, [internalAgentView]);
|
|
1631
|
-
|
|
1632
|
-
useEffect(() => {
|
|
1633
|
-
if (!stdout) return undefined;
|
|
1634
|
-
const update = () => {
|
|
1635
|
-
const next = { cols: stdout.columns || 0, rows: stdout.rows || 0 };
|
|
1636
|
-
setSize((prev) => (prev.cols === next.cols && prev.rows === next.rows ? prev : next));
|
|
1637
|
-
};
|
|
1638
|
-
update();
|
|
1639
|
-
stdout.on("resize", update);
|
|
1640
|
-
return () => stdout.off("resize", update);
|
|
1641
|
-
}, [stdout]);
|
|
1642
|
-
|
|
1643
|
-
// Load persisted input history once on mount.
|
|
1644
|
-
useEffect(() => {
|
|
1645
|
-
try {
|
|
1646
|
-
const history = loadInputHistory(props.projectRoot, 200, { globalMode: props.globalMode });
|
|
1647
|
-
if (history.length > 0) dispatch({ type: "history/load", list: history });
|
|
1648
|
-
} catch { /* ignore */ }
|
|
1649
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1650
|
-
}, []);
|
|
1651
|
-
|
|
1652
|
-
const sendInternalAgentWatch = (agentId, enabled) => {
|
|
1653
|
-
if (!agentId || !props.daemonConnection || typeof props.daemonConnection.send !== "function") return;
|
|
1654
|
-
try {
|
|
1655
|
-
const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
|
|
1656
|
-
props.daemonConnection.send({
|
|
1657
|
-
type: IPC_REQUEST_TYPES.BUS_WATCH,
|
|
1658
|
-
agent_id: agentId,
|
|
1659
|
-
enabled: enabled !== false,
|
|
1660
|
-
});
|
|
1661
|
-
} catch { /* ignore */ }
|
|
1662
|
-
};
|
|
1663
|
-
|
|
1664
|
-
const reconcileMultiWindowInternalWatches = useCallback(() => {
|
|
1665
|
-
const current = stateRef.current || {};
|
|
1666
|
-
const agents = Array.isArray(current.agents) ? current.agents : [];
|
|
1667
|
-
const next = new Set();
|
|
1668
|
-
if (multiWindowActive) {
|
|
1669
|
-
for (const agentId of agents) {
|
|
1670
|
-
const enterRequest = resolveAgentEnterRequest({
|
|
1671
|
-
agentId,
|
|
1672
|
-
projectRoot: currentProjectRootRef.current || props.projectRoot,
|
|
1673
|
-
activeAgentMeta: current.activeAgentMeta,
|
|
1674
|
-
settings: current.settings,
|
|
1675
|
-
});
|
|
1676
|
-
if (enterRequest && enterRequest.useBus) next.add(agentId);
|
|
1677
|
-
}
|
|
1678
|
-
}
|
|
1679
|
-
const previous = multiWindowWatchedInternalAgentsRef.current;
|
|
1680
|
-
for (const agentId of next) {
|
|
1681
|
-
if (!previous.has(agentId)) sendInternalAgentWatch(agentId, true);
|
|
1682
|
-
}
|
|
1683
|
-
for (const agentId of previous) {
|
|
1684
|
-
if (!next.has(agentId)) sendInternalAgentWatch(agentId, false);
|
|
1685
|
-
}
|
|
1686
|
-
multiWindowWatchedInternalAgentsRef.current = next;
|
|
1687
|
-
}, [multiWindowActive, props.projectRoot, props.daemonConnection]);
|
|
1688
|
-
|
|
1689
|
-
useEffect(() => {
|
|
1690
|
-
if (!multiWindowActive) return;
|
|
1691
|
-
const controller = multiWindowControllerRef.current;
|
|
1692
|
-
if (!controller) return;
|
|
1693
|
-
reconcileMultiWindowInternalWatches();
|
|
1694
|
-
if (typeof controller.syncAgents === "function") controller.syncAgents();
|
|
1695
|
-
if (typeof controller.renderAll === "function") controller.renderAll();
|
|
1696
|
-
}, [multiWindowActive, state.agents, state.logLines, state.draft, state.status, size.cols, size.rows, mwCursor, state.focusMode, state.dashboardView, state.selectedAgentIndex, state.selectedProjectIndex, state.selectedModeIndex, state.selectedProviderIndex, state.selectedCronIndex, mwTerminalFocused, reconcileMultiWindowInternalWatches]);
|
|
1697
|
-
|
|
1698
|
-
useEffect(() => {
|
|
1699
|
-
if (multiWindowActive) return;
|
|
1700
|
-
reconcileMultiWindowInternalWatches();
|
|
1701
|
-
}, [multiWindowActive, reconcileMultiWindowInternalWatches]);
|
|
1702
|
-
|
|
1703
|
-
const sendInternalAgentMessage = (agentId, message) => {
|
|
1704
|
-
if (!agentId || !message || !props.daemonConnection || typeof props.daemonConnection.send !== "function") return;
|
|
1705
|
-
try {
|
|
1706
|
-
const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
|
|
1707
|
-
props.daemonConnection.send({
|
|
1708
|
-
type: IPC_REQUEST_TYPES.BUS_SEND,
|
|
1709
|
-
target: agentId,
|
|
1710
|
-
message,
|
|
1711
|
-
injection_mode: "immediate",
|
|
1712
|
-
source: "chat-internal-agent-view",
|
|
1713
|
-
});
|
|
1714
|
-
} catch (err) {
|
|
1715
|
-
setInternalAgentView((prev) => appendInternalAgentText(
|
|
1716
|
-
updateInternalViewStatus(prev, "blocked", err && err.message ? err.message : String(err || "")),
|
|
1717
|
-
`Error: ${err && err.message ? err.message : err}\n`,
|
|
1718
|
-
));
|
|
1719
|
-
}
|
|
1720
|
-
};
|
|
1721
|
-
|
|
1722
|
-
const isInternalAlias = (view, value) => {
|
|
1723
|
-
if (!view || !view.agentId) return false;
|
|
1724
|
-
const text = String(value || "");
|
|
1725
|
-
if (!text) return false;
|
|
1726
|
-
const aliases = new Set((view.aliases || []).concat([view.agentId, view.label]).filter(Boolean).map(String));
|
|
1727
|
-
return aliases.has(text);
|
|
1728
|
-
};
|
|
1729
|
-
|
|
1730
|
-
const buildInternalAgentAliases = (agentId) => {
|
|
1731
|
-
const current = stateRef.current || {};
|
|
1732
|
-
const metaMap = current.activeAgentMeta instanceof Map ? current.activeAgentMeta : new Map();
|
|
1733
|
-
const meta = metaMap.get(agentId) || {};
|
|
1734
|
-
return new Set([
|
|
1735
|
-
agentId,
|
|
1736
|
-
meta.nickname,
|
|
1737
|
-
meta.scoped_nickname,
|
|
1738
|
-
meta.display_nickname,
|
|
1739
|
-
meta.fullId,
|
|
1740
|
-
].filter(Boolean).map(String));
|
|
1741
|
-
};
|
|
1742
|
-
|
|
1743
|
-
const writeMultiWindowInternalEvent = useCallback((data = {}) => {
|
|
1744
|
-
const controller = multiWindowControllerRef.current;
|
|
1745
|
-
if (!multiWindowActive || !controller || typeof controller.writeToPane !== "function") return false;
|
|
1746
|
-
const watched = multiWindowWatchedInternalAgentsRef.current;
|
|
1747
|
-
if (!watched || watched.size === 0) return false;
|
|
1748
|
-
|
|
1749
|
-
let handled = false;
|
|
1750
|
-
for (const agentId of watched) {
|
|
1751
|
-
const aliases = buildInternalAgentAliases(agentId);
|
|
1752
|
-
const publisher = String(data.publisher || (data.event === "broadcast" ? "broadcast" : "bus"));
|
|
1753
|
-
const target = String(data.target || data.subscriber || "");
|
|
1754
|
-
const fromAgent = aliases.has(publisher);
|
|
1755
|
-
const toAgent = aliases.has(target) || aliases.has(String(data.subscriber || ""));
|
|
1756
|
-
if (!fromAgent && !toAgent) continue;
|
|
1757
|
-
if (data.silent) {
|
|
1758
|
-
handled = true;
|
|
1759
|
-
continue;
|
|
1760
|
-
}
|
|
1761
|
-
if (data.source === "chat-internal-agent-view" && toAgent && !fromAgent) {
|
|
1762
|
-
handled = true;
|
|
1763
|
-
continue;
|
|
1764
|
-
}
|
|
1765
|
-
if (data.event === "activity_state_changed") {
|
|
1766
|
-
const state = internalStatusLabel(data.state || data.activity_state || "");
|
|
1767
|
-
const detail = String(data.detail || (data.data && data.data.detail) || data.message || "").trim();
|
|
1768
|
-
controller.writeToPane(agentId, `\r\n[${state}${detail ? ` · ${detail}` : ""}]\r\n`);
|
|
1769
|
-
handled = true;
|
|
1770
|
-
continue;
|
|
1771
|
-
}
|
|
1772
|
-
|
|
1773
|
-
const { displayMessage, streamPayload } = parseInternalBusPayload(data.message || "");
|
|
1774
|
-
if (streamPayload) {
|
|
1775
|
-
if (!fromAgent) {
|
|
1776
|
-
handled = true;
|
|
1777
|
-
continue;
|
|
1778
|
-
}
|
|
1779
|
-
const delta = typeof streamPayload.delta === "string"
|
|
1780
|
-
? streamPayload.delta.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n").replace(/\\r/g, "\n")
|
|
1781
|
-
: "";
|
|
1782
|
-
if (delta) controller.writeToPane(agentId, delta);
|
|
1783
|
-
if (streamPayload.done) controller.writeToPane(agentId, "\r\n");
|
|
1784
|
-
handled = true;
|
|
1785
|
-
continue;
|
|
1786
|
-
}
|
|
1787
|
-
if (!displayMessage) {
|
|
1788
|
-
handled = true;
|
|
1789
|
-
continue;
|
|
1790
|
-
}
|
|
1791
|
-
const prefix = fromAgent ? "* " : "> ";
|
|
1792
|
-
controller.writeToPane(agentId, `${prefix}${displayMessage.replace(/\n/g, `\r\n `)}\r\n`);
|
|
1793
|
-
handled = true;
|
|
1794
|
-
}
|
|
1795
|
-
return handled;
|
|
1796
|
-
}, [multiWindowActive]);
|
|
1797
|
-
|
|
1798
|
-
const handleInternalStatus = (data = {}) => {
|
|
1799
|
-
const view = internalAgentViewRef.current;
|
|
1800
|
-
if (!view || !view.agentId) return;
|
|
1801
|
-
const metaList = Array.isArray(data.active_meta) ? data.active_meta : [];
|
|
1802
|
-
for (const meta of metaList) {
|
|
1803
|
-
const metaId = meta && (meta.fullId || meta.subscriber_id || meta.id) ? String(meta.fullId || meta.subscriber_id || meta.id) : "";
|
|
1804
|
-
const typedId = meta && meta.type && meta.id ? `${meta.type}:${meta.id}` : "";
|
|
1805
|
-
if (!isInternalAlias(view, metaId) && !isInternalAlias(view, typedId)) continue;
|
|
1806
|
-
const status = internalStatusLabel(meta.activity_state || meta.state || "");
|
|
1807
|
-
const detail = String(meta.activity_detail || meta.detail || meta.status_text || "").trim();
|
|
1808
|
-
setInternalAgentView((prev) => (
|
|
1809
|
-
prev.agentId === view.agentId ? updateInternalViewStatus(prev, status, detail) : prev
|
|
1810
|
-
));
|
|
1811
|
-
return;
|
|
1812
|
-
}
|
|
1813
|
-
};
|
|
1814
|
-
|
|
1815
|
-
const handleInternalBusMessage = (data = {}) => {
|
|
1816
|
-
const view = internalAgentViewRef.current;
|
|
1817
|
-
if (!view || !view.agentId) return false;
|
|
1818
|
-
if (data.event === "activity_state_changed") {
|
|
1819
|
-
const actor = String(data.subscriber || data.publisher || "").trim();
|
|
1820
|
-
if (!isInternalAlias(view, actor)) return false;
|
|
1821
|
-
setInternalAgentView((prev) => (
|
|
1822
|
-
prev.agentId === view.agentId
|
|
1823
|
-
? {
|
|
1824
|
-
...updateInternalViewStatus(
|
|
1825
|
-
prev,
|
|
1826
|
-
data.state || data.activity_state || "",
|
|
1827
|
-
data.detail || (data.data && data.data.detail) || data.message || "",
|
|
1828
|
-
),
|
|
1829
|
-
}
|
|
1830
|
-
: prev
|
|
1831
|
-
));
|
|
1832
|
-
return true;
|
|
1833
|
-
}
|
|
1834
|
-
const publisher = String(data.publisher || (data.event === "broadcast" ? "broadcast" : "bus"));
|
|
1835
|
-
const target = String(data.target || data.subscriber || "");
|
|
1836
|
-
const fromAgent = isInternalAlias(view, publisher);
|
|
1837
|
-
const toAgent = isInternalAlias(view, target);
|
|
1838
|
-
if (!fromAgent && !toAgent) return false;
|
|
1839
|
-
if (data.silent) return true;
|
|
1840
|
-
if (data.source === "chat-internal-agent-view" && toAgent && !fromAgent) return true;
|
|
1841
|
-
|
|
1842
|
-
const { displayMessage, streamPayload } = parseInternalBusPayload(data.message || "");
|
|
1843
|
-
if (streamPayload) {
|
|
1844
|
-
if (!fromAgent) return true;
|
|
1845
|
-
const delta = typeof streamPayload.delta === "string"
|
|
1846
|
-
? streamPayload.delta.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n").replace(/\\r/g, "\n")
|
|
1847
|
-
: "";
|
|
1848
|
-
if (delta) {
|
|
1849
|
-
setInternalAgentView((prev) => (
|
|
1850
|
-
prev.agentId === view.agentId
|
|
1851
|
-
? updateInternalViewStatus(
|
|
1852
|
-
appendInternalAgentText(prev, delta, { prefix: "* " }),
|
|
1853
|
-
streamPayload.done ? "ready" : "working",
|
|
1854
|
-
streamPayload.reason || prev.detail || "",
|
|
1855
|
-
)
|
|
1856
|
-
: prev
|
|
1857
|
-
));
|
|
1858
|
-
} else if (streamPayload.done) {
|
|
1859
|
-
setInternalAgentView((prev) => (
|
|
1860
|
-
prev.agentId === view.agentId ? updateInternalViewStatus(prev, "ready", "") : prev
|
|
1861
|
-
));
|
|
1862
|
-
}
|
|
1863
|
-
return true;
|
|
1864
|
-
}
|
|
1865
|
-
if (!displayMessage) return true;
|
|
1866
|
-
setInternalAgentView((prev) => {
|
|
1867
|
-
if (prev.agentId !== view.agentId) return prev;
|
|
1868
|
-
const next = fromAgent
|
|
1869
|
-
? appendInternalAgentText(prev, `${displayMessage}\n`, { prefix: "* " })
|
|
1870
|
-
: appendInternalAgentText(prev, `${displayMessage}\n`, { prefix: "> " });
|
|
1871
|
-
return fromAgent ? updateInternalViewStatus(next, "ready", "") : next;
|
|
1872
|
-
});
|
|
1873
|
-
return true;
|
|
1874
|
-
};
|
|
1875
|
-
|
|
1876
|
-
const handleInternalErrorMessage = (message = "") => {
|
|
1877
|
-
const view = internalAgentViewRef.current;
|
|
1878
|
-
if (!view || !view.agentId) return false;
|
|
1879
|
-
setInternalAgentView((prev) => (
|
|
1880
|
-
appendInternalErrorToView(prev, view.agentId, message)
|
|
1881
|
-
));
|
|
1882
|
-
return true;
|
|
1883
|
-
};
|
|
1884
|
-
|
|
1885
|
-
const handleInternalSendOk = () => {
|
|
1886
|
-
const view = internalAgentViewRef.current;
|
|
1887
|
-
if (!view || !view.agentId) return false;
|
|
1888
|
-
setInternalAgentView((prev) => (
|
|
1889
|
-
prev.agentId === view.agentId ? updateInternalViewStatus(prev, "ready", "") : prev
|
|
1890
|
-
));
|
|
1891
|
-
return true;
|
|
1892
|
-
};
|
|
1893
|
-
|
|
1894
|
-
// STATUS requests arrive in bursts (every bus message routes through
|
|
1895
|
-
// the router's requestStatus plus command callbacks); each response
|
|
1896
|
-
// dispatches dashboard updates. Coalesce bursts into one send per
|
|
1897
|
-
// window so a busy bus can't spin the render loop.
|
|
1898
|
-
const statusRequestThrottlerRef = useRef(null);
|
|
1899
|
-
if (!statusRequestThrottlerRef.current) {
|
|
1900
|
-
statusRequestThrottlerRef.current = createThrottledSender(() => {
|
|
1901
|
-
try {
|
|
1902
|
-
const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
|
|
1903
|
-
const conn = props.daemonConnection;
|
|
1904
|
-
if (conn && typeof conn.send === "function") conn.send({ type: IPC_REQUEST_TYPES.STATUS });
|
|
1905
|
-
} catch { /* ignore */ }
|
|
1906
|
-
}, 500);
|
|
1907
|
-
}
|
|
1908
|
-
const requestDaemonStatus = useCallback(() => {
|
|
1909
|
-
const throttled = statusRequestThrottlerRef.current;
|
|
1910
|
-
if (throttled) throttled();
|
|
1911
|
-
}, []);
|
|
1912
|
-
|
|
1913
|
-
const updateDashboardFromStatus = useCallback((data = {}) => {
|
|
1914
|
-
const activeIds = Array.isArray(data.active) ? data.active : [];
|
|
1915
|
-
const metaList = Array.isArray(data.active_meta) ? data.active_meta : [];
|
|
1916
|
-
const { buildAgentMaps } = require("../../app/chat/agentDirectory");
|
|
1917
|
-
const { labelMap, metaMap } = buildAgentMaps(activeIds, metaList);
|
|
1918
|
-
const agentsForDispatch = activeIds.map((id) => {
|
|
1919
|
-
const meta = metaMap.get(id) || {};
|
|
1920
|
-
const colon = id.indexOf(":");
|
|
1921
|
-
const fallbackType = colon > 0 ? id.slice(0, colon) : id;
|
|
1922
|
-
const fallbackId = colon > 0 ? id.slice(colon + 1) : "";
|
|
1923
|
-
return {
|
|
1924
|
-
...meta,
|
|
1925
|
-
fullId: id,
|
|
1926
|
-
type: meta.type || fallbackType,
|
|
1927
|
-
id: meta.id || fallbackId,
|
|
1928
|
-
nickname: labelMap.get(id) || id,
|
|
1929
|
-
};
|
|
1930
|
-
});
|
|
1931
|
-
dispatch({ type: "agents/set", list: agentsForDispatch });
|
|
1932
|
-
if (data.cron && Array.isArray(data.cron.tasks)) {
|
|
1933
|
-
dispatch({ type: "cron/set", list: data.cron.tasks });
|
|
1934
|
-
}
|
|
1935
|
-
dispatch({ type: "loop/set", summary: data.loop || null });
|
|
1936
|
-
handleInternalStatus(data);
|
|
1937
|
-
}, []);
|
|
1938
|
-
|
|
1939
|
-
// Wire daemon: register a message handler that turns IPC responses
|
|
1940
|
-
// through the same daemonMessageRouter blessed uses, then adapts the
|
|
1941
|
-
// blessed callbacks to Ink state updates.
|
|
1942
|
-
useEffect(() => {
|
|
1943
|
-
if (!interactive) return undefined;
|
|
1944
|
-
const conn = props.daemonConnection;
|
|
1945
|
-
const setHandler = props.setDaemonMessageHandler;
|
|
1946
|
-
if (!conn || typeof conn.connect !== "function" || typeof setHandler !== "function") {
|
|
1947
|
-
return undefined;
|
|
1948
|
-
}
|
|
1949
|
-
const { IPC_RESPONSE_TYPES } = require("../../runtime/contracts/eventContract");
|
|
1950
|
-
const { createDaemonMessageRouter } = require("../../app/chat/daemonMessageRouter");
|
|
1951
|
-
const streamState = streamStateRef.current;
|
|
1952
|
-
const router = createDaemonMessageRouter({
|
|
1953
|
-
escapeBlessed: (value) => String(value == null ? "" : value),
|
|
1954
|
-
stripBlessedTags,
|
|
1955
|
-
logMessage: logInkMessage,
|
|
1956
|
-
renderScreen: () => {},
|
|
1957
|
-
updateDashboard: updateDashboardFromStatus,
|
|
1958
|
-
requestStatus: requestDaemonStatus,
|
|
1959
|
-
resolveStatusLine: (text, data = {}) => {
|
|
1960
|
-
// Terminal status: static (no UFO) and auto-revert to Ready.
|
|
1961
|
-
setStatusText(text, {
|
|
1962
|
-
type: data && data.phase === "error" ? "error" : "none",
|
|
1963
|
-
showTimer: false,
|
|
1964
|
-
});
|
|
1965
|
-
scheduleStatusAutoClear();
|
|
1966
|
-
},
|
|
1967
|
-
enqueueBusStatus: (item = {}) => setStatusText(item.text || "Processing bus message", { type: "typing" }),
|
|
1968
|
-
resolveBusStatus: (item = {}) => {
|
|
1969
|
-
setStatusText(item.text || "Bus message processed", { type: "done" });
|
|
1970
|
-
scheduleStatusAutoClear();
|
|
1971
|
-
},
|
|
1972
|
-
getPending: () => pendingRef.current,
|
|
1973
|
-
setPending: (value) => { pendingRef.current = value || null; },
|
|
1974
|
-
resolveAgentDisplayName: (value) => {
|
|
1975
|
-
const current = stateRef.current || {};
|
|
1976
|
-
const meta = current.activeAgentMeta instanceof Map ? current.activeAgentMeta.get(value) : null;
|
|
1977
|
-
return getAgentLabelFor(meta, value);
|
|
1978
|
-
},
|
|
1979
|
-
getCurrentView: () => {
|
|
1980
|
-
const current = stateRef.current || {};
|
|
1981
|
-
return current.viewingAgentId ? "agent" : "main";
|
|
1982
|
-
},
|
|
1983
|
-
isAgentViewUsesBus: () => Boolean(internalAgentViewRef.current && internalAgentViewRef.current.agentId),
|
|
1984
|
-
getViewingAgent: () => {
|
|
1985
|
-
const current = stateRef.current || {};
|
|
1986
|
-
return current.viewingAgentId || (internalAgentViewRef.current && internalAgentViewRef.current.agentId) || "";
|
|
1987
|
-
},
|
|
1988
|
-
isAgentEventForViewingAgent: (data, viewingAgent, publisher) => {
|
|
1989
|
-
const view = internalAgentViewRef.current || {};
|
|
1990
|
-
if (!view.agentId && !viewingAgent) return false;
|
|
1991
|
-
const candidates = [
|
|
1992
|
-
viewingAgent,
|
|
1993
|
-
publisher,
|
|
1994
|
-
data && data.publisher,
|
|
1995
|
-
data && data.target,
|
|
1996
|
-
data && data.subscriber,
|
|
1997
|
-
];
|
|
1998
|
-
return candidates.some((candidate) => isInternalAlias(view, candidate));
|
|
1999
|
-
},
|
|
2000
|
-
writeToAgentTerm: (text, meta = {}) => {
|
|
2001
|
-
const view = internalAgentViewRef.current;
|
|
2002
|
-
if (!view || !view.agentId) return;
|
|
2003
|
-
setInternalAgentView((prev) => (
|
|
2004
|
-
applyInternalAgentTermWrite(prev, view.agentId, text, meta)
|
|
2005
|
-
));
|
|
2006
|
-
},
|
|
2007
|
-
consumePendingDelivery: (...args) => streamState.consumePendingDelivery(...args),
|
|
2008
|
-
getPendingState: (...args) => streamState.getPendingState(...args),
|
|
2009
|
-
beginStream: (...args) => streamState.beginStream(...args),
|
|
2010
|
-
appendStreamDelta: (...args) => streamState.appendStreamDelta(...args),
|
|
2011
|
-
finalizeStream: (...args) => streamState.finalizeStream(...args),
|
|
2012
|
-
hasStream: (...args) => streamState.hasStream(...args),
|
|
2013
|
-
setTransientAgentState: (agentId, value, options = {}) => {
|
|
2014
|
-
if (!agentId || !value) return;
|
|
2015
|
-
const detail = options.detail || "";
|
|
2016
|
-
// Activity updates fire per agent event; skip the dispatch (and
|
|
2017
|
-
// the re-render it implies) when nothing actually changed.
|
|
2018
|
-
const current = stateRef.current || {};
|
|
2019
|
-
const metaMap = current.activeAgentMeta instanceof Map ? current.activeAgentMeta : null;
|
|
2020
|
-
const existing = (metaMap && metaMap.get(agentId)) || {};
|
|
2021
|
-
if (existing.activity_state === value && String(existing.activity_detail || "") === detail) {
|
|
2022
|
-
return;
|
|
2023
|
-
}
|
|
2024
|
-
dispatch({
|
|
2025
|
-
type: "agents/patchMeta",
|
|
2026
|
-
agentId,
|
|
2027
|
-
patch: {
|
|
2028
|
-
activity_state: value,
|
|
2029
|
-
activity_detail: detail,
|
|
2030
|
-
},
|
|
2031
|
-
});
|
|
2032
|
-
},
|
|
2033
|
-
clearTransientAgentState: (agentId) => {
|
|
2034
|
-
if (!agentId) return;
|
|
2035
|
-
const current = stateRef.current || {};
|
|
2036
|
-
const metaMap = current.activeAgentMeta instanceof Map ? current.activeAgentMeta : null;
|
|
2037
|
-
const existing = (metaMap && metaMap.get(agentId)) || {};
|
|
2038
|
-
if (!existing.activity_state && !existing.activity_detail) return;
|
|
2039
|
-
dispatch({
|
|
2040
|
-
type: "agents/patchMeta",
|
|
2041
|
-
agentId,
|
|
2042
|
-
patch: {
|
|
2043
|
-
activity_state: "",
|
|
2044
|
-
activity_detail: "",
|
|
2045
|
-
},
|
|
2046
|
-
});
|
|
2047
|
-
},
|
|
2048
|
-
refreshDashboard: () => {},
|
|
2049
|
-
});
|
|
2050
|
-
setHandler((msg) => {
|
|
2051
|
-
if (!msg || typeof msg !== "object") return;
|
|
2052
|
-
if (msg.type === IPC_RESPONSE_TYPES.ERROR && handleInternalErrorMessage(msg.error || "unknown error")) {
|
|
2053
|
-
return;
|
|
2054
|
-
}
|
|
2055
|
-
if (msg.type === IPC_RESPONSE_TYPES.BUS_SEND_OK) {
|
|
2056
|
-
if (handleInternalSendOk()) return;
|
|
2057
|
-
const text = `✓ Message delivered`;
|
|
2058
|
-
logInkMessage("system", text);
|
|
2059
|
-
dispatch({ type: "status/idle" });
|
|
2060
|
-
requestDaemonStatus();
|
|
2061
|
-
return;
|
|
2062
|
-
}
|
|
2063
|
-
if (msg.type === IPC_RESPONSE_TYPES.BUS) {
|
|
2064
|
-
writeMultiWindowInternalEvent(msg.data || {});
|
|
2065
|
-
}
|
|
2066
|
-
router.handleMessage(msg);
|
|
2067
|
-
});
|
|
2068
|
-
conn.connect();
|
|
2069
|
-
return () => {
|
|
2070
|
-
try { if (typeof conn.close === "function") conn.close(); } catch { /* ignore */ }
|
|
2071
|
-
};
|
|
2072
|
-
}, [interactive, logInkMessage, requestDaemonStatus, setStatusText, updateDashboardFromStatus, writeMultiWindowInternalEvent]);
|
|
2073
|
-
|
|
2074
|
-
// commandExecutor wiring. The blessed implementation reuses this
|
|
2075
|
-
// module to dispatch every slash command (~30 callbacks). We adapt
|
|
2076
|
-
// the callback surface to ink: log/status/render writes go through
|
|
2077
|
-
// dispatch, daemon ops go through props.daemonConnection, and
|
|
2078
|
-
// blessed-tag markup the executor sprinkles into log lines is
|
|
2079
|
-
// stripped before rendering.
|
|
2080
|
-
const commandExecutorRef = useRef(null);
|
|
2081
|
-
useEffect(() => {
|
|
2082
|
-
if (!interactive) return undefined;
|
|
2083
|
-
const { createCommandExecutor } = require("../../app/chat/commandExecutor");
|
|
2084
|
-
const { parseCommand: parseCmd } = require("../../app/chat/commands");
|
|
2085
|
-
const { startDaemon: transportStartDaemon, stopDaemon: transportStopDaemon } = require("../../app/chat/transport");
|
|
2086
|
-
const AgentActivator = require("../../coordination/bus/activate");
|
|
2087
|
-
const conn = props.daemonConnection;
|
|
2088
|
-
|
|
2089
|
-
try {
|
|
2090
|
-
commandExecutorRef.current = createCommandExecutor({
|
|
2091
|
-
projectRoot: props.projectRoot,
|
|
2092
|
-
getActiveProjectRoot: () => currentProjectRootRef.current || props.projectRoot,
|
|
2093
|
-
parseCommand: parseCmd,
|
|
2094
|
-
escapeBlessed: (v) => String(v == null ? "" : v),
|
|
2095
|
-
logMessage: logInkMessage,
|
|
2096
|
-
resolveStatusLine: (text) => setStatusText(text),
|
|
2097
|
-
renderScreen: () => {},
|
|
2098
|
-
clearLog: () => {
|
|
2099
|
-
// Clear the persisted chat history file so reopening the chat
|
|
2100
|
-
// doesn't reload old messages.
|
|
2101
|
-
try {
|
|
2102
|
-
const root = currentProjectRootRef.current || props.projectRoot;
|
|
2103
|
-
const historyOptions = chatHistoryOptionsForScope({
|
|
2104
|
-
globalMode: props.globalMode,
|
|
2105
|
-
globalScope: (stateRef.current && stateRef.current.globalScope) || "controller",
|
|
2106
|
-
});
|
|
2107
|
-
const file = chatHistoryFilePath(root, historyOptions);
|
|
2108
|
-
if (file && fs.existsSync(file)) fs.writeFileSync(file, "");
|
|
2109
|
-
} catch { /* ignore */ }
|
|
2110
|
-
// ink redraws by erasing only as many lines as the last frame
|
|
2111
|
-
// emitted. After log/clear the next frame is shorter, so the
|
|
2112
|
-
// older log lines remain in the terminal scrollback. Wipe the
|
|
2113
|
-
// visible screen + scrollback first, then dispatch — ink will
|
|
2114
|
-
// repaint the (now small) frame onto a clean buffer.
|
|
2115
|
-
try {
|
|
2116
|
-
const out = (typeof process !== "undefined" && process.stdout) || null;
|
|
2117
|
-
if (out && out.isTTY && typeof out.write === "function") {
|
|
2118
|
-
out.write("\x1b[2J\x1b[3J\x1b[H");
|
|
2119
|
-
}
|
|
2120
|
-
} catch { /* ignore */ }
|
|
2121
|
-
dispatch({ type: "log/clear" });
|
|
2122
|
-
},
|
|
2123
|
-
getActiveAgents: () => (stateRef.current && stateRef.current.agents) || [],
|
|
2124
|
-
getActiveAgentMetaMap: () => (stateRef.current && stateRef.current.activeAgentMeta) || new Map(),
|
|
2125
|
-
getAgentLabel: (id) => {
|
|
2126
|
-
const metaMap = (stateRef.current && stateRef.current.activeAgentMeta) || new Map();
|
|
2127
|
-
return getAgentLabelFor(metaMap.get(id), id);
|
|
2128
|
-
},
|
|
2129
|
-
isDaemonRunning: (root) => props.env && props.env.isRunning ? props.env.isRunning(root || props.projectRoot) : true,
|
|
2130
|
-
startDaemon: (root, options = {}) => {
|
|
2131
|
-
const targetRoot = root || props.projectRoot;
|
|
2132
|
-
if (props.env && typeof props.env.startDaemon === "function") return props.env.startDaemon(targetRoot, options);
|
|
2133
|
-
return transportStartDaemon(targetRoot, options);
|
|
2134
|
-
},
|
|
2135
|
-
stopDaemon: (root, options = {}) => transportStopDaemon(root || props.projectRoot, options),
|
|
2136
|
-
restartDaemon: async (root) => {
|
|
2137
|
-
const targetRoot = root || currentProjectRootRef.current || props.projectRoot;
|
|
2138
|
-
if (
|
|
2139
|
-
targetRoot === (currentProjectRootRef.current || props.projectRoot) &&
|
|
2140
|
-
props.daemonCoordinator &&
|
|
2141
|
-
typeof props.daemonCoordinator.restart === "function"
|
|
2142
|
-
) {
|
|
2143
|
-
await props.daemonCoordinator.restart();
|
|
2144
|
-
return;
|
|
2145
|
-
}
|
|
2146
|
-
try { if (conn && typeof conn.close === "function") conn.close(); } catch { /* ignore */ }
|
|
2147
|
-
await restartDaemonLifecycle({
|
|
2148
|
-
projectRoot: targetRoot,
|
|
2149
|
-
isRunning: props.env && typeof props.env.isRunning === "function" ? props.env.isRunning : null,
|
|
2150
|
-
stopDaemon: (daemonRoot) => transportStopDaemon(daemonRoot, { source: "ink-command:/daemon restart" }),
|
|
2151
|
-
startDaemon: (daemonRoot) => transportStartDaemon(daemonRoot),
|
|
2152
|
-
connect: targetRoot === (currentProjectRootRef.current || props.projectRoot) && conn && typeof conn.connect === "function"
|
|
2153
|
-
? () => conn.connect()
|
|
2154
|
-
: null,
|
|
2155
|
-
});
|
|
2156
|
-
},
|
|
2157
|
-
send: (req) => { try { if (conn && typeof conn.send === "function") conn.send(req); } catch { /* ignore */ } },
|
|
2158
|
-
requestStatus: requestDaemonStatus,
|
|
2159
|
-
requestCron: (payload = {}) => {
|
|
2160
|
-
try {
|
|
2161
|
-
const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
|
|
2162
|
-
if (conn && typeof conn.send === "function") {
|
|
2163
|
-
conn.send({ type: IPC_REQUEST_TYPES.CRON, ...payload });
|
|
2164
|
-
}
|
|
2165
|
-
} catch { /* ignore */ }
|
|
2166
|
-
},
|
|
2167
|
-
activateAgent: async (target) => {
|
|
2168
|
-
const activator = new AgentActivator(currentProjectRootRef.current || props.projectRoot);
|
|
2169
|
-
await activator.activate(target);
|
|
2170
|
-
},
|
|
2171
|
-
globalMode: Boolean(props.globalMode),
|
|
2172
|
-
listProjects: () => (stateRef.current && stateRef.current.projects) || [],
|
|
2173
|
-
getCurrentProject: () => ({ project_root: currentProjectRootRef.current || props.projectRoot }),
|
|
2174
|
-
switchProject: async (target) => {
|
|
2175
|
-
const rawTarget = String((target && (target.projectRoot || target.project_root || target.target)) || target || "").trim();
|
|
2176
|
-
let targetRoot = rawTarget;
|
|
2177
|
-
if (/^\d+$/.test(rawTarget)) {
|
|
2178
|
-
const idx = Number.parseInt(rawTarget, 10) - 1;
|
|
2179
|
-
const projects = (stateRef.current && stateRef.current.projects) || [];
|
|
2180
|
-
targetRoot = resolveProjectRowRoot(projects[idx]);
|
|
2181
|
-
}
|
|
2182
|
-
const switchProject = switchToProjectRootRef.current;
|
|
2183
|
-
if (typeof switchProject !== "function") {
|
|
2184
|
-
return { ok: false, error: "project switching unavailable" };
|
|
2185
|
-
}
|
|
2186
|
-
return switchProject(targetRoot, { focusInput: true });
|
|
2187
|
-
},
|
|
2188
|
-
toggleMultiWindow,
|
|
2189
|
-
});
|
|
2190
|
-
} catch (err) {
|
|
2191
|
-
dispatch({ type: "log/append", text: `Error: command executor unavailable (${err && err.message ? err.message : err})` });
|
|
2192
|
-
}
|
|
2193
|
-
return undefined;
|
|
2194
|
-
}, [interactive, logInkMessage, requestDaemonStatus, setStatusText, toggleMultiWindow]);
|
|
2195
|
-
|
|
2196
|
-
// Periodic STATUS poll to keep the agents footer fresh, mirroring
|
|
2197
|
-
// blessed's requestStatus on a timer.
|
|
2198
|
-
useEffect(() => {
|
|
2199
|
-
if (!interactive) return undefined;
|
|
2200
|
-
const conn = props.daemonConnection;
|
|
2201
|
-
if (!conn || typeof conn.send !== "function") return undefined;
|
|
2202
|
-
const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
|
|
2203
|
-
const tick = () => {
|
|
2204
|
-
try { conn.send({ type: IPC_REQUEST_TYPES.STATUS }); } catch { /* ignore */ }
|
|
2205
|
-
};
|
|
2206
|
-
tick();
|
|
2207
|
-
const timer = setInterval(tick, 3000);
|
|
2208
|
-
return () => clearInterval(timer);
|
|
2209
|
-
}, [interactive]);
|
|
2210
|
-
|
|
2211
|
-
// Refresh the project rail in global mode. blessed pulls this off the
|
|
2212
|
-
// local registry; we do the same so the dashboard's first row tracks
|
|
2213
|
-
// every running project without needing a daemon round-trip.
|
|
2214
|
-
const refreshGlobalProjects = useCallback((activeRoot = currentProjectRoot) => {
|
|
2215
|
-
if (!props.globalMode) return [];
|
|
2216
|
-
const list = loadGlobalProjectRows(activeRoot);
|
|
2217
|
-
dispatch({
|
|
2218
|
-
type: "projects/set",
|
|
2219
|
-
list,
|
|
2220
|
-
activeProjectRoot: activeRoot,
|
|
2221
|
-
});
|
|
2222
|
-
return list;
|
|
2223
|
-
}, [props.globalMode, currentProjectRoot]);
|
|
2224
|
-
|
|
2225
|
-
useEffect(() => {
|
|
2226
|
-
if (!interactive || !props.globalMode) return undefined;
|
|
2227
|
-
const refresh = () => {
|
|
2228
|
-
try { refreshGlobalProjects(currentProjectRoot); } catch { /* ignore */ }
|
|
2229
|
-
};
|
|
2230
|
-
refresh();
|
|
2231
|
-
const timer = setInterval(refresh, 4000);
|
|
2232
|
-
return () => clearInterval(timer);
|
|
2233
|
-
}, [interactive, props.globalMode, currentProjectRoot, refreshGlobalProjects]);
|
|
2234
|
-
|
|
2235
|
-
const selectedProject = state.selectedProjectIndex >= 0 ? state.projects[state.selectedProjectIndex] : null;
|
|
2236
|
-
const selectedProjectRoot = state.selectedProjectRoot || resolveProjectRowRoot(selectedProject);
|
|
2237
|
-
const currentProject = state.projects.find((row) => resolveProjectRowRoot(row) === currentProjectRoot) || null;
|
|
2238
|
-
const currentProjectLabel = currentProject
|
|
2239
|
-
? String(currentProject.label || currentProject.project_name || path.basename(currentProjectRoot) || currentProjectRoot)
|
|
2240
|
-
: "";
|
|
2241
|
-
const inCommittedProjectScope = Boolean(props.globalMode && state.globalScope === "project" && currentProjectRoot);
|
|
2242
|
-
const displayAgents = state.agents;
|
|
2243
|
-
const displayAgentMeta = state.activeAgentMeta;
|
|
2244
|
-
const targetAgentId = state.agentSelectionMode && state.selectedAgentIndex >= 0
|
|
2245
|
-
? displayAgents[state.selectedAgentIndex]
|
|
2246
|
-
: null;
|
|
2247
|
-
const targetAgentMeta = targetAgentId ? displayAgentMeta.get(targetAgentId) : null;
|
|
2248
|
-
const targetAgentLabel = targetAgentId ? getAgentLabelFor(targetAgentMeta, targetAgentId) : "";
|
|
2249
|
-
const restartDaemonBestEffort = useCallback(() => {
|
|
2250
|
-
const coordinator = props.daemonCoordinator;
|
|
2251
|
-
if (coordinator && typeof coordinator.restart === "function") {
|
|
2252
|
-
Promise.resolve(coordinator.restart()).catch((err) => {
|
|
2253
|
-
dispatch({ type: "log/append", text: `Error: ${err && err.message ? err.message : err}` });
|
|
2254
|
-
});
|
|
2255
|
-
return;
|
|
2256
|
-
}
|
|
2257
|
-
const conn = props.daemonConnection;
|
|
2258
|
-
try { if (conn && typeof conn.close === "function") conn.close(); } catch { /* ignore */ }
|
|
2259
|
-
try { if (conn && typeof conn.connect === "function") conn.connect(); } catch { /* ignore */ }
|
|
2260
|
-
}, []);
|
|
2261
|
-
|
|
2262
|
-
const persistSetting = useCallback((patch, statusText, restart = false) => {
|
|
2263
|
-
try {
|
|
2264
|
-
const { saveConfig } = require("../../config");
|
|
2265
|
-
saveConfig(props.projectRoot, patch);
|
|
2266
|
-
} catch (err) {
|
|
2267
|
-
dispatch({ type: "log/append", text: `Error: ${err && err.message ? err.message : err}` });
|
|
2268
|
-
}
|
|
2269
|
-
if (statusText) {
|
|
2270
|
-
setStatusText(statusText, { type: "typing", showTimer: false });
|
|
2271
|
-
}
|
|
2272
|
-
if (restart) restartDaemonBestEffort();
|
|
2273
|
-
}, [restartDaemonBestEffort, setStatusText]);
|
|
2274
|
-
|
|
2275
|
-
const clearUfooAgentIdentity = useCallback(() => {
|
|
2276
|
-
try {
|
|
2277
|
-
const { getUfooPaths } = require("../../coordination/state/paths");
|
|
2278
|
-
const agentDir = getUfooPaths(props.projectRoot).agentDir;
|
|
2279
|
-
fs.rmSync(path.join(agentDir, "ufoo-agent.json"), { force: true });
|
|
2280
|
-
fs.rmSync(path.join(agentDir, "ufoo-agent.history.jsonl"), { force: true });
|
|
2281
|
-
} catch { /* ignore */ }
|
|
2282
|
-
}, []);
|
|
2283
|
-
|
|
2284
|
-
const applySelectedMode = useCallback(() => {
|
|
2285
|
-
const { normalizeLaunchMode } = require("../../config");
|
|
2286
|
-
const mode = normalizeLaunchMode(state.modeOptions[state.selectedModeIndex]);
|
|
2287
|
-
dispatch({ type: "settings/applyMode" });
|
|
2288
|
-
persistSetting({ launchMode: mode }, `Launch mode: ${mode}`, true);
|
|
2289
|
-
dispatch({ type: "focus/set", mode: "input" });
|
|
2290
|
-
}, [state.modeOptions, state.selectedModeIndex, persistSetting]);
|
|
2291
|
-
|
|
2292
|
-
const applySelectedProvider = useCallback(() => {
|
|
2293
|
-
const { normalizeAgentProvider } = require("../../config");
|
|
2294
|
-
const selected = state.providerOptions[state.selectedProviderIndex];
|
|
2295
|
-
const provider = normalizeAgentProvider(selected && selected.value);
|
|
2296
|
-
dispatch({ type: "settings/applyProvider" });
|
|
2297
|
-
clearUfooAgentIdentity();
|
|
2298
|
-
persistSetting({ agentProvider: provider }, `ufoo-agent: ${provider === "claude-cli" ? "claude" : "codex"}`, true);
|
|
2299
|
-
dispatch({ type: "focus/set", mode: "input" });
|
|
2300
|
-
}, [state.providerOptions, state.selectedProviderIndex, clearUfooAgentIdentity, persistSetting]);
|
|
2301
|
-
|
|
2302
|
-
const sendCronStop = useCallback((taskId) => {
|
|
2303
|
-
if (!taskId || !props.daemonConnection || typeof props.daemonConnection.send !== "function") return;
|
|
2304
|
-
try {
|
|
2305
|
-
const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
|
|
2306
|
-
props.daemonConnection.send({ type: IPC_REQUEST_TYPES.CRON, operation: "stop", id: taskId });
|
|
2307
|
-
} catch (err) {
|
|
2308
|
-
dispatch({ type: "log/append", text: `Error: ${err && err.message ? err.message : err}` });
|
|
2309
|
-
}
|
|
2310
|
-
}, []);
|
|
2311
|
-
|
|
2312
|
-
const switchToProjectRoot = useCallback(async (targetRoot, options = {}) => {
|
|
2313
|
-
const root = String(targetRoot || "").trim();
|
|
2314
|
-
if (!root) return { ok: false, error: "project root unavailable" };
|
|
2315
|
-
if (props.globalMode && props.env && typeof props.env.isRunning === "function" && !props.env.isRunning(root)) {
|
|
2316
|
-
try {
|
|
2317
|
-
const { markProjectStopped } = require("../../runtime/projects");
|
|
2318
|
-
markProjectStopped(root);
|
|
2319
|
-
} catch { /* ignore */ }
|
|
2320
|
-
refreshGlobalProjects(currentProjectRoot);
|
|
2321
|
-
dispatch({ type: "projects/clearSelection" });
|
|
2322
|
-
dispatch({ type: "focus/set", mode: "input" });
|
|
2323
|
-
const label = path.basename(root) || root;
|
|
2324
|
-
const result = { ok: false, error: `project is not running: ${label}`, stopped: true };
|
|
2325
|
-
dispatch({ type: "log/append", text: `Project ${label} is not running; removed stale dashboard entry` });
|
|
2326
|
-
return result;
|
|
2327
|
-
}
|
|
2328
|
-
const focusInput = options.focusInput === true;
|
|
2329
|
-
const selected = state.projects.find((row) => resolveProjectRowRoot(row) === root) || {};
|
|
2330
|
-
dispatch({ type: "log/clear" });
|
|
2331
|
-
const banner = buildChatBannerLines({
|
|
2332
|
-
...props,
|
|
2333
|
-
activeProjectRoot: root,
|
|
2334
|
-
globalScope: "project",
|
|
2335
|
-
}, fmt.UCODE_VERSION || "");
|
|
2336
|
-
dispatch({ type: "log/appendMany", lines: banner });
|
|
2337
|
-
const persisted = loadChatHistory(root, 200, { globalMode: false });
|
|
2338
|
-
if (persisted.length > 0) {
|
|
2339
|
-
dispatch({ type: "log/append", text: "" });
|
|
2340
|
-
dispatch({ type: "log/append", text: "─── history ───" });
|
|
2341
|
-
dispatch({ type: "log/appendMany", lines: persisted });
|
|
2342
|
-
}
|
|
2343
|
-
if (props.daemonCoordinator && typeof props.daemonCoordinator.switchProject === "function") {
|
|
2344
|
-
const { socketPath } = require("../../runtime/daemon");
|
|
2345
|
-
const res = await Promise.resolve(props.daemonCoordinator.switchProject({
|
|
2346
|
-
projectRoot: root,
|
|
2347
|
-
sockPath: socketPath(root),
|
|
2348
|
-
autoStart: false,
|
|
2349
|
-
}));
|
|
2350
|
-
if (!res || res.ok !== true) {
|
|
2351
|
-
dispatch({ type: "log/append", text: `Error: ${(res && res.error) || "switch failed"}` });
|
|
2352
|
-
return res || { ok: false, error: "switch failed" };
|
|
2353
|
-
}
|
|
2354
|
-
}
|
|
2355
|
-
setCurrentProjectRoot(root);
|
|
2356
|
-
dispatch({ type: "scope/set", scope: "project" });
|
|
2357
|
-
dispatch({
|
|
2358
|
-
type: "projects/select",
|
|
2359
|
-
index: state.projects.indexOf(selected),
|
|
2360
|
-
projectRoot: root,
|
|
2361
|
-
});
|
|
2362
|
-
refreshGlobalProjects(root);
|
|
2363
|
-
if (focusInput) dispatch({ type: "focus/set", mode: "input" });
|
|
2364
|
-
try {
|
|
2365
|
-
const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
|
|
2366
|
-
if (props.daemonConnection && typeof props.daemonConnection.send === "function") {
|
|
2367
|
-
props.daemonConnection.send({ type: IPC_REQUEST_TYPES.STATUS });
|
|
2368
|
-
}
|
|
2369
|
-
} catch { /* ignore */ }
|
|
2370
|
-
return { ok: true, project_root: root };
|
|
2371
|
-
}, [
|
|
2372
|
-
props,
|
|
2373
|
-
props.daemonCoordinator,
|
|
2374
|
-
props.daemonConnection,
|
|
2375
|
-
props.env,
|
|
2376
|
-
state.projects,
|
|
2377
|
-
refreshGlobalProjects,
|
|
2378
|
-
currentProjectRoot,
|
|
2379
|
-
]);
|
|
2380
|
-
|
|
2381
|
-
useEffect(() => {
|
|
2382
|
-
switchToProjectRootRef.current = switchToProjectRoot;
|
|
2383
|
-
}, [switchToProjectRoot]);
|
|
2384
|
-
|
|
2385
|
-
const switchToControllerRoot = useCallback(async () => {
|
|
2386
|
-
const root = props.activeProjectRoot || props.projectRoot || "";
|
|
2387
|
-
if (!root) return { ok: false, error: "controller root unavailable" };
|
|
2388
|
-
if (props.daemonCoordinator && typeof props.daemonCoordinator.switchProject === "function") {
|
|
2389
|
-
const { socketPath } = require("../../runtime/daemon");
|
|
2390
|
-
const res = await Promise.resolve(props.daemonCoordinator.switchProject({
|
|
2391
|
-
projectRoot: root,
|
|
2392
|
-
sockPath: socketPath(root),
|
|
2393
|
-
}));
|
|
2394
|
-
if (!res || res.ok !== true) {
|
|
2395
|
-
dispatch({ type: "log/append", text: `Error: ${(res && res.error) || "switch to global failed"}` });
|
|
2396
|
-
return res || { ok: false, error: "switch to global failed" };
|
|
2397
|
-
}
|
|
2398
|
-
}
|
|
2399
|
-
|
|
2400
|
-
dispatch({ type: "projects/clearSelection" });
|
|
2401
|
-
dispatch({ type: "scope/set", scope: "controller" });
|
|
2402
|
-
setCurrentProjectRoot(root);
|
|
2403
|
-
refreshGlobalProjects(root);
|
|
2404
|
-
|
|
2405
|
-
dispatch({ type: "log/clear" });
|
|
2406
|
-
const banner = buildChatBannerLines({
|
|
2407
|
-
...props,
|
|
2408
|
-
activeProjectRoot: root,
|
|
2409
|
-
globalScope: "controller",
|
|
2410
|
-
}, fmt.UCODE_VERSION || "");
|
|
2411
|
-
dispatch({ type: "log/appendMany", lines: banner });
|
|
2412
|
-
const persisted = loadChatHistory(root, 200, { globalMode: true });
|
|
2413
|
-
if (persisted.length > 0) {
|
|
2414
|
-
dispatch({ type: "log/append", text: "" });
|
|
2415
|
-
dispatch({ type: "log/append", text: "─── history ───" });
|
|
2416
|
-
dispatch({ type: "log/appendMany", lines: persisted });
|
|
2417
|
-
}
|
|
2418
|
-
|
|
2419
|
-
const snapshot = readProjectAgentSnapshot(root);
|
|
2420
|
-
dispatch({ type: "agents/set", list: snapshot.agents.map((id) => snapshot.metaMap.get(id) || { fullId: id }) });
|
|
2421
|
-
try {
|
|
2422
|
-
const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
|
|
2423
|
-
if (props.daemonConnection && typeof props.daemonConnection.send === "function") {
|
|
2424
|
-
props.daemonConnection.send({ type: IPC_REQUEST_TYPES.STATUS });
|
|
2425
|
-
}
|
|
2426
|
-
} catch { /* ignore */ }
|
|
2427
|
-
return { ok: true, project_root: root };
|
|
2428
|
-
}, [
|
|
2429
|
-
props,
|
|
2430
|
-
props.daemonCoordinator,
|
|
2431
|
-
props.daemonConnection,
|
|
2432
|
-
refreshGlobalProjects,
|
|
2433
|
-
]);
|
|
2434
|
-
|
|
2435
|
-
const closeSelectedProject = useCallback(async () => {
|
|
2436
|
-
if (!props.globalMode || !Array.isArray(state.projects) || state.projects.length === 0) return;
|
|
2437
|
-
const selectedIndex = state.selectedProjectIndex >= 0 ? state.selectedProjectIndex : 0;
|
|
2438
|
-
const proj = state.projects[selectedIndex];
|
|
2439
|
-
const targetRoot = resolveProjectRowRoot(proj);
|
|
2440
|
-
const label = (proj && (proj.label || proj.project_name)) || targetRoot;
|
|
2441
|
-
if (!targetRoot) {
|
|
2442
|
-
dispatch({ type: "log/append", text: "Error: project root unavailable" });
|
|
2443
|
-
return;
|
|
2444
|
-
}
|
|
2445
|
-
|
|
2446
|
-
dispatch({ type: "log/append", text: `Closing project ${label} daemon and agents...` });
|
|
2447
|
-
let activeRoot = currentProjectRoot;
|
|
2448
|
-
try {
|
|
2449
|
-
if (targetRoot === currentProjectRoot) {
|
|
2450
|
-
const fallback = state.projects
|
|
2451
|
-
.map(resolveProjectRowRoot)
|
|
2452
|
-
.find((root) => root && root !== targetRoot);
|
|
2453
|
-
if (!fallback) {
|
|
2454
|
-
dispatch({ type: "log/append", text: "Error: Cannot close current project; switch to another project first" });
|
|
2455
|
-
return;
|
|
2456
|
-
}
|
|
2457
|
-
if (!props.daemonCoordinator || typeof props.daemonCoordinator.switchProject !== "function") {
|
|
2458
|
-
dispatch({ type: "log/append", text: "Error: project switching unavailable" });
|
|
2459
|
-
return;
|
|
2460
|
-
}
|
|
2461
|
-
const { socketPath } = require("../../runtime/daemon");
|
|
2462
|
-
const switched = await Promise.resolve(props.daemonCoordinator.switchProject({
|
|
2463
|
-
projectRoot: fallback,
|
|
2464
|
-
sockPath: socketPath(fallback),
|
|
2465
|
-
autoStart: false,
|
|
2466
|
-
}));
|
|
2467
|
-
if (!switched || switched.ok !== true) {
|
|
2468
|
-
dispatch({ type: "log/append", text: `Error: Failed to switch project before close: ${(switched && switched.error) || "switch failed"}` });
|
|
2469
|
-
return;
|
|
2470
|
-
}
|
|
2471
|
-
activeRoot = fallback;
|
|
2472
|
-
setCurrentProjectRoot(fallback);
|
|
2473
|
-
dispatch({ type: "scope/set", scope: "project" });
|
|
2474
|
-
}
|
|
2475
|
-
|
|
2476
|
-
const { stopDaemon } = require("../../app/chat/transport");
|
|
2477
|
-
const { isRunning } = require("../../runtime/daemon");
|
|
2478
|
-
stopDaemon(targetRoot, { source: `ink-project-close:${targetRoot}` });
|
|
2479
|
-
refreshGlobalProjects(activeRoot);
|
|
2480
|
-
if (isRunning(targetRoot)) {
|
|
2481
|
-
dispatch({ type: "log/append", text: `Error: Project ${label} daemon is still running after stop` });
|
|
2482
|
-
return;
|
|
2483
|
-
}
|
|
2484
|
-
dispatch({ type: "log/append", text: `Closed project ${label} daemon and agents` });
|
|
2485
|
-
} catch (err) {
|
|
2486
|
-
dispatch({ type: "log/append", text: `Error: ${err && err.message ? err.message : err}` });
|
|
2487
|
-
}
|
|
2488
|
-
}, [
|
|
2489
|
-
props.globalMode,
|
|
2490
|
-
props.daemonCoordinator,
|
|
2491
|
-
state.projects,
|
|
2492
|
-
state.selectedProjectIndex,
|
|
2493
|
-
currentProjectRoot,
|
|
2494
|
-
refreshGlobalProjects,
|
|
2495
|
-
]);
|
|
2496
|
-
|
|
2497
|
-
const submit = useCallback(async (submitted) => {
|
|
2498
|
-
const value = String(submitted == null ? state.draft : submitted);
|
|
2499
|
-
const trimmed = value.trim();
|
|
2500
|
-
if (props.globalMode && state.globalScope === "project" && selectedProjectRoot && selectedProjectRoot !== currentProjectRoot) {
|
|
2501
|
-
const switched = await switchToProjectRoot(selectedProjectRoot, { focusInput: true });
|
|
2502
|
-
if (!switched || switched.ok !== true) return;
|
|
2503
|
-
}
|
|
2504
|
-
dispatch({ type: "draft/clear" });
|
|
2505
|
-
const { createInputSubmitHandler } = require("../../app/chat/inputSubmitHandler");
|
|
2506
|
-
const { parseAtTarget } = require("../../app/chat/commands");
|
|
2507
|
-
const { resolveAgentId } = require("../../app/chat/agentDirectory");
|
|
2508
|
-
const { subscriberToSafeName } = require("../../coordination/bus/utils");
|
|
2509
|
-
const { getUfooPaths } = require("../../coordination/state/paths");
|
|
2510
|
-
const { createTerminalAdapterRouter } = require("../../runtime/terminal/adapterRouter");
|
|
2511
|
-
const submitState = {};
|
|
2512
|
-
Object.defineProperties(submitState, {
|
|
2513
|
-
targetAgent: {
|
|
2514
|
-
get: () => targetAgentId || null,
|
|
2515
|
-
set: (next) => {
|
|
2516
|
-
const id = String(next || "");
|
|
2517
|
-
if (!id) {
|
|
2518
|
-
dispatch({ type: "agents/clearTarget" });
|
|
2519
|
-
return;
|
|
2520
|
-
}
|
|
2521
|
-
const idx = displayAgents.indexOf(id);
|
|
2522
|
-
if (idx >= 0) dispatch({ type: "agents/select", index: idx });
|
|
2523
|
-
},
|
|
2524
|
-
},
|
|
2525
|
-
pending: {
|
|
2526
|
-
get: () => pendingRef.current,
|
|
2527
|
-
set: (next) => { pendingRef.current = next || null; },
|
|
2528
|
-
},
|
|
2529
|
-
activeAgentMetaMap: {
|
|
2530
|
-
get: () => displayAgentMeta,
|
|
2531
|
-
},
|
|
2532
|
-
});
|
|
2533
|
-
const send = (req) => {
|
|
2534
|
-
if (!props.daemonConnection || typeof props.daemonConnection.send !== "function") {
|
|
2535
|
-
throw new Error("daemon connection unavailable");
|
|
2536
|
-
}
|
|
2537
|
-
props.daemonConnection.send(req);
|
|
2538
|
-
};
|
|
2539
|
-
const handler = createInputSubmitHandler({
|
|
2540
|
-
state: submitState,
|
|
2541
|
-
parseAtTarget,
|
|
2542
|
-
resolveAgentId: (label) => resolveAgentId({
|
|
2543
|
-
label,
|
|
2544
|
-
activeAgents: displayAgents,
|
|
2545
|
-
labelMap: buildActiveAgentLabelMap(displayAgents, displayAgentMeta),
|
|
2546
|
-
lookupNickname: (nickname) => {
|
|
2547
|
-
for (const [id, meta] of displayAgentMeta.entries()) {
|
|
2548
|
-
if (!meta) continue;
|
|
2549
|
-
if (meta.nickname === nickname || meta.scoped_nickname === nickname || meta.display_nickname === nickname) return id;
|
|
2550
|
-
}
|
|
2551
|
-
return null;
|
|
2552
|
-
},
|
|
2553
|
-
}),
|
|
2554
|
-
executeCommand: async (text) => {
|
|
2555
|
-
const exec = commandExecutorRef.current;
|
|
2556
|
-
if (!exec || typeof exec.executeCommand !== "function") {
|
|
2557
|
-
throw new Error("command executor not ready yet");
|
|
2558
|
-
}
|
|
2559
|
-
return exec.executeCommand(text);
|
|
2560
|
-
},
|
|
2561
|
-
queueStatusLine: (text) => setStatusText(text, { type: "typing", showTimer: true }),
|
|
2562
|
-
send,
|
|
2563
|
-
logMessage: logInkMessage,
|
|
2564
|
-
getAgentLabel: (id) => getAgentLabelFor(displayAgentMeta.get(id), id),
|
|
2565
|
-
escapeBlessed: (next) => String(next == null ? "" : next),
|
|
2566
|
-
markPendingDelivery: (agentId) => {
|
|
2567
|
-
const meta = displayAgentMeta.get(agentId);
|
|
2568
|
-
streamStateRef.current.markPendingDelivery(agentId, getAgentLabelFor(meta, agentId));
|
|
2569
|
-
},
|
|
2570
|
-
clearTargetAgent: () => dispatch({ type: "agents/clearTarget" }),
|
|
2571
|
-
setTargetAgent: (agentId) => {
|
|
2572
|
-
const idx = displayAgents.indexOf(agentId);
|
|
2573
|
-
if (idx >= 0) dispatch({ type: "agents/select", index: idx });
|
|
2574
|
-
},
|
|
2575
|
-
enterAgentView: (agentId, options = {}) => {
|
|
2576
|
-
const payload = buildAgentEnterPayload(agentId);
|
|
2577
|
-
if (payload && options.useBus) payload.useBus = true;
|
|
2578
|
-
if (payload && payload.useBus) {
|
|
2579
|
-
enterInternalAgentView(payload);
|
|
2580
|
-
return;
|
|
2581
|
-
}
|
|
2582
|
-
if (payload && typeof props.requestEnterAgentView === "function") {
|
|
2583
|
-
props.requestEnterAgentView(agentId, payload);
|
|
2584
|
-
exit();
|
|
2585
|
-
}
|
|
2586
|
-
},
|
|
2587
|
-
getAgentAdapter: (agentId) => {
|
|
2588
|
-
const meta = displayAgentMeta.get(agentId) || {};
|
|
2589
|
-
const launchMode = String(meta.launch_mode || meta.launchMode || state.settings.launchMode || "").trim();
|
|
2590
|
-
return createTerminalAdapterRouter().getAdapter({ launchMode, agentId, meta });
|
|
2591
|
-
},
|
|
2592
|
-
activateAgent: async (agentId) => {
|
|
2593
|
-
const AgentActivator = require("../../coordination/bus/activate");
|
|
2594
|
-
const activator = new AgentActivator(currentProjectRoot || props.projectRoot);
|
|
2595
|
-
await activator.activate(agentId);
|
|
2596
|
-
},
|
|
2597
|
-
getInjectSockPath: (agentId) => {
|
|
2598
|
-
const safeName = subscriberToSafeName(agentId);
|
|
2599
|
-
return path.join(getUfooPaths(currentProjectRoot || props.projectRoot).busQueuesDir, safeName, "inject.sock");
|
|
2600
|
-
},
|
|
2601
|
-
existsSync: fs.existsSync,
|
|
2602
|
-
commitInputHistory: (text) => {
|
|
2603
|
-
dispatch({ type: "history/push", value: text });
|
|
2604
|
-
try { appendInputHistory(props.projectRoot, text, { globalMode: props.globalMode }); } catch { /* ignore */ }
|
|
2605
|
-
},
|
|
2606
|
-
focusInput: () => dispatch({ type: "focus/set", mode: "input" }),
|
|
2607
|
-
renderScreen: () => {},
|
|
2608
|
-
getShellCwd: () => activeChatHistoryRoot,
|
|
2609
|
-
runShellCommand: async (shellCommand, options = {}) => {
|
|
2610
|
-
const { runShellCommand } = require("../../app/chat/shellCommand");
|
|
2611
|
-
return runShellCommand(shellCommand, options);
|
|
2612
|
-
},
|
|
2613
|
-
});
|
|
2614
|
-
try {
|
|
2615
|
-
await handler.handleSubmit(value);
|
|
2616
|
-
} catch (err) {
|
|
2617
|
-
dispatch({ type: "log/append", text: `Error: ${err && err.message ? err.message : "send failed"}` });
|
|
2618
|
-
dispatch({ type: "status/idle" });
|
|
2619
|
-
}
|
|
2620
|
-
}, [
|
|
2621
|
-
state.draft,
|
|
2622
|
-
targetAgentId,
|
|
2623
|
-
props.globalMode,
|
|
2624
|
-
props.projectRoot,
|
|
2625
|
-
props.daemonConnection,
|
|
2626
|
-
props.requestEnterAgentView,
|
|
2627
|
-
selectedProjectRoot,
|
|
2628
|
-
currentProjectRoot,
|
|
2629
|
-
state.globalScope,
|
|
2630
|
-
state.settings.launchMode,
|
|
2631
|
-
switchToProjectRoot,
|
|
2632
|
-
displayAgents,
|
|
2633
|
-
displayAgentMeta,
|
|
2634
|
-
activeChatHistoryRoot,
|
|
2635
|
-
logInkMessage,
|
|
2636
|
-
setStatusText,
|
|
2637
|
-
exit,
|
|
2638
|
-
]);
|
|
2639
|
-
|
|
2640
|
-
const onArrowUpAtTop = useCallback((currentValue) => {
|
|
2641
|
-
// Clear @-target before history so Up from an empty ›@agent prompt
|
|
2642
|
-
// restores the bare › prompt instead of recalling a prior draft.
|
|
2643
|
-
const inputValue = currentValue != null ? currentValue : state.draft;
|
|
2644
|
-
if (fmt.shouldClearAgentSelectionOnUp({
|
|
2645
|
-
agentSelectionMode: state.agentSelectionMode,
|
|
2646
|
-
inputValue,
|
|
2647
|
-
})) {
|
|
2648
|
-
dispatch({ type: "agents/clearTarget" });
|
|
2649
|
-
return;
|
|
2650
|
-
}
|
|
2651
|
-
if (state.inputHistory.length > 0) {
|
|
2652
|
-
const next = Math.max(0, state.historyIndex - 1);
|
|
2653
|
-
if (next !== state.historyIndex || state.draft !== state.inputHistory[next]) {
|
|
2654
|
-
dispatch({ type: "history/setIndex", index: next });
|
|
2655
|
-
dispatch({ type: "draft/set", value: state.inputHistory[next] || "" });
|
|
2656
|
-
setCompletionSuppressedDraft(state.inputHistory[next] || "");
|
|
2657
|
-
setDraftVersion((v) => v + 1);
|
|
2658
|
-
}
|
|
2659
|
-
}
|
|
2660
|
-
}, [state.inputHistory, state.historyIndex, state.draft, state.agentSelectionMode]);
|
|
2661
|
-
|
|
2662
|
-
const onArrowDownAtBottom = useCallback((currentValue) => {
|
|
2663
|
-
if (state.inputHistory.length > 0) {
|
|
2664
|
-
const transition = fmt.resolveHistoryDownTransition({
|
|
2665
|
-
inputHistory: state.inputHistory,
|
|
2666
|
-
historyIndex: state.historyIndex,
|
|
2667
|
-
currentValue,
|
|
2668
|
-
});
|
|
2669
|
-
if (transition.moved) {
|
|
2670
|
-
dispatch({ type: "history/setIndex", index: transition.nextHistoryIndex });
|
|
2671
|
-
dispatch({ type: "draft/set", value: transition.nextValue });
|
|
2672
|
-
setCompletionSuppressedDraft(transition.nextValue);
|
|
2673
|
-
setDraftVersion((v) => v + 1);
|
|
2674
|
-
return;
|
|
2675
|
-
}
|
|
2676
|
-
}
|
|
2677
|
-
// Hand focus to the dashboard. Three-tier flow:
|
|
2678
|
-
// global mode → projects → agents → mode/provider/cron
|
|
2679
|
-
// project mode → agents → mode/provider/cron
|
|
2680
|
-
if (props.globalMode) {
|
|
2681
|
-
dispatch({ type: "focus/set", mode: "dashboard" });
|
|
2682
|
-
if (state.projects.length > 0 && state.selectedProjectIndex < 0) {
|
|
2683
|
-
dispatch({ type: "view/set", view: "projects" });
|
|
2684
|
-
dispatch({ type: "projects/select", index: 0, projectRoot: resolveProjectRowRoot(state.projects[0]) });
|
|
2685
|
-
dispatch({ type: "projects/window", windowStart: 0 });
|
|
2686
|
-
} else {
|
|
2687
|
-
dispatch({ type: "view/set", view: "agents" });
|
|
2688
|
-
if (displayAgents.length > 0 && state.selectedAgentIndex < 0) {
|
|
2689
|
-
dispatch({ type: "agents/select", index: 0 });
|
|
2690
|
-
}
|
|
2691
|
-
}
|
|
2692
|
-
return;
|
|
2693
|
-
}
|
|
2694
|
-
dispatch({ type: "focus/set", mode: "dashboard" });
|
|
2695
|
-
dispatch({ type: "view/set", view: "agents" });
|
|
2696
|
-
if (displayAgents.length > 0 && state.selectedAgentIndex < 0) {
|
|
2697
|
-
dispatch({ type: "agents/select", index: 0 });
|
|
2698
|
-
}
|
|
2699
|
-
}, [state.inputHistory, state.historyIndex, state.projects.length, state.selectedProjectIndex, displayAgents.length, state.selectedAgentIndex, props.globalMode]);
|
|
2700
|
-
|
|
2701
|
-
const onArrowSideAtEmpty = useCallback((direction) => {
|
|
2702
|
-
if (!state.agentSelectionMode || displayAgents.length === 0) return;
|
|
2703
|
-
const cur = state.selectedAgentIndex < 0 ? 0 : state.selectedAgentIndex;
|
|
2704
|
-
const next = direction === "left"
|
|
2705
|
-
? Math.max(0, cur - 1)
|
|
2706
|
-
: Math.min(displayAgents.length - 1, cur + 1);
|
|
2707
|
-
dispatch({ type: "agents/select", index: next });
|
|
2708
|
-
}, [state.agentSelectionMode, state.selectedAgentIndex, displayAgents.length]);
|
|
2709
|
-
|
|
2710
|
-
// Inline completions: shown above the input whenever the draft starts
|
|
2711
|
-
// with "/" or "@". Tab/Enter accept the highlighted entry, ↑↓ move the
|
|
2712
|
-
// selection. The list reuses the pure buildCompletions helper from
|
|
2713
|
-
// src/ui/format so jest can pin the source list without rendering ink.
|
|
2714
|
-
const { COMMAND_REGISTRY, COMMAND_TREE } = require("../../app/chat/commands");
|
|
2715
|
-
const agentLabels = displayAgents.map((id) =>
|
|
2716
|
-
getAgentLabelFor(displayAgentMeta.get(id), id)
|
|
2717
|
-
);
|
|
2718
|
-
|
|
2719
|
-
// Lazy-load the dynamic completion sources once so /group run and
|
|
2720
|
-
// /solo run get the same alias/profile suggestions blessed shows.
|
|
2721
|
-
const dynamicSourcesRef = useRef(null);
|
|
2722
|
-
if (!dynamicSourcesRef.current) {
|
|
2723
|
-
const sources = { groupTemplates: [], soloProfiles: [] };
|
|
2724
|
-
try {
|
|
2725
|
-
const { loadTemplateRegistry } = require("../../orchestration/groups/templates");
|
|
2726
|
-
const reg = typeof loadTemplateRegistry === "function" ? loadTemplateRegistry(props.projectRoot) : null;
|
|
2727
|
-
if (reg && Array.isArray(reg.templates)) {
|
|
2728
|
-
sources.groupTemplates = reg.templates.map((item) => ({
|
|
2729
|
-
alias: item.alias,
|
|
2730
|
-
cmd: item.alias,
|
|
2731
|
-
desc: item.templateDescription || "",
|
|
2732
|
-
source: item.source || "",
|
|
2733
|
-
}));
|
|
2734
|
-
}
|
|
2735
|
-
} catch { /* ignore */ }
|
|
2736
|
-
try {
|
|
2737
|
-
const { loadPromptProfileRegistry } = require("../../orchestration/groups/promptProfiles");
|
|
2738
|
-
const { buildPromptProfileCandidates } = require("../../orchestration/solo/commands");
|
|
2739
|
-
const reg = typeof loadPromptProfileRegistry === "function" ? loadPromptProfileRegistry(props.projectRoot) : null;
|
|
2740
|
-
if (reg && typeof buildPromptProfileCandidates === "function") {
|
|
2741
|
-
sources.soloProfiles = buildPromptProfileCandidates(reg) || [];
|
|
2742
|
-
}
|
|
2743
|
-
} catch { /* ignore */ }
|
|
2744
|
-
dynamicSourcesRef.current = sources;
|
|
2745
|
-
}
|
|
2746
|
-
|
|
2747
|
-
const completions = fmt.buildCompletions({
|
|
2748
|
-
text: state.draft,
|
|
2749
|
-
agents: displayAgents,
|
|
2750
|
-
agentLabels,
|
|
2751
|
-
commands: COMMAND_REGISTRY,
|
|
2752
|
-
commandTree: COMMAND_TREE,
|
|
2753
|
-
groupTemplates: dynamicSourcesRef.current.groupTemplates,
|
|
2754
|
-
soloProfiles: dynamicSourcesRef.current.soloProfiles,
|
|
2755
|
-
limit: 20,
|
|
2756
|
-
});
|
|
2757
|
-
const [completionIndex, setCompletionIndex] = useState(0);
|
|
2758
|
-
// First visible row inside the popup. We show 8 rows at a time
|
|
2759
|
-
// (POPUP_PAGE_SIZE) and slide the window when the cursor crosses
|
|
2760
|
-
// the bottom or top, mimicking how a terminal list typically scrolls.
|
|
2761
|
-
const POPUP_PAGE_SIZE = 8;
|
|
2762
|
-
const [completionWindowStart, setCompletionWindowStart] = useState(0);
|
|
2763
|
-
// Bumped whenever the completion popup writes a new value into the
|
|
2764
|
-
// draft — MultilineInput watches this counter so it can park its
|
|
2765
|
-
// cursor at the end of the freshly accepted suggestion instead of
|
|
2766
|
-
// staying wherever the user last typed.
|
|
2767
|
-
const [draftVersion, setDraftVersion] = useState(0);
|
|
2768
|
-
// History recall should not immediately turn a recalled command such as
|
|
2769
|
-
// "/history" into an active completion popup; otherwise ↑/↓ get captured
|
|
2770
|
-
// by completion navigation and the user cannot keep walking history.
|
|
2771
|
-
const [completionSuppressedDraft, setCompletionSuppressedDraft] = useState(null);
|
|
2772
|
-
// Reset the selection cursor whenever the suggestion list shape changes.
|
|
2773
|
-
useEffect(() => {
|
|
2774
|
-
if (completions.length === 0) {
|
|
2775
|
-
if (completionIndex !== 0) setCompletionIndex(0);
|
|
2776
|
-
if (completionWindowStart !== 0) setCompletionWindowStart(0);
|
|
2777
|
-
} else if (completionIndex >= completions.length) {
|
|
2778
|
-
setCompletionIndex(completions.length - 1);
|
|
2779
|
-
setCompletionWindowStart(Math.max(0, completions.length - POPUP_PAGE_SIZE));
|
|
2780
|
-
}
|
|
2781
|
-
}, [completions.length, completionIndex, completionWindowStart]);
|
|
2782
|
-
useEffect(() => {
|
|
2783
|
-
if (multiWindowActive) setMwCursor(String(state.draft || "").length);
|
|
2784
|
-
}, [draftVersion]);
|
|
2785
|
-
const completionsOpen = completions.length > 0 && state.draft !== completionSuppressedDraft;
|
|
2786
|
-
const acceptCompletion = useCallback(() => {
|
|
2787
|
-
if (!completionsOpen) return false;
|
|
2788
|
-
const item = completions[Math.max(0, Math.min(completions.length - 1, completionIndex))];
|
|
2789
|
-
if (item) {
|
|
2790
|
-
dispatch({ type: "draft/set", value: item.replace });
|
|
2791
|
-
setCompletionSuppressedDraft(item.hasChildren ? null : item.replace);
|
|
2792
|
-
setDraftVersion((v) => v + 1);
|
|
2793
|
-
}
|
|
2794
|
-
setCompletionIndex(0);
|
|
2795
|
-
return true;
|
|
2796
|
-
}, [completionsOpen, completions, completionIndex]);
|
|
2797
|
-
|
|
2798
|
-
const buildAgentEnterPayload = (agentId) => {
|
|
2799
|
-
const agentMeta = displayAgentMeta.get(agentId);
|
|
2800
|
-
const enterRequest = resolveAgentEnterRequest({
|
|
2801
|
-
agentId,
|
|
2802
|
-
projectRoot: currentProjectRoot || props.projectRoot,
|
|
2803
|
-
activeAgentMeta: displayAgentMeta,
|
|
2804
|
-
settings: state.settings,
|
|
2805
|
-
});
|
|
2806
|
-
return {
|
|
2807
|
-
...enterRequest,
|
|
2808
|
-
agentLabel: getAgentLabelFor(agentMeta, agentId),
|
|
2809
|
-
agentAliases: [
|
|
2810
|
-
agentId,
|
|
2811
|
-
agentMeta && agentMeta.nickname,
|
|
2812
|
-
agentMeta && agentMeta.scoped_nickname,
|
|
2813
|
-
agentMeta && agentMeta.display_nickname,
|
|
2814
|
-
].filter(Boolean).map(String),
|
|
2815
|
-
};
|
|
2816
|
-
};
|
|
2817
|
-
|
|
2818
|
-
const activateExternalAgent = (agentId) => {
|
|
2819
|
-
const id = String(agentId || "").trim();
|
|
2820
|
-
if (!id) return;
|
|
2821
|
-
try {
|
|
2822
|
-
const AgentActivator = require("../../coordination/bus/activate");
|
|
2823
|
-
const activator = new AgentActivator(currentProjectRoot || props.projectRoot);
|
|
2824
|
-
void activator.activate(id);
|
|
2825
|
-
} catch (err) {
|
|
2826
|
-
logInkMessage("error", `✗ Failed to activate ${id}: ${err && err.message ? err.message : "unknown error"}`);
|
|
2827
|
-
}
|
|
2828
|
-
};
|
|
2829
|
-
|
|
2830
|
-
const enterInternalAgentView = (enterRequest = {}) => {
|
|
2831
|
-
const agentId = String(enterRequest.agentId || "").trim();
|
|
2832
|
-
if (!agentId) return;
|
|
2833
|
-
const previous = internalAgentViewRef.current;
|
|
2834
|
-
if (previous && previous.agentId && previous.agentId !== agentId) {
|
|
2835
|
-
sendInternalAgentWatch(previous.agentId, false);
|
|
2836
|
-
}
|
|
2837
|
-
const next = createInternalAgentViewState({
|
|
2838
|
-
agentId,
|
|
2839
|
-
label: enterRequest.agentLabel || agentId,
|
|
2840
|
-
aliases: enterRequest.agentAliases || [],
|
|
2841
|
-
projectRoot: enterRequest.projectRoot || currentProjectRoot || props.projectRoot,
|
|
2842
|
-
width: size.cols || 80,
|
|
2843
|
-
});
|
|
2844
|
-
setInternalAgentView(next);
|
|
2845
|
-
internalAgentViewRef.current = next;
|
|
2846
|
-
dispatch({ type: "agentView/enter", agentId });
|
|
2847
|
-
dispatch({ type: "focus/set", mode: "input" });
|
|
2848
|
-
dispatch({ type: "agents/clearTarget" });
|
|
2849
|
-
sendInternalAgentWatch(agentId, true);
|
|
2850
|
-
try {
|
|
2851
|
-
const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
|
|
2852
|
-
if (props.daemonConnection && typeof props.daemonConnection.send === "function") {
|
|
2853
|
-
props.daemonConnection.send({ type: IPC_REQUEST_TYPES.STATUS });
|
|
2854
|
-
}
|
|
2855
|
-
} catch { /* ignore */ }
|
|
2856
|
-
};
|
|
2857
|
-
|
|
2858
|
-
const exitInternalAgentView = () => {
|
|
2859
|
-
const view = internalAgentViewRef.current;
|
|
2860
|
-
if (view && view.agentId) sendInternalAgentWatch(view.agentId, false);
|
|
2861
|
-
const empty = createInternalAgentViewState();
|
|
2862
|
-
setInternalAgentView(empty);
|
|
2863
|
-
internalAgentViewRef.current = empty;
|
|
2864
|
-
dispatch({ type: "agentView/exit" });
|
|
2865
|
-
dispatch({ type: "view/set", view: "agents" });
|
|
2866
|
-
dispatch({ type: "focus/set", mode: "input" });
|
|
2867
|
-
};
|
|
2868
|
-
|
|
2869
|
-
const submitInternalAgentInput = () => {
|
|
2870
|
-
const view = internalAgentViewRef.current;
|
|
2871
|
-
const text = String((view && view.input) || "").trim();
|
|
2872
|
-
if (!view || !view.agentId || !text) return;
|
|
2873
|
-
setInternalAgentView((prev) => ({
|
|
2874
|
-
...updateInternalViewStatus(
|
|
2875
|
-
appendInternalAgentText(prev, `${text}\n`, { prefix: "> " }),
|
|
2876
|
-
"working",
|
|
2877
|
-
"",
|
|
2878
|
-
),
|
|
2879
|
-
input: "",
|
|
2880
|
-
cursor: 0,
|
|
2881
|
-
}));
|
|
2882
|
-
sendInternalAgentMessage(view.agentId, text);
|
|
2883
|
-
};
|
|
2884
|
-
|
|
2885
|
-
const handleInternalAgentDashboardKey = (input, key = {}) => {
|
|
2886
|
-
const keyName = resolveInternalKeyName(input, key);
|
|
2887
|
-
const totalItems = 1 + displayAgents.length;
|
|
2888
|
-
const currentIndex = Math.max(
|
|
2889
|
-
0,
|
|
2890
|
-
Math.min(totalItems - 1, Number(internalAgentViewRef.current.barIndex) || 0),
|
|
2891
|
-
);
|
|
2892
|
-
if (keyName === "left") {
|
|
2893
|
-
setInternalAgentView((prev) => ({
|
|
2894
|
-
...prev,
|
|
2895
|
-
barIndex: Math.max(0, (Number(prev.barIndex) || 0) - 1),
|
|
2896
|
-
}));
|
|
2897
|
-
return true;
|
|
2898
|
-
}
|
|
2899
|
-
if (keyName === "right") {
|
|
2900
|
-
setInternalAgentView((prev) => ({
|
|
2901
|
-
...prev,
|
|
2902
|
-
barIndex: Math.min(totalItems - 1, (Number(prev.barIndex) || 0) + 1),
|
|
2903
|
-
}));
|
|
2904
|
-
return true;
|
|
2905
|
-
}
|
|
2906
|
-
if (keyName === "up") {
|
|
2907
|
-
dispatch({ type: "focus/set", mode: "input" });
|
|
2908
|
-
return true;
|
|
2909
|
-
}
|
|
2910
|
-
if (keyName === "return" || keyName === "enter") {
|
|
2911
|
-
if (currentIndex === 0) {
|
|
2912
|
-
exitInternalAgentView();
|
|
2913
|
-
return true;
|
|
2914
|
-
}
|
|
2915
|
-
const agentId = displayAgents[currentIndex - 1];
|
|
2916
|
-
if (!agentId) return true;
|
|
2917
|
-
if (agentId === state.viewingAgentId) {
|
|
2918
|
-
dispatch({ type: "focus/set", mode: "input" });
|
|
2919
|
-
return true;
|
|
2920
|
-
}
|
|
2921
|
-
const payload = buildAgentEnterPayload(agentId);
|
|
2922
|
-
const action = resolveDashboardAgentEnterAction(payload);
|
|
2923
|
-
if (action === "internal") {
|
|
2924
|
-
enterInternalAgentView(payload);
|
|
2925
|
-
return true;
|
|
2926
|
-
}
|
|
2927
|
-
if (action === "activate") {
|
|
2928
|
-
if (state.viewingAgentId) sendInternalAgentWatch(state.viewingAgentId, false);
|
|
2929
|
-
dispatch({ type: "agentView/exit" });
|
|
2930
|
-
dispatch({ type: "view/set", view: "agents" });
|
|
2931
|
-
dispatch({ type: "focus/set", mode: "input" });
|
|
2932
|
-
activateExternalAgent(agentId);
|
|
2933
|
-
return true;
|
|
2934
|
-
}
|
|
2935
|
-
if (payload && typeof props.requestEnterAgentView === "function") {
|
|
2936
|
-
if (state.viewingAgentId) sendInternalAgentWatch(state.viewingAgentId, false);
|
|
2937
|
-
props.requestEnterAgentView(agentId, payload);
|
|
2938
|
-
exit();
|
|
2939
|
-
}
|
|
2940
|
-
return true;
|
|
2941
|
-
}
|
|
2942
|
-
if (key && key.ctrl && input === "x") {
|
|
2943
|
-
if (currentIndex <= 0) return true;
|
|
2944
|
-
const agentId = displayAgents[currentIndex - 1];
|
|
2945
|
-
if (!agentId) return true;
|
|
2946
|
-
try {
|
|
2947
|
-
const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
|
|
2948
|
-
if (props.daemonConnection && typeof props.daemonConnection.send === "function") {
|
|
2949
|
-
props.daemonConnection.send({ type: IPC_REQUEST_TYPES.CLOSE_AGENT, agent_id: agentId });
|
|
2950
|
-
}
|
|
2951
|
-
} catch { /* ignore */ }
|
|
2952
|
-
if (agentId === state.viewingAgentId) {
|
|
2953
|
-
exitInternalAgentView();
|
|
2954
|
-
} else {
|
|
2955
|
-
setInternalAgentView((prev) => ({
|
|
2956
|
-
...prev,
|
|
2957
|
-
barIndex: Math.min(Number(prev.barIndex) || 0, Math.max(0, displayAgents.length - 1)),
|
|
2958
|
-
}));
|
|
2959
|
-
}
|
|
2960
|
-
return true;
|
|
2961
|
-
}
|
|
2962
|
-
return true;
|
|
2963
|
-
};
|
|
2964
|
-
|
|
2965
|
-
const handleInternalAgentViewKey = (input, key = {}) => {
|
|
2966
|
-
if (!state.viewingAgentId) return false;
|
|
2967
|
-
const keyName = resolveInternalKeyName(input, key);
|
|
2968
|
-
|
|
2969
|
-
if (state.focusMode === "dashboard") {
|
|
2970
|
-
return handleInternalAgentDashboardKey(input, key);
|
|
2971
|
-
}
|
|
2972
|
-
|
|
2973
|
-
if (keyName === "escape") {
|
|
2974
|
-
exitInternalAgentView();
|
|
2975
|
-
return true;
|
|
2976
|
-
}
|
|
2977
|
-
if (keyName === "down") {
|
|
2978
|
-
setInternalAgentView((prev) => ({ ...prev, barIndex: 0 }));
|
|
2979
|
-
dispatch({ type: "focus/set", mode: "dashboard" });
|
|
2980
|
-
return true;
|
|
2981
|
-
}
|
|
2982
|
-
if (keyName === "return" || keyName === "enter") {
|
|
2983
|
-
submitInternalAgentInput();
|
|
2984
|
-
return true;
|
|
2985
|
-
}
|
|
2986
|
-
if (key && key.ctrl && keyName === "u") {
|
|
2987
|
-
setInternalAgentView((prev) => ({ ...prev, input: "", cursor: 0 }));
|
|
2988
|
-
return true;
|
|
2989
|
-
}
|
|
2990
|
-
if (key && key.ctrl && keyName === "a") {
|
|
2991
|
-
setInternalAgentView((prev) => ({ ...prev, cursor: 0 }));
|
|
2992
|
-
return true;
|
|
2993
|
-
}
|
|
2994
|
-
if (key && key.ctrl && keyName === "e") {
|
|
2995
|
-
setInternalAgentView((prev) => ({ ...prev, cursor: String(prev.input || "").length }));
|
|
2996
|
-
return true;
|
|
2997
|
-
}
|
|
2998
|
-
if (keyName === "left") {
|
|
2999
|
-
setInternalAgentView((prev) => ({
|
|
3000
|
-
...prev,
|
|
3001
|
-
cursor: previousInternalBoundary(prev.input, prev.cursor),
|
|
3002
|
-
}));
|
|
3003
|
-
return true;
|
|
3004
|
-
}
|
|
3005
|
-
if (keyName === "right") {
|
|
3006
|
-
setInternalAgentView((prev) => ({
|
|
3007
|
-
...prev,
|
|
3008
|
-
cursor: nextInternalBoundary(prev.input, prev.cursor),
|
|
3009
|
-
}));
|
|
3010
|
-
return true;
|
|
3011
|
-
}
|
|
3012
|
-
if (keyName === "backspace") {
|
|
3013
|
-
setInternalAgentView((prev) => {
|
|
3014
|
-
const cursor = Number.isFinite(prev.cursor) ? prev.cursor : String(prev.input || "").length;
|
|
3015
|
-
if (cursor <= 0) return prev;
|
|
3016
|
-
const previous = previousInternalBoundary(prev.input, cursor);
|
|
3017
|
-
return {
|
|
3018
|
-
...prev,
|
|
3019
|
-
input: String(prev.input || "").slice(0, previous) + String(prev.input || "").slice(cursor),
|
|
3020
|
-
cursor: previous,
|
|
3021
|
-
};
|
|
3022
|
-
});
|
|
3023
|
-
return true;
|
|
3024
|
-
}
|
|
3025
|
-
if (keyName === "delete") {
|
|
3026
|
-
setInternalAgentView((prev) => {
|
|
3027
|
-
const text = String(prev.input || "");
|
|
3028
|
-
const cursor = Number.isFinite(prev.cursor) ? prev.cursor : text.length;
|
|
3029
|
-
if (cursor >= text.length) return prev;
|
|
3030
|
-
const next = nextInternalBoundary(text, cursor);
|
|
3031
|
-
return {
|
|
3032
|
-
...prev,
|
|
3033
|
-
input: text.slice(0, cursor) + text.slice(next),
|
|
3034
|
-
cursor,
|
|
3035
|
-
};
|
|
3036
|
-
});
|
|
3037
|
-
return true;
|
|
3038
|
-
}
|
|
3039
|
-
if (input
|
|
3040
|
-
&& !(key && key.ctrl)
|
|
3041
|
-
&& !(key && key.meta)
|
|
3042
|
-
&& !/^[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]+$/.test(input)) {
|
|
3043
|
-
const clean = String(input).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
3044
|
-
setInternalAgentView((prev) => {
|
|
3045
|
-
const text = String(prev.input || "");
|
|
3046
|
-
const cursor = Number.isFinite(prev.cursor) ? prev.cursor : text.length;
|
|
3047
|
-
return {
|
|
3048
|
-
...prev,
|
|
3049
|
-
input: text.slice(0, cursor) + clean + text.slice(cursor),
|
|
3050
|
-
cursor: cursor + clean.length,
|
|
3051
|
-
};
|
|
3052
|
-
});
|
|
3053
|
-
return true;
|
|
3054
|
-
}
|
|
3055
|
-
return true;
|
|
3056
|
-
};
|
|
3057
|
-
|
|
3058
|
-
useInput((input, key) => {
|
|
3059
|
-
if (multiWindowActive) {
|
|
3060
|
-
const controller = multiWindowControllerRef.current;
|
|
3061
|
-
const termFocused = mwTerminalFocusedRef.current;
|
|
3062
|
-
if (key.ctrl && input === "q") {
|
|
3063
|
-
if (controller && typeof controller.handleKey === "function") {
|
|
3064
|
-
controller.handleKey({ name: "q", ctrl: true, sequence: "" });
|
|
3065
|
-
}
|
|
3066
|
-
mwTerminalFocusedRef.current = false;
|
|
3067
|
-
setMwTerminalFocused(false);
|
|
3068
|
-
return;
|
|
3069
|
-
}
|
|
3070
|
-
if (key.ctrl && input === "w") {
|
|
3071
|
-
const agents = controller ? controller.getAgentIds() : [];
|
|
3072
|
-
if (agents.length === 0) return;
|
|
3073
|
-
if (!termFocused) {
|
|
3074
|
-
if (controller) controller.focusAgent(agents[0]);
|
|
3075
|
-
mwTerminalFocusedRef.current = true;
|
|
3076
|
-
setMwTerminalFocused(true);
|
|
3077
|
-
} else {
|
|
3078
|
-
const current = controller ? controller.getFocused() : null;
|
|
3079
|
-
const idx = current ? agents.indexOf(current) : -1;
|
|
3080
|
-
if (idx >= 0 && idx < agents.length - 1) {
|
|
3081
|
-
controller.focusAgent(agents[idx + 1]);
|
|
3082
|
-
} else {
|
|
3083
|
-
mwTerminalFocusedRef.current = false;
|
|
3084
|
-
setMwTerminalFocused(false);
|
|
3085
|
-
if (controller) controller.focusAgent(agents[0]);
|
|
3086
|
-
}
|
|
3087
|
-
}
|
|
3088
|
-
if (controller) controller.renderAll();
|
|
3089
|
-
return;
|
|
3090
|
-
}
|
|
3091
|
-
if (termFocused && controller && typeof controller.sendInput === "function") {
|
|
3092
|
-
const now = Date.now();
|
|
3093
|
-
const last = mwLastInputRef.current;
|
|
3094
|
-
if (input === " " && !key.ctrl && !key.meta && isCJK(last.char) && now - last.time < 150) {
|
|
3095
|
-
return;
|
|
3096
|
-
}
|
|
3097
|
-
const raw = inkKeyToRaw(input, key);
|
|
3098
|
-
if (raw) {
|
|
3099
|
-
const cleaned = raw.length > 1 && /[⺀-鿿가-豈-︰-﹏]/.test(raw)
|
|
3100
|
-
? raw.replace(/ +$/, "")
|
|
3101
|
-
: raw;
|
|
3102
|
-
if (cleaned) {
|
|
3103
|
-
controller.sendInput(cleaned);
|
|
3104
|
-
const lastChar = cleaned[cleaned.length - 1];
|
|
3105
|
-
mwLastInputRef.current = { char: lastChar, time: now };
|
|
3106
|
-
}
|
|
3107
|
-
}
|
|
3108
|
-
return;
|
|
3109
|
-
}
|
|
3110
|
-
}
|
|
3111
|
-
if (key.ctrl && input === "c") { exit(); return; }
|
|
3112
|
-
if (key.ctrl && input === "o") { dispatch({ type: "merge/expand" }); return; }
|
|
3113
|
-
if (state.viewingAgentId) {
|
|
3114
|
-
handleInternalAgentViewKey(input, key);
|
|
3115
|
-
return;
|
|
3116
|
-
}
|
|
3117
|
-
|
|
3118
|
-
// Completion popup steals arrow/Enter/Esc/Tab while it's open. The
|
|
3119
|
-
// user types to filter, picks with the cursor and accepts with Tab
|
|
3120
|
-
// or Enter; Esc dismisses by clearing the trigger character.
|
|
3121
|
-
if (completionsOpen) {
|
|
3122
|
-
if (key.upArrow) {
|
|
3123
|
-
setCompletionIndex((i) => {
|
|
3124
|
-
const next = (i - 1 + completions.length) % completions.length;
|
|
3125
|
-
setCompletionWindowStart((ws) => {
|
|
3126
|
-
if (next < ws) return next;
|
|
3127
|
-
if (next === completions.length - 1) {
|
|
3128
|
-
// wrapped to the bottom — snap window to the tail.
|
|
3129
|
-
return Math.max(0, completions.length - POPUP_PAGE_SIZE);
|
|
3130
|
-
}
|
|
3131
|
-
return ws;
|
|
3132
|
-
});
|
|
3133
|
-
return next;
|
|
3134
|
-
});
|
|
3135
|
-
return;
|
|
3136
|
-
}
|
|
3137
|
-
if (key.downArrow) {
|
|
3138
|
-
setCompletionIndex((i) => {
|
|
3139
|
-
const next = (i + 1) % completions.length;
|
|
3140
|
-
setCompletionWindowStart((ws) => {
|
|
3141
|
-
if (next === 0) return 0; // wrapped to the head
|
|
3142
|
-
if (next >= ws + POPUP_PAGE_SIZE) return next - POPUP_PAGE_SIZE + 1;
|
|
3143
|
-
return ws;
|
|
3144
|
-
});
|
|
3145
|
-
return next;
|
|
3146
|
-
});
|
|
3147
|
-
return;
|
|
3148
|
-
}
|
|
3149
|
-
if (key.return) {
|
|
3150
|
-
// Final/leaf completions submit immediately; parents only fill draft.
|
|
3151
|
-
const item = completions[Math.max(0, Math.min(completions.length - 1, completionIndex))];
|
|
3152
|
-
if (item && !item.hasChildren) {
|
|
3153
|
-
const cmd = String(item.replace || "").trim();
|
|
3154
|
-
setCompletionIndex(0);
|
|
3155
|
-
setCompletionSuppressedDraft(null);
|
|
3156
|
-
if (cmd) void submit(cmd);
|
|
3157
|
-
return;
|
|
3158
|
-
}
|
|
3159
|
-
acceptCompletion();
|
|
3160
|
-
return;
|
|
3161
|
-
}
|
|
3162
|
-
if (key.tab) {
|
|
3163
|
-
acceptCompletion();
|
|
3164
|
-
return;
|
|
3165
|
-
}
|
|
3166
|
-
if (key.escape) {
|
|
3167
|
-
setCompletionSuppressedDraft(null);
|
|
3168
|
-
dispatch({ type: "draft/clear" });
|
|
3169
|
-
return;
|
|
3170
|
-
}
|
|
3171
|
-
}
|
|
3172
|
-
|
|
3173
|
-
if (key.tab) {
|
|
3174
|
-
if (state.focusMode === "dashboard") {
|
|
3175
|
-
dispatch({ type: "focus/set", mode: "input" });
|
|
3176
|
-
return;
|
|
3177
|
-
}
|
|
3178
|
-
dispatch({ type: "focus/set", mode: "dashboard" });
|
|
3179
|
-
dispatch({ type: "view/set", view: props.globalMode ? "projects" : "agents" });
|
|
3180
|
-
if (props.globalMode && state.projects.length > 0 && state.selectedProjectIndex < 0) {
|
|
3181
|
-
dispatch({ type: "view/set", view: "projects" });
|
|
3182
|
-
dispatch({ type: "projects/select", index: 0, projectRoot: resolveProjectRowRoot(state.projects[0]) });
|
|
3183
|
-
} else if (!props.globalMode && state.agents.length > 0 && state.selectedAgentIndex < 0) {
|
|
3184
|
-
dispatch({ type: "agents/select", index: 0 });
|
|
3185
|
-
} else if (props.globalMode && state.projects.length === 0) {
|
|
3186
|
-
dispatch({ type: "view/set", view: "agents" });
|
|
3187
|
-
if (displayAgents.length > 0 && state.selectedAgentIndex < 0) {
|
|
3188
|
-
dispatch({ type: "agents/select", index: 0 });
|
|
3189
|
-
}
|
|
3190
|
-
}
|
|
3191
|
-
return;
|
|
3192
|
-
}
|
|
3193
|
-
// Dashboard focus + agents view + agent selected + Enter: hand off
|
|
3194
|
-
// to the agent view. Queue-only internal agents stay inside Ink,
|
|
3195
|
-
// matching blessed's useBus view; PTY/socket agents still hand off
|
|
3196
|
-
// to the raw mirror via the runChatInk loop.
|
|
3197
|
-
if (key.return && state.focusMode === "dashboard"
|
|
3198
|
-
&& state.dashboardView === "agents"
|
|
3199
|
-
&& state.agentSelectionMode
|
|
3200
|
-
&& state.selectedAgentIndex >= 0) {
|
|
3201
|
-
const agentId = displayAgents[state.selectedAgentIndex];
|
|
3202
|
-
if (agentId && multiWindowActive) {
|
|
3203
|
-
const controller = multiWindowControllerRef.current;
|
|
3204
|
-
if (controller && typeof controller.focusAgent === "function") {
|
|
3205
|
-
controller.focusAgent(agentId);
|
|
3206
|
-
}
|
|
3207
|
-
setMwTerminalFocused(true);
|
|
3208
|
-
mwTerminalFocusedRef.current = true;
|
|
3209
|
-
dispatch({ type: "focus/set", mode: "input" });
|
|
3210
|
-
return;
|
|
3211
|
-
}
|
|
3212
|
-
if (agentId) {
|
|
3213
|
-
const enterPayload = buildAgentEnterPayload(agentId);
|
|
3214
|
-
const action = resolveDashboardAgentEnterAction(enterPayload);
|
|
3215
|
-
if (action === "internal") {
|
|
3216
|
-
enterInternalAgentView(enterPayload);
|
|
3217
|
-
return;
|
|
3218
|
-
}
|
|
3219
|
-
if (action === "activate") {
|
|
3220
|
-
dispatch({ type: "agents/clearTarget" });
|
|
3221
|
-
dispatch({ type: "focus/set", mode: "input" });
|
|
3222
|
-
activateExternalAgent(agentId);
|
|
3223
|
-
return;
|
|
3224
|
-
}
|
|
3225
|
-
if (typeof props.requestEnterAgentView === "function") {
|
|
3226
|
-
props.requestEnterAgentView(agentId, enterPayload);
|
|
3227
|
-
exit();
|
|
3228
|
-
}
|
|
3229
|
-
}
|
|
3230
|
-
return;
|
|
3231
|
-
}
|
|
3232
|
-
// Dashboard focus + projects view: ←/→ moves the highlighted
|
|
3233
|
-
// project, Enter switches the daemon connection to that project,
|
|
3234
|
-
// Ctrl+X stops it.
|
|
3235
|
-
if (state.focusMode === "dashboard" && state.dashboardView === "projects" && state.projects.length === 0) {
|
|
3236
|
-
if (key.downArrow) {
|
|
3237
|
-
for (const action of buildEmptyProjectsDownActions(state, displayAgents)) dispatch(action);
|
|
3238
|
-
return;
|
|
3239
|
-
}
|
|
3240
|
-
if (key.upArrow || key.return || key.escape) {
|
|
3241
|
-
dispatch({ type: "focus/set", mode: "input" });
|
|
3242
|
-
}
|
|
3243
|
-
return;
|
|
3244
|
-
}
|
|
3245
|
-
if (state.focusMode === "dashboard" && state.dashboardView === "projects" && state.projects.length > 0) {
|
|
3246
|
-
if (key.leftArrow || key.rightArrow) {
|
|
3247
|
-
const cur = Number.isFinite(state.selectedProjectIndex) && state.selectedProjectIndex >= 0
|
|
3248
|
-
? state.selectedProjectIndex : 0;
|
|
3249
|
-
const next = key.leftArrow
|
|
3250
|
-
? Math.max(0, cur - 1)
|
|
3251
|
-
: Math.min(state.projects.length - 1, cur + 1);
|
|
3252
|
-
if (next === cur) return;
|
|
3253
|
-
dispatch({ type: "projects/select", index: next, projectRoot: resolveProjectRowRoot(state.projects[next]) });
|
|
3254
|
-
// Slide the visible window to keep the cursor on screen. We mirror
|
|
3255
|
-
// clampAgentWindowWithSelection's logic with maxProjectWindow=5.
|
|
3256
|
-
const max = Math.max(1, Math.min(5, state.projects.length));
|
|
3257
|
-
let nextStart = state.projectListWindowStart || 0;
|
|
3258
|
-
if (next < nextStart) nextStart = next;
|
|
3259
|
-
else if (next >= nextStart + max) nextStart = next - max + 1;
|
|
3260
|
-
if (nextStart !== state.projectListWindowStart) {
|
|
3261
|
-
dispatch({ type: "projects/window", windowStart: nextStart });
|
|
3262
|
-
}
|
|
3263
|
-
|
|
3264
|
-
const proj = state.projects[next];
|
|
3265
|
-
const target = resolveProjectRowRoot(proj);
|
|
3266
|
-
if (target && state.globalScope === "project") {
|
|
3267
|
-
void switchToProjectRoot(target);
|
|
3268
|
-
}
|
|
3269
|
-
return;
|
|
3270
|
-
}
|
|
3271
|
-
if (key.return) {
|
|
3272
|
-
const cur = state.selectedProjectIndex >= 0 ? state.selectedProjectIndex : 0;
|
|
3273
|
-
const proj = state.projects[cur];
|
|
3274
|
-
const target = resolveProjectRowRoot(proj);
|
|
3275
|
-
void switchToProjectRoot(target, { focusInput: true });
|
|
3276
|
-
return;
|
|
3277
|
-
}
|
|
3278
|
-
if (input
|
|
3279
|
-
&& !(key && key.ctrl)
|
|
3280
|
-
&& !(key && key.meta)
|
|
3281
|
-
&& !/^[\x00-\x1f\x7f]+$/.test(input)
|
|
3282
|
-
&& !input.includes("\n")
|
|
3283
|
-
&& !input.includes("\r")) {
|
|
3284
|
-
const cur = state.selectedProjectIndex >= 0 ? state.selectedProjectIndex : 0;
|
|
3285
|
-
const target = resolveProjectRowRoot(state.projects[cur]);
|
|
3286
|
-
void switchToProjectRoot(target, { focusInput: true });
|
|
3287
|
-
dispatch({ type: "draft/set", value: `${state.draft || ""}${input}` });
|
|
3288
|
-
setDraftVersion((v) => v + 1);
|
|
3289
|
-
return;
|
|
3290
|
-
}
|
|
3291
|
-
if (key.ctrl && input === "x") {
|
|
3292
|
-
void closeSelectedProject();
|
|
3293
|
-
return;
|
|
3294
|
-
}
|
|
3295
|
-
if (key.upArrow) {
|
|
3296
|
-
// Up out of projects → toggle back to input.
|
|
3297
|
-
dispatch({ type: "projects/clearSelection" });
|
|
3298
|
-
dispatch({ type: "focus/set", mode: "input" });
|
|
3299
|
-
return;
|
|
3300
|
-
}
|
|
3301
|
-
if (key.escape) {
|
|
3302
|
-
dispatch({ type: "projects/clearSelection" });
|
|
3303
|
-
if (state.globalScope === "project") {
|
|
3304
|
-
void switchToControllerRoot();
|
|
3305
|
-
return;
|
|
3306
|
-
}
|
|
3307
|
-
dispatch({ type: "focus/set", mode: "input" });
|
|
3308
|
-
return;
|
|
3309
|
-
}
|
|
3310
|
-
if (key.downArrow) {
|
|
3311
|
-
// Down from projects → agents row stays in dashboard focus.
|
|
3312
|
-
dispatch({ type: "view/set", view: "agents" });
|
|
3313
|
-
if (displayAgents.length > 0 && state.selectedAgentIndex < 0) {
|
|
3314
|
-
dispatch({ type: "agents/select", index: 0 });
|
|
3315
|
-
}
|
|
3316
|
-
return;
|
|
3317
|
-
}
|
|
3318
|
-
}
|
|
3319
|
-
|
|
3320
|
-
if (state.focusMode === "dashboard"
|
|
3321
|
-
&& state.dashboardView === "agents"
|
|
3322
|
-
&& input
|
|
3323
|
-
&& !(key && key.ctrl)
|
|
3324
|
-
&& !(key && key.meta)
|
|
3325
|
-
&& !/^[\x00-\x1f\x7f]+$/.test(input)
|
|
3326
|
-
&& !input.includes("\n")
|
|
3327
|
-
&& !input.includes("\r")) {
|
|
3328
|
-
if (displayAgents.length > 0 && state.selectedAgentIndex < 0) {
|
|
3329
|
-
dispatch({ type: "agents/select", index: 0 });
|
|
3330
|
-
}
|
|
3331
|
-
dispatch({ type: "focus/set", mode: "input" });
|
|
3332
|
-
dispatch({ type: "draft/set", value: `${state.draft || ""}${input}` });
|
|
3333
|
-
setDraftVersion((v) => v + 1);
|
|
3334
|
-
return;
|
|
3335
|
-
}
|
|
3336
|
-
|
|
3337
|
-
// Dashboard focus on agents/mode/provider/cron — ↑↓ flip between
|
|
3338
|
-
// sibling views, ←/→ pick within the active view, Esc returns to
|
|
3339
|
-
// the input.
|
|
3340
|
-
if (state.focusMode === "dashboard"
|
|
3341
|
-
&& (state.dashboardView === "agents"
|
|
3342
|
-
|| state.dashboardView === "mode"
|
|
3343
|
-
|| state.dashboardView === "provider"
|
|
3344
|
-
|| state.dashboardView === "cron")) {
|
|
3345
|
-
if (key.escape) {
|
|
3346
|
-
if (state.dashboardView === "agents") dispatch({ type: "agents/clearTarget" });
|
|
3347
|
-
dispatch({ type: "focus/set", mode: "input" });
|
|
3348
|
-
return;
|
|
3349
|
-
}
|
|
3350
|
-
if (state.dashboardView === "agents") {
|
|
3351
|
-
if (key.leftArrow || key.rightArrow) {
|
|
3352
|
-
if (displayAgents.length > 0) {
|
|
3353
|
-
const cur = state.selectedAgentIndex < 0 ? 0 : state.selectedAgentIndex;
|
|
3354
|
-
const next = key.leftArrow
|
|
3355
|
-
? Math.max(0, cur - 1)
|
|
3356
|
-
: Math.min(displayAgents.length - 1, cur + 1);
|
|
3357
|
-
dispatch({ type: "agents/select", index: next });
|
|
3358
|
-
}
|
|
3359
|
-
return;
|
|
3360
|
-
}
|
|
3361
|
-
if (key.ctrl && input === "x") {
|
|
3362
|
-
if (state.selectedAgentIndex >= 0 && state.selectedAgentIndex < displayAgents.length) {
|
|
3363
|
-
const agentId = displayAgents[state.selectedAgentIndex];
|
|
3364
|
-
try {
|
|
3365
|
-
const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
|
|
3366
|
-
if (props.daemonConnection && typeof props.daemonConnection.send === "function") {
|
|
3367
|
-
props.daemonConnection.send({ type: IPC_REQUEST_TYPES.CLOSE_AGENT, agent_id: agentId });
|
|
3368
|
-
}
|
|
3369
|
-
} catch (err) {
|
|
3370
|
-
dispatch({ type: "log/append", text: `Error: ${err && err.message ? err.message : err}` });
|
|
3371
|
-
}
|
|
3372
|
-
dispatch({ type: "agents/clearTarget" });
|
|
3373
|
-
dispatch({ type: "focus/set", mode: "input" });
|
|
3374
|
-
}
|
|
3375
|
-
return;
|
|
3376
|
-
}
|
|
3377
|
-
if (key.return) {
|
|
3378
|
-
dispatch({ type: "focus/set", mode: "input" });
|
|
3379
|
-
return;
|
|
3380
|
-
}
|
|
3381
|
-
if (key.downArrow) {
|
|
3382
|
-
dispatch({ type: "view/set", view: "mode" });
|
|
3383
|
-
const launchModeIndex = state.modeOptions.indexOf(state.settings.launchMode);
|
|
3384
|
-
dispatch({ type: "modeIndex/set", index: launchModeIndex >= 0 ? launchModeIndex : 0 });
|
|
3385
|
-
return;
|
|
3386
|
-
}
|
|
3387
|
-
if (key.upArrow) {
|
|
3388
|
-
// Top of the agents tier: in global mode go back to projects,
|
|
3389
|
-
// otherwise leave dashboard focus altogether.
|
|
3390
|
-
dispatch({ type: "agents/clearTarget" });
|
|
3391
|
-
if (props.globalMode) dispatch({ type: "view/set", view: "projects" });
|
|
3392
|
-
else dispatch({ type: "focus/set", mode: "input" });
|
|
3393
|
-
return;
|
|
3394
|
-
}
|
|
3395
|
-
}
|
|
3396
|
-
if (state.dashboardView === "mode") {
|
|
3397
|
-
if (key.leftArrow || key.rightArrow) {
|
|
3398
|
-
const len = state.modeOptions.length;
|
|
3399
|
-
if (len > 0) {
|
|
3400
|
-
const cur = state.selectedModeIndex;
|
|
3401
|
-
const next = key.leftArrow
|
|
3402
|
-
? Math.max(0, cur - 1)
|
|
3403
|
-
: Math.min(len - 1, cur + 1);
|
|
3404
|
-
if (next !== cur) dispatch({ type: "modeIndex/set", index: next });
|
|
3405
|
-
}
|
|
3406
|
-
return;
|
|
3407
|
-
}
|
|
3408
|
-
if (key.downArrow) {
|
|
3409
|
-
dispatch({ type: "view/set", view: "provider" });
|
|
3410
|
-
const providerIndex = state.providerOptions.findIndex((opt) => opt.value === state.settings.agentProvider);
|
|
3411
|
-
dispatch({ type: "providerIndex/set", index: providerIndex >= 0 ? providerIndex : 0 });
|
|
3412
|
-
return;
|
|
3413
|
-
}
|
|
3414
|
-
if (key.upArrow) { dispatch({ type: "view/set", view: "agents" }); return; }
|
|
3415
|
-
if (key.return) { applySelectedMode(); return; }
|
|
3416
|
-
}
|
|
3417
|
-
if (state.dashboardView === "provider") {
|
|
3418
|
-
if (key.leftArrow || key.rightArrow) {
|
|
3419
|
-
const len = state.providerOptions.length;
|
|
3420
|
-
if (len > 0) {
|
|
3421
|
-
const cur = state.selectedProviderIndex;
|
|
3422
|
-
const next = key.leftArrow
|
|
3423
|
-
? Math.max(0, cur - 1)
|
|
3424
|
-
: Math.min(len - 1, cur + 1);
|
|
3425
|
-
if (next !== cur) dispatch({ type: "providerIndex/set", index: next });
|
|
3426
|
-
}
|
|
3427
|
-
return;
|
|
3428
|
-
}
|
|
3429
|
-
if (key.downArrow) {
|
|
3430
|
-
dispatch({ type: "view/set", view: "cron" });
|
|
3431
|
-
dispatch({ type: "cronIndex/set", index: state.cronTasks.length > 0 ? 0 : -1 });
|
|
3432
|
-
return;
|
|
3433
|
-
}
|
|
3434
|
-
if (key.upArrow) { dispatch({ type: "view/set", view: "mode" }); return; }
|
|
3435
|
-
if (key.return) { applySelectedProvider(); return; }
|
|
3436
|
-
}
|
|
3437
|
-
if (state.dashboardView === "cron") {
|
|
3438
|
-
if (key.leftArrow || key.rightArrow) {
|
|
3439
|
-
const len = state.cronTasks.length;
|
|
3440
|
-
if (len > 0) {
|
|
3441
|
-
const cur = state.selectedCronIndex < 0 ? 0 : state.selectedCronIndex;
|
|
3442
|
-
const next = key.leftArrow ? Math.max(0, cur - 1) : Math.min(len - 1, cur + 1);
|
|
3443
|
-
if (next !== cur) dispatch({ type: "cronIndex/set", index: next });
|
|
3444
|
-
}
|
|
3445
|
-
return;
|
|
3446
|
-
}
|
|
3447
|
-
if (key.downArrow) {
|
|
3448
|
-
// Cron is the last tier — don't wrap back to agents.
|
|
3449
|
-
return;
|
|
3450
|
-
}
|
|
3451
|
-
if (key.upArrow) { dispatch({ type: "view/set", view: "provider" }); return; }
|
|
3452
|
-
if (key.ctrl && input === "x") {
|
|
3453
|
-
const maxIndex = state.cronTasks.length - 1;
|
|
3454
|
-
if (maxIndex >= 0 && state.selectedCronIndex >= 0 && state.selectedCronIndex <= maxIndex) {
|
|
3455
|
-
const task = state.cronTasks[state.selectedCronIndex];
|
|
3456
|
-
const id = task && task.id ? String(task.id).trim() : "";
|
|
3457
|
-
if (id) {
|
|
3458
|
-
sendCronStop(id);
|
|
3459
|
-
return;
|
|
3460
|
-
}
|
|
3461
|
-
}
|
|
3462
|
-
dispatch({ type: "focus/set", mode: "input" });
|
|
3463
|
-
return;
|
|
3464
|
-
}
|
|
3465
|
-
if (key.return) { dispatch({ type: "focus/set", mode: "input" }); return; }
|
|
3466
|
-
}
|
|
3467
|
-
}
|
|
3468
|
-
|
|
3469
|
-
// Multi-window typing handler: replicates MultilineInput's key handling
|
|
3470
|
-
// so both modes share the same input behavior.
|
|
3471
|
-
if (multiWindowActive && state.focusMode !== "dashboard") {
|
|
3472
|
-
const intercepted = completionsOpen && (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow || key.return);
|
|
3473
|
-
if (intercepted) return;
|
|
3474
|
-
if (key.return) {
|
|
3475
|
-
if (key.meta) {
|
|
3476
|
-
const before = (state.draft || "").slice(0, mwCursor);
|
|
3477
|
-
const after = (state.draft || "").slice(mwCursor);
|
|
3478
|
-
dispatch({ type: "draft/set", value: `${before}\n${after}` });
|
|
3479
|
-
setMwCursor(mwCursor + 1);
|
|
3480
|
-
return;
|
|
3481
|
-
}
|
|
3482
|
-
const value = String(state.draft || "").trim();
|
|
3483
|
-
if (value) { submit(value); setMwCursor(0); }
|
|
3484
|
-
return;
|
|
3485
|
-
}
|
|
3486
|
-
if (key.escape) {
|
|
3487
|
-
if (state.agentSelectionMode) { dispatch({ type: "agents/clearTarget" }); return; }
|
|
3488
|
-
if (state.draft) { dispatch({ type: "draft/clear" }); setMwCursor(0); }
|
|
3489
|
-
else if (state.status && state.status.message) { dispatch({ type: "status/idle" }); }
|
|
3490
|
-
return;
|
|
3491
|
-
}
|
|
3492
|
-
if (key.ctrl) {
|
|
3493
|
-
if (input === "a") { setMwCursor(fmt.moveCursorToVisualLineBoundary({ cursorPos: mwCursor, inputValue: state.draft || "", width: inputWidth, boundary: "start" })); return; }
|
|
3494
|
-
if (input === "e") { setMwCursor(fmt.moveCursorToVisualLineBoundary({ cursorPos: mwCursor, inputValue: state.draft || "", width: inputWidth, boundary: "end" })); return; }
|
|
3495
|
-
if (input === "b") { setMwCursor(fmt.moveCursorHorizontally(mwCursor, state.draft || "", "left")); return; }
|
|
3496
|
-
if (input === "f") { setMwCursor(fmt.moveCursorHorizontally(mwCursor, state.draft || "", "right")); return; }
|
|
3497
|
-
if (input === "d") { const d = state.draft || ""; if (mwCursor < d.length) { dispatch({ type: "draft/set", value: d.slice(0, mwCursor) + d.slice(mwCursor + 1) }); } return; }
|
|
3498
|
-
if (input === "h") { const d = state.draft || ""; if (mwCursor > 0) { dispatch({ type: "draft/set", value: d.slice(0, mwCursor - 1) + d.slice(mwCursor) }); setMwCursor(mwCursor - 1); } return; }
|
|
3499
|
-
if (input === "k") { dispatch({ type: "draft/set", value: (state.draft || "").slice(0, mwCursor) }); return; }
|
|
3500
|
-
if (input === "u") { dispatch({ type: "draft/set", value: (state.draft || "").slice(mwCursor) }); setMwCursor(0); return; }
|
|
3501
|
-
if (input === "w") { const r = fmt.deleteWordBeforeCursor(state.draft || "", mwCursor); dispatch({ type: "draft/set", value: r.value }); setMwCursor(r.cursorPos); return; }
|
|
3502
|
-
return;
|
|
3503
|
-
}
|
|
3504
|
-
if (key.meta) {
|
|
3505
|
-
if (input === "b") { setMwCursor(fmt.moveCursorByWord(state.draft || "", mwCursor, "backward")); return; }
|
|
3506
|
-
if (input === "f") { setMwCursor(fmt.moveCursorByWord(state.draft || "", mwCursor, "forward")); return; }
|
|
3507
|
-
if (input === "d") { const end = fmt.moveCursorByWord(state.draft || "", mwCursor, "forward"); const d = state.draft || ""; dispatch({ type: "draft/set", value: d.slice(0, mwCursor) + d.slice(end) }); return; }
|
|
3508
|
-
}
|
|
3509
|
-
if (key.backspace || key.delete) {
|
|
3510
|
-
const d = state.draft || "";
|
|
3511
|
-
if (key.meta || key.ctrl) { const r = fmt.deleteWordBeforeCursor(d, mwCursor); dispatch({ type: "draft/set", value: r.value }); setMwCursor(r.cursorPos); }
|
|
3512
|
-
else if (mwCursor > 0) { dispatch({ type: "draft/set", value: d.slice(0, mwCursor - 1) + d.slice(mwCursor) }); setMwCursor(mwCursor - 1); }
|
|
3513
|
-
return;
|
|
3514
|
-
}
|
|
3515
|
-
if (key.leftArrow) {
|
|
3516
|
-
if (!state.draft && typeof onArrowSideAtEmpty === "function") { onArrowSideAtEmpty("left"); return; }
|
|
3517
|
-
setMwCursor(fmt.moveCursorHorizontally(mwCursor, state.draft || "", "left"));
|
|
3518
|
-
return;
|
|
3519
|
-
}
|
|
3520
|
-
if (key.rightArrow) {
|
|
3521
|
-
if (!state.draft && typeof onArrowSideAtEmpty === "function") { onArrowSideAtEmpty("right"); return; }
|
|
3522
|
-
setMwCursor(fmt.moveCursorHorizontally(mwCursor, state.draft || "", "right"));
|
|
3523
|
-
return;
|
|
3524
|
-
}
|
|
3525
|
-
if (key.upArrow) { onArrowUpAtTop(); return; }
|
|
3526
|
-
if (key.downArrow) { onArrowDownAtBottom(state.draft); return; }
|
|
3527
|
-
if (input && !key.ctrl && !key.meta) {
|
|
3528
|
-
const filtered = input.replace(/[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]/g, "");
|
|
3529
|
-
if (filtered) {
|
|
3530
|
-
const d = state.draft || "";
|
|
3531
|
-
dispatch({ type: "draft/set", value: d.slice(0, mwCursor) + filtered + d.slice(mwCursor) });
|
|
3532
|
-
setMwCursor(mwCursor + filtered.length);
|
|
3533
|
-
}
|
|
3534
|
-
return;
|
|
3535
|
-
}
|
|
3536
|
-
return;
|
|
3537
|
-
}
|
|
3538
|
-
}, { isActive: interactive });
|
|
3539
|
-
|
|
3540
|
-
// Chrome text for multi-window mode only — the visible status bar is the
|
|
3541
|
-
// ChatStatusLine component below, which owns its spinner tick. The
|
|
3542
|
-
// multi-window controller re-renders on its own events, so a static
|
|
3543
|
-
// first-frame indicator here matches what was effectively shown before.
|
|
3544
|
-
const statusText = computeStatusText(state.status, 0);
|
|
3545
|
-
const inputWidth = Math.max(20, (size.cols || 80) - 4);
|
|
3546
|
-
const promptPrefix = (() => {
|
|
3547
|
-
const projectPrefix = inCommittedProjectScope && currentProjectLabel ? `${currentProjectLabel} ` : "";
|
|
3548
|
-
const visibleTargetAgentLabel = state.focusMode === "dashboard" && state.dashboardView !== "agents"
|
|
3549
|
-
? ""
|
|
3550
|
-
: targetAgentLabel;
|
|
3551
|
-
if (visibleTargetAgentLabel) return `${projectPrefix}›@${visibleTargetAgentLabel} `;
|
|
3552
|
-
return `${projectPrefix}› `;
|
|
3553
|
-
})();
|
|
3554
|
-
|
|
3555
|
-
if (multiWindowActive) {
|
|
3556
|
-
const { renderDashboardLines } = require("./DashboardBar");
|
|
3557
|
-
const clampedCursor = fmt.clampCursorPos(mwCursor, state.draft || "");
|
|
3558
|
-
multiWindowChromeRef.current = {
|
|
3559
|
-
statusText,
|
|
3560
|
-
promptPrefix,
|
|
3561
|
-
draft: state.draft || "",
|
|
3562
|
-
cursor: clampedCursor,
|
|
3563
|
-
completions: completionsOpen ? completions : [],
|
|
3564
|
-
completionIndex: completionsOpen ? completionIndex : -1,
|
|
3565
|
-
completionWindowStart: completionsOpen ? completionWindowStart : 0,
|
|
3566
|
-
completionPageSize: POPUP_PAGE_SIZE,
|
|
3567
|
-
dashboardLines: renderDashboardLines({
|
|
3568
|
-
dashboardView: state.dashboardView,
|
|
3569
|
-
focusMode: state.focusMode,
|
|
3570
|
-
globalMode: state.globalMode,
|
|
3571
|
-
globalScope: state.globalScope,
|
|
3572
|
-
activeAgents: displayAgents,
|
|
3573
|
-
activeAgentMeta: displayAgentMeta,
|
|
3574
|
-
activeAgentId: targetAgentId || "",
|
|
3575
|
-
selectedAgentIndex: state.selectedAgentIndex,
|
|
3576
|
-
agentListWindowStart: state.agentListWindowStart,
|
|
3577
|
-
projectListWindowStart: state.projectListWindowStart,
|
|
3578
|
-
maxProjectWindow: 5,
|
|
3579
|
-
maxWidth: Math.max(20, size.cols || 80),
|
|
3580
|
-
getAgentLabel: (id) => getAgentLabelFor(displayAgentMeta.get(id), id),
|
|
3581
|
-
getAgentState: (id) => {
|
|
3582
|
-
const meta = displayAgentMeta.get(id);
|
|
3583
|
-
return meta && typeof meta.activity_state === "string" ? meta.activity_state : "";
|
|
3584
|
-
},
|
|
3585
|
-
launchMode: state.settings.launchMode,
|
|
3586
|
-
agentProvider: state.settings.agentProvider,
|
|
3587
|
-
modeOptions: state.modeOptions,
|
|
3588
|
-
selectedModeIndex: state.selectedModeIndex,
|
|
3589
|
-
providerOptions: state.providerOptions,
|
|
3590
|
-
selectedProviderIndex: state.selectedProviderIndex,
|
|
3591
|
-
cronTasks: state.cronTasks,
|
|
3592
|
-
selectedCronIndex: state.selectedCronIndex,
|
|
3593
|
-
projects: state.projects,
|
|
3594
|
-
selectedProjectIndex: state.selectedProjectIndex,
|
|
3595
|
-
activeProjectRoot: currentProjectRoot,
|
|
3596
|
-
dashHints: buildDashHints(state, targetAgentLabel),
|
|
3597
|
-
}),
|
|
3598
|
-
};
|
|
3599
|
-
}
|
|
3600
|
-
|
|
3601
|
-
useEffect(() => {
|
|
3602
|
-
if (!multiWindowActive) return;
|
|
3603
|
-
const controller = multiWindowControllerRef.current;
|
|
3604
|
-
if (controller && typeof controller.renderAll === "function") {
|
|
3605
|
-
controller.renderAll();
|
|
3606
|
-
}
|
|
3607
|
-
}, [multiWindowActive, completionsOpen, completions.length, completionIndex, completionWindowStart]);
|
|
3608
|
-
|
|
3609
|
-
// Append-only feed for the <Static> log area. Ink's Static renders each
|
|
3610
|
-
// item exactly once (permanently, above the live frame), which is what
|
|
3611
|
-
// stops the 10fps full-log erase/rewrite — but it also means items must
|
|
3612
|
-
// be immutable and the array must never shrink. The reducer's LOG_CAP
|
|
3613
|
-
// truncates from the head of state.logLines, so we can't pass it
|
|
3614
|
-
// directly; instead we copy only the newly appended tail (tracked via
|
|
3615
|
-
// the monotonic lineSeq) into our own list. log/clear resets lineSeq,
|
|
3616
|
-
// which bumps `generation` and remounts the Static (its internal cursor
|
|
3617
|
-
// would otherwise point past the shrunk array). The fresh array per
|
|
3618
|
-
// batch matters too: Static memoizes on the items reference.
|
|
3619
|
-
const staticLogRef = useRef({ items: [], lastSeq: 0, generation: 0 });
|
|
3620
|
-
const staticLog = useMemo(() => {
|
|
3621
|
-
const prev = staticLogRef.current || { items: [], lastSeq: 0, generation: 0 };
|
|
3622
|
-
let { items, lastSeq, generation } = prev;
|
|
3623
|
-
if (state.lineSeq < lastSeq) {
|
|
3624
|
-
items = [];
|
|
3625
|
-
lastSeq = 0;
|
|
3626
|
-
generation += 1;
|
|
3627
|
-
}
|
|
3628
|
-
const newCount = state.lineSeq - lastSeq;
|
|
3629
|
-
if (newCount > 0) {
|
|
3630
|
-
const fresh = state.logLines.slice(-newCount);
|
|
3631
|
-
items = items.slice();
|
|
3632
|
-
for (const entry of fresh) {
|
|
3633
|
-
items.push(decorateStaticLogEntry(items[items.length - 1], entry));
|
|
3634
|
-
}
|
|
3635
|
-
lastSeq = state.lineSeq;
|
|
3636
|
-
}
|
|
3637
|
-
const next = { items, lastSeq, generation };
|
|
3638
|
-
staticLogRef.current = next;
|
|
3639
|
-
return next;
|
|
3640
|
-
}, [state.logLines, state.lineSeq]);
|
|
3641
|
-
|
|
3642
|
-
// Grouped view of the in-flight stream, recomputed only when the stream
|
|
3643
|
-
// itself advances (the batched flush above keeps that cadence low)
|
|
3644
|
-
// instead of on every unrelated render.
|
|
3645
|
-
const activeStreamGroups = useMemo(() => {
|
|
3646
|
-
if (!state.activeStream) return null;
|
|
3647
|
-
const lines = activeStreamText(state.activeStream).split(/\r?\n/);
|
|
3648
|
-
const prefix = state.activeStream.publisher
|
|
3649
|
-
? `${state.activeStream.publisher}: `
|
|
3650
|
-
: "";
|
|
3651
|
-
return buildChatLogGroups(lines.map((line, idx) => ({
|
|
3652
|
-
id: `s-${idx}`,
|
|
3653
|
-
text: idx === 0 ? `${prefix}${line}` : ` ${line}`,
|
|
3654
|
-
sourceType: "bus",
|
|
3655
|
-
type: "bus",
|
|
3656
|
-
})));
|
|
3657
|
-
}, [state.activeStream]);
|
|
3658
|
-
|
|
3659
|
-
if (multiWindowActive) {
|
|
3660
|
-
return null;
|
|
3661
|
-
}
|
|
3662
|
-
|
|
3663
|
-
const renderChatLogLines = (row, { key, continuation = false, groupKind = "", marginTop = 0, marginBottom = 0 } = {}) => {
|
|
3664
|
-
const colors = CHAT_LOG_ROW_PALETTE[row.kind] || CHAT_LOG_ROW_PALETTE.plain;
|
|
3665
|
-
const cols = Math.max(20, size.cols || 80);
|
|
3666
|
-
const lines = buildChatLogDisplayLines(row, {
|
|
3667
|
-
continuation,
|
|
3668
|
-
groupKind: groupKind || row.kind,
|
|
3669
|
-
cols,
|
|
3670
|
-
});
|
|
3671
|
-
const textProps = {
|
|
3672
|
-
color: row.kind === "user" ? "green" : colors.body,
|
|
3673
|
-
bold: Boolean(
|
|
3674
|
-
row.kind === "user"
|
|
3675
|
-
|| colors.bold
|
|
3676
|
-
|| row.kind === "error"
|
|
3677
|
-
|| row.kind === "assistant"
|
|
3678
|
-
|| row.kind === "banner"
|
|
3679
|
-
),
|
|
3680
|
-
wrap: "truncate",
|
|
3681
|
-
};
|
|
3682
|
-
if (colors.dim) textProps.dimColor = true;
|
|
3683
|
-
if (row.kind === "agent" || row.kind === "report") {
|
|
3684
|
-
textProps.color = colors.speaker;
|
|
3685
|
-
}
|
|
3686
|
-
if (lines.length <= 1) {
|
|
3687
|
-
return h(Box, { key, width: "100%", marginTop, marginBottom },
|
|
3688
|
-
h(Text, textProps, (lines[0] != null ? lines[0] : " ") || " "));
|
|
3689
|
-
}
|
|
3690
|
-
return h(Box, {
|
|
3691
|
-
key,
|
|
3692
|
-
flexDirection: "column",
|
|
3693
|
-
width: "100%",
|
|
3694
|
-
marginTop,
|
|
3695
|
-
marginBottom,
|
|
3696
|
-
},
|
|
3697
|
-
...lines.map((line, idx) => h(Text, {
|
|
3698
|
-
key: `${key}-r${idx}`,
|
|
3699
|
-
...textProps,
|
|
3700
|
-
}, line || " ")));
|
|
3701
|
-
};
|
|
3702
|
-
|
|
3703
|
-
const renderChatLogEntry = (entry, group) => {
|
|
3704
|
-
const row = entry && entry.row ? entry.row : buildChatLogLineModel("");
|
|
3705
|
-
const key = entry && entry.id ? entry.id : `log-${row.body}`;
|
|
3706
|
-
if (row.kind === "spacer") {
|
|
3707
|
-
return h(Text, { key, color: "gray" }, " ");
|
|
3708
|
-
}
|
|
3709
|
-
return renderChatLogLines(row, {
|
|
3710
|
-
key,
|
|
3711
|
-
continuation: Boolean(entry && entry.continuation),
|
|
3712
|
-
groupKind: group && group.kind ? group.kind : row.kind,
|
|
3713
|
-
marginBottom: row.kind === "user" || row.kind === "divider" ? 1 : 0,
|
|
3714
|
-
});
|
|
3715
|
-
};
|
|
3716
|
-
|
|
3717
|
-
const renderChatLogGroup = (group) => {
|
|
3718
|
-
const entries = Array.isArray(group && group.entries) ? group.entries : [];
|
|
3719
|
-
if (entries.length === 0) return null;
|
|
3720
|
-
const first = entries[0] || {};
|
|
3721
|
-
const row = first.row || buildChatLogLineModel("");
|
|
3722
|
-
if (row.kind === "spacer" || row.kind === "banner" || row.kind === "divider" || row.kind === "user") {
|
|
3723
|
-
return renderChatLogEntry(first, group);
|
|
3724
|
-
}
|
|
3725
|
-
return h(Box, {
|
|
3726
|
-
key: `group-${group.id}`,
|
|
3727
|
-
flexDirection: "column",
|
|
3728
|
-
width: "100%",
|
|
3729
|
-
marginBottom: 1,
|
|
3730
|
-
},
|
|
3731
|
-
...entries.map((entry) => renderChatLogEntry(entry, group)));
|
|
3732
|
-
};
|
|
3733
|
-
|
|
3734
|
-
// Renderer for one finalized (append-only) <Static> log item. Spacing
|
|
3735
|
-
// uses decorateStaticLogEntry's marginBefore; body text is pre-wrapped to
|
|
3736
|
-
// the terminal width so Ink never wrap:"wrap"s CJK inside Static.
|
|
3737
|
-
const renderStaticChatLogItem = (item) => {
|
|
3738
|
-
const { row, groupKind, continuation, marginBefore } = item;
|
|
3739
|
-
const key = item.entry && item.entry.id ? item.entry.id : `log-${row.body}`;
|
|
3740
|
-
if (row.kind === "spacer") {
|
|
3741
|
-
return h(Box, { key, marginTop: marginBefore ? 1 : 0 },
|
|
3742
|
-
h(Text, { color: "gray" }, " "));
|
|
3743
|
-
}
|
|
3744
|
-
return renderChatLogLines(row, {
|
|
3745
|
-
key,
|
|
3746
|
-
continuation,
|
|
3747
|
-
groupKind,
|
|
3748
|
-
marginTop: marginBefore ? 1 : 0,
|
|
3749
|
-
marginBottom: row.kind === "user" || row.kind === "divider" ? 1 : 0,
|
|
3750
|
-
});
|
|
3751
|
-
};
|
|
3752
|
-
|
|
3753
|
-
if (state.viewingAgentId) {
|
|
3754
|
-
const maxWidth = Math.max(20, size.cols || 80);
|
|
3755
|
-
const logRows = Math.max(1, (size.rows || 24) - 5);
|
|
3756
|
-
const visibleRows = buildInternalLogRows(internalAgentView.lines || [], maxWidth, logRows);
|
|
3757
|
-
const inputText = String(internalAgentView.input || "");
|
|
3758
|
-
const cursor = Math.max(0, Math.min(inputText.length, Number(internalAgentView.cursor) || 0));
|
|
3759
|
-
const beforeCursor = inputText.slice(0, cursor);
|
|
3760
|
-
const cursorChar = inputText.slice(cursor, nextInternalBoundary(inputText, cursor)) || " ";
|
|
3761
|
-
const afterCursor = inputText.slice(cursor + (cursorChar === " " ? 0 : cursorChar.length));
|
|
3762
|
-
const barFocused = state.focusMode === "dashboard";
|
|
3763
|
-
const barIndex = Math.max(
|
|
3764
|
-
0,
|
|
3765
|
-
Math.min(displayAgents.length, Number(internalAgentView.barIndex) || 0),
|
|
3766
|
-
);
|
|
3767
|
-
const barHint = barFocused ? "│ ←/→ · Enter · ↑ · ^X" : "│ ↓ agents";
|
|
3768
|
-
const barItem = (text, index, options = {}) => {
|
|
3769
|
-
const keyboardSelected = barFocused && barIndex === index;
|
|
3770
|
-
return h(Text, {
|
|
3771
|
-
key: `agent-bar-${index}-${text}`,
|
|
3772
|
-
color: keyboardSelected || options.current === true ? undefined : "cyan",
|
|
3773
|
-
inverse: keyboardSelected,
|
|
3774
|
-
bold: options.current === true,
|
|
3775
|
-
wrap: "truncate",
|
|
3776
|
-
}, text);
|
|
3777
|
-
};
|
|
3778
|
-
const agentBarChildren = displayAgents.length === 0
|
|
3779
|
-
? [h(Text, { key: "agent-bar-none", color: "cyan", wrap: "truncate" }, "none")]
|
|
3780
|
-
: displayAgents.flatMap((id, idx) => {
|
|
3781
|
-
const meta = displayAgentMeta.get(id);
|
|
3782
|
-
return [
|
|
3783
|
-
idx > 0 ? h(Text, { key: `agent-bar-space-${id}`, color: "gray", wrap: "truncate" }, " ") : null,
|
|
3784
|
-
barItem(getAgentLabelFor(meta, id), idx + 1, {
|
|
3785
|
-
current: isInternalViewingAgent(id, meta, internalAgentView, state.viewingAgentId),
|
|
3786
|
-
}),
|
|
3787
|
-
];
|
|
3788
|
-
}).filter(Boolean);
|
|
3789
|
-
return h(Box, { flexDirection: "column", width: "100%" },
|
|
3790
|
-
h(Box, { flexDirection: "column", width: "100%" },
|
|
3791
|
-
...visibleRows.map((row, idx) => {
|
|
3792
|
-
const kind = row && row.kind ? row.kind : "agent";
|
|
3793
|
-
const color = kind === "user"
|
|
3794
|
-
? "cyan"
|
|
3795
|
-
: (kind === "system" || kind === "meta" || kind === "spacer" ? "gray" : (kind === "error" ? "red" : undefined));
|
|
3796
|
-
return h(Text, {
|
|
3797
|
-
key: `agent-log-${idx}`,
|
|
3798
|
-
color,
|
|
3799
|
-
bold: Boolean(row && row.bold),
|
|
3800
|
-
wrap: "truncate",
|
|
3801
|
-
}, (row && row.text) || " ");
|
|
3802
|
-
}),
|
|
3803
|
-
),
|
|
3804
|
-
h(InternalStatusLine, { view: internalAgentView, maxWidth }),
|
|
3805
|
-
h(Text, { color: "gray", wrap: "truncate" }, "─".repeat(maxWidth)),
|
|
3806
|
-
h(Box, { width: "100%" },
|
|
3807
|
-
h(Text, { color: "magenta" }, "› "),
|
|
3808
|
-
beforeCursor ? h(Text, { wrap: "truncate" }, beforeCursor) : null,
|
|
3809
|
-
h(Text, { inverse: true }, cursorChar),
|
|
3810
|
-
afterCursor ? h(Text, { wrap: "truncate" }, afterCursor) : null,
|
|
3811
|
-
),
|
|
3812
|
-
h(Text, { color: "gray", wrap: "truncate" }, "─".repeat(maxWidth)),
|
|
3813
|
-
h(Box, { width: "100%" },
|
|
3814
|
-
h(Text, { color: "gray", wrap: "truncate" }, " "),
|
|
3815
|
-
barItem("ufoo", 0),
|
|
3816
|
-
h(Text, { color: "gray", wrap: "truncate" }, " "),
|
|
3817
|
-
...agentBarChildren,
|
|
3818
|
-
h(Text, { color: "gray", wrap: "truncate" }, ` ${barHint}`),
|
|
3819
|
-
),
|
|
3820
|
-
);
|
|
3821
|
-
}
|
|
3822
|
-
|
|
3823
|
-
const lastStaticItem = staticLog.items[staticLog.items.length - 1] || null;
|
|
3824
|
-
|
|
3825
|
-
return h(Box, { flexDirection: "column", width: "100%" },
|
|
3826
|
-
// Finalized log entries live in <Static>: each item is written to the
|
|
3827
|
-
// terminal exactly once (above the live frame) and never re-rendered,
|
|
3828
|
-
// so spinner ticks and keystrokes no longer erase/rewrite the whole
|
|
3829
|
-
// scrollback. key=generation remounts the Static after log/clear.
|
|
3830
|
-
h(Static, {
|
|
3831
|
-
key: `chat-log-${staticLog.generation}`,
|
|
3832
|
-
items: staticLog.items,
|
|
3833
|
-
}, (item) => renderStaticChatLogItem(item)),
|
|
3834
|
-
// Reproduces the trailing marginBottom the last transcript group used
|
|
3835
|
-
// to contribute in the dynamic layout.
|
|
3836
|
-
lastStaticItem && STATIC_GROUPABLE_KINDS.has(lastStaticItem.groupKind)
|
|
3837
|
-
? h(Text, { color: "gray" }, " ")
|
|
3838
|
-
: null,
|
|
3839
|
-
state.activeMerge ? h(Box, null,
|
|
3840
|
-
h(Text, { color: state.activeMerge.entries.some((e) => e.isError) ? "red" : "cyan" },
|
|
3841
|
-
fmt.buildToolMergeRowText(state.activeMerge.entries)),
|
|
3842
|
-
) : null,
|
|
3843
|
-
activeStreamGroups ? h(Box, { flexDirection: "column" },
|
|
3844
|
-
...activeStreamGroups.map(renderChatLogGroup).filter(Boolean),
|
|
3845
|
-
) : null,
|
|
3846
|
-
h(ChatStatusLine, {
|
|
3847
|
-
status: state.status,
|
|
3848
|
-
version: fmt.UCODE_VERSION,
|
|
3849
|
-
cols: size.cols || 80,
|
|
3850
|
-
}),
|
|
3851
|
-
completionsOpen ? (() => {
|
|
3852
|
-
const start = Math.min(completionWindowStart, Math.max(0, completions.length - POPUP_PAGE_SIZE));
|
|
3853
|
-
const end = Math.min(completions.length, start + POPUP_PAGE_SIZE);
|
|
3854
|
-
const visible = completions.slice(start, end);
|
|
3855
|
-
return h(Box, { flexDirection: "column" },
|
|
3856
|
-
h(Text, { color: "gray" }, "─".repeat(Math.max(8, size.cols || 80))),
|
|
3857
|
-
...visible.map((s, idxInWindow) => {
|
|
3858
|
-
const idx = start + idxInWindow;
|
|
3859
|
-
return h(Box, { key: `cmp-${idx}` },
|
|
3860
|
-
h(Text, { color: idx === completionIndex ? "cyan" : "gray", inverse: idx === completionIndex }, s.label),
|
|
3861
|
-
s.description ? h(Text, { color: "gray" }, ` ${s.description}`) : null,
|
|
3862
|
-
);
|
|
3863
|
-
}),
|
|
3864
|
-
);
|
|
3865
|
-
})() : null,
|
|
3866
|
-
h(Box, { width: "100%" },
|
|
3867
|
-
h(MultilineInput, {
|
|
3868
|
-
value: state.draft,
|
|
3869
|
-
valueVersion: draftVersion,
|
|
3870
|
-
onChange: (next) => {
|
|
3871
|
-
if (completionSuppressedDraft !== null && next !== completionSuppressedDraft) {
|
|
3872
|
-
setCompletionSuppressedDraft(null);
|
|
3873
|
-
}
|
|
3874
|
-
dispatch({ type: "draft/set", value: next });
|
|
3875
|
-
},
|
|
3876
|
-
onSubmit: (value) => {
|
|
3877
|
-
setCompletionSuppressedDraft(null);
|
|
3878
|
-
submit(value);
|
|
3879
|
-
},
|
|
3880
|
-
onCancel: () => {
|
|
3881
|
-
setCompletionSuppressedDraft(null);
|
|
3882
|
-
if (props.globalMode && state.globalScope === "project") {
|
|
3883
|
-
void switchToControllerRoot();
|
|
3884
|
-
return;
|
|
3885
|
-
}
|
|
3886
|
-
// Esc clears the current target if one is locked, otherwise
|
|
3887
|
-
// dismisses the in-flight task status. There's no per-request
|
|
3888
|
-
// AbortController on daemonConnection (the IPC layer is fire-
|
|
3889
|
-
// and-forget), so we clear the spinner so the user knows the
|
|
3890
|
-
// UI is responsive again.
|
|
3891
|
-
if (state.agentSelectionMode) {
|
|
3892
|
-
dispatch({ type: "agents/clearTarget" });
|
|
3893
|
-
return;
|
|
3894
|
-
}
|
|
3895
|
-
if (state.status && state.status.message) {
|
|
3896
|
-
dispatch({ type: "status/idle" });
|
|
3897
|
-
}
|
|
3898
|
-
},
|
|
3899
|
-
onArrowUpAtTop,
|
|
3900
|
-
onArrowDownAtBottom,
|
|
3901
|
-
onArrowLeftAtEmpty: () => onArrowSideAtEmpty("left"),
|
|
3902
|
-
onArrowRightAtEmpty: () => onArrowSideAtEmpty("right"),
|
|
3903
|
-
width: inputWidth,
|
|
3904
|
-
interactive: interactive && state.focusMode !== "dashboard",
|
|
3905
|
-
interceptArrowsAndEnter: completionsOpen,
|
|
3906
|
-
placeholder: "",
|
|
3907
|
-
promptPrefix,
|
|
3908
|
-
// Dashboard renders 2 rows in global mode (always shows the
|
|
3909
|
-
// projects rail) or when an agents/mode/provider/cron view is
|
|
3910
|
-
// focused; otherwise it's a single summary row. Telling
|
|
3911
|
-
// MultilineInput how many UI rows live below it lets the IME
|
|
3912
|
-
// composition popup follow the on-screen caret instead of
|
|
3913
|
-
// appearing at the bottom-right of the terminal.
|
|
3914
|
-
linesBelowInput: props.globalMode
|
|
3915
|
-
? 2
|
|
3916
|
-
: (state.focusMode === "dashboard" ? 2 : 1),
|
|
3917
|
-
}),
|
|
3918
|
-
),
|
|
3919
|
-
h(DashboardBar, {
|
|
3920
|
-
dashboardView: state.dashboardView,
|
|
3921
|
-
focusMode: state.focusMode,
|
|
3922
|
-
globalMode: state.globalMode,
|
|
3923
|
-
globalScope: state.globalScope,
|
|
3924
|
-
activeAgents: displayAgents,
|
|
3925
|
-
activeAgentMeta: displayAgentMeta,
|
|
3926
|
-
activeAgentId: targetAgentId || "",
|
|
3927
|
-
selectedAgentIndex: state.selectedAgentIndex,
|
|
3928
|
-
agentListWindowStart: state.agentListWindowStart,
|
|
3929
|
-
projectListWindowStart: state.projectListWindowStart,
|
|
3930
|
-
maxProjectWindow: 5,
|
|
3931
|
-
maxWidth: Math.max(20, size.cols || 80),
|
|
3932
|
-
getAgentLabel: (id) => getAgentLabelFor(displayAgentMeta.get(id), id),
|
|
3933
|
-
getAgentState: (id) => {
|
|
3934
|
-
const meta = displayAgentMeta.get(id);
|
|
3935
|
-
return meta && typeof meta.activity_state === "string" ? meta.activity_state : "";
|
|
3936
|
-
},
|
|
3937
|
-
launchMode: state.settings.launchMode,
|
|
3938
|
-
agentProvider: state.settings.agentProvider,
|
|
3939
|
-
modeOptions: state.modeOptions,
|
|
3940
|
-
selectedModeIndex: state.selectedModeIndex,
|
|
3941
|
-
providerOptions: state.providerOptions,
|
|
3942
|
-
selectedProviderIndex: state.selectedProviderIndex,
|
|
3943
|
-
cronTasks: state.cronTasks,
|
|
3944
|
-
selectedCronIndex: state.selectedCronIndex,
|
|
3945
|
-
projects: state.projects,
|
|
3946
|
-
selectedProjectIndex: state.selectedProjectIndex,
|
|
3947
|
-
activeProjectRoot: currentProjectRoot,
|
|
3948
|
-
dashHints: buildDashHints(state, targetAgentLabel),
|
|
3949
|
-
}),
|
|
3950
|
-
);
|
|
3951
|
-
};
|
|
3952
|
-
}
|
|
3953
|
-
|
|
3954
|
-
function buildDashHints(state, targetAgentLabel) {
|
|
3955
|
-
void targetAgentLabel; // navigation hint removed by request
|
|
3956
|
-
return {
|
|
3957
|
-
agents: "←/→ select · Enter · ↓ mode · ↑ back",
|
|
3958
|
-
agentsGlobal: "←/→ select · Enter · ↓ mode · ↑ projects",
|
|
3959
|
-
agentsEmpty: "↓ mode · ↑ back",
|
|
3960
|
-
mode: "←/→ select · Enter · ↓ provider · ↑ back",
|
|
3961
|
-
provider: "←/→ select · Enter · ↓ cron · ↑ back",
|
|
3962
|
-
cron: "←/→ switch · Ctrl+X stop · ↑ back",
|
|
3963
|
-
projects: "Use /open <path> or /project switch <index|path>",
|
|
3964
|
-
projectsFocus: "←/→ switch · Ctrl+X close · ↓ second row · Enter confirm · ↑ back",
|
|
3965
|
-
projectsEmpty: "Run ufoo chat or ufoo daemon start in project directories",
|
|
3966
|
-
};
|
|
3967
|
-
}
|
|
3968
|
-
|
|
3969
|
-
function computeStatusText(status, spinnerTick) {
|
|
3970
|
-
const message = String((status && status.message) || "");
|
|
3971
|
-
if (!message) return "CHAT · Ready";
|
|
3972
|
-
const type = inferStatusType(message, status && status.type);
|
|
3973
|
-
if (type === "done" || type === "success") {
|
|
3974
|
-
const clean = stripBlessedTags(message).trim();
|
|
3975
|
-
return /^[✓✔]/.test(clean) ? clean : `✓ ${clean}`;
|
|
3976
|
-
}
|
|
3977
|
-
if (type === "error") {
|
|
3978
|
-
const clean = stripBlessedTags(message).trim();
|
|
3979
|
-
return /^[✗!]/.test(clean) ? clean : `✗ ${clean}`;
|
|
3980
|
-
}
|
|
3981
|
-
if (!isAnimatedStatusType(type)) return stripBlessedTags(message).trim() || "CHAT · Ready";
|
|
3982
|
-
const indicators = fmt.STATUS_INDICATORS[type] || fmt.STATUS_INDICATORS.thinking;
|
|
3983
|
-
const indicator = indicators[Math.max(0, Math.floor(Number(spinnerTick) || 0)) % indicators.length];
|
|
3984
|
-
const startedAt = Number.isFinite(status && status.startedAt) ? status.startedAt : 0;
|
|
3985
|
-
const timerText = status && status.showTimer && startedAt
|
|
3986
|
-
? ` (${fmt.formatPendingElapsed(Date.now() - startedAt)}, esc cancel)`
|
|
3987
|
-
: "";
|
|
3988
|
-
return `${indicator} ${message}${timerText}`;
|
|
3989
|
-
}
|
|
3990
|
-
|
|
3991
|
-
async function runChatInk(projectRoot, options = {}) {
|
|
3992
|
-
const env = bootstrapEnvironment(projectRoot, options);
|
|
3993
|
-
|
|
3994
|
-
if (env.needsBootstrap || !fs.existsSync(env.runtimePaths.ufooDir)) {
|
|
3995
|
-
const repoRoot = path.join(__dirname, "..", "..", "..");
|
|
3996
|
-
const init = new env.UfooInit(repoRoot);
|
|
3997
|
-
await init.init({
|
|
3998
|
-
targets: "context,bus",
|
|
3999
|
-
project: projectRoot,
|
|
4000
|
-
controllerMode: env.globalMode,
|
|
4001
|
-
});
|
|
4002
|
-
}
|
|
4003
|
-
|
|
4004
|
-
await ensureSubscriberId(projectRoot);
|
|
4005
|
-
|
|
4006
|
-
if (!env.isRunning(projectRoot)) {
|
|
4007
|
-
env.startDaemon(projectRoot);
|
|
4008
|
-
}
|
|
4009
|
-
|
|
4010
|
-
const { socketPath } = require("../../runtime/daemon");
|
|
4011
|
-
const { connectWithRetry } = require("../../app/chat/transport");
|
|
4012
|
-
const { createDaemonTransport } = require("../../app/chat/daemonTransport");
|
|
4013
|
-
const { createDaemonConnection } = require("../../app/chat/daemonConnection");
|
|
4014
|
-
const { createDaemonCoordinator } = require("../../app/chat/daemonCoordinator");
|
|
4015
|
-
const { startDaemon, stopDaemon } = require("../../app/chat/transport");
|
|
4016
|
-
const { loadConfig } = require("../../config");
|
|
4017
|
-
const { startAgentMirror, startInternalAgentMirror } = require("./agentMirror");
|
|
4018
|
-
const sock = socketPath(projectRoot);
|
|
4019
|
-
const daemonTransport = createDaemonTransport({
|
|
4020
|
-
projectRoot,
|
|
4021
|
-
sockPath: sock,
|
|
4022
|
-
isRunning: env.isRunning,
|
|
4023
|
-
startDaemon: env.startDaemon,
|
|
4024
|
-
connectWithRetry,
|
|
4025
|
-
});
|
|
4026
|
-
|
|
4027
|
-
// The connection's `handleMessage` callback is filled in by ChatApp once
|
|
4028
|
-
// it mounts and has its dispatcher ready. We expose a setter so the
|
|
4029
|
-
// component can wire it without ChatApp needing to construct daemon
|
|
4030
|
-
// internals itself.
|
|
4031
|
-
let routedMessageHandler = () => {};
|
|
4032
|
-
const daemonConnection = createDaemonConnection({
|
|
4033
|
-
connectClient: daemonTransport.connectClient.bind(daemonTransport),
|
|
4034
|
-
handleMessage: (msg) => routedMessageHandler(msg),
|
|
4035
|
-
queueStatusLine: () => {},
|
|
4036
|
-
resolveStatusLine: () => {},
|
|
4037
|
-
logMessage: () => {},
|
|
4038
|
-
});
|
|
4039
|
-
const daemonCoordinator = createDaemonCoordinator({
|
|
4040
|
-
projectRoot,
|
|
4041
|
-
daemonTransport,
|
|
4042
|
-
daemonConnection,
|
|
4043
|
-
stopDaemon,
|
|
4044
|
-
startDaemon,
|
|
4045
|
-
isDaemonRunning: env.isRunning,
|
|
4046
|
-
logMessage: () => {},
|
|
4047
|
-
queueStatusLine: () => {},
|
|
4048
|
-
resolveStatusLine: () => {},
|
|
4049
|
-
});
|
|
4050
|
-
|
|
4051
|
-
// We loop the ink mount so an "enter agent" request can unmount ink,
|
|
4052
|
-
// hand stdout/stdin to the raw PTY mirror, then bring ink back on exit.
|
|
4053
|
-
let pendingEnter = null;
|
|
4054
|
-
const baseProps = {
|
|
4055
|
-
activeProjectRoot: env.activeProjectRoot,
|
|
4056
|
-
projectRoot,
|
|
4057
|
-
globalMode: env.globalMode,
|
|
4058
|
-
globalScope: env.globalMode ? "controller" : "project",
|
|
4059
|
-
daemonConnection,
|
|
4060
|
-
daemonTransport,
|
|
4061
|
-
daemonCoordinator,
|
|
4062
|
-
env,
|
|
4063
|
-
initialSettings: loadConfig(projectRoot),
|
|
4064
|
-
setDaemonMessageHandler: (fn) => { routedMessageHandler = typeof fn === "function" ? fn : () => {}; },
|
|
4065
|
-
requestEnterAgentView: (agentId, enterOptions = {}) => {
|
|
4066
|
-
pendingEnter = {
|
|
4067
|
-
agentId,
|
|
4068
|
-
options: enterOptions && typeof enterOptions === "object" ? enterOptions : {},
|
|
4069
|
-
};
|
|
4070
|
-
},
|
|
4071
|
-
};
|
|
4072
|
-
|
|
4073
|
-
// eslint-disable-next-line no-constant-condition
|
|
4074
|
-
while (true) {
|
|
4075
|
-
pendingEnter = null;
|
|
4076
|
-
const handle = await runInk(
|
|
4077
|
-
(React, ink) => {
|
|
4078
|
-
const ChatApp = createChatApp({ React, ink, props: baseProps });
|
|
4079
|
-
return React.createElement(ChatApp);
|
|
4080
|
-
},
|
|
4081
|
-
{ stdin: process.stdin, stdout: process.stdout, exitOnCtrlC: true }
|
|
4082
|
-
);
|
|
4083
|
-
|
|
4084
|
-
// Wait until either the user exits the app or ChatApp asks to enter
|
|
4085
|
-
// an agent view. The component triggers the latter by setting
|
|
4086
|
-
// pendingEnter and then calling handle.unmount() via its onExit.
|
|
4087
|
-
await handle.waitUntilExit();
|
|
4088
|
-
if (!pendingEnter) return;
|
|
4089
|
-
|
|
4090
|
-
// Hand stdout/stdin to the mirror. When it exits, loop and re-mount.
|
|
4091
|
-
const enterRequest = pendingEnter;
|
|
4092
|
-
pendingEnter = null;
|
|
4093
|
-
const enteredAgentId = enterRequest && enterRequest.agentId;
|
|
4094
|
-
const enterOptions = enterRequest && enterRequest.options ? enterRequest.options : {};
|
|
4095
|
-
const enteredProjectRoot = enterOptions.projectRoot || projectRoot;
|
|
4096
|
-
await new Promise((resolve) => {
|
|
4097
|
-
if (enterOptions.useBus) {
|
|
4098
|
-
startInternalAgentMirror({
|
|
4099
|
-
agentId: enteredAgentId,
|
|
4100
|
-
agentLabel: enterOptions.agentLabel,
|
|
4101
|
-
agentAliases: enterOptions.agentAliases,
|
|
4102
|
-
projectRoot: enteredProjectRoot,
|
|
4103
|
-
daemonConnection,
|
|
4104
|
-
setDaemonMessageHandler: (fn) => {
|
|
4105
|
-
routedMessageHandler = typeof fn === "function" ? fn : () => {};
|
|
4106
|
-
},
|
|
4107
|
-
onExit: resolve,
|
|
4108
|
-
});
|
|
4109
|
-
return;
|
|
4110
|
-
}
|
|
4111
|
-
startAgentMirror({
|
|
4112
|
-
agentId: enteredAgentId,
|
|
4113
|
-
projectRoot: enteredProjectRoot,
|
|
4114
|
-
onExit: resolve,
|
|
4115
|
-
});
|
|
4116
|
-
});
|
|
4117
|
-
}
|
|
4118
|
-
}
|
|
4119
|
-
|
|
4120
|
-
module.exports = {
|
|
4121
|
-
runChatInk,
|
|
4122
|
-
createChatApp,
|
|
4123
|
-
createChatStatusLine,
|
|
4124
|
-
createInternalStatusLine,
|
|
4125
|
-
createInkStreamState,
|
|
4126
|
-
createThrottledSender,
|
|
4127
|
-
decorateStaticLogEntry,
|
|
4128
|
-
buildChatLogDisplayLines,
|
|
4129
|
-
expandChatLogPhysicalLines,
|
|
4130
|
-
bootstrapEnvironment,
|
|
4131
|
-
buildDirectBusSendRequest,
|
|
4132
|
-
buildPromptIpcRequest,
|
|
4133
|
-
chatHistoryOptionsForScope,
|
|
4134
|
-
classifyChatLogLine,
|
|
4135
|
-
buildChatLogLineModel,
|
|
4136
|
-
buildChatLogGroups,
|
|
4137
|
-
createInkMultiWindowToggle,
|
|
4138
|
-
resolveActiveAgentId,
|
|
4139
|
-
resolveInjectSockPathForAgent,
|
|
4140
|
-
resolveAgentEnterRequest,
|
|
4141
|
-
resolveDashboardAgentEnterAction,
|
|
4142
|
-
buildEmptyProjectsDownActions,
|
|
4143
|
-
buildInternalLogRows,
|
|
4144
|
-
computeStatusText,
|
|
4145
|
-
computeInternalStatusText,
|
|
4146
|
-
inferStatusType,
|
|
4147
|
-
isAnimatedStatusType,
|
|
4148
|
-
resolveInternalKeyName,
|
|
4149
|
-
isInternalViewingAgent,
|
|
4150
|
-
applyInternalAgentTermWrite,
|
|
4151
|
-
appendInternalErrorToView,
|
|
4152
|
-
};
|