agent-sh 0.6.0 → 0.8.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 (50) hide show
  1. package/README.md +5 -1
  2. package/dist/agent/agent-loop.d.ts +2 -2
  3. package/dist/agent/agent-loop.js +106 -13
  4. package/dist/agent/conversation-state.d.ts +39 -9
  5. package/dist/agent/conversation-state.js +336 -17
  6. package/dist/agent/history-file.d.ts +36 -0
  7. package/dist/agent/history-file.js +167 -0
  8. package/dist/agent/nuclear-form.d.ts +41 -0
  9. package/dist/agent/nuclear-form.js +175 -0
  10. package/dist/agent/system-prompt.d.ts +2 -2
  11. package/dist/agent/system-prompt.js +25 -4
  12. package/dist/agent/tools/user-shell.js +4 -1
  13. package/dist/context-manager.d.ts +3 -2
  14. package/dist/context-manager.js +16 -111
  15. package/dist/core.js +30 -1
  16. package/dist/event-bus.d.ts +37 -0
  17. package/dist/extensions/overlay-agent.d.ts +14 -0
  18. package/dist/extensions/overlay-agent.js +147 -0
  19. package/dist/extensions/slash-commands.js +28 -0
  20. package/dist/extensions/terminal-buffer.d.ts +14 -0
  21. package/dist/extensions/terminal-buffer.js +125 -0
  22. package/dist/extensions/tui-renderer.js +122 -84
  23. package/dist/index.js +4 -0
  24. package/dist/input-handler.js +6 -1
  25. package/dist/output-parser.js +8 -0
  26. package/dist/settings.d.ts +19 -2
  27. package/dist/settings.js +21 -3
  28. package/dist/shell.d.ts +5 -0
  29. package/dist/shell.js +31 -2
  30. package/dist/token-budget.d.ts +13 -0
  31. package/dist/token-budget.js +50 -0
  32. package/dist/types.d.ts +13 -22
  33. package/dist/utils/ansi.d.ts +10 -0
  34. package/dist/utils/ansi.js +27 -0
  35. package/dist/utils/floating-panel.d.ts +227 -0
  36. package/dist/utils/floating-panel.js +807 -0
  37. package/dist/utils/line-editor.d.ts +9 -0
  38. package/dist/utils/line-editor.js +44 -0
  39. package/dist/utils/markdown.js +3 -3
  40. package/dist/utils/output-writer.d.ts +14 -0
  41. package/dist/utils/output-writer.js +16 -0
  42. package/dist/utils/terminal-buffer.d.ts +69 -0
  43. package/dist/utils/terminal-buffer.js +179 -0
  44. package/dist/utils/tool-display.d.ts +1 -0
  45. package/dist/utils/tool-display.js +1 -1
  46. package/examples/extensions/claude-code-bridge/index.ts +77 -1
  47. package/examples/extensions/overlay-agent.ts +70 -0
  48. package/examples/extensions/pi-bridge/index.ts +87 -2
  49. package/examples/extensions/terminal-buffer.ts +184 -0
  50. package/package.json +5 -1
