@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 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 agent ↔ plan |
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.5.10",
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 === 'plan' ? 'plan' : flags.mode === 'agent' ? 'agent' : cfg.mode || 'agent',
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 uploaded = [];
307
- for (const file of attachments) {
308
- const abs = path.resolve(file);
309
- if (!fs.existsSync(abs)) {
310
- write(c.yellow(` ! missing attachment: ${file}`));
311
- continue;
312
- }
313
- const name = `attachments/${Date.now()}-${path.basename(abs)}`;
314
- const res = await ctx.bridge.upload(name, fs.readFileSync(abs));
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 task mode (default: ${c.gray('agent')})`);
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`);
@@ -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 === 'plan' ? 'plan' : cfg.mode || 'agent',
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 === 'agent' ? 'plan' : 'agent';
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 (agent / plan)', hint: 'tab', run: () => {
453
- this.state.mode = this.state.mode === 'agent' ? 'plan' : 'agent';
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
- let instruction = text;
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
- for (const ref of refs) {
597
- const abs = path.resolve(this.folder, ref);
598
- if (!fs.existsSync(abs)) continue;
599
- const name = `attachments/${Date.now()}-${path.basename(abs)}`;
600
- const up = await this.bridge.upload(name, fs.readFileSync(abs));
601
- if (up.status === 200) attachments.push(up.json.path);
602
- }
603
- if (attachments.length) {
604
- instruction += `\n\n[attachments in the workspace: ${attachments.join(', ')}]`;
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 === 'plan' ? 'Plan' : 'Build';
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
- return this.state.mode === 'plan' ? this.theme.c.accent : this.theme.c.secondary;
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 === 'plan' ? 'plan' : 'agents'],
1093
+ ['tab', modeLabel(st.mode).toLowerCase()],
1058
1094
  ['ctrl+p', 'commands'],
1059
1095
  ];
1060
1096
  const block = W2.promptBlock(t, {