veodl 1.8.2 → 1.9.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/CHANGELOG.md +28 -0
- package/README.md +796 -725
- package/docs/AGENT_GUIDE.md +82 -66
- package/package.json +1 -2
- package/src/alias.js +258 -0
- package/src/cli.js +613 -454
- package/src/config-diagnostics.js +145 -144
- package/src/config-editor.js +285 -281
- package/src/config-template.js +224 -171
- package/src/config.js +217 -213
- package/src/doctor.js +1 -1
- package/src/downloader.js +601 -600
- package/src/interactive.js +115 -82
- package/src/output.js +1 -1
- package/src/runs.js +343 -333
- package/src/source-discovery.js +251 -0
- package/src/state.js +27 -27
- package/src/uninstall.js +222 -0
- package/src/updater.js +5 -3
package/src/config-editor.js
CHANGED
|
@@ -1,281 +1,285 @@
|
|
|
1
|
-
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { emitKeypressEvents } from 'node:readline';
|
|
3
|
-
import { PassThrough } from 'node:stream';
|
|
4
|
-
import { StringDecoder } from 'node:string_decoder';
|
|
5
|
-
import { parseConfigText } from './config.js';
|
|
6
|
-
import { semanticIssue, editorCompletions } from './config-diagnostics.js';
|
|
7
|
-
import { configClipboard } from './config-clipboard.js';
|
|
8
|
-
|
|
9
|
-
const safe = text => text.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
|
|
10
|
-
|
|
11
|
-
export async function validateEditorText(text) {
|
|
12
|
-
try {
|
|
13
|
-
// Run source-aware checks even when the normal loader rejects a setting.
|
|
14
|
-
return await semanticIssue(text);
|
|
15
|
-
} catch (error) {
|
|
16
|
-
try { parseConfigText(text); } catch (configError) { error = configError; }
|
|
17
|
-
const location = /line (\d+), column (\d+)/.exec(error.message);
|
|
18
|
-
return { message: error.message.replace(/^The veo config file is not valid JSON: .*?\(line \d+, column \d+\)\. /, ''), line: location ? Number(location[1]) : null,
|
|
19
|
-
column: location ? Number(location[2]) : null };
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// A small text buffer shared by the keyboard handler and focused editing tests.
|
|
24
|
-
export class EditorBuffer {
|
|
25
|
-
constructor(text) { this.text = text; this.cursor = 0; this.anchor = null; }
|
|
26
|
-
get selection() {
|
|
27
|
-
return this.anchor === null ? [this.cursor, this.cursor] : [Math.min(this.anchor, this.cursor), Math.max(this.anchor, this.cursor)];
|
|
28
|
-
}
|
|
29
|
-
deleteSelection() {
|
|
30
|
-
const [start, end] = this.selection;
|
|
31
|
-
this.anchor = null;
|
|
32
|
-
if (start === end) return false;
|
|
33
|
-
this.text = this.text.slice(0, start) + this.text.slice(end); this.cursor = start;
|
|
34
|
-
return true;
|
|
35
|
-
}
|
|
36
|
-
point(row, col, extend = false) {
|
|
37
|
-
if (extend && this.anchor === null) this.anchor = this.cursor;
|
|
38
|
-
if (!extend) this.anchor = null;
|
|
39
|
-
const lines = this.text.split('\n');
|
|
40
|
-
row = Math.max(0, Math.min(lines.length - 1, row));
|
|
41
|
-
this.cursor = lines.slice(0, row).reduce((sum, line) => sum + line.length + 1, 0) + Math.max(0, Math.min(lines[row].length, col));
|
|
42
|
-
}
|
|
43
|
-
insert(text) {
|
|
44
|
-
this.deleteSelection();
|
|
45
|
-
this.text = this.text.slice(0, this.cursor) + text + this.text.slice(this.cursor);
|
|
46
|
-
this.cursor += text.length;
|
|
47
|
-
}
|
|
48
|
-
get position() {
|
|
49
|
-
const before = this.text.slice(0, this.cursor);
|
|
50
|
-
return { row: before.split('\n').length - 1, col: this.cursor - before.lastIndexOf('\n') - 1 };
|
|
51
|
-
}
|
|
52
|
-
move(rows) {
|
|
53
|
-
const lines = this.text.split('\n'), { row, col } = this.position;
|
|
54
|
-
const next = Math.max(0, Math.min(lines.length - 1, row + rows));
|
|
55
|
-
this.cursor = lines.slice(0, next).reduce((sum, line) => sum + line.length + 1, 0) + Math.min(col, lines[next].length);
|
|
56
|
-
}
|
|
57
|
-
key(name) {
|
|
58
|
-
if (['backspace', 'delete'].includes(name) && this.deleteSelection()) return;
|
|
59
|
-
if (['left', 'right', 'up', 'down', 'home', 'end'].includes(name)) this.anchor = null;
|
|
60
|
-
if (name === 'left') this.cursor = Math.max(0, this.cursor - 1);
|
|
61
|
-
if (name === 'right') this.cursor = Math.min(this.text.length, this.cursor + 1);
|
|
62
|
-
if (name === 'up') this.move(-1);
|
|
63
|
-
if (name === 'down') this.move(1);
|
|
64
|
-
if (name === 'home') this.cursor -= this.position.col;
|
|
65
|
-
if (name === 'end') this.cursor = this.text.indexOf('\n', this.cursor) < 0 ? this.text.length : this.text.indexOf('\n', this.cursor);
|
|
66
|
-
if (name === 'backspace' && this.cursor) {
|
|
67
|
-
this.text = this.text.slice(0, this.cursor - 1) + this.text.slice(this.cursor); this.cursor--;
|
|
68
|
-
}
|
|
69
|
-
if (name === 'delete') this.text = this.text.slice(0, this.cursor) + this.text.slice(this.cursor + 1);
|
|
70
|
-
if (name === 'return') this.insert('\n');
|
|
71
|
-
if (name === 'tab') this.insert(' ');
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function colorLine(line, enabled = true) {
|
|
76
|
-
const clean = safe(line);
|
|
77
|
-
if (!enabled) return clean;
|
|
78
|
-
return clean.replace(/("(?:\\.|[^"\\])*"\s*:?)|(\/\/.*$)|\b(true|false|null|-?\d+(?:\.\d+)?)\b/g,
|
|
79
|
-
(token, string, comment) => `\x1b[${comment ? 90 : string ? (token.endsWith(':') ? 36 : 32) : 33}m${token}\x1b[0m`);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export async function editConfig(file, { input = process.stdin, output = process.stdout, color = !Object.hasOwn(process.env, 'NO_COLOR'), clipboard = configClipboard } = {}) {
|
|
83
|
-
if (!input.isTTY || !output.isTTY || !input.setRawMode) throw new Error('The config editor requires an interactive terminal. Set EDITOR to use an external editor.');
|
|
84
|
-
const original = await readFile(file, 'utf8');
|
|
85
|
-
const newline = original.includes('\r\n') ? '\r\n' : '\n';
|
|
86
|
-
const buffer = new EditorBuffer(original.replace(/\r\n/g, '\n'));
|
|
87
|
-
let saved = buffer.text, disk = original, issue = await validateEditorText(buffer.text);
|
|
88
|
-
let top = 0, left = 0, followCursor = true, question = false, busy = false, closed = false, timer;
|
|
89
|
-
let status = '', revision = 0;
|
|
90
|
-
let viewport, dragging = false, completion = null;
|
|
91
|
-
const wasRaw = input.isRaw, wasPaused = input.isPaused();
|
|
92
|
-
const render = () => {
|
|
93
|
-
if (closed) return;
|
|
94
|
-
const width = Math.max(1, (output.columns || 80) - 1)
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
keyboard.write(
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (key.name === 'escape')
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
243
|
-
if (key.ctrl && key.name === '
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
3
|
+
import { PassThrough } from 'node:stream';
|
|
4
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
5
|
+
import { parseConfigText } from './config.js';
|
|
6
|
+
import { semanticIssue, editorCompletions } from './config-diagnostics.js';
|
|
7
|
+
import { configClipboard } from './config-clipboard.js';
|
|
8
|
+
|
|
9
|
+
const safe = text => text.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
|
|
10
|
+
|
|
11
|
+
export async function validateEditorText(text) {
|
|
12
|
+
try {
|
|
13
|
+
// Run source-aware checks even when the normal loader rejects a setting.
|
|
14
|
+
return await semanticIssue(text);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
try { parseConfigText(text); } catch (configError) { error = configError; }
|
|
17
|
+
const location = /line (\d+), column (\d+)/.exec(error.message);
|
|
18
|
+
return { message: error.message.replace(/^The veo config file is not valid JSON: .*?\(line \d+, column \d+\)\. /, ''), line: location ? Number(location[1]) : null,
|
|
19
|
+
column: location ? Number(location[2]) : null };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A small text buffer shared by the keyboard handler and focused editing tests.
|
|
24
|
+
export class EditorBuffer {
|
|
25
|
+
constructor(text) { this.text = text; this.cursor = 0; this.anchor = null; }
|
|
26
|
+
get selection() {
|
|
27
|
+
return this.anchor === null ? [this.cursor, this.cursor] : [Math.min(this.anchor, this.cursor), Math.max(this.anchor, this.cursor)];
|
|
28
|
+
}
|
|
29
|
+
deleteSelection() {
|
|
30
|
+
const [start, end] = this.selection;
|
|
31
|
+
this.anchor = null;
|
|
32
|
+
if (start === end) return false;
|
|
33
|
+
this.text = this.text.slice(0, start) + this.text.slice(end); this.cursor = start;
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
point(row, col, extend = false) {
|
|
37
|
+
if (extend && this.anchor === null) this.anchor = this.cursor;
|
|
38
|
+
if (!extend) this.anchor = null;
|
|
39
|
+
const lines = this.text.split('\n');
|
|
40
|
+
row = Math.max(0, Math.min(lines.length - 1, row));
|
|
41
|
+
this.cursor = lines.slice(0, row).reduce((sum, line) => sum + line.length + 1, 0) + Math.max(0, Math.min(lines[row].length, col));
|
|
42
|
+
}
|
|
43
|
+
insert(text) {
|
|
44
|
+
this.deleteSelection();
|
|
45
|
+
this.text = this.text.slice(0, this.cursor) + text + this.text.slice(this.cursor);
|
|
46
|
+
this.cursor += text.length;
|
|
47
|
+
}
|
|
48
|
+
get position() {
|
|
49
|
+
const before = this.text.slice(0, this.cursor);
|
|
50
|
+
return { row: before.split('\n').length - 1, col: this.cursor - before.lastIndexOf('\n') - 1 };
|
|
51
|
+
}
|
|
52
|
+
move(rows) {
|
|
53
|
+
const lines = this.text.split('\n'), { row, col } = this.position;
|
|
54
|
+
const next = Math.max(0, Math.min(lines.length - 1, row + rows));
|
|
55
|
+
this.cursor = lines.slice(0, next).reduce((sum, line) => sum + line.length + 1, 0) + Math.min(col, lines[next].length);
|
|
56
|
+
}
|
|
57
|
+
key(name) {
|
|
58
|
+
if (['backspace', 'delete'].includes(name) && this.deleteSelection()) return;
|
|
59
|
+
if (['left', 'right', 'up', 'down', 'home', 'end'].includes(name)) this.anchor = null;
|
|
60
|
+
if (name === 'left') this.cursor = Math.max(0, this.cursor - 1);
|
|
61
|
+
if (name === 'right') this.cursor = Math.min(this.text.length, this.cursor + 1);
|
|
62
|
+
if (name === 'up') this.move(-1);
|
|
63
|
+
if (name === 'down') this.move(1);
|
|
64
|
+
if (name === 'home') this.cursor -= this.position.col;
|
|
65
|
+
if (name === 'end') this.cursor = this.text.indexOf('\n', this.cursor) < 0 ? this.text.length : this.text.indexOf('\n', this.cursor);
|
|
66
|
+
if (name === 'backspace' && this.cursor) {
|
|
67
|
+
this.text = this.text.slice(0, this.cursor - 1) + this.text.slice(this.cursor); this.cursor--;
|
|
68
|
+
}
|
|
69
|
+
if (name === 'delete') this.text = this.text.slice(0, this.cursor) + this.text.slice(this.cursor + 1);
|
|
70
|
+
if (name === 'return') this.insert('\n');
|
|
71
|
+
if (name === 'tab') this.insert(' ');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function colorLine(line, enabled = true) {
|
|
76
|
+
const clean = safe(line);
|
|
77
|
+
if (!enabled) return clean;
|
|
78
|
+
return clean.replace(/("(?:\\.|[^"\\])*"\s*:?)|(\/\/.*$)|\b(true|false|null|-?\d+(?:\.\d+)?)\b/g,
|
|
79
|
+
(token, string, comment) => `\x1b[${comment ? 90 : string ? (token.endsWith(':') ? 36 : 32) : 33}m${token}\x1b[0m`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function editConfig(file, { input = process.stdin, output = process.stdout, color = !Object.hasOwn(process.env, 'NO_COLOR'), clipboard = configClipboard } = {}) {
|
|
83
|
+
if (!input.isTTY || !output.isTTY || !input.setRawMode) throw new Error('The config editor requires an interactive terminal. Set EDITOR to use an external editor.');
|
|
84
|
+
const original = await readFile(file, 'utf8');
|
|
85
|
+
const newline = original.includes('\r\n') ? '\r\n' : '\n';
|
|
86
|
+
const buffer = new EditorBuffer(original.replace(/\r\n/g, '\n'));
|
|
87
|
+
let saved = buffer.text, disk = original, issue = await validateEditorText(buffer.text);
|
|
88
|
+
let top = 0, left = 0, followCursor = true, question = false, busy = false, closed = false, timer;
|
|
89
|
+
let status = '', revision = 0;
|
|
90
|
+
let viewport, dragging = false, completion = null;
|
|
91
|
+
const wasRaw = input.isRaw, wasPaused = input.isPaused();
|
|
92
|
+
const render = () => {
|
|
93
|
+
if (closed) return;
|
|
94
|
+
const width = Math.max(1, (output.columns || 80) - 1);
|
|
95
|
+
const diagnosis = issue ? `${issue.line ? `Line ${issue.line}, column ${issue.column}: ` : ''}${issue.message}` : '';
|
|
96
|
+
const detail = safe(completion ? `Options (${completion.index + 1}/${completion.choices.length}): ${JSON.stringify(completion.choices[completion.index])} | Up/Down choose, Enter apply, Esc cancel` :
|
|
97
|
+
status ? `${status}${diagnosis ? ` | ${diagnosis}` : ''}` : diagnosis || 'Config OK');
|
|
98
|
+
const detailRows = Math.min(3, Math.max(1, Math.ceil(detail.length / width)));
|
|
99
|
+
const height = Math.max(1, (output.rows || 24) - 2 - detailRows);
|
|
100
|
+
const lines = buffer.text.split('\n'), { row, col } = buffer.position;
|
|
101
|
+
const gutter = Math.min(width - 1, String(lines.length).length + 2), available = Math.max(1, width - gutter);
|
|
102
|
+
if (followCursor) {
|
|
103
|
+
top = Math.max(0, Math.min(top, row));
|
|
104
|
+
if (row >= top + height) top = row - height + 1;
|
|
105
|
+
}
|
|
106
|
+
top = Math.max(0, Math.min(top, Math.max(0, lines.length - height)));
|
|
107
|
+
left = Math.max(0, Math.min(left, col)); if (col >= left + available) left = col - available + 1;
|
|
108
|
+
viewport = { height, gutter, width };
|
|
109
|
+
const [selectionStart, selectionEnd] = buffer.selection;
|
|
110
|
+
let offset = lines.slice(0, top).reduce((sum, line) => sum + line.length + 1, 0);
|
|
111
|
+
const screen = [safe(`veo config | ${file}${buffer.text !== saved ? ' *' : ''}`).slice(0, width)];
|
|
112
|
+
for (let index = top; index < top + height; index++) {
|
|
113
|
+
const line = lines[index];
|
|
114
|
+
const prefix = line === undefined ? '' : `${index + 1 === issue?.line ? '!' : ' '}${String(index + 1).padStart(Math.max(0, gutter - 2))} `;
|
|
115
|
+
const content = (line || '').slice(left, left + available);
|
|
116
|
+
const start = Math.max(0, selectionStart - offset - left), end = Math.min(content.length, selectionEnd - offset - left);
|
|
117
|
+
if (end > start) {
|
|
118
|
+
screen.push(prefix + colorLine(content.slice(0, start), color) + '\x1b[7m' + safe(content.slice(start, end)) + '\x1b[0m' + colorLine(content.slice(end), color));
|
|
119
|
+
} else if (issue?.start !== undefined && color) {
|
|
120
|
+
const from = Math.max(0, issue.start - offset - left), to = Math.min(content.length, issue.end - offset - left);
|
|
121
|
+
screen.push(to > from ? prefix + colorLine(content.slice(0, from), color) + '\x1b[41;97m' + safe(content.slice(from, to)) + '\x1b[0m' + colorLine(content.slice(to), color) : prefix + colorLine(content, color));
|
|
122
|
+
} else screen.push(index + 1 === issue?.line && color ? `\x1b[41;97m${prefix}${safe(content)}\x1b[0m` : prefix + colorLine(content, color));
|
|
123
|
+
offset += (line || '').length + 1;
|
|
124
|
+
}
|
|
125
|
+
const position = `Ln ${row + 1}, Col ${col + 1}`;
|
|
126
|
+
const help = safe(question ? 'Discard unsaved changes? Y = discard, N / Esc = keep editing' : 'Ctrl+S Save | Ctrl+C Copy | Ctrl+V Paste | Esc Exit | F2 Options | Wheel Scroll');
|
|
127
|
+
const helpWidth = Math.max(0, width - position.length - 1);
|
|
128
|
+
screen.push((help.slice(0, helpWidth) + ' '.repeat(Math.max(1, width - position.length - Math.min(help.length, helpWidth))) + position).slice(0, width));
|
|
129
|
+
for (let index = 0; index < detailRows; index++) screen.push(detail.slice(index * width, (index + 1) * width));
|
|
130
|
+
const cursorVisible = row >= top && row < top + height;
|
|
131
|
+
output.write('\x1b[?25l\x1b[H' + screen.map(line => line + '\x1b[K').join('\r\n') +
|
|
132
|
+
(cursorVisible ? `\x1b[${row - top + 2};${gutter + col - left + 1}H\x1b[?25h` : ''));
|
|
133
|
+
};
|
|
134
|
+
await new Promise((resolve, reject) => {
|
|
135
|
+
const keyboard = new PassThrough();
|
|
136
|
+
const decoder = new StringDecoder('utf8');
|
|
137
|
+
let pending = '', mouseTimer;
|
|
138
|
+
const mouse = (button, x, y, release) => {
|
|
139
|
+
if (busy || closed || question) return;
|
|
140
|
+
if (button & 64) {
|
|
141
|
+
if (!release) {
|
|
142
|
+
followCursor = false;
|
|
143
|
+
top += (button & 1) ? 3 : -3;
|
|
144
|
+
render();
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
completion = null;
|
|
149
|
+
if ((button & 3) !== 0) return;
|
|
150
|
+
const inside = y >= 2 && y <= viewport.height + 1 && x >= 1 && x <= viewport.width;
|
|
151
|
+
if (!inside && !dragging) return;
|
|
152
|
+
if (!(button & 32) && !release) {
|
|
153
|
+
followCursor = true;
|
|
154
|
+
buffer.point(top + y - 2, left + x - viewport.gutter - 1);
|
|
155
|
+
buffer.anchor = buffer.cursor; dragging = true;
|
|
156
|
+
} else if (dragging) {
|
|
157
|
+
buffer.point(top + Math.max(0, Math.min(viewport.height - 1, y - 2)), left + x - viewport.gutter - 1, true);
|
|
158
|
+
}
|
|
159
|
+
if (release) dragging = false;
|
|
160
|
+
render();
|
|
161
|
+
};
|
|
162
|
+
// Filter SGR mouse reports before readline so their digits never enter the file.
|
|
163
|
+
const onData = chunk => {
|
|
164
|
+
clearTimeout(mouseTimer);
|
|
165
|
+
pending += typeof chunk === 'string' ? chunk : decoder.write(chunk);
|
|
166
|
+
while (pending) {
|
|
167
|
+
const match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/.exec(pending);
|
|
168
|
+
if (match) {
|
|
169
|
+
pending = pending.slice(match[0].length);
|
|
170
|
+
mouse(Number(match[1]), Number(match[2]), Number(match[3]), match[4] === 'm');
|
|
171
|
+
} else if (/^\x1b(?:\[(?:<[\d;]*)?)?$/.test(pending)) {
|
|
172
|
+
mouseTimer = setTimeout(() => { if (!pending.startsWith('\x1b[<')) keyboard.write(pending); pending = ''; }, 100);
|
|
173
|
+
break;
|
|
174
|
+
} else {
|
|
175
|
+
const character = String.fromCodePoint(pending.codePointAt(0));
|
|
176
|
+
keyboard.write(character); pending = pending.slice(character.length);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
const finish = error => {
|
|
181
|
+
if (closed) return;
|
|
182
|
+
closed = true; clearTimeout(timer); clearTimeout(mouseTimer);
|
|
183
|
+
input.off('data', onData); keyboard.off('keypress', onKey); keyboard.destroy();
|
|
184
|
+
input.off('keypress', onKey); input.off('end', onEnd); input.off('error', onError); output.off('resize', render);
|
|
185
|
+
input.setRawMode(Boolean(wasRaw)); if (wasPaused) input.pause();
|
|
186
|
+
output.write('\x1b[?1002l\x1b[?1006l\x1b[0m\x1b[?25h\x1b[?1049l');
|
|
187
|
+
error ? reject(error) : resolve();
|
|
188
|
+
};
|
|
189
|
+
const onEnd = () => finish();
|
|
190
|
+
const onError = error => finish(error);
|
|
191
|
+
const onKey = async (text, key = {}) => {
|
|
192
|
+
if (busy || closed) return;
|
|
193
|
+
try {
|
|
194
|
+
if (question) {
|
|
195
|
+
if (text?.toLowerCase() === 'y') return finish();
|
|
196
|
+
if (text?.toLowerCase() === 'n' || key.name === 'escape') question = false;
|
|
197
|
+
render(); return;
|
|
198
|
+
}
|
|
199
|
+
if (completion) {
|
|
200
|
+
if (key.name === 'escape') completion = null;
|
|
201
|
+
else if (['up', 'down', 'tab'].includes(key.name)) completion.index = (completion.index + (key.name === 'up' ? -1 : 1) + completion.choices.length) % completion.choices.length;
|
|
202
|
+
else if (key.name === 'return') {
|
|
203
|
+
buffer.anchor = completion.start; buffer.cursor = completion.end;
|
|
204
|
+
buffer.insert(JSON.stringify(completion.choices[completion.index]));
|
|
205
|
+
completion = null; status = ''; revision++; clearTimeout(timer);
|
|
206
|
+
busy = true; issue = await validateEditorText(buffer.text); busy = false;
|
|
207
|
+
}
|
|
208
|
+
render(); return;
|
|
209
|
+
}
|
|
210
|
+
if (key.name === 'f2' || (key.ctrl && key.name === 'space')) {
|
|
211
|
+
const options = editorCompletions(buffer.text, buffer.cursor);
|
|
212
|
+
completion = options ? { ...options, index: 0 } : null;
|
|
213
|
+
status = options ? '' : 'No options here. Place the cursor on a property or value in valid JSON.';
|
|
214
|
+
render(); return;
|
|
215
|
+
}
|
|
216
|
+
if (key.ctrl && key.name === 'c') {
|
|
217
|
+
const [start, end] = buffer.selection;
|
|
218
|
+
if (start === end) status = 'Select text first, then press Ctrl+C.';
|
|
219
|
+
else {
|
|
220
|
+
busy = true;
|
|
221
|
+
try { await clipboard.copy(buffer.text.slice(start, end)); status = 'Copied to clipboard.'; }
|
|
222
|
+
catch (error) { status = `Copy failed: ${error.message}`; }
|
|
223
|
+
finally { busy = false; }
|
|
224
|
+
}
|
|
225
|
+
render(); return;
|
|
226
|
+
}
|
|
227
|
+
if (key.ctrl && key.name === 'v') {
|
|
228
|
+
busy = true;
|
|
229
|
+
try {
|
|
230
|
+
const pasted = (await clipboard.paste()).replace(/\r\n?|\n/g, '\n').replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '');
|
|
231
|
+
if (pasted) {
|
|
232
|
+
buffer.insert(pasted); followCursor = true;
|
|
233
|
+
status = ''; issue = await validateEditorText(buffer.text);
|
|
234
|
+
revision++; clearTimeout(timer);
|
|
235
|
+
} else status = 'Clipboard has no text.';
|
|
236
|
+
} catch (error) { status = `Paste failed: ${error.message}`; }
|
|
237
|
+
finally { busy = false; }
|
|
238
|
+
render(); return;
|
|
239
|
+
}
|
|
240
|
+
if (key.ctrl && ['up', 'down'].includes(key.name)) {
|
|
241
|
+
followCursor = false; top += key.name === 'up' ? -3 : 3; render(); return;
|
|
242
|
+
}
|
|
243
|
+
if (key.name === 'escape' || (key.ctrl && key.name === 'q')) {
|
|
244
|
+
if (buffer.text === saved) return finish();
|
|
245
|
+
question = true; render(); return;
|
|
246
|
+
}
|
|
247
|
+
if (key.ctrl && key.name === 's') {
|
|
248
|
+
followCursor = true;
|
|
249
|
+
revision++; clearTimeout(timer); buffer.anchor = null;
|
|
250
|
+
busy = true;
|
|
251
|
+
issue = await validateEditorText(buffer.text);
|
|
252
|
+
if (issue) {
|
|
253
|
+
if (issue.line) buffer.cursor = buffer.text.split('\n').slice(0, issue.line - 1).reduce((sum, line) => sum + line.length + 1, 0) + issue.column - 1;
|
|
254
|
+
} else {
|
|
255
|
+
try {
|
|
256
|
+
if (await readFile(file, 'utf8') !== disk) throw new Error('File changed outside the editor. Exit and reopen before saving.');
|
|
257
|
+
const next = buffer.text.replace(/\n/g, newline);
|
|
258
|
+
await writeFile(file, next, 'utf8'); disk = next; saved = buffer.text; status = 'Saved';
|
|
259
|
+
} catch (error) { status = `Save failed: ${error.message}`; }
|
|
260
|
+
}
|
|
261
|
+
busy = false; render(); return;
|
|
262
|
+
}
|
|
263
|
+
if (key.ctrl || key.meta) return;
|
|
264
|
+
followCursor = true;
|
|
265
|
+
const before = buffer.text;
|
|
266
|
+
if (key.name === 'pageup' || key.name === 'pagedown') buffer.move((key.name === 'pageup' ? -1 : 1) * viewport.height);
|
|
267
|
+
else if (['left', 'right', 'up', 'down', 'home', 'end', 'backspace', 'delete', 'return', 'tab'].includes(key.name)) buffer.key(key.name);
|
|
268
|
+
else if (text && !/[\x00-\x1f\x7f-\x9f]/.test(text)) buffer.insert(text);
|
|
269
|
+
if (before !== buffer.text) {
|
|
270
|
+
status = ''; issue = null; const current = ++revision; clearTimeout(timer);
|
|
271
|
+
timer = setTimeout(async () => {
|
|
272
|
+
const result = await validateEditorText(buffer.text);
|
|
273
|
+
if (!closed && current === revision) { issue = result; render(); }
|
|
274
|
+
}, 150);
|
|
275
|
+
}
|
|
276
|
+
render();
|
|
277
|
+
} catch (error) { finish(error); }
|
|
278
|
+
};
|
|
279
|
+
emitKeypressEvents(keyboard); keyboard.on('keypress', onKey);
|
|
280
|
+
input.on('data', onData);
|
|
281
|
+
input.on('keypress', onKey); input.on('end', onEnd); input.on('error', onError); output.on('resize', render);
|
|
282
|
+
input.setRawMode(true); input.resume();
|
|
283
|
+
output.write('\x1b[?1049h\x1b[?1006h\x1b[?1002h'); render();
|
|
284
|
+
});
|
|
285
|
+
}
|