@bubblebrain-ai/bubble 0.0.3 → 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);
@@ -2728,6 +2733,9 @@ function OpenTuiApp(props) {
2728
2733
  function onPromptContentChange(value) {
2729
2734
  const nextValue = typeof value === "string" ? value : readPromptText();
2730
2735
  promptText = nextValue;
2736
+ if (!applyingComposerImageReplacement) {
2737
+ void applyComposerImagePathReplacement(nextValue);
2738
+ }
2731
2739
  if (promptHistoryIndex !== undefined
2732
2740
  && nextValue !== (promptHistory[promptHistoryIndex] ?? "")) {
2733
2741
  resetPromptHistoryBrowse();
@@ -2995,8 +3003,62 @@ function OpenTuiApp(props) {
2995
3003
  setPromptText(`/${skillName} `);
2996
3004
  redrawDock();
2997
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
+ }
2998
3043
  async function handleInput(input) {
2999
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;
3000
3062
  if (input.startsWith("/")) {
3001
3063
  const skillInvocation = parseSkillInvocation(input, skills);
3002
3064
  if (skillInvocation) {
@@ -3071,7 +3133,9 @@ function OpenTuiApp(props) {
3071
3133
  const isCompactResult = result.startsWith("✓ Compaction complete");
3072
3134
  if (isCompactResult) {
3073
3135
  setNotice(result);
3074
- redrawTranscript();
3136
+ displayMessages = reconstructDisplayMessages(props.agent.messages);
3137
+ streamingDisplay = undefined;
3138
+ redrawTranscript(undefined, displayMessages);
3075
3139
  setTimeout(() => setNotice(""), 4000);
3076
3140
  }
3077
3141
  else {
@@ -5744,6 +5808,37 @@ function toSelectOption(item) {
5744
5808
  value: item.value,
5745
5809
  };
5746
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
+ }
5747
5842
  function reconstructDisplayMessages(agentMessages) {
5748
5843
  const result = [];
5749
5844
  for (const message of agentMessages) {
@@ -5752,7 +5847,12 @@ function reconstructDisplayMessages(agentMessages) {
5752
5847
  if (message.role === "user") {
5753
5848
  if (message.isMeta)
5754
5849
  continue;
5755
- 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
+ });
5756
5856
  continue;
5757
5857
  }
5758
5858
  const toolCalls = [];
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@bubblebrain-ai/bubble",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "A terminal coding agent",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": ">=20.0.0"
8
8
  },
9
9
  "bin": {
10
- "bubble": "dist/main.js"
10
+ "bubble": "dist/bin.js"
11
11
  },
12
12
  "files": [
13
13
  "dist",
@@ -16,7 +16,7 @@
16
16
  "!dist/**/*.test.d.ts"
17
17
  ],
18
18
  "scripts": {
19
- "build": "rm -rf dist && tsc && chmod +x dist/main.js",
19
+ "build": "rm -rf dist && tsc && chmod +x dist/bin.js dist/main.js",
20
20
  "dev": "tsc && bun dist/main.js",
21
21
  "prepack": "npm run build",
22
22
  "start": "bun dist/main.js",