agent-sh 0.12.25 → 0.12.26

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/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
3
  import * as path from "node:path";
4
- import { activateShell } from "./shell/index.js";
4
+ import { activateShell, registerShellHandlers } from "./shell/index.js";
5
5
  import { createCore } from "./core.js";
6
6
  import { palette as p } from "./utils/palette.js";
7
7
  import { loadBuiltinExtensions } from "./extensions/index.js";
@@ -237,6 +237,8 @@ async function main() {
237
237
  process.exit(0);
238
238
  };
239
239
  const extCtx = core.extensionContext({ quit: cleanup });
240
+ // Before loadExtensions: extensions look up shell handlers at activation.
241
+ registerShellHandlers(extCtx);
240
242
  // Load before spawning the shell so PS1 lands below the banner.
241
243
  await loadBuiltinExtensions(extCtx, getSettings().disabledBuiltins);
242
244
  const loadExtensionsTimeoutMs = 10000;
@@ -27,6 +27,11 @@ export interface ShellHandle {
27
27
  /** Forward terminal size changes to the PTY. */
28
28
  resize(cols: number, rows: number): void;
29
29
  }
30
+ /**
31
+ * Register shell-owned handlers extensions can `ctx.call`. Must run before
32
+ * `loadExtensions`; the handlers only need the bus, not the PTY.
33
+ */
34
+ export declare function registerShellHandlers(ctx: ExtensionContext): void;
30
35
  /**
31
36
  * Construct the Shell, wire resize forwarding, and register cleanup with the
32
37
  * provided ExtensionContext. Returns a handle the caller (typically
@@ -1,6 +1,19 @@
1
1
  import { Shell } from "./shell.js";
2
2
  import { StdoutSurface } from "../utils/compositor.js";
3
3
  import { TerminalBuffer } from "../utils/terminal-buffer.js";
4
+ /**
5
+ * Register shell-owned handlers extensions can `ctx.call`. Must run before
6
+ * `loadExtensions`; the handlers only need the bus, not the PTY.
7
+ */
8
+ export function registerShellHandlers(ctx) {
9
+ let terminalBufferSingleton;
10
+ ctx.define("terminal-buffer", () => {
11
+ if (terminalBufferSingleton !== undefined)
12
+ return terminalBufferSingleton;
13
+ terminalBufferSingleton = TerminalBuffer.createWired(ctx.bus);
14
+ return terminalBufferSingleton;
15
+ });
16
+ }
4
17
  /**
5
18
  * Construct the Shell, wire resize forwarding, and register cleanup with the
6
19
  * provided ExtensionContext. Returns a handle the caller (typically
@@ -13,14 +26,6 @@ export function activateShell(ctx, opts) {
13
26
  ctx.compositor.setDefault("agent", stdoutSurface);
14
27
  ctx.compositor.setDefault("query", stdoutSurface);
15
28
  ctx.compositor.setDefault("status", stdoutSurface);
16
- // Lazy because @xterm/headless is optional; null when not installed.
17
- let terminalBufferSingleton;
18
- ctx.define("terminal-buffer", () => {
19
- if (terminalBufferSingleton !== undefined)
20
- return terminalBufferSingleton;
21
- terminalBufferSingleton = TerminalBuffer.createWired(ctx.bus);
22
- return terminalBufferSingleton;
23
- });
24
29
  const shell = new Shell({
25
30
  bus: ctx.bus,
26
31
  handlers: { define: ctx.define, call: ctx.call },
@@ -229,9 +229,15 @@ export class InputHandler {
229
229
  return false;
230
230
  }
231
231
  renderModeInput() {
232
- this.view.clearAutocomplete();
233
- this.drawPrompt();
234
- this.updateAutocomplete();
232
+ this.view.beginFrame();
233
+ try {
234
+ this.view.clearAutocomplete();
235
+ this.drawPrompt();
236
+ this.updateAutocomplete();
237
+ }
238
+ finally {
239
+ this.view.endFrame();
240
+ }
235
241
  }
236
242
  updateAutocomplete() {
237
243
  const buf = this.editor.text;
@@ -278,13 +284,19 @@ export class InputHandler {
278
284
  else {
279
285
  this.editor.setText(selected.name);
280
286
  }
281
- this.view.clearAutocomplete();
282
- this.autocompleteActive = false;
283
- this.autocompleteItems = [];
284
- this.autocompleteIndex = 0;
285
- this.drawPrompt();
286
- if (isFileAc)
287
- this.updateAutocomplete();
287
+ this.view.beginFrame();
288
+ try {
289
+ this.view.clearAutocomplete();
290
+ this.autocompleteActive = false;
291
+ this.autocompleteItems = [];
292
+ this.autocompleteIndex = 0;
293
+ this.drawPrompt();
294
+ if (isFileAc)
295
+ this.updateAutocomplete();
296
+ }
297
+ finally {
298
+ this.view.endFrame();
299
+ }
288
300
  }
289
301
  dismissAutocomplete() {
290
302
  this.view.clearAutocomplete();
@@ -314,11 +326,17 @@ export class InputHandler {
314
326
  case "changed": {
315
327
  const switchMode = this.modes.get(this.editor.text);
316
328
  if (this.editor.text.length === 1 && switchMode && switchMode !== this.activeMode) {
317
- this.dismissAutocomplete();
318
- this.view.clearPromptArea();
319
- this.activeMode = switchMode;
320
- this.editor.clear();
321
- this.drawPrompt(false);
329
+ this.view.beginFrame();
330
+ try {
331
+ this.dismissAutocomplete();
332
+ this.view.clearPromptArea();
333
+ this.activeMode = switchMode;
334
+ this.editor.clear();
335
+ this.drawPrompt(false);
336
+ }
337
+ finally {
338
+ this.view.endFrame();
339
+ }
322
340
  break;
323
341
  }
324
342
  this.historyIndex = -1;
@@ -371,8 +389,14 @@ export class InputHandler {
371
389
  }
372
390
  case "cancel":
373
391
  if (this.autocompleteActive) {
374
- this.dismissAutocomplete();
375
- this.drawPrompt();
392
+ this.view.beginFrame();
393
+ try {
394
+ this.dismissAutocomplete();
395
+ this.drawPrompt();
396
+ }
397
+ finally {
398
+ this.view.endFrame();
399
+ }
376
400
  }
377
401
  else {
378
402
  this.exitMode();
@@ -393,9 +417,15 @@ export class InputHandler {
393
417
  this.autocompleteIndex === 0
394
418
  ? this.autocompleteItems.length - 1
395
419
  : this.autocompleteIndex - 1;
396
- this.view.clearAutocomplete();
397
- this.drawPrompt();
398
- this.view.drawAutocomplete({ items: this.autocompleteItems, selected: this.autocompleteIndex });
420
+ this.view.beginFrame();
421
+ try {
422
+ this.view.clearAutocomplete();
423
+ this.drawPrompt();
424
+ this.view.drawAutocomplete({ items: this.autocompleteItems, selected: this.autocompleteIndex });
425
+ }
426
+ finally {
427
+ this.view.endFrame();
428
+ }
399
429
  }
400
430
  else if (this.history.length > 0) {
401
431
  if (this.historyIndex === -1) {
@@ -406,8 +436,14 @@ export class InputHandler {
406
436
  this.historyIndex--;
407
437
  }
408
438
  this.editor.setText(this.history[this.historyIndex]);
409
- this.view.clearAutocomplete();
410
- this.drawPrompt();
439
+ this.view.beginFrame();
440
+ try {
441
+ this.view.clearAutocomplete();
442
+ this.drawPrompt();
443
+ }
444
+ finally {
445
+ this.view.endFrame();
446
+ }
411
447
  }
412
448
  break;
413
449
  case "arrow-down":
@@ -416,9 +452,15 @@ export class InputHandler {
416
452
  this.autocompleteIndex === this.autocompleteItems.length - 1
417
453
  ? 0
418
454
  : this.autocompleteIndex + 1;
419
- this.view.clearAutocomplete();
420
- this.drawPrompt();
421
- this.view.drawAutocomplete({ items: this.autocompleteItems, selected: this.autocompleteIndex });
455
+ this.view.beginFrame();
456
+ try {
457
+ this.view.clearAutocomplete();
458
+ this.drawPrompt();
459
+ this.view.drawAutocomplete({ items: this.autocompleteItems, selected: this.autocompleteIndex });
460
+ }
461
+ finally {
462
+ this.view.endFrame();
463
+ }
422
464
  }
423
465
  else if (this.historyIndex !== -1) {
424
466
  if (this.historyIndex < this.history.length - 1) {
@@ -429,8 +471,14 @@ export class InputHandler {
429
471
  this.historyIndex = -1;
430
472
  this.editor.setText(this.savedBuffer);
431
473
  }
432
- this.view.clearAutocomplete();
433
- this.drawPrompt();
474
+ this.view.beginFrame();
475
+ try {
476
+ this.view.clearAutocomplete();
477
+ this.drawPrompt();
478
+ }
479
+ finally {
480
+ this.view.endFrame();
481
+ }
434
482
  }
435
483
  break;
436
484
  }
@@ -26,7 +26,12 @@ export declare class TuiInputView {
26
26
  private cursorTermCol;
27
27
  private autocompleteLines;
28
28
  private readonly surface;
29
+ private frameBuf;
29
30
  constructor(surface?: RenderSurface);
31
+ beginFrame(): void;
32
+ endFrame(): void;
33
+ private emit;
34
+ private autoFrame;
30
35
  resetCursor(): void;
31
36
  enableModeKeys(): void;
32
37
  disableModeKeys(): void;
@@ -11,130 +11,171 @@ export class TuiInputView {
11
11
  cursorTermCol = 1;
12
12
  autocompleteLines = 0;
13
13
  surface;
14
+ frameBuf = null;
14
15
  constructor(surface) {
15
16
  this.surface = surface ?? new StdoutSurface();
16
17
  }
18
+ // Frame buffering: coalesces all emit() calls until endFrame() into one
19
+ // surface.write, bracketed by cursor hide/show so intermediate redraw
20
+ // states never flicker through.
21
+ beginFrame() {
22
+ if (this.frameBuf === null)
23
+ this.frameBuf = "\x1b[?25l";
24
+ }
25
+ endFrame() {
26
+ if (this.frameBuf === null)
27
+ return;
28
+ const out = this.frameBuf + "\x1b[?25h";
29
+ this.frameBuf = null;
30
+ this.surface.write(out);
31
+ }
32
+ emit(s) {
33
+ if (this.frameBuf !== null)
34
+ this.frameBuf += s;
35
+ else
36
+ this.surface.write(s);
37
+ }
38
+ autoFrame(fn) {
39
+ const owned = this.frameBuf === null;
40
+ if (owned)
41
+ this.beginFrame();
42
+ try {
43
+ return fn();
44
+ }
45
+ finally {
46
+ if (owned)
47
+ this.endFrame();
48
+ }
49
+ }
17
50
  resetCursor() {
18
51
  this.cursorRowsBelow = 0;
19
52
  this.cursorTermCol = 1;
20
53
  }
21
54
  enableModeKeys() {
22
55
  // Kitty progressive enhancement + bracket paste (Shift+Enter → \x1b[13;2u).
23
- this.surface.write("\x1b[>1u\x1b[?2004h");
56
+ this.emit("\x1b[>1u\x1b[?2004h");
24
57
  }
25
58
  disableModeKeys() {
26
- this.surface.write("\x1b[<u\x1b[?2004l");
59
+ this.emit("\x1b[<u\x1b[?2004l");
27
60
  }
28
61
  clearPromptArea() {
29
- if (this.cursorRowsBelow > 0) {
30
- this.surface.write(`\x1b[${this.cursorRowsBelow}A`);
31
- }
32
- this.surface.write("\r\x1b[J");
33
- this.cursorRowsBelow = 0;
62
+ this.autoFrame(() => {
63
+ if (this.cursorRowsBelow > 0) {
64
+ this.emit(`\x1b[${this.cursorRowsBelow}A`);
65
+ }
66
+ this.emit("\r\x1b[J");
67
+ this.cursorRowsBelow = 0;
68
+ });
34
69
  }
35
70
  drawPrompt(vm) {
36
- const termW = this.surface.columns;
37
- if (this.cursorRowsBelow > 0) {
38
- this.surface.write(`\x1b[${this.cursorRowsBelow}A`);
39
- }
40
- this.surface.write("\r\x1b[J");
41
- const infoPrefix = vm.agentInfo.info
42
- ? `${vm.agentInfo.info} ${p.success}${vm.indicator}${p.reset} `
43
- : `${p.success}${vm.indicator}${p.reset} `;
44
- const promptPrefix = infoPrefix + p.warning + p.bold + vm.promptIcon + " " + p.reset;
45
- const promptVisLen = visibleLen(infoPrefix) + visibleLen(vm.promptIcon) + 1;
46
- const display = vm.showBuffer ? vm.displayText : "";
47
- const dCursor = vm.showBuffer ? vm.displayCursor : 0;
48
- if (!vm.showBuffer) {
49
- this.surface.write(promptPrefix);
50
- const N = promptVisLen;
51
- this.cursorRowsBelow = N > 0 ? Math.ceil(N / termW) - 1 : 0;
52
- this.cursorTermCol = N === 0 ? 1 : (N % termW === 0 ? termW : (N % termW) + 1);
53
- }
54
- else if (!display.includes("\n")) {
55
- // DECSC/DECRC bracket the after-cursor text so the cursor lands mid-line.
56
- const before = display.slice(0, dCursor);
57
- const after = display.slice(dCursor);
58
- this.surface.write(promptPrefix + p.accent + before + p.reset +
59
- "\x1b7" +
60
- p.accent + after + p.reset +
61
- "\x1b8");
62
- const cursorVisCol = promptVisLen + visibleLen(before);
63
- this.cursorRowsBelow = cursorVisCol > 0 ? Math.ceil(cursorVisCol / termW) - 1 : 0;
64
- this.cursorTermCol = cursorVisCol === 0 ? 1 : (cursorVisCol % termW === 0 ? termW : (cursorVisCol % termW) + 1);
65
- }
66
- else {
67
- const lines = display.split("\n");
68
- const indent = " ".repeat(promptVisLen);
69
- let charsRemaining = dCursor;
70
- let cursorLine = 0;
71
- for (let li = 0; li < lines.length; li++) {
72
- if (charsRemaining <= lines[li].length) {
73
- cursorLine = li;
74
- break;
75
- }
76
- charsRemaining -= lines[li].length + 1;
77
- cursorLine = li + 1;
71
+ this.autoFrame(() => {
72
+ const termW = this.surface.columns;
73
+ if (this.cursorRowsBelow > 0) {
74
+ this.emit(`\x1b[${this.cursorRowsBelow}A`);
78
75
  }
79
- let output = "";
80
- let cursorRowFromTop = 0;
81
- let rowsSoFar = 0;
82
- for (let li = 0; li < lines.length; li++) {
83
- const prefix = li === 0 ? promptPrefix : indent;
84
- const lineText = lines[li];
85
- const lineVisLen = promptVisLen + visibleLen(lineText);
86
- const lineTermRows = lineVisLen > 0 ? Math.ceil(lineVisLen / termW) : 1;
87
- if (li === cursorLine) {
88
- const before = lineText.slice(0, charsRemaining);
89
- const after = lineText.slice(charsRemaining);
90
- output += prefix + p.accent + before + p.reset;
91
- output += "\x1b7";
92
- output += p.accent + after + p.reset;
93
- const beforeVisCol = promptVisLen + visibleLen(before);
94
- cursorRowFromTop = rowsSoFar + (beforeVisCol > 0 ? Math.ceil(beforeVisCol / termW) - 1 : 0);
95
- this.cursorTermCol = beforeVisCol === 0 ? 1 : (beforeVisCol % termW === 0 ? termW : (beforeVisCol % termW) + 1);
76
+ this.emit("\r\x1b[J");
77
+ const infoPrefix = vm.agentInfo.info
78
+ ? `${vm.agentInfo.info} ${p.success}${vm.indicator}${p.reset} `
79
+ : `${p.success}${vm.indicator}${p.reset} `;
80
+ const promptPrefix = infoPrefix + p.warning + p.bold + vm.promptIcon + " " + p.reset;
81
+ const promptVisLen = visibleLen(infoPrefix) + visibleLen(vm.promptIcon) + 1;
82
+ const display = vm.showBuffer ? vm.displayText : "";
83
+ const dCursor = vm.showBuffer ? vm.displayCursor : 0;
84
+ if (!vm.showBuffer) {
85
+ this.emit(promptPrefix);
86
+ const N = promptVisLen;
87
+ this.cursorRowsBelow = N > 0 ? Math.ceil(N / termW) - 1 : 0;
88
+ this.cursorTermCol = N === 0 ? 1 : (N % termW === 0 ? termW : (N % termW) + 1);
89
+ }
90
+ else if (!display.includes("\n")) {
91
+ // DECSC/DECRC bracket the after-cursor text so the cursor lands mid-line.
92
+ const before = display.slice(0, dCursor);
93
+ const after = display.slice(dCursor);
94
+ this.emit(promptPrefix + p.accent + before + p.reset +
95
+ "\x1b7" +
96
+ p.accent + after + p.reset +
97
+ "\x1b8");
98
+ const cursorVisCol = promptVisLen + visibleLen(before);
99
+ this.cursorRowsBelow = cursorVisCol > 0 ? Math.ceil(cursorVisCol / termW) - 1 : 0;
100
+ this.cursorTermCol = cursorVisCol === 0 ? 1 : (cursorVisCol % termW === 0 ? termW : (cursorVisCol % termW) + 1);
101
+ }
102
+ else {
103
+ const lines = display.split("\n");
104
+ const indent = " ".repeat(promptVisLen);
105
+ let charsRemaining = dCursor;
106
+ let cursorLine = 0;
107
+ for (let li = 0; li < lines.length; li++) {
108
+ if (charsRemaining <= lines[li].length) {
109
+ cursorLine = li;
110
+ break;
111
+ }
112
+ charsRemaining -= lines[li].length + 1;
113
+ cursorLine = li + 1;
96
114
  }
97
- else {
98
- output += prefix + p.accent + lineText + p.reset;
115
+ let output = "";
116
+ let cursorRowFromTop = 0;
117
+ let rowsSoFar = 0;
118
+ for (let li = 0; li < lines.length; li++) {
119
+ const prefix = li === 0 ? promptPrefix : indent;
120
+ const lineText = lines[li];
121
+ const lineVisLen = promptVisLen + visibleLen(lineText);
122
+ const lineTermRows = lineVisLen > 0 ? Math.ceil(lineVisLen / termW) : 1;
123
+ if (li === cursorLine) {
124
+ const before = lineText.slice(0, charsRemaining);
125
+ const after = lineText.slice(charsRemaining);
126
+ output += prefix + p.accent + before + p.reset;
127
+ output += "\x1b7";
128
+ output += p.accent + after + p.reset;
129
+ const beforeVisCol = promptVisLen + visibleLen(before);
130
+ cursorRowFromTop = rowsSoFar + (beforeVisCol > 0 ? Math.ceil(beforeVisCol / termW) - 1 : 0);
131
+ this.cursorTermCol = beforeVisCol === 0 ? 1 : (beforeVisCol % termW === 0 ? termW : (beforeVisCol % termW) + 1);
132
+ }
133
+ else {
134
+ output += prefix + p.accent + lineText + p.reset;
135
+ }
136
+ if (li < lines.length - 1)
137
+ output += "\n";
138
+ rowsSoFar += lineTermRows;
99
139
  }
100
- if (li < lines.length - 1)
101
- output += "\n";
102
- rowsSoFar += lineTermRows;
140
+ this.emit(output + "\x1b8");
141
+ this.cursorRowsBelow = cursorRowFromTop;
103
142
  }
104
- this.surface.write(output + "\x1b8");
105
- this.cursorRowsBelow = cursorRowFromTop;
106
- }
143
+ });
107
144
  }
108
145
  drawAutocomplete(vm) {
109
146
  if (vm.items.length === 0)
110
147
  return;
111
- const lines = [];
112
- for (let i = 0; i < vm.items.length; i++) {
113
- const item = vm.items[i];
114
- const selected = i === vm.selected;
115
- if (selected) {
116
- lines.push(` \x1b[7m ${p.accent}${item.name.padEnd(12)}${p.reset}\x1b[7m ${item.description} ${p.reset}`);
148
+ this.autoFrame(() => {
149
+ const lines = [];
150
+ for (let i = 0; i < vm.items.length; i++) {
151
+ const item = vm.items[i];
152
+ const selected = i === vm.selected;
153
+ if (selected) {
154
+ lines.push(` \x1b[7m ${p.accent}${item.name.padEnd(12)}${p.reset}\x1b[7m ${item.description} ${p.reset}`);
155
+ }
156
+ else {
157
+ lines.push(` ${p.muted}${item.name.padEnd(12)} ${item.description}${p.reset}`);
158
+ }
117
159
  }
118
- else {
119
- lines.push(` ${p.muted}${item.name.padEnd(12)} ${item.description}${p.reset}`);
160
+ this.emit("\n" + lines.join("\n"));
161
+ this.autocompleteLines = lines.length;
162
+ if (this.autocompleteLines > 0) {
163
+ this.emit(`\x1b[${this.autocompleteLines}A`);
120
164
  }
121
- }
122
- this.surface.write("\n" + lines.join("\n"));
123
- this.autocompleteLines = lines.length;
124
- if (this.autocompleteLines > 0) {
125
- this.surface.write(`\x1b[${this.autocompleteLines}A`);
126
- }
127
- // Absolute column set — preceding \n may have scrolled, invalidating DECSC.
128
- this.surface.write(`\x1b[${this.cursorTermCol}G`);
165
+ // Absolute column set — preceding \n may have scrolled, invalidating DECSC.
166
+ this.emit(`\x1b[${this.cursorTermCol}G`);
167
+ });
129
168
  }
130
169
  clearAutocomplete() {
131
170
  if (this.autocompleteLines <= 0)
132
171
  return;
133
- // CSI B (cursor down, bounded) so we don't scroll on the last row.
134
- for (let i = 0; i < this.autocompleteLines; i++) {
135
- this.surface.write("\x1b[B\x1b[2K");
136
- }
137
- this.surface.write(`\x1b[${this.autocompleteLines}A\x1b[${this.cursorTermCol}G`);
138
- this.autocompleteLines = 0;
172
+ this.autoFrame(() => {
173
+ // CSI B (cursor down, bounded) so we don't scroll on the last row.
174
+ for (let i = 0; i < this.autocompleteLines; i++) {
175
+ this.emit("\x1b[B\x1b[2K");
176
+ }
177
+ this.emit(`\x1b[${this.autocompleteLines}A\x1b[${this.cursorTermCol}G`);
178
+ this.autocompleteLines = 0;
179
+ });
139
180
  }
140
181
  }
@@ -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 new TerminalBuffer. Returns null if xterm is not installed.
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 create(config?: TerminalBufferConfig): TerminalBuffer | null;
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
- let loadAttempted = false;
23
- let available = false;
24
- let TerminalCtor;
25
- let SerializeAddonCtor;
26
- function ensureXterm() {
27
- if (loadAttempted)
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
- if (!ensureXterm())
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 TerminalCtor({ cols, rows, allowProposedApi: true, scrollback });
84
- const serialize = new SerializeAddonCtor();
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 and wire it to a bus's shell:pty-data event.
90
- * Returns null if xterm is not installed.
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
- bus.on("shell:pty-data", ({ raw }) => { pending += raw; });
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
  }
@@ -5,9 +5,20 @@
5
5
  *
6
6
  * Requires opencode authenticated locally (`opencode auth login`).
7
7
  */
