@bubblebrain-ai/bubble 0.0.7 → 0.0.9
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/categories.d.ts +34 -0
- package/dist/agent/categories.js +98 -0
- package/dist/agent/profiles.d.ts +4 -0
- package/dist/agent/profiles.js +2 -3
- package/dist/agent/subagent-control.d.ts +5 -0
- package/dist/agent/subagent-control.js +4 -0
- package/dist/agent/subagent-lifecycle-reminder.d.ts +3 -0
- package/dist/agent/subagent-lifecycle-reminder.js +102 -0
- package/dist/agent/subagent-route-format.d.ts +8 -0
- package/dist/agent/subagent-route-format.js +18 -0
- package/dist/agent/subtask-policy.d.ts +0 -1
- package/dist/agent/subtask-policy.js +0 -4
- package/dist/agent.d.ts +18 -0
- package/dist/agent.js +188 -16
- package/dist/config.d.ts +23 -3
- package/dist/config.js +59 -6
- package/dist/context/budget.d.ts +3 -2
- package/dist/context/budget.js +29 -15
- package/dist/context/compact.d.ts +23 -0
- package/dist/context/compact.js +129 -0
- package/dist/context/llm-compactor.d.ts +19 -0
- package/dist/context/llm-compactor.js +200 -0
- package/dist/context/projector.js +28 -12
- package/dist/context/token-estimator.d.ts +14 -0
- package/dist/context/token-estimator.js +106 -0
- package/dist/context/tool-output-truncate.d.ts +8 -0
- package/dist/context/tool-output-truncate.js +59 -0
- package/dist/context/usage.d.ts +34 -0
- package/dist/context/usage.js +213 -0
- package/dist/diff-stats.d.ts +5 -0
- package/dist/diff-stats.js +21 -0
- package/dist/main.js +68 -7
- package/dist/mcp/transports.d.ts +1 -0
- package/dist/mcp/transports.js +8 -0
- package/dist/model-catalog.d.ts +9 -0
- package/dist/model-catalog.js +17 -1
- package/dist/orchestrator/default-hooks.js +24 -18
- package/dist/prompt/compose.js +2 -1
- package/dist/prompt/provider-prompts/kimi.js +3 -1
- package/dist/provider-openai-codex.d.ts +13 -2
- package/dist/provider-openai-codex.js +81 -32
- package/dist/provider-registry.js +22 -6
- package/dist/provider-transform.d.ts +3 -1
- package/dist/provider-transform.js +15 -0
- package/dist/provider.d.ts +4 -1
- package/dist/provider.js +89 -4
- package/dist/reasoning-debug.d.ts +7 -0
- package/dist/reasoning-debug.js +30 -0
- package/dist/session-log.js +13 -2
- package/dist/session-types.d.ts +1 -1
- package/dist/slash-commands/commands.js +60 -2
- package/dist/slash-commands/types.d.ts +7 -0
- package/dist/tools/agent-lifecycle.js +22 -4
- package/dist/tools/edit.js +7 -2
- package/dist/tools/file-state.d.ts +19 -0
- package/dist/tools/file-state.js +15 -0
- package/dist/tools/glob.js +2 -1
- package/dist/tools/grep.js +2 -2
- package/dist/tools/lsp.js +2 -2
- package/dist/tools/path-utils.d.ts +2 -0
- package/dist/tools/path-utils.js +16 -0
- package/dist/tools/read.d.ts +1 -1
- package/dist/tools/read.js +207 -14
- package/dist/tools/write.js +3 -2
- package/dist/tui/escape-confirmation.d.ts +15 -0
- package/dist/tui/escape-confirmation.js +30 -0
- package/dist/tui/run.js +93 -23
- package/dist/tui-ink/app.d.ts +52 -0
- package/dist/tui-ink/app.js +1129 -0
- package/dist/tui-ink/approval/approval-dialog.d.ts +13 -0
- package/dist/tui-ink/approval/approval-dialog.js +132 -0
- package/dist/tui-ink/approval/diff-view.d.ts +7 -0
- package/dist/tui-ink/approval/diff-view.js +44 -0
- package/dist/tui-ink/approval/select.d.ts +35 -0
- package/dist/tui-ink/approval/select.js +88 -0
- package/dist/tui-ink/code-highlight.d.ts +8 -0
- package/dist/tui-ink/code-highlight.js +122 -0
- package/dist/tui-ink/detect-theme.d.ts +19 -0
- package/dist/tui-ink/detect-theme.js +123 -0
- package/dist/tui-ink/display-history.d.ts +38 -0
- package/dist/tui-ink/display-history.js +130 -0
- package/dist/tui-ink/edit-diff.d.ts +11 -0
- package/dist/tui-ink/edit-diff.js +52 -0
- package/dist/tui-ink/file-mentions.d.ts +29 -0
- package/dist/tui-ink/file-mentions.js +174 -0
- package/dist/tui-ink/footer.d.ts +19 -0
- package/dist/tui-ink/footer.js +45 -0
- package/dist/tui-ink/image-paste.d.ts +54 -0
- package/dist/tui-ink/image-paste.js +288 -0
- package/dist/tui-ink/input-box.d.ts +41 -0
- package/dist/tui-ink/input-box.js +694 -0
- package/dist/tui-ink/input-history.d.ts +16 -0
- package/dist/tui-ink/input-history.js +81 -0
- package/dist/tui-ink/markdown.d.ts +38 -0
- package/dist/tui-ink/markdown.js +394 -0
- package/dist/tui-ink/message-list.d.ts +33 -0
- package/dist/tui-ink/message-list.js +667 -0
- package/dist/tui-ink/model-picker.d.ts +43 -0
- package/dist/tui-ink/model-picker.js +331 -0
- package/dist/tui-ink/plan-confirm.d.ts +7 -0
- package/dist/tui-ink/plan-confirm.js +105 -0
- package/dist/tui-ink/question-dialog.d.ts +8 -0
- package/dist/tui-ink/question-dialog.js +99 -0
- package/dist/tui-ink/recent-activity.d.ts +8 -0
- package/dist/tui-ink/recent-activity.js +71 -0
- package/dist/tui-ink/run.d.ts +37 -0
- package/dist/tui-ink/run.js +53 -0
- package/dist/tui-ink/theme.d.ts +66 -0
- package/dist/tui-ink/theme.js +115 -0
- package/dist/tui-ink/todos.d.ts +7 -0
- package/dist/tui-ink/todos.js +46 -0
- package/dist/tui-ink/trace-groups.d.ts +27 -0
- package/dist/tui-ink/trace-groups.js +389 -0
- package/dist/tui-ink/use-terminal-size.d.ts +4 -0
- package/dist/tui-ink/use-terminal-size.js +21 -0
- package/dist/tui-ink/welcome.d.ts +18 -0
- package/dist/tui-ink/welcome.js +138 -0
- package/dist/types.d.ts +10 -0
- package/package.json +7 -1
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { Box, Text, useCursor, useInput, usePaste, useStdout } from "ink";
|
|
4
|
+
import stringWidth from "string-width";
|
|
5
|
+
import { appendFileSync } from "node:fs";
|
|
6
|
+
import { registry as slashRegistry } from "../slash-commands/index.js";
|
|
7
|
+
import { useTheme } from "./theme.js";
|
|
8
|
+
import { filterFileSuggestions, findAtContext, listProjectFiles } from "./file-mentions.js";
|
|
9
|
+
import { ingestClipboardImage, ingestImagePath, isImageFilePath, isScreenshotTempPath, splitPastedPaths, } from "./image-paste.js";
|
|
10
|
+
import { appendHistoryEntry, loadHistorySync, pushHistoryEntry, stepHistory, } from "./input-history.js";
|
|
11
|
+
const MIN_VISIBLE_LINES = 3;
|
|
12
|
+
const MAX_VISIBLE_LINES = 6;
|
|
13
|
+
const PADDING_X = 1;
|
|
14
|
+
const PROMPT = " > ";
|
|
15
|
+
const MAX_VISIBLE_SUGGESTIONS = 8;
|
|
16
|
+
export function needsCursorRowCompensation(nextOutputHeight, viewportRows, previousOutputHeight) {
|
|
17
|
+
const hadPreviousFrame = previousOutputHeight !== null && previousOutputHeight > 0;
|
|
18
|
+
const isFullscreen = nextOutputHeight >= viewportRows;
|
|
19
|
+
const wasFullscreen = hadPreviousFrame && previousOutputHeight >= viewportRows;
|
|
20
|
+
const wasOverflowing = hadPreviousFrame && previousOutputHeight > viewportRows;
|
|
21
|
+
const isOverflowing = nextOutputHeight > viewportRows;
|
|
22
|
+
const isLeavingFullscreen = wasFullscreen && nextOutputHeight < viewportRows;
|
|
23
|
+
// Ink omits the trailing newline in two cases that matter for cursor math:
|
|
24
|
+
// the normal fullscreen frame, and the clear/sync frame used when leaving an
|
|
25
|
+
// overflowing viewport. buildCursorSuffix still assumes the cursor starts one
|
|
26
|
+
// line below the output, so pass y+1 in those cases.
|
|
27
|
+
return isFullscreen || wasOverflowing || (isOverflowing && hadPreviousFrame) || isLeavingFullscreen;
|
|
28
|
+
}
|
|
29
|
+
// Break a logical line into segments that each fit within `maxWidth` display
|
|
30
|
+
// columns. Uses string-width so CJK and emoji wrap correctly; empty lines
|
|
31
|
+
// still produce one empty segment so cursors on blank lines render.
|
|
32
|
+
function wrapLineByWidth(line, maxWidth) {
|
|
33
|
+
if (line.length === 0)
|
|
34
|
+
return [""];
|
|
35
|
+
const out = [];
|
|
36
|
+
let current = "";
|
|
37
|
+
let currentWidth = 0;
|
|
38
|
+
for (const ch of line) {
|
|
39
|
+
const w = stringWidth(ch);
|
|
40
|
+
if (currentWidth + w > maxWidth && current.length > 0) {
|
|
41
|
+
out.push(current);
|
|
42
|
+
current = "";
|
|
43
|
+
currentWidth = 0;
|
|
44
|
+
}
|
|
45
|
+
current += ch;
|
|
46
|
+
currentWidth += w;
|
|
47
|
+
}
|
|
48
|
+
if (current.length > 0 || out.length === 0)
|
|
49
|
+
out.push(current);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
function computeVisualLines(text, maxWidth) {
|
|
53
|
+
const logical = text.split("\n");
|
|
54
|
+
const out = [];
|
|
55
|
+
let abs = 0;
|
|
56
|
+
for (let lIdx = 0; lIdx < logical.length; lIdx++) {
|
|
57
|
+
const line = logical[lIdx];
|
|
58
|
+
const segments = wrapLineByWidth(line, maxWidth);
|
|
59
|
+
let offset = 0;
|
|
60
|
+
for (const seg of segments) {
|
|
61
|
+
out.push({ text: seg, absStart: abs + offset, logicalLineIndex: lIdx });
|
|
62
|
+
offset += seg.length;
|
|
63
|
+
}
|
|
64
|
+
abs += line.length + 1; // consume the "\n"
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
// Map a source-text cursor index to its (visualRow, visualCol) coordinates.
|
|
69
|
+
function cursorToVisual(visualLines, cursor) {
|
|
70
|
+
if (visualLines.length === 0)
|
|
71
|
+
return { row: 0, col: 0 };
|
|
72
|
+
let row = 0;
|
|
73
|
+
for (let i = 0; i < visualLines.length; i++) {
|
|
74
|
+
if (visualLines[i].absStart <= cursor)
|
|
75
|
+
row = i;
|
|
76
|
+
else
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
const vl = visualLines[row];
|
|
80
|
+
const charOffset = Math.max(0, cursor - vl.absStart);
|
|
81
|
+
return { row, col: stringWidth(vl.text.slice(0, charOffset)) };
|
|
82
|
+
}
|
|
83
|
+
// Map a (visualRow, visualCol) target back to a source-text cursor index.
|
|
84
|
+
// Used by up/down arrows to preserve the visual column when jumping rows.
|
|
85
|
+
function visualToCursor(visualLines, row, col) {
|
|
86
|
+
if (visualLines.length === 0)
|
|
87
|
+
return 0;
|
|
88
|
+
const clamped = Math.max(0, Math.min(visualLines.length - 1, row));
|
|
89
|
+
const vl = visualLines[clamped];
|
|
90
|
+
let width = 0;
|
|
91
|
+
let charOffset = 0;
|
|
92
|
+
for (const ch of vl.text) {
|
|
93
|
+
const w = stringWidth(ch);
|
|
94
|
+
if (width + w > col)
|
|
95
|
+
break;
|
|
96
|
+
width += w;
|
|
97
|
+
charOffset += ch.length;
|
|
98
|
+
}
|
|
99
|
+
return vl.absStart + charOffset;
|
|
100
|
+
}
|
|
101
|
+
export function shouldSubmitExactSlashSuggestion(input, suggestionName) {
|
|
102
|
+
if (!suggestionName)
|
|
103
|
+
return false;
|
|
104
|
+
return input.trim() === `/${suggestionName}`;
|
|
105
|
+
}
|
|
106
|
+
export function resolveSlashEnterAction(input, suggestions, selectedIndex) {
|
|
107
|
+
if (suggestions.some((item) => shouldSubmitExactSlashSuggestion(input, item.name))) {
|
|
108
|
+
return { kind: "submit" };
|
|
109
|
+
}
|
|
110
|
+
const suggestion = suggestions[selectedIndex];
|
|
111
|
+
return suggestion ? { kind: "complete", text: `/${suggestion.name} ` } : { kind: "none" };
|
|
112
|
+
}
|
|
113
|
+
const KITTY_RETURN_PRIVATE_USE = String.fromCodePoint(57345);
|
|
114
|
+
export function isInkModifiedEnterInput(input) {
|
|
115
|
+
const normalized = input.startsWith("\x1b") ? input.slice(1) : input;
|
|
116
|
+
return normalized === KITTY_RETURN_PRIVATE_USE
|
|
117
|
+
|| /^\[(?:13|57345)(?::\d+){0,2};[2-9]\d*(?::[12])?u$/.test(normalized)
|
|
118
|
+
|| /^\[27;[2-9]\d*(?::[12])?;(?:13|57345)~$/.test(normalized);
|
|
119
|
+
}
|
|
120
|
+
export function resolveInkEnterIntent(input, key) {
|
|
121
|
+
if (key.eventType === "release")
|
|
122
|
+
return "none";
|
|
123
|
+
const hasReturnInput = !!input && /[\r\n]/.test(input);
|
|
124
|
+
if (isInkModifiedEnterInput(input))
|
|
125
|
+
return "newline";
|
|
126
|
+
const isEnter = hasReturnInput || !!key.return;
|
|
127
|
+
if (!isEnter)
|
|
128
|
+
return "none";
|
|
129
|
+
if (key.shift || key.ctrl || key.meta)
|
|
130
|
+
return "newline";
|
|
131
|
+
return "submit";
|
|
132
|
+
}
|
|
133
|
+
export function insertNewlineAtCursor(text, cursor) {
|
|
134
|
+
const clampedCursor = Math.max(0, Math.min(text.length, cursor));
|
|
135
|
+
return {
|
|
136
|
+
text: `${text.slice(0, clampedCursor)}\n${text.slice(clampedCursor)}`,
|
|
137
|
+
cursor: clampedCursor + 1,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, terminalColumns, cwd }) {
|
|
141
|
+
const theme = useTheme();
|
|
142
|
+
const width = terminalColumns;
|
|
143
|
+
const [text, setText] = useState("");
|
|
144
|
+
const [cursor, setCursor] = useState(0);
|
|
145
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
146
|
+
const [projectFiles, setProjectFiles] = useState(null);
|
|
147
|
+
const [attachments, setAttachments] = useState([]);
|
|
148
|
+
const [history, setHistory] = useState(() => loadHistorySync());
|
|
149
|
+
const [historyIndex, setHistoryIndex] = useState(null);
|
|
150
|
+
const historyDraftRef = useRef("");
|
|
151
|
+
const loadingFilesRef = useRef(false);
|
|
152
|
+
// Paste and the keystrokes that follow can arrive inside the same stdin chunk
|
|
153
|
+
// and dispatch within one discreteUpdates batch. If the Enter that a user
|
|
154
|
+
// typed after a paste fires before React commits the paste-driven setState,
|
|
155
|
+
// useInput's Enter branch reads stale `text` and submits without the paste.
|
|
156
|
+
// This ref flips synchronously at paste-start and clears after the paste
|
|
157
|
+
// commit has been flushed — useInput's Enter handler bails while it's set.
|
|
158
|
+
const pastePendingRef = useRef(false);
|
|
159
|
+
const isSlashContext = text.startsWith("/") && cursor > 0 && !text.includes("\n");
|
|
160
|
+
const slashPrefix = isSlashContext ? text.slice(1).toLowerCase() : "";
|
|
161
|
+
const atContext = useMemo(() => (isSlashContext ? null : findAtContext(text, cursor)), [text, cursor, isSlashContext]);
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!atContext || projectFiles !== null || loadingFilesRef.current)
|
|
164
|
+
return;
|
|
165
|
+
loadingFilesRef.current = true;
|
|
166
|
+
listProjectFiles(cwd).then((files) => setProjectFiles(files), () => setProjectFiles([]));
|
|
167
|
+
}, [atContext, cwd, projectFiles]);
|
|
168
|
+
// Request a steady (non-blinking) block cursor via DECSCUSR while this
|
|
169
|
+
// component is mounted. Terminals default to a blinking cursor, which is
|
|
170
|
+
// distracting in an input that you'd glance away from. Restore the
|
|
171
|
+
// terminal default on unmount so the user's shell isn't left with our
|
|
172
|
+
// choice sticking around.
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
if (!process.stdout.isTTY)
|
|
175
|
+
return;
|
|
176
|
+
process.stdout.write("\x1b[2 q"); // steady block
|
|
177
|
+
return () => {
|
|
178
|
+
process.stdout.write("\x1b[0 q"); // reset to terminal default
|
|
179
|
+
};
|
|
180
|
+
}, []);
|
|
181
|
+
const slashSuggestions = useMemo(() => {
|
|
182
|
+
if (!isSlashContext)
|
|
183
|
+
return [];
|
|
184
|
+
const commandSuggestions = slashRegistry.list().map((command) => ({
|
|
185
|
+
type: "command",
|
|
186
|
+
name: command.name,
|
|
187
|
+
description: command.description,
|
|
188
|
+
}));
|
|
189
|
+
const skillSuggestions = (skillRegistry?.summaries() ?? []).map((skill) => ({
|
|
190
|
+
type: "skill",
|
|
191
|
+
name: skill.name,
|
|
192
|
+
description: skill.description,
|
|
193
|
+
}));
|
|
194
|
+
const all = [...commandSuggestions, ...skillSuggestions];
|
|
195
|
+
return all.filter((item) => item.name.toLowerCase().startsWith(slashPrefix));
|
|
196
|
+
}, [isSlashContext, slashPrefix, skillRegistry]);
|
|
197
|
+
const fileSuggestions = useMemo(() => {
|
|
198
|
+
if (!atContext || !projectFiles)
|
|
199
|
+
return [];
|
|
200
|
+
return filterFileSuggestions(projectFiles, atContext.query, MAX_VISIBLE_SUGGESTIONS * 3);
|
|
201
|
+
}, [atContext, projectFiles]);
|
|
202
|
+
const mode = slashSuggestions.length > 0
|
|
203
|
+
? "slash"
|
|
204
|
+
: atContext
|
|
205
|
+
? "file"
|
|
206
|
+
: null;
|
|
207
|
+
const activeCount = mode === "slash" ? slashSuggestions.length : mode === "file" ? fileSuggestions.length : 0;
|
|
208
|
+
const navigable = activeCount > 0;
|
|
209
|
+
const showSuggestions = mode !== null;
|
|
210
|
+
let suggestionOffset = 0;
|
|
211
|
+
if (navigable && activeCount > MAX_VISIBLE_SUGGESTIONS) {
|
|
212
|
+
suggestionOffset = Math.min(Math.max(selectedIndex - Math.floor(MAX_VISIBLE_SUGGESTIONS / 2), 0), activeCount - MAX_VISIBLE_SUGGESTIONS);
|
|
213
|
+
}
|
|
214
|
+
const insertTextAtCursor = React.useCallback((insertion) => {
|
|
215
|
+
if (!insertion)
|
|
216
|
+
return;
|
|
217
|
+
setText((prev) => {
|
|
218
|
+
const c = cursor;
|
|
219
|
+
const before = prev.slice(0, c);
|
|
220
|
+
const after = prev.slice(c);
|
|
221
|
+
return before + insertion + after;
|
|
222
|
+
});
|
|
223
|
+
setCursor((c) => c + insertion.length);
|
|
224
|
+
}, [cursor]);
|
|
225
|
+
const addAttachment = React.useCallback((att) => {
|
|
226
|
+
setAttachments((prev) => [...prev, att]);
|
|
227
|
+
}, []);
|
|
228
|
+
const notice = React.useCallback((msg) => {
|
|
229
|
+
onPasteNotice?.(msg);
|
|
230
|
+
}, [onPasteNotice]);
|
|
231
|
+
// Empty paste is the common signal that the clipboard holds an image and the
|
|
232
|
+
// terminal has nothing textual to deliver. Probe the clipboard; if it yields
|
|
233
|
+
// an image, treat the paste as an image attachment. macOS only — Linux/Win
|
|
234
|
+
// terminals don't reliably emit empty pastes on image-only clipboards.
|
|
235
|
+
const tryClipboardImage = React.useCallback(async () => {
|
|
236
|
+
const { attachment, error } = await ingestClipboardImage();
|
|
237
|
+
if (attachment) {
|
|
238
|
+
addAttachment(attachment);
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
if (error && error !== "clipboard has no image") {
|
|
242
|
+
notice(`image paste failed: ${error}`);
|
|
243
|
+
}
|
|
244
|
+
return false;
|
|
245
|
+
}, [addAttachment, notice]);
|
|
246
|
+
usePaste((pasted) => {
|
|
247
|
+
pastePendingRef.current = true;
|
|
248
|
+
// Clear the ref after React has committed the paste-driven setState.
|
|
249
|
+
// setTimeout with 0 runs after the current discreteUpdates batch flushes.
|
|
250
|
+
const clearPending = () => {
|
|
251
|
+
setTimeout(() => {
|
|
252
|
+
pastePendingRef.current = false;
|
|
253
|
+
}, 0);
|
|
254
|
+
};
|
|
255
|
+
// Strip orphaned focus-event tails that can appear if focus reporting
|
|
256
|
+
// splits across the paste boundary. Bracketed paste also delivers line
|
|
257
|
+
// breaks as bare CR on many terminals; left as-is, those CRs survive into
|
|
258
|
+
// the rendered Text and the terminal interprets them as "return to column
|
|
259
|
+
// 0", visually overwriting earlier characters even though the underlying
|
|
260
|
+
// state still holds the full paste.
|
|
261
|
+
const clean = pasted
|
|
262
|
+
.replace(/\x1b\[I$/, "")
|
|
263
|
+
.replace(/\x1b\[O$/, "")
|
|
264
|
+
.replace(/\r\n?/g, "\n");
|
|
265
|
+
// Empty paste on macOS usually means "Cmd+V with an image on the clipboard".
|
|
266
|
+
if (clean.length === 0) {
|
|
267
|
+
if (process.platform === "darwin") {
|
|
268
|
+
void tryClipboardImage().finally(clearPending);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
clearPending();
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// Look for image paths inside the paste (drag-and-drop from Finder/
|
|
276
|
+
// Nautilus/Explorer). Multi-selection can arrive newline- or
|
|
277
|
+
// space-separated.
|
|
278
|
+
const tokens = splitPastedPaths(clean);
|
|
279
|
+
const imageTokens = tokens.filter(isImageFilePath);
|
|
280
|
+
if (imageTokens.length === 0) {
|
|
281
|
+
// Plain text paste — insert into the input at the cursor.
|
|
282
|
+
insertTextAtCursor(clean);
|
|
283
|
+
clearPending();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const handle = async () => {
|
|
287
|
+
const results = await Promise.all(imageTokens.map((t) => ingestImagePath(t)));
|
|
288
|
+
const successful = [];
|
|
289
|
+
const errors = [];
|
|
290
|
+
for (let i = 0; i < results.length; i++) {
|
|
291
|
+
const { attachment, error } = results[i];
|
|
292
|
+
if (attachment) {
|
|
293
|
+
successful.push(attachment);
|
|
294
|
+
}
|
|
295
|
+
else if (error) {
|
|
296
|
+
errors.push(`${imageTokens[i]}: ${error}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// macOS screenshot shortcut writes a TemporaryItems path into the
|
|
300
|
+
// clipboard but the file may already be gone by the time we read it.
|
|
301
|
+
// Fall back to the clipboard image when that happens.
|
|
302
|
+
if (successful.length === 0 &&
|
|
303
|
+
process.platform === "darwin" &&
|
|
304
|
+
imageTokens.some(isScreenshotTempPath)) {
|
|
305
|
+
const clipOk = await tryClipboardImage();
|
|
306
|
+
if (clipOk)
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
for (const att of successful)
|
|
310
|
+
addAttachment(att);
|
|
311
|
+
const nonImageLines = tokens.filter((t) => !isImageFilePath(t));
|
|
312
|
+
if (successful.length > 0 && nonImageLines.length > 0) {
|
|
313
|
+
insertTextAtCursor(nonImageLines.join("\n"));
|
|
314
|
+
}
|
|
315
|
+
else if (successful.length === 0) {
|
|
316
|
+
// None resolved — fall back to treating the paste as text.
|
|
317
|
+
insertTextAtCursor(clean);
|
|
318
|
+
}
|
|
319
|
+
for (const err of errors)
|
|
320
|
+
notice(err);
|
|
321
|
+
};
|
|
322
|
+
void handle().finally(clearPending);
|
|
323
|
+
}, { isActive: !disabled });
|
|
324
|
+
const applyFileSuggestion = (selectedPath) => {
|
|
325
|
+
if (!atContext)
|
|
326
|
+
return;
|
|
327
|
+
const before = text.slice(0, atContext.start);
|
|
328
|
+
const after = text.slice(atContext.end);
|
|
329
|
+
const insert = `@${selectedPath} `;
|
|
330
|
+
const newText = before + insert + after;
|
|
331
|
+
setText(newText);
|
|
332
|
+
setCursor(before.length + insert.length);
|
|
333
|
+
setSelectedIndex(0);
|
|
334
|
+
};
|
|
335
|
+
const submitInput = (submittedText) => {
|
|
336
|
+
if (submittedText.trim().length === 0 && attachments.length === 0)
|
|
337
|
+
return;
|
|
338
|
+
onSubmit({ text: submittedText, images: attachments });
|
|
339
|
+
if (submittedText.trim().length > 0) {
|
|
340
|
+
const nextHistory = pushHistoryEntry(history, submittedText);
|
|
341
|
+
if (nextHistory !== history) {
|
|
342
|
+
setHistory(nextHistory);
|
|
343
|
+
appendHistoryEntry(submittedText);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
setText("");
|
|
347
|
+
setCursor(0);
|
|
348
|
+
setSelectedIndex(0);
|
|
349
|
+
setAttachments([]);
|
|
350
|
+
setHistoryIndex(null);
|
|
351
|
+
historyDraftRef.current = "";
|
|
352
|
+
};
|
|
353
|
+
const applySlashEnterAction = (submittedText) => {
|
|
354
|
+
const action = resolveSlashEnterAction(submittedText, slashSuggestions, selectedIndex);
|
|
355
|
+
if (action.kind === "submit") {
|
|
356
|
+
submitInput(submittedText);
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
if (action.kind === "complete") {
|
|
360
|
+
setText(action.text);
|
|
361
|
+
setCursor(action.text.length);
|
|
362
|
+
setSelectedIndex(0);
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
return false;
|
|
366
|
+
};
|
|
367
|
+
useInput((input, key) => {
|
|
368
|
+
if (disabled)
|
|
369
|
+
return;
|
|
370
|
+
if (process.env.BUBBLE_KEY_DEBUG) {
|
|
371
|
+
try {
|
|
372
|
+
appendFileSync("/tmp/bubble-key.log", JSON.stringify({
|
|
373
|
+
t: new Date().toISOString(),
|
|
374
|
+
input,
|
|
375
|
+
inputCodes: [...input].map((ch) => ch.codePointAt(0)),
|
|
376
|
+
key,
|
|
377
|
+
}) + "\n");
|
|
378
|
+
}
|
|
379
|
+
catch { }
|
|
380
|
+
}
|
|
381
|
+
const enterIntent = resolveInkEnterIntent(input, key);
|
|
382
|
+
if (enterIntent === "newline") {
|
|
383
|
+
const next = insertNewlineAtCursor(text, cursor);
|
|
384
|
+
setText(next.text);
|
|
385
|
+
setCursor(next.cursor);
|
|
386
|
+
setSelectedIndex(0);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (enterIntent === "submit" && input && /[\r\n]/.test(input)) {
|
|
390
|
+
const beforeReturn = input.split(/[\r\n]/)[0] ?? "";
|
|
391
|
+
const nextText = text.slice(0, cursor) + beforeReturn + text.slice(cursor);
|
|
392
|
+
if (showSuggestions) {
|
|
393
|
+
if (mode === "slash" && navigable && applySlashEnterAction(nextText)) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (mode === "file") {
|
|
397
|
+
if (navigable) {
|
|
398
|
+
const suggestion = fileSuggestions[selectedIndex];
|
|
399
|
+
if (suggestion)
|
|
400
|
+
applyFileSuggestion(suggestion.path);
|
|
401
|
+
}
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
submitInput(nextText);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
// Autocomplete navigation
|
|
409
|
+
if (showSuggestions) {
|
|
410
|
+
if (navigable && key.upArrow) {
|
|
411
|
+
setSelectedIndex((i) => (i - 1 + activeCount) % activeCount);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (navigable && key.downArrow) {
|
|
415
|
+
setSelectedIndex((i) => (i + 1) % activeCount);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (key.escape) {
|
|
419
|
+
setSelectedIndex(0);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (key.return || key.tab) {
|
|
423
|
+
if (mode === "slash" && navigable) {
|
|
424
|
+
if (key.return)
|
|
425
|
+
applySlashEnterAction(text);
|
|
426
|
+
if (key.tab) {
|
|
427
|
+
const suggestion = slashSuggestions[selectedIndex];
|
|
428
|
+
if (suggestion) {
|
|
429
|
+
const newText = `/${suggestion.name} `;
|
|
430
|
+
setText(newText);
|
|
431
|
+
setCursor(newText.length);
|
|
432
|
+
setSelectedIndex(0);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (mode === "file") {
|
|
438
|
+
if (navigable) {
|
|
439
|
+
const suggestion = fileSuggestions[selectedIndex];
|
|
440
|
+
if (suggestion)
|
|
441
|
+
applyFileSuggestion(suggestion.path);
|
|
442
|
+
}
|
|
443
|
+
// Swallow Enter/Tab even when no matches to avoid accidental submit.
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (enterIntent === "submit") {
|
|
449
|
+
// A paste is still mid-flight — dropping this Enter avoids submitting
|
|
450
|
+
// an input state that doesn't yet include the paste.
|
|
451
|
+
if (pastePendingRef.current)
|
|
452
|
+
return;
|
|
453
|
+
submitInput(text);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (key.backspace || key.delete) {
|
|
457
|
+
if (cursor > 0) {
|
|
458
|
+
const before = text.slice(0, cursor - 1);
|
|
459
|
+
const after = text.slice(cursor);
|
|
460
|
+
setText(before + after);
|
|
461
|
+
setCursor(cursor - 1);
|
|
462
|
+
setSelectedIndex(0);
|
|
463
|
+
}
|
|
464
|
+
else if (attachments.length > 0) {
|
|
465
|
+
// Backspace at position 0 drops the most recent attachment so users
|
|
466
|
+
// can undo a misfired paste without submitting the message.
|
|
467
|
+
setAttachments((prev) => prev.slice(0, -1));
|
|
468
|
+
}
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (key.leftArrow) {
|
|
472
|
+
setCursor(Math.max(0, cursor - 1));
|
|
473
|
+
setSelectedIndex(0);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (key.rightArrow) {
|
|
477
|
+
setCursor(Math.min(text.length, cursor + 1));
|
|
478
|
+
setSelectedIndex(0);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (key.upArrow) {
|
|
482
|
+
if (cursorVisualRow > 0) {
|
|
483
|
+
setCursor(visualToCursor(visualLines, cursorVisualRow - 1, cursorVisualCol));
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const result = stepHistory({ history, index: historyIndex, draft: historyDraftRef.current }, "up", text);
|
|
487
|
+
if (result.changed) {
|
|
488
|
+
setText(result.text);
|
|
489
|
+
setCursor(result.text.length);
|
|
490
|
+
setHistoryIndex(result.index);
|
|
491
|
+
historyDraftRef.current = result.draft;
|
|
492
|
+
setSelectedIndex(0);
|
|
493
|
+
}
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (key.downArrow) {
|
|
497
|
+
if (cursorVisualRow < visualLines.length - 1) {
|
|
498
|
+
setCursor(visualToCursor(visualLines, cursorVisualRow + 1, cursorVisualCol));
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const result = stepHistory({ history, index: historyIndex, draft: historyDraftRef.current }, "down", text);
|
|
502
|
+
if (result.changed) {
|
|
503
|
+
setText(result.text);
|
|
504
|
+
setCursor(result.text.length);
|
|
505
|
+
setHistoryIndex(result.index);
|
|
506
|
+
historyDraftRef.current = result.draft;
|
|
507
|
+
setSelectedIndex(0);
|
|
508
|
+
}
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (input) {
|
|
512
|
+
const before = text.slice(0, cursor);
|
|
513
|
+
const after = text.slice(cursor);
|
|
514
|
+
setText(before + input + after);
|
|
515
|
+
setCursor(cursor + input.length);
|
|
516
|
+
setSelectedIndex(0);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
// Anchor the cursor directly to whichever line Box currently contains the
|
|
520
|
+
// cursor. Its absolute yoga (top, left) IS the row the cursor should land
|
|
521
|
+
// on — no manual border/row offsets that can drift one row off after a
|
|
522
|
+
// layout shift.
|
|
523
|
+
const cursorLineRef = useRef(null);
|
|
524
|
+
const lastCursorRef = useRef(null);
|
|
525
|
+
const previousOutputHeightRef = useRef(null);
|
|
526
|
+
const previousViewportRowsRef = useRef(null);
|
|
527
|
+
const previousInputFrameSignatureRef = useRef(null);
|
|
528
|
+
const previousRowCompensationRef = useRef(0);
|
|
529
|
+
const { setCursorPosition } = useCursor();
|
|
530
|
+
const { stdout } = useStdout();
|
|
531
|
+
const contentWidth = Math.max(1, width - PADDING_X * 2);
|
|
532
|
+
const lineWidth = Math.max(1, contentWidth - PROMPT.length);
|
|
533
|
+
const visualLines = useMemo(() => computeVisualLines(text, lineWidth), [text, lineWidth]);
|
|
534
|
+
const { row: cursorVisualRow, col: cursorVisualCol } = cursorToVisual(visualLines, cursor);
|
|
535
|
+
const totalLines = Math.max(visualLines.length, 1);
|
|
536
|
+
const visibleLines = Math.min(Math.max(totalLines, MIN_VISIBLE_LINES), MAX_VISIBLE_LINES);
|
|
537
|
+
let scrollOffset = 0;
|
|
538
|
+
if (totalLines > visibleLines) {
|
|
539
|
+
scrollOffset = Math.min(Math.max(cursorVisualRow - Math.floor(visibleLines / 2), 0), totalLines - visibleLines);
|
|
540
|
+
}
|
|
541
|
+
const displayedLines = [];
|
|
542
|
+
const topPadLines = totalLines < visibleLines
|
|
543
|
+
? Math.floor((visibleLines - totalLines) / 2)
|
|
544
|
+
: 0;
|
|
545
|
+
for (let i = 0; i < topPadLines; i++) {
|
|
546
|
+
displayedLines.push({ kind: "pad", key: `top-${i}` });
|
|
547
|
+
}
|
|
548
|
+
const contentLineCount = Math.min(totalLines, visibleLines - topPadLines);
|
|
549
|
+
for (let i = 0; i < contentLineCount; i++) {
|
|
550
|
+
const visualIdx = scrollOffset + i;
|
|
551
|
+
const vl = visualLines[visualIdx];
|
|
552
|
+
displayedLines.push({
|
|
553
|
+
kind: "content",
|
|
554
|
+
text: vl ? vl.text : "",
|
|
555
|
+
visualIdx,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
while (displayedLines.length < visibleLines) {
|
|
559
|
+
displayedLines.push({ kind: "pad", key: `bottom-${displayedLines.length}` });
|
|
560
|
+
}
|
|
561
|
+
const hasMoreAbove = scrollOffset > 0;
|
|
562
|
+
const hasMoreBelow = scrollOffset + visibleLines < totalLines;
|
|
563
|
+
const inputFrameSignature = [
|
|
564
|
+
disabled ? "disabled" : "active",
|
|
565
|
+
text,
|
|
566
|
+
scrollOffset.toString(),
|
|
567
|
+
visibleLines.toString(),
|
|
568
|
+
attachments.map((att) => `${att.filename ?? "clipboard"}:${att.bytes}`).join(","),
|
|
569
|
+
mode ?? "none",
|
|
570
|
+
selectedIndex.toString(),
|
|
571
|
+
suggestionOffset.toString(),
|
|
572
|
+
activeCount.toString(),
|
|
573
|
+
projectFiles?.length.toString() ?? "loading",
|
|
574
|
+
].join("\u0000");
|
|
575
|
+
// Measure after yoga runs (useLayoutEffect fires after Ink's resetAfterCommit
|
|
576
|
+
// calls onComputeLayout). Push the new position into useCursor's ref and bump
|
|
577
|
+
// `cursorTick` to force one more render so useCursor's useInsertionEffect
|
|
578
|
+
// sees the fresh value and Ink emits a cursor-only update.
|
|
579
|
+
//
|
|
580
|
+
// While the input is disabled (agent is running, pickers open, etc.) the
|
|
581
|
+
// user can't type. Keeping the real cursor visible in the input makes it
|
|
582
|
+
// flicker every time streaming output above it re-lays out the frame, so
|
|
583
|
+
// we hide it entirely until input is active again.
|
|
584
|
+
const [cursorTick, setCursorTick] = useState(0);
|
|
585
|
+
useLayoutEffect(() => {
|
|
586
|
+
let node = cursorLineRef.current ?? undefined;
|
|
587
|
+
if (!node?.yogaNode) {
|
|
588
|
+
if (disabled && lastCursorRef.current !== null) {
|
|
589
|
+
lastCursorRef.current = null;
|
|
590
|
+
setCursorTick((t) => t + 1);
|
|
591
|
+
}
|
|
592
|
+
setCursorPosition(undefined);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
let left = 0;
|
|
596
|
+
let top = 0;
|
|
597
|
+
let lastNode;
|
|
598
|
+
const trace = [];
|
|
599
|
+
while (node?.yogaNode) {
|
|
600
|
+
const layout = node.yogaNode.getComputedLayout();
|
|
601
|
+
left += layout.left;
|
|
602
|
+
top += layout.top;
|
|
603
|
+
if (process.env.BUBBLE_CURSOR_DEBUG) {
|
|
604
|
+
trace.push(`${node.nodeName}(+${layout.left},+${layout.top})`);
|
|
605
|
+
}
|
|
606
|
+
lastNode = node;
|
|
607
|
+
node = node.parentNode;
|
|
608
|
+
}
|
|
609
|
+
const rootHeight = lastNode?.yogaNode?.getComputedHeight() ?? 0;
|
|
610
|
+
const viewportRows = stdout.rows ?? process.stdout.rows ?? 24;
|
|
611
|
+
const previousOutputHeight = previousOutputHeightRef.current;
|
|
612
|
+
// After a clear/sync frame, Ink's physical terminal cursor remains on the
|
|
613
|
+
// last rendered row even though log-update records an output string with a
|
|
614
|
+
// trailing newline. The forced cursor render that follows has the same
|
|
615
|
+
// visible frame, so keep the same row compensation until the input frame
|
|
616
|
+
// content or height actually changes.
|
|
617
|
+
const sameRenderedFrame = previousOutputHeight === rootHeight &&
|
|
618
|
+
previousViewportRowsRef.current === viewportRows &&
|
|
619
|
+
previousInputFrameSignatureRef.current === inputFrameSignature;
|
|
620
|
+
const rowCompensation = sameRenderedFrame
|
|
621
|
+
? previousRowCompensationRef.current
|
|
622
|
+
: needsCursorRowCompensation(rootHeight, viewportRows, previousOutputHeight) ? 1 : 0;
|
|
623
|
+
previousOutputHeightRef.current = rootHeight;
|
|
624
|
+
previousViewportRowsRef.current = viewportRows;
|
|
625
|
+
previousInputFrameSignatureRef.current = inputFrameSignature;
|
|
626
|
+
previousRowCompensationRef.current = rowCompensation;
|
|
627
|
+
if (disabled) {
|
|
628
|
+
if (lastCursorRef.current !== null) {
|
|
629
|
+
lastCursorRef.current = null;
|
|
630
|
+
setCursorPosition(undefined);
|
|
631
|
+
setCursorTick((t) => t + 1);
|
|
632
|
+
}
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const next = {
|
|
636
|
+
x: left + PROMPT.length + cursorVisualCol,
|
|
637
|
+
y: top + rowCompensation,
|
|
638
|
+
};
|
|
639
|
+
if (process.env.BUBBLE_CURSOR_DEBUG) {
|
|
640
|
+
try {
|
|
641
|
+
appendFileSync("/tmp/bubble-cursor.log", `${new Date().toISOString()} row=${cursorVisualRow} col=${cursorVisualCol} -> x=${next.x} y=${next.y} (rootH=${rootHeight}, prevH=${previousOutputHeight ?? "none"}, vp=${viewportRows}, comp=${rowCompensation}) | ${trace.join(" < ")}\n`);
|
|
642
|
+
}
|
|
643
|
+
catch { }
|
|
644
|
+
}
|
|
645
|
+
const prev = lastCursorRef.current;
|
|
646
|
+
if (!prev || prev.x !== next.x || prev.y !== next.y) {
|
|
647
|
+
lastCursorRef.current = next;
|
|
648
|
+
setCursorPosition(next);
|
|
649
|
+
setCursorTick((t) => t + 1);
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
// Reference cursorTick so the effect re-runs on the forced render pass.
|
|
653
|
+
void cursorTick;
|
|
654
|
+
const inputBg = disabled ? theme.inputBgDisabled : theme.inputBg;
|
|
655
|
+
const moreBelow = totalLines - scrollOffset - visibleLines;
|
|
656
|
+
const filledLine = (value) => {
|
|
657
|
+
const visibleWidth = stringWidth(value);
|
|
658
|
+
return value + " ".repeat(Math.max(0, contentWidth - visibleWidth));
|
|
659
|
+
};
|
|
660
|
+
return (_jsxs(Box, { flexDirection: "column", children: [attachments.length > 0 && (_jsx(Box, { flexDirection: "row", flexWrap: "wrap", paddingX: PADDING_X, marginBottom: 0, children: attachments.map((att, i) => {
|
|
661
|
+
const label = att.filename || "clipboard";
|
|
662
|
+
const kb = Math.max(1, Math.round(att.bytes / 1024));
|
|
663
|
+
return (_jsx(Box, { marginRight: 1, children: _jsx(Text, { color: theme.accent, children: `[img${attachments.length > 1 ? ` ${i + 1}` : ""}: ${label} · ${kb}KB]` }) }, i));
|
|
664
|
+
}) })), _jsxs(Box, { flexDirection: "column", paddingX: PADDING_X, children: [hasMoreAbove && (_jsx(Text, { backgroundColor: inputBg, color: theme.muted, dimColor: true, children: filledLine(` ↑ ${scrollOffset} more`) })), displayedLines.map((row) => {
|
|
665
|
+
if (row.kind === "pad") {
|
|
666
|
+
return (_jsx(Text, { backgroundColor: inputBg, children: " ".repeat(contentWidth) }, row.key));
|
|
667
|
+
}
|
|
668
|
+
const { text: line, visualIdx } = row;
|
|
669
|
+
const lineText = line.length === 0 ? " " : line;
|
|
670
|
+
const isFirst = visualIdx === 0;
|
|
671
|
+
const isCursorLine = visualIdx === cursorVisualRow;
|
|
672
|
+
const prompt = isFirst ? PROMPT : " ".repeat(PROMPT.length);
|
|
673
|
+
const fill = " ".repeat(Math.max(0, lineWidth - stringWidth(lineText)));
|
|
674
|
+
return (_jsxs(Box, { height: 1, overflow: "hidden", ref: isCursorLine
|
|
675
|
+
? (el) => {
|
|
676
|
+
cursorLineRef.current = el;
|
|
677
|
+
}
|
|
678
|
+
: undefined, children: [_jsx(Text, { backgroundColor: inputBg, color: isFirst ? theme.accent : theme.inputText, children: prompt }), _jsx(Text, { backgroundColor: inputBg, color: theme.inputText, children: lineText }), _jsx(Text, { backgroundColor: inputBg, children: fill })] }, visualIdx));
|
|
679
|
+
}), hasMoreBelow && (_jsx(Text, { backgroundColor: inputBg, color: theme.muted, dimColor: true, children: filledLine(` ↓ ${moreBelow} more`) }))] }), showSuggestions && mode === "slash" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 4, children: [slashSuggestions
|
|
680
|
+
.slice(suggestionOffset, suggestionOffset + MAX_VISIBLE_SUGGESTIONS)
|
|
681
|
+
.map((cmd, visibleIndex) => {
|
|
682
|
+
const i = suggestionOffset + visibleIndex;
|
|
683
|
+
const label = `/${cmd.name}`.padEnd(17);
|
|
684
|
+
const isSelected = i === selectedIndex;
|
|
685
|
+
return (_jsx(Box, { height: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: isSelected ? theme.accent : theme.muted, bold: isSelected, children: label }), cmd.type === "skill" && _jsx(Text, { color: theme.muted, children: " [skill]" }), _jsxs(Text, { dimColor: true, children: [" ", cmd.description] })] }) }, cmd.name));
|
|
686
|
+
}), slashSuggestions.length > MAX_VISIBLE_SUGGESTIONS && (_jsx(Text, { color: theme.muted, children: `Showing ${suggestionOffset + 1}-${Math.min(suggestionOffset + MAX_VISIBLE_SUGGESTIONS, slashSuggestions.length)} of ${slashSuggestions.length}` }))] })), showSuggestions && mode === "file" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [projectFiles === null && _jsx(Text, { dimColor: true, children: "Loading project files\u2026" }), projectFiles !== null && fileSuggestions.length === 0 && (_jsxs(Text, { dimColor: true, children: ["No files match \"", atContext?.query ?? "", "\""] })), fileSuggestions
|
|
687
|
+
.slice(suggestionOffset, suggestionOffset + MAX_VISIBLE_SUGGESTIONS)
|
|
688
|
+
.map((s, visibleIndex) => {
|
|
689
|
+
const i = suggestionOffset + visibleIndex;
|
|
690
|
+
const maxWidth = Math.max(10, Math.min(80, contentWidth - 2));
|
|
691
|
+
const label = s.path.length > maxWidth ? "…" + s.path.slice(-(maxWidth - 1)) : s.path;
|
|
692
|
+
return (_jsx(Box, { height: 1, children: i === selectedIndex ? (_jsx(Text, { backgroundColor: "white", color: "black", children: ` ${label} ` })) : (_jsx(Text, { children: ` ${label}` })) }, s.path));
|
|
693
|
+
}), fileSuggestions.length > MAX_VISIBLE_SUGGESTIONS && (_jsx(Text, { color: theme.muted, children: `Showing ${suggestionOffset + 1}-${Math.min(suggestionOffset + MAX_VISIBLE_SUGGESTIONS, fileSuggestions.length)} of ${fileSuggestions.length}` }))] }))] }));
|
|
694
|
+
}
|