@bubblebrain-ai/bubble 0.0.28 → 0.0.30

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 (63) hide show
  1. package/README.md +23 -3
  2. package/dist/agent/categories.d.ts +2 -0
  3. package/dist/agent/categories.js +4 -0
  4. package/dist/agent/child-runner.d.ts +5 -1
  5. package/dist/agent/child-runner.js +35 -2
  6. package/dist/agent/profiles.js +3 -0
  7. package/dist/agent/structured-output.d.ts +37 -0
  8. package/dist/agent/structured-output.js +193 -0
  9. package/dist/agent/subagent-control.d.ts +3 -0
  10. package/dist/agent/subagent-scheduler.d.ts +10 -0
  11. package/dist/agent/subagent-scheduler.js +31 -0
  12. package/dist/agent/workflow/control.d.ts +37 -0
  13. package/dist/agent/workflow/control.js +20 -0
  14. package/dist/agent/workflow/errors.d.ts +16 -0
  15. package/dist/agent/workflow/errors.js +24 -0
  16. package/dist/agent/workflow/runtime.d.ts +75 -0
  17. package/dist/agent/workflow/runtime.js +237 -0
  18. package/dist/agent.d.ts +105 -0
  19. package/dist/agent.js +425 -17
  20. package/dist/context/compact-llm.d.ts +10 -1
  21. package/dist/context/compact-llm.js +13 -5
  22. package/dist/context/compact.d.ts +30 -0
  23. package/dist/context/compact.js +34 -17
  24. package/dist/goal/format.d.ts +1 -1
  25. package/dist/goal/format.js +1 -1
  26. package/dist/network/provider-transport.d.ts +9 -0
  27. package/dist/network/provider-transport.js +19 -1
  28. package/dist/provider.d.ts +14 -0
  29. package/dist/provider.js +24 -0
  30. package/dist/session.d.ts +16 -0
  31. package/dist/session.js +33 -1
  32. package/dist/slash-commands/commands.js +41 -113
  33. package/dist/slash-commands/types.d.ts +14 -9
  34. package/dist/tools/agent-lifecycle.d.ts +6 -0
  35. package/dist/tools/agent-lifecycle.js +285 -0
  36. package/dist/tools/child-tools.d.ts +10 -0
  37. package/dist/tools/child-tools.js +12 -0
  38. package/dist/tools/read.d.ts +1 -1
  39. package/dist/tools/read.js +9 -0
  40. package/dist/tui/image-display.d.ts +6 -0
  41. package/dist/tui/image-display.js +26 -1
  42. package/dist/tui-ink/app.d.ts +0 -18
  43. package/dist/tui-ink/app.js +168 -230
  44. package/dist/tui-ink/compaction-progress.d.ts +19 -0
  45. package/dist/tui-ink/compaction-progress.js +74 -0
  46. package/dist/tui-ink/input-box.d.ts +10 -1
  47. package/dist/tui-ink/input-box.js +56 -16
  48. package/dist/tui-ink/markdown.d.ts +18 -0
  49. package/dist/tui-ink/markdown.js +172 -16
  50. package/dist/tui-ink/message-list.d.ts +1 -2
  51. package/dist/tui-ink/message-list.js +50 -107
  52. package/dist/tui-ink/run.js +5 -0
  53. package/dist/tui-ink/subagent-inspector.d.ts +17 -0
  54. package/dist/tui-ink/subagent-inspector.js +189 -0
  55. package/dist/tui-ink/subagent-view.d.ts +47 -0
  56. package/dist/tui-ink/subagent-view.js +163 -0
  57. package/dist/tui-ink/terminal-env.d.ts +15 -0
  58. package/dist/tui-ink/terminal-env.js +22 -0
  59. package/dist/tui-ink/use-terminal-size.js +33 -6
  60. package/dist/tui-ink/width.d.ts +18 -0
  61. package/dist/tui-ink/width.js +130 -0
  62. package/dist/types.d.ts +35 -0
  63. package/package.json +2 -1
