agent-sh 0.7.0 → 0.9.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 (86) hide show
  1. package/README.md +28 -33
  2. package/dist/agent/agent-loop.d.ts +31 -8
  3. package/dist/agent/agent-loop.js +277 -66
  4. package/dist/agent/conversation-state.d.ts +41 -9
  5. package/dist/agent/conversation-state.js +340 -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 +176 -0
  10. package/dist/agent/system-prompt.d.ts +4 -5
  11. package/dist/agent/system-prompt.js +16 -11
  12. package/dist/agent/token-budget.d.ts +13 -0
  13. package/dist/agent/token-budget.js +50 -0
  14. package/dist/agent/tool-protocol.d.ts +83 -0
  15. package/dist/agent/tool-protocol.js +386 -0
  16. package/dist/agent/tools/user-shell.js +4 -1
  17. package/dist/agent/types.d.ts +21 -1
  18. package/dist/context-manager.d.ts +0 -1
  19. package/dist/context-manager.js +5 -110
  20. package/dist/core.d.ts +7 -7
  21. package/dist/core.js +76 -180
  22. package/dist/event-bus.d.ts +40 -0
  23. package/dist/event-bus.js +20 -1
  24. package/dist/extension-loader.d.ts +5 -0
  25. package/dist/extension-loader.js +104 -17
  26. package/dist/extensions/agent-backend.d.ts +13 -0
  27. package/dist/extensions/agent-backend.js +167 -0
  28. package/dist/extensions/command-suggest.d.ts +3 -3
  29. package/dist/extensions/command-suggest.js +4 -3
  30. package/dist/extensions/index.d.ts +19 -0
  31. package/dist/extensions/index.js +25 -0
  32. package/dist/extensions/slash-commands.d.ts +1 -1
  33. package/dist/extensions/slash-commands.js +44 -1
  34. package/dist/extensions/terminal-buffer.d.ts +1 -1
  35. package/dist/extensions/terminal-buffer.js +22 -8
  36. package/dist/extensions/tui-renderer.js +177 -122
  37. package/dist/index.js +14 -20
  38. package/dist/settings.d.ts +25 -2
  39. package/dist/settings.js +25 -4
  40. package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +1 -1
  41. package/dist/{input-handler.js → shell/input-handler.js} +60 -43
  42. package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
  43. package/dist/{output-parser.js → shell/output-parser.js} +1 -1
  44. package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
  45. package/dist/{shell.js → shell/shell.js} +24 -6
  46. package/dist/types.d.ts +49 -32
  47. package/dist/utils/ansi.d.ts +10 -0
  48. package/dist/utils/ansi.js +27 -0
  49. package/dist/utils/compositor.d.ts +62 -0
  50. package/dist/utils/compositor.js +88 -0
  51. package/dist/utils/diff-renderer.js +92 -4
  52. package/dist/utils/floating-panel.d.ts +34 -3
  53. package/dist/utils/floating-panel.js +315 -82
  54. package/dist/utils/handler-registry.d.ts +26 -10
  55. package/dist/utils/handler-registry.js +52 -16
  56. package/dist/utils/line-editor.d.ts +32 -3
  57. package/dist/utils/line-editor.js +218 -36
  58. package/dist/utils/markdown.d.ts +1 -0
  59. package/dist/utils/markdown.js +4 -4
  60. package/dist/utils/message-utils.d.ts +35 -0
  61. package/dist/utils/message-utils.js +75 -0
  62. package/dist/utils/terminal-buffer.d.ts +9 -1
  63. package/dist/utils/terminal-buffer.js +31 -2
  64. package/dist/utils/tool-display.d.ts +1 -0
  65. package/dist/utils/tool-display.js +1 -1
  66. package/dist/utils/tool-interactive.d.ts +12 -0
  67. package/dist/utils/tool-interactive.js +53 -0
  68. package/examples/extensions/ash-acp-bridge/README.md +39 -0
  69. package/examples/extensions/ash-acp-bridge/package.json +23 -0
  70. package/examples/extensions/ash-acp-bridge/src/index.ts +571 -0
  71. package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
  72. package/examples/extensions/ash-mcp-bridge/README.md +72 -0
  73. package/examples/extensions/ash-mcp-bridge/index.ts +154 -0
  74. package/examples/extensions/ash-mcp-bridge/package.json +9 -0
  75. package/examples/extensions/claude-code-bridge/index.ts +77 -1
  76. package/examples/extensions/interactive-prompts.ts +82 -110
  77. package/examples/extensions/overlay-agent.ts +84 -38
  78. package/examples/extensions/peer-mesh.ts +450 -0
  79. package/examples/extensions/pi-bridge/index.ts +87 -2
  80. package/examples/extensions/questionnaire.ts +249 -0
  81. package/examples/extensions/tmux-pane.ts +307 -0
  82. package/examples/extensions/web-access.ts +327 -0
  83. package/package.json +9 -1
  84. package/dist/extensions/overlay-agent.d.ts +0 -11
  85. package/dist/extensions/overlay-agent.js +0 -43
  86. package/examples/extensions/terminal-buffer.ts +0 -184
