@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.
- 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 +82 -62
- package/dist/tui-ink/input-box.d.ts +1 -0
- package/dist/tui-ink/input-box.js +23 -17
- 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
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
1010
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
-
//
|
|
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);
|
|
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,
|
|
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;
|