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 +3 -1
- 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/terminal-buffer.d.ts +6 -9
- package/dist/utils/terminal-buffer.js +21 -53
- package/examples/extensions/opencode-bridge/index.ts +255 -37
- package/package.json +1 -1
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;
|
package/dist/shell/index.d.ts
CHANGED
|
@@ -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
|
package/dist/shell/index.js
CHANGED
|
@@ -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.
|
|
233
|
-
|
|
234
|
-
|
|
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.
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
this.
|
|
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.
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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.
|
|
375
|
-
|
|
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.
|
|
397
|
-
|
|
398
|
-
|
|
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.
|
|
410
|
-
|
|
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.
|
|
420
|
-
|
|
421
|
-
|
|
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.
|
|
433
|
-
|
|
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.
|
|
56
|
+
this.emit("\x1b[>1u\x1b[?2004h");
|
|
24
57
|
}
|
|
25
58
|
disableModeKeys() {
|
|
26
|
-
this.
|
|
59
|
+
this.emit("\x1b[<u\x1b[?2004l");
|
|
27
60
|
}
|
|
28
61
|
clearPromptArea() {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
rowsSoFar += lineTermRows;
|
|
140
|
+
this.emit(output + "\x1b8");
|
|
141
|
+
this.cursorRowsBelow = cursorRowFromTop;
|
|
103
142
|
}
|
|
104
|
-
|
|
105
|
-
this.cursorRowsBelow = cursorRowFromTop;
|
|
106
|
-
}
|
|
143
|
+
});
|
|
107
144
|
}
|
|
108
145
|
drawAutocomplete(vm) {
|
|
109
146
|
if (vm.items.length === 0)
|
|
110
147
|
return;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
this.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
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
|
}
|
|
@@ -5,9 +5,20 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Requires opencode authenticated locally (`opencode auth login`).
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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({
|
|
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({
|
|
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({
|
|
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: "
|
|
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
|
+
}
|