@@ -0,0 +1,74 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Box, Text } from "ink";
4
+ import { useTheme } from "./theme.js";
5
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
+ const BAR_WIDTH = 24;
7
+ // Chars of streamed summary at which the summarizing phase nears its ceiling.
8
+ // A 9-section handoff summary is typically ~1.5–4k chars; this makes the bar
9
+ // feel alive without ever claiming completion before the model actually returns
10
+ // (the curve is asymptotic and capped at 0.9 until the apply phase).
11
+ const SUMMARY_CHAR_ESTIMATE = 2500;
12
+ const PHASE_LABEL = {
13
+ collecting: "收集历史",
14
+ summarizing: "生成摘要中",
15
+ applying: "应用压缩",
16
+ };
17
+ /**
18
+ * Map a compaction phase + streamed length onto a 0..1 bar fill. There is no
19
+ * true denominator for a single LLM call, so the curve is honest by design:
20
+ * it ramps but never reaches 1.0 (or even 0.9) until the work is actually done.
21
+ */
22
+ export function compactionFraction(progress) {
23
+ switch (progress.phase) {
24
+ case "collecting":
25
+ return 0.05;
26
+ case "summarizing": {
27
+ const ramp = 1 - Math.exp(-progress.streamedChars / SUMMARY_CHAR_ESTIMATE);
28
+ return Math.min(0.9, 0.1 + 0.8 * ramp);
29
+ }
30
+ case "applying":
31
+ return 0.95;
32
+ }
33
+ }
34
+ export function renderBar(fraction, width = BAR_WIDTH) {
35
+ const clamped = Math.max(0, Math.min(1, fraction));
36
+ const filledCount = Math.round(clamped * width);
37
+ return {
38
+ filled: "█".repeat(filledCount),
39
+ empty: "░".repeat(width - filledCount),
40
+ };
41
+ }
42
+ function formatChars(count) {
43
+ if (count < 1000)
44
+ return `${count}`;
45
+ return `${(count / 1000).toFixed(1)}k`;
46
+ }
47
+ /**
48
+ * Bottom-stack progress card for a manual `/compact` run. Mount it only while a
49
+ * compaction is in flight (i.e. render conditionally on a non-null progress) so
50
+ * its elapsed-time clock resets per run.
51
+ */
52
+ export function CompactionProgressCard({ progress }) {
53
+ const theme = useTheme();
54
+ const [frameIndex, setFrameIndex] = useState(0);
55
+ const [startedAt] = useState(() => Date.now());
56
+ const [now, setNow] = useState(() => Date.now());
57
+ useEffect(() => {
58
+ const t = setInterval(() => {
59
+ setFrameIndex((i) => (i + 1) % SPINNER_FRAMES.length);
60
+ setNow(Date.now());
61
+ }, 100);
62
+ return () => clearInterval(t);
63
+ }, []);
64
+ if (!progress)
65
+ return null;
66
+ const fraction = compactionFraction(progress);
67
+ const { filled, empty } = renderBar(fraction);
68
+ const pct = Math.round(fraction * 100);
69
+ const elapsed = ((now - startedAt) / 1000).toFixed(1);
70
+ const phaseLine = progress.phase === "summarizing"
71
+ ? `· ${PHASE_LABEL.summarizing} (流式接收 ${formatChars(progress.streamedChars)} chars)`
72
+ : `· ${PHASE_LABEL[progress.phase]}`;
73
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingBottom: 1, flexShrink: 0, children: [_jsx(Text, { color: theme.accent, children: `${SPINNER_FRAMES[frameIndex]} Compacting context…` }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: "[" }), _jsx(Text, { color: theme.accent, children: filled }), _jsx(Text, { color: theme.dim, children: empty }), _jsx(Text, { color: theme.muted, children: `] ${pct}%` })] }), _jsx(Text, { color: theme.muted, children: phaseLine }), _jsx(Text, { color: theme.muted, children: `· 已耗时 ${elapsed}s` })] }));
74
+ }
@@ -19,6 +19,12 @@ interface InputBoxProps {
19
19
  onQueue?: (payload: SubmitPayload) => void;
20
20
  onPasteNotice?: (notice: string) => void;
21
21
  disabled?: boolean;
22
+ /**
23
+ * Called when Down is pressed at the bottom edge with nothing newer in
24
+ * history — the parent uses this to move focus out of the composer (e.g. into
25
+ * the subagent entry), matching Claude Code's ↓-to-focus-the-task-panel.
26
+ */
27
+ onArrowDownAtBottom?: () => void;
22
28
  cursorResetEpoch?: number;
23
29
  draftText?: string;
24
30
  draftEpoch?: number;
@@ -41,6 +47,9 @@ export declare function resolveCursorRowCompensation(input: {
41
47
  viewportRows: number;
42
48
  previousOutputHeight: number | null;
43
49
  }): number;
50
+ export declare function isCtrlLetterInput(input: string, key: {
51
+ ctrl?: boolean;
52
+ }, letter: string): boolean;
44
53
  export declare function isCtrlCInput(input: string, key: {
45
54
  ctrl?: boolean;
46
55
  }): boolean;
@@ -142,4 +151,4 @@ export declare function resolveComposerEditAction(input: string, key: {
142
151
  home?: boolean;
143
152
  end?: boolean;
144
153
  }): ComposerEditAction | null;
145
- export declare function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorResetEpoch, draftText, draftEpoch, onDraftApplied, skillRegistry, localSlashCommands, terminalColumns, cwd, sessionFile, nextImageLabelStart, }: InputBoxProps): import("react/jsx-runtime").JSX.Element;
154
+ export declare function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDownAtBottom, cursorResetEpoch, draftText, draftEpoch, onDraftApplied, skillRegistry, localSlashCommands, terminalColumns, cwd, sessionFile, nextImageLabelStart, }: InputBoxProps): import("react/jsx-runtime").JSX.Element;
@@ -1,7 +1,7 @@
1
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
- import stringWidth from "string-width";
4
+ import { visualWidth, graphemeWidth } from "./width.js";
5
5
  import { appendFileSync } from "node:fs";
