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.
Files changed (42) hide show
  1. package/README.md +21 -0
  2. package/dist/acp-client.d.ts +24 -0
  3. package/dist/acp-client.js +155 -33
  4. package/dist/context-manager.d.ts +5 -3
  5. package/dist/context-manager.js +62 -31
  6. package/dist/core.js +10 -0
  7. package/dist/event-bus.d.ts +26 -0
  8. package/dist/event-bus.js +10 -0
  9. package/dist/extension-loader.js +3 -14
  10. package/dist/extensions/shell-exec.js +27 -22
  11. package/dist/extensions/tui-renderer.d.ts +1 -1
  12. package/dist/extensions/tui-renderer.js +369 -126
  13. package/dist/index.js +184 -37
  14. package/dist/input-handler.d.ts +10 -0
  15. package/dist/input-handler.js +169 -10
  16. package/dist/mcp-server.js +37 -8
  17. package/dist/settings.d.ts +44 -0
  18. package/dist/settings.js +61 -0
  19. package/dist/shell.d.ts +1 -0
  20. package/dist/shell.js +44 -4
  21. package/dist/types.d.ts +17 -0
  22. package/dist/utils/ansi.d.ts +4 -1
  23. package/dist/utils/ansi.js +60 -2
  24. package/dist/utils/box-frame.js +2 -1
  25. package/dist/utils/diff-renderer.js +1 -1
  26. package/dist/utils/frame-renderer.d.ts +26 -0
  27. package/dist/utils/frame-renderer.js +76 -0
  28. package/dist/utils/handler-registry.d.ts +41 -0
  29. package/dist/utils/handler-registry.js +52 -0
  30. package/dist/utils/line-editor.d.ts +21 -1
  31. package/dist/utils/line-editor.js +193 -99
  32. package/dist/utils/markdown.d.ts +15 -6
  33. package/dist/utils/markdown.js +106 -67
  34. package/dist/utils/output-writer.d.ts +22 -0
  35. package/dist/utils/output-writer.js +29 -0
  36. package/dist/utils/stream-transform.d.ts +70 -0
  37. package/dist/utils/stream-transform.js +229 -0
  38. package/dist/utils/tool-display.d.ts +11 -8
  39. package/dist/utils/tool-display.js +69 -46
  40. package/examples/extensions/latex-images.ts +142 -0
  41. package/examples/pi-agent-sh.ts +166 -0
  42. 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 declare function createSpinner(): SpinnerState;
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
- export declare function stopSpinner(state: SpinnerState): void;
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
- lines.push(`${p.warning}${p.bold}${icon} ${tool.title}${p.reset}`);
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
- const maxCmdW = Math.max(1, width - 4);
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
- // Show raw input args for non-terminal, non-file tools
82
- if (!tool.command && !tool.locations?.length && tool.rawInput) {
83
- const detail = formatRawInput(tool.rawInput, width - 4);
84
- if (detail)
85
- lines.push(` ${p.dim}${detail}${p.reset}`);
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(), interval: null };
182
+ export function createSpinner(opts) {
183
+ return { frame: 0, startTime: opts?.startTime || Date.now() };
164
184
  }
165
185
  /**
166
- * Start a spinner that writes to stdout on the current line.
167
- * Returns the SpinnerState for later stopping.
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 startSpinner(label, opts) {
170
- const state = createSpinner();
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
- state.interval = setInterval(() => {
173
- const frame = SPINNER_FRAMES[state.frame % SPINNER_FRAMES.length];
174
- const elapsed = formatElapsed(Date.now() - state.startTime);
175
- const timer = elapsed ? ` ${p.dim}${elapsed}${p.reset}` : "";
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
+ }