@bubblebrain-ai/bubble 0.0.28 → 0.0.29

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 (59) hide show
  1. package/README.md +21 -0
  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/network/provider-transport.d.ts +9 -0
  25. package/dist/network/provider-transport.js +19 -1
  26. package/dist/provider.d.ts +14 -0
  27. package/dist/provider.js +24 -0
  28. package/dist/session.d.ts +16 -0
  29. package/dist/session.js +33 -1
  30. package/dist/slash-commands/commands.js +47 -1
  31. package/dist/slash-commands/types.d.ts +16 -1
  32. package/dist/tools/agent-lifecycle.d.ts +6 -0
  33. package/dist/tools/agent-lifecycle.js +285 -0
  34. package/dist/tools/child-tools.d.ts +10 -0
  35. package/dist/tools/child-tools.js +12 -0
  36. package/dist/tools/read.d.ts +1 -1
  37. package/dist/tools/read.js +9 -0
  38. package/dist/tui/image-display.d.ts +6 -0
  39. package/dist/tui/image-display.js +26 -1
  40. package/dist/tui-ink/app.js +84 -6
  41. package/dist/tui-ink/compaction-progress.d.ts +19 -0
  42. package/dist/tui-ink/compaction-progress.js +74 -0
  43. package/dist/tui-ink/input-box.d.ts +7 -1
  44. package/dist/tui-ink/input-box.js +48 -15
  45. package/dist/tui-ink/markdown.d.ts +18 -0
  46. package/dist/tui-ink/markdown.js +172 -16
  47. package/dist/tui-ink/message-list.js +38 -94
  48. package/dist/tui-ink/run.js +5 -0
  49. package/dist/tui-ink/subagent-inspector.d.ts +17 -0
  50. package/dist/tui-ink/subagent-inspector.js +189 -0
  51. package/dist/tui-ink/subagent-view.d.ts +47 -0
  52. package/dist/tui-ink/subagent-view.js +163 -0
  53. package/dist/tui-ink/terminal-env.d.ts +15 -0
  54. package/dist/tui-ink/terminal-env.js +22 -0
  55. package/dist/tui-ink/use-terminal-size.js +33 -6
  56. package/dist/tui-ink/width.d.ts +18 -0
  57. package/dist/tui-ink/width.js +130 -0
  58. package/dist/types.d.ts +35 -0
  59. package/package.json +2 -1
@@ -0,0 +1,19 @@
1
+ import type { CompactionProgress } from "../slash-commands/types.js";
2
+ /**
3
+ * Map a compaction phase + streamed length onto a 0..1 bar fill. There is no
4
+ * true denominator for a single LLM call, so the curve is honest by design:
5
+ * it ramps but never reaches 1.0 (or even 0.9) until the work is actually done.
6
+ */
7
+ export declare function compactionFraction(progress: CompactionProgress): number;
8
+ export declare function renderBar(fraction: number, width?: number): {
9
+ filled: string;
10
+ empty: string;
11
+ };
12
+ /**
13
+ * Bottom-stack progress card for a manual `/compact` run. Mount it only while a
14
+ * compaction is in flight (i.e. render conditionally on a non-null progress) so
15
+ * its elapsed-time clock resets per run.
16
+ */
17
+ export declare function CompactionProgressCard({ progress }: {
18
+ progress: CompactionProgress | null;
19
+ }): import("react/jsx-runtime").JSX.Element | null;
@@ -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;
@@ -142,4 +148,4 @@ export declare function resolveComposerEditAction(input: string, key: {
142
148
  home?: boolean;
143
149
  end?: boolean;
144
150
  }): 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;
151
+ 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;
@@ -92,7 +92,8 @@ export function splitLineAtCursor(lineText, charOffset) {
92
92
  };
93
93
  }
94
94
  // 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
95
+ // columns. Uses the shared terminal-aware width (./width.js) so CJK, emoji and
96
+ // ambiguous-width chars wrap exactly as the terminal renders them; empty lines
96
97
  // still produce one empty segment so cursors on blank lines render.
