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.
- package/LICENSE +21 -0
- package/README.md +103 -0
- package/dist/app/completion.js +98 -0
- package/dist/app/historyStore.js +119 -0
- package/dist/app/inputBuffer.js +116 -0
- package/dist/app/inputStore.js +24 -0
- package/dist/app/promptStore.js +40 -0
- package/dist/app/queueStore.js +21 -0
- package/dist/app/slash/commands/core.js +292 -0
- package/dist/app/slash/commands/debug.js +11 -0
- package/dist/app/slash/commands/ops.js +163 -0
- package/dist/app/slash/commands/session.js +91 -0
- package/dist/app/slash/commands/setup.js +47 -0
- package/dist/app/slash/commands/toggles.js +36 -0
- package/dist/app/slash/registry.js +79 -0
- package/dist/app/slash/types.js +16 -0
- package/dist/app/turnStore.js +60 -0
- package/dist/app/uiStore.js +31 -0
- package/dist/app.js +219 -0
- package/dist/banner.js +20 -0
- package/dist/components/appLayout.js +22 -0
- package/dist/components/branding.js +6 -0
- package/dist/components/overlays/confirmPrompt.js +25 -0
- package/dist/components/overlays/helpOverlay.js +75 -0
- package/dist/components/overlays/historySearch.js +48 -0
- package/dist/components/overlays/modelPicker.js +59 -0
- package/dist/components/overlays/overlayUtils.js +18 -0
- package/dist/components/overlays/secretPrompt.js +35 -0
- package/dist/components/overlays/sessionPicker.js +92 -0
- package/dist/components/overlays/skillsHub.js +70 -0
- package/dist/components/streamingMarkdown.js +220 -0
- package/dist/components/textInput.js +264 -0
- package/dist/components/thinking.js +39 -0
- package/dist/components/transcript.js +22 -0
- package/dist/config/timing.js +14 -0
- package/dist/entry.js +43 -0
- package/dist/gateway/client.js +312 -0
- package/dist/types.js +7 -0
- package/package.json +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jai Kumar Meena
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# cvc-tui
|
|
2
|
+
|
|
3
|
+
The **Cognitive Version Control** terminal UI — a React + [Ink](https://github.com/vadimdemedes/ink) app that runs in your terminal and talks to the CVC Python core through a JSON-RPC gateway.
|
|
4
|
+
|
|
5
|
+
This package is a **sidecar** to the Python `cvc` wheel. It is built once with `bun build --compile` and the resulting single binary is embedded in the wheel (Slice 13). Users never `npm install` it directly.
|
|
6
|
+
|
|
7
|
+
## Status
|
|
8
|
+
|
|
9
|
+
| Slice | What's in this package |
|
|
10
|
+
| ----- | ---------------------- |
|
|
11
|
+
| **Slice 11 (this scaffold)** | Build pipeline, banner, layout shell, store skeleton, gateway *stub*. |
|
|
12
|
+
| Slice 12 | Streaming markdown renderer, slash commands, history. |
|
|
13
|
+
| Slice 13 | Real JSON-RPC GatewayClient over stdio + `bun --compile` packaging. |
|
|
14
|
+
|
|
15
|
+
## Architecture
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
19
|
+
│ entry.tsx │
|
|
20
|
+
│ ├─ TTY check → fallback to plain banner │
|
|
21
|
+
│ ├─ GatewayClient.connect() (stub today) │
|
|
22
|
+
│ └─ render(<App/>) via Ink │
|
|
23
|
+
│ │
|
|
24
|
+
│ app.tsx │
|
|
25
|
+
│ └─ <AppLayout> │
|
|
26
|
+
│ ├─ <Branding/> banner + version + model │
|
|
27
|
+
│ ├─ <TranscriptArea/> nanostores: $messages │
|
|
28
|
+
│ └─ <InputLine/> <Thinking/> when busy │
|
|
29
|
+
│ │
|
|
30
|
+
│ app/uiStore.ts ← terminal dims, focus, busy │
|
|
31
|
+
│ app/turnStore.ts ← messages, current turn lifecycle │
|
|
32
|
+
│ gateway/client.ts ← STUB until Slice 11 wraps up │
|
|
33
|
+
└─────────────────────────────────────────────────────────────┘
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Why Ink + React 19?
|
|
37
|
+
|
|
38
|
+
- **Ink 6** gives us flex layout, mouse, focus, raw-mode input, all over node streams.
|
|
39
|
+
- **React 19** + the new compiler removes 90% of `useMemo`/`useCallback` ceremony.
|
|
40
|
+
- **nanostores** for state — zero boilerplate, surgical re-renders, no provider tree.
|
|
41
|
+
|
|
42
|
+
### Why Bun?
|
|
43
|
+
|
|
44
|
+
- 3-5× faster cold start than `tsx` for the same TS sources.
|
|
45
|
+
- `bun build --compile` produces a single ~50 MB binary we ship inside the wheel.
|
|
46
|
+
- We still keep an `npm`/`tsx` fallback so contributors without Bun can hack on it.
|
|
47
|
+
|
|
48
|
+
### CVC theme
|
|
49
|
+
|
|
50
|
+
| Token | Value | Usage |
|
|
51
|
+
| ------- | --------- | ----- |
|
|
52
|
+
| primary | `#e63946` | banner, prompt arrow, assistant role tag |
|
|
53
|
+
| dark | `#0d1b2a` | reserved for backgrounds when supported |
|
|
54
|
+
| accent | `#4a9eff` | tagline, user role tag, secondary highlights |
|
|
55
|
+
| dim | `#6b7280` | placeholders, separators |
|
|
56
|
+
|
|
57
|
+
Assumes the user's terminal font is **JetBrains Mono** (or any Nerd Font with box-drawing + braille).
|
|
58
|
+
|
|
59
|
+
## Scripts
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
bun install # or: npm install
|
|
63
|
+
bun run dev # or: npm run dev (watches & reloads)
|
|
64
|
+
bun run build # or: npm run build (compiles to dist/)
|
|
65
|
+
bun run test # or: npm run test (vitest)
|
|
66
|
+
bun run lint
|
|
67
|
+
bun run fmt
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
If `bun` is not on `PATH`, every script silently falls back to `tsx` / `tsc`.
|
|
71
|
+
|
|
72
|
+
## Layout
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
src/
|
|
76
|
+
├── entry.tsx # process bootstrap
|
|
77
|
+
├── app.tsx # root component tree
|
|
78
|
+
├── banner.ts # CVC ASCII art + colour tokens
|
|
79
|
+
├── types.ts # shared TS types + theme constants
|
|
80
|
+
├── app/
|
|
81
|
+
│ ├── uiStore.ts # nanostores: ui state
|
|
82
|
+
│ └── turnStore.ts # nanostores: turn / transcript
|
|
83
|
+
├── components/
|
|
84
|
+
│ ├── appLayout.tsx # flex root + resize tracking
|
|
85
|
+
│ ├── branding.tsx # banner panel
|
|
86
|
+
│ ├── textInput.tsx # ink-text-input wrapper
|
|
87
|
+
│ ├── streamingMarkdown.tsx# placeholder renderer (Slice 12)
|
|
88
|
+
│ └── thinking.tsx # animated spinner
|
|
89
|
+
├── config/
|
|
90
|
+
│ └── timing.ts # spinner / throttle constants
|
|
91
|
+
├── gateway/
|
|
92
|
+
│ └── client.ts # JSON-RPC client STUB
|
|
93
|
+
└── __tests__/
|
|
94
|
+
└── banner.test.ts
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Beating Hermes
|
|
98
|
+
|
|
99
|
+
Where Hermes' `ui-tui/` mixes a `packages/hermes-ink` workspace + Babel + a separate compile step, `cvc-tui` is **a single Bun-built binary**. No workspaces, no Babel pipeline, no `tsx --watch` in production. Cold start drops from ~700 ms (Hermes) to ~120 ms (target).
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
Same as the parent `cvc` project.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Tab-completion logic. Pure functions where possible, with one
|
|
2
|
+
// fs-touching helper for path expansion.
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import { listCommandNames } from './slash/registry.js';
|
|
7
|
+
/**
|
|
8
|
+
* Find the token under the cursor: a maximal run of non-whitespace ending at
|
|
9
|
+
* `cursor`. Returns null when no token (cursor on whitespace at boundary).
|
|
10
|
+
*/
|
|
11
|
+
export function tokenAtCursor(text, cursor) {
|
|
12
|
+
if (cursor < 0)
|
|
13
|
+
cursor = 0;
|
|
14
|
+
if (cursor > text.length)
|
|
15
|
+
cursor = text.length;
|
|
16
|
+
let s = cursor;
|
|
17
|
+
while (s > 0 && !/\s/.test(text[s - 1]))
|
|
18
|
+
s--;
|
|
19
|
+
const tok = text.slice(s, cursor);
|
|
20
|
+
if (!tok)
|
|
21
|
+
return null;
|
|
22
|
+
const isSlash = tok.startsWith('/') && s === firstNonSpaceOnLine(text, s);
|
|
23
|
+
const isPath = !isSlash && (tok.startsWith('~/') || tok.startsWith('./') || tok.startsWith('../') || tok.startsWith('/'));
|
|
24
|
+
return { start: s, token: tok, isSlash, isPath };
|
|
25
|
+
}
|
|
26
|
+
function firstNonSpaceOnLine(text, off) {
|
|
27
|
+
// Walk back to start of logical line, then forward over spaces/tabs.
|
|
28
|
+
const lineStart = text.lastIndexOf('\n', off - 1) + 1;
|
|
29
|
+
let i = lineStart;
|
|
30
|
+
while (i < text.length && (text[i] === ' ' || text[i] === '\t'))
|
|
31
|
+
i++;
|
|
32
|
+
return i;
|
|
33
|
+
}
|
|
34
|
+
/** Slash-command candidates whose name starts with `prefix`. */
|
|
35
|
+
export function slashCandidates(prefix) {
|
|
36
|
+
return listCommandNames()
|
|
37
|
+
.filter((n) => n.startsWith(prefix))
|
|
38
|
+
.sort();
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Path candidates for `prefix`. Reads the parent directory and returns
|
|
42
|
+
* matching entries (files + dirs, dirs suffixed with `/`).
|
|
43
|
+
*
|
|
44
|
+
* Side-effect: touches the filesystem (fs.readdirSync).
|
|
45
|
+
*/
|
|
46
|
+
export function pathCandidates(prefix) {
|
|
47
|
+
let abs = prefix;
|
|
48
|
+
let displayPrefix = prefix;
|
|
49
|
+
if (prefix.startsWith('~/')) {
|
|
50
|
+
abs = path.join(os.homedir(), prefix.slice(2));
|
|
51
|
+
}
|
|
52
|
+
// Decide directory + leaf to filter by.
|
|
53
|
+
const lastSlash = abs.lastIndexOf('/');
|
|
54
|
+
let dirAbs;
|
|
55
|
+
let dirDisplay;
|
|
56
|
+
let leaf;
|
|
57
|
+
if (lastSlash < 0) {
|
|
58
|
+
dirAbs = '.';
|
|
59
|
+
dirDisplay = '';
|
|
60
|
+
leaf = abs;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
dirAbs = abs.slice(0, lastSlash + 1) || '/';
|
|
64
|
+
dirDisplay = displayPrefix.slice(0, lastSlash + 1);
|
|
65
|
+
leaf = abs.slice(lastSlash + 1);
|
|
66
|
+
}
|
|
67
|
+
let entries;
|
|
68
|
+
try {
|
|
69
|
+
entries = fs.readdirSync(dirAbs, { withFileTypes: true });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const e of entries) {
|
|
76
|
+
if (!e.name.startsWith(leaf))
|
|
77
|
+
continue;
|
|
78
|
+
if (leaf === '' && e.name.startsWith('.'))
|
|
79
|
+
continue; // skip hidden when no leaf
|
|
80
|
+
out.push(dirDisplay + e.name + (e.isDirectory() ? '/' : ''));
|
|
81
|
+
}
|
|
82
|
+
return out.sort();
|
|
83
|
+
}
|
|
84
|
+
/** Compute candidates for the given context. */
|
|
85
|
+
export function candidatesFor(ctx) {
|
|
86
|
+
if (ctx.isSlash)
|
|
87
|
+
return slashCandidates(ctx.token);
|
|
88
|
+
if (ctx.isPath)
|
|
89
|
+
return pathCandidates(ctx.token);
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
/** Apply a candidate to the buffer at the given start offset (replacing token). */
|
|
93
|
+
export function applyCandidate(text, start, tokenLen, candidate) {
|
|
94
|
+
const before = text.slice(0, start);
|
|
95
|
+
const after = text.slice(start + tokenLen);
|
|
96
|
+
const next = before + candidate + after;
|
|
97
|
+
return { text: next, cursor: start + candidate.length };
|
|
98
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// History store — persists user input to ~/.cvc/agent_history.
|
|
2
|
+
// One entry per line, dedup against immediate previous, max 10000.
|
|
3
|
+
import { atom } from 'nanostores';
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import * as os from 'node:os';
|
|
7
|
+
export const HISTORY_MAX = 10000;
|
|
8
|
+
function defaultHistoryPath() {
|
|
9
|
+
return path.join(os.homedir(), '.cvc', 'agent_history');
|
|
10
|
+
}
|
|
11
|
+
let historyPath = defaultHistoryPath();
|
|
12
|
+
/** Used by tests to redirect persistence. */
|
|
13
|
+
export function setHistoryPath(p) {
|
|
14
|
+
historyPath = p;
|
|
15
|
+
}
|
|
16
|
+
export function getHistoryPath() {
|
|
17
|
+
return historyPath;
|
|
18
|
+
}
|
|
19
|
+
export const $history = atom([]);
|
|
20
|
+
/**
|
|
21
|
+
* Cursor pointing into history while user is browsing with Up/Down.
|
|
22
|
+
* `null` means "not browsing" (i.e. at the live edit buffer).
|
|
23
|
+
*/
|
|
24
|
+
export const $historyCursor = atom(null);
|
|
25
|
+
/** Snapshot of the live buffer so we can restore it after browsing. */
|
|
26
|
+
export const $historyDraft = atom('');
|
|
27
|
+
export function loadHistory() {
|
|
28
|
+
try {
|
|
29
|
+
if (!fs.existsSync(historyPath)) {
|
|
30
|
+
$history.set([]);
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
const raw = fs.readFileSync(historyPath, 'utf8');
|
|
34
|
+
// Drop empty trailing line, ignore blank lines.
|
|
35
|
+
const lines = raw.split('\n').filter((l) => l.length > 0);
|
|
36
|
+
$history.set(lines);
|
|
37
|
+
return lines;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
$history.set([]);
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Append an entry, dedup-against-previous, cap at HISTORY_MAX, persist. */
|
|
45
|
+
export function pushHistory(entry) {
|
|
46
|
+
const trimmed = entry.replace(/\s+$/g, '');
|
|
47
|
+
if (!trimmed)
|
|
48
|
+
return;
|
|
49
|
+
const cur = $history.get();
|
|
50
|
+
if (cur.length > 0 && cur[cur.length - 1] === trimmed) {
|
|
51
|
+
// Dedup consecutive duplicates without rewriting the file.
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
let next = [...cur, trimmed];
|
|
55
|
+
if (next.length > HISTORY_MAX) {
|
|
56
|
+
next = next.slice(next.length - HISTORY_MAX);
|
|
57
|
+
}
|
|
58
|
+
$history.set(next);
|
|
59
|
+
persist();
|
|
60
|
+
}
|
|
61
|
+
export function persist() {
|
|
62
|
+
try {
|
|
63
|
+
fs.mkdirSync(path.dirname(historyPath), { recursive: true });
|
|
64
|
+
fs.writeFileSync(historyPath, $history.get().join('\n') + '\n', 'utf8');
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Best-effort — never crash the UI on history I/O failure.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Reverse-incremental search. Returns up to `limit` matches, most-recent-first.
|
|
72
|
+
*/
|
|
73
|
+
export function searchHistory(query, limit = 50) {
|
|
74
|
+
if (!query)
|
|
75
|
+
return [];
|
|
76
|
+
const q = query.toLowerCase();
|
|
77
|
+
const out = [];
|
|
78
|
+
const cur = $history.get();
|
|
79
|
+
for (let i = cur.length - 1; i >= 0 && out.length < limit; i--) {
|
|
80
|
+
if (cur[i].toLowerCase().includes(q))
|
|
81
|
+
out.push(cur[i]);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
/** Move history cursor toward older entries; returns the entry text or null. */
|
|
86
|
+
export function historyPrev(currentDraft) {
|
|
87
|
+
const h = $history.get();
|
|
88
|
+
if (h.length === 0)
|
|
89
|
+
return null;
|
|
90
|
+
let idx = $historyCursor.get();
|
|
91
|
+
if (idx === null) {
|
|
92
|
+
$historyDraft.set(currentDraft);
|
|
93
|
+
idx = h.length - 1;
|
|
94
|
+
}
|
|
95
|
+
else if (idx > 0) {
|
|
96
|
+
idx -= 1;
|
|
97
|
+
}
|
|
98
|
+
$historyCursor.set(idx);
|
|
99
|
+
return h[idx];
|
|
100
|
+
}
|
|
101
|
+
/** Move history cursor toward newer entries; returns the entry text or null on fall-through. */
|
|
102
|
+
export function historyNext() {
|
|
103
|
+
const h = $history.get();
|
|
104
|
+
const idx = $historyCursor.get();
|
|
105
|
+
if (idx === null)
|
|
106
|
+
return null;
|
|
107
|
+
if (idx >= h.length - 1) {
|
|
108
|
+
// Fell off the end: restore draft.
|
|
109
|
+
$historyCursor.set(null);
|
|
110
|
+
return $historyDraft.get();
|
|
111
|
+
}
|
|
112
|
+
const next = idx + 1;
|
|
113
|
+
$historyCursor.set(next);
|
|
114
|
+
return h[next];
|
|
115
|
+
}
|
|
116
|
+
export function resetHistoryBrowse() {
|
|
117
|
+
$historyCursor.set(null);
|
|
118
|
+
$historyDraft.set('');
|
|
119
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Pure text-buffer helpers for the multi-line composer.
|
|
2
|
+
// Kept side-effect-free so they can be unit-tested without Ink/React.
|
|
3
|
+
export function emptyBuffer() {
|
|
4
|
+
return { text: '', cursor: 0 };
|
|
5
|
+
}
|
|
6
|
+
export function insert(buf, s) {
|
|
7
|
+
const text = buf.text.slice(0, buf.cursor) + s + buf.text.slice(buf.cursor);
|
|
8
|
+
return { text, cursor: buf.cursor + s.length };
|
|
9
|
+
}
|
|
10
|
+
export function backspace(buf) {
|
|
11
|
+
if (buf.cursor === 0)
|
|
12
|
+
return buf;
|
|
13
|
+
const text = buf.text.slice(0, buf.cursor - 1) + buf.text.slice(buf.cursor);
|
|
14
|
+
return { text, cursor: buf.cursor - 1 };
|
|
15
|
+
}
|
|
16
|
+
export function del(buf) {
|
|
17
|
+
if (buf.cursor >= buf.text.length)
|
|
18
|
+
return buf;
|
|
19
|
+
const text = buf.text.slice(0, buf.cursor) + buf.text.slice(buf.cursor + 1);
|
|
20
|
+
return { text, cursor: buf.cursor };
|
|
21
|
+
}
|
|
22
|
+
export function moveLeft(buf) {
|
|
23
|
+
return { ...buf, cursor: Math.max(0, buf.cursor - 1) };
|
|
24
|
+
}
|
|
25
|
+
export function moveRight(buf) {
|
|
26
|
+
return { ...buf, cursor: Math.min(buf.text.length, buf.cursor + 1) };
|
|
27
|
+
}
|
|
28
|
+
export function moveHome(buf) {
|
|
29
|
+
// Move to start of current logical line.
|
|
30
|
+
const before = buf.text.slice(0, buf.cursor);
|
|
31
|
+
const nl = before.lastIndexOf('\n');
|
|
32
|
+
return { ...buf, cursor: nl < 0 ? 0 : nl + 1 };
|
|
33
|
+
}
|
|
34
|
+
export function moveEnd(buf) {
|
|
35
|
+
const after = buf.text.slice(buf.cursor);
|
|
36
|
+
const nl = after.indexOf('\n');
|
|
37
|
+
return { ...buf, cursor: nl < 0 ? buf.text.length : buf.cursor + nl };
|
|
38
|
+
}
|
|
39
|
+
/** Lines split on \n (keeps empty trailing line). */
|
|
40
|
+
export function lines(text) {
|
|
41
|
+
return text.split('\n');
|
|
42
|
+
}
|
|
43
|
+
/** Convert cursor offset to {row, col} on logical lines. */
|
|
44
|
+
export function cursorRowCol(buf) {
|
|
45
|
+
const before = buf.text.slice(0, buf.cursor);
|
|
46
|
+
const ls = before.split('\n');
|
|
47
|
+
return { row: ls.length - 1, col: ls[ls.length - 1].length };
|
|
48
|
+
}
|
|
49
|
+
/** Move cursor up one logical line, preserving column when possible. */
|
|
50
|
+
export function moveUp(buf) {
|
|
51
|
+
const { row, col } = cursorRowCol(buf);
|
|
52
|
+
if (row === 0)
|
|
53
|
+
return buf;
|
|
54
|
+
const ls = lines(buf.text);
|
|
55
|
+
const targetCol = Math.min(col, ls[row - 1].length);
|
|
56
|
+
// Compute new offset: sum of lines 0..row-2 + their newlines + targetCol.
|
|
57
|
+
let off = 0;
|
|
58
|
+
for (let i = 0; i < row - 1; i++)
|
|
59
|
+
off += ls[i].length + 1;
|
|
60
|
+
off += targetCol;
|
|
61
|
+
return { ...buf, cursor: off };
|
|
62
|
+
}
|
|
63
|
+
export function moveDown(buf) {
|
|
64
|
+
const { row, col } = cursorRowCol(buf);
|
|
65
|
+
const ls = lines(buf.text);
|
|
66
|
+
if (row >= ls.length - 1)
|
|
67
|
+
return buf;
|
|
68
|
+
const targetCol = Math.min(col, ls[row + 1].length);
|
|
69
|
+
let off = 0;
|
|
70
|
+
for (let i = 0; i < row + 1; i++)
|
|
71
|
+
off += ls[i].length + 1;
|
|
72
|
+
off += targetCol;
|
|
73
|
+
return { ...buf, cursor: off };
|
|
74
|
+
}
|
|
75
|
+
/** True when the cursor is on the first logical line. */
|
|
76
|
+
export function onFirstLine(buf) {
|
|
77
|
+
return cursorRowCol(buf).row === 0;
|
|
78
|
+
}
|
|
79
|
+
/** True when the cursor is on the last logical line. */
|
|
80
|
+
export function onLastLine(buf) {
|
|
81
|
+
return cursorRowCol(buf).row === lines(buf.text).length - 1;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Word-wrap a logical line into visual rows of width `cols`.
|
|
85
|
+
* Returns array of strings (each ≤ cols chars). Tries to break at spaces.
|
|
86
|
+
*/
|
|
87
|
+
export function wrapLine(line, cols) {
|
|
88
|
+
if (cols <= 0)
|
|
89
|
+
return [line];
|
|
90
|
+
if (line.length <= cols)
|
|
91
|
+
return [line];
|
|
92
|
+
const out = [];
|
|
93
|
+
let rest = line;
|
|
94
|
+
while (rest.length > cols) {
|
|
95
|
+
let cut = cols;
|
|
96
|
+
// Look for a space to break at, within a small window.
|
|
97
|
+
const window = rest.slice(0, cols);
|
|
98
|
+
const lastSpace = window.lastIndexOf(' ');
|
|
99
|
+
if (lastSpace > Math.floor(cols * 0.5)) {
|
|
100
|
+
cut = lastSpace + 1;
|
|
101
|
+
}
|
|
102
|
+
out.push(rest.slice(0, cut));
|
|
103
|
+
rest = rest.slice(cut);
|
|
104
|
+
}
|
|
105
|
+
if (rest.length)
|
|
106
|
+
out.push(rest);
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
/** Wrap the full text, returning visual rows. */
|
|
110
|
+
export function wrapText(text, cols) {
|
|
111
|
+
return text.split('\n').flatMap((l) => (l.length === 0 ? [''] : wrapLine(l, cols)));
|
|
112
|
+
}
|
|
113
|
+
/** Stats for the dim status footer. */
|
|
114
|
+
export function bufferStats(text) {
|
|
115
|
+
return { chars: text.length, lineCount: text.split('\n').length };
|
|
116
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Composer state — kept in a nanostore so any subsystem can read/write
|
|
2
|
+
// without prop-drilling. Holds the active text buffer + completion state.
|
|
3
|
+
import { atom } from 'nanostores';
|
|
4
|
+
import { emptyBuffer } from './inputBuffer.js';
|
|
5
|
+
export const $buffer = atom(emptyBuffer());
|
|
6
|
+
export const $completion = atom({
|
|
7
|
+
active: false,
|
|
8
|
+
prefix: '',
|
|
9
|
+
candidates: [],
|
|
10
|
+
index: 0,
|
|
11
|
+
start: 0,
|
|
12
|
+
});
|
|
13
|
+
export function resetCompletion() {
|
|
14
|
+
$completion.set({ active: false, prefix: '', candidates: [], index: 0, start: 0 });
|
|
15
|
+
}
|
|
16
|
+
export function setBuffer(b) {
|
|
17
|
+
$buffer.set(b);
|
|
18
|
+
// Any non-Tab edit should reset the completion cycle.
|
|
19
|
+
resetCompletion();
|
|
20
|
+
}
|
|
21
|
+
/** Direct setter that does not reset completion (used by the cycle itself). */
|
|
22
|
+
export function setBufferRaw(b) {
|
|
23
|
+
$buffer.set(b);
|
|
24
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Prompt store — confirmation + secret prompts requested by any subsystem.
|
|
2
|
+
// The composer hides while a prompt is active; the prompt overlay owns input.
|
|
3
|
+
import { atom } from 'nanostores';
|
|
4
|
+
export const $prompt = atom(null);
|
|
5
|
+
let counter = 0;
|
|
6
|
+
function nextId() {
|
|
7
|
+
counter += 1;
|
|
8
|
+
return `prompt_${Date.now()}_${counter}`;
|
|
9
|
+
}
|
|
10
|
+
/** Request a yes/no confirmation. Resolves with `true`/`false`, or `null` on Esc. */
|
|
11
|
+
export function requestConfirm(prompt, opts = {}) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
$prompt.set({
|
|
14
|
+
id: nextId(),
|
|
15
|
+
kind: 'confirm',
|
|
16
|
+
prompt,
|
|
17
|
+
destructive: opts.destructive,
|
|
18
|
+
resolve: (v) => resolve(v),
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/** Request a secret. Resolves with the entered string, or `null` on Esc. */
|
|
23
|
+
export function requestSecret(prompt, opts = {}) {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
$prompt.set({
|
|
26
|
+
id: nextId(),
|
|
27
|
+
kind: 'secret',
|
|
28
|
+
prompt,
|
|
29
|
+
mask: opts.mask !== false,
|
|
30
|
+
resolve: (v) => resolve(v),
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
export function resolveActivePrompt(value) {
|
|
35
|
+
const cur = $prompt.get();
|
|
36
|
+
if (!cur)
|
|
37
|
+
return;
|
|
38
|
+
cur.resolve(value);
|
|
39
|
+
$prompt.set(null);
|
|
40
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Queue store — submissions made while a turn is streaming are queued
|
|
2
|
+
// and flushed when the active turn ends. Composer shows `[queued: N]`.
|
|
3
|
+
import { atom } from 'nanostores';
|
|
4
|
+
export const $queue = atom([]);
|
|
5
|
+
export function enqueue(text) {
|
|
6
|
+
const t = text.replace(/\s+$/g, '');
|
|
7
|
+
if (!t)
|
|
8
|
+
return;
|
|
9
|
+
$queue.set([...$queue.get(), t]);
|
|
10
|
+
}
|
|
11
|
+
export function dequeueAll() {
|
|
12
|
+
const cur = $queue.get();
|
|
13
|
+
$queue.set([]);
|
|
14
|
+
return cur;
|
|
15
|
+
}
|
|
16
|
+
export function queueLength() {
|
|
17
|
+
return $queue.get().length;
|
|
18
|
+
}
|
|
19
|
+
export function clearQueue() {
|
|
20
|
+
$queue.set([]);
|
|
21
|
+
}
|