u-foo 3.0.2 → 3.0.4

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.
@@ -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
+ };
@@ -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 = [];
@@ -1295,6 +1345,9 @@ module.exports = {
1295
1345
  normalizeModelLabel,
1296
1346
  normalizeToolLogDetail,
1297
1347
  normalizeToolMergeEntry,
1348
+ messageContentText,
1349
+ toolMessagePreview,
1350
+ redactUserMessageForLog,
1298
1351
  parseActiveAgentsFromBusStatus,
1299
1352
  planAgentsFooter,
1300
1353
  planProjectsRail,
@@ -25,6 +25,11 @@
25
25
  * component (e.g. completion popup) can
26
26
  * handle them. Plain editing keys still work.
27
27
  * placeholder (string) rendered in gray when value is empty
28
+ * onPasteText(filtered) optional. Called for non-empty paste/insert
29
+ * chunks before insertText. May return:
30
+ * string — insert that text instead
31
+ * { text } — insert text (may be "")
32
+ * null/undefined — fall back to filtered text
28
33
  *
29
34
  * Newlines: Enter submits. Use Alt+Enter (delivered as meta+Return) or end the
30
35
  * line with `\` (the legacy continuation trick) to insert a literal newline.
@@ -32,7 +37,8 @@
32
37
  * plain Enter, so it would silently submit.
33
38
  *
34
39
  * Bracketed paste arrives as a multi-byte `input` chunk in useInput; we route
35
- * it through insertText, so multi-line paste already works without extra code.
40
+ * it through insertText (or onPasteText), so multi-line paste already works
41
+ * without extra code.
36
42
  */
37
43
 
38
44
  const fmt = require("../format");
@@ -167,6 +173,7 @@ function createMultilineInput({ React, ink }) {
167
173
  // the IME composition window pops up at the visible (inverse) cursor
168
174
  // instead of at the bottom of the screen.
169
175
  linesBelowInput = 0,
176
+ onPasteText = null,
170
177
  }) {
171
178
  // Cursor is owned by this component. preferredCol tracks the visual
172
179
  // column we want to keep when bouncing across lines of different widths
@@ -392,7 +399,36 @@ function createMultilineInput({ React, ink }) {
392
399
  // Plain character / paste. Filter control bytes.
393
400
  if (input && !key.ctrl && !key.meta) {
394
401
  const filtered = input.replace(/[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]/g, "");
395
- if (filtered) insertText(filtered);
402
+ // Binary clipboard paste can strip to empty — still notify parent so
403
+ // it can try macOS PNGf clipboard ingest.
404
+ if (!filtered) {
405
+ if (typeof onPasteText === "function") {
406
+ try { onPasteText(""); } catch { /* ignore */ }
407
+ }
408
+ return;
409
+ }
410
+ if (typeof onPasteText === "function" && (filtered.length > 1 || filtered.includes("\n"))) {
411
+ let rewritten;
412
+ try {
413
+ rewritten = onPasteText(filtered);
414
+ } catch {
415
+ rewritten = filtered;
416
+ }
417
+ if (rewritten == null) {
418
+ insertText(filtered);
419
+ return;
420
+ }
421
+ if (typeof rewritten === "string") {
422
+ if (rewritten) insertText(rewritten);
423
+ return;
424
+ }
425
+ if (rewritten && typeof rewritten === "object") {
426
+ const nextText = rewritten.text == null ? filtered : String(rewritten.text);
427
+ if (nextText) insertText(nextText);
428
+ return;
429
+ }
430
+ }
431
+ insertText(filtered);
396
432
  }
397
433
  }, { isActive: interactive });
398
434
 
@@ -17,6 +17,11 @@
17
17
  const { runInk } = require("../runInk");
18
18
  const fmt = require("../format");
19
19
  const { createMultilineInput } = require("./MultilineInput");
20
+ const {
21
+ handleImagePaste,
22
+ formatUserLogWithAttachments,
23
+ buildAttachedImagesPromptPrefix,
24
+ } = require("../../code/imageIngest");
20
25
 
21
26
  // Throttle for the live thinking-chain status line: rapid thinking_delta
22
27
  // chunks would otherwise re-render the footer on every SSE event.
@@ -70,6 +75,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
70
75
  );
71
76
  const [draft, setDraft] = useState("");
72
77
  const [draftVersion, setDraftVersion] = useState(0);
78
+ const [imageAttachments, setImageAttachments] = useState([]);
73
79
  // status: idle when message === "". `type` picks a STATUS_INDICATORS
74
80
  // bucket; `showTimer` and `startedAt` reproduce the blessed spinner
75
81
  // controls. The BG suffix is computed from backgroundTasksRef and
@@ -510,12 +516,20 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
510
516
 
511
517
  const runChainRef = useRef(Promise.resolve());
512
518
 
513
- const executeLine = useCallback(async (rawValue) => {
514
- const normalized = String(rawValue || "").replace(/\r?\n/g, " ").trim();
515
- if (!normalized) return;
519
+ const executeLine = useCallback(async (rawValue, options = {}) => {
520
+ const modelSource = options.modelText != null ? options.modelText : rawValue;
521
+ const logSource = options.logText != null ? options.logText : modelSource;
522
+ const preserveNewlines = Boolean(options.preserveNewlines);
523
+ const modelNormalized = preserveNewlines
524
+ ? String(modelSource || "").trim()
525
+ : String(modelSource || "").replace(/\r?\n/g, " ").trim();
526
+ const logNormalized = fmt.redactUserMessageForLog(
527
+ String(logSource || "").replace(/\r?\n/g, " ").trim(),
528
+ );
529
+ if (!modelNormalized && !logNormalized) return;
516
530
  toolMergeScopeRef.current += 1;
517
531
  flushActiveMerge();
518
- appendLogLine(`› ${normalized}`, "user");
532
+ appendLogLine(`› ${logNormalized || modelNormalized}`, "user");
519
533
 
520
534
  const runtimeWorkspace = String(
521
535
  (props.state && props.state.workspaceRoot) || props.workspaceRoot || process.cwd()
@@ -523,7 +537,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
523
537
 
524
538
  let result;
525
539
  try {
526
- result = props.runSingleCommand(normalized, runtimeWorkspace);
540
+ result = props.runSingleCommand(modelNormalized, runtimeWorkspace);
527
541
  } catch (err) {
528
542
  appendLogText(`Error: ${err && err.message ? err.message : "command parse failed"}`, "error");
529
543
  return;
@@ -963,21 +977,27 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
963
977
 
964
978
  const submit = useCallback((submitted) => {
965
979
  const value = String(submitted == null ? draft : submitted);
980
+ const attachments = Array.isArray(imageAttachments) ? imageAttachments.slice() : [];
966
981
  const trimmed = value.trim();
967
- if (!trimmed) return;
982
+ if (!trimmed && attachments.length === 0) return;
968
983
  setDraft("");
969
984
  setDraftVersion((v) => v + 1);
985
+ setImageAttachments([]);
970
986
  setInputHistory((prev) => {
971
- const next = prev.concat([trimmed]).slice(-200);
987
+ const historyValue = formatUserLogWithAttachments(trimmed, attachments) || trimmed;
988
+ const next = prev.concat([historyValue]).slice(-200);
972
989
  setHistoryIndex(next.length);
973
990
  return next;
974
991
  });
975
992
 
993
+ const modelText = `${buildAttachedImagesPromptPrefix(attachments)}${trimmed}`.trim();
994
+ const logText = formatUserLogWithAttachments(trimmed, attachments);
995
+
976
996
  // Pending approval/choice/chat takes priority over nudge / new NL.
977
997
  try {
978
998
  const { hasPendingUserInteraction } = require("../../code/context/userInteraction");
979
999
  if (props.state && props.state.executionState && hasPendingUserInteraction(props.state.executionState)) {
980
- appendLogText(`› ${trimmed}`, "user");
1000
+ appendLogText(`› ${logText}`, "user");
981
1001
  const startedAt = Date.now();
982
1002
  setStatus({
983
1003
  message: "Applying your reply...",
@@ -1053,10 +1073,11 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
1053
1073
  if (!props.state.executionState || typeof props.state.executionState !== "object") {
1054
1074
  props.state.executionState = emptyExecutionState();
1055
1075
  }
1056
- const queued = enqueueUserPrompt(props.state.executionState, trimmed);
1076
+ const queued = enqueueUserPrompt(props.state.executionState, modelText);
1077
+ const reminderPreview = logText.slice(0, 120) + (logText.length > 120 ? "…" : "");
1057
1078
  appendLogText(
1058
1079
  queued.enqueued
1059
- ? `Queued user reminder for next model turn: ${trimmed.slice(0, 120)}${trimmed.length > 120 ? "…" : ""}`
1080
+ ? `Queued user reminder for next model turn: ${reminderPreview}`
1060
1081
  : "Could not queue user reminder (empty).",
1061
1082
  "system",
1062
1083
  );
@@ -1065,10 +1086,15 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
1065
1086
 
1066
1087
  // Serialize executions so streaming tasks don't interleave.
1067
1088
  runChainRef.current = runChainRef.current
1068
- .then(() => executeLine(value))
1089
+ .then(() => executeLine(modelText, {
1090
+ modelText,
1091
+ logText,
1092
+ preserveNewlines: attachments.length > 0,
1093
+ }))
1069
1094
  .catch((err) => appendLogText(`Error: ${err && err.message ? err.message : err}`, "error"));
1070
1095
  }, [
1071
1096
  draft,
1097
+ imageAttachments,
1072
1098
  executeLine,
1073
1099
  appendLogText,
1074
1100
  appendLogLine,
@@ -1282,6 +1308,16 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
1282
1308
  }),
1283
1309
  );
1284
1310
  })() : null,
1311
+ imageAttachments.length > 0
1312
+ ? h(Box, { flexDirection: "column", width: "100%", marginBottom: 0 },
1313
+ h(Text, { color: "cyan", dimColor: true },
1314
+ imageAttachments.map((item) => {
1315
+ const name = item.fileName || require("path").basename(String(item.relPath || "image"));
1316
+ return `[img] ${name}`;
1317
+ }).join(" "),
1318
+ ),
1319
+ )
1320
+ : null,
1285
1321
  h(Box, { width: "100%" },
1286
1322
  h(MultilineInput, {
1287
1323
  value: draft,
@@ -1292,6 +1328,33 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
1292
1328
  }
1293
1329
  setDraft(next);
1294
1330
  },
1331
+ onPasteText: (filtered) => {
1332
+ const workspaceRoot = String(
1333
+ (props.state && props.state.workspaceRoot) || props.workspaceRoot || process.cwd(),
1334
+ );
1335
+ const sessionId = String((props.state && props.state.sessionId) || "session");
1336
+ const outcome = handleImagePaste(filtered, {
1337
+ workspaceRoot,
1338
+ sessionId,
1339
+ tryClipboard: true,
1340
+ });
1341
+ if (Array.isArray(outcome.attachments) && outcome.attachments.length > 0) {
1342
+ setImageAttachments((prev) => {
1343
+ const next = prev.slice();
1344
+ for (const item of outcome.attachments) {
1345
+ if (!item || !item.relPath) continue;
1346
+ if (next.some((existing) => existing.relPath === item.relPath)) continue;
1347
+ next.push(item);
1348
+ }
1349
+ return next;
1350
+ });
1351
+ }
1352
+ if (Array.isArray(outcome.errors) && outcome.errors.length > 0 && outcome.attachments.length === 0) {
1353
+ // Soft notice only when nothing was ingested.
1354
+ appendLogText(`Image paste: ${outcome.errors[0]}`, "system");
1355
+ }
1356
+ return { text: outcome.text == null ? filtered : outcome.text };
1357
+ },
1295
1358
  onSubmit: (value) => {
1296
1359
  setCompletionSuppressedDraft(null);
1297
1360
  submit(value);