@lardexx/arenacode 2.9.0 → 2.10.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 +33 -12
- package/package.json +2 -2
- package/src/automation/linux.js +2 -40
- package/src/automation/mac.js +657 -0
- package/src/automation/png.js +47 -0
- package/src/automation.js +20 -3
- package/src/cli.js +5 -2
- package/src/config.js +1 -0
- package/src/local.js +106 -11
- package/src/tui/app.js +40 -2
package/README.md
CHANGED
|
@@ -170,8 +170,15 @@ screenshots are `XGetImage` + `XGetPixel` encoded to PNG by the client itself (z
|
|
|
170
170
|
and the X events the server delivered are compared with the requested keycodes, chords, buttons and pixels. On Wayland
|
|
171
171
|
the XWayland display is used. `ui app launch` still starts the program's own `.desktop` command; nothing else is spawned.
|
|
172
172
|
|
|
173
|
-
**macOS
|
|
174
|
-
|
|
173
|
+
**macOS** is native too, again with no helper binaries: mouse with `CGEventCreateMouseEvent` + `CGEventPost`
|
|
174
|
+
(left/right/middle, double and triple click through the click-state field, drag interpolated), keyboard with
|
|
175
|
+
`CGEventCreateKeyboardEvent` plus modifier *flags* (`cmd`, `ctrl`, `alt`, `shift`) and Apple's fixed `kVK_*`
|
|
176
|
+
keycodes, typing through `CGEventKeyboardSetUnicodeString` so any character or emoji goes in without a layout,
|
|
177
|
+
windows from `CGWindowListCopyWindowInfo` (number, bounds, owner pid), screenshots from `CGDisplayCreateImage`
|
|
178
|
+
converted from BGRA to PNG by the client. `brew install cliclick` is gone. Only what CoreGraphics cannot do —
|
|
179
|
+
launch an app, bring a window to the front, resize it — still uses `osascript`, which ships with macOS. Grant the
|
|
180
|
+
terminal **Accessibility** (input) and **Screen Recording** (pixels) in System Settings → Privacy & Security.
|
|
181
|
+
On macOS/Linux the accessibility tree is not read: `ui state` returns windows and geometry as elements.
|
|
175
182
|
|
|
176
183
|
**Ask and Proceed / Proceed.** The desktop's Settings switch, `ctrl+x a` here (also `/ask`, `/proceed`,
|
|
177
184
|
`--proceed`, and `arenacode config set approval proceed`). The default, **Ask and Proceed**, asks before commands,
|
|
@@ -254,7 +261,7 @@ The agent never touches your PC directly: it asks, and this client shows a card.
|
|
|
254
261
|
|
|
255
262
|
| Card | Source | Executed here |
|
|
256
263
|
|---|---|---|
|
|
257
|
-
| **terminal** | `POST /task/<id>/local-exec` |
|
|
264
|
+
| **terminal** | `POST /task/<id>/local-exec` | your shell — zsh/bash/sh/fish on macOS and Linux, PowerShell/pwsh/cmd/WSL/Git Bash on Windows (see [Shells](#shells)) — asked before running unless `--yes` |
|
|
258
265
|
| **files** | `local-fs` | `ls`, `stat`, `read`, `write`, `replace`, `find`, `mkdir`, `mv`, `rm`, `pull`, `git_status`, `git_log`, `git_diff`, `git_commit`, `git_revert`, `git_publish` |
|
|
259
266
|
| **plan** | `local-ui` `plan` | approve / reject / ask for a revision, right in the terminal |
|
|
260
267
|
| **file from the agent** | `local_file` | downloaded from `GET /file?raw=1` into the open folder |
|
|
@@ -264,17 +271,30 @@ Every file path is resolved **inside the open folder**; paths that escape it are
|
|
|
264
271
|
the folder itself. Local operations run with your user's permissions — `--confirm-writes` adds a confirmation for
|
|
265
272
|
mutating file operations, `--yes` skips confirmations for terminal commands.
|
|
266
273
|
|
|
267
|
-
###
|
|
274
|
+
### Shells
|
|
268
275
|
|
|
269
|
-
|
|
270
|
-
`control` / `type` / `keystroke` / `screenshot` / `app` / `state` / `apps` / `multi_*`). A terminal cannot, so
|
|
271
|
-
those cards are answered with an explicit error instead of silently doing nothing:
|
|
276
|
+
Terminal cards run in a real shell, and which one is not fixed:
|
|
272
277
|
|
|
273
|
-
|
|
274
|
-
|
|
278
|
+
| Platform | Shells |
|
|
279
|
+
|---|---|
|
|
280
|
+
| **Windows** | `powershell` (default), `pwsh`, `cmd`, `wsl`, `bash` (Git Bash when installed, otherwise the WSL bash) |
|
|
281
|
+
| **macOS / Linux** | `zsh`, `bash`, `sh`, `fish`, `dash`, `ksh` — or any absolute path |
|
|
282
|
+
|
|
283
|
+
The order is: the shell the **bridge** names in the `local-exec` request → the shell **you pinned** →
|
|
284
|
+
your **login shell** (`$SHELL`, e.g. `zsh` on macOS, `bash` on most Linux boxes; PowerShell on Windows).
|
|
285
|
+
Pin it with any of:
|
|
286
|
+
|
|
287
|
+
```
|
|
288
|
+
arenacode --shell zsh
|
|
289
|
+
arenacode config set shell zsh
|
|
290
|
+
/shell # in the TUI: pick from the shells installed on this machine
|
|
275
291
|
```
|
|
276
292
|
|
|
277
|
-
|
|
293
|
+
`sysinfo` reports `shell` and the full `shells` list, and the `!command` prompt line in the TUI runs in the
|
|
294
|
+
same shell as the agent's cards. Commands are passed as a single argv entry (`zsh -lc '<command>'`,
|
|
295
|
+
`cmd.exe /d /s /c '<command>'`, `powershell.exe -NoProfile -NonInteractive -Command '<command>'`), so quotes,
|
|
296
|
+
`$variables` and pipes are never re-quoted on the way through. `test/shell.js` checks every mapping and runs
|
|
297
|
+
real commands through `sh`/`bash`.
|
|
278
298
|
|
|
279
299
|
## How it talks to the bridge
|
|
280
300
|
|
|
@@ -319,7 +339,8 @@ release it on the bridge with `keys unbind <ID>`).
|
|
|
319
339
|
"pc_access": true,
|
|
320
340
|
"device": "",
|
|
321
341
|
"vision": false,
|
|
322
|
-
"idle_timeout": 180
|
|
342
|
+
"idle_timeout": 180,
|
|
343
|
+
"shell": ""
|
|
323
344
|
}
|
|
324
345
|
```
|
|
325
346
|
|
|
@@ -328,7 +349,7 @@ Node client cannot, so the key sits in that file — keep it readable only by yo
|
|
|
328
349
|
(`arenacode keys` on the bridge side: `python3 bridge_server.py keys create "laptop CLI"`).
|
|
329
350
|
|
|
330
351
|
Environment variables override the file: `ARENACODE_URL`, `ARENACODE_KEY`, `ARENACODE_FOLDER`,
|
|
331
|
-
`ARENACODE_CONFIG`.
|
|
352
|
+
`ARENACODE_SHELL`, `ARENACODE_CONFIG`.
|
|
332
353
|
|
|
333
354
|
## Troubleshooting
|
|
334
355
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lardexx/arenacode",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.0",
|
|
4
4
|
"description": "Terminal client for ArenaCode: talks to an arena-bridge, streams the Agent Mode timeline, and runs local commands, file operations and computer automation on your PC (windows-use.dll on Windows, Xlib/XTest on Linux, osascript on macOS). Node port of the ArenaCode desktop client.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"vendor/"
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
|
-
"test": "node test/integration.js && node test/parity.js && node test/automation.js && node test/native.js"
|
|
23
|
+
"test": "node test/shell.js && node test/integration.js && node test/parity.js && node test/automation.js && node test/native.js"
|
|
24
24
|
},
|
|
25
25
|
"keywords": [
|
|
26
26
|
"arenacode",
|
package/src/automation/linux.js
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
const fs = require('node:fs');
|
|
8
8
|
const os = require('node:os');
|
|
9
9
|
const path = require('node:path');
|
|
10
|
-
const zlib = require('node:zlib');
|
|
11
10
|
const { spawn } = require('node:child_process');
|
|
12
11
|
const { lib, fn } = require('./ffi');
|
|
12
|
+
const { encodePng } = require('./png');
|
|
13
13
|
|
|
14
14
|
const DISPLAY = () => process.env.DISPLAY || ':0';
|
|
15
15
|
|
|
@@ -356,8 +356,7 @@ function pressKeys(combo) {
|
|
|
356
356
|
requireX();
|
|
357
357
|
const parts = String(combo || '')
|
|
358
358
|
.toLowerCase()
|
|
359
|
-
.split(
|
|
360
|
-
.map((p) => p.trim())
|
|
359
|
+
.split(/[\s+]+/)
|
|
361
360
|
.filter(Boolean);
|
|
362
361
|
if (!parts.length) return { ok: false, error: `unreadable key combination: ${combo}` };
|
|
363
362
|
let repeat = 1;
|
|
@@ -553,43 +552,6 @@ function resizeWindow(handle, x, y, width, height) {
|
|
|
553
552
|
|
|
554
553
|
// ---------------------------------------------------------------- screenshot
|
|
555
554
|
|
|
556
|
-
function crc32(buf) {
|
|
557
|
-
let c = 0xffffffff;
|
|
558
|
-
for (let i = 0; i < buf.length; i += 1) {
|
|
559
|
-
c ^= buf[i];
|
|
560
|
-
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
561
|
-
}
|
|
562
|
-
return (c ^ 0xffffffff) >>> 0;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
function pngChunk(type, data) {
|
|
566
|
-
const len = Buffer.alloc(4);
|
|
567
|
-
len.writeUInt32BE(data.length);
|
|
568
|
-
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
|
569
|
-
const crc = Buffer.alloc(4);
|
|
570
|
-
crc.writeUInt32BE(crc32(body));
|
|
571
|
-
return Buffer.concat([len, body, crc]);
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
function encodePng(rgb, width, height) {
|
|
575
|
-
const raw = Buffer.alloc((width * 3 + 1) * height);
|
|
576
|
-
for (let y = 0; y < height; y += 1) {
|
|
577
|
-
raw[y * (width * 3 + 1)] = 0;
|
|
578
|
-
rgb.copy(raw, y * (width * 3 + 1) + 1, y * width * 3, (y + 1) * width * 3);
|
|
579
|
-
}
|
|
580
|
-
const ihdr = Buffer.alloc(13);
|
|
581
|
-
ihdr.writeUInt32BE(width, 0);
|
|
582
|
-
ihdr.writeUInt32BE(height, 4);
|
|
583
|
-
ihdr[8] = 8;
|
|
584
|
-
ihdr[9] = 2;
|
|
585
|
-
return Buffer.concat([
|
|
586
|
-
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
587
|
-
pngChunk('IHDR', ihdr),
|
|
588
|
-
pngChunk('IDAT', zlib.deflateSync(raw, { level: 6 })),
|
|
589
|
-
pngChunk('IEND', Buffer.alloc(0)),
|
|
590
|
-
]);
|
|
591
|
-
}
|
|
592
|
-
|
|
593
555
|
function screenshot(dest) {
|
|
594
556
|
openDisplay();
|
|
595
557
|
const XGetImage = fn(
|
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// macOS backend: CoreGraphics through koffi. Same idea as the Linux one — no helper
|
|
4
|
+
// binaries, no `brew install cliclick`: the mouse is CGEventCreateMouseEvent + CGEventPost,
|
|
5
|
+
// the keyboard is CGEventCreateKeyboardEvent (chords) and CGEventKeyboardSetUnicodeString
|
|
6
|
+
// (any character, emoji included), windows come from CGWindowListCopyWindowInfo and the
|
|
7
|
+
// screenshot from CGDisplayCreateImage. Only what CoreGraphics cannot do at all —
|
|
8
|
+
// launching an app, bringing a window to the front, resizing it — still goes through
|
|
9
|
+
// `osascript`, which ships with macOS.
|
|
10
|
+
//
|
|
11
|
+
// Permissions: posting events needs Accessibility for the terminal (System Settings →
|
|
12
|
+
// Privacy & Security → Accessibility); window titles and pixels need Screen Recording.
|
|
13
|
+
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
const { spawnSync } = require('node:child_process');
|
|
18
|
+
const { fn, lib } = require('./ffi');
|
|
19
|
+
const { encodePng } = require('./png');
|
|
20
|
+
|
|
21
|
+
const CG = '/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics';
|
|
22
|
+
const CF = '/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation';
|
|
23
|
+
|
|
24
|
+
const K_CG_HID_EVENT_TAP = 0;
|
|
25
|
+
const K_CG_SESSION_EVENT_TAP = 1;
|
|
26
|
+
|
|
27
|
+
const EVENT = {
|
|
28
|
+
leftDown: 1,
|
|
29
|
+
leftUp: 2,
|
|
30
|
+
rightDown: 3,
|
|
31
|
+
rightUp: 4,
|
|
32
|
+
mouseMoved: 5,
|
|
33
|
+
leftDragged: 6,
|
|
34
|
+
rightDragged: 7,
|
|
35
|
+
keyDown: 10,
|
|
36
|
+
keyUp: 11,
|
|
37
|
+
scrollWheel: 22,
|
|
38
|
+
};
|
|
39
|
+
const FIELD_CLICK_STATE = 1;
|
|
40
|
+
|
|
41
|
+
const FLAG = {
|
|
42
|
+
shift: 0x20000,
|
|
43
|
+
control: 0x40000,
|
|
44
|
+
option: 0x80000,
|
|
45
|
+
command: 0x100000,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// kVK_* — fixed virtual keycodes; they do not depend on the keyboard layout.
|
|
49
|
+
const VK = {
|
|
50
|
+
a: 0, s: 1, d: 2, f: 3, h: 4, g: 5, z: 6, x: 7, c: 8, v: 9,
|
|
51
|
+
b: 11, q: 12, w: 13, e: 14, r: 15, y: 16, t: 17,
|
|
52
|
+
1: 18, 2: 19, 3: 20, 4: 21, 6: 22, 5: 23, equal: 24, 9: 25, 7: 26, minus: 27,
|
|
53
|
+
8: 28, 0: 29, bracketright: 30, o: 31, u: 32, bracketleft: 33, i: 34, p: 35,
|
|
54
|
+
return: 36, l: 37, j: 38, quote: 39, k: 40, semicolon: 41, backslash: 42,
|
|
55
|
+
comma: 43, slash: 44, n: 45, m: 46, period: 47, tab: 48, space: 49, grave: 50,
|
|
56
|
+
backspace: 51, escape: 53, command: 55, shift: 56, capslock: 57, option: 58,
|
|
57
|
+
control: 59, rightshift: 60, rightoption: 61, rightcontrol: 62,
|
|
58
|
+
help: 114, home: 115, pageup: 116, delete: 117, end: 119, pagedown: 121,
|
|
59
|
+
left: 123, right: 124, down: 125, up: 126,
|
|
60
|
+
f1: 122, f2: 120, f3: 99, f4: 118, f5: 96, f6: 97, f7: 98, f8: 100,
|
|
61
|
+
f9: 101, f10: 109, f11: 103, f12: 111,
|
|
62
|
+
enter: 76,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const MODIFIERS = {
|
|
66
|
+
cmd: FLAG.command, command: FLAG.command, meta: FLAG.command, win: FLAG.command, super: FLAG.command,
|
|
67
|
+
ctrl: FLAG.control, control: FLAG.control,
|
|
68
|
+
alt: FLAG.option, option: FLAG.option, opt: FLAG.option,
|
|
69
|
+
shift: FLAG.shift,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Names that AppleScript/linux-style tasks use, mapped to the kVK_* table.
|
|
73
|
+
const ALIASES = {
|
|
74
|
+
esc: 'escape', delete: 'backspace', backspace: 'backspace', del: 'delete',
|
|
75
|
+
arrowup: 'up', arrowdown: 'down', arrowleft: 'left', arrowright: 'right',
|
|
76
|
+
up: 'up', down: 'down', left: 'left', right: 'right',
|
|
77
|
+
pgup: 'pageup', pgdn: 'pagedown', page_up: 'pageup', page_down: 'pagedown',
|
|
78
|
+
home: 'home', end: 'end', space: 'space', spacebar: 'space',
|
|
79
|
+
return: 'return', enter: 'return', tab: 'tab', capslock: 'capslock',
|
|
80
|
+
'[': 'bracketleft', ']': 'bracketright', ';': 'semicolon', '=': 'equal',
|
|
81
|
+
'-': 'minus', '`': 'grave', '\\': 'backslash', ',': 'comma', '.': 'period',
|
|
82
|
+
'/': 'slash', "'": 'quote',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
let bound = null;
|
|
86
|
+
|
|
87
|
+
function bindings() {
|
|
88
|
+
if (bound) return bound;
|
|
89
|
+
if (!lib(CG) || !lib(CF)) throw new Error('CoreGraphics not available (not macOS?)');
|
|
90
|
+
bound = {
|
|
91
|
+
CGMainDisplayID: fn(CG, 'CGMainDisplayID', 'unsigned int CGMainDisplayID(void)'),
|
|
92
|
+
CGDisplayPixelsWide: fn(CG, 'CGDisplayPixelsWide', 'unsigned long CGDisplayPixelsWide(unsigned int display)'),
|
|
93
|
+
CGDisplayPixelsHigh: fn(CG, 'CGDisplayPixelsHigh', 'unsigned long CGDisplayPixelsHigh(unsigned int display)'),
|
|
94
|
+
CGEventCreateMouseEvent: fn(
|
|
95
|
+
CG,
|
|
96
|
+
'CGEventCreateMouseEvent',
|
|
97
|
+
'void* CGEventCreateMouseEvent(void* source, int mouseType, double x, double y, int mouseButton)'
|
|
98
|
+
),
|
|
99
|
+
CGEventCreateKeyboardEvent: fn(
|
|
100
|
+
CG,
|
|
101
|
+
'CGEventCreateKeyboardEvent',
|
|
102
|
+
'void* CGEventCreateKeyboardEvent(void* source, unsigned short virtualKey, bool keyDown)'
|
|
103
|
+
),
|
|
104
|
+
CGEventCreateScrollWheelEvent2: fn(
|
|
105
|
+
CG,
|
|
106
|
+
'CGEventCreateScrollWheelEvent2',
|
|
107
|
+
'void* CGEventCreateScrollWheelEvent2(void* source, int units, unsigned int wheelCount, int wheel1, int wheel2, int wheel3)'
|
|
108
|
+
),
|
|
109
|
+
CGEventPost: fn(CG, 'CGEventPost', 'void CGEventPost(int tap, void* event)'),
|
|
110
|
+
CGEventSetFlags: fn(CG, 'CGEventSetFlags', 'void CGEventSetFlags(void* event, unsigned long long flags)'),
|
|
111
|
+
CGEventSetIntegerValueField: fn(
|
|
112
|
+
CG,
|
|
113
|
+
'CGEventSetIntegerValueField',
|
|
114
|
+
'void CGEventSetIntegerValueField(void* event, int field, long long value)'
|
|
115
|
+
),
|
|
116
|
+
CGEventKeyboardSetUnicodeString: fn(
|
|
117
|
+
CG,
|
|
118
|
+
'CGEventKeyboardSetUnicodeString',
|
|
119
|
+
'void CGEventKeyboardSetUnicodeString(void* event, unsigned long length, void* unicodeString)'
|
|
120
|
+
),
|
|
121
|
+
CGWindowListCopyWindowInfo: fn(
|
|
122
|
+
CG,
|
|
123
|
+
'CGWindowListCopyWindowInfo',
|
|
124
|
+
'void* CGWindowListCopyWindowInfo(unsigned int option, unsigned int relativeToWindow)'
|
|
125
|
+
),
|
|
126
|
+
CGDisplayCreateImage: fn(CG, 'CGDisplayCreateImage', 'void* CGDisplayCreateImage(unsigned int display)'),
|
|
127
|
+
CGImageGetWidth: fn(CG, 'CGImageGetWidth', 'unsigned long CGImageGetWidth(void* image)'),
|
|
128
|
+
CGImageGetHeight: fn(CG, 'CGImageGetHeight', 'unsigned long CGImageGetHeight(void* image)'),
|
|
129
|
+
CGImageGetBytesPerRow: fn(CG, 'CGImageGetBytesPerRow', 'unsigned long CGImageGetBytesPerRow(void* image)'),
|
|
130
|
+
CGImageGetBitsPerPixel: fn(CG, 'CGImageGetBitsPerPixel', 'unsigned long CGImageGetBitsPerPixel(void* image)'),
|
|
131
|
+
CGImageGetDataProvider: fn(CG, 'CGImageGetDataProvider', 'void* CGImageGetDataProvider(void* image)'),
|
|
132
|
+
CFDataGetBytePtr: fn(CF, 'CFDataGetBytePtr', 'const unsigned char* CFDataGetBytePtr(void* data)'),
|
|
133
|
+
CFDataGetLength: fn(CF, 'CFDataGetLength', 'long CFDataGetLength(void* data)'),
|
|
134
|
+
CFDataGetBytes: fn(CF, 'CFDataGetBytes', 'void CFDataGetBytes(void* data, void* buffer, unsigned long length)'),
|
|
135
|
+
CGDataProviderCopyData: fn(CF, 'CGDataProviderCopyData', 'void* CGDataProviderCopyData(void* provider)'),
|
|
136
|
+
CFRelease: fn(CF, 'CFRelease', 'void CFRelease(void* ref)'),
|
|
137
|
+
// CF containers are only aliases of the CoreFoundation types we read windows with
|
|
138
|
+
CFArrayGetCount: fn(CF, 'CFArrayGetCount', 'long CFArrayGetCount(void* array)'),
|
|
139
|
+
CFArrayGetValueAtIndex: fn(CF, 'CFArrayGetValueAtIndex', 'void* CFArrayGetValueAtIndex(void* array, long index)'),
|
|
140
|
+
CFDictionaryGetValue: fn(CF, 'CFDictionaryGetValue', 'void* CFDictionaryGetValue(void* dict, void* key)'),
|
|
141
|
+
CFStringCreateWithCString: fn(
|
|
142
|
+
CF,
|
|
143
|
+
'CFStringCreateWithCString',
|
|
144
|
+
'void* CFStringCreateWithCString(void* allocator, const char* cString, unsigned int encoding)'
|
|
145
|
+
),
|
|
146
|
+
CFStringGetCString: fn(
|
|
147
|
+
CF,
|
|
148
|
+
'CFStringGetCString',
|
|
149
|
+
'bool CFStringGetCString(void* string, void* buffer, long bufferSize, unsigned int encoding)'
|
|
150
|
+
),
|
|
151
|
+
CFStringGetLength: fn(CF, 'CFStringGetLength', 'long CFStringGetLength(void* string)'),
|
|
152
|
+
CFNumberGetValue: fn(CF, 'CFNumberGetValue', 'bool CFNumberGetValue(void* number, int type, void* valuePtr)'),
|
|
153
|
+
CFBooleanGetValue: fn(CF, 'CFBooleanGetValue', 'bool CFBooleanGetValue(void* boolean)'),
|
|
154
|
+
};
|
|
155
|
+
return bound;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function requireCG() {
|
|
159
|
+
if (process.platform !== 'darwin') throw new Error('the macOS backend runs on macOS only');
|
|
160
|
+
return bindings();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const K_CF_STRING_ENCODING_UTF8 = 0x08000100;
|
|
164
|
+
|
|
165
|
+
function cfString(text) {
|
|
166
|
+
const b = bindings();
|
|
167
|
+
return b.CFStringCreateWithCString(null, String(text), K_CF_STRING_ENCODING_UTF8);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function cfToString(ref) {
|
|
171
|
+
if (!ref) return '';
|
|
172
|
+
const koffi = require('koffi');
|
|
173
|
+
const b = bindings();
|
|
174
|
+
const length = Number(b.CFStringGetLength(ref));
|
|
175
|
+
if (!Number.isFinite(length) || length < 0) return '';
|
|
176
|
+
const size = Math.max(16, length * 4 + 1);
|
|
177
|
+
const buffer = koffi.alloc('uint8_t', size);
|
|
178
|
+
if (!b.CFStringGetCString(ref, buffer, size, K_CF_STRING_ENCODING_UTF8)) return '';
|
|
179
|
+
return String(koffi.decode(buffer, 'uint8_t', size)).split('\0')[0];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function cfNumber(ref) {
|
|
183
|
+
if (!ref) return 0;
|
|
184
|
+
const koffi = require('koffi');
|
|
185
|
+
const out = koffi.alloc('double', 1);
|
|
186
|
+
const b = bindings();
|
|
187
|
+
// kCFNumberDoubleType = 13, kCFNumberSInt64Type = 4
|
|
188
|
+
if (b.CFNumberGetValue(ref, 13, out)) return Number(koffi.decode(out, 'double', 1));
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Reads a key out of the CGWindowList dictionaries. */
|
|
193
|
+
function dictValue(dict, key, stringKey) {
|
|
194
|
+
const b = bindings();
|
|
195
|
+
const name = cfString(key);
|
|
196
|
+
let raw = null;
|
|
197
|
+
try {
|
|
198
|
+
raw = b.CFDictionaryGetValue(dict, name);
|
|
199
|
+
if (!raw) return stringKey ? '' : 0;
|
|
200
|
+
return stringKey ? cfToString(raw) : cfNumber(raw);
|
|
201
|
+
} finally {
|
|
202
|
+
if (name) b.CFRelease(name);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
207
|
+
|
|
208
|
+
// ------------------------------------------------------------------- input --
|
|
209
|
+
|
|
210
|
+
function post(event, tap = K_CG_HID_EVENT_TAP) {
|
|
211
|
+
const b = bindings();
|
|
212
|
+
b.CGEventPost(tap, event);
|
|
213
|
+
if (event) b.CFRelease(event);
|
|
214
|
+
// let the window server digest the event before the next one
|
|
215
|
+
sleep(2);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function moveMouse(x, y) {
|
|
219
|
+
const b = requireCG();
|
|
220
|
+
const event = b.CGEventCreateMouseEvent(null, EVENT.mouseMoved, Number(x), Number(y), 0);
|
|
221
|
+
if (!event) return { ok: false, error: 'CGEventCreateMouseEvent failed' };
|
|
222
|
+
post(event);
|
|
223
|
+
return { ok: true, x, y };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function buttonEvent(type, x, y, button = 'left') {
|
|
227
|
+
const b = bindings();
|
|
228
|
+
const which = button === 'right' ? 1 : button === 'middle' ? 2 : 0;
|
|
229
|
+
return b.CGEventCreateMouseEvent(null, type, Number(x), Number(y), which);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function click(x, y, { button = 'left', count = 1 } = {}) {
|
|
233
|
+
const b = requireCG();
|
|
234
|
+
const clicks = Math.max(1, Math.trunc(count) || 1);
|
|
235
|
+
const down = button === 'right' ? EVENT.rightDown : EVENT.leftDown;
|
|
236
|
+
const up = button === 'right' ? EVENT.rightUp : EVENT.leftUp;
|
|
237
|
+
moveMouse(x, y);
|
|
238
|
+
for (let i = 1; i <= Math.min(3, clicks); i += 1) {
|
|
239
|
+
const press = buttonEvent(down, x, y, button);
|
|
240
|
+
if (press) {
|
|
241
|
+
b.CGEventSetIntegerValueField(press, FIELD_CLICK_STATE, i);
|
|
242
|
+
post(press);
|
|
243
|
+
}
|
|
244
|
+
const release = buttonEvent(up, x, y, button);
|
|
245
|
+
if (release) {
|
|
246
|
+
b.CGEventSetIntegerValueField(release, FIELD_CLICK_STATE, i);
|
|
247
|
+
post(release);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return { ok: true, x, y, button, count: clicks };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function mouseButton(x, y, button, down) {
|
|
254
|
+
const b = requireCG();
|
|
255
|
+
moveMouse(x, y);
|
|
256
|
+
const type = button === 'right' ? (down ? EVENT.rightDown : EVENT.rightUp) : down ? EVENT.leftDown : EVENT.leftUp;
|
|
257
|
+
const event = buttonEvent(type, x, y, button);
|
|
258
|
+
if (!event) return { ok: false, error: 'CGEventCreateMouseEvent failed' };
|
|
259
|
+
post(event);
|
|
260
|
+
return { ok: true, x, y, button };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function drag(x, y, x2, y2) {
|
|
264
|
+
const b = requireCG();
|
|
265
|
+
const downEvent = buttonEvent(EVENT.leftDown, x, y, 'left');
|
|
266
|
+
if (!downEvent) return { ok: false, error: 'CGEventCreateMouseEvent failed' };
|
|
267
|
+
post(downEvent);
|
|
268
|
+
const steps = 12;
|
|
269
|
+
for (let i = 1; i <= steps; i += 1) {
|
|
270
|
+
const px = Math.round(x + ((x2 - x) * i) / steps);
|
|
271
|
+
const py = Math.round(y + ((y2 - y) * i) / steps);
|
|
272
|
+
const moved = b.CGEventCreateMouseEvent(null, EVENT.leftDragged, px, py, 0);
|
|
273
|
+
if (moved) post(moved);
|
|
274
|
+
}
|
|
275
|
+
const upEvent = buttonEvent(EVENT.leftUp, x2, y2, 'left');
|
|
276
|
+
if (upEvent) post(upEvent);
|
|
277
|
+
const released = b.CGEventCreateMouseEvent(null, EVENT.mouseMoved, x2, y2, 0);
|
|
278
|
+
if (released) post(released);
|
|
279
|
+
return { ok: true, from: [x, y], to: [x2, y2] };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function scroll(amount, x = null, y = null) {
|
|
283
|
+
const b = requireCG();
|
|
284
|
+
if (x !== null && y !== null) moveMouse(x, y);
|
|
285
|
+
const lines = Math.max(1, Math.abs(Math.trunc(amount) || 1));
|
|
286
|
+
const signed = (Math.trunc(amount) || 1) > 0 ? lines : -lines;
|
|
287
|
+
const event = b.CGEventCreateScrollWheelEvent2(null, 1 /* line */, 1, signed, 0, 0);
|
|
288
|
+
if (!event) return { ok: false, error: 'CGEventCreateScrollWheelEvent2 failed' };
|
|
289
|
+
post(event);
|
|
290
|
+
return { ok: true, amount };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Types text as unicode: layout-independent, works for é, ü, 中文, emoji. */
|
|
294
|
+
function typeText(text, x = null, y = null) {
|
|
295
|
+
const b = requireCG();
|
|
296
|
+
if (x !== null && y !== null) click(x, y);
|
|
297
|
+
const value = String(text || '');
|
|
298
|
+
let typed = 0;
|
|
299
|
+
for (const ch of value) {
|
|
300
|
+
const utf16 = Buffer.from(ch, 'utf16le');
|
|
301
|
+
const length = utf16.length / 2;
|
|
302
|
+
const down = b.CGEventCreateKeyboardEvent(null, 0, true);
|
|
303
|
+
const up = b.CGEventCreateKeyboardEvent(null, 0, false);
|
|
304
|
+
if (!down || !up) return { ok: false, error: 'CGEventCreateKeyboardEvent failed' };
|
|
305
|
+
const koffi = require('koffi');
|
|
306
|
+
const buffer = koffi.alloc('uint16_t', length);
|
|
307
|
+
koffi.encode(buffer, koffi.array('uint16_t', length), [...Array(length).keys()].map((i) => utf16.readUInt16LE(i * 2)));
|
|
308
|
+
b.CGEventKeyboardSetUnicodeString(down, length, buffer);
|
|
309
|
+
b.CGEventKeyboardSetUnicodeString(up, length, buffer);
|
|
310
|
+
post(down);
|
|
311
|
+
post(up);
|
|
312
|
+
typed += 1;
|
|
313
|
+
}
|
|
314
|
+
return { ok: true, chars: value.length, typed };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function keycodeFor(name) {
|
|
318
|
+
const key = String(name || '').toLowerCase();
|
|
319
|
+
const alias = ALIASES[key] || key;
|
|
320
|
+
if (alias in VK) return VK[alias];
|
|
321
|
+
if (key.length === 1) {
|
|
322
|
+
const lower = key.toLowerCase();
|
|
323
|
+
if (lower in VK) return VK[lower];
|
|
324
|
+
}
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* `ctrl+shift+t`, `cmd+space`, `alt+f4 x2` → { flags, keys, repeat }. Pure, so it is
|
|
330
|
+
* checked on every platform by test/native.js.
|
|
331
|
+
*/
|
|
332
|
+
function parseChord(combo) {
|
|
333
|
+
const parts = String(combo || '')
|
|
334
|
+
.toLowerCase()
|
|
335
|
+
.split(/[\s+]+/)
|
|
336
|
+
.filter(Boolean);
|
|
337
|
+
if (!parts.length) return { ok: false, error: `unreadable key combination: ${combo}` };
|
|
338
|
+
let repeat = 1;
|
|
339
|
+
let flags = 0;
|
|
340
|
+
const keys = [];
|
|
341
|
+
const modifiers = [];
|
|
342
|
+
for (const part of parts) {
|
|
343
|
+
if (part === 'repeat') {
|
|
344
|
+
repeat = 2;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (/^x\d+$/.test(part)) {
|
|
348
|
+
repeat = Number(part.slice(1));
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (MODIFIERS[part] !== undefined) {
|
|
352
|
+
flags |= MODIFIERS[part];
|
|
353
|
+
modifiers.push(part);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const code = keycodeFor(part);
|
|
357
|
+
if (code !== null) keys.push(code);
|
|
358
|
+
}
|
|
359
|
+
if (!keys.length && !modifiers.length) return { ok: false, error: `unreadable key combination: ${combo}` };
|
|
360
|
+
if (!keys.length) keys.push(VK.space); // a bare modifier: press it
|
|
361
|
+
return { ok: true, flags, keys, modifiers, repeat: Math.min(50, Math.max(1, repeat)) };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function pressKeys(combo) {
|
|
365
|
+
const b = requireCG();
|
|
366
|
+
const chord = parseChord(combo);
|
|
367
|
+
if (!chord.ok) return chord;
|
|
368
|
+
const { flags, keys, repeat } = chord;
|
|
369
|
+
for (let i = 0; i < repeat; i += 1) {
|
|
370
|
+
for (const code of keys) {
|
|
371
|
+
const down = b.CGEventCreateKeyboardEvent(null, code, true);
|
|
372
|
+
const up = b.CGEventCreateKeyboardEvent(null, code, false);
|
|
373
|
+
if (!down || !up) return { ok: false, error: 'CGEventCreateKeyboardEvent failed' };
|
|
374
|
+
if (flags) {
|
|
375
|
+
b.CGEventSetFlags(down, flags);
|
|
376
|
+
b.CGEventSetFlags(up, flags);
|
|
377
|
+
}
|
|
378
|
+
post(down);
|
|
379
|
+
post(up);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return { ok: true, keys: combo };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ------------------------------------------------------------------ screen --
|
|
386
|
+
|
|
387
|
+
function screenSize() {
|
|
388
|
+
const b = requireCG();
|
|
389
|
+
const display = b.CGMainDisplayID();
|
|
390
|
+
return { ok: true, width: Number(b.CGDisplayPixelsWide(display)), height: Number(b.CGDisplayPixelsHigh(display)) };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function windows() {
|
|
394
|
+
const b = requireCG();
|
|
395
|
+
const options = 1 | 16; // kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements
|
|
396
|
+
const list = b.CGWindowListCopyWindowInfo(options, 0);
|
|
397
|
+
if (!list) return { ok: true, windows: [] };
|
|
398
|
+
const out = [];
|
|
399
|
+
try {
|
|
400
|
+
const count = Number(b.CFArrayGetCount(list));
|
|
401
|
+
for (let i = 0; i < count; i += 1) {
|
|
402
|
+
const dict = b.CFArrayGetValueAtIndex(list, i);
|
|
403
|
+
if (!dict) continue;
|
|
404
|
+
const name = dictValue(dict, 'kCGWindowName', true) || dictValue(dict, 'kCGWindowOwnerName', true);
|
|
405
|
+
if (!name) continue;
|
|
406
|
+
const layer = dictValue(dict, 'kCGWindowLayer', false);
|
|
407
|
+
if (layer !== 0) continue; // menu bar, dock, overlays
|
|
408
|
+
// kCGWindowBounds is a nested dictionary with X/Y/Width/Height numbers
|
|
409
|
+
const boundsRef = (() => {
|
|
410
|
+
const key = cfString('kCGWindowBounds');
|
|
411
|
+
try {
|
|
412
|
+
return b.CFDictionaryGetValue(dict, key);
|
|
413
|
+
} finally {
|
|
414
|
+
if (key) b.CFRelease(key);
|
|
415
|
+
}
|
|
416
|
+
})();
|
|
417
|
+
const x = boundsRef ? dictValue(boundsRef, 'X', false) : 0;
|
|
418
|
+
const y = boundsRef ? dictValue(boundsRef, 'Y', false) : 0;
|
|
419
|
+
const width = boundsRef ? dictValue(boundsRef, 'Width', false) : 0;
|
|
420
|
+
const height = boundsRef ? dictValue(boundsRef, 'Height', false) : 0;
|
|
421
|
+
if (!width || !height) continue;
|
|
422
|
+
out.push({
|
|
423
|
+
name,
|
|
424
|
+
handle: dictValue(dict, 'kCGWindowNumber', false),
|
|
425
|
+
hwnd: dictValue(dict, 'kCGWindowNumber', false),
|
|
426
|
+
x: Math.round(x),
|
|
427
|
+
y: Math.round(y),
|
|
428
|
+
width: Math.round(width),
|
|
429
|
+
height: Math.round(height),
|
|
430
|
+
process_id: dictValue(dict, 'kCGWindowOwnerPID', false),
|
|
431
|
+
status: 'normal',
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
} finally {
|
|
435
|
+
b.CFRelease(list);
|
|
436
|
+
}
|
|
437
|
+
return { ok: true, windows: out };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function activeWindow() {
|
|
441
|
+
const res = osascript('tell application "System Events" to get name of first application process whose frontmost is true');
|
|
442
|
+
if (!res.ok || !res.out) return { ok: true, name: '', handle: null };
|
|
443
|
+
const hit = windows().windows.find((w) => w.name === res.out) || null;
|
|
444
|
+
return { ok: true, name: res.out, handle: hit ? hit.handle : null };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** CoreGraphics hands out BGRA (little endian, alpha first) with a padded row stride. */
|
|
448
|
+
function bgraToRgb(raw, width, height, bytesPerRow, bitsPerPixel = 32) {
|
|
449
|
+
const rgb = Buffer.alloc(width * height * 3);
|
|
450
|
+
const bpp = Math.max(4, Math.floor(bitsPerPixel / 8));
|
|
451
|
+
for (let y = 0; y < height; y += 1) {
|
|
452
|
+
for (let x = 0; x < width; x += 1) {
|
|
453
|
+
const src = y * bytesPerRow + x * bpp;
|
|
454
|
+
const dst = (y * width + x) * 3;
|
|
455
|
+
rgb[dst] = raw[src + 2];
|
|
456
|
+
rgb[dst + 1] = raw[src + 1];
|
|
457
|
+
rgb[dst + 2] = raw[src];
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return rgb;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function screenshot(dest) {
|
|
464
|
+
const b = requireCG();
|
|
465
|
+
const display = b.CGMainDisplayID();
|
|
466
|
+
const image = b.CGDisplayCreateImage(display);
|
|
467
|
+
if (!image) return { ok: false, error: 'CGDisplayCreateImage failed (grant Screen Recording to the terminal)' };
|
|
468
|
+
try {
|
|
469
|
+
const width = Number(b.CGImageGetWidth(image));
|
|
470
|
+
const height = Number(b.CGImageGetHeight(image));
|
|
471
|
+
const bytesPerRow = Number(b.CGImageGetBytesPerRow(image));
|
|
472
|
+
const bits = Number(b.CGImageGetBitsPerPixel(image));
|
|
473
|
+
const provider = b.CGImageGetDataProvider(image);
|
|
474
|
+
const data = provider ? b.CGDataProviderCopyData(provider) : null;
|
|
475
|
+
if (!data) return { ok: false, error: 'CGDataProviderCopyData failed' };
|
|
476
|
+
const koffi = require('koffi');
|
|
477
|
+
try {
|
|
478
|
+
const length = Number(b.CFDataGetLength(data));
|
|
479
|
+
const ptr = b.CFDataGetBytePtr(data);
|
|
480
|
+
if (!ptr) return { ok: false, error: 'CFDataGetBytePtr failed' };
|
|
481
|
+
const raw = koffi.decode(ptr, 'uint8_t', length);
|
|
482
|
+
const rgb = bgraToRgb(raw, width, height, bytesPerRow, bits);
|
|
483
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
484
|
+
fs.writeFileSync(dest, encodePng(rgb, width, height));
|
|
485
|
+
return { ok: true, file: dest, width, height, mime: 'image/png' };
|
|
486
|
+
} finally {
|
|
487
|
+
b.CFRelease(data);
|
|
488
|
+
}
|
|
489
|
+
} finally {
|
|
490
|
+
b.CFRelease(image);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ----------------------------------------------------- what CoreGraphics cannot do --
|
|
495
|
+
|
|
496
|
+
function osascript(script) {
|
|
497
|
+
const res = spawnSync('osascript', ['-e', script], { encoding: 'utf8', timeout: 20000 });
|
|
498
|
+
return { ok: res.status === 0, out: String(res.stdout || '').trim(), error: String(res.stderr || '').trim() };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function launchApp(name) {
|
|
502
|
+
const res = spawnSync('open', ['-a', String(name)], { encoding: 'utf8', timeout: 30000 });
|
|
503
|
+
return res.status === 0 ? { ok: true, app: name } : { ok: false, error: String(res.stderr || 'open -a failed').trim() };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function focusWindow(name) {
|
|
507
|
+
const res = osascript(
|
|
508
|
+
`tell application "System Events" to set frontmost of (first application process whose name is ${JSON.stringify(
|
|
509
|
+
String(name)
|
|
510
|
+
)}) to true`
|
|
511
|
+
);
|
|
512
|
+
return res.ok ? { ok: true, name } : { ok: false, error: res.error || `window not found: ${name}` };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function resizeWindow(name, x, y, width, height) {
|
|
516
|
+
const res = osascript(
|
|
517
|
+
`tell application "System Events" to tell (first application process whose name is ${JSON.stringify(
|
|
518
|
+
String(name)
|
|
519
|
+
)}) to set {position, size} of window 1 to {{${x}, ${y}}, {${width}, ${height}}}`
|
|
520
|
+
);
|
|
521
|
+
return res.ok ? { ok: true, name, x, y, width, height } : { ok: false, error: res.error || 'resize failed' };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function listApps() {
|
|
525
|
+
const out = [];
|
|
526
|
+
for (const dir of ['/Applications', '/System/Applications', path.join(os.homedir(), 'Applications')]) {
|
|
527
|
+
let entries = [];
|
|
528
|
+
try {
|
|
529
|
+
entries = fs.readdirSync(dir);
|
|
530
|
+
} catch {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
for (const entry of entries) {
|
|
534
|
+
if (!entry.endsWith('.app')) continue;
|
|
535
|
+
out.push({ name: entry.replace(/\.app$/, ''), app_id: path.join(dir, entry) });
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return { ok: true, apps: out };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function state(handle = null) {
|
|
542
|
+
const wins = windows();
|
|
543
|
+
const size = screenSize();
|
|
544
|
+
const active = activeWindow();
|
|
545
|
+
const list = wins.ok ? wins.windows : [];
|
|
546
|
+
const focused = handle || active.handle;
|
|
547
|
+
const elements = list
|
|
548
|
+
.filter((w) => !focused || w.handle === focused)
|
|
549
|
+
.map((w) => ({
|
|
550
|
+
name: w.name,
|
|
551
|
+
control_type: 'Window',
|
|
552
|
+
kind: 'Window',
|
|
553
|
+
bounding_box: { left: w.x, top: w.y, right: w.x + w.width, bottom: w.y + w.height },
|
|
554
|
+
center: { x: w.x + Math.floor(w.width / 2), y: w.y + Math.floor(w.height / 2) },
|
|
555
|
+
value: '',
|
|
556
|
+
enabled: true,
|
|
557
|
+
automation_id: '',
|
|
558
|
+
}));
|
|
559
|
+
return {
|
|
560
|
+
ok: true,
|
|
561
|
+
windows: list,
|
|
562
|
+
active_window: active.handle ? list.find((w) => w.handle === active.handle) || { name: active.name, handle: active.handle } : null,
|
|
563
|
+
interactive_elements: elements,
|
|
564
|
+
scrollable_elements: [],
|
|
565
|
+
interactive_elements_text: elements
|
|
566
|
+
.map((e, i) => `${i + 1}. ${e.control_type} '${e.name}' at (${e.center.x}, ${e.center.y})`)
|
|
567
|
+
.join('\n'),
|
|
568
|
+
scrollable_elements_text: '',
|
|
569
|
+
tree_status: 'coregraphics',
|
|
570
|
+
system: {
|
|
571
|
+
windows_version: `darwin ${os.release()}`,
|
|
572
|
+
os_version: os.release(),
|
|
573
|
+
screen_size: { width: size.width, height: size.height },
|
|
574
|
+
user_account_type: 'Local Account',
|
|
575
|
+
default_language: process.env.LANG || 'en',
|
|
576
|
+
dpi_scaling: 1,
|
|
577
|
+
},
|
|
578
|
+
metadata: { depth: 'top-level', kind: 'windows-list', all_desktops: false },
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function available() {
|
|
583
|
+
if (process.platform !== 'darwin') return { ok: false, backend: 'macos', error: 'macOS only' };
|
|
584
|
+
try {
|
|
585
|
+
const size = screenSize();
|
|
586
|
+
if (!size.width || !size.height) throw new Error('no display');
|
|
587
|
+
return { ok: true, backend: 'macos', detail: 'CoreGraphics via FFI (no external tools)', screen: size };
|
|
588
|
+
} catch (err) {
|
|
589
|
+
return { ok: false, backend: 'macos', error: err.message };
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function sysinfo() {
|
|
594
|
+
const size = screenSize();
|
|
595
|
+
return {
|
|
596
|
+
ok: true,
|
|
597
|
+
system: {
|
|
598
|
+
windows_version: `darwin ${os.release()}`,
|
|
599
|
+
os_version: os.release(),
|
|
600
|
+
screen_size: { width: size.width, height: size.height },
|
|
601
|
+
user_account_type: 'Local Account',
|
|
602
|
+
default_language: process.env.LANG || 'en',
|
|
603
|
+
dpi_scaling: 1,
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const backend = {
|
|
609
|
+
name: 'coregraphics-ffi',
|
|
610
|
+
available,
|
|
611
|
+
screenSize,
|
|
612
|
+
windows,
|
|
613
|
+
activeWindow,
|
|
614
|
+
state,
|
|
615
|
+
moveMouse,
|
|
616
|
+
click,
|
|
617
|
+
mouseButton,
|
|
618
|
+
drag,
|
|
619
|
+
scroll,
|
|
620
|
+
typeText,
|
|
621
|
+
pressKeys,
|
|
622
|
+
focusWindow,
|
|
623
|
+
resizeWindow,
|
|
624
|
+
screenshot,
|
|
625
|
+
launchApp,
|
|
626
|
+
listApps,
|
|
627
|
+
sysinfo,
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
module.exports = {
|
|
631
|
+
backend,
|
|
632
|
+
available,
|
|
633
|
+
screenSize,
|
|
634
|
+
windows,
|
|
635
|
+
activeWindow,
|
|
636
|
+
state,
|
|
637
|
+
moveMouse,
|
|
638
|
+
click,
|
|
639
|
+
mouseButton,
|
|
640
|
+
drag,
|
|
641
|
+
scroll,
|
|
642
|
+
typeText,
|
|
643
|
+
pressKeys,
|
|
644
|
+
focusWindow,
|
|
645
|
+
resizeWindow,
|
|
646
|
+
screenshot,
|
|
647
|
+
launchApp,
|
|
648
|
+
listApps,
|
|
649
|
+
sysinfo,
|
|
650
|
+
VK,
|
|
651
|
+
FLAG,
|
|
652
|
+
MODIFIERS,
|
|
653
|
+
EVENT,
|
|
654
|
+
keycodeFor,
|
|
655
|
+
parseChord,
|
|
656
|
+
bgraToRgb,
|
|
657
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// PNG encoding without dependencies: the screenshots of the Linux and macOS backends
|
|
4
|
+
// are RGB rasters, wrapped here in IHDR/IDAT/IEND with zlib.
|
|
5
|
+
|
|
6
|
+
const zlib = require('node:zlib');
|
|
7
|
+
|
|
8
|
+
function crc32(buf) {
|
|
9
|
+
let c = 0xffffffff;
|
|
10
|
+
for (let i = 0; i < buf.length; i += 1) {
|
|
11
|
+
c ^= buf[i];
|
|
12
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
13
|
+
}
|
|
14
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function pngChunk(type, data) {
|
|
18
|
+
const len = Buffer.alloc(4);
|
|
19
|
+
len.writeUInt32BE(data.length);
|
|
20
|
+
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
|
21
|
+
const crc = Buffer.alloc(4);
|
|
22
|
+
crc.writeUInt32BE(crc32(body));
|
|
23
|
+
return Buffer.concat([len, body, crc]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** rgb: width*height*3 bytes, top-down, no padding. */
|
|
27
|
+
function encodePng(rgb, width, height) {
|
|
28
|
+
const stride = width * 3;
|
|
29
|
+
const raw = Buffer.alloc((stride + 1) * height);
|
|
30
|
+
for (let y = 0; y < height; y += 1) {
|
|
31
|
+
raw[y * (stride + 1)] = 0; // filter: none
|
|
32
|
+
rgb.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
|
|
33
|
+
}
|
|
34
|
+
const ihdr = Buffer.alloc(13);
|
|
35
|
+
ihdr.writeUInt32BE(width, 0);
|
|
36
|
+
ihdr.writeUInt32BE(height, 4);
|
|
37
|
+
ihdr[8] = 8; // bit depth
|
|
38
|
+
ihdr[9] = 2; // truecolour
|
|
39
|
+
return Buffer.concat([
|
|
40
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
41
|
+
pngChunk('IHDR', ihdr),
|
|
42
|
+
pngChunk('IDAT', zlib.deflateSync(raw, { level: 6 })),
|
|
43
|
+
pngChunk('IEND', Buffer.alloc(0)),
|
|
44
|
+
]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { encodePng, crc32 };
|
package/src/automation.js
CHANGED
|
@@ -1003,11 +1003,24 @@ let nativeKind = null;
|
|
|
1003
1003
|
|
|
1004
1004
|
try {
|
|
1005
1005
|
if (isLinux) {
|
|
1006
|
+
// Linux has no fallback any more: the FFI backend *is* the backend (the old one
|
|
1007
|
+
// shelled out to xdotool, which is exactly what we do not want).
|
|
1006
1008
|
nativeBackend = require('./automation/linux').backend;
|
|
1007
1009
|
nativeKind = 'x11';
|
|
1008
1010
|
} else if (isWin) {
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
+
// The DLL ships in the package; PowerShell stays only as a safety net for the case
|
|
1012
|
+
// where it cannot be loaded at all.
|
|
1013
|
+
const dll = require('./automation/win-dll');
|
|
1014
|
+
if (dll.load()) {
|
|
1015
|
+
nativeBackend = dll.backend;
|
|
1016
|
+
nativeKind = 'dll';
|
|
1017
|
+
}
|
|
1018
|
+
} else if (isMac) {
|
|
1019
|
+
const mac = require('./automation/mac');
|
|
1020
|
+
if (mac.available().ok) {
|
|
1021
|
+
nativeBackend = mac.backend;
|
|
1022
|
+
nativeKind = 'mac';
|
|
1023
|
+
}
|
|
1011
1024
|
}
|
|
1012
1025
|
} catch (err) {
|
|
1013
1026
|
nativeBackend = null;
|
|
@@ -1023,7 +1036,11 @@ if (nativeBackend) {
|
|
|
1023
1036
|
};
|
|
1024
1037
|
|
|
1025
1038
|
Object.assign(automation, {
|
|
1026
|
-
native: {
|
|
1039
|
+
native: {
|
|
1040
|
+
kind: nativeKind,
|
|
1041
|
+
name: nativeBackend.name,
|
|
1042
|
+
dll: nativeKind === 'dll' ? require('./automation/win-dll').locate() : null,
|
|
1043
|
+
},
|
|
1027
1044
|
available: () => nativeBackend.available(),
|
|
1028
1045
|
screenSize: () => nativeBackend.screenSize(),
|
|
1029
1046
|
windows: () => nativeBackend.windows(),
|
package/src/cli.js
CHANGED
|
@@ -103,6 +103,7 @@ function context(flags) {
|
|
|
103
103
|
json: Boolean(flags.json),
|
|
104
104
|
confirmWrites: Boolean(flags['confirm-writes']),
|
|
105
105
|
approval: normaliseApproval(flags.proceed ? 'proceed' : flags.ask ? 'ask' : cfg.approval),
|
|
106
|
+
shell: String(flags.shell || process.env.ARENACODE_SHELL || cfg.shell || '').trim(),
|
|
106
107
|
idleTimeout: Number(flags['idle-timeout'] || cfg.idle_timeout || 180),
|
|
107
108
|
bridge: new Bridge(url, key, device),
|
|
108
109
|
};
|
|
@@ -194,7 +195,7 @@ function cmdConfig(positional, flags) {
|
|
|
194
195
|
const name = key;
|
|
195
196
|
const val = rest.join(' ');
|
|
196
197
|
if (!name || val === '') {
|
|
197
|
-
write(`usage: ${SELF} config set <url|key|lang|mode|approval|pc_access> <value>`);
|
|
198
|
+
write(`usage: ${SELF} config set <url|key|lang|mode|approval|pc_access|shell> <value>`);
|
|
198
199
|
return 2;
|
|
199
200
|
}
|
|
200
201
|
if (name === 'approval' && !['ask', 'proceed'].includes(String(val))) {
|
|
@@ -337,6 +338,7 @@ async function runInstruction(ctx, text, { attachments = [] } = {}) {
|
|
|
337
338
|
const result = await followTask(ctx.bridge, taskId, {
|
|
338
339
|
folder: ctx.folder,
|
|
339
340
|
lang: ctx.lang,
|
|
341
|
+
shell: ctx.shell,
|
|
340
342
|
yes: ctx.yes,
|
|
341
343
|
approval: ctx.approval,
|
|
342
344
|
confirmWrites: ctx.confirmWrites,
|
|
@@ -400,6 +402,7 @@ async function cmdUi(flags) {
|
|
|
400
402
|
yes: ctx.yes,
|
|
401
403
|
approval: ctx.approval,
|
|
402
404
|
confirmWrites: ctx.confirmWrites,
|
|
405
|
+
shell: ctx.shell,
|
|
403
406
|
});
|
|
404
407
|
await app.start();
|
|
405
408
|
return 0;
|
|
@@ -688,7 +691,7 @@ async function cmdHandshake(flags) {
|
|
|
688
691
|
const USAGE = {
|
|
689
692
|
init: [['init', 'configure bridge URL + API key, then test the connection']],
|
|
690
693
|
config: [['config [show|path]', 'read the saved configuration'],
|
|
691
|
-
['config set <key> <value>', 'url · key · lang · mode · pc_access']],
|
|
694
|
+
['config set <key> <value>', 'url · key · lang · mode · pc_access · shell']],
|
|
692
695
|
ping: [['ping', 'bridge status and API key check']],
|
|
693
696
|
doctor: [['doctor', 'local checks: node, platform, folder, git, bridge']],
|
|
694
697
|
ask: [['ask "instruction"', 'run one task and stream the timeline']],
|
package/src/config.js
CHANGED
package/src/local.js
CHANGED
|
@@ -48,22 +48,110 @@ function relativize(root, abs) {
|
|
|
48
48
|
// Local terminal (local-exec)
|
|
49
49
|
// --------------------------------------------------------------------------- //
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
// Every shell the client can drive. The bridge may ask for one by name (`shell` in the
|
|
52
|
+
// local-exec request), the user can pin one (`arenacode config set shell zsh`, `/shell`),
|
|
53
|
+
// and otherwise the client takes the user's login shell, like a terminal would.
|
|
54
|
+
const UNIX_SHELLS = {
|
|
55
|
+
sh: '/bin/sh',
|
|
56
|
+
bash: '/bin/bash',
|
|
57
|
+
zsh: '/bin/zsh',
|
|
58
|
+
fish: '/usr/bin/fish',
|
|
59
|
+
dash: '/bin/dash',
|
|
60
|
+
ksh: '/bin/ksh',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const WINDOWS_SHELLS = ['powershell', 'pwsh', 'cmd', 'wsl', 'bash'];
|
|
64
|
+
|
|
65
|
+
function shellExists(file) {
|
|
66
|
+
try {
|
|
67
|
+
fs.accessSync(file, fs.constants.X_OK);
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** sh/bash/zsh/dash/ksh: -l login, -c command. fish spells the same as -l -c. */
|
|
75
|
+
function unixShell(name) {
|
|
76
|
+
const wanted = String(name || '').toLowerCase().replace(/\.exe$/, '');
|
|
77
|
+
const alias = { 'git-bash': 'bash', gitbash: 'bash' }[wanted] || wanted;
|
|
78
|
+
let file = UNIX_SHELLS[alias];
|
|
79
|
+
if (!file && (alias === 'sh' || alias.startsWith('/'))) file = alias === 'sh' ? '/bin/sh' : alias;
|
|
80
|
+
if (!file) file = whichPath(alias);
|
|
81
|
+
if (!file) file = UNIX_SHELLS[alias] || null;
|
|
82
|
+
if (!file) return null;
|
|
83
|
+
const base = path.basename(file);
|
|
84
|
+
const args = base === 'fish' ? ['-l', '-c', null] : ['-lc', null];
|
|
85
|
+
return { file, args };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function whichPath(name) {
|
|
89
|
+
for (const dir of ['/bin', '/usr/bin', '/usr/local/bin', '/opt/homebrew/bin']) {
|
|
90
|
+
const file = path.join(dir, name);
|
|
91
|
+
if (shellExists(file)) return file;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The shell to use when nobody asked for a specific one. */
|
|
97
|
+
function detectShell(platform = process.platform) {
|
|
98
|
+
if (platform === 'win32') {
|
|
99
|
+
if (shellExists('pwsh.exe') || whichPath('pwsh')) return 'pwsh';
|
|
100
|
+
return 'powershell';
|
|
101
|
+
}
|
|
102
|
+
const login = path.basename(process.env.SHELL || '');
|
|
103
|
+
if (login && (UNIX_SHELLS[login] || whichPath(login))) return login;
|
|
104
|
+
return 'sh';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** What the client can offer on this machine — for `sysinfo`, `/shell` and the help. */
|
|
108
|
+
function availableShells(platform = process.platform) {
|
|
109
|
+
if (platform === 'win32') {
|
|
110
|
+
return WINDOWS_SHELLS.filter((name) => name === 'powershell' || name === 'cmd' || Boolean(whichPath(name)));
|
|
111
|
+
}
|
|
112
|
+
const found = Object.keys(UNIX_SHELLS).filter((name) => shellExists(UNIX_SHELLS[name]) || Boolean(whichPath(name)));
|
|
113
|
+
return found.length ? found : ['sh'];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Command template for one shell. `command` is passed as its own argv entry, never
|
|
118
|
+
* through a second round of quoting, so quotes and `$` survive.
|
|
119
|
+
*/
|
|
120
|
+
function shellCommand(shell, command, platform = process.platform) {
|
|
121
|
+
const name = String(shell || detectShell(platform)).toLowerCase().replace(/\.exe$/, '');
|
|
122
|
+
if (platform === 'win32') {
|
|
123
|
+
switch (name) {
|
|
54
124
|
case 'cmd':
|
|
55
125
|
return { file: 'cmd.exe', args: ['/d', '/s', '/c', command] };
|
|
56
126
|
case 'wsl':
|
|
57
127
|
return { file: 'wsl.exe', args: ['sh', '-lc', command] };
|
|
128
|
+
case 'bash': {
|
|
129
|
+
// Git Bash when it is installed, otherwise the WSL bash
|
|
130
|
+
const gitBash = [
|
|
131
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
132
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
133
|
+
].find((file) => {
|
|
134
|
+
try {
|
|
135
|
+
return fs.existsSync(file);
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
return gitBash
|
|
141
|
+
? { file: gitBash, args: ['-lc', command] }
|
|
142
|
+
: { file: 'wsl.exe', args: ['bash', '-lc', command] };
|
|
143
|
+
}
|
|
144
|
+
case 'pwsh':
|
|
145
|
+
return { file: 'pwsh.exe', args: ['-NoProfile', '-NonInteractive', '-Command', command] };
|
|
58
146
|
case 'powershell':
|
|
147
|
+
case 'ps':
|
|
59
148
|
default:
|
|
60
|
-
return {
|
|
61
|
-
file: shell === 'pwsh' ? 'pwsh.exe' : 'powershell.exe',
|
|
62
|
-
args: ['-NoProfile', '-NonInteractive', '-Command', command],
|
|
63
|
-
};
|
|
149
|
+
return { file: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-Command', command] };
|
|
64
150
|
}
|
|
65
151
|
}
|
|
66
|
-
|
|
152
|
+
const template = unixShell(name) || unixShell('sh');
|
|
153
|
+
const args = template.args.map((arg) => (arg === null ? command : arg));
|
|
154
|
+
return { file: template.file, args };
|
|
67
155
|
}
|
|
68
156
|
|
|
69
157
|
/** Runs a command and collects stdout+stderr and the exit code. */
|
|
@@ -96,9 +184,12 @@ async function handleLocalExec(bridge, taskId, request, opts) {
|
|
|
96
184
|
const { rid, command, cwd: reqCwd, shell, admin, sandbox_reason: sandboxReason } = request;
|
|
97
185
|
const lang = opts.lang;
|
|
98
186
|
const dir = reqCwd ? resolveInRoot(opts.folder, reqCwd) : opts.folder;
|
|
187
|
+
// the bridge names the shell it wrote the command for; otherwise the user's choice
|
|
188
|
+
// from `arenacode config set shell …` / `/shell`, otherwise the login shell
|
|
189
|
+
const chosenShell = shell || opts.shell || detectShell();
|
|
99
190
|
|
|
100
191
|
write('');
|
|
101
|
-
write(` ${c.bold(c.yellow('⌘ terminal'))} ${c.dim('·')} ${c.cyan(
|
|
192
|
+
write(` ${c.bold(c.yellow('⌘ terminal'))} ${c.dim('·')} ${c.cyan(chosenShell)} ${c.dim('·')} ${c.gray(dir)}`);
|
|
102
193
|
write(indentLines(command, ' '));
|
|
103
194
|
if (admin) write(` ${c.yellow('!')} ${t(lang, 'Richiede i permessi di amministratore: qui il comando gira senza UAC.', 'Needs administrator rights: here it runs without UAC.', 'Necesita permisos de administrador: aquí se ejecuta sin UAC.')}`);
|
|
104
195
|
if (sandboxReason) write(` ${c.yellow('!')} ${t(lang, 'L’agente chiede di installare sul PC', 'The agent asks to install on the PC', 'El agente pide instalar en el PC')}: ${c.gray(sandboxReason)}`);
|
|
@@ -125,7 +216,7 @@ async function handleLocalExec(bridge, taskId, request, opts) {
|
|
|
125
216
|
return true;
|
|
126
217
|
}
|
|
127
218
|
|
|
128
|
-
const { output, code } = await runShell(command, { cwd: dir, shell });
|
|
219
|
+
const { output, code } = await runShell(command, { cwd: dir, shell: chosenShell });
|
|
129
220
|
await bridge.localResult(taskId, { rid, output, code });
|
|
130
221
|
write(` ${code === 0 ? c.green('✓') : c.red('✗')} ${c.gray(`exit ${code}`)}`);
|
|
131
222
|
return true;
|
|
@@ -495,7 +586,8 @@ async function handleLocalUi(bridge, taskId, request, opts) {
|
|
|
495
586
|
cpus: os.cpus().length,
|
|
496
587
|
memory_mb: Math.round(os.totalmem() / 1048576),
|
|
497
588
|
node: process.version,
|
|
498
|
-
shell:
|
|
589
|
+
shell: detectShell(),
|
|
590
|
+
shells: availableShells(),
|
|
499
591
|
};
|
|
500
592
|
await bridge.localResult(taskId, { rid, output: JSON.stringify(info), code: 0 });
|
|
501
593
|
write(` ${c.green('✓')} ${c.gray('sysinfo')}`);
|
|
@@ -683,6 +775,9 @@ async function handleLocalFile(bridge, taskId, request, opts) {
|
|
|
683
775
|
|
|
684
776
|
module.exports = {
|
|
685
777
|
runUiAction,
|
|
778
|
+
shellCommand,
|
|
779
|
+
detectShell,
|
|
780
|
+
availableShells,
|
|
686
781
|
REFUSED,
|
|
687
782
|
resolveInRoot,
|
|
688
783
|
runShell,
|
package/src/tui/app.js
CHANGED
|
@@ -14,7 +14,7 @@ const { getTheme, themeNames } = require('./theme');
|
|
|
14
14
|
|
|
15
15
|
const { Bridge } = require('../bridge');
|
|
16
16
|
const { buildMeta, readProjectContext } = require('../meta');
|
|
17
|
-
const { runShell, runFsOp, resolveInRoot } = require('../local');
|
|
17
|
+
const { runShell, runFsOp, resolveInRoot, detectShell, availableShells, shellCommand } = require('../local');
|
|
18
18
|
const { readConfig, patchConfig, deviceId, resolveUrl, resolveKey, openFolder, VERSION } = require('../config');
|
|
19
19
|
|
|
20
20
|
const { MODE_LINES, modeLabel, nextMode, normaliseMode } = require('../modes');
|
|
@@ -49,6 +49,7 @@ class App {
|
|
|
49
49
|
this.autoApprove = Boolean(opts.yes);
|
|
50
50
|
this.confirmWrites = Boolean(opts.confirmWrites);
|
|
51
51
|
this.approval = normaliseApproval(opts.approval);
|
|
52
|
+
this.shell = String(opts.shell || cfg.shell || '').trim() || detectShell();
|
|
52
53
|
|
|
53
54
|
this.state = {
|
|
54
55
|
mode: normaliseMode(opts.mode || cfg.mode || 'agent'),
|
|
@@ -493,6 +494,7 @@ class App {
|
|
|
493
494
|
{ slash: '/ask', title: 'Ask and Proceed: ask before commands, files and plans', hint: '', group: 'Session', run: () => this.setApproval('ask') },
|
|
494
495
|
{ slash: '/proceed', title: 'Proceed: approve the plan and run everything without asking', hint: '', group: 'Session', run: () => this.setApproval('proceed') },
|
|
495
496
|
{ slash: '/theme', title: 'Cycle the colour theme', hint: 'ctrl+x t', group: 'Session', run: by('Theme') },
|
|
497
|
+
{ slash: '/shell', title: 'Show or change the shell commands run in', hint: '', group: 'Session', run: () => this.pickShell() },
|
|
496
498
|
{ slash: '/status', title: 'Ping the bridge', hint: '', group: 'Session', run: by('Bridge status') },
|
|
497
499
|
{ slash: '/help', title: 'Every keybinding', hint: 'f1', group: 'Session', run: () => this.openHelp() },
|
|
498
500
|
{ slash: '/quit', title: 'Leave arenacode', hint: 'ctrl+x q', group: 'Session', run: () => this.quit() },
|
|
@@ -538,6 +540,42 @@ class App {
|
|
|
538
540
|
this.toast(`approval: ${next === 'proceed' ? 'Proceed' : 'Ask and Proceed'}`, next === 'proceed' ? 'warn' : 'ok');
|
|
539
541
|
}
|
|
540
542
|
|
|
543
|
+
/** `/shell` (and the double-click on a card's shell line): pick from the shells here. */
|
|
544
|
+
async pickShell(name = '') {
|
|
545
|
+
const shells = availableShells();
|
|
546
|
+
const wanted = String(name || '').trim().toLowerCase();
|
|
547
|
+
if (wanted) {
|
|
548
|
+
if (!shells.includes(wanted)) {
|
|
549
|
+
this.toast(`shell: ${wanted} not found (${shells.join(', ')})`, 'warn');
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
this.setShell(wanted);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
// a free-text prompt instead of a dialog: esc keeps the current shell
|
|
556
|
+
this.toast(`shell: ${this.shell} (${shells.join(', ')})`);
|
|
557
|
+
const current = shellCommand(this.shell).file;
|
|
558
|
+
const answer = String(await this.prompt(`shell [${current}]`)).trim().toLowerCase();
|
|
559
|
+
if (!answer) return;
|
|
560
|
+
if (!shells.includes(answer)) {
|
|
561
|
+
this.toast(`shell: ${answer} not found (${shells.join(', ')})`, 'warn');
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
this.setShell(answer);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
setShell(name) {
|
|
568
|
+
this.shell = name;
|
|
569
|
+
try {
|
|
570
|
+
patchConfig({ shell: name });
|
|
571
|
+
} catch {
|
|
572
|
+
/* a read-only HOME keeps the choice in memory only */
|
|
573
|
+
}
|
|
574
|
+
const { file } = shellCommand(name);
|
|
575
|
+
this.push({ kind: 'system', text: `shell: ${name} · ${file}` });
|
|
576
|
+
this.toast(`shell: ${name}`, 'ok');
|
|
577
|
+
}
|
|
578
|
+
|
|
541
579
|
onOverlayKey(key) {
|
|
542
580
|
const ov = this.state.overlay;
|
|
543
581
|
if (key.name === 'escape') {
|
|
@@ -672,7 +710,7 @@ class App {
|
|
|
672
710
|
const cmd = text.slice(1).trim();
|
|
673
711
|
this.push({ kind: 'user', text: cmd, shell: true });
|
|
674
712
|
const item = this.push({ kind: 'tool', icon: 'terminal', label: cmd, state: 'running', detail: '' });
|
|
675
|
-
const { output, code } = await runShell(cmd, { cwd: this.folder, shell:
|
|
713
|
+
const { output, code } = await runShell(cmd, { cwd: this.folder, shell: this.shell, quiet: true });
|
|
676
714
|
item.state = code === 0 ? 'done' : 'failed';
|
|
677
715
|
item.detail = output.trim();
|
|
678
716
|
item.secs = undefined;
|