97
98
  function wrapLineByWidth(line, maxWidth) {
98
99
  if (line.length === 0)
@@ -101,7 +102,7 @@ function wrapLineByWidth(line, maxWidth) {
101
102
  let current = "";
102
103
  let currentWidth = 0;
103
104
  for (const ch of line) {
104
- const w = stringWidth(ch);
105
+ const w = graphemeWidth(ch);
105
106
  if (currentWidth + w > maxWidth && current.length > 0) {
106
107
  out.push(current);
107
108
  current = "";
@@ -143,7 +144,7 @@ function cursorToVisual(visualLines, cursor) {
143
144
  }
144
145
  const vl = visualLines[row];
145
146
  const charOffset = Math.max(0, cursor - vl.absStart);
146
- return { row, col: stringWidth(vl.text.slice(0, charOffset)) };
147
+ return { row, col: visualWidth(vl.text.slice(0, charOffset)) };
147
148
  }
148
149
  // Map a (visualRow, visualCol) target back to a source-text cursor index.
149
150
  // Used by up/down arrows to preserve the visual column when jumping rows.
@@ -155,7 +156,7 @@ function visualToCursor(visualLines, row, col) {
155
156
  let width = 0;
156
157
  let charOffset = 0;
157
158
  for (const ch of vl.text) {
158
- const w = stringWidth(ch);
159
+ const w = graphemeWidth(ch);
159
160
  if (width + w > col)
160
161
  break;
161
162
  width += w;
@@ -325,7 +326,7 @@ export function resolveComposerEditAction(input, key) {
325
326
  return "delete-line-end";
326
327
  return null;
327
328
  }
328
- export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorResetEpoch = 0, draftText, draftEpoch = 0, onDraftApplied, skillRegistry, localSlashCommands = [], terminalColumns, cwd, sessionFile, nextImageLabelStart = 1, }) {
329
+ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, onArrowDownAtBottom, cursorResetEpoch = 0, draftText, draftEpoch = 0, onDraftApplied, skillRegistry, localSlashCommands = [], terminalColumns, cwd, sessionFile, nextImageLabelStart = 1, }) {
329
330
  const theme = useTheme();
330
331
  const width = terminalColumns;
331
332
  const historyScope = useMemo(() => ({ sessionFile, cwd }), [sessionFile, cwd]);
@@ -345,6 +346,9 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
345
346
  const submittedPayloadFingerprintRef = useRef(null);
346
347
  const loadingFilesRef = useRef(false);
347
348
  const nextPastedContentIndexRef = useRef(1);
349
+ // Kept equal to attachments.length so a synchronous multi-image paste loop
350
+ // assigns each image its correct (distinct) inline label index.
351
+ const attachmentCountRef = useRef(0);
348
352
  // Paste and the keystrokes that follow can arrive inside the same stdin chunk
349
353
  // and dispatch within one discreteUpdates batch. If the Enter that a user
350
354
  // typed after a paste fires before React commits the paste-driven setState,
@@ -448,9 +452,16 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
448
452
  setCursor((c) => c + insertion.length);
449
453
  }, [cursor]);
450
454
  const addAttachment = React.useCallback((att) => {
455
+ const base = imageLabelStartOverride ?? nextImageLabelStart;
456
+ const index = attachmentCountRef.current;
457
+ attachmentCountRef.current += 1;
458
+ // Place the image label inline at the cursor so the reference appears where
459
+ // it was pasted (not forced to the start), and moves with later edits. It is
460
+ // stripped from the text on submit so the model still receives clean text.
461
+ insertTextAtCursor(`${imageDisplayLabel(base + index)} `);
451
462
  ensureImageLabelStart();
452
463
  setAttachments((prev) => [...prev, att]);
453
- }, [ensureImageLabelStart]);
464
+ }, [ensureImageLabelStart, insertTextAtCursor, imageLabelStartOverride, nextImageLabelStart]);
454
465
  const notice = React.useCallback((msg) => {
455
466
  onPasteNotice?.(msg);
456
467
  }, [onPasteNotice]);
@@ -580,7 +591,12 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
580
591
  setSelectedIndex(0);
581
592
  };
582
593
  const submitInput = (submittedText, target = "submit") => {
583
- const expandedText = expandPastedContentMarkers(submittedText, pastedContentRefs);
594
+ const labelStartForSubmit = imageLabelStartOverride ?? nextImageLabelStart;
595
+ const inlineLabels = attachments.map((_, index) => imageDisplayLabel(labelStartForSubmit + index));
596
+ // Text-paste markers expand to their content (not replayable); image labels
597
+ // are a composer-only affordance stripped here (replayable via attachments).
598
+ const pasteExpanded = expandPastedContentMarkers(submittedText, pastedContentRefs);
599
+ const expandedText = stripInlineImageLabels(pasteExpanded, inlineLabels);
584
600
  if (expandedText.trim().length === 0 && attachments.length === 0)
585
601
  return;
586
602
  const deliver = target === "queue" && onQueue ? onQueue : onSubmit;
@@ -588,7 +604,10 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
588
604
  // The pasted-content marker is a composer-only affordance. Submit the
589
605
  // fully-expanded text so the transcript shows what was actually sent —
590
606
  // the agent already receives the expanded text — rather than the marker.
607
+ // `text` (model input) has image labels stripped; `displayText` keeps them
608
+ // inline at their paste position so the transcript shows the image there.
591
609
  text: expandedText,
610
+ ...(inlineLabels.length > 0 && pasteExpanded !== expandedText ? { displayText: pasteExpanded } : {}),
592
611
  images: attachments,
593
612
  imageDisplayStart: attachments.length > 0 ? (imageLabelStartOverride ?? nextImageLabelStart) : undefined,
594
613
  };
