agent-sh 0.2.0 → 0.3.1
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 +155 -33
- package/dist/context-manager.d.ts +5 -3
- package/dist/context-manager.js +62 -31
- package/dist/core.js +10 -0
- package/dist/event-bus.d.ts +26 -0
- package/dist/event-bus.js +10 -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 +369 -126
- 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 +44 -0
- package/dist/settings.js +61 -0
- package/dist/shell.d.ts +1 -0
- package/dist/shell.js +44 -4
- package/dist/types.d.ts +17 -0
- package/dist/utils/ansi.d.ts +4 -1
- package/dist/utils/ansi.js +60 -2
- package/dist/utils/box-frame.js +2 -1
- package/dist/utils/diff-renderer.js +1 -1
- package/dist/utils/frame-renderer.d.ts +26 -0
- package/dist/utils/frame-renderer.js +76 -0
- package/dist/utils/handler-registry.d.ts +41 -0
- package/dist/utils/handler-registry.js +52 -0
- package/dist/utils/line-editor.d.ts +21 -1
- package/dist/utils/line-editor.js +193 -99
- package/dist/utils/markdown.d.ts +15 -6
- package/dist/utils/markdown.js +106 -67
- package/dist/utils/output-writer.d.ts +22 -0
- package/dist/utils/output-writer.js +29 -0
- package/dist/utils/stream-transform.d.ts +70 -0
- package/dist/utils/stream-transform.js +229 -0
- package/dist/utils/tool-display.d.ts +11 -8
- package/dist/utils/tool-display.js +69 -46
- package/examples/extensions/latex-images.ts +142 -0
- package/examples/pi-agent-sh.ts +166 -0
- package/package.json +10 -2
|
@@ -16,6 +16,8 @@ export type LineEditAction = {
|
|
|
16
16
|
action: "delete-empty";
|
|
17
17
|
} | {
|
|
18
18
|
action: "tab";
|
|
19
|
+
} | {
|
|
20
|
+
action: "shift+tab";
|
|
19
21
|
} | {
|
|
20
22
|
action: "arrow-up";
|
|
21
23
|
} | {
|
|
@@ -24,12 +26,30 @@ export type LineEditAction = {
|
|
|
24
26
|
export declare class LineEditor {
|
|
25
27
|
buffer: string;
|
|
26
28
|
cursor: number;
|
|
29
|
+
private pendingSeq;
|
|
27
30
|
/** Process raw terminal input, return actions for the consumer. */
|
|
28
31
|
feed(data: string): LineEditAction[];
|
|
32
|
+
/** Check if there's a pending incomplete escape sequence. */
|
|
33
|
+
hasPendingEscape(): boolean;
|
|
34
|
+
/** Flush a pending sequence — treat bare \x1b as cancel, discard incomplete CSI. */
|
|
35
|
+
flushPendingEscape(): LineEditAction[];
|
|
29
36
|
clear(): void;
|
|
37
|
+
private readonly bindings;
|
|
38
|
+
/** Resolve a key name from the bindings table and execute it. */
|
|
39
|
+
private dispatch;
|
|
40
|
+
/** Map a legacy control character to a key name. */
|
|
41
|
+
private static readonly CTRL_MAP;
|
|
42
|
+
private handleControl;
|
|
43
|
+
/** Handle a kitty protocol CSI u sequence. Params format: "keycode;modifier". */
|
|
44
|
+
private handleKittyKey;
|
|
45
|
+
private insertAt;
|
|
46
|
+
private moveTo;
|
|
47
|
+
private deleteBackward;
|
|
48
|
+
private deleteForward;
|
|
49
|
+
private deleteRange;
|
|
30
50
|
/**
|
|
31
51
|
* Parse and handle a CSI sequence (\x1b[...) starting at `start`.
|
|
32
|
-
* Returns the number of bytes consumed.
|
|
52
|
+
* Returns the number of bytes consumed and whether the sequence was incomplete.
|
|
33
53
|
*/
|
|
34
54
|
private handleCSI;
|
|
35
55
|
private wordBackward;
|
|
@@ -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.d.ts
CHANGED
|
@@ -7,15 +7,18 @@ export declare function wrapLine(text: string, maxWidth: number): string[];
|
|
|
7
7
|
* Streaming markdown renderer that processes chunks of text,
|
|
8
8
|
* renders complete lines with ANSI formatting, and wraps output
|
|
9
9
|
* in a bordered box.
|
|
10
|
+
*
|
|
11
|
+
* The renderer accumulates lines internally. Call `drainLines()` to
|
|
12
|
+
* extract them — this is the only way output leaves the renderer.
|
|
10
13
|
*/
|
|
11
14
|
export declare class MarkdownRenderer {
|
|
12
15
|
private buffer;
|
|
13
|
-
private inCodeBlock;
|
|
14
|
-
private codeLanguage;
|
|
15
|
-
private codeLines;
|
|
16
16
|
private contentWidth;
|
|
17
17
|
private firstLine;
|
|
18
|
-
|
|
18
|
+
private pendingLines;
|
|
19
|
+
private width;
|
|
20
|
+
private tableRows;
|
|
21
|
+
constructor(width: number);
|
|
19
22
|
/**
|
|
20
23
|
* Push a streaming chunk. Complete lines are rendered immediately;
|
|
21
24
|
* incomplete trailing text stays in the buffer.
|
|
@@ -27,13 +30,19 @@ export declare class MarkdownRenderer {
|
|
|
27
30
|
flush(): void;
|
|
28
31
|
printTopBorder(): void;
|
|
29
32
|
printBottomBorder(): void;
|
|
33
|
+
/**
|
|
34
|
+
* Extract and clear all accumulated lines.
|
|
35
|
+
* This is the only way output leaves the renderer.
|
|
36
|
+
*/
|
|
37
|
+
drainLines(): string[];
|
|
30
38
|
private processBuffer;
|
|
31
39
|
private processLine;
|
|
40
|
+
private flushTable;
|
|
32
41
|
private renderLine;
|
|
33
42
|
private renderInline;
|
|
34
|
-
private renderCodeBlock;
|
|
35
43
|
/**
|
|
36
|
-
*
|
|
44
|
+
* Add a single line with a subtle left indent.
|
|
45
|
+
* The line is accumulated internally — call drainLines() to extract.
|
|
37
46
|
*/
|
|
38
47
|
writeLine(text: string): void;
|
|
39
48
|
}
|
package/dist/utils/markdown.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { highlight } from "cli-highlight";
|
|
2
1
|
import { visibleLen } from "./ansi.js";
|
|
3
2
|
import { palette as p } from "./palette.js";
|
|
4
3
|
const MAX_CONTENT_WIDTH = 90;
|
|
@@ -7,6 +6,8 @@ const MAX_CONTENT_WIDTH = 90;
|
|
|
7
6
|
* Returns an array of lines, each fitting within `maxWidth` visible characters.
|
|
8
7
|
*/
|
|
9
8
|
export function wrapLine(text, maxWidth) {
|
|
9
|
+
if (!(maxWidth > 0))
|
|
10
|
+
return [text]; // catches NaN, <=0, undefined
|
|
10
11
|
if (visibleLen(text) <= maxWidth)
|
|
11
12
|
return [text];
|
|
12
13
|
const result = [];
|
|
@@ -74,18 +75,20 @@ export function wrapLine(text, maxWidth) {
|
|
|
74
75
|
* Streaming markdown renderer that processes chunks of text,
|
|
75
76
|
* renders complete lines with ANSI formatting, and wraps output
|
|
76
77
|
* in a bordered box.
|
|
78
|
+
*
|
|
79
|
+
* The renderer accumulates lines internally. Call `drainLines()` to
|
|
80
|
+
* extract them — this is the only way output leaves the renderer.
|
|
77
81
|
*/
|
|
78
82
|
export class MarkdownRenderer {
|
|
79
83
|
buffer = "";
|
|
80
|
-
inCodeBlock = false;
|
|
81
|
-
codeLanguage = "";
|
|
82
|
-
codeLines = [];
|
|
83
84
|
contentWidth;
|
|
84
85
|
firstLine = true;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
pendingLines = [];
|
|
87
|
+
width;
|
|
88
|
+
tableRows = [];
|
|
89
|
+
constructor(width) {
|
|
90
|
+
this.width = Math.max(10, width);
|
|
91
|
+
this.contentWidth = Math.min(MAX_CONTENT_WIDTH, this.width - 2);
|
|
89
92
|
}
|
|
90
93
|
/**
|
|
91
94
|
* Push a streaming chunk. Complete lines are rendered immediately;
|
|
@@ -99,22 +102,27 @@ export class MarkdownRenderer {
|
|
|
99
102
|
* Flush any remaining text in the buffer (called when the response ends).
|
|
100
103
|
*/
|
|
101
104
|
flush() {
|
|
102
|
-
if (this.inCodeBlock) {
|
|
103
|
-
this.renderCodeBlock();
|
|
104
|
-
}
|
|
105
105
|
if (this.buffer.length > 0) {
|
|
106
106
|
this.processLine(this.buffer);
|
|
107
107
|
this.buffer = "";
|
|
108
108
|
}
|
|
109
|
+
this.flushTable();
|
|
109
110
|
}
|
|
110
111
|
printTopBorder() {
|
|
111
|
-
|
|
112
|
-
process.stdout.write(`${p.dim}${p.accent}${"─".repeat(w)}${p.reset}\n`);
|
|
112
|
+
this.pendingLines.push(`${p.dim}${p.accent}${"─".repeat(this.width)}${p.reset}`);
|
|
113
113
|
this.firstLine = true;
|
|
114
114
|
}
|
|
115
115
|
printBottomBorder() {
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
this.pendingLines.push(`${p.dim}${p.accent}${"─".repeat(this.width)}${p.reset}`);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Extract and clear all accumulated lines.
|
|
120
|
+
* This is the only way output leaves the renderer.
|
|
121
|
+
*/
|
|
122
|
+
drainLines() {
|
|
123
|
+
const lines = this.pendingLines;
|
|
124
|
+
this.pendingLines = [];
|
|
125
|
+
return lines;
|
|
118
126
|
}
|
|
119
127
|
processBuffer() {
|
|
120
128
|
const lines = this.buffer.split("\n");
|
|
@@ -124,32 +132,82 @@ export class MarkdownRenderer {
|
|
|
124
132
|
}
|
|
125
133
|
}
|
|
126
134
|
processLine(line) {
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
this.
|
|
132
|
-
this.codeLanguage = fenceMatch[2] || "";
|
|
133
|
-
this.codeLines = [];
|
|
135
|
+
// Table row detection: lines with | separators
|
|
136
|
+
if (/^\s*\|/.test(line)) {
|
|
137
|
+
const cells = parseTableRow(line);
|
|
138
|
+
if (cells) {
|
|
139
|
+
this.tableRows.push(cells);
|
|
134
140
|
return;
|
|
135
141
|
}
|
|
136
|
-
else {
|
|
137
|
-
this.inCodeBlock = false;
|
|
138
|
-
this.renderCodeBlock();
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
if (this.inCodeBlock) {
|
|
143
|
-
this.codeLines.push(line);
|
|
144
|
-
return;
|
|
145
142
|
}
|
|
143
|
+
// Non-table line — flush any buffered table first
|
|
144
|
+
this.flushTable();
|
|
146
145
|
const rendered = this.renderLine(line);
|
|
147
|
-
// Word-wrap and output each wrapped line
|
|
148
146
|
const wrapped = wrapLine(rendered, this.contentWidth);
|
|
149
147
|
for (const wl of wrapped) {
|
|
150
148
|
this.writeLine(wl);
|
|
151
149
|
}
|
|
152
150
|
}
|
|
151
|
+
flushTable() {
|
|
152
|
+
if (this.tableRows.length === 0)
|
|
153
|
+
return;
|
|
154
|
+
const rows = this.tableRows;
|
|
155
|
+
this.tableRows = [];
|
|
156
|
+
// Filter out separator rows (|---|---|)
|
|
157
|
+
const sepIdx = [];
|
|
158
|
+
const dataRows = [];
|
|
159
|
+
for (let i = 0; i < rows.length; i++) {
|
|
160
|
+
if (rows[i].every((c) => /^[-:]+$/.test(c.trim()) || c.trim() === "")) {
|
|
161
|
+
sepIdx.push(i);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
dataRows.push(rows[i]);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (dataRows.length === 0)
|
|
168
|
+
return;
|
|
169
|
+
// Normalize column count
|
|
170
|
+
const numCols = Math.max(...dataRows.map((r) => r.length));
|
|
171
|
+
for (const row of dataRows) {
|
|
172
|
+
while (row.length < numCols)
|
|
173
|
+
row.push("");
|
|
174
|
+
}
|
|
175
|
+
// Calculate column widths from content
|
|
176
|
+
const colWidths = new Array(numCols).fill(0);
|
|
177
|
+
for (const row of dataRows) {
|
|
178
|
+
for (let c = 0; c < numCols; c++) {
|
|
179
|
+
colWidths[c] = Math.max(colWidths[c], row[c].length);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Shrink columns proportionally if total exceeds content width
|
|
183
|
+
// Account for separators: " │ " between cols (3 chars each) + 2 outer padding
|
|
184
|
+
const separatorWidth = (numCols - 1) * 3;
|
|
185
|
+
const availableWidth = this.contentWidth - separatorWidth;
|
|
186
|
+
const totalWidth = colWidths.reduce((a, b) => a + b, 0);
|
|
187
|
+
if (totalWidth > availableWidth && availableWidth > numCols) {
|
|
188
|
+
const scale = availableWidth / totalWidth;
|
|
189
|
+
for (let c = 0; c < numCols; c++) {
|
|
190
|
+
colWidths[c] = Math.max(1, Math.floor(colWidths[c] * scale));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Render rows
|
|
194
|
+
const hasHeader = sepIdx.includes(1) && dataRows.length > 1;
|
|
195
|
+
for (let i = 0; i < dataRows.length; i++) {
|
|
196
|
+
const row = dataRows[i];
|
|
197
|
+
const isHeader = hasHeader && i === 0;
|
|
198
|
+
const cells = row.map((cell, c) => {
|
|
199
|
+
const w = colWidths[c];
|
|
200
|
+
const text = cell.length > w ? cell.slice(0, w - 1) + "…" : cell.padEnd(w);
|
|
201
|
+
return isHeader ? `${p.bold}${text}${p.reset}` : text;
|
|
202
|
+
});
|
|
203
|
+
this.writeLine(`${p.dim}│${p.reset} ${cells.join(` ${p.dim}│${p.reset} `)} ${p.dim}│${p.reset}`);
|
|
204
|
+
// Separator after header
|
|
205
|
+
if (isHeader) {
|
|
206
|
+
const sep = colWidths.map((w) => "─".repeat(w)).join(`─┼─`);
|
|
207
|
+
this.writeLine(`${p.dim}├─${sep}─┤${p.reset}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
153
211
|
renderLine(line) {
|
|
154
212
|
if (line.trim() === "")
|
|
155
213
|
return "";
|
|
@@ -195,54 +253,35 @@ export class MarkdownRenderer {
|
|
|
195
253
|
text = text.replace(/\*\*\*(.+?)\*\*\*/g, `${p.bold}${p.italic}$1${p.reset}`);
|
|
196
254
|
// Bold
|
|
197
255
|
text = text.replace(/\*\*(.+?)\*\*/g, `${p.bold}$1${p.reset}`);
|
|
198
|
-
text = text.replace(/__(.+?)__/g, `${p.bold}$1${p.reset}`);
|
|
256
|
+
text = text.replace(/(?<!\w)__(.+?)__(?!\w)/g, `${p.bold}$1${p.reset}`);
|
|
199
257
|
// Italic
|
|
200
258
|
text = text.replace(/\*(.+?)\*/g, `${p.italic}$1${p.reset}`);
|
|
201
|
-
text = text.replace(/_(.+?)_/g, `${p.italic}$1${p.reset}`);
|
|
259
|
+
text = text.replace(/(?<!\w)_(.+?)_(?!\w)/g, `${p.italic}$1${p.reset}`);
|
|
202
260
|
// Strikethrough
|
|
203
261
|
text = text.replace(/~~(.+?)~~/g, `${p.dim}$1${p.reset}`);
|
|
204
262
|
// Links
|
|
205
263
|
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, `$1 ${p.muted}${p.underline}($2)${p.reset}`);
|
|
206
264
|
return text;
|
|
207
265
|
}
|
|
208
|
-
renderCodeBlock() {
|
|
209
|
-
const code = this.codeLines.join("\n");
|
|
210
|
-
const lang = this.codeLanguage;
|
|
211
|
-
if (lang) {
|
|
212
|
-
this.writeLine(`${p.dim}${lang}${p.reset}`);
|
|
213
|
-
}
|
|
214
|
-
let highlighted;
|
|
215
|
-
try {
|
|
216
|
-
highlighted = highlight(code, { language: lang || undefined });
|
|
217
|
-
}
|
|
218
|
-
catch {
|
|
219
|
-
highlighted = `${p.success}${code}${p.reset}`;
|
|
220
|
-
}
|
|
221
|
-
// Code blocks get indented, and each line is individually wrapped
|
|
222
|
-
for (const line of highlighted.split("\n")) {
|
|
223
|
-
const indented = ` ${line}`;
|
|
224
|
-
const wrapped = wrapLine(indented, this.contentWidth);
|
|
225
|
-
for (const wl of wrapped) {
|
|
226
|
-
this.writeLine(wl);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
this.codeLanguage = "";
|
|
230
|
-
this.codeLines = [];
|
|
231
|
-
}
|
|
232
266
|
/**
|
|
233
|
-
*
|
|
267
|
+
* Add a single line with a subtle left indent.
|
|
268
|
+
* The line is accumulated internally — call drainLines() to extract.
|
|
234
269
|
*/
|
|
235
270
|
writeLine(text) {
|
|
236
271
|
if (this.firstLine && visibleLen(text) === 0)
|
|
237
272
|
return;
|
|
238
273
|
this.firstLine = false;
|
|
239
|
-
|
|
240
|
-
if (process.stdout.writable) {
|
|
241
|
-
try {
|
|
242
|
-
process.stdout.write('');
|
|
243
|
-
}
|
|
244
|
-
catch (e) {
|
|
245
|
-
}
|
|
246
|
-
}
|
|
274
|
+
this.pendingLines.push(` ${text}`);
|
|
247
275
|
}
|
|
248
276
|
}
|
|
277
|
+
/** Parse a markdown table row into trimmed cell strings, or null if not a table row. */
|
|
278
|
+
function parseTableRow(line) {
|
|
279
|
+
const trimmed = line.trim();
|
|
280
|
+
if (!trimmed.startsWith("|") || !trimmed.endsWith("|"))
|
|
281
|
+
return null;
|
|
282
|
+
// Split on |, drop first and last empty entries
|
|
283
|
+
const parts = trimmed.split("|");
|
|
284
|
+
if (parts.length < 3)
|
|
285
|
+
return null; // need at least |cell|
|
|
286
|
+
return parts.slice(1, -1).map((c) => c.trim());
|
|
287
|
+
}
|