zames_pro 2.5.0 → 2.6.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 +32 -0
- package/dist/agent-loop.js +206 -7
- package/dist/browser.js +1 -1
- package/dist/config-menu.js +231 -0
- package/dist/config.js +143 -0
- package/dist/i18n.js +276 -0
- package/dist/index.js +415 -189
- package/dist/input.js +72 -7
- package/dist/spinner.js +11 -37
- package/dist/system-prompt.js +8 -2
- package/dist/xml-toolcall.js +37 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,6 +60,38 @@ zames --help
|
|
|
60
60
|
Global config: `~/.zames/config.json`
|
|
61
61
|
Local (per project): `.zamesrc.json`
|
|
62
62
|
|
|
63
|
+
You can view and change settings without leaving the agent — use the
|
|
64
|
+
`/config` command. Run it without arguments to open an interactive menu
|
|
65
|
+
(↑/↓ to move, Enter to change, `d` to reset, `q` to quit). Booleans and enums
|
|
66
|
+
toggle in place; numbers and strings open an input prompt.
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
/config interactive settings menu
|
|
70
|
+
/config list print all editable settings
|
|
71
|
+
/config get <path> show a setting
|
|
72
|
+
/config set <path> <value> change a setting
|
|
73
|
+
/config reset <path> reset a setting to its default
|
|
74
|
+
/config path show config file paths
|
|
75
|
+
/config lang <ru|en> switch interface and agent language
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Examples:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
/config set maxIterations 20
|
|
82
|
+
/config set confirmation.bash false
|
|
83
|
+
/config lang en
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Changes are written to the project `.zamesrc.json` and applied right away
|
|
87
|
+
(where possible without a restart).
|
|
88
|
+
|
|
89
|
+
### Language
|
|
90
|
+
|
|
91
|
+
`/config lang ru` or `/config lang en` switches both the interface language
|
|
92
|
+
(help, messages, spinner) and the language the agent answers you in. The
|
|
93
|
+
locale lives in `ui.locale` in the config file.
|
|
94
|
+
|
|
63
95
|
Agent data is stored in `~/.zames`: browser profile, logs, undo history, self-review snapshots.
|
|
64
96
|
|
|
65
97
|
## License
|
package/dist/agent-loop.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildSystemPrompt } from './system-prompt.js';
|
|
2
2
|
import { getGitContext, formatGitContext } from './gitTools.js';
|
|
3
3
|
import { parseXmlToolCalls } from './xml-toolcall.js';
|
|
4
|
-
export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 40, freshChat = false, sendSystemPrompt = false, transcript = null, onThinking = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, debugLog = false, }) {
|
|
4
|
+
export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 40, freshChat = false, sendSystemPrompt = false, transcript = null, onThinking = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, debugLog = false, locale = 'ru', }) {
|
|
5
5
|
if (freshChat) {
|
|
6
6
|
await browser.newChat();
|
|
7
7
|
transcript?.log('new_chat');
|
|
@@ -35,6 +35,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
35
35
|
workdir,
|
|
36
36
|
tools,
|
|
37
37
|
gitContext: gitText,
|
|
38
|
+
locale,
|
|
38
39
|
});
|
|
39
40
|
transcript?.log('system_prompt', {
|
|
40
41
|
length: systemPrompt.length,
|
|
@@ -51,6 +52,13 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
51
52
|
const MAX_MALFORMED_RETRIES = 3;
|
|
52
53
|
let stallRetries = 0;
|
|
53
54
|
const MAX_STALL_RETRIES = 5;
|
|
55
|
+
// Защита от «агент встал»: DeepSeek иногда присылает финальный текст,
|
|
56
|
+
// который лишь ОПИСЫВАЕТ следующий вызов инструмента (или рвёт ответ на
|
|
57
|
+
// полуслове), и агент молча завершает задачу, хотя работа не сделана.
|
|
58
|
+
// Если финальный ответ похож на «сейчас вызову …» — переспрашиваем, а не
|
|
59
|
+
// останавливаемся. Счётчик общий, чтобы не зациклиться на болтливой модели.
|
|
60
|
+
let looksDoneRetries = 0;
|
|
61
|
+
const MAX_LOOKSDONE_RETRIES = 3;
|
|
54
62
|
for (let i = 0; i < maxIterations; i++) {
|
|
55
63
|
onThinking();
|
|
56
64
|
// Первое сообщение (task) — пользовательский ввод: без паузы.
|
|
@@ -83,10 +91,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
83
91
|
// только явный JSON, но и XML/DSML-формы, «грязные» варианты и
|
|
84
92
|
// незакрытые фрагменты: если такой ответ молча принять за финальный,
|
|
85
93
|
// агент встанет, хотя модель пыталась позвать инструмент.
|
|
86
|
-
const looksLikeToolCall =
|
|
87
|
-
/\{\s*"?(tool|name|args)"?\s*:/.test(rawResponse) ||
|
|
88
|
-
/<\s*\|?\s*(DSML|invoke|parameter)/i.test(rawResponse) ||
|
|
89
|
-
/^\s*\[?\s*\{[^}]*$/.test(rawResponse.trim());
|
|
94
|
+
const looksLikeToolCall = responseLooksLikeToolCall(rawResponse);
|
|
90
95
|
if (looksLikeToolCall && malformedRetries < MAX_MALFORMED_RETRIES) {
|
|
91
96
|
malformedRetries++;
|
|
92
97
|
transcript?.log('malformed_toolcall', {
|
|
@@ -108,10 +113,16 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
108
113
|
continue;
|
|
109
114
|
}
|
|
110
115
|
const trimmed = (rawResponse || '').trim();
|
|
116
|
+
// Служебный ответ — это КОРОТКАЯ заглушка DeepSeek («Reading…») или
|
|
117
|
+
// короткое уведомление о лимите. Слова про rate limit в ДЛИННОМ
|
|
118
|
+
// ответе — это, как правило, сам агент цитирует код/логи (в транскрипте
|
|
119
|
+
// был ровно такой случай: ответ на 1365 символов про ask() и лимиты),
|
|
120
|
+
// и принимать его за «служебный» нельзя, иначе агент зря переспрашивает.
|
|
111
121
|
const looksService = !trimmed ||
|
|
112
122
|
trimmed.length < 2 ||
|
|
113
123
|
/^(reading|thinking|searching|analyzing|generating|stop|остановить|читаю|думаю|поиск|анализ)[\s.…]*$/i.test(trimmed) ||
|
|
114
|
-
|
|
124
|
+
(trimmed.length <= 200 &&
|
|
125
|
+
/(messages? too frequent|too many requests|rate limit|слишком часто|try again later)/i.test(trimmed));
|
|
115
126
|
if (looksService && stallRetries < MAX_STALL_RETRIES) {
|
|
116
127
|
stallRetries++;
|
|
117
128
|
transcript?.log('stall_retry', {
|
|
@@ -131,6 +142,32 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
131
142
|
'Если задача выполнена — вызови инструмент respond с итоговым сообщением.';
|
|
132
143
|
continue;
|
|
133
144
|
}
|
|
145
|
+
// Ответ похож на «сейчас вызову инструмент», но вызова в нём нет.
|
|
146
|
+
// DeepSeek иногда так обрывает ход: пишет «Now update README…» или
|
|
147
|
+
// «Let me run the tests…» и замолкает. Если принять это за финал,
|
|
148
|
+
// агент встаёт, не сделав работу. Просим продолжить и на этот раз
|
|
149
|
+
// обязательно вызвать инструмент (или respond, если правда готово).
|
|
150
|
+
if (looksLikeUnfinishedWork(trimmed) && looksDoneRetries < MAX_LOOKSDONE_RETRIES) {
|
|
151
|
+
looksDoneRetries++;
|
|
152
|
+
transcript?.log('unfinished_retry', {
|
|
153
|
+
attempt: looksDoneRetries,
|
|
154
|
+
response: rawResponse,
|
|
155
|
+
});
|
|
156
|
+
if (debugLog) {
|
|
157
|
+
console.error('внимание: ответ похож на незавершённую работу, прошу продолжить (попытка ' +
|
|
158
|
+
looksDoneRetries +
|
|
159
|
+
'/' +
|
|
160
|
+
MAX_LOOKSDONE_RETRIES +
|
|
161
|
+
')');
|
|
162
|
+
}
|
|
163
|
+
message =
|
|
164
|
+
'Похоже, ты собирался вызвать инструмент, но не вызвал. ' +
|
|
165
|
+
'Если задача ещё не выполнена — ответь РОВНО одним JSON-объектом ' +
|
|
166
|
+
'вызова инструмента, без текста до и после. ' +
|
|
167
|
+
'Если задача действительно выполнена — вызови respond с итоговым ' +
|
|
168
|
+
'сообщением оператору.';
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
134
171
|
onAssistantMessage(rawResponse);
|
|
135
172
|
transcript?.log('assistant_final', { message: rawResponse });
|
|
136
173
|
return rawResponse;
|
|
@@ -206,6 +243,52 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
206
243
|
}
|
|
207
244
|
return 'Достигнут лимит итераций.';
|
|
208
245
|
}
|
|
246
|
+
// Ответ похож на вызов инструмента, но parseToolCall() его не распознал.
|
|
247
|
+
// Используется как страховка от «агент вызвал инструмент и остановился»:
|
|
248
|
+
// в этом случае runAgentLoop просит модель переотправить вызов, а не
|
|
249
|
+
// завершает задачу. Ловим и явные форматы, и «поломанные» головы вызова
|
|
250
|
+
// (`<|tool": ...`, `**tool**:`, `tool": ...`), и обрезанные вызовы.
|
|
251
|
+
export function responseLooksLikeToolCall(rawResponse) {
|
|
252
|
+
const raw = rawResponse || '';
|
|
253
|
+
return (
|
|
254
|
+
// Явные маркеры форматов tool-call: JSON-ключ "tool", XML/DSML-теги,
|
|
255
|
+
// function_call и т.п.
|
|
256
|
+
/("tool"\s*:|\btool_calls?\b|\binvoke\b|\bparameter\b|DSML|function_call)/i.test(raw) ||
|
|
257
|
+
// «tool» без открывающей кавычки/скобки, с мусорным префиксом
|
|
258
|
+
// (`<|tool":`, `**tool**:`, `- tool:`): ключ вызова, а не проза.
|
|
259
|
+
/(^|[^A-Za-z0-9_])(?:\*\*)?tool(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/.test(raw) ||
|
|
260
|
+
// Ключи в одинарных кавычках или без кавычек: {'tool': 'Read', ...}.
|
|
261
|
+
/[\{\[]\s*['"]?(tool|name|args)['"]?\s*:/.test(raw) ||
|
|
262
|
+
// Обрезанный вызов: начинается как JSON-объект, но не закрыт, и в нём
|
|
263
|
+
// есть ключ аргумента (args/command/path/...). Требуем именно открывающую
|
|
264
|
+
// скобку в начале (после пробелов/префикса), чтобы не ловить обычную
|
|
265
|
+
// прозу с двоеточиями вроде «path: ...».
|
|
266
|
+
/^\s*[\[\{]/.test(raw) &&
|
|
267
|
+
/["']?(?:tool|args|command|path|old_string|content|content_base64)["']?\s*:/.test(raw) ||
|
|
268
|
+
/<\s*\|?\s*(DSML|invoke|parameter)/i.test(raw) ||
|
|
269
|
+
/^\s*\[?\s*\{[^}]*$/.test(raw.trim()));
|
|
270
|
+
}
|
|
271
|
+
// Текст, который обещает вызов инструмента в будущем времени, но самого
|
|
272
|
+
// вызова не содержит. DeepSeek регулярно так «зависает»: пишет
|
|
273
|
+
// «Now update README to mention …», «Let me run the tests», «Сейчас проверю»
|
|
274
|
+
// и останавливается. Такие ответы нельзя принимать за финальные — иначе
|
|
275
|
+
// агент встаёт, не выполнив работу. Держим эвристику узкой (будущее время /
|
|
276
|
+
// намерение), чтобы не ловить обычные отчёты о выполненной работе.
|
|
277
|
+
function looksLikeUnfinishedWork(text) {
|
|
278
|
+
const t = (text || '').trim();
|
|
279
|
+
if (!t)
|
|
280
|
+
return false;
|
|
281
|
+
// Длинные ответы (отчёты) не трогаем — там может быть что угодно.
|
|
282
|
+
if (t.length > 600)
|
|
283
|
+
return false;
|
|
284
|
+
// Уже есть финальный маркер — считаем ответ завершённым.
|
|
285
|
+
if (/\b(done|finished|completed|готово|выполнено|завершено)\b/i.test(t)) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
const en = /\b(now|next|then|let me|let's|i will|i'll|i am going to|i'm going to|going to|about to|will now|time to)\b[^.!?\n]{0,120}\b(update|write|edit|read|run|check|add|fix|create|remove|delete|apply|test|commit|push|install|open|search|look|verify|change|modify|implement|review)\b/i;
|
|
289
|
+
const ru = /(^|[^а-яё])(проверю|обновлю|исправлю|добавлю|запущу|выполню|посмотрю|прочитаю|изменю|попробую|сделаю)([^а-яё]|$)/i;
|
|
290
|
+
return en.test(t) || ru.test(t);
|
|
291
|
+
}
|
|
209
292
|
// ============ JSON parsing ============
|
|
210
293
|
function repairRawControlChars(str) {
|
|
211
294
|
let out = '';
|
|
@@ -639,6 +722,81 @@ function findMatching(text, openIdx, openCh, closeCh) {
|
|
|
639
722
|
}
|
|
640
723
|
return -1;
|
|
641
724
|
}
|
|
725
|
+
// Модель иногда отдаёт вызов инструмента с ключами/строками в одинарных
|
|
726
|
+
// кавычках («{'tool': 'Read', 'args': {...}}») или с ключами без кавычек
|
|
727
|
+
// («{tool: "Read", args: {...}}»). Это не валидный JSON, и без нормализации
|
|
728
|
+
// такой ответ молча принимается за финальный — агент встаёт, не вызвав
|
|
729
|
+
// инструмент. Приводим его к двойным кавычкам.
|
|
730
|
+
function normalizePseudoJson(str) {
|
|
731
|
+
// Ключи без кавычек: {tool: ...} или , args: ... → "tool": / "args":
|
|
732
|
+
let out = str.replace(/([\{\[]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:/g, '$1"$2":');
|
|
733
|
+
out = out.replace(/,\s*([A-Za-z_][A-Za-z0-9_]*)\s*:/g, ', "$1":');
|
|
734
|
+
// Одинарные кавычки → двойные. Не трогаем содержимое уже двойных строк,
|
|
735
|
+
// идущее подряд, и экранируем случайные двойные внутри одинарных.
|
|
736
|
+
let res = '';
|
|
737
|
+
let inDouble = false;
|
|
738
|
+
let inSingle = false;
|
|
739
|
+
for (let i = 0; i < out.length; i++) {
|
|
740
|
+
const c = out[i];
|
|
741
|
+
if (c === '\\' && (inDouble || inSingle)) {
|
|
742
|
+
res += c;
|
|
743
|
+
if (i + 1 < out.length) {
|
|
744
|
+
res += out[i + 1];
|
|
745
|
+
i++;
|
|
746
|
+
}
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
if (c === '"' && !inSingle) {
|
|
750
|
+
inDouble = !inDouble;
|
|
751
|
+
res += c;
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
if (c === "'" && !inDouble) {
|
|
755
|
+
if (!inSingle) {
|
|
756
|
+
inSingle = true;
|
|
757
|
+
res += '"';
|
|
758
|
+
}
|
|
759
|
+
else {
|
|
760
|
+
inSingle = false;
|
|
761
|
+
res += '"';
|
|
762
|
+
}
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
if (inSingle && c === '"') {
|
|
766
|
+
res += '\\"';
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
res += c;
|
|
770
|
+
}
|
|
771
|
+
return res;
|
|
772
|
+
}
|
|
773
|
+
// Модель иногда портит начало вызова: `<|tool": "Bash", "args": {...}`,
|
|
774
|
+
// `tool": "Read", ...`, `**tool**: ...`, `- tool: ...`. В таких ответах
|
|
775
|
+
// нет открывающей `{`, а ключ `tool` лишился первой кавычки. Если такой
|
|
776
|
+
// ответ принять за финальный, агент молча встанет (частая «остановка»).
|
|
777
|
+
// Восстанавливаем: срезаем мусорный префикс до слова tool, добавляем `{` и
|
|
778
|
+
// доводим кавычки ключа до парных.
|
|
779
|
+
function repairToolCallPreamble(text) {
|
|
780
|
+
const t = (text || '').trim();
|
|
781
|
+
const m = t.match(/(?:^|[^A-Za-z0-9_])(?:\*\*)?(tool)(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/);
|
|
782
|
+
if (!m || m.index === undefined)
|
|
783
|
+
return null;
|
|
784
|
+
// Начало ищем с первой кавычки/скобки вокруг ключа, иначе — с слова tool.
|
|
785
|
+
let start = m.index;
|
|
786
|
+
const brace = t.indexOf('{', Math.max(0, start - 1));
|
|
787
|
+
if (brace !== -1 && brace < start)
|
|
788
|
+
start = brace;
|
|
789
|
+
let frag = t.slice(start);
|
|
790
|
+
// Если фрагмент не начинается с `{` — добавляем его.
|
|
791
|
+
if (!frag.startsWith('{')) {
|
|
792
|
+
// Ключ мог потерять открывающую кавычку: tool": → "tool":.
|
|
793
|
+
// Срезаем ведущий мусор до слова tool и нормализуем кавычки ключа.
|
|
794
|
+
frag = frag.replace(/^[^A-Za-z0-9_]*/, '');
|
|
795
|
+
frag = frag.replace(/^(?:\*\*)?(["'`\u2018\u2019\u201c\u201d]*)(tool)(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/, '"$2":');
|
|
796
|
+
frag = '{' + frag;
|
|
797
|
+
}
|
|
798
|
+
return frag;
|
|
799
|
+
}
|
|
642
800
|
export function parseToolCall(text) {
|
|
643
801
|
if (!text || typeof text !== 'string')
|
|
644
802
|
return null;
|
|
@@ -671,8 +829,24 @@ export function parseToolCall(text) {
|
|
|
671
829
|
if (ctrlArr)
|
|
672
830
|
return ctrlArr;
|
|
673
831
|
}
|
|
832
|
+
// Псевдо-JSON (одинарные кавычки / ключи без кавычек) — нормализуем и
|
|
833
|
+
// пробуем распарсить как обычный вызов, прежде чем идти в permissive.
|
|
834
|
+
if (/['"]?tool['"]?\s*:/.test(cleaned)) {
|
|
835
|
+
const norm = normalizePseudoJson(cleaned);
|
|
836
|
+
if (norm !== cleaned) {
|
|
837
|
+
for (const raw of extractJsonObjects(norm)) {
|
|
838
|
+
const a = tryParseArray(raw);
|
|
839
|
+
if (a)
|
|
840
|
+
return a;
|
|
841
|
+
const o = tryParse(raw);
|
|
842
|
+
if (o)
|
|
843
|
+
return o;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
674
847
|
const permissive = parseToolCallPermissive(cleaned) ||
|
|
675
|
-
parseToolCallPermissive(repairRawControlChars(cleaned))
|
|
848
|
+
parseToolCallPermissive(repairRawControlChars(cleaned)) ||
|
|
849
|
+
parseToolCallPermissive(normalizePseudoJson(cleaned));
|
|
676
850
|
if (permissive)
|
|
677
851
|
return { ...permissive, _permissive: true };
|
|
678
852
|
const toolIdx = cleaned.search(/["']?tool["']?\s:/);
|
|
@@ -687,6 +861,31 @@ export function parseToolCall(text) {
|
|
|
687
861
|
const xmlCalls = parseXmlToolCalls(cleaned);
|
|
688
862
|
if (xmlCalls)
|
|
689
863
|
return Array.isArray(xmlCalls) ? xmlCalls : [xmlCalls];
|
|
864
|
+
// Последняя попытка: «починить» испорченную голову вызова (`<|tool": ...`,
|
|
865
|
+
// `tool": ...`, `**tool**: ...`, `- tool: ...`). Делаем это ТОЛЬКО как
|
|
866
|
+
// fallback, после обычного разбора — иначе легко испортить валидный JSON
|
|
867
|
+
// (например, массив вызовов начинается с `[`, внутри которого `{"tool":`).
|
|
868
|
+
const preamble = repairToolCallPreamble(cleaned);
|
|
869
|
+
if (preamble && preamble !== cleaned) {
|
|
870
|
+
const reps = [
|
|
871
|
+
preamble,
|
|
872
|
+
repairRawControlChars(preamble),
|
|
873
|
+
normalizePseudoJson(preamble),
|
|
874
|
+
];
|
|
875
|
+
for (const rep of reps) {
|
|
876
|
+
for (const raw of extractJsonObjects(rep)) {
|
|
877
|
+
const a = tryParseArray(raw);
|
|
878
|
+
if (a)
|
|
879
|
+
return a;
|
|
880
|
+
const o = tryParse(raw);
|
|
881
|
+
if (o)
|
|
882
|
+
return o;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
const perm = parseToolCallPermissive(preamble);
|
|
886
|
+
if (perm)
|
|
887
|
+
return { ...perm, _permissive: true };
|
|
888
|
+
}
|
|
690
889
|
return null;
|
|
691
890
|
}
|
|
692
891
|
function extractPreToolText(text) {
|
package/dist/browser.js
CHANGED
|
@@ -506,7 +506,7 @@ export class DeepSeekBrowser {
|
|
|
506
506
|
const gap = this.minSendIntervalMs - (Date.now() - this._lastSentAt);
|
|
507
507
|
if (gap <= 0)
|
|
508
508
|
return;
|
|
509
|
-
console.error(theme.warn(`⏳ пауза ${Math.ceil(gap / 1000)}с перед
|
|
509
|
+
console.error(theme.warn(`⏳ пауза ${Math.ceil(gap / 1000)}с перед отправкой`));
|
|
510
510
|
await this.page.waitForTimeout(gap);
|
|
511
511
|
}
|
|
512
512
|
async _askOnce(prompt, { timeout, agent }) {
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// Интерактивное меню настроек для команды /config.
|
|
2
|
+
//
|
|
3
|
+
// Меню рисует список параметров, сгруппированных по секциям, и позволяет
|
|
4
|
+
// менять их стрелками: boolean/enum переключаются на месте, number/string
|
|
5
|
+
// запрашивают ввод. Все строки берутся из i18n (cfg.*), поэтому меню
|
|
6
|
+
// полностью локализовано.
|
|
7
|
+
//
|
|
8
|
+
// Модуль не знает про config.ts напрямую (кроме типов схемы): чтение и
|
|
9
|
+
// запись значений делает вызывающий через колбэки. Так меню легко тестировать
|
|
10
|
+
// и переиспользовать.
|
|
11
|
+
import { theme } from './theme.js';
|
|
12
|
+
const ESC = String.fromCharCode(27);
|
|
13
|
+
// Убираем ANSI-последовательности, чтобы посчитать видимую длину строки.
|
|
14
|
+
function stripAnsi(s) {
|
|
15
|
+
return s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
|
|
16
|
+
}
|
|
17
|
+
function displayValue(field, value, t) {
|
|
18
|
+
if (value === undefined)
|
|
19
|
+
return t('cfg.menu.default');
|
|
20
|
+
if (typeof value === 'boolean')
|
|
21
|
+
return value ? t('common.on') : t('common.off');
|
|
22
|
+
return String(value);
|
|
23
|
+
}
|
|
24
|
+
function groupLabel(field, t) {
|
|
25
|
+
return t(field.groupKey);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Показать интерактивное меню. Возвращает промис, который резолвится, когда
|
|
29
|
+
* пользователь вышел (q/Esc/Ctrl+C). В не-TTY бросает ошибку — вызывающий
|
|
30
|
+
* должен показать текстовый список.
|
|
31
|
+
*/
|
|
32
|
+
export function runConfigMenu(opts) {
|
|
33
|
+
const input = opts.input || process.stdin;
|
|
34
|
+
const output = opts.output || process.stdout;
|
|
35
|
+
const { fields, t, get, set, reset } = opts;
|
|
36
|
+
if (!input.isTTY || !input.setRawMode) {
|
|
37
|
+
return Promise.reject(new Error(t('cfg.menu.notty')));
|
|
38
|
+
}
|
|
39
|
+
let cursor = 0;
|
|
40
|
+
let editing = false;
|
|
41
|
+
let editBuf = '';
|
|
42
|
+
let message = null;
|
|
43
|
+
let exited = false;
|
|
44
|
+
let resolveDone = null;
|
|
45
|
+
const wasRaw = input.isRaw;
|
|
46
|
+
input.setRawMode(true);
|
|
47
|
+
input.resume();
|
|
48
|
+
output.write(ESC + '[?25l'); // прячем курсор на время отрисовки
|
|
49
|
+
// Отрисовка меню: строки идут сверху вниз, курсор возвращаем в начало
|
|
50
|
+
// блока через относительные перемещения, чтобы не плодить пустые строки.
|
|
51
|
+
let lastRows = 0;
|
|
52
|
+
const render = () => {
|
|
53
|
+
const lines = [];
|
|
54
|
+
lines.push(theme.bold(t('cfg.menu.title')));
|
|
55
|
+
let lastGroup = '';
|
|
56
|
+
for (let i = 0; i < fields.length; i++) {
|
|
57
|
+
const f = fields[i];
|
|
58
|
+
const g = groupLabel(f, t);
|
|
59
|
+
if (g !== lastGroup) {
|
|
60
|
+
lines.push(theme.system(' ' + g));
|
|
61
|
+
lastGroup = g;
|
|
62
|
+
}
|
|
63
|
+
const selected = i === cursor;
|
|
64
|
+
const marker = selected ? theme.prompt('\u276f ') : ' ';
|
|
65
|
+
const label = selected ? theme.prompt(f.labelKey ? t(f.labelKey) : f.path) : t(f.labelKey || f.path);
|
|
66
|
+
const val = displayValue(f, get(f.path), t);
|
|
67
|
+
const valText = selected ? theme.assistant(val) : theme.dim(val);
|
|
68
|
+
const pathText = selected ? theme.dim(' ' + f.path) : '';
|
|
69
|
+
lines.push(marker + label + theme.dim(' = ') + valText + pathText);
|
|
70
|
+
}
|
|
71
|
+
if (editing) {
|
|
72
|
+
lines.push('');
|
|
73
|
+
lines.push(theme.user(t('cfg.menu.edit_hint')));
|
|
74
|
+
lines.push(theme.prompt('\u276f ') + editBuf);
|
|
75
|
+
}
|
|
76
|
+
else if (message) {
|
|
77
|
+
lines.push('');
|
|
78
|
+
lines.push(theme.assistant(message));
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
lines.push('');
|
|
82
|
+
lines.push(theme.dim(t('cfg.menu.hint')));
|
|
83
|
+
}
|
|
84
|
+
// Стираем прошлый блок и печатаем новый. Считаем ВИЗУАЛЬНЫЕ строки
|
|
85
|
+
// (с учётом переноса по ширине терминала), иначе на узких терминалах
|
|
86
|
+
// остаётся «мусор» из нестёртых хвостов.
|
|
87
|
+
const cols = output.columns || 80;
|
|
88
|
+
const rowsOf = (s) => {
|
|
89
|
+
const len = stripAnsi(s).length;
|
|
90
|
+
return Math.max(1, Math.ceil(len / Math.max(1, cols)));
|
|
91
|
+
};
|
|
92
|
+
if (lastRows > 0) {
|
|
93
|
+
output.write(ESC + '[' + lastRows + 'A');
|
|
94
|
+
for (let i = 0; i < lastRows; i++) {
|
|
95
|
+
output.write(ESC + '[2K' + ESC + '[1B');
|
|
96
|
+
}
|
|
97
|
+
output.write(ESC + '[' + lastRows + 'A');
|
|
98
|
+
}
|
|
99
|
+
output.write(lines.join('\n') + '\n');
|
|
100
|
+
lastRows = lines.reduce((n, l) => n + rowsOf(l), 0);
|
|
101
|
+
};
|
|
102
|
+
const cleanup = () => {
|
|
103
|
+
if (exited)
|
|
104
|
+
return;
|
|
105
|
+
exited = true;
|
|
106
|
+
input.removeListener('data', onData);
|
|
107
|
+
if (input.setRawMode)
|
|
108
|
+
input.setRawMode(wasRaw || false);
|
|
109
|
+
output.write(ESC + '[?25h'); // возвращаем курсор
|
|
110
|
+
if (resolveDone)
|
|
111
|
+
resolveDone();
|
|
112
|
+
};
|
|
113
|
+
const commitEdit = () => {
|
|
114
|
+
const f = fields[cursor];
|
|
115
|
+
const raw = editBuf.trim();
|
|
116
|
+
editing = false;
|
|
117
|
+
editBuf = '';
|
|
118
|
+
if (!raw) {
|
|
119
|
+
message = null;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
set(f, raw);
|
|
124
|
+
message = t('cfg.menu.saved', {
|
|
125
|
+
v: f.path,
|
|
126
|
+
value: displayValue(f, get(f.path), t),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
message = String(e.message || e);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const toggle = () => {
|
|
134
|
+
const f = fields[cursor];
|
|
135
|
+
if (f.type === 'boolean') {
|
|
136
|
+
const cur = get(f.path);
|
|
137
|
+
set(f, cur === true ? 'false' : 'true');
|
|
138
|
+
message = null;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (f.type === 'enum' && f.values && f.values.length) {
|
|
142
|
+
const cur = String(get(f.path) ?? '');
|
|
143
|
+
const idx = f.values.indexOf(cur);
|
|
144
|
+
const next = f.values[(idx + 1) % f.values.length];
|
|
145
|
+
set(f, next);
|
|
146
|
+
message = null;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// number/string — запрашиваем ввод.
|
|
150
|
+
editing = true;
|
|
151
|
+
editBuf = get(f.path) === undefined ? '' : String(get(f.path));
|
|
152
|
+
};
|
|
153
|
+
const onData = (buf) => {
|
|
154
|
+
const s = buf.toString('utf-8');
|
|
155
|
+
if (editing) {
|
|
156
|
+
// Обрабатываем ввод посимвольно: терминал может прислать несколько
|
|
157
|
+
// байт сразу (вставка, авто-повтор), Enter — 0x0d/0x0a, backspace — 0x7f.
|
|
158
|
+
for (const ch of s) {
|
|
159
|
+
if (ch === '\r' || ch === '\n') {
|
|
160
|
+
commitEdit();
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
if (ch === ESC) {
|
|
164
|
+
editing = false;
|
|
165
|
+
editBuf = '';
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
if (ch === '\x03') {
|
|
169
|
+
cleanup();
|
|
170
|
+
output.write('\n');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (ch === '\x7f' || ch === '\b') {
|
|
174
|
+
editBuf = editBuf.slice(0, -1);
|
|
175
|
+
}
|
|
176
|
+
else if (ch >= ' ') {
|
|
177
|
+
editBuf += ch;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
render();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
// Терминал может прислать несколько клавиш одним пакетом (быстрый набор,
|
|
184
|
+
// авто-повтор, автоматизация). Разбираем буфер по токенам: сначала
|
|
185
|
+
// escape-последовательности (стрелки), затем одиночные символы.
|
|
186
|
+
let i = 0;
|
|
187
|
+
while (i < s.length) {
|
|
188
|
+
if (s.startsWith('\x1b[A', i)) {
|
|
189
|
+
cursor = (cursor - 1 + fields.length) % fields.length;
|
|
190
|
+
message = null;
|
|
191
|
+
i += 3;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (s.startsWith('\x1b[B', i)) {
|
|
195
|
+
cursor = (cursor + 1) % fields.length;
|
|
196
|
+
message = null;
|
|
197
|
+
i += 3;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const ch = s[i];
|
|
201
|
+
if (ch === 'q' || ch === 'Q' || ch === '\x03' || ch === ESC) {
|
|
202
|
+
cleanup();
|
|
203
|
+
output.write('\n');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (ch === 'k') {
|
|
207
|
+
cursor = (cursor - 1 + fields.length) % fields.length;
|
|
208
|
+
message = null;
|
|
209
|
+
}
|
|
210
|
+
else if (ch === 'j') {
|
|
211
|
+
cursor = (cursor + 1) % fields.length;
|
|
212
|
+
message = null;
|
|
213
|
+
}
|
|
214
|
+
else if (ch === '\r' || ch === '\n' || ch === ' ') {
|
|
215
|
+
toggle();
|
|
216
|
+
}
|
|
217
|
+
else if (ch === 'd' || ch === 'D') {
|
|
218
|
+
const f = fields[cursor];
|
|
219
|
+
reset(f);
|
|
220
|
+
message = t('cfg.reset', { v: f.path });
|
|
221
|
+
}
|
|
222
|
+
i++;
|
|
223
|
+
}
|
|
224
|
+
render();
|
|
225
|
+
};
|
|
226
|
+
return new Promise((resolve) => {
|
|
227
|
+
resolveDone = resolve;
|
|
228
|
+
input.on('data', onData);
|
|
229
|
+
render();
|
|
230
|
+
});
|
|
231
|
+
}
|