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 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
@@ -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 = /("tool"\s*:|\btool_calls?\b|\binvoke\b|\bparameter\b|DSML|function_call)/i.test(rawResponse) ||
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
- /(messages? too frequent|too many requests|rate limit|слишком часто|try again later)/i.test(trimmed);
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
+ }