@bubblebrain-ai/bubble 0.0.2 → 0.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.
@@ -14,6 +14,7 @@ import path from "node:path";
14
14
  import { promisify } from "node:util";
15
15
  const execFileAsync = promisify(execFile);
16
16
  const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp)$/i;
17
+ const IMAGE_EXT_SOURCE = String.raw `(?:png|jpe?g|gif|webp|bmp)`;
17
18
  // Anthropic/OpenAI image uploads cap at ~5MB base64. We target a bit below so
18
19
  // the base64 inflation (4/3) doesn't push us over.
19
20
  const MAX_BASE64_BYTES = 5 * 1024 * 1024;
@@ -30,6 +31,112 @@ export function isImageFilePath(raw) {
30
31
  // be treated as a path.
31
32
  return path.isAbsolute(s) || s.startsWith("~") || /^[A-Za-z]:\\/.test(s);
32
33
  }
34
+ export function extractImagePathTokens(input) {
35
+ const pattern = new RegExp(String.raw `(^|\s)(?:"([^"]+\.${IMAGE_EXT_SOURCE})"|'([^']+\.${IMAGE_EXT_SOURCE})'|((?:~|\/|[A-Za-z]:\\)(?:\\ |[^\s"'<>])+\.${IMAGE_EXT_SOURCE}))(?=$|\s)`, "gi");
36
+ const tokens = [];
37
+ for (const match of input.matchAll(pattern)) {
38
+ const leading = match[1] ?? "";
39
+ const rawPath = match[2] ?? match[3] ?? match[4];
40
+ if (!rawPath || !isImageFilePath(rawPath))
41
+ continue;
42
+ const start = (match.index ?? 0) + leading.length;
43
+ const end = (match.index ?? 0) + match[0].length;
44
+ tokens.push({ rawPath, start, end });
45
+ }
46
+ return tokens;
47
+ }
48
+ export function removeImagePathTokens(input, tokens) {
49
+ if (tokens.length === 0)
50
+ return input.trim();
51
+ let out = "";
52
+ let cursor = 0;
53
+ for (const token of tokens) {
54
+ out += input.slice(cursor, token.start);
55
+ out += " ";
56
+ cursor = token.end;
57
+ }
58
+ out += input.slice(cursor);
59
+ return out
60
+ .replace(/[ \t]+/g, " ")
61
+ .replace(/ *\n */g, "\n")
62
+ .replace(/\n{3,}/g, "\n\n")
63
+ .trim();
64
+ }
65
+ export function imageAttachmentLabel(att, index) {
66
+ return `image#${index}${imageExtension(att)}`;
67
+ }
68
+ export function imageAttachmentReference(att, index) {
69
+ return `[${imageAttachmentLabel(att, index)}]`;
70
+ }
71
+ export function imageAttachmentLabelPattern() {
72
+ return /\[image#(\d+)\.[^\]\s]+\]/g;
73
+ }
74
+ function defaultImagePrompt(count) {
75
+ return count === 1
76
+ ? "Please analyze the attached image."
77
+ : "Please analyze the attached images.";
78
+ }
79
+ function imageExtension(att) {
80
+ const fromPath = path.extname(att.filename ?? att.sourcePath ?? "").toLowerCase();
81
+ if (fromPath)
82
+ return fromPath;
83
+ if (att.mediaType === "image/jpeg")
84
+ return ".jpg";
85
+ if (att.mediaType === "image/webp")
86
+ return ".webp";
87
+ if (att.mediaType === "image/gif")
88
+ return ".gif";
89
+ if (att.mediaType === "image/bmp")
90
+ return ".bmp";
91
+ return ".png";
92
+ }
93
+ export function buildImageContentParts(promptText, attachments) {
94
+ const text = promptText.trim() || defaultImagePrompt(attachments.length);
95
+ return [
96
+ { type: "text", text },
97
+ ...attachments.map((attachment) => ({
98
+ type: "image_url",
99
+ image_url: { url: attachment.dataUrl },
100
+ })),
101
+ ];
102
+ }
103
+ export function formatImageDisplayInput(promptText, attachments, labelStart = 1) {
104
+ const text = promptText.trim() || defaultImagePrompt(attachments.length);
105
+ const imageLines = attachments.map((attachment, index) => imageAttachmentReference(attachment, labelStart + index));
106
+ return `${text}\n${imageLines.join("\n")}`;
107
+ }
108
+ export function buildImageContentPartsFromLabels(input, attachmentsByLabel) {
109
+ const matches = Array.from(input.matchAll(imageAttachmentLabelPattern()));
110
+ const usedLabels = [];
111
+ const parts = [];
112
+ let cursor = 0;
113
+ for (const match of matches) {
114
+ const label = match[0].slice(1, -1);
115
+ const attachment = attachmentsByLabel.get(label);
116
+ if (!attachment)
117
+ continue;
118
+ const start = match.index ?? 0;
119
+ const before = input.slice(cursor, start).trim();
120
+ if (before)
121
+ parts.push({ type: "text", text: before });
122
+ parts.push({ type: "image_url", image_url: { url: attachment.dataUrl } });
123
+ usedLabels.push(label);
124
+ cursor = start + match[0].length;
125
+ }
126
+ if (usedLabels.length === 0)
127
+ return { displayInput: input, usedLabels: [] };
128
+ const rest = input.slice(cursor).trim();
129
+ if (rest)
130
+ parts.push({ type: "text", text: rest });
131
+ if (!parts.some((part) => part.type === "text")) {
132
+ parts.unshift({ type: "text", text: defaultImagePrompt(usedLabels.length) });
133
+ }
134
+ return {
135
+ actualInput: parts,
136
+ displayInput: input.trim() || usedLabels.map((label) => `[${label}]`).join("\n"),
137
+ usedLabels,
138
+ };
139
+ }
33
140
  /**
34
141
  * Split a pasted blob into candidate path tokens.
35
142
  *
@@ -286,3 +393,113 @@ export async function ingestClipboardImage() {
286
393
  return { error: validation.reason };
287
394
  return { attachment: sized };
288
395
  }
396
+ export async function resolveImageInput(input, options = {}) {
397
+ const tokens = extractImagePathTokens(input);
398
+ if (tokens.length === 0) {
399
+ return {
400
+ actualInput: input,
401
+ displayInput: input,
402
+ errors: [],
403
+ attachments: [],
404
+ imagePathCount: 0,
405
+ };
406
+ }
407
+ const attachments = [];
408
+ const errors = [];
409
+ const attachmentsByToken = new Map();
410
+ let nextLabelIndex = options.labelStart ?? 1;
411
+ for (const token of tokens) {
412
+ const result = await ingestImagePath(token.rawPath);
413
+ if (result.attachment) {
414
+ attachments.push(result.attachment);
415
+ attachmentsByToken.set(token, {
416
+ attachment: result.attachment,
417
+ label: imageAttachmentLabel(result.attachment, nextLabelIndex++),
418
+ });
419
+ }
420
+ else {
421
+ errors.push(`${token.rawPath}: ${result.error ?? "could not attach image"}`);
422
+ }
423
+ }
424
+ if (attachments.length === 0) {
425
+ return {
426
+ actualInput: input,
427
+ displayInput: input,
428
+ errors,
429
+ attachments: [],
430
+ imagePathCount: tokens.length,
431
+ };
432
+ }
433
+ const parts = [];
434
+ let displayInput = "";
435
+ let cursor = 0;
436
+ for (const token of tokens) {
437
+ const entry = attachmentsByToken.get(token);
438
+ if (!entry)
439
+ continue;
440
+ const before = input.slice(cursor, token.start);
441
+ displayInput += before;
442
+ const text = before.trim();
443
+ if (text)
444
+ parts.push({ type: "text", text });
445
+ parts.push({ type: "image_url", image_url: { url: entry.attachment.dataUrl } });
446
+ displayInput += `[${entry.label}]`;
447
+ cursor = token.end;
448
+ }
449
+ const rest = input.slice(cursor);
450
+ displayInput += rest;
451
+ const restText = rest.trim();
452
+ if (restText)
453
+ parts.push({ type: "text", text: restText });
454
+ if (!parts.some((part) => part.type === "text")) {
455
+ parts.unshift({ type: "text", text: defaultImagePrompt(attachments.length) });
456
+ }
457
+ return {
458
+ actualInput: parts,
459
+ displayInput: displayInput.trim(),
460
+ errors,
461
+ attachments,
462
+ imagePathCount: tokens.length,
463
+ };
464
+ }
465
+ export async function resolveComposerImagePaths(input, options = {}) {
466
+ const tokens = extractImagePathTokens(input);
467
+ let nextLabelIndex = options.labelStart ?? 1;
468
+ if (tokens.length === 0) {
469
+ return {
470
+ text: input,
471
+ attachments: [],
472
+ errors: [],
473
+ imagePathCount: 0,
474
+ nextLabelIndex,
475
+ };
476
+ }
477
+ const errors = [];
478
+ const attachments = [];
479
+ const replacements = new Map();
480
+ for (const token of tokens) {
481
+ const result = await ingestImagePath(token.rawPath);
482
+ if (!result.attachment) {
483
+ errors.push(`${token.rawPath}: ${result.error ?? "could not attach image"}`);
484
+ continue;
485
+ }
486
+ const label = imageAttachmentLabel(result.attachment, nextLabelIndex++);
487
+ attachments.push({ ...result.attachment, label });
488
+ replacements.set(token, `[${label}]`);
489
+ }
490
+ let text = "";
491
+ let cursor = 0;
492
+ for (const token of tokens) {
493
+ text += input.slice(cursor, token.start);
494
+ text += replacements.get(token) ?? input.slice(token.start, token.end);
495
+ cursor = token.end;
496
+ }
497
+ text += input.slice(cursor);
498
+ return {
499
+ text,
500
+ attachments,
501
+ errors,
502
+ imagePathCount: tokens.length,
503
+ nextLabelIndex,
504
+ };
505
+ }
package/dist/tui/run.js CHANGED
@@ -20,6 +20,7 @@ import { inferBashPrefix } from "../approval/session-cache.js";
20
20
  import { createFrames } from "./opencode-spinner.js";
21
21
  import { copyTextToClipboard } from "./clipboard.js";
22
22
  import { readGitSidebarState } from "./sidebar-state.js";
23
+ import { buildImageContentPartsFromLabels, imageAttachmentLabelPattern, resolveComposerImagePaths, resolveImageInput, } from "./image-paste.js";
23
24
  import { isModeCycleKeyEvent, isModeCycleSequence, isModifiedEnterSequence, PROMPT_TEXTAREA_KEYBINDINGS, } from "./prompt-keybindings.js";
24
25
  import { keyNameFromEvent, keyNameFromSequence } from "./global-key-router.js";
25
26
  const treeSitterClient = getTreeSitterClient();
@@ -263,6 +264,10 @@ function OpenTuiApp(props) {
263
264
  .filter((message) => message.role === "user" && message.content !== "(multimedia)")
264
265
  .map((message) => message.content)
265
266
  .slice(-PROMPT_HISTORY_LIMIT);
267
+ let nextImageAttachmentIndex = nextImageLabelIndex(displayMessages);
268
+ const pendingImageAttachments = new Map();
269
+ let composerImageResolutionSeq = 0;
270
+ let applyingComposerImageReplacement = false;
266
271
  let promptHistoryIndex;
267
272
  let promptHistoryDraft = "";
268
273
  const [isRunning, setIsRunning] = createSignal(false);
@@ -387,6 +392,10 @@ function OpenTuiApp(props) {
387
392
  const sidebarLspRows = [];
388
393
  const sidebarLspMarkers = [];
389
394
  const sidebarLspLabels = [];
395
+ let sidebarTodoSection;
396
+ const sidebarTodoRows = [];
397
+ const sidebarTodoMarkers = [];
398
+ const sidebarTodoLabels = [];
390
399
  const sidebarFileRows = [];
391
400
  const sidebarFileLabels = [];
392
401
  const sidebarFileAdditions = [];
@@ -621,6 +630,12 @@ function OpenTuiApp(props) {
621
630
  setSidebarTick((value) => value + 1);
622
631
  syncSidebarContext();
623
632
  };
633
+ const syncTodosFromAgent = () => {
634
+ const nextTodos = props.agent.getTodos();
635
+ setTodos(nextTodos);
636
+ syncSidebarTodos(nextTodos);
637
+ bumpSidebar();
638
+ };
624
639
  function refreshGitSidebar() {
625
640
  setGitState(readGitSidebarState(props.args.cwd));
626
641
  syncSidebarFiles();
@@ -721,6 +736,35 @@ function OpenTuiApp(props) {
721
736
  }
722
737
  sidebarShell?.requestRender();
723
738
  }
739
+ function syncSidebarTodos(nextTodos = todos()) {
740
+ const visible = nextTodos.slice(0, 8);
741
+ if (sidebarTodoSection) {
742
+ sidebarTodoSection.visible = visible.length > 0;
743
+ }
744
+ for (let index = 0; index < 8; index++) {
745
+ const row = sidebarTodoRows[index];
746
+ const marker = sidebarTodoMarkers[index];
747
+ const label = sidebarTodoLabels[index];
748
+ const todo = visible[index];
749
+ if (!row || !marker || !label)
750
+ continue;
751
+ row.visible = !!todo;
752
+ if (!todo) {
753
+ safeRequestRender(row);
754
+ continue;
755
+ }
756
+ const completed = todo.status === "completed";
757
+ const inProgress = todo.status === "in_progress";
758
+ const labelText = inProgress ? (todo.activeForm || todo.content) : todo.content;
759
+ marker.content = completed ? "✓" : inProgress ? "◉" : "○";
760
+ marker.fg = completed ? theme.success : inProgress ? theme.warning : theme.textMuted;
761
+ label.content = labelText;
762
+ label.fg = completed ? theme.success : inProgress ? theme.warning : theme.textMuted;
763
+ safeRequestRender(row);
764
+ }
765
+ sidebarShell?.requestRender();
766
+ rootBox?.requestRender();
767
+ }
724
768
  function showSidebarLspRows(statuses) {
725
769
  for (let index = 0; index < sidebarLspRows.length; index++) {
726
770
  const row = sidebarLspRows[index];
@@ -2689,6 +2733,9 @@ function OpenTuiApp(props) {
2689
2733
  function onPromptContentChange(value) {
2690
2734
  const nextValue = typeof value === "string" ? value : readPromptText();
2691
2735
  promptText = nextValue;
2736
+ if (!applyingComposerImageReplacement) {
2737
+ void applyComposerImagePathReplacement(nextValue);
2738
+ }
2692
2739
  if (promptHistoryIndex !== undefined
2693
2740
  && nextValue !== (promptHistory[promptHistoryIndex] ?? "")) {
2694
2741
  resetPromptHistoryBrowse();
@@ -2956,8 +3003,62 @@ function OpenTuiApp(props) {
2956
3003
  setPromptText(`/${skillName} `);
2957
3004
  redrawDock();
2958
3005
  }
3006
+ async function applyComposerImagePathReplacement(snapshot) {
3007
+ const seq = ++composerImageResolutionSeq;
3008
+ const result = await resolveComposerImagePaths(snapshot, { labelStart: nextImageAttachmentIndex });
3009
+ if (seq !== composerImageResolutionSeq)
3010
+ return;
3011
+ if (result.attachments.length === 0)
3012
+ return;
3013
+ if ((readPromptText() || promptText) !== snapshot)
3014
+ return;
3015
+ for (const attachment of result.attachments) {
3016
+ pendingImageAttachments.set(attachment.label, attachment);
3017
+ }
3018
+ nextImageAttachmentIndex = Math.max(nextImageAttachmentIndex, result.nextLabelIndex);
3019
+ applyingComposerImageReplacement = true;
3020
+ try {
3021
+ setPromptText(result.text);
3022
+ }
3023
+ finally {
3024
+ applyingComposerImageReplacement = false;
3025
+ }
3026
+ }
3027
+ async function expandTextParts(parts) {
3028
+ const expandedParts = [];
3029
+ for (const part of parts) {
3030
+ if (part.type !== "text") {
3031
+ expandedParts.push(part);
3032
+ continue;
3033
+ }
3034
+ const expansion = await expandAtMentions(part.text, props.args.cwd);
3035
+ if (expansion.missing.length)
3036
+ addMessage("error", `Could not resolve @mention: ${expansion.missing.join(", ")}`);
3037
+ for (const skipped of expansion.skipped)
3038
+ addMessage("error", `Skipped @${skipped.path}: ${skipped.reason}`);
3039
+ expandedParts.push({ type: "text", text: expansion.text });
3040
+ }
3041
+ return expandedParts;
3042
+ }
2959
3043
  async function handleInput(input) {
2960
3044
  setNotice("");
3045
+ const labeledInput = buildImageContentPartsFromLabels(input, pendingImageAttachments);
3046
+ if (labeledInput.actualInput) {
3047
+ await runAgentInput(await expandTextParts(labeledInput.actualInput), labeledInput.displayInput);
3048
+ for (const label of labeledInput.usedLabels)
3049
+ pendingImageAttachments.delete(label);
3050
+ return;
3051
+ }
3052
+ const imageInput = await resolveImageInput(input, { labelStart: nextImageAttachmentIndex });
3053
+ for (const error of imageInput.errors)
3054
+ addMessage("error", `Skipped image: ${error}`);
3055
+ if (imageInput.attachments.length > 0) {
3056
+ await runAgentInput(await expandTextParts(imageInput.actualInput), imageInput.displayInput);
3057
+ nextImageAttachmentIndex += imageInput.attachments.length;
3058
+ return;
3059
+ }
3060
+ if (imageInput.imagePathCount > 0)
3061
+ return;
2961
3062
  if (input.startsWith("/")) {
2962
3063
  const skillInvocation = parseSkillInvocation(input, skills);
2963
3064
  if (skillInvocation) {
@@ -3016,6 +3117,7 @@ function OpenTuiApp(props) {
3016
3117
  return true;
3017
3118
  if (props.agent.mode !== mode())
3018
3119
  setMode(props.agent.mode);
3120
+ syncTodosFromAgent();
3019
3121
  syncModelChrome();
3020
3122
  syncModeChrome();
3021
3123
  if (uiDisposed)
@@ -3031,7 +3133,9 @@ function OpenTuiApp(props) {
3031
3133
  const isCompactResult = result.startsWith("✓ Compaction complete");
3032
3134
  if (isCompactResult) {
3033
3135
  setNotice(result);
3034
- redrawTranscript();
3136
+ displayMessages = reconstructDisplayMessages(props.agent.messages);
3137
+ streamingDisplay = undefined;
3138
+ redrawTranscript(undefined, displayMessages);
3035
3139
  setTimeout(() => setNotice(""), 4000);
3036
3140
  }
3037
3141
  else {
@@ -3494,6 +3598,7 @@ function OpenTuiApp(props) {
3494
3598
  }
3495
3599
  else if (event.type === "todos_updated") {
3496
3600
  setTodos(event.todos);
3601
+ syncSidebarTodos(event.todos);
3497
3602
  bumpSidebar();
3498
3603
  }
3499
3604
  else if (event.type === "mode_changed") {
@@ -4068,7 +4173,6 @@ function OpenTuiApp(props) {
4068
4173
  const context = sidebarContextState();
4069
4174
  const mcpStates = sidebarMcpStates();
4070
4175
  const files = gitState().files;
4071
- const activeTodos = todos().filter((todo) => todo.status !== "completed");
4072
4176
  return h("box", {
4073
4177
  ref: (ref) => {
4074
4178
  sidebarShell = ref;
@@ -4154,7 +4258,7 @@ function OpenTuiApp(props) {
4154
4258
  ? renderSidebarSection("Compactions", [
4155
4259
  h("text", { fg: theme.info, wrapMode: "word" }, `${currentTranscriptMessages().filter((m) => m.syntheticKind === "ui_compact_card").length} in this session`),
4156
4260
  ])
4157
- : null, renderSidebarMcp(mcpStates), renderSidebarLsp(), activeTodos.length ? renderSidebarTodos(activeTodos) : null, renderSidebarFiles(files))),
4261
+ : null, renderSidebarMcp(mcpStates), renderSidebarLsp(), renderSidebarTodos(todos()), renderSidebarFiles(files))),
4158
4262
  renderSidebarFooter(),
4159
4263
  ]);
4160
4264
  }
@@ -4239,11 +4343,37 @@ function OpenTuiApp(props) {
4239
4343
  }),
4240
4344
  ]);
4241
4345
  }
4242
- function renderSidebarTodos(activeTodos) {
4243
- return renderSidebarSection("Todo", activeTodos.slice(0, 6).map((todo) => {
4244
- const marker = todo.status === "in_progress" ? ">" : "o";
4245
- const color = todo.status === "in_progress" ? theme.primary : theme.textMuted;
4246
- return h("text", { fg: color, wrapMode: "word" }, `${marker} ${todo.activeForm || todo.content}`);
4346
+ function renderSidebarTodos(todos) {
4347
+ const visible = todos.slice(0, 8);
4348
+ return h("box", {
4349
+ flexDirection: "column",
4350
+ flexShrink: 0,
4351
+ visible: visible.length > 0,
4352
+ ref: (ref) => {
4353
+ sidebarTodoSection = ref;
4354
+ syncSidebarTodos();
4355
+ },
4356
+ }, h("text", { fg: theme.text }, "Todo"), ...Array.from({ length: 8 }, (_, index) => {
4357
+ const todo = visible[index];
4358
+ const completed = todo?.status === "completed";
4359
+ const inProgress = todo?.status === "in_progress";
4360
+ const labelText = todo
4361
+ ? (inProgress ? (todo.activeForm || todo.content) : todo.content)
4362
+ : "";
4363
+ return h("box", {
4364
+ flexDirection: "row",
4365
+ gap: 1,
4366
+ visible: !!todo,
4367
+ ref: (ref) => { sidebarTodoRows[index] = ref; },
4368
+ }, h("text", {
4369
+ fg: completed ? theme.success : inProgress ? theme.warning : theme.textMuted,
4370
+ flexShrink: 0,
4371
+ ref: (ref) => { sidebarTodoMarkers[index] = ref; },
4372
+ }, completed ? "✓" : inProgress ? "◉" : "○"), h("text", {
4373
+ fg: completed ? theme.success : inProgress ? theme.warning : theme.textMuted,
4374
+ wrapMode: "word",
4375
+ ref: (ref) => { sidebarTodoLabels[index] = ref; },
4376
+ }, labelText));
4247
4377
  }));
4248
4378
  }
4249
4379
  function renderSidebarFiles(files) {
@@ -5249,10 +5379,50 @@ function createModelSwitchEntry(ctx, model, key, signature) {
5249
5379
  ]);
5250
5380
  return { key, signature, node, refs: {} };
5251
5381
  }
5382
+ function createTodoWriteRenderable(ctx, tool) {
5383
+ const todos = tool.args.todos || [];
5384
+ const summary = tool.result || "";
5385
+ if (!isToolFinished(tool)) {
5386
+ return createBox(ctx, {
5387
+ paddingLeft: 3,
5388
+ marginTop: 1,
5389
+ flexDirection: "column",
5390
+ flexShrink: 0,
5391
+ }, [
5392
+ createText(ctx, `~ → Planning tasks...`, { fg: toolColor(tool) }),
5393
+ ]);
5394
+ }
5395
+ return createBox(ctx, {
5396
+ border: ["left"],
5397
+ borderColor: theme.borderSubtle,
5398
+ backgroundColor: theme.backgroundPanel,
5399
+ marginTop: 1,
5400
+ paddingTop: 1,
5401
+ paddingBottom: 1,
5402
+ paddingLeft: 2,
5403
+ flexDirection: "column",
5404
+ flexShrink: 0,
5405
+ }, [
5406
+ createText(ctx, `# Todo ${summary ? `— ${summary}` : ""}`, { fg: theme.textMuted }),
5407
+ ...todos.map((todo, index) => {
5408
+ const completed = todo.status === "completed";
5409
+ const inProgress = todo.status === "in_progress";
5410
+ const marker = completed ? "✓" : inProgress ? "◉" : "○";
5411
+ const fg = completed ? theme.success : inProgress ? theme.warning : theme.textMuted;
5412
+ return createText(ctx, ` ${marker} ${todo.content}`, {
5413
+ fg,
5414
+ marginTop: index === 0 ? 1 : 0,
5415
+ });
5416
+ }),
5417
+ ]);
5418
+ }
5252
5419
  function createToolRenderable(ctx, tool, syntaxStyle, width = 80) {
5253
5420
  if (tool.name === "question") {
5254
5421
  return createQuestionToolRenderable(ctx, tool);
5255
5422
  }
5423
+ if (tool.name === "todo_write") {
5424
+ return createTodoWriteRenderable(ctx, tool);
5425
+ }
5256
5426
  const icon = tool.name === "bash" ? "$" : tool.name === "edit" || tool.name === "write" ? "✎" : "●";
5257
5427
  const color = toolColor(tool);
5258
5428
  const header = toolHeader(tool);
@@ -5638,6 +5808,37 @@ function toSelectOption(item) {
5638
5808
  value: item.value,
5639
5809
  };
5640
5810
  }
5811
+ function nextImageLabelIndex(messages) {
5812
+ let max = 0;
5813
+ for (const message of messages) {
5814
+ for (const match of message.content.matchAll(imageAttachmentLabelPattern())) {
5815
+ max = Math.max(max, Number(match[1] ?? 0));
5816
+ }
5817
+ }
5818
+ return max + 1;
5819
+ }
5820
+ function imageExtensionFromUrl(url) {
5821
+ const mediaMatch = url.match(/^data:image\/([^;,]+)/i);
5822
+ const media = mediaMatch?.[1]?.toLowerCase();
5823
+ if (media === "jpeg")
5824
+ return "jpg";
5825
+ if (media === "png" || media === "webp" || media === "gif" || media === "bmp")
5826
+ return media;
5827
+ const pathMatch = url.match(/\.([a-z0-9]+)(?:[?#].*)?$/i);
5828
+ return pathMatch?.[1]?.toLowerCase() || "png";
5829
+ }
5830
+ function formatDisplayContentParts(content, labelStart) {
5831
+ const text = content
5832
+ .filter((part) => part.type === "text")
5833
+ .map((part) => part.text)
5834
+ .join("\n")
5835
+ .trim();
5836
+ let imageIndex = labelStart;
5837
+ const imageLines = content
5838
+ .filter((part) => part.type === "image_url")
5839
+ .map((part) => `[image#${imageIndex++}.${imageExtensionFromUrl(part.image_url.url)}]`);
5840
+ return [text, ...imageLines].filter(Boolean).join("\n") || "(multimedia)";
5841
+ }
5641
5842
  function reconstructDisplayMessages(agentMessages) {
5642
5843
  const result = [];
5643
5844
  for (const message of agentMessages) {
@@ -5646,7 +5847,12 @@ function reconstructDisplayMessages(agentMessages) {
5646
5847
  if (message.role === "user") {
5647
5848
  if (message.isMeta)
5648
5849
  continue;
5649
- result.push({ role: "user", content: typeof message.content === "string" ? message.content : "(multimedia)" });
5850
+ result.push({
5851
+ role: "user",
5852
+ content: typeof message.content === "string"
5853
+ ? message.content
5854
+ : formatDisplayContentParts(message.content, nextImageLabelIndex(result)),
5855
+ });
5650
5856
  continue;
5651
5857
  }
5652
5858
  const toolCalls = [];
@@ -5988,8 +6194,10 @@ function extractToolDiff(tool) {
5988
6194
  const index = tool.result.indexOf(marker);
5989
6195
  if (index === -1)
5990
6196
  return undefined;
5991
- const diff = tool.result.slice(index + marker.length).trim();
5992
- return diff ? diff : undefined;
6197
+ const rawDiff = tool.result.slice(index + marker.length);
6198
+ const diagnosticsIndex = rawDiff.search(/\n\nLSP diagnostics in /);
6199
+ const diff = diagnosticsIndex === -1 ? rawDiff : rawDiff.slice(0, diagnosticsIndex);
6200
+ return diff.trim().length > 0 ? diff : undefined;
5993
6201
  }
5994
6202
  function diffViewMode(width = 80) {
5995
6203
  return width > 120 ? "split" : "unified";
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@bubblebrain-ai/bubble",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "A terminal coding agent",
5
5
  "type": "module",
6
+ "engines": {
7
+ "node": ">=20.0.0"
8
+ },
6
9
  "bin": {
7
- "bubble": "dist/main.js"
10
+ "bubble": "dist/bin.js"
8
11
  },
9
12
  "files": [
10
13
  "dist",
@@ -13,7 +16,7 @@
13
16
  "!dist/**/*.test.d.ts"
14
17
  ],
15
18
  "scripts": {
16
- "build": "rm -rf dist && tsc && chmod +x dist/main.js",
19
+ "build": "rm -rf dist && tsc && chmod +x dist/bin.js dist/main.js",
17
20
  "dev": "tsc && bun dist/main.js",
18
21
  "prepack": "npm run build",
19
22
  "start": "bun dist/main.js",