agent-sh 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/dist/acp-client.d.ts +24 -0
- package/dist/acp-client.js +155 -33
- package/dist/context-manager.d.ts +5 -3
- package/dist/context-manager.js +62 -31
- package/dist/core.js +10 -0
- package/dist/event-bus.d.ts +26 -0
- package/dist/event-bus.js +10 -0
- package/dist/extension-loader.js +3 -14
- package/dist/extensions/shell-exec.js +27 -22
- package/dist/extensions/tui-renderer.d.ts +1 -1
- package/dist/extensions/tui-renderer.js +369 -126
- package/dist/index.js +184 -37
- package/dist/input-handler.d.ts +10 -0
- package/dist/input-handler.js +169 -10
- package/dist/mcp-server.js +37 -8
- package/dist/settings.d.ts +44 -0
- package/dist/settings.js +61 -0
- package/dist/shell.d.ts +1 -0
- package/dist/shell.js +44 -4
- package/dist/types.d.ts +17 -0
- package/dist/utils/ansi.d.ts +4 -1
- package/dist/utils/ansi.js +60 -2
- package/dist/utils/box-frame.js +2 -1
- package/dist/utils/diff-renderer.js +1 -1
- package/dist/utils/frame-renderer.d.ts +26 -0
- package/dist/utils/frame-renderer.js +76 -0
- package/dist/utils/handler-registry.d.ts +41 -0
- package/dist/utils/handler-registry.js +52 -0
- package/dist/utils/line-editor.d.ts +21 -1
- package/dist/utils/line-editor.js +193 -99
- package/dist/utils/markdown.d.ts +15 -6
- package/dist/utils/markdown.js +106 -67
- package/dist/utils/output-writer.d.ts +22 -0
- package/dist/utils/output-writer.js +29 -0
- package/dist/utils/stream-transform.d.ts +70 -0
- package/dist/utils/stream-transform.js +229 -0
- package/dist/utils/tool-display.d.ts +11 -8
- package/dist/utils/tool-display.js +69 -46
- package/examples/extensions/latex-images.ts +142 -0
- package/examples/pi-agent-sh.ts +166 -0
- package/package.json +10 -2
|
@@ -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;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Register a delimiter-based block transform on the content pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Automatically handles:
|
|
5
|
+
* - Buffering across chunk boundaries
|
|
6
|
+
* - Safe boundary detection (only emits text outside open delimiters)
|
|
7
|
+
* - Flush on response-done
|
|
8
|
+
*
|
|
9
|
+
* Example:
|
|
10
|
+
* createBlockTransform(bus, {
|
|
11
|
+
* open: "$$",
|
|
12
|
+
* close: "$$",
|
|
13
|
+
* transform(latex) {
|
|
14
|
+
* const png = renderLatex(latex);
|
|
15
|
+
* return png ? { type: "image", data: png } : null;
|
|
16
|
+
* },
|
|
17
|
+
* });
|
|
18
|
+
*/
|
|
19
|
+
export function createBlockTransform(bus, opts) {
|
|
20
|
+
let buffer = "";
|
|
21
|
+
bus.onPipe("agent:response-chunk", (e) => {
|
|
22
|
+
// Process text from e.text and from text blocks in e.blocks
|
|
23
|
+
const outBlocks = [];
|
|
24
|
+
if (e.blocks) {
|
|
25
|
+
for (const block of e.blocks) {
|
|
26
|
+
if (block.type === "text") {
|
|
27
|
+
// Run delimiter detection on text blocks
|
|
28
|
+
buffer += block.text;
|
|
29
|
+
const { blocks: parsed, pending } = processBuffer(buffer, opts);
|
|
30
|
+
buffer = pending;
|
|
31
|
+
outBlocks.push(...parsed);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
// Pass through non-text blocks unchanged
|
|
35
|
+
outBlocks.push(block);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Also process any raw text not yet in blocks
|
|
40
|
+
if (e.text) {
|
|
41
|
+
buffer += e.text;
|
|
42
|
+
const { blocks: parsed, pending } = processBuffer(buffer, opts);
|
|
43
|
+
buffer = pending;
|
|
44
|
+
outBlocks.push(...parsed);
|
|
45
|
+
}
|
|
46
|
+
return { ...e, text: "", blocks: outBlocks };
|
|
47
|
+
});
|
|
48
|
+
bus.onPipe("agent:response-done", (e) => {
|
|
49
|
+
if (buffer) {
|
|
50
|
+
// Unclosed pattern — flush as text
|
|
51
|
+
bus.emitTransform("agent:response-chunk", {
|
|
52
|
+
text: buffer,
|
|
53
|
+
blocks: [{ type: "text", text: buffer }],
|
|
54
|
+
});
|
|
55
|
+
buffer = "";
|
|
56
|
+
}
|
|
57
|
+
return e;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export function createFencedBlockTransform(bus, opts) {
|
|
61
|
+
let buffer = "";
|
|
62
|
+
let inFence = false;
|
|
63
|
+
let fenceMatch = null;
|
|
64
|
+
let fenceLines = [];
|
|
65
|
+
let flushing = false;
|
|
66
|
+
bus.onPipe("agent:response-chunk", (e) => {
|
|
67
|
+
if (flushing)
|
|
68
|
+
return e; // pass through during flush to avoid re-buffering
|
|
69
|
+
// Collect text from blocks or raw text
|
|
70
|
+
let incoming = "";
|
|
71
|
+
if (e.blocks) {
|
|
72
|
+
// Process text blocks, pass through non-text blocks
|
|
73
|
+
const passthrough = [];
|
|
74
|
+
for (const block of e.blocks) {
|
|
75
|
+
if (block.type === "text") {
|
|
76
|
+
incoming += block.text;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
passthrough.push(block);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const { blocks, pending } = processFencedBuffer(buffer + incoming, opts, inFence, fenceMatch, fenceLines);
|
|
83
|
+
buffer = pending.text;
|
|
84
|
+
inFence = pending.inFence;
|
|
85
|
+
fenceMatch = pending.fenceMatch;
|
|
86
|
+
fenceLines = pending.fenceLines;
|
|
87
|
+
return { ...e, text: "", blocks: [...passthrough, ...blocks] };
|
|
88
|
+
}
|
|
89
|
+
// No blocks yet — work with raw text
|
|
90
|
+
incoming = buffer + e.text;
|
|
91
|
+
const { blocks, pending } = processFencedBuffer(incoming, opts, inFence, fenceMatch, fenceLines);
|
|
92
|
+
buffer = pending.text;
|
|
93
|
+
inFence = pending.inFence;
|
|
94
|
+
fenceMatch = pending.fenceMatch;
|
|
95
|
+
fenceLines = pending.fenceLines;
|
|
96
|
+
const existing = e.blocks ?? [];
|
|
97
|
+
return { ...e, text: "", blocks: [...existing, ...blocks] };
|
|
98
|
+
});
|
|
99
|
+
function flushBuffer() {
|
|
100
|
+
if (!buffer && !inFence)
|
|
101
|
+
return;
|
|
102
|
+
let remaining = buffer;
|
|
103
|
+
if (inFence) {
|
|
104
|
+
remaining = (fenceMatch?.[0] ?? "") + "\n" + fenceLines.join("\n") + (remaining ? "\n" + remaining : "");
|
|
105
|
+
inFence = false;
|
|
106
|
+
fenceMatch = null;
|
|
107
|
+
fenceLines = [];
|
|
108
|
+
}
|
|
109
|
+
buffer = "";
|
|
110
|
+
if (remaining) {
|
|
111
|
+
flushing = true;
|
|
112
|
+
bus.emitTransform("agent:response-chunk", {
|
|
113
|
+
text: "",
|
|
114
|
+
blocks: [{ type: "text", text: remaining }],
|
|
115
|
+
});
|
|
116
|
+
flushing = false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
bus.onPipe("agent:response-done", (e) => {
|
|
120
|
+
flushBuffer();
|
|
121
|
+
return e;
|
|
122
|
+
});
|
|
123
|
+
return { flush: flushBuffer };
|
|
124
|
+
}
|
|
125
|
+
function processFencedBuffer(text, opts, inFence, fenceMatch, fenceLines) {
|
|
126
|
+
const blocks = [];
|
|
127
|
+
const lines = text.split("\n");
|
|
128
|
+
// Last element might be an incomplete line — hold it back
|
|
129
|
+
const incompleteLine = lines.pop();
|
|
130
|
+
let textAccum = ""; // accumulate non-fence text as one block
|
|
131
|
+
for (const line of lines) {
|
|
132
|
+
if (inFence) {
|
|
133
|
+
// Check for closing fence
|
|
134
|
+
if (opts.close.test(line)) {
|
|
135
|
+
const content = fenceLines.join("\n");
|
|
136
|
+
const result = opts.transform(fenceMatch, content);
|
|
137
|
+
if (result === null) {
|
|
138
|
+
const lang = fenceMatch?.[1] ?? "";
|
|
139
|
+
blocks.push({ type: "code-block", language: lang, code: content });
|
|
140
|
+
}
|
|
141
|
+
else if (Array.isArray(result)) {
|
|
142
|
+
blocks.push(...result);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
blocks.push(result);
|
|
146
|
+
}
|
|
147
|
+
inFence = false;
|
|
148
|
+
fenceMatch = null;
|
|
149
|
+
fenceLines = [];
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
fenceLines.push(line);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
// Check for opening fence
|
|
157
|
+
const match = line.match(opts.open);
|
|
158
|
+
if (match) {
|
|
159
|
+
// Flush accumulated text before the fence
|
|
160
|
+
if (textAccum) {
|
|
161
|
+
blocks.push({ type: "text", text: textAccum });
|
|
162
|
+
textAccum = "";
|
|
163
|
+
}
|
|
164
|
+
inFence = true;
|
|
165
|
+
fenceMatch = match;
|
|
166
|
+
fenceLines = [];
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
// Accumulate non-fence text (keep contiguous for downstream transforms)
|
|
170
|
+
textAccum += line + "\n";
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Flush remaining accumulated text
|
|
175
|
+
if (textAccum) {
|
|
176
|
+
blocks.push({ type: "text", text: textAccum });
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
blocks,
|
|
180
|
+
pending: {
|
|
181
|
+
text: incompleteLine,
|
|
182
|
+
inFence,
|
|
183
|
+
fenceMatch,
|
|
184
|
+
fenceLines,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
// ── Inline delimiter block transform ─────────────────────────────
|
|
189
|
+
function processBuffer(text, opts) {
|
|
190
|
+
const blocks = [];
|
|
191
|
+
let i = 0;
|
|
192
|
+
while (i < text.length) {
|
|
193
|
+
const openIdx = text.indexOf(opts.open, i);
|
|
194
|
+
if (openIdx === -1) {
|
|
195
|
+
// No more delimiters — everything is safe text
|
|
196
|
+
const remainder = text.slice(i);
|
|
197
|
+
if (remainder)
|
|
198
|
+
blocks.push({ type: "text", text: remainder });
|
|
199
|
+
return { blocks, pending: "" };
|
|
200
|
+
}
|
|
201
|
+
const searchFrom = openIdx + opts.open.length;
|
|
202
|
+
const closeIdx = text.indexOf(opts.close, searchFrom);
|
|
203
|
+
if (closeIdx === -1) {
|
|
204
|
+
// Unclosed delimiter — emit text before, hold back from delimiter
|
|
205
|
+
const before = text.slice(i, openIdx);
|
|
206
|
+
if (before)
|
|
207
|
+
blocks.push({ type: "text", text: before });
|
|
208
|
+
return { blocks, pending: text.slice(openIdx) };
|
|
209
|
+
}
|
|
210
|
+
// Complete match
|
|
211
|
+
const before = text.slice(i, openIdx);
|
|
212
|
+
if (before)
|
|
213
|
+
blocks.push({ type: "text", text: before });
|
|
214
|
+
const inner = text.slice(searchFrom, closeIdx).trim();
|
|
215
|
+
const result = opts.transform(inner);
|
|
216
|
+
if (result === null) {
|
|
217
|
+
// Transform declined — keep original text with delimiters
|
|
218
|
+
blocks.push({ type: "text", text: opts.open + inner + opts.close });
|
|
219
|
+
}
|
|
220
|
+
else if (Array.isArray(result)) {
|
|
221
|
+
blocks.push(...result);
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
blocks.push(result);
|
|
225
|
+
}
|
|
226
|
+
i = closeIdx + opts.close.length;
|
|
227
|
+
}
|
|
228
|
+
return { blocks, pending: "" };
|
|
229
|
+
}
|
|
@@ -29,14 +29,17 @@ export declare function formatElapsed(ms: number): string;
|
|
|
29
29
|
export interface SpinnerState {
|
|
30
30
|
frame: number;
|
|
31
31
|
startTime: number;
|
|
32
|
-
interval: ReturnType<typeof setInterval> | null;
|
|
33
32
|
}
|
|
34
|
-
export
|
|
35
|
-
/**
|
|
36
|
-
* Start a spinner that writes to stdout on the current line.
|
|
37
|
-
* Returns the SpinnerState for later stopping.
|
|
38
|
-
*/
|
|
39
|
-
export declare function startSpinner(label: string, opts?: {
|
|
33
|
+
export interface SpinnerOpts {
|
|
40
34
|
color?: string;
|
|
35
|
+
hint?: string;
|
|
36
|
+
startTime?: number;
|
|
37
|
+
}
|
|
38
|
+
export declare function createSpinner(opts?: {
|
|
39
|
+
startTime?: number;
|
|
41
40
|
}): SpinnerState;
|
|
42
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Pure function: render the current spinner line and advance the frame.
|
|
43
|
+
* Does not write to stdout — the caller is responsible for output.
|
|
44
|
+
*/
|
|
45
|
+
export declare function renderSpinnerLine(state: SpinnerState, label: string, opts?: SpinnerOpts): string;
|
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tool display renderer with elapsed timer and width-adaptive output.
|
|
3
|
-
*
|
|
4
|
-
* Follows the render(width) -> string[] protocol for completed tools.
|
|
5
|
-
* Also provides a spinner/timer component for in-progress tools.
|
|
6
|
-
*/
|
|
7
1
|
import { visibleLen } from "./ansi.js";
|
|
8
2
|
import { palette as p } from "./palette.js";
|
|
9
3
|
// ── Quiet command detection ──────────────────────────────────────
|
|
@@ -61,28 +55,54 @@ export function renderToolCall(tool, width) {
|
|
|
61
55
|
return [`${p.warning}${text}${p.reset}`];
|
|
62
56
|
}
|
|
63
57
|
const lines = [];
|
|
64
|
-
|
|
58
|
+
// Build a compact detail string to append after the title
|
|
59
|
+
let detail = "";
|
|
60
|
+
const cwd = process.cwd();
|
|
65
61
|
if (mode === "full") {
|
|
66
|
-
// Show file locations if available
|
|
67
|
-
if (tool.locations && tool.locations.length > 0) {
|
|
68
|
-
for (const loc of tool.locations) {
|
|
69
|
-
const lineInfo = loc.line ? `:${loc.line}` : "";
|
|
70
|
-
lines.push(` ${p.dim}${loc.path}${lineInfo}${p.reset}`);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
// Show command string for terminal tools
|
|
74
62
|
if (tool.command) {
|
|
75
|
-
|
|
76
|
-
const cmd = tool.command.length > maxCmdW
|
|
77
|
-
? tool.command.slice(0, maxCmdW - 1) + "…"
|
|
78
|
-
: tool.command;
|
|
79
|
-
lines.push(` ${p.dim}$ ${cmd}${p.reset}`);
|
|
63
|
+
detail = `$ ${tool.command}`;
|
|
80
64
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
65
|
+
else if (tool.locations && tool.locations.length > 0) {
|
|
66
|
+
const loc = tool.locations[0];
|
|
67
|
+
const lineInfo = loc.line ? `:${loc.line}` : "";
|
|
68
|
+
detail = `${shortenPath(loc.path, cwd)}${lineInfo}`;
|
|
69
|
+
}
|
|
70
|
+
else if (tool.rawInput) {
|
|
71
|
+
const raw = tool.rawInput;
|
|
72
|
+
if (raw && typeof raw === "object") {
|
|
73
|
+
if (typeof raw.command === "string") {
|
|
74
|
+
detail = `$ ${raw.command}`;
|
|
75
|
+
}
|
|
76
|
+
else if (typeof raw.operation === "string") {
|
|
77
|
+
detail = raw.operation;
|
|
78
|
+
if (raw.ids && Array.isArray(raw.ids)) {
|
|
79
|
+
detail += ` #${raw.ids.join(",")}`;
|
|
80
|
+
}
|
|
81
|
+
if (typeof raw.query === "string") {
|
|
82
|
+
detail += ` "${raw.query}"`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
detail = formatRawInput(tool.rawInput, width - tool.title.length - 6);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Render as single line: ► title: detail
|
|
92
|
+
const maxDetailW = Math.max(1, width - tool.title.length - 6);
|
|
93
|
+
if (detail) {
|
|
94
|
+
if (detail.length > maxDetailW)
|
|
95
|
+
detail = detail.slice(0, maxDetailW - 1) + "…";
|
|
96
|
+
lines.push(`${p.warning}${p.bold}${icon} ${tool.title}${p.reset}${p.dim}: ${detail}${p.reset}`);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
lines.push(`${p.warning}${p.bold}${icon} ${tool.title}${p.reset}`);
|
|
100
|
+
}
|
|
101
|
+
// Show additional file locations on separate lines (if more than one)
|
|
102
|
+
if (mode === "full" && tool.locations && tool.locations.length > 1) {
|
|
103
|
+
for (const loc of tool.locations.slice(1)) {
|
|
104
|
+
const lineInfo = loc.line ? `:${loc.line}` : "";
|
|
105
|
+
lines.push(` ${p.dim}${shortenPath(loc.path, cwd)}${lineInfo}${p.reset}`);
|
|
86
106
|
}
|
|
87
107
|
}
|
|
88
108
|
return lines;
|
|
@@ -159,33 +179,36 @@ export function formatElapsed(ms) {
|
|
|
159
179
|
}
|
|
160
180
|
// ── Spinner with elapsed timer ───────────────────────────────────
|
|
161
181
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
162
|
-
export function createSpinner() {
|
|
163
|
-
return { frame: 0, startTime: Date.now()
|
|
182
|
+
export function createSpinner(opts) {
|
|
183
|
+
return { frame: 0, startTime: opts?.startTime || Date.now() };
|
|
164
184
|
}
|
|
165
185
|
/**
|
|
166
|
-
*
|
|
167
|
-
*
|
|
186
|
+
* Pure function: render the current spinner line and advance the frame.
|
|
187
|
+
* Does not write to stdout — the caller is responsible for output.
|
|
168
188
|
*/
|
|
169
|
-
export function
|
|
170
|
-
const
|
|
189
|
+
export function renderSpinnerLine(state, label, opts) {
|
|
190
|
+
const frame = SPINNER_FRAMES[state.frame % SPINNER_FRAMES.length];
|
|
191
|
+
state.frame++;
|
|
171
192
|
const color = opts?.color ?? p.accent;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
process.stdout.write(`\r ${color}${frame} ${label}...${p.reset}${timer}\x1b[K`);
|
|
177
|
-
state.frame++;
|
|
178
|
-
}, 80);
|
|
179
|
-
return state;
|
|
180
|
-
}
|
|
181
|
-
export function stopSpinner(state) {
|
|
182
|
-
if (state.interval) {
|
|
183
|
-
clearInterval(state.interval);
|
|
184
|
-
state.interval = null;
|
|
185
|
-
process.stdout.write("\r\x1b[2K");
|
|
186
|
-
}
|
|
193
|
+
const elapsed = formatElapsed(Date.now() - state.startTime);
|
|
194
|
+
const timer = elapsed ? ` ${p.dim}${elapsed}${p.reset}` : "";
|
|
195
|
+
const hint = opts?.hint ? ` ${p.dim}${opts.hint}${p.reset}` : "";
|
|
196
|
+
return `${color}${frame} ${label}...${p.reset}${timer}${hint}`;
|
|
187
197
|
}
|
|
188
198
|
// ── Helpers ──────────────────────────────────────────────────────
|
|
199
|
+
/**
|
|
200
|
+
* Shorten an absolute path to a relative or tilde-prefixed form.
|
|
201
|
+
*/
|
|
202
|
+
function shortenPath(p, cwd) {
|
|
203
|
+
if (p.startsWith(cwd + "/"))
|
|
204
|
+
return p.slice(cwd.length + 1);
|
|
205
|
+
if (p.startsWith(cwd))
|
|
206
|
+
return p.slice(cwd.length) || ".";
|
|
207
|
+
const home = process.env.HOME;
|
|
208
|
+
if (home && p.startsWith(home + "/"))
|
|
209
|
+
return "~/" + p.slice(home.length + 1);
|
|
210
|
+
return p;
|
|
211
|
+
}
|
|
189
212
|
function truncateVisible(text, maxWidth) {
|
|
190
213
|
if (visibleLen(text) <= maxWidth)
|
|
191
214
|
return text;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LaTeX image overlay extension.
|
|
3
|
+
*
|
|
4
|
+
* Renders $$...$$ equations as inline terminal images using the same
|
|
5
|
+
* pipeline as Emacs org-mode: latex → dvipng.
|
|
6
|
+
*
|
|
7
|
+
* Uses the content transform pipeline (createBlockTransform + ContentBlock)
|
|
8
|
+
* so the extension just defines delimiters and a transform function —
|
|
9
|
+
* no manual buffering, no process.stdout hacks.
|
|
10
|
+
*
|
|
11
|
+
* Requirements:
|
|
12
|
+
* - latex and dvipng (typically from TeX Live: `brew install --cask mactex`)
|
|
13
|
+
* - iTerm2, WezTerm, Kitty, or Ghostty terminal
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* agent-sh -e ./examples/extensions/latex-images.ts
|
|
17
|
+
*/
|
|
18
|
+
import { execSync } from "node:child_process";
|
|
19
|
+
import * as fs from "node:fs";
|
|
20
|
+
import * as os from "node:os";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
import type { ExtensionContext } from "agent-sh/types";
|
|
23
|
+
|
|
24
|
+
// Settings loaded in activate() via ctx.getExtensionSettings
|
|
25
|
+
let config = { dpi: 300, fgColor: "d4d4d4" };
|
|
26
|
+
|
|
27
|
+
/** Encode PNG as iTerm2 or Kitty inline image escape sequence. */
|
|
28
|
+
function encodeImage(data: Buffer): string {
|
|
29
|
+
const b64 = data.toString("base64");
|
|
30
|
+
if (process.env.TERM_PROGRAM === "iTerm.app" || process.env.TERM_PROGRAM === "WezTerm") {
|
|
31
|
+
return `\x1b]1337;File=inline=1;size=${data.length};preserveAspectRatio=1:${b64}\x07`;
|
|
32
|
+
}
|
|
33
|
+
if (process.env.KITTY_WINDOW_ID || process.env.TERM_PROGRAM === "ghostty") {
|
|
34
|
+
const chunks: string[] = [];
|
|
35
|
+
for (let i = 0; i < b64.length; i += 4096) {
|
|
36
|
+
const chunk = b64.slice(i, i + 4096);
|
|
37
|
+
const isLast = i + 4096 >= b64.length;
|
|
38
|
+
chunks.push(i === 0
|
|
39
|
+
? `\x1b_Gf=100,t=d,a=T,m=${isLast ? 0 : 1};${chunk}\x1b\\`
|
|
40
|
+
: `\x1b_Gm=${isLast ? 0 : 1};${chunk}\x1b\\`);
|
|
41
|
+
}
|
|
42
|
+
return chunks.join("");
|
|
43
|
+
}
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── LaTeX rendering via latex + dvipng ───────────────────────────
|
|
48
|
+
|
|
49
|
+
const LATEX_TEMPLATE = (equation: string, fg: string) => `
|
|
50
|
+
\\documentclass[border=1pt]{standalone}
|
|
51
|
+
\\usepackage{amsmath,amssymb,amsfonts}
|
|
52
|
+
\\usepackage{xcolor}
|
|
53
|
+
\\begin{document}
|
|
54
|
+
\\color[HTML]{${fg}}
|
|
55
|
+
$\\displaystyle ${equation}$
|
|
56
|
+
\\end{document}
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
let tmpDir: string | null = null;
|
|
60
|
+
let renderCounter = 0;
|
|
61
|
+
|
|
62
|
+
function ensureTmpDir(): string {
|
|
63
|
+
if (!tmpDir) {
|
|
64
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "latex-img-"));
|
|
65
|
+
}
|
|
66
|
+
return tmpDir;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function renderEquation(equation: string): Buffer | null {
|
|
70
|
+
const dir = ensureTmpDir();
|
|
71
|
+
const idx = renderCounter++;
|
|
72
|
+
const texPath = path.join(dir, `eq${idx}.tex`);
|
|
73
|
+
const dviPath = path.join(dir, `eq${idx}.dvi`);
|
|
74
|
+
const pngPath = path.join(dir, `eq${idx}.png`);
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
fs.writeFileSync(texPath, LATEX_TEMPLATE(equation, config.fgColor));
|
|
78
|
+
|
|
79
|
+
execSync(
|
|
80
|
+
`latex -interaction=nonstopmode -output-directory="${dir}" "${texPath}"`,
|
|
81
|
+
{ timeout: 10000, stdio: "pipe", cwd: dir },
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
execSync(
|
|
85
|
+
`dvipng -D ${config.dpi} -T tight -bg Transparent --truecolor -o "${pngPath}" "${dviPath}"`,
|
|
86
|
+
{ timeout: 10000, stdio: "pipe" },
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return fs.readFileSync(pngPath);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (process.env.DEBUG) {
|
|
92
|
+
const msg = err instanceof Error ? (err as any).stderr?.toString() || err.message : String(err);
|
|
93
|
+
process.stderr.write(`[latex-images] render failed: ${msg}\n`);
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Extension entry point ────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
export default function activate(ctx: ExtensionContext) {
|
|
102
|
+
const { bus } = ctx;
|
|
103
|
+
|
|
104
|
+
// Load settings: ~/.agent-sh/settings.json → "latex-images": { dpi, fgColor }
|
|
105
|
+
config = ctx.getExtensionSettings("latex-images", config);
|
|
106
|
+
|
|
107
|
+
// Check for latex + dvipng
|
|
108
|
+
try {
|
|
109
|
+
execSync("latex --version", { stdio: "ignore", timeout: 3000 });
|
|
110
|
+
execSync("dvipng --version", { stdio: "ignore", timeout: 3000 });
|
|
111
|
+
} catch {
|
|
112
|
+
bus.emit("ui:error", {
|
|
113
|
+
message: "latex-images: latex and dvipng required (brew install --cask mactex)",
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Handle inline $$...$$ display math
|
|
119
|
+
ctx.createBlockTransform({
|
|
120
|
+
open: "$$",
|
|
121
|
+
close: "$$",
|
|
122
|
+
transform(latex) {
|
|
123
|
+
const png = renderEquation(latex);
|
|
124
|
+
if (!png) return null;
|
|
125
|
+
return { type: "image", data: png };
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Advise the code block renderer — wrap the default syntax highlighter
|
|
130
|
+
ctx.advise("render:code-block", (next, language: string, code: string, width: number) => {
|
|
131
|
+
if (language !== "latex" && language !== "tex") return next(language, code, width);
|
|
132
|
+
const png = renderEquation(code);
|
|
133
|
+
if (!png) return next(language, code, width); // render failed — fall through
|
|
134
|
+
ctx.call("render:image", png);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
process.on("exit", () => {
|
|
138
|
+
if (tmpDir) {
|
|
139
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|