@bubblebrain-ai/bubble 0.0.19 → 0.0.21

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 (96) hide show
  1. package/dist/agent/internal-reminder-sanitizer.d.ts +1 -0
  2. package/dist/agent/internal-reminder-sanitizer.js +46 -0
  3. package/dist/agent.d.ts +10 -0
  4. package/dist/agent.js +310 -18
  5. package/dist/approval/controller.d.ts +6 -0
  6. package/dist/approval/controller.js +104 -11
  7. package/dist/checkpoints.d.ts +57 -0
  8. package/dist/checkpoints.js +0 -0
  9. package/dist/debug-trace.js +4 -0
  10. package/dist/feishu/agent-host/run-driver.js +29 -0
  11. package/dist/hooks/config.d.ts +9 -0
  12. package/dist/hooks/config.js +278 -0
  13. package/dist/hooks/controller.d.ts +24 -0
  14. package/dist/hooks/controller.js +254 -0
  15. package/dist/hooks/index.d.ts +6 -0
  16. package/dist/hooks/index.js +4 -0
  17. package/dist/hooks/log.d.ts +14 -0
  18. package/dist/hooks/log.js +54 -0
  19. package/dist/hooks/runner.d.ts +5 -0
  20. package/dist/hooks/runner.js +225 -0
  21. package/dist/hooks/trust.d.ts +37 -0
  22. package/dist/hooks/trust.js +143 -0
  23. package/dist/hooks/types.d.ts +173 -0
  24. package/dist/hooks/types.js +46 -0
  25. package/dist/main.js +86 -13
  26. package/dist/memory/prompts.js +3 -1
  27. package/dist/model-catalog.js +2 -0
  28. package/dist/model-pricing.js +8 -0
  29. package/dist/network/chatgpt-transport.d.ts +0 -1
  30. package/dist/network/chatgpt-transport.js +40 -121
  31. package/dist/network/provider-transport.d.ts +32 -0
  32. package/dist/network/provider-transport.js +265 -0
  33. package/dist/network/retry.d.ts +29 -0
  34. package/dist/network/retry.js +88 -0
  35. package/dist/network/system-proxy.d.ts +18 -0
  36. package/dist/network/system-proxy.js +175 -0
  37. package/dist/provider-anthropic.d.ts +1 -0
  38. package/dist/provider-anthropic.js +127 -52
  39. package/dist/provider-openai-codex.js +19 -29
  40. package/dist/session-log.js +3 -3
  41. package/dist/session.d.ts +31 -0
  42. package/dist/session.js +69 -0
  43. package/dist/slash-commands/commands.js +164 -0
  44. package/dist/slash-commands/types.d.ts +6 -0
  45. package/dist/tools/bash.js +4 -0
  46. package/dist/tools/edit-apply.js +63 -3
  47. package/dist/tools/edit.d.ts +2 -1
  48. package/dist/tools/edit.js +6 -5
  49. package/dist/tools/index.d.ts +7 -0
  50. package/dist/tools/index.js +2 -2
  51. package/dist/tools/write.d.ts +2 -1
  52. package/dist/tools/write.js +2 -1
  53. package/dist/tui/display-history.d.ts +4 -3
  54. package/dist/tui/display-history.js +34 -57
  55. package/dist/tui/display-sanitizer.d.ts +3 -0
  56. package/dist/tui/display-sanitizer.js +38 -0
  57. package/dist/tui/image-paste.d.ts +18 -0
  58. package/dist/tui/image-paste.js +60 -0
  59. package/dist/tui/paste-placeholder.d.ts +1 -0
  60. package/dist/tui/paste-placeholder.js +7 -0
  61. package/dist/tui/run.d.ts +2 -0
  62. package/dist/tui/run.js +568 -223
  63. package/dist/tui/trace-groups.d.ts +16 -0
  64. package/dist/tui/trace-groups.js +82 -5
  65. package/dist/tui/transcript-scroll.d.ts +25 -0
  66. package/dist/tui/transcript-scroll.js +20 -0
  67. package/dist/tui/wordmark.d.ts +1 -0
  68. package/dist/tui/wordmark.js +56 -54
  69. package/dist/tui-ink/app.d.ts +4 -1
  70. package/dist/tui-ink/app.js +303 -248
  71. package/dist/tui-ink/display-history.d.ts +16 -1
  72. package/dist/tui-ink/display-history.js +50 -21
  73. package/dist/tui-ink/footer.d.ts +6 -12
  74. package/dist/tui-ink/footer.js +10 -29
  75. package/dist/tui-ink/image-paste.d.ts +59 -0
  76. package/dist/tui-ink/image-paste.js +277 -0
  77. package/dist/tui-ink/input-box.d.ts +26 -1
  78. package/dist/tui-ink/input-box.js +171 -41
  79. package/dist/tui-ink/message-list.d.ts +1 -1
  80. package/dist/tui-ink/message-list.js +46 -29
  81. package/dist/tui-ink/run.d.ts +7 -2
  82. package/dist/tui-ink/run.js +73 -23
  83. package/dist/tui-ink/terminal-mouse.d.ts +1 -0
  84. package/dist/tui-ink/terminal-mouse.js +4 -0
  85. package/dist/tui-ink/trace-groups.d.ts +16 -0
  86. package/dist/tui-ink/trace-groups.js +90 -6
  87. package/dist/tui-ink/transcript-viewport-math.d.ts +11 -0
  88. package/dist/tui-ink/transcript-viewport-math.js +17 -0
  89. package/dist/tui-ink/transcript-viewport.d.ts +24 -0
  90. package/dist/tui-ink/transcript-viewport.js +83 -0
  91. package/dist/tui-ink/welcome.d.ts +9 -7
  92. package/dist/tui-ink/welcome.js +7 -33
  93. package/dist/tui-opentui/app.js +2 -1
  94. package/dist/tui-opentui/trace-groups.js +40 -4
  95. package/dist/types.d.ts +27 -0
  96. package/package.json +1 -1
