zames_pro 2.2.1 → 2.4.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/dist/index.js CHANGED
@@ -181,6 +181,10 @@ ${theme.bold('Опции CLI:')}
181
181
 
182
182
  ${theme.bold('Пока агент работает:')}
183
183
  печать + Enter поставить сообщение в очередь (уйдёт после текущей задачи)
184
+ ↑ / ↓ история введённых сообщений
185
+ Ctrl+← / Ctrl+→ перемещение по словам
186
+ / + Tab подсказка и автодополнение slash-команд
187
+ Ctrl+J новая строка (Shift+Enter в терминалах с поддержкой)
184
188
  Esc, Ctrl+C прервать текущую генерацию
185
189
 
186
190
  ${theme.bold('Обычные команды:')}
@@ -226,6 +230,34 @@ ${theme.bold('Файлы:')}
226
230
  function dirLabel(p) {
227
231
  return path.basename(p) || p;
228
232
  }
233
+ // Список slash-команд для автодополнения при вводе «/» (Tab — дополнить).
234
+ const SLASH_COMMANDS = [
235
+ { name: '/help', description: 'справка по командам и опциям' },
236
+ { name: '/new', description: 'новый чат (сброс контекста)' },
237
+ { name: '/clear', description: 'то же, что /new' },
238
+ { name: '/sessions', description: 'список сохранённых сессий' },
239
+ { name: '/chats', description: 'последние чаты DeepSeek' },
240
+ { name: '/resume', description: 'открыть чат №n из /chats' },
241
+ { name: '/resume-id', description: 'восстановить сессию по полному id' },
242
+ { name: '/chat', description: 'показать текущий chat id' },
243
+ { name: '/cd', description: 'сменить рабочую директорию' },
244
+ { name: '/pwd', description: 'текущая рабочая директория' },
245
+ { name: '/status', description: 'состояние сессии' },
246
+ { name: '/reload', description: 'перечитать модули логики без перезапуска' },
247
+ { name: '/undo', description: 'откатить последнюю запись/правку' },
248
+ { name: '/undo-list', description: 'список того, что можно откатить' },
249
+ { name: '/transcript', description: 'путь к файлу транскрипта' },
250
+ { name: '/config', description: 'показать текущий конфиг' },
251
+ { name: '/debug-dom', description: 'сохранить HTML страницы (отладка)' },
252
+ { name: '/self-review', description: 'снапшот src/ и запуск ревью' },
253
+ { name: '/self-fix', description: 'продолжить в существующем снапшоте' },
254
+ { name: '/self-done', description: 'выйти из режима ревью' },
255
+ { name: '/self-list', description: 'список снапшотов' },
256
+ { name: '/self-diff', description: 'различия текущего src/ и снапшота' },
257
+ { name: '/self-apply', description: 'применить снапшот к src/ (с бэкапом)' },
258
+ { name: '/exit', description: 'выход' },
259
+ { name: '/quit', description: 'выход' },
260
+ ];
229
261
  // Временные файлы агента (одноразовые скрипты и т.п.) складываем в
230
262
  // <проект>/tmp — эта папка в .gitignore и очищается при каждом запуске.
231
263
  const TMP_DIR = path.join(__dirname, '..', 'tmp');
@@ -839,7 +871,10 @@ async function main() {
839
871
  return theme.prompt('❯ ') + tail + theme.dim(' › ');
840
872
  };
