zames_pro 2.7.0 → 2.9.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
@@ -7,7 +7,8 @@ import { DeepSeekBrowser } from './browser.js';
7
7
  import { createTools } from './tools.js';
8
8
  import { runAgentLoop } from './agent-loop.js';
9
9
  import { createSpinner } from './spinner.js';
10
- import { LineEditor } from './input.js';
10
+ import { LineEditor, expandPastes, pasteReplacement } from './input.js';
11
+ import { parseImagePaste, extForMime, saveToTemp, guessMime, isImageName, formatSize, looksLikeFilePath, readClipboardImageDetailed, sniffMime, readWindowsClipboardFiles, } from './attachments.js';
11
12
  import { loadConfig, DEFAULTS, CONFIG_PATHS, ZAMES_HOME, CONFIG_SCHEMA, getByPath, validateConfigValue, writeConfigValue, resetConfigValue, } from './config.js';
12
13
  import { runConfigMenu } from './config-menu.js';
13
14
  import { translate, normalizeLocale, localeDisplayName, isLocale, } from './i18n.js';
@@ -195,6 +196,7 @@ ${theme.bold(t('help.while_working'))}
195
196
  ${t('help.key.slash')}
196
197
  ${t('help.key.newline')}
197
198
  ${t('help.key.backslash')}
199
+ ${t('help.key.attach')}
198
200
  ${t('help.key.esc')}
199
201
 
200
202
  ${theme.bold(t('help.commands'))}
