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.
Files changed (130) hide show
  1. package/dist/app/completion.js +4 -0
  2. package/dist/app/createGatewayEventHandler.js +508 -0
  3. package/dist/app/createSlashHandler.js +101 -0
  4. package/dist/app/delegationStore.js +51 -0
  5. package/dist/app/gatewayContext.js +17 -0
  6. package/dist/app/historyStore.js +4 -0
  7. package/dist/app/inputBuffer.js +4 -0
  8. package/dist/app/inputSelectionStore.js +8 -0
  9. package/dist/app/inputStore.js +4 -0
  10. package/dist/app/interfaces.js +6 -0
  11. package/dist/app/overlayStore.js +40 -0
  12. package/dist/app/promptStore.js +4 -0
  13. package/dist/app/queueStore.js +4 -0
  14. package/dist/app/scroll.js +44 -0
  15. package/dist/app/setupHandoff.js +28 -0
  16. package/dist/app/slash/commands/core.js +421 -234
  17. package/dist/app/slash/commands/debug.js +39 -6
  18. package/dist/app/slash/commands/ops.js +471 -136
  19. package/dist/app/slash/commands/session.js +405 -65
  20. package/dist/app/slash/commands/setup.js +16 -43
  21. package/dist/app/slash/commands/toggles.js +4 -0
  22. package/dist/app/slash/registry.js +7 -68
  23. package/dist/app/slash/types.js +1 -16
  24. package/dist/app/spawnHistoryStore.js +105 -0
  25. package/dist/app/turnController.js +650 -0
  26. package/dist/app/turnStore.js +47 -59
  27. package/dist/app/uiStore.js +34 -29
  28. package/dist/app/useComposerState.js +265 -0
  29. package/dist/app/useConfigSync.js +144 -0
  30. package/dist/app/useInputHandlers.js +403 -0
  31. package/dist/app/useLongRunToolCharms.js +50 -0
  32. package/dist/app/useMainApp.js +629 -0
  33. package/dist/app/useSessionLifecycle.js +175 -0
  34. package/dist/app/useSubmission.js +287 -0
  35. package/dist/app.js +13 -217
  36. package/dist/banner.js +40 -3
  37. package/dist/components/agentsOverlay.js +474 -0
  38. package/dist/components/appChrome.js +252 -0
  39. package/dist/components/appLayout.js +121 -22
  40. package/dist/components/appOverlays.js +65 -0
  41. package/dist/components/branding.js +97 -6
  42. package/dist/components/fpsOverlay.js +22 -0
  43. package/dist/components/helpHint.js +21 -0
  44. package/dist/components/markdown.js +501 -0
  45. package/dist/components/maskedPrompt.js +12 -0
  46. package/dist/components/messageLine.js +82 -0
  47. package/dist/components/modelPicker.js +254 -0
  48. package/dist/components/overlayControls.js +30 -0
  49. package/dist/components/overlays/helpOverlay.js +1 -0
  50. package/dist/components/overlays/historySearch.js +1 -0
  51. package/dist/components/overlays/modelPicker.js +2 -1
  52. package/dist/components/overlays/overlayUtils.js +2 -1
  53. package/dist/components/overlays/secretPrompt.js +1 -0
  54. package/dist/components/overlays/sessionPicker.js +1 -0
  55. package/dist/components/overlays/skillsHub.js +1 -0
  56. package/dist/components/prompts.js +95 -0
  57. package/dist/components/queuedMessages.js +24 -0
  58. package/dist/components/sessionPicker.js +130 -0
  59. package/dist/components/skillsHub.js +165 -0
  60. package/dist/components/streamingAssistant.js +35 -0
  61. package/dist/components/streamingMarkdown.js +110 -186
  62. package/dist/components/textInput.js +748 -218
  63. package/dist/components/themed.js +12 -0
  64. package/dist/components/thinking.js +493 -36
  65. package/dist/components/todoPanel.js +40 -0
  66. package/dist/config/env.js +18 -0
  67. package/dist/config/limits.js +22 -0
  68. package/dist/config/timing.js +4 -0
  69. package/dist/content/charms.js +5 -0
  70. package/dist/content/faces.js +21 -0
  71. package/dist/content/fortunes.js +29 -0
  72. package/dist/content/hotkeys.js +38 -0
  73. package/dist/content/placeholders.js +15 -0
  74. package/dist/content/setup.js +14 -0
  75. package/dist/content/verbs.js +41 -0
  76. package/dist/domain/details.js +53 -0
  77. package/dist/domain/messages.js +63 -0
  78. package/dist/domain/paths.js +16 -0
  79. package/dist/domain/providers.js +11 -0
  80. package/dist/domain/roles.js +6 -0
  81. package/dist/domain/slash.js +11 -0
  82. package/dist/domain/usage.js +1 -0
  83. package/dist/domain/viewport.js +33 -0
  84. package/dist/entry.js +65 -40
  85. package/dist/gatewayClient.js +574 -0
  86. package/dist/gatewayTypes.js +1 -0
  87. package/dist/hooks/useCompletion.js +86 -0
  88. package/dist/hooks/useGitBranch.js +58 -0
  89. package/dist/hooks/useInputHistory.js +12 -0
  90. package/dist/hooks/useQueue.js +57 -0
  91. package/dist/hooks/useVirtualHistory.js +401 -0
  92. package/dist/lib/circularBuffer.js +43 -0
  93. package/dist/lib/clipboard.js +126 -0
  94. package/dist/lib/editor.js +41 -0
  95. package/dist/lib/editor.test.js +58 -0
  96. package/dist/lib/emoji.js +49 -0
  97. package/dist/lib/externalCli.js +11 -0
  98. package/dist/lib/forceTruecolor.js +26 -0
  99. package/dist/lib/fpsStore.js +36 -0
  100. package/dist/lib/gracefulExit.js +29 -0
  101. package/dist/lib/history.js +69 -0
  102. package/dist/lib/inputMetrics.js +143 -0
  103. package/dist/lib/liveProgress.js +51 -0
  104. package/dist/lib/liveProgress.test.js +89 -0
  105. package/dist/lib/mathUnicode.js +685 -0
  106. package/dist/lib/memory.js +123 -0
  107. package/dist/lib/memoryMonitor.js +76 -0
  108. package/dist/lib/messages.js +3 -0
  109. package/dist/lib/messages.test.js +25 -0
  110. package/dist/lib/osc52.js +53 -0
  111. package/dist/lib/perfPane.js +94 -0
  112. package/dist/lib/platform.js +312 -0
  113. package/dist/lib/precisionWheel.js +25 -0
  114. package/dist/lib/reasoning.js +39 -0
  115. package/dist/lib/rpc.js +26 -0
  116. package/dist/lib/subagentTree.js +287 -0
  117. package/dist/lib/syntax.js +89 -0
  118. package/dist/lib/terminalModes.js +46 -0
  119. package/dist/lib/terminalParity.js +48 -0
  120. package/dist/lib/terminalSetup.js +321 -0
  121. package/dist/lib/text.js +203 -0
  122. package/dist/lib/text.test.js +18 -0
  123. package/dist/lib/todo.js +2 -0
  124. package/dist/lib/todo.test.js +22 -0
  125. package/dist/lib/viewportStore.js +82 -0
  126. package/dist/lib/virtualHeights.js +61 -0
  127. package/dist/lib/wheelAccel.js +143 -0
  128. package/dist/theme.js +398 -0
  129. package/dist/types.js +1 -7
  130. 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
