@bubblebrain-ai/bubble 0.0.8 → 0.0.10

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.
Files changed (74) hide show
  1. package/dist/agent/categories.d.ts +34 -0
  2. package/dist/agent/categories.js +98 -0
  3. package/dist/agent/profiles.d.ts +4 -0
  4. package/dist/agent/profiles.js +2 -3
  5. package/dist/agent/subagent-control.d.ts +5 -0
  6. package/dist/agent/subagent-control.js +4 -0
  7. package/dist/agent/subagent-lifecycle-reminder.d.ts +3 -0
  8. package/dist/agent/subagent-lifecycle-reminder.js +102 -0
  9. package/dist/agent/subagent-route-format.d.ts +8 -0
  10. package/dist/agent/subagent-route-format.js +18 -0
  11. package/dist/agent/subtask-policy.d.ts +0 -1
  12. package/dist/agent/subtask-policy.js +0 -4
  13. package/dist/agent.d.ts +12 -0
  14. package/dist/agent.js +152 -13
  15. package/dist/config.d.ts +23 -3
  16. package/dist/config.js +59 -6
  17. package/dist/context/budget.d.ts +3 -3
  18. package/dist/context/budget.js +29 -15
  19. package/dist/context/compact.d.ts +23 -0
  20. package/dist/context/compact.js +129 -0
  21. package/dist/context/llm-compactor.d.ts +19 -0
  22. package/dist/context/llm-compactor.js +200 -0
  23. package/dist/context/projector.js +28 -12
  24. package/dist/context/token-estimator.d.ts +14 -0
  25. package/dist/context/token-estimator.js +106 -0
  26. package/dist/context/tool-output-truncate.d.ts +8 -0
  27. package/dist/context/tool-output-truncate.js +59 -0
  28. package/dist/context/usage.js +9 -9
  29. package/dist/main.js +43 -6
  30. package/dist/model-catalog.d.ts +9 -0
  31. package/dist/model-catalog.js +16 -0
  32. package/dist/orchestrator/default-hooks.js +18 -0
  33. package/dist/provider-openai-codex.d.ts +13 -2
  34. package/dist/provider-openai-codex.js +81 -32
  35. package/dist/provider-registry.js +20 -4
  36. package/dist/slash-commands/commands.js +24 -0
  37. package/dist/slash-commands/types.d.ts +7 -0
  38. package/dist/tools/agent-lifecycle.js +22 -4
  39. package/dist/tools/edit.js +2 -2
  40. package/dist/tools/glob.js +2 -1
  41. package/dist/tools/grep.js +2 -2
  42. package/dist/tools/lsp.js +2 -2
  43. package/dist/tools/path-utils.d.ts +2 -0
  44. package/dist/tools/path-utils.js +16 -0
  45. package/dist/tools/read.js +117 -5
  46. package/dist/tools/write.js +3 -2
  47. package/dist/tui-ink/app.d.ts +11 -2
  48. package/dist/tui-ink/app.js +191 -78
  49. package/dist/tui-ink/approval/approval-dialog.js +4 -1
  50. package/dist/tui-ink/approval/diff-view.js +2 -1
  51. package/dist/tui-ink/approval/select.js +2 -1
  52. package/dist/tui-ink/code-highlight.d.ts +2 -0
  53. package/dist/tui-ink/code-highlight.js +30 -2
  54. package/dist/tui-ink/detect-theme.d.ts +19 -0
  55. package/dist/tui-ink/detect-theme.js +123 -0
  56. package/dist/tui-ink/footer.js +4 -3
  57. package/dist/tui-ink/input-box.js +83 -26
  58. package/dist/tui-ink/input-history.d.ts +16 -0
  59. package/dist/tui-ink/input-history.js +81 -0
  60. package/dist/tui-ink/markdown.js +30 -20
  61. package/dist/tui-ink/message-list.js +112 -16
  62. package/dist/tui-ink/model-picker.js +6 -1
  63. package/dist/tui-ink/plan-confirm.js +2 -1
  64. package/dist/tui-ink/question-dialog.js +2 -1
  65. package/dist/tui-ink/run.d.ts +5 -1
  66. package/dist/tui-ink/run.js +30 -2
  67. package/dist/tui-ink/theme.d.ts +64 -35
  68. package/dist/tui-ink/theme.js +81 -8
  69. package/dist/tui-ink/todos.js +5 -3
  70. package/dist/tui-ink/trace-groups.d.ts +3 -1
  71. package/dist/tui-ink/trace-groups.js +93 -14
  72. package/dist/tui-ink/welcome.js +23 -4
  73. package/dist/types.d.ts +6 -0
  74. 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