@@ -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
+ }
@@ -10,6 +10,16 @@ export interface SubmitPayload {
10
10
  }
11
11
  interface InputBoxProps {
12
12
  onSubmit: (payload: SubmitPayload) => void;
13
+ /**
14
+ * When set (agent running), Tab queues the composer content for the next
15
+ * turn instead of its idle-time behavior.
16
+ */
17
+ onQueue?: (payload: SubmitPayload) => void;
18
+ /**
19
+ * Receives scroll intent when Up/Down arrows are classified as synthetic
20
+ * wheel events (terminal alternate-scroll) rather than keyboard presses.
21
+ */
22
+ onWheelScroll?: (direction: "up" | "down", lines: number) => void;
13
23
  onPasteNotice?: (notice: string) => void;
14
24
  disabled?: boolean;
15
25
  cursorResetEpoch?: number;
@@ -31,6 +41,21 @@ export declare function resolveCursorRowCompensation(input: {
31
41
  export declare function isCtrlCInput(input: string, key: {
32
42
  ctrl?: boolean;
33
43
  }): boolean;
44
+ /**
45
+ * Split a composer line around the cursor so the cell under it can render as
46
+ * an inverse-video software cursor. The visible cursor must not depend on the
47
+ * real terminal cursor: Ink only re-arms its one-shot cursor escape when the
48
+ * component owning useCursor re-commits, so frames produced by other
49
+ * components' local state (the waiting spinner, viewport scrolling) hide the
50
+ * hardware cursor for most of an agent run. Drawing the cell ourselves keeps
51
+ * the cursor visible on every frame; the real (mostly hidden) cursor is still
52
+ * positioned for IME anchoring.
53
+ */
54
+ export declare function splitLineAtCursor(lineText: string, charOffset: number): {
55
+ before: string;
56
+ at: string;
57
+ after: string;
58
+ };
34
59
  export declare function shouldSubmitExactSlashSuggestion(input: string, suggestionName?: string): boolean;
35
60
  export declare function resolveSlashEnterAction(input: string, suggestions: Array<{
36
61
  name: string;
@@ -55,4 +80,4 @@ export declare function insertNewlineAtCursor(text: string, cursor: number): {
55
80
  text: string;
56
81
  cursor: number;
57
82
  };
58
- export declare function InputBox({ onSubmit, onPasteNotice, disabled, cursorResetEpoch, draftText, draftEpoch, onDraftApplied, skillRegistry, terminalColumns, cwd, }: InputBoxProps): import("react/jsx-runtime").JSX.Element;
83
+ export declare function InputBox({ onSubmit, onQueue, onWheelScroll, onPasteNotice, disabled, cursorResetEpoch, draftText, draftEpoch, onDraftApplied, skillRegistry, terminalColumns, cwd, }: InputBoxProps): import("react/jsx-runtime").JSX.Element;