@@ -31,6 +31,7 @@
31
31
  * import { FloatingPanel } from "agent-sh/utils/floating-panel.js";
32
32
  */
33
33
  import { stripAnsi } from "./ansi.js";
34
+ import { wrapLine } from "./markdown.js";
34
35
  import { LineEditor } from "./line-editor.js";
35
36
  import { TerminalBuffer } from "./terminal-buffer.js";
36
37
  import { HandlerRegistry } from "./handler-registry.js";
@@ -88,6 +89,7 @@ export class FloatingPanel {
88
89
  * - `{prefix}:composite-row(content: string, bgLine: string|null, boxLeft: number, boxW: number, cols: number) -> string`
89
90
  * - `{prefix}:submit(query: string) -> void`
90
91
  * - `{prefix}:dismiss() -> void`
92
+ * - `{prefix}:show() -> void`
91
93
  * - `{prefix}:input(data: string) -> boolean`
92
94
  * - `{prefix}:build-row(content: string, width: number) -> string`
93
95
  */
@@ -100,19 +102,26 @@ export class FloatingPanel {
100
102
  triggerSeqs;
101
103
  // ── State ───────────────────────────────────────────────────
102
104
  phase = "idle";
105
+ _visible = false; // whether the panel box is shown on screen
106
+ _passthrough = false; // hidden but still rendering TerminalBuffer
103
107
  editor = new LineEditor();
104
108
  contentLines = [];
105
109
  currentPartialLine = "";
106
110
  scrollOffset = 0;
111
+ userScrolled = false; // true when user manually scrolled away from bottom
107
112
  title = "";
108
113
  footer = "";
109
114
  renderTimer = null;
110
- autoDismissTimer = null;
111
115
  resizeHandler = null;
112
116
  prevFrame = [];
113
117
  suppressNextRedraw = false;
118
+ autoDismissTimer = null;
114
119
  ptyBuffer = ""; // PTY output accumulated while overlay is open
115
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 = "";
116
125
  constructor(bus, config, handlers) {
117
126
  this.bus = bus;
118
127
  this.externalBuffer = config.terminalBuffer;
@@ -140,28 +149,59 @@ export class FloatingPanel {
140
149
  const p = this.prefix;
141
150
  // Default content renderer: uses built-in appendText/appendLine buffer
142
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
143
168
  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
- };
169
+ const promptLine = `\x1b[36m${this.config.promptIcon}${RESET} ${ctx.inputBuffer}`;
170
+ all.push(promptLine);
148
171
  }
149
- const all = [...ctx.contentLines, ...(ctx.partialLine ? [ctx.partialLine] : [])];
150
- // Auto-scroll
172
+ // Scroll: auto-scroll to bottom unless user manually scrolled
151
173
  let offset = ctx.scrollOffset;
152
- if (all.length > ctx.height) {
153
- offset = all.length - ctx.height;
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;
154
180
  }
155
181
  else {
156
- offset = 0;
182
+ offset = maxOffset;
157
183
  }
158
184
  this.scrollOffset = offset;
159
- return { lines: all.slice(offset, offset + ctx.height) };
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 };
160
198
  });
