u-foo 2.3.30 → 2.3.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +5 -1
  2. package/scripts/chat-app-smoke.js +30 -0
  3. package/scripts/ink-demo.js +23 -0
  4. package/scripts/ink-smoke.js +30 -0
  5. package/scripts/ucode-app-smoke.js +36 -0
  6. package/src/chat/commandExecutor.js +6 -2
  7. package/src/chat/daemonMessageRouter.js +9 -1
  8. package/src/chat/daemonTransport.js +2 -1
  9. package/src/chat/dashboardKeyController.js +0 -40
  10. package/src/chat/dashboardView.js +0 -20
  11. package/src/chat/index.js +9 -1
  12. package/src/chat/inputSubmitHandler.js +34 -0
  13. package/src/chat/projectCloseController.js +1 -1
  14. package/src/chat/shellCommand.js +42 -0
  15. package/src/chat/transport.js +16 -3
  16. package/src/cli.js +4 -3
  17. package/src/code/agent.js +4 -0
  18. package/src/code/nativeRunner.js +74 -0
  19. package/src/code/taskDecomposer.js +5 -4
  20. package/src/code/tui.js +73 -561
  21. package/src/daemon/index.js +169 -27
  22. package/src/daemon/ipcServer.js +23 -1
  23. package/src/daemon/promptRequest.js +6 -1
  24. package/src/daemon/run.js +11 -4
  25. package/src/projects/runtimes.js +1 -1
  26. package/src/ufoo/agentRegistryDiagnostics.js +43 -0
  27. package/src/ui/MIGRATION.md +382 -0
  28. package/src/ui/components/ChatApp.js +2950 -0
  29. package/src/ui/components/DashboardBar.js +417 -0
  30. package/src/ui/components/InkDemo.js +96 -0
  31. package/src/ui/components/MultilineInput.js +387 -0
  32. package/src/ui/components/UcodeApp.js +813 -0
  33. package/src/ui/components/agentMirror.js +725 -0
  34. package/src/ui/components/chatReducer.js +337 -0
  35. package/src/ui/format/index.js +997 -0
  36. package/src/ui/index.js +9 -0
  37. package/src/ui/runInk.js +57 -0
  38. package/src/utils/nodeExecutable.js +26 -0
