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.
Files changed (54) hide show
  1. package/README.md +12 -43
  2. package/dist/agent/agent-loop.d.ts +1 -0
  3. package/dist/agent/agent-loop.js +119 -26
  4. package/dist/agent/subagent.js +3 -1
  5. package/dist/agent/system-prompt.d.ts +1 -1
  6. package/dist/agent/system-prompt.js +21 -16
  7. package/dist/agent/tools/bash.js +10 -1
  8. package/dist/agent/tools/display.d.ts +13 -0
  9. package/dist/agent/tools/display.js +70 -0
  10. package/dist/agent/tools/edit-file.js +60 -7
  11. package/dist/agent/tools/glob.js +39 -7
  12. package/dist/agent/tools/grep.js +111 -20
  13. package/dist/agent/tools/ls.js +31 -2
  14. package/dist/agent/tools/read-file.d.ts +9 -1
  15. package/dist/agent/tools/read-file.js +50 -4
  16. package/dist/agent/tools/user-shell.js +40 -13
  17. package/dist/agent/tools/write-file.js +9 -1
  18. package/dist/agent/types.d.ts +35 -1
  19. package/dist/context-manager.d.ts +3 -1
  20. package/dist/context-manager.js +11 -1
  21. package/dist/core.d.ts +1 -3
  22. package/dist/core.js +23 -12
  23. package/dist/event-bus.d.ts +41 -3
  24. package/dist/extension-loader.d.ts +1 -1
  25. package/dist/extension-loader.js +1 -3
  26. package/dist/extensions/overlay-agent.d.ts +11 -0
  27. package/dist/extensions/overlay-agent.js +43 -0
  28. package/dist/extensions/terminal-buffer.d.ts +14 -0
  29. package/dist/extensions/terminal-buffer.js +120 -0
  30. package/dist/extensions/tui-renderer.js +344 -83
  31. package/dist/index.js +45 -36
  32. package/dist/input-handler.js +10 -3
  33. package/dist/output-parser.js +8 -0
  34. package/dist/settings.js +1 -1
  35. package/dist/shell.d.ts +5 -0
  36. package/dist/shell.js +29 -4
  37. package/dist/types.d.ts +13 -0
  38. package/dist/utils/diff.js +10 -0
  39. package/dist/utils/floating-panel.d.ts +198 -0
  40. package/dist/utils/floating-panel.js +590 -0
  41. package/dist/utils/markdown.d.ts +1 -0
  42. package/dist/utils/markdown.js +23 -1
  43. package/dist/utils/output-writer.d.ts +14 -0
  44. package/dist/utils/output-writer.js +16 -0
  45. package/dist/utils/terminal-buffer.d.ts +65 -0
  46. package/dist/utils/terminal-buffer.js +166 -0
  47. package/dist/utils/tool-display.d.ts +4 -0
  48. package/dist/utils/tool-display.js +22 -5
  49. package/examples/extensions/claude-code-bridge/index.ts +8 -12
  50. package/examples/extensions/overlay-agent.ts +70 -0
  51. package/examples/extensions/pi-bridge/index.ts +10 -12
  52. package/examples/extensions/secret-guard.ts +100 -0
  53. package/examples/extensions/terminal-buffer.ts +184 -0
  54. 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
+ }
@@ -15,6 +15,7 @@ export declare class MarkdownRenderer {
15
15
  private buffer;
16
16
  private contentWidth;
17
17
  private firstLine;
18
+ private lastLineBlank;
18
19
  private pendingLines;
19
20
  private width;
20
21
  private tableRows;
@@ -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
- if (this.firstLine && visibleLen(text) === 0)
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
  }