@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
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: {
@@ -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";
@@ -310,7 +307,11 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
310
307
  const activeAbortRef = useRef(null);
311
308
  const exitRequestedRef = useRef(false);
312
309
  const sessionStartRef = useRef(Date.now());
313
- const viewportRef = useRef(null);
310
+ // Bumped whenever the settled transcript is rebuilt non-monotonically
311
+ // (/clear, /compact, /rewind, session switch). Used as the <Static> key in
312
+ // MessageList so Ink discards its already-printed rows and re-prints the
313
+ // rebuilt list onto a freshly-cleared screen instead of appending duplicates.
314
+ const [staticGeneration, setStaticGeneration] = useState(0);
314
315
  // Steer/queue while the agent runs:
315
316
  // Enter steers the current run via the agent's input controller; Tab (or an
316
317
  // ineligible input) queues for the next turn. Both render placeholder user
@@ -492,14 +493,6 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
492
493
  syncFirstPending();
493
494
  return unsubscribe;
494
495
  }, [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
496
  const rebuildSystemPrompt = useCallback((overrides) => {
504
497
  const modelParts = agent.model.includes(":")
505
498
  ? agent.model.split(":")
@@ -523,23 +516,11 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
523
516
  requestExit();
524
517
  return;
525
518
  }
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
- }
519
+ // Scrolling is the terminal's job now: settled rows live in native
520
+ // scrollback (committed via <Static>), so the wheel, tmux copy-mode, and
521
+ // PageUp/PageDown scroll the real terminal with no app involvement and no
522
+ // flicker. Bubble no longer intercepts mouse reports or page keys, which
523
+ // also frees the arrow keys entirely for composer history.
543
524
  if (pendingPlan || pendingApproval || pendingQuestion || pendingFeedback || statsPanel)
544
525
  return;
545
526
  if (key.ctrl && input.toLowerCase() === "p" && !pickerMode && !activeAbortRef.current) {
@@ -608,9 +589,39 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
608
589
  const updateDisplayMessages = useCallback((updater) => {
609
590
  setMessages((prev) => compactDisplayMessages(updater(prev).map(withMessageKey)));
610
591
  }, []);
592
+ // Non-append transcript rebuilds (/clear, /compact, /rewind, session switch)
593
+ // replace the settled list rather than extending it. The rows already
594
+ // committed to the terminal's native scrollback (via <Static>) cannot be
595
+ // un-printed, so we wipe the screen + scrollback and bump the Static key:
596
+ // Ink then re-prints the rebuilt list fresh instead of appending duplicates.
597
+ const resetTranscript = useCallback((updater) => {
598
+ if (process.stdout.isTTY) {
599
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
600
+ }
601
+ setStaticGeneration((generation) => generation + 1);
602
+ updateDisplayMessages(updater);
603
+ }, [updateDisplayMessages]);
611
604
  const addMessage = useCallback((role, content) => {
612
605
  updateDisplayMessages((prev) => [...prev, withMessageKey({ role, content })]);
613
606
  }, [updateDisplayMessages]);
607
+ // Reflow on terminal resize. ink 7.0.3 only clears its dynamic frame when the
608
+ // terminal NARROWS (see its resized() handler); on widen / tmux split the
609
+ // stale frame is left behind and the working trace duplicates into
610
+ // scrollback. Dedicated scrollback renderers (pi-tui) handle this by doing a
611
+ // full clear + re-print on ANY width/height change so content rewraps
612
+ // cleanly — resetTranscript does exactly that here. Debounced so a drag
613
+ // coalesces into one reflow instead of flashing on every resize event.
614
+ const didMountSizeRef = useRef(false);
615
+ useEffect(() => {
616
+ if (!didMountSizeRef.current) {
617
+ didMountSizeRef.current = true;
618
+ return;
619
+ }
620
+ const timer = setTimeout(() => {
621
+ resetTranscript((prev) => prev);
622
+ }, 80);
623
+ return () => clearTimeout(timer);
624
+ }, [terminalColumns, terminalRows, resetTranscript]);
614
625
  useEffect(() => {
615
626
  if (!updateNoticeRefresh)
616
627
  return;
@@ -629,16 +640,15 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
629
640
  };
630
641
  }, [addMessage, updateNoticeRefresh]);
631
642
  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
- }, []);
643
+ // Settled rows live in the terminal's native scrollback now (committed via
644
+ // <Static>), so clearing React state is not enough — resetTranscript wipes
645
+ // the screen + scrollback and re-prints the (now empty) transcript.
646
+ resetTranscript(() => []);
647
+ }, [resetTranscript]);
637
648
  // Render a placeholder user row for input waiting to enter the run.
