cli-surf 0.11.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/README.md +52 -0
- package/cli.js +1239 -0
- package/cliPrompt.js +527 -0
- package/cliSkills.js +264 -0
- package/cliTools.js +490 -0
- package/cliUi.js +436 -0
- package/opusModels.js +83 -0
- package/package.json +31 -0
- package/skills/commit/SKILL.md +21 -0
- package/skills/review/SKILL.md +29 -0
package/cliPrompt.js
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Живая строка ввода для чата — как в Claude Code: печатаешь «/mo»,
|
|
3
|
+
* снизу выпадают подсказки, Tab/Enter — принять, ↑↓ — выбор/история.
|
|
4
|
+
* Без внешних зависимостей: raw-mode stdin + ANSI, прямой разбор байтов
|
|
5
|
+
* (поэтому не конфликтует с keypress-хуком пикера модели).
|
|
6
|
+
* Только для TTY; для pipe остаётся обычный readline.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { paint, tr } from './cliUi.js';
|
|
12
|
+
import { listSkillNames } from './cliSkills.js';
|
|
13
|
+
const HIST_PATH = path.join(os.homedir(), '.surf-cli', 'history');
|
|
14
|
+
const HIST_LIMIT = 200;
|
|
15
|
+
const MAX_ROWS = 6;
|
|
16
|
+
export function loadHistory() {
|
|
17
|
+
try {
|
|
18
|
+
return fs.readFileSync(HIST_PATH, 'utf8').split('\n').map(l => l.replace(/\r$/, '')).filter(l => l.length > 0).slice(-HIST_LIMIT);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function appendHistory(line) {
|
|
25
|
+
const trimmed = line.trim();
|
|
26
|
+
if (!trimmed)
|
|
27
|
+
return;
|
|
28
|
+
try {
|
|
29
|
+
fs.mkdirSync(path.dirname(HIST_PATH), { recursive: true });
|
|
30
|
+
const prev = loadHistory();
|
|
31
|
+
if (prev[prev.length - 1] === trimmed)
|
|
32
|
+
return;
|
|
33
|
+
fs.appendFileSync(HIST_PATH, `${trimmed}\n`);
|
|
34
|
+
if (prev.length >= HIST_LIMIT) {
|
|
35
|
+
fs.writeFileSync(HIST_PATH, [...prev.slice(-HIST_LIMIT + 1), trimmed].join('\n') + '\n');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch { /* история — необязательна */ }
|
|
39
|
+
}
|
|
40
|
+
function stripAnsi(text) {
|
|
41
|
+
const ESC = String.fromCharCode(0x1b);
|
|
42
|
+
let out = '';
|
|
43
|
+
let i = 0;
|
|
44
|
+
for (;;) {
|
|
45
|
+
const j = text.indexOf(ESC, i);
|
|
46
|
+
if (j < 0) {
|
|
47
|
+
out += text.slice(i);
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
out += text.slice(i, j);
|
|
51
|
+
const m = /^\[[0-9;]*m/.exec(text.slice(j + 1));
|
|
52
|
+
i = m ? j + 1 + m[0].length : j + 1;
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Одна строка ввода с живыми подсказками.
|
|
58
|
+
* Возвращает текст и вложения из дропа (токены [image N]/[file N] в тексте).
|
|
59
|
+
* null в line = отмена (Ctrl+C / Ctrl+D на пустой).
|
|
60
|
+
*/
|
|
61
|
+
export async function promptMenuLine(opts) {
|
|
62
|
+
const { prompt, history, complete, noArgCommands } = opts;
|
|
63
|
+
const stdin = process.stdin;
|
|
64
|
+
const stdout = process.stdout;
|
|
65
|
+
if (!stdin.isTTY || !stdout.isTTY)
|
|
66
|
+
throw new Error('promptMenuLine needs a TTY');
|
|
67
|
+
const pw = stripAnsi(prompt).length;
|
|
68
|
+
let buffer = '';
|
|
69
|
+
let cursor = 0;
|
|
70
|
+
let matches = [];
|
|
71
|
+
let sel = 0;
|
|
72
|
+
let menuOffset = 0;
|
|
73
|
+
let menuOpen = false;
|
|
74
|
+
let histIdx = history.length;
|
|
75
|
+
let drawn = 0;
|
|
76
|
+
// Вложения из дропа: токены [image N]/[file N] живут прямо в строке.
|
|
77
|
+
let attachments = [];
|
|
78
|
+
let imageSeq = 0;
|
|
79
|
+
let fileSeq = 0;
|
|
80
|
+
const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
|
|
81
|
+
const tokenSet = () => new Set(attachments.map(a => a.token));
|
|
82
|
+
/** Подсветка токенов вложений в строке ввода (paint сам гаснет без цвета). */
|
|
83
|
+
const paintTokens = (text) => {
|
|
84
|
+
const known = tokenSet();
|
|
85
|
+
return text.split(/(\[(?:image|file) \d+\])/g).map(part => known.has(part) ? paint(part, 'bold', 'accent') : part).join('');
|
|
86
|
+
};
|
|
87
|
+
const refreshMenu = () => {
|
|
88
|
+
matches = complete(buffer);
|
|
89
|
+
sel = 0;
|
|
90
|
+
menuOffset = 0;
|
|
91
|
+
menuOpen = matches.length > 0;
|
|
92
|
+
};
|
|
93
|
+
const keepSelectionVisible = () => {
|
|
94
|
+
if (sel < menuOffset)
|
|
95
|
+
menuOffset = sel;
|
|
96
|
+
if (sel >= menuOffset + MAX_ROWS)
|
|
97
|
+
menuOffset = sel - MAX_ROWS + 1;
|
|
98
|
+
};
|
|
99
|
+
const insertTokenForFile = (absPath) => {
|
|
100
|
+
const ext = absPath.split('.').pop()?.toLowerCase() ?? '';
|
|
101
|
+
const kind = IMAGE_EXT.has(ext) ? 'image' : 'file';
|
|
102
|
+
const n = kind === 'image' ? (imageSeq += 1) : (fileSeq += 1);
|
|
103
|
+
const token = `[${kind} ${n}]`;
|
|
104
|
+
attachments.push({ token, path: absPath, kind });
|
|
105
|
+
buffer = buffer.slice(0, cursor) + token + buffer.slice(cursor);
|
|
106
|
+
cursor += token.length;
|
|
107
|
+
histIdx = history.length;
|
|
108
|
+
};
|
|
109
|
+
const removeAttachment = (token) => {
|
|
110
|
+
attachments = attachments.filter(a => a.token !== token);
|
|
111
|
+
};
|
|
112
|
+
/** Известный токен ровно перед курсором (для удаления целиком). */
|
|
113
|
+
const tokenEndingAt = () => {
|
|
114
|
+
const m = buffer.slice(0, cursor).match(/\[(image|file) \d+\]$/);
|
|
115
|
+
if (m && tokenSet().has(m[0]))
|
|
116
|
+
return { token: m[0], start: cursor - m[0].length };
|
|
117
|
+
return null;
|
|
118
|
+
};
|
|
119
|
+
/** Известный токен ровно под курсором (для Delete вперёд). */
|
|
120
|
+
const tokenStartingAt = () => {
|
|
121
|
+
const m = buffer.slice(cursor).match(/^\[(image|file) \d+\]/);
|
|
122
|
+
if (m && tokenSet().has(m[0]))
|
|
123
|
+
return { token: m[0], end: cursor + m[0].length };
|
|
124
|
+
return null;
|
|
125
|
+
};
|
|
126
|
+
let finish = null;
|
|
127
|
+
const done = (value) => {
|
|
128
|
+
stdin.removeListener('data', onData);
|
|
129
|
+
try {
|
|
130
|
+
stdin.setRawMode(false);
|
|
131
|
+
}
|
|
132
|
+
catch { /* уже не raw */ }
|
|
133
|
+
stdin.pause();
|
|
134
|
+
stdout.write('\x1b[?25h');
|
|
135
|
+
if (value === null) {
|
|
136
|
+
eraseDrawn();
|
|
137
|
+
stdout.write('\r\x1b[2K');
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
eraseDrawn();
|
|
141
|
+
stdout.write(`\r\x1b[${pw + buffer.length}C\n`);
|
|
142
|
+
stdout.write('\x1b[J');
|
|
143
|
+
}
|
|
144
|
+
finish?.({ line: value, attachments });
|
|
145
|
+
};
|
|
146
|
+
const eraseDrawn = () => {
|
|
147
|
+
if (drawn === 0)
|
|
148
|
+
return;
|
|
149
|
+
let s = '';
|
|
150
|
+
for (let i = 0; i < drawn; i += 1)
|
|
151
|
+
s += '\x1b[1B\x1b[2K';
|
|
152
|
+
s += `\x1b[${drawn}A\r`;
|
|
153
|
+
stdout.write(s);
|
|
154
|
+
drawn = 0;
|
|
155
|
+
};
|
|
156
|
+
const render = () => {
|
|
157
|
+
eraseDrawn();
|
|
158
|
+
const rows = [`${prompt}${paintTokens(buffer)}`];
|
|
159
|
+
if (menuOpen) {
|
|
160
|
+
matches.slice(menuOffset, menuOffset + MAX_ROWS).forEach((m, row) => {
|
|
161
|
+
const i = menuOffset + row;
|
|
162
|
+
const active = i === sel;
|
|
163
|
+
const mark = active ? paint('❯', 'accent') : ' ';
|
|
164
|
+
const value = active ? paint(m.value, 'bold', 'white') : paint(m.value, 'gray');
|
|
165
|
+
const desc = m.desc ? paint(` ${m.desc}`, 'gray') : '';
|
|
166
|
+
rows.push(`${mark} ${value}${desc}`);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
stdout.write(`\r${rows.map(l => `\x1b[K${l}`).join('\n')}`);
|
|
170
|
+
drawn = menuOpen ? Math.min(matches.length, MAX_ROWS) : 0;
|
|
171
|
+
const up = rows.length - 1;
|
|
172
|
+
if (up > 0)
|
|
173
|
+
stdout.write(`\x1b[${up}A`);
|
|
174
|
+
stdout.write(`\r\x1b[${pw + cursor}C`);
|
|
175
|
+
};
|
|
176
|
+
const insert = (text) => {
|
|
177
|
+
buffer = buffer.slice(0, cursor) + text + buffer.slice(cursor);
|
|
178
|
+
cursor += text.length;
|
|
179
|
+
histIdx = history.length;
|
|
180
|
+
refreshMenu();
|
|
181
|
+
render();
|
|
182
|
+
};
|
|
183
|
+
const submit = () => {
|
|
184
|
+
if (menuOpen && matches[sel] && buffer !== matches[sel].value) {
|
|
185
|
+
// Как в Claude: Enter дополняет недопечатанное, а не отправляет.
|
|
186
|
+
buffer = matches[sel].value;
|
|
187
|
+
if (noArgCommands.includes(buffer)) {
|
|
188
|
+
done(buffer);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (!buffer.endsWith(' '))
|
|
192
|
+
buffer += ' ';
|
|
193
|
+
cursor = buffer.length;
|
|
194
|
+
histIdx = history.length;
|
|
195
|
+
refreshMenu();
|
|
196
|
+
render();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
done(buffer);
|
|
200
|
+
};
|
|
201
|
+
const tabAccept = () => {
|
|
202
|
+
const item = menuOpen ? matches[sel] : undefined;
|
|
203
|
+
if (!item)
|
|
204
|
+
return;
|
|
205
|
+
buffer = item.value;
|
|
206
|
+
if (!noArgCommands.includes(buffer) && !buffer.endsWith(' '))
|
|
207
|
+
buffer += ' ';
|
|
208
|
+
cursor = buffer.length;
|
|
209
|
+
histIdx = history.length;
|
|
210
|
+
refreshMenu();
|
|
211
|
+
render();
|
|
212
|
+
};
|
|
213
|
+
const moveHist = (dir) => {
|
|
214
|
+
const next = histIdx + dir;
|
|
215
|
+
if (next < 0 || next > history.length)
|
|
216
|
+
return;
|
|
217
|
+
histIdx = next;
|
|
218
|
+
buffer = histIdx === history.length ? '' : (history[histIdx] ?? '');
|
|
219
|
+
cursor = buffer.length;
|
|
220
|
+
refreshMenu();
|
|
221
|
+
render();
|
|
222
|
+
};
|
|
223
|
+
const killWord = () => {
|
|
224
|
+
const left = buffer.slice(0, cursor).replace(/\s+$/, '');
|
|
225
|
+
const cut = left.match(/[\s_/-]+[^\s_/-]*$/) ?? left.match(/^[^\s_/-]+$/);
|
|
226
|
+
const drop = cut ? cut[0].length : 0;
|
|
227
|
+
buffer = buffer.slice(0, cursor - drop) + buffer.slice(cursor);
|
|
228
|
+
cursor -= drop;
|
|
229
|
+
histIdx = history.length;
|
|
230
|
+
refreshMenu();
|
|
231
|
+
render();
|
|
232
|
+
};
|
|
233
|
+
const resetLine = (text) => {
|
|
234
|
+
buffer = text;
|
|
235
|
+
cursor = text.length;
|
|
236
|
+
attachments = [];
|
|
237
|
+
histIdx = history.length;
|
|
238
|
+
refreshMenu();
|
|
239
|
+
render();
|
|
240
|
+
};
|
|
241
|
+
function onData(buf) {
|
|
242
|
+
const s = buf.toString('utf8');
|
|
243
|
+
switch (s) {
|
|
244
|
+
case '\r':
|
|
245
|
+
case '\n':
|
|
246
|
+
submit();
|
|
247
|
+
return;
|
|
248
|
+
case '\t':
|
|
249
|
+
tabAccept();
|
|
250
|
+
return;
|
|
251
|
+
case '\x7f':
|
|
252
|
+
case '\b':
|
|
253
|
+
if (cursor > 0) {
|
|
254
|
+
const tok = tokenEndingAt();
|
|
255
|
+
if (tok) {
|
|
256
|
+
buffer = buffer.slice(0, tok.start) + buffer.slice(cursor);
|
|
257
|
+
cursor = tok.start;
|
|
258
|
+
removeAttachment(tok.token);
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
buffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor);
|
|
262
|
+
cursor -= 1;
|
|
263
|
+
}
|
|
264
|
+
histIdx = history.length;
|
|
265
|
+
refreshMenu();
|
|
266
|
+
render();
|
|
267
|
+
}
|
|
268
|
+
return;
|
|
269
|
+
case '\x1b':
|
|
270
|
+
if (menuOpen) {
|
|
271
|
+
menuOpen = false;
|
|
272
|
+
render();
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
resetLine('');
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
case '\x1b[A':
|
|
279
|
+
case '\x1bOA':
|
|
280
|
+
if (menuOpen && matches.length > 1) {
|
|
281
|
+
sel = (sel - 1 + matches.length) % matches.length;
|
|
282
|
+
keepSelectionVisible();
|
|
283
|
+
render();
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
moveHist(-1);
|
|
287
|
+
}
|
|
288
|
+
return;
|
|
289
|
+
case '\x1b[B':
|
|
290
|
+
case '\x1bOB':
|
|
291
|
+
if (menuOpen && matches.length > 1) {
|
|
292
|
+
sel = (sel + 1) % matches.length;
|
|
293
|
+
keepSelectionVisible();
|
|
294
|
+
render();
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
moveHist(1);
|
|
298
|
+
}
|
|
299
|
+
return;
|
|
300
|
+
case '\x1b[C':
|
|
301
|
+
case '\x1bOC':
|
|
302
|
+
if (cursor < buffer.length) {
|
|
303
|
+
cursor += 1;
|
|
304
|
+
render();
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
case '\x1b[D':
|
|
308
|
+
case '\x1bOD':
|
|
309
|
+
if (cursor > 0) {
|
|
310
|
+
cursor -= 1;
|
|
311
|
+
render();
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
case '\x1b[H':
|
|
315
|
+
case '\x1b[1~':
|
|
316
|
+
case '\x1b[7~':
|
|
317
|
+
case '\x1bOH':
|
|
318
|
+
case '\x01':
|
|
319
|
+
cursor = 0;
|
|
320
|
+
render();
|
|
321
|
+
return;
|
|
322
|
+
case '\x1b[F':
|
|
323
|
+
case '\x1b[4~':
|
|
324
|
+
case '\x1b[8~':
|
|
325
|
+
case '\x1bOF':
|
|
326
|
+
case '\x05':
|
|
327
|
+
cursor = buffer.length;
|
|
328
|
+
render();
|
|
329
|
+
return;
|
|
330
|
+
case '\x1b[5~':
|
|
331
|
+
moveHist(-1);
|
|
332
|
+
return;
|
|
333
|
+
case '\x1b[6~':
|
|
334
|
+
moveHist(1);
|
|
335
|
+
return;
|
|
336
|
+
case '\x1b[3~':
|
|
337
|
+
if (cursor < buffer.length) {
|
|
338
|
+
const tok = tokenStartingAt();
|
|
339
|
+
if (tok) {
|
|
340
|
+
buffer = buffer.slice(0, cursor) + buffer.slice(tok.end);
|
|
341
|
+
removeAttachment(tok.token);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1);
|
|
345
|
+
}
|
|
346
|
+
histIdx = history.length;
|
|
347
|
+
refreshMenu();
|
|
348
|
+
render();
|
|
349
|
+
}
|
|
350
|
+
return;
|
|
351
|
+
case '\x15':
|
|
352
|
+
resetLine('');
|
|
353
|
+
return;
|
|
354
|
+
case '\x17':
|
|
355
|
+
killWord();
|
|
356
|
+
return;
|
|
357
|
+
case '\x03':
|
|
358
|
+
done(null);
|
|
359
|
+
return;
|
|
360
|
+
case '\x04':
|
|
361
|
+
if (buffer.length === 0)
|
|
362
|
+
done(null);
|
|
363
|
+
return;
|
|
364
|
+
default:
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
// Неизвестные escape-последовательности (F1-F12 и т.п.) — игнор целиком,
|
|
368
|
+
// иначе их хвост впечатается в строку (как было с PgUp → «[5~»).
|
|
369
|
+
if (s.charCodeAt(0) === 0x1b)
|
|
370
|
+
return;
|
|
371
|
+
// Одиночные управляющие — игнор; остальное (включая вставки) — в буфер.
|
|
372
|
+
if (s.length === 1 && (s.charCodeAt(0) < 0x20 || s.charCodeAt(0) === 0x7f))
|
|
373
|
+
return;
|
|
374
|
+
const clean = [...s].filter(c => {
|
|
375
|
+
const n = c.codePointAt(0) ?? 0;
|
|
376
|
+
return (n >= 0x20 && n < 0x7f) || n >= 0xa0;
|
|
377
|
+
}).join('');
|
|
378
|
+
if (!clean)
|
|
379
|
+
return;
|
|
380
|
+
// Кусок целиком (вставка/drop), а не посимвольный ввод: пути существующих
|
|
381
|
+
// файлов превращаем в токены [image N]/[file N] прямо в строке.
|
|
382
|
+
if (clean.length > 1) {
|
|
383
|
+
const { files, rest } = extractDroppedFiles(clean);
|
|
384
|
+
if (files.length > 0) {
|
|
385
|
+
for (const f of files)
|
|
386
|
+
insertTokenForFile(f);
|
|
387
|
+
if (rest)
|
|
388
|
+
insert(rest);
|
|
389
|
+
else {
|
|
390
|
+
histIdx = history.length;
|
|
391
|
+
refreshMenu();
|
|
392
|
+
render();
|
|
393
|
+
}
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
insert(clean);
|
|
398
|
+
}
|
|
399
|
+
return new Promise(resolve => {
|
|
400
|
+
finish = resolve;
|
|
401
|
+
refreshMenu();
|
|
402
|
+
render();
|
|
403
|
+
stdin.on('data', onData);
|
|
404
|
+
stdin.setRawMode(true);
|
|
405
|
+
stdin.resume();
|
|
406
|
+
stdout.write('\x1b[?25l');
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
export const CHAT_COMMANDS = [
|
|
410
|
+
{ value: '/usage', desc: { ru: 'общий лимит', en: 'shared limit' }, takesArg: false },
|
|
411
|
+
{ value: '/model', desc: { ru: 'выбор модели', en: 'choose a model' }, takesArg: true },
|
|
412
|
+
{ value: '/permissions', desc: { ru: 'разрешения агента', en: 'agent permissions' }, takesArg: false },
|
|
413
|
+
{ value: '/clear', desc: { ru: 'очистить историю', en: 'clear history' }, takesArg: false },
|
|
414
|
+
{ value: '/help', desc: { ru: 'команды', en: 'commands' }, takesArg: false },
|
|
415
|
+
{ value: '/skills', desc: { ru: 'навыки агента', en: 'agent skills' }, takesArg: false },
|
|
416
|
+
{ value: '/logout', desc: { ru: 'выйти из аккаунта', en: 'sign out' }, takesArg: false },
|
|
417
|
+
{ value: '/exit', desc: { ru: 'выйти', en: 'exit' }, takesArg: false },
|
|
418
|
+
];
|
|
419
|
+
export const CHAT_NO_ARG_COMMANDS = CHAT_COMMANDS.filter(c => !c.takesArg).map(c => c.value);
|
|
420
|
+
/** Живые подсказки строки ввода: команды, модели, скиллы. */
|
|
421
|
+
export function completeChatLine(buffer) {
|
|
422
|
+
// /model открывает отдельный пикер после Enter. Не показываем здесь модели:
|
|
423
|
+
// иначе Enter в автодополнении подставляет первый вариант вместо команды.
|
|
424
|
+
if (buffer === '/model' || buffer.startsWith('/model '))
|
|
425
|
+
return [];
|
|
426
|
+
if (/^\/[^\s]*$/.test(buffer)) {
|
|
427
|
+
const frag = buffer.slice(1).toLowerCase();
|
|
428
|
+
const commands = CHAT_COMMANDS.filter(c => c.value.startsWith(buffer))
|
|
429
|
+
.map(c => ({ value: c.value, desc: tr(c.desc.ru, c.desc.en) }));
|
|
430
|
+
// Скиллы как slash-команды: /имя (дешёвый листинг имён, без парсинга).
|
|
431
|
+
let skills = [];
|
|
432
|
+
try {
|
|
433
|
+
skills = listSkillNames(process.cwd())
|
|
434
|
+
.filter(n => n.startsWith(frag) && !commands.some(c => c.value === `/${n}`))
|
|
435
|
+
.slice(0, 6)
|
|
436
|
+
.map(n => ({ value: `/${n} `, desc: tr('навык', 'skill') }));
|
|
437
|
+
}
|
|
438
|
+
catch { /* подсказки необязательны */ }
|
|
439
|
+
return [...commands, ...skills];
|
|
440
|
+
}
|
|
441
|
+
return [];
|
|
442
|
+
}
|
|
443
|
+
const CODE_PATH_RE = /(^|\s)(\.{0,2}\/[\w.-]+)+\/?/;
|
|
444
|
+
const CODE_EXT_RE = /\.(ts|tsx|js|jsx|mjs|cjs|py|json|md|markdown|txt|csv|ya?ml|toml|cfg|ini|css|scss|html|sql|sh|ps1|bat|cmd|java|go|rs|c|cpp|h|cs|php|rb|swift|kt)\b/;
|
|
445
|
+
const CODE_WORD_RE = /(прочитай|покажи|посмотри|открой|найди|создай|напиши|поправь|исправь|почини|запусти|выполни|проверь|переименуй|удали|добавь|файл|файлы|файлов|папк|каталог|код|скрипт|функци|класс|баг|ошибк|тест|тесты|репозиторий|проект|директори|программ|read|glob|grep|write|edit|file|files|folder|directory|code|script|function|class|bug|error|test|repo|project)/;
|
|
446
|
+
/**
|
|
447
|
+
* Похоже ли сообщение на задачу с файлами/кодом (→ агентный цикл),
|
|
448
|
+
* или это обычный чат. Эвристика + явный префикс «!».
|
|
449
|
+
*/
|
|
450
|
+
export function looksLikeCodeTask(text) {
|
|
451
|
+
const t = text.trim().toLowerCase();
|
|
452
|
+
if (!t)
|
|
453
|
+
return false;
|
|
454
|
+
if (t.startsWith('!'))
|
|
455
|
+
return true;
|
|
456
|
+
if (t.startsWith('/'))
|
|
457
|
+
return false;
|
|
458
|
+
if (CODE_PATH_RE.test(t))
|
|
459
|
+
return true;
|
|
460
|
+
if (CODE_EXT_RE.test(t))
|
|
461
|
+
return true;
|
|
462
|
+
return CODE_WORD_RE.test(t);
|
|
463
|
+
}
|
|
464
|
+
/** Разбить вставку на токены с учётом кавычек ("a b.png" c.png). */
|
|
465
|
+
function splitQuoted(text) {
|
|
466
|
+
const parts = [];
|
|
467
|
+
let cur = '';
|
|
468
|
+
let quote = null;
|
|
469
|
+
for (const ch of text) {
|
|
470
|
+
if (quote) {
|
|
471
|
+
cur += ch;
|
|
472
|
+
if (ch === quote)
|
|
473
|
+
quote = null;
|
|
474
|
+
}
|
|
475
|
+
else if (ch === '"' || ch === "'") {
|
|
476
|
+
quote = ch;
|
|
477
|
+
cur += ch;
|
|
478
|
+
}
|
|
479
|
+
else if (ch === ' ' || ch === '\t') {
|
|
480
|
+
if (cur) {
|
|
481
|
+
parts.push(cur);
|
|
482
|
+
cur = '';
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
cur += ch;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (cur)
|
|
490
|
+
parts.push(cur);
|
|
491
|
+
return parts;
|
|
492
|
+
}
|
|
493
|
+
function unquote(token) {
|
|
494
|
+
const t = token.trim();
|
|
495
|
+
if (t.length >= 2 && ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")))) {
|
|
496
|
+
return t.slice(1, -1);
|
|
497
|
+
}
|
|
498
|
+
return t;
|
|
499
|
+
}
|
|
500
|
+
function looksLikePath(token) {
|
|
501
|
+
return token.includes('/') || token.includes('\\') || /\.[A-Za-z0-9]{1,6}$/.test(token);
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Вытащить из вставленного куска пути существующих файлов (drag-and-drop
|
|
505
|
+
* из проводника терминал вставляет как текст). Возвращает файлы и остаток.
|
|
506
|
+
* Одиночные символы (обычный ввод) не трогаем — только вставки целиком.
|
|
507
|
+
*/
|
|
508
|
+
export function extractDroppedFiles(chunk) {
|
|
509
|
+
const files = [];
|
|
510
|
+
const rest = [];
|
|
511
|
+
for (const part of splitQuoted(chunk)) {
|
|
512
|
+
const candidate = unquote(part);
|
|
513
|
+
if (candidate && looksLikePath(candidate)) {
|
|
514
|
+
try {
|
|
515
|
+
const abs = path.resolve(candidate);
|
|
516
|
+
if (fs.statSync(abs).isFile()) {
|
|
517
|
+
files.push(abs);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
catch { /* не файл — остаётся текстом */ }
|
|
522
|
+
}
|
|
523
|
+
rest.push(part);
|
|
524
|
+
}
|
|
525
|
+
return { files, rest: rest.join(' ') };
|
|
526
|
+
}
|
|
527
|
+
//# sourceMappingURL=cliPrompt.js.map
|