- export async function highlightCode(code, lang) {
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
+ }
@@ -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 { theme } from "./theme.js";
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
- const traceText = data.verboseTrace ? " ⌃O details:on" : " ⌃O details";
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, Fragment as _Fragment } from "react/jsx-runtime";
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 { theme } from "./theme.js";
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
- const MIN_VISIBLE_LINES = 1;
11
- const MAX_VISIBLE_LINES = 5;
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
- const clean = pasted.replace(/\x1b\[I$/, "").replace(/\x1b\[O$/, "");
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
- for (let i = 0; i < visibleLines; i++) {
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 borderChar = "─";
605
- const topBorder = hasMoreAbove
606
- ? `─── ${scrollOffset} more ${borderChar.repeat(Math.max(0, contentWidth - 14 - scrollOffset.toString().length))}`
607
- : borderChar.repeat(contentWidth);
608
- const bottomBorder = hasMoreBelow
609
- ? `─── ↓ ${totalLines - scrollOffset - visibleLines} more ${borderChar.repeat(Math.max(0, contentWidth - 16 - (totalLines - scrollOffset - visibleLines).toString().length))}`
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
- }) })), _jsx(Text, { color: theme.inputBorder, children: topBorder.slice(0, contentWidth) }), _jsx(Box, { flexDirection: "column", paddingX: PADDING_X, children: displayedLines.map(({ text: line, visualIdx }) => {
616
- const displayLine = line.length === 0 ? " " : line;
617
- const isFirst = visualIdx === 0;
618
- const isCursorLine = visualIdx === cursorVisualRow;
619
- return (_jsxs(Box, { height: 1, overflow: "hidden", ref: isCursorLine
620
- ? (el) => {
621
- cursorLineRef.current = el;
622
- }
623
- : undefined, children: [isFirst ? (_jsx(Text, { color: theme.accent, children: PROMPT })) : (_jsx(Text, { children: " ".repeat(PROMPT.length) })), _jsx(Text, { children: displayLine })] }, visualIdx));
624
- }) }), _jsx(Text, { color: theme.inputBorder, children: bottomBorder.slice(0, contentWidth) }), showSuggestions && mode === "slash" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [slashSuggestions
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
- return (_jsx(Box, { height: 1, children: _jsx(Text, { children: i === selectedIndex ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.accent, bold: true, children: "❯ " }), _jsx(Text, { color: theme.accent, bold: true, children: cmd.name.padEnd(16) }), _jsxs(Text, { color: theme.muted, children: [" [", cmd.type, "]"] }), _jsxs(Text, { dimColor: true, children: [" ", cmd.description] })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: ` ${cmd.name.padEnd(16)}` }), _jsxs(Text, { dimColor: true, children: [" [", cmd.type, "]"] }), _jsxs(Text, { dimColor: true, children: [" ", cmd.description] })] })) }) }, cmd.name));
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
+ }
@@ -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 { theme } from "./theme.js";
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 [highlighted, setHighlighted] = React.useState(null);
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
- async function run() {
268
- const code = lines.join("\n");
269
- if (!code)
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
- try {
272
- const ansi = await highlightCode(code, lang || "text");
273
- if (!cancelled)
274
- setHighlighted(ansi.split("\n"));
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?.map((line, i) => (_jsx(Text, { children: line || " " }, i))) })] }));
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;