cvc-tui 0.1.0 → 0.4.0
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/dist/app/completion.js +4 -0
- package/dist/app/createGatewayEventHandler.js +508 -0
- package/dist/app/createSlashHandler.js +101 -0
- package/dist/app/delegationStore.js +51 -0
- package/dist/app/gatewayContext.js +17 -0
- package/dist/app/historyStore.js +4 -0
- package/dist/app/inputBuffer.js +4 -0
- package/dist/app/inputSelectionStore.js +8 -0
- package/dist/app/inputStore.js +4 -0
- package/dist/app/interfaces.js +6 -0
- package/dist/app/overlayStore.js +40 -0
- package/dist/app/promptStore.js +4 -0
- package/dist/app/queueStore.js +4 -0
- package/dist/app/scroll.js +44 -0
- package/dist/app/setupHandoff.js +28 -0
- package/dist/app/slash/commands/core.js +421 -234
- package/dist/app/slash/commands/debug.js +39 -6
- package/dist/app/slash/commands/ops.js +471 -136
- package/dist/app/slash/commands/session.js +405 -65
- package/dist/app/slash/commands/setup.js +16 -43
- package/dist/app/slash/commands/toggles.js +4 -0
- package/dist/app/slash/registry.js +7 -68
- package/dist/app/slash/types.js +1 -16
- package/dist/app/spawnHistoryStore.js +105 -0
- package/dist/app/turnController.js +650 -0
- package/dist/app/turnStore.js +47 -59
- package/dist/app/uiStore.js +34 -29
- package/dist/app/useComposerState.js +265 -0
- package/dist/app/useConfigSync.js +144 -0
- package/dist/app/useInputHandlers.js +403 -0
- package/dist/app/useLongRunToolCharms.js +50 -0
- package/dist/app/useMainApp.js +629 -0
- package/dist/app/useSessionLifecycle.js +175 -0
- package/dist/app/useSubmission.js +287 -0
- package/dist/app.js +13 -217
- package/dist/banner.js +40 -3
- package/dist/components/agentsOverlay.js +474 -0
- package/dist/components/appChrome.js +252 -0
- package/dist/components/appLayout.js +121 -22
- package/dist/components/appOverlays.js +65 -0
- package/dist/components/branding.js +97 -6
- package/dist/components/fpsOverlay.js +22 -0
- package/dist/components/helpHint.js +21 -0
- package/dist/components/markdown.js +501 -0
- package/dist/components/maskedPrompt.js +12 -0
- package/dist/components/messageLine.js +82 -0
- package/dist/components/modelPicker.js +254 -0
- package/dist/components/overlayControls.js +30 -0
- package/dist/components/overlays/helpOverlay.js +1 -0
- package/dist/components/overlays/historySearch.js +1 -0
- package/dist/components/overlays/modelPicker.js +2 -1
- package/dist/components/overlays/overlayUtils.js +2 -1
- package/dist/components/overlays/secretPrompt.js +1 -0
- package/dist/components/overlays/sessionPicker.js +1 -0
- package/dist/components/overlays/skillsHub.js +1 -0
- package/dist/components/prompts.js +95 -0
- package/dist/components/queuedMessages.js +24 -0
- package/dist/components/sessionPicker.js +130 -0
- package/dist/components/skillsHub.js +165 -0
- package/dist/components/streamingAssistant.js +35 -0
- package/dist/components/streamingMarkdown.js +110 -186
- package/dist/components/textInput.js +748 -218
- package/dist/components/themed.js +12 -0
- package/dist/components/thinking.js +493 -36
- package/dist/components/todoPanel.js +40 -0
- package/dist/config/env.js +18 -0
- package/dist/config/limits.js +22 -0
- package/dist/config/timing.js +4 -0
- package/dist/content/charms.js +5 -0
- package/dist/content/faces.js +21 -0
- package/dist/content/fortunes.js +29 -0
- package/dist/content/hotkeys.js +38 -0
- package/dist/content/placeholders.js +15 -0
- package/dist/content/setup.js +14 -0
- package/dist/content/verbs.js +41 -0
- package/dist/domain/details.js +53 -0
- package/dist/domain/messages.js +63 -0
- package/dist/domain/paths.js +16 -0
- package/dist/domain/providers.js +11 -0
- package/dist/domain/roles.js +6 -0
- package/dist/domain/slash.js +11 -0
- package/dist/domain/usage.js +1 -0
- package/dist/domain/viewport.js +33 -0
- package/dist/entry.js +65 -40
- package/dist/gatewayClient.js +574 -0
- package/dist/gatewayTypes.js +1 -0
- package/dist/hooks/useCompletion.js +86 -0
- package/dist/hooks/useGitBranch.js +58 -0
- package/dist/hooks/useInputHistory.js +12 -0
- package/dist/hooks/useQueue.js +57 -0
- package/dist/hooks/useVirtualHistory.js +401 -0
- package/dist/lib/circularBuffer.js +43 -0
- package/dist/lib/clipboard.js +126 -0
- package/dist/lib/editor.js +41 -0
- package/dist/lib/editor.test.js +58 -0
- package/dist/lib/emoji.js +49 -0
- package/dist/lib/externalCli.js +11 -0
- package/dist/lib/forceTruecolor.js +26 -0
- package/dist/lib/fpsStore.js +36 -0
- package/dist/lib/gracefulExit.js +29 -0
- package/dist/lib/history.js +69 -0
- package/dist/lib/inputMetrics.js +143 -0
- package/dist/lib/liveProgress.js +51 -0
- package/dist/lib/liveProgress.test.js +89 -0
- package/dist/lib/mathUnicode.js +685 -0
- package/dist/lib/memory.js +123 -0
- package/dist/lib/memoryMonitor.js +76 -0
- package/dist/lib/messages.js +3 -0
- package/dist/lib/messages.test.js +25 -0
- package/dist/lib/osc52.js +53 -0
- package/dist/lib/perfPane.js +94 -0
- package/dist/lib/platform.js +312 -0
- package/dist/lib/precisionWheel.js +25 -0
- package/dist/lib/reasoning.js +39 -0
- package/dist/lib/rpc.js +26 -0
- package/dist/lib/subagentTree.js +287 -0
- package/dist/lib/syntax.js +89 -0
- package/dist/lib/terminalModes.js +46 -0
- package/dist/lib/terminalParity.js +48 -0
- package/dist/lib/terminalSetup.js +321 -0
- package/dist/lib/text.js +203 -0
- package/dist/lib/text.test.js +18 -0
- package/dist/lib/todo.js +2 -0
- package/dist/lib/todo.test.js +22 -0
- package/dist/lib/viewportStore.js +82 -0
- package/dist/lib/virtualHeights.js +61 -0
- package/dist/lib/wheelAccel.js +143 -0
- package/dist/theme.js +398 -0
- package/dist/types.js +1 -7
- package/package.json +2 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// @ts-nocheck
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
// Ported from CVC Agent (https://github.com/NousResearch/cvc)
|
|
5
|
+
// Original Copyright (c) 2025 Nous Research. CVC adaptations (c) 2026 Jai Kumar Meena.
|
|
6
|
+
import { Box, Text, useInput, useStdout } from '@cvc/ink';
|
|
7
|
+
import { useEffect, useState } from 'react';
|
|
8
|
+
import { rpcErrorMessage } from '../lib/rpc.js';
|
|
9
|
+
import { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js';
|
|
10
|
+
const VISIBLE = 12;
|
|
11
|
+
const MIN_WIDTH = 40;
|
|
12
|
+
const MAX_WIDTH = 90;
|
|
13
|
+
export function SkillsHub({ gw, onClose, t }) {
|
|
14
|
+
const [skillsByCat, setSkillsByCat] = useState({});
|
|
15
|
+
const [selectedCat, setSelectedCat] = useState('');
|
|
16
|
+
const [catIdx, setCatIdx] = useState(0);
|
|
17
|
+
const [skillIdx, setSkillIdx] = useState(0);
|
|
18
|
+
const [stage, setStage] = useState('category');
|
|
19
|
+
const [info, setInfo] = useState(null);
|
|
20
|
+
const [installing, setInstalling] = useState(false);
|
|
21
|
+
const [err, setErr] = useState('');
|
|
22
|
+
const [loading, setLoading] = useState(true);
|
|
23
|
+
const { stdout } = useStdout();
|
|
24
|
+
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6));
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
gw.request('skills.manage', { action: 'list' })
|
|
27
|
+
.then(r => {
|
|
28
|
+
setSkillsByCat(r?.skills ?? {});
|
|
29
|
+
setErr('');
|
|
30
|
+
setLoading(false);
|
|
31
|
+
})
|
|
32
|
+
.catch((e) => {
|
|
33
|
+
setErr(rpcErrorMessage(e));
|
|
34
|
+
setLoading(false);
|
|
35
|
+
});
|
|
36
|
+
}, [gw]);
|
|
37
|
+
const cats = Object.keys(skillsByCat).sort();
|
|
38
|
+
const skills = selectedCat ? (skillsByCat[selectedCat] ?? []) : [];
|
|
39
|
+
const skillName = skills[skillIdx] ?? '';
|
|
40
|
+
const back = () => {
|
|
41
|
+
if (stage === 'actions') {
|
|
42
|
+
setStage('skill');
|
|
43
|
+
setInfo(null);
|
|
44
|
+
setErr('');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (stage === 'skill') {
|
|
48
|
+
setStage('category');
|
|
49
|
+
setSkillIdx(0);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
onClose();
|
|
53
|
+
};
|
|
54
|
+
useOverlayKeys({ disabled: installing, onBack: back, onClose });
|
|
55
|
+
const inspect = (name) => {
|
|
56
|
+
setInfo(null);
|
|
57
|
+
setErr('');
|
|
58
|
+
gw.request('skills.manage', { action: 'inspect', query: name })
|
|
59
|
+
.then(r => setInfo(r?.info ?? { name }))
|
|
60
|
+
.catch((e) => setErr(rpcErrorMessage(e)));
|
|
61
|
+
};
|
|
62
|
+
const install = (name) => {
|
|
63
|
+
setInstalling(true);
|
|
64
|
+
setErr('');
|
|
65
|
+
gw.request('skills.manage', { action: 'install', query: name })
|
|
66
|
+
.then(() => onClose())
|
|
67
|
+
.catch((e) => setErr(rpcErrorMessage(e)))
|
|
68
|
+
.finally(() => setInstalling(false));
|
|
69
|
+
};
|
|
70
|
+
useInput((ch, key) => {
|
|
71
|
+
if (installing) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (stage === 'actions') {
|
|
75
|
+
if (key.return) {
|
|
76
|
+
setStage('skill');
|
|
77
|
+
setInfo(null);
|
|
78
|
+
setErr('');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (ch.toLowerCase() === 'x' && skillName) {
|
|
82
|
+
install(skillName);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (ch.toLowerCase() === 'i' && skillName) {
|
|
86
|
+
inspect(skillName);
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const count = stage === 'category' ? cats.length : skills.length;
|
|
91
|
+
const sel = stage === 'category' ? catIdx : skillIdx;
|
|
92
|
+
const setSel = stage === 'category' ? setCatIdx : setSkillIdx;
|
|
93
|
+
if (key.upArrow && sel > 0) {
|
|
94
|
+
setSel(v => v - 1);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (key.downArrow && sel < count - 1) {
|
|
98
|
+
setSel(v => v + 1);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (key.return) {
|
|
102
|
+
if (stage === 'category') {
|
|
103
|
+
const cat = cats[catIdx];
|
|
104
|
+
if (!cat) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
setSelectedCat(cat);
|
|
108
|
+
setSkillIdx(0);
|
|
109
|
+
setStage('skill');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const name = skills[skillIdx];
|
|
113
|
+
if (name) {
|
|
114
|
+
setStage('actions');
|
|
115
|
+
inspect(name);
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const n = ch === '0' ? 10 : parseInt(ch, 10);
|
|
120
|
+
if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) {
|
|
121
|
+
const next = windowOffset(count, sel, VISIBLE) + n - 1;
|
|
122
|
+
if (stage === 'category') {
|
|
123
|
+
const cat = cats[next];
|
|
124
|
+
if (cat) {
|
|
125
|
+
setSelectedCat(cat);
|
|
126
|
+
setCatIdx(next);
|
|
127
|
+
setSkillIdx(0);
|
|
128
|
+
setStage('skill');
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const name = skills[next];
|
|
133
|
+
if (name) {
|
|
134
|
+
setSkillIdx(next);
|
|
135
|
+
setStage('actions');
|
|
136
|
+
inspect(name);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
if (loading) {
|
|
141
|
+
return _jsx(Text, { color: t.color.muted, children: "loading skills\u2026" });
|
|
142
|
+
}
|
|
143
|
+
if (err && stage === 'category') {
|
|
144
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsxs(Text, { color: t.color.label, children: ["error: ", err] }), _jsx(OverlayHint, { t: t, children: "Esc/q cancel" })] }));
|
|
145
|
+
}
|
|
146
|
+
if (!cats.length) {
|
|
147
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsx(Text, { color: t.color.muted, children: "no skills available" }), _jsx(OverlayHint, { t: t, children: "Esc/q cancel" })] }));
|
|
148
|
+
}
|
|
149
|
+
if (stage === 'category') {
|
|
150
|
+
const rows = cats.map(c => `${c} · ${skillsByCat[c]?.length ?? 0} skills`);
|
|
151
|
+
const { items, offset } = windowItems(rows, catIdx, VISIBLE);
|
|
152
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsx(Text, { bold: true, color: t.color.accent, children: "Skills Hub" }), _jsx(Text, { color: t.color.muted, children: "select a category" }), offset > 0 && _jsxs(Text, { color: t.color.muted, children: [" \u2191 ", offset, " more"] }), items.map((row, i) => {
|
|
153
|
+
const idx = offset + i;
|
|
154
|
+
return (_jsxs(Text, { bold: catIdx === idx, color: catIdx === idx ? t.color.accent : t.color.muted, inverse: catIdx === idx, wrap: "truncate-end", children: [catIdx === idx ? '▸ ' : ' ', i + 1, ". ", row] }, row));
|
|
155
|
+
}), offset + VISIBLE < rows.length && _jsxs(Text, { color: t.color.muted, children: [" \u2193 ", rows.length - offset - VISIBLE, " more"] }), _jsx(OverlayHint, { t: t, children: "\u2191/\u2193 select \u00B7 Enter open \u00B7 1-9,0 quick \u00B7 Esc/q cancel" })] }));
|
|
156
|
+
}
|
|
157
|
+
if (stage === 'skill') {
|
|
158
|
+
const { items, offset } = windowItems(skills, skillIdx, VISIBLE);
|
|
159
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsx(Text, { bold: true, color: t.color.accent, children: selectedCat }), _jsxs(Text, { color: t.color.muted, children: [skills.length, " skill(s)"] }), !skills.length ? _jsx(Text, { color: t.color.muted, children: "no skills in this category" }) : null, offset > 0 && _jsxs(Text, { color: t.color.muted, children: [" \u2191 ", offset, " more"] }), items.map((row, i) => {
|
|
160
|
+
const idx = offset + i;
|
|
161
|
+
return (_jsxs(Text, { bold: skillIdx === idx, color: skillIdx === idx ? t.color.accent : t.color.muted, inverse: skillIdx === idx, wrap: "truncate-end", children: [skillIdx === idx ? '▸ ' : ' ', i + 1, ". ", row] }, row));
|
|
162
|
+
}), offset + VISIBLE < skills.length && (_jsxs(Text, { color: t.color.muted, children: [" \u2193 ", skills.length - offset - VISIBLE, " more"] })), _jsx(OverlayHint, { t: t, children: skills.length ? '↑/↓ select · Enter open · 1-9,0 quick · Esc back · q close' : 'Esc back · q close' })] }));
|
|
163
|
+
}
|
|
164
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsx(Text, { bold: true, color: t.color.accent, children: info?.name ?? skillName }), _jsx(Text, { color: t.color.muted, children: info?.category ?? selectedCat }), info?.description ? _jsx(Text, { color: t.color.text, children: info.description }) : null, info?.path ? _jsxs(Text, { color: t.color.muted, children: ["path: ", info.path] }) : null, !info && !err ? _jsx(Text, { color: t.color.muted, children: "loading\u2026" }) : null, err ? _jsxs(Text, { color: t.color.label, children: ["error: ", err] }) : null, installing ? _jsx(Text, { color: t.color.accent, children: "installing\u2026" }) : null, _jsx(OverlayHint, { t: t, children: "i reinspect \u00B7 x reinstall \u00B7 Enter/Esc back \u00B7 q close" })] }));
|
|
165
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// @ts-nocheck
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
// Ported from CVC Agent (https://github.com/NousResearch/cvc)
|
|
5
|
+
// Original Copyright (c) 2025 Nous Research. CVC adaptations (c) 2026 Jai Kumar Meena.
|
|
6
|
+
import { useStore } from '@nanostores/react';
|
|
7
|
+
import { memo } from 'react';
|
|
8
|
+
import { toggleTodoCollapsed, useTurnSelector } from '../app/turnStore.js';
|
|
9
|
+
import { $uiState } from '../app/uiStore.js';
|
|
10
|
+
import { appendToolShelfMessage } from '../lib/liveProgress.js';
|
|
11
|
+
import { MessageLine } from './messageLine.js';
|
|
12
|
+
import { TodoPanel } from './todoPanel.js';
|
|
13
|
+
const groupedSegments = (segments) => segments.reduce((acc, msg) => appendToolShelfMessage(acc, msg), []);
|
|
14
|
+
export const StreamingAssistant = memo(function StreamingAssistant({ cols, compact, detailsMode, detailsModeCommandOverride, progress, sections }) {
|
|
15
|
+
const ui = useStore($uiState);
|
|
16
|
+
const streamSegments = useTurnSelector(state => state.streamSegments);
|
|
17
|
+
const streamPendingTools = useTurnSelector(state => state.streamPendingTools);
|
|
18
|
+
const streaming = useTurnSelector(state => state.streaming);
|
|
19
|
+
const activeTools = useTurnSelector(state => state.tools);
|
|
20
|
+
const showStreamingArea = Boolean(streaming);
|
|
21
|
+
if (!progress.showProgressArea && !showStreamingArea && !activeTools.length) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return (_jsxs(_Fragment, { children: [groupedSegments(streamSegments).map((msg, i) => (_jsx(MessageLine, { cols: cols, compact: compact, detailsMode: detailsMode, detailsModeCommandOverride: detailsModeCommandOverride, msg: msg, sections: sections, t: ui.theme }, `seg:${i}`))), !!activeTools.length && (_jsx(MessageLine, { cols: cols, compact: compact, detailsMode: detailsMode, detailsModeCommandOverride: detailsModeCommandOverride, msg: { kind: 'trail', role: 'system', text: '' }, sections: sections, t: ui.theme, tools: activeTools })), showStreamingArea && (_jsx(MessageLine, { cols: cols, compact: compact, detailsMode: detailsMode, detailsModeCommandOverride: detailsModeCommandOverride, isStreaming: true, msg: {
|
|
25
|
+
role: 'assistant',
|
|
26
|
+
text: streaming,
|
|
27
|
+
...(streamPendingTools.length && { tools: streamPendingTools })
|
|
28
|
+
}, sections: sections, t: ui.theme })), !showStreamingArea && !!streamPendingTools.length && (_jsx(MessageLine, { cols: cols, compact: compact, detailsMode: detailsMode, detailsModeCommandOverride: detailsModeCommandOverride, msg: { kind: 'trail', role: 'system', text: '', tools: streamPendingTools }, sections: sections, t: ui.theme }))] }));
|
|
29
|
+
});
|
|
30
|
+
export const LiveTodoPanel = memo(function LiveTodoPanel() {
|
|
31
|
+
const ui = useStore($uiState);
|
|
32
|
+
const todos = useTurnSelector(state => state.todos);
|
|
33
|
+
const collapsed = useTurnSelector(state => state.todoCollapsed);
|
|
34
|
+
return _jsx(TodoPanel, { collapsed: collapsed, onToggle: toggleTodoCollapsed, t: ui.theme, todos: todos });
|
|
35
|
+
});
|
|
@@ -1,45 +1,63 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
//
|
|
2
|
+
// @ts-nocheck
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
// Ported from CVC Agent (https://github.com/NousResearch/cvc)
|
|
5
|
+
// Original Copyright (c) 2025 Nous Research. CVC adaptations (c) 2026 Jai Kumar Meena.
|
|
6
|
+
// StreamingMd — incremental markdown renderer for in-flight assistant text.
|
|
3
7
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// memoised and only the unstable suffix re-tokenises on each delta.
|
|
8
|
-
// * The stable prefix is monotonically growing (stored in a ref), so
|
|
9
|
-
// React reuses the cached subtree across deltas.
|
|
8
|
+
// Naive approach (render <Md text={full}/>) re-tokenizes the entire message
|
|
9
|
+
// on every stream delta. At 20-char batches over a 3 KB response that's 150
|
|
10
|
+
// full re-parses.
|
|
10
11
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
12
|
+
// This splits `text` at the last stable top-level block boundary (blank
|
|
13
|
+
// line outside a fenced code span) into:
|
|
14
|
+
// stablePrefix — passed to an inner <Md>, memoized on its exact text
|
|
15
|
+
// value. During the turn, the prefix only grows monotonically,
|
|
16
|
+
// so its memo key matches the previous render and React
|
|
17
|
+
// reuses the cached subtree — zero re-tokenization.
|
|
18
|
+
// unstableSuffix — the in-flight block(s). A separate <Md> re-parses just
|
|
19
|
+
// this tail on every delta (O(unstable length) vs.
|
|
20
|
+
// O(total length)).
|
|
19
21
|
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
|
|
22
|
+
// The boundary is stored in a ref so it only advances — idempotent under
|
|
23
|
+
// StrictMode double-render. Component unmounts between turns (isStreaming
|
|
24
|
+
// flips off → message moves to history and renders via <Md> directly), so
|
|
25
|
+
// the ref resets naturally.
|
|
26
|
+
//
|
|
27
|
+
// Layout: the two <Md> subtrees MUST render stacked (column). The parent
|
|
28
|
+
// container in messageLine.tsx is a default `flexDirection: 'row'` Box
|
|
29
|
+
// (Ink's default), so returning a bare Fragment of two <Md> siblings
|
|
30
|
+
// laid them out side-by-side — producing the "two jumbled columns while
|
|
31
|
+
// streaming" rendering bug. Wrapping in a flexDirection="column" Box
|
|
32
|
+
// here localizes the fix to the streaming path; the non-streaming <Md>
|
|
33
|
+
// already returns its own column Box, so its single-child case was never
|
|
34
|
+
// affected.
|
|
35
|
+
import { Box } from '@cvc/ink';
|
|
36
|
+
import { memo, useRef } from 'react';
|
|
37
|
+
import { Md } from './markdown.js';
|
|
38
|
+
// Count ``` / ~~~ AND `$$` / `\[…\]` fence toggles in `s` up to `end`. Odd
|
|
39
|
+
// = currently inside a fenced block; splitting the prefix there would
|
|
40
|
+
// orphan the fence and let the unstable suffix re-render as broken
|
|
41
|
+
// markdown. Math fences only toggle when the code fence is closed so
|
|
42
|
+
// snippets like ` ```\n$$x$$\n``` ` (math example inside a code block)
|
|
43
|
+
// don't double-count. A `$$x$$` line that opens AND closes on its own
|
|
44
|
+
// produces zero net toggles; that's `len >= 4` plus `endsDollar`.
|
|
45
|
+
//
|
|
46
|
+
// NB: this is INTENTIONALLY more conservative than `markdown.tsx`'s
|
|
47
|
+
// parser, which falls back to paragraph rendering when an `$$` opener
|
|
48
|
+
// has no matching closer. The renderer can do that safely because it
|
|
49
|
+
// always sees the full text on every call. The streaming chunker
|
|
50
|
+
// cannot — once a chunk is committed to the monotonic stable prefix it
|
|
51
|
+
// is frozen, so prematurely deciding "this `$$` is just prose" would
|
|
52
|
+
// permanently commit a paragraph rendering that becomes wrong the
|
|
53
|
+
// instant the closer streams in. Treating any unmatched `$$` opener
|
|
54
|
+
// as still-open keeps the boundary parked behind it until the closer
|
|
55
|
+
// arrives (or the stream ends and the non-streaming `<Md>` takes over,
|
|
56
|
+
// at which point the renderer's fallback kicks in correctly).
|
|
41
57
|
const fenceOpenAt = (s, end) => {
|
|
42
58
|
let codeOpen = false;
|
|
59
|
+
let mathOpen = false;
|
|
60
|
+
let mathOpener = null;
|
|
43
61
|
let i = 0;
|
|
44
62
|
while (i < end) {
|
|
45
63
|
const nl = s.indexOf('\n', i);
|
|
@@ -48,173 +66,79 @@ const fenceOpenAt = (s, end) => {
|
|
|
48
66
|
if (/^(?:`{3,}|~{3,})/.test(line)) {
|
|
49
67
|
codeOpen = !codeOpen;
|
|
50
68
|
}
|
|
51
|
-
if (
|
|
69
|
+
else if (!codeOpen) {
|
|
70
|
+
if (!mathOpen && /^\$\$/.test(line)) {
|
|
71
|
+
const isSingleLine = line.length >= 4 && /\$\$$/.test(line);
|
|
72
|
+
if (!isSingleLine) {
|
|
73
|
+
mathOpen = true;
|
|
74
|
+
mathOpener = '$$';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else if (!mathOpen && /^\\\[/.test(line)) {
|
|
78
|
+
const isSingleLine = /\\\]$/.test(line);
|
|
79
|
+
if (!isSingleLine) {
|
|
80
|
+
mathOpen = true;
|
|
81
|
+
mathOpener = '\\[';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else if (mathOpen && mathOpener === '$$' && /\$\$$/.test(line)) {
|
|
85
|
+
mathOpen = false;
|
|
86
|
+
mathOpener = null;
|
|
87
|
+
}
|
|
88
|
+
else if (mathOpen && mathOpener === '\\[' && /\\\]$/.test(line)) {
|
|
89
|
+
mathOpen = false;
|
|
90
|
+
mathOpener = null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (nl < 0 || nl >= end) {
|
|
52
94
|
break;
|
|
95
|
+
}
|
|
53
96
|
i = nl + 1;
|
|
54
97
|
}
|
|
55
|
-
return codeOpen;
|
|
98
|
+
return codeOpen || mathOpen;
|
|
56
99
|
};
|
|
57
|
-
|
|
100
|
+
// Find the last "\n\n" boundary before `end` that is OUTSIDE a fenced code
|
|
101
|
+
// block. Returns the index AFTER the second newline (start of the next
|
|
102
|
+
// block), or -1 if no safe boundary exists yet.
|
|
58
103
|
export const findStableBoundary = (text) => {
|
|
59
104
|
let idx = text.length;
|
|
60
105
|
while (idx > 0) {
|
|
61
106
|
const boundary = text.lastIndexOf('\n\n', idx - 1);
|
|
62
|
-
if (boundary < 0)
|
|
107
|
+
if (boundary < 0) {
|
|
63
108
|
return -1;
|
|
109
|
+
}
|
|
110
|
+
// Boundary candidate: end of stable prefix is boundary + 2 (start of
|
|
111
|
+
// next block). Check fence balance up to that point.
|
|
64
112
|
const splitAt = boundary + 2;
|
|
65
|
-
if (!fenceOpenAt(text, splitAt))
|
|
113
|
+
if (!fenceOpenAt(text, splitAt)) {
|
|
66
114
|
return splitAt;
|
|
115
|
+
}
|
|
67
116
|
idx = boundary;
|
|
68
117
|
}
|
|
69
118
|
return -1;
|
|
70
119
|
};
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const renderInline = (tokens, key = 0) => {
|
|
78
|
-
if (!tokens)
|
|
79
|
-
return null;
|
|
80
|
-
const out = [];
|
|
81
|
-
tokens.forEach((tok, i) => {
|
|
82
|
-
const k = `${key}-${i}`;
|
|
83
|
-
switch (tok.type) {
|
|
84
|
-
case 'text':
|
|
85
|
-
case 'escape':
|
|
86
|
-
out.push(_jsx(Text, { children: tok.text ?? tok.raw ?? '' }, k));
|
|
87
|
-
break;
|
|
88
|
-
case 'strong':
|
|
89
|
-
out.push(_jsx(Text, { bold: true, children: renderInline(tok.tokens, i) }, k));
|
|
90
|
-
break;
|
|
91
|
-
case 'em':
|
|
92
|
-
out.push(_jsx(Text, { italic: true, children: renderInline(tok.tokens, i) }, k));
|
|
93
|
-
break;
|
|
94
|
-
case 'del':
|
|
95
|
-
out.push(_jsx(Text, { strikethrough: true, children: renderInline(tok.tokens, i) }, k));
|
|
96
|
-
break;
|
|
97
|
-
case 'codespan':
|
|
98
|
-
out.push(_jsxs(Text, { color: CVC_THEME.accent, backgroundColor: "#1b2735", children: [' ', tok.text ?? '', ' '] }, k));
|
|
99
|
-
break;
|
|
100
|
-
case 'link': {
|
|
101
|
-
const href = tok.href ?? '';
|
|
102
|
-
const label = (tok.text ?? href) || '';
|
|
103
|
-
// OSC 8 — embed the click target in the escape sequence so the
|
|
104
|
-
// visible text is just the label (still styled accent+underline).
|
|
105
|
-
out.push(_jsx(Text, { color: CVC_THEME.accent, underline: true, children: osc8Link(href, label) }, k));
|
|
106
|
-
break;
|
|
107
|
-
}
|
|
108
|
-
case 'br':
|
|
109
|
-
out.push(_jsx(Text, { children: '\n' }, k));
|
|
110
|
-
break;
|
|
111
|
-
case 'image': {
|
|
112
|
-
const href = tok.href ?? '';
|
|
113
|
-
const label = tok.text ?? '';
|
|
114
|
-
out.push(_jsxs(Text, { color: CVC_THEME.dim, children: ["[image: ", label, "] ", href] }, k));
|
|
115
|
-
break;
|
|
116
|
-
}
|
|
117
|
-
default:
|
|
118
|
-
out.push(_jsx(Text, { children: tok.raw ?? tok.text ?? '' }, k));
|
|
119
|
-
}
|
|
120
|
-
});
|
|
121
|
-
return out;
|
|
122
|
-
};
|
|
123
|
-
// -- block rendering ----------------------------------------------------------
|
|
124
|
-
const renderCodeBlock = (text, lang, k) => {
|
|
125
|
-
const language = (lang ?? '').trim().toLowerCase();
|
|
126
|
-
let body = text;
|
|
127
|
-
if (language && supportsLanguage(language)) {
|
|
128
|
-
try {
|
|
129
|
-
body = highlight(text, { language, ignoreIllegals: true });
|
|
130
|
-
}
|
|
131
|
-
catch {
|
|
132
|
-
body = text;
|
|
133
|
-
}
|
|
120
|
+
export const StreamingMd = memo(function StreamingMd({ compact, t, text }) {
|
|
121
|
+
const stablePrefixRef = useRef('');
|
|
122
|
+
// Reset if the text no longer starts with our recorded prefix (defensive;
|
|
123
|
+
// normally the component unmounts between turns so this shouldn't trigger).
|
|
124
|
+
if (!text.startsWith(stablePrefixRef.current)) {
|
|
125
|
+
stablePrefixRef.current = '';
|
|
134
126
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
switch (tok.type) {
|
|
143
|
-
case 'heading': {
|
|
144
|
-
const t = tok;
|
|
145
|
-
const colour = HEADING_COLOURS[t.depth] ?? CVC_THEME.dim;
|
|
146
|
-
return (_jsx(Box, { children: _jsxs(Text, { bold: true, color: colour, children: ['#'.repeat(t.depth), ' ', renderInline(t.tokens)] }) }, k));
|
|
147
|
-
}
|
|
148
|
-
case 'paragraph': {
|
|
149
|
-
const t = tok;
|
|
150
|
-
return (_jsx(Text, { children: renderInline(t.tokens) }, k));
|
|
151
|
-
}
|
|
152
|
-
case 'code': {
|
|
153
|
-
const t = tok;
|
|
154
|
-
return renderCodeBlock(t.text ?? '', t.lang ?? undefined, k);
|
|
155
|
-
}
|
|
156
|
-
case 'blockquote': {
|
|
157
|
-
const t = tok;
|
|
158
|
-
return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: CVC_THEME.dim, children: "\u2502 " }), _jsx(Box, { flexDirection: "column", children: (t.tokens ?? []).map((child, ci) => (_jsx(Fragment, { children: renderBlock(child, `${k}-${ci}`) }, ci))) })] }, k));
|
|
159
|
-
}
|
|
160
|
-
case 'list': {
|
|
161
|
-
const t = tok;
|
|
162
|
-
return (_jsx(Box, { flexDirection: "column", children: t.items.map((it, i) => renderListItem(it, !!t.ordered, i, `${k}-i${i}`)) }, k));
|
|
163
|
-
}
|
|
164
|
-
case 'hr':
|
|
165
|
-
return (_jsx(Text, { color: CVC_THEME.dim, children: "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }, k));
|
|
166
|
-
case 'space':
|
|
167
|
-
return _jsx(Text, { children: " " }, k);
|
|
168
|
-
case 'html':
|
|
169
|
-
return _jsx(Text, { children: tok.text }, k);
|
|
170
|
-
case 'text': {
|
|
171
|
-
const t = tok;
|
|
172
|
-
const inline = t.tokens;
|
|
173
|
-
return (_jsx(Text, { children: inline ? renderInline(inline) : t.text }, k));
|
|
174
|
-
}
|
|
175
|
-
default:
|
|
176
|
-
return (_jsx(Text, { children: tok.raw ?? '' }, k));
|
|
177
|
-
}
|
|
178
|
-
};
|
|
179
|
-
const lexerOptions = { gfm: true, breaks: false };
|
|
180
|
-
/** Public: render arbitrary markdown as a stack of Ink Box/Text nodes. */
|
|
181
|
-
export const Markdown = memo(function Markdown({ text }) {
|
|
182
|
-
if (!text)
|
|
183
|
-
return null;
|
|
184
|
-
let tokens = [];
|
|
185
|
-
try {
|
|
186
|
-
tokens = marked.lexer(text, lexerOptions);
|
|
187
|
-
}
|
|
188
|
-
catch {
|
|
189
|
-
return _jsx(Text, { children: text });
|
|
190
|
-
}
|
|
191
|
-
return (_jsx(Box, { flexDirection: "column", children: tokens.map((tok, i) => (_jsx(Fragment, { children: renderBlock(tok, `b${i}`) }, i))) }));
|
|
192
|
-
});
|
|
193
|
-
/**
|
|
194
|
-
* StreamingMarkdown — the renderer used while a turn is in flight.
|
|
195
|
-
* Splits `content` at the last safe block boundary so the stable prefix
|
|
196
|
-
* memoises across deltas (avoiding O(N) re-tokenisation per chunk).
|
|
197
|
-
*/
|
|
198
|
-
export const StreamingMarkdown = memo(function StreamingMarkdown({ content, color, }) {
|
|
199
|
-
const stableRef = useRef('');
|
|
200
|
-
if (!content.startsWith(stableRef.current)) {
|
|
201
|
-
stableRef.current = '';
|
|
127
|
+
const boundary = findStableBoundary(text);
|
|
128
|
+
// Only advance the prefix — never retreat. The boundary math looks at the
|
|
129
|
+
// FULL text each call; if it returns a larger index than before, we grow
|
|
130
|
+
// the cached prefix. Monotonic growth makes the memo key stable across
|
|
131
|
+
// deltas (identical string → same <Md> subtree → no re-render).
|
|
132
|
+
if (boundary > stablePrefixRef.current.length) {
|
|
133
|
+
stablePrefixRef.current = text.slice(0, boundary);
|
|
202
134
|
}
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
135
|
+
const stablePrefix = stablePrefixRef.current;
|
|
136
|
+
const unstableSuffix = text.slice(stablePrefix.length);
|
|
137
|
+
if (!stablePrefix) {
|
|
138
|
+
return _jsx(Md, { compact: compact, t: t, text: unstableSuffix });
|
|
206
139
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (!content)
|
|
210
|
-
return null;
|
|
211
|
-
if (color) {
|
|
212
|
-
// Plain-text override path (used for user echo bubbles).
|
|
213
|
-
return _jsx(Text, { color: color, children: content });
|
|
140
|
+
if (!unstableSuffix) {
|
|
141
|
+
return _jsx(Md, { compact: compact, t: t, text: stablePrefix });
|
|
214
142
|
}
|
|
215
|
-
|
|
216
|
-
return _jsx(Markdown, { text: unstable });
|
|
217
|
-
if (!unstable)
|
|
218
|
-
return _jsx(Markdown, { text: stable });
|
|
219
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Markdown, { text: stable }), _jsx(Markdown, { text: unstable })] }));
|
|
143
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Md, { compact: compact, t: t, text: stablePrefix }), _jsx(Md, { compact: compact, t: t, text: unstableSuffix })] }));
|
|
220
144
|
});
|