@lardexx/arenacode 2.5.10 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -2
- package/package.json +2 -2
- package/src/attach.js +188 -0
- package/src/cli.js +13 -30
- package/src/clipboard.js +133 -0
- package/src/modes.js +55 -0
- package/src/tui/app.js +68 -32
package/README.md
CHANGED
|
@@ -106,16 +106,34 @@ How the copy is built and tested: [`docs/TUI.md`](docs/TUI.md).
|
|
|
106
106
|
|---|---|
|
|
107
107
|
| `enter` | send · `shift+enter` newline |
|
|
108
108
|
| `esc` | interrupt the turn · clear the input · quit |
|
|
109
|
-
| `tab` | switch
|
|
109
|
+
| `tab` | switch mode: Build → Plan → Computer |
|
|
110
110
|
| `ctrl+p` | command palette (`↑↓` select, `enter` run, `esc` close) |
|
|
111
111
|
| `/` | slash commands with an inline list above the prompt |
|
|
112
|
-
| `@file` | attach a file from the open folder |
|
|
112
|
+
| `@file` | attach a file from the open folder (uploaded to `uploads/`) |
|
|
113
|
+
| `@folder` | inline the folder listing in the task |
|
|
114
|
+
| `ctrl+v` | paste: a clipboard image becomes `images/clipboard-*.png` and is attached |
|
|
113
115
|
| `!command` | run a command locally |
|
|
114
116
|
| `↑` / `↓` | prompt history (or move through the slash list) |
|
|
115
117
|
| `pgup` / `pgdn` | scroll the transcript |
|
|
116
118
|
| `ctrl+x` then | `q` quit · `n` new session · `l` task queue · `t` theme · `d` tool output · `b` sidebar · `s` stop |
|
|
117
119
|
| `ctrl+c` | interrupt, then quit |
|
|
118
120
|
|
|
121
|
+
**The same four flows as the desktop client.** The Windows app is the reference for how a task reaches the bridge,
|
|
122
|
+
so the port reproduces it byte for byte:
|
|
123
|
+
|
|
124
|
+
- **Paste an image.** `ctrl+v` reads the clipboard image, saves it as `<folder>/images/clipboard-YYYYMMDD-HHMMSS.png`
|
|
125
|
+
(the desktop's naming), uploads it and lists it in the task, so the agent can open it with `read`.
|
|
126
|
+
- **Switch agent.** `tab` cycles the three modes of the desktop client — Build (`[ArenaCode mode: agent]`),
|
|
127
|
+
Plan (`[ArenaCode mode: plan]`) and Computer (`[ArenaCode mode: computer-agent]`) — and the first line of every
|
|
128
|
+
task is the desktop's own mode line, verbatim.
|
|
129
|
+
- **Send a message.** `POST /task` carries `instruction`, `meta`, `agent_prompt` and `project_context`, the same
|
|
130
|
+
body the desktop client builds.
|
|
131
|
+
- **Mention something.** `@file` is uploaded to `uploads/<timestamp>/` and named in `[attachments in the workspace: …]`;
|
|
132
|
+
`@folder` is inlined as `[folder contents … (n files, list only, not attached): …]`; missing paths outside the
|
|
133
|
+
folder are reported under `[paths on the PC: …]`. Messages follow the interface language (`it`, `en`, `es`).
|
|
134
|
+
|
|
135
|
+
The exact strings are checked by `test/parity.js` against the ones extracted from `ArenaCode-x64.exe`.
|
|
136
|
+
|
|
119
137
|
**Dialogs.** Local actions open opencode-shaped dialogs — panel background, title on the left, `esc` on the right,
|
|
120
138
|
the selected row painted in the primary colour, hints underneath: `Run once / Don't run` for terminal commands,
|
|
121
139
|
`Approve / Reject / Revise` for the agent's plan, and file operations reported back to the bridge. `--yes` skips the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lardexx/arenacode",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "Terminal client for ArenaCode: talks to an arena-bridge, streams the Agent Mode timeline, and runs local commands and file operations on your PC. Node port of the ArenaCode desktop client.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"LICENSE"
|
|
20
20
|
],
|
|
21
21
|
"scripts": {
|
|
22
|
-
"test": "node test/integration.js"
|
|
22
|
+
"test": "node test/integration.js && node test/parity.js"
|
|
23
23
|
},
|
|
24
24
|
"keywords": [
|
|
25
25
|
"arenacode",
|
package/src/attach.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { stamp } = require('./clipboard');
|
|
6
|
+
|
|
7
|
+
const SKIP_DIRS = new Set([
|
|
8
|
+
'node_modules',
|
|
9
|
+
'__pycache__',
|
|
10
|
+
'.git',
|
|
11
|
+
'.venv',
|
|
12
|
+
'.next',
|
|
13
|
+
'.cache',
|
|
14
|
+
'.idea',
|
|
15
|
+
'.vscode',
|
|
16
|
+
'dist',
|
|
17
|
+
'build',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']);
|
|
21
|
+
const MAX_FILES = 300;
|
|
22
|
+
const MAX_BYTES = 64 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
const TEXT = {
|
|
25
|
+
it: {
|
|
26
|
+
attachments: '[allegati nel workspace: ',
|
|
27
|
+
folder: '[contenuto della cartella ',
|
|
28
|
+
empty: '[cartella vuota: ',
|
|
29
|
+
other: ' (altri %d file, solo elenco, non allegati): ',
|
|
30
|
+
paths: '[percorsi sul PC: ',
|
|
31
|
+
tooMany: 'troppi file (max %d) o file oltre %d MB',
|
|
32
|
+
noImage: 'Immagine negli appunti non leggibile.',
|
|
33
|
+
pasted: 'immagine allegata',
|
|
34
|
+
},
|
|
35
|
+
en: {
|
|
36
|
+
attachments: '[attachments in the workspace: ',
|
|
37
|
+
folder: '[folder contents ',
|
|
38
|
+
empty: '[empty folder: ',
|
|
39
|
+
other: ' (%d files, list only, not attached): ',
|
|
40
|
+
paths: '[paths on the PC: ',
|
|
41
|
+
tooMany: 'too many files (max %d) or files over %d MB',
|
|
42
|
+
noImage: 'Clipboard image could not be read.',
|
|
43
|
+
pasted: 'image attached',
|
|
44
|
+
},
|
|
45
|
+
es: {
|
|
46
|
+
attachments: '[adjuntos en el espacio de trabajo: ',
|
|
47
|
+
folder: '[contenido de la carpeta ',
|
|
48
|
+
empty: '[carpeta vacía: ',
|
|
49
|
+
other: ' (%d archivos, solo lista, no adjuntos): ',
|
|
50
|
+
paths: '[rutas en el PC: ',
|
|
51
|
+
tooMany: 'demasiados archivos (máx %d) o archivos de más de %d MB',
|
|
52
|
+
noImage: 'La imagen del portapapeles no se pudo leer.',
|
|
53
|
+
pasted: 'imagen adjunta',
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function strings(lang) {
|
|
58
|
+
return TEXT[lang] || TEXT.en;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Lists the files of a folder the way the desktop client inlines them. */
|
|
62
|
+
function folderListing(folder, rel, lang) {
|
|
63
|
+
const s = strings(lang);
|
|
64
|
+
const root = path.resolve(folder, rel);
|
|
65
|
+
const names = [];
|
|
66
|
+
let truncated = false;
|
|
67
|
+
|
|
68
|
+
const walk = (dir, prefix, depth) => {
|
|
69
|
+
let entries;
|
|
70
|
+
try {
|
|
71
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
72
|
+
} catch {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
76
|
+
for (const e of entries) {
|
|
77
|
+
if (names.length >= MAX_FILES) {
|
|
78
|
+
truncated = true;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (e.name.startsWith('.') && depth === 0 && SKIP_DIRS.has(e.name)) continue;
|
|
82
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
83
|
+
const childRel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
84
|
+
if (e.isDirectory()) {
|
|
85
|
+
walk(path.join(dir, e.name), childRel, depth + 1);
|
|
86
|
+
} else {
|
|
87
|
+
names.push(childRel);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
walk(root, '', 0);
|
|
92
|
+
|
|
93
|
+
if (!names.length) return `${s.empty}${rel || '.'}]`;
|
|
94
|
+
const total = names.length + (truncated ? 1 : 0);
|
|
95
|
+
return `${s.folder}${rel || '.'}${s.other.replace('%d', total)}${names.join(', ')}${truncated ? ', …' : ''}]`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isInside(folder, abs) {
|
|
99
|
+
const rel = path.relative(folder, abs);
|
|
100
|
+
return rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function upload(bridge, folder, abs, name) {
|
|
104
|
+
const stat = fs.statSync(abs);
|
|
105
|
+
if (stat.size > MAX_BYTES) return { error: 'size' };
|
|
106
|
+
const dir = `uploads/${stamp()}`;
|
|
107
|
+
const remote = `${dir}/${name}`;
|
|
108
|
+
const res = await bridge.upload(remote, fs.readFileSync(abs));
|
|
109
|
+
if (res.status !== 200) return { error: `HTTP ${res.status}` };
|
|
110
|
+
return { remote: (res.json && res.json.path) || remote, size: stat.size };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Turns `@refs` into the same instruction blocks the desktop client produces:
|
|
115
|
+
* files land in the bridge workspace, folders are inlined as a listing.
|
|
116
|
+
*/
|
|
117
|
+
async function attachRefs(bridge, folder, refs, { lang = 'en' } = {}) {
|
|
118
|
+
const s = strings(lang);
|
|
119
|
+
const uploaded = [];
|
|
120
|
+
const blocks = [];
|
|
121
|
+
const paths = [];
|
|
122
|
+
const warnings = [];
|
|
123
|
+
|
|
124
|
+
for (const ref of refs) {
|
|
125
|
+
const abs = path.resolve(folder, ref);
|
|
126
|
+
let exists = true;
|
|
127
|
+
let stat = null;
|
|
128
|
+
try {
|
|
129
|
+
stat = fs.statSync(abs);
|
|
130
|
+
} catch {
|
|
131
|
+
exists = false;
|
|
132
|
+
}
|
|
133
|
+
if (!exists) {
|
|
134
|
+
if (/^[a-zA-Z]:[\\/]/.test(ref) || ref.startsWith('/')) paths.push(ref);
|
|
135
|
+
else warnings.push(`not found: ${ref}`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (stat.isDirectory()) {
|
|
139
|
+
blocks.push(folderListing(folder, ref, lang));
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const up = await upload(bridge, folder, abs, path.basename(abs));
|
|
143
|
+
if (up.error === 'size') {
|
|
144
|
+
warnings.push(s.tooMany.replace('%d', MAX_FILES).replace('%d', MAX_BYTES / (1024 * 1024)));
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (up.error) {
|
|
148
|
+
warnings.push(`upload failed: ${ref} (${up.error})`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
uploaded.push(up.remote);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { uploaded, blocks, paths, warnings, s };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Moves a clipboard PNG into <folder>/images and uploads it, like the desktop. */
|
|
158
|
+
async function attachImage(bridge, folder, tmpPng, { lang = 'en', name } = {}) {
|
|
159
|
+
const id = name || `clipboard-${stamp()}.png`;
|
|
160
|
+
const localDir = path.join(folder, 'images');
|
|
161
|
+
fs.mkdirSync(localDir, { recursive: true });
|
|
162
|
+
const local = path.join(localDir, id);
|
|
163
|
+
try {
|
|
164
|
+
fs.renameSync(tmpPng, local);
|
|
165
|
+
} catch {
|
|
166
|
+
fs.copyFileSync(tmpPng, local);
|
|
167
|
+
try {
|
|
168
|
+
fs.unlinkSync(tmpPng);
|
|
169
|
+
} catch {
|
|
170
|
+
/* ignore */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const up = await upload(bridge, folder, local, id);
|
|
174
|
+
if (up.error) return { error: up.error, local };
|
|
175
|
+
return { local, rel: path.relative(folder, local), remote: up.remote };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Appends the blocks the desktop appends to the instruction text. */
|
|
179
|
+
function decorate(text, { uploaded = [], blocks = [], paths = [], lang = 'en' } = {}) {
|
|
180
|
+
const s = strings(lang);
|
|
181
|
+
const extra = [...blocks];
|
|
182
|
+
if (uploaded.length) extra.push(`${s.attachments}${uploaded.join(', ')}]`);
|
|
183
|
+
if (paths.length) extra.push(`${s.paths}${paths.join(', ')}]`);
|
|
184
|
+
if (!extra.length) return text;
|
|
185
|
+
return `${text}\n\n${extra.join('\n\n')}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { attachRefs, attachImage, decorate, folderListing, strings, IMAGE_EXT, MAX_FILES, MAX_BYTES };
|
package/src/cli.js
CHANGED
|
@@ -9,6 +9,8 @@ const {
|
|
|
9
9
|
resolveUrl, resolveKey, openFolder,
|
|
10
10
|
} = require('./config');
|
|
11
11
|
const { Bridge } = require('./bridge');
|
|
12
|
+
const { MODE_LINES, MODES, normaliseMode } = require('./modes');
|
|
13
|
+
const { attachRefs, decorate } = require('./attach');
|
|
12
14
|
const { buildMeta, readProjectContext } = require('./meta');
|
|
13
15
|
const { followTask } = require('./turn');
|
|
14
16
|
const { c, write, ask, banner, truncate, indentLines } = require('./ui');
|
|
@@ -30,17 +32,6 @@ const INSTALL_HINT = process.platform === 'win32'
|
|
|
30
32
|
? 'npm install -g @lardexx/arenacode (then reopen the terminal)'
|
|
31
33
|
: 'npm install -g @lardexx/arenacode';
|
|
32
34
|
|
|
33
|
-
const MODE_LINES = {
|
|
34
|
-
agent:
|
|
35
|
-
'[ArenaCode mode: agent]\n' +
|
|
36
|
-
'AGENT MODE (not plan mode). Carry the task out directly: do not make a plan, do not send ui plan, ' +
|
|
37
|
-
'do not write a plan. This line overrides any plan instruction from earlier tasks or from the chat, ' +
|
|
38
|
-
'even if you remember one. Work, verify each change, and reply.',
|
|
39
|
-
plan:
|
|
40
|
-
'[ArenaCode mode: plan]\n' +
|
|
41
|
-
'PLAN MODE. Send the plan first with `ui plan`: the steps, the files involved, the risks and what you ' +
|
|
42
|
-
'will not do. Then stop and wait for the user to approve it before acting.',
|
|
43
|
-
};
|
|
44
35
|
|
|
45
36
|
// --------------------------------------------------------------------------- //
|
|
46
37
|
// argument parsing
|
|
@@ -104,7 +95,7 @@ function context(flags) {
|
|
|
104
95
|
folder,
|
|
105
96
|
device,
|
|
106
97
|
lang: flags.lang || cfg.lang || 'it',
|
|
107
|
-
mode: flags.mode
|
|
98
|
+
mode: normaliseMode(flags.mode || cfg.mode || 'agent'),
|
|
108
99
|
pcAccess: flags['no-pc'] ? false : cfg.pc_access !== false,
|
|
109
100
|
yes: Boolean(flags.yes),
|
|
110
101
|
json: Boolean(flags.json),
|
|
@@ -303,23 +294,15 @@ async function runInstruction(ctx, text, { attachments = [] } = {}) {
|
|
|
303
294
|
let instruction = `${MODE_LINES[ctx.mode] || MODE_LINES.agent}\n${text}`;
|
|
304
295
|
|
|
305
296
|
if (attachments.length) {
|
|
306
|
-
const
|
|
307
|
-
for (const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
if (res.status !== 200) {
|
|
316
|
-
write(c.yellow(` ! upload failed: ${file}`));
|
|
317
|
-
continue;
|
|
318
|
-
}
|
|
319
|
-
uploaded.push(res.json.path);
|
|
320
|
-
write(` ${c.green('↑')} ${c.gray(`${path.basename(abs)} → ${res.json.path} (${res.json.size} B)`)}`);
|
|
321
|
-
}
|
|
322
|
-
if (uploaded.length) instruction += `\n\n[attachments in the workspace: ${uploaded.join(', ')}]`;
|
|
297
|
+
const att = await attachRefs(ctx.bridge, ctx.folder, attachments, { lang: ctx.lang });
|
|
298
|
+
for (const w of att.warnings) write(c.yellow(` ! ${w}`));
|
|
299
|
+
for (const remote of att.uploaded) write(` ${c.green('↑')} ${c.gray(remote)}`);
|
|
300
|
+
instruction = decorate(instruction, {
|
|
301
|
+
uploaded: att.uploaded,
|
|
302
|
+
blocks: att.blocks,
|
|
303
|
+
paths: att.paths,
|
|
304
|
+
lang: ctx.lang,
|
|
305
|
+
});
|
|
323
306
|
}
|
|
324
307
|
|
|
325
308
|
let taskId = null;
|
|
@@ -756,7 +739,7 @@ function help() {
|
|
|
756
739
|
write(` ${c.cyan('options')}`);
|
|
757
740
|
write(` --folder <dir> open folder (the project the agent works on)`);
|
|
758
741
|
write(` --url <url> --key <key> override the saved bridge settings`);
|
|
759
|
-
write(` --mode agent|plan
|
|
742
|
+
write(` --mode agent|plan|computer-agent task mode (default: ${c.gray('agent')})`);
|
|
760
743
|
write(` --lang it|en|es language for instructions and cards`);
|
|
761
744
|
write(` --no-pc tell the agent the PC terminal is off`);
|
|
762
745
|
write(` --yes run local commands without asking`);
|
package/src/clipboard.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
|
|
7
|
+
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
8
|
+
|
|
9
|
+
// The desktop client stores a pasted screenshot as <folder>\images\clipboard-YYYYMMDD-HHMMSS.png
|
|
10
|
+
function stamp(d = new Date()) {
|
|
11
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
12
|
+
return (
|
|
13
|
+
`${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-` +
|
|
14
|
+
`${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isPng(buf) {
|
|
19
|
+
return Buffer.isBuffer(buf) && buf.length > 8 && buf.subarray(0, 8).equals(PNG_MAGIC);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Windows: System.Windows.Forms.Clipboard needs an STA thread, so run
|
|
23
|
+
// PowerShell with -STA. The image is written straight to `dest`.
|
|
24
|
+
function grabWindows(dest) {
|
|
25
|
+
const script = [
|
|
26
|
+
'Add-Type -AssemblyName System.Windows.Forms',
|
|
27
|
+
'Add-Type -AssemblyName System.Drawing',
|
|
28
|
+
'$img = [System.Windows.Forms.Clipboard]::GetImage()',
|
|
29
|
+
'if ($null -eq $img) { Write-Output "none"; exit 2 }',
|
|
30
|
+
`$img.Save('${dest.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png)`,
|
|
31
|
+
'$img.Dispose()',
|
|
32
|
+
'Write-Output "ok"',
|
|
33
|
+
].join('; ');
|
|
34
|
+
for (const exe of ['powershell', 'pwsh']) {
|
|
35
|
+
const r = spawnSync(exe, ['-NoProfile', '-NonInteractive', '-STA', '-Command', script], {
|
|
36
|
+
timeout: 20_000,
|
|
37
|
+
windowsHide: true,
|
|
38
|
+
});
|
|
39
|
+
if (r.error && r.error.code === 'ENOENT') continue;
|
|
40
|
+
if (r.status === 0 && fs.existsSync(dest) && fs.statSync(dest).size > 0) return true;
|
|
41
|
+
// No image on the clipboard, or no PowerShell: nothing to paste.
|
|
42
|
+
if (r.status === 2) return false;
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function grabUnix(dest) {
|
|
49
|
+
const attempts = [
|
|
50
|
+
['wl-paste', ['--no-newline', '--type', 'image/png']],
|
|
51
|
+
['xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']],
|
|
52
|
+
];
|
|
53
|
+
for (const [cmd, args] of attempts) {
|
|
54
|
+
const r = spawnSync(cmd, args, { timeout: 10_000, maxBuffer: 64 * 1024 * 1024 });
|
|
55
|
+
if (r.error) continue;
|
|
56
|
+
if (r.status === 0 && isPng(r.stdout)) {
|
|
57
|
+
fs.writeFileSync(dest, r.stdout);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Saves the image currently on the clipboard as a PNG in `dest`.
|
|
66
|
+
* Returns true when an image was written, false when the clipboard holds
|
|
67
|
+
* no image (or the platform has no clipboard access).
|
|
68
|
+
*/
|
|
69
|
+
function grabImage(dest) {
|
|
70
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
71
|
+
// test/automation hook: treat this file as the clipboard image
|
|
72
|
+
const hook = process.env.ARENACODE_CLIPBOARD_IMAGE;
|
|
73
|
+
if (hook) {
|
|
74
|
+
try {
|
|
75
|
+
if (fs.existsSync(hook)) {
|
|
76
|
+
fs.copyFileSync(hook, dest);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
/* ignore */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
if (process.platform === 'win32') return grabWindows(dest);
|
|
85
|
+
return grabUnix(dest);
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Text on the clipboard, or '' when there is none. */
|
|
92
|
+
function readClipboardText() {
|
|
93
|
+
try {
|
|
94
|
+
if (process.platform === 'win32') {
|
|
95
|
+
for (const exe of ['powershell', 'pwsh']) {
|
|
96
|
+
const r = spawnSync(exe, ['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'], {
|
|
97
|
+
timeout: 10_000,
|
|
98
|
+
encoding: 'utf8',
|
|
99
|
+
windowsHide: true,
|
|
100
|
+
});
|
|
101
|
+
if (r.error && r.error.code === 'ENOENT') continue;
|
|
102
|
+
return r.status === 0 && r.stdout ? r.stdout.replace(/\r\n/g, '\n').replace(/\n$/, '') : '';
|
|
103
|
+
}
|
|
104
|
+
return '';
|
|
105
|
+
}
|
|
106
|
+
for (const [cmd, args] of [
|
|
107
|
+
['wl-paste', ['--no-newline']],
|
|
108
|
+
['xclip', ['-selection', 'clipboard', '-o']],
|
|
109
|
+
]) {
|
|
110
|
+
const r = spawnSync(cmd, args, { timeout: 10_000, encoding: 'utf8' });
|
|
111
|
+
if (!r.error && r.status === 0) return r.stdout || '';
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
/* ignore */
|
|
115
|
+
}
|
|
116
|
+
return '';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** True when the clipboard looks like it holds an image (cheap check, no write). */
|
|
120
|
+
function hasImage() {
|
|
121
|
+
const tmp = path.join(require('os').tmpdir(), `arenacode-probe-${process.pid}-${Date.now()}.png`);
|
|
122
|
+
const ok = grabImage(tmp);
|
|
123
|
+
if (ok) {
|
|
124
|
+
try {
|
|
125
|
+
fs.unlinkSync(tmp);
|
|
126
|
+
} catch {
|
|
127
|
+
/* ignore */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return ok;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { grabImage, readClipboardText, hasImage, stamp, isPng };
|
package/src/modes.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Mode lines copied verbatim from the Windows client (ArenaCode-x64.exe, UTF-16
|
|
4
|
+
// string table). The first line of every task must be one of these: the agent
|
|
5
|
+
// treats it as the only valid mode for that task.
|
|
6
|
+
const MODE_LINES = {
|
|
7
|
+
agent:
|
|
8
|
+
'[ArenaCode mode: agent]\n' +
|
|
9
|
+
'AGENT MODE (not plan mode). Carry the task out directly: do not make a plan, do not send ui plan, ' +
|
|
10
|
+
'do not write a plan. This line overrides any plan instruction from earlier tasks or from the chat, ' +
|
|
11
|
+
'even if you remember one. Work, verify each change, and reply.',
|
|
12
|
+
plan:
|
|
13
|
+
'[ArenaCode mode: plan]\n' +
|
|
14
|
+
'PLAN ONLY until the plan is approved. Do not edit files, do not run commands and do not change ' +
|
|
15
|
+
'anything on the PC. Study the request and the project (read-only), then send a short numbered plan ' +
|
|
16
|
+
'with the command python3 bridge/agentctl.py ui plan <task id> --text "..." (steps, files involved, ' +
|
|
17
|
+
'risks, what you will not do). Wait for its result. If it says ok:true, the user approved: carry the ' +
|
|
18
|
+
'plan out now, in this same turn, step by step, and report what you changed. If it says revise:true ' +
|
|
19
|
+
'with feedback, the user wants a different plan: reword the plan taking the feedback into account and ' +
|
|
20
|
+
'send it again with ui plan, in this same turn. If it is refused or returns an error, do not change ' +
|
|
21
|
+
'anything and reply.',
|
|
22
|
+
'computer-agent': '[ArenaCode mode: computer-agent]',
|
|
23
|
+
'plan-approved':
|
|
24
|
+
'[ArenaCode mode: plan approved]\n' +
|
|
25
|
+
'The user approved the plan in your previous message. Carry it out now, step by step, following that ' +
|
|
26
|
+
'plan. At the end, report what you changed.',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// The three modes the desktop client can start a task in, in the order tab
|
|
30
|
+
// cycles through them.
|
|
31
|
+
const MODES = ['agent', 'plan', 'computer-agent'];
|
|
32
|
+
|
|
33
|
+
const MODE_LABELS = {
|
|
34
|
+
agent: 'Build',
|
|
35
|
+
plan: 'Plan',
|
|
36
|
+
'computer-agent': 'Computer',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function modeLabel(mode) {
|
|
40
|
+
return MODE_LABELS[mode] || MODE_LABELS.agent;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function nextMode(mode) {
|
|
44
|
+
const i = MODES.indexOf(mode);
|
|
45
|
+
return MODES[(i < 0 ? 0 : i + 1) % MODES.length];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normaliseMode(mode) {
|
|
49
|
+
const m = String(mode || '').toLowerCase();
|
|
50
|
+
if (m === 'computer' || m === 'computer-agent' || m === 'computer_agent') return 'computer-agent';
|
|
51
|
+
if (m === 'plan') return 'plan';
|
|
52
|
+
return 'agent';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { MODE_LINES, MODES, MODE_LABELS, modeLabel, nextMode, normaliseMode };
|
package/src/tui/app.js
CHANGED
|
@@ -17,6 +17,10 @@ const { buildMeta, readProjectContext } = require('../meta');
|
|
|
17
17
|
const { runShell, runFsOp, resolveInRoot } = require('../local');
|
|
18
18
|
const { readConfig, patchConfig, deviceId, resolveUrl, resolveKey, openFolder, VERSION } = require('../config');
|
|
19
19
|
|
|
20
|
+
const { MODE_LINES, modeLabel, nextMode, normaliseMode } = require('../modes');
|
|
21
|
+
const { attachRefs, attachImage, decorate, strings } = require('../attach');
|
|
22
|
+
const { grabImage, readClipboardText, stamp } = require('../clipboard');
|
|
23
|
+
|
|
20
24
|
const AGENT_PROMPT = (() => {
|
|
21
25
|
try {
|
|
22
26
|
return fs.readFileSync(path.join(__dirname, '..', 'prompts', 'agent-prompt.md'), 'utf8');
|
|
@@ -25,18 +29,6 @@ const AGENT_PROMPT = (() => {
|
|
|
25
29
|
}
|
|
26
30
|
})();
|
|
27
31
|
|
|
28
|
-
const MODE_LINES = {
|
|
29
|
-
agent:
|
|
30
|
-
'[ArenaCode mode: agent]\n' +
|
|
31
|
-
'AGENT MODE (not plan mode). Carry the task out directly: do not make a plan, do not send ui plan, ' +
|
|
32
|
-
'do not write a plan. This line overrides any plan instruction from earlier tasks or from the chat, ' +
|
|
33
|
-
'even if you remember one. Work, verify each change, and reply.',
|
|
34
|
-
plan:
|
|
35
|
-
'[ArenaCode mode: plan]\n' +
|
|
36
|
-
'PLAN MODE. Send the plan first with `ui plan`: the steps, the files involved, the risks and what you ' +
|
|
37
|
-
'will not do. Then stop and wait for the user to approve it before acting.',
|
|
38
|
-
};
|
|
39
|
-
|
|
40
32
|
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
41
33
|
|
|
42
34
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -56,11 +48,12 @@ class App {
|
|
|
56
48
|
this.confirmWrites = Boolean(opts.confirmWrites);
|
|
57
49
|
|
|
58
50
|
this.state = {
|
|
59
|
-
mode: opts.mode
|
|
51
|
+
mode: normaliseMode(opts.mode || cfg.mode || 'agent'),
|
|
60
52
|
status: 'idle',
|
|
61
53
|
details: true,
|
|
62
54
|
items: [],
|
|
63
55
|
seqItems: new Map(),
|
|
56
|
+
pendingImages: [],
|
|
64
57
|
input: '',
|
|
65
58
|
cursor: 0,
|
|
66
59
|
history: [],
|
|
@@ -224,9 +217,13 @@ class App {
|
|
|
224
217
|
this.invalidate();
|
|
225
218
|
return undefined;
|
|
226
219
|
}
|
|
220
|
+
if (key.ctrl && key.name === 'v') {
|
|
221
|
+
this.pasteClipboard();
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
227
224
|
if (key.name === 'tab') {
|
|
228
|
-
st.mode = st.mode
|
|
229
|
-
this.toast(`mode: ${st.mode}`);
|
|
225
|
+
st.mode = nextMode(st.mode);
|
|
226
|
+
this.toast(`mode: ${modeLabel(st.mode)}`);
|
|
230
227
|
return undefined;
|
|
231
228
|
}
|
|
232
229
|
if (key.name === 'f1') return this.openHelp();
|
|
@@ -449,9 +446,9 @@ class App {
|
|
|
449
446
|
this.state.details = !this.state.details;
|
|
450
447
|
this.toast(`details ${this.state.details ? 'on' : 'off'}`);
|
|
451
448
|
} },
|
|
452
|
-
{ title: 'Switch mode (
|
|
453
|
-
this.state.mode = this.state.mode
|
|
454
|
-
this.toast(`mode: ${this.state.mode}`);
|
|
449
|
+
{ title: 'Switch mode (Build / Plan / Computer)', hint: 'tab', run: () => {
|
|
450
|
+
this.state.mode = nextMode(this.state.mode);
|
|
451
|
+
this.toast(`mode: ${modeLabel(this.state.mode)}`);
|
|
455
452
|
} },
|
|
456
453
|
{ title: 'Theme', hint: 'ctrl+x t', run: () => {
|
|
457
454
|
const names = themeNames();
|
|
@@ -565,6 +562,42 @@ class App {
|
|
|
565
562
|
}
|
|
566
563
|
}
|
|
567
564
|
|
|
565
|
+
/** ctrl+v: a clipboard image becomes an attachment, otherwise text is pasted. */
|
|
566
|
+
async pasteClipboard() {
|
|
567
|
+
const st = this.state;
|
|
568
|
+
if (st.status === 'running') {
|
|
569
|
+
this.toast('a turn is running: press esc to stop', 'warn');
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (!this.url) {
|
|
573
|
+
this.toast('no bridge URL: run arenacode init', 'error');
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const tmp = path.join(require('os').tmpdir(), `arenacode-clip-${stamp()}-${process.pid}.png`);
|
|
577
|
+
if (grabImage(tmp)) {
|
|
578
|
+
const res = await attachImage(this.bridge, this.folder, tmp, { lang: this.lang });
|
|
579
|
+
if (res.error) {
|
|
580
|
+
this.toast(strings(this.lang).noImage, 'error');
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
st.pendingImages.push(res.remote);
|
|
584
|
+
this.push({ kind: 'system', text: `${strings(this.lang).pasted}: ${res.rel}` });
|
|
585
|
+
this.toast(`${strings(this.lang).pasted}: ${res.rel}`);
|
|
586
|
+
this.invalidate();
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const text = readClipboardText();
|
|
590
|
+
if (text) {
|
|
591
|
+
const before = st.input.slice(0, st.cursor);
|
|
592
|
+
const after = st.input.slice(st.cursor);
|
|
593
|
+
st.input = before + text + after;
|
|
594
|
+
st.cursor += text.length;
|
|
595
|
+
this.invalidate();
|
|
596
|
+
} else {
|
|
597
|
+
this.toast(strings(this.lang).noImage, 'warn');
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
568
601
|
// ---------------------------------------------------------------- turn
|
|
569
602
|
async submit(text) {
|
|
570
603
|
const st = this.state;
|
|
@@ -590,18 +623,19 @@ class App {
|
|
|
590
623
|
return;
|
|
591
624
|
}
|
|
592
625
|
|
|
593
|
-
|
|
594
|
-
const attachments = [];
|
|
626
|
+
// @file uploads the file, @folder inlines its listing, exactly like the desktop client
|
|
595
627
|
const refs = [...text.matchAll(/@([^\s]+)/g)].map((m) => m[1]);
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
const
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
628
|
+
let instruction = text;
|
|
629
|
+
if (refs.length || st.pendingImages.length) {
|
|
630
|
+
const att = await attachRefs(this.bridge, this.folder, refs, { lang: this.lang });
|
|
631
|
+
for (const w of att.warnings) this.toast(w, 'warn');
|
|
632
|
+
const images = st.pendingImages.splice(0, st.pendingImages.length);
|
|
633
|
+
instruction = decorate(text, {
|
|
634
|
+
uploaded: [...att.uploaded, ...images],
|
|
635
|
+
blocks: att.blocks,
|
|
636
|
+
paths: att.paths,
|
|
637
|
+
lang: this.lang,
|
|
638
|
+
});
|
|
605
639
|
}
|
|
606
640
|
|
|
607
641
|
this.push({ kind: 'user', text: text.replace(/@([^\s]+)/g, '$1') });
|
|
@@ -1032,12 +1066,14 @@ class App {
|
|
|
1032
1066
|
}
|
|
1033
1067
|
|
|
1034
1068
|
agentName() {
|
|
1035
|
-
return this.state.mode
|
|
1069
|
+
return modeLabel(this.state.mode);
|
|
1036
1070
|
}
|
|
1037
1071
|
|
|
1038
1072
|
agentColor() {
|
|
1039
1073
|
// opencode gives each agent its own colour; Build is the secondary blue, Plan the accent violet
|
|
1040
|
-
|
|
1074
|
+
if (this.state.mode === 'plan') return this.theme.c.accent;
|
|
1075
|
+
if (this.state.mode === 'computer-agent') return this.theme.c.success;
|
|
1076
|
+
return this.theme.c.secondary;
|
|
1041
1077
|
}
|
|
1042
1078
|
|
|
1043
1079
|
promptWidth(W) {
|
|
@@ -1054,7 +1090,7 @@ class App {
|
|
|
1054
1090
|
const hints = busy
|
|
1055
1091
|
? []
|
|
1056
1092
|
: [
|
|
1057
|
-
['tab', st.mode
|
|
1093
|
+
['tab', modeLabel(st.mode).toLowerCase()],
|
|
1058
1094
|
['ctrl+p', 'commands'],
|
|
1059
1095
|
];
|
|
1060
1096
|
const block = W2.promptBlock(t, {
|