6
6
  import { registry as slashRegistry } from "../slash-commands/index.js";
7
7
  import { useTheme } from "./theme.js";
@@ -13,7 +13,7 @@ import { stripTerminalMouseSequences } from "./terminal-mouse.js";
13
13
  import { submitPayloadFingerprint } from "./submit-dedupe.js";
14
14
  export { createPastedContentMarker, expandPastedContentMarkers, shouldCollapsePastedContent, } from "../tui/paste-placeholder.js";
15
15
  import { createPastedContentMarker, expandPastedContentMarkers, shouldCollapsePastedContent, } from "../tui/paste-placeholder.js";
16
- import { imageDisplayLabel } from "../tui/image-display.js";
16
+ import { imageDisplayLabel, stripInlineImageLabels } from "../tui/image-display.js";
17
17
  const MIN_VISIBLE_LINES = 3;
18
18
  const MAX_VISIBLE_LINES = 6;
19
19
  const PADDING_X = 1;
@@ -37,8 +37,15 @@ export function resolveCursorRowCompensation(input) {
37
37
  return input.previousRowCompensation;
38
38
  return needsCursorRowCompensation(input.nextOutputHeight, input.viewportRows, input.previousOutputHeight) ? 1 : 0;
39
39
  }
40
+ export function isCtrlLetterInput(input, key, letter) {
41
+ const normalized = letter.toLowerCase();
42
+ if (!/^[a-z]$/.test(normalized))
43
+ return false;
44
+ const rawControlInput = String.fromCharCode(normalized.charCodeAt(0) - 96);
45
+ return input === rawControlInput || (key.ctrl === true && input.toLowerCase() === normalized);
46
+ }
40
47
  export function isCtrlCInput(input, key) {
41
- return input === "\x03" || (key.ctrl === true && input.toLowerCase() === "c");
48
+ return isCtrlLetterInput(input, key, "c");
42
49
  }
43
50
  export function shouldUseLineComposerFrame(_background) {
44
51
  return true;
@@ -92,7 +99,8 @@ export function splitLineAtCursor(lineText, charOffset) {
92
99
  };
93
100
  }
94
101
  // Break a logical line into segments that each fit within `maxWidth` display
95
- // columns. Uses string-width so CJK and emoji wrap correctly; empty lines
102
+ // columns. Uses the shared terminal-aware width (./width.js) so CJK, emoji and
103
+ // ambiguous-width chars wrap exactly as the terminal renders them; empty lines
96
104
  // still produce one empty segment so cursors on blank lines render.
97
105
  function wrapLineByWidth(line, maxWidth) {
98
106
  if (line.length === 0)
@@ -101,7 +109,7 @@ function wrapLineByWidth(line, maxWidth) {
101
109
  let current = "";
102
110
  let currentWidth = 0;
103
111
  for (const ch of line) {
104
- const w = stringWidth(ch);
112
+ const w = graphemeWidth(ch);
105
113
  if (currentWidth + w > maxWidth && current.length > 0) {
106
114
  out.push(current);
107
115
  current = "";
@@ -143,7 +151,7 @@ function cursorToVisual(visualLines, cursor) {
143
151
  }
144
152
  const vl = visualLines[row];
145
153
  const charOffset = Math.max(0, cursor - vl.absStart);
146
- return { row, col: stringWidth(vl.text.slice(0, charOffset)) };
154
+ return { row, col: visualWidth(vl.text.slice(0, charOffset)) };
147
155
  }
148
156
  // Map a (visualRow, visualCol) target back to a source-text cursor index.
149
157
  // Used by up/down arrows to preserve the visual column when jumping rows.
@@ -155,7 +163,7 @@ function visualToCursor(visualLines, row, col) {
155
163
  let width = 0;
156
164
  let charOffset = 0;
157
165
  for (const ch of vl.text) {
158
- const w = stringWidth(ch);
166
+ const w = graphemeWidth(ch);
159
167
  if (width + w > col)
160
168
  break;
161
169
  width += w;
@@ -325,7 +333,7 @@ export function resolveComposerEditAction(input, key) {
325
333
  return "delete-line-end";
326
334
  return null;
327
335
  }
328
- export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorResetEpoch = 0, draftText, draftEpoch = 0, onDraftApplied, skillRegistry, localSlashCommands = [], terminalColumns, cwd, sessionFile, nextImageLabelStart = 1, }) {
336
+ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDownAtBottom, cursorResetEpoch = 0, draftText, draftEpoch = 0, onDraftApplied, skillRegistry, localSlashCommands = [], terminalColumns, cwd, sessionFile, nextImageLabelStart = 1, }) {
329
337
  const theme = useTheme();
330
338
  const width = terminalColumns;
331
339
  const historyScope = useMemo(() => ({ sessionFile, cwd }), [sessionFile, cwd]);
@@ -345,6 +353,9 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
345
353
  const submittedPayloadFingerprintRef = useRef(null);
346
354
  const loadingFilesRef = useRef(false);
347
355
  const nextPastedContentIndexRef = useRef(1);
356
+ // Kept equal to attachments.length so a synchronous multi-image paste loop
357
+ // assigns each image its correct (distinct) inline label index.
358
+ const attachmentCountRef = useRef(0);
348
359
  // Paste and the keystrokes that follow can arrive inside the same stdin chunk
349
360
  // and dispatch within one discreteUpdates batch. If the Enter that a user
350
361
  // typed after a paste fires before React commits the paste-driven setState,
@@ -448,9 +459,16 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
448
459
  setCursor((c) => c + insertion.length);
449
460
  }, [cursor]);
450
461
  const addAttachment = React.useCallback((att) => {
462
+ const base = imageLabelStartOverride ?? nextImageLabelStart;
463
+ const index = attachmentCountRef.current;
464
+ attachmentCountRef.current += 1;
465
+ // Place the image label inline at the cursor so the reference appears where
466
+ // it was pasted (not forced to the start), and moves with later edits. It is
467
+ // stripped from the text on submit so the model still receives clean text.
468
+ insertTextAtCursor(`${imageDisplayLabel(base + index)} `);
451
469
  ensureImageLabelStart();
452
470
  setAttachments((prev) => [...prev, att]);
453
- }, [ensureImageLabelStart]);
471
+ }, [ensureImageLabelStart, insertTextAtCursor, imageLabelStartOverride, nextImageLabelStart]);
454
472
  const notice = React.useCallback((msg) => {
455
473
  onPasteNotice?.(msg);
456
474
  }, [onPasteNotice]);
