@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.
- package/dist/agent/internal-reminder-sanitizer.d.ts +1 -0
- package/dist/agent/internal-reminder-sanitizer.js +46 -0
- package/dist/agent.d.ts +10 -0
- package/dist/agent.js +310 -18
- package/dist/approval/controller.d.ts +6 -0
- package/dist/approval/controller.js +104 -11
- package/dist/checkpoints.d.ts +57 -0
- package/dist/checkpoints.js +0 -0
- package/dist/debug-trace.js +4 -0
- package/dist/feishu/agent-host/run-driver.js +29 -0
- package/dist/hooks/config.d.ts +9 -0
- package/dist/hooks/config.js +278 -0
- package/dist/hooks/controller.d.ts +24 -0
- package/dist/hooks/controller.js +254 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.js +4 -0
- package/dist/hooks/log.d.ts +14 -0
- package/dist/hooks/log.js +54 -0
- package/dist/hooks/runner.d.ts +5 -0
- package/dist/hooks/runner.js +225 -0
- package/dist/hooks/trust.d.ts +37 -0
- package/dist/hooks/trust.js +143 -0
- package/dist/hooks/types.d.ts +173 -0
- package/dist/hooks/types.js +46 -0
- package/dist/main.js +86 -13
- package/dist/memory/prompts.js +3 -1
- package/dist/model-catalog.js +2 -0
- package/dist/model-pricing.js +8 -0
- package/dist/network/chatgpt-transport.d.ts +0 -1
- package/dist/network/chatgpt-transport.js +40 -121
- package/dist/network/provider-transport.d.ts +32 -0
- package/dist/network/provider-transport.js +265 -0
- package/dist/network/retry.d.ts +29 -0
- package/dist/network/retry.js +88 -0
- package/dist/network/system-proxy.d.ts +18 -0
- package/dist/network/system-proxy.js +175 -0
- package/dist/provider-anthropic.d.ts +1 -0
- package/dist/provider-anthropic.js +127 -52
- package/dist/provider-openai-codex.js +19 -29
- package/dist/session-log.js +3 -3
- package/dist/session.d.ts +31 -0
- package/dist/session.js +69 -0
- package/dist/slash-commands/commands.js +164 -0
- package/dist/slash-commands/types.d.ts +6 -0
- package/dist/tools/bash.js +4 -0
- package/dist/tools/edit-apply.js +63 -3
- package/dist/tools/edit.d.ts +2 -1
- package/dist/tools/edit.js +6 -5
- package/dist/tools/index.d.ts +7 -0
- package/dist/tools/index.js +2 -2
- package/dist/tools/write.d.ts +2 -1
- package/dist/tools/write.js +2 -1
- package/dist/tui/display-history.d.ts +4 -3
- package/dist/tui/display-history.js +34 -57
- package/dist/tui/display-sanitizer.d.ts +3 -0
- package/dist/tui/display-sanitizer.js +38 -0
- package/dist/tui/image-paste.d.ts +18 -0
- package/dist/tui/image-paste.js +60 -0
- package/dist/tui/paste-placeholder.d.ts +1 -0
- package/dist/tui/paste-placeholder.js +7 -0
- package/dist/tui/run.d.ts +2 -0
- package/dist/tui/run.js +568 -223
- package/dist/tui/trace-groups.d.ts +16 -0
- package/dist/tui/trace-groups.js +82 -5
- package/dist/tui/transcript-scroll.d.ts +25 -0
- package/dist/tui/transcript-scroll.js +20 -0
- package/dist/tui/wordmark.d.ts +1 -0
- package/dist/tui/wordmark.js +56 -54
- package/dist/tui-ink/app.d.ts +4 -1
- package/dist/tui-ink/app.js +303 -248
- 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 +90 -6
- 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/app.js +2 -1
- package/dist/tui-opentui/trace-groups.js +40 -4
- package/dist/types.d.ts +27 -0
- 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
|
-
|
|
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
|
+
}
|
|
@@ -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;
|