@@ -597,9 +616,10 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
597
616
  return;
598
617
  submittedPayloadFingerprintRef.current = fingerprint;
599
618
  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)) {
619
+ // A collapsed text-paste marker cannot be safely replayed once its
620
+ // in-memory reference is gone; skip those. Image-label stripping is fine to
621
+ // replay (the attachments are stored on the history entry).
622
+ if (pasteExpanded === submittedText && (expandedText.trim().length > 0 || attachments.length > 0)) {
603
623
  const historyEntry = {
604
624
  text: expandedText,
605
625
  images: attachments,
@@ -617,6 +637,7 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
617
637
  setCursor(0);
618
638
  setSelectedIndex(0);
619
639
  setAttachments([]);
640
+ attachmentCountRef.current = 0;
620
641
  setImageLabelStartOverride(null);
621
642
  setPastedContentRefs([]);
622
643
  nextPastedContentIndexRef.current = 1;
@@ -788,6 +809,7 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
788
809
  setImageLabelStartOverride(null);
789
810
  return next;
790
811
  });
812
+ attachmentCountRef.current = Math.max(0, attachmentCountRef.current - 1);
791
813
  }
792
814
  return;
793
815
  }
@@ -872,6 +894,7 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
872
894
  setCursor(draftText.length);
873
895
  setSelectedIndex(0);
874
896
  setAttachments([]);
897
+ attachmentCountRef.current = 0;
875
898
  setImageLabelStartOverride(null);
876
899
  setPastedContentRefs([]);
877
900
  nextPastedContentIndexRef.current = 1;
@@ -903,7 +926,11 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
903
926
  const lineWidth = Math.max(1, contentWidth - PROMPT.length);
904
927
  const imageLabelStart = imageLabelStartOverride ?? nextImageLabelStart;
905
928
  const attachmentLabels = useMemo(() => attachments.map((_, index) => imageDisplayLabel(imageLabelStart + index)), [attachments, imageLabelStart]);
906
- const imageInlinePrefix = attachmentLabels.length > 0 ? `${attachmentLabels.join(" ")} ` : "";
929
+ // Labels are normally inline in `text` at their paste position. Only labels
930
+ // that are NOT present inline (e.g. attachments restored from history) fall
931
+ // back to a leading prefix so they stay visible.
932
+ const unmarkedLabels = useMemo(() => attachmentLabels.filter((label) => !text.includes(label)), [attachmentLabels, text]);
933
+ const imageInlinePrefix = unmarkedLabels.length > 0 ? `${unmarkedLabels.join(" ")} ` : "";
907
934
  const displayText = imageInlinePrefix + text;
908
935
  const displayCursor = cursor + imageInlinePrefix.length;
909
936
  const displayCursorToSourceCursor = (value) => Math.max(0, Math.min(text.length, value - imageInlinePrefix.length));
@@ -944,6 +971,7 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
944
971
  setText(result.text);
945
972
  setCursor(result.text.length);
946
973
  setAttachments(result.images ?? []);
974
+ attachmentCountRef.current = (result.images ?? []).length;
947
975
  setImageLabelStartOverride(result.imageDisplayStart ?? null);
948
976
  setHistoryIndex(result.index);
949
977
  historyDraftRef.current = result.draft;
@@ -951,6 +979,11 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
951
979
  setPastedContentRefs([]);
952
980
  nextPastedContentIndexRef.current = 1;
953
981
  }
982
+ else if (direction === "down") {
983
+ // At the bottom edge with nothing newer in history: hand Down to the
984
+ // parent so focus can move into the subagent entry (Claude Code parity).
985
+ onArrowDownAtBottom?.();
986
+ }
954
987
  };
955
988
  const classifyVerticalArrow = (direction) => {
956
989
  performVerticalArrowRef.current(direction);
@@ -1103,7 +1136,7 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
1103
1136
  });
1104
1137
  const moreBelow = totalLines - scrollOffset - visibleLines;
1105
1138
  const filledLine = (value) => {
1106
- const visibleWidth = stringWidth(value);
1139
+ const visibleWidth = visualWidth(value);
1107
1140
  return value + " ".repeat(Math.max(0, contentWidth - visibleWidth));
1108
1141
  };
1109
1142
  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 +1159,7 @@ export function InputBox({ onSubmit, onQueue, onPasteNotice, disabled, cursorRes
1126
1159
  : undefined,
1127
1160
  });
1128
1161
  const renderedLine = renderedSegments.map((segment) => segment.text).join("");
1129
- const fill = " ".repeat(Math.max(0, lineWidth - stringWidth(renderedLine)));
1162
+ const fill = " ".repeat(Math.max(0, lineWidth - visualWidth(renderedLine)));
1130
1163
  return (_jsxs(Box, { height: 1, overflow: "hidden", backgroundColor: composerBg, ref: isCursorLine
1131
1164
  ? (el) => {
1132
1165
  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
  }