@bubblebrain-ai/bubble 0.0.7 → 0.0.9
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/categories.d.ts +34 -0
- package/dist/agent/categories.js +98 -0
- package/dist/agent/profiles.d.ts +4 -0
- package/dist/agent/profiles.js +2 -3
- package/dist/agent/subagent-control.d.ts +5 -0
- package/dist/agent/subagent-control.js +4 -0
- package/dist/agent/subagent-lifecycle-reminder.d.ts +3 -0
- package/dist/agent/subagent-lifecycle-reminder.js +102 -0
- package/dist/agent/subagent-route-format.d.ts +8 -0
- package/dist/agent/subagent-route-format.js +18 -0
- package/dist/agent/subtask-policy.d.ts +0 -1
- package/dist/agent/subtask-policy.js +0 -4
- package/dist/agent.d.ts +18 -0
- package/dist/agent.js +188 -16
- package/dist/config.d.ts +23 -3
- package/dist/config.js +59 -6
- package/dist/context/budget.d.ts +3 -2
- package/dist/context/budget.js +29 -15
- package/dist/context/compact.d.ts +23 -0
- package/dist/context/compact.js +129 -0
- package/dist/context/llm-compactor.d.ts +19 -0
- package/dist/context/llm-compactor.js +200 -0
- package/dist/context/projector.js +28 -12
- package/dist/context/token-estimator.d.ts +14 -0
- package/dist/context/token-estimator.js +106 -0
- package/dist/context/tool-output-truncate.d.ts +8 -0
- package/dist/context/tool-output-truncate.js +59 -0
- package/dist/context/usage.d.ts +34 -0
- package/dist/context/usage.js +213 -0
- package/dist/diff-stats.d.ts +5 -0
- package/dist/diff-stats.js +21 -0
- package/dist/main.js +68 -7
- package/dist/mcp/transports.d.ts +1 -0
- package/dist/mcp/transports.js +8 -0
- package/dist/model-catalog.d.ts +9 -0
- package/dist/model-catalog.js +17 -1
- package/dist/orchestrator/default-hooks.js +24 -18
- package/dist/prompt/compose.js +2 -1
- package/dist/prompt/provider-prompts/kimi.js +3 -1
- package/dist/provider-openai-codex.d.ts +13 -2
- package/dist/provider-openai-codex.js +81 -32
- package/dist/provider-registry.js +22 -6
- package/dist/provider-transform.d.ts +3 -1
- package/dist/provider-transform.js +15 -0
- package/dist/provider.d.ts +4 -1
- package/dist/provider.js +89 -4
- package/dist/reasoning-debug.d.ts +7 -0
- package/dist/reasoning-debug.js +30 -0
- package/dist/session-log.js +13 -2
- package/dist/session-types.d.ts +1 -1
- package/dist/slash-commands/commands.js +60 -2
- package/dist/slash-commands/types.d.ts +7 -0
- package/dist/tools/agent-lifecycle.js +22 -4
- package/dist/tools/edit.js +7 -2
- package/dist/tools/file-state.d.ts +19 -0
- package/dist/tools/file-state.js +15 -0
- package/dist/tools/glob.js +2 -1
- package/dist/tools/grep.js +2 -2
- package/dist/tools/lsp.js +2 -2
- package/dist/tools/path-utils.d.ts +2 -0
- package/dist/tools/path-utils.js +16 -0
- package/dist/tools/read.d.ts +1 -1
- package/dist/tools/read.js +207 -14
- package/dist/tools/write.js +3 -2
- package/dist/tui/escape-confirmation.d.ts +15 -0
- package/dist/tui/escape-confirmation.js +30 -0
- package/dist/tui/run.js +93 -23
- package/dist/tui-ink/app.d.ts +52 -0
- package/dist/tui-ink/app.js +1129 -0
- package/dist/tui-ink/approval/approval-dialog.d.ts +13 -0
- package/dist/tui-ink/approval/approval-dialog.js +132 -0
- package/dist/tui-ink/approval/diff-view.d.ts +7 -0
- package/dist/tui-ink/approval/diff-view.js +44 -0
- package/dist/tui-ink/approval/select.d.ts +35 -0
- package/dist/tui-ink/approval/select.js +88 -0
- package/dist/tui-ink/code-highlight.d.ts +8 -0
- package/dist/tui-ink/code-highlight.js +122 -0
- package/dist/tui-ink/detect-theme.d.ts +19 -0
- package/dist/tui-ink/detect-theme.js +123 -0
- package/dist/tui-ink/display-history.d.ts +38 -0
- package/dist/tui-ink/display-history.js +130 -0
- package/dist/tui-ink/edit-diff.d.ts +11 -0
- package/dist/tui-ink/edit-diff.js +52 -0
- package/dist/tui-ink/file-mentions.d.ts +29 -0
- package/dist/tui-ink/file-mentions.js +174 -0
- package/dist/tui-ink/footer.d.ts +19 -0
- package/dist/tui-ink/footer.js +45 -0
- package/dist/tui-ink/image-paste.d.ts +54 -0
- package/dist/tui-ink/image-paste.js +288 -0
- package/dist/tui-ink/input-box.d.ts +41 -0
- package/dist/tui-ink/input-box.js +694 -0
- package/dist/tui-ink/input-history.d.ts +16 -0
- package/dist/tui-ink/input-history.js +81 -0
- package/dist/tui-ink/markdown.d.ts +38 -0
- package/dist/tui-ink/markdown.js +394 -0
- package/dist/tui-ink/message-list.d.ts +33 -0
- package/dist/tui-ink/message-list.js +667 -0
- package/dist/tui-ink/model-picker.d.ts +43 -0
- package/dist/tui-ink/model-picker.js +331 -0
- package/dist/tui-ink/plan-confirm.d.ts +7 -0
- package/dist/tui-ink/plan-confirm.js +105 -0
- package/dist/tui-ink/question-dialog.d.ts +8 -0
- package/dist/tui-ink/question-dialog.js +99 -0
- package/dist/tui-ink/recent-activity.d.ts +8 -0
- package/dist/tui-ink/recent-activity.js +71 -0
- package/dist/tui-ink/run.d.ts +37 -0
- package/dist/tui-ink/run.js +53 -0
- package/dist/tui-ink/theme.d.ts +66 -0
- package/dist/tui-ink/theme.js +115 -0
- package/dist/tui-ink/todos.d.ts +7 -0
- package/dist/tui-ink/todos.js +46 -0
- package/dist/tui-ink/trace-groups.d.ts +27 -0
- package/dist/tui-ink/trace-groups.js +389 -0
- package/dist/tui-ink/use-terminal-size.d.ts +4 -0
- package/dist/tui-ink/use-terminal-size.js +21 -0
- package/dist/tui-ink/welcome.d.ts +18 -0
- package/dist/tui-ink/welcome.js +138 -0
- package/dist/types.d.ts +10 -0
- package/package.json +7 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ApprovalDecision, ApprovalRequest } from "../../approval/types.js";
|
|
2
|
+
interface ApprovalDialogProps {
|
|
3
|
+
request: ApprovalRequest;
|
|
4
|
+
onDecision: (decision: ApprovalDecision) => void;
|
|
5
|
+
/**
|
|
6
|
+
* Selecting "Yes, and don't ask again for <prefix>" calls this with the
|
|
7
|
+
* (possibly user-edited) prefix so the harness can register it in the
|
|
8
|
+
* session-scoped bash allowlist.
|
|
9
|
+
*/
|
|
10
|
+
onAllowBashPrefix?: (prefix: string) => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function ApprovalDialog({ request, onDecision, onAllowBashPrefix, }: ApprovalDialogProps): import("react/jsx-runtime").JSX.Element;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { useTheme } from "../theme.js";
|
|
4
|
+
import { ApprovalSelect } from "./select.js";
|
|
5
|
+
import { DiffView } from "./diff-view.js";
|
|
6
|
+
import { inferBashPrefix } from "../../approval/session-cache.js";
|
|
7
|
+
import { classifyBashDanger } from "../../approval/danger.js";
|
|
8
|
+
export function ApprovalDialog({ request, onDecision, onAllowBashPrefix, }) {
|
|
9
|
+
const theme = useTheme();
|
|
10
|
+
const options = buildOptions(request);
|
|
11
|
+
const onSubmit = (id, extras) => {
|
|
12
|
+
switch (id) {
|
|
13
|
+
case "yes":
|
|
14
|
+
onDecision({ action: "approve", feedback: extras.feedback });
|
|
15
|
+
return;
|
|
16
|
+
case "yes-bash-prefix": {
|
|
17
|
+
const prefix = (extras.editedValue ?? "").trim();
|
|
18
|
+
if (prefix)
|
|
19
|
+
onAllowBashPrefix?.(prefix);
|
|
20
|
+
onDecision({ action: "approve" });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
case "no":
|
|
24
|
+
default:
|
|
25
|
+
onDecision({ action: "reject", feedback: extras.feedback });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const onCancel = () => onDecision({ action: "reject" });
|
|
30
|
+
const title = dialogTitle(request);
|
|
31
|
+
const question = dialogQuestion(request);
|
|
32
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.accent, paddingX: 1, marginY: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: title }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(RequestPreview, { request: request }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: question }) }), _jsx(Box, { marginTop: 1, children: _jsx(ApprovalSelect, { options: options, onSubmit: onSubmit, onCancel: onCancel, hint: "\u2191\u2193 choose \u00B7 Enter select \u00B7 Tab add feedback \u00B7 Esc reject" }) })] }));
|
|
33
|
+
}
|
|
34
|
+
function buildOptions(request) {
|
|
35
|
+
if (request.type === "bash") {
|
|
36
|
+
const prefix = inferBashPrefix(request.command);
|
|
37
|
+
return [
|
|
38
|
+
{ id: "yes", label: "Yes", allowAmend: true, amendPlaceholder: "and tell Claude what to do next" },
|
|
39
|
+
{
|
|
40
|
+
id: "yes-bash-prefix",
|
|
41
|
+
label: "Yes, and don't ask again for",
|
|
42
|
+
editableValue: {
|
|
43
|
+
initial: prefix,
|
|
44
|
+
placeholder: "command prefix (e.g. npm run:*)",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "no",
|
|
49
|
+
label: "No",
|
|
50
|
+
description: "(tab to add feedback)",
|
|
51
|
+
allowAmend: true,
|
|
52
|
+
amendPlaceholder: "and tell Claude what to do differently",
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
// edit / write
|
|
57
|
+
return [
|
|
58
|
+
{ id: "yes", label: "Yes", allowAmend: true, amendPlaceholder: "and tell Claude what to do next" },
|
|
59
|
+
{
|
|
60
|
+
id: "no",
|
|
61
|
+
label: "No",
|
|
62
|
+
description: "(tab to add feedback)",
|
|
63
|
+
allowAmend: true,
|
|
64
|
+
amendPlaceholder: "and tell Claude what to do differently",
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
function dialogTitle(req) {
|
|
69
|
+
switch (req.type) {
|
|
70
|
+
case "edit":
|
|
71
|
+
return "Edit file";
|
|
72
|
+
case "write":
|
|
73
|
+
return req.fileExists ? "Overwrite file" : "Create file";
|
|
74
|
+
case "bash":
|
|
75
|
+
return "Bash command";
|
|
76
|
+
case "lsp":
|
|
77
|
+
return "Language server operation";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function dialogQuestion(req) {
|
|
81
|
+
switch (req.type) {
|
|
82
|
+
case "edit":
|
|
83
|
+
return `Do you want to make this edit to ${basename(req.path)}?`;
|
|
84
|
+
case "write":
|
|
85
|
+
return `Do you want to ${req.fileExists ? "overwrite" : "create"} ${basename(req.path)}?`;
|
|
86
|
+
case "bash":
|
|
87
|
+
return "Do you want to proceed?";
|
|
88
|
+
case "lsp":
|
|
89
|
+
return `Do you want to run ${req.operation} on ${basename(req.path)}?`;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function basename(p) {
|
|
93
|
+
const idx = p.lastIndexOf("/");
|
|
94
|
+
return idx >= 0 ? p.slice(idx + 1) : p;
|
|
95
|
+
}
|
|
96
|
+
function RequestPreview({ request }) {
|
|
97
|
+
switch (request.type) {
|
|
98
|
+
case "bash":
|
|
99
|
+
return _jsx(BashPreview, { command: request.command, cwd: request.cwd });
|
|
100
|
+
case "edit":
|
|
101
|
+
return _jsx(DiffView, { diff: request.diff });
|
|
102
|
+
case "write":
|
|
103
|
+
return _jsx(WritePreview, { path: request.path, content: request.content });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function BashPreview({ command, cwd }) {
|
|
107
|
+
const theme = useTheme();
|
|
108
|
+
const danger = classifyBashDanger(command);
|
|
109
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "$ " }), _jsx(Text, { children: command })] }), _jsxs(Text, { color: theme.muted, children: ["cwd: ", compressHome(cwd)] }), danger && (_jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: theme.warning, bold: true, children: ["\u26A0 ", danger.pattern, ":"] }), _jsxs(Text, { color: theme.warning, children: [" ", danger.message] })] }))] }));
|
|
110
|
+
}
|
|
111
|
+
const MAX_WRITE_PREVIEW_LINES = 20;
|
|
112
|
+
function WritePreview({ path, content }) {
|
|
113
|
+
const theme = useTheme();
|
|
114
|
+
const lines = content.split("\n");
|
|
115
|
+
const shown = lines.slice(0, MAX_WRITE_PREVIEW_LINES);
|
|
116
|
+
const overflow = lines.length - shown.length;
|
|
117
|
+
const totalBytes = Buffer.byteLength(content, "utf-8");
|
|
118
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: compressHome(path) }), _jsxs(Text, { color: theme.muted, children: [" \u00B7 ", lines.length, " line", lines.length === 1 ? "" : "s", " \u00B7 ", formatBytes(totalBytes)] })] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [shown.map((line, i) => (_jsxs(Text, { color: "green", children: ["+ ", line || " "] }, i))), overflow > 0 && (_jsxs(Text, { color: theme.muted, children: ["\u2026 ", overflow, " more line", overflow === 1 ? "" : "s"] }))] })] }));
|
|
119
|
+
}
|
|
120
|
+
function formatBytes(n) {
|
|
121
|
+
if (n < 1024)
|
|
122
|
+
return `${n}B`;
|
|
123
|
+
if (n < 1024 * 1024)
|
|
124
|
+
return `${(n / 1024).toFixed(1)}KB`;
|
|
125
|
+
return `${(n / (1024 * 1024)).toFixed(1)}MB`;
|
|
126
|
+
}
|
|
127
|
+
function compressHome(p) {
|
|
128
|
+
const home = process.env.HOME || "";
|
|
129
|
+
if (home && p.startsWith(home))
|
|
130
|
+
return `~${p.slice(home.length)}`;
|
|
131
|
+
return p;
|
|
132
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
interface DiffViewProps {
|
|
2
|
+
diff: string;
|
|
3
|
+
/** Hard cap on total rendered lines across all hunks. Excess is truncated. */
|
|
4
|
+
maxLines?: number;
|
|
5
|
+
}
|
|
6
|
+
export declare function DiffView({ diff, maxLines }: DiffViewProps): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { useTheme } from "../theme.js";
|
|
4
|
+
import { parseDiffHunks } from "../../approval/diff-hunks.js";
|
|
5
|
+
const DEFAULT_MAX_LINES = 40;
|
|
6
|
+
export function DiffView({ diff, maxLines = DEFAULT_MAX_LINES }) {
|
|
7
|
+
const theme = useTheme();
|
|
8
|
+
const hunks = parseDiffHunks(diff);
|
|
9
|
+
if (hunks.length === 0) {
|
|
10
|
+
return (_jsx(Text, { color: theme.muted, children: "(no diff body to display)" }));
|
|
11
|
+
}
|
|
12
|
+
// Distribute the line budget across hunks. Simple approach: render hunks in
|
|
13
|
+
// order until the budget is exhausted; if a hunk overflows, keep its header,
|
|
14
|
+
// show as many body lines as fit, then emit a "… N more" marker.
|
|
15
|
+
let remaining = maxLines;
|
|
16
|
+
const rendered = [];
|
|
17
|
+
let trailingSkipped = 0;
|
|
18
|
+
for (let i = 0; i < hunks.length; i++) {
|
|
19
|
+
const hunk = hunks[i];
|
|
20
|
+
if (remaining <= 1) {
|
|
21
|
+
trailingSkipped += hunk.lines.length + 1;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
remaining -= 1; // header line
|
|
25
|
+
const available = Math.max(0, remaining);
|
|
26
|
+
if (hunk.lines.length <= available) {
|
|
27
|
+
rendered.push({ hunk, shown: hunk.lines, truncatedBy: 0 });
|
|
28
|
+
remaining -= hunk.lines.length;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
const shown = hunk.lines.slice(0, available);
|
|
32
|
+
rendered.push({ hunk, shown, truncatedBy: hunk.lines.length - available });
|
|
33
|
+
remaining = 0;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return (_jsxs(Box, { flexDirection: "column", children: [rendered.map(({ hunk, shown, truncatedBy }, i) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.accent, children: hunk.header }), shown.map((line, j) => (_jsx(Text, { color: colorForDiffLine(line), children: line || " " }, j))), truncatedBy > 0 && (_jsxs(Text, { color: theme.muted, children: ["\u2026 ", truncatedBy, " more line", truncatedBy === 1 ? "" : "s", " in this hunk"] }))] }, i))), trailingSkipped > 0 && (_jsxs(Text, { color: theme.muted, children: ["\u2026 ", trailingSkipped, " more line", trailingSkipped === 1 ? "" : "s", " across later hunks"] }))] }));
|
|
37
|
+
}
|
|
38
|
+
function colorForDiffLine(line) {
|
|
39
|
+
if (line.startsWith("+"))
|
|
40
|
+
return "green";
|
|
41
|
+
if (line.startsWith("-"))
|
|
42
|
+
return "red";
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface ApprovalOption {
|
|
2
|
+
/** Stable identifier returned to the parent. */
|
|
3
|
+
id: string;
|
|
4
|
+
/** Primary label shown in the menu. */
|
|
5
|
+
label: string;
|
|
6
|
+
/** Dim description appended after the label. */
|
|
7
|
+
description?: string;
|
|
8
|
+
/** If true, Tab on this option turns it into a feedback input. */
|
|
9
|
+
allowAmend?: boolean;
|
|
10
|
+
/** Placeholder shown in the amend input. */
|
|
11
|
+
amendPlaceholder?: string;
|
|
12
|
+
/**
|
|
13
|
+
* If set, this option has an inline-editable data value (rendered right
|
|
14
|
+
* after `label`). When the option is focused, typing modifies the value;
|
|
15
|
+
* backspace removes the last character. Use-case: "Yes, and don't ask
|
|
16
|
+
* again for `<prefix>`" — the prefix is editable before submit.
|
|
17
|
+
*/
|
|
18
|
+
editableValue?: {
|
|
19
|
+
initial: string;
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export type ApprovalSubmit = (optionId: string, extras: {
|
|
24
|
+
feedback?: string;
|
|
25
|
+
editedValue?: string;
|
|
26
|
+
}) => void;
|
|
27
|
+
interface ApprovalSelectProps {
|
|
28
|
+
options: ApprovalOption[];
|
|
29
|
+
onSubmit: ApprovalSubmit;
|
|
30
|
+
onCancel: () => void;
|
|
31
|
+
hint?: string;
|
|
32
|
+
initialIndex?: number;
|
|
33
|
+
}
|
|
34
|
+
export declare function ApprovalSelect({ options, onSubmit, onCancel, hint, initialIndex, }: ApprovalSelectProps): import("react/jsx-runtime").JSX.Element;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { useTheme } from "../theme.js";
|
|
5
|
+
export function ApprovalSelect({ options, onSubmit, onCancel, hint, initialIndex = 0, }) {
|
|
6
|
+
const theme = useTheme();
|
|
7
|
+
const [focusIndex, setFocusIndex] = useState(Math.max(0, Math.min(initialIndex, options.length - 1)));
|
|
8
|
+
const [amending, setAmending] = useState(false);
|
|
9
|
+
const [amendText, setAmendText] = useState("");
|
|
10
|
+
// Map of option.id → current edited value. Populated lazily so navigating
|
|
11
|
+
// away and back preserves edits.
|
|
12
|
+
const [editedValues, setEditedValues] = useState(() => {
|
|
13
|
+
const seed = {};
|
|
14
|
+
for (const opt of options) {
|
|
15
|
+
if (opt.editableValue)
|
|
16
|
+
seed[opt.id] = opt.editableValue.initial;
|
|
17
|
+
}
|
|
18
|
+
return seed;
|
|
19
|
+
});
|
|
20
|
+
const focused = options[focusIndex];
|
|
21
|
+
const canAmend = !!focused?.allowAmend;
|
|
22
|
+
const hasEditableValue = !!focused?.editableValue;
|
|
23
|
+
const currentValue = focused?.editableValue ? editedValues[focused.id] ?? "" : "";
|
|
24
|
+
useInput((input, key) => {
|
|
25
|
+
if (amending) {
|
|
26
|
+
if (key.escape) {
|
|
27
|
+
setAmending(false);
|
|
28
|
+
setAmendText("");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (key.return) {
|
|
32
|
+
const editedValue = focused.editableValue ? currentValue : undefined;
|
|
33
|
+
onSubmit(focused.id, { feedback: amendText.trim() || undefined, editedValue });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (key.backspace || key.delete) {
|
|
37
|
+
setAmendText((prev) => prev.slice(0, -1));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (input) {
|
|
41
|
+
setAmendText((prev) => prev + input);
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (key.escape) {
|
|
46
|
+
onCancel();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// Vertical nav always works regardless of editable value, so the user can
|
|
50
|
+
// still move off an option they don't want.
|
|
51
|
+
if (key.upArrow) {
|
|
52
|
+
setFocusIndex((i) => (i - 1 + options.length) % options.length);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (key.downArrow) {
|
|
56
|
+
setFocusIndex((i) => (i + 1) % options.length);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (key.return) {
|
|
60
|
+
const editedValue = focused.editableValue ? currentValue : undefined;
|
|
61
|
+
onSubmit(focused.id, { editedValue });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (key.tab && canAmend) {
|
|
65
|
+
setAmending(true);
|
|
66
|
+
setAmendText("");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// When the focused option has an editable value, plain keypresses mutate it.
|
|
70
|
+
if (hasEditableValue) {
|
|
71
|
+
if (key.backspace || key.delete) {
|
|
72
|
+
setEditedValues((prev) => ({ ...prev, [focused.id]: (prev[focused.id] ?? "").slice(0, -1) }));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (input && !key.ctrl && !key.meta) {
|
|
76
|
+
setEditedValues((prev) => ({ ...prev, [focused.id]: (prev[focused.id] ?? "") + input }));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
return (_jsxs(Box, { flexDirection: "column", children: [options.map((option, idx) => {
|
|
81
|
+
const isFocused = idx === focusIndex;
|
|
82
|
+
const value = option.editableValue ? editedValues[option.id] ?? "" : undefined;
|
|
83
|
+
if (isFocused && amending) {
|
|
84
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: "› " }), _jsxs(Text, { bold: true, children: [option.label, ":"] }), _jsx(Text, { children: " " }), _jsx(Text, { color: amendText ? undefined : theme.muted, children: amendText || option.amendPlaceholder || "type feedback…" }), _jsx(Text, { backgroundColor: "white", color: "black", children: " " })] }, option.id));
|
|
85
|
+
}
|
|
86
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: isFocused ? theme.accent : theme.muted, children: isFocused ? "› " : " " }), _jsx(Text, { bold: isFocused, color: isFocused ? undefined : theme.muted, children: option.label }), option.editableValue && (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: " " }), _jsx(Text, { color: isFocused ? theme.accent : theme.muted, children: "[" }), _jsx(Text, { color: isFocused ? undefined : theme.muted, children: value || option.editableValue.placeholder || "" }), isFocused && (_jsx(Text, { backgroundColor: "white", color: "black", children: " " })), _jsx(Text, { color: isFocused ? theme.accent : theme.muted, children: "]" })] })), option.description && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: theme.muted, children: option.description })] }))] }, option.id));
|
|
87
|
+
}), hint && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: hint }) }))] }));
|
|
88
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight code highlighting for TUI using Shiki.
|
|
3
|
+
* Converts token colors to ANSI escape codes for Ink rendering.
|
|
4
|
+
*/
|
|
5
|
+
export declare function warmHighlighter(): void;
|
|
6
|
+
export declare function highlightCode(code: string, lang: string): Promise<string>;
|
|
7
|
+
export declare function highlightCodeSync(code: string, lang: string): string | null;
|
|
8
|
+
export declare function inferLang(path?: string): string;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight code highlighting for TUI using Shiki.
|
|
3
|
+
* Converts token colors to ANSI escape codes for Ink rendering.
|
|
4
|
+
*/
|
|
5
|
+
import { createHighlighter } from "shiki";
|
|
6
|
+
let highlighterPromise = null;
|
|
7
|
+
let highlighterReady = null;
|
|
8
|
+
function getHighlighter() {
|
|
9
|
+
if (!highlighterPromise) {
|
|
10
|
+
highlighterPromise = createHighlighter({
|
|
11
|
+
themes: ["github-dark"],
|
|
12
|
+
langs: [
|
|
13
|
+
"bash",
|
|
14
|
+
"css",
|
|
15
|
+
"diff",
|
|
16
|
+
"dockerfile",
|
|
17
|
+
"go",
|
|
18
|
+
"html",
|
|
19
|
+
"ini",
|
|
20
|
+
"javascript",
|
|
21
|
+
"json",
|
|
22
|
+
"jsx",
|
|
23
|
+
"markdown",
|
|
24
|
+
"python",
|
|
25
|
+
"rust",
|
|
26
|
+
"shell",
|
|
27
|
+
"sql",
|
|
28
|
+
"typescript",
|
|
29
|
+
"tsx",
|
|
30
|
+
"yaml",
|
|
31
|
+
],
|
|
32
|
+
}).then((h) => {
|
|
33
|
+
highlighterReady = h;
|
|
34
|
+
return h;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return highlighterPromise;
|
|
38
|
+
}
|
|
39
|
+
// Fire-and-forget warmup so synchronous callers can use highlightCodeSync once
|
|
40
|
+
// shiki has finished loading. Safe to call multiple times.
|
|
41
|
+
export function warmHighlighter() {
|
|
42
|
+
getHighlighter().catch(() => { });
|
|
43
|
+
}
|
|
44
|
+
function hexToAnsiFg(hex) {
|
|
45
|
+
if (!hex)
|
|
46
|
+
return "";
|
|
47
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
48
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
49
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
50
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
51
|
+
}
|
|
52
|
+
function tokensToAnsi(tokens) {
|
|
53
|
+
const lines = [];
|
|
54
|
+
for (const line of tokens) {
|
|
55
|
+
let lineStr = "";
|
|
56
|
+
for (const token of line) {
|
|
57
|
+
lineStr += hexToAnsiFg(token.color) + token.content;
|
|
58
|
+
}
|
|
59
|
+
lineStr += "\x1b[0m";
|
|
60
|
+
lines.push(lineStr);
|
|
61
|
+
}
|
|
62
|
+
return lines.join("\n");
|
|
63
|
+
}
|
|
64
|
+
function runHighlight(h, code, lang) {
|
|
65
|
+
const loaded = h.getLoadedLanguages();
|
|
66
|
+
const safeLang = loaded.includes(lang) ? lang : "text";
|
|
67
|
+
const { tokens } = h.codeToTokens(code, { lang: safeLang, theme: "github-dark" });
|
|
68
|
+
return tokensToAnsi(tokens);
|
|
69
|
+
}
|
|
70
|
+
export async function highlightCode(code, lang) {
|
|
71
|
+
const h = await getHighlighter();
|
|
72
|
+
return runHighlight(h, code, lang);
|
|
73
|
+
}
|
|
74
|
+
// Synchronous variant that returns null when shiki hasn't finished loading yet.
|
|
75
|
+
// Used by code paths that render into Ink's <Static> (which only paints once)
|
|
76
|
+
// so the first frame can already carry highlighted output.
|
|
77
|
+
export function highlightCodeSync(code, lang) {
|
|
78
|
+
if (!highlighterReady) {
|
|
79
|
+
// Ensure warmup is in flight for future renders.
|
|
80
|
+
warmHighlighter();
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
return runHighlight(highlighterReady, code, lang);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const LANG_MAP = {
|
|
91
|
+
bash: "bash",
|
|
92
|
+
css: "css",
|
|
93
|
+
diff: "diff",
|
|
94
|
+
dockerfile: "dockerfile",
|
|
95
|
+
go: "go",
|
|
96
|
+
html: "html",
|
|
97
|
+
htm: "html",
|
|
98
|
+
ini: "ini",
|
|
99
|
+
cfg: "ini",
|
|
100
|
+
toml: "ini",
|
|
101
|
+
js: "javascript",
|
|
102
|
+
json: "json",
|
|
103
|
+
jsx: "jsx",
|
|
104
|
+
md: "markdown",
|
|
105
|
+
mdx: "markdown",
|
|
106
|
+
py: "python",
|
|
107
|
+
rs: "rust",
|
|
108
|
+
sh: "bash",
|
|
109
|
+
shell: "shell",
|
|
110
|
+
sql: "sql",
|
|
111
|
+
ts: "typescript",
|
|
112
|
+
tsx: "tsx",
|
|
113
|
+
yaml: "yaml",
|
|
114
|
+
yml: "yaml",
|
|
115
|
+
zsh: "bash",
|
|
116
|
+
};
|
|
117
|
+
export function inferLang(path) {
|
|
118
|
+
if (!path)
|
|
119
|
+
return "text";
|
|
120
|
+
const ext = path.split(".").pop()?.toLowerCase();
|
|
121
|
+
return (ext && LANG_MAP[ext]) || "text";
|
|
122
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect whether the host terminal is using a light or dark background so we
|
|
3
|
+
* can pick a sensible default palette when the user has theme set to "auto".
|
|
4
|
+
*
|
|
5
|
+
* Resolution order:
|
|
6
|
+
* 1. `COLORFGBG` env var — synchronous, set by VTE-family terminals (GNOME
|
|
7
|
+
* Terminal, Konsole) and iTerm2 (when enabled). Format is "fg;bg" or
|
|
8
|
+
* "fg;aux;bg" with each value being an ANSI color index 0–15.
|
|
9
|
+
* 2. OSC 11 query — write `ESC ] 11 ; ? BEL`, listen on stdin for a reply
|
|
10
|
+
* shaped like `ESC ] 11 ; rgb:RRRR/GGGG/BBBB BEL`. Capped at ~150 ms so
|
|
11
|
+
* we don't stall startup on terminals that swallow the query.
|
|
12
|
+
* 3. Fallback to "dark" — most coding terminals are dark, so this is the
|
|
13
|
+
* least surprising default when detection fails.
|
|
14
|
+
*
|
|
15
|
+
* Must run BEFORE Ink's `render()` takes over stdin. Ink puts stdin into raw
|
|
16
|
+
* mode and consumes input itself, so the OSC 11 reply would never reach us.
|
|
17
|
+
*/
|
|
18
|
+
import type { ResolvedTheme } from "./theme.js";
|
|
19
|
+
export declare function detectTerminalTheme(timeoutMs?: number): Promise<ResolvedTheme>;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect whether the host terminal is using a light or dark background so we
|
|
3
|
+
* can pick a sensible default palette when the user has theme set to "auto".
|
|
4
|
+
*
|
|
5
|
+
* Resolution order:
|
|
6
|
+
* 1. `COLORFGBG` env var — synchronous, set by VTE-family terminals (GNOME
|
|
7
|
+
* Terminal, Konsole) and iTerm2 (when enabled). Format is "fg;bg" or
|
|
8
|
+
* "fg;aux;bg" with each value being an ANSI color index 0–15.
|
|
9
|
+
* 2. OSC 11 query — write `ESC ] 11 ; ? BEL`, listen on stdin for a reply
|
|
10
|
+
* shaped like `ESC ] 11 ; rgb:RRRR/GGGG/BBBB BEL`. Capped at ~150 ms so
|
|
11
|
+
* we don't stall startup on terminals that swallow the query.
|
|
12
|
+
* 3. Fallback to "dark" — most coding terminals are dark, so this is the
|
|
13
|
+
* least surprising default when detection fails.
|
|
14
|
+
*
|
|
15
|
+
* Must run BEFORE Ink's `render()` takes over stdin. Ink puts stdin into raw
|
|
16
|
+
* mode and consumes input itself, so the OSC 11 reply would never reach us.
|
|
17
|
+
*/
|
|
18
|
+
export async function detectTerminalTheme(timeoutMs = 150) {
|
|
19
|
+
const fromEnv = parseColorFgBg(process.env.COLORFGBG);
|
|
20
|
+
if (fromEnv)
|
|
21
|
+
return fromEnv;
|
|
22
|
+
if (process.stdout.isTTY && process.stdin.isTTY) {
|
|
23
|
+
const fromOsc = await queryOsc11(timeoutMs);
|
|
24
|
+
if (fromOsc)
|
|
25
|
+
return fromOsc;
|
|
26
|
+
}
|
|
27
|
+
return "dark";
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* COLORFGBG examples:
|
|
31
|
+
* "15;0" → bright-white fg on black bg → dark
|
|
32
|
+
* "0;15" → black fg on bright-white bg → light
|
|
33
|
+
* "15;default;0" → some terminals add a default-bg sentinel in the middle.
|
|
34
|
+
*
|
|
35
|
+
* ANSI indices 0–6 are typically dark (black, red, green, yellow, blue,
|
|
36
|
+
* magenta, cyan); 7–15 are typically light (gray-to-white-ish). 7 itself
|
|
37
|
+
* (white) is ambiguous on some terminals but more often points to light.
|
|
38
|
+
*/
|
|
39
|
+
function parseColorFgBg(value) {
|
|
40
|
+
if (!value)
|
|
41
|
+
return null;
|
|
42
|
+
const parts = value.split(";");
|
|
43
|
+
const last = parts[parts.length - 1];
|
|
44
|
+
if (!last)
|
|
45
|
+
return null;
|
|
46
|
+
const bg = parseInt(last, 10);
|
|
47
|
+
if (Number.isNaN(bg))
|
|
48
|
+
return null;
|
|
49
|
+
if (bg >= 0 && bg <= 6)
|
|
50
|
+
return "dark";
|
|
51
|
+
if (bg >= 7 && bg <= 15)
|
|
52
|
+
return "light";
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
function queryOsc11(timeoutMs) {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
const stdin = process.stdin;
|
|
58
|
+
const stdout = process.stdout;
|
|
59
|
+
let settled = false;
|
|
60
|
+
const originalRaw = stdin.isRaw;
|
|
61
|
+
let buffer = "";
|
|
62
|
+
const cleanup = () => {
|
|
63
|
+
stdin.removeListener("data", onData);
|
|
64
|
+
try {
|
|
65
|
+
stdin.setRawMode(originalRaw);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// ignore — terminal may have already restored
|
|
69
|
+
}
|
|
70
|
+
stdin.pause();
|
|
71
|
+
};
|
|
72
|
+
const finish = (result) => {
|
|
73
|
+
if (settled)
|
|
74
|
+
return;
|
|
75
|
+
settled = true;
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
cleanup();
|
|
78
|
+
resolve(result);
|
|
79
|
+
};
|
|
80
|
+
const onData = (chunk) => {
|
|
81
|
+
buffer += chunk.toString("utf8");
|
|
82
|
+
// Match `ESC ] 11 ; rgb:RRRR/GGGG/BBBB ST` where ST is BEL (\x07) or
|
|
83
|
+
// ESC \\. Some terminals reply with shorter hex (rgb:rr/gg/bb).
|
|
84
|
+
const match = buffer.match(/\x1b\]11;rgb:([0-9a-fA-F]+)\/([0-9a-fA-F]+)\/([0-9a-fA-F]+)(?:\x07|\x1b\\)/);
|
|
85
|
+
if (!match)
|
|
86
|
+
return;
|
|
87
|
+
const [, r, g, b] = match;
|
|
88
|
+
const lum = relativeLuminance(parseHexChannel(r), parseHexChannel(g), parseHexChannel(b));
|
|
89
|
+
finish(lum > 0.5 ? "light" : "dark");
|
|
90
|
+
};
|
|
91
|
+
try {
|
|
92
|
+
stdin.setRawMode(true);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
resolve(null);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
stdin.resume();
|
|
99
|
+
stdin.on("data", onData);
|
|
100
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
101
|
+
try {
|
|
102
|
+
stdout.write("\x1b]11;?\x07");
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
finish(null);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/** Normalize a hex channel string of arbitrary length to a 0–1 float. */
|
|
110
|
+
function parseHexChannel(hex) {
|
|
111
|
+
const max = (1 << (hex.length * 4)) - 1;
|
|
112
|
+
return parseInt(hex, 16) / max;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* sRGB relative luminance per WCAG 2.x. Output range is 0 (black) to 1 (white).
|
|
116
|
+
* We treat ≥ 0.5 as "light"; the actual threshold is forgiving because real
|
|
117
|
+
* terminal backgrounds tend to be near-pure black (≈0.0) or near-pure white
|
|
118
|
+
* (≈1.0).
|
|
119
|
+
*/
|
|
120
|
+
function relativeLuminance(r, g, b) {
|
|
121
|
+
const channel = (c) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
122
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
123
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ToolResultMetadata } from "../types.js";
|
|
2
|
+
export interface DisplayMessage {
|
|
3
|
+
/** Stable identity, used as Static/list key. Generated by the UI layer. */
|
|
4
|
+
key?: string;
|
|
5
|
+
role: "user" | "assistant" | "error";
|
|
6
|
+
content: string;
|
|
7
|
+
reasoning?: string;
|
|
8
|
+
toolCalls?: DisplayToolCall[];
|
|
9
|
+
parts?: DisplayMessagePart[];
|
|
10
|
+
syntheticKind?: "ui_summary";
|
|
11
|
+
hiddenCount?: number;
|
|
12
|
+
}
|
|
13
|
+
export type DisplayMessagePart = DisplayTextPart | DisplayToolsPart;
|
|
14
|
+
export interface DisplayTextPart {
|
|
15
|
+
type: "text";
|
|
16
|
+
content: string;
|
|
17
|
+
}
|
|
18
|
+
export interface DisplayToolsPart {
|
|
19
|
+
type: "tools";
|
|
20
|
+
toolCalls: DisplayToolCall[];
|
|
21
|
+
}
|
|
22
|
+
export interface DisplayToolCall {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
args: Record<string, any>;
|
|
26
|
+
result?: string;
|
|
27
|
+
isError?: boolean;
|
|
28
|
+
metadata?: ToolResultMetadata;
|
|
29
|
+
/** Set when the tool_start event was received. Used to render elapsed time. */
|
|
30
|
+
startedAt?: number;
|
|
31
|
+
}
|
|
32
|
+
export declare function nextDisplayMessageKey(prefix?: string): string;
|
|
33
|
+
export declare function appendTextPart(parts: DisplayMessagePart[], content: string): void;
|
|
34
|
+
export declare function appendToolPart(parts: DisplayMessagePart[], toolCall: DisplayToolCall): void;
|
|
35
|
+
export declare function snapshotDisplayParts(parts: DisplayMessagePart[]): DisplayMessagePart[];
|
|
36
|
+
export declare function contentFromParts(parts: DisplayMessagePart[]): string;
|
|
37
|
+
export declare function toolCallsFromParts(parts: DisplayMessagePart[]): DisplayToolCall[];
|
|
38
|
+
export declare function compactDisplayMessages(messages: DisplayMessage[]): DisplayMessage[];
|