@bubblebrain-ai/bubble 0.0.20 → 0.0.22

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 (98) hide show
  1. package/dist/agent/abort-errors.d.ts +14 -0
  2. package/dist/agent/abort-errors.js +21 -0
  3. package/dist/agent/budget-ledger.d.ts +41 -0
  4. package/dist/agent/budget-ledger.js +64 -0
  5. package/dist/agent/child-runner.d.ts +55 -0
  6. package/dist/agent/child-runner.js +312 -0
  7. package/dist/agent/profiles.d.ts +8 -0
  8. package/dist/agent/profiles.js +27 -5
  9. package/dist/agent/result-integrator.d.ts +22 -0
  10. package/dist/agent/result-integrator.js +50 -0
  11. package/dist/agent/subagent-control.d.ts +31 -0
  12. package/dist/agent/subagent-control.js +27 -0
  13. package/dist/agent/subagent-lifecycle-reminder.js +11 -2
  14. package/dist/agent/subagent-scheduler.d.ts +95 -0
  15. package/dist/agent/subagent-scheduler.js +256 -0
  16. package/dist/agent/subagent-store.d.ts +41 -0
  17. package/dist/agent/subagent-store.js +149 -0
  18. package/dist/agent/subagent-summary.d.ts +30 -0
  19. package/dist/agent/subagent-summary.js +74 -0
  20. package/dist/agent/worktree.d.ts +29 -0
  21. package/dist/agent/worktree.js +73 -0
  22. package/dist/agent.d.ts +64 -5
  23. package/dist/agent.js +365 -288
  24. package/dist/approval/controller.js +9 -1
  25. package/dist/approval/tool-helper.js +2 -0
  26. package/dist/approval/types.d.ts +17 -1
  27. package/dist/checkpoints.d.ts +57 -0
  28. package/dist/checkpoints.js +0 -0
  29. package/dist/config.d.ts +8 -0
  30. package/dist/config.js +17 -0
  31. package/dist/feishu/agent-host/approval-card.js +9 -0
  32. package/dist/feishu/agent-host/run-driver.js +2 -0
  33. package/dist/main.js +88 -13
  34. package/dist/network/errors.d.ts +28 -0
  35. package/dist/network/errors.js +24 -0
  36. package/dist/orchestrator/default-hooks.js +5 -1
  37. package/dist/prompt/compose.js +3 -0
  38. package/dist/prompt/delegation.d.ts +14 -0
  39. package/dist/prompt/delegation.js +64 -0
  40. package/dist/prompt/task-reminders.d.ts +5 -1
  41. package/dist/prompt/task-reminders.js +10 -2
  42. package/dist/provider-anthropic.js +23 -0
  43. package/dist/provider.js +23 -3
  44. package/dist/session.d.ts +31 -0
  45. package/dist/session.js +69 -0
  46. package/dist/slash-commands/commands.js +109 -2
  47. package/dist/slash-commands/types.d.ts +6 -0
  48. package/dist/tools/agent-lifecycle.d.ts +29 -3
  49. package/dist/tools/agent-lifecycle.js +394 -40
  50. package/dist/tools/bash.js +4 -0
  51. package/dist/tools/child-tools.d.ts +31 -0
  52. package/dist/tools/child-tools.js +106 -0
  53. package/dist/tools/edit.d.ts +2 -1
  54. package/dist/tools/edit.js +2 -1
  55. package/dist/tools/index.d.ts +7 -0
  56. package/dist/tools/index.js +3 -3
  57. package/dist/tools/write.d.ts +2 -1
  58. package/dist/tools/write.js +2 -1
  59. package/dist/tui/image-paste.d.ts +18 -0
  60. package/dist/tui/image-paste.js +60 -0
  61. package/dist/tui/run.d.ts +11 -1
  62. package/dist/tui/run.js +399 -71
  63. package/dist/tui/session-picker-data.d.ts +18 -0
  64. package/dist/tui/session-picker-data.js +21 -0
  65. package/dist/tui/trace-groups.d.ts +16 -0
  66. package/dist/tui/trace-groups.js +42 -1
  67. package/dist/tui/transcript-scroll.d.ts +25 -0
  68. package/dist/tui/transcript-scroll.js +20 -0
  69. package/dist/tui/wordmark.d.ts +2 -0
  70. package/dist/tui/wordmark.js +31 -4
  71. package/dist/tui-ink/app.d.ts +4 -1
  72. package/dist/tui-ink/app.js +301 -247
  73. package/dist/tui-ink/approval/approval-dialog.js +10 -0
  74. package/dist/tui-ink/display-history.d.ts +16 -1
  75. package/dist/tui-ink/display-history.js +50 -21
  76. package/dist/tui-ink/footer.d.ts +6 -12
  77. package/dist/tui-ink/footer.js +10 -29
  78. package/dist/tui-ink/image-paste.d.ts +59 -0
  79. package/dist/tui-ink/image-paste.js +277 -0
  80. package/dist/tui-ink/input-box.d.ts +26 -1
  81. package/dist/tui-ink/input-box.js +171 -41
  82. package/dist/tui-ink/message-list.d.ts +1 -1
  83. package/dist/tui-ink/message-list.js +46 -29
  84. package/dist/tui-ink/run.d.ts +7 -2
  85. package/dist/tui-ink/run.js +73 -23
  86. package/dist/tui-ink/terminal-mouse.d.ts +1 -0
  87. package/dist/tui-ink/terminal-mouse.js +4 -0
  88. package/dist/tui-ink/trace-groups.d.ts +16 -0
  89. package/dist/tui-ink/trace-groups.js +50 -2
  90. package/dist/tui-ink/transcript-viewport-math.d.ts +11 -0
  91. package/dist/tui-ink/transcript-viewport-math.js +17 -0
  92. package/dist/tui-ink/transcript-viewport.d.ts +24 -0
  93. package/dist/tui-ink/transcript-viewport.js +83 -0
  94. package/dist/tui-ink/welcome.d.ts +9 -7
  95. package/dist/tui-ink/welcome.js +7 -33
  96. package/dist/tui-opentui/approval/approval-dialog.js +10 -0
  97. package/dist/types.d.ts +17 -0
  98. package/package.json +1 -1
