agent-sh 0.12.26 → 0.12.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/agent-loop.js +2 -2
- package/dist/agent/conversation-state.js +22 -1
- package/dist/install.js +84 -4
- package/dist/utils/floating-panel.d.ts +16 -4
- package/dist/utils/floating-panel.js +209 -66
- package/examples/extensions/emacs-buffer.ts +364 -0
- package/examples/extensions/overlay-agent.ts +28 -5
- package/examples/extensions/terminal-buffer.ts +174 -33
- package/examples/extensions/tunnel-vision.ts +405 -0
- package/examples/extensions/web-access.ts +3 -108
- package/package.json +1 -1
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emacs buffer extension.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the terminal-buffer extension but for a running Emacs server,
|
|
5
|
+
* trading PTY screen-scraping for structural access via `emacsclient -e`.
|
|
6
|
+
*
|
|
7
|
+
* Registers three agent tools (only when `emacsclient` is available and
|
|
8
|
+
* a server is reachable):
|
|
9
|
+
*
|
|
10
|
+
* - emacs_read : structured snapshot of the selected window —
|
|
11
|
+
* buffer, file, mode, point, narrowing, modeline,
|
|
12
|
+
* echo area, and the visible region (window-start
|
|
13
|
+
* to window-end). Optional all-windows mode.
|
|
14
|
+
*
|
|
15
|
+
* - emacs_keys : send a `kbd`-notation key sequence
|
|
16
|
+
* (e.g. "C-x C-s", "SPC f f"). Goes through Emacs's
|
|
17
|
+
* own key parser, so failed chords don't leak as
|
|
18
|
+
* literal text and Doom leaders work without timing
|
|
19
|
+
* tricks.
|
|
20
|
+
*
|
|
21
|
+
* - emacs_eval : evaluate arbitrary elisp inside the running Emacs.
|
|
22
|
+
* Use for structural operations (buffer edits,
|
|
23
|
+
* window manipulation, calling commands directly).
|
|
24
|
+
*
|
|
25
|
+
* All three round-trip results through a temp file as JSON. Requires
|
|
26
|
+
* Emacs 27+ for `json-serialize`.
|
|
27
|
+
*
|
|
28
|
+
* Usage:
|
|
29
|
+
* ash -e ./examples/extensions/emacs-buffer.ts
|
|
30
|
+
*
|
|
31
|
+
* # Or install permanently
|
|
32
|
+
* cp examples/extensions/emacs-buffer.ts ~/.agent-sh/extensions/
|
|
33
|
+
*/
|
|
34
|
+
import * as fs from "node:fs";
|
|
35
|
+
import * as os from "node:os";
|
|
36
|
+
import * as path from "node:path";
|
|
37
|
+
import { spawnSync } from "node:child_process";
|
|
38
|
+
import type { ExtensionContext } from "agent-sh/types";
|
|
39
|
+
|
|
40
|
+
function emacsclientAvailable(): boolean {
|
|
41
|
+
// `emacsclient -e t` exits 0 only if a server is actually reachable.
|
|
42
|
+
const r = spawnSync("emacsclient", ["-e", "t"], { encoding: "utf-8" });
|
|
43
|
+
return r.status === 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function evalToJson<T = unknown>(body: string): T {
|
|
47
|
+
const out = path.join(
|
|
48
|
+
os.tmpdir(),
|
|
49
|
+
`agent-sh-emacs-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
|
|
50
|
+
);
|
|
51
|
+
// Compute the result *before* with-temp-file: inside its body, current-buffer
|
|
52
|
+
// is the temp buffer, and execute-kbd-macro can shift current-buffer to the
|
|
53
|
+
// user's live buffer mid-flight, causing (insert ...) to write JSON into it.
|
|
54
|
+
const wrapped = `(let ((__result (progn ${body}))) (with-temp-file ${JSON.stringify(out)} (insert (json-serialize __result))))`;
|
|
55
|
+
const r = spawnSync("emacsclient", ["-e", wrapped], { encoding: "utf-8" });
|
|
56
|
+
if (r.status !== 0) {
|
|
57
|
+
try { fs.unlinkSync(out); } catch { /* ignore */ }
|
|
58
|
+
throw new Error(`emacsclient failed: ${(r.stderr || r.stdout || "").trim()}`);
|
|
59
|
+
}
|
|
60
|
+
let json: string;
|
|
61
|
+
try {
|
|
62
|
+
json = fs.readFileSync(out, "utf-8");
|
|
63
|
+
} finally {
|
|
64
|
+
try { fs.unlinkSync(out); } catch { /* ignore */ }
|
|
65
|
+
}
|
|
66
|
+
return JSON.parse(json) as T;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Used by emacs_eval, where the result might not be JSON-serializable.
|
|
70
|
+
function evalPrinted(body: string): string {
|
|
71
|
+
const r = spawnSync("emacsclient", ["-e", body], { encoding: "utf-8" });
|
|
72
|
+
if (r.status !== 0) {
|
|
73
|
+
throw new Error(`emacsclient failed: ${(r.stderr || r.stdout || "").trim()}`);
|
|
74
|
+
}
|
|
75
|
+
return r.stdout.replace(/\n$/, "");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface WindowSnapshot {
|
|
79
|
+
selected: boolean;
|
|
80
|
+
buffer: string;
|
|
81
|
+
file: string | null;
|
|
82
|
+
mode: string;
|
|
83
|
+
modified: boolean;
|
|
84
|
+
narrowed: boolean;
|
|
85
|
+
point: number;
|
|
86
|
+
line: number;
|
|
87
|
+
column: number;
|
|
88
|
+
window_start: number;
|
|
89
|
+
window_end: number;
|
|
90
|
+
visible: string;
|
|
91
|
+
modeline: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface EmacsSnapshot {
|
|
95
|
+
windows: WindowSnapshot[];
|
|
96
|
+
echo_area: string | null;
|
|
97
|
+
minibuffer_active: boolean;
|
|
98
|
+
minibuffer_prompt: string | null;
|
|
99
|
+
minibuffer_contents: string | null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Plist for one window. Conventions: t / :false for booleans (json-serialize
|
|
103
|
+
// would otherwise map nil → {}, not null), :null for explicit nulls.
|
|
104
|
+
const WINDOW_PLIST = `
|
|
105
|
+
(let* ((buf (window-buffer w))
|
|
106
|
+
(s (window-start w))
|
|
107
|
+
(e (window-end w t)))
|
|
108
|
+
(with-current-buffer buf
|
|
109
|
+
(save-excursion
|
|
110
|
+
(goto-char (window-point w))
|
|
111
|
+
(list
|
|
112
|
+
:selected (if (eq w (selected-window)) t :false)
|
|
113
|
+
:buffer (buffer-name)
|
|
114
|
+
:file (or (buffer-file-name) :null)
|
|
115
|
+
:mode (symbol-name major-mode)
|
|
116
|
+
:modified (if (buffer-modified-p) t :false)
|
|
117
|
+
:narrowed (if (or (/= (point-min) 1) (/= (point-max) (1+ (buffer-size)))) t :false)
|
|
118
|
+
:point (point)
|
|
119
|
+
:line (line-number-at-pos (point))
|
|
120
|
+
:column (current-column)
|
|
121
|
+
:window_start s
|
|
122
|
+
:window_end e
|
|
123
|
+
:visible (buffer-substring-no-properties s e)
|
|
124
|
+
:modeline (substring-no-properties (format-mode-line mode-line-format nil w))))))
|
|
125
|
+
`;
|
|
126
|
+
|
|
127
|
+
function snapshotElisp(allWindows: boolean): string {
|
|
128
|
+
const winList = allWindows
|
|
129
|
+
? "(window-list)"
|
|
130
|
+
: "(list (selected-window))";
|
|
131
|
+
return `
|
|
132
|
+
(list
|
|
133
|
+
:windows (vconcat
|
|
134
|
+
(mapcar (lambda (w) ${WINDOW_PLIST}) ${winList}))
|
|
135
|
+
:echo_area (or (current-message) :null)
|
|
136
|
+
:minibuffer_active (if (active-minibuffer-window) t :false)
|
|
137
|
+
:minibuffer_prompt (or (and (active-minibuffer-window)
|
|
138
|
+
(with-current-buffer (window-buffer (minibuffer-window))
|
|
139
|
+
(or (minibuffer-prompt) "")))
|
|
140
|
+
:null)
|
|
141
|
+
:minibuffer_contents (or (and (active-minibuffer-window)
|
|
142
|
+
(with-current-buffer (window-buffer (minibuffer-window))
|
|
143
|
+
(minibuffer-contents-no-properties)))
|
|
144
|
+
:null))
|
|
145
|
+
`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function snapshot(allWindows: boolean): EmacsSnapshot {
|
|
149
|
+
return evalToJson<EmacsSnapshot>(snapshotElisp(allWindows));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderWindow(w: WindowSnapshot, idx: number): string {
|
|
153
|
+
const tag = w.selected ? "selected" : `window ${idx}`;
|
|
154
|
+
const flags: string[] = [];
|
|
155
|
+
if (w.modified) flags.push("modified");
|
|
156
|
+
if (w.narrowed) flags.push("narrowed");
|
|
157
|
+
const flagsStr = flags.length ? ` [${flags.join(", ")}]` : "";
|
|
158
|
+
const fileStr = w.file ? ` file=${w.file}` : "";
|
|
159
|
+
const visible = markCursor(w.visible, w.point - w.window_start);
|
|
160
|
+
|
|
161
|
+
return [
|
|
162
|
+
`── ${tag}${flagsStr} ──`,
|
|
163
|
+
`buffer=${w.buffer}${fileStr} mode=${w.mode}`,
|
|
164
|
+
`point=${w.point} line=${w.line} col=${w.column}`,
|
|
165
|
+
`modeline: ${w.modeline}`,
|
|
166
|
+
`visible (${w.window_start}..${w.window_end}):`,
|
|
167
|
+
visible,
|
|
168
|
+
].join("\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function markCursor(visible: string, offset: number): string {
|
|
172
|
+
if (offset < 0 || offset > visible.length) return visible;
|
|
173
|
+
return visible.slice(0, offset) + "▮" + visible.slice(offset);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function renderSnapshot(snap: EmacsSnapshot): string {
|
|
177
|
+
const parts = snap.windows.map((w, i) => renderWindow(w, i));
|
|
178
|
+
if (snap.minibuffer_active && snap.minibuffer_prompt !== null) {
|
|
179
|
+
parts.push(
|
|
180
|
+
`── minibuffer ──\n${snap.minibuffer_prompt}${snap.minibuffer_contents ?? ""}`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (snap.echo_area) {
|
|
184
|
+
parts.push(`── echo area ──\n${snap.echo_area}`);
|
|
185
|
+
}
|
|
186
|
+
return parts.join("\n\n");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export default function activate(ctx: ExtensionContext): void {
|
|
190
|
+
const { registerTool } = ctx;
|
|
191
|
+
if (!emacsclientAvailable()) return;
|
|
192
|
+
|
|
193
|
+
registerTool({
|
|
194
|
+
name: "emacs_read",
|
|
195
|
+
description:
|
|
196
|
+
"Read the state of the user's running Emacs: selected window's buffer, " +
|
|
197
|
+
"file path, major mode, point (line/column), narrowing, modeline, the " +
|
|
198
|
+
"currently visible region (window-start to window-end) with a ▮ cursor " +
|
|
199
|
+
"marker, plus the echo area / minibuffer if active. With all_windows=true, " +
|
|
200
|
+
"returns the same data for every visible window. Use this to ground answers " +
|
|
201
|
+
"in what the user is actually looking at, not just guessing from filenames. " +
|
|
202
|
+
"Far more reliable than terminal_read for Emacs — it sees structure, not pixels.",
|
|
203
|
+
input_schema: {
|
|
204
|
+
type: "object",
|
|
205
|
+
properties: {
|
|
206
|
+
all_windows: {
|
|
207
|
+
type: "boolean",
|
|
208
|
+
description:
|
|
209
|
+
"Include every visible window in the current frame, not just the " +
|
|
210
|
+
"selected one. Default: false.",
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
showOutput: true,
|
|
215
|
+
getDisplayInfo: () => ({ kind: "read" as const, icon: "⌬", locations: [] }),
|
|
216
|
+
|
|
217
|
+
async execute(args) {
|
|
218
|
+
const all = (args.all_windows as boolean) ?? false;
|
|
219
|
+
try {
|
|
220
|
+
const snap = snapshot(all);
|
|
221
|
+
return { content: renderSnapshot(snap), exitCode: 0, isError: false };
|
|
222
|
+
} catch (e) {
|
|
223
|
+
return {
|
|
224
|
+
content: `emacs_read failed: ${(e as Error).message}`,
|
|
225
|
+
exitCode: 1,
|
|
226
|
+
isError: true,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
registerTool({
|
|
233
|
+
name: "emacs_keys",
|
|
234
|
+
description:
|
|
235
|
+
"Send a key sequence to the user's running Emacs, parsed by Emacs itself " +
|
|
236
|
+
"via `kbd`. Use Emacs `kbd` notation:\n" +
|
|
237
|
+
" C-x C-s — Ctrl+x Ctrl+s (save)\n" +
|
|
238
|
+
" M-x — Meta/Alt+x\n" +
|
|
239
|
+
" C-M-f — Ctrl+Meta+f\n" +
|
|
240
|
+
" SPC f f — Doom/Spacemacs leader find-file (no timing tricks needed)\n" +
|
|
241
|
+
" RET ESC TAB DEL — named keys\n" +
|
|
242
|
+
" <up> <down> — arrow keys\n\n" +
|
|
243
|
+
"Why this beats terminal_keys for Emacs: the key parser is authoritative, so " +
|
|
244
|
+
"C-c works as a prefix without queueing garbage, leader keys resolve without " +
|
|
245
|
+
"inter-key delays, and failed chords surface as a `kbd` parse error instead of " +
|
|
246
|
+
"leaking into the buffer as text. Returns a fresh emacs_read snapshot after " +
|
|
247
|
+
"the keys execute.",
|
|
248
|
+
input_schema: {
|
|
249
|
+
type: "object",
|
|
250
|
+
properties: {
|
|
251
|
+
keys: {
|
|
252
|
+
type: "string",
|
|
253
|
+
description:
|
|
254
|
+
"A `kbd`-style key sequence, e.g. \"C-x C-s\", \"M-x find-file RET\", \"SPC f f\".",
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
required: ["keys"],
|
|
258
|
+
},
|
|
259
|
+
showOutput: false,
|
|
260
|
+
getDisplayInfo: () => ({ kind: "execute" as const, icon: "⌥", locations: [] }),
|
|
261
|
+
formatCall: (args) => `keys: ${args.keys}`,
|
|
262
|
+
|
|
263
|
+
async execute(args) {
|
|
264
|
+
const keys = args.keys as string;
|
|
265
|
+
try {
|
|
266
|
+
// condition-case so kbd parse / runtime errors surface structurally
|
|
267
|
+
// rather than as a non-zero emacsclient exit.
|
|
268
|
+
const body = `
|
|
269
|
+
(condition-case err
|
|
270
|
+
(progn
|
|
271
|
+
(execute-kbd-macro (kbd ${JSON.stringify(keys)}))
|
|
272
|
+
${snapshotElisp(false).trim()})
|
|
273
|
+
(error (list :error (error-message-string err))))
|
|
274
|
+
`;
|
|
275
|
+
const result = evalToJson<EmacsSnapshot | { error: string }>(body);
|
|
276
|
+
if ("error" in result) {
|
|
277
|
+
return {
|
|
278
|
+
content: `emacs_keys error: ${result.error}`,
|
|
279
|
+
exitCode: 1,
|
|
280
|
+
isError: true,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
content: `Keys sent.\n\n${renderSnapshot(result)}`,
|
|
285
|
+
exitCode: 0,
|
|
286
|
+
isError: false,
|
|
287
|
+
};
|
|
288
|
+
} catch (e) {
|
|
289
|
+
return {
|
|
290
|
+
content: `emacs_keys failed: ${(e as Error).message}`,
|
|
291
|
+
exitCode: 1,
|
|
292
|
+
isError: true,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
registerTool({
|
|
299
|
+
name: "emacs_eval",
|
|
300
|
+
description:
|
|
301
|
+
"Evaluate elisp inside the user's running Emacs. The high-leverage tool: " +
|
|
302
|
+
"buffer edits, window manipulation, calling named commands, reading any " +
|
|
303
|
+
"data structure Emacs knows. Returns the printed value plus a fresh " +
|
|
304
|
+
"emacs_read snapshot.\n\n" +
|
|
305
|
+
"Useful idioms:\n" +
|
|
306
|
+
" (with-current-buffer \"foo.org\" (buffer-substring-no-properties (point-min) (point-max)))\n" +
|
|
307
|
+
" (with-current-buffer (window-buffer (selected-window)) (save-buffer))\n" +
|
|
308
|
+
" (call-interactively '+default/find-file)\n" +
|
|
309
|
+
" (split-window-right)\n\n" +
|
|
310
|
+
"Caveat: this mutates the user's live editor. The change is undoable in the " +
|
|
311
|
+
"buffer (C-/) but not all elisp side effects are reversible — be deliberate.",
|
|
312
|
+
input_schema: {
|
|
313
|
+
type: "object",
|
|
314
|
+
properties: {
|
|
315
|
+
elisp: {
|
|
316
|
+
type: "string",
|
|
317
|
+
description:
|
|
318
|
+
"Elisp form(s) to evaluate. Multiple forms are allowed; only the last " +
|
|
319
|
+
"form's value is returned in the printed-value section.",
|
|
320
|
+
},
|
|
321
|
+
skip_snapshot: {
|
|
322
|
+
type: "boolean",
|
|
323
|
+
description:
|
|
324
|
+
"If true, don't return a post-eval emacs_read snapshot. Useful for " +
|
|
325
|
+
"pure read-only evals where the snapshot would be noise. Default: false.",
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
required: ["elisp"],
|
|
329
|
+
},
|
|
330
|
+
showOutput: false,
|
|
331
|
+
getDisplayInfo: () => ({ kind: "execute" as const, icon: "λ", locations: [] }),
|
|
332
|
+
formatCall: (args) => {
|
|
333
|
+
const elisp = (args.elisp as string).trim().split("\n")[0];
|
|
334
|
+
return elisp.length > 80 ? elisp.slice(0, 77) + "..." : elisp;
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
async execute(args) {
|
|
338
|
+
const elisp = args.elisp as string;
|
|
339
|
+
const skipSnap = (args.skip_snapshot as boolean) ?? false;
|
|
340
|
+
try {
|
|
341
|
+
const printed = evalPrinted(elisp);
|
|
342
|
+
let suffix = "";
|
|
343
|
+
if (!skipSnap) {
|
|
344
|
+
try {
|
|
345
|
+
suffix = "\n\n" + renderSnapshot(snapshot(false));
|
|
346
|
+
} catch (e) {
|
|
347
|
+
suffix = `\n\n(snapshot failed: ${(e as Error).message})`;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
content: `=> ${printed}${suffix}`,
|
|
352
|
+
exitCode: 0,
|
|
353
|
+
isError: false,
|
|
354
|
+
};
|
|
355
|
+
} catch (e) {
|
|
356
|
+
return {
|
|
357
|
+
content: `emacs_eval failed: ${(e as Error).message}`,
|
|
358
|
+
exitCode: 1,
|
|
359
|
+
isError: true,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
}
|
|
@@ -104,8 +104,18 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
104
104
|
surface: panelSurface,
|
|
105
105
|
});
|
|
106
106
|
}
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
if (query.startsWith("/")) {
|
|
108
|
+
// Sync commands (/model, /help) render via ui:info and leave us in
|
|
109
|
+
// input phase; ones that fan out to agent:submit flip the phase via
|
|
110
|
+
// the agent:processing-start listener below.
|
|
111
|
+
const spaceIdx = query.indexOf(" ");
|
|
112
|
+
const name = spaceIdx === -1 ? query : query.slice(0, spaceIdx);
|
|
113
|
+
const args = spaceIdx === -1 ? "" : query.slice(spaceIdx + 1).trim();
|
|
114
|
+
bus.emit("command:execute", { name, args });
|
|
115
|
+
} else {
|
|
116
|
+
panel.setActive();
|
|
117
|
+
session.submit(query);
|
|
118
|
+
}
|
|
109
119
|
});
|
|
110
120
|
|
|
111
121
|
panel.handlers.advise("panel:show", (_next) => {
|
|
@@ -114,9 +124,9 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
114
124
|
}
|
|
115
125
|
});
|
|
116
126
|
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
panel.handlers.advise("panel:
|
|
127
|
+
// While the agent is still working, keep the session open so output and
|
|
128
|
+
// tool calls survive a hide. Once it's idle, close to release redirects.
|
|
129
|
+
panel.handlers.advise("panel:hide", (next) => {
|
|
120
130
|
next();
|
|
121
131
|
if (session && !panel.processing) {
|
|
122
132
|
session.close();
|
|
@@ -124,6 +134,19 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
124
134
|
}
|
|
125
135
|
});
|
|
126
136
|
|
|
137
|
+
panel.handlers.advise("panel:reset", (next) => {
|
|
138
|
+
next();
|
|
139
|
+
if (session) {
|
|
140
|
+
session.close();
|
|
141
|
+
session = null;
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Picks up turns triggered indirectly (e.g. /skill:foo → agent:submit).
|
|
146
|
+
bus.on("agent:processing-start", () => {
|
|
147
|
+
if (panel.active && !panel.processing) panel.setActive();
|
|
148
|
+
});
|
|
149
|
+
|
|
127
150
|
bus.on("agent:processing-done", () => {
|
|
128
151
|
if (panel.active) panel.setDone();
|
|
129
152
|
});
|
|
@@ -16,25 +16,129 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { ExtensionContext } from "agent-sh/types";
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
const NAMED_KEYS: Record<string, string> = {
|
|
20
|
+
RET: "\r", ENTER: "\r", CR: "\r",
|
|
21
|
+
ESC: "\x1b",
|
|
22
|
+
TAB: "\t",
|
|
23
|
+
BS: "\x7f", BACKSPACE: "\x7f",
|
|
24
|
+
DEL: "\x1b[3~", DELETE: "\x1b[3~",
|
|
25
|
+
SPC: " ", SPACE: " ",
|
|
26
|
+
UP: "\x1b[A", DOWN: "\x1b[B", RIGHT: "\x1b[C", LEFT: "\x1b[D",
|
|
27
|
+
HOME: "\x1b[H", END: "\x1b[F",
|
|
28
|
+
PGUP: "\x1b[5~", PGDN: "\x1b[6~",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function ctrlByte(ch: string): string {
|
|
32
|
+
if (ch === " ") return "\x00";
|
|
33
|
+
const code = ch.charCodeAt(0);
|
|
34
|
+
if (code >= 0x40 && code <= 0x7e) return String.fromCharCode(code & 0x1f);
|
|
35
|
+
throw new Error(`Cannot apply Ctrl modifier to ${JSON.stringify(ch)}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseToken(body: string): string {
|
|
39
|
+
if (!body) throw new Error("Empty key token <>");
|
|
40
|
+
const upper = body.toUpperCase();
|
|
41
|
+
if (upper in NAMED_KEYS) return NAMED_KEYS[upper];
|
|
42
|
+
|
|
43
|
+
const fn = /^F(\d{1,2})$/i.exec(body);
|
|
44
|
+
if (fn) {
|
|
45
|
+
const n = parseInt(fn[1], 10);
|
|
46
|
+
const map: Record<number, string> = {
|
|
47
|
+
1: "\x1bOP", 2: "\x1bOQ", 3: "\x1bOR", 4: "\x1bOS",
|
|
48
|
+
5: "\x1b[15~", 6: "\x1b[17~", 7: "\x1b[18~", 8: "\x1b[19~",
|
|
49
|
+
9: "\x1b[20~", 10: "\x1b[21~", 11: "\x1b[23~", 12: "\x1b[24~",
|
|
50
|
+
};
|
|
51
|
+
if (n in map) return map[n];
|
|
52
|
+
throw new Error(`Unknown function key <${body}>`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let rest = body;
|
|
56
|
+
let ctrl = false, meta = false;
|
|
57
|
+
while (true) {
|
|
58
|
+
if (/^C-/i.test(rest)) { ctrl = true; rest = rest.slice(2); }
|
|
59
|
+
else if (/^M-/i.test(rest) || /^A-/i.test(rest)) { meta = true; rest = rest.slice(2); }
|
|
60
|
+
else break;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let core: string;
|
|
64
|
+
const restUpper = rest.toUpperCase();
|
|
65
|
+
if (restUpper in NAMED_KEYS) core = NAMED_KEYS[restUpper];
|
|
66
|
+
else if (rest.length === 1) core = rest;
|
|
67
|
+
else throw new Error(`Unparseable key token <${body}>`);
|
|
68
|
+
|
|
69
|
+
if (ctrl) {
|
|
70
|
+
if (core.length !== 1) {
|
|
71
|
+
throw new Error(`Ctrl modifier on multi-byte key <${body}> not supported`);
|
|
72
|
+
}
|
|
73
|
+
core = ctrlByte(core);
|
|
74
|
+
}
|
|
75
|
+
if (meta) core = "\x1b" + core;
|
|
76
|
+
return core;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Tokenize a `keys` string into chords — atomic units that get written
|
|
81
|
+
* separately when inter_key_ms > 0, so multi-key leaders resolve under
|
|
82
|
+
* the leader timer.
|
|
83
|
+
*/
|
|
84
|
+
export function tokenizeKeys(input: string): string[] {
|
|
85
|
+
const chords: string[] = [];
|
|
86
|
+
let i = 0;
|
|
87
|
+
while (i < input.length) {
|
|
88
|
+
const ch = input[i];
|
|
89
|
+
if (ch === "<") {
|
|
90
|
+
const end = input.indexOf(">", i + 1);
|
|
91
|
+
if (end === -1) throw new Error(`Unterminated key token starting at index ${i}`);
|
|
92
|
+
chords.push(parseToken(input.slice(i + 1, end)));
|
|
93
|
+
i = end + 1;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (ch === "\\" && i + 1 < input.length) {
|
|
97
|
+
const next = input[i + 1];
|
|
98
|
+
if (next === "r") { chords.push("\r"); i += 2; continue; }
|
|
99
|
+
if (next === "n") { chords.push("\n"); i += 2; continue; }
|
|
100
|
+
if (next === "t") { chords.push("\t"); i += 2; continue; }
|
|
101
|
+
if (next === "\\") { chords.push("\\"); i += 2; continue; }
|
|
102
|
+
if (next === "0") { chords.push("\0"); i += 2; continue; }
|
|
103
|
+
if (next === "x" && i + 3 < input.length) {
|
|
104
|
+
const hex = input.slice(i + 2, i + 4);
|
|
105
|
+
if (/^[0-9a-fA-F]{2}$/.test(hex)) {
|
|
106
|
+
chords.push(String.fromCharCode(parseInt(hex, 16)));
|
|
107
|
+
i += 4;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
chords.push(ch);
|
|
112
|
+
i += 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
chords.push(ch);
|
|
116
|
+
i += 1;
|
|
117
|
+
}
|
|
118
|
+
return chords;
|
|
30
119
|
}
|
|
31
120
|
|
|
32
121
|
function settle(ms = 100): Promise<void> {
|
|
33
122
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
34
123
|
}
|
|
35
124
|
|
|
125
|
+
function diffScreens(before: string, after: string): string {
|
|
126
|
+
const beforeLines = before.split("\n");
|
|
127
|
+
const afterLines = after.split("\n");
|
|
128
|
+
const max = Math.max(beforeLines.length, afterLines.length);
|
|
129
|
+
const changes: string[] = [];
|
|
130
|
+
for (let i = 0; i < max; i++) {
|
|
131
|
+
const a = beforeLines[i] ?? "";
|
|
132
|
+
const b = afterLines[i] ?? "";
|
|
133
|
+
if (a !== b) changes.push(`row ${i}: ${JSON.stringify(a)} → ${JSON.stringify(b)}`);
|
|
134
|
+
}
|
|
135
|
+
if (changes.length === 0) return "(no visible change)";
|
|
136
|
+
if (changes.length > 12) return `${changes.length} rows changed (see full screen below)`;
|
|
137
|
+
return changes.join("\n");
|
|
138
|
+
}
|
|
139
|
+
|
|
36
140
|
export default function activate(ctx: ExtensionContext): void {
|
|
37
|
-
const { bus, registerTool
|
|
141
|
+
const { bus, registerTool } = ctx;
|
|
38
142
|
const tb = ctx.call("terminal-buffer");
|
|
39
143
|
if (!tb) return; // @xterm/headless not installed, or shell frontend not loaded
|
|
40
144
|
|
|
@@ -86,17 +190,22 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
86
190
|
description:
|
|
87
191
|
"Send keystrokes directly into the user's live terminal PTY, as if the user typed them. " +
|
|
88
192
|
"Use this to interact with programs already running in the terminal (vim, htop, less, ssh, REPLs, etc.) " +
|
|
89
|
-
"or to type commands at the shell prompt
|
|
90
|
-
"
|
|
91
|
-
"
|
|
92
|
-
"
|
|
93
|
-
"
|
|
94
|
-
"
|
|
95
|
-
" - Ctrl+
|
|
96
|
-
"
|
|
97
|
-
"
|
|
98
|
-
"
|
|
99
|
-
"
|
|
193
|
+
"or to type commands at the shell prompt.\n\n" +
|
|
194
|
+
"Preferred input: named-key tokens in angle brackets. They are unambiguous and let inter_key_ms " +
|
|
195
|
+
"delay the right boundaries (one chord per token):\n" +
|
|
196
|
+
" <RET> <ESC> <TAB> <BS> <DEL> <SPC>\n" +
|
|
197
|
+
" <UP> <DOWN> <LEFT> <RIGHT> <HOME> <END> <PGUP> <PGDN>\n" +
|
|
198
|
+
" <F1>..<F12>\n" +
|
|
199
|
+
" <C-x> = Ctrl+x, <M-x> = Meta/Alt+x, <C-M-x> = Ctrl+Meta+x\n\n" +
|
|
200
|
+
"Backslash escapes are also accepted for raw bytes: \\r \\n \\t \\xNN.\n\n" +
|
|
201
|
+
"Example: quit vim without saving — keys=\"<ESC>:q!<RET>\" (or the older \"\\x1b:q!\\r\").\n\n" +
|
|
202
|
+
"Emacs pitfalls (read before sending keys to a running Emacs):\n" +
|
|
203
|
+
" - Abort is <C-g>, NOT <C-c>. <C-c> is a prefix key in Emacs and will queue garbage.\n" +
|
|
204
|
+
" - Failed multi-key chords get inserted into the buffer as literal text. Send small, " +
|
|
205
|
+
" well-tested sequences and call terminal_read between them to verify.\n" +
|
|
206
|
+
" - Doom/Spacemacs leader sequences (e.g. <SPC> f f) need timing — set inter_key_ms=50 " +
|
|
207
|
+
" or higher so the leader timer can resolve each chord.\n" +
|
|
208
|
+
" - For complex Emacs operations, prefer `emacsclient -e '(...)'` over typing keys.\n\n" +
|
|
100
209
|
"Always call terminal_read after sending keys to verify the result.",
|
|
101
210
|
input_schema: {
|
|
102
211
|
type: "object",
|
|
@@ -104,14 +213,20 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
104
213
|
keys: {
|
|
105
214
|
type: "string",
|
|
106
215
|
description:
|
|
107
|
-
"The keystrokes to send.
|
|
108
|
-
"
|
|
216
|
+
"The keystrokes to send. Prefer named tokens like <C-g>, <RET>, <ESC>, <SPC>. " +
|
|
217
|
+
"Backslash escapes (\\r, \\t, \\x1b) and raw characters are also accepted.",
|
|
109
218
|
},
|
|
110
219
|
settle_ms: {
|
|
111
220
|
type: "number",
|
|
112
221
|
description:
|
|
113
|
-
"Milliseconds to wait after
|
|
114
|
-
"
|
|
222
|
+
"Milliseconds to wait after the last chord for the terminal to settle before " +
|
|
223
|
+
"snapshotting the screen (default: 150). Increase for slow programs.",
|
|
224
|
+
},
|
|
225
|
+
inter_key_ms: {
|
|
226
|
+
type: "number",
|
|
227
|
+
description:
|
|
228
|
+
"Milliseconds to wait between chords. Default 0 (send all at once). Set ~50 for " +
|
|
229
|
+
"Doom/Spacemacs leader sequences or any binding that depends on key-chord timeouts.",
|
|
115
230
|
},
|
|
116
231
|
},
|
|
117
232
|
required: ["keys"],
|
|
@@ -133,29 +248,55 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
133
248
|
.replace(/\\t|\t/g, "TAB")
|
|
134
249
|
.replace(/\\x03|\x03/g, "^C")
|
|
135
250
|
.replace(/\\x04|\x04/g, "^D")
|
|
251
|
+
.replace(/\\x07|\x07/g, "^G")
|
|
136
252
|
.replace(/\\x7f|\x7f/g, "BS");
|
|
137
253
|
},
|
|
138
254
|
|
|
139
255
|
async execute(args) {
|
|
140
256
|
const raw = args.keys as string;
|
|
141
|
-
const keys = interpretEscapes(raw);
|
|
142
257
|
const settleMs = (args.settle_ms as number) ?? 150;
|
|
258
|
+
const interKeyMs = (args.inter_key_ms as number) ?? 0;
|
|
259
|
+
|
|
260
|
+
let chords: string[];
|
|
261
|
+
try {
|
|
262
|
+
chords = tokenizeKeys(raw);
|
|
263
|
+
} catch (e) {
|
|
264
|
+
return {
|
|
265
|
+
content: `Invalid keys argument: ${(e as Error).message}`,
|
|
266
|
+
exitCode: 1,
|
|
267
|
+
isError: true,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
tb.flush();
|
|
272
|
+
const before = tb.readScreen();
|
|
143
273
|
|
|
144
274
|
bus.emit("shell:stdout-show", {});
|
|
145
275
|
process.stdout.write("\n");
|
|
146
|
-
|
|
276
|
+
|
|
277
|
+
for (let i = 0; i < chords.length; i++) {
|
|
278
|
+
bus.emit("shell:pty-write", { data: chords[i] });
|
|
279
|
+
if (interKeyMs > 0 && i < chords.length - 1) {
|
|
280
|
+
await settle(interKeyMs);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
147
283
|
|
|
148
284
|
await settle(settleMs);
|
|
149
285
|
bus.emit("shell:stdout-hide", {});
|
|
150
286
|
|
|
151
|
-
|
|
287
|
+
tb.flush();
|
|
288
|
+
const after = tb.readScreen();
|
|
152
289
|
const info = [
|
|
153
|
-
altScreen ? "mode: alternate screen" : "mode: normal",
|
|
154
|
-
`cursor: row=${cursorY} col=${cursorX}`,
|
|
290
|
+
after.altScreen ? "mode: alternate screen" : "mode: normal",
|
|
291
|
+
`cursor: row=${after.cursorY} col=${after.cursorX}`,
|
|
155
292
|
].join(", ");
|
|
293
|
+
const diff = diffScreens(before.text, after.text);
|
|
156
294
|
|
|
157
295
|
return {
|
|
158
|
-
content:
|
|
296
|
+
content:
|
|
297
|
+
`Keys sent (${chords.length} chord${chords.length === 1 ? "" : "s"}).\n` +
|
|
298
|
+
`Diff:\n${diff}\n\n` +
|
|
299
|
+
`Screen after:\n[${info}]\n\n${after.text}`,
|
|
159
300
|
exitCode: 0,
|
|
160
301
|
isError: false,
|
|
161
302
|
};
|