@bubblebrain-ai/bubble 0.0.25 → 0.0.26

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 (38) hide show
  1. package/README.md +4 -2
  2. package/dist/agent.js +1 -1
  3. package/dist/clipboard.d.ts +14 -0
  4. package/dist/clipboard.js +132 -0
  5. package/dist/model-catalog.d.ts +3 -1
  6. package/dist/model-catalog.js +17 -28
  7. package/dist/prompt/compose.js +1 -1
  8. package/dist/provider-anthropic.d.ts +4 -0
  9. package/dist/provider-anthropic.js +31 -0
  10. package/dist/provider-ark-responses.d.ts +17 -0
  11. package/dist/provider-ark-responses.js +462 -0
  12. package/dist/provider-transform.js +7 -0
  13. package/dist/provider.d.ts +7 -0
  14. package/dist/provider.js +150 -22
  15. package/dist/slash-commands/commands.js +22 -0
  16. package/dist/tools/todo.js +22 -38
  17. package/dist/tui-ink/app.js +80 -58
  18. package/dist/tui-ink/input-box.d.ts +1 -0
  19. package/dist/tui-ink/input-box.js +20 -16
  20. package/dist/tui-ink/message-list.d.ts +17 -1
  21. package/dist/tui-ink/message-list.js +74 -13
  22. package/dist/tui-ink/model-picker.d.ts +3 -2
  23. package/dist/tui-ink/model-picker.js +17 -4
  24. package/dist/tui-ink/question-dialog.js +36 -10
  25. package/dist/tui-ink/run.js +14 -22
  26. package/dist/tui-ink/terminal-mouse.d.ts +11 -0
  27. package/dist/tui-ink/terminal-mouse.js +13 -0
  28. package/dist/tui-ink/welcome.js +13 -3
  29. package/dist/variant/variant-resolver.js +4 -1
  30. package/package.json +1 -1
  31. package/dist/tui/transcript-scroll.d.ts +0 -25
  32. package/dist/tui/transcript-scroll.js +0 -20
  33. package/dist/tui-ink/transcript-input.d.ts +0 -8
  34. package/dist/tui-ink/transcript-input.js +0 -9
  35. package/dist/tui-ink/transcript-viewport-math.d.ts +0 -10
  36. package/dist/tui-ink/transcript-viewport-math.js +0 -16
  37. package/dist/tui-ink/transcript-viewport.d.ts +0 -24
  38. package/dist/tui-ink/transcript-viewport.js +0 -83
@@ -16,7 +16,6 @@ import { createPastedContentMarker, expandPastedContentMarkers, shouldCollapsePa
16
16
  import { imageDisplayLabel } from "../tui/image-display.js";
17
17
  const MIN_VISIBLE_LINES = 3;
18
18
  const MAX_VISIBLE_LINES = 6;
19
- const CURSOR_BLINK_INTERVAL_MS = 530;
20
19
  const PADDING_X = 1;
21
20
  const PROMPT = " > ";
22
21
  const MAX_VISIBLE_SUGGESTIONS = 8;
@@ -44,6 +43,9 @@ export function isCtrlCInput(input, key) {
44
43
  export function shouldUseLineComposerFrame(_background) {
45
44
  return true;
46
45
  }
46
+ export function composerSurfaceBackground(lineFrame, background, inputBg) {
47
+ return lineFrame ? background : inputBg;
48
+ }
47
49
  export function shouldUseHardwareComposerCursor(env = process.env) {
48
50
  return env.BUBBLE_HARDWARE_CURSOR === "1";
49
51
  }
@@ -903,23 +905,24 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
903
905
  const displayText = imageInlinePrefix + text;
904
906
  const displayCursor = cursor + imageInlinePrefix.length;
905
907
  const displayCursorToSourceCursor = (value) => Math.max(0, Math.min(text.length, value - imageInlinePrefix.length));
908
+ // Steady (non-blinking) cursor on purpose. The composer lives in the live
909
+ // (repainting) region; a blink timer would rewrite these rows ~twice a second
910
+ // even at idle, and the terminal drops any in-progress text selection every
911
+ // time the underlying cells are rewritten — which is why composer text could
912
+ // not be highlighted/copied while agent answers (committed to <Static>, never
913
+ // repainted) could. Keeping the cursor steady leaves the idle composer frame
914
+ // static, so native selection works. We still hide it while disabled.
906
915
  useEffect(() => {
907
- if (disabled) {
908
- setSoftwareCursorVisible(false);
909
- return;
910
- }
911
- setSoftwareCursorVisible(true);
912
- const timer = setInterval(() => {
913
- setSoftwareCursorVisible((visible) => !visible);
914
- }, CURSOR_BLINK_INTERVAL_MS);
915
- return () => clearInterval(timer);
916
- }, [disabled, displayCursor, displayText]);
916
+ setSoftwareCursorVisible(!disabled);
917
+ }, [disabled]);
917
918
  const visualLines = useMemo(() => computeVisualLines(displayText, lineWidth), [displayText, lineWidth]);
918
919
  const { row: cursorVisualRow, col: cursorVisualCol } = cursorToVisual(visualLines, displayCursor);
919
- // ---- Wheel-vs-keyboard classification for Up/Down arrows ----
920
+ // ---- Up/Down arrow handling in the composer ----
920
921
  //
921
- // Up/Down reaching the composer are keyboard navigation: move within
922
- // multiline input first, then browse prompt history at the top/bottom edge.
922
+ // Scrolling is the terminal's job now (native scrollback), so the composer
923
+ // owns Up/Down unconditionally: move within multiline input first, then
924
+ // browse prompt history at the top edge (Up → previous sent message) or the
925
+ // bottom edge (Down → next message, then back to the in-progress draft).
923
926
  const performVerticalArrowRef = useRef(() => { });
