agent-sh 0.7.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 (41) 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 +0 -1
  14. package/dist/context-manager.js +5 -110
  15. package/dist/core.js +14 -0
  16. package/dist/event-bus.d.ts +14 -0
  17. package/dist/extensions/overlay-agent.d.ts +4 -1
  18. package/dist/extensions/overlay-agent.js +115 -11
  19. package/dist/extensions/slash-commands.js +28 -0
  20. package/dist/extensions/terminal-buffer.js +9 -4
  21. package/dist/extensions/tui-renderer.js +119 -84
  22. package/dist/settings.d.ts +19 -2
  23. package/dist/settings.js +21 -3
  24. package/dist/shell.js +4 -0
  25. package/dist/token-budget.d.ts +13 -0
  26. package/dist/token-budget.js +50 -0
  27. package/dist/types.d.ts +0 -22
  28. package/dist/utils/ansi.d.ts +10 -0
  29. package/dist/utils/ansi.js +27 -0
  30. package/dist/utils/floating-panel.d.ts +32 -3
  31. package/dist/utils/floating-panel.js +296 -79
  32. package/dist/utils/line-editor.d.ts +9 -0
  33. package/dist/utils/line-editor.js +44 -0
  34. package/dist/utils/markdown.js +3 -3
  35. package/dist/utils/terminal-buffer.d.ts +4 -0
  36. package/dist/utils/terminal-buffer.js +13 -0
  37. package/dist/utils/tool-display.d.ts +1 -0
  38. package/dist/utils/tool-display.js +1 -1
  39. package/examples/extensions/claude-code-bridge/index.ts +77 -1
  40. package/examples/extensions/pi-bridge/index.ts +87 -2
  41. package/package.json +1 -1
@@ -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,18 @@ 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 panel is currently visible on screen. */
332
+ get visible() {
333
+ return this._visible;
334
+ }
289
335
  get terminalBuffer() {
290
336
  return this.buffer;
291
337
  }
