agent-sh 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/dist/acp-client.d.ts +24 -0
- package/dist/acp-client.js +144 -38
- package/dist/context-manager.d.ts +5 -3
- package/dist/context-manager.js +62 -31
- package/dist/event-bus.d.ts +4 -0
- package/dist/extension-loader.js +3 -14
- package/dist/extensions/shell-exec.js +27 -22
- package/dist/extensions/tui-renderer.d.ts +1 -1
- package/dist/extensions/tui-renderer.js +125 -26
- package/dist/index.js +184 -37
- package/dist/input-handler.d.ts +10 -0
- package/dist/input-handler.js +169 -10
- package/dist/mcp-server.js +37 -8
- package/dist/settings.d.ts +33 -0
- package/dist/settings.js +43 -0
- package/dist/shell.d.ts +1 -0
- package/dist/shell.js +44 -4
- package/dist/types.d.ts +2 -0
- package/dist/utils/ansi.d.ts +4 -1
- package/dist/utils/ansi.js +60 -2
- package/dist/utils/line-editor.d.ts +21 -1
- package/dist/utils/line-editor.js +193 -99
- package/dist/utils/markdown.js +4 -4
- package/dist/utils/tool-display.d.ts +2 -0
- package/dist/utils/tool-display.js +48 -20
- package/examples/pi-agent-sh.ts +166 -0
- package/package.json +1 -1
|
@@ -5,12 +5,22 @@
|
|
|
5
5
|
* terminal input bytes and receive high-level actions back. Buffer and
|
|
6
6
|
* cursor state are public for rendering.
|
|
7
7
|
*/
|
|
8
|
+
// ── Kitty protocol keycode → readable name ──────────────────────
|
|
9
|
+
const KITTY_KEY_NAMES = {
|
|
10
|
+
9: "tab", 13: "enter", 27: "escape", 127: "backspace",
|
|
11
|
+
};
|
|
8
12
|
// ── Line editor ─────────────────────────────────────────────────
|
|
9
13
|
export class LineEditor {
|
|
10
14
|
buffer = "";
|
|
11
15
|
cursor = 0;
|
|
16
|
+
pendingSeq = ""; // buffered incomplete escape sequence
|
|
12
17
|
/** Process raw terminal input, return actions for the consumer. */
|
|
13
18
|
feed(data) {
|
|
19
|
+
// If we had a pending incomplete escape sequence, prepend it
|
|
20
|
+
if (this.pendingSeq) {
|
|
21
|
+
data = this.pendingSeq + data;
|
|
22
|
+
this.pendingSeq = "";
|
|
23
|
+
}
|
|
14
24
|
const actions = [];
|
|
15
25
|
let i = 0;
|
|
16
26
|
while (i < data.length) {
|
|
@@ -18,16 +28,66 @@ export class LineEditor {
|
|
|
18
28
|
// ── Escape sequences ────────────────────────────────
|
|
19
29
|
if (ch === "\x1b") {
|
|
20
30
|
const next = data[i + 1];
|
|
21
|
-
//
|
|
31
|
+
// Incomplete escape — buffer and wait for next feed()
|
|
22
32
|
if (next == null) {
|
|
23
|
-
|
|
33
|
+
this.pendingSeq = "\x1b";
|
|
24
34
|
i++;
|
|
25
35
|
continue;
|
|
26
36
|
}
|
|
27
37
|
// CSI sequence: \x1b[...
|
|
28
38
|
if (next === "[") {
|
|
29
|
-
const { consumed } = this.handleCSI(data, i, actions);
|
|
30
|
-
|
|
39
|
+
const { consumed, incomplete } = this.handleCSI(data, i, actions);
|
|
40
|
+
if (incomplete) {
|
|
41
|
+
this.pendingSeq = data.slice(i, i + consumed);
|
|
42
|
+
i += consumed;
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
i += consumed;
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
// SS3 sequence: \x1bO... (application cursor mode — arrow keys, Home, End)
|
|
50
|
+
if (next === "O") {
|
|
51
|
+
const ss3Final = data[i + 2];
|
|
52
|
+
if (ss3Final == null) {
|
|
53
|
+
// Incomplete — buffer for next feed()
|
|
54
|
+
this.pendingSeq = data.slice(i, i + 2);
|
|
55
|
+
i += 2;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
i += 3; // consume \x1b O <final>
|
|
59
|
+
switch (ss3Final) {
|
|
60
|
+
case "A":
|
|
61
|
+
actions.push({ action: "arrow-up" });
|
|
62
|
+
break;
|
|
63
|
+
case "B":
|
|
64
|
+
actions.push({ action: "arrow-down" });
|
|
65
|
+
break;
|
|
66
|
+
case "C":
|
|
67
|
+
if (this.cursor < this.buffer.length) {
|
|
68
|
+
this.cursor++;
|
|
69
|
+
actions.push({ action: "changed" });
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
72
|
+
case "D":
|
|
73
|
+
if (this.cursor > 0) {
|
|
74
|
+
this.cursor--;
|
|
75
|
+
actions.push({ action: "changed" });
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
case "H": // Home
|
|
79
|
+
if (this.cursor > 0) {
|
|
80
|
+
this.cursor = 0;
|
|
81
|
+
actions.push({ action: "changed" });
|
|
82
|
+
}
|
|
83
|
+
break;
|
|
84
|
+
case "F": // End
|
|
85
|
+
if (this.cursor < this.buffer.length) {
|
|
86
|
+
this.cursor = this.buffer.length;
|
|
87
|
+
actions.push({ action: "changed" });
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
31
91
|
continue;
|
|
32
92
|
}
|
|
33
93
|
// Alt/Option + key: \x1b followed by char
|
|
@@ -56,98 +116,10 @@ export class LineEditor {
|
|
|
56
116
|
continue;
|
|
57
117
|
}
|
|
58
118
|
// ── Control characters ──────────────────────────────
|
|
59
|
-
if (ch === "\
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
if (ch === "\x03") {
|
|
65
|
-
actions.push({ action: "cancel" });
|
|
66
|
-
i++;
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
if (ch === "\t") {
|
|
70
|
-
actions.push({ action: "tab" });
|
|
71
|
-
i++;
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
if (ch === "\x7f" || ch === "\b") {
|
|
75
|
-
// Backspace
|
|
76
|
-
if (this.buffer.length === 0) {
|
|
77
|
-
actions.push({ action: "delete-empty" });
|
|
78
|
-
}
|
|
79
|
-
else if (this.cursor > 0) {
|
|
80
|
-
this.buffer = this.buffer.slice(0, this.cursor - 1) + this.buffer.slice(this.cursor);
|
|
81
|
-
this.cursor--;
|
|
82
|
-
actions.push({ action: "changed" });
|
|
83
|
-
}
|
|
84
|
-
i++;
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
// Ctrl-A: home
|
|
88
|
-
if (ch === "\x01") {
|
|
89
|
-
if (this.cursor > 0) {
|
|
90
|
-
this.cursor = 0;
|
|
91
|
-
actions.push({ action: "changed" });
|
|
92
|
-
}
|
|
93
|
-
i++;
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
// Ctrl-E: end
|
|
97
|
-
if (ch === "\x05") {
|
|
98
|
-
if (this.cursor < this.buffer.length) {
|
|
99
|
-
this.cursor = this.buffer.length;
|
|
100
|
-
actions.push({ action: "changed" });
|
|
101
|
-
}
|
|
102
|
-
i++;
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
// Ctrl-B: back one char
|
|
106
|
-
if (ch === "\x02") {
|
|
107
|
-
if (this.cursor > 0) {
|
|
108
|
-
this.cursor--;
|
|
109
|
-
actions.push({ action: "changed" });
|
|
110
|
-
}
|
|
111
|
-
i++;
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
// Ctrl-F: forward one char
|
|
115
|
-
if (ch === "\x06") {
|
|
116
|
-
if (this.cursor < this.buffer.length) {
|
|
117
|
-
this.cursor++;
|
|
118
|
-
actions.push({ action: "changed" });
|
|
119
|
-
}
|
|
120
|
-
i++;
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
// Ctrl-U: delete to start of line
|
|
124
|
-
if (ch === "\x15") {
|
|
125
|
-
if (this.cursor > 0) {
|
|
126
|
-
this.buffer = this.buffer.slice(this.cursor);
|
|
127
|
-
this.cursor = 0;
|
|
128
|
-
actions.push({ action: "changed" });
|
|
129
|
-
}
|
|
130
|
-
i++;
|
|
131
|
-
continue;
|
|
132
|
-
}
|
|
133
|
-
// Ctrl-K: delete to end of line
|
|
134
|
-
if (ch === "\x0b") {
|
|
135
|
-
if (this.cursor < this.buffer.length) {
|
|
136
|
-
this.buffer = this.buffer.slice(0, this.cursor);
|
|
137
|
-
actions.push({ action: "changed" });
|
|
138
|
-
}
|
|
139
|
-
i++;
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
// Ctrl-W: delete word backward
|
|
143
|
-
if (ch === "\x17") {
|
|
144
|
-
if (this.deleteWordBackward())
|
|
145
|
-
actions.push({ action: "changed" });
|
|
146
|
-
i++;
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
// Other control chars — ignore
|
|
150
|
-
if (ch.charCodeAt(0) < 0x20) {
|
|
119
|
+
if (ch.charCodeAt(0) < 0x20 || ch === "\x7f") {
|
|
120
|
+
const action = this.handleControl(ch);
|
|
121
|
+
if (action)
|
|
122
|
+
actions.push(action);
|
|
151
123
|
i++;
|
|
152
124
|
continue;
|
|
153
125
|
}
|
|
@@ -159,14 +131,123 @@ export class LineEditor {
|
|
|
159
131
|
}
|
|
160
132
|
return actions;
|
|
161
133
|
}
|
|
134
|
+
/** Check if there's a pending incomplete escape sequence. */
|
|
135
|
+
hasPendingEscape() {
|
|
136
|
+
return this.pendingSeq.length > 0;
|
|
137
|
+
}
|
|
138
|
+
/** Flush a pending sequence — treat bare \x1b as cancel, discard incomplete CSI. */
|
|
139
|
+
flushPendingEscape() {
|
|
140
|
+
if (!this.pendingSeq)
|
|
141
|
+
return [];
|
|
142
|
+
const wasBarEscape = this.pendingSeq === "\x1b";
|
|
143
|
+
this.pendingSeq = "";
|
|
144
|
+
return wasBarEscape ? [{ action: "cancel" }] : [];
|
|
145
|
+
}
|
|
162
146
|
clear() {
|
|
163
147
|
this.buffer = "";
|
|
164
148
|
this.cursor = 0;
|
|
149
|
+
this.pendingSeq = "";
|
|
150
|
+
}
|
|
151
|
+
// ── Key bindings ────────────────────────────────────────────
|
|
152
|
+
//
|
|
153
|
+
// Single source of truth for all keybindings. Both legacy control
|
|
154
|
+
// characters and kitty protocol sequences resolve to a key name
|
|
155
|
+
// and look it up here. To add a binding, add one entry.
|
|
156
|
+
bindings = {
|
|
157
|
+
"enter": () => ({ action: "submit", buffer: this.buffer }),
|
|
158
|
+
"ctrl+c": () => ({ action: "cancel" }),
|
|
159
|
+
"tab": () => ({ action: "tab" }),
|
|
160
|
+
"backspace": () => this.deleteBackward(),
|
|
161
|
+
"ctrl+d": () => this.buffer.length === 0 ? { action: "delete-empty" } : this.deleteForward(),
|
|
162
|
+
"ctrl+a": () => this.moveTo(0),
|
|
163
|
+
"ctrl+e": () => this.moveTo(this.buffer.length),
|
|
164
|
+
"ctrl+b": () => this.moveTo(this.cursor - 1),
|
|
165
|
+
"ctrl+f": () => this.moveTo(this.cursor + 1),
|
|
166
|
+
"ctrl+u": () => this.deleteRange(0, this.cursor),
|
|
167
|
+
"ctrl+k": () => this.deleteRange(this.cursor, this.buffer.length),
|
|
168
|
+
"ctrl+w": () => this.deleteWordBackward() ? { action: "changed" } : null,
|
|
169
|
+
"shift+enter": () => this.insertAt("\n"),
|
|
170
|
+
"shift+tab": () => ({ action: "shift+tab" }),
|
|
171
|
+
};
|
|
172
|
+
/** Resolve a key name from the bindings table and execute it. */
|
|
173
|
+
dispatch(key) {
|
|
174
|
+
return this.bindings[key]?.() ?? null;
|
|
175
|
+
}
|
|
176
|
+
// ── Legacy control character mapping ───────────────────────
|
|
177
|
+
/** Map a legacy control character to a key name. */
|
|
178
|
+
static CTRL_MAP = {
|
|
179
|
+
"\r": "enter", "\x03": "ctrl+c", "\t": "tab",
|
|
180
|
+
"\x7f": "backspace", "\b": "backspace",
|
|
181
|
+
"\x01": "ctrl+a", "\x02": "ctrl+b", "\x04": "ctrl+d",
|
|
182
|
+
"\x05": "ctrl+e", "\x06": "ctrl+f", "\x0b": "ctrl+k",
|
|
183
|
+
"\x15": "ctrl+u", "\x17": "ctrl+w",
|
|
184
|
+
};
|
|
185
|
+
handleControl(ch) {
|
|
186
|
+
const key = LineEditor.CTRL_MAP[ch];
|
|
187
|
+
return key ? this.dispatch(key) : null;
|
|
188
|
+
}
|
|
189
|
+
// ── Kitty keyboard protocol ────────────────────────────────
|
|
190
|
+
/** Handle a kitty protocol CSI u sequence. Params format: "keycode;modifier". */
|
|
191
|
+
handleKittyKey(params) {
|
|
192
|
+
const [kc, mod] = params.split(";").map(Number);
|
|
193
|
+
const keycode = kc;
|
|
194
|
+
const mods = (mod ?? 1) - 1; // kitty modifier bits
|
|
195
|
+
// Build key name from modifier + keycode
|
|
196
|
+
const modNames = [];
|
|
197
|
+
if (mods & 4)
|
|
198
|
+
modNames.push("ctrl");
|
|
199
|
+
if (mods & 1)
|
|
200
|
+
modNames.push("shift");
|
|
201
|
+
if (mods & 2)
|
|
202
|
+
modNames.push("alt");
|
|
203
|
+
const keyName = KITTY_KEY_NAMES[keycode] ?? String.fromCharCode(keycode);
|
|
204
|
+
const fullName = [...modNames, keyName].join("+");
|
|
205
|
+
// Try exact binding first, then fall back to ctrl char mapping
|
|
206
|
+
return this.dispatch(fullName)
|
|
207
|
+
?? ((mods & 4) && keycode >= 97 && keycode <= 122
|
|
208
|
+
? this.dispatch(`ctrl+${String.fromCharCode(keycode)}`)
|
|
209
|
+
: null)
|
|
210
|
+
?? (mods === 0 ? this.handleControl(String.fromCharCode(keycode)) : null);
|
|
211
|
+
}
|
|
212
|
+
// ── Editing primitives ─────────────────────────────────────
|
|
213
|
+
insertAt(ch) {
|
|
214
|
+
this.buffer = this.buffer.slice(0, this.cursor) + ch + this.buffer.slice(this.cursor);
|
|
215
|
+
this.cursor++;
|
|
216
|
+
return { action: "changed" };
|
|
217
|
+
}
|
|
218
|
+
moveTo(pos) {
|
|
219
|
+
const clamped = Math.max(0, Math.min(pos, this.buffer.length));
|
|
220
|
+
if (clamped === this.cursor)
|
|
221
|
+
return null;
|
|
222
|
+
this.cursor = clamped;
|
|
223
|
+
return { action: "changed" };
|
|
224
|
+
}
|
|
225
|
+
deleteBackward() {
|
|
226
|
+
if (this.buffer.length === 0)
|
|
227
|
+
return { action: "delete-empty" };
|
|
228
|
+
if (this.cursor <= 0)
|
|
229
|
+
return null;
|
|
230
|
+
this.buffer = this.buffer.slice(0, this.cursor - 1) + this.buffer.slice(this.cursor);
|
|
231
|
+
this.cursor--;
|
|
232
|
+
return { action: "changed" };
|
|
233
|
+
}
|
|
234
|
+
deleteForward() {
|
|
235
|
+
if (this.cursor >= this.buffer.length)
|
|
236
|
+
return null;
|
|
237
|
+
this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(this.cursor + 1);
|
|
238
|
+
return { action: "changed" };
|
|
239
|
+
}
|
|
240
|
+
deleteRange(start, end) {
|
|
241
|
+
if (start >= end)
|
|
242
|
+
return null;
|
|
243
|
+
this.buffer = this.buffer.slice(0, start) + this.buffer.slice(end);
|
|
244
|
+
this.cursor = start;
|
|
245
|
+
return { action: "changed" };
|
|
165
246
|
}
|
|
166
247
|
// ── CSI sequence handling ───────────────────────────────────
|
|
167
248
|
/**
|
|
168
249
|
* Parse and handle a CSI sequence (\x1b[...) starting at `start`.
|
|
169
|
-
* Returns the number of bytes consumed.
|
|
250
|
+
* Returns the number of bytes consumed and whether the sequence was incomplete.
|
|
170
251
|
*/
|
|
171
252
|
handleCSI(data, start, actions) {
|
|
172
253
|
// Skip \x1b[
|
|
@@ -177,8 +258,12 @@ export class LineEditor {
|
|
|
177
258
|
params += data[j];
|
|
178
259
|
j++;
|
|
179
260
|
}
|
|
180
|
-
|
|
181
|
-
|
|
261
|
+
// If we ran out of data before the final byte, sequence is incomplete
|
|
262
|
+
if (j >= data.length) {
|
|
263
|
+
return { consumed: j - start, incomplete: true };
|
|
264
|
+
}
|
|
265
|
+
const final = data[j];
|
|
266
|
+
const consumed = j - start + 1;
|
|
182
267
|
// Dispatch on final byte
|
|
183
268
|
switch (final) {
|
|
184
269
|
case "A": // Up arrow
|
|
@@ -223,6 +308,15 @@ export class LineEditor {
|
|
|
223
308
|
actions.push({ action: "changed" });
|
|
224
309
|
}
|
|
225
310
|
break;
|
|
311
|
+
case "Z": // Shift+Tab (legacy CSI sequence)
|
|
312
|
+
actions.push({ action: "shift+tab" });
|
|
313
|
+
break;
|
|
314
|
+
case "u": { // Kitty keyboard protocol: \x1b[<keycode>;<modifier>u
|
|
315
|
+
const action = this.handleKittyKey(params);
|
|
316
|
+
if (action)
|
|
317
|
+
actions.push(action);
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
226
320
|
case "~": // Extended keys: Delete (3~), etc.
|
|
227
321
|
if (params === "3") {
|
|
228
322
|
// Delete key: delete char under cursor
|
package/dist/utils/markdown.js
CHANGED
|
@@ -108,13 +108,13 @@ export class MarkdownRenderer {
|
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
printTopBorder() {
|
|
111
|
-
const
|
|
112
|
-
process.stdout.write(`${p.dim}${p.accent}${"─".repeat(
|
|
111
|
+
const termW = process.stdout.columns || 80;
|
|
112
|
+
process.stdout.write(`${p.dim}${p.accent}${"─".repeat(termW)}${p.reset}\n`);
|
|
113
113
|
this.firstLine = true;
|
|
114
114
|
}
|
|
115
115
|
printBottomBorder() {
|
|
116
|
-
const
|
|
117
|
-
process.stdout.write(`${p.dim}${p.accent}${"─".repeat(
|
|
116
|
+
const termW = process.stdout.columns || 80;
|
|
117
|
+
process.stdout.write(`${p.dim}${p.accent}${"─".repeat(termW)}${p.reset}\n`);
|
|
118
118
|
}
|
|
119
119
|
processBuffer() {
|
|
120
120
|
const lines = this.buffer.split("\n");
|
|
@@ -38,5 +38,7 @@ export declare function createSpinner(): SpinnerState;
|
|
|
38
38
|
*/
|
|
39
39
|
export declare function startSpinner(label: string, opts?: {
|
|
40
40
|
color?: string;
|
|
41
|
+
hint?: string;
|
|
42
|
+
startTime?: number;
|
|
41
43
|
}): SpinnerState;
|
|
42
44
|
export declare function stopSpinner(state: SpinnerState): void;
|
|
@@ -61,28 +61,53 @@ export function renderToolCall(tool, width) {
|
|
|
61
61
|
return [`${p.warning}${text}${p.reset}`];
|
|
62
62
|
}
|
|
63
63
|
const lines = [];
|
|
64
|
-
|
|
64
|
+
// Build a compact detail string to append after the title
|
|
65
|
+
let detail = "";
|
|
65
66
|
if (mode === "full") {
|
|
66
|
-
// Show file locations if available
|
|
67
|
-
if (tool.locations && tool.locations.length > 0) {
|
|
68
|
-
for (const loc of tool.locations) {
|
|
69
|
-
const lineInfo = loc.line ? `:${loc.line}` : "";
|
|
70
|
-
lines.push(` ${p.dim}${loc.path}${lineInfo}${p.reset}`);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
// Show command string for terminal tools
|
|
74
67
|
if (tool.command) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
68
|
+
detail = `$ ${tool.command}`;
|
|
69
|
+
}
|
|
70
|
+
else if (tool.locations && tool.locations.length > 0) {
|
|
71
|
+
const loc = tool.locations[0];
|
|
72
|
+
const lineInfo = loc.line ? `:${loc.line}` : "";
|
|
73
|
+
detail = `${loc.path}${lineInfo}`;
|
|
74
|
+
}
|
|
75
|
+
else if (tool.rawInput) {
|
|
76
|
+
const raw = tool.rawInput;
|
|
77
|
+
if (raw && typeof raw === "object") {
|
|
78
|
+
if (typeof raw.command === "string") {
|
|
79
|
+
detail = `$ ${raw.command}`;
|
|
80
|
+
}
|
|
81
|
+
else if (typeof raw.operation === "string") {
|
|
82
|
+
detail = raw.operation;
|
|
83
|
+
if (raw.ids && Array.isArray(raw.ids)) {
|
|
84
|
+
detail += ` #${raw.ids.join(",")}`;
|
|
85
|
+
}
|
|
86
|
+
if (typeof raw.query === "string") {
|
|
87
|
+
detail += ` "${raw.query}"`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
detail = formatRawInput(tool.rawInput, width - tool.title.length - 6);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
80
94
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
95
|
+
}
|
|
96
|
+
// Render as single line: ► title: detail
|
|
97
|
+
const maxDetailW = Math.max(1, width - tool.title.length - 6);
|
|
98
|
+
if (detail) {
|
|
99
|
+
if (detail.length > maxDetailW)
|
|
100
|
+
detail = detail.slice(0, maxDetailW - 1) + "…";
|
|
101
|
+
lines.push(`${p.warning}${p.bold}${icon} ${tool.title}${p.reset}${p.dim}: ${detail}${p.reset}`);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
lines.push(`${p.warning}${p.bold}${icon} ${tool.title}${p.reset}`);
|
|
105
|
+
}
|
|
106
|
+
// Show additional file locations on separate lines (if more than one)
|
|
107
|
+
if (mode === "full" && tool.locations && tool.locations.length > 1) {
|
|
108
|
+
for (const loc of tool.locations.slice(1)) {
|
|
109
|
+
const lineInfo = loc.line ? `:${loc.line}` : "";
|
|
110
|
+
lines.push(` ${p.dim}${loc.path}${lineInfo}${p.reset}`);
|
|
86
111
|
}
|
|
87
112
|
}
|
|
88
113
|
return lines;
|
|
@@ -168,12 +193,15 @@ export function createSpinner() {
|
|
|
168
193
|
*/
|
|
169
194
|
export function startSpinner(label, opts) {
|
|
170
195
|
const state = createSpinner();
|
|
196
|
+
if (opts?.startTime)
|
|
197
|
+
state.startTime = opts.startTime;
|
|
171
198
|
const color = opts?.color ?? p.accent;
|
|
199
|
+
const hint = opts?.hint ? ` ${p.dim}${opts.hint}${p.reset}` : "";
|
|
172
200
|
state.interval = setInterval(() => {
|
|
173
201
|
const frame = SPINNER_FRAMES[state.frame % SPINNER_FRAMES.length];
|
|
174
202
|
const elapsed = formatElapsed(Date.now() - state.startTime);
|
|
175
203
|
const timer = elapsed ? ` ${p.dim}${elapsed}${p.reset}` : "";
|
|
176
|
-
process.stdout.write(`\r ${color}${frame} ${label}...${p.reset}${timer}\x1b[K`);
|
|
204
|
+
process.stdout.write(`\r ${color}${frame} ${label}...${p.reset}${timer}${hint}\x1b[K`);
|
|
177
205
|
state.frame++;
|
|
178
206
|
}, 80);
|
|
179
207
|
return state;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi extension: agent-sh tools (user_shell + shell_recall).
|
|
3
|
+
*
|
|
4
|
+
* When running inside agent-sh, registers tools that communicate with
|
|
5
|
+
* the user's live terminal via a Unix domain socket (JSON-RPC 2.0).
|
|
6
|
+
*
|
|
7
|
+
* - user_shell: execute commands in the user's live PTY
|
|
8
|
+
* - shell_recall: search/expand/browse session exchange history
|
|
9
|
+
*
|
|
10
|
+
* Socket path comes from the AGENT_SH_SOCKET env var.
|
|
11
|
+
* When not running inside agent-sh, the extension silently does nothing.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
15
|
+
import { Type } from "@sinclair/typebox";
|
|
16
|
+
import { createConnection } from "node:net";
|
|
17
|
+
|
|
18
|
+
const SOCKET_PATH = process.env.AGENT_SH_SOCKET;
|
|
19
|
+
|
|
20
|
+
export default function (pi: ExtensionAPI): void {
|
|
21
|
+
if (!SOCKET_PATH) return; // Not running inside agent-sh
|
|
22
|
+
|
|
23
|
+
pi.registerTool({
|
|
24
|
+
name: "shell_cwd",
|
|
25
|
+
label: "Shell CWD",
|
|
26
|
+
description:
|
|
27
|
+
"Get the user's current working directory in their live shell. " +
|
|
28
|
+
"IMPORTANT: Your internal working directory may differ from the user's actual shell cwd — " +
|
|
29
|
+
"the user may have cd'd after your session started. Call this tool to get the real cwd " +
|
|
30
|
+
"before file operations if you're unsure.",
|
|
31
|
+
promptSnippet:
|
|
32
|
+
"Get the user's real shell cwd (may differ from your internal cwd).",
|
|
33
|
+
parameters: Type.Object({}),
|
|
34
|
+
|
|
35
|
+
async execute() {
|
|
36
|
+
const result = (await callSocket("shell/cwd", {})) as { cwd: string };
|
|
37
|
+
return {
|
|
38
|
+
content: [{ type: "text", text: `User's current working directory: ${result.cwd}` }],
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
pi.registerTool({
|
|
44
|
+
name: "user_shell",
|
|
45
|
+
label: "User Shell",
|
|
46
|
+
description:
|
|
47
|
+
"Execute a command in the user's live terminal session. " +
|
|
48
|
+
"Use this for commands that should affect the user's shell state: " +
|
|
49
|
+
"cd, export, source, pushd/popd, alias, etc. " +
|
|
50
|
+
"The command runs in the user's actual shell with their full environment " +
|
|
51
|
+
"(aliases, functions, PATH), not an isolated subprocess. " +
|
|
52
|
+
"NOTE: Your internal cwd may be stale — the user may have cd'd. " +
|
|
53
|
+
"Check the shell context for [shell cwd:...] labels or call shell_cwd " +
|
|
54
|
+
"to determine the real working directory. Use absolute paths when possible.",
|
|
55
|
+
promptSnippet:
|
|
56
|
+
"Run commands in the user's live shell (cd, export, source — affects their session). " +
|
|
57
|
+
"Your internal cwd may be stale — check shell context or use shell_cwd for the real cwd.",
|
|
58
|
+
parameters: Type.Object({
|
|
59
|
+
command: Type.String({ description: "Shell command to execute in the user's live terminal" }),
|
|
60
|
+
}),
|
|
61
|
+
|
|
62
|
+
async execute(_toolCallId, params) {
|
|
63
|
+
const result = (await callSocket("shell/exec", { command: params.command })) as {
|
|
64
|
+
output: string;
|
|
65
|
+
cwd: string;
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
content: [{ type: "text", text: result.output || "(no output)" }],
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
pi.registerTool({
|
|
74
|
+
name: "shell_recall",
|
|
75
|
+
label: "Shell Recall",
|
|
76
|
+
description:
|
|
77
|
+
"Retrieve past shell commands, agent responses, and tool executions from the session history. " +
|
|
78
|
+
"Use this to look up truncated output, search for previous commands or errors, " +
|
|
79
|
+
"or browse recent exchanges. Each entry shows [shell cwd:...] so you can see " +
|
|
80
|
+
"which directory commands were run in. Operations: " +
|
|
81
|
+
'"browse" lists recent exchange summaries with line counts, ' +
|
|
82
|
+
'"search" finds exchanges matching a regex query, ' +
|
|
83
|
+
'"expand" retrieves content by exchange ID (use start/end for specific line ranges).',
|
|
84
|
+
promptSnippet:
|
|
85
|
+
"Look up session history — search past commands/output, expand truncated exchanges, or browse recent activity.",
|
|
86
|
+
parameters: Type.Object({
|
|
87
|
+
operation: Type.Optional(
|
|
88
|
+
Type.Union([Type.Literal("search"), Type.Literal("expand"), Type.Literal("browse")], {
|
|
89
|
+
description: 'Operation to perform (default: "browse")',
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
query: Type.Optional(
|
|
93
|
+
Type.String({ description: 'Search query — supports regex (required for "search")' }),
|
|
94
|
+
),
|
|
95
|
+
ids: Type.Optional(
|
|
96
|
+
Type.Array(Type.Number(), {
|
|
97
|
+
description: 'Exchange IDs to expand (required for "expand")',
|
|
98
|
+
}),
|
|
99
|
+
),
|
|
100
|
+
start: Type.Optional(
|
|
101
|
+
Type.Number({ description: "Start line number, 1-indexed (optional, for expand)" }),
|
|
102
|
+
),
|
|
103
|
+
end: Type.Optional(
|
|
104
|
+
Type.Number({ description: "End line number, inclusive (optional, for expand)" }),
|
|
105
|
+
),
|
|
106
|
+
}),
|
|
107
|
+
|
|
108
|
+
async execute(_toolCallId, params) {
|
|
109
|
+
const result = (await callSocket("shell/recall", {
|
|
110
|
+
operation: params.operation || "browse",
|
|
111
|
+
query: params.query,
|
|
112
|
+
ids: params.ids,
|
|
113
|
+
start: params.start,
|
|
114
|
+
end: params.end,
|
|
115
|
+
})) as { result: string };
|
|
116
|
+
return {
|
|
117
|
+
content: [{ type: "text", text: result.result || "(no results)" }],
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// -- agent-sh socket client (JSON-RPC 2.0) --
|
|
124
|
+
|
|
125
|
+
let rpcId = 0;
|
|
126
|
+
|
|
127
|
+
function callSocket(method: string, params?: Record<string, unknown>): Promise<unknown> {
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
const conn = createConnection(SOCKET_PATH!);
|
|
130
|
+
let buffer = "";
|
|
131
|
+
|
|
132
|
+
conn.on("connect", () => {
|
|
133
|
+
const msg = { jsonrpc: "2.0", id: ++rpcId, method, params: params ?? {} };
|
|
134
|
+
conn.write(JSON.stringify(msg) + "\n");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
conn.on("data", (chunk) => {
|
|
138
|
+
buffer += chunk.toString();
|
|
139
|
+
const newlineIdx = buffer.indexOf("\n");
|
|
140
|
+
if (newlineIdx === -1) return;
|
|
141
|
+
|
|
142
|
+
const line = buffer.slice(0, newlineIdx).trim();
|
|
143
|
+
conn.destroy();
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const response = JSON.parse(line);
|
|
147
|
+
if (response.error) {
|
|
148
|
+
reject(new Error(response.error.message || "RPC error"));
|
|
149
|
+
} else {
|
|
150
|
+
resolve(response.result);
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
reject(new Error(`Invalid response from agent-sh: ${line}`));
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
conn.on("error", (err) => {
|
|
158
|
+
reject(new Error(`Failed to connect to agent-sh: ${err.message}`));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
conn.setTimeout(35_000, () => {
|
|
162
|
+
conn.destroy();
|
|
163
|
+
reject(new Error("Connection to agent-sh timed out"));
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
}
|