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/README.md +21 -0
- package/dist/agent-loop.js +53 -2
- package/dist/attachments.js +332 -0
- package/dist/browser.js +99 -9
- package/dist/i18n.js +20 -0
- package/dist/index.js +164 -20
- package/dist/input.js +155 -6
- package/dist/system-prompt.js +6 -2
- package/dist/xml-toolcall.js +75 -0
- package/package.json +1 -1
- package/scripts/postinstall.mjs +78 -51
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:
|
|
530
|
-
//
|
|
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
|
-
|
|
588
|
+
pasteBuf += s;
|
|
554
589
|
s = '';
|
|
555
590
|
}
|
|
556
591
|
else {
|
|
557
|
-
|
|
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 = {
|
|
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.
|
|
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 = {
|
|
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.
|
|
909
|
-
|
|
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
|
-
|
|
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
|
|
410
|
-
if (!
|
|
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
|
|
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
|
-
|
|
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.
|
|
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();
|
package/dist/system-prompt.js
CHANGED
|
@@ -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):
|