@@ -77,6 +77,8 @@ function dialogTitle(req) {
77
77
  return "Bash command";
78
78
  case "lsp":
79
79
  return "Language server operation";
80
+ case "agent_profile":
81
+ return "Project agent profile";
80
82
  }
81
83
  }
82
84
  function dialogQuestion(req) {
@@ -91,6 +93,8 @@ function dialogQuestion(req) {
91
93
  return "Do you want to proceed?";
92
94
  case "lsp":
93
95
  return `Do you want to run ${req.operation} on ${basename(req.path)}?`;
96
+ case "agent_profile":
97
+ return `Trust the repository profile "${req.name}" to drive a subagent? It is remembered for this session until the file changes.`;
94
98
  }
95
99
  }
96
100
  function basename(p) {
@@ -107,8 +111,14 @@ function RequestPreview({ request }) {
107
111
  return _jsx(DiffView, { diff: request.diff });
108
112
  case "write":
109
113
  return _jsx(WritePreview, { path: request.path, content: request.content });
114
+ case "agent_profile":
115
+ return _jsx(AgentProfilePreview, { path: request.path, promptPreview: request.promptPreview });
110
116
  }
111
117
  }
118
+ function AgentProfilePreview({ path, promptPreview }) {
119
+ const theme = useTheme();
120
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.muted, children: compressHome(path) }), _jsx(Text, { children: promptPreview }), _jsx(Text, { color: theme.warning, children: "This prompt comes from the repository's .bubble/agents and will drive a subagent." })] }));
121
+ }
112
122
  function BashPreview({ command, cwd }) {
113
123
  const theme = useTheme();
114
124
  const danger = classifyBashDanger(command);
@@ -1,18 +1,33 @@
1
1
  import type { Message, ToolResultMetadata } from "../types.js";
2
+ export type UserInputStatus = "queued" | "pending_steer";
2
3
  export interface DisplayMessage {
3
4
  /** Stable identity, used as the transcript list key. Generated by the UI layer. */
4
5
  key?: string;
5
6
  role: "user" | "assistant" | "error";
6
7
  content: string;
8
+ /** Correlates queued/steer placeholder rows with their lifecycle events. */
9
+ clientId?: string;
10
+ /** Badge for user input waiting to enter the run (QUEUED / STEER). */
11
+ inputStatus?: UserInputStatus;
7
12
  reasoning?: string;
8
13
  toolCalls?: DisplayToolCall[];
9
14
  parts?: DisplayMessagePart[];
10
- syntheticKind?: "ui_summary" | "ui_compact_summary";
15
+ syntheticKind?: "ui_summary" | "ui_compact_summary" | "ui_interrupt";
11
16
  /** Markdown body shown inside a `ui_compact_summary` card. */
12
17
  compactionSummary?: string;
13
18
  hiddenCount?: number;
14
19
  taskElapsedMs?: number;
15
20
  }
21
+ export declare function userInputStatusBadgeLabel(status?: UserInputStatus): string | undefined;
22
+ export declare function setUserInputStatus(message: DisplayMessage, inputStatus?: UserInputStatus): DisplayMessage;
23
+ /**
24
+ * Aborted assistant messages carry a model-facing interruption note appended
25
+ * to their content (whole content, or a "\n\n"-joined suffix after partial
26
+ * streamed text). That note is for the next model call, not the user — strip
27
+ * it so only what the assistant actually said renders, and let the caller
28
+ * show a dedicated interrupt indicator instead.
29
+ */
30
+ export declare function stripInterruptedAssistantMarker(content: string, marker: string): string;
16
31
  export type DisplayMessagePart = DisplayTextPart | DisplayToolsPart;
17
32
  export interface DisplayTextPart {
18
33
  type: "text";
@@ -1,3 +1,36 @@
1
+ export function userInputStatusBadgeLabel(status) {
2
+ switch (status) {
3
+ case "queued":
4
+ return "QUEUED";
5
+ case "pending_steer":
6
+ return "STEER";
7
+ default:
8
+ return undefined;
9
+ }
10
+ }
11
+ export function setUserInputStatus(message, inputStatus) {
12
+ if (inputStatus)
13
+ return { ...message, inputStatus };
14
+ const { inputStatus: _inputStatus, ...rest } = message;
15
+ return rest;
16
+ }
17
+ /**
18
+ * Aborted assistant messages carry a model-facing interruption note appended
19
+ * to their content (whole content, or a "\n\n"-joined suffix after partial
20
+ * streamed text). That note is for the next model call, not the user — strip
21
+ * it so only what the assistant actually said renders, and let the caller
22
+ * show a dedicated interrupt indicator instead.
23
+ */
24
+ export function stripInterruptedAssistantMarker(content, marker) {
25
+ if (!content || !marker)
26
+ return content;
27
+ if (content === marker)
28
+ return "";
29
+ const suffix = `\n\n${marker}`;
30
+ if (content.endsWith(suffix))
31
+ return content.slice(0, -suffix.length);
32
+ return content;
33
+ }
1
34
  let __displayMessageCounter = 0;
2
35
  export function nextDisplayMessageKey(prefix = "msg") {
3
36
  __displayMessageCounter += 1;
@@ -44,8 +77,11 @@ export function toolCallsFromParts(parts) {
44
77
  return parts.flatMap((part) => part.type === "tools" ? part.toolCalls : []);
45
78
  }
46
79
  const FULL_DETAIL_WINDOW = 24;
47
- const MAX_OLD_CONTENT_CHARS = 1200;
48
- const MAX_OLD_REASONING_CHARS = 600;
80
+ // Folding policy: message text (content, reasoning) is NEVER rewritten or
81
+ // truncated what the user or the assistant said renders verbatim. All
82
+ // messages stay in the list (the alt-screen viewport scrolls them); older
83
+ // messages only collapse bulky tool-result bodies, which the UI re-expands
84
+ // on demand.
49
85
  export function compactDisplayMessages(messages) {
50
86
  if (messages.length === 0) {
51
87
  return messages;
@@ -54,28 +90,24 @@ export function compactDisplayMessages(messages) {
54
90
  const detailStart = Math.max(0, visible.length - FULL_DETAIL_WINDOW);
55
91
  return visible.map((message, index) => (index < detailStart ? compactDisplayMessage(message) : message));
56
92
  }
93
+ // Messages that already went through compaction. Re-compacting them would
94
+ // produce equal-but-new objects on every transcript update, which defeats the
95
+ // React.memo row cache in message-list.tsx — settled rows must keep identity.
96
+ const compactedMessages = new WeakSet();
57
97
  function compactDisplayMessage(message) {
58
- if (message.syntheticKind === "ui_summary" || message.syntheticKind === "ui_compact_summary") {
98
+ if (message.syntheticKind) {
59
99
  return message;
60
100
  }
61
- return {
101
+ if (compactedMessages.has(message)) {
102
+ return message;
103
+ }
104
+ const compacted = {
62
105
  ...message,
63
- content: truncateText(message.content, MAX_OLD_CONTENT_CHARS),
64
- reasoning: message.reasoning
65
- ? truncateText(message.reasoning, MAX_OLD_REASONING_CHARS)
66
- : message.reasoning,
67
106
  toolCalls: message.toolCalls?.map(compactToolCall),
68
107
  parts: message.parts?.map(compactDisplayPart),
69
108
  };
70
- }
71
- function truncateText(value, maxChars) {
72
- if (value.length <= maxChars) {
73
- return value;
74
- }
75
- const head = Math.max(1, Math.floor(maxChars * 0.7));
76
- const tail = Math.max(1, maxChars - head - 32);
77
- const omitted = value.length - head - tail;
78
- return `${value.slice(0, head)}\n...[${omitted} chars omitted for UI]...\n${value.slice(-tail)}`;
109
+ compactedMessages.add(compacted);
110
+ return compacted;
79
111
  }
80
112
  function cloneToolCall(toolCall) {
81
113
  return {
@@ -85,10 +117,7 @@ function cloneToolCall(toolCall) {
85
117
  }
86
118
  function compactDisplayPart(part) {
87
119
  if (part.type === "text") {
88
- return {
89
- ...part,
90
- content: truncateText(part.content, MAX_OLD_CONTENT_CHARS),
91
- };
120
+ return part;
92
121
  }
93
122
  return {
94
123
  type: "tools",
@@ -1,19 +1,13 @@
1
1
  import type { PermissionMode } from "../types.js";
2
- export interface FooterUsageTotals {
3
- prompt: number;
4
- completion: number;
5
- }
6
2
  export interface FooterData {
7
- cwd: string;
8
- providerId: string;
9
- model: string;
10
- thinkingLevel: string;
11
- showThinking: boolean;
12
3
  mode?: PermissionMode;
13
- usageTotals: FooterUsageTotals;
14
- verboseTrace?: boolean;
15
4
  }
5
+ /**
6
+ * Bottom status line. Path / provider / model moved into the welcome banner;
7
+ * the footer only surfaces the permission-mode badge, so it renders nothing
8
+ * (zero rows) in the default mode.
9
+ */
16
10
  export declare function FooterBar({ data }: {
17
11
  data: FooterData;
18
- }): import("react/jsx-runtime").JSX.Element;
12
+ }): import("react/jsx-runtime").JSX.Element | null;
19
13
  export declare function buildFooterData(input: FooterData): FooterData;
@@ -1,19 +1,16 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- import { homedir } from "node:os";
4
3
  import { useTheme } from "./theme.js";
5
4
  import { PERMISSION_MODE_INFO } from "../permission/mode.js";
5
+ /**
6
+ * Bottom status line. Path / provider / model moved into the welcome banner;
7
+ * the footer only surfaces the permission-mode badge, so it renders nothing
8
+ * (zero rows) in the default mode.
9
+ */
6
10
  export function FooterBar({ data }) {
7
- const theme = useTheme();
8
- const usageText = data.usageTotals.prompt || data.usageTotals.completion
9
- ? `↑${formatTokens(data.usageTotals.prompt)} ↓${formatTokens(data.usageTotals.completion)}`
10
- : "";
11
- const thinkingText = data.showThinking
12
- ? data.thinkingLevel && data.thinkingLevel !== "off"
13
- ? ` • ⌃R ${data.thinkingLevel}`
14
- : " • ⌃R off"
15
- : "";
16
- return (_jsxs(Box, { paddingX: 1, flexShrink: 0, children: [_jsx(Text, { color: theme.muted, children: formatCwd(data.cwd) }), usageText && (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: " " }), _jsx(Text, { color: theme.muted, dimColor: true, children: usageText })] })), _jsx(ModeBadge, { mode: data.mode }), _jsx(Box, { flexGrow: 1 }), _jsx(Text, { color: theme.muted, children: data.providerId }), _jsx(Text, { color: theme.muted, children: " \u2022 " }), _jsx(Text, { color: theme.toolName, children: data.model }), _jsx(Text, { color: theme.muted, dimColor: true, children: thinkingText })] }));
11
+ if (!data.mode || data.mode === "default")
12
+ return null;
13
+ return (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ModeBadge, { mode: data.mode }) }));
17
14
  }
18
15
  function ModeBadge({ mode }) {
19
16
  const theme = useTheme();
@@ -22,24 +19,8 @@ function ModeBadge({ mode }) {
22
19
  const info = PERMISSION_MODE_INFO[mode];
23
20
  const color = theme[info.color] ?? theme.muted;
24
21
  const symbol = info.symbol ? `${info.symbol} ` : "";
25
- return (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: " " }), _jsxs(Text, { color: color, bold: true, children: [symbol, info.shortTitle, " on"] }), _jsx(Text, { color: theme.muted, children: " \u21E7\u21E5" })] }));
22
+ return (_jsxs(_Fragment, { children: [_jsxs(Text, { color: color, bold: true, children: [symbol, info.shortTitle, " on"] }), _jsx(Text, { color: theme.muted, children: " \u21E7\u21E5" })] }));
26
23
  }