924
927
  performVerticalArrowRef.current = (direction) => {
925
928
  if (direction === "up") {
@@ -1086,6 +1089,7 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
1086
1089
  // Reference cursorTick so the effect re-runs on the forced render pass.
1087
1090
  void cursorTick;
1088
1091
  const inputBg = disabled ? theme.inputBgDisabled : theme.inputBg;
1092
+ const composerBg = composerSurfaceBackground(lineFrame, theme.background, inputBg);
1089
1093
  const rowBg = lineFrame ? undefined : inputBg;
1090
1094
  const cursorFg = lineFrame ? theme.background : inputBg;
1091
1095
  const cursorCellStyle = resolveSoftwareCursorCellStyle({
@@ -1100,7 +1104,7 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
1100
1104
  const visibleWidth = stringWidth(value);
1101
1105
  return value + " ".repeat(Math.max(0, contentWidth - visibleWidth));
1102
1106
  };
1103
- return (_jsxs(Box, { flexDirection: "column", width: width, backgroundColor: theme.background, children: [lineFrame && (_jsx(Box, { paddingX: PADDING_X, children: _jsx(Text, { color: theme.border, children: "─".repeat(contentWidth) }) })), _jsxs(Box, { flexDirection: "column", paddingX: PADDING_X, width: width, backgroundColor: inputBg, children: [hasMoreAbove && (_jsx(Text, { backgroundColor: rowBg, color: theme.muted, dimColor: true, children: filledLine(` ↑ ${scrollOffset} more`) })), displayedLines.map((row) => {
1107
+ return (_jsxs(Box, { flexDirection: "column", width: width, backgroundColor: theme.background, children: [lineFrame && (_jsx(Box, { paddingX: PADDING_X, children: _jsx(Text, { color: theme.border, children: "─".repeat(contentWidth) }) })), _jsxs(Box, { flexDirection: "column", paddingX: PADDING_X, width: width, backgroundColor: composerBg, children: [hasMoreAbove && (_jsx(Text, { backgroundColor: rowBg, color: theme.muted, dimColor: true, children: filledLine(` ↑ ${scrollOffset} more`) })), displayedLines.map((row) => {
1104
1108
  if (row.kind === "pad") {
1105
1109
  return (_jsx(Text, { backgroundColor: rowBg, children: " ".repeat(contentWidth) }, row.key));
1106
1110
  }
@@ -1121,7 +1125,7 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
1121
1125
  });
1122
1126
  const renderedLine = renderedSegments.map((segment) => segment.text).join("");
1123
1127
  const fill = " ".repeat(Math.max(0, lineWidth - stringWidth(renderedLine)));
1124
- return (_jsxs(Box, { height: 1, overflow: "hidden", backgroundColor: inputBg, ref: isCursorLine
1128
+ return (_jsxs(Box, { height: 1, overflow: "hidden", backgroundColor: composerBg, ref: isCursorLine
1125
1129
  ? (el) => {
1126
1130
  cursorLineRef.current = el;
1127
1131
  }
@@ -25,6 +25,22 @@ interface MessageListProps {
25
25
  nowTick?: number;
26
26
  /** Optional banner rendered as the first item in the app-controlled transcript. */
27
27
  welcomeBanner?: React.ReactNode;
28
+ /**
29
+ * Bumped whenever the settled transcript is rebuilt non-monotonically
30
+ * (/clear, /compact, /rewind, session switch). Used as the <Static> key so
31
+ * Ink discards its already-printed rows and re-prints the rebuilt list onto
32
+ * a freshly-cleared screen instead of appending duplicates.
33
+ */
34
+ staticGeneration?: number;
35
+ /** Horizontal padding applied inside each committed/streaming row. */
36
+ paddingX?: number;
37
+ /**
38
+ * Maximum height (rows) for the live/dynamic region. The in-progress turn is
39
+ * clipped to this and pinned to the bottom (tail view) so the live frame
40
+ * never exceeds the viewport — a taller-than-pane frame breaks Ink's redraw
41
+ * under tmux. The full turn still lands in <Static> scrollback on commit.
42
+ */
43
+ maxStreamRows?: number;
28
44
  }
29
- export declare function MessageList({ messages, streamingContent, streamingReasoning, streamingTools, streamingParts, terminalColumns, showThinking, expandedToolOutput, verboseTrace, pendingApproval, nowTick, welcomeBanner, }: MessageListProps): import("react/jsx-runtime").JSX.Element;
45
+ export declare function MessageList({ messages, streamingContent, streamingReasoning, streamingTools, streamingParts, terminalColumns, showThinking, expandedToolOutput, verboseTrace, pendingApproval, nowTick, welcomeBanner, staticGeneration, paddingX, maxStreamRows, }: MessageListProps): import("react/jsx-runtime").JSX.Element;
30
46
  export {};
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React from "react";
3
- import { Box, Text } from "ink";
3
+ import { Box, Static, Text, measureElement } from "ink";
4
4
  import { useTheme } from "./theme.js";
5
5
  import { highlightCode, inferLang } from "./code-highlight.js";
6
6
  import { MarkdownContent, StreamingMarkdown } from "./markdown.js";
@@ -11,7 +11,7 @@ import { formatSubagentRoute } from "../agent/subagent-route-format.js";
11
11
  import { sanitizeInternalReminderBlocks } from "../agent/internal-reminder-sanitizer.js";
12
12
  import { splitImageDisplayContent } from "../tui/image-display.js";
13
13
  const EXECUTE_COMMAND_BLOCK_MAX_LINES = 4;
14
- export function MessageList({ messages, streamingContent, streamingReasoning, streamingTools, streamingParts, terminalColumns, showThinking = false, expandedToolOutput = false, verboseTrace, pendingApproval, nowTick, welcomeBanner, }) {
14
+ export function MessageList({ messages, streamingContent, streamingReasoning, streamingTools, streamingParts, terminalColumns, showThinking = false, expandedToolOutput = false, verboseTrace, pendingApproval, nowTick, welcomeBanner, staticGeneration = 0, paddingX = 1, maxStreamRows, }) {
15
15
  const theme = useTheme();
16
16
  const hasStreaming = !!(streamingContent ||
17
17
  streamingReasoning ||
@@ -35,12 +35,63 @@ export function MessageList({ messages, streamingContent, streamingReasoning, st
35
35
  separateFromPrevious: msg.role === "user" && regularMessages[i - 1]?.role === "user",
36
36
  });
37
37
  }
38
- return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [staticItems.map((item) => {
39
- if (item.kind === "welcome") {
40
- return _jsx(React.Fragment, { children: welcomeBanner }, item.key);
41
- }
42
- return (_jsx(MessageItem, { message: item.message, terminalColumns: terminalColumns, showThinking: showThinking, expandedToolOutput: expandedToolOutput, verboseTrace: verboseTrace, showExpandHint: item.showExpandHint, separateFromPrevious: item.separateFromPrevious, nowTick: item.showExpandHint ? nowTick : undefined }, item.key));
43
- }), hasStreaming && (_jsx(StreamingMessage, { content: streamingContent, reasoning: streamingReasoning, tools: streamingTools, parts: streamingParts, terminalColumns: terminalColumns, showThinking: showThinking, expandedToolOutput: expandedToolOutput, verboseTrace: verboseTrace, pendingApproval: pendingApproval, nowTick: nowTick })), pendingSteerMessages.length > 0 && (_jsx(PendingInputMessagesBlock, { messages: pendingSteerMessages, terminalColumns: terminalColumns, title: "Messages to steer at next model call", hint: "applies before the next provider request", bulletColor: theme.warning })), queuedInputMessages.length > 0 && (_jsx(PendingInputMessagesBlock, { messages: queuedInputMessages, terminalColumns: terminalColumns, title: "Messages queued for next turn", hint: "runs after the current answer", bulletColor: theme.muted }))] }));
38
+ const hasDynamic = hasStreaming || pendingSteerMessages.length > 0 || queuedInputMessages.length > 0;
39
+ // The live region must never grow taller than the viewport: a frame taller
40
+ // than the pane breaks Ink's in-place redraw under tmux (the cursor-up clear
41
+ // can't reach scrolled-off rows), leaving large blank gaps + stray glyphs.
42
+ // Clip it to maxStreamRows and pin to the bottom so the user sees the latest
43
+ // output (tail view); the full turn lands in <Static> scrollback on commit.
44
+ const clampDynamic = typeof maxStreamRows === "number" && maxStreamRows > 0;
45
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(Static, { items: staticItems, children: (item) => {
46
+ if (item.kind === "welcome") {
47
+ return (_jsx(Box, { flexDirection: "column", paddingX: paddingX, children: welcomeBanner }, item.key));
48
+ }
49
+ return (_jsx(Box, { flexDirection: "column", paddingX: paddingX, children: _jsx(MessageItem, { message: item.message, terminalColumns: terminalColumns, showThinking: showThinking, expandedToolOutput: expandedToolOutput, verboseTrace: verboseTrace, showExpandHint: item.showExpandHint, separateFromPrevious: item.separateFromPrevious }) }, item.key));
50
+ } }, `transcript-${staticGeneration}`), hasDynamic && (_jsxs(DynamicClamp, { maxRows: clampDynamic ? maxStreamRows : undefined, paddingX: paddingX, children: [hasStreaming && (_jsx(StreamingMessage, { content: streamingContent, reasoning: streamingReasoning, tools: streamingTools, parts: streamingParts, terminalColumns: terminalColumns, showThinking: showThinking, expandedToolOutput: expandedToolOutput, verboseTrace: verboseTrace, pendingApproval: pendingApproval, nowTick: nowTick })), pendingSteerMessages.length > 0 && (_jsx(PendingInputMessagesBlock, { messages: pendingSteerMessages, terminalColumns: terminalColumns, title: "Messages to steer at next model call", hint: "applies before the next provider request", bulletColor: theme.warning })), queuedInputMessages.length > 0 && (_jsx(PendingInputMessagesBlock, { messages: queuedInputMessages, terminalColumns: terminalColumns, title: "Messages queued for next turn", hint: "runs after the current answer", bulletColor: theme.muted }))] }))] }));
51
+ }
52
+ /**
53
+ * Bounds the live (in-progress turn) region to at most `maxRows` rows, pinned
54
+ * to the bottom so the user always sees the latest output (tail view). A live
55
+ * frame taller than the terminal pane breaks Ink's in-place redraw under tmux
56
+ * (the cursor-up clear can't reach rows that scrolled off), leaving large blank
57
+ * gaps and stray glyphs. We measure the natural content height and only clip
58
+ * when it actually exceeds `maxRows`, so short turns keep their natural height
59
+ * (no reserved-space gap). The full turn still lands in <Static> scrollback the
60
+ * moment it commits, so nothing is lost — only the live preview is windowed.
61
+ */
62
+ function DynamicClamp({ maxRows, paddingX, children, }) {
63
+ const innerRef = React.useRef(null);
64
+ const [offset, setOffset] = React.useState(0);
65
+ const [clipHeight, setClipHeight] = React.useState(undefined);
66
+ // Re-measure after every commit (streaming changes height with each token).
67
+ // useLayoutEffect runs before Ink flushes the frame to the terminal, so the
68
+ // clamp is applied in the same paint — avoiding a one-frame overflow flash
69
+ // under tmux when a turn first grows past the pane. setState bails out via
70
+ // Object.is when nothing moved, so the steady state does not loop.
71
+ React.useLayoutEffect(() => {
72
+ if (!maxRows || !innerRef.current) {
73
+ if (offset !== 0)
74
+ setOffset(0);
75
+ if (clipHeight !== undefined)
76
+ setClipHeight(undefined);
77
+ return;
78
+ }
79
+ const height = measureElement(innerRef.current).height;
80
+ if (height > maxRows) {
81
+ const nextOffset = -(height - maxRows);
82
+ if (nextOffset !== offset)
83
+ setOffset(nextOffset);
84
+ if (clipHeight !== maxRows)
85
+ setClipHeight(maxRows);
86
+ }
87
+ else {
88
+ if (offset !== 0)
89
+ setOffset(0);
90
+ if (clipHeight !== undefined)
91
+ setClipHeight(undefined);
92
+ }
93
+ });
94
+ return (_jsx(Box, { flexDirection: "column", flexShrink: 0, paddingX: paddingX, ...(clipHeight !== undefined ? { height: clipHeight, overflowY: "hidden" } : {}), children: _jsx(Box, { ref: innerRef, flexDirection: "column", flexShrink: 0, marginTop: offset, children: children }) }));
44
95
  }
45
96
  // Memoized: with no <Static> region, every transcript row re-renders on each
46
97
  // state change unless its props are referentially stable. Message objects are
@@ -62,13 +113,16 @@ const MessageItem = React.memo(function MessageItem({ message, terminalColumns,
62
113
  return (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: theme.error, children: "\u23F9 " }), _jsx(Text, { color: theme.muted, dimColor: true, children: message.content || "Interrupted by user" })] }));
63
114
  }
64
115
  const visibleReasoning = sanitizeInternalReminderBlocks(message.reasoning ?? "").trim();
65
- const hasVisibleAssistantContent = !!message.content ||
116
+ // Same defense as reasoning: strip any internal reminder markup the model
117
+ // echoed back into its visible answer so it never reaches the transcript.
118
+ const visibleContent = sanitizeInternalReminderBlocks(message.content ?? "");
119
+ const hasVisibleAssistantContent = !!visibleContent.trim() ||
66
120
  (message.toolCalls?.length ?? 0) > 0 ||
67
121
  (message.parts?.length ?? 0) > 0 ||
68
122
  (!!visibleReasoning && (showThinking || verboseTrace));
69
123
  if (!hasVisibleAssistantContent)
70
124
  return null;
71
- return (_jsxs(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: [visibleReasoning && (showThinking || verboseTrace) && _jsx(ReasoningTraceBlock, { reasoning: visibleReasoning }), message.parts && message.parts.length > 0 ? (_jsx(MessageParts, { parts: message.parts, terminalColumns: terminalColumns, expandedToolOutput: expandedToolOutput, verboseTrace: verboseTrace, pendingApproval: undefined, showExpandHint: showExpandHint, nowTick: nowTick })) : (_jsxs(_Fragment, { children: [message.toolCalls && (_jsx(ToolsPart, { toolCalls: message.toolCalls, terminalColumns: terminalColumns, expandedToolOutput: expandedToolOutput, verboseTrace: verboseTrace, pendingApproval: undefined, showExpandHint: showExpandHint, nowTick: nowTick })), message.content && _jsx(MarkdownContent, { content: message.content })] })), verboseTrace && message.toolCalls && message.toolCalls.length > 0 && (_jsx(TurnDigest, { toolCalls: message.toolCalls })), message.taskElapsedMs !== undefined && (_jsx(TaskDurationLine, { elapsedMs: message.taskElapsedMs }))] }));
125
+ return (_jsxs(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: [visibleReasoning && (showThinking || verboseTrace) && _jsx(ReasoningTraceBlock, { reasoning: visibleReasoning }), message.parts && message.parts.length > 0 ? (_jsx(MessageParts, { parts: message.parts, terminalColumns: terminalColumns, expandedToolOutput: expandedToolOutput, verboseTrace: verboseTrace, pendingApproval: undefined, showExpandHint: showExpandHint, nowTick: nowTick })) : (_jsxs(_Fragment, { children: [message.toolCalls && (_jsx(ToolsPart, { toolCalls: message.toolCalls, terminalColumns: terminalColumns, expandedToolOutput: expandedToolOutput, verboseTrace: verboseTrace, pendingApproval: undefined, showExpandHint: showExpandHint, nowTick: nowTick })), visibleContent.trim() && _jsx(MarkdownContent, { content: visibleContent })] })), verboseTrace && message.toolCalls && message.toolCalls.length > 0 && (_jsx(TurnDigest, { toolCalls: message.toolCalls })), message.taskElapsedMs !== undefined && (_jsx(TaskDurationLine, { elapsedMs: message.taskElapsedMs }))] }));
72
126
  });
73
127
  function StreamingMessage({ content, reasoning, tools, parts, terminalColumns, showThinking, expandedToolOutput, verboseTrace, pendingApproval, nowTick, }) {
74
128
  const deferredContent = React.useDeferredValue(content);
@@ -105,13 +159,19 @@ function findLastTextPartIndex(parts) {
105
159
  }
106
160
  function TimelineText({ content, compactTop, terminalColumns, streaming = false, }) {
107
161
  const theme = useTheme();
108
- if (!content.trim())
162
+ // Strip any internal reminder/context markup the model echoed back into its
163
+ // visible text — the reasoning path already does this. Without it, a model
164
+ // that parrots a <bubble_internal_*> block (e.g. an injected system reminder)
165
+ // leaks it straight into the transcript. The streaming sanitizer also holds a
166
+ // half-typed block until it closes, so partial markup never flashes.
167
+ const visible = sanitizeInternalReminderBlocks(content);
168
+ if (!visible.trim())
109
169
  return null;
110
170
  // marginLeft (2) + "● " marker (3 visual cells) = 5 cells consumed by the
111
171
  // timeline gutter; pass the remaining width so wide blocks like tables size
112
172
  // themselves against the actual content area instead of the raw terminal.
113
173
  const available = terminalColumns ? Math.max(20, terminalColumns - 5) : undefined;
114
- const trimmed = content.trim();
174
+ const trimmed = visible.trim();
115
175
  return (_jsxs(Box, { marginLeft: 2, marginTop: compactTop ? 0 : 1, children: [_jsx(Text, { color: theme.agent, children: "\u25CF " }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: streaming ? (_jsx(StreamingMarkdown, { content: trimmed, maxWidth: available })) : (_jsx(MarkdownContent, { content: trimmed, maxWidth: available })) })] }));
116
176
  }
117
177
  function ToolsPart({ toolCalls, terminalColumns, expandedToolOutput, verboseTrace, pendingApproval, showExpandHint, compactTop = false, nowTick, showActivity = false, }) {
@@ -254,7 +314,8 @@ function UserMessageBlock({ content, terminalColumns, inputStatus, separateFromP
254
314
  const { bodyLines, referenceLines } = splitImageDisplayContent(content);
255
315
  const wrappedLines = bodyLines
256
316
  .flatMap((line) => wrapByVisualWidth(line, bubbleTextWidth));
257
- return (_jsxs(Box, { flexDirection: "column", marginTop: separateFromPrevious ? 1 : 0, children: [badge && (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: inputStatus === "pending_steer" ? theme.warning : theme.muted, children: ` ${badge} ` }), _jsx(Text, { color: theme.dim, children: inputStatus === "pending_steer" ? "applies at the next model call" : "runs after this turn" })] })), wrappedLines.map((line, index) => (_jsxs(Box, { children: [_jsx(Text, { backgroundColor: theme.userMessageBg, color: theme.userRail, children: index === 0 ? "▌ " : " " }), _jsx(Text, { backgroundColor: theme.userMessageBg, color: theme.userMessageText, children: ` ${padVisual(line || " ", bubbleTextWidth)} ` })] }, index))), referenceLines.map((line, index) => (_jsx(Box, { children: _jsx(Text, { color: theme.muted, children: ` ${line}` }) }, `attachment-${index}`)))] }));
317
+ const attachmentReferenceIndent = " ".repeat(railWidth + 1);
318
+ return (_jsxs(Box, { flexDirection: "column", marginTop: separateFromPrevious ? 1 : 0, children: [badge && (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: inputStatus === "pending_steer" ? theme.warning : theme.muted, children: ` ${badge} ` }), _jsx(Text, { color: theme.dim, children: inputStatus === "pending_steer" ? "applies at the next model call" : "runs after this turn" })] })), wrappedLines.map((line, index) => (_jsxs(Box, { children: [_jsx(Text, { backgroundColor: theme.userMessageBg, color: theme.userRail, children: "▌ " }), _jsx(Text, { backgroundColor: theme.userMessageBg, color: theme.userMessageText, children: ` ${padVisual(line || " ", bubbleTextWidth)} ` })] }, index))), referenceLines.map((line, index) => (_jsx(Box, { children: _jsx(Text, { color: theme.muted, children: `${attachmentReferenceIndent}${line}` }) }, `attachment-${index}`)))] }));
258
319
  }
259
320
  function PendingInputMessagesBlock({ messages, terminalColumns, title, hint, bulletColor, }) {
260
321
  const theme = useTheme();
@@ -31,8 +31,8 @@ export declare function modelPickerBodyRows(termHeight: number): number;
31
31
  export declare function clampPickerIndex(index: number, length: number): number;
32
32
  export declare function pickerWindowStart(selectedIndex: number, length: number, visibleRows: number): number;
33
33
  export declare function padPickerRows(rows: string[], bodyRows: number, width: number): string[];
34
- export declare function formatReasoningLevelsLabel(levels: readonly ThinkingLevel[]): string;
35
- export declare function formatModelPickerRow(option: Pick<ModelPickerOption, "label" | "providerBadge" | "reasoningLevels">, options: {
34
+ export declare function formatReasoningLevelsLabel(levels: readonly ThinkingLevel[], asToggle?: boolean): string;
35
+ export declare function formatModelPickerRow(option: Pick<ModelPickerOption, "id" | "label" | "providerBadge" | "reasoningLevels">, options: {
36
36
  selected: boolean;
37
37
  current: boolean;
38
38
  width: number;
@@ -40,6 +40,7 @@ export declare function formatModelPickerRow(option: Pick<ModelPickerOption, "la
40
40
  export declare function formatEffortPickerRow(level: ThinkingLevel, options: {
41
41
  selected: boolean;
42
42
  width: number;
43
+ asToggle?: boolean;
43
44
  }): string;
44
45
  export declare function formatNoModelResultsRow(query: string, width: number): string;
45
46
  export declare function preferredEffortIndex(option: Pick<ModelPickerOption, "reasoningLevels">, currentThinkingLevel: ThinkingLevel): number;
@@ -78,8 +78,17 @@ export function padPickerRows(rows, bodyRows, width) {
78
78
  }
79
79
  return padded;
80
80
  }
81
- export function formatReasoningLevelsLabel(levels) {
81
+ // MiniMax models expose thinking as a binary on/off switch (the API's `thinking`
82
+ // param is disabled|adaptive — there's no graded effort), so render the "on"
83
+ // level as on/off instead of our internal "medium". Scoped to MiniMax only —
84
+ // other 2-level models (e.g. GLM toggles) keep their effort labels.
85
+ function isMiniMaxToggleModel(modelId) {
86
+ return modelId.toLowerCase().includes("minimax");
87
+ }
88
+ export function formatReasoningLevelsLabel(levels, asToggle = false) {
82
89
  const normalized = levels.length > 0 ? levels : ["off"];
90
+ if (asToggle)
91
+ return "thinking on/off";
83
92
  return `effort ${normalized.join("/")}`;
84
93
  }
85
94
  export function formatModelPickerRow(option, options) {
@@ -87,7 +96,7 @@ export function formatModelPickerRow(option, options) {
87
96
  const marker = options.selected ? "> " : " ";
88
97
  const label = option.label.replace(/\s+/g, " ").trim();
89
98
  const provider = option.providerBadge.replace(/\s+/g, " ").trim();
90
- const effort = formatReasoningLevelsLabel(option.reasoningLevels);
99
+ const effort = formatReasoningLevelsLabel(option.reasoningLevels, isMiniMaxToggleModel(option.id));
91
100
  const current = options.current ? " ●" : "";
92
101
  const providerWidth = Math.max(6, Math.min(16, Math.floor(width * 0.18)));
93
102
  const effortWidth = Math.max(12, Math.min(30, Math.floor(width * 0.32)));
@@ -106,7 +115,8 @@ export function formatModelPickerRow(option, options) {
106
115
  export function formatEffortPickerRow(level, options) {
107
116
  const width = Math.max(24, options.width);
108
117
  const marker = options.selected ? "> " : " ";
109
- const row = `${marker}${level} ${effortDescription(level)}`;
118
+ const name = options.asToggle ? (level === "off" ? "off" : "on") : level;
119
+ const row = `${marker}${name} ${effortDescription(level, options.asToggle)}`;
110
120
  return padVisual(truncateVisual(row, width), width);
111
121
  }
112
122
  export function formatNoModelResultsRow(query, width) {
@@ -125,7 +135,9 @@ export function preferredEffortIndex(option, currentThinkingLevel) {
125
135
  export function shouldOpenEffortPicker(option) {
126
136
  return option.reasoningLevels.length > 1;
127
137
  }
128
- function effortDescription(level) {
138
+ function effortDescription(level, asToggle) {
139
+ if (asToggle)
140
+ return level === "off" ? "thinking disabled" : "thinking enabled";
129
141
  switch (level) {
130
142
  case "off":
131
143
  return "no reasoning effort";
@@ -343,6 +355,7 @@ function EffortPickerView({ model, selectedIndex, bodyRows, rowWidth, }) {
343
355
  row: formatEffortPickerRow(level, {
344
356
  selected: index === safeSelectedIndex,
345
357
  width: rowWidth,
358
+ asToggle: isMiniMaxToggleModel(model.id),
346
359
  }),
347
360
  selected: index === safeSelectedIndex,
348
361
  }));
@@ -15,17 +15,27 @@ export function QuestionDialog({ request, onSubmit, onCancel }) {
15
15
  const canUseCustom = question?.custom !== false;
16
16
  const isMultiple = question?.multiple === true;
17
17
  const totalTabs = request.questions.length;
18
+ // The "Custom: type to answer" row is the last navigable item (when custom is
19
+ // allowed), so Up/Down can reach and highlight it just like an option.
20
+ const customIndex = canUseCustom ? options.length : -1;
21
+ const navCount = options.length + (canUseCustom ? 1 : 0);
22
+ const isCustomSelected = canUseCustom && selected === customIndex;
18
23
  const currentAnswer = useMemo(() => answers[index] ?? [], [answers, index]);
19
24
  const commitQuestion = () => {
20
- const option = options[selected]?.label;
21
25
  const customAnswer = custom.trim();
22
- const nextAnswer = customAnswer
23
- ? [customAnswer]
26
+ // Submit what is actually selected: the Custom row submits the typed text;
27
+ // an option row submits that option (a stale custom buffer no longer wins).
28
+ const nextAnswer = isCustomSelected
29
+ ? customAnswer
30
+ ? [customAnswer]
31
+ : []
24
32
  : isMultiple
25
33
  ? currentAnswer
26
- : option
27
- ? [option]
28
- : [];
34
+ : options[selected]?.label
35
+ ? [options[selected].label]
36
+ : customAnswer
37
+ ? [customAnswer]
38
+ : [];
29
39
  const nextAnswers = answers.map((answer, i) => i === index ? nextAnswer : answer);
30
40
  if (index < request.questions.length - 1) {
31
41
  setAnswers(nextAnswers);
@@ -82,11 +92,24 @@ export function QuestionDialog({ request, onSubmit, onCancel }) {
82
92
  return;
83
93
  }
84
94
  if (key.downArrow) {
85
- setSelected((i) => Math.min(Math.max(0, options.length - 1), i + 1));
95
+ setSelected((i) => Math.min(Math.max(0, navCount - 1), i + 1));
96
+ return;
97
+ }
98
+ // Tab toggles a checkbox; only meaningful while an option row is selected.
99
+ if (key.tab) {
100
+ if (!isCustomSelected)
101
+ toggleCurrentOption();
86
102
  return;
87
103
  }
88
- if (key.tab || input === " ") {
89
- toggleCurrentOption();
104
+ if (input === " ") {
105
+ // Space toggles the highlighted option, but on the Custom row it types a
106
+ // literal space into the answer instead of swallowing the keystroke.
107
+ if (isCustomSelected) {
108
+ setCustom((value) => value + " ");
109
+ }
110
+ else {
111
+ toggleCurrentOption();
112
+ }
90
113
  return;
91
114
  }
92
115
  if (key.return) {
@@ -97,7 +120,10 @@ export function QuestionDialog({ request, onSubmit, onCancel }) {
97
120
  setCustom((value) => value.slice(0, -1));
98
121
  return;
99
122
  }
123
+ // Any printable key starts/continues the custom answer and moves the
124
+ // highlight onto the Custom row, so typing and arrow navigation agree.
100
125
  if (canUseCustom && input && !key.ctrl && !key.meta) {
126
+ setSelected(customIndex);
101
127
  setCustom((value) => value + input);
102
128
  }
103
129
  });
@@ -105,5 +131,5 @@ export function QuestionDialog({ request, onSubmit, onCancel }) {
105
131
  const isSelected = optionIndex === selected;
106
132
  const isChecked = currentAnswer.includes(option.label);
107
133
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: isSelected ? theme.accent : undefined, children: [isSelected ? "> " : " ", isMultiple ? `[${isChecked ? "x" : " "}] ` : "", option.label] }), option.description && (_jsx(Box, { marginLeft: 4, children: _jsx(Text, { color: theme.muted, dimColor: true, children: option.description }) }))] }, `${option.label}-${optionIndex}`));
108
- }) }), canUseCustom && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: custom ? undefined : theme.muted, children: ["Custom: ", custom || "type to answer..."] }) })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: "\u2191\u2193 choose \u00B7 Tab/Space toggle \u00B7 Enter submit \u00B7 Esc dismiss" }) })] }));
134
+ }) }), canUseCustom && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: isCustomSelected ? theme.accent : custom ? undefined : theme.muted, children: [isCustomSelected ? "> " : " ", "Custom: ", custom || "type to answer"] }) })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.muted, children: ["\u2191\u2193 choose \u00B7 ", isMultiple ? "Space toggle · " : "", "type for Custom \u00B7 Enter submit \u00B7 Esc dismiss"] }) })] }));
109
135
  }
