@bubblebrain-ai/bubble 0.0.8 → 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 +12 -0
- package/dist/agent.js +152 -13
- package/dist/config.d.ts +23 -3
- package/dist/config.js +59 -6
- package/dist/context/budget.d.ts +3 -3
- 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.js +9 -9
- package/dist/main.js +43 -6
- package/dist/model-catalog.d.ts +9 -0
- package/dist/model-catalog.js +16 -0
- package/dist/orchestrator/default-hooks.js +18 -0
- package/dist/provider-openai-codex.d.ts +13 -2
- package/dist/provider-openai-codex.js +81 -32
- package/dist/provider-registry.js +20 -4
- package/dist/slash-commands/commands.js +24 -0
- package/dist/slash-commands/types.d.ts +7 -0
- package/dist/tools/agent-lifecycle.js +22 -4
- package/dist/tools/edit.js +2 -2
- 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.js +117 -5
- package/dist/tools/write.js +3 -2
- package/dist/tui-ink/app.d.ts +11 -2
- package/dist/tui-ink/app.js +191 -78
- package/dist/tui-ink/approval/approval-dialog.js +4 -1
- package/dist/tui-ink/approval/diff-view.js +2 -1
- package/dist/tui-ink/approval/select.js +2 -1
- package/dist/tui-ink/code-highlight.d.ts +2 -0
- package/dist/tui-ink/code-highlight.js +30 -2
- package/dist/tui-ink/detect-theme.d.ts +19 -0
- package/dist/tui-ink/detect-theme.js +123 -0
- package/dist/tui-ink/footer.js +4 -3
- package/dist/tui-ink/input-box.js +83 -26
- package/dist/tui-ink/input-history.d.ts +16 -0
- package/dist/tui-ink/input-history.js +81 -0
- package/dist/tui-ink/markdown.js +30 -20
- package/dist/tui-ink/message-list.js +112 -16
- package/dist/tui-ink/model-picker.js +6 -1
- package/dist/tui-ink/plan-confirm.js +2 -1
- package/dist/tui-ink/question-dialog.js +2 -1
- package/dist/tui-ink/run.d.ts +5 -1
- package/dist/tui-ink/run.js +30 -2
- package/dist/tui-ink/theme.d.ts +64 -35
- package/dist/tui-ink/theme.js +81 -8
- package/dist/tui-ink/todos.js +5 -3
- package/dist/tui-ink/trace-groups.d.ts +3 -1
- package/dist/tui-ink/trace-groups.js +93 -14
- package/dist/tui-ink/welcome.js +23 -4
- package/dist/types.d.ts +6 -0
- package/package.json +2 -1
|
@@ -2,5 +2,7 @@
|
|
|
2
2
|
* Lightweight code highlighting for TUI using Shiki.
|
|
3
3
|
* Converts token colors to ANSI escape codes for Ink rendering.
|
|
4
4
|
*/
|
|
5
|
+
export declare function warmHighlighter(): void;
|
|
5
6
|
export declare function highlightCode(code: string, lang: string): Promise<string>;
|
|
7
|
+
export declare function highlightCodeSync(code: string, lang: string): string | null;
|
|
6
8
|
export declare function inferLang(path?: string): string;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { createHighlighter } from "shiki";
|
|
6
6
|
let highlighterPromise = null;
|
|
7
|
+
let highlighterReady = null;
|
|
7
8
|
function getHighlighter() {
|
|
8
9
|
if (!highlighterPromise) {
|
|
9
10
|
highlighterPromise = createHighlighter({
|
|
@@ -28,10 +29,18 @@ function getHighlighter() {
|
|
|
28
29
|
"tsx",
|
|
29
30
|
"yaml",
|
|
30
31
|
],
|
|
32
|
+
}).then((h) => {
|
|
33
|
+
highlighterReady = h;
|
|
34
|
+
return h;
|
|
31
35
|
});
|
|
32
36
|
}
|
|
33
37
|
return highlighterPromise;
|
|
34
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
|
+
}
|
|
35
44
|
function hexToAnsiFg(hex) {
|
|
36
45
|
if (!hex)
|
|
37
46
|
return "";
|
|
@@ -52,13 +61,32 @@ function tokensToAnsi(tokens) {
|
|
|
52
61
|
}
|
|
53
62
|
return lines.join("\n");
|
|
54
63
|
}
|
|
55
|
-
|
|
56
|
-
const h = await getHighlighter();
|
|
64
|
+
function runHighlight(h, code, lang) {
|
|
57
65
|
const loaded = h.getLoadedLanguages();
|
|
58
66
|
const safeLang = loaded.includes(lang) ? lang : "text";
|
|
59
67
|
const { tokens } = h.codeToTokens(code, { lang: safeLang, theme: "github-dark" });
|
|
60
68
|
return tokensToAnsi(tokens);
|
|
61
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
|
+
}
|
|
62
90
|
const LANG_MAP = {
|
|
63
91
|
bash: "bash",
|
|
64
92
|
css: "css",
|
|
@@ -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
|
+
}
|
package/dist/tui-ink/footer.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import {
|
|
4
|
+
import { useTheme } from "./theme.js";
|
|
5
5
|
import { PERMISSION_MODE_INFO } from "../permission/mode.js";
|
|
6
6
|
export function FooterBar({ data }) {
|
|
7
|
+
const theme = useTheme();
|
|
7
8
|
const usageText = data.usageTotals.prompt || data.usageTotals.completion
|
|
8
9
|
? `↑${formatTokens(data.usageTotals.prompt)} ↓${formatTokens(data.usageTotals.completion)}`
|
|
9
10
|
: "";
|
|
@@ -12,10 +13,10 @@ export function FooterBar({ data }) {
|
|
|
12
13
|
? ` • ⌃R ${data.thinkingLevel}`
|
|
13
14
|
: " • ⌃R off"
|
|
14
15
|
: "";
|
|
15
|
-
|
|
16
|
-
return (_jsxs(Box, { paddingX: 1, flexShrink: 0, children: [_jsx(Text, { color: theme.muted, children: formatCwd(data.cwd) }), usageText && (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: " " }), _jsx(Text, { color: theme.muted, dimColor: true, children: usageText })] })), _jsx(ModeBadge, { mode: data.mode }), _jsx(Box, { flexGrow: 1 }), _jsx(Text, { color: theme.muted, children: data.providerId }), _jsx(Text, { color: theme.muted, children: " \u2022 " }), _jsx(Text, { color: theme.toolName, children: data.model }), _jsxs(Text, { color: theme.muted, dimColor: true, children: [thinkingText, traceText] })] }));
|
|
16
|
+
return (_jsxs(Box, { paddingX: 1, flexShrink: 0, children: [_jsx(Text, { color: theme.muted, children: formatCwd(data.cwd) }), usageText && (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: " " }), _jsx(Text, { color: theme.muted, dimColor: true, children: usageText })] })), _jsx(ModeBadge, { mode: data.mode }), _jsx(Box, { flexGrow: 1 }), _jsx(Text, { color: theme.muted, children: data.providerId }), _jsx(Text, { color: theme.muted, children: " \u2022 " }), _jsx(Text, { color: theme.toolName, children: data.model }), _jsx(Text, { color: theme.muted, dimColor: true, children: thinkingText })] }));
|
|
17
17
|
}
|
|
18
18
|
function ModeBadge({ mode }) {
|
|
19
|
+
const theme = useTheme();
|
|
19
20
|
if (!mode || mode === "default")
|
|
20
21
|
return null;
|
|
21
22
|
const info = PERMISSION_MODE_INFO[mode];
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { Box, Text, useCursor, useInput, usePaste, useStdout } from "ink";
|
|
4
4
|
import stringWidth from "string-width";
|
|
5
5
|
import { appendFileSync } from "node:fs";
|
|
6
6
|
import { registry as slashRegistry } from "../slash-commands/index.js";
|
|
7
|
-
import {
|
|
7
|
+
import { useTheme } from "./theme.js";
|
|
8
8
|
import { filterFileSuggestions, findAtContext, listProjectFiles } from "./file-mentions.js";
|
|
9
9
|
import { ingestClipboardImage, ingestImagePath, isImageFilePath, isScreenshotTempPath, splitPastedPaths, } from "./image-paste.js";
|
|
10
|
-
|
|
11
|
-
const
|
|
10
|
+
import { appendHistoryEntry, loadHistorySync, pushHistoryEntry, stepHistory, } from "./input-history.js";
|
|
11
|
+
const MIN_VISIBLE_LINES = 3;
|
|
12
|
+
const MAX_VISIBLE_LINES = 6;
|
|
12
13
|
const PADDING_X = 1;
|
|
13
|
-
const PROMPT = "> ";
|
|
14
|
+
const PROMPT = " > ";
|
|
14
15
|
const MAX_VISIBLE_SUGGESTIONS = 8;
|
|
15
16
|
export function needsCursorRowCompensation(nextOutputHeight, viewportRows, previousOutputHeight) {
|
|
16
17
|
const hadPreviousFrame = previousOutputHeight !== null && previousOutputHeight > 0;
|
|
@@ -137,12 +138,16 @@ export function insertNewlineAtCursor(text, cursor) {
|
|
|
137
138
|
};
|
|
138
139
|
}
|
|
139
140
|
export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, terminalColumns, cwd }) {
|
|
141
|
+
const theme = useTheme();
|
|
140
142
|
const width = terminalColumns;
|
|
141
143
|
const [text, setText] = useState("");
|
|
142
144
|
const [cursor, setCursor] = useState(0);
|
|
143
145
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
144
146
|
const [projectFiles, setProjectFiles] = useState(null);
|
|
145
147
|
const [attachments, setAttachments] = useState([]);
|
|
148
|
+
const [history, setHistory] = useState(() => loadHistorySync());
|
|
149
|
+
const [historyIndex, setHistoryIndex] = useState(null);
|
|
150
|
+
const historyDraftRef = useRef("");
|
|
146
151
|
const loadingFilesRef = useRef(false);
|
|
147
152
|
// Paste and the keystrokes that follow can arrive inside the same stdin chunk
|
|
148
153
|
// and dispatch within one discreteUpdates batch. If the Enter that a user
|
|
@@ -248,8 +253,15 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
|
|
|
248
253
|
}, 0);
|
|
249
254
|
};
|
|
250
255
|
// Strip orphaned focus-event tails that can appear if focus reporting
|
|
251
|
-
// splits across the paste boundary.
|
|
252
|
-
|
|
256
|
+
// splits across the paste boundary. Bracketed paste also delivers line
|
|
257
|
+
// breaks as bare CR on many terminals; left as-is, those CRs survive into
|
|
258
|
+
// the rendered Text and the terminal interprets them as "return to column
|
|
259
|
+
// 0", visually overwriting earlier characters even though the underlying
|
|
260
|
+
// state still holds the full paste.
|
|
261
|
+
const clean = pasted
|
|
262
|
+
.replace(/\x1b\[I$/, "")
|
|
263
|
+
.replace(/\x1b\[O$/, "")
|
|
264
|
+
.replace(/\r\n?/g, "\n");
|
|
253
265
|
// Empty paste on macOS usually means "Cmd+V with an image on the clipboard".
|
|
254
266
|
if (clean.length === 0) {
|
|
255
267
|
if (process.platform === "darwin") {
|
|
@@ -324,10 +336,19 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
|
|
|
324
336
|
if (submittedText.trim().length === 0 && attachments.length === 0)
|
|
325
337
|
return;
|
|
326
338
|
onSubmit({ text: submittedText, images: attachments });
|
|
339
|
+
if (submittedText.trim().length > 0) {
|
|
340
|
+
const nextHistory = pushHistoryEntry(history, submittedText);
|
|
341
|
+
if (nextHistory !== history) {
|
|
342
|
+
setHistory(nextHistory);
|
|
343
|
+
appendHistoryEntry(submittedText);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
327
346
|
setText("");
|
|
328
347
|
setCursor(0);
|
|
329
348
|
setSelectedIndex(0);
|
|
330
349
|
setAttachments([]);
|
|
350
|
+
setHistoryIndex(null);
|
|
351
|
+
historyDraftRef.current = "";
|
|
331
352
|
};
|
|
332
353
|
const applySlashEnterAction = (submittedText) => {
|
|
333
354
|
const action = resolveSlashEnterAction(submittedText, slashSuggestions, selectedIndex);
|
|
@@ -460,12 +481,30 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
|
|
|
460
481
|
if (key.upArrow) {
|
|
461
482
|
if (cursorVisualRow > 0) {
|
|
462
483
|
setCursor(visualToCursor(visualLines, cursorVisualRow - 1, cursorVisualCol));
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const result = stepHistory({ history, index: historyIndex, draft: historyDraftRef.current }, "up", text);
|
|
487
|
+
if (result.changed) {
|
|
488
|
+
setText(result.text);
|
|
489
|
+
setCursor(result.text.length);
|
|
490
|
+
setHistoryIndex(result.index);
|
|
491
|
+
historyDraftRef.current = result.draft;
|
|
492
|
+
setSelectedIndex(0);
|
|
463
493
|
}
|
|
464
494
|
return;
|
|
465
495
|
}
|
|
466
496
|
if (key.downArrow) {
|
|
467
497
|
if (cursorVisualRow < visualLines.length - 1) {
|
|
468
498
|
setCursor(visualToCursor(visualLines, cursorVisualRow + 1, cursorVisualCol));
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const result = stepHistory({ history, index: historyIndex, draft: historyDraftRef.current }, "down", text);
|
|
502
|
+
if (result.changed) {
|
|
503
|
+
setText(result.text);
|
|
504
|
+
setCursor(result.text.length);
|
|
505
|
+
setHistoryIndex(result.index);
|
|
506
|
+
historyDraftRef.current = result.draft;
|
|
507
|
+
setSelectedIndex(0);
|
|
469
508
|
}
|
|
470
509
|
return;
|
|
471
510
|
}
|
|
@@ -500,14 +539,25 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
|
|
|
500
539
|
scrollOffset = Math.min(Math.max(cursorVisualRow - Math.floor(visibleLines / 2), 0), totalLines - visibleLines);
|
|
501
540
|
}
|
|
502
541
|
const displayedLines = [];
|
|
503
|
-
|
|
542
|
+
const topPadLines = totalLines < visibleLines
|
|
543
|
+
? Math.floor((visibleLines - totalLines) / 2)
|
|
544
|
+
: 0;
|
|
545
|
+
for (let i = 0; i < topPadLines; i++) {
|
|
546
|
+
displayedLines.push({ kind: "pad", key: `top-${i}` });
|
|
547
|
+
}
|
|
548
|
+
const contentLineCount = Math.min(totalLines, visibleLines - topPadLines);
|
|
549
|
+
for (let i = 0; i < contentLineCount; i++) {
|
|
504
550
|
const visualIdx = scrollOffset + i;
|
|
505
551
|
const vl = visualLines[visualIdx];
|
|
506
552
|
displayedLines.push({
|
|
553
|
+
kind: "content",
|
|
507
554
|
text: vl ? vl.text : "",
|
|
508
555
|
visualIdx,
|
|
509
556
|
});
|
|
510
557
|
}
|
|
558
|
+
while (displayedLines.length < visibleLines) {
|
|
559
|
+
displayedLines.push({ kind: "pad", key: `bottom-${displayedLines.length}` });
|
|
560
|
+
}
|
|
511
561
|
const hasMoreAbove = scrollOffset > 0;
|
|
512
562
|
const hasMoreBelow = scrollOffset + visibleLines < totalLines;
|
|
513
563
|
const inputFrameSignature = [
|
|
@@ -601,31 +651,38 @@ export function InputBox({ onSubmit, onPasteNotice, disabled, skillRegistry, ter
|
|
|
601
651
|
});
|
|
602
652
|
// Reference cursorTick so the effect re-runs on the forced render pass.
|
|
603
653
|
void cursorTick;
|
|
604
|
-
const
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
: borderChar.repeat(contentWidth);
|
|
654
|
+
const inputBg = disabled ? theme.inputBgDisabled : theme.inputBg;
|
|
655
|
+
const moreBelow = totalLines - scrollOffset - visibleLines;
|
|
656
|
+
const filledLine = (value) => {
|
|
657
|
+
const visibleWidth = stringWidth(value);
|
|
658
|
+
return value + " ".repeat(Math.max(0, contentWidth - visibleWidth));
|
|
659
|
+
};
|
|
611
660
|
return (_jsxs(Box, { flexDirection: "column", children: [attachments.length > 0 && (_jsx(Box, { flexDirection: "row", flexWrap: "wrap", paddingX: PADDING_X, marginBottom: 0, children: attachments.map((att, i) => {
|
|
612
661
|
const label = att.filename || "clipboard";
|
|
613
662
|
const kb = Math.max(1, Math.round(att.bytes / 1024));
|
|
614
663
|
return (_jsx(Box, { marginRight: 1, children: _jsx(Text, { color: theme.accent, children: `[img${attachments.length > 1 ? ` ${i + 1}` : ""}: ${label} · ${kb}KB]` }) }, i));
|
|
615
|
-
}) })),
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
664
|
+
}) })), _jsxs(Box, { flexDirection: "column", paddingX: PADDING_X, children: [hasMoreAbove && (_jsx(Text, { backgroundColor: inputBg, color: theme.muted, dimColor: true, children: filledLine(` ↑ ${scrollOffset} more`) })), displayedLines.map((row) => {
|
|
665
|
+
if (row.kind === "pad") {
|
|
666
|
+
return (_jsx(Text, { backgroundColor: inputBg, children: " ".repeat(contentWidth) }, row.key));
|
|
667
|
+
}
|
|
668
|
+
const { text: line, visualIdx } = row;
|
|
669
|
+
const lineText = line.length === 0 ? " " : line;
|
|
670
|
+
const isFirst = visualIdx === 0;
|
|
671
|
+
const isCursorLine = visualIdx === cursorVisualRow;
|
|
672
|
+
const prompt = isFirst ? PROMPT : " ".repeat(PROMPT.length);
|
|
673
|
+
const fill = " ".repeat(Math.max(0, lineWidth - stringWidth(lineText)));
|
|
674
|
+
return (_jsxs(Box, { height: 1, overflow: "hidden", ref: isCursorLine
|
|
675
|
+
? (el) => {
|
|
676
|
+
cursorLineRef.current = el;
|
|
677
|
+
}
|
|
678
|
+
: undefined, children: [_jsx(Text, { backgroundColor: inputBg, color: isFirst ? theme.accent : theme.inputText, children: prompt }), _jsx(Text, { backgroundColor: inputBg, color: theme.inputText, children: lineText }), _jsx(Text, { backgroundColor: inputBg, children: fill })] }, visualIdx));
|
|
679
|
+
}), hasMoreBelow && (_jsx(Text, { backgroundColor: inputBg, color: theme.muted, dimColor: true, children: filledLine(` ↓ ${moreBelow} more`) }))] }), showSuggestions && mode === "slash" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 4, children: [slashSuggestions
|
|
625
680
|
.slice(suggestionOffset, suggestionOffset + MAX_VISIBLE_SUGGESTIONS)
|
|
626
681
|
.map((cmd, visibleIndex) => {
|
|
627
682
|
const i = suggestionOffset + visibleIndex;
|
|
628
|
-
|
|
683
|
+
const label = `/${cmd.name}`.padEnd(17);
|
|
684
|
+
const isSelected = i === selectedIndex;
|
|
685
|
+
return (_jsx(Box, { height: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: isSelected ? theme.accent : theme.muted, bold: isSelected, children: label }), cmd.type === "skill" && _jsx(Text, { color: theme.muted, children: " [skill]" }), _jsxs(Text, { dimColor: true, children: [" ", cmd.description] })] }) }, cmd.name));
|
|
629
686
|
}), slashSuggestions.length > MAX_VISIBLE_SUGGESTIONS && (_jsx(Text, { color: theme.muted, children: `Showing ${suggestionOffset + 1}-${Math.min(suggestionOffset + MAX_VISIBLE_SUGGESTIONS, slashSuggestions.length)} of ${slashSuggestions.length}` }))] })), showSuggestions && mode === "file" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [projectFiles === null && _jsx(Text, { dimColor: true, children: "Loading project files\u2026" }), projectFiles !== null && fileSuggestions.length === 0 && (_jsxs(Text, { dimColor: true, children: ["No files match \"", atContext?.query ?? "", "\""] })), fileSuggestions
|
|
630
687
|
.slice(suggestionOffset, suggestionOffset + MAX_VISIBLE_SUGGESTIONS)
|
|
631
688
|
.map((s, visibleIndex) => {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare function defaultHistoryFilePath(): string;
|
|
2
|
+
export declare function loadHistorySync(filePath?: string): string[];
|
|
3
|
+
export declare function appendHistoryEntry(entry: string, filePath?: string): void;
|
|
4
|
+
export interface HistoryNavState {
|
|
5
|
+
history: string[];
|
|
6
|
+
index: number | null;
|
|
7
|
+
draft: string;
|
|
8
|
+
}
|
|
9
|
+
export interface HistoryNavResult {
|
|
10
|
+
text: string;
|
|
11
|
+
index: number | null;
|
|
12
|
+
draft: string;
|
|
13
|
+
changed: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function stepHistory(state: HistoryNavState, direction: "up" | "down", currentText: string): HistoryNavResult;
|
|
16
|
+
export declare function pushHistoryEntry(history: string[], entry: string): string[];
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { getBubbleHome } from "../bubble-home.js";
|
|
4
|
+
const MAX_HISTORY_ENTRIES = 1000;
|
|
5
|
+
export function defaultHistoryFilePath() {
|
|
6
|
+
return join(getBubbleHome(), "input-history.jsonl");
|
|
7
|
+
}
|
|
8
|
+
// JSONL on disk: each line is a JSON-encoded string. JSON encoding handles
|
|
9
|
+
// embedded newlines and quotes so multi-line composer entries round-trip safely.
|
|
10
|
+
export function loadHistorySync(filePath = defaultHistoryFilePath()) {
|
|
11
|
+
try {
|
|
12
|
+
if (!existsSync(filePath))
|
|
13
|
+
return [];
|
|
14
|
+
const raw = readFileSync(filePath, "utf8");
|
|
15
|
+
const out = [];
|
|
16
|
+
for (const line of raw.split("\n")) {
|
|
17
|
+
if (!line)
|
|
18
|
+
continue;
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(line);
|
|
21
|
+
if (typeof parsed === "string" && parsed.length > 0)
|
|
22
|
+
out.push(parsed);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Malformed line — skip rather than fail the whole load.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return out.length > MAX_HISTORY_ENTRIES ? out.slice(-MAX_HISTORY_ENTRIES) : out;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function appendHistoryEntry(entry, filePath = defaultHistoryFilePath()) {
|
|
35
|
+
if (!entry || entry.trim().length === 0)
|
|
36
|
+
return;
|
|
37
|
+
try {
|
|
38
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
39
|
+
appendFileSync(filePath, JSON.stringify(entry) + "\n", "utf8");
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Persistence is best-effort; never crash the composer over disk IO.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Pure transition for ↑/↓ navigation. `index === null` means the user is
|
|
46
|
+
// editing a fresh draft; otherwise it points at history[index]. When stepping
|
|
47
|
+
// from the draft into history we snapshot the current text so ↓ past the
|
|
48
|
+
// newest entry can restore it.
|
|
49
|
+
export function stepHistory(state, direction, currentText) {
|
|
50
|
+
const { history, index, draft } = state;
|
|
51
|
+
const noChange = { text: currentText, index, draft, changed: false };
|
|
52
|
+
if (direction === "up") {
|
|
53
|
+
if (history.length === 0)
|
|
54
|
+
return noChange;
|
|
55
|
+
if (index === null) {
|
|
56
|
+
const newIdx = history.length - 1;
|
|
57
|
+
return { text: history[newIdx], index: newIdx, draft: currentText, changed: true };
|
|
58
|
+
}
|
|
59
|
+
if (index > 0) {
|
|
60
|
+
return { text: history[index - 1], index: index - 1, draft, changed: true };
|
|
61
|
+
}
|
|
62
|
+
return noChange;
|
|
63
|
+
}
|
|
64
|
+
// down
|
|
65
|
+
if (index === null)
|
|
66
|
+
return noChange;
|
|
67
|
+
if (index < history.length - 1) {
|
|
68
|
+
return { text: history[index + 1], index: index + 1, draft, changed: true };
|
|
69
|
+
}
|
|
70
|
+
// Past the newest entry: restore the saved draft and clear it.
|
|
71
|
+
return { text: draft, index: null, draft: "", changed: true };
|
|
72
|
+
}
|
|
73
|
+
// Push to in-memory history with last-entry dedupe so repeated identical
|
|
74
|
+
// submissions don't spam the stack.
|
|
75
|
+
export function pushHistoryEntry(history, entry) {
|
|
76
|
+
if (!entry || entry.trim().length === 0)
|
|
77
|
+
return history;
|
|
78
|
+
if (history.length > 0 && history[history.length - 1] === entry)
|
|
79
|
+
return history;
|
|
80
|
+
return [...history, entry];
|
|
81
|
+
}
|
package/dist/tui-ink/markdown.js
CHANGED
|
@@ -7,8 +7,8 @@ import React from "react";
|
|
|
7
7
|
import { Box, Text } from "ink";
|
|
8
8
|
import stringWidth from "string-width";
|
|
9
9
|
import { useTerminalSize } from "./use-terminal-size.js";
|
|
10
|
-
import {
|
|
11
|
-
import { highlightCode } from "./code-highlight.js";
|
|
10
|
+
import { useTheme } from "./theme.js";
|
|
11
|
+
import { highlightCode, highlightCodeSync } from "./code-highlight.js";
|
|
12
12
|
const graphemeSegmenter = typeof Intl !== "undefined" && typeof Intl.Segmenter === "function"
|
|
13
13
|
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
14
14
|
: null;
|
|
@@ -261,31 +261,40 @@ function InlineText({ text }) {
|
|
|
261
261
|
return _jsx(Text, { children: renderInlineSegments(text, "inline") });
|
|
262
262
|
}
|
|
263
263
|
function CodeBlock({ lang, lines }) {
|
|
264
|
-
const
|
|
264
|
+
const theme = useTheme();
|
|
265
|
+
// Lazy init: try sync highlight when shiki is already warm so the very first
|
|
266
|
+
// paint carries highlighted output. This matters because MessageList renders
|
|
267
|
+
// committed messages inside Ink's <Static>, which only paints each item once
|
|
268
|
+
// — anything we ship via setState in useEffect lands too late to appear in
|
|
269
|
+
// scrollback. Fall back to raw lines if shiki hasn't loaded yet.
|
|
270
|
+
const [highlighted, setHighlighted] = React.useState(() => {
|
|
271
|
+
const code = lines.join("\n");
|
|
272
|
+
if (!code)
|
|
273
|
+
return lines;
|
|
274
|
+
const sync = highlightCodeSync(code, lang || "text");
|
|
275
|
+
return sync ? sync.split("\n") : lines;
|
|
276
|
+
});
|
|
277
|
+
const upgraded = React.useRef(highlighted !== lines);
|
|
265
278
|
React.useEffect(() => {
|
|
279
|
+
if (upgraded.current)
|
|
280
|
+
return;
|
|
266
281
|
let cancelled = false;
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
282
|
+
const code = lines.join("\n");
|
|
283
|
+
if (!code)
|
|
284
|
+
return;
|
|
285
|
+
highlightCode(code, lang || "text")
|
|
286
|
+
.then((ansi) => {
|
|
287
|
+
if (cancelled)
|
|
270
288
|
return;
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
catch {
|
|
277
|
-
if (!cancelled)
|
|
278
|
-
setHighlighted(lines);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
// Show plain text immediately while highlighting loads
|
|
282
|
-
setHighlighted(lines);
|
|
283
|
-
run();
|
|
289
|
+
upgraded.current = true;
|
|
290
|
+
setHighlighted(ansi.split("\n"));
|
|
291
|
+
})
|
|
292
|
+
.catch(() => { });
|
|
284
293
|
return () => {
|
|
285
294
|
cancelled = true;
|
|
286
295
|
};
|
|
287
296
|
}, [lang, lines]);
|
|
288
|
-
return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [lang && _jsx(Text, { color: theme.muted, children: lang }), _jsx(Box, { flexDirection: "column", children: highlighted
|
|
297
|
+
return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [lang && _jsx(Text, { color: theme.muted, children: lang }), _jsx(Box, { flexDirection: "column", children: highlighted.map((line, i) => (_jsx(Text, { children: line || " " }, i))) })] }));
|
|
289
298
|
}
|
|
290
299
|
function TableBlock({ headers, rows, maxWidth, }) {
|
|
291
300
|
const { columns: termWidth } = useTerminalSize();
|
|
@@ -354,6 +363,7 @@ function inlineSegmentsWidth(segments) {
|
|
|
354
363
|
return segments.reduce((sum, segment) => sum + visualWidth(segment.text), 0);
|
|
355
364
|
}
|
|
356
365
|
function HeadingBlock({ level, text }) {
|
|
366
|
+
const theme = useTheme();
|
|
357
367
|
const props = { bold: true };
|
|
358
368
|
if (level === 1) {
|
|
359
369
|
props.underline = true;
|