@bubblebrain-ai/bubble 0.0.6 → 0.0.8

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