agent-sh 0.1.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 +659 -0
- package/dist/acp-client.d.ts +76 -0
- package/dist/acp-client.js +507 -0
- package/dist/context-manager.d.ts +45 -0
- package/dist/context-manager.js +405 -0
- package/dist/core.d.ts +41 -0
- package/dist/core.js +76 -0
- package/dist/event-bus.d.ts +140 -0
- package/dist/event-bus.js +79 -0
- package/dist/executor.d.ts +31 -0
- package/dist/executor.js +116 -0
- package/dist/extension-loader.d.ts +16 -0
- package/dist/extension-loader.js +164 -0
- package/dist/extensions/file-autocomplete.d.ts +2 -0
- package/dist/extensions/file-autocomplete.js +63 -0
- package/dist/extensions/shell-recall.d.ts +9 -0
- package/dist/extensions/shell-recall.js +8 -0
- package/dist/extensions/slash-commands.d.ts +2 -0
- package/dist/extensions/slash-commands.js +105 -0
- package/dist/extensions/tui-renderer.d.ts +2 -0
- package/dist/extensions/tui-renderer.js +354 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +159 -0
- package/dist/input-handler.d.ts +48 -0
- package/dist/input-handler.js +302 -0
- package/dist/output-parser.d.ts +55 -0
- package/dist/output-parser.js +166 -0
- package/dist/shell.d.ts +54 -0
- package/dist/shell.js +219 -0
- package/dist/types.d.ts +71 -0
- package/dist/types.js +1 -0
- package/dist/utils/ansi.d.ts +12 -0
- package/dist/utils/ansi.js +23 -0
- package/dist/utils/box-frame.d.ts +21 -0
- package/dist/utils/box-frame.js +60 -0
- package/dist/utils/diff-renderer.d.ts +20 -0
- package/dist/utils/diff-renderer.js +506 -0
- package/dist/utils/diff.d.ts +24 -0
- package/dist/utils/diff.js +122 -0
- package/dist/utils/file-watcher.d.ts +31 -0
- package/dist/utils/file-watcher.js +101 -0
- package/dist/utils/markdown.d.ts +39 -0
- package/dist/utils/markdown.js +248 -0
- package/dist/utils/palette.d.ts +32 -0
- package/dist/utils/palette.js +36 -0
- package/dist/utils/tool-display.d.ts +33 -0
- package/dist/utils/tool-display.js +141 -0
- package/examples/extensions/interactive-prompts.ts +161 -0
- package/examples/extensions/solarized-theme.ts +27 -0
- package/package.json +72 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { visibleLen } from "./utils/ansi.js";
|
|
2
|
+
import { palette as p } from "./utils/palette.js";
|
|
3
|
+
export class InputHandler {
|
|
4
|
+
ctx;
|
|
5
|
+
lineBuffer = "";
|
|
6
|
+
agentInputMode = false;
|
|
7
|
+
agentInputBuffer = "";
|
|
8
|
+
autocompleteActive = false;
|
|
9
|
+
autocompleteIndex = 0;
|
|
10
|
+
autocompleteItems = [];
|
|
11
|
+
autocompleteLines = 0;
|
|
12
|
+
bus;
|
|
13
|
+
onShowAgentInfo;
|
|
14
|
+
constructor(opts) {
|
|
15
|
+
this.ctx = opts.ctx;
|
|
16
|
+
this.bus = opts.bus;
|
|
17
|
+
this.onShowAgentInfo = opts.onShowAgentInfo;
|
|
18
|
+
}
|
|
19
|
+
/** Write the agent prompt line (clear + info prefix + ❯ + buffer text). */
|
|
20
|
+
writeAgentPromptLine(showBuffer = true) {
|
|
21
|
+
const agentInfo = this.onShowAgentInfo();
|
|
22
|
+
const infoPrefix = agentInfo.info ? `${agentInfo.info} ` : "";
|
|
23
|
+
process.stdout.write("\r\x1b[2K" +
|
|
24
|
+
infoPrefix +
|
|
25
|
+
p.warning + p.bold + "❯ " + p.reset +
|
|
26
|
+
(showBuffer ? p.accent + this.agentInputBuffer + p.reset : ""));
|
|
27
|
+
}
|
|
28
|
+
handleInput(data) {
|
|
29
|
+
// If agent is running (processing a query), only Ctrl-C and control keys
|
|
30
|
+
if (this.ctx.isAgentActive()) {
|
|
31
|
+
if (data === "\x03") {
|
|
32
|
+
this.bus.emit("agent:cancel-request", {});
|
|
33
|
+
}
|
|
34
|
+
else if (data.length === 1 && data.charCodeAt(0) < 32) {
|
|
35
|
+
this.bus.emit("input:keypress", { key: data });
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// Forward control chars that normal shell mode doesn't handle
|
|
40
|
+
if (data.length === 1 && data.charCodeAt(0) < 32 && !this.agentInputMode) {
|
|
41
|
+
const code = data.charCodeAt(0);
|
|
42
|
+
// Don't intercept keys that shell mode handles: CR, Ctrl-C, Ctrl-D, Tab
|
|
43
|
+
if (code !== 0x0d && code !== 0x03 && code !== 0x04 && code !== 0x09) {
|
|
44
|
+
this.bus.emit("input:keypress", { key: data });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// If in agent input mode (typing a query after ">")
|
|
48
|
+
if (this.agentInputMode) {
|
|
49
|
+
this.handleAgentInput(data);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
for (let i = 0; i < data.length; i++) {
|
|
53
|
+
const ch = data[i];
|
|
54
|
+
if (ch === "\r") {
|
|
55
|
+
// Record the command — output will be captured until next prompt marker
|
|
56
|
+
if (this.lineBuffer.trim()) {
|
|
57
|
+
this.ctx.onCommandEntered(this.lineBuffer.trim(), this.ctx.getCwd());
|
|
58
|
+
}
|
|
59
|
+
this.lineBuffer = "";
|
|
60
|
+
this.ctx.writeToPty(ch);
|
|
61
|
+
}
|
|
62
|
+
else if (ch === "\x7f" || ch === "\b") {
|
|
63
|
+
this.lineBuffer = this.lineBuffer.slice(0, -1);
|
|
64
|
+
this.ctx.writeToPty(ch);
|
|
65
|
+
}
|
|
66
|
+
else if (ch === "\x03") {
|
|
67
|
+
this.lineBuffer = "";
|
|
68
|
+
this.ctx.writeToPty(ch);
|
|
69
|
+
}
|
|
70
|
+
else if (ch === "\x04") {
|
|
71
|
+
this.lineBuffer = "";
|
|
72
|
+
this.ctx.writeToPty(ch);
|
|
73
|
+
}
|
|
74
|
+
else if (ch.charCodeAt(0) < 32 && ch !== "\t") {
|
|
75
|
+
this.lineBuffer = "";
|
|
76
|
+
this.ctx.writeToPty(ch);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
// Check if ">" at start of empty line → enter agent input mode
|
|
80
|
+
// But not if a foreground process (ssh, vim, etc.) is running
|
|
81
|
+
if (this.lineBuffer === "" && ch === ">" && !this.ctx.isForegroundBusy()) {
|
|
82
|
+
this.enterAgentInputMode();
|
|
83
|
+
return; // don't process remaining chars
|
|
84
|
+
}
|
|
85
|
+
this.lineBuffer += ch;
|
|
86
|
+
this.ctx.writeToPty(ch);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
enterAgentInputMode() {
|
|
91
|
+
this.agentInputMode = true;
|
|
92
|
+
this.agentInputBuffer = "";
|
|
93
|
+
this.writeAgentPromptLine(false);
|
|
94
|
+
}
|
|
95
|
+
exitAgentInputMode() {
|
|
96
|
+
this.dismissAutocomplete();
|
|
97
|
+
this.agentInputMode = false;
|
|
98
|
+
this.agentInputBuffer = "";
|
|
99
|
+
process.stdout.write("\r\x1b[2K");
|
|
100
|
+
this.printPrompt();
|
|
101
|
+
}
|
|
102
|
+
printPrompt() {
|
|
103
|
+
this.ctx.redrawPrompt();
|
|
104
|
+
}
|
|
105
|
+
renderAgentInput() {
|
|
106
|
+
this.clearAutocompleteLines();
|
|
107
|
+
this.writeAgentPromptLine();
|
|
108
|
+
this.updateAutocomplete();
|
|
109
|
+
}
|
|
110
|
+
updateAutocomplete() {
|
|
111
|
+
const { items } = this.bus.emitPipe("autocomplete:request", {
|
|
112
|
+
buffer: this.agentInputBuffer,
|
|
113
|
+
items: [],
|
|
114
|
+
});
|
|
115
|
+
if (items.length > 0) {
|
|
116
|
+
this.autocompleteItems = items;
|
|
117
|
+
this.autocompleteActive = true;
|
|
118
|
+
if (this.autocompleteIndex >= items.length)
|
|
119
|
+
this.autocompleteIndex = 0;
|
|
120
|
+
this.renderAutocomplete();
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
this.autocompleteActive = false;
|
|
124
|
+
this.autocompleteItems = [];
|
|
125
|
+
this.autocompleteLines = 0;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
renderAutocomplete() {
|
|
129
|
+
if (!this.autocompleteActive || this.autocompleteItems.length === 0)
|
|
130
|
+
return;
|
|
131
|
+
const lines = [];
|
|
132
|
+
for (let i = 0; i < this.autocompleteItems.length; i++) {
|
|
133
|
+
const item = this.autocompleteItems[i];
|
|
134
|
+
const selected = i === this.autocompleteIndex;
|
|
135
|
+
if (selected) {
|
|
136
|
+
lines.push(` \x1b[7m ${p.accent}${item.name.padEnd(12)}${p.reset}\x1b[7m ${item.description} ${p.reset}`);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
lines.push(` ${p.muted}${item.name.padEnd(12)} ${item.description}${p.reset}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
process.stdout.write("\n" + lines.join("\n"));
|
|
143
|
+
this.autocompleteLines = lines.length;
|
|
144
|
+
if (this.autocompleteLines > 0) {
|
|
145
|
+
process.stdout.write(`\x1b[${this.autocompleteLines}A`);
|
|
146
|
+
}
|
|
147
|
+
const agentInfo = this.onShowAgentInfo();
|
|
148
|
+
const infoLength = visibleLen(agentInfo.info);
|
|
149
|
+
const col = infoLength + 2 + this.agentInputBuffer.length;
|
|
150
|
+
process.stdout.write(`\r\x1b[${col}C`);
|
|
151
|
+
}
|
|
152
|
+
clearAutocompleteLines() {
|
|
153
|
+
if (this.autocompleteLines <= 0)
|
|
154
|
+
return;
|
|
155
|
+
process.stdout.write("\x1b7"); // save cursor
|
|
156
|
+
for (let i = 0; i < this.autocompleteLines; i++) {
|
|
157
|
+
process.stdout.write("\n\x1b[2K"); // move down, clear line
|
|
158
|
+
}
|
|
159
|
+
process.stdout.write("\x1b8"); // restore cursor
|
|
160
|
+
this.autocompleteLines = 0;
|
|
161
|
+
}
|
|
162
|
+
applyAutocomplete() {
|
|
163
|
+
if (!this.autocompleteActive || this.autocompleteItems.length === 0)
|
|
164
|
+
return;
|
|
165
|
+
const selected = this.autocompleteItems[this.autocompleteIndex];
|
|
166
|
+
if (!selected)
|
|
167
|
+
return;
|
|
168
|
+
const atPos = this.agentInputBuffer.lastIndexOf("@");
|
|
169
|
+
const isFileAc = atPos >= 0 &&
|
|
170
|
+
(atPos === 0 || this.agentInputBuffer[atPos - 1] === " ") &&
|
|
171
|
+
!this.agentInputBuffer.slice(atPos + 1).includes(" ");
|
|
172
|
+
if (isFileAc) {
|
|
173
|
+
this.agentInputBuffer =
|
|
174
|
+
this.agentInputBuffer.slice(0, atPos) + "@" + selected.name;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
this.agentInputBuffer = selected.name;
|
|
178
|
+
}
|
|
179
|
+
this.clearAutocompleteLines();
|
|
180
|
+
this.autocompleteActive = false;
|
|
181
|
+
this.autocompleteItems = [];
|
|
182
|
+
this.autocompleteIndex = 0;
|
|
183
|
+
this.writeAgentPromptLine();
|
|
184
|
+
if (isFileAc)
|
|
185
|
+
this.updateAutocomplete();
|
|
186
|
+
}
|
|
187
|
+
dismissAutocomplete() {
|
|
188
|
+
this.clearAutocompleteLines();
|
|
189
|
+
this.autocompleteActive = false;
|
|
190
|
+
this.autocompleteItems = [];
|
|
191
|
+
this.autocompleteIndex = 0;
|
|
192
|
+
}
|
|
193
|
+
handleAgentInput(data) {
|
|
194
|
+
for (let i = 0; i < data.length; i++) {
|
|
195
|
+
const ch = data[i];
|
|
196
|
+
// Detect arrow key sequences: \x1b[A (up), \x1b[B (down)
|
|
197
|
+
if (ch === "\x1b" && data[i + 1] === "[") {
|
|
198
|
+
const arrow = data[i + 2];
|
|
199
|
+
if (arrow === "A" && this.autocompleteActive) {
|
|
200
|
+
// Arrow up
|
|
201
|
+
this.autocompleteIndex =
|
|
202
|
+
this.autocompleteIndex === 0
|
|
203
|
+
? this.autocompleteItems.length - 1
|
|
204
|
+
: this.autocompleteIndex - 1;
|
|
205
|
+
this.clearAutocompleteLines();
|
|
206
|
+
this.writeAgentPromptLine();
|
|
207
|
+
this.renderAutocomplete();
|
|
208
|
+
i += 2;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
else if (arrow === "B" && this.autocompleteActive) {
|
|
212
|
+
this.autocompleteIndex =
|
|
213
|
+
this.autocompleteIndex === this.autocompleteItems.length - 1
|
|
214
|
+
? 0
|
|
215
|
+
: this.autocompleteIndex + 1;
|
|
216
|
+
this.clearAutocompleteLines();
|
|
217
|
+
this.writeAgentPromptLine();
|
|
218
|
+
this.renderAutocomplete();
|
|
219
|
+
i += 2;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
else if (!this.autocompleteActive) {
|
|
223
|
+
// Escape without arrow: cancel agent input mode
|
|
224
|
+
this.dismissAutocomplete();
|
|
225
|
+
this.exitAgentInputMode();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
// Other escape sequences (e.g. left/right arrow) — ignore for now
|
|
229
|
+
i += 2;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (ch === "\x1b") {
|
|
233
|
+
// Bare escape (no bracket follows)
|
|
234
|
+
if (this.autocompleteActive) {
|
|
235
|
+
this.dismissAutocomplete();
|
|
236
|
+
this.writeAgentPromptLine();
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
this.dismissAutocomplete();
|
|
240
|
+
this.exitAgentInputMode();
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (ch === "\t") {
|
|
245
|
+
if (this.autocompleteActive) {
|
|
246
|
+
this.applyAutocomplete();
|
|
247
|
+
}
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (ch === "\r") {
|
|
251
|
+
if (this.autocompleteActive) {
|
|
252
|
+
this.applyAutocomplete();
|
|
253
|
+
}
|
|
254
|
+
const query = this.agentInputBuffer.trim();
|
|
255
|
+
this.clearAutocompleteLines();
|
|
256
|
+
process.stdout.write("\r\x1b[2K");
|
|
257
|
+
this.agentInputMode = false;
|
|
258
|
+
this.agentInputBuffer = "";
|
|
259
|
+
this.dismissAutocomplete();
|
|
260
|
+
if (query && query.startsWith("/")) {
|
|
261
|
+
const spaceIdx = query.indexOf(" ");
|
|
262
|
+
const name = spaceIdx === -1 ? query : query.slice(0, spaceIdx);
|
|
263
|
+
const args = spaceIdx === -1 ? "" : query.slice(spaceIdx + 1).trim();
|
|
264
|
+
this.bus.emit("command:execute", { name, args });
|
|
265
|
+
this.ctx.redrawPrompt();
|
|
266
|
+
}
|
|
267
|
+
else if (query) {
|
|
268
|
+
this.bus.emit("agent:submit", { query });
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
this.exitAgentInputMode();
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
else if (ch === "\x03") {
|
|
276
|
+
// Ctrl-C: cancel
|
|
277
|
+
this.dismissAutocomplete();
|
|
278
|
+
this.exitAgentInputMode();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
else if (ch === "\x7f" || ch === "\b") {
|
|
282
|
+
// Backspace
|
|
283
|
+
if (this.agentInputBuffer.length > 0) {
|
|
284
|
+
this.agentInputBuffer = this.agentInputBuffer.slice(0, -1);
|
|
285
|
+
this.autocompleteIndex = 0;
|
|
286
|
+
this.renderAgentInput();
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
this.dismissAutocomplete();
|
|
290
|
+
this.exitAgentInputMode();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
else if (ch.charCodeAt(0) >= 32) {
|
|
295
|
+
// Printable character
|
|
296
|
+
this.agentInputBuffer += ch;
|
|
297
|
+
this.autocompleteIndex = 0;
|
|
298
|
+
this.renderAgentInput();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { EventBus } from "./event-bus.js";
|
|
2
|
+
/**
|
|
3
|
+
* Parses PTY output to detect command boundaries, track cwd,
|
|
4
|
+
* and emit shell events. Owns the command lifecycle state.
|
|
5
|
+
*/
|
|
6
|
+
export declare class OutputParser {
|
|
7
|
+
private bus;
|
|
8
|
+
private cwd;
|
|
9
|
+
private currentOutputCapture;
|
|
10
|
+
private lastCommand;
|
|
11
|
+
private foregroundBusy;
|
|
12
|
+
private capturingPrompt;
|
|
13
|
+
private promptCaptureComplete;
|
|
14
|
+
private promptBuffer;
|
|
15
|
+
private lastPrompt;
|
|
16
|
+
constructor(bus: EventBus, initialCwd: string);
|
|
17
|
+
/** Process a chunk of PTY output data. */
|
|
18
|
+
processData(data: string): void;
|
|
19
|
+
/** Called when user presses Enter on a non-empty line. */
|
|
20
|
+
onCommandEntered(command: string, cwd: string): void;
|
|
21
|
+
/** Returns the full captured prompt bytes, or empty if incomplete. */
|
|
22
|
+
getLastPrompt(): string;
|
|
23
|
+
/**
|
|
24
|
+
* Returns just the last line of the captured prompt (e.g. p10k's "❯ " line).
|
|
25
|
+
* This is safe to replay with \r because it's linear text (colors + chars),
|
|
26
|
+
* not relative cursor positioning. Returns empty if no complete capture.
|
|
27
|
+
*/
|
|
28
|
+
getLastPromptLine(): string;
|
|
29
|
+
isPromptCaptureComplete(): boolean;
|
|
30
|
+
isForegroundBusy(): boolean;
|
|
31
|
+
getCwd(): string;
|
|
32
|
+
private parseOSC7;
|
|
33
|
+
/**
|
|
34
|
+
* Detect our custom prompt marker (OSC 9999) in the PTY stream.
|
|
35
|
+
* Each time a prompt appears, we finalize the previous command's output.
|
|
36
|
+
*/
|
|
37
|
+
private parsePromptMarker;
|
|
38
|
+
/**
|
|
39
|
+
* Detect end-of-prompt marker (OSC 9998). Finalizes the bracketed capture.
|
|
40
|
+
*
|
|
41
|
+
* By the time this runs, the current chunk has already been appended to
|
|
42
|
+
* promptBuffer (either by parsePromptMarker for the first chunk, or by
|
|
43
|
+
* the wasCapturing guard in processData for subsequent chunks). So we
|
|
44
|
+
* just need to trim everything from the end marker onward.
|
|
45
|
+
*/
|
|
46
|
+
private parsePromptEnd;
|
|
47
|
+
/**
|
|
48
|
+
* Strip internal OSC markers from captured prompt so replay is clean.
|
|
49
|
+
* We intentionally strip all OSC 7 sequences — they're used for cwd
|
|
50
|
+
* reporting and have no visual effect, so replaying them would just
|
|
51
|
+
* cause duplicate cwd-change events.
|
|
52
|
+
*/
|
|
53
|
+
private sanitizePromptForReplay;
|
|
54
|
+
private removeEchoedCommand;
|
|
55
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { stripAnsi } from "./utils/ansi.js";
|
|
2
|
+
/**
|
|
3
|
+
* Parses PTY output to detect command boundaries, track cwd,
|
|
4
|
+
* and emit shell events. Owns the command lifecycle state.
|
|
5
|
+
*/
|
|
6
|
+
export class OutputParser {
|
|
7
|
+
bus;
|
|
8
|
+
cwd;
|
|
9
|
+
currentOutputCapture = "";
|
|
10
|
+
lastCommand = "";
|
|
11
|
+
foregroundBusy = false;
|
|
12
|
+
capturingPrompt = false;
|
|
13
|
+
promptCaptureComplete = false;
|
|
14
|
+
promptBuffer = "";
|
|
15
|
+
lastPrompt = "";
|
|
16
|
+
constructor(bus, initialCwd) {
|
|
17
|
+
this.bus = bus;
|
|
18
|
+
this.cwd = initialCwd;
|
|
19
|
+
}
|
|
20
|
+
/** Process a chunk of PTY output data. */
|
|
21
|
+
processData(data) {
|
|
22
|
+
this.parseOSC7(data);
|
|
23
|
+
// Bracketed prompt capture: accumulate bytes between OSC 9999 and 9998.
|
|
24
|
+
// parsePromptMarker may start capture (setting promptBuffer to the tail
|
|
25
|
+
// of the current chunk), so we only append subsequent chunks here.
|
|
26
|
+
const wasCapturing = this.capturingPrompt;
|
|
27
|
+
this.parsePromptMarker(data);
|
|
28
|
+
// If we were already capturing before this chunk (and still are), append
|
|
29
|
+
// the full chunk. If capture just started in parsePromptMarker above, the
|
|
30
|
+
// tail after the start marker is already in promptBuffer — don't double-add.
|
|
31
|
+
if (wasCapturing && this.capturingPrompt) {
|
|
32
|
+
this.promptBuffer += data;
|
|
33
|
+
}
|
|
34
|
+
// Check for end marker. Must run after the append above so that
|
|
35
|
+
// multi-chunk captures include this chunk's data before we finalize.
|
|
36
|
+
this.parsePromptEnd(data);
|
|
37
|
+
}
|
|
38
|
+
/** Called when user presses Enter on a non-empty line. */
|
|
39
|
+
onCommandEntered(command, cwd) {
|
|
40
|
+
this.lastCommand = command;
|
|
41
|
+
this.currentOutputCapture = "";
|
|
42
|
+
this.bus.emit("shell:command-start", { command, cwd });
|
|
43
|
+
if (!this.foregroundBusy) {
|
|
44
|
+
this.foregroundBusy = true;
|
|
45
|
+
this.bus.emit("shell:foreground-busy", { busy: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Returns the full captured prompt bytes, or empty if incomplete. */
|
|
49
|
+
getLastPrompt() {
|
|
50
|
+
if (!this.promptCaptureComplete)
|
|
51
|
+
return "";
|
|
52
|
+
return this.lastPrompt;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Returns just the last line of the captured prompt (e.g. p10k's "❯ " line).
|
|
56
|
+
* This is safe to replay with \r because it's linear text (colors + chars),
|
|
57
|
+
* not relative cursor positioning. Returns empty if no complete capture.
|
|
58
|
+
*/
|
|
59
|
+
getLastPromptLine() {
|
|
60
|
+
if (!this.promptCaptureComplete)
|
|
61
|
+
return "";
|
|
62
|
+
// Find the last \r\n or \n — everything after it is the final prompt line
|
|
63
|
+
const lastNewline = this.lastPrompt.lastIndexOf("\n");
|
|
64
|
+
if (lastNewline < 0)
|
|
65
|
+
return this.lastPrompt;
|
|
66
|
+
return this.lastPrompt.slice(lastNewline + 1);
|
|
67
|
+
}
|
|
68
|
+
isPromptCaptureComplete() {
|
|
69
|
+
return this.promptCaptureComplete;
|
|
70
|
+
}
|
|
71
|
+
isForegroundBusy() {
|
|
72
|
+
return this.foregroundBusy;
|
|
73
|
+
}
|
|
74
|
+
getCwd() {
|
|
75
|
+
return this.cwd;
|
|
76
|
+
}
|
|
77
|
+
// ── Parsing ─────────────────────────────────────────────────
|
|
78
|
+
parseOSC7(data) {
|
|
79
|
+
const match = data.match(/\x1b\]7;file:\/\/[^/]*(\/[^\x07\x1b]*)/);
|
|
80
|
+
if (match?.[1]) {
|
|
81
|
+
const newCwd = decodeURIComponent(match[1]);
|
|
82
|
+
if (newCwd !== this.cwd) {
|
|
83
|
+
this.cwd = newCwd;
|
|
84
|
+
this.bus.emit("shell:cwd-change", { cwd: this.cwd });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Detect our custom prompt marker (OSC 9999) in the PTY stream.
|
|
90
|
+
* Each time a prompt appears, we finalize the previous command's output.
|
|
91
|
+
*/
|
|
92
|
+
parsePromptMarker(data) {
|
|
93
|
+
if (data.includes("\x1b]9999;PROMPT\x07")) {
|
|
94
|
+
if (this.foregroundBusy) {
|
|
95
|
+
this.foregroundBusy = false;
|
|
96
|
+
this.bus.emit("shell:foreground-busy", { busy: false });
|
|
97
|
+
}
|
|
98
|
+
if (this.lastCommand) {
|
|
99
|
+
const output = stripAnsi(this.currentOutputCapture).trim();
|
|
100
|
+
const cleaned = this.removeEchoedCommand(output, this.lastCommand);
|
|
101
|
+
this.bus.emit("shell:command-done", {
|
|
102
|
+
command: this.lastCommand,
|
|
103
|
+
output: cleaned,
|
|
104
|
+
cwd: this.cwd,
|
|
105
|
+
exitCode: null,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
this.lastCommand = "";
|
|
109
|
+
this.currentOutputCapture = "";
|
|
110
|
+
// Start bracketed prompt capture: accumulate bytes until OSC 9998
|
|
111
|
+
this.capturingPrompt = true;
|
|
112
|
+
this.promptCaptureComplete = false;
|
|
113
|
+
this.promptBuffer = "";
|
|
114
|
+
const markerEnd = data.indexOf("\x1b]9999;PROMPT\x07") + "\x1b]9999;PROMPT\x07".length;
|
|
115
|
+
if (markerEnd < data.length) {
|
|
116
|
+
this.promptBuffer = data.slice(markerEnd);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
this.currentOutputCapture += data;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Detect end-of-prompt marker (OSC 9998). Finalizes the bracketed capture.
|
|
125
|
+
*
|
|
126
|
+
* By the time this runs, the current chunk has already been appended to
|
|
127
|
+
* promptBuffer (either by parsePromptMarker for the first chunk, or by
|
|
128
|
+
* the wasCapturing guard in processData for subsequent chunks). So we
|
|
129
|
+
* just need to trim everything from the end marker onward.
|
|
130
|
+
*/
|
|
131
|
+
parsePromptEnd(data) {
|
|
132
|
+
if (!this.capturingPrompt)
|
|
133
|
+
return;
|
|
134
|
+
if (!data.includes("\x1b]9998;READY\x07"))
|
|
135
|
+
return;
|
|
136
|
+
// promptBuffer already contains this chunk's data. Find the end marker
|
|
137
|
+
// within the buffer and trim everything from it onward.
|
|
138
|
+
const endMarker = "\x1b]9998;READY\x07";
|
|
139
|
+
const bufEndIdx = this.promptBuffer.indexOf(endMarker);
|
|
140
|
+
if (bufEndIdx >= 0) {
|
|
141
|
+
this.promptBuffer = this.promptBuffer.slice(0, bufEndIdx);
|
|
142
|
+
}
|
|
143
|
+
this.capturingPrompt = false;
|
|
144
|
+
this.promptCaptureComplete = true;
|
|
145
|
+
this.lastPrompt = this.sanitizePromptForReplay(this.promptBuffer);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Strip internal OSC markers from captured prompt so replay is clean.
|
|
149
|
+
* We intentionally strip all OSC 7 sequences — they're used for cwd
|
|
150
|
+
* reporting and have no visual effect, so replaying them would just
|
|
151
|
+
* cause duplicate cwd-change events.
|
|
152
|
+
*/
|
|
153
|
+
sanitizePromptForReplay(raw) {
|
|
154
|
+
return raw
|
|
155
|
+
.replace(/\x1b\]7;[^\x07]*\x07/g, "") // OSC 7 (cwd reporting)
|
|
156
|
+
.replace(/\x1b\]9999;PROMPT\x07/g, "") // start marker
|
|
157
|
+
.replace(/\x1b\]9998;READY\x07/g, ""); // end marker
|
|
158
|
+
}
|
|
159
|
+
removeEchoedCommand(output, command) {
|
|
160
|
+
const lines = output.split("\n");
|
|
161
|
+
if (lines.length > 0 && lines[0].includes(command.slice(0, 20))) {
|
|
162
|
+
return lines.slice(1).join("\n").trim();
|
|
163
|
+
}
|
|
164
|
+
return output;
|
|
165
|
+
}
|
|
166
|
+
}
|
package/dist/shell.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { EventBus } from "./event-bus.js";
|
|
2
|
+
import { type InputContext } from "./input-handler.js";
|
|
3
|
+
export declare class Shell implements InputContext {
|
|
4
|
+
private ptyProcess;
|
|
5
|
+
private bus;
|
|
6
|
+
private inputHandler;
|
|
7
|
+
private outputParser;
|
|
8
|
+
private paused;
|
|
9
|
+
private agentActive;
|
|
10
|
+
private tmpDir?;
|
|
11
|
+
constructor(opts: {
|
|
12
|
+
bus: EventBus;
|
|
13
|
+
onShowAgentInfo?: () => {
|
|
14
|
+
info: string;
|
|
15
|
+
model?: string;
|
|
16
|
+
};
|
|
17
|
+
cols: number;
|
|
18
|
+
rows: number;
|
|
19
|
+
shell: string;
|
|
20
|
+
cwd: string;
|
|
21
|
+
});
|
|
22
|
+
isForegroundBusy(): boolean;
|
|
23
|
+
getCwd(): string;
|
|
24
|
+
isAgentActive(): boolean;
|
|
25
|
+
writeToPty(data: string): void;
|
|
26
|
+
/**
|
|
27
|
+
* Lightweight redraw: replay just the last line of the shell's prompt
|
|
28
|
+
* (e.g. p10k's "❯ "). This works because agent input mode only overwrites
|
|
29
|
+
* the final prompt line — the path bar above is still intact. The last
|
|
30
|
+
* line is linear text (colors + chars + clear-to-end), no cursor positioning.
|
|
31
|
+
*/
|
|
32
|
+
redrawPrompt(): void;
|
|
33
|
+
/**
|
|
34
|
+
* Heavy redraw: send \n to PTY to trigger a full precmd → prompt cycle.
|
|
35
|
+
* Use this after agent responses where stdout has moved far from where
|
|
36
|
+
* zle expects the cursor. The blank line is acceptable as a separator.
|
|
37
|
+
*/
|
|
38
|
+
freshPrompt(): void;
|
|
39
|
+
onCommandEntered(command: string, cwd: string): void;
|
|
40
|
+
private setupOutput;
|
|
41
|
+
private setupInput;
|
|
42
|
+
/**
|
|
43
|
+
* React to agent lifecycle events — Shell manages its own state
|
|
44
|
+
* rather than being driven by AcpClient. This means AcpClient has
|
|
45
|
+
* zero frontend knowledge; any frontend can subscribe to the same events.
|
|
46
|
+
*/
|
|
47
|
+
private setupAgentLifecycle;
|
|
48
|
+
resize(cols: number, rows: number): void;
|
|
49
|
+
onExit(callback: (e: {
|
|
50
|
+
exitCode: number;
|
|
51
|
+
signal?: number;
|
|
52
|
+
}) => void): void;
|
|
53
|
+
kill(): void;
|
|
54
|
+
}
|