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.
@@ -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
  };
@@ -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
- constructor(terminalWidth?: number);
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
- * Write a single line with a subtle left indent.
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
  }
@@ -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
- constructor(terminalWidth) {
86
- const termW = terminalWidth ?? (process.stdout.columns || 100);
87
- // 2-char left indent for content
88
- this.contentWidth = Math.min(MAX_CONTENT_WIDTH, termW - 2);
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
- const termW = process.stdout.columns || 80;
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
- const termW = process.stdout.columns || 80;
117
- process.stdout.write(`${p.dim}${p.accent}${"─".repeat(termW)}${p.reset}\n`);
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
- // Code fence detection
128
- const fenceMatch = line.match(/^(\s*)```(\w*)/);
129
- if (fenceMatch) {
130
- if (!this.inCodeBlock) {
131
- this.inCodeBlock = true;
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
- * Write a single line with a subtle left indent.
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
- process.stdout.write(` ${text}\n`);
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;