638
649
  const addStatusUserMessage = useCallback((content, status) => {
639
650
  const key = nextDisplayMessageKey("user");
640
651
  updateDisplayMessages((prev) => [...prev, { key, role: "user", content, inputStatus: status }]);
641
- viewportRef.current?.forceScrollToBottom();
642
652
  return key;
643
653
  }, [updateDisplayMessages]);
644
654
  const prepareSubmitDisplay = useCallback((payload) => {
@@ -774,13 +784,12 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
774
784
  clearComposerDraft();
775
785
  setSessionManager(result.manager);
776
786
  setTodos(agent.getTodos());
777
- updateDisplayMessages(() => [
787
+ resetTranscript(() => [
778
788
  ...reconstructDisplayMessages(agent.messages).filter((message) => !queuedDisplayKeys.has(message.key ?? "")),
779
789
  withMessageKey({ role: "assistant", content: `⤷ Resumed session: ${sessionDisplayName(result.manager)}` }),
780
790
  ]);
781
- viewportRef.current?.forceScrollToBottom();
782
791
  closePicker();
783
- }, [addMessage, agent, clearComposerDraft, closePicker, setStartingSubmit, switchSession, updateDisplayMessages]);
792
+ }, [addMessage, agent, clearComposerDraft, closePicker, setStartingSubmit, switchSession, resetTranscript]);
784
793
  const handleModelSelect = useCallback((model, selectedThinkingLevel) => {
785
794
  const run = async () => {
786
795
  const nextThinkingLevel = await switchAgentModel({
@@ -795,8 +804,11 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
795
804
  setThinkingLevel,
796
805
  sessionManager,
797
806
  });
807
+ // MiniMax thinking is a binary toggle (adaptive thinking), not a graded
808
+ // effort — show it as "thinking mode" rather than "medium effort".
809
+ const isMiniMaxModel = model.toLowerCase().includes("minimax");
798
810
  const effortNote = nextThinkingLevel && nextThinkingLevel !== "off"
799
- ? ` with ${nextThinkingLevel} effort`
811
+ ? (isMiniMaxModel ? " in thinking mode" : ` with ${nextThinkingLevel} effort`)
800
812
  : "";
801
813
  addMessage("assistant", `Model switched to ${displayModel(model)}${effortNote}.`);
802
814
  closePicker();
@@ -1006,9 +1018,8 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1006
1018
  ...prev,
1007
1019
  withMessageKey({ role: "user", content: displayContent }),
1008
1020
  ]);
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();
1021
+ // The new user row commits to native scrollback; the terminal keeps
1022
+ // the prompt in view, so there is no app-side "snap to bottom" to do.
1012
1023
  }
1013
1024
  setIsRunning(true);
1014
1025
  runStartRef.current = Date.now();
@@ -1214,11 +1225,22 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1214
1225
  // boundary. Move it after the just-finished tool/assistant
1215
1226
  // turn instead of clearing the badge in its original
1216
1227
  // placeholder position.
