agent-sh 0.3.0 → 0.4.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 +28 -11
- package/dist/acp-client.d.ts +6 -1
- package/dist/acp-client.js +68 -24
- package/dist/core.js +12 -2
- package/dist/event-bus.d.ts +26 -0
- package/dist/event-bus.js +10 -0
- package/dist/extensions/tui-renderer.d.ts +1 -1
- package/dist/extensions/tui-renderer.js +325 -165
- package/dist/index.js +44 -16
- package/dist/input-handler.d.ts +17 -8
- package/dist/input-handler.js +79 -39
- package/dist/settings.d.ts +11 -0
- package/dist/settings.js +19 -1
- package/dist/shell.js +3 -1
- package/dist/types.d.ts +28 -0
- 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.js +4 -0
- 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 +9 -8
- package/dist/utils/tool-display.js +26 -31
- package/examples/extensions/latex-images.ts +142 -0
- package/package.json +10 -2
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named handler registry with Emacs-style advice.
|
|
3
|
+
*
|
|
4
|
+
* Built-in extensions register named handlers with `define`.
|
|
5
|
+
* User extensions wrap them with `advise` — each advisor receives
|
|
6
|
+
* `next` (the previous handler) and decides whether to call it.
|
|
7
|
+
*
|
|
8
|
+
* registry.define("render:code-block", (lang, code) => highlight(lang, code));
|
|
9
|
+
*
|
|
10
|
+
* registry.advise("render:code-block", (next, lang, code) => {
|
|
11
|
+
* if (lang === "latex") return renderLatex(code);
|
|
12
|
+
* return next(lang, code); // call original
|
|
13
|
+
* });
|
|
14
|
+
*/
|
|
15
|
+
export declare class HandlerRegistry {
|
|
16
|
+
private handlers;
|
|
17
|
+
/**
|
|
18
|
+
* Register a named handler. If one already exists, it's replaced.
|
|
19
|
+
*/
|
|
20
|
+
define(name: string, fn: (...args: any[]) => any): void;
|
|
21
|
+
/**
|
|
22
|
+
* Wrap a named handler with advice. The wrapper receives the
|
|
23
|
+
* previous handler as `next` and all original arguments.
|
|
24
|
+
*
|
|
25
|
+
* - Call `next(...args)` to invoke the original (around/before/after)
|
|
26
|
+
* - Don't call `next` to replace entirely (override)
|
|
27
|
+
* - Call `next` conditionally to wrap (around)
|
|
28
|
+
*
|
|
29
|
+
* Multiple advisors chain: each wraps the previous one.
|
|
30
|
+
* If no handler exists yet, `next` is a no-op.
|
|
31
|
+
*/
|
|
32
|
+
advise(name: string, wrapper: (next: (...args: any[]) => any, ...args: any[]) => any): void;
|
|
33
|
+
/**
|
|
34
|
+
* Call a named handler. Returns undefined if no handler is registered.
|
|
35
|
+
*/
|
|
36
|
+
call(name: string, ...args: any[]): any;
|
|
37
|
+
/**
|
|
38
|
+
* Check if a named handler exists.
|
|
39
|
+
*/
|
|
40
|
+
has(name: string): boolean;
|
|
41
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named handler registry with Emacs-style advice.
|
|
3
|
+
*
|
|
4
|
+
* Built-in extensions register named handlers with `define`.
|
|
5
|
+
* User extensions wrap them with `advise` — each advisor receives
|
|
6
|
+
* `next` (the previous handler) and decides whether to call it.
|
|
7
|
+
*
|
|
8
|
+
* registry.define("render:code-block", (lang, code) => highlight(lang, code));
|
|
9
|
+
*
|
|
10
|
+
* registry.advise("render:code-block", (next, lang, code) => {
|
|
11
|
+
* if (lang === "latex") return renderLatex(code);
|
|
12
|
+
* return next(lang, code); // call original
|
|
13
|
+
* });
|
|
14
|
+
*/
|
|
15
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
16
|
+
export class HandlerRegistry {
|
|
17
|
+
handlers = new Map();
|
|
18
|
+
/**
|
|
19
|
+
* Register a named handler. If one already exists, it's replaced.
|
|
20
|
+
*/
|
|
21
|
+
define(name, fn) {
|
|
22
|
+
this.handlers.set(name, fn);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Wrap a named handler with advice. The wrapper receives the
|
|
26
|
+
* previous handler as `next` and all original arguments.
|
|
27
|
+
*
|
|
28
|
+
* - Call `next(...args)` to invoke the original (around/before/after)
|
|
29
|
+
* - Don't call `next` to replace entirely (override)
|
|
30
|
+
* - Call `next` conditionally to wrap (around)
|
|
31
|
+
*
|
|
32
|
+
* Multiple advisors chain: each wraps the previous one.
|
|
33
|
+
* If no handler exists yet, `next` is a no-op.
|
|
34
|
+
*/
|
|
35
|
+
advise(name, wrapper) {
|
|
36
|
+
const original = this.handlers.get(name) ?? (() => undefined);
|
|
37
|
+
this.handlers.set(name, (...args) => wrapper(original, ...args));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Call a named handler. Returns undefined if no handler is registered.
|
|
41
|
+
*/
|
|
42
|
+
call(name, ...args) {
|
|
43
|
+
const fn = this.handlers.get(name);
|
|
44
|
+
return fn?.(...args);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Check if a named handler exists.
|
|
48
|
+
*/
|
|
49
|
+
has(name) {
|
|
50
|
+
return this.handlers.has(name);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -166,6 +166,10 @@ export class LineEditor {
|
|
|
166
166
|
"ctrl+u": () => this.deleteRange(0, this.cursor),
|
|
167
167
|
"ctrl+k": () => this.deleteRange(this.cursor, this.buffer.length),
|
|
168
168
|
"ctrl+w": () => this.deleteWordBackward() ? { action: "changed" } : null,
|
|
169
|
+
"alt+f": () => this.wordForward() ? { action: "changed" } : null,
|
|
170
|
+
"alt+b": () => this.wordBackward() ? { action: "changed" } : null,
|
|
171
|
+
"alt+d": () => this.deleteWordForward() ? { action: "changed" } : null,
|
|
172
|
+
"alt+backspace": () => this.deleteWordBackward() ? { action: "changed" } : null,
|
|
169
173
|
"shift+enter": () => this.insertAt("\n"),
|
|
170
174
|
"shift+tab": () => ({ action: "shift+tab" }),
|
|
171
175
|
};
|
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(termW)}${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
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstraction over terminal output.
|
|
3
|
+
*
|
|
4
|
+
* All TUI rendering goes through an OutputWriter instead of calling
|
|
5
|
+
* process.stdout.write directly. This enables testing (BufferWriter),
|
|
6
|
+
* alternative frontends, and a single point of control for output.
|
|
7
|
+
*/
|
|
8
|
+
export interface OutputWriter {
|
|
9
|
+
write(text: string): void;
|
|
10
|
+
get columns(): number;
|
|
11
|
+
}
|
|
12
|
+
/** Default writer that forwards to process.stdout. */
|
|
13
|
+
export declare class StdoutWriter implements OutputWriter {
|
|
14
|
+
write(text: string): void;
|
|
15
|
+
get columns(): number;
|
|
16
|
+
}
|
|
17
|
+
/** Captures all output in memory. Useful for testing. */
|
|
18
|
+
export declare class BufferWriter implements OutputWriter {
|
|
19
|
+
output: string[];
|
|
20
|
+
columns: number;
|
|
21
|
+
write(text: string): void;
|
|
22
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstraction over terminal output.
|
|
3
|
+
*
|
|
4
|
+
* All TUI rendering goes through an OutputWriter instead of calling
|
|
5
|
+
* process.stdout.write directly. This enables testing (BufferWriter),
|
|
6
|
+
* alternative frontends, and a single point of control for output.
|
|
7
|
+
*/
|
|
8
|
+
/** Default writer that forwards to process.stdout. */
|
|
9
|
+
export class StdoutWriter {
|
|
10
|
+
write(text) {
|
|
11
|
+
if (process.stdout.writable) {
|
|
12
|
+
try {
|
|
13
|
+
process.stdout.write(text);
|
|
14
|
+
}
|
|
15
|
+
catch { }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
get columns() {
|
|
19
|
+
return process.stdout.columns || 80;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Captures all output in memory. Useful for testing. */
|
|
23
|
+
export class BufferWriter {
|
|
24
|
+
output = [];
|
|
25
|
+
columns = 80;
|
|
26
|
+
write(text) {
|
|
27
|
+
this.output.push(text);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stream transform helpers for content pipeline extensions.
|
|
3
|
+
*
|
|
4
|
+
* Handles the boilerplate of buffering across chunk boundaries,
|
|
5
|
+
* pattern matching, and flush-on-done coordination.
|
|
6
|
+
*/
|
|
7
|
+
import type { EventBus, ContentBlock } from "../event-bus.js";
|
|
8
|
+
export interface BlockTransformOptions {
|
|
9
|
+
/** Opening delimiter (e.g. "$$") */
|
|
10
|
+
open: string;
|
|
11
|
+
/** Closing delimiter (e.g. "$$") */
|
|
12
|
+
close: string;
|
|
13
|
+
/**
|
|
14
|
+
* Transform the content between delimiters.
|
|
15
|
+
* Return a ContentBlock (text, image, or raw) or null to keep original.
|
|
16
|
+
*/
|
|
17
|
+
transform: (content: string) => ContentBlock | ContentBlock[] | null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Register a delimiter-based block transform on the content pipeline.
|
|
21
|
+
*
|
|
22
|
+
* Automatically handles:
|
|
23
|
+
* - Buffering across chunk boundaries
|
|
24
|
+
* - Safe boundary detection (only emits text outside open delimiters)
|
|
25
|
+
* - Flush on response-done
|
|
26
|
+
*
|
|
27
|
+
* Example:
|
|
28
|
+
* createBlockTransform(bus, {
|
|
29
|
+
* open: "$$",
|
|
30
|
+
* close: "$$",
|
|
31
|
+
* transform(latex) {
|
|
32
|
+
* const png = renderLatex(latex);
|
|
33
|
+
* return png ? { type: "image", data: png } : null;
|
|
34
|
+
* },
|
|
35
|
+
* });
|
|
36
|
+
*/
|
|
37
|
+
export declare function createBlockTransform(bus: EventBus, opts: BlockTransformOptions): void;
|
|
38
|
+
export interface FencedBlockTransformOptions {
|
|
39
|
+
/** Regex matching the opening fence line. Captures are passed to transform. */
|
|
40
|
+
open: RegExp;
|
|
41
|
+
/** Regex matching the closing fence line. */
|
|
42
|
+
close: RegExp;
|
|
43
|
+
/**
|
|
44
|
+
* Transform a complete fenced block.
|
|
45
|
+
* Receives the opening fence match and the content between fences.
|
|
46
|
+
* Return ContentBlock(s), or null to produce a default code-block.
|
|
47
|
+
*/
|
|
48
|
+
transform: (openMatch: RegExpMatchArray, content: string) => ContentBlock | ContentBlock[] | null;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Register a line-delimited fenced block transform on the content pipeline.
|
|
52
|
+
*
|
|
53
|
+
* Detects patterns like ```lang\n...\n``` in the streaming text,
|
|
54
|
+
* buffers the content line-by-line, and produces ContentBlocks when
|
|
55
|
+
* the closing fence arrives.
|
|
56
|
+
*
|
|
57
|
+
* Example:
|
|
58
|
+
* createFencedBlockTransform(bus, {
|
|
59
|
+
* open: /^```(\w*)\s*$/,
|
|
60
|
+
* close: /^```\s*$/,
|
|
61
|
+
* transform(match, content) {
|
|
62
|
+
* return { type: "code-block", language: match[1] || "", code: content };
|
|
63
|
+
* },
|
|
64
|
+
* });
|
|
65
|
+
*/
|
|
66
|
+
export interface FencedBlockTransformHandle {
|
|
67
|
+
/** Flush any buffered text (e.g. before tool calls, to preserve interleaving). */
|
|
68
|
+
flush(): void;
|
|
69
|
+
}
|
|
70
|
+
export declare function createFencedBlockTransform(bus: EventBus, opts: FencedBlockTransformOptions): FencedBlockTransformHandle;
|