agent-sh 0.5.0 → 0.7.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 +12 -43
- package/dist/agent/agent-loop.d.ts +1 -0
- package/dist/agent/agent-loop.js +119 -26
- package/dist/agent/subagent.js +3 -1
- package/dist/agent/system-prompt.d.ts +1 -1
- package/dist/agent/system-prompt.js +21 -16
- package/dist/agent/tools/bash.js +10 -1
- package/dist/agent/tools/display.d.ts +13 -0
- package/dist/agent/tools/display.js +70 -0
- package/dist/agent/tools/edit-file.js +60 -7
- package/dist/agent/tools/glob.js +39 -7
- package/dist/agent/tools/grep.js +111 -20
- package/dist/agent/tools/ls.js +31 -2
- package/dist/agent/tools/read-file.d.ts +9 -1
- package/dist/agent/tools/read-file.js +50 -4
- package/dist/agent/tools/user-shell.js +40 -13
- package/dist/agent/tools/write-file.js +9 -1
- package/dist/agent/types.d.ts +35 -1
- package/dist/context-manager.d.ts +3 -1
- package/dist/context-manager.js +11 -1
- package/dist/core.d.ts +1 -3
- package/dist/core.js +23 -12
- package/dist/event-bus.d.ts +41 -3
- package/dist/extension-loader.d.ts +1 -1
- package/dist/extension-loader.js +1 -3
- package/dist/extensions/overlay-agent.d.ts +11 -0
- package/dist/extensions/overlay-agent.js +43 -0
- package/dist/extensions/terminal-buffer.d.ts +14 -0
- package/dist/extensions/terminal-buffer.js +120 -0
- package/dist/extensions/tui-renderer.js +344 -83
- package/dist/index.js +45 -36
- package/dist/input-handler.js +10 -3
- package/dist/output-parser.js +8 -0
- package/dist/settings.js +1 -1
- package/dist/shell.d.ts +5 -0
- package/dist/shell.js +29 -4
- package/dist/types.d.ts +13 -0
- package/dist/utils/diff.js +10 -0
- package/dist/utils/floating-panel.d.ts +198 -0
- package/dist/utils/floating-panel.js +590 -0
- package/dist/utils/markdown.d.ts +1 -0
- package/dist/utils/markdown.js +23 -1
- package/dist/utils/output-writer.d.ts +14 -0
- package/dist/utils/output-writer.js +16 -0
- package/dist/utils/terminal-buffer.d.ts +65 -0
- package/dist/utils/terminal-buffer.js +166 -0
- package/dist/utils/tool-display.d.ts +4 -0
- package/dist/utils/tool-display.js +22 -5
- package/examples/extensions/claude-code-bridge/index.ts +8 -12
- package/examples/extensions/overlay-agent.ts +70 -0
- package/examples/extensions/pi-bridge/index.ts +10 -12
- package/examples/extensions/secret-guard.ts +100 -0
- package/examples/extensions/terminal-buffer.ts +184 -0
- package/package.json +5 -1
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Floating panel utility for overlay extensions.
|
|
3
|
+
*
|
|
4
|
+
* Provides a composited floating box rendered over the terminal using
|
|
5
|
+
* an alternate screen buffer. Handles the full overlay lifecycle:
|
|
6
|
+
* stdout hold/release, input routing, compositing, scroll, and
|
|
7
|
+
* screen restore.
|
|
8
|
+
*
|
|
9
|
+
* Rendering is fully customizable via the handler/advise pattern:
|
|
10
|
+
*
|
|
11
|
+
* // Replace the entire frame renderer
|
|
12
|
+
* panel.handlers.define("panel:render-frame", (ctx) => {
|
|
13
|
+
* // ctx has geo, content, bgLines, phase, title, footer, border
|
|
14
|
+
* return { rows: myCustomRows, cursorSeq: "" };
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* // Or advise individual pieces
|
|
18
|
+
* panel.handlers.advise("panel:render-border-top", (next, ctx) => {
|
|
19
|
+
* return `┏━ ${ctx.title} ${"━".repeat(ctx.geo.boxW - ctx.title.length - 5)}┓`;
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* panel.handlers.advise("panel:composite-row", (next, boxLine, bgLine, ...) => {
|
|
23
|
+
* // custom compositing (e.g. no dimming, blur effect, etc.)
|
|
24
|
+
* return next(boxLine, bgLine, ...);
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* When @xterm/headless is needed (for dimmed background compositing):
|
|
28
|
+
* npm install @xterm/headless@5.5.0 @xterm/addon-serialize@0.13.0
|
|
29
|
+
*
|
|
30
|
+
* Usage from extensions:
|
|
31
|
+
* import { FloatingPanel } from "agent-sh/utils/floating-panel.js";
|
|
32
|
+
*/
|
|
33
|
+
import { stripAnsi } from "./ansi.js";
|
|
34
|
+
import { LineEditor } from "./line-editor.js";
|
|
35
|
+
import { TerminalBuffer } from "./terminal-buffer.js";
|
|
36
|
+
import { HandlerRegistry } from "./handler-registry.js";
|
|
37
|
+
// ── ANSI constants ──────────────────────────────────────────────
|
|
38
|
+
const DIM = "\x1b[2m";
|
|
39
|
+
const RESET = "\x1b[0m";
|
|
40
|
+
const INVERSE = "\x1b[7m";
|
|
41
|
+
const SYNC_START = "\x1b[?2026h";
|
|
42
|
+
const SYNC_END = "\x1b[?2026l";
|
|
43
|
+
// ── Border characters ───────────────────────────────────────────
|
|
44
|
+
const BORDERS = {
|
|
45
|
+
rounded: { tl: "\u256d", tr: "\u256e", bl: "\u2570", br: "\u256f", h: "\u2500", v: "\u2502" },
|
|
46
|
+
square: { tl: "\u250c", tr: "\u2510", bl: "\u2514", br: "\u2518", h: "\u2500", v: "\u2502" },
|
|
47
|
+
double: { tl: "\u2554", tr: "\u2557", bl: "\u255a", br: "\u255d", h: "\u2550", v: "\u2551" },
|
|
48
|
+
heavy: { tl: "\u250f", tr: "\u2513", bl: "\u2517", br: "\u251b", h: "\u2501", v: "\u2503" },
|
|
49
|
+
};
|
|
50
|
+
// ── Trigger sequence helpers ────────────────────────────────────
|
|
51
|
+
// Programs like vim enable xterm's modifyOtherKeys or the kitty
|
|
52
|
+
// keyboard protocol, which encode Ctrl+key as CSI sequences instead
|
|
53
|
+
// of raw control bytes. We pre-compute every encoding of the
|
|
54
|
+
// trigger so it works regardless of what the foreground process has
|
|
55
|
+
// negotiated with the terminal.
|
|
56
|
+
function buildTriggerSequences(trigger) {
|
|
57
|
+
const seqs = [trigger];
|
|
58
|
+
if (trigger.length === 1) {
|
|
59
|
+
const code = trigger.charCodeAt(0);
|
|
60
|
+
if (code < 32) {
|
|
61
|
+
// Ctrl+key: base codepoint is code | 0x40 (e.g. 0x1c → 0x5c = '\')
|
|
62
|
+
const base = code | 0x40;
|
|
63
|
+
// xterm modifyOtherKeys mode 2: ESC[27;5;<base>~
|
|
64
|
+
seqs.push(`\x1b[27;5;${base}~`);
|
|
65
|
+
// kitty keyboard protocol: ESC[<base>;5u
|
|
66
|
+
seqs.push(`\x1b[${base};5u`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return seqs;
|
|
70
|
+
}
|
|
71
|
+
// ── FloatingPanel ───────────────────────────────────────────────
|
|
72
|
+
export class FloatingPanel {
|
|
73
|
+
// ── Configuration ───────────────────────────────────────────
|
|
74
|
+
config;
|
|
75
|
+
bus;
|
|
76
|
+
border;
|
|
77
|
+
externalBuffer;
|
|
78
|
+
prefix;
|
|
79
|
+
/**
|
|
80
|
+
* Handler registry for this panel. Extensions use `handlers.advise()`
|
|
81
|
+
* to customize rendering and behavior.
|
|
82
|
+
*
|
|
83
|
+
* Registered handlers:
|
|
84
|
+
* - `{prefix}:render-content(ctx: RenderContext) -> RenderResult`
|
|
85
|
+
* - `{prefix}:render-frame(ctx: FrameContext) -> FrameResult`
|
|
86
|
+
* - `{prefix}:render-border-top(ctx: FrameContext) -> string`
|
|
87
|
+
* - `{prefix}:render-border-bottom(ctx: FrameContext) -> string`
|
|
88
|
+
* - `{prefix}:composite-row(content: string, bgLine: string|null, boxLeft: number, boxW: number, cols: number) -> string`
|
|
89
|
+
* - `{prefix}:submit(query: string) -> void`
|
|
90
|
+
* - `{prefix}:dismiss() -> void`
|
|
91
|
+
* - `{prefix}:input(data: string) -> boolean`
|
|
92
|
+
* - `{prefix}:build-row(content: string, width: number) -> string`
|
|
93
|
+
*/
|
|
94
|
+
handlers;
|
|
95
|
+
// ── Headless terminal (lazy, optional) ──────────────────────
|
|
96
|
+
buffer = null;
|
|
97
|
+
bufferInitialized = false;
|
|
98
|
+
// ── Trigger sequences ───────────────────────────────────────
|
|
99
|
+
/** All byte sequences that should be recognized as the trigger key. */
|
|
100
|
+
triggerSeqs;
|
|
101
|
+
// ── State ───────────────────────────────────────────────────
|
|
102
|
+
phase = "idle";
|
|
103
|
+
editor = new LineEditor();
|
|
104
|
+
contentLines = [];
|
|
105
|
+
currentPartialLine = "";
|
|
106
|
+
scrollOffset = 0;
|
|
107
|
+
title = "";
|
|
108
|
+
footer = "";
|
|
109
|
+
renderTimer = null;
|
|
110
|
+
autoDismissTimer = null;
|
|
111
|
+
resizeHandler = null;
|
|
112
|
+
prevFrame = [];
|
|
113
|
+
suppressNextRedraw = false;
|
|
114
|
+
ptyBuffer = ""; // PTY output accumulated while overlay is open
|
|
115
|
+
usedAltScreen = false; // whether we entered our own alt screen
|
|
116
|
+
constructor(bus, config, handlers) {
|
|
117
|
+
this.bus = bus;
|
|
118
|
+
this.externalBuffer = config.terminalBuffer;
|
|
119
|
+
this.prefix = config.handlerPrefix ?? "panel";
|
|
120
|
+
this.handlers = handlers ?? new HandlerRegistry();
|
|
121
|
+
this.config = {
|
|
122
|
+
trigger: config.trigger,
|
|
123
|
+
width: config.width ?? "80%",
|
|
124
|
+
maxWidth: config.maxWidth ?? 100,
|
|
125
|
+
height: config.height ?? "60%",
|
|
126
|
+
minHeight: config.minHeight ?? 6,
|
|
127
|
+
borderStyle: config.borderStyle ?? "rounded",
|
|
128
|
+
dimBackground: config.dimBackground ?? true,
|
|
129
|
+
autoDismissMs: config.autoDismissMs ?? 0,
|
|
130
|
+
promptIcon: config.promptIcon ?? "\u276f",
|
|
131
|
+
handlerPrefix: this.prefix,
|
|
132
|
+
};
|
|
133
|
+
this.border = BORDERS[this.config.borderStyle];
|
|
134
|
+
this.triggerSeqs = buildTriggerSequences(config.trigger);
|
|
135
|
+
this.registerDefaultHandlers();
|
|
136
|
+
this.wireEvents();
|
|
137
|
+
}
|
|
138
|
+
// ── Default handler registration ───────────────────────────
|
|
139
|
+
registerDefaultHandlers() {
|
|
140
|
+
const p = this.prefix;
|
|
141
|
+
// Default content renderer: uses built-in appendText/appendLine buffer
|
|
142
|
+
this.handlers.define(`${p}:render-content`, (ctx) => {
|
|
143
|
+
if (ctx.phase === "input") {
|
|
144
|
+
return {
|
|
145
|
+
lines: [`\x1b[36m${this.config.promptIcon}${RESET} ${ctx.inputBuffer}`],
|
|
146
|
+
cursor: { row: 0, col: this.config.promptIcon.length + 1 + ctx.inputCursor },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const all = [...ctx.contentLines, ...(ctx.partialLine ? [ctx.partialLine] : [])];
|
|
150
|
+
// Auto-scroll
|
|
151
|
+
let offset = ctx.scrollOffset;
|
|
152
|
+
if (all.length > ctx.height) {
|
|
153
|
+
offset = all.length - ctx.height;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
offset = 0;
|
|
157
|
+
}
|
|
158
|
+
this.scrollOffset = offset;
|
|
159
|
+
return { lines: all.slice(offset, offset + ctx.height) };
|
|
160
|
+
});
|
|
161
|
+
// Default submit: no-op (extension overrides)
|
|
162
|
+
this.handlers.define(`${p}:submit`, (_query) => { });
|
|
163
|
+
// Default dismiss: no-op
|
|
164
|
+
this.handlers.define(`${p}:dismiss`, () => { });
|
|
165
|
+
// Default custom input handler: don't consume
|
|
166
|
+
this.handlers.define(`${p}:input`, (_data) => false);
|
|
167
|
+
// Default row builder: truncate and pad
|
|
168
|
+
this.handlers.define(`${p}:build-row`, (content, width) => {
|
|
169
|
+
const plain = stripAnsi(content);
|
|
170
|
+
const display = plain.length > width
|
|
171
|
+
? content.slice(0, width - 1) + "\u2026"
|
|
172
|
+
: content;
|
|
173
|
+
const pad = Math.max(0, width - stripAnsi(display).length);
|
|
174
|
+
return display + " ".repeat(pad);
|
|
175
|
+
});
|
|
176
|
+
// Default border-top renderer
|
|
177
|
+
this.handlers.define(`${p}:render-border-top`, (ctx) => {
|
|
178
|
+
const { geo, border: b } = ctx;
|
|
179
|
+
const titleText = ctx.title || (ctx.phase === "input" ? "input" : ctx.phase === "done" ? "done" : "...");
|
|
180
|
+
const titleStr = ` ${INVERSE} ${titleText} ${RESET} `;
|
|
181
|
+
const titleVisLen = titleText.length + 4;
|
|
182
|
+
const dashCount = Math.max(0, geo.boxW - titleVisLen - 3);
|
|
183
|
+
return `${b.tl}${b.h}${titleStr}${b.h.repeat(dashCount)}${b.tr}`;
|
|
184
|
+
});
|
|
185
|
+
// Default border-bottom renderer
|
|
186
|
+
this.handlers.define(`${p}:render-border-bottom`, (ctx) => {
|
|
187
|
+
const { geo, border: b } = ctx;
|
|
188
|
+
if (ctx.footer) {
|
|
189
|
+
const footerPad = Math.max(0, geo.boxW - ctx.footer.length - 3);
|
|
190
|
+
return `${b.bl}${b.h.repeat(footerPad)}${DIM}${ctx.footer}${RESET}${b.h}${b.br}`;
|
|
191
|
+
}
|
|
192
|
+
return `${b.bl}${b.h.repeat(geo.boxW - 2)}${b.br}`;
|
|
193
|
+
});
|
|
194
|
+
// Default composite-row: merge content on top of dimmed background
|
|
195
|
+
this.handlers.define(`${p}:composite-row`, (boxLine, bgLine, boxLeft, boxW, cols) => {
|
|
196
|
+
if (bgLine !== null) {
|
|
197
|
+
const bg = bgLine.padEnd(cols);
|
|
198
|
+
return `${DIM}${bg.slice(0, boxLeft)}${RESET}${boxLine}${DIM}${bg.slice(boxLeft + boxW)}${RESET}`;
|
|
199
|
+
}
|
|
200
|
+
return boxLine;
|
|
201
|
+
});
|
|
202
|
+
// Default frame renderer: assembles borders, content rows, and background
|
|
203
|
+
this.handlers.define(`${p}:render-frame`, (ctx) => {
|
|
204
|
+
const { geo, content, bgLines, border: b } = ctx;
|
|
205
|
+
const visibleContent = [...(content.lines ?? [])];
|
|
206
|
+
while (visibleContent.length < geo.contentH)
|
|
207
|
+
visibleContent.push("");
|
|
208
|
+
const composite = (boxLine, bg) => this.handlers.call(`${p}:composite-row`, boxLine, bg, geo.boxLeft, geo.boxW, geo.cols);
|
|
209
|
+
const buildRow = (c, w) => this.handlers.call(`${p}:build-row`, c, w);
|
|
210
|
+
const frame = [];
|
|
211
|
+
for (let row = 0; row < geo.rows; row++) {
|
|
212
|
+
const relRow = row - geo.boxTop;
|
|
213
|
+
const bg = bgLines?.[row] ?? null;
|
|
214
|
+
if (relRow < 0 || relRow >= geo.boxH) {
|
|
215
|
+
// Outside box
|
|
216
|
+
if (bgLines) {
|
|
217
|
+
frame.push(`${DIM}${(bgLines[row] || "").padEnd(geo.cols).slice(0, geo.cols)}${RESET}\x1b[K`);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
frame.push("\x1b[2K");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
else if (relRow === 0) {
|
|
224
|
+
frame.push(composite(this.handlers.call(`${p}:render-border-top`, ctx), bg));
|
|
225
|
+
}
|
|
226
|
+
else if (relRow === geo.boxH - 1) {
|
|
227
|
+
frame.push(composite(this.handlers.call(`${p}:render-border-bottom`, ctx), bg));
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
const raw = visibleContent[relRow - 1] || "";
|
|
231
|
+
const boxLine = `${b.v} ${buildRow(raw, geo.contentW)} ${b.v}`;
|
|
232
|
+
frame.push(composite(boxLine, bg));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
let cursorSeq = "";
|
|
236
|
+
if (content.cursor) {
|
|
237
|
+
const cursorRow = geo.boxTop + 1 + content.cursor.row;
|
|
238
|
+
const cursorCol = geo.boxLeft + 2 + content.cursor.col;
|
|
239
|
+
cursorSeq = `\x1b[${cursorRow + 1};${cursorCol + 1}H`;
|
|
240
|
+
}
|
|
241
|
+
return { rows: frame, cursorSeq };
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
// ── Bus event wiring ───────────────────────────────────────
|
|
245
|
+
wireEvents() {
|
|
246
|
+
// Buffer PTY output while overlay is open so we can replay it on dismiss.
|
|
247
|
+
// Alt screen restore discards anything written while it was active.
|
|
248
|
+
this.bus.on("shell:pty-data", ({ raw }) => {
|
|
249
|
+
if (this.phase !== "idle")
|
|
250
|
+
this.ptyBuffer += raw;
|
|
251
|
+
});
|
|
252
|
+
this.bus.onPipe("input:intercept", (payload) => this.handleIntercept(payload));
|
|
253
|
+
this.bus.onPipe("shell:redraw-prompt", (payload) => {
|
|
254
|
+
if (this.phase !== "idle") {
|
|
255
|
+
return { ...payload, handled: true };
|
|
256
|
+
}
|
|
257
|
+
// After dismiss, suppress one redraw — restoreScreen already
|
|
258
|
+
// restored the terminal content, so freshPrompt's \n is unwanted.
|
|
259
|
+
if (this.suppressNextRedraw) {
|
|
260
|
+
this.suppressNextRedraw = false;
|
|
261
|
+
return { ...payload, handled: true };
|
|
262
|
+
}
|
|
263
|
+
return payload;
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
/** Check whether data matches any encoding of the trigger key. */
|
|
267
|
+
isTrigger(data) {
|
|
268
|
+
return this.triggerSeqs.includes(data);
|
|
269
|
+
}
|
|
270
|
+
// ── Lazy terminal buffer setup ──────────────────────────────
|
|
271
|
+
ensureBuffer() {
|
|
272
|
+
if (this.bufferInitialized)
|
|
273
|
+
return this.buffer;
|
|
274
|
+
this.bufferInitialized = true;
|
|
275
|
+
if (!this.config.dimBackground)
|
|
276
|
+
return null;
|
|
277
|
+
if (this.externalBuffer) {
|
|
278
|
+
this.buffer = this.externalBuffer;
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
this.buffer = TerminalBuffer.createWired(this.bus);
|
|
282
|
+
}
|
|
283
|
+
return this.buffer;
|
|
284
|
+
}
|
|
285
|
+
// ── Public lifecycle ────────────────────────────────────────
|
|
286
|
+
get active() {
|
|
287
|
+
return this.phase !== "idle";
|
|
288
|
+
}
|
|
289
|
+
get terminalBuffer() {
|
|
290
|
+
return this.buffer;
|
|
291
|
+
}
|
|
292
|
+
open() {
|
|
293
|
+
if (this.phase !== "idle")
|
|
294
|
+
return;
|
|
295
|
+
this.ensureBuffer();
|
|
296
|
+
this.phase = "input";
|
|
297
|
+
this.editor.clear();
|
|
298
|
+
this.contentLines = [];
|
|
299
|
+
this.currentPartialLine = "";
|
|
300
|
+
this.scrollOffset = 0;
|
|
301
|
+
this.title = "";
|
|
302
|
+
this.footer = "";
|
|
303
|
+
this.prevFrame = [];
|
|
304
|
+
this.ptyBuffer = "";
|
|
305
|
+
this.bus.emit("shell:stdout-hold", {});
|
|
306
|
+
// If a foreground program (vim, htop) is already on alt screen,
|
|
307
|
+
// don't enter a second alt screen — it doesn't nest. Instead,
|
|
308
|
+
// render directly on the current screen and restore from the
|
|
309
|
+
// xterm buffer on dismiss.
|
|
310
|
+
this.usedAltScreen = !(this.buffer?.altScreen);
|
|
311
|
+
if (this.usedAltScreen) {
|
|
312
|
+
process.stdout.write("\x1b[?1049h");
|
|
313
|
+
}
|
|
314
|
+
this.resizeHandler = () => { this.prevFrame = []; this.render(); };
|
|
315
|
+
process.stdout.on("resize", this.resizeHandler);
|
|
316
|
+
this.render();
|
|
317
|
+
}
|
|
318
|
+
dismiss() {
|
|
319
|
+
if (this.phase === "idle")
|
|
320
|
+
return;
|
|
321
|
+
if (this.renderTimer) {
|
|
322
|
+
clearTimeout(this.renderTimer);
|
|
323
|
+
this.renderTimer = null;
|
|
324
|
+
}
|
|
325
|
+
if (this.autoDismissTimer) {
|
|
326
|
+
clearTimeout(this.autoDismissTimer);
|
|
327
|
+
this.autoDismissTimer = null;
|
|
328
|
+
}
|
|
329
|
+
if (this.resizeHandler) {
|
|
330
|
+
process.stdout.off("resize", this.resizeHandler);
|
|
331
|
+
this.resizeHandler = null;
|
|
332
|
+
}
|
|
333
|
+
this.suppressNextRedraw = true;
|
|
334
|
+
this.phase = "idle";
|
|
335
|
+
this.editor.clear();
|
|
336
|
+
this.prevFrame = [];
|
|
337
|
+
this.restoreScreen();
|
|
338
|
+
// Replay any PTY output that arrived while the overlay was open.
|
|
339
|
+
// Alt screen restore discarded it, but it represents real work
|
|
340
|
+
// the agent did (commands run, output produced).
|
|
341
|
+
if (this.ptyBuffer) {
|
|
342
|
+
process.stdout.write(this.ptyBuffer);
|
|
343
|
+
this.ptyBuffer = "";
|
|
344
|
+
}
|
|
345
|
+
// Reset any accumulated stdout-show refs, then release hold.
|
|
346
|
+
this.bus.emit("shell:stdout-hide", {});
|
|
347
|
+
this.bus.emit("shell:stdout-release", {});
|
|
348
|
+
this.handlers.call(`${this.prefix}:dismiss`);
|
|
349
|
+
}
|
|
350
|
+
// ── Public content API ──────────────────────────────────────
|
|
351
|
+
appendText(text) {
|
|
352
|
+
for (const ch of text) {
|
|
353
|
+
if (ch === "\n") {
|
|
354
|
+
this.contentLines.push(this.currentPartialLine);
|
|
355
|
+
this.currentPartialLine = "";
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
this.currentPartialLine += ch;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
this.scheduleRender();
|
|
362
|
+
}
|
|
363
|
+
appendLine(line) {
|
|
364
|
+
if (this.currentPartialLine) {
|
|
365
|
+
this.contentLines.push(this.currentPartialLine);
|
|
366
|
+
this.currentPartialLine = "";
|
|
367
|
+
}
|
|
368
|
+
this.contentLines.push(line);
|
|
369
|
+
this.scheduleRender();
|
|
370
|
+
}
|
|
371
|
+
updateLastLine(fn) {
|
|
372
|
+
if (this.contentLines.length > 0) {
|
|
373
|
+
this.contentLines[this.contentLines.length - 1] = fn(this.contentLines[this.contentLines.length - 1]);
|
|
374
|
+
}
|
|
375
|
+
this.scheduleRender();
|
|
376
|
+
}
|
|
377
|
+
clearContent() {
|
|
378
|
+
this.contentLines = [];
|
|
379
|
+
this.currentPartialLine = "";
|
|
380
|
+
this.scrollOffset = 0;
|
|
381
|
+
this.scheduleRender();
|
|
382
|
+
}
|
|
383
|
+
setTitle(title) {
|
|
384
|
+
this.title = title;
|
|
385
|
+
this.scheduleRender();
|
|
386
|
+
}
|
|
387
|
+
setFooter(footer) {
|
|
388
|
+
this.footer = footer;
|
|
389
|
+
this.scheduleRender();
|
|
390
|
+
}
|
|
391
|
+
setActive() {
|
|
392
|
+
this.phase = "active";
|
|
393
|
+
}
|
|
394
|
+
setDone() {
|
|
395
|
+
this.phase = "done";
|
|
396
|
+
this.render();
|
|
397
|
+
if (this.config.autoDismissMs > 0) {
|
|
398
|
+
this.autoDismissTimer = setTimeout(() => {
|
|
399
|
+
if (this.phase === "done")
|
|
400
|
+
this.dismiss();
|
|
401
|
+
}, this.config.autoDismissMs);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
getInput() {
|
|
405
|
+
return this.editor.buffer;
|
|
406
|
+
}
|
|
407
|
+
requestRender() {
|
|
408
|
+
this.scheduleRender();
|
|
409
|
+
}
|
|
410
|
+
// ── Input handling ──────────────────────────────────────────
|
|
411
|
+
handleIntercept(payload) {
|
|
412
|
+
const consumed = { ...payload, consumed: true };
|
|
413
|
+
const { data } = payload;
|
|
414
|
+
switch (this.phase) {
|
|
415
|
+
case "done":
|
|
416
|
+
this.dismiss();
|
|
417
|
+
return consumed;
|
|
418
|
+
case "input":
|
|
419
|
+
this.handleInputKey(data);
|
|
420
|
+
return consumed;
|
|
421
|
+
case "active":
|
|
422
|
+
if (data === "\x03")
|
|
423
|
+
this.bus.emit("agent:cancel-request", {});
|
|
424
|
+
else if (data === "\x1b" || this.isTrigger(data))
|
|
425
|
+
this.dismiss();
|
|
426
|
+
else
|
|
427
|
+
this.handlers.call(`${this.prefix}:input`, data);
|
|
428
|
+
return consumed;
|
|
429
|
+
default: // idle
|
|
430
|
+
if (this.isTrigger(data)) {
|
|
431
|
+
this.open();
|
|
432
|
+
return consumed;
|
|
433
|
+
}
|
|
434
|
+
return payload;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
handleInputKey(data) {
|
|
438
|
+
// Check full data string against trigger sequences (may be multi-byte)
|
|
439
|
+
if (this.isTrigger(data)) {
|
|
440
|
+
this.dismiss();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
for (let i = 0; i < data.length; i++) {
|
|
444
|
+
const ch = data[i];
|
|
445
|
+
if (ch === "\x1b" && data[i + 1] == null) {
|
|
446
|
+
this.dismiss();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (ch.charCodeAt(0) === 0x03) {
|
|
450
|
+
this.dismiss();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const actions = this.editor.feed(data);
|
|
455
|
+
for (const action of actions) {
|
|
456
|
+
switch (action.action) {
|
|
457
|
+
case "submit": {
|
|
458
|
+
const query = this.editor.buffer.trim();
|
|
459
|
+
if (!query) {
|
|
460
|
+
this.dismiss();
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
this.phase = "active";
|
|
464
|
+
this.editor.clear();
|
|
465
|
+
this.handlers.call(`${this.prefix}:submit`, query);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
case "cancel":
|
|
469
|
+
this.dismiss();
|
|
470
|
+
return;
|
|
471
|
+
case "changed":
|
|
472
|
+
case "tab":
|
|
473
|
+
case "shift+tab":
|
|
474
|
+
case "arrow-up":
|
|
475
|
+
case "arrow-down":
|
|
476
|
+
case "delete-empty":
|
|
477
|
+
this.render();
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// ── Geometry ───────────────────────────────────────────────
|
|
483
|
+
/** Compute box geometry from config + current terminal size. */
|
|
484
|
+
computeGeometry() {
|
|
485
|
+
const cols = process.stdout.columns || 80;
|
|
486
|
+
const rows = process.stdout.rows || 24;
|
|
487
|
+
const boxW = Math.min(this.resolveSize(this.config.width, cols - 4), this.config.maxWidth);
|
|
488
|
+
const boxH = Math.min(this.resolveSize(this.config.height, rows - 4), Math.max(this.config.minHeight + 2, rows - 4));
|
|
489
|
+
const boxTop = Math.floor((rows - boxH) / 2);
|
|
490
|
+
const boxLeft = Math.floor((cols - boxW) / 2);
|
|
491
|
+
return { cols, rows, boxW, boxH, boxTop, boxLeft, contentW: boxW - 4, contentH: boxH - 2 };
|
|
492
|
+
}
|
|
493
|
+
// ── Frame building ────────────────────────────────────────
|
|
494
|
+
buildFrame() {
|
|
495
|
+
const geo = this.computeGeometry();
|
|
496
|
+
// Call render-content handler
|
|
497
|
+
const renderCtx = {
|
|
498
|
+
width: geo.contentW,
|
|
499
|
+
height: geo.contentH,
|
|
500
|
+
phase: this.phase,
|
|
501
|
+
inputBuffer: this.editor.buffer,
|
|
502
|
+
inputCursor: this.editor.cursor,
|
|
503
|
+
scrollOffset: this.scrollOffset,
|
|
504
|
+
contentLines: this.contentLines,
|
|
505
|
+
partialLine: this.currentPartialLine,
|
|
506
|
+
};
|
|
507
|
+
const content = this.handlers.call(`${this.prefix}:render-content`, renderCtx);
|
|
508
|
+
// Get background
|
|
509
|
+
const bgLines = this.buffer?.getScreenLines(geo.rows) ?? null;
|
|
510
|
+
// Build frame context and delegate to render-frame handler
|
|
511
|
+
const frameCtx = {
|
|
512
|
+
geo,
|
|
513
|
+
content,
|
|
514
|
+
bgLines,
|
|
515
|
+
phase: this.phase,
|
|
516
|
+
title: this.title,
|
|
517
|
+
footer: this.footer,
|
|
518
|
+
border: this.border,
|
|
519
|
+
};
|
|
520
|
+
return this.handlers.call(`${this.prefix}:render-frame`, frameCtx);
|
|
521
|
+
}
|
|
522
|
+
// ── Rendering ─────────────────────────────────────────────
|
|
523
|
+
scheduleRender() {
|
|
524
|
+
if (this.renderTimer)
|
|
525
|
+
return;
|
|
526
|
+
this.renderTimer = setTimeout(() => {
|
|
527
|
+
this.renderTimer = null;
|
|
528
|
+
this.render();
|
|
529
|
+
}, 32);
|
|
530
|
+
}
|
|
531
|
+
render() {
|
|
532
|
+
if (this.phase === "idle")
|
|
533
|
+
return;
|
|
534
|
+
const { rows: frame, cursorSeq } = this.buildFrame();
|
|
535
|
+
// Differential write — only send rows that changed
|
|
536
|
+
const out = [SYNC_START];
|
|
537
|
+
let dirty = false;
|
|
538
|
+
for (let i = 0; i < frame.length; i++) {
|
|
539
|
+
if (frame[i] !== this.prevFrame[i]) {
|
|
540
|
+
out.push(`\x1b[${i + 1};1H`);
|
|
541
|
+
out.push(frame[i]);
|
|
542
|
+
dirty = true;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
for (let i = frame.length; i < this.prevFrame.length; i++) {
|
|
546
|
+
out.push(`\x1b[${i + 1};1H\x1b[2K`);
|
|
547
|
+
dirty = true;
|
|
548
|
+
}
|
|
549
|
+
if (cursorSeq)
|
|
550
|
+
out.push(cursorSeq);
|
|
551
|
+
out.push(SYNC_END);
|
|
552
|
+
if (this.prevFrame.length === 0 || dirty) {
|
|
553
|
+
process.stdout.write(out.join(""));
|
|
554
|
+
}
|
|
555
|
+
this.prevFrame = frame;
|
|
556
|
+
}
|
|
557
|
+
// ── Screen helpers ────────────────────────────────────────
|
|
558
|
+
restoreScreen() {
|
|
559
|
+
if (this.usedAltScreen) {
|
|
560
|
+
// Leave alt screen — the terminal restores the saved main buffer.
|
|
561
|
+
process.stdout.write("\x1b[?1049l");
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
// We were already on alt screen (vim, htop, etc.) so we didn't
|
|
565
|
+
// enter our own. Restore by rewriting the screen content from
|
|
566
|
+
// the xterm buffer, which mirrors what the foreground program
|
|
567
|
+
// had rendered.
|
|
568
|
+
const raw = this.buffer?.serialize() ?? "";
|
|
569
|
+
const rows = process.stdout.rows || 24;
|
|
570
|
+
const lines = raw.split("\n");
|
|
571
|
+
const out = [SYNC_START];
|
|
572
|
+
for (let i = 0; i < rows; i++) {
|
|
573
|
+
out.push(`\x1b[${i + 1};1H\x1b[2K`);
|
|
574
|
+
if (i < lines.length)
|
|
575
|
+
out.push(lines[i]);
|
|
576
|
+
}
|
|
577
|
+
out.push(SYNC_END);
|
|
578
|
+
process.stdout.write(out.join(""));
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
resolveSize(spec, available) {
|
|
582
|
+
if (typeof spec === "number")
|
|
583
|
+
return Math.min(spec, available);
|
|
584
|
+
if (typeof spec === "string" && spec.endsWith("%")) {
|
|
585
|
+
const pct = parseInt(spec, 10) / 100;
|
|
586
|
+
return Math.floor(available * pct);
|
|
587
|
+
}
|
|
588
|
+
return available;
|
|
589
|
+
}
|
|
590
|
+
}
|
package/dist/utils/markdown.d.ts
CHANGED
package/dist/utils/markdown.js
CHANGED
|
@@ -83,6 +83,7 @@ export class MarkdownRenderer {
|
|
|
83
83
|
buffer = "";
|
|
84
84
|
contentWidth;
|
|
85
85
|
firstLine = true;
|
|
86
|
+
lastLineBlank = false;
|
|
86
87
|
pendingLines = [];
|
|
87
88
|
width;
|
|
88
89
|
tableRows = [];
|
|
@@ -192,6 +193,9 @@ export class MarkdownRenderer {
|
|
|
192
193
|
}
|
|
193
194
|
// Render rows
|
|
194
195
|
const hasHeader = sepIdx.includes(1) && dataRows.length > 1;
|
|
196
|
+
// Top border
|
|
197
|
+
const topBorder = colWidths.map((w) => "─".repeat(w)).join(`─┬─`);
|
|
198
|
+
this.writeLine(`${p.dim}┌─${topBorder}─┐${p.reset}`);
|
|
195
199
|
for (let i = 0; i < dataRows.length; i++) {
|
|
196
200
|
const row = dataRows[i];
|
|
197
201
|
const isHeader = hasHeader && i === 0;
|
|
@@ -207,6 +211,9 @@ export class MarkdownRenderer {
|
|
|
207
211
|
this.writeLine(`${p.dim}├─${sep}─┤${p.reset}`);
|
|
208
212
|
}
|
|
209
213
|
}
|
|
214
|
+
// Bottom border
|
|
215
|
+
const bottomBorder = colWidths.map((w) => "─".repeat(w)).join(`─┴─`);
|
|
216
|
+
this.writeLine(`${p.dim}└─${bottomBorder}─┘${p.reset}`);
|
|
210
217
|
}
|
|
211
218
|
renderLine(line) {
|
|
212
219
|
if (line.trim() === "")
|
|
@@ -232,6 +239,16 @@ export class MarkdownRenderer {
|
|
|
232
239
|
const bq = line.match(/^>\s?(.*)/);
|
|
233
240
|
if (bq)
|
|
234
241
|
return `${p.muted}│${p.reset} ${p.dim}${p.italic}${this.renderInline(bq[1] || "")}${p.reset}`;
|
|
242
|
+
// Task list (checkbox items) — must come before generic unordered list
|
|
243
|
+
const task = line.match(/^(\s*)[*\-+]\s+\[([ xX])\]\s+(.*)/);
|
|
244
|
+
if (task) {
|
|
245
|
+
const indent = task[1] || "";
|
|
246
|
+
const checked = task[2] !== " ";
|
|
247
|
+
const box = checked
|
|
248
|
+
? `${p.success}☑${p.reset}`
|
|
249
|
+
: `${p.dim}☐${p.reset}`;
|
|
250
|
+
return `${indent} ${box} ${this.renderInline(task[3] || "")}`;
|
|
251
|
+
}
|
|
235
252
|
// Unordered list
|
|
236
253
|
const ul = line.match(/^(\s*)[*\-+]\s+(.*)/);
|
|
237
254
|
if (ul) {
|
|
@@ -268,9 +285,14 @@ export class MarkdownRenderer {
|
|
|
268
285
|
* The line is accumulated internally — call drainLines() to extract.
|
|
269
286
|
*/
|
|
270
287
|
writeLine(text) {
|
|
271
|
-
|
|
288
|
+
const isBlank = visibleLen(text) === 0;
|
|
289
|
+
if (this.firstLine && isBlank)
|
|
290
|
+
return;
|
|
291
|
+
// Collapse consecutive blank lines to a single one
|
|
292
|
+
if (isBlank && this.lastLineBlank)
|
|
272
293
|
return;
|
|
273
294
|
this.firstLine = false;
|
|
295
|
+
this.lastLineBlank = isBlank;
|
|
274
296
|
this.pendingLines.push(` ${text}`);
|
|
275
297
|
}
|
|
276
298
|
}
|
|
@@ -5,12 +5,26 @@
|
|
|
5
5
|
* process.stdout.write directly. This enables testing (BufferWriter),
|
|
6
6
|
* alternative frontends, and a single point of control for output.
|
|
7
7
|
*/
|
|
8
|
+
/** Simple ref-counted counter. Increment/decrement never goes below zero. */
|
|
9
|
+
export declare class RefCounter {
|
|
10
|
+
private count;
|
|
11
|
+
increment(): void;
|
|
12
|
+
decrement(): void;
|
|
13
|
+
reset(): void;
|
|
14
|
+
get active(): boolean;
|
|
15
|
+
get value(): number;
|
|
16
|
+
}
|
|
8
17
|
export interface OutputWriter {
|
|
9
18
|
write(text: string): void;
|
|
10
19
|
get columns(): number;
|
|
11
20
|
}
|
|
12
21
|
/** Default writer that forwards to process.stdout. */
|
|
13
22
|
export declare class StdoutWriter implements OutputWriter {
|
|
23
|
+
/** When > 0, all writes are silently dropped. Ref-counted. */
|
|
24
|
+
private readonly _hold;
|
|
25
|
+
hold(): void;
|
|
26
|
+
release(): void;
|
|
27
|
+
get held(): boolean;
|
|
14
28
|
write(text: string): void;
|
|
15
29
|
get columns(): number;
|
|
16
30
|
}
|