cvc-tui 0.1.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 (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +103 -0
  3. package/dist/app/completion.js +98 -0
  4. package/dist/app/historyStore.js +119 -0
  5. package/dist/app/inputBuffer.js +116 -0
  6. package/dist/app/inputStore.js +24 -0
  7. package/dist/app/promptStore.js +40 -0
  8. package/dist/app/queueStore.js +21 -0
  9. package/dist/app/slash/commands/core.js +292 -0
  10. package/dist/app/slash/commands/debug.js +11 -0
  11. package/dist/app/slash/commands/ops.js +163 -0
  12. package/dist/app/slash/commands/session.js +91 -0
  13. package/dist/app/slash/commands/setup.js +47 -0
  14. package/dist/app/slash/commands/toggles.js +36 -0
  15. package/dist/app/slash/registry.js +79 -0
  16. package/dist/app/slash/types.js +16 -0
  17. package/dist/app/turnStore.js +60 -0
  18. package/dist/app/uiStore.js +31 -0
  19. package/dist/app.js +219 -0
  20. package/dist/banner.js +20 -0
  21. package/dist/components/appLayout.js +22 -0
  22. package/dist/components/branding.js +6 -0
  23. package/dist/components/overlays/confirmPrompt.js +25 -0
  24. package/dist/components/overlays/helpOverlay.js +75 -0
  25. package/dist/components/overlays/historySearch.js +48 -0
  26. package/dist/components/overlays/modelPicker.js +59 -0
  27. package/dist/components/overlays/overlayUtils.js +18 -0
  28. package/dist/components/overlays/secretPrompt.js +35 -0
  29. package/dist/components/overlays/sessionPicker.js +92 -0
  30. package/dist/components/overlays/skillsHub.js +70 -0
  31. package/dist/components/streamingMarkdown.js +220 -0
  32. package/dist/components/textInput.js +264 -0
  33. package/dist/components/thinking.js +39 -0
  34. package/dist/components/transcript.js +22 -0
  35. package/dist/config/timing.js +14 -0
  36. package/dist/entry.js +43 -0
  37. package/dist/gateway/client.js +312 -0
  38. package/dist/types.js +7 -0
  39. package/package.json +77 -0
@@ -0,0 +1,18 @@
1
+ // Tiny shared helpers for CVC arrow-key navigable overlays.
2
+ // Keeps modelPicker / sessionPicker / skillsHub / helpOverlay consistent
3
+ // without dragging in the heavier Hermes overlayControls surface.
4
+ /** Window a list around the selected index so a cursor at any depth stays visible. */
5
+ export function windowItems(all, selected, visible) {
6
+ if (all.length <= visible)
7
+ return { items: all, offset: 0 };
8
+ const half = Math.floor(visible / 2);
9
+ let offset = Math.max(0, Math.min(all.length - visible, selected - half));
10
+ return { items: all.slice(offset, offset + visible), offset };
11
+ }
12
+ /** Filter a list of strings by a case-insensitive substring (q is the query). */
13
+ export function filterRows(rows, q, key) {
14
+ const needle = q.trim().toLowerCase();
15
+ if (!needle)
16
+ return rows;
17
+ return rows.filter(r => key(r).toLowerCase().includes(needle));
18
+ }
@@ -0,0 +1,35 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Secret prompt overlay — masked input. Resolves with the entered string,
3
+ // or null on Esc.
4
+ import { useState } from 'react';
5
+ import { Box, Text, useInput } from 'ink';
6
+ import { useStore } from '@nanostores/react';
7
+ import { $prompt, resolveActivePrompt } from '../../app/promptStore.js';
8
+ export const SecretPrompt = () => {
9
+ const cur = useStore($prompt);
10
+ const [value, setValue] = useState('');
11
+ if (!cur || cur.kind !== 'secret')
12
+ return null;
13
+ const mask = cur.mask !== false;
14
+ useInput((input, key) => {
15
+ if (key.escape) {
16
+ resolveActivePrompt(null);
17
+ return;
18
+ }
19
+ if (key.return) {
20
+ resolveActivePrompt(value);
21
+ setValue('');
22
+ return;
23
+ }
24
+ if (key.backspace || key.delete) {
25
+ setValue(value.slice(0, -1));
26
+ return;
27
+ }
28
+ if (input && !key.ctrl && !key.meta && !key.tab) {
29
+ setValue(value + input);
30
+ return;
31
+ }
32
+ });
33
+ const display = mask ? '●'.repeat(value.length) : value;
34
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "#e63946", bold: true, children: "\uD83D\uDD12 " }), _jsxs(Text, { children: [cur.prompt, " "] }), _jsx(Text, { children: display })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: "Enter to submit \u00B7 Esc to cancel" }) })] }));
35
+ };
@@ -0,0 +1,92 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ // CVC sessionPicker overlay — lists ~/.cvc/sessions/*.json with timestamp +
3
+ // first-message preview, arrow-key navigable, filter input.
4
+ import { useEffect, useMemo, useState } from 'react';
5
+ import { Box, Text, useInput } from 'ink';
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+ import * as os from 'node:os';
9
+ import { CVC_THEME } from '../../types.js';
10
+ import { filterRows, windowItems } from './overlayUtils.js';
11
+ const VISIBLE = 10;
12
+ /** Load sessions from ~/.cvc/sessions/*.json. Resilient to malformed files. */
13
+ export function loadSessions(dir = path.join(os.homedir(), '.cvc', 'sessions')) {
14
+ if (!fs.existsSync(dir))
15
+ return [];
16
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
17
+ const out = [];
18
+ for (const f of files) {
19
+ const fp = path.join(dir, f);
20
+ try {
21
+ const stat = fs.statSync(fp);
22
+ const raw = fs.readFileSync(fp, 'utf8');
23
+ const j = JSON.parse(raw);
24
+ const first = (j.messages ?? []).find(m => m.role === 'user');
25
+ const preview = (first?.content ?? '').replace(/\s+/g, ' ').trim().slice(0, 80) || '(empty)';
26
+ out.push({
27
+ id: j.id ?? f.replace(/\.json$/, ''),
28
+ path: fp,
29
+ mtime: stat.mtimeMs,
30
+ preview,
31
+ title: j.title,
32
+ });
33
+ }
34
+ catch {
35
+ // skip malformed
36
+ }
37
+ }
38
+ return out.sort((a, b) => b.mtime - a.mtime);
39
+ }
40
+ const fmtAge = (ms) => {
41
+ const d = Math.max(0, Date.now() - ms);
42
+ const m = Math.floor(d / 60000);
43
+ if (m < 1)
44
+ return 'just now';
45
+ if (m < 60)
46
+ return `${m}m ago`;
47
+ const h = Math.floor(m / 60);
48
+ if (h < 24)
49
+ return `${h}h ago`;
50
+ return `${Math.floor(h / 24)}d ago`;
51
+ };
52
+ export const SessionPicker = ({ sessions, onSelect, onCancel }) => {
53
+ const [all, setAll] = useState(sessions ?? []);
54
+ const [query, setQuery] = useState('');
55
+ const [idx, setIdx] = useState(0);
56
+ useEffect(() => {
57
+ if (!sessions)
58
+ setAll(loadSessions());
59
+ }, [sessions]);
60
+ const filtered = useMemo(() => filterRows(all, query, s => `${s.title ?? ''} ${s.preview} ${s.id}`), [all, query]);
61
+ const safeIdx = Math.min(idx, Math.max(0, filtered.length - 1));
62
+ useInput((input, key) => {
63
+ if (key.escape)
64
+ return onCancel();
65
+ if (key.upArrow && safeIdx > 0)
66
+ return setIdx(safeIdx - 1);
67
+ if (key.downArrow && safeIdx < filtered.length - 1)
68
+ return setIdx(safeIdx + 1);
69
+ if (key.return) {
70
+ const s = filtered[safeIdx];
71
+ if (s)
72
+ onSelect(s.id);
73
+ return;
74
+ }
75
+ if (key.backspace || key.delete) {
76
+ setQuery(q => q.slice(0, -1));
77
+ setIdx(0);
78
+ return;
79
+ }
80
+ if (input && !key.ctrl && !key.meta && !key.tab) {
81
+ setQuery(q => q + input);
82
+ setIdx(0);
83
+ }
84
+ });
85
+ const rows = filtered.map(s => `${(s.title ?? s.id).slice(0, 24).padEnd(24)} · ${fmtAge(s.mtime).padEnd(10)} · ${s.preview}`);
86
+ const { items, offset } = windowItems(rows, safeIdx, VISIBLE);
87
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: CVC_THEME.primary, bold: true, children: ["Sessions (", filtered.length, "/", all.length, ")"] }), _jsxs(Text, { dimColor: true, children: ["filter: ", query || '(none)'] }), offset > 0 ? _jsx(Text, { dimColor: true, children: ` ↑ ${offset} more` }) : _jsx(Text, { children: " " }), items.length === 0 ? (_jsx(Text, { dimColor: true, children: "no sessions" })) : (items.map((row, i) => {
88
+ const k = offset + i;
89
+ const active = safeIdx === k;
90
+ return (_jsxs(Text, { color: active ? CVC_THEME.primary : undefined, inverse: active, bold: active, children: [active ? '▸ ' : ' ', row] }, filtered[k]?.id ?? `s-${k}`));
91
+ })), offset + VISIBLE < rows.length ? (_jsx(Text, { dimColor: true, children: ` ↓ ${rows.length - offset - VISIBLE} more` })) : (_jsx(Text, { children: " " })), _jsx(Text, { dimColor: true, children: "type to filter \u00B7 \u2191/\u2193 select \u00B7 Enter resume \u00B7 Esc cancel" })] }));
92
+ };
@@ -0,0 +1,70 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // CVC skillsHub overlay — list skills (via gateway or supplied prop), search/filter,
3
+ // arrow-key navigable, Enter triggers an onActivate callback (e.g. to inject the
4
+ // skill into the next user message).
5
+ import { useEffect, useMemo, useState } from 'react';
6
+ import { Box, Text, useInput } from 'ink';
7
+ import { CVC_THEME } from '../../types.js';
8
+ import { filterRows, windowItems } from './overlayUtils.js';
9
+ const VISIBLE = 12;
10
+ export const SkillsHub = ({ skills, loadSkills, onActivate, onCancel, }) => {
11
+ const [all, setAll] = useState(skills ?? []);
12
+ const [loading, setLoading] = useState(!skills && Boolean(loadSkills));
13
+ const [query, setQuery] = useState('');
14
+ const [idx, setIdx] = useState(0);
15
+ useEffect(() => {
16
+ if (skills || !loadSkills)
17
+ return;
18
+ let cancelled = false;
19
+ loadSkills()
20
+ .then(list => {
21
+ if (!cancelled) {
22
+ setAll(list);
23
+ setLoading(false);
24
+ }
25
+ })
26
+ .catch(() => {
27
+ if (!cancelled)
28
+ setLoading(false);
29
+ });
30
+ return () => {
31
+ cancelled = true;
32
+ };
33
+ }, [skills, loadSkills]);
34
+ const filtered = useMemo(() => filterRows(all, query, s => `${s.name} ${s.description ?? ''} ${s.category ?? ''}`), [all, query]);
35
+ const safeIdx = Math.min(idx, Math.max(0, filtered.length - 1));
36
+ useInput((input, key) => {
37
+ if (key.escape)
38
+ return onCancel();
39
+ if (key.upArrow && safeIdx > 0)
40
+ return setIdx(safeIdx - 1);
41
+ if (key.downArrow && safeIdx < filtered.length - 1)
42
+ return setIdx(safeIdx + 1);
43
+ if (key.return) {
44
+ const s = filtered[safeIdx];
45
+ if (s)
46
+ onActivate(s);
47
+ return;
48
+ }
49
+ if (key.backspace || key.delete) {
50
+ setQuery(q => q.slice(0, -1));
51
+ setIdx(0);
52
+ return;
53
+ }
54
+ if (input && !key.ctrl && !key.meta && !key.tab) {
55
+ setQuery(q => q + input);
56
+ setIdx(0);
57
+ }
58
+ });
59
+ const rows = filtered.map(s => {
60
+ const cat = s.category ? `[${s.category}] ` : '';
61
+ const desc = s.description ? ` — ${s.description}` : '';
62
+ return `${cat}${s.name}${desc}`;
63
+ });
64
+ const { items, offset } = windowItems(rows, safeIdx, VISIBLE);
65
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: CVC_THEME.primary, bold: true, children: ["Skills Hub (", filtered.length, "/", all.length, ")"] }), _jsxs(Text, { dimColor: true, children: ["filter: ", query || '(none)'] }), loading ? (_jsx(Text, { dimColor: true, children: "loading skills\u2026" })) : items.length === 0 ? (_jsx(Text, { dimColor: true, children: "no skills match" })) : (_jsxs(_Fragment, { children: [offset > 0 ? _jsx(Text, { dimColor: true, children: ` ↑ ${offset} more` }) : _jsx(Text, { children: " " }), items.map((row, i) => {
66
+ const k = offset + i;
67
+ const active = safeIdx === k;
68
+ return (_jsxs(Text, { color: active ? CVC_THEME.primary : undefined, inverse: active, bold: active, children: [active ? '▸ ' : ' ', row] }, filtered[k]?.name ?? `sk-${k}`));
69
+ }), offset + VISIBLE < rows.length ? (_jsx(Text, { dimColor: true, children: ` ↓ ${rows.length - offset - VISIBLE} more` })) : (_jsx(Text, { children: " " }))] })), _jsx(Text, { dimColor: true, children: "type to filter \u00B7 \u2191/\u2193 select \u00B7 Enter activate \u00B7 Esc cancel" })] }));
70
+ };
@@ -0,0 +1,220 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Streaming markdown renderer for the CVC TUI.
3
+ //
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.
10
+ //
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)
19
+ //
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. */
41
+ const fenceOpenAt = (s, end) => {
42
+ let codeOpen = false;
43
+ let i = 0;
44
+ while (i < end) {
45
+ const nl = s.indexOf('\n', i);
46
+ const lineEnd = nl < 0 || nl > end ? end : nl;
47
+ const line = s.slice(i, lineEnd).trim();
48
+ if (/^(?:`{3,}|~{3,})/.test(line)) {
49
+ codeOpen = !codeOpen;
50
+ }
51
+ if (nl < 0 || nl >= end)
52
+ break;
53
+ i = nl + 1;
54
+ }
55
+ return codeOpen;
56
+ };
57
+ /** Last \n\n boundary outside a fenced block, returned as start-of-next-block index. */
58
+ export const findStableBoundary = (text) => {
59
+ let idx = text.length;
60
+ while (idx > 0) {
61
+ const boundary = text.lastIndexOf('\n\n', idx - 1);
62
+ if (boundary < 0)
63
+ return -1;
64
+ const splitAt = boundary + 2;
65
+ if (!fenceOpenAt(text, splitAt))
66
+ return splitAt;
67
+ idx = boundary;
68
+ }
69
+ return -1;
70
+ };
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
+ }
134
+ }
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 = '';
202
+ }
203
+ const boundary = findStableBoundary(content);
204
+ if (boundary > stableRef.current.length) {
205
+ stableRef.current = content.slice(0, boundary);
206
+ }
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 });
214
+ }
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 })] }));
220
+ });