@bubblebrain-ai/bubble 0.0.25 → 0.0.27

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 +82 -62
  18. package/dist/tui-ink/input-box.d.ts +1 -0
  19. package/dist/tui-ink/input-box.js +23 -17
  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
package/dist/provider.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import OpenAI from "openai";
7
7
  import { appendFileSync } from "node:fs";
8
8
  import { createAnthropicMessagesProvider } from "./provider-anthropic.js";
9
+ import { createArkResponsesProvider } from "./provider-ark-responses.js";
9
10
  import { createOpenAICodexProvider, isOpenAICodexBaseUrl } from "./provider-openai-codex.js";
10
11
  import { createProviderProtocolArtifactFilter } from "./provider-artifacts.js";
11
12
  import { resolveProviderRequestConfig } from "./provider-transform.js";
@@ -76,9 +77,13 @@ export function createUnavailableProvider(message) {
76
77
  return { streamChat, complete };
77
78
  }
78
79
  export function createProviderInstance(options) {
79
- if (resolveProviderProtocol(options) === "anthropic-messages") {
80
+ const protocol = resolveProviderProtocol(options);
81
+ if (protocol === "anthropic-messages") {
80
82
  return createAnthropicMessagesProvider(options);
81
83
  }
84
+ if (protocol === "ark-responses") {
85
+ return createArkResponsesProvider(options);
86
+ }
82
87
  if (isOpenAICodexBaseUrl(options.baseURL)) {
83
88
  return createOpenAICodexProvider({
84
89
  ...options,
@@ -131,28 +136,39 @@ export function createProviderInstance(options) {
131
136
  if (requestConfig.reasoningEffort && requestConfig.reasoningEffort !== "off") {
132
137
  body.reasoning = { enabled: true };
133
138
  }
139
+ const createCompletion = async (requestBody) => {
140
+ try {
141
+ return await client.chat.completions.create(requestBody, {
142
+ signal: chatOptions.abortSignal,
143
+ ...(chatOptions.rateLimitPolicy === "defer" ? { maxRetries: 0 } : {}),
144
+ });
145
+ }
146
+ catch (error) {
147
+ if (error?.status === 429) {
148
+ const retryAfterHeader = error?.headers?.["retry-after"];
149
+ const retryAfterSeconds = Number(retryAfterHeader);
150
+ throw new RateLimitError(error?.message || "Rate limited (429)", {
151
+ status: 429,
152
+ retryAfterMs: Number.isFinite(retryAfterSeconds) ? Math.round(retryAfterSeconds * 1000) : undefined,
153
+ cause: error,
154
+ });
155
+ }
156
+ throw error;
157
+ }
158
+ };
159
+ if (shouldUseNonStreamingToolCalls(options, tools, chatOptions.toolChoice)) {
160
+ body.stream = false;
161
+ delete body.stream_options;
162
+ const response = await createCompletion(body);
163
+ yield* translateOpenAIFullResponse(response);
164
+ yield { type: "done" };
165
+ return;
166
+ }
134
167
  // Rate-limit contract (design §4.5): "defer" disables the SDK's own
135
168
  // retries so the caller is the single 429 backoff layer; either policy
136
169
  // surfaces a final 429 as a typed RateLimitError instead of a string.
137
170
  let stream;
138
- try {
139
- stream = (await client.chat.completions.create(body, {
140
- signal: chatOptions.abortSignal,
141
- ...(chatOptions.rateLimitPolicy === "defer" ? { maxRetries: 0 } : {}),
142
- }));
143
- }
144
- catch (error) {
145
- if (error?.status === 429) {
146
- const retryAfterHeader = error?.headers?.["retry-after"];
147
- const retryAfterSeconds = Number(retryAfterHeader);
148
- throw new RateLimitError(error?.message || "Rate limited (429)", {
149
- status: 429,
150
- retryAfterMs: Number.isFinite(retryAfterSeconds) ? Math.round(retryAfterSeconds * 1000) : undefined,
151
- cause: error,
152
- });
153
- }
154
- throw error;
155
- }
171
+ stream = (await createCompletion(body));
156
172
  yield* translateOpenAIStream(stream, {
157
173
  toolArgsMergeMode: resolveToolArgsMergeMode(options.providerId || "", options.baseURL),
158
174
  reasoningMergeMode: resolveReasoningMergeMode(options.providerId || "", options.baseURL),
@@ -194,6 +210,10 @@ function resolveProviderProtocol(options) {
194
210
  return options.protocol;
195
211
  const providerId = (options.providerId || "").toLowerCase();
196
212
  const baseURL = options.baseURL.toLowerCase();
213
+ if (providerId === "doubao"
214
+ && baseURL.replace(/\/+$/, "") === "https://ark.cn-beijing.volces.com/api/v3") {
215
+ return "ark-responses";
216
+ }
197
217
  if (providerId === "anthropic"
198
218
  || providerId.endsWith("-anthropic")
199
219
  || baseURL.includes("/anthropic")) {
@@ -221,6 +241,12 @@ function shouldRequestStreamUsage(options) {
221
241
  || providerId === "zai-coding-plan"
222
242
  || isMiniMaxOpenAICompatible(options);
223
243
  }
244
+ function shouldUseNonStreamingToolCalls(options, tools, toolChoice) {
245
+ return (options.providerId || "").toLowerCase() === "doubao"
246
+ && !!tools
247
+ && tools.length > 0
248
+ && toolChoice !== "none";
249
+ }
224
250
  export function normalizeToolArgsDetailed(raw) {
225
251
  const s = (raw ?? "").trim();
226
252
  if (!s) {
@@ -321,6 +347,101 @@ function extractBalancedJson(s, start) {
321
347
  }
322
348
  return null;
323
349
  }
350
+ /**
351
+ * Convert a non-streaming OpenAI-compatible chat-completions response into the
352
+ * same chunk protocol used by the streaming adapter. This is used for provider
353
+ * tool-call paths where streamed function arguments are not reliable enough to
354
+ * execute safely.
355
+ */
356
+ export async function* translateOpenAIFullResponse(response) {
357
+ const usageChunk = usageToStreamChunk(response?.usage);
358
+ if (usageChunk)
359
+ yield usageChunk;
360
+ const choice = response?.choices?.[0];
361
+ const finishReason = choice?.finish_reason;
362
+ const truncatedByLength = finishReason === "length";
363
+ const message = choice?.message;
364
+ if (!message)
365
+ return;
366
+ const reasoningDetails = extractReasoningDetailsText(message.reasoning_details);
367
+ const reasoning = reasoningDetails
368
+ ?? (typeof message.reasoning === "string" ? message.reasoning : undefined)
369
+ ?? (typeof message.thinking === "string" ? message.thinking : undefined)
370
+ ?? (typeof message.reasoning_content === "string" ? message.reasoning_content : undefined);
371
+ if (reasoning) {
372
+ yield { type: "reasoning_delta", content: reasoning };
373
+ }
374
+ if (typeof message.content === "string" && message.content) {
375
+ const textFilter = createProviderProtocolArtifactFilter();
376
+ const cleaned = textFilter.push(message.content) + textFilter.flush();
377
+ if (cleaned) {
378
+ yield { type: "text", content: cleaned };
379
+ }
380
+ }
381
+ const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
382
+ for (let index = 0; index < toolCalls.length; index += 1) {
383
+ const toolCall = toolCalls[index];
384
+ const name = typeof toolCall?.function?.name === "string" ? toolCall.function.name : "";
385
+ if (!name)
386
+ continue;
387
+ const id = typeof toolCall?.id === "string" && toolCall.id
388
+ ? toolCall.id
389
+ : `call_${index}`;
390
+ const rawArgs = typeof toolCall?.function?.arguments === "string"
391
+ ? toolCall.function.arguments
392
+ : JSON.stringify(toolCall?.function?.arguments ?? {});
393
+ const normalized = normalizeToolArgsDetailed(rawArgs);
394
+ const corrupt = normalized.corrupt || truncatedByLength;
395
+ debugToolArgs({
396
+ stage: "full-response-tool-call",
397
+ id,
398
+ name,
399
+ entryArgs: rawArgs,
400
+ finalArgs: normalized.args,
401
+ finishReason,
402
+ corrupt,
403
+ });
404
+ yield { type: "tool_call", id, name, arguments: "", isStart: true, isEnd: false };
405
+ if (rawArgs) {
406
+ yield { type: "tool_call", id, name, arguments: rawArgs, isStart: false, isEnd: false };
407
+ }
408
+ yield {
409
+ type: "tool_call",
410
+ id,
411
+ name,
412
+ arguments: "",
413
+ argumentsFull: normalized.args,
414
+ argumentsCorrupt: corrupt || undefined,
415
+ isStart: false,
416
+ isEnd: true,
417
+ };
418
+ }
419
+ }
420
+ function usageToStreamChunk(usage) {
421
+ if (!usage)
422
+ return undefined;
423
+ return {
424
+ type: "usage",
425
+ usage: {
426
+ promptTokens: typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0,
427
+ completionTokens: typeof usage.completion_tokens === "number" ? usage.completion_tokens : 0,
428
+ promptCacheHitTokens: typeof usage.prompt_cache_hit_tokens === "number"
429
+ ? usage.prompt_cache_hit_tokens
430
+ : typeof usage.prompt_tokens_details?.cached_tokens === "number"
431
+ ? usage.prompt_tokens_details.cached_tokens
432
+ : undefined,
433
+ promptCacheMissTokens: typeof usage.prompt_cache_miss_tokens === "number"
434
+ ? usage.prompt_cache_miss_tokens
435
+ : typeof usage.prompt_tokens_details?.cached_tokens === "number" && typeof usage.prompt_tokens === "number"
436
+ ? Math.max(0, usage.prompt_tokens - usage.prompt_tokens_details.cached_tokens)
437
+ : undefined,
438
+ reasoningTokens: typeof usage.completion_tokens_details?.reasoning_tokens === "number"
439
+ ? usage.completion_tokens_details.reasoning_tokens
440
+ : undefined,
441
+ totalTokens: typeof usage.total_tokens === "number" ? usage.total_tokens : undefined,
442
+ },
443
+ };
444
+ }
324
445
  /**
325
446
  * Convert an OpenAI-compatible chat-completions stream into our internal StreamChunk events.
326
447
  *
@@ -359,14 +480,15 @@ export async function* translateOpenAIStream(stream, options = {}) {
359
480
  }
360
481
  }
361
482
  const normalized = normalizeToolArgsDetailed(entry.args);
362
- debugToolArgs({ stage: "flush-end", id: entry.id, name: entry.name, entryArgs: entry.args, finalArgs: normalized.args, corrupt: normalized.corrupt });
483
+ const corrupt = normalized.corrupt || !!entry.corrupt;
484
+ debugToolArgs({ stage: "flush-end", id: entry.id, name: entry.name, entryArgs: entry.args, finalArgs: normalized.args, corrupt });
363
485
  yield {
364
486
  type: "tool_call",
365
487
  id: entry.id,
366
488
  name: entry.name,
367
489
  arguments: "",
368
490
  argumentsFull: normalized.args,
369
- argumentsCorrupt: normalized.corrupt || undefined,
491
+ argumentsCorrupt: corrupt || undefined,
370
492
  isStart: false,
371
493
  isEnd: true,
372
494
  };
@@ -526,7 +648,13 @@ export async function* translateOpenAIStream(stream, options = {}) {
526
648
  }
527
649
  }
528
650
  }
529
- if (finishReason === "tool_calls") {
651
+ if (finishReason === "length") {
652
+ for (const entry of toolCalls.values()) {
653
+ entry.corrupt = true;
654
+ }
655
+ yield* flushToolCalls();
656
+ }
657
+ else if (finishReason === "tool_calls") {
530
658
  yield* flushToolCalls();
531
659
  }
532
660
  }
@@ -8,6 +8,7 @@ import { getAvailableThinkingLevels, getDefaultThinkingLevel, normalizeThinkingL
8
8
  import { SessionManager } from "../session.js";
9
9
  import { buildSystemPrompt } from "../system-prompt.js";
10
10
  import { normalizeSingleLine } from "../text-display.js";
11
+ import { copyToClipboard } from "../clipboard.js";
11
12
  import { formatRelativeTime } from "../tui/recent-activity.js";
12
13
  import { HOOK_EVENT_NAMES, isHookEventName } from "../hooks/index.js";
13
14
  import { isThinkingLevel } from "../variant/thinking-level.js";
@@ -402,6 +403,27 @@ const builtinSlashCommandEntries = [
402
403
  ctx.clearMessages();
403
404
  },
404
405
  },
406
+ {
407
+ name: "copy",
408
+ description: "Copy the last assistant message to the system clipboard",
409
+ async handler(args, ctx) {
410
+ const lastAssistant = [...ctx.agent.messages]
411
+ .reverse()
412
+ .find((m) => m.role === "assistant" && typeof m.content === "string" && m.content.trim().length > 0);
413
+ if (!lastAssistant || typeof lastAssistant.content !== "string") {
414
+ return "No assistant message to copy yet.";
415
+ }
416
+ const text = lastAssistant.content;
417
+ try {
418
+ await copyToClipboard(text);
419
+ }
420
+ catch (err) {
421
+ return `Failed to copy to clipboard: ${err?.message || String(err)}`;
422
+ }
423
+ const chars = text.length;
424
+ return `Copied last assistant message to clipboard (${chars} character${chars === 1 ? "" : "s"}).`;
425
+ },
426
+ },
405
427
  {
406
428
  name: "rewind",
407
429
  description: "Rewind conversation and/or file edits to before an earlier message. Usage: /rewind [n] [--code|--chat]",
@@ -13,50 +13,33 @@ export function createTodoTool(store) {
13
13
 
14
14
  ## When to use
15
15
 
16
- Use this tool proactively when any of these apply:
17
- 1. Complex multi-step work — 3 or more distinct steps or file locations
18
- 2. Non-trivial tasks requiring planning or coordination across multiple operations
19
- 3. The user explicitly asks for a todo list
20
- 4. The user provides a list of things to do (numbered, comma-separated, bulleted)
21
- 5. New instructions arrive mid-session — capture them as todos before starting
22
- 6. Starting work on a task — mark it in_progress BEFORE beginning. Only one item may be in_progress at a time
23
- 7. Finishing a task — mark it completed immediately, don't batch completions
16
+ Default to just doing the work. Reach for a list only when actively tracking progress would genuinely help you or the user follow it — never to pad simple work with filler steps or to state the obvious. When in doubt, skip the list and do the task; a list you never meaningfully update is just noise.
24
17
 
25
- ## When NOT to use
18
+ A list earns its place when:
19
+ - The task is non-trivial and spans many actions across several areas of the codebase
20
+ - There are non-obvious phases or dependencies you must hold in mind to avoid losing track (a plain read → edit → test sequence does not count)
21
+ - The work is ambiguous and benefits from outlining the goals up front
22
+ - The user asked for several distinct things in one prompt, or gave a numbered/bulleted list
23
+ - The user explicitly asked for a todo list (aka TODOs)
24
+ - You discover extra steps mid-task and intend to finish them before yielding
26
25
 
27
- Skip this tool when:
28
- 1. There is a single, straightforward task
29
- 2. The task is trivial and tracking provides no organizational benefit
30
- 3. The work can be completed in fewer than 3 trivial steps
31
- 4. The request is purely conversational or informational
26
+ ## Quality bar
32
27
 
33
- If there is only one trivial task, just do it don't create a todo first.
28
+ If you do make a list, make a good one: meaningful, logically ordered steps that are easy to verify as you go.
34
29
 
35
- ## Examples
30
+ Good — distinct, verifiable steps for genuinely multi-part work:
31
+ 1. Add CSS variables for the color palette
32
+ 2. Add the toggle with localStorage state
33
+ 3. Refactor components to use the variables
34
+ 4. Verify every view for readability
36
35
 
37
- <example>
38
- User: Add a dark mode toggle to the settings page, then run tests and build.
39
- Assistant: *creates a 5-item todo: toggle UI, theme state, CSS tokens, update components, run tests + build*
40
- <reasoning>Multiple distinct steps across UI, state, styles, and verification. User explicitly asked for tests + build.</reasoning>
41
- </example>
36
+ Good — scope a search uncovers makes the list worth it:
37
+ "Rename getCwd across the project" grep finds 15 call sites in 8 files → one item per file so none are missed.
42
38
 
43
- <example>
44
- User: Rename getCwd to getCurrentWorkingDirectory across the project.
45
- Assistant: *greps, finds 15 call sites across 8 files, creates a per-file todo list*
46
- <reasoning>Scope discovered via grep shows many locations; a todo ensures each file is tracked and none are missed.</reasoning>
47
- </example>
39
+ Bad — padding a task you could just do; do NOT create a list for this:
40
+ "Fix the typo in the README title" → 1. Find typo 2. Open file 3. Fix it 4. Save. That is one edit — just make it.
48
41
 
49
- <example>
50
- User: How do I print "Hello World" in Python?
51
- Assistant: *answers in one sentence with a snippet — no todo*
52
- <reasoning>Informational, one-step, no tracking benefit.</reasoning>
53
- </example>
54
-
55
- <example>
56
- User: Add a comment to calculateTotal explaining what it does.
57
- Assistant: *calls edit directly — no todo*
58
- <reasoning>Single, localized change in one file.</reasoning>
59
- </example>
42
+ Bad — vague, unverifiable filler: "Make it work", "Improve the styling", "Clean things up".
60
43
 
61
44
  ## Task states
62
45
 
@@ -73,7 +56,8 @@ Each item needs:
73
56
  - Update status in real time; mark completed IMMEDIATELY on finishing.
74
57
  - Never mark completed if tests are failing, implementation is partial, errors are unresolved, or needed files are missing — keep as in_progress.
75
58
  - When blocked, add a new task describing what must be resolved.
76
- - Remove items that are no longer relevant; don't leave stale entries.`,
59
+ - Remove items that are no longer relevant; don't leave stale entries.
60
+ - Do not re-send the list when nothing meaningful has changed since the last call; update only after real progress.`,
77
61
  parameters: {
78
62
  type: "object",
79
63
  properties: {
@@ -6,7 +6,7 @@ import { isHiddenToolMetadata } from "../agent/discovery-barrier.js";
6
6
  import { SessionManager } from "../session.js";
7
7
  import { registry as slashRegistry } from "../slash-commands/index.js";
8
8
  import { UserConfig, maskKey } from "../config.js";
9
- import { createPastedContentMarker, InputBox, isCtrlCInput, shouldCollapsePastedContent, } from "./input-box.js";
9
+ import { InputBox, isCtrlCInput, } from "./input-box.js";
10
10
  import { MessageList } from "./message-list.js";
11
11
  import { appendTextPart, appendToolPart, compactDisplayMessages, contentFromParts, latestCompactionSummary, moveStatusMessageToEnd, nextDisplayMessageKey, setUserInputStatus, snapshotDisplayParts, stripInterruptedAssistantMarker, toolCallsFromParts, } from "./display-history.js";
12
12
  import { AgentRunInputQueue } from "../agent/input-controller.js";
@@ -32,11 +32,8 @@ import { collectFeedback } from "../feedback/collect.js";
32
32
  import { isKeyReleaseEvent } from "./key-events.js";
33
33
  import { errorMessage, formatModelSwitchError, switchAgentModel } from "../tui/model-switch.js";
34
34
  import { formatImageUserDisplayText, nextImageDisplayLabelStart } from "../tui/image-display.js";
35
- import { sanitizeTerminalMouseInput, transcriptScrollLinesFromMouseInput } from "./terminal-mouse.js";
36
- import { transcriptPageScrollDirection } from "./transcript-input.js";
37
35
  import { decideStartingSubmitFingerprint, submitPayloadFingerprint } from "./submit-dedupe.js";
38
36
  import { isQueuedInputForCurrentSession, queuedAndPendingDisplayKeys, } from "./input-queue.js";
39
- import { TranscriptViewport } from "./transcript-viewport.js";
40
37
  import { SessionPicker } from "./session-picker.js";
41
38
  import { sessionDisplayName } from "../tui/session-display.js";
42
39
  import { parseGoalCommand } from "../goal/command.js";
@@ -87,9 +84,7 @@ function reconstructDisplayMessages(agentMessages) {
87
84
  result.push({
88
85
  key: nextDisplayMessageKey("user"),
89
86
  role: "user",
90
- content: typeof m.content === "string"
91
- ? (shouldCollapsePastedContent(m.content) ? createPastedContentMarker(m.content) : m.content)
92
- : "(multimedia)",
87
+ content: typeof m.content === "string" ? m.content : "(multimedia)",
93
88
  });
94
89
  }
95
90
  else if (m.role === "assistant") {
@@ -310,7 +305,11 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
310
305
  const activeAbortRef = useRef(null);
311
306
  const exitRequestedRef = useRef(false);
312
307
  const sessionStartRef = useRef(Date.now());
313
- const viewportRef = useRef(null);
308
+ // Bumped whenever the settled transcript is rebuilt non-monotonically
309
+ // (/clear, /compact, /rewind, session switch). Used as the <Static> key in
310
+ // MessageList so Ink discards its already-printed rows and re-prints the
311
+ // rebuilt list onto a freshly-cleared screen instead of appending duplicates.
312
+ const [staticGeneration, setStaticGeneration] = useState(0);
314
313
  // Steer/queue while the agent runs:
315
314
  // Enter steers the current run via the agent's input controller; Tab (or an
316
315
  // ineligible input) queues for the next turn. Both render placeholder user
@@ -492,14 +491,6 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
492
491
  syncFirstPending();
493
492
  return unsubscribe;
494
493
  }, [questionController]);
495
- // An approval or question demands the user's attention: re-engage
496
- // bottom-follow even if they had scrolled up (second force trigger
497
- // documented in transcript-scroll.ts).
498
- useEffect(() => {
499
- if (pendingApproval || pendingQuestion) {
500
- viewportRef.current?.forceScrollToBottom();
501
- }
502
- }, [pendingApproval, pendingQuestion]);
503
494
  const rebuildSystemPrompt = useCallback((overrides) => {
504
495
  const modelParts = agent.model.includes(":")
505
496
  ? agent.model.split(":")
@@ -523,23 +514,11 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
523
514
  requestExit();
524
515
  return;
525
516
  }
526
- const overlayActive = !!(pickerMode || pendingPlan || pendingApproval || pendingQuestion || pendingFeedback || statsPanel);
527
- const mouseInput = sanitizeTerminalMouseInput(input);
528
- if (mouseInput.wheelDirections.length > 0) {
529
- for (const lines of transcriptScrollLinesFromMouseInput(mouseInput, { overlayActive })) {
530
- viewportRef.current?.scrollBy(lines);
531
- }
532
- }
533
- if (mouseInput.hasMouse) {
534
- if (!mouseInput.strippedInput)
535
- return;
536
- input = mouseInput.strippedInput;
537
- }
538
- const pageScrollDirection = transcriptPageScrollDirection(key, { overlayActive });
539
- if (pageScrollDirection) {
540
- viewportRef.current?.scrollPage(pageScrollDirection);
541
- return;
542
- }
517
+ // Scrolling is the terminal's job now: settled rows live in native
518
+ // scrollback (committed via <Static>), so the wheel, tmux copy-mode, and
519
+ // PageUp/PageDown scroll the real terminal with no app involvement and no
520
+ // flicker. Bubble no longer intercepts mouse reports or page keys, which
521
+ // also frees the arrow keys entirely for composer history.
543
522
  if (pendingPlan || pendingApproval || pendingQuestion || pendingFeedback || statsPanel)
544
523
  return;
545
524
  if (key.ctrl && input.toLowerCase() === "p" && !pickerMode && !activeAbortRef.current) {
@@ -608,9 +587,39 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
608
587
  const updateDisplayMessages = useCallback((updater) => {
609
588
  setMessages((prev) => compactDisplayMessages(updater(prev).map(withMessageKey)));
610
589
  }, []);
590
+ // Non-append transcript rebuilds (/clear, /compact, /rewind, session switch)
591
+ // replace the settled list rather than extending it. The rows already
592
+ // committed to the terminal's native scrollback (via <Static>) cannot be
593
+ // un-printed, so we wipe the screen + scrollback and bump the Static key:
594
+ // Ink then re-prints the rebuilt list fresh instead of appending duplicates.
595
+ const resetTranscript = useCallback((updater) => {
596
+ if (process.stdout.isTTY) {
597
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
598
+ }
599
+ setStaticGeneration((generation) => generation + 1);
600
+ updateDisplayMessages(updater);
601
+ }, [updateDisplayMessages]);
611
602
  const addMessage = useCallback((role, content) => {
612
603
  updateDisplayMessages((prev) => [...prev, withMessageKey({ role, content })]);
613
604
  }, [updateDisplayMessages]);
605
+ // Reflow on terminal resize. ink 7.0.3 only clears its dynamic frame when the
606
+ // terminal NARROWS (see its resized() handler); on widen / tmux split the
607
+ // stale frame is left behind and the working trace duplicates into
608
+ // scrollback. Dedicated scrollback renderers (pi-tui) handle this by doing a
609
+ // full clear + re-print on ANY width/height change so content rewraps
610
+ // cleanly — resetTranscript does exactly that here. Debounced so a drag
611
+ // coalesces into one reflow instead of flashing on every resize event.
612
+ const didMountSizeRef = useRef(false);
613
+ useEffect(() => {
614
+ if (!didMountSizeRef.current) {
615
+ didMountSizeRef.current = true;
616
+ return;
617
+ }
618
+ const timer = setTimeout(() => {
619
+ resetTranscript((prev) => prev);
620
+ }, 80);
621
+ return () => clearTimeout(timer);
622
+ }, [terminalColumns, terminalRows, resetTranscript]);
614
623
  useEffect(() => {
615
624
  if (!updateNoticeRefresh)
616
625
  return;
@@ -629,16 +638,15 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
629
638
  };
630
639
  }, [addMessage, updateNoticeRefresh]);
631
640
  const clearMessages = useCallback(() => {
632
- // The transcript lives entirely in React state now (alt-screen viewport,
633
- // no terminal scrollback) clearing state clears the screen. Writing
634
- // \x1b[2J here would just flash a black frame before the next paint.
635
- setMessages([]);
636
- }, []);
641
+ // Settled rows live in the terminal's native scrollback now (committed via
642
+ // <Static>), so clearing React state is not enough — resetTranscript wipes
643
+ // the screen + scrollback and re-prints the (now empty) transcript.
644
+ resetTranscript(() => []);
645
+ }, [resetTranscript]);
637
646
  // Render a placeholder user row for input waiting to enter the run.
638
647
  const addStatusUserMessage = useCallback((content, status) => {
639
648
  const key = nextDisplayMessageKey("user");
640
649
  updateDisplayMessages((prev) => [...prev, { key, role: "user", content, inputStatus: status }]);
641
- viewportRef.current?.forceScrollToBottom();
642
650
  return key;
643
651
  }, [updateDisplayMessages]);
644
652
  const prepareSubmitDisplay = useCallback((payload) => {
@@ -774,13 +782,12 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
774
782
  clearComposerDraft();
775
783
  setSessionManager(result.manager);
776
784
  setTodos(agent.getTodos());
777
- updateDisplayMessages(() => [
785
+ resetTranscript(() => [
778
786
  ...reconstructDisplayMessages(agent.messages).filter((message) => !queuedDisplayKeys.has(message.key ?? "")),
779
787
  withMessageKey({ role: "assistant", content: `⤷ Resumed session: ${sessionDisplayName(result.manager)}` }),
780
788
  ]);
781
- viewportRef.current?.forceScrollToBottom();
782
789
  closePicker();
783
- }, [addMessage, agent, clearComposerDraft, closePicker, setStartingSubmit, switchSession, updateDisplayMessages]);
790
+ }, [addMessage, agent, clearComposerDraft, closePicker, setStartingSubmit, switchSession, resetTranscript]);
784
791
  const handleModelSelect = useCallback((model, selectedThinkingLevel) => {
785
792
  const run = async () => {
786
793
  const nextThinkingLevel = await switchAgentModel({
@@ -795,8 +802,11 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
795
802
  setThinkingLevel,
796
803
  sessionManager,
797
804
  });
805
+ // MiniMax thinking is a binary toggle (adaptive thinking), not a graded
806
+ // effort — show it as "thinking mode" rather than "medium effort".
807
+ const isMiniMaxModel = model.toLowerCase().includes("minimax");
798
808
  const effortNote = nextThinkingLevel && nextThinkingLevel !== "off"
799
- ? ` with ${nextThinkingLevel} effort`
809
+ ? (isMiniMaxModel ? " in thinking mode" : ` with ${nextThinkingLevel} effort`)
800
810
  : "";
801
811
  addMessage("assistant", `Model switched to ${displayModel(model)}${effortNote}.`);
802
812
  closePicker();
@@ -1006,9 +1016,8 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1006
1016
  ...prev,
1007
1017
  withMessageKey({ role: "user", content: displayContent }),
1008
1018
  ]);
1009
- // Sending is an explicit "watch the newest turn" intent: snap the
1010
- // transcript back to the bottom even if the user had scrolled up.
1011
- viewportRef.current?.forceScrollToBottom();
1019
+ // The new user row commits to native scrollback; the terminal keeps
1020
+ // the prompt in view, so there is no app-side "snap to bottom" to do.
1012
1021
  }
1013
1022
  setIsRunning(true);
1014
1023
  runStartRef.current = Date.now();
@@ -1214,11 +1223,22 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1214
1223
  // boundary. Move it after the just-finished tool/assistant
1215
1224
  // turn instead of clearing the badge in its original
1216
1225
  // placeholder position.
1226
+ //
1227
+ // This move pulls the pending-steer block out of the live
1228
+ // (dynamic) region and re-commits it elsewhere in <Static>, so
1229
+ // the live frame SHRINKS and the block's old rows are vacated
1230
+ // with nothing taking their place. Ink's in-place redraw leaves
1231
+ // those rows behind under tmux (its cursor-up clear can't reach
1232
+ // a frame that has scrolled), which is the blank gap users see
1233
+ // after steering. A full reprint (resetTranscript) rewrites the
1234
+ // transcript cleanly with no leftover — the same fix the resize
1235
+ // path uses. Unlike a turn settling (content moves in place),
1236
+ // this reorder is rare, so the reprint cost is acceptable.
1217
1237
  const steer = pendingSteersRef.current.get(event.id);
1218
1238
  if (steer) {
1219
1239
  pendingSteersRef.current.delete(event.id);
1220
1240
  setPendingSteerCount(pendingSteersRef.current.size);
1221
- updateDisplayMessages((prev) => moveStatusMessageToEnd(prev, steer.displayKey));
1241
+ resetTranscript((prev) => moveStatusMessageToEnd(prev, steer.displayKey));
1222
1242
  }
1223
1243
  break;
1224
1244
  }
@@ -1267,7 +1287,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1267
1287
  commitAssistantMessage();
1268
1288
  if (err instanceof AgentAbortError || err?.name === "AbortError") {
1269
1289
  runCancelled = true;
1270
- updateDisplayMessages(() => reconstructDisplayMessages(agent.messages));
1290
+ resetTranscript(() => reconstructDisplayMessages(agent.messages));
1271
1291
  }
1272
1292
  else {
1273
1293
  runErrored = true;
@@ -1535,7 +1555,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1535
1555
  // card; otherwise the pre-compaction history would keep rendering.
1536
1556
  if (result.startsWith("✓ Compaction complete")) {
1537
1557
  const summary = latestCompactionSummary(agent.messages);
1538
- updateDisplayMessages(() => [
1558
+ resetTranscript(() => [
1539
1559
  ...reconstructDisplayMessages(agent.messages),
1540
1560
  {
1541
1561
  role: "assistant",
@@ -1548,7 +1568,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1548
1568
  else if (result.startsWith("⏪")) {
1549
1569
  // /rewind truncated agent.messages — rebuild the transcript from
1550
1570
  // the rewound state before appending the summary.
1551
- updateDisplayMessages(() => [
1571
+ resetTranscript(() => [
1552
1572
  ...reconstructDisplayMessages(agent.messages),
1553
1573
  { role: "assistant", content: result },
1554
1574
  ]);
@@ -1638,23 +1658,23 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1638
1658
  return null;
1639
1659
  })()
1640
1660
  : null;
1641
- const showThinkingLabel = getAvailableThinkingLevels(agent.providerId, agent.apiModel).length > 2
1642
- && thinkingLevel
1643
- && thinkingLevel !== "off";
1661
+ // MiniMax has only off/on, so the graded ">2 levels" gate would hide its label;
1662
+ // surface it too (rendered as "thinking mode" by formatModelLine).
1663
+ const isMiniMaxProvider = (agent.providerId || "").toLowerCase().includes("minimax");
1664
+ const showThinkingLabel = Boolean(thinkingLevel)
1665
+ && thinkingLevel !== "off"
1666
+ && (isMiniMaxProvider || getAvailableThinkingLevels(agent.providerId, agent.apiModel).length > 2);
1644
1667
  const welcomeBannerNode = showWelcome ? (_jsx(WelcomeBanner, { terminalColumns: terminalColumns, tips: buildTips(agent, safeRegistry), updateNotice: currentUpdateNotice, cwd: friendlyCwd(args.cwd), providerId: agent.providerId || safeRegistry.getDefault()?.id, modelLabel: agent.model ? displayModel(agent.model) : undefined, thinkingLabel: showThinkingLabel ? thinkingLevel : undefined })) : null;
1645
1668
  const commandPaletteItems = useMemo(() => buildCommandPaletteItems(safeSkillRegistry), [safeSkillRegistry]);
1646
1669
  const mcpReconnectItems = useMemo(() => buildMcpReconnectItems(mcpManager), [mcpManager]);
1647
- // One row shorter than the terminal on purpose. A frame that exactly fills
1648
- // the screen makes Ink omit the trailing newline ("fullscreen" mode), and
1649
- // Ink's cursor-only repositioning (buildReturnToBottom) miscalculates by one
1650
- // row for such frames the composer cursor lands one row above the prompt
1651
- // after any cursor-only update (e.g. restoreLastOutput following an external
1652
- // stdout write). Keeping every frame below viewport height keeps all of
1653
- // Ink's cursor paths on the consistent trailing-newline math.
1654
- const frameRows = Math.max(4, terminalRows - 1);
1670
+ // No fixed-height frame: settled rows flow into the terminal's native
1671
+ // scrollback via <Static>, and only the dynamic bottom stack (streaming
1672
+ // tail, pickers, composer, footer) occupies the live region. Letting it size
1673
+ // to its content keeps the composer pinned just below the latest output the
1674
+ // way ordinary shell programs do.
1655
1675
  const sidebarWidth = sidebarVisible ? Math.min(42, Math.max(28, Math.floor(terminalColumns * 0.34))) : 0;
1656
1676
  const mainWidth = Math.max(40, terminalColumns - sidebarWidth);
1657
- return (_jsx(ThemeProvider, { value: palette, children: _jsxs(Box, { flexDirection: "row", width: terminalColumns, height: frameRows, backgroundColor: palette.background, children: [_jsxs(Box, { flexDirection: "column", width: mainWidth, height: frameRows, backgroundColor: palette.background, children: [_jsx(TranscriptViewport, { ref: viewportRef, children: _jsx(Box, { flexDirection: "column", paddingX: 1, paddingTop: 1, flexShrink: 0, children: _jsx(MessageList, { messages: messages, streamingContent: streamingContent, streamingReasoning: streamingReasoning, streamingTools: streamingTools, streamingParts: streamingParts, terminalColumns: mainWidth, showThinking: showThinking, expandedToolOutput: expandedToolOutput, verboseTrace: verboseTrace, pendingApproval: approvalHint, nowTick: nowTick, welcomeBanner: welcomeBannerNode }) }) }), pickerMode === "model" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ModelPicker, { registry: safeRegistry, current: agent.model, currentThinkingLevel: thinkingLevel, recent: userConfig.getRecentModels(), onSelect: handleModelSelect, onCancel: closePicker }) })), pickerMode === "provider" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1677
+ return (_jsx(ThemeProvider, { value: palette, children: _jsxs(Box, { flexDirection: "row", width: terminalColumns, backgroundColor: palette.background, children: [_jsxs(Box, { flexDirection: "column", width: mainWidth, backgroundColor: palette.background, children: [_jsx(MessageList, { messages: messages, streamingContent: streamingContent, streamingReasoning: streamingReasoning, streamingTools: streamingTools, streamingParts: streamingParts, terminalColumns: mainWidth, showThinking: showThinking, expandedToolOutput: expandedToolOutput, verboseTrace: verboseTrace, pendingApproval: approvalHint, nowTick: nowTick, welcomeBanner: welcomeBannerNode, staticGeneration: staticGeneration, paddingX: 1, maxStreamRows: Math.max(6, terminalRows - 10) }), pickerMode === "model" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ModelPicker, { registry: safeRegistry, current: agent.model, currentThinkingLevel: thinkingLevel, recent: userConfig.getRecentModels(), onSelect: handleModelSelect, onCancel: closePicker }) })), pickerMode === "provider" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1658
1678
  .filter((p) => isUserVisibleProvider(p.id))
1659
1679
  .map((p) => {
1660
1680
  const configured = safeRegistry.getConfigured().find((item) => item.id === p.id);
@@ -45,6 +45,7 @@ export declare function isCtrlCInput(input: string, key: {
45
45
  ctrl?: boolean;
46
46
  }): boolean;
47
47
  export declare function shouldUseLineComposerFrame(_background: string): boolean;
48
+ export declare function composerSurfaceBackground(lineFrame: boolean, background: string, inputBg: string): string;
48
49
  export declare function shouldUseHardwareComposerCursor(env?: Record<string, string | undefined>): boolean;
49
50
  export declare function composerVerticalArrowDirection(key: {
50
51
  upArrow?: boolean;