@blockrun/franklin 3.5.1 → 3.6.2
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/agent/bash-guard.d.ts +17 -0
- package/dist/agent/bash-guard.js +158 -0
- package/dist/agent/permissions.js +41 -2
- package/dist/agent/streaming-executor.js +32 -0
- package/dist/agent/tokens.js +1 -1
- package/dist/agent/types.d.ts +9 -0
- package/dist/mcp/client.js +36 -0
- package/dist/pricing.js +1 -1
- package/dist/tools/bash.js +56 -1
- package/dist/tools/edit.js +4 -2
- package/dist/tools/read.d.ts +2 -0
- package/dist/tools/read.js +28 -0
- package/dist/tools/write.js +2 -1
- package/dist/ui/app.js +167 -32
- package/dist/ui/markdown.d.ts +6 -0
- package/dist/ui/markdown.js +73 -6
- package/dist/ui/model-picker.js +2 -2
- package/dist/ui/mouse.d.ts +29 -0
- package/dist/ui/mouse.js +89 -0
- package/dist/ui/terminal.js +45 -28
- package/dist/ui/vim-input.d.ts +19 -0
- package/dist/ui/vim-input.js +439 -0
- package/package.json +1 -1
package/dist/ui/mouse.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mouse event support for Ink terminal UI.
|
|
3
|
+
* Enables SGR extended mouse tracking (DECSET 1000+1006) and parses events from stdin.
|
|
4
|
+
* Lightweight — only handles clicks, not drag/hover/selection.
|
|
5
|
+
*/
|
|
6
|
+
import { EventEmitter } from 'node:events';
|
|
7
|
+
// ─── Terminal escape sequences ────────────────────────────────────────────
|
|
8
|
+
const ENABLE_MOUSE = '\x1b[?1000h' + // Normal mouse tracking (clicks + wheel)
|
|
9
|
+
'\x1b[?1006h'; // SGR extended format (readable coordinates)
|
|
10
|
+
const DISABLE_MOUSE = '\x1b[?1006l' +
|
|
11
|
+
'\x1b[?1000l';
|
|
12
|
+
// SGR mouse event format: ESC [ < button ; col ; row M (press) or m (release)
|
|
13
|
+
const SGR_MOUSE_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
|
14
|
+
// ─── Mouse Manager ───────────────────────────────────────────────────────
|
|
15
|
+
class MouseManager extends EventEmitter {
|
|
16
|
+
enabled = false;
|
|
17
|
+
stdinListener = null;
|
|
18
|
+
/**
|
|
19
|
+
* Enable mouse tracking. Call once on app startup.
|
|
20
|
+
* Returns cleanup function to call on unmount.
|
|
21
|
+
*/
|
|
22
|
+
enable() {
|
|
23
|
+
if (this.enabled)
|
|
24
|
+
return () => { };
|
|
25
|
+
this.enabled = true;
|
|
26
|
+
// Write enable sequences
|
|
27
|
+
process.stdout.write(ENABLE_MOUSE);
|
|
28
|
+
// Listen on stdin for mouse sequences
|
|
29
|
+
// We use 'data' event at a higher priority than Ink's handler.
|
|
30
|
+
// Mouse sequences that we parse are still passed to Ink (we can't consume them),
|
|
31
|
+
// but Ink will ignore unrecognized escape sequences.
|
|
32
|
+
this.stdinListener = (data) => {
|
|
33
|
+
const str = data.toString('utf-8');
|
|
34
|
+
let match;
|
|
35
|
+
SGR_MOUSE_RE.lastIndex = 0;
|
|
36
|
+
while ((match = SGR_MOUSE_RE.exec(str)) !== null) {
|
|
37
|
+
const btnCode = parseInt(match[1], 10);
|
|
38
|
+
const col = parseInt(match[2], 10) - 1; // 1-indexed → 0-indexed
|
|
39
|
+
const row = parseInt(match[3], 10) - 1;
|
|
40
|
+
const isPress = match[4] === 'M';
|
|
41
|
+
// Decode button
|
|
42
|
+
const baseBtn = btnCode & 0x03;
|
|
43
|
+
const isWheel = (btnCode & 0x40) !== 0;
|
|
44
|
+
let button;
|
|
45
|
+
if (isWheel) {
|
|
46
|
+
button = baseBtn === 0 ? 'wheel-up' : 'wheel-down';
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
button = baseBtn === 0 ? 'left' : baseBtn === 1 ? 'middle' : 'right';
|
|
50
|
+
}
|
|
51
|
+
const event = {
|
|
52
|
+
button,
|
|
53
|
+
action: isPress ? 'press' : 'release',
|
|
54
|
+
col,
|
|
55
|
+
row,
|
|
56
|
+
};
|
|
57
|
+
this.emit('mouse', event);
|
|
58
|
+
// Emit convenience events
|
|
59
|
+
if (button === 'left' && isPress) {
|
|
60
|
+
this.emit('click', event);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
process.stdin.on('data', this.stdinListener);
|
|
65
|
+
return () => this.disable();
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Disable mouse tracking and clean up.
|
|
69
|
+
*/
|
|
70
|
+
disable() {
|
|
71
|
+
if (!this.enabled)
|
|
72
|
+
return;
|
|
73
|
+
this.enabled = false;
|
|
74
|
+
if (this.stdinListener) {
|
|
75
|
+
process.stdin.removeListener('data', this.stdinListener);
|
|
76
|
+
this.stdinListener = null;
|
|
77
|
+
}
|
|
78
|
+
// Best-effort: disable mouse tracking
|
|
79
|
+
try {
|
|
80
|
+
process.stdout.write(DISABLE_MOUSE);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Ignore write errors during cleanup (stdout may be closed)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
isEnabled() { return this.enabled; }
|
|
87
|
+
}
|
|
88
|
+
/** Singleton mouse manager. */
|
|
89
|
+
export const mouse = new MouseManager();
|
package/dist/ui/terminal.js
CHANGED
|
@@ -74,12 +74,29 @@ class MarkdownRenderer {
|
|
|
74
74
|
}
|
|
75
75
|
else {
|
|
76
76
|
this.inCodeBlock = true;
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
const lang = line.slice(3).trim().split(/\s/)[0].toLowerCase();
|
|
78
|
+
this.codeBlockLang = lang;
|
|
79
|
+
const LANG_LABELS = {
|
|
80
|
+
ts: 'TypeScript', typescript: 'TypeScript', js: 'JavaScript', javascript: 'JavaScript',
|
|
81
|
+
py: 'Python', python: 'Python', rs: 'Rust', rust: 'Rust', go: 'Go',
|
|
82
|
+
sh: 'Shell', bash: 'Shell', zsh: 'Shell', json: 'JSON', yaml: 'YAML',
|
|
83
|
+
sql: 'SQL', html: 'HTML', css: 'CSS', diff: 'Diff',
|
|
84
|
+
tsx: 'TSX', jsx: 'JSX',
|
|
85
|
+
};
|
|
86
|
+
const label = LANG_LABELS[lang] || (lang ? lang.toUpperCase() : '');
|
|
87
|
+
return chalk.dim('```') + (label ? chalk.dim.italic(` ${label}`) : '');
|
|
79
88
|
}
|
|
80
89
|
}
|
|
81
|
-
// Inside code block —
|
|
90
|
+
// Inside code block — diff highlighting + cyan
|
|
82
91
|
if (this.inCodeBlock) {
|
|
92
|
+
if (this.codeBlockLang === 'diff') {
|
|
93
|
+
if (line.startsWith('+'))
|
|
94
|
+
return chalk.green(line);
|
|
95
|
+
if (line.startsWith('-'))
|
|
96
|
+
return chalk.red(line);
|
|
97
|
+
if (line.startsWith('@@'))
|
|
98
|
+
return chalk.cyan(line);
|
|
99
|
+
}
|
|
83
100
|
return chalk.cyan(line);
|
|
84
101
|
}
|
|
85
102
|
// Headers
|
|
@@ -232,38 +249,38 @@ export class TerminalUI {
|
|
|
232
249
|
const capName = cap?.name || 'unknown';
|
|
233
250
|
const elapsed = cap ? Date.now() - cap.startTime : 0;
|
|
234
251
|
this.activeCapabilities.delete(event.id);
|
|
235
|
-
const
|
|
252
|
+
const elapsedFmt = elapsed >= 1000
|
|
253
|
+
? `${(elapsed / 1000).toFixed(1)}s`
|
|
254
|
+
: `${elapsed}ms`;
|
|
255
|
+
const timeStr = elapsed > 100 ? chalk.dim(` ${elapsedFmt}`) : '';
|
|
236
256
|
if (event.result.isError) {
|
|
237
|
-
console.error(chalk.red(` ✗
|
|
238
|
-
timeStr
|
|
239
|
-
|
|
257
|
+
console.error(chalk.red(` ✗ `) + chalk.bold(capName) +
|
|
258
|
+
timeStr);
|
|
259
|
+
// Show error preview lines
|
|
260
|
+
const errLines = event.result.output.split('\n').filter(Boolean).slice(0, 3);
|
|
261
|
+
for (const line of errLines) {
|
|
262
|
+
console.error(chalk.red(` ⎿ ${line.slice(0, 120)}`));
|
|
263
|
+
}
|
|
240
264
|
}
|
|
241
265
|
else {
|
|
242
|
-
// Show diff-like output for Edit tool
|
|
243
266
|
const output = event.result.output;
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const lines = output.split('\n').slice(0, 5);
|
|
256
|
-
for (const line of lines) {
|
|
257
|
-
console.error(chalk.dim(` │ ${line.slice(0, 100)}`));
|
|
258
|
-
}
|
|
259
|
-
if (output.split('\n').length > 5) {
|
|
260
|
-
console.error(chalk.dim(` │ ... (${output.split('\n').length - 5} more lines)`));
|
|
261
|
-
}
|
|
267
|
+
const icon = chalk.green('✓');
|
|
268
|
+
console.error(` ${icon} ${chalk.bold(capName)}${timeStr}`);
|
|
269
|
+
if (capName === 'Bash') {
|
|
270
|
+
// Show last 5 lines of command output
|
|
271
|
+
const outLines = output.split('\n').filter(Boolean);
|
|
272
|
+
const show = outLines.slice(-5);
|
|
273
|
+
for (const line of show) {
|
|
274
|
+
console.error(chalk.dim(` ⎿ ${line.slice(0, 120)}`));
|
|
275
|
+
}
|
|
276
|
+
if (outLines.length > 5) {
|
|
277
|
+
console.error(chalk.dim(` ⎿ ... ${outLines.length - 5} more lines`));
|
|
262
278
|
}
|
|
263
279
|
}
|
|
264
|
-
else {
|
|
280
|
+
else if (output.trim()) {
|
|
281
|
+
// Other tools: show first line as preview
|
|
265
282
|
const preview = truncateOutput(output, 120);
|
|
266
|
-
console.error(chalk.
|
|
283
|
+
console.error(chalk.dim(` ⎿ ${preview}`));
|
|
267
284
|
}
|
|
268
285
|
}
|
|
269
286
|
break;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vim-style text input for Franklin's Ink UI.
|
|
3
|
+
* Supports normal/insert mode, motions, operators, counts.
|
|
4
|
+
*
|
|
5
|
+
* Normal mode: h/l/w/b/e/0/$ for movement, i/a/A/I to enter insert, x/dd/dw/D for delete
|
|
6
|
+
* Insert mode: standard text entry, Esc to return to normal mode
|
|
7
|
+
*/
|
|
8
|
+
export type VimMode = 'insert' | 'normal';
|
|
9
|
+
interface VimInputProps {
|
|
10
|
+
value: string;
|
|
11
|
+
onChange: (value: string) => void;
|
|
12
|
+
onSubmit: (value: string) => void;
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
focus?: boolean;
|
|
15
|
+
showMode?: boolean;
|
|
16
|
+
onModeChange?: (mode: VimMode) => void;
|
|
17
|
+
}
|
|
18
|
+
export default function VimInput({ value, onChange, onSubmit, placeholder, focus, showMode, onModeChange, }: VimInputProps): import("react/jsx-runtime").JSX.Element;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Vim-style text input for Franklin's Ink UI.
|
|
4
|
+
* Supports normal/insert mode, motions, operators, counts.
|
|
5
|
+
*
|
|
6
|
+
* Normal mode: h/l/w/b/e/0/$ for movement, i/a/A/I to enter insert, x/dd/dw/D for delete
|
|
7
|
+
* Insert mode: standard text entry, Esc to return to normal mode
|
|
8
|
+
*/
|
|
9
|
+
import { useState, useCallback, useRef } from 'react';
|
|
10
|
+
import { Box, Text, useInput } from 'ink';
|
|
11
|
+
/**
|
|
12
|
+
* Find the start of the next word (Vim 'w' motion).
|
|
13
|
+
*/
|
|
14
|
+
function nextWord(text, pos) {
|
|
15
|
+
let i = pos;
|
|
16
|
+
// Skip current word chars
|
|
17
|
+
while (i < text.length && /\w/.test(text[i]))
|
|
18
|
+
i++;
|
|
19
|
+
// Skip non-word non-space
|
|
20
|
+
while (i < text.length && /[^\w\s]/.test(text[i]))
|
|
21
|
+
i++;
|
|
22
|
+
// Skip whitespace
|
|
23
|
+
while (i < text.length && /\s/.test(text[i]))
|
|
24
|
+
i++;
|
|
25
|
+
return Math.min(i, text.length);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Find the start of the previous word (Vim 'b' motion).
|
|
29
|
+
*/
|
|
30
|
+
function prevWord(text, pos) {
|
|
31
|
+
let i = pos - 1;
|
|
32
|
+
// Skip whitespace backwards
|
|
33
|
+
while (i > 0 && /\s/.test(text[i]))
|
|
34
|
+
i--;
|
|
35
|
+
// Skip non-word non-space backwards
|
|
36
|
+
if (i > 0 && /[^\w\s]/.test(text[i])) {
|
|
37
|
+
while (i > 0 && /[^\w\s]/.test(text[i - 1]))
|
|
38
|
+
i--;
|
|
39
|
+
return i;
|
|
40
|
+
}
|
|
41
|
+
// Skip word chars backwards
|
|
42
|
+
while (i > 0 && /\w/.test(text[i - 1]))
|
|
43
|
+
i--;
|
|
44
|
+
return Math.max(0, i);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Find the end of the current word (Vim 'e' motion).
|
|
48
|
+
*/
|
|
49
|
+
function endWord(text, pos) {
|
|
50
|
+
let i = pos + 1;
|
|
51
|
+
// Skip whitespace
|
|
52
|
+
while (i < text.length && /\s/.test(text[i]))
|
|
53
|
+
i++;
|
|
54
|
+
// Move to end of word
|
|
55
|
+
while (i < text.length - 1 && /\w/.test(text[i + 1]))
|
|
56
|
+
i++;
|
|
57
|
+
return Math.min(i, text.length - 1);
|
|
58
|
+
}
|
|
59
|
+
export default function VimInput({ value, onChange, onSubmit, placeholder = '', focus = true, showMode = true, onModeChange, }) {
|
|
60
|
+
const [mode, setMode] = useState('insert');
|
|
61
|
+
const [cursor, setCursor] = useState(value.length);
|
|
62
|
+
const [cmdBuf, setCmdBuf] = useState(''); // accumulated command buffer (for counts + operators)
|
|
63
|
+
const [yankBuf, setYankBuf] = useState(''); // internal clipboard
|
|
64
|
+
const [undoStack, setUndoStack] = useState([]); // simple undo
|
|
65
|
+
const lastValueRef = useRef(value);
|
|
66
|
+
// Keep cursor in bounds when value changes externally
|
|
67
|
+
const clampedCursor = Math.min(cursor, mode === 'normal' ? Math.max(0, value.length - 1) : value.length);
|
|
68
|
+
const switchMode = useCallback((newMode) => {
|
|
69
|
+
setMode(newMode);
|
|
70
|
+
setCmdBuf('');
|
|
71
|
+
onModeChange?.(newMode);
|
|
72
|
+
}, [onModeChange]);
|
|
73
|
+
const saveUndo = useCallback(() => {
|
|
74
|
+
setUndoStack(prev => [...prev.slice(-20), value]);
|
|
75
|
+
}, [value]);
|
|
76
|
+
const updateValue = useCallback((newVal, newCursor) => {
|
|
77
|
+
onChange(newVal);
|
|
78
|
+
setCursor(Math.max(0, Math.min(newCursor, mode === 'normal' ? Math.max(0, newVal.length - 1) : newVal.length)));
|
|
79
|
+
lastValueRef.current = newVal;
|
|
80
|
+
}, [onChange, mode]);
|
|
81
|
+
useInput((input, key) => {
|
|
82
|
+
if (!focus)
|
|
83
|
+
return;
|
|
84
|
+
// Submit on Enter in any mode
|
|
85
|
+
if (key.return) {
|
|
86
|
+
if (mode === 'normal')
|
|
87
|
+
switchMode('insert');
|
|
88
|
+
onSubmit(value);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// ── INSERT MODE ──
|
|
92
|
+
if (mode === 'insert') {
|
|
93
|
+
// Escape → normal mode
|
|
94
|
+
if (key.escape) {
|
|
95
|
+
const newCursor = Math.max(0, clampedCursor - 1);
|
|
96
|
+
setCursor(newCursor);
|
|
97
|
+
switchMode('normal');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// Backspace
|
|
101
|
+
if (key.backspace) {
|
|
102
|
+
if (clampedCursor > 0) {
|
|
103
|
+
saveUndo();
|
|
104
|
+
updateValue(value.slice(0, clampedCursor - 1) + value.slice(clampedCursor), clampedCursor - 1);
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// Delete
|
|
109
|
+
if (key.delete) {
|
|
110
|
+
if (clampedCursor < value.length) {
|
|
111
|
+
saveUndo();
|
|
112
|
+
updateValue(value.slice(0, clampedCursor) + value.slice(clampedCursor + 1), clampedCursor);
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// Arrow keys in insert mode
|
|
117
|
+
if (key.leftArrow) {
|
|
118
|
+
setCursor(Math.max(0, clampedCursor - 1));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (key.rightArrow) {
|
|
122
|
+
setCursor(Math.min(value.length, clampedCursor + 1));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (key.upArrow || key.downArrow)
|
|
126
|
+
return; // let parent handle history
|
|
127
|
+
// Ctrl+A: beginning of line
|
|
128
|
+
if (key.ctrl && input === 'a') {
|
|
129
|
+
setCursor(0);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// Ctrl+E: end of line
|
|
133
|
+
if (key.ctrl && input === 'e') {
|
|
134
|
+
setCursor(value.length);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// Ctrl+W: delete word backward
|
|
138
|
+
if (key.ctrl && input === 'w') {
|
|
139
|
+
const wp = prevWord(value, clampedCursor);
|
|
140
|
+
saveUndo();
|
|
141
|
+
updateValue(value.slice(0, wp) + value.slice(clampedCursor), wp);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// Ctrl+U: delete to beginning
|
|
145
|
+
if (key.ctrl && input === 'u') {
|
|
146
|
+
saveUndo();
|
|
147
|
+
setYankBuf(value.slice(0, clampedCursor));
|
|
148
|
+
updateValue(value.slice(clampedCursor), 0);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// Ctrl+K: delete to end
|
|
152
|
+
if (key.ctrl && input === 'k') {
|
|
153
|
+
saveUndo();
|
|
154
|
+
setYankBuf(value.slice(clampedCursor));
|
|
155
|
+
updateValue(value.slice(0, clampedCursor), clampedCursor);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
// Skip control chars and tab
|
|
159
|
+
if (key.ctrl || key.meta || key.tab)
|
|
160
|
+
return;
|
|
161
|
+
// Regular character input
|
|
162
|
+
if (input) {
|
|
163
|
+
saveUndo();
|
|
164
|
+
updateValue(value.slice(0, clampedCursor) + input + value.slice(clampedCursor), clampedCursor + input.length);
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// ── NORMAL MODE ──
|
|
169
|
+
if (key.escape) {
|
|
170
|
+
setCmdBuf('');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
// Arrow keys work in normal mode too
|
|
174
|
+
if (key.leftArrow) {
|
|
175
|
+
setCursor(Math.max(0, clampedCursor - 1));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (key.rightArrow) {
|
|
179
|
+
setCursor(Math.min(Math.max(0, value.length - 1), clampedCursor + 1));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (key.upArrow || key.downArrow)
|
|
183
|
+
return; // let parent handle
|
|
184
|
+
// Backspace in normal mode = left
|
|
185
|
+
if (key.backspace) {
|
|
186
|
+
setCursor(Math.max(0, clampedCursor - 1));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// Build command buffer
|
|
190
|
+
const fullCmd = cmdBuf + input;
|
|
191
|
+
// Parse count prefix
|
|
192
|
+
const countMatch = fullCmd.match(/^(\d+)(.*)/);
|
|
193
|
+
const count = countMatch ? parseInt(countMatch[1]) : 1;
|
|
194
|
+
const cmd = countMatch ? countMatch[2] : fullCmd;
|
|
195
|
+
// ── Mode switches ──
|
|
196
|
+
if (cmd === 'i') {
|
|
197
|
+
switchMode('insert');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (cmd === 'a') {
|
|
201
|
+
setCursor(Math.min(value.length, clampedCursor + 1));
|
|
202
|
+
switchMode('insert');
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (cmd === 'I') {
|
|
206
|
+
setCursor(0);
|
|
207
|
+
switchMode('insert');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (cmd === 'A') {
|
|
211
|
+
setCursor(value.length);
|
|
212
|
+
switchMode('insert');
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (cmd === 's') { // substitute: delete char and enter insert
|
|
216
|
+
saveUndo();
|
|
217
|
+
updateValue(value.slice(0, clampedCursor) + value.slice(clampedCursor + 1), clampedCursor);
|
|
218
|
+
switchMode('insert');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (cmd === 'S' || cmd === 'cc') { // substitute line
|
|
222
|
+
saveUndo();
|
|
223
|
+
setYankBuf(value);
|
|
224
|
+
updateValue('', 0);
|
|
225
|
+
switchMode('insert');
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
// ── Navigation ──
|
|
229
|
+
if (cmd === 'h') {
|
|
230
|
+
setCursor(Math.max(0, clampedCursor - count));
|
|
231
|
+
setCmdBuf('');
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (cmd === 'l') {
|
|
235
|
+
setCursor(Math.min(Math.max(0, value.length - 1), clampedCursor + count));
|
|
236
|
+
setCmdBuf('');
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (cmd === 'w') {
|
|
240
|
+
let pos = clampedCursor;
|
|
241
|
+
for (let n = 0; n < count; n++)
|
|
242
|
+
pos = nextWord(value, pos);
|
|
243
|
+
setCursor(Math.min(pos, Math.max(0, value.length - 1)));
|
|
244
|
+
setCmdBuf('');
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (cmd === 'b') {
|
|
248
|
+
let pos = clampedCursor;
|
|
249
|
+
for (let n = 0; n < count; n++)
|
|
250
|
+
pos = prevWord(value, pos);
|
|
251
|
+
setCursor(pos);
|
|
252
|
+
setCmdBuf('');
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (cmd === 'e') {
|
|
256
|
+
let pos = clampedCursor;
|
|
257
|
+
for (let n = 0; n < count; n++)
|
|
258
|
+
pos = endWord(value, pos);
|
|
259
|
+
setCursor(Math.min(pos, Math.max(0, value.length - 1)));
|
|
260
|
+
setCmdBuf('');
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (cmd === '0') {
|
|
264
|
+
// Only if not building a count (e.g., "10w" — "0" is part of count)
|
|
265
|
+
if (!countMatch || countMatch[2] === '0') {
|
|
266
|
+
setCursor(0);
|
|
267
|
+
setCmdBuf('');
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (cmd === '$') {
|
|
272
|
+
setCursor(Math.max(0, value.length - 1));
|
|
273
|
+
setCmdBuf('');
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (cmd === '^') {
|
|
277
|
+
const firstNonSpace = value.search(/\S/);
|
|
278
|
+
setCursor(firstNonSpace >= 0 ? firstNonSpace : 0);
|
|
279
|
+
setCmdBuf('');
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// ── Editing ──
|
|
283
|
+
if (cmd === 'x') {
|
|
284
|
+
if (value.length > 0) {
|
|
285
|
+
saveUndo();
|
|
286
|
+
const deleted = value.slice(clampedCursor, clampedCursor + count);
|
|
287
|
+
setYankBuf(deleted);
|
|
288
|
+
const newVal = value.slice(0, clampedCursor) + value.slice(clampedCursor + count);
|
|
289
|
+
updateValue(newVal, Math.min(clampedCursor, Math.max(0, newVal.length - 1)));
|
|
290
|
+
}
|
|
291
|
+
setCmdBuf('');
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (cmd === 'X') {
|
|
295
|
+
if (clampedCursor > 0) {
|
|
296
|
+
saveUndo();
|
|
297
|
+
const start = Math.max(0, clampedCursor - count);
|
|
298
|
+
setYankBuf(value.slice(start, clampedCursor));
|
|
299
|
+
updateValue(value.slice(0, start) + value.slice(clampedCursor), start);
|
|
300
|
+
}
|
|
301
|
+
setCmdBuf('');
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (cmd === 'dd') {
|
|
305
|
+
saveUndo();
|
|
306
|
+
setYankBuf(value);
|
|
307
|
+
updateValue('', 0);
|
|
308
|
+
setCmdBuf('');
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (cmd === 'D') {
|
|
312
|
+
saveUndo();
|
|
313
|
+
setYankBuf(value.slice(clampedCursor));
|
|
314
|
+
updateValue(value.slice(0, clampedCursor), Math.max(0, clampedCursor - 1));
|
|
315
|
+
setCmdBuf('');
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (cmd === 'C') { // change to end of line
|
|
319
|
+
saveUndo();
|
|
320
|
+
setYankBuf(value.slice(clampedCursor));
|
|
321
|
+
updateValue(value.slice(0, clampedCursor), clampedCursor);
|
|
322
|
+
switchMode('insert');
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (cmd === 'dw') {
|
|
326
|
+
saveUndo();
|
|
327
|
+
let pos = clampedCursor;
|
|
328
|
+
for (let n = 0; n < count; n++)
|
|
329
|
+
pos = nextWord(value, pos);
|
|
330
|
+
setYankBuf(value.slice(clampedCursor, pos));
|
|
331
|
+
updateValue(value.slice(0, clampedCursor) + value.slice(pos), clampedCursor);
|
|
332
|
+
setCmdBuf('');
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (cmd === 'db') {
|
|
336
|
+
saveUndo();
|
|
337
|
+
let pos = clampedCursor;
|
|
338
|
+
for (let n = 0; n < count; n++)
|
|
339
|
+
pos = prevWord(value, pos);
|
|
340
|
+
setYankBuf(value.slice(pos, clampedCursor));
|
|
341
|
+
updateValue(value.slice(0, pos) + value.slice(clampedCursor), pos);
|
|
342
|
+
setCmdBuf('');
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (cmd === 'cw') { // change word
|
|
346
|
+
saveUndo();
|
|
347
|
+
let pos = clampedCursor;
|
|
348
|
+
for (let n = 0; n < count; n++)
|
|
349
|
+
pos = nextWord(value, pos);
|
|
350
|
+
setYankBuf(value.slice(clampedCursor, pos));
|
|
351
|
+
updateValue(value.slice(0, clampedCursor) + value.slice(pos), clampedCursor);
|
|
352
|
+
switchMode('insert');
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (cmd === 'cb') { // change back
|
|
356
|
+
saveUndo();
|
|
357
|
+
let pos = clampedCursor;
|
|
358
|
+
for (let n = 0; n < count; n++)
|
|
359
|
+
pos = prevWord(value, pos);
|
|
360
|
+
setYankBuf(value.slice(pos, clampedCursor));
|
|
361
|
+
updateValue(value.slice(0, pos) + value.slice(clampedCursor), pos);
|
|
362
|
+
switchMode('insert');
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
// ── Yank & Paste ──
|
|
366
|
+
if (cmd === 'yy') {
|
|
367
|
+
setYankBuf(value);
|
|
368
|
+
setCmdBuf('');
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (cmd === 'yw') {
|
|
372
|
+
const pos = nextWord(value, clampedCursor);
|
|
373
|
+
setYankBuf(value.slice(clampedCursor, pos));
|
|
374
|
+
setCmdBuf('');
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (cmd === 'p') {
|
|
378
|
+
if (yankBuf) {
|
|
379
|
+
saveUndo();
|
|
380
|
+
const insertAt = Math.min(clampedCursor + 1, value.length);
|
|
381
|
+
updateValue(value.slice(0, insertAt) + yankBuf + value.slice(insertAt), insertAt + yankBuf.length - 1);
|
|
382
|
+
}
|
|
383
|
+
setCmdBuf('');
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (cmd === 'P') {
|
|
387
|
+
if (yankBuf) {
|
|
388
|
+
saveUndo();
|
|
389
|
+
updateValue(value.slice(0, clampedCursor) + yankBuf + value.slice(clampedCursor), clampedCursor + yankBuf.length - 1);
|
|
390
|
+
}
|
|
391
|
+
setCmdBuf('');
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
// ── Undo ──
|
|
395
|
+
if (cmd === 'u') {
|
|
396
|
+
if (undoStack.length > 0) {
|
|
397
|
+
const prev = undoStack[undoStack.length - 1];
|
|
398
|
+
setUndoStack(s => s.slice(0, -1));
|
|
399
|
+
onChange(prev);
|
|
400
|
+
setCursor(Math.min(clampedCursor, Math.max(0, prev.length - 1)));
|
|
401
|
+
lastValueRef.current = prev;
|
|
402
|
+
}
|
|
403
|
+
setCmdBuf('');
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
// ── Accumulate partial commands ──
|
|
407
|
+
// Valid prefixes for multi-key commands
|
|
408
|
+
if (/^\d+$/.test(fullCmd)) {
|
|
409
|
+
setCmdBuf(fullCmd);
|
|
410
|
+
return;
|
|
411
|
+
} // count accumulating
|
|
412
|
+
if (fullCmd === 'd' || fullCmd === 'c' || fullCmd === 'y') {
|
|
413
|
+
setCmdBuf(fullCmd);
|
|
414
|
+
return;
|
|
415
|
+
} // operator pending
|
|
416
|
+
if (/^\d+[dcy]$/.test(fullCmd)) {
|
|
417
|
+
setCmdBuf(fullCmd);
|
|
418
|
+
return;
|
|
419
|
+
} // count + operator
|
|
420
|
+
// Unknown command — reset
|
|
421
|
+
setCmdBuf('');
|
|
422
|
+
}, { isActive: focus });
|
|
423
|
+
// ── Render ──
|
|
424
|
+
const displayValue = value || (mode === 'insert' ? placeholder : '');
|
|
425
|
+
const isEmpty = !value;
|
|
426
|
+
// Build the displayed text with cursor
|
|
427
|
+
let rendered;
|
|
428
|
+
if (isEmpty && mode === 'insert') {
|
|
429
|
+
rendered = (_jsx(Text, { dimColor: true, children: placeholder }));
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
// Split text around cursor for highlighting
|
|
433
|
+
const before = displayValue.slice(0, clampedCursor);
|
|
434
|
+
const atCursor = displayValue[clampedCursor] || ' ';
|
|
435
|
+
const after = displayValue.slice(clampedCursor + 1);
|
|
436
|
+
rendered = (_jsxs(Text, { children: [before, _jsx(Text, { inverse: focus, bold: mode === 'normal', children: atCursor }), after] }));
|
|
437
|
+
}
|
|
438
|
+
return (_jsxs(Box, { children: [showMode && mode === 'normal' && (_jsx(Text, { color: "yellow", bold: true, children: "[N] " })), rendered, cmdBuf && mode === 'normal' && (_jsxs(Text, { dimColor: true, children: [" ", cmdBuf] }))] }));
|
|
439
|
+
}
|
package/package.json
CHANGED