agent-sh 0.1.0 → 0.2.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 +50 -581
- package/dist/acp-client.js +37 -10
- package/dist/context-manager.d.ts +1 -1
- package/dist/context-manager.js +14 -14
- package/dist/event-bus.d.ts +25 -0
- package/dist/extensions/shell-exec.d.ts +24 -0
- package/dist/extensions/shell-exec.js +183 -0
- package/dist/extensions/tui-renderer.js +9 -3
- package/dist/index.js +42 -0
- package/dist/input-handler.d.ts +3 -3
- package/dist/input-handler.js +97 -124
- package/dist/mcp-server.d.ts +13 -0
- package/dist/mcp-server.js +205 -0
- package/dist/output-parser.d.ts +5 -26
- package/dist/output-parser.js +16 -78
- package/dist/shell.d.ts +8 -4
- package/dist/shell.js +46 -8
- package/dist/types.d.ts +2 -0
- package/dist/utils/line-editor.d.ts +39 -0
- package/dist/utils/line-editor.js +287 -0
- package/dist/utils/tool-display.d.ts +9 -0
- package/dist/utils/tool-display.js +63 -8
- package/package.json +1 -1
package/dist/output-parser.js
CHANGED
|
@@ -9,10 +9,7 @@ export class OutputParser {
|
|
|
9
9
|
currentOutputCapture = "";
|
|
10
10
|
lastCommand = "";
|
|
11
11
|
foregroundBusy = false;
|
|
12
|
-
|
|
13
|
-
promptCaptureComplete = false;
|
|
14
|
-
promptBuffer = "";
|
|
15
|
-
lastPrompt = "";
|
|
12
|
+
promptReady = false;
|
|
16
13
|
constructor(bus, initialCwd) {
|
|
17
14
|
this.bus = bus;
|
|
18
15
|
this.cwd = initialCwd;
|
|
@@ -20,19 +17,7 @@ export class OutputParser {
|
|
|
20
17
|
/** Process a chunk of PTY output data. */
|
|
21
18
|
processData(data) {
|
|
22
19
|
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
20
|
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
21
|
this.parsePromptEnd(data);
|
|
37
22
|
}
|
|
38
23
|
/** Called when user presses Enter on a non-empty line. */
|
|
@@ -45,28 +30,9 @@ export class OutputParser {
|
|
|
45
30
|
this.bus.emit("shell:foreground-busy", { busy: true });
|
|
46
31
|
}
|
|
47
32
|
}
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
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;
|
|
33
|
+
/** Whether the shell's prompt is fully rendered and ready for input. */
|
|
34
|
+
isPromptReady() {
|
|
35
|
+
return this.promptReady;
|
|
70
36
|
}
|
|
71
37
|
isForegroundBusy() {
|
|
72
38
|
return this.foregroundBusy;
|
|
@@ -90,7 +56,14 @@ export class OutputParser {
|
|
|
90
56
|
* Each time a prompt appears, we finalize the previous command's output.
|
|
91
57
|
*/
|
|
92
58
|
parsePromptMarker(data) {
|
|
93
|
-
|
|
59
|
+
const marker = "\x1b]9999;PROMPT\x07";
|
|
60
|
+
const markerIdx = data.indexOf(marker);
|
|
61
|
+
if (markerIdx !== -1) {
|
|
62
|
+
// Capture any output that arrived in the same chunk before the marker
|
|
63
|
+
if (markerIdx > 0) {
|
|
64
|
+
this.currentOutputCapture += data.slice(0, markerIdx);
|
|
65
|
+
}
|
|
66
|
+
this.promptReady = false;
|
|
94
67
|
if (this.foregroundBusy) {
|
|
95
68
|
this.foregroundBusy = false;
|
|
96
69
|
this.bus.emit("shell:foreground-busy", { busy: false });
|
|
@@ -107,54 +80,19 @@ export class OutputParser {
|
|
|
107
80
|
}
|
|
108
81
|
this.lastCommand = "";
|
|
109
82
|
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
83
|
}
|
|
119
84
|
else {
|
|
120
85
|
this.currentOutputCapture += data;
|
|
121
86
|
}
|
|
122
87
|
}
|
|
123
88
|
/**
|
|
124
|
-
* Detect end-of-prompt marker (OSC 9998).
|
|
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.
|
|
89
|
+
* Detect end-of-prompt marker (OSC 9998). The prompt is fully rendered
|
|
90
|
+
* and the shell is ready for input.
|
|
130
91
|
*/
|
|
131
92
|
parsePromptEnd(data) {
|
|
132
|
-
if (
|
|
133
|
-
|
|
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);
|
|
93
|
+
if (data.includes("\x1b]9998;READY\x07")) {
|
|
94
|
+
this.promptReady = true;
|
|
142
95
|
}
|
|
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
96
|
}
|
|
159
97
|
removeEchoedCommand(output, command) {
|
|
160
98
|
const lines = output.split("\n");
|
package/dist/shell.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export declare class Shell implements InputContext {
|
|
|
7
7
|
private outputParser;
|
|
8
8
|
private paused;
|
|
9
9
|
private agentActive;
|
|
10
|
+
private isZsh;
|
|
10
11
|
private tmpDir?;
|
|
11
12
|
constructor(opts: {
|
|
12
13
|
bus: EventBus;
|
|
@@ -24,10 +25,11 @@ export declare class Shell implements InputContext {
|
|
|
24
25
|
isAgentActive(): boolean;
|
|
25
26
|
writeToPty(data: string): void;
|
|
26
27
|
/**
|
|
27
|
-
* Lightweight redraw:
|
|
28
|
-
* (
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* Lightweight redraw: ask the shell to redraw its own prompt via a hidden
|
|
29
|
+
* ZLE widget (zsh) bound to \e[9999~. The shell knows how to draw its
|
|
30
|
+
* prompt correctly — we don't try to replay captured bytes.
|
|
31
|
+
*
|
|
32
|
+
* For bash, falls back to sending \n for a fresh prompt cycle.
|
|
31
33
|
*/
|
|
32
34
|
redrawPrompt(): void;
|
|
33
35
|
/**
|
|
@@ -45,6 +47,8 @@ export declare class Shell implements InputContext {
|
|
|
45
47
|
* zero frontend knowledge; any frontend can subscribe to the same events.
|
|
46
48
|
*/
|
|
47
49
|
private setupAgentLifecycle;
|
|
50
|
+
/** Temp directory used for shell config and sockets. */
|
|
51
|
+
getTmpDir(): string | undefined;
|
|
48
52
|
resize(cols: number, rows: number): void;
|
|
49
53
|
onExit(callback: (e: {
|
|
50
54
|
exitCode: number;
|
package/dist/shell.js
CHANGED
|
@@ -11,6 +11,7 @@ export class Shell {
|
|
|
11
11
|
outputParser;
|
|
12
12
|
paused = false;
|
|
13
13
|
agentActive = false;
|
|
14
|
+
isZsh = false;
|
|
14
15
|
tmpDir;
|
|
15
16
|
constructor(opts) {
|
|
16
17
|
// Build environment — filter out undefined values (node-pty's native
|
|
@@ -38,6 +39,7 @@ export class Shell {
|
|
|
38
39
|
let shellArgs;
|
|
39
40
|
const osc7Cmd = 'printf "\\e]7;file://%s%s\\a" "$(hostname)" "$PWD"';
|
|
40
41
|
const promptMarker = 'printf "\\e]9999;PROMPT\\a"';
|
|
42
|
+
this.isZsh = isZsh;
|
|
41
43
|
if (isZsh) {
|
|
42
44
|
// For zsh: use ZDOTDIR to source user's real config, then append
|
|
43
45
|
// our hooks via precmd_functions (additive — doesn't clobber p10k/omz).
|
|
@@ -68,6 +70,14 @@ export class Shell {
|
|
|
68
70
|
" }",
|
|
69
71
|
"fi",
|
|
70
72
|
"zle -N zle-line-init __agent_sh_line_init",
|
|
73
|
+
"",
|
|
74
|
+
"# Hidden widget to trigger prompt redraw from Node.js side",
|
|
75
|
+
"# Bound to an unused escape sequence that no real key produces",
|
|
76
|
+
"__agent_sh_redraw() {",
|
|
77
|
+
" zle reset-prompt",
|
|
78
|
+
"}",
|
|
79
|
+
"zle -N __agent_sh_redraw",
|
|
80
|
+
"bindkey '\\e[9999~' __agent_sh_redraw",
|
|
71
81
|
].join("\n") + "\n");
|
|
72
82
|
env.ZDOTDIR = this.tmpDir;
|
|
73
83
|
shellArgs = ["--no-globalrcs"];
|
|
@@ -131,10 +141,11 @@ export class Shell {
|
|
|
131
141
|
this.ptyProcess.write(data);
|
|
132
142
|
}
|
|
133
143
|
/**
|
|
134
|
-
* Lightweight redraw:
|
|
135
|
-
* (
|
|
136
|
-
*
|
|
137
|
-
*
|
|
144
|
+
* Lightweight redraw: ask the shell to redraw its own prompt via a hidden
|
|
145
|
+
* ZLE widget (zsh) bound to \e[9999~. The shell knows how to draw its
|
|
146
|
+
* prompt correctly — we don't try to replay captured bytes.
|
|
147
|
+
*
|
|
148
|
+
* For bash, falls back to sending \n for a fresh prompt cycle.
|
|
138
149
|
*/
|
|
139
150
|
redrawPrompt() {
|
|
140
151
|
const result = this.bus.emitPipe("shell:redraw-prompt", {
|
|
@@ -142,12 +153,12 @@ export class Shell {
|
|
|
142
153
|
handled: false,
|
|
143
154
|
});
|
|
144
155
|
if (!result.handled) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
156
|
+
if (this.isZsh) {
|
|
157
|
+
// Trigger the hidden ZLE widget — zle reset-prompt redraws cleanly
|
|
158
|
+
this.ptyProcess.write("\x1b[9999~");
|
|
148
159
|
}
|
|
149
160
|
else {
|
|
150
|
-
//
|
|
161
|
+
// Bash: no zle reset-prompt equivalent, use fresh prompt cycle
|
|
151
162
|
this.ptyProcess.write("\n");
|
|
152
163
|
}
|
|
153
164
|
}
|
|
@@ -202,8 +213,35 @@ export class Shell {
|
|
|
202
213
|
this.paused = true;
|
|
203
214
|
return payload;
|
|
204
215
|
});
|
|
216
|
+
// Shell exec: write a command to the live PTY and capture its output.
|
|
217
|
+
// stdout is paused during agent processing, so PTY output flows through
|
|
218
|
+
// OutputParser (for OSC detection) but never reaches the terminal.
|
|
219
|
+
this.bus.onPipeAsync("shell:exec-request", async (payload) => {
|
|
220
|
+
const output = await new Promise((resolve, reject) => {
|
|
221
|
+
const timeout = setTimeout(() => {
|
|
222
|
+
this.bus.off("shell:command-done", handler);
|
|
223
|
+
// Kill any hung command
|
|
224
|
+
this.ptyProcess.write("\x03");
|
|
225
|
+
reject(new Error("Shell exec timed out after 30s"));
|
|
226
|
+
}, 30_000);
|
|
227
|
+
const handler = (e) => {
|
|
228
|
+
clearTimeout(timeout);
|
|
229
|
+
this.bus.off("shell:command-done", handler);
|
|
230
|
+
resolve({ output: e.output, cwd: e.cwd });
|
|
231
|
+
};
|
|
232
|
+
this.bus.on("shell:command-done", handler);
|
|
233
|
+
// Start capture and write to PTY
|
|
234
|
+
this.outputParser.onCommandEntered(payload.command, this.outputParser.getCwd());
|
|
235
|
+
this.ptyProcess.write(payload.command + "\r");
|
|
236
|
+
});
|
|
237
|
+
return { ...payload, output: output.output, cwd: output.cwd, done: true };
|
|
238
|
+
});
|
|
205
239
|
}
|
|
206
240
|
// ── Public API (used by index.ts) ──
|
|
241
|
+
/** Temp directory used for shell config and sockets. */
|
|
242
|
+
getTmpDir() {
|
|
243
|
+
return this.tmpDir;
|
|
244
|
+
}
|
|
207
245
|
resize(cols, rows) {
|
|
208
246
|
this.ptyProcess.resize(cols, rows);
|
|
209
247
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface AgentShellConfig {
|
|
|
8
8
|
shell?: string;
|
|
9
9
|
model?: string;
|
|
10
10
|
extensions?: string[];
|
|
11
|
+
/** Full shell environment (from user's rc files) for agent subprocess. */
|
|
12
|
+
shellEnv?: Record<string, string>;
|
|
11
13
|
}
|
|
12
14
|
/**
|
|
13
15
|
* Context passed to user/third-party extensions.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal line editor with readline-style keybindings.
|
|
3
|
+
*
|
|
4
|
+
* Pure logic — no I/O, no rendering, no event bus. Consumers feed raw
|
|
5
|
+
* terminal input bytes and receive high-level actions back. Buffer and
|
|
6
|
+
* cursor state are public for rendering.
|
|
7
|
+
*/
|
|
8
|
+
export type LineEditAction = {
|
|
9
|
+
action: "changed";
|
|
10
|
+
} | {
|
|
11
|
+
action: "submit";
|
|
12
|
+
buffer: string;
|
|
13
|
+
} | {
|
|
14
|
+
action: "cancel";
|
|
15
|
+
} | {
|
|
16
|
+
action: "delete-empty";
|
|
17
|
+
} | {
|
|
18
|
+
action: "tab";
|
|
19
|
+
} | {
|
|
20
|
+
action: "arrow-up";
|
|
21
|
+
} | {
|
|
22
|
+
action: "arrow-down";
|
|
23
|
+
};
|
|
24
|
+
export declare class LineEditor {
|
|
25
|
+
buffer: string;
|
|
26
|
+
cursor: number;
|
|
27
|
+
/** Process raw terminal input, return actions for the consumer. */
|
|
28
|
+
feed(data: string): LineEditAction[];
|
|
29
|
+
clear(): void;
|
|
30
|
+
/**
|
|
31
|
+
* Parse and handle a CSI sequence (\x1b[...) starting at `start`.
|
|
32
|
+
* Returns the number of bytes consumed.
|
|
33
|
+
*/
|
|
34
|
+
private handleCSI;
|
|
35
|
+
private wordBackward;
|
|
36
|
+
private wordForward;
|
|
37
|
+
private deleteWordBackward;
|
|
38
|
+
private deleteWordForward;
|
|
39
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal line editor with readline-style keybindings.
|
|
3
|
+
*
|
|
4
|
+
* Pure logic — no I/O, no rendering, no event bus. Consumers feed raw
|
|
5
|
+
* terminal input bytes and receive high-level actions back. Buffer and
|
|
6
|
+
* cursor state are public for rendering.
|
|
7
|
+
*/
|
|
8
|
+
// ── Line editor ─────────────────────────────────────────────────
|
|
9
|
+
export class LineEditor {
|
|
10
|
+
buffer = "";
|
|
11
|
+
cursor = 0;
|
|
12
|
+
/** Process raw terminal input, return actions for the consumer. */
|
|
13
|
+
feed(data) {
|
|
14
|
+
const actions = [];
|
|
15
|
+
let i = 0;
|
|
16
|
+
while (i < data.length) {
|
|
17
|
+
const ch = data[i];
|
|
18
|
+
// ── Escape sequences ────────────────────────────────
|
|
19
|
+
if (ch === "\x1b") {
|
|
20
|
+
const next = data[i + 1];
|
|
21
|
+
// Bare Escape (nothing follows in this chunk)
|
|
22
|
+
if (next == null) {
|
|
23
|
+
actions.push({ action: "cancel" });
|
|
24
|
+
i++;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
// CSI sequence: \x1b[...
|
|
28
|
+
if (next === "[") {
|
|
29
|
+
const { consumed } = this.handleCSI(data, i, actions);
|
|
30
|
+
i += consumed;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
// Alt/Option + key: \x1b followed by char
|
|
34
|
+
i += 2; // consume \x1b + next byte
|
|
35
|
+
if (next === "\x7f") {
|
|
36
|
+
// Option+Backspace: delete word backward
|
|
37
|
+
if (this.deleteWordBackward())
|
|
38
|
+
actions.push({ action: "changed" });
|
|
39
|
+
}
|
|
40
|
+
else if (next === "b") {
|
|
41
|
+
// Alt+B: word backward
|
|
42
|
+
if (this.wordBackward())
|
|
43
|
+
actions.push({ action: "changed" });
|
|
44
|
+
}
|
|
45
|
+
else if (next === "f") {
|
|
46
|
+
// Alt+F: word forward
|
|
47
|
+
if (this.wordForward())
|
|
48
|
+
actions.push({ action: "changed" });
|
|
49
|
+
}
|
|
50
|
+
else if (next === "d") {
|
|
51
|
+
// Alt+D: delete word forward
|
|
52
|
+
if (this.deleteWordForward())
|
|
53
|
+
actions.push({ action: "changed" });
|
|
54
|
+
}
|
|
55
|
+
// Other Alt+key — ignore
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
// ── Control characters ──────────────────────────────
|
|
59
|
+
if (ch === "\r") {
|
|
60
|
+
actions.push({ action: "submit", buffer: this.buffer });
|
|
61
|
+
i++;
|
|
62
|
+
continue;
|
|
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) {
|
|
151
|
+
i++;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
// ── Printable character ─────────────────────────────
|
|
155
|
+
this.buffer = this.buffer.slice(0, this.cursor) + ch + this.buffer.slice(this.cursor);
|
|
156
|
+
this.cursor++;
|
|
157
|
+
actions.push({ action: "changed" });
|
|
158
|
+
i++;
|
|
159
|
+
}
|
|
160
|
+
return actions;
|
|
161
|
+
}
|
|
162
|
+
clear() {
|
|
163
|
+
this.buffer = "";
|
|
164
|
+
this.cursor = 0;
|
|
165
|
+
}
|
|
166
|
+
// ── CSI sequence handling ───────────────────────────────────
|
|
167
|
+
/**
|
|
168
|
+
* Parse and handle a CSI sequence (\x1b[...) starting at `start`.
|
|
169
|
+
* Returns the number of bytes consumed.
|
|
170
|
+
*/
|
|
171
|
+
handleCSI(data, start, actions) {
|
|
172
|
+
// Skip \x1b[
|
|
173
|
+
let j = start + 2;
|
|
174
|
+
// Accumulate parameter bytes (0x20-0x3F: digits, semicolons, etc.)
|
|
175
|
+
let params = "";
|
|
176
|
+
while (j < data.length && data.charCodeAt(j) >= 0x20 && data.charCodeAt(j) < 0x40) {
|
|
177
|
+
params += data[j];
|
|
178
|
+
j++;
|
|
179
|
+
}
|
|
180
|
+
const final = j < data.length ? data[j] : "";
|
|
181
|
+
const consumed = j - start + (final ? 1 : 0);
|
|
182
|
+
// Dispatch on final byte
|
|
183
|
+
switch (final) {
|
|
184
|
+
case "A": // Up arrow
|
|
185
|
+
actions.push({ action: "arrow-up" });
|
|
186
|
+
break;
|
|
187
|
+
case "B": // Down arrow
|
|
188
|
+
actions.push({ action: "arrow-down" });
|
|
189
|
+
break;
|
|
190
|
+
case "C": // Right (or modified right: 1;3C, 1;5C = word right)
|
|
191
|
+
if (params.includes(";")) {
|
|
192
|
+
if (this.wordForward())
|
|
193
|
+
actions.push({ action: "changed" });
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
if (this.cursor < this.buffer.length) {
|
|
197
|
+
this.cursor++;
|
|
198
|
+
actions.push({ action: "changed" });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
break;
|
|
202
|
+
case "D": // Left (or modified left: 1;3D, 1;5D = word left)
|
|
203
|
+
if (params.includes(";")) {
|
|
204
|
+
if (this.wordBackward())
|
|
205
|
+
actions.push({ action: "changed" });
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
if (this.cursor > 0) {
|
|
209
|
+
this.cursor--;
|
|
210
|
+
actions.push({ action: "changed" });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
break;
|
|
214
|
+
case "H": // Home
|
|
215
|
+
if (this.cursor > 0) {
|
|
216
|
+
this.cursor = 0;
|
|
217
|
+
actions.push({ action: "changed" });
|
|
218
|
+
}
|
|
219
|
+
break;
|
|
220
|
+
case "F": // End
|
|
221
|
+
if (this.cursor < this.buffer.length) {
|
|
222
|
+
this.cursor = this.buffer.length;
|
|
223
|
+
actions.push({ action: "changed" });
|
|
224
|
+
}
|
|
225
|
+
break;
|
|
226
|
+
case "~": // Extended keys: Delete (3~), etc.
|
|
227
|
+
if (params === "3") {
|
|
228
|
+
// Delete key: delete char under cursor
|
|
229
|
+
if (this.cursor < this.buffer.length) {
|
|
230
|
+
this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(this.cursor + 1);
|
|
231
|
+
actions.push({ action: "changed" });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
break;
|
|
235
|
+
// All other CSI sequences — silently ignored
|
|
236
|
+
}
|
|
237
|
+
return { consumed };
|
|
238
|
+
}
|
|
239
|
+
// ── Word movement / deletion helpers ────────────────────────
|
|
240
|
+
wordBackward() {
|
|
241
|
+
if (this.cursor === 0)
|
|
242
|
+
return false;
|
|
243
|
+
let pos = this.cursor;
|
|
244
|
+
// Skip spaces
|
|
245
|
+
while (pos > 0 && this.buffer[pos - 1] === " ")
|
|
246
|
+
pos--;
|
|
247
|
+
// Skip word chars
|
|
248
|
+
while (pos > 0 && this.buffer[pos - 1] !== " ")
|
|
249
|
+
pos--;
|
|
250
|
+
if (pos === this.cursor)
|
|
251
|
+
return false;
|
|
252
|
+
this.cursor = pos;
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
wordForward() {
|
|
256
|
+
if (this.cursor >= this.buffer.length)
|
|
257
|
+
return false;
|
|
258
|
+
let pos = this.cursor;
|
|
259
|
+
// Skip word chars
|
|
260
|
+
while (pos < this.buffer.length && this.buffer[pos] !== " ")
|
|
261
|
+
pos++;
|
|
262
|
+
// Skip spaces
|
|
263
|
+
while (pos < this.buffer.length && this.buffer[pos] === " ")
|
|
264
|
+
pos++;
|
|
265
|
+
if (pos === this.cursor)
|
|
266
|
+
return false;
|
|
267
|
+
this.cursor = pos;
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
deleteWordBackward() {
|
|
271
|
+
if (this.cursor === 0)
|
|
272
|
+
return false;
|
|
273
|
+
const start = this.cursor;
|
|
274
|
+
this.wordBackward();
|
|
275
|
+
this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(start);
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
deleteWordForward() {
|
|
279
|
+
if (this.cursor >= this.buffer.length)
|
|
280
|
+
return false;
|
|
281
|
+
const start = this.cursor;
|
|
282
|
+
this.wordForward();
|
|
283
|
+
this.buffer = this.buffer.slice(0, start) + this.buffer.slice(this.cursor);
|
|
284
|
+
this.cursor = start;
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -4,6 +4,15 @@ export interface ToolCallRender {
|
|
|
4
4
|
title: string;
|
|
5
5
|
/** Optional command string for bash-like tools. */
|
|
6
6
|
command?: string;
|
|
7
|
+
/** Tool kind from ACP (read, edit, execute, search, etc.). */
|
|
8
|
+
kind?: string;
|
|
9
|
+
/** File locations affected by the tool call. */
|
|
10
|
+
locations?: {
|
|
11
|
+
path: string;
|
|
12
|
+
line?: number | null;
|
|
13
|
+
}[];
|
|
14
|
+
/** Raw input parameters sent to the tool. */
|
|
15
|
+
rawInput?: unknown;
|
|
7
16
|
}
|
|
8
17
|
export interface ToolResultRender {
|
|
9
18
|
exitCode: number | null;
|