@@ -1,23 +1,23 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { render } from "ink";
3
3
  import { App } from "./app.js";
4
- import { ALTERNATE_SCROLL_DISABLE, MOUSE_REPORTING_DISABLE, MOUSE_REPORTING_ENABLE, } from "./terminal-mouse.js";
5
4
  import { warmHighlighter } from "./code-highlight.js";
6
5
  export function createInkAppElement(agent, args, options, onExit) {
7
6
  return (_jsx(App, { agent: agent, args: args, sessionManager: options.sessionManager, switchSession: options.switchSession, createProvider: options.createProvider, registry: options.registry, skillRegistry: options.skillRegistry, planHandlerRef: options.planHandlerRef, approvalHandlerRef: options.approvalHandlerRef, questionController: options.questionController, bashAllowlist: options.bashAllowlist, settingsManager: options.settingsManager, lspService: options.lspService, mcpManager: options.mcpManager, goalStore: options.goalStore, themeMode: options.themeMode, themeOverrides: options.themeOverrides, detectedTheme: options.detectedTheme, onThemeModeChange: options.onThemeModeChange, flushMemory: options.flushMemory, runMemoryCompaction: options.runMemoryCompaction, runMemorySummary: options.runMemorySummary, runMemoryRefresh: options.runMemoryRefresh, bypassEnabled: options.bypassEnabled, updateNotice: options.updateNotice, updateNoticeRefresh: options.updateNoticeRefresh, hookController: options.hookController, onExit: onExit }));
8
7
  }
