@bubblebrain-ai/bubble 0.0.11 → 0.0.13

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 (160) hide show
  1. package/dist/agent/input-controller.d.ts +11 -0
  2. package/dist/agent/input-controller.js +30 -0
  3. package/dist/agent.d.ts +6 -4
  4. package/dist/agent.js +39 -2
  5. package/dist/feishu/agent-host/run-driver.js +13 -6
  6. package/dist/feishu/agent-host/runtime-deps.d.ts +2 -2
  7. package/dist/feishu/router/commands.js +2 -1
  8. package/dist/feishu/scope/session-binder.js +1 -1
  9. package/dist/feishu/serve.js +3 -3
  10. package/dist/main.js +78 -12
  11. package/dist/prompt/compose.js +3 -3
  12. package/dist/prompt/environment.js +2 -0
  13. package/dist/prompt/reminders.js +1 -1
  14. package/dist/provider-openai-codex.d.ts +8 -1
  15. package/dist/provider-openai-codex.js +33 -9
  16. package/dist/provider.d.ts +2 -0
  17. package/dist/session-title.d.ts +16 -0
  18. package/dist/session-title.js +134 -0
  19. package/dist/session-types.d.ts +5 -0
  20. package/dist/session.d.ts +5 -0
  21. package/dist/session.js +75 -9
  22. package/dist/skills/invocation.js +0 -18
  23. package/dist/skills/registry.d.ts +1 -0
  24. package/dist/skills/registry.js +2 -0
  25. package/dist/slash-commands/commands.js +29 -22
  26. package/dist/slash-commands/registry.js +1 -1
  27. package/dist/slash-commands/types.d.ts +10 -0
  28. package/dist/text-display.d.ts +3 -0
  29. package/dist/text-display.js +25 -0
  30. package/dist/tools/index.d.ts +1 -0
  31. package/dist/tools/index.js +3 -1
  32. package/dist/tools/skill-search.d.ts +10 -0
  33. package/dist/tools/skill-search.js +134 -0
  34. package/dist/tools/skill.js +1 -4
  35. package/dist/tui/clipboard.d.ts +1 -0
  36. package/dist/tui/clipboard.js +53 -0
  37. package/dist/tui/detect-theme.d.ts +2 -0
  38. package/dist/tui/detect-theme.js +87 -0
  39. package/dist/tui/display-history.d.ts +62 -0
  40. package/dist/tui/display-history.js +305 -0
  41. package/dist/tui/edit-diff.d.ts +11 -0
  42. package/dist/tui/edit-diff.js +52 -0
  43. package/dist/tui/escape-confirmation.d.ts +15 -0
  44. package/dist/tui/escape-confirmation.js +30 -0
  45. package/dist/tui/file-mentions.d.ts +29 -0
  46. package/dist/tui/file-mentions.js +174 -0
  47. package/dist/tui/global-key-router.d.ts +3 -0
  48. package/dist/tui/global-key-router.js +87 -0
  49. package/dist/tui/image-paste.d.ts +95 -0
  50. package/dist/tui/image-paste.js +505 -0
  51. package/dist/tui/input-history.d.ts +16 -0
  52. package/dist/tui/input-history.js +79 -0
  53. package/dist/tui/markdown-inline.d.ts +22 -0
  54. package/dist/tui/markdown-inline.js +68 -0
  55. package/dist/tui/markdown-theme-rules.d.ts +23 -0
  56. package/dist/tui/markdown-theme-rules.js +164 -0
  57. package/dist/tui/markdown-theme.d.ts +5 -0
  58. package/dist/tui/markdown-theme.js +27 -0
  59. package/dist/tui/opencode-spinner.d.ts +22 -0
  60. package/dist/tui/opencode-spinner.js +216 -0
  61. package/dist/tui/prompt-keybindings.d.ts +42 -0
  62. package/dist/tui/prompt-keybindings.js +35 -0
  63. package/dist/tui/recent-activity.d.ts +8 -0
  64. package/dist/tui/recent-activity.js +71 -0
  65. package/dist/tui/render-signature.d.ts +1 -0
  66. package/dist/tui/render-signature.js +7 -0
  67. package/dist/tui/run.d.ts +45 -0
  68. package/dist/tui/run.js +8816 -0
  69. package/dist/tui/session-display.d.ts +6 -0
  70. package/dist/tui/session-display.js +12 -0
  71. package/dist/tui/sidebar-mcp.d.ts +31 -0
  72. package/dist/tui/sidebar-mcp.js +62 -0
  73. package/dist/tui/sidebar-state.d.ts +12 -0
  74. package/dist/tui/sidebar-state.js +69 -0
  75. package/dist/tui/streaming-tool-args.d.ts +15 -0
  76. package/dist/tui/streaming-tool-args.js +30 -0
  77. package/dist/tui/tool-renderers/fallback.d.ts +2 -0
  78. package/dist/tui/tool-renderers/fallback.js +75 -0
  79. package/dist/tui/tool-renderers/registry.d.ts +3 -0
  80. package/dist/tui/tool-renderers/registry.js +11 -0
  81. package/dist/tui/tool-renderers/subagent.d.ts +2 -0
  82. package/dist/tui/tool-renderers/subagent.js +135 -0
  83. package/dist/tui/tool-renderers/types.d.ts +36 -0
  84. package/dist/tui/tool-renderers/types.js +1 -0
  85. package/dist/tui/tool-renderers/write-preview.d.ts +12 -0
  86. package/dist/tui/tool-renderers/write-preview.js +30 -0
  87. package/dist/tui/tool-renderers/write.d.ts +6 -0
  88. package/dist/tui/tool-renderers/write.js +88 -0
  89. package/dist/tui/trace-groups.d.ts +27 -0
  90. package/dist/tui/trace-groups.js +412 -0
  91. package/dist/tui/wordmark.d.ts +15 -0
  92. package/dist/tui/wordmark.js +179 -0
  93. package/dist/tui-ink/app.js +98 -70
  94. package/dist/tui-ink/input-box.d.ts +22 -1
  95. package/dist/tui-ink/input-box.js +105 -11
  96. package/dist/tui-ink/message-list.js +12 -3
  97. package/dist/tui-ink/model-picker.d.ts +18 -0
  98. package/dist/tui-ink/model-picker.js +80 -23
  99. package/dist/tui-ink/session-picker.js +5 -7
  100. package/dist/tui-ink/theme.d.ts +3 -9
  101. package/dist/tui-ink/theme.js +39 -45
  102. package/dist/tui-ink/welcome.js +22 -78
  103. package/dist/tui-opentui/app.d.ts +54 -0
  104. package/dist/tui-opentui/app.js +1363 -0
  105. package/dist/tui-opentui/approval/approval-dialog.d.ts +15 -0
  106. package/dist/tui-opentui/approval/approval-dialog.js +139 -0
  107. package/dist/tui-opentui/approval/diff-view.d.ts +9 -0
  108. package/dist/tui-opentui/approval/diff-view.js +43 -0
  109. package/dist/tui-opentui/approval/select.d.ts +37 -0
  110. package/dist/tui-opentui/approval/select.js +91 -0
  111. package/dist/tui-opentui/detect-theme.d.ts +2 -0
  112. package/dist/tui-opentui/detect-theme.js +87 -0
  113. package/dist/tui-opentui/display-history.d.ts +55 -0
  114. package/dist/tui-opentui/display-history.js +129 -0
  115. package/dist/tui-opentui/edit-diff.d.ts +11 -0
  116. package/dist/tui-opentui/edit-diff.js +52 -0
  117. package/dist/tui-opentui/feedback-dialog.d.ts +21 -0
  118. package/dist/tui-opentui/feedback-dialog.js +164 -0
  119. package/dist/tui-opentui/feishu-setup-picker.d.ts +7 -0
  120. package/dist/tui-opentui/feishu-setup-picker.js +272 -0
  121. package/dist/tui-opentui/file-mentions.d.ts +29 -0
  122. package/dist/tui-opentui/file-mentions.js +174 -0
  123. package/dist/tui-opentui/footer.d.ts +26 -0
  124. package/dist/tui-opentui/footer.js +40 -0
  125. package/dist/tui-opentui/image-paste.d.ts +54 -0
  126. package/dist/tui-opentui/image-paste.js +288 -0
  127. package/dist/tui-opentui/input-box.d.ts +34 -0
  128. package/dist/tui-opentui/input-box.js +471 -0
  129. package/dist/tui-opentui/input-history.d.ts +16 -0
  130. package/dist/tui-opentui/input-history.js +79 -0
  131. package/dist/tui-opentui/markdown.d.ts +66 -0
  132. package/dist/tui-opentui/markdown.js +127 -0
  133. package/dist/tui-opentui/message-list.d.ts +31 -0
  134. package/dist/tui-opentui/message-list.js +125 -0
  135. package/dist/tui-opentui/model-picker.d.ts +63 -0
  136. package/dist/tui-opentui/model-picker.js +450 -0
  137. package/dist/tui-opentui/plan-confirm.d.ts +9 -0
  138. package/dist/tui-opentui/plan-confirm.js +124 -0
  139. package/dist/tui-opentui/question-dialog.d.ts +10 -0
  140. package/dist/tui-opentui/question-dialog.js +110 -0
  141. package/dist/tui-opentui/recent-activity.d.ts +8 -0
  142. package/dist/tui-opentui/recent-activity.js +71 -0
  143. package/dist/tui-opentui/run-session-picker.d.ts +10 -0
  144. package/dist/tui-opentui/run-session-picker.js +28 -0
  145. package/dist/tui-opentui/run.d.ts +38 -0
  146. package/dist/tui-opentui/run.js +48 -0
  147. package/dist/tui-opentui/session-picker.d.ts +12 -0
  148. package/dist/tui-opentui/session-picker.js +120 -0
  149. package/dist/tui-opentui/theme.d.ts +89 -0
  150. package/dist/tui-opentui/theme.js +157 -0
  151. package/dist/tui-opentui/todos.d.ts +9 -0
  152. package/dist/tui-opentui/todos.js +45 -0
  153. package/dist/tui-opentui/trace-groups.d.ts +27 -0
  154. package/dist/tui-opentui/trace-groups.js +412 -0
  155. package/dist/tui-opentui/use-terminal-size.d.ts +4 -0
  156. package/dist/tui-opentui/use-terminal-size.js +5 -0
  157. package/dist/tui-opentui/welcome.d.ts +25 -0
  158. package/dist/tui-opentui/welcome.js +77 -0
  159. package/dist/types.d.ts +24 -0
  160. package/package.json +5 -1