8
- import { createOpencode, type OpencodeClient, type Event, type Part, type ToolPart } from "@opencode-ai/sdk";
8
+ import {
9
+ createOpencode,
10
+ type OpencodeClient,
11
+ type Event,
12
+ type Part,
13
+ type ToolPart,
14
+ type QuestionRequest,
15
+ type QuestionInfo,
16
+ } from "@opencode-ai/sdk/v2";
9
17
  import type { ExtensionContext } from "agent-sh/types";
18
+ import type { InteractiveSession } from "agent-sh/agent/types";
10
19
  import { computeDiff, type DiffResult } from "agent-sh/utils/diff";
20
+ import { createToolUI } from "agent-sh/utils/tool-interactive";
21
+ import { palette as p } from "agent-sh/utils/palette";
11
22
 
12
23
  function parseUnifiedDiff(patch: string): DiffResult | null {
13
24
  if (!patch) return null;
@@ -49,7 +60,7 @@ function parseUnifiedDiff(patch: string): DiffResult | null {
49
60
  }
50
61
 
51
62
  export default function activate(ctx: ExtensionContext): void {
52
- const { bus, call } = ctx;
63
+ const { bus, call, compositor } = ctx;
53
64
 
54
65
  const cwd = (): string => {
55
66
  const v = call("cwd");
@@ -81,6 +92,7 @@ export default function activate(ctx: ExtensionContext): void {
81
92
  // prompt() and SSE deltas race; resolve the turn on session.idle.
82
93
  let pendingTurnEnd: (() => void) | null = null;
83
94
  let turnIdleSeen = false;
95
+ let turnError: string | null = null;
84
96
 
85
97
  const listeners: Array<{ event: string; fn: Function }> = [];
86
98
 
@@ -112,6 +124,9 @@ export default function activate(ctx: ExtensionContext): void {
112
124
 
113
125
  function handleToolPart(part: ToolPart): void {
114
126
  const { callID, tool: toolName, state } = part;
127
+ // Question tool is presented via an interactive picker (see question.asked) —
128
+ // skip the timeline entry to avoid a duplicate "running" bar.
129
+ if (toolName === "question") return;
115
130
  const kind = toolKind(toolName);
116
131
 
117
132
  if (state.status !== "pending" && !announcedTools.has(callID)) {
@@ -177,6 +192,7 @@ export default function activate(ctx: ExtensionContext): void {
177
192
  turnText += text;
178
193
  }
179
194
 
195
+
180
196
  function handleEvent(event: Event): void {
181
197
  if (!sessionId) return;
182
198
  const evType = (event as any).type as string;
@@ -209,23 +225,86 @@ export default function activate(ctx: ExtensionContext): void {
209
225
  }
210
226
  case "session.error": {
211
227
  const err = props.error as { message?: string } | undefined;
212
- bus.emit("agent:error", { message: err?.message ?? "opencode session error" });
228
+ const message = err?.message ?? "opencode session error";
229
+ // session.prompt() does not always reject on session error;
230
+ // drive turn-end ourselves and abort to unstick a hanging prompt().
231
+ turnError = message;
232
+ bus.emit("agent:error", { message });
233
+ turnIdleSeen = true;
234
+ pendingTurnEnd?.();
235
+ if (runtime && sessionId) {
236
+ runtime.client.session
237
+ .abort({ sessionID: sessionId, directory: sessionDirectory ?? undefined })
238
+ .catch(() => { /* abort is best-effort */ });
239
+ }
240
+ break;
241
+ }
242
+ case "question.asked": {
243
+ const req = props as QuestionRequest;
244
+ if (!runtime) break;
245
+ const ui = createToolUI(bus, compositor.surface("agent"));
246
+ ui.custom(createQuestionSession(req.questions)).then(async (result: QuestionResult) => {
247
+ if (!runtime) return;
248
+ // Record the question + answer as a synthetic tool entry so the
249
+ // timeline shows what was asked and what the user picked.
250
+ const callID = `question-${req.id}`;
251
+ const detail = req.questions.length === 1
252
+ ? req.questions[0]!.question
253
+ : req.questions.map((q, i) => `${q.header || `Q${i + 1}`}: ${q.question}`).join("; ");
254
+ bus.emit("agent:tool-started", {
255
+ title: "question",
256
+ toolCallId: callID,
257
+ kind: "execute",
258
+ displayDetail: detail,
259
+ });
260
+ if (result.cancelled) {
261
+ bus.emitTransform("agent:tool-completed", {
262
+ toolCallId: callID,
263
+ exitCode: 1,
264
+ rawOutput: "cancelled",
265
+ kind: "execute",
266
+ resultDisplay: { summary: "cancelled" },
267
+ });
268
+ runtime.client.question
269
+ .reject({ requestID: req.id, directory: sessionDirectory ?? undefined })
270
+ .catch(() => { /* best-effort */ });
271
+ return;
272
+ }
273
+ const summary = result.answers.length === 1
274
+ ? result.answers[0]!.join(", ")
275
+ : result.answers
276
+ .map((ans, i) => `${req.questions[i]!.header || `Q${i + 1}`}: ${ans.join(", ")}`)
277
+ .join("; ");
278
+ bus.emitTransform("agent:tool-completed", {
279
+ toolCallId: callID,
280
+ exitCode: 0,
281
+ rawOutput: summary,
282
+ kind: "execute",
283
+ resultDisplay: { summary },
284
+ });
285
+ try {
286
+ await runtime.client.question.reply({
287
+ requestID: req.id,
288
+ answers: result.answers,
289
+ directory: sessionDirectory ?? undefined,
290
+ });
291
+ } catch (err) {
292
+ bus.emit("agent:error", {
293
+ message: err instanceof Error ? err.message : String(err),
294
+ });
295
+ }
296
+ });
213
297
  break;
214
298
  }
215
299
  // Without a reply the gated tool hangs forever. The bridge has no
216
300
  // interactive approval UI, so auto-approve — mirrors claude-code-
217
301
  // bridge's permissionMode: "acceptEdits". Set permission.edit:
218
302
  // "allow" in opencode.json to skip the round-trip entirely.
219
- case "permission.asked":
220
- case "permission.updated": {
221
- const permissionID = props.id as string | undefined;
222
- if (!permissionID || !runtime || !sessionId) break;
223
- runtime.client
224
- .postSessionIdPermissionsPermissionId({
225
- path: { id: sessionId, permissionID },
226
- query: sessionDirectory ? { directory: sessionDirectory } : undefined,
227
- body: { response: "once" },
228
- })
303
+ case "permission.asked": {
304
+ const requestID = props.id as string | undefined;
305
+ if (!requestID || !runtime) break;
306
+ runtime.client.permission
307
+ .reply({ requestID, reply: "once", directory: sessionDirectory ?? undefined })
229
308
  .catch(() => { /* approval is best-effort */ });
230
309
  break;
231
310
  }
@@ -235,7 +314,7 @@ export default function activate(ctx: ExtensionContext): void {
235
314
  async function consumeEvents(client: OpencodeClient, signal: AbortSignal): Promise<void> {
236
315
  while (!signal.aborted) {
237
316
  try {
238
- const result = await client.event.subscribe({ signal });
317
+ const result = await client.event.subscribe({}, { signal });
239
318
  for await (const ev of result.stream) {
240
319
  if (signal.aborted) return;
241
320
  handleEvent(ev as Event);
@@ -261,6 +340,7 @@ export default function activate(ctx: ExtensionContext): void {
261
340
  bus.emit("agent:processing-start", {});
262
341
  turnText = "";
263
342
  turnIdleSeen = false;
343
+ turnError = null;
264
344
  // Set the idle waiter BEFORE prompt() so a fast session.idle can't
265
345
  // race in before we're listening.
266
346
  const idlePromise = new Promise<void>((resolve) => {
@@ -272,11 +352,9 @@ export default function activate(ctx: ExtensionContext): void {
272
352
 
273
353
  try {
274
354
  const res = await runtime.client.session.prompt({
275
- path: { id: sessionId },
276
- query: sessionDirectory ? { directory: sessionDirectory } : undefined,
277
- body: {
278
- parts: [{ type: "text", text: finalPrompt }],
279
- },
355
+ sessionID: sessionId,
356
+ directory: sessionDirectory ?? undefined,
357
+ parts: [{ type: "text", text: finalPrompt }],
280
358
  });
281
359
  if (!turnIdleSeen) {
282
360
  await Promise.race([
@@ -284,23 +362,29 @@ export default function activate(ctx: ExtensionContext): void {
284
362
  new Promise<void>((r) => setTimeout(r, 60_000)),
285
363
  ]);
286
364
  }
287
- // Fallback if SSE never delivered text (network blip, missed
288
- // partKinds entry); the prompt response always carries the final.
289
- if (!turnText && res.data?.parts) {
290
- for (const p of res.data.parts) {
291
- if (p.type === "text" && p.text) turnText += p.text;
292
- }
293
- if (turnText) {
294
- bus.emitTransform("agent:response-chunk", {
295
- blocks: [{ type: "text" as const, text: turnText }],
296
- });
365
+ if (turnError) {
366
+ bus.emitTransform("agent:response-done", { response: "" });
367
+ } else {
368
+ // Fallback if SSE never delivered text (network blip, missed
369
+ // partKinds entry); the prompt response always carries the final.
370
+ if (!turnText && res.data?.parts) {
371
+ for (const p of res.data.parts) {
372
+ if (p.type === "text" && p.text) turnText += p.text;
373
+ }
374
+ if (turnText) {
375
+ bus.emitTransform("agent:response-chunk", {
376
+ blocks: [{ type: "text" as const, text: turnText }],
377
+ });
378
+ }
297
379
  }
380
+ bus.emitTransform("agent:response-done", { response: turnText });
298
381
  }
299
- bus.emitTransform("agent:response-done", { response: turnText });
300
382
  } catch (err) {
301
- bus.emit("agent:error", {
302
- message: err instanceof Error ? err.message : String(err),
303
- });
383
+ if (!turnError) {
384
+ bus.emit("agent:error", {
385
+ message: err instanceof Error ? err.message : String(err),
386
+ });
387
+ }
304
388
  } finally {
305
389
  pendingTurnEnd = null;
306
390
  bus.emit("agent:processing-done", {});
@@ -310,7 +394,7 @@ export default function activate(ctx: ExtensionContext): void {
310
394
  const onCancel = async () => {
311
395
  if (!runtime || !sessionId) return;
312
396
  try {
313
- await runtime.client.session.abort({ path: { id: sessionId } });
397
+ await runtime.client.session.abort({ sessionID: sessionId, directory: sessionDirectory ?? undefined });
314
398
  } catch { /* abort is best-effort */ }
315
399
  };
316
400
 
@@ -321,7 +405,7 @@ export default function activate(ctx: ExtensionContext): void {
321
405
  partKinds.clear();
322
406
  // /reset is the one moment we deliberately let the project switch.
323
407
  sessionDirectory = cwd();
324
- const res = await runtime.client.session.create({ query: { directory: sessionDirectory } });
408
+ const res = await runtime.client.session.create({ directory: sessionDirectory });
325
409
  sessionId = res.data?.id ?? null;
326
410
  };
327
411
 
@@ -352,13 +436,13 @@ export default function activate(ctx: ExtensionContext): void {
352
436
  void consumeEvents(runtime.client, streamAbort.signal);
353
437
 
354
438
  sessionDirectory = cwd();
355
- const res = await runtime.client.session.create({ query: { directory: sessionDirectory } });
439
+ const res = await runtime.client.session.create({ directory: sessionDirectory });
356
440
  sessionId = res.data?.id ?? null;
357
441
  if (!sessionId) throw new Error("session.create returned no id");
358
442
 
359
443
  wireListeners();
360
444
  booting = false;
361
- bus.emit("agent:info", { name: "opencode", version: "1.x" });
445
+ bus.emit("agent:info", { name: "opencode", version: "2.x" });
362
446
  } catch (err) {
363
447
  booting = false;
364
448
  bus.emit("ui:error", {
@@ -381,3 +465,137 @@ export default function activate(ctx: ExtensionContext): void {
381
465
  },
382
466
  });
383
467
  }
468
+
469
+ // ── Interactive question picker ──────────────────────────────────
470
+
471
+ type QuestionResult = { answers: string[][]; cancelled: boolean };
472
+
473
+ function isKey(data: string, key: string): boolean {
474
+ switch (key) {
475
+ case "up": return data === "\x1b[A" || data === "\x1bOA";
476
+ case "down": return data === "\x1b[B" || data === "\x1bOB";
477
+ case "left": return data === "\x1b[D" || data === "\x1bOD";
478
+ case "right": return data === "\x1b[C" || data === "\x1bOC";
479
+ case "enter": return data === "\r" || data === "\n";
480
+ case "escape": return data === "\x1b";
481
+ case "tab": return data === "\t";
482
+ default: return data === key;
483
+ }
484
+ }
485
+
486
+ function createQuestionSession(questions: QuestionInfo[]): InteractiveSession<QuestionResult> {
487
+ const isMulti = questions.length > 1;
488
+ let tab = 0;
489
+ let optionIdx = 0;
490
+ // Per-question selected option indices (set, to support `multiple`).
491
+ const selections: Set<number>[] = questions.map(() => new Set());
492
+
493
+ return {
494
+ render(width) {
495
+ const w = Math.min(80, width);
496
+ const lines: string[] = [];
497
+ const q = questions[tab]!;
498
+ const sel = selections[tab]!;
499
+
500
+ lines.push(`${p.muted}${"─".repeat(w)}${p.reset}`);
501
+
502
+ if (isMulti) {
503
+ const tabs = questions.map((qq, i) => {
504
+ const answered = selections[i]!.size > 0;
505
+ const active = i === tab;
506
+ const box = answered ? "■" : "□";
507
+ const label = ` ${box} ${qq.header || `Q${i + 1}`} `;
508
+ return active
509
+ ? `${p.accent}${p.bold}${label}${p.reset}`
510
+ : `${p.muted}${label}${p.reset}`;
511
+ });
512
+ lines.push(` ${tabs.join(" ")}`);
513
+ lines.push("");
514
+ }
515
+
516
+ lines.push(` ${q.question}`);
517
+ lines.push("");
518
+ for (let i = 0; i < q.options.length; i++) {
519
+ const opt = q.options[i]!;
520
+ const cursor = i === optionIdx ? p.accent : "";
521
+ const reset = i === optionIdx ? p.reset : "";
522
+ const arrow = i === optionIdx ? `${p.accent}>${p.reset} ` : " ";
523
+ const mark = q.multiple
524
+ ? (sel.has(i) ? "[x]" : "[ ]")
525
+ : (sel.has(i) ? "(o)" : "( )");
526
+ lines.push(`${arrow}${cursor}${mark} ${i + 1}. ${opt.label}${reset}`);
527
+ if (opt.description) {
528
+ lines.push(` ${p.muted}${opt.description}${p.reset}`);
529
+ }
530
+ }
531
+
532
+ lines.push("");
533
+ const navKeys = isMulti ? "Tab/←→ switch • " : "";
534
+ const actionKeys = q.multiple
535
+ ? "↑↓ navigate • Space toggle • Enter confirm • Esc cancel"
536
+ : "↑↓ navigate • Enter select • Esc cancel";
537
+ lines.push(` ${p.dim}${navKeys}${actionKeys}${p.reset}`);
538
+ lines.push(`${p.muted}${"─".repeat(w)}${p.reset}`);
539
+ return lines;
540
+ },
541
+
542
+ handleInput(data, done) {
543
+ const q = questions[tab]!;
544
+ const sel = selections[tab]!;
545
+
546
+ if (isKey(data, "escape")) {
547
+ done({ answers: [], cancelled: true });
548
+ return;
549
+ }
550
+
551
+ if (isMulti) {
552
+ if (isKey(data, "tab") || isKey(data, "right")) {
553
+ tab = (tab + 1) % questions.length;
554
+ optionIdx = 0;
555
+ return;
556
+ }
557
+ if (isKey(data, "left")) {
558
+ tab = (tab - 1 + questions.length) % questions.length;
559
+ optionIdx = 0;
560
+ return;
561
+ }
562
+ }
563
+
564
+ if (isKey(data, "up")) {
565
+ optionIdx = Math.max(0, optionIdx - 1);
566
+ return;
567
+ }
568
+ if (isKey(data, "down")) {
569
+ optionIdx = Math.min(q.options.length - 1, optionIdx + 1);
570
+ return;
571
+ }
572
+
573
+ if (q.multiple && data === " ") {
574
+ if (sel.has(optionIdx)) sel.delete(optionIdx); else sel.add(optionIdx);
575
+ return;
576
+ }
577
+
578
+ if (isKey(data, "enter")) {
579
+ if (!q.multiple) {
580
+ sel.clear();
581
+ sel.add(optionIdx);
582
+ }
583
+ if (sel.size === 0) return;
584
+
585
+ const allAnswered = selections.every((s) => s.size > 0);
586
+ if (!isMulti || allAnswered) {
587
+ const answers = questions.map((qq, i) =>
588
+ Array.from(selections[i]!).map((idx) => qq.options[idx]!.label),
589
+ );
590
+ done({ answers, cancelled: false });
591
+ return;
592
+ }
593
+ const next = selections.findIndex((s) => s.size === 0);
594
+ if (next !== -1) {
595
+ tab = next;
596
+ optionIdx = 0;
597
+ }
598
+ }
599
+ },
600
+ };
601
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.12.25",
3
+ "version": "0.12.26",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",