9
8
  /**
10
- * Best-effort terminal restore for abnormal exits. DECSET mouse modes are
11
- * global terminal state if the process dies without disabling them, the
12
- * user's shell receives \x1b[<35;… garbage on every mouse move. The alt-screen
13
- * and cursor writes are defensive duplicates of Ink's own teardown (idempotent
14
- * when Ink already ran; load-bearing when it didn't).
9
+ * Best-effort terminal restore for abnormal exits. Bubble renders into the
10
+ * primary screen (no alt-screen, no mouse reporting) so the transcript flows
11
+ * into the terminal's native scrollback there is no global mouse/alt-screen
12
+ * state to undo. We only make sure the cursor is visible again, mirroring
13
+ * Ink's own teardown (idempotent when Ink already ran; load-bearing when it
14
+ * didn't).
15
15
  */
16
16
  function restoreTerminal() {
17
17
  if (!process.stdout.isTTY)
18
18
  return;
19
19
  try {
20
- process.stdout.write(ALTERNATE_SCROLL_DISABLE + MOUSE_REPORTING_DISABLE + "\x1b[?1049l\x1b[?25h");
20
+ process.stdout.write("\x1b[?25h");
21
21
  }
22
22
  catch {
23
23
  // stdout may already be destroyed during shutdown
@@ -65,26 +65,18 @@ export async function runTui(agent, args, options = {}) {
65
65
  // reportEventTypes keeps release events out of text input.
66
66
  flags: ["disambiguateEscapeCodes", "reportEventTypes"],
67
67
  },
68
- // The whole point of the Ink migration: render into the 1049 alternate
69
- // screen so streaming repaints never touch the user's shell scrollback.
70
- // Ink degrades this to false automatically when stdout is not a TTY.
71
- alternateScreen: true,
68
+ // Render into the primary screen (NOT the 1049 alternate screen): settled
69
+ // transcript rows are committed once via Ink's <Static> region so they
70
+ // flow into the terminal's native scrollback. That gives flicker-free
71
+ // native scroll + text selection + copy (and tmux copy-mode) for free,
72
+ // and frees the arrow keys for composer history. Only the streaming tail
73
+ // and the composer live in the repainting region at the bottom.
74
+ alternateScreen: false,
72
75
  });
