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
@@ -165,38 +165,58 @@ function loadChatHistory(projectRoot, cap = 200, options = {}) {
165
165
  const raw = fs.readFileSync(file, "utf8");
166
166
  const lines = raw.split(/\r?\n/).filter(Boolean);
167
167
  const out = [];
168
- const pushLine = (line = "") => {
168
+ const pushLine = (line = "", sourceType = "") => {
169
169
  const value = String(line || "");
170
170
  if (!value.trim()) {
171
- if (out.length > 0 && out[out.length - 1] !== "") out.push("");
171
+ if (out.length > 0) {
172
+ const last = out[out.length - 1];
173
+ const lastText = typeof last === "object" ? last.text : last;
174
+ if (lastText !== "") out.push({ text: "", sourceType: sourceType || "system" });
175
+ }
172
176
  return;
173
177
  }
174
- out.push(value);
178
+ out.push(sourceType ? { text: value, sourceType } : value);
175
179
  };
176
180
  for (const line of lines) {
177
181
  try {
178
182
  const entry = JSON.parse(line);
179
183
  if (!entry) continue;
180
184
  if (entry.type === "spacer") {
181
- pushLine("");
185
+ pushLine("", "system");
182
186
  continue;
183
187
  }
184
188
  const text = String(entry.text || "");
185
189
  if (!text) continue;
190
+ const sourceType = String(entry.type || "");
186
191
  // Strip blessed-tag markup that the legacy log writer used; ink
187
192
  // can't render those tags and we don't want them shown literally.
188
193
  const stripped = text.replace(/\{[^{}]+\}/g, "");
189
194
  for (const renderedLine of normalizeInkLogLines(stripped)) {
190
- pushLine(renderedLine);
195
+ pushLine(renderedLine, sourceType);
191
196
  }
192
197
  } catch {
193
198
  // ignore malformed lines
194
199
  }
195
200
  }
196
- while (out.length > 0 && out[0] === "") out.shift();
197
- while (out.length > 0 && out[out.length - 1] === "") out.pop();
201
+ while (out.length > 0) {
202
+ const first = out[0];
203
+ const firstText = typeof first === "object" ? first.text : first;
204
+ if (firstText !== "") break;
205
+ out.shift();
206
+ }
207
+ while (out.length > 0) {
208
+ const last = out[out.length - 1];
209
+ const lastText = typeof last === "object" ? last.text : last;
210
+ if (lastText !== "") break;
211
+ out.pop();
212
+ }
198
213
  const capped = out.slice(-cap);
199
- while (capped.length > 0 && capped[0] === "") capped.shift();
214
+ while (capped.length > 0) {
215
+ const first = capped[0];
216
+ const firstText = typeof first === "object" ? first.text : first;
217
+ if (firstText !== "") break;
218
+ capped.shift();
219
+ }
200
220
  return capped;
201
221
  } catch {
202
222
  return [];
@@ -435,12 +455,26 @@ function createThrottledSender(send, windowMs = 500) {
435
455
  // Kinds whose log entries render as a margin-bottom "transcript cell" in
436
456
  // buildChatLogGroups. Kept in sync with canAppendToChatLogGroup in
437
457
  // chatLogModel.js.
438
- const STATIC_GROUPABLE_KINDS = new Set(["assistant", "agent", "success", "error", "meta", "plain"]);
458
+ const STATIC_GROUPABLE_KINDS = new Set([
459
+ "assistant",
460
+ "agent",
461
+ "report",
462
+ "success",
463
+ "error",
464
+ "meta",
465
+ "system",
466
+ "plain",
467
+ ]);
439
468
 
440
469
  // Shared row colors for both the dynamic (stream) and <Static> renderers.
470
+ // Aligned with ucode LOG_LINE_TEXT_PROPS: user green+bold, system dim gray,
471
+ // team bus/agent cyan, ufoo assistant white/bold marker.
441
472
  const CHAT_LOG_ROW_PALETTE = {
473
+ user: { marker: "green", speaker: "green", body: "green", bold: true },
442
474
  assistant: { marker: "cyan", speaker: "white", body: undefined, bold: true },
443
475
  agent: { marker: "cyan", speaker: "cyan", body: undefined, bold: false },
476
+ report: { marker: "yellow", speaker: "yellow", body: undefined, bold: false },
477
+ system: { marker: "gray", speaker: "gray", body: "gray", bold: false, dim: true },
444
478
  error: { marker: "red", speaker: "red", body: "red", bold: true },
445
479
  success: { marker: "green", speaker: "green", body: "green", bold: false },
446
480
  divider: { marker: "gray", speaker: "gray", body: "gray", bold: false },
@@ -460,21 +494,39 @@ function decorateStaticLogEntry(prev, entry) {
460
494
  const markdownState = prev && prev.markdownState && typeof prev.markdownState === "object"
461
495
  ? { inCodeBlock: Boolean(prev.markdownState.inCodeBlock) }
462
496
  : { inCodeBlock: false };
463
- const sourceText = entry && typeof entry === "object" && entry.text != null
464
- ? String(entry.text)
465
- : entry;
466
- const row = buildChatLogLineModel(sourceText, { markdownState });
497
+ const source = entry && typeof entry === "object" ? entry : { text: entry };
498
+ const sourceText = source.text != null ? String(source.text) : String(entry || "");
499
+ const sourceType = String(source.sourceType || source.type || "");
500
+ const meta = source.meta && typeof source.meta === "object" ? source.meta : {};
501
+ const row = buildChatLogLineModel({
502
+ ...source,
503
+ text: sourceText,
504
+ sourceType,
505
+ meta,
506
+ }, { markdownState, sourceType, meta });
467
507
  const continuation = Boolean(
468
508
  prev
469
- && (row.kind === "plain" || row.kind === "spacer")
470
- && STATIC_GROUPABLE_KINDS.has(prev.groupKind)
509
+ && (
510
+ ((row.kind === "plain" || row.kind === "spacer") && STATIC_GROUPABLE_KINDS.has(prev.groupKind))
511
+ || (prev.groupKind === "user" && row.kind === "user" && row.marker !== "›")
512
+ )
471
513
  );
472
514
  const groupKind = continuation ? prev.groupKind : row.kind;
473
515
  // A gap belongs between visual blocks: only on entries that START a new
474
516
  // block, and only when the previous block was a transcript group (whose
475
517
  // old dynamic renderer contributed a trailing marginBottom).
476
- const marginBefore = Boolean(!continuation && prev && STATIC_GROUPABLE_KINDS.has(prev.groupKind));
477
- return { entry, row, groupKind, continuation, marginBefore, markdownState };
518
+ // User turns also get a leading gap so › prompts don't sit flush against
519
+ // the previous transcript cell (ucode parity).
520
+ const marginBefore = Boolean(
521
+ !continuation
522
+ && prev
523
+ && (
524
+ STATIC_GROUPABLE_KINDS.has(prev.groupKind)
525
+ || prev.groupKind === "user"
526
+ || row.kind === "user"
527
+ )
528
+ );
529
+ return { entry: source, row, groupKind, continuation, marginBefore, markdownState };
478
530
  }
479
531
 
480
532
  function createInkStreamState({
@@ -1315,7 +1367,15 @@ function createChatApp({ React, ink, props, interactive = true }) {
1315
1367
  }
1316
1368
  const lines = normalizeInkLogLines(text);
1317
1369
  if (lines.length === 0) return;
1318
- dispatch({ type: "log/appendMany", lines });
1370
+ const payload = lines.map((line, index) => ({
1371
+ text: line,
1372
+ type,
1373
+ sourceType: type,
1374
+ // Attach router meta only on the first physical line so multi-line
1375
+ // bus/reply bodies don't duplicate publisher payloads.
1376
+ meta: index === 0 && meta && typeof meta === "object" ? meta : {},
1377
+ }));
1378
+ dispatch({ type: "log/appendMany", lines: payload });
1319
1379
  appendScopedHistory(type, stripBlessedTags(text), meta);
1320
1380
  }, [appendScopedHistory, setStatusText]);
1321
1381
 
@@ -3505,6 +3565,8 @@ function createChatApp({ React, ink, props, interactive = true }) {
3505
3565
  return buildChatLogGroups(lines.map((line, idx) => ({
3506
3566
  id: `s-${idx}`,
3507
3567
  text: idx === 0 ? `${prefix}${line}` : ` ${line}`,
3568
+ sourceType: "bus",
3569
+ type: "bus",
3508
3570
  })));
3509
3571
  }, [state.activeStream]);
3510
3572
 
@@ -3512,6 +3574,18 @@ function createChatApp({ React, ink, props, interactive = true }) {
3512
3574
  return null;
3513
3575
  }
3514
3576
 
3577
+ const renderUserLogBody = (bodyText = "") => {
3578
+ const body = String(bodyText || "");
3579
+ const atMatch = body.match(/^@([^\s]+)\s+(.*)$/);
3580
+ if (atMatch) {
3581
+ return {
3582
+ at: atMatch[1],
3583
+ rest: atMatch[2] || "",
3584
+ };
3585
+ }
3586
+ return { at: "", rest: body };
3587
+ };
3588
+
3515
3589
  const renderChatLogEntry = (entry, group) => {
3516
3590
  const row = entry && entry.row ? entry.row : buildChatLogLineModel("");
3517
3591
  const key = entry && entry.id ? entry.id : `log-${row.body}`;
@@ -3529,12 +3603,31 @@ function createChatApp({ React, ink, props, interactive = true }) {
3529
3603
  h(Text, { color: colors.body, bold: true, wrap: "truncate" }, row.body),
3530
3604
  );
3531
3605
  }
3606
+ if (row.kind === "user") {
3607
+ const userBody = renderUserLogBody(row.bodyText);
3608
+ return h(Box, { key, width: "100%", marginBottom: 1 },
3609
+ h(Text, { color: "green", bold: true }, row.markerText || "› "),
3610
+ userBody.at
3611
+ ? h(Text, { color: "magenta", bold: true }, `@${userBody.at} `)
3612
+ : null,
3613
+ h(Text, { color: "green", bold: true, wrap: "wrap" }, userBody.rest),
3614
+ );
3615
+ }
3532
3616
  const markerText = entry && entry.continuation
3533
- ? (group && (group.kind === "assistant" || group.kind === "agent") ? " " : " ")
3617
+ ? (group && (group.kind === "assistant" || group.kind === "agent" || group.kind === "report") ? " " : " ")
3534
3618
  : row.markerText;
3619
+ const bodyProps = {
3620
+ color: colors.body,
3621
+ wrap: "wrap",
3622
+ };
3623
+ if (colors.dim) bodyProps.dimColor = true;
3535
3624
  return h(Box, { key, width: "100%" },
3536
- h(Text, { color: colors.marker, bold: row.kind === "error" }, markerText),
3537
- h(Text, { color: colors.body, wrap: "wrap" },
3625
+ h(Text, {
3626
+ color: colors.marker,
3627
+ bold: row.kind === "error" || row.kind === "assistant",
3628
+ dimColor: Boolean(colors.dim),
3629
+ }, markerText),
3630
+ h(Text, bodyProps,
3538
3631
  row.speaker && !(entry && entry.continuation)
3539
3632
  ? h(Text, { color: colors.speaker, bold: colors.bold }, row.speaker)
3540
3633
  : null,
@@ -3551,7 +3644,7 @@ function createChatApp({ React, ink, props, interactive = true }) {
3551
3644
  if (entries.length === 0) return null;
3552
3645
  const first = entries[0] || {};
3553
3646
  const row = first.row || buildChatLogLineModel("");
3554
- if (row.kind === "spacer" || row.kind === "banner" || row.kind === "divider") {
3647
+ if (row.kind === "spacer" || row.kind === "banner" || row.kind === "divider" || row.kind === "user") {
3555
3648
  return renderChatLogEntry(first, group);
3556
3649
  }
3557
3650
  return h(Box, {
@@ -3587,12 +3680,31 @@ function createChatApp({ React, ink, props, interactive = true }) {
3587
3680
  h(Text, { color: colors.body, bold: true, wrap: "truncate" }, row.body),
3588
3681
  );
3589
3682
  }
3683
+ if (row.kind === "user") {
3684
+ const userBody = renderUserLogBody(row.bodyText);
3685
+ return h(Box, { key, width: "100%", marginTop, marginBottom: 1 },
3686
+ h(Text, { color: "green", bold: true }, row.markerText || "› "),
3687
+ userBody.at
3688
+ ? h(Text, { color: "magenta", bold: true }, `@${userBody.at} `)
3689
+ : null,
3690
+ h(Text, { color: "green", bold: true, wrap: "wrap" }, userBody.rest),
3691
+ );
3692
+ }
3590
3693
  const markerText = continuation
3591
- ? (groupKind === "assistant" || groupKind === "agent" ? " " : " ")
3694
+ ? (groupKind === "assistant" || groupKind === "agent" || groupKind === "report" ? " " : " ")
3592
3695
  : row.markerText;
3696
+ const bodyProps = {
3697
+ color: colors.body,
3698
+ wrap: "wrap",
3699
+ };
3700
+ if (colors.dim) bodyProps.dimColor = true;
3593
3701
  return h(Box, { key, width: "100%", marginTop },
3594
- h(Text, { color: colors.marker, bold: row.kind === "error" }, markerText),
3595
- h(Text, { color: colors.body, wrap: "wrap" },
3702
+ h(Text, {
3703
+ color: colors.marker,
3704
+ bold: row.kind === "error" || row.kind === "assistant",
3705
+ dimColor: Boolean(colors.dim),
3706
+ }, markerText),
3707
+ h(Text, bodyProps,
3596
3708
  row.speaker && !continuation
3597
3709
  ? h(Text, { color: colors.speaker, bold: colors.bold }, row.speaker)
3598
3710
  : null,
@@ -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
@@ -323,14 +329,36 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
323
329
 
324
330
  const { UCODE_COMMAND_REGISTRY, UCODE_COMMAND_TREE } = require("../../code/commands");
325
331
  const { listSessionSummaries } = require("../../code/sessionStore");
326
- const { suggestUcodeModels, applyUcodeModelCommand } = require("../../code/modelCommand");
332
+ const { suggestUcodeModels, suggestUcodeThinkingLevels, applyUcodeModelCommand, listUcodeModels } = require("../../code/modelCommand");
327
333
  let resumeSessions = [];
328
334
  try {
329
335
  resumeSessions = listSessionSummaries(props.workspaceRoot || process.cwd(), { limit: 40 });
330
336
  } catch {
331
337
  resumeSessions = [];
332
338
  }
333
- const modelSuggestions = suggestUcodeModels(props.state || {});
339
+ const [remoteModels, setRemoteModels] = useState([]);
340
+ useEffect(() => {
341
+ let cancelled = false;
342
+ (async () => {
343
+ try {
344
+ const listed = await listUcodeModels(props.state || {}, {
345
+ workspaceRoot: props.workspaceRoot || process.cwd(),
346
+ });
347
+ if (!cancelled && listed.ok) {
348
+ setRemoteModels(Array.isArray(listed.models) ? listed.models : []);
349
+ }
350
+ } catch {
351
+ if (!cancelled) setRemoteModels([]);
352
+ }
353
+ })();
354
+ return () => { cancelled = true; };
355
+ }, [
356
+ props.workspaceRoot,
357
+ props.state && props.state.provider,
358
+ props.state && props.state.model,
359
+ ]);
360
+ const modelSuggestions = suggestUcodeModels(props.state || {}, { models: remoteModels });
361
+ const thinkingSuggestions = suggestUcodeThinkingLevels(props.state || {});
334
362
 
335
363
  const completions = fmt.buildCompletions({
336
364
  text: draft,
@@ -341,6 +369,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
341
369
  argumentLists: {
342
370
  "/resume": resumeSessions,
343
371
  "/model": modelSuggestions,
372
+ "/model/thinking": thinkingSuggestions,
344
373
  },
345
374
  limit: 20,
346
375
  });
@@ -487,12 +516,20 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
487
516
 
488
517
  const runChainRef = useRef(Promise.resolve());
489
518
 
490
- const executeLine = useCallback(async (rawValue) => {
491
- const normalized = String(rawValue || "").replace(/\r?\n/g, " ").trim();
492
- 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;
493
530
  toolMergeScopeRef.current += 1;
494
531
  flushActiveMerge();
495
- appendLogLine(`› ${normalized}`, "user");
532
+ appendLogLine(`› ${logNormalized || modelNormalized}`, "user");
496
533
 
497
534
  const runtimeWorkspace = String(
498
535
  (props.state && props.state.workspaceRoot) || props.workspaceRoot || process.cwd()
@@ -500,7 +537,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
500
537
 
501
538
  let result;
502
539
  try {
503
- result = props.runSingleCommand(normalized, runtimeWorkspace);
540
+ result = props.runSingleCommand(modelNormalized, runtimeWorkspace);
504
541
  } catch (err) {
505
542
  appendLogText(`Error: ${err && err.message ? err.message : "command parse failed"}`, "error");
506
543
  return;
@@ -541,7 +578,9 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
541
578
  return;
542
579
  }
543
580
  case "model": {
544
- const applied = applyUcodeModelCommand(props.state || {}, result);
581
+ const applied = await applyUcodeModelCommand(props.state || {}, result, {
582
+ workspaceRoot: runtimeWorkspace,
583
+ });
545
584
  appendLogText(applied.output || "", applied.ok ? "system" : "error");
546
585
  if (applied.ok && result.action === "set" && typeof props.persistSessionState === "function") {
547
586
  try {
@@ -938,21 +977,27 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
938
977
 
939
978
  const submit = useCallback((submitted) => {
940
979
  const value = String(submitted == null ? draft : submitted);
980
+ const attachments = Array.isArray(imageAttachments) ? imageAttachments.slice() : [];
941
981
  const trimmed = value.trim();
942
- if (!trimmed) return;
982
+ if (!trimmed && attachments.length === 0) return;
943
983
  setDraft("");
944
984
  setDraftVersion((v) => v + 1);
985
+ setImageAttachments([]);
945
986
  setInputHistory((prev) => {
946
- const next = prev.concat([trimmed]).slice(-200);
987
+ const historyValue = formatUserLogWithAttachments(trimmed, attachments) || trimmed;
988
+ const next = prev.concat([historyValue]).slice(-200);
947
989
  setHistoryIndex(next.length);
948
990
  return next;
949
991
  });
950
992
 
993
+ const modelText = `${buildAttachedImagesPromptPrefix(attachments)}${trimmed}`.trim();
994
+ const logText = formatUserLogWithAttachments(trimmed, attachments);
995
+
951
996
  // Pending approval/choice/chat takes priority over nudge / new NL.
952
997
  try {
953
998
  const { hasPendingUserInteraction } = require("../../code/context/userInteraction");
954
999
  if (props.state && props.state.executionState && hasPendingUserInteraction(props.state.executionState)) {
955
- appendLogText(`› ${trimmed}`, "user");
1000
+ appendLogText(`› ${logText}`, "user");
956
1001
  const startedAt = Date.now();
957
1002
  setStatus({
958
1003
  message: "Applying your reply...",
@@ -1028,10 +1073,11 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
1028
1073
  if (!props.state.executionState || typeof props.state.executionState !== "object") {
1029
1074
  props.state.executionState = emptyExecutionState();
1030
1075
  }
1031
- 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 ? "…" : "");
1032
1078
  appendLogText(
1033
1079
  queued.enqueued
1034
- ? `Queued user reminder for next model turn: ${trimmed.slice(0, 120)}${trimmed.length > 120 ? "…" : ""}`
1080
+ ? `Queued user reminder for next model turn: ${reminderPreview}`
1035
1081
  : "Could not queue user reminder (empty).",
1036
1082
  "system",
1037
1083
  );
@@ -1040,10 +1086,15 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
1040
1086
 
1041
1087
  // Serialize executions so streaming tasks don't interleave.
1042
1088
  runChainRef.current = runChainRef.current
1043
- .then(() => executeLine(value))
1089
+ .then(() => executeLine(modelText, {
1090
+ modelText,
1091
+ logText,
1092
+ preserveNewlines: attachments.length > 0,
1093
+ }))
1044
1094
  .catch((err) => appendLogText(`Error: ${err && err.message ? err.message : err}`, "error"));
1045
1095
  }, [
1046
1096
  draft,
1097
+ imageAttachments,
1047
1098
  executeLine,
1048
1099
  appendLogText,
1049
1100
  appendLogLine,
@@ -1257,6 +1308,16 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
1257
1308
  }),
1258
1309
  );
1259
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,
1260
1321
  h(Box, { width: "100%" },
1261
1322
  h(MultilineInput, {
1262
1323
  value: draft,
@@ -1267,6 +1328,33 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
1267
1328
  }
1268
1329
  setDraft(next);
1269
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
+ },
1270
1358
  onSubmit: (value) => {
1271
1359
  setCompletionSuppressedDraft(null);
1272
1360
  submit(value);