zames_pro 2.6.0 → 2.8.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/input.js CHANGED
@@ -1,34 +1,73 @@
1
1
  import { theme } from './theme.js';
2
2
  import { renderMarkdown } from './markdown.js';
3
3
  import { randomThinkingPhrase, stripEllipsis } from './spinner.js';
4
- // Постоянная строка ввода внизу терминала + область статуса/вывода над ней.
4
+ import { AttachmentStore, parseImagePaste, looksLikeFilePath, } from './attachments.js';
5
+ // A permanent input line at the bottom of the terminal + a status/output area above it.
5
6
  //
6
- // Зачем свой редактор:
7
- // * строка ввода видна ВСЕГДА и не затирается выводом агента;
8
- // * спиннер/статус и ответы печатаются ВЫШЕ строки ввода (printAbove);
9
- // * перерисовка учитывает перенос по ширине терминала, поэтому нет
10
- // дублирования строк при многострочном вводе;
11
- // * многострочный ввод: Enter отправляет, но если курсор стоит сразу
12
- // после «\», Enter удаляет «\» и переносит строку (как в Claude Code).
13
- // Ctrl+J, Ctrl+Enter и Shift+Enter (в терминалах с расширенным
14
- // протоколом) всегда вставляют перевод строки;
15
- // * перенос длинных строк по словам (слово не рвётся посередине);
16
- // * перемещение по словам: Ctrl+←/→ (и Alt+B/Alt+F), удаление слова
17
- // Ctrl+W; Ctrl+K — удалить до конца строки;
18
- // * история введённых сообщений: ↑/↓ на краях ввода (внутри многострочного
19
- // ввода стрелки двигают по строкам, как в обычном терминале);
20
- // * подсказки slash-команд: при вводе «/» под строкой показываются
21
- // подходящие команды с описанием, Tab — дополнить.
7
+ // Why a custom editor:
8
+ // * the input line is ALWAYS visible and is not overwritten by the agent's output;
9
+ // * the spinner/status and answers are printed ABOVE the input line (printAbove);
10
+ // * redraw accounts for wrapping by terminal width, so there is no
11
+ // line duplication on multiline input;
12
+ // * multiline input: Enter sends, but if the cursor is right after a «\»,
13
+ // Enter removes the «\» and breaks the line (like in Claude Code).
14
+ // Ctrl+J, Ctrl+Enter and Shift+Enter (in terminals with the extended
15
+ // protocol) always insert a newline;
16
+ // * long lines wrap by words (a word is not split in the middle);
17
+ // * word movement: Ctrl+←/→ (and Alt+B/Alt+F), word deletion
18
+ // Ctrl+W; Ctrl+K — delete to end of line;
19
+ // * input history: ↑/↓ at the edges of the input (inside multiline
20
+ // input the arrows move by lines, like in a normal terminal);
21
+ // * slash-command hints: when you type «/», matching commands with
22
+ // descriptions are shown below the line, Tab completes.
22
23
  //
23
- // Все управляющие символы собираются из кодов, чтобы в файле не было
24
- // «сырых» ESC/CR/LF в строковых литералах (см. AGENTS.md).
24
+ // All control characters are assembled from codes so the file has no
25
+ // "raw" ESC/CR/LF in string literals (see AGENTS.md).
25
26
  const NL = String.fromCharCode(10);
26
27
  const CR = String.fromCharCode(13);
27
28
  const ESC = String.fromCharCode(27);
28
29
  const PASTE_START = ESC + '[200~';
29
30
  const PASTE_END = ESC + '[201~';