@@ -279,6 +281,27 @@ function buildSlashCommands() {
279
281
  // We put the agent's temporary files (one-off scripts, etc.) in
280
282
  // <project>/tmp — this folder is in .gitignore and is cleaned on every launch.
281
283
  const TMP_DIR = path.join(__dirname, '..', 'tmp');
284
+ // Resolve a pasted string into a path to an existing file. Handles quoted
285
+ // paths (drag&drop from some file managers adds quotes) and paths relative to
286
+ // the working directory.
287
+ async function resolveAttachPath(workdir, raw) {
288
+ let s = String(raw || '').trim();
289
+ if (!s)
290
+ return null;
291
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
292
+ s = s.slice(1, -1);
293
+ }
294
+ s = s.replace(/\\ /g, ' ');
295
+ if (!looksLikeFilePath(s) && !isImageName(s))
296
+ return null;
297
+ const candidates = path.isAbsolute(s) ? [s] : [path.resolve(workdir, s)];
298
+ for (const c of candidates) {
299
+ const st = await fs.stat(c).catch(() => null);
300
+ if (st && st.isFile())
301
+ return c;
302
+ }
303
+ return null;
304
+ }
282
305
  async function cleanTmpDir() {
283
306
  try {
284
307
  await fs.rm(TMP_DIR, { recursive: true, force: true });
@@ -519,6 +542,8 @@ function watchInput({ onEscape, onChange, onQueue, } = {}) {
519
542
  process.stdout.write(ESC + '[?2004h');
520
543
  let buf = '';
521
544
  let inPaste = false;
545
+ let pasteBuf = '';
546
+ const pastes = [];
522
547
  const PASTE_START = ESC + '[200~';
523
548
  const PASTE_END = ESC + '[201~';
524
549
  const CSI_RE = new RegExp('^' + CSI + '[0-9;]*[A-Za-z~]');
@@ -526,8 +551,9 @@ function watchInput({ onEscape, onChange, onQueue, } = {}) {
526
551
  if (onChange)
527
552
  onChange(buf);
528
553
  };
529
- // Pasted text: newlines inside a multiline paste are treated as spaces —
530
- // the message goes as a single line.
554
+ // Pasted text: small pastes (1–2 lines) are flattened into one line;
555
+ // large ones (3+ lines) are collapsed into "[Pasted lines#N]" (the original
556
+ // text is expanded back when the message is queued).
531
557
  const insert = (text) => {
532
558
  buf += text
533
559
  .split(CR + LF)
@@ -537,6 +563,15 @@ function watchInput({ onEscape, onChange, onQueue, } = {}) {
537
563
  .split(LF)
538
564
  .join(' ');
539
565
  };
566
+ const insertPaste = (raw) => {
567
+ const rep = pasteReplacement(raw);
568
+ if (!rep) {
569
+ insert(raw);
570
+ return;
571
+ }
572
+ pastes.push(rep);
573
+ buf += rep.marker;
574
+ };
540
575
  function onData(data) {
541
576
  let s = data.toString('utf-8');
542
577
  // A single Esc — abort generation. Arrows come as a whole chunk and
@@ -550,13 +585,15 @@ function watchInput({ onEscape, onChange, onQueue, } = {}) {
550
585
  if (inPaste) {
551
586
  const end = s.indexOf(PASTE_END);
552
587
  if (end === -1) {
553
- insert(s);
588
+ pasteBuf += s;
554
589
  s = '';
555
590
  }
556
591
  else {
557
- insert(s.slice(0, end));
592
+ pasteBuf += s.slice(0, end);
558
593
  s = s.slice(end + PASTE_END.length);
559
594
  inPaste = false;
595
+ insertPaste(pasteBuf);
596
+ pasteBuf = '';
560
597
  }
561
598
  emitChange();
562
599
  continue;
@@ -566,6 +603,7 @@ function watchInput({ onEscape, onChange, onQueue, } = {}) {
566
603
  const before = s.slice(0, start);
567
604
  s = s.slice(start + PASTE_START.length);
568
605
  inPaste = true;
606
+ pasteBuf = '';
569
607
  if (before) {
570
608
  insert(before);
571
609
  emitChange();
@@ -576,8 +614,9 @@ function watchInput({ onEscape, onChange, onQueue, } = {}) {
576
614
  const code = s.charCodeAt(0);
577
615
  s = s.slice(1);
578
616
  if (ch === CR || ch === LF) {
579
- const text = buf.trim();
617
+ const text = expandPastes(pastes, buf).trim();
580
618
  buf = '';
619
+ pastes.length = 0;
581
620
  emitChange();
582
621
  if (text && onQueue)
583
622
  onQueue(text);
@@ -592,6 +631,7 @@ function watchInput({ onEscape, onChange, onQueue, } = {}) {
592
631
  if (code === 21) {
593
632
  // Ctrl+U — clear what was typed.
594
633
  buf = '';
634
+ pastes.length = 0;
595
635
  emitChange();
596
636
  continue;
597
637
  }
@@ -638,7 +678,7 @@ async function resolveWorkdir() {
638
678
  return dir;
639
679
  }
640
680
  // ---------- task runner ----------
641
- async function runTask(browser, tools, taskText, workdir, opts) {
681
+ async function runTask(browser, tools, taskText, workdir, opts, attachments = []) {
642
682
  const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, } = opts;
643
683
  // In TTY mode the UI is a LineEditor: it owns the input (queue, Esc,
644
684
  // Ctrl+C) and draws the status ABOVE the permanent input line. In non-TTY
@@ -657,7 +697,7 @@ async function runTask(browser, tools, taskText, workdir, opts) {
657
697
  },
658
698
  onChange: (text) => ui.setPending(text),
659
699
  onQueue: (text) => {
660
- queue.push(text);
700
+ queue.push({ text });
661
701
  ui.setPending(null);
662
702
  ui.stop();
663
703
  console.log(theme.user(t('msg.queued', { n: queue.length })) +
@@ -666,7 +706,12 @@ async function runTask(browser, tools, taskText, workdir, opts) {
666
706
  },
667
707
  });
668
708
  try {
669
- let next = { task: taskText, freshChat, sendSystemPrompt };
709
+ let next = {
710
+ text: taskText,
711
+ attachments,
712
+ freshChat,
713
+ sendSystemPrompt,
714
+ };
670
715
  // Execute the task, then everything the user managed to type while it
671
716
  // ran. The queue may be replenished right during draining.
672
717
  while (true) {
@@ -674,11 +719,12 @@ async function runTask(browser, tools, taskText, workdir, opts) {
674
719
  await mod.runAgentLoop({
675
720
  browser,
676
721
  tools,
677
- task: next.task,
722
+ task: next.text,
678
723
  workdir,
679
724
  maxIterations: maxIter,
680
725
  freshChat: next.freshChat,
681
726
  sendSystemPrompt: next.sendSystemPrompt,
727
+ attachments: next.attachments || [],
682
728
  transcript,
683
729
  onThinking: () => ui.thinking(),
684
730
  onToolCall: (name, toolArgs) => ui.toolCall(name, toolArgs),
@@ -697,16 +743,21 @@ async function runTask(browser, tools, taskText, workdir, opts) {
697
743
  }
698
744
  if (!queue.length)
699
745
  break;
700
- const queued = queue.shift() ?? '';
746
+ const queued = queue.shift() ?? { text: '' };
701
747
  ui.stop();
702
748
  if (editor) {
703
- editor.printAbove(theme.user(t('msg.from_queue')) + theme.assistant(queued));
749
+ editor.printAbove(theme.user(t('msg.from_queue')) + theme.assistant(queued.text));
704
750
  }
705
751
  else {
706
- console.log(theme.user(t('msg.from_queue')) + theme.assistant(queued));
707
- }
708
- transcript?.log('queued_task', { task: queued });
709
- next = { task: queued, freshChat: false, sendSystemPrompt: false };
752
+ console.log(theme.user(t('msg.from_queue')) + theme.assistant(queued.text));
753
+ }
754
+ transcript?.log('queued_task', { task: queued.text });
755
+ next = {
756
+ text: queued.text,
757
+ attachments: queued.attachments,
758
+ freshChat: false,
759
+ sendSystemPrompt: false,
760
+ };
710
761
  }
711
762
  }
712
763
  catch (e) {
@@ -831,6 +882,10 @@ async function main() {
831
882
  // Messages the user typed while the agent worked. runTask takes them one
832
883
  // by one after the current task finishes.
833
884
  const pendingQueue = [];
885
+ // Attachments for the next submit (filled by the editor's onAttachments).
886
+ let pendingAttachments = [];
887
+ // Show the "no clipboard image" hint only once per session.
888
+ let clipboardWarned = false;
834
889
  // ---------- review mode state ----------
835
890
  // null — normal mode.
836
891
  // { snapDir, snapName, originalWorkdir } — we're inside a snapshot, the chat
@@ -905,8 +960,94 @@ async function main() {
905
960
  commands: buildSlashCommands(),
906
961
  });
907
962
  editor = ed;
908
- ed.onSubmit = (text) => {
909
- pendingQueue.push(text);
963
+ ed.setTmpDir(TMP_DIR);
964
+ ed.onAttach = async (raw) => {
965
+ // Case 1: the paste is the image data itself (data URL / base64 blob).
966
+ const image = parseImagePaste(raw);
967
+ if (image) {
968
+ const name = 'paste' + extForMime(image.mime);
969
+ const p = await saveToTemp(TMP_DIR, name, image.data);
970
+ const att = ed.attachments.add({
971
+ path: p,
972
+ name,
973
+ mime: image.mime,
974
+ size: image.data.length,
975
+ });
976
+ ed.printAbove(theme.system(t('msg.attached_image', { marker: att.marker, size: formatSize(image.data.length) })));
977
+ return att;
978
+ }
979
+ // Case 2: the paste is a path to a local file (drag&drop or copy path).
980
+ const filePath = await resolveAttachPath(currentWorkdir, raw);
981
+ if (!filePath)
982
+ return null;
983
+ const data = await fs.readFile(filePath).catch(() => null);
984
+ if (!data)
985
+ return null;
986
+ const name = path.basename(filePath);
987
+ const mime = guessMime(name);
988
+ const att = ed.attachments.add({
989
+ path: filePath,
990
+ name,
991
+ mime,
992
+ size: data.length,
993
+ });
994
+ ed.printAbove(theme.system(t('msg.attached_file', { marker: att.marker, name, size: formatSize(data.length) })));
995
+ return att;
996
+ };
997
+ ed.onClipboard = async () => {
998
+ const res = readClipboardImageDetailed();
999
+ if (!res.data || !res.data.length) {
1000
+ // On WSL the user may have copied a FILE in Windows (not an image):
1001
+ // the Windows clipboard holds its path - attach it directly.
1002
+ for (const fp of readWindowsClipboardFiles()) {
1003
+ const data = await fs.readFile(fp).catch(() => null);
1004
+ if (!data)
1005
+ continue;
1006
+ const nm = path.basename(fp);
1007
+ const att = ed.attachments.add({
1008
+ path: fp,
1009
+ name: nm,
1010
+ mime: guessMime(nm),
1011
+ size: data.length,
1012
+ });
1013
+ ed.printAbove(theme.system(t('msg.attached_file', {
1014
+ marker: att.marker,
1015
+ name: nm,
1016
+ size: formatSize(data.length),
1017
+ }) + theme.dim(' (windows-clipboard)')));
1018
+ return att;
1019
+ }
1020
+ if (!clipboardWarned) {
1021
+ clipboardWarned = true;
1022
+ ed.printAbove(theme.warn(t('msg.clip_empty', { via: res.via }) +
1023
+ String.fromCharCode(10) +
1024
+ t('msg.clip_hint')));
1025
+ }
1026
+ return null;
1027
+ }
1028
+ const mime = sniffMime(res.data) || 'image/png';
1029
+ const name = 'clipboard-' + Date.now() + (extForMime(mime) || '.png');
1030
+ const p = await saveToTemp(TMP_DIR, name, res.data);
1031
+ const att = ed.attachments.add({
1032
+ path: p,
1033
+ name,
1034
+ mime,
1035
+ size: res.data.length,
1036
+ });
1037
+ ed.printAbove(theme.system(t('msg.attached_image', {
1038
+ marker: att.marker,
1039
+ size: formatSize(res.data.length),
1040
+ }) + theme.dim(' (' + res.via + ')')));
1041
+ return att;
1042
+ };
1043
+ ed.onSubmit = (text, items) => {
1044
+ const attachments = (items || []).map((a) => ({
1045
+ path: a.path,
1046
+ name: a.name,
1047
+ mime: a.mime,
1048
+ }));
1049
+ const msg = { text, attachments };
1050
+ pendingQueue.push(msg);
910
1051
  if (waiter) {
911
1052
  const r = waiter;
912
1053
  waiter = null;
@@ -1159,10 +1300,13 @@ async function main() {
1159
1300
  }
1160
1301
  while (running) {
1161
1302
  let input;
1303
+ let inputAttachments = [];
1162
1304
  try {
1163
1305
  if (editor) {
1164
1306
  editor.setPrompt(buildPrompt());
1165
- input = await takeInput();
1307
+ const msg = await takeInput();
1308
+ input = msg ? msg.text : null;
1309
+ inputAttachments = msg?.attachments || [];
1166
1310
  }
1167
1311
  else {
1168
1312
  let tail;
@@ -1642,7 +1786,7 @@ async function main() {
1642
1786
  saveLastChat(chatId, currentWorkdir);
1643
1787
  }
1644
1788
  },
1645
- });
1789
+ }, inputAttachments);
1646
1790
  }
1647
1791
  finally {
1648
1792
  if (editor)
package/dist/input.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { theme } from './theme.js';
2
2
  import { renderMarkdown } from './markdown.js';
3
3
  import { randomThinkingPhrase, stripEllipsis } from './spinner.js';
4
+ import { AttachmentStore, parseImagePaste, looksLikeFilePath, } from './attachments.js';
4
5
  // A permanent input line at the bottom of the terminal + a status/output area above it.
5
6
  //
6
7
  // Why a custom editor:
@@ -27,6 +28,44 @@ 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~';
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
+ }
30
69
  // Dot animation: start from an empty string (0 dots), then grow.
31
70
  // We align the width to the maximum (3) so the hint doesn't shift.
32
71
  const DOTS = ['', '.', '..', '...'];
@@ -156,6 +195,18 @@ export class LineEditor {
156
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,6 +230,29 @@ 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 { }
253
+ }
254
+ setTmpDir(dir) {
255
+ this._tmpDir = dir;
182
256
  }
183
257
  // List of hints for the current input. We show them only when the line
184
258
  // starts with «/» and is a single word (no spaces/newlines) — like
@@ -271,6 +345,7 @@ 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 ----------
@@ -406,11 +481,14 @@ export class LineEditor {
406
481
  }
407
482
  // ---------- input ----------
408
483
  _submit() {
409
- const text = this.buf;
410
- if (!text.trim()) {
484
+ const display = this.buf;
485
+ if (!display.trim()) {
411
486
  this._render();
412
487
  return;
413
488
  }
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);
414
492
  // Add to history only non-empty messages that don't duplicate the previous one.
415
493
  if (text.trim() && this.history[this.history.length - 1] !== text) {
416
494
  this.history.push(text);
@@ -422,9 +500,14 @@ export class LineEditor {
422
500
  this.pendingText = null;
423
501
  this._stopDots();
424
502
  this.statusText = '';
425
- 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();
426
509
  if (this.onSubmit)
427
- this.onSubmit(text);
510
+ this.onSubmit(text, attached);
428
511
  }
429
512
  _insert(text) {
430
513
  const chars = Array.from(this.buf);
@@ -433,6 +516,53 @@ export class LineEditor {
433
516
  this.buf = next.join('');
434
517
  this.cursor += ins.length;
435
518
  }
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
+ }
436
566
  // Insert a newline. If the cursor is right after a «\»
437
567
  // (like in Claude Code), the character is removed — so «\» + Enter gives
438
568
  // an ordinary break without a stray backslash in the text.
@@ -595,13 +725,23 @@ export class LineEditor {
595
725
  if (this._inPaste) {
596
726
  const end = s.indexOf(PASTE_END);
597
727
  if (end === -1) {
598
- this._insert(s);
728
+ // The paste arrived in several chunks — accumulate until the end marker.
729
+ this._pasteBuf += s;
599
730
  s = '';
600
731
  }
601
732
  else {
602
- this._insert(s.slice(0, end));
733
+ this._pasteBuf += s.slice(0, end);
603
734
  s = s.slice(end + PASTE_END.length);
604
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 = '';
605
745
  }
606
746
  this._render();
607
747
  continue;
@@ -611,6 +751,7 @@ export class LineEditor {
611
751
  const before = s.slice(0, pasteAt);
612
752
  s = s.slice(pasteAt + PASTE_START.length);
613
753
  this._inPaste = true;
754
+ this._pasteBuf = '';
614
755
  if (before) {
615
756
  this._insert(before);
616
757
  this._render();
@@ -699,9 +840,17 @@ export class LineEditor {
699
840
  if (code === 21) {
700
841
  this.buf = '';
701
842
  this.cursor = 0;
843
+ this.pastes = [];
702
844
  this._render();
703
845
  continue;
704
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
+ }
705
854
  if (code === 23) {
706
855
  this._deleteWordLeft();
707
856
  this._render();
@@ -1,5 +1,5 @@
1
1
  import { translate } from './i18n.js';
2
- export function buildSystemPrompt({ workdir, tools, gitContext = null, locale = 'ru', }) {
2
+ export function buildSystemPrompt({ workdir, tools, gitContext = null, locale = 'ru', attachments = [], }) {
3
3
  const t = translate(locale);
4
4
  const toolDescriptions = tools
5
5
  .map((t2) => `### ${t2.name}\n${t2.description}\nПараметры: ${JSON.stringify(t2.parameters)}`)
@@ -7,6 +7,10 @@ export function buildSystemPrompt({ workdir, tools, gitContext = null, locale =
7
7
  const gitSection = gitContext
8
8
  ? `\n## Git context\n\n${gitContext}\n`
9
9
  : '\n## Git context\n\nNot a git repository (or git is not installed).\n';
10
+ const attachSection = attachments.length
11
+ ? `\n## Attachments\n\nThe user attached files to this task: ${attachments.join(', ')}.\n` +
12
+ `Each [image#N] / [file#N] marker corresponds to a file the user pasted into the terminal; the file was uploaded to the chat and also saved under <project>/tmp. Look at the images in the chat; for files, read the copy in tmp if you need the contents.\n`
13
+ : '';
10
14
  return `You are a coding agent running in a terminal. You help the user with software engineering tasks by reading files, writing code, running commands, and iterating until the task is done.
11
15
 
12
16
  You work in the directory: ${workdir}
@@ -46,7 +50,7 @@ operator will not read it, so never use plain text.\n\nNO PROSE AROUND TOOL CALL
46
50
  You have access to the following tools:
47
51
 
48
52
  ${toolDescriptions}
49
- ${gitSection}
53
+ ${gitSection}${attachSection}
50
54
  ## How to use tools
51
55
 
52
56
  To call ONE tool, respond with ONLY a JSON object (no markdown fences, no extra text):