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.
- package/package.json +1 -1
- package/src/agents/prompts/native/tasks.js +4 -1
- package/src/agents/prompts/native/toolDescriptions/readImage.js +23 -0
- package/src/app/chat/commandExecutor.js +111 -1
- package/src/app/chat/commands.js +2 -1
- package/src/app/chat/daemonMessageRouter.js +1 -1
- package/src/app/chat/inputSubmitHandler.js +3 -2
- package/src/code/commands.js +3 -3
- package/src/code/context/assembler.js +17 -1
- package/src/code/context/planMode.js +2 -2
- package/src/code/context/promptLayers.js +12 -10
- package/src/code/context/reducers.js +35 -0
- package/src/code/context/transcriptSync.js +25 -5
- package/src/code/dispatch.js +8 -0
- package/src/code/imageIngest.js +367 -0
- package/src/code/modelCommand.js +199 -23
- package/src/code/nativeRunner.js +184 -20
- package/src/code/protocol/protocolValidator.js +3 -3
- package/src/code/providers/anthropicMessagesTransport.js +28 -1
- package/src/code/providers/index.js +2 -0
- package/src/code/providers/modelsCatalog.js +304 -0
- package/src/code/providers/openaiChatTransport.js +19 -1
- package/src/code/providers/visionBlocks.js +110 -0
- package/src/code/repl.js +37 -8
- package/src/code/runtime/taskControl.js +177 -53
- package/src/code/runtime/taskFocus.js +30 -10
- package/src/code/runtime/taskLoop.js +12 -1
- package/src/code/runtime/taskRun.js +10 -1
- package/src/code/thinkingLevels.js +132 -0
- package/src/code/tools/readImage.js +110 -0
- package/src/code/tools/taskRun.js +118 -0
- package/src/config.js +10 -1
- package/src/ui/format/index.js +103 -5
- package/src/ui/ink/ChatApp.js +137 -25
- package/src/ui/ink/MultilineInput.js +38 -2
- package/src/ui/ink/UcodeApp.js +102 -14
- package/src/ui/ink/chatLogModel.js +238 -32
- 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
|
+
};
|
package/src/code/modelCommand.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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
|
};
|