@@ -580,7 +598,12 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
580
598
  setSelectedIndex(0);
581
599
  };
582
600
  const submitInput = (submittedText, target = "submit") => {
583
- const expandedText = expandPastedContentMarkers(submittedText, pastedContentRefs);
601
+ const labelStartForSubmit = imageLabelStartOverride ?? nextImageLabelStart;
602
+ const inlineLabels = attachments.map((_, index) => imageDisplayLabel(labelStartForSubmit + index));
603
+ // Text-paste markers expand to their content (not replayable); image labels
604
+ // are a composer-only affordance stripped here (replayable via attachments).
605
+ const pasteExpanded = expandPastedContentMarkers(submittedText, pastedContentRefs);
606
+ const expandedText = stripInlineImageLabels(pasteExpanded, inlineLabels);
584
607
  if (expandedText.trim().length === 0 && attachments.length === 0)
585
608
  return;
586
609
  const deliver = target === "queue" && onQueue ? onQueue : onSubmit;
@@ -588,7 +611,10 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
588
611
  // The pasted-content marker is a composer-only affordance. Submit the
589
612
  // fully-expanded text so the transcript shows what was actually sent —
590
613
  // the agent already receives the expanded text — rather than the marker.
614
+ // `text` (model input) has image labels stripped; `displayText` keeps them
615
+ // inline at their paste position so the transcript shows the image there.
591
616
  text: expandedText,
617
+ ...(inlineLabels.length > 0 && pasteExpanded !== expandedText ? { displayText: pasteExpanded } : {}),
592
618
  images: attachments,
593
619
  imageDisplayStart: attachments.length > 0 ? (imageLabelStartOverride ?? nextImageLabelStart) : undefined,
594
620
  };
@@ -597,9 +623,10 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
597
623
  return;
598
624
  submittedPayloadFingerprintRef.current = fingerprint;
599
625
  deliver(payload);
600
- // A collapsed marker cannot be safely replayed from history once its
601
- // in-memory paste reference is gone; skip those entries instead.
602
- if (expandedText === submittedText && (expandedText.trim().length > 0 || attachments.length > 0)) {
626
+ // A collapsed text-paste marker cannot be safely replayed once its
627
+ // in-memory reference is gone; skip those. Image-label stripping is fine to
628
+ // replay (the attachments are stored on the history entry).
629
+ if (pasteExpanded === submittedText && (expandedText.trim().length > 0 || attachments.length > 0)) {
603
630
  const historyEntry = {
604
631
  text: expandedText,
605
632
  images: attachments,
@@ -617,6 +644,7 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
617
644
  setCursor(0);
618
645
  setSelectedIndex(0);
619
646
  setAttachments([]);
647
+ attachmentCountRef.current = 0;
620
648
  setImageLabelStartOverride(null);
621
649
  setPastedContentRefs([]);
622
650
  nextPastedContentIndexRef.current = 1;
@@ -788,6 +816,7 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
788
816
  setImageLabelStartOverride(null);
789
817
  return next;
790
818
  });
819
+ attachmentCountRef.current = Math.max(0, attachmentCountRef.current - 1);
791
820
  }
792
821
  return;
793
822
  }
@@ -872,6 +901,7 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
872
901
  setCursor(draftText.length);
873
902
  setSelectedIndex(0);
874
903
  setAttachments([]);
904
+ attachmentCountRef.current = 0;
875
905
  setImageLabelStartOverride(null);
876
906
  setPastedContentRefs([]);
877
907
  nextPastedContentIndexRef.current = 1;
