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,367 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+ const { execFileSync } = require("child_process");
7
+ const { mediaTypeFromPath, sniffMediaType, MAX_IMAGE_BYTES } = require("./tools/readImage");
8
+
9
+ const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp)$/i;
10
+ const FILE_URL_RE = /^file:\/\//i;
11
+
12
+ function uploadsDir(workspaceRoot = "", sessionId = "") {
13
+ const root = path.resolve(String(workspaceRoot || process.cwd()));
14
+ const sid = String(sessionId || "session").trim().replace(/[^a-zA-Z0-9._-]+/g, "_") || "session";
15
+ return path.join(root, ".ufoo", "agent", "ucode", "uploads", sid);
16
+ }
17
+
18
+ function safeBaseName(filePath = "") {
19
+ const base = path.basename(String(filePath || "image.png"));
20
+ const cleaned = base.replace(/[^\w.\-()+ ]+/g, "_").replace(/\s+/g, " ").trim();
21
+ if (!cleaned) return "image.png";
22
+ if (!IMAGE_EXT_RE.test(cleaned)) return `${cleaned}.png`;
23
+ return cleaned.slice(0, 120);
24
+ }
25
+
26
+ function decodeFileUrl(value = "") {
27
+ const text = String(value || "").trim();
28
+ if (!FILE_URL_RE.test(text)) return text;
29
+ try {
30
+ const parsed = new URL(text);
31
+ if (parsed.protocol !== "file:") return text;
32
+ return decodeURIComponent(parsed.pathname || "");
33
+ } catch {
34
+ return text.replace(FILE_URL_RE, "");
35
+ }
36
+ }
37
+
38
+ function looksLikeImagePath(candidate = "") {
39
+ const text = decodeFileUrl(String(candidate || "").trim().replace(/^['"]|['"]$/g, ""));
40
+ if (!text || !IMAGE_EXT_RE.test(text)) return false;
41
+ if (text.startsWith("/") || /^[A-Za-z]:[\\/]/.test(text) || text.startsWith("~")) return true;
42
+ // Relative paths ending in image ext (drag from cwd listings)
43
+ if (!/\s/.test(text) && IMAGE_EXT_RE.test(text)) return true;
44
+ return false;
45
+ }
46
+
47
+ function expandHome(filePath = "") {
48
+ const text = String(filePath || "");
49
+ if (text.startsWith("~/")) return path.join(os.homedir(), text.slice(2));
50
+ return text;
51
+ }
52
+
53
+ /**
54
+ * Extract image file paths from terminal paste / drag-drop text.
55
+ * Supports file://, quoted paths with spaces, and bare absolute paths.
56
+ */
57
+ function extractImagePathsFromPaste(text = "") {
58
+ const raw = String(text || "");
59
+ if (!raw.trim()) return [];
60
+ const found = [];
61
+ const seen = new Set();
62
+
63
+ function pushPath(candidate) {
64
+ let next = decodeFileUrl(String(candidate || "").trim());
65
+ next = next.replace(/^['"]|['"]$/g, "");
66
+ if (!looksLikeImagePath(next)) return;
67
+ next = expandHome(next);
68
+ const key = path.resolve(next);
69
+ if (seen.has(key)) return;
70
+ seen.add(key);
71
+ found.push(next);
72
+ }
73
+
74
+ // Quoted paths (possibly with spaces)
75
+ const quoted = /["']([^"']+\.(?:png|jpe?g|gif|webp))["']/gi;
76
+ let match;
77
+ while ((match = quoted.exec(raw))) {
78
+ pushPath(match[1]);
79
+ }
80
+
81
+ // file:// URLs
82
+ const fileUrls = /file:\/\/[^\s"'<>]+/gi;
83
+ while ((match = fileUrls.exec(raw))) {
84
+ pushPath(match[0]);
85
+ }
86
+
87
+ // Bare tokens / lines
88
+ for (const line of raw.split(/\r?\n/)) {
89
+ const trimmed = line.trim();
90
+ if (!trimmed) continue;
91
+ if (looksLikeImagePath(trimmed)) {
92
+ pushPath(trimmed);
93
+ continue;
94
+ }
95
+ for (const token of trimmed.split(/\s+/)) {
96
+ if (looksLikeImagePath(token)) pushPath(token);
97
+ }
98
+ }
99
+
100
+ return found;
101
+ }
102
+
103
+ function stripExtractedPathsFromText(text = "", paths = []) {
104
+ let out = String(text || "");
105
+ for (const p of paths) {
106
+ const variants = [
107
+ `"${p}"`,
108
+ `'${p}'`,
109
+ p,
110
+ p.startsWith("/") ? `file://${p}` : "",
111
+ p.startsWith("/") ? `file://${encodeURI(p)}` : "",
112
+ ].filter(Boolean);
113
+ for (const v of variants) {
114
+ out = out.split(v).join(" ");
115
+ }
116
+ }
117
+ return out
118
+ .replace(/[ \t]+\n/g, "\n")
119
+ .replace(/\n{3,}/g, "\n\n")
120
+ .replace(/[ \t]{2,}/g, " ")
121
+ .replace(/\s+"/g, " ")
122
+ .replace(/"\s+/g, " ")
123
+ .replace(/\s+'/g, " ")
124
+ .replace(/'\s+/g, " ")
125
+ .trim();
126
+ }
127
+
128
+ function formatImageLogLabel({ relPath = "", fileName = "", path: pathText = "" } = {}) {
129
+ const name = String(fileName || "").trim()
130
+ || path.basename(String(relPath || pathText || "").trim())
131
+ || "image";
132
+ return `[image: ${name}]`;
133
+ }
134
+
135
+ function formatUserLogWithAttachments(userText = "", attachments = []) {
136
+ const labels = (Array.isArray(attachments) ? attachments : [])
137
+ .map((item) => formatImageLogLabel(item))
138
+ .filter(Boolean);
139
+ const body = String(userText || "").trim();
140
+ if (labels.length === 0) return body;
141
+ if (!body) return labels.join(" ");
142
+ return `${labels.join(" ")} ${body}`;
143
+ }
144
+
145
+ function buildAttachedImagesPromptPrefix(attachments = []) {
146
+ const list = Array.isArray(attachments) ? attachments : [];
147
+ if (list.length === 0) return "";
148
+ const lines = [
149
+ "[Attached images — call read_image on each path]",
150
+ ...list.map((item) => `- ${item.relPath || item.path || ""}`).filter((line) => line !== "- "),
151
+ "",
152
+ ];
153
+ return lines.join("\n");
154
+ }
155
+
156
+ function ingestImageFile({
157
+ sourcePath = "",
158
+ workspaceRoot = process.cwd(),
159
+ sessionId = "",
160
+ buffer = null,
161
+ preferredName = "",
162
+ } = {}) {
163
+ const root = path.resolve(String(workspaceRoot || process.cwd()));
164
+ let data = buffer;
165
+ let fromPath = String(sourcePath || "").trim();
166
+
167
+ if (!data) {
168
+ if (!fromPath) {
169
+ return { ok: false, error: "sourcePath or buffer required" };
170
+ }
171
+ fromPath = expandHome(decodeFileUrl(fromPath));
172
+ try {
173
+ const stat = fs.statSync(fromPath);
174
+ if (!stat.isFile()) return { ok: false, error: `not a file: ${fromPath}` };
175
+ if (stat.size > MAX_IMAGE_BYTES) {
176
+ return {
177
+ ok: false,
178
+ error: `image too large (${stat.size} bytes); max ${MAX_IMAGE_BYTES}`,
179
+ };
180
+ }
181
+ data = fs.readFileSync(fromPath);
182
+ } catch (err) {
183
+ return { ok: false, error: err && err.message ? err.message : "read failed" };
184
+ }
185
+ }
186
+
187
+ if (!Buffer.isBuffer(data)) {
188
+ return { ok: false, error: "image buffer required" };
189
+ }
190
+ if (data.length > MAX_IMAGE_BYTES) {
191
+ return {
192
+ ok: false,
193
+ error: `image too large (${data.length} bytes); max ${MAX_IMAGE_BYTES}`,
194
+ };
195
+ }
196
+
197
+ const sniffed = sniffMediaType(data);
198
+ const fromName = mediaTypeFromPath(preferredName || fromPath);
199
+ const mediaType = sniffed || fromName;
200
+ if (!mediaType) {
201
+ return { ok: false, error: "unsupported image type (use png, jpeg, gif, or webp)" };
202
+ }
203
+
204
+ const ext = mediaType === "image/jpeg"
205
+ ? ".jpg"
206
+ : mediaType === "image/gif"
207
+ ? ".gif"
208
+ : mediaType === "image/webp"
209
+ ? ".webp"
210
+ : ".png";
211
+
212
+ let base = safeBaseName(preferredName || fromPath || `clipboard${ext}`);
213
+ if (!IMAGE_EXT_RE.test(base)) base = `${base}${ext}`;
214
+ // Normalize extension to sniffed type
215
+ base = `${path.basename(base, path.extname(base))}${ext}`;
216
+
217
+ const dir = uploadsDir(root, sessionId);
218
+ fs.mkdirSync(dir, { recursive: true });
219
+ const stamp = Date.now().toString(36);
220
+ const destName = `${stamp}-${base}`;
221
+ const absPath = path.join(dir, destName);
222
+ fs.writeFileSync(absPath, data);
223
+
224
+ const relPath = path.relative(root, absPath).split(path.sep).join("/");
225
+ return {
226
+ ok: true,
227
+ relPath,
228
+ absPath,
229
+ fileName: base,
230
+ mediaType,
231
+ bytes: data.length,
232
+ };
233
+ }
234
+
235
+ function tryIngestClipboardImage({
236
+ workspaceRoot = process.cwd(),
237
+ sessionId = "",
238
+ platform = process.platform,
239
+ execFile = execFileSync,
240
+ } = {}) {
241
+ if (platform !== "darwin") {
242
+ return { ok: false, error: "clipboard image ingest is only supported on macOS" };
243
+ }
244
+
245
+ const tmpPath = path.join(
246
+ os.tmpdir(),
247
+ `ufoo-clipboard-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.png`,
248
+ );
249
+ // AppleScript: write clipboard PNGf to a temp file.
250
+ const script = [
251
+ `set outPath to POSIX file ${JSON.stringify(tmpPath)}`,
252
+ "try",
253
+ " set pngData to the clipboard as «class PNGf»",
254
+ " set fileRef to open for access outPath with write permission",
255
+ " set eof of fileRef to 0",
256
+ " write pngData to fileRef",
257
+ " close access fileRef",
258
+ ' return "ok"',
259
+ "on error errMsg number errNum",
260
+ " try",
261
+ " close access outPath",
262
+ " end try",
263
+ ' return "err:" & errMsg',
264
+ "end try",
265
+ ].join("\n");
266
+
267
+ let resultText = "";
268
+ try {
269
+ resultText = String(execFile("osascript", ["-e", script], {
270
+ encoding: "utf8",
271
+ timeout: 5000,
272
+ maxBuffer: 1024 * 1024,
273
+ }) || "").trim();
274
+ } catch (err) {
275
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
276
+ return {
277
+ ok: false,
278
+ error: err && err.message ? err.message : "clipboard read failed",
279
+ };
280
+ }
281
+
282
+ if (!resultText.startsWith("ok")) {
283
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
284
+ return {
285
+ ok: false,
286
+ error: resultText.replace(/^err:/, "").trim() || "no PNG image on clipboard",
287
+ };
288
+ }
289
+
290
+ try {
291
+ const ingested = ingestImageFile({
292
+ sourcePath: tmpPath,
293
+ workspaceRoot,
294
+ sessionId,
295
+ preferredName: `clipboard-${Date.now().toString(36)}.png`,
296
+ });
297
+ return ingested;
298
+ } finally {
299
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Handle a paste chunk: ingest image paths and/or macOS clipboard bitmap.
305
+ * Returns text to insert into the editor (paths removed) plus attachments.
306
+ */
307
+ function handleImagePaste(text = "", {
308
+ workspaceRoot = process.cwd(),
309
+ sessionId = "",
310
+ tryClipboard = true,
311
+ platform = process.platform,
312
+ execFile = execFileSync,
313
+ } = {}) {
314
+ const raw = String(text || "");
315
+ const paths = extractImagePathsFromPaste(raw);
316
+ const attachments = [];
317
+ const errors = [];
318
+
319
+ for (const sourcePath of paths) {
320
+ const ingested = ingestImageFile({ sourcePath, workspaceRoot, sessionId });
321
+ if (ingested.ok) attachments.push(ingested);
322
+ else errors.push(ingested.error || "ingest failed");
323
+ }
324
+
325
+ let remaining = stripExtractedPathsFromText(raw, paths);
326
+
327
+ // If paste had no usable text/paths, try clipboard PNG (Cmd+V of a screenshot).
328
+ const trimmedRemaining = remaining.trim();
329
+ const looksEmptyOrBinary = !trimmedRemaining
330
+ || /[\x00-\x08\x0e-\x1f]/.test(raw)
331
+ || (Buffer.byteLength(raw, "utf8") > 200 && paths.length === 0 && !/\s/.test(raw.slice(0, 40)));
332
+
333
+ if (tryClipboard && attachments.length === 0 && looksEmptyOrBinary) {
334
+ const clip = tryIngestClipboardImage({
335
+ workspaceRoot,
336
+ sessionId,
337
+ platform,
338
+ execFile,
339
+ });
340
+ if (clip.ok) {
341
+ attachments.push(clip);
342
+ remaining = "";
343
+ } else if (paths.length === 0 && !trimmedRemaining) {
344
+ errors.push(clip.error || "clipboard ingest failed");
345
+ }
346
+ }
347
+
348
+ return {
349
+ text: remaining,
350
+ attachments,
351
+ errors,
352
+ };
353
+ }
354
+
355
+ module.exports = {
356
+ IMAGE_EXT_RE,
357
+ uploadsDir,
358
+ safeBaseName,
359
+ extractImagePathsFromPaste,
360
+ stripExtractedPathsFromText,
361
+ formatImageLogLabel,
362
+ formatUserLogWithAttachments,
363
+ buildAttachedImagesPromptPrefix,
364
+ ingestImageFile,
365
+ tryIngestClipboardImage,
366
+ handleImagePaste,
367
+ };
@@ -1,37 +1,176 @@
1
1
  "use strict";
2
2
 
3
- const { saveGlobalUcodeConfig } = require("../config");
3
+ const { saveGlobalUcodeConfig, loadGlobalUcodeConfig } = require("../config");
4
+ const {
5
+ listProviderModels,
6
+ confirmModelSupported,
7
+ } = require("./providers/modelsCatalog");
8
+ const {
9
+ normalizeThinkingLevel,
10
+ suggestThinkingLevels,
11
+ applyThinkingLevelToEnv,
12
+ resolveThinkingFromEnvAndConfig,
13
+ DEFAULT_THINKING_LEVEL,
14
+ } = require("./thinkingLevels");
15
+
16
+ function fallbackModelSuggestions(provider = "") {
17
+ const text = String(provider || "").trim().toLowerCase();
18
+ if (text.includes("anthropic") || text.includes("claude")) {
19
+ return ["claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"];
20
+ }
21
+ if (text.includes("kimi") || text.includes("moonshot")) {
22
+ return ["k3", "kimi-k2.5", "moonshot-v1-128k"];
23
+ }
24
+ return ["gpt-5.4", "gpt-5.3", "o3", "o4-mini"];
25
+ }
26
+
27
+ function resolveModelRuntime(state = {}, options = {}) {
28
+ // Lazy require: nativeRunner pulls agent/repl paths that can load modelCommand.
29
+ const { resolveRuntimeConfig } = require("./nativeRunner");
30
+ return resolveRuntimeConfig({
31
+ workspaceRoot: options.workspaceRoot || process.cwd(),
32
+ provider: options.provider || (state && state.provider) || "",
33
+ model: options.model || (state && state.model) || "",
34
+ });
35
+ }
36
+
37
+ function currentThinkingLevel(state = {}) {
38
+ let configLevel = "";
39
+ try {
40
+ configLevel = String((loadGlobalUcodeConfig() || {}).ucodeThinking || "").trim();
41
+ } catch {
42
+ configLevel = "";
43
+ }
44
+ const fromState = normalizeThinkingLevel(state && state.thinking);
45
+ const resolved = resolveThinkingFromEnvAndConfig({
46
+ env: process.env,
47
+ configLevel: fromState || configLevel,
48
+ });
49
+ if (resolved.level) return resolved.level;
50
+ if (resolved.source === "env-budget") {
51
+ // Approximate a named level for the secondary menu highlight.
52
+ const budget = Number(resolved.budgetTokens) || 0;
53
+ if (budget <= 0) return "off";
54
+ if (budget <= 3000) return "low";
55
+ if (budget <= 16000) return "medium";
56
+ if (budget <= 40000) return "high";
57
+ return "max";
58
+ }
59
+ return DEFAULT_THINKING_LEVEL;
60
+ }
61
+
62
+ function persistThinkingLevel(state = {}, level = "") {
63
+ const normalized = normalizeThinkingLevel(level);
64
+ if (!normalized) return "";
65
+ if (state && typeof state === "object") state.thinking = normalized;
66
+ try {
67
+ saveGlobalUcodeConfig({ ucodeThinking: normalized });
68
+ } catch {
69
+ // best-effort
70
+ }
71
+ applyThinkingLevelToEnv(normalized, process.env);
72
+ return normalized;
73
+ }
74
+
75
+ /**
76
+ * Fetch models from the configured provider's /models route.
77
+ */
78
+ async function listUcodeModels(state = {}, options = {}) {
79
+ const runtime = resolveModelRuntime(state, options);
80
+ const listed = await listProviderModels({
81
+ ...runtime,
82
+ fetchImpl: options.fetchImpl,
83
+ timeoutMs: options.timeoutMs,
84
+ skipCache: options.skipCache === true,
85
+ });
86
+ return {
87
+ ...listed,
88
+ provider: runtime.provider,
89
+ transport: runtime.transport,
90
+ baseUrl: runtime.baseUrl,
91
+ };
92
+ }
4
93
 
5
94
  /**
6
95
  * Apply /model show|set against the live session state.
7
- * Persists ucodeModel to the global config so the next launch keeps it.
96
+ * Persists ucodeModel / ucodeThinking so the next launch keeps them.
97
+ * Set validates against the provider models route when available.
98
+ *
99
+ * result.shape:
100
+ * { action: "show" }
101
+ * { action: "set", model, thinking? }
8
102
  */
9
- function applyUcodeModelCommand(state = {}, result = {}) {
103
+ async function applyUcodeModelCommand(state = {}, result = {}, options = {}) {
10
104
  const action = String((result && result.action) || "").trim().toLowerCase();
11
105
  if (action === "show") {
12
106
  const model = String((state && state.model) || "").trim() || "(unset)";
13
107
  const provider = String((state && state.provider) || "").trim() || "(unset)";
108
+ const thinking = currentThinkingLevel(state);
109
+ const lines = [
110
+ `model: ${model}`,
111
+ `provider: ${provider}`,
112
+ `thinking: ${thinking}`,
113
+ "usage: /model <model-id> [off|low|medium|high|max]",
114
+ ];
115
+ try {
116
+ const listed = await listUcodeModels(state, options);
117
+ if (listed.ok && listed.models.length > 0) {
118
+ const sample = listed.models.slice(0, 12);
119
+ lines.push(`models route: ${listed.url}`);
120
+ lines.push(`available (${listed.models.length}): ${sample.join(", ")}${listed.models.length > 12 ? "…" : ""}`);
121
+ } else if (listed.error) {
122
+ lines.push(`models route: ${listed.error}`);
123
+ }
124
+ } catch (err) {
125
+ lines.push(`models route: ${err && err.message ? err.message : "unavailable"}`);
126
+ }
14
127
  return {
15
128
  ok: true,
16
129
  error: "",
17
- output: [
18
- `model: ${model}`,
19
- `provider: ${provider}`,
20
- "usage: /model <model-id>",
21
- ].join("\n"),
130
+ output: lines.join("\n"),
22
131
  model: String((state && state.model) || "").trim(),
132
+ thinking,
23
133
  };
24
134
  }
25
135
  if (action === "set") {
26
136
  const next = String((result && result.model) || "").trim();
137
+ const thinkingRaw = String((result && result.thinking) || "").trim();
138
+ const thinkingNext = normalizeThinkingLevel(thinkingRaw);
27
139
  if (!next) {
28
140
  return {
29
141
  ok: false,
30
- error: "usage: /model [model-id]",
31
- output: "usage: /model [model-id]",
142
+ error: "usage: /model [model-id] [off|low|medium|high|max]",
143
+ output: "usage: /model [model-id] [off|low|medium|high|max]",
32
144
  };
33
145
  }
146
+ if (thinkingRaw && !thinkingNext) {
147
+ return {
148
+ ok: false,
149
+ error: `unknown thinking level "${thinkingRaw}" (use off|low|medium|high|max)`,
150
+ output: `unknown thinking level "${thinkingRaw}" (use off|low|medium|high|max)`,
151
+ };
152
+ }
153
+
154
+ const runtime = resolveModelRuntime(state, { ...options, model: next });
155
+ const confirmation = await confirmModelSupported({
156
+ ...runtime,
157
+ model: next,
158
+ fetchImpl: options.fetchImpl,
159
+ timeoutMs: options.timeoutMs,
160
+ skipCache: options.skipCache === true,
161
+ strict: options.strict === true,
162
+ });
163
+ if (!confirmation.allowed) {
164
+ return {
165
+ ok: false,
166
+ error: confirmation.error || `model "${next}" is not supported`,
167
+ output: confirmation.error || `model "${next}" is not supported`,
168
+ models: confirmation.models,
169
+ };
170
+ }
171
+
34
172
  const previous = String((state && state.model) || "").trim();
173
+ const previousThinking = currentThinkingLevel(state);
35
174
  if (state && typeof state === "object") state.model = next;
36
175
  try {
37
176
  saveGlobalUcodeConfig({ ucodeModel: next });
@@ -43,45 +182,82 @@ function applyUcodeModelCommand(state = {}, result = {}) {
43
182
  } catch {
44
183
  // ignore env write failures
45
184
  }
46
- const output = previous && previous !== next
185
+
186
+ const lines = [];
187
+ const modelOutput = previous && previous !== next
47
188
  ? `model switched: ${previous} → ${next}`
48
189
  : `model set: ${next}`;
190
+ lines.push(modelOutput);
191
+
192
+ let appliedThinking = "";
193
+ if (thinkingNext) {
194
+ appliedThinking = persistThinkingLevel(state, thinkingNext);
195
+ if (previousThinking && previousThinking !== appliedThinking) {
196
+ lines.push(`thinking: ${previousThinking} → ${appliedThinking}`);
197
+ } else {
198
+ lines.push(`thinking: ${appliedThinking}`);
199
+ }
200
+ }
201
+
202
+ if (confirmation.warning) lines.push(`note: ${confirmation.warning}`);
203
+ if (confirmation.ok && confirmation.models.length > 0) {
204
+ lines.push(`confirmed via models route (${confirmation.models.length} available)`);
205
+ }
49
206
  return {
50
207
  ok: true,
51
208
  error: "",
52
- output,
209
+ output: lines.join("\n"),
53
210
  model: next,
54
211
  previous,
212
+ thinking: appliedThinking || previousThinking,
213
+ warning: confirmation.warning || "",
214
+ models: confirmation.models,
55
215
  };
56
216
  }
57
217
  return {
58
218
  ok: false,
59
- error: "usage: /model [model-id]",
60
- output: "usage: /model [model-id]",
219
+ error: "usage: /model [model-id] [off|low|medium|high|max]",
220
+ output: "usage: /model [model-id] [off|low|medium|high|max]",
61
221
  };
62
222
  }
63
223
 
64
- function suggestUcodeModels(state = {}) {
224
+ /**
225
+ * Build /model completion rows. Prefer a live models-route catalog when
226
+ * provided; otherwise fall back to a small hardcoded list.
227
+ * Models are marked hasChildren so the TUI opens a thinking-intensity
228
+ * secondary menu after the id is chosen.
229
+ */
230
+ function suggestUcodeModels(state = {}, options = {}) {
65
231
  const current = String((state && state.model) || "").trim();
66
232
  const provider = String((state && state.provider) || "").trim().toLowerCase();
67
- let defaults = ["gpt-5.4", "gpt-5.3", "o3", "o4-mini"];
68
- if (provider.includes("anthropic") || provider.includes("claude")) {
69
- defaults = ["claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"];
70
- } else if (provider.includes("kimi") || provider.includes("moonshot")) {
71
- defaults = ["kimi-k2.5", "moonshot-v1-128k"];
72
- }
233
+ const remote = Array.isArray(options.models)
234
+ ? options.models.map((item) => String(item || "").trim()).filter(Boolean)
235
+ : [];
236
+ const defaults = remote.length > 0 ? remote : fallbackModelSuggestions(provider);
73
237
  const ids = [];
74
238
  if (current) ids.push(current);
75
239
  for (const id of defaults) {
76
240
  if (id && !ids.includes(id)) ids.push(id);
77
241
  }
78
- return ids.map((id) => ({
242
+ return ids.slice(0, 40).map((id) => ({
79
243
  id,
80
- desc: id === current ? "current" : "",
244
+ desc: id === current
245
+ ? "current · pick thinking next"
246
+ : (remote.length > 0 ? "models route · pick thinking next" : "pick thinking next"),
247
+ hasChildren: true,
81
248
  }));
82
249
  }
83
250
 
251
+ function suggestUcodeThinkingLevels(state = {}) {
252
+ return suggestThinkingLevels({ current: currentThinkingLevel(state) });
253
+ }
254
+
84
255
  module.exports = {
85
256
  applyUcodeModelCommand,
86
257
  suggestUcodeModels,
258
+ suggestUcodeThinkingLevels,
259
+ listUcodeModels,
260
+ fallbackModelSuggestions,
261
+ currentThinkingLevel,
262
+ persistThinkingLevel,
87
263
  };