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,79 @@
1
+ // Central CVC slash command registry. Mirrors Hermes ui-tui surface so
2
+ // users coming from Hermes feel at home, but every command is owned by CVC.
3
+ import { coreCommands } from './commands/core.js';
4
+ import { debugCommands } from './commands/debug.js';
5
+ import { opsCommands } from './commands/ops.js';
6
+ import { sessionCommands } from './commands/session.js';
7
+ import { setupCommands } from './commands/setup.js';
8
+ import { toggleCommands } from './commands/toggles.js';
9
+ export const SLASH_COMMANDS = [
10
+ ...coreCommands,
11
+ ...sessionCommands,
12
+ ...opsCommands,
13
+ ...setupCommands,
14
+ ...toggleCommands,
15
+ ...debugCommands,
16
+ ];
17
+ const byName = new Map(SLASH_COMMANDS.flatMap(cmd => [cmd.name, ...(cmd.aliases ?? [])].map(n => [n.toLowerCase(), cmd])));
18
+ export function findSlashCommand(name) {
19
+ return byName.get(name.replace(/^\//, '').toLowerCase());
20
+ }
21
+ export function listSlashNames() {
22
+ return SLASH_COMMANDS.map(c => `/${c.name}`).sort();
23
+ }
24
+ /**
25
+ * Returns "/"-prefixed canonical names + aliases. Used by completion.ts to
26
+ * populate slash candidates. Keeps both names ("listSlashNames" and
27
+ * "listCommandNames") exported so older slice modules don't drift.
28
+ */
29
+ export function listCommandNames() {
30
+ const all = new Set();
31
+ for (const c of SLASH_COMMANDS) {
32
+ all.add(`/${c.name}`);
33
+ for (const a of c.aliases ?? [])
34
+ all.add(`/${a}`);
35
+ }
36
+ return Array.from(all).sort();
37
+ }
38
+ /**
39
+ * Parse a raw input line and dispatch it. Returns true if it WAS a slash
40
+ * command (handled or not), false if the line should be treated as a normal
41
+ * user message.
42
+ *
43
+ * Strip a single matched layer of surrounding quotes from `arg`. This is the
44
+ * same affordance Hermes ui-tui applies to inputs like `/title 'foo bar'` or
45
+ * `/title "foo bar"` — users coming from a shell expect quoting to "just
46
+ * work" for arguments containing spaces.
47
+ */
48
+ export function unquoteArg(arg) {
49
+ const t = arg.trim();
50
+ if (t.length < 2)
51
+ return t;
52
+ const first = t.charCodeAt(0);
53
+ const last = t.charCodeAt(t.length - 1);
54
+ if (first === last && (first === 34 /* " */ || first === 39 /* ' */)) {
55
+ return t.slice(1, -1);
56
+ }
57
+ return t;
58
+ }
59
+ export async function dispatchSlash(line, ctx) {
60
+ if (!line.startsWith('/'))
61
+ return false;
62
+ const trimmed = line.trim();
63
+ const sp = trimmed.indexOf(' ');
64
+ const head = sp === -1 ? trimmed.slice(1) : trimmed.slice(1, sp);
65
+ const rawArg = sp === -1 ? '' : trimmed.slice(sp + 1);
66
+ const arg = unquoteArg(rawArg);
67
+ const cmd = findSlashCommand(head);
68
+ if (!cmd) {
69
+ ctx.emit({ kind: 'sys', text: `unknown command: /${head} — try /help` });
70
+ return true;
71
+ }
72
+ try {
73
+ await cmd.run(arg, ctx, trimmed);
74
+ }
75
+ catch (err) {
76
+ ctx.emit({ kind: 'sys', text: `command failed: ${String(err)}` });
77
+ }
78
+ return true;
79
+ }
@@ -0,0 +1,16 @@
1
+ // CVC slash command framework — minimal, self-contained.
2
+ // Mirrors the Hermes ui-tui slash surface but with a much smaller ctx so
3
+ // commands stay easy to test and the TUI can grow the surface incrementally.
4
+ // Helper used by every toggle-style command.
5
+ export const flagFromArg = (arg, current) => {
6
+ if (!arg)
7
+ return !current;
8
+ const m = arg.trim().toLowerCase();
9
+ if (m === 'on' || m === 'true' || m === '1')
10
+ return true;
11
+ if (m === 'off' || m === 'false' || m === '0')
12
+ return false;
13
+ if (m === 'toggle' || m === 'cycle')
14
+ return !current;
15
+ return null;
16
+ };
@@ -0,0 +1,60 @@
1
+ // Turn-lifecycle store: tracks the active turn + transcript.
2
+ // Extended in Slice 12A to support streaming deltas: `appendDelta`
3
+ // mutates the in-flight assistant message in place.
4
+ import { atom } from 'nanostores';
5
+ export const $messages = atom([]);
6
+ export const $turn = atom(null);
7
+ let _idCounter = 0;
8
+ export const newId = (prefix = 'm') => `${prefix}-${Date.now()}-${++_idCounter}`;
9
+ export function appendMessage(msg) {
10
+ $messages.set([...$messages.get(), msg]);
11
+ }
12
+ export function startTurn(id) {
13
+ $turn.set({ id, status: 'streaming', startedAt: Date.now() });
14
+ }
15
+ export function endTurn(error) {
16
+ const cur = $turn.get();
17
+ if (!cur)
18
+ return;
19
+ $turn.set({
20
+ ...cur,
21
+ status: error ? 'error' : 'done',
22
+ endedAt: Date.now(),
23
+ error,
24
+ });
25
+ }
26
+ export function clearTranscript() {
27
+ $messages.set([]);
28
+ $turn.set(null);
29
+ }
30
+ /** Append a streaming text delta to the *last* assistant message. */
31
+ export function appendDelta(text) {
32
+ if (!text)
33
+ return;
34
+ const list = $messages.get();
35
+ if (list.length === 0)
36
+ return;
37
+ const last = list[list.length - 1];
38
+ if (last.role !== 'assistant')
39
+ return;
40
+ const updated = { ...last, content: last.content + text };
41
+ $messages.set([...list.slice(0, -1), updated]);
42
+ }
43
+ /** Begin a new assistant turn — adds an empty assistant message + flips $turn. */
44
+ export function beginAssistantTurn() {
45
+ const id = newId('a');
46
+ startTurn(id);
47
+ appendMessage({ id, role: 'assistant', content: '', ts: Date.now() });
48
+ return id;
49
+ }
50
+ /** Push a user message into the transcript. */
51
+ export function pushSystemMessage(content) {
52
+ const id = newId('sys');
53
+ appendMessage({ id, role: 'system', content, ts: Date.now() });
54
+ return id;
55
+ }
56
+ export function pushUserMessage(content) {
57
+ const id = newId('u');
58
+ appendMessage({ id, role: 'user', content, ts: Date.now() });
59
+ return id;
60
+ }
@@ -0,0 +1,31 @@
1
+ // UI-wide store: terminal dimensions, focus, modal visibility, theme overrides,
2
+ // active overlay (modelPicker | sessionPicker | skillsHub | help | null).
3
+ import { atom, map } from 'nanostores';
4
+ import { CVC_THEME } from '../types.js';
5
+ export const $ui = map({
6
+ cols: process.stdout.columns ?? 80,
7
+ rows: process.stdout.rows ?? 24,
8
+ focused: 'input',
9
+ modal: null,
10
+ busy: false,
11
+ });
12
+ export const $theme = atom(CVC_THEME);
13
+ /** Active full-screen overlay (mutually exclusive with the composer). */
14
+ export const $activeOverlay = atom(null);
15
+ export function openOverlay(name) {
16
+ $activeOverlay.set(name);
17
+ $ui.setKey('focused', 'modal');
18
+ $ui.setKey('modal', name);
19
+ }
20
+ export function closeOverlay() {
21
+ $activeOverlay.set(null);
22
+ $ui.setKey('focused', 'input');
23
+ $ui.setKey('modal', null);
24
+ }
25
+ export function setBusy(b) {
26
+ $ui.setKey('busy', b);
27
+ }
28
+ export function setSize(cols, rows) {
29
+ $ui.setKey('cols', cols);
30
+ $ui.setKey('rows', rows);
31
+ }
package/dist/app.js ADDED
@@ -0,0 +1,219 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { Box, Text, useApp, useInput } from 'ink';
4
+ import { useStore } from '@nanostores/react';
5
+ import { AppLayout } from './components/appLayout.js';
6
+ import { Branding } from './components/branding.js';
7
+ import { TextInput } from './components/textInput.js';
8
+ import { Thinking } from './components/thinking.js';
9
+ import { Transcript } from './components/transcript.js';
10
+ import { appendDelta, beginAssistantTurn, endTurn, pushUserMessage, pushSystemMessage, } from './app/turnStore.js';
11
+ import { $ui, $activeOverlay, openOverlay, closeOverlay, setBusy } from './app/uiStore.js';
12
+ import { CVC_THEME } from './types.js';
13
+ import { dispatchSlash } from './app/slash/registry.js';
14
+ import { ModelPicker } from './components/overlays/modelPicker.js';
15
+ import { SessionPicker, loadSessions } from './components/overlays/sessionPicker.js';
16
+ import { SkillsHub } from './components/overlays/skillsHub.js';
17
+ import { HelpOverlay } from './components/overlays/helpOverlay.js';
18
+ import { ConfirmPrompt } from './components/overlays/confirmPrompt.js';
19
+ import { SecretPrompt } from './components/overlays/secretPrompt.js';
20
+ import { $prompt } from './app/promptStore.js';
21
+ const InputLine = ({ onSubmit }) => {
22
+ const [value, setValue] = useState('');
23
+ const ui = useStore($ui);
24
+ const overlay = useStore($activeOverlay);
25
+ const prompt = useStore($prompt);
26
+ if (overlay || prompt)
27
+ return null; // overlay/prompt owns input
28
+ if (ui.busy)
29
+ return _jsx(Thinking, { label: "thinking" });
30
+ return (_jsx(TextInput, { value: value, onChange: setValue, onSubmit: (v) => {
31
+ const trimmed = v.trim();
32
+ setValue('');
33
+ if (trimmed)
34
+ onSubmit(trimmed);
35
+ }, placeholder: "ask CVC anything" }));
36
+ };
37
+ const StatusLine = ({ error }) => {
38
+ if (!error)
39
+ return null;
40
+ return (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: CVC_THEME.primary, children: ["\u26A0 ", error] }) }));
41
+ };
42
+ const Overlays = ({ gateway, onPickModel, onPickSession, onPickSkill }) => {
43
+ const overlay = useStore($activeOverlay);
44
+ const [sessions, setSessions] = useState([]);
45
+ const [skills, setSkills] = useState([]);
46
+ useEffect(() => {
47
+ if (overlay === 'sessionPicker') {
48
+ try {
49
+ setSessions(loadSessions());
50
+ }
51
+ catch {
52
+ setSessions([]);
53
+ }
54
+ }
55
+ if (overlay === 'skillsHub' && gateway) {
56
+ gateway
57
+ .send('skills.list', {})
58
+ .then((res) => {
59
+ const items = Array.isArray(res?.skills) ? res.skills : [];
60
+ setSkills(items);
61
+ })
62
+ .catch(() => setSkills([]));
63
+ }
64
+ }, [overlay, gateway]);
65
+ if (!overlay)
66
+ return null;
67
+ if (overlay === 'help')
68
+ return _jsx(HelpOverlay, { onClose: closeOverlay });
69
+ if (overlay === 'modelPicker') {
70
+ // Static provider list — gateway can extend later via models.list RPC.
71
+ const providers = [
72
+ { slug: 'github-copilot', name: 'GitHub Copilot', models: ['claude-sonnet-4.6', 'gpt-5', 'gemini-2.5-pro'] },
73
+ { slug: 'nvidia-nim', name: 'NVIDIA NIM', models: ['nemotron-3-super-120b', 'kimi-k2.5', 'minimax-m2.5'] },
74
+ { slug: 'google', name: 'Google', models: ['gemini-3.1-pro-preview', 'gemini-3.1-flash-lite', 'gemma-4-31b'] },
75
+ ];
76
+ return (_jsx(ModelPicker, { providers: providers, onSelect: (p, m) => { onPickModel(`${p}/${m}`); closeOverlay(); }, onCancel: closeOverlay }));
77
+ }
78
+ if (overlay === 'sessionPicker') {
79
+ return (_jsx(SessionPicker, { sessions: sessions, onSelect: (id) => {
80
+ const s = sessions.find((x) => x.id === id);
81
+ if (s)
82
+ onPickSession(s);
83
+ closeOverlay();
84
+ }, onCancel: closeOverlay }));
85
+ }
86
+ if (overlay === 'skillsHub') {
87
+ return (_jsx(SkillsHub, { skills: skills, onActivate: (s) => { onPickSkill(s); closeOverlay(); }, onCancel: closeOverlay }));
88
+ }
89
+ return null;
90
+ };
91
+ const Prompts = () => {
92
+ const prompt = useStore($prompt);
93
+ if (!prompt)
94
+ return null;
95
+ if (prompt.kind === 'confirm')
96
+ return _jsx(ConfirmPrompt, {});
97
+ if (prompt.kind === 'secret')
98
+ return _jsx(SecretPrompt, {});
99
+ return null;
100
+ };
101
+ export const App = ({ version, model, gateway }) => {
102
+ const { exit } = useApp();
103
+ const [error, setError] = useState(null);
104
+ const [activeModel, setActiveModel] = useState(model);
105
+ const gatewayRef = useRef(gateway ?? null);
106
+ useInput((input, key) => {
107
+ if (key.ctrl && (input === 'c' || input === 'd')) {
108
+ exit();
109
+ }
110
+ });
111
+ useEffect(() => {
112
+ return () => {
113
+ void gatewayRef.current?.close().catch(() => { });
114
+ };
115
+ }, []);
116
+ const handleEffect = useMemo(() => (effect) => {
117
+ switch (effect.kind) {
118
+ case 'sys':
119
+ if (effect.text)
120
+ pushSystemMessage(effect.text);
121
+ return;
122
+ case 'panel':
123
+ case 'page':
124
+ if (effect.title)
125
+ pushSystemMessage(`── ${effect.title} ──`);
126
+ for (const sec of effect.sections ?? []) {
127
+ if (sec.title)
128
+ pushSystemMessage(`▸ ${sec.title}`);
129
+ if (sec.text)
130
+ pushSystemMessage(sec.text);
131
+ for (const [k, v] of sec.rows ?? [])
132
+ pushSystemMessage(` ${k}: ${v}`);
133
+ }
134
+ return;
135
+ case 'overlay':
136
+ if (effect.overlay === 'modelPicker')
137
+ openOverlay('modelPicker');
138
+ else if (effect.overlay === 'sessionPicker')
139
+ openOverlay('sessionPicker');
140
+ else if (effect.overlay === 'skillsHub')
141
+ openOverlay('skillsHub');
142
+ else if (effect.overlay === 'help')
143
+ openOverlay('help');
144
+ else
145
+ pushSystemMessage(`overlay '${effect.overlay}' not yet implemented`);
146
+ return;
147
+ case 'rpc':
148
+ if (effect.method && gatewayRef.current) {
149
+ void gatewayRef.current
150
+ .send(effect.method, effect.params ?? {})
151
+ .catch((err) => setError(`rpc ${effect.method}: ${err.message}`));
152
+ }
153
+ return;
154
+ case 'die':
155
+ exit();
156
+ return;
157
+ case 'patchUi':
158
+ case 'noop':
159
+ default:
160
+ return;
161
+ }
162
+ }, [exit]);
163
+ const handleSubmit = async (text) => {
164
+ // Slash command path
165
+ if (text.startsWith('/')) {
166
+ const ctx = {
167
+ sid: null,
168
+ ui: {},
169
+ emit: handleEffect,
170
+ history: () => [],
171
+ rpc: gatewayRef.current
172
+ ? (method, params) => gatewayRef.current.send(method, params ?? {})
173
+ : undefined,
174
+ };
175
+ pushUserMessage(text);
176
+ await dispatchSlash(text, ctx);
177
+ return;
178
+ }
179
+ // Normal chat path
180
+ const gw = gatewayRef.current;
181
+ if (!gw) {
182
+ setError('gateway not connected');
183
+ return;
184
+ }
185
+ pushUserMessage(text);
186
+ beginAssistantTurn();
187
+ setBusy(true);
188
+ setError(null);
189
+ try {
190
+ await gw.sendChat({ message: text }, {
191
+ onDelta: (chunk) => appendDelta(chunk),
192
+ onError: (err) => setError(err.message),
193
+ });
194
+ endTurn();
195
+ }
196
+ catch (err) {
197
+ const msg = err.message;
198
+ setError(msg);
199
+ endTurn(msg);
200
+ }
201
+ finally {
202
+ setBusy(false);
203
+ }
204
+ };
205
+ return (_jsxs(AppLayout, { children: [_jsx(Branding, { version: version, model: activeModel }), _jsx(Transcript, {}), _jsx(StatusLine, { error: error }), _jsx(Overlays, { gateway: gatewayRef.current, onPickModel: (m) => {
206
+ setActiveModel(m);
207
+ pushSystemMessage(`model → ${m}`);
208
+ if (gatewayRef.current) {
209
+ void gatewayRef.current.send('model.set', { model: m }).catch(() => { });
210
+ }
211
+ }, onPickSession: (s) => {
212
+ pushSystemMessage(`session → ${s.id}`);
213
+ if (gatewayRef.current) {
214
+ void gatewayRef.current.send('session.resume', { id: s.id });
215
+ }
216
+ }, onPickSkill: (s) => {
217
+ pushSystemMessage(`skill → ${s.name}`);
218
+ } }), _jsx(Prompts, {}), _jsx(InputLine, { onSubmit: handleSubmit })] }));
219
+ };
package/dist/banner.js ADDED
@@ -0,0 +1,20 @@
1
+ // CVC ASCII banner. Rendered once at startup by <Branding/>.
2
+ // Colour applied at render-time via Ink <Text color="..."> wrappers.
3
+ export const CVC_BANNER = String.raw `
4
+ ██████╗██╗ ██╗ ██████╗
5
+ ██╔════╝██║ ██║██╔════╝
6
+ ██║ ██║ ██║██║
7
+ ██║ ╚██╗ ██╔╝██║
8
+ ╚██████╗ ╚████╔╝ ╚██████╗
9
+ ╚═════╝ ╚═══╝ ╚═════╝
10
+ `.trim();
11
+ export const CVC_TAGLINE = 'Cognitive Version Control';
12
+ export const BANNER_COLOR = '#e63946';
13
+ export const TAGLINE_COLOR = '#4a9eff';
14
+ /**
15
+ * Returns the banner as a plain (uncoloured) string suitable for tests
16
+ * and for non-TTY fallbacks. The TTY renderer colours it via Ink.
17
+ */
18
+ export function renderBannerPlain({ version = '0.0.1', model = 'unknown' } = {}) {
19
+ return [CVC_BANNER, '', `${CVC_TAGLINE} v${version} · model: ${model}`].join('\n');
20
+ }
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect } from 'react';
3
+ import { Box, useStdout } from 'ink';
4
+ import { setSize } from '../app/uiStore.js';
5
+ /**
6
+ * Root flex container. Tracks terminal resizes and pushes dims into the UI store
7
+ * so child components can react via nanostores selectors.
8
+ */
9
+ export const AppLayout = ({ children }) => {
10
+ const { stdout } = useStdout();
11
+ useEffect(() => {
12
+ const onResize = () => {
13
+ setSize(stdout.columns ?? 80, stdout.rows ?? 24);
14
+ };
15
+ onResize();
16
+ stdout.on('resize', onResize);
17
+ return () => {
18
+ stdout.off('resize', onResize);
19
+ };
20
+ }, [stdout]);
21
+ return (_jsx(Box, { flexDirection: "column", width: "100%", paddingX: 1, paddingY: 0, children: children }));
22
+ };
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { CVC_BANNER, CVC_TAGLINE, BANNER_COLOR, TAGLINE_COLOR } from '../banner.js';
4
+ export const Branding = ({ version = '0.0.1', model = 'unknown' }) => {
5
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: BANNER_COLOR, bold: true, children: CVC_BANNER }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: TAGLINE_COLOR, children: CVC_TAGLINE }), _jsx(Text, { color: "gray", children: ` v${version} · ` }), _jsx(Text, { color: "white", children: model })] })] }));
6
+ };
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { useStore } from '@nanostores/react';
4
+ import { $prompt, resolveActivePrompt } from '../../app/promptStore.js';
5
+ export const ConfirmPrompt = () => {
6
+ const cur = useStore($prompt);
7
+ if (!cur || cur.kind !== 'confirm')
8
+ return null;
9
+ const destructive = !!cur.destructive;
10
+ useInput((input, key) => {
11
+ if (key.escape) {
12
+ resolveActivePrompt(null);
13
+ return;
14
+ }
15
+ if (input === 'y' || input === 'Y') {
16
+ resolveActivePrompt(true);
17
+ return;
18
+ }
19
+ if (input === 'n' || input === 'N' || key.return) {
20
+ resolveActivePrompt(false);
21
+ return;
22
+ }
23
+ });
24
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: destructive ? '#e63946' : '#4a9eff', bold: true, children: destructive ? '⚠ ' : '? ' }), _jsx(Text, { children: cur.prompt })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: "[y/N] \u00B7 Enter = no \u00B7 Esc = cancel" }) })] }));
25
+ };
@@ -0,0 +1,75 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ // CVC helpOverlay — full-screen /help overlay listing every slash command
3
+ // from the central registry, grouped by category. Arrow-key scrollable.
4
+ import { useMemo, useState } from 'react';
5
+ import { Box, Text, useInput } from 'ink';
6
+ import { CVC_THEME } from '../../types.js';
7
+ import { SLASH_COMMANDS } from '../../app/slash/registry.js';
8
+ import { windowItems } from './overlayUtils.js';
9
+ const VISIBLE = 18;
10
+ const ORDER = [
11
+ 'core',
12
+ 'session',
13
+ 'ops',
14
+ 'setup',
15
+ 'toggles',
16
+ 'debug',
17
+ 'other',
18
+ ];
19
+ const TITLES = {
20
+ core: 'Core',
21
+ session: 'Sessions',
22
+ ops: 'Models · Skills · Tools',
23
+ setup: 'Setup',
24
+ toggles: 'Toggles',
25
+ debug: 'Debug',
26
+ other: 'Other',
27
+ };
28
+ function groupCommands() {
29
+ const buckets = new Map();
30
+ for (const c of SLASH_COMMANDS) {
31
+ if (c.hidden)
32
+ continue;
33
+ const k = (c.category ?? 'other');
34
+ if (!buckets.has(k))
35
+ buckets.set(k, []);
36
+ buckets.get(k).push(c);
37
+ }
38
+ const rows = [];
39
+ for (const k of ORDER) {
40
+ const list = buckets.get(k);
41
+ if (!list || list.length === 0)
42
+ continue;
43
+ rows.push({ kind: 'header', text: TITLES[k] ?? String(k) });
44
+ list.sort((a, b) => a.name.localeCompare(b.name));
45
+ for (const c of list) {
46
+ const aliases = c.aliases?.length ? ` (${c.aliases.map(a => `/${a}`).join(', ')})` : '';
47
+ rows.push({ kind: 'cmd', text: `/${c.name}${aliases} — ${c.help}`, cmd: c });
48
+ }
49
+ }
50
+ return rows;
51
+ }
52
+ export const HelpOverlay = ({ onClose }) => {
53
+ const rows = useMemo(groupCommands, []);
54
+ const [top, setTop] = useState(0);
55
+ useInput((input, key) => {
56
+ if (key.escape || input === 'q' || (key.ctrl && input === 'c'))
57
+ return onClose();
58
+ if (key.upArrow && top > 0)
59
+ return setTop(top - 1);
60
+ if (key.downArrow && top < Math.max(0, rows.length - VISIBLE))
61
+ return setTop(top + 1);
62
+ if (key.pageUp)
63
+ return setTop(Math.max(0, top - VISIBLE));
64
+ if (key.pageDown)
65
+ return setTop(Math.min(Math.max(0, rows.length - VISIBLE), top + VISIBLE));
66
+ });
67
+ const { items, offset } = windowItems(rows, top + Math.floor(VISIBLE / 2), VISIBLE);
68
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: CVC_THEME.primary, bold: true, children: ["CVC \u2014 slash commands (", rows.filter(r => r.kind === 'cmd').length, ")"] }), offset > 0 ? _jsx(Text, { dimColor: true, children: ` ↑ ${offset} more` }) : _jsx(Text, { children: " " }), items.map((row, i) => {
69
+ const k = `${offset + i}-${row.kind}`;
70
+ if (row.kind === 'header') {
71
+ return (_jsx(Text, { color: CVC_THEME.primary, bold: true, children: row.text }, k));
72
+ }
73
+ return (_jsxs(Text, { dimColor: false, children: [' ', row.text] }, k));
74
+ }), offset + VISIBLE < rows.length ? (_jsx(Text, { dimColor: true, children: ` ↓ ${rows.length - offset - VISIBLE} more` })) : (_jsx(Text, { children: " " })), _jsx(Text, { dimColor: true, children: "\u2191/\u2193 scroll \u00B7 PgUp/PgDn page \u00B7 Esc/q close" })] }));
75
+ };
@@ -0,0 +1,48 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Reverse-incremental history search overlay (Ctrl+R).
3
+ // Keystrokes refine the query; Up/Down moves through matches; Enter accepts;
4
+ // Esc cancels.
5
+ import { useState } from 'react';
6
+ import { Box, Text, useInput } from 'ink';
7
+ import { searchHistory } from '../../app/historyStore.js';
8
+ export const HistorySearch = ({ onAccept, onCancel }) => {
9
+ const [query, setQuery] = useState('');
10
+ const [index, setIndex] = useState(0);
11
+ const matches = searchHistory(query, 50);
12
+ const current = matches[index] ?? '';
13
+ useInput((input, key) => {
14
+ if (key.escape || (key.ctrl && input === 'g')) {
15
+ onCancel();
16
+ return;
17
+ }
18
+ if (key.return) {
19
+ if (current)
20
+ onAccept(current);
21
+ else
22
+ onCancel();
23
+ return;
24
+ }
25
+ if (key.upArrow || (key.ctrl && input === 'r')) {
26
+ if (matches.length > 0)
27
+ setIndex(Math.min(matches.length - 1, index + 1));
28
+ return;
29
+ }
30
+ if (key.downArrow || (key.ctrl && input === 's')) {
31
+ setIndex(Math.max(0, index - 1));
32
+ return;
33
+ }
34
+ if (key.backspace || key.delete) {
35
+ setQuery(query.slice(0, -1));
36
+ setIndex(0);
37
+ return;
38
+ }
39
+ if (input && !key.ctrl && !key.meta && !key.tab) {
40
+ setQuery(query + input);
41
+ setIndex(0);
42
+ return;
43
+ }
44
+ });
45
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "#e63946", children: "(reverse-i-search)" }), _jsx(Text, { children: `'${query}': ` }), _jsx(Text, { children: current })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: matches.length > 0
46
+ ? `${index + 1}/${matches.length} match${matches.length === 1 ? '' : 'es'} · ↑/↓ navigate · Enter accept · Esc cancel`
47
+ : 'no matches · Esc cancel' }) })] }));
48
+ };
@@ -0,0 +1,59 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // CVC modelPicker overlay — arrow-key navigable, two-stage (provider → model).
3
+ // Behavior parity with Hermes ui-tui modelPicker, but consumes preloaded data
4
+ // via props so it stays trivial to test and wire.
5
+ import { useMemo, useState } from 'react';
6
+ import { Box, Text, useInput } from 'ink';
7
+ import { CVC_THEME } from '../../types.js';
8
+ import { windowItems } from './overlayUtils.js';
9
+ const VISIBLE = 10;
10
+ export const ModelPicker = ({ providers, currentModel, onSelect, onCancel, }) => {
11
+ const [stage, setStage] = useState('provider');
12
+ const [pIdx, setPIdx] = useState(() => Math.max(0, providers.findIndex(p => p.is_current)));
13
+ const [mIdx, setMIdx] = useState(0);
14
+ const provider = providers[pIdx];
15
+ const models = provider?.models ?? [];
16
+ useInput((_input, key) => {
17
+ if (key.escape) {
18
+ if (stage === 'model') {
19
+ setStage('provider');
20
+ setMIdx(0);
21
+ return;
22
+ }
23
+ onCancel();
24
+ return;
25
+ }
26
+ const count = stage === 'provider' ? providers.length : models.length;
27
+ const sel = stage === 'provider' ? pIdx : mIdx;
28
+ const setSel = stage === 'provider' ? setPIdx : setMIdx;
29
+ if (key.upArrow && sel > 0)
30
+ return setSel(sel - 1);
31
+ if (key.downArrow && sel < count - 1)
32
+ return setSel(sel + 1);
33
+ if (key.return) {
34
+ if (stage === 'provider') {
35
+ if (provider) {
36
+ setStage('model');
37
+ setMIdx(0);
38
+ }
39
+ return;
40
+ }
41
+ const m = models[mIdx];
42
+ if (provider && m)
43
+ onSelect(provider.slug, m);
44
+ }
45
+ });
46
+ const rows = useMemo(() => {
47
+ if (stage === 'provider') {
48
+ return providers.map(p => `${p.is_current ? '*' : '●'} ${p.name} · ${p.models.length} models`);
49
+ }
50
+ return models.map(m => `${m === currentModel ? '*' : ' '} ${m}`);
51
+ }, [stage, providers, models, currentModel]);
52
+ const sel = stage === 'provider' ? pIdx : mIdx;
53
+ const { items, offset } = windowItems(rows, sel, VISIBLE);
54
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: CVC_THEME.primary, bold: true, children: stage === 'provider' ? 'Select provider (1/2)' : `Select model — ${provider?.name ?? ''} (2/2)` }), _jsx(Text, { dimColor: true, children: currentModel ? `current: ${currentModel}` : ' ' }), offset > 0 ? _jsx(Text, { dimColor: true, children: ` ↑ ${offset} more` }) : _jsx(Text, { children: " " }), items.map((row, i) => {
55
+ const idx = offset + i;
56
+ const active = sel === idx;
57
+ return (_jsxs(Text, { color: active ? CVC_THEME.primary : undefined, inverse: active, bold: active, children: [active ? '▸ ' : ' ', row] }, `${stage}-${idx}`));
58
+ }), offset + VISIBLE < rows.length ? (_jsx(Text, { dimColor: true, children: ` ↓ ${rows.length - offset - VISIBLE} more` })) : (_jsx(Text, { children: " " })), _jsxs(Text, { dimColor: true, children: ["\u2191/\u2193 select \u00B7 Enter choose \u00B7 Esc ", stage === 'model' ? 'back' : 'cancel'] })] }));
59
+ };