@@ -903,7 +933,11 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
903
933
  const lineWidth = Math.max(1, contentWidth - PROMPT.length);
904
934
  const imageLabelStart = imageLabelStartOverride ?? nextImageLabelStart;
905
935
  const attachmentLabels = useMemo(() => attachments.map((_, index) => imageDisplayLabel(imageLabelStart + index)), [attachments, imageLabelStart]);
906
- const imageInlinePrefix = attachmentLabels.length > 0 ? `${attachmentLabels.join(" ")} ` : "";
936
+ // Labels are normally inline in `text` at their paste position. Only labels
937
+ // that are NOT present inline (e.g. attachments restored from history) fall
938
+ // back to a leading prefix so they stay visible.
939
+ const unmarkedLabels = useMemo(() => attachmentLabels.filter((label) => !text.includes(label)), [attachmentLabels, text]);
940
+ const imageInlinePrefix = unmarkedLabels.length > 0 ? `${unmarkedLabels.join(" ")} ` : "";
907
941
  const displayText = imageInlinePrefix + text;
908
942
  const displayCursor = cursor + imageInlinePrefix.length;
909
943
  const displayCursorToSourceCursor = (value) => Math.max(0, Math.min(text.length, value - imageInlinePrefix.length));
@@ -944,6 +978,7 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
944
978
  setText(result.text);
945
979
  setCursor(result.text.length);
946
980
  setAttachments(result.images ?? []);
981
+ attachmentCountRef.current = (result.images ?? []).length;
947
982
  setImageLabelStartOverride(result.imageDisplayStart ?? null);
948
983
  setHistoryIndex(result.index);
949
984
  historyDraftRef.current = result.draft;
@@ -951,6 +986,11 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
951
986
  setPastedContentRefs([]);
952
987
  nextPastedContentIndexRef.current = 1;
953
988
  }
989
+ else if (direction === "down") {
990
+ // At the bottom edge with nothing newer in history: hand Down to the
991
+ // parent so focus can move into the subagent entry (Claude Code parity).
992
+ onArrowDownAtBottom?.();
993
+ }
954
994
  };
955
995
  const classifyVerticalArrow = (direction) => {
956
996
  performVerticalArrowRef.current(direction);
@@ -1103,7 +1143,7 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
1103
1143
  });
1104
1144
  const moreBelow = totalLines - scrollOffset - visibleLines;
1105
1145
  const filledLine = (value) => {
1106
- const visibleWidth = stringWidth(value);
1146
+ const visibleWidth = visualWidth(value);
1107
1147
  return value + " ".repeat(Math.max(0, contentWidth - visibleWidth));
1108
1148
  };
