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,997 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Pure formatting + input-math helpers shared between the legacy blessed TUI
5
+ * (src/code/tui.js, src/chat/index.js) and the new ink-based TUIs under
6
+ * src/ui/components/. No blessed import allowed in this module.
7
+ *
8
+ * Anything that touches a blessed widget (escapeBlessedLiteral, the blessed
9
+ * banner builder, resolveLogContentWidth) stays in src/code/tui.js.
10
+ */
11
+
12
+ const chalk = require("chalk");
13
+ const pkg = require("../../../package.json");
14
+
15
+ const UCODE_BANNER_LINES = [
16
+ "█ █ █▀▀ █▀█ █▀▄ █▀▀",
17
+ "█ █ █ █ █ █ █ █▀ ",
18
+ "▀▀▀ ▀▀▀ ▀▀▀ ▀▀ ▀▀▀",
19
+ ];
20
+
21
+ const UCODE_VERSION = String((pkg && pkg.version) || "dev");
22
+
23
+ const STATUS_INDICATORS = {
24
+ thinking: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
25
+ typing: ["◐", "◓", "◑", "◒"],
26
+ waiting: ["∙", "∙∙", "∙∙∙", "∙∙", "∙"],
27
+ };
28
+
29
+ // Friendly labels for the tool-call events surfaced in the status line.
30
+ // Keep this list in sync with the keys handled by buildMergedToolSummaryText.
31
+ const TOOL_LABELS = {
32
+ read: "Reading file",
33
+ write: "Writing file",
34
+ edit: "Editing file",
35
+ bash: "Running command",
36
+ };
37
+
38
+ const ANSI_PATTERN = /\x1B\[[0-9;?]*[ -/]*[@-~]/g;
39
+
40
+ function charDisplayWidth(char = "") {
41
+ if (!char) return 0;
42
+ const code = char.codePointAt(0) || 0;
43
+ if (code === 0) return 0;
44
+ if (code < 32 || (code >= 0x7f && code < 0xa0)) return 0;
45
+ if ((code >= 0x0300 && code <= 0x036f) ||
46
+ (code >= 0x1ab0 && code <= 0x1aff) ||
47
+ (code >= 0x1dc0 && code <= 0x1dff) ||
48
+ (code >= 0x20d0 && code <= 0x20ff) ||
49
+ (code >= 0xfe20 && code <= 0xfe2f)) {
50
+ return 0;
51
+ }
52
+ if ((code >= 0x1100 && code <= 0x115f) ||
53
+ code === 0x2329 ||
54
+ code === 0x232a ||
55
+ (code >= 0x2e80 && code <= 0xa4cf) ||
56
+ (code >= 0xac00 && code <= 0xd7a3) ||
57
+ (code >= 0xf900 && code <= 0xfaff) ||
58
+ (code >= 0xfe10 && code <= 0xfe19) ||
59
+ (code >= 0xfe30 && code <= 0xfe6f) ||
60
+ (code >= 0xff00 && code <= 0xff60) ||
61
+ (code >= 0xffe0 && code <= 0xffe6) ||
62
+ (code >= 0x1f300 && code <= 0x1faff)) {
63
+ return 2;
64
+ }
65
+ return 1;
66
+ }
67
+
68
+ function displayCellWidth(text = "") {
69
+ return Array.from(String(text || "").replace(ANSI_PATTERN, "")).reduce(
70
+ (sum, char) => sum + charDisplayWidth(char),
71
+ 0
72
+ );
73
+ }
74
+
75
+ // NOTE: returns a blessed-flavoured tag string ("{cyan-bg}{white-fg}...").
76
+ // Used by the legacy blessed TUI; ink callers should not render this directly
77
+ // (the tags would show up as literal text). When P1 needs the same output for
78
+ // ink, add a sibling helper that emits chalk/ANSI instead.
79
+ function formatHighlightedUserInput(text = "", {
80
+ width = 80,
81
+ escapeText = (value) => String(value || ""),
82
+ } = {}) {
83
+ const plain = String(text || "").trim();
84
+ if (!plain) return "";
85
+ const targetWidth = Math.max(1, Math.floor(Number(width) || 80) - 1);
86
+ const prefix = " → ";
87
+ const suffix = " ";
88
+ const contentWidth = displayCellWidth(`${prefix}${plain}${suffix}`);
89
+ const pad = " ".repeat(Math.max(0, targetWidth - contentWidth));
90
+ return `{cyan-bg}{white-fg}${prefix}${escapeText(plain)}${suffix}${pad}{/white-fg}{/cyan-bg}`;
91
+ }
92
+
93
+ class StreamBuffer {
94
+ constructor(writer, options = {}) {
95
+ this.writer = writer;
96
+ this.buffer = "";
97
+ this.delay = options.delay || 8;
98
+ this.chunkSize = options.chunkSize || 3;
99
+ this.isStreaming = false;
100
+ this.streamPromise = null;
101
+ }
102
+
103
+ async write(text) {
104
+ this.buffer += text;
105
+ if (!this.isStreaming) {
106
+ this.isStreaming = true;
107
+ this.streamPromise = this.flush();
108
+ }
109
+ return this.streamPromise;
110
+ }
111
+
112
+ async flush() {
113
+ while (this.buffer.length > 0) {
114
+ const chunk = this.buffer.slice(0, this.chunkSize);
115
+ this.buffer = this.buffer.slice(this.chunkSize);
116
+ this.writer(chunk);
117
+ if (this.buffer.length > 0) {
118
+ await new Promise((resolve) => setTimeout(resolve, this.delay));
119
+ }
120
+ }
121
+ this.isStreaming = false;
122
+ }
123
+
124
+ async finish() {
125
+ if (this.isStreaming) {
126
+ await this.streamPromise;
127
+ }
128
+ if (this.buffer.length > 0) {
129
+ this.writer(this.buffer);
130
+ this.buffer = "";
131
+ }
132
+ }
133
+ }
134
+
135
+ function normalizeModelLabel(model = "") {
136
+ const text = String(model || "").trim();
137
+ if (text) return text;
138
+ return "default";
139
+ }
140
+
141
+ function buildUcodeBannerLines({ model = "", engine = "ufoo-core", nickname = "", agentId = "", workspaceRoot = "", sessionId = "", width = 0 } = {}) {
142
+ const modelLabel = normalizeModelLabel(model);
143
+ void width;
144
+ void engine;
145
+ void nickname;
146
+ void agentId;
147
+
148
+ const path = require("path");
149
+ const os = require("os");
150
+ const currentDir = workspaceRoot || process.cwd();
151
+ const homeDir = os.homedir();
152
+
153
+ let shortPath = currentDir;
154
+ if (currentDir.startsWith(homeDir)) {
155
+ shortPath = currentDir.replace(homeDir, "~");
156
+ }
157
+ shortPath = path.normalize(shortPath);
158
+
159
+ const logoLines = UCODE_BANNER_LINES.map((line) => chalk.cyan(line));
160
+ const infoLines = [];
161
+ infoLines.push(`${chalk.dim("Version:")} ${chalk.cyan.bold(UCODE_VERSION)}`);
162
+ infoLines.push(`${chalk.dim("Model:")} ${chalk.yellow(modelLabel)}`);
163
+ infoLines.push(`${chalk.dim("Dictionary:")} ${chalk.gray(shortPath)}`);
164
+ const normalizedSessionId = String(sessionId || "").trim();
165
+ if (normalizedSessionId) {
166
+ infoLines.push(`${chalk.dim("Session:")} ${chalk.gray(normalizedSessionId)}`);
167
+ }
168
+ const logoPadding = " ".repeat(
169
+ UCODE_BANNER_LINES.reduce((max, line) => Math.max(max, String(line || "").length), 0)
170
+ );
171
+ const rows = Math.max(logoLines.length, infoLines.length);
172
+
173
+ return Array.from({ length: rows }, (_, index) => {
174
+ const logoLine = logoLines[index] || logoPadding;
175
+ const info = infoLines[index] || "";
176
+ return ` ${logoLine} ${info}`;
177
+ });
178
+ }
179
+
180
+ function shouldUseUcodeTui({ stdin, stdout, jsonOutput, forceTui = false, disableTui = false } = {}) {
181
+ if (disableTui) return false;
182
+ if (jsonOutput) return false;
183
+ if (forceTui) return true;
184
+ return Boolean(stdin && stdin.isTTY && stdout && stdout.isTTY);
185
+ }
186
+
187
+ function parseActiveAgentsFromBusStatus(busStatus = "") {
188
+ const lines = String(busStatus || "").replace(ANSI_PATTERN, "").split(/\r?\n/);
189
+ const agents = [];
190
+ let inOnlineSection = false;
191
+
192
+ for (const line of lines) {
193
+ const trimmed = String(line || "").trim();
194
+ if (!trimmed) continue;
195
+
196
+ if (/^Online agents:\s*$/i.test(trimmed)) {
197
+ inOnlineSection = true;
198
+ continue;
199
+ }
200
+ if (!inOnlineSection) continue;
201
+
202
+ if (/^\(none\)$/i.test(trimmed)) {
203
+ continue;
204
+ }
205
+
206
+ if (/^[A-Za-z][A-Za-z ]+:\s*$/.test(trimmed)) {
207
+ break;
208
+ }
209
+
210
+ const rawId = trimmed.replace(/\s+\([^)]+\)\s*$/, "");
211
+ if (!rawId) continue;
212
+ const [type, ...idParts] = rawId.split(":");
213
+ const id = idParts.join(":");
214
+ if (!type) continue;
215
+
216
+ agents.push({
217
+ type,
218
+ id,
219
+ status: "active",
220
+ fullId: rawId,
221
+ nickname: (trimmed.match(/\(([^)]+)\)\s*$/) || [])[1] || "",
222
+ });
223
+ }
224
+
225
+ if (agents.length === 0) {
226
+ for (const line of lines) {
227
+ const trimmed = String(line || "").trim();
228
+ const match = trimmed.match(/^([a-z-]+):([a-f0-9]+)\s+\((active|idle)\)$/);
229
+ if (!match) continue;
230
+ agents.push({
231
+ type: match[1],
232
+ id: match[2],
233
+ status: match[3],
234
+ fullId: `${match[1]}:${match[2]}`,
235
+ nickname: "",
236
+ });
237
+ }
238
+ }
239
+
240
+ return agents;
241
+ }
242
+
243
+ function loadActiveAgents(workspaceRoot) {
244
+ try {
245
+ const { execSync } = require("child_process");
246
+ const busStatus = execSync("ufoo bus status", {
247
+ cwd: workspaceRoot,
248
+ encoding: "utf8",
249
+ });
250
+ return parseActiveAgentsFromBusStatus(busStatus);
251
+ } catch {
252
+ return [];
253
+ }
254
+ }
255
+
256
+ function renderLogLinesWithMarkdown(text = "", state = {}, escapeFn = (value) => String(value || "")) {
257
+ const { renderMarkdownLines } = require("../../shared/markdownRenderer");
258
+ return renderMarkdownLines(text, state, escapeFn);
259
+ }
260
+
261
+ function shouldEnterAgentSelection(inputValue = "") {
262
+ const text = String(inputValue || "");
263
+ const trimmed = text.trim();
264
+ return !trimmed;
265
+ }
266
+
267
+ function resolveAgentSelectionOnDown({
268
+ agentSelectionMode = false,
269
+ selectedAgentIndex = -1,
270
+ totalAgents = 0,
271
+ } = {}) {
272
+ const total = Number.isFinite(totalAgents) ? Math.max(0, Math.floor(totalAgents)) : 0;
273
+ if (total <= 0) return { action: "none", index: -1 };
274
+ if (agentSelectionMode) {
275
+ const keep = selectedAgentIndex >= 0 && selectedAgentIndex < total ? selectedAgentIndex : 0;
276
+ return { action: "hold", index: keep };
277
+ }
278
+ const enter = selectedAgentIndex >= 0 && selectedAgentIndex < total ? selectedAgentIndex : 0;
279
+ return { action: "enter", index: enter };
280
+ }
281
+
282
+ function cycleAgentSelectionIndex(selectedAgentIndex = -1, totalAgents = 0, direction = "right") {
283
+ const total = Number.isFinite(totalAgents) ? Math.max(0, Math.floor(totalAgents)) : 0;
284
+ if (total <= 0) return -1;
285
+ const current = selectedAgentIndex >= 0 && selectedAgentIndex < total ? selectedAgentIndex : 0;
286
+ if (direction === "left") {
287
+ return (current - 1 + total) % total;
288
+ }
289
+ return (current + 1) % total;
290
+ }
291
+
292
+ function shouldClearAgentSelectionOnUp({
293
+ agentSelectionMode = false,
294
+ inputValue = "",
295
+ } = {}) {
296
+ return Boolean(agentSelectionMode && shouldEnterAgentSelection(inputValue));
297
+ }
298
+
299
+ function moveCursorHorizontally(cursorPos = 0, inputValue = "", direction = "right") {
300
+ const text = String(inputValue || "");
301
+ const max = text.length;
302
+ const pos = Number.isFinite(cursorPos) ? Math.max(0, Math.floor(cursorPos)) : 0;
303
+ if (direction === "left") return Math.max(0, pos - 1);
304
+ return Math.min(max, pos + 1);
305
+ }
306
+
307
+ function clampCursorPos(cursorPos = 0, inputValue = "") {
308
+ const text = String(inputValue || "");
309
+ const pos = Number.isFinite(cursorPos) ? Math.floor(cursorPos) : 0;
310
+ return Math.max(0, Math.min(text.length, pos));
311
+ }
312
+
313
+ function findLogicalLineStart(inputValue = "", cursorPos = 0) {
314
+ const text = String(inputValue || "");
315
+ const pos = clampCursorPos(cursorPos, text);
316
+ const prevNewline = text.lastIndexOf("\n", Math.max(0, pos - 1));
317
+ return prevNewline === -1 ? 0 : prevNewline + 1;
318
+ }
319
+
320
+ function findLogicalLineEnd(inputValue = "", cursorPos = 0) {
321
+ const text = String(inputValue || "");
322
+ const pos = clampCursorPos(cursorPos, text);
323
+ const nextNewline = text.indexOf("\n", pos);
324
+ return nextNewline === -1 ? text.length : nextNewline;
325
+ }
326
+
327
+ function moveCursorToVisualLineBoundary({
328
+ cursorPos = 0,
329
+ inputValue = "",
330
+ width = 80,
331
+ boundary = "start",
332
+ strWidth,
333
+ } = {}) {
334
+ const inputMath = require("../../chat/inputMath");
335
+ const text = String(inputValue || "");
336
+ const normalizedWidth = Number.isFinite(width) ? Math.max(1, Math.floor(width)) : 1;
337
+ const pos = clampCursorPos(cursorPos, text);
338
+ const { row } = inputMath.getCursorRowCol(text, pos, normalizedWidth, strWidth);
339
+ if (boundary === "end") {
340
+ return inputMath.getCursorPosForRowCol(text, row, normalizedWidth, normalizedWidth, strWidth);
341
+ }
342
+ return inputMath.getCursorPosForRowCol(text, row, 0, normalizedWidth, strWidth);
343
+ }
344
+
345
+ function moveCursorVertically({
346
+ cursorPos = 0,
347
+ inputValue = "",
348
+ width = 80,
349
+ direction = "down",
350
+ preferredCol = null,
351
+ strWidth,
352
+ } = {}) {
353
+ const inputMath = require("../../chat/inputMath");
354
+ const text = String(inputValue || "");
355
+ const normalizedWidth = Number.isFinite(width) ? Math.max(1, Math.floor(width)) : 1;
356
+ const pos = clampCursorPos(cursorPos, text);
357
+ const { row, col } = inputMath.getCursorRowCol(text, pos, normalizedWidth, strWidth);
358
+ const totalRows = inputMath.countLines(text, normalizedWidth, strWidth);
359
+ const targetCol = Number.isFinite(preferredCol) ? preferredCol : col;
360
+
361
+ if (direction === "up") {
362
+ if (row <= 0) {
363
+ return { moved: false, nextCursorPos: pos, preferredCol: targetCol, boundary: "top" };
364
+ }
365
+ return {
366
+ moved: true,
367
+ nextCursorPos: inputMath.getCursorPosForRowCol(text, row - 1, targetCol, normalizedWidth, strWidth),
368
+ preferredCol: targetCol,
369
+ boundary: "",
370
+ };
371
+ }
372
+
373
+ if (row >= totalRows - 1) {
374
+ return { moved: false, nextCursorPos: pos, preferredCol: targetCol, boundary: "bottom" };
375
+ }
376
+ return {
377
+ moved: true,
378
+ nextCursorPos: inputMath.getCursorPosForRowCol(text, row + 1, targetCol, normalizedWidth, strWidth),
379
+ preferredCol: targetCol,
380
+ boundary: "",
381
+ };
382
+ }
383
+
384
+ function deleteWordBeforeCursor(inputValue = "", cursorPos = 0) {
385
+ const text = String(inputValue || "");
386
+ const pos = clampCursorPos(cursorPos, text);
387
+ if (pos <= 0) return { value: text, cursorPos: pos };
388
+ const before = text.slice(0, pos);
389
+ const after = text.slice(pos);
390
+ const match = before.match(/\s*\S+\s*$/);
391
+ const start = match ? pos - match[0].length : Math.max(0, pos - 1);
392
+ return {
393
+ value: before.slice(0, start) + after,
394
+ cursorPos: start,
395
+ };
396
+ }
397
+
398
+ function moveCursorByWord(inputValue = "", cursorPos = 0, direction = "forward") {
399
+ const text = String(inputValue || "");
400
+ const pos = clampCursorPos(cursorPos, text);
401
+ if (direction === "backward") {
402
+ const before = text.slice(0, pos);
403
+ const trimmedEnd = before.search(/\S\s*$/) >= 0 ? before.replace(/\s+$/, "") : before;
404
+ const match = trimmedEnd.match(/\S+$/);
405
+ return match ? trimmedEnd.length - match[0].length : 0;
406
+ }
407
+ const after = text.slice(pos);
408
+ const match = after.match(/^\s*\S+/);
409
+ return match ? Math.min(text.length, pos + match[0].length) : text.length;
410
+ }
411
+
412
+ function resolveHistoryDownTransition({
413
+ inputHistory = [],
414
+ historyIndex = 0,
415
+ currentValue = "",
416
+ } = {}) {
417
+ const history = Array.isArray(inputHistory) ? inputHistory : [];
418
+ if (history.length <= 0) {
419
+ return {
420
+ moved: false,
421
+ nextHistoryIndex: Number.isFinite(historyIndex) ? Math.max(0, Math.floor(historyIndex)) : 0,
422
+ nextValue: String(currentValue || ""),
423
+ };
424
+ }
425
+ const currentIndex = Number.isFinite(historyIndex) ? Math.max(0, Math.floor(historyIndex)) : 0;
426
+ if (currentIndex >= history.length) {
427
+ return {
428
+ moved: false,
429
+ nextHistoryIndex: history.length,
430
+ nextValue: String(currentValue || ""),
431
+ };
432
+ }
433
+ const nextHistoryIndex = Math.min(history.length, currentIndex + 1);
434
+ const nextValue = nextHistoryIndex >= history.length ? "" : String(history[nextHistoryIndex] || "");
435
+ const moved = nextHistoryIndex !== currentIndex || nextValue !== String(currentValue || "");
436
+ return {
437
+ moved,
438
+ nextHistoryIndex,
439
+ nextValue,
440
+ };
441
+ }
442
+
443
+ function filterSelectableAgents(agents = [], selfSubscriberId = "") {
444
+ const selfId = String(selfSubscriberId || "").trim();
445
+ const list = Array.isArray(agents) ? agents : [];
446
+ if (!selfId) {
447
+ return list.filter((agent) => {
448
+ const fullId = String(agent && agent.fullId ? agent.fullId : "").trim();
449
+ const type = String(agent && agent.type ? agent.type : "").trim();
450
+ if (fullId === "ufoo-agent") return false;
451
+ if (type === "ufoo-agent") return false;
452
+ return true;
453
+ });
454
+ }
455
+ return list.filter((agent) => {
456
+ const fullId = String(agent && agent.fullId ? agent.fullId : "").trim();
457
+ const type = String(agent && agent.type ? agent.type : "").trim();
458
+ if (!fullId) return true;
459
+ if (fullId === "ufoo-agent") return false;
460
+ if (type === "ufoo-agent") return false;
461
+ return fullId !== selfId;
462
+ });
463
+ }
464
+
465
+ function stripLeakedEscapeTags(text = "") {
466
+ const source = String(text == null ? "" : text);
467
+ const withoutClosedTags = source.replace(/\{[^{}\n]*escape[^{}\n]*\}/gi, "");
468
+ const withoutDanglingEscape = withoutClosedTags.replace(/\{\s*\/?\s*escape[\s\S]*$/gi, "");
469
+ return withoutDanglingEscape.replace(/\{\s*\/?\s*e?s?c?a?p?e?[^{}\n]*$/gi, "");
470
+ }
471
+
472
+ function findTrailingEscapeTagPrefix(text = "") {
473
+ const raw = String(text == null ? "" : text);
474
+ if (!raw) return "";
475
+ const windowSize = 40;
476
+ const tail = raw.slice(Math.max(0, raw.length - windowSize));
477
+ const braceIndex = tail.lastIndexOf("{");
478
+ if (braceIndex < 0) return "";
479
+ const suffix = tail.slice(braceIndex);
480
+ if (suffix.includes("}")) return "";
481
+
482
+ const compact = suffix.toLowerCase().replace(/\s+/g, "");
483
+ if (!compact.startsWith("{")) return "";
484
+ if (/^\{\/?e?s?c?a?p?e?[^}]*$/.test(compact)) {
485
+ return suffix;
486
+ }
487
+ return "";
488
+ }
489
+
490
+ function createEscapeTagStripper() {
491
+ let carry = "";
492
+
493
+ return {
494
+ write(chunk = "") {
495
+ const incoming = String(chunk == null ? "" : chunk);
496
+ if (!incoming && !carry) return "";
497
+ const combined = `${carry}${incoming}`;
498
+ const trailing = findTrailingEscapeTagPrefix(combined);
499
+ const safeText = trailing
500
+ ? combined.slice(0, combined.length - trailing.length)
501
+ : combined;
502
+ carry = trailing;
503
+ return stripLeakedEscapeTags(safeText);
504
+ },
505
+ flush() {
506
+ if (!carry) return "";
507
+ const rest = "";
508
+ carry = "";
509
+ return rest;
510
+ },
511
+ };
512
+ }
513
+
514
+ function formatPendingElapsed(ms = 0) {
515
+ const totalSeconds = Math.max(0, Math.floor(Number(ms) / 1000));
516
+ return `${totalSeconds} s`;
517
+ }
518
+
519
+ function normalizeBashToolCommand(args = {}, payload = {}) {
520
+ const argObj = args && typeof args === "object" ? args : {};
521
+ const resObj = payload && typeof payload === "object" ? payload : {};
522
+ const command = String(argObj.command || argObj.cmd || "").trim();
523
+ const code = Number.isFinite(resObj.code) ? `exit ${resObj.code}` : "";
524
+ return [command, code].filter(Boolean).join(" · ");
525
+ }
526
+
527
+ function normalizeToolMergeEntry(entry = {}) {
528
+ const source = entry && typeof entry === "object" ? entry : {};
529
+ const tool = String(source.tool || "").trim().toLowerCase() || "tool";
530
+ const detail = String(source.detail || "").trim();
531
+ const isError = Boolean(source.isError);
532
+ const errorText = String(source.errorText || "").trim();
533
+ const summary = [tool, detail].filter(Boolean).join(" · ") || tool;
534
+ return {
535
+ tool,
536
+ detail,
537
+ isError,
538
+ errorText,
539
+ summary,
540
+ };
541
+ }
542
+
543
+ function buildMergedToolSummaryText(entries = []) {
544
+ const list = Array.isArray(entries)
545
+ ? entries.map((item) => normalizeToolMergeEntry(item))
546
+ : [];
547
+ const count = list.length;
548
+ if (count <= 0) return "Ran tool";
549
+ const first = list[0];
550
+ if (count === 1) return `Ran ${first.summary}`;
551
+ const errorCount = list.filter((item) => item.isError).length;
552
+ const errorSuffix = errorCount > 0 ? ` · ${errorCount} error${errorCount === 1 ? "" : "s"}` : "";
553
+ return `Ran ${first.summary} · … +${count - 1} calls${errorSuffix}`;
554
+ }
555
+
556
+ function buildMergedToolExpandedLines(entries = []) {
557
+ const list = Array.isArray(entries)
558
+ ? entries.map((item) => normalizeToolMergeEntry(item))
559
+ : [];
560
+ const maxLength = 120;
561
+ return list.map((item) => {
562
+ const base = item.summary;
563
+ let line;
564
+ if (!item.isError) {
565
+ line = base;
566
+ } else {
567
+ line = item.errorText ? `${base} · error: ${item.errorText}` : `${base} · error`;
568
+ }
569
+ if (line.length > maxLength) {
570
+ return line.slice(0, maxLength - 3) + "...";
571
+ }
572
+ return line;
573
+ });
574
+ }
575
+
576
+ // Composed live-row text for an in-flight tool group: shows the merged
577
+ // summary, plus a "(Ctrl+O expand)" hint once at least two entries are
578
+ // present.
579
+ function buildToolMergeRowText(entries = []) {
580
+ const list = Array.isArray(entries) ? entries : [];
581
+ const summary = buildMergedToolSummaryText(list);
582
+ if (list.length >= 2) return `· ${summary} (Ctrl+O expand)`;
583
+ return `· ${summary}`;
584
+ }
585
+
586
+ /**
587
+ * Lay out the global-mode project rail inside a single line. Like
588
+ * planAgentsFooter, but with two differences:
589
+ * - the caller provides `windowStart` so the rail can scroll horizontally
590
+ * under cursor control rather than dropping items at the end;
591
+ * - we normally avoid truncating individual labels, but the selected
592
+ * project is always represented by at least one visible chip.
593
+ *
594
+ * Returns { items, windowStart, leftMore, rightMore } where items is the
595
+ * sub-array of `labels` that fits and windowStart is the (possibly
596
+ * adjusted) starting index after clamping for the selection cursor.
597
+ */
598
+ function planProjectsRail({
599
+ labels = [],
600
+ selectedIndex = -1,
601
+ windowStart = 0,
602
+ maxCells = 80,
603
+ } = {}) {
604
+ const items = Array.isArray(labels) ? labels.map(String) : [];
605
+ if (items.length === 0) {
606
+ return { items: [], windowStart: 0, leftMore: false, rightMore: false };
607
+ }
608
+ const budget = Math.max(1, Math.floor(Number(maxCells) || 0));
609
+ const sepWidth = displayCellWidth(" ");
610
+ const moreLeft = "< ";
611
+ const moreRight = " >";
612
+ const moreLeftWidth = displayCellWidth(moreLeft);
613
+ const moreRightWidth = displayCellWidth(moreRight);
614
+ const overflowMarker = "...";
615
+
616
+ const truncateToCells = (label = "", cells = 1) => {
617
+ const limit = Math.max(1, Math.floor(Number(cells) || 0));
618
+ const text = String(label || "");
619
+ if (displayCellWidth(text) <= limit) return text;
620
+ const markerWidth = displayCellWidth(overflowMarker);
621
+ if (limit <= markerWidth) return overflowMarker.slice(0, limit);
622
+ let out = "";
623
+ let used = 0;
624
+ const bodyLimit = limit - markerWidth;
625
+ for (const ch of text) {
626
+ const width = displayCellWidth(ch);
627
+ if (used + width > bodyLimit) break;
628
+ out += ch;
629
+ used += width;
630
+ }
631
+ return `${out || text.slice(0, 1)}${overflowMarker}`;
632
+ };
633
+
634
+ // Clamp the requested windowStart so the cursor is visible.
635
+ let start = Math.max(0, Math.min(items.length - 1, Math.floor(Number(windowStart) || 0)));
636
+ if (selectedIndex >= 0 && selectedIndex < items.length && selectedIndex < start) {
637
+ start = selectedIndex;
638
+ }
639
+
640
+ // Greedy fit forward from `start`, reserving room for the < and > arrows
641
+ // when we can't fit everything.
642
+ const tryFit = (s) => {
643
+ const out = [];
644
+ let used = 0;
645
+ for (let i = s; i < items.length; i += 1) {
646
+ const label = items[i];
647
+ const labelWidth = displayCellWidth(label);
648
+ const lead = out.length === 0 ? 0 : sepWidth;
649
+ const reserveLeft = s > 0 ? moreLeftWidth : 0;
650
+ const reserveRight = i < items.length - 1 ? moreRightWidth : 0;
651
+ if (used + lead + labelWidth + reserveLeft + reserveRight > budget) break;
652
+ out.push({ index: i, label });
653
+ used += lead + labelWidth;
654
+ }
655
+ return out;
656
+ };
657
+
658
+ let visible = tryFit(start);
659
+ // If the selected index would fall past the end of the visible window,
660
+ // slide forward until it's covered.
661
+ if (selectedIndex >= 0) {
662
+ while (visible.length > 0 && visible[visible.length - 1].index < selectedIndex && start < items.length - 1) {
663
+ start += 1;
664
+ visible = tryFit(start);
665
+ }
666
+ }
667
+ // Never let the window slide so far that the selection drops off.
668
+ if (selectedIndex >= 0 && visible.length > 0 && visible[0].index > selectedIndex) {
669
+ start = selectedIndex;
670
+ visible = tryFit(start);
671
+ }
672
+
673
+ if (visible.length === 0) {
674
+ const fallbackIndex = selectedIndex >= 0 && selectedIndex < items.length ? selectedIndex : start;
675
+ start = fallbackIndex;
676
+ const reserveLeft = start > 0 ? moreLeftWidth : 0;
677
+ const reserveRight = fallbackIndex < items.length - 1 ? moreRightWidth : 0;
678
+ const labelBudget = Math.max(1, budget - reserveLeft - reserveRight);
679
+ visible = [{
680
+ index: fallbackIndex,
681
+ label: truncateToCells(items[fallbackIndex], labelBudget),
682
+ }];
683
+ }
684
+
685
+ return {
686
+ items: visible.map((v) => ({ label: v.label, absoluteIndex: v.index })),
687
+ windowStart: start,
688
+ leftMore: start > 0,
689
+ rightMore: visible.length > 0 && visible[visible.length - 1].index < items.length - 1,
690
+ };
691
+ }
692
+
693
+ /**
694
+ * Lay out the Agents footer inside a fixed cell budget. Returns:
695
+ * { items: [{ label, selected, truncated }], overflowed, hint }
696
+ *
697
+ * `hint` is the rendered "+N more" suffix (or "" when nothing was dropped),
698
+ * already including its leading separator. Callers should render
699
+ * items[0..n-1] separated by " " then append hint with no extra spacing.
700
+ *
701
+ * The planner reserves room for the worst-case hint width up front so the
702
+ * trailing label never has to be removed once we decide to print "+N more".
703
+ *
704
+ * `labels` is the array of strings to render (already prefixed with "@").
705
+ * `selectedIndex` is the agent under the selection cursor (or -1).
706
+ * `maxCells` is the total visual width available for the agent strip,
707
+ * separators included.
708
+ */
709
+ function planAgentsFooter(labels = [], selectedIndex = -1, maxCells = 80) {
710
+ const items = Array.isArray(labels) ? labels.map(String) : [];
711
+ const budget = Math.max(1, Math.floor(Number(maxCells) || 0));
712
+ const sepText = " ";
713
+ const sepWidth = displayCellWidth(sepText);
714
+ const overflowMarker = "...";
715
+ const overflowMarkerWidth = displayCellWidth(overflowMarker);
716
+
717
+ // Reserve worst-case "+N more" width once, where N can be at most
718
+ // labels.length. We treat this as a hard upper bound so we never have
719
+ // to backtrack and pop a label after committing to it.
720
+ const worstCaseHint = items.length > 0
721
+ ? ` +${items.length} more`
722
+ : "";
723
+ const worstCaseHintWidth = displayCellWidth(worstCaseHint);
724
+
725
+ const out = [];
726
+ let used = 0;
727
+ let firstOverflowAt = -1;
728
+
729
+ for (let i = 0; i < items.length; i += 1) {
730
+ const label = items[i];
731
+ const labelWidth = displayCellWidth(label);
732
+ const lead = out.length === 0 ? 0 : sepWidth;
733
+ const remainingItems = items.length - i - 1;
734
+ // Always keep room for the hint when there's at least one item that
735
+ // might not fit later. When this is the last label, the hint is empty
736
+ // so no reservation is needed.
737
+ const reserveHint = remainingItems > 0 ? worstCaseHintWidth : 0;
738
+
739
+ if (used + lead + labelWidth + reserveHint <= budget) {
740
+ out.push({ label, selected: i === selectedIndex, truncated: false });
741
+ used += lead + labelWidth;
742
+ continue;
743
+ }
744
+
745
+ // Try to fit a truncated version: room for "..." + at least 1 cell.
746
+ const reserveForCurrent = remainingItems > 0 ? worstCaseHintWidth : 0;
747
+ const remaining = budget - used - lead - overflowMarkerWidth - reserveForCurrent;
748
+ if (remaining > 0) {
749
+ let acc = "";
750
+ let accWidth = 0;
751
+ for (const ch of label) {
752
+ const w = displayCellWidth(ch);
753
+ if (accWidth + w > remaining) break;
754
+ acc += ch;
755
+ accWidth += w;
756
+ }
757
+ if (acc) {
758
+ out.push({
759
+ label: `${acc}${overflowMarker}`,
760
+ selected: i === selectedIndex,
761
+ truncated: true,
762
+ });
763
+ used += lead + accWidth + overflowMarkerWidth;
764
+ firstOverflowAt = i + 1;
765
+ break;
766
+ }
767
+ }
768
+ firstOverflowAt = i;
769
+ break;
770
+ }
771
+
772
+ const overflowed = firstOverflowAt < 0 ? 0 : items.length - firstOverflowAt;
773
+ const hint = overflowed > 0 ? ` +${overflowed} more` : "";
774
+ return { items: out, overflowed, hint };
775
+ }
776
+
777
+ /**
778
+ * Build a list of inline-completion suggestions for the current input.
779
+ * Returns at most `limit` items; an empty list means "no popup".
780
+ *
781
+ * Triggers:
782
+ * "/<prefix>" top-level slash commands matching <prefix>
783
+ * "/<cmd> <prefix>" sub-commands of <cmd> matching <prefix>
784
+ * "/<cmd> <sub> <prefix>" sub-sub-commands (e.g. /settings agent set)
785
+ * "@<prefix>" known agent ids/labels matching <prefix>
786
+ * Anything else returns no suggestions.
787
+ */
788
+ function buildCompletions({
789
+ text = "",
790
+ agents = [],
791
+ agentLabels = [],
792
+ commands = [],
793
+ commandTree = null,
794
+ groupTemplates = [],
795
+ soloProfiles = [],
796
+ limit = 8,
797
+ } = {}) {
798
+ const raw = String(text || "");
799
+ if (!raw) return [];
800
+ const trimmed = raw.trimStart();
801
+ const endsWithWhitespace = /\s$/.test(trimmed);
802
+
803
+ if (trimmed.startsWith("/")) {
804
+ const parts = trimmed.split(/\s+/);
805
+ const head = parts[0]; // "/launch"
806
+ const tail = parts.slice(1);
807
+
808
+ // Dynamic argument completion for /group run <alias> and
809
+ // /solo run <profile>. These pull from runtime sources (group
810
+ // templates, prompt-profile registry) rather than COMMAND_TREE.
811
+ const dynList = (head === "/group" && tail[0] === "run")
812
+ ? groupTemplates
813
+ : (head === "/solo" && tail[0] === "run")
814
+ ? soloProfiles
815
+ : null;
816
+ if (dynList && (tail.length >= 2 || trimmed.endsWith(" "))) {
817
+ const partial = String(tail[1] || "").toLowerCase();
818
+ const out = [];
819
+ for (const item of (Array.isArray(dynList) ? dynList : [])) {
820
+ const id = String((item && (item.alias || item.cmd || item.id || item.name)) || "");
821
+ if (!id) continue;
822
+ if (partial && !id.toLowerCase().startsWith(partial)) continue;
823
+ const desc = String((item && (item.desc || item.summary || item.description || item.source)) || "");
824
+ out.push({
825
+ kind: "argument",
826
+ label: `${head} ${tail[0]} ${id}`,
827
+ replace: `${head} ${tail[0]} ${id} `,
828
+ description: desc,
829
+ hasChildren: false,
830
+ });
831
+ if (out.length >= limit) break;
832
+ }
833
+ if (partial && out.length === 1) {
834
+ const candidate = String(out[0].replace || "").trim().split(/\s+/).pop() || "";
835
+ if (candidate.toLowerCase() === partial && !out[0].hasChildren) return [];
836
+ }
837
+ return out;
838
+ }
839
+
840
+ // Sub-command completion: "/cmd <prefix>" or "/cmd sub <prefix>".
841
+ if (tail.length >= 1 && commandTree) {
842
+ const headKey = head.startsWith("/") ? head : `/${head}`;
843
+ let node = commandTree[headKey];
844
+ if (!node || typeof node !== "object") return [];
845
+ // Walk into nested children for everything but the last token.
846
+ for (let i = 0; i < tail.length - 1; i += 1) {
847
+ const segment = tail[i];
848
+ if (!segment) return [];
849
+ const next = node && node.children && node.children[segment];
850
+ if (!next) return [];
851
+ node = next;
852
+ }
853
+ const children = node && node.children;
854
+ if (!children || typeof children !== "object") return [];
855
+ const partial = String(tail[tail.length - 1] || "").toLowerCase();
856
+ const prefixSoFar = `${head} ${tail.slice(0, -1).join(" ")}`.replace(/\s+$/, "");
857
+ // Sort by `order` (when present) then alphabetically — matches the
858
+ // sortCommands helper used by the blessed completion popup.
859
+ const entries = Object.keys(children).map((name) => ({
860
+ name,
861
+ ...children[name],
862
+ }));
863
+ entries.sort((a, b) => {
864
+ const orderA = Number.isFinite(a.order) ? a.order : 999;
865
+ const orderB = Number.isFinite(b.order) ? b.order : 999;
866
+ if (orderA !== orderB) return orderA - orderB;
867
+ return a.name.localeCompare(b.name);
868
+ });
869
+ const out = [];
870
+ for (const entry of entries) {
871
+ if (!entry.name.toLowerCase().startsWith(partial)) continue;
872
+ const hasDynamicArguments = (head === "/group" && entry.name === "run")
873
+ || (head === "/solo" && entry.name === "run");
874
+ out.push({
875
+ kind: "subcommand",
876
+ label: `${prefixSoFar} ${entry.name}`.trim(),
877
+ replace: `${prefixSoFar} ${entry.name} `.replace(/^\s+/, ""),
878
+ description: String(entry.desc || entry.summary || entry.description || ""),
879
+ hasChildren: Boolean((entry.children && typeof entry.children === "object") || hasDynamicArguments),
880
+ });
881
+ if (out.length >= limit) break;
882
+ }
883
+ if (!endsWithWhitespace && out.length === 1) {
884
+ const candidate = String(out[0].replace || "").trim().split(/\s+/).pop() || "";
885
+ if (candidate.toLowerCase() === partial && !out[0].hasChildren) return [];
886
+ }
887
+ return out;
888
+ }
889
+
890
+ // Top-level command completion.
891
+ const after = trimmed.slice(1);
892
+ const prefix = after.toLowerCase();
893
+ const list = Array.isArray(commands) ? commands : [];
894
+ const out = [];
895
+ for (const item of list) {
896
+ // Registry entries already include the leading '/' in `cmd`. Strip
897
+ // it before matching the user's prefix and put it back when we
898
+ // render so we don't end up with '//cron'.
899
+ const rawName = String((item && item.cmd) || item || "");
900
+ const bare = rawName.startsWith("/") ? rawName.slice(1) : rawName;
901
+ const lower = bare.toLowerCase();
902
+ if (!bare) continue;
903
+ if (!lower.startsWith(prefix)) continue;
904
+ out.push({
905
+ kind: "command",
906
+ label: `/${bare}`,
907
+ replace: `/${bare} `,
908
+ description: String((item && (item.desc || item.summary || item.description)) || ""),
909
+ hasChildren: Boolean(commandTree && commandTree[`/${bare}`] && commandTree[`/${bare}`].children),
910
+ });
911
+ if (out.length >= limit) break;
912
+ }
913
+ if (!endsWithWhitespace && out.length === 1) {
914
+ const candidate = String(out[0].replace || "").trim().replace(/^\//, "").toLowerCase();
915
+ if (candidate === prefix && !out[0].hasChildren) return [];
916
+ }
917
+ return out;
918
+ }
919
+
920
+ if (trimmed.startsWith("@")) {
921
+ const after = trimmed.slice(1);
922
+ if (after.includes(" ")) return [];
923
+ const prefix = after.toLowerCase();
924
+ const idList = Array.isArray(agents) ? agents : [];
925
+ const labelList = Array.isArray(agentLabels) ? agentLabels : [];
926
+ const seen = new Set();
927
+ const out = [];
928
+ for (let i = 0; i < idList.length; i += 1) {
929
+ const id = String(idList[i] || "");
930
+ const label = String((labelList[i] != null ? labelList[i] : id) || "");
931
+ if (!id) continue;
932
+ if (seen.has(id)) continue;
933
+ const idMatch = id.toLowerCase().startsWith(prefix);
934
+ const labelMatch = label.toLowerCase().startsWith(prefix);
935
+ if (!idMatch && !labelMatch) continue;
936
+ seen.add(id);
937
+ out.push({
938
+ kind: "agent",
939
+ label: `@${label}`,
940
+ replace: `@${label} `,
941
+ description: id !== label ? id : "",
942
+ });
943
+ if (out.length >= limit) break;
944
+ }
945
+ if (out.length === 1) {
946
+ const candidate = String(out[0].label || "").replace(/^@/, "").toLowerCase();
947
+ if (candidate === prefix) return [];
948
+ }
949
+ return out;
950
+ }
951
+
952
+ return [];
953
+ }
954
+
955
+ module.exports = {
956
+ ANSI_PATTERN,
957
+ STATUS_INDICATORS,
958
+ StreamBuffer,
959
+ TOOL_LABELS,
960
+ UCODE_BANNER_LINES,
961
+ UCODE_VERSION,
962
+ buildMergedToolExpandedLines,
963
+ buildMergedToolSummaryText,
964
+ buildToolMergeRowText,
965
+ buildCompletions,
966
+ buildUcodeBannerLines,
967
+ charDisplayWidth,
968
+ clampCursorPos,
969
+ createEscapeTagStripper,
970
+ cycleAgentSelectionIndex,
971
+ deleteWordBeforeCursor,
972
+ displayCellWidth,
973
+ filterSelectableAgents,
974
+ findLogicalLineEnd,
975
+ findLogicalLineStart,
976
+ findTrailingEscapeTagPrefix,
977
+ formatHighlightedUserInput,
978
+ formatPendingElapsed,
979
+ loadActiveAgents,
980
+ moveCursorByWord,
981
+ moveCursorHorizontally,
982
+ moveCursorToVisualLineBoundary,
983
+ moveCursorVertically,
984
+ normalizeBashToolCommand,
985
+ normalizeModelLabel,
986
+ normalizeToolMergeEntry,
987
+ parseActiveAgentsFromBusStatus,
988
+ planAgentsFooter,
989
+ planProjectsRail,
990
+ renderLogLinesWithMarkdown,
991
+ resolveAgentSelectionOnDown,
992
+ resolveHistoryDownTransition,
993
+ shouldClearAgentSelectionOnUp,
994
+ shouldEnterAgentSelection,
995
+ shouldUseUcodeTui,
996
+ stripLeakedEscapeTags,
997
+ };