@bubblebrain-ai/bubble 0.0.27 → 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.
- package/README.md +21 -0
- package/dist/agent/categories.d.ts +2 -0
- package/dist/agent/categories.js +4 -0
- package/dist/agent/child-runner.d.ts +5 -1
- package/dist/agent/child-runner.js +35 -2
- package/dist/agent/profiles.js +3 -0
- package/dist/agent/structured-output.d.ts +37 -0
- package/dist/agent/structured-output.js +193 -0
- package/dist/agent/subagent-control.d.ts +3 -0
- package/dist/agent/subagent-scheduler.d.ts +10 -0
- package/dist/agent/subagent-scheduler.js +31 -0
- package/dist/agent/workflow/control.d.ts +37 -0
- package/dist/agent/workflow/control.js +20 -0
- package/dist/agent/workflow/errors.d.ts +16 -0
- package/dist/agent/workflow/errors.js +24 -0
- package/dist/agent/workflow/runtime.d.ts +75 -0
- package/dist/agent/workflow/runtime.js +237 -0
- package/dist/agent.d.ts +105 -0
- package/dist/agent.js +425 -17
- package/dist/context/compact-llm.d.ts +10 -1
- package/dist/context/compact-llm.js +13 -5
- package/dist/context/compact.d.ts +30 -0
- package/dist/context/compact.js +34 -17
- package/dist/network/provider-transport.d.ts +9 -0
- package/dist/network/provider-transport.js +19 -1
- package/dist/provider-anthropic.js +13 -0
- package/dist/provider.d.ts +14 -0
- package/dist/provider.js +24 -0
- package/dist/session.d.ts +16 -0
- package/dist/session.js +33 -1
- package/dist/slash-commands/commands.js +47 -1
- package/dist/slash-commands/types.d.ts +16 -1
- package/dist/tools/agent-lifecycle.d.ts +6 -0
- package/dist/tools/agent-lifecycle.js +285 -0
- package/dist/tools/child-tools.d.ts +10 -0
- package/dist/tools/child-tools.js +12 -0
- package/dist/tools/read.d.ts +1 -1
- package/dist/tools/read.js +9 -0
- package/dist/tui/image-display.d.ts +6 -0
- package/dist/tui/image-display.js +26 -1
- package/dist/tui-ink/app.js +84 -6
- package/dist/tui-ink/compaction-progress.d.ts +19 -0
- package/dist/tui-ink/compaction-progress.js +74 -0
- package/dist/tui-ink/input-box.d.ts +7 -1
- package/dist/tui-ink/input-box.js +48 -15
- package/dist/tui-ink/markdown.d.ts +18 -0
- package/dist/tui-ink/markdown.js +172 -16
- package/dist/tui-ink/message-list.js +38 -94
- package/dist/tui-ink/run.js +5 -0
- package/dist/tui-ink/subagent-inspector.d.ts +17 -0
- package/dist/tui-ink/subagent-inspector.js +189 -0
- package/dist/tui-ink/subagent-view.d.ts +47 -0
- package/dist/tui-ink/subagent-view.js +163 -0
- package/dist/tui-ink/terminal-env.d.ts +15 -0
- package/dist/tui-ink/terminal-env.js +22 -0
- package/dist/tui-ink/use-terminal-size.js +33 -6
- package/dist/tui-ink/width.d.ts +18 -0
- package/dist/tui-ink/width.js +130 -0
- package/dist/types.d.ts +35 -0
- 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
|
|
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
|
|
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 =
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
|
601
|
-
// in-memory
|
|
602
|
-
|
|
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
|
-
|
|
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 =
|
|
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 -
|
|
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;
|
package/dist/tui-ink/markdown.js
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
-
|
|
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) =>
|
|
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
|
}
|