27
24
  export function buildFooterData(input) {
28
25
  return input;
29
26
  }
30
- function formatTokens(count) {
31
- if (count < 1000)
32
- return String(count);
33
- if (count < 10000)
34
- return `${(count / 1000).toFixed(1)}k`;
35
- if (count < 1000000)
36
- return `${Math.round(count / 1000)}k`;
37
- return `${(count / 1000000).toFixed(1)}M`;
38
- }
39
- function formatCwd(cwd) {
40
- const home = homedir();
41
- if (cwd.startsWith(home)) {
42
- return `~${cwd.slice(home.length)}`;
43
- }
44
- return cwd;
45
- }
@@ -7,6 +7,7 @@
7
7
  * TemporaryItems path and the clipboard — the path often gets cleaned up before
8
8
  * we can read it, so we fall back to the clipboard.
9
9
  */
10
+ import type { ContentPart } from "../types.js";
10
11
  export interface ImageAttachment {
11
12
  base64: string;
12
13
  mediaType: string;
@@ -17,7 +18,47 @@ export interface ImageAttachment {
17
18
  filename?: string;
18
19
  sourcePath?: string;
19
20
  }
21
+ export interface ImagePathToken {
22
+ rawPath: string;
23
+ start: number;
24
+ end: number;
25
+ }
26
+ export interface ImageInputResolution {
27
+ actualInput: string | ContentPart[];
28
+ displayInput: string;
29
+ errors: string[];
30
+ attachments: ImageAttachment[];
31
+ imagePathCount: number;
32
+ }
33
+ export interface LabeledImageAttachment extends ImageAttachment {
34
+ label: string;
35
+ }
36
+ export interface ComposerImageResolution {
37
+ text: string;
38
+ attachments: LabeledImageAttachment[];
39
+ errors: string[];
40
+ imagePathCount: number;
41
+ nextLabelIndex: number;
42
+ }
20
43
  export declare function isImageFilePath(raw: string): boolean;
44
+ export declare function extractImagePathTokens(input: string): ImagePathToken[];
45
+ export declare function removeImagePathTokens(input: string, tokens: ImagePathToken[]): string;
46
+ export declare function imageAttachmentLabel(att: ImageAttachment, index: number): string;
47
+ /**
48
+ * Label for an image path before ingestion runs. Matches what
49
+ * imageAttachmentLabel produces for the same file, so a label inserted at
50
+ * paste time stays a valid key once the attachment is registered.
51
+ */
52
+ export declare function imageLabelForPath(rawPath: string, index: number): string;
53
+ export declare function imageAttachmentReference(att: ImageAttachment, index: number): string;
54
+ export declare function imageAttachmentLabelPattern(): RegExp;
55
+ export declare function buildImageContentParts(promptText: string, attachments: ImageAttachment[]): ContentPart[];
56
+ export declare function formatImageDisplayInput(promptText: string, attachments: ImageAttachment[], labelStart?: number): string;
57
+ export declare function buildImageContentPartsFromLabels(input: string, attachmentsByLabel: Map<string, ImageAttachment>): {
58
+ actualInput?: ContentPart[];
59
+ displayInput: string;
60
+ usedLabels: string[];
61
+ };
21
62
  /**
22
63
  * Split a pasted blob into candidate path tokens.
23
64
  *
@@ -26,6 +67,18 @@ export declare function isImageFilePath(raw: string): boolean;
26
67
  * only on a space that is followed by the start of a new absolute path.
27
68
  */
28
69
  export declare function splitPastedPaths(pasted: string): string[];
70
+ /**
71
+ * True when a pasted blob consists solely of image file paths (drag from
72
+ * Finder, or a terminal that converts clipboard images to temp-file paths).
73
+ */
74
+ export declare function isImagePathPaste(pasted: string): boolean;
75
+ /**
76
+ * Bare image filename with no directory, e.g. "Screenshot ... AM.png".
77
+ * Copying an image file in Finder puts only the file's NAME in the
78
+ * clipboard's plain-text flavor — the actual bits arrive as a file-url or
79
+ * image flavor that must be read from the clipboard separately.
80
+ */
81
+ export declare function bareImageFilenameFromPaste(pasted: string): string | null;
29
82
  export declare function readImageFromPath(rawPath: string): Promise<ImageAttachment | null>;
30
83
  /** macOS screenshot shortcut writes to these paths and they may be auto-cleaned. */
31
84
  export declare function isScreenshotTempPath(s: string): boolean;
@@ -52,3 +105,9 @@ export declare function ingestClipboardImage(): Promise<{
52
105
  attachment?: ImageAttachment;
53
106
  error?: string;
54
107
  }>;
108
+ export declare function resolveImageInput(input: string, options?: {
109
+ labelStart?: number;
110
+ }): Promise<ImageInputResolution>;
111
+ export declare function resolveComposerImagePaths(input: string, options?: {
112
+ labelStart?: number;
113
+ }): Promise<ComposerImageResolution>;
@@ -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,121 @@ 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
+ /**
69
+ * Label for an image path before ingestion runs. Matches what
70
+ * imageAttachmentLabel produces for the same file, so a label inserted at
71
+ * paste time stays a valid key once the attachment is registered.
72
+ */
73
+ export function imageLabelForPath(rawPath, index) {
74
+ const ext = path.extname(unescapeShell(rawPath.trim())).toLowerCase() || ".png";
75
+ return `image#${index}${ext}`;
76
+ }
77
+ export function imageAttachmentReference(att, index) {
78
+ return `[${imageAttachmentLabel(att, index)}]`;
79
+ }
80
+ export function imageAttachmentLabelPattern() {
81
+ return /\[image#(\d+)\.[^\]\s]+\]/g;
82
+ }
83
+ function defaultImagePrompt(count) {
84
+ return count === 1
85
+ ? "Please analyze the attached image."
86
+ : "Please analyze the attached images.";
87
+ }
88
+ function imageExtension(att) {
89
+ const fromPath = path.extname(att.filename ?? att.sourcePath ?? "").toLowerCase();
90
+ if (fromPath)
91
+ return fromPath;
92
+ if (att.mediaType === "image/jpeg")
93
+ return ".jpg";
94
+ if (att.mediaType === "image/webp")
95
+ return ".webp";
96
+ if (att.mediaType === "image/gif")
97
+ return ".gif";
98
+ if (att.mediaType === "image/bmp")
99
+ return ".bmp";
100
+ return ".png";
101
+ }
102
+ export function buildImageContentParts(promptText, attachments) {
103
+ const text = promptText.trim() || defaultImagePrompt(attachments.length);
104
+ return [
105
+ { type: "text", text },
106
+ ...attachments.map((attachment) => ({
107
+ type: "image_url",
108
+ image_url: { url: attachment.dataUrl },
109
+ })),
110
+ ];
111
+ }
112
+ export function formatImageDisplayInput(promptText, attachments, labelStart = 1) {
113
+ const text = promptText.trim() || defaultImagePrompt(attachments.length);
114
+ const imageLines = attachments.map((attachment, index) => imageAttachmentReference(attachment, labelStart + index));
115
+ return `${text}\n${imageLines.join("\n")}`;
116
+ }
117
+ export function buildImageContentPartsFromLabels(input, attachmentsByLabel) {
118
+ const matches = Array.from(input.matchAll(imageAttachmentLabelPattern()));
119
+ const usedLabels = [];
120
+ const parts = [];
121
+ let cursor = 0;
122
+ for (const match of matches) {
123
+ const label = match[0].slice(1, -1);
124
+ const attachment = attachmentsByLabel.get(label);
125
+ if (!attachment)
126
+ continue;
127
+ const start = match.index ?? 0;
128
+ const before = input.slice(cursor, start).trim();
129
+ if (before)
130
+ parts.push({ type: "text", text: before });
131
+ parts.push({ type: "image_url", image_url: { url: attachment.dataUrl } });
132
+ usedLabels.push(label);
133
+ cursor = start + match[0].length;
134
+ }
135
+ if (usedLabels.length === 0)
136
+ return { displayInput: input, usedLabels: [] };
137
+ const rest = input.slice(cursor).trim();
138
+ if (rest)
139
+ parts.push({ type: "text", text: rest });
140
+ if (!parts.some((part) => part.type === "text")) {
141
+ parts.unshift({ type: "text", text: defaultImagePrompt(usedLabels.length) });
142
+ }
143
+ return {
144
+ actualInput: parts,
145
+ displayInput: input.trim() || usedLabels.map((label) => `[${label}]`).join("\n"),
146
+ usedLabels,
147
+ };
148
+ }
33
149
  /**
34
150
  * Split a pasted blob into candidate path tokens.
35
151
  *
@@ -48,6 +164,30 @@ export function splitPastedPaths(pasted) {
48
164
  }
49
165
  return out;
50
166
  }
167
+ /**
168
+ * True when a pasted blob consists solely of image file paths (drag from
169
+ * Finder, or a terminal that converts clipboard images to temp-file paths).
170
+ */
171
+ export function isImagePathPaste(pasted) {
172
+ const pieces = splitPastedPaths(pasted);
173
+ return pieces.length > 0 && pieces.every((piece) => isImageFilePath(piece));
174
+ }
175
+ /**
176
+ * Bare image filename with no directory, e.g. "Screenshot ... AM.png".
177
+ * Copying an image file in Finder puts only the file's NAME in the
178
+ * clipboard's plain-text flavor — the actual bits arrive as a file-url or
179
+ * image flavor that must be read from the clipboard separately.
180
+ */
181
+ export function bareImageFilenameFromPaste(pasted) {
182
+ const s = pasted.trim();
183
+ if (!s || s.length > 255)
184
+ return null;
185
+ if (/[\n\r/\\]/.test(s))
186
+ return null;
187
+ if (!IMAGE_EXT.test(s))
188
+ return null;
189
+ return s;
190
+ }
51
191
  function mediaTypeFromExt(p) {
52
192
  const ext = path.extname(p).toLowerCase();
53
193
  if (ext === ".jpg" || ext === ".jpeg")
@@ -277,6 +417,14 @@ export async function ingestImagePath(p) {
277
417
  return { attachment: sized };
278
418
  }
279
419
  export async function ingestClipboardImage() {
420
+ // A file reference wins over bitmap flavors: for a copied FILE, coercing
421
+ // the clipboard to PNGf yields the file's generic ICON, not the image.
422
+ const filePath = await getClipboardFilePath();
423
+ if (filePath) {
424
+ if (isImageFilePath(filePath))
425
+ return ingestImagePath(filePath);
426
+ return { error: `clipboard file is not an image: ${filePath}` };
427
+ }
280
428
  const raw = await getImageFromClipboard();
281
429
  if (!raw)
282
430
  return { error: "clipboard has no image" };
@@ -286,3 +434,132 @@ export async function ingestClipboardImage() {
286
434
  return { error: validation.reason };
287
435
  return { attachment: sized };
288
436
  }
437
+ async function getClipboardFilePath() {
438
+ if (process.platform !== "darwin")
439
+ return null;
440
+ try {
441
+ // Probe first — AppleScript happily coerces plain TEXT into a file URL,
442
+ // so only trust «class furl» when the clipboard really carries one.
443
+ const probe = await execFileAsync("osascript", ["-e", "clipboard info for «class furl»"], {
444
+ timeout: 5000,
445
+ });
446
+ if (!String(probe.stdout).includes("furl"))
447
+ return null;
448
+ const result = await execFileAsync("osascript", ["-e", "POSIX path of (the clipboard as «class furl»)"], { timeout: 5000 });
449
+ const p = String(result.stdout).trim();
450
+ return p || null;
451
+ }
452
+ catch {
453
+ return null;
454
+ }
455
+ }
456
+ export async function resolveImageInput(input, options = {}) {
457
+ const tokens = extractImagePathTokens(input);
458
+ if (tokens.length === 0) {
459
+ return {
460
+ actualInput: input,
461
+ displayInput: input,
462
+ errors: [],
463
+ attachments: [],
464
+ imagePathCount: 0,
465
+ };
466
+ }
467
+ const attachments = [];
468
+ const errors = [];
469
+ const attachmentsByToken = new Map();
470
+ let nextLabelIndex = options.labelStart ?? 1;
471
+ for (const token of tokens) {
472
+ const result = await ingestImagePath(token.rawPath);
473
+ if (result.attachment) {
474
+ attachments.push(result.attachment);
475
+ attachmentsByToken.set(token, {
476
+ attachment: result.attachment,
477
+ label: imageAttachmentLabel(result.attachment, nextLabelIndex++),
478
+ });
479
+ }
480
+ else {
481
+ errors.push(`${token.rawPath}: ${result.error ?? "could not attach image"}`);
482
+ }
483
+ }
484
+ if (attachments.length === 0) {
485
+ return {
486
+ actualInput: input,
487
+ displayInput: input,
488
+ errors,
489
+ attachments: [],
490
+ imagePathCount: tokens.length,
491
+ };
492
+ }
493
+ const parts = [];
494
+ let displayInput = "";
495
+ let cursor = 0;
496
+ for (const token of tokens) {
497
+ const entry = attachmentsByToken.get(token);
498
+ if (!entry)
499
+ continue;
500
+ const before = input.slice(cursor, token.start);
501
+ displayInput += before;
502
+ const text = before.trim();
503
+ if (text)
504
+ parts.push({ type: "text", text });
505
+ parts.push({ type: "image_url", image_url: { url: entry.attachment.dataUrl } });
506
+ displayInput += `[${entry.label}]`;
507
+ cursor = token.end;
508
+ }
509
+ const rest = input.slice(cursor);
510
+ displayInput += rest;
511
+ const restText = rest.trim();
512
+ if (restText)
513
+ parts.push({ type: "text", text: restText });
514
+ if (!parts.some((part) => part.type === "text")) {
515
+ parts.unshift({ type: "text", text: defaultImagePrompt(attachments.length) });
516
+ }
517
+ return {
518
+ actualInput: parts,
519
+ displayInput: displayInput.trim(),
520
+ errors,
521
+ attachments,
522
+ imagePathCount: tokens.length,
523
+ };
524
+ }
525
+ export async function resolveComposerImagePaths(input, options = {}) {
526
+ const tokens = extractImagePathTokens(input);
527
+ let nextLabelIndex = options.labelStart ?? 1;
528
+ if (tokens.length === 0) {
529
+ return {
530
+ text: input,
531
+ attachments: [],
532
+ errors: [],
533
+ imagePathCount: 0,
534
+ nextLabelIndex,
535
+ };
536
+ }
537
+ const errors = [];
538
+ const attachments = [];
539
+ const replacements = new Map();
540
+ for (const token of tokens) {
541
+ const result = await ingestImagePath(token.rawPath);
542
+ if (!result.attachment) {
543
+ errors.push(`${token.rawPath}: ${result.error ?? "could not attach image"}`);
544
+ continue;
545
+ }
546
+ const label = imageAttachmentLabel(result.attachment, nextLabelIndex++);
547
+ attachments.push({ ...result.attachment, label });
548
+ replacements.set(token, `[${label}]`);
549
+ }
550
+ let text = "";
551
+ let cursor = 0;
552
+ for (const token of tokens) {
553
+ text += input.slice(cursor, token.start);
554
+ text += replacements.get(token) ?? input.slice(token.start, token.end);
555
+ cursor = token.end;
556
+ }
557
+ text += input.slice(cursor);
558
+ return {
559
+ text,
560
+ attachments,
561
+ errors,
562
+ imagePathCount: tokens.length,
563
+ nextLabelIndex,
564
+ };
565
+ }