@@ -0,0 +1,807 @@
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 { wrapLine } from "./markdown.js";
35
+ import { LineEditor } from "./line-editor.js";
36
+ import { TerminalBuffer } from "./terminal-buffer.js";
37
+ import { HandlerRegistry } from "./handler-registry.js";
38
+ // ── ANSI constants ──────────────────────────────────────────────
39
+ const DIM = "\x1b[2m";
40
+ const RESET = "\x1b[0m";
41
+ const INVERSE = "\x1b[7m";
42
+ const SYNC_START = "\x1b[?2026h";
43
+ const SYNC_END = "\x1b[?2026l";
44
+ // ── Border characters ───────────────────────────────────────────
45
+ const BORDERS = {
46
+ rounded: { tl: "\u256d", tr: "\u256e", bl: "\u2570", br: "\u256f", h: "\u2500", v: "\u2502" },
47
+ square: { tl: "\u250c", tr: "\u2510", bl: "\u2514", br: "\u2518", h: "\u2500", v: "\u2502" },
48
+ double: { tl: "\u2554", tr: "\u2557", bl: "\u255a", br: "\u255d", h: "\u2550", v: "\u2551" },
49
+ heavy: { tl: "\u250f", tr: "\u2513", bl: "\u2517", br: "\u251b", h: "\u2501", v: "\u2503" },
50
+ };
51
+ // ── Trigger sequence helpers ────────────────────────────────────
52
+ // Programs like vim enable xterm's modifyOtherKeys or the kitty
53
+ // keyboard protocol, which encode Ctrl+key as CSI sequences instead
54
+ // of raw control bytes. We pre-compute every encoding of the
55
+ // trigger so it works regardless of what the foreground process has
56
+ // negotiated with the terminal.
57
+ function buildTriggerSequences(trigger) {
58
+ const seqs = [trigger];
59
+ if (trigger.length === 1) {
60
+ const code = trigger.charCodeAt(0);
61
+ if (code < 32) {
62
+ // Ctrl+key: base codepoint is code | 0x40 (e.g. 0x1c → 0x5c = '\')
63
+ const base = code | 0x40;
64
+ // xterm modifyOtherKeys mode 2: ESC[27;5;<base>~
65
+ seqs.push(`\x1b[27;5;${base}~`);
66
+ // kitty keyboard protocol: ESC[<base>;5u
67
+ seqs.push(`\x1b[${base};5u`);
68
+ }
69
+ }
70
+ return seqs;
71
+ }
72
+ // ── FloatingPanel ───────────────────────────────────────────────
73
+ export class FloatingPanel {
74
+ // ── Configuration ───────────────────────────────────────────
75
+ config;
76
+ bus;
77
+ border;
78
+ externalBuffer;
79
+ prefix;
80
+ /**
81
+ * Handler registry for this panel. Extensions use `handlers.advise()`
82
+ * to customize rendering and behavior.
83
+ *
84
+ * Registered handlers:
85
+ * - `{prefix}:render-content(ctx: RenderContext) -> RenderResult`
86
+ * - `{prefix}:render-frame(ctx: FrameContext) -> FrameResult`
87
+ * - `{prefix}:render-border-top(ctx: FrameContext) -> string`
88
+ * - `{prefix}:render-border-bottom(ctx: FrameContext) -> string`
89
+ * - `{prefix}:composite-row(content: string, bgLine: string|null, boxLeft: number, boxW: number, cols: number) -> string`
90
+ * - `{prefix}:submit(query: string) -> void`
91
+ * - `{prefix}:dismiss() -> void`
92
+ * - `{prefix}:show() -> void`
93
+ * - `{prefix}:input(data: string) -> boolean`
94
+ * - `{prefix}:build-row(content: string, width: number) -> string`
95
+ */
96
+ handlers;
97
+ // ── Headless terminal (lazy, optional) ──────────────────────
98
+ buffer = null;
99
+ bufferInitialized = false;
100
+ // ── Trigger sequences ───────────────────────────────────────
101
+ /** All byte sequences that should be recognized as the trigger key. */
102
+ triggerSeqs;
103
+ // ── State ───────────────────────────────────────────────────
104
+ phase = "idle";
105
+ _visible = false; // whether the panel box is shown on screen
106
+ _passthrough = false; // hidden but still rendering TerminalBuffer
107
+ editor = new LineEditor();
108
+ contentLines = [];
109
+ currentPartialLine = "";
110
+ scrollOffset = 0;
111
+ userScrolled = false; // true when user manually scrolled away from bottom
112
+ title = "";
113
+ footer = "";
114
+ renderTimer = null;
115
+ resizeHandler = null;
116
+ prevFrame = [];
117
+ suppressNextRedraw = false;
118
+ autoDismissTimer = null;
119
+ ptyBuffer = ""; // PTY output accumulated while overlay is open
120
+ usedAltScreen = false; // whether we entered our own alt screen
121
+ wrapCache = new Map(); // line → wrapped lines (invalidated on width change)
122
+ wrapCacheWidth = 0;
123
+ passthroughTimer = null;
124
+ prevSerialized = "";
125
+ constructor(bus, config, handlers) {
126
+ this.bus = bus;
127
+ this.externalBuffer = config.terminalBuffer;
128
+ this.prefix = config.handlerPrefix ?? "panel";
129
+ this.handlers = handlers ?? new HandlerRegistry();
130
+ this.config = {
131
+ trigger: config.trigger,
132
+ width: config.width ?? "80%",
133
+ maxWidth: config.maxWidth ?? 100,
134
+ height: config.height ?? "60%",
135
+ minHeight: config.minHeight ?? 6,
136
+ borderStyle: config.borderStyle ?? "rounded",
137
+ dimBackground: config.dimBackground ?? true,
138
+ autoDismissMs: config.autoDismissMs ?? 0,
139
+ promptIcon: config.promptIcon ?? "\u276f",
140
+ handlerPrefix: this.prefix,
141
+ };
142
+ this.border = BORDERS[this.config.borderStyle];
143
+ this.triggerSeqs = buildTriggerSequences(config.trigger);
144
+ this.registerDefaultHandlers();
145
+ this.wireEvents();
146
+ }
147
+ // ── Default handler registration ───────────────────────────
148
+ registerDefaultHandlers() {
149
+ const p = this.prefix;
150
+ // Default content renderer: uses built-in appendText/appendLine buffer
151
+ this.handlers.define(`${p}:render-content`, (ctx) => {
152
+ const raw = [...ctx.contentLines, ...(ctx.partialLine ? [ctx.partialLine] : [])];
153
+ // Invalidate wrap cache if width changed
154
+ if (ctx.width !== this.wrapCacheWidth) {
155
+ this.wrapCache.clear();
156
+ this.wrapCacheWidth = ctx.width;
157
+ }
158
+ const all = [];
159
+ for (const line of raw) {
160
+ let wrapped = this.wrapCache.get(line);
161
+ if (!wrapped) {
162
+ wrapped = wrapLine(line, ctx.width);
163
+ this.wrapCache.set(line, wrapped);
164
+ }
165
+ all.push(...wrapped);
166
+ }
167
+ // In input phase, append the prompt line at the bottom of content
168
+ if (ctx.phase === "input") {
169
+ const promptLine = `\x1b[36m${this.config.promptIcon}${RESET} ${ctx.inputBuffer}`;
170
+ all.push(promptLine);
171
+ }
172
+ // Scroll: auto-scroll to bottom unless user manually scrolled
173
+ let offset = ctx.scrollOffset;
174
+ const maxOffset = Math.max(0, all.length - ctx.height);
175
+ if (this.userScrolled) {
176
+ offset = Math.min(offset, maxOffset);
177
+ // Resume auto-scroll if user scrolled back to bottom
178
+ if (offset >= maxOffset)
179
+ this.userScrolled = false;
180
+ }
181
+ else {
182
+ offset = maxOffset;
183
+ }
184
+ this.scrollOffset = offset;
185
+ const visible = all.slice(offset, offset + ctx.height);
186
+ // Cursor position for input mode
187
+ if (ctx.phase === "input") {
188
+ const promptRow = visible.length - 1;
189
+ // If prompt is visible, set cursor
190
+ if (promptRow >= 0) {
191
+ return {
192
+ lines: visible,
193
+ cursor: { row: promptRow, col: this.config.promptIcon.length + 1 + ctx.inputCursor },
194
+ };
195
+ }
196
+ }
197
+ return { lines: visible };
198
+ });
199
+ // Default submit: no-op (extension overrides)
200
+ this.handlers.define(`${p}:submit`, (_query) => { });
201
+ // Default dismiss: no-op
202
+ this.handlers.define(`${p}:dismiss`, () => { });
203
+ // Default show: no-op (extension overrides to rebuild content on re-show)
204
+ this.handlers.define(`${p}:show`, () => { });
205
+ // Default custom input handler: don't consume
206
+ this.handlers.define(`${p}:input`, (_data) => false);
207
+ // Default row builder: truncate and pad
208
+ this.handlers.define(`${p}:build-row`, (content, width) => {
209
+ const plain = stripAnsi(content);
210
+ const display = plain.length > width
211
+ ? content.slice(0, width - 1) + "\u2026"
212
+ : content;
213
+ const pad = Math.max(0, width - stripAnsi(display).length);
214
+ return display + " ".repeat(pad);
215
+ });
216
+ // Default border-top renderer
217
+ this.handlers.define(`${p}:render-border-top`, (ctx) => {
218
+ const { geo, border: b } = ctx;
219
+ const titleText = ctx.title || (ctx.phase === "input" ? "input" : ctx.phase === "done" ? "done" : "...");
220
+ const titleStr = ` ${INVERSE} ${titleText} ${RESET} `;
221
+ const titleVisLen = titleText.length + 4;
222
+ const dashCount = Math.max(0, geo.boxW - titleVisLen - 3);
223
+ return `${b.tl}${b.h}${titleStr}${b.h.repeat(dashCount)}${b.tr}`;
224
+ });
225
+ // Default border-bottom renderer
226
+ this.handlers.define(`${p}:render-border-bottom`, (ctx) => {
227
+ const { geo, border: b } = ctx;
228
+ if (ctx.footer) {
229
+ const visLen = stripAnsi(ctx.footer).length;
230
+ const footerPad = Math.max(0, geo.boxW - visLen - 3);
231
+ return `${b.bl}${b.h.repeat(footerPad)}${DIM}${ctx.footer}${RESET}${b.h}${b.br}`;
232
+ }
233
+ return `${b.bl}${b.h.repeat(geo.boxW - 2)}${b.br}`;
234
+ });
235
+ // Default composite-row: merge content on top of dimmed background
236
+ this.handlers.define(`${p}:composite-row`, (boxLine, bgLine, boxLeft, boxW, cols) => {
237
+ if (bgLine !== null) {
238
+ const bg = bgLine.padEnd(cols);
239
+ return `${DIM}${bg.slice(0, boxLeft)}${RESET}${boxLine}${DIM}${bg.slice(boxLeft + boxW)}${RESET}`;
240
+ }
241
+ return boxLine;
242
+ });
243
+ // Default frame renderer: assembles borders, content rows, and background
244
+ this.handlers.define(`${p}:render-frame`, (ctx) => {
245
+ const { geo, content, bgLines, border: b } = ctx;
246
+ const visibleContent = [...(content.lines ?? [])];
247
+ while (visibleContent.length < geo.contentH)
248
+ visibleContent.push("");
249
+ const composite = (boxLine, bg) => this.handlers.call(`${p}:composite-row`, boxLine, bg, geo.boxLeft, geo.boxW, geo.cols);
250
+ const buildRow = (c, w) => this.handlers.call(`${p}:build-row`, c, w);
251
+ const frame = [];
252
+ for (let row = 0; row < geo.rows; row++) {
253
+ const relRow = row - geo.boxTop;
254
+ const bg = bgLines?.[row] ?? null;
255
+ if (relRow < 0 || relRow >= geo.boxH) {
256
+ // Outside box
257
+ if (bgLines) {
258
+ frame.push(`${DIM}${(bgLines[row] || "").padEnd(geo.cols).slice(0, geo.cols)}${RESET}\x1b[K`);
259
+ }
260
+ else {
261
+ frame.push("\x1b[2K");
262
+ }
263
+ }
264
+ else if (relRow === 0) {
265
+ frame.push(composite(this.handlers.call(`${p}:render-border-top`, ctx), bg));
266
+ }
267
+ else if (relRow === geo.boxH - 1) {
268
+ frame.push(composite(this.handlers.call(`${p}:render-border-bottom`, ctx), bg));
269
+ }
270
+ else {
271
+ const raw = visibleContent[relRow - 1] || "";
272
+ const boxLine = `${b.v} ${buildRow(raw, geo.contentW)} ${b.v}`;
273
+ frame.push(composite(boxLine, bg));
274
+ }
275
+ }
276
+ let cursorSeq = "";
277
+ if (content.cursor) {
278
+ const cursorRow = geo.boxTop + 1 + content.cursor.row;
279
+ const cursorCol = geo.boxLeft + 2 + content.cursor.col;
280
+ cursorSeq = `\x1b[${cursorRow + 1};${cursorCol + 1}H`;
281
+ }
282
+ return { rows: frame, cursorSeq };
283
+ });
284
+ }
285
+ // ── Bus event wiring ───────────────────────────────────────
286
+ wireEvents() {
287
+ // Buffer PTY output while overlay is visible (alt screen discards it).
288
+ // Don't buffer when hidden — PTY flows to terminal directly via stdout-show.
289
+ this.bus.on("shell:pty-data", ({ raw }) => {
290
+ if (this._visible)
291
+ this.ptyBuffer += raw;
292
+ });
293
+ this.bus.onPipe("input:intercept", (payload) => this.handleIntercept(payload));
294
+ this.bus.onPipe("shell:redraw-prompt", (payload) => {
295
+ if (this._visible || this._passthrough) {
296
+ return { ...payload, handled: true };
297
+ }
298
+ // After dismiss, suppress one redraw — restoreScreen already
299
+ // restored the terminal content, so freshPrompt's \n is unwanted.
300
+ if (this.suppressNextRedraw) {
301
+ this.suppressNextRedraw = false;
302
+ return { ...payload, handled: true };
303
+ }
304
+ return payload;
305
+ });
306
+ }
307
+ /** Check whether data matches any encoding of the trigger key. */
308
+ isTrigger(data) {
309
+ return this.triggerSeqs.includes(data);
310
+ }
311
+ // ── Lazy terminal buffer setup ──────────────────────────────
312
+ ensureBuffer() {
313
+ if (this.bufferInitialized)
314
+ return this.buffer;
315
+ this.bufferInitialized = true;
316
+ if (!this.config.dimBackground)
317
+ return null;
318
+ if (this.externalBuffer) {
319
+ this.buffer = this.externalBuffer;
320
+ }
321
+ else {
322
+ this.buffer = TerminalBuffer.createWired(this.bus);
323
+ }
324
+ return this.buffer;
325
+ }
326
+ // ── Public lifecycle ────────────────────────────────────────
327
+ /** Whether the panel has an active conversation (may be hidden). */
328
+ get active() {
329
+ return this.phase !== "idle";
330
+ }
331
+ /** Whether the panel is currently visible on screen. */
332
+ get visible() {
333
+ return this._visible;
334
+ }
335
+ get terminalBuffer() {
336
+ return this.buffer;
337
+ }
338
+ /** Open a fresh panel with a new conversation. */
339
+ open() {
340
+ if (this.phase !== "idle")
341
+ return;
342
+ this.ensureBuffer();
343
+ this.phase = "input";
344
+ this.editor.clear();
345
+ this.contentLines = [];
346
+ this.currentPartialLine = "";
347
+ this.scrollOffset = 0;
348
+ this.userScrolled = false;
349
+ this.title = "";
350
+ this.footer = "";
351
+ this.prevFrame = [];
352
+ this.enterScreen();
353
+ }
354
+ /** Hide the panel without destroying conversation state. */
355
+ hide() {
356
+ if (!this._visible)
357
+ return;
358
+ if (this.renderTimer) {
359
+ clearTimeout(this.renderTimer);
360
+ this.renderTimer = null;
361
+ }
362
+ this._visible = false;
363
+ this.prevFrame = [];
364
+ if (this.phase === "active" && this.buffer) {
365
+ // Agent still working — enter passthrough mode.
366
+ // Keep alt screen + stdout held. Render TerminalBuffer directly
367
+ // so the background program's screen stays correct without
368
+ // handing rendering control back to ncurses.
369
+ this._passthrough = true;
370
+ this.ptyBuffer = "";
371
+ this.startPassthrough();
372
+ }
373
+ else {
374
+ // Agent idle or done — full teardown, hand back control.
375
+ this.teardownScreen();
376
+ }
377
+ this.handlers.call(`${this.prefix}:dismiss`);
378
+ }
379
+ /** Show the panel again after hide(), preserving conversation. */
380
+ show() {
381
+ if (this._visible || this.phase === "idle")
382
+ return;
383
+ if (this._passthrough) {
384
+ // Resume from passthrough — alt screen + stdout hold already active.
385
+ this.stopPassthrough();
386
+ this._passthrough = false;
387
+ this._visible = true;
388
+ this.prevFrame = [];
389
+ this.render();
390
+ }
391
+ else {
392
+ // Cold show — need full screen setup.
393
+ this.prevFrame = [];
394
+ this.enterScreen();
395
+ }
396
+ this.handlers.call(`${this.prefix}:show`);
397
+ }
398
+ /** Fully destroy the panel, resetting all state. */
399
+ dismiss() {
400
+ if (this.phase === "idle")
401
+ return;
402
+ if (this.autoDismissTimer) {
403
+ clearTimeout(this.autoDismissTimer);
404
+ this.autoDismissTimer = null;
405
+ }
406
+ if (this._passthrough) {
407
+ this.stopPassthrough();
408
+ this._passthrough = false;
409
+ this.teardownScreen();
410
+ }
411
+ else if (this._visible) {
412
+ this._visible = false;
413
+ if (this.renderTimer) {
414
+ clearTimeout(this.renderTimer);
415
+ this.renderTimer = null;
416
+ }
417
+ this.prevFrame = [];
418
+ this.teardownScreen();
419
+ }
420
+ this.phase = "idle";
421
+ this.editor.clear();
422
+ this.contentLines = [];
423
+ this.currentPartialLine = "";
424
+ this.scrollOffset = 0;
425
+ this.title = "";
426
+ this.footer = "";
427
+ }
428
+ /** Common screen enter logic shared by open() and show(). */
429
+ enterScreen() {
430
+ this._visible = true;
431
+ this.ptyBuffer = "";
432
+ this.bus.emit("shell:stdout-hold", {});
433
+ this.usedAltScreen = !(this.buffer?.altScreen);
434
+ if (this.usedAltScreen) {
435
+ process.stdout.write("\x1b[?1049h");
436
+ }
437
+ this.resizeHandler = () => { this.prevFrame = []; this.render(); };
438
+ process.stdout.on("resize", this.resizeHandler);
439
+ this.render();
440
+ }
441
+ // ── Public content API ──────────────────────────────────────
442
+ appendText(text) {
443
+ for (const ch of text) {
444
+ if (ch === "\n") {
445
+ this.contentLines.push(this.currentPartialLine);
446
+ this.currentPartialLine = "";
447
+ }
448
+ else {
449
+ this.currentPartialLine += ch;
450
+ }
451
+ }
452
+ this.scheduleRender();
453
+ }
454
+ appendLine(line) {
455
+ if (this.currentPartialLine) {
456
+ this.contentLines.push(this.currentPartialLine);
457
+ this.currentPartialLine = "";
458
+ }
459
+ this.contentLines.push(line);
460
+ this.scheduleRender();
461
+ }
462
+ updateLastLine(fn) {
463
+ if (this.contentLines.length > 0) {
464
+ this.contentLines[this.contentLines.length - 1] = fn(this.contentLines[this.contentLines.length - 1]);
465
+ }
466
+ this.scheduleRender();
467
+ }
468
+ clearContent() {
469
+ this.contentLines = [];
470
+ this.currentPartialLine = "";
471
+ this.scrollOffset = 0;
472
+ this.scheduleRender();
473
+ }
474
+ setTitle(title) {
475
+ this.title = title;
476
+ this.scheduleRender();
477
+ }
478
+ setFooter(footer) {
479
+ this.footer = footer;
480
+ this.scheduleRender();
481
+ }
482
+ setActive() {
483
+ this.phase = "active";
484
+ }
485
+ setDone() {
486
+ if (this._passthrough) {
487
+ // Agent finished while hidden — session over, hand back control.
488
+ this.dismiss();
489
+ return;
490
+ }
491
+ if (this.config.autoDismissMs > 0) {
492
+ // Legacy behavior: enter done state, auto-dismiss after delay
493
+ this.phase = "done";
494
+ this.render();
495
+ this.autoDismissTimer = setTimeout(() => {
496
+ if (this.phase === "done")
497
+ this.dismiss();
498
+ }, this.config.autoDismissMs);
499
+ }
500
+ else {
501
+ // Auto-prompt: transition to input for follow-up conversation
502
+ this.phase = "input";
503
+ this.editor.clear();
504
+ this.render();
505
+ }
506
+ }
507
+ scrollUp(lines = 3) {
508
+ this.scrollOffset = Math.max(0, this.scrollOffset - lines);
509
+ this.userScrolled = true;
510
+ this.render();
511
+ }
512
+ scrollDown(lines = 3) {
513
+ this.scrollOffset += lines;
514
+ this.userScrolled = true;
515
+ this.render();
516
+ }
517
+ getInput() {
518
+ return this.editor.buffer;
519
+ }
520
+ requestRender() {
521
+ this.scheduleRender();
522
+ }
523
+ // ── Input handling ──────────────────────────────────────────
524
+ handleIntercept(payload) {
525
+ const consumed = { ...payload, consumed: true };
526
+ const { data } = payload;
527
+ // Toggle visibility when trigger is pressed and panel is hidden but active
528
+ if (this.isTrigger(data) && this.phase !== "idle" && !this._visible) {
529
+ this.show();
530
+ return consumed;
531
+ }
532
+ // When not visible, only intercept the trigger key
533
+ if (!this._visible && this.phase !== "idle") {
534
+ return payload;
535
+ }
536
+ switch (this.phase) {
537
+ case "done":
538
+ this.dismiss();
539
+ return consumed;
540
+ case "input":
541
+ this.handleInputKey(data);
542
+ return consumed;
543
+ case "active":
544
+ if (data === "\x03") {
545
+ this.bus.emit("agent:cancel-request", {});
546
+ }
547
+ else if (data === "\x1b" || this.isTrigger(data)) {
548
+ this.hide();
549
+ }
550
+ else if (this.handleScroll(data)) {
551
+ // scroll handled
552
+ }
553
+ else {
554
+ this.handlers.call(`${this.prefix}:input`, data);
555
+ }
556
+ return consumed;
557
+ default: // idle
558
+ if (this.isTrigger(data)) {
559
+ this.open();
560
+ return consumed;
561
+ }
562
+ return payload;
563
+ }
564
+ }
565
+ /** Handle scroll input. Returns true if consumed. */
566
+ handleScroll(data) {
567
+ // Arrow up / mouse wheel up
568
+ if (data === "\x1b[A" || data === "\x1bOA") {
569
+ this.scrollUp(1);
570
+ return true;
571
+ }
572
+ // Arrow down / mouse wheel down
573
+ if (data === "\x1b[B" || data === "\x1bOB") {
574
+ this.scrollDown(1);
575
+ return true;
576
+ }
577
+ // Page up (CSI 5~)
578
+ if (data === "\x1b[5~") {
579
+ this.scrollUp(this.computeGeometry().contentH - 1);
580
+ return true;
581
+ }
582
+ // Page down (CSI 6~)
583
+ if (data === "\x1b[6~") {
584
+ this.scrollDown(this.computeGeometry().contentH - 1);
585
+ return true;
586
+ }
587
+ // Mouse wheel: CSI M followed by button byte (64 = wheel up, 65 = wheel down)
588
+ if (data.length >= 6 && data.startsWith("\x1b[M")) {
589
+ const button = data.charCodeAt(3);
590
+ if (button === 96) {
591
+ this.scrollUp(3);
592
+ return true;
593
+ } // wheel up
594
+ if (button === 97) {
595
+ this.scrollDown(3);
596
+ return true;
597
+ } // wheel down
598
+ }
599
+ // SGR mouse: CSI < 64;x;yM (wheel up) / CSI < 65;x;yM (wheel down)
600
+ const sgr = data.match(/^\x1b\[<(64|65);\d+;\d+M$/);
601
+ if (sgr) {
602
+ if (sgr[1] === "64") {
603
+ this.scrollUp(3);
604
+ return true;
605
+ }
606
+ if (sgr[1] === "65") {
607
+ this.scrollDown(3);
608
+ return true;
609
+ }
610
+ }
611
+ return false;
612
+ }
613
+ handleInputKey(data) {
614
+ // Check full data string against trigger sequences (may be multi-byte)
615
+ if (this.isTrigger(data)) {
616
+ this.hide();
617
+ return;
618
+ }
619
+ for (let i = 0; i < data.length; i++) {
620
+ const ch = data[i];
621
+ if (ch === "\x1b" && data[i + 1] == null) {
622
+ this.hide();
623
+ return;
624
+ }
625
+ if (ch.charCodeAt(0) === 0x03) {
626
+ this.hide();
627
+ return;
628
+ }
629
+ }
630
+ // Page Up/Down and mouse wheel scroll even in input phase
631
+ if (this.handleScroll(data))
632
+ return;
633
+ const actions = this.editor.feed(data);
634
+ for (const action of actions) {
635
+ switch (action.action) {
636
+ case "submit": {
637
+ const query = this.editor.buffer.trim();
638
+ if (!query) {
639
+ this.hide();
640
+ return;
641
+ }
642
+ this.editor.pushHistory(query);
643
+ this.phase = "active";
644
+ this.editor.clear();
645
+ this.handlers.call(`${this.prefix}:submit`, query);
646
+ return;
647
+ }
648
+ case "cancel":
649
+ this.hide();
650
+ return;
651
+ case "arrow-up": {
652
+ const hist = this.editor.historyBack();
653
+ if (hist)
654
+ this.render();
655
+ break;
656
+ }
657
+ case "arrow-down": {
658
+ const hist = this.editor.historyForward();
659
+ if (hist)
660
+ this.render();
661
+ break;
662
+ }
663
+ case "changed":
664
+ case "tab":
665
+ case "shift+tab":
666
+ case "delete-empty":
667
+ this.render();
668
+ break;
669
+ }
670
+ }
671
+ }
672
+ // ── Geometry ───────────────────────────────────────────────
673
+ /** Compute box geometry from config + current terminal size. */
674
+ computeGeometry() {
675
+ const cols = process.stdout.columns || 80;
676
+ const rows = process.stdout.rows || 24;
677
+ const boxW = Math.min(this.resolveSize(this.config.width, cols - 4), this.config.maxWidth);
678
+ const boxH = Math.min(this.resolveSize(this.config.height, rows - 4), Math.max(this.config.minHeight + 2, rows - 4));
679
+ const boxTop = Math.floor((rows - boxH) / 2);
680
+ const boxLeft = Math.floor((cols - boxW) / 2);
681
+ return { cols, rows, boxW, boxH, boxTop, boxLeft, contentW: boxW - 4, contentH: boxH - 2 };
682
+ }
683
+ // ── Frame building ────────────────────────────────────────
684
+ buildFrame() {
685
+ const geo = this.computeGeometry();
686
+ // Call render-content handler
687
+ const renderCtx = {
688
+ width: geo.contentW,
689
+ height: geo.contentH,
690
+ phase: this.phase,
691
+ inputBuffer: this.editor.buffer,
692
+ inputCursor: this.editor.cursor,
693
+ scrollOffset: this.scrollOffset,
694
+ contentLines: this.contentLines,
695
+ partialLine: this.currentPartialLine,
696
+ };
697
+ const content = this.handlers.call(`${this.prefix}:render-content`, renderCtx);
698
+ // Get background
699
+ const bgLines = this.buffer?.getScreenLines(geo.rows) ?? null;
700
+ // Build frame context and delegate to render-frame handler
701
+ const frameCtx = {
702
+ geo,
703
+ content,
704
+ bgLines,
705
+ phase: this.phase,
706
+ title: this.title,
707
+ footer: this.footer,
708
+ border: this.border,
709
+ };
710
+ return this.handlers.call(`${this.prefix}:render-frame`, frameCtx);
711
+ }
712
+ // ── Rendering ─────────────────────────────────────────────
713
+ scheduleRender() {
714
+ if (this.renderTimer)
715
+ return;
716
+ this.renderTimer = setTimeout(() => {
717
+ this.renderTimer = null;
718
+ this.render();
719
+ }, 32);
720
+ }
721
+ render() {
722
+ if (this.phase === "idle" || !this._visible)
723
+ return;
724
+ const { rows: frame, cursorSeq } = this.buildFrame();
725
+ // Differential write — only send rows that changed
726
+ const out = [SYNC_START];
727
+ let dirty = false;
728
+ for (let i = 0; i < frame.length; i++) {
729
+ if (frame[i] !== this.prevFrame[i]) {
730
+ out.push(`\x1b[${i + 1};1H`);
731
+ out.push(frame[i]);
732
+ dirty = true;
733
+ }
734
+ }
735
+ for (let i = frame.length; i < this.prevFrame.length; i++) {
736
+ out.push(`\x1b[${i + 1};1H\x1b[2K`);
737
+ dirty = true;
738
+ }
739
+ if (cursorSeq)
740
+ out.push(cursorSeq);
741
+ out.push(SYNC_END);
742
+ if (this.prevFrame.length === 0 || dirty) {
743
+ process.stdout.write(out.join(""));
744
+ }
745
+ this.prevFrame = frame;
746
+ }
747
+ // ── Screen helpers ────────────────────────────────────────
748
+ /** Full screen teardown: exit alt screen, release stdout, force redraw. */
749
+ teardownScreen() {
750
+ if (this.resizeHandler) {
751
+ process.stdout.off("resize", this.resizeHandler);
752
+ this.resizeHandler = null;
753
+ }
754
+ this.suppressNextRedraw = true;
755
+ if (this.usedAltScreen) {
756
+ process.stdout.write("\x1b[?1049l");
757
+ }
758
+ // ncurses's curscr is stale — only a real dimension change triggers
759
+ // clearok + full repaint (same-size SIGWINCH is a no-op).
760
+ const cols = process.stdout.columns || 80;
761
+ const rows = process.stdout.rows || 24;
762
+ this.bus.emit("shell:pty-resize", { cols, rows: rows - 1 });
763
+ setTimeout(() => {
764
+ this.bus.emit("shell:pty-resize", { cols, rows });
765
+ }, 50);
766
+ if (!this.buffer && this.ptyBuffer) {
767
+ process.stdout.write(this.ptyBuffer);
768
+ }
769
+ this.ptyBuffer = "";
770
+ this.bus.emit("shell:stdout-hide", {});
771
+ this.bus.emit("shell:stdout-release", {});
772
+ }
773
+ // ── Passthrough rendering ─────────────────────────────────
774
+ /** Start rendering TerminalBuffer directly (no overlay box). */
775
+ startPassthrough() {
776
+ this.prevSerialized = "";
777
+ this.renderPassthrough();
778
+ this.passthroughTimer = setInterval(() => this.renderPassthrough(), 50);
779
+ }
780
+ stopPassthrough() {
781
+ if (this.passthroughTimer) {
782
+ clearInterval(this.passthroughTimer);
783
+ this.passthroughTimer = null;
784
+ }
785
+ this.prevSerialized = "";
786
+ }
787
+ /** Render the TerminalBuffer's screen content directly (no overlay). */
788
+ renderPassthrough() {
789
+ if (!this.buffer)
790
+ return;
791
+ this.buffer.flush();
792
+ const serialized = this.buffer.serialize();
793
+ if (serialized && serialized !== this.prevSerialized) {
794
+ this.prevSerialized = serialized;
795
+ process.stdout.write(`${SYNC_START}\x1b[H${serialized}${SYNC_END}`);
796
+ }
797
+ }
798
+ resolveSize(spec, available) {
799
+ if (typeof spec === "number")
800
+ return Math.min(spec, available);
801
+ if (typeof spec === "string" && spec.endsWith("%")) {
802
+ const pct = parseInt(spec, 10) / 100;
803
+ return Math.floor(available * pct);
804
+ }
805
+ return available;
806
+ }
807
+ }