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.
@@ -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,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 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;
41
35
  hint?: string;
42
36
  startTime?: number;
37
+ }
38
+ export declare function createSpinner(opts?: {
39
+ startTime?: number;
43
40
  }): SpinnerState;
44
- 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 ──────────────────────────────────────
@@ -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(), interval: null };
182
+ export function createSpinner(opts) {
183
+ return { frame: 0, startTime: opts?.startTime || Date.now() };
189
184
  }
190
185
  /**
191
- * Start a spinner that writes to stdout on the current line.
192
- * 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.
193
188
  */
194
- export function startSpinner(label, opts) {
195
- const state = createSpinner();
196
- if (opts?.startTime)
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
- state.interval = setInterval(() => {
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.0",
3
+ "version": "0.3.1",
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": [