agent-sh 0.3.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.
@@ -28,6 +28,17 @@ export interface Settings {
28
28
  declare const DEFAULTS: Required<Settings>;
29
29
  /** Load settings from disk (cached after first call). */
30
30
  export declare function getSettings(): Settings & typeof DEFAULTS;
31
+ /**
32
+ * Get settings for an extension, namespaced under its key in settings.json.
33
+ *
34
+ * Example settings.json:
35
+ * { "latex-images": { "dpi": 600, "fgColor": "ffffff" } }
36
+ *
37
+ * Usage in extension:
38
+ * const config = getExtensionSettings("latex-images", { dpi: 300, fgColor: "d4d4d4" });
39
+ * // config.dpi === 600 (overridden), config.fgColor === "ffffff" (overridden)
40
+ */
41
+ export declare function getExtensionSettings<T extends Record<string, unknown>>(namespace: string, defaults: T): T;
31
42
  /** Reset cached settings (for testing or after external edit). */
32
43
  export declare function reloadSettings(): void;
33
44
  export {};
package/dist/settings.js CHANGED
@@ -18,7 +18,7 @@ const DEFAULTS = {
18
18
  shellHeadLines: 5,
19
19
  shellTailLines: 5,
20
20
  recallExpandMaxLines: 100,
21
- maxCommandOutputLines: 5,
21
+ maxCommandOutputLines: 3,
22
22
  readOutputMaxLines: 0,
23
23
  diffMaxLines: 20,
24
24
  enableMcp: true,
@@ -37,6 +37,24 @@ export function getSettings() {
37
37
  }
38
38
  return { ...DEFAULTS, ...cached };
39
39
  }
40
+ /**
41
+ * Get settings for an extension, namespaced under its key in settings.json.
42
+ *
43
+ * Example settings.json:
44
+ * { "latex-images": { "dpi": 600, "fgColor": "ffffff" } }
45
+ *
46
+ * Usage in extension:
47
+ * const config = getExtensionSettings("latex-images", { dpi: 300, fgColor: "d4d4d4" });
48
+ * // config.dpi === 600 (overridden), config.fgColor === "ffffff" (overridden)
49
+ */
50
+ export function getExtensionSettings(namespace, defaults) {
51
+ const all = getSettings();
52
+ const ext = all[namespace];
53
+ if (ext && typeof ext === "object" && !Array.isArray(ext)) {
54
+ return { ...defaults, ...ext };
55
+ }
56
+ return defaults;
57
+ }
40
58
  /** Reset cached settings (for testing or after external edit). */
41
59
  export function reloadSettings() {
42
60
  cached = null;
package/dist/types.d.ts CHANGED
@@ -2,6 +2,9 @@ import type { EventBus } from "./event-bus.js";
2
2
  import type { ContextManager } from "./context-manager.js";
3
3
  import type { AcpClient } from "./acp-client.js";
4
4
  import type { ColorPalette } from "./utils/palette.js";
5
+ import type { BlockTransformOptions, FencedBlockTransformOptions } from "./utils/stream-transform.js";
6
+ export type { ContentBlock } from "./event-bus.js";
7
+ export type { BlockTransformOptions, FencedBlockTransformOptions } from "./utils/stream-transform.js";
5
8
  export interface AgentShellConfig {
6
9
  agentCommand: string;
7
10
  agentArgs: string[];
@@ -24,6 +27,18 @@ export interface ExtensionContext {
24
27
  quit: () => void;
25
28
  /** Override color palette slots for theming. */
26
29
  setPalette: (overrides: Partial<ColorPalette>) => void;
30
+ /** Register a delimiter-based content transform (e.g. $$...$$ → image). */
31
+ createBlockTransform: (opts: BlockTransformOptions) => void;
32
+ /** Register a fenced block transform (e.g. ```lang...``` → code-block). */
33
+ createFencedBlockTransform: (opts: FencedBlockTransformOptions) => void;
34
+ /** Read extension-namespaced settings from ~/.agent-sh/settings.json. */
35
+ getExtensionSettings: <T extends Record<string, unknown>>(namespace: string, defaults: T) => T;
36
+ /** Register a named handler. */
37
+ define: (name: string, fn: (...args: any[]) => any) => void;
38
+ /** Wrap a named handler. Receives `next` (original) + args. */
39
+ advise: (name: string, wrapper: (next: (...args: any[]) => any, ...args: any[]) => any) => void;
40
+ /** Call a named handler. */
41
+ call: (name: string, ...args: any[]) => any;
27
42
  }
28
43
  export interface TerminalSession {
29
44
  id: string;
@@ -22,7 +22,8 @@ const BORDERS = {
22
22
  * @returns Array of terminal-ready lines with borders
23
23
  */
24
24
  export function renderBoxFrame(content, opts) {
25
- const { width, borderColor = p.dim } = opts;
25
+ const { width: rawWidth, borderColor = p.dim } = opts;
26
+ const width = Math.max(6, rawWidth);
26
27
  const style = opts.style ?? "rounded";
27
28
  const b = BORDERS[style];
28
29
  const bc = borderColor;
@@ -320,7 +320,7 @@ function renderSplit(diff, opts) {
320
320
  const lang = useSyntax ? detectLanguage(opts.filePath) : undefined;
321
321
  const totalWidth = opts.width;
322
322
  // 3 chars for " │ " separator
323
- const colWidth = Math.floor((totalWidth - 3) / 2);
323
+ const colWidth = Math.max(1, Math.floor((totalWidth - 3) / 2));
324
324
  // Compute max line number width
325
325
  let maxNo = 0;
326
326
  for (const hunk of diff.hunks) {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Differential frame renderer.
3
+ *
4
+ * Accepts a frame (string[]) and writes only the lines that changed
5
+ * compared to the previous frame. Designed for scrolling content
6
+ * (not full-screen ownership like pi-tui).
7
+ *
8
+ * Fast paths:
9
+ * 1. First render → write everything
10
+ * 2. Append-only → write only new lines
11
+ * 3. Last line changed → \r overwrite (for spinner / partial streaming)
12
+ * 4. General diff → cursor-up, rewrite changed region, cursor-down
13
+ */
14
+ import type { OutputWriter } from "./output-writer.js";
15
+ export declare class FrameRenderer {
16
+ private writer;
17
+ private prevLines;
18
+ constructor(writer: OutputWriter);
19
+ /**
20
+ * Render a new frame, writing only the diff to the output.
21
+ * Each line in `lines` should NOT include a trailing newline.
22
+ */
23
+ update(lines: string[]): void;
24
+ /** Reset state — next update will be treated as a first render. */
25
+ reset(): void;
26
+ }
@@ -0,0 +1,76 @@
1
+ export class FrameRenderer {
2
+ writer;
3
+ prevLines = [];
4
+ constructor(writer) {
5
+ this.writer = writer;
6
+ }
7
+ /**
8
+ * Render a new frame, writing only the diff to the output.
9
+ * Each line in `lines` should NOT include a trailing newline.
10
+ */
11
+ update(lines) {
12
+ const prev = this.prevLines;
13
+ if (prev.length === 0) {
14
+ // Fast path 1: first render
15
+ for (const line of lines) {
16
+ this.writer.write(line + "\n");
17
+ }
18
+ this.prevLines = lines.slice();
19
+ return;
20
+ }
21
+ // Find first and last changed indices
22
+ const minLen = Math.min(prev.length, lines.length);
23
+ let firstChanged = -1;
24
+ let lastChanged = -1;
25
+ for (let i = 0; i < minLen; i++) {
26
+ if (prev[i] !== lines[i]) {
27
+ if (firstChanged === -1)
28
+ firstChanged = i;
29
+ lastChanged = i;
30
+ }
31
+ }
32
+ // Check for appended or removed lines
33
+ const appended = lines.length > prev.length;
34
+ const truncated = lines.length < prev.length;
35
+ if (firstChanged === -1 && !appended && !truncated) {
36
+ // No changes at all
37
+ this.prevLines = lines.slice();
38
+ return;
39
+ }
40
+ if (firstChanged === -1 && appended) {
41
+ // Fast path 2: only new lines appended, existing unchanged
42
+ for (let i = prev.length; i < lines.length; i++) {
43
+ this.writer.write(lines[i] + "\n");
44
+ }
45
+ this.prevLines = lines.slice();
46
+ return;
47
+ }
48
+ // General diff: move cursor up to first changed line, rewrite
49
+ const linesFromBottom = prev.length - (firstChanged === -1 ? prev.length : firstChanged);
50
+ if (linesFromBottom > 0) {
51
+ this.writer.write(`\x1b[${linesFromBottom}A`); // cursor up
52
+ }
53
+ this.writer.write("\r"); // start of line
54
+ // Rewrite from firstChanged to end of new frame
55
+ const start = firstChanged === -1 ? prev.length : firstChanged;
56
+ for (let i = start; i < lines.length; i++) {
57
+ this.writer.write(`\x1b[2K${lines[i]}\n`); // clear line + write + newline
58
+ }
59
+ // If new frame is shorter, clear remaining old lines
60
+ if (truncated) {
61
+ for (let i = lines.length; i < prev.length; i++) {
62
+ this.writer.write("\x1b[2K\n");
63
+ }
64
+ // Move cursor back up to end of new content
65
+ const extra = prev.length - lines.length;
66
+ if (extra > 0) {
67
+ this.writer.write(`\x1b[${extra}A`);
68
+ }
69
+ }
70
+ this.prevLines = lines.slice();
71
+ }
72
+ /** Reset state — next update will be treated as a first render. */
73
+ reset() {
74
+ this.prevLines = [];
75
+ }
76
+ }
@@ -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
+ }
@@ -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
+ }