cvc-tui 0.1.0 → 0.4.1
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/entry.js +71145 -34
- package/package.json +3 -2
- package/dist/app/completion.js +0 -98
- package/dist/app/historyStore.js +0 -119
- package/dist/app/inputBuffer.js +0 -116
- package/dist/app/inputStore.js +0 -24
- package/dist/app/promptStore.js +0 -40
- package/dist/app/queueStore.js +0 -21
- package/dist/app/slash/commands/core.js +0 -292
- package/dist/app/slash/commands/debug.js +0 -11
- package/dist/app/slash/commands/ops.js +0 -163
- package/dist/app/slash/commands/session.js +0 -91
- package/dist/app/slash/commands/setup.js +0 -47
- package/dist/app/slash/commands/toggles.js +0 -36
- package/dist/app/slash/registry.js +0 -79
- package/dist/app/slash/types.js +0 -16
- package/dist/app/turnStore.js +0 -60
- package/dist/app/uiStore.js +0 -31
- package/dist/app.js +0 -219
- package/dist/banner.js +0 -20
- package/dist/components/appLayout.js +0 -22
- package/dist/components/branding.js +0 -6
- package/dist/components/overlays/confirmPrompt.js +0 -25
- package/dist/components/overlays/helpOverlay.js +0 -75
- package/dist/components/overlays/historySearch.js +0 -48
- package/dist/components/overlays/modelPicker.js +0 -59
- package/dist/components/overlays/overlayUtils.js +0 -18
- package/dist/components/overlays/secretPrompt.js +0 -35
- package/dist/components/overlays/sessionPicker.js +0 -92
- package/dist/components/overlays/skillsHub.js +0 -70
- package/dist/components/streamingMarkdown.js +0 -220
- package/dist/components/textInput.js +0 -264
- package/dist/components/thinking.js +0 -39
- package/dist/components/transcript.js +0 -22
- package/dist/config/timing.js +0 -14
- package/dist/gateway/client.js +0 -312
- package/dist/types.js +0 -7
|
@@ -1,264 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
// CVC power-input composer — Hermes ui-tui parity.
|
|
3
|
-
//
|
|
4
|
-
// Features:
|
|
5
|
-
// • Multi-line buffer (Alt+Enter / Shift+Enter newline; Enter submit)
|
|
6
|
-
// • Word-wrap to terminal columns
|
|
7
|
-
// • Visible inverse-block cursor
|
|
8
|
-
// • Dim live char/line counter footer
|
|
9
|
-
// • Up/Down history cycle (when on first/last logical line)
|
|
10
|
-
// • Ctrl+R reverse-incremental search overlay
|
|
11
|
-
// • Tab completion: slash commands + file paths
|
|
12
|
-
// • Ctrl+L clears
|
|
13
|
-
// • Queued submission indicator `[queued: N]`
|
|
14
|
-
// • Hidden when promptStore has an active confirm/secret prompt
|
|
15
|
-
//
|
|
16
|
-
// Pure buffer math lives in ../app/inputBuffer.ts; this file is the React
|
|
17
|
-
// glue + key dispatch.
|
|
18
|
-
import { useCallback, useEffect, useState } from 'react';
|
|
19
|
-
import { Box, Text, useInput } from 'ink';
|
|
20
|
-
import { useStore } from '@nanostores/react';
|
|
21
|
-
import { $buffer, $completion, resetCompletion, setBuffer, setBufferRaw, } from '../app/inputStore.js';
|
|
22
|
-
import { emptyBuffer, insert, backspace, del as bufDel, moveLeft, moveRight, moveHome, moveEnd, moveUp, moveDown, onFirstLine, onLastLine, bufferStats, cursorRowCol, } from '../app/inputBuffer.js';
|
|
23
|
-
import { $history, loadHistory, pushHistory, historyPrev, historyNext, resetHistoryBrowse, } from '../app/historyStore.js';
|
|
24
|
-
import { $queue } from '../app/queueStore.js';
|
|
25
|
-
import { $prompt } from '../app/promptStore.js';
|
|
26
|
-
import { $ui } from '../app/uiStore.js';
|
|
27
|
-
import { tokenAtCursor, candidatesFor, applyCandidate, } from '../app/completion.js';
|
|
28
|
-
import { HistorySearch } from './overlays/historySearch.js';
|
|
29
|
-
import { ConfirmPrompt } from './overlays/confirmPrompt.js';
|
|
30
|
-
import { SecretPrompt } from './overlays/secretPrompt.js';
|
|
31
|
-
const PROMPT_COLOR = '#e63946';
|
|
32
|
-
/**
|
|
33
|
-
* Render the buffer with an inverse-block cursor at `cursor`. Wraps to `cols`
|
|
34
|
-
* columns. Returns an array of visual lines (already containing ANSI invert
|
|
35
|
-
* codes for the cursor cell).
|
|
36
|
-
*/
|
|
37
|
-
function renderWrapped(buf, _cols) {
|
|
38
|
-
const text = buf.text;
|
|
39
|
-
const cursor = Math.max(0, Math.min(buf.cursor, text.length));
|
|
40
|
-
// Insert cursor sentinel as inverted character.
|
|
41
|
-
const ESC = '\x1b';
|
|
42
|
-
const INV = `${ESC}[7m`;
|
|
43
|
-
const OFF = `${ESC}[27m`;
|
|
44
|
-
const ch = cursor < text.length && text[cursor] !== '\n' ? text[cursor] : ' ';
|
|
45
|
-
const withCursor = text.slice(0, cursor) + INV + ch + OFF + text.slice(cursor + (cursor < text.length && text[cursor] !== '\n' ? 1 : 0));
|
|
46
|
-
// Wrap each logical line. We can't naively wrapText because of escape codes;
|
|
47
|
-
// however the codes are zero-width so length-based wrap with the *raw* text
|
|
48
|
-
// still produces visually correct output if we map back. For simplicity and
|
|
49
|
-
// since terminal widths in our tests are generous, we wrap on raw text with
|
|
50
|
-
// injected codes treated as part of the line — the codes themselves don't
|
|
51
|
-
// consume cells, but wrapText sees them as chars. To stay correct, wrap the
|
|
52
|
-
// raw line and re-inject the cursor cell.
|
|
53
|
-
// Simpler: split on '\n' from withCursor and let Ink soft-wrap visually.
|
|
54
|
-
return withCursor.split('\n');
|
|
55
|
-
}
|
|
56
|
-
/** Slim hint footer: `chars · lines · [queued: N]`. */
|
|
57
|
-
const Hints = ({ chars, lineCount, queued, mode, }) => {
|
|
58
|
-
const parts = [`${chars} chars`, `${lineCount} line${lineCount === 1 ? '' : 's'}`];
|
|
59
|
-
if (queued > 0)
|
|
60
|
-
parts.push(`queued: ${queued}`);
|
|
61
|
-
if (mode)
|
|
62
|
-
parts.push(mode);
|
|
63
|
-
return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: parts.join(' · ') }) }));
|
|
64
|
-
};
|
|
65
|
-
export const Composer = ({ onSubmit, placeholder, prompt = '❯' }) => {
|
|
66
|
-
const buf = useStore($buffer);
|
|
67
|
-
const completion = useStore($completion);
|
|
68
|
-
const ui = useStore($ui);
|
|
69
|
-
const queue = useStore($queue);
|
|
70
|
-
const prompts = useStore($prompt);
|
|
71
|
-
const history = useStore($history);
|
|
72
|
-
const [searchOpen, setSearchOpen] = useState(false);
|
|
73
|
-
// One-time history load.
|
|
74
|
-
useEffect(() => {
|
|
75
|
-
if (history.length === 0)
|
|
76
|
-
loadHistory();
|
|
77
|
-
}, [history.length]);
|
|
78
|
-
// If a prompt is active, show the appropriate overlay instead of the composer.
|
|
79
|
-
// (Composer hides — Hermes parity.)
|
|
80
|
-
if (prompts) {
|
|
81
|
-
if (prompts.kind === 'confirm')
|
|
82
|
-
return _jsx(ConfirmPrompt, {});
|
|
83
|
-
if (prompts.kind === 'secret')
|
|
84
|
-
return _jsx(SecretPrompt, {});
|
|
85
|
-
}
|
|
86
|
-
if (searchOpen) {
|
|
87
|
-
return (_jsx(HistorySearch, { onAccept: (entry) => {
|
|
88
|
-
setBuffer({ text: entry, cursor: entry.length });
|
|
89
|
-
setSearchOpen(false);
|
|
90
|
-
}, onCancel: () => setSearchOpen(false) }));
|
|
91
|
-
}
|
|
92
|
-
const cols = Math.max(20, ui.cols ?? 80);
|
|
93
|
-
const submit = useCallback(() => {
|
|
94
|
-
const text = buf.text;
|
|
95
|
-
if (!text.trim()) {
|
|
96
|
-
// Empty submit: just clear.
|
|
97
|
-
setBuffer(emptyBuffer());
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
pushHistory(text);
|
|
101
|
-
resetHistoryBrowse();
|
|
102
|
-
setBuffer(emptyBuffer());
|
|
103
|
-
if (onSubmit)
|
|
104
|
-
onSubmit(text);
|
|
105
|
-
}, [buf.text, onSubmit]);
|
|
106
|
-
const cycleCompletion = useCallback((dir) => {
|
|
107
|
-
// Tab pressed: either start a new cycle or rotate the existing one.
|
|
108
|
-
if (completion.active && completion.candidates.length > 0) {
|
|
109
|
-
const n = completion.candidates.length;
|
|
110
|
-
const nextIdx = (completion.index + dir + n) % n;
|
|
111
|
-
const cand = completion.candidates[nextIdx];
|
|
112
|
-
const replaced = applyCandidate(buf.text, completion.start, completion.prefix.length, cand);
|
|
113
|
-
// Use raw setter so we don't reset the cycle.
|
|
114
|
-
setBufferRaw({ text: replaced.text, cursor: replaced.cursor });
|
|
115
|
-
$completion.set({
|
|
116
|
-
...completion,
|
|
117
|
-
index: nextIdx,
|
|
118
|
-
prefix: cand,
|
|
119
|
-
});
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
const ctx = tokenAtCursor(buf.text, buf.cursor);
|
|
123
|
-
if (!ctx)
|
|
124
|
-
return;
|
|
125
|
-
const cands = candidatesFor(ctx);
|
|
126
|
-
if (cands.length === 0)
|
|
127
|
-
return;
|
|
128
|
-
const first = cands[0];
|
|
129
|
-
const replaced = applyCandidate(buf.text, ctx.start, ctx.token.length, first);
|
|
130
|
-
setBufferRaw({ text: replaced.text, cursor: replaced.cursor });
|
|
131
|
-
$completion.set({
|
|
132
|
-
active: true,
|
|
133
|
-
prefix: first,
|
|
134
|
-
candidates: cands,
|
|
135
|
-
index: 0,
|
|
136
|
-
start: ctx.start,
|
|
137
|
-
});
|
|
138
|
-
}, [buf.text, buf.cursor, completion]);
|
|
139
|
-
useInput((input, key) => {
|
|
140
|
-
// Ctrl+L: clear buffer.
|
|
141
|
-
if (key.ctrl && input === 'l') {
|
|
142
|
-
setBuffer(emptyBuffer());
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
// Ctrl+R: open reverse-search overlay.
|
|
146
|
-
if (key.ctrl && input === 'r') {
|
|
147
|
-
setSearchOpen(true);
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
// Ctrl+C: clear current input (don't crash; parent handles outer SIGINT).
|
|
151
|
-
if (key.ctrl && input === 'c') {
|
|
152
|
-
if (buf.text.length === 0) {
|
|
153
|
-
process.exit(0);
|
|
154
|
-
}
|
|
155
|
-
setBuffer(emptyBuffer());
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
// Tab / Shift+Tab.
|
|
159
|
-
if (key.tab) {
|
|
160
|
-
cycleCompletion(key.shift ? -1 : 1);
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
// Enter / newline. Ink reports Alt+Enter via key.meta, Shift+Enter via key.shift.
|
|
164
|
-
if (key.return) {
|
|
165
|
-
if (key.meta || key.shift) {
|
|
166
|
-
setBuffer(insert(buf, '\n'));
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
submit();
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
if (key.backspace || key.delete) {
|
|
173
|
-
// Ink convention: `key.delete` is forward-delete on some terms; `key.backspace` for backspace.
|
|
174
|
-
if (key.delete && !key.backspace) {
|
|
175
|
-
setBuffer(bufDel(buf));
|
|
176
|
-
}
|
|
177
|
-
else {
|
|
178
|
-
setBuffer(backspace(buf));
|
|
179
|
-
}
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
if (key.leftArrow) {
|
|
183
|
-
setBuffer(moveLeft(buf));
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
if (key.rightArrow) {
|
|
187
|
-
setBuffer(moveRight(buf));
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
if (key.upArrow) {
|
|
191
|
-
// History cycle when on the first logical line.
|
|
192
|
-
if (onFirstLine(buf)) {
|
|
193
|
-
const entry = historyPrev(buf.text);
|
|
194
|
-
if (entry !== null) {
|
|
195
|
-
setBuffer({ text: entry, cursor: entry.length });
|
|
196
|
-
}
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
setBuffer(moveUp(buf));
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
if (key.downArrow) {
|
|
203
|
-
if (onLastLine(buf)) {
|
|
204
|
-
const entry = historyNext();
|
|
205
|
-
if (entry !== null) {
|
|
206
|
-
setBuffer({ text: entry, cursor: entry.length });
|
|
207
|
-
}
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
setBuffer(moveDown(buf));
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
// Home / End — Ink doesn't expose these directly; they often arrive as raw escapes.
|
|
214
|
-
if (key.ctrl && input === 'a') {
|
|
215
|
-
setBuffer(moveHome(buf));
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
if (key.ctrl && input === 'e') {
|
|
219
|
-
setBuffer(moveEnd(buf));
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
if (key.escape) {
|
|
223
|
-
// Esc: cancel completion, otherwise clear buffer.
|
|
224
|
-
if (completion.active) {
|
|
225
|
-
resetCompletion();
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
setBuffer(emptyBuffer());
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
// Printable input.
|
|
232
|
-
if (input && !key.ctrl && !key.meta) {
|
|
233
|
-
setBuffer(insert(buf, input));
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
});
|
|
237
|
-
const stats = bufferStats(buf.text);
|
|
238
|
-
const wrapped = renderWrapped(buf, cols);
|
|
239
|
-
const showPlaceholder = buf.text.length === 0 && placeholder;
|
|
240
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { color: PROMPT_COLOR, children: [prompt, " "] }), showPlaceholder ? (_jsx(Text, { dimColor: true, children: placeholder })) : (_jsx(Box, { flexDirection: "column", children: wrapped.map((line, i) => (_jsx(Text, { children: line || ' ' }, i))) }))] }), _jsx(Hints, { chars: stats.chars, lineCount: stats.lineCount, queued: queue.length, mode: completion.active ? `tab ${completion.index + 1}/${completion.candidates.length}` : undefined })] }));
|
|
241
|
-
};
|
|
242
|
-
export const TextInput = ({ value, onChange, onSubmit, placeholder, prompt }) => {
|
|
243
|
-
const buf = useStore($buffer);
|
|
244
|
-
// Sync controlled value into the store on mount / external change.
|
|
245
|
-
useEffect(() => {
|
|
246
|
-
if (buf.text !== value) {
|
|
247
|
-
setBuffer({ text: value, cursor: value.length });
|
|
248
|
-
}
|
|
249
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
250
|
-
}, [value]);
|
|
251
|
-
// Push buffer changes back to parent on every text change.
|
|
252
|
-
useEffect(() => {
|
|
253
|
-
if (buf.text !== value)
|
|
254
|
-
onChange(buf.text);
|
|
255
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
256
|
-
}, [buf.text]);
|
|
257
|
-
return (_jsx(Composer, { placeholder: placeholder, prompt: prompt, onSubmit: (t) => {
|
|
258
|
-
onChange('');
|
|
259
|
-
if (onSubmit)
|
|
260
|
-
onSubmit(t);
|
|
261
|
-
} }));
|
|
262
|
-
};
|
|
263
|
-
// Re-exports for tests / consumers.
|
|
264
|
-
export { cursorRowCol };
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useState } from 'react';
|
|
3
|
-
import { Box, Text } from 'ink';
|
|
4
|
-
import { TIMING } from '../config/timing.js';
|
|
5
|
-
// unicode-animations exposes a large catalog of frame arrays. We import lazily
|
|
6
|
-
// and gracefully fall back if the shape changes.
|
|
7
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
|
-
let UA = null;
|
|
9
|
-
try {
|
|
10
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
11
|
-
UA = await import('unicode-animations').catch(() => null);
|
|
12
|
-
}
|
|
13
|
-
catch {
|
|
14
|
-
UA = null;
|
|
15
|
-
}
|
|
16
|
-
const FALLBACK_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
17
|
-
function pickFrames() {
|
|
18
|
-
try {
|
|
19
|
-
if (UA?.default?.dots)
|
|
20
|
-
return UA.default.dots;
|
|
21
|
-
if (Array.isArray(UA?.dots))
|
|
22
|
-
return UA.dots;
|
|
23
|
-
}
|
|
24
|
-
catch {
|
|
25
|
-
/* ignore */
|
|
26
|
-
}
|
|
27
|
-
return FALLBACK_FRAMES;
|
|
28
|
-
}
|
|
29
|
-
export const Thinking = ({ label = 'thinking', color = '#e63946' }) => {
|
|
30
|
-
const [frame, setFrame] = useState(0);
|
|
31
|
-
const frames = pickFrames();
|
|
32
|
-
useEffect(() => {
|
|
33
|
-
const id = setInterval(() => {
|
|
34
|
-
setFrame((f) => (f + 1) % frames.length);
|
|
35
|
-
}, TIMING.SPINNER_FRAME_MS);
|
|
36
|
-
return () => clearInterval(id);
|
|
37
|
-
}, [frames.length]);
|
|
38
|
-
return (_jsxs(Box, { children: [_jsxs(Text, { color: color, children: [frames[frame], " "] }), _jsxs(Text, { color: "gray", children: [label, "\u2026"] })] }));
|
|
39
|
-
};
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
3
|
-
import { useStore } from '@nanostores/react';
|
|
4
|
-
import { $messages, $turn } from '../app/turnStore.js';
|
|
5
|
-
import { Markdown, StreamingMarkdown } from './streamingMarkdown.js';
|
|
6
|
-
import { CVC_THEME } from '../types.js';
|
|
7
|
-
const Bubble = ({ msg, streaming }) => {
|
|
8
|
-
const isUser = msg.role === 'user';
|
|
9
|
-
const borderColor = isUser ? CVC_THEME.primary : CVC_THEME.dim;
|
|
10
|
-
const tag = isUser ? 'you' : msg.role === 'assistant' ? 'cvc' : msg.role;
|
|
11
|
-
const tagColor = isUser ? CVC_THEME.primary : CVC_THEME.accent;
|
|
12
|
-
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { bold: true, color: tagColor, children: `▌ ${tag}` }), _jsx(Box, { borderStyle: "round", borderColor: borderColor, paddingX: 1, flexDirection: "column", children: isUser ? (_jsx(Text, { children: msg.content })) : streaming ? (_jsx(StreamingMarkdown, { content: msg.content })) : (_jsx(Markdown, { text: msg.content })) })] }));
|
|
13
|
-
};
|
|
14
|
-
export const Transcript = () => {
|
|
15
|
-
const messages = useStore($messages);
|
|
16
|
-
const turn = useStore($turn);
|
|
17
|
-
if (messages.length === 0) {
|
|
18
|
-
return (_jsx(Box, { marginY: 1, children: _jsx(Text, { color: CVC_THEME.dim, children: "\u2014 no messages yet \u2014 type below to start \u2014" }) }));
|
|
19
|
-
}
|
|
20
|
-
const lastId = messages[messages.length - 1]?.id;
|
|
21
|
-
return (_jsx(Box, { flexDirection: "column", marginY: 1, children: messages.map((m) => (_jsx(Bubble, { msg: m, streaming: m.role === 'assistant' && m.id === lastId && turn?.status === 'streaming' }, m.id))) }));
|
|
22
|
-
};
|
package/dist/config/timing.js
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
// Centralised timing constants for the CVC TUI.
|
|
2
|
-
// Keep all magic numbers controlling UI cadence in this file.
|
|
3
|
-
export const TIMING = {
|
|
4
|
-
/** Spinner frame interval (ms) */
|
|
5
|
-
SPINNER_FRAME_MS: 80,
|
|
6
|
-
/** Throttle for streaming markdown re-renders (ms) */
|
|
7
|
-
STREAM_THROTTLE_MS: 16,
|
|
8
|
-
/** Debounce on text-input change handlers (ms) */
|
|
9
|
-
INPUT_DEBOUNCE_MS: 0,
|
|
10
|
-
/** Gateway connect retry backoff (ms) */
|
|
11
|
-
GATEWAY_RECONNECT_MS: 1500,
|
|
12
|
-
/** Heartbeat ping interval (ms) */
|
|
13
|
-
HEARTBEAT_MS: 15000,
|
|
14
|
-
};
|
package/dist/gateway/client.js
DELETED
|
@@ -1,312 +0,0 @@
|
|
|
1
|
-
// Real subprocess GatewayClient.
|
|
2
|
-
//
|
|
3
|
-
// Spawns `python -m cvc.tui_gateway.entry` (or attaches to a configured
|
|
4
|
-
// `cvc-tui-gateway` binary), speaks newline-delimited JSON-RPC 2.0 over
|
|
5
|
-
// stdio, and surfaces:
|
|
6
|
-
// * request/response RPCs — `send(method, params)` → Promise<result>
|
|
7
|
-
// * server-push events — `on('event', ev => …)` (e.g. chat.delta,
|
|
8
|
-
// chat.done, chat.start)
|
|
9
|
-
// * stderr lines — `on('stderr', line => …)`
|
|
10
|
-
// * lifecycle — `on('exit', code => …)`,
|
|
11
|
-
// `on('connected', info => …)`
|
|
12
|
-
//
|
|
13
|
-
// The protocol contract matches the Python gateway in cvc/tui_gateway/:
|
|
14
|
-
// request : {jsonrpc:'2.0', id, method, params}
|
|
15
|
-
// response: {jsonrpc:'2.0', id, result|error}
|
|
16
|
-
// event : {jsonrpc:'2.0', method, params} (no id)
|
|
17
|
-
//
|
|
18
|
-
// During `connect()` we issue a `session.info` handshake; the resolved
|
|
19
|
-
// session payload (version, model, provider, session_id, cwd) is emitted
|
|
20
|
-
// as `connected` and also returned from connect().
|
|
21
|
-
//
|
|
22
|
-
// Tests inject a fake transport via the `transport` constructor option.
|
|
23
|
-
// In production the transport is a child_process spawned with
|
|
24
|
-
// `stdio: ['pipe','pipe','pipe']`.
|
|
25
|
-
import { spawn } from 'node:child_process';
|
|
26
|
-
import { EventEmitter } from 'node:events';
|
|
27
|
-
import { existsSync } from 'node:fs';
|
|
28
|
-
import { delimiter, resolve } from 'node:path';
|
|
29
|
-
import { createInterface } from 'node:readline';
|
|
30
|
-
const DEFAULT_TIMEOUT_MS = Math.max(10000, parseInt(process.env.CVC_TUI_RPC_TIMEOUT_MS ?? '120000', 10) || 120000);
|
|
31
|
-
const STARTUP_TIMEOUT_MS = Math.max(3000, parseInt(process.env.CVC_TUI_STARTUP_TIMEOUT_MS ?? '15000', 10) || 15000);
|
|
32
|
-
const resolvePython = (root) => {
|
|
33
|
-
const configured = process.env.CVC_PYTHON?.trim() || process.env.PYTHON?.trim();
|
|
34
|
-
if (configured)
|
|
35
|
-
return configured;
|
|
36
|
-
const venv = process.env.VIRTUAL_ENV?.trim();
|
|
37
|
-
const candidates = [
|
|
38
|
-
venv && resolve(venv, 'bin/python'),
|
|
39
|
-
venv && resolve(venv, 'bin/python3'),
|
|
40
|
-
venv && resolve(venv, 'Scripts/python.exe'),
|
|
41
|
-
resolve(root, '.venv/bin/python'),
|
|
42
|
-
resolve(root, '.venv/bin/python3'),
|
|
43
|
-
resolve(root, 'venv/bin/python'),
|
|
44
|
-
resolve(root, 'venv/bin/python3'),
|
|
45
|
-
];
|
|
46
|
-
for (const c of candidates) {
|
|
47
|
-
if (c && existsSync(c))
|
|
48
|
-
return c;
|
|
49
|
-
}
|
|
50
|
-
return process.platform === 'win32' ? 'python' : 'python3';
|
|
51
|
-
};
|
|
52
|
-
const findCvcRoot = () => {
|
|
53
|
-
// entry.tsx is typically at <root>/cvc-tui/src/entry.tsx
|
|
54
|
-
// dist/entry.js at <root>/cvc-tui/dist/entry.js
|
|
55
|
-
// Walk up looking for a "cvc/tui_gateway" sibling.
|
|
56
|
-
if (process.env.CVC_PYTHON_SRC_ROOT)
|
|
57
|
-
return process.env.CVC_PYTHON_SRC_ROOT;
|
|
58
|
-
let dir = process.cwd();
|
|
59
|
-
for (let i = 0; i < 6; i++) {
|
|
60
|
-
if (existsSync(resolve(dir, 'cvc', 'tui_gateway', 'entry.py')))
|
|
61
|
-
return dir;
|
|
62
|
-
const parent = resolve(dir, '..');
|
|
63
|
-
if (parent === dir)
|
|
64
|
-
break;
|
|
65
|
-
dir = parent;
|
|
66
|
-
}
|
|
67
|
-
return process.cwd();
|
|
68
|
-
};
|
|
69
|
-
export class GatewayClient extends EventEmitter {
|
|
70
|
-
opts;
|
|
71
|
-
proc = null;
|
|
72
|
-
transport = null;
|
|
73
|
-
stdoutRl = null;
|
|
74
|
-
stderrRl = null;
|
|
75
|
-
pending = new Map();
|
|
76
|
-
reqId = 0;
|
|
77
|
-
session = null;
|
|
78
|
-
connected = false;
|
|
79
|
-
exited = false;
|
|
80
|
-
constructor(opts = {}) {
|
|
81
|
-
super();
|
|
82
|
-
this.setMaxListeners(0);
|
|
83
|
-
this.opts = opts;
|
|
84
|
-
}
|
|
85
|
-
/** Spawn (or attach) the gateway and run the session.info handshake. */
|
|
86
|
-
async connect() {
|
|
87
|
-
if (this.connected) {
|
|
88
|
-
return this.session;
|
|
89
|
-
}
|
|
90
|
-
if (this.opts.transport) {
|
|
91
|
-
this.transport = this.opts.transport;
|
|
92
|
-
this.wireTransport();
|
|
93
|
-
}
|
|
94
|
-
else {
|
|
95
|
-
this.spawnGateway();
|
|
96
|
-
}
|
|
97
|
-
// Handshake — single session.info round-trip with bounded timeout.
|
|
98
|
-
const info = (await this.send('session.info', {}, { timeoutMs: STARTUP_TIMEOUT_MS }));
|
|
99
|
-
this.session = info;
|
|
100
|
-
this.connected = true;
|
|
101
|
-
this.emit('connected', info);
|
|
102
|
-
return info;
|
|
103
|
-
}
|
|
104
|
-
spawnGateway() {
|
|
105
|
-
const root = this.opts.cwd ?? findCvcRoot();
|
|
106
|
-
const python = this.opts.python ?? resolvePython(root);
|
|
107
|
-
const env = { ...process.env };
|
|
108
|
-
const pyPath = env.PYTHONPATH?.trim();
|
|
109
|
-
env.PYTHONPATH = pyPath ? `${root}${delimiter}${pyPath}` : root;
|
|
110
|
-
const proc = spawn(python, ['-m', 'cvc.tui_gateway.entry'], {
|
|
111
|
-
cwd: root,
|
|
112
|
-
env,
|
|
113
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
114
|
-
});
|
|
115
|
-
this.proc = proc;
|
|
116
|
-
this.transport = {
|
|
117
|
-
stdin: proc.stdin,
|
|
118
|
-
stdout: proc.stdout,
|
|
119
|
-
stderr: proc.stderr,
|
|
120
|
-
kill: () => proc.kill(),
|
|
121
|
-
on: (event, cb) => {
|
|
122
|
-
proc.on(event, cb);
|
|
123
|
-
},
|
|
124
|
-
};
|
|
125
|
-
proc.on('error', (err) => {
|
|
126
|
-
this.emit('stderr', `[spawn] ${err.message}`);
|
|
127
|
-
this.handleExit(1);
|
|
128
|
-
});
|
|
129
|
-
proc.on('exit', (code) => this.handleExit(code));
|
|
130
|
-
this.wireTransport();
|
|
131
|
-
}
|
|
132
|
-
wireTransport() {
|
|
133
|
-
const t = this.transport;
|
|
134
|
-
this.stdoutRl = createInterface({ input: t.stdout });
|
|
135
|
-
this.stdoutRl.on('line', (line) => this.handleLine(line));
|
|
136
|
-
if (t.stderr) {
|
|
137
|
-
this.stderrRl = createInterface({ input: t.stderr });
|
|
138
|
-
this.stderrRl.on('line', (line) => {
|
|
139
|
-
const trimmed = line.trim();
|
|
140
|
-
if (!trimmed)
|
|
141
|
-
return;
|
|
142
|
-
this.emit('stderr', trimmed);
|
|
143
|
-
if (this.opts.forwardStderr) {
|
|
144
|
-
process.stderr.write(`[cvc-gateway] ${trimmed}\n`);
|
|
145
|
-
}
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
// Lifecycle: injected transports surface exit through their own `on()`.
|
|
149
|
-
// The real subprocess path also calls handleExit via proc.on('exit')
|
|
150
|
-
// registered in spawnGateway, so guard with `exited` to dedupe.
|
|
151
|
-
t.on?.('exit', (code) => this.handleExit(code));
|
|
152
|
-
}
|
|
153
|
-
handleLine(raw) {
|
|
154
|
-
const line = raw.trim();
|
|
155
|
-
if (!line)
|
|
156
|
-
return;
|
|
157
|
-
let frame;
|
|
158
|
-
try {
|
|
159
|
-
frame = JSON.parse(line);
|
|
160
|
-
}
|
|
161
|
-
catch {
|
|
162
|
-
this.emit('stderr', `[protocol] malformed stdout: ${line.slice(0, 200)}`);
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
this.dispatch(frame);
|
|
166
|
-
}
|
|
167
|
-
dispatch(frame) {
|
|
168
|
-
// Response — has `id` and (`result` xor `error`).
|
|
169
|
-
if ('id' in frame && (('result' in frame) || ('error' in frame))) {
|
|
170
|
-
const id = String(frame.id);
|
|
171
|
-
const p = this.pending.get(id);
|
|
172
|
-
if (!p)
|
|
173
|
-
return;
|
|
174
|
-
this.pending.delete(id);
|
|
175
|
-
clearTimeout(p.timer);
|
|
176
|
-
if ('error' in frame && frame.error) {
|
|
177
|
-
const err = frame.error;
|
|
178
|
-
p.reject(new Error(`${p.method}: ${err.message ?? 'rpc error'} (code=${err.code ?? '?'})`));
|
|
179
|
-
}
|
|
180
|
-
else {
|
|
181
|
-
p.resolve(frame.result);
|
|
182
|
-
}
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
// Event — has `method` but no `id`.
|
|
186
|
-
if (typeof frame.method === 'string' && !('id' in frame)) {
|
|
187
|
-
const ev = {
|
|
188
|
-
method: frame.method,
|
|
189
|
-
params: frame.params ?? {},
|
|
190
|
-
};
|
|
191
|
-
this.emit('event', ev);
|
|
192
|
-
this.emit(`event:${ev.method}`, ev.params);
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
// Anything else is malformed — log but don't crash.
|
|
196
|
-
this.emit('stderr', `[protocol] unexpected frame: ${JSON.stringify(frame).slice(0, 200)}`);
|
|
197
|
-
}
|
|
198
|
-
handleExit(code) {
|
|
199
|
-
if (this.exited)
|
|
200
|
-
return;
|
|
201
|
-
this.exited = true;
|
|
202
|
-
for (const p of Array.from(this.pending.values())) {
|
|
203
|
-
clearTimeout(p.timer);
|
|
204
|
-
p.reject(new Error(`gateway exited (code=${code ?? 'null'})`));
|
|
205
|
-
}
|
|
206
|
-
this.pending.clear();
|
|
207
|
-
this.stdoutRl?.close();
|
|
208
|
-
this.stderrRl?.close();
|
|
209
|
-
this.connected = false;
|
|
210
|
-
this.emit('exit', code);
|
|
211
|
-
}
|
|
212
|
-
/** Issue a JSON-RPC request and await its response. */
|
|
213
|
-
send(method, params = {}, opts = {}) {
|
|
214
|
-
if (!this.transport) {
|
|
215
|
-
return Promise.reject(new Error('GatewayClient: not connected'));
|
|
216
|
-
}
|
|
217
|
-
if (this.exited) {
|
|
218
|
-
return Promise.reject(new Error('GatewayClient: gateway has exited'));
|
|
219
|
-
}
|
|
220
|
-
const id = String(++this.reqId);
|
|
221
|
-
const frame = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
|
|
222
|
-
const timeoutMs = opts.timeoutMs ?? this.opts.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
223
|
-
return new Promise((resolve, reject) => {
|
|
224
|
-
const timer = setTimeout(() => {
|
|
225
|
-
this.pending.delete(id);
|
|
226
|
-
reject(new Error(`${method}: timeout after ${timeoutMs}ms`));
|
|
227
|
-
}, timeoutMs);
|
|
228
|
-
this.pending.set(id, { id, method, resolve, reject, timer });
|
|
229
|
-
try {
|
|
230
|
-
this.transport.stdin.write(frame);
|
|
231
|
-
}
|
|
232
|
-
catch (err) {
|
|
233
|
-
clearTimeout(timer);
|
|
234
|
-
this.pending.delete(id);
|
|
235
|
-
reject(err);
|
|
236
|
-
}
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
/**
|
|
240
|
-
* Streaming chat helper. Wires `chat.start` / `chat.delta` / `chat.done`
|
|
241
|
-
* to the supplied handlers, then issues `chat.send`. Resolves with the
|
|
242
|
-
* `chat.done` summary; rejects on RPC error.
|
|
243
|
-
*/
|
|
244
|
-
async sendChat(params, handlers = {}) {
|
|
245
|
-
const onStart = (p) => {
|
|
246
|
-
handlers.onStart?.({ provider: String(p.provider ?? ''), model: String(p.model ?? '') });
|
|
247
|
-
};
|
|
248
|
-
const onDelta = (p) => {
|
|
249
|
-
const t = typeof p.text === 'string' ? p.text : '';
|
|
250
|
-
if (t)
|
|
251
|
-
handlers.onDelta?.(t);
|
|
252
|
-
};
|
|
253
|
-
const onDone = (p) => {
|
|
254
|
-
handlers.onDone?.(p);
|
|
255
|
-
};
|
|
256
|
-
this.on('event:chat.start', onStart);
|
|
257
|
-
this.on('event:chat.delta', onDelta);
|
|
258
|
-
this.on('event:chat.done', onDone);
|
|
259
|
-
try {
|
|
260
|
-
const summary = (await this.send('chat.send', params));
|
|
261
|
-
return summary;
|
|
262
|
-
}
|
|
263
|
-
catch (err) {
|
|
264
|
-
handlers.onError?.(err);
|
|
265
|
-
throw err;
|
|
266
|
-
}
|
|
267
|
-
finally {
|
|
268
|
-
this.off('event:chat.start', onStart);
|
|
269
|
-
this.off('event:chat.delta', onDelta);
|
|
270
|
-
this.off('event:chat.done', onDone);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
/** Resolved session info from the handshake. */
|
|
274
|
-
getSession() {
|
|
275
|
-
return this.session;
|
|
276
|
-
}
|
|
277
|
-
isConnected() {
|
|
278
|
-
return this.connected && !this.exited;
|
|
279
|
-
}
|
|
280
|
-
/** Best-effort orderly shutdown — issues system.shutdown and kills proc. */
|
|
281
|
-
async close() {
|
|
282
|
-
if (!this.transport || this.exited)
|
|
283
|
-
return;
|
|
284
|
-
try {
|
|
285
|
-
await this.send('system.shutdown', {}, { timeoutMs: 1000 });
|
|
286
|
-
}
|
|
287
|
-
catch {
|
|
288
|
-
// ignore — the gateway may already be tearing down.
|
|
289
|
-
}
|
|
290
|
-
try {
|
|
291
|
-
this.transport.kill?.();
|
|
292
|
-
}
|
|
293
|
-
catch {
|
|
294
|
-
// ignore
|
|
295
|
-
}
|
|
296
|
-
if (this.proc && !this.proc.killed) {
|
|
297
|
-
try {
|
|
298
|
-
this.proc.kill();
|
|
299
|
-
}
|
|
300
|
-
catch {
|
|
301
|
-
// ignore
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
this.handleExit(0);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
/**
|
|
308
|
-
* Render an OSC 8 hyperlink escape sequence: `\x1b]8;;URL\x1b\\TEXT\x1b]8;;\x1b\\`.
|
|
309
|
-
* Re-exported here for tests + downstream consumers that don't want a hard
|
|
310
|
-
* dependency on the markdown component.
|
|
311
|
-
*/
|
|
312
|
-
export const osc8Link = (url, label) => `\x1b]8;;${url}\x1b\\${label}\x1b]8;;\x1b\\`;
|