agent-sh 0.12.22 → 0.12.23
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/core.js +4 -2
- package/dist/event-bus.d.ts +1 -0
- package/dist/executor.js +18 -4
- package/dist/shell/shell.d.ts +26 -7
- package/dist/shell/shell.js +133 -74
- package/dist/utils/floating-panel.d.ts +1 -2
- package/dist/utils/floating-panel.js +35 -36
- package/dist/utils/markdown.js +2 -2
- package/examples/extensions/overlay-agent.ts +51 -43
- package/package.json +1 -1
package/dist/core.js
CHANGED
|
@@ -204,9 +204,11 @@ export function createCore(config) {
|
|
|
204
204
|
cleanups.push(compositor.redirect("agent", surface));
|
|
205
205
|
cleanups.push(compositor.redirect("query", surface));
|
|
206
206
|
cleanups.push(compositor.redirect("status", surface));
|
|
207
|
-
//
|
|
207
|
+
// Suppress the host shell's mute lifecycle and post-turn
|
|
208
|
+
// redraw nudge. on-processing-done is intentionally not advised
|
|
209
|
+
// — its scope cleanup must always run.
|
|
208
210
|
cleanups.push(handlers.advise("shell:on-processing-start", (next) => active ? undefined : next()));
|
|
209
|
-
cleanups.push(handlers.advise("shell:on-processing-
|
|
211
|
+
cleanups.push(handlers.advise("shell:on-processing-redraw", (next) => active ? undefined : next()));
|
|
210
212
|
// Suppress chrome
|
|
211
213
|
if (opts.suppressBorders !== false) {
|
|
212
214
|
cleanups.push(handlers.advise("tui:response-border", (next, ...a) => active ? null : next(...a)));
|
package/dist/event-bus.d.ts
CHANGED
package/dist/executor.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
2
3
|
import { stripAnsi } from "./utils/ansi.js";
|
|
4
|
+
// Node reports a missing cwd as `spawn <binary> ENOENT` — disambiguate.
|
|
5
|
+
function explainSpawnError(err, cwd) {
|
|
6
|
+
if (err.code === "ENOENT" && !existsSync(cwd)) {
|
|
7
|
+
return `cwd no longer exists: ${cwd} (${err.message})`;
|
|
8
|
+
}
|
|
9
|
+
return err.message;
|
|
10
|
+
}
|
|
3
11
|
let cachedBashPath;
|
|
4
12
|
/** Resolve a usable bash binary, or null if none is on PATH.
|
|
5
13
|
* Unix: `/bin/bash` (canonical, present on every Linux/macOS install).
|
|
@@ -60,7 +68,10 @@ export function executeCommand(opts) {
|
|
|
60
68
|
catch (err) {
|
|
61
69
|
session.exitCode = -1;
|
|
62
70
|
session.spawnFailed = true;
|
|
63
|
-
|
|
71
|
+
const msg = err instanceof Error
|
|
72
|
+
? explainSpawnError(err, opts.cwd)
|
|
73
|
+
: String(err);
|
|
74
|
+
session.output = `Failed to spawn: ${msg}`;
|
|
64
75
|
session.done = true;
|
|
65
76
|
session.resolve?.();
|
|
66
77
|
return { session, done };
|
|
@@ -103,7 +114,7 @@ export function executeCommand(opts) {
|
|
|
103
114
|
const code = err.code;
|
|
104
115
|
if (code === "ENOENT" || code === "EACCES")
|
|
105
116
|
session.spawnFailed = true;
|
|
106
|
-
session.output += `\nProcess error: ${err.
|
|
117
|
+
session.output += `\nProcess error: ${explainSpawnError(err, opts.cwd)}`;
|
|
107
118
|
session.done = true;
|
|
108
119
|
session.process = null;
|
|
109
120
|
session.resolve?.();
|
|
@@ -149,7 +160,10 @@ export function executeArgv(opts) {
|
|
|
149
160
|
catch (err) {
|
|
150
161
|
session.exitCode = -1;
|
|
151
162
|
session.spawnFailed = true;
|
|
152
|
-
|
|
163
|
+
const msg = err instanceof Error
|
|
164
|
+
? explainSpawnError(err, opts.cwd)
|
|
165
|
+
: String(err);
|
|
166
|
+
session.output = `Failed to spawn ${opts.file}: ${msg}`;
|
|
153
167
|
session.done = true;
|
|
154
168
|
session.resolve?.();
|
|
155
169
|
return { session, done };
|
|
@@ -197,7 +211,7 @@ export function executeArgv(opts) {
|
|
|
197
211
|
const code = err.code;
|
|
198
212
|
if (code === "ENOENT" || code === "EACCES")
|
|
199
213
|
session.spawnFailed = true;
|
|
200
|
-
session.output += `\nProcess error: ${err.
|
|
214
|
+
session.output += `\nProcess error: ${explainSpawnError(err, opts.cwd)}`;
|
|
201
215
|
session.done = true;
|
|
202
216
|
session.process = null;
|
|
203
217
|
session.resolve?.();
|
package/dist/shell/shell.d.ts
CHANGED
|
@@ -4,16 +4,25 @@ export interface ShellHandlers {
|
|
|
4
4
|
define: (name: string, fn: (...args: any[]) => any) => void;
|
|
5
5
|
call: (name: string, ...args: any[]) => any;
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* A claim on the shell's stdout-mute state. Acquire from shell.acquire*,
|
|
9
|
+
* pair with release() in a try/finally. Token-shape forces symmetry —
|
|
10
|
+
* the only way to influence the gate is to hold and release a scope.
|
|
11
|
+
*/
|
|
12
|
+
export interface ShellScope {
|
|
13
|
+
readonly reason: string;
|
|
14
|
+
release(): void;
|
|
15
|
+
}
|
|
7
16
|
export declare class Shell implements InputContext {
|
|
8
17
|
private ptyProcess;
|
|
9
18
|
private bus;
|
|
10
19
|
private handlers;
|
|
11
20
|
private inputHandler;
|
|
12
21
|
private outputParser;
|
|
13
|
-
private
|
|
14
|
-
private
|
|
15
|
-
private
|
|
16
|
-
private
|
|
22
|
+
private hardMuteScopes;
|
|
23
|
+
private softMuteScopes;
|
|
24
|
+
private unmuteScopes;
|
|
25
|
+
private pendingEchoSkips;
|
|
17
26
|
private agentActive;
|
|
18
27
|
private isZsh;
|
|
19
28
|
private tmpDir?;
|
|
@@ -30,6 +39,15 @@ export declare class Shell implements InputContext {
|
|
|
30
39
|
cwd: string;
|
|
31
40
|
instanceId: string;
|
|
32
41
|
});
|
|
42
|
+
/** Compositing-layer claim — overrides any unmute. */
|
|
43
|
+
acquireHardMute(reason: string): ShellScope;
|
|
44
|
+
/** Agent-turn / exec-style mute — overridable by unmute. */
|
|
45
|
+
acquireMute(reason: string): ShellScope;
|
|
46
|
+
/** Force visible while held; overrides soft mutes only. */
|
|
47
|
+
acquireUnmute(reason: string): ShellScope;
|
|
48
|
+
/** Swallow the next \n-terminated chunk from PTY (one per call). */
|
|
49
|
+
skipNextLine(): void;
|
|
50
|
+
private isHostMuted;
|
|
33
51
|
isForegroundBusy(): boolean;
|
|
34
52
|
getCwd(): string;
|
|
35
53
|
isAgentActive(): boolean;
|
|
@@ -52,9 +70,10 @@ export declare class Shell implements InputContext {
|
|
|
52
70
|
private setupOutput;
|
|
53
71
|
private setupInput;
|
|
54
72
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
73
|
+
* shell:on-processing-done splits into unconditional state cleanup
|
|
74
|
+
* (release agent-turn scope) and an advisable redraw (freshPrompt).
|
|
75
|
+
* RemoteSession suppresses the redraw, never the cleanup, so soft-mute
|
|
76
|
+
* can't leak past the end of a turn even when overlays are involved.
|
|
58
77
|
*/
|
|
59
78
|
private setupAgentLifecycle;
|
|
60
79
|
/** Temp directory used for shell config and sockets. */
|
package/dist/shell/shell.js
CHANGED
|
@@ -5,17 +5,19 @@ import * as pty from "node-pty";
|
|
|
5
5
|
import { InputHandler } from "./input-handler.js";
|
|
6
6
|
import { OutputParser } from "./output-parser.js";
|
|
7
7
|
import { getSettings } from "../settings.js";
|
|
8
|
-
import { RefCounter } from "../utils/ref-counter.js";
|
|
9
8
|
export class Shell {
|
|
10
9
|
ptyProcess;
|
|
11
10
|
bus;
|
|
12
11
|
handlers;
|
|
13
12
|
inputHandler;
|
|
14
13
|
outputParser;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
// hardMute is unconditional (overlay compositing); softMute is overridable
|
|
15
|
+
// by unmute (terminal_keys, permission UI). Gate: hard wins; otherwise
|
|
16
|
+
// muted iff softMute held without an unmute.
|
|
17
|
+
hardMuteScopes = new Set();
|
|
18
|
+
softMuteScopes = new Set();
|
|
19
|
+
unmuteScopes = new Set();
|
|
20
|
+
pendingEchoSkips = 0;
|
|
19
21
|
agentActive = false;
|
|
20
22
|
isZsh = false;
|
|
21
23
|
tmpDir;
|
|
@@ -186,12 +188,75 @@ export class Shell {
|
|
|
186
188
|
this.bus.on("shell:pty-resize", ({ cols, rows }) => {
|
|
187
189
|
this.ptyProcess.resize(cols, rows);
|
|
188
190
|
});
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
this.bus.on("shell:stdout-
|
|
194
|
-
|
|
191
|
+
// Compat shims for the bus-event API. shell:stdout-hold maps to hard
|
|
192
|
+
// mute so terminal_keys' stdout-show can't paint through the overlay.
|
|
193
|
+
let holdRefcount = 0;
|
|
194
|
+
let holdScope = null;
|
|
195
|
+
this.bus.on("shell:stdout-hold", () => {
|
|
196
|
+
if (holdRefcount === 0)
|
|
197
|
+
holdScope = this.acquireHardMute("bus:stdout-hold");
|
|
198
|
+
holdRefcount++;
|
|
199
|
+
});
|
|
200
|
+
this.bus.on("shell:stdout-release", () => {
|
|
201
|
+
if (holdRefcount === 0)
|
|
202
|
+
return;
|
|
203
|
+
holdRefcount--;
|
|
204
|
+
if (holdRefcount === 0) {
|
|
205
|
+
holdScope?.release();
|
|
206
|
+
holdScope = null;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
let showRefcount = 0;
|
|
210
|
+
let showScope = null;
|
|
211
|
+
this.bus.on("shell:stdout-show", () => {
|
|
212
|
+
if (showRefcount === 0)
|
|
213
|
+
showScope = this.acquireUnmute("bus:stdout-show");
|
|
214
|
+
showRefcount++;
|
|
215
|
+
});
|
|
216
|
+
this.bus.on("shell:stdout-hide", () => {
|
|
217
|
+
if (showRefcount === 0)
|
|
218
|
+
return;
|
|
219
|
+
showRefcount--;
|
|
220
|
+
if (showRefcount === 0) {
|
|
221
|
+
showScope?.release();
|
|
222
|
+
showScope = null;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
// ── Scope-based gating ─────────────────────────────────────
|
|
227
|
+
/** Compositing-layer claim — overrides any unmute. */
|
|
228
|
+
acquireHardMute(reason) {
|
|
229
|
+
const scope = {
|
|
230
|
+
reason,
|
|
231
|
+
release: () => { this.hardMuteScopes.delete(scope); },
|
|
232
|
+
};
|
|
233
|
+
this.hardMuteScopes.add(scope);
|
|
234
|
+
return scope;
|
|
235
|
+
}
|
|
236
|
+
/** Agent-turn / exec-style mute — overridable by unmute. */
|
|
237
|
+
acquireMute(reason) {
|
|
238
|
+
const scope = {
|
|
239
|
+
reason,
|
|
240
|
+
release: () => { this.softMuteScopes.delete(scope); },
|
|
241
|
+
};
|
|
242
|
+
this.softMuteScopes.add(scope);
|
|
243
|
+
return scope;
|
|
244
|
+
}
|
|
245
|
+
/** Force visible while held; overrides soft mutes only. */
|
|
246
|
+
acquireUnmute(reason) {
|
|
247
|
+
const scope = {
|
|
248
|
+
reason,
|
|
249
|
+
release: () => { this.unmuteScopes.delete(scope); },
|
|
250
|
+
};
|
|
251
|
+
this.unmuteScopes.add(scope);
|
|
252
|
+
return scope;
|
|
253
|
+
}
|
|
254
|
+
/** Swallow the next \n-terminated chunk from PTY (one per call). */
|
|
255
|
+
skipNextLine() { this.pendingEchoSkips++; }
|
|
256
|
+
isHostMuted() {
|
|
257
|
+
if (this.hardMuteScopes.size > 0)
|
|
258
|
+
return true;
|
|
259
|
+
return this.softMuteScopes.size > 0 && this.unmuteScopes.size === 0;
|
|
195
260
|
}
|
|
196
261
|
// ── InputContext implementation (delegates to OutputParser) ──
|
|
197
262
|
isForegroundBusy() {
|
|
@@ -211,12 +276,9 @@ export class Shell {
|
|
|
211
276
|
* zsh (ZLE widget) and bash (readline redraw-current-line) bind to repaint.
|
|
212
277
|
*/
|
|
213
278
|
redrawPrompt() {
|
|
214
|
-
// Stale echoSkip/paused from handleProcessingDone re-entering a mode
|
|
215
|
-
// would swallow the redraw and freeze the terminal visually.
|
|
216
|
-
this.echoSkip = false;
|
|
217
|
-
this.paused = false;
|
|
218
279
|
const result = this.bus.emitPipe("shell:redraw-prompt", {
|
|
219
280
|
cwd: this.outputParser.getCwd(),
|
|
281
|
+
kind: "redraw",
|
|
220
282
|
handled: false,
|
|
221
283
|
});
|
|
222
284
|
if (!result.handled) {
|
|
@@ -234,6 +296,7 @@ export class Shell {
|
|
|
234
296
|
freshPrompt() {
|
|
235
297
|
const result = this.bus.emitPipe("shell:redraw-prompt", {
|
|
236
298
|
cwd: this.outputParser.getCwd(),
|
|
299
|
+
kind: "fresh",
|
|
237
300
|
handled: false,
|
|
238
301
|
});
|
|
239
302
|
if (!result.handled) {
|
|
@@ -250,16 +313,13 @@ export class Shell {
|
|
|
250
313
|
this.ptyProcess.onData((data) => {
|
|
251
314
|
this.bus.emit("shell:pty-data", { raw: data });
|
|
252
315
|
this.outputParser.processData(data);
|
|
253
|
-
if (this.
|
|
254
|
-
return;
|
|
255
|
-
if (this.paused && !this.stdoutShow.active)
|
|
316
|
+
if (this.isHostMuted())
|
|
256
317
|
return;
|
|
257
|
-
|
|
258
|
-
if (this.echoSkip) {
|
|
318
|
+
if (this.pendingEchoSkips > 0) {
|
|
259
319
|
const nlIdx = data.indexOf("\n");
|
|
260
320
|
if (nlIdx === -1)
|
|
261
321
|
return;
|
|
262
|
-
this.
|
|
322
|
+
this.pendingEchoSkips--;
|
|
263
323
|
const rest = data.slice(nlIdx + 1);
|
|
264
324
|
if (rest)
|
|
265
325
|
process.stdout.write(rest);
|
|
@@ -275,81 +335,80 @@ export class Shell {
|
|
|
275
335
|
});
|
|
276
336
|
}
|
|
277
337
|
/**
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
338
|
+
* shell:on-processing-done splits into unconditional state cleanup
|
|
339
|
+
* (release agent-turn scope) and an advisable redraw (freshPrompt).
|
|
340
|
+
* RemoteSession suppresses the redraw, never the cleanup, so soft-mute
|
|
341
|
+
* can't leak past the end of a turn even when overlays are involved.
|
|
281
342
|
*/
|
|
282
343
|
setupAgentLifecycle() {
|
|
283
|
-
|
|
284
|
-
// then redraw the prompt when done. Extensions advise these handlers
|
|
285
|
-
// to change behavior (e.g. tmux split keeps the shell interactive).
|
|
344
|
+
let agentTurnScope = null;
|
|
286
345
|
this.handlers.define("shell:on-processing-start", () => {
|
|
287
346
|
this.agentActive = true;
|
|
288
|
-
|
|
347
|
+
agentTurnScope = this.acquireMute("agent-turn");
|
|
289
348
|
});
|
|
290
|
-
this.handlers.define("shell:on-processing-
|
|
291
|
-
this.agentActive = false;
|
|
292
|
-
// If handleProcessingDone re-entered a mode, leave stdout paused so
|
|
293
|
-
// stale PTY output doesn't overwrite the mode prompt (exitMode →
|
|
294
|
-
// redrawPrompt will unpause). Setting echoSkip here would swallow
|
|
295
|
-
// that PTY output since no \n was sent.
|
|
349
|
+
this.handlers.define("shell:on-processing-redraw", () => {
|
|
296
350
|
if (!this.inputHandler.handleProcessingDone()) {
|
|
297
|
-
this.
|
|
298
|
-
|
|
299
|
-
this.echoSkip = true;
|
|
300
|
-
}
|
|
351
|
+
if (this.freshPrompt())
|
|
352
|
+
this.skipNextLine();
|
|
301
353
|
}
|
|
302
354
|
});
|
|
355
|
+
this.handlers.define("shell:on-processing-done", () => {
|
|
356
|
+
this.agentActive = false;
|
|
357
|
+
agentTurnScope?.release();
|
|
358
|
+
agentTurnScope = null;
|
|
359
|
+
this.handlers.call("shell:on-processing-redraw");
|
|
360
|
+
});
|
|
303
361
|
this.bus.on("agent:processing-start", () => {
|
|
304
362
|
this.handlers.call("shell:on-processing-start");
|
|
305
363
|
});
|
|
306
364
|
this.bus.on("agent:processing-done", () => {
|
|
307
365
|
this.handlers.call("shell:on-processing-done");
|
|
308
366
|
});
|
|
309
|
-
// Permission
|
|
310
|
-
//
|
|
367
|
+
// Permission UI is briefly visible during the prompt; an unmute scope
|
|
368
|
+
// overrides whatever mute is currently held, then releases cleanly.
|
|
369
|
+
// Doesn't touch agent-turn state, so suppressed handlers can't leak.
|
|
370
|
+
let permissionVisible = null;
|
|
311
371
|
this.bus.on("permission:request", () => {
|
|
312
|
-
|
|
372
|
+
permissionVisible?.release();
|
|
373
|
+
permissionVisible = this.acquireUnmute("permission-ui");
|
|
313
374
|
});
|
|
314
375
|
this.bus.onPipeAsync("permission:request", async (payload) => {
|
|
315
|
-
|
|
376
|
+
permissionVisible?.release();
|
|
377
|
+
permissionVisible = null;
|
|
316
378
|
return payload;
|
|
317
379
|
});
|
|
318
|
-
// Shell exec: write a command to the live PTY and capture its output.
|
|
319
|
-
// stdout is paused during agent processing, so PTY output flows through
|
|
320
|
-
// OutputParser (for OSC detection) but never reaches the terminal.
|
|
321
380
|
this.bus.onPipeAsync("shell:exec-request", async (payload) => {
|
|
322
|
-
|
|
323
|
-
this.
|
|
381
|
+
const visible = this.acquireUnmute("exec-request");
|
|
382
|
+
this.skipNextLine();
|
|
324
383
|
process.stdout.write("\n");
|
|
325
384
|
this.bus.emit("shell:agent-exec-start", {});
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
385
|
+
try {
|
|
386
|
+
const output = await new Promise((resolve, reject) => {
|
|
387
|
+
const timeout = setTimeout(() => {
|
|
388
|
+
this.bus.off("shell:command-done", handler);
|
|
389
|
+
this.ptyProcess.write("\x03");
|
|
390
|
+
reject(new Error("Shell exec timed out after 30s"));
|
|
391
|
+
}, 30_000);
|
|
392
|
+
const handler = (e) => {
|
|
393
|
+
clearTimeout(timeout);
|
|
394
|
+
this.bus.off("shell:command-done", handler);
|
|
395
|
+
resolve({ output: e.output, cwd: e.cwd, exitCode: e.exitCode });
|
|
396
|
+
};
|
|
397
|
+
this.bus.on("shell:command-done", handler);
|
|
398
|
+
this.outputParser.onCommandEntered(payload.command, this.outputParser.getCwd());
|
|
399
|
+
// Collapse literal newlines to spaces so the PTY receives a single-line
|
|
400
|
+
// command. Multi-line commands (e.g. git commit -m "...\n...") would
|
|
401
|
+
// cause the shell to execute prematurely, producing garbled output from
|
|
402
|
+
// syntax highlighting plugins (zsh syntax highlighting, etc).
|
|
403
|
+
const oneLine = payload.command.replace(/\n/g, " ");
|
|
404
|
+
this.ptyProcess.write(oneLine + "\r");
|
|
405
|
+
});
|
|
406
|
+
return { ...payload, output: output.output, cwd: output.cwd, exitCode: output.exitCode, done: true };
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
visible.release();
|
|
410
|
+
this.bus.emit("shell:agent-exec-done", {});
|
|
411
|
+
}
|
|
353
412
|
});
|
|
354
413
|
}
|
|
355
414
|
// ── Public API (used by index.ts) ──
|
|
@@ -172,7 +172,6 @@ export declare class FloatingPanel {
|
|
|
172
172
|
private prevFrame;
|
|
173
173
|
private suppressNextRedraw;
|
|
174
174
|
private autoDismissTimer;
|
|
175
|
-
private ptyBuffer;
|
|
176
175
|
private usedAltScreen;
|
|
177
176
|
private wrapCache;
|
|
178
177
|
private wrapCacheWidth;
|
|
@@ -204,6 +203,7 @@ export declare class FloatingPanel {
|
|
|
204
203
|
appendText(text: string): void;
|
|
205
204
|
appendLine(line: string): void;
|
|
206
205
|
updateLastLine(fn: (line: string) => string): void;
|
|
206
|
+
popLastLine(): void;
|
|
207
207
|
clearContent(): void;
|
|
208
208
|
setTitle(title: string): void;
|
|
209
209
|
setFooter(footer: string): void;
|
|
@@ -222,7 +222,6 @@ export declare class FloatingPanel {
|
|
|
222
222
|
private buildFrame;
|
|
223
223
|
private scheduleRender;
|
|
224
224
|
private render;
|
|
225
|
-
/** Full screen teardown: exit alt screen, release stdout, force redraw. */
|
|
226
225
|
private teardownScreen;
|
|
227
226
|
/** Start rendering TerminalBuffer directly (no overlay box). */
|
|
228
227
|
private startPassthrough;
|
|
@@ -118,7 +118,6 @@ export class FloatingPanel {
|
|
|
118
118
|
prevFrame = [];
|
|
119
119
|
suppressNextRedraw = false;
|
|
120
120
|
autoDismissTimer = null;
|
|
121
|
-
ptyBuffer = ""; // PTY output accumulated while overlay is open
|
|
122
121
|
usedAltScreen = false; // whether we entered our own alt screen
|
|
123
122
|
wrapCache = new Map(); // line → wrapped lines (invalidated on width change)
|
|
124
123
|
wrapCacheWidth = 0;
|
|
@@ -287,20 +286,14 @@ export class FloatingPanel {
|
|
|
287
286
|
}
|
|
288
287
|
// ── Bus event wiring ───────────────────────────────────────
|
|
289
288
|
wireEvents() {
|
|
290
|
-
// Buffer PTY output while overlay is visible (alt screen discards it).
|
|
291
|
-
// Don't buffer when hidden — PTY flows to terminal directly via stdout-show.
|
|
292
|
-
this.bus.on("shell:pty-data", ({ raw }) => {
|
|
293
|
-
if (this._visible)
|
|
294
|
-
this.ptyBuffer += raw;
|
|
295
|
-
});
|
|
296
289
|
this.bus.onPipe("input:intercept", (payload) => this.handleIntercept(payload));
|
|
297
290
|
this.bus.onPipe("shell:redraw-prompt", (payload) => {
|
|
298
291
|
if (this._visible || this._passthrough) {
|
|
299
292
|
return { ...payload, handled: true };
|
|
300
293
|
}
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
if (this.suppressNextRedraw) {
|
|
294
|
+
// Suppress only freshPrompt's \n — an in-place redraw must not
|
|
295
|
+
// consume the slot, or unrelated mode-exit redraws go missing.
|
|
296
|
+
if (this.suppressNextRedraw && payload.kind === "fresh") {
|
|
304
297
|
this.suppressNextRedraw = false;
|
|
305
298
|
return { ...payload, handled: true };
|
|
306
299
|
}
|
|
@@ -374,7 +367,6 @@ export class FloatingPanel {
|
|
|
374
367
|
// so the background program's screen stays correct without
|
|
375
368
|
// handing rendering control back to ncurses.
|
|
376
369
|
this._passthrough = true;
|
|
377
|
-
this.ptyBuffer = "";
|
|
378
370
|
this.startPassthrough();
|
|
379
371
|
}
|
|
380
372
|
else {
|
|
@@ -435,7 +427,6 @@ export class FloatingPanel {
|
|
|
435
427
|
/** Common screen enter logic shared by open() and show(). */
|
|
436
428
|
enterScreen() {
|
|
437
429
|
this._visible = true;
|
|
438
|
-
this.ptyBuffer = "";
|
|
439
430
|
this.bus.emit("shell:stdout-hold", {});
|
|
440
431
|
this.usedAltScreen = !(this.buffer?.altScreen);
|
|
441
432
|
if (this.usedAltScreen) {
|
|
@@ -471,6 +462,15 @@ export class FloatingPanel {
|
|
|
471
462
|
}
|
|
472
463
|
this.scheduleRender();
|
|
473
464
|
}
|
|
465
|
+
popLastLine() {
|
|
466
|
+
if (this.currentPartialLine) {
|
|
467
|
+
this.currentPartialLine = "";
|
|
468
|
+
}
|
|
469
|
+
else if (this.contentLines.length > 0) {
|
|
470
|
+
this.contentLines.pop();
|
|
471
|
+
}
|
|
472
|
+
this.scheduleRender();
|
|
473
|
+
}
|
|
474
474
|
clearContent() {
|
|
475
475
|
this.contentLines = [];
|
|
476
476
|
this.currentPartialLine = "";
|
|
@@ -751,39 +751,38 @@ export class FloatingPanel {
|
|
|
751
751
|
this.prevFrame = frame;
|
|
752
752
|
}
|
|
753
753
|
// ── Screen helpers ────────────────────────────────────────
|
|
754
|
-
/** Full screen teardown: exit alt screen, release stdout, force redraw. */
|
|
755
754
|
teardownScreen() {
|
|
756
755
|
this.resizeUnsub?.();
|
|
757
756
|
this.resizeUnsub = null;
|
|
758
757
|
this.suppressNextRedraw = true;
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
// Without this, commands run by the agent (e.g. user_shell ls)
|
|
768
|
-
// would vanish — the alt screen exit restores the saved screen
|
|
769
|
-
// from before the overlay opened, losing any shell output produced
|
|
770
|
-
// during the session.
|
|
771
|
-
if (this.ptyBuffer) {
|
|
772
|
-
this.surface.write(this.ptyBuffer);
|
|
773
|
-
}
|
|
774
|
-
this.ptyBuffer = "";
|
|
775
|
-
this.bus.emit("shell:stdout-release", {});
|
|
776
|
-
if (stillInAltScreen || programExited) {
|
|
777
|
-
// Either a TUI app is still running and needs SIGWINCH to repaint,
|
|
778
|
-
// or the overlaid program exited (e.g. agent quit vim) and we
|
|
779
|
-
// discarded its stale buffer — SIGWINCH makes the shell redraw
|
|
780
|
-
// its prompt cleanly.
|
|
758
|
+
this.buffer?.flush();
|
|
759
|
+
const programInAlt = !!this.buffer?.altScreen;
|
|
760
|
+
if (!this.usedAltScreen && programInAlt) {
|
|
761
|
+
// Program still in its own alt-screen — SIGWINCH so it redraws
|
|
762
|
+
// and re-asserts its modes; replaying from the mirror would
|
|
763
|
+
// freeze modes serialize() doesn't track (modifyOtherKeys, kitty
|
|
764
|
+
// kbd) and leave ctrl-c arriving as \x1b[27;5;99~.
|
|
765
|
+
this.bus.emit("shell:stdout-release", {});
|
|
781
766
|
const cols = this.surface.columns;
|
|
782
767
|
const rows = this.surface.rows;
|
|
783
768
|
this.bus.emit("shell:pty-resize", { cols, rows: rows - 1 });
|
|
784
769
|
setTimeout(() => {
|
|
785
770
|
this.bus.emit("shell:pty-resize", { cols, rows });
|
|
786
771
|
}, 50);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
this.surface.write("\x1b[?1049l");
|
|
775
|
+
if (!this.usedAltScreen) {
|
|
776
|
+
// Program exited mid-overlay; its reset bytes were eaten by
|
|
777
|
+
// stdout-hold. Reset modes serialize() doesn't track or the
|
|
778
|
+
// host stays in vim's modifyOtherKeys mode.
|
|
779
|
+
this.surface.write("\x1b[>4;0m\x1b[<u\x1b[?2004l\x1b[?1004l" +
|
|
780
|
+
"\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l");
|
|
781
|
+
}
|
|
782
|
+
this.bus.emit("shell:stdout-release", {});
|
|
783
|
+
const serialized = this.buffer?.serialize();
|
|
784
|
+
if (serialized) {
|
|
785
|
+
this.surface.write(`${SYNC_START}\x1b[2J\x1b[H${serialized}${SYNC_END}`);
|
|
787
786
|
}
|
|
788
787
|
}
|
|
789
788
|
// ── Passthrough rendering ─────────────────────────────────
|
|
@@ -808,7 +807,7 @@ export class FloatingPanel {
|
|
|
808
807
|
const serialized = this.buffer.serialize();
|
|
809
808
|
if (serialized && serialized !== this.prevSerialized) {
|
|
810
809
|
this.prevSerialized = serialized;
|
|
811
|
-
this.surface.write(`${SYNC_START}\x1b[H${serialized}${SYNC_END}`);
|
|
810
|
+
this.surface.write(`${SYNC_START}\x1b[2J\x1b[H${serialized}${SYNC_END}`);
|
|
812
811
|
}
|
|
813
812
|
}
|
|
814
813
|
resolveSize(spec, available) {
|
package/dist/utils/markdown.js
CHANGED
|
@@ -364,6 +364,8 @@ export class MarkdownRenderer {
|
|
|
364
364
|
return this.renderInline(line);
|
|
365
365
|
}
|
|
366
366
|
renderInline(text) {
|
|
367
|
+
// Links first — later subs inject `\x1b[…m` whose `[` would be eaten here.
|
|
368
|
+
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, `$1 ${p.muted}${p.underline}($2)${p.reset}`);
|
|
367
369
|
// Inline code
|
|
368
370
|
text = text.replace(/`([^`]+)`/g, `${p.accent}$1${p.reset}`);
|
|
369
371
|
// Bold + italic
|
|
@@ -376,8 +378,6 @@ export class MarkdownRenderer {
|
|
|
376
378
|
text = text.replace(/(?<!\w)_(.+?)_(?!\w)/g, `${p.italic}$1${p.reset}`);
|
|
377
379
|
// Strikethrough
|
|
378
380
|
text = text.replace(/~~(.+?)~~/g, `${p.dim}$1${p.reset}`);
|
|
379
|
-
// Links
|
|
380
|
-
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, `$1 ${p.muted}${p.underline}($2)${p.reset}`);
|
|
381
381
|
return text;
|
|
382
382
|
}
|
|
383
383
|
/**
|
|
@@ -1,41 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Overlay agent extension.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Press Ctrl+\ from anywhere — a shell prompt, vim, ssh, htop, a REPL — to
|
|
5
|
+
* summon the agent in a floating panel composited over the current terminal.
|
|
6
|
+
* The agent sees the live screen as `<terminal_buffer>` context (when a TUI
|
|
7
|
+
* is active) or `<shell_events>` (at a shell prompt), so screen-aware
|
|
8
|
+
* questions answer without a tool round-trip.
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
+
* Install (from an npm install of agent-sh):
|
|
11
|
+
* mkdir -p ~/.agent-sh/extensions
|
|
12
|
+
* cp "$(npm root -g)/agent-sh/examples/extensions/overlay-agent.ts" \
|
|
13
|
+
* ~/.agent-sh/extensions/
|
|
10
14
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
15
|
+
* Or load ad-hoc without copying:
|
|
16
|
+
* agent-sh -e "$(npm root -g)/agent-sh/examples/extensions/overlay-agent.ts"
|
|
13
17
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
+
* Optional companion extensions (copy the same way) — without them the
|
|
19
|
+
* overlay can read the screen but cannot interact with it:
|
|
20
|
+
* - terminal-buffer.ts → terminal_read / terminal_keys tools
|
|
21
|
+
* - user-shell.ts → user_shell tool (run new shell commands)
|
|
18
22
|
*/
|
|
19
23
|
import type { ExtensionContext, RemoteSession } from "agent-sh/types";
|
|
20
24
|
import type { RenderSurface } from "agent-sh/utils/compositor";
|
|
21
25
|
import { FloatingPanel } from "agent-sh/utils/floating-panel";
|
|
26
|
+
import { formatScreenContext, type TerminalBuffer } from "agent-sh/utils/terminal-buffer";
|
|
22
27
|
|
|
23
28
|
/** Adapt a FloatingPanel to the RenderSurface interface. */
|
|
24
29
|
function createPanelSurface(panel: FloatingPanel): RenderSurface {
|
|
30
|
+
// Track the spinner row so a stop-clear ("\r\x1b[2K") removes it
|
|
31
|
+
// instead of leaving an orphan blank line in the panel.
|
|
32
|
+
let spinnerLine = false;
|
|
25
33
|
return {
|
|
26
34
|
write(text: string): void {
|
|
27
|
-
// Handle \r (carriage return) — overwrite the current line.
|
|
28
|
-
// The spinner uses "\r <content>\x1b[K" to update in-place.
|
|
29
35
|
if (text.startsWith("\r")) {
|
|
30
|
-
// Strip \r and any erase-line sequences
|
|
31
36
|
const cleaned = text.replace(/^\r/, "").replace(/\x1b\[\d*K/g, "");
|
|
32
37
|
if (cleaned.trim()) {
|
|
33
|
-
panel.updateLastLine(() => cleaned);
|
|
38
|
+
if (spinnerLine) panel.updateLastLine(() => cleaned);
|
|
39
|
+
else { panel.appendLine(cleaned); spinnerLine = true; }
|
|
40
|
+
} else if (spinnerLine) {
|
|
41
|
+
panel.popLastLine();
|
|
42
|
+
spinnerLine = false;
|
|
34
43
|
}
|
|
35
44
|
return;
|
|
36
45
|
}
|
|
37
|
-
|
|
38
|
-
// Regular text — may contain newlines
|
|
46
|
+
if (spinnerLine) { panel.popLastLine(); spinnerLine = false; }
|
|
39
47
|
panel.appendText(text);
|
|
40
48
|
},
|
|
41
49
|
writeLine(line: string): void {
|
|
@@ -44,12 +52,23 @@ function createPanelSurface(panel: FloatingPanel): RenderSurface {
|
|
|
44
52
|
get columns(): number {
|
|
45
53
|
return panel.computeGeometry().contentW;
|
|
46
54
|
},
|
|
55
|
+
get rows(): number {
|
|
56
|
+
return panel.computeGeometry().contentH;
|
|
57
|
+
},
|
|
58
|
+
onResize(cb: (cols: number, rows: number) => void): () => void {
|
|
59
|
+
const handler = () => {
|
|
60
|
+
const g = panel.computeGeometry();
|
|
61
|
+
cb(g.contentW, g.contentH);
|
|
62
|
+
};
|
|
63
|
+
process.stdout.on("resize", handler);
|
|
64
|
+
return () => { process.stdout.off("resize", handler); };
|
|
65
|
+
},
|
|
47
66
|
};
|
|
48
67
|
}
|
|
49
68
|
|
|
50
69
|
export default function activate(ctx: ExtensionContext): void {
|
|
51
70
|
const { bus, registerInstruction, createRemoteSession } = ctx;
|
|
52
|
-
const terminalBuffer = ctx.call("terminal-buffer");
|
|
71
|
+
const terminalBuffer: TerminalBuffer | null = ctx.call("terminal-buffer");
|
|
53
72
|
|
|
54
73
|
const panel = new FloatingPanel(bus, {
|
|
55
74
|
trigger: "\x1c", // Ctrl+\
|
|
@@ -60,22 +79,21 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
60
79
|
const panelSurface = createPanelSurface(panel);
|
|
61
80
|
let session: RemoteSession | null = null;
|
|
62
81
|
|
|
63
|
-
// Tell the LLM it's running inside an overlay session. The matching
|
|
64
|
-
// system-prompt block (registered via registerInstruction below) describes
|
|
65
|
-
// how to behave in this mode.
|
|
66
82
|
ctx.registerContextProducer("interactive-session", () =>
|
|
67
83
|
session?.active ? "interactive-session: true" : null,
|
|
68
84
|
);
|
|
69
85
|
|
|
86
|
+
// Inject the live screen for TUI / REPL programs. At a plain shell prompt
|
|
87
|
+
// `<shell_events>` already covers the visible scrollback — skip to dedupe.
|
|
88
|
+
ctx.registerContextProducer("terminal-screen", () => {
|
|
89
|
+
if (!session?.active || !terminalBuffer?.altScreen) return null;
|
|
90
|
+
return formatScreenContext(terminalBuffer.readScreen(), 80);
|
|
91
|
+
});
|
|
92
|
+
|
|
70
93
|
registerInstruction("Interactive Overlay Sessions", [
|
|
71
|
-
"When
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
"- Start with terminal_read if you need to understand what's on screen.",
|
|
75
|
-
"- Prefer terminal_keys to interact with whatever is currently running.",
|
|
76
|
-
"- Use user_shell only for running new, standalone commands — not for interacting with",
|
|
77
|
-
" what's already on screen.",
|
|
78
|
-
"- Keep responses concise — the user is in the middle of a workflow.",
|
|
94
|
+
"When dynamic context includes `interactive-session: true`, the user summoned you via a",
|
|
95
|
+
"hotkey overlay from their live terminal. They're mid-workflow (shell prompt, vim, ssh, a",
|
|
96
|
+
"REPL, etc.) — keep responses concise and prefer reading what's on screen over asking.",
|
|
79
97
|
].join("\n"));
|
|
80
98
|
|
|
81
99
|
// ── Panel lifecycle ────────────────────────────────────────────
|
|
@@ -84,7 +102,6 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
84
102
|
if (!session) {
|
|
85
103
|
session = createRemoteSession({
|
|
86
104
|
surface: panelSurface,
|
|
87
|
-
suppressQueryBox: true,
|
|
88
105
|
});
|
|
89
106
|
}
|
|
90
107
|
panel.setActive();
|
|
@@ -92,18 +109,13 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
92
109
|
});
|
|
93
110
|
|
|
94
111
|
panel.handlers.advise("panel:show", (_next) => {
|
|
95
|
-
// Re-establish session if panel is shown while agent is still working
|
|
96
112
|
if (panel.active && !session) {
|
|
97
|
-
session = createRemoteSession({
|
|
98
|
-
surface: panelSurface,
|
|
99
|
-
suppressQueryBox: true,
|
|
100
|
-
});
|
|
113
|
+
session = createRemoteSession({ surface: panelSurface });
|
|
101
114
|
}
|
|
102
115
|
});
|
|
103
116
|
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
// output buffers in the panel and agent can keep executing tools.
|
|
117
|
+
// Keep the session alive while the agent is still working, even after
|
|
118
|
+
// dismiss — so output keeps buffering and tools keep executing.
|
|
107
119
|
panel.handlers.advise("panel:dismiss", (next) => {
|
|
108
120
|
next();
|
|
109
121
|
if (session && !panel.processing) {
|
|
@@ -113,10 +125,6 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
113
125
|
});
|
|
114
126
|
|
|
115
127
|
bus.on("agent:processing-done", () => {
|
|
116
|
-
if (
|
|
117
|
-
panel.setDone();
|
|
118
|
-
// If panel was hidden while processing (passthrough), setDone()
|
|
119
|
-
// triggers dismiss() which closes the session above.
|
|
120
|
-
// If panel is still visible, session stays for the follow-up prompt.
|
|
128
|
+
if (panel.active) panel.setDone();
|
|
121
129
|
});
|
|
122
130
|
}
|