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.
- package/dist/acp-client.js +38 -22
- package/dist/core.js +10 -0
- package/dist/event-bus.d.ts +22 -0
- package/dist/event-bus.js +10 -0
- package/dist/extensions/tui-renderer.d.ts +1 -1
- package/dist/extensions/tui-renderer.js +304 -160
- package/dist/settings.d.ts +11 -0
- package/dist/settings.js +19 -1
- package/dist/types.d.ts +15 -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/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
package/dist/settings.d.ts
CHANGED
|
@@ -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:
|
|
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;
|
package/dist/utils/box-frame.js
CHANGED
|
@@ -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
|
+
}
|
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
|
+
}
|