u-foo 3.0.1 → 3.0.3

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 +1 -1
  2. package/src/agents/prompts/native/tasks.js +4 -1
  3. package/src/agents/prompts/native/toolDescriptions/readImage.js +23 -0
  4. package/src/app/chat/commandExecutor.js +111 -1
  5. package/src/app/chat/commands.js +2 -1
  6. package/src/app/chat/daemonMessageRouter.js +1 -1
  7. package/src/app/chat/inputSubmitHandler.js +3 -2
  8. package/src/code/commands.js +3 -3
  9. package/src/code/context/assembler.js +17 -1
  10. package/src/code/context/planMode.js +2 -2
  11. package/src/code/context/promptLayers.js +12 -10
  12. package/src/code/context/reducers.js +35 -0
  13. package/src/code/context/transcriptSync.js +25 -5
  14. package/src/code/dispatch.js +8 -0
  15. package/src/code/imageIngest.js +367 -0
  16. package/src/code/modelCommand.js +199 -23
  17. package/src/code/nativeRunner.js +184 -20
  18. package/src/code/protocol/protocolValidator.js +3 -3
  19. package/src/code/providers/anthropicMessagesTransport.js +28 -1
  20. package/src/code/providers/index.js +2 -0
  21. package/src/code/providers/modelsCatalog.js +304 -0
  22. package/src/code/providers/openaiChatTransport.js +19 -1
  23. package/src/code/providers/visionBlocks.js +110 -0
  24. package/src/code/repl.js +37 -8
  25. package/src/code/runtime/taskControl.js +177 -53
  26. package/src/code/runtime/taskFocus.js +30 -10
  27. package/src/code/runtime/taskLoop.js +12 -1
  28. package/src/code/runtime/taskRun.js +10 -1
  29. package/src/code/thinkingLevels.js +132 -0
  30. package/src/code/tools/readImage.js +110 -0
  31. package/src/code/tools/taskRun.js +118 -0
  32. package/src/config.js +10 -1
  33. package/src/ui/format/index.js +103 -5
  34. package/src/ui/ink/ChatApp.js +137 -25
  35. package/src/ui/ink/MultilineInput.js +38 -2
  36. package/src/ui/ink/UcodeApp.js +102 -14
  37. package/src/ui/ink/chatLogModel.js +238 -32
  38. package/src/ui/ink/chatReducer.js +18 -6
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Ucode thinking-intensity levels.
5
+ *
6
+ * Used as the secondary `/model <id> <level>` menu after picking a model.
7
+ * Maps to Anthropic extended-thinking budget_tokens and OpenAI-compatible
8
+ * reasoning_effort where the transport supports it.
9
+ */
10
+
11
+ const THINKING_LEVELS = Object.freeze([
12
+ {
13
+ id: "off",
14
+ desc: "disable extended thinking",
15
+ budgetTokens: 0,
16
+ reasoningEffort: "",
17
+ },
18
+ {
19
+ id: "low",
20
+ desc: "light thinking",
21
+ budgetTokens: 2048,
22
+ reasoningEffort: "low",
23
+ },
24
+ {
25
+ id: "medium",
26
+ desc: "default thinking",
27
+ budgetTokens: 10000,
28
+ reasoningEffort: "medium",
29
+ },
30
+ {
31
+ id: "high",
32
+ desc: "deeper thinking",
33
+ budgetTokens: 32000,
34
+ reasoningEffort: "high",
35
+ },
36
+ {
37
+ id: "max",
38
+ desc: "maximum thinking budget",
39
+ budgetTokens: 48000,
40
+ reasoningEffort: "high",
41
+ },
42
+ ]);
43
+
44
+ const DEFAULT_THINKING_LEVEL = "medium";
45
+ const THINKING_LEVEL_IDS = new Set(THINKING_LEVELS.map((item) => item.id));
46
+
47
+ function normalizeThinkingLevel(value = "") {
48
+ const raw = String(value || "").trim().toLowerCase();
49
+ if (!raw) return "";
50
+ if (raw === "none" || raw === "disable" || raw === "disabled" || raw === "0") return "off";
51
+ if (raw === "med" || raw === "default") return "medium";
52
+ if (raw === "maximum" || raw === "xhigh" || raw === "ultra") return "max";
53
+ if (THINKING_LEVEL_IDS.has(raw)) return raw;
54
+ return "";
55
+ }
56
+
57
+ function getThinkingLevel(id = "") {
58
+ const normalized = normalizeThinkingLevel(id) || DEFAULT_THINKING_LEVEL;
59
+ return THINKING_LEVELS.find((item) => item.id === normalized) || THINKING_LEVELS[2];
60
+ }
61
+
62
+ function suggestThinkingLevels(options = {}) {
63
+ const current = normalizeThinkingLevel(options.current || "") || DEFAULT_THINKING_LEVEL;
64
+ return THINKING_LEVELS.map((item) => ({
65
+ id: item.id,
66
+ desc: item.id === current ? `${item.desc} · current` : item.desc,
67
+ }));
68
+ }
69
+
70
+ function resolveThinkingFromEnvAndConfig({
71
+ env = process.env,
72
+ configLevel = "",
73
+ } = {}) {
74
+ // Explicit numeric budget still wins (advanced override).
75
+ const rawBudget = env && env.UFOO_UCODE_THINKING_BUDGET_TOKENS;
76
+ if (rawBudget !== undefined && rawBudget !== null && String(rawBudget).trim() !== "") {
77
+ const parsed = Number.parseInt(String(rawBudget), 10);
78
+ if (!Number.isFinite(parsed) || parsed <= 0) {
79
+ return {
80
+ level: "off",
81
+ budgetTokens: 0,
82
+ reasoningEffort: "",
83
+ source: "env-budget",
84
+ };
85
+ }
86
+ return {
87
+ level: "",
88
+ budgetTokens: Math.floor(parsed),
89
+ reasoningEffort: "",
90
+ source: "env-budget",
91
+ };
92
+ }
93
+
94
+ const fromEnv = normalizeThinkingLevel(env && env.UFOO_UCODE_THINKING);
95
+ const fromConfig = normalizeThinkingLevel(configLevel);
96
+ const level = fromEnv || fromConfig || DEFAULT_THINKING_LEVEL;
97
+ const spec = getThinkingLevel(level);
98
+ return {
99
+ level: spec.id,
100
+ budgetTokens: spec.budgetTokens,
101
+ reasoningEffort: spec.reasoningEffort,
102
+ source: fromEnv ? "env" : (fromConfig ? "config" : "default"),
103
+ };
104
+ }
105
+
106
+ function applyThinkingLevelToEnv(level = "", env = process.env) {
107
+ const normalized = normalizeThinkingLevel(level);
108
+ if (!normalized) return "";
109
+ const spec = getThinkingLevel(normalized);
110
+ try {
111
+ env.UFOO_UCODE_THINKING = spec.id;
112
+ if (spec.budgetTokens > 0) {
113
+ env.UFOO_UCODE_THINKING_BUDGET_TOKENS = String(spec.budgetTokens);
114
+ } else {
115
+ env.UFOO_UCODE_THINKING_BUDGET_TOKENS = "0";
116
+ }
117
+ } catch {
118
+ // ignore env write failures
119
+ }
120
+ return spec.id;
121
+ }
122
+
123
+ module.exports = {
124
+ THINKING_LEVELS,
125
+ THINKING_LEVEL_IDS,
126
+ DEFAULT_THINKING_LEVEL,
127
+ normalizeThinkingLevel,
128
+ getThinkingLevel,
129
+ suggestThinkingLevels,
130
+ resolveThinkingFromEnvAndConfig,
131
+ applyThinkingLevelToEnv,
132
+ };
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { resolveWorkspacePath } = require("./common");
6
+
7
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
8
+
9
+ const EXT_MEDIA = Object.freeze({
10
+ ".png": "image/png",
11
+ ".jpg": "image/jpeg",
12
+ ".jpeg": "image/jpeg",
13
+ ".gif": "image/gif",
14
+ ".webp": "image/webp",
15
+ });
16
+
17
+ function sniffMediaType(buffer = Buffer.alloc(0)) {
18
+ if (!Buffer.isBuffer(buffer) || buffer.length < 12) return "";
19
+ if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
20
+ return "image/png";
21
+ }
22
+ if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
23
+ return "image/jpeg";
24
+ }
25
+ if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
26
+ return "image/gif";
27
+ }
28
+ if (
29
+ buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46
30
+ && buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50
31
+ ) {
32
+ return "image/webp";
33
+ }
34
+ return "";
35
+ }
36
+
37
+ function mediaTypeFromPath(filePath = "") {
38
+ const ext = path.extname(String(filePath || "")).toLowerCase();
39
+ return EXT_MEDIA[ext] || "";
40
+ }
41
+
42
+ function runReadImageTool(input = {}, options = {}) {
43
+ try {
44
+ const filePath = String(input.path || input.file || "").trim();
45
+ if (!filePath) {
46
+ return { ok: false, error: "path is required" };
47
+ }
48
+ const { workspaceRoot, resolved } = resolveWorkspacePath(
49
+ options.workspaceRoot,
50
+ filePath,
51
+ options.cwd,
52
+ );
53
+ const stat = fs.statSync(resolved);
54
+ if (!stat.isFile()) {
55
+ return { ok: false, error: `not a file: ${resolved}` };
56
+ }
57
+ if (stat.size > MAX_IMAGE_BYTES) {
58
+ return {
59
+ ok: false,
60
+ error: `image too large (${stat.size} bytes); max ${MAX_IMAGE_BYTES} bytes — compress or resize first`,
61
+ path: resolved,
62
+ bytes: stat.size,
63
+ };
64
+ }
65
+
66
+ const buffer = fs.readFileSync(resolved);
67
+ const sniffed = sniffMediaType(buffer);
68
+ const fromExt = mediaTypeFromPath(resolved);
69
+ const mediaType = sniffed || fromExt;
70
+ if (!mediaType) {
71
+ return {
72
+ ok: false,
73
+ error: "unsupported image type (use png, jpeg, gif, or webp)",
74
+ path: resolved,
75
+ bytes: buffer.length,
76
+ };
77
+ }
78
+ if (fromExt && sniffed && fromExt !== sniffed) {
79
+ return {
80
+ ok: false,
81
+ error: `image type mismatch: extension suggests ${fromExt}, bytes are ${sniffed}`,
82
+ path: resolved,
83
+ bytes: buffer.length,
84
+ };
85
+ }
86
+
87
+ return {
88
+ ok: true,
89
+ kind: "image",
90
+ workspaceRoot,
91
+ path: resolved,
92
+ mediaType,
93
+ bytes: buffer.length,
94
+ base64: buffer.toString("base64"),
95
+ };
96
+ } catch (err) {
97
+ return {
98
+ ok: false,
99
+ error: err && err.message ? err.message : "read_image failed",
100
+ };
101
+ }
102
+ }
103
+
104
+ module.exports = {
105
+ MAX_IMAGE_BYTES,
106
+ EXT_MEDIA,
107
+ sniffMediaType,
108
+ mediaTypeFromPath,
109
+ runReadImageTool,
110
+ };
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+
3
+ const {
4
+ startStandaloneTask,
5
+ cancelTask,
6
+ failTask,
7
+ completeTaskFromLoop,
8
+ } = require("../runtime/taskControl");
9
+ const { getTaskRun } = require("../runtime/taskRun");
10
+ const { emptyExecutionState } = require("../context/executionSegment");
11
+
12
+ function normalizeTaskRunCommand(args = {}) {
13
+ if (!args || typeof args !== "object") return null;
14
+ const operation = String(args.operation || args.op || "").trim().toLowerCase();
15
+ if (!operation) return null;
16
+ return {
17
+ operation,
18
+ objective: args.objective,
19
+ title: args.title,
20
+ taskRunId: args.taskRunId || args.task_run_id,
21
+ nodeId: args.nodeId || args.node_id,
22
+ reason: args.reason,
23
+ result: args.result,
24
+ commandId: args.commandId || args.command_id,
25
+ };
26
+ }
27
+
28
+ function runTaskRunTool(args = {}, options = {}) {
29
+ const command = normalizeTaskRunCommand(args) || args;
30
+ const operation = String(command.operation || "").trim().toLowerCase();
31
+ const executionState = options.executionState && typeof options.executionState === "object"
32
+ ? options.executionState
33
+ : emptyExecutionState();
34
+ const commandId = String(command.commandId || "").trim();
35
+ const runTool = options.runTool || null;
36
+ const knownTools = options.knownTools || null;
37
+
38
+ let payload;
39
+ if (operation === "start") {
40
+ payload = startStandaloneTask(executionState, {
41
+ objective: command.objective,
42
+ title: command.title,
43
+ commandId,
44
+ runTool,
45
+ knownTools,
46
+ processImmediately: options.processImmediately !== false,
47
+ });
48
+ } else if (operation === "cancel") {
49
+ payload = cancelTask(executionState, {
50
+ taskRunId: command.taskRunId,
51
+ nodeId: command.nodeId,
52
+ reason: command.reason,
53
+ commandId,
54
+ });
55
+ } else if (operation === "fail") {
56
+ payload = failTask(executionState, {
57
+ taskRunId: command.taskRunId,
58
+ nodeId: command.nodeId,
59
+ reason: command.reason,
60
+ commandId,
61
+ });
62
+ } else if (operation === "complete") {
63
+ payload = completeTaskFromLoop(executionState, {
64
+ taskRunId: command.taskRunId,
65
+ result: command.result,
66
+ commandId,
67
+ });
68
+ } else if (operation === "inspect") {
69
+ const run = getTaskRun(executionState, command.taskRunId);
70
+ if (!run) {
71
+ payload = {
72
+ status: "rejected",
73
+ ok: false,
74
+ errors: [{ code: "TASK_RUN_NOT_FOUND", message: "task run missing" }],
75
+ };
76
+ } else {
77
+ payload = {
78
+ status: "accepted",
79
+ ok: true,
80
+ taskRun: {
81
+ id: run.id,
82
+ kind: run.kind || "",
83
+ status: run.status,
84
+ phase: run.phase,
85
+ objective: run.objective || "",
86
+ title: run.title || "",
87
+ parentGraphId: run.parentGraphId || "",
88
+ parentNodeId: run.parentNodeId || "",
89
+ childGraphId: run.childGraphId || "",
90
+ result: run.result,
91
+ error: run.error,
92
+ changedFiles: Array.isArray(run.changedFiles) ? run.changedFiles.slice() : [],
93
+ },
94
+ };
95
+ }
96
+ } else {
97
+ payload = {
98
+ status: "rejected",
99
+ ok: false,
100
+ errors: [{
101
+ code: "UNKNOWN_TASK_RUN_OP",
102
+ message: `unknown task_run operation: ${operation || "(empty)"}`,
103
+ }],
104
+ };
105
+ }
106
+
107
+ const ok = payload && payload.ok !== false && payload.status !== "rejected";
108
+ return {
109
+ ok,
110
+ ...payload,
111
+ executionState,
112
+ };
113
+ }
114
+
115
+ module.exports = {
116
+ normalizeTaskRunCommand,
117
+ runTaskRunTool,
118
+ };
package/src/config.js CHANGED
@@ -2,7 +2,14 @@ const fs = require("fs");
2
2
  const os = require("os");