73
- // Keep alternate-scroll disabled so wheel events do not alias keyboard
74
- // arrows. Enable SGR mouse reporting after alt-screen entry so wheel events
75
- // scroll the transcript through a distinct input channel.
76
- if (process.stdout.isTTY) {
77
- process.stdout.write(ALTERNATE_SCROLL_DISABLE + MOUSE_REPORTING_ENABLE);
78
- }
79
76
  try {
80
77
  await instance.waitUntilExit();
81
78
  }
82
79
  finally {
83
- // Reset mouse reporting before anything is printed to the primary screen;
84
- // Ink has already left the alt screen by the time waitUntilExit() resolves.
85
- if (process.stdout.isTTY) {
86
- process.stdout.write(ALTERNATE_SCROLL_DISABLE + MOUSE_REPORTING_DISABLE);
87
- }
88
80
  process.off("uncaughtException", onFatalError);
89
81
  process.off("SIGTERM", onSigterm);
90
82
  }
@@ -2,6 +2,16 @@ export declare const ALTERNATE_SCROLL_ENABLE = "\u001B[?1007h";
2
2
  export declare const ALTERNATE_SCROLL_DISABLE = "\u001B[?1007l";
3
3
  export declare const MOUSE_REPORTING_ENABLE = "\u001B[?1000h\u001B[?1006h";
