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.
@@ -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
- panel.setActive();
108
- session.submit(query);
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
- // Keep the session alive while the agent is still working, even after
118
- // dismiss so output keeps buffering and tools keep executing.
119
- panel.handlers.advise("panel:dismiss", (next) => {
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
- /** Interpret C-style escape sequences (e.g. \r → CR, \x1b ESC). */
20
- function interpretEscapes(str: string): string {
21
- return str.replace(/\\(x[0-9a-fA-F]{2}|r|n|t|\\|0)/g, (_, seq: string) => {
22
- if (seq === "r") return "\r";
23
- if (seq === "n") return "\n";
24
- if (seq === "t") return "\t";
25
- if (seq === "\\") return "\\";
26
- if (seq === "0") return "\0";
27
- if (seq.startsWith("x")) return String.fromCharCode(parseInt(seq.slice(1), 16));
28
- return seq;
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, registerInstruction } = ctx;
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. This types directly into whatever is currently on screen.\n\n" +
90
- "Escape sequences for special keys:\n" +
91
- " - Escape: \\x1b\n" +
92
- " - Enter/Return: \\r\n" +
93
- " - Tab: \\t\n" +
94
- " - Ctrl+C: \\x03\n" +
95
- " - Ctrl+D: \\x04\n" +
96
- " - Ctrl+Z: \\x1a\n" +
97
- " - Arrow keys: \\x1b[A (up), \\x1b[B (down), \\x1b[C (right), \\x1b[D (left)\n" +
98
- " - Backspace: \\x7f\n\n" +
99
- "Example: to quit vim without saving, send keys=\"\\x1b:q!\\r\" (Escape, :q!, Enter).\n" +
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. Use \\x1b for Escape, \\r for Enter, \\t for Tab, " +
108
- "\\x03 for Ctrl+C, etc. Regular characters are sent as-is.",
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 sending keys for the terminal to settle before " +
114
- "returning (default: 150). Increase for slow programs.",
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
- bus.emit("shell:pty-write", { data: keys });
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
- const { text, altScreen, cursorX, cursorY } = tb.readScreen();
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: `Keys sent. Screen after:\n[${info}]\n\n${text}`,
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
  };