30
- // Анимация точек: старт с пустой строки (0 точек), затем рост.
31
- // Ширину выравниваем по максимуму (3), чтобы подсказка не смещалась.
31
+ // A large paste (3+ lines) is collapsed in the input line into a short
32
+ // marker "[Pasted lines#N]" so the line stays readable; the original text is
33
+ // kept aside and expanded back on submit. Pastes of 1–2 lines are inserted
34
+ // as-is (small pastes are usually short and don't clutter the line).
35
+ export const PASTE_MIN_LINES = 3;
36
+ // Number of lines in a pasted block (the trailing newline doesn't add a line).
37
+ export function countPasteLines(raw) {
38
+ const normalized = String(raw).split(CR + NL).join(NL).split(CR).join(NL);
39
+ const trimmed = normalized.replace(/\n+$/, '');
40
+ if (trimmed === '')
41
+ return 1;
42
+ return trimmed.split(NL).length;
43
+ }
44
+ export function formatPasteMarker(lines) {
45
+ return '[Pasted lines#' + lines + ']';
46
+ }
47
+ // Decide how to represent a pasted block: null — insert as-is (small paste),
48
+ // otherwise { marker, text }: the marker goes into the input line, and the
49
+ // original text is expanded back on submit.
50
+ export function pasteReplacement(raw) {
51
+ const text = String(raw).split(CR + NL).join(NL).split(CR).join(NL);
52
+ const lines = countPasteLines(text);
53
+ if (lines < PASTE_MIN_LINES)
54
+ return null;
55
+ return { marker: formatPasteMarker(lines), text };
56
+ }
57
+ // Replace "[Pasted lines#N]" markers with the original pasted text (the first
58
+ // occurrence in order — matches the order in which the pastes were inserted).
59
+ export function expandPastes(pastes, text) {
60
+ let out = text;
61
+ for (const p of pastes) {
62
+ const idx = out.indexOf(p.marker);
63
+ if (idx !== -1) {
64
+ out = out.slice(0, idx) + p.text + out.slice(idx + p.marker.length);
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+ // Dot animation: start from an empty string (0 dots), then grow.
70
+ // We align the width to the maximum (3) so the hint doesn't shift.
32
71
  const DOTS = ['', '.', '..', '...'];
33
72
  const DOTS_PAD = ' ';
34
73
  const HINT = theme.dim(' · Esc — стоп');
@@ -40,15 +79,15 @@ function safeJson(v) {
40
79
  return String(v);
41
80
  }
42
81
  }
43
- // Сколько визуальных строк занимает текст при данной ширине терминала
44
- // (с учётом переноса). Нужно, чтобы курсор/стирание блока не сбивались,
45
- // когда статус длиннее ширины терминала и переносится на 2+ строки.
82
+ // How many visual lines the text occupies at the given terminal width
83
+ // (accounting for wrapping). Needed so the cursor/block erase don't drift
84
+ // when the status is longer than the terminal width and wraps onto 2+ lines.
46
85
  export function visRows(s, cols) {
47
86
  const width = Math.max(1, Number(cols) || 80);
48
87
  const len = visLen(s);
49
88
  return Math.max(1, Math.ceil(len / width));
50
89
  }
51
- // Видимая длина строки без ANSI-последовательностей.
90
+ // Visible length of a line without ANSI sequences.
52
91
  export function visLen(s) {
53
92
  s = String(s);
54
93
  let n = 0;
@@ -82,13 +121,13 @@ export function layoutInput(promptStr, buf, cursor, cols) {
82
121
  const prefix = first ? promptStr : pad;
83
122
  const avail = Math.max(1, width - promptW);
84
123
  const start = i;
85
- // Собираем очередную визуальную строку. Сначала набираем столько
86
- // символов, сколько влезает (count < avail). Если упёрлись в ширину
87
- // и следующий символ — не конец строки, откатываемся к последнему
88
- // пробелу, чтобы не разрывать слово посередине (word-wrap).
124
+ // Build the next visual line. First we take as many
125
+ // characters as fit (count < avail). If we hit the width
126
+ // and the next character is not the end of the line, we roll back to the
127
+ // last space so we don't split a word in the middle (word-wrap).
89
128
  let text = '';
90
129
  let count = 0;
91
- let lastSpace = -1; // индекс пробела в text (по Array.from)
130
+ let lastSpace = -1; // index of the space in text (by Array.from)
92
131
  while (i < chars.length && chars[i] !== NL && count < avail) {
93
132
  if (chars[i] === ' ')
94
133
  lastSpace = count;
@@ -98,7 +137,7 @@ export function layoutInput(promptStr, buf, cursor, cols) {
98
137
  }
99
138
  const atLineEnd = i >= chars.length || chars[i] === NL;
100
139
  if (!atLineEnd && count >= avail && lastSpace > 0) {
101
- // Переносим «хвост» строки на следующую визуальную строку.
140
+ // Move the "tail" of the line to the next visual line.
102
141
  const textChars = Array.from(text);
103
142
  const tailLen = textChars.length - lastSpace;
104
143
  i -= tailLen;
@@ -149,13 +188,25 @@ export class LineEditor {
149
188
  _thinkBase;
150
189
  _wasRaw;
151
190
  _onData;
152
- // История отправленных сообщений (для стрелок вверх/вниз).
191
+ // History of sent messages (for the up/down arrows).
153
192
  history;
154
193
  _histIndex;
155
194
  _histDraft;
156
- // Подсказки slash-команд (показываются при вводе «/»).
195
+ // Slash-command hints (shown when you type «/»).
157
196
  slashCommands;
158
197
  _suggestCount;
198
+ // Pasted blocks collapsed into markers (expanded back on submit).
199
+ pastes;
200
+ _pasteBuf;
201
+ // Attached images/files (saved to <project>/tmp and sent to the chat).
202
+ attachments;
203
+ _tmpDir;
204
+ // Insert callback: receives a pasted image/file from the terminal. Returns
205
+ // the attachment if it was saved, so the editor can show a marker.
206
+ onAttach;
207
+ // Clipboard callback: tries to read an image from the OS clipboard when the
208
+ // terminal itself sends no usable data (Ctrl+V, right-click, empty paste).
209
+ onClipboard;
159
210
  constructor({ prompt = '> ', commands = [] } = {}) {
160
211
  this.promptStr = prompt;
161
212
  this.buf = '';
@@ -179,10 +230,33 @@ export class LineEditor {
179
230
  this._histDraft = '';
180
231
  this.slashCommands = commands;
181
232
  this._suggestCount = 0;
233
+ this.pastes = [];
234
+ this._pasteBuf = '';
235
+ this.attachments = new AttachmentStore();
236
+ this._tmpDir = '';
237
+ this.onAttach = null;
238
+ this.onClipboard = null;
239
+ }
240
+ // Read the OS clipboard for an image and insert its marker. Used when the
241
+ // terminal sends no usable paste data (Ctrl+V / right-click / empty paste).
242
+ async _tryClipboard() {
243
+ if (!this.onClipboard)
244
+ return;
245
+ try {
246
+ const att = await this.onClipboard();
247
+ if (att) {
248
+ this._insert(att.marker);
249
+ this._render();
250
+ }
251
+ }
252
+ catch { }
182
253
  }
183
- // Список подсказок для текущего ввода. Показываем, только когда строка
184
- // начинается с «/» и это одно слово (без пробелов/переводов строк) — как
185
- // автодополнение команд в Claude Code.
254
+ setTmpDir(dir) {
255
+ this._tmpDir = dir;
256
+ }
257
+ // List of hints for the current input. We show them only when the line
258
+ // starts with «/» and is a single word (no spaces/newlines) — like
259
+ // command completion in Claude Code.
186
260
  _suggestions() {
187
261
  const b = this.buf;
188
262
  if (!b.startsWith('/'))
@@ -192,8 +266,8 @@ export class LineEditor {
192
266
  const q = b.toLowerCase();
193
267
  return this.slashCommands.filter((c) => c.name.toLowerCase().startsWith(q));
194
268
  }
195
- // Tab: дополнить команду до общего префикса; если совпадение одно —
196
- // подставить его целиком и добавить пробел.
269
+ // Tab: complete the command up to the common prefix; if there is a single
270
+ // match — insert it whole and add a space.
197
271
  _completeCommand() {
198
272
  const sugg = this._suggestions();
199
273
  if (!sugg.length)
@@ -236,9 +310,9 @@ export class LineEditor {
236
310
  if (stdin.setRawMode)
237
311
  stdin.setRawMode(this._wasRaw || false);
238
312
  }
239
- // Временно отдать терминал внешнему UI (например, меню /config):
240
- // снимаем свой обработчик ввода и стираем блок ввода, чтобы чужой вывод
241
- // не накладывался на строку ввода. Парно с resume().
313
+ // Temporarily hand the terminal to an external UI (e.g. the /config menu):
314
+ // we detach our input handler and erase the input block so foreign output
315
+ // doesn't overlap the input line. Paired with resume().
242
316
  pause() {
243
317
  const stdin = process.stdin;
244
318
  this._stopDots();
@@ -263,7 +337,7 @@ export class LineEditor {
263
337
  this.promptStr = str;
264
338
  this._render();
265
339
  }
266
- // Обновить описания slash-команд (смена языка интерфейса).
340
+ // Update slash-command descriptions (interface language change).
267
341
  setCommands(commands) {
268
342
  this.slashCommands = commands;
269
343
  this._render();
@@ -271,9 +345,10 @@ export class LineEditor {
271
345
  clear() {
272
346
  this.buf = '';
273
347
  this.cursor = 0;
348
+ this.pastes = [];
274
349
  this._render();
275
350
  }
276
- // ---------- отрисовка ----------
351
+ // ---------- rendering ----------
277
352
  _eraseBlock() {
278
353
  if (!this.rendered)
279
354
  return;
@@ -289,14 +364,14 @@ export class LineEditor {
289
364
  let top = 0;
290
365
  if (this.statusText) {
291
366
  out += this.statusText + NL;
292
- // Статус может переноситься на несколько строк — учитываем это,
293
- // иначе стирание блока промахивается и статусы «стопкой» копятся.
367
+ // The status may wrap onto several lines — we account for this,
368
+ // otherwise the block erase misses and statuses pile up.
294
369
  top = visRows(this.statusText, cols);
295
370
  }
296
371
  const lay = layoutInput(this.promptStr, this.buf, this.cursor, cols);
297
372
  out += lay.rows.map((r) => r.prefix + r.text).join(NL);
298
- // Подсказки slash-команд рисуем НИЖЕ строки ввода. Курсор потом
299
- // возвращаем вверх, на строку ввода, поэтому cursorRowFromTop не меняется.
373
+ // We draw the slash-command hints BELOW the input line. We then move the
374
+ // cursor back up to the input line, so cursorRowFromTop doesn't change.
300
375
  const sugg = this._suggestions();
301
376
  const shown = sugg.slice(0, 8);
302
377
  this._suggestCount = shown.length;
@@ -314,8 +389,8 @@ export class LineEditor {
314
389
  }
315
390
  process.stdout.write(out);
316
391
  const lastRow = lay.rows.length - 1;
317
- // Курсор вверх: сначала на строку ввода внутри lay, затем ещё на
318
- // строки подсказок (если они есть) — курсор должен стоять на вводе.
392
+ // Move the cursor up: first to the input line within lay, then further by
393
+ // the hint lines (if any) — the cursor must sit on the input.
319
394
  const linesBelow = shown.length ? shown.length + (sugg.length > shown.length ? 1 : 0) : 0;
320
395
  const up = lastRow - lay.cursorRow + linesBelow;
321
396
  if (up > 0)
@@ -330,13 +405,13 @@ export class LineEditor {
330
405
  this._eraseBlock();
331
406
  this._writeBlock();
332
407
  }
333
- // Напечатать блок ВЫШЕ строки ввода и вернуть строку ввода на место.
408
+ // Print a block ABOVE the input line and put the input line back.
334
409
  printAbove(text) {
335
410
  this._eraseBlock();
336
411
  process.stdout.write(String(text) + NL);
337
412
  this._writeBlock();
338
413
  }
339
- // ---------- интерфейс, совместимый со спиннером ----------
414
+ // ---------- spinner-compatible interface ----------
340
415
  setStatus(text) {
341
416
  this.statusText = text || '';
342
417
  this._render();
@@ -348,8 +423,8 @@ export class LineEditor {
348
423
  }
349
424
  }
350
425
  _dots(n) {
351
- // Точки того же цвета, что и база, и фиксированной ширины — иначе
352
- // подсказка справа «прыгает» при смене фазы анимации.
426
+ // The dots are the same color as the base and of fixed width — otherwise
427
+ // the hint on the right "jumps" when the animation phase changes.
353
428
  return theme.brown(DOTS[n] + DOTS_PAD.slice(DOTS[n].length));
354
429
  }
355
430
  _startThinking() {
@@ -399,14 +474,22 @@ export class LineEditor {
399
474
  const rendered = renderMarkdown(msg);
400
475
  this.printAbove(NL + theme.assistant('● Ответ') + NL + rendered + NL + theme.dim('─'.repeat(60)));
401
476
  }
402
- // ---------- ввод ----------
477
+ // Warning to the operator (e.g. the agent stopped suspiciously).
478
+ // Printed above the input line without overwriting it.
479
+ warning(msg) {
480
+ this.printAbove(NL + theme.warn('⚠ ' + msg));
481
+ }
482
+ // ---------- input ----------
403
483
  _submit() {
404
- const text = this.buf;
405
- if (!text.trim()) {
484
+ const display = this.buf;
485
+ if (!display.trim()) {
406
486
  this._render();
407
487
  return;
408
488
  }
409
- // В историю — только непустые и не дублирующие прошлое сообщение.
489
+ // The input line shows compact markers "[Pasted lines#N]" instead of large
490
+ // pastes — expand them back into the original text before sending.
491
+ const text = expandPastes(this.pastes, display);
492
+ // Add to history only non-empty messages that don't duplicate the previous one.
410
493
  if (text.trim() && this.history[this.history.length - 1] !== text) {
411
494
  this.history.push(text);
412
495
  }
@@ -417,9 +500,14 @@ export class LineEditor {
417
500
  this.pendingText = null;
418
501
  this._stopDots();
419
502
  this.statusText = '';
420
- this.printAbove(theme.user('❯ ') + text);
503
+ // Attachments collected for this message (images/files). They are handed
504
+ // to the browser layer to be attached to the chat.
505
+ const attached = this.attachments.items.slice();
506
+ this.printAbove(theme.user('❯ ') + display);
507
+ this.pastes = [];
508
+ this.attachments.reset();
421
509
  if (this.onSubmit)
422
- this.onSubmit(text);
510
+ this.onSubmit(text, attached);
423
511
  }
424
512
  _insert(text) {
425
513
  const chars = Array.from(this.buf);
@@ -428,9 +516,56 @@ export class LineEditor {
428
516
  this.buf = next.join('');
429
517
  this.cursor += ins.length;
430
518
  }
431
- // Вставить перевод строки. Если курсор стоит сразу после символа «\»
432
- // (как в Claude Code), символ удаляется — чтобы «\» + Enter давали
433
- // обычный перенос без лишней обратной косой черты в тексте.
519
+ // Insert a pasted block. Small pastes (1–2 lines) go in as-is; large ones
520
+ // (3+ lines) are collapsed into "[Pasted lines#N]" so the input line stays
521
+ // readable — the original text is expanded back on submit.
522
+ _insertPaste(raw) {
523
+ // A pasted image (a data URL / base64 blob) or a file path is saved to
524
+ // tmp and shown as [image#N] / [file#N] — the marker is attached to the
525
+ // message on submit.
526
+ if (this.onAttach) {
527
+ const asImage = parseImagePaste(raw);
528
+ const trimmed = String(raw).trim();
529
+ const isPath = !asImage && looksLikeFilePath(trimmed);
530
+ if (asImage || isPath) {
531
+ void this._attachAsync(raw);
532
+ return;
533
+ }
534
+ }
535
+ this._insertFallback(raw);
536
+ }
537
+ // Save a pasted image/file to tmp and put the [image#N]/[file#N] marker
538
+ // into the input line.
539
+ async _attachAsync(raw) {
540
+ if (!this.onAttach)
541
+ return;
542
+ try {
543
+ const att = await this.onAttach(raw);
544
+ if (att) {
545
+ this._insert(att.marker);
546
+ this._render();
547
+ return;
548
+ }
549
+ }
550
+ catch { }
551
+ // Attachment failed (e.g. the path does not exist) — insert the text as-is
552
+ // so nothing is lost.
553
+ this._insertFallback(raw);
554
+ this._render();
555
+ }
556
+ _insertFallback(raw) {
557
+ const rep = pasteReplacement(raw);
558
+ if (!rep) {
559
+ const text = String(raw).split(CR + NL).join(NL).split(CR).join(NL);
560
+ this._insert(text);
561
+ return;
562
+ }
563
+ this.pastes.push(rep);
564
+ this._insert(rep.marker);
565
+ }
566
+ // Insert a newline. If the cursor is right after a «\»
567
+ // (like in Claude Code), the character is removed — so «\» + Enter gives
568
+ // an ordinary break without a stray backslash in the text.
434
569
  _insertNewline() {
435
570
  const chars = Array.from(this.buf);
436
571
  if (this.cursor > 0 && chars[this.cursor - 1] === '\\') {
@@ -477,9 +612,9 @@ export class LineEditor {
477
612
  i++;
478
613
  this.cursor = i;
479
614
  }
480
- // Курсор на первой/последней ВИЗУАЛЬНОЙ строке (с учётом переноса).
481
- // Нужно, чтобы Up/Down работали как история на краях ввода, а внутри —
482
- // как перемещение по строкам, как в обычном терминале.
615
+ // Is the cursor on the first/last VISUAL line (accounting for wrapping).
616
+ // Needed so Up/Down act as history at the edges of the input, and inside —
617
+ // as line movement, like in a normal terminal.
483
618
  _onFirstVisualLine() {
484
619
  const cols = process.stdout.columns || 80;
485
620
  const lay = layoutInput(this.promptStr, this.buf, this.cursor, cols);
@@ -521,8 +656,8 @@ export class LineEditor {
521
656
  nextEnd++;
522
657
  this.cursor = nextStart + Math.min(col, nextEnd - nextStart);
523
658
  }
524
- // Перемещение на слово назад (Ctrl+Left / Alt+B): пропускаем пробелы
525
- // слева, затем идём до начала слова.
659
+ // Move one word back (Ctrl+Left / Alt+B): skip spaces
660
+ // on the left, then go to the start of the word.
526
661
  _wordLeft() {
527
662
  const chars = Array.from(this.buf);
528
663
  let i = this.cursor;
@@ -532,7 +667,7 @@ export class LineEditor {
532
667
  i--;
533
668
  this.cursor = i;
534
669
  }
535
- // Перемещение на слово вперёд (Ctrl+Right / Alt+F).
670
+ // Move one word forward (Ctrl+Right / Alt+F).
536
671
  _wordRight() {
537
672
  const chars = Array.from(this.buf);
538
673
  let i = this.cursor;
@@ -542,7 +677,7 @@ export class LineEditor {
542
677
  i++;
543
678
  this.cursor = i;
544
679
  }
545
- // Удаление слова слева (Ctrl+W / Alt+Backspace).
680
+ // Delete the word on the left (Ctrl+W / Alt+Backspace).
546
681
  _deleteWordLeft() {
547
682
  const chars = Array.from(this.buf);
548
683
  let i = this.cursor;
@@ -554,7 +689,7 @@ export class LineEditor {
554
689
  this.buf = chars.join('');
555
690
  this.cursor = i;
556
691
  }
557
- // ---------- история ----------
692
+ // ---------- history ----------
558
693
  _historyUp() {
559
694
  if (!this.history.length)
560
695
  return;
@@ -590,13 +725,23 @@ export class LineEditor {
590
725
  if (this._inPaste) {
591
726
  const end = s.indexOf(PASTE_END);
592
727
  if (end === -1) {
593
- this._insert(s);
728
+ // The paste arrived in several chunks — accumulate until the end marker.
729
+ this._pasteBuf += s;
594
730
  s = '';
595
731
  }
596
732
  else {
597
- this._insert(s.slice(0, end));
733
+ this._pasteBuf += s.slice(0, end);
598
734
  s = s.slice(end + PASTE_END.length);
599
735
  this._inPaste = false;
736
+ if (this._pasteBuf === '') {
737
+ // Empty bracketed paste: many terminals send this for an image on
738
+ // the clipboard (there is no text to deliver). Try the OS clipboard.
739
+ void this._tryClipboard();
740
+ }
741
+ else {
742
+ this._insertPaste(this._pasteBuf);
743
+ }
744
+ this._pasteBuf = '';
600
745
  }
601
746
  this._render();
602
747
  continue;
@@ -606,14 +751,15 @@ export class LineEditor {
606
751
  const before = s.slice(0, pasteAt);
607
752
  s = s.slice(pasteAt + PASTE_START.length);
608
753
  this._inPaste = true;
754
+ this._pasteBuf = '';
609
755
  if (before) {
610
756
  this._insert(before);
611
757
  this._render();
612
758
  }
613
759
  continue;
614
760
  }
615
- // Shift+Enter, Ctrl+Enter в терминалах с расширенным протоколом
616
- // и Alt+Enter — вставка перевода строки.
761
+ // Shift+Enter, Ctrl+Enter in terminals with the extended protocol
762
+ // and Alt+Enter — insert a newline.
617
763
  if (s.startsWith(ESC + '[13;2u')) {
618
764
  this._insertNewline();
619
765
  s = s.slice(7);
@@ -647,10 +793,10 @@ export class LineEditor {
647
793
  const ch = s[0];
648
794
  const code = s.charCodeAt(0);
649
795
  s = s.slice(1);
650
- // Enter: если предыдущий символ — «\», Claude-Code-стиль: удаляем
651
- // «\» и переносим строку; иначе отправляем сообщение.
652
- // Ctrl+J (code 10) всегда вставляет перевод строки; Ctrl+Enter
653
- // (ESC[13;5u) и Shift+Enter тоже.
796
+ // Enter: if the previous character is «\», Claude-Code style: remove
797
+ // the «\» and break the line; otherwise send the message.
798
+ // Ctrl+J (code 10) always inserts a newline; Ctrl+Enter
799
+ // (ESC[13;5u) and Shift+Enter too.
654
800
  if (ch === CR) {
655
801
  const before = this.cursor > 0 ? Array.from(this.buf)[this.cursor - 1] : '';
656
802
  if (before === '\\') {
@@ -694,16 +840,24 @@ export class LineEditor {
694
840
  if (code === 21) {
695
841
  this.buf = '';
696
842
  this.cursor = 0;
843
+ this.pastes = [];
697
844
  this._render();
698
845
  continue;
699
846
  }
847
+ if (code === 22) {
848
+ // Ctrl+V: terminals rarely deliver an image as text here, so we try the
849
+ // OS clipboard first; if there is no image we fall back to reading the
850
+ // text clipboard via the terminal's own paste (nothing to do).
851
+ void this._tryClipboard();
852
+ continue;
853
+ }
700
854
  if (code === 23) {
701
855
  this._deleteWordLeft();
702
856
  this._render();
703
857
  continue;
704
858
  }
705
859
  if (code === 11) {
706
- // Ctrl+K — удалить от курсора до конца строки.
860
+ // Ctrl+K — delete from the cursor to the end of the line.
707
861
  const arr = Array.from(this.buf);
708
862
  let e = this.cursor;
709
863
  while (e < arr.length && arr[e] !== NL)
@@ -720,7 +874,7 @@ export class LineEditor {
720
874
  }
721
875
  if (code === 27) {
722
876
  // Ctrl+Left / Ctrl+Right (xterm: ESC [1;5D / ESC [1;5C;
723
- // некоторые терминалы: ESC [5D / ESC [5C).
877
+ // some terminals: ESC [5D / ESC [5C).
724
878
  if (s.startsWith('[1;5D') || s.startsWith('[5D')) {
725
879
  this._wordLeft();
726
880
  s = s.slice(s.startsWith('[1;5D') ? 5 : 3);
@@ -746,7 +900,7 @@ export class LineEditor {
746
900
  continue;
747
901
  }
748
902
  if (s.startsWith('[A')) {
749
- // Вверх: на первой визуальной строке — история, иначе — строка выше.
903
+ // Up: on the first visual line — history, otherwise — the line above.
750
904
  if (this._onFirstVisualLine())
751
905
  this._historyUp();
752
906
  else
@@ -782,7 +936,7 @@ export class LineEditor {
782
936
  this._render();
783
937
  continue;
784
938
  }
785
- // Alt+B / Alt+F — перемещение по словам.
939
+ // Alt+B / Alt+F — word movement.
786
940
  if (s.startsWith('b') || s.startsWith('B')) {
787
941
  this._wordLeft();
788
942
  s = s.slice(1);
@@ -808,7 +962,7 @@ export class LineEditor {
808
962
  }
809
963
  }
810
964
  }
811
- // Проверка раскладки: node src/input.js --selftest
965
+ // Layout check: node src/input.js --selftest
812
966
  export function selftest() {
813
967
  const cases = [
814
968
  { p: '> ', b: 'hello', c: 5, w: 80 },
package/dist/markdown.js CHANGED
@@ -14,7 +14,7 @@ const theme = {
14
14
  tableHeader: { color: '#9fb8d8', bold: true },
15
15
  tableCell: { color: '#d0d0d0' },
16
16
  };
17
- // Подсветка кода через cli-highlight (транзитивно уже есть у нас).
17
+ // Code highlighting via cli-highlight (already present transitively).
18
18
  function highlighter(code, lang) {
19
19
  try {
20
20
  return highlight(code, {