agent-sh 0.12.25 → 0.12.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/agent-loop.js +2 -2
- package/dist/agent/conversation-state.js +22 -1
- package/dist/index.js +3 -1
- package/dist/install.js +84 -4
- package/dist/shell/index.d.ts +5 -0
- package/dist/shell/index.js +13 -8
- package/dist/shell/input-handler.js +75 -27
- package/dist/shell/tui-input-view.d.ts +5 -0
- package/dist/shell/tui-input-view.js +137 -96
- package/dist/utils/floating-panel.d.ts +16 -4
- package/dist/utils/floating-panel.js +209 -66
- package/dist/utils/terminal-buffer.d.ts +6 -9
- package/dist/utils/terminal-buffer.js +21 -53
- package/examples/extensions/emacs-buffer.ts +364 -0
- package/examples/extensions/opencode-bridge/index.ts +255 -37
- package/examples/extensions/overlay-agent.ts +28 -5
- package/examples/extensions/terminal-buffer.ts +174 -33
- package/examples/extensions/tunnel-vision.ts +405 -0
- package/examples/extensions/web-access.ts +3 -108
- package/package.json +1 -1
|
@@ -90,7 +90,8 @@ export class FloatingPanel {
|
|
|
90
90
|
* - `{prefix}:render-border-bottom(ctx: FrameContext) -> string`
|
|
91
91
|
* - `{prefix}:composite-row(content: string, bgLine: string|null, boxLeft: number, boxW: number, cols: number) -> string`
|
|
92
92
|
* - `{prefix}:submit(query: string) -> void`
|
|
93
|
-
* - `{prefix}:
|
|
93
|
+
* - `{prefix}:hide() -> void` (screen down; conversation state preserved)
|
|
94
|
+
* - `{prefix}:reset() -> void` (conversation state cleared)
|
|
94
95
|
* - `{prefix}:show() -> void`
|
|
95
96
|
* - `{prefix}:input(data: string) -> boolean`
|
|
96
97
|
* - `{prefix}:build-row(content: string, width: number) -> string`
|
|
@@ -123,6 +124,10 @@ export class FloatingPanel {
|
|
|
123
124
|
wrapCacheWidth = 0;
|
|
124
125
|
passthroughTimer = null;
|
|
125
126
|
prevSerialized = "";
|
|
127
|
+
// ── Autocomplete ────────────────────────────────────────────
|
|
128
|
+
autocompleteItems = [];
|
|
129
|
+
autocompleteIndex = 0;
|
|
130
|
+
autocompleteActive = false;
|
|
126
131
|
constructor(bus, config, handlers) {
|
|
127
132
|
this.bus = bus;
|
|
128
133
|
this.surface = config.surface ?? new StdoutSurface();
|
|
@@ -166,10 +171,54 @@ export class FloatingPanel {
|
|
|
166
171
|
}
|
|
167
172
|
all.push(...wrapped);
|
|
168
173
|
}
|
|
169
|
-
|
|
174
|
+
if (ctx.phase === "input" && this.autocompleteActive && this.autocompleteItems.length > 0) {
|
|
175
|
+
const ACMax = 5;
|
|
176
|
+
const items = this.autocompleteItems;
|
|
177
|
+
let acStart = 0;
|
|
178
|
+
let acEnd = items.length;
|
|
179
|
+
if (items.length > ACMax) {
|
|
180
|
+
acStart = Math.max(0, this.autocompleteIndex - Math.floor(ACMax / 2));
|
|
181
|
+
acStart = Math.min(acStart, items.length - ACMax);
|
|
182
|
+
acEnd = acStart + ACMax;
|
|
183
|
+
}
|
|
184
|
+
for (let i = acStart; i < acEnd; i++) {
|
|
185
|
+
const item = items[i];
|
|
186
|
+
const selected = i === this.autocompleteIndex;
|
|
187
|
+
const desc = item.description ? ` ${item.description}` : "";
|
|
188
|
+
if (selected) {
|
|
189
|
+
all.push(`${INVERSE} ${item.name}${desc} ${RESET}`);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
all.push(` ${item.name}${DIM}${desc}${RESET}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
let promptStartIdx = -1;
|
|
197
|
+
let cursorRowOffset = 0;
|
|
198
|
+
let cursorCol = 0;
|
|
170
199
|
if (ctx.phase === "input") {
|
|
171
|
-
const
|
|
172
|
-
|
|
200
|
+
const w = ctx.width;
|
|
201
|
+
const prefixLen = this.config.promptIcon.length + 1;
|
|
202
|
+
const styledPrefix = `\x1b[36m${this.config.promptIcon}${RESET} `;
|
|
203
|
+
const input = ctx.inputBuffer;
|
|
204
|
+
const firstLineCap = Math.max(1, w - prefixLen);
|
|
205
|
+
promptStartIdx = all.length;
|
|
206
|
+
if (input.length === 0) {
|
|
207
|
+
all.push(styledPrefix);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
all.push(styledPrefix + input.slice(0, firstLineCap));
|
|
211
|
+
for (let i = firstLineCap; i < input.length; i += w) {
|
|
212
|
+
all.push(input.slice(i, i + w));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const cursorVp = prefixLen + ctx.inputCursor;
|
|
216
|
+
cursorRowOffset = Math.floor(cursorVp / w);
|
|
217
|
+
cursorCol = cursorVp % w;
|
|
218
|
+
// Cursor on an exact wrap boundary lands past the last rendered row.
|
|
219
|
+
while (all.length - promptStartIdx <= cursorRowOffset) {
|
|
220
|
+
all.push("");
|
|
221
|
+
}
|
|
173
222
|
}
|
|
174
223
|
// Scroll: auto-scroll to bottom unless user manually scrolled
|
|
175
224
|
let offset = ctx.scrollOffset;
|
|
@@ -185,24 +234,20 @@ export class FloatingPanel {
|
|
|
185
234
|
}
|
|
186
235
|
this.scrollOffset = offset;
|
|
187
236
|
const visible = all.slice(offset, offset + ctx.height);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
// If prompt is visible, set cursor
|
|
192
|
-
if (promptRow >= 0) {
|
|
237
|
+
if (ctx.phase === "input" && promptStartIdx >= 0) {
|
|
238
|
+
const cursorRowInVisible = promptStartIdx + cursorRowOffset - offset;
|
|
239
|
+
if (cursorRowInVisible >= 0 && cursorRowInVisible < visible.length) {
|
|
193
240
|
return {
|
|
194
241
|
lines: visible,
|
|
195
|
-
cursor: { row:
|
|
242
|
+
cursor: { row: cursorRowInVisible, col: cursorCol },
|
|
196
243
|
};
|
|
197
244
|
}
|
|
198
245
|
}
|
|
199
246
|
return { lines: visible };
|
|
200
247
|
});
|
|
201
|
-
// Default submit: no-op (extension overrides)
|
|
202
248
|
this.handlers.define(`${p}:submit`, (_query) => { });
|
|
203
|
-
|
|
204
|
-
this.handlers.define(`${p}:
|
|
205
|
-
// Default show: no-op (extension overrides to rebuild content on re-show)
|
|
249
|
+
this.handlers.define(`${p}:hide`, () => { });
|
|
250
|
+
this.handlers.define(`${p}:reset`, () => { });
|
|
206
251
|
this.handlers.define(`${p}:show`, () => { });
|
|
207
252
|
// Default custom input handler: don't consume
|
|
208
253
|
this.handlers.define(`${p}:input`, (_data) => false);
|
|
@@ -342,6 +387,7 @@ export class FloatingPanel {
|
|
|
342
387
|
this.ensureBuffer();
|
|
343
388
|
this.phase = "input";
|
|
344
389
|
this.editor.clear();
|
|
390
|
+
this.clearAutocomplete();
|
|
345
391
|
this.contentLines = [];
|
|
346
392
|
this.currentPartialLine = "";
|
|
347
393
|
this.scrollOffset = 0;
|
|
@@ -373,7 +419,7 @@ export class FloatingPanel {
|
|
|
373
419
|
// Agent idle or done — full teardown, hand back control.
|
|
374
420
|
this.teardownScreen();
|
|
375
421
|
}
|
|
376
|
-
this.handlers.call(`${this.prefix}:
|
|
422
|
+
this.handlers.call(`${this.prefix}:hide`);
|
|
377
423
|
}
|
|
378
424
|
/** Show the panel again after hide(), preserving conversation. */
|
|
379
425
|
show() {
|
|
@@ -394,14 +440,27 @@ export class FloatingPanel {
|
|
|
394
440
|
}
|
|
395
441
|
this.handlers.call(`${this.prefix}:show`);
|
|
396
442
|
}
|
|
397
|
-
/**
|
|
398
|
-
|
|
443
|
+
/** End the conversation: screen down and all buffered state cleared. */
|
|
444
|
+
reset() {
|
|
399
445
|
if (this.phase === "idle")
|
|
400
446
|
return;
|
|
401
447
|
if (this.autoDismissTimer) {
|
|
402
448
|
clearTimeout(this.autoDismissTimer);
|
|
403
449
|
this.autoDismissTimer = null;
|
|
404
450
|
}
|
|
451
|
+
this.teardownToHidden();
|
|
452
|
+
this.phase = "idle";
|
|
453
|
+
this.editor.clear();
|
|
454
|
+
this.clearAutocomplete();
|
|
455
|
+
this.contentLines = [];
|
|
456
|
+
this.currentPartialLine = "";
|
|
457
|
+
this.scrollOffset = 0;
|
|
458
|
+
this.title = "";
|
|
459
|
+
this.footer = "";
|
|
460
|
+
this.handlers.call(`${this.prefix}:reset`);
|
|
461
|
+
}
|
|
462
|
+
/** Screen-only teardown; conversation state is left untouched. */
|
|
463
|
+
teardownToHidden() {
|
|
405
464
|
if (this._passthrough) {
|
|
406
465
|
this.stopPassthrough();
|
|
407
466
|
this._passthrough = false;
|
|
@@ -416,13 +475,6 @@ export class FloatingPanel {
|
|
|
416
475
|
this.prevFrame = [];
|
|
417
476
|
this.teardownScreen();
|
|
418
477
|
}
|
|
419
|
-
this.phase = "idle";
|
|
420
|
-
this.editor.clear();
|
|
421
|
-
this.contentLines = [];
|
|
422
|
-
this.currentPartialLine = "";
|
|
423
|
-
this.scrollOffset = 0;
|
|
424
|
-
this.title = "";
|
|
425
|
-
this.footer = "";
|
|
426
478
|
}
|
|
427
479
|
/** Common screen enter logic shared by open() and show(). */
|
|
428
480
|
enterScreen() {
|
|
@@ -489,24 +541,25 @@ export class FloatingPanel {
|
|
|
489
541
|
this.phase = "active";
|
|
490
542
|
}
|
|
491
543
|
setDone() {
|
|
492
|
-
if (this._passthrough) {
|
|
493
|
-
// Agent finished while hidden — session over, hand back control.
|
|
494
|
-
this.dismiss();
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
544
|
if (this.config.autoDismissMs > 0) {
|
|
498
|
-
// Legacy behavior: enter done state, auto-dismiss after delay
|
|
499
545
|
this.phase = "done";
|
|
500
|
-
this.render();
|
|
501
546
|
this.autoDismissTimer = setTimeout(() => {
|
|
502
547
|
if (this.phase === "done")
|
|
503
|
-
this.
|
|
548
|
+
this.reset();
|
|
504
549
|
}, this.config.autoDismissMs);
|
|
505
550
|
}
|
|
506
551
|
else {
|
|
507
|
-
// Auto-prompt: transition to input for follow-up conversation
|
|
508
552
|
this.phase = "input";
|
|
509
553
|
this.editor.clear();
|
|
554
|
+
this.clearAutocomplete();
|
|
555
|
+
}
|
|
556
|
+
if (this._passthrough) {
|
|
557
|
+
// Agent finished while hidden — release the screen but keep state
|
|
558
|
+
// so the next summon resumes the transcript.
|
|
559
|
+
this.teardownToHidden();
|
|
560
|
+
this.handlers.call(`${this.prefix}:hide`);
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
510
563
|
this.render();
|
|
511
564
|
}
|
|
512
565
|
}
|
|
@@ -526,6 +579,64 @@ export class FloatingPanel {
|
|
|
526
579
|
requestRender() {
|
|
527
580
|
this.scheduleRender();
|
|
528
581
|
}
|
|
582
|
+
// ── Autocomplete helpers ────────────────────────────────────
|
|
583
|
+
updateAutocomplete() {
|
|
584
|
+
if (this.phase !== "input") {
|
|
585
|
+
this.clearAutocomplete();
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
const buf = this.editor.text;
|
|
589
|
+
let command = null;
|
|
590
|
+
let commandArgs = null;
|
|
591
|
+
if (buf.startsWith("/")) {
|
|
592
|
+
const spaceIdx = buf.indexOf(" ");
|
|
593
|
+
if (spaceIdx !== -1) {
|
|
594
|
+
command = buf.slice(0, spaceIdx);
|
|
595
|
+
commandArgs = buf.slice(spaceIdx + 1);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const { items } = this.bus.emitPipe("autocomplete:request", {
|
|
599
|
+
buffer: buf,
|
|
600
|
+
command,
|
|
601
|
+
commandArgs,
|
|
602
|
+
items: [],
|
|
603
|
+
});
|
|
604
|
+
if (items.length > 0) {
|
|
605
|
+
this.autocompleteItems = items;
|
|
606
|
+
this.autocompleteActive = true;
|
|
607
|
+
if (this.autocompleteIndex >= items.length)
|
|
608
|
+
this.autocompleteIndex = 0;
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
this.clearAutocomplete();
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
applyAutocomplete() {
|
|
615
|
+
if (!this.autocompleteActive || this.autocompleteItems.length === 0)
|
|
616
|
+
return false;
|
|
617
|
+
const sel = this.autocompleteItems[this.autocompleteIndex];
|
|
618
|
+
if (!sel)
|
|
619
|
+
return false;
|
|
620
|
+
// For @file completion only the partial after the last @ is replaced.
|
|
621
|
+
const text = this.editor.text;
|
|
622
|
+
const atPos = text.lastIndexOf("@");
|
|
623
|
+
const isFileAc = atPos >= 0
|
|
624
|
+
&& (atPos === 0 || text[atPos - 1] === " ")
|
|
625
|
+
&& !text.slice(atPos + 1).includes(" ");
|
|
626
|
+
if (isFileAc) {
|
|
627
|
+
this.editor.setText(text.slice(0, atPos) + "@" + sel.name);
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
this.editor.setText(sel.name);
|
|
631
|
+
}
|
|
632
|
+
this.clearAutocomplete();
|
|
633
|
+
return true;
|
|
634
|
+
}
|
|
635
|
+
clearAutocomplete() {
|
|
636
|
+
this.autocompleteActive = false;
|
|
637
|
+
this.autocompleteItems = [];
|
|
638
|
+
this.autocompleteIndex = 0;
|
|
639
|
+
}
|
|
529
640
|
// ── Input handling ──────────────────────────────────────────
|
|
530
641
|
handleIntercept(payload) {
|
|
531
642
|
const consumed = { ...payload, consumed: true };
|
|
@@ -541,7 +652,7 @@ export class FloatingPanel {
|
|
|
541
652
|
}
|
|
542
653
|
switch (this.phase) {
|
|
543
654
|
case "done":
|
|
544
|
-
this.
|
|
655
|
+
this.reset();
|
|
545
656
|
return consumed;
|
|
546
657
|
case "input":
|
|
547
658
|
this.handleInputKey(data);
|
|
@@ -568,41 +679,40 @@ export class FloatingPanel {
|
|
|
568
679
|
return payload;
|
|
569
680
|
}
|
|
570
681
|
}
|
|
571
|
-
/**
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
682
|
+
/**
|
|
683
|
+
* Handle scroll input. Returns true if consumed.
|
|
684
|
+
* Pass `includeArrows=false` in input phase so arrows reach the editor.
|
|
685
|
+
*/
|
|
686
|
+
handleScroll(data, includeArrows = true) {
|
|
687
|
+
if (includeArrows) {
|
|
688
|
+
if (data === "\x1b[A" || data === "\x1bOA") {
|
|
689
|
+
this.scrollUp(1);
|
|
690
|
+
return true;
|
|
691
|
+
}
|
|
692
|
+
if (data === "\x1b[B" || data === "\x1bOB") {
|
|
693
|
+
this.scrollDown(1);
|
|
694
|
+
return true;
|
|
695
|
+
}
|
|
582
696
|
}
|
|
583
|
-
// Page up (CSI 5~)
|
|
584
697
|
if (data === "\x1b[5~") {
|
|
585
698
|
this.scrollUp(this.computeGeometry().contentH - 1);
|
|
586
699
|
return true;
|
|
587
700
|
}
|
|
588
|
-
// Page down (CSI 6~)
|
|
589
701
|
if (data === "\x1b[6~") {
|
|
590
702
|
this.scrollDown(this.computeGeometry().contentH - 1);
|
|
591
703
|
return true;
|
|
592
704
|
}
|
|
593
|
-
// Mouse wheel: CSI M followed by button byte (64 = wheel up, 65 = wheel down)
|
|
594
705
|
if (data.length >= 6 && data.startsWith("\x1b[M")) {
|
|
595
706
|
const button = data.charCodeAt(3);
|
|
596
707
|
if (button === 96) {
|
|
597
708
|
this.scrollUp(3);
|
|
598
709
|
return true;
|
|
599
|
-
}
|
|
710
|
+
}
|
|
600
711
|
if (button === 97) {
|
|
601
712
|
this.scrollDown(3);
|
|
602
713
|
return true;
|
|
603
|
-
}
|
|
714
|
+
}
|
|
604
715
|
}
|
|
605
|
-
// SGR mouse: CSI < 64;x;yM (wheel up) / CSI < 65;x;yM (wheel down)
|
|
606
716
|
const sgr = data.match(/^\x1b\[<(64|65);\d+;\d+M$/);
|
|
607
717
|
if (sgr) {
|
|
608
718
|
if (sgr[1] === "64") {
|
|
@@ -617,59 +727,92 @@ export class FloatingPanel {
|
|
|
617
727
|
return false;
|
|
618
728
|
}
|
|
619
729
|
handleInputKey(data) {
|
|
620
|
-
// Check full data string against trigger sequences (may be multi-byte)
|
|
621
730
|
if (this.isTrigger(data)) {
|
|
622
731
|
this.hide();
|
|
623
732
|
return;
|
|
624
733
|
}
|
|
625
734
|
for (let i = 0; i < data.length; i++) {
|
|
626
735
|
const ch = data[i];
|
|
627
|
-
if (ch === "\x1b" && data[i + 1] == null) {
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
736
|
+
if ((ch === "\x1b" && data[i + 1] == null) || ch.charCodeAt(0) === 0x03) {
|
|
737
|
+
// First Esc/Ctrl+C closes the dropdown; second hides the panel.
|
|
738
|
+
if (this.autocompleteActive) {
|
|
739
|
+
this.clearAutocomplete();
|
|
740
|
+
this.render();
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
632
743
|
this.hide();
|
|
633
744
|
return;
|
|
634
745
|
}
|
|
635
746
|
}
|
|
636
|
-
|
|
637
|
-
if (this.handleScroll(data))
|
|
747
|
+
if (this.handleScroll(data, false))
|
|
638
748
|
return;
|
|
639
749
|
const actions = this.editor.feed(data);
|
|
640
750
|
for (const action of actions) {
|
|
641
751
|
switch (action.action) {
|
|
642
752
|
case "submit": {
|
|
753
|
+
// Apply selection on Enter so it both picks and submits.
|
|
754
|
+
this.applyAutocomplete();
|
|
643
755
|
const query = this.editor.text.trim();
|
|
644
756
|
if (!query) {
|
|
645
757
|
this.hide();
|
|
646
758
|
return;
|
|
647
759
|
}
|
|
648
760
|
this.editor.pushHistory(query);
|
|
649
|
-
this.phase = "active";
|
|
650
761
|
this.editor.clear();
|
|
762
|
+
this.clearAutocomplete();
|
|
763
|
+
// Phase change is the submit handler's call — sync slash commands
|
|
764
|
+
// (e.g. /model, /help) keep the user in input mode.
|
|
651
765
|
this.handlers.call(`${this.prefix}:submit`, query);
|
|
652
766
|
return;
|
|
653
767
|
}
|
|
654
768
|
case "cancel":
|
|
769
|
+
if (this.autocompleteActive) {
|
|
770
|
+
this.clearAutocomplete();
|
|
771
|
+
this.render();
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
655
774
|
this.hide();
|
|
656
775
|
return;
|
|
776
|
+
case "tab":
|
|
777
|
+
// Re-query after applying a command name so arg completions show.
|
|
778
|
+
if (this.applyAutocomplete())
|
|
779
|
+
this.updateAutocomplete();
|
|
780
|
+
this.render();
|
|
781
|
+
break;
|
|
782
|
+
case "shift+tab":
|
|
783
|
+
this.render();
|
|
784
|
+
break;
|
|
657
785
|
case "arrow-up": {
|
|
658
|
-
|
|
659
|
-
|
|
786
|
+
if (this.autocompleteActive) {
|
|
787
|
+
this.autocompleteIndex = this.autocompleteIndex === 0
|
|
788
|
+
? this.autocompleteItems.length - 1
|
|
789
|
+
: this.autocompleteIndex - 1;
|
|
660
790
|
this.render();
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
const hist = this.editor.historyBack();
|
|
794
|
+
if (hist)
|
|
795
|
+
this.render();
|
|
796
|
+
}
|
|
661
797
|
break;
|
|
662
798
|
}
|
|
663
799
|
case "arrow-down": {
|
|
664
|
-
|
|
665
|
-
|
|
800
|
+
if (this.autocompleteActive) {
|
|
801
|
+
this.autocompleteIndex = this.autocompleteIndex === this.autocompleteItems.length - 1
|
|
802
|
+
? 0
|
|
803
|
+
: this.autocompleteIndex + 1;
|
|
666
804
|
this.render();
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
const hist = this.editor.historyForward();
|
|
808
|
+
if (hist)
|
|
809
|
+
this.render();
|
|
810
|
+
}
|
|
667
811
|
break;
|
|
668
812
|
}
|
|
669
813
|
case "changed":
|
|
670
|
-
case "tab":
|
|
671
|
-
case "shift+tab":
|
|
672
814
|
case "delete-empty":
|
|
815
|
+
this.updateAutocomplete();
|
|
673
816
|
this.render();
|
|
674
817
|
break;
|
|
675
818
|
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import type { EventBus } from "../event-bus.js";
|
|
2
|
-
/** Check if @xterm/headless is installed without loading it. */
|
|
3
|
-
export declare function isXtermAvailable(): boolean;
|
|
4
2
|
export interface TerminalBufferConfig {
|
|
5
3
|
/** Terminal width in columns. Default: process.stdout.columns || 80. */
|
|
6
4
|
cols?: number;
|
|
@@ -31,15 +29,14 @@ export declare class TerminalBuffer {
|
|
|
31
29
|
/** Flush pending drip-feed data (set by createWired). */
|
|
32
30
|
_flushPending: (() => void) | null;
|
|
33
31
|
private constructor();
|
|
32
|
+
static create(config?: TerminalBufferConfig): TerminalBuffer;
|
|
34
33
|
/**
|
|
35
|
-
* Create a
|
|
34
|
+
* Create a TerminalBuffer wired to a bus's `shell:pty-data` event.
|
|
35
|
+
* Drip-feeds writes asynchronously: synchronous `term.write()` in the
|
|
36
|
+
* pty-data handler changes PTY read coalescing enough to introduce
|
|
37
|
+
* visual artifacts.
|
|
36
38
|
*/
|
|
37
|
-
static
|
|
38
|
-
/**
|
|
39
|
-
* Create a TerminalBuffer and wire it to a bus's shell:pty-data event.
|
|
40
|
-
* Returns null if xterm is not installed.
|
|
41
|
-
*/
|
|
42
|
-
static createWired(bus: EventBus, config?: TerminalBufferConfig): TerminalBuffer | null;
|
|
39
|
+
static createWired(bus: EventBus, config?: TerminalBufferConfig): TerminalBuffer;
|
|
43
40
|
/** Flush any pending drip-feed data into the virtual terminal. */
|
|
44
41
|
flush(): void;
|
|
45
42
|
/** Write raw data into the virtual terminal. */
|
|
@@ -9,38 +9,19 @@
|
|
|
9
9
|
* - floating-panel.ts: composited overlay rendering + screen restore
|
|
10
10
|
* - terminal-buffer extension: agent tools (terminal_read, terminal_keys)
|
|
11
11
|
* - Any extension needing a virtual terminal snapshot
|
|
12
|
-
*
|
|
13
|
-
* The xterm dependency is loaded lazily on first use. If @xterm/headless
|
|
14
|
-
* is not installed, create() returns null.
|
|
15
|
-
*
|
|
16
|
-
* Install (optional):
|
|
17
|
-
* npm install @xterm/headless@5.5.0 @xterm/addon-serialize@0.13.0
|
|
18
12
|
*/
|
|
13
|
+
// xterm is loaded lazily on first TerminalBuffer.create(). Subcommands
|
|
14
|
+
// (init/install/list) and non-shell frontends (web bridges) import this
|
|
15
|
+
// file transitively but never instantiate a buffer; they shouldn't pay
|
|
16
|
+
// the xterm parse cost at startup.
|
|
19
17
|
import { createRequire } from "module";
|
|
20
|
-
// ── Lazy xterm loader ───────────────────────────────────────────
|
|
21
18
|
const require = createRequire(import.meta.url);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return available;
|
|
29
|
-
loadAttempted = true;
|
|
30
|
-
try {
|
|
31
|
-
TerminalCtor = require("@xterm/headless").Terminal;
|
|
32
|
-
SerializeAddonCtor = require("@xterm/addon-serialize").SerializeAddon;
|
|
33
|
-
available = true;
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
available = false;
|
|
37
|
-
}
|
|
38
|
-
return available;
|
|
39
|
-
}
|
|
40
|
-
/** Check if @xterm/headless is installed without loading it. */
|
|
41
|
-
export function isXtermAvailable() {
|
|
42
|
-
return ensureXterm();
|
|
43
|
-
}
|
|
19
|
+
// Node's require cache memoizes the first hit; subsequent calls are
|
|
20
|
+
// just a hashmap lookup, so this stays lazy without our own caching.
|
|
21
|
+
const loadXterm = () => ({
|
|
22
|
+
Terminal: require("@xterm/headless").Terminal,
|
|
23
|
+
SerializeAddon: require("@xterm/addon-serialize").SerializeAddon,
|
|
24
|
+
});
|
|
44
25
|
/**
|
|
45
26
|
* Format a screen snapshot as an XML context block for agent injection.
|
|
46
27
|
* Trims, caps to `maxLines` (from the bottom), and wraps in `<terminal_buffer>`.
|
|
@@ -71,47 +52,35 @@ export class TerminalBuffer {
|
|
|
71
52
|
this.term = term;
|
|
72
53
|
this.serializeAddon = serialize;
|
|
73
54
|
}
|
|
74
|
-
/**
|
|
75
|
-
* Create a new TerminalBuffer. Returns null if xterm is not installed.
|
|
76
|
-
*/
|
|
77
55
|
static create(config) {
|
|
78
|
-
|
|
79
|
-
return null;
|
|
56
|
+
const { Terminal, SerializeAddon } = loadXterm();
|
|
80
57
|
const cols = config?.cols ?? (process.stdout.columns || 80);
|
|
81
58
|
const rows = config?.rows ?? (process.stdout.rows || 24);
|
|
82
59
|
const scrollback = config?.scrollback ?? 200;
|
|
83
|
-
const term = new
|
|
84
|
-
const serialize = new
|
|
60
|
+
const term = new Terminal({ cols, rows, allowProposedApi: true, scrollback });
|
|
61
|
+
const serialize = new SerializeAddon();
|
|
85
62
|
term.loadAddon(serialize);
|
|
86
63
|
return new TerminalBuffer(term, serialize);
|
|
87
64
|
}
|
|
88
65
|
/**
|
|
89
|
-
* Create a TerminalBuffer
|
|
90
|
-
*
|
|
66
|
+
* Create a TerminalBuffer wired to a bus's `shell:pty-data` event.
|
|
67
|
+
* Drip-feeds writes asynchronously: synchronous `term.write()` in the
|
|
68
|
+
* pty-data handler changes PTY read coalescing enough to introduce
|
|
69
|
+
* visual artifacts.
|
|
91
70
|
*/
|
|
92
71
|
static createWired(bus, config) {
|
|
93
72
|
const tb = TerminalBuffer.create(config);
|
|
94
|
-
if (!tb)
|
|
95
|
-
return null;
|
|
96
|
-
// Buffer PTY data and drip-feed to xterm in the background.
|
|
97
|
-
// Synchronous term.write() in the pty-data handler introduces enough
|
|
98
|
-
// latency to change PTY read coalescing, causing visual artifacts.
|
|
99
73
|
let pending = "";
|
|
100
|
-
|
|
101
|
-
setInterval(() => {
|
|
102
|
-
if (pending) {
|
|
103
|
-
const d = pending;
|
|
104
|
-
pending = "";
|
|
105
|
-
tb.write(d);
|
|
106
|
-
}
|
|
107
|
-
}, 50);
|
|
108
|
-
tb._flushPending = () => {
|
|
74
|
+
const drain = () => {
|
|
109
75
|
if (pending) {
|
|
110
76
|
const d = pending;
|
|
111
77
|
pending = "";
|
|
112
78
|
tb.write(d);
|
|
113
79
|
}
|
|
114
80
|
};
|
|
81
|
+
bus.on("shell:pty-data", ({ raw }) => { pending += raw; });
|
|
82
|
+
setInterval(drain, 50);
|
|
83
|
+
tb._flushPending = drain;
|
|
115
84
|
process.stdout.on("resize", () => {
|
|
116
85
|
tb.resize(process.stdout.columns || 80, process.stdout.rows || 24);
|
|
117
86
|
});
|
|
@@ -171,7 +140,6 @@ export class TerminalBuffer {
|
|
|
171
140
|
const line = buf.getLine(y);
|
|
172
141
|
lines.push(line ? line.translateToString(true) : "");
|
|
173
142
|
}
|
|
174
|
-
// Trim trailing empty lines
|
|
175
143
|
while (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
176
144
|
lines.pop();
|
|
177
145
|
}
|