161
199
  // Default submit: no-op (extension overrides)
162
200
  this.handlers.define(`${p}:submit`, (_query) => { });
163
201
  // Default dismiss: no-op
164
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`, () => { });
165
205
  // Default custom input handler: don't consume
166
206
  this.handlers.define(`${p}:input`, (_data) => false);
167
207
  // Default row builder: truncate and pad
@@ -186,7 +226,8 @@ export class FloatingPanel {
186
226
  this.handlers.define(`${p}:render-border-bottom`, (ctx) => {
187
227
  const { geo, border: b } = ctx;
188
228
  if (ctx.footer) {
189
- const footerPad = Math.max(0, geo.boxW - ctx.footer.length - 3);
229
+ const visLen = stripAnsi(ctx.footer).length;
230
+ const footerPad = Math.max(0, geo.boxW - visLen - 3);
190
231
  return `${b.bl}${b.h.repeat(footerPad)}${DIM}${ctx.footer}${RESET}${b.h}${b.br}`;
191
232
  }
192
233
  return `${b.bl}${b.h.repeat(geo.boxW - 2)}${b.br}`;
@@ -243,15 +284,15 @@ export class FloatingPanel {
243
284
  }
244
285
  // ── Bus event wiring ───────────────────────────────────────
245
286
  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.
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.
248
289
  this.bus.on("shell:pty-data", ({ raw }) => {
249
- if (this.phase !== "idle")
290
+ if (this._visible)
250
291
  this.ptyBuffer += raw;
251
292
  });
252
293
  this.bus.onPipe("input:intercept", (payload) => this.handleIntercept(payload));
253
294
  this.bus.onPipe("shell:redraw-prompt", (payload) => {
254
- if (this.phase !== "idle") {
295
+ if (this._visible || this._passthrough) {
255
296
  return { ...payload, handled: true };
256
297
  }
257
298
  // After dismiss, suppress one redraw — restoreScreen already
@@ -283,12 +324,22 @@ export class FloatingPanel {
283
324
  return this.buffer;
284
325
  }
285
326
  // ── Public lifecycle ────────────────────────────────────────
327
+ /** Whether the panel has an active conversation (may be hidden). */
286
328
  get active() {
287
329
  return this.phase !== "idle";
288
330
  }
331
+ /** Whether the agent is currently processing a query. */
332
+ get processing() {
333
+ return this.phase === "active";
334
+ }
335
+ /** Whether the panel is currently visible on screen. */
336
+ get visible() {
337
+ return this._visible;
338
+ }
289
339
  get terminalBuffer() {
290
340
  return this.buffer;
291
341
  }
342
+ /** Open a fresh panel with a new conversation. */
292
343
  open() {
293
344
  if (this.phase !== "idle")
294
345
  return;
@@ -298,54 +349,98 @@ export class FloatingPanel {
298
349
  this.contentLines = [];
299
350
  this.currentPartialLine = "";
300
351
  this.scrollOffset = 0;
352
+ this.userScrolled = false;
301
353
  this.title = "";
302
354
  this.footer = "";
303
355
  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();
356
+ this.enterScreen();
317
357
  }
318
- dismiss() {
319
- if (this.phase === "idle")
358
+ /** Hide the panel without destroying conversation state. */
359
+ hide() {
360
+ if (!this._visible)
320
361
  return;
321
362
  if (this.renderTimer) {
322
363
  clearTimeout(this.renderTimer);
323
364
  this.renderTimer = null;
324
365
  }
366
+ this._visible = false;
367
+ this.prevFrame = [];
368
+ if (this.phase === "active" && this.buffer) {
369
+ // Agent still working — enter passthrough mode.
370
+ // Keep alt screen + stdout held. Render TerminalBuffer directly
371
+ // so the background program's screen stays correct without
372
+ // handing rendering control back to ncurses.
373
+ this._passthrough = true;
374
+ this.ptyBuffer = "";
375
+ this.startPassthrough();
376
+ }
377
+ else {
378
+ // Agent idle or done — full teardown, hand back control.
379
+ this.teardownScreen();
380
+ }
381
+ this.handlers.call(`${this.prefix}:dismiss`);
382
+ }
383
+ /** Show the panel again after hide(), preserving conversation. */
384
+ show() {
385
+ if (this._visible || this.phase === "idle")
386
+ return;
387
+ if (this._passthrough) {
388
+ // Resume from passthrough — alt screen + stdout hold already active.
389
+ this.stopPassthrough();
390
+ this._passthrough = false;
391
+ this._visible = true;
392
+ this.prevFrame = [];
393
+ this.render();
394
+ }
395
+ else {
396
+ // Cold show — need full screen setup.
397
+ this.prevFrame = [];
398
+ this.enterScreen();
399
+ }
400
+ this.handlers.call(`${this.prefix}:show`);
401
+ }
402
+ /** Fully destroy the panel, resetting all state. */
403
+ dismiss() {
404
+ if (this.phase === "idle")
405
+ return;
325
406
  if (this.autoDismissTimer) {
326
407
  clearTimeout(this.autoDismissTimer);
327
408
  this.autoDismissTimer = null;
328
409
  }
329
- if (this.resizeHandler) {
330
- process.stdout.off("resize", this.resizeHandler);
331
- this.resizeHandler = null;
410
+ if (this._passthrough) {
411
+ this.stopPassthrough();
412
+ this._passthrough = false;
413
+ this.teardownScreen();
414
+ }
415
+ else if (this._visible) {
416
+ this._visible = false;
417
+ if (this.renderTimer) {
418
+ clearTimeout(this.renderTimer);
419
+ this.renderTimer = null;
420
+ }
421
+ this.prevFrame = [];
422
+ this.teardownScreen();
332
423
  }
333
- this.suppressNextRedraw = true;
334
424
  this.phase = "idle";
335
425
  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 = "";
426
+ this.contentLines = [];
427
+ this.currentPartialLine = "";
428
+ this.scrollOffset = 0;
429
+ this.title = "";
430
+ this.footer = "";
431
+ }
432
+ /** Common screen enter logic shared by open() and show(). */
433
+ enterScreen() {
434
+ this._visible = true;
435
+ this.ptyBuffer = "";
436
+ this.bus.emit("shell:stdout-hold", {});
437
+ this.usedAltScreen = !(this.buffer?.altScreen);
438
+ if (this.usedAltScreen) {
439
+ process.stdout.write("\x1b[?1049h");
344
440
  }
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`);
441
+ this.resizeHandler = () => { this.prevFrame = []; this.render(); };
442
+ process.stdout.on("resize", this.resizeHandler);
443
+ this.render();
349
444
  }