4
4
  export declare const MOUSE_REPORTING_DISABLE = "\u001B[?1003l\u001B[?1002l\u001B[?1000l\u001B[?1005l\u001B[?1006l\u001B[?1015l";
5
+ interface TerminalMouseEnv {
6
+ BUBBLE_ENABLE_MOUSE?: string;
7
+ BUBBLE_TUI_MOUSE?: string;
8
+ }
9
+ /**
10
+ * Terminal mouse reporting captures drag events before the host terminal can
11
+ * create a native text selection. Keep it opt-in until Bubble owns a full
12
+ * renderer-level selection/copy pipeline.
13
+ */
14
+ export declare function isTerminalMouseReportingEnabled(env?: TerminalMouseEnv): boolean;
5
15
  export type MouseWheelDirection = "up" | "down";
6
16
  export interface TerminalMouseInput {
7
17
  strippedInput: string;
@@ -15,3 +25,4 @@ export declare function transcriptScrollLinesFromMouseInput(mouseInput: Terminal
15
25
  export declare function stripTerminalMouseSequences(input: string): string;
16
26
  export declare function hasTerminalMouseSequence(input: string): boolean;
17
27
  export declare function parseTerminalMouseWheel(input: string): MouseWheelDirection[];
28
+ export {};
@@ -10,6 +10,19 @@ export const MOUSE_REPORTING_ENABLE = "\x1b[?1000h\x1b[?1006h";
10
10
  // Disable every common tracking mode defensively in case a crash or another
11
11
  // renderer left the terminal in a reporting state.
12
12
  export const MOUSE_REPORTING_DISABLE = "\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1005l\x1b[?1006l\x1b[?1015l";
13
+ function envValueEnabled(value) {
14
+ if (!value)
15
+ return false;
16
+ return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
17
+ }
18
+ /**
19
+ * Terminal mouse reporting captures drag events before the host terminal can
20
+ * create a native text selection. Keep it opt-in until Bubble owns a full
21
+ * renderer-level selection/copy pipeline.
22
+ */
23
+ export function isTerminalMouseReportingEnabled(env = process.env) {
24
+ return envValueEnabled(env.BUBBLE_ENABLE_MOUSE) || envValueEnabled(env.BUBBLE_TUI_MOUSE);
25
+ }
13
26
  const ESCAPED_MOUSE_SEQUENCE_RE = /\x1b(?:\[<(\d+);\d+;\d+([mM])|\[M([\s\S])[\s\S]{2})/g;
14
27
  const RAW_SGR_MOUSE_SEQUENCE_RE = /\[?<(\d+);\d+;\d+([mM])/g;
15
28
  const RAW_SGR_MOUSE_INPUT_RE = /^(?:\[?<\d+;\d+;\d+[mM])+$/;
@@ -31,12 +31,22 @@ export function WelcomeBanner({ terminalColumns, tips, updateNotice, cwd, provid
31
31
  }
32
32
  export function formatModelLine({ providerId, modelLabel, thinkingLabel, tips, }) {
33
33
  const parts = [];
34
- if (modelLabel)
35
- parts.push(thinkingLabel ? `${modelLabel} with ${thinkingLabel} effort` : modelLabel);
34
+ // MiniMax thinking is a binary toggle (adaptive thinking), so label it
35
+ // "thinking mode" rather than "<level> effort"; and its provider id
36
+ // ("minimax-anthropic") is redundant with the model name, so omit it.
37
+ const isMiniMax = (providerId || "").toLowerCase().includes("minimax");
38
+ if (modelLabel) {
39
+ if (thinkingLabel && isMiniMax)
40
+ parts.push(modelLabel, "thinking mode");
41
+ else if (thinkingLabel)
42
+ parts.push(`${modelLabel} with ${thinkingLabel} effort`);
43
+ else
44
+ parts.push(modelLabel);
45
+ }
36
46
  const readyTip = tips.find((item) => item.startsWith("Ready with"));
37
47
  if (!modelLabel && readyTip)
38
48
  parts.push(readyTip.replace(/^Ready with\s+/, ""));
39
- if (providerId)
49
+ if (providerId && !isMiniMax)
40
50
  parts.push(providerId);
41
51
  return parts.join(" · ");
42
52
  }
@@ -1,10 +1,13 @@
1
- import { getBuiltinModel } from "../model-catalog.js";
1
+ import { getBuiltinModel, getModelDefaultReasoningLevel } from "../model-catalog.js";
2
2
  import { clampThinkingLevel } from "./thinking-level.js";
3
3
  export function getAvailableThinkingLevels(providerId, modelId) {
4
4
  return getBuiltinModel(providerId, modelId)?.reasoningLevels ?? ["off"];
5
5
  }
6
6
  export function getDefaultThinkingLevel(providerId, modelId) {
7
7
  const levels = getAvailableThinkingLevels(providerId, modelId);
8
+ const explicitDefault = getModelDefaultReasoningLevel(providerId, modelId);
9
+ if (explicitDefault && levels.includes(explicitDefault))
10
+ return explicitDefault;
8
11
  return levels.includes("medium") ? "medium" : levels[0] || "off";
9
12
  }
10
13
  export function normalizeThinkingLevel(level, supportedLevels) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bubblebrain-ai/bubble",
3
- "version": "0.0.25",
3
+ "version": "0.0.26",
4
4
  "description": "A terminal coding agent",
5
5
  "type": "module",
6
6
  "engines": {
@@ -1,25 +0,0 @@
1
- /**
2
- * Transcript scroll-follow policy.
3
- *
4
- * The transcript snaps to the bottom ("follows") while the user is at the
5
- * bottom, and stays put while they read older history. Two events override a
6
- * scrolled-up position and re-engage following:
7
- * - the user sends a message (explicit intent to watch the newest turn)
8
- * - an approval prompt appears (requires the user's attention)
9
- *
10
- * Those renders set `forcePending`, which must survive until the deferred
11
- * scroll actually runs: streaming redraws in the interim recompute the follow
12
- * flag from the (still unscrolled) position and would otherwise cancel the
13
- * snap. A user mouse scroll clears the pending force — their latest gesture
14
- * always wins.
15
- */
16
- export interface TranscriptScrollState {
17
- /** A forceFollow render is waiting for its deferred scroll to execute. */
18
- forcePending: boolean;
19
- /** The viewport was at the bottom when the update was scheduled. */
20
- shouldFollow: boolean;
21
- /** Live follow flag, recomputed from the viewport position on each render. */
22
- following: boolean;
23
- }
24
- export type TranscriptScrollAction = "scroll-bottom" | "sync-position";
25
- export declare function resolveTranscriptScroll(state: TranscriptScrollState): TranscriptScrollAction;
@@ -1,20 +0,0 @@
1
- /**
2
- * Transcript scroll-follow policy.
3
- *
4
- * The transcript snaps to the bottom ("follows") while the user is at the
5
- * bottom, and stays put while they read older history. Two events override a
6
- * scrolled-up position and re-engage following:
7
- * - the user sends a message (explicit intent to watch the newest turn)
8
- * - an approval prompt appears (requires the user's attention)
9
- *
10
- * Those renders set `forcePending`, which must survive until the deferred
11
- * scroll actually runs: streaming redraws in the interim recompute the follow
12
- * flag from the (still unscrolled) position and would otherwise cancel the
13
- * snap. A user mouse scroll clears the pending force — their latest gesture
14
- * always wins.
15
- */
16
- export function resolveTranscriptScroll(state) {
17
- if (state.forcePending)
18
- return "scroll-bottom";
19
- return state.shouldFollow && state.following ? "scroll-bottom" : "sync-position";
20
- }