agent-sh 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -11
- package/dist/acp-client.d.ts +6 -1
- package/dist/acp-client.js +68 -24
- package/dist/core.js +12 -2
- package/dist/event-bus.d.ts +26 -0
- package/dist/event-bus.js +10 -0
- package/dist/extensions/tui-renderer.d.ts +1 -1
- package/dist/extensions/tui-renderer.js +325 -165
- package/dist/index.js +44 -16
- package/dist/input-handler.d.ts +17 -8
- package/dist/input-handler.js +79 -39
- package/dist/settings.d.ts +11 -0
- package/dist/settings.js +19 -1
- package/dist/shell.js +3 -1
- package/dist/types.d.ts +28 -0
- package/dist/utils/box-frame.js +2 -1
- package/dist/utils/diff-renderer.js +1 -1
- package/dist/utils/frame-renderer.d.ts +26 -0
- package/dist/utils/frame-renderer.js +76 -0
- package/dist/utils/handler-registry.d.ts +41 -0
- package/dist/utils/handler-registry.js +52 -0
- package/dist/utils/line-editor.js +4 -0
- package/dist/utils/markdown.d.ts +15 -6
- package/dist/utils/markdown.js +106 -67
- package/dist/utils/output-writer.d.ts +22 -0
- package/dist/utils/output-writer.js +29 -0
- package/dist/utils/stream-transform.d.ts +70 -0
- package/dist/utils/stream-transform.js +229 -0
- package/dist/utils/tool-display.d.ts +9 -8
- package/dist/utils/tool-display.js +26 -31
- package/examples/extensions/latex-images.ts +142 -0
- package/package.json +10 -2
|
@@ -0,0 +1,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,16 +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;
|
|
41
35
|
hint?: string;
|
|
42
36
|
startTime?: number;
|
|
37
|
+
}
|
|
38
|
+
export declare function createSpinner(opts?: {
|
|
39
|
+
startTime?: number;
|
|
43
40
|
}): SpinnerState;
|
|
44
|
-
|
|
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 ──────────────────────────────────────
|
|
@@ -63,6 +57,7 @@ export function renderToolCall(tool, width) {
|
|
|
63
57
|
const lines = [];
|
|
64
58
|
// Build a compact detail string to append after the title
|
|
65
59
|
let detail = "";
|
|
60
|
+
const cwd = process.cwd();
|
|
66
61
|
if (mode === "full") {
|
|
67
62
|
if (tool.command) {
|
|
68
63
|
detail = `$ ${tool.command}`;
|
|
@@ -70,7 +65,7 @@ export function renderToolCall(tool, width) {
|
|
|
70
65
|
else if (tool.locations && tool.locations.length > 0) {
|
|
71
66
|
const loc = tool.locations[0];
|
|
72
67
|
const lineInfo = loc.line ? `:${loc.line}` : "";
|
|
73
|
-
detail = `${loc.path}${lineInfo}`;
|
|
68
|
+
detail = `${shortenPath(loc.path, cwd)}${lineInfo}`;
|
|
74
69
|
}
|
|
75
70
|
else if (tool.rawInput) {
|
|
76
71
|
const raw = tool.rawInput;
|
|
@@ -107,7 +102,7 @@ export function renderToolCall(tool, width) {
|
|
|
107
102
|
if (mode === "full" && tool.locations && tool.locations.length > 1) {
|
|
108
103
|
for (const loc of tool.locations.slice(1)) {
|
|
109
104
|
const lineInfo = loc.line ? `:${loc.line}` : "";
|
|
110
|
-
lines.push(` ${p.dim}${loc.path}${lineInfo}${p.reset}`);
|
|
105
|
+
lines.push(` ${p.dim}${shortenPath(loc.path, cwd)}${lineInfo}${p.reset}`);
|
|
111
106
|
}
|
|
112
107
|
}
|
|
113
108
|
return lines;
|
|
@@ -184,36 +179,36 @@ export function formatElapsed(ms) {
|
|
|
184
179
|
}
|
|
185
180
|
// ── Spinner with elapsed timer ───────────────────────────────────
|
|
186
181
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
187
|
-
export function createSpinner() {
|
|
188
|
-
return { frame: 0, startTime: Date.now()
|
|
182
|
+
export function createSpinner(opts) {
|
|
183
|
+
return { frame: 0, startTime: opts?.startTime || Date.now() };
|
|
189
184
|
}
|
|
190
185
|
/**
|
|
191
|
-
*
|
|
192
|
-
*
|
|
186
|
+
* Pure function: render the current spinner line and advance the frame.
|
|
187
|
+
* Does not write to stdout — the caller is responsible for output.
|
|
193
188
|
*/
|
|
194
|
-
export function
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
state.startTime = opts.startTime;
|
|
189
|
+
export function renderSpinnerLine(state, label, opts) {
|
|
190
|
+
const frame = SPINNER_FRAMES[state.frame % SPINNER_FRAMES.length];
|
|
191
|
+
state.frame++;
|
|
198
192
|
const color = opts?.color ?? p.accent;
|
|
193
|
+
const elapsed = formatElapsed(Date.now() - state.startTime);
|
|
194
|
+
const timer = elapsed ? ` ${p.dim}${elapsed}${p.reset}` : "";
|
|
199
195
|
const hint = opts?.hint ? ` ${p.dim}${opts.hint}${p.reset}` : "";
|
|
200
|
-
|
|
201
|
-
const frame = SPINNER_FRAMES[state.frame % SPINNER_FRAMES.length];
|
|
202
|
-
const elapsed = formatElapsed(Date.now() - state.startTime);
|
|
203
|
-
const timer = elapsed ? ` ${p.dim}${elapsed}${p.reset}` : "";
|
|
204
|
-
process.stdout.write(`\r ${color}${frame} ${label}...${p.reset}${timer}${hint}\x1b[K`);
|
|
205
|
-
state.frame++;
|
|
206
|
-
}, 80);
|
|
207
|
-
return state;
|
|
208
|
-
}
|
|
209
|
-
export function stopSpinner(state) {
|
|
210
|
-
if (state.interval) {
|
|
211
|
-
clearInterval(state.interval);
|
|
212
|
-
state.interval = null;
|
|
213
|
-
process.stdout.write("\r\x1b[2K");
|
|
214
|
-
}
|
|
196
|
+
return `${color}${frame} ${label}...${p.reset}${timer}${hint}`;
|
|
215
197
|
}
|
|
216
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
|
+
}
|
|
217
212
|
function truncateVisible(text, maxWidth) {
|
|
218
213
|
if (visibleLen(text) <= maxWidth)
|
|
219
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sh",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "A shell-first terminal where any ACP-compatible AI agent is one keystroke away",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/core.js",
|
|
@@ -17,10 +17,18 @@
|
|
|
17
17
|
"types": "./dist/core.d.ts",
|
|
18
18
|
"default": "./dist/core.js"
|
|
19
19
|
},
|
|
20
|
-
"./utils/*": "./dist/utils
|
|
20
|
+
"./utils/*": "./dist/utils/*.js",
|
|
21
21
|
"./types": {
|
|
22
22
|
"types": "./dist/types.d.ts",
|
|
23
23
|
"default": "./dist/types.js"
|
|
24
|
+
},
|
|
25
|
+
"./settings": {
|
|
26
|
+
"types": "./dist/settings.d.ts",
|
|
27
|
+
"default": "./dist/settings.js"
|
|
28
|
+
},
|
|
29
|
+
"./utils/stream-transform": {
|
|
30
|
+
"types": "./dist/utils/stream-transform.d.ts",
|
|
31
|
+
"default": "./dist/utils/stream-transform.js"
|
|
24
32
|
}
|
|
25
33
|
},
|
|
26
34
|
"files": [
|