@bubblebrain-ai/bubble 0.0.12 → 0.0.14
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/execution-governor.js +1 -1
- package/dist/agent/input-controller.d.ts +11 -0
- package/dist/agent/input-controller.js +30 -0
- package/dist/agent/tool-intent.js +1 -0
- package/dist/agent.d.ts +8 -4
- package/dist/agent.js +623 -312
- package/dist/approval/controller.d.ts +1 -0
- package/dist/approval/controller.js +20 -3
- package/dist/approval/tool-helper.js +2 -0
- package/dist/approval/types.d.ts +14 -1
- package/dist/context/compact.js +9 -3
- package/dist/context/projector.js +27 -12
- package/dist/debug-trace.d.ts +27 -0
- package/dist/debug-trace.js +385 -0
- package/dist/feishu/agent-host/approval-card.js +9 -0
- package/dist/feishu/serve.js +7 -1
- package/dist/main.js +86 -9
- package/dist/model-catalog.js +1 -0
- package/dist/orchestrator/default-hooks.js +19 -8
- package/dist/orchestrator/hooks.d.ts +1 -0
- package/dist/prompt/environment.js +2 -0
- package/dist/prompt/reminders.d.ts +5 -6
- package/dist/prompt/reminders.js +8 -9
- package/dist/prompt/runtime.js +2 -2
- package/dist/provider-openai-codex.d.ts +7 -0
- package/dist/provider-openai-codex.js +265 -124
- package/dist/provider-registry.d.ts +2 -0
- package/dist/provider-registry.js +58 -9
- package/dist/provider.d.ts +3 -0
- package/dist/provider.js +5 -1
- package/dist/session-log.js +13 -1
- package/dist/slash-commands/commands.js +39 -0
- package/dist/slash-commands/types.d.ts +12 -0
- package/dist/stats/usage.d.ts +52 -0
- package/dist/stats/usage.js +414 -0
- package/dist/tools/apply-patch.d.ts +9 -0
- package/dist/tools/apply-patch.js +330 -0
- package/dist/tools/bash.js +205 -44
- package/dist/tools/edit-apply.d.ts +5 -2
- package/dist/tools/edit-apply.js +221 -31
- package/dist/tools/edit.js +12 -3
- package/dist/tools/file-mutation-queue.d.ts +1 -0
- package/dist/tools/file-mutation-queue.js +12 -1
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.js +7 -1
- package/dist/tools/patch-apply.d.ts +41 -0
- package/dist/tools/patch-apply.js +312 -0
- package/dist/tools/server-manager.d.ts +36 -0
- package/dist/tools/server-manager.js +234 -0
- package/dist/tools/server.d.ts +6 -0
- package/dist/tools/server.js +245 -0
- package/dist/tools/write.d.ts +3 -6
- package/dist/tools/write.js +26 -46
- package/dist/tui/clipboard.d.ts +1 -0
- package/dist/tui/clipboard.js +53 -0
- package/dist/tui/detect-theme.d.ts +2 -0
- package/dist/tui/detect-theme.js +87 -0
- package/dist/tui/display-history.d.ts +63 -0
- package/dist/tui/display-history.js +306 -0
- package/dist/tui/edit-diff.d.ts +11 -0
- package/dist/tui/edit-diff.js +57 -0
- package/dist/tui/escape-confirmation.d.ts +15 -0
- package/dist/tui/escape-confirmation.js +30 -0
- package/dist/tui/file-mentions.d.ts +29 -0
- package/dist/tui/file-mentions.js +174 -0
- package/dist/tui/global-key-router.d.ts +3 -0
- package/dist/tui/global-key-router.js +87 -0
- package/dist/tui/image-paste.d.ts +95 -0
- package/dist/tui/image-paste.js +505 -0
- package/dist/tui/input-history.d.ts +16 -0
- package/dist/tui/input-history.js +79 -0
- package/dist/tui/markdown-inline.d.ts +22 -0
- package/dist/tui/markdown-inline.js +68 -0
- package/dist/tui/markdown-theme-rules.d.ts +23 -0
- package/dist/tui/markdown-theme-rules.js +164 -0
- package/dist/tui/markdown-theme.d.ts +5 -0
- package/dist/tui/markdown-theme.js +27 -0
- package/dist/tui/model-picker-data.d.ts +10 -0
- package/dist/tui/model-picker-data.js +32 -0
- package/dist/tui/opencode-spinner.d.ts +22 -0
- package/dist/tui/opencode-spinner.js +216 -0
- package/dist/tui/prompt-keybindings.d.ts +42 -0
- package/dist/tui/prompt-keybindings.js +35 -0
- package/dist/tui/recent-activity.d.ts +8 -0
- package/dist/tui/recent-activity.js +71 -0
- package/dist/tui/render-signature.d.ts +1 -0
- package/dist/tui/render-signature.js +7 -0
- package/dist/tui/run.d.ts +45 -0
- package/dist/tui/run.js +9359 -0
- package/dist/tui/session-display.d.ts +6 -0
- package/dist/tui/session-display.js +12 -0
- package/dist/tui/sidebar-mcp.d.ts +31 -0
- package/dist/tui/sidebar-mcp.js +62 -0
- package/dist/tui/sidebar-state.d.ts +12 -0
- package/dist/tui/sidebar-state.js +69 -0
- package/dist/tui/streaming-tool-args.d.ts +15 -0
- package/dist/tui/streaming-tool-args.js +30 -0
- package/dist/tui/tool-renderers/fallback.d.ts +2 -0
- package/dist/tui/tool-renderers/fallback.js +75 -0
- package/dist/tui/tool-renderers/registry.d.ts +3 -0
- package/dist/tui/tool-renderers/registry.js +11 -0
- package/dist/tui/tool-renderers/subagent.d.ts +2 -0
- package/dist/tui/tool-renderers/subagent.js +135 -0
- package/dist/tui/tool-renderers/types.d.ts +36 -0
- package/dist/tui/tool-renderers/types.js +1 -0
- package/dist/tui/tool-renderers/write-preview.d.ts +12 -0
- package/dist/tui/tool-renderers/write-preview.js +32 -0
- package/dist/tui/tool-renderers/write.d.ts +6 -0
- package/dist/tui/tool-renderers/write.js +88 -0
- package/dist/tui/trace-groups.d.ts +27 -0
- package/dist/tui/trace-groups.js +419 -0
- package/dist/tui/wordmark.d.ts +15 -0
- package/dist/tui/wordmark.js +179 -0
- package/dist/tui-ink/app.js +45 -9
- package/dist/tui-ink/approval/approval-dialog.js +7 -1
- package/dist/tui-ink/display-history.d.ts +1 -0
- package/dist/tui-ink/display-history.js +5 -4
- package/dist/tui-ink/message-list.js +23 -9
- package/dist/tui-ink/theme.d.ts +3 -9
- package/dist/tui-ink/theme.js +39 -45
- package/dist/tui-ink/trace-groups.js +1 -1
- package/dist/tui-ink/welcome.js +22 -78
- package/dist/tui-opentui/app.d.ts +54 -0
- package/dist/tui-opentui/app.js +1365 -0
- package/dist/tui-opentui/approval/approval-dialog.d.ts +15 -0
- package/dist/tui-opentui/approval/approval-dialog.js +145 -0
- package/dist/tui-opentui/approval/diff-view.d.ts +9 -0
- package/dist/tui-opentui/approval/diff-view.js +43 -0
- package/dist/tui-opentui/approval/select.d.ts +37 -0
- package/dist/tui-opentui/approval/select.js +91 -0
- package/dist/tui-opentui/detect-theme.d.ts +2 -0
- package/dist/tui-opentui/detect-theme.js +87 -0
- package/dist/tui-opentui/display-history.d.ts +56 -0
- package/dist/tui-opentui/display-history.js +130 -0
- package/dist/tui-opentui/edit-diff.d.ts +11 -0
- package/dist/tui-opentui/edit-diff.js +57 -0
- package/dist/tui-opentui/feedback-dialog.d.ts +21 -0
- package/dist/tui-opentui/feedback-dialog.js +164 -0
- package/dist/tui-opentui/feishu-setup-picker.d.ts +7 -0
- package/dist/tui-opentui/feishu-setup-picker.js +272 -0
- package/dist/tui-opentui/file-mentions.d.ts +29 -0
- package/dist/tui-opentui/file-mentions.js +174 -0
- package/dist/tui-opentui/footer.d.ts +26 -0
- package/dist/tui-opentui/footer.js +40 -0
- package/dist/tui-opentui/image-paste.d.ts +54 -0
- package/dist/tui-opentui/image-paste.js +288 -0
- package/dist/tui-opentui/input-box.d.ts +34 -0
- package/dist/tui-opentui/input-box.js +471 -0
- package/dist/tui-opentui/input-history.d.ts +16 -0
- package/dist/tui-opentui/input-history.js +79 -0
- package/dist/tui-opentui/markdown.d.ts +66 -0
- package/dist/tui-opentui/markdown.js +127 -0
- package/dist/tui-opentui/message-list.d.ts +31 -0
- package/dist/tui-opentui/message-list.js +128 -0
- package/dist/tui-opentui/model-picker.d.ts +63 -0
- package/dist/tui-opentui/model-picker.js +450 -0
- package/dist/tui-opentui/plan-confirm.d.ts +9 -0
- package/dist/tui-opentui/plan-confirm.js +124 -0
- package/dist/tui-opentui/question-dialog.d.ts +10 -0
- package/dist/tui-opentui/question-dialog.js +110 -0
- package/dist/tui-opentui/recent-activity.d.ts +8 -0
- package/dist/tui-opentui/recent-activity.js +71 -0
- package/dist/tui-opentui/run-session-picker.d.ts +10 -0
- package/dist/tui-opentui/run-session-picker.js +28 -0
- package/dist/tui-opentui/run.d.ts +38 -0
- package/dist/tui-opentui/run.js +48 -0
- package/dist/tui-opentui/session-picker.d.ts +12 -0
- package/dist/tui-opentui/session-picker.js +120 -0
- package/dist/tui-opentui/theme.d.ts +89 -0
- package/dist/tui-opentui/theme.js +157 -0
- package/dist/tui-opentui/todos.d.ts +9 -0
- package/dist/tui-opentui/todos.js +45 -0
- package/dist/tui-opentui/trace-groups.d.ts +27 -0
- package/dist/tui-opentui/trace-groups.js +419 -0
- package/dist/tui-opentui/use-terminal-size.d.ts +4 -0
- package/dist/tui-opentui/use-terminal-size.js +5 -0
- package/dist/tui-opentui/welcome.d.ts +25 -0
- package/dist/tui-opentui/welcome.js +77 -0
- package/dist/types.d.ts +36 -2
- package/package.json +5 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
const MAX_INLINE_BYTES = 200 * 1024;
|
|
7
|
+
const IGNORED_DIRS = new Set([".git", "node_modules", "dist", "build", ".next", ".turbo", ".cache"]);
|
|
8
|
+
const fileListCache = new Map();
|
|
9
|
+
export function findAtContext(text, cursor) {
|
|
10
|
+
const before = text.slice(0, cursor);
|
|
11
|
+
const at = before.lastIndexOf("@");
|
|
12
|
+
if (at === -1)
|
|
13
|
+
return null;
|
|
14
|
+
const prev = at === 0 ? "" : before[at - 1];
|
|
15
|
+
if (prev !== "" && !/\s/.test(prev))
|
|
16
|
+
return null;
|
|
17
|
+
const query = before.slice(at + 1);
|
|
18
|
+
if (/\s/.test(query))
|
|
19
|
+
return null;
|
|
20
|
+
return { start: at, end: cursor, query };
|
|
21
|
+
}
|
|
22
|
+
export function filterFileSuggestions(files, query, limit = 20) {
|
|
23
|
+
const q = query.toLowerCase();
|
|
24
|
+
if (q.length === 0) {
|
|
25
|
+
return files.slice(0, limit).map((p) => ({ path: p, score: 1 }));
|
|
26
|
+
}
|
|
27
|
+
const scored = [];
|
|
28
|
+
for (const file of files) {
|
|
29
|
+
const lower = file.toLowerCase();
|
|
30
|
+
const base = path.basename(lower);
|
|
31
|
+
let score = 0;
|
|
32
|
+
if (base.startsWith(q))
|
|
33
|
+
score = 100;
|
|
34
|
+
else if (lower.startsWith(q))
|
|
35
|
+
score = 80;
|
|
36
|
+
else if (base.includes(q))
|
|
37
|
+
score = 60;
|
|
38
|
+
else if (lower.includes(q))
|
|
39
|
+
score = 40;
|
|
40
|
+
if (score > 0)
|
|
41
|
+
scored.push({ path: file, score });
|
|
42
|
+
}
|
|
43
|
+
scored.sort((a, b) => (b.score - a.score) || (a.path.length - b.path.length) || a.path.localeCompare(b.path));
|
|
44
|
+
return scored.slice(0, limit);
|
|
45
|
+
}
|
|
46
|
+
export async function listProjectFiles(cwd) {
|
|
47
|
+
const cached = fileListCache.get(cwd);
|
|
48
|
+
if (cached)
|
|
49
|
+
return cached;
|
|
50
|
+
const files = await discoverFiles(cwd);
|
|
51
|
+
fileListCache.set(cwd, files);
|
|
52
|
+
return files;
|
|
53
|
+
}
|
|
54
|
+
export function invalidateFileListCache(cwd) {
|
|
55
|
+
if (cwd)
|
|
56
|
+
fileListCache.delete(cwd);
|
|
57
|
+
else
|
|
58
|
+
fileListCache.clear();
|
|
59
|
+
}
|
|
60
|
+
async function discoverFiles(cwd) {
|
|
61
|
+
try {
|
|
62
|
+
const { stdout } = await execFileAsync("git", ["ls-files", "-co", "--exclude-standard"], {
|
|
63
|
+
cwd,
|
|
64
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
65
|
+
});
|
|
66
|
+
const files = stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
67
|
+
if (files.length > 0)
|
|
68
|
+
return files;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Not a git repo or git unavailable — fall through to filesystem walk.
|
|
72
|
+
}
|
|
73
|
+
return walkFilesystem(cwd);
|
|
74
|
+
}
|
|
75
|
+
async function walkFilesystem(root) {
|
|
76
|
+
const results = [];
|
|
77
|
+
async function visit(dir, rel) {
|
|
78
|
+
let entries;
|
|
79
|
+
try {
|
|
80
|
+
entries = (await fs.readdir(dir, { withFileTypes: true }));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
if (entry.name.startsWith(".") && entry.name !== ".env" && entry.name !== ".gitignore")
|
|
87
|
+
continue;
|
|
88
|
+
if (IGNORED_DIRS.has(entry.name))
|
|
89
|
+
continue;
|
|
90
|
+
const abs = path.join(dir, entry.name);
|
|
91
|
+
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
|
92
|
+
if (entry.isDirectory()) {
|
|
93
|
+
await visit(abs, relPath);
|
|
94
|
+
}
|
|
95
|
+
else if (entry.isFile()) {
|
|
96
|
+
results.push(relPath);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
await visit(root, "");
|
|
101
|
+
return results;
|
|
102
|
+
}
|
|
103
|
+
const MENTION_REGEX = /(^|\s)@([^\s]+)/g;
|
|
104
|
+
export async function expandAtMentions(text, cwd) {
|
|
105
|
+
const result = { text, expanded: [], missing: [], skipped: [] };
|
|
106
|
+
const mentions = Array.from(text.matchAll(MENTION_REGEX));
|
|
107
|
+
if (mentions.length === 0)
|
|
108
|
+
return result;
|
|
109
|
+
const blocks = [];
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
for (const match of mentions) {
|
|
112
|
+
const token = match[2];
|
|
113
|
+
if (seen.has(token))
|
|
114
|
+
continue;
|
|
115
|
+
seen.add(token);
|
|
116
|
+
const abs = path.resolve(cwd, token);
|
|
117
|
+
if (!abs.startsWith(path.resolve(cwd))) {
|
|
118
|
+
result.skipped.push({ path: token, reason: "outside project" });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
let stat;
|
|
122
|
+
try {
|
|
123
|
+
stat = await fs.stat(abs);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
result.missing.push(token);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!stat.isFile()) {
|
|
130
|
+
result.skipped.push({ path: token, reason: "not a file" });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (stat.size > MAX_INLINE_BYTES) {
|
|
134
|
+
result.skipped.push({ path: token, reason: "too large", bytes: stat.size });
|
|
135
|
+
blocks.push(`### @${token}\n(${formatBytes(stat.size)}, exceeds inline limit of ${formatBytes(MAX_INLINE_BYTES)} — use the Read tool to access)`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
let contents;
|
|
139
|
+
try {
|
|
140
|
+
contents = await fs.readFile(abs, "utf8");
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
result.skipped.push({ path: token, reason: `read failed: ${err.message || String(err)}` });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
result.expanded.push({ path: token, bytes: stat.size, truncated: false });
|
|
147
|
+
const lang = guessLanguage(token);
|
|
148
|
+
blocks.push(`### @${token}\n\`\`\`${lang}\n${contents}\n\`\`\``);
|
|
149
|
+
}
|
|
150
|
+
if (blocks.length === 0)
|
|
151
|
+
return result;
|
|
152
|
+
result.text = `${text}\n\n---\nReferenced files:\n\n${blocks.join("\n\n")}`;
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
function formatBytes(n) {
|
|
156
|
+
if (n < 1024)
|
|
157
|
+
return `${n}B`;
|
|
158
|
+
if (n < 1024 * 1024)
|
|
159
|
+
return `${(n / 1024).toFixed(1)}KB`;
|
|
160
|
+
return `${(n / 1024 / 1024).toFixed(1)}MB`;
|
|
161
|
+
}
|
|
162
|
+
function guessLanguage(filePath) {
|
|
163
|
+
const ext = path.extname(filePath).slice(1).toLowerCase();
|
|
164
|
+
const map = {
|
|
165
|
+
ts: "ts", tsx: "tsx", js: "js", jsx: "jsx",
|
|
166
|
+
py: "python", rb: "ruby", go: "go", rs: "rust",
|
|
167
|
+
java: "java", kt: "kotlin", swift: "swift",
|
|
168
|
+
c: "c", h: "c", cpp: "cpp", cc: "cpp", hpp: "cpp",
|
|
169
|
+
cs: "csharp", php: "php", sh: "bash", zsh: "bash", bash: "bash",
|
|
170
|
+
json: "json", yaml: "yaml", yml: "yaml", toml: "toml", xml: "xml",
|
|
171
|
+
html: "html", css: "css", scss: "scss", sql: "sql", md: "markdown",
|
|
172
|
+
};
|
|
173
|
+
return map[ext] ?? "";
|
|
174
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/react */
|
|
2
|
+
import React from "react";
|
|
3
|
+
import type { PermissionMode } from "../types.js";
|
|
4
|
+
export interface FooterUsageTotals {
|
|
5
|
+
prompt: number;
|
|
6
|
+
completion: number;
|
|
7
|
+
}
|
|
8
|
+
export interface FooterData {
|
|
9
|
+
cwd: string;
|
|
10
|
+
providerId: string;
|
|
11
|
+
model: string;
|
|
12
|
+
thinkingLevel: string;
|
|
13
|
+
showThinking: boolean;
|
|
14
|
+
mode?: PermissionMode;
|
|
15
|
+
usageTotals: FooterUsageTotals;
|
|
16
|
+
verboseTrace?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Status row beneath the input composer. opencode-style: dim hints on the
|
|
20
|
+
* left, provider + model + tokens on the right, separated by `·` middots.
|
|
21
|
+
* No border, single row of muted text.
|
|
22
|
+
*/
|
|
23
|
+
export declare function FooterBar({ data }: {
|
|
24
|
+
data: FooterData;
|
|
25
|
+
}): React.ReactNode;
|
|
26
|
+
export declare function buildFooterData(input: FooterData): FooterData;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { useTheme } from "./theme.js";
|
|
4
|
+
import { PERMISSION_MODE_INFO } from "../permission/mode.js";
|
|
5
|
+
/**
|
|
6
|
+
* Status row beneath the input composer. opencode-style: dim hints on the
|
|
7
|
+
* left, provider + model + tokens on the right, separated by `·` middots.
|
|
8
|
+
* No border, single row of muted text.
|
|
9
|
+
*/
|
|
10
|
+
export function FooterBar({ data }) {
|
|
11
|
+
const theme = useTheme();
|
|
12
|
+
const usageText = data.usageTotals.prompt || data.usageTotals.completion
|
|
13
|
+
? `↑${formatTokens(data.usageTotals.prompt)} ↓${formatTokens(data.usageTotals.completion)}`
|
|
14
|
+
: "";
|
|
15
|
+
const thinkingText = data.showThinking
|
|
16
|
+
? data.thinkingLevel && data.thinkingLevel !== "off"
|
|
17
|
+
? `⌃R ${data.thinkingLevel}`
|
|
18
|
+
: "⌃R off"
|
|
19
|
+
: "";
|
|
20
|
+
return (_jsx("box", { style: { flexDirection: "column", flexShrink: 0, marginTop: 1 }, children: _jsxs("box", { style: { paddingLeft: 2, paddingRight: 2, flexDirection: "row" }, children: [_jsx("text", { fg: theme.textMuted, content: formatCwd(data.cwd) }), _jsx("text", { fg: theme.textDim, content: " \u00B7 " }), _jsx("text", { fg: theme.accent, content: data.mode ? PERMISSION_MODE_INFO[data.mode]?.shortTitle ?? "default" : "default" }), data.mode && data.mode !== "default" && (_jsx("text", { fg: theme.textDim, content: " \u21E7\u21E5" })), usageText && (_jsxs(_Fragment, { children: [_jsx("text", { fg: theme.textDim, content: " \u00B7 " }), _jsx("text", { fg: theme.textMuted, content: usageText })] })), _jsx("box", { style: { flexGrow: 1 } }), _jsx("text", { fg: theme.textDim, content: data.providerId }), _jsx("text", { fg: theme.textDim, content: " \u00B7 " }), _jsx("text", { fg: theme.text, attributes: 1, content: data.model }), thinkingText && (_jsxs(_Fragment, { children: [_jsx("text", { fg: theme.textDim, content: " \u00B7 " }), _jsx("text", { fg: theme.textMuted, content: thinkingText })] }))] }) }));
|
|
21
|
+
}
|
|
22
|
+
export function buildFooterData(input) {
|
|
23
|
+
return input;
|
|
24
|
+
}
|
|
25
|
+
function formatTokens(count) {
|
|
26
|
+
if (count < 1000)
|
|
27
|
+
return String(count);
|
|
28
|
+
if (count < 10000)
|
|
29
|
+
return `${(count / 1000).toFixed(1)}k`;
|
|
30
|
+
if (count < 1000000)
|
|
31
|
+
return `${Math.round(count / 1000)}k`;
|
|
32
|
+
return `${(count / 1000000).toFixed(1)}M`;
|
|
33
|
+
}
|
|
34
|
+
function formatCwd(cwd) {
|
|
35
|
+
const home = homedir();
|
|
36
|
+
if (cwd.startsWith(home)) {
|
|
37
|
+
return `~${cwd.slice(home.length)}`;
|
|
38
|
+
}
|
|
39
|
+
return cwd;
|
|
40
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image paste utilities: path detection, file reading, clipboard access, and size-capping.
|
|
3
|
+
*
|
|
4
|
+
* Terminals don't forward image bytes to stdin. Paths arrive as text when users
|
|
5
|
+
* drag files in; Cmd+V of an image produces an empty paste (we probe the
|
|
6
|
+
* clipboard). macOS screenshot shortcut (Cmd+Shift+Ctrl+4) writes to both a
|
|
7
|
+
* TemporaryItems path and the clipboard — the path often gets cleaned up before
|
|
8
|
+
* we can read it, so we fall back to the clipboard.
|
|
9
|
+
*/
|
|
10
|
+
export interface ImageAttachment {
|
|
11
|
+
base64: string;
|
|
12
|
+
mediaType: string;
|
|
13
|
+
/** Raw byte size of the decoded image (not base64). */
|
|
14
|
+
bytes: number;
|
|
15
|
+
/** data:<mediaType>;base64,<...> — ready to send as image_url.url. */
|
|
16
|
+
dataUrl: string;
|
|
17
|
+
filename?: string;
|
|
18
|
+
sourcePath?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function isImageFilePath(raw: string): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Split a pasted blob into candidate path tokens.
|
|
23
|
+
*
|
|
24
|
+
* Multi-drag from Finder delivers a mix of newline- and space-separated
|
|
25
|
+
* absolute paths. Spaces inside a single path are escaped (`\ `) — we split
|
|
26
|
+
* only on a space that is followed by the start of a new absolute path.
|
|
27
|
+
*/
|
|
28
|
+
export declare function splitPastedPaths(pasted: string): string[];
|
|
29
|
+
export declare function readImageFromPath(rawPath: string): Promise<ImageAttachment | null>;
|
|
30
|
+
/** macOS screenshot shortcut writes to these paths and they may be auto-cleaned. */
|
|
31
|
+
export declare function isScreenshotTempPath(s: string): boolean;
|
|
32
|
+
export declare function getImageFromClipboard(): Promise<ImageAttachment | null>;
|
|
33
|
+
/**
|
|
34
|
+
* If the image is close to the API size cap, try to downscale it in place.
|
|
35
|
+
* Uses the OS-native tools that are typically available:
|
|
36
|
+
* - macOS: `sips` (always present)
|
|
37
|
+
* - linux: ImageMagick `convert` (if installed)
|
|
38
|
+
* Returns the original attachment if resize isn't needed or can't run.
|
|
39
|
+
*/
|
|
40
|
+
export declare function maybeResizeImage(att: ImageAttachment): Promise<ImageAttachment>;
|
|
41
|
+
export interface ValidationResult {
|
|
42
|
+
ok: boolean;
|
|
43
|
+
reason?: string;
|
|
44
|
+
}
|
|
45
|
+
export declare function validateImageSize(att: ImageAttachment): ValidationResult;
|
|
46
|
+
/** End-to-end: given a file path, read -> resize-if-needed -> validate. */
|
|
47
|
+
export declare function ingestImagePath(p: string): Promise<{
|
|
48
|
+
attachment?: ImageAttachment;
|
|
49
|
+
error?: string;
|
|
50
|
+
}>;
|
|
51
|
+
export declare function ingestClipboardImage(): Promise<{
|
|
52
|
+
attachment?: ImageAttachment;
|
|
53
|
+
error?: string;
|
|
54
|
+
}>;
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image paste utilities: path detection, file reading, clipboard access, and size-capping.
|
|
3
|
+
*
|
|
4
|
+
* Terminals don't forward image bytes to stdin. Paths arrive as text when users
|
|
5
|
+
* drag files in; Cmd+V of an image produces an empty paste (we probe the
|
|
6
|
+
* clipboard). macOS screenshot shortcut (Cmd+Shift+Ctrl+4) writes to both a
|
|
7
|
+
* TemporaryItems path and the clipboard — the path often gets cleaned up before
|
|
8
|
+
* we can read it, so we fall back to the clipboard.
|
|
9
|
+
*/
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
11
|
+
import fs from "node:fs/promises";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
|
17
|
+
// Anthropic/OpenAI image uploads cap at ~5MB base64. We target a bit below so
|
|
18
|
+
// the base64 inflation (4/3) doesn't push us over.
|
|
19
|
+
const MAX_BASE64_BYTES = 5 * 1024 * 1024;
|
|
20
|
+
const RESIZE_TRIGGER_BYTES = Math.floor(MAX_BASE64_BYTES * 0.95);
|
|
21
|
+
// Target max dimension for auto-resize.
|
|
22
|
+
const RESIZE_MAX_DIM = 2048;
|
|
23
|
+
export function isImageFilePath(raw) {
|
|
24
|
+
const s = raw.trim();
|
|
25
|
+
if (!s)
|
|
26
|
+
return false;
|
|
27
|
+
if (!IMAGE_EXT.test(s))
|
|
28
|
+
return false;
|
|
29
|
+
// Require an absolute or home-relative path. Pasted arbitrary text shouldn't
|
|
30
|
+
// be treated as a path.
|
|
31
|
+
return path.isAbsolute(s) || s.startsWith("~") || /^[A-Za-z]:\\/.test(s);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Split a pasted blob into candidate path tokens.
|
|
35
|
+
*
|
|
36
|
+
* Multi-drag from Finder delivers a mix of newline- and space-separated
|
|
37
|
+
* absolute paths. Spaces inside a single path are escaped (`\ `) — we split
|
|
38
|
+
* only on a space that is followed by the start of a new absolute path.
|
|
39
|
+
*/
|
|
40
|
+
export function splitPastedPaths(pasted) {
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const line of pasted.split(/\r?\n/)) {
|
|
43
|
+
for (const piece of line.split(/ (?=\/|[A-Za-z]:\\)/)) {
|
|
44
|
+
const t = piece.trim();
|
|
45
|
+
if (t)
|
|
46
|
+
out.push(t);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
function mediaTypeFromExt(p) {
|
|
52
|
+
const ext = path.extname(p).toLowerCase();
|
|
53
|
+
if (ext === ".jpg" || ext === ".jpeg")
|
|
54
|
+
return "image/jpeg";
|
|
55
|
+
if (ext === ".gif")
|
|
56
|
+
return "image/gif";
|
|
57
|
+
if (ext === ".webp")
|
|
58
|
+
return "image/webp";
|
|
59
|
+
if (ext === ".bmp")
|
|
60
|
+
return "image/bmp";
|
|
61
|
+
return "image/png";
|
|
62
|
+
}
|
|
63
|
+
function resolveHome(p) {
|
|
64
|
+
if (p.startsWith("~/") || p === "~") {
|
|
65
|
+
return path.join(os.homedir(), p.slice(1));
|
|
66
|
+
}
|
|
67
|
+
return p;
|
|
68
|
+
}
|
|
69
|
+
function unescapeShell(p) {
|
|
70
|
+
return p.replace(/\\ /g, " ");
|
|
71
|
+
}
|
|
72
|
+
function attachmentFromBuffer(buffer, mediaType, meta = {}) {
|
|
73
|
+
const base64 = buffer.toString("base64");
|
|
74
|
+
return {
|
|
75
|
+
base64,
|
|
76
|
+
mediaType,
|
|
77
|
+
bytes: buffer.length,
|
|
78
|
+
dataUrl: `data:${mediaType};base64,${base64}`,
|
|
79
|
+
filename: meta.filename,
|
|
80
|
+
sourcePath: meta.sourcePath,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export async function readImageFromPath(rawPath) {
|
|
84
|
+
const resolved = resolveHome(unescapeShell(rawPath.trim()));
|
|
85
|
+
try {
|
|
86
|
+
const stat = await fs.stat(resolved);
|
|
87
|
+
if (!stat.isFile())
|
|
88
|
+
return null;
|
|
89
|
+
const buffer = await fs.readFile(resolved);
|
|
90
|
+
return attachmentFromBuffer(buffer, mediaTypeFromExt(resolved), {
|
|
91
|
+
filename: path.basename(resolved),
|
|
92
|
+
sourcePath: resolved,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** macOS screenshot shortcut writes to these paths and they may be auto-cleaned. */
|
|
100
|
+
export function isScreenshotTempPath(s) {
|
|
101
|
+
return /\/TemporaryItems\/.*screencaptureui.*\/Screenshot/i.test(s);
|
|
102
|
+
}
|
|
103
|
+
export async function getImageFromClipboard() {
|
|
104
|
+
switch (process.platform) {
|
|
105
|
+
case "darwin":
|
|
106
|
+
return getClipboardImageDarwin();
|
|
107
|
+
case "linux":
|
|
108
|
+
return getClipboardImageLinux();
|
|
109
|
+
case "win32":
|
|
110
|
+
return getClipboardImageWindows();
|
|
111
|
+
default:
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function getClipboardImageDarwin() {
|
|
116
|
+
// Probe first — `as «class PNGf»` throws if clipboard has no image.
|
|
117
|
+
try {
|
|
118
|
+
await execFileAsync("osascript", ["-e", "the clipboard as «class PNGf»"], {
|
|
119
|
+
timeout: 5000,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const tmp = path.join(os.tmpdir(), `bubble_clip_${Date.now()}_${process.pid}.png`);
|
|
126
|
+
const script = `set png_data to (the clipboard as «class PNGf»)\n` +
|
|
127
|
+
`set fp to open for access POSIX file "${tmp}" with write permission\n` +
|
|
128
|
+
`write png_data to fp\n` +
|
|
129
|
+
`close access fp`;
|
|
130
|
+
try {
|
|
131
|
+
await execFileAsync("osascript", ["-e", script], { timeout: 5000 });
|
|
132
|
+
const buf = await fs.readFile(tmp);
|
|
133
|
+
return attachmentFromBuffer(buf, "image/png");
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
await fs.unlink(tmp).catch(() => undefined);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function getClipboardImageLinux() {
|
|
143
|
+
const candidates = [
|
|
144
|
+
["xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]],
|
|
145
|
+
["wl-paste", ["--type", "image/png"]],
|
|
146
|
+
];
|
|
147
|
+
for (const [cmd, args] of candidates) {
|
|
148
|
+
try {
|
|
149
|
+
// encoding: "buffer" makes stdout a Buffer instead of a string so PNG
|
|
150
|
+
// bytes survive without UTF-8 mangling.
|
|
151
|
+
const result = await execFileAsync(cmd, args, {
|
|
152
|
+
timeout: 5000,
|
|
153
|
+
encoding: "buffer",
|
|
154
|
+
});
|
|
155
|
+
const buf = result.stdout;
|
|
156
|
+
if (buf && buf.length > 0) {
|
|
157
|
+
return attachmentFromBuffer(buf, "image/png");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
async function getClipboardImageWindows() {
|
|
167
|
+
const tmp = path.join(os.tmpdir(), `bubble_clip_${Date.now()}_${process.pid}.png`);
|
|
168
|
+
const tmpPs = tmp.replace(/\\/g, "\\\\");
|
|
169
|
+
const script = `Add-Type -AssemblyName System.Drawing; ` +
|
|
170
|
+
`$img = Get-Clipboard -Format Image; ` +
|
|
171
|
+
`if ($img) { $img.Save('${tmpPs}', [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'OK' } ` +
|
|
172
|
+
`else { Write-Output 'NONE' }`;
|
|
173
|
+
try {
|
|
174
|
+
const result = await execFileAsync("powershell", ["-NoProfile", "-Command", script], { timeout: 5000 });
|
|
175
|
+
if (!String(result.stdout).includes("OK"))
|
|
176
|
+
return null;
|
|
177
|
+
const buf = await fs.readFile(tmp);
|
|
178
|
+
return attachmentFromBuffer(buf, "image/png");
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
await fs.unlink(tmp).catch(() => undefined);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function which(cmd) {
|
|
188
|
+
try {
|
|
189
|
+
await execFileAsync(process.platform === "win32" ? "where" : "which", [cmd], {
|
|
190
|
+
timeout: 1500,
|
|
191
|
+
});
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* If the image is close to the API size cap, try to downscale it in place.
|
|
200
|
+
* Uses the OS-native tools that are typically available:
|
|
201
|
+
* - macOS: `sips` (always present)
|
|
202
|
+
* - linux: ImageMagick `convert` (if installed)
|
|
203
|
+
* Returns the original attachment if resize isn't needed or can't run.
|
|
204
|
+
*/
|
|
205
|
+
export async function maybeResizeImage(att) {
|
|
206
|
+
if (att.base64.length < RESIZE_TRIGGER_BYTES)
|
|
207
|
+
return att;
|
|
208
|
+
const tmpDir = os.tmpdir();
|
|
209
|
+
const stamp = `${Date.now()}_${process.pid}`;
|
|
210
|
+
const inExt = path.extname(att.filename ?? att.sourcePath ?? `.png`).toLowerCase() || ".png";
|
|
211
|
+
const tmpIn = path.join(tmpDir, `bubble_img_in_${stamp}${inExt}`);
|
|
212
|
+
const tmpOut = path.join(tmpDir, `bubble_img_out_${stamp}.jpg`);
|
|
213
|
+
try {
|
|
214
|
+
await fs.writeFile(tmpIn, Buffer.from(att.base64, "base64"));
|
|
215
|
+
let ok = false;
|
|
216
|
+
if (process.platform === "darwin") {
|
|
217
|
+
try {
|
|
218
|
+
await execFileAsync("sips", ["-Z", String(RESIZE_MAX_DIM), "-s", "format", "jpeg", "-s", "formatOptions", "80", tmpIn, "--out", tmpOut], { timeout: 10000 });
|
|
219
|
+
ok = true;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
ok = false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
else if (await which("convert")) {
|
|
226
|
+
try {
|
|
227
|
+
await execFileAsync("convert", [tmpIn, "-resize", `${RESIZE_MAX_DIM}x${RESIZE_MAX_DIM}>`, "-quality", "80", tmpOut], { timeout: 10000 });
|
|
228
|
+
ok = true;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
ok = false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (!ok)
|
|
235
|
+
return att;
|
|
236
|
+
const resized = await fs.readFile(tmpOut);
|
|
237
|
+
if (resized.length >= att.bytes)
|
|
238
|
+
return att;
|
|
239
|
+
return attachmentFromBuffer(resized, "image/jpeg", {
|
|
240
|
+
filename: att.filename,
|
|
241
|
+
sourcePath: att.sourcePath,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return att;
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
await fs.unlink(tmpIn).catch(() => undefined);
|
|
249
|
+
await fs.unlink(tmpOut).catch(() => undefined);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
export function validateImageSize(att) {
|
|
253
|
+
if (att.base64.length > MAX_BASE64_BYTES) {
|
|
254
|
+
const kb = Math.round(att.base64.length / 1024);
|
|
255
|
+
const max = Math.round(MAX_BASE64_BYTES / 1024);
|
|
256
|
+
const hint = process.platform === "darwin"
|
|
257
|
+
? " (install/confirm `sips` on PATH to auto-resize)"
|
|
258
|
+
: process.platform === "linux"
|
|
259
|
+
? " (install ImageMagick `convert` to auto-resize)"
|
|
260
|
+
: "";
|
|
261
|
+
return {
|
|
262
|
+
ok: false,
|
|
263
|
+
reason: `image base64 is ${kb}KB, exceeds ${max}KB API cap${hint}`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
return { ok: true };
|
|
267
|
+
}
|
|
268
|
+
/** End-to-end: given a file path, read -> resize-if-needed -> validate. */
|
|
269
|
+
export async function ingestImagePath(p) {
|
|
270
|
+
const raw = await readImageFromPath(p);
|
|
271
|
+
if (!raw)
|
|
272
|
+
return { error: `cannot read image at ${p}` };
|
|
273
|
+
const sized = await maybeResizeImage(raw);
|
|
274
|
+
const validation = validateImageSize(sized);
|
|
275
|
+
if (!validation.ok)
|
|
276
|
+
return { error: validation.reason };
|
|
277
|
+
return { attachment: sized };
|
|
278
|
+
}
|
|
279
|
+
export async function ingestClipboardImage() {
|
|
280
|
+
const raw = await getImageFromClipboard();
|
|
281
|
+
if (!raw)
|
|
282
|
+
return { error: "clipboard has no image" };
|
|
283
|
+
const sized = await maybeResizeImage(raw);
|
|
284
|
+
const validation = validateImageSize(sized);
|
|
285
|
+
if (!validation.ok)
|
|
286
|
+
return { error: validation.reason };
|
|
287
|
+
return { attachment: sized };
|
|
288
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/react */
|
|
2
|
+
/**
|
|
3
|
+
* Composer input for the OpenTUI TUI. Manages a multi-line editable buffer,
|
|
4
|
+
* cursor, history, file mention autocomplete, slash-command autocomplete,
|
|
5
|
+
* and text + image paste. Simpler than the Ink version because OpenTUI
|
|
6
|
+
* handles paste and selection at the native layer.
|
|
7
|
+
*/
|
|
8
|
+
import React from "react";
|
|
9
|
+
import type { SkillRegistry } from "../skills/registry.js";
|
|
10
|
+
import { type ImageAttachment } from "./image-paste.js";
|
|
11
|
+
export interface SubmitPayload {
|
|
12
|
+
text: string;
|
|
13
|
+
displayText?: string;
|
|
14
|
+
images: ImageAttachment[];
|
|
15
|
+
}
|
|
16
|
+
interface InputBoxProps {
|
|
17
|
+
onSubmit: (payload: SubmitPayload) => void;
|
|
18
|
+
onPasteNotice?: (notice: string) => void;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
cursorResetEpoch?: number;
|
|
21
|
+
draftText?: string;
|
|
22
|
+
draftEpoch?: number;
|
|
23
|
+
onDraftApplied?: () => void;
|
|
24
|
+
skillRegistry?: SkillRegistry;
|
|
25
|
+
terminalColumns: number;
|
|
26
|
+
cwd: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function shouldCollapsePastedContent(text: string): boolean;
|
|
29
|
+
export declare function createPastedContentMarker(content: string): string;
|
|
30
|
+
export declare function isCtrlCInput(input: string, key: {
|
|
31
|
+
ctrl?: boolean;
|
|
32
|
+
}): boolean;
|
|
33
|
+
export declare function InputBox({ onSubmit, onPasteNotice, disabled, cursorResetEpoch, draftText, draftEpoch, onDraftApplied, skillRegistry, terminalColumns, cwd, }: InputBoxProps): React.ReactNode;
|
|
34
|
+
export {};
|