338
+ /** Open a fresh panel with a new conversation. */
292
339
  open() {
293
340
  if (this.phase !== "idle")
294
341
  return;
@@ -298,54 +345,98 @@ export class FloatingPanel {
298
345
  this.contentLines = [];
299
346
  this.currentPartialLine = "";
300
347
  this.scrollOffset = 0;
348
+ this.userScrolled = false;
301
349
  this.title = "";
302
350
  this.footer = "";
303
351
  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();
352
+ this.enterScreen();
317
353
  }
318
- dismiss() {
319
- if (this.phase === "idle")
354
+ /** Hide the panel without destroying conversation state. */
355
+ hide() {
356
+ if (!this._visible)
320
357
  return;
321
358
  if (this.renderTimer) {
322
359
  clearTimeout(this.renderTimer);
323
360
  this.renderTimer = null;
324
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;
325
402
  if (this.autoDismissTimer) {
326
403
  clearTimeout(this.autoDismissTimer);
327
404
  this.autoDismissTimer = null;
328
405
  }
329
- if (this.resizeHandler) {
330
- process.stdout.off("resize", this.resizeHandler);
331
- this.resizeHandler = null;
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();
332
419
  }
333
- this.suppressNextRedraw = true;
334
420
  this.phase = "idle";
335
421
  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 = "";
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");
344
436
  }
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`);
437
+ this.resizeHandler = () => { this.prevFrame = []; this.render(); };
438
+ process.stdout.on("resize", this.resizeHandler);
439
+ this.render();
349
440
  }
350
441
  // ── Public content API ──────────────────────────────────────
351
442
  appendText(text) {
@@ -392,14 +483,36 @@ export class FloatingPanel {
392
483
  this.phase = "active";
393
484
  }
394
485
  setDone() {
395
- this.phase = "done";
396
- this.render();
486
+ if (this._passthrough) {
487
+ // Agent finished while hidden — session over, hand back control.
488
+ this.dismiss();
489
+ return;
490
+ }
397
491
  if (this.config.autoDismissMs > 0) {
492
+ // Legacy behavior: enter done state, auto-dismiss after delay
493
+ this.phase = "done";
494
+ this.render();
398
495
  this.autoDismissTimer = setTimeout(() => {
399
496
  if (this.phase === "done")
400
497
  this.dismiss();
401
498
  }, this.config.autoDismissMs);
402
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();
403
516
  }
404
517
  getInput() {
405
518
  return this.editor.buffer;
@@ -411,6 +524,15 @@ export class FloatingPanel {
411
524
  handleIntercept(payload) {
412
525
  const consumed = { ...payload, consumed: true };
413
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
+ }
414
536
  switch (this.phase) {
415
537
  case "done":
416
538
  this.dismiss();
@@ -419,12 +541,18 @@ export class FloatingPanel {
419
541
  this.handleInputKey(data);
420
542
  return consumed;
421
543
  case "active":
422
- if (data === "\x03")
544
+ if (data === "\x03") {
423
545
  this.bus.emit("agent:cancel-request", {});
424
- else if (data === "\x1b" || this.isTrigger(data))
425
- this.dismiss();
426
- else
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 {
427
554
  this.handlers.call(`${this.prefix}:input`, data);
555
+ }
428
556
  return consumed;
429
557
  default: // idle
430
558
  if (this.isTrigger(data)) {
@@ -434,45 +562,107 @@ export class FloatingPanel {
434
562
  return payload;
435
563
  }
436
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
+ }
437
613
  handleInputKey(data) {
438
614
  // Check full data string against trigger sequences (may be multi-byte)
439
615
  if (this.isTrigger(data)) {
440
- this.dismiss();
616
+ this.hide();
441
617
  return;
442
618
  }
443
619
  for (let i = 0; i < data.length; i++) {
444
620
  const ch = data[i];
445
621
  if (ch === "\x1b" && data[i + 1] == null) {
446
- this.dismiss();
622
+ this.hide();
447
623
  return;
448
624
  }
449
625
  if (ch.charCodeAt(0) === 0x03) {
450
- this.dismiss();
626
+ this.hide();
451
627
  return;
452
628
  }
453
629
  }
630
+ // Page Up/Down and mouse wheel scroll even in input phase
631
+ if (this.handleScroll(data))
632
+ return;
454
633
  const actions = this.editor.feed(data);
455
634
  for (const action of actions) {
456
635
  switch (action.action) {
457
636
  case "submit": {
458
637
  const query = this.editor.buffer.trim();
459
638
  if (!query) {
460
- this.dismiss();
639
+ this.hide();
461
640
  return;
462
641
  }
642
+ this.editor.pushHistory(query);
463
643
  this.phase = "active";
464
644
  this.editor.clear();
465
645
  this.handlers.call(`${this.prefix}:submit`, query);
466
646
  return;
467
647
  }
468
648
  case "cancel":
469
- this.dismiss();
649
+ this.hide();
470
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
+ }
471
663
  case "changed":
472
664
  case "tab":
473
665
  case "shift+tab":
474
- case "arrow-up":
475
- case "arrow-down":
476
666
  case "delete-empty":
477
667
  this.render();
478
668
  break;
@@ -529,7 +719,7 @@ export class FloatingPanel {
529
719
  }, 32);
530
720
  }
531
721
  render() {
532
- if (this.phase === "idle")
722
+ if (this.phase === "idle" || !this._visible)
533
723
  return;
534
724
  const { rows: frame, cursorSeq } = this.buildFrame();
535
725
  // Differential write — only send rows that changed
@@ -555,27 +745,54 @@ export class FloatingPanel {
555
745
  this.prevFrame = frame;
556
746
  }
557
747
  // ── Screen helpers ────────────────────────────────────────
558
- restoreScreen() {
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;
559
755
  if (this.usedAltScreen) {
560
- // Leave alt screen — the terminal restores the saved main buffer.
561
756
  process.stdout.write("\x1b[?1049l");
562
757
  }
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(""));
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}`);
579
796
  }
580
797
  }
