@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.
- package/README.md +23 -3
- 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/goal/format.d.ts +1 -1
- package/dist/goal/format.js +1 -1
- package/dist/network/provider-transport.d.ts +9 -0
- package/dist/network/provider-transport.js +19 -1
- 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 +41 -113
- package/dist/slash-commands/types.d.ts +14 -9
- 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.d.ts +0 -18
- package/dist/tui-ink/app.js +168 -230
- 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 +10 -1
- package/dist/tui-ink/input-box.js +56 -16
- package/dist/tui-ink/markdown.d.ts +18 -0
- package/dist/tui-ink/markdown.js +172 -16
- package/dist/tui-ink/message-list.d.ts +1 -2
- package/dist/tui-ink/message-list.js +50 -107
- 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,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
|
|
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
|
|
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
|
|
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 =
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
|
601
|
-
// in-memory
|
|
602
|
-
|
|
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
|
-
|
|
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 =
|
|
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 -
|
|
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;
|
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
|
}
|
|
@@ -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,
|
|
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 {};
|