3
3
  const path = require("path");
4
4
 
5
- const UCODE_FIELDS = ["ucodeProvider", "ucodeModel", "ucodeBaseUrl", "ucodeApiKey", "ucodeAgentDir"];
5
+ const UCODE_FIELDS = [
6
+ "ucodeProvider",
7
+ "ucodeModel",
8
+ "ucodeBaseUrl",
9
+ "ucodeApiKey",
10
+ "ucodeAgentDir",
11
+ "ucodeThinking",
12
+ ];
6
13
 
7
14
  const SETTINGS_MODEL_DEFAULTS = Object.freeze({
8
15
  agent: Object.freeze({
@@ -47,6 +54,7 @@ const DEFAULT_UCODE_CONFIG = {
47
54
  ucodeBaseUrl: "",
48
55
  ucodeApiKey: "",
49
56
  ucodeAgentDir: "",
57
+ ucodeThinking: "",
50
58
  };
51
59
 
52
60
  function normalizeLaunchMode(value) {
@@ -251,6 +259,7 @@ function loadGlobalUcodeConfig() {
251
259
  ucodeBaseUrl: typeof raw.ucodeBaseUrl === "string" ? raw.ucodeBaseUrl : "",
252
260
  ucodeApiKey: typeof raw.ucodeApiKey === "string" ? raw.ucodeApiKey : "",
253
261
  ucodeAgentDir: typeof raw.ucodeAgentDir === "string" ? raw.ucodeAgentDir : "",
262
+ ucodeThinking: typeof raw.ucodeThinking === "string" ? raw.ucodeThinking : "",
254
263
  };
255
264
  }
256
265
 
@@ -38,10 +38,14 @@ const STATUS_INDICATORS = {
38
38
  // Keep this list in sync with the keys handled by buildMergedToolSummaryText.
39
39
  const TOOL_LABELS = {
40
40
  read: "Reading file",
41
+ read_image: "Reading image",
41
42
  write: "Writing file",
42
43
  edit: "Editing file",
43
44
  bash: "Running command",
44
45
  artifact_read: "Reading artifact",
46
+ plan_graph: "Updating plan",
47
+ task_run: "Managing task",
48
+ ask_user: "Asking user",
45
49
  };
46
50
 
47
51
  const ANSI_PATTERN = /\x1B\[[0-9;?]*[ -/]*[@-~]/g;
@@ -283,7 +287,15 @@ function messageContentText(message = {}) {
283
287
  if (Array.isArray(content)) {
284
288
  return content.map((part) => {
285
289
  if (typeof part === "string") return part;
286
- if (part && typeof part === "object" && part.text != null) return String(part.text);
290
+ if (!part || typeof part !== "object") return "";
291
+ const type = String(part.type || "").trim().toLowerCase();
292
+ if (type === "image" || type === "image_url") {
293
+ const name = part.fileName
294
+ || (part.path ? require("path").basename(String(part.path)) : "")
295
+ || "image";
296
+ return `[image: ${name}]`;
297
+ }
298
+ if (part.text != null) return String(part.text);
287
299
  return "";
288
300
  }).join("");
289
301
  }
@@ -296,15 +308,47 @@ function toolMessagePreview(message = {}) {
296
308
  try {
297
309
  const parsed = JSON.parse(raw);
298
310
  if (parsed && typeof parsed === "object") {
311
+ if (parsed.kind === "image" || parsed.base64 || parsed.mediaType) {
312
+ const name = require("path").basename(String(parsed.path || parsed.fileName || "image"));
313
+ return parsed.preview || `[image: ${name}]`;
314
+ }
299
315
  if (parsed.preview) return String(parsed.preview);
300
316
  if (parsed.artifactId) return `artifact:${parsed.artifactId}`;
317
+ if (parsed.base64) {
318
+ const clone = { ...parsed };
319
+ delete clone.base64;
320
+ return JSON.stringify(clone);
321
+ }
301
322
  }
302
323
  } catch {
303
324
  // plain tool text
304
325
  }
326
+ if (/data:image\/[a-zA-Z+]+;base64,/.test(raw) || /"base64"\s*:/.test(raw)) {
327
+ return "[image]";
328
+ }
305
329
  return raw;
306
330
  }
307
331
 
332
+ /**
333
+ * Collapse attached-image prompt prefixes / base64 blobs for TUI log display.
334
+ */
335
+ function redactUserMessageForLog(text = "") {
336
+ let out = String(text || "");
337
+ out = out.replace(
338
+ /\[Attached images[^\]]*\]\s*(?:-\s*.+\n?)*/gi,
339
+ (block) => {
340
+ const paths = [...block.matchAll(/^\s*-\s*(.+)$/gm)].map((m) => String(m[1] || "").trim());
341
+ if (paths.length === 0) return "[image]";
342
+ return paths
343
+ .map((p) => `[image: ${require("path").basename(p)}]`)
344
+ .join(" ");
345
+ },
346
+ );
347
+ out = out.replace(/data:image\/[a-zA-Z+]+;base64,[A-Za-z0-9+/=\s]+/g, "[image]");
348
+ out = out.replace(/"base64"\s*:\s*"[^"]*"/g, '"base64":"[redacted]"');
349
+ return out.replace(/\n{3,}/g, "\n\n").trim();
350
+ }
351
+
308
352
  /**
309
353
  * Convert persisted nlMessages into ucode TUI log rows for resume/history.
310
354
  * Applies the shared ANSI markdown renderer so restored assistant text matches
@@ -349,7 +393,7 @@ function buildUcodeSessionLogEntries(messages = [], options = {}) {
349
393
  if (!message || typeof message !== "object") continue;
350
394
  const role = String(message.role || "").trim().toLowerCase();
351
395
  if (role === "user") {
352
- const text = messageContentText(message);
396
+ const text = redactUserMessageForLog(messageContentText(message));
353
397
  if (!text.trim()) continue;
354
398
  const lines = text.split(/\r?\n/);
355
399
  lines.forEach((line, index) => {
@@ -671,6 +715,12 @@ function normalizeToolLogDetail(tool = "", args = {}, payload = {}) {
671
715
  return shortenPathDetail(pathText);
672
716
  }
673
717
 
718
+ if (name === "read_image") {
719
+ const pathText = String(argObj.path || resObj.path || resObj.fileName || "").trim();
720
+ const base = pathText ? require("path").basename(pathText) : "image";
721
+ return `[image: ${base}]`;
722
+ }
723
+
674
724
  if (name === "artifact_read") {
675
725
  const artifactId = String(argObj.artifactId || argObj.id || resObj.artifactId || "").trim();
676
726
  const rangeBits = [];
@@ -1103,13 +1153,52 @@ function buildCompletions({
1103
1153
  }
1104
1154
  }
1105
1155
 
1106
- // Generic top-level argument lists (e.g. /resume <session-id>).
1156
+ // Generic top-level argument lists (e.g. /resume <session-id>,
1157
+ // /model <id> [thinking]). /model supports a secondary intensity menu.
1107
1158
  if (
1108
1159
  Array.isArray(argListForHead)
1109
1160
  && argListForHead.length > 0
1110
1161
  && !(headNode && headNode.children)
1111
1162
  && (endsWithWhitespace || tail.length >= 1)
1112
1163
  ) {
1164
+ const secondaryList = argumentLists && typeof argumentLists === "object"
1165
+ ? argumentLists[`${headKey}/thinking`] || argumentLists[`${headKey}/think`]
1166
+ : null;
1167
+ const supportsThinkingMenu = head === "/model" && Array.isArray(secondaryList) && secondaryList.length > 0;
1168
+
1169
+ // Secondary menu: "/model <id> " or "/model <id> <partial>"
1170
+ if (supportsThinkingMenu && (
1171
+ (tail.length === 1 && endsWithWhitespace)
1172
+ || tail.length >= 2
1173
+ )) {
1174
+ if (tail.length > 2) return [];
1175
+ const modelId = String(tail[0] || "").trim();
1176
+ if (!modelId) return [];
1177
+ const partial = tail.length >= 2 && !endsWithWhitespace
1178
+ ? String(tail[1] || "").toLowerCase()
1179
+ : "";
1180
+ const out = [];
1181
+ for (const item of secondaryList) {
1182
+ const id = String((item && (item.alias || item.cmd || item.id || item.name)) || item || "");
1183
+ if (!id) continue;
1184
+ if (partial && !id.toLowerCase().startsWith(partial)) continue;
1185
+ const desc = String((item && (item.desc || item.summary || item.description || item.source)) || "");
1186
+ out.push({
1187
+ kind: "argument",
1188
+ label: `${head} ${modelId} ${id}`,
1189
+ replace: `${head} ${modelId} ${id}`,
1190
+ description: desc,
1191
+ hasChildren: false,
1192
+ });
1193
+ if (out.length >= limit) break;
1194
+ }
1195
+ if (partial && out.length === 1) {
1196
+ const candidate = String(out[0].replace || "").trim().split(/\s+/).pop() || "";
1197
+ if (candidate.toLowerCase() === partial && !out[0].hasChildren) return [];
1198
+ }
1199
+ return out;
1200
+ }
1201
+
1113
1202
  if (tail.length > 1) return [];
1114
1203
  const partial = String(tail[0] || "").toLowerCase();
1115
1204
  const out = [];
@@ -1118,17 +1207,23 @@ function buildCompletions({
1118
1207
  if (!id) continue;
1119
1208
  if (partial && !id.toLowerCase().startsWith(partial)) continue;
1120
1209
  const desc = String((item && (item.desc || item.summary || item.description || item.source)) || "");
1210
+ const hasChildren = Boolean(
1211
+ (item && item.hasChildren)
1212
+ || supportsThinkingMenu
1213
+ );
1121
1214
  out.push({
1122
1215
  kind: "argument",
1123
1216
  label: `${head} ${id}`,
1124
- replace: `${head} ${id} `,
1217
+ // Trailing space keeps the popup open for the thinking submenu.
1218
+ replace: hasChildren ? `${head} ${id} ` : `${head} ${id} `,
1125
1219
  description: desc,
1126
- hasChildren: false,
1220
+ hasChildren,
1127
1221
  });
1128
1222
  if (out.length >= limit) break;
1129
1223
  }
1130
1224
  if (partial && out.length === 1) {
1131
1225
  const candidate = String(out[0].replace || "").trim().split(/\s+/).pop() || "";
1226
+ // Keep the popup open when the sole match still has a submenu.
1132
1227
  if (candidate.toLowerCase() === partial && !out[0].hasChildren) return [];
1133
1228
  }
1134
1229
  return out;
@@ -1250,6 +1345,9 @@ module.exports = {
1250
1345
  normalizeModelLabel,
1251
1346
  normalizeToolLogDetail,
1252
1347
  normalizeToolMergeEntry,
1348
+ messageContentText,
1349
+ toolMessagePreview,
1350
+ redactUserMessageForLog,
1253
1351
  parseActiveAgentsFromBusStatus,
1254
1352
  planAgentsFooter,
1255
1353
  planProjectsRail,