@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.
- package/README.md +4 -2
- package/dist/agent.js +1 -1
- package/dist/clipboard.d.ts +14 -0
- package/dist/clipboard.js +132 -0
- package/dist/model-catalog.d.ts +3 -1
- package/dist/model-catalog.js +17 -28
- package/dist/prompt/compose.js +1 -1
- package/dist/provider-anthropic.d.ts +4 -0
- package/dist/provider-anthropic.js +31 -0
- package/dist/provider-ark-responses.d.ts +17 -0
- package/dist/provider-ark-responses.js +462 -0
- package/dist/provider-transform.js +7 -0
- package/dist/provider.d.ts +7 -0
- package/dist/provider.js +150 -22
- package/dist/slash-commands/commands.js +22 -0
- package/dist/tools/todo.js +22 -38
- package/dist/tui-ink/app.js +80 -58
- package/dist/tui-ink/input-box.d.ts +1 -0
- package/dist/tui-ink/input-box.js +20 -16
- package/dist/tui-ink/message-list.d.ts +17 -1
- package/dist/tui-ink/message-list.js +74 -13
- package/dist/tui-ink/model-picker.d.ts +3 -2
- package/dist/tui-ink/model-picker.js +17 -4
- package/dist/tui-ink/question-dialog.js +36 -10
- package/dist/tui-ink/run.js +14 -22
- package/dist/tui-ink/terminal-mouse.d.ts +11 -0
- package/dist/tui-ink/terminal-mouse.js +13 -0
- package/dist/tui-ink/welcome.js +13 -3
- package/dist/variant/variant-resolver.js +4 -1
- package/package.json +1 -1
- package/dist/tui/transcript-scroll.d.ts +0 -25
- package/dist/tui/transcript-scroll.js +0 -20
- package/dist/tui-ink/transcript-input.d.ts +0 -8
- package/dist/tui-ink/transcript-input.js +0 -9
- package/dist/tui-ink/transcript-viewport-math.d.ts +0 -10
- package/dist/tui-ink/transcript-viewport-math.js +0 -16
- package/dist/tui-ink/transcript-viewport.d.ts +0 -24
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 === "
|
|
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]",
|
package/dist/tools/todo.js
CHANGED
|
@@ -13,50 +13,33 @@ export function createTodoTool(store) {
|
|
|
13
13
|
|
|
14
14
|
## When to use
|
|
15
15
|
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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: {
|
package/dist/tui-ink/app.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
1010
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
-
//
|
|
1648
|
-
//
|
|
1649
|
-
//
|
|
1650
|
-
//
|
|
1651
|
-
//
|
|
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,
|
|
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;
|