1228
+ //
1229
+ // This move pulls the pending-steer block out of the live
1230
+ // (dynamic) region and re-commits it elsewhere in <Static>, so
1231
+ // the live frame SHRINKS and the block's old rows are vacated
1232
+ // with nothing taking their place. Ink's in-place redraw leaves
1233
+ // those rows behind under tmux (its cursor-up clear can't reach
1234
+ // a frame that has scrolled), which is the blank gap users see
1235
+ // after steering. A full reprint (resetTranscript) rewrites the
1236
+ // transcript cleanly with no leftover — the same fix the resize
1237
+ // path uses. Unlike a turn settling (content moves in place),
1238
+ // this reorder is rare, so the reprint cost is acceptable.
1217
1239
  const steer = pendingSteersRef.current.get(event.id);
1218
1240
  if (steer) {
1219
1241
  pendingSteersRef.current.delete(event.id);
1220
1242
  setPendingSteerCount(pendingSteersRef.current.size);
1221
- updateDisplayMessages((prev) => moveStatusMessageToEnd(prev, steer.displayKey));
1243
+ resetTranscript((prev) => moveStatusMessageToEnd(prev, steer.displayKey));
1222
1244
  }
1223
1245
  break;
1224
1246
  }
@@ -1267,7 +1289,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1267
1289
  commitAssistantMessage();
1268
1290
  if (err instanceof AgentAbortError || err?.name === "AbortError") {
1269
1291
  runCancelled = true;
1270
- updateDisplayMessages(() => reconstructDisplayMessages(agent.messages));
1292
+ resetTranscript(() => reconstructDisplayMessages(agent.messages));
1271
1293
  }
1272
1294
  else {
1273
1295
  runErrored = true;
@@ -1535,7 +1557,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1535
1557
  // card; otherwise the pre-compaction history would keep rendering.
1536
1558
  if (result.startsWith("✓ Compaction complete")) {
1537
1559
  const summary = latestCompactionSummary(agent.messages);
1538
- updateDisplayMessages(() => [
1560
+ resetTranscript(() => [
1539
1561
  ...reconstructDisplayMessages(agent.messages),
1540
1562
  {
1541
1563
  role: "assistant",
@@ -1548,7 +1570,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1548
1570
  else if (result.startsWith("⏪")) {
1549
1571
  // /rewind truncated agent.messages — rebuild the transcript from
1550
1572
  // the rewound state before appending the summary.
1551
- updateDisplayMessages(() => [
1573
+ resetTranscript(() => [
1552
1574
  ...reconstructDisplayMessages(agent.messages),
1553
1575
  { role: "assistant", content: result },
1554
1576
  ]);
@@ -1638,23 +1660,23 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1638
1660
  return null;
1639
1661
  })()
1640
1662
  : null;
1641
- const showThinkingLabel = getAvailableThinkingLevels(agent.providerId, agent.apiModel).length > 2
1642
- && thinkingLevel
1643
- && thinkingLevel !== "off";
1663
+ // MiniMax has only off/on, so the graded ">2 levels" gate would hide its label;
1664
+ // surface it too (rendered as "thinking mode" by formatModelLine).
1665
+ const isMiniMaxProvider = (agent.providerId || "").toLowerCase().includes("minimax");
1666
+ const showThinkingLabel = Boolean(thinkingLevel)
1667
+ && thinkingLevel !== "off"
1668
+ && (isMiniMaxProvider || getAvailableThinkingLevels(agent.providerId, agent.apiModel).length > 2);
1644
1669
  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
1670
  const commandPaletteItems = useMemo(() => buildCommandPaletteItems(safeSkillRegistry), [safeSkillRegistry]);
1646
1671
  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);
1672
+ // No fixed-height frame: settled rows flow into the terminal's native
1673
+ // scrollback via <Static>, and only the dynamic bottom stack (streaming
1674
+ // tail, pickers, composer, footer) occupies the live region. Letting it size
1675
+ // to its content keeps the composer pinned just below the latest output the
1676
+ // way ordinary shell programs do.
1655
1677
  const sidebarWidth = sidebarVisible ? Math.min(42, Math.max(28, Math.floor(terminalColumns * 0.34))) : 0;
1656
1678
  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
1679
+ 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
1680
  .filter((p) => isUserVisibleProvider(p.id))
1659
1681
  .map((p) => {
1660
1682
  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;