@bubblebrain-ai/bubble 0.0.20 → 0.0.22
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/dist/agent/abort-errors.d.ts +14 -0
- package/dist/agent/abort-errors.js +21 -0
- package/dist/agent/budget-ledger.d.ts +41 -0
- package/dist/agent/budget-ledger.js +64 -0
- package/dist/agent/child-runner.d.ts +55 -0
- package/dist/agent/child-runner.js +312 -0
- package/dist/agent/profiles.d.ts +8 -0
- package/dist/agent/profiles.js +27 -5
- package/dist/agent/result-integrator.d.ts +22 -0
- package/dist/agent/result-integrator.js +50 -0
- package/dist/agent/subagent-control.d.ts +31 -0
- package/dist/agent/subagent-control.js +27 -0
- package/dist/agent/subagent-lifecycle-reminder.js +11 -2
- package/dist/agent/subagent-scheduler.d.ts +95 -0
- package/dist/agent/subagent-scheduler.js +256 -0
- package/dist/agent/subagent-store.d.ts +41 -0
- package/dist/agent/subagent-store.js +149 -0
- package/dist/agent/subagent-summary.d.ts +30 -0
- package/dist/agent/subagent-summary.js +74 -0
- package/dist/agent/worktree.d.ts +29 -0
- package/dist/agent/worktree.js +73 -0
- package/dist/agent.d.ts +64 -5
- package/dist/agent.js +365 -288
- package/dist/approval/controller.js +9 -1
- package/dist/approval/tool-helper.js +2 -0
- package/dist/approval/types.d.ts +17 -1
- package/dist/checkpoints.d.ts +57 -0
- package/dist/checkpoints.js +0 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +17 -0
- package/dist/feishu/agent-host/approval-card.js +9 -0
- package/dist/feishu/agent-host/run-driver.js +2 -0
- package/dist/main.js +88 -13
- package/dist/network/errors.d.ts +28 -0
- package/dist/network/errors.js +24 -0
- package/dist/orchestrator/default-hooks.js +5 -1
- package/dist/prompt/compose.js +3 -0
- package/dist/prompt/delegation.d.ts +14 -0
- package/dist/prompt/delegation.js +64 -0
- package/dist/prompt/task-reminders.d.ts +5 -1
- package/dist/prompt/task-reminders.js +10 -2
- package/dist/provider-anthropic.js +23 -0
- package/dist/provider.js +23 -3
- package/dist/session.d.ts +31 -0
- package/dist/session.js +69 -0
- package/dist/slash-commands/commands.js +109 -2
- package/dist/slash-commands/types.d.ts +6 -0
- package/dist/tools/agent-lifecycle.d.ts +29 -3
- package/dist/tools/agent-lifecycle.js +394 -40
- package/dist/tools/bash.js +4 -0
- package/dist/tools/child-tools.d.ts +31 -0
- package/dist/tools/child-tools.js +106 -0
- package/dist/tools/edit.d.ts +2 -1
- package/dist/tools/edit.js +2 -1
- package/dist/tools/index.d.ts +7 -0
- package/dist/tools/index.js +3 -3
- package/dist/tools/write.d.ts +2 -1
- package/dist/tools/write.js +2 -1
- package/dist/tui/image-paste.d.ts +18 -0
- package/dist/tui/image-paste.js +60 -0
- package/dist/tui/run.d.ts +11 -1
- package/dist/tui/run.js +399 -71
- package/dist/tui/session-picker-data.d.ts +18 -0
- package/dist/tui/session-picker-data.js +21 -0
- package/dist/tui/trace-groups.d.ts +16 -0
- package/dist/tui/trace-groups.js +42 -1
- package/dist/tui/transcript-scroll.d.ts +25 -0
- package/dist/tui/transcript-scroll.js +20 -0
- package/dist/tui/wordmark.d.ts +2 -0
- package/dist/tui/wordmark.js +31 -4
- package/dist/tui-ink/app.d.ts +4 -1
- package/dist/tui-ink/app.js +301 -247
- package/dist/tui-ink/approval/approval-dialog.js +10 -0
- package/dist/tui-ink/display-history.d.ts +16 -1
- package/dist/tui-ink/display-history.js +50 -21
- package/dist/tui-ink/footer.d.ts +6 -12
- package/dist/tui-ink/footer.js +10 -29
- package/dist/tui-ink/image-paste.d.ts +59 -0
- package/dist/tui-ink/image-paste.js +277 -0
- package/dist/tui-ink/input-box.d.ts +26 -1
- package/dist/tui-ink/input-box.js +171 -41
- package/dist/tui-ink/message-list.d.ts +1 -1
- package/dist/tui-ink/message-list.js +46 -29
- package/dist/tui-ink/run.d.ts +7 -2
- package/dist/tui-ink/run.js +73 -23
- package/dist/tui-ink/terminal-mouse.d.ts +1 -0
- package/dist/tui-ink/terminal-mouse.js +4 -0
- package/dist/tui-ink/trace-groups.d.ts +16 -0
- package/dist/tui-ink/trace-groups.js +50 -2
- package/dist/tui-ink/transcript-viewport-math.d.ts +11 -0
- package/dist/tui-ink/transcript-viewport-math.js +17 -0
- package/dist/tui-ink/transcript-viewport.d.ts +24 -0
- package/dist/tui-ink/transcript-viewport.js +83 -0
- package/dist/tui-ink/welcome.d.ts +9 -7
- package/dist/tui-ink/welcome.js +7 -33
- package/dist/tui-opentui/approval/approval-dialog.js +10 -0
- package/dist/types.d.ts +17 -0
- package/package.json +1 -1
package/dist/tui-ink/run.js
CHANGED
|
@@ -1,15 +1,55 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { render } from "ink";
|
|
3
|
-
import chalk from "chalk";
|
|
4
3
|
import { App } from "./app.js";
|
|
4
|
+
import { MOUSE_REPORTING_DISABLE } from "./terminal-mouse.js";
|
|
5
|
+
// DECSET 1007: terminals translate the mouse wheel into Up/Down arrow keys
|
|
6
|
+
// while the alternate screen is active. Mouse reporting stays OFF on purpose
|
|
7
|
+
// so plain drag-select and copy keep their native terminal behavior; the
|
|
8
|
+
// composer classifies wheel-synthesized arrows vs real key presses.
|
|
9
|
+
const ALTERNATE_SCROLL_ENABLE = "\x1b[?1007h";
|
|
10
|
+
const ALTERNATE_SCROLL_DISABLE = "\x1b[?1007l";
|
|
5
11
|
import { warmHighlighter } from "./code-highlight.js";
|
|
12
|
+
/**
|
|
13
|
+
* Best-effort terminal restore for abnormal exits. DECSET mouse modes are
|
|
14
|
+
* global terminal state — if the process dies without disabling them, the
|
|
15
|
+
* user's shell receives \x1b[<35;… garbage on every mouse move. The alt-screen
|
|
16
|
+
* and cursor writes are defensive duplicates of Ink's own teardown (idempotent
|
|
17
|
+
* when Ink already ran; load-bearing when it didn't).
|
|
18
|
+
*/
|
|
19
|
+
function restoreTerminal() {
|
|
20
|
+
if (!process.stdout.isTTY)
|
|
21
|
+
return;
|
|
22
|
+
try {
|
|
23
|
+
process.stdout.write(ALTERNATE_SCROLL_DISABLE + MOUSE_REPORTING_DISABLE + "\x1b[?1049l\x1b[?25h");
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// stdout may already be destroyed during shutdown
|
|
27
|
+
}
|
|
28
|
+
}
|
|
6
29
|
export async function runTui(agent, args, options = {}) {
|
|
7
30
|
// Kick off shiki load before the first code block is rendered. Fire and
|
|
8
31
|
// forget — CodeBlock's lazy init falls back to raw lines if this isn't ready
|
|
9
32
|
// yet, so callers don't need to await it.
|
|
10
33
|
warmHighlighter();
|
|
11
34
|
let exitSummary;
|
|
12
|
-
const
|
|
35
|
+
const onFatalError = (err) => {
|
|
36
|
+
restoreTerminal();
|
|
37
|
+
const detail = err instanceof Error ? err.stack ?? err.message : String(err);
|
|
38
|
+
try {
|
|
39
|
+
process.stderr.write(`${detail}\n`);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// nothing left to report to
|
|
43
|
+
}
|
|
44
|
+
process.exit(1);
|
|
45
|
+
};
|
|
46
|
+
const onSigterm = () => {
|
|
47
|
+
restoreTerminal();
|
|
48
|
+
process.exit(143);
|
|
49
|
+
};
|
|
50
|
+
process.on("uncaughtException", onFatalError);
|
|
51
|
+
process.on("SIGTERM", onSigterm);
|
|
52
|
+
const instance = render(_jsx(App, { agent: agent, args: args, sessionManager: options.sessionManager, createProvider: options.createProvider, registry: options.registry, skillRegistry: options.skillRegistry, planHandlerRef: options.planHandlerRef, approvalHandlerRef: options.approvalHandlerRef, questionController: options.questionController, bashAllowlist: options.bashAllowlist, settingsManager: options.settingsManager, lspService: options.lspService, mcpManager: options.mcpManager, themeMode: options.themeMode, themeOverrides: options.themeOverrides, detectedTheme: options.detectedTheme, onThemeModeChange: options.onThemeModeChange, flushMemory: options.flushMemory, runMemoryCompaction: options.runMemoryCompaction, runMemorySummary: options.runMemorySummary, runMemoryRefresh: options.runMemoryRefresh, bypassEnabled: options.bypassEnabled, updateNotice: options.updateNotice, hookController: options.hookController, onExit: (summary) => {
|
|
13
53
|
// The app already called useApp().exit() inside requestExit, which
|
|
14
54
|
// triggers Ink's own unmount + TTY restore. waitUntilExit() below is
|
|
15
55
|
// the canonical signal that we're done — we deliberately do *not*
|
|
@@ -25,34 +65,44 @@ export async function runTui(agent, args, options = {}) {
|
|
|
25
65
|
exitOnCtrlC: false,
|
|
26
66
|
kittyKeyboard: {
|
|
27
67
|
mode: "enabled",
|
|
28
|
-
|
|
68
|
+
// reportEventTypes lets the composer tell real arrow-key presses
|
|
69
|
+
// (kitty-enhanced, carry eventType) apart from the bare arrow
|
|
70
|
+
// sequences terminals synthesize for wheel scrolling in alternate
|
|
71
|
+
// screen — see the classifier in input-box.tsx.
|
|
72
|
+
flags: ["disambiguateEscapeCodes", "reportEventTypes"],
|
|
29
73
|
},
|
|
74
|
+
// The whole point of the Ink migration: render into the 1049 alternate
|
|
75
|
+
// screen so streaming repaints never touch the user's shell scrollback.
|
|
76
|
+
// Ink degrades this to false automatically when stdout is not a TTY.
|
|
77
|
+
alternateScreen: true,
|
|
30
78
|
});
|
|
31
|
-
|
|
79
|
+
// Enable alternate-scroll after render() so it follows alt-screen entry:
|
|
80
|
+
// the wheel arrives as Up/Down arrows, while plain drag-select and copy
|
|
81
|
+
// keep their native terminal behavior (no mouse reporting).
|
|
82
|
+
if (process.stdout.isTTY) {
|
|
83
|
+
process.stdout.write(ALTERNATE_SCROLL_ENABLE);
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
await instance.waitUntilExit();
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
// Reset scroll translation before anything is printed to the primary
|
|
90
|
+
// screen; Ink has already left the alt screen by the time
|
|
91
|
+
// waitUntilExit() resolves.
|
|
92
|
+
if (process.stdout.isTTY) {
|
|
93
|
+
process.stdout.write(ALTERNATE_SCROLL_DISABLE);
|
|
94
|
+
}
|
|
95
|
+
process.off("uncaughtException", onFatalError);
|
|
96
|
+
process.off("SIGTERM", onSigterm);
|
|
97
|
+
}
|
|
32
98
|
// zsh's PROMPT_SP prints a reverse-video `%` if the previous program left
|
|
33
99
|
// the cursor mid-line. Ink's interactive teardown (log-update.done) doesn't
|
|
34
100
|
// emit a trailing newline, so mirror Ink's non-interactive branch and align
|
|
35
101
|
// the cursor to column 0 before handing control back to the shell.
|
|
36
102
|
if (process.stdout.isTTY) {
|
|
37
103
|
process.stdout.write("\n");
|
|
38
|
-
if (exitSummary) {
|
|
39
|
-
process.stdout.write(formatExitSummary(exitSummary) + "\n");
|
|
40
|
-
}
|
|
41
104
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return chalk.dim(`${label} ${formatWallMs(summary.wallMs)}`);
|
|
46
|
-
}
|
|
47
|
-
function formatWallMs(ms) {
|
|
48
|
-
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
|
49
|
-
if (totalSeconds < 60)
|
|
50
|
-
return `${totalSeconds}s`;
|
|
51
|
-
const minutes = Math.floor(totalSeconds / 60);
|
|
52
|
-
const seconds = totalSeconds % 60;
|
|
53
|
-
if (minutes < 60)
|
|
54
|
-
return `${minutes}m ${seconds}s`;
|
|
55
|
-
const hours = Math.floor(minutes / 60);
|
|
56
|
-
const minutesRest = minutes % 60;
|
|
57
|
-
return `${hours}h ${minutesRest}m ${seconds}s`;
|
|
105
|
+
// The exit summary is printed by main.ts (single print site, after the alt
|
|
106
|
+
// screen has been left, so it lands in the real shell scrollback).
|
|
107
|
+
return exitSummary;
|
|
58
108
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export declare const MOUSE_REPORTING_DISABLE = "\u001B[?1006l\u001B[?1000l";
|
|
1
2
|
export type MouseWheelDirection = "up" | "down";
|
|
2
3
|
export declare function stripTerminalMouseSequences(input: string): string;
|
|
3
4
|
export declare function hasTerminalMouseSequence(input: string): boolean;
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// Bubble does NOT enable mouse reporting — plain drag-select and copy keep
|
|
2
|
+
// their native terminal behavior. This disable sequence is written
|
|
3
|
+
// defensively on teardown in case a previous crash left reporting on.
|
|
4
|
+
export const MOUSE_REPORTING_DISABLE = "\x1b[?1006l\x1b[?1000l";
|
|
1
5
|
const SGR_MOUSE_SEQUENCE_RE = /\x1b?\[?<\d+;\d+;\d+[mM]/g;
|
|
2
6
|
const SGR_MOUSE_WHEEL_RE = /\x1b?\[?<(\d+);\d+;\d+([mM])/g;
|
|
3
7
|
export function stripTerminalMouseSequences(input) {
|
|
@@ -7,6 +7,10 @@ export interface TraceGroup {
|
|
|
7
7
|
count?: number;
|
|
8
8
|
noun?: string;
|
|
9
9
|
command?: string;
|
|
10
|
+
/** Model-provided one-line summary of what the command does (bash `description` arg). */
|
|
11
|
+
description?: string;
|
|
12
|
+
/** Original command split into lines, line breaks preserved (execute groups only). */
|
|
13
|
+
commandLines?: string[];
|
|
10
14
|
items: string[];
|
|
11
15
|
previewLines: string[];
|
|
12
16
|
errorLines: string[];
|
|
@@ -25,3 +29,15 @@ export declare function buildTraceGroups(toolCalls: DisplayToolCall[], options?:
|
|
|
25
29
|
export declare function formatTracePath(value: unknown, homeDir?: string): string;
|
|
26
30
|
export declare function formatElapsed(startedAt: number | undefined, now?: number): string | null;
|
|
27
31
|
export declare function traceGroupLabel(group: TraceGroup): string;
|
|
32
|
+
/**
|
|
33
|
+
* An execute command is shown inline in the header only when nothing is lost:
|
|
34
|
+
* no description competing for the slot, a single logical line, and it fits
|
|
35
|
+
* the width budget. Otherwise the full command renders as a wrapped block
|
|
36
|
+
* below the header — commands are never clipped mid-line.
|
|
37
|
+
*/
|
|
38
|
+
export declare function shouldInlineExecuteCommand(group: TraceGroup, widthBudget: number): boolean;
|
|
39
|
+
/** Visible command-block lines for compact rendering, capped at `maxLines`. */
|
|
40
|
+
export declare function executeCommandBlock(group: TraceGroup, maxLines: number): {
|
|
41
|
+
lines: string[];
|
|
42
|
+
omitted: number;
|
|
43
|
+
};
|
|
@@ -65,12 +65,36 @@ export function formatElapsed(startedAt, now = Date.now()) {
|
|
|
65
65
|
return `${minutes}m${remainder.toString().padStart(2, "0")}s`;
|
|
66
66
|
}
|
|
67
67
|
export function traceGroupLabel(group) {
|
|
68
|
+
if (group.description)
|
|
69
|
+
return `${group.title} ${group.description}`;
|
|
68
70
|
if (group.command)
|
|
69
71
|
return `${group.title} ${group.command}`;
|
|
70
72
|
if (group.count !== undefined && group.noun)
|
|
71
73
|
return `${group.title} ${group.count} ${group.noun}`;
|
|
72
74
|
return group.title;
|
|
73
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* An execute command is shown inline in the header only when nothing is lost:
|
|
78
|
+
* no description competing for the slot, a single logical line, and it fits
|
|
79
|
+
* the width budget. Otherwise the full command renders as a wrapped block
|
|
80
|
+
* below the header — commands are never clipped mid-line.
|
|
81
|
+
*/
|
|
82
|
+
export function shouldInlineExecuteCommand(group, widthBudget) {
|
|
83
|
+
if (group.kind !== "execute" || !group.command)
|
|
84
|
+
return false;
|
|
85
|
+
if (group.description)
|
|
86
|
+
return false;
|
|
87
|
+
const lines = group.commandLines ?? [];
|
|
88
|
+
if (lines.length > 1)
|
|
89
|
+
return false;
|
|
90
|
+
return group.command.length <= widthBudget;
|
|
91
|
+
}
|
|
92
|
+
/** Visible command-block lines for compact rendering, capped at `maxLines`. */
|
|
93
|
+
export function executeCommandBlock(group, maxLines) {
|
|
94
|
+
const lines = group.commandLines ?? [];
|
|
95
|
+
const shown = lines.slice(0, maxLines);
|
|
96
|
+
return { lines: shown, omitted: Math.max(0, lines.length - shown.length) };
|
|
97
|
+
}
|
|
74
98
|
function classifyTool(toolCall) {
|
|
75
99
|
if (toolCall.metadata?.kind === "subagent") {
|
|
76
100
|
return { kind: "subagent", title: "Subagents", bucketKey: `subagent:${toolCall.id}`, groupable: false };
|
|
@@ -244,11 +268,15 @@ function buildSearchGroup(classifier, raw, options, pending, startedAt, hasError
|
|
|
244
268
|
function buildExecuteGroup(classifier, tool, options, pending, startedAt, hasError, errorCount) {
|
|
245
269
|
const lines = resultLines(tool.result).map((line) => formatTracePath(line, options.homeDir));
|
|
246
270
|
const { shown, omitted } = take(lines, options.maxPreviewLines);
|
|
271
|
+
const rawCommand = String(tool.args.command ?? tool.args.cmd ?? commandFromRawArguments(tool.rawArguments) ?? "");
|
|
272
|
+
const description = String(tool.args.description ?? "").trim() || undefined;
|
|
247
273
|
return {
|
|
248
274
|
kind: "execute",
|
|
249
275
|
title: classifier.title,
|
|
250
276
|
raw: [tool],
|
|
251
|
-
command: normalizeCommand(
|
|
277
|
+
command: normalizeCommand(rawCommand),
|
|
278
|
+
description,
|
|
279
|
+
commandLines: commandLinesOf(rawCommand),
|
|
252
280
|
items: [],
|
|
253
281
|
previewLines: shown,
|
|
254
282
|
errorLines: [],
|
|
@@ -262,7 +290,7 @@ function buildExecuteGroup(classifier, tool, options, pending, startedAt, hasErr
|
|
|
262
290
|
function buildMutationGroup(classifier, raw, options, pending, startedAt, hasError, errorCount) {
|
|
263
291
|
const items = raw
|
|
264
292
|
.map((tool) => {
|
|
265
|
-
const path = formatTracePath(tool.args.path ?? "", options.homeDir);
|
|
293
|
+
const path = formatTracePath(tool.args.path ?? firstMetadataPath(tool) ?? "", options.homeDir);
|
|
266
294
|
const details = tool.name === "edit" ? getEditDiffDetails(tool) : null;
|
|
267
295
|
const suffix = details ? ` ${formatCompactEditStats(details.added, details.removed)}` : "";
|
|
268
296
|
return path ? `${path}${suffix}` : "";
|
|
@@ -428,6 +456,19 @@ function displayToolName(name) {
|
|
|
428
456
|
return "Tool";
|
|
429
457
|
return name.charAt(0).toUpperCase() + name.slice(1).replace(/_/g, " ");
|
|
430
458
|
}
|
|
459
|
+
// Preserves the command's own line structure (heredocs, && chains the model
|
|
460
|
+
// formatted across lines); only trims trailing whitespace and outer blank lines.
|
|
461
|
+
function commandLinesOf(rawCommand) {
|
|
462
|
+
const lines = rawCommand
|
|
463
|
+
.replace(/\r\n/g, "\n")
|
|
464
|
+
.split("\n")
|
|
465
|
+
.map((line) => line.trimEnd());
|
|
466
|
+
while (lines.length > 0 && lines[0].trim() === "")
|
|
467
|
+
lines.shift();
|
|
468
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === "")
|
|
469
|
+
lines.pop();
|
|
470
|
+
return lines;
|
|
471
|
+
}
|
|
431
472
|
function toolHeader(tool, homeDir) {
|
|
432
473
|
const args = tool.args || {};
|
|
433
474
|
for (const key of ["path", "command", "pattern", "query", "url"]) {
|
|
@@ -436,8 +477,15 @@ function toolHeader(tool, homeDir) {
|
|
|
436
477
|
return formatTracePath(value, homeDir);
|
|
437
478
|
}
|
|
438
479
|
}
|
|
480
|
+
const path = firstMetadataPath(tool);
|
|
481
|
+
if (path)
|
|
482
|
+
return formatTracePath(path, homeDir);
|
|
439
483
|
return undefined;
|
|
440
484
|
}
|
|
485
|
+
function firstMetadataPath(tool) {
|
|
486
|
+
const paths = tool.metadata?.paths;
|
|
487
|
+
return Array.isArray(paths) && typeof paths[0] === "string" ? paths[0] : undefined;
|
|
488
|
+
}
|
|
441
489
|
function formatCompactEditStats(added, removed) {
|
|
442
490
|
const parts = [];
|
|
443
491
|
if (added > 0)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure scroll arithmetic for the alt-screen transcript viewport.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the live OpenTUI scrollbox semantics (src/tui/run.ts
|
|
5
|
+
* transcriptMaxScrollTop / isTranscriptAtBottom): "at bottom" tolerates a
|
|
6
|
+
* one-line slack so sub-line rounding never flips the follow flag while the
|
|
7
|
+
* user sits at the end of the transcript.
|
|
8
|
+
*/
|
|
9
|
+
export declare function maxScrollTop(contentHeight: number, viewportHeight: number): number;
|
|
10
|
+
export declare function clampScrollTop(scrollTop: number, contentHeight: number, viewportHeight: number): number;
|
|
11
|
+
export declare function isAtBottom(scrollTop: number, contentHeight: number, viewportHeight: number): boolean;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure scroll arithmetic for the alt-screen transcript viewport.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the live OpenTUI scrollbox semantics (src/tui/run.ts
|
|
5
|
+
* transcriptMaxScrollTop / isTranscriptAtBottom): "at bottom" tolerates a
|
|
6
|
+
* one-line slack so sub-line rounding never flips the follow flag while the
|
|
7
|
+
* user sits at the end of the transcript.
|
|
8
|
+
*/
|
|
9
|
+
export function maxScrollTop(contentHeight, viewportHeight) {
|
|
10
|
+
return Math.max(0, contentHeight - viewportHeight);
|
|
11
|
+
}
|
|
12
|
+
export function clampScrollTop(scrollTop, contentHeight, viewportHeight) {
|
|
13
|
+
return Math.max(0, Math.min(scrollTop, maxScrollTop(contentHeight, viewportHeight)));
|
|
14
|
+
}
|
|
15
|
+
export function isAtBottom(scrollTop, contentHeight, viewportHeight) {
|
|
16
|
+
return scrollTop >= maxScrollTop(contentHeight, viewportHeight) - 1;
|
|
17
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export interface TranscriptViewportHandle {
|
|
3
|
+
/**
|
|
4
|
+
* Re-engage bottom-follow. Sets the pending force flag from
|
|
5
|
+
* transcript-scroll.ts, so the snap survives streaming renders that land
|
|
6
|
+
* between the request and the next measured layout.
|
|
7
|
+
*/
|
|
8
|
+
forceScrollToBottom(): void;
|
|
9
|
+
/** Scroll by N lines (negative = up). A user gesture: cancels a pending force. */
|
|
10
|
+
scrollBy(lines: number): void;
|
|
11
|
+
scrollPage(direction: "up" | "down"): void;
|
|
12
|
+
}
|
|
13
|
+
interface TranscriptViewportProps {
|
|
14
|
+
children: React.ReactNode;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Height-clamped scrolling viewport for the alt-screen transcript. The outer
|
|
18
|
+
* box clips; the inner box carries the full transcript and slides via a
|
|
19
|
+
* negative top margin. Follow policy is the shared transcript-scroll.ts:
|
|
20
|
+
* stay snapped while at the bottom, hold position while reading history,
|
|
21
|
+
* snap back on send/approval via forceScrollToBottom().
|
|
22
|
+
*/
|
|
23
|
+
export declare const TranscriptViewport: React.ForwardRefExoticComponent<TranscriptViewportProps & React.RefAttributes<TranscriptViewportHandle>>;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
3
|
+
import { Box, measureElement } from "ink";
|
|
4
|
+
import { resolveTranscriptScroll } from "../tui/transcript-scroll.js";
|
|
5
|
+
import { clampScrollTop, isAtBottom, maxScrollTop } from "./transcript-viewport-math.js";
|
|
6
|
+
/**
|
|
7
|
+
* Height-clamped scrolling viewport for the alt-screen transcript. The outer
|
|
8
|
+
* box clips; the inner box carries the full transcript and slides via a
|
|
9
|
+
* negative top margin. Follow policy is the shared transcript-scroll.ts:
|
|
10
|
+
* stay snapped while at the bottom, hold position while reading history,
|
|
11
|
+
* snap back on send/approval via forceScrollToBottom().
|
|
12
|
+
*/
|
|
13
|
+
export const TranscriptViewport = forwardRef(function TranscriptViewport({ children }, ref) {
|
|
14
|
+
const viewportRef = useRef(null);
|
|
15
|
+
const contentRef = useRef(null);
|
|
16
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
17
|
+
const scrollTopRef = useRef(0);
|
|
18
|
+
const followingRef = useRef(true);
|
|
19
|
+
const forcePendingRef = useRef(false);
|
|
20
|
+
// forceScrollToBottom must work even when no other state changes in the
|
|
21
|
+
// same tick; bumping this guarantees a commit so the measuring effect runs.
|
|
22
|
+
const [, setScrollEpoch] = useState(0);
|
|
23
|
+
const applyScrollTop = (next) => {
|
|
24
|
+
scrollTopRef.current = next;
|
|
25
|
+
setScrollTop(next);
|
|
26
|
+
};
|
|
27
|
+
// measureElement returns the Yoga-computed size, valid only after layout —
|
|
28
|
+
// callable from effects and input handlers, never during render.
|
|
29
|
+
const measureHeights = () => ({
|
|
30
|
+
viewportHeight: viewportRef.current ? measureElement(viewportRef.current).height : 0,
|
|
31
|
+
contentHeight: contentRef.current ? measureElement(contentRef.current).height : 0,
|
|
32
|
+
});
|
|
33
|
+
// Content and viewport heights change with streaming text, tool expansion,
|
|
34
|
+
// resize, and bottom-stack visibility — no dependency list covers them
|
|
35
|
+
// all, so re-resolve after every commit. setState below bails out via
|
|
36
|
+
// Object.is when nothing moved, so the steady state does not loop.
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const { viewportHeight, contentHeight } = measureHeights();
|
|
39
|
+
if (viewportHeight <= 0)
|
|
40
|
+
return;
|
|
41
|
+
const action = resolveTranscriptScroll({
|
|
42
|
+
forcePending: forcePendingRef.current,
|
|
43
|
+
shouldFollow: followingRef.current,
|
|
44
|
+
following: followingRef.current,
|
|
45
|
+
});
|
|
46
|
+
if (action === "scroll-bottom") {
|
|
47
|
+
forcePendingRef.current = false;
|
|
48
|
+
followingRef.current = true;
|
|
49
|
+
applyScrollTop(maxScrollTop(contentHeight, viewportHeight));
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
const clamped = clampScrollTop(scrollTopRef.current, contentHeight, viewportHeight);
|
|
53
|
+
followingRef.current = isAtBottom(clamped, contentHeight, viewportHeight);
|
|
54
|
+
if (clamped !== scrollTopRef.current)
|
|
55
|
+
applyScrollTop(clamped);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
useImperativeHandle(ref, () => {
|
|
59
|
+
const scrollBy = (lines) => {
|
|
60
|
+
forcePendingRef.current = false; // the user's latest gesture wins
|
|
61
|
+
const { viewportHeight, contentHeight } = measureHeights();
|
|
62
|
+
if (viewportHeight <= 0)
|
|
63
|
+
return;
|
|
64
|
+
const next = clampScrollTop(scrollTopRef.current + lines, contentHeight, viewportHeight);
|
|
65
|
+
followingRef.current = isAtBottom(next, contentHeight, viewportHeight);
|
|
66
|
+
if (next !== scrollTopRef.current)
|
|
67
|
+
applyScrollTop(next);
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
forceScrollToBottom() {
|
|
71
|
+
forcePendingRef.current = true;
|
|
72
|
+
setScrollEpoch((epoch) => epoch + 1);
|
|
73
|
+
},
|
|
74
|
+
scrollBy,
|
|
75
|
+
scrollPage(direction) {
|
|
76
|
+
const { viewportHeight } = measureHeights();
|
|
77
|
+
const step = Math.max(1, viewportHeight - 2);
|
|
78
|
+
scrollBy(direction === "up" ? -step : step);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}, []);
|
|
82
|
+
return (_jsx(Box, { ref: viewportRef, flexDirection: "column", flexGrow: 1, minHeight: 0, overflowY: "hidden", children: _jsx(Box, { ref: contentRef, flexDirection: "column", flexShrink: 0, marginTop: -scrollTop, children: children }) }));
|
|
83
|
+
});
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import type { DisplayMessage } from "./display-history.js";
|
|
2
2
|
interface WelcomeBannerProps {
|
|
3
3
|
terminalColumns: number;
|
|
4
|
-
modelLabel?: string;
|
|
5
|
-
cwd?: string;
|
|
6
4
|
tips: string[];
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
/** One-line "update available" notice shown under the version. */
|
|
6
|
+
updateNotice?: string;
|
|
7
|
+
/** Friendly working directory (~ collapsed). */
|
|
8
|
+
cwd?: string;
|
|
9
|
+
providerId?: string;
|
|
10
|
+
modelLabel?: string;
|
|
11
|
+
/** Active thinking level, rendered as part of the model unit (e.g. "xhigh"). */
|
|
12
|
+
thinkingLabel?: string;
|
|
11
13
|
}
|
|
12
14
|
interface WelcomeVisibilityInput {
|
|
13
15
|
messages: Pick<DisplayMessage, "role" | "syntheticKind">[];
|
|
14
16
|
startedWithVisibleHistory: boolean;
|
|
15
17
|
}
|
|
16
18
|
export declare function shouldShowWelcomeBanner({ startedWithVisibleHistory, }: WelcomeVisibilityInput): boolean;
|
|
17
|
-
export declare function WelcomeBanner({ terminalColumns,
|
|
19
|
+
export declare function WelcomeBanner({ terminalColumns, tips, updateNotice, cwd, providerId, modelLabel, thinkingLabel, }: WelcomeBannerProps): import("react/jsx-runtime").JSX.Element;
|
|
18
20
|
export {};
|
package/dist/tui-ink/welcome.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { Box, Text } from "ink";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { useTheme } from "./theme.js";
|
|
6
|
-
import {
|
|
6
|
+
import { bubbleWordmarkForWidth, } from "../tui/wordmark.js";
|
|
7
7
|
const require = createRequire(import.meta.url);
|
|
8
8
|
const PACKAGE_VERSION = readPackageVersion();
|
|
9
|
-
const WIDE_LOGO_MIN_WIDTH = bubbleWordmarkMaxWidth(BUBBLE_WORDMARK) + 4;
|
|
10
9
|
export function shouldShowWelcomeBanner({ startedWithVisibleHistory, }) {
|
|
11
10
|
// Keep banner visibility tied to the initial history, not transient overlays,
|
|
12
11
|
// so opening and closing a picker does not move it in the transcript.
|
|
@@ -14,20 +13,19 @@ export function shouldShowWelcomeBanner({ startedWithVisibleHistory, }) {
|
|
|
14
13
|
return false;
|
|
15
14
|
return true;
|
|
16
15
|
}
|
|
17
|
-
export function WelcomeBanner({ terminalColumns,
|
|
16
|
+
export function WelcomeBanner({ terminalColumns, tips, updateNotice, cwd, providerId, modelLabel, thinkingLabel, }) {
|
|
18
17
|
const theme = useTheme();
|
|
19
18
|
const effectiveWidth = Math.max(20, Math.min(terminalColumns - 2, 118));
|
|
20
|
-
|
|
19
|
+
// Adaptive sizing: large pixel logo on wide terminals, standard, then the
|
|
20
|
+
// single-line compact mark — same thresholds as the OpenTUI home screen.
|
|
21
|
+
const logoLines = bubbleWordmarkForWidth(effectiveWidth);
|
|
21
22
|
const actionableTips = tips
|
|
22
23
|
.filter((item) => !item.startsWith("Ready with") && item.trim().length > 0)
|
|
23
24
|
.slice(0, 2);
|
|
24
25
|
const tip = actionableTips.length > 0
|
|
25
26
|
? actionableTips.join(" · ")
|
|
26
27
|
: "Type / for commands and @ to reference files";
|
|
27
|
-
|
|
28
|
-
return (_jsxs(Box, { width: effectiveWidth, flexDirection: "column", alignItems: "center", marginBottom: 1, children: [_jsx(Box, { flexDirection: "column", alignItems: "center", children: useWideLogo
|
|
29
|
-
? BUBBLE_WORDMARK.map((line, rowIndex) => (_jsx(LogoRow, { line: line }, `logo-row-${rowIndex}`)))
|
|
30
|
-
: _jsx(CompactLogo, {}) }), _jsx(Box, { marginTop: 2, children: _jsx(Text, { bold: true, color: theme.muted, children: PACKAGE_VERSION }) }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { bold: true, color: theme.userMessageText, children: "TIP: " }), _jsx(Text, { bold: true, color: theme.userMessageText, children: tip })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: "shift+tab to cycle modes \u00B7 ctrl+r for reasoning \u00B7 ctrl+o for trace" }) }), modelLine && (_jsx(Box, { children: _jsx(Text, { color: theme.muted, children: truncateToWidth(modelLine, effectiveWidth - 4) }) })), _jsxs(Box, { marginTop: 1, children: [_jsx(StatusItem, { label: "Skills", count: skillsCount, ok: skillsCount > 0 }), _jsx(Text, { color: theme.muted, children: " " }), _jsx(StatusItem, { label: "MCPs", count: mcpConnectedCount, total: mcpTotalCount, ok: mcpTotalCount === 0 || mcpConnectedCount === mcpTotalCount }), _jsx(Text, { color: theme.muted, children: " " }), _jsx(StatusItem, { label: "AGENTS.md", ok: hasAgentsFile })] })] }));
|
|
28
|
+
return (_jsxs(Box, { width: effectiveWidth, flexDirection: "column", alignItems: "center", marginBottom: 1, children: [_jsx(Box, { flexDirection: "column", alignItems: "center", children: logoLines.map((line, rowIndex) => (_jsx(LogoRow, { line: line }, `logo-row-${rowIndex}`))) }), _jsx(Box, { marginTop: 2, children: _jsx(Text, { bold: true, color: theme.muted, children: PACKAGE_VERSION }) }), updateNotice && (_jsx(Box, { children: _jsx(Text, { color: theme.accent, children: updateNotice }) })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { bold: true, color: theme.userMessageText, children: "TIP: " }), _jsx(Text, { bold: true, color: theme.userMessageText, children: tip })] }), (cwd || modelLabel) && (_jsxs(Box, { marginTop: 1, children: [cwd && _jsx(Text, { color: theme.muted, children: cwd }), cwd && (providerId || modelLabel) && _jsx(Text, { children: " " }), providerId && _jsxs(Text, { color: theme.muted, dimColor: true, children: [providerId, " \u00B7 "] }), modelLabel && (_jsxs(Text, { bold: true, color: theme.toolName, children: [modelLabel, thinkingLabel ? ` ${thinkingLabel}` : ""] }))] }))] }));
|
|
31
29
|
}
|
|
32
30
|
function LogoRow({ line }) {
|
|
33
31
|
const theme = useTheme();
|
|
@@ -36,14 +34,6 @@ function LogoRow({ line }) {
|
|
|
36
34
|
}
|
|
37
35
|
return (_jsx(Box, { children: line.segments.map((segment, index) => (_jsx(React.Fragment, { children: _jsx(Text, { bold: true, color: logoColor(theme, segment.tone), children: segment.text }) }, `${index}-${segment.text}`))) }));
|
|
38
36
|
}
|
|
39
|
-
function CompactLogo() {
|
|
40
|
-
const theme = useTheme();
|
|
41
|
-
const line = BUBBLE_COMPACT_WORDMARK[0];
|
|
42
|
-
if (!line?.segments) {
|
|
43
|
-
return _jsx(Text, { bold: true, color: theme.warning, children: bubbleWordmarkLineText(line ?? { text: "" }) });
|
|
44
|
-
}
|
|
45
|
-
return (_jsx(Box, { children: line.segments.map((segment, index) => (_jsx(Text, { bold: true, color: logoColor(theme, segment.tone), children: segment.text }, `${segment.text}-${index}`))) }));
|
|
46
|
-
}
|
|
47
37
|
function logoColor(theme, tone) {
|
|
48
38
|
switch (tone) {
|
|
49
39
|
case "brand": return theme.warning;
|
|
@@ -53,15 +43,6 @@ function logoColor(theme, tone) {
|
|
|
53
43
|
case "caption": return theme.muted;
|
|
54
44
|
}
|
|
55
45
|
}
|
|
56
|
-
function StatusItem({ label, count, total, ok, }) {
|
|
57
|
-
const theme = useTheme();
|
|
58
|
-
const countText = count === undefined
|
|
59
|
-
? ""
|
|
60
|
-
: total !== undefined && total > count
|
|
61
|
-
? ` (${count}/${total})`
|
|
62
|
-
: ` (${count})`;
|
|
63
|
-
return (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, color: theme.muted, children: [label, countText, " "] }), _jsx(Text, { bold: true, color: ok ? theme.success : theme.error, children: ok ? "✓" : "×" })] }));
|
|
64
|
-
}
|
|
65
46
|
function readPackageVersion() {
|
|
66
47
|
try {
|
|
67
48
|
const pkg = require("../../package.json");
|
|
@@ -71,10 +52,3 @@ function readPackageVersion() {
|
|
|
71
52
|
return "v0.0.0";
|
|
72
53
|
}
|
|
73
54
|
}
|
|
74
|
-
function truncateToWidth(text, maxWidth) {
|
|
75
|
-
if (maxWidth <= 0)
|
|
76
|
-
return "";
|
|
77
|
-
if (text.length <= maxWidth)
|
|
78
|
-
return text;
|
|
79
|
-
return text.slice(0, Math.max(1, maxWidth - 1)) + "…";
|
|
80
|
-
}
|
|
@@ -84,6 +84,8 @@ function dialogTitle(req) {
|
|
|
84
84
|
return "Bash command";
|
|
85
85
|
case "lsp":
|
|
86
86
|
return "Language server operation";
|
|
87
|
+
case "agent_profile":
|
|
88
|
+
return "Project agent profile";
|
|
87
89
|
}
|
|
88
90
|
}
|
|
89
91
|
function dialogQuestion(req) {
|
|
@@ -98,6 +100,8 @@ function dialogQuestion(req) {
|
|
|
98
100
|
return "Do you want to proceed?";
|
|
99
101
|
case "lsp":
|
|
100
102
|
return `Do you want to run ${req.operation} on ${basename(req.path)}?`;
|
|
103
|
+
case "agent_profile":
|
|
104
|
+
return `Trust the repository profile "${req.name}" to drive a subagent? It is remembered for this session until the file changes.`;
|
|
101
105
|
}
|
|
102
106
|
}
|
|
103
107
|
function basename(p) {
|
|
@@ -114,8 +118,14 @@ function RequestPreview({ request }) {
|
|
|
114
118
|
return _jsx(DiffView, { diff: request.diff });
|
|
115
119
|
case "write":
|
|
116
120
|
return _jsx(WritePreview, { path: request.path, content: request.content });
|
|
121
|
+
case "agent_profile":
|
|
122
|
+
return _jsx(AgentProfilePreview, { path: request.path, promptPreview: request.promptPreview });
|
|
117
123
|
}
|
|
118
124
|
}
|
|
125
|
+
function AgentProfilePreview({ path, promptPreview }) {
|
|
126
|
+
const theme = useTheme();
|
|
127
|
+
return (_jsxs("box", { style: { flexDirection: "column" }, children: [_jsx("text", { fg: theme.muted, children: compressHome(path) }), _jsx("text", { children: promptPreview }), _jsx("box", { style: { marginTop: 1 }, children: _jsx("text", { fg: theme.warning, children: "This prompt comes from the repository's .bubble/agents and will drive a subagent." }) })] }));
|
|
128
|
+
}
|
|
119
129
|
function BashPreview({ command, cwd }) {
|
|
120
130
|
const theme = useTheme();
|
|
121
131
|
const danger = classifyBashDanger(command);
|
package/dist/types.d.ts
CHANGED
|
@@ -199,6 +199,16 @@ export interface ToolContext {
|
|
|
199
199
|
}) => Promise<import("./agent/subagent-control.js").SubagentThreadSnapshot>;
|
|
200
200
|
closeSubAgent?: (agentId: string) => Promise<import("./agent/subagent-control.js").SubagentThreadSnapshot>;
|
|
201
201
|
listSubAgents?: () => import("./agent/subagent-control.js").SubagentThreadSnapshot[];
|
|
202
|
+
runAgentTeam?: (cwd: string, options: {
|
|
203
|
+
profile: import("./agent/profiles.js").AgentProfile;
|
|
204
|
+
category?: string;
|
|
205
|
+
promptTemplate: string;
|
|
206
|
+
items: string[];
|
|
207
|
+
parentToolCallId: string;
|
|
208
|
+
emitUpdate?: (update: ToolUpdate) => void;
|
|
209
|
+
abortSignal?: AbortSignal;
|
|
210
|
+
approval?: "fail" | "disabled";
|
|
211
|
+
}) => Promise<import("./agent/subagent-control.js").SubagentThreadSnapshot[]>;
|
|
202
212
|
};
|
|
203
213
|
emitUpdate?: (update: ToolUpdate) => void;
|
|
204
214
|
}
|
|
@@ -291,6 +301,13 @@ export interface Provider {
|
|
|
291
301
|
temperature?: number;
|
|
292
302
|
thinkingLevel?: ThinkingLevel;
|
|
293
303
|
abortSignal?: AbortSignal;
|
|
304
|
+
/**
|
|
305
|
+
* How the transport treats HTTP 429 (design doc §4.5). "handle"
|
|
306
|
+
* (default): retry inside the transport. "defer": throw a typed
|
|
307
|
+
* RateLimitError immediately so the caller owns the backoff — used by
|
|
308
|
+
* subagent routes where the scheduler is the single 429 backoff layer.
|
|
309
|
+
*/
|
|
310
|
+
rateLimitPolicy?: import("./network/errors.js").RateLimitPolicy;
|
|
294
311
|
}): AsyncIterable<StreamChunk>;
|
|
295
312
|
complete(messages: ProviderMessage[], options?: {
|
|
296
313
|
model?: string;
|