581
798
  resolveSize(spec, available) {
@@ -27,6 +27,9 @@ export declare class LineEditor {
27
27
  buffer: string;
28
28
  cursor: number;
29
29
  private pendingSeq;
30
+ private history;
31
+ private historyIndex;
32
+ private savedBuffer;
30
33
  /** Process raw terminal input, return actions for the consumer. */
31
34
  feed(data: string): LineEditAction[];
32
35
  /** Check if there's a pending incomplete escape sequence. */
@@ -34,6 +37,12 @@ export declare class LineEditor {
34
37
  /** Flush a pending sequence — treat bare \x1b as cancel, discard incomplete CSI. */
35
38
  flushPendingEscape(): LineEditAction[];
36
39
  clear(): void;
40
+ /** Add a line to history (most recent first). */
41
+ pushHistory(line: string): void;
42
+ /** Navigate to a previous history entry. Returns changed action or null. */
43
+ historyBack(): LineEditAction | null;
44
+ /** Navigate to a more recent history entry. Returns changed action or null. */
45
+ historyForward(): LineEditAction | null;
37
46
  private readonly bindings;
38
47
  /** Resolve a key name from the bindings table and execute it. */
39
48
  private dispatch;
@@ -14,6 +14,10 @@ export class LineEditor {
14
14
  buffer = "";
15
15
  cursor = 0;
16
16
  pendingSeq = ""; // buffered incomplete escape sequence
17
+ // ── History ──────────────────────────────────────────────────
18
+ history = [];
19
+ historyIndex = -1; // -1 = current input, 0..N = history entries (newest first)
20
+ savedBuffer = ""; // saves current input when browsing history
17
21
  /** Process raw terminal input, return actions for the consumer. */
18
22
  feed(data) {
19
23
  // If we had a pending incomplete escape sequence, prepend it
@@ -147,6 +151,46 @@ export class LineEditor {
147
151
  this.buffer = "";
148
152
  this.cursor = 0;
149
153
  this.pendingSeq = "";
154
+ this.historyIndex = -1;
155
+ this.savedBuffer = "";
156
+ }
157
+ /** Add a line to history (most recent first). */
158
+ pushHistory(line) {
159
+ if (!line.trim())
160
+ return;
161
+ // Deduplicate: remove if already at top
162
+ if (this.history.length > 0 && this.history[0] === line)
163
+ return;
164
+ this.history.unshift(line);
165
+ // Cap history size
166
+ if (this.history.length > 100)
167
+ this.history.pop();
168
+ }
169
+ /** Navigate to a previous history entry. Returns changed action or null. */
170
+ historyBack() {
171
+ if (this.historyIndex + 1 >= this.history.length)
172
+ return null;
173
+ if (this.historyIndex === -1) {
174
+ this.savedBuffer = this.buffer; // save current input
175
+ }
176
+ this.historyIndex++;
177
+ this.buffer = this.history[this.historyIndex];
178
+ this.cursor = this.buffer.length;
179
+ return { action: "changed" };
180
+ }
181
+ /** Navigate to a more recent history entry. Returns changed action or null. */
182
+ historyForward() {
183
+ if (this.historyIndex <= -1)
184
+ return null;
185
+ this.historyIndex--;
186
+ if (this.historyIndex === -1) {
187
+ this.buffer = this.savedBuffer;
188
+ }
189
+ else {
190
+ this.buffer = this.history[this.historyIndex];
191
+ }
192
+ this.cursor = this.buffer.length;
193
+ return { action: "changed" };
150
194
  }
151
195
  // ── Key bindings ────────────────────────────────────────────
152
196
  //
@@ -1,4 +1,4 @@
1
- import { visibleLen } from "./ansi.js";
1
+ import { visibleLen, truncateToWidth, padEndToWidth } from "./ansi.js";
2
2
  import { palette as p } from "./palette.js";
3
3
  const MAX_CONTENT_WIDTH = 90;
4
4
  /**
@@ -177,7 +177,7 @@ export class MarkdownRenderer {
177
177
  const colWidths = new Array(numCols).fill(0);
178
178
  for (const row of dataRows) {
179
179
  for (let c = 0; c < numCols; c++) {
180
- colWidths[c] = Math.max(colWidths[c], row[c].length);
180
+ colWidths[c] = Math.max(colWidths[c], visibleLen(row[c]));
181
181
  }
182
182
  }
183
183
  // Shrink columns proportionally if total exceeds content width
@@ -201,7 +201,7 @@ export class MarkdownRenderer {
201
201
  const isHeader = hasHeader && i === 0;
202
202
  const cells = row.map((cell, c) => {
203
203
  const w = colWidths[c];
204
- const text = cell.length > w ? cell.slice(0, w - 1) + "…" : cell.padEnd(w);
204
+ const text = visibleLen(cell) > w ? truncateToWidth(cell, w) : padEndToWidth(cell, w);
205
205
  return isHeader ? `${p.bold}${text}${p.reset}` : text;
206
206
  });
207
207
  this.writeLine(`${p.dim}│${p.reset} ${cells.join(` ${p.dim}│${p.reset} `)} ${p.dim}│${p.reset}`);