- // Streaming markdown renderer for the CVC TUI.
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
- // Behaviour parity with Hermes' streamingMarkdown:
5
- // * Splits in-flight text at the last stable top-level block boundary
6
- // (a blank line OUTSIDE a fenced code block) so the stable prefix is
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
- // Block support:
12
- // * ATX headings (#, ##, ) bold, accent-coloured by level
13
- // * Fenced code (``` / ~~~) w/ syntax highlighting via cli-highlight
14
- // * Bullet & numbered lists (incl. nesting via leading whitespace)
15
- // * Blockquotes (`> …`) — dim left bar
16
- // * Horizontal rules (---, ***)
17
- // * Inline: **bold**, *italic*, `code`, ~~strike~~, [text](url)
18
- // * Bare URLs and OSC 8 hyperlinks (terminal-clickable)
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
- // Implementation notes:
21
- // * We do NOT use marked's full HTML pipeline — the lexer is enough.
22
- // marked.lexer() returns block tokens; we recurse into inline tokens
23
- // ourselves so we can control Ink primitives exactly.
24
- // * Code highlighting is opt-in per language. cli-highlight already
25
- // emits ANSI escapes (which Ink's <Text> passes through).
26
- // * Links: we render them as OSC 8 hyperlinks via a raw escape string
27
- // wrapped in <Text> terminals that don't understand OSC 8 will
28
- // simply show the visible text portion.
29
- import { Fragment, memo, useRef } from 'react';
30
- import { Box, Text } from 'ink';
31
- import { marked } from 'marked';
32
- import { highlight, supportsLanguage } from 'cli-highlight';
33
- import { CVC_THEME } from '../types.js';
34
- const ESC = '\x1b';
35
- const OSC8_OPEN = (url) => `${ESC}]8;;${url}${ESC}\\`;
36
- const OSC8_CLOSE = `${ESC}]8;;${ESC}\\`;
37
- /** Wrap visible text with OSC 8 hyperlink escape so terminals make it clickable. */
38
- export const osc8Link = (url, label) => `${OSC8_OPEN(url)}${label}${OSC8_CLOSE}`;
39
- // -- stable-boundary chunker ---------------------------------------------------
40
- /** Count whether `s[0..end)` ends inside an open ``` / ~~~ fence. */
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 (nl < 0 || nl >= end)
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
- /** Last \n\n boundary outside a fenced block, returned as start-of-next-block index. */
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
- // -- inline rendering ---------------------------------------------------------
72
- const HEADING_COLOURS = {
73
- 1: CVC_THEME.primary, // CVC red
74
- 2: CVC_THEME.accent,
75
- 3: CVC_THEME.accent,
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
- return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginY: 0, children: [language ? _jsxs(Text, { color: CVC_THEME.dim, children: ["\u2500 ", language] }) : null, body.split('\n').map((line, i) => (_jsx(Text, { children: line }, i)))] }, k));
136
- };
137
- const renderListItem = (item, ordered, index, k) => {
138
- const bullet = ordered ? `${index + 1}. ` : '• ';
139
- return (_jsxs(Box, { children: [_jsx(Text, { color: CVC_THEME.accent, children: bullet }), _jsx(Box, { flexDirection: "column", children: item.tokens?.map((child, ci) => (_jsx(Fragment, { children: renderBlock(child, `${k}-${ci}`) }, ci))) })] }, k));
140
- };
141
- const renderBlock = (tok, k) => {
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 boundary = findStableBoundary(content);
204
- if (boundary > stableRef.current.length) {
205
- stableRef.current = content.slice(0, boundary);
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
- const stable = stableRef.current;
208
- const unstable = content.slice(stable.length);
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
- if (!stable)
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
  });