@@ -0,0 +1,471 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ /** @jsxImportSource @opentui/react */
3
+ /**
4
+ * Composer input for the OpenTUI TUI. Manages a multi-line editable buffer,
5
+ * cursor, history, file mention autocomplete, slash-command autocomplete,
6
+ * and text + image paste. Simpler than the Ink version because OpenTUI
7
+ * handles paste and selection at the native layer.
8
+ */
9
+ import React, { useCallback, useEffect, useRef, useState } from "react";
10
+ import { useKeyboard, usePaste } from "@opentui/react";
11
+ import { decodePasteBytes } from "@opentui/core";
12
+ import { registry as slashRegistry } from "../slash-commands/index.js";
13
+ import { useTheme } from "./theme.js";
14
+ import { filterFileSuggestions, findAtContext, listProjectFiles } from "./file-mentions.js";
15
+ import { ingestImagePath, isImageFilePath, isScreenshotTempPath, splitPastedPaths, } from "./image-paste.js";
16
+ import { appendHistoryEntry, loadHistorySync, stepHistory, } from "./input-history.js";
17
+ const PROMPT = " > ";
18
+ const LONG_PASTE_CHAR_THRESHOLD = 1000;
19
+ const LONG_PASTE_LINE_THRESHOLD = 20;
20
+ const MAX_VISIBLE_SUGGESTIONS = 8;
21
+ export function shouldCollapsePastedContent(text) {
22
+ if (text.length >= LONG_PASTE_CHAR_THRESHOLD)
23
+ return true;
24
+ const lines = text.split("\n").length;
25
+ return lines >= LONG_PASTE_LINE_THRESHOLD;
26
+ }
27
+ export function createPastedContentMarker(content) {
28
+ const lineCount = content.split("\n").length;
29
+ const wordCount = content.trim().split(/\s+/).length;
30
+ return `[Pasted ${lineCount} lines · ${wordCount} words]`;
31
+ }
32
+ export function isCtrlCInput(input, key) {
33
+ return input === "\x03" || (key.ctrl === true && input.toLowerCase() === "c");
34
+ }
35
+ export function InputBox({ onSubmit, onPasteNotice, disabled = false, cursorResetEpoch, draftText, draftEpoch, onDraftApplied, skillRegistry, terminalColumns, cwd, }) {
36
+ const theme = useTheme();
37
+ const [buffer, setBuffer] = useState("");
38
+ const [cursor, setCursor] = useState(0);
39
+ const [images, setImages] = useState([]);
40
+ const [pastedRefs, setPastedRefs] = useState(new Map());
41
+ const [history] = useState(() => loadHistorySync());
42
+ const [historyIndex, setHistoryIndex] = useState(null);
43
+ const [suggestions, setSuggestions] = useState([]);
44
+ const [suggestionIndex, setSuggestionIndex] = useState(0);
45
+ const [suggestionKind, setSuggestionKind] = useState(null);
46
+ // Refs mirror the latest values so handlers passed into useKeyboard / usePaste
47
+ // read fresh state even if those hooks bind the callback once and never
48
+ // re-subscribe on state change. Updated synchronously on every render so
49
+ // the next event sees what just landed in the previous setState.
50
+ const bufferRef = useRef(buffer);
51
+ const cursorRef = useRef(cursor);
52
+ const imagesRef = useRef(images);
53
+ const pastedRefsRef = useRef(pastedRefs);
54
+ const suggestionsRef = useRef(suggestions);
55
+ const suggestionIndexRef = useRef(suggestionIndex);
56
+ const suggestionKindRef = useRef(suggestionKind);
57
+ const historyIndexRef = useRef(historyIndex);
58
+ bufferRef.current = buffer;
59
+ cursorRef.current = cursor;
60
+ imagesRef.current = images;
61
+ pastedRefsRef.current = pastedRefs;
62
+ suggestionsRef.current = suggestions;
63
+ suggestionIndexRef.current = suggestionIndex;
64
+ suggestionKindRef.current = suggestionKind;
65
+ historyIndexRef.current = historyIndex;
66
+ // Reset cursor / buffer on epoch bump from app (used after /clear, etc).
67
+ useEffect(() => {
68
+ if (cursorResetEpoch === undefined)
69
+ return;
70
+ setBuffer("");
71
+ setCursor(0);
72
+ setImages([]);
73
+ setPastedRefs(new Map());
74
+ setSuggestions([]);
75
+ setSuggestionKind(null);
76
+ setHistoryIndex(null);
77
+ }, [cursorResetEpoch]);
78
+ // Accept draft text fill from outside (e.g. skill picker → composer).
79
+ useEffect(() => {
80
+ if (draftText === undefined || draftEpoch === undefined)
81
+ return;
82
+ setBuffer(draftText);
83
+ setCursor(draftText.length);
84
+ onDraftApplied?.();
85
+ }, [draftText, draftEpoch, onDraftApplied]);
86
+ const updateSuggestions = useCallback(async (text, cursorPos) => {
87
+ const atCtx = findAtContext(text, cursorPos);
88
+ if (atCtx) {
89
+ const allFiles = await listProjectFiles(cwd);
90
+ const files = filterFileSuggestions(allFiles, atCtx.query);
91
+ if (files.length > 0) {
92
+ setSuggestions(files.slice(0, MAX_VISIBLE_SUGGESTIONS).map((f) => ({
93
+ label: f.path,
94
+ insert: `@${f.path}`,
95
+ })));
96
+ setSuggestionIndex(0);
97
+ setSuggestionKind("file");
98
+ return;
99
+ }
100
+ }
101
+ // Slash command suggestion if the buffer starts with /
102
+ const slashMatch = /^\s*\/(\S*)$/.exec(text.slice(0, cursorPos));
103
+ if (slashMatch) {
104
+ const query = slashMatch[1] ?? "";
105
+ const commands = slashRegistry.list?.() ?? [];
106
+ const filtered = commands
107
+ .filter((c) => !query || c.name.toLowerCase().includes(query.toLowerCase()))
108
+ .slice(0, MAX_VISIBLE_SUGGESTIONS)
109
+ .map((c) => ({
110
+ label: `/${c.name}`,
111
+ detail: c.description,
112
+ insert: `/${c.name}`,
113
+ }));
114
+ if (filtered.length > 0) {
115
+ setSuggestions(filtered);
116
+ setSuggestionIndex(0);
117
+ setSuggestionKind("slash");
118
+ return;
119
+ }
120
+ }
121
+ setSuggestions([]);
122
+ setSuggestionKind(null);
123
+ }, [cwd]);
124
+ // All handlers read from refs so they stay correct under hooks that bind
125
+ // the callback once. Writes still go through setState so React re-renders.
126
+ const insertAtCursor = useCallback((text) => {
127
+ const b = bufferRef.current;
128
+ const c = cursorRef.current;
129
+ const next = b.slice(0, c) + text + b.slice(c);
130
+ const nextCursor = c + text.length;
131
+ setBuffer(next);
132
+ setCursor(nextCursor);
133
+ void updateSuggestions(next, nextCursor);
134
+ }, [updateSuggestions]);
135
+ const acceptSuggestion = useCallback(() => {
136
+ const b = bufferRef.current;
137
+ const c = cursorRef.current;
138
+ const sugs = suggestionsRef.current;
139
+ const idx = suggestionIndexRef.current;
140
+ const kind = suggestionKindRef.current;
141
+ const s = sugs[idx];
142
+ if (!s)
143
+ return;
144
+ if (kind === "file") {
145
+ const atCtx = findAtContext(b, c);
146
+ if (!atCtx)
147
+ return;
148
+ const next = b.slice(0, atCtx.start) + s.insert + b.slice(atCtx.end);
149
+ const nextCursor = atCtx.start + s.insert.length;
150
+ setBuffer(next);
151
+ setCursor(nextCursor);
152
+ }
153
+ else if (kind === "slash") {
154
+ const slashMatch = /^(\s*)\/(\S*)$/.exec(b);
155
+ if (slashMatch) {
156
+ const lead = slashMatch[1] ?? "";
157
+ const next = `${lead}${s.insert} `;
158
+ setBuffer(next);
159
+ setCursor(next.length);
160
+ }
161
+ }
162
+ setSuggestions([]);
163
+ setSuggestionKind(null);
164
+ }, []);
165
+ const submit = useCallback(() => {
166
+ const b = bufferRef.current;
167
+ const imgs = imagesRef.current;
168
+ const refs = pastedRefsRef.current;
169
+ if (!b.trim() && imgs.length === 0)
170
+ return;
171
+ let expanded = b;
172
+ for (const [marker, content] of refs) {
173
+ expanded = expanded.split(marker).join(content);
174
+ }
175
+ const payload = {
176
+ text: expanded,
177
+ displayText: expanded !== b ? b : undefined,
178
+ images: imgs,
179
+ };
180
+ if (expanded.trim())
181
+ appendHistoryEntry(expanded);
182
+ onSubmit(payload);
183
+ setBuffer("");
184
+ setCursor(0);
185
+ setImages([]);
186
+ setPastedRefs(new Map());
187
+ setSuggestions([]);
188
+ setSuggestionKind(null);
189
+ setHistoryIndex(null);
190
+ }, [onSubmit]);
191
+ usePaste((event) => {
192
+ if (disabled)
193
+ return;
194
+ const text = decodePasteBytes(event.bytes);
195
+ // Detect image file paths from drag-and-drop or screenshot tools.
196
+ const paths = splitPastedPaths(text);
197
+ const imagePaths = paths.filter((p) => isImageFilePath(p) || isScreenshotTempPath(p));
198
+ if (imagePaths.length > 0) {
199
+ void Promise.all(imagePaths.map((p) => ingestImagePath(p))).then((results) => {
200
+ const attachments = [];
201
+ for (const r of results) {
202
+ if (r.attachment)
203
+ attachments.push(r.attachment);
204
+ }
205
+ if (attachments.length > 0) {
206
+ setImages((prev) => [...prev, ...attachments]);
207
+ onPasteNotice?.(`Attached ${attachments.length} image${attachments.length === 1 ? "" : "s"}`);
208
+ }
209
+ });
210
+ return;
211
+ }
212
+ // Plain text: collapse if long, otherwise insert at cursor.
213
+ if (shouldCollapsePastedContent(text)) {
214
+ const marker = createPastedContentMarker(text);
215
+ setPastedRefs((prev) => {
216
+ const next = new Map(prev);
217
+ next.set(marker, text);
218
+ return next;
219
+ });
220
+ insertAtCursor(marker);
221
+ }
222
+ else {
223
+ insertAtCursor(text);
224
+ }
225
+ });
226
+ useKeyboard((key) => {
227
+ if (disabled)
228
+ return;
229
+ if (key.eventType === "release")
230
+ return;
231
+ // Pull latest state from refs at the top of each event — useKeyboard may
232
+ // bind this handler once for the component's lifetime, so closure reads
233
+ // of `buffer`/`cursor`/etc. would otherwise be stuck on initial values.
234
+ const b = bufferRef.current;
235
+ const c = cursorRef.current;
236
+ const sugs = suggestionsRef.current;
237
+ const hIndex = historyIndexRef.current;
238
+ // Suggestion navigation
239
+ if (sugs.length > 0) {
240
+ if (key.name === "up") {
241
+ setSuggestionIndex((i) => (i - 1 + sugs.length) % sugs.length);
242
+ return;
243
+ }
244
+ if (key.name === "down") {
245
+ setSuggestionIndex((i) => (i + 1) % sugs.length);
246
+ return;
247
+ }
248
+ if (key.name === "tab" || key.name === "return") {
249
+ acceptSuggestion();
250
+ return;
251
+ }
252
+ if (key.name === "escape") {
253
+ setSuggestions([]);
254
+ setSuggestionKind(null);
255
+ return;
256
+ }
257
+ }
258
+ if (key.name === "return") {
259
+ if (key.shift) {
260
+ insertAtCursor("\n");
261
+ return;
262
+ }
263
+ submit();
264
+ return;
265
+ }
266
+ if (key.name === "backspace") {
267
+ if (c === 0)
268
+ return;
269
+ const next = b.slice(0, c - 1) + b.slice(c);
270
+ const nextCursor = c - 1;
271
+ setBuffer(next);
272
+ setCursor(nextCursor);
273
+ void updateSuggestions(next, nextCursor);
274
+ return;
275
+ }
276
+ if (key.name === "delete") {
277
+ if (c === b.length)
278
+ return;
279
+ const next = b.slice(0, c) + b.slice(c + 1);
280
+ setBuffer(next);
281
+ void updateSuggestions(next, c);
282
+ return;
283
+ }
284
+ if (key.name === "left") {
285
+ if (key.option || key.ctrl) {
286
+ setCursor(previousWordBoundary(b, c));
287
+ }
288
+ else {
289
+ setCursor(Math.max(0, c - 1));
290
+ }
291
+ return;
292
+ }
293
+ if (key.name === "right") {
294
+ if (key.option || key.ctrl) {
295
+ setCursor(nextWordBoundary(b, c));
296
+ }
297
+ else {
298
+ setCursor(Math.min(b.length, c + 1));
299
+ }
300
+ return;
301
+ }
302
+ if (key.name === "up") {
303
+ if (hIndex !== null || b === "") {
304
+ const next = stepHistory({ history, index: hIndex, draft: "" }, "up", b);
305
+ if (next.changed) {
306
+ setBuffer(next.text);
307
+ setCursor(next.text.length);
308
+ setHistoryIndex(next.index);
309
+ }
310
+ return;
311
+ }
312
+ const lineStart = b.lastIndexOf("\n", c - 1);
313
+ if (lineStart === -1)
314
+ return;
315
+ const col = c - lineStart - 1;
316
+ const prevLineEnd = lineStart;
317
+ const prevLineStart = b.lastIndexOf("\n", prevLineEnd - 1) + 1;
318
+ setCursor(Math.min(prevLineEnd, prevLineStart + col));
319
+ return;
320
+ }
321
+ if (key.name === "down") {
322
+ if (hIndex !== null) {
323
+ const next = stepHistory({ history, index: hIndex, draft: "" }, "down", b);
324
+ setBuffer(next.text);
325
+ setCursor(next.text.length);
326
+ setHistoryIndex(next.index);
327
+ return;
328
+ }
329
+ const lineEnd = b.indexOf("\n", c);
330
+ if (lineEnd === -1)
331
+ return;
332
+ const lineStart = b.lastIndexOf("\n", c - 1) + 1;
333
+ const col = c - lineStart;
334
+ const nextLineStart = lineEnd + 1;
335
+ const nextLineEnd = b.indexOf("\n", nextLineStart);
336
+ const limit = nextLineEnd === -1 ? b.length : nextLineEnd;
337
+ setCursor(Math.min(limit, nextLineStart + col));
338
+ return;
339
+ }
340
+ if (key.name === "home" || (key.ctrl && key.name === "a")) {
341
+ const lineStart = b.lastIndexOf("\n", c - 1) + 1;
342
+ setCursor(lineStart);
343
+ return;
344
+ }
345
+ if (key.name === "end" || (key.ctrl && key.name === "e")) {
346
+ const lineEnd = b.indexOf("\n", c);
347
+ setCursor(lineEnd === -1 ? b.length : lineEnd);
348
+ return;
349
+ }
350
+ if (key.ctrl && key.name === "u") {
351
+ const lineStart = b.lastIndexOf("\n", c - 1) + 1;
352
+ const next = b.slice(0, lineStart) + b.slice(c);
353
+ setBuffer(next);
354
+ setCursor(lineStart);
355
+ void updateSuggestions(next, lineStart);
356
+ return;
357
+ }
358
+ if (key.ctrl && key.name === "k") {
359
+ const lineEnd = b.indexOf("\n", c);
360
+ const end = lineEnd === -1 ? b.length : lineEnd;
361
+ const next = b.slice(0, c) + b.slice(end);
362
+ setBuffer(next);
363
+ void updateSuggestions(next, c);
364
+ return;
365
+ }
366
+ // Character input — accept anything that's a printable string for key.name
367
+ // and not a control combo. Multi-codepoint names (CJK input methods send
368
+ // a fully-composed character; some terminals send 2+ codepoints for one
369
+ // grapheme) are still treated as a single insert.
370
+ if (key.name && !key.ctrl && isPrintableKeyName(key.name)) {
371
+ insertAtCursor(key.name);
372
+ }
373
+ });
374
+ const lines = buffer.split("\n");
375
+ const placeholderActive = buffer === "" && images.length === 0;
376
+ const railColor = disabled ? theme.inputBorderDisabled : theme.accent;
377
+ const surfaceBg = disabled ? theme.shade : theme.surface;
378
+ // Cursor position in visual lines.
379
+ const cursorRow = (() => {
380
+ const before = buffer.slice(0, cursor);
381
+ return before.split("\n").length - 1;
382
+ })();
383
+ const cursorCol = (() => {
384
+ const lastNl = buffer.lastIndexOf("\n", cursor - 1);
385
+ return cursor - lastNl - 1;
386
+ })();
387
+ return (_jsxs("box", { style: { flexDirection: "column", flexShrink: 0 }, children: [suggestions.length > 0 && (_jsx("box", { style: { flexDirection: "column", marginBottom: 0, paddingLeft: 3 }, children: suggestions.map((s, i) => (_jsx("text", { fg: i === suggestionIndex ? theme.accent : theme.textMuted, attributes: i === suggestionIndex ? 1 : 0, content: `${i === suggestionIndex ? "▸ " : " "}${s.label}${s.detail ? " " + s.detail : ""}` }, `sug-${i}`))) })), images.length > 0 && (_jsx("box", { style: { paddingLeft: 3, marginBottom: 0 }, children: _jsx("text", { fg: theme.accent, content: `${images.length} image${images.length === 1 ? "" : "s"} attached` }) })), React.createElement("box", {
388
+ style: {
389
+ flexShrink: 0,
390
+ backgroundColor: surfaceBg,
391
+ flexDirection: "column",
392
+ paddingTop: 1,
393
+ paddingBottom: 1,
394
+ paddingLeft: 2,
395
+ paddingRight: 2,
396
+ },
397
+ border: ["left"],
398
+ borderColor: railColor,
399
+ customBorderChars: {
400
+ topLeft: "",
401
+ topRight: "",
402
+ bottomLeft: "╹",
403
+ bottomRight: "",
404
+ horizontal: " ",
405
+ vertical: "┃",
406
+ topT: "",
407
+ bottomT: "",
408
+ cross: "",
409
+ leftT: "",
410
+ rightT: "",
411
+ },
412
+ }, placeholderActive ? (_jsx("text", { fg: theme.inputPlaceholder, content: disabled ? "Agent is responding…" : 'Ask anything... "Fix a TODO in the codebase"' }, "placeholder")) : (lines.map((line, idx) => {
413
+ const isCursorLine = idx === cursorRow;
414
+ if (!isCursorLine) {
415
+ return (_jsx("text", { fg: theme.inputText, content: line || " " }, `l-${idx}`));
416
+ }
417
+ const before = line.slice(0, cursorCol);
418
+ const at = line.slice(cursorCol, cursorCol + 1) || " ";
419
+ const after = line.slice(cursorCol + 1);
420
+ return (_jsxs("box", { style: { flexDirection: "row" }, children: [_jsx("text", { fg: theme.inputText, content: before }), _jsx("text", { fg: theme.inputBg, bg: theme.accent, content: at }), _jsx("text", { fg: theme.inputText, content: after })] }, `l-${idx}`));
421
+ })))] }));
422
+ }
423
+ /**
424
+ * True if `name` is a graphical character we should insert as-is (covers both
425
+ * single ASCII codepoints and composed CJK characters delivered by an IME).
426
+ * Filters out OpenTUI's named keys like "tab", "return", "f1", "pageup".
427
+ */
428
+ function isPrintableKeyName(name) {
429
+ if (name.length === 0)
430
+ return false;
431
+ // Common named keys OpenTUI passes through as `key.name`. If a single
432
+ // ASCII letter ends up here, length === 1 and it's not in this list, so it
433
+ // gets inserted normally.
434
+ const NAMED = new Set([
435
+ "tab", "return", "enter", "escape", "backspace", "delete", "space",
436
+ "up", "down", "left", "right",
437
+ "home", "end", "pageup", "pagedown", "insert",
438
+ "capslock", "numlock", "scrolllock", "printscreen", "pause",
439
+ ]);
440
+ if (NAMED.has(name))
441
+ return false;
442
+ if (/^f\d{1,2}$/.test(name))
443
+ return false; // f1..f24
444
+ // Reject obvious control bytes if any sneak through.
445
+ if (name.length === 1) {
446
+ const cp = name.codePointAt(0);
447
+ if (cp < 0x20 || cp === 0x7f)
448
+ return false;
449
+ }
450
+ return true;
451
+ }
452
+ function previousWordBoundary(text, cursor) {
453
+ if (cursor === 0)
454
+ return 0;
455
+ let i = cursor - 1;
456
+ while (i > 0 && /\s/.test(text[i]))
457
+ i--;
458
+ while (i > 0 && !/\s/.test(text[i - 1]))
459
+ i--;
460
+ return i;
461
+ }
462
+ function nextWordBoundary(text, cursor) {
463
+ if (cursor === text.length)
464
+ return text.length;
465
+ let i = cursor;
466
+ while (i < text.length && /\s/.test(text[i]))
467
+ i++;
468
+ while (i < text.length && !/\s/.test(text[i]))
469
+ i++;
470
+ return i;
471
+ }
@@ -0,0 +1,16 @@
1
+ export declare function defaultHistoryFilePath(): string;
2
+ export declare function loadHistorySync(filePath?: string): string[];
3
+ export declare function appendHistoryEntry(entry: string, filePath?: string): void;
4
+ export interface HistoryNavState {
5
+ history: string[];
6
+ index: number | null;
7
+ draft: string;
8
+ }
9
+ export interface HistoryNavResult {
10
+ text: string;
11
+ index: number | null;
12
+ draft: string;
13
+ changed: boolean;
14
+ }
15
+ export declare function stepHistory(state: HistoryNavState, direction: "up" | "down", currentText: string): HistoryNavResult;
16
+ export declare function pushHistoryEntry(history: string[], entry: string): string[];
@@ -0,0 +1,79 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { getBubbleHome } from "../bubble-home.js";
4
+ const MAX_HISTORY_ENTRIES = 1000;
5
+ export function defaultHistoryFilePath() {
6
+ return join(getBubbleHome(), "input-history.jsonl");
7
+ }
8
+ // JSONL on disk: each line is a JSON-encoded string. JSON encoding handles
9
+ // embedded newlines and quotes so multi-line composer entries round-trip safely.
10
+ export function loadHistorySync(filePath = defaultHistoryFilePath()) {
11
+ try {
12
+ if (!existsSync(filePath))
13
+ return [];
14
+ const raw = readFileSync(filePath, "utf8");
15
+ const out = [];
16
+ for (const line of raw.split("\n")) {
17
+ if (!line)
18
+ continue;
19
+ try {
20
+ const parsed = JSON.parse(line);
21
+ if (typeof parsed === "string" && parsed.length > 0)
22
+ out.push(parsed);
23
+ }
24
+ catch {
25
+ // Malformed line - skip rather than fail the whole load.
26
+ }
27
+ }
28
+ return out.length > MAX_HISTORY_ENTRIES ? out.slice(-MAX_HISTORY_ENTRIES) : out;
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ }
34
+ export function appendHistoryEntry(entry, filePath = defaultHistoryFilePath()) {
35
+ if (!entry || entry.trim().length === 0)
36
+ return;
37
+ try {
38
+ mkdirSync(dirname(filePath), { recursive: true });
39
+ appendFileSync(filePath, JSON.stringify(entry) + "\n", "utf8");
40
+ }
41
+ catch {
42
+ // Persistence is best-effort; never crash the composer over disk IO.
43
+ }
44
+ }
45
+ // Pure transition for up/down navigation. `index === null` means the user is
46
+ // editing a fresh draft; otherwise it points at history[index]. When stepping
47
+ // from the draft into history we snapshot the current text so down past the
48
+ // newest entry can restore it.
49
+ export function stepHistory(state, direction, currentText) {
50
+ const { history, index, draft } = state;
51
+ const noChange = { text: currentText, index, draft, changed: false };
52
+ if (direction === "up") {
53
+ if (history.length === 0)
54
+ return noChange;
55
+ if (index === null) {
56
+ const newIdx = history.length - 1;
57
+ return { text: history[newIdx], index: newIdx, draft: currentText, changed: true };
58
+ }
59
+ if (index > 0) {
60
+ return { text: history[index - 1], index: index - 1, draft, changed: true };
61
+ }
62
+ return noChange;
63
+ }
64
+ if (index === null)
65
+ return noChange;
66
+ if (index < history.length - 1) {
67
+ return { text: history[index + 1], index: index + 1, draft, changed: true };
68
+ }
69
+ return { text: draft, index: null, draft: "", changed: true };
70
+ }
71
+ // Push to in-memory history with last-entry dedupe so repeated identical
72
+ // submissions don't spam the stack.
73
+ export function pushHistoryEntry(history, entry) {
74
+ if (!entry || entry.trim().length === 0)
75
+ return history;
76
+ if (history.length > 0 && history[history.length - 1] === entry)
77
+ return history;
78
+ return [...history, entry];
79
+ }
@@ -0,0 +1,66 @@
1
+ /** @jsxImportSource @opentui/react */
2
+ /**
3
+ * Markdown rendering for the OpenTUI-based TUI.
4
+ *
5
+ * The previous Ink implementation hand-parsed markdown into block primitives
6
+ * because Ink had no native markdown support. OpenTUI ships
7
+ * `MarkdownRenderable` as a built-in (tree-sitter backed), so most of the
8
+ * 600+ lines of parsing logic collapse to a single intrinsic element.
9
+ *
10
+ * Public exports are kept compatible with the old Ink module so consumers
11
+ * (message-list, plan-confirm) don't need import changes.
12
+ */
13
+ import React from "react";
14
+ export type MarkdownBlock = {
15
+ type: "paragraph";
16
+ lines: string[];
17
+ } | {
18
+ type: "heading";
19
+ level: number;
20
+ text: string;
21
+ } | {
22
+ type: "code";
23
+ lang: string;
24
+ lines: string[];
25
+ } | {
26
+ type: "table";
27
+ headers: string[];
28
+ rows: string[][];
29
+ };
30
+ export interface MarkdownInlineSegment {
31
+ text: string;
32
+ bold?: boolean;
33
+ italic?: boolean;
34
+ code?: boolean;
35
+ }
36
+ /**
37
+ * Detect the byte offset where the last complete block ends. Used by the
38
+ * streaming renderer to keep already-finalized blocks stable while the
39
+ * trailing block is still being typed.
40
+ */
41
+ export declare function findLastBlockStart(text: string): number;
42
+ /** Stub kept for test compatibility — OpenTUI parses internally. */
43
+ export declare function parseMarkdownBlocks(text: string): MarkdownBlock[];
44
+ /** Stub kept for test compatibility. */
45
+ export declare function parseMarkdownInlineSegments(text: string, style?: {
46
+ bold?: boolean;
47
+ italic?: boolean;
48
+ code?: boolean;
49
+ }): MarkdownInlineSegment[];
50
+ interface MarkdownProps {
51
+ content: string;
52
+ terminalColumns?: number;
53
+ }
54
+ /**
55
+ * Render a complete (non-streaming) markdown blob. OpenTUI's MarkdownRenderable
56
+ * handles parse + style + code highlighting via tree-sitter internally.
57
+ */
58
+ export declare function MarkdownContent({ content, terminalColumns }: MarkdownProps): React.ReactNode;
59
+ /**
60
+ * Streaming variant. With OpenTUI's double-buffered renderer there's no
61
+ * tearing penalty for re-parsing on every token — the renderer composes the
62
+ * full frame natively before swapping. So we just delegate to the same
63
+ * primitive and let it handle partial input.
64
+ */
65
+ export declare function StreamingMarkdown({ content, terminalColumns }: MarkdownProps): React.ReactNode;
66
+ export {};