350
445
  // ── Public content API ──────────────────────────────────────
351
446
  appendText(text) {
@@ -392,17 +487,39 @@ export class FloatingPanel {
392
487
  this.phase = "active";
393
488
  }
394
489
  setDone() {
395
- this.phase = "done";
396
- this.render();
490
+ if (this._passthrough) {
491
+ // Agent finished while hidden — session over, hand back control.
492
+ this.dismiss();
493
+ return;
494
+ }
397
495
  if (this.config.autoDismissMs > 0) {
496
+ // Legacy behavior: enter done state, auto-dismiss after delay
497
+ this.phase = "done";
498
+ this.render();
398
499
  this.autoDismissTimer = setTimeout(() => {
399
500
  if (this.phase === "done")
400
501
  this.dismiss();
401
502
  }, this.config.autoDismissMs);
402
503
  }
504
+ else {
505
+ // Auto-prompt: transition to input for follow-up conversation
506
+ this.phase = "input";
507
+ this.editor.clear();
508
+ this.render();
509
+ }
510
+ }
511
+ scrollUp(lines = 3) {
512
+ this.scrollOffset = Math.max(0, this.scrollOffset - lines);
513
+ this.userScrolled = true;
514
+ this.render();
515
+ }
516
+ scrollDown(lines = 3) {
517
+ this.scrollOffset += lines;
518
+ this.userScrolled = true;
519
+ this.render();
403
520
  }
404
521
  getInput() {
405
- return this.editor.buffer;
522
+ return this.editor.text;
406
523
  }
407
524
  requestRender() {
408
525
  this.scheduleRender();
@@ -411,6 +528,15 @@ export class FloatingPanel {
411
528
  handleIntercept(payload) {
412
529
  const consumed = { ...payload, consumed: true };
413
530
  const { data } = payload;
531
+ // Toggle visibility when trigger is pressed and panel is hidden but active
532
+ if (this.isTrigger(data) && this.phase !== "idle" && !this._visible) {
533
+ this.show();
534
+ return consumed;
535
+ }
536
+ // When not visible, only intercept the trigger key
537
+ if (!this._visible && this.phase !== "idle") {
538
+ return payload;
539
+ }
414
540
  switch (this.phase) {
415
541
  case "done":
416
542
  this.dismiss();
@@ -419,12 +545,18 @@ export class FloatingPanel {
419
545
  this.handleInputKey(data);
420
546
  return consumed;
421
547
  case "active":
422
- if (data === "\x03")
548
+ if (data === "\x03") {
423
549
  this.bus.emit("agent:cancel-request", {});
424
- else if (data === "\x1b" || this.isTrigger(data))
425
- this.dismiss();
426
- else
550
+ }
551
+ else if (data === "\x1b" || this.isTrigger(data)) {
552
+ this.hide();
553
+ }
554
+ else if (this.handleScroll(data)) {
555
+ // scroll handled
556
+ }
557
+ else {
427
558
  this.handlers.call(`${this.prefix}:input`, data);
559
+ }
428
560
  return consumed;
429
561
  default: // idle
430
562
  if (this.isTrigger(data)) {
@@ -434,45 +566,107 @@ export class FloatingPanel {
434
566
  return payload;
435
567
  }
436
568
  }
569
+ /** Handle scroll input. Returns true if consumed. */
570
+ handleScroll(data) {
571
+ // Arrow up / mouse wheel up
572
+ if (data === "\x1b[A" || data === "\x1bOA") {
573
+ this.scrollUp(1);
574
+ return true;
575
+ }
576
+ // Arrow down / mouse wheel down
577
+ if (data === "\x1b[B" || data === "\x1bOB") {
578
+ this.scrollDown(1);
579
+ return true;
580
+ }
581
+ // Page up (CSI 5~)
582
+ if (data === "\x1b[5~") {
583
+ this.scrollUp(this.computeGeometry().contentH - 1);
584
+ return true;
585
+ }
586
+ // Page down (CSI 6~)
587
+ if (data === "\x1b[6~") {
588
+ this.scrollDown(this.computeGeometry().contentH - 1);
589
+ return true;
590
+ }
591
+ // Mouse wheel: CSI M followed by button byte (64 = wheel up, 65 = wheel down)
592
+ if (data.length >= 6 && data.startsWith("\x1b[M")) {
593
+ const button = data.charCodeAt(3);
594
+ if (button === 96) {
595
+ this.scrollUp(3);
596
+ return true;
597
+ } // wheel up
598
+ if (button === 97) {
599
+ this.scrollDown(3);
600
+ return true;
601
+ } // wheel down
602
+ }
603
+ // SGR mouse: CSI < 64;x;yM (wheel up) / CSI < 65;x;yM (wheel down)
604
+ const sgr = data.match(/^\x1b\[<(64|65);\d+;\d+M$/);
605
+ if (sgr) {
606
+ if (sgr[1] === "64") {
607
+ this.scrollUp(3);
608
+ return true;
609
+ }
610
+ if (sgr[1] === "65") {
611
+ this.scrollDown(3);
612
+ return true;
613
+ }
614
+ }
615
+ return false;
616
+ }
437
617
  handleInputKey(data) {
438
618
  // Check full data string against trigger sequences (may be multi-byte)
439
619
  if (this.isTrigger(data)) {
440
- this.dismiss();
620
+ this.hide();
441
621
  return;
442
622
  }
443
623
  for (let i = 0; i < data.length; i++) {
444
624
  const ch = data[i];
445
625
  if (ch === "\x1b" && data[i + 1] == null) {
446
- this.dismiss();
626
+ this.hide();
447
627
  return;
448
628
  }
449
629
  if (ch.charCodeAt(0) === 0x03) {
450
- this.dismiss();
630
+ this.hide();
451
631
  return;
452
632
  }
453
633
  }
634
+ // Page Up/Down and mouse wheel scroll even in input phase
635
+ if (this.handleScroll(data))
636
+ return;
454
637
  const actions = this.editor.feed(data);
455
638
  for (const action of actions) {
456
639
  switch (action.action) {
457
640
  case "submit": {
458
- const query = this.editor.buffer.trim();
641
+ const query = this.editor.text.trim();
459
642
  if (!query) {
460
- this.dismiss();
643
+ this.hide();
461
644
  return;
462
645
  }
646
+ this.editor.pushHistory(query);
463
647
  this.phase = "active";
464
648
  this.editor.clear();
465
649
  this.handlers.call(`${this.prefix}:submit`, query);
466
650
  return;
467
651
  }
468
652
  case "cancel":
469
- this.dismiss();
653
+ this.hide();
470
654
  return;
655
+ case "arrow-up": {
656
+ const hist = this.editor.historyBack();
657
+ if (hist)
658
+ this.render();
659
+ break;
660
+ }
661
+ case "arrow-down": {
662
+ const hist = this.editor.historyForward();
663
+ if (hist)
664
+ this.render();
665
+ break;
666
+ }
471
667
  case "changed":
472
668
  case "tab":
473
669
  case "shift+tab":
474
- case "arrow-up":
475
- case "arrow-down":
476
670
  case "delete-empty":
477
671
  this.render();
478
672
  break;
@@ -498,8 +692,8 @@ export class FloatingPanel {
498
692
  width: geo.contentW,
499
693
  height: geo.contentH,
500
694
  phase: this.phase,
501
- inputBuffer: this.editor.buffer,
502
- inputCursor: this.editor.cursor,
695
+ inputBuffer: this.editor.displayText,
696
+ inputCursor: this.editor.displayCursor,
503
697
  scrollOffset: this.scrollOffset,
504
698
  contentLines: this.contentLines,
505
699
  partialLine: this.currentPartialLine,
@@ -529,7 +723,7 @@ export class FloatingPanel {
529
723
  }, 32);
530
724
  }
531
725
  render() {
532
- if (this.phase === "idle")
726
+ if (this.phase === "idle" || !this._visible)
533
727
  return;
534
728
  const { rows: frame, cursorSeq } = this.buildFrame();
535
729
  // Differential write — only send rows that changed
@@ -555,27 +749,66 @@ export class FloatingPanel {
555
749
  this.prevFrame = frame;
556
750
  }
557
751
  // ── Screen helpers ────────────────────────────────────────
558
- restoreScreen() {
752
+ /** Full screen teardown: exit alt screen, release stdout, force redraw. */
753
+ teardownScreen() {
754
+ if (this.resizeHandler) {
755
+ process.stdout.off("resize", this.resizeHandler);
756
+ this.resizeHandler = null;
757
+ }
758
+ this.suppressNextRedraw = true;
759
+ // Re-check alt screen state: the program we overlaid may have exited
760
+ // (e.g. agent quit vim via terminal_keys) while the panel was active.
761
+ const stillInAltScreen = !this.usedAltScreen && !!this.buffer?.altScreen;
762
+ const programExited = !this.usedAltScreen && !stillInAltScreen;
559
763
  if (this.usedAltScreen) {
560
- // Leave alt screen — the terminal restores the saved main buffer.
561
764
  process.stdout.write("\x1b[?1049l");
562
765
  }
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() ?? "";
766
+ // Replay PTY output that arrived while the overlay was active.
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
+ process.stdout.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.
781
+ const cols = process.stdout.columns || 80;
569
782
  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(""));
783
+ this.bus.emit("shell:pty-resize", { cols, rows: rows - 1 });
784
+ setTimeout(() => {
785
+ this.bus.emit("shell:pty-resize", { cols, rows });
786
+ }, 50);
787
+ }
788
+ }
789
+ // ── Passthrough rendering ─────────────────────────────────
790
+ /** Start rendering TerminalBuffer directly (no overlay box). */
791
+ startPassthrough() {
792
+ this.prevSerialized = "";
793
+ this.renderPassthrough();
794
+ this.passthroughTimer = setInterval(() => this.renderPassthrough(), 50);
795
+ }
796
+ stopPassthrough() {
797
+ if (this.passthroughTimer) {
798
+ clearInterval(this.passthroughTimer);
799
+ this.passthroughTimer = null;
800
+ }
801
+ this.prevSerialized = "";
802
+ }
803
+ /** Render the TerminalBuffer's screen content directly (no overlay). */
804
+ renderPassthrough() {
805
+ if (!this.buffer)
806
+ return;
807
+ this.buffer.flush();
808
+ const serialized = this.buffer.serialize();
809
+ if (serialized && serialized !== this.prevSerialized) {
810
+ this.prevSerialized = serialized;
811
+ process.stdout.write(`${SYNC_START}\x1b[H${serialized}${SYNC_END}`);
579
812
  }
580
813
  }
581
814
  resolveSize(spec, available) {
@@ -11,27 +11,42 @@
11
11
  * if (lang === "latex") return renderLatex(code);
12
12
  * return next(lang, code); // call original
13
13
  * });
14
+ *
15
+ * Internally, each handler is stored as a base function plus an ordered
16
+ * list of advisors. `call` builds the chain on invocation, so advisors
17
+ * can be added or removed at any time without closure entanglement.
14
18
  */
19
+ type HandlerFn = (...args: any[]) => any;
20
+ type Advisor = (next: HandlerFn, ...args: any[]) => any;
21
+ /** The subset of HandlerRegistry methods available to extensions. */
22
+ export interface HandlerFunctions {
23
+ define(name: string, fn: (...args: any[]) => any): void;
24
+ advise(name: string, advisor: (next: (...args: any[]) => any, ...args: any[]) => any): () => void;
25
+ call(name: string, ...args: any[]): any;
26
+ }
15
27
  export declare class HandlerRegistry {
16
- private handlers;
28
+ private entries;
17
29
  /**
18
- * Register a named handler. If one already exists, it's replaced.
30
+ * Register a named handler. If one already exists, its base is replaced
31
+ * but existing advisors are preserved.
19
32
  */
20
- define(name: string, fn: (...args: any[]) => any): void;
33
+ define(name: string, fn: HandlerFn): void;
21
34
  /**
22
- * Wrap a named handler with advice. The wrapper receives the
23
- * previous handler as `next` and all original arguments.
35
+ * Add an advisor to a named handler. The advisor receives `next`
36
+ * (the rest of the chain) and all original arguments.
24
37
  *
25
- * - Call `next(...args)` to invoke the original (around/before/after)
38
+ * - Call `next(...args)` to invoke the rest of the chain
26
39
  * - Don't call `next` to replace entirely (override)
27
40
  * - Call `next` conditionally to wrap (around)
28
41
  *
29
- * Multiple advisors chain: each wraps the previous one.
30
- * If no handler exists yet, `next` is a no-op.
42
+ * Advisors run outermost-first (last added = outermost).
43
+ * Returns an unadvise function that cleanly removes this advisor.
31
44
  */
32
- advise(name: string, wrapper: (next: (...args: any[]) => any, ...args: any[]) => any): void;
45
+ advise(name: string, advisor: Advisor): () => void;
33
46
  /**
34
- * Call a named handler. Returns undefined if no handler is registered.
47
+ * Call a named handler. Builds the advisor chain on each call:
48
+ * outermost advisor wraps the next, down to the base handler.
49
+ * Returns undefined if no handler is registered.
35
50
  */
36
51
  call(name: string, ...args: any[]): any;
37
52
  /**
@@ -39,3 +54,4 @@ export declare class HandlerRegistry {
39
54
  */
40
55
  has(name: string): boolean;
41
56
  }
57
+ export {};