1109
1149
  return (_jsxs(Box, { flexDirection: "column", width: width, backgroundColor: theme.background, children: [lineFrame && (_jsx(Box, { paddingX: PADDING_X, children: _jsx(Text, { color: theme.border, children: "─".repeat(contentWidth) }) })), _jsxs(Box, { flexDirection: "column", paddingX: PADDING_X, width: width, backgroundColor: composerBg, children: [hasMoreAbove && (_jsx(Text, { backgroundColor: rowBg, color: theme.muted, dimColor: true, children: filledLine(` ↑ ${scrollOffset} more`) })), displayedLines.map((row) => {
@@ -1126,7 +1166,7 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
1126
1166
  : undefined,
1127
1167
  });
1128
1168
  const renderedLine = renderedSegments.map((segment) => segment.text).join("");
1129
- const fill = " ".repeat(Math.max(0, lineWidth - stringWidth(renderedLine)));
1169
+ const fill = " ".repeat(Math.max(0, lineWidth - visualWidth(renderedLine)));
1130
1170
  return (_jsxs(Box, { height: 1, overflow: "hidden", backgroundColor: composerBg, ref: isCursorLine
1131
1171
  ? (el) => {
1132
1172
  cursorLineRef.current = el;
@@ -44,6 +44,19 @@ export declare function parseMarkdownBlocks(text: string): MarkdownBlock[];
44
44
  */
45
45
  export declare function findLastBlockStart(text: string): number;
46
46
  export declare function parseMarkdownInlineSegments(text: string, style?: InlineStyle): MarkdownInlineSegment[];
47
+ /**
48
+ * CJK-aware line wrap over styled inline segments.
49
+ *
50
+ * Ink wraps a <Text> via wrap-ansi with `hard: true`, which treats only ASCII
51
+ * spaces as break opportunities. Chinese joins items with ideographic
52
+ * punctuation like "、" and no ASCII space, so a long run such as
53
+ * `A、B、ProviderSendTurnInput` is seen as ONE unbreakable word and gets chopped
54
+ * mid-token at the column edge (e.g. `ProviderSendTurnInpu` + `t`), and the
55
+ * overflow spills past the box into the terminal's own hard wrap. We instead
56
+ * break at ASCII spaces AND after CJK punctuation (、,。!?;:)等 — keeping ASCII
57
+ * identifiers whole — and hard-split only a lone token wider than the line.
58
+ */
59
+ export declare function wrapInlineSegments(segments: MarkdownInlineSegment[], maxWidth: number): MarkdownInlineSegment[][];
47
60
  /**
48
61
  * Streaming-aware wrapper around `MarkdownContent`.
49
62
  *
@@ -63,6 +76,11 @@ export declare function StreamingMarkdown({ content, maxWidth, }: {
63
76
  content: string;
64
77
  maxWidth?: number;
65
78
  }): import("react/jsx-runtime").JSX.Element;
79
+ export declare function splitListItem(line: string): {
80
+ prefix: string;
81
+ content: string;
82
+ indent: number;
83
+ } | null;
66
84
  export declare function MarkdownContent({ content, maxWidth, }: {
67
85
  content: string;
68
86
  maxWidth?: number;
@@ -1,11 +1,11 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * Lightweight Markdown renderer for Ink TUI.
4
4
  * Supports code blocks, inline formatting, and tables.
5
5
  */
6
6
  import React from "react";
7
7
  import { Box, Text } from "ink";
8
- import stringWidth from "string-width";
8
+ import { visualWidth, graphemeWidth } from "./width.js";
9
9
  import { useTerminalSize } from "./use-terminal-size.js";
10
10
  import { useTheme } from "./theme.js";
11
11
  import { highlightCode, highlightCodeSync } from "./code-highlight.js";
@@ -219,16 +219,6 @@ function normalizeTableRow(row, colCount) {
219
219
  normalized.push("");
220
220
  return normalized;
221
221
  }
222
- function visualWidth(str) {
223
- if (!str)
224
- return 0;
225
- return stringWidth(str);
226
- }
227
- function graphemeWidth(grapheme) {
228
- if (!grapheme)
229
- return 0;
230
- return stringWidth(grapheme);
231
- }
232
222
  // Inline formatting: bold, italic, inline code
233
223
  export function parseMarkdownInlineSegments(text, style = {}) {
234
224
  const segments = [];
@@ -330,14 +320,28 @@ function appendInlineSegment(segments, text, style) {
330
320
  segments.push(next);
331
321
  }
332
322
  }
323
+ function renderSegmentNodes(segments, keyPrefix) {
324
+ return segments.map((segment, index) => (_jsx(Text, { bold: segment.bold, italic: segment.italic, color: segment.code ? "#a78bfa" : undefined, children: segment.text }, `${keyPrefix}-${index}`)));
325
+ }
333
326
  function renderInlineSegments(text, keyPrefix, style = {}) {
334
- return parseMarkdownInlineSegments(text, style).map((segment, index) => (_jsx(Text, { bold: segment.bold, italic: segment.italic, color: segment.code ? "#a78bfa" : undefined, children: segment.text }, `${keyPrefix}-${index}`)));
327
+ return renderSegmentNodes(parseMarkdownInlineSegments(text, style), keyPrefix);
335
328
  }
336
329
  function inlinePlainText(text) {
337
330
  return parseMarkdownInlineSegments(text).map((segment) => segment.text).join("");
338
331
  }
339
- function InlineText({ text }) {
340
- return _jsx(Text, { children: renderInlineSegments(text, "inline") });
332
+ function InlineText({ text, maxWidth }) {
333
+ const { columns } = useTerminalSize();
334
+ // Pre-wrap CJK-aware so Ink's space-only wrap-ansi can't chop "、"-joined runs
335
+ // mid-token. A caller-supplied maxWidth is exact (it already nets out the
336
+ // gutter); otherwise fall back to the terminal width minus a generous margin
337
+ // for borders/padding so a nested card never over-estimates and overflows —
338
+ // wrapping a few cells early is invisible, a mid-token chop is not.
339
+ const effectiveWidth = maxWidth ?? (columns ? Math.max(20, columns - 10) : undefined);
340
+ if (effectiveWidth === undefined) {
341
+ return _jsx(Text, { children: renderInlineSegments(text, "inline") });
342
+ }
343
+ const wrapped = wrapInlineSegments(parseMarkdownInlineSegments(text), effectiveWidth);
344
+ return (_jsx(_Fragment, { children: wrapped.map((segments, li) => (_jsx(Text, { children: segments.length ? renderSegmentNodes(segments, `inline-${li}`) : " " }, li))) }));
341
345
  }
342
346
  function CodeBlock({ lang, lines }) {
343
347
  const theme = useTheme();
@@ -436,6 +440,137 @@ function truncateInlineSegments(segments, width) {
436
440
  appendInlineSegment(output, "…", {});
437
441
  return output;
438
442
  }
443
+ /**
444
+ * CJK-aware line wrap over styled inline segments.
445
+ *
446
+ * Ink wraps a <Text> via wrap-ansi with `hard: true`, which treats only ASCII
447
+ * spaces as break opportunities. Chinese joins items with ideographic
448
+ * punctuation like "、" and no ASCII space, so a long run such as
449
+ * `A、B、ProviderSendTurnInput` is seen as ONE unbreakable word and gets chopped
450
+ * mid-token at the column edge (e.g. `ProviderSendTurnInpu` + `t`), and the
451
+ * overflow spills past the box into the terminal's own hard wrap. We instead
452
+ * break at ASCII spaces AND after CJK punctuation (、,。!?;:)等 — keeping ASCII
453
+ * identifiers whole — and hard-split only a lone token wider than the line.
454
+ */
455
+ export function wrapInlineSegments(segments, maxWidth) {
456
+ if (maxWidth <= 0)
457
+ return [segments];
458
+ const cells = [];
459
+ for (const segment of segments) {
460
+ const style = { bold: segment.bold, italic: segment.italic, code: segment.code };
461
+ for (const grapheme of splitGraphemes(segment.text)) {
462
+ cells.push({ grapheme, width: graphemeWidth(grapheme), style });
463
+ }
464
+ }
465
+ // CJK punctuation that acts as break opportunity AFTER it (like English comma/period).
466
+ const isCJKPunctuation = (g) => /^[、,。!?;:,!?;:]$/.test(g);
467
+ const chunks = [];
468
+ const chunkWidth = (run) => run.reduce((sum, cell) => sum + cell.width, 0);
469
+ let i = 0;
470
+ while (i < cells.length) {
471
+ const cell = cells[i];
472
+ if (cell.grapheme === " ") {
473
+ const run = [];
474
+ while (i < cells.length && cells[i].grapheme === " ")
475
+ run.push(cells[i++]);
476
+ chunks.push({ cells: run, width: chunkWidth(run), breakAfter: true });
477
+ }
478
+ else if (cell.width >= 2) {
479
+ chunks.push({ cells: [cell], width: cell.width, breakAfter: isCJKPunctuation(cell.grapheme) });
480
+ i++;
481
+ }
482
+ else {
483
+ const run = [];
484
+ while (i < cells.length && cells[i].grapheme !== " " && cells[i].width < 2)
485
+ run.push(cells[i++]);
486
+ chunks.push({ cells: run, width: chunkWidth(run), breakAfter: false });
487
+ }
488
+ }
489
+ // CJK line-break rules (禁則処理 / kinsoku): a closing bracket or a trailing
490
+ // punctuation mark must never begin a wrapped line (避头), and an opening
491
+ // bracket must never end one (避尾). Otherwise a line that fills up right
492
+ // before ")。" pushes that lone punctuation onto the next row — the jarring
493
+ // orphaned ")。" at the left margin. Glue each such mark to the chunk it must
494
+ // stay with, so the pair wraps as one unit (and may move down together).
495
+ const noStartGlyph = (g) => /^[、,。.!?;:))〕】}」』》〉…―ー]$/.test(g);
496
+ const noEndGlyph = (g) => /^[((〔【{「『《〈]$/.test(g);
497
+ const soleGlyph = (c) => (c.cells.length === 1 ? c.cells[0].grapheme : "");
498
+ const isSpaceChunk = (c) => c.cells[0]?.grapheme === " ";
499
+ const glued = [];
500
+ for (const chunk of chunks) {
501
+ const g = soleGlyph(chunk);
502
+ const prev = glued[glued.length - 1];
503
+ if (g && noStartGlyph(g) && prev && !isSpaceChunk(prev)) {
504
+ prev.cells.push(...chunk.cells);
505
+ prev.width += chunk.width;
506
+ prev.breakAfter = chunk.breakAfter; // inherit the mark's own break behavior
507
+ }
508
+ else {
509
+ glued.push({ cells: [...chunk.cells], width: chunk.width, breakAfter: chunk.breakAfter });
510
+ }
511
+ }
512
+ for (let k = glued.length - 2; k >= 0; k--) {
513
+ const g = soleGlyph(glued[k]);
514
+ if (g && noEndGlyph(g) && !isSpaceChunk(glued[k + 1])) {
515
+ const next = glued[k + 1];
516
+ next.cells.unshift(...glued[k].cells);
517
+ next.width += glued[k].width;
518
+ glued.splice(k, 1);
519
+ }
520
+ }
521
+ const lines = [];
522
+ let line = [];
523
+ let lineWidth = 0;
524
+ const flush = () => { lines.push(line); line = []; lineWidth = 0; };
525
+ for (let j = 0; j < glued.length; j++) {
526
+ const chunk = glued[j];
527
+ const isSpace = chunk.cells[0]?.grapheme === " ";
528
+ if (isSpace) {
529
+ if (lineWidth === 0)
530
+ continue;
531
+ if (lineWidth + chunk.width <= maxWidth) {
532
+ line.push(...chunk.cells);
533
+ lineWidth += chunk.width;
534
+ }
535
+ else {
536
+ flush();
537
+ }
538
+ continue;
539
+ }
540
+ if (lineWidth + chunk.width <= maxWidth) {
541
+ line.push(...chunk.cells);
542
+ lineWidth += chunk.width;
543
+ if (chunk.breakAfter && j < glued.length - 1) {
544
+ const nextChunk = glued[j + 1];
545
+ const nextIsSpace = nextChunk.cells[0]?.grapheme === " ";
546
+ if (!nextIsSpace && lineWidth + nextChunk.width > maxWidth)
547
+ flush();
548
+ }
549
+ continue;
550
+ }
551
+ if (lineWidth > 0)
552
+ flush();
553
+ if (chunk.width <= maxWidth) {
554
+ line.push(...chunk.cells);
555
+ lineWidth = chunk.width;
556
+ continue;
557
+ }
558
+ for (const cell of chunk.cells) {
559
+ if (lineWidth + cell.width > maxWidth && lineWidth > 0)
560
+ flush();
561
+ line.push(cell);
562
+ lineWidth += cell.width;
563
+ }
564
+ }
565
+ if (line.length > 0 || lines.length === 0)
566
+ lines.push(line);
567
+ return lines.map((lineCells) => {
568
+ const out = [];
569
+ for (const cell of lineCells)
570
+ appendInlineSegment(out, cell.grapheme, cell.style);
571
+ return out;
572
+ });
573
+ }
439
574
  function inlineSegmentsWidth(segments) {
440
575
  return segments.reduce((sum, segment) => sum + visualWidth(segment.text), 0);
441
576
  }
@@ -484,6 +619,18 @@ export function StreamingMarkdown({ content, maxWidth, }) {
484
619
  const unstableSuffix = content.substring(stablePrefix.length);
485
620
  return (_jsxs(Box, { flexDirection: "column", children: [stablePrefix && _jsx(MarkdownContent, { content: stablePrefix, maxWidth: maxWidth }), unstableSuffix && _jsx(MarkdownContent, { content: unstableSuffix, maxWidth: maxWidth })] }));
486
621
  }
622
+ // A markdown list item: optional indent, a bullet (-, *, +) or ordered marker
623
+ // (1. / 2)), then whitespace, then the item text. The captured prefix is
624
+ // rendered in its own fixed column so wrapped continuation lines hang-indent
625
+ // under the item text instead of collapsing back to the left margin.
626
+ const LIST_ITEM_RE = /^(\s*)([-*+]|\d{1,9}[.)])(\s+)(\S.*)$/;
627
+ export function splitListItem(line) {
628
+ const m = LIST_ITEM_RE.exec(line);
629
+ if (!m)
630
+ return null;
631
+ const prefix = m[1] + m[2] + m[3];
632
+ return { prefix, content: m[4], indent: visualWidth(prefix) };
633
+ }
487
634
  export function MarkdownContent({ content, maxWidth, }) {
488
635
  const blocks = React.useMemo(() => parseMarkdownBlocks(content), [content]);
489
636
  return (_jsx(Box, { flexDirection: "column", children: blocks.map((block, i) => {
@@ -496,6 +643,15 @@ export function MarkdownContent({ content, maxWidth, }) {
496
643
  if (block.type === "heading") {
497
644
  return _jsx(HeadingBlock, { level: block.level, text: block.text }, i);
498
645
  }
499
- return (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: block.lines.map((line, li) => (_jsx(InlineText, { text: line }, li))) }, i));
646
+ return (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: block.lines.map((line, li) => {
647
+ const item = splitListItem(line);
648
+ if (item) {
649
+ // Marker in a non-shrinking column; the item text flows in a
650
+ // flex column to its right so every wrapped row aligns under it.
651
+ const contentWidth = maxWidth !== undefined ? Math.max(4, maxWidth - item.indent) : undefined;
652
+ return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexShrink: 0, children: _jsx(Text, { children: item.prefix }) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsx(InlineText, { text: item.content, maxWidth: contentWidth }) })] }, li));
653
+ }
654
+ return _jsx(InlineText, { text: line, maxWidth: maxWidth }, li);
655
+ }) }, i));
500
656
  }) }));
501
657
  }
@@ -18,7 +18,6 @@ interface MessageListProps {
18
18
  streamingParts: DisplayMessagePart[];
19
19
  terminalColumns: number;
20
20
  showThinking?: boolean;
21
- expandedToolOutput?: boolean;
22
21
  verboseTrace: boolean;
23
22
  pendingApproval?: PendingApprovalHint | null;
24
23
  /** Animation tick used to refresh in-progress elapsed counters. */
@@ -42,5 +41,5 @@ interface MessageListProps {
42
41
  */
43
42
  maxStreamRows?: number;
44
43
  }
45
- export declare function MessageList({ messages, streamingContent, streamingReasoning, streamingTools, streamingParts, terminalColumns, showThinking, expandedToolOutput, verboseTrace, pendingApproval, nowTick, welcomeBanner, staticGeneration, paddingX, maxStreamRows, }: MessageListProps): import("react/jsx-runtime").JSX.Element;
44
+ export declare function MessageList({ messages, streamingContent, streamingReasoning, streamingTools, streamingParts, terminalColumns, showThinking, verboseTrace, pendingApproval, nowTick, welcomeBanner, staticGeneration, paddingX, maxStreamRows, }: MessageListProps): import("react/jsx-runtime").JSX.Element;
46
45
  export {};