841
873
  if (process.stdin.isTTY && process.stdout.isTTY) {
842
- const ed = new LineEditor({ prompt: buildPrompt() });
874
+ const ed = new LineEditor({
875
+ prompt: buildPrompt(),
876
+ commands: SLASH_COMMANDS,
877
+ });
843
878
  editor = ed;
844
879
  ed.onSubmit = (text) => {
845
880
  pendingQueue.push(text);
package/dist/input.js CHANGED
@@ -9,7 +9,14 @@ import { randomThinkingPhrase, stripEllipsis } from './spinner.js';
9
9
  // * перерисовка учитывает перенос по ширине терминала, поэтому нет
10
10
  // дублирования строк при многострочном вводе;
11
11
  // * многострочный ввод: Enter отправляет, Ctrl+J (и Shift+Enter в
12
- // терминалах с расширенным протоколом) вставляют перевод строки.
12
+ // терминалах с расширенным протоколом) вставляют перевод строки;
13
+ // * перенос длинных строк по словам (слово не рвётся посередине);
14
+ // * перемещение по словам: Ctrl+←/→ (и Alt+B/Alt+F), удаление слова
15
+ // Ctrl+W; Ctrl+K — удалить до конца строки;
16
+ // * история введённых сообщений: ↑/↓ на краях ввода (внутри многострочного
17
+ // ввода стрелки двигают по строкам, как в обычном терминале);
18
+ // * подсказки slash-команд: при вводе «/» под строкой показываются
19
+ // подходящие команды с описанием, Tab — дополнить.
13
20
  //
14
21
  // Все управляющие символы собираются из кодов, чтобы в файле не было
15
22
  // «сырых» ESC/CR/LF в строковых литералах (см. AGENTS.md).
@@ -22,7 +29,7 @@ const PASTE_END = ESC + '[201~';
22
29
  // Ширину выравниваем по максимуму (3), чтобы подсказка не смещалась.
23
30
  const DOTS = ['', '.', '..', '...'];
24
31
  const DOTS_PAD = ' ';
25
- const HINT = theme.dim(' · Enter — отправить, Ctrl+J — новая строка, Esc — стоп');
32
+ const HINT = theme.dim(' · Enter — отправить, ↑/↓ — история, Ctrl+←/→ — по словам, Esc — стоп');
26
33
  function safeJson(v) {
27
34
  try {
28
35
  return JSON.stringify(v);
@@ -64,14 +71,29 @@ export function layoutInput(promptStr, buf, cursor, cols) {
64
71
  const first = rows.length === 0;
65
72
  const prefix = first ? promptStr : pad;
66
73
  const avail = Math.max(1, width - promptW);
74
+ const start = i;
75
+ // Собираем очередную визуальную строку. Сначала набираем столько
76
+ // символов, сколько влезает (count < avail). Если упёрлись в ширину
77
+ // и следующий символ — не конец строки, откатываемся к последнему
78
+ // пробелу, чтобы не разрывать слово посередине (word-wrap).
67
79
  let text = '';
68
80
  let count = 0;
69
- const start = i;
81
+ let lastSpace = -1; // индекс пробела в text (по Array.from)
70
82
  while (i < chars.length && chars[i] !== NL && count < avail) {
83
+ if (chars[i] === ' ')
84
+ lastSpace = count;
71
85
  text += chars[i];
72
86
  i++;
73
87
  count++;
74
88
  }
89
+ const atLineEnd = i >= chars.length || chars[i] === NL;
90
+ if (!atLineEnd && count >= avail && lastSpace > 0) {
91
+ // Переносим «хвост» строки на следующую визуальную строку.
92
+ const textChars = Array.from(text);
93
+ const tailLen = textChars.length - lastSpace;
94
+ i -= tailLen;
95
+ text = textChars.slice(0, lastSpace).join('');
96
+ }
75
97
  if (i < chars.length && chars[i] === NL) {
76
98
  i++;
77
99
  rows.push({ prefix, text, start });
@@ -117,7 +139,14 @@ export class LineEditor {
117
139
  _thinkBase;
118
140
  _wasRaw;
119
141
  _onData;
120
- constructor({ prompt = '> ' } = {}) {
142
+ // История отправленных сообщений (для стрелок вверх/вниз).
143
+ history;
144
+ _histIndex;
145
+ _histDraft;
146
+ // Подсказки slash-команд (показываются при вводе «/»).
147
+ slashCommands;
148
+ _suggestCount;
149
+ constructor({ prompt = '> ', commands = [] } = {}) {
121
150
  this.promptStr = prompt;
122
151
  this.buf = '';
123
152
  this.cursor = 0;
@@ -135,6 +164,45 @@ export class LineEditor {
135
164
  this._thinkBase = '';
136
165
  this._wasRaw = false;
137
166
  this._onData = (b) => this._handle(b);
167
+ this.history = [];
168
+ this._histIndex = 0;
169
+ this._histDraft = '';
170
+ this.slashCommands = commands;
171
+ this._suggestCount = 0;
172
+ }
173
+ // Список подсказок для текущего ввода. Показываем, только когда строка
174
+ // начинается с «/» и это одно слово (без пробелов/переводов строк) — как
175
+ // автодополнение команд в Claude Code.
176
+ _suggestions() {
177
+ const b = this.buf;
178
+ if (!b.startsWith('/'))
179
+ return [];
180
+ if (b.includes(NL) || b.includes(' '))
181
+ return [];
182
+ const q = b.toLowerCase();
183
+ return this.slashCommands.filter((c) => c.name.toLowerCase().startsWith(q));
184
+ }
185
+ // Tab: дополнить команду до общего префикса; если совпадение одно —
186
+ // подставить его целиком и добавить пробел.
187
+ _completeCommand() {
188
+ const sugg = this._suggestions();
189
+ if (!sugg.length)
190
+ return;
191
+ if (sugg.length === 1) {
192
+ this.buf = sugg[0].name + ' ';
193
+ this.cursor = Array.from(this.buf).length;
194
+ return;
195
+ }
196
+ let prefix = sugg[0].name;
197
+ for (const c of sugg) {
198
+ while (!c.name.toLowerCase().startsWith(prefix.toLowerCase())) {
199
+ prefix = prefix.slice(0, -1);
200
+ }
201
+ }
202
+ if (prefix.length > this.buf.length) {
203
+ this.buf = prefix;
204
+ this.cursor = Array.from(this.buf).length;
205
+ }
138
206
  }
139
207
  start() {
140
208
  const stdin = process.stdin;
@@ -187,9 +255,29 @@ export class LineEditor {
187
255
  }
188
256
  const lay = layoutInput(this.promptStr, this.buf, this.cursor, cols);
189
257
  out += lay.rows.map((r) => r.prefix + r.text).join(NL);
258
+ // Подсказки slash-команд рисуем НИЖЕ строки ввода. Курсор потом
259
+ // возвращаем вверх, на строку ввода, поэтому cursorRowFromTop не меняется.
260
+ const sugg = this._suggestions();
261
+ const shown = sugg.slice(0, 8);
262
+ this._suggestCount = shown.length;
263
+ if (shown.length) {
264
+ const maxName = Math.max(...shown.map((c) => c.name.length));
265
+ const lines = shown.map((c) => {
266
+ const name = theme.prompt(c.name.padEnd(maxName));
267
+ const desc = theme.dim(' ' + c.description);
268
+ return ' ' + name + desc;
269
+ });
270
+ out += NL + lines.join(NL);
271
+ const hidden = sugg.length - shown.length;
272
+ if (hidden > 0)
273
+ out += NL + theme.dim(' …ещё ' + hidden);
274
+ }
190
275
  process.stdout.write(out);
191
276
  const lastRow = lay.rows.length - 1;
192
- const up = lastRow - lay.cursorRow;
277
+ // Курсор вверх: сначала на строку ввода внутри lay, затем ещё на
278
+ // строки подсказок (если они есть) — курсор должен стоять на вводе.
279
+ const linesBelow = shown.length ? shown.length + (sugg.length > shown.length ? 1 : 0) : 0;
280
+ const up = lastRow - lay.cursorRow + linesBelow;
193
281
  if (up > 0)
194
282
  process.stdout.write(ESC + '[' + up + 'A');
195
283
  process.stdout.write(CR);
@@ -278,6 +366,12 @@ export class LineEditor {
278
366
  this._render();
279
367
  return;
280
368
  }
369
+ // В историю — только непустые и не дублирующие прошлое сообщение.
370
+ if (text.trim() && this.history[this.history.length - 1] !== text) {
371
+ this.history.push(text);
372
+ }
373
+ this._histIndex = this.history.length;
374
+ this._histDraft = '';
281
375
  this.buf = '';
282
376
  this.cursor = 0;
283
377
  this.pendingText = null;
@@ -331,6 +425,19 @@ export class LineEditor {
331
425
  i++;
332
426
  this.cursor = i;
333
427
  }
428
+ // Курсор на первой/последней ВИЗУАЛЬНОЙ строке (с учётом переноса).
429
+ // Нужно, чтобы Up/Down работали как история на краях ввода, а внутри —
430
+ // как перемещение по строкам, как в обычном терминале.
431
+ _onFirstVisualLine() {
432
+ const cols = process.stdout.columns || 80;
433
+ const lay = layoutInput(this.promptStr, this.buf, this.cursor, cols);
434
+ return lay.cursorRow === 0;
435
+ }
436
+ _onLastVisualLine() {
437
+ const cols = process.stdout.columns || 80;
438
+ const lay = layoutInput(this.promptStr, this.buf, this.cursor, cols);
439
+ return lay.cursorRow === lay.rows.length - 1;
440
+ }
334
441
  _up() {
335
442
  const chars = Array.from(this.buf);
336
443
  let start = this.cursor;
@@ -362,6 +469,64 @@ export class LineEditor {
362
469
  nextEnd++;
363
470
  this.cursor = nextStart + Math.min(col, nextEnd - nextStart);
364
471
  }
472
+ // Перемещение на слово назад (Ctrl+Left / Alt+B): пропускаем пробелы
473
+ // слева, затем идём до начала слова.
474
+ _wordLeft() {
475
+ const chars = Array.from(this.buf);
476
+ let i = this.cursor;
477
+ while (i > 0 && /\s/.test(chars[i - 1]))
478
+ i--;
479
+ while (i > 0 && !/\s/.test(chars[i - 1]))
480
+ i--;
481
+ this.cursor = i;
482
+ }
483
+ // Перемещение на слово вперёд (Ctrl+Right / Alt+F).
484
+ _wordRight() {
485
+ const chars = Array.from(this.buf);
486
+ let i = this.cursor;
487
+ while (i < chars.length && /\s/.test(chars[i]))
488
+ i++;
489
+ while (i < chars.length && !/\s/.test(chars[i]))
490
+ i++;
491
+ this.cursor = i;
492
+ }
493
+ // Удаление слова слева (Ctrl+W / Alt+Backspace).
494
+ _deleteWordLeft() {
495
+ const chars = Array.from(this.buf);
496
+ let i = this.cursor;
497
+ while (i > 0 && /\s/.test(chars[i - 1]))
498
+ i--;
499
+ while (i > 0 && !/\s/.test(chars[i - 1]))
500
+ i--;
501
+ chars.splice(i, this.cursor - i);
502
+ this.buf = chars.join('');
503
+ this.cursor = i;
504
+ }
505
+ // ---------- история ----------
506
+ _historyUp() {
507
+ if (!this.history.length)
508
+ return;
509
+ if (this._histIndex === this.history.length)
510
+ this._histDraft = this.buf;
511
+ if (this._histIndex > 0) {
512
+ this._histIndex--;
513
+ this.buf = this.history[this._histIndex];
514
+ this.cursor = Array.from(this.buf).length;
515
+ }
516
+ }
517
+ _historyDown() {
518
+ if (!this.history.length)
519
+ return;
520
+ if (this._histIndex < this.history.length - 1) {
521
+ this._histIndex++;
522
+ this.buf = this.history[this._histIndex];
523
+ }
524
+ else {
525
+ this._histIndex = this.history.length;
526
+ this.buf = this._histDraft;
527
+ }
528
+ this.cursor = Array.from(this.buf).length;
529
+ }
365
530
  _handle(data) {
366
531
  let s = data.toString('utf-8');
367
532
  if (!this._inPaste && s === ESC) {
@@ -446,18 +611,53 @@ export class LineEditor {
446
611
  this._render();
447
612
  continue;
448
613
  }
614
+ if (code === 9) {
615
+ this._completeCommand();
616
+ this._render();
617
+ continue;
618
+ }
449
619
  if (code === 21) {
450
620
  this.buf = '';
451
621
  this.cursor = 0;
452
622
  this._render();
453
623
  continue;
454
624
  }
625
+ if (code === 23) {
626
+ this._deleteWordLeft();
627
+ this._render();
628
+ continue;
629
+ }
630
+ if (code === 11) {
631
+ // Ctrl+K — удалить от курсора до конца строки.
632
+ const arr = Array.from(this.buf);
633
+ let e = this.cursor;
634
+ while (e < arr.length && arr[e] !== NL)
635
+ e++;
636
+ arr.splice(this.cursor, e - this.cursor);
637
+ this.buf = arr.join('');
638
+ this._render();
639
+ continue;
640
+ }
455
641
  if (code === 127 || code === 8) {
456
642
  this._backspace();
457
643
  this._render();
458
644
  continue;
459
645
  }
460
646
  if (code === 27) {
647
+ // Ctrl+Left / Ctrl+Right (xterm: ESC [1;5D / ESC [1;5C;
648
+ // некоторые терминалы: ESC [5D / ESC [5C).
649
+ if (s.startsWith('[1;5D') || s.startsWith('[5D')) {
650
+ this._wordLeft();
651
+ s = s.slice(s.startsWith('[1;5D') ? 5 : 3);
652
+ this._render();
653
+ continue;
654
+ }
655
+ if (s.startsWith('[1;5C') || s.startsWith('[5C')) {
656
+ this._wordRight();
657
+ s = s.slice(s.startsWith('[1;5C') ? 5 : 3);
658
+ this._render();
659
+ continue;
660
+ }
461
661
  if (s.startsWith('[D')) {
462
662
  this._left();
463
663
  s = s.slice(2);
@@ -471,13 +671,20 @@ export class LineEditor {
471
671
  continue;
472
672
  }
473
673
  if (s.startsWith('[A')) {
474
- this._up();
674
+ // Вверх: на первой визуальной строке — история, иначе — строка выше.
675
+ if (this._onFirstVisualLine())
676
+ this._historyUp();
677
+ else
678
+ this._up();
475
679
  s = s.slice(2);
476
680
  this._render();
477
681
  continue;
478
682
  }
479
683
  if (s.startsWith('[B')) {
480
- this._down();
684
+ if (this._onLastVisualLine())
685
+ this._historyDown();
686
+ else
687
+ this._down();
481
688
  s = s.slice(2);
482
689
  this._render();
483
690
  continue;
@@ -500,6 +707,19 @@ export class LineEditor {
500
707
  this._render();
501
708
  continue;
502
709
  }
710
+ // Alt+B / Alt+F — перемещение по словам.
711
+ if (s.startsWith('b') || s.startsWith('B')) {
712
+ this._wordLeft();
713
+ s = s.slice(1);
714
+ this._render();
715
+ continue;
716
+ }
717
+ if (s.startsWith('f') || s.startsWith('F')) {
718
+ this._wordRight();
719
+ s = s.slice(1);
720
+ this._render();
721
+ continue;
722
+ }
503
723
  let j = 0;
504
724
  while (j < s.length && !/[A-Za-z~]/.test(s[j]))
505
725
  j++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.2.1",
3
+ "version": "2.4.0",
4
4
  "description": "Terminal coding agent over chat.deepseek.com via Playwright",
5
5
  "type": "module",
6
6
  "bin": {