@@ -0,0 +1,2950 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Ink-based chat TUI. Behaviourally equivalent to runChatBlessed in
5
+ * src/chat/index.js but rendered via React + ink.
6
+ *
7
+ * Activation: Ink is the default chat TUI. Set UFOO_TUI=blessed to use the
8
+ * legacy blessed renderer while it remains available as a fallback.
9
+ *
10
+ * Coverage today: layout shell + dashboard bar (5 modes: projects, agents,
11
+ * mode, provider, cron) + multiline editor + status line +
12
+ * Tab/Esc focus + agent selection + Up/Down history, daemon routing,
13
+ * command execution, completion and internal-agent views.
14
+ *
15
+ * Chat state is kept in chatReducer.js so the entire transition table can
16
+ * be exercised by jest without mounting ink.
17
+ */
18
+
19
+ const path = require("path");
20
+ const fs = require("fs");
21
+ const crypto = require("crypto");
22
+
23
+ const { runInk } = require("../runInk");
24
+ const fmt = require("../format");
25
+ const { createMultilineInput } = require("./MultilineInput");
26
+ const { createDashboardBar } = require("./DashboardBar");
27
+ const { reducer, createInitialState } = require("./chatReducer");
28
+
29
+ function bootstrapEnvironment(projectRoot, options = {}) {
30
+ // Mirror of the early section of runChatBlessed: ensure ufoo dirs exist
31
+ // and that we have a stable subscriber ID. We deliberately keep the
32
+ // non-UI side-effects in their own helper so unit tests can assert on
33
+ // them without importing ink.
34
+ const { canonicalProjectRoot } = require("../../projects");
35
+ const { getUfooPaths } = require("../../ufoo/paths");
36
+ const UfooInit = require("../../init");
37
+ const { isRunning } = require("../../daemon");
38
+ const { startDaemon } = require("../../chat/transport");
39
+
40
+ const globalMode = options && options.globalMode === true;
41
+ let activeProjectRoot = projectRoot;
42
+ try {
43
+ activeProjectRoot = canonicalProjectRoot(projectRoot);
44
+ } catch {
45
+ activeProjectRoot = path.resolve(projectRoot || process.cwd());
46
+ }
47
+
48
+ const runtimePaths = getUfooPaths(projectRoot);
49
+ const contextIndexFile = path.join(runtimePaths.ufooDir, "context", "decisions.jsonl");
50
+ const needsBootstrap = globalMode && (
51
+ !fs.existsSync(runtimePaths.ufooDir)
52
+ || !fs.existsSync(runtimePaths.busDir)
53
+ || !fs.existsSync(runtimePaths.agentDir)
54
+ || !fs.existsSync(contextIndexFile)
55
+ );
56
+
57
+ return {
58
+ activeProjectRoot,
59
+ globalMode,
60
+ runtimePaths,
61
+ needsBootstrap,
62
+ UfooInit,
63
+ isRunning,
64
+ startDaemon,
65
+ };
66
+ }
67
+
68
+ async function ensureSubscriberId(projectRoot) {
69
+ if (process.env.UFOO_SUBSCRIBER_ID) return;
70
+ const { getUfooPaths } = require("../../ufoo/paths");
71
+ const sessionFile = path.join(getUfooPaths(projectRoot).ufooDir, "chat", "session-id.txt");
72
+ const sessionDir = path.dirname(sessionFile);
73
+ fs.mkdirSync(sessionDir, { recursive: true });
74
+ let sessionId;
75
+ if (fs.existsSync(sessionFile)) {
76
+ sessionId = fs.readFileSync(sessionFile, "utf8").trim();
77
+ } else {
78
+ sessionId = crypto.randomBytes(4).toString("hex");
79
+ fs.writeFileSync(sessionFile, sessionId, "utf8");
80
+ }
81
+ process.env.UFOO_SUBSCRIBER_ID = `claude-code:${sessionId}`;
82
+ }
83
+
84
+ function inputHistoryFilePath(projectRoot, options = {}) {
85
+ const { getUfooPaths } = require("../../ufoo/paths");
86
+ const { globalMode } = options || {};
87
+ if (globalMode) {
88
+ const os = require("os");
89
+ const globalChatRoot = path.join(os.homedir(), ".ufoo", "chat");
90
+ const globalDir = path.join(globalChatRoot, "global-input-history");
91
+ const projectId = projectRootToId(projectRoot);
92
+ return path.join(globalDir, `${projectId}.jsonl`);
93
+ }
94
+ return path.join(getUfooPaths(projectRoot || process.cwd()).ufooDir, "chat", "input-history.jsonl");
95
+ }
96
+
97
+ function chatHistoryFilePath(projectRoot, options = {}) {
98
+ const { getUfooPaths } = require("../../ufoo/paths");
99
+ const { globalMode } = options || {};
100
+ if (globalMode) {
101
+ const os = require("os");
102
+ const globalChatRoot = path.join(os.homedir(), ".ufoo", "chat");
103
+ const globalDir = path.join(globalChatRoot, "global-history");
104
+ const projectId = projectRootToId(projectRoot);
105
+ return path.join(globalDir, `${projectId}.jsonl`);
106
+ }
107
+ return path.join(getUfooPaths(projectRoot || process.cwd()).ufooDir, "chat", "history.jsonl");
108
+ }
109
+
110
+ function projectRootToId(projectRoot) {
111
+ try {
112
+ const { buildProjectId } = require("../../projects");
113
+ return buildProjectId(projectRoot || process.cwd());
114
+ } catch {
115
+ return crypto.createHash("sha256").update(String(projectRoot || "")).digest("hex").slice(0, 16);
116
+ }
117
+ }
118
+
119
+ function loadChatHistory(projectRoot, cap = 200, options = {}) {
120
+ const file = chatHistoryFilePath(projectRoot, options);
121
+ try {
122
+ if (!fs.existsSync(file)) return [];
123
+ const raw = fs.readFileSync(file, "utf8");
124
+ const lines = raw.split(/\r?\n/).filter(Boolean);
125
+ const out = [];
126
+ for (const line of lines) {
127
+ try {
128
+ const entry = JSON.parse(line);
129
+ if (!entry) continue;
130
+ if (entry.type === "spacer") {
131
+ out.push("");
132
+ continue;
133
+ }
134
+ const text = String(entry.text || "");
135
+ if (!text) continue;
136
+ // Strip blessed-tag markup that the legacy log writer used; ink
137
+ // can't render those tags and we don't want them shown literally.
138
+ const stripped = text.replace(/\{[^{}]+\}/g, "");
139
+ out.push(stripped);
140
+ } catch {
141
+ // ignore malformed lines
142
+ }
143
+ }
144
+ return out.slice(-cap);
145
+ } catch {
146
+ return [];
147
+ }
148
+ }
149
+
150
+ function loadInputHistory(projectRoot, cap = 200, options = {}) {
151
+ const file = inputHistoryFilePath(projectRoot, options);
152
+ try {
153
+ if (!fs.existsSync(file)) return [];
154
+ const raw = fs.readFileSync(file, "utf8");
155
+ const lines = raw.split(/\r?\n/).filter(Boolean);
156
+ const out = [];
157
+ for (const line of lines) {
158
+ try {
159
+ const obj = JSON.parse(line);
160
+ const value = String((obj && obj.value) || "").trim();
161
+ if (value) out.push(value);
162
+ } catch {
163
+ // ignore malformed entries
164
+ }
165
+ }
166
+ return out.slice(-cap);
167
+ } catch {
168
+ return [];
169
+ }
170
+ }
171
+
172
+ function appendInputHistory(projectRoot, value, options = {}) {
173
+ const trimmed = String(value || "").trim();
174
+ if (!trimmed) return;
175
+ const file = inputHistoryFilePath(projectRoot, options);
176
+ try {
177
+ fs.mkdirSync(path.dirname(file), { recursive: true });
178
+ fs.appendFileSync(file, `${JSON.stringify({ value: trimmed, ts: Date.now() })}\n`);
179
+ } catch {
180
+ // best-effort persistence; failure is not user-visible
181
+ }
182
+ }
183
+
184
+ function appendChatHistory(projectRoot, type, text, meta = {}, options = {}) {
185
+ const value = String(text || "");
186
+ if (!value && type !== "spacer") return;
187
+ const file = chatHistoryFilePath(projectRoot, options);
188
+ try {
189
+ fs.mkdirSync(path.dirname(file), { recursive: true });
190
+ fs.appendFileSync(file, `${JSON.stringify({
191
+ ts: new Date().toISOString(),
192
+ type,
193
+ text: value,
194
+ meta: meta && typeof meta === "object" ? meta : {},
195
+ })}\n`);
196
+ } catch {
197
+ // best-effort persistence; failure is not user-visible
198
+ }
199
+ }
200
+
201
+ function chatHistoryOptionsForScope({ globalMode = false, globalScope = "controller" } = {}) {
202
+ return {
203
+ globalMode: Boolean(globalMode && globalScope !== "project"),
204
+ };
205
+ }
206
+
207
+ function getAgentLabelFor(meta, agentId) {
208
+ if (meta && meta.nickname) return meta.nickname;
209
+ if (!agentId) return "";
210
+ const colon = agentId.indexOf(":");
211
+ if (colon < 0) return agentId;
212
+ const head = agentId.slice(0, colon);
213
+ const tail = agentId.slice(colon + 1).slice(0, 6);
214
+ return tail ? `${head}:${tail}` : head;
215
+ }
216
+
217
+ function buildActiveAgentLabelMap(activeAgents = [], activeAgentMeta = new Map()) {
218
+ const out = new Map();
219
+ const metaMap = activeAgentMeta instanceof Map ? activeAgentMeta : new Map();
220
+ for (const id of Array.isArray(activeAgents) ? activeAgents : []) {
221
+ out.set(id, getAgentLabelFor(metaMap.get(id), id));
222
+ }
223
+ return out;
224
+ }
225
+
226
+ function resolveActiveAgentId(label, activeAgents = [], activeAgentMeta = new Map()) {
227
+ const { resolveAgentId } = require("../../chat/agentDirectory");
228
+ const metaMap = activeAgentMeta instanceof Map ? activeAgentMeta : new Map();
229
+ return resolveAgentId({
230
+ label,
231
+ activeAgents: Array.isArray(activeAgents) ? activeAgents : [],
232
+ labelMap: buildActiveAgentLabelMap(activeAgents, metaMap),
233
+ lookupNickname: (nickname) => {
234
+ for (const [id, meta] of metaMap.entries()) {
235
+ if (!meta) continue;
236
+ if (meta.nickname === nickname || meta.scoped_nickname === nickname || meta.display_nickname === nickname) {
237
+ return id;
238
+ }
239
+ }
240
+ return null;
241
+ },
242
+ });
243
+ }
244
+
245
+ function buildDirectBusSendRequest({
246
+ text,
247
+ targetAgentId = null,
248
+ activeAgents = [],
249
+ activeAgentMeta = new Map(),
250
+ } = {}) {
251
+ const trimmed = String(text || "").trim();
252
+ if (!trimmed) return null;
253
+ if (targetAgentId) {
254
+ return {
255
+ target: targetAgentId,
256
+ message: trimmed,
257
+ source: "chat-direct",
258
+ };
259
+ }
260
+
261
+ const { parseAtTarget } = require("../../chat/commands");
262
+ const atTarget = parseAtTarget(trimmed);
263
+ if (!atTarget || !atTarget.message) return null;
264
+ const target = resolveActiveAgentId(atTarget.target, activeAgents, activeAgentMeta) || atTarget.target;
265
+ return {
266
+ target,
267
+ message: atTarget.message.trim(),
268
+ source: "chat-direct",
269
+ };
270
+ }
271
+
272
+ function resolveAgentEnterRequest({
273
+ agentId,
274
+ projectRoot = "",
275
+ activeAgentMeta = new Map(),
276
+ settings = {},
277
+ } = {}) {
278
+ const id = String(agentId || "").trim();
279
+ if (!id) return null;
280
+
281
+ const metaMap = activeAgentMeta instanceof Map ? activeAgentMeta : new Map();
282
+ const meta = metaMap.get(id) || {};
283
+ const configuredLaunchMode = settings && settings.launchMode && settings.launchMode !== "auto"
284
+ ? settings.launchMode
285
+ : "";
286
+ const launchMode = String(meta.launch_mode || meta.launchMode || configuredLaunchMode || "").trim();
287
+ const { createTerminalAdapterRouter } = require("../../terminal/adapterRouter");
288
+ const adapter = createTerminalAdapterRouter().getAdapter({ launchMode, agentId: id, meta });
289
+ const caps = adapter && adapter.capabilities ? adapter.capabilities : {};
290
+
291
+ return {
292
+ agentId: id,
293
+ projectRoot: String(projectRoot || ""),
294
+ launchMode,
295
+ useBus: Boolean(caps.supportsInternalQueueLoop && !caps.supportsSocketProtocol),
296
+ supportsSocket: Boolean(caps.supportsSocketProtocol),
297
+ supportsInternalQueue: Boolean(caps.supportsInternalQueueLoop),
298
+ supportsActivate: Boolean(caps.supportsActivate),
299
+ };
300
+ }
301
+
302
+ function buildPromptIpcRequest(text) {
303
+ const { IPC_REQUEST_TYPES } = require("../../shared/eventContract");
304
+ return {
305
+ type: IPC_REQUEST_TYPES.PROMPT,
306
+ text,
307
+ request_meta: {
308
+ source: "chat-dialog",
309
+ dispatch_default_injection_mode: "immediate",
310
+ allow_relevance_queue: true,
311
+ },
312
+ };
313
+ }
314
+
315
+ function stripBlessedTags(text = "") {
316
+ return String(text || "")
317
+ .replace(/\{\/?[^{}\n]+\}/g, "")
318
+ .replace(/\r/g, "");
319
+ }
320
+
321
+ function normalizeInkLogLines(text = "") {
322
+ const clean = stripBlessedTags(text);
323
+ return clean.split(/\r?\n/);
324
+ }
325
+
326
+ function createInkStreamState({
327
+ dispatch,
328
+ appendHistory,
329
+ displayNameForPublisher = (value) => value,
330
+ } = {}) {
331
+ const streams = new Map();
332
+ const pendingDeliveries = new Map();
333
+
334
+ function deliveryKey(agentId, agentLabel) {
335
+ return String(agentId || agentLabel || "").trim();
336
+ }
337
+
338
+ function markPendingDelivery(agentId, agentLabel) {
339
+ const key = deliveryKey(agentId, agentLabel);
340
+ if (!key) return;
341
+ const existing = pendingDeliveries.get(key) || { count: 0, keys: new Set() };
342
+ existing.count += 1;
343
+ for (const candidate of [agentId, agentLabel]) {
344
+ const value = String(candidate || "").trim();
345
+ if (value) {
346
+ pendingDeliveries.set(value, existing);
347
+ existing.keys.add(value);
348
+ }
349
+ }
350
+ }
351
+
352
+ function getPendingState(publisher, displayName) {
353
+ for (const candidate of [publisher, displayName]) {
354
+ const key = String(candidate || "").trim();
355
+ if (key && pendingDeliveries.has(key)) {
356
+ return { key, state: pendingDeliveries.get(key) };
357
+ }
358
+ }
359
+ return null;
360
+ }
361
+
362
+ function consumePendingDelivery(publisher, displayName) {
363
+ const hit = getPendingState(publisher, displayName);
364
+ if (!hit) return false;
365
+ hit.state.count -= 1;
366
+ if (hit.state.count <= 0) {
367
+ for (const key of hit.state.keys || []) pendingDeliveries.delete(key);
368
+ }
369
+ return true;
370
+ }
371
+
372
+ function beginStream(publisher, prefix, continuationPrefix, meta) {
373
+ const key = String(publisher || "bus");
374
+ let state = streams.get(key);
375
+ if (state) return state;
376
+ const displayName = stripBlessedTags(prefix || displayNameForPublisher(key) || key)
377
+ .replace(/\s*·\s*$/, "")
378
+ .trim() || displayNameForPublisher(key) || key;
379
+ state = {
380
+ publisher: key,
381
+ displayName,
382
+ prefix,
383
+ continuationPrefix,
384
+ full: "",
385
+ meta: meta || {},
386
+ };
387
+ streams.set(key, state);
388
+ dispatch({ type: "stream/begin", publisher: displayName });
389
+ return state;
390
+ }
391
+
392
+ function appendStreamDelta(state, delta) {
393
+ if (!state || !delta) return;
394
+ state.full += String(delta || "");
395
+ dispatch({ type: "stream/delta", publisher: state.displayName || state.publisher, delta: String(delta || "") });
396
+ }
397
+
398
+ function finalizeStream(publisher, meta, reason = "") {
399
+ const key = String(publisher || "bus");
400
+ const state = streams.get(key);
401
+ if (!state) return;
402
+ dispatch({ type: "stream/end" });
403
+ if (typeof appendHistory === "function") {
404
+ const text = state.displayName
405
+ ? `${state.displayName}: ${state.full}`
406
+ : state.full;
407
+ appendHistory("bus", text, { ...(meta || state.meta || {}), stream_done: true, stream_reason: reason });
408
+ }
409
+ streams.delete(key);
410
+ }
411
+
412
+ function hasStream(publisher) {
413
+ return streams.has(String(publisher || "bus"));
414
+ }
415
+
416
+ return {
417
+ markPendingDelivery,
418
+ getPendingState,
419
+ consumePendingDelivery,
420
+ beginStream,
421
+ appendStreamDelta,
422
+ finalizeStream,
423
+ hasStream,
424
+ };
425
+ }
426
+
427
+ function formatShellCommandResultLines(result = {}) {
428
+ const lines = [];
429
+ const stdout = String(result.stdout || "").trimEnd();
430
+ const stderr = String(result.stderr || "").trimEnd();
431
+ if (stdout) lines.push(...stdout.split(/\r?\n/).map((line) => ({ type: "system", text: line })));
432
+ if (stderr) lines.push(...stderr.split(/\r?\n/).map((line) => ({ type: result.ok ? "system" : "error", text: line })));
433
+ if (!stdout && !stderr) lines.push({ type: "system", text: "(no output)" });
434
+ if (!result.ok) {
435
+ const suffix = result.signal ? ` signal ${result.signal}` : ` exit ${result.code != null ? result.code : 1}`;
436
+ lines.push({ type: "error", text: `Command failed:${suffix}` });
437
+ }
438
+ return lines;
439
+ }
440
+
441
+ function fitPlainLine(text = "", width = 80) {
442
+ const limit = Math.max(1, Math.floor(Number(width) || 80));
443
+ const raw = String(text || "").replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
444
+ let out = "";
445
+ let cells = 0;
446
+ for (const char of Array.from(raw)) {
447
+ const charWidth = fmt.charDisplayWidth(char);
448
+ if (cells + charWidth > limit) break;
449
+ out += char;
450
+ cells += charWidth;
451
+ }
452
+ if (out.length < raw.length && limit > 1) {
453
+ while (fmt.displayCellWidth(out) > limit - 1) {
454
+ out = Array.from(out).slice(0, -1).join("");
455
+ }
456
+ out = `${out}…`;
457
+ }
458
+ return out || " ";
459
+ }
460
+
461
+ function stripInternalLogMarkup(text = "") {
462
+ return String(text || "")
463
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
464
+ .replace(/\{\/?[^{}\n]+\}/g, "");
465
+ }
466
+
467
+ function wrapInternalPlainLine(text = "", width = 80) {
468
+ const limit = Math.max(1, Math.floor(Number(width) || 80));
469
+ const clean = stripInternalLogMarkup(text).replace(/\r/g, "");
470
+ if (!clean) return [""];
471
+ const rows = [];
472
+ let row = "";
473
+ let cells = 0;
474
+ for (const char of Array.from(clean)) {
475
+ const charWidth = fmt.charDisplayWidth(char);
476
+ if (cells > 0 && cells + charWidth > limit) {
477
+ rows.push(row);
478
+ row = "";
479
+ cells = 0;
480
+ }
481
+ row += char;
482
+ cells += charWidth;
483
+ }
484
+ rows.push(row);
485
+ return rows;
486
+ }
487
+
488
+ function classifyInternalLogLine(line = "") {
489
+ const raw = stripInternalLogMarkup(line).replace(/\r/g, "");
490
+ if (!raw) return { kind: "spacer", text: "", markdown: false, bold: false };
491
+ if (raw.startsWith("> ")) return { kind: "user", text: raw.slice(2), markdown: false, bold: false };
492
+ if (raw.startsWith("* ")) return { kind: "agent", text: raw.slice(2), markdown: true, bold: false };
493
+ if (/^error:/i.test(raw) || /^\[error\]/i.test(raw)) {
494
+ return { kind: "error", text: raw, markdown: true, bold: false };
495
+ }
496
+ if (/^ufoo internal agent\b/i.test(raw)) {
497
+ return { kind: "system", text: raw, markdown: false, bold: true };
498
+ }
499
+ if (/^(agent|directory):/i.test(raw)) {
500
+ return { kind: "meta", text: raw, markdown: false, bold: false };
501
+ }
502
+ return { kind: "agent", text: raw, markdown: true, bold: false };
503
+ }
504
+
505
+ function internalLogPrefixes(kind) {
506
+ if (kind === "user") return { first: "› ", rest: " " };
507
+ if (kind === "system") return { first: "· ", rest: " " };
508
+ if (kind === "meta") return { first: " ", rest: " " };
509
+ return { first: "", rest: "" };
510
+ }
511
+
512
+ function buildInternalLogRows(lines = [], width = 80, maxRows = 20) {
513
+ const limit = Math.max(1, Math.floor(Number(width) || 80));
514
+ const rows = [];
515
+ const markdownState = {};
516
+ const source = Array.isArray(lines) ? lines : [];
517
+ for (const line of source) {
518
+ const classified = classifyInternalLogLine(line);
519
+ if (classified.kind === "spacer") {
520
+ rows.push({ kind: "spacer", text: " ", bold: false });
521
+ continue;
522
+ }
523
+
524
+ let rendered = [classified.text];
525
+ if (classified.markdown) {
526
+ try {
527
+ rendered = fmt.renderLogLinesWithMarkdown(classified.text, markdownState, (value) => String(value || ""))
528
+ .map(stripInternalLogMarkup);
529
+ } catch {
530
+ rendered = [classified.text];
531
+ }
532
+ }
533
+
534
+ const prefixes = internalLogPrefixes(classified.kind);
535
+ for (const renderedLine of rendered) {
536
+ const chunks = wrapInternalPlainLine(
537
+ renderedLine,
538
+ Math.max(1, limit - fmt.displayCellWidth(prefixes.first)),
539
+ );
540
+ chunks.forEach((chunk, idx) => {
541
+ const prefix = idx === 0 ? prefixes.first : prefixes.rest;
542
+ rows.push({
543
+ kind: classified.kind,
544
+ text: fitPlainLine(`${prefix}${chunk}`, limit),
545
+ bold: classified.bold,
546
+ });
547
+ });
548
+ }
549
+ }
550
+ return rows.slice(-Math.max(1, Math.floor(Number(maxRows) || 20)));
551
+ }
552
+
553
+ function internalInputBoundaries(text = "") {
554
+ const source = String(text || "");
555
+ if (!source) return [0];
556
+ try {
557
+ if (typeof Intl !== "undefined" && typeof Intl.Segmenter === "function") {
558
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
559
+ const boundaries = [0];
560
+ for (const part of segmenter.segment(source)) {
561
+ boundaries.push(part.index + part.segment.length);
562
+ }
563
+ return Array.from(new Set(boundaries)).sort((a, b) => a - b);
564
+ }
565
+ } catch {
566
+ // Fall through.
567
+ }
568
+ const boundaries = [0];
569
+ let offset = 0;
570
+ for (const char of Array.from(source)) {
571
+ offset += char.length;
572
+ boundaries.push(offset);
573
+ }
574
+ return boundaries;
575
+ }
576
+
577
+ function previousInternalBoundary(text = "", cursor = 0) {
578
+ const target = Math.max(0, Math.min(String(text || "").length, cursor));
579
+ let previous = 0;
580
+ for (const boundary of internalInputBoundaries(text)) {
581
+ if (boundary < target) previous = boundary;
582
+ else break;
583
+ }
584
+ return previous;
585
+ }
586
+
587
+ function nextInternalBoundary(text = "", cursor = 0) {
588
+ const source = String(text || "");
589
+ const target = Math.max(0, Math.min(source.length, cursor));
590
+ for (const boundary of internalInputBoundaries(source)) {
591
+ if (boundary > target) return boundary;
592
+ }
593
+ return source.length;
594
+ }
595
+
596
+ function resolveInternalKeyName(input = "", key = {}) {
597
+ const raw = String(input || "");
598
+ if (raw === "\x7f" || raw === "\b" || raw === "\x08") return "backspace";
599
+ if (raw === "\x1b[3~" || raw === "\u001b[3~") return "delete";
600
+ if (key && key.backspace) return "backspace";
601
+ if (key && key.delete) return "backspace";
602
+ if (key && key.name === "backspace") return "backspace";
603
+ if (key && key.name === "delete") return "backspace";
604
+ if (key && key.name) return String(key.name);
605
+ if (key && key.escape) return "escape";
606
+ if (key && key.return) return "return";
607
+ if (key && key.leftArrow) return "left";
608
+ if (key && key.rightArrow) return "right";
609
+ if (key && key.upArrow) return "up";
610
+ if (key && key.downArrow) return "down";
611
+ if (key && key.ctrl && raw.length === 1) return raw.toLowerCase();
612
+ return "";
613
+ }
614
+
615
+ function isInternalViewingAgent(agentId, meta, view = {}, viewingAgentId = "") {
616
+ const id = String(agentId || "").trim();
617
+ if (!id) return false;
618
+ const candidates = new Set([
619
+ viewingAgentId,
620
+ view && view.agentId,
621
+ view && view.label,
622
+ ...((view && Array.isArray(view.aliases)) ? view.aliases : []),
623
+ ].filter(Boolean).map((value) => String(value).trim()).filter(Boolean));
624
+ if (candidates.has(id)) return true;
625
+ const metaIds = [
626
+ meta && meta.fullId,
627
+ meta && meta.agent_id,
628
+ meta && meta.subscriber_id,
629
+ meta && meta.nickname,
630
+ meta && meta.scoped_nickname,
631
+ meta && meta.display_nickname,
632
+ meta && meta.type && meta.id ? `${meta.type}:${meta.id}` : "",
633
+ getAgentLabelFor(meta, id),
634
+ ].filter(Boolean).map((value) => String(value).trim()).filter(Boolean);
635
+ return metaIds.some((value) => candidates.has(value));
636
+ }
637
+
638
+ function compactDisplayProjectRoot(projectRoot = "") {
639
+ const os = require("os");
640
+ const raw = String(projectRoot || process.cwd() || "").trim();
641
+ const home = os.homedir();
642
+ if (home && (raw === home || raw.startsWith(`${home}/`))) return `~${raw.slice(home.length)}`;
643
+ return raw || ".";
644
+ }
645
+
646
+ function buildInternalAgentStartupLines({ agentId = "", label = "", projectRoot = "", width = 80 } = {}) {
647
+ return [
648
+ fitPlainLine(`ufoo internal agent · ${label || agentId}`, width),
649
+ fitPlainLine(`agent: ${agentId}`, width),
650
+ fitPlainLine(`directory: ${compactDisplayProjectRoot(projectRoot)}`, width),
651
+ "",
652
+ ];
653
+ }
654
+
655
+ function createInternalAgentViewState({
656
+ agentId,
657
+ label,
658
+ aliases = [],
659
+ projectRoot,
660
+ width = 80,
661
+ } = {}) {
662
+ let history = [];
663
+ try {
664
+ const { loadInternalAgentLogHistory } = require("../../chat/internalAgentLogHistory");
665
+ history = loadInternalAgentLogHistory(projectRoot || process.cwd(), agentId, {
666
+ maxEvents: 400,
667
+ maxLines: 1000,
668
+ });
669
+ } catch {
670
+ history = [];
671
+ }
672
+ const safeAliases = [agentId, label].concat(aliases || []).filter(Boolean).map(String);
673
+ return {
674
+ agentId: String(agentId || ""),
675
+ label: String(label || agentId || ""),
676
+ aliases: Array.from(new Set(safeAliases)),
677
+ projectRoot: String(projectRoot || ""),
678
+ lines: buildInternalAgentStartupLines({ agentId, label, projectRoot, width })
679
+ .concat(history.length > 0 ? history : [""]),
680
+ input: "",
681
+ cursor: 0,
682
+ status: "ready",
683
+ detail: "",
684
+ statusStartedAt: 0,
685
+ barIndex: 0,
686
+ };
687
+ }
688
+
689
+ function appendInternalAgentText(view, text = "", options = {}) {
690
+ const current = view && typeof view === "object" ? view : {};
691
+ const lines = Array.isArray(current.lines) ? current.lines.slice() : [];
692
+ if (lines.length === 0) lines.push("");
693
+ const prefix = options.prefix || "";
694
+ const clean = String(text || "").replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
695
+ .replace(/\r\n/g, "\n")
696
+ .replace(/\r/g, "\n");
697
+ if (prefix && lines[lines.length - 1] !== "") lines.push("");
698
+ if (prefix && lines[lines.length - 1] === "") lines[lines.length - 1] = prefix;
699
+ for (const char of clean) {
700
+ if (char === "\n") {
701
+ lines.push("");
702
+ } else {
703
+ lines[lines.length - 1] += char;
704
+ }
705
+ }
706
+ return {
707
+ ...current,
708
+ lines: lines.slice(-1000),
709
+ };
710
+ }
711
+
712
+ function parseInternalBusPayload(raw = "") {
713
+ let displayMessage = String(raw || "");
714
+ let streamPayload = null;
715
+ try {
716
+ const parsed = JSON.parse(raw);
717
+ if (parsed && typeof parsed === "object" && parsed.reply) {
718
+ displayMessage = parsed.reply;
719
+ } else if (parsed && typeof parsed === "object" && parsed.stream) {
720
+ streamPayload = parsed;
721
+ }
722
+ } catch {
723
+ // Plain text.
724
+ }
725
+ return {
726
+ displayMessage: String(displayMessage || "").replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n").replace(/\\r/g, "\n"),
727
+ streamPayload,
728
+ };
729
+ }
730
+
731
+ function internalStatusLabel(value = "") {
732
+ const state = String(value || "").trim().toLowerCase();
733
+ if (state === "waiting" || state === "waiting_input") return "waiting";
734
+ if (state === "blocked" || state === "error") return "blocked";
735
+ if (state === "busy" || state === "processing" || state === "working") return "working";
736
+ if (state === "idle" || state === "ready") return "ready";
737
+ return state || "ready";
738
+ }
739
+
740
+ function updateInternalViewStatus(view = {}, status = "", detail = "", now = Date.now()) {
741
+ const current = view && typeof view === "object" ? view : {};
742
+ const nextStatus = internalStatusLabel(status || current.status || "");
743
+ const nextDetail = String(detail || "").trim();
744
+ const timed = nextStatus === "working" || nextStatus === "waiting" || nextStatus === "blocked";
745
+ const previousStartedAt = Number.isFinite(current.statusStartedAt) ? current.statusStartedAt : 0;
746
+ const statusStartedAt = timed
747
+ ? (current.status === nextStatus && previousStartedAt ? previousStartedAt : now)
748
+ : 0;
749
+ return {
750
+ ...current,
751
+ status: nextStatus,
752
+ detail: nextDetail,
753
+ statusStartedAt,
754
+ };
755
+ }
756
+
757
+ function applyInternalAgentTermWrite(view = {}, activeAgentId = "", text = "", meta = {}) {
758
+ const current = view && typeof view === "object" ? view : {};
759
+ if (!current.agentId || current.agentId !== activeAgentId) return current;
760
+ const streamPayload = meta && meta.streamPayload && typeof meta.streamPayload === "object"
761
+ ? meta.streamPayload
762
+ : {};
763
+ const done = Boolean((meta && meta.done) || streamPayload.done);
764
+ const rawText = String(text || "");
765
+ const next = rawText
766
+ ? appendInternalAgentText(current, rawText, { prefix: "* " })
767
+ : current;
768
+ if (done) return updateInternalViewStatus(next, "ready", "");
769
+ return updateInternalViewStatus(next, "working", "");
770
+ }
771
+
772
+ function appendInternalErrorToView(view = {}, activeAgentId = "", message = "") {
773
+ const current = view && typeof view === "object" ? view : {};
774
+ if (!current.agentId || current.agentId !== activeAgentId) return current;
775
+ const detail = String(message || "unknown error");
776
+ const lines = Array.isArray(current.lines) ? current.lines : [];
777
+ const separator = lines.length > 0 && lines[lines.length - 1] ? "\n" : "";
778
+ return appendInternalAgentText(
779
+ updateInternalViewStatus(current, "blocked", detail),
780
+ `${separator}Error: ${detail}\n`,
781
+ );
782
+ }
783
+
784
+ function computeInternalStatusText(view = {}, spinnerTick = 0, now = Date.now()) {
785
+ const current = view && typeof view === "object" ? view : {};
786
+ const status = internalStatusLabel(current.status || "");
787
+ const label = String(current.label || current.agentId || "agent").trim();
788
+ const detail = String(current.detail || "").trim();
789
+ if (status === "ready") {
790
+ return `ufoo · ${label} · Ready · Enter send · Esc back`;
791
+ }
792
+ const type = status === "waiting" ? "waiting" : "thinking";
793
+ const indicators = fmt.STATUS_INDICATORS[type] || fmt.STATUS_INDICATORS.thinking;
794
+ const indicator = status === "blocked"
795
+ ? "!"
796
+ : indicators[Math.max(0, Math.floor(Number(spinnerTick) || 0)) % indicators.length];
797
+ const message = status === "waiting"
798
+ ? "Waiting for input"
799
+ : (status === "blocked" ? "Blocked" : "Working");
800
+ const startedAt = Number.isFinite(current.statusStartedAt) ? current.statusStartedAt : 0;
801
+ const timer = startedAt ? ` (${fmt.formatPendingElapsed(now - startedAt)})` : "";
802
+ return `${indicator} ${label} · ${message}${detail ? ` · ${detail}` : ""}${timer} · Esc back`;
803
+ }
804
+
805
+ const CHAT_BANNER_LINES = [
806
+ "█ █ █▀▀ █▀█ █▀▄ █▀▀ █ █ ▄▀█ ▀█▀",
807
+ "█ █ █ █ █ █ █ █ █▀█ █▀█ █ ",
808
+ "▀▀▀ ▀▀▀ ▀▀▀ ▀▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀ ",
809
+ ];
810
+
811
+ function buildChatBannerLines(props, version) {
812
+ const os = require("os");
813
+ const home = os.homedir();
814
+ const root = props.activeProjectRoot || process.cwd();
815
+ const shortRoot = root.startsWith(home) ? root.replace(home, "~") : root;
816
+ const modeLabel = props.globalMode
817
+ ? `global (${props.globalScope || "controller"})`
818
+ : "project";
819
+ const padding = " ".repeat(
820
+ CHAT_BANNER_LINES.reduce((max, line) => Math.max(max, line.length), 0)
821
+ );
822
+ const info = [
823
+ `Version: ${version}`,
824
+ `Mode: ${modeLabel}`,
825
+ `Dictionary: ${shortRoot}`,
826
+ ];
827
+ const rows = Math.max(CHAT_BANNER_LINES.length, info.length);
828
+ const out = [];
829
+ for (let i = 0; i < rows; i += 1) {
830
+ const left = CHAT_BANNER_LINES[i] || padding;
831
+ const right = info[i] || "";
832
+ out.push(` ${left} ${right}`);
833
+ }
834
+ return out;
835
+ }
836
+
837
+ function resolveProjectRowRoot(row = {}) {
838
+ const raw = String((row && (row.root || row.project_root)) || "").trim();
839
+ if (!raw) return "";
840
+ try {
841
+ const { canonicalProjectRoot } = require("../../projects");
842
+ return canonicalProjectRoot(raw);
843
+ } catch {
844
+ return path.resolve(raw);
845
+ }
846
+ }
847
+
848
+ function loadGlobalProjectRows(activeProjectRoot = "") {
849
+ const {
850
+ listProjectRuntimes,
851
+ filterVisibleProjectRuntimes,
852
+ isGlobalControllerProjectRoot,
853
+ markProjectStopped,
854
+ } = require("../../projects");
855
+ let rows = listProjectRuntimes({ validate: true, cleanupTmp: true }) || [];
856
+ for (const row of rows) {
857
+ const status = String((row && row.status) || "").trim().toLowerCase();
858
+ const root = resolveProjectRowRoot(row);
859
+ if (status === "stale" && root && !isGlobalControllerProjectRoot(root)) {
860
+ try { markProjectStopped(root); } catch { /* ignore stale cleanup failures */ }
861
+ }
862
+ }
863
+ rows = filterVisibleProjectRuntimes(rows);
864
+ rows = rows.filter((row) => !isGlobalControllerProjectRoot(resolveProjectRowRoot(row)));
865
+ return rows.map((row) => ({
866
+ id: row.project_id || row.project_root || "",
867
+ label: row.project_name || (row.project_root ? path.basename(row.project_root) : ""),
868
+ root: row.project_root || "",
869
+ status: row.status || "",
870
+ active: resolveProjectRowRoot(row) === String(activeProjectRoot || ""),
871
+ }));
872
+ }
873
+
874
+ function readProjectAgentSnapshot(projectRoot = "") {
875
+ if (!projectRoot) return { agents: [], metaMap: new Map() };
876
+ try {
877
+ const { buildStatus } = require("../../daemon/status");
878
+ const { buildAgentMaps } = require("../../chat/agentDirectory");
879
+ const status = buildStatus(projectRoot);
880
+ const activeIds = Array.isArray(status.active) ? status.active : [];
881
+ const metaList = Array.isArray(status.active_meta) ? status.active_meta : [];
882
+ const { labelMap, metaMap } = buildAgentMaps(activeIds, metaList);
883
+ const merged = new Map();
884
+ for (const id of activeIds) {
885
+ const meta = metaMap.get(id) || {};
886
+ const colon = id.indexOf(":");
887
+ const fallbackType = colon > 0 ? id.slice(0, colon) : id;
888
+ const fallbackId = colon > 0 ? id.slice(colon + 1) : "";
889
+ merged.set(id, {
890
+ ...meta,
891
+ fullId: id,
892
+ type: meta.type || fallbackType,
893
+ id: meta.id || fallbackId,
894
+ nickname: labelMap.get(id) || id,
895
+ });
896
+ }
897
+ return { agents: activeIds, metaMap: merged };
898
+ } catch {
899
+ return { agents: [], metaMap: new Map() };
900
+ }
901
+ }
902
+
903
+ function createChatApp({ React, ink, props, interactive = true }) {
904
+ const { useReducer, useEffect, useState, useCallback, useRef } = React;
905
+ const { Box, Text, Static, useInput, useApp, useStdout } = ink;
906
+ const h = React.createElement;
907
+ const MultilineInput = createMultilineInput({ React, ink });
908
+ const DashboardBar = createDashboardBar({ React, ink });
909
+
910
+ // Build the initial log: chat history if there is any, otherwise an
911
+ // ASCII banner with project / mode / version info. We resolve history
912
+ // synchronously here so the very first paint already shows it instead
913
+ // of rendering an empty banner and then flashing in the lines.
914
+ const versionLabel = String(fmt.UCODE_VERSION || "");
915
+ const banner = buildChatBannerLines(props, versionLabel);
916
+ const persistedHistory = loadChatHistory(props.projectRoot, 200, { globalMode: props.globalMode });
917
+ const initialLogText = persistedHistory.length > 0
918
+ ? banner.concat(["", "─── history ───"]).concat(persistedHistory).concat([""])
919
+ : banner.concat([""]);
920
+
921
+ return function ChatApp() {
922
+ const [state, dispatch] = useReducer(
923
+ reducer,
924
+ undefined,
925
+ () => createInitialState({
926
+ banner: initialLogText,
927
+ globalMode: props.globalMode,
928
+ globalScope: props.globalScope || "controller",
929
+ settings: props.initialSettings || {},
930
+ })
931
+ );
932
+ const [size, setSize] = useState({ cols: 0, rows: 0 });
933
+ const [spinnerTick, setSpinnerTick] = useState(0);
934
+ const [currentProjectRoot, setCurrentProjectRoot] = useState(props.activeProjectRoot || props.projectRoot || "");
935
+ const [internalAgentView, setInternalAgentView] = useState(() => createInternalAgentViewState());
936
+ const stateRef = useRef(state);
937
+ const currentProjectRootRef = useRef(currentProjectRoot);
938
+ const internalAgentViewRef = useRef(internalAgentView);
939
+ const pendingRef = useRef(null);
940
+ const streamStateRef = useRef(null);
941
+ const historyScopeRef = useRef(null);
942
+ const switchToProjectRootRef = useRef(null);
943
+ const activeChatHistoryRoot = currentProjectRoot || props.projectRoot;
944
+ const activeChatHistoryOptions = chatHistoryOptionsForScope({
945
+ globalMode: props.globalMode,
946
+ globalScope: state.globalScope,
947
+ });
948
+ const { exit } = useApp();
949
+ const { stdout } = useStdout();
950
+
951
+ useEffect(() => {
952
+ stateRef.current = state;
953
+ }, [state]);
954
+
955
+ useEffect(() => {
956
+ currentProjectRootRef.current = currentProjectRoot;
957
+ }, [currentProjectRoot]);
958
+
959
+ historyScopeRef.current = {
960
+ root: activeChatHistoryRoot,
961
+ options: activeChatHistoryOptions,
962
+ };
963
+
964
+ const appendScopedHistory = useCallback((kind, text, meta = {}) => {
965
+ appendChatHistory(activeChatHistoryRoot, kind, text, meta, activeChatHistoryOptions);
966
+ }, [activeChatHistoryRoot, activeChatHistoryOptions.globalMode]);
967
+
968
+ const setStatusText = useCallback((text, options = {}) => {
969
+ const clean = stripBlessedTags(text).trim();
970
+ if (!clean) {
971
+ dispatch({ type: "status/idle" });
972
+ return;
973
+ }
974
+ dispatch({
975
+ type: "status/set",
976
+ payload: {
977
+ message: clean,
978
+ type: options.type || "typing",
979
+ showTimer: options.showTimer === true,
980
+ startedAt: options.startedAt || Date.now(),
981
+ },
982
+ });
983
+ }, []);
984
+
985
+ const logInkMessage = useCallback((kind, text, meta = {}) => {
986
+ const type = String(kind || "system");
987
+ if (type === "status") {
988
+ setStatusText(text);
989
+ return;
990
+ }
991
+ const lines = normalizeInkLogLines(text);
992
+ if (lines.length === 0) return;
993
+ dispatch({ type: "log/appendMany", lines });
994
+ appendScopedHistory(type, stripBlessedTags(text), meta);
995
+ }, [appendScopedHistory, setStatusText]);
996
+
997
+ if (!streamStateRef.current) {
998
+ streamStateRef.current = createInkStreamState({
999
+ dispatch,
1000
+ appendHistory: (kind, text, meta = {}) => {
1001
+ const scope = historyScopeRef.current || {};
1002
+ appendChatHistory(scope.root || props.projectRoot, kind, text, meta, scope.options || {});
1003
+ },
1004
+ displayNameForPublisher: (publisher) => {
1005
+ const current = stateRef.current || {};
1006
+ const meta = current.activeAgentMeta instanceof Map ? current.activeAgentMeta.get(publisher) : null;
1007
+ return getAgentLabelFor(meta, publisher);
1008
+ },
1009
+ });
1010
+ }
1011
+
1012
+ useEffect(() => {
1013
+ internalAgentViewRef.current = internalAgentView;
1014
+ }, [internalAgentView]);
1015
+
1016
+ useEffect(() => {
1017
+ if (!stdout) return undefined;
1018
+ const update = () =>
1019
+ setSize({ cols: stdout.columns || 0, rows: stdout.rows || 0 });
1020
+ update();
1021
+ stdout.on("resize", update);
1022
+ return () => stdout.off("resize", update);
1023
+ }, [stdout]);
1024
+
1025
+ // Load persisted input history once on mount.
1026
+ useEffect(() => {
1027
+ try {
1028
+ const history = loadInputHistory(props.projectRoot, 200, { globalMode: props.globalMode });
1029
+ if (history.length > 0) dispatch({ type: "history/load", list: history });
1030
+ } catch { /* ignore */ }
1031
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1032
+ }, []);
1033
+
1034
+ const sendInternalAgentWatch = (agentId, enabled) => {
1035
+ if (!agentId || !props.daemonConnection || typeof props.daemonConnection.send !== "function") return;
1036
+ try {
1037
+ const { IPC_REQUEST_TYPES } = require("../../shared/eventContract");
1038
+ props.daemonConnection.send({
1039
+ type: IPC_REQUEST_TYPES.BUS_WATCH,
1040
+ agent_id: agentId,
1041
+ enabled: enabled !== false,
1042
+ });
1043
+ } catch { /* ignore */ }
1044
+ };
1045
+
1046
+ const sendInternalAgentMessage = (agentId, message) => {
1047
+ if (!agentId || !message || !props.daemonConnection || typeof props.daemonConnection.send !== "function") return;
1048
+ try {
1049
+ const { IPC_REQUEST_TYPES } = require("../../shared/eventContract");
1050
+ props.daemonConnection.send({
1051
+ type: IPC_REQUEST_TYPES.BUS_SEND,
1052
+ target: agentId,
1053
+ message,
1054
+ injection_mode: "immediate",
1055
+ source: "chat-internal-agent-view",
1056
+ });
1057
+ } catch (err) {
1058
+ setInternalAgentView((prev) => appendInternalAgentText(
1059
+ updateInternalViewStatus(prev, "blocked", err && err.message ? err.message : String(err || "")),
1060
+ `Error: ${err && err.message ? err.message : err}\n`,
1061
+ ));
1062
+ }
1063
+ };
1064
+
1065
+ const isInternalAlias = (view, value) => {
1066
+ if (!view || !view.agentId) return false;
1067
+ const text = String(value || "");
1068
+ if (!text) return false;
1069
+ const aliases = new Set((view.aliases || []).concat([view.agentId, view.label]).filter(Boolean).map(String));
1070
+ return aliases.has(text);
1071
+ };
1072
+
1073
+ const handleInternalStatus = (data = {}) => {
1074
+ const view = internalAgentViewRef.current;
1075
+ if (!view || !view.agentId) return;
1076
+ const metaList = Array.isArray(data.active_meta) ? data.active_meta : [];
1077
+ for (const meta of metaList) {
1078
+ const metaId = meta && (meta.fullId || meta.subscriber_id || meta.id) ? String(meta.fullId || meta.subscriber_id || meta.id) : "";
1079
+ const typedId = meta && meta.type && meta.id ? `${meta.type}:${meta.id}` : "";
1080
+ if (!isInternalAlias(view, metaId) && !isInternalAlias(view, typedId)) continue;
1081
+ const status = internalStatusLabel(meta.activity_state || meta.state || "");
1082
+ const detail = String(meta.activity_detail || meta.detail || meta.status_text || "").trim();
1083
+ setInternalAgentView((prev) => (
1084
+ prev.agentId === view.agentId ? updateInternalViewStatus(prev, status, detail) : prev
1085
+ ));
1086
+ return;
1087
+ }
1088
+ };
1089
+
1090
+ const handleInternalBusMessage = (data = {}) => {
1091
+ const view = internalAgentViewRef.current;
1092
+ if (!view || !view.agentId) return false;
1093
+ if (data.event === "activity_state_changed") {
1094
+ const actor = String(data.subscriber || data.publisher || "").trim();
1095
+ if (!isInternalAlias(view, actor)) return false;
1096
+ setInternalAgentView((prev) => (
1097
+ prev.agentId === view.agentId
1098
+ ? {
1099
+ ...updateInternalViewStatus(
1100
+ prev,
1101
+ data.state || data.activity_state || "",
1102
+ data.detail || (data.data && data.data.detail) || data.message || "",
1103
+ ),
1104
+ }
1105
+ : prev
1106
+ ));
1107
+ return true;
1108
+ }
1109
+ const publisher = String(data.publisher || (data.event === "broadcast" ? "broadcast" : "bus"));
1110
+ const target = String(data.target || data.subscriber || "");
1111
+ const fromAgent = isInternalAlias(view, publisher);
1112
+ const toAgent = isInternalAlias(view, target);
1113
+ if (!fromAgent && !toAgent) return false;
1114
+ if (data.silent) return true;
1115
+ if (data.source === "chat-internal-agent-view" && toAgent && !fromAgent) return true;
1116
+
1117
+ const { displayMessage, streamPayload } = parseInternalBusPayload(data.message || "");
1118
+ if (streamPayload) {
1119
+ if (!fromAgent) return true;
1120
+ const delta = typeof streamPayload.delta === "string"
1121
+ ? streamPayload.delta.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n").replace(/\\r/g, "\n")
1122
+ : "";
1123
+ if (delta) {
1124
+ setInternalAgentView((prev) => (
1125
+ prev.agentId === view.agentId
1126
+ ? updateInternalViewStatus(
1127
+ appendInternalAgentText(prev, delta, { prefix: "* " }),
1128
+ streamPayload.done ? "ready" : "working",
1129
+ streamPayload.reason || prev.detail || "",
1130
+ )
1131
+ : prev
1132
+ ));
1133
+ } else if (streamPayload.done) {
1134
+ setInternalAgentView((prev) => (
1135
+ prev.agentId === view.agentId ? updateInternalViewStatus(prev, "ready", "") : prev
1136
+ ));
1137
+ }
1138
+ return true;
1139
+ }
1140
+ if (!displayMessage) return true;
1141
+ setInternalAgentView((prev) => {
1142
+ if (prev.agentId !== view.agentId) return prev;
1143
+ const next = fromAgent
1144
+ ? appendInternalAgentText(prev, `${displayMessage}\n`, { prefix: "* " })
1145
+ : appendInternalAgentText(prev, `${displayMessage}\n`, { prefix: "> " });
1146
+ return fromAgent ? updateInternalViewStatus(next, "ready", "") : next;
1147
+ });
1148
+ return true;
1149
+ };
1150
+
1151
+ const handleInternalErrorMessage = (message = "") => {
1152
+ const view = internalAgentViewRef.current;
1153
+ if (!view || !view.agentId) return false;
1154
+ setInternalAgentView((prev) => (
1155
+ appendInternalErrorToView(prev, view.agentId, message)
1156
+ ));
1157
+ return true;
1158
+ };
1159
+
1160
+ const handleInternalSendOk = () => {
1161
+ const view = internalAgentViewRef.current;
1162
+ if (!view || !view.agentId) return false;
1163
+ setInternalAgentView((prev) => (
1164
+ prev.agentId === view.agentId ? updateInternalViewStatus(prev, "ready", "") : prev
1165
+ ));
1166
+ return true;
1167
+ };
1168
+
1169
+ const requestDaemonStatus = useCallback(() => {
1170
+ try {
1171
+ const { IPC_REQUEST_TYPES } = require("../../shared/eventContract");
1172
+ const conn = props.daemonConnection;
1173
+ if (conn && typeof conn.send === "function") conn.send({ type: IPC_REQUEST_TYPES.STATUS });
1174
+ } catch { /* ignore */ }
1175
+ }, [props.daemonConnection]);
1176
+
1177
+ const updateDashboardFromStatus = useCallback((data = {}) => {
1178
+ const activeIds = Array.isArray(data.active) ? data.active : [];
1179
+ const metaList = Array.isArray(data.active_meta) ? data.active_meta : [];
1180
+ const { buildAgentMaps } = require("../../chat/agentDirectory");
1181
+ const { labelMap, metaMap } = buildAgentMaps(activeIds, metaList);
1182
+ const agentsForDispatch = activeIds.map((id) => {
1183
+ const meta = metaMap.get(id) || {};
1184
+ const colon = id.indexOf(":");
1185
+ const fallbackType = colon > 0 ? id.slice(0, colon) : id;
1186
+ const fallbackId = colon > 0 ? id.slice(colon + 1) : "";
1187
+ return {
1188
+ ...meta,
1189
+ fullId: id,
1190
+ type: meta.type || fallbackType,
1191
+ id: meta.id || fallbackId,
1192
+ nickname: labelMap.get(id) || id,
1193
+ };
1194
+ });
1195
+ dispatch({ type: "agents/set", list: agentsForDispatch });
1196
+ if (data.cron && Array.isArray(data.cron.tasks)) {
1197
+ dispatch({ type: "cron/set", list: data.cron.tasks });
1198
+ }
1199
+ dispatch({ type: "loop/set", summary: data.loop || null });
1200
+ handleInternalStatus(data);
1201
+ }, []);
1202
+
1203
+ // Wire daemon: register a message handler that turns IPC responses
1204
+ // through the same daemonMessageRouter blessed uses, then adapts the
1205
+ // blessed callbacks to Ink state updates.
1206
+ useEffect(() => {
1207
+ if (!interactive) return undefined;
1208
+ const conn = props.daemonConnection;
1209
+ const setHandler = props.setDaemonMessageHandler;
1210
+ if (!conn || typeof conn.connect !== "function" || typeof setHandler !== "function") {
1211
+ return undefined;
1212
+ }
1213
+ const { IPC_RESPONSE_TYPES } = require("../../shared/eventContract");
1214
+ const { createDaemonMessageRouter } = require("../../chat/daemonMessageRouter");
1215
+ const streamState = streamStateRef.current;
1216
+ const router = createDaemonMessageRouter({
1217
+ escapeBlessed: (value) => String(value == null ? "" : value),
1218
+ stripBlessedTags,
1219
+ logMessage: logInkMessage,
1220
+ renderScreen: () => {},
1221
+ updateDashboard: updateDashboardFromStatus,
1222
+ requestStatus: requestDaemonStatus,
1223
+ resolveStatusLine: (text, data = {}) => {
1224
+ setStatusText(text, {
1225
+ type: data && data.phase === "error" ? "error" : "typing",
1226
+ showTimer: false,
1227
+ });
1228
+ },
1229
+ enqueueBusStatus: (item = {}) => setStatusText(item.text || "Processing bus message", { type: "typing" }),
1230
+ resolveBusStatus: (item = {}) => setStatusText(item.text || "Bus message processed", { type: "typing" }),
1231
+ getPending: () => pendingRef.current,
1232
+ setPending: (value) => { pendingRef.current = value || null; },
1233
+ resolveAgentDisplayName: (value) => {
1234
+ const current = stateRef.current || {};
1235
+ const meta = current.activeAgentMeta instanceof Map ? current.activeAgentMeta.get(value) : null;
1236
+ return getAgentLabelFor(meta, value);
1237
+ },
1238
+ getCurrentView: () => {
1239
+ const current = stateRef.current || {};
1240
+ return current.viewingAgentId ? "agent" : "main";
1241
+ },
1242
+ isAgentViewUsesBus: () => Boolean(internalAgentViewRef.current && internalAgentViewRef.current.agentId),
1243
+ getViewingAgent: () => {
1244
+ const current = stateRef.current || {};
1245
+ return current.viewingAgentId || (internalAgentViewRef.current && internalAgentViewRef.current.agentId) || "";
1246
+ },
1247
+ isAgentEventForViewingAgent: (data, viewingAgent, publisher) => {
1248
+ const view = internalAgentViewRef.current || {};
1249
+ if (!view.agentId && !viewingAgent) return false;
1250
+ const candidates = [
1251
+ viewingAgent,
1252
+ publisher,
1253
+ data && data.publisher,
1254
+ data && data.target,
1255
+ data && data.subscriber,
1256
+ ];
1257
+ return candidates.some((candidate) => isInternalAlias(view, candidate));
1258
+ },
1259
+ writeToAgentTerm: (text, meta = {}) => {
1260
+ const view = internalAgentViewRef.current;
1261
+ if (!view || !view.agentId) return;
1262
+ setInternalAgentView((prev) => (
1263
+ applyInternalAgentTermWrite(prev, view.agentId, text, meta)
1264
+ ));
1265
+ },
1266
+ consumePendingDelivery: (...args) => streamState.consumePendingDelivery(...args),
1267
+ getPendingState: (...args) => streamState.getPendingState(...args),
1268
+ beginStream: (...args) => streamState.beginStream(...args),
1269
+ appendStreamDelta: (...args) => streamState.appendStreamDelta(...args),
1270
+ finalizeStream: (...args) => streamState.finalizeStream(...args),
1271
+ hasStream: (...args) => streamState.hasStream(...args),
1272
+ setTransientAgentState: (agentId, value, options = {}) => {
1273
+ if (!agentId || !value) return;
1274
+ dispatch({
1275
+ type: "agents/patchMeta",
1276
+ agentId,
1277
+ patch: {
1278
+ activity_state: value,
1279
+ activity_detail: options.detail || "",
1280
+ },
1281
+ });
1282
+ },
1283
+ clearTransientAgentState: (agentId) => {
1284
+ if (!agentId) return;
1285
+ dispatch({
1286
+ type: "agents/patchMeta",
1287
+ agentId,
1288
+ patch: {
1289
+ activity_state: "",
1290
+ activity_detail: "",
1291
+ },
1292
+ });
1293
+ },
1294
+ refreshDashboard: () => {},
1295
+ });
1296
+ setHandler((msg) => {
1297
+ if (!msg || typeof msg !== "object") return;
1298
+ if (msg.type === IPC_RESPONSE_TYPES.ERROR && handleInternalErrorMessage(msg.error || "unknown error")) {
1299
+ return;
1300
+ }
1301
+ if (msg.type === IPC_RESPONSE_TYPES.BUS_SEND_OK) {
1302
+ if (handleInternalSendOk()) return;
1303
+ const text = `✓ Message delivered`;
1304
+ logInkMessage("system", text);
1305
+ dispatch({ type: "status/idle" });
1306
+ requestDaemonStatus();
1307
+ return;
1308
+ }
1309
+ router.handleMessage(msg);
1310
+ });
1311
+ conn.connect();
1312
+ return () => {
1313
+ try { if (typeof conn.close === "function") conn.close(); } catch { /* ignore */ }
1314
+ };
1315
+ }, [interactive, logInkMessage, requestDaemonStatus, setStatusText, updateDashboardFromStatus]);
1316
+
1317
+ // commandExecutor wiring. The blessed implementation reuses this
1318
+ // module to dispatch every slash command (~30 callbacks). We adapt
1319
+ // the callback surface to ink: log/status/render writes go through
1320
+ // dispatch, daemon ops go through props.daemonConnection, and
1321
+ // blessed-tag markup the executor sprinkles into log lines is
1322
+ // stripped before rendering.
1323
+ const commandExecutorRef = useRef(null);
1324
+ useEffect(() => {
1325
+ if (!interactive) return undefined;
1326
+ const { createCommandExecutor } = require("../../chat/commandExecutor");
1327
+ const { parseCommand: parseCmd } = require("../../chat/commands");
1328
+ const { startDaemon: transportStartDaemon, stopDaemon: transportStopDaemon } = require("../../chat/transport");
1329
+ const AgentActivator = require("../../bus/activate");
1330
+ const conn = props.daemonConnection;
1331
+
1332
+ try {
1333
+ commandExecutorRef.current = createCommandExecutor({
1334
+ projectRoot: props.projectRoot,
1335
+ getActiveProjectRoot: () => currentProjectRootRef.current || props.projectRoot,
1336
+ parseCommand: parseCmd,
1337
+ escapeBlessed: (v) => String(v == null ? "" : v),
1338
+ logMessage: logInkMessage,
1339
+ resolveStatusLine: (text) => setStatusText(text),
1340
+ renderScreen: () => {},
1341
+ getActiveAgents: () => (stateRef.current && stateRef.current.agents) || [],
1342
+ getActiveAgentMetaMap: () => (stateRef.current && stateRef.current.activeAgentMeta) || new Map(),
1343
+ getAgentLabel: (id) => {
1344
+ const metaMap = (stateRef.current && stateRef.current.activeAgentMeta) || new Map();
1345
+ return getAgentLabelFor(metaMap.get(id), id);
1346
+ },
1347
+ isDaemonRunning: (root) => props.env && props.env.isRunning ? props.env.isRunning(root || props.projectRoot) : true,
1348
+ startDaemon: (root, options = {}) => {
1349
+ const targetRoot = root || props.projectRoot;
1350
+ if (props.env && typeof props.env.startDaemon === "function") return props.env.startDaemon(targetRoot, options);
1351
+ return transportStartDaemon(targetRoot, options);
1352
+ },
1353
+ stopDaemon: (root, options = {}) => transportStopDaemon(root || props.projectRoot, options),
1354
+ restartDaemon: async (root) => {
1355
+ const targetRoot = root || currentProjectRootRef.current || props.projectRoot;
1356
+ if (
1357
+ targetRoot === (currentProjectRootRef.current || props.projectRoot) &&
1358
+ props.daemonCoordinator &&
1359
+ typeof props.daemonCoordinator.restart === "function"
1360
+ ) {
1361
+ await props.daemonCoordinator.restart();
1362
+ return;
1363
+ }
1364
+ try { if (conn && typeof conn.close === "function") conn.close(); } catch { /* ignore */ }
1365
+ transportStopDaemon(targetRoot, { source: "ink-command:/daemon restart" });
1366
+ transportStartDaemon(targetRoot);
1367
+ if (targetRoot === (currentProjectRootRef.current || props.projectRoot) && conn && typeof conn.connect === "function") {
1368
+ await conn.connect();
1369
+ }
1370
+ },
1371
+ send: (req) => { try { if (conn && typeof conn.send === "function") conn.send(req); } catch { /* ignore */ } },
1372
+ requestStatus: requestDaemonStatus,
1373
+ requestCron: (payload = {}) => {
1374
+ try {
1375
+ const { IPC_REQUEST_TYPES } = require("../../shared/eventContract");
1376
+ if (conn && typeof conn.send === "function") {
1377
+ conn.send({ type: IPC_REQUEST_TYPES.CRON, ...payload });
1378
+ }
1379
+ } catch { /* ignore */ }
1380
+ },
1381
+ activateAgent: async (target) => {
1382
+ const activator = new AgentActivator(currentProjectRootRef.current || props.projectRoot);
1383
+ await activator.activate(target);
1384
+ },
1385
+ globalMode: Boolean(props.globalMode),
1386
+ listProjects: () => (stateRef.current && stateRef.current.projects) || [],
1387
+ getCurrentProject: () => ({ project_root: currentProjectRootRef.current || props.projectRoot }),
1388
+ switchProject: async (target) => {
1389
+ const rawTarget = String((target && (target.projectRoot || target.project_root || target.target)) || target || "").trim();
1390
+ let targetRoot = rawTarget;
1391
+ if (/^\d+$/.test(rawTarget)) {
1392
+ const idx = Number.parseInt(rawTarget, 10) - 1;
1393
+ const projects = (stateRef.current && stateRef.current.projects) || [];
1394
+ targetRoot = resolveProjectRowRoot(projects[idx]);
1395
+ }
1396
+ const switchProject = switchToProjectRootRef.current;
1397
+ if (typeof switchProject !== "function") {
1398
+ return { ok: false, error: "project switching unavailable" };
1399
+ }
1400
+ return switchProject(targetRoot, { focusInput: true });
1401
+ },
1402
+ });
1403
+ } catch (err) {
1404
+ dispatch({ type: "log/append", text: `Error: command executor unavailable (${err && err.message ? err.message : err})` });
1405
+ }
1406
+ return undefined;
1407
+ }, [interactive, logInkMessage, requestDaemonStatus, setStatusText]);
1408
+
1409
+ // Periodic STATUS poll to keep the agents footer fresh, mirroring
1410
+ // blessed's requestStatus on a timer.
1411
+ useEffect(() => {
1412
+ if (!interactive) return undefined;
1413
+ const conn = props.daemonConnection;
1414
+ if (!conn || typeof conn.send !== "function") return undefined;
1415
+ const { IPC_REQUEST_TYPES } = require("../../shared/eventContract");
1416
+ const tick = () => {
1417
+ try { conn.send({ type: IPC_REQUEST_TYPES.STATUS }); } catch { /* ignore */ }
1418
+ };
1419
+ tick();
1420
+ const timer = setInterval(tick, 3000);
1421
+ return () => clearInterval(timer);
1422
+ }, [interactive]);
1423
+
1424
+ // Refresh the project rail in global mode. blessed pulls this off the
1425
+ // local registry; we do the same so the dashboard's first row tracks
1426
+ // every running project without needing a daemon round-trip.
1427
+ const refreshGlobalProjects = useCallback((activeRoot = currentProjectRoot) => {
1428
+ if (!props.globalMode) return [];
1429
+ const list = loadGlobalProjectRows(activeRoot);
1430
+ dispatch({
1431
+ type: "projects/set",
1432
+ list,
1433
+ activeProjectRoot: activeRoot,
1434
+ });
1435
+ return list;
1436
+ }, [props.globalMode, currentProjectRoot]);
1437
+
1438
+ useEffect(() => {
1439
+ if (!interactive || !props.globalMode) return undefined;
1440
+ const refresh = () => {
1441
+ try { refreshGlobalProjects(currentProjectRoot); } catch { /* ignore */ }
1442
+ };
1443
+ refresh();
1444
+ const timer = setInterval(refresh, 4000);
1445
+ return () => clearInterval(timer);
1446
+ }, [interactive, props.globalMode, currentProjectRoot, refreshGlobalProjects]);
1447
+
1448
+ useEffect(() => {
1449
+ const internalStatus = state.viewingAgentId ? internalStatusLabel(internalAgentView.status) : "ready";
1450
+ const internalActive = internalStatus !== "ready";
1451
+ if ((!state.status.message || state.status.type === "none") && !internalActive) return undefined;
1452
+ const timer = setInterval(() => setSpinnerTick((t) => t + 1), 100);
1453
+ return () => clearInterval(timer);
1454
+ }, [state.status.message, state.status.type, state.viewingAgentId, internalAgentView.status]);
1455
+
1456
+ const selectedProject = state.selectedProjectIndex >= 0 ? state.projects[state.selectedProjectIndex] : null;
1457
+ const selectedProjectRoot = state.selectedProjectRoot || resolveProjectRowRoot(selectedProject);
1458
+ const currentProject = state.projects.find((row) => resolveProjectRowRoot(row) === currentProjectRoot) || null;
1459
+ const currentProjectLabel = currentProject
1460
+ ? String(currentProject.label || currentProject.project_name || path.basename(currentProjectRoot) || currentProjectRoot)
1461
+ : "";
1462
+ const inCommittedProjectScope = Boolean(props.globalMode && state.globalScope === "project" && currentProjectRoot);
1463
+ const displayAgents = state.agents;
1464
+ const displayAgentMeta = state.activeAgentMeta;
1465
+ const targetAgentId = state.agentSelectionMode && state.selectedAgentIndex >= 0
1466
+ ? displayAgents[state.selectedAgentIndex]
1467
+ : null;
1468
+ const targetAgentMeta = targetAgentId ? displayAgentMeta.get(targetAgentId) : null;
1469
+ const targetAgentLabel = targetAgentId ? getAgentLabelFor(targetAgentMeta, targetAgentId) : "";
1470
+ const restartDaemonBestEffort = useCallback(() => {
1471
+ const coordinator = props.daemonCoordinator;
1472
+ if (coordinator && typeof coordinator.restart === "function") {
1473
+ Promise.resolve(coordinator.restart()).catch((err) => {
1474
+ dispatch({ type: "log/append", text: `Error: ${err && err.message ? err.message : err}` });
1475
+ });
1476
+ return;
1477
+ }
1478
+ const conn = props.daemonConnection;
1479
+ try { if (conn && typeof conn.close === "function") conn.close(); } catch { /* ignore */ }
1480
+ try { if (conn && typeof conn.connect === "function") conn.connect(); } catch { /* ignore */ }
1481
+ }, []);
1482
+
1483
+ const persistSetting = useCallback((patch, statusText, restart = false) => {
1484
+ try {
1485
+ const { saveConfig } = require("../../config");
1486
+ saveConfig(props.projectRoot, patch);
1487
+ } catch (err) {
1488
+ dispatch({ type: "log/append", text: `Error: ${err && err.message ? err.message : err}` });
1489
+ }
1490
+ if (statusText) {
1491
+ dispatch({
1492
+ type: "status/set",
1493
+ payload: { message: statusText, type: "typing", showTimer: false, startedAt: Date.now() },
1494
+ });
1495
+ }
1496
+ if (restart) restartDaemonBestEffort();
1497
+ }, [restartDaemonBestEffort]);
1498
+
1499
+ const clearUfooAgentIdentity = useCallback(() => {
1500
+ try {
1501
+ const { getUfooPaths } = require("../../ufoo/paths");
1502
+ const agentDir = getUfooPaths(props.projectRoot).agentDir;
1503
+ fs.rmSync(path.join(agentDir, "ufoo-agent.json"), { force: true });
1504
+ fs.rmSync(path.join(agentDir, "ufoo-agent.history.jsonl"), { force: true });
1505
+ } catch { /* ignore */ }
1506
+ }, []);
1507
+
1508
+ const applySelectedMode = useCallback(() => {
1509
+ const { normalizeLaunchMode } = require("../../config");
1510
+ const mode = normalizeLaunchMode(state.modeOptions[state.selectedModeIndex]);
1511
+ dispatch({ type: "settings/applyMode" });
1512
+ persistSetting({ launchMode: mode }, `Launch mode: ${mode}`, true);
1513
+ dispatch({ type: "focus/set", mode: "input" });
1514
+ }, [state.modeOptions, state.selectedModeIndex, persistSetting]);
1515
+
1516
+ const applySelectedProvider = useCallback(() => {
1517
+ const { normalizeAgentProvider } = require("../../config");
1518
+ const selected = state.providerOptions[state.selectedProviderIndex];
1519
+ const provider = normalizeAgentProvider(selected && selected.value);
1520
+ dispatch({ type: "settings/applyProvider" });
1521
+ clearUfooAgentIdentity();
1522
+ persistSetting({ agentProvider: provider }, `ufoo-agent: ${provider === "claude-cli" ? "claude" : "codex"}`, true);
1523
+ dispatch({ type: "focus/set", mode: "input" });
1524
+ }, [state.providerOptions, state.selectedProviderIndex, clearUfooAgentIdentity, persistSetting]);
1525
+
1526
+ const sendCronStop = useCallback((taskId) => {
1527
+ if (!taskId || !props.daemonConnection || typeof props.daemonConnection.send !== "function") return;
1528
+ try {
1529
+ const { IPC_REQUEST_TYPES } = require("../../shared/eventContract");
1530
+ props.daemonConnection.send({ type: IPC_REQUEST_TYPES.CRON, operation: "stop", id: taskId });
1531
+ } catch (err) {
1532
+ dispatch({ type: "log/append", text: `Error: ${err && err.message ? err.message : err}` });
1533
+ }
1534
+ }, []);
1535
+
1536
+ const switchToProjectRoot = useCallback(async (targetRoot, options = {}) => {
1537
+ const root = String(targetRoot || "").trim();
1538
+ if (!root) return { ok: false, error: "project root unavailable" };
1539
+ if (props.globalMode && props.env && typeof props.env.isRunning === "function" && !props.env.isRunning(root)) {
1540
+ try {
1541
+ const { markProjectStopped } = require("../../projects");
1542
+ markProjectStopped(root);
1543
+ } catch { /* ignore */ }
1544
+ refreshGlobalProjects(currentProjectRoot);
1545
+ dispatch({ type: "projects/clearSelection" });
1546
+ dispatch({ type: "focus/set", mode: "input" });
1547
+ const label = path.basename(root) || root;
1548
+ const result = { ok: false, error: `project is not running: ${label}`, stopped: true };
1549
+ dispatch({ type: "log/append", text: `Project ${label} is not running; removed stale dashboard entry` });
1550
+ return result;
1551
+ }
1552
+ const focusInput = options.focusInput === true;
1553
+ const selected = state.projects.find((row) => resolveProjectRowRoot(row) === root) || {};
1554
+ dispatch({ type: "log/clear" });
1555
+ const banner = buildChatBannerLines({
1556
+ ...props,
1557
+ activeProjectRoot: root,
1558
+ globalScope: "project",
1559
+ }, fmt.UCODE_VERSION || "");
1560
+ dispatch({ type: "log/appendMany", lines: banner });
1561
+ const persisted = loadChatHistory(root, 200, { globalMode: false });
1562
+ if (persisted.length > 0) {
1563
+ dispatch({ type: "log/append", text: "" });
1564
+ dispatch({ type: "log/append", text: "─── history ───" });
1565
+ dispatch({ type: "log/appendMany", lines: persisted });
1566
+ }
1567
+ if (props.daemonCoordinator && typeof props.daemonCoordinator.switchProject === "function") {
1568
+ const { socketPath } = require("../../daemon");
1569
+ const res = await Promise.resolve(props.daemonCoordinator.switchProject({
1570
+ projectRoot: root,
1571
+ sockPath: socketPath(root),
1572
+ autoStart: false,
1573
+ }));
1574
+ if (!res || res.ok !== true) {
1575
+ dispatch({ type: "log/append", text: `Error: ${(res && res.error) || "switch failed"}` });
1576
+ return res || { ok: false, error: "switch failed" };
1577
+ }
1578
+ }
1579
+ setCurrentProjectRoot(root);
1580
+ dispatch({ type: "scope/set", scope: "project" });
1581
+ dispatch({
1582
+ type: "projects/select",
1583
+ index: state.projects.indexOf(selected),
1584
+ projectRoot: root,
1585
+ });
1586
+ refreshGlobalProjects(root);
1587
+ if (focusInput) dispatch({ type: "focus/set", mode: "input" });
1588
+ try {
1589
+ const { IPC_REQUEST_TYPES } = require("../../shared/eventContract");
1590
+ if (props.daemonConnection && typeof props.daemonConnection.send === "function") {
1591
+ props.daemonConnection.send({ type: IPC_REQUEST_TYPES.STATUS });
1592
+ }
1593
+ } catch { /* ignore */ }
1594
+ return { ok: true, project_root: root };
1595
+ }, [
1596
+ props,
1597
+ props.daemonCoordinator,
1598
+ props.daemonConnection,
1599
+ props.env,
1600
+ state.projects,
1601
+ refreshGlobalProjects,
1602
+ currentProjectRoot,
1603
+ ]);
1604
+
1605
+ useEffect(() => {
1606
+ switchToProjectRootRef.current = switchToProjectRoot;
1607
+ }, [switchToProjectRoot]);
1608
+
1609
+ const switchToControllerRoot = useCallback(async () => {
1610
+ const root = props.activeProjectRoot || props.projectRoot || "";
1611
+ if (!root) return { ok: false, error: "controller root unavailable" };
1612
+ if (props.daemonCoordinator && typeof props.daemonCoordinator.switchProject === "function") {
1613
+ const { socketPath } = require("../../daemon");
1614
+ const res = await Promise.resolve(props.daemonCoordinator.switchProject({
1615
+ projectRoot: root,
1616
+ sockPath: socketPath(root),
1617
+ }));
1618
+ if (!res || res.ok !== true) {
1619
+ dispatch({ type: "log/append", text: `Error: ${(res && res.error) || "switch to global failed"}` });
1620
+ return res || { ok: false, error: "switch to global failed" };
1621
+ }
1622
+ }
1623
+
1624
+ dispatch({ type: "projects/clearSelection" });
1625
+ dispatch({ type: "scope/set", scope: "controller" });
1626
+ setCurrentProjectRoot(root);
1627
+ refreshGlobalProjects(root);
1628
+
1629
+ dispatch({ type: "log/clear" });
1630
+ const banner = buildChatBannerLines({
1631
+ ...props,
1632
+ activeProjectRoot: root,
1633
+ globalScope: "controller",
1634
+ }, fmt.UCODE_VERSION || "");
1635
+ dispatch({ type: "log/appendMany", lines: banner });
1636
+ const persisted = loadChatHistory(root, 200, { globalMode: true });
1637
+ if (persisted.length > 0) {
1638
+ dispatch({ type: "log/append", text: "" });
1639
+ dispatch({ type: "log/append", text: "─── history ───" });
1640
+ dispatch({ type: "log/appendMany", lines: persisted });
1641
+ }
1642
+
1643
+ const snapshot = readProjectAgentSnapshot(root);
1644
+ dispatch({ type: "agents/set", list: snapshot.agents.map((id) => snapshot.metaMap.get(id) || { fullId: id }) });
1645
+ try {
1646
+ const { IPC_REQUEST_TYPES } = require("../../shared/eventContract");
1647
+ if (props.daemonConnection && typeof props.daemonConnection.send === "function") {
1648
+ props.daemonConnection.send({ type: IPC_REQUEST_TYPES.STATUS });
1649
+ }
1650
+ } catch { /* ignore */ }
1651
+ return { ok: true, project_root: root };
1652
+ }, [
1653
+ props,
1654
+ props.daemonCoordinator,
1655
+ props.daemonConnection,
1656
+ refreshGlobalProjects,
1657
+ ]);
1658
+
1659
+ const closeSelectedProject = useCallback(async () => {
1660
+ if (!props.globalMode || !Array.isArray(state.projects) || state.projects.length === 0) return;
1661
+ const selectedIndex = state.selectedProjectIndex >= 0 ? state.selectedProjectIndex : 0;
1662
+ const proj = state.projects[selectedIndex];
1663
+ const targetRoot = resolveProjectRowRoot(proj);
1664
+ const label = (proj && (proj.label || proj.project_name)) || targetRoot;
1665
+ if (!targetRoot) {
1666
+ dispatch({ type: "log/append", text: "Error: project root unavailable" });
1667
+ return;
1668
+ }
1669
+
1670
+ dispatch({ type: "log/append", text: `Closing project ${label} daemon and agents...` });
1671
+ let activeRoot = currentProjectRoot;
1672
+ try {
1673
+ if (targetRoot === currentProjectRoot) {
1674
+ const fallback = state.projects
1675
+ .map(resolveProjectRowRoot)
1676
+ .find((root) => root && root !== targetRoot);
1677
+ if (!fallback) {
1678
+ dispatch({ type: "log/append", text: "Error: Cannot close current project; switch to another project first" });
1679
+ return;
1680
+ }
1681
+ if (!props.daemonCoordinator || typeof props.daemonCoordinator.switchProject !== "function") {
1682
+ dispatch({ type: "log/append", text: "Error: project switching unavailable" });
1683
+ return;
1684
+ }
1685
+ const { socketPath } = require("../../daemon");
1686
+ const switched = await Promise.resolve(props.daemonCoordinator.switchProject({
1687
+ projectRoot: fallback,
1688
+ sockPath: socketPath(fallback),
1689
+ autoStart: false,
1690
+ }));
1691
+ if (!switched || switched.ok !== true) {
1692
+ dispatch({ type: "log/append", text: `Error: Failed to switch project before close: ${(switched && switched.error) || "switch failed"}` });
1693
+ return;
1694
+ }
1695
+ activeRoot = fallback;
1696
+ setCurrentProjectRoot(fallback);
1697
+ dispatch({ type: "scope/set", scope: "project" });
1698
+ }
1699
+
1700
+ const { stopDaemon } = require("../../chat/transport");
1701
+ const { isRunning } = require("../../daemon");
1702
+ stopDaemon(targetRoot, { source: `ink-project-close:${targetRoot}` });
1703
+ refreshGlobalProjects(activeRoot);
1704
+ if (isRunning(targetRoot)) {
1705
+ dispatch({ type: "log/append", text: `Error: Project ${label} daemon is still running after stop` });
1706
+ return;
1707
+ }
1708
+ dispatch({ type: "log/append", text: `Closed project ${label} daemon and agents` });
1709
+ } catch (err) {
1710
+ dispatch({ type: "log/append", text: `Error: ${err && err.message ? err.message : err}` });
1711
+ }
1712
+ }, [
1713
+ props.globalMode,
1714
+ props.daemonCoordinator,
1715
+ state.projects,
1716
+ state.selectedProjectIndex,
1717
+ currentProjectRoot,
1718
+ refreshGlobalProjects,
1719
+ ]);
1720
+
1721
+ const submit = useCallback(async (submitted) => {
1722
+ const value = String(submitted == null ? state.draft : submitted);
1723
+ const trimmed = value.trim();
1724
+ if (props.globalMode && state.globalScope === "project" && selectedProjectRoot && selectedProjectRoot !== currentProjectRoot) {
1725
+ const switched = await switchToProjectRoot(selectedProjectRoot, { focusInput: true });
1726
+ if (!switched || switched.ok !== true) return;
1727
+ }
1728
+ dispatch({ type: "draft/clear" });
1729
+ const { createInputSubmitHandler } = require("../../chat/inputSubmitHandler");
1730
+ const { parseAtTarget } = require("../../chat/commands");
1731
+ const { resolveAgentId } = require("../../chat/agentDirectory");
1732
+ const { subscriberToSafeName } = require("../../bus/utils");
1733
+ const { getUfooPaths } = require("../../ufoo/paths");
1734
+ const { createTerminalAdapterRouter } = require("../../terminal/adapterRouter");
1735
+ const submitState = {};
1736
+ Object.defineProperties(submitState, {
1737
+ targetAgent: {
1738
+ get: () => targetAgentId || null,
1739
+ set: (next) => {
1740
+ const id = String(next || "");
1741
+ if (!id) {
1742
+ dispatch({ type: "agents/clearTarget" });
1743
+ return;
1744
+ }
1745
+ const idx = displayAgents.indexOf(id);
1746
+ if (idx >= 0) dispatch({ type: "agents/select", index: idx });
1747
+ },
1748
+ },
1749
+ pending: {
1750
+ get: () => pendingRef.current,
1751
+ set: (next) => { pendingRef.current = next || null; },
1752
+ },
1753
+ activeAgentMetaMap: {
1754
+ get: () => displayAgentMeta,
1755
+ },
1756
+ });
1757
+ const send = (req) => {
1758
+ if (!props.daemonConnection || typeof props.daemonConnection.send !== "function") {
1759
+ throw new Error("daemon connection unavailable");
1760
+ }
1761
+ props.daemonConnection.send(req);
1762
+ };
1763
+ const handler = createInputSubmitHandler({
1764
+ state: submitState,
1765
+ parseAtTarget,
1766
+ resolveAgentId: (label) => resolveAgentId({
1767
+ label,
1768
+ activeAgents: displayAgents,
1769
+ labelMap: buildActiveAgentLabelMap(displayAgents, displayAgentMeta),
1770
+ lookupNickname: (nickname) => {
1771
+ for (const [id, meta] of displayAgentMeta.entries()) {
1772
+ if (!meta) continue;
1773
+ if (meta.nickname === nickname || meta.scoped_nickname === nickname || meta.display_nickname === nickname) return id;
1774
+ }
1775
+ return null;
1776
+ },
1777
+ }),
1778
+ executeCommand: async (text) => {
1779
+ const exec = commandExecutorRef.current;
1780
+ if (!exec || typeof exec.executeCommand !== "function") {
1781
+ throw new Error("command executor not ready yet");
1782
+ }
1783
+ return exec.executeCommand(text);
1784
+ },
1785
+ queueStatusLine: (text) => setStatusText(text, { type: "typing", showTimer: true }),
1786
+ send,
1787
+ logMessage: logInkMessage,
1788
+ getAgentLabel: (id) => getAgentLabelFor(displayAgentMeta.get(id), id),
1789
+ escapeBlessed: (next) => String(next == null ? "" : next),
1790
+ markPendingDelivery: (agentId) => {
1791
+ const meta = displayAgentMeta.get(agentId);
1792
+ streamStateRef.current.markPendingDelivery(agentId, getAgentLabelFor(meta, agentId));
1793
+ },
1794
+ clearTargetAgent: () => dispatch({ type: "agents/clearTarget" }),
1795
+ setTargetAgent: (agentId) => {
1796
+ const idx = displayAgents.indexOf(agentId);
1797
+ if (idx >= 0) dispatch({ type: "agents/select", index: idx });
1798
+ },
1799
+ enterAgentView: (agentId, options = {}) => {
1800
+ const payload = buildAgentEnterPayload(agentId);
1801
+ if (payload && options.useBus) payload.useBus = true;
1802
+ if (payload && payload.useBus) {
1803
+ enterInternalAgentView(payload);
1804
+ return;
1805
+ }
1806
+ if (payload && typeof props.requestEnterAgentView === "function") {
1807
+ props.requestEnterAgentView(agentId, payload);
1808
+ exit();
1809
+ }
1810
+ },
1811
+ getAgentAdapter: (agentId) => {
1812
+ const meta = displayAgentMeta.get(agentId) || {};
1813
+ const launchMode = String(meta.launch_mode || meta.launchMode || state.settings.launchMode || "").trim();
1814
+ return createTerminalAdapterRouter().getAdapter({ launchMode, agentId, meta });
1815
+ },
1816
+ activateAgent: async (agentId) => {
1817
+ const AgentActivator = require("../../bus/activate");
1818
+ const activator = new AgentActivator(currentProjectRoot || props.projectRoot);
1819
+ await activator.activate(agentId);
1820
+ },
1821
+ getInjectSockPath: (agentId) => {
1822
+ const safeName = subscriberToSafeName(agentId);
1823
+ return path.join(getUfooPaths(currentProjectRoot || props.projectRoot).busQueuesDir, safeName, "inject.sock");
1824
+ },
1825
+ existsSync: fs.existsSync,
1826
+ commitInputHistory: (text) => {
1827
+ dispatch({ type: "history/push", value: text });
1828
+ try { appendInputHistory(props.projectRoot, text, { globalMode: props.globalMode }); } catch { /* ignore */ }
1829
+ },
1830
+ focusInput: () => dispatch({ type: "focus/set", mode: "input" }),
1831
+ renderScreen: () => {},
1832
+ getShellCwd: () => activeChatHistoryRoot,
1833
+ runShellCommand: async (shellCommand, options = {}) => {
1834
+ const { runShellCommand } = require("../../chat/shellCommand");
1835
+ return runShellCommand(shellCommand, options);
1836
+ },
1837
+ });
1838
+ try {
1839
+ await handler.handleSubmit(value);
1840
+ } catch (err) {
1841
+ dispatch({ type: "log/append", text: `Error: ${err && err.message ? err.message : "send failed"}` });
1842
+ dispatch({ type: "status/idle" });
1843
+ }
1844
+ }, [
1845
+ state.draft,
1846
+ targetAgentId,
1847
+ props.globalMode,
1848
+ props.projectRoot,
1849
+ props.daemonConnection,
1850
+ props.requestEnterAgentView,
1851
+ selectedProjectRoot,
1852
+ currentProjectRoot,
1853
+ state.globalScope,
1854
+ state.settings.launchMode,
1855
+ switchToProjectRoot,
1856
+ displayAgents,
1857
+ displayAgentMeta,
1858
+ activeChatHistoryRoot,
1859
+ logInkMessage,
1860
+ setStatusText,
1861
+ exit,
1862
+ ]);
1863
+
1864
+ const onArrowUpAtTop = useCallback(() => {
1865
+ if (state.inputHistory.length > 0) {
1866
+ const next = Math.max(0, state.historyIndex - 1);
1867
+ if (next !== state.historyIndex || state.draft !== state.inputHistory[next]) {
1868
+ dispatch({ type: "history/setIndex", index: next });
1869
+ dispatch({ type: "draft/set", value: state.inputHistory[next] || "" });
1870
+ setCompletionSuppressedDraft(state.inputHistory[next] || "");
1871
+ setDraftVersion((v) => v + 1);
1872
+ return;
1873
+ }
1874
+ }
1875
+ if (state.agentSelectionMode) dispatch({ type: "agents/clearTarget" });
1876
+ }, [state.inputHistory, state.historyIndex, state.draft, state.agentSelectionMode]);
1877
+
1878
+ const onArrowDownAtBottom = useCallback((currentValue) => {
1879
+ if (state.inputHistory.length > 0) {
1880
+ const transition = fmt.resolveHistoryDownTransition({
1881
+ inputHistory: state.inputHistory,
1882
+ historyIndex: state.historyIndex,
1883
+ currentValue,
1884
+ });
1885
+ if (transition.moved) {
1886
+ dispatch({ type: "history/setIndex", index: transition.nextHistoryIndex });
1887
+ dispatch({ type: "draft/set", value: transition.nextValue });
1888
+ setCompletionSuppressedDraft(transition.nextValue);
1889
+ setDraftVersion((v) => v + 1);
1890
+ return;
1891
+ }
1892
+ }
1893
+ // Hand focus to the dashboard. Three-tier flow:
1894
+ // global mode → projects → agents → mode/provider/cron
1895
+ // project mode → agents → mode/provider/cron
1896
+ if (props.globalMode) {
1897
+ dispatch({ type: "focus/set", mode: "dashboard" });
1898
+ if (state.projects.length > 0 && state.selectedProjectIndex < 0) {
1899
+ dispatch({ type: "view/set", view: "projects" });
1900
+ dispatch({ type: "projects/select", index: 0, projectRoot: resolveProjectRowRoot(state.projects[0]) });
1901
+ dispatch({ type: "projects/window", windowStart: 0 });
1902
+ } else {
1903
+ dispatch({ type: "view/set", view: "agents" });
1904
+ if (displayAgents.length > 0 && state.selectedAgentIndex < 0) {
1905
+ dispatch({ type: "agents/select", index: 0 });
1906
+ }
1907
+ }
1908
+ return;
1909
+ }
1910
+ dispatch({ type: "focus/set", mode: "dashboard" });
1911
+ dispatch({ type: "view/set", view: "agents" });
1912
+ if (displayAgents.length > 0 && state.selectedAgentIndex < 0) {
1913
+ dispatch({ type: "agents/select", index: 0 });
1914
+ }
1915
+ }, [state.inputHistory, state.historyIndex, state.projects.length, state.selectedProjectIndex, displayAgents.length, state.selectedAgentIndex, props.globalMode]);
1916
+
1917
+ const onArrowSideAtEmpty = useCallback((direction) => {
1918
+ if (!state.agentSelectionMode || displayAgents.length === 0) return;
1919
+ const cur = state.selectedAgentIndex < 0 ? 0 : state.selectedAgentIndex;
1920
+ const next = direction === "left"
1921
+ ? Math.max(0, cur - 1)
1922
+ : Math.min(displayAgents.length - 1, cur + 1);
1923
+ dispatch({ type: "agents/select", index: next });
1924
+ }, [state.agentSelectionMode, state.selectedAgentIndex, displayAgents.length]);
1925
+
1926
+ // Inline completions: shown above the input whenever the draft starts
1927
+ // with "/" or "@". Tab/Enter accept the highlighted entry, ↑↓ move the
1928
+ // selection. The list reuses the pure buildCompletions helper from
1929
+ // src/ui/format so jest can pin the source list without rendering ink.
1930
+ const { COMMAND_REGISTRY, COMMAND_TREE } = require("../../chat/commands");
1931
+ const agentLabels = displayAgents.map((id) =>
1932
+ getAgentLabelFor(displayAgentMeta.get(id), id)
1933
+ );
1934
+
1935
+ // Lazy-load the dynamic completion sources once so /group run and
1936
+ // /solo run get the same alias/profile suggestions blessed shows.
1937
+ const dynamicSourcesRef = useRef(null);
1938
+ if (!dynamicSourcesRef.current) {
1939
+ const sources = { groupTemplates: [], soloProfiles: [] };
1940
+ try {
1941
+ const { loadTemplateRegistry } = require("../../group/templates");
1942
+ const reg = typeof loadTemplateRegistry === "function" ? loadTemplateRegistry(props.projectRoot) : null;
1943
+ if (reg && Array.isArray(reg.templates)) {
1944
+ sources.groupTemplates = reg.templates.map((item) => ({
1945
+ alias: item.alias,
1946
+ cmd: item.alias,
1947
+ desc: item.templateDescription || "",
1948
+ source: item.source || "",
1949
+ }));
1950
+ }
1951
+ } catch { /* ignore */ }
1952
+ try {
1953
+ const { loadPromptProfileRegistry } = require("../../group/promptProfiles");
1954
+ const { buildPromptProfileCandidates } = require("../../solo/commands");
1955
+ const reg = typeof loadPromptProfileRegistry === "function" ? loadPromptProfileRegistry(props.projectRoot) : null;
1956
+ if (reg && typeof buildPromptProfileCandidates === "function") {
1957
+ sources.soloProfiles = buildPromptProfileCandidates(reg) || [];
1958
+ }
1959
+ } catch { /* ignore */ }
1960
+ dynamicSourcesRef.current = sources;
1961
+ }
1962
+
1963
+ const completions = fmt.buildCompletions({
1964
+ text: state.draft,
1965
+ agents: displayAgents,
1966
+ agentLabels,
1967
+ commands: COMMAND_REGISTRY,
1968
+ commandTree: COMMAND_TREE,
1969
+ groupTemplates: dynamicSourcesRef.current.groupTemplates,
1970
+ soloProfiles: dynamicSourcesRef.current.soloProfiles,
1971
+ limit: 20,
1972
+ });
1973
+ const [completionIndex, setCompletionIndex] = useState(0);
1974
+ // First visible row inside the popup. We show 8 rows at a time
1975
+ // (POPUP_PAGE_SIZE) and slide the window when the cursor crosses
1976
+ // the bottom or top, mimicking how a terminal list typically scrolls.
1977
+ const POPUP_PAGE_SIZE = 8;
1978
+ const [completionWindowStart, setCompletionWindowStart] = useState(0);
1979
+ // Bumped whenever the completion popup writes a new value into the
1980
+ // draft — MultilineInput watches this counter so it can park its
1981
+ // cursor at the end of the freshly accepted suggestion instead of
1982
+ // staying wherever the user last typed.
1983
+ const [draftVersion, setDraftVersion] = useState(0);
1984
+ // History recall should not immediately turn a recalled command such as
1985
+ // "/history" into an active completion popup; otherwise ↑/↓ get captured
1986
+ // by completion navigation and the user cannot keep walking history.
1987
+ const [completionSuppressedDraft, setCompletionSuppressedDraft] = useState(null);
1988
+ // Reset the selection cursor whenever the suggestion list shape changes.
1989
+ useEffect(() => {
1990
+ if (completions.length === 0) {
1991
+ if (completionIndex !== 0) setCompletionIndex(0);
1992
+ if (completionWindowStart !== 0) setCompletionWindowStart(0);
1993
+ } else if (completionIndex >= completions.length) {
1994
+ setCompletionIndex(completions.length - 1);
1995
+ setCompletionWindowStart(Math.max(0, completions.length - POPUP_PAGE_SIZE));
1996
+ }
1997
+ }, [completions.length, completionIndex, completionWindowStart]);
1998
+ const completionsOpen = completions.length > 0 && state.draft !== completionSuppressedDraft;
1999
+ const acceptCompletion = useCallback(() => {
2000
+ if (!completionsOpen) return false;
2001
+ const item = completions[Math.max(0, Math.min(completions.length - 1, completionIndex))];
2002
+ if (item) {
2003
+ dispatch({ type: "draft/set", value: item.replace });
2004
+ setCompletionSuppressedDraft(item.hasChildren ? null : item.replace);
2005
+ setDraftVersion((v) => v + 1);
2006
+ }
2007
+ setCompletionIndex(0);
2008
+ return true;
2009
+ }, [completionsOpen, completions, completionIndex]);
2010
+
2011
+ const buildAgentEnterPayload = (agentId) => {
2012
+ const agentMeta = displayAgentMeta.get(agentId);
2013
+ const enterRequest = resolveAgentEnterRequest({
2014
+ agentId,
2015
+ projectRoot: currentProjectRoot || props.projectRoot,
2016
+ activeAgentMeta: displayAgentMeta,
2017
+ settings: state.settings,
2018
+ });
2019
+ return {
2020
+ ...enterRequest,
2021
+ agentLabel: getAgentLabelFor(agentMeta, agentId),
2022
+ agentAliases: [
2023
+ agentId,
2024
+ agentMeta && agentMeta.nickname,
2025
+ agentMeta && agentMeta.scoped_nickname,
2026
+ agentMeta && agentMeta.display_nickname,
2027
+ ].filter(Boolean).map(String),
2028
+ };
2029
+ };
2030
+
2031
+ const enterInternalAgentView = (enterRequest = {}) => {
2032
+ const agentId = String(enterRequest.agentId || "").trim();
2033
+ if (!agentId) return;
2034
+ const previous = internalAgentViewRef.current;
2035
+ if (previous && previous.agentId && previous.agentId !== agentId) {
2036
+ sendInternalAgentWatch(previous.agentId, false);
2037
+ }
2038
+ const next = createInternalAgentViewState({
2039
+ agentId,
2040
+ label: enterRequest.agentLabel || agentId,
2041
+ aliases: enterRequest.agentAliases || [],
2042
+ projectRoot: enterRequest.projectRoot || currentProjectRoot || props.projectRoot,
2043
+ width: size.cols || 80,
2044
+ });
2045
+ setInternalAgentView(next);
2046
+ internalAgentViewRef.current = next;
2047
+ dispatch({ type: "agentView/enter", agentId });
2048
+ dispatch({ type: "focus/set", mode: "input" });
2049
+ dispatch({ type: "agents/clearTarget" });
2050
+ sendInternalAgentWatch(agentId, true);
2051
+ try {
2052
+ const { IPC_REQUEST_TYPES } = require("../../shared/eventContract");
2053
+ if (props.daemonConnection && typeof props.daemonConnection.send === "function") {
2054
+ props.daemonConnection.send({ type: IPC_REQUEST_TYPES.STATUS });
2055
+ }
2056
+ } catch { /* ignore */ }
2057
+ };
2058
+
2059
+ const exitInternalAgentView = () => {
2060
+ const view = internalAgentViewRef.current;
2061
+ if (view && view.agentId) sendInternalAgentWatch(view.agentId, false);
2062
+ const empty = createInternalAgentViewState();
2063
+ setInternalAgentView(empty);
2064
+ internalAgentViewRef.current = empty;
2065
+ dispatch({ type: "agentView/exit" });
2066
+ dispatch({ type: "view/set", view: "agents" });
2067
+ dispatch({ type: "focus/set", mode: "input" });
2068
+ };
2069
+
2070
+ const submitInternalAgentInput = () => {
2071
+ const view = internalAgentViewRef.current;
2072
+ const text = String((view && view.input) || "").trim();
2073
+ if (!view || !view.agentId || !text) return;
2074
+ setInternalAgentView((prev) => ({
2075
+ ...updateInternalViewStatus(
2076
+ appendInternalAgentText(prev, `${text}\n`, { prefix: "> " }),
2077
+ "working",
2078
+ "",
2079
+ ),
2080
+ input: "",
2081
+ cursor: 0,
2082
+ }));
2083
+ sendInternalAgentMessage(view.agentId, text);
2084
+ };
2085
+
2086
+ const handleInternalAgentDashboardKey = (input, key = {}) => {
2087
+ const keyName = resolveInternalKeyName(input, key);
2088
+ const totalItems = 1 + displayAgents.length;
2089
+ const currentIndex = Math.max(
2090
+ 0,
2091
+ Math.min(totalItems - 1, Number(internalAgentViewRef.current.barIndex) || 0),
2092
+ );
2093
+ if (keyName === "left") {
2094
+ setInternalAgentView((prev) => ({
2095
+ ...prev,
2096
+ barIndex: Math.max(0, (Number(prev.barIndex) || 0) - 1),
2097
+ }));
2098
+ return true;
2099
+ }
2100
+ if (keyName === "right") {
2101
+ setInternalAgentView((prev) => ({
2102
+ ...prev,
2103
+ barIndex: Math.min(totalItems - 1, (Number(prev.barIndex) || 0) + 1),
2104
+ }));
2105
+ return true;
2106
+ }
2107
+ if (keyName === "up") {
2108
+ dispatch({ type: "focus/set", mode: "input" });
2109
+ return true;
2110
+ }
2111
+ if (keyName === "return" || keyName === "enter") {
2112
+ if (currentIndex === 0) {
2113
+ exitInternalAgentView();
2114
+ return true;
2115
+ }
2116
+ const agentId = displayAgents[currentIndex - 1];
2117
+ if (!agentId) return true;
2118
+ if (agentId === state.viewingAgentId) {
2119
+ dispatch({ type: "focus/set", mode: "input" });
2120
+ return true;
2121
+ }
2122
+ const payload = buildAgentEnterPayload(agentId);
2123
+ if (payload && payload.useBus) {
2124
+ enterInternalAgentView(payload);
2125
+ return true;
2126
+ }
2127
+ if (payload && typeof props.requestEnterAgentView === "function") {
2128
+ if (state.viewingAgentId) sendInternalAgentWatch(state.viewingAgentId, false);
2129
+ props.requestEnterAgentView(agentId, payload);
2130
+ exit();
2131
+ }
2132
+ return true;
2133
+ }
2134
+ if (key && key.ctrl && input === "x") {
2135
+ if (currentIndex <= 0) return true;
2136
+ const agentId = displayAgents[currentIndex - 1];
2137
+ if (!agentId) return true;
2138
+ try {
2139
+ const { IPC_REQUEST_TYPES } = require("../../shared/eventContract");
2140
+ if (props.daemonConnection && typeof props.daemonConnection.send === "function") {
2141
+ props.daemonConnection.send({ type: IPC_REQUEST_TYPES.CLOSE_AGENT, agent_id: agentId });
2142
+ }
2143
+ } catch { /* ignore */ }
2144
+ if (agentId === state.viewingAgentId) {
2145
+ exitInternalAgentView();
2146
+ } else {
2147
+ setInternalAgentView((prev) => ({
2148
+ ...prev,
2149
+ barIndex: Math.min(Number(prev.barIndex) || 0, Math.max(0, displayAgents.length - 1)),
2150
+ }));
2151
+ }
2152
+ return true;
2153
+ }
2154
+ return true;
2155
+ };
2156
+
2157
+ const handleInternalAgentViewKey = (input, key = {}) => {
2158
+ if (!state.viewingAgentId) return false;
2159
+ const keyName = resolveInternalKeyName(input, key);
2160
+
2161
+ if (state.focusMode === "dashboard") {
2162
+ return handleInternalAgentDashboardKey(input, key);
2163
+ }
2164
+
2165
+ if (keyName === "escape") {
2166
+ exitInternalAgentView();
2167
+ return true;
2168
+ }
2169
+ if (keyName === "down") {
2170
+ setInternalAgentView((prev) => ({ ...prev, barIndex: 0 }));
2171
+ dispatch({ type: "focus/set", mode: "dashboard" });
2172
+ return true;
2173
+ }
2174
+ if (keyName === "return" || keyName === "enter") {
2175
+ submitInternalAgentInput();
2176
+ return true;
2177
+ }
2178
+ if (key && key.ctrl && keyName === "u") {
2179
+ setInternalAgentView((prev) => ({ ...prev, input: "", cursor: 0 }));
2180
+ return true;
2181
+ }
2182
+ if (key && key.ctrl && keyName === "a") {
2183
+ setInternalAgentView((prev) => ({ ...prev, cursor: 0 }));
2184
+ return true;
2185
+ }
2186
+ if (key && key.ctrl && keyName === "e") {
2187
+ setInternalAgentView((prev) => ({ ...prev, cursor: String(prev.input || "").length }));
2188
+ return true;
2189
+ }
2190
+ if (keyName === "left") {
2191
+ setInternalAgentView((prev) => ({
2192
+ ...prev,
2193
+ cursor: previousInternalBoundary(prev.input, prev.cursor),
2194
+ }));
2195
+ return true;
2196
+ }
2197
+ if (keyName === "right") {
2198
+ setInternalAgentView((prev) => ({
2199
+ ...prev,
2200
+ cursor: nextInternalBoundary(prev.input, prev.cursor),
2201
+ }));
2202
+ return true;
2203
+ }
2204
+ if (keyName === "backspace") {
2205
+ setInternalAgentView((prev) => {
2206
+ const cursor = Number.isFinite(prev.cursor) ? prev.cursor : String(prev.input || "").length;
2207
+ if (cursor <= 0) return prev;
2208
+ const previous = previousInternalBoundary(prev.input, cursor);
2209
+ return {
2210
+ ...prev,
2211
+ input: String(prev.input || "").slice(0, previous) + String(prev.input || "").slice(cursor),
2212
+ cursor: previous,
2213
+ };
2214
+ });
2215
+ return true;
2216
+ }
2217
+ if (keyName === "delete") {
2218
+ setInternalAgentView((prev) => {
2219
+ const text = String(prev.input || "");
2220
+ const cursor = Number.isFinite(prev.cursor) ? prev.cursor : text.length;
2221
+ if (cursor >= text.length) return prev;
2222
+ const next = nextInternalBoundary(text, cursor);
2223
+ return {
2224
+ ...prev,
2225
+ input: text.slice(0, cursor) + text.slice(next),
2226
+ cursor,
2227
+ };
2228
+ });
2229
+ return true;
2230
+ }
2231
+ if (input
2232
+ && !(key && key.ctrl)
2233
+ && !(key && key.meta)
2234
+ && !/^[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]+$/.test(input)) {
2235
+ const clean = String(input).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2236
+ setInternalAgentView((prev) => {
2237
+ const text = String(prev.input || "");
2238
+ const cursor = Number.isFinite(prev.cursor) ? prev.cursor : text.length;
2239
+ return {
2240
+ ...prev,
2241
+ input: text.slice(0, cursor) + clean + text.slice(cursor),
2242
+ cursor: cursor + clean.length,
2243
+ };
2244
+ });
2245
+ return true;
2246
+ }
2247
+ return true;
2248
+ };
2249
+
2250
+ useInput((input, key) => {
2251
+ if (key.ctrl && input === "c") { exit(); return; }
2252
+ if (key.ctrl && input === "o") { dispatch({ type: "merge/expand" }); return; }
2253
+ if (state.viewingAgentId) {
2254
+ handleInternalAgentViewKey(input, key);
2255
+ return;
2256
+ }
2257
+
2258
+ // Completion popup steals arrow/Enter/Esc/Tab while it's open. The
2259
+ // user types to filter, picks with the cursor and accepts with Tab
2260
+ // or Enter; Esc dismisses by clearing the trigger character.
2261
+ if (completionsOpen) {
2262
+ if (key.upArrow) {
2263
+ setCompletionIndex((i) => {
2264
+ const next = (i - 1 + completions.length) % completions.length;
2265
+ setCompletionWindowStart((ws) => {
2266
+ if (next < ws) return next;
2267
+ if (next === completions.length - 1) {
2268
+ // wrapped to the bottom — snap window to the tail.
2269
+ return Math.max(0, completions.length - POPUP_PAGE_SIZE);
2270
+ }
2271
+ return ws;
2272
+ });
2273
+ return next;
2274
+ });
2275
+ return;
2276
+ }
2277
+ if (key.downArrow) {
2278
+ setCompletionIndex((i) => {
2279
+ const next = (i + 1) % completions.length;
2280
+ setCompletionWindowStart((ws) => {
2281
+ if (next === 0) return 0; // wrapped to the head
2282
+ if (next >= ws + POPUP_PAGE_SIZE) return next - POPUP_PAGE_SIZE + 1;
2283
+ return ws;
2284
+ });
2285
+ return next;
2286
+ });
2287
+ return;
2288
+ }
2289
+ if (key.return || key.tab) { acceptCompletion(); return; }
2290
+ if (key.escape) {
2291
+ setCompletionSuppressedDraft(null);
2292
+ dispatch({ type: "draft/clear" });
2293
+ return;
2294
+ }
2295
+ }
2296
+
2297
+ if (key.tab) {
2298
+ if (state.focusMode === "dashboard") {
2299
+ dispatch({ type: "focus/set", mode: "input" });
2300
+ return;
2301
+ }
2302
+ dispatch({ type: "focus/set", mode: "dashboard" });
2303
+ dispatch({ type: "view/set", view: props.globalMode ? "projects" : "agents" });
2304
+ if (props.globalMode && state.projects.length > 0 && state.selectedProjectIndex < 0) {
2305
+ dispatch({ type: "view/set", view: "projects" });
2306
+ dispatch({ type: "projects/select", index: 0, projectRoot: resolveProjectRowRoot(state.projects[0]) });
2307
+ } else if (!props.globalMode && state.agents.length > 0 && state.selectedAgentIndex < 0) {
2308
+ dispatch({ type: "agents/select", index: 0 });
2309
+ } else if (props.globalMode && state.projects.length === 0) {
2310
+ dispatch({ type: "view/set", view: "agents" });
2311
+ if (displayAgents.length > 0 && state.selectedAgentIndex < 0) {
2312
+ dispatch({ type: "agents/select", index: 0 });
2313
+ }
2314
+ }
2315
+ return;
2316
+ }
2317
+ // Dashboard focus + agents view + agent selected + Enter: hand off
2318
+ // to the agent view. Queue-only internal agents stay inside Ink,
2319
+ // matching blessed's useBus view; PTY/socket agents still hand off
2320
+ // to the raw mirror via the runChatInk loop.
2321
+ if (key.return && state.focusMode === "dashboard"
2322
+ && state.dashboardView === "agents"
2323
+ && state.agentSelectionMode
2324
+ && state.selectedAgentIndex >= 0) {
2325
+ const agentId = displayAgents[state.selectedAgentIndex];
2326
+ if (agentId && typeof props.requestEnterAgentView === "function") {
2327
+ const enterPayload = buildAgentEnterPayload(agentId);
2328
+ if (enterPayload.useBus) {
2329
+ enterInternalAgentView(enterPayload);
2330
+ return;
2331
+ }
2332
+ props.requestEnterAgentView(agentId, enterPayload);
2333
+ exit();
2334
+ }
2335
+ return;
2336
+ }
2337
+ // Dashboard focus + projects view: ←/→ moves the highlighted
2338
+ // project, Enter switches the daemon connection to that project,
2339
+ // Ctrl+X stops it.
2340
+ if (state.focusMode === "dashboard" && state.dashboardView === "projects" && state.projects.length === 0) {
2341
+ if (key.downArrow) {
2342
+ dispatch({ type: "view/set", view: "agents" });
2343
+ if (displayAgents.length > 0 && state.selectedAgentIndex < 0) {
2344
+ dispatch({ type: "agents/select", index: 0 });
2345
+ }
2346
+ return;
2347
+ }
2348
+ if (key.upArrow || key.return || key.escape) {
2349
+ dispatch({ type: "focus/set", mode: "input" });
2350
+ }
2351
+ return;
2352
+ }
2353
+ if (state.focusMode === "dashboard" && state.dashboardView === "projects" && state.projects.length > 0) {
2354
+ if (key.leftArrow || key.rightArrow) {
2355
+ const cur = Number.isFinite(state.selectedProjectIndex) && state.selectedProjectIndex >= 0
2356
+ ? state.selectedProjectIndex : 0;
2357
+ const next = key.leftArrow
2358
+ ? Math.max(0, cur - 1)
2359
+ : Math.min(state.projects.length - 1, cur + 1);
2360
+ if (next === cur) return;
2361
+ dispatch({ type: "projects/select", index: next, projectRoot: resolveProjectRowRoot(state.projects[next]) });
2362
+ // Slide the visible window to keep the cursor on screen. We mirror
2363
+ // clampAgentWindowWithSelection's logic with maxProjectWindow=5.
2364
+ const max = Math.max(1, Math.min(5, state.projects.length));
2365
+ let nextStart = state.projectListWindowStart || 0;
2366
+ if (next < nextStart) nextStart = next;
2367
+ else if (next >= nextStart + max) nextStart = next - max + 1;
2368
+ if (nextStart !== state.projectListWindowStart) {
2369
+ dispatch({ type: "projects/window", windowStart: nextStart });
2370
+ }
2371
+
2372
+ const proj = state.projects[next];
2373
+ const target = resolveProjectRowRoot(proj);
2374
+ if (target && state.globalScope === "project") {
2375
+ void switchToProjectRoot(target);
2376
+ }
2377
+ return;
2378
+ }
2379
+ if (key.return) {
2380
+ const cur = state.selectedProjectIndex >= 0 ? state.selectedProjectIndex : 0;
2381
+ const proj = state.projects[cur];
2382
+ const target = resolveProjectRowRoot(proj);
2383
+ void switchToProjectRoot(target, { focusInput: true });
2384
+ return;
2385
+ }
2386
+ if (input
2387
+ && !(key && key.ctrl)
2388
+ && !(key && key.meta)
2389
+ && !/^[\x00-\x1f\x7f]+$/.test(input)
2390
+ && !input.includes("\n")
2391
+ && !input.includes("\r")) {
2392
+ const cur = state.selectedProjectIndex >= 0 ? state.selectedProjectIndex : 0;
2393
+ const target = resolveProjectRowRoot(state.projects[cur]);
2394
+ void switchToProjectRoot(target, { focusInput: true });
2395
+ dispatch({ type: "draft/set", value: `${state.draft || ""}${input}` });
2396
+ setDraftVersion((v) => v + 1);
2397
+ return;
2398
+ }
2399
+ if (key.ctrl && input === "x") {
2400
+ void closeSelectedProject();
2401
+ return;
2402
+ }
2403
+ if (key.upArrow) {
2404
+ // Up out of projects → toggle back to input.
2405
+ dispatch({ type: "projects/clearSelection" });
2406
+ dispatch({ type: "focus/set", mode: "input" });
2407
+ return;
2408
+ }
2409
+ if (key.escape) {
2410
+ dispatch({ type: "projects/clearSelection" });
2411
+ if (state.globalScope === "project") {
2412
+ void switchToControllerRoot();
2413
+ return;
2414
+ }
2415
+ dispatch({ type: "focus/set", mode: "input" });
2416
+ return;
2417
+ }
2418
+ if (key.downArrow) {
2419
+ // Down from projects → agents row stays in dashboard focus.
2420
+ dispatch({ type: "view/set", view: "agents" });
2421
+ if (displayAgents.length > 0 && state.selectedAgentIndex < 0) {
2422
+ dispatch({ type: "agents/select", index: 0 });
2423
+ }
2424
+ return;
2425
+ }
2426
+ }
2427
+
2428
+ if (state.focusMode === "dashboard"
2429
+ && state.dashboardView === "agents"
2430
+ && input
2431
+ && !(key && key.ctrl)
2432
+ && !(key && key.meta)
2433
+ && !/^[\x00-\x1f\x7f]+$/.test(input)
2434
+ && !input.includes("\n")
2435
+ && !input.includes("\r")) {
2436
+ if (displayAgents.length > 0 && state.selectedAgentIndex < 0) {
2437
+ dispatch({ type: "agents/select", index: 0 });
2438
+ }
2439
+ dispatch({ type: "focus/set", mode: "input" });
2440
+ dispatch({ type: "draft/set", value: `${state.draft || ""}${input}` });
2441
+ setDraftVersion((v) => v + 1);
2442
+ return;
2443
+ }
2444
+
2445
+ // Dashboard focus on agents/mode/provider/cron — ↑↓ flip between
2446
+ // sibling views, ←/→ pick within the active view, Esc returns to
2447
+ // the input. Mirrors the blessed handlers in dashboardKeyController.
2448
+ if (state.focusMode === "dashboard"
2449
+ && (state.dashboardView === "agents"
2450
+ || state.dashboardView === "mode"
2451
+ || state.dashboardView === "provider"
2452
+ || state.dashboardView === "cron")) {
2453
+ if (key.escape) {
2454
+ if (state.dashboardView === "agents") dispatch({ type: "agents/clearTarget" });
2455
+ dispatch({ type: "focus/set", mode: "input" });
2456
+ return;
2457
+ }
2458
+ if (state.dashboardView === "agents") {
2459
+ if (key.leftArrow || key.rightArrow) {
2460
+ if (displayAgents.length > 0) {
2461
+ const cur = state.selectedAgentIndex < 0 ? 0 : state.selectedAgentIndex;
2462
+ const next = key.leftArrow
2463
+ ? Math.max(0, cur - 1)
2464
+ : Math.min(displayAgents.length - 1, cur + 1);
2465
+ dispatch({ type: "agents/select", index: next });
2466
+ }
2467
+ return;
2468
+ }
2469
+ if (key.ctrl && input === "x") {
2470
+ if (state.selectedAgentIndex >= 0 && state.selectedAgentIndex < displayAgents.length) {
2471
+ const agentId = displayAgents[state.selectedAgentIndex];
2472
+ try {
2473
+ const { IPC_REQUEST_TYPES } = require("../../shared/eventContract");
2474
+ if (props.daemonConnection && typeof props.daemonConnection.send === "function") {
2475
+ props.daemonConnection.send({ type: IPC_REQUEST_TYPES.CLOSE_AGENT, agent_id: agentId });
2476
+ }
2477
+ } catch (err) {
2478
+ dispatch({ type: "log/append", text: `Error: ${err && err.message ? err.message : err}` });
2479
+ }
2480
+ dispatch({ type: "agents/clearTarget" });
2481
+ dispatch({ type: "focus/set", mode: "input" });
2482
+ }
2483
+ return;
2484
+ }
2485
+ if (key.return) {
2486
+ dispatch({ type: "focus/set", mode: "input" });
2487
+ return;
2488
+ }
2489
+ if (key.downArrow) {
2490
+ dispatch({ type: "view/set", view: "mode" });
2491
+ const launchModeIndex = state.modeOptions.indexOf(state.settings.launchMode);
2492
+ dispatch({ type: "modeIndex/set", index: launchModeIndex >= 0 ? launchModeIndex : 0 });
2493
+ return;
2494
+ }
2495
+ if (key.upArrow) {
2496
+ // Top of the agents tier: in global mode go back to projects,
2497
+ // otherwise leave dashboard focus altogether.
2498
+ dispatch({ type: "agents/clearTarget" });
2499
+ if (props.globalMode) dispatch({ type: "view/set", view: "projects" });
2500
+ else dispatch({ type: "focus/set", mode: "input" });
2501
+ return;
2502
+ }
2503
+ }
2504
+ if (state.dashboardView === "mode") {
2505
+ if (key.leftArrow || key.rightArrow) {
2506
+ const len = state.modeOptions.length;
2507
+ if (len > 0) {
2508
+ const cur = state.selectedModeIndex;
2509
+ const next = key.leftArrow
2510
+ ? Math.max(0, cur - 1)
2511
+ : Math.min(len - 1, cur + 1);
2512
+ if (next !== cur) dispatch({ type: "modeIndex/set", index: next });
2513
+ }
2514
+ return;
2515
+ }
2516
+ if (key.downArrow) {
2517
+ dispatch({ type: "view/set", view: "provider" });
2518
+ const providerIndex = state.providerOptions.findIndex((opt) => opt.value === state.settings.agentProvider);
2519
+ dispatch({ type: "providerIndex/set", index: providerIndex >= 0 ? providerIndex : 0 });
2520
+ return;
2521
+ }
2522
+ if (key.upArrow) { dispatch({ type: "view/set", view: "agents" }); return; }
2523
+ if (key.return) { applySelectedMode(); return; }
2524
+ }
2525
+ if (state.dashboardView === "provider") {
2526
+ if (key.leftArrow || key.rightArrow) {
2527
+ const len = state.providerOptions.length;
2528
+ if (len > 0) {
2529
+ const cur = state.selectedProviderIndex;
2530
+ const next = key.leftArrow
2531
+ ? Math.max(0, cur - 1)
2532
+ : Math.min(len - 1, cur + 1);
2533
+ if (next !== cur) dispatch({ type: "providerIndex/set", index: next });
2534
+ }
2535
+ return;
2536
+ }
2537
+ if (key.downArrow) {
2538
+ dispatch({ type: "view/set", view: "cron" });
2539
+ dispatch({ type: "cronIndex/set", index: state.cronTasks.length > 0 ? 0 : -1 });
2540
+ return;
2541
+ }
2542
+ if (key.upArrow) { dispatch({ type: "view/set", view: "mode" }); return; }
2543
+ if (key.return) { applySelectedProvider(); return; }
2544
+ }
2545
+ if (state.dashboardView === "cron") {
2546
+ if (key.leftArrow || key.rightArrow) {
2547
+ const len = state.cronTasks.length;
2548
+ if (len > 0) {
2549
+ const cur = state.selectedCronIndex < 0 ? 0 : state.selectedCronIndex;
2550
+ const next = key.leftArrow ? Math.max(0, cur - 1) : Math.min(len - 1, cur + 1);
2551
+ if (next !== cur) dispatch({ type: "cronIndex/set", index: next });
2552
+ }
2553
+ return;
2554
+ }
2555
+ if (key.downArrow) {
2556
+ // Cron is the last tier — don't wrap back to agents.
2557
+ return;
2558
+ }
2559
+ if (key.upArrow) { dispatch({ type: "view/set", view: "provider" }); return; }
2560
+ if (key.ctrl && input === "x") {
2561
+ const maxIndex = state.cronTasks.length - 1;
2562
+ if (maxIndex >= 0 && state.selectedCronIndex >= 0 && state.selectedCronIndex <= maxIndex) {
2563
+ const task = state.cronTasks[state.selectedCronIndex];
2564
+ const id = task && task.id ? String(task.id).trim() : "";
2565
+ if (id) {
2566
+ sendCronStop(id);
2567
+ return;
2568
+ }
2569
+ }
2570
+ dispatch({ type: "focus/set", mode: "input" });
2571
+ return;
2572
+ }
2573
+ if (key.return) { dispatch({ type: "focus/set", mode: "input" }); return; }
2574
+ }
2575
+ }
2576
+ }, { isActive: interactive });
2577
+
2578
+ const statusText = computeStatusText(state.status, spinnerTick);
2579
+ const inputWidth = Math.max(20, (size.cols || 80) - 4);
2580
+ const promptPrefix = (() => {
2581
+ const projectPrefix = inCommittedProjectScope && currentProjectLabel ? `${currentProjectLabel} ` : "";
2582
+ if (targetAgentLabel) return `${projectPrefix}›@${targetAgentLabel} `;
2583
+ return `${projectPrefix}› `;
2584
+ })();
2585
+
2586
+ if (state.viewingAgentId) {
2587
+ const maxWidth = Math.max(20, size.cols || 80);
2588
+ const logRows = Math.max(1, (size.rows || 24) - 5);
2589
+ const visibleRows = buildInternalLogRows(internalAgentView.lines || [], maxWidth, logRows);
2590
+ const status = internalStatusLabel(internalAgentView.status);
2591
+ const internalStatusText = computeInternalStatusText(internalAgentView, spinnerTick);
2592
+ const internalStatusColor = status === "blocked" ? "red" : (status === "ready" ? "gray" : "cyan");
2593
+ const inputText = String(internalAgentView.input || "");
2594
+ const cursor = Math.max(0, Math.min(inputText.length, Number(internalAgentView.cursor) || 0));
2595
+ const beforeCursor = inputText.slice(0, cursor);
2596
+ const cursorChar = inputText.slice(cursor, nextInternalBoundary(inputText, cursor)) || " ";
2597
+ const afterCursor = inputText.slice(cursor + (cursorChar === " " ? 0 : cursorChar.length));
2598
+ const barFocused = state.focusMode === "dashboard";
2599
+ const barIndex = Math.max(
2600
+ 0,
2601
+ Math.min(displayAgents.length, Number(internalAgentView.barIndex) || 0),
2602
+ );
2603
+ const barHint = barFocused ? "│ ←/→ · Enter · ↑ · ^X" : "│ ↓ agents";
2604
+ const barItem = (text, index, options = {}) => {
2605
+ const keyboardSelected = barFocused && barIndex === index;
2606
+ return h(Text, {
2607
+ key: `agent-bar-${index}-${text}`,
2608
+ color: keyboardSelected || options.current === true ? undefined : "cyan",
2609
+ inverse: keyboardSelected,
2610
+ bold: options.current === true,
2611
+ wrap: "truncate",
2612
+ }, text);
2613
+ };
2614
+ const agentBarChildren = displayAgents.length === 0
2615
+ ? [h(Text, { key: "agent-bar-none", color: "cyan", wrap: "truncate" }, "none")]
2616
+ : displayAgents.flatMap((id, idx) => {
2617
+ const meta = displayAgentMeta.get(id);
2618
+ return [
2619
+ idx > 0 ? h(Text, { key: `agent-bar-space-${id}`, color: "gray", wrap: "truncate" }, " ") : null,
2620
+ barItem(getAgentLabelFor(meta, id), idx + 1, {
2621
+ current: isInternalViewingAgent(id, meta, internalAgentView, state.viewingAgentId),
2622
+ }),
2623
+ ];
2624
+ }).filter(Boolean);
2625
+ return h(Box, { flexDirection: "column", width: "100%" },
2626
+ h(Box, { flexDirection: "column", width: "100%" },
2627
+ ...visibleRows.map((row, idx) => {
2628
+ const kind = row && row.kind ? row.kind : "agent";
2629
+ const color = kind === "user"
2630
+ ? "cyan"
2631
+ : (kind === "system" || kind === "meta" || kind === "spacer" ? "gray" : (kind === "error" ? "red" : undefined));
2632
+ return h(Text, {
2633
+ key: `agent-log-${idx}`,
2634
+ color,
2635
+ bold: Boolean(row && row.bold),
2636
+ wrap: "truncate",
2637
+ }, (row && row.text) || " ");
2638
+ }),
2639
+ ),
2640
+ h(Text, { color: internalStatusColor, wrap: "truncate" },
2641
+ fitPlainLine(internalStatusText, maxWidth)),
2642
+ h(Text, { color: "gray", wrap: "truncate" }, "─".repeat(maxWidth)),
2643
+ h(Box, { width: "100%" },
2644
+ h(Text, { color: "magenta" }, "› "),
2645
+ beforeCursor ? h(Text, { wrap: "truncate" }, beforeCursor) : null,
2646
+ h(Text, { inverse: true }, cursorChar),
2647
+ afterCursor ? h(Text, { wrap: "truncate" }, afterCursor) : null,
2648
+ ),
2649
+ h(Text, { color: "gray", wrap: "truncate" }, "─".repeat(maxWidth)),
2650
+ h(Box, { width: "100%" },
2651
+ h(Text, { color: "gray", wrap: "truncate" }, " "),
2652
+ barItem("ufoo", 0),
2653
+ h(Text, { color: "gray", wrap: "truncate" }, " "),
2654
+ ...agentBarChildren,
2655
+ h(Text, { color: "gray", wrap: "truncate" }, ` ${barHint}`),
2656
+ ),
2657
+ );
2658
+ }
2659
+
2660
+ return h(Box, { flexDirection: "column", width: "100%" },
2661
+ h(Box, { flexDirection: "column", width: "100%" },
2662
+ ...state.logLines.map((item) => h(Text, { key: item.id }, item.text || " ")),
2663
+ ),
2664
+ state.activeMerge ? h(Box, null,
2665
+ h(Text, { color: state.activeMerge.entries.some((e) => e.isError) ? "red" : "cyan" },
2666
+ fmt.buildToolMergeRowText(state.activeMerge.entries)),
2667
+ ) : null,
2668
+ state.activeStream ? h(Box, { flexDirection: "column" },
2669
+ ...(() => {
2670
+ const lines = String(state.activeStream.text || "").split(/\r?\n/);
2671
+ const prefix = state.activeStream.publisher
2672
+ ? `${state.activeStream.publisher}: `
2673
+ : "";
2674
+ return lines.map((line, idx) => h(Text, {
2675
+ key: `s-${idx}`,
2676
+ color: "cyan",
2677
+ }, idx === 0 ? `${prefix}${line}` : ` ${line}`));
2678
+ })(),
2679
+ ) : null,
2680
+ h(Box, { marginTop: 1, width: "100%" },
2681
+ h(Text, { color: "gray" }, statusText),
2682
+ h(Box, { flexGrow: 1 }),
2683
+ h(Text, { color: "gray" }, `v${fmt.UCODE_VERSION}`),
2684
+ ),
2685
+ completionsOpen ? (() => {
2686
+ const start = Math.min(completionWindowStart, Math.max(0, completions.length - POPUP_PAGE_SIZE));
2687
+ const end = Math.min(completions.length, start + POPUP_PAGE_SIZE);
2688
+ const visible = completions.slice(start, end);
2689
+ return h(Box, { flexDirection: "column" },
2690
+ h(Text, { color: "gray" }, "─".repeat(Math.max(8, size.cols || 80))),
2691
+ ...visible.map((s, idxInWindow) => {
2692
+ const idx = start + idxInWindow;
2693
+ return h(Box, { key: `cmp-${idx}` },
2694
+ h(Text, { color: idx === completionIndex ? "cyan" : "gray", inverse: idx === completionIndex }, s.label),
2695
+ s.description ? h(Text, { color: "gray" }, ` ${s.description}`) : null,
2696
+ );
2697
+ }),
2698
+ );
2699
+ })() : null,
2700
+ h(Box, { width: "100%" },
2701
+ h(MultilineInput, {
2702
+ value: state.draft,
2703
+ valueVersion: draftVersion,
2704
+ onChange: (next) => {
2705
+ if (completionSuppressedDraft !== null && next !== completionSuppressedDraft) {
2706
+ setCompletionSuppressedDraft(null);
2707
+ }
2708
+ dispatch({ type: "draft/set", value: next });
2709
+ },
2710
+ onSubmit: (value) => {
2711
+ setCompletionSuppressedDraft(null);
2712
+ submit(value);
2713
+ },
2714
+ onCancel: () => {
2715
+ setCompletionSuppressedDraft(null);
2716
+ if (props.globalMode && state.globalScope === "project") {
2717
+ void switchToControllerRoot();
2718
+ return;
2719
+ }
2720
+ // Esc clears the current target if one is locked, otherwise
2721
+ // dismisses the in-flight task status. There's no per-request
2722
+ // AbortController on daemonConnection (the IPC layer is fire-
2723
+ // and-forget), so we clear the spinner so the user knows the
2724
+ // UI is responsive again.
2725
+ if (state.agentSelectionMode) {
2726
+ dispatch({ type: "agents/clearTarget" });
2727
+ return;
2728
+ }
2729
+ if (state.status && state.status.message) {
2730
+ dispatch({ type: "status/idle" });
2731
+ }
2732
+ },
2733
+ onArrowUpAtTop,
2734
+ onArrowDownAtBottom,
2735
+ onArrowLeftAtEmpty: () => onArrowSideAtEmpty("left"),
2736
+ onArrowRightAtEmpty: () => onArrowSideAtEmpty("right"),
2737
+ width: inputWidth,
2738
+ interactive: interactive && state.focusMode !== "dashboard",
2739
+ interceptArrowsAndEnter: completionsOpen,
2740
+ placeholder: "",
2741
+ promptPrefix,
2742
+ }),
2743
+ ),
2744
+ h(DashboardBar, {
2745
+ dashboardView: state.dashboardView,
2746
+ focusMode: state.focusMode,
2747
+ globalMode: state.globalMode,
2748
+ globalScope: state.globalScope,
2749
+ activeAgents: displayAgents,
2750
+ activeAgentMeta: displayAgentMeta,
2751
+ activeAgentId: targetAgentId || "",
2752
+ selectedAgentIndex: state.selectedAgentIndex,
2753
+ agentListWindowStart: state.agentListWindowStart,
2754
+ projectListWindowStart: state.projectListWindowStart,
2755
+ maxProjectWindow: 5,
2756
+ maxWidth: Math.max(20, size.cols || 80),
2757
+ getAgentLabel: (id) => getAgentLabelFor(displayAgentMeta.get(id), id),
2758
+ getAgentState: (id) => {
2759
+ const meta = displayAgentMeta.get(id);
2760
+ return meta && typeof meta.activity_state === "string" ? meta.activity_state : "";
2761
+ },
2762
+ launchMode: state.settings.launchMode,
2763
+ agentProvider: state.settings.agentProvider,
2764
+ modeOptions: state.modeOptions,
2765
+ selectedModeIndex: state.selectedModeIndex,
2766
+ providerOptions: state.providerOptions,
2767
+ selectedProviderIndex: state.selectedProviderIndex,
2768
+ cronTasks: state.cronTasks,
2769
+ selectedCronIndex: state.selectedCronIndex,
2770
+ projects: state.projects,
2771
+ selectedProjectIndex: state.selectedProjectIndex,
2772
+ activeProjectRoot: currentProjectRoot,
2773
+ dashHints: buildDashHints(state, targetAgentLabel),
2774
+ }),
2775
+ );
2776
+ };
2777
+ }
2778
+
2779
+ function buildDashHints(state, targetAgentLabel) {
2780
+ void targetAgentLabel; // navigation hint removed by request
2781
+ return {
2782
+ agents: "←/→ select · Enter · ↓ mode · ↑ back",
2783
+ agentsGlobal: "←/→ select · Enter · ↓ mode · ↑ projects",
2784
+ agentsEmpty: "↓ mode · ↑ back",
2785
+ mode: "←/→ select · Enter · ↓ provider · ↑ back",
2786
+ provider: "←/→ select · Enter · ↓ cron · ↑ back",
2787
+ cron: "←/→ switch · Ctrl+X stop · ↑ back",
2788
+ projects: "Use /open <path> or /project switch <index|path>",
2789
+ projectsFocus: "←/→ switch · Ctrl+X close · ↓ second row · Enter confirm · ↑ back",
2790
+ projectsEmpty: "Run ufoo chat or ufoo daemon start in project directories",
2791
+ };
2792
+ }
2793
+
2794
+ function computeStatusText(status, spinnerTick) {
2795
+ const message = String((status && status.message) || "");
2796
+ if (!message) return "CHAT · Ready";
2797
+ const type = String((status && status.type) || "thinking");
2798
+ const indicators = fmt.STATUS_INDICATORS[type] || fmt.STATUS_INDICATORS.thinking;
2799
+ const indicator = indicators[Math.max(0, Math.floor(Number(spinnerTick) || 0)) % indicators.length];
2800
+ const startedAt = Number.isFinite(status && status.startedAt) ? status.startedAt : 0;
2801
+ const timerText = status && status.showTimer && startedAt
2802
+ ? ` (${fmt.formatPendingElapsed(Date.now() - startedAt)}, esc cancel)`
2803
+ : "";
2804
+ return `${indicator} ${message}${timerText}`;
2805
+ }
2806
+
2807
+ async function runChatInk(projectRoot, options = {}) {
2808
+ const env = bootstrapEnvironment(projectRoot, options);
2809
+
2810
+ if (env.needsBootstrap || !fs.existsSync(env.runtimePaths.ufooDir)) {
2811
+ const repoRoot = path.join(__dirname, "..", "..", "..");
2812
+ const init = new env.UfooInit(repoRoot);
2813
+ await init.init({
2814
+ modules: "context,bus",
2815
+ project: projectRoot,
2816
+ controllerMode: env.globalMode,
2817
+ });
2818
+ }
2819
+
2820
+ await ensureSubscriberId(projectRoot);
2821
+
2822
+ if (!env.isRunning(projectRoot)) {
2823
+ env.startDaemon(projectRoot);
2824
+ }
2825
+
2826
+ const { socketPath } = require("../../daemon");
2827
+ const { connectWithRetry } = require("../../chat/transport");
2828
+ const { createDaemonTransport } = require("../../chat/daemonTransport");
2829
+ const { createDaemonConnection } = require("../../chat/daemonConnection");
2830
+ const { createDaemonCoordinator } = require("../../chat/daemonCoordinator");
2831
+ const { startDaemon, stopDaemon } = require("../../chat/transport");
2832
+ const { loadConfig } = require("../../config");
2833
+ const { startAgentMirror, startInternalAgentMirror } = require("./agentMirror");
2834
+ const sock = socketPath(projectRoot);
2835
+ const daemonTransport = createDaemonTransport({
2836
+ projectRoot,
2837
+ sockPath: sock,
2838
+ isRunning: env.isRunning,
2839
+ startDaemon: env.startDaemon,
2840
+ connectWithRetry,
2841
+ });
2842
+
2843
+ // The connection's `handleMessage` callback is filled in by ChatApp once
2844
+ // it mounts and has its dispatcher ready. We expose a setter so the
2845
+ // component can wire it without ChatApp needing to construct daemon
2846
+ // internals itself.
2847
+ let routedMessageHandler = () => {};
2848
+ const daemonConnection = createDaemonConnection({
2849
+ connectClient: daemonTransport.connectClient.bind(daemonTransport),
2850
+ handleMessage: (msg) => routedMessageHandler(msg),
2851
+ queueStatusLine: () => {},
2852
+ resolveStatusLine: () => {},
2853
+ logMessage: () => {},
2854
+ });
2855
+ const daemonCoordinator = createDaemonCoordinator({
2856
+ projectRoot,
2857
+ daemonTransport,
2858
+ daemonConnection,
2859
+ stopDaemon,
2860
+ startDaemon,
2861
+ logMessage: () => {},
2862
+ queueStatusLine: () => {},
2863
+ resolveStatusLine: () => {},
2864
+ });
2865
+
2866
+ // We loop the ink mount so an "enter agent" request can unmount ink,
2867
+ // hand stdout/stdin to the raw PTY mirror, then bring ink back on exit.
2868
+ let pendingEnter = null;
2869
+ const baseProps = {
2870
+ activeProjectRoot: env.activeProjectRoot,
2871
+ projectRoot,
2872
+ globalMode: env.globalMode,
2873
+ globalScope: env.globalMode ? "controller" : "project",
2874
+ daemonConnection,
2875
+ daemonTransport,
2876
+ daemonCoordinator,
2877
+ env,
2878
+ initialSettings: loadConfig(projectRoot),
2879
+ setDaemonMessageHandler: (fn) => { routedMessageHandler = typeof fn === "function" ? fn : () => {}; },
2880
+ requestEnterAgentView: (agentId, enterOptions = {}) => {
2881
+ pendingEnter = {
2882
+ agentId,
2883
+ options: enterOptions && typeof enterOptions === "object" ? enterOptions : {},
2884
+ };
2885
+ },
2886
+ };
2887
+
2888
+ // eslint-disable-next-line no-constant-condition
2889
+ while (true) {
2890
+ pendingEnter = null;
2891
+ const handle = await runInk(
2892
+ (React, ink) => {
2893
+ const ChatApp = createChatApp({ React, ink, props: baseProps });
2894
+ return React.createElement(ChatApp);
2895
+ },
2896
+ { stdin: process.stdin, stdout: process.stdout, exitOnCtrlC: true }
2897
+ );
2898
+
2899
+ // Wait until either the user exits the app or ChatApp asks to enter
2900
+ // an agent view. The component triggers the latter by setting
2901
+ // pendingEnter and then calling handle.unmount() via its onExit.
2902
+ await handle.waitUntilExit();
2903
+ if (!pendingEnter) return;
2904
+
2905
+ // Hand stdout/stdin to the mirror. When it exits, loop and re-mount.
2906
+ const enterRequest = pendingEnter;
2907
+ pendingEnter = null;
2908
+ const enteredAgentId = enterRequest && enterRequest.agentId;
2909
+ const enterOptions = enterRequest && enterRequest.options ? enterRequest.options : {};
2910
+ const enteredProjectRoot = enterOptions.projectRoot || projectRoot;
2911
+ await new Promise((resolve) => {
2912
+ if (enterOptions.useBus) {
2913
+ startInternalAgentMirror({
2914
+ agentId: enteredAgentId,
2915
+ agentLabel: enterOptions.agentLabel,
2916
+ agentAliases: enterOptions.agentAliases,
2917
+ projectRoot: enteredProjectRoot,
2918
+ daemonConnection,
2919
+ setDaemonMessageHandler: (fn) => {
2920
+ routedMessageHandler = typeof fn === "function" ? fn : () => {};
2921
+ },
2922
+ onExit: resolve,
2923
+ });
2924
+ return;
2925
+ }
2926
+ startAgentMirror({
2927
+ agentId: enteredAgentId,
2928
+ projectRoot: enteredProjectRoot,
2929
+ onExit: resolve,
2930
+ });
2931
+ });
2932
+ }
2933
+ }
2934
+
2935
+ module.exports = {
2936
+ runChatInk,
2937
+ createChatApp,
2938
+ bootstrapEnvironment,
2939
+ buildDirectBusSendRequest,
2940
+ buildPromptIpcRequest,
2941
+ chatHistoryOptionsForScope,
2942
+ resolveActiveAgentId,
2943
+ resolveAgentEnterRequest,
2944
+ buildInternalLogRows,
2945
+ computeInternalStatusText,
2946
+ resolveInternalKeyName,
2947
+ isInternalViewingAgent,
2948
+ applyInternalAgentTermWrite,
2949
+ appendInternalErrorToView,
2950
+ };