@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.
- package/dist/agent/abort-errors.d.ts +14 -0
- package/dist/agent/abort-errors.js +21 -0
- package/dist/agent/budget-ledger.d.ts +41 -0
- package/dist/agent/budget-ledger.js +64 -0
- package/dist/agent/child-runner.d.ts +55 -0
- package/dist/agent/child-runner.js +312 -0
- package/dist/agent/profiles.d.ts +8 -0
- package/dist/agent/profiles.js +27 -5
- package/dist/agent/result-integrator.d.ts +22 -0
- package/dist/agent/result-integrator.js +50 -0
- package/dist/agent/subagent-control.d.ts +31 -0
- package/dist/agent/subagent-control.js +27 -0
- package/dist/agent/subagent-lifecycle-reminder.js +11 -2
- package/dist/agent/subagent-scheduler.d.ts +95 -0
- package/dist/agent/subagent-scheduler.js +256 -0
- package/dist/agent/subagent-store.d.ts +41 -0
- package/dist/agent/subagent-store.js +149 -0
- package/dist/agent/subagent-summary.d.ts +30 -0
- package/dist/agent/subagent-summary.js +74 -0
- package/dist/agent/worktree.d.ts +29 -0
- package/dist/agent/worktree.js +73 -0
- package/dist/agent.d.ts +64 -5
- package/dist/agent.js +365 -288
- package/dist/approval/controller.js +9 -1
- package/dist/approval/tool-helper.js +2 -0
- package/dist/approval/types.d.ts +17 -1
- package/dist/checkpoints.d.ts +57 -0
- package/dist/checkpoints.js +0 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +17 -0
- package/dist/feishu/agent-host/approval-card.js +9 -0
- package/dist/feishu/agent-host/run-driver.js +2 -0
- package/dist/main.js +88 -13
- package/dist/network/errors.d.ts +28 -0
- package/dist/network/errors.js +24 -0
- package/dist/orchestrator/default-hooks.js +5 -1
- package/dist/prompt/compose.js +3 -0
- package/dist/prompt/delegation.d.ts +14 -0
- package/dist/prompt/delegation.js +64 -0
- package/dist/prompt/task-reminders.d.ts +5 -1
- package/dist/prompt/task-reminders.js +10 -2
- package/dist/provider-anthropic.js +23 -0
- package/dist/provider.js +23 -3
- package/dist/session.d.ts +31 -0
- package/dist/session.js +69 -0
- package/dist/slash-commands/commands.js +109 -2
- package/dist/slash-commands/types.d.ts +6 -0
- package/dist/tools/agent-lifecycle.d.ts +29 -3
- package/dist/tools/agent-lifecycle.js +394 -40
- package/dist/tools/bash.js +4 -0
- package/dist/tools/child-tools.d.ts +31 -0
- package/dist/tools/child-tools.js +106 -0
- package/dist/tools/edit.d.ts +2 -1
- package/dist/tools/edit.js +2 -1
- package/dist/tools/index.d.ts +7 -0
- package/dist/tools/index.js +3 -3
- package/dist/tools/write.d.ts +2 -1
- package/dist/tools/write.js +2 -1
- package/dist/tui/image-paste.d.ts +18 -0
- package/dist/tui/image-paste.js +60 -0
- package/dist/tui/run.d.ts +11 -1
- package/dist/tui/run.js +399 -71
- package/dist/tui/session-picker-data.d.ts +18 -0
- package/dist/tui/session-picker-data.js +21 -0
- package/dist/tui/trace-groups.d.ts +16 -0
- package/dist/tui/trace-groups.js +42 -1
- package/dist/tui/transcript-scroll.d.ts +25 -0
- package/dist/tui/transcript-scroll.js +20 -0
- package/dist/tui/wordmark.d.ts +2 -0
- package/dist/tui/wordmark.js +31 -4
- package/dist/tui-ink/app.d.ts +4 -1
- package/dist/tui-ink/app.js +301 -247
- package/dist/tui-ink/approval/approval-dialog.js +10 -0
- package/dist/tui-ink/display-history.d.ts +16 -1
- package/dist/tui-ink/display-history.js +50 -21
- package/dist/tui-ink/footer.d.ts +6 -12
- package/dist/tui-ink/footer.js +10 -29
- package/dist/tui-ink/image-paste.d.ts +59 -0
- package/dist/tui-ink/image-paste.js +277 -0
- package/dist/tui-ink/input-box.d.ts +26 -1
- package/dist/tui-ink/input-box.js +171 -41
- package/dist/tui-ink/message-list.d.ts +1 -1
- package/dist/tui-ink/message-list.js +46 -29
- package/dist/tui-ink/run.d.ts +7 -2
- package/dist/tui-ink/run.js +73 -23
- package/dist/tui-ink/terminal-mouse.d.ts +1 -0
- package/dist/tui-ink/terminal-mouse.js +4 -0
- package/dist/tui-ink/trace-groups.d.ts +16 -0
- package/dist/tui-ink/trace-groups.js +50 -2
- package/dist/tui-ink/transcript-viewport-math.d.ts +11 -0
- package/dist/tui-ink/transcript-viewport-math.js +17 -0
- package/dist/tui-ink/transcript-viewport.d.ts +24 -0
- package/dist/tui-ink/transcript-viewport.js +83 -0
- package/dist/tui-ink/welcome.d.ts +9 -7
- package/dist/tui-ink/welcome.js +7 -33
- package/dist/tui-opentui/approval/approval-dialog.js +10 -0
- package/dist/types.d.ts +17 -0
- 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
|
-
|
|
48
|
-
|
|
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
|
|
98
|
+
if (message.syntheticKind) {
|
|
59
99
|
return message;
|
|
60
100
|
}
|
|
61
|
-
|
|
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
|
-
|
|
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",
|
package/dist/tui-ink/footer.d.ts
CHANGED
|
@@ -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;
|
package/dist/tui-ink/footer.js
CHANGED
|
@@ -1